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:
Omar Sobh
2026-07-20 09:49:38 -07:00
parent d6dbd044c8
commit 47f986257f
4 changed files with 381 additions and 0 deletions
+49
View File
@@ -361,6 +361,55 @@ pub async fn delete(
Ok(Json(serde_json::json!({ "deleted": true })))
}
#[derive(Debug, Deserialize)]
pub struct HerdrDispatchRequest {
pub cli: String,
pub prompt: String,
}
#[derive(Debug, Serialize)]
pub struct HerdrDispatchResponse {
pub pane_id: String,
pub node_id: Uuid,
}
/// POST /api/missions/{id}/herdr-dispatch — manually spawn a Herdr
/// pane on the mission's target_node running `cli` with `prompt`.
/// Requires mission.runtime_kind = 'local_herdr' + target_node_id set.
/// Wizard integration + auto-dispatch land in later phases; this
/// exists so Phase 1b's fleet_herdr module can be exercised end-to-end
/// against a real node while the rest of the arc builds out.
pub async fn herdr_dispatch(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<HerdrDispatchRequest>,
) -> Result<Json<HerdrDispatchResponse>, ApiError> {
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
if mission.runtime_kind != "local_herdr" {
return Err(ApiError::BadRequest);
}
let node_id = mission.target_node_id.ok_or(ApiError::BadRequest)?;
let handle = crate::fleet_herdr::dispatch(
state.node_hub.clone(),
cm_domain::NodeId::from(node_id),
id,
body.cli.trim(),
body.prompt.trim(),
)
.await
.map_err(|e| {
eprintln!("herdr_dispatch mission {id}: {e}");
ApiError::Internal
})?;
Ok(Json(HerdrDispatchResponse {
pane_id: handle.pane_id,
node_id,
}))
}
pub async fn set_status(
State(state): State<AppState>,
Authed(user): Authed,