type Approval.agent as Ident

This commit is contained in:
damocles 2026-07-22 19:38:23 +02:00 committed by mara
commit 76647415af
10 changed files with 71 additions and 53 deletions

1
Cargo.lock generated
View file

@ -1768,6 +1768,7 @@ name = "hive-sh4re"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"hive-types",
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -41,12 +41,9 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
// Sub-second git seed + forge-remote wire. Routing through // Sub-second git seed + forge-remote wire. Routing through
// the queue would surface a queue card that's gone before // the queue would surface a queue card that's gone before
// the operator's eyes refocus. Run inline. // the operator's eyes refocus. Run inline.
let agent = hive_types::Ident::parse(&approval.agent).map_err(|e| { let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
anyhow::anyhow!("approval {} has invalid agent name: {e}", approval.id) let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
})?; let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
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 run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await
} }
ApprovalKind::UpdateMetaInputs => { ApprovalKind::UpdateMetaInputs => {
@ -75,11 +72,14 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
ApprovalKind::Spawn => { ApprovalKind::Spawn => {
// The spawn's tail `Reconcile` starts the container, so the // The spawn's tail `Reconcile` starts the container, so the
// new agent's power intent is `Up` from the outset. // 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"); tracing::warn!(agent = %approval.agent, error = ?e, "agent_power: seed on spawn failed");
} }
let submitted = coord.job_queue.submit(crate::job_queue::templates::spawn( let submitted = coord.job_queue.submit(crate::job_queue::templates::spawn(
&approval.agent, approval.agent.as_str(),
id, id,
format!("approval #{id} spawn"), format!("approval #{id} spawn"),
)); ));
@ -110,7 +110,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
// deploy tail). // deploy tail).
enqueue_approval_rebuild( enqueue_approval_rebuild(
&coord, &coord,
&approval.agent, approval.agent.as_str(),
id, id,
format!("approval #{id} merge config pr"), format!("approval #{id} merge config pr"),
); );
@ -158,8 +158,8 @@ pub async fn run_approval_merge_config_pr(
approval_id: i64, approval_id: i64,
) -> Result<()> { ) -> Result<()> {
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::MergeConfigPr)?; let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::MergeConfigPr)?;
let agent_dir = crate::paths::agent_runtime_dir(&approval.agent); let agent_dir = crate::paths::agent_runtime_dir(approval.agent.as_str());
let applied_dir = crate::paths::applied_dir(&approval.agent); let applied_dir = crate::paths::applied_dir(approval.agent.as_str());
// Captured up front to scope the failure-comment's build-log lookup to // Captured up front to scope the failure-comment's build-log lookup to
// rows this deploy produced (see `post_merge_failure_to_pr`). // rows this deploy produced (see `post_merge_failure_to_pr`).
let since_ts = hive_sh4re::wire_time::now_unix(); 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, // ff'd by the merge, so only the tag refspec actually lands; best-effort,
// never fails the approval. // never fails the approval.
coord.set_queue_step(queue_entry_id, "forge push"); 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"); 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 // 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::<u64>() else { let Ok(pr) = approval.commit_ref.parse::<u64>() else {
return; return;
}; };
let repo = crate::forge::config_repo(&approval.agent); let repo = crate::forge::config_repo(approval.agent.as_str());
let log_section = coord let log_section = coord
.build_logs .build_logs
.list_recent_for_agent(&approval.agent, 10) .list_recent_for_agent(approval.agent.as_str(), 10)
.ok() .ok()
.and_then(|rows| { .and_then(|rows| {
rows.into_iter() 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. // 1. Drift gate: the live PR head must still equal what was reviewed.
coord.set_queue_step(queue_entry_id, "verify PR head"); 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). // 3. Eval-verify BEFORE the irreversible push (bad nix fails fast here).
coord.set_queue_step(queue_entry_id, "verify proposal (eval)"); 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 ( return (
Err(anyhow::anyhow!("verify merge head {reviewed}: {e:#}")), Err(anyhow::anyhow!("verify merge head {reviewed}: {e:#}")),
None, None,
@ -364,7 +366,7 @@ async fn run_merge_config_pr(
// 5. Deploy tail. target == finalize == the reviewed head. // 5. Deploy tail. target == finalize == the reviewed head.
deploy_applied_target( deploy_applied_target(
coord, coord,
&approval.agent, approval.agent.as_str(),
agent_dir, agent_dir,
applied_dir, applied_dir,
&reviewed, &reviewed,
@ -391,7 +393,7 @@ async fn run_approval_schedule_prompt(
coord coord
.scheduled_prompts .scheduled_prompts
.submit(&crate::scheduled_prompts::NewSchedule { .submit(&crate::scheduled_prompts::NewSchedule {
owner: approval.agent.clone(), owner: approval.agent.to_string(),
targets: payload.targets, targets: payload.targets,
body: payload.body, body: payload.body,
first_fire_at_unix: payload.first_fire_at_unix, 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 // access) — warn-only, then the resolution events + a rescan so
// the dashboard reflects the post-spawn state either way. // the dashboard reflects the post-spawn state either way.
if result.is_ok() { if result.is_ok() {
forge_after_first_spawn(coord, &approval.agent).await; forge_after_first_spawn(coord, approval.agent.as_str()).await;
} else { } else {
coord.rescan_containers_and_emit().await; coord.rescan_containers_and_emit().await;
crate::dashboard::emit_tombstones_snapshot(coord).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 // and let `topology::reconcile` assign the default position on
// first spawn, so this path never names a specific root agent. // first spawn, so this path never names a specific root agent.
if !approval.commit_ref.is_empty() { 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}"))?; .map_err(|e| anyhow::anyhow!("topology add_child: {e}"))?;
} }
// Create the agent's state root as a btrfs subvolume FIRST, before // 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 // materialise the state root as a plain directory — after which
// the subvolume create is silently skipped and the agent never // the subvolume create is silently skipped and the agent never
// lands on a subvolume (no quota, no snapshot). Order matters. // lands on a subvolume (no quota, no snapshot). Order matters.
lifecycle::ensure_agent_state_subvolume(&approval.agent).await?; lifecycle::ensure_agent_state_subvolume(approval.agent.as_str()).await?;
lifecycle::setup_proposed(&proposed_dir, &approval.agent).await?; lifecycle::setup_proposed(&proposed_dir, approval.agent.as_str()).await?;
lifecycle::ensure_claude_dir(&claude_dir)?; lifecycle::ensure_claude_dir(&claude_dir)?;
lifecycle::ensure_state_dir(&notes_dir)?; lifecycle::ensure_state_dir(&notes_dir)?;
Ok(()) Ok(())
} }
.await; .await;
if result.is_ok() 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"); tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed");
} }
@ -571,7 +573,7 @@ fn finish_approval(
approval.id, approval.id,
&HelperEvent::ApprovalResolved { &HelperEvent::ApprovalResolved {
id: approval.id, id: approval.id,
agent: approval.agent.clone(), agent: approval.agent.to_string(),
commit_ref: approval.commit_ref.clone(), commit_ref: approval.commit_ref.clone(),
status, status,
note: note.clone(), note: note.clone(),
@ -592,7 +594,7 @@ fn finish_approval(
let status_str = if ok { "approved" } else { "failed" }; let status_str = if ok { "approved" } else { "failed" };
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: approval.id, id: approval.id,
agent: &approval.agent, agent: approval.agent.as_str(),
approval_kind, approval_kind,
sha_short, sha_short,
status: status_str, status: status_str,
@ -610,7 +612,7 @@ fn finish_approval(
coord.notify_submitter( coord.notify_submitter(
approval.id, approval.id,
&HelperEvent::ConfigReady { &HelperEvent::ConfigReady {
agent: approval.agent.clone(), agent: approval.agent.to_string(),
}, },
); );
} }
@ -618,7 +620,7 @@ fn finish_approval(
ApprovalKind::Spawn => coord.notify_submitter( ApprovalKind::Spawn => coord.notify_submitter(
approval.id, approval.id,
&HelperEvent::Spawned { &HelperEvent::Spawned {
agent: approval.agent.clone(), agent: approval.agent.to_string(),
ok, ok,
note, note,
}, },
@ -630,7 +632,7 @@ fn finish_approval(
coord.notify_submitter( coord.notify_submitter(
approval.id, approval.id,
&HelperEvent::Rebuilt { &HelperEvent::Rebuilt {
agent: approval.agent.clone(), agent: approval.agent.to_string(),
ok, ok,
note, note,
sha: approval.fetched_sha.clone(), sha: approval.fetched_sha.clone(),
@ -881,7 +883,7 @@ pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
a.id, a.id,
&HelperEvent::ApprovalResolved { &HelperEvent::ApprovalResolved {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
commit_ref: a.commit_ref, commit_ref: a.commit_ref,
status: ApprovalStatus::Denied, status: ApprovalStatus::Denied,
note: note.map(String::from), 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 { coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id, id,
agent: &agent_owned, agent: agent_owned.as_str(),
approval_kind, approval_kind,
sha_short, sha_short,
status: "denied", status: "denied",

View file

@ -66,12 +66,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
) { ) {
return true; return true;
} }
let Ok(agent) = hive_types::Ident::parse(&a.agent) else { if Coordinator::agent_proposed_dir(&a.agent).exists() {
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 true
} else { } else {
let note = "agent state dir missing"; let note = "agent state dir missing";
@ -83,7 +78,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
.map(|s| s[..s.len().min(12)].to_owned()); .map(|s| s[..s.len().min(12)].to_owned());
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: a.id, id: a.id,
agent: &a.agent, agent: a.agent.as_str(),
approval_kind: a.kind.as_str(), approval_kind: a.kind.as_str(),
sha_short, sha_short,
status: "failed", status: "failed",

View file

@ -552,7 +552,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
let kind = a.kind.as_str(); let kind = a.kind.as_str();
ApprovalHistoryView { ApprovalHistoryView {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
kind, kind,
sha_short, sha_short,
status, status,
@ -567,7 +567,7 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
out.push(match a.kind { out.push(match a.kind {
hive_sh4re::ApprovalKind::Spawn => ApprovalView { hive_sh4re::ApprovalKind::Spawn => ApprovalView {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
kind: "spawn", kind: "spawn",
sha_short: None, sha_short: None,
description: a.description, description: a.description,
@ -577,7 +577,7 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
}, },
hive_sh4re::ApprovalKind::InitConfig => ApprovalView { hive_sh4re::ApprovalKind::InitConfig => ApprovalView {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
kind: "init_config", kind: "init_config",
sha_short: None, sha_short: None,
description: a.description, description: a.description,
@ -587,7 +587,7 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
}, },
hive_sh4re::ApprovalKind::UpdateMetaInputs => ApprovalView { hive_sh4re::ApprovalKind::UpdateMetaInputs => ApprovalView {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
kind: "update_meta_inputs", kind: "update_meta_inputs",
sha_short: None, sha_short: None,
description: a.description, description: a.description,
@ -597,7 +597,7 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
}, },
hive_sh4re::ApprovalKind::SchedulePrompt => ApprovalView { hive_sh4re::ApprovalKind::SchedulePrompt => ApprovalView {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
kind: "schedule_prompt", kind: "schedule_prompt",
sha_short: None, sha_short: None,
description: a.description, description: a.description,
@ -618,7 +618,7 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
let pr_number = a.commit_ref.parse::<u64>().ok(); let pr_number = a.commit_ref.parse::<u64>().ok();
ApprovalView { ApprovalView {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
kind: "merge_config_pr", kind: "merge_config_pr",
sha_short: sha, sha_short: sha,
description: a.description, description: a.description,

View file

@ -145,13 +145,15 @@ fn reconcile_stale_config_pr_approvals(
} }
}; };
for a in pending { 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; continue;
} }
let Ok(pr_number) = a.commit_ref.parse::<u64>() else { let Ok(pr_number) = a.commit_ref.parse::<u64>() else {
continue; continue;
}; };
if open_prs.contains(&(a.agent.clone(), pr_number)) { if open_prs.contains(&(a.agent.to_string(), pr_number)) {
continue; continue;
} }
match coord match coord
@ -165,7 +167,7 @@ fn reconcile_stale_config_pr_approvals(
); );
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: a.id, id: a.id,
agent: &a.agent, agent: a.agent.as_str(),
approval_kind: "merge_config_pr", approval_kind: "merge_config_pr",
sha_short: a sha_short: a
.fetched_sha .fetched_sha

View file

@ -66,7 +66,7 @@ pub fn for_agent(coord: &Coordinator, agent: &str) -> Result<Vec<LooseEnd>> {
} }
out.push(LooseEnd::Approval { out.push(LooseEnd::Approval {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
commit_ref: a.commit_ref, commit_ref: a.commit_ref,
description: a.description, description: a.description,
age_seconds: saturating_age(now, a.requested_at.timestamp()), age_seconds: saturating_age(now, a.requested_at.timestamp()),
@ -110,7 +110,7 @@ pub fn hive_wide(coord: &Coordinator) -> Result<Vec<LooseEnd>> {
for a in coord.approvals.pending()? { for a in coord.approvals.pending()? {
out.push(LooseEnd::Approval { out.push(LooseEnd::Approval {
id: a.id, id: a.id,
agent: a.agent, agent: a.agent.to_string(),
commit_ref: a.commit_ref, commit_ref: a.commit_ref,
description: a.description, description: a.description,
age_seconds: saturating_age(now, a.requested_at.timestamp()), age_seconds: saturating_age(now, a.requested_at.timestamp()),

View file

@ -227,7 +227,7 @@ pub fn handle_cancel_loose_end(
.map(|s| s[..s.len().min(12)].to_owned()); .map(|s| s[..s.len().min(12)].to_owned());
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: approval.id, id: approval.id,
agent: &approval.agent, agent: approval.agent.as_str(),
approval_kind: approval.kind.as_str(), approval_kind: approval.kind.as_str(),
sha_short, sha_short,
status: "cancelled", status: "cancelled",

View file

@ -307,7 +307,7 @@ impl Approvals {
/// across both callers (one suppressed the lint, the other aliased the /// across both callers (one suppressed the lint, the other aliased the
/// tuple) — one named projection + mapper now backs both. /// tuple) — one named projection + mapper now backs both.
struct ApprovalLookup { struct ApprovalLookup {
agent: String, agent: hive_types::Ident,
kind: String, kind: String,
commit_ref: String, commit_ref: String,
requested_at: i64, requested_at: i64,
@ -323,8 +323,16 @@ impl ApprovalLookup {
description FROM approvals WHERE id = ?1"; description FROM approvals WHERE id = ?1";
fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> { fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
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 { Ok(Self {
agent: row.get(0)?, agent,
kind: row.get(1)?, kind: row.get(1)?,
commit_ref: row.get(2)?, commit_ref: row.get(2)?,
requested_at: row.get(3)?, requested_at: row.get(3)?,
@ -399,9 +407,17 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
)); ));
} }
}; };
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 { Ok(Approval {
id: row.get(0)?, id: row.get(0)?,
agent: row.get(1)?, agent,
kind, kind,
commit_ref: row.get(3)?, commit_ref: row.get(3)?,
requested_at: hive_sh4re::wire_time::from_secs(row.get(4)?), requested_at: hive_sh4re::wire_time::from_secs(row.get(4)?),

View file

@ -8,6 +8,7 @@ workspace = true
[dependencies] [dependencies]
chrono.workspace = true chrono.workspace = true
hive-types.workspace = true
schemars.workspace = true schemars.workspace = true
serde.workspace = true serde.workspace = true

View file

@ -1,6 +1,7 @@
//! Wire types shared between `hive-c0re` and the in-container harness. //! Wire types shared between `hive-c0re` and the in-container harness.
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use hive_types::Ident;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub mod assets; pub mod assets;
@ -52,7 +53,7 @@ pub fn pending_hint(remaining: u64) -> String {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Approval { pub struct Approval {
pub id: i64, pub id: i64,
pub agent: String, pub agent: Ident,
#[serde(default)] #[serde(default)]
pub kind: ApprovalKind, pub kind: ApprovalKind,
/// Kind-specific payload (git sha / inputs array / schedule /// Kind-specific payload (git sha / inputs array / schedule