swarm-controller: own the swarm-wide forge objects; hive-c0re stops creating them

The orgs agent-configs/internal/agents (plus mirror owners), the
operators team in agents and agent-configs, the pull-mirrors,
internal/docs, internal/knowledge (public, README-seeded) and the
agent-configs org avatar are one set per forge. hive-c0re ensured them in
its boot sweep, as the core admin, and only on the hive co-located with
the forge container.

swarm-controller now reconciles them at start and every 5 minutes
(forge/objects.rs: observe -> pure plan -> apply). A failed object logs
a warn line plus a pass summary and is retried next tick. create_repo
ensures the agent-configs org and its operators team first, so a config
repo's merge gate never depends on the periodic pass having run.

hive-c0re drops ensure_org, SEEDED_ORGS, ensure_mirrors/ensure_mirror_repo,
ensure_operators_team, ensure_shared_docs_repo, ensure_knowledge_repo/
set_repo_public, seed_readme, ensure_config_org_avatar and the one-shot
knowledge::remove_webhook cleanup, with their now-unused helpers.

nix: the mirror list moves from the hive-c0re unit
(HYPERHIVE_FORGE_MIRRORS) to the swarm-controller unit
(SWARM_CONTROLLER_FORGE_MIRRORS), with an eval warning when mirrors are
declared on a host that runs no controller. c0re.orgAvatarPng is renamed
to deploy.swarm-controller.configOrgAvatarPng.

Refs #3782
This commit is contained in:
atlas 2026-09-24 15:00:10 +02:00
commit 20419ccd41
18 changed files with 1456 additions and 822 deletions

View file

@ -30,11 +30,11 @@ the local clone updates automatically (see
Canonical forge location: `internal/knowledge` (org `internal`, Canonical forge location: `internal/knowledge` (org `internal`,
repo `knowledge`). The repo is public, so every agent's forge account repo `knowledge`). The repo is public, so every agent's forge account
has read access without an explicit per-agent collaborator grant; has read access without an explicit per-agent collaborator grant;
only the `core` account has push access, for autoseeding. only the `core` account has push access.
hive-c0re autocreates the repo at startup if it doesn't exist, The swarm controller creates the repo if it doesn't exist, at startup
seeding it with a `README.md` containing a contribution guide and a and every few minutes after, and seeds an empty one with a `README.md`
blank table of contents. Add an entry to that ToC each time you containing a contribution guide and a blank table of contents. Add an entry to that ToC each time you
create a new document. create a new document.
## Sync mechanism ## Sync mechanism
@ -56,9 +56,10 @@ pull`, so agents see the new content on their next turn.
**don't add a per-hive hook.** A webhook has exactly one target **don't add a per-hive hook.** A webhook has exactly one target
URL, so a second registration against the same repo doesn't add a URL, so a second registration against the same repo doesn't add a
recipient — it takes delivery away from whoever registered first. recipient — it takes delivery away from whoever registered first.
Earlier versions had each hive register its own; hive-c0re now Earlier versions had each hive register its own, and later ones
removes its own leftover at startup, so migrating needs no operator removed that leftover at startup. A hive upgraded straight past those
step. versions still holds its old hook: delete any hook on the repo that
points at a hive's own `/webhook/knowledge`.
2. **Periodic pull** — a background task in `hive-c0re::main` 2. **Periodic pull** — a background task in `hive-c0re::main`
pulls on a fixed cadence as a fallback (webhook missed, c0re pulls on a fixed cadence as a fallback (webhook missed, c0re

View file

@ -156,7 +156,7 @@ Gated on `HYPERHIVE_FORGE_CI_ENABLED` (the nix module sets it on `hive-c0re.serv
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
When `deploy.forgejo.ci.enable` is set, hive-c0re autoseeds an When `deploy.forgejo.ci.enable` is set, the swarm controller autoseeds an
`actions/checkout` pull-mirror on the local forge and sets Forgejo's `actions/checkout` pull-mirror on the local forge and sets Forgejo's
`DEFAULT_ACTIONS_URL` to point at the local instance. This means CI `DEFAULT_ACTIONS_URL` to point at the local instance. This means CI
`uses: actions/checkout@vN` steps resolve entirely on loopback — no `uses: actions/checkout@vN` steps resolve entirely on loopback — no
@ -164,13 +164,13 @@ external DNS on the CI critical path.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
**hive-c0re** itself seeds the mirror during its forge The **swarm controller** seeds the mirror in its swarm-wide forge-objects
provisioning sweep (`forge/repos.rs::ensure_mirrors`). The nix module pass (`swarm-controller/src/forge/objects.rs`), beside the orgs it ensures.
forwards the effective mirror list as `HYPERHIVE_FORGE_MIRRORS` in the The nix module forwards the effective mirror list as
`hive-c0re` service environment (JSON-encoded `[{upstream, dest}]` `SWARM_CONTROLLER_FORGE_MIRRORS` in the `swarm-controller` service
list). hive-c0re already holds the admin token for the rest of the environment (JSON-encoded `[{upstream, dest}]` list). It reads the list of
forge provisioning sweep (orgs, agent accounts, etc.), so mirror the host the controller runs on: mirrors declared on any other host aren't
seeding lives in the same place rather than a separate host-side unit. seeded, and evaluation warns about them.
**General-purpose mirrors**: you can pre-seed any external repo as a **General-purpose mirrors**: you can pre-seed any external repo as a
pull-mirror via `services.hyperhive.deploy.forgejo.mirrors`: pull-mirror via `services.hyperhive.deploy.forgejo.mirrors`:
@ -182,10 +182,10 @@ services.hyperhive.deploy.forgejo.mirrors = [
]; ];
``` ```
hive-c0re creates each entry as a real Forgejo pull-mirror — not a one-off The controller creates each entry as a real Forgejo pull-mirror — not a one-off
clone. Forgejo re-syncs the mirror on every pull (`git-upload-pack` clone. Forgejo re-syncs the mirror on every pull (`git-upload-pack`
request), so a DNS blip during that sync will propagate back to the request), so a DNS blip during that sync will propagate back to the
runner as a hard `git clone` failure. hive-c0re runner as a hard `git clone` failure. The controller
autocreates the `<owner>` org in `dest`. Keep mirror dests out of the autocreates the `<owner>` org in `dest`. Keep mirror dests out of the
hive-c0re-managed namespaces (`config/`, `shared/`, `agents/`, `core/`) hive-c0re-managed namespaces (`config/`, `shared/`, `agents/`, `core/`)
to avoid provisioning collisions. to avoid provisioning collisions.

View file

@ -453,6 +453,27 @@ Swarm-side, `GET /api/agents/<name>/state/stream` relays that subject as SSE,
resolving the agent's hive at request time exactly as the terminal stream does. resolving the agent's hive at request time exactly as the terminal stream does.
The payload passes through opaquely — the controller never parses a header. The payload passes through opaquely — the controller never parses a header.
### Swarm-wide forge objects
The controller also keeps the forge objects that are one per swarm, not
one per hive. It ensures them at start and every five minutes after
(`swarm-controller/src/forge/objects.rs`):
- the orgs `agent-configs`, `internal` and `agents`, plus each mirror's
owner org;
- the empty `operators` merge-gate team in `agents` and `agent-configs`;
- the pull-mirrors from `deploy.forgejo.mirrors` on the controller's
host (with the `actions/checkout` one `deploy.forgejo.ci.enable` adds);
- `internal/docs` (private) and `internal/knowledge` (public, with a
seed `README.md` while empty);
- the `agent-configs` org avatar
(`deploy.swarm-controller.configOrgAvatarPng`).
hive-c0re no longer creates any of them. A pass that can't finish logs a
`warn` line per object plus `swarm forge objects: pass incomplete` in
`journalctl -u swarm-controller`, and retries on the next tick. While the
controller is down the objects stay as they are.
### Swarm-wide forge webhooks ### Swarm-wide forge webhooks
At startup the controller registers two Forgejo hooks pointing at At startup the controller registers two Forgejo hooks pointing at
@ -467,8 +488,8 @@ re-derive. Approval happens once, at the swarm level: a hive receives a
decision, not an event to adjudicate. decision, not an event to adjudicate.
**`internal/knowledge` is on that path.** The controller's is the only **`internal/knowledge` is on that path.** The controller's is the only
hook on it: hives no longer register their own, and each removes its hook on it: hives no longer register their own (see
leftover at startup. A webhook has exactly one target URL, so per-hive `docs/integrations/knowledge.md` for clearing a leftover). A webhook has exactly one target URL, so per-hive
registration never added a recipient — it took delivery away from registration never added a recipient — it took delivery away from
whichever hive registered before it. whichever hive registered before it.

View file

@ -1,7 +1,7 @@
//! Optional Forgejo wiring — per-agent account alignment, //! Optional Forgejo wiring — per-agent account alignment,
//! config-repo mirroring, meta read-access grants. Also seeds //! config-repo mirroring, meta read-access grants, and each agent's
//! `internal/docs` — a private repo every agent gets read-only //! read-only grant on `internal/docs` (the swarm-controller creates that
//! collaborator access to for operator-curated shared content. //! repo, with the other swarm-wide orgs, teams and repos).
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`. //! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
mod ci_runner; mod ci_runner;
@ -17,9 +17,9 @@ pub use pr_merge::{
}; };
pub use reconcile::{reconcile_config_apply, reconcile_config_status}; pub use reconcile::{reconcile_config_apply, reconcile_config_status};
pub use repos::{ pub use repos::{
clone_config_into_proposed, create_agent_repo, ensure_config_repo, ensure_knowledge_repo, clone_config_into_proposed, create_agent_repo, ensure_config_repo, ensure_meta_remote,
ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main, ensure_repo, fast_forward_applied_main, fetch_config_main_into_applied, meta_read_access,
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access, push_config, push_meta, shared_docs_access,
}; };
pub use users::core_token; pub use users::core_token;
@ -30,10 +30,9 @@ use anyhow::{Context, Result};
use forgejo_api::{Auth, Forgejo}; use forgejo_api::{Auth, Forgejo};
use url::Url; use url::Url;
use repos::{ensure_mirrors, ensure_operators_team, ensure_org};
use users::{ use users::{
ensure_config_org_avatar, ensure_core_avatar, ensure_core_user_and_token, ensure_core_avatar, ensure_core_user_and_token, ensure_repo_creation_disabled,
ensure_repo_creation_disabled, ensure_user_email, ensure_user_email,
}; };
const FORGE_CONTAINER: &str = "hive-forge"; const FORGE_CONTAINER: &str = "hive-forge";
@ -107,36 +106,30 @@ pub(crate) const CONFIG_ORG: &str = "agent-configs";
/// that every agent gets read-only access to. Agents use it as a /// that every agent gets read-only access to. Agents use it as a
/// common reference without the operator having to bake content into /// common reference without the operator having to bake content into
/// the system prompt or rely on `/shared`. Only the manager + operator /// the system prompt or rely on `/shared`. Only the manager + operator
/// (i.e. `core` user) can push. /// (i.e. `core` user) can push. The swarm-controller creates the org
/// and the repo; this hive only grants its agents read access.
const SHARED_ORG: &str = "internal"; const SHARED_ORG: &str = "internal";
/// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent /// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent
/// at `{forge_http_base()}/internal/docs.git`. /// at `{forge_http_base()}/internal/docs.git`.
const SHARED_DOCS_REPO: &str = "docs"; const SHARED_DOCS_REPO: &str = "docs";
/// The hive-wide knowledge repo inside `SHARED_ORG`. Public — agents
/// can fork it and open PRs without explicit collaborator grants.
/// Bind-mounted read-only into every container at `/knowledge`.
/// See `hive-c0re/src/workers/knowledge.rs`.
const KNOWLEDGE_REPO: &str = crate::knowledge::REPO;
/// Forgejo org that owns agent-created repos. Agents can't create /// Forgejo org that owns agent-created repos. Agents can't create
/// repos with their own token (`max_repo_creation = 0`); instead hive-c0re /// repos with their own token (`max_repo_creation = 0`); instead hive-c0re
/// creates them here and adds the requesting agent as a **write** member /// creates them here and adds the requesting agent as a **write** member
/// (not owner/admin). Because the org — not the agent — owns the repo, /// (not owner/admin). Because the org — not the agent — owns the repo,
/// perms stay c0re-managed and branch protection (referencing /// perms stay c0re-managed and branch protection (referencing
/// [`OPERATORS_TEAM`]) can block the author from merging their own PR. This /// [`OPERATORS_TEAM`]) can block the author from merging their own PR. This
/// is the "agents namespace" repos land in by default. /// is the "agents namespace" repos land in by default. The swarm-controller
/// ensures the org itself.
const AGENTS_ORG: &str = "agents"; const AGENTS_ORG: &str = "agents";
/// Operator merge-gate team inside [`AGENTS_ORG`]. Provisioned **empty** by /// Operator merge-gate team inside [`AGENTS_ORG`]. Provisioned **empty** by
/// hive-c0re (so perms can be set before anyone joins); the operator adds /// the swarm-controller (so perms can be set before anyone joins); the
/// herself via the forge UI / hivectl. Branch protection on agents-org repos /// operator adds herself via the forge UI. Branch protection on agents-org
/// references this team by name for the merge/approval whitelist, so the /// repos references this team by name for the merge/approval whitelist, so
/// rule never hardcodes a specific reviewer agent (which may not exist). /// the rule never hardcodes a specific reviewer agent (which may not exist).
const OPERATORS_TEAM: &str = "operators"; const OPERATORS_TEAM: &str = "operators";
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
/// `core/meta` (the `core` user's own namespace — no org needed).
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG, AGENTS_ORG];
/// Leak `s` to get a `&'static str` warning `kind` for the small, bounded /// Leak `s` to get a `&'static str` warning `kind` for the small, bounded
/// set of per-org boot warnings in [`ensure_all`] (one per seeded org, at /// set of boot warnings in [`ensure_all`] keyed by a runtime name (at
/// most a handful per process). [`crate::warnings::set_boot_warning`] /// most a handful per process). [`crate::warnings::set_boot_warning`]
/// requires a `'static` kind so distinct orgs/repos don't clobber each /// requires a `'static` kind so distinct orgs/repos don't clobber each
/// other's banner entry; leaking a few short strings once per boot is /// other's banner entry; leaking a few short strings once per boot is
@ -277,44 +270,15 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
ok ok
} }
/// The `core_token.is_some()` half of [`ensure_all`]: orgs, teams, the meta /// The `core_token.is_some()` half of [`ensure_all`]: the meta repo, the
/// repo, shared/knowledge repos, avatars, and CI runner registration — every /// local knowledge clone, the core avatar, and CI runner registration —
/// step that needs an authenticated forge client. Split out purely to keep /// every step that needs an authenticated forge client. The swarm-wide
/// objects (orgs, the `operators` team, mirrors, `internal/docs`,
/// `internal/knowledge`, the `agent-configs` avatar) are the
/// swarm-controller's, not this hive's. Split out purely to keep
/// `ensure_all` under clippy's function-length limit; not meant to be called /// `ensure_all` under clippy's function-length limit; not meant to be called
/// from anywhere else. /// from anywhere else.
async fn ensure_all_orgs_and_repos(token: &str) { async fn ensure_all_orgs_and_repos(token: &str) {
for org in SEEDED_ORGS {
if let Err(e) = ensure_org(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_org failed");
crate::warnings::set_boot_warning(
static_kind(format!("forge_ensure_org_{org}")),
"crit",
format!("forge: org {org} provisioning failed: {e}"),
);
}
}
// Seed the operator-declared pull-mirrors (nix `forge.mirrors` +
// the CI-auto `actions/checkout`, forwarded via the
// `HYPERHIVE_FORGE_MIRRORS` env). Each ensures its own dest org, so
// this is independent of the SEEDED_ORGS loop above.
ensure_mirrors(token).await;
// Provision the operator merge-gate team (empty) inside BOTH the
// agents org and the agent-configs org so branch protection in each
// can reference it before anyone joins. Gitea teams are org-scoped —
// missing the agent-configs copy 422'd every config-repo protection
// apply, leaving those repos unprotected and letting operator-merged
// config PRs bypass the deploy pipeline. The operator adds herself as
// a member out-of-band.
for org in [AGENTS_ORG, CONFIG_ORG] {
if let Err(e) = ensure_operators_team(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_operators_team failed");
crate::warnings::set_boot_warning(
static_kind(format!("forge_ensure_operators_team_{org}")),
"crit",
format!("forge: operators team in {org} provisioning failed: {e}"),
);
}
}
// Meta repo lives at core/meta — pushed from git_commit in // Meta repo lives at core/meta — pushed from git_commit in
// meta.rs on every deploy/lock-update. Make sure it exists // meta.rs on every deploy/lock-update. Make sure it exists
// before the first push hits a 404. // before the first push hits a 404.
@ -326,26 +290,8 @@ async fn ensure_all_orgs_and_repos(token: &str) {
format!("forge: core/meta repo provisioning failed: {e}"), format!("forge: core/meta repo provisioning failed: {e}"),
); );
} }
// Seed the shared docs repo. internal is already in
// SEEDED_ORGS above so the org exists; ensure the repo itself.
if let Err(e) = ensure_shared_docs_repo(token).await {
tracing::warn!(error = ?e, "forge: ensure_shared_docs_repo failed");
crate::warnings::set_boot_warning(
"forge_ensure_shared_docs_repo",
"warn",
format!("forge: shared docs repo provisioning failed: {e}"),
);
}
// Seed the hive-wide knowledge repo.
if let Err(e) = ensure_knowledge_repo(token).await {
tracing::warn!(error = ?e, "forge: ensure_knowledge_repo failed");
crate::warnings::set_boot_warning(
"forge_ensure_knowledge_repo",
"crit",
format!("forge: knowledge repo provisioning failed: {e}"),
);
}
// Clone knowledge repo locally so it can be bind-mounted into agents. // Clone knowledge repo locally so it can be bind-mounted into agents.
// The swarm-controller creates and seeds the repo itself.
if let Err(e) = crate::knowledge::ensure_local_clone(token).await { if let Err(e) = crate::knowledge::ensure_local_clone(token).await {
tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed"); tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed");
crate::warnings::set_boot_warning( crate::warnings::set_boot_warning(
@ -362,14 +308,6 @@ async fn ensure_all_orgs_and_repos(token: &str) {
format!("forge: core avatar upload failed: {e}"), format!("forge: core avatar upload failed: {e}"),
); );
} }
if let Err(e) = ensure_config_org_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed");
crate::warnings::set_boot_warning(
"forge_ensure_config_org_avatar",
"warn",
format!("forge: agent-configs org avatar upload failed: {e}"),
);
}
// Register the hive-ci Actions runner (off the container's boot path; // Register the hive-ci Actions runner (off the container's boot path;
// no-op when CI is disabled or the runner already holds valid creds). // no-op when CI is disabled or the runner already holds valid creds).
ci_runner::ensure_ci_runner_registered(token).await; ci_runner::ensure_ci_runner_registered(token).await;
@ -419,7 +357,8 @@ async fn wait_until_ready() -> bool {
/// each has a forgejo user + token, plus an `agent-configs/<name>` /// each has a forgejo user + token, plus an `agent-configs/<name>`
/// repo mirroring its applied config. Also seeds the `core` admin /// repo mirroring its applied config. Also seeds the `core` admin
/// user (hive-c0re's own identity for pushing the meta repo + driving /// user (hive-c0re's own identity for pushing the meta repo + driving
/// the API), the `agent-configs` org, and the `core/meta` repo. /// the API) and the `core/meta` repo. The `agent-configs` org itself is
/// the swarm-controller's to ensure.
/// Called once at hive-c0re startup. Per-step failures are logged /// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep. /// but don't abort the sweep.
pub async fn ensure_all() { pub async fn ensure_all() {
@ -495,9 +434,9 @@ pub async fn ensure_all() {
/// An org-level hook covers every repo in `agent-configs` automatically, /// An org-level hook covers every repo in `agent-configs` automatically,
/// so no per-repo setup is needed as new agents are provisioned. /// so no per-repo setup is needed as new agents are provisioned.
/// ///
/// Called at startup beside `knowledge::remove_webhook`, its opposite: that /// Called at startup. The knowledge repo's one hook is the controller's
/// repo's one hook is the controller's now, this one has not moved yet. No-op /// now; this one has not moved yet. No-op when the core token is absent
/// when the core token is absent (forge not yet provisioned). /// (forge not yet provisioned).
/// ///
/// # Errors /// # Errors
/// ///

View file

@ -10,9 +10,7 @@ use std::sync::Mutex;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use forgejo_api::structs::{ use forgejo_api::structs::{
AddCollaboratorOption, AddCollaboratorOptionPermission, CreateBranchProtectionOption, AddCollaboratorOption, AddCollaboratorOptionPermission, CreateBranchProtectionOption,
CreateOrgOption, CreateRepoOption, CreateTeamOption, CreateTeamOptionPermission, CreateRepoOption, EditBranchProtectionOption, Repository,
EditBranchProtectionOption, EditRepoOption, EditTeamOption, EditTeamOptionPermission,
MigrateRepoOptions, MigrateRepoOptionsService, Repository,
}; };
use forgejo_api::{ApiErrorKind, ForgejoError}; use forgejo_api::{ApiErrorKind, ForgejoError};
use reqwest::StatusCode; use reqwest::StatusCode;
@ -20,8 +18,8 @@ use reqwest::StatusCode;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
use super::{ use super::{
AGENTS_ORG, CONFIG_ORG, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO, SHARED_ORG, api, AGENTS_ORG, CONFIG_ORG, OPERATORS_TEAM, SHARED_DOCS_REPO, SHARED_ORG, api, core_auth_header,
core_auth_header, core_token, forge_git_url, forge_http_base, is_present, core_token, forge_git_url, forge_http_base, is_present,
}; };
/// Creation options for an empty repo defaulting to `main`. /// Creation options for an empty repo defaulting to `main`.
@ -42,48 +40,6 @@ fn repo_option(name: &str, private: bool) -> CreateRepoOption {
} }
} }
/// `EditRepoOption` with every field unset — repo edits only ever
/// change the one field the caller sets on top (Forgejo leaves `None`
/// fields untouched).
fn sparse_edit_repo_option() -> EditRepoOption {
EditRepoOption {
allow_fast_forward_only_merge: None,
allow_manual_merge: None,
allow_merge_commits: None,
allow_rebase: None,
allow_rebase_explicit: None,
allow_rebase_update: None,
allow_squash_merge: None,
archived: None,
autodetect_manual_merge: None,
default_allow_maintainer_edit: None,
default_branch: None,
default_delete_branch_after_merge: None,
default_merge_style: None,
default_update_style: None,
description: None,
enable_prune: None,
external_tracker: None,
external_wiki: None,
globally_editable_wiki: None,
has_actions: None,
has_issues: None,
has_packages: None,
has_projects: None,
has_pull_requests: None,
has_releases: None,
has_wiki: None,
ignore_whitespace_conflicts: None,
internal_tracker: None,
mirror_interval: None,
name: None,
private: None,
template: None,
website: None,
wiki_branch: None,
}
}
/// Whether a create-style call failed because the object already /// Whether a create-style call failed because the object already
/// exists. Forgejo signals this as HTTP 409 (conflict) or 422 /// exists. Forgejo signals this as HTTP 409 (conflict) or 422
/// (validation). The typed client surfaces those as /// (validation). The typed client surfaces those as
@ -104,59 +60,6 @@ fn is_already_exists(e: &ForgejoError) -> bool {
} }
} }
/// Whether an error is specifically an HTTP 409 conflict (and NOT a
/// 422): the migrate endpoint's 422 is a validation error (bad
/// `clone_addr` / service) and must surface, so it can't share
/// [`is_already_exists`]'s 422 tolerance.
fn is_conflict(e: &ForgejoError) -> bool {
match e {
ForgejoError::ApiError(api) => {
matches!(api.error_kind(), ApiErrorKind::Other(s) if *s == StatusCode::CONFLICT)
}
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::CONFLICT,
_ => false,
}
}
/// Whether a rendered error message names the "already exists" case. The
/// discriminator that tells a *benign* already-exists 422 apart from a
/// *real* validation 422 (invalid units, etc.). Case-insensitive.
fn message_says_already_exists(rendered: &str) -> bool {
rendered.to_lowercase().contains("already exists")
}
/// Whether `e` is Forgejo saying the resource already exists — matching
/// the 409 conflict shape *and* the 422 shape this Forgejo build actually
/// returns for a duplicate team: `validation failed: team already exists`.
/// A 409-only [`is_conflict`] check misses that 422, so the caller fires
/// a spurious "provisioning failed" warning every boot and skips its
/// settings reconcile. This still surfaces *other* 422s (bad request
/// body) as real failures — only a 422 whose message names the
/// already-exists case is folded in.
fn is_already_exists_lenient(e: &ForgejoError) -> bool {
if is_conflict(e) {
return true;
}
let is_unprocessable = match e {
ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::ValidationFailed),
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::UNPROCESSABLE_ENTITY,
_ => false,
};
is_unprocessable && message_says_already_exists(&e.to_string())
}
/// Whether an error is Forgejo saying 404 — the resource is absent,
/// as opposed to a transport / auth / server failure.
fn is_not_found(e: &ForgejoError) -> bool {
match e {
ForgejoError::ApiError(api) => {
matches!(api.error_kind(), ApiErrorKind::NotFound { .. })
}
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND,
_ => false,
}
}
/// Fold a repo-creation result's "already exists" (409 / 422) into /// Fold a repo-creation result's "already exists" (409 / 422) into
/// success. `label` is `<owner>/<name>` — purely for log + error /// success. `label` is `<owner>/<name>` — purely for log + error
/// context. /// context.
@ -174,28 +77,6 @@ fn created_or_exists(res: Result<Repository, ForgejoError>, label: &str) -> Resu
} }
} }
/// Set an existing repo to public visibility. No-op if the repo is
/// already public. Used for `internal/knowledge` which may have been
/// created as private on an older deployment.
async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> {
let mut edit = sparse_edit_repo_option();
edit.private = Some(false);
api(token)?
.repo_edit(owner, repo, edit)
.await
.with_context(|| format!("edit {owner}/{repo} (set public)"))?;
tracing::debug!(%owner, %repo, "forge: repo set to public");
Ok(())
}
/// Create `name` inside org `org` as a public repo. Idempotent.
async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> {
let res = api(token)?
.create_org_repo(org, repo_option(name, false))
.await;
created_or_exists(res, &format!("{org}/{name}"))
}
/// Create a repo in the token-owner's own namespace. `token` belongs /// Create a repo in the token-owner's own namespace. `token` belongs
/// to the user we want the repo owned by (we use `core`'s token for /// to the user we want the repo owned by (we use `core`'s token for
/// `core/meta`). Idempotent. /// `core/meta`). Idempotent.
@ -356,13 +237,6 @@ fn record_branch_protection_result(name: &str, ok: bool) {
} }
} }
/// Ensure the `internal/docs` repo exists. Called once at startup
/// after `ensure_org(SHARED_ORG)`. Idempotent — `ensure_org_repo`
/// treats 409 as success.
pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> {
ensure_org_repo(SHARED_ORG, SHARED_DOCS_REPO, core_token).await
}
/// Grant agent `name` read-only collaborator access to `internal/docs`. /// Grant agent `name` read-only collaborator access to `internal/docs`.
/// Idempotent: re-adding an existing collaborator succeeds (Forgejo /// Idempotent: re-adding an existing collaborator succeeds (Forgejo
/// answers 204 either way). Mirrors `meta_read_access` so agents can /// answers 204 either way). Mirrors `meta_read_access` so agents can
@ -380,19 +254,6 @@ pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Ensure the `internal/knowledge` repo exists and is public.
/// Called once at startup after `ensure_org(SHARED_ORG)`. Idempotent.
///
/// The repo is created as public so any agent with a forge account can
/// fork it and open PRs to contribute. Existing deployments that ended
/// up with a private repo are patched to public on the next hive-c0re
/// startup via `set_repo_public`.
pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> {
ensure_org_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_token).await?;
// Ensure public even if the repo already existed as private (older deployment).
set_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_token).await
}
/// Grant agent `name` read-only collaborator access to `core/meta` on /// Grant agent `name` read-only collaborator access to `core/meta` on
/// the forge so the agent can clone/fetch the meta flake. Idempotent: /// the forge so the agent can clone/fetch the meta flake. Idempotent:
/// re-adding an existing collaborator succeeds (Forgejo answers 204 /// re-adding an existing collaborator succeeds (Forgejo answers 204
@ -732,261 +593,6 @@ async fn run_config_push(
.context("invoke git push agent-configs") .context("invoke git push agent-configs")
} }
/// Create an org named `name` (`org_create`). Idempotent: HTTP 422
/// ("user already exists") / 409 is treated as success.
pub(super) async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
let org = CreateOrgOption {
description: None,
email: None,
full_name: None,
location: None,
repo_admin_change_team_access: None,
username: name.to_owned(),
visibility: None,
website: None,
};
match api(admin_token)?.org_create(org).await {
Ok(_) => {
tracing::info!(%name, "forge: created org");
Ok(())
}
Err(e) if is_already_exists(&e) => {
tracing::debug!(%name, "forge: org already exists");
Ok(())
}
Err(e) => Err(e).with_context(|| format!("create org {name}")),
}
}
/// One operator-declared pull-mirror, forwarded from the nix
/// `services.hyperhive.forge.mirrors` option as JSON in
/// `HYPERHIVE_FORGE_MIRRORS`.
#[derive(serde::Deserialize)]
struct Mirror {
/// Upstream clone URL to mirror from (e.g. `https://github.com/actions/checkout`).
upstream: String,
/// Local `<owner>/<repo>` the mirror is created at.
dest: String,
}
/// Ensure each `HYPERHIVE_FORGE_MIRRORS` entry exists as a real Forgejo
/// pull-mirror. The env carries the JSON-encoded nix `forge.mirrors` list
/// (plus the CI-auto `actions/checkout` entry). Absent/empty env = no-op.
/// Per-mirror failures warn and continue — never abort the startup sweep.
pub(super) async fn ensure_mirrors(admin_token: &str) {
let raw = match std::env::var("HYPERHIVE_FORGE_MIRRORS") {
Ok(s) if !s.trim().is_empty() => s,
_ => return,
};
let mirrors: Vec<Mirror> = match serde_json::from_str(&raw) {
Ok(m) => m,
Err(e) => {
tracing::warn!(error = ?e, "forge: HYPERHIVE_FORGE_MIRRORS is not valid JSON; skipping mirror seed");
return;
}
};
for m in mirrors {
let Some((owner, repo)) = m.dest.split_once('/') else {
tracing::warn!(dest = %m.dest, "forge: mirror dest is not <owner>/<repo>; skipping");
continue;
};
// Create the dest org first (idempotent); the mirror can't land
// without its owner existing.
if let Err(e) = ensure_org(owner, admin_token).await {
tracing::warn!(%owner, error = ?e, "forge: ensure_org for mirror failed");
continue;
}
if let Err(e) = ensure_mirror_repo(&m.upstream, owner, repo, admin_token).await {
tracing::warn!(dest = %m.dest, error = ?e, "forge: ensure_mirror_repo failed");
}
}
}
/// Periodic sync interval for pull-mirrors. Forgejo syncs mirrors
/// on-access by default, which re-introduces external DNS latency on
/// every `git clone` (the hive-ci runner shares the host netns and is
/// therefore affected by host resolver blips). A fixed periodic interval
/// isolates CI from transient DNS failures — a stale mirror is
/// acceptable; a broken clone because of a momentary DNS blip is not.
const MIRROR_INTERVAL: &str = "8h0m0s";
/// Create `owner/repo` as a pull-mirror of `upstream` via the migrate API.
/// Idempotent: if the repo already exists this function patches its
/// `mirror_interval` to ensure it matches (covers mirrors that were
/// created before the interval was introduced). A 409 on the migrate
/// call (a race between the existence check and the migrate) is also
/// success.
async fn ensure_mirror_repo(
upstream: &str,
owner: &str,
repo: &str,
admin_token: &str,
) -> Result<()> {
let client = api(admin_token)?;
match client.repo_get(owner, repo).await {
Ok(_) => {
// Mirror already present. Patch interval so mirrors seeded before
// this field was introduced (or with a different value) converge.
let mut edit = sparse_edit_repo_option();
edit.mirror_interval = Some(MIRROR_INTERVAL.to_owned());
match client.repo_edit(owner, repo, edit).await {
Ok(_) => {
tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated");
}
Err(e) => {
tracing::warn!(
%owner, %repo, error = %e,
"forge: failed to set mirror_interval on existing pull-mirror"
);
}
}
return Ok(());
}
// Absent — fall through to migrate.
Err(e) if is_not_found(&e) => {}
// Anything else (transport, auth, 5xx) leaves the repo's existence
// unknown: migrating anyway would fold a 409 into success and skip
// the interval patch this pass. Surface it instead.
Err(e) => {
return Err(e).with_context(|| format!("get pull-mirror {owner}/{repo}"));
}
}
let opts = MigrateRepoOptions {
auth_password: None,
auth_token: None,
auth_username: None,
clone_addr: upstream.to_owned(),
description: None,
issues: None,
labels: None,
lfs: None,
lfs_endpoint: None,
milestones: None,
mirror: Some(true),
// Periodic refresh instead of on-access sync — keeps CI isolated
// from external DNS failures at clone time.
mirror_interval: Some(MIRROR_INTERVAL.to_owned()),
private: Some(false),
pull_requests: None,
releases: None,
repo_name: repo.to_owned(),
repo_owner: Some(owner.to_owned()),
service: Some(MigrateRepoOptionsService::Git),
uid: None,
wiki: None,
};
match client.repo_migrate(opts).await {
Ok(_) => {
tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror");
Ok(())
}
// 409 = a race created it between our existence check and here (the
// check is the real idempotency guard). NOT 422: for the migrate
// endpoint 422 is a validation error (bad clone_addr / service), so
// it must surface via the error arm, not be swallowed as "already
// exists" — hence `is_conflict`, not `is_already_exists`.
Err(e) if is_conflict(&e) => {
tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)");
Ok(())
}
Err(e) => Err(e).with_context(|| format!("migrate pull-mirror {owner}/{repo}")),
}
}
/// Provision the [`OPERATORS_TEAM`] inside `org` as an **empty** team.
/// Branch protection on that org's repos references it as the
/// merge/approval whitelist; the operator adds herself as a member via the
/// forge UI / hivectl. `includes_all_repositories` so the gate applies to
/// every repo in the org; `write` is enough to approve + merge. hive-c0re
/// never manages membership. Idempotent (409 = already exists).
///
/// Must run for BOTH [`AGENTS_ORG`] and [`CONFIG_ORG`]: Gitea teams are
/// org-scoped, so a config-repo branch-protection rule referencing
/// `operators` needs the team to exist in `agent-configs` too. Missing it
/// there 422'd every `apply_config_repo_branch_protection`, leaving config
/// repos unprotected — operator-merged config PRs then bypassed the deploy
/// pipeline and silently didn't apply.
///
/// Uses `is_conflict` (409 only) — NOT `is_already_exists` (which also
/// folds 422 into "already exists"). A 422 from `org_create_team` is a
/// real validation error (bad request shape, missing units, etc.) that
/// must surface so it can be fixed; the previous 422-swallowing hid the
/// true cause and left the team silently uncreated every boot.
/// Repo-unit access flags for the `operators` team.
/// Explicit list so Forgejo doesn't reject a null/absent `units` field;
/// a `write`-permission team needs at least `repo.code` + `repo.pulls`
/// to review and merge PRs.
const OPERATORS_TEAM_UNITS: &[&str] = &[
"repo.code",
"repo.issues",
"repo.pulls",
"repo.releases",
"repo.wiki",
"repo.projects",
"repo.packages",
];
pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
let units_vec: Vec<String> = OPERATORS_TEAM_UNITS.iter().map(|&s| s.to_owned()).collect();
let team = CreateTeamOption {
can_create_org_repo: Some(false),
description: Some("hyperhive operators — merge gate for agent repos".to_owned()),
includes_all_repositories: Some(true),
name: OPERATORS_TEAM.to_owned(),
permission: Some(CreateTeamOptionPermission::Write),
units: Some(units_vec.clone()),
units_map: None,
};
let client = api(token)?;
match client.org_create_team(org, team).await {
Ok(_) => {
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
Ok(())
}
// Team already exists — reconcile settings to desired state so a
// team created with an older/wrong shape self-heals on next boot.
// Forgejo signals the duplicate as a 409 conflict OR (this build) a
// 422 `validation failed: team already exists`; both mean the same
// thing, so fold both in via `is_already_exists_lenient` — a 409-only
// check missed the 422 and warned every boot. List teams to find the
// id (required by org_edit_team), then unconditionally PATCH to the
// desired settings. Members are a separate endpoint; untouched here.
Err(e) if is_already_exists_lenient(&e) => {
let (_headers, teams) = client
.org_list_teams(org)
.await
.with_context(|| format!("list teams for {org}"))?;
let team_id = teams
.into_iter()
.find(|t| t.name.as_deref() == Some(OPERATORS_TEAM))
.and_then(|t| t.id)
.with_context(|| {
format!("{OPERATORS_TEAM} team not found in {org} after 409 conflict")
})?;
let edit = EditTeamOption {
can_create_org_repo: Some(false),
description: Some("hyperhive operators — merge gate for agent repos".to_owned()),
includes_all_repositories: Some(true),
name: OPERATORS_TEAM.to_owned(),
permission: Some(EditTeamOptionPermission::Write),
units: Some(units_vec),
units_map: None,
};
client
.org_edit_team(team_id, edit)
.await
.with_context(|| format!("reconcile {org}/{OPERATORS_TEAM} team settings"))?;
tracing::debug!(%org, "forge: reconciled {OPERATORS_TEAM} team settings");
Ok(())
}
// Any OTHER error (a 422 that is NOT already-exists, or a transport
// / auth failure): surface it — don't mask a real failure. A
// non-already-exists 422 means the request body is invalid (e.g.
// Forgejo rejected the units list); it repeats every boot until fixed.
Err(e) => Err(e).with_context(|| format!("create team {org}/{OPERATORS_TEAM}")),
}
}
/// Add `user` as a collaborator on `owner/repo` at `permission`. /// Add `user` as a collaborator on `owner/repo` at `permission`.
/// Idempotent: adding an existing collaborator just updates its /// Idempotent: adding an existing collaborator just updates its
/// permission (Forgejo answers 204 either way; a 201 from older /// permission (Forgejo answers 204 either way; a 201 from older
@ -1224,27 +830,3 @@ pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Res
tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate"); tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate");
Ok(format!("{AGENTS_ORG}/{repo}")) Ok(format!("{AGENTS_ORG}/{repo}"))
} }
#[cfg(test)]
mod tests {
use super::message_says_already_exists;
#[test]
fn already_exists_message_is_recognised() {
// The exact 422 body this Forgejo build returns for a duplicate team.
assert!(message_says_already_exists(
"validation failed: team already exists [org_id: 10, name: operators]"
));
// Case-insensitive.
assert!(message_says_already_exists("Repository Already Exists"));
}
#[test]
fn real_validation_error_is_not_treated_as_already_exists() {
// A genuine bad-request 422 must still surface, not be folded in.
assert!(!message_says_already_exists(
"validation failed: units must not be empty"
));
assert!(!message_says_already_exists("not found"));
}
}

View file

@ -12,7 +12,7 @@ use forgejo_api::structs::{EditUserOption, UpdateUserAvatarOption};
use forgejo_api::{ApiErrorKind, ForgejoError}; use forgejo_api::{ApiErrorKind, ForgejoError};
use reqwest::StatusCode; use reqwest::StatusCode;
use super::{CONFIG_ORG, api, forge_admin}; use super::{api, forge_admin};
const TOKEN_NAME_PREFIX: &str = "hyperhive"; const TOKEN_NAME_PREFIX: &str = "hyperhive";
// Where the host-side `core` admin token lives. Used by hive-c0re itself // Where the host-side `core` admin token lives. Used by hive-c0re itself
@ -20,21 +20,18 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive";
// in `crate::paths`; aliased here under the long-standing name. // in `crate::paths`; aliased here under the long-standing name.
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH; use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
// Forge provisioning markers (`forge/core-avatar-set`, // Forge provisioning markers (`forge/core-avatar-set`,
// `forge/agent-configs-avatar-set`, `forge/email-aligned-<name>`) live // `forge/email-aligned-<name>`) live in `crate::paths` — one-shot guards:
// in `crate::paths` — one-shot guards: the upload/align runs once, the // the upload/align runs once, the marker is written, subsequent startups
// marker is written, subsequent startups skip. Delete one to force its // skip. Delete one to force its step to re-run.
// step to re-run. // The avatar PNG path is resolved by `core_avatar_png_path` below —
// Avatar PNG paths are resolved by `core_avatar_png_path` / // only-used-here, so it lives in this module rather than the shared
// `config_org_avatar_png_path` below — only-used-here, so they live // `hive_sh4re::assets` helpers (which now hold only `prompt_template`, the
// in this module rather than the shared `hive_sh4re::assets` helpers // one asset path every crate needs — the agent icon, the last other one, moved off the
// (which now hold only `prompt_template`, the one asset path every
// crate needs — the agent icon, the last other one, moved off the
// shared-asset model entirely; see `hive-agent::web_ui::screen`). // shared-asset model entirely; see `hive-agent::web_ui::screen`).
/// `$HIVE_ASSETS_DIR/branding/hyperhive.png` — the core mark, /// `$HIVE_ASSETS_DIR/branding/hyperhive.png` — the core mark,
/// rasterised. Not independently configurable (unlike the org avatar /// rasterised. Not independently configurable — override the whole
/// below) — override the whole `services.hyperhive.c0re.assets` /// `services.hyperhive.c0re.assets` package to change it.
/// package to change it.
fn core_avatar_png_path() -> std::path::PathBuf { fn core_avatar_png_path() -> std::path::PathBuf {
let dir = std::env::var("HIVE_ASSETS_DIR").expect( let dir = std::env::var("HIVE_ASSETS_DIR").expect(
"HIVE_ASSETS_DIR is unset — the hyperhive NixOS module sets it from \ "HIVE_ASSETS_DIR is unset — the hyperhive NixOS module sets it from \
@ -44,21 +41,6 @@ fn core_avatar_png_path() -> std::path::PathBuf {
std::path::PathBuf::from(dir).join("branding/hyperhive.png") std::path::PathBuf::from(dir).join("branding/hyperhive.png")
} }
/// Path to the `agent-configs` org's avatar PNG. Independently
/// configurable via `services.hyperhive.c0re.orgAvatarPng` — the nix
/// module resolves `HIVE_ORG_AVATAR_PNG` to that option's value, or
/// the bundled `agent-configs.png` from `assets` when unset, so an
/// operator can override just this image without replacing the whole
/// `assets` package.
fn config_org_avatar_png_path() -> std::path::PathBuf {
std::path::PathBuf::from(std::env::var("HIVE_ORG_AVATAR_PNG").expect(
"HIVE_ORG_AVATAR_PNG is unset — the hyperhive NixOS module sets it \
unconditionally on the hive-c0re unit (from \
services.hyperhive.c0re.orgAvatarPng or the bundled default), so \
this process was started outside that unit",
))
}
/// Bootstrap `core` token scopes — adds `read:admin,write:admin` on /// Bootstrap `core` token scopes — adds `read:admin,write:admin` on
/// top of the agent scopes (swarm-controller's `AGENT_TOKEN_SCOPES`) /// top of the agent scopes (swarm-controller's `AGENT_TOKEN_SCOPES`)
/// so the host daemon can drive `/api/v1/admin/*`. Site-admin /// so the host daemon can drive `/api/v1/admin/*`. Site-admin
@ -369,39 +351,6 @@ pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// Set the `agent-configs` org's Forgejo avatar to the
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
/// one-shot, marker-guarded, best-effort. Uses `org_update_avatar`
/// (`POST /api/v1/orgs/{org}/avatar`, base64-PNG payload).
pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
let marker = crate::paths::forge_config_org_avatar_marker();
if marker.exists() {
return Ok(());
}
let png_path = config_org_avatar_png_path();
let png_bytes = tokio::fs::read(&png_path)
.await
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
api(token)?
.org_update_avatar(
CONFIG_ORG,
UpdateUserAvatarOption {
image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)),
},
)
.await
.with_context(|| format!("set {CONFIG_ORG} avatar"))?;
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(marker, "").ok();
tracing::info!(
org = CONFIG_ORG,
"forge: set org avatar to configs-stack logo"
);
Ok(())
}
/// Outcome of probing whether the persisted core token still works /// Outcome of probing whether the persisted core token still works
/// against the *current* forge. Existence on disk is not validity: a /// against the *current* forge. Existence on disk is not validity: a
/// token minted before a forge rebuild / re-provision is unknown to the /// token minted before a forge rebuild / re-provision is unknown to the

