Skip to main content

netsky_core/
paths.rs

1//! Filesystem paths used across the netsky runtime.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use chrono::Utc;
7
8use crate::Result;
9use crate::consts::{
10    AGENT0_CRASHLOOP_MARKER, AGENT0_HANG_MARKER, AGENT0_HANG_PAGED_MARKER, AGENT0_INBOX_SUBDIR,
11    AGENT0_PANE_HASH_FILE, AGENT0_QUIET_UNTIL_PREFIX, AGENT0_RESTART_ATTEMPTS_FILE,
12    AGENTINFINITY_READY_MARKER, AGENTINIT_ESCALATION_MARKER, AGENTINIT_FAILURES_FILE,
13    CRASH_HANDOFF_FILENAME_PREFIX, CRASH_HANDOFF_FILENAME_SUFFIX, CRASH_HANDOFFS_SUBDIR,
14    ENV_NETSKY_DIR, ESCALATE_FAILED_MARKER_PREFIX, HANDOFF_ARCHIVE_SUBDIR, LAUNCHD_LABEL,
15    LAUNCHD_PLIST_SUBDIR, LOGS_SUBDIR, LOOP_RESUME_FILE, NETSKY_DIR_DEFAULT_SUBDIR, PROMPTS_SUBDIR,
16    RESTART_ARCHIVE_SUBDIR, RESTART_DETACHED_LOG_FILENAME, RESTART_STATUS_SUBDIR, STATE_DIR,
17    TICKER_MISSING_COUNT_FILE,
18};
19
20fn agent_state_file(agent: &str, suffix: &str) -> PathBuf {
21    state_dir().join(format!("{agent}-{suffix}"))
22}
23
24pub fn home() -> PathBuf {
25    dirs::home_dir().expect("netsky requires a home directory")
26}
27
28/// Resolve the canonical netsky root. Resolution order:
29///
30/// 1. `$NETSKY_DIR` when set.
31/// 2. `$HOME/netsky`.
32///
33/// The default is deliberately independent of cwd. `cargo install netsky`
34/// hosts should converge on `~/netsky` after `netsky init`; running from
35/// a random checkout or workspace must not silently make that directory
36/// the constellation root. Developers who need a different root can set
37/// `$NETSKY_DIR` explicitly.
38pub fn resolve_netsky_dir() -> PathBuf {
39    let home_dir = home();
40    let env = std::env::var_os(ENV_NETSKY_DIR).map(PathBuf::from);
41    resolve_netsky_dir_from(env.as_deref(), &home_dir)
42}
43
44fn resolve_netsky_dir_from(env_dir: Option<&Path>, home_dir: &Path) -> PathBuf {
45    // 1. $NETSKY_DIR if set — accept source trees, plain dirs, and paths
46    //    that `netsky init --path "$NETSKY_DIR"` will create.
47    if let Some(p) = env_dir {
48        return p.to_path_buf();
49    }
50
51    // 2. Fall back: ~/netsky (created by `netsky init` or
52    //    ensure_netsky_dir on first use).
53    home_dir.join(NETSKY_DIR_DEFAULT_SUBDIR)
54}
55
56/// Walk from `start` toward the filesystem root looking for a directory
57/// satisfying [`is_netsky_source_tree`]. Returns the first match. Used as
58/// the dev escape hatch when the user is in `workspaces/<task>/repo`
59/// (a valid netsky checkout deeper in the tree) and `$NETSKY_DIR` is
60/// unset.
61pub fn walk_up_to_netsky_dir(start: &Path) -> Option<PathBuf> {
62    for ancestor in start.ancestors() {
63        if is_netsky_source_tree(ancestor) {
64            return Some(ancestor.to_path_buf());
65        }
66    }
67    None
68}
69
70/// Advisory source-tree check. This is no longer a hard gate.
71///
72/// The embedded prompts mean binary-only installs do not need a source
73/// checkout. Callers that care about developer mode can still use this
74/// to prefer a checkout when one is available.
75pub fn is_netsky_source_tree(p: &Path) -> bool {
76    p.join("src/crates/netsky-core/prompts/base.md").is_file()
77        && p.join("src/crates/netsky-cli/Cargo.toml").is_file()
78}
79
80/// Hard-exit guard for side-effecting top-level commands. Returns Ok if
81/// the current working directory matches the resolved netsky dir.
82/// Binary-only mode treats the resolved dir as `~/.netsky` and skips
83/// the cwd gate.
84///
85/// Otherwise prints a one-line stderr message naming the expected dir
86/// and exits the process with code 2.
87///
88/// Skipping the gate on read-only commands (doctor, attach, --help) is
89/// deliberate: those should work from anywhere so the operator can
90/// diagnose a misconfigured machine without first fighting the cwd.
91pub fn require_netsky_cwd(command_name: &str) -> std::io::Result<()> {
92    let resolved = resolve_netsky_dir();
93    if !is_netsky_source_tree(&resolved) {
94        return Ok(());
95    }
96    let cwd = std::env::current_dir()?;
97    let cwd_canon = std::fs::canonicalize(&cwd).unwrap_or(cwd);
98    let resolved_canon = std::fs::canonicalize(&resolved).unwrap_or(resolved.clone());
99    if cwd_canon != resolved_canon {
100        eprintln!(
101            "netsky: refusing to run `{command_name}` from {}; expected cwd is {} \
102             ($NETSKY_DIR or $HOME/netsky). cd there and retry, or set NETSKY_DIR, or \
103             install via `cargo install netsky` and run from any directory.",
104            cwd_canon.display(),
105            resolved_canon.display(),
106        );
107        std::process::exit(2);
108    }
109    Ok(())
110}
111
112pub fn state_dir() -> PathBuf {
113    home().join(STATE_DIR)
114}
115
116pub fn cron_file_path() -> PathBuf {
117    state_dir().join("cron.toml")
118}
119
120pub fn prompts_dir() -> PathBuf {
121    home().join(PROMPTS_SUBDIR)
122}
123
124/// Directory holding crash-handoff drafts written by the watchdog on
125/// crash-recovery. Lives under the durable state dir so the macOS /tmp
126/// reaper does not eat pending handoffs after ~3 days.
127pub fn crash_handoffs_dir() -> PathBuf {
128    home().join(CRASH_HANDOFFS_SUBDIR)
129}
130
131/// Canonical crash-handoff path for a given pid under [`crash_handoffs_dir`].
132pub fn crash_handoff_file_for(pid: u32) -> PathBuf {
133    crash_handoffs_dir().join(format!(
134        "{CRASH_HANDOFF_FILENAME_PREFIX}{pid}{CRASH_HANDOFF_FILENAME_SUFFIX}"
135    ))
136}
137
138/// Path to the on-disk system-prompt file for `agent_name`. The spawner
139/// atomically writes the rendered prompt here and sets `NETSKY_PROMPT_FILE`
140/// to this path so the tmux-spawned shell can `cat` it at exec time.
141pub fn prompt_file_for(agent_name: &str) -> PathBuf {
142    prompts_dir().join(format!("{agent_name}.md"))
143}
144
145/// Refuse to traverse any symlink under `root` on the path to `target`.
146///
147/// Missing components are allowed so callers can still `create_dir_all`
148/// the destination afterward. This keeps channel writes and drains from
149/// following a tampered inbox tree out of the netsky state directory.
150pub fn assert_no_symlink_under(root: &Path, target: &Path) -> Result<()> {
151    let rel = match target.strip_prefix(root) {
152        Ok(r) => r,
153        Err(_) => crate::bail!(
154            "internal: target {} is not under channel root {}",
155            target.display(),
156            root.display()
157        ),
158    };
159    if let Ok(meta) = std::fs::symlink_metadata(root)
160        && meta.file_type().is_symlink()
161    {
162        crate::bail!("refusing to operate on symlinked root {}", root.display());
163    }
164    let mut cur = root.to_path_buf();
165    for comp in rel.components() {
166        cur.push(comp);
167        match std::fs::symlink_metadata(&cur) {
168            Ok(meta) if meta.file_type().is_symlink() => {
169                crate::bail!("refusing to traverse symlink at {}", cur.display());
170            }
171            Ok(_) => {}
172            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
173            Err(e) => return Err(e.into()),
174        }
175    }
176    Ok(())
177}
178
179pub fn agentinfinity_ready_marker() -> PathBuf {
180    home().join(AGENTINFINITY_READY_MARKER)
181}
182
183pub fn agentinit_escalation_marker() -> PathBuf {
184    home().join(AGENTINIT_ESCALATION_MARKER)
185}
186
187pub fn agentinit_failures_file() -> PathBuf {
188    home().join(AGENTINIT_FAILURES_FILE)
189}
190
191pub fn agent0_pane_hash_file() -> PathBuf {
192    agent_pane_hash_file("agent0")
193}
194
195pub fn agent0_hang_marker() -> PathBuf {
196    agent_hang_marker("agent0")
197}
198
199pub fn agent0_hang_paged_marker() -> PathBuf {
200    agent_hang_paged_marker("agent0")
201}
202
203/// Per-agent pane-hash state for generalized hang detection.
204pub fn agent_pane_hash_file(agent: &str) -> PathBuf {
205    if agent == "agent0" {
206        return home().join(AGENT0_PANE_HASH_FILE);
207    }
208    agent_state_file(agent, "pane-hash")
209}
210
211/// Per-agent hang marker. `agent0` keeps its historical path.
212pub fn agent_hang_marker(agent: &str) -> PathBuf {
213    if agent == "agent0" {
214        return home().join(AGENT0_HANG_MARKER);
215    }
216    agent_state_file(agent, "hang-suspected")
217}
218
219/// Per-agent hang page marker. `agent0` keeps its historical path.
220pub fn agent_hang_paged_marker(agent: &str) -> PathBuf {
221    if agent == "agent0" {
222        return home().join(AGENT0_HANG_PAGED_MARKER);
223    }
224    agent_state_file(agent, "hang-paged")
225}
226
227/// P0-1 crashloop sliding-window attempts file. Newline-delimited unix
228/// ts; pruned by the watchdog on every append.
229pub fn agent0_restart_attempts_file() -> PathBuf {
230    home().join(AGENT0_RESTART_ATTEMPTS_FILE)
231}
232
233/// P0-1 crashloop marker. Presence = the watchdog has fired escalation
234/// for a sustained restart-failure pattern; cleared on the first
235/// healthy tick after recovery.
236pub fn agent0_crashloop_marker() -> PathBuf {
237    home().join(AGENT0_CRASHLOOP_MARKER)
238}
239
240/// P0-2 restart-status directory. The detached `netsky restart` child
241/// writes `<ts>-<pid>.json` files here at known phase transitions.
242pub fn restart_status_dir() -> PathBuf {
243    home().join(RESTART_STATUS_SUBDIR)
244}
245
246/// P1-4 restart-archive directory. Forensic home for the detached
247/// restart log + archived stale-processing files. Out of the /tmp
248/// reaper window; swept on age by the watchdog tick preflight.
249pub fn restart_archive_dir() -> PathBuf {
250    home().join(RESTART_ARCHIVE_SUBDIR)
251}
252
253/// Canonical path for the detached restart subprocess stdout+stderr log.
254pub fn restart_detached_log_path() -> PathBuf {
255    restart_archive_dir().join(RESTART_DETACHED_LOG_FILENAME)
256}
257
258pub fn ticker_missing_count_file() -> PathBuf {
259    home().join(TICKER_MISSING_COUNT_FILE)
260}
261
262/// Durable JSONL event log for watchdog events. One file per UTC day
263/// under `~/.netsky/logs/`. Backend-agnostic: written before any DB
264/// call so a meta.db outage does not eat forensics (H4 spec,
265/// briefs/vnext-overnight/wave1-harvest.md section 5).
266pub fn watchdog_event_log_for(day: &str) -> PathBuf {
267    logs_dir().join(format!("watchdog-events-{day}.jsonl"))
268}
269
270pub fn watchdog_event_log_path() -> PathBuf {
271    watchdog_event_log_for(&Utc::now().format("%Y-%m-%d").to_string())
272}
273
274/// Durable logs directory, sibling to `state_dir()`. Auto-created on
275/// first write by `ensure_logs_dir()`; the event-log append path also
276/// creates parents defensively in case this helper was not called.
277pub fn logs_dir() -> PathBuf {
278    home().join(LOGS_SUBDIR)
279}
280
281pub fn ensure_logs_dir() -> std::io::Result<()> {
282    std::fs::create_dir_all(logs_dir())
283}
284
285/// Marker file written when `netsky escalate` fails on both attempts
286/// (first osascript timed out or errored, retry also fell over). The
287/// timestamp plus subsecond/counter suffix in the filename makes markers
288/// dedupe-able and sortable; the body carries subject + last stderr for
289/// forensics.
290pub fn escalate_failed_marker(ts: &str) -> PathBuf {
291    static COUNTER: AtomicU64 = AtomicU64::new(0);
292
293    let millis = Utc::now().format("%3f");
294    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
295    state_dir().join(format!(
296        "{ESCALATE_FAILED_MARKER_PREFIX}{ts}-{millis}-{n:04}"
297    ))
298}
299
300/// Path for a quiet sentinel that expires at `epoch` (unix seconds). The
301/// filename embeds the epoch so multiple arms can co-exist transiently
302/// and the watchdog picks the max. `netsky quiet <seconds>` writes one.
303pub fn agent0_quiet_sentinel_for(epoch: u64) -> PathBuf {
304    state_dir().join(format!("{AGENT0_QUIET_UNTIL_PREFIX}{epoch}"))
305}
306
307/// Filename prefix used by the watchdog to glob for quiet sentinels.
308pub fn agent0_quiet_sentinel_prefix() -> &'static str {
309    AGENT0_QUIET_UNTIL_PREFIX
310}
311
312pub fn loop_resume_file() -> PathBuf {
313    home().join(LOOP_RESUME_FILE)
314}
315
316pub fn handoff_archive_dir() -> PathBuf {
317    home().join(HANDOFF_ARCHIVE_SUBDIR)
318}
319
320pub fn agent0_inbox_dir() -> PathBuf {
321    home().join(AGENT0_INBOX_SUBDIR)
322}
323
324pub fn launchd_plist_path() -> PathBuf {
325    home()
326        .join(LAUNCHD_PLIST_SUBDIR)
327        .join(format!("{LAUNCHD_LABEL}.plist"))
328}
329
330/// Ensure the state directory exists. Idempotent.
331pub fn ensure_state_dir() -> std::io::Result<()> {
332    std::fs::create_dir_all(state_dir())
333}
334
335/// Ensure the netsky root + state subdirectory exist. Idempotent.
336/// Creates `~/netsky/` (the resolved root) and `~/.netsky/state/` (the
337/// durable state dir) so both binary-only and source-tree modes work.
338pub fn ensure_netsky_dir() -> std::io::Result<()> {
339    let root = resolve_netsky_dir();
340    std::fs::create_dir_all(&root)?;
341    std::fs::create_dir_all(state_dir())
342}
343
344/// Resolve the netsky root for spawn pathways (`cmd::agent::run`,
345/// `cmd::up::run`) so a clone always lands its tmux session on the
346/// netsky root, regardless of which random subdir agent0 happened to
347/// be in when it called `netsky agent N`.
348///
349/// Closes the spawn-cwd-pin gap from `briefs/clone-cwd-pin.md`
350/// (agent5): the `prompts/clone.md` stanza promises clones a netsky
351/// cwd, but `current_dir()` would silently inherit `workspaces/foo/`
352/// when agent0 wandered there. Now the resolver is consulted first.
353pub fn netsky_root_or_cwd() -> std::io::Result<PathBuf> {
354    Ok(resolve_netsky_dir())
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use std::fs;
361
362    fn make_valid_checkout(root: &Path) {
363        fs::create_dir_all(root.join("src/crates/netsky-core/prompts")).unwrap();
364        fs::write(
365            root.join("src/crates/netsky-core/prompts/base.md"),
366            "# base",
367        )
368        .unwrap();
369        fs::create_dir_all(root.join("src/crates/netsky-cli")).unwrap();
370        fs::write(
371            root.join("src/crates/netsky-cli/Cargo.toml"),
372            "[package]\nname = \"netsky\"\n",
373        )
374        .unwrap();
375    }
376
377    #[test]
378    fn is_netsky_source_tree_requires_both_sentinels() {
379        let tmp = tempfile::tempdir().unwrap();
380        assert!(!is_netsky_source_tree(tmp.path()), "empty dir should fail");
381
382        // Just one sentinel = still invalid.
383        fs::create_dir_all(tmp.path().join("src/crates/netsky-core/prompts")).unwrap();
384        fs::write(
385            tmp.path().join("src/crates/netsky-core/prompts/base.md"),
386            "x",
387        )
388        .unwrap();
389        assert!(
390            !is_netsky_source_tree(tmp.path()),
391            "only base.md present should fail"
392        );
393
394        // Both sentinels = valid.
395        fs::create_dir_all(tmp.path().join("src/crates/netsky-cli")).unwrap();
396        fs::write(tmp.path().join("src/crates/netsky-cli/Cargo.toml"), "x").unwrap();
397        assert!(
398            is_netsky_source_tree(tmp.path()),
399            "both sentinels should pass"
400        );
401    }
402
403    #[test]
404    fn walk_up_finds_valid_ancestor() {
405        let tmp = tempfile::tempdir().unwrap();
406        make_valid_checkout(tmp.path());
407        let nested = tmp.path().join("workspaces/iroh-v0/repo");
408        fs::create_dir_all(&nested).unwrap();
409        let found = walk_up_to_netsky_dir(&nested).expect("should find ancestor");
410        assert_eq!(
411            fs::canonicalize(&found).unwrap(),
412            fs::canonicalize(tmp.path()).unwrap()
413        );
414    }
415
416    #[test]
417    fn walk_up_returns_none_when_no_ancestor_valid() {
418        let tmp = tempfile::tempdir().unwrap();
419        let nested = tmp.path().join("a/b/c");
420        fs::create_dir_all(&nested).unwrap();
421        assert!(walk_up_to_netsky_dir(&nested).is_none());
422    }
423
424    #[test]
425    fn resolve_prefers_env_var_when_set() {
426        let tmp = tempfile::tempdir().unwrap();
427        let env = tmp.path().join("custom");
428        let resolved = resolve_netsky_dir_from(Some(&env), tmp.path());
429        assert_eq!(resolved, env);
430    }
431
432    #[test]
433    fn resolve_defaults_to_home_netsky_even_from_checkout() {
434        let tmp = tempfile::tempdir().unwrap();
435        let home = tmp.path().join("home");
436        fs::create_dir_all(&home).unwrap();
437        make_valid_checkout(tmp.path());
438        let nested = tmp.path().join("workspaces/task/repo");
439        fs::create_dir_all(&nested).unwrap();
440
441        let resolved = resolve_netsky_dir_from(None, &home);
442        assert_eq!(resolved, home.join(NETSKY_DIR_DEFAULT_SUBDIR));
443    }
444
445    #[test]
446    fn escalate_failed_marker_uses_unique_paths_for_rapid_calls() {
447        let first = escalate_failed_marker("20260417T110000Z");
448        let second = escalate_failed_marker("20260417T110000Z");
449
450        assert_ne!(first, second);
451    }
452}