//! Scheduled-prompt request handlers (`ListSchedules` / //! `RequestSchedulePrompt` / `CancelSchedule` / `EditSchedule` / //! `FireScheduleNow`), their shared ownership check, and the //! schedule-to-wire mapping reused by the dashboard //! (`schedule_to_wire_public` / `filter_ghost_schedule_targets`). use std::sync::Arc; use hive_core_agent_sock::Response; use crate::coordinator::Coordinator; /// `ListSchedules` — snapshot every scheduled prompt onto the wire. pub(super) fn handle_list_schedules(coord: &Arc) -> Response { match coord.scheduled_prompts.list() { Ok(schedules) => Response::Schedules { schedules: schedules.into_iter().map(schedule_to_wire).collect(), }, Err(e) => Response::Err { message: format!("list scheduled prompts: {e:#}"), }, } } /// Submit a `RequestSchedulePrompt` payload as an `ApprovalKind::SchedulePrompt` /// row. Encodes the payload into the approval's `commit_ref` so the /// approve handler can re-parse it without a side table. Validates /// inputs (non-empty targets, non-empty body, sane interval) at /// submit time — the operator should never see a malformed schedule /// pending approval. pub(super) fn handle_request_schedule_prompt( coord: &Arc, requester: &str, payload: &hive_sh4re::SchedulePromptPayload, ) -> Response { if payload.targets.is_empty() { return Response::Err { message: "schedule must have at least one target".into(), }; } if payload.body.trim().is_empty() { return Response::Err { message: "schedule body must be non-empty".into(), }; } if let Some(0) = payload.interval_seconds { return Response::Err { message: "interval_seconds must be > 0 (use None for one-shot)".into(), }; } let commit_ref = match serde_json::to_string(payload) { Ok(s) => s, Err(e) => { return Response::Err { message: format!("encode SchedulePromptPayload: {e:#}"), }; } }; let id = match coord.approvals.submit_kind( requester, hive_sh4re::ApprovalKind::SchedulePrompt, &commit_ref, payload.description.as_deref(), requester, None, ) { Ok(id) => id, Err(e) => { return Response::Err { message: format!("queue schedule_prompt approval: {e:#}"), }; } }; tracing::info!( %id, requester, targets = ?payload.targets, first_fire_at = payload.first_fire_at_unix, interval = ?payload.interval_seconds, "schedule_prompt approval queued" ); coord.emit_approval_added(crate::coordinator::ApprovalAdded { id, agent: requester, approval_kind: "schedule_prompt", sha_short: None, description: payload.description.clone(), pr_number: None, }); Response::Ok } /// Cancel a schedule (whole or per-target). Manager-surface /// authorization: a manager can cancel its own schedules + any /// schedule whose owner is one of its sub-agents (topology-walked). /// The operator surface bypasses this and can cancel anything; /// agents reaching this path through the manager get the /// topology-scoped check. pub(super) fn handle_cancel_schedule( coord: &Arc, requester: &str, schedule_id: i64, targets: Option<&[String]>, ) -> Response { let schedule = match coord.scheduled_prompts.get(schedule_id) { Ok(Some(s)) => s, Ok(None) => { return Response::Err { message: format!("schedule {schedule_id} not found"), }; } Err(e) => { return Response::Err { message: format!("read schedule {schedule_id}: {e:#}"), }; } }; if !cancel_authorized(requester, &schedule.owner) { return Response::Err { message: format!( "not authorized: {requester} cannot cancel schedule owned by {owner}", owner = schedule.owner ), }; } let result = match targets { Some(list) if !list.is_empty() => coord .scheduled_prompts .cancel_targets(schedule_id, list) .map_err(|e| format!("cancel targets: {e:#}")), _ => coord .scheduled_prompts .cancel_all(schedule_id) .map_err(|e| format!("cancel all: {e:#}")), }; match result { Ok(()) => { coord.emit_schedules_snapshot(); Response::Ok } Err(message) => Response::Err { message }, } } /// Authorize + dispatch a `FireScheduleNow` request from the /// manager surface. Same ownership rules as `CancelSchedule`: /// requester can fire its own schedules + any owned by an agent /// in its subtree. The actual fan-out lives in /// `scheduled_prompts_worker::fire_now`. pub(super) async fn handle_fire_schedule_now( coord: &Arc, requester: &str, schedule_id: i64, ) -> Response { let schedule = match coord.scheduled_prompts.get(schedule_id) { Ok(Some(s)) => s, Ok(None) => { return Response::Err { message: format!("schedule {schedule_id} not found"), }; } Err(e) => { return Response::Err { message: format!("read schedule {schedule_id}: {e:#}"), }; } }; if !cancel_authorized(requester, &schedule.owner) { return Response::Err { message: format!( "not authorized: {requester} cannot fire schedule owned by {owner}", owner = schedule.owner ), }; } // MCP fire_schedule_now stays no-reset (cadence intact); the // reset-timer option is a dashboard-dialog affordance. match crate::scheduled_prompts_worker::fire_now(coord, schedule_id, false).await { Ok(_report) => { coord.emit_schedules_snapshot(); Response::Ok } Err(e) => Response::Err { message: format!("fire schedule {schedule_id} now: {e:#}"), }, } } /// Field-named PATCH payload for [`handle_edit_schedule`]. Every /// field is "leave alone" when `None`; the double-`Option` fields /// additionally distinguish clear (`Some(None)`) from set /// (`Some(Some(v))`). #[allow( clippy::option_option, reason = "double-Option carries three-state PATCH semantics: outer None = \ leave alone, Some(None) = clear, Some(Some(v)) = set" )] pub(super) struct EditSchedulePatch { pub(super) body: Option, pub(super) description: Option>, pub(super) interval_seconds: Option>, pub(super) next_fire_at_unix: Option, pub(super) targets_add: Option>, pub(super) targets_remove: Option>, } /// Authorize + dispatch a `EditSchedule` patch. Same ownership /// rules as `CancelSchedule` — the manager can edit /// schedules it owns + any owned by an agent in its subtree. /// Forwards the partial payload to /// `ScheduledPrompts::update` which enforces the cancelled-row / /// zero-interval validation. Returns `Ok` on a clean update; /// `Err` with the underlying message on any auth / validation /// failure so the dashboard can surface it verbatim. pub(super) fn handle_edit_schedule( coord: &Arc, requester: &str, schedule_id: i64, patch: EditSchedulePatch, ) -> Response { let EditSchedulePatch { body, description, interval_seconds, next_fire_at_unix, targets_add, targets_remove, } = patch; let schedule = match coord.scheduled_prompts.get(schedule_id) { Ok(Some(s)) => s, Ok(None) => { return Response::Err { message: format!("schedule {schedule_id} not found"), }; } Err(e) => { return Response::Err { message: format!("read schedule {schedule_id}: {e:#}"), }; } }; if !cancel_authorized(requester, &schedule.owner) { return Response::Err { message: format!( "not authorized: {requester} cannot edit schedule owned by {owner}", owner = schedule.owner ), }; } let patch = crate::scheduled_prompts::UpdateSchedule { body, description, interval_seconds, next_fire_at_unix, targets_add, targets_remove, }; match coord.scheduled_prompts.update(schedule_id, patch) { Ok(()) => { coord.emit_schedules_snapshot(); Response::Ok } Err(e) => Response::Err { message: format!("edit schedule {schedule_id}: {e:#}"), }, } } /// Permission check for `CancelSchedule` on the manager surface. /// `requester` (always `ruth` here) can cancel its own schedules. /// Sub-agent ownership is delegated to topology — see /// `crate::topology::is_descendant_of`. Also reused by /// `handle_fire_schedule_now` — fire-auth follows the same shape. fn cancel_authorized(requester: &str, owner: &str) -> bool { if requester == owner { return true; } if requester == hive_sh4re::OPERATOR_RECIPIENT { return true; } // Manager can cancel anything owned by an agent in its subtree. // For the current single-manager topology that covers everything, // but the check stays correct as the tree grows. crate::topology::is_descendant_of(owner, requester) } /// Map a `scheduled_prompts::Schedule` to its public wire shape. /// Field-by-field copy — the two types are intentionally identical; /// the separation keeps hive-sh4re free of hive-c0re-internal types. /// Public alias `schedule_to_wire_public` re-exports for /// `dashboard.rs::api_schedules` without crossing the module /// boundary into the socket-server file. pub fn schedule_to_wire_public(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSchedule { schedule_to_wire(s) } /// Drop schedule targets that point at agents which no longer exist, so /// the dashboard's 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); the `operator` pseudo-target is always retained since /// it isn't a container. Applied only to the dashboard wire paths /// (`api_schedules` + the `SchedulesChanged` SSE emit) — the /// manager-facing `list_schedules` stays unfiltered so agents can still /// see and cancel stale targets. This is a view filter: the underlying /// schedule rows keep every target, so a re-spawned agent's targets /// reappear on their own. pub(crate) fn filter_ghost_schedule_targets( schedules: &mut [hive_sh4re::WireSchedule], live: &std::collections::HashSet, ) { for s in schedules.iter_mut() { s.targets .retain(|t| t.target == hive_sh4re::OPERATOR_RECIPIENT || live.contains(&t.target)); } } fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSchedule { hive_sh4re::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::WireScheduleSource::Operator } crate::scheduled_prompts::ScheduleSource::Approval { id } => { hive_sh4re::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::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(), } } #[cfg(test)] mod tests { use super::*; fn target(name: &str) -> hive_sh4re::WireScheduleTarget { hive_sh4re::WireScheduleTarget { target: name.to_owned(), cancelled_at_unix: None, last_fired_at_unix: None, last_result: None, } } fn schedule(targets: &[&str]) -> hive_sh4re::WireSchedule { hive_sh4re::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::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_and_operator() { let live: std::collections::HashSet = ["iris".to_owned(), "damocles".to_owned()] .into_iter() .collect(); let mut schedules = vec![schedule(&["iris", "ghost", "operator", "damocles"])]; filter_ghost_schedule_targets(&mut schedules, &live); let kept: Vec<&str> = schedules[0] .targets .iter() .map(|t| t.target.as_str()) .collect(); // `ghost` (destroyed) dropped; live agents + operator pseudo-target kept. assert_eq!(kept, vec!["iris", "operator", "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); // operator is never in the live set but is always retained; here // there's no operator target, so everything drops. assert!(schedules[0].targets.is_empty()); } }