use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::time::Duration;
pub const MIN_EVERY: u64 = 60;
const IDLE: Duration = Duration::from_secs(60);
const SETTLING: Duration = Duration::from_secs(15);
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn file_stamp(at: i64) -> String {
let stamp = crate::clock::local_stamp(at);
let (date, rest) = stamp.split_once(' ').unwrap_or((stamp.as_str(), ""));
let time: String = rest
.chars()
.take_while(|c| *c != ' ')
.filter(char::is_ascii_digit)
.collect();
format!("{date}-{time}")
}
fn extension(format: &str) -> &'static str {
match format {
"csv" => "csv",
"json" => "json",
"html" => "html",
crate::report::PDF => "pdf",
_ => "md",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Schedule {
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window: Option<String>,
#[serde(default)]
pub stores: Vec<String>,
pub format: String,
pub model: String,
pub every_seconds: u64,
pub dir: PathBuf,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_run: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
impl Schedule {
pub fn new(kind: &str, format: &str, model: &str, every_seconds: u64, dir: &Path) -> Self {
Self {
kind: kind.to_string(),
window: None,
stores: Vec::new(),
format: format.to_string(),
model: model.to_string(),
every_seconds,
dir: dir.to_path_buf(),
enabled: true,
last_run: None,
next_run: None,
last_path: None,
last_error: None,
}
}
pub fn check(&self) -> Result<()> {
crate::report::kind_of(&self.kind)?;
crate::report::check_any_format(&self.format)?;
crate::report::window_of(self.window.as_deref())?;
if crate::report::price_named(&self.model).is_none() {
bail!(
"no prices for model {:?} — this binary prices {}",
self.model,
crate::report::price_names()
);
}
if self.every_seconds < MIN_EVERY {
bail!(
"a cadence of {}s is below the floor of {MIN_EVERY}s; give a longer interval",
self.every_seconds
);
}
if !self.dir.is_absolute() {
bail!(
"the destination {} is relative; a schedule is run by the daemon, \
which has no working directory of yours — give an absolute path",
self.dir.display()
);
}
Ok(())
}
pub fn due(&self, now: i64) -> bool {
self.enabled && self.next_run.is_some_and(|at| at <= now)
}
fn advance(&mut self, now: i64) {
self.last_run = Some(now);
self.next_run = Some(now + self.every_seconds as i64);
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Schedules {
#[serde(default)]
pub schedules: BTreeMap<String, Schedule>,
}
impl Schedules {
pub fn load() -> Result<Self> {
Self::load_from(&crate::home::schedules_path()?)
}
pub fn save(&self) -> Result<()> {
self.save_to(&crate::home::schedules_path()?)
}
pub fn load_from(path: &Path) -> Result<Self> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
serde_json::from_str(&text).with_context(|| {
format!(
"{} is not readable as a schedules file; move it aside to start over",
path.display()
)
})
}
pub fn save_to(&self, path: &Path) -> Result<()> {
let dir = path.parent().unwrap_or(Path::new("."));
crate::home::secure_dir(dir)
.with_context(|| format!("creating the store home {}", dir.display()))?;
let temp = path.with_extension(format!("json.{}.new", std::process::id()));
let body = serde_json::to_string_pretty(self)? + "\n";
crate::home::write_private(&temp, body.as_bytes())
.with_context(|| format!("writing {}", temp.display()))?;
if let Err(e) = std::fs::rename(&temp, path) {
let _ = std::fs::remove_file(&temp);
return Err(e).with_context(|| format!("writing {}", path.display()));
}
Ok(())
}
pub fn add(&mut self, mut schedule: Schedule) -> Result<String> {
schedule.check()?;
if !schedule.dir.is_dir() {
bail!(
"the destination {} is not a directory that exists — create it first, \
or name one that is already there",
schedule.dir.display()
);
}
if schedule.next_run.is_none() {
schedule.next_run = Some(unix_now() + schedule.every_seconds as i64);
}
let id = self.free_id();
self.schedules.insert(id.clone(), schedule);
Ok(id)
}
pub fn remove(&mut self, id: &str) -> bool {
self.schedules.remove(id).is_some()
}
pub fn due(&self, now: i64) -> Vec<String> {
self.schedules
.iter()
.filter(|(_, s)| s.due(now))
.map(|(id, _)| id.clone())
.collect()
}
pub fn until_next(&self, now: i64) -> Duration {
self.schedules
.values()
.filter(|s| s.enabled)
.filter_map(|s| s.next_run)
.min()
.map(|at| Duration::from_secs((at - now).max(1) as u64))
.unwrap_or(IDLE)
}
pub fn sleep_for(&self, now: i64) -> Duration {
self.until_next(now).min(IDLE)
}
fn free_id(&self) -> String {
(1..)
.map(|n| format!("s{n}"))
.find(|id| !self.schedules.contains_key(id))
.unwrap_or_default()
}
}
static GENERATING: Mutex<()> = Mutex::new(());
pub fn generating() -> MutexGuard<'static, ()> {
GENERATING.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn fire(schedule: &mut Schedule, fleet: &crate::fleet::Fleet, now: i64) {
let _slot = generating();
schedule.advance(now);
match write_report(schedule, fleet, now) {
Ok(path) => {
schedule.last_path = Some(path);
schedule.last_error = None;
}
Err(e) => {
schedule.last_path = None;
schedule.last_error = Some(format!("{e:#}"));
}
}
}
fn write_report(schedule: &Schedule, fleet: &crate::fleet::Fleet, now: i64) -> Result<PathBuf> {
schedule.check()?;
let window = crate::report::window_of(schedule.window.as_deref())?;
let report = crate::report::generate_over(
fleet,
&schedule.kind,
&schedule.model,
window,
&schedule.stores,
)?;
let body = report.render_bytes(&schedule.format)?;
if !schedule.dir.is_dir() {
bail!(
"the destination {} is not a directory that exists — it may have been \
moved, renamed, or be on a volume that is not mounted",
schedule.dir.display()
);
}
let name = format!(
"semlith-{}-{}.{}",
schedule.kind,
file_stamp(now),
extension(&schedule.format)
);
let path = schedule.dir.join(name);
std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
pub fn tick(
path: &Path,
fleet: Option<&crate::fleet::Fleet>,
settled: bool,
now: i64,
) -> Result<Duration> {
let mut file = Schedules::load_from(path)?;
let due = file.due(now);
if due.is_empty() {
return Ok(file.sleep_for(now));
}
if !settled {
return Ok(SETTLING);
}
for id in due {
let Some(schedule) = file.schedules.get_mut(&id) else {
continue;
};
match fleet {
Some(fleet) => fire(schedule, fleet, now),
None => {
schedule.advance(now);
schedule.last_path = None;
schedule.last_error = Some(
"no store is open in this daemon, so there is nothing to report on".into(),
);
}
}
}
file.save_to(path)?;
Ok(file.sleep_for(unix_now()))
}
pub struct Runner {
stop: AtomicBool,
generation: Mutex<u64>,
wake: Condvar,
}
impl Runner {
pub fn new() -> Arc<Self> {
Arc::new(Self {
stop: AtomicBool::new(false),
generation: Mutex::new(0),
wake: Condvar::new(),
})
}
pub fn wake(&self) {
let mut generation = self.generation.lock().unwrap_or_else(|e| e.into_inner());
*generation += 1;
drop(generation);
self.wake.notify_all();
}
pub fn stop(&self) {
self.stop.store(true, Ordering::SeqCst);
self.wake();
}
pub fn spawn(state: Arc<crate::daemon::State>) {
std::thread::spawn(move || {
let runner = Arc::clone(&state.schedules);
let Ok(path) = crate::home::schedules_path() else {
return;
};
while !runner.stop.load(Ordering::SeqCst) {
let seen = *runner.generation.lock().unwrap_or_else(|e| e.into_inner());
let until = runner.pass(&state, &path);
let generation = runner.generation.lock().unwrap_or_else(|e| e.into_inner());
if *generation == seen && !runner.stop.load(Ordering::SeqCst) {
let _ = runner.wake.wait_timeout(generation, until);
}
}
});
}
fn pass(&self, state: &Arc<crate::daemon::State>, path: &Path) -> Duration {
let settled = state
.stores()
.iter()
.all(|store| !store.run_live() && store.queue_depth() == 0);
if let Err(e) = state.open_fleet() {
state.say(&format!("schedules: could not open the stores: {e:#}"));
return SETTLING;
}
let fleet = state.fleet.lock().unwrap_or_else(|e| e.into_inner());
match tick(path, fleet.as_ref(), settled, unix_now()) {
Ok(until) => until,
Err(e) => {
state.say(&format!("schedules: {e:#}"));
IDLE
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn weekly() -> Schedule {
Schedule::new(
"health",
"markdown",
"Sonnet 5",
7 * 24 * 60 * 60,
&std::env::temp_dir(),
)
}
#[test]
fn a_cadence_is_an_interval_not_a_chip() {
let mut s = weekly();
s.every_seconds = 5 * 60 * 60 + 17;
let back: Schedule = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
assert_eq!(back.every_seconds, 18_017);
}
#[test]
fn a_schedule_that_cannot_work_says_what_would_have() {
let mut s = weekly();
s.kind = "team-rollup".into();
let e = s.check().unwrap_err().to_string();
assert!(e.contains("savings"), "{e}");
let mut s = weekly();
s.model = "GPT-9".into();
assert!(s.check().unwrap_err().to_string().contains("Sonnet 5"));
let mut s = weekly();
s.every_seconds = 5;
assert!(
s.check()
.unwrap_err()
.to_string()
.contains("below the floor")
);
let mut s = weekly();
s.dir = PathBuf::from("reports");
assert!(s.check().unwrap_err().to_string().contains("absolute"));
}
#[test]
fn a_file_stamp_is_the_clock_stamp_without_the_punctuation() {
let stamp = file_stamp(1_789_653_120);
assert_eq!(stamp.len(), "2026-09-17-135200".len(), "{stamp}");
assert!(
stamp.chars().all(|c| c.is_ascii_digit() || c == '-'),
"{stamp}"
);
}
#[test]
fn ids_fill_the_gaps() {
let mut file = Schedules::default();
assert_eq!(file.add(weekly()).unwrap(), "s1");
assert_eq!(file.add(weekly()).unwrap(), "s2");
assert!(file.remove("s1"));
assert_eq!(file.add(weekly()).unwrap(), "s1");
assert!(!file.remove("s9"));
}
#[test]
fn an_empty_set_sleeps_for_the_ceiling() {
assert_eq!(Schedules::default().until_next(0), IDLE);
assert_eq!(Schedules::default().sleep_for(0), IDLE);
let mut file = Schedules::default();
let mut s = weekly();
s.next_run = Some(1_000);
file.schedules.insert("s1".into(), s);
assert_eq!(file.until_next(400), Duration::from_secs(600));
assert_eq!(file.sleep_for(400), IDLE);
file.schedules.get_mut("s1").unwrap().enabled = false;
assert_eq!(file.until_next(400), IDLE);
assert_eq!(file.sleep_for(400), IDLE);
}
}