Closes the #3110 split — lib.rs is now just the crate doc comment and the pub mod list. journal.rs's new doc comment fixes a pre-existing bug: the old JournalPriority doc text in lib.rs was actually half Capability's doc (a leftover from an earlier reorder that moved the code but not the comment above it).
407 lines
14 KiB
Rust
407 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_core_agent_sock::Response;
|
|
|
|
use crate::coordinator::Coordinator;
|
|
|
|
/// `ListSchedules` — snapshot every scheduled prompt onto the wire.
|
|
pub(super) fn handle_list_schedules(coord: &Arc<Coordinator>) -> 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<Coordinator>,
|
|
requester: &str,
|
|
payload: &hive_sh4re::manager::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::approvals::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<Coordinator>,
|
|
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<Coordinator>,
|
|
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<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,
|
|
) -> 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::manager::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::schedule::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::schedule::WireSchedule],
|
|
live: &std::collections::HashSet<String>,
|
|
) {
|
|
for s in schedules.iter_mut() {
|
|
s.targets.retain(|t| {
|
|
t.target == hive_sh4re::manager::OPERATOR_RECIPIENT || 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(),
|
|
}
|
|
}
|
|
|
|
#[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_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());
|
|
}
|
|
}
|