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, 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
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-core/prompts/base.md").is_file()
77 && p.join("src/crates/netsky-cli/Cargo.toml").is_file()
78}
79
80pub 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
124pub fn crash_handoffs_dir() -> PathBuf {
128 home().join(CRASH_HANDOFFS_SUBDIR)
129}
130
131pub 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
138pub fn prompt_file_for(agent_name: &str) -> PathBuf {
142 prompts_dir().join(format!("{agent_name}.md"))
143}
144
145pub 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
203pub 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
211pub 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
219pub 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
227pub fn agent0_restart_attempts_file() -> PathBuf {
230 home().join(AGENT0_RESTART_ATTEMPTS_FILE)
231}
232
233pub fn agent0_crashloop_marker() -> PathBuf {
237 home().join(AGENT0_CRASHLOOP_MARKER)
238}
239
240pub fn restart_status_dir() -> PathBuf {
243 home().join(RESTART_STATUS_SUBDIR)
244}
245
246pub fn restart_archive_dir() -> PathBuf {
250 home().join(RESTART_ARCHIVE_SUBDIR)
251}
252
253pub 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
262pub 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
274pub 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
285pub 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
300pub fn agent0_quiet_sentinel_for(epoch: u64) -> PathBuf {
304 state_dir().join(format!("{AGENT0_QUIET_UNTIL_PREFIX}{epoch}"))
305}
306
307pub 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
330pub fn ensure_state_dir() -> std::io::Result<()> {
332 std::fs::create_dir_all(state_dir())
333}
334
335pub 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
344pub 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 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 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}