feat(fleet): GET /api/fleet/capacity returns the scheduler's own survey

Pulled forward from the observability phase because the capacity harness
scenario needs it. A test that recomputed the slot arithmetic in bash would
drift from `vm_placement` and then agree with itself while the scheduler did
something else — the same shape as every silent-success bug in this codebase.

Returns `survey()` + `rank()` unmodified, and keeps `unfit` as its own list:
"the fleet is full" and "we could not read the fleet" send an operator to
different places, so they must not be summed into one number.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 05:11:21 -07:00
co-authored by Claude Opus 5
parent d48bdbc9a7
commit dc0443de34
2 changed files with 54 additions and 0 deletions
+53
View File
@@ -402,3 +402,56 @@ async fn bridge_terminal(hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket)
}
hub.terminal_close(node_id, sid).await;
}
/// `GET /api/fleet/capacity` — what the SCHEDULER sees, verbatim.
///
/// Pulled forward from the observability phase because the capacity harness
/// scenario needs it: a test that recomputed the slot arithmetic in bash would
/// drift from `vm_placement` and then agree with itself while the scheduler did
/// something else. This returns `vm_placement::survey` unmodified, so the fleet
/// page, the harness and the placer cannot disagree.
///
/// `backend` narrows to the nodes that can boot one image (`?backend=claude`),
/// matching what `choose` does for a phase.
pub async fn capacity(
State(state): State<AppState>,
Authed(user): Authed,
Query(q): Query<CapacityQuery>,
) -> Result<Json<Value>, ApiError> {
let ws = user.workspace_id.as_uuid().to_owned();
let (fit, unfit) =
crate::vm_placement::survey(&state.pool, &state.node_hub, ws, q.backend.as_deref())
.await
.map_err(|e| {
eprintln!("fleet capacity survey failed: {e}");
ApiError::Internal
})?;
let ranked = crate::vm_placement::rank(fit);
Ok(Json(json!({
// Total free slots across the fleet. A burst larger than this MUST
// queue rather than overcommit — that is the whole feature.
"slots": ranked.iter().map(|n| n.slots).sum::<i64>(),
"nodes": ranked.iter().map(|n| json!({
"id": n.node_id,
"name": n.name,
"slots": n.slots,
"committedVms": n.committed_vms,
"headroom": n.headroom,
"memTotalMib": n.mem_total_mib,
"usedEffMib": n.used_eff_mib,
"diskFreeGib": n.disk_free_gib,
})).collect::<Vec<_>>(),
// Never folded into the above. "Full" and "unreadable" send an
// operator to different places, so they stay separate here too.
"unfit": unfit.iter().map(|(id, name, why)| json!({
"id": id,
"name": name,
"reason": why.reason(),
})).collect::<Vec<_>>(),
})))
}
#[derive(Deserialize)]
pub struct CapacityQuery {
pub backend: Option<String>,
}