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

1
Cargo.lock generated
View file

@ -4909,6 +4909,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"bytes",
"chrono",
"clap",
"clap-markdown",
"clap_complete",

View file

@ -82,6 +82,8 @@ declarations.
[`swarm/secrets.md`](swarm/secrets.md) for where each file lives today.
- **How does the secret store come up, who writes its grants, how's it
sealed?** → [`swarm/bao.md`](swarm/bao.md).
- **How does an agent get a prompt at set times, and why doesn't it fire
twice after downtime?** → [`swarm/schedules.md`](swarm/schedules.md).
## Scheduler, CI, observability

View file

@ -21,6 +21,8 @@ grafana, the controller, authelia, …) →
- **control plane** — `swarm-controller`: hive directory, agent roster, job
graph, agent creation. → [below](#swarm-controller)
- **scheduled prompts** — named prompts for one or more agents at set times,
kept by the swarm and fired in each agent's container. → [`schedules.md`](schedules.md)
- **swarm UI** — the operator's day-to-day surface, on the swarm apex,
`admins` only. → [`ui.md`](ui.md)
- **shared services** — one forge, homeserver, SSO, queue and metrics/logs

150
docs/swarm/schedules.md Normal file
View file

@ -0,0 +1,150 @@
# Scheduled prompts
A scheduled prompt hands one or more agents the same text at set times: once,
or every interval from a start time. Each prompt has a name, its title. The
swarm keeps every prompt, and each agent it names fires it in its own
container, so a fire needs neither the hive nor the swarm controller to be up
at that moment, only the agent.
Operators manage them with `swarmctl schedule` on the swarm controller's
host. Agents can't create, change or remove one. For a one-off wake an agent
sets itself, it uses `remind`, which stays inside its container.
## Managing them
```console
# swarmctl schedule add --name standup --agent scribe --agent iris \
--body "post the standup summary" --at 2026-10-05T09:00:00+02:00 --every 1d
added schedule kqzpmwab
kqzpmwab "standup" every 1d from 2026-10-05T07:00:00Z next 2026-10-05T07:00:00Z agents: iris, scribe | post the standup summary
# swarmctl schedule list
# swarmctl schedule list --agent scribe
# swarmctl schedule edit kqzpmwab --body "post the standup summary to the team room"
# swarmctl schedule edit kqzpmwab --add-agent atlas --remove-agent iris
# swarmctl schedule pause kqzpmwab
# swarmctl schedule resume kqzpmwab
# swarmctl schedule rm kqzpmwab
```
- `--agent` names an agent to fire the prompt in; repeat it for more. Each
must be an agent some hive's wanted state declares.
- `--at` takes an RFC 3339 time with its offset, or unix seconds. Without it
the first fire is now. A time in the past fires its latest past time once,
as soon as each agent reads the prompt.
- `--every` takes a count and a unit, `s`, `m`, `h` or `d`. Without it the
prompt fires once.
- `schedule edit` changes only the flags you pass and keeps the id. `--once`
turns a recurring prompt into a one-shot. `--add-agent` and
`--remove-agent` change who gets it; removing the last agent fails, so
remove the prompt instead.
- `schedule list` shows one row per prompt: id, name, cadence, start, next
fire time, agents, and the first line of the body.
The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md).
## When a prompt fires
A prompt's fire times are its start time plus whole intervals. Each agent
works them out from the prompt itself, so:
- **Editing the text or the name doesn't move them.** Neither does changing
the agents. Only a new `--at` or `--every` does.
- **An agent that was down or disconnected fires once.** When it's back, it
fires the latest time it missed and then carries on at the next one. The
earlier missed times don't fire.
- **A restart refires nothing.** Each agent records the last time it fired
for each prompt in its state database, and fires a time only when it's
later than that. An agent removed from a prompt and added back keeps that
record, so it doesn't refire a time it already fired.
- **A one-shot fires once**, even though it stays in `schedule list` until
you remove it. Moving its `--at` to a later time fires it again then.
- **A paused prompt doesn't fire**, and the times that pass while it's paused
don't fire when it resumes. An agent marks those times as passed while it
watches the paused prompt; if the agent was down for the whole pause, it
fires the latest time it missed once, as above.
A fire reaches the agent as a `schedule` loose end with the prompt's text,
the same as a hive schedule's. The agent's turn loop wakes for it like any
other loose end. If the agent hasn't dealt with the previous fire yet, the
new fire replaces it rather than adding a second one; once the agent
dismissed the previous one, the next fire opens a new one.
Each agent reads its prompts every 10 seconds, so a fire can land up to 10
seconds after its time. A change reaches each agent within the same window,
or within a minute if the controller's first attempt to pass it on failed.
## When a prompt doesn't fire
- **The agent has no queue credential of its own.** The agent reads its
prompts with its own swarm-queue credential. An agent connected with its
hive's shared client logs `connected with the hive's shared client, which
has no read grant on the agent-schedules bucket` and fires nothing.
- **The agent's first connect with its own credential failed.** The harness
keeps the outcome of its first connect for the rest of its run: it falls
back to the hive's shared client (the case above) or stays disconnected,
and reads no prompts until it restarts.
- **The agent hasn't read its prompts since it started.** It fires from the
last list it read; until the first read succeeds it fires nothing. A read
that fails later keeps the previous list.
<details><summary>Implementation</summary>
swarm-controller is the only client that writes either of the two KV buckets
involved.
`schedule-definitions` holds every prompt as one JSON document under the key
`all`. Agents hold no grant on this bucket. Each change re-reads the key and
writes against its revision, so a prompt exists for every one of its agents or for
none of them, and two changes at once don't lose one.
`agent-schedules` holds one key per agent: the prompts naming that agent,
without their names or other agents.
```json
{
"schedules": [
{
"id": "kqzpmwab",
"body": "post the standup summary",
"anchor_unix": 1791183600,
"interval_seconds": 86400,
"paused": false
}
]
}
```
`interval_seconds` is absent for a one-shot. After each change the controller
rewrites the key of every agent the change named before or after, and once a
minute it rewrites every agent's key, so a rewrite that failed converges. Ids
are 8 lowercase letters, the same in every agent's key, never reused, and so
never equal to a hive schedule's numeric id.
`swarmctl schedule` calls these routes over the controller's socket:
| method | path | body |
| -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/schedules` | —; answers every prompt, sorted by name then id |
| `POST` | `/api/schedules` | `name`, `agents`, `body`, and optional `anchor_unix`, `interval_seconds`, `paused`; answers 201 with the prompt |
| `PATCH` | `/api/schedules/<id>` | any of `name`, `body`, `anchor_unix`, `interval_seconds` (`null` for a one-shot), `paused`, `add_agents`, `remove_agents` |
| `DELETE` | `/api/schedules/<id>` | —; answers 204 |
Each prompt they answer with has `id`, `name`, `agents` (sorted), `body`,
`anchor_unix`, `interval_seconds` (`null` for a one-shot), `paused`, and
`next_fire_unix`: the next time on the prompt's grid, as of the moment the
controller answers, `null` when paused or a one-shot whose time has passed.
An agent's own queue credential grants exactly one subject in the swarm's
JetStream: `$JS.API.DIRECT.GET.KV_agent-schedules.$KV.agent-schedules.<agent>`,
the direct read of its own key. It can't read another agent's key, open or
watch the bucket, or write to it. A KV watch would need a consumer, and a
consumer create subject doesn't name a key, so granting one would let the
agent read every key; the agent reads its key every 10 seconds instead.
The harness (`hive-agent`'s `schedule_timer`) keeps the last fired time per
prompt id in the `schedule_fired_slots` table of its state database, and
pushes each fire as the todo `schedule`/`schedule:<id>` with source
`scheduled`, reopening an acknowledged one.
</details>

View file

@ -9,6 +9,13 @@ This document contains the help content for the `swarmctl` command-line program.
* [`swarmctl agent create`↴](#swarmctl-agent-create)
* [`swarmctl agent mint-identity`↴](#swarmctl-agent-mint-identity)
* [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token)
* [`swarmctl schedule`↴](#swarmctl-schedule)
* [`swarmctl schedule add`↴](#swarmctl-schedule-add)
* [`swarmctl schedule list`↴](#swarmctl-schedule-list)
* [`swarmctl schedule edit`↴](#swarmctl-schedule-edit)
* [`swarmctl schedule pause`↴](#swarmctl-schedule-pause)
* [`swarmctl schedule resume`↴](#swarmctl-schedule-resume)
* [`swarmctl schedule rm`↴](#swarmctl-schedule-rm)
* [`swarmctl user`↴](#swarmctl-user)
* [`swarmctl user add`↴](#swarmctl-user-add)
* [`swarmctl user reset-password`↴](#swarmctl-user-reset-password)
@ -27,6 +34,7 @@ swarm-level operator CLI
###### **Subcommands:**
* `agent` — Manage agents across the swarm
* `schedule` — Manage scheduled prompts
* `user` — Manage subjects in the swarm's SSO provider
* `forge` — Manage human accounts on the swarm's forge
* `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
@ -129,6 +137,143 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
## `swarmctl schedule`
Manage scheduled prompts.
The swarm keeps each prompt, and every agent it names fires it in its own container. Fire times are the schedule's start time plus whole intervals: changing the body never moves them, and an agent that was down fires the latest missed one once.
**Usage:** `swarmctl schedule <COMMAND>`
###### **Subcommands:**
* `add` — Add a scheduled prompt and print it with its new id
* `list` — List scheduled prompts, one row each, with their agents and next fire time
* `edit` — Change a scheduled prompt
* `pause` — Stop a scheduled prompt firing. Times that pass while it's paused aren't fired when it resumes
* `resume` — Let a paused scheduled prompt fire again, from its next time on
* `rm` — Remove a scheduled prompt from every agent it names
## `swarmctl schedule add`
Add a scheduled prompt and print it with its new id.
Without `--every` it fires once. Without `--at` its first fire is now.
**Usage:** `swarmctl schedule add [OPTIONS] --name <TEXT> --agent <AGENT> --body <TEXT>`
###### **Options:**
* `--name <TEXT>` — The prompt's title
* `--agent <AGENT>` — Agent to fire it in. Repeatable; at least one
* `--body <TEXT>` — The prompt, delivered to each agent verbatim
* `--at <TIME>` — First fire time: an RFC 3339 time with its offset, such as `2026-10-05T09:00:00+02:00`, or unix seconds. Defaults to now
* `--every <DURATION>` — Time between fires: a count with a unit of `s`, `m`, `h` or `d`, such as `1h` or `7d`. Leave out for a one-shot
* `--paused` — Add it paused
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl schedule list`
List scheduled prompts, one row each, with their agents and next fire time
**Usage:** `swarmctl schedule list [OPTIONS]`
###### **Options:**
* `--agent <AGENT>` — Show only the prompts that fire in this agent
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl schedule edit`
Change a scheduled prompt.
Only the flags given change, and the id stays. `--at` and `--every` move the fire times; `--name`, `--body` and the agent flags don't.
**Usage:** `swarmctl schedule edit [OPTIONS] <ID>`
###### **Arguments:**
* `<ID>` — The prompt's id, as `schedule list` shows it
###### **Options:**
* `--name <TEXT>` — New title
* `--body <TEXT>` — New prompt text
* `--at <TIME>` — New first fire time, in the form `schedule add --at` takes
* `--every <DURATION>` — New time between fires, in the form `schedule add --every` takes
* `--once` — Make it a one-shot
* `--add-agent <AGENT>` — Agent to start firing it in. Repeatable
* `--remove-agent <AGENT>` — Agent to stop firing it in. Repeatable. Fails if the agent isn't a target, or if no agent would remain
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl schedule pause`
Stop a scheduled prompt firing. Times that pass while it's paused aren't fired when it resumes
**Usage:** `swarmctl schedule pause [OPTIONS] <ID>`
###### **Arguments:**
* `<ID>` — The prompt's id, as `schedule list` shows it
###### **Options:**
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl schedule resume`
Let a paused scheduled prompt fire again, from its next time on
**Usage:** `swarmctl schedule resume [OPTIONS] <ID>`
###### **Arguments:**
* `<ID>` — The prompt's id, as `schedule list` shows it
###### **Options:**
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl schedule rm`
Remove a scheduled prompt from every agent it names
**Usage:** `swarmctl schedule rm [OPTIONS] <ID>`
###### **Arguments:**
* `<ID>` — The prompt's id, as `schedule list` shows it
###### **Options:**
* `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
## `swarmctl user`
Manage subjects in the swarm's SSO provider

View file

@ -37,10 +37,10 @@ rusqlite.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
# Bare: `kv`/`notices` name buckets and streams this harness opens neither end
# of. The terminal publisher is a plain core-subject publish, so it needs the
# connect and the payload limit and nothing from JetStream.
swarm-queue-client.workspace = true
# `agent-schedules` alone: the schedule ticker reads its own key with one direct
# get. `kv`/`notices` name buckets and streams this harness opens neither end
# of, and the terminal publisher is a plain core-subject publish.
swarm-queue-client = { workspace = true, features = ["agent-schedules"] }
# This agent's own queue secret, read from the store under its own certificate.
swarm-secret-client.workspace = true
tokio.workspace = true

View file

@ -25,6 +25,7 @@ mod plugins;
mod prompt;
mod reminder_timer;
mod reminders;
mod schedule_timer;
mod serve_common;
mod state_entry_watch;
mod stats;
@ -479,6 +480,9 @@ fn spawn_todo_socket(
// Same shape again: nudge (and feed an OTEL gauge) on a
// grown CLAUDE.md instead of a grown state dir.
tokio::spawn(claude_md_watch::run(store.clone(), todo_wake.clone()));
// Swarm-scheduled prompts fire as todos too, from the list the
// swarm keeps for this agent.
tokio::spawn(schedule_timer::run(store.clone(), todo_wake.clone()));
Some(store)
}
Err(e) => {

View file

@ -0,0 +1,388 @@
//! Fires this agent's swarm-scheduled prompts, in its own container.
//!
//! swarm-controller keeps the agent's list under its key in the
//! `agent-schedules` KV bucket. This task reads that key every
//! [`POLL_INTERVAL`] with the one subject the agent's own queue credential is
//! granted there (`swarm_queue_client::agent_schedules::read_own`), and fires
//! each due schedule as the same `schedule`-subsystem todo, keyed
//! `schedule:<id>`, that hive-c0re's schedule worker pushes. The wake path
//! is the todo store's.
//!
//! **Fire times come from the schedule alone**: `anchor + k * interval`
//! ([`Schedule::latest_slot`]). Rewriting the list, or changing a body,
//! leaves them where they were.
//!
//! **No duplicates.** The last slot fired for each schedule id is kept in the
//! state db ([`FiredSlots`]), and a slot fires only when it is later than
//! that. After downtime the latest missed slot fires once and the earlier
//! ones not at all; a restart refires nothing already recorded. A one-shot
//! has one slot, so it fires once however long it stays listed. Records are
//! never dropped when a schedule leaves the list: ids are never reused, and
//! a list that reads empty for a moment must not erase what was fired.
//!
//! **Paused** schedules do not fire, and each slot that passes while one is
//! paused is recorded as if fired, so resuming does not fire it.
//!
//! The list itself is held in memory only: until the first successful read
//! after a start, nothing fires. Reading needs this agent's own credential;
//! on the hive's shared client the task logs that and stops.
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Context as _, Result};
use rusqlite::{Connection, OptionalExtension as _, params};
use swarm_queue_client::agent_schedules::{AgentSchedules, Read, Schedule, read_own};
use tokio::sync::Notify;
use crate::swarm_queue::{Connection as QueueConnection, Presented};
use crate::todos::Todos;
/// How often the key is read and the list checked for due slots. Also the
/// longest a fire can lag its slot, and a change can take to reach the agent.
const POLL_INTERVAL: Duration = Duration::from_secs(10);
/// The todo subsystem and source hive-c0re's schedule worker uses.
const SUBSYSTEM: &str = "schedule";
const SOURCE: &str = "scheduled";
const SCHEMA: &str = r"
CREATE TABLE IF NOT EXISTS schedule_fired_slots (
schedule_id TEXT PRIMARY KEY,
slot_unix INTEGER NOT NULL
);
";
/// The last slot fired, or passed while paused, per schedule id.
pub struct FiredSlots {
conn: Mutex<Connection>,
}
impl FiredSlots {
/// Open (creating if needed) the table in the db at `path`.
///
/// # Errors
///
/// Propagates sqlite open / schema-apply failures.
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)
.with_context(|| format!("open schedule slots db {}", path.display()))?;
conn.execute_batch(SCHEMA)
.context("apply schedule slots schema")?;
Ok(Self {
conn: Mutex::new(conn),
})
}
fn last(&self, id: &str) -> Result<Option<i64>> {
let conn = self
.conn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Ok(conn
.query_row(
"SELECT slot_unix FROM schedule_fired_slots WHERE schedule_id = ?1",
params![id],
|row| row.get(0),
)
.optional()?)
}
fn record(&self, id: &str, slot: i64) -> Result<()> {
let conn = self
.conn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
conn.execute(
"INSERT INTO schedule_fired_slots (schedule_id, slot_unix) VALUES (?1, ?2) \
ON CONFLICT(schedule_id) DO UPDATE SET slot_unix = excluded.slot_unix",
params![id, slot],
)?;
Ok(())
}
}
/// What one schedule needs at `now`.
#[derive(Debug, PartialEq, Eq)]
enum Step {
/// Fire, then record the slot.
Fire(i64),
/// Record the slot without firing: it passed while paused.
Skip(i64),
Nothing,
}
fn decide(schedule: &Schedule, last: Option<i64>, now: i64) -> Step {
let Some(slot) = schedule.latest_slot(now) else {
return Step::Nothing;
};
if last.is_some_and(|last| last >= slot) {
return Step::Nothing;
}
if schedule.paused {
Step::Skip(slot)
} else {
Step::Fire(slot)
}
}
/// Apply [`decide`] to every schedule in `list`. `fire` returns whether the
/// fire landed; a slot is recorded only after it did, so a failed fire is
/// retried on the next tick.
fn tick(
schedules: &AgentSchedules,
slots: &FiredSlots,
now: i64,
mut fire: impl FnMut(&Schedule) -> bool,
) {
for schedule in &schedules.schedules {
let previous = match slots.last(&schedule.id) {
Ok(previous) => previous,
Err(e) => {
tracing::warn!(id = %schedule.id, error = ?e, "schedule: reading the fired slot failed");
continue;
}
};
let slot = match decide(schedule, previous, now) {
Step::Nothing => continue,
Step::Skip(slot) => slot,
Step::Fire(slot) => {
if !fire(schedule) {
continue;
}
tracing::info!(id = %schedule.id, slot, "schedule fired");
slot
}
};
if let Err(e) = slots.record(&schedule.id, slot) {
tracing::warn!(id = %schedule.id, slot, error = ?e, "schedule: recording the slot failed");
}
}
}
/// Push `schedule` as the todo hive-c0re's worker pushes, and wake the turn
/// loop when it is new or reopened.
fn push_todo(todos: &Todos, wake: &Notify, schedule: &Schedule) -> bool {
let key = format!("schedule:{}", schedule.id);
// `true`: each fire is a new occurrence, so an acked earlier one reopens.
match todos.upsert(SUBSYSTEM, Some(&key), &schedule.body, Some(SOURCE), true) {
Ok((_, changed)) => {
if changed {
wake.notify_one();
}
true
}
Err(e) => {
tracing::warn!(id = %schedule.id, error = ?e, "schedule: pushing the todo failed");
false
}
}
}
/// Run the ticker for the harness's lifetime, if this agent has a queue
/// credential and a label to name its key with.
pub async fn run(todos: Arc<Todos>, wake: Arc<Notify>) {
if !crate::swarm_queue::configured() {
return;
}
let agent = crate::identity::label();
if agent.is_empty() {
tracing::warn!("this agent has no label; not reading its swarm schedules");
return;
}
let slots = match FiredSlots::open(&crate::paths::state_db()) {
Ok(slots) => slots,
Err(e) => {
tracing::error!(error = ?e, "open schedule slots failed — swarm schedules disabled");
return;
}
};
let Some(QueueConnection { client, presented }) = crate::swarm_queue::client().await else {
return;
};
if presented != Presented::Agent {
tracing::warn!(
"connected with the hive's shared client, which has no read grant on the \
agent-schedules bucket; swarm schedules are not read"
);
return;
}
tracing::info!(%agent, "reading swarm schedules from the swarm queue");
let mut list: Option<AgentSchedules> = None;
loop {
refresh(&client, &agent, &mut list).await;
if let Some(list) = &list {
let now = chrono::Utc::now().timestamp();
tick(list, &slots, now, |s| push_todo(&todos, &wake, s));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
/// Replace `list` with the key's current value. On any failure the previous
/// list stays, so schedules keep firing through a queue outage.
async fn refresh(client: &async_nats::Client, agent: &str, list: &mut Option<AgentSchedules>) {
// An unconnected client does not fail a JetStream request, it hangs on it.
if swarm_queue_client::ensure_connected(client).is_err() {
return;
}
match read_own(client, agent).await {
Ok(Read::Current(current)) => *list = Some(current),
Ok(Read::NoBucket) => {
tracing::debug!("schedule: the agent-schedules bucket does not answer yet");
}
Err(e) => tracing::warn!(error = %e, "schedule: reading this agent's schedules failed"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn schedule(id: &str, anchor: i64, interval: Option<u64>) -> Schedule {
Schedule {
id: id.to_owned(),
body: format!("{id} body"),
anchor_unix: anchor,
interval_seconds: interval,
paused: false,
}
}
fn list(schedules: Vec<Schedule>) -> AgentSchedules {
AgentSchedules { schedules }
}
fn slots() -> FiredSlots {
FiredSlots::open(Path::new(":memory:")).unwrap()
}
/// The bodies fired by one tick at `now`.
fn fired(list: &AgentSchedules, slots: &FiredSlots, now: i64) -> Vec<String> {
let mut out = Vec::new();
tick(list, slots, now, |s| {
out.push(s.body.clone());
true
});
out
}
#[test]
fn a_body_edit_does_not_move_the_cadence() {
let slots = slots();
let mut l = list(vec![schedule("a", 1000, Some(100))]);
assert_eq!(fired(&l, &slots, 1000), ["a body"]);
"edited".clone_into(&mut l.schedules[0].body);
assert!(fired(&l, &slots, 1050).is_empty());
assert!(fired(&l, &slots, 1099).is_empty());
assert_eq!(fired(&l, &slots, 1100), ["edited"]);
}
#[test]
fn an_offline_gap_of_several_slots_fires_once_then_rejoins_the_grid() {
let slots = slots();
let l = list(vec![schedule("a", 1000, Some(100))]);
assert_eq!(fired(&l, &slots, 1000), ["a body"]);
// Slots 1100 through 1500 pass unseen.
assert_eq!(fired(&l, &slots, 1550), ["a body"]);
assert!(fired(&l, &slots, 1560).is_empty());
assert!(fired(&l, &slots, 1599).is_empty());
assert_eq!(fired(&l, &slots, 1600), ["a body"]);
}
#[test]
fn a_restart_after_a_fire_does_not_refire() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("state.sqlite");
let l = list(vec![schedule("a", 1000, Some(100))]);
let before = FiredSlots::open(&db).unwrap();
assert_eq!(fired(&l, &before, 1010), ["a body"]);
drop(before);
let after = FiredSlots::open(&db).unwrap();
assert!(fired(&l, &after, 1020).is_empty());
// Control: the reopened store still fires the next slot.
assert_eq!(fired(&l, &after, 1100), ["a body"]);
}
/// An agent dropped from a schedule and added back gets the same id, so
/// the slot it already fired stays fired.
#[test]
fn leaving_and_rejoining_a_schedule_does_not_refire() {
let slots = slots();
let l = list(vec![schedule("a", 1000, Some(100))]);
assert_eq!(fired(&l, &slots, 1000), ["a body"]);
assert!(fired(&list(Vec::new()), &slots, 1020).is_empty());
assert!(fired(&l, &slots, 1040).is_empty());
assert_eq!(fired(&l, &slots, 1100), ["a body"]);
}
#[test]
fn a_list_refresh_does_not_refire() {
let slots = slots();
let l = list(vec![
schedule("a", 1000, Some(100)),
schedule("b", 900, None),
]);
assert_eq!(fired(&l, &slots, 1000), ["a body", "b body"]);
let refreshed: AgentSchedules =
serde_json::from_slice(&serde_json::to_vec(&l).unwrap()).unwrap();
assert!(fired(&refreshed, &slots, 1001).is_empty());
// An empty read in between erases nothing.
assert!(fired(&list(Vec::new()), &slots, 1002).is_empty());
assert!(fired(&refreshed, &slots, 1003).is_empty());
}
#[test]
fn a_one_shot_fires_once() {
let slots = slots();
let l = list(vec![schedule("once", 1000, None)]);
assert!(fired(&l, &slots, 999).is_empty());
assert_eq!(fired(&l, &slots, 1000), ["once body"]);
assert!(fired(&l, &slots, 1001).is_empty());
assert!(fired(&l, &slots, 1_000_000).is_empty());
}
#[test]
fn a_paused_schedule_does_not_fire_or_catch_up() {
let slots = slots();
let mut l = list(vec![schedule("a", 1000, Some(100))]);
l.schedules[0].paused = true;
assert!(fired(&l, &slots, 1000).is_empty());
assert!(fired(&l, &slots, 1250).is_empty());
l.schedules[0].paused = false;
// Slot 1200 passed while paused, so resuming does not fire it.
assert!(fired(&l, &slots, 1260).is_empty());
assert_eq!(fired(&l, &slots, 1300), ["a body"]);
}
#[test]
fn a_failed_fire_is_retried_and_not_recorded() {
let slots = slots();
let l = list(vec![schedule("a", 1000, Some(100))]);
tick(&l, &slots, 1000, |_| false);
assert_eq!(fired(&l, &slots, 1005), ["a body"]);
}
/// The same todo hive-c0re's worker pushes, so the wake path does not
/// change: subsystem `schedule`, key `schedule:<id>`, source
/// `scheduled`, and a reopen once the agent acked the previous fire.
#[test]
fn a_fire_is_the_hive_c0re_schedule_todo() {
let todos = Todos::open(Path::new(":memory:")).unwrap();
let wake = Notify::new();
let s = schedule("abcdefgh", 1000, Some(100));
assert!(push_todo(&todos, &wake, &s));
let listed = todos.list(Some(SUBSYSTEM)).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(
listed[0].subsystem_key.as_deref(),
Some("schedule:abcdefgh")
);
assert_eq!(listed[0].summary, "abcdefgh body");
assert_eq!(listed[0].source.as_deref(), Some(SOURCE));
todos.mark_done(listed[0].id).unwrap();
assert!(push_todo(&todos, &wake, &s));
assert_eq!(todos.list(Some(SUBSYSTEM)).unwrap().len(), 1);
}
}

View file

@ -850,6 +850,13 @@ in
# `term-sub-{agent}` stream that keeps them, so its subjects
# and limits are never the agent's to choose.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$SWARM.term.{agent}.sub.>"}"
# Reading its own key of the `agent-schedules` KV bucket
# (`swarm_queue_client::agent_schedules`), and nothing else of
# JetStream: a direct get names the key in its subject, so
# this one subject is one key. No `STREAM.INFO`, no
# `CONSUMER.CREATE` (a watch's create subject carries no key,
# so it would cover every agent's) and no `$KV` write.
"--agent-token-publish-subject ${lib.escapeShellArg "\$\$JS.API.DIRECT.GET.KV_agent-schedules.\$\$KV.agent-schedules.{agent}"}"
"--store-cert-role ${lib.escapeShellArg authCertRole}"
];
# Every credential arrives by `LoadCredential` and is named

View file

@ -165,7 +165,7 @@ let
# The whole per-agent grant, compared as a list rather than by infix: a
# wider subject added beside these (`$$JS.API.>`, another stream's name)
# would pass every presence check above.
name = "a verified agent's grant is exactly its own subjects, with no JetStream API subject";
name = "a verified agent's grant is exactly its own subjects, and its one JetStream API subject reads its own schedule key";
ok =
let
args = lib.splitString " " (responderOf natsOldPath).serviceConfig.ExecStart;
@ -183,6 +183,7 @@ let
"'$$SWARM.agent-state.{agent}'"
"'$$KV.agent-icons.{agent}'"
"'$$SWARM.term.{agent}.sub.>'"
"'$$JS.API.DIRECT.GET.KV_agent-schedules.$$KV.agent-schedules.{agent}'"
];
}
{

View file

@ -41,6 +41,11 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`).
- **Swarm-wide forge objects and webhooks** →
[`docs/swarm/README.md`](../docs/swarm/README.md#swarm-wide-forge-objects).
- **Scheduled prompts** — named prompts for one or more agents, kept in
the `schedule-definitions` KV bucket and written into each agent's key of
`agent-schedules`, both of which this daemon alone writes
(`GET`/`POST /api/schedules`, `PATCH`/`DELETE /api/schedules/{id}`); each
agent fires them itself → [`docs/swarm/schedules.md`](../docs/swarm/schedules.md).
- **Relays** — each agent's terminal and turn-state header as SSE, agent
icons, the cross-repo issue report, and the UI's quick links.

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 {

View file

@ -363,12 +363,14 @@ mod tests {
"$KV.agent-icons.{agent}",
"--agent-token-publish-subject",
"$SWARM.term.{agent}.sub.>",
"--agent-token-publish-subject",
"$JS.API.DIRECT.GET.KV_agent-schedules.$KV.agent-schedules.{agent}",
"--store-cert-role",
"swarm-nats-auth",
])
.expect("the unit's own argument vector must parse");
assert_eq!(args.agent_client_suffix, "-agent");
assert_eq!(args.agent_token_publish_subjects.len(), 4);
assert_eq!(args.agent_token_publish_subjects.len(), 5);
}
/// The control for the case above: an ordinary value parses through the

View file

@ -1290,6 +1290,77 @@ mod tests {
);
}
/// The schedule template as `swarm-nats.nix` spells it reads one key: the
/// agent's own. It covers no other agent's key, no other stream or bucket,
/// and no way to open, watch or write the bucket. The hive's shared agent
/// client gets none of it.
#[test]
fn an_agent_may_read_only_its_own_schedule_key() {
use swarm_queue_client::agent_schedules::{
BUCKET, STREAM, direct_get_subject, key_subject,
};
let p = policy_with_agent_subject()
.with_agent_token_subjects(vec![
"$JS.API.DIRECT.GET.KV_agent-schedules.$KV.agent-schedules.{agent}".to_owned(),
])
.expect("a per-agent template is valid");
let atlas = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(atlas.publish, vec![direct_get_subject("atlas")]);
// Control: argus's own expansion is a different subject, so atlas
// lacking it is the grant being scoped, not the template being empty.
let argus = p.agent_token_permissions("argus").expect("configured");
assert_eq!(argus.publish, vec![direct_get_subject("argus")]);
assert!(!atlas.publish.contains(&direct_get_subject("argus")));
let covers = |grant: &str, subject: &str| {
let g: Vec<&str> = grant.split('.').collect();
let s: Vec<&str> = subject.split('.').collect();
for (i, t) in g.iter().enumerate() {
match *t {
">" => return s.len() > i,
"*" if i < s.len() => {}
t if s.get(i) == Some(&t) => {}
_ => return false,
}
}
g.len() == s.len()
};
for refused in [
direct_get_subject("argus"),
format!("$JS.API.DIRECT.GET.{STREAM}"),
format!("$JS.API.STREAM.INFO.{STREAM}"),
format!("$JS.API.STREAM.CREATE.{STREAM}"),
format!("$JS.API.STREAM.DELETE.{STREAM}"),
format!("$JS.API.STREAM.MSG.GET.{STREAM}"),
format!("$JS.API.CONSUMER.CREATE.{STREAM}"),
format!(
"$JS.API.CONSUMER.CREATE.{STREAM}.w.{}",
key_subject("atlas")
),
key_subject("atlas"),
key_subject("argus"),
"$JS.API.DIRECT.GET.KV_agent-icons.$KV.agent-icons.atlas".to_owned(),
"$JS.API.DIRECT.GET.KV_hive-wanted-alpha.$KV.hive-wanted-alpha.alpha".to_owned(),
"$JS.API.INFO".to_owned(),
] {
assert!(
!atlas.publish.iter().any(|g| covers(g, &refused)),
"atlas's grant covers {refused}"
);
}
// Control: the matcher does match, so the refusals above are not
// vacuous.
assert!(covers(&atlas.publish[0], &direct_get_subject("atlas")));
assert!(covers("$KV.*.>", &key_subject("argus")));
let shared = p.permissions("hive-alpha-agent").expect("alpha's agents");
assert!(
!shared.publish.iter().any(|s| s.contains(BUCKET)),
"the hive's shared agent client got a schedule subject: {shared:?}"
);
}
#[test]
fn an_agent_token_subject_without_the_placeholder_is_refused() {
let err = policy()

View file

@ -33,6 +33,10 @@ notices = ["async-nats/jetstream"]
# `subagent_term::open_or_create`, for swarm-controller, which creates every
# agent's subagent stream. Explicit for the same reason as `notices`.
subagent-term = ["async-nats/jetstream"]
# `agent_schedules::read_own`, for the agent harness, which reads its own
# schedule key without opening the bucket. Explicit for the same reason as
# `notices`.
agent-schedules = ["async-nats/jetstream"]
[dependencies]
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature

View file

@ -0,0 +1,234 @@
//! The per-agent schedule KV bucket: one key per agent, holding the scheduled
//! prompts that fire in that agent.
//!
//! **swarm-controller is the only writer**, and creates the bucket on its
//! first write. It keeps each prompt once, with its name and every agent it
//! goes to, and writes each agent's key as the prompts naming that agent,
//! without the name or the other agents. Each agent's harness reads its own
//! key and fires the schedules itself; the fire times are a pure function of
//! each schedule ([`crate::agent_schedules::Schedule::latest_slot`]), so
//! rewriting the list never moves them.
//!
//! **An agent reads with one `DIRECT.GET` on its own key, not a watch.** A KV
//! watch is an unnamed consumer, whose create subject carries no filter, so
//! granting it would grant every agent's key. The direct get carries the key
//! in its subject ([`crate::agent_schedules::direct_get_subject`]), which is
//! what lets the queue grant scope the read to one agent. It is also the
//! agent's whole `JetStream` grant: `read_own` builds the stream handle
//! without a `STREAM.INFO`.
//!
//! Keyed by the agent alone, like [`crate::agent_icon`], and for the same
//! reason: an agent can move between hives.
/// The KV bucket schedules are published into, keyed by agent name.
pub const BUCKET: &str = "agent-schedules";
/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`.
pub const STREAM: &str = "KV_agent-schedules";
/// The subject `agent`'s entry is stored under.
#[must_use]
pub fn key_subject(agent: &str) -> String {
format!("$KV.{BUCKET}.{agent}")
}
/// The request subject that reads `agent`'s current entry, and so the one
/// subject the queue grants that agent in this bucket.
#[must_use]
pub fn direct_get_subject(agent: &str) -> String {
format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent))
}
/// The prompts that fire in one agent: the value under `BUCKET`/`<agent>`.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentSchedules {
#[serde(default)]
pub schedules: Vec<Schedule>,
}
/// One scheduled prompt.
///
/// Its fire times are `anchor_unix + k * interval_seconds` for `k >= 0`, or
/// `anchor_unix` alone when there is no interval.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Schedule {
/// Minted by the controller, the same in every agent the prompt names,
/// and never reused, so an agent's record of what it last fired for an
/// id cannot apply to a different schedule.
pub id: String,
/// The prompt, delivered verbatim as the todo's summary.
pub body: String,
/// The first fire time, unix seconds.
pub anchor_unix: i64,
/// Seconds between fires; `None` is a one-shot.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>,
/// A paused schedule does not fire, and the slots that pass while it is
/// paused are not fired when it resumes.
#[serde(default)]
pub paused: bool,
}
impl Schedule {
/// The latest fire time at or before `now`, or `None` while the first is
/// still ahead.
#[must_use]
pub fn latest_slot(&self, now: i64) -> Option<i64> {
if now < self.anchor_unix {
return None;
}
let Some(interval) = self
.interval_seconds
.filter(|i| *i > 0)
.and_then(|i| i64::try_from(i).ok())
else {
return Some(self.anchor_unix);
};
let elapsed = now - self.anchor_unix;
Some(self.anchor_unix + elapsed - elapsed % interval)
}
}
/// Open the schedule bucket for writing, creating it if nothing has yet.
///
/// **Controller-side only.** `history: 1`: an agent wants its current list.
#[cfg(feature = "kv")]
pub async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, crate::Error> {
let js = async_nats::jetstream::new(client.clone());
match js.get_key_value(BUCKET).await {
Ok(store) => Ok(store),
Err(e) => {
tracing::info!(
bucket = BUCKET,
reason = %e,
"agent-schedule bucket not available, creating it"
);
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: BUCKET.to_owned(),
description: "Scheduled prompts the swarm controller declares for each agent"
.to_owned(),
history: 1,
..Default::default()
})
.await
.map_err(|source| crate::Error::CreateBucket {
bucket: BUCKET.to_owned(),
source,
})
}
}
}
/// What reading one agent's key found.
#[cfg(feature = "agent-schedules")]
#[derive(Debug)]
pub enum Read {
/// The bucket answered; an absent or deleted key is an empty list.
Current(AgentSchedules),
/// Nothing answers for the bucket's stream: no schedule has been written
/// yet, or `JetStream` is not serving it right now. Not an empty list,
/// since the second case says nothing about the agent's schedules.
NoBucket,
}
/// Read `agent`'s current list with the one subject its grant covers.
///
/// # Errors
///
/// A failed request or an undecodable value. A request the queue refused for
/// permissions reaches the client as a timeout, not as a refusal.
#[cfg(feature = "agent-schedules")]
pub async fn read_own(
client: &async_nats::Client,
agent: &str,
) -> Result<Read, Box<dyn std::error::Error + Send + Sync>> {
use async_nats::jetstream::stream::DirectGetErrorKind;
let js = async_nats::jetstream::new(client.clone());
let stream = js.get_stream_no_info(STREAM).await?;
let message = match stream.direct_get_last_for_subject(key_subject(agent)).await {
Ok(message) => message,
Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => {
return Ok(Read::Current(AgentSchedules::default()));
}
Err(e) if is_no_responders(&e) => return Ok(Read::NoBucket),
Err(e) => return Err(e.into()),
};
// A KV delete or purge is stored as an empty marker carrying this header.
let removed = message
.headers
.get("KV-Operation")
.is_some_and(|op| matches!(op.as_str(), "DEL" | "PURGE"));
if removed {
return Ok(Read::Current(AgentSchedules::default()));
}
Ok(Read::Current(serde_json::from_slice(&message.payload)?))
}
#[cfg(feature = "agent-schedules")]
fn is_no_responders(e: &async_nats::jetstream::stream::DirectGetError) -> bool {
matches!(
e.kind(),
async_nats::jetstream::stream::DirectGetErrorKind::ErrorResponse(status, _)
if status == async_nats::StatusCode::NO_RESPONDERS
)
}
#[cfg(test)]
mod tests {
use super::*;
fn every(anchor_unix: i64, interval: Option<u64>) -> Schedule {
Schedule {
id: "abc".to_owned(),
body: "hi".to_owned(),
anchor_unix,
interval_seconds: interval,
paused: false,
}
}
/// The names the grant in `swarm-nats.nix` spells with `{agent}`.
#[test]
fn names_match_the_queue_grant() {
assert_eq!(STREAM, format!("KV_{BUCKET}"));
assert_eq!(key_subject("iris"), "$KV.agent-schedules.iris");
assert_eq!(
direct_get_subject("iris"),
"$JS.API.DIRECT.GET.KV_agent-schedules.$KV.agent-schedules.iris"
);
}
#[test]
fn slots_sit_on_the_anchor_grid() {
let s = every(1000, Some(60));
assert_eq!(s.latest_slot(999), None);
assert_eq!(s.latest_slot(1000), Some(1000));
assert_eq!(s.latest_slot(1059), Some(1000));
assert_eq!(s.latest_slot(1060), Some(1060));
assert_eq!(s.latest_slot(1000 + 60 * 7 + 5), Some(1000 + 60 * 7));
}
#[test]
fn a_one_shot_has_one_slot() {
let s = every(1000, None);
assert_eq!(s.latest_slot(999), None);
assert_eq!(s.latest_slot(1000), Some(1000));
assert_eq!(s.latest_slot(1_000_000), Some(1000));
}
/// Fields a newer controller adds must not cost an older agent its list,
/// and a list written without the optional fields decodes to a recurring
/// or one-shot schedule that is not paused.
#[test]
fn decoding_tolerates_missing_and_unknown_fields() {
let doc = r#"{"schedules":[{"id":"a","body":"b","anchor_unix":5,"note":"x"}],"rev":2}"#;
let decoded: AgentSchedules = serde_json::from_str(doc).expect("decodes");
let mut want = every(5, None);
"a".clone_into(&mut want.id);
"b".clone_into(&mut want.body);
assert_eq!(decoded.schedules, vec![want]);
}
}

View file

@ -196,6 +196,11 @@ pub mod agent_token;
/// placement stays out of the key, and for who may write it.
pub mod agent_icon;
/// The bucket swarm-controller declares each agent's scheduled prompts in —
/// one key per agent, read by that agent alone. The names are unconditional;
/// writing needs `kv` and reading needs `agent-schedules`.
pub mod agent_schedules;
/// The subject the swarm controller publishes on when the hive-wide knowledge
/// repository has changed. One writer, many readers — every hive subscribes.
///

View file

@ -10,6 +10,7 @@ path = "src/main.rs"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
# `agent create` — bare HTTP/1.1 client over swarm-controller's unix
# socket. No new workspace dependency: `bytes`, `http-body-util`,
# `hyper` and `hyper-util` are all already pinned in the root manifest.

View file

@ -153,3 +153,19 @@ person's on their first login through authelia, and until then this fails
saying so. Running it on a site admin changes nothing, and an agent's name is
refused. The response shape is mirrored in `src/forge.rs`, for the same
reason as `agent create`'s.
## `schedule`
```console
# swarmctl schedule add --name standup --agent scribe --body "post the standup summary" --every 1d
# swarmctl schedule list --agent scribe
# swarmctl schedule edit kqzpmwab --at 2026-10-05T09:00:00+02:00 --add-agent iris
# swarmctl schedule pause kqzpmwab
# swarmctl schedule rm kqzpmwab
```
The `/api/schedules` routes on the swarm-controller, over the
same socket as `agent create`; each verb waits for the stored result. What a
schedule does once stored is in
[`docs/swarm/schedules.md`](../docs/swarm/schedules.md). The shapes are
mirrored in `src/schedule.rs`, for the same reason as `agent create`'s.

View file

@ -184,8 +184,23 @@ pub(crate) async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
request: &Req,
what: &str,
) -> Result<Resp> {
let body =
serde_json::to_vec(request).with_context(|| format!("serialising the {what} request"))?;
send(socket, hyper::Method::POST, uri, Some(request), what).await
}
/// One request to `uri` over the controller's unix socket, with `request` as
/// its JSON body when there is one. A `204` decodes as JSON `null`, so a
/// caller expecting no body asks for `()`.
pub(crate) async fn send<Req: Serialize, Resp: serde::de::DeserializeOwned>(
socket: &Path,
method: hyper::Method,
uri: &str,
request: Option<&Req>,
what: &str,
) -> Result<Resp> {
let body = request
.map(serde_json::to_vec)
.transpose()
.with_context(|| format!("serialising the {what} request"))?;
let stream = UnixStream::connect(socket)
.await
@ -193,19 +208,22 @@ pub(crate) async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream))
.await
.with_context(|| format!("HTTP handshake with {}", socket.display()))?;
// Drives the connection's I/O; a one-shot POST has nothing to do with
// Drives the connection's I/O; a one-shot request has nothing to do with
// the join handle, and a connection that dies surfaces as an error on
// `send_request` or on reading the body.
tokio::spawn(conn);
let req = hyper::Request::builder()
.method(hyper::Method::POST)
let mut req = hyper::Request::builder()
.method(method)
.uri(uri)
// A unix socket has no authority of its own, but HTTP/1.1 requires
// the header; the controller routes on the path alone.
.header(hyper::header::HOST, "localhost")
.header(hyper::header::CONTENT_TYPE, "application/json")
.body(Full::new(bytes::Bytes::from(body)))
.header(hyper::header::HOST, "localhost");
if body.is_some() {
req = req.header(hyper::header::CONTENT_TYPE, "application/json");
}
let req = req
.body(Full::new(bytes::Bytes::from(body.unwrap_or_default())))
.with_context(|| format!("building the {what} request"))?;
let resp = sender
@ -223,6 +241,11 @@ pub(crate) async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
if !status.is_success() {
bail!("{}", describe_error(status, &body));
}
let body = if status == hyper::StatusCode::NO_CONTENT {
bytes::Bytes::from_static(b"null")
} else {
body
};
serde_json::from_slice(&body).with_context(|| {
format!(
"swarm-controller answered {status} but its body is not a {what} response: {}",

View file

@ -29,6 +29,7 @@
mod agent;
mod forge;
mod schedule;
mod users;
use std::fs::{self, File, Permissions};
@ -119,6 +120,16 @@ enum Verb {
#[command(subcommand)]
command: AgentVerb,
},
/// Manage scheduled prompts.
///
/// The swarm keeps each prompt, and every agent it names fires it in its
/// own container. Fire times are the schedule's start time plus whole
/// intervals: changing the body never moves them, and an agent that was
/// down fires the latest missed one once.
Schedule {
#[command(subcommand)]
command: ScheduleVerb,
},
/// Manage subjects in the swarm's SSO provider.
User {
#[command(subcommand)]
@ -276,6 +287,119 @@ struct ForgeMakeAdminArgs {
controller_socket: Option<PathBuf>,
}
#[derive(Subcommand)]
enum ScheduleVerb {
/// Add a scheduled prompt and print it with its new id.
///
/// Without `--every` it fires once. Without `--at` its first fire is now.
Add(ScheduleAddArgs),
/// List scheduled prompts, one row each, with their agents and next fire
/// time.
List(ScheduleListArgs),
/// Change a scheduled prompt.
///
/// Only the flags given change, and the id stays. `--at` and `--every`
/// move the fire times; `--name`, `--body` and the agent flags don't.
Edit(ScheduleEditArgs),
/// Stop a scheduled prompt firing. Times that pass while it's paused
/// aren't fired when it resumes.
Pause(ScheduleIdArgs),
/// Let a paused scheduled prompt fire again, from its next time on.
Resume(ScheduleIdArgs),
/// Remove a scheduled prompt from every agent it names.
Rm(ScheduleIdArgs),
}
#[derive(Args)]
struct ScheduleAddArgs {
/// The prompt's title.
#[arg(long, value_name = "TEXT")]
name: String,
/// Agent to fire it in. Repeatable; at least one.
#[arg(long = "agent", value_name = "AGENT", required = true)]
agents: Vec<String>,
/// The prompt, delivered to each agent verbatim.
#[arg(long, value_name = "TEXT")]
body: String,
/// First fire time: an RFC 3339 time with its offset, such as
/// `2026-10-05T09:00:00+02:00`, or unix seconds. Defaults to now.
#[arg(long, value_name = "TIME")]
at: Option<String>,
/// Time between fires: a count with a unit of `s`, `m`, `h` or `d`,
/// such as `1h` or `7d`. Leave out for a one-shot.
#[arg(long, value_name = "DURATION")]
every: Option<String>,
/// Add it paused.
#[arg(long)]
paused: bool,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleListArgs {
/// Show only the prompts that fire in this agent.
#[arg(long, value_name = "AGENT")]
agent: Option<String>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleEditArgs {
/// The prompt's id, as `schedule list` shows it.
id: String,
/// New title.
#[arg(long, value_name = "TEXT")]
name: Option<String>,
/// New prompt text.
#[arg(long, value_name = "TEXT")]
body: Option<String>,
/// New first fire time, in the form `schedule add --at` takes.
#[arg(long, value_name = "TIME")]
at: Option<String>,
/// New time between fires, in the form `schedule add --every` takes.
#[arg(long, value_name = "DURATION", conflicts_with = "once")]
every: Option<String>,
/// Make it a one-shot.
#[arg(long)]
once: bool,
/// Agent to start firing it in. Repeatable.
#[arg(long = "add-agent", value_name = "AGENT")]
add_agents: Vec<String>,
/// Agent to stop firing it in. Repeatable. Fails if the agent isn't a
/// target, or if no agent would remain.
#[arg(long = "remove-agent", value_name = "AGENT")]
remove_agents: Vec<String>,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ScheduleIdArgs {
/// The prompt's id, as `schedule list` shows it.
id: String,
#[command(flatten)]
socket: ControllerSocketArg,
}
#[derive(Args)]
struct ControllerSocketArg {
/// swarm-controller's unix socket.
///
/// Supplied by the nix module that installs this binary, from the same
/// `socketPath` option the daemon binds; falls back to
/// `SWARM_CONTROLLER_SOCKET`.
#[arg(long, value_name = "PATH")]
controller_socket: Option<PathBuf>,
}
impl ControllerSocketArg {
fn resolve(self) -> Result<PathBuf> {
path_from(self.controller_socket, "SWARM_CONTROLLER_SOCKET")
}
}
#[derive(Subcommand)]
enum UserVerb {
/// Add a user, generating a password for them.
@ -379,6 +503,8 @@ fn main() -> Result<()> {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
forge::make_admin(&socket, &args.name)
}
// Same socket-resolution reasoning as `Create` above.
Verb::Schedule { command } => schedule(command),
// Resolved lazily, inside the one arm that actually touches the
// deployment env vars — see the `MarkdownDocs` doc comment above
// for why an unconditional resolve up front would be wrong.
@ -418,6 +544,38 @@ fn main() -> Result<()> {
}
}
fn schedule(command: ScheduleVerb) -> Result<()> {
match command {
ScheduleVerb::Add(a) => schedule::add(
&a.socket.resolve()?,
&schedule::AddFlags {
name: &a.name,
agents: &a.agents,
body: &a.body,
at: a.at.as_deref(),
every: a.every.as_deref(),
paused: a.paused,
},
),
ScheduleVerb::List(a) => schedule::list(&a.socket.resolve()?, a.agent.as_deref()),
ScheduleVerb::Edit(a) => {
let edit = schedule::EditRequest::new(schedule::EditFlags {
name: a.name,
body: a.body,
at: a.at.as_deref(),
every: a.every.as_deref(),
once: a.once,
add_agents: a.add_agents,
remove_agents: a.remove_agents,
})?;
schedule::edit(&a.socket.resolve()?, &a.id, &edit)
}
ScheduleVerb::Pause(a) => schedule::set_paused(&a.socket.resolve()?, &a.id, true),
ScheduleVerb::Resume(a) => schedule::set_paused(&a.socket.resolve()?, &a.id, false),
ScheduleVerb::Rm(a) => schedule::remove(&a.socket.resolve()?, &a.id),
}
}
fn user_add(paths: &Paths, args: AddArgs) -> Result<()> {
users::validate_username(&args.username)?;
@ -713,6 +871,23 @@ mod tests {
Cli::command().debug_assert();
}
/// A schedule with no agent would fire nowhere, so `--agent` is required
/// at parse time, and repeats collect into the target list.
#[test]
fn schedule_add_takes_one_or_more_agents() {
let base = ["swarmctl", "schedule", "add", "--name", "n", "--body", "b"];
assert!(Cli::try_parse_from(base).is_err());
let cli = Cli::try_parse_from(base.iter().copied().chain(["--agent", "a", "--agent", "b"]))
.expect("parses");
let Verb::Schedule {
command: ScheduleVerb::Add(args),
} = cli.command
else {
panic!("parsed as another verb");
};
assert_eq!(args.agents, ["a", "b"]);
}
/// `--hive` carries the address the agent is deployed to and the
/// endpoint has no default for it, so omitting it has to fail at parse
/// time rather than reach the controller as an empty string.

416
swarmctl/src/schedule.rs Normal file
View file

@ -0,0 +1,416 @@
//! `swarmctl schedule` — the operator's scheduled prompts, through
//! swarm-controller's `/api/schedules` routes.
//!
//! Each verb waits for the controller's answer, which is the list or the
//! schedule as stored. Each agent the schedule names picks a change up on its
//! next read of its schedule key and fires in its own container.
//!
//! The shapes below mirror the controller's own `ScheduleView` and request
//! bodies, for the reason [`crate::agent`]'s module doc gives.
use std::path::Path;
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use crate::agent::{parse_ident, send};
/// One schedule as the controller shows it.
#[derive(Debug, Deserialize)]
struct ScheduleView {
id: String,
name: String,
agents: Vec<String>,
body: String,
anchor_unix: i64,
interval_seconds: Option<u64>,
paused: bool,
next_fire_unix: Option<i64>,
}
/// Body of `POST /api/schedules`.
#[derive(Serialize)]
struct AddRequest<'a> {
name: &'a str,
agents: Vec<String>,
body: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
anchor_unix: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
interval_seconds: Option<u64>,
paused: bool,
}
/// Body of `PATCH /api/schedules/{id}`. An absent field is left as it is.
#[derive(Default, Serialize)]
pub(crate) struct EditRequest {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
anchor_unix: Option<i64>,
#[serde(skip_serializing_if = "IntervalEdit::is_keep")]
interval_seconds: IntervalEdit,
#[serde(skip_serializing_if = "Option::is_none")]
paused: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
add_agents: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
remove_agents: Vec<String>,
}
/// What an edit does to the interval. `Set(None)` is sent as `null`, which
/// makes a one-shot; `Keep` is not sent at all.
#[derive(Clone, Copy, Default)]
enum IntervalEdit {
#[default]
Keep,
Set(Option<u64>),
}
impl IntervalEdit {
fn is_keep(&self) -> bool {
matches!(self, Self::Keep)
}
}
impl Serialize for IntervalEdit {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Self::Keep | Self::Set(None) => s.serialize_none(),
Self::Set(Some(seconds)) => s.serialize_u64(*seconds),
}
}
}
/// What `schedule edit` was asked to change.
pub(crate) struct EditFlags<'a> {
pub name: Option<String>,
pub body: Option<String>,
pub at: Option<&'a str>,
pub every: Option<&'a str>,
pub once: bool,
pub add_agents: Vec<String>,
pub remove_agents: Vec<String>,
}
impl EditRequest {
/// The edit `schedule edit` asks for. `once` and `every` are exclusive
/// in the CLI, so at most one of them is set here.
pub(crate) fn new(flags: EditFlags<'_>) -> Result<Self> {
let interval_seconds = match (flags.every, flags.once) {
(Some(every), _) => IntervalEdit::Set(Some(parse_every(every)?)),
(None, true) => IntervalEdit::Set(None),
(None, false) => IntervalEdit::Keep,
};
let edit = Self {
name: flags.name,
body: flags.body,
anchor_unix: flags.at.map(parse_at).transpose()?,
interval_seconds,
paused: None,
add_agents: agents(&flags.add_agents)?,
remove_agents: agents(&flags.remove_agents)?,
};
if edit.name.is_none()
&& edit.body.is_none()
&& edit.anchor_unix.is_none()
&& edit.interval_seconds.is_keep()
&& edit.add_agents.is_empty()
&& edit.remove_agents.is_empty()
{
bail!(
"nothing to change: pass --name, --body, --at, --every, --once, --add-agent \
or --remove-agent"
);
}
Ok(edit)
}
fn paused(paused: bool) -> Self {
Self {
paused: Some(paused),
..Self::default()
}
}
}
fn agents(names: &[String]) -> Result<Vec<String>> {
names.iter().map(|n| parse_ident(n, "agent name")).collect()
}
fn runtime() -> Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.context("starting a tokio runtime for the controller request")
}
const COLLECTION: &str = "/api/schedules";
/// The schedule's path. The id is checked like an agent name, which every id
/// the controller mints satisfies, so it cannot reshape the URI.
fn member(id: &str) -> Result<String> {
let id = parse_ident(id, "schedule id")?;
Ok(format!("{COLLECTION}/{id}"))
}
/// What `schedule add` was asked for.
pub(crate) struct AddFlags<'a> {
pub name: &'a str,
pub agents: &'a [String],
pub body: &'a str,
pub at: Option<&'a str>,
pub every: Option<&'a str>,
pub paused: bool,
}
/// Run `swarmctl schedule add`.
pub(crate) fn add(socket: &Path, flags: &AddFlags<'_>) -> Result<()> {
let request = AddRequest {
name: flags.name,
agents: agents(flags.agents)?,
body: flags.body,
anchor_unix: flags.at.map(parse_at).transpose()?,
interval_seconds: flags.every.map(parse_every).transpose()?,
paused: flags.paused,
};
let added: ScheduleView = runtime()?.block_on(send(
socket,
hyper::Method::POST,
COLLECTION,
Some(&request),
"schedule-add",
))?;
println!("added schedule {}", added.id);
println!("{}", render(&added));
Ok(())
}
/// Run `swarmctl schedule list`, optionally only the schedules naming `agent`.
pub(crate) fn list(socket: &Path, agent: Option<&str>) -> Result<()> {
let agent = agent.map(|a| parse_ident(a, "agent name")).transpose()?;
let schedules: Vec<ScheduleView> = runtime()?.block_on(send(
socket,
hyper::Method::GET,
COLLECTION,
None::<&()>,
"schedule-list",
))?;
let shown: Vec<&ScheduleView> = schedules
.iter()
.filter(|s| agent.as_ref().is_none_or(|a| s.agents.contains(a)))
.collect();
if shown.is_empty() {
println!("no schedules");
}
for s in shown {
println!("{}", render(s));
}
Ok(())
}
/// Run `swarmctl schedule edit`.
pub(crate) fn edit(socket: &Path, id: &str, edit: &EditRequest) -> Result<()> {
let edited: ScheduleView = runtime()?.block_on(send(
socket,
hyper::Method::PATCH,
&member(id)?,
Some(edit),
"schedule-edit",
))?;
println!("{}", render(&edited));
Ok(())
}
/// Run `swarmctl schedule pause` or `resume`.
pub(crate) fn set_paused(socket: &Path, id: &str, paused: bool) -> Result<()> {
edit(socket, id, &EditRequest::paused(paused))
}
/// Run `swarmctl schedule rm`.
pub(crate) fn remove(socket: &Path, id: &str) -> Result<()> {
let uri = member(id)?;
runtime()?.block_on(send::<(), ()>(
socket,
hyper::Method::DELETE,
&uri,
None,
"schedule-rm",
))?;
println!("removed schedule {id}");
Ok(())
}
/// `--at`: unix seconds, or an RFC 3339 time with its offset.
fn parse_at(value: &str) -> Result<i64> {
if let Ok(unix) = value.parse::<i64>() {
return Ok(unix);
}
chrono::DateTime::parse_from_rfc3339(value)
.map(|t| t.timestamp())
.with_context(|| {
format!(
"--at {value:?} is neither unix seconds nor an RFC 3339 time such as \
2026-10-05T09:00:00+02:00"
)
})
}
/// `--every`: a positive count of seconds, minutes, hours or days, such as
/// `90s`, `15m`, `1h` or `7d`. A bare number is seconds.
fn parse_every(value: &str) -> Result<u64> {
let (digits, unit) = match value.char_indices().last() {
Some((i, c)) if c.is_ascii_alphabetic() => (&value[..i], c),
_ => (value, 's'),
};
let scale = match unit {
's' => 1,
'm' => 60,
'h' => 3600,
'd' => 86_400,
_ => bail!("--every {value:?}: the unit must be s, m, h or d"),
};
let count: u64 = digits
.parse()
.with_context(|| format!("--every {value:?} is not a count followed by s, m, h or d"))?;
match count.checked_mul(scale) {
Some(0) => bail!("--every {value:?} is zero; leave --every out for a one-shot"),
Some(seconds) => Ok(seconds),
None => bail!("--every {value:?} is too long"),
}
}
fn utc(unix: i64) -> String {
chrono::DateTime::from_timestamp(unix, 0).map_or_else(
|| unix.to_string(),
|t| t.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
)
}
/// One row per schedule: id, name, cadence, next fire, agents, and the
/// body's first line.
fn render(s: &ScheduleView) -> String {
let cadence = match s.interval_seconds {
Some(i) => format!("every {}", every_text(i)),
None => "once".to_owned(),
};
let next = match (s.paused, s.next_fire_unix) {
(true, _) => "paused".to_owned(),
(false, Some(t)) => format!("next {}", utc(t)),
(false, None) => format!("fired at {}", utc(s.anchor_unix)),
};
let body = s.body.lines().next().unwrap_or_default();
format!(
"{} {:?} {cadence} from {} {next} agents: {} | {body}",
s.id,
s.name,
utc(s.anchor_unix),
s.agents.join(", ")
)
}
fn every_text(seconds: u64) -> String {
for (unit, scale) in [("d", 86_400), ("h", 3600), ("m", 60)] {
if seconds.is_multiple_of(scale) {
return format!("{}{unit}", seconds / scale);
}
}
format!("{seconds}s")
}
#[cfg(test)]
mod tests {
use super::*;
fn flags() -> EditFlags<'static> {
EditFlags {
name: None,
body: None,
at: None,
every: None,
once: false,
add_agents: Vec::new(),
remove_agents: Vec::new(),
}
}
#[test]
fn every_takes_a_count_and_a_unit() {
assert_eq!(parse_every("90").unwrap(), 90);
assert_eq!(parse_every("90s").unwrap(), 90);
assert_eq!(parse_every("15m").unwrap(), 900);
assert_eq!(parse_every("1h").unwrap(), 3600);
assert_eq!(parse_every("7d").unwrap(), 604_800);
assert!(parse_every("0h").is_err());
assert!(parse_every("1w").is_err());
assert!(parse_every("h").is_err());
assert!(parse_every("").is_err());
}
#[test]
fn at_takes_unix_seconds_or_rfc3339() {
assert_eq!(parse_at("1700000000").unwrap(), 1_700_000_000);
assert_eq!(parse_at("1970-01-01T01:00:00+01:00").unwrap(), 0);
assert!(parse_at("tomorrow").is_err());
}
/// The edit body says exactly what the flags changed: a `--once` sends an
/// explicit `null` interval, and an unset flag sends nothing.
#[test]
fn an_edit_sends_only_what_was_asked() {
let body = |e: &EditRequest| serde_json::to_string(e).unwrap();
let e = EditRequest::new(EditFlags {
name: Some("hi".to_owned()),
..flags()
})
.unwrap();
assert_eq!(body(&e), r#"{"name":"hi"}"#);
let e = EditRequest::new(EditFlags {
once: true,
..flags()
})
.unwrap();
assert_eq!(body(&e), r#"{"interval_seconds":null}"#);
let e = EditRequest::new(EditFlags {
at: Some("5"),
every: Some("1m"),
add_agents: vec!["iris".to_owned()],
remove_agents: vec!["atlas".to_owned()],
..flags()
})
.unwrap();
assert_eq!(
body(&e),
r#"{"anchor_unix":5,"interval_seconds":60,"add_agents":["iris"],"remove_agents":["atlas"]}"#
);
assert_eq!(body(&EditRequest::paused(true)), r#"{"paused":true}"#);
assert!(EditRequest::new(flags()).is_err());
assert!(
EditRequest::new(EditFlags {
add_agents: vec!["Not An Ident".to_owned()],
..flags()
})
.is_err()
);
}
/// The controller's list item, as the wire shape documents it, decodes
/// and renders as one row naming every agent.
#[test]
fn a_listed_schedule_renders_as_one_row() {
let s: ScheduleView = serde_json::from_str(
r#"{"id":"abc","name":"standup","agents":["atlas","iris"],"body":"post it\nmore",
"anchor_unix":0,"interval_seconds":86400,"paused":false,"next_fire_unix":86400}"#,
)
.unwrap();
let row = render(&s);
assert!(!row.contains('\n'), "{row}");
assert!(row.contains("\"standup\""), "{row}");
assert!(row.contains("every 1d"), "{row}");
assert!(row.contains("next 1970-01-02T00:00:00Z"), "{row}");
assert!(row.contains("agents: atlas, iris"), "{row}");
assert!(row.ends_with("| post it"), "{row}");
}
}