View file

@ -184,13 +184,8 @@ async fn run_matrix_sweep() -> Result<()> {
/// `tokio::spawn` block it replaced used: no-op (not an error) when the /// `tokio::spawn` block it replaced used: no-op (not an error) when the
/// HMAC secret, core token, or hive domain aren't available yet. /// HMAC secret, core token, or hive domain aren't available yet.
/// ///
/// The node now does one of each: it still registers the config-PR hook, /// Registers the config-PR hook only. The knowledge hook is the swarm
/// and it *removes* the knowledge one. A knowledge push is delivered to /// controller's, which addresses an event to each hive over the queue.
/// the swarm controller, which addresses an event to each hive over the
/// queue — so a hive holding its own registration is holding a shared
/// resource only one party can own. The removal runs every boot rather
/// than behind a marker because it is already idempotent: it is a no-op
/// the moment the hook is gone.
async fn run_webhook_register() -> Result<()> { async fn run_webhook_register() -> Result<()> {
let Ok(webhook_secret) = crate::webhook_secret::load_or_generate() else { let Ok(webhook_secret) = crate::webhook_secret::load_or_generate() else {
tracing::debug!("webhook secret unavailable; skipping hook registration"); tracing::debug!("webhook secret unavailable; skipping hook registration");
@ -206,9 +201,6 @@ async fn run_webhook_register() -> Result<()> {
tracing::debug!("HYPERHIVE_HIVE_DOMAIN unset; skipping webhook registration"); tracing::debug!("HYPERHIVE_HIVE_DOMAIN unset; skipping webhook registration");
return Ok(()); return Ok(());
}; };
if let Err(e) = crate::workers::knowledge::remove_webhook(&token, &domain).await {
tracing::warn!(error = ?e, "knowledge: remove_webhook failed");
}
if let Err(e) = crate::forge::ensure_config_pr_webhook(&token, &domain, &webhook_secret).await { if let Err(e) = crate::forge::ensure_config_pr_webhook(&token, &domain, &webhook_secret).await {
tracing::warn!(error = ?e, "forge: ensure_config_pr_webhook failed"); tracing::warn!(error = ?e, "forge: ensure_config_pr_webhook failed");
} }

View file

@ -80,12 +80,6 @@ pub fn forge_core_avatar_marker() -> PathBuf {
forge_dir().join("core-avatar-set") forge_dir().join("core-avatar-set")
} }
/// `forge/agent-configs-avatar-set` — marker: agent-configs org avatar set.
#[must_use]
pub fn forge_config_org_avatar_marker() -> PathBuf {
forge_dir().join("agent-configs-avatar-set")
}
/// `forge/email-aligned-<name>` — marker: `<name>`'s forge email aligned. /// `forge/email-aligned-<name>` — marker: `<name>`'s forge email aligned.
#[must_use] #[must_use]
pub fn forge_email_aligned_marker(name: &str) -> PathBuf { pub fn forge_email_aligned_marker(name: &str) -> PathBuf {
@ -319,14 +313,10 @@ pub fn agent_runtime_dir(name: &str) -> PathBuf {
/// within the same filesystem is atomic. /// within the same filesystem is atomic.
pub fn relocate_legacy_state() { pub fn relocate_legacy_state() {
let root = state_root(); let root = state_root();
let moves: [(&str, PathBuf); 8] = [ let moves: [(&str, PathBuf); 7] = [
("broker.sqlite", db_dir().join("broker.sqlite")), ("broker.sqlite", db_dir().join("broker.sqlite")),
("build_logs.sqlite", db_dir().join("build_logs.sqlite")), ("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
("forge-core-avatar-set", forge_core_avatar_marker()), ("forge-core-avatar-set", forge_core_avatar_marker()),
(
"forge-agent-configs-avatar-set",
forge_config_org_avatar_marker(),
),
("matrix-sender-token", matrix_sender_token()), ("matrix-sender-token", matrix_sender_token()),
("matrix-space-room-id", matrix_space_room_id()), ("matrix-space-room-id", matrix_space_room_id()),
("matrix-creds", matrix_creds_dir()), ("matrix-creds", matrix_creds_dir()),

View file

@ -15,7 +15,9 @@
//! `/webhook/knowledge`. A webhook has exactly one target URL, so with //! `/webhook/knowledge`. A webhook has exactly one target URL, so with
//! more than one hive that was last-writer-wins rather than idempotent — //! more than one hive that was last-writer-wins rather than idempotent —
//! every hive but the most recent silently stopped receiving deliveries. //! every hive but the most recent silently stopped receiving deliveries.
//! [`remove_webhook`] is the migration off it. //!
//! The repo itself (public, README-seeded) is the swarm controller's too;
//! this module only clones and pulls it.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@ -35,38 +37,12 @@ pub use crate::paths::KNOWLEDGE_DIR as LOCAL_DIR;
/// read-only from [`LOCAL_DIR`] into every agent container. /// read-only from [`LOCAL_DIR`] into every agent container.
pub const CONTAINER_MOUNT: &str = "/knowledge"; 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 /// Clone `internal/knowledge` to [`LOCAL_DIR`] if it is not already a git
/// repository. `core_token` authenticates the HTTPS clone so private repos /// repository. `core_token` authenticates the HTTPS clone so private repos
/// work. Idempotent — skips if `LOCAL_DIR/.git` exists. /// work. Idempotent — skips if `LOCAL_DIR/.git` exists.
/// ///
/// When the upstream repo is empty (freshly created), seeds it with a /// The swarm controller seeds a README into a freshly created repo. A clone
/// README.md before returning so the local clone is always non-empty and /// taken before that lands is empty; the next [`pull`] fills it.
/// agents see a useful starting document.
/// ///
/// Called once at hive-c0re startup after `forge::ensure_all`. /// Called once at hive-c0re startup after `forge::ensure_all`.
pub async fn ensure_local_clone(core_token: &str) -> Result<()> { pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
@ -87,129 +63,6 @@ pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
anyhow::bail!("git clone {ORG}/{REPO} failed: {stderr}"); anyhow::bail!("git clone {ORG}/{REPO} failed: {stderr}");
} }
tracing::info!("knowledge: cloned {ORG}/{REPO} to {LOCAL_DIR}"); 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(()) Ok(())
} }
@ -256,8 +109,7 @@ pub async fn pull(coord: &Coordinator) -> Result<()> {
.await; .await;
// Discard any local drift before pulling — both tracked (`reset --hard`) // Discard any local drift before pulling — both tracked (`reset --hard`)
// and untracked (`clean -fd`). Nothing in this codebase writes to // and untracked (`clean -fd`). Nothing in this codebase writes to
// `LOCAL_DIR` after the initial clone (`seed_readme` runs once, at // `LOCAL_DIR` after the initial clone — this working tree exists to mirror
// creation, before any pull) — this working tree exists to mirror
// `origin/main`, not to be edited in place. A tracked file left dirty // `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 // by any other means (a stray manual edit on the host, an interrupted
// prior operation, or — the actual root cause here — two unsynchronized // prior operation, or — the actual root cause here — two unsynchronized
@ -280,7 +132,7 @@ pub async fn pull(coord: &Coordinator) -> Result<()> {
.await; .await;
let before = head_sha().await; let before = head_sha().await;
// The repo is public (`ensure_knowledge_repo` makes it so), so an // The repo is public (the swarm controller makes it so), so an
// unauthenticated pull is enough — but authenticate when a token is around, // unauthenticated pull is enough — but authenticate when a token is around,
// which keeps this working if the repo is ever made private again. // 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 auth = crate::forge::core_token().map(|t| core_auth_header(&t));

View file

@ -47,15 +47,6 @@ in
# prompts). `hive_sh4re::assets::*` reads paths underneath. # prompts). `hive_sh4re::assets::*` reads paths underneath.
# `forge/users.rs` reads the core avatar PNG from here on startup. # `forge/users.rs` reads the core avatar PNG from here on startup.
HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive"; HIVE_ASSETS_DIR = "${cfg.assets}/share/hyperhive";
# `agent-configs` org avatar PNG — independently overridable via
# `orgAvatarPng` without replacing the whole `assets` package.
# Falls back to the bundled PNG under HIVE_ASSETS_DIR when unset.
# Read by `forge::users::config_org_avatar_png_path`.
HIVE_ORG_AVATAR_PNG =
if cfg.orgAvatarPng != null then
"${cfg.orgAvatarPng}"
else
"${cfg.assets}/share/hyperhive/branding/agent-configs.png";
# Whether this hive runs ruthless — no root/manager agent at all # Whether this hive runs ruthless — no root/manager agent at all
# (`auto_update::ensure_root_agent`). Default false = root # (`auto_update::ensure_root_agent`). Default false = root
# auto-managed; true makes the sweep a no-op. # auto-managed; true makes the sweep a no-op.

View file

@ -55,19 +55,6 @@
rust derivation. rust derivation.
''; '';
}; };
orgAvatarPng = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
PNG uploaded once as the `agent-configs` Forgejo org's avatar
(`forge::users::ensure_config_org_avatar`, one-shot,
marker-guarded — delete `forge/agent-configs-avatar-set` under
the state dir to force a re-upload after changing this).
Defaults (`null`) to the bundled `agent-configs.png` from
`assets`. Set this to override just the org avatar without
replacing the whole `assets` package.
'';
};
xdgIcons = lib.mkOption { xdgIcons = lib.mkOption {
type = lib.types.package; type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.xdg-icons"; defaultText = lib.literalExpression "hyperhive.packages.\${system}.xdg-icons";

View file

@ -633,9 +633,9 @@ in
''; '';
} }
{ {
# Keep mirror orgs out of the hive-c0re-managed namespaces # Keep mirror orgs out of the hyperhive-managed namespaces
# (config/shared/agents/core) so the seed never races / collides # (config/shared/agents/core) so the seed never races / collides
# with hive-c0re's own startup provisioning of those orgs. # with the provisioning of those orgs.
assertion = lib.all ( assertion = lib.all (
m: m:
!(lib.elem (builtins.elemAt (lib.splitString "/" m.dest) 0) [ !(lib.elem (builtins.elemAt (lib.splitString "/" m.dest) 0) [
@ -647,8 +647,8 @@ in
) effectiveMirrors; ) effectiveMirrors;
message = '' message = ''
services.hyperhive.deploy.forgejo.mirrors[].dest must not place a mirror services.hyperhive.deploy.forgejo.mirrors[].dest must not place a mirror
in a hive-c0re-managed org (config / shared / agents / core) — in a hyperhive-managed org (config / shared / agents / core) —
those are provisioned by hive-c0re and a mirror there would those are provisioned by hyperhive itself and a mirror there would
collide. Use a dedicated org (e.g. "actions/checkout"). collide. Use a dedicated org (e.g. "actions/checkout").
''; '';
} }
@ -1358,12 +1358,22 @@ in
]; ];
}; };
# Forward the declared pull-mirrors to hive-c0re, which seeds them in # Forward the declared pull-mirrors to the swarm-controller, which seeds
# its forge provisioning sweep (`forge.rs::ensure_mirrors`, alongside # them in its swarm-wide forge-objects pass
# the SEEDED_ORGS ensure). c0re already holds the core admin token and # (`swarm-controller/src/forge/objects.rs`), beside the orgs they live in.
# ensures the orgs there, so the seeding lives in one place rather than # JSON-encoded list of { upstream, dest }; `[]` when nothing to seed.
# a parallel host-side unit. JSON-encoded list of { upstream, dest }; #
# `[]` when nothing to seed (c0re no-ops). # ⚠️ This host's `mirrors` reach a controller on THIS host only. A
systemd.services.hive-c0re.environment.HYPERHIVE_FORGE_MIRRORS = builtins.toJSON effectiveMirrors; # controller elsewhere reads its own host's list, so mirrors declared
# here would never be seeded; the warning below says so at eval rather
# than leaving it to be noticed as a missing repo.
systemd.services.swarm-controller.environment.SWARM_CONTROLLER_FORGE_MIRRORS =
lib.mkIf deployCfg.swarm-controller.enable (builtins.toJSON effectiveMirrors);
warnings = lib.optional (effectiveMirrors != [ ] && !deployCfg.swarm-controller.enable) ''
services.hyperhive.deploy.forgejo.mirrors (or the actions/checkout mirror
deploy.forgejo.ci.enable adds) is set on a host that does not run the
swarm-controller. The controller seeds mirrors from its own host's list,
so these are not seeded: declare them on the swarm-controller's host.
'';
}; };
} }

View file

@ -190,6 +190,13 @@ let
# Same `%d` shape as the queue secret above — root reads the plaintext # Same `%d` shape as the queue secret above — root reads the plaintext
# at unit start, the daemon's own user sees a 0400 copy. # at unit start, the daemon's own user sees a 0400 copy.
SWARM_CONTROLLER_FORGE_TOKEN_FILE = "%d/forge-token"; SWARM_CONTROLLER_FORGE_TOKEN_FILE = "%d/forge-token";
# `agent-configs` org avatar for the forge-objects pass
# (`forge/objects.rs`). Only meaningful with forge access, hence here.
SWARM_CONTROLLER_CONFIG_ORG_AVATAR_PNG =
if deployCfg.swarm-controller.configOrgAvatarPng != null then
"${deployCfg.swarm-controller.configOrgAvatarPng}"
else
"${config.services.hyperhive.c0re.assets}/share/hyperhive/branding/agent-configs.png";
}; };
# How the forge must address this controller to deliver a swarm-wide # How the forge must address this controller to deliver a swarm-wide
@ -311,6 +318,12 @@ in
hostUnit = true; hostUnit = true;
enable = deployCfg.swarm-controller.enable; enable = deployCfg.swarm-controller.enable;
}) })
# The avatar moved with the forge-objects pass that uploads it: from
# hive-c0re's boot sweep to this daemon.
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "c0re" "orgAvatarPng" ]
[ "services" "hyperhive" "deploy" "swarm-controller" "configOrgAvatarPng" ]
)
]; ];
options.services.hyperhive.swarm.controller = { options.services.hyperhive.swarm.controller = {
@ -498,6 +511,20 @@ in
''; '';
}; };
configOrgAvatarPng = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
PNG uploaded once as the `agent-configs` Forgejo org's avatar by
the controller's swarm-wide forge-objects pass (one-shot,
marker-guarded — delete `forge-config-org-avatar-set` under the
controller's state directory to force a re-upload after changing
this). Defaults (`null`) to the bundled `agent-configs.png` from
{option}`services.hyperhive.c0re.assets`. Set this to override just
the org avatar without replacing the whole `assets` package.
'';
};
forgeTokenFile = lib.mkOption { forgeTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
# Forge's own delivery path when the forge runs here, since nothing # Forge's own delivery path when the forge runs here, since nothing

View file

@ -60,6 +60,19 @@ let
deploy.allSwarmServices = true; deploy.allSwarmServices = true;
deploy.bao.enable = false; deploy.bao.enable = false;
}; };
# The swarm-wide forge objects are the controller's (forge/objects.rs), so
# the mirror list and the org avatar go to its unit, not hive-c0re's.
aMirror = {
upstream = "https://example.invalid/tool";
dest = "mirrors/tool";
};
controllerWithMirrors = hive {
deploy.swarm-controller.enable = true;
deploy.forgejo.mirrors = [ aMirror ];
c0re.orgAvatarPng = "/etc/marker-avatar.png";
};
mirrorsNoController = hive { deploy.forgejo.mirrors = [ aMirror ]; };
cases = [ cases = [
{ {
# `lib.all` over an empty set holds vacuously, so the roster is counted # `lib.all` over an empty set holds vacuously, so the roster is counted
@ -123,6 +136,28 @@ let
name = "an explicit gateway.enable = false wins over the modules asserting it"; name = "an explicit gateway.enable = false wins over the modules asserting it";
ok = !(hive { gateway.enable = false; }).services.nginx.enable; ok = !(hive { gateway.enable = false; }).services.nginx.enable;
} }
{
name = "declared forge mirrors reach the swarm-controller, not hive-c0re";
ok =
builtins.fromJSON controllerWithMirrors.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_FORGE_MIRRORS
== [ aMirror ]
&& !(controllerWithMirrors.systemd.services.hive-c0re.environment ? HYPERHIVE_FORGE_MIRRORS);
}
{
name = "forge mirrors on a host without the controller warn that nothing seeds them";
ok =
let
warns = cfg: lib.any (lib.hasInfix "declare them on the swarm-controller's host") cfg.warnings;
in
warns mirrorsNoController && !(warns controllerWithMirrors);
}
{
name = "the old c0re org-avatar option lands on the controller's unit";
ok =
controllerWithMirrors.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_CONFIG_ORG_AVATAR_PNG
== "/etc/marker-avatar.png"
&& !(controllerWithMirrors.systemd.services.hive-c0re.environment ? HIVE_ORG_AVATAR_PNG);
}
]; ];
in in
runGroup "swarm-services-switch" cases runGroup "swarm-services-switch" cases

