//! Herdr second-runtime dispatch (Phase 1b). //! //! Server-side client for the `herdr_dispatch` / `herdr_status` / //! `herdr_read` ops the fleet-node daemon exposes. Missions with //! `runtime_kind = 'local_herdr'` route through this module instead //! of RuntimeProvisioner + ZeroClaw. //! //! Flow: //! 1. dispatch(mission, task) → node daemon spawns a Herdr pane + //! launches the requested CLI. Returns the (workspace_id, tab_id, //! pane_id) triple; caller persists it on topology_runs so a //! resumed run can reattach rather than double-spawn. //! 2. poll_until_done(pane_id) → periodically issues herdr_status //! until agent_status ∈ {done, idle} or a timeout. Between polls //! the operator can watch the pane live on the node (Phase 2's //! "Live pane" tab surfaces it in-browser). //! 3. read_transcript(pane_id) → final scrape after completion, //! persisted to run_events for the Tasks tab. use cm_domain::NodeId; use serde_json::{json, Value}; use std::sync::Arc; use std::time::Duration; use uuid::Uuid; use crate::fleet::NodeHub; /// What the caller needs to persist on the topology_run so a server /// restart can reattach to the same live pane. #[derive(Debug, Clone)] pub struct DispatchHandle { pub pane_id: String, pub raw_split_response: String, } /// Spawn a Herdr pane on `node` for `mission_id` and start `cli` with /// `prompt`. Returns the pane handle to persist. /// /// `cli` is the executable name — "claude", "codex", "kimi", "opencode", /// "omp", "pi". Server callers should validate against the mission's /// team template (a rust_sdlc mission on tank probably wants Claude /// Code; a research mission on morpheus probably wants Kimi). pub async fn dispatch( hub: Arc, node_id: NodeId, mission_id: Uuid, cli: &str, prompt: &str, ) -> Result { let args = json!({ "mission_id": mission_id.to_string(), "cli": cli, "prompt": prompt, }); let out = hub .call_timeout(node_id, "herdr_dispatch", args, 60) .await?; if !out.ok { return Err(format!( "node rejected dispatch: {}", truncate(&out.output, 400) )); } let payload: Value = serde_json::from_str(&out.output).map_err(|e| { format!( "dispatch payload not json: {e}: {}", truncate(&out.output, 200) ) })?; let pane_id = payload .get("pane_id") .and_then(Value::as_str) .filter(|s| !s.is_empty()) .ok_or_else(|| "no pane_id in dispatch response".to_string())? .to_string(); let split = payload .get("split") .and_then(Value::as_str) .unwrap_or_default() .to_string(); Ok(DispatchHandle { pane_id, raw_split_response: split, }) } /// Read the current agent state of a pane. Returns raw pane.get JSON /// so the caller can inspect any field (agent, agent_status, cwd, /// process metadata). pub async fn status(hub: Arc, node_id: NodeId, pane_id: &str) -> Result { let out = hub .call_timeout(node_id, "herdr_status", json!({ "pane_id": pane_id }), 20) .await?; if !out.ok { return Err(format!("status failed: {}", truncate(&out.output, 300))); } serde_json::from_str(&out.output) .map_err(|e| format!("status not json: {e}: {}", truncate(&out.output, 200))) } /// Fetch the full session snapshot from a node's Herdr daemon /// (`herdr api snapshot`). Returns raw JSON so the frontend can render /// workspaces + tabs + panes + agent states without a schema hop. pub async fn snapshot(hub: Arc, node_id: NodeId) -> Result { let out = hub .call_timeout(node_id, "herdr_snapshot", json!({}), 15) .await?; if !out.ok { return Err(format!("snapshot failed: {}", truncate(&out.output, 300))); } serde_json::from_str(&out.output) .map_err(|e| format!("snapshot not json: {e}: {}", truncate(&out.output, 200))) } /// Pull the last `lines` of the pane's scrollback (unwrapped) — used /// to persist a completed run's transcript. pub async fn read_transcript( hub: Arc, node_id: NodeId, pane_id: &str, lines: u32, ) -> Result { let out = hub .call_timeout( node_id, "herdr_read", json!({ "pane_id": pane_id, "lines": lines }), 30, ) .await?; if !out.ok { return Err(format!("read failed: {}", truncate(&out.output, 300))); } Ok(out.output) } /// Poll status every `poll_secs` until agent_status ∈ terminal set, /// or `timeout_secs` elapses. Returns the final status JSON. /// /// Terminal set: 'done' | 'idle' after the pane has been seen at /// least once in a non-idle state (avoids returning immediately for /// a pane that hasn't yet started working). pub async fn wait_for_completion( hub: Arc, node_id: NodeId, pane_id: &str, poll_secs: u64, timeout_secs: u64, ) -> Result { let started = tokio::time::Instant::now(); let mut ever_working = false; loop { if started.elapsed() > Duration::from_secs(timeout_secs) { return Err(format!( "pane {pane_id} did not complete in {timeout_secs}s" )); } let s = status(hub.clone(), node_id, pane_id).await?; let state = s .pointer("/result/agent_status") .and_then(Value::as_str) .unwrap_or("unknown"); match state { "working" | "blocked" => ever_working = true, "done" => return Ok(s), "idle" if ever_working => return Ok(s), _ => {} } tokio::time::sleep(Duration::from_secs(poll_secs)).await; } } fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { format!("{}…", &s[..max]) } }