1use 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
28pub 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 if let Some(p) = env_dir {
48 return p.to_path_buf();
49 }
50
51 home_dir.join(NETSKY_DIR_DEFAULT_SUBDIR)
54}
55
56pub 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
70pub 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
81pub 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
125pub fn crash_handoffs_dir() -> PathBuf {
129 home().join(CRASH_HANDOFFS_SUBDIR)
130}
131
132pub 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
139pub fn prompt_file_for(agent_name: &str) -> PathBuf {
143 prompts_dir().join(format!("{agent_name}.md"))
144}
145
146pub 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
204pub 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
212pub 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
220pub 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
228pub fn agent0_restart_attempts_file() -> PathBuf {
231 home().join(AGENT0_RESTART_ATTEMPTS_FILE)
232}
233
234pub fn agent0_crashloop_marker() -> PathBuf {
238 home().join(AGENT0_CRASHLOOP_MARKER)
239}
240
241pub fn restart_status_dir() -> PathBuf {
244 home().join(RESTART_STATUS_SUBDIR)
245}
246
247pub fn restart_archive_dir() -> PathBuf {
251 home().join(RESTART_ARCHIVE_SUBDIR)
252}
253
254pub 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
263pub 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
275pub 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
286pub 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
301pub fn agent0_quiet_sentinel_for(epoch: u64) -> PathBuf {
305 state_dir().join(format!("{AGENT0_QUIET_UNTIL_PREFIX}{epoch}"))
306}
307
308pub 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
339pub fn ensure_state_dir() -> std::io::Result<()> {
341 std::fs::create_dir_all(state_dir())
342}
343
344pub 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
353pub 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 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 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}