refactor(#2416): remove the non-pr config-change flow (request_apply_commit / applycommit)

This commit is contained in:
damocles 2026-07-15 20:45:29 +02:00 committed by mara
commit c2bd7db998
34 changed files with 293 additions and 1635 deletions

View file

@ -15,10 +15,10 @@ use crate::lifecycle;
/// either runs the work inline (`InitConfig`, sub-second git ops) or
/// submits it to the job queue so the dashboard POST returns
/// immediately while the long-running pipeline runs off-thread
/// (operator no longer blocks on a 30-90s spinner for `ApplyCommit`).
/// (operator no longer blocks on a 30-90s spinner for `MergeConfigPr`).
///
/// Dispatch:
/// - `ApplyCommit` / `MergeConfigPr` → a single-node `ApprovalDeploy`
/// - `MergeConfigPr` → a single-node `ApprovalDeploy`
/// DAG (the two-phase meta deploy stays opaque in v1; ~30-90s)
/// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion)
/// - `Spawn` → a `Spawn` DAG (`Create → WriteDropin → Reconcile`)
@ -46,15 +46,6 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await
}
ApprovalKind::ApplyCommit => {
enqueue_approval_rebuild(
&coord,
&approval.agent,
id,
format!("approval #{id} apply commit"),
);
Ok(())
}
ApprovalKind::UpdateMetaInputs => {
// Inputs JSON-encoded into commit_ref by the manager's
// submit path — surface them on the DAG so the dashboard
@ -109,11 +100,11 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
result
}
ApprovalKind::MergeConfigPr => {
// Like ApplyCommit, the work ends in a container rebuild, so
// route it through the rebuild queue. The queue worker
// dispatches MergeConfigPr approvals to `run_merge_config_pr`
// (verify the reviewed PR head, ff the forge config repo's
// main to it, mark merged, then the shared deploy tail).
// The work ends in a container rebuild, so route it through the
// rebuild queue. The queue worker dispatches MergeConfigPr
// approvals to `run_merge_config_pr` (verify the reviewed PR head,
// ff the forge config repo's main to it, mark merged, then the
// deploy tail).
enqueue_approval_rebuild(
&coord,
&approval.agent,
@ -126,10 +117,9 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
}
/// Submit the single-node `ApprovalDeploy` DAG tied to an approval id.
/// Shared by the `ApplyCommit` and `MergeConfigPr` dispatch arms — both
/// end in a container rebuild routed through the queue, differing only
/// in the `reason`. The node executor branches on the approval's kind
/// to pick the right handler.
/// Used by the `MergeConfigPr` dispatch arm — the work ends in a container
/// rebuild routed through the queue; the node executor runs
/// `run_merge_config_pr`.
fn enqueue_approval_rebuild(
coord: &Arc<Coordinator>,
agent: &str,
@ -149,40 +139,9 @@ fn enqueue_approval_rebuild(
coord.emit_rebuild_queue_snapshot();
}
/// Worker entry point for `ApprovalKind::ApplyCommit` queue entries.
/// Re-fetches the approval row, runs the commit pipeline, and fires
/// `ApprovalResolved` + the lifecycle event (`Rebuilt` / `Spawned`
/// for first-spawn).
pub async fn run_approval_apply_commit(
coord: &Arc<Coordinator>,
queue_entry_id: Option<u64>,
approval_id: i64,
) -> Result<()> {
let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::ApplyCommit)?;
// Runtime dir creation is handled inside lifecycle::rebuild_no_meta's
// spawn path (first-spawn) or is already present for rebuilds.
let agent_dir = crate::paths::agent_runtime_dir(&approval.agent);
let applied_dir = crate::paths::applied_dir(&approval.agent);
coord.set_queue_step(queue_entry_id, "apply commit");
let (result, terminal_tag, is_first_spawn) =
run_apply_commit(coord, &approval, &agent_dir, &applied_dir, queue_entry_id).await;
coord.set_queue_step(queue_entry_id, "forge push");
if let Err(e) = crate::forge::push_config(&approval.agent).await {
tracing::warn!(agent = %approval.agent, error = ?e, "forge: push_config after apply failed");
}
if is_first_spawn && result.is_ok() {
coord.set_queue_step(queue_entry_id, "first-spawn forge bootstrap");
forge_after_first_spawn(coord, &approval.agent).await;
}
// `finish_approval` returns the original `result` so the queue
// worker sees Ok/Err and marks the queue entry accordingly. The
// approval row + helper events have already been fanned out.
finish_approval(coord, &approval, result, terminal_tag, is_first_spawn)
}
/// Worker entry point for `ApprovalKind::MergeConfigPr` queue entries —
/// the PR-based config flow's counterpart to `run_approval_apply_commit`.
/// Re-fetches the approval row, runs the merge pipeline, and fires
/// Worker entry point for `ApprovalKind::MergeConfigPr` queue entries — the
/// config-change flow's deploy worker. Re-fetches the approval row, runs the
/// merge pipeline, and fires
/// `ApprovalResolved` + the `Rebuilt` lifecycle event via `finish_approval`.
/// `run_merge_config_pr` already fast-forwarded the forge repo's `main` to the
/// reviewed head (that IS the merge), so `push_config`'s `main` refspec is a
@ -218,7 +177,7 @@ pub async fn run_approval_merge_config_pr(
if let Err(e) = &result {
post_merge_failure_to_pr(coord, &approval, since_ts, e).await;
}
finish_approval(coord, &approval, result, terminal_tag, false)
finish_approval(coord, &approval, result, terminal_tag)
}
/// Max stderr bytes to inline in a PR failure comment. Keeps the comment
@ -298,7 +257,7 @@ fn tail_bytes(s: &str, max_bytes: usize) -> String {
/// 2. fetch the reviewed head into the applied repo so later git ops resolve
/// it locally;
/// 3. eval-verify the reviewed commit against the meta flake BEFORE the
/// irreversible push (same gate `run_apply_commit` uses);
/// irreversible push;
/// 4. fast-forward the forge repo's `main` to the reviewed head — THE merge;
/// 5. mark the PR merged (best-effort: `main` is already at the head, so a
/// failure here is logged, not fatal);
@ -399,8 +358,7 @@ async fn run_merge_config_pr(
Err(e) => return (Err(anyhow::anyhow!("ff-merge PR #{pr}: {e}")), None),
}
// 5. Shared deploy tail. target == finalize == the reviewed head;
// never a first spawn (the agent already exists).
// 5. Deploy tail. target == finalize == the reviewed head.
deploy_applied_target(
coord,
&approval.agent,
@ -410,7 +368,6 @@ async fn run_merge_config_pr(
&reviewed,
id,
&prev_main_sha,
false,
queue_entry_id,
)
.await
@ -443,7 +400,7 @@ async fn run_approval_schedule_prompt(
.context("insert scheduled prompt")
}
.await;
finish_approval(coord, &approval, result, None, false)
finish_approval(coord, &approval, result, None)
}
/// Terminal hook for approval-carrying DAGs — the job queue's
@ -497,7 +454,7 @@ pub(crate) async fn resolve_approval_dag(
crate::dashboard::emit_tombstones_snapshot(coord).await;
}
}
if let Err(e) = finish_approval(coord, &approval, result, None, false) {
if let Err(e) = finish_approval(coord, &approval, result, None) {
tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure");
}
}
@ -590,7 +547,7 @@ async fn run_approval_init_config(
{
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed");
}
finish_approval(coord, &approval, result, None, false)
finish_approval(coord, &approval, result, None)
}
fn finish_approval(
@ -598,7 +555,6 @@ fn finish_approval(
approval: &hive_sh4re::Approval,
result: Result<()>,
terminal_tag: Option<String>,
is_first_spawn: bool,
) -> Result<()> {
let (status, note, ok) = match &result {
Ok(()) => (ApprovalStatus::Approved, None, true),
@ -665,22 +621,10 @@ fn finish_approval(
sha: approval.fetched_sha.clone(),
},
),
ApprovalKind::ApplyCommit if is_first_spawn => {
coord.notify_submitter(
approval.id,
&HelperEvent::Spawned {
agent: approval.agent.clone(),
ok,
note,
sha: approval.fetched_sha.clone(),
},
);
}
// MergeConfigPr ends in a container rebuild just like a
// non-first-spawn ApplyCommit, so both surface the same Rebuilt
// lifecycle event. (MergeConfigPr is never a first spawn — the
// agent already exists — so it never hits the Spawned arm above.)
ApprovalKind::ApplyCommit | ApprovalKind::MergeConfigPr => {
// MergeConfigPr ends in a container rebuild — surface a Rebuilt
// lifecycle event. (It is never a first spawn — the agent already
// exists — so it never needs the Spawned arm above.)
ApprovalKind::MergeConfigPr => {
coord.notify_submitter(
approval.id,
&HelperEvent::Rebuilt {
@ -699,148 +643,25 @@ fn finish_approval(
result
}
/// Tag-driven `ApplyCommit` handler. Walks the approval through the tag
/// state machine documented in `docs/approvals.md`: stamp
/// `approved/<id>` and `building/<id>` first so the audit trail
/// captures intent, then drop the candidate tree into the working dir
/// without moving HEAD, run the rebuild, and either fast-forward
/// `applied/main` to the proposal commit on success
/// (`deployed/<id>`) or annotate `failed/<id>` with the build error
/// and reset the working tree back to the last known-good main. main
/// never advances on a failed build, so a crash-and-recover doesn't
/// leave the agent pointing at a tree it can't evaluate. The shared
/// ff/deploy/rebuild/finalize tail lives in `deploy_applied_target`.
async fn run_apply_commit(
coord: &Arc<Coordinator>,
approval: &hive_sh4re::Approval,
agent_dir: &std::path::Path,
applied_dir: &std::path::Path,
queue_entry_id: Option<u64>,
) -> (Result<()>, Option<String>, bool) {
let id = approval.id;
let proposal_ref = format!("refs/tags/proposal/{id}");
// Detect first spawn before we touch anything so we can branch on it
// throughout this function.
let is_first_spawn = !lifecycle::container_exists(&approval.agent).await;
// Defensive: submit-time should have planted proposal/<id>, but if
// the row was migrated from an older schema or the tag got pruned
// we fail early with a clear note rather than building a stale
// tree.
if let Err(e) = lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
return (
Err(anyhow::anyhow!(
"missing proposal tag {proposal_ref}: {e:#}"
)),
None,
is_first_spawn,
);
}
// Capture the currently-deployed sha so we can roll applied/main
// (and the meta lock indirectly) back if the build fails.
let prev_main_sha = match lifecycle::git_rev_parse(applied_dir, "refs/heads/main").await {
Ok(s) => s,
Err(e) => {
return (
Err(anyhow::anyhow!("read applied/main: {e:#}")),
None,
is_first_spawn,
);
}
};
// Pre-flight eval-verify the proposal commit against the meta flake
// WITHOUT mutating applied/main or the meta lock, so an evaluation
// error (bad nix, missing module option, unresolvable lock) fails
// fast here instead of after we've fast-forwarded main and have to
// roll it back. Skipped on first spawn: the agent has no
// `agent-<name>` meta input to override yet (sync_agents adds it
// below). This is the reusable verify primitive the PR-based config
// flow gates its irreversible ff-push on.
if !is_first_spawn {
let proposal_sha = match lifecycle::git_rev_parse(applied_dir, &proposal_ref).await {
Ok(s) => s,
Err(e) => {
return (
Err(anyhow::anyhow!("rev-parse {proposal_ref}: {e:#}")),
None,
is_first_spawn,
);
}
};
coord.set_queue_step(queue_entry_id, "verify proposal (eval)");
if let Err(e) =
crate::meta::verify_commit(&approval.agent, applied_dir, &proposal_sha).await
{
return (
Err(anyhow::anyhow!("verify proposal {proposal_ref}: {e:#}")),
None,
is_first_spawn,
);
}
}
coord.set_queue_step(queue_entry_id, "plant tags");
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("approved/{id}"), &proposal_ref).await
{
return (
Err(anyhow::anyhow!("plant approved/{id}: {e:#}")),
None,
is_first_spawn,
);
}
if let Err(e) = lifecycle::git_tag(applied_dir, &format!("building/{id}"), &proposal_ref).await
{
return (
Err(anyhow::anyhow!("plant building/{id}: {e:#}")),
None,
is_first_spawn,
);
}
// Fast-forward applied/main to the proposal, run the meta deploy +
// container rebuild, and finalize/roll-back — the tail shared with the
// PR-merge flow. ApplyCommit's target == finalize sha source is
// `fetched_sha` (or the proposal ref when unset), matching the prior
// inline behavior exactly.
let (result, tag) = deploy_applied_target(
coord,
&approval.agent,
agent_dir,
applied_dir,
&proposal_ref,
approval.fetched_sha.as_deref().unwrap_or(&proposal_ref),
id,
&prev_main_sha,
is_first_spawn,
queue_entry_id,
)
.await;
(result, tag, is_first_spawn)
}
/// Shared deploy tail for config-applying approvals (`ApplyCommit` + the
/// PR-merge flow). Fast-forwards `applied/main` to `target_ref`, syncs the
/// working tree, runs the meta two-phase deploy + container rebuild, and
/// plants the `deployed/<tag_base>` / `failed/<tag_base>` bookkeeping tags.
/// On build failure it rolls `applied/main` back to `prev_main_sha` and aborts
/// the staged meta lock so the agent stays on its last-good tree. Returns the
/// build result + the terminal tag name.
/// Deploy tail for the config-PR merge flow. Fast-forwards `applied/main` to
/// `target_ref`, syncs the working tree, runs the meta two-phase deploy +
/// container rebuild, and plants the `deployed/<tag_base>` /
/// `failed/<tag_base>` bookkeeping tags. On build failure it rolls
/// `applied/main` back to `prev_main_sha` and aborts the staged meta lock so
/// the agent stays on its last-good tree. Returns the build result + the
/// terminal tag name.
///
/// Caller-specific bits stay OUT of here: the source fetch (proposal tag vs
/// forge fetch), the `approved/building` tags, `verify_commit`, and any forge
/// ff-push / mark-merged. `is_first_spawn` gates the one-time meta
/// `sync_agents` step (only `ApplyCommit`'s first spawn passes `true`;
/// the PR-merge flow always passes `false` — the agent already exists).
/// `finalize_sha` is the sha recorded by `meta::finalize_deploy`; `target_ref`
/// is what `applied/main` fast-forwards to (a proposal ref or a commit sha).
/// Caller-specific bits stay OUT of here: fetching the PR head, the
/// `verify_commit` gate, the ff-merge, and forge mark-merged. `finalize_sha`
/// is the sha recorded by `meta::finalize_deploy`; `target_ref` is what
/// `applied/main` fast-forwards to. The agent always already exists here (a
/// merge is never a first spawn), so there's no `sync_agents` step — the
/// operator `Spawn` flow owns first-time meta registration.
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "one sequential ff/deploy/rebuild/finalize pipeline shared by both \
config-apply callers; splitting it would obscure the linear flow"
reason = "one sequential ff/deploy/rebuild/finalize pipeline; splitting it \
would obscure the linear flow"
)]
async fn deploy_applied_target(
coord: &Arc<Coordinator>,
@ -851,7 +672,6 @@ async fn deploy_applied_target(
finalize_sha: &str,
tag_base: i64,
prev_main_sha: &str,
is_first_spawn: bool,
queue_entry_id: Option<u64>,
) -> (Result<()>, Option<String>) {
let id = tag_base;
@ -872,33 +692,6 @@ async fn deploy_applied_target(
return (Err(anyhow::anyhow!("read-tree to main: {e:#}")), None);
}
// First spawn: sync_agents must add this agent to the meta flake
// before prepare_deploy can update its input lock (which won't
// exist yet if this is the agent's first deploy).
if is_first_spawn {
coord.set_queue_step(queue_entry_id, "meta sync_agents (first spawn)");
let agents = match lifecycle::agents_for_meta_listing_with(agent).await {
Ok(a) => a,
Err(e) => {
let _ =
lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await;
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
return (
Err(anyhow::anyhow!("agents_for_meta_listing_with: {e:#}")),
None,
);
}
};
if let Err(e) = crate::meta::sync_agents(&coord.hive_env(), &agents).await {
let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await;
let _ = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await;
return (
Err(anyhow::anyhow!("meta sync_agents for first spawn: {e:#}")),
None,
);
}
}
coord.set_queue_step(queue_entry_id, "meta prepare_deploy");
// Phase 1 of the meta two-phase deploy: relock without committing.
if let Err(e) = crate::meta::prepare_deploy(agent).await {
@ -1066,42 +859,14 @@ async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
crate::meta::sync_agents(&coord.hive_env(), &agents).await
}
pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
let approval = coord.approvals.get(id)?;
coord.approvals.mark_denied(id, note)?;
tracing::info!(%id, note, "approval denied");
let mut tag = None;
// MergeConfigPr denials carry no git tag — the PR stays open on the forge.
let tag: Option<String> = None;
if let Some(a) = approval {
let sha = a.fetched_sha.clone();
// ApplyCommit approvals leave a `denied/<id>` tag on the
// proposal commit so rejected configs are first-class git
// objects — `git show denied/<id>` in the manager's applied
// mount yields both the tree the operator rejected and (in
// the annotated body) the reason. Spawn approvals have no
// commit to tag, so they fall through unannotated.
if matches!(a.kind, ApprovalKind::ApplyCommit) {
let applied_dir = crate::paths::applied_dir(&a.agent);
let proposal_ref = format!("refs/tags/proposal/{id}");
if lifecycle::git_rev_parse(&applied_dir, &proposal_ref)
.await
.is_ok()
{
let tag_name = format!("denied/{id}");
let body = note.unwrap_or("").to_owned();
if let Err(e) =
lifecycle::git_tag_annotated(&applied_dir, &tag_name, &proposal_ref, &body)
.await
{
tracing::warn!(%id, error = ?e, "plant denied tag failed");
} else {
tag = Some(tag_name);
}
}
// Mirror the denied/<id> tag to the forge.
if let Err(e) = crate::forge::push_config(&a.agent).await {
tracing::warn!(%id, agent = %a.agent, error = ?e, "forge: push_config after deny failed");
}
}
let approval_kind = a.kind.as_str();
let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned());
let description = a.description.clone();

View file

@ -423,7 +423,6 @@ pub struct ApprovalAdded<'a> {
pub agent: &'a str,
pub approval_kind: &'static str,
pub sha_short: Option<String>,
pub diff: Option<String>,
pub description: Option<String>,
pub pr_number: Option<u64>,
}
@ -789,15 +788,13 @@ impl Coordinator {
}
/// Emit `ApprovalAdded` immediately after the row is inserted in
/// sqlite. Caller passes the diff text it already computed (or
/// `None` for spawn approvals which carry no diff).
/// sqlite.
pub fn emit_approval_added(&self, ev: ApprovalAdded<'_>) {
let ApprovalAdded {
id,
agent,
approval_kind,
sha_short,
diff,
description,
pr_number,
} = ev;
@ -807,7 +804,6 @@ impl Coordinator {
agent: agent.to_owned(),
approval_kind,
sha_short,
diff,
description,
pr_number,
});

View file

@ -1,13 +1,8 @@
//! Approval endpoints + diff machinery for the dashboard.
//! Approval endpoints for the dashboard.
//!
//! Approve/deny actions, the orphan-approval GC sweep used by the
//! `/api/state` builder, and the unified-diff endpoints (on-demand
//! `/api/approval-diff/{id}` against a chosen base, plus the `pub(crate)`
//! `approval_diff` the manager-socket handler pre-computes at submit time).
//! Approve/deny actions plus the orphan-approval GC sweep used by the
//! `/api/state` builder.
use std::path::Path;
use anyhow::{Context, Result};
use axum::{
extract::{Form, Path as AxumPath, State},
http::StatusCode,
@ -16,12 +11,9 @@ use axum::{
use hive_sh4re::Approval;
use serde::Deserialize;
use problem_details::ProblemDetails;
use super::{AppState, error_problem, error_response};
use super::{AppState, error_response};
use crate::actions;
use crate::coordinator::Coordinator;
use crate::lifecycle;
pub(super) async fn post_approve(
State(state): State<AppState>,
@ -53,7 +45,7 @@ pub(super) async fn post_deny(
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match actions::deny(&state.coord, id, note).await {
match actions::deny(&state.coord, id, note) {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
}
@ -87,7 +79,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
id: a.id,
agent: &a.agent,
approval_kind: "apply_commit",
approval_kind: a.kind.as_str(),
sha_short,
status: "failed",
note: Some(note.to_owned()),
@ -98,142 +90,3 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
})
.collect()
}
/// Multi-file unified diff between the currently-deployed tree and
/// the proposal for this approval. Runs against the applied repo
/// since the canonical proposal commit lives there (manager-side
/// amendments don't move it). Empty output means proposal == main —
/// a no-op approval.
///
/// `pub(crate)` so the manager-socket handler can pre-compute the
/// diff once at submission time and embed it in the `ApprovalAdded`
/// dashboard event (instead of forcing the dashboard to wait a
/// `/api/state` cycle to see the diff for newly-queued approvals).
pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String {
let applied = crate::paths::applied_dir(agent);
if !applied.join(".git").exists() {
return format!("(no applied git repo at {})", applied.display());
}
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
match git_diff_refs(&applied, "refs/heads/main", &proposal_ref).await {
Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
Ok(s) => s,
Err(e) => format!("(error: {e:#})"),
}
}
async fn git_diff_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result<String> {
let out = lifecycle::git_command()
.current_dir(applied_dir)
.args(["diff", &format!("{base_ref}..{target_ref}")])
.output()
.await
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"git diff {base_ref}..{target_ref} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Numeric ids of `<prefix>/<n>` tags in the applied repo (e.g.
/// `proposal/3` → `3`). Unparseable suffixes are skipped. Used to
/// resolve the `approved` / `previous` diff bases for an approval.
async fn tag_ids(applied_dir: &Path, prefix: &str) -> Vec<i64> {
let Ok(out) = lifecycle::git_command()
.current_dir(applied_dir)
.args(["tag", "-l", &format!("{prefix}/*")])
.output()
.await
else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let strip = format!("{prefix}/");
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.trim().strip_prefix(&strip))
.filter_map(|s| s.parse::<i64>().ok())
.collect()
}
#[derive(Deserialize)]
pub(super) struct DiffBaseQuery {
/// `applied` (running tree — default), `approved` (most recent
/// earlier approved proposal), or `previous` (the prior queued
/// proposal for this agent).
base: Option<String>,
}
/// On-demand unified diff for one `ApplyCommit` approval against a
/// chosen base. `applied` = `applied/main` (what's running);
/// `approved` = the most recent earlier `approved/<n>` tag (the last
/// proposal the operator OK'd, even if its build then failed);
/// `previous` = the prior queued `proposal/<n>` (the incremental
/// delta when the manager chains proposals). Returns the raw diff
/// text — the dashboard classifies lines client-side.
pub(super) async fn get_approval_diff(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
) -> Result<Response, ProblemDetails> {
let base = q.base.as_deref().unwrap_or("applied");
let approval = match state.coord.approvals.get(id) {
Ok(Some(a)) => a,
Ok(None) => return Err(error_problem(&format!("approval {id} not found"))),
Err(e) => return Err(error_problem(&format!("approval {id}: {e:#}"))),
};
if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) {
return Err(error_problem("spawn approvals carry no commit to diff"));
}
let applied = crate::paths::applied_dir(&approval.agent);
if !applied.join(".git").exists() {
return Ok(plain_text(format!(
"(no applied git repo at {})",
applied.display()
)));
}
let target = format!("refs/tags/proposal/{id}");
let base_ref = match base {
"applied" => Some("refs/heads/main".to_owned()),
"approved" => {
let ids = tag_ids(&applied, "approved").await;
ids.into_iter()
.filter(|&n| n != id)
.max()
.map(|n| format!("refs/tags/approved/{n}"))
}
"previous" => {
let ids = tag_ids(&applied, "proposal").await;
ids.into_iter()
.filter(|&n| n < id)
.max()
.map(|n| format!("refs/tags/proposal/{n}"))
}
other => {
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail(format!("unknown diff base {other:?}")));
}
};
let Some(base_ref) = base_ref else {
return Ok(plain_text(match base {
"approved" => "(no earlier approved proposal to diff against)".to_owned(),
_ => "(no previous proposal to diff against)".to_owned(),
}));
};
match git_diff_refs(&applied, &base_ref, &target).await {
Ok(s) if s.is_empty() => Ok(plain_text(
"(identical — no changes vs this base)".to_owned(),
)),
Ok(s) => Ok(plain_text(s)),
Err(e) => Err(error_problem(&format!("git diff: {e:#}"))),
}
}
fn plain_text(body: String) -> Response {
(StatusCode::OK, body).into_response()
}

View file

@ -202,7 +202,7 @@ pub(super) async fn post_request_spawn(
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
// Phase 5b: notify the dashboard event channel so live
// subscribers can append the row without a snapshot
// refetch. Spawn approvals carry no diff/sha.
// refetch. Spawn approvals carry no sha.
state
.coord
.emit_approval_added(crate::coordinator::ApprovalAdded {
@ -210,7 +210,6 @@ pub(super) async fn post_request_spawn(
agent: &name,
approval_kind: "spawn",
sha_short: None,
diff: None,
description: None,
pr_number: None,
});

View file

@ -35,11 +35,6 @@ mod tombstones;
mod topology;
mod webhook;
// Pre-computed at approval-submit time by the manager-socket handler
// (`socket_server.rs`) and embedded in the `ApprovalAdded` event, so
// re-exported at the module root to preserve the `crate::dashboard::approval_diff`
// path across the submodule split.
pub(crate) use approvals::approval_diff;
// Run after lock bumps by the job queue (`job_queue/exec.rs`); the view
// type feeds `DashboardEvent::MetaInputsChanged` (`dashboard_events.rs`).
// Re-exported to preserve the `crate::dashboard::*` paths across the split.
@ -83,7 +78,6 @@ pub async fn serve(
.route("/api/state", get(state_snapshot::api_state))
.route("/api/journal/{name}", get(journal::get_journal))
.route("/api/journal-host", get(journal::get_journal_host))
.route("/api/approval-diff/{id}", get(approvals::get_approval_diff))
.route("/api/state-file", get(state_files::get_state_file))
.route(
"/api/matrix-accounts",

View file

@ -23,7 +23,7 @@ use crate::container_view::ContainerView;
use super::meta_inputs::{MetaInputView, read_meta_inputs};
use super::tombstones::{TombstoneView, build_tombstone_views};
use super::{AppState, approval_diff, approvals, error_response, scan_validated_paths};
use super::{AppState, approvals, error_response, scan_validated_paths};
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize)]
@ -233,31 +233,16 @@ struct ApprovalView {
id: i64,
agent: String,
kind: &'static str,
/// First 12 chars of the `commit_ref`, for `ApplyCommit` only.
/// Display-only (the short chip on the card).
/// First 12 chars of the reviewed PR head sha, for `MergeConfigPr`
/// only. Display-only (the short chip on the card).
sha_short: Option<String>,
/// Full commit sha, for `ApplyCommit` only. The frontend builds the
/// "commit on forge" link from this rather than `sha_short`: forgejo
/// 404s an abbreviated sha for the proposal commit (it lives on a
/// `proposal/<id>` tag ref, which forgejo won't disambiguate a short
/// hash against), but resolves the full 40-char sha by direct lookup.
#[serde(skip_serializing_if = "Option::is_none")]
sha_full: Option<String>,
/// Raw unified diff text, for `ApplyCommit` only. The client splits
/// on `\n` and per-line classifies (`+` / `-` / `@@` / `--- ` / `+++ `
/// → diff-add / diff-del / diff-hunk / diff-file). Shipping raw
/// instead of pre-rendered HTML saves bytes on the wire (no
/// per-line `<span>` markup) and removes the only HTML-escape
/// surface from the snapshot.
diff: Option<String>,
/// Manager-supplied description shown on the approval card.
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
/// Forge PR number, for `MergeConfigPr` only. Lets the frontend
/// build a "review PR on forge" link
/// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`) the same
/// way it builds the `apply_commit` "commit on forge" link from the
/// sha. `None` for every other kind.
/// (`{forgeBase}/agent-configs/{agent}/pulls/{pr_number}`). `None`
/// for every other kind.
#[serde(skip_serializing_if = "Option::is_none")]
pr_number: Option<u64>,
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
@ -352,7 +337,7 @@ pub(super) async fn api_state(
log_default("approvals.pending", state.coord.approvals.pending()),
);
let transients = build_transient_views(&containers, &transient_snapshot);
let approvals = build_approval_views(pending_approvals).await;
let approvals = build_approval_views(pending_approvals);
let approval_history = log_default(
"approvals.recent_resolved",
state.coord.approvals.recent_resolved(30),
@ -544,8 +529,8 @@ fn transient_label(k: crate::coordinator::TransientKind) -> &'static str {
}
}
/// Render each pending approval into its dashboard view (short sha +
/// unified diff for `ApplyCommit`, just the name for `Spawn`).
/// Render each pending approval into its dashboard view (short sha for
/// `MergeConfigPr`, just the name for `Spawn`).
/// Project a resolved sqlite row into the lean shape the dashboard
/// history tab consumes — no `diff_html` (rendering 30 of them
/// per /api/state poll would mean 30 git diffs per refresh).
@ -576,37 +561,15 @@ fn history_view(a: Approval) -> ApprovalHistoryView {
}
}
async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
let mut out = Vec::with_capacity(approvals.len());
for a in approvals {
out.push(match a.kind {
hive_sh4re::ApprovalKind::ApplyCommit => {
// Prefer the canonical fetched sha from applied;
// commit_ref is only the manager's claim and may be
// amended out from under us.
let displayed = a.fetched_sha.as_deref().unwrap_or(&a.commit_ref);
let sha = displayed[..displayed.len().min(12)].to_owned();
let diff = approval_diff(&a.agent, a.id).await;
ApprovalView {
id: a.id,
agent: a.agent.clone(),
kind: "apply_commit",
sha_short: Some(sha),
sha_full: Some(displayed.to_owned()),
diff: Some(diff),
description: a.description,
pr_number: None,
commit_ref: None,
requested_at: a.requested_at,
}
}
hive_sh4re::ApprovalKind::Spawn => ApprovalView {
id: a.id,
agent: a.agent,
kind: "spawn",
sha_short: None,
sha_full: None,
diff: None,
description: a.description,
pr_number: None,
commit_ref: None,
@ -617,8 +580,6 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
agent: a.agent,
kind: "init_config",
sha_short: None,
sha_full: None,
diff: None,
description: a.description,
pr_number: None,
commit_ref: None,
@ -629,8 +590,6 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
agent: a.agent,
kind: "update_meta_inputs",
sha_short: None,
sha_full: None,
diff: None,
description: a.description,
pr_number: None,
commit_ref: Some(a.commit_ref),
@ -641,8 +600,6 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
agent: a.agent,
kind: "schedule_prompt",
sha_short: None,
sha_full: None,
diff: None,
description: a.description,
pr_number: None,
commit_ref: Some(a.commit_ref),
@ -650,8 +607,8 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
},
hive_sh4re::ApprovalKind::MergeConfigPr => {
// commit_ref = PR number; fetched_sha = the reviewed PR
// head. Show the head sha; the forge PR diff surface is
// a later phase of the PR-based config flow — None for now.
// head. Show the head sha; the config diff surface lives
// on the forge PR itself.
let sha = a
.fetched_sha
.as_deref()
@ -664,8 +621,6 @@ async fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
agent: a.agent,
kind: "merge_config_pr",
sha_short: sha,
sha_full: None,
diff: None,
description: a.description,
pr_number,
commit_ref: None,

View file

@ -62,11 +62,10 @@ pub enum DashboardEvent {
},
/// A new approval landed in the pending queue. Payload carries
/// enough to render the dashboard row without a `/api/state`
/// refetch (`diff` is the raw unified diff text, same shape the
/// snapshot ships).
/// refetch.
///
/// The approval's own kind (`"apply_commit"` / `"spawn"`) lives on
/// `approval_kind` rather than `kind` because the latter is taken
/// The approval's own kind (`"merge_config_pr"` / `"spawn"`) lives
/// on `approval_kind` rather than `kind` because the latter is taken
/// by the serde tag identifying which `DashboardEvent` variant
/// this is.
ApprovalAdded {
@ -75,7 +74,6 @@ pub enum DashboardEvent {
agent: String,
approval_kind: &'static str,
sha_short: Option<String>,
diff: Option<String>,
description: Option<String>,
/// Forge PR number, for `merge_config_pr` approvals only — lets
/// the live `applyApprovalAdded` path build the "review PR on
@ -350,9 +348,8 @@ mod tests {
seq: 1,
id: 1,
agent: "x".into(),
approval_kind: "apply_commit",
approval_kind: "merge_config_pr",
sha_short: None,
diff: None,
description: None,
pr_number: None,
},
@ -360,7 +357,7 @@ mod tests {
seq: 1,
id: 1,
agent: "x".into(),
approval_kind: "apply_commit",
approval_kind: "merge_config_pr",
sha_short: None,
status: "approved",
resolved_at: hive_sh4re::wire_time::from_secs(0),

View file

@ -1,415 +0,0 @@
//! Pre-apply validation for agent `flake.lock` files.
//!
//! Every `request_apply_commit` lands a `proposal/<id>` tag in the
//! agent's applied repo before the operator sees the approval. We
//! parse `flake.lock` from that tag's tree and reject the request if
//! two or more nodes share an identical `original` field — that
//! signals a missing `inputs.<X>.inputs.nixpkgs.follows = "nixpkgs"`
//! directive in `flake.nix` and would inflate meta's lock with
//! duplicates after deploy.
//!
//! The check runs on the agent repo, not meta, and catches *new*
//! violations only. Existing agents whose lock already has duplicates
//! are out of scope here and get a coordinated config-change pass via
//! the manager instead.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::Path;
use anyhow::{Context, Result};
use serde_json::Value;
use tokio::process::Command;
use crate::lifecycle::git_command;
/// One group of `flake.lock` nodes that all share the same canonical
/// `original` reference. Surfaced in the rejection message so the
/// operator (and the manager that submitted the apply) can see
/// exactly which input pair needs a `follows` directive.
#[derive(Debug, Clone)]
pub struct DuplicateGroup {
/// One of the original `Value`s from the lock — used for
/// pretty-printing in the error message. Canonical equivalence
/// is enforced by the `BTreeMap` key in `duplicate_groups`, so
/// we don't need to keep the canonicalised form on the struct.
pub original: Value,
/// Names of the flake.lock nodes that share this `original`,
/// sorted for stable error output.
pub keys: Vec<String>,
}
/// Read `flake.lock` from `<tag>:flake.lock` in `repo`. Returns
/// `Ok(None)` when the file isn't tracked in that tag (no inputs ⇒
/// nothing to dedup); `Err` only on real git plumbing failures.
async fn read_lock_at_tag(repo: &Path, tag: &str) -> Result<Option<String>> {
let spec = format!("{tag}:flake.lock");
let out = git_command()
.current_dir(repo)
.args(["show", &spec])
.output()
.await
.with_context(|| format!("git show {spec} in {}", repo.display()))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
// git uses two different messages for "path not in tree"
// depending on whether the path also collides with an on-disk
// file. Both translate to "no flake.lock in this commit" —
// a legitimate, dedup-clean state for an agent with empty
// `inputs = { }`. Any other git failure (permission denied,
// ref-not-found, etc.) propagates as a hard error rather than
// being silently swallowed.
if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") {
return Ok(None);
}
anyhow::bail!("git show {spec} failed: {}", stderr.trim());
}
Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
}
/// Recursively serialise `v` with object keys sorted, so two
/// JSON values that differ only in key insertion order produce the
/// same string. `serde_json::Value` preserves `IndexMap` order by
/// default, which is fine for parsing but breaks our group-by-key
/// idea — hence this hand-rolled canonicaliser.
fn canonical_json(v: &Value) -> String {
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut s = String::from("{");
for (i, k) in keys.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&serde_json::to_string(k).unwrap_or_default());
s.push(':');
s.push_str(&canonical_json(&map[*k]));
}
s.push('}');
s
}
Value::Array(arr) => {
let mut s = String::from("[");
for (i, x) in arr.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&canonical_json(x));
}
s.push(']');
s
}
_ => serde_json::to_string(v).unwrap_or_default(),
}
}
/// Parse `raw` (a `flake.lock` JSON document) and return every group
/// of nodes whose `original` field is identical. Nodes without an
/// `original` (the synthetic `root`, or anomalous entries) are
/// skipped. Groups with only one member are filtered out — only true
/// duplicates surface.
///
/// Pure function, no I/O — covered by the unit tests below.
pub fn duplicate_groups(raw: &str) -> Result<Vec<DuplicateGroup>> {
let json: Value = serde_json::from_str(raw).context("parse flake.lock")?;
let Some(nodes) = json.get("nodes").and_then(|v| v.as_object()) else {
return Ok(Vec::new());
};
let mut groups: BTreeMap<String, DuplicateGroup> = BTreeMap::new();
for (name, node) in nodes {
let Some(original) = node.get("original") else {
continue;
};
let key = canonical_json(original);
let entry = groups.entry(key).or_insert_with(|| DuplicateGroup {
original: original.clone(),
keys: Vec::new(),
});
entry.keys.push(name.clone());
}
let mut dups: Vec<DuplicateGroup> = groups.into_values().filter(|g| g.keys.len() > 1).collect();
for g in &mut dups {
g.keys.sort();
}
Ok(dups)
}
/// Re-derive the agent's `flake.lock` from its `flake.nix` (in a
/// throw-away worktree at the proposal tag) and reject the apply when
/// the result differs from what's committed — that means the manager
/// edited `flake.nix` but didn't commit the regenerated lock, so the
/// shipped state lies about what nix will actually fetch.
///
/// Plain `nix flake lock` (no `--update-input` flags) only fills in
/// MISSING entries; it never refreshes existing ones. So a lock that
/// matches its `flake.nix` round-trips to a no-op, and any diff is a
/// real "stale lock" signal.
///
/// Materialises the proposal tag into a temp worktree under
/// `std::env::temp_dir()` to avoid touching `applied/<n>/main` while
/// the check runs. Cleanup is unconditional via the inner-fn pattern.
///
/// Returns `Ok(())` when in sync (or there's no `flake.nix` at all);
/// `Err` with a human-readable message on stale lock or nix tooling
/// failure.
pub async fn check_lock_in_sync(repo: &Path, tag: &str, approval_id: i64) -> Result<()> {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp_dir = std::env::temp_dir().join(format!("hive-flake-check-{approval_id}-{suffix}"));
// Detached worktree at the proposal tag — gives us a clean, mutable
// copy of the agent's tree without disturbing whatever's currently
// checked out on `applied/<n>/main`.
let out = git_command()
.current_dir(repo)
.args([
"worktree",
"add",
"--detach",
&tmp_dir.to_string_lossy(),
tag,
])
.output()
.await
.with_context(|| format!("git worktree add {} {tag}", tmp_dir.display()))?;
if !out.status.success() {
anyhow::bail!(
"git worktree add failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let result = lock_in_sync_inner(&tmp_dir).await;
// Best-effort cleanup. `git worktree remove --force` handles the
// common case; `remove_dir_all` mops up if git decided the worktree
// is half-gone (or if the inner work bailed before nix touched the
// tree). Failures here are logged, not propagated — the check's
// result is what matters.
if let Err(e) = remove_worktree(repo, &tmp_dir).await {
tracing::warn!(
worktree = %tmp_dir.display(),
error = %format!("{e:#}"),
"flake_check: temp worktree cleanup failed"
);
}
result
}
async fn lock_in_sync_inner(worktree: &Path) -> Result<()> {
// No `flake.nix` means there's nothing for nix to lock — skip the
// check cleanly (the dedup check will likewise no-op).
if !worktree.join("flake.nix").exists() {
return Ok(());
}
let committed = tokio::fs::read_to_string(worktree.join("flake.lock"))
.await
.ok();
// `--extra-experimental-features` mirrors `meta::nix` for hosts
// that haven't already enabled flakes in `nix.conf`. Plain
// `nix flake lock` (no `--update-input`) fills missing entries but
// never refreshes existing ones — exactly the semantics we want.
let out = Command::new("nix")
.current_dir(worktree)
.args([
"--extra-experimental-features",
"nix-command flakes",
"flake",
"lock",
])
.output()
.await
.with_context(|| format!("nix flake lock in {}", worktree.display()))?;
if !out.status.success() {
anyhow::bail!(
"nix flake lock failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let regenerated = tokio::fs::read_to_string(worktree.join("flake.lock"))
.await
.ok();
// An agent that declares inputs in flake.nix but ships no
// flake.lock at all hits this branch (committed = None,
// regenerated = Some(...)). That's a deliberate reject: every
// agent with inputs MUST commit its lock, otherwise meta's
// dedup pass has nothing to introspect and the broken state
// leaks downstream. Treated identically to a stale lock.
if committed.as_deref() != regenerated.as_deref() {
anyhow::bail!(
"flake.lock is out of sync with flake.nix — `nix flake lock` produces a different lock. \
Run `nix flake lock` in your agent config, commit the result, and re-submit request_apply_commit."
);
}
Ok(())
}
async fn remove_worktree(repo: &Path, worktree: &Path) -> Result<()> {
let out = git_command()
.current_dir(repo)
.args(["worktree", "remove", "--force", &worktree.to_string_lossy()])
.output()
.await
.with_context(|| format!("git worktree remove {}", worktree.display()))?;
if !out.status.success() {
// `git worktree remove` already errored — still try the raw
// rmdir so we don't leak the dir on disk. Surface the original
// git stderr for context.
let _ = tokio::fs::remove_dir_all(worktree).await;
anyhow::bail!(
"git worktree remove failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
// git removed the worktree's metadata but the dir itself may
// linger on stripped-down git versions — best-effort clean.
let _ = tokio::fs::remove_dir_all(worktree).await;
Ok(())
}
/// Run the dedup check against the agent's freshly-applied tree.
///
/// `Ok(())` means either the commit doesn't carry a `flake.lock` (no
/// inputs declared) or every node has a unique `original`. `Err`
/// carries a multi-line message listing every duplicate group with
/// the offending node names, suitable for surfacing on the failed
/// approval row.
pub async fn check_no_duplicate_inputs(repo: &Path, tag: &str) -> Result<()> {
let Some(raw) = read_lock_at_tag(repo, tag).await? else {
return Ok(());
};
let dups = duplicate_groups(&raw)?;
if dups.is_empty() {
return Ok(());
}
let mut msg = String::from(
"flake.lock has duplicate flake inputs — add a `follows` directive in flake.nix to collapse them:\n",
);
for g in &dups {
let original = serde_json::to_string(&g.original).unwrap_or_else(|_| "?".into());
let _ = writeln!(msg, " - {original} → nodes [{}]", g.keys.join(", "));
}
anyhow::bail!("{}", msg.trim_end());
}
#[cfg(test)]
mod tests {
use super::*;
const CLEAN_LOCK: &str = r#"{
"nodes": {
"nixpkgs": {
"locked": {"rev": "aaa"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
},
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
},
"root": "root",
"version": 7
}"#;
const DUPLICATE_LOCK: &str = r#"{
"nodes": {
"nixpkgs": {
"locked": {"rev": "aaa"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
},
"nixpkgs_2": {
"locked": {"rev": "ccc"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
},
"nixpkgs_3": {
"locked": {"rev": "ddd"},
"original": {"owner": "NixOS", "repo": "nixpkgs", "ref": "nixos-26.05", "type": "github"}
},
"treefmt-nix": {
"locked": {"rev": "eee"},
"original": {"owner": "numtide", "repo": "treefmt-nix", "type": "github"}
},
"treefmt-nix_2": {
"locked": {"rev": "fff"},
"original": {"type": "github", "owner": "numtide", "repo": "treefmt-nix"}
},
"root": {"inputs": {"nixpkgs": "nixpkgs"}}
},
"root": "root",
"version": 7
}"#;
#[test]
fn clean_lock_has_no_duplicates() {
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
assert!(dups.is_empty(), "expected no dups, got {dups:#?}");
}
#[test]
fn duplicate_lock_reports_groups() {
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
assert_eq!(dups.len(), 2, "expected nixpkgs + treefmt-nix groups");
// Order is BTreeMap-stable: sorted by canonical_json key. The
// numtide treefmt-nix key sorts before the NixOS nixpkgs one
// because `numtide` < `NixOS` lexicographically (case-sensitive,
// capitals come first... wait — capital N is 0x4e, lowercase n
// is 0x6e, so capitals come first). So nixpkgs group sorts
// first. Verify by content instead of position to avoid coupling
// to that subtlety.
let nixpkgs_group = dups
.iter()
.find(|g| g.original.get("ref").is_some())
.expect("nixpkgs group present");
assert_eq!(
nixpkgs_group.keys,
vec!["nixpkgs", "nixpkgs_2", "nixpkgs_3"]
);
let treefmt_group = dups
.iter()
.find(|g| g.original.get("ref").is_none())
.expect("treefmt-nix group present");
assert_eq!(treefmt_group.keys, vec!["treefmt-nix", "treefmt-nix_2"]);
}
#[test]
fn key_order_in_original_does_not_matter() {
// The treefmt-nix and treefmt-nix_2 entries above use different
// key orderings for `original` ({owner,repo,type} vs
// {type,owner,repo}); duplicate_groups should still merge them.
let dups = duplicate_groups(DUPLICATE_LOCK).expect("parse");
let treefmt = dups
.iter()
.find(|g| g.keys.iter().any(|k| k == "treefmt-nix"))
.expect("treefmt-nix group");
assert!(treefmt.keys.contains(&"treefmt-nix_2".to_owned()));
}
#[test]
fn nodes_without_original_are_ignored() {
// The synthetic `root` node has no `original` and must not be
// grouped against anything.
let dups = duplicate_groups(CLEAN_LOCK).expect("parse");
assert!(dups.iter().all(|g| !g.keys.iter().any(|k| k == "root")));
}
#[test]
fn missing_nodes_object_is_not_an_error() {
// A lock file that's syntactically JSON but lacks `nodes` (e.g.
// a partial test fixture) should fail open — no dups reported.
let raw = r#"{"root": "root", "version": 7}"#;
let dups = duplicate_groups(raw).expect("parse");
assert!(dups.is_empty());
}
#[test]
fn invalid_json_is_a_hard_error() {
let err = duplicate_groups("not-json-at-all").unwrap_err();
assert!(format!("{err:#}").contains("parse flake.lock"));
}
}

View file

@ -500,11 +500,10 @@ async fn run_write_perm_file(
Ok(NodeOutput::default())
}
/// Opaque approval deploy pipeline: `ApplyCommit` and `MergeConfigPr`
/// both end in a container rebuild; branch on the approval row's kind
/// (the authoritative source). The two-phase prepare/finalize/abort
/// meta deploy — and the approval resolution — stay inside
/// `actions.rs` in v1 (design doc §9).
/// Opaque approval deploy pipeline for `MergeConfigPr`: verify + ff-merge the
/// reviewed PR head, then the container rebuild. The two-phase
/// prepare/finalize/abort meta deploy — and the approval resolution — stay
/// inside `actions.rs` in v1 (design doc §9).
async fn run_approval_deploy(coord: &Arc<Coordinator>, claim: &Claim) -> Result<NodeOutput> {
let approval_id = claim
.approval_id
@ -514,18 +513,9 @@ async fn run_approval_deploy(coord: &Arc<Coordinator>, claim: &Claim) -> Result<
// container build, and no other meta mutation may land inside that
// window (it would sweep the staged lock and neuter `abort_deploy`).
let _window = crate::meta::exclusive().await;
let kind = coord
.approvals
.get(approval_id)
.ok()
.flatten()
.map(|a| a.kind);
let result = if kind == Some(hive_sh4re::ApprovalKind::MergeConfigPr) {
crate::actions::run_approval_merge_config_pr(coord, Some(claim.dag_id), approval_id).await
} else {
crate::actions::run_approval_apply_commit(coord, Some(claim.dag_id), approval_id).await
};
result.map(|()| NodeOutput::default())
crate::actions::run_approval_merge_config_pr(coord, Some(claim.dag_id), approval_id)
.await
.map(|()| NodeOutput::default())
}
/// Terminal-roll-up hook, fired exactly once per DAG (node completion

View file

@ -110,9 +110,9 @@ pub enum NodeKind {
/// Commit `tool-groups.json` / `capabilities.json` per the DAG's
/// `perm_payload` (commit fused under `META_LOCK`).
WritePermFile,
/// Opaque approval deploy pipeline (`ApplyCommit` /
/// `MergeConfigPr`): the two-phase prepare/finalize/abort meta
/// deploy stays inside `actions.rs` in v1 — deliberately not
/// Opaque approval deploy pipeline (`MergeConfigPr`): the two-phase
/// prepare/finalize/abort meta deploy stays inside `actions.rs` in v1 —
/// deliberately not
/// modeled as scheduler nodes (see the design doc §9).
ApprovalDeploy,
/// Write the agent's durable power intent (`wanted = Up` when `up`, else

View file

@ -96,9 +96,9 @@ pub fn rebuild(agent: &str, source: Source, reason: String, relock: bool) -> Dag
}
}
/// Approval-driven deploy (`ApplyCommit` / `MergeConfigPr`): the whole
/// two-phase pipeline stays one opaque node in v1 (design doc §9) —
/// wire-visible as a `rebuild` card like today.
/// Approval-driven deploy (`MergeConfigPr`): the whole two-phase pipeline
/// stays one opaque node in v1 (design doc §9) — wire-visible as a `rebuild`
/// card like today.
pub fn approval_deploy(agent: &str, approval_id: i64, reason: String) -> DagSpec {
DagSpec {
template: Template::Rebuild,

View file

@ -24,7 +24,6 @@ pub mod container_view;
pub mod coordinator;
pub mod dashboard;
pub mod dashboard_events;
pub mod flake_check;
pub mod forge;
pub mod gateway_nginx;
pub mod job_queue;

View file

@ -12,7 +12,7 @@ const GIT_EMAIL: &str = "c0re@hyperhive.local";
/// Return the SHA of the root (oldest, no-parent) commit in a repo.
/// Used to seed the applied repo at the template baseline rather than at
/// `main`, so the first `ApplyCommit` diff shows the manager's real changes.
/// `main`, so `deployed/0` records the template, not the manager's first commit.
pub(super) async fn git_root_commit(dir: &Path) -> Result<String> {
let out = git_command()
.current_dir(dir)
@ -71,51 +71,6 @@ pub async fn git(dir: &Path, args: &[&str]) -> Result<()> {
Ok(())
}
/// Fetch the commit `sha` from the `src` git repo into `dst` and pin
/// it as `refs/tags/<tag>`. Used at `request_apply_commit` time so
/// hive-c0re captures an immutable handle on the manager's commit;
/// subsequent amendments / force-pushes in `src` no longer affect
/// what gets built. Returns the resolved full sha.
///
/// `sha` must be a commit sha (short or full) — the caller
/// (`submit_apply_commit`) shape-checks it first. We resolve it
/// LOCALLY against `src` rather than asking the remote to resolve
/// it: `git fetch <remote> <sha>:<dst>` treats the left side as a
/// remote *ref name*, and a bare sha is not one ("couldn't find
/// remote ref ..."). Fetching by sha would need a full 40-hex sha
/// plus `uploadpack.allow*SHA1InWant` on the remote, which the
/// proposed repos don't set. hive-c0re has direct read access to
/// `src`, so a local `rev-parse` + a branch-glob fetch sidesteps
/// the whole sha-want negotiation.
pub async fn git_fetch_to_tag(dst: &Path, src: &Path, sha: &str, tag: &str) -> Result<String> {
let src_str = src.display().to_string();
// Resolve the (short-or-full) sha to a full sha against the
// source repo. The `^{commit}` peel + non-zero exit on a missing
// object means a typo'd / stale sha fails loudly right here.
let full = git_rev_parse(src, &format!("{sha}^{{commit}}"))
.await
.with_context(|| format!("commit '{sha}' not found in proposed repo {src_str}"))?;
// Bring src's objects into dst. Fetching every head pulls the
// wanted commit's history (always reachable from a branch in the
// manager's flow) into dst's object db without sha-want.
git(
dst,
&[
"fetch",
"--no-tags",
&src_str,
"+refs/heads/*:refs/remotes/proposal-src/*",
],
)
.await?;
// Pin the exact commit as the proposal tag. The objects are now
// local so this resolves without touching the remote.
git(dst, &["tag", tag, &full]).await.with_context(|| {
format!("tag {tag} at {full}: commit not reachable from any branch in proposed repo")
})?;
Ok(full)
}
/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha.
pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
let out = git_command()

View file

@ -7,8 +7,8 @@ mod setup;
mod tests;
pub use git::{
git, git_command, git_fetch_to_tag, git_read_tree_reset, git_rev_parse, git_tag,
git_tag_annotated, git_update_ref,
git, git_command, git_read_tree_reset, git_rev_parse, git_tag, git_tag_annotated,
git_update_ref,
};
pub use host_config::{
CONTAINER_MANAGER_AGENTS_MOUNT, CONTAINER_MANAGER_APPLIED_MOUNT, write_dropins,
@ -345,14 +345,6 @@ async fn agents_after_spawn(name: &str) -> Result<Vec<crate::meta::AgentSpec>> {
agents_for_meta(Some(name)).await
}
/// Like `agents_for_meta_listing` but with an extra agent added (for a
/// container that doesn't exist yet). Used by the first-spawn path in
/// `actions::run_apply_commit` to register the new agent in meta before
/// `prepare_deploy` tries to update its input lock.
pub async fn agents_for_meta_listing_with(extra: &str) -> Result<Vec<crate::meta::AgentSpec>> {
agents_for_meta(Some(extra)).await
}
/// Public enumeration of currently-existing agents (whatever
/// `nixos-container list` says), sorted, no extras. For callers
/// outside this module that need to reseed meta after lifecycle

View file

@ -44,8 +44,7 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
// manager's ergonomics. The URL is the path inside the manager
// container (`/applied/<n>/.git`), where the RO bind in
// `set_nspawn_flags` makes it real. hive-c0re itself never
// dereferences this remote; the host-side fetch in
// `request_apply_commit` uses absolute host paths.
// dereferences this remote.
ensure_applied_remote(proposed_dir, name).await
}
@ -71,9 +70,8 @@ async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> {
/// Set up the applied repo. First-spawn only: init the repo, pull
/// proposed's initial commit in via `git fetch`, tag it `deployed/0`.
/// This is the *only* time hive-c0re reads from `proposed` for an
/// agent — subsequent proposals are fetched at `request_apply_commit`
/// time and tagged `proposal/<id>` (see `actions::approve` for the
/// tag state machine).
/// agent — subsequent config changes are fetched from the reviewed
/// forge PR head at merge time (see `actions::run_merge_config_pr`).
///
/// `proposed_dir` is `None` on rebuild paths where the repo already
/// exists — we just verify it's the right shape and bail otherwise.
@ -100,10 +98,8 @@ pub async fn setup_applied(
git(applied_dir, &["init", "--initial-branch=main"]).await?;
let proposed_str = proposed.display().to_string();
// Seed the applied repo at the root (template) commit of proposed,
// not at `main`. This ensures `deployed/0` is the template baseline
// so the first ApplyCommit diff shows the manager's real changes
// rather than an empty diff (which happens when the manager has
// already committed their config and proposed/main == proposal/<id>).
// not at `main`, so `deployed/0` is the template baseline rather
// than whatever the manager may have already committed on top.
let root_sha = git_root_commit(proposed).await?;
git(
applied_dir,

View file

@ -168,7 +168,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostResponse::success()
}
HostRequest::Deny { id } => {
actions::deny(&coord, *id, None).await?;
actions::deny(&coord, *id, None)?;
HostResponse::success()
}
HostRequest::SetParent { child, new_parent } => {

View file

@ -1,17 +1,14 @@
//! Config-approval request handlers: `RequestInitConfig` /
//! `RequestApplyCommit` / `RequestUpdateMetaInputs`,
//! plus the shared submit helpers (`submit_init_config` / `submit_apply_commit`
//! / `submit_merge_config_pr`) and the commit-sha shape check
//! (`validate_commit_ref`).
//! `RequestUpdateMetaInputs`, plus the shared submit helpers
//! (`submit_init_config` / `submit_merge_config_pr`).
//!
//! `submit_merge_config_pr` is called from the dashboard webhook handler
//! (`dashboard::webhook`) — agents no longer need an MCP tool for this;
//! opening a config PR on `agent-configs/<agent>` is enough to trigger
//! hive-c0re's webhook-driven queue path.
//! (`dashboard::webhook`) — agents no longer need an MCP tool for config
//! changes; opening a config PR on `agent-configs/<agent>` is enough to
//! trigger hive-c0re's webhook-driven queue path.
use std::sync::Arc;
use anyhow::{Context, Result};
use hive_sh4re::AgentResponse;
use super::require_new_child;
@ -40,30 +37,6 @@ pub(super) fn handle_request_init_config(
}
}
/// `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).
@ -105,7 +78,6 @@ pub(super) fn handle_request_update_meta_inputs(
agent: requester,
approval_kind: "update_meta_inputs",
sha_short: None,
diff: None,
description: description.map(str::to_owned),
pr_number: None,
});
@ -118,10 +90,10 @@ pub(super) fn handle_request_update_meta_inputs(
///
/// The PR head sha is stored as `fetched_sha` on the approval row — the
/// "reviewed sha" the approve handler (`run_merge_config_pr`) drift-gates
/// against before doing anything irreversible. Unlike `submit_apply_commit`
/// this does NOT fetch the commit into the applied repo at submission time
/// (that happens inside the approve handler, step 2, after the drift check).
/// No flake pre-flight either — eval-verify happens at approval time too.
/// against before doing anything irreversible. This does NOT fetch the commit
/// into the applied repo at submission time (that happens inside the approve
/// handler, step 2, after the drift check). No flake pre-flight either —
/// eval-verify happens at approval time too.
pub(crate) async fn submit_merge_config_pr(
coord: &Arc<Coordinator>,
agent: &str,
@ -134,7 +106,7 @@ pub(crate) async fn submit_merge_config_pr(
anyhow::bail!(
"applied repo missing for agent '{agent}' (expected at {}) — \
merge_config_pr requires the agent to be fully provisioned; \
use request_apply_commit for the first config deploy",
spawn the agent first (operator spawn) before opening config PRs",
applied_dir.display()
);
}
@ -199,32 +171,12 @@ pub(crate) async fn submit_merge_config_pr(
agent,
approval_kind: "merge_config_pr",
sha_short: Some(sha_short),
diff: None, // diff is not pre-computed; the dashboard fetches it on demand
description: description.map(str::to_owned),
pr_number: Some(pr_number),
});
Ok(id)
}
/// `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.
///
@ -247,7 +199,8 @@ pub(crate) fn submit_init_config(
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",
nothing to init; config changes go through a forge PR on \
agent-configs/{name}",
proposed_dir.display()
);
}
@ -271,176 +224,8 @@ pub(crate) fn submit_init_config(
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,
None, // sha resolved after git_fetch_to_tag below; set via set_fetched_sha
)
.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, &note);
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, &note);
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, &note);
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());
}
}

View file

@ -29,9 +29,7 @@ pub(crate) use config_approvals::submit_merge_config_pr;
pub(crate) use schedules::filter_ghost_schedule_targets;
pub use schedules::schedule_to_wire_public;
use config_approvals::{
handle_request_apply_commit, handle_request_init_config, handle_request_update_meta_inputs,
};
use config_approvals::{handle_request_init_config, handle_request_update_meta_inputs};
use lifecycle_handlers::{
handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update,
};
@ -565,20 +563,6 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
AgentRequest::RequestInitConfig { name, description } => {
handle_request_init_config(coord, agent, name, description.clone())
}
AgentRequest::RequestApplyCommit {
agent: target_agent,
commit_ref,
description,
} => {
handle_request_apply_commit(
coord,
agent,
target_agent,
commit_ref,
description.as_deref(),
)
.await
}
// Agent-state queries: own subtree is free; other agents + the
// hive-wide `"*"` sweep require `QueryAgentState`.
AgentRequest::GetLooseEnds { agent: target } => {
@ -722,9 +706,9 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<AgentResponse
}
}
/// Topology guard for `request_init_config` / `request_apply_commit`,
/// which may legitimately target a child that does not exist *yet*
/// (spawning a brand-new sub-agent). The caller may act on a
/// Topology guard for `request_init_config`, which may legitimately target a
/// child that does not exist *yet* (seeding a brand-new sub-agent's config
/// repo). The caller may act on a
/// `target` that is EITHER already its direct child (re-init / config
/// update of an existing child) OR brand-new (absent from the topology
/// tree — the requester becomes its parent). A name that already

View file

@ -84,7 +84,6 @@ pub(super) fn handle_request_schedule_prompt(
agent: requester,
approval_kind: "schedule_prompt",
sha_short: None,
diff: None,
description: payload.description.clone(),
pr_number: None,
});

View file

@ -1,6 +1,7 @@
//! Approval queue. Manager submits via `RequestApplyCommit`; the user
//! approves/denies via the host admin CLI; on approval the host runs the
//! corresponding action (Phase 5a: `lifecycle::rebuild(agent)`).
//! Approval queue. Requests are submitted by the manager (`RequestInitConfig`
//! / `RequestUpdateMetaInputs`), the config-PR webhook (`MergeConfigPr`), or
//! the operator (`Spawn`); the user approves/denies via the host admin CLI;
//! on approval the host runs the corresponding action.
use std::path::Path;
use std::sync::Mutex;
@ -73,9 +74,9 @@ impl Approvals {
/// Insert a new pending approval row. `fetched_sha` may be supplied
/// when the sha is already known at submission time (e.g. `MergeConfigPr`
/// fetches the PR head before inserting), making the insert + sha-set
/// atomic. Pass `None` when the sha is resolved after insertion (e.g.
/// `ApplyCommit`'s `git_fetch_to_tag` step) and call [`set_fetched_sha`]
/// separately.
/// atomic. Pass `None` when the kind carries no sha (e.g. `Spawn` /
/// `InitConfig`) or the sha is resolved after insertion, then call
/// [`set_fetched_sha`] separately.
pub fn submit_kind(
&self,
agent: &str,
@ -366,7 +367,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
// Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note, fetched_sha, description.
let kind: String = row.get(2)?;
let kind = match kind.as_str() {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
@ -413,7 +413,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
Ok(match s {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
@ -463,8 +462,15 @@ mod tests {
#[test]
fn mixed_kinds_all_listed() {
let (_dir, _path, db) = open_temp();
db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a", None)
.unwrap();
db.submit_kind(
"a",
ApprovalKind::MergeConfigPr,
"deadbeef",
None,
"a",
None,
)
.unwrap();
db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None)
.unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None)
@ -482,7 +488,7 @@ mod tests {
let id = db
.submit_kind(
"bitburner",
ApprovalKind::ApplyCommit,
ApprovalKind::MergeConfigPr,
"cafef00d",
Some("test"),
"bitburner",
@ -523,7 +529,7 @@ mod tests {
let good = db
.submit_kind(
"good",
ApprovalKind::ApplyCommit,
ApprovalKind::MergeConfigPr,
"cafe",
None,
"good",
@ -553,7 +559,7 @@ mod tests {
let id = db
.submit_kind(
"child",
ApprovalKind::ApplyCommit,
ApprovalKind::MergeConfigPr,
"cafe",
None,
"parent",
@ -565,7 +571,7 @@ mod tests {
let raw = Connection::open(&path).unwrap();
raw.execute(
"INSERT INTO approvals (agent, kind, commit_ref, requested_at, status)
VALUES ('old', 'apply_commit', '', 0, 'pending')",
VALUES ('old', 'spawn', '', 0, 'pending')",
[],
)
.unwrap();