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# 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
118/// The `git` wrapper script. Intercepts branch creation to record branch name.
119const 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
166// ---------------------------------------------------------------------------
167// Public API
168// ---------------------------------------------------------------------------
169
170/// Install `gh` and `git` wrapper scripts to the given directory.
171/// Called with the Ninox bin dir (`~/.config/ninox/bin/`) in production.
172pub 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
179/// Install wrappers to the default Ninox bin dir.
180pub fn install_wrappers() -> Result<()> {
181    install_wrappers_to(&crate::config::AppConfig::ninox_bin_dir())
182}
183
184/// Write a thin `ninox` shim to the Ninox bin dir that forwards all arguments
185/// to the currently-running executable. This ensures that when an orchestrator
186/// runs `ninox spawn` (and `~/.config/ninox/bin` is first in PATH), it always
187/// invokes the same build that is currently running — not a stale system install.
188pub 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
200/// Read session metadata from `{dir}/{session_id}.json`.
201/// Returns empty `SessionMetadata` if the file does not exist or is malformed.
202pub 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    // Metadata written before `agentReportedPrs` existed only carries the
229    // scalar keys — surface that PR in the list view too.
230    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
239/// Record a worker's request for additional work (`ninox request-work`) as
240/// its own file under `{dir}/{session_id}.requests/`. One file per request
241/// keeps every writer single-owner: nothing here ever read-modify-writes the
242/// shared `{session_id}.json` the bash wrappers rewrite.
243pub 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    // pid + per-process sequence make the id unique even when two workers
252    // (or one worker twice) request work in the same millisecond.
253    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
277/// Work requests not yet delivered to the orchestrator, oldest first.
278pub 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()), // no directory → nothing pending
283    };
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
301/// Mark work requests delivered by renaming their file out of the pending
302/// set (`.json` → `.json.delivered`) — kept on disk as an audit trail.
303/// Best-effort per id: one failed rename must not leave *later* ids pending
304/// (they would be fully re-delivered next tick); the first error is
305/// returned after every id has been attempted.
306pub 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
324/// Delete every metadata artifact for a session: the shared JSON, the
325/// work-request directory, and the notified-PRs marker. Best-effort — used
326/// when a session is removed, so a later session reusing the same id
327/// doesn't inherit stale suppressions or pending requests.
328pub 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
334/// Extra PRs (beyond the session's tracked one) that have already been
335/// notified. Poller-owned side file — deliberately not the shared
336/// `{session}.json` (wrapper races) and not the `prs` table (whose ids are
337/// bare PR numbers and collide across repos).
338pub 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
346/// Append PR numbers to the session's notified-extra-PRs marker file.
347/// Only the poller writes this file, so read-modify-write is race-free.
348pub 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(&notified)?)?;
361    std::fs::rename(&tmp, &path)?;
362    Ok(())
363}
364
365// ---------------------------------------------------------------------------
366// Internal helpers
367// ---------------------------------------------------------------------------
368
369fn 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
381/// Load the raw metadata JSON object. `Ok(None)` when the file is missing or
382/// malformed — callers treat both as "no metadata yet".
383fn 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
391/// The wrappers write numbers as JSON strings (jq `--arg`); accept both.
392fn 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    // Atomic write: write to temp, then rename.
398    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    /// The wrapper scripts read only the NINOX_* env names — the legacy
476    /// ATHENE_* transition fallbacks are gone.
477    #[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    /// Work requests must never touch the shared `{session}.json` — the gh/git
487    /// wrappers rewrite that file concurrently, and a read-modify-write from
488    /// another process could erase their updates (or vice versa).
489    #[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    /// Removing a session must clear every metadata artifact, or a reused
525    /// session id (they're slugified human-chosen names) inherits the old
526    /// session's notified-PR suppressions and stale pending requests.
527    #[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        // Idempotent on a session with no artifacts.
544        remove_session_artifacts(dir.path(), "missing");
545    }
546
547    /// The extra-PR "already notified" marker is a poller-owned side file —
548    /// never the shared `{session}.json`, and never keyed on the globally
549    /// collision-prone `prs.id`.
550    #[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        // Per-session, not global.
559        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    /// Metadata written before `agentReportedPrs` existed only has the scalar
588    /// keys — the list view must still surface that PR so extra-PR detection
589    /// and first-PR detection see the same universe.
590    #[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    /// The gh wrapper must append every created PR to `agentReportedPrs`, not
607    /// just overwrite the scalar keys — otherwise a worker that opens N PRs
608    /// only ever exposes the latest one to the poller.
609    #[test]
610    fn gh_wrapper_appends_to_pr_list() {
611        assert!(GH_WRAPPER.contains("agentReportedPrs"));
612    }
613}