use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use netsky_core::config::owner_name;
use netsky_core::consts::{
ENV_TICKER_INTERVAL, NETSKY_BIN, TICK_INTERVAL_CONFIG, TICK_LAST_MARKER, TICK_MIN_INTERVAL_S,
TICKER_INTERVAL_DEFAULT_S, TICKER_LOG_PATH, TICKER_LOG_ROTATE_BYTES, TICKER_SESSION, TMUX_BIN,
};
use netsky_core::paths::{agent0_inbox_dir, ticker_missing_count_file};
use netsky_sh::tmux;
use crate::cli::TickCommand;
const TICK_REQUEST_TEXT: &str = include_str!("../../prompts/tick-request.md");
const TICK_REQUEST_FROM: &str = "agentinfinity";
pub fn run(sub: TickCommand) -> netsky_core::Result<()> {
match sub {
TickCommand::Enable { seconds } => enable(seconds),
TickCommand::Disable => disable(),
TickCommand::Request => request(),
TickCommand::Ticker => ticker_loop(),
TickCommand::TickerStart => ticker_start(),
}
}
fn enable(seconds: u64) -> netsky_core::Result<()> {
if seconds < TICK_MIN_INTERVAL_S {
netsky_core::bail!("interval must be >= {TICK_MIN_INTERVAL_S}s (got {seconds})");
}
atomic_write(Path::new(TICK_INTERVAL_CONFIG), &seconds.to_string())?;
let _ = fs::remove_file(TICK_LAST_MARKER);
println!("[netsky tick enable] enabled at {seconds}s; next tick on next watchdog pass (~2min)");
Ok(())
}
fn disable() -> netsky_core::Result<()> {
if Path::new(TICK_INTERVAL_CONFIG).exists() {
fs::remove_file(TICK_INTERVAL_CONFIG)?;
println!("[netsky tick disable] disabled");
} else {
println!("[netsky tick disable] already disabled (no config file)");
}
Ok(())
}
pub(crate) fn request() -> netsky_core::Result<()> {
let inbox = agent0_inbox_dir();
fs::create_dir_all(&inbox)?;
let now = SystemTime::now().duration_since(UNIX_EPOCH)?;
let ts_iso = chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.6fZ")
.to_string();
let ts_ns = format!("{}{:09}", now.as_secs(), now.subsec_nanos());
let path = inbox.join(format!("tick-{ts_ns}.json"));
let name = owner_name();
let rendered = render_tick_request(TICK_REQUEST_TEXT, &name);
let text_escaped = escape_json_string(rendered.trim());
let envelope = format!(
"{{\"from\":\"{TICK_REQUEST_FROM}\",\"text\":\"{text_escaped}\",\"ts\":\"{ts_iso}\"}}\n"
);
atomic_write(&path, &envelope)?;
println!("[netsky tick request {ts_iso}] dropped {}", path.display());
Ok(())
}
pub(crate) fn ticker_missing_count() -> u32 {
fs::read_to_string(ticker_missing_count_file())
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0)
}
pub(crate) fn ticker_missing_record(count: u32) -> netsky_core::Result<()> {
ticker_missing_record_at(&ticker_missing_count_file(), count)
}
pub(crate) fn ticker_missing_clear() -> netsky_core::Result<()> {
ticker_missing_clear_at(&ticker_missing_count_file())
}
fn ticker_missing_record_at(path: &Path, count: u32) -> netsky_core::Result<()> {
atomic_write(path, &count.to_string()).map_err(Into::into)
}
fn ticker_missing_clear_at(path: &Path) -> netsky_core::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
fn ticker_loop() -> netsky_core::Result<()> {
let interval = std::env::var(ENV_TICKER_INTERVAL)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(TICKER_INTERVAL_DEFAULT_S);
log_line(&format!(
"[netsky-ticker] started at {}; interval={interval}s",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
loop {
let out = Command::new(NETSKY_BIN).args(["watchdog", "tick"]).output();
match out {
Ok(o) => {
tee(&String::from_utf8_lossy(&o.stdout));
tee(&String::from_utf8_lossy(&o.stderr));
if !o.status.success() {
log_line(&format!(
"[netsky-ticker] tick returned non-zero at {}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
}
}
Err(e) => {
log_line(&format!(
"[netsky-ticker] tick subprocess failed at {}: {e}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
));
}
}
std::thread::sleep(Duration::from_secs(interval));
}
}
fn tee(text: &str) {
for line in text.lines() {
if line.is_empty() {
continue;
}
log_line(line);
}
}
pub(crate) fn ticker_start() -> netsky_core::Result<()> {
if tmux::has_session(TICKER_SESSION) {
let _ = ticker_missing_clear();
println!("[ticker-start] session '{TICKER_SESSION}' already up; skipping spawn");
return Ok(());
}
let cmd = format!("{NETSKY_BIN} tick ticker");
let status = Command::new(TMUX_BIN)
.args(["new-session", "-d", "-s", TICKER_SESSION, &cmd])
.status()?;
if !status.success() {
netsky_core::bail!("tmux new-session failed for '{TICKER_SESSION}'");
}
let _ = ticker_missing_clear();
let interval = std::env::var(ENV_TICKER_INTERVAL)
.unwrap_or_else(|_| TICKER_INTERVAL_DEFAULT_S.to_string());
println!(
"[ticker-start] spawned '{TICKER_SESSION}' — watchdog ticks every {interval}s (bash sleep-loop)"
);
Ok(())
}
fn atomic_write(target: &Path, content: &str) -> std::io::Result<()> {
let tmp = target.with_extension("tmp");
fs::write(&tmp, content)?;
fs::rename(&tmp, target)
}
fn log_line(line: &str) {
println!("{line}");
maybe_rotate_log();
if let Ok(mut f) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(TICKER_LOG_PATH)
{
let _ = writeln!(f, "{line}");
}
}
fn maybe_rotate_log() {
if let Ok(md) = fs::metadata(TICKER_LOG_PATH)
&& md.len() > TICKER_LOG_ROTATE_BYTES
{
let rotated = format!("{TICKER_LOG_PATH}.1");
let _ = fs::rename(TICKER_LOG_PATH, &rotated);
}
}
fn render_tick_request(template: &str, owner_name: &str) -> String {
template
.replace("{{ owner_name }}", owner_name)
.replace("{{owner_name}}", owner_name)
}
fn escape_json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn json_escape_handles_specials() {
assert_eq!(escape_json_string("a\"b"), "a\\\"b");
assert_eq!(escape_json_string("a\\b"), "a\\\\b");
assert_eq!(escape_json_string("line1\nline2"), "line1\\nline2");
}
#[test]
fn render_substitutes_owner_name() {
let out = render_tick_request("ping {{ owner_name }} now", "Cody");
assert_eq!(out, "ping Cody now");
assert!(!out.contains("{{"));
}
#[test]
fn render_tolerates_no_inner_whitespace() {
let out = render_tick_request("ping {{owner_name}} now", "Cody");
assert_eq!(out, "ping Cody now");
}
#[test]
fn render_default_keeps_baked_template_owner_neutral() {
assert!(
TICK_REQUEST_TEXT.contains("{{ owner_name }}"),
"tick-request.md lost its owner_name placeholder"
);
let rendered =
render_tick_request(TICK_REQUEST_TEXT, netsky_core::consts::OWNER_NAME_DEFAULT);
assert!(rendered.contains(netsky_core::consts::OWNER_NAME_DEFAULT));
assert!(!rendered.contains("{{"));
}
#[test]
fn ticker_missing_count_roundtrips() {
let dir = tempdir().unwrap();
let path = dir.path().join("netsky-ticker-missing-count");
ticker_missing_record_at(&path, 1).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "1");
}
#[test]
fn ticker_missing_clear_removes_state() {
let dir = tempdir().unwrap();
let path = dir.path().join("netsky-ticker-missing-count");
std::fs::write(&path, "2").unwrap();
assert!(path.exists());
ticker_missing_clear_at(&path).unwrap();
assert!(!path.exists());
}
}