View file

@ -34,6 +34,7 @@ use utoipa::ToSchema;
use crate::webhook::DeliveryKind; use crate::webhook::DeliveryKind;
pub mod agent_token; pub mod agent_token;
pub mod objects;
pub mod site_admin; pub mod site_admin;
/// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it /// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it
@ -81,12 +82,9 @@ pub struct IssueReportRow {
} }
/// The `operators` team, whitelisted for the merge gate on every repo /// The `operators` team, whitelisted for the merge gate on every repo
/// this client protects — provisioned by `hive-c0re::forge::repos` /// this client protects. Provisioned by this daemon's own periodic pass
/// already (`ensure_operators_team`), not re-provisioned here. If that /// ([`objects`]), and checked again by [`Client::create_repo`] before it
/// assumption ever breaks (this becomes reachable before any hive's /// applies a rule naming the team.
/// `hive-c0re` has run its startup sweep), branch-protection creation
/// below will fail loudly rather than silently no-op — see
/// [`Client::create_repo`]'s doc comment.
const OPERATORS_TEAM: &str = "operators"; const OPERATORS_TEAM: &str = "operators";
/// The org owning per-agent config repos — where this daemon creates them, /// The org owning per-agent config repos — where this daemon creates them,
@ -105,7 +103,7 @@ const OPERATORS_TEAM: &str = "operators";
/// config repo created in `agents` is invisible to every one of those paths, /// config repo created in `agents` is invisible to every one of those paths,
/// and nothing errors, because both orgs exist and both accept a repo. /// and nothing errors, because both orgs exist and both accept a repo.
/// ///
/// The merge gate survives the distinction: `hive-c0re` provisions the /// The merge gate survives the distinction: [`objects`] provisions the
/// `operators` team in **both** orgs precisely so branch protection can be /// `operators` team in **both** orgs precisely so branch protection can be
/// applied in either. /// applied in either.
const CONFIG_ORG: &str = "agent-configs"; const CONFIG_ORG: &str = "agent-configs";
@ -288,12 +286,9 @@ impl Client {
/// collaborator, not on this whitelist) still cannot push directly, /// collaborator, not on this whitelist) still cannot push directly,
/// so this doesn't loosen the "can't merge your own PR" guarantee. /// so this doesn't loosen the "can't merge your own PR" guarantee.
/// ///
/// Assumes [`OPERATORS_TEAM`] already exists in [`CONFIG_ORG`] /// Needs [`OPERATORS_TEAM`] to exist in [`CONFIG_ORG`], which
/// (provisioned by `hive-c0re::forge::repos::ensure_operators_team` /// [`Client::create_repo`] ensures first — if it still doesn't, this
/// on its own startup sweep, not re-provisioned here) — if it /// fails loudly rather than silently leaving the repo unprotected.
/// doesn't yet, this fails loudly rather than silently leaving the
/// repo unprotected, which is the correct failure mode for a
/// prerequisite that's supposed to already be there.
async fn apply_operator_branch_protection(&self, repo: &str) -> Result<()> { async fn apply_operator_branch_protection(&self, repo: &str) -> Result<()> {
let rule = CreateBranchProtectionOption { let rule = CreateBranchProtectionOption {
apply_to_admins: None, apply_to_admins: None,
@ -357,7 +352,12 @@ impl Client {
/// doesn't exist yet"), unlike collaborator-add and config-seed, which /// doesn't exist yet"), unlike collaborator-add and config-seed, which
/// are genuinely separate operations against an already-existing repo. /// are genuinely separate operations against an already-existing repo.
/// Idempotent — safe to call again. /// Idempotent — safe to call again.
///
/// Ensures the org and its [`OPERATORS_TEAM`] first: the periodic
/// [`objects`] pass does too, but an agent can be created before that
/// pass has succeeded once, and the rule below names the team.
pub async fn create_repo(&self, repo: &str) -> Result<String> { pub async fn create_repo(&self, repo: &str) -> Result<String> {
self.ensure_merge_gate_prerequisites().await?;
self.ensure_org_repo(repo).await?; self.ensure_org_repo(repo).await?;
self.apply_operator_branch_protection(repo).await?; self.apply_operator_branch_protection(repo).await?;
tracing::info!(%repo, "swarm forge: created repo in {CONFIG_ORG} with operator merge gate"); tracing::info!(%repo, "swarm forge: created repo in {CONFIG_ORG} with operator merge gate");

File diff suppressed because it is too large Load diff

View file

@ -1910,6 +1910,25 @@ fn spawn_matrix_account_backfill(
}); });
} }
/// Start the forge's periodic passes — the swarm-wide objects and agents'
/// tokens — when a forge is configured. Lifted out of `main` for
/// `clippy::too_many_lines`.
fn spawn_forge_workers(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
forge_client: Option<Arc<forge::Client>>,
) {
let Some(client) = forge_client else {
return;
};
forge::objects::spawn(Arc::clone(&client));
let sched = Arc::clone(jobq);
forge::agent_token::spawn(client, move |agents| {
if let Err(e) = queue_forge_token_mints(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed");
}
});
}
/// Check an agent's forge token now, and mint one if it is missing or stale. /// Check an agent's forge token now, and mint one if it is missing or stale.
/// ///
/// The periodic pass (`forge::agent_token::spawn`) does the same every five /// The periodic pass (`forge::agent_token::spawn`) does the same every five
@ -2342,14 +2361,7 @@ async fn main() -> Result<()> {
); );
} }
if let Some(client) = forge_client.clone() { spawn_forge_workers(&jobq, forge_client.clone());
let sched = Arc::clone(&jobq);
forge::agent_token::spawn(client, move |agents| {
if let Err(e) = queue_forge_token_mints(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed");
}
});
}
let config_prs = forge_client.clone().map(config_pr::spawn); let config_prs = forge_client.clone().map(config_pr::spawn);
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone()); let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());

View file

@ -69,11 +69,23 @@ fn secret_path() -> std::path::PathBuf {
/// tests setting it race — which is not hypothetical here, it is how the /// tests setting it race — which is not hypothetical here, it is how the
/// first version of this module's tests failed. /// first version of this module's tests failed.
fn secret_path_from(raw: Option<&str>) -> std::path::PathBuf { fn secret_path_from(raw: Option<&str>) -> std::path::PathBuf {
state_dir_from(raw).join(SECRET_FILE)
}
/// This daemon's state directory, read the same way [`secret_path`] reads
/// it. Other state files (the forge avatar marker in
/// `crate::forge::objects`) live beside the secret, so both share this one
/// reading of `STATE_DIRECTORY`.
pub fn state_dir() -> std::path::PathBuf {
state_dir_from(std::env::var("STATE_DIRECTORY").ok().as_deref())
}
fn state_dir_from(raw: Option<&str>) -> std::path::PathBuf {
let dir = raw let dir = raw
.and_then(|raw| raw.split(':').next()) .and_then(|raw| raw.split(':').next())
.filter(|first| !first.is_empty()) .filter(|first| !first.is_empty())
.unwrap_or(DEFAULT_STATE_DIR); .unwrap_or(DEFAULT_STATE_DIR);
std::path::PathBuf::from(dir).join(SECRET_FILE) std::path::PathBuf::from(dir)
} }
/// Load the swarm's webhook HMAC secret, generating and persisting it if the /// Load the swarm's webhook HMAC secret, generating and persisting it if the