Watch
0
0
Fork
You've already forked hyperhive
0

swarm: named scheduled prompts for one or more agents, fired in each agent

swarm-controller keeps every scheduled prompt (id, name, agents, body,
anchor, interval, paused) as one document under one key of a new
`schedule-definitions` KV bucket, so each change is a single
compare-and-set and a prompt exists for all of its agents or none. It
writes each agent's key of a new `agent-schedules` bucket as the prompts
naming that agent, without names or other agents, after every change and
on a one-minute reconcile pass. It is the only writer of both, behind
GET/POST /api/schedules and PATCH/DELETE /api/schedules/{id}.
`swarmctl schedule add|list|edit|pause|resume|rm` drives those routes over
the controller socket.

An agent's own queue credential is granted one more subject, the direct
get of its own key in `agent-schedules`. A KV watch is not granted: its
consumer create subject carries no key, so it would cover every agent's.

hive-agent's new schedule ticker reads that key every 10 seconds and fires
due slots as the same `schedule:<id>` todo hive-c0re's worker pushes. Fire
times are anchor + k*interval; the last fired slot per schedule id is kept
in the state db, so a body edit or list refresh never moves or refires a
slot, downtime fires the latest missed slot once, a restart refires
nothing, a one-shot fires once, and paused slots are skipped.

Refs #4914
This commit is contained in:
atlas 2026-10-04 16:29:44 +02:00 • committed by mara
commit a0b1846006
24 changed files with 2617 additions and 19 deletions

View file

