From 76647415af35166ad022ced0116aac0062562f6f Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 22 Jul 2026 19:38:23 +0200 Subject: [PATCH] type Approval.agent as Ident --- Cargo.lock | 1 + hive-c0re/src/actions.rs | 62 ++++++++++++----------- hive-c0re/src/dashboard/approvals.rs | 9 +--- hive-c0re/src/dashboard/state_snapshot.rs | 12 ++--- hive-c0re/src/forge/config_pr_poll.rs | 8 +-- hive-c0re/src/loose_ends.rs | 4 +- hive-c0re/src/questions.rs | 2 +- hive-c0re/src/stores/approvals.rs | 22 ++++++-- hive-sh4re/Cargo.toml | 1 + hive-sh4re/src/lib.rs | 3 +- 10 files changed, 71 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f4ab5b4..a75fe8fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1768,6 +1768,7 @@ name = "hive-sh4re" version = "0.1.0" dependencies = [ "chrono", + "hive-types", "schemars", "serde", "serde_json", diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index e091a686..13e3992e 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -41,12 +41,9 @@ pub async fn approve(coord: Arc, 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 agent = hive_types::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); + 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); run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await } ApprovalKind::UpdateMetaInputs => { @@ -75,11 +72,14 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { ApprovalKind::Spawn => { // The spawn's tail `Reconcile` starts the container, so the // new agent's power intent is `Up` from the outset. - if let Err(e) = coord.power.set(&approval.agent, crate::power::Wanted::Up) { + if let Err(e) = coord + .power + .set(approval.agent.as_str(), crate::power::Wanted::Up) + { tracing::warn!(agent = %approval.agent, error = ?e, "agent_power: seed on spawn failed"); } let submitted = coord.job_queue.submit(crate::job_queue::templates::spawn( - &approval.agent, + approval.agent.as_str(), id, format!("approval #{id} spawn"), )); @@ -110,7 +110,7 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { // deploy tail). enqueue_approval_rebuild( &coord, - &approval.agent, + approval.agent.as_str(), id, format!("approval #{id} merge config pr"), ); @@ -158,8 +158,8 @@ pub async fn run_approval_merge_config_pr( approval_id: i64, ) -> Result<()> { let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::MergeConfigPr)?; - let agent_dir = crate::paths::agent_runtime_dir(&approval.agent); - let applied_dir = crate::paths::applied_dir(&approval.agent); + let agent_dir = crate::paths::agent_runtime_dir(approval.agent.as_str()); + let applied_dir = crate::paths::applied_dir(approval.agent.as_str()); // Captured up front to scope the failure-comment's build-log lookup to // rows this deploy produced (see `post_merge_failure_to_pr`). let since_ts = hive_sh4re::wire_time::now_unix(); @@ -172,7 +172,7 @@ pub async fn run_approval_merge_config_pr( // ff'd by the merge, so only the tag refspec actually lands; best-effort, // never fails the approval. coord.set_queue_step(queue_entry_id, "forge push"); - if let Err(e) = crate::forge::push_config(&approval.agent).await { + if let Err(e) = crate::forge::push_config(approval.agent.as_str()).await { tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after merge failed"); } // On a failed deploy, surface the failing build log back onto the PR so @@ -208,11 +208,11 @@ async fn post_merge_failure_to_pr( let Ok(pr) = approval.commit_ref.parse::() else { return; }; - let repo = crate::forge::config_repo(&approval.agent); + let repo = crate::forge::config_repo(approval.agent.as_str()); let log_section = coord .build_logs - .list_recent_for_agent(&approval.agent, 10) + .list_recent_for_agent(approval.agent.as_str(), 10) .ok() .and_then(|rows| { rows.into_iter() @@ -300,7 +300,7 @@ async fn run_merge_config_pr( ); } }; - let repo = crate::forge::config_repo(&approval.agent); + let repo = crate::forge::config_repo(approval.agent.as_str()); // 1. Drift gate: the live PR head must still equal what was reviewed. coord.set_queue_step(queue_entry_id, "verify PR head"); @@ -328,7 +328,9 @@ async fn run_merge_config_pr( // 3. Eval-verify BEFORE the irreversible push (bad nix fails fast here). coord.set_queue_step(queue_entry_id, "verify proposal (eval)"); - if let Err(e) = crate::meta::verify_commit(&approval.agent, applied_dir, &reviewed).await { + if let Err(e) = + crate::meta::verify_commit(approval.agent.as_str(), applied_dir, &reviewed).await + { return ( Err(anyhow::anyhow!("verify merge head {reviewed}: {e:#}")), None, @@ -364,7 +366,7 @@ async fn run_merge_config_pr( // 5. Deploy tail. target == finalize == the reviewed head. deploy_applied_target( coord, - &approval.agent, + approval.agent.as_str(), agent_dir, applied_dir, &reviewed, @@ -391,7 +393,7 @@ async fn run_approval_schedule_prompt( coord .scheduled_prompts .submit(&crate::scheduled_prompts::NewSchedule { - owner: approval.agent.clone(), + owner: approval.agent.to_string(), targets: payload.targets, body: payload.body, first_fire_at_unix: payload.first_fire_at_unix, @@ -451,7 +453,7 @@ pub(crate) async fn resolve_approval_dag( // access) — warn-only, then the resolution events + a rescan so // the dashboard reflects the post-spawn state either way. if result.is_ok() { - forge_after_first_spawn(coord, &approval.agent).await; + forge_after_first_spawn(coord, approval.agent.as_str()).await; } else { coord.rescan_containers_and_emit().await; crate::dashboard::emit_tombstones_snapshot(coord).await; @@ -528,7 +530,7 @@ async fn run_approval_init_config( // and let `topology::reconcile` assign the default position on // first spawn, so this path never names a specific root agent. if !approval.commit_ref.is_empty() { - crate::topology::add_child(&approval.agent, &approval.commit_ref) + crate::topology::add_child(approval.agent.as_str(), &approval.commit_ref) .map_err(|e| anyhow::anyhow!("topology add_child: {e}"))?; } // Create the agent's state root as a btrfs subvolume FIRST, before @@ -538,15 +540,15 @@ async fn run_approval_init_config( // materialise the state root as a plain directory — after which // the subvolume create is silently skipped and the agent never // lands on a subvolume (no quota, no snapshot). Order matters. - lifecycle::ensure_agent_state_subvolume(&approval.agent).await?; - lifecycle::setup_proposed(&proposed_dir, &approval.agent).await?; + lifecycle::ensure_agent_state_subvolume(approval.agent.as_str()).await?; + lifecycle::setup_proposed(&proposed_dir, approval.agent.as_str()).await?; lifecycle::ensure_claude_dir(&claude_dir)?; lifecycle::ensure_state_dir(¬es_dir)?; Ok(()) } .await; if result.is_ok() - && let Err(e) = crate::forge::ensure_meta_remote(&approval.agent).await + && let Err(e) = crate::forge::ensure_meta_remote(approval.agent.as_str()).await { tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed"); } @@ -571,7 +573,7 @@ fn finish_approval( approval.id, &HelperEvent::ApprovalResolved { id: approval.id, - agent: approval.agent.clone(), + agent: approval.agent.to_string(), commit_ref: approval.commit_ref.clone(), status, note: note.clone(), @@ -592,7 +594,7 @@ fn finish_approval( let status_str = if ok { "approved" } else { "failed" }; coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id: approval.id, - agent: &approval.agent, + agent: approval.agent.as_str(), approval_kind, sha_short, status: status_str, @@ -610,7 +612,7 @@ fn finish_approval( coord.notify_submitter( approval.id, &HelperEvent::ConfigReady { - agent: approval.agent.clone(), + agent: approval.agent.to_string(), }, ); } @@ -618,7 +620,7 @@ fn finish_approval( ApprovalKind::Spawn => coord.notify_submitter( approval.id, &HelperEvent::Spawned { - agent: approval.agent.clone(), + agent: approval.agent.to_string(), ok, note, }, @@ -630,7 +632,7 @@ fn finish_approval( coord.notify_submitter( approval.id, &HelperEvent::Rebuilt { - agent: approval.agent.clone(), + agent: approval.agent.to_string(), ok, note, sha: approval.fetched_sha.clone(), @@ -881,7 +883,7 @@ pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { a.id, &HelperEvent::ApprovalResolved { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), commit_ref: a.commit_ref, status: ApprovalStatus::Denied, note: note.map(String::from), @@ -892,7 +894,7 @@ pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { ); coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id, - agent: &agent_owned, + agent: agent_owned.as_str(), approval_kind, sha_short, status: "denied", diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index 04c1ee96..4239eb6f 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -66,12 +66,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec) -> Vec ApprovalHistoryView { let kind = a.kind.as_str(); ApprovalHistoryView { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), kind, sha_short, status, @@ -567,7 +567,7 @@ fn build_approval_views(approvals: Vec) -> Vec { out.push(match a.kind { hive_sh4re::ApprovalKind::Spawn => ApprovalView { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), kind: "spawn", sha_short: None, description: a.description, @@ -577,7 +577,7 @@ fn build_approval_views(approvals: Vec) -> Vec { }, hive_sh4re::ApprovalKind::InitConfig => ApprovalView { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), kind: "init_config", sha_short: None, description: a.description, @@ -587,7 +587,7 @@ fn build_approval_views(approvals: Vec) -> Vec { }, hive_sh4re::ApprovalKind::UpdateMetaInputs => ApprovalView { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), kind: "update_meta_inputs", sha_short: None, description: a.description, @@ -597,7 +597,7 @@ fn build_approval_views(approvals: Vec) -> Vec { }, hive_sh4re::ApprovalKind::SchedulePrompt => ApprovalView { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), kind: "schedule_prompt", sha_short: None, description: a.description, @@ -618,7 +618,7 @@ fn build_approval_views(approvals: Vec) -> Vec { let pr_number = a.commit_ref.parse::().ok(); ApprovalView { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), kind: "merge_config_pr", sha_short: sha, description: a.description, diff --git a/hive-c0re/src/forge/config_pr_poll.rs b/hive-c0re/src/forge/config_pr_poll.rs index ba233b44..fe8ef2b2 100644 --- a/hive-c0re/src/forge/config_pr_poll.rs +++ b/hive-c0re/src/forge/config_pr_poll.rs @@ -145,13 +145,15 @@ fn reconcile_stale_config_pr_approvals( } }; for a in pending { - if a.kind != hive_sh4re::ApprovalKind::MergeConfigPr || !scanned_agents.contains(&a.agent) { + if a.kind != hive_sh4re::ApprovalKind::MergeConfigPr + || !scanned_agents.contains(a.agent.as_str()) + { continue; } let Ok(pr_number) = a.commit_ref.parse::() else { continue; }; - if open_prs.contains(&(a.agent.clone(), pr_number)) { + if open_prs.contains(&(a.agent.to_string(), pr_number)) { continue; } match coord @@ -165,7 +167,7 @@ fn reconcile_stale_config_pr_approvals( ); coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id: a.id, - agent: &a.agent, + agent: a.agent.as_str(), approval_kind: "merge_config_pr", sha_short: a .fetched_sha diff --git a/hive-c0re/src/loose_ends.rs b/hive-c0re/src/loose_ends.rs index b09228f8..d510d222 100644 --- a/hive-c0re/src/loose_ends.rs +++ b/hive-c0re/src/loose_ends.rs @@ -66,7 +66,7 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result> { } out.push(LooseEnd::Approval { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), commit_ref: a.commit_ref, description: a.description, age_seconds: saturating_age(now, a.requested_at.timestamp()), @@ -110,7 +110,7 @@ pub fn hive_wide(coord: &Coordinator) -> Result> { for a in coord.approvals.pending()? { out.push(LooseEnd::Approval { id: a.id, - agent: a.agent, + agent: a.agent.to_string(), commit_ref: a.commit_ref, description: a.description, age_seconds: saturating_age(now, a.requested_at.timestamp()), diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index f6e878a3..d531ee0c 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -227,7 +227,7 @@ pub fn handle_cancel_loose_end( .map(|s| s[..s.len().min(12)].to_owned()); coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id: approval.id, - agent: &approval.agent, + agent: approval.agent.as_str(), approval_kind: approval.kind.as_str(), sha_short, status: "cancelled", diff --git a/hive-c0re/src/stores/approvals.rs b/hive-c0re/src/stores/approvals.rs index 3405d75c..313f04e4 100644 --- a/hive-c0re/src/stores/approvals.rs +++ b/hive-c0re/src/stores/approvals.rs @@ -307,7 +307,7 @@ impl Approvals { /// across both callers (one suppressed the lint, the other aliased the /// tuple) — one named projection + mapper now backs both. struct ApprovalLookup { - agent: String, + agent: hive_types::Ident, kind: String, commit_ref: String, requested_at: i64, @@ -323,8 +323,16 @@ impl ApprovalLookup { description FROM approvals WHERE id = ?1"; fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let agent: String = row.get(0)?; + let agent = hive_types::Ident::parse(&agent).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + format!("invalid approval agent {agent:?}: {e}").into(), + ) + })?; Ok(Self { - agent: row.get(0)?, + agent, kind: row.get(1)?, commit_ref: row.get(2)?, requested_at: row.get(3)?, @@ -399,9 +407,17 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { )); } }; + let agent: String = row.get(1)?; + let agent = hive_types::Ident::parse(&agent).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + format!("invalid approval agent {agent:?}: {e}").into(), + ) + })?; Ok(Approval { id: row.get(0)?, - agent: row.get(1)?, + agent, kind, commit_ref: row.get(3)?, requested_at: hive_sh4re::wire_time::from_secs(row.get(4)?), diff --git a/hive-sh4re/Cargo.toml b/hive-sh4re/Cargo.toml index 613b0fa7..3338ed51 100644 --- a/hive-sh4re/Cargo.toml +++ b/hive-sh4re/Cargo.toml @@ -8,6 +8,7 @@ workspace = true [dependencies] chrono.workspace = true +hive-types.workspace = true schemars.workspace = true serde.workspace = true diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index c8bf4c15..91babdcf 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -1,6 +1,7 @@ //! Wire types shared between `hive-c0re` and the in-container harness. use chrono::{DateTime, Utc}; +use hive_types::Ident; use serde::{Deserialize, Serialize}; pub mod assets; @@ -52,7 +53,7 @@ pub fn pending_hint(remaining: u64) -> String { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Approval { pub id: i64, - pub agent: String, + pub agent: Ident, #[serde(default)] pub kind: ApprovalKind, /// Kind-specific payload (git sha / inputs array / schedule