-
Notifications
You must be signed in to change notification settings - Fork 0
feat(vent): friction capture + per-turn consolidation → auto-filed items #259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c38981e
81e83cb
03f49e1
ffb040c
ec1d020
e77d9b6
267a381
ea65413
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| CREATE TABLE IF NOT EXISTS vents ( | ||
| id TEXT PRIMARY KEY, | ||
| project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, | ||
| message TEXT NOT NULL, | ||
| severity TEXT NOT NULL DEFAULT 'medium', | ||
| tags TEXT NOT NULL DEFAULT '[]', | ||
| topic_key TEXT NOT NULL, | ||
| seen_count INTEGER NOT NULL DEFAULT 1, | ||
| actionable INTEGER NOT NULL DEFAULT 0, | ||
| item_id TEXT REFERENCES items(id) ON DELETE SET NULL, | ||
| first_event_id TEXT NOT NULL, | ||
| created_at INTEGER NOT NULL, | ||
| updated_at INTEGER NOT NULL, | ||
| UNIQUE(project_id, topic_key) | ||
| ); | ||
| CREATE INDEX IF NOT EXISTS idx_vents_project_actionable ON vents(project_id, actionable); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| use crate::error::Result; | ||
| use rusqlite::{Connection, OptionalExtension, params}; | ||
|
|
||
| #[derive(Debug, Clone, serde::Serialize)] | ||
| pub struct Vent { | ||
| pub id: String, | ||
| pub project_id: String, | ||
| pub message: String, | ||
| pub severity: String, | ||
| pub tags: String, | ||
| pub topic_key: String, | ||
| pub seen_count: i64, | ||
| pub actionable: bool, | ||
| pub item_id: Option<String>, | ||
| pub first_event_id: String, | ||
| pub created_at: i64, | ||
| pub updated_at: i64, | ||
| } | ||
|
|
||
| pub struct UpsertOutcome { | ||
| pub id: String, | ||
| pub seen_count: i64, | ||
| pub existing_item_id: Option<String>, | ||
| pub was_actionable: bool, | ||
| } | ||
|
|
||
| #[allow(clippy::too_many_arguments)] | ||
| pub fn upsert( | ||
| conn: &Connection, | ||
| project_id: &str, | ||
| message: &str, | ||
| severity: &str, | ||
| tags_json: &str, | ||
| topic_key: &str, | ||
| first_event_id: &str, | ||
| seen_delta: i64, | ||
| now: i64, | ||
| ) -> Result<UpsertOutcome> { | ||
| let existing = conn | ||
| .query_row( | ||
| "SELECT id, seen_count, item_id, actionable FROM vents | ||
| WHERE project_id = ?1 AND topic_key = ?2", | ||
| params![project_id, topic_key], | ||
| |r| { | ||
| Ok(( | ||
| r.get::<_, String>(0)?, | ||
| r.get::<_, i64>(1)?, | ||
| r.get::<_, Option<String>>(2)?, | ||
| r.get::<_, i64>(3)? != 0, | ||
| )) | ||
| }, | ||
| ) | ||
| .optional()?; | ||
|
|
||
| if let Some((id, seen, item_id, actionable)) = existing { | ||
| let new_seen = seen + seen_delta; | ||
| conn.execute( | ||
| "UPDATE vents SET seen_count = ?2, message = ?3, severity = ?4, | ||
| tags = ?5, updated_at = ?6 WHERE id = ?1", | ||
| params![id, new_seen, message, severity, tags_json, now], | ||
| )?; | ||
| return Ok(UpsertOutcome { | ||
| id, | ||
| seen_count: new_seen, | ||
| existing_item_id: item_id, | ||
| was_actionable: actionable, | ||
| }); | ||
| } | ||
|
|
||
| let id = db_kit::ids::new_id(); | ||
| conn.execute( | ||
| "INSERT INTO vents (id, project_id, message, severity, tags, topic_key, | ||
| seen_count, actionable, item_id, first_event_id, created_at, updated_at) | ||
| VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, ?8, ?9, ?9)", | ||
| params![ | ||
| id, | ||
| project_id, | ||
| message, | ||
| severity, | ||
| tags_json, | ||
| topic_key, | ||
| seen_delta, | ||
| first_event_id, | ||
| now | ||
| ], | ||
| )?; | ||
|
Comment on lines
+39
to
+86
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== vent.rs ==\n'
wc -l crates/agentflare-backend/src/vent.rs
sed -n '1,220p' crates/agentflare-backend/src/vent.rs | cat -n
printf '\n== search vents schema / upsert callers ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
'CREATE TABLE vents|UNIQUE\\s*\\(project_id,\\s*topic_key\\)|upsert|seen_count|topic_key' \
crates/agentflare-backend -SRepository: getappz/agentflare Length of output: 10102 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== callers of vent::upsert ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' 'vent::upsert|upsert\(' crates/agentflare-backend/src -S
printf '\n== migration 0006_vents.sql ==\n'
cat -n crates/agentflare-backend/src/migrations/0006_vents.sqlRepository: getappz/agentflare Length of output: 1322 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== transaction / lock / retry usage ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
'BEGIN IMMEDIATE|BEGIN EXCLUSIVE|transaction\(|immediate_transaction|busy_timeout|retry|ConstraintViolation|UNIQUE\(project_id, topic_key\)' \
crates/agentflare-backend/src -S
printf '\n== db connection setup ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
'open_in_memory|open\(|Connection::open|pragma|journal_mode|foreign_keys|busy_timeout' \
crates/agentflare-backend/src -SRepository: getappz/agentflare Length of output: 9315 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== db.rs ==\n'
wc -l crates/agentflare-backend/src/db.rs
sed -n '1,240p' crates/agentflare-backend/src/db.rs | cat -n
printf '\n== connection sharing / mutex / pool ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
'Mutex<Connection>|Arc<Mutex<Connection>>|Pool<|r2d2|deadpool|Connection>|Db|AppState|State|open_db\(' \
crates/agentflare-backend/src -SRepository: getappz/agentflare Length of output: 10691 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== vent module exports / runtime references ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
'\bmod vent\b|\bpub use vent\b|\bvent::upsert\b|\bupsert\(' \
crates/agentflare-backend/src crates -S
printf '\n== where open_db is used outside tests ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' \
'\bopen_db\(' \
. -SRepository: getappz/agentflare Length of output: 3298 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== src/vent/consolidate.rs ==\n'
wc -l src/vent/consolidate.rs
sed -n '1,260p' src/vent/consolidate.rs | cat -n
printf '\n== src/cli/vent.rs ==\n'
wc -l src/cli/vent.rs
sed -n '1,180p' src/cli/vent.rs | cat -n
printf '\n== src/hook.rs vent references ==\n'
rg -n --hidden --glob '!target' --glob '!node_modules' 'vent::|consolidat|upsert\(' src/hook.rs src/mcp_server.rs -SRepository: getappz/agentflare Length of output: 18040 Make the vent upsert atomic 🤖 Prompt for AI Agents |
||
| Ok(UpsertOutcome { | ||
| id, | ||
| seen_count: seen_delta, | ||
| existing_item_id: None, | ||
| was_actionable: false, | ||
| }) | ||
| } | ||
|
|
||
| pub fn link_item(conn: &Connection, vent_id: &str, item_id: &str) -> Result<()> { | ||
| conn.execute( | ||
| "UPDATE vents SET item_id = ?2 WHERE id = ?1", | ||
| params![vent_id, item_id], | ||
| )?; | ||
| Ok(()) | ||
| } | ||
|
|
||
| pub fn set_actionable(conn: &Connection, vent_id: &str, actionable: bool) -> Result<()> { | ||
| conn.execute( | ||
| "UPDATE vents SET actionable = ?2 WHERE id = ?1", | ||
| params![vent_id, i64::from(actionable)], | ||
| )?; | ||
| Ok(()) | ||
| } | ||
|
|
||
| pub fn list(conn: &Connection, project_id: &str, actionable_only: bool) -> Result<Vec<Vent>> { | ||
| let mut stmt = conn.prepare( | ||
| "SELECT id, project_id, message, severity, tags, topic_key, seen_count, | ||
| actionable, item_id, first_event_id, created_at, updated_at | ||
| FROM vents WHERE project_id = ?1 AND (?2 = 0 OR actionable = 1) | ||
| ORDER BY updated_at DESC", | ||
| )?; | ||
| let rows = stmt.query_map(params![project_id, i64::from(actionable_only)], |r| { | ||
| Ok(Vent { | ||
| id: r.get(0)?, | ||
| project_id: r.get(1)?, | ||
| message: r.get(2)?, | ||
| severity: r.get(3)?, | ||
| tags: r.get(4)?, | ||
| topic_key: r.get(5)?, | ||
| seen_count: r.get(6)?, | ||
| actionable: r.get::<_, i64>(7)? != 0, | ||
| item_id: r.get(8)?, | ||
| first_event_id: r.get(9)?, | ||
| created_at: r.get(10)?, | ||
| updated_at: r.get(11)?, | ||
| }) | ||
| })?; | ||
| rows.collect::<rusqlite::Result<Vec<_>>>() | ||
| .map_err(Into::into) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use crate::db::open_in_memory; | ||
|
|
||
| fn seed_project(conn: &rusqlite::Connection) -> String { | ||
| conn.execute("INSERT INTO workspaces (id,name,slug,item_label,created_at,updated_at) VALUES ('w','W','w','Item',1,1)", []).unwrap(); | ||
| conn.execute("INSERT INTO projects (id,workspace_id,name,identifier,created_at,updated_at) VALUES ('p','w','P','P',1,1)", []).unwrap(); | ||
| "p".to_string() | ||
| } | ||
|
|
||
| #[test] | ||
| fn upsert_dedups_by_topic_and_accumulates_seen_count() { | ||
| let conn = open_in_memory().unwrap(); | ||
| let p = seed_project(&conn); | ||
| let a = upsert( | ||
| &conn, | ||
| &p, | ||
| "disk full", | ||
| "medium", | ||
| "[]", | ||
| "disk full", | ||
| "ev1", | ||
| 1, | ||
| 100, | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(a.seen_count, 1); | ||
| assert!(a.existing_item_id.is_none()); | ||
| let b = upsert( | ||
| &conn, | ||
| &p, | ||
| "disk full", | ||
| "high", | ||
| "[]", | ||
| "disk full", | ||
| "ev2", | ||
| 5, | ||
| 200, | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(a.id, b.id, "same topic → same row"); | ||
| assert_eq!(b.seen_count, 6, "1 + delta 5"); | ||
| assert_eq!(list(&conn, &p, false).unwrap().len(), 1); | ||
| } | ||
|
|
||
| #[test] | ||
| fn link_item_and_actionable_filter() { | ||
| let conn = open_in_memory().unwrap(); | ||
| let p = seed_project(&conn); | ||
| conn.execute("INSERT INTO states (id,project_id,name,group_name,sequence,is_default,created_at,updated_at) VALUES ('s','p','Backlog','backlog',1.0,1,1,1)", []).unwrap(); | ||
| conn.execute("INSERT INTO items (id,project_id,state_id,name,created_at,updated_at) VALUES ('it','p','s','x',1,1)", []).unwrap(); | ||
| let v = upsert(&conn, &p, "m", "low", "[]", "m", "ev", 1, 1).unwrap(); | ||
| set_actionable(&conn, &v.id, true).unwrap(); | ||
| link_item(&conn, &v.id, "it").unwrap(); | ||
| let only = list(&conn, &p, true).unwrap(); | ||
| assert_eq!(only.len(), 1); | ||
| assert_eq!(only[0].item_id.as_deref(), Some("it")); | ||
| assert!(only[0].actionable); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| use clap::{Args, Subcommand}; | ||
|
|
||
| #[derive(Args)] | ||
| pub struct VentArgs { | ||
| #[command(subcommand)] | ||
| pub cmd: VentCmd, | ||
| } | ||
|
|
||
| #[derive(Subcommand)] | ||
| pub enum VentCmd { | ||
| /// Log a friction vent (append-only; triaged at the next turn). | ||
| Say { | ||
| message: String, | ||
| #[arg(long, default_value = "medium")] | ||
| severity: String, | ||
| #[arg(long = "tag")] | ||
| tags: Vec<String>, | ||
| }, | ||
| /// Triage buffered vents now (also run automatically once per turn). | ||
| Consolidate, | ||
| /// List triaged vents for this repo's project. | ||
| List { | ||
| #[arg(long)] | ||
| actionable: bool, | ||
| }, | ||
| } | ||
|
|
||
| pub fn run(args: VentArgs) { | ||
| match args.cmd { | ||
| VentCmd::Say { | ||
| message, | ||
| severity, | ||
| 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}"), | ||
| Err(e) => eprintln!("vent failed: {e}"), | ||
| } | ||
| } | ||
| VentCmd::Consolidate => { | ||
| let r = crate::vent::consolidate::consolidate(); | ||
| println!( | ||
| "consolidated {} vent(s) → {} item(s){}", | ||
| r.consolidated, | ||
| r.items_created.len(), | ||
| if r.buffered_no_project > 0 { | ||
| format!( | ||
| " ({} buffered — no linked project yet)", | ||
| r.buffered_no_project | ||
| ) | ||
| } else { | ||
| String::new() | ||
| } | ||
| ); | ||
| for id in r.items_created { | ||
| println!(" filed item {id}"); | ||
| } | ||
| } | ||
| VentCmd::List { actionable } => { | ||
| let conn = match agentflare_backend::db::open_db(&crate::vent::paths::backend_db_path()) | ||
| { | ||
| Ok(c) => c, | ||
| Err(e) => { | ||
| eprintln!("cannot open backend: {e}"); | ||
| return; | ||
| } | ||
| }; | ||
| let link = crate::vent::paths::repo_root() | ||
| .join(".agentflare") | ||
| .join("project.json"); | ||
| let project_id = std::fs::read(&link) | ||
| .ok() | ||
| .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok()) | ||
| .and_then(|v| { | ||
| v.get("project_id") | ||
| .and_then(|p| p.as_str().map(String::from)) | ||
| }); | ||
| let Some(pid) = project_id else { | ||
| println!("no linked project — run an agentflare item/memory command here first"); | ||
| return; | ||
| }; | ||
| match agentflare_backend::vent::list(&conn, &pid, actionable) { | ||
| Ok(vents) => { | ||
| for v in vents { | ||
| println!( | ||
| "{} seen×{} {} {}{}", | ||
| if v.actionable { "●" } else { "○" }, | ||
| v.seen_count, | ||
| v.severity, | ||
| v.message.split('\n').next().unwrap_or(""), | ||
| v.item_id | ||
| .map(|i| format!(" → item {i}")) | ||
| .unwrap_or_default(), | ||
| ); | ||
| } | ||
| } | ||
| Err(e) => eprintln!("list failed: {e}"), | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| #[test] | ||
| fn append_then_read_back_roundtrips() { | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let log = dir.path().join("v.jsonl"); | ||
| crate::vent::capture::append(&log, None, "high", &[], "roundtrip check").unwrap(); | ||
| let (lines, _) = | ||
| crate::vent::consolidate::read_new_lines(&log, &dir.path().join("v.cursor")); | ||
| assert_eq!(lines.len(), 1); | ||
| assert_eq!(lines[0].message, "roundtrip check"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the raw-log location.
The implementation stores logs as per-repository files under
state_dir()/vents/(for example,<repo_slug>.jsonl), not a singlevents.jsonl. Update this user-facing path description.🤖 Prompt for AI Agents