@ -57,6 +57,7 @@ mod matrix_account;
mod otel_http_client;
mod queue_identity;
mod read_policy;
mod schedules;
mod status;
mod store;
mod subagent_term;
@ -616,6 +617,7 @@ fn socket_path() -> PathBuf {
(name = "webhook", description = "swarm-wide forge webhook receipt"),
(name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"),
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
(name = "schedules", description = "scheduled prompts (`swarmctl schedule`)"),
)
)]
struct ApiDoc;
@ -933,8 +935,8 @@ fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_detail
problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
}
/// The status a [`wanted::WantedWriter`] or [`agent_icon::AgentIconReader`]
/// failure should answer with.
/// The status a [`wanted::WantedWriter`], [`agent_icon::AgentIconReader`] or
/// `schedules` queue failure should answer with.
///
/// `WantedWriter::view`/`set` call `ensure_connected` internally and
/// propagate through `anyhow`, so the concrete
@ -945,7 +947,7 @@ fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_detail
/// is reconnecting, or hasn't yet) and every other queue-backed route here
/// already answers 503 for it; anything else is a genuine failure to publish
/// or read, which stays 500.
fn wanted_error_status(e: &anyhow::Error) -> axum::http::StatusCode {
pub(crate) fn wanted_error_status(e: &anyhow::Error) -> axum::http::StatusCode {
match e.downcast_ref::<swarm_queue_client::Error>() {
Some(swarm_queue_client::Error::NotConnected(_)) => {
axum::http::StatusCode::SERVICE_UNAVAILABLE
@ -2409,6 +2411,23 @@ fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[H
subagent_term::spawn(status.queue_client(), wanted, hives);
}
/// The per-agent background passes: subagent streams and schedule keys.
/// Lifted out of `main` for `clippy::too_many_lines`.
fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
spawn_subagent_streams(status, hives);
spawn_schedule_reconcile(status, hives);
}
/// Start rewriting every agent's schedule key from the definitions when a
/// swarm queue is wired up. Same shape as [`spawn_subagent_streams`].
fn spawn_schedule_reconcile(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
return;
};
let hives = hives.iter().map(|h| h.name.clone()).collect();
schedules::spawn_reconcile(status.queue_client(), wanted, hives);
}
/// Check an agent's forge token now, and mint one if it is missing or stale.
///
/// The periodic pass (`forge::agent_token::spawn`) does the same every five
@ -2937,7 +2956,7 @@ async fn main() -> Result<()> {
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
spawn_agent_renewal(&jobq, wanted_writer(status.as_ref()), &hives);
spawn_subagent_streams(status.as_ref(), &hives);
spawn_per_agent_upkeep(status.as_ref(), &hives);
// Before serving, because a hive whose role does not exist cannot log in,
// and one whose policy does not exist logs in able to read nothing —
// either way it cannot collect what this daemon writes for it. A store
@ -3016,6 +3035,11 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(subagent_term::stream_subagent_term))
.routes(routes!(agent_state_stream::stream_agent_state))
.routes(routes!(agent_input::post_agent_input))
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
.routes(routes!(
schedules::edit_schedule,
schedules::remove_schedule
))
.routes(routes!(issue_report::get_repos))
.routes(routes!(issue_report::get_issue_report_all))
.routes(routes!(issue_report::get_issue_report))

View file

@ -0,0 +1,922 @@
//! `/api/schedules` — the operator's scheduled prompts. Each has an id, a
//! name, a body, a cadence and the agents it goes to.
//!
//! **Two buckets.** Every schedule in the swarm is one document under one key
//! ([`DEFINITIONS_KEY`] in [`DEFINITIONS_BUCKET`]), which only this daemon
//! reads or writes. Every change is one compare-and-set on that key, so a
//! schedule exists for all of its agents or for none of them.
//!
//! Each agent reads only its own key of `agent-schedules`
//! (`swarm_queue_client::agent_schedules`), which holds the schedules naming
//! it, without their names or other agents. That key is a projection of the
//! definitions: after a change this daemon rewrites the key of each agent the
//! change touched, and [`spawn_reconcile`] rewrites every agent's key every
//! [`RECONCILE_INTERVAL`], so a projection write that failed is repaired
//! within that interval. A projection keeps each schedule's id, which the
//! agent keys its fired-slot record on, so dropping an agent and adding it
//! back does not refire a slot.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use async_nats::jetstream::kv::{CreateErrorKind, Operation, Store, UpdateErrorKind};
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use problem_details::ProblemDetails;
use serde::{Deserialize, Deserializer, Serialize};
use swarm_queue_client::agent_schedules::{self, AgentSchedules, Schedule};
use utoipa::ToSchema;
use crate::wanted::WantedWriter;
use crate::{AppState, error_problem};
/// The bucket holding every schedule definition. Agents hold no grant on it.
pub const DEFINITIONS_BUCKET: &str = "schedule-definitions";
/// The one key in [`DEFINITIONS_BUCKET`].
pub const DEFINITIONS_KEY: &str = "all";
/// How many times a losing writer re-reads and re-applies, as in
/// `wanted::MAX_ATTEMPTS`.
const MAX_ATTEMPTS: usize = 5;
/// Length of a minted schedule id.
const ID_LEN: usize = 8;
/// How often [`spawn_reconcile`] rewrites every agent's key. Also the
/// longest a failed projection write leaves an agent behind.
const RECONCILE_INTERVAL: Duration = Duration::from_mins(1);
/// Every schedule in the swarm: the value under [`DEFINITIONS_KEY`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct Definitions {
#[serde(default)]
schedules: Vec<Definition>,
}
/// One schedule as the operator defines it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Definition {
id: String,
name: String,
agents: BTreeSet<String>,
body: String,
anchor_unix: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
interval_seconds: Option<u64>,
#[serde(default)]
paused: bool,
}
impl Definition {
/// What an agent sees of this schedule.
fn for_agent(&self) -> Schedule {
Schedule {
id: self.id.clone(),
body: self.body.clone(),
anchor_unix: self.anchor_unix,
interval_seconds: self.interval_seconds,
paused: self.paused,
}
}
/// The next fire time after `now`, or `None` when nothing will fire:
/// paused, or a one-shot whose time has passed.
fn next_fire(&self, now: i64) -> Option<i64> {
if self.paused {
return None;
}
if now < self.anchor_unix {
return Some(self.anchor_unix);
}
let interval = i64::try_from(self.interval_seconds?).ok()?;
Some(self.for_agent().latest_slot(now)? + interval)
}
}
/// `agent`'s key: the schedules that name it, in definition order.
fn projection(definitions: &Definitions, agent: &str) -> AgentSchedules {
AgentSchedules {
schedules: definitions
.schedules
.iter()
.filter(|d| d.agents.contains(agent))
.map(Definition::for_agent)
.collect(),
}
}
/// One schedule as the API shows it.
#[derive(Debug, Serialize, ToSchema)]
pub struct ScheduleView {
id: String,
/// The operator's title for it.
name: String,
/// The agents it fires in, sorted.
agents: Vec<String>,
/// The prompt, delivered to each agent verbatim.
body: String,
/// The first fire time, unix seconds.
anchor_unix: i64,
/// Seconds between fires; `null` for a one-shot.
interval_seconds: Option<u64>,
paused: bool,
/// The next fire time on the schedule's grid, unix seconds, computed when
/// the response is built; `null` when paused or a one-shot whose time
/// has passed. Says nothing about whether an agent fired an earlier one.
next_fire_unix: Option<i64>,
}
impl ScheduleView {
fn new(d: &Definition, now: i64) -> Self {
Self {
id: d.id.clone(),
name: d.name.clone(),
agents: d.agents.iter().cloned().collect(),
body: d.body.clone(),
anchor_unix: d.anchor_unix,
interval_seconds: d.interval_seconds,
paused: d.paused,
next_fire_unix: d.next_fire(now),
}
}
}
/// A new schedule.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct AddScheduleRequest {
/// The title. Must not be empty.
name: String,
/// The agents to fire in, at least one. Each must be declared on a hive.
agents: Vec<String>,
/// The prompt, delivered verbatim. Must not be empty.
body: String,
/// The first fire time, unix seconds. Defaults to now.
anchor_unix: Option<i64>,
/// Seconds between fires, at least 1. Absent for a one-shot.
interval_seconds: Option<u64>,
#[serde(default)]
paused: bool,
}
/// A partial change to one schedule; an absent field is left as it is.
#[derive(Debug, Clone, Default, Deserialize, ToSchema)]
pub struct EditScheduleRequest {
name: Option<String>,
body: Option<String>,
anchor_unix: Option<i64>,
/// A number sets the interval; `null` makes the schedule a one-shot.
#[serde(default)]
#[schema(value_type = Option<u64>)]
interval_seconds: IntervalEdit,
paused: Option<bool>,
/// Agents to add. Each must be declared on a hive; one already targeted
/// is left as it is.
#[serde(default)]
add_agents: Vec<String>,
/// Agents to remove. Each must be a current target, and at least one
/// target must remain.
#[serde(default)]
remove_agents: Vec<String>,
}
/// What an edit does to the interval: an absent field keeps it, `null` makes
/// a one-shot, a number sets it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum IntervalEdit {
#[default]
Keep,
Set(Option<u64>),
}
impl<'de> Deserialize<'de> for IntervalEdit {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Option::<u64>::deserialize(d).map(Self::Set)
}
}
/// Why a change was refused before it was written.
#[derive(Debug, PartialEq, Eq)]
enum Refusal {
Invalid(String),
NotDeclared(String),
NoSuchSchedule(String),
}
fn invalid(why: &str) -> Refusal {
Refusal::Invalid(why.to_owned())
}
fn check_text(value: &str, what: &str) -> Result<(), Refusal> {
if value.trim().is_empty() {
return Err(Refusal::Invalid(format!("the {what} is empty")));
}
Ok(())
}
fn check_interval(interval: Option<u64>) -> Result<(), Refusal> {
if interval == Some(0) {
return Err(invalid(
"interval_seconds must be at least 1; omit it for a one-shot",
));
}
Ok(())
}
/// `names` as agent identifiers, each declared on some hive per `live`.
fn check_agents(names: &[String], live: &BTreeSet<String>) -> Result<BTreeSet<String>, Refusal> {
let mut agents = BTreeSet::new();
for name in names {
let agent = hive_types::Ident::parse(name)
.map(hive_types::Ident::into_string)
.map_err(|why| Refusal::Invalid(format!("agent {name:?}: {why}")))?;
if !live.contains(&agent) {
return Err(Refusal::NotDeclared(agent));
}
agents.insert(agent);
}
Ok(agents)
}
impl AddScheduleRequest {
fn into_definition(
self,
id: String,
now: i64,
live: &BTreeSet<String>,
) -> Result<Definition, Refusal> {
check_text(&self.name, "name")?;
check_text(&self.body, "body")?;
check_interval(self.interval_seconds)?;
let agents = check_agents(&self.agents, live)?;
if agents.is_empty() {
return Err(invalid("a schedule needs at least one agent"));
}
Ok(Definition {
id,
name: self.name,
agents,
body: self.body,
anchor_unix: self.anchor_unix.unwrap_or(now),
interval_seconds: self.interval_seconds,
paused: self.paused,
})
}
}
impl EditScheduleRequest {
/// Apply the edit to schedule `id`, returning it as changed and every
/// agent it named before or after.
fn apply(
self,
definitions: &mut Definitions,
id: &str,
live: &BTreeSet<String>,
) -> Result<(Definition, BTreeSet<String>), Refusal> {
if let Some(name) = &self.name {
check_text(name, "name")?;
}
if let Some(body) = &self.body {
check_text(body, "body")?;
}
if let IntervalEdit::Set(interval) = self.interval_seconds {
check_interval(interval)?;
}
let added = check_agents(&self.add_agents, live)?;
let d = definitions
.schedules
.iter_mut()
.find(|d| d.id == id)
.ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?;
let mut agents = d.agents.clone();
agents.extend(added);
for name in &self.remove_agents {
if !agents.remove(name) {
return Err(Refusal::Invalid(format!(
"agent {name:?} is not a target of schedule {id:?}"
)));
}
}
if agents.is_empty() {
return Err(invalid(
"this would leave the schedule with no agents; remove the schedule instead",
));
}
let touched = d.agents.union(&agents).cloned().collect();
d.agents = agents;
if let Some(name) = self.name {
d.name = name;
}
if let Some(body) = self.body {
d.body = body;
}
if let Some(anchor) = self.anchor_unix {
d.anchor_unix = anchor;
}
if let IntervalEdit::Set(interval) = self.interval_seconds {
d.interval_seconds = interval;
}
if let Some(paused) = self.paused {
d.paused = paused;
}
Ok((d.clone(), touched))
}
}
/// Remove schedule `id`, returning the agents it named.
fn remove(definitions: &mut Definitions, id: &str) -> Result<BTreeSet<String>, Refusal> {
let at = definitions
.schedules
.iter()
.position(|d| d.id == id)
.ok_or_else(|| Refusal::NoSuchSchedule(id.to_owned()))?;
Ok(definitions.schedules.remove(at).agents)
}
/// A fresh id: lowercase letters only, so the agent's todo key
/// `schedule:<id>` can never equal one hive-c0re pushes for its numeric ids.
fn mint_id(taken: &Definitions) -> anyhow::Result<String> {
loop {
let mut bytes = [0u8; ID_LEN];
getrandom::fill(&mut bytes)
.map_err(|e| anyhow::anyhow!("drawing a schedule id from the kernel's CSPRNG: {e}"))?;
let id: String = bytes.iter().map(|b| char::from(b'a' + b % 26)).collect();
if !taken.schedules.iter().any(|d| d.id == id) {
return Ok(id);
}
}
}
/// The failure of one request, as the problem it answers with.
enum Failure {
Refused(Refusal),
Queue(anyhow::Error),
}
impl From<anyhow::Error> for Failure {
fn from(e: anyhow::Error) -> Self {
Self::Queue(e)
}
}
impl From<Refusal> for Failure {
fn from(r: Refusal) -> Self {
Self::Refused(r)
}
}
fn problem(failure: Failure) -> ProblemDetails {
match failure {
Failure::Refused(Refusal::Invalid(why)) => error_problem(StatusCode::BAD_REQUEST, &why),
Failure::Refused(Refusal::NotDeclared(agent)) => error_problem(
StatusCode::BAD_REQUEST,
&format!("no hive's wanted state declares an agent {agent:?} that is not destroyed"),
),
Failure::Refused(Refusal::NoSuchSchedule(id)) => {
error_problem(StatusCode::NOT_FOUND, &format!("no schedule {id:?}"))
}
Failure::Queue(e) => {
tracing::warn!(error = %format!("{e:#}"), "schedules: queue failure");
error_problem(crate::wanted_error_status(&e), &format!("{e:#}"))
}
}
}
/// Open a bucket only this daemon writes, creating it if nothing has yet.
async fn open_or_create_definitions(client: &async_nats::Client) -> anyhow::Result<Store> {
let js = async_nats::jetstream::new(client.clone());
if let Ok(store) = js.get_key_value(DEFINITIONS_BUCKET).await {
return Ok(store);
}
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: DEFINITIONS_BUCKET.to_owned(),
description: "Every scheduled prompt the swarm controller keeps".to_owned(),
history: 1,
..Default::default()
})
.await
.with_context(|| format!("creating the {DEFINITIONS_BUCKET} bucket"))
}
/// The value of `key` decoded, with the revision to write it back against.
/// An absent or deleted key is the default value.
async fn read<T: Default + serde::de::DeserializeOwned>(
store: &Store,
key: &str,
) -> anyhow::Result<(T, Option<u64>)> {
let Some(entry) = store
.entry(key)
.await
.with_context(|| format!("reading {key}"))?
else {
return Ok((T::default(), None));
};
if entry.operation != Operation::Put {
return Ok((T::default(), Some(entry.revision)));
}
let value =
serde_json::from_slice(&entry.value).with_context(|| format!("{key} is not decodable"))?;
Ok((value, Some(entry.revision)))
}
/// Write `value` to `key` against `revision`. `Ok(false)` when another write
/// landed first.
async fn write<T: Serialize>(
store: &Store,
key: &str,
value: &T,
revision: Option<u64>,
) -> anyhow::Result<bool> {
let encoded = serde_json::to_vec(value)?;
match revision {
Some(revision) => match store.update(key, encoded.into(), revision).await {
Ok(_) => Ok(true),
Err(e) if matches!(e.kind(), UpdateErrorKind::WrongLastRevision) => Ok(false),
Err(e) => Err(e).with_context(|| format!("writing {key}")),
},
None => match store.create(key, encoded.into()).await {
Ok(_) => Ok(true),
Err(e) if matches!(e.kind(), CreateErrorKind::AlreadyExists) => Ok(false),
Err(e) => Err(e).with_context(|| format!("writing {key}")),
},
}
}
/// Apply `change` to the definitions and write them back, re-reading and
/// re-applying when another write landed in between.
async fn commit<T>(
store: &Store,
mut change: impl FnMut(&mut Definitions) -> Result<T, Failure>,
) -> Result<T, Failure> {
for _ in 0..MAX_ATTEMPTS {
let (mut definitions, revision) = read::<Definitions>(store, DEFINITIONS_KEY).await?;
let out = change(&mut definitions)?;
if write(store, DEFINITIONS_KEY, &definitions, revision).await? {
return Ok(out);
}
tracing::debug!("schedules: definitions write lost a race, retrying");
}
Err(
anyhow::anyhow!("gave up writing the schedules after {MAX_ATTEMPTS} conflicting writes")
.into(),
)
}
/// Rewrite `agent`'s key from the current definitions.
///
/// The agent key's revision is read before the definitions, so a write based
/// on definitions older than another writer's fails its revision check and
/// is redone from fresh ones.
async fn project(definitions: &Store, agents: &Store, agent: &str) -> anyhow::Result<()> {
for _ in 0..MAX_ATTEMPTS {
let (current, revision) = read::<AgentSchedules>(agents, agent).await?;
let (all, _) = read::<Definitions>(definitions, DEFINITIONS_KEY).await?;
let wanted = projection(&all, agent);
if current == wanted {
return Ok(());
}
if write(agents, agent, &wanted, revision).await? {
return Ok(());
}
}
anyhow::bail!(
"gave up writing the schedules of {agent} after {MAX_ATTEMPTS} conflicting writes"
)
}
/// [`project`] each of `touched`, logging a failure: the definitions are
/// already written, and [`spawn_reconcile`] repairs the agent's key.
async fn project_all(client: &async_nats::Client, definitions: &Store, touched: &BTreeSet<String>) {
let agents = match agent_schedules::open_or_create(client).await {
Ok(store) => store,
Err(e) => {
tracing::warn!(
error = %swarm_queue_client::chain(&e),
"schedules: opening the agent-schedules bucket failed; the reconcile pass retries"
);
return;
}
};
for agent in touched {
if let Err(e) = project(definitions, &agents, agent).await {
tracing::warn!(
%agent,
error = %format!("{e:#}"),
"schedules: rewriting the agent's key failed; the reconcile pass retries"
);
}
}
}
/// Rewrite every agent's key from the definitions, now and every
/// [`RECONCILE_INTERVAL`] after: the agents the definitions name, and every
/// live agent, so one dropped from its last schedule is emptied too.
pub(crate) fn spawn_reconcile(
client: async_nats::Client,
wanted: Arc<WantedWriter>,
hives: Vec<String>,
) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
if swarm_queue_client::ensure_connected(&client).is_err() {
continue;
}
let store = match open_or_create_definitions(&client).await {
Ok(store) => store,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "schedules: reconcile could not open the definitions");
continue;
}
};
let all = match read::<Definitions>(&store, DEFINITIONS_KEY).await {
Ok((all, _)) => all,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "schedules: reconcile could not read the definitions");
continue;
}
};
let mut agents = crate::subagent_term::live_agents(&wanted, &hives).await;
agents.extend(all.schedules.iter().flat_map(|d| d.agents.iter().cloned()));
project_all(&client, &store, &agents).await;
}
});
}
/// The connected queue client, or the problem.
fn queue(state: &AppState) -> Result<async_nats::Client, (StatusCode, String)> {
let status = state.status.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
"no swarm queue is configured on this host".to_owned(),
)
})?;
let client = status.queue_client();
swarm_queue_client::ensure_connected(&client).map_err(|e| {
(
StatusCode::SERVICE_UNAVAILABLE,
swarm_queue_client::chain(&e),
)
})?;
Ok(client)
}
fn refused((status, detail): (StatusCode, String)) -> ProblemDetails {
error_problem(status, &detail)
}
/// Every agent some hive declares and has not destroyed.
async fn live(state: &AppState) -> Result<BTreeSet<String>, ProblemDetails> {
let Some(wanted) = state.wanted.as_ref() else {
return Err(error_problem(
StatusCode::SERVICE_UNAVAILABLE,
"no swarm queue is configured on this host",
));
};
let hives: Vec<String> = state.hives.iter().map(|h| h.name.clone()).collect();
Ok(crate::subagent_term::live_agents(wanted, &hives).await)
}
#[utoipa::path(
get,
path = "/api/schedules",
responses(
(status = 200, description = "every schedule, sorted by name then id; empty when \
there are none", body = Vec<ScheduleView>),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the schedules could not be read (problem+json)",
body = String),
),
tag = "schedules"
)]
pub(crate) async fn list_schedules(
State(state): State<AppState>,
) -> Result<Json<Vec<ScheduleView>>, ProblemDetails> {
let client = queue(&state).map_err(refused)?;
let store = open_or_create_definitions(&client)
.await
.map_err(|e| problem(e.into()))?;
let (mut all, _) = read::<Definitions>(&store, DEFINITIONS_KEY)
.await
.map_err(|e| problem(e.into()))?;
all.schedules
.sort_by(|a, b| (&a.name, &a.id).cmp(&(&b.name, &b.id)));
let now = unix_now();
Ok(Json(
all.schedules
.iter()
.map(|d| ScheduleView::new(d, now))
.collect(),
))
}
#[utoipa::path(
post,
path = "/api/schedules",
request_body = AddScheduleRequest,
responses(
(status = 201, description = "the schedule as stored, with its new id",
body = ScheduleView),
(status = 400, description = "the name or body is empty, the interval is 0, no \
agent is named, or an agent is not an identifier or not declared on any hive \
(problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the schedules could not be written (problem+json)",
body = String),
),
tag = "schedules"
)]
pub(crate) async fn add_schedule(
State(state): State<AppState>,
Json(req): Json<AddScheduleRequest>,
) -> Result<(StatusCode, Json<ScheduleView>), ProblemDetails> {
let client = queue(&state).map_err(refused)?;
let live = live(&state).await?;
let now = unix_now();
let store = open_or_create_definitions(&client)
.await
.map_err(|e| problem(e.into()))?;
let added = commit(&store, |all| {
let id = mint_id(all)?;
let d = req.clone().into_definition(id, now, &live)?;
all.schedules.push(d.clone());
Ok(d)
})
.await
.map_err(problem)?;
tracing::info!(id = %added.id, agents = ?added.agents, "added a schedule");
project_all(&client, &store, &added.agents).await;
Ok((StatusCode::CREATED, Json(ScheduleView::new(&added, now))))
}
#[utoipa::path(
patch,
path = "/api/schedules/{id}",
params(("id" = String, Path, description = "schedule to change")),
request_body = EditScheduleRequest,
responses(
(status = 200, description = "the schedule as stored", body = ScheduleView),
(status = 400, description = "the name or body is empty, the interval is 0, an \
added agent is not declared on any hive, a removed agent is not a target, or \
no agent would remain (problem+json)", body = String),
(status = 404, description = "there is no schedule with this id (problem+json)",
body = String),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the schedules could not be written (problem+json)",
body = String),
),
tag = "schedules"
)]
pub(crate) async fn edit_schedule(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<EditScheduleRequest>,
) -> Result<Json<ScheduleView>, ProblemDetails> {
let client = queue(&state).map_err(refused)?;
let live = live(&state).await?;
let store = open_or_create_definitions(&client)
.await
.map_err(|e| problem(e.into()))?;
let (edited, touched) = commit(&store, |all| Ok(req.clone().apply(all, &id, &live)?))
.await
.map_err(problem)?;
tracing::info!(%id, "edited a schedule");
project_all(&client, &store, &touched).await;
Ok(Json(ScheduleView::new(&edited, unix_now())))
}
#[utoipa::path(
delete,
path = "/api/schedules/{id}",
params(("id" = String, Path, description = "schedule to remove")),
responses(
(status = 204, description = "removed; it fires no more"),
(status = 404, description = "there is no schedule with this id (problem+json)",
body = String),
(status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String),
(status = 500, description = "the schedules could not be written (problem+json)",
body = String),
),
tag = "schedules"
)]
pub(crate) async fn remove_schedule(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, ProblemDetails> {
let client = queue(&state).map_err(refused)?;
let store = open_or_create_definitions(&client)
.await
.map_err(|e| problem(e.into()))?;
let touched = commit(&store, |all| Ok(remove(all, &id)?))
.await
.map_err(problem)?;
tracing::info!(%id, "removed a schedule");
project_all(&client, &store, &touched).await;
Ok(StatusCode::NO_CONTENT)
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
#[cfg(test)]
mod tests {
use super::*;
fn live() -> BTreeSet<String> {
["atlas", "iris", "scribe"].map(str::to_owned).into()
}
fn standup() -> Definition {
Definition {
id: "abc".to_owned(),
name: "standup".to_owned(),
agents: ["atlas", "iris"].map(str::to_owned).into(),
body: "post the standup".to_owned(),
anchor_unix: 1000,
interval_seconds: Some(3600),
paused: false,
}
}
fn all() -> Definitions {
Definitions {
schedules: vec![standup()],
}
}
fn edit(json: &str) -> EditScheduleRequest {
serde_json::from_str(json).expect("decodes")
}
/// Each agent's key carries the schedules naming it, under the same id,
/// and nothing of the name or the other agents.
#[test]
fn an_agents_key_holds_the_schedules_naming_it() {
let mut defs = all();
let mut other = standup();
"xyz".clone_into(&mut other.id);
other.agents = ["scribe"].map(str::to_owned).into();
defs.schedules.push(other);
let atlas = projection(&defs, "atlas");
assert_eq!(atlas.schedules, vec![standup().for_agent()]);
assert_eq!(projection(&defs, "scribe").schedules[0].id, "xyz");
assert!(projection(&defs, "argus").schedules.is_empty());
let encoded = serde_json::to_string(&atlas).unwrap();
assert!(!encoded.contains("\"name\""), "{encoded}");
assert!(!encoded.contains("iris"), "{encoded}");
}
/// A body or name edit leaves anchor and interval, and so the fire
/// times, where they were, and keeps the id.
#[test]
fn a_text_edit_leaves_the_cadence_and_id_alone() {
let mut defs = all();
let (out, touched) = edit(r#"{"body":"retro","name":"retro"}"#)
.apply(&mut defs, "abc", &live())
.unwrap();
assert_eq!(
(out.id.as_str(), out.body.as_str(), out.name.as_str()),
("abc", "retro", "retro")
);
assert_eq!((out.anchor_unix, out.interval_seconds), (1000, Some(3600)));
assert_eq!(touched, standup().agents);
}
/// Adding and removing targets keeps the id, and every agent on either
/// side of the change is rewritten.
#[test]
fn the_agent_set_changes_without_a_new_id() {
let mut defs = all();
let (out, touched) = edit(r#"{"add_agents":["scribe"],"remove_agents":["iris"]}"#)
.apply(&mut defs, "abc", &live())
.unwrap();
assert_eq!(out.id, "abc");
assert_eq!(out.agents, ["atlas", "scribe"].map(str::to_owned).into());
assert_eq!(
touched,
["atlas", "iris", "scribe"].map(str::to_owned).into()
);
assert_eq!(defs.schedules, vec![out]);
}
#[test]
fn bad_edits_are_refused_before_anything_changes() {
for (json, id) in [
(r#"{"interval_seconds":0}"#, "abc"),
(r#"{"body":" "}"#, "abc"),
(r#"{"name":""}"#, "abc"),
(r#"{"add_agents":["nobody"]}"#, "abc"),
(r#"{"remove_agents":["scribe"]}"#, "abc"),
(r#"{"remove_agents":["atlas","iris"]}"#, "abc"),
(r#"{"body":"x"}"#, "nope"),
] {
let mut defs = all();
assert!(edit(json).apply(&mut defs, id, &live()).is_err(), "{json}");
assert_eq!(defs, all(), "{json}");
}
}
#[test]
fn a_null_interval_makes_a_one_shot_and_an_absent_one_keeps_it() {
let mut defs = all();
let (kept, _) = edit(r#"{"paused":true}"#)
.apply(&mut defs, "abc", &live())
.unwrap();
assert_eq!(kept.interval_seconds, Some(3600));
assert!(kept.paused);
let (once, _) = edit(r#"{"interval_seconds":null}"#)
.apply(&mut defs, "abc", &live())
.unwrap();
assert_eq!(once.interval_seconds, None);
}
#[test]
fn removing_drops_only_the_named_schedule() {
let mut defs = all();
assert_eq!(
remove(&mut defs, "zzz"),
Err(Refusal::NoSuchSchedule("zzz".to_owned()))
);
assert_eq!(defs, all());
assert_eq!(remove(&mut defs, "abc").unwrap(), standup().agents);
assert!(defs.schedules.is_empty());
}
#[test]
fn an_add_needs_a_name_and_declared_agents_and_anchors_at_now() {
let add = |json: &str| {
serde_json::from_str::<AddScheduleRequest>(json)
.unwrap()
.into_definition("id".to_owned(), 42, &live())
};
let d = add(r#"{"name":"n","agents":["iris","atlas","iris"],"body":"hi"}"#).unwrap();
assert_eq!(
(d.anchor_unix, d.interval_seconds, d.paused),
(42, None, false)
);
assert_eq!(d.agents, ["atlas", "iris"].map(str::to_owned).into());
assert!(add(r#"{"name":" ","agents":["iris"],"body":"hi"}"#).is_err());
assert!(add(r#"{"name":"n","agents":[],"body":"hi"}"#).is_err());
assert_eq!(
add(r#"{"name":"n","agents":["nobody"],"body":"hi"}"#),
Err(Refusal::NotDeclared("nobody".to_owned()))
);
assert!(add(r#"{"name":"n","agents":["Not An Ident"],"body":"hi"}"#).is_err());
}
#[test]
fn next_fire_is_the_next_grid_slot_or_none() {
let mut d = standup();
assert_eq!(d.next_fire(999), Some(1000));
assert_eq!(d.next_fire(1000), Some(4600));
assert_eq!(d.next_fire(4601), Some(8200));
d.paused = true;
assert_eq!(d.next_fire(999), None);
d.paused = false;
d.interval_seconds = None;
assert_eq!(d.next_fire(999), Some(1000));
assert_eq!(d.next_fire(1000), None);
}
#[test]
fn a_view_shows_the_wire_shape() {
let v = serde_json::to_value(ScheduleView::new(&standup(), 1500)).unwrap();
assert_eq!(
v,
serde_json::json!({
"id": "abc",
"name": "standup",
"agents": ["atlas", "iris"],
"body": "post the standup",
"anchor_unix": 1000,
"interval_seconds": 3600,
"paused": false,
"next_fire_unix": 4600,
})
);
}
/// Letters only: hive-c0re keys its todos `schedule:<number>`, and a
/// swarm id that could be all digits could collapse into one of them.
#[test]
fn minted_ids_are_letters_and_avoid_taken_ones() {
let defs = all();
for _ in 0..100 {
let id = mint_id(&defs).unwrap();
assert_eq!(id.len(), ID_LEN);
assert!(id.chars().all(|c| c.is_ascii_lowercase()), "{id}");
assert_ne!(id, "abc");
}
}
}

View file

@ -72,7 +72,7 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives
}
/// Every agent the readable declarations of `hives` keep alive.
async fn live_agents(wanted: &WantedWriter, hives: &[String]) -> BTreeSet<String> {
pub(crate) async fn live_agents(wanted: &WantedWriter, hives: &[String]) -> BTreeSet<String> {
let mut declarations = Vec::with_capacity(hives.len());
for hive in hives {
match wanted.view(hive).await {