Skip to main content

ninox_core/
hooks.rs

1use anyhow::Result;
2use std::path::{Path, PathBuf};
3
4// ---------------------------------------------------------------------------
5// Metadata types
6// ---------------------------------------------------------------------------
7
8#[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    /// Every PR the agent has reported creating, in creation order. The
14    /// scalar `pr_number`/`pr_url` fields always mirror the latest one; this
15    /// list is what lets the poller see PRs beyond the first.
16    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/// Additional work a worker asked the orchestrator to schedule
26/// (`ninox request-work`). Stored one file per request under
27/// `{dir}/{session}.requests/` — never inside the shared `{session}.json`,
28/// which the gh/git bash wrappers rewrite concurrently and without locking.
29#[derive(Debug, Clone, PartialEq)]
30pub struct WorkRequest {
31    pub id:           String,
32    pub description:  String,
33    pub requested_at: i64,
34}
35
36// ---------------------------------------------------------------------------
37// Wrapper scripts
38// ---------------------------------------------------------------------------
39
40/// The `gh` wrapper script. Intercepts `gh pr create`, extracts the PR URL
41/// from output, and writes it to the session metadata JSON file.
42///
43/// Env vars consumed at runtime (injected by ninox when spawning the tmux session):
44///   NINOX_SESSION     — session ID used as metadata filename
45///   NINOX_DATA_DIR    — directory where {NINOX_SESSION}.json lives
46const 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# Detect `pr create` regardless of preceding global flags (`-R`, `--repo`,
66# `--hostname`, with or without `=`) — argv-position matching alone
67# (`$1==pr && $2==create`) misses `gh -R owner/repo pr create` entirely,
68# silently falling through to the real gh with no metadata capture.
69_is_pr_create=false
70_skip_next=false
71_positional=()
72for _arg in "$@"; do
73    if $_skip_next; then
74        _skip_next=false
75        continue
76    fi
77    case "$_arg" in
78        -R|--repo|--hostname)
79            _skip_next=true
80            continue
81            ;;
82        --repo=*|--hostname=*)
83            continue
84            ;;
85        -R?*)
86            # Cuddled short-flag form gh's parser also accepts, e.g.
87            # `-Rowner/repo` (no space) — value is embedded, nothing to skip.
88            continue
89            ;;
90    esac
91    _positional+=("$_arg")
92done
93if [[ "${_positional[0]:-}" == "pr" && "${_positional[1]:-}" == "create" ]]; then
94    _is_pr_create=true
95fi
96
97# Run the real gh and tee output so we can parse it.
98if $_is_pr_create; then
99    _output=$("$_real_gh" "$@" 2>&1)
100    _exit=$?
101    echo "$_output"
102    _nx_session="${NINOX_SESSION:-}"
103    _nx_data_dir="${NINOX_DATA_DIR:-}"
104    if [[ $_exit -eq 0 && -n "$_nx_session" && -n "$_nx_data_dir" ]]; then
105        _pr_url=$(echo "$_output" | grep -oE 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+' | head -1)
106        if [[ -n "$_pr_url" ]]; then
107            _pr_num=$(echo "$_pr_url" | grep -oE '[0-9]+$')
108            _meta_file="${_nx_data_dir}/${_nx_session}.json"
109            mkdir -p "$(dirname "$_meta_file")"
110            _tmp="${_meta_file}.tmp.$$"
111            if [[ -f "$_meta_file" ]]; then
112                _existing=$(cat "$_meta_file")
113            else
114                _existing="{}"
115            fi
116            if command -v jq &>/dev/null; then
117                echo "$_existing" | jq \
118                    --arg url "$_pr_url" \
119                    --arg num "$_pr_num" \
120                    '. + {"agentReportedPrUrl": $url, "agentReportedPrNumber": $num, "agentReportedState": "pr_created"}
121                     | .agentReportedPrs = ((.agentReportedPrs // []) + [{"number": $num, "url": $url}])' \
122                    > "$_tmp" && mv "$_tmp" "$_meta_file"
123            elif command -v node &>/dev/null; then
124                # PR URL and number are passed via env vars, not interpolated into the
125                # script string, to avoid shell injection from external GitHub output.
126                NINOX_PR_URL="$_pr_url" NINOX_PR_NUM="$_pr_num" node -e "
127                    const fs = require('fs');
128                    const url = process.env.NINOX_PR_URL;
129                    const num = process.env.NINOX_PR_NUM;
130                    const f = '${_meta_file}';
131                    const m = JSON.parse(fs.existsSync(f) ? fs.readFileSync(f,'utf8') : '{}');
132                    m.agentReportedPrUrl = url;
133                    m.agentReportedPrNumber = num;
134                    m.agentReportedState = 'pr_created';
135                    m.agentReportedPrs = (Array.isArray(m.agentReportedPrs) ? m.agentReportedPrs : [])
136                        .concat([{ number: num, url: url }]);
137                    fs.writeFileSync(f + '.tmp.\$\$', JSON.stringify(m,null,2));
138                    fs.renameSync(f + '.tmp.\$\$', f);
139                " 2>/dev/null || echo "ninox: warning: node failed to record PR metadata for $_pr_url" >&2
140            else
141                echo "ninox: warning: neither jq nor node found — PR metadata for $_pr_url was not recorded" >&2
142            fi
143        fi
144    fi
145    exit $_exit
146else
147    exec "$_real_gh" "$@"
148fi
149"#;
150
151/// The `git` wrapper script. Intercepts branch creation to record branch name.
152const GIT_WRAPPER: &str = r#"#!/usr/bin/env bash
153# Ninox git wrapper — records branch name on checkout -b / switch -c.
154set -euo pipefail
155
156_real_git=""
157IFS=: read -ra _path_parts <<< "$PATH"
158for _dir in "${_path_parts[@]}"; do
159    _candidate="$_dir/git"
160    if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
161        _real_git="$_candidate"
162        break
163    fi
164done
165if [[ -z "$_real_git" ]]; then
166    echo "ninox: git not found in PATH (excluding wrapper)" >&2
167    exit 1
168fi
169
170# Run the real git command first.
171"$_real_git" "$@"
172_exit=$?
173
174# On success, capture branch name for checkout -b / switch -c.
175_nx_session="${NINOX_SESSION:-}"
176_nx_data_dir="${NINOX_DATA_DIR:-}"
177if [[ $_exit -eq 0 && -n "$_nx_session" && -n "$_nx_data_dir" ]]; then
178    _branch=""
179    if [[ "${1:-}" == "checkout" && "${2:-}" == "-b" && -n "${3:-}" ]]; then
180        _branch="${3}"
181    elif [[ "${1:-}" == "switch" && "${2:-}" == "-c" && -n "${3:-}" ]]; then
182        _branch="${3}"
183    fi
184    if [[ -n "$_branch" ]]; then
185        _meta_file="${_nx_data_dir}/${_nx_session}.json"
186        mkdir -p "$(dirname "$_meta_file")"
187        if command -v jq &>/dev/null; then
188            _tmp="${_meta_file}.tmp.$$"
189            _existing=$([ -f "$_meta_file" ] && cat "$_meta_file" || echo "{}")
190            echo "$_existing" | jq --arg b "$_branch" '. + {"branch": $b}' \
191                > "$_tmp" && mv "$_tmp" "$_meta_file"
192        fi
193    fi
194fi
195
196exit $_exit
197"#;
198
199// ---------------------------------------------------------------------------
200// Public API
201// ---------------------------------------------------------------------------
202
203/// Install `gh` and `git` wrapper scripts to the given directory.
204/// Called with the Ninox bin dir (`~/.config/ninox/bin/`) in production.
205pub fn install_wrappers_to(bin_dir: &Path) -> Result<()> {
206    std::fs::create_dir_all(bin_dir)?;
207    write_executable(bin_dir.join("gh"),  GH_WRAPPER)?;
208    write_executable(bin_dir.join("git"), GIT_WRAPPER)?;
209    Ok(())
210}
211
212/// Install wrappers to the default Ninox bin dir.
213pub fn install_wrappers() -> Result<()> {
214    install_wrappers_to(&crate::config::AppConfig::ninox_bin_dir())
215}
216
217/// Write a thin `ninox` shim to the Ninox bin dir that forwards all arguments
218/// to the currently-running executable. This ensures that when an orchestrator
219/// runs `ninox spawn` (and `~/.config/ninox/bin` is first in PATH), it always
220/// invokes the same build that is currently running — not a stale system install.
221pub fn install_self_shim(current_exe: &Path) -> Result<()> {
222    let bin_dir = crate::config::AppConfig::ninox_bin_dir();
223    std::fs::create_dir_all(&bin_dir)?;
224    let exe = current_exe.to_string_lossy().replace('\'', "'\\''");
225    let script = format!(
226        "#!/usr/bin/env bash\nexec '{}' \"$@\"\n",
227        exe
228    );
229    write_executable(bin_dir.join("ninox"), &script)?;
230    Ok(())
231}
232
233/// Read session metadata from `{dir}/{session_id}.json`.
234/// Returns empty `SessionMetadata` if the file does not exist or is malformed.
235pub fn read_session_metadata(dir: &Path, session_id: &str) -> Result<SessionMetadata> {
236    let map = match read_metadata_map(&metadata_path(dir, session_id))? {
237        Some(m) => m,
238        None    => return Ok(SessionMetadata::default()),
239    };
240    let pr_number = map.get("agentReportedPrNumber").and_then(as_u64);
241    let pr_url = map.get("agentReportedPrUrl")
242        .and_then(|v| v.as_str())
243        .map(|s| s.to_string());
244    let branch = map.get("branch")
245        .and_then(|v| v.as_str())
246        .map(|s| s.to_string());
247
248    let mut pr_reports: Vec<PrReport> = map.get("agentReportedPrs")
249        .and_then(|v| v.as_array())
250        .map(|prs| {
251            prs.iter()
252                .filter_map(|p| {
253                    Some(PrReport {
254                        number: as_u64(p.get("number")?)?,
255                        url:    p.get("url").and_then(|u| u.as_str()).map(String::from),
256                    })
257                })
258                .collect()
259        })
260        .unwrap_or_default();
261    // Metadata written before `agentReportedPrs` existed only carries the
262    // scalar keys — surface that PR in the list view too.
263    if pr_reports.is_empty() {
264        if let Some(number) = pr_number {
265            pr_reports.push(PrReport { number, url: pr_url.clone() });
266        }
267    }
268
269    Ok(SessionMetadata { pr_number, pr_url, branch, pr_reports })
270}
271
272/// Record a worker's request for additional work (`ninox request-work`) as
273/// its own file under `{dir}/{session_id}.requests/`. One file per request
274/// keeps every writer single-owner: nothing here ever read-modify-writes the
275/// shared `{session_id}.json` the bash wrappers rewrite.
276pub fn append_work_request(dir: &Path, session_id: &str, description: &str) -> Result<WorkRequest> {
277    use std::sync::atomic::{AtomicU32, Ordering};
278    static SEQ: AtomicU32 = AtomicU32::new(0);
279
280    let requested_at = std::time::SystemTime::now()
281        .duration_since(std::time::UNIX_EPOCH)
282        .map(|d| d.as_millis() as i64)
283        .unwrap_or(0);
284    // pid + per-process sequence make the id unique even when two workers
285    // (or one worker twice) request work in the same millisecond.
286    let request = WorkRequest {
287        id: format!(
288            "wr-{requested_at}-{}-{}",
289            std::process::id(),
290            SEQ.fetch_add(1, Ordering::Relaxed),
291        ),
292        description: description.to_string(),
293        requested_at,
294    };
295
296    let requests_dir = work_requests_dir(dir, session_id);
297    std::fs::create_dir_all(&requests_dir)?;
298    let path = requests_dir.join(format!("{}.json", request.id));
299    let body = serde_json::json!({
300        "id":          request.id,
301        "description": request.description,
302        "requestedAt": request.requested_at,
303    });
304    let tmp = path.with_extension("tmp");
305    std::fs::write(&tmp, serde_json::to_string_pretty(&body)?)?;
306    std::fs::rename(&tmp, &path)?;
307    Ok(request)
308}
309
310/// Work requests not yet delivered to the orchestrator, oldest first.
311pub fn read_pending_work_requests(dir: &Path, session_id: &str) -> Result<Vec<WorkRequest>> {
312    let requests_dir = work_requests_dir(dir, session_id);
313    let entries = match std::fs::read_dir(&requests_dir) {
314        Ok(e)  => e,
315        Err(_) => return Ok(Vec::new()), // no directory → nothing pending
316    };
317    let mut requests: Vec<WorkRequest> = entries
318        .filter_map(|e| e.ok())
319        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
320        .filter_map(|e| {
321            let raw = std::fs::read_to_string(e.path()).ok()?;
322            let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
323            Some(WorkRequest {
324                id:           v.get("id")?.as_str()?.to_string(),
325                description:  v.get("description")?.as_str()?.to_string(),
326                requested_at: v.get("requestedAt").and_then(|t| t.as_i64()).unwrap_or(0),
327            })
328        })
329        .collect();
330    requests.sort_by(|a, b| a.requested_at.cmp(&b.requested_at).then(a.id.cmp(&b.id)));
331    Ok(requests)
332}
333
334/// Mark work requests delivered by renaming their file out of the pending
335/// set (`.json` → `.json.delivered`) — kept on disk as an audit trail.
336/// Best-effort per id: one failed rename must not leave *later* ids pending
337/// (they would be fully re-delivered next tick); the first error is
338/// returned after every id has been attempted.
339pub fn mark_work_requests_delivered(dir: &Path, session_id: &str, ids: &[String]) -> Result<()> {
340    let requests_dir = work_requests_dir(dir, session_id);
341    let mut first_err: Option<std::io::Error> = None;
342    for id in ids {
343        let path = requests_dir.join(format!("{id}.json"));
344        if path.exists() {
345            let delivered = requests_dir.join(format!("{id}.json.delivered"));
346            if let Err(e) = std::fs::rename(&path, &delivered) {
347                first_err.get_or_insert(e);
348            }
349        }
350    }
351    match first_err {
352        Some(e) => Err(e.into()),
353        None    => Ok(()),
354    }
355}
356
357/// Delete every metadata artifact for a session: the shared JSON, the
358/// work-request directory, and the notified-PRs marker. Best-effort — used
359/// when a session is removed, so a later session reusing the same id
360/// doesn't inherit stale suppressions or pending requests.
361pub fn remove_session_artifacts(dir: &Path, session_id: &str) {
362    let _ = std::fs::remove_file(metadata_path(dir, session_id));
363    let _ = std::fs::remove_dir_all(work_requests_dir(dir, session_id));
364    let _ = std::fs::remove_file(notified_prs_path(dir, session_id));
365}
366
367/// Extra PRs (beyond the session's tracked one) that have already been
368/// notified. Poller-owned side file — deliberately not the shared
369/// `{session}.json` (wrapper races) and not the `prs` table (whose ids are
370/// bare PR numbers and collide across repos).
371pub fn read_notified_extra_prs(dir: &Path, session_id: &str) -> Vec<u64> {
372    let path = notified_prs_path(dir, session_id);
373    std::fs::read_to_string(&path)
374        .ok()
375        .and_then(|raw| serde_json::from_str::<Vec<u64>>(&raw).ok())
376        .unwrap_or_default()
377}
378
379/// Append PR numbers to the session's notified-extra-PRs marker file.
380/// Only the poller writes this file, so read-modify-write is race-free.
381pub fn mark_extra_prs_notified(dir: &Path, session_id: &str, numbers: &[u64]) -> Result<()> {
382    let path = notified_prs_path(dir, session_id);
383    let mut notified = read_notified_extra_prs(dir, session_id);
384    for n in numbers {
385        if !notified.contains(n) {
386            notified.push(*n);
387        }
388    }
389    if let Some(parent) = path.parent() {
390        std::fs::create_dir_all(parent)?;
391    }
392    let tmp = path.with_extension("tmp");
393    std::fs::write(&tmp, serde_json::to_string(&notified)?)?;
394    std::fs::rename(&tmp, &path)?;
395    Ok(())
396}
397
398// ---------------------------------------------------------------------------
399// Internal helpers
400// ---------------------------------------------------------------------------
401
402fn metadata_path(dir: &Path, session_id: &str) -> PathBuf {
403    dir.join(format!("{session_id}.json"))
404}
405
406fn work_requests_dir(dir: &Path, session_id: &str) -> PathBuf {
407    dir.join(format!("{session_id}.requests"))
408}
409
410fn notified_prs_path(dir: &Path, session_id: &str) -> PathBuf {
411    dir.join(format!("{session_id}.notified-prs.json"))
412}
413
414/// Load the raw metadata JSON object. `Ok(None)` when the file is missing or
415/// malformed — callers treat both as "no metadata yet".
416fn read_metadata_map(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
417    if !path.exists() {
418        return Ok(None);
419    }
420    let raw = std::fs::read_to_string(path)?;
421    Ok(serde_json::from_str(&raw).ok())
422}
423
424/// The wrappers write numbers as JSON strings (jq `--arg`); accept both.
425fn as_u64(v: &serde_json::Value) -> Option<u64> {
426    v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
427}
428
429fn write_executable(path: PathBuf, content: &str) -> Result<()> {
430    // Atomic write: write to temp, then rename.
431    let tmp = path.with_extension("tmp");
432    std::fs::write(&tmp, content)?;
433    #[cfg(unix)]
434    {
435        use std::os::unix::fs::PermissionsExt;
436        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
437    }
438    std::fs::rename(&tmp, &path)?;
439    Ok(())
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use tempfile::tempdir;
446
447    #[test]
448    fn install_wrappers_creates_executables() {
449        let dir = tempdir().unwrap();
450        install_wrappers_to(dir.path()).unwrap();
451        assert!(dir.path().join("gh").exists());
452        assert!(dir.path().join("git").exists());
453        #[cfg(unix)]
454        {
455            use std::os::unix::fs::PermissionsExt;
456            let gh_mode = std::fs::metadata(dir.path().join("gh"))
457                .unwrap().permissions().mode();
458            assert!(gh_mode & 0o111 != 0, "gh wrapper should be executable");
459        }
460    }
461
462    #[test]
463    fn read_session_metadata_parses_pr_number() {
464        let dir = tempdir().unwrap();
465        let metadata = serde_json::json!({
466            "agentReportedPrNumber": "42",
467            "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
468            "branch": "feat/my-fix"
469        });
470        std::fs::write(
471            dir.path().join("s1.json"),
472            serde_json::to_string(&metadata).unwrap(),
473        ).unwrap();
474        let m = read_session_metadata(dir.path(), "s1").unwrap();
475        assert_eq!(m.pr_number, Some(42));
476        assert_eq!(m.branch.as_deref(), Some("feat/my-fix"));
477    }
478
479    #[test]
480    fn read_session_metadata_returns_default_on_missing_file() {
481        let dir = tempdir().unwrap();
482        let m = read_session_metadata(dir.path(), "nonexistent").unwrap();
483        assert_eq!(m.pr_number, None);
484        assert_eq!(m.branch, None);
485    }
486
487    #[test]
488    fn read_session_metadata_handles_malformed_json() {
489        let dir = tempdir().unwrap();
490        std::fs::write(dir.path().join("bad.json"), "not json").unwrap();
491        let m = read_session_metadata(dir.path(), "bad").unwrap();
492        assert_eq!(m.pr_number, None);
493    }
494
495    #[test]
496    fn append_work_request_accumulates_pending_requests() {
497        let dir = tempdir().unwrap();
498        let first  = append_work_request(dir.path(), "s1", "Fix flaky auth test").unwrap();
499        let second = append_work_request(dir.path(), "s1", "Migrate config loader").unwrap();
500        assert_ne!(first.id, second.id, "each request needs a distinct id");
501
502        let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
503        assert_eq!(pending.len(), 2);
504        assert_eq!(pending[0].description, "Fix flaky auth test");
505        assert_eq!(pending[1].description, "Migrate config loader");
506    }
507
508    /// The wrapper scripts read only the NINOX_* env names — the legacy
509    /// ATHENE_* transition fallbacks are gone.
510    #[test]
511    fn wrapper_scripts_read_only_ninox_env() {
512        for wrapper in [GH_WRAPPER, GIT_WRAPPER] {
513            assert!(wrapper.contains("${NINOX_SESSION:-}"));
514            assert!(wrapper.contains("${NINOX_DATA_DIR:-}"));
515            assert!(!wrapper.contains("ATHENE"));
516        }
517    }
518
519    /// Work requests must never touch the shared `{session}.json` — the gh/git
520    /// wrappers rewrite that file concurrently, and a read-modify-write from
521    /// another process could erase their updates (or vice versa).
522    #[test]
523    fn append_work_request_leaves_shared_metadata_file_alone() {
524        let dir = tempdir().unwrap();
525        let shared = r#"{"agentReportedPrNumber": "42", "branch": "feat/x"}"#;
526        std::fs::write(dir.path().join("s1.json"), shared).unwrap();
527        append_work_request(dir.path(), "s1", "New task").unwrap();
528        assert_eq!(
529            std::fs::read_to_string(dir.path().join("s1.json")).unwrap(),
530            shared,
531            "shared metadata JSON must be byte-identical after a work request",
532        );
533        let m = read_session_metadata(dir.path(), "s1").unwrap();
534        assert_eq!(m.pr_number, Some(42));
535        assert_eq!(read_pending_work_requests(dir.path(), "s1").unwrap().len(), 1);
536    }
537
538    #[test]
539    fn mark_work_requests_delivered_removes_only_named_ids_from_pending() {
540        let dir = tempdir().unwrap();
541        let first = append_work_request(dir.path(), "s1", "task a").unwrap();
542        let second = append_work_request(dir.path(), "s1", "task b").unwrap();
543
544        mark_work_requests_delivered(dir.path(), "s1", std::slice::from_ref(&first.id)).unwrap();
545
546        let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
547        assert_eq!(pending.len(), 1);
548        assert_eq!(pending[0].id, second.id);
549    }
550
551    #[test]
552    fn pending_work_requests_empty_when_none_recorded() {
553        let dir = tempdir().unwrap();
554        assert!(read_pending_work_requests(dir.path(), "s1").unwrap().is_empty());
555    }
556
557    /// Removing a session must clear every metadata artifact, or a reused
558    /// session id (they're slugified human-chosen names) inherits the old
559    /// session's notified-PR suppressions and stale pending requests.
560    #[test]
561    fn remove_session_artifacts_clears_all_files_for_that_session_only() {
562        let dir = tempdir().unwrap();
563        std::fs::write(dir.path().join("s1.json"), "{}").unwrap();
564        append_work_request(dir.path(), "s1", "task").unwrap();
565        mark_extra_prs_notified(dir.path(), "s1", &[9]).unwrap();
566        std::fs::write(dir.path().join("s2.json"), "{}").unwrap();
567        append_work_request(dir.path(), "s2", "other").unwrap();
568
569        remove_session_artifacts(dir.path(), "s1");
570
571        assert!(!dir.path().join("s1.json").exists());
572        assert!(!dir.path().join("s1.requests").exists());
573        assert!(!dir.path().join("s1.notified-prs.json").exists());
574        assert!(dir.path().join("s2.json").exists(), "other sessions untouched");
575        assert_eq!(read_pending_work_requests(dir.path(), "s2").unwrap().len(), 1);
576        // Idempotent on a session with no artifacts.
577        remove_session_artifacts(dir.path(), "missing");
578    }
579
580    /// The extra-PR "already notified" marker is a poller-owned side file —
581    /// never the shared `{session}.json`, and never keyed on the globally
582    /// collision-prone `prs.id`.
583    #[test]
584    fn notified_extra_prs_round_trip_and_accumulate() {
585        let dir = tempdir().unwrap();
586        assert!(read_notified_extra_prs(dir.path(), "s1").is_empty());
587        mark_extra_prs_notified(dir.path(), "s1", &[43, 44]).unwrap();
588        mark_extra_prs_notified(dir.path(), "s1", &[45]).unwrap();
589        let notified = read_notified_extra_prs(dir.path(), "s1");
590        assert_eq!(notified, vec![43, 44, 45]);
591        // Per-session, not global.
592        assert!(read_notified_extra_prs(dir.path(), "s2").is_empty());
593    }
594
595    #[test]
596    fn read_session_metadata_parses_reported_pr_list() {
597        let dir = tempdir().unwrap();
598        let metadata = serde_json::json!({
599            "agentReportedPrNumber": "44",
600            "agentReportedPrUrl": "https://github.com/org/repo/pull/44",
601            "agentReportedPrs": [
602                {"number": "42", "url": "https://github.com/org/repo/pull/42"},
603                {"number": "43", "url": "https://github.com/org/repo/pull/43"},
604                {"number": "44", "url": "https://github.com/org/repo/pull/44"},
605            ],
606        });
607        std::fs::write(
608            dir.path().join("s1.json"),
609            serde_json::to_string(&metadata).unwrap(),
610        ).unwrap();
611        let m = read_session_metadata(dir.path(), "s1").unwrap();
612        let numbers: Vec<u64> = m.pr_reports.iter().map(|p| p.number).collect();
613        assert_eq!(numbers, vec![42, 43, 44]);
614        assert_eq!(
615            m.pr_reports[0].url.as_deref(),
616            Some("https://github.com/org/repo/pull/42"),
617        );
618    }
619
620    /// Metadata written before `agentReportedPrs` existed only has the scalar
621    /// keys — the list view must still surface that PR so extra-PR detection
622    /// and first-PR detection see the same universe.
623    #[test]
624    fn read_session_metadata_synthesizes_pr_list_from_legacy_scalars() {
625        let dir = tempdir().unwrap();
626        let metadata = serde_json::json!({
627            "agentReportedPrNumber": "42",
628            "agentReportedPrUrl": "https://github.com/org/repo/pull/42",
629        });
630        std::fs::write(
631            dir.path().join("s1.json"),
632            serde_json::to_string(&metadata).unwrap(),
633        ).unwrap();
634        let m = read_session_metadata(dir.path(), "s1").unwrap();
635        assert_eq!(m.pr_reports.len(), 1);
636        assert_eq!(m.pr_reports[0].number, 42);
637    }
638
639    /// The gh wrapper must append every created PR to `agentReportedPrs`, not
640    /// just overwrite the scalar keys — otherwise a worker that opens N PRs
641    /// only ever exposes the latest one to the poller.
642    #[test]
643    fn gh_wrapper_appends_to_pr_list() {
644        assert!(GH_WRAPPER.contains("agentReportedPrs"));
645    }
646
647    /// A missing jq *and* node must be diagnosable, not a silent no-op —
648    /// the wrapper previously dropped the write with `|| true` and no trace.
649    #[test]
650    fn gh_wrapper_warns_when_neither_jq_nor_node_available() {
651        assert!(GH_WRAPPER.contains("neither jq nor node"));
652    }
653
654    /// Runs the real `GH_WRAPPER` script as a subprocess against a fake
655    /// "real gh" that just echoes a PR URL, proving the argv scan actually
656    /// detects `pr create` end to end — not just via string matching on the
657    /// script source.
658    fn run_gh_wrapper(args: &[&str], path_extra: &std::path::Path, session: &str, data_dir: &std::path::Path)
659        -> std::process::Output
660    {
661        let wrapper_dir = tempdir().unwrap();
662        let wrapper_path = wrapper_dir.path().join("gh");
663        write_executable(wrapper_path.clone(), GH_WRAPPER).unwrap();
664        let path_env = format!(
665            "{}:{}",
666            path_extra.display(),
667            std::env::var("PATH").unwrap_or_default(),
668        );
669        std::process::Command::new(&wrapper_path)
670            .args(args)
671            .env("PATH", path_env)
672            .env("NINOX_SESSION", session)
673            .env("NINOX_DATA_DIR", data_dir.to_string_lossy().to_string())
674            .output()
675            .expect("failed to run gh wrapper")
676    }
677
678    fn fake_real_gh(pr_url: &str) -> tempfile::TempDir {
679        let dir = tempdir().unwrap();
680        write_executable(
681            dir.path().join("gh"),
682            &format!("#!/usr/bin/env bash\necho 'Created pull request {pr_url}'\n"),
683        ).unwrap();
684        dir
685    }
686
687    /// `gh -R owner/repo pr create ...` and `gh --repo owner/repo pr create
688    /// ...` must both be detected — a global flag before the subcommand
689    /// previously made the wrapper fall straight through to real gh with no
690    /// metadata capture at all.
691    #[test]
692    fn gh_wrapper_detects_pr_create_behind_global_repo_flags() {
693        for extra_args in [
694            vec!["-R", "org/repo"],
695            vec!["--repo", "org/repo"],
696            vec!["--repo=org/repo"],
697            vec!["-Rorg/repo"],
698        ] {
699            let real_gh_dir = fake_real_gh("https://github.com/org/repo/pull/99");
700            let data_dir = tempdir().unwrap();
701
702            let mut args = extra_args.clone();
703            args.extend(["pr", "create", "--title", "t", "--body", "b"]);
704
705            let output = run_gh_wrapper(&args, real_gh_dir.path(), "s1", data_dir.path());
706            assert!(
707                output.status.success(),
708                "wrapper failed for {extra_args:?}: {}",
709                String::from_utf8_lossy(&output.stderr),
710            );
711            let meta = read_session_metadata(data_dir.path(), "s1").unwrap();
712            assert_eq!(
713                meta.pr_number, Some(99),
714                "flags {extra_args:?} should still be detected as pr create",
715            );
716        }
717    }
718
719    /// Regression guard: non-`pr create` invocations (even with the same
720    /// global flags) must still pass straight through with no metadata
721    /// write — the argv scan must not become overly eager.
722    #[test]
723    fn gh_wrapper_does_not_intercept_non_pr_create_invocations() {
724        let real_gh_dir = fake_real_gh("https://github.com/org/repo/pull/99");
725        let data_dir = tempdir().unwrap();
726
727        let output = run_gh_wrapper(
728            &["-R", "org/repo", "pr", "list"],
729            real_gh_dir.path(), "s1", data_dir.path(),
730        );
731        assert!(output.status.success());
732        assert!(!data_dir.path().join("s1.json").exists());
733    }
734}