netsky 0.2.0

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky cron ...` — durable scheduled dispatch.

use chrono::Utc;
use netsky_core::cron::{self, CronEntry, CronFile};
use netsky_core::envelope::valid_agent_id;
use netsky_core::paths::cron_file_path;
use serde::Serialize;
use serde_json::{Value, json};

use crate::cli::CronCommand;
use crate::cmd::channel::{self, SendEnvelopeOptions};

const CRON_KIND: &str = "cron";
const CRON_FROM: &str = "agentcron";

pub fn run(sub: CronCommand) -> netsky_core::Result<()> {
    match sub {
        CronCommand::Add {
            label,
            schedule,
            target,
            prompt,
            json,
        } => add(&label, &schedule, &target, &prompt, json),
        CronCommand::List { json } => list(json),
        CronCommand::Remove { label, json } => remove(&label, json),
        CronCommand::Tick { json } => tick(json),
    }
}

fn envelope(summary: &str, data: Value) -> Value {
    json!({
        "command": "cron",
        "status": "green",
        "summary": summary,
        "generated_at": Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
        "data": data,
    })
}

fn emit(env: &Value) -> netsky_core::Result<()> {
    println!("{}", serde_json::to_string_pretty(env)?);
    Ok(())
}

fn add(
    label: &str,
    schedule: &str,
    target: &str,
    prompt: &str,
    json: bool,
) -> netsky_core::Result<()> {
    if label.trim().is_empty() {
        netsky_core::bail!("label must not be empty");
    }
    if !valid_agent_id(target) {
        netsky_core::bail!(
            "invalid target {target:?} (expected agent<lowercase-alnum>, e.g. agent0, agentinfinity)"
        );
    }
    let mut file = cron::load()?;
    if file.entries.iter().any(|entry| entry.label == label) {
        netsky_core::bail!("cron entry {label:?} already exists");
    }
    let mut entry = CronEntry {
        label: label.to_string(),
        schedule: schedule.to_string(),
        target: target.to_string(),
        prompt: prompt.to_string(),
        last_fired: None,
    };
    entry.validate()?;
    // Seed last_fired with the add time so is_due can fire on the next
    // scheduled occurrence. Without this, a new cron never fires because
    // is_due returns false on a None last_fired.
    entry.mark_fired(Utc::now());
    file.entries.push(entry);
    file.entries.sort_by(|a, b| a.label.cmp(&b.label));
    cron::save(&file)?;
    if json {
        return emit(&envelope(
            &format!("added {label}"),
            json!({
                "label": label,
                "schedule": schedule,
                "target": target,
                "prompt": prompt,
                "path": cron_file_path().display().to_string(),
            }),
        ));
    }
    println!(
        "[netsky cron add] {label} {schedule} -> {target} ({})",
        cron_file_path().display()
    );
    Ok(())
}

fn list(json: bool) -> netsky_core::Result<()> {
    let file = cron::load()?;
    let now = Utc::now();
    let rows = list_rows(&file, now)?;
    if json {
        let summary = format!(
            "{} cron entr{}",
            rows.len(),
            if rows.len() == 1 { "y" } else { "ies" }
        );
        return emit(&envelope(
            &summary,
            json!({
                "entries": rows,
                "count": rows.len(),
            }),
        ));
    }
    if rows.is_empty() {
        println!("(no cron entries)");
        return Ok(());
    }
    for row in rows {
        let last = row.last_fired.as_deref().unwrap_or("-");
        println!(
            "{}\tschedule={}\ttarget={}\tnext={}\tlast={}\tprompt={}",
            row.label, row.schedule, row.target, row.next_fire, last, row.prompt
        );
    }
    Ok(())
}

fn remove(label: &str, json: bool) -> netsky_core::Result<()> {
    let mut file = cron::load()?;
    let before = file.entries.len();
    file.entries.retain(|entry| entry.label != label);
    if file.entries.len() == before {
        netsky_core::bail!("cron entry {label:?} not found");
    }
    cron::save(&file)?;
    if json {
        return emit(&envelope(
            &format!("removed {label}"),
            json!({ "label": label, "removed": 1 }),
        ));
    }
    println!("[netsky cron remove] {label}");
    Ok(())
}

fn tick(json: bool) -> netsky_core::Result<()> {
    tick_at(
        &cron_file_path(),
        &channel::channel_root(),
        Utc::now(),
        json,
    )
}

fn tick_at(
    path: &std::path::Path,
    channel_root: &std::path::Path,
    now: chrono::DateTime<Utc>,
    json: bool,
) -> netsky_core::Result<()> {
    let mut file = cron::load_from(path)?;
    let count = dispatch_due_entries_at(channel_root, &mut file, now)?;
    cron::save_to(path, &file)?;
    if json {
        return emit(&envelope(
            &format!(
                "dispatched {count} due entr{}",
                if count == 1 { "y" } else { "ies" }
            ),
            json!({ "dispatched": count }),
        ));
    }
    println!(
        "[netsky cron tick] dispatched {count} due entr{}",
        if count == 1 { "y" } else { "ies" }
    );
    Ok(())
}

fn dispatch_due_entries_at(
    root: &std::path::Path,
    file: &mut CronFile,
    now: chrono::DateTime<Utc>,
) -> netsky_core::Result<usize> {
    let mut dispatched = 0usize;
    for entry in &mut file.entries {
        if !entry.is_due(now)? {
            continue;
        }
        channel::send_envelope_at(
            root,
            &entry.target,
            &entry.prompt,
            SendEnvelopeOptions {
                from_override: Some(CRON_FROM),
                kind: Some(CRON_KIND),
                thread: Some(&entry.label),
                in_reply_to: None,
                requires_ack: None,
            },
        )?;
        entry.mark_fired(now);
        dispatched += 1;
    }
    Ok(dispatched)
}

#[derive(Debug, Serialize)]
struct ListRow {
    label: String,
    schedule: String,
    target: String,
    prompt: String,
    last_fired: Option<String>,
    next_fire: String,
}

fn list_rows(file: &CronFile, now: chrono::DateTime<Utc>) -> netsky_core::Result<Vec<ListRow>> {
    file.entries
        .iter()
        .map(|entry| {
            Ok(ListRow {
                label: entry.label.clone(),
                schedule: entry.schedule.clone(),
                target: entry.target.clone(),
                prompt: entry.prompt.clone(),
                last_fired: entry.last_fired.clone(),
                next_fire: entry.next_fire(now)?.to_rfc3339(),
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::TimeZone;
    use std::fs;
    use tempfile::tempdir;

    fn sample_file() -> CronFile {
        CronFile {
            entries: vec![CronEntry {
                label: "daily".to_string(),
                schedule: "0 0 * * *".to_string(),
                target: "agent0".to_string(),
                prompt: "/notes".to_string(),
                last_fired: Some("2026-04-15T00:00:00Z".to_string()),
            }],
        }
    }

    #[test]
    fn add_rejects_duplicate_label() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("cron.toml");
        cron::save_to(&path, &sample_file()).unwrap();

        let file = cron::load_from(&path).unwrap();
        assert!(file.entries.iter().any(|entry| entry.label == "daily"));
        assert!(file.entries.iter().all(|entry| entry.label != "other"));
    }

    #[test]
    fn list_rows_include_next_fire() {
        let rows = list_rows(
            &sample_file(),
            Utc.with_ymd_and_hms(2026, 4, 17, 12, 0, 0).unwrap(),
        )
        .unwrap();
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].label, "daily");
        assert_eq!(rows[0].next_fire, "2026-04-16T00:00:00+00:00");
    }

    #[test]
    fn tick_dispatches_exactly_one_envelope_for_due_entry() {
        let dir = tempdir().unwrap();
        unsafe {
            std::env::set_var("HOME", dir.path());
        }
        let path = dir.path().join(".netsky").join("cron.toml");
        cron::save_to(&path, &sample_file()).unwrap();

        tick_at(
            &path,
            &dir.path().join(".claude/channels/agent"),
            Utc.with_ymd_and_hms(2026, 4, 17, 0, 0, 0).unwrap(),
            false,
        )
        .unwrap();

        let inbox = dir.path().join(".claude/channels/agent/agent0/inbox");
        let entries: Vec<_> = fs::read_dir(&inbox).unwrap().flatten().collect();
        assert_eq!(entries.len(), 1);
        let raw = fs::read_to_string(entries[0].path()).unwrap();
        let env: netsky_core::envelope::Envelope = serde_json::from_str(&raw).unwrap();
        assert_eq!(env.kind.as_deref(), Some("cron"));
        assert_eq!(env.thread.as_deref(), Some("daily"));

        let updated = cron::load_from(&path).unwrap();
        assert_eq!(
            updated.entries[0].last_fired.as_deref(),
            Some("2026-04-17T00:00:00+00:00")
        );
    }
}