1use anyhow::Result;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Default)]
9pub struct SessionMetadata {
10 pub pr_number: Option<u64>,
11 pub pr_url: Option<String>,
12 pub branch: Option<String>,
13 pub pr_reports: Vec<PrReport>,
17}
18
19#[derive(Debug, Clone, PartialEq)]
20pub struct PrReport {
21 pub number: u64,
22 pub url: Option<String>,
23}
24
25#[derive(Debug, Clone, PartialEq)]
30pub struct WorkRequest {
31 pub id: String,
32 pub description: String,
33 pub requested_at: i64,
34}
35
36const GH_WRAPPER: &str = r#"#!/usr/bin/env bash
47# Ninox gh wrapper — intercepts gh pr create to record PR metadata.
48set -euo pipefail
49
50# Locate the real gh binary (skip ourselves).
51_real_gh=""
52IFS=: read -ra _path_parts <<< "$PATH"
53for _dir in "${_path_parts[@]}"; do
54 _candidate="$_dir/gh"
55 if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
56 _real_gh="$_candidate"
57 break
58 fi
59done
60if [[ -z "$_real_gh" ]]; then
61 echo "ninox: gh not found in PATH (excluding wrapper)" >&2
62 exit 1
63fi
64
65# Run the real gh and tee output so we can parse it.
66if [[ "${1:-}" == "pr" && "${2:-}" == "create" ]]; then
67 _output=$("$_real_gh" "$@" 2>&1)
68 _exit=$?
69 echo "$_output"
70 if [[ $_exit -eq 0 && -n "${ATHENE_SESSION:-}" && -n "${ATHENE_DATA_DIR:-}" ]]; then
71 _pr_url=$(echo "$_output" | grep -oE 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+' | head -1)
72 if [[ -n "$_pr_url" ]]; then
73 _pr_num=$(echo "$_pr_url" | grep -oE '[0-9]+$')
74 _meta_file="${ATHENE_DATA_DIR}/${ATHENE_SESSION}.json"
75 mkdir -p "$(dirname "$_meta_file")"
76 _tmp="${_meta_file}.tmp.$$"
77 if [[ -f "$_meta_file" ]]; then
78 _existing=$(cat "$_meta_file")
79 else
80 _existing="{}"
81 fi
82 if command -v jq &>/dev/null; then
83 echo "$_existing" | jq \
84 --arg url "$_pr_url" \
85 --arg num "$_pr_num" \
86 '. + {"agentReportedPrUrl": $url, "agentReportedPrNumber": $num, "agentReportedState": "pr_created"}
87 | .agentReportedPrs = ((.agentReportedPrs // []) + [{"number": $num, "url": $url}])' \
88 > "$_tmp" && mv "$_tmp" "$_meta_file"
89 else
90 # Fallback: node (likely available alongside gh).
91 # PR URL and number are passed via env vars, not interpolated into the
92 # script string, to avoid shell injection from external GitHub output.
93 NINOX_PR_URL="$_pr_url" NINOX_PR_NUM="$_pr_num" node -e "
94 const fs = require('fs');
95 const url = process.env.NINOX_PR_URL;
96 const num = process.env.NINOX_PR_NUM;
97 const f = '${_meta_file}';
98 const m = JSON.parse(fs.existsSync(f) ? fs.readFileSync(f,'utf8') : '{}');
99 m.agentReportedPrUrl = url;
100 m.agentReportedPrNumber = num;
101 m.agentReportedState = 'pr_created';
102 m.agentReportedPrs = (Array.isArray(m.agentReportedPrs) ? m.agentReportedPrs : [])
103 .concat([{ number: num, url: url }]);
104 fs.writeFileSync(f + '.tmp.\$\$', JSON.stringify(m,null,2));
105 fs.renameSync(f + '.tmp.\$\$', f);
106 " 2>/dev/null || true
107 fi
108 fi
109 fi
110 exit $_exit
111else
112 exec "$_real_gh" "$@"
113fi
114"#;
115
116const GIT_WRAPPER: &str = r#"#!/usr/bin/env bash
118# Ninox git wrapper — records branch name on checkout -b / switch -c.
119set -euo pipefail
120
121_real_git=""
122IFS=: read -ra _path_parts <<< "$PATH"
123for _dir in "${_path_parts[@]}"; do
124 _candidate="$_dir/git"
125 if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
126 _real_git="$_candidate"
127 break
128 fi
129done
130if [[ -z "$_real_git" ]]; then
131 echo "ninox: git not found in PATH (excluding wrapper)" >&2
132 exit 1
133fi
134
135# Run the real git command first.
136"$_real_git" "$@"
137_exit=$?
138
139# On success, capture branch name for checkout -b / switch -c.
140if [[ $_exit -eq 0 && -n "${ATHENE_SESSION:-}" && -n "${ATHENE_DATA_DIR:-}" ]]; then
141 _branch=""
142 if [[ "${1:-}" == "checkout" && "${2:-}" == "-b" && -n "${3:-}" ]]; then
143 _branch="${3}"
144 elif [[ "${1:-}" == "switch" && "${2:-}" == "-c" && -n "${3:-}" ]]; then
145 _branch="${3}"
146 fi
147 if [[ -n "$_branch" ]]; then
148 _meta_file="${ATHENE_DATA_DIR}/${ATHENE_SESSION}.json"
149 mkdir -p "$(dirname "$_meta_file")"
150 if command -v jq &>/dev/null; then
151 _tmp="${_meta_file}.tmp.$$"
152 _existing=$([ -f "$_meta_file" ] && cat "$_meta_file" || echo "{}")
153 echo "$_existing" | jq --arg b "$_branch" '. + {"branch": $b}' \
154 > "$_tmp" && mv "$_tmp" "$_meta_file"
155 fi
156 fi
157fi
158
159exit $_exit
160"#;
161
162pub fn install_wrappers_to(bin_dir: &Path) -> Result<()> {
169 std::fs::create_dir_all(bin_dir)?;
170 write_executable(bin_dir.join("gh"), GH_WRAPPER)?;
171 write_executable(bin_dir.join("git"), GIT_WRAPPER)?;
172 Ok(())
173}
174
175pub fn install_wrappers() -> Result<()> {
177 install_wrappers_to(&crate::config::AppConfig::ninox_bin_dir())
178}
179
180pub fn install_self_shim(current_exe: &Path) -> Result<()> {
185 let bin_dir = crate::config::AppConfig::ninox_bin_dir();
186 std::fs::create_dir_all(&bin_dir)?;
187 let exe = current_exe.to_string_lossy().replace('\'', "'\\''");
188 let script = format!(
189 "#!/usr/bin/env bash\nexec '{}' \"$@\"\n",
190 exe
191 );
192 write_executable(bin_dir.join("ninox"), &script)?;
193 Ok(())
194}
195
196pub fn read_session_metadata(dir: &Path, session_id: &str) -> Result<SessionMetadata> {
199 let map = match read_metadata_map(&metadata_path(dir, session_id))? {
200 Some(m) => m,
201 None => return Ok(SessionMetadata::default()),
202 };
203 let pr_number = map.get("agentReportedPrNumber").and_then(as_u64);
204 let pr_url = map.get("agentReportedPrUrl")
205 .and_then(|v| v.as_str())
206 .map(|s| s.to_string());
207 let branch = map.get("branch")
208 .and_then(|v| v.as_str())
209 .map(|s| s.to_string());
210
211 let mut pr_reports: Vec<PrReport> = map.get("agentReportedPrs")
212 .and_then(|v| v.as_array())
213 .map(|prs| {
214 prs.iter()
215 .filter_map(|p| {
216 Some(PrReport {
217 number: as_u64(p.get("number")?)?,
218 url: p.get("url").and_then(|u| u.as_str()).map(String::from),
219 })
220 })
221 .collect()
222 })
223 .unwrap_or_default();
224 if pr_reports.is_empty() {
227 if let Some(number) = pr_number {
228 pr_reports.push(PrReport { number, url: pr_url.clone() });
229 }
230 }
231
232 Ok(SessionMetadata { pr_number, pr_url, branch, pr_reports })
233}
234
235pub fn append_work_request(dir: &Path, session_id: &str, description: &str) -> Result<WorkRequest> {
240 use std::sync::atomic::{AtomicU32, Ordering};
241 static SEQ: AtomicU32 = AtomicU32::new(0);
242
243 let requested_at = std::time::SystemTime::now()
244 .duration_since(std::time::UNIX_EPOCH)
245 .map(|d| d.as_millis() as i64)
246 .unwrap_or(0);
247 let request = WorkRequest {
250 id: format!(
251 "wr-{requested_at}-{}-{}",
252 std::process::id(),
253 SEQ.fetch_add(1, Ordering::Relaxed),
254 ),
255 description: description.to_string(),
256 requested_at,
257 };
258
259 let requests_dir = work_requests_dir(dir, session_id);
260 std::fs::create_dir_all(&requests_dir)?;
261 let path = requests_dir.join(format!("{}.json", request.id));
262 let body = serde_json::json!({
263 "id": request.id,
264 "description": request.description,
265 "requestedAt": request.requested_at,
266 });
267 let tmp = path.with_extension("tmp");
268 std::fs::write(&tmp, serde_json::to_string_pretty(&body)?)?;
269 std::fs::rename(&tmp, &path)?;
270 Ok(request)
271}
272
273pub fn read_pending_work_requests(dir: &Path, session_id: &str) -> Result<Vec<WorkRequest>> {
275 let requests_dir = work_requests_dir(dir, session_id);
276 let entries = match std::fs::read_dir(&requests_dir) {
277 Ok(e) => e,
278 Err(_) => return Ok(Vec::new()), };
280 let mut requests: Vec<WorkRequest> = entries
281 .filter_map(|e| e.ok())
282 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
283 .filter_map(|e| {
284 let raw = std::fs::read_to_string(e.path()).ok()?;
285 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
286 Some(WorkRequest {
287 id: v.get("id")?.as_str()?.to_string(),
288 description: v.get("description")?.as_str()?.to_string(),
289 requested_at: v.get("requestedAt").and_then(|t| t.as_i64()).unwrap_or(0),
290 })
291 })
292 .collect();
293 requests.sort_by(|a, b| a.requested_at.cmp(&b.requested_at).then(a.id.cmp(&b.id)));
294 Ok(requests)
295}
296
297pub fn mark_work_requests_delivered(dir: &Path, session_id: &str, ids: &[String]) -> Result<()> {
303 let requests_dir = work_requests_dir(dir, session_id);
304 let mut first_err: Option<std::io::Error> = None;
305 for id in ids {
306 let path = requests_dir.join(format!("{id}.json"));
307 if path.exists() {
308 let delivered = requests_dir.join(format!("{id}.json.delivered"));
309 if let Err(e) = std::fs::rename(&path, &delivered) {
310 first_err.get_or_insert(e);
311 }
312 }
313 }
314 match first_err {
315 Some(e) => Err(e.into()),
316 None => Ok(()),
317 }
318}
319
320pub fn remove_session_artifacts(dir: &Path, session_id: &str) {
325 let _ = std::fs::remove_file(metadata_path(dir, session_id));
326 let _ = std::fs::remove_dir_all(work_requests_dir(dir, session_id));
327 let _ = std::fs::remove_file(notified_prs_path(dir, session_id));
328}
329
330pub fn read_notified_extra_prs(dir: &Path, session_id: &str) -> Vec<u64> {
335 let path = notified_prs_path(dir, session_id);
336 std::fs::read_to_string(&path)
337 .ok()
338 .and_then(|raw| serde_json::from_str::<Vec<u64>>(&raw).ok())
339 .unwrap_or_default()
340}
341
342pub fn mark_extra_prs_notified(dir: &Path, session_id: &str, numbers: &[u64]) -> Result<()> {
345 let path = notified_prs_path(dir, session_id);
346 let mut notified = read_notified_extra_prs(dir, session_id);
347 for n in numbers {
348 if !notified.contains(n) {
349 notified.push(*n);
350 }
351 }
352 if let Some(parent) = path.parent() {
353 std::fs::create_dir_all(parent)?;
354 }
355 let tmp = path.with_extension("tmp");
356 std::fs::write(&tmp, serde_json::to_string(¬ified)?)?;
357 std::fs::rename(&tmp, &path)?;
358 Ok(())
359}
360
361fn metadata_path(dir: &Path, session_id: &str) -> PathBuf {
366 dir.join(format!("{session_id}.json"))
367}
368
369fn work_requests_dir(dir: &Path, session_id: &str) -> PathBuf {
370 dir.join(format!("{session_id}.requests"))
371}
372
373fn notified_prs_path(dir: &Path, session_id: &str) -> PathBuf {
374 dir.join(format!("{session_id}.notified-prs.json"))
375}
376
377fn read_metadata_map(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
380 if !path.exists() {
381 return Ok(None);
382 }
383 let raw = std::fs::read_to_string(path)?;
384 Ok(serde_json::from_str(&raw).ok())
385}
386
387fn as_u64(v: &serde_json::Value) -> Option<u64> {
389 v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
390}
391
392fn write_executable(path: PathBuf, content: &str) -> Result<()> {
393 let tmp = path.with_extension("tmp");
395 std::fs::write(&tmp, content)?;
396 #[cfg(unix)]
397 {
398 use std::os::unix::fs::PermissionsExt;
399 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
400 }
401 std::fs::rename(&tmp, &path)?;
402 Ok(())
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use tempfile::tempdir;
409
410 #[test]
411 fn install_wrappers_creates_executables() {
412 let dir = tempdir().unwrap();
413 install_wrappers_to(dir.path()).unwrap();
414 assert!(dir.path().join("gh").exists());
415 assert!(dir.path().join("git").exists());
416 #[cfg(unix)]
417 {
418 use std::os::unix::fs::PermissionsExt;
419 let gh_mode = std::fs::metadata(dir.path().join("gh"))
420 .unwrap().permissions().mode();
421 assert!(gh_mode & 0o111 != 0, "gh wrapper should be executable");
422 }
423 }
424
425 #[test]
426 fn read_session_metadata_parses_pr_number() {
427 let dir = tempdir().unwrap();
428 let metadata = serde_json::json!({
429 "agentReportedPrNumber": "42",
430 "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
431 "branch": "feat/my-fix"
432 });
433 std::fs::write(
434 dir.path().join("s1.json"),
435 serde_json::to_string(&metadata).unwrap(),
436 ).unwrap();
437 let m = read_session_metadata(dir.path(), "s1").unwrap();
438 assert_eq!(m.pr_number, Some(42));
439 assert_eq!(m.branch.as_deref(), Some("feat/my-fix"));
440 }
441
442 #[test]
443 fn read_session_metadata_returns_default_on_missing_file() {
444 let dir = tempdir().unwrap();
445 let m = read_session_metadata(dir.path(), "nonexistent").unwrap();
446 assert_eq!(m.pr_number, None);
447 assert_eq!(m.branch, None);
448 }
449
450 #[test]
451 fn read_session_metadata_handles_malformed_json() {
452 let dir = tempdir().unwrap();
453 std::fs::write(dir.path().join("bad.json"), "not json").unwrap();
454 let m = read_session_metadata(dir.path(), "bad").unwrap();
455 assert_eq!(m.pr_number, None);
456 }
457
458 #[test]
459 fn append_work_request_accumulates_pending_requests() {
460 let dir = tempdir().unwrap();
461 let first = append_work_request(dir.path(), "s1", "Fix flaky auth test").unwrap();
462 let second = append_work_request(dir.path(), "s1", "Migrate config loader").unwrap();
463 assert_ne!(first.id, second.id, "each request needs a distinct id");
464
465 let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
466 assert_eq!(pending.len(), 2);
467 assert_eq!(pending[0].description, "Fix flaky auth test");
468 assert_eq!(pending[1].description, "Migrate config loader");
469 }
470
471 #[test]
475 fn append_work_request_leaves_shared_metadata_file_alone() {
476 let dir = tempdir().unwrap();
477 let shared = r#"{"agentReportedPrNumber": "42", "branch": "feat/x"}"#;
478 std::fs::write(dir.path().join("s1.json"), shared).unwrap();
479 append_work_request(dir.path(), "s1", "New task").unwrap();
480 assert_eq!(
481 std::fs::read_to_string(dir.path().join("s1.json")).unwrap(),
482 shared,
483 "shared metadata JSON must be byte-identical after a work request",
484 );
485 let m = read_session_metadata(dir.path(), "s1").unwrap();
486 assert_eq!(m.pr_number, Some(42));
487 assert_eq!(read_pending_work_requests(dir.path(), "s1").unwrap().len(), 1);
488 }
489
490 #[test]
491 fn mark_work_requests_delivered_removes_only_named_ids_from_pending() {
492 let dir = tempdir().unwrap();
493 let first = append_work_request(dir.path(), "s1", "task a").unwrap();
494 let second = append_work_request(dir.path(), "s1", "task b").unwrap();
495
496 mark_work_requests_delivered(dir.path(), "s1", std::slice::from_ref(&first.id)).unwrap();
497
498 let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
499 assert_eq!(pending.len(), 1);
500 assert_eq!(pending[0].id, second.id);
501 }
502
503 #[test]
504 fn pending_work_requests_empty_when_none_recorded() {
505 let dir = tempdir().unwrap();
506 assert!(read_pending_work_requests(dir.path(), "s1").unwrap().is_empty());
507 }
508
509 #[test]
513 fn remove_session_artifacts_clears_all_files_for_that_session_only() {
514 let dir = tempdir().unwrap();
515 std::fs::write(dir.path().join("s1.json"), "{}").unwrap();
516 append_work_request(dir.path(), "s1", "task").unwrap();
517 mark_extra_prs_notified(dir.path(), "s1", &[9]).unwrap();
518 std::fs::write(dir.path().join("s2.json"), "{}").unwrap();
519 append_work_request(dir.path(), "s2", "other").unwrap();
520
521 remove_session_artifacts(dir.path(), "s1");
522
523 assert!(!dir.path().join("s1.json").exists());
524 assert!(!dir.path().join("s1.requests").exists());
525 assert!(!dir.path().join("s1.notified-prs.json").exists());
526 assert!(dir.path().join("s2.json").exists(), "other sessions untouched");
527 assert_eq!(read_pending_work_requests(dir.path(), "s2").unwrap().len(), 1);
528 remove_session_artifacts(dir.path(), "missing");
530 }
531
532 #[test]
536 fn notified_extra_prs_round_trip_and_accumulate() {
537 let dir = tempdir().unwrap();
538 assert!(read_notified_extra_prs(dir.path(), "s1").is_empty());
539 mark_extra_prs_notified(dir.path(), "s1", &[43, 44]).unwrap();
540 mark_extra_prs_notified(dir.path(), "s1", &[45]).unwrap();
541 let notified = read_notified_extra_prs(dir.path(), "s1");
542 assert_eq!(notified, vec![43, 44, 45]);
543 assert!(read_notified_extra_prs(dir.path(), "s2").is_empty());
545 }
546
547 #[test]
548 fn read_session_metadata_parses_reported_pr_list() {
549 let dir = tempdir().unwrap();
550 let metadata = serde_json::json!({
551 "agentReportedPrNumber": "44",
552 "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
553 "agentReportedPrs": [
554 {"number": "42", "url": "https://github.com/org/repo/pull/42"},
555 {"number": "43", "url": "https://github.com/org/repo/pull/43"},
556 {"number": "44", "url": "https://github.com/org/repo/pull/44"},
557 ],
558 });
559 std::fs::write(
560 dir.path().join("s1.json"),
561 serde_json::to_string(&metadata).unwrap(),
562 ).unwrap();
563 let m = read_session_metadata(dir.path(), "s1").unwrap();
564 let numbers: Vec<u64> = m.pr_reports.iter().map(|p| p.number).collect();
565 assert_eq!(numbers, vec![42, 43, 44]);
566 assert_eq!(
567 m.pr_reports[0].url.as_deref(),
568 Some("https://github.com/org/repo/pull/42"),
569 );
570 }
571
572 #[test]
576 fn read_session_metadata_synthesizes_pr_list_from_legacy_scalars() {
577 let dir = tempdir().unwrap();
578 let metadata = serde_json::json!({
579 "agentReportedPrNumber": "42",
580 "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
581 });
582 std::fs::write(
583 dir.path().join("s1.json"),
584 serde_json::to_string(&metadata).unwrap(),
585 ).unwrap();
586 let m = read_session_metadata(dir.path(), "s1").unwrap();
587 assert_eq!(m.pr_reports.len(), 1);
588 assert_eq!(m.pr_reports[0].number, 42);
589 }
590
591 #[test]
595 fn gh_wrapper_appends_to_pr_list() {
596 assert!(GH_WRAPPER.contains("agentReportedPrs"));
597 }
598}