diff --git a/src/cli/vent.rs b/src/cli/vent.rs index f0dfe4daf..843b8fefb 100644 --- a/src/cli/vent.rs +++ b/src/cli/vent.rs @@ -18,6 +18,14 @@ pub enum VentCmd { }, /// Triage buffered vents now (also run automatically once per turn). Consolidate, + /// List (or file, with --title/--body) pending agentflare-core vents as + /// ONE batched GitHub issue on getappz/agentflare. + File { + #[arg(long)] + title: Option, + #[arg(long)] + body: Option, + }, /// List triaged vents for this repo's project. List { #[arg(long)] @@ -33,9 +41,11 @@ pub fn run(args: VentArgs) { tags, } => { let severity = crate::vent::classify::normalize_severity(Some(&severity)); - let log = crate::vent::paths::log_path(); - match crate::vent::capture::append(&log, None, severity, &tags, message.trim()) { - Ok(id) => println!("vented {id}"), + match crate::vent::capture::append_routed(None, severity, &tags, message.trim()) { + Ok((id, true)) => { + println!("vent {id} suppressed (same friction already vented recently)") + } + Ok((id, false)) => println!("vented {id}"), Err(e) => eprintln!("vent failed: {e}"), } } @@ -58,6 +68,49 @@ pub fn run(args: VentArgs) { println!(" filed item {id}"); } } + VentCmd::File { title, body } => { + let log = crate::vent::paths::global_log_path(); + let filed = crate::vent::paths::global_filed_path(); + let batch = + crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp()); + if batch.is_empty() { + println!("nothing to file"); + return; + } + let (title, body) = match (title, body) { + (Some(title), Some(body)) => (title, body), + (None, None) => { + println!("{} pending agentflare-core vent(s):", batch.len()); + for g in &batch { + println!(" [{}] seen×{} {}", g.severity, g.seen_count, g.message); + } + println!("re-run with --title and --body to file these as one GitHub issue"); + return; + } + (title, body) => { + eprintln!( + "vent file: need both --title and --body to file (got title={}, body={})", + title.is_some(), + body.is_some() + ); + return; + } + }; + match crate::vent::file::create_github_issue(&title, &body) { + Ok(url) => { + let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); + let now = chrono::Utc::now().timestamp(); + match crate::vent::file::mark_filed(&filed, &keys, &url, now) { + Ok(()) => println!("filed {url} ({} vent(s))", keys.len()), + Err(e) => eprintln!( + "filed {url} but failed to record state ({e}) — re-filing will \ + create a duplicate issue until this is reconciled by hand" + ), + } + } + Err(e) => eprintln!("gh issue create failed: {e}"), + } + } VentCmd::List { actionable } => { let conn = match agentflare_backend::db::open_db(&crate::vent::paths::backend_db_path()) { @@ -104,6 +157,28 @@ pub fn run(args: VentArgs) { #[cfg(test)] mod tests { + #[test] + fn file_command_reports_nothing_when_batch_is_empty() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + let batch = crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp()); + assert!(batch.is_empty(), "no log file yet means nothing pending"); + } + + #[test] + fn say_via_append_routed_marks_agentflare_core_message_as_suppressed_on_repeat() { + crate::paths::test_support::with_temp_home(|| { + let (_, first) = + crate::vent::capture::append_routed(None, "high", &[], "af-guard blocked a push") + .unwrap(); + let (_, second) = + crate::vent::capture::append_routed(None, "high", &[], "af-guard blocked a push") + .unwrap(); + assert!(!first); + assert!(second); + }); + } + #[test] fn append_then_read_back_roundtrips() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/init.rs b/src/init.rs index 16a5a881f..0cb4472ee 100644 --- a/src/init.rs +++ b/src/init.rs @@ -198,6 +198,32 @@ fn add_hook_entry( true } +/// Adds one prompt-type hook entry for `event` unless an entry containing +/// `marker` is already present. Same idempotency pattern as `add_hook_entry` +/// but for `"type": "prompt"` hooks (used for PostToolUseFailure judge). +fn add_prompt_hook_entry( + hooks_obj: &mut Map, + event: &str, + marker: &str, + matcher: &str, + prompt: &str, + timeout: u64, +) -> bool { + let arr = hooks_obj + .entry(event) + .or_insert_with(|| json!([])) + .as_array_mut() + .unwrap(); + if arr.iter().any(|v| v.to_string().contains(marker)) { + return false; + } + arr.push(json!({ + "matcher": matcher, + "hooks": [{ "type": "prompt", "prompt": prompt, "timeout": timeout, "continueOnBlock": true }] + })); + true +} + fn wire_claude_code() { let path = claude_settings_path(); let mut settings = read_json_object(&path, || json!({})); @@ -236,6 +262,14 @@ fn wire_claude_code() { format!("\"{bin}\" hook pre-compact"), 5, ); + added |= add_prompt_hook_entry( + hooks_obj, + "PostToolUseFailure", + "genuine FRICTION", + "Bash|Edit|Write", + crate::rule_text::VENT_JUDGE_PROMPT, + 15, + ); if !added { ui::skip("~/.claude/settings.json hooks (already wired)"); @@ -695,6 +729,7 @@ mod tests { assert!(content.contains("UserPromptSubmit")); assert!(content.contains("PreToolUse")); assert!(content.contains("PreCompact")); + assert!(content.contains("PostToolUseFailure")); }); } @@ -749,6 +784,32 @@ mod tests { }); } + #[test] + fn wire_claude_code_wires_post_tool_use_failure_judge_prompt() { + with_temp_home(|| { + wire_claude_code(); + let content = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap(); + assert!( + content.contains("genuine FRICTION"), + "expected the judge-prompt marker in PostToolUseFailure hooks" + ); + }); + } + + #[test] + fn wire_claude_code_post_tool_use_failure_is_idempotent() { + with_temp_home(|| { + wire_claude_code(); + let first = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap(); + wire_claude_code(); + let second = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap(); + assert_eq!( + first, second, + "second run must not duplicate PostToolUseFailure" + ); + }); + } + #[test] fn wire_claude_code_backfills_pre_tool_use_into_already_wired_install() { with_temp_home(|| { diff --git a/src/ipc/process.rs b/src/ipc/process.rs index 41faf2e62..5086a01fa 100644 --- a/src/ipc/process.rs +++ b/src/ipc/process.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + pub fn is_alive(pid: u32) -> bool { #[cfg(windows)] { @@ -149,6 +151,25 @@ fn parse_other_pids(raw: &str, self_pid: u32) -> Vec { pids } +pub fn run_with_timeout(f: F, timeout: Duration) -> Result +where + F: FnOnce() -> T, + F: Send + 'static, + T: Send + 'static, +{ + let handle = std::thread::spawn(f); + let deadline = std::time::Instant::now() + timeout; + loop { + if handle.is_finished() { + return Ok(handle.join().unwrap()); + } + if std::time::Instant::now() >= deadline { + return Err("timed out".to_string()); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 4bdd24308..b6d4b6ecb 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -1185,12 +1185,67 @@ impl AgentflareMcp { } let severity = crate::vent::classify::normalize_severity(req.severity.as_deref()); let tags = req.tags.unwrap_or_default(); - let log = crate::vent::paths::log_path(); - let event_id = - crate::vent::capture::append(&log, None, severity, &tags, req.message.trim()).map_err( - |e| ErrorData::internal_error(format!("vent capture failed: {e}"), None), - )?; - Ok(serde_json::json!({ "ok": true, "event_id": event_id }).to_string()) + let (event_id, suppressed) = + crate::vent::capture::append_routed(None, severity, &tags, req.message.trim()) + .map_err(|e| { + ErrorData::internal_error(format!("vent capture failed: {e}"), None) + })?; + Ok( + serde_json::json!({ "ok": true, "event_id": event_id, "suppressed": suppressed }) + .to_string(), + ) + } + + #[tool( + description = "List or file agentflare's own tooling bugs (git shim, af-guard, item-tracker friction -- NOT this project's own vents) as ONE batched GitHub issue on getappz/agentflare. Call without title/body to see the pending batch and write a summary yourself; call again with both to file it." + )] + fn vent_file(&self, Parameters(req): Parameters) -> Result { + let log = crate::vent::paths::global_log_path(); + let filed = crate::vent::paths::global_filed_path(); + let batch = crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp()); + if batch.is_empty() { + return Ok(serde_json::json!({ "ok": true, "pending": [] }).to_string()); + } + match (req.title, req.body) { + (Some(title), Some(body)) => { + let url = crate::vent::file::create_github_issue(&title, &body).map_err(|e| { + ErrorData::internal_error(format!("gh issue create failed: {e}"), None) + })?; + let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); + let now = chrono::Utc::now().timestamp(); + // The issue itself is already created at this point — even if + // mark_filed fails, the caller must still learn the issue_url + // (not just an opaque error), or the same batch gets re-filed + // as a duplicate issue on the next call. + match crate::vent::file::mark_filed(&filed, &keys, &url, now) { + Ok(()) => Ok(serde_json::json!({ + "ok": true, + "issue_url": url, + "filed_count": keys.len(), + }) + .to_string()), + Err(e) => Ok(serde_json::json!({ + "ok": true, + "issue_url": url, + "filed_count": keys.len(), + "warning": format!( + "issue created but failed to record filed-state ({e}) — re-filing \ + will create a duplicate issue until this is reconciled by hand" + ), + }) + .to_string()), + } + } + (None, None) => Ok(serde_json::json!({ "ok": true, "pending": batch }).to_string()), + (title, body) => Err(ErrorData::invalid_params( + format!( + "vent_file needs both title and body to file (got title={}, body={}), or neither to list pending", + title.is_some(), + body.is_some() + ), + None, + )), + } } /// Rejects PR titles that don't start with a conventional-commit type, diff --git a/src/mcp_server/types.rs b/src/mcp_server/types.rs index 61cfff677..aaccb5bb2 100644 --- a/src/mcp_server/types.rs +++ b/src/mcp_server/types.rs @@ -326,6 +326,14 @@ pub(crate) struct VentRequest { pub(crate) tags: Option>, } +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct VentFileRequest { + #[serde(default)] + pub(crate) title: Option, + #[serde(default)] + pub(crate) body: Option, +} + #[derive(Debug, Default, Deserialize, schemars::JsonSchema)] pub(crate) struct MemoryRequest { #[schemars(description = "Action: compact|context|curate|handoff|recall|relate|remember")] diff --git a/src/rule_text.rs b/src/rule_text.rs index 04677de54..148436360 100644 --- a/src/rule_text.rs +++ b/src/rule_text.rs @@ -52,6 +52,27 @@ pub fn all() -> Vec<&'static str> { /// config registration needed). Shells out to the same `agentflare hook /// pre-tool-use` classifier Claude Code's PreToolUse hook already uses, so /// there's one branch-guard decision, not a JS reimplementation of it. +/// PostToolUseFailure judge prompt: decides whether a tool failure is +/// genuine friction worth a vent call. Explicitly separates "a guard blocked +/// exactly what it exists to block, cleanly" (not friction) from "a guard +/// blocked me in a confusing or undocumented way" (friction). +pub const VENT_JUDGE_PROMPT: &str = r#"A tool call just failed. Tool call details: +$ARGUMENTS + +Judge whether this is genuine FRICTION worth logging via agentflare's vent tool (mcp__flare__vent), using the SAME broad definition its own classifier uses -- not just "wrong/missing tool": +- severity is high / this genuinely blocked progress, OR +- the situation involves a pattern like broke/broken/fails/failing/wrong/should/missing/can't/cannot/error/panic/crash/hang/stuck/fabricated, OR +- this is a repeat of a problem that already happened earlier this session. + +This covers a wrong or missing tool, a fabricated assumption about the environment, an environment/tooling gap, a confusing or misleading error message, or a tool silently doing the wrong thing -- not just outright blocking failures. + +NOT friction: an ordinary expected failure (a grep with no matches, a file legitimately absent during exploration, a normal retry, slow builds, a routine denied permission), OR a guard/hook that blocked exactly the thing it exists to block, with a clear reason and a discoverable next step (e.g. a branch-protection guard refusing a write to main, or a destructive-command guard refusing rm -rf) -- that guard did its job; it is not a bug. + +Friction IS still the right call when a guard's block is confusing or its workaround is undocumented -- e.g. it names an alternative that doesn't exist, or leaves you guessing which action it actually wants. + +If this is genuine friction: respond NOT ok, with a reason that nudges calling mcp__flare__vent with a concise, specific message (state the actual wrong tool/assumption/gap, not a vague complaint) and an appropriate severity. +If this is an ordinary failure or a guard working as intended: respond ok."#; + pub const OPENCODE_BRANCH_GUARD_JS: &str = r#"// opencode has no PreToolUse hook wired to agentflare's own PreToolUse guard // (that's Claude-Code-only, ~/.claude/settings.json), and opencode's own // permission config isn't branch-aware -- so write/edit/patch tools went diff --git a/src/vent/capture.rs b/src/vent/capture.rs index 08ba703f7..3f4e2a736 100644 --- a/src/vent/capture.rs +++ b/src/vent/capture.rs @@ -40,10 +40,176 @@ pub fn append( Ok(id) } +pub const THROTTLE_WINDOW_SECS: i64 = 300; +pub const THROTTLE_MAX_PER_WINDOW: usize = 1; + +fn recent_matches_for_topic( + log_path: &Path, + topic_key: &str, + now: chrono::DateTime, +) -> Vec { + let Ok(text) = std::fs::read_to_string(log_path) else { + return Vec::new(); + }; + text.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| crate::vent::classify::topic_key(&v.message) == topic_key) + .filter(|v| { + chrono::DateTime::parse_from_rfc3339(&v.ts) + .map(|t| { + let t = t.with_timezone(&chrono::Utc); + t <= now && (now - t).num_seconds() < THROTTLE_WINDOW_SECS + }) + .unwrap_or(false) + }) + .collect() +} + +/// Classifies origin, picks the destination log (global agentflare-core vs. +/// per-repo), throttles repeat vents of the same topic within +/// `THROTTLE_WINDOW_SECS`, and appends if not throttled. Returns +/// `(event_id, suppressed)` — `suppressed = true` means nothing was written. +pub fn append_routed( + session: Option<&str>, + severity: &str, + tags: &[String], + message: &str, +) -> std::io::Result<(String, bool)> { + let log_path = if crate::vent::classify::origin(message) == "agentflare-core" { + crate::vent::paths::global_log_path() + } else { + crate::vent::paths::log_path() + }; + let topic_key = crate::vent::classify::topic_key(message); + let now = chrono::Utc::now(); + let matches = recent_matches_for_topic(&log_path, &topic_key, now); + if matches.len() >= THROTTLE_MAX_PER_WINDOW { + // Reference the real, already-logged event instead of minting a + // fresh id — `event_id()` is time-salted, so a second call would + // return an id that was never written anywhere. + let id = matches + .last() + .map(|v| v.event_id.clone()) + .unwrap_or_else(|| crate::vent::event_id(message)); + return Ok((id, true)); + } + let id = append(&log_path, session, severity, tags, message)?; + Ok((id, false)) +} + #[cfg(test)] mod tests { use super::*; + fn write_line_at(log: &std::path::Path, ts: &str, severity: &str, message: &str) { + let line = VentLine { + event_id: crate::vent::event_id(message), + ts: ts.to_string(), + session: None, + severity: severity.to_string(), + tags: vec![], + message: message.to_string(), + }; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .unwrap(); + writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap(); + } + + #[test] + fn recent_count_ignores_lines_outside_the_window() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("v.jsonl"); + write_line_at( + &log, + "2020-01-01T00:00:00Z", + "high", + "an old repeated failure", + ); + let now = chrono::Utc::now(); + let key = crate::vent::classify::topic_key("an old repeated failure"); + assert_eq!(recent_matches_for_topic(&log, &key, now).len(), 0); + } + + #[test] + fn recent_count_ignores_future_dated_lines() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("v.jsonl"); + let now = chrono::Utc::now(); + let future = now + chrono::Duration::seconds(60); + write_line_at(&log, &future.to_rfc3339(), "high", "a future-dated entry"); + let key = crate::vent::classify::topic_key("a future-dated entry"); + assert_eq!( + recent_matches_for_topic(&log, &key, now).len(), + 0, + "a future timestamp must not count as recent just because the age is negative" + ); + } + + #[test] + fn recent_count_includes_lines_inside_the_window() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("v.jsonl"); + let now = chrono::Utc::now(); + write_line_at(&log, &now.to_rfc3339(), "high", "a fresh repeated failure"); + let key = crate::vent::classify::topic_key("a fresh repeated failure"); + assert_eq!(recent_matches_for_topic(&log, &key, now).len(), 1); + } + + #[test] + fn append_routed_suppresses_second_identical_call_within_window() { + crate::paths::test_support::with_temp_home(|| { + let (_, suppressed1) = + append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); + let (_, suppressed2) = + append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); + assert!(!suppressed1, "first occurrence must be captured"); + assert!( + suppressed2, + "second identical vent within the window must be throttled" + ); + }); + } + + #[test] + fn append_routed_suppressed_call_returns_original_event_id() { + crate::paths::test_support::with_temp_home(|| { + let (first_id, suppressed1) = + append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); + let (second_id, suppressed2) = + append_routed(None, "high", &[], "the exact same af-guard bug").unwrap(); + assert!(!suppressed1); + assert!(suppressed2); + assert_eq!( + second_id, first_id, + "suppressed call must reference the real logged event, not a fabricated id" + ); + }); + } + + #[test] + fn append_routed_sends_agentflare_core_and_external_to_different_logs() { + crate::paths::test_support::with_temp_home(|| { + append_routed( + None, + "high", + &[], + "af-guard blocked a protected branch push", + ) + .unwrap(); + append_routed(None, "high", &[], "a totally unrelated bash quoting bug").unwrap(); + let global_text = + std::fs::read_to_string(crate::vent::paths::global_log_path()).unwrap_or_default(); + let repo_text = + std::fs::read_to_string(crate::vent::paths::log_path()).unwrap_or_default(); + assert!(global_text.contains("af-guard")); + assert!(!repo_text.contains("af-guard")); + assert!(repo_text.contains("bash quoting bug")); + }); + } + #[test] fn append_writes_one_parseable_line_per_call() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/vent/classify.rs b/src/vent/classify.rs index 3a1fe5b01..6b5c7a80b 100644 --- a/src/vent/classify.rs +++ b/src/vent/classify.rs @@ -10,6 +10,24 @@ static MARKER_RE: LazyLock = LazyLock::new(|| { .expect("static marker regex is valid") }); +static ORIGIN_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?i)af-guard|agentflare git shim|orchestrator-managed by agentflare|item tracker is wired up for this repo", + ) + .expect("static origin regex is valid") +}); + +/// Classifies which pipeline a vent message belongs to: agentflare's own +/// tooling (git shim, af-guard, item-tracker redirects) vs. everything else. +/// Deliberately narrow so plain errors mentioning "git worktree" don't match. +pub fn origin(message: &str) -> &'static str { + if ORIGIN_RE.is_match(message) { + "agentflare-core" + } else { + "external" + } +} + pub fn topic_key(message: &str) -> String { message .to_lowercase() @@ -83,4 +101,42 @@ mod tests { assert_eq!(normalize_severity(Some("garbage")), "medium"); assert_eq!(normalize_severity(None), "medium"); } + + #[test] + fn origin_detects_agentflare_core_signatures() { + assert_eq!( + origin("af-guard's Bash hook blocked git merge-base --is-ancestor"), + "agentflare-core" + ); + assert_eq!( + origin("agentflare git shim: denied -- use item tool's claim flow"), + "agentflare-core" + ); + assert_eq!( + origin("'git worktree' is orchestrator-managed by agentflare"), + "agentflare-core" + ); + assert_eq!( + origin( + "agentflare-backend's item tracker is wired up for this repo -- use the `item` MCP tool" + ), + "agentflare-core" + ); + } + + #[test] + fn origin_defaults_to_external_for_unrelated_failures() { + assert_eq!( + origin("bash: a for loop expanded $d to every file in cwd"), + "external" + ); + assert_eq!( + origin("lean-ctx ctx_read failed with os error 5 on a directory"), + "external" + ); + assert_eq!( + origin("git worktree remove failed: Filename too long"), + "external", + ); + } } diff --git a/src/vent/consolidate.rs b/src/vent/consolidate.rs index 88ac5bae6..2edc031a9 100644 --- a/src/vent/consolidate.rs +++ b/src/vent/consolidate.rs @@ -76,6 +76,7 @@ pub fn consolidate_core( severity: String, count: i64, first_event: String, + tags: Vec, } let mut groups: BTreeMap = BTreeMap::new(); for l in &lines { @@ -85,9 +86,11 @@ pub fn consolidate_core( severity: l.severity.clone(), count: 0, first_event: l.event_id.clone(), + tags: l.tags.clone(), }); g.count += 1; g.message = l.message.clone(); + g.tags = l.tags.clone(); if severity_rank(&l.severity) > severity_rank(&g.severity) { g.severity = l.severity.clone(); } @@ -95,13 +98,13 @@ pub fn consolidate_core( for (key, g) in groups { report.consolidated += g.count as usize; - let tags_json = "[]"; + let tags_json = serde_json::to_string(&g.tags).unwrap_or_else(|_| "[]".to_string()); let out = match agentflare_backend::vent::upsert( conn, project_id, &g.message, &g.severity, - tags_json, + &tags_json, &key, &g.first_event, g.count, @@ -300,4 +303,36 @@ mod tests { .unwrap(); assert_eq!(seen, 43); } + + #[test] + fn tags_survive_consolidation_instead_of_being_dropped() { + let conn = open_in_memory().unwrap(); + let (p, s) = seed(&conn); + let dir = tempfile::tempdir().unwrap(); + let (log, cur) = (dir.path().join("v.jsonl"), dir.path().join("v.cursor")); + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log) + .unwrap(); + let line = crate::vent::capture::VentLine { + event_id: crate::vent::event_id("tagged failure"), + ts: "t".into(), + session: None, + severity: "high".to_string(), + tags: vec!["dx".to_string()], + message: "tagged failure".to_string(), + }; + writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap(); + drop(f); + consolidate_core(&conn, &p, &s, &log, &cur).unwrap(); + let tags: String = conn + .query_row("SELECT tags FROM vents", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + tags, r#"["dx"]"#, + "tags must flow through, not be hardcoded to []" + ); + } } diff --git a/src/vent/file.rs b/src/vent/file.rs new file mode 100644 index 000000000..7385118fc --- /dev/null +++ b/src/vent/file.rs @@ -0,0 +1,244 @@ +use crate::vent::capture::VentLine; +use crate::vent::classify::{classify, severity_rank, topic_key}; +use std::collections::BTreeMap; +use std::path::Path; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PendingGroup { + pub topic_key: String, + pub message: String, + pub severity: String, + pub seen_count: i64, +} + +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +struct FiledEntry { + issue_url: String, + filed_at: i64, +} + +/// How long a filed topic stays excluded from `pending_batch` before it's +/// eligible to resurface. Without this, a bug that regresses after its issue +/// is closed (or never actually gets fixed) would be hidden forever. +pub const FILED_TTL_SECS: i64 = 30 * 24 * 60 * 60; + +fn read_all_lines(log_path: &Path) -> Vec { + let Ok(text) = std::fs::read_to_string(log_path) else { + return Vec::new(); + }; + text.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .collect() +} + +fn load_filed(filed_path: &Path) -> BTreeMap { + let Ok(text) = std::fs::read_to_string(filed_path) else { + return BTreeMap::new(); + }; + serde_json::from_str(&text).unwrap_or_default() +} + +/// Writes via a sibling temp file + rename so a process killed mid-write +/// can never leave `filed_path` holding a truncated/corrupt JSON document. +fn save_filed(filed_path: &Path, filed: &BTreeMap) -> std::io::Result<()> { + if let Some(parent) = filed_path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp_path = filed_path.with_extension("json.tmp"); + std::fs::write(&tmp_path, serde_json::to_string_pretty(filed)?)?; + std::fs::rename(&tmp_path, filed_path) +} + +/// Groups the global agentflare-core log by topic, applies the same +/// actionability rule the per-project pipeline uses, and excludes anything +/// recorded in `filed_path` less than `FILED_TTL_SECS` ago — a topic filed +/// longer than that resurfaces so a recurring or unfixed bug isn't hidden +/// forever behind a single old issue. +pub fn pending_batch(log_path: &Path, filed_path: &Path, now: i64) -> Vec { + let lines = read_all_lines(log_path); + let filed = load_filed(filed_path); + let mut groups: BTreeMap = BTreeMap::new(); + for l in &lines { + let key = topic_key(&l.message); + let g = groups.entry(key.clone()).or_insert_with(|| PendingGroup { + topic_key: key.clone(), + message: l.message.clone(), + severity: l.severity.clone(), + seen_count: 0, + }); + g.seen_count += 1; + g.message = l.message.clone(); + if severity_rank(&l.severity) > severity_rank(&g.severity) { + g.severity = l.severity.clone(); + } + } + groups + .into_values() + .filter(|g| classify(&g.severity, g.seen_count, &g.message)) + .filter(|g| match filed.get(&g.topic_key) { + Some(entry) => now - entry.filed_at >= FILED_TTL_SECS, + None => true, + }) + .collect() +} + +/// Records every topic in `topic_keys` as filed under `issue_url`. All-or- +/// nothing at the call-site level: callers only invoke this after a confirmed +/// issue URL comes back from `create_github_issue`. +pub fn mark_filed( + filed_path: &Path, + topic_keys: &[String], + issue_url: &str, + filed_at: i64, +) -> std::io::Result<()> { + let mut filed = load_filed(filed_path); + for k in topic_keys { + filed.insert( + k.clone(), + FiledEntry { + issue_url: issue_url.to_string(), + filed_at, + }, + ); + } + save_filed(filed_path, &filed) +} + +const GH_ISSUE_CREATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Shells out to `gh issue create`. Not unit tested (matches this repo's existing +/// convention for subprocess calls) — exercised at the integration/manual level. +/// Bounded by `GH_ISSUE_CREATE_TIMEOUT` so a hung `gh` (network stall, stuck +/// auth prompt) can't block the calling MCP tool call/CLI invocation forever. +pub fn create_github_issue(title: &str, body: &str) -> std::io::Result { + let title = title.to_string(); + let body = body.to_string(); + let output = crate::ipc::process::run_with_timeout( + move || { + std::process::Command::new("gh") + .args([ + "issue", + "create", + "--repo", + "getappz/agentflare", + "--title", + &title, + "--body", + &body, + ]) + .output() + }, + GH_ISSUE_CREATE_TIMEOUT, + ) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("gh issue create did not finish within {GH_ISSUE_CREATE_TIMEOUT:?}"), + ) + })??; + if !output.status.success() { + return Err(std::io::Error::other( + String::from_utf8_lossy(&output.stderr).to_string(), + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vent::capture::VentLine; + use std::io::Write; + + fn write_lines(log: &Path, msgs: &[(&str, &str)]) { + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .unwrap(); + for (sev, m) in msgs { + let line = VentLine { + event_id: crate::vent::event_id(m), + ts: "2026-01-01T00:00:00Z".into(), + session: None, + severity: (*sev).to_string(), + tags: vec![], + message: (*m).to_string(), + }; + writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap(); + } + } + + #[test] + fn pending_batch_groups_by_topic_and_tracks_seen_count() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + write_lines( + &log, + &[ + ("high", "af-guard blocked something"), + ("high", "af-guard blocked something"), + ("high", "a totally different bug"), + ], + ); + let batch = pending_batch(&log, &filed, 0); + assert_eq!(batch.len(), 2, "two distinct topics"); + let guard_group = batch + .iter() + .find(|g| g.message.contains("af-guard")) + .unwrap(); + assert_eq!(guard_group.seen_count, 2); + } + + #[test] + fn pending_batch_excludes_non_actionable_noise() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + write_lines(&log, &[("low", "just a calm note")]); + assert!(pending_batch(&log, &filed, 0).is_empty()); + } + + #[test] + fn mark_filed_removes_group_from_next_pending_batch() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + write_lines(&log, &[("high", "af-guard blocked something")]); + let now = 1_700_000_000; + let batch = pending_batch(&log, &filed, now); + assert_eq!(batch.len(), 1); + let keys: Vec = batch.iter().map(|g| g.topic_key.clone()).collect(); + mark_filed( + &filed, + &keys, + "https://github.com/getappz/agentflare/issues/1", + now, + ) + .unwrap(); + assert!( + pending_batch(&log, &filed, now + 60).is_empty(), + "already-filed topic must not reappear within the TTL window" + ); + } + + #[test] + fn pending_batch_allows_refiling_after_ttl_expires() { + let dir = tempfile::tempdir().unwrap(); + let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json")); + write_lines(&log, &[("high", "af-guard blocked something")]); + let filed_at = 1_700_000_000; + let keys = vec![topic_key("af-guard blocked something")]; + mark_filed( + &filed, + &keys, + "https://github.com/getappz/agentflare/issues/1", + filed_at, + ) + .unwrap(); + assert!( + pending_batch(&log, &filed, filed_at + FILED_TTL_SECS - 1).is_empty(), + "still within the TTL window" + ); + let batch = pending_batch(&log, &filed, filed_at + FILED_TTL_SECS + 1); + assert_eq!(batch.len(), 1, "must resurface once the TTL has elapsed"); + } +} diff --git a/src/vent/mod.rs b/src/vent/mod.rs index eb8abefc2..d4cec418e 100644 --- a/src/vent/mod.rs +++ b/src/vent/mod.rs @@ -1,6 +1,7 @@ pub mod capture; pub mod classify; pub mod consolidate; +pub mod file; pub mod paths; pub fn event_id(message: &str) -> String { diff --git a/src/vent/paths.rs b/src/vent/paths.rs index 3625100b6..3ec06db89 100644 --- a/src/vent/paths.rs +++ b/src/vent/paths.rs @@ -44,6 +44,24 @@ pub fn cursor_path() -> PathBuf { .join(format!("{}.cursor", repo_slug())) } +/// Global, cross-repo log for agentflare-core vents (git shim / af-guard / +/// item-tracker friction) — deliberately NOT repo-slugged like `log_path()`, +/// so the same underlying agentflare bug hit in different repos dedups into +/// one place instead of splitting across N per-project rows. +pub fn global_log_path() -> PathBuf { + crate::state::state_dir() + .join("vents") + .join("agentflare-core.jsonl") +} + +/// `topic_key -> {issue_url, filed_at}` for the global agentflare-core log. +/// A vent is either filed or it isn't — no separate cursor file is needed. +pub fn global_filed_path() -> PathBuf { + crate::state::state_dir() + .join("vents") + .join("agentflare-core.filed.json") +} + pub fn backend_db_path() -> PathBuf { crate::paths::home().join(".agentflare").join("backend.db") } @@ -75,4 +93,21 @@ mod tests { fn repo_key_is_stable() { assert_eq!(repo_key(), repo_key()); } + + #[test] + fn global_paths_are_siblings_and_not_repo_scoped() { + let _guard = agent_registry::detect::PATH_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let log = global_log_path(); + let filed = global_filed_path(); + assert!(log.to_string_lossy().contains("agentflare-core.jsonl")); + assert!( + filed + .to_string_lossy() + .contains("agentflare-core.filed.json") + ); + assert_eq!(log.parent(), filed.parent()); + assert_eq!(global_log_path(), global_log_path()); + } }