netsky-core 0.2.0

netsky core: agent model, prompt loader, spawner, config
Documentation
//! Durable loop entry storage under `~/.netsky/state/loops/*.toml`.

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");
    }
}