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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user