use std::fs;
use std::path::{Path, PathBuf};
use chrono::{DateTime, TimeDelta, Utc};
use rand::random;
use serde::{Deserialize, Serialize};
use crate::consts::LOOP_DYNAMIC_DEFAULT_DELAY_S;
use crate::envelope::valid_agent_id;
use crate::paths::{loop_file_path, loops_dir};
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LoopMode {
Fixed,
Dynamic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopEntry {
pub id: String,
pub agent: String,
pub created_utc: String,
pub mode: LoopMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_secs: Option<u64>,
pub prompt: String,
pub next_fire_utc: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_fire_utc: Option<String>,
}
impl LoopEntry {
pub fn new_fixed(
id: String,
agent: String,
prompt: String,
interval_secs: u64,
now: DateTime<Utc>,
) -> crate::Result<Self> {
let mut entry = Self {
id,
agent,
created_utc: now.to_rfc3339(),
mode: LoopMode::Fixed,
interval_secs: Some(interval_secs),
prompt,
next_fire_utc: add_delay(now, interval_secs)?.to_rfc3339(),
last_fire_utc: None,
};
entry.validate()?;
Ok(entry)
}
pub fn new_dynamic(
id: String,
agent: String,
prompt: String,
now: DateTime<Utc>,
) -> crate::Result<Self> {
let mut entry = Self {
id,
agent,
created_utc: now.to_rfc3339(),
mode: LoopMode::Dynamic,
interval_secs: None,
prompt,
next_fire_utc: add_delay(now, LOOP_DYNAMIC_DEFAULT_DELAY_S)?.to_rfc3339(),
last_fire_utc: None,
};
entry.validate()?;
Ok(entry)
}
pub fn validate(&mut self) -> crate::Result<()> {
if !self.id.starts_with("loop-") || self.id.len() != "loop-".len() + 8 {
crate::bail!("invalid loop id {:?}", self.id);
}
if !valid_agent_id(&self.agent) {
crate::bail!(
"invalid agent {:?} (expected agent<lowercase-alnum>, e.g. agent0, agentinfinity)",
self.agent
);
}
if self.prompt.trim().is_empty() {
crate::bail!("loop prompt must not be empty");
}
let _ = parse_ts("created_utc", &self.created_utc)?;
let _ = parse_ts("next_fire_utc", &self.next_fire_utc)?;
let _ = self.last_fired_at()?;
match self.mode {
LoopMode::Fixed => {
let interval = self.interval_secs.ok_or_else(|| {
crate::anyhow!("fixed loop {} missing interval_secs", self.id)
})?;
if interval == 0 {
crate::bail!("fixed loop {} interval_secs must be > 0", self.id);
}
}
LoopMode::Dynamic => {
if self.interval_secs.is_some() {
crate::bail!("dynamic loop {} must not set interval_secs", self.id);
}
}
}
Ok(())
}
pub fn next_fire_at(&self) -> crate::Result<DateTime<Utc>> {
parse_ts("next_fire_utc", &self.next_fire_utc)
}
pub fn last_fired_at(&self) -> crate::Result<Option<DateTime<Utc>>> {
self.last_fire_utc
.as_deref()
.map(|raw| parse_ts("last_fire_utc", raw))
.transpose()
}
pub fn is_due(&self, now: DateTime<Utc>) -> crate::Result<bool> {
Ok(self.next_fire_at()? <= now)
}
pub fn mark_fired(&mut self, now: DateTime<Utc>) -> crate::Result<()> {
self.last_fire_utc = Some(now.to_rfc3339());
let delay = match self.mode {
LoopMode::Fixed => self.interval_secs.expect("validated fixed loop"),
LoopMode::Dynamic => LOOP_DYNAMIC_DEFAULT_DELAY_S,
};
self.next_fire_utc = add_delay(now, delay)?.to_rfc3339();
Ok(())
}
pub fn reschedule(&mut self, now: DateTime<Utc>, delay_secs: u64) -> crate::Result<()> {
self.next_fire_utc = add_delay(now, delay_secs)?.to_rfc3339();
Ok(())
}
}
pub fn load_all() -> crate::Result<Vec<LoopEntry>> {
load_all_from(&loops_dir())
}
pub fn load_all_from(dir: &Path) -> crate::Result<Vec<LoopEntry>> {
let mut entries = Vec::new();
let read_dir = match fs::read_dir(dir) {
Ok(read_dir) => read_dir,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
Err(e) => return Err(e.into()),
};
for item in read_dir {
let path = item?.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
continue;
}
entries.push(load_from(&path)?);
}
entries.sort_by(|a, b| a.id.cmp(&b.id));
Ok(entries)
}
pub fn load(id: &str) -> crate::Result<LoopEntry> {
load_from(&loop_file_path(id))
}
pub fn load_from(path: &Path) -> crate::Result<LoopEntry> {
let raw = fs::read_to_string(path)?;
let mut entry: LoopEntry =
toml::from_str(&raw).map_err(|e| crate::anyhow!("parse {}: {e}", path.display()))?;
entry.validate()?;
Ok(entry)
}
pub fn save(entry: &LoopEntry) -> crate::Result<PathBuf> {
save_to(&loop_file_path(&entry.id), entry)
}
pub fn save_to(path: &Path, entry: &LoopEntry) -> crate::Result<PathBuf> {
let mut entry = entry.clone();
entry.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("toml.tmp");
let body = toml::to_string_pretty(&entry)
.map_err(|e| crate::anyhow!("serialize {}: {e}", path.display()))?;
fs::write(&tmp, body)?;
fs::rename(&tmp, path)?;
Ok(path.to_path_buf())
}
pub fn delete(id: &str) -> crate::Result<bool> {
match fs::remove_file(loop_file_path(id)) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.into()),
}
}
pub fn next_id() -> crate::Result<String> {
let dir = loops_dir();
fs::create_dir_all(&dir)?;
for _ in 0..32 {
let id = format!("loop-{:08x}", random::<u32>());
if !loop_file_path(&id).exists() {
return Ok(id);
}
}
crate::bail!("failed to allocate unique loop id after 32 attempts")
}
fn add_delay(now: DateTime<Utc>, delay_secs: u64) -> crate::Result<DateTime<Utc>> {
let delay_i64 = i64::try_from(delay_secs)
.map_err(|_| crate::anyhow!("loop delay overflow: {delay_secs}s"))?;
now.checked_add_signed(TimeDelta::seconds(delay_i64))
.ok_or_else(|| crate::anyhow!("loop delay overflow: {delay_secs}s"))
}
fn parse_ts(field: &str, raw: &str) -> crate::Result<DateTime<Utc>> {
Ok(DateTime::parse_from_rfc3339(raw)
.map_err(|e| crate::anyhow!("invalid {field} {:?}: {e}", raw))?
.with_timezone(&Utc))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use tempfile::tempdir;
#[test]
fn save_and_load_round_trip() {
let dir = tempdir().unwrap();
let path = dir.path().join("loop-00000001.toml");
let now = Utc.with_ymd_and_hms(2026, 4, 19, 16, 19, 0).unwrap();
let entry = LoopEntry::new_dynamic(
"loop-00000001".to_string(),
"agent0".to_string(),
"check status".to_string(),
now,
)
.unwrap();
save_to(&path, &entry).unwrap();
let loaded = load_from(&path).unwrap();
assert_eq!(loaded.id, entry.id);
assert_eq!(loaded.agent, entry.agent);
assert_eq!(loaded.mode, LoopMode::Dynamic);
assert_eq!(loaded.next_fire_utc, entry.next_fire_utc);
}
#[test]
fn fixed_loop_mark_fired_advances_from_now() {
let now = Utc.with_ymd_and_hms(2026, 4, 19, 16, 19, 0).unwrap();
let mut entry = LoopEntry::new_fixed(
"loop-00000001".to_string(),
"agent0".to_string(),
"check status".to_string(),
300,
now,
)
.unwrap();
let fire_at = Utc.with_ymd_and_hms(2026, 4, 19, 16, 24, 7).unwrap();
entry.mark_fired(fire_at).unwrap();
assert_eq!(
entry.last_fire_utc.as_deref(),
Some("2026-04-19T16:24:07+00:00")
);
assert_eq!(entry.next_fire_utc, "2026-04-19T16:29:07+00:00");
}
}