netsky-core 0.1.6

netsky core: agent model, prompt loader, spawner, config
Documentation
//! Filesystem paths used across the netsky runtime.

use std::path::{Path, PathBuf};

use chrono::Utc;

use crate::Result;
use crate::consts::{
    AGENT0_CRASHLOOP_MARKER, AGENT0_HANG_MARKER, AGENT0_HANG_PAGED_MARKER, AGENT0_INBOX_SUBDIR,
    AGENT0_PANE_HASH_FILE, AGENT0_QUIET_UNTIL_PREFIX, AGENT0_RESTART_ATTEMPTS_FILE,
    AGENTINFINITY_READY_MARKER, AGENTINIT_ESCALATION_MARKER, AGENTINIT_FAILURES_FILE,
    CRASH_HANDOFF_FILENAME_PREFIX, CRASH_HANDOFF_FILENAME_SUFFIX, CRASH_HANDOFFS_SUBDIR,
    ENV_NETSKY_DIR, HANDOFF_ARCHIVE_SUBDIR, LAUNCHD_LABEL, LAUNCHD_PLIST_SUBDIR, LOOP_RESUME_FILE,
    NETSKY_DIR_DEFAULT_SUBDIR, PROMPTS_SUBDIR, RESTART_ARCHIVE_SUBDIR,
    RESTART_DETACHED_LOG_FILENAME, RESTART_STATUS_SUBDIR, STATE_DIR, TICKER_MISSING_COUNT_FILE,
};

fn agent_state_file(agent: &str, suffix: &str) -> PathBuf {
    state_dir().join(format!("{agent}-{suffix}"))
}

pub fn home() -> PathBuf {
    dirs::home_dir().expect("netsky requires a home directory")
}

/// Resolve the canonical netsky root. Resolution order:
///
/// 1. `$NETSKY_DIR` when set.
/// 2. `$HOME/netsky`.
///
/// The default is deliberately independent of cwd. `cargo install netsky`
/// hosts should converge on `~/netsky` after `netsky init`; running from
/// a random checkout or workspace must not silently make that directory
/// the constellation root. Developers who need a different root can set
/// `$NETSKY_DIR` explicitly.
pub fn resolve_netsky_dir() -> PathBuf {
    let home_dir = home();
    let env = std::env::var_os(ENV_NETSKY_DIR).map(PathBuf::from);
    resolve_netsky_dir_from(env.as_deref(), &home_dir)
}

fn resolve_netsky_dir_from(env_dir: Option<&Path>, home_dir: &Path) -> PathBuf {
    // 1. $NETSKY_DIR if set — accept source trees, plain dirs, and paths
    //    that `netsky init --path "$NETSKY_DIR"` will create.
    if let Some(p) = env_dir {
        return p.to_path_buf();
    }

    // 2. Fall back: ~/netsky (created by `netsky init` or
    //    ensure_netsky_dir on first use).
    home_dir.join(NETSKY_DIR_DEFAULT_SUBDIR)
}

/// Walk from `start` toward the filesystem root looking for a directory
/// satisfying [`is_netsky_source_tree`]. Returns the first match. Used as
/// the dev escape hatch when the user is in `workspaces/<task>/repo`
/// (a valid netsky checkout deeper in the tree) and `$NETSKY_DIR` is
/// unset.
pub fn walk_up_to_netsky_dir(start: &Path) -> Option<PathBuf> {
    for ancestor in start.ancestors() {
        if is_netsky_source_tree(ancestor) {
            return Some(ancestor.to_path_buf());
        }
    }
    None
}

/// Advisory source-tree check. This is no longer a hard gate.
///
/// The embedded prompts mean binary-only installs do not need a source
/// checkout. Callers that care about developer mode can still use this
/// to prefer a checkout when one is available.
pub fn is_netsky_source_tree(p: &Path) -> bool {
    p.join("prompts/base.md").is_file() && p.join("src/crates/netsky-cli/Cargo.toml").is_file()
}

/// Hard-exit guard for side-effecting top-level commands. Returns Ok if
/// the current working directory matches the resolved netsky dir.
/// Binary-only mode treats the resolved dir as `~/.netsky` and skips
/// the cwd gate.
///
/// Otherwise prints a one-line stderr message naming the expected dir
/// and exits the process with code 2.
///
/// Skipping the gate on read-only commands (doctor, attach, --help) is
/// deliberate: those should work from anywhere so the operator can
/// diagnose a misconfigured machine without first fighting the cwd.
pub fn require_netsky_cwd(command_name: &str) -> std::io::Result<()> {
    let resolved = resolve_netsky_dir();
    if !is_netsky_source_tree(&resolved) {
        return Ok(());
    }
    let cwd = std::env::current_dir()?;
    let cwd_canon = std::fs::canonicalize(&cwd).unwrap_or(cwd);
    let resolved_canon = std::fs::canonicalize(&resolved).unwrap_or(resolved.clone());
    if cwd_canon != resolved_canon {
        eprintln!(
            "netsky: refusing to run `{command_name}` from {}; expected cwd is {} \
             ($NETSKY_DIR or $HOME/netsky). cd there and retry, or set NETSKY_DIR, or \
             install via `cargo install netsky` and run from any directory.",
            cwd_canon.display(),
            resolved_canon.display(),
        );
        std::process::exit(2);
    }
    Ok(())
}

pub fn state_dir() -> PathBuf {
    home().join(STATE_DIR)
}

pub fn prompts_dir() -> PathBuf {
    home().join(PROMPTS_SUBDIR)
}

/// Directory holding crash-handoff drafts written by the watchdog on
/// crash-recovery. Lives under the durable state dir so the macOS /tmp
/// reaper does not eat pending handoffs after ~3 days.
pub fn crash_handoffs_dir() -> PathBuf {
    home().join(CRASH_HANDOFFS_SUBDIR)
}

/// Canonical crash-handoff path for a given pid under [`crash_handoffs_dir`].
pub fn crash_handoff_file_for(pid: u32) -> PathBuf {
    crash_handoffs_dir().join(format!(
        "{CRASH_HANDOFF_FILENAME_PREFIX}{pid}{CRASH_HANDOFF_FILENAME_SUFFIX}"
    ))
}

/// Path to the on-disk system-prompt file for `agent_name`. The spawner
/// atomically writes the rendered prompt here and sets `NETSKY_PROMPT_FILE`
/// to this path so the tmux-spawned shell can `cat` it at exec time.
pub fn prompt_file_for(agent_name: &str) -> PathBuf {
    prompts_dir().join(format!("{agent_name}.md"))
}

/// Refuse to traverse any symlink under `root` on the path to `target`.
///
/// Missing components are allowed so callers can still `create_dir_all`
/// the destination afterward. This keeps channel writes and drains from
/// following a tampered inbox tree out of the netsky state directory.
pub fn assert_no_symlink_under(root: &Path, target: &Path) -> Result<()> {
    let rel = match target.strip_prefix(root) {
        Ok(r) => r,
        Err(_) => crate::bail!(
            "internal: target {} is not under channel root {}",
            target.display(),
            root.display()
        ),
    };
    if let Ok(meta) = std::fs::symlink_metadata(root)
        && meta.file_type().is_symlink()
    {
        crate::bail!("refusing to operate on symlinked root {}", root.display());
    }
    let mut cur = root.to_path_buf();
    for comp in rel.components() {
        cur.push(comp);
        match std::fs::symlink_metadata(&cur) {
            Ok(meta) if meta.file_type().is_symlink() => {
                crate::bail!("refusing to traverse symlink at {}", cur.display());
            }
            Ok(_) => {}
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(e) => return Err(e.into()),
        }
    }
    Ok(())
}

pub fn agentinfinity_ready_marker() -> PathBuf {
    home().join(AGENTINFINITY_READY_MARKER)
}

pub fn agentinit_escalation_marker() -> PathBuf {
    home().join(AGENTINIT_ESCALATION_MARKER)
}

pub fn agentinit_failures_file() -> PathBuf {
    home().join(AGENTINIT_FAILURES_FILE)
}

pub fn agent0_pane_hash_file() -> PathBuf {
    agent_pane_hash_file("agent0")
}

pub fn agent0_hang_marker() -> PathBuf {
    agent_hang_marker("agent0")
}

pub fn agent0_hang_paged_marker() -> PathBuf {
    agent_hang_paged_marker("agent0")
}

/// Per-agent pane-hash state for generalized hang detection.
pub fn agent_pane_hash_file(agent: &str) -> PathBuf {
    if agent == "agent0" {
        return home().join(AGENT0_PANE_HASH_FILE);
    }
    agent_state_file(agent, "pane-hash")
}

/// Per-agent hang marker. `agent0` keeps its historical path.
pub fn agent_hang_marker(agent: &str) -> PathBuf {
    if agent == "agent0" {
        return home().join(AGENT0_HANG_MARKER);
    }
    agent_state_file(agent, "hang-suspected")
}

/// Per-agent hang page marker. `agent0` keeps its historical path.
pub fn agent_hang_paged_marker(agent: &str) -> PathBuf {
    if agent == "agent0" {
        return home().join(AGENT0_HANG_PAGED_MARKER);
    }
    agent_state_file(agent, "hang-paged")
}

/// P0-1 crashloop sliding-window attempts file. Newline-delimited unix
/// ts; pruned by the watchdog on every append.
pub fn agent0_restart_attempts_file() -> PathBuf {
    home().join(AGENT0_RESTART_ATTEMPTS_FILE)
}

/// P0-1 crashloop marker. Presence = the watchdog has fired escalation
/// for a sustained restart-failure pattern; cleared on the first
/// healthy tick after recovery.
pub fn agent0_crashloop_marker() -> PathBuf {
    home().join(AGENT0_CRASHLOOP_MARKER)
}

/// P0-2 restart-status directory. The detached `netsky restart` child
/// writes `<ts>-<pid>.json` files here at known phase transitions.
pub fn restart_status_dir() -> PathBuf {
    home().join(RESTART_STATUS_SUBDIR)
}

/// P1-4 restart-archive directory. Forensic home for the detached
/// restart log + archived stale-processing files. Out of the /tmp
/// reaper window; swept on age by the watchdog tick preflight.
pub fn restart_archive_dir() -> PathBuf {
    home().join(RESTART_ARCHIVE_SUBDIR)
}

/// Canonical path for the detached restart subprocess stdout+stderr log.
pub fn restart_detached_log_path() -> PathBuf {
    restart_archive_dir().join(RESTART_DETACHED_LOG_FILENAME)
}

pub fn ticker_missing_count_file() -> PathBuf {
    home().join(TICKER_MISSING_COUNT_FILE)
}

pub fn watchdog_event_log_for(day: &str) -> PathBuf {
    state_dir().join(format!("netsky-io-watchdog.{day}.log"))
}

pub fn watchdog_event_log_path() -> PathBuf {
    watchdog_event_log_for(&Utc::now().format("%Y-%m-%d").to_string())
}

/// Path for a quiet sentinel that expires at `epoch` (unix seconds). The
/// filename embeds the epoch so multiple arms can co-exist transiently
/// and the watchdog picks the max. `netsky quiet <seconds>` writes one.
pub fn agent0_quiet_sentinel_for(epoch: u64) -> PathBuf {
    state_dir().join(format!("{AGENT0_QUIET_UNTIL_PREFIX}{epoch}"))
}

/// Filename prefix used by the watchdog to glob for quiet sentinels.
pub fn agent0_quiet_sentinel_prefix() -> &'static str {
    AGENT0_QUIET_UNTIL_PREFIX
}

pub fn loop_resume_file() -> PathBuf {
    home().join(LOOP_RESUME_FILE)
}

pub fn handoff_archive_dir() -> PathBuf {
    home().join(HANDOFF_ARCHIVE_SUBDIR)
}

pub fn agent0_inbox_dir() -> PathBuf {
    home().join(AGENT0_INBOX_SUBDIR)
}

pub fn launchd_plist_path() -> PathBuf {
    home()
        .join(LAUNCHD_PLIST_SUBDIR)
        .join(format!("{LAUNCHD_LABEL}.plist"))
}

/// Ensure the state directory exists. Idempotent.
pub fn ensure_state_dir() -> std::io::Result<()> {
    std::fs::create_dir_all(state_dir())
}

/// Ensure the netsky root + state subdirectory exist. Idempotent.
/// Creates `~/netsky/` (the resolved root) and `~/.netsky/state/` (the
/// durable state dir) so both binary-only and source-tree modes work.
pub fn ensure_netsky_dir() -> std::io::Result<()> {
    let root = resolve_netsky_dir();
    std::fs::create_dir_all(&root)?;
    std::fs::create_dir_all(state_dir())
}

/// Resolve the netsky root for spawn pathways (`cmd::agent::run`,
/// `cmd::up::run`) so a clone always lands its tmux session on the
/// netsky root, regardless of which random subdir agent0 happened to
/// be in when it called `netsky agent N`.
///
/// Closes the spawn-cwd-pin gap from `briefs/clone-cwd-pin.md`
/// (agent5): the `prompts/clone.md` stanza promises clones a netsky
/// cwd, but `current_dir()` would silently inherit `workspaces/foo/`
/// when agent0 wandered there. Now the resolver is consulted first.
pub fn netsky_root_or_cwd() -> std::io::Result<PathBuf> {
    Ok(resolve_netsky_dir())
}

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

    fn make_valid_checkout(root: &Path) {
        fs::create_dir_all(root.join("prompts")).unwrap();
        fs::write(root.join("prompts/base.md"), "# base").unwrap();
        fs::create_dir_all(root.join("src/crates/netsky-cli")).unwrap();
        fs::write(
            root.join("src/crates/netsky-cli/Cargo.toml"),
            "[package]\nname = \"netsky\"\n",
        )
        .unwrap();
    }

    #[test]
    fn is_netsky_source_tree_requires_both_sentinels() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(!is_netsky_source_tree(tmp.path()), "empty dir should fail");

        // Just one sentinel = still invalid.
        fs::create_dir_all(tmp.path().join("prompts")).unwrap();
        fs::write(tmp.path().join("prompts/base.md"), "x").unwrap();
        assert!(
            !is_netsky_source_tree(tmp.path()),
            "only base.md present should fail"
        );

        // Both sentinels = valid.
        fs::create_dir_all(tmp.path().join("src/crates/netsky-cli")).unwrap();
        fs::write(tmp.path().join("src/crates/netsky-cli/Cargo.toml"), "x").unwrap();
        assert!(
            is_netsky_source_tree(tmp.path()),
            "both sentinels should pass"
        );
    }

    #[test]
    fn walk_up_finds_valid_ancestor() {
        let tmp = tempfile::tempdir().unwrap();
        make_valid_checkout(tmp.path());
        let nested = tmp.path().join("workspaces/iroh-v0/repo");
        fs::create_dir_all(&nested).unwrap();
        let found = walk_up_to_netsky_dir(&nested).expect("should find ancestor");
        assert_eq!(
            fs::canonicalize(&found).unwrap(),
            fs::canonicalize(tmp.path()).unwrap()
        );
    }

    #[test]
    fn walk_up_returns_none_when_no_ancestor_valid() {
        let tmp = tempfile::tempdir().unwrap();
        let nested = tmp.path().join("a/b/c");
        fs::create_dir_all(&nested).unwrap();
        assert!(walk_up_to_netsky_dir(&nested).is_none());
    }

    #[test]
    fn resolve_prefers_env_var_when_set() {
        let tmp = tempfile::tempdir().unwrap();
        let env = tmp.path().join("custom");
        let resolved = resolve_netsky_dir_from(Some(&env), tmp.path());
        assert_eq!(resolved, env);
    }

    #[test]
    fn resolve_defaults_to_home_netsky_even_from_checkout() {
        let tmp = tempfile::tempdir().unwrap();
        let home = tmp.path().join("home");
        fs::create_dir_all(&home).unwrap();
        make_valid_checkout(tmp.path());
        let nested = tmp.path().join("workspaces/task/repo");
        fs::create_dir_all(&nested).unwrap();

        let resolved = resolve_netsky_dir_from(None, &home);
        assert_eq!(resolved, home.join(NETSKY_DIR_DEFAULT_SUBDIR));
    }
}