herdr phase 1b: fleet_herdr dispatch module + node daemon ops
The second-runtime path uses the existing NodeHub control channel —
NOT SSH. Node daemons already accept typed ops over their outbound
websocket; adding three herdr_* ops keeps everything on the auth
model that already works fleet-wide (control-channel token, no new
SSH key management, no server-container-mounted keys).
Node daemon (clawmates-node):
- New herdr_op handler in main.rs dispatching:
* herdr_dispatch — workspace create + pane split + rename + run
* herdr_status — pane get JSON (agent, agent_status, cwd)
* herdr_read — recent-unwrapped scrollback, N lines
- Herdr binary resolved from ~/.local/bin, brew, /usr/local/bin.
Missing binary returns clean error so cm-api can distinguish
"node not set up for Herdr yet" from "Herdr op failed".
cm-api:
- crates/cm-api/src/fleet_herdr.rs — dispatch / status /
read_transcript / wait_for_completion helpers on top of
hub.call_timeout(). wait_for_completion polls until agent_status
hits 'done' or an idle-after-working state, matching the SKILL
file's "either idle or done is completed" semantic.
- routes::missions::herdr_dispatch — POST /api/missions/{id}/
herdr-dispatch { cli, prompt }. Requires runtime_kind = 'local_herdr'
and target_node_id set. Manual trigger so Phase 1b is exercisable
end-to-end before Phase 1c wires the wizard + orchestrator.
Not yet wired: mission_orchestrator::on_launch still ignores
runtime_kind. Phase 1c adds the wizard picker AND the on_launch
branch that auto-dispatches on draft→running for local_herdr
missions. This commit only adds the primitives.
Verified: SQLX_OFFLINE=true cargo check --workspace green.
Phase 0 (Herdr install on fleet nodes) is the blocker to actually
exercising this end-to-end.
This commit is contained in:
@@ -503,6 +503,20 @@ async fn handle_frame(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Herdr dispatch ops. Server sends `herdr_dispatch` to open a
|
||||
// sibling pane on the node's Herdr session and start the requested
|
||||
// CLI (claude / codex / kimi / etc.) with a prompt. `herdr_status`
|
||||
// polls that pane's agent_status; `herdr_read` scrapes its recent
|
||||
// transcript. Node just shells out to the `herdr` binary — the
|
||||
// Herdr background daemon is expected to already be running.
|
||||
op @ ("herdr_dispatch" | "herdr_status" | "herdr_read") => {
|
||||
if let Some(id) = v.get("id").and_then(Value::as_u64) {
|
||||
let (ok, output) = herdr_op(op, &v).await;
|
||||
let _ = out.send(
|
||||
json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Agent-sandbox container ops: drive the REAL DockerDriver so the
|
||||
// hardening (cap-drop ALL, seccomp, no-net, read-only, non-root) is
|
||||
// byte-identical to the gateway's local sandboxes.
|
||||
@@ -839,6 +853,154 @@ fn handle_of(v: &Value) -> SandboxHandle {
|
||||
|
||||
/// Run an agent-sandbox container op via the local DockerDriver (full hardening),
|
||||
/// returning the result payload as JSON or an error message.
|
||||
/// Herdr control ops. Requires `herdr` in PATH and a running background
|
||||
/// session (Phase 0 install). Returns raw JSON strings from the herdr
|
||||
/// CLI so the server can parse pane_id / agent_status without a
|
||||
/// second RPC hop.
|
||||
///
|
||||
/// Ops:
|
||||
/// herdr_dispatch { mission_id, cli, prompt, direction? } →
|
||||
/// runs `herdr pane split ... && herdr pane run ... "prompt"`
|
||||
/// output = the split's JSON response so the server can extract
|
||||
/// result.pane.pane_id
|
||||
/// herdr_status { pane_id } → `herdr pane get <pane_id>` JSON
|
||||
/// herdr_read { pane_id, lines? } → recent-unwrapped scrollback
|
||||
async fn herdr_op(op: &str, v: &Value) -> (bool, String) {
|
||||
let herdr = match std::env::var("HOME").ok().and_then(|h| {
|
||||
[
|
||||
format!("{h}/.local/bin/herdr"),
|
||||
"/opt/homebrew/bin/herdr".to_string(),
|
||||
"/usr/local/bin/herdr".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
}) {
|
||||
Some(p) => p,
|
||||
None => return (false, "herdr binary not found on PATH".into()),
|
||||
};
|
||||
let herdr = std::sync::Arc::new(herdr);
|
||||
let run = move |args: Vec<String>| {
|
||||
let herdr = herdr.clone();
|
||||
async move {
|
||||
let fut = tokio::process::Command::new(herdr.as_str())
|
||||
.args(&args)
|
||||
.output();
|
||||
match tokio::time::timeout(Duration::from_secs(30), fut).await {
|
||||
Ok(Ok(o)) => {
|
||||
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
if !o.status.success() {
|
||||
s.push_str(&String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
(o.status.success(), s)
|
||||
}
|
||||
Ok(Err(e)) => (false, format!("spawn error: {e}")),
|
||||
Err(_) => (false, "herdr op timed out".into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
match op {
|
||||
"herdr_dispatch" => {
|
||||
let mission_id = v
|
||||
.get("mission_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let cli = v.get("cli").and_then(Value::as_str).unwrap_or("claude");
|
||||
let prompt = v.get("prompt").and_then(Value::as_str).unwrap_or("");
|
||||
let direction = v
|
||||
.get("direction")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("right");
|
||||
// 1. Ensure a mission workspace exists (idempotent — label collision falls through).
|
||||
let _ = run(vec![
|
||||
"workspace".into(),
|
||||
"create".into(),
|
||||
"--label".into(),
|
||||
format!("mission-{mission_id}"),
|
||||
])
|
||||
.await;
|
||||
// 2. Split off a fresh pane in that workspace and read its pane_id.
|
||||
let (ok, split_out) = run(vec![
|
||||
"pane".into(),
|
||||
"split".into(),
|
||||
"--direction".into(),
|
||||
direction.into(),
|
||||
"--no-focus".into(),
|
||||
])
|
||||
.await;
|
||||
if !ok {
|
||||
return (false, format!("split failed: {split_out}"));
|
||||
}
|
||||
let pane_id = match serde_json::from_str::<Value>(&split_out) {
|
||||
Ok(j) => j
|
||||
.pointer("/result/pane/pane_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
if pane_id.is_empty() {
|
||||
return (false, format!("no pane_id in split response: {split_out}"));
|
||||
}
|
||||
// 3. Rename for operator readability.
|
||||
let _ = run(vec![
|
||||
"pane".into(),
|
||||
"rename".into(),
|
||||
pane_id.clone(),
|
||||
format!("mission-{mission_id}"),
|
||||
])
|
||||
.await;
|
||||
// 4. Launch the CLI with the prompt inline.
|
||||
let launch = if prompt.is_empty() {
|
||||
cli.to_string()
|
||||
} else {
|
||||
// Single-quoted so shell metacharacters in the prompt don't
|
||||
// reinterpret. Herdr's pane.run sends this verbatim to the shell.
|
||||
let escaped = prompt.replace('\'', "'\\''");
|
||||
format!("{cli} '{escaped}'")
|
||||
};
|
||||
let (rok, rout) = run(vec![
|
||||
"pane".into(),
|
||||
"run".into(),
|
||||
pane_id.clone(),
|
||||
launch,
|
||||
])
|
||||
.await;
|
||||
let payload = serde_json::json!({
|
||||
"pane_id": pane_id,
|
||||
"split": split_out,
|
||||
"run_output": rout,
|
||||
"run_ok": rok,
|
||||
});
|
||||
(rok, payload.to_string())
|
||||
}
|
||||
"herdr_status" => {
|
||||
let pane = v.get("pane_id").and_then(Value::as_str).unwrap_or_default();
|
||||
if pane.is_empty() {
|
||||
return (false, "pane_id required".into());
|
||||
}
|
||||
run(vec!["pane".into(), "get".into(), pane.to_string()]).await
|
||||
}
|
||||
"herdr_read" => {
|
||||
let pane = v.get("pane_id").and_then(Value::as_str).unwrap_or_default();
|
||||
let lines = v.get("lines").and_then(Value::as_u64).unwrap_or(200);
|
||||
if pane.is_empty() {
|
||||
return (false, "pane_id required".into());
|
||||
}
|
||||
run(vec![
|
||||
"pane".into(),
|
||||
"read".into(),
|
||||
pane.to_string(),
|
||||
"--source".into(),
|
||||
"recent-unwrapped".into(),
|
||||
"--lines".into(),
|
||||
lines.to_string(),
|
||||
])
|
||||
.await
|
||||
}
|
||||
_ => (false, format!("unknown herdr op {op}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sb_op(op: &str, v: &Value) -> (bool, String) {
|
||||
let driver = match DockerDriver::connect() {
|
||||
Ok(d) => d,
|
||||
|
||||
Reference in New Issue
Block a user