use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct SessionMetadata {
pub pr_number: Option<u64>,
pub pr_url: Option<String>,
pub branch: Option<String>,
pub pr_reports: Vec<PrReport>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PrReport {
pub number: u64,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WorkRequest {
pub id: String,
pub description: String,
pub requested_at: i64,
}
const GH_WRAPPER: &str = r#"#!/usr/bin/env bash
# Ninox gh wrapper — intercepts gh pr create to record PR metadata.
set -euo pipefail
# Locate the real gh binary (skip ourselves).
_real_gh=""
IFS=: read -ra _path_parts <<< "$PATH"
for _dir in "${_path_parts[@]}"; do
_candidate="$_dir/gh"
if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
_real_gh="$_candidate"
break
fi
done
if [[ -z "$_real_gh" ]]; then
echo "ninox: gh not found in PATH (excluding wrapper)" >&2
exit 1
fi
# Run the real gh and tee output so we can parse it.
if [[ "${1:-}" == "pr" && "${2:-}" == "create" ]]; then
_output=$("$_real_gh" "$@" 2>&1)
_exit=$?
echo "$_output"
_nx_session="${NINOX_SESSION:-}"
_nx_data_dir="${NINOX_DATA_DIR:-}"
if [[ $_exit -eq 0 && -n "$_nx_session" && -n "$_nx_data_dir" ]]; then
_pr_url=$(echo "$_output" | grep -oE 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+' | head -1)
if [[ -n "$_pr_url" ]]; then
_pr_num=$(echo "$_pr_url" | grep -oE '[0-9]+$')
_meta_file="${_nx_data_dir}/${_nx_session}.json"
mkdir -p "$(dirname "$_meta_file")"
_tmp="${_meta_file}.tmp.$$"
if [[ -f "$_meta_file" ]]; then
_existing=$(cat "$_meta_file")
else
_existing="{}"
fi
if command -v jq &>/dev/null; then
echo "$_existing" | jq \
--arg url "$_pr_url" \
--arg num "$_pr_num" \
'. + {"agentReportedPrUrl": $url, "agentReportedPrNumber": $num, "agentReportedState": "pr_created"}
| .agentReportedPrs = ((.agentReportedPrs // []) + [{"number": $num, "url": $url}])' \
> "$_tmp" && mv "$_tmp" "$_meta_file"
else
# Fallback: node (likely available alongside gh).
# PR URL and number are passed via env vars, not interpolated into the
# script string, to avoid shell injection from external GitHub output.
NINOX_PR_URL="$_pr_url" NINOX_PR_NUM="$_pr_num" node -e "
const fs = require('fs');
const url = process.env.NINOX_PR_URL;
const num = process.env.NINOX_PR_NUM;
const f = '${_meta_file}';
const m = JSON.parse(fs.existsSync(f) ? fs.readFileSync(f,'utf8') : '{}');
m.agentReportedPrUrl = url;
m.agentReportedPrNumber = num;
m.agentReportedState = 'pr_created';
m.agentReportedPrs = (Array.isArray(m.agentReportedPrs) ? m.agentReportedPrs : [])
.concat([{ number: num, url: url }]);
fs.writeFileSync(f + '.tmp.\$\$', JSON.stringify(m,null,2));
fs.renameSync(f + '.tmp.\$\$', f);
" 2>/dev/null || true
fi
fi
fi
exit $_exit
else
exec "$_real_gh" "$@"
fi
"#;
const GIT_WRAPPER: &str = r#"#!/usr/bin/env bash
# Ninox git wrapper — records branch name on checkout -b / switch -c.
set -euo pipefail
_real_git=""
IFS=: read -ra _path_parts <<< "$PATH"
for _dir in "${_path_parts[@]}"; do
_candidate="$_dir/git"
if [[ "$_candidate" != "$0" && -x "$_candidate" ]]; then
_real_git="$_candidate"
break
fi
done
if [[ -z "$_real_git" ]]; then
echo "ninox: git not found in PATH (excluding wrapper)" >&2
exit 1
fi
# Run the real git command first.
"$_real_git" "$@"
_exit=$?
# On success, capture branch name for checkout -b / switch -c.
_nx_session="${NINOX_SESSION:-}"
_nx_data_dir="${NINOX_DATA_DIR:-}"
if [[ $_exit -eq 0 && -n "$_nx_session" && -n "$_nx_data_dir" ]]; then
_branch=""
if [[ "${1:-}" == "checkout" && "${2:-}" == "-b" && -n "${3:-}" ]]; then
_branch="${3}"
elif [[ "${1:-}" == "switch" && "${2:-}" == "-c" && -n "${3:-}" ]]; then
_branch="${3}"
fi
if [[ -n "$_branch" ]]; then
_meta_file="${_nx_data_dir}/${_nx_session}.json"
mkdir -p "$(dirname "$_meta_file")"
if command -v jq &>/dev/null; then
_tmp="${_meta_file}.tmp.$$"
_existing=$([ -f "$_meta_file" ] && cat "$_meta_file" || echo "{}")
echo "$_existing" | jq --arg b "$_branch" '. + {"branch": $b}' \
> "$_tmp" && mv "$_tmp" "$_meta_file"
fi
fi
fi
exit $_exit
"#;
pub fn install_wrappers_to(bin_dir: &Path) -> Result<()> {
std::fs::create_dir_all(bin_dir)?;
write_executable(bin_dir.join("gh"), GH_WRAPPER)?;
write_executable(bin_dir.join("git"), GIT_WRAPPER)?;
Ok(())
}
pub fn install_wrappers() -> Result<()> {
install_wrappers_to(&crate::config::AppConfig::ninox_bin_dir())
}
pub fn install_self_shim(current_exe: &Path) -> Result<()> {
let bin_dir = crate::config::AppConfig::ninox_bin_dir();
std::fs::create_dir_all(&bin_dir)?;
let exe = current_exe.to_string_lossy().replace('\'', "'\\''");
let script = format!(
"#!/usr/bin/env bash\nexec '{}' \"$@\"\n",
exe
);
write_executable(bin_dir.join("ninox"), &script)?;
Ok(())
}
pub fn read_session_metadata(dir: &Path, session_id: &str) -> Result<SessionMetadata> {
let map = match read_metadata_map(&metadata_path(dir, session_id))? {
Some(m) => m,
None => return Ok(SessionMetadata::default()),
};
let pr_number = map.get("agentReportedPrNumber").and_then(as_u64);
let pr_url = map.get("agentReportedPrUrl")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let branch = map.get("branch")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut pr_reports: Vec<PrReport> = map.get("agentReportedPrs")
.and_then(|v| v.as_array())
.map(|prs| {
prs.iter()
.filter_map(|p| {
Some(PrReport {
number: as_u64(p.get("number")?)?,
url: p.get("url").and_then(|u| u.as_str()).map(String::from),
})
})
.collect()
})
.unwrap_or_default();
if pr_reports.is_empty() {
if let Some(number) = pr_number {
pr_reports.push(PrReport { number, url: pr_url.clone() });
}
}
Ok(SessionMetadata { pr_number, pr_url, branch, pr_reports })
}
pub fn append_work_request(dir: &Path, session_id: &str, description: &str) -> Result<WorkRequest> {
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
let requested_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let request = WorkRequest {
id: format!(
"wr-{requested_at}-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed),
),
description: description.to_string(),
requested_at,
};
let requests_dir = work_requests_dir(dir, session_id);
std::fs::create_dir_all(&requests_dir)?;
let path = requests_dir.join(format!("{}.json", request.id));
let body = serde_json::json!({
"id": request.id,
"description": request.description,
"requestedAt": request.requested_at,
});
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(&body)?)?;
std::fs::rename(&tmp, &path)?;
Ok(request)
}
pub fn read_pending_work_requests(dir: &Path, session_id: &str) -> Result<Vec<WorkRequest>> {
let requests_dir = work_requests_dir(dir, session_id);
let entries = match std::fs::read_dir(&requests_dir) {
Ok(e) => e,
Err(_) => return Ok(Vec::new()), };
let mut requests: Vec<WorkRequest> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
.filter_map(|e| {
let raw = std::fs::read_to_string(e.path()).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
Some(WorkRequest {
id: v.get("id")?.as_str()?.to_string(),
description: v.get("description")?.as_str()?.to_string(),
requested_at: v.get("requestedAt").and_then(|t| t.as_i64()).unwrap_or(0),
})
})
.collect();
requests.sort_by(|a, b| a.requested_at.cmp(&b.requested_at).then(a.id.cmp(&b.id)));
Ok(requests)
}
pub fn mark_work_requests_delivered(dir: &Path, session_id: &str, ids: &[String]) -> Result<()> {
let requests_dir = work_requests_dir(dir, session_id);
let mut first_err: Option<std::io::Error> = None;
for id in ids {
let path = requests_dir.join(format!("{id}.json"));
if path.exists() {
let delivered = requests_dir.join(format!("{id}.json.delivered"));
if let Err(e) = std::fs::rename(&path, &delivered) {
first_err.get_or_insert(e);
}
}
}
match first_err {
Some(e) => Err(e.into()),
None => Ok(()),
}
}
pub fn remove_session_artifacts(dir: &Path, session_id: &str) {
let _ = std::fs::remove_file(metadata_path(dir, session_id));
let _ = std::fs::remove_dir_all(work_requests_dir(dir, session_id));
let _ = std::fs::remove_file(notified_prs_path(dir, session_id));
}
pub fn read_notified_extra_prs(dir: &Path, session_id: &str) -> Vec<u64> {
let path = notified_prs_path(dir, session_id);
std::fs::read_to_string(&path)
.ok()
.and_then(|raw| serde_json::from_str::<Vec<u64>>(&raw).ok())
.unwrap_or_default()
}
pub fn mark_extra_prs_notified(dir: &Path, session_id: &str, numbers: &[u64]) -> Result<()> {
let path = notified_prs_path(dir, session_id);
let mut notified = read_notified_extra_prs(dir, session_id);
for n in numbers {
if !notified.contains(n) {
notified.push(*n);
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, serde_json::to_string(¬ified)?)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
fn metadata_path(dir: &Path, session_id: &str) -> PathBuf {
dir.join(format!("{session_id}.json"))
}
fn work_requests_dir(dir: &Path, session_id: &str) -> PathBuf {
dir.join(format!("{session_id}.requests"))
}
fn notified_prs_path(dir: &Path, session_id: &str) -> PathBuf {
dir.join(format!("{session_id}.notified-prs.json"))
}
fn read_metadata_map(path: &Path) -> Result<Option<serde_json::Map<String, serde_json::Value>>> {
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&raw).ok())
}
fn as_u64(v: &serde_json::Value) -> Option<u64> {
v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
}
fn write_executable(path: PathBuf, content: &str) -> Result<()> {
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
}
std::fs::rename(&tmp, &path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn install_wrappers_creates_executables() {
let dir = tempdir().unwrap();
install_wrappers_to(dir.path()).unwrap();
assert!(dir.path().join("gh").exists());
assert!(dir.path().join("git").exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let gh_mode = std::fs::metadata(dir.path().join("gh"))
.unwrap().permissions().mode();
assert!(gh_mode & 0o111 != 0, "gh wrapper should be executable");
}
}
#[test]
fn read_session_metadata_parses_pr_number() {
let dir = tempdir().unwrap();
let metadata = serde_json::json!({
"agentReportedPrNumber": "42",
"agentReportedPrUrl": "https://github.com/org/repo/pull/42",
"branch": "feat/my-fix"
});
std::fs::write(
dir.path().join("s1.json"),
serde_json::to_string(&metadata).unwrap(),
).unwrap();
let m = read_session_metadata(dir.path(), "s1").unwrap();
assert_eq!(m.pr_number, Some(42));
assert_eq!(m.branch.as_deref(), Some("feat/my-fix"));
}
#[test]
fn read_session_metadata_returns_default_on_missing_file() {
let dir = tempdir().unwrap();
let m = read_session_metadata(dir.path(), "nonexistent").unwrap();
assert_eq!(m.pr_number, None);
assert_eq!(m.branch, None);
}
#[test]
fn read_session_metadata_handles_malformed_json() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("bad.json"), "not json").unwrap();
let m = read_session_metadata(dir.path(), "bad").unwrap();
assert_eq!(m.pr_number, None);
}
#[test]
fn append_work_request_accumulates_pending_requests() {
let dir = tempdir().unwrap();
let first = append_work_request(dir.path(), "s1", "Fix flaky auth test").unwrap();
let second = append_work_request(dir.path(), "s1", "Migrate config loader").unwrap();
assert_ne!(first.id, second.id, "each request needs a distinct id");
let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].description, "Fix flaky auth test");
assert_eq!(pending[1].description, "Migrate config loader");
}
#[test]
fn wrapper_scripts_read_only_ninox_env() {
for wrapper in [GH_WRAPPER, GIT_WRAPPER] {
assert!(wrapper.contains("${NINOX_SESSION:-}"));
assert!(wrapper.contains("${NINOX_DATA_DIR:-}"));
assert!(!wrapper.contains("ATHENE"));
}
}
#[test]
fn append_work_request_leaves_shared_metadata_file_alone() {
let dir = tempdir().unwrap();
let shared = r#"{"agentReportedPrNumber": "42", "branch": "feat/x"}"#;
std::fs::write(dir.path().join("s1.json"), shared).unwrap();
append_work_request(dir.path(), "s1", "New task").unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("s1.json")).unwrap(),
shared,
"shared metadata JSON must be byte-identical after a work request",
);
let m = read_session_metadata(dir.path(), "s1").unwrap();
assert_eq!(m.pr_number, Some(42));
assert_eq!(read_pending_work_requests(dir.path(), "s1").unwrap().len(), 1);
}
#[test]
fn mark_work_requests_delivered_removes_only_named_ids_from_pending() {
let dir = tempdir().unwrap();
let first = append_work_request(dir.path(), "s1", "task a").unwrap();
let second = append_work_request(dir.path(), "s1", "task b").unwrap();
mark_work_requests_delivered(dir.path(), "s1", std::slice::from_ref(&first.id)).unwrap();
let pending = read_pending_work_requests(dir.path(), "s1").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, second.id);
}
#[test]
fn pending_work_requests_empty_when_none_recorded() {
let dir = tempdir().unwrap();
assert!(read_pending_work_requests(dir.path(), "s1").unwrap().is_empty());
}
#[test]
fn remove_session_artifacts_clears_all_files_for_that_session_only() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("s1.json"), "{}").unwrap();
append_work_request(dir.path(), "s1", "task").unwrap();
mark_extra_prs_notified(dir.path(), "s1", &[9]).unwrap();
std::fs::write(dir.path().join("s2.json"), "{}").unwrap();
append_work_request(dir.path(), "s2", "other").unwrap();
remove_session_artifacts(dir.path(), "s1");
assert!(!dir.path().join("s1.json").exists());
assert!(!dir.path().join("s1.requests").exists());
assert!(!dir.path().join("s1.notified-prs.json").exists());
assert!(dir.path().join("s2.json").exists(), "other sessions untouched");
assert_eq!(read_pending_work_requests(dir.path(), "s2").unwrap().len(), 1);
remove_session_artifacts(dir.path(), "missing");
}
#[test]
fn notified_extra_prs_round_trip_and_accumulate() {
let dir = tempdir().unwrap();
assert!(read_notified_extra_prs(dir.path(), "s1").is_empty());
mark_extra_prs_notified(dir.path(), "s1", &[43, 44]).unwrap();
mark_extra_prs_notified(dir.path(), "s1", &[45]).unwrap();
let notified = read_notified_extra_prs(dir.path(), "s1");
assert_eq!(notified, vec![43, 44, 45]);
assert!(read_notified_extra_prs(dir.path(), "s2").is_empty());
}
#[test]
fn read_session_metadata_parses_reported_pr_list() {
let dir = tempdir().unwrap();
let metadata = serde_json::json!({
"agentReportedPrNumber": "44",
"agentReportedPrUrl": "https://github.com/org/repo/pull/44",
"agentReportedPrs": [
{"number": "42", "url": "https://github.com/org/repo/pull/42"},
{"number": "43", "url": "https://github.com/org/repo/pull/43"},
{"number": "44", "url": "https://github.com/org/repo/pull/44"},
],
});
std::fs::write(
dir.path().join("s1.json"),
serde_json::to_string(&metadata).unwrap(),
).unwrap();
let m = read_session_metadata(dir.path(), "s1").unwrap();
let numbers: Vec<u64> = m.pr_reports.iter().map(|p| p.number).collect();
assert_eq!(numbers, vec![42, 43, 44]);
assert_eq!(
m.pr_reports[0].url.as_deref(),
Some("https://github.com/org/repo/pull/42"),
);
}
#[test]
fn read_session_metadata_synthesizes_pr_list_from_legacy_scalars() {
let dir = tempdir().unwrap();
let metadata = serde_json::json!({
"agentReportedPrNumber": "42",
"agentReportedPrUrl": "https://github.com/org/repo/pull/42",
});
std::fs::write(
dir.path().join("s1.json"),
serde_json::to_string(&metadata).unwrap(),
).unwrap();
let m = read_session_metadata(dir.path(), "s1").unwrap();
assert_eq!(m.pr_reports.len(), 1);
assert_eq!(m.pr_reports[0].number, 42);
}
#[test]
fn gh_wrapper_appends_to_pr_list() {
assert!(GH_WRAPPER.contains("agentReportedPrs"));
}
}