343 lines
13 KiB
Rust
343 lines
13 KiB
Rust
//! Config-approval request handlers: `RequestInitConfig` /
|
|
//! `RequestApplyCommit` / `RequestUpdateMetaInputs`, plus the shared
|
|
//! submit helpers (`submit_init_config` / `submit_apply_commit`) and the
|
|
//! commit-sha shape check (`validate_commit_ref`).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result};
|
|
use hive_sh4re::AgentResponse;
|
|
|
|
use super::require_new_child;
|
|
use crate::coordinator::Coordinator;
|
|
|
|
/// `RequestInitConfig` — queue an `InitConfig` approval for an agent. The
|
|
/// `name` must be brand-new (absent from the topology) or already in the
|
|
/// caller's subtree; the requester is recorded as the new agent's parent (the
|
|
/// root requesting a new agent → a top-level agent, matching reconcile's
|
|
/// default).
|
|
pub(super) fn handle_request_init_config(
|
|
coord: &Arc<Coordinator>,
|
|
agent: &str,
|
|
name: &str,
|
|
description: Option<String>,
|
|
) -> AgentResponse {
|
|
if let Some(err) = require_new_child(agent, name, "request_init_config for") {
|
|
return err;
|
|
}
|
|
tracing::info!(%agent, %name, "request_init_config");
|
|
match submit_init_config(coord, name, Some(agent), description) {
|
|
Ok(_id) => AgentResponse::Ok,
|
|
Err(e) => AgentResponse::Err {
|
|
message: format!("{e:#}"),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `RequestApplyCommit` — queue an apply-commit approval for an agent. The
|
|
/// target must be in the caller's subtree (the root covers every agent).
|
|
pub(super) async fn handle_request_apply_commit(
|
|
coord: &Arc<Coordinator>,
|
|
agent: &str,
|
|
target_agent: &str,
|
|
commit_ref: &str,
|
|
description: Option<&str>,
|
|
) -> AgentResponse {
|
|
if let Some(err) = require_new_child(agent, target_agent, "request_apply_commit for") {
|
|
return err;
|
|
}
|
|
tracing::info!(%agent, %target_agent, %commit_ref, "request_apply_commit");
|
|
match submit_apply_commit(coord, target_agent, commit_ref, description, agent).await {
|
|
Ok((id, sha)) => {
|
|
tracing::info!(%id, %target_agent, %sha, "apply_commit approval queued");
|
|
AgentResponse::Ok
|
|
}
|
|
Err(e) => AgentResponse::Err {
|
|
message: format!("{e:#}"),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval
|
|
/// carrying the JSON-encoded input list in `commit_ref` (no git commit
|
|
/// is involved; the field is the payload the approval handler decodes).
|
|
pub(super) fn handle_request_update_meta_inputs(
|
|
coord: &Arc<Coordinator>,
|
|
requester: &str,
|
|
inputs: &[String],
|
|
description: Option<&str>,
|
|
) -> AgentResponse {
|
|
let label = if inputs.is_empty() {
|
|
"all inputs".to_string()
|
|
} else {
|
|
inputs.join(", ")
|
|
};
|
|
tracing::info!(%requester, %label, "request_update_meta_inputs");
|
|
let commit_ref = serde_json::to_string(inputs).unwrap_or_default();
|
|
let id = match coord
|
|
.approvals
|
|
.submit_kind(
|
|
requester,
|
|
hive_sh4re::ApprovalKind::UpdateMetaInputs,
|
|
&commit_ref,
|
|
description,
|
|
requester,
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("{e:#}"))
|
|
{
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
return AgentResponse::Err {
|
|
message: format!("queue update_meta_inputs approval: {e:#}"),
|
|
};
|
|
}
|
|
};
|
|
tracing::info!(%id, %label, "update_meta_inputs approval queued");
|
|
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
|
id,
|
|
agent: requester,
|
|
approval_kind: "update_meta_inputs",
|
|
sha_short: None,
|
|
diff: None,
|
|
description: description.map(str::to_owned),
|
|
pr_number: None,
|
|
});
|
|
AgentResponse::Ok
|
|
}
|
|
|
|
/// `request_apply_commit` takes a commit SHA only — not a branch or
|
|
/// tag name. A branch is mutable; pinning the proposal to a concrete
|
|
/// sha keeps "what the manager asked to deploy" unambiguous and means
|
|
/// the `proposal/<id>` tag is a faithful record of the request.
|
|
/// Accepts a 7..=40 char hex string (short or full sha); the exact
|
|
/// commit is resolved + existence-checked against the proposed repo
|
|
/// later in `lifecycle::git_fetch_to_tag`.
|
|
pub(crate) fn validate_commit_ref(commit_ref: &str) -> Result<()> {
|
|
let n = commit_ref.len();
|
|
let hex = commit_ref.chars().all(|c| c.is_ascii_hexdigit());
|
|
if !(7..=40).contains(&n) || !hex {
|
|
anyhow::bail!(
|
|
"commit_ref '{commit_ref}' is not a commit sha — request_apply_commit \
|
|
takes a 7-40 char hex sha, not a branch or tag name"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Queue an `InitConfig` approval for a brand-new agent whose config repo
|
|
/// does not yet exist. Shared between the manager and agent sockets.
|
|
///
|
|
/// `parent`, when `Some`, is the agent that will own the new child once
|
|
/// the operator approves: it is stashed in the approval's `commit_ref`
|
|
/// field (unused for `InitConfig` otherwise — same pattern
|
|
/// `UpdateMetaInputs` uses to carry its inputs JSON) and consumed in
|
|
/// `run_approval_init_config` to write the `child -> parent` topology
|
|
/// edge. Callers pass the requesting agent, so the requester becomes the
|
|
/// new agent's parent (the root requesting a new agent → a top-level agent,
|
|
/// matching `topology::reconcile`'s default). `None` writes no explicit
|
|
/// edge (reconcile-default placement) — retained for that fallback.
|
|
pub(crate) fn submit_init_config(
|
|
coord: &Arc<Coordinator>,
|
|
name: &str,
|
|
parent: Option<&str>,
|
|
description: Option<String>,
|
|
) -> anyhow::Result<i64> {
|
|
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name);
|
|
if proposed_dir.join(".git").exists() {
|
|
anyhow::bail!(
|
|
"proposed config repo for '{name}' already exists at {} - \
|
|
use request_apply_commit to update an existing agent's config",
|
|
proposed_dir.display()
|
|
);
|
|
}
|
|
let id = coord
|
|
.approvals
|
|
.submit_kind(
|
|
name,
|
|
hive_sh4re::ApprovalKind::InitConfig,
|
|
parent.unwrap_or(""),
|
|
description.as_deref(),
|
|
// `parent` is the requesting agent (becomes the new child's
|
|
// parent); it's also the submitter the approval events route
|
|
// back to. No declared parent = operator-initiated path.
|
|
parent.unwrap_or("operator"),
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
|
tracing::info!(%id, %name, "init_config approval queued");
|
|
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
|
id,
|
|
agent: name,
|
|
approval_kind: "init_config",
|
|
sha_short: None,
|
|
diff: None,
|
|
description,
|
|
pr_number: None,
|
|
});
|
|
Ok(id)
|
|
}
|
|
|
|
/// Submit-time half of the apply flow: queue the approval row, then
|
|
/// fetch the manager's commit from the proposed repo into applied and
|
|
/// pin it as `refs/tags/proposal/<id>`. From this point on the manager
|
|
/// repo is irrelevant for this approval — even if the manager amends
|
|
/// or force-pushes, the canonical sha hive-c0re will eventually
|
|
/// approve/deny lives in applied's object DB.
|
|
///
|
|
/// If anything fails after the row is inserted (sha missing in
|
|
/// proposed, fs error, git plumbing crash) we mark the row failed and
|
|
/// surface the error to the manager. We don't try to roll the row
|
|
/// back — the failure is part of the audit trail.
|
|
pub(crate) async fn submit_apply_commit(
|
|
coord: &Arc<Coordinator>,
|
|
agent: &str,
|
|
commit_ref: &str,
|
|
description: Option<&str>,
|
|
submitter: &str,
|
|
) -> anyhow::Result<(i64, String)> {
|
|
validate_commit_ref(commit_ref)?;
|
|
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(agent);
|
|
let applied_dir = crate::paths::applied_dir(agent);
|
|
if !proposed_dir.exists() {
|
|
anyhow::bail!(
|
|
"proposed repo missing for agent '{agent}' (expected at {})",
|
|
proposed_dir.display()
|
|
);
|
|
}
|
|
if !applied_dir.join(".git").exists() {
|
|
// First deploy: seed the applied repo from proposed so we can plant
|
|
// the proposal/<id> tag below. setup_applied seeds at the root
|
|
// (template) commit of proposed, not at main, so deployed/0 is the
|
|
// template baseline. This makes the diff mara sees on approval
|
|
// show the manager's actual changes rather than an empty diff.
|
|
crate::lifecycle::setup_applied(&applied_dir, Some(&proposed_dir), agent)
|
|
.await
|
|
.context("seed applied repo for first spawn")?;
|
|
}
|
|
let id = coord
|
|
.approvals
|
|
.submit_kind(
|
|
agent,
|
|
hive_sh4re::ApprovalKind::ApplyCommit,
|
|
commit_ref,
|
|
description,
|
|
submitter,
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
|
|
let tag = format!("proposal/{id}");
|
|
let sha =
|
|
match crate::lifecycle::git_fetch_to_tag(&applied_dir, &proposed_dir, commit_ref, &tag)
|
|
.await
|
|
{
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
// Surface the failure on the approval row so the
|
|
// dashboard reflects it instead of leaving a phantom
|
|
// pending entry. The note doubles as the operator-visible
|
|
// explanation of why the approval can't be approved.
|
|
let note = format!("{e:#}");
|
|
let _ = coord.approvals.mark_failed(id, ¬e);
|
|
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
|
id,
|
|
agent,
|
|
approval_kind: "apply_commit",
|
|
sha_short: None,
|
|
status: "failed",
|
|
note: Some(note),
|
|
description: description.map(str::to_owned),
|
|
});
|
|
return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
|
|
}
|
|
};
|
|
coord
|
|
.approvals
|
|
.set_fetched_sha(id, &sha)
|
|
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
|
|
// Pre-flight gates: both reject the apply before approval if
|
|
// the agent's flake state would inflate meta's lock with duplicates
|
|
// or lie about what nix will fetch. Both checks independently read
|
|
// `<tag>:flake.lock` via git — they don't share state. Order matters
|
|
// only for early-exit + messaging: sync first means a stale lock
|
|
// bails with the actionable "run `nix flake lock`" hint rather than
|
|
// a dedup pass on a lock nix would never produce.
|
|
//
|
|
// Runs after `set_fetched_sha` so the failed row carries the sha
|
|
// that broke. Both failure paths mark + emit, then bail.
|
|
let sha_short = sha[..sha.len().min(12)].to_owned();
|
|
if let Err(e) = crate::flake_check::check_lock_in_sync(&applied_dir, &tag, id).await {
|
|
let note = format!("{e:#}");
|
|
let _ = coord.approvals.mark_failed(id, ¬e);
|
|
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
|
id,
|
|
agent,
|
|
approval_kind: "apply_commit",
|
|
sha_short: Some(sha_short.clone()),
|
|
status: "failed",
|
|
note: Some(note),
|
|
description: description.map(str::to_owned),
|
|
});
|
|
return Err(anyhow::anyhow!("flake lock-sync check: {e:#}"));
|
|
}
|
|
if let Err(e) = crate::flake_check::check_no_duplicate_inputs(&applied_dir, &tag).await {
|
|
let note = format!("{e:#}");
|
|
let _ = coord.approvals.mark_failed(id, ¬e);
|
|
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
|
id,
|
|
agent,
|
|
approval_kind: "apply_commit",
|
|
sha_short: Some(sha_short.clone()),
|
|
status: "failed",
|
|
note: Some(note),
|
|
description: description.map(str::to_owned),
|
|
});
|
|
return Err(anyhow::anyhow!("flake dedup check: {e:#}"));
|
|
}
|
|
// Mirror the freshly-planted proposal/<id> tag to the forge.
|
|
if let Err(e) = crate::forge::push_config(agent).await {
|
|
tracing::warn!(%agent, %id, error = ?e, "forge: push_config after submit failed");
|
|
}
|
|
// Phase 5b: surface the new pending approval on the dashboard
|
|
// event channel. Compute the diff once here so live subscribers
|
|
// get a fully-formed row without a snapshot refetch. `sha_short`
|
|
// is reused from the dedup gate above.
|
|
let diff = crate::dashboard::approval_diff(agent, id).await;
|
|
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
|
id,
|
|
agent,
|
|
approval_kind: "apply_commit",
|
|
sha_short: Some(sha_short),
|
|
diff: Some(diff),
|
|
description: description.map(str::to_owned),
|
|
pr_number: None,
|
|
});
|
|
Ok((id, sha))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn accepts_short_and_full_sha() {
|
|
assert!(validate_commit_ref("e194f78").is_ok());
|
|
assert!(validate_commit_ref("e194f7812ab").is_ok());
|
|
assert!(validate_commit_ref(&"a".repeat(40)).is_ok());
|
|
// Uppercase hex resolves fine through `git rev-parse`.
|
|
assert!(validate_commit_ref("E194F78").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_branch_and_tag_names() {
|
|
// The exact bug class this guard exists for.
|
|
assert!(validate_commit_ref("main").is_err());
|
|
assert!(validate_commit_ref("HEAD").is_err());
|
|
assert!(validate_commit_ref("deployed/0").is_err());
|
|
assert!(validate_commit_ref("feature-branch").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_too_short_too_long_and_empty() {
|
|
assert!(validate_commit_ref("").is_err());
|
|
assert!(validate_commit_ref("abc123").is_err()); // 6 chars
|
|
assert!(validate_commit_ref(&"a".repeat(41)).is_err());
|
|
}
|
|
}
|