diff --git a/docs/approvals.md b/docs/approvals.md index 4d27167e..2656eb0e 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -90,7 +90,7 @@ initial configuration before any container is created. ### Approval kinds (wire shapes) -`ApprovalKind` carries five variants; each maps to a different +`ApprovalKind` carries six variants; each maps to a different `commit_ref` encoding because that field is overloaded as the kind-specific payload carrier. @@ -99,6 +99,16 @@ kind-specific payload carrier. proposal fetch lives in `fetched_sha` on the same `Approval` row (only `ApplyCommit` populates it). See the End-to-end flow above. +- `MergeConfigPr` — the PR-based config flow's counterpart to + `ApplyCommit`. `commit_ref` stores the **PR number** (decimal), + and `fetched_sha` is the PR **head sha the operator reviewed**. + On approve, `run_merge_config_pr` re-reads the live PR head and + aborts if it drifted from `fetched_sha` (re-review), then fetches + that head into the applied repo, eval-verifies it, fast-forwards + the forge config repo's `main` to it (the merge), marks the PR + merged (best-effort — `main` is already there), and runs the same + shared deploy tail as `ApplyCommit` (`deploy_applied_target`). + Never a first spawn. - `Spawn` — direct container creation under the default `agent.nix` template. `commit_ref` is empty. Submitted via `HostRequest::RequestSpawn` (operator-gated, the @@ -323,6 +333,7 @@ the approval handler enqueues a `QueueEntry` into the global | `ApprovalKind` | `QueueKind` queued | `QueueSource` | |---|---|---| | `ApplyCommit` | `Rebuild` | `Approval` | +| `MergeConfigPr` | `Rebuild` | `Approval` | | `UpdateMetaInputs` | `MetaUpdate` | `Approval` | | `Spawn` | `Spawn` | `Approval` | | `InitConfig` | — runs inline (sub-second git seed) | — | @@ -331,7 +342,8 @@ the approval handler enqueues a `QueueEntry` into the global Each queue entry carries the originating `approval_id` so the worker can re-fetch the approval row when it dispatches, run the kind-specific pipeline (`run_approval_apply_commit` / -`run_approval_update_meta_inputs` / `run_approval_spawn`), and +`run_approval_merge_config_pr` / `run_approval_update_meta_inputs` / +`run_approval_spawn`), and fire the matching `HelperEvent::*` on completion via `finish_approval`. diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 9206a518..dfa733ea 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -46,20 +46,12 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await } ApprovalKind::ApplyCommit => { - coord - .rebuild_queue - .enqueue_full(crate::rebuild_queue::FullEnqueue { - kind: crate::rebuild_queue::QueueKind::Rebuild, - agent: approval.agent.clone(), - source: crate::rebuild_queue::QueueSource::Approval, - reason: format!("approval #{id} apply commit"), - parent_id: None, - inputs: Vec::new(), - approval_id: Some(id), - perm_payload: None, - depends_on: Vec::new(), - }); - coord.emit_rebuild_queue_snapshot(); + enqueue_approval_rebuild( + &coord, + &approval.agent, + id, + format!("approval #{id} apply commit"), + ); Ok(()) } ApprovalKind::UpdateMetaInputs => { @@ -128,9 +120,49 @@ pub async fn approve(coord: Arc, 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). + enqueue_approval_rebuild( + &coord, + &approval.agent, + id, + format!("approval #{id} merge config pr"), + ); + Ok(()) + } } } +/// Enqueue a `Rebuild` queue entry 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 queue `reason`. +/// The queue worker branches on the approval's kind to pick the right handler. +fn enqueue_approval_rebuild( + coord: &Arc, + agent: &str, + approval_id: i64, + reason: String, +) { + coord + .rebuild_queue + .enqueue_full(crate::rebuild_queue::FullEnqueue { + kind: crate::rebuild_queue::QueueKind::Rebuild, + agent: agent.to_owned(), + source: crate::rebuild_queue::QueueSource::Approval, + reason, + parent_id: None, + inputs: Vec::new(), + approval_id: Some(approval_id), + perm_payload: None, + depends_on: Vec::new(), + }); + 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` @@ -160,6 +192,159 @@ pub async fn run_approval_apply_commit( 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 +/// `ApprovalResolved` + the `Rebuilt` lifecycle event via `finish_approval`. +/// Unlike the apply-commit path it does NOT call `push_config` afterwards: +/// `run_merge_config_pr` already fast-forwarded the forge repo's `main` to +/// the reviewed head (that IS the merge), so a mirror push would be a no-op. +/// A `MergeConfigPr` is never a first spawn (the agent already exists). +pub async fn run_approval_merge_config_pr( + coord: &Arc, + queue_entry_id: Option, + approval_id: i64, +) -> Result<()> { + let approval = fetch_approval_for_worker(coord, approval_id, ApprovalKind::MergeConfigPr)?; + let agent_dir = coord.ensure_runtime(&approval.agent)?; + let applied_dir = Coordinator::agent_applied_dir(&approval.agent); + coord.set_queue_step(queue_entry_id, "merge config pr"); + let (result, terminal_tag) = + run_merge_config_pr(coord, &approval, &agent_dir, &applied_dir, queue_entry_id).await; + finish_approval(coord, &approval, result, terminal_tag, false) +} + +/// PR-merge config pipeline. `approval.commit_ref` is the PR number; +/// `approval.fetched_sha` is the PR head sha the operator reviewed. Steps: +/// 1. drift gate — re-read the live PR head; if it moved since review, abort +/// WITHOUT mutating anything (the operator must re-review the new head); +/// 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); +/// 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); +/// 6. run the shared deploy tail (`deploy_applied_target`): ff applied/main, +/// meta deploy, container rebuild, finalize/rollback. +/// +/// Returns `(build result, terminal tag)` like `deploy_applied_target`. Any +/// pre-merge abort returns `Err` with no mutation; the operator re-reviews. +async fn run_merge_config_pr( + coord: &Arc, + approval: &hive_sh4re::Approval, + agent_dir: &std::path::Path, + applied_dir: &std::path::Path, + queue_entry_id: Option, +) -> (Result<()>, Option) { + let id = approval.id; + let pr: u64 = match approval.commit_ref.parse() { + Ok(n) => n, + Err(e) => { + return ( + Err(anyhow::anyhow!( + "parse PR number from commit_ref {:?}: {e}", + approval.commit_ref + )), + None, + ); + } + }; + let reviewed = match approval.fetched_sha.as_deref() { + Some(s) => s.to_owned(), + None => { + return ( + Err(anyhow::anyhow!( + "merge config pr approval {id} has no reviewed head sha" + )), + None, + ); + } + }; + let repo = crate::forge::config_repo(&approval.agent); + + // 1. Drift gate: the live PR head must still equal what was reviewed. + coord.set_queue_step(queue_entry_id, "verify PR head"); + let head = match crate::forge::pr_head_sha(&repo, pr).await { + Ok(h) => h, + Err(e) => return (Err(anyhow::anyhow!("read PR #{pr} head: {e}")), None), + }; + if head != reviewed { + return ( + Err(anyhow::anyhow!( + "PR #{pr} head drifted since review (reviewed {reviewed}, now {head}); re-review before merging" + )), + None, + ); + } + + // 2. Fetch the reviewed head into applied so ff/verify/deploy resolve it. + coord.set_queue_step(queue_entry_id, "fetch PR head"); + if let Err(e) = crate::forge::fetch_pr_head_into_applied(&repo, pr).await { + return ( + Err(anyhow::anyhow!("fetch PR #{pr} head into applied: {e}")), + None, + ); + } + + // 3. Eval-verify BEFORE the irreversible push (bad nix fails fast here). + coord.set_queue_step(queue_entry_id, "verify proposal (eval)"); + if let Err(e) = crate::meta::verify_commit(&approval.agent, applied_dir, &reviewed).await { + return ( + Err(anyhow::anyhow!("verify merge head {reviewed}: {e:#}")), + None, + ); + } + + // Capture the currently-deployed sha for the deploy tail's rollback. + 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), + }; + + // 4. THE merge: fast-forward the forge repo's main to the reviewed head. + coord.set_queue_step(queue_entry_id, "fast-forward forge main"); + match crate::forge::ff_push_to_main(&repo, &reviewed).await { + Ok(()) => {} + Err(crate::forge::ForgeMergeError::NotFastForward { .. }) => { + return ( + Err(anyhow::anyhow!( + "PR #{pr}: forge main raced ahead of reviewed {reviewed}; re-review before merging" + )), + None, + ); + } + Err(e) => return (Err(anyhow::anyhow!("ff-push PR #{pr} to main: {e}")), None), + } + + // 5. Mark the PR merged. Best-effort: main is already at the reviewed + // head, so the deploy is correct regardless — a failure here (incl. + // HeadDrift vs the forge PR record) is logged, not fatal. + coord.set_queue_step(queue_entry_id, "mark PR merged"); + if let Err(e) = crate::forge::mark_pr_merged(&repo, pr, &reviewed).await { + tracing::warn!( + agent = %approval.agent, %id, %pr, error = ?e, + "mark PR merged failed; main already at reviewed head, continuing to deploy" + ); + } + + // 6. Shared deploy tail. target == finalize == the reviewed head; + // never a first spawn (the agent already exists). + deploy_applied_target( + coord, + &approval.agent, + agent_dir, + applied_dir, + &reviewed, + &reviewed, + id, + &prev_main_sha, + false, + queue_entry_id, + ) + .await +} + /// Inline (non-queued) handler for `ApprovalKind::SchedulePrompt`. /// On approve, decode the `SchedulePromptPayload` JSON from the /// approval's `commit_ref`, insert a row into `scheduled_prompts` @@ -381,6 +566,7 @@ fn finish_approval( ApprovalKind::InitConfig => "init_config", ApprovalKind::UpdateMetaInputs => "update_meta_inputs", ApprovalKind::SchedulePrompt => "schedule_prompt", + ApprovalKind::MergeConfigPr => "merge_config_pr", }; let sha_short = approval .fetched_sha @@ -423,13 +609,19 @@ fn finish_approval( sha: approval.fetched_sha.clone(), }); } - ApprovalKind::ApplyCommit => coord.notify_manager(&HelperEvent::Rebuilt { - agent: approval.agent.clone(), - ok, - note, - sha: approval.fetched_sha.clone(), - tag: terminal_tag, - }), + // 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 => { + coord.notify_manager(&HelperEvent::Rebuilt { + agent: approval.agent.clone(), + ok, + note, + sha: approval.fetched_sha.clone(), + tag: terminal_tag, + }); + } // UpdateMetaInputs / SchedulePrompt: ApprovalResolved already // carries the result. No separate lifecycle event needed. ApprovalKind::UpdateMetaInputs | ApprovalKind::SchedulePrompt => {} @@ -446,12 +638,8 @@ fn finish_approval( /// (`deployed/`) or annotate `failed/` 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. -#[allow( - clippy::too_many_lines, - reason = "one sequential build/tag/notify pipeline; splitting the steps \ - across helpers would obscure the linear flow without shrinking it" -)] +/// 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, approval: &hive_sh4re::Approval, @@ -542,28 +730,76 @@ async fn run_apply_commit( ); } + // 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/` / `failed/` 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). +#[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" +)] +async fn deploy_applied_target( + coord: &Arc, + agent: &str, + agent_dir: &std::path::Path, + applied_dir: &std::path::Path, + target_ref: &str, + finalize_sha: &str, + tag_base: i64, + prev_main_sha: &str, + is_first_spawn: bool, + queue_entry_id: Option, +) -> (Result<()>, Option) { + let id = tag_base; + coord.set_queue_step(queue_entry_id, "fast-forward applied/main"); - // Fast-forward applied/main to proposal/ + sync the working - // tree. Meta input pins `?ref=main`, so this is what makes nix - // re-lock to the proposal commit on the prepare_deploy step - // below. On build failure we roll main back to prev_main_sha so - // a crash leaves the agent on its last-good tree. - if let Err(e) = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &proposal_ref).await { - return ( - Err(anyhow::anyhow!("ff main to {proposal_ref}: {e:#}")), - None, - is_first_spawn, - ); + // Fast-forward applied/main to target_ref + sync the working tree. + // Meta input pins `?ref=main`, so this is what makes nix re-lock to + // the target commit on the prepare_deploy step below. On build + // failure we roll main back to prev_main_sha so a crash leaves the + // agent on its last-good tree. + if let Err(e) = lifecycle::git_update_ref(applied_dir, "refs/heads/main", target_ref).await { + return (Err(anyhow::anyhow!("ff main to {target_ref}: {e:#}")), None); } if let Err(e) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await { // main is ahead; working tree didn't sync. Roll main back to // keep the two consistent before bailing. - let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await; - return ( - Err(anyhow::anyhow!("read-tree to main: {e:#}")), - None, - is_first_spawn, - ); + let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await; + return (Err(anyhow::anyhow!("read-tree to main: {e:#}")), None); } // First spawn: sync_agents must add this agent to the meta flake @@ -571,40 +807,34 @@ async fn run_apply_commit( // 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(&approval.agent).await { + 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; + 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, - is_first_spawn, ); } }; 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_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, - is_first_spawn, ); } } 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(&approval.agent).await { - let _ = lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await; + if let Err(e) = crate::meta::prepare_deploy(agent).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 prepare_deploy: {e:#}")), - None, - is_first_spawn, - ); + return (Err(anyhow::anyhow!("meta prepare_deploy: {e:#}")), None); } // Container-level rebuild (or first-time create) against meta#. @@ -612,9 +842,9 @@ async fn run_apply_commit( // the dashboard reflects actual phase progress rather than a static // "nixos-container update" label for the whole multi-minute window. let hive = coord.hive_env(); - let paths = Coordinator::agent_paths(&approval.agent, agent_dir.to_path_buf()); + let paths = Coordinator::agent_paths(agent, agent_dir.to_path_buf()); let build_result = lifecycle::rebuild_no_meta( - &approval.agent, + agent, &hive, &paths, &|step| coord.set_queue_step(queue_entry_id, step), @@ -632,54 +862,47 @@ async fn run_apply_commit( Ok(()) => { coord.set_queue_step(queue_entry_id, "finalize deploy"); let tag = format!("deployed/{id}"); - if let Err(e) = lifecycle::git_tag(applied_dir, &tag, &proposal_ref).await { - tracing::warn!(agent = %approval.agent, %id, error = ?e, "plant deployed tag failed"); + if let Err(e) = lifecycle::git_tag(applied_dir, &tag, target_ref).await { + tracing::warn!(%agent, %id, error = ?e, "plant deployed tag failed"); } - if let Err(e) = crate::meta::finalize_deploy( - &approval.agent, - approval.fetched_sha.as_deref().unwrap_or(&proposal_ref), - &tag, - ) - .await - { + if let Err(e) = crate::meta::finalize_deploy(agent, finalize_sha, &tag).await { // The build itself succeeded — meta lock landed but // couldn't be committed. Surface as a soft warn so the // operator can git-commit by hand if they care. - tracing::warn!(agent = %approval.agent, %id, error = ?e, "meta finalize_deploy failed"); + tracing::warn!(%agent, %id, error = ?e, "meta finalize_deploy failed"); } // Wake the agent on its next turn so claude sees the // config change took effect. Same hint pattern as // auto_update::rebuild_agent — manager approved a // proposal, agent picks up where it left off with the // new env / packages. - coord.kick_agent(&approval.agent, "config update applied"); - (Ok(()), Some(tag), is_first_spawn) + coord.kick_agent(agent, "config update applied"); + (Ok(()), Some(tag)) } Err(e) => { let tag = format!("failed/{id}"); let body = format!("{e:#}"); if let Err(te) = - lifecycle::git_tag_annotated(applied_dir, &tag, &proposal_ref, &body).await + lifecycle::git_tag_annotated(applied_dir, &tag, target_ref, &body).await { - tracing::warn!(agent = %approval.agent, %id, error = ?te, "annotate failed tag failed"); + tracing::warn!(%agent, %id, error = ?te, "annotate failed tag failed"); } // Roll main back to last known-good so the on-disk state // matches what nixos-container last successfully built. if let Err(re) = - lifecycle::git_update_ref(applied_dir, "refs/heads/main", &prev_main_sha).await + lifecycle::git_update_ref(applied_dir, "refs/heads/main", prev_main_sha).await { - tracing::warn!(agent = %approval.agent, %id, error = ?re, "main rollback failed"); + tracing::warn!(%agent, %id, error = ?re, "main rollback failed"); } if let Err(re) = lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main").await { - tracing::warn!(agent = %approval.agent, %id, error = ?re, "rollback read-tree failed"); + tracing::warn!(%agent, %id, error = ?re, "rollback read-tree failed"); } // Drop the staged meta lock change so the deploy log // only ever shows successes. if let Err(ae) = crate::meta::abort_deploy().await { - tracing::warn!(agent = %approval.agent, %id, error = ?ae, "meta abort_deploy failed"); + tracing::warn!(%agent, %id, error = ?ae, "meta abort_deploy failed"); } - let _ = coord; - (Err(e), Some(tag), is_first_spawn) + (Err(e), Some(tag)) } } } @@ -809,6 +1032,7 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<() ApprovalKind::InitConfig => "init_config", ApprovalKind::UpdateMetaInputs => "update_meta_inputs", ApprovalKind::SchedulePrompt => "schedule_prompt", + ApprovalKind::MergeConfigPr => "merge_config_pr", }; let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned()); let description = a.description.clone(); diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index 21a4b617..2abf82bc 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -335,6 +335,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { "init_config" => ApprovalKind::InitConfig, "update_meta_inputs" => ApprovalKind::UpdateMetaInputs, "schedule_prompt" => ApprovalKind::SchedulePrompt, + "merge_config_pr" => ApprovalKind::MergeConfigPr, other => { return Err(rusqlite::Error::FromSqlConversionFailure( 2, @@ -383,6 +384,7 @@ pub(crate) fn kind_to_str(kind: ApprovalKind) -> &'static str { ApprovalKind::InitConfig => "init_config", ApprovalKind::UpdateMetaInputs => "update_meta_inputs", ApprovalKind::SchedulePrompt => "schedule_prompt", + ApprovalKind::MergeConfigPr => "merge_config_pr", } } @@ -393,6 +395,7 @@ fn kind_from_str(s: &str) -> Result { "init_config" => ApprovalKind::InitConfig, "update_meta_inputs" => ApprovalKind::UpdateMetaInputs, "schedule_prompt" => ApprovalKind::SchedulePrompt, + "merge_config_pr" => ApprovalKind::MergeConfigPr, other => bail!("unknown approval kind '{other}'"), }) } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 21e3f7ce..a09ece5f 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -870,6 +870,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView { hive_sh4re::ApprovalKind::InitConfig => "init_config", hive_sh4re::ApprovalKind::UpdateMetaInputs => "update_meta_inputs", hive_sh4re::ApprovalKind::SchedulePrompt => "schedule_prompt", + hive_sh4re::ApprovalKind::MergeConfigPr => "merge_config_pr", }; ApprovalHistoryView { id: a.id, @@ -939,6 +940,24 @@ async fn build_approval_views(approvals: Vec) -> Vec { description: a.description, requested_at: a.requested_at, }, + 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. + let sha = a + .fetched_sha + .as_deref() + .map(|s| s[..s.len().min(12)].to_owned()); + ApprovalView { + id: a.id, + agent: a.agent, + kind: "merge_config_pr", + sha_short: sha, + diff: None, + description: a.description, + requested_at: a.requested_at, + } + } }); } out diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 8a3c12b2..e2d953c1 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -1225,6 +1225,43 @@ pub async fn pr_head_sha(repo: &str, pr: u64) -> Result Ok(sha.to_string()) } +/// Full `owner/name` path of an agent's config repo on the forge — the +/// `agent-configs` org mirror that the PR-merge flow reads + fast-forwards. +pub fn config_repo(agent: &str) -> String { + format!("{CONFIG_ORG}/{agent}") +} + +/// Fetch PR #`pr`'s head into the agent's applied repo via +/// `refs/pull//head` (which Forgejo always serves — a bare-sha fetch can +/// be refused by uploadpack policy). This makes the reviewed head an object +/// in the applied repo so the ancestor check in [`ff_push_to_main`], the +/// `git_update_ref(main, …)` in the deploy tail, and the eval-verify all +/// resolve it locally before the irreversible push. +/// +/// # Errors +/// `Other` on transport failure or a non-zero git exit. +pub async fn fetch_pr_head_into_applied(repo: &str, pr: u64) -> Result<(), ForgeMergeError> { + let token = core_token() + .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; + let url = tokenised_repo_url(repo, &token); + let applied = Coordinator::agent_applied_dir(repo_agent_name(repo)); + let refspec = format!("refs/pull/{pr}/head"); + let out = crate::lifecycle::git_command() + .current_dir(&applied) + .args(["fetch", "--no-tags", &url, &refspec]) + .output() + .await + .context("git fetch PR head into applied")?; + if !out.status.success() { + return Err(ForgeMergeError::Other(anyhow::anyhow!( + "git fetch {repo} {refspec} into applied failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(()) +} + /// Fast-forward the forge repo's `main` to `sha` — THE merge in the PR flow. /// Reads `main`'s current sha (`git ls-remote … refs/heads/main`), verifies it /// is a strict ancestor of `sha` (`git merge-base --is-ancestor`, run in the diff --git a/hive-c0re/src/rebuild_queue.rs b/hive-c0re/src/rebuild_queue.rs index 6a816f84..55bd76f5 100644 --- a/hive-c0re/src/rebuild_queue.rs +++ b/hive-c0re/src/rebuild_queue.rs @@ -852,13 +852,36 @@ pub async fn run_fast_worker(coord: std::sync::Arc, + entry: &QueueEntry, + approval_id: i64, +) -> anyhow::Result<()> { + let kind = coord + .approvals + .get(approval_id) + .ok() + .flatten() + .map(|a| a.kind); + if kind == Some(hive_sh4re::ApprovalKind::MergeConfigPr) { + crate::actions::run_approval_merge_config_pr(coord, Some(entry.id), approval_id).await + } else { + crate::actions::run_approval_apply_commit(coord, Some(entry.id), approval_id).await + } +} + async fn dispatch( coord: &std::sync::Arc, entry: &QueueEntry, ) -> anyhow::Result<()> { match (entry.kind, entry.approval_id) { (QueueKind::Rebuild, Some(approval_id)) => { - crate::actions::run_approval_apply_commit(coord, Some(entry.id), approval_id).await + dispatch_rebuild_approval(coord, entry, approval_id).await } (QueueKind::Rebuild, None) => { let current_rev = diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index ed121f15..7079ec94 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -154,10 +154,12 @@ pub struct Approval { /// Kind-specific payload (git sha / inputs array / schedule /// payload / empty). See the Approval struct doc. pub commit_ref: String, - /// `ApplyCommit` only: the canonical hive-c0re-vouched sha after - /// the proposal fetch, tagged `proposal/`. Stable for the + /// The canonical hive-c0re-vouched sha. For `ApplyCommit`: the sha + /// after the proposal fetch, tagged `proposal/` (stable for the /// approval's lifetime — manager amends in proposed don't change - /// what gets built. + /// what gets built). For `MergeConfigPr`: the reviewed PR head + /// pinned at submit; if the PR head drifts off it before merge, + /// hive-c0re refreshes this + re-renders the card for re-review. #[serde(default, skip_serializing_if = "Option::is_none")] pub fetched_sha: Option, pub requested_at: i64, @@ -192,6 +194,12 @@ pub enum ApprovalKind { UpdateMetaInputs, /// Add a scheduled prompt to the broker queue. SchedulePrompt, + /// Merge an operator-reviewed config PR: hive-c0re verifies the + /// reviewed PR head, fast-forwards the forge config repo's `main` + /// to it, marks the PR merged, then runs the same deploy tail as + /// `ApplyCommit`. `commit_ref` = PR number; `fetched_sha` = the + /// reviewed PR head pinned at submit. See `docs/approvals.md`. + MergeConfigPr, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]