refactor(hive-c0re): drop the request_init_config tool and InitConfig approval

swarm-controller's `InitAgentConfigRepo` node already covers config-repo
creation, so this deletes a duplicate rather than a capability; old
`init_config` rows are skipped by `collect_lenient` with no migration, by
operator decision.

Refs #4398
This commit is contained in:
atlas 2026-09-14 18:50:24 +02:00
commit a3b672d1d5
31 changed files with 134 additions and 601 deletions

View file

@ -12,11 +12,10 @@ use hive_sh4re::manager::HelperEvent;
use crate::coordinator::Coordinator;
use crate::lifecycle;
/// Approve a pending request. Marks the approval row durably, then
/// 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 `MergeConfigPr`).
/// Approve a pending request. Marks the approval row durably, then submits
/// the work 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 `MergeConfigPr`).
///
/// Dispatch:
/// - `MergeConfigPr` → a `DeployWindow` DAG (`MergeVerify → DeployApply →
@ -24,7 +23,6 @@ use crate::lifecycle;
/// resource-holding root; ~30-90s)
/// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion)
/// - `Spawn` → a `Spawn` DAG (`Create → WriteDropin → Reconcile`)
/// - `InitConfig` → inline (<1s; queue card would be noise)
///
/// Every queued kind — deploys included — resolves its approval row via
/// [`resolve_approval_dag`] when the DAG settles terminal.
@ -38,15 +36,6 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
"approval: dispatching",
);
match approval.kind {
ApprovalKind::InitConfig => {
// Sub-second git seed + forge-remote wire. Routing through
// the queue would surface a queue card that's gone before
// the operator's eyes refocus. Run inline.
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
run_approval_init_config(&coord, approval, proposed_dir, claude_dir, notes_dir).await
}
ApprovalKind::UpdateMetaInputs => {
// Inputs JSON-encoded into commit_ref by the manager's
// submit path — surface them on the DAG so the dashboard
@ -638,49 +627,6 @@ async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) {
crate::dashboard::emit_tombstones_snapshot(coord).await;
}
/// Inline (non-queued) handler for `ApprovalKind::InitConfig`. Just
/// seeds the proposed git repo + the per-agent dirs — sub-second
/// work that doesn't justify a queue card.
async fn run_approval_init_config(
coord: &Coordinator,
approval: hive_sh4re::approvals::Approval,
proposed_dir: std::path::PathBuf,
claude_dir: std::path::PathBuf,
notes_dir: std::path::PathBuf,
) -> Result<()> {
let result: Result<()> = async {
// Place the new child under its requesting parent (carried in
// commit_ref by submit_init_config). An empty commit_ref means
// no explicit parent was named (privileged manager socket, or an
// approval queued before the new-child feature) — write no edge
// and let `topology::reconcile` assign the default position on
// first spawn, so this path never names a specific root agent.
if !approval.commit_ref.is_empty() {
crate::topology::add_child(approval.agent.as_str(), &approval.commit_ref)
.map_err(|e| anyhow::anyhow!("topology add_child: {e}"))?;
}
// Create the agent's state root as a btrfs subvolume FIRST, before
// any dir-seed touches it. `ensure_agent_state_subvolume` is
// progressive ("root exists → skip"), and `setup_proposed` does a
// `create_dir_all` on the proposed-config path which would
// materialise the state root as a plain directory — after which
// the subvolume create is silently skipped and the agent never
// lands on a subvolume (no quota, no snapshot). Order matters.
lifecycle::ensure_agent_state_subvolume(approval.agent.as_str()).await?;
lifecycle::setup_proposed(&proposed_dir, approval.agent.as_str()).await?;
lifecycle::ensure_claude_dir(&claude_dir)?;
lifecycle::ensure_state_dir(&notes_dir)?;
Ok(())
}
.await;
if result.is_ok()
&& let Err(e) = crate::forge::ensure_meta_remote(approval.agent.as_str()).await
{
tracing::warn!(agent = %approval.agent, error = ?e, "forge: ensure_meta_remote after init_config failed");
}
finish_approval(coord, &approval, result, None).await
}
async fn finish_approval(
coord: &Coordinator,
approval: &hive_sh4re::approvals::Approval,
@ -727,28 +673,12 @@ async fn finish_approval(
note: note.clone(),
description: approval.description.clone(),
});
// For spawn/rebuild/init_config approvals, also surface the underlying
// action so the manager knows whether the lifecycle step succeeded.
// The ApprovalResolved event already carries the same `ok` signal but
// For spawn/rebuild approvals, also surface the underlying action so the
// manager knows whether the lifecycle step succeeded. The
// ApprovalResolved event already carries the same `ok` signal but
// separating it lets the manager react to the lifecycle change
// without having to special-case approvals.
match approval.kind {
ApprovalKind::InitConfig => {
if ok {
let _ = coord
.push_todo_submitter(
approval.id,
"core",
Some(format!("config_ready:{}", approval.agent)),
format!(
"agent '{}' config repo ready — edit + apply",
approval.agent
),
None,
)
.await;
}
}
ApprovalKind::Spawn => {
let summary = if ok {
format!("agent '{}' spawned", approval.agent)

View file

@ -229,9 +229,8 @@ pub fn write(topology: &BTreeMap<String, Option<String>>) -> std::io::Result<()>
/// Compute the default topology for a fresh install: every agent is a
/// root (parent = null). There is no structural "manager" — agents
/// arrange themselves via explicit parent edges (an agent-requested
/// sub-agent gets a requester-as-parent edge at `init_config`; the
/// operator reparents via the dashboard / `RequestSetParent` API).
/// arrange themselves via explicit parent edges, written by the operator
/// through the dashboard / `RequestSetParent` API.
/// Used by `meta::sync_agents` on first call to seed `topology.json`.
///
/// As soon as an explicit write lands (dashboard / `RequestSetParent`
@ -298,53 +297,6 @@ pub fn apply_set_parent(
Ok(next)
}
/// Declare a brand-new agent's parent edge before the agent exists in
/// the container set. Unlike [`crate::meta::bulk_commit_topology`] /
/// [`apply_set_parent`] (which reparent an entry that must already be
/// present), this inserts a fresh `child -> parent`
/// row. Used by the `InitConfig` approval to place a just-scaffolded
/// sub-agent under its requesting parent, so the edge is in place
/// before the first apply-commit spawns the container (and before
/// [`reconcile`] would otherwise default it to the manager).
///
/// Idempotent when the edge already exists. Refuses to clobber an entry
/// whose parent differs (one agent can't steal another's child) and
/// validates that `parent` is itself a known agent.
pub fn add_child(child: &str, parent: &str) -> Result<(), String> {
let current = read();
match apply_add_child(&current, child, parent)? {
Some(next) => write(&next).map_err(|e| format!("write topology.json: {e}")),
None => Ok(()),
}
}
/// Pure form of [`add_child`] for unit tests. Returns the post-insert
/// map (caller writes it back), `None` for an idempotent no-op (edge
/// already present), or an error string (unknown parent / name owned by
/// a different parent).
pub fn apply_add_child(
topo: &BTreeMap<String, Option<String>>,
child: &str,
parent: &str,
) -> Result<Option<BTreeMap<String, Option<String>>>, String> {
if !topo.contains_key(parent) {
return Err(format!("unknown parent: {parent}"));
}
match topo.get(child) {
Some(Some(p)) if p == parent => return Ok(None),
Some(existing) => {
return Err(format!(
"agent {child} already exists in topology (parent: {existing:?}); \
refusing to reparent via add_child"
));
}
None => {}
}
let mut next = topo.clone();
next.insert(child.to_owned(), Some(parent.to_owned()));
Ok(Some(next))
}
/// Reconcile `topology.json` against the current agent set. Adds an
/// entry (default: parent = null — a new agent with no declared parent
/// is its own root) for any agent missing from the file; removes
@ -353,10 +305,10 @@ pub fn apply_add_child(
/// choices stick across regenerations. Returns true when the file
/// changed and should be re-committed by the caller.
///
/// `pending` lists agents that have an operator-approved proposed config
/// repo but no container yet (init'd, not yet spawned). They are KEPT
/// (not dropped) so the parent edge written at `InitConfig` approval
/// survives until the first apply-commit, but they are NOT seeded with a
/// `pending` lists agents that have a provisioned proposed config repo
/// but no container yet (provisioned, not yet spawned). They are KEPT
/// (not dropped) so an explicit parent edge written before the first
/// spawn survives until the first apply-commit, but they are NOT seeded with a
/// default parent here and NOT added to roles — that happens when the
/// container actually spawns and the name moves into `agent_names`.
pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result<bool> {
@ -384,12 +336,11 @@ pub fn apply_reconcile(
for name in agent_names {
if !next.contains_key(name) {
// A new agent with no declared parent defaults to root
// (parent = null). Agent-requested sub-agents always carry an
// explicit requester-as-parent edge (written at init_config
// approval), so they never hit this default — only
// user/operator-initiated spawns do, and those are roots. No
// agent is structurally privileged here: "root-ness" is just
// a null parent.
// (parent = null). An agent placed under a parent carries an
// explicit edge written before its first spawn, so it never
// hits this default — only spawns with no declared parent do,
// and those are roots. No agent is structurally privileged
// here: "root-ness" is just a null parent.
next.insert(name.clone(), None);
changed = true;
}
@ -502,8 +453,7 @@ mod tests {
#[test]
fn default_seed_makes_every_agent_root() {
// No structural manager: every agent defaults to root (null
// parent). Explicit edges (init_config / dashboard) are layered
// on later.
// parent). Explicit edges are layered on later.
let agents = vec![
"alice".to_owned(),
crate::lifecycle::MANAGER_NAME.to_owned(),
@ -630,42 +580,6 @@ mod tests {
assert_eq!(next, topo_three_level());
}
#[test]
fn apply_add_child_inserts_new_edge_under_parent() {
// alice spawns a brand-new child `dora`: the edge lands
// with alice as parent without disturbing the rest of the tree.
let next = apply_add_child(&topo_three_level(), "dora", "alice")
.unwrap()
.expect("brand-new edge should produce a map");
assert_eq!(next.get("dora"), Some(&Some("alice".to_owned())));
// existing entries untouched.
assert_eq!(next.get("bob"), Some(&Some("alice".to_owned())));
}
#[test]
fn apply_add_child_is_idempotent_when_edge_exists() {
// bob already under alice — re-init returns a no-op (None).
assert!(
apply_add_child(&topo_three_level(), "bob", "alice")
.unwrap()
.is_none()
);
}
#[test]
fn apply_add_child_refuses_unknown_parent() {
let err = apply_add_child(&topo_three_level(), "dora", "nobody").unwrap_err();
assert!(err.contains("unknown parent"), "err = {err}");
}
#[test]
fn apply_add_child_refuses_name_owned_by_other_parent() {
// bob lives under alice; the manager can't claim it via add_child.
let err = apply_add_child(&topo_three_level(), "bob", crate::lifecycle::MANAGER_NAME)
.unwrap_err();
assert!(err.contains("already exists"), "err = {err}");
}
#[test]
fn apply_reconcile_adds_missing_live_agent_as_root() {
// A live agent with no prior topology entry defaults to root
@ -696,7 +610,7 @@ mod tests {
#[test]
fn apply_reconcile_keeps_pending_init_agent_edge() {
// `dora` was init'd under alice (edge present) but has no
// `dora` was placed under alice (edge present) but has no
// container yet, so it's absent from the live set. It must NOT
// be dropped, and its alice-parent edge must be preserved (not
// re-seeded under the manager).

View file

@ -1512,8 +1512,8 @@ impl Coordinator {
/// Agents that have an operator-approved proposed config repo but
/// were never deployed — proposed `.git` exists, applied `.git` does
/// not. Their `topology.json` parent edge (written at `InitConfig`
/// approval) must survive `topology::reconcile` until the first
/// not. Their `topology.json` parent edge (written before the first
/// spawn) must survive `topology::reconcile` until the first
/// apply-commit spawns the container. Distinct from tombstones,
/// which have an applied repo from a prior deploy.
#[must_use]

View file

@ -83,13 +83,9 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
approvals
.into_iter()
.filter(|a| {
// Spawn and InitConfig approvals are for not-yet-existent agents;
// the proposed dir is supposed to be missing.
if matches!(
a.kind,
hive_sh4re::approvals::ApprovalKind::Spawn
| hive_sh4re::approvals::ApprovalKind::InitConfig
) {
// A Spawn approval is for a not-yet-existent agent; the proposed
// dir is supposed to be missing, so absence is not orphanhood.
if a.kind == hive_sh4re::approvals::ApprovalKind::Spawn {
return true;
}
if Coordinator::agent_proposed_dir(&a.agent).exists() {

View file

@ -449,16 +449,6 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
commit_ref: None,
requested_at: a.requested_at,
},
hive_sh4re::approvals::ApprovalKind::InitConfig => ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: "init_config",
sha_short: None,
description: a.description,
pr_number: None,
commit_ref: None,
requested_at: a.requested_at,
},
hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs => ApprovalView {
id: a.id,
agent: a.agent.to_string(),

View file

@ -106,11 +106,10 @@ fn config_bind_source(name: &str) -> PathBuf {
/// hive-c0re still reads it directly on the host (`stats::hive_stats`),
/// which needs no bind mount into the parent.
///
/// ⚠️ The seeding done at `InitConfig` approval is **not** affected by the
/// `config` flag and must not be read as a reason to widen it: that runs
/// as hive-c0re against the host path (see `actions.rs`, which seeds the
/// repo and wires its forge remote inline), and `read_only` on a bind
/// constrains writers *inside* the container only.
/// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by
/// the `config` flag and must not be read as a reason to widen it: that runs
/// as hive-c0re against the host path (see `lifecycle::setup_proposed`), and
/// `read_only` on a bind constrains writers *inside* the container only.
fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
let Ok(child) = hive_types::Ident::parse(child) else {
tracing::warn!(%child, "skipping child bind: invalid agent name");

View file

@ -171,8 +171,6 @@ pub async fn setup_applied(
/// valid session; credential files inside (`.credentials.json` etc.) are 0600 so
/// secrets stay private regardless of the directory mode. Idempotent: existing
/// dirs are left untouched (an agent's OAuth tokens survive `destroy`/recreate).
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
/// dirs without calling the full `spawn`.
pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
use std::io;
if !claude_dir.exists() {
@ -208,9 +206,9 @@ pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
Ok(())
}
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
/// dirs without calling the full `spawn`. Also creates the sibling `harness/`
/// dir so the first harness startup can write its sqlite files immediately.
/// Create the per-agent state dir if missing. Also creates the sibling
/// `harness/` dir so the first harness startup can write its sqlite files
/// immediately.
pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
if !notes_dir.exists() {
std::fs::create_dir_all(notes_dir)

View file

@ -1,6 +1,5 @@
//! Config-approval request handlers: `RequestInitConfig` /
//! `RequestUpdateMetaInputs`, plus the shared submit helpers
//! (`submit_init_config` / `submit_merge_config_pr`).
//! Config-approval request handlers: `RequestUpdateMetaInputs`, plus the
//! shared submit helper `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 config
@ -11,70 +10,8 @@ use std::sync::Arc;
use hive_core_agent_sock::Response;
use super::require_new_child;
use crate::coordinator::Coordinator;
/// `RequestInitConfig` — queue an `InitConfig` approval for an agent. The
/// `name` must be brand-new (absent from the topology) or already in the
/// caller's subtree; the requester is recorded as the new agent's parent (the
/// root requesting a new agent → a top-level agent, matching reconcile's
/// default).
pub(super) fn handle_request_init_config(
coord: &Arc<Coordinator>,
agent: &str,
name: &str,
description: Option<String>,
) -> Response {
if let Some(err) = require_new_child(agent, name, "request_init_config for") {
return err;
}
tracing::info!(%agent, %name, "request_init_config");
// Warn, do not refuse: an agent already created under a colliding
// name must stay re-initialisable, so the refusal comes later, once
// the warning has had time to be seen.
//
// Checked HERE and not only in `swarm-controller::create_agent`:
// that daemon is opt-in and off on most hives, while this is the
// path the `request_init_config` tool takes on every hive. Guarding
// only the rarer one would have left the common flow exactly as
// unguarded as before.
//
// The blacklist itself comes from nix via `HIVE_RESERVED_NAMES`, so it
// stays a config change rather than a rebuild. An UNSET variable means
// this daemon was never told — which is not the same as "no name is
// reserved", and saying nothing there would be a check that reports
// clean because it could not run.
let raw = hive_types::reserved_names_raw();
let warnings = match raw.as_deref().map(hive_types::parse_reserved_names) {
None => {
tracing::error!(
var = hive_types::RESERVED_NAMES_ENV,
"request_init_config: reserved-name check could not run — variable not set"
);
vec![format!(
"the reserved-name check did not run: {} is unset, so {name:?} was accepted \
without being checked against the protocol literals",
hive_types::RESERVED_NAMES_ENV
)]
}
Some(reserved) if hive_types::is_reserved_name(name, &reserved) => {
tracing::warn!(%agent, %name, "request_init_config: reserved name");
vec![format!(
"agent name {name:?} is a reserved protocol name — messages from this agent will \
be indistinguishable from hyperhive's own; this will become an error"
)]
}
Some(_) => Vec::new(),
};
match submit_init_config(coord, name, Some(agent), description) {
Ok(_id) if warnings.is_empty() => Response::Ok,
Ok(_id) => Response::OkWarn { warnings },
Err(e) => Response::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).
@ -214,58 +151,3 @@ pub(crate) async fn submit_merge_config_pr(
});
Ok(id)
}
/// Queue an `InitConfig` approval for a brand-new agent whose config repo
/// does not yet exist. Shared between the manager and agent sockets.
///
/// `parent`, when `Some`, is the agent that will own the new child once
/// the operator approves: it is stashed in the approval's `commit_ref`
/// field (unused for `InitConfig` otherwise — same pattern
/// `UpdateMetaInputs` uses to carry its inputs JSON) and consumed in
/// `run_approval_init_config` to write the `child -> parent` topology
/// edge. Callers pass the requesting agent, so the requester becomes the
/// new agent's parent (the root requesting a new agent → a top-level agent,
/// matching `topology::reconcile`'s default). `None` writes no explicit
/// edge (reconcile-default placement) — retained for that fallback.
pub(crate) fn submit_init_config(
coord: &Arc<Coordinator>,
name: &str,
parent: Option<&str>,
description: Option<String>,
) -> anyhow::Result<i64> {
let agent = hive_types::Ident::parse(name)
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(&agent);
if proposed_dir.join(".git").exists() {
anyhow::bail!(
"proposed config repo for '{name}' already exists at {} - \
nothing to init; config changes go through a forge PR on \
agent-configs/{name}",
proposed_dir.display()
);
}
let id = coord
.approvals
.submit_kind(
name,
hive_sh4re::approvals::ApprovalKind::InitConfig,
parent.unwrap_or(""),
description.as_deref(),
// `parent` is the requesting agent (becomes the new child's
// parent); it's also the submitter the approval events route
// back to. No declared parent = operator-initiated path.
parent.unwrap_or("operator"),
None, // no sha for InitConfig
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
tracing::info!(%id, %name, "init_config approval queued");
coord.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: name,
approval_kind: "init_config",
sha_short: None,
description,
pr_number: None,
});
Ok(id)
}

View file

@ -30,7 +30,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_init_config, handle_request_update_meta_inputs};
use config_approvals::handle_request_update_meta_inputs;
use lifecycle_handlers::{
handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update,
};
@ -560,9 +560,6 @@ async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Respo
Request::Kill { name } => handle_kill(coord, agent, name).await,
Request::Update { name } => handle_update(coord, agent, name),
Request::ListDescendants => handle_list_descendants(coord, agent).await,
Request::RequestInitConfig { name, description } => {
handle_request_init_config(coord, agent, name, description.clone())
}
// Agent-state queries: own subtree is free; other agents + the
// hive-wide `"*"` sweep require `QueryAgentState`.
Request::GetLooseEnds { agent: target } => {
@ -695,46 +692,6 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option<Response> {
}
}
/// 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 in its subtree (re-init / config
/// update of an agent it owns) OR brand-new (absent from the topology
/// tree — the requester becomes its parent). A name that already
/// exists outside the caller's subtree is refused so
/// one agent can't hijack another's sub-tree.
///
/// Also re-runs the agent-name format check (a traversal / malformed name
/// could never be a descendant): a brand-new name now flows straight to
/// `submit_init_config`, which builds filesystem paths from it, so validate
/// before that.
fn require_new_child(agent: &str, target: &str, action: &str) -> Option<Response> {
if let Err(reason) = hive_types::Ident::parse(target) {
return Some(Response::Err {
message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"),
});
}
// brand-new name (absent from topology) — requester becomes the parent on
// approval; allowed for any caller.
if !crate::topology::read().contains_key(target) {
return None;
}
// existing agent — allowed only if it's in the caller's subtree
// (re-init / config update of an agent the caller owns; the root owns
// every existing agent). Refuses an agent outside the caller's subtree
// so one agent can't hijack another's config.
if crate::topology::is_descendant_of(target, agent) {
None
} else {
Some(Response::Err {
message: format!(
"agent `{agent}` cannot {action} `{target}`: it already exists \
outside its subtree in the topology tree"
),
})
}
}
/// `GetLooseEnds` — read the target's loose ends. `None` / own / a subtree
/// descendant resolve freely (a parent sees its subtree, the root sees all);
/// any other named agent needs `QueryAgentState`; `"*"` is a hive-wide sweep

View file

@ -1,5 +1,5 @@
//! Approval queue. Requests are submitted by the manager (`RequestInitConfig`
//! / `RequestUpdateMetaInputs`), the config-PR webhook (`MergeConfigPr`), or
//! Approval queue. Requests are submitted by the manager
//! (`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.
@ -74,8 +74,7 @@ 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 kind carries no sha (e.g. `Spawn` /
/// `InitConfig`).
/// atomic. Pass `None` when the kind carries no sha (e.g. `Spawn`).
pub fn submit_kind(
&self,
agent: &str,
@ -380,7 +379,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
let kind: String = row.get(2)?;
let kind = match kind.as_str() {
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
"schedule_prompt" => ApprovalKind::SchedulePrompt,
"merge_config_pr" => ApprovalKind::MergeConfigPr,
@ -434,7 +432,6 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result<Approval> {
fn kind_from_str(s: &str) -> Result<ApprovalKind> {
Ok(match s {
"spawn" => ApprovalKind::Spawn,
"init_config" => ApprovalKind::InitConfig,
"update_meta_inputs" => ApprovalKind::UpdateMetaInputs,
"schedule_prompt" => ApprovalKind::SchedulePrompt,
"merge_config_pr" => ApprovalKind::MergeConfigPr,
@ -454,31 +451,6 @@ mod tests {
(dir, path, db)
}
#[test]
fn init_config_approval_round_trips() {
// Regression test: an `init_config` row used to fail
// deserialization (row_to_approval matched only apply_commit +
// spawn), erroring out the whole `pending()` query — every
// approval then vanished from the dashboard.
let (_dir, _path, db) = open_temp();
let id = db
.submit_kind(
"bitburner",
ApprovalKind::InitConfig,
"",
Some("scaffold"),
"bitburner",
None,
)
.expect("submit init_config");
let pending = db
.pending()
.expect("pending() must not error on an init_config row");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, id);
assert!(matches!(pending[0].kind, ApprovalKind::InitConfig));
}
#[test]
fn mixed_kinds_all_listed() {
let (_dir, _path, db) = open_temp();
@ -493,7 +465,7 @@ mod tests {
.unwrap();
db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None)
.unwrap();
db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None)
db.submit_kind("c", ApprovalKind::UpdateMetaInputs, "[]", None, "c", None)
.unwrap();
let pending = db.pending().expect("pending");
assert_eq!(pending.len(), 3, "all three kinds must be visible");