Skip to main content

netsky_core/
loops.rs

1//! Durable loop entry storage under `~/.netsky/state/loops/*.toml`.
2
3use 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}