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 _nx_session="${NINOX_SESSION:-}"
71 _nx_data_dir="${NINOX_DATA_DIR:-}"
72 if [[ $_exit -eq 0 && -n "$_nx_session" && -n "$_nx_data_dir" ]]; then
73 _pr_url=$(echo "$_output" | grep -oE 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+' | head -1)
74 if [[ -n "$_pr_url" ]]; then
75 _pr_num=$(echo "$_pr_url" | grep -oE '[0-9]+$')
76 _meta_file="${_nx_data_dir}/${_nx_session}.json"
77 mkdir -p "$(dirname "$_meta_file")"
78 _tmp="${_meta_file}.tmp.$$"
79 if [[ -f "$_meta_file" ]]; then
80 _existing=$(cat "$_meta_file")
81 else
82 _existing="{}"
83 fi
84 if command -v jq &>/dev/null; then
85 echo "$_existing" | jq \
86 --arg url "$_pr_url" \
87 --arg num "$_pr_num" \
88 '. + {"agentReportedPrUrl": $url, "agentReportedPrNumber": $num, "agentReportedState": "pr_created"}
89 | .agentReportedPrs = ((.agentReportedPrs // []) + [{"number": $num, "url": $url}])' \
90 > "$_tmp" && mv "$_tmp" "$_meta_file"
91 else
92 # Fallback: node (likely available alongside gh).
93 # PR URL and number are passed via env vars, not interpolated into the
94 # script string, to avoid shell injection from external GitHub output.
95 NINOX_PR_URL="$_pr_url" NINOX_PR_NUM="$_pr_num" node -e "
96 const fs = require('fs');
97 const url = process.env.NINOX_PR_URL;
98 const num = process.env.NINOX_PR_NUM;
99 const f = '${_meta_file}';
100 const m = JSON.parse(fs.existsSync(f) ? fs.readFileSync(f,'utf8') : '{}');
101 m.agentReportedPrUrl = url;
102 m.agentReportedPrNumber = num;
103 m.agentReportedState = 'pr_created';
104 m.agentReportedPrs = (Array.isArray(m.agentReportedPrs) ? m.agentReportedPrs : [])
105 .concat([{ number: num, url: url }]);
106 fs.writeFileSync(f + '.tmp.\$\$', JSON.stringify(m,null,2));
107 fs.renameSync(f + '.tmp.\$\$', f);
108 " 2>/dev/null || true
109 fi
110 fi
111 fi
112 exit $_exit
113else
114 exec "$_real_gh" "$@"
115fi
116"#;
117
118const GIT_WRAPPER: &str = r#"#!/usr/bin/env bash
120# Ninox git wrapper — records branch name on checkout -b / switch -c.
121set -euo pipefail
122
123_real_git=""
124IFS=: read -ra _path_parts <<< "$PATH"
125for _dir in "${_path_parts[@]}"; do
126 _candidate="$_dir/git"
127 if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
128 _real_git="$_candidate"
129 break
130 fi
131done
132if [[ -z "$_real_git" ]]; then
133 echo "ninox: git not found in PATH (excluding wrapper)" >&2
134 exit 1
135fi
136
137# Run the real git command first.
138"$_real_git" "$@"
139_exit=$?
140
141# On success, capture branch name for checkout -b / switch -c.
142_nx_session="${NINOX_SESSION:-}"
143_nx_data_dir="${NINOX_DATA_DIR:-}"
144if [[ $_exit -eq 0 && -n "$_nx_session" && -n "$_nx_data_dir" ]]; then
145 _branch=""
146 if [[ "${1:-}" == "checkout" && "${2:-}" == "-b" && -n "${3:-}" ]]; then
147 _branch="${3}"
148 elif [[ "${1:-}" == "switch" && "${2:-}" == "-c" && -n "${3:-}" ]]; then
149 _branch="${3}"
150 fi
151 if [[ -n "$_branch" ]]; then
152 _meta_file="${_nx_data_dir}/${_nx_session}.json"
153 mkdir -p "$(dirname "$_meta_file")"
154 if command -v jq &>/dev/null; then
155 _tmp="${_meta_file}.tmp.$$"
156 _existing=$([ -f "$_meta_file" ] && cat "$_meta_file" || echo "{}")
157 echo "$_existing" | jq --arg b "$_branch" '. + {"branch": $b}' \
158 > "$_tmp" && mv "$_tmp" "$_meta_file"
159 fi
160 fi
161fi
162
163exit $_exit
164"#;
165
166pub fn install_wrappers_to(bin_dir: &Path) -> Result<()> {
173 std::fs::create_dir_all(bin_dir)?;
174 write_executable(bin_dir.join("gh"), GH_WRAPPER)?;
175 write_executable(bin_dir.join("git"), GIT_WRAPPER)?;
176 Ok(())
177}
178
179pub fn install_wrappers() -> Result<()> {
181 install_wrappers_to(&crate::config::AppConfig::ninox_bin_dir())
182}
183
184pub fn install_self_shim(current_exe: &Path) -> Result<()> {
189 let bin_dir = crate::config::AppConfig::ninox_bin_dir();
190 std::fs::create_dir_all(&bin_dir)?;
191 let exe = current_exe.to_string_lossy().replace('\'', "'\\''");
192 let script = format!(
193 "#!/usr/bin/env bash\nexec '{}' \"$@\"\n",
194 exe
195 );
196 write_executable(bin_dir.join("ninox"), &script)?;
197 Ok(())
198}
199
200pub fn read_session_metadata(dir: &Path, session_id: &str) -> Result<SessionMetadata> {
203 let map = match read_metadata_map(&metadata_path(dir, session_id))? {
204 Some(m) => m,
205 None => return Ok(SessionMetadata::default()),
206 };
207 let pr_number = map.get("agentReportedPrNumber").and_then(as_u64);
208 let pr_url = map.get("agentReportedPrUrl")
209 .and_then(|v| v.as_str())
210 .map(|s| s.to_string());
211 let branch = map.get("branch")
212 .and_then(|v| v.as_str())
213 .map(|s| s.to_string());
214
215 let mut pr_reports: Vec<PrReport> = map.get("agentReportedPrs")
216 .and_then(|v| v.as_array())
217 .map(|prs| {
218 prs.iter()
219 .filter_map(|p| {
220 Some(PrReport {
221 number: as_u64(p.get("number")?)?,
222 url: p.get("url").and_then(|u| u.as_str()).map(String::from),
223 })
224 })
225 .collect()
226 })
227 .unwrap_or_default();
228 if pr_reports.is_empty() {
231 if let Some(number) = pr_number {
232 pr_reports.push(PrReport { number, url: pr_url.clone() });
233 }
234 }
235
236 Ok(SessionMetadata { pr_number, pr_url, branch, pr_reports })
237}
238
239pub fn append_work_request(dir: &Path, session_id: &str, description: &str) -> Result<WorkRequest> {
244 use std::sync::atomic::{AtomicU32, Ordering};
245 static SEQ: AtomicU32 = AtomicU32::new(0);
246
247 let requested_at = std::time::SystemTime::now()
248 .duration_since(std::time::UNIX_EPOCH)
249 .map(|d| d.as_millis() as i64)
250 .unwrap_or(0);
251 let request = WorkRequest {
254 id: format!(
255 "wr-{requested_at}-{}-{}",
256 std::process::id(),
257 SEQ.fetch_add(1, Ordering::Relaxed),
258 ),
259 description: description.to_string(),
260 requested_at,
261 };
262
263 let requests_dir = work_requests_dir(dir, session_id);
264 std::fs::create_dir_all(&requests_dir)?;
265 let path = requests_dir.join(format!("{}.json", request.id));
266 let body = serde_json::json!({
267 "id": request.id,
268 "description": request.description,
269 "requestedAt": request.requested_at,
270 });
271 let tmp = path.with_extension("tmp");
272 std::fs::write(&tmp, serde_json::to_string_pretty(&body)?)?;
273 std::fs::rename(&tmp, &path)?;
274 Ok(request)
275}
276
277pub fn read_pending_work_requests(dir: &Path, session_id: &str) -> Result<Vec<WorkRequest>> {
279 let requests_dir = work_requests_dir(dir, session_id);
280 let entries = match std::fs::read_dir(&requests_dir) {
281 Ok(e) => e,
282 Err(_) => return Ok(Vec::new()), };
284 let mut requests: Vec<WorkRequest> = entries
285 .filter_map(|e| e.ok())
286 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
287 .filter_map(|e| {
288 let raw = std::fs::read_to_string(e.path()).ok()?;
289 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
290 Some(WorkRequest {
291 id: v.get("id")?.as_str()?.to_string(),
292 description: v.get("description")?.as_str()?.to_string(),
293 requested_at: v.get("requestedAt").and_then(|t| t.as_i64()).unwrap_or(0),
294 })
295 })
296 .collect();
297 requests.sort_by(|a, b| a.requested_at.cmp(&b.requested_at).then(a.id.cmp(&b.id)));
298 Ok(requests)
299}
300
301pub fn mark_work_requests_delivered(dir: &Path, session_id: &str, ids: &[String]) -> Result<()> {
307 let requests_dir = work_requests_dir(dir, session_id);
308 let mut first_err: Option<std::io::Error> = None;
309 for id in ids {
310 let path = requests_dir.join(format!("{id}.json"));
311 if path.exists() {
312 let delivered = requests_dir.join(format!("{id}.json.delivered"));
313 if let Err(e) = std::fs::rename(&path, &delivered) {
314 first_err.get_or_insert(e);
315 }
316 }
317 }
318 match first_err {
319 Some(e) => Err(e.into()),
320 None => Ok(()),
321 }
322}
323
324pub fn remove_session_artifacts(dir: &Path, session_id: &str) {
329 let _ = std::fs::remove_file(metadata_path(dir, session_id));
330 let _ = std::fs::remove_dir_all(work_requests_dir(dir, session_id));
331 let _ = std::fs::remove_file(notified_prs_path(dir, session_id));
332}
333
334pub fn read_notified_extra_prs(dir: &Path, session_id: &str) -> Vec<u64> {
339 let path = notified_prs_path(dir, session_id);
340 std::fs::read_to_string(&path)
341 .ok()
342 .and_then(|raw| serde_json::from_str::<Vec<u64>>(&raw).ok())
343 .unwrap_or_default()
344}
345
346pub fn mark_extra_prs_notified(dir: &Path, session_id: &str, numbers: &[u64]) -> Result<()> {
349 let path = notified_prs_path(dir, session_id);
350 let mut notified = read_notified_extra_prs(dir, session_id);
351 for n in numbers {
352 if !notified.contains(n) {
353 notified.push(*n);
354 }
355 }
356 if let Some(parent) = path.parent() {
357 std::fs::create_dir_all(parent)?;
358 }
359 let tmp = path.with_extension("tmp");
360 std::fs::write(&tmp, serde_json::to_string(¬ified)?)?;
361 std::fs::rename(&tmp, &path)?;
362 Ok(())
363}
364
365fn metadata_path(dir: &Path, session_id: &str) -> PathBuf {
370 dir.join(format!("{session_id}.json"))
371}
372
373fn work_requests_dir(dir: &Path, session_id: &str) -> PathBuf {
374 dir.join(format!("{session_id}.requests"))
375}
376
377fn notified_prs_path(dir: &Path, session_id: &str) -> PathBuf {
378 dir.join(format!("{session_id}.notified-prs.json"))
379}
380
381fn read_metadata_map(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
384 if !path.exists() {
385 return Ok(None);
386 }
387 let raw = std::fs::read_to_string(path)?;
388 Ok(serde_json::from_str(&raw).ok())
389}
390
391fn as_u64(v: &serde_json::Value) -> Option<u64> {
393 v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
394}
395
396fn write_executable(path: PathBuf, content: &str) -> Result<()> {
397 let tmp = path.with_extension("tmp");
399 std::fs::write(&tmp, content)?;
400 #[cfg(unix)]
401 {
402 use std::os::unix::fs::PermissionsExt;
403 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
404 }
405 std::fs::rename(&tmp, &path)?;
406 Ok(())
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use tempfile::tempdir;
413
414 #[test]
415 fn install_wrappers_creates_executables() {
416 let dir = tempdir().unwrap();
417 install_wrappers_to(dir.path()).unwrap();
418 assert!(dir.path().join("gh").exists());
419 assert!(dir.path().join("git").exists());
420 #[cfg(unix)]
421 {
422 use std::os::unix::fs::PermissionsExt;
423 let gh_mode = std::fs::metadata(dir.path().join("gh"))
424 .unwrap().permissions().mode();
425 assert!(gh_mode & 0o111 != 0, "gh wrapper should be executable");
426 }
427 }
428
429 #[test]
430 fn read_session_metadata_parses_pr_number() {
431 let dir = tempdir().unwrap();
432 let metadata = serde_json::json!({
433 "agentReportedPrNumber": "42",
434 "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
435 "branch": "feat/my-fix"
436 });
437 std::fs::write(
438 dir.path().join("s1.json"),
439 serde_json::to_string(&metadata).unwrap(),
440 ).unwrap();
441 let m = read_session_metadata(dir.path(), "s1").unwrap();
442 assert_eq!(m.pr_number, Some(42));
443 assert_eq!(m.branch.as_deref(), Some("feat/my-fix"));
444 }
445
446 #[test]
447 fn read_session_metadata_returns_default_on_missing_file() {
448 let dir = tempdir().unwrap();
449 let m = read_session_metadata(dir.path(), "nonexistent").unwrap();
450 assert_eq!(m.pr_number, None);
451 assert_eq!(m.branch, None);
452 }
453
454 #[test]
455 fn read_session_metadata_handles_malformed_json() {
456 let dir = tempdir().unwrap();
457 std::fs::write(dir.path().join("bad.json"), "not json").unwrap();
458 let m = read_session_metadata(dir.path(), "bad").unwrap();
459 assert_eq!(m.pr_number, None);
460 }
461
462 #[test]
463 fn append_work_request_accumulates_pending_requests() {
464 let dir = tempdir().unwrap();
465 let first = append_work_request(dir.path(), "s1", "Fix flaky auth test").unwrap();
466 let second = append_work_request(dir.path(), "s1", "Migrate config loader").unwrap();
467 assert_ne!(first.id, second.id, "each request needs a distinct id");
468
469 let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
470 assert_eq!(pending.len(), 2);
471 assert_eq!(pending[0].description, "Fix flaky auth test");
472 assert_eq!(pending[1].description, "Migrate config loader");
473 }
474
475 #[test]
478 fn wrapper_scripts_read_only_ninox_env() {
479 for wrapper in [GH_WRAPPER, GIT_WRAPPER] {
480 assert!(wrapper.contains("${NINOX_SESSION:-}"));
481 assert!(wrapper.contains("${NINOX_DATA_DIR:-}"));
482 assert!(!wrapper.contains("ATHENE"));
483 }
484 }
485
486 #[test]
490 fn append_work_request_leaves_shared_metadata_file_alone() {
491 let dir = tempdir().unwrap();
492 let shared = r#"{"agentReportedPrNumber": "42", "branch": "feat/x"}"#;
493 std::fs::write(dir.path().join("s1.json"), shared).unwrap();
494 append_work_request(dir.path(), "s1", "New task").unwrap();
495 assert_eq!(
496 std::fs::read_to_string(dir.path().join("s1.json")).unwrap(),
497 shared,
498 "shared metadata JSON must be byte-identical after a work request",
499 );
500 let m = read_session_metadata(dir.path(), "s1").unwrap();
501 assert_eq!(m.pr_number, Some(42));
502 assert_eq!(read_pending_work_requests(dir.path(), "s1").unwrap().len(), 1);
503 }
504
505 #[test]
506 fn mark_work_requests_delivered_removes_only_named_ids_from_pending() {
507 let dir = tempdir().unwrap();
508 let first = append_work_request(dir.path(), "s1", "task a").unwrap();
509 let second = append_work_request(dir.path(), "s1", "task b").unwrap();
510
511 mark_work_requests_delivered(dir.path(), "s1", std::slice::from_ref(&first.id)).unwrap();
512
513 let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
514 assert_eq!(pending.len(), 1);
515 assert_eq!(pending[0].id, second.id);
516 }
517
518 #[test]
519 fn pending_work_requests_empty_when_none_recorded() {
520 let dir = tempdir().unwrap();
521 assert!(read_pending_work_requests(dir.path(), "s1").unwrap().is_empty());
522 }
523
524 #[test]
528 fn remove_session_artifacts_clears_all_files_for_that_session_only() {
529 let dir = tempdir().unwrap();
530 std::fs::write(dir.path().join("s1.json"), "{}").unwrap();
531 append_work_request(dir.path(), "s1", "task").unwrap();
532 mark_extra_prs_notified(dir.path(), "s1", &[9]).unwrap();
533 std::fs::write(dir.path().join("s2.json"), "{}").unwrap();
534 append_work_request(dir.path(), "s2", "other").unwrap();
535
536 remove_session_artifacts(dir.path(), "s1");
537
538 assert!(!dir.path().join("s1.json").exists());
539 assert!(!dir.path().join("s1.requests").exists());
540 assert!(!dir.path().join("s1.notified-prs.json").exists());
541 assert!(dir.path().join("s2.json").exists(), "other sessions untouched");
542 assert_eq!(read_pending_work_requests(dir.path(), "s2").unwrap().len(), 1);
543 remove_session_artifacts(dir.path(), "missing");
545 }
546
547 #[test]
551 fn notified_extra_prs_round_trip_and_accumulate() {
552 let dir = tempdir().unwrap();
553 assert!(read_notified_extra_prs(dir.path(), "s1").is_empty());
554 mark_extra_prs_notified(dir.path(), "s1", &[43, 44]).unwrap();
555 mark_extra_prs_notified(dir.path(), "s1", &[45]).unwrap();
556 let notified = read_notified_extra_prs(dir.path(), "s1");
557 assert_eq!(notified, vec![43, 44, 45]);
558 assert!(read_notified_extra_prs(dir.path(), "s2").is_empty());
560 }
561
562 #[test]
563 fn read_session_metadata_parses_reported_pr_list() {
564 let dir = tempdir().unwrap();
565 let metadata = serde_json::json!({
566 "agentReportedPrNumber": "44",
567 "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
568 "agentReportedPrs": [
569 {"number": "42", "url": "https://github.com/org/repo/pull/42"},
570 {"number": "43", "url": "https://github.com/org/repo/pull/43"},
571 {"number": "44", "url": "https://github.com/org/repo/pull/44"},
572 ],
573 });
574 std::fs::write(
575 dir.path().join("s1.json"),
576 serde_json::to_string(&metadata).unwrap(),
577 ).unwrap();
578 let m = read_session_metadata(dir.path(), "s1").unwrap();
579 let numbers: Vec<u64> = m.pr_reports.iter().map(|p| p.number).collect();
580 assert_eq!(numbers, vec![42, 43, 44]);
581 assert_eq!(
582 m.pr_reports[0].url.as_deref(),
583 Some("https://github.com/org/repo/pull/42"),
584 );
585 }
586
587 #[test]
591 fn read_session_metadata_synthesizes_pr_list_from_legacy_scalars() {
592 let dir = tempdir().unwrap();
593 let metadata = serde_json::json!({
594 "agentReportedPrNumber": "42",
595 "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
596 });
597 std::fs::write(
598 dir.path().join("s1.json"),
599 serde_json::to_string(&metadata).unwrap(),
600 ).unwrap();
601 let m = read_session_metadata(dir.path(), "s1").unwrap();
602 assert_eq!(m.pr_reports.len(), 1);
603 assert_eq!(m.pr_reports[0].number, 42);
604 }
605
606 #[test]
610 fn gh_wrapper_appends_to_pr_list() {
611 assert!(GH_WRAPPER.contains("agentReportedPrs"));
612 }
613}