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