hive-c0re stops owning scheduled prompts; agents lose the schedule tools
Scheduled prompts are swarm-level now (swarmctl schedule, #4936). The hive keeps the rows it has only so the operator can copy them over. - hive-c0re no longer fires anything: the scheduled-prompts worker is gone, and with it every create/edit/cancel/pause/resume/fire path (dashboard endpoints, agent-socket verbs, the SchedulePrompt approval kind and its approve handler). The store keeps open + list; GET /api/schedules stays. - A v6 approvals migration drops schedule_prompt rows. - Nothing submits approvals any more, so the submit path goes too: Approvals::submit_kind (now a test fixture), the ApprovalAdded dashboard event and its emitter. - Coordinator::push_todo, whose only caller was the worker, goes, and with it hive_host_sock::agent_todo_socket and hive-c0re's hive-agent-sock / hive-sock-client deps. - The schedules_changed dashboard event goes; nothing mutates the rows. - Agents lose request_schedule_prompt, cancel_schedule, fire_schedule_now, edit_schedule and list_schedules, the `scheduling` tool group, their system-prompt line and their transcript formatters. `remind` is unchanged.
This commit is contained in:
parent
7d80f5146d
commit
c3a54bbdd0
31 changed files with 273 additions and 3124 deletions
|
|
@ -31,7 +31,7 @@ pub(super) async fn post_approve(
|
|||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
) -> Response {
|
||||
match actions::approve(state.coord.clone(), id).await {
|
||||
match actions::approve(&state.coord, id) {
|
||||
// 200 instead of 303 — `actions::approve` fires
|
||||
// `ApprovalResolved` (success path) or the eventual failure
|
||||
// event, both of which the dashboard's derived store applies
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ use crate::lifecycle;
|
|||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
|
||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
||||
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
|
||||
(name = "schedules", description = "read-only scheduled-prompt list + 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"),
|
||||
|
|
@ -112,8 +112,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
// GET-vs-GET or POST-vs-POST pairs across different paths do. Each
|
||||
// call below is therefore scoped to exactly one path — two handlers
|
||||
// in the same call only when they genuinely share a path with
|
||||
// different methods (`schedules::api_schedules`/`post_schedule_new`
|
||||
// on `/api/schedules`) — chained via
|
||||
// different methods — chained via
|
||||
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
|
||||
// with everything in it.
|
||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
|
|
@ -141,15 +140,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
.routes(routes!(permissions::post_permissions))
|
||||
.routes(routes!(permissions::get_stale_permissions))
|
||||
.routes(routes!(permissions::delete_agent_permissions))
|
||||
.routes(routes!(
|
||||
schedules::api_schedules,
|
||||
schedules::post_schedule_new
|
||||
))
|
||||
.routes(routes!(schedules::patch_schedule))
|
||||
.routes(routes!(schedules::post_schedule_cancel))
|
||||
.routes(routes!(schedules::post_schedule_pause))
|
||||
.routes(routes!(schedules::post_schedule_resume))
|
||||
.routes(routes!(schedules::post_schedule_fire_now))
|
||||
.routes(routes!(schedules::api_schedules))
|
||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||
.routes(routes!(approvals::post_approve))
|
||||
.routes(routes!(approvals::post_deny))
|
||||
|
|
@ -367,15 +358,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,
|
||||
schedules::post_schedule_new
|
||||
))
|
||||
.routes(routes!(schedules::patch_schedule))
|
||||
.routes(routes!(schedules::post_schedule_cancel))
|
||||
.routes(routes!(schedules::post_schedule_pause))
|
||||
.routes(routes!(schedules::post_schedule_resume))
|
||||
.routes(routes!(schedules::post_schedule_fire_now))
|
||||
.routes(routes!(schedules::api_schedules))
|
||||
.routes(routes!(schedules::post_rebuild_queue_cancel))
|
||||
.routes(routes!(approvals::post_approve))
|
||||
.routes(routes!(approvals::post_deny))
|
||||
|
|
|
|||
|
|
@ -1,41 +1,31 @@
|
|||
//! Scheduled-prompt + rebuild-queue endpoints for the dashboard.
|
||||
//! Scheduled-prompt list + rebuild-queue cancel endpoints for the dashboard.
|
||||
//!
|
||||
//! Operator-direct schedule CRUD (`/api/schedules` GET/POST, `{id}` PATCH,
|
||||
//! `{id}/cancel` + `{id}/fire-now`) — the operator click *is* the approval,
|
||||
//! so these skip the manager approval gate. Also the co-located
|
||||
//! `/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},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
|
||||
use crate::scheduled_prompts::ScheduleNotFoundOrCancelled;
|
||||
use crate::scheduled_prompts_worker::FireNowReport;
|
||||
|
||||
use super::{AppState, error_problem, error_response};
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct NewScheduleBody {
|
||||
id: i64,
|
||||
}
|
||||
use super::{AppState, error_response};
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct CancelResultBody {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// Snapshot of every schedule for the
|
||||
/// 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; see the batch report.
|
||||
// endpoint. `serde_json::Value` placeholder.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/schedules",
|
||||
|
|
@ -48,13 +38,8 @@ pub(super) struct CancelResultBody {
|
|||
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(crate::socket_server::schedule_to_wire_public)
|
||||
.collect();
|
||||
// Drop ghost targets (agents that no longer exist) so the
|
||||
// table never shows dead columns. Uses the reliable async
|
||||
// roster snapshot here on the request path.
|
||||
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()
|
||||
|
|
@ -62,117 +47,56 @@ pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
|
|||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect();
|
||||
crate::socket_server::filter_ghost_schedule_targets(&mut wire, &live);
|
||||
filter_ghost_schedule_targets(&mut wire, &live);
|
||||
axum::Json(wire).into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator-direct schedule creation
|
||||
/// (mara: "user can add them manually").
|
||||
///
|
||||
/// Accepts the same `SchedulePromptPayload` shape as the manager
|
||||
/// request flow but skips the approval gate — the operator click
|
||||
/// *is* the approval. The schedule lands directly with
|
||||
/// `source = Operator` and the worker picks it up at fire time.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/schedules",
|
||||
// `hive_sh4re::manager::SchedulePromptPayload` (the actual body) has no `ToSchema` —
|
||||
// same reasoning as the `Vec<serde_json::Value>` placeholder on
|
||||
// `api_schedules` above.
|
||||
request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"),
|
||||
responses(
|
||||
(status = 200, description = "created; body carries the new row id", body = NewScheduleBody),
|
||||
(status = 400, description = "no targets, empty body, or interval_seconds == 0"),
|
||||
(status = 500, description = "submit failed"),
|
||||
),
|
||||
tag = "schedules"
|
||||
)]
|
||||
pub(super) async fn post_schedule_new(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(payload): axum::Json<hive_sh4re::manager::SchedulePromptPayload>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
if payload.targets.is_empty() {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail("schedule must have at least one target"));
|
||||
}
|
||||
if payload
|
||||
.targets
|
||||
.iter()
|
||||
.any(|t| t == hive_sh4re::manager::OPERATOR_RECIPIENT)
|
||||
{
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail("operator is not a valid schedule target"));
|
||||
}
|
||||
if payload.body.trim().is_empty() {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail("schedule body must be non-empty"));
|
||||
}
|
||||
if let Some(0) = payload.interval_seconds {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail("interval_seconds must be > 0 (use None for one-shot)"));
|
||||
}
|
||||
let new = crate::scheduled_prompts::NewSchedule {
|
||||
owner: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
|
||||
targets: payload.targets,
|
||||
body: payload.body,
|
||||
first_fire_at_unix: payload.first_fire_at_unix,
|
||||
interval_seconds: payload.interval_seconds,
|
||||
description: payload.description,
|
||||
source: crate::scheduled_prompts::ScheduleSource::Operator,
|
||||
};
|
||||
match state.coord.scheduled_prompts.submit(&new) {
|
||||
Ok(id) => {
|
||||
state.coord.emit_schedules_snapshot();
|
||||
Ok(axum::Json(NewScheduleBody { id }).into_response())
|
||||
}
|
||||
Err(e) => Err(error_problem(&format!("schedule submit: {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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional JSON body for `fire-now`. Absent / empty body ⇒
|
||||
/// `reset_timer = false` (back-compat: cadence stays intact).
|
||||
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
|
||||
pub(super) struct FireNowBody {
|
||||
#[serde(default)]
|
||||
reset_timer: bool,
|
||||
}
|
||||
|
||||
/// Operator-initiated
|
||||
/// out-of-band fire of a scheduled prompt.
|
||||
///
|
||||
/// Runs the per-target fan-out once immediately and reports
|
||||
/// per-target outcome counts. One-shot schedules are consumed
|
||||
/// (cancelled) by a manual fire — the operator's intent is "send
|
||||
/// this now, the scheduled time was wrong." For recurring schedules
|
||||
/// the cadence stays intact unless the body carries
|
||||
/// `{"reset_timer": true}`, in which case the countdown is re-armed
|
||||
/// from now (`next_fire_at = now + interval`).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/schedules/{id}/fire-now",
|
||||
params(("id" = i64, Path, description = "schedule row id")),
|
||||
request_body(content = FireNowBody, description = "optional; absent body means reset_timer = false"),
|
||||
responses(
|
||||
(status = 200, description = "fired; per-target outcome counts", body = FireNowReport),
|
||||
(status = 500, description = "schedule missing, cancelled, or fully drained"),
|
||||
),
|
||||
tag = "schedules"
|
||||
)]
|
||||
pub(super) async fn post_schedule_fire_now(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
body: Option<axum::Json<FireNowBody>>,
|
||||
) -> Response {
|
||||
let reset_timer = body.is_some_and(|axum::Json(b)| b.reset_timer);
|
||||
match crate::scheduled_prompts_worker::fire_now(&state.coord, id, reset_timer).await {
|
||||
Ok(report) => {
|
||||
state.coord.emit_schedules_snapshot();
|
||||
axum::Json(report).into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("fire schedule {id} now: {e:#}")),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,213 +136,55 @@ pub(super) async fn post_rebuild_queue_cancel(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
|
||||
pub(super) struct CancelScheduleForm {
|
||||
/// `None` / absent / empty array → cancel whole schedule.
|
||||
#[serde(default)]
|
||||
targets: Option<Vec<String>>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(serde::Deserialize, Default, utoipa::ToSchema)]
|
||||
#[allow(
|
||||
clippy::option_option,
|
||||
reason = "double-Option carries three-state PATCH semantics on the wire \
|
||||
(missing key = leave alone, JSON null = clear, value = set); \
|
||||
collapsing to a single Option would lose the 'clear' state"
|
||||
)]
|
||||
pub(super) struct EditScheduleForm {
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
/// Double-`Option` semantics on the wire: missing key = leave
|
||||
/// alone, explicit `null` = clear, value = set. serde's
|
||||
/// `deserialize_with` trick to distinguish missing from null:
|
||||
/// we wrap each editable field in its own helper. Simpler
|
||||
/// here — keep them plain `Option<Option<_>>` and document
|
||||
/// that the dashboard caller passes JSON `null` to clear.
|
||||
#[serde(default, deserialize_with = "deserialize_some")]
|
||||
description: Option<Option<String>>,
|
||||
#[serde(default, deserialize_with = "deserialize_some")]
|
||||
interval_seconds: Option<Option<u64>>,
|
||||
#[serde(default)]
|
||||
next_fire_at_unix: Option<i64>,
|
||||
/// New targets to add. Replace-on-conflict: re-adding a
|
||||
/// previously-cancelled target drops the tombstone and the
|
||||
/// target starts fresh (operator intent on re-add = "this
|
||||
/// target is active again, fresh start").
|
||||
#[serde(default)]
|
||||
targets_add: Option<Vec<String>>,
|
||||
/// Targets to cancel. Same path as `cancel_targets`:
|
||||
/// tombstones preserve audit and the parent schedule
|
||||
/// auto-cancels when no active targets remain.
|
||||
#[serde(default)]
|
||||
targets_remove: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// serde adaptor: turns missing-key into `None`, explicit-null
|
||||
/// into `Some(None)`, value into `Some(Some(v))`. Standard trick
|
||||
/// for distinguishing "field absent" from "field set to null" in
|
||||
/// JSON PATCH bodies.
|
||||
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
T: serde::Deserialize<'de>,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
T::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
/// Partial update of an existing
|
||||
/// schedule.
|
||||
///
|
||||
/// Mutable fields: `body`, `description`, `interval_seconds`,
|
||||
/// `next_fire_at_unix`, plus the target set via `targets_add` /
|
||||
/// `targets_remove`. Both target lists are applied in the same
|
||||
/// transaction as the scalar fields with removes-before-adds;
|
||||
/// re-adding a previously-removed target resets per-target history
|
||||
/// (fresh start); draining all targets auto-cancels the parent
|
||||
/// schedule. JSON body uses missing-key = "leave alone", explicit
|
||||
/// null = "clear" for `description` + `interval_seconds`. Cancelled
|
||||
/// schedules are refused — submit a new one instead. Returns the
|
||||
/// updated `WireSchedule` so the caller's post-edit refresh has the
|
||||
/// new state inline.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/schedules/{id}",
|
||||
params(("id" = i64, Path, description = "schedule row id")),
|
||||
responses(
|
||||
(status = 200, description = "updated, wire shape", body = serde_json::Value),
|
||||
(status = 500, description = "update failed (cancelled, not found, ...)"),
|
||||
),
|
||||
tag = "schedules"
|
||||
)]
|
||||
pub(super) async fn patch_schedule(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
axum::Json(form): axum::Json<EditScheduleForm>,
|
||||
) -> Response {
|
||||
let patch = crate::scheduled_prompts::UpdateSchedule {
|
||||
body: form.body,
|
||||
description: form.description,
|
||||
interval_seconds: form.interval_seconds,
|
||||
next_fire_at_unix: form.next_fire_at_unix,
|
||||
targets_add: form.targets_add,
|
||||
targets_remove: form.targets_remove,
|
||||
};
|
||||
if let Err(e) = state.coord.scheduled_prompts.update(id, patch) {
|
||||
return error_response(&format!("edit schedule {id}: {e:#}"));
|
||||
}
|
||||
match state.coord.scheduled_prompts.get(id) {
|
||||
Ok(Some(s)) => {
|
||||
let wire = crate::socket_server::schedule_to_wire_public(s);
|
||||
state.coord.emit_schedules_snapshot();
|
||||
axum::Json(wire).into_response()
|
||||
}
|
||||
Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")),
|
||||
Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause a schedule so the worker
|
||||
/// skips it until explicitly resumed.
|
||||
///
|
||||
/// Idempotent; no-op on an already-paused row. Returns 404 when the
|
||||
/// schedule is cancelled or not found.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/schedules/{id}/pause",
|
||||
params(("id" = i64, Path, description = "schedule row id")),
|
||||
responses(
|
||||
(status = 200, description = "paused", body = String),
|
||||
(status = 404, description = "schedule cancelled or not found"),
|
||||
(status = 500, description = "pause failed"),
|
||||
),
|
||||
tag = "schedules"
|
||||
)]
|
||||
pub(super) async fn post_schedule_pause(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
) -> Response {
|
||||
match state.coord.scheduled_prompts.pause(id) {
|
||||
Ok(()) => {
|
||||
state.coord.emit_schedules_snapshot();
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
if e.downcast_ref::<ScheduleNotFoundOrCancelled>().is_some() {
|
||||
(StatusCode::NOT_FOUND, format!("{e}")).into_response()
|
||||
} else {
|
||||
error_response(&format!("pause schedule {id}: {e:#}"))
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume a paused schedule.
|
||||
///
|
||||
/// Idempotent; no-op on an already-active row. Returns 404 when the
|
||||
/// schedule is cancelled or not found.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/schedules/{id}/resume",
|
||||
params(("id" = i64, Path, description = "schedule row id")),
|
||||
responses(
|
||||
(status = 200, description = "resumed", body = String),
|
||||
(status = 404, description = "schedule cancelled or not found"),
|
||||
(status = 500, description = "resume failed"),
|
||||
),
|
||||
tag = "schedules"
|
||||
)]
|
||||
pub(super) async fn post_schedule_resume(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
) -> Response {
|
||||
match state.coord.scheduled_prompts.resume(id) {
|
||||
Ok(()) => {
|
||||
state.coord.emit_schedules_snapshot();
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
if e.downcast_ref::<ScheduleNotFoundOrCancelled>().is_some() {
|
||||
(StatusCode::NOT_FOUND, format!("{e}")).into_response()
|
||||
} else {
|
||||
error_response(&format!("resume schedule {id}: {e:#}"))
|
||||
}
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator-side cancel
|
||||
/// (whole schedule when no `targets` field, partial when one is
|
||||
/// provided).
|
||||
///
|
||||
/// Operator bypasses the topology check; the manager surface
|
||||
/// enforces it for agent callers.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/schedules/{id}/cancel",
|
||||
params(("id" = i64, Path, description = "schedule row id")),
|
||||
responses(
|
||||
(status = 200, description = "cancelled (whole or partial)", body = String),
|
||||
(status = 500, description = "cancel failed"),
|
||||
),
|
||||
tag = "schedules"
|
||||
)]
|
||||
pub(super) async fn post_schedule_cancel(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
body: Option<axum::Json<CancelScheduleForm>>,
|
||||
) -> Response {
|
||||
let targets = body
|
||||
.and_then(|axum::Json(b)| b.targets)
|
||||
.filter(|t| !t.is_empty());
|
||||
let result = match targets.as_deref() {
|
||||
Some(list) => state.coord.scheduled_prompts.cancel_targets(id, list),
|
||||
None => state.coord.scheduled_prompts.cancel_all(id),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
state.coord.emit_schedules_snapshot();
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")),
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,8 +152,7 @@ struct ApprovalView {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
/// Raw `commit_ref` payload for `UpdateMetaInputs` (JSON-encoded
|
||||
/// `Vec<String>` of input names; `"[]"` = all inputs) and
|
||||
/// `SchedulePrompt` (JSON-encoded `SchedulePromptPayload`). The
|
||||
/// `Vec<String>` of input names; `"[]"` = all inputs). The
|
||||
/// frontend parses this to render a human-readable card body.
|
||||
commit_ref: String,
|
||||
/// RFC 3339 UTC time the approval was queued. Rendered as a
|
||||
|
|
|
|||
Loading…
Reference in a new issue