hives read the forge through their own swarm-minted read account
swarm-controller now gives every hive in its directory a forge account, `hive-<hive>`, in a new `hives` team of `agent-configs` (read on `repo.code` + `repo.pulls`, every repo including later ones), and mints one `read:repository` token named `swarm-hive` into the store at `swarm/hives/<hive>/forge/token`. A five-minute pass queues a `MintHiveForgeToken` node for a hive whose token is missing or no longer matches the forge, whose account is missing, or whose team membership or team settings drifted. A hive whose store or forge read failed is never queued, so an outage cannot rotate every hive's token. The token decision reuses `agent_token`'s classifier and replace step, now parameterised by name and scopes. An agent may be called `hive-<x>`. The hive account is created with `hive-<hive>@hives.hyperhive.local` and the job refuses an account by that name with any other email before it locks it down, adds it to the team or mints for it; agent creation already refuses the hive's account by the same email check. The controller's bao policy gains the `swarm/hives/+/forge/token` leaf (create/read/update), pinned by the bao-grants module-eval assertion. The hive's own policy already reads `swarm/hives/<hive>/*`. hive-c0re reads that token through `forge::reader_token()`, falling back to the core token file when the store holds none, and uses it for: fetching a merged config rev (swarm deploy, `hivectl forge reconcile-config`), reseeding a purged `applied`, the meta-update cascade's fast-forward, seeding `proposed` on first deploy, and finding the PR that merged a refused rev. Those reads no longer require a local `hive-forge` container. The `/knowledge` clone moves from the forge host's boot sweep into the knowledge pull, so every hive clones it. Posting the deploy-failure comment on that PR stays on the core token. Refs #4851
This commit is contained in:
parent
c8472966e9
commit
21e3f626f4
17 changed files with 1222 additions and 138 deletions
|
|
@ -1,15 +1,18 @@
|
|||
//! Optional Forgejo wiring — the `core/meta` repo, meta read-access
|
||||
//! grants, and each agent's read-only grant on `internal/docs` (the
|
||||
//! swarm-controller creates that repo, with the other swarm-wide orgs,
|
||||
//! teams and repos).
|
||||
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
|
||||
//! teams and repos). Those writes, as `core`, run only beside a local
|
||||
//! `hive-forge` container; reads of the forge go through [`reader_token`] on
|
||||
//! every hive. Full design: `docs/integrations/forge.md`.
|
||||
|
||||
mod pr_comment;
|
||||
mod reader;
|
||||
mod reconcile;
|
||||
mod repos;
|
||||
mod users;
|
||||
|
||||
pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
|
||||
pub use reader::reader_token;
|
||||
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
||||
pub use repos::{
|
||||
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main,
|
||||
|
|
@ -71,14 +74,20 @@ fn git_url_with_base(base: &str, repo: &str) -> String {
|
|||
}
|
||||
|
||||
/// The `http.extraHeader` value authenticating as the forge core user.
|
||||
pub(crate) fn core_auth_header(token: &str) -> String {
|
||||
basic_auth_header("core", token)
|
||||
}
|
||||
|
||||
/// The `http.extraHeader` value carrying `token`. Forgejo takes the account
|
||||
/// from the token, so `login` only labels the request.
|
||||
///
|
||||
/// Basic auth over a header rather than userinfo in the URL, so the secret
|
||||
/// reaches git through the environment (see [`forge_git_url`]). Pair with
|
||||
/// [`crate::lifecycle::git_command_authed`], which is the only thing that
|
||||
/// should ever hold the result.
|
||||
pub(crate) fn core_auth_header(token: &str) -> String {
|
||||
fn basic_auth_header(login: &str, token: &str) -> String {
|
||||
use base64::Engine as _;
|
||||
let basic = base64::engine::general_purpose::STANDARD.encode(format!("core:{token}"));
|
||||
let basic = base64::engine::general_purpose::STANDARD.encode(format!("{login}:{token}"));
|
||||
format!("Authorization: Basic {basic}")
|
||||
}
|
||||
|
||||
|
|
@ -223,8 +232,7 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
|||
}
|
||||
|
||||
/// The `core_token.is_some()` half of [`ensure_all`]: the meta repo, the
|
||||
/// local knowledge clone, the core avatar, and removing this hive's own
|
||||
/// config-PR hook —
|
||||
/// core avatar, and removing this hive's own config-PR hook —
|
||||
/// 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
|
||||
|
|
@ -243,16 +251,6 @@ async fn ensure_all_orgs_and_repos(token: &str) {
|
|||
format!("forge: core/meta repo provisioning failed: {e}"),
|
||||
);
|
||||
}
|
||||
// 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 {
|
||||
tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed");
|
||||
crate::warnings::set_boot_warning(
|
||||
"forge_knowledge_local_clone",
|
||||
"crit",
|
||||
format!("knowledge: local clone failed: {e}"),
|
||||
);
|
||||
}
|
||||
if let Err(e) = ensure_core_avatar(token).await {
|
||||
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
||||
crate::warnings::set_boot_warning(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
//! Commenting on an agent's config PRs as the core forge user — how a hive
|
||||
//! reports a merged config commit it refused to deploy back onto the PR that
|
||||
//! merged it.
|
||||
//! merged it. Finding that PR only reads, so it uses [`reader_token`].
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use super::{CONFIG_ORG, api, core_token};
|
||||
use super::{CONFIG_ORG, api, core_token, reader_token};
|
||||
|
||||
/// Full `owner/name` path of an agent's config repo on the forge.
|
||||
pub fn config_repo(agent: &str) -> String {
|
||||
|
|
@ -14,13 +14,13 @@ pub fn config_repo(agent: &str) -> String {
|
|||
/// The number of the merged PR that put commit `sha` on `repo`'s base branch.
|
||||
///
|
||||
/// # Errors
|
||||
/// Absent core token, malformed repo, no such PR, or transport/API failure.
|
||||
/// No read token, malformed repo, no such PR, or transport/API failure.
|
||||
pub async fn merged_pr_for_commit(repo: &str, sha: &str) -> Result<u64> {
|
||||
let token = core_token().context("forge core token absent")?;
|
||||
let reader = reader_token().await.context("no forge read token")?;
|
||||
let (owner, name) = repo
|
||||
.split_once('/')
|
||||
.with_context(|| format!("forge repo `{repo}` is not owner/name"))?;
|
||||
let pull = api(&token)?
|
||||
let pull = api(reader.value())?
|
||||
.repo_get_commit_pull_request(owner, name, sha)
|
||||
.await
|
||||
.context("GET pull request of commit")?;
|
||||
|
|
|
|||
159
hive-c0re/src/forge/reader.rs
Normal file
159
hive-c0re/src/forge/reader.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! The credential this hive reads the forge with: its own read-only account,
|
||||
//! whose token `swarm-controller` mints into the store at
|
||||
//! `swarm/hives/<hive>/forge/token`, and the core token file where the store
|
||||
//! holds none.
|
||||
//!
|
||||
//! The file is the fallback so a hive deployed before the controller mints its
|
||||
//! token keeps the reads it had: the forge host holds the file, no other hive
|
||||
//! does.
|
||||
|
||||
use super::{basic_auth_header, core_token};
|
||||
|
||||
/// The basic-auth user [`ReaderToken::auth_header`] names for the core token.
|
||||
const CORE_LOGIN: &str = "core";
|
||||
|
||||
/// A forge token and the account it belongs to.
|
||||
///
|
||||
/// No `Debug` derive: `value` is a live forge credential.
|
||||
pub struct ReaderToken {
|
||||
login: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ReaderToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ReaderToken")
|
||||
.field("login", &self.login)
|
||||
.field("value", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReaderToken {
|
||||
/// The token itself, for an API client.
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
/// The `http.extraHeader` value for git; see [`basic_auth_header`].
|
||||
pub fn auth_header(&self) -> String {
|
||||
basic_auth_header(&self.login, &self.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// The token to read the forge with, or `None` when this hive has neither.
|
||||
///
|
||||
/// 🩸 Logs the store path and which source won, never a value.
|
||||
pub async fn reader_token() -> Option<ReaderToken> {
|
||||
pick(stored_reader_token().await, core_token())
|
||||
}
|
||||
|
||||
/// The store's token wins over the core file's.
|
||||
fn pick(stored: Option<ReaderToken>, core: Option<String>) -> Option<ReaderToken> {
|
||||
let picked = stored.or_else(|| {
|
||||
core.map(|value| ReaderToken {
|
||||
login: CORE_LOGIN.to_owned(),
|
||||
value,
|
||||
})
|
||||
});
|
||||
if let Some(t) = &picked {
|
||||
tracing::debug!(login = %t.login, "forge: reading as this account");
|
||||
} else {
|
||||
tracing::debug!("forge: no read token in the store or the core token file");
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
/// This hive's token from the store, under the hive's own identity.
|
||||
///
|
||||
/// `None`, never an error, for every way it can come up empty — no hive name,
|
||||
/// no `BAO_*` identity, an unreachable store, nothing at the path — the same
|
||||
/// as `matrix::stored_sender_token`, and for the same reason: each sends the
|
||||
/// caller to the fallback.
|
||||
async fn stored_reader_token() -> Option<ReaderToken> {
|
||||
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
||||
.ok()
|
||||
.filter(|h| !h.is_empty())?;
|
||||
let path = match swarm_secret_client::forge::hive_token_path(&hive) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "forge: this hive's name does not form a store path");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "forge: no swarm secret store to read the hive token from");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match store
|
||||
.read::<swarm_secret_client::forge::Credential>(&path)
|
||||
.await
|
||||
{
|
||||
Ok(credential) if !credential.value.trim().is_empty() => Some(ReaderToken {
|
||||
login: swarm_secret_client::forge::hive_login(&hive),
|
||||
value: credential.value.trim().to_owned(),
|
||||
}),
|
||||
Ok(_) => {
|
||||
tracing::warn!(%path, "forge: the stored hive token is empty");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(%path, error = %e, "forge: no hive token in the store");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stored() -> ReaderToken {
|
||||
ReaderToken {
|
||||
login: "hive-alpha".to_owned(),
|
||||
value: "from-the-store".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_store_wins_over_the_core_file() {
|
||||
let t = pick(Some(stored()), Some("from-the-file".to_owned())).expect("a token");
|
||||
assert_eq!(t.value(), "from-the-store");
|
||||
assert_eq!(t.login, "hive-alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_core_file_is_the_fallback() {
|
||||
let t = pick(None, Some("from-the-file".to_owned())).expect("a token");
|
||||
assert_eq!(t.value(), "from-the-file");
|
||||
assert_eq!(t.login, CORE_LOGIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neither_is_none() {
|
||||
assert!(pick(None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_names_the_tokens_own_account() {
|
||||
use base64::Engine as _;
|
||||
let header = stored().auth_header();
|
||||
let encoded = header
|
||||
.strip_prefix("Authorization: Basic ")
|
||||
.expect("a basic auth header");
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.expect("base64");
|
||||
assert_eq!(decoded, b"hive-alpha:from-the-store");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_the_value() {
|
||||
let shown = format!("{:?}", stored());
|
||||
assert!(!shown.contains("from-the-store"), "{shown}");
|
||||
assert!(shown.contains("hive-alpha"), "{shown}");
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ use anyhow::{Context, Result};
|
|||
|
||||
use hive_host_sock::{HostResponse, ReconcileDirection};
|
||||
|
||||
use super::{CONFIG_ORG, core_auth_header, core_token, forge_git_url, is_present};
|
||||
use super::{CONFIG_ORG, forge_git_url, reader_token};
|
||||
|
||||
/// Scratch ref the forge `main` is fetched into — outside the normal
|
||||
/// branch/tag namespace so it never collides with real refs.
|
||||
|
|
@ -27,11 +27,11 @@ const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main";
|
|||
/// ref (read-only, no working-tree change) and return the applied dir.
|
||||
/// Also how a swarm deploy of a merged commit gets that commit's objects.
|
||||
pub async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
|
||||
if !is_present().await {
|
||||
anyhow::bail!("forge is not running");
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
anyhow::bail!("forge core token not available");
|
||||
let Some(reader) = reader_token().await else {
|
||||
anyhow::bail!(
|
||||
"no forge read token: the swarm store holds none for this hive and there is no core \
|
||||
token file"
|
||||
);
|
||||
};
|
||||
let dir = crate::paths::applied_dir(agent);
|
||||
if !dir.join(".git").exists() {
|
||||
|
|
@ -41,7 +41,7 @@ pub async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
|
|||
crate::lifecycle::git_authed(
|
||||
&dir,
|
||||
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
|
||||
&core_auth_header(&token),
|
||||
&reader.auth_header(),
|
||||
)
|
||||
.await
|
||||
.context("fetch forge config main (does forge main exist yet?)")?;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use crate::coordinator::Coordinator;
|
|||
|
||||
use super::{
|
||||
CONFIG_ORG, SHARED_DOCS_REPO, SHARED_ORG, api, core_auth_header, core_token, forge_git_url,
|
||||
forge_http_base, is_present,
|
||||
forge_http_base, is_present, reader_token,
|
||||
};
|
||||
|
||||
/// Creation options for an empty repo defaulting to `main`.
|
||||
|
|
@ -223,17 +223,14 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> {
|
|||
/// snapshot. A config deployed through hive-c0re without a forge merge never
|
||||
/// reaches it.
|
||||
///
|
||||
/// Best-effort: returns `false` (not an error) rather than bailing when the
|
||||
/// forge is absent, the core token isn't minted yet, `applied` already has a
|
||||
/// `.git` (nothing to do), or any git step fails — callers fall back to a
|
||||
/// less-current source or the original hard error. Only `true` once `applied` is
|
||||
/// Best-effort: returns `false` (not an error) rather than bailing when there
|
||||
/// is no [`reader_token`], `applied` already has a `.git` (nothing to do), or
|
||||
/// any git step fails — callers fall back to a less-current source or the
|
||||
/// original hard error. Only `true` once `applied` is
|
||||
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own
|
||||
/// `setup_applied` seed shape exactly.
|
||||
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
||||
if !is_present().await {
|
||||
return false;
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
let Some(reader) = reader_token().await else {
|
||||
return false;
|
||||
};
|
||||
let applied = crate::paths::applied_dir(name);
|
||||
|
|
@ -249,7 +246,7 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
|||
return false;
|
||||
}
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
||||
let auth = core_auth_header(&token);
|
||||
let auth = reader.auth_header();
|
||||
let out = crate::lifecycle::git_command_authed(&auth)
|
||||
.current_dir(&applied)
|
||||
.args([
|
||||
|
|
@ -298,14 +295,11 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool {
|
|||
/// ancestry-gated advance for a reviewed PR merge, which this is not.
|
||||
///
|
||||
/// Best-effort, same shape as [`fetch_config_main_into_applied`]: returns
|
||||
/// `false` (not an error) when the forge is absent, the core token isn't
|
||||
/// minted, `applied` has no `.git` yet (nothing to fast-forward — reseed
|
||||
/// instead), or any git step fails.
|
||||
/// `false` (not an error) when there is no [`reader_token`], `applied` has
|
||||
/// no `.git` yet (nothing to fast-forward — reseed instead), or any git step
|
||||
/// fails.
|
||||
pub async fn fast_forward_applied_main(name: &str) -> bool {
|
||||
if !is_present().await {
|
||||
return false;
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
let Some(reader) = reader_token().await else {
|
||||
return false;
|
||||
};
|
||||
let applied = crate::paths::applied_dir(name);
|
||||
|
|
@ -313,7 +307,7 @@ pub async fn fast_forward_applied_main(name: &str) -> bool {
|
|||
return false;
|
||||
}
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
||||
let auth = core_auth_header(&token);
|
||||
let auth = reader.auth_header();
|
||||
let out = crate::lifecycle::git_command_authed(&auth)
|
||||
.current_dir(&applied)
|
||||
.args(["fetch", "--no-tags", &url, "refs/heads/main"])
|
||||
|
|
@ -356,8 +350,8 @@ pub async fn fast_forward_applied_main(name: &str) -> bool {
|
|||
/// is the fallback, not the default.
|
||||
///
|
||||
/// Best-effort like [`fetch_config_main_into_applied`], and `false` is an
|
||||
/// ordinary answer rather than an error: the forge is absent, the core token
|
||||
/// is not minted yet, or `agent-configs/<name>` has no `main` — the last of
|
||||
/// ordinary answer rather than an error: there is no [`reader_token`], the
|
||||
/// forge is unreachable, or `agent-configs/<name>` has no `main` — the last of
|
||||
/// which is the *normal* case right after `swarm-controller` creates the
|
||||
/// agent, before it has pushed anything into the repo it just made.
|
||||
///
|
||||
|
|
@ -366,17 +360,14 @@ pub async fn fast_forward_applied_main(name: &str) -> bool {
|
|||
/// `.git` behind either — so the caller's "does this repo already exist"
|
||||
/// check reads exactly the same before and after a failed attempt.
|
||||
pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool {
|
||||
if !is_present().await {
|
||||
return false;
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
let Some(reader) = reader_token().await else {
|
||||
return false;
|
||||
};
|
||||
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
|
||||
// `--branch main` fails outright against a repo with no commits, so an
|
||||
// empty repo reads as "nothing to take" instead of cloning to an unborn
|
||||
// HEAD that would then look like a seeded checkout.
|
||||
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
|
||||
let out = crate::lifecycle::git_command_authed(&reader.auth_header())
|
||||
.args([
|
||||
"clone",
|
||||
"--branch",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ use super::git::{
|
|||
/// there's genuinely nothing to clone yet — `swarm-controller` creates
|
||||
/// `agent-configs/<name>` before telling a hive to deploy, but hasn't
|
||||
/// necessarily pushed `main` to it yet, or the forge is unreachable, or
|
||||
/// the core token isn't minted.
|
||||
/// this hive has no forge read token.
|
||||
///
|
||||
/// **Seeding is the whole of hive-c0re's write.** Changes to the repo
|
||||
/// arrive as PRs from a clone, via the forge, like any other code
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::forge::{core_auth_header, forge_git_url};
|
||||
use crate::forge::forge_git_url;
|
||||
|
||||
pub const ORG: &str = "internal";
|
||||
pub const REPO: &str = "knowledge";
|
||||
|
|
@ -38,14 +38,12 @@ pub use crate::paths::KNOWLEDGE_DIR as LOCAL_DIR;
|
|||
pub const CONTAINER_MOUNT: &str = "/knowledge";
|
||||
|
||||
/// 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.
|
||||
/// repository, authenticated with `auth` (an `http.extraHeader` value) when
|
||||
/// given. Idempotent — skips if `LOCAL_DIR/.git` exists.
|
||||
///
|
||||
/// The swarm controller seeds a README into a freshly created repo. A clone
|
||||
/// taken before that lands is empty; the next [`pull`] fills it.
|
||||
///
|
||||
/// Called once at hive-c0re startup after `forge::ensure_all`.
|
||||
pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
|
||||
async fn ensure_local_clone(auth: Option<&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}");
|
||||
|
|
@ -53,7 +51,11 @@ pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
|
|||
}
|
||||
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))
|
||||
let mut cmd = match auth {
|
||||
Some(header) => crate::lifecycle::git_command_authed(header),
|
||||
None => crate::lifecycle::git_command(),
|
||||
};
|
||||
let out = cmd
|
||||
.args(["clone", &url, LOCAL_DIR])
|
||||
.output()
|
||||
.await
|
||||
|
|
@ -86,15 +88,21 @@ async fn head_sha() -> Option<String> {
|
|||
/// `--ff-only` so a force-push to the knowledge repo never wedges the local
|
||||
/// copy silently.
|
||||
///
|
||||
/// When there is no clone yet, takes one instead ([`ensure_local_clone`]),
|
||||
/// so every hive gets `/knowledge` from its first pull.
|
||||
///
|
||||
/// 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.
|
||||
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.
|
||||
// The repo is public (the swarm controller makes it so), so an
|
||||
// unauthenticated clone or 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::reader_token().await.map(|t| t.auth_header());
|
||||
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");
|
||||
return ensure_local_clone(auth.as_deref()).await;
|
||||
}
|
||||
// Rewrite any credentialed `origin` left by an older clone, which spliced
|
||||
// `core:<token>@` into the URL and persisted it in `.git/config` — the one
|
||||
|
|
@ -129,10 +137,6 @@ pub async fn pull(coord: &Coordinator) -> Result<()> {
|
|||
.await;
|
||||
|
||||
let before = head_sha().await;
|
||||
// The repo is public (the swarm controller 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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue