hive: remove the read-only schedules page and the old schedule store
Scheduled prompts live at swarm level (swarm-controller's /api/schedules, `swarmctl schedule`, the swarm UI's /schedules page). The hive dashboard's SCH3DUL3S tab only listed the rows left in hive-c0re's `scheduled_prompts` table so operators could copy them over; the operator confirmed everything is migrated. Removed: - the SCH3DUL3S tab, its pane, schedules.js, its CSS (including the `.rqe-source*` rules, whose only emitter was schedules.js) and the hub tile's "schedules" entry - hive-c0re's GET /api/schedules and the `scheduled_prompts` store (with its v1 column migration and test) - hive-sh4re's `schedule` wire types, which only that route used - docs describing the tab, the route and the two tables The rebuild-queue cancel route that shared the module moves to dashboard/rebuild_queue.rs under an OpenAPI tag of its own. The two sqlite tables are not dropped: nothing opens them any more, and persistence.md says they can be dropped by hand. Closes #4916
This commit is contained in:
parent
2f05e2db28
commit
78412b3d3b
22 changed files with 84 additions and 979 deletions
|
|
@ -38,7 +38,7 @@ use crate::lifecycle;
|
|||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
|
||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
||||
(name = "schedules", description = "read-only scheduled-prompt list + rebuild-queue cancel"),
|
||||
(name = "rebuild_queue", description = "rebuild-queue cancel"),
|
||||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
(name = "tombstones", description = "purge of retained state for destroyed agents"),
|
||||
|
|
@ -57,7 +57,7 @@ mod lifecycle_ops;
|
|||
mod meta_inputs;
|
||||
mod misc_api;
|
||||
pub(crate) mod permissions;
|
||||
mod schedules;
|
||||
mod rebuild_queue;
|
||||
mod state_files;
|
||||
mod state_snapshot;
|
||||
mod tombstones;
|
||||
|
|
@ -132,8 +132,7 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
|
|||
.routes(routes!(permissions::post_permissions))
|
||||
.routes(routes!(permissions::get_stale_permissions))
|
||||
.routes(routes!(permissions::delete_agent_permissions))
|
||||
.routes(routes!(schedules::api_schedules))
|
||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
||||
.routes(routes!(lifecycle_ops::post_destroy))
|
||||
.routes(routes!(lifecycle_ops::post_kill))
|
||||
.routes(routes!(lifecycle_ops::post_restart))
|
||||
|
|
@ -389,8 +388,7 @@ mod router_build_probe {
|
|||
.routes(routes!(permissions::post_permissions))
|
||||
.routes(routes!(permissions::get_stale_permissions))
|
||||
.routes(routes!(permissions::delete_agent_permissions))
|
||||
.routes(routes!(schedules::api_schedules))
|
||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
||||
.routes(routes!(lifecycle_ops::post_destroy))
|
||||
.routes(routes!(lifecycle_ops::post_kill))
|
||||
.routes(routes!(lifecycle_ops::post_restart))
|
||||
|
|
|
|||
49
hive-c0re/src/dashboard/rebuild_queue.rs
Normal file
49
hive-c0re/src/dashboard/rebuild_queue.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
//! Rebuild-queue cancel endpoint for the dashboard.
|
||||
|
||||
use axum::{
|
||||
extract::{Path as AxumPath, State},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct CancelResultBody {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// 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 = "rebuild_queue"
|
||||
)]
|
||||
pub(super) async fn post_rebuild_queue_cancel(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<u64>,
|
||||
) -> 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
//! 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<serde_json::Value>),
|
||||
(status = 500, description = "sqlite read failed"),
|
||||
),
|
||||
tag = "schedules"
|
||||
)]
|
||||
pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
|
||||
match state.coord.scheduled_prompts.list() {
|
||||
Ok(rows) => {
|
||||
let mut wire: Vec<hive_sh4re::schedule::WireSchedule> =
|
||||
rows.into_iter().map(schedule_to_wire).collect();
|
||||
let live: std::collections::HashSet<String> = 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<String>,
|
||||
) {
|
||||
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<AppState>,
|
||||
AxumPath(id): AxumPath<u64>,
|
||||
) -> 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<String> = ["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<String> = std::collections::HashSet::new();
|
||||
let mut schedules = vec![schedule(&["gone1", "gone2"])];
|
||||
filter_ghost_schedule_targets(&mut schedules, &live);
|
||||
assert!(schedules[0].targets.is_empty());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue