phase 8 step 2: approval-gated spawn + dashboard spinner

This commit is contained in:
müde 2026-05-15 12:53:13 +02:00
parent a42fdb3a5c
commit c59fa8541c
10 changed files with 382 additions and 90 deletions

View file

@ -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<Coordinator>, 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(

View file

@ -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<Connection>,
}
@ -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<i64> {
self.submit_kind(agent, ApprovalKind::ApplyCommit, commit_ref)
}
pub fn submit_kind(&self, agent: &str, kind: ApprovalKind, commit_ref: &str) -> Result<i64> {
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<Vec<Approval>> {
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<Option<Approval>> {
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<Approval> {
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<Approval> {
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<Approval> {
"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<Approval> {
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<ApprovalKind> {
Ok(match s {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn,
other => bail!("unknown approval kind '{other}'"),
})
}

View file

@ -30,6 +30,24 @@ pub struct Coordinator {
/// `flake.nix` files as `inputs.hyperhive.url`.
pub hyperhive_flake: String,
agents: Mutex<HashMap<String, AgentSocket>>,
/// 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<HashMap<String, TransientState>>,
}
/// 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<String, TransientState> {
self.transient.lock().unwrap().clone()
}
pub fn agent_dir(name: &str) -> PathBuf {
PathBuf::from(format!("{AGENT_RUNTIME_ROOT}/{name}"))
}

View file

@ -42,6 +42,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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<AppState>) -> Html<String
let hostname = host.split(':').next().unwrap_or(host).to_owned();
let containers = lifecycle::list().await.unwrap_or_default();
let transient = state.coord.transient_snapshot();
let approvals = gc_orphans(
&state.coord,
state.coord.approvals.pending().unwrap_or_default(),
);
let approvals_html = render_approvals(&approvals).await;
// Auto-refresh the dashboard root while there's a spawn in flight, so the
// operator sees the new agent show up in the container list without
// having to reload manually. 2s is a reasonable poll interval for
// nixos-container create + start, which usually finishes in <30s.
let refresh = if transient.is_empty() {
String::new()
} else {
"<meta http-equiv=\"refresh\" content=\"2\">".to_owned()
};
Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
containers = render_containers(&containers, &hostname),
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\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<AppState>, AxumPath(id): AxumPath<i64>) -> 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<AppState>, AxumPath(id): AxumPath<i64>) -
}
}
#[derive(Deserialize)]
struct RequestSpawnForm {
name: String,
}
async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,
) -> 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<AppState>, AxumPath(name): AxumPath<String>) -> 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<String, crate::coordinator::TransientState>,
hostname: &str,
) -> String {
let mut out = String::from(
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
);
if containers.is_empty() {
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\">\n <input name=\"name\" placeholder=\"new agent name (≤9 chars)\" maxlength=\"9\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-spawn\">◆ R3QU3ST SP4WN</button>\n</form>\n<p class=\"meta\">spawn requests queue as approvals. operator approves below to actually create the container.</p>\n");
// Render in-flight spawns first so the operator sees feedback immediately.
if !transient.is_empty() {
out.push_str("<ul>\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,
"<li><span class=\"glyph spinner\">◐</span> <span class=\"agent\">{name}</span> <span class=\"role role-pending\">{label}</span> <span class=\"meta\">nixos-container create + start ({secs}s)</span></li>",
);
}
out.push_str("</ul>\n");
}
if containers.is_empty() && transient.is_empty() {
out.push_str("<p class=\"empty\">▓ no managed containers ▓</p>\n");
return out;
}
@ -180,15 +243,27 @@ async fn render_approvals(approvals: &[Approval]) -> String {
}
out.push_str("<ul class=\"approvals\">\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,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
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,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind\">apply</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
id = a.id,
agent = a.agent,
diff = html_escape(&diff),
);
}
hive_sh4re::ApprovalKind::Spawn => {
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">⊕</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind kind-spawn\">spawn</span> <span class=\"meta\">new sub-agent — container will be created on approve</span>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n</li>",
id = a.id,
agent = a.agent,
);
}
}
}
out.push_str("</ul>\n");
out
@ -220,6 +295,11 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
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;

View file

@ -44,11 +44,17 @@ enum Cmd {
#[arg(long, default_value_t = 7000)]
dashboard_port: u16,
},
/// Spawn a new agent container (`hive-agent-<name>`).
/// Spawn a new agent container directly (`hive-agent-<name>`). 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 <id>` (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?)
}

View file

@ -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:#}"),
},

View file

@ -46,7 +46,7 @@ async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
return Ok(());
}
let resp = match serde_json::from_str::<HostRequest>(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<Coordinator>) -> Result<()> {
}
}
async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse {
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let result: anyhow::Result<HostResponse> = 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()
}
})