404 lines
14 KiB
Rust
404 lines
14 KiB
Rust
//! 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_agent_sock::AgentResponse;
|
|
|
|
use crate::coordinator::Coordinator;
|
|
|
|
/// `ListSchedules` — snapshot every scheduled prompt onto the wire.
|
|
pub(super) fn handle_list_schedules(coord: &Arc<Coordinator>) -> AgentResponse {
|
|
match coord.scheduled_prompts.list() {
|
|
Ok(schedules) => AgentResponse::Schedules {
|
|
schedules: schedules.into_iter().map(schedule_to_wire).collect(),
|
|
},
|
|
Err(e) => AgentResponse::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<Coordinator>,
|
|
requester: &str,
|
|
payload: &hive_sh4re::SchedulePromptPayload,
|
|
) -> AgentResponse {
|
|
if payload.targets.is_empty() {
|
|
return AgentResponse::Err {
|
|
message: "schedule must have at least one target".into(),
|
|
};
|
|
}
|
|
if payload.body.trim().is_empty() {
|
|
return AgentResponse::Err {
|
|
message: "schedule body must be non-empty".into(),
|
|
};
|
|
}
|
|
if let Some(0) = payload.interval_seconds {
|
|
return AgentResponse::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 AgentResponse::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 AgentResponse::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,
|
|
});
|
|
AgentResponse::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<Coordinator>,
|
|
requester: &str,
|
|
schedule_id: i64,
|
|
targets: Option<&[String]>,
|
|
) -> AgentResponse {
|
|
let schedule = match coord.scheduled_prompts.get(schedule_id) {
|
|
Ok(Some(s)) => s,
|
|
Ok(None) => {
|
|
return AgentResponse::Err {
|
|
message: format!("schedule {schedule_id} not found"),
|
|
};
|
|
}
|
|
Err(e) => {
|
|
return AgentResponse::Err {
|
|
message: format!("read schedule {schedule_id}: {e:#}"),
|
|
};
|
|
}
|
|
};
|
|
if !cancel_authorized(requester, &schedule.owner) {
|
|
return AgentResponse::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();
|
|
AgentResponse::Ok
|
|
}
|
|
Err(message) => AgentResponse::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<Coordinator>,
|
|
requester: &str,
|
|
schedule_id: i64,
|
|
) -> AgentResponse {
|
|
let schedule = match coord.scheduled_prompts.get(schedule_id) {
|
|
Ok(Some(s)) => s,
|
|
Ok(None) => {
|
|
return AgentResponse::Err {
|
|
message: format!("schedule {schedule_id} not found"),
|
|
};
|
|
}
|
|
Err(e) => {
|
|
return AgentResponse::Err {
|
|
message: format!("read schedule {schedule_id}: {e:#}"),
|
|
};
|
|
}
|
|
};
|
|
if !cancel_authorized(requester, &schedule.owner) {
|
|
return AgentResponse::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();
|
|
AgentResponse::Ok
|
|
}
|
|
Err(e) => AgentResponse::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<String>,
|
|
pub(super) description: Option<Option<String>>,
|
|
pub(super) interval_seconds: Option<Option<u64>>,
|
|
pub(super) next_fire_at_unix: Option<i64>,
|
|
pub(super) targets_add: Option<Vec<String>>,
|
|
pub(super) targets_remove: Option<Vec<String>>,
|
|
}
|
|
|
|
/// 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<Coordinator>,
|
|
requester: &str,
|
|
schedule_id: i64,
|
|
patch: EditSchedulePatch,
|
|
) -> AgentResponse {
|
|
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 AgentResponse::Err {
|
|
message: format!("schedule {schedule_id} not found"),
|
|
};
|
|
}
|
|
Err(e) => {
|
|
return AgentResponse::Err {
|
|
message: format!("read schedule {schedule_id}: {e:#}"),
|
|
};
|
|
}
|
|
};
|
|
if !cancel_authorized(requester, &schedule.owner) {
|
|
return AgentResponse::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();
|
|
AgentResponse::Ok
|
|
}
|
|
Err(e) => AgentResponse::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<String>,
|
|
) {
|
|
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: hive_sh4re::wire_time::from_secs(s.next_fire_at_unix),
|
|
created_at_unix: hive_sh4re::wire_time::from_secs(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.map(hive_sh4re::wire_time::from_secs),
|
|
paused_at_unix: s.paused_at_unix.map(hive_sh4re::wire_time::from_secs),
|
|
description: s.description,
|
|
targets: s
|
|
.targets
|
|
.into_iter()
|
|
.map(|t| hive_sh4re::WireScheduleTarget {
|
|
target: t.target,
|
|
cancelled_at_unix: t.cancelled_at_unix.map(hive_sh4re::wire_time::from_secs),
|
|
last_fired_at_unix: t.last_fired_at_unix.map(hive_sh4re::wire_time::from_secs),
|
|
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<String> = ["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<String> = 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());
|
|
}
|
|
}
|