//! Scheduled-prompt list + rebuild-queue cancel endpoints for the dashboard. //! //! `/api/schedules` is read-only: it lists the rows still in the hive's //! `scheduled_prompts` table so the operator can recreate them with //! `swarmctl schedule` (`docs/swarm/schedules.md`). Nothing on the hive //! writes or fires those rows. Also the co-located //! `/api/rebuild-queue/{id}/cancel` endpoint. use axum::{ extract::{Path as AxumPath, State}, response::{IntoResponse, Response}, }; use super::{AppState, error_response}; #[derive(serde::Serialize, utoipa::ToSchema)] pub(super) struct CancelResultBody { cancelled: bool, } /// Snapshot of every schedule row for the /// scheduled-prompts tab. /// /// Returns the wire shape directly so the frontend can render /// without an extra translation layer. // `hive_sh4re::schedule::WireSchedule` (the actual body) has no `ToSchema` — adding // one would pull `utoipa` into the wire-types crate for a single dashboard // endpoint. `serde_json::Value` placeholder. #[utoipa::path( get, path = "/api/schedules", responses( (status = 200, description = "every schedule, wire shape", body = Vec), (status = 500, description = "sqlite read failed"), ), tag = "schedules" )] pub(super) async fn api_schedules(State(state): State) -> Response { match state.coord.scheduled_prompts.list() { Ok(rows) => { let mut wire: Vec = rows.into_iter().map(schedule_to_wire).collect(); let live: std::collections::HashSet = state .coord .containers_snapshot() .await .into_iter() .map(|c| c.name) .collect(); filter_ghost_schedule_targets(&mut wire, &live); axum::Json(wire).into_response() } Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")), } } /// Drop schedule targets that point at agents which no longer exist, so /// the schedule table doesn't render ghost columns for destroyed agents. /// `live` is the set of logical agent names from the last /// `nixos-container list` scan (stopped agents included, destroyed ones /// absent). A view filter: the rows keep every target. fn filter_ghost_schedule_targets( schedules: &mut [hive_sh4re::schedule::WireSchedule], live: &std::collections::HashSet, ) { for s in schedules.iter_mut() { s.targets.retain(|t| live.contains(&t.target)); } } fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::schedule::WireSchedule { hive_sh4re::schedule::WireSchedule { id: s.id, owner: s.owner, body: s.body, interval_seconds: s.interval_seconds, next_fire_at_unix: s.next_fire_at_unix, created_at_unix: s.created_at_unix, source: match s.source { crate::scheduled_prompts::ScheduleSource::Operator => { hive_sh4re::schedule::WireScheduleSource::Operator } crate::scheduled_prompts::ScheduleSource::Approval { id } => { hive_sh4re::schedule::WireScheduleSource::Approval { id } } }, cancelled_at_unix: s.cancelled_at_unix, paused_at_unix: s.paused_at_unix, description: s.description, targets: s .targets .into_iter() .map(|t| hive_sh4re::schedule::WireScheduleTarget { target: t.target, cancelled_at_unix: t.cancelled_at_unix, last_fired_at_unix: t.last_fired_at_unix, last_result: t.last_result, }) .collect(), } } /// Drop still-queued work from the job /// queue. /// /// `id` is a **node** id. A DAG's root cancels the whole group (the scheduler /// cascades to pending descendants), which is what the dashboard's cancel /// button sends today — a DAG id *is* its root node's id. An interior node /// cancels just that branch. /// /// Refuses `Running` / terminal nodes: an /// in-flight node owns the agent's nix store + nixos-container update /// lock and can't be safely interrupted from the queue side. Always /// returns 200; the body is `{"cancelled": true}` on a successful /// flip to Cancelled, `{"cancelled": false}` when the DAG was /// Running / terminal / gone. On success a fresh `RebuildQueueChanged` /// snapshot fires so the state flip surfaces live. #[utoipa::path( post, path = "/api/rebuild-queue/{id}/cancel", params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")), responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)), tag = "schedules" )] pub(super) async fn post_rebuild_queue_cancel( State(state): State, AxumPath(id): AxumPath, ) -> Response { if state.coord.job_queue.cancel(id) { // Any terminal side effect is the DAG's own spared tail node, which the // scheduler picks up on its next pass — nothing to fire from here. state.coord.emit_rebuild_queue_snapshot(); axum::Json(CancelResultBody { cancelled: true }).into_response() } else { axum::Json(CancelResultBody { cancelled: false }).into_response() } } #[cfg(test)] mod tests { use super::*; fn target(name: &str) -> hive_sh4re::schedule::WireScheduleTarget { hive_sh4re::schedule::WireScheduleTarget { target: name.to_owned(), cancelled_at_unix: None, last_fired_at_unix: None, last_result: None, } } fn schedule(targets: &[&str]) -> hive_sh4re::schedule::WireSchedule { hive_sh4re::schedule::WireSchedule { id: 1, owner: "operator".to_owned(), body: "ping".to_owned(), interval_seconds: None, next_fire_at_unix: hive_sh4re::wire_time::from_secs(0), created_at_unix: hive_sh4re::wire_time::from_secs(0), source: hive_sh4re::schedule::WireScheduleSource::Operator, cancelled_at_unix: None, paused_at_unix: None, description: None, targets: targets.iter().map(|t| target(t)).collect(), } } #[test] fn ghost_filter_drops_dead_agents_keeps_live() { let live: std::collections::HashSet = ["iris".to_owned(), "damocles".to_owned()] .into_iter() .collect(); let mut schedules = vec![schedule(&["iris", "ghost", "damocles"])]; filter_ghost_schedule_targets(&mut schedules, &live); let kept: Vec<&str> = schedules[0] .targets .iter() .map(|t| t.target.as_str()) .collect(); assert_eq!(kept, vec!["iris", "damocles"]); } #[test] fn ghost_filter_can_empty_targets_when_all_dead() { let live: std::collections::HashSet = std::collections::HashSet::new(); let mut schedules = vec![schedule(&["gone1", "gone2"])]; filter_ghost_schedule_targets(&mut schedules, &live); assert!(schedules[0].targets.is_empty()); } }