From c59fa8541c05971c1ec698108ebbc46335ee9890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 12:53:13 +0200 Subject: [PATCH] phase 8 step 2: approval-gated spawn + dashboard spinner --- CLAUDE.md | 9 +- hive-ag3nt/src/bin/hive-m1nd.rs | 9 +- hive-c0re/src/actions.rs | 90 +++++++++++++++----- hive-c0re/src/approvals.rs | 92 +++++++++++++++++---- hive-c0re/src/coordinator.rs | 38 +++++++++ hive-c0re/src/dashboard.rs | 140 ++++++++++++++++++++++++++++---- hive-c0re/src/main.rs | 13 ++- hive-c0re/src/manager_server.rs | 33 ++------ hive-c0re/src/server.rs | 18 ++-- hive-sh4re/src/lib.rs | 30 ++++++- 10 files changed, 382 insertions(+), 90 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eb1a4de..c238f1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,8 +232,13 @@ See PLAN.md → "Phase 8" for the full design. Summary: `/var/lib/hyperhive/agents//` unless the operator passes an explicit wipe flag. Recreating an agent of the same name reuses prior creds. - **First spawn is approval-gated.** New agent names go through the same - approval queue as config edits. Dashboard shows a spinner during - `nixos-container create` + `update` + `start`. + approval queue as config edits. Manager calls `RequestSpawn` (CLI: + `hive-m1nd request-spawn `); operator can also queue from the + dashboard or `hive-c0re request-spawn `. The host's direct + `hive-c0re spawn ` still works as a privileged bypass for tests. + Approve runs `lifecycle::spawn` in a background task; the dashboard polls + via `` and renders a spinner row while + `nixos-container create` + `update` + `start` is in flight. - **"needs login" partial-run state.** No valid session in `~/.claude/` → harness binds the web UI but does NOT start the turn loop. Dashboard surfaces this state per-agent. diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index a2989fc..3e1705f 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -33,8 +33,9 @@ enum Cmd { Send { to: String, body: String }, /// Pop one message from the manager's inbox. Recv, - /// Spawn a sub-agent. - Spawn { name: String }, + /// Submit a spawn request for the user to approve (creates a pending + /// approval; on approval the host creates + starts the container). + RequestSpawn { name: String }, /// Kill a sub-agent. Kill { name: String }, /// Submit a config commit on the agent's config repo for user approval. @@ -67,7 +68,9 @@ async fn main() -> Result<()> { } Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await, Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await, - Cmd::Spawn { name } => one_shot(&cli.socket, ManagerRequest::Spawn { name }).await, + Cmd::RequestSpawn { name } => { + one_shot(&cli.socket, ManagerRequest::RequestSpawn { name }).await + } Cmd::Kill { name } => one_shot(&cli.socket, ManagerRequest::Kill { name }).await, Cmd::RequestApplyCommit { agent, commit_ref } => { one_shot( diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 4c5899b..c365557 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -3,37 +3,85 @@ //! `&Coordinator` and the request parameters; callers stitch the response //! shape they want (HTTP redirect vs JSON). -use anyhow::{Result, bail}; -use hive_sh4re::{ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; +use std::sync::Arc; -use crate::coordinator::Coordinator; +use anyhow::{Result, bail}; +use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; + +use crate::coordinator::{Coordinator, TransientKind}; use crate::lifecycle::{self, MANAGER_NAME}; -/// Approve a pending request: read the agent.nix at the approval's commit from -/// the proposed repo, copy into the applied repo, commit there, and rebuild -/// the agent container. On failure marks the approval failed (with the error -/// note) and returns the error. Either way, an `ApprovalResolved` helper event -/// is pushed into the manager's inbox. -pub async fn approve(coord: &Coordinator, id: i64) -> Result<()> { +/// Approve a pending request and run the underlying action. Dispatches on the +/// approval kind: +/// - `ApplyCommit`: read agent.nix at the approval's commit from the proposed +/// repo, copy into the applied repo, commit there, rebuild the container. +/// Synchronous — returns once the rebuild completes. +/// - `Spawn`: create + start a brand-new sub-agent container. Runs in a +/// background task so the operator's approve click returns immediately; +/// the dashboard surfaces a transient `Spawning` state until the container +/// is up. On failure, the approval is marked failed. +/// +/// In all cases an `ApprovalResolved` helper event lands in the manager's +/// inbox when the work resolves. +pub async fn approve(coord: Arc, id: i64) -> Result<()> { let approval = coord.approvals.mark_approved(id)?; - tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding"); + tracing::info!( + %approval.id, + %approval.agent, + kind = ?approval.kind, + %approval.commit_ref, + "approval: running action", + ); let agent_dir = coord.register_agent(&approval.agent)?; let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent); let applied_dir = Coordinator::agent_applied_dir(&approval.agent); let claude_dir = Coordinator::agent_claude_dir(&approval.agent); - let result: Result<()> = async { - lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref).await?; - lifecycle::rebuild( - &approval.agent, - &coord.hyperhive_flake, - &agent_dir, - &applied_dir, - &claude_dir, - ) - .await + + match approval.kind { + ApprovalKind::ApplyCommit => { + let result = async { + lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref).await?; + lifecycle::rebuild( + &approval.agent, + &coord.hyperhive_flake, + &agent_dir, + &applied_dir, + &claude_dir, + ) + .await + } + .await; + finish_approval(&coord, &approval, result) + } + ApprovalKind::Spawn => { + // Run the spawn in the background so the approve POST returns + // immediately. The dashboard reads `transient` to render a spinner. + coord.set_transient(&approval.agent, TransientKind::Spawning); + let coord_bg = coord.clone(); + let approval_bg = approval.clone(); + tokio::spawn(async move { + let agent_bg = approval_bg.agent.clone(); + let result = lifecycle::spawn( + &approval_bg.agent, + &coord_bg.hyperhive_flake, + &agent_dir, + &proposed_dir, + &applied_dir, + &claude_dir, + ) + .await; + coord_bg.clear_transient(&agent_bg); + if let Err(e) = finish_approval(&coord_bg, &approval_bg, result) { + tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); + } + }); + Ok(()) + } } - .await; +} + +fn finish_approval(coord: &Coordinator, approval: &hive_sh4re::Approval, result: Result<()>) -> Result<()> { match result { Ok(()) => { notify_manager( diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index ea87320..d3e659b 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; -use hive_sh4re::{Approval, ApprovalStatus}; +use hive_sh4re::{Approval, ApprovalKind, ApprovalStatus}; use rusqlite::{Connection, OptionalExtension, params}; const SCHEMA: &str = r" @@ -24,6 +24,23 @@ CREATE INDEX IF NOT EXISTS idx_approvals_pending ON approvals (id) WHERE status = 'pending'; "; +/// Add the `kind` column to pre-Phase-8 databases. ALTER TABLE ADD COLUMN is +/// idempotent here only via a column-existence check (sqlite doesn't support +/// IF NOT EXISTS on ADD COLUMN). Defaults legacy rows to `apply_commit`, +/// which matches their actual semantics. +fn ensure_kind_column(conn: &Connection) -> Result<()> { + let has_kind: bool = conn + .prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'kind'")? + .exists([])?; + if !has_kind { + conn.execute_batch( + "ALTER TABLE approvals ADD COLUMN kind TEXT NOT NULL DEFAULT 'apply_commit';", + ) + .context("add approvals.kind column")?; + } + Ok(()) +} + pub struct Approvals { conn: Mutex, } @@ -38,17 +55,22 @@ impl Approvals { .with_context(|| format!("open approvals db {}", path.display()))?; conn.execute_batch(SCHEMA) .context("apply approvals schema")?; + ensure_kind_column(&conn).context("migrate approvals.kind")?; Ok(Self { conn: Mutex::new(conn), }) } pub fn submit(&self, agent: &str, commit_ref: &str) -> Result { + self.submit_kind(agent, ApprovalKind::ApplyCommit, commit_ref) + } + + pub fn submit_kind(&self, agent: &str, kind: ApprovalKind, commit_ref: &str) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO approvals (agent, commit_ref, requested_at, status) - VALUES (?1, ?2, ?3, 'pending')", - params![agent, commit_ref, now_unix()], + "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status) + VALUES (?1, ?2, ?3, ?4, 'pending')", + params![agent, kind_to_str(kind), commit_ref, now_unix()], )?; Ok(conn.last_insert_rowid()) } @@ -56,7 +78,7 @@ impl Approvals { pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, agent, commit_ref, requested_at, status, resolved_at, note + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note FROM approvals WHERE status = 'pending' ORDER BY id ASC", @@ -69,7 +91,7 @@ impl Approvals { pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, agent, commit_ref, requested_at, status, resolved_at, note + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note FROM approvals WHERE id = ?1", params![id], row_to_approval, @@ -82,14 +104,22 @@ impl Approvals { /// approval so the caller can run the action and pass the agent name. pub fn mark_approved(&self, id: i64) -> Result { let conn = self.conn.lock().unwrap(); - let current: Option<(String, String, i64, String)> = conn + let current: Option<(String, String, String, i64, String)> = conn .query_row( - "SELECT agent, commit_ref, requested_at, status FROM approvals WHERE id = ?1", + "SELECT agent, kind, commit_ref, requested_at, status FROM approvals WHERE id = ?1", params![id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, ) .optional()?; - let Some((agent, commit_ref, requested_at, status)) = current else { + let Some((agent, kind, commit_ref, requested_at, status)) = current else { bail!("approval {id} not found"); }; if status != "pending" { @@ -103,6 +133,7 @@ impl Approvals { Ok(Approval { id, agent, + kind: kind_from_str(&kind)?, commit_ref, requested_at, status: ApprovalStatus::Approved, @@ -147,7 +178,20 @@ impl Approvals { } fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let status: String = row.get(4)?; + // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note. + let kind: String = row.get(2)?; + let kind = match kind.as_str() { + "apply_commit" => ApprovalKind::ApplyCommit, + "spawn" => ApprovalKind::Spawn, + other => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + format!("unknown approval kind '{other}'").into(), + )); + } + }; + let status: String = row.get(5)?; let status = match status.as_str() { "pending" => ApprovalStatus::Pending, "approved" => ApprovalStatus::Approved, @@ -155,7 +199,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { "failed" => ApprovalStatus::Failed, other => { return Err(rusqlite::Error::FromSqlConversionFailure( - 4, + 5, rusqlite::types::Type::Text, format!("unknown approval status '{other}'").into(), )); @@ -164,11 +208,27 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Approval { id: row.get(0)?, agent: row.get(1)?, - commit_ref: row.get(2)?, - requested_at: row.get(3)?, + kind, + commit_ref: row.get(3)?, + requested_at: row.get(4)?, status, - resolved_at: row.get(5)?, - note: row.get(6)?, + resolved_at: row.get(6)?, + note: row.get(7)?, + }) +} + +fn kind_to_str(kind: ApprovalKind) -> &'static str { + match kind { + ApprovalKind::ApplyCommit => "apply_commit", + ApprovalKind::Spawn => "spawn", + } +} + +fn kind_from_str(s: &str) -> Result { + Ok(match s { + "apply_commit" => ApprovalKind::ApplyCommit, + "spawn" => ApprovalKind::Spawn, + other => bail!("unknown approval kind '{other}'"), }) } diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 3430685..6bc2e06 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -30,6 +30,24 @@ pub struct Coordinator { /// `flake.nix` files as `inputs.hyperhive.url`. pub hyperhive_flake: String, agents: Mutex>, + /// Agents whose lifecycle action (currently just spawn) is in flight. + /// Read by the dashboard to render a spinner; cleared when the action + /// resolves (success or failure). + transient: Mutex>, +} + +/// Per-agent in-progress state that the dashboard surfaces between approve +/// click and container ready. +#[derive(Debug, Clone)] +pub struct TransientState { + pub kind: TransientKind, + pub since: std::time::Instant, +} + +#[derive(Debug, Clone, Copy)] +pub enum TransientKind { + /// `lifecycle::spawn` is running (nixos-container create + update + start). + Spawning, } impl Coordinator { @@ -41,6 +59,7 @@ impl Coordinator { approvals: Arc::new(approvals), hyperhive_flake, agents: Mutex::new(HashMap::new()), + transient: Mutex::new(HashMap::new()), }) } @@ -64,6 +83,25 @@ impl Coordinator { } } + /// Mark an agent as in-progress (only one state per agent for now). + pub fn set_transient(&self, name: &str, kind: TransientKind) { + self.transient.lock().unwrap().insert( + name.to_owned(), + TransientState { + kind, + since: std::time::Instant::now(), + }, + ); + } + + pub fn clear_transient(&self, name: &str) { + self.transient.lock().unwrap().remove(name); + } + + pub fn transient_snapshot(&self) -> HashMap { + self.transient.lock().unwrap().clone() + } + pub fn agent_dir(name: &str) -> PathBuf { PathBuf::from(format!("{AGENT_RUNTIME_ROOT}/{name}")) } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 8f8cb31..e42f325 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -42,6 +42,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) .route("/destroy/{name}", post(post_destroy)) + .route("/request-spawn", post(post_request_spawn)) .route("/send", post(post_send)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); @@ -62,15 +63,26 @@ async fn index(headers: HeaderMap, State(state): State) -> Html".to_owned() + }; + Html(format!( - "\n\n\n\nhyperhive // h1ve-c0re\n{STYLE}\n\n\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", - containers = render_containers(&containers, &hostname), + "\n\n\n\nhyperhive // h1ve-c0re\n{refresh}\n{STYLE}\n\n\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", + containers = render_containers(&containers, &transient, &hostname), talk = render_talk(&containers), )) } @@ -112,7 +124,7 @@ async fn messages_stream( } async fn post_approve(State(state): State, AxumPath(id): AxumPath) -> Response { - match actions::approve(&state.coord, id).await { + match actions::approve(state.coord.clone(), id).await { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("approve {id} failed: {e:#}")), } @@ -125,6 +137,32 @@ async fn post_deny(State(state): State, AxumPath(id): AxumPath) - } } +#[derive(Deserialize)] +struct RequestSpawnForm { + name: String, +} + +async fn post_request_spawn( + State(state): State, + Form(form): Form, +) -> Response { + let name = form.name.trim().to_owned(); + if name.is_empty() { + return error_response("spawn: `name` required"); + } + match state + .coord + .approvals + .submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "") + { + Ok(id) => { + tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); + Redirect::to("/").into_response() + } + Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), + } +} + async fn post_destroy(State(state): State, AxumPath(name): AxumPath) -> Response { match actions::destroy(&state.coord, &name).await { Ok(()) => Redirect::to("/").into_response(), @@ -143,11 +181,36 @@ fn error_response(message: &str) -> Response { .into_response() } -fn render_containers(containers: &[String], hostname: &str) -> String { +fn render_containers( + containers: &[String], + transient: &std::collections::HashMap, + hostname: &str, +) -> String { let mut out = String::from( "

◆ C0NTAINERS ◆

\n
══════════════════════════════════════════════════════════════
\n", ); - if containers.is_empty() { + out.push_str("
\n \n \n
\n

spawn requests queue as approvals. operator approves below to actually create the container.

\n"); + // Render in-flight spawns first so the operator sees feedback immediately. + if !transient.is_empty() { + out.push_str("
    \n"); + for (name, state) in transient { + // Skip names that already exist in `containers` (race: spawn finished + // between transient set and list refresh). + if containers.iter().any(|c| c == &format!("h-{name}")) { + continue; + } + let secs = state.since.elapsed().as_secs(); + let label = match state.kind { + crate::coordinator::TransientKind::Spawning => "spawning…", + }; + let _ = writeln!( + out, + "
  • {name} {label} nixos-container create + start ({secs}s)
  • ", + ); + } + out.push_str("
\n"); + } + if containers.is_empty() && transient.is_empty() { out.push_str("

▓ no managed containers ▓

\n"); return out; } @@ -180,15 +243,27 @@ async fn render_approvals(approvals: &[Approval]) -> String { } out.push_str("
    \n"); for a in approvals { - let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)]; - let diff = approval_diff(&a.agent, &a.commit_ref).await; - let _ = writeln!( - out, - "
  • \n
    #{id} {agent} {sha_short}\n
    \n
    \n
    \n
    diff vs applied
    {diff}
    \n
  • ", - id = a.id, - agent = a.agent, - diff = html_escape(&diff), - ); + match a.kind { + hive_sh4re::ApprovalKind::ApplyCommit => { + let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)]; + let diff = approval_diff(&a.agent, &a.commit_ref).await; + let _ = writeln!( + out, + "
  • \n
    #{id} {agent} apply {sha_short}\n
    \n
    \n
    \n
    diff vs applied
    {diff}
    \n
  • ", + id = a.id, + agent = a.agent, + diff = html_escape(&diff), + ); + } + hive_sh4re::ApprovalKind::Spawn => { + let _ = writeln!( + out, + "
  • \n
    #{id} {agent} spawn new sub-agent — container will be created on approve\n
    \n
    \n
    \n
  • ", + id = a.id, + agent = a.agent, + ); + } + } } out.push_str("
\n"); out @@ -220,6 +295,11 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { approvals .into_iter() .filter(|a| { + // Spawn approvals are for not-yet-existent agents; the proposed + // dir is supposed to be missing. + if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) { + return true; + } if Coordinator::agent_proposed_dir(&a.agent).exists() { true } else { @@ -435,6 +515,38 @@ const STYLE: &str = r#" .btn-deny { color: var(--red); border-color: var(--red); } .btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-talk { color: var(--cyan); border-color: var(--cyan); } + .btn-spawn { color: var(--amber); border-color: var(--amber); } + .spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; } + .spawnform input { + font-family: inherit; + font-size: 1em; + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border); + padding: 0.4em 0.6em; + flex: 1; + } + .spawnform input::placeholder { color: var(--muted); } + .spawnform input:focus { outline: 1px solid var(--purple); } + .role-pending { color: var(--amber); border-color: var(--amber); } + .kind { + display: inline-block; + margin-left: 0.4em; + padding: 0.05em 0.5em; + border: 1px solid var(--purple-dim); + color: var(--purple-dim); + border-radius: 2px; + font-size: 0.75em; + letter-spacing: 0.1em; + text-transform: uppercase; + } + .kind-spawn { color: var(--amber); border-color: var(--amber); } + .spinner { + display: inline-block; + animation: spin 1s linear infinite; + color: var(--amber); + } + @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .talkform { display: flex; gap: 0.6em; diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 9491c0a..1db132d 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -44,11 +44,17 @@ enum Cmd { #[arg(long, default_value_t = 7000)] dashboard_port: u16, }, - /// Spawn a new agent container (`hive-agent-`). + /// Spawn a new agent container directly (`hive-agent-`). Bypasses + /// the approval queue — use only as an operator on the host. For + /// approval-gated spawns, use `request-spawn` instead. Spawn { name: String }, + /// Queue a spawn request as an approval. The container is created on + /// `approve ` (CLI) or the dashboard's APPR0VE button. + RequestSpawn { name: String }, /// Stop a managed container (graceful). Kill { name: String }, - /// Fully tear down a sub-agent (state + applied repo + drop-in wiped). + /// Tear down a sub-agent container. Container is removed; persistent + /// state (config repos + Claude credentials) is kept by default. Destroy { name: String }, /// Apply pending config to a managed container. Rebuild { name: String }, @@ -91,6 +97,9 @@ async fn main() -> Result<()> { Cmd::Spawn { name } => { render(client::request(&cli.socket, HostRequest::Spawn { name }).await?) } + Cmd::RequestSpawn { name } => { + render(client::request(&cli.socket, HostRequest::RequestSpawn { name }).await?) + } Cmd::Kill { name } => { render(client::request(&cli.socket, HostRequest::Kill { name }).await?) } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 674e59d..a7329da 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -91,31 +91,16 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse message: format!("{e:#}"), }, }, - ManagerRequest::Spawn { name } => { - tracing::info!(%name, "manager: spawn"); - let result: Result<()> = async { - let agent_dir = coord.register_agent(name)?; - let proposed_dir = Coordinator::agent_proposed_dir(name); - let applied_dir = Coordinator::agent_applied_dir(name); - let claude_dir = Coordinator::agent_claude_dir(name); - if let Err(e) = lifecycle::spawn( - name, - &coord.hyperhive_flake, - &agent_dir, - &proposed_dir, - &applied_dir, - &claude_dir, - ) - .await - { - coord.unregister_agent(name); - return Err(e); + ManagerRequest::RequestSpawn { name } => { + tracing::info!(%name, "manager: request_spawn"); + match coord + .approvals + .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "") + { + Ok(id) => { + tracing::info!(%id, %name, "spawn approval queued"); + ManagerResponse::Ok } - Ok(()) - } - .await; - match result { - Ok(()) => ManagerResponse::Ok, Err(e) => ManagerResponse::Err { message: format!("{e:#}"), }, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index bd89c6f..9a96030 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -46,7 +46,7 @@ async fn handle(stream: UnixStream, coord: Arc) -> Result<()> { return Ok(()); } let resp = match serde_json::from_str::(line.trim()) { - Ok(req) => dispatch(&req, &coord).await, + Ok(req) => dispatch(&req, coord.clone()).await, Err(e) => HostResponse::error(format!("parse error: {e}")), }; let mut payload = serde_json::to_string(&resp)?; @@ -56,7 +56,7 @@ async fn handle(stream: UnixStream, coord: Arc) -> Result<()> { } } -async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { +async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let result: anyhow::Result = async { Ok(match req { HostRequest::Spawn { name } => { @@ -81,6 +81,14 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { } HostResponse::success() } + HostRequest::RequestSpawn { name } => { + tracing::info!(%name, "request_spawn"); + let id = coord + .approvals + .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "")?; + tracing::info!(%id, %name, "spawn approval queued"); + HostResponse::success() + } HostRequest::Kill { name } => { tracing::info!(%name, "kill"); lifecycle::kill(name).await?; @@ -88,7 +96,7 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostResponse::success() } HostRequest::Destroy { name } => { - actions::destroy(coord, name).await?; + actions::destroy(&coord, name).await?; HostResponse::success() } HostRequest::Rebuild { name } => { @@ -109,11 +117,11 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { HostRequest::List => HostResponse::list(lifecycle::list().await?), HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?), HostRequest::Approve { id } => { - actions::approve(coord, *id).await?; + actions::approve(coord.clone(), *id).await?; HostResponse::success() } HostRequest::Deny { id } => { - actions::deny(coord, *id)?; + actions::deny(&coord, *id)?; HostResponse::success() } }) diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 26bef10..0cdb76d 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -12,8 +12,16 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] pub enum HostRequest { - /// Create and start a sub-agent container `hive-agent-`. + /// Create and start a sub-agent container directly (no approval). Use + /// this from privileged contexts (operator on the host); it bypasses the + /// approval queue intentionally so test scripts and one-off recoveries + /// don't need a separate approve step. Spawn { name: String }, + /// Submit a spawn request for the user to approve. On approval the host + /// creates and starts the container. Mirrors the manager's + /// `RequestSpawn` — exposed on the admin socket so the dashboard and CLI + /// can also queue spawns through the approval flow. + RequestSpawn { name: String }, /// Stop a managed container (graceful). Kill { name: String }, /// Tear down a sub-agent container: stop + remove + drop the systemd @@ -48,6 +56,9 @@ pub struct HostResponse { pub struct Approval { pub id: i64, pub agent: String, + #[serde(default)] + pub kind: ApprovalKind, + /// For `ApplyCommit`: the git sha to apply. For `Spawn`: empty. pub commit_ref: String, pub requested_at: i64, pub status: ApprovalStatus, @@ -57,6 +68,17 @@ pub struct Approval { pub note: Option, } +/// What action the approval, when granted, will trigger. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalKind { + /// Apply a manager-proposed config commit (existing flow). + #[default] + ApplyCommit, + /// Create + start a new sub-agent container with the given name. + Spawn, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ApprovalStatus { @@ -181,8 +203,10 @@ pub enum ManagerRequest { body: String, }, Recv, - /// Spawn a sub-agent. Phase 5 will gate this on user approval. - Spawn { + /// Submit a spawn request for the user to approve. On approval the host + /// creates and starts the container. Brand-new agent names only — if an + /// agent of the same name already exists, the approval will fail. + RequestSpawn { name: String, }, /// Stop a sub-agent (graceful).