feat(#2302): thread &Ident through agent path builders
This commit is contained in:
parent
1286029947
commit
bf644cc126
23 changed files with 168 additions and 85 deletions
|
|
@ -41,9 +41,12 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
|||
// Sub-second git seed + forge-remote wire. Routing through
|
||||
// the queue would surface a queue card that's gone before
|
||||
// the operator's eyes refocus. Run inline.
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
|
||||
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
|
||||
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
|
||||
let agent = hive_host_sock::Ident::parse(&approval.agent).map_err(|e| {
|
||||
anyhow::anyhow!("approval {} has invalid agent name: {e}", approval.id)
|
||||
})?;
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(&agent);
|
||||
let claude_dir = Coordinator::agent_claude_dir(&agent);
|
||||
let notes_dir = Coordinator::agent_notes_dir(&agent);
|
||||
run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await
|
||||
}
|
||||
ApprovalKind::UpdateMetaInputs => {
|
||||
|
|
@ -798,10 +801,16 @@ pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Resul
|
|||
if let Err(e) = crate::priv_client::delete_agent_subvolume(name).await {
|
||||
tracing::warn!(error = ?e, %name, "purge: delete state subvolume failed");
|
||||
}
|
||||
for dir in [
|
||||
crate::paths::agent_state_dir(name),
|
||||
crate::paths::applied_dir(name),
|
||||
] {
|
||||
// A malformed name can't have a persistent state tree (the state dir
|
||||
// is only ever created under a validated Ident), so its removal is a
|
||||
// no-op — skip the state-dir sweep and just clear the applied dir.
|
||||
let state_dir = hive_host_sock::Ident::parse(name)
|
||||
.ok()
|
||||
.map(|id| crate::paths::agent_state_dir(&id));
|
||||
for dir in state_dir
|
||||
.into_iter()
|
||||
.chain([crate::paths::applied_dir(name)])
|
||||
{
|
||||
if dir.exists()
|
||||
&& let Err(e) = std::fs::remove_dir_all(&dir)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,20 +64,27 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
|||
let topology = crate::topology::read();
|
||||
let mut out = Vec::new();
|
||||
for c in &raw {
|
||||
let Some(logical) = c.strip_prefix(AGENT_PREFIX).map(str::to_owned) else {
|
||||
let Some(logical) = c.strip_prefix(AGENT_PREFIX) else {
|
||||
continue;
|
||||
};
|
||||
// Parse the nspawn machine suffix into an Ident once at this
|
||||
// enumeration origin; a suffix that isn't a valid ident isn't one
|
||||
// of our agents, so skip it.
|
||||
let Ok(logical) = hive_host_sock::Ident::parse(logical) else {
|
||||
continue;
|
||||
};
|
||||
let deployed_full = locked
|
||||
.get(&format!("agent-{logical}"))
|
||||
.map(std::string::String::as_str);
|
||||
let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full).await;
|
||||
let needs_update =
|
||||
crate::auto_update::agent_config_pending(logical.as_str(), deployed_full).await;
|
||||
let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned());
|
||||
let pending_reminders = coord
|
||||
.broker
|
||||
.count_pending_reminders_for(logical.as_str())
|
||||
.unwrap_or(0);
|
||||
let parent = topology.get(&logical).cloned().flatten();
|
||||
let running = lifecycle::is_running(&logical).await;
|
||||
let parent = topology.get(logical.as_str()).cloned().flatten();
|
||||
let running = lifecycle::is_running(logical.as_str()).await;
|
||||
// needs_login fires when EITHER the claude session dir is missing
|
||||
// (boot-time / fresh container) OR the harness wrote the auth-failed
|
||||
// sentinel because a turn hit 401. Cleared for stopped containers —
|
||||
|
|
@ -94,10 +101,10 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
|||
None
|
||||
};
|
||||
out.push(ContainerView {
|
||||
port: lifecycle::agent_web_port(&logical),
|
||||
port: lifecycle::agent_web_port(logical.as_str()),
|
||||
running,
|
||||
container: c.clone(),
|
||||
name: logical,
|
||||
name: logical.into_string(),
|
||||
needs_update,
|
||||
needs_login,
|
||||
deployed_sha,
|
||||
|
|
@ -126,7 +133,7 @@ pub fn claude_has_session(dir: &Path) -> bool {
|
|||
/// the consolidated `hyperhive-harness.json`. Falls back to the legacy
|
||||
/// individual sentinel files written by older harness builds so in-place
|
||||
/// upgrades don't lose state during the transition window.
|
||||
fn read_harness_flags(name: &str) -> (bool, bool) {
|
||||
fn read_harness_flags(name: &hive_host_sock::Ident) -> (bool, bool) {
|
||||
let dir = Coordinator::agent_notes_dir(name);
|
||||
if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json"))
|
||||
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
|
|
@ -147,7 +154,7 @@ fn read_harness_flags(name: &str) -> (bool, bool) {
|
|||
(rate_limited, needs_login)
|
||||
}
|
||||
|
||||
fn auth_failed_sentinel(name: &str) -> bool {
|
||||
fn auth_failed_sentinel(name: &hive_host_sock::Ident) -> bool {
|
||||
read_harness_flags(name).1
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +165,7 @@ fn auth_failed_sentinel(name: &str) -> bool {
|
|||
/// NB: callers building `AgentMeta` for a *stopped* container should
|
||||
/// clear the result — the on-disk status is a stale snapshot from
|
||||
/// before the stop. Use `read_agent_status_live` for that.
|
||||
pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
|
||||
pub fn read_agent_status(name: &hive_host_sock::Ident) -> (Option<String>, Option<i64>) {
|
||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
|
||||
let meta = std::fs::metadata(&path).ok();
|
||||
// Read at most STATUS_MAX_CHARS * 4 + 2 bytes: 4 is the max UTF-8 byte
|
||||
|
|
@ -198,8 +205,10 @@ pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
|
|||
///
|
||||
/// Returned tuple is `(status_text, status_set_at, running)`.
|
||||
/// `name` is the logical agent name (same as the broker recipient).
|
||||
pub async fn read_agent_status_live(name: &str) -> (Option<String>, Option<i64>, bool) {
|
||||
if !lifecycle::is_running(name).await {
|
||||
pub async fn read_agent_status_live(
|
||||
name: &hive_host_sock::Ident,
|
||||
) -> (Option<String>, Option<i64>, bool) {
|
||||
if !lifecycle::is_running(name.as_str()).await {
|
||||
return (None, None, false);
|
||||
}
|
||||
let (text, set_at) = read_agent_status(name);
|
||||
|
|
@ -212,7 +221,7 @@ pub async fn read_agent_status_live(name: &str) -> (Option<String>, Option<i64>,
|
|||
/// so it always reflects the resolved priority (nix config > runtime
|
||||
/// override > default). Returns `None` when the field is absent or the
|
||||
/// harness has not yet started a turn.
|
||||
fn read_active_model(name: &str) -> Option<String> {
|
||||
fn read_active_model(name: &hive_host_sock::Ident) -> Option<String> {
|
||||
let path = Coordinator::agent_notes_dir(name).join("hyperhive-harness.json");
|
||||
let raw = std::fs::read_to_string(path).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
|
|
|
|||
|
|
@ -549,12 +549,18 @@ impl Coordinator {
|
|||
/// created. All other paths are derived statically from `name`.
|
||||
#[must_use]
|
||||
pub fn agent_paths(name: &str, agent_dir: PathBuf) -> AgentPaths {
|
||||
// `name` is validated upstream (spawn-approval / enqueue gate / the
|
||||
// MANAGER_NAME const), so an invalid ident here is a construction
|
||||
// bug. This is the step-3 boundary between the Ident-threaded path
|
||||
// builders and the job_queue layer (threaded post hive-jobq cutover).
|
||||
let name = hive_host_sock::Ident::parse(name)
|
||||
.expect("agent_paths: name must be a valid ident (validated at spawn/enqueue)");
|
||||
AgentPaths {
|
||||
agent: agent_dir,
|
||||
proposed: Self::agent_proposed_dir(name),
|
||||
applied: crate::paths::applied_dir(name),
|
||||
claude: Self::agent_claude_dir(name),
|
||||
notes: Self::agent_notes_dir(name),
|
||||
proposed: Self::agent_proposed_dir(&name),
|
||||
applied: crate::paths::applied_dir(name.as_str()),
|
||||
claude: Self::agent_claude_dir(&name),
|
||||
notes: Self::agent_notes_dir(&name),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1442,7 +1448,7 @@ impl Coordinator {
|
|||
|
||||
/// Manager-editable proposed config repo. Bind-mounted into the manager
|
||||
/// container as `/agents/<name>/config/`.
|
||||
pub fn agent_proposed_dir(name: &str) -> PathBuf {
|
||||
pub fn agent_proposed_dir(name: &hive_host_sock::Ident) -> PathBuf {
|
||||
crate::paths::agent_state_dir(name).join("config")
|
||||
}
|
||||
|
||||
|
|
@ -1450,14 +1456,14 @@ impl Coordinator {
|
|||
/// container at `/root/.claude` so OAuth state survives container
|
||||
/// destroy/recreate. Each agent owns its own token lineage — sharing
|
||||
/// would break on the first refresh-token rotation.
|
||||
pub fn agent_claude_dir(name: &str) -> PathBuf {
|
||||
pub fn agent_claude_dir(name: &hive_host_sock::Ident) -> PathBuf {
|
||||
crate::paths::agent_state_dir(name).join("claude")
|
||||
}
|
||||
|
||||
/// Per-agent durable knowledge dir. Bind-mounted RW into the agent
|
||||
/// container at `/agents/{name}/state`. Survives destroy/recreate.
|
||||
/// Agent-visible — claude is told to write long-lived notes here.
|
||||
pub fn agent_notes_dir(name: &str) -> PathBuf {
|
||||
pub fn agent_notes_dir(name: &hive_host_sock::Ident) -> PathBuf {
|
||||
crate::paths::agent_state_dir(name).join("state")
|
||||
}
|
||||
|
||||
|
|
@ -1467,7 +1473,7 @@ impl Coordinator {
|
|||
/// `hyperhive-turn-stats.sqlite`, `hyperhive-model`) — kept separate
|
||||
/// from the agent-visible `state/` so claude's "my notes" view is
|
||||
/// uncluttered and the host vacuum has a clean sweep root.
|
||||
pub fn agent_harness_dir(name: &str) -> PathBuf {
|
||||
pub fn agent_harness_dir(name: &hive_host_sock::Ident) -> PathBuf {
|
||||
crate::paths::agent_state_dir(name).join("harness")
|
||||
}
|
||||
|
||||
|
|
@ -1477,14 +1483,14 @@ impl Coordinator {
|
|||
/// destroyed-but-kept tombstones; callers filter the latter by
|
||||
/// subtracting `lifecycle::list()`.
|
||||
#[must_use]
|
||||
pub fn kept_state_names() -> Vec<String> {
|
||||
pub fn kept_state_names() -> Vec<hive_host_sock::Ident> {
|
||||
let Ok(rd) = std::fs::read_dir(crate::paths::agents_root()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<String> = rd
|
||||
let mut out: Vec<hive_host_sock::Ident> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.filter_map(|e| hive_host_sock::Ident::parse(&e.file_name().into_string().ok()?).ok())
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
|
|
@ -1497,12 +1503,12 @@ impl Coordinator {
|
|||
/// apply-commit spawns the container. Distinct from tombstones,
|
||||
/// which have an applied repo from a prior deploy.
|
||||
#[must_use]
|
||||
pub fn pending_init_names() -> Vec<String> {
|
||||
pub fn pending_init_names() -> Vec<hive_host_sock::Ident> {
|
||||
Self::kept_state_names()
|
||||
.into_iter()
|
||||
.filter(|n| {
|
||||
Self::agent_proposed_dir(n).join(".git").exists()
|
||||
&& !crate::paths::applied_dir(n).join(".git").exists()
|
||||
&& !crate::paths::applied_dir(n.as_str()).join(".git").exists()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,12 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
|
|||
) {
|
||||
return true;
|
||||
}
|
||||
if Coordinator::agent_proposed_dir(&a.agent).exists() {
|
||||
let Ok(agent) = hive_host_sock::Ident::parse(&a.agent) else {
|
||||
let _ = coord.approvals.mark_failed(a.id, "invalid agent name");
|
||||
tracing::warn!(id = a.id, agent = %a.agent, "auto-failed approval with invalid agent name");
|
||||
return false;
|
||||
};
|
||||
if Coordinator::agent_proposed_dir(&agent).exists() {
|
||||
true
|
||||
} else {
|
||||
let note = "agent state dir missing";
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Respo
|
|||
let Ok(agent) = Ident::parse(agent) else {
|
||||
return error_response(&format!("extra-forges: invalid agent {agent:?}"));
|
||||
};
|
||||
let dir = Coordinator::agent_notes_dir(agent.as_str());
|
||||
let dir = Coordinator::agent_notes_dir(&agent);
|
||||
let mut forges = Vec::new();
|
||||
match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => {
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) ->
|
|||
return error_response(&format!("matrix-accounts: invalid agent name {agent:?}"));
|
||||
};
|
||||
|
||||
let dir = Coordinator::agent_notes_dir(agent.as_str());
|
||||
let dir = Coordinator::agent_notes_dir(&agent);
|
||||
let (snapshot, as_of_unix) = read_accounts_snapshot(&dir);
|
||||
let mut accounts = Vec::new();
|
||||
match std::fs::read_dir(&dir) {
|
||||
|
|
@ -313,7 +313,7 @@ pub(super) async fn get_github_account(Query(q): Query<GithubAccountQuery>) -> R
|
|||
let Ok(agent) = Ident::parse(agent) else {
|
||||
return error_response(&format!("github-account: invalid agent {agent:?}"));
|
||||
};
|
||||
let present = Coordinator::agent_notes_dir(agent.as_str())
|
||||
let present = Coordinator::agent_notes_dir(&agent)
|
||||
.join("github-token")
|
||||
.exists();
|
||||
axum::Json(GithubAccountStatus { present }).into_response()
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ pub(super) async fn get_stale_permissions(
|
|||
let kept: std::collections::HashSet<String> =
|
||||
crate::coordinator::Coordinator::kept_state_names()
|
||||
.into_iter()
|
||||
.map(hive_host_sock::Ident::into_string)
|
||||
.collect();
|
||||
// Known = live roster ∪ kept-state names.
|
||||
let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect();
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ pub(super) fn build_tombstone_views(
|
|||
.unwrap_or(0);
|
||||
let has_creds = claude_has_session(&Coordinator::agent_claude_dir(&name));
|
||||
TombstoneView {
|
||||
name,
|
||||
name: name.into_string(),
|
||||
state_bytes,
|
||||
last_seen,
|
||||
has_creds,
|
||||
|
|
@ -136,7 +136,7 @@ pub(super) async fn post_purge_tombstone(
|
|||
}
|
||||
let mut errors = Vec::new();
|
||||
for dir in [
|
||||
crate::paths::agent_state_dir(name.as_str()),
|
||||
crate::paths::agent_state_dir(&name),
|
||||
crate::paths::applied_dir(name.as_str()),
|
||||
] {
|
||||
if dir.exists()
|
||||
|
|
|
|||
|
|
@ -420,7 +420,12 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> {
|
|||
if !is_present().await {
|
||||
return Ok(());
|
||||
}
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||
// A malformed name has no proposed config repo (repos are only created
|
||||
// under a validated Ident), so there's nothing to wire — no-op.
|
||||
let Ok(agent) = hive_host_sock::Ident::parse(name) else {
|
||||
return Ok(());
|
||||
};
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(&agent);
|
||||
if !proposed_dir.join(".git").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,11 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
|
|||
/// state") for the rationale. Creates missing host-side directories so
|
||||
/// nspawn doesn't refuse to start; missing dirs are non-fatal.
|
||||
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
||||
let child_root = crate::paths::agent_state_dir(child);
|
||||
let Ok(child) = hive_host_sock::Ident::parse(child) else {
|
||||
tracing::warn!(%child, "skipping child bind: invalid agent name");
|
||||
return;
|
||||
};
|
||||
let child_root = crate::paths::agent_state_dir(&child);
|
||||
for sub in ["state", "harness", "config"] {
|
||||
let host = child_root.join(sub);
|
||||
let _ = std::fs::create_dir_all(&host);
|
||||
|
|
@ -197,7 +201,9 @@ async fn set_nspawn_flags(
|
|||
read_only: false,
|
||||
});
|
||||
}
|
||||
let own_config = crate::paths::agent_state_dir(agent_name).join("config");
|
||||
let agent_id = hive_host_sock::Ident::parse(agent_name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?;
|
||||
let own_config = crate::paths::agent_state_dir(&agent_id).join("config");
|
||||
std::fs::create_dir_all(&own_config)
|
||||
.with_context(|| format!("create {}", own_config.display()))?;
|
||||
binds.push(BindMount {
|
||||
|
|
|
|||
|
|
@ -212,7 +212,9 @@ pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
|
|||
/// brand-new agent on a btrfs host gets a real subvolume. Subvolume creation
|
||||
/// is privileged, so it's delegated to hive-priv.
|
||||
pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> {
|
||||
let root = crate::paths::agent_state_dir(name);
|
||||
let agent = hive_host_sock::Ident::parse(name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||
let root = crate::paths::agent_state_dir(&agent);
|
||||
if root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ pub fn admin_token_path() -> PathBuf {
|
|||
|
||||
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||
/// `/state/matrix-token` from inside the container).
|
||||
fn token_path(name: &str) -> PathBuf {
|
||||
fn token_path(name: &hive_host_sock::Ident) -> PathBuf {
|
||||
Coordinator::agent_notes_dir(name).join("matrix-token")
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ fn password_path(name: &str) -> PathBuf {
|
|||
/// move credentials from old deployments to the new location. Safe to
|
||||
/// call after `destroy --purge` — the path will simply not exist and
|
||||
/// the migration is a no-op.
|
||||
fn legacy_password_path(name: &str) -> PathBuf {
|
||||
fn legacy_password_path(name: &hive_host_sock::Ident) -> PathBuf {
|
||||
Coordinator::agent_notes_dir(name).join("matrix-password")
|
||||
}
|
||||
|
||||
|
|
@ -611,7 +611,9 @@ pub async fn ensure_user_for(
|
|||
register_token: &str,
|
||||
) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = token_path(name);
|
||||
let agent = hive_host_sock::Ident::parse(name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||
let path = token_path(&agent);
|
||||
if path.exists()
|
||||
&& let Ok(existing) = std::fs::read_to_string(&path)
|
||||
&& !existing.trim().is_empty()
|
||||
|
|
@ -623,7 +625,7 @@ pub async fn ensure_user_for(
|
|||
// One-time migration: move the password from the old location inside
|
||||
// agent_notes_dir (purgeable) to the new location outside it.
|
||||
let new_pw_path = password_path(name);
|
||||
let old_pw_path = legacy_password_path(name);
|
||||
let old_pw_path = legacy_password_path(&agent);
|
||||
if !new_pw_path.exists() && old_pw_path.exists() {
|
||||
if let Some(parent) = new_pw_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
|
|
|
|||
|
|
@ -122,7 +122,10 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
// only fills in missing entries. Idempotent; when nothing changed
|
||||
// the file isn't touched.
|
||||
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
||||
let pending = crate::coordinator::Coordinator::pending_init_names();
|
||||
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
|
||||
.into_iter()
|
||||
.map(hive_host_sock::Ident::into_string)
|
||||
.collect();
|
||||
crate::topology::reconcile(&agent_names, &pending)
|
||||
.with_context(|| format!("reconcile {}", crate::topology::topology_path().display()))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -68,11 +68,11 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
|
|||
tracing::debug!("migration: phase 1+2 (applied + proposed repos)");
|
||||
for name in &names {
|
||||
tracing::debug!(%name, "migration: applied+proposed");
|
||||
if let Err(e) = migrate_applied_repo(name).await {
|
||||
if let Err(e) = migrate_applied_repo(name.as_str()).await {
|
||||
tracing::warn!(%name, error = ?e, "migration: applied repo rewrite failed");
|
||||
}
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
||||
let proposed = lifecycle::setup_proposed(&proposed_dir, name);
|
||||
let proposed = lifecycle::setup_proposed(&proposed_dir, name.as_str());
|
||||
match tokio::time::timeout(GIT_TIMEOUT, proposed).await {
|
||||
Ok(Err(e)) => tracing::warn!(%name, error = ?e, "migration: setup_proposed failed"),
|
||||
Err(_) => {
|
||||
|
|
@ -108,8 +108,9 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
|
|||
// update activation triggers. Without this, crash_watch
|
||||
// would fire ContainerCrash for every agent here and the
|
||||
// manager would spuriously try to recover them.
|
||||
let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding);
|
||||
let result = repoint_container(name).await;
|
||||
let guard =
|
||||
coord.transient_guard(name.as_str(), crate::coordinator::TransientKind::Rebuilding);
|
||||
let result = repoint_container(name.as_str()).await;
|
||||
drop(guard);
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(%name, error = ?e, "migration: container repoint failed");
|
||||
|
|
@ -140,7 +141,7 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
|
|||
/// and into the sibling harness dir. Best-effort: logs warnings but never
|
||||
/// fails. Idempotent — each file is only moved if present at the old path
|
||||
/// and absent at the new path.
|
||||
fn migrate_harness_files(name: &str) {
|
||||
fn migrate_harness_files(name: &hive_host_sock::Ident) {
|
||||
const HARNESS_FILES: &[&str] = &[
|
||||
"hyperhive-events.sqlite",
|
||||
"hyperhive-turn-stats.sqlite",
|
||||
|
|
@ -266,16 +267,17 @@ async fn rename_manager_container(coord: &Arc<Coordinator>) {
|
|||
}
|
||||
}
|
||||
|
||||
async fn enumerate_agents() -> Vec<String> {
|
||||
async fn enumerate_agents() -> Vec<hive_host_sock::Ident> {
|
||||
let containers = lifecycle::list().await.unwrap_or_default();
|
||||
containers
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
if c == MANAGER_CONTAINER {
|
||||
Some(MANAGER_NAME.to_owned())
|
||||
let name = if c == MANAGER_CONTAINER {
|
||||
MANAGER_NAME
|
||||
} else {
|
||||
c.strip_prefix(AGENT_PREFIX).map(str::to_owned)
|
||||
}
|
||||
c.strip_prefix(AGENT_PREFIX)?
|
||||
};
|
||||
hive_host_sock::Ident::parse(name).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -351,8 +353,8 @@ async fn repoint_container(name: &str) -> Result<()> {
|
|||
/// Idempotent — skips when entry already present. Prevents a silent tool
|
||||
/// downgrade when upgrading from a build that relied on the manager-flavor
|
||||
/// fallback in `effective_tool_groups()`.
|
||||
fn backfill_manager_tool_groups(names: &[String]) {
|
||||
if !names.iter().any(|n| n == MANAGER_NAME) {
|
||||
fn backfill_manager_tool_groups(names: &[hive_host_sock::Ident]) {
|
||||
if !names.iter().any(|n| n.as_str() == MANAGER_NAME) {
|
||||
return; // ruth not deployed — nothing to backfill
|
||||
}
|
||||
let existing = tool_groups::groups_for(MANAGER_NAME);
|
||||
|
|
|
|||
|
|
@ -321,7 +321,9 @@ fn matrix_http_client() -> Result<reqwest::Client> {
|
|||
/// True when `name` has a state dir under the agents root, i.e. it's a
|
||||
/// managed agent rather than a bare (operator/human) matrix account.
|
||||
fn agent_exists(name: &str) -> Result<bool> {
|
||||
crate::paths::agent_state_dir(name)
|
||||
let name = hive_host_sock::Ident::parse(name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||
crate::paths::agent_state_dir(&name)
|
||||
.try_exists()
|
||||
.with_context(|| format!("check agent state dir for {name}"))
|
||||
}
|
||||
|
|
@ -354,7 +356,9 @@ async fn handle_matrix_create_user(name: &str, password: Option<&str>) -> Result
|
|||
crate::matrix::ensure_user_for(&client, name, ®ister_token)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
||||
let agent = hive_host_sock::Ident::parse(name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||
let path = Coordinator::agent_notes_dir(&agent).join("matrix-token");
|
||||
out.push(format!("matrix: provisioned agent user '{name}'"));
|
||||
out.push(format!("token persisted at: {}", path.display()));
|
||||
} else {
|
||||
|
|
@ -405,7 +409,9 @@ async fn handle_forge_create_user(name: &str, password: Option<&str>) -> Result<
|
|||
crate::forge::ensure_user_for(name)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
let path = Coordinator::agent_notes_dir(name).join("forge-token");
|
||||
let agent = hive_host_sock::Ident::parse(name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||
let path = Coordinator::agent_notes_dir(&agent).join("forge-token");
|
||||
out.push(format!("forge: provisioned agent user '{name}'"));
|
||||
out.push(format!("token persisted at: {}", path.display()));
|
||||
} else {
|
||||
|
|
@ -459,7 +465,10 @@ async fn handle_quota_limit(name: &str, limit: Option<u64>) -> Result<HostRespon
|
|||
async fn handle_quota_show(name: Option<&str>) -> Result<HostResponse> {
|
||||
let agents: Vec<String> = match name {
|
||||
Some(n) => vec![n.to_owned()],
|
||||
None => Coordinator::kept_state_names(),
|
||||
None => Coordinator::kept_state_names()
|
||||
.into_iter()
|
||||
.map(hive_host_sock::Ident::into_string)
|
||||
.collect(),
|
||||
};
|
||||
let mut rows = Vec::with_capacity(agents.len());
|
||||
for agent in &agents {
|
||||
|
|
|
|||
|
|
@ -195,7 +195,9 @@ pub(crate) fn submit_init_config(
|
|||
parent: Option<&str>,
|
||||
description: Option<String>,
|
||||
) -> anyhow::Result<i64> {
|
||||
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name);
|
||||
let agent = hive_host_sock::Ident::parse(name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent);
|
||||
if proposed_dir.join(".git").exists() {
|
||||
anyhow::bail!(
|
||||
"proposed config repo for '{name}' already exists at {} - \
|
||||
|
|
|
|||
|
|
@ -425,13 +425,16 @@ async fn handle_get_agent_meta(
|
|||
// the OS level. Validate it before any path is built. The `None` default
|
||||
// (`target == agent`) is the caller's own authenticated name, already
|
||||
// valid — but validating unconditionally is simplest and harmless.
|
||||
if let Err(reason) = hive_host_sock::Ident::parse(target) {
|
||||
return hive_agent_sock::Response::Err {
|
||||
message: format!("get_agent_meta: invalid agent name {target:?}: {reason}"),
|
||||
};
|
||||
}
|
||||
let target_id = match hive_host_sock::Ident::parse(target) {
|
||||
Ok(id) => id,
|
||||
Err(reason) => {
|
||||
return hive_agent_sock::Response::Err {
|
||||
message: format!("get_agent_meta: invalid agent name {target:?}: {reason}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
let (status_text, status_set_at, running) =
|
||||
crate::container_view::read_agent_status_live(target).await;
|
||||
crate::container_view::read_agent_status_live(&target_id).await;
|
||||
let (hive_name, swarm_name) = crate::container_view::hive_swarm_names();
|
||||
hive_agent_sock::Response::AgentMeta {
|
||||
name: target.to_owned(),
|
||||
|
|
@ -448,7 +451,7 @@ async fn handle_get_agent_meta(
|
|||
// another on a public matrix instance. The `Ident::parse` gate
|
||||
// above is what closes the real vector here (path traversal via `../`
|
||||
// in an agent-supplied name).
|
||||
matrix_accounts: read_agent_matrix_identities(target),
|
||||
matrix_accounts: read_agent_matrix_identities(&target_id),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -458,7 +461,7 @@ async fn handle_get_agent_meta(
|
|||
/// or the daemon not up yet) yields an empty list. The `MatrixIdentity`
|
||||
/// serde shape matches the snapshot entries; the snapshot's `live` field is
|
||||
/// ignored (only live accounts are written).
|
||||
fn read_agent_matrix_identities(agent: &str) -> Vec<hive_sh4re::MatrixIdentity> {
|
||||
fn read_agent_matrix_identities(agent: &hive_host_sock::Ident) -> Vec<hive_sh4re::MatrixIdentity> {
|
||||
let path = Coordinator::agent_notes_dir(agent).join("matrix-accounts.json");
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
|
|
@ -1112,8 +1115,12 @@ pub(crate) fn handle_send(
|
|||
};
|
||||
}
|
||||
if resolved != hive_sh4re::OPERATOR_RECIPIENT {
|
||||
let state_root = crate::paths::agent_state_dir(&resolved);
|
||||
if !state_root.exists() {
|
||||
// A name that doesn't parse as an Ident can't be a local agent, so
|
||||
// it collapses into the same "unknown recipient" error as a valid
|
||||
// name with no state dir.
|
||||
let exists = hive_host_sock::Ident::parse(&resolved)
|
||||
.is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
|
||||
if !exists {
|
||||
return Response::Err {
|
||||
message: format!(
|
||||
"send failed: unknown recipient `{resolved}` \
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ async fn du_bytes(path: &std::path::Path) -> Option<u64> {
|
|||
/// agent's state-dir contribution was 0; with the writable rootfs nearly empty
|
||||
/// (almost everything is bind-mounted), that surfaced as all agents reporting
|
||||
/// 0 disk.
|
||||
async fn measure_agent_disk(name: &str) -> u64 {
|
||||
async fn measure_agent_disk(name: &hive_host_sock::Ident) -> u64 {
|
||||
let state_dir = Coordinator::agent_notes_dir(name);
|
||||
let rootfs = PathBuf::from(format!("{NIXOS_CONTAINERS_ROOT}/h-{name}"));
|
||||
let mut total = 0u64;
|
||||
|
|
@ -136,7 +136,7 @@ pub async fn disk_sampler_loop() {
|
|||
for name in Coordinator::kept_state_names() {
|
||||
let bytes = measure_agent_disk(&name).await;
|
||||
if let Ok(mut cache) = disk_cache().write() {
|
||||
cache.insert(name, bytes);
|
||||
cache.insert(name.into_string(), bytes);
|
||||
}
|
||||
}
|
||||
sleep(DISK_SAMPLE_INTERVAL).await;
|
||||
|
|
@ -240,7 +240,9 @@ pub async fn gather() -> Vec<ContainerResource> {
|
|||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
let dir = scope_dir(&format!("h-{name}"));
|
||||
dir.join("cpu.stat").exists().then_some((name, dir))
|
||||
dir.join("cpu.stat")
|
||||
.exists()
|
||||
.then_some((name.into_string(), dir))
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats {
|
|||
*bash_mix.entry(h.clone()).or_insert(0) += c;
|
||||
}
|
||||
agents.push(AgentRollup {
|
||||
name,
|
||||
name: name.into_string(),
|
||||
turns: agg.turns,
|
||||
input_tokens: agg.input,
|
||||
output_tokens: agg.output,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ pub fn spawn(coord: Arc<Coordinator>) {
|
|||
if lifecycle::is_running(&logical).await {
|
||||
current_running.insert(logical.clone());
|
||||
}
|
||||
if claude_has_session(&Coordinator::agent_claude_dir(&logical)) {
|
||||
if hive_host_sock::Ident::parse(&logical)
|
||||
.is_ok_and(|id| claude_has_session(&Coordinator::agent_claude_dir(&id)))
|
||||
{
|
||||
current_logged_in.insert(logical.clone());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ fn inline_fallback(req_path: &str, reason: &str, message: &str) -> String {
|
|||
/// inline-falls-back). `pub` because `socket_server::handle_remind`
|
||||
/// reuses it for the at-remind-time auto-file path.
|
||||
pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(), String> {
|
||||
let agent = hive_host_sock::Ident::parse(agent)
|
||||
.map_err(|e| format!("invalid agent name {agent:?}: {e}"))?;
|
||||
let Some(parent) = host_path.parent() else {
|
||||
return Err("internal: host path has no parent".to_owned());
|
||||
};
|
||||
|
|
@ -143,7 +145,7 @@ pub fn write_payload(agent: &str, host_path: &Path, message: &str) -> Result<(),
|
|||
let parent_canonical = parent
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("parent canonicalize failed: {e}"))?;
|
||||
let agent_root = Coordinator::agent_notes_dir(agent)
|
||||
let agent_root = Coordinator::agent_notes_dir(&agent)
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("agent state root canonicalize failed: {e}"))?;
|
||||
if !parent_canonical.starts_with(&agent_root) {
|
||||
|
|
@ -189,7 +191,9 @@ pub fn container_state_prefix(agent: &str) -> String {
|
|||
/// reason string on rejection. `pub` so `socket_server::handle_remind`
|
||||
/// can reuse it for the at-remind-time auto-file path.
|
||||
pub fn resolve_host_path(agent: &str, req_path: &str) -> Result<PathBuf, String> {
|
||||
let prefix = container_state_prefix(agent);
|
||||
let agent = hive_host_sock::Ident::parse(agent)
|
||||
.map_err(|e| format!("invalid agent name {agent:?}: {e}"))?;
|
||||
let prefix = container_state_prefix(agent.as_str());
|
||||
let Some(rel) = req_path.strip_prefix(&prefix) else {
|
||||
return Err(format!(
|
||||
"must be absolute and under `{prefix}` (got `{req_path}`)"
|
||||
|
|
@ -209,7 +213,7 @@ pub fn resolve_host_path(agent: &str, req_path: &str) -> Result<PathBuf, String>
|
|||
}
|
||||
}
|
||||
}
|
||||
Ok(Coordinator::agent_notes_dir(agent).join(rel_path))
|
||||
Ok(Coordinator::agent_notes_dir(&agent).join(rel_path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -31,9 +31,13 @@ pub const HOST_SOCKET: &str = "/run/hyperhive/host.sock";
|
|||
pub const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
|
||||
|
||||
/// `agents/<name>` — one agent's persistent state root.
|
||||
///
|
||||
/// Takes a validated [`Ident`] (not a raw `&str`) so a per-agent state path
|
||||
/// can never be built from an unvalidated name — the `../` traversal guard is
|
||||
/// the type, enforced at the one place every agent path is rooted.
|
||||
#[must_use]
|
||||
pub fn agent_state_dir(name: &str) -> PathBuf {
|
||||
PathBuf::from(AGENTS_ROOT).join(name)
|
||||
pub fn agent_state_dir(name: &Ident) -> PathBuf {
|
||||
PathBuf::from(AGENTS_ROOT).join(name.as_str())
|
||||
}
|
||||
|
||||
/// `gateway/gateway.htpasswd` — nginx basic-auth credential store for the
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ pub(crate) async fn query_hive_urls(socket: &Path) -> Option<hive_host_sock::Hiv
|
|||
/// "needs root" error fixes that first-run footgun, where running a
|
||||
/// privileged verb without sudo reported as a missing agent.
|
||||
pub(crate) fn agent_exists(name: &str) -> Result<bool> {
|
||||
let root = hive_host_sock::agent_state_dir(name);
|
||||
let Ok(name) = hive_host_sock::Ident::parse(name) else {
|
||||
bail!("invalid agent name {name:?}");
|
||||
};
|
||||
let root = hive_host_sock::agent_state_dir(&name);
|
||||
match root.try_exists() {
|
||||
Ok(found) => Ok(found),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => bail!(
|
||||
|
|
|
|||
Loading…
Reference in a new issue