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