netsky 0.1.6

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky tick ...` — status-tick controls + ticker driver.
//!
//! - `enable <secs>`: opt agent0 into watchdog-driven status ticks.
//! - `disable`:       remove the config, ticks paused.
//! - `request`:       drop a tick-request envelope into agent0's inbox.
//! - `ticker`:        infinite sleep-loop driver, runs in a tmux session.
//! - `ticker-start`:  idempotently spawn the `netsky-ticker` tmux session.

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 serde_json::json;

use crate::cli::TickCommand;
use crate::observability;

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())?;
    // Reset the last-tick marker so the next watchdog pass fires immediately
    // rather than honoring a stale interval from a prior session.
    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"));

    // The stored text is a single-line JSON string; escape newlines so the
    // envelope stays valid JSON even though the source template is multiline.
    // owner_name() consults env > netsky.toml [owner].name > OWNER_NAME_DEFAULT
    // per the precedence rule in briefs/netsky-config-design.md section 4.
    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 {
        // Capture stdout+stderr so the unified watchdog log carries
        // every `[watchdog-tick ...]` line, not just the ticker's own
        // status messages. The log is what `netsky doctor` reads to
        // compute tick-rate.
        let out = Command::new(NETSKY_BIN).args(["watchdog", "tick"]).output();
        let detail = match &out {
            Ok(o) => json!({
                "status": o.status.code(),
                "ok": o.status.success(),
                "interval_s": interval,
            }),
            Err(e) => json!({
                "status": "spawn-failed",
                "error": e.to_string(),
                "interval_s": interval,
            }),
        };
        observability::record_tick("ticker", detail);
        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(());
    }
    // Invoke our own binary from the session, not a bash script.
    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();
    // Best-effort append to the shared watchdog log.
    if let Ok(mut f) = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(TICKER_LOG_PATH)
    {
        let _ = writeln!(f, "{line}");
    }
}

/// Rotate TICKER_LOG_PATH to `.1` once it exceeds the size threshold.
/// The ticker emits a handful of lines per pass (1-3), so the check is
/// cheap. Best-effort: errors are swallowed — logging is advisory.
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);
    }
}

/// Substitute the `{{ owner_name }}` placeholder in the tick-request
/// template. Tolerates the two canonical spellings (`{{ owner_name }}`
/// and `{{owner_name}}`) the same way `prompt::apply_bindings` does, so
/// the template author can use either form without surprise.
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() {
        // The shipped template MUST carry the placeholder, not a baked
        // name — the whole point of the parameterization. If someone
        // ever inlines a name back into prompts/tick-request.md this
        // test fails loudly.
        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());
    }
}