phase 8 step 2: approval-gated spawn + dashboard spinner
This commit is contained in:
parent
a42fdb3a5c
commit
c59fa8541c
10 changed files with 382 additions and 90 deletions
|
|
@ -232,8 +232,13 @@ See PLAN.md → "Phase 8" for the full design. Summary:
|
|||
`/var/lib/hyperhive/agents/<name>/` 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 <name>`); operator can also queue from the
|
||||
dashboard or `hive-c0re request-spawn <name>`. The host's direct
|
||||
`hive-c0re spawn <name>` still works as a privileged bypass for tests.
|
||||
Approve runs `lifecycle::spawn` in a background task; the dashboard polls
|
||||
via `<meta refresh>` 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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}'"),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:#}"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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-<name>`.
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue