refactor(#2416): remove the non-pr config-change flow (request_apply_commit / applycommit)
This commit is contained in:
parent
fbbd5d921c
commit
c2bd7db998
34 changed files with 293 additions and 1635 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } => {
|
||||
|
|
|
|||
|
|
@ -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, ¬e);
|
||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||
id,
|
||||
agent,
|
||||
approval_kind: "apply_commit",
|
||||
sha_short: None,
|
||||
status: "failed",
|
||||
note: Some(note),
|
||||
description: description.map(str::to_owned),
|
||||
});
|
||||
return Err(anyhow::anyhow!("git_fetch_to_tag: {e:#}"));
|
||||
}
|
||||
};
|
||||
coord
|
||||
.approvals
|
||||
.set_fetched_sha(id, &sha)
|
||||
.map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?;
|
||||
// Pre-flight gates: both reject the apply before approval if
|
||||
// the agent's flake state would inflate meta's lock with duplicates
|
||||
// or lie about what nix will fetch. Both checks independently read
|
||||
// `<tag>:flake.lock` via git — they don't share state. Order matters
|
||||
// only for early-exit + messaging: sync first means a stale lock
|
||||
// bails with the actionable "run `nix flake lock`" hint rather than
|
||||
// a dedup pass on a lock nix would never produce.
|
||||
//
|
||||
// Runs after `set_fetched_sha` so the failed row carries the sha
|
||||
// that broke. Both failure paths mark + emit, then bail.
|
||||
let sha_short = sha[..sha.len().min(12)].to_owned();
|
||||
if let Err(e) = crate::flake_check::check_lock_in_sync(&applied_dir, &tag, id).await {
|
||||
let note = format!("{e:#}");
|
||||
let _ = coord.approvals.mark_failed(id, ¬e);
|
||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||
id,
|
||||
agent,
|
||||
approval_kind: "apply_commit",
|
||||
sha_short: Some(sha_short.clone()),
|
||||
status: "failed",
|
||||
note: Some(note),
|
||||
description: description.map(str::to_owned),
|
||||
});
|
||||
return Err(anyhow::anyhow!("flake lock-sync check: {e:#}"));
|
||||
}
|
||||
if let Err(e) = crate::flake_check::check_no_duplicate_inputs(&applied_dir, &tag).await {
|
||||
let note = format!("{e:#}");
|
||||
let _ = coord.approvals.mark_failed(id, ¬e);
|
||||
coord.emit_approval_resolved(crate::coordinator::ApprovalResolved {
|
||||
id,
|
||||
agent,
|
||||
approval_kind: "apply_commit",
|
||||
sha_short: Some(sha_short.clone()),
|
||||
status: "failed",
|
||||
note: Some(note),
|
||||
description: description.map(str::to_owned),
|
||||
});
|
||||
return Err(anyhow::anyhow!("flake dedup check: {e:#}"));
|
||||
}
|
||||
// Mirror the freshly-planted proposal/<id> tag to the forge.
|
||||
if let Err(e) = crate::forge::push_config(agent).await {
|
||||
tracing::warn!(%agent, %id, error = ?e, "forge: push_config after submit failed");
|
||||
}
|
||||
// Phase 5b: surface the new pending approval on the dashboard
|
||||
// event channel. Compute the diff once here so live subscribers
|
||||
// get a fully-formed row without a snapshot refetch. `sha_short`
|
||||
// is reused from the dedup gate above.
|
||||
let diff = crate::dashboard::approval_diff(agent, id).await;
|
||||
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
|
||||
id,
|
||||
agent,
|
||||
approval_kind: "apply_commit",
|
||||
sha_short: Some(sha_short),
|
||||
diff: Some(diff),
|
||||
description: description.map(str::to_owned),
|
||||
pr_number: None,
|
||||
});
|
||||
Ok((id, sha))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_short_and_full_sha() {
|
||||
assert!(validate_commit_ref("e194f78").is_ok());
|
||||
assert!(validate_commit_ref("e194f7812ab").is_ok());
|
||||
assert!(validate_commit_ref(&"a".repeat(40)).is_ok());
|
||||
// Uppercase hex resolves fine through `git rev-parse`.
|
||||
assert!(validate_commit_ref("E194F78").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_branch_and_tag_names() {
|
||||
// The exact bug class this guard exists for.
|
||||
assert!(validate_commit_ref("main").is_err());
|
||||
assert!(validate_commit_ref("HEAD").is_err());
|
||||
assert!(validate_commit_ref("deployed/0").is_err());
|
||||
assert!(validate_commit_ref("feature-branch").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_short_too_long_and_empty() {
|
||||
assert!(validate_commit_ref("").is_err());
|
||||
assert!(validate_commit_ref("abc123").is_err()); // 6 chars
|
||||
assert!(validate_commit_ref(&"a".repeat(41)).is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue