1use std::fs;
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, TimeDelta, Utc};
7use rand::random;
8use serde::{Deserialize, Serialize};
9
10use crate::consts::LOOP_DYNAMIC_DEFAULT_DELAY_S;
11use crate::envelope::valid_agent_id;
12use crate::paths::{loop_file_path, loops_dir};
13
14#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum LoopMode {
17 Fixed,
18 Dynamic,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct LoopEntry {
23 pub id: String,
24 pub agent: String,
25 pub created_utc: String,
26 pub mode: LoopMode,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub interval_secs: Option<u64>,
29 pub prompt: String,
30 pub next_fire_utc: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub last_fire_utc: Option<String>,
33}
34
35impl LoopEntry {
36 pub fn new_fixed(
37 id: String,
38 agent: String,
39 prompt: String,
40 interval_secs: u64,
41 now: DateTime<Utc>,
42 ) -> crate::Result<Self> {
43 let mut entry = Self {
44 id,
45 agent,
46 created_utc: now.to_rfc3339(),
47 mode: LoopMode::Fixed,
48 interval_secs: Some(interval_secs),
49 prompt,
50 next_fire_utc: add_delay(now, interval_secs)?.to_rfc3339(),
51 last_fire_utc: None,
52 };
53 entry.validate()?;
54 Ok(entry)
55 }
56
57 pub fn new_dynamic(
58 id: String,
59 agent: String,
60 prompt: String,
61 now: DateTime<Utc>,
62 ) -> crate::Result<Self> {
63 let mut entry = Self {
64 id,
65 agent,
66 created_utc: now.to_rfc3339(),
67 mode: LoopMode::Dynamic,
68 interval_secs: None,
69 prompt,
70 next_fire_utc: add_delay(now, LOOP_DYNAMIC_DEFAULT_DELAY_S)?.to_rfc3339(),
71 last_fire_utc: None,
72 };
73 entry.validate()?;
74 Ok(entry)
75 }
76
77 pub fn validate(&mut self) -> crate::Result<()> {
78 if !self.id.starts_with("loop-") || self.id.len() != "loop-".len() + 8 {
79 crate::bail!("invalid loop id {:?}", self.id);
80 }
81 if !valid_agent_id(&self.agent) {
82 crate::bail!(
83 "invalid agent {:?} (expected agent<lowercase-alnum>, e.g. agent0, agentinfinity)",
84 self.agent
85 );
86 }
87 if self.prompt.trim().is_empty() {
88 crate::bail!("loop prompt must not be empty");
89 }
90 let _ = parse_ts("created_utc", &self.created_utc)?;
91 let _ = parse_ts("next_fire_utc", &self.next_fire_utc)?;
92 let _ = self.last_fired_at()?;
93 match self.mode {
94 LoopMode::Fixed => {
95 let interval = self.interval_secs.ok_or_else(|| {
96 crate::anyhow!("fixed loop {} missing interval_secs", self.id)
97 })?;
98 if interval == 0 {
99 crate::bail!("fixed loop {} interval_secs must be > 0", self.id);
100 }
101 }
102 LoopMode::Dynamic => {
103 if self.interval_secs.is_some() {
104 crate::bail!("dynamic loop {} must not set interval_secs", self.id);
105 }
106 }
107 }
108 Ok(())
109 }
110
111 pub fn next_fire_at(&self) -> crate::Result<DateTime<Utc>> {
112 parse_ts("next_fire_utc", &self.next_fire_utc)
113 }
114
115 pub fn last_fired_at(&self) -> crate::Result<Option<DateTime<Utc>>> {
116 self.last_fire_utc
117 .as_deref()
118 .map(|raw| parse_ts("last_fire_utc", raw))
119 .transpose()
120 }
121
122 pub fn is_due(&self, now: DateTime<Utc>) -> crate::Result<bool> {
123 Ok(self.next_fire_at()? <= now)
124 }
125
126 pub fn mark_fired(&mut self, now: DateTime<Utc>) -> crate::Result<()> {
127 self.last_fire_utc = Some(now.to_rfc3339());
128 let delay = match self.mode {
129 LoopMode::Fixed => self.interval_secs.expect("validated fixed loop"),
130 LoopMode::Dynamic => LOOP_DYNAMIC_DEFAULT_DELAY_S,
131 };
132 self.next_fire_utc = add_delay(now, delay)?.to_rfc3339();
133 Ok(())
134 }
135
136 pub fn reschedule(&mut self, now: DateTime<Utc>, delay_secs: u64) -> crate::Result<()> {
137 self.next_fire_utc = add_delay(now, delay_secs)?.to_rfc3339();
138 Ok(())
139 }
140}
141
142pub fn load_all() -> crate::Result<Vec<LoopEntry>> {
143 load_all_from(&loops_dir())
144}
145
146pub fn load_all_from(dir: &Path) -> crate::Result<Vec<LoopEntry>> {
147 let mut entries = Vec::new();
148 let read_dir = match fs::read_dir(dir) {
149 Ok(read_dir) => read_dir,
150 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
151 Err(e) => return Err(e.into()),
152 };
153 for item in read_dir {
154 let path = item?.path();
155 if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
156 continue;
157 }
158 entries.push(load_from(&path)?);
159 }
160 entries.sort_by(|a, b| a.id.cmp(&b.id));
161 Ok(entries)
162}
163
164pub fn load(id: &str) -> crate::Result<LoopEntry> {
165 load_from(&loop_file_path(id))
166}
167
168pub fn load_from(path: &Path) -> crate::Result<LoopEntry> {
169 let raw = fs::read_to_string(path)?;
170 let mut entry: LoopEntry =
171 toml::from_str(&raw).map_err(|e| crate::anyhow!("parse {}: {e}", path.display()))?;
172 entry.validate()?;
173 Ok(entry)
174}
175
176pub fn save(entry: &LoopEntry) -> crate::Result<PathBuf> {
177 save_to(&loop_file_path(&entry.id), entry)
178}
179
180pub fn save_to(path: &Path, entry: &LoopEntry) -> crate::Result<PathBuf> {
181 let mut entry = entry.clone();
182 entry.validate()?;
183 if let Some(parent) = path.parent() {
184 fs::create_dir_all(parent)?;
185 }
186 let tmp = path.with_extension("toml.tmp");
187 let body = toml::to_string_pretty(&entry)
188 .map_err(|e| crate::anyhow!("serialize {}: {e}", path.display()))?;
189 fs::write(&tmp, body)?;
190 fs::rename(&tmp, path)?;
191 Ok(path.to_path_buf())
192}
193
194pub fn delete(id: &str) -> crate::Result<bool> {
195 match fs::remove_file(loop_file_path(id)) {
196 Ok(()) => Ok(true),
197 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
198 Err(e) => Err(e.into()),
199 }
200}
201
202pub fn next_id() -> crate::Result<String> {
203 let dir = loops_dir();
204 fs::create_dir_all(&dir)?;
205 for _ in 0..32 {
206 let id = format!("loop-{:08x}", random::<u32>());
207 if !loop_file_path(&id).exists() {
208 return Ok(id);
209 }
210 }
211 crate::bail!("failed to allocate unique loop id after 32 attempts")
212}
213
214fn add_delay(now: DateTime<Utc>, delay_secs: u64) -> crate::Result<DateTime<Utc>> {
215 let delay_i64 = i64::try_from(delay_secs)
216 .map_err(|_| crate::anyhow!("loop delay overflow: {delay_secs}s"))?;
217 now.checked_add_signed(TimeDelta::seconds(delay_i64))
218 .ok_or_else(|| crate::anyhow!("loop delay overflow: {delay_secs}s"))
219}
220
221fn parse_ts(field: &str, raw: &str) -> crate::Result<DateTime<Utc>> {
222 Ok(DateTime::parse_from_rfc3339(raw)
223 .map_err(|e| crate::anyhow!("invalid {field} {:?}: {e}", raw))?
224 .with_timezone(&Utc))
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use chrono::TimeZone;
231 use tempfile::tempdir;
232
233 #[test]
234 fn save_and_load_round_trip() {
235 let dir = tempdir().unwrap();
236 let path = dir.path().join("loop-00000001.toml");
237 let now = Utc.with_ymd_and_hms(2026, 4, 19, 16, 19, 0).unwrap();
238 let entry = LoopEntry::new_dynamic(
239 "loop-00000001".to_string(),
240 "agent0".to_string(),
241 "check status".to_string(),
242 now,
243 )
244 .unwrap();
245 save_to(&path, &entry).unwrap();
246 let loaded = load_from(&path).unwrap();
247 assert_eq!(loaded.id, entry.id);
248 assert_eq!(loaded.agent, entry.agent);
249 assert_eq!(loaded.mode, LoopMode::Dynamic);
250 assert_eq!(loaded.next_fire_utc, entry.next_fire_utc);
251 }
252
253 #[test]
254 fn fixed_loop_mark_fired_advances_from_now() {
255 let now = Utc.with_ymd_and_hms(2026, 4, 19, 16, 19, 0).unwrap();
256 let mut entry = LoopEntry::new_fixed(
257 "loop-00000001".to_string(),
258 "agent0".to_string(),
259 "check status".to_string(),
260 300,
261 now,
262 )
263 .unwrap();
264 let fire_at = Utc.with_ymd_and_hms(2026, 4, 19, 16, 24, 7).unwrap();
265 entry.mark_fired(fire_at).unwrap();
266 assert_eq!(
267 entry.last_fire_utc.as_deref(),
268 Some("2026-04-19T16:24:07+00:00")
269 );
270 assert_eq!(entry.next_fire_utc, "2026-04-19T16:29:07+00:00");
271 }
272}