Compare commits
3 changed files with 17 additions and 212 deletions
|
|
@ -178,21 +178,15 @@ impl Coordinator {
|
||||||
/// Drop a system message into the given agent's inbox. Wakes the
|
/// Drop a system message into the given agent's inbox. Wakes the
|
||||||
/// turn loop with a "you were just (re)started" hint — operator
|
/// turn loop with a "you were just (re)started" hint — operator
|
||||||
/// caused the transition, agent picks up where it left off
|
/// caused the transition, agent picks up where it left off
|
||||||
/// (notes are in the bind-mounted state dir, last turn is in
|
/// (notes are in /state/, last turn is in --continue's session).
|
||||||
/// --continue's session). Best-effort; broker errors are logged
|
/// Best-effort; broker errors are logged but don't propagate.
|
||||||
/// but don't propagate.
|
|
||||||
pub fn kick_agent(&self, name: &str, reason: &str) {
|
pub fn kick_agent(&self, name: &str, reason: &str) {
|
||||||
// Sub-agents bind their state at /agents/<name>/state. The
|
|
||||||
// manager has both /state (legacy mount) and /agents
|
|
||||||
// bind-mounted, so /agents/<name>/state resolves there too —
|
|
||||||
// use that uniformly so the wake message has one canonical
|
|
||||||
// path that works everywhere.
|
|
||||||
let body = format!(
|
let body = format!(
|
||||||
"{reason}\n\nYou were just (re)started by the operator. \
|
"{reason}\n\nYou were just (re)started by the operator. \
|
||||||
If you were mid-task, check `/agents/{name}/state/` for \
|
If you were mid-task, check `/state/` for your notes \
|
||||||
your notes and pick up where you left off. claude's \
|
and pick up where you left off. claude's `--continue` \
|
||||||
`--continue` session is intact, so prior context is \
|
session is intact, so prior context is still in your \
|
||||||
still in your window."
|
window."
|
||||||
);
|
);
|
||||||
if let Err(e) = self.broker.send(&hive_sh4re::Message {
|
if let Err(e) = self.broker.send(&hive_sh4re::Message {
|
||||||
from: hive_sh4re::SYSTEM_SENDER.to_owned(),
|
from: hive_sh4re::SYSTEM_SENDER.to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,7 @@ use tokio::process::Command;
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
const FORGE_CONTAINER: &str = "hive-forge";
|
const FORGE_CONTAINER: &str = "hive-forge";
|
||||||
const FORGE_HTTP: &str = "http://localhost:3000";
|
|
||||||
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 to push the meta repo + drive admin API calls (org
|
|
||||||
/// creation, future webhook setup, etc.). Root-only.
|
|
||||||
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
|
|
||||||
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives
|
|
||||||
/// at `core/meta` (the `core` user's own namespace — no org needed);
|
|
||||||
/// `agents` holds per-agent applied config repos so they're
|
|
||||||
/// visible/grouped together and access can be granted org-wide
|
|
||||||
/// (RO membership for the future shared docs/skills repo).
|
|
||||||
const SEEDED_ORGS: &[&str] = &["agents"];
|
|
||||||
/// Forgejo scopes the agent's token gets. Broad-but-not-admin: every
|
/// Forgejo scopes the agent's token gets. Broad-but-not-admin: every
|
||||||
/// repo / PR / issue thing an agent needs day-to-day, no admin
|
/// repo / PR / issue thing an agent needs day-to-day, no admin
|
||||||
/// surface.
|
/// surface.
|
||||||
|
|
@ -122,23 +111,18 @@ fn extract_token(output: &str) -> Option<String> {
|
||||||
|
|
||||||
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
||||||
/// returns a "user already exists" error which we treat as success.
|
/// returns a "user already exists" error which we treat as success.
|
||||||
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
async fn ensure_user_exists(name: &str) -> Result<()> {
|
||||||
/// `core` user that drives the API.
|
let result = forge_admin(&[
|
||||||
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
|
|
||||||
let mut args = vec![
|
|
||||||
"user",
|
"user",
|
||||||
"create",
|
"create",
|
||||||
"--username",
|
"--username",
|
||||||
name,
|
name,
|
||||||
"--email",
|
"--email",
|
||||||
];
|
&format!("{name}@hive.local"),
|
||||||
let email = format!("{name}@hive.local");
|
"--random-password",
|
||||||
args.push(&email);
|
"--must-change-password=false",
|
||||||
args.extend(["--random-password", "--must-change-password=false"]);
|
])
|
||||||
if admin {
|
.await;
|
||||||
args.push("--admin");
|
|
||||||
}
|
|
||||||
let result = forge_admin(&args).await;
|
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
tracing::info!(%name, "forge: created user");
|
tracing::info!(%name, "forge: created user");
|
||||||
|
|
@ -207,183 +191,18 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
ensure_user_exists(name, false).await?;
|
ensure_user_exists(name).await?;
|
||||||
mint_and_persist_token(name, &path).await
|
mint_and_persist_token(name, &path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the bootstrap `core` admin user + a token at
|
|
||||||
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
|
|
||||||
/// API calls (org creation now, meta-repo push later). Returns the
|
|
||||||
/// token. Idempotent: skips creation when user exists, skips token
|
|
||||||
/// when the file is present.
|
|
||||||
async fn ensure_core_user_and_token() -> Result<String> {
|
|
||||||
let path = std::path::Path::new(CORE_TOKEN_PATH);
|
|
||||||
if let Ok(existing) = std::fs::read_to_string(path) {
|
|
||||||
let trimmed = existing.trim().to_owned();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
return Ok(trimmed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ensure_user_exists("core", true).await?;
|
|
||||||
mint_and_persist_token("core", path).await?;
|
|
||||||
let raw = std::fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
|
||||||
Ok(raw.trim().to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST `/api/v1/user/repos` to create a repo in the authenticated
|
|
||||||
/// user's own namespace. `token` belongs to the user we want the
|
|
||||||
/// repo owned by (we use `core`'s token for `core/meta`). Idempotent:
|
|
||||||
/// HTTP 409 ("repository already exists") is treated as success.
|
|
||||||
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
|
||||||
let body = format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#);
|
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/user/repos");
|
|
||||||
let out = Command::new("curl")
|
|
||||||
.args([
|
|
||||||
"-sS",
|
|
||||||
"-o",
|
|
||||||
"/dev/null",
|
|
||||||
"-w",
|
|
||||||
"%{http_code}",
|
|
||||||
"-X",
|
|
||||||
"POST",
|
|
||||||
"-H",
|
|
||||||
"Content-Type: application/json",
|
|
||||||
"-H",
|
|
||||||
&format!("Authorization: token {token}"),
|
|
||||||
"-d",
|
|
||||||
&body,
|
|
||||||
&url,
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("invoke curl POST /api/v1/user/repos")?;
|
|
||||||
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
||||||
match code.as_str() {
|
|
||||||
"201" => {
|
|
||||||
tracing::info!(%name, "forge: created repo");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
"409" | "422" => {
|
|
||||||
tracing::debug!(%name, "forge: repo already exists");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
other => anyhow::bail!(
|
|
||||||
"POST /api/v1/user/repos name={name} returned HTTP {other}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the persisted core token, or None when the forge isn't
|
|
||||||
/// seeded yet. Cheap — just a file read.
|
|
||||||
pub fn core_token() -> Option<String> {
|
|
||||||
std::fs::read_to_string(CORE_TOKEN_PATH)
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_owned())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push `dir` (the meta repo) to `core/meta` on the local forge.
|
|
||||||
/// Best-effort: returns Err which callers log + ignore. No-op when
|
|
||||||
/// the core token isn't present (forge not enabled).
|
|
||||||
pub async fn push_meta(dir: &Path) -> Result<()> {
|
|
||||||
let Some(token) = core_token() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
// Token-in-URL push. Forgejo accepts `oauth2:<token>` or just
|
|
||||||
// any-username:<token>; using `core` matches the owner so the
|
|
||||||
// remote name is self-describing.
|
|
||||||
let url = format!("http://core:{token}@localhost:3000/core/meta.git");
|
|
||||||
let out = Command::new("git")
|
|
||||||
.current_dir(dir)
|
|
||||||
.args(["push", "--force", &url, "HEAD:main"])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("invoke git push core/meta")?;
|
|
||||||
if !out.status.success() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"git push core/meta failed ({}): {}",
|
|
||||||
out.status,
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tracing::info!("forge: pushed meta to core/meta");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
|
|
||||||
/// HTTP 422 ("user already exists") is treated as success.
|
|
||||||
async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
|
|
||||||
let body = format!(r#"{{"username":"{name}"}}"#);
|
|
||||||
let url = format!("{FORGE_HTTP}/api/v1/orgs");
|
|
||||||
let out = Command::new("curl")
|
|
||||||
.args([
|
|
||||||
"-sS",
|
|
||||||
"-o",
|
|
||||||
"/dev/null",
|
|
||||||
"-w",
|
|
||||||
"%{http_code}",
|
|
||||||
"-X",
|
|
||||||
"POST",
|
|
||||||
"-H",
|
|
||||||
"Content-Type: application/json",
|
|
||||||
"-H",
|
|
||||||
&format!("Authorization: token {admin_token}"),
|
|
||||||
"-d",
|
|
||||||
&body,
|
|
||||||
&url,
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("invoke curl POST /api/v1/orgs")?;
|
|
||||||
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
||||||
match code.as_str() {
|
|
||||||
"201" => {
|
|
||||||
tracing::info!(%name, "forge: created org");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
"422" | "409" => {
|
|
||||||
tracing::debug!(%name, "forge: org already exists");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
other => anyhow::bail!(
|
|
||||||
"POST /api/v1/orgs name={name} returned HTTP {other}: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sweep every existing container (manager + sub-agents) and ensure
|
/// Sweep every existing container (manager + sub-agents) and ensure
|
||||||
/// each has a forgejo user + token. Also seeds the `core` admin
|
/// each has a forgejo user + token. Called once at hive-c0re
|
||||||
/// user (hive-c0re's own identity for pushing the meta repo + driving
|
/// startup. Per-agent failures are logged but don't abort the sweep.
|
||||||
/// the API) and the `core` / `agents` orgs the system pushes into.
|
|
||||||
/// Called once at hive-c0re startup. Per-step failures are logged
|
|
||||||
/// but don't abort the sweep.
|
|
||||||
pub async fn ensure_all() {
|
pub async fn ensure_all() {
|
||||||
if !is_present().await {
|
if !is_present().await {
|
||||||
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
|
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let core_token = match ensure_core_user_and_token().await {
|
|
||||||
Ok(t) => Some(t),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Some(token) = core_token.as_deref() {
|
|
||||||
for org in SEEDED_ORGS {
|
|
||||||
if let Err(e) = ensure_org(org, token).await {
|
|
||||||
tracing::warn!(%org, error = ?e, "forge: ensure_org failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Meta repo lives at core/meta — pushed from git_commit in
|
|
||||||
// meta.rs on every deploy/lock-update. Make sure it exists
|
|
||||||
// before the first push hits a 404.
|
|
||||||
if let Err(e) = ensure_repo("meta", token).await {
|
|
||||||
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Ok(containers) = crate::lifecycle::list().await else {
|
let Ok(containers) = crate::lifecycle::list().await else {
|
||||||
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
|
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -342,15 +342,7 @@ async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||||
message,
|
message,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
// Best-effort mirror to the bundled forge. No-op when the forge
|
|
||||||
// isn't seeded (no core token on disk); push failures log a warn
|
|
||||||
// but don't bubble up — a missing mirror shouldn't fail an
|
|
||||||
// otherwise successful deploy.
|
|
||||||
if let Err(e) = crate::forge::push_meta(dir).await {
|
|
||||||
tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn nix(dir: &Path, args: &[&str]) -> Result<()> {
|
async fn nix(dir: &Path, args: &[&str]) -> Result<()> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue