use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
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, ESCALATE_FAILED_MARKER_PREFIX, HANDOFF_ARCHIVE_SUBDIR, LAUNCHD_LABEL,
LAUNCHD_PLIST_SUBDIR, LOGS_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")
}
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 {
if let Some(p) = env_dir {
return p.to_path_buf();
}
home_dir.join(NETSKY_DIR_DEFAULT_SUBDIR)
}
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
}
pub fn is_netsky_source_tree(p: &Path) -> bool {
p.join("src/crates/netsky-core/prompts/base.md").is_file()
&& p.join("src/crates/netsky-cli/Cargo.toml").is_file()
}
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 cron_file_path() -> PathBuf {
state_dir().join("cron.toml")
}
pub fn prompts_dir() -> PathBuf {
home().join(PROMPTS_SUBDIR)
}
pub fn crash_handoffs_dir() -> PathBuf {
home().join(CRASH_HANDOFFS_SUBDIR)
}
pub fn crash_handoff_file_for(pid: u32) -> PathBuf {
crash_handoffs_dir().join(format!(
"{CRASH_HANDOFF_FILENAME_PREFIX}{pid}{CRASH_HANDOFF_FILENAME_SUFFIX}"
))
}
pub fn prompt_file_for(agent_name: &str) -> PathBuf {
prompts_dir().join(format!("{agent_name}.md"))
}
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")
}
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")
}
pub fn agent_hang_marker(agent: &str) -> PathBuf {
if agent == "agent0" {
return home().join(AGENT0_HANG_MARKER);
}
agent_state_file(agent, "hang-suspected")
}
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")
}
pub fn agent0_restart_attempts_file() -> PathBuf {
home().join(AGENT0_RESTART_ATTEMPTS_FILE)
}
pub fn agent0_crashloop_marker() -> PathBuf {
home().join(AGENT0_CRASHLOOP_MARKER)
}
pub fn restart_status_dir() -> PathBuf {
home().join(RESTART_STATUS_SUBDIR)
}
pub fn restart_archive_dir() -> PathBuf {
home().join(RESTART_ARCHIVE_SUBDIR)
}
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 {
logs_dir().join(format!("watchdog-events-{day}.jsonl"))
}
pub fn watchdog_event_log_path() -> PathBuf {
watchdog_event_log_for(&Utc::now().format("%Y-%m-%d").to_string())
}
pub fn logs_dir() -> PathBuf {
home().join(LOGS_SUBDIR)
}
pub fn ensure_logs_dir() -> std::io::Result<()> {
std::fs::create_dir_all(logs_dir())
}
pub fn escalate_failed_marker(ts: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let millis = Utc::now().format("%3f");
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
state_dir().join(format!(
"{ESCALATE_FAILED_MARKER_PREFIX}{ts}-{millis}-{n:04}"
))
}
pub fn agent0_quiet_sentinel_for(epoch: u64) -> PathBuf {
state_dir().join(format!("{AGENT0_QUIET_UNTIL_PREFIX}{epoch}"))
}
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"))
}
pub fn ensure_state_dir() -> std::io::Result<()> {
std::fs::create_dir_all(state_dir())
}
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())
}
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("src/crates/netsky-core/prompts")).unwrap();
fs::write(
root.join("src/crates/netsky-core/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");
fs::create_dir_all(tmp.path().join("src/crates/netsky-core/prompts")).unwrap();
fs::write(
tmp.path().join("src/crates/netsky-core/prompts/base.md"),
"x",
)
.unwrap();
assert!(
!is_netsky_source_tree(tmp.path()),
"only base.md present should fail"
);
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));
}
#[test]
fn escalate_failed_marker_uses_unique_paths_for_rapid_calls() {
let first = escalate_failed_marker("20260417T110000Z");
let second = escalate_failed_marker("20260417T110000Z");
assert_ne!(first, second);
}
}