diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index d7b88de7..04b6a016 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -315,6 +315,11 @@ enum Commands { #[arg(long, default_value_t = 20)] limit: usize, }, + /// Read and write the shared context graph + Graph { + #[command(subcommand)] + command: GraphCommands, + }, /// Export sessions, tool spans, and metrics in OTLP-compatible JSON ExportOtel { /// Session ID or alias. Omit to export all sessions. @@ -378,6 +383,93 @@ enum MessageCommands { }, } +#[derive(clap::Subcommand, Debug)] +enum GraphCommands { + /// Create or update a graph entity + AddEntity { + /// Optional source session ID or alias for provenance + #[arg(long)] + session_id: Option, + /// Entity type such as file, function, type, or decision + #[arg(long = "type")] + entity_type: String, + /// Stable entity name + #[arg(long)] + name: String, + /// Optional path associated with the entity + #[arg(long)] + path: Option, + /// Short human summary + #[arg(long, default_value = "")] + summary: String, + /// Metadata in key=value form + #[arg(long = "meta")] + metadata: Vec, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Create or update a relation between two entities + Link { + /// Optional source session ID or alias for provenance + #[arg(long)] + session_id: Option, + /// Source entity ID + #[arg(long)] + from: i64, + /// Target entity ID + #[arg(long)] + to: i64, + /// Relation type such as references, defines, or depends_on + #[arg(long)] + relation: String, + /// Short human summary + #[arg(long, default_value = "")] + summary: String, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// List entities in the shared context graph + Entities { + /// Filter by source session ID or alias + #[arg(long)] + session_id: Option, + /// Filter by entity type + #[arg(long = "type")] + entity_type: Option, + /// Maximum entities to return + #[arg(long, default_value_t = 20)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// List relations in the shared context graph + Relations { + /// Filter to relations touching a specific entity ID + #[arg(long)] + entity_id: Option, + /// Maximum relations to return + #[arg(long, default_value_t = 20)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Show one entity plus its incoming and outgoing relations + Show { + /// Entity ID + entity_id: i64, + /// Maximum incoming/outgoing relations to return + #[arg(long, default_value_t = 10)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, +} + #[derive(clap::ValueEnum, Clone, Debug)] enum MessageKindArg { Handoff, @@ -1033,6 +1125,113 @@ async fn main() -> Result<()> { println!("{}", format_decisions_human(&entries, all)); } } + Some(Commands::Graph { command }) => match command { + GraphCommands::AddEntity { + session_id, + entity_type, + name, + path, + summary, + metadata, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let metadata = parse_key_value_pairs(&metadata, "graph metadata")?; + let entity = db.upsert_context_entity( + resolved_session_id.as_deref(), + &entity_type, + &name, + path.as_deref(), + &summary, + &metadata, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&entity)?); + } else { + println!("{}", format_graph_entity_human(&entity)); + } + } + GraphCommands::Link { + session_id, + from, + to, + relation, + summary, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let relation = db.upsert_context_relation( + resolved_session_id.as_deref(), + from, + to, + &relation, + &summary, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&relation)?); + } else { + println!("{}", format_graph_relation_human(&relation)); + } + } + GraphCommands::Entities { + session_id, + entity_type, + limit, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let entities = db.list_context_entities( + resolved_session_id.as_deref(), + entity_type.as_deref(), + limit, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&entities)?); + } else { + println!( + "{}", + format_graph_entities_human(&entities, resolved_session_id.is_some()) + ); + } + } + GraphCommands::Relations { + entity_id, + limit, + json, + } => { + let relations = db.list_context_relations(entity_id, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&relations)?); + } else { + println!("{}", format_graph_relations_human(&relations)); + } + } + GraphCommands::Show { + entity_id, + limit, + json, + } => { + let detail = db + .get_context_entity_detail(entity_id, limit)? + .ok_or_else(|| { + anyhow::anyhow!("Context graph entity not found: {entity_id}") + })?; + if json { + println!("{}", serde_json::to_string_pretty(&detail)?); + } else { + println!("{}", format_graph_entity_detail_human(&detail)); + } + } + }, Some(Commands::ExportOtel { session_id, output }) => { sync_runtime_session_metrics(&db, &cfg)?; let resolved_session_id = session_id @@ -1859,6 +2058,158 @@ fn format_decisions_human(entries: &[session::DecisionLogEntry], include_session lines.join("\n") } +fn format_graph_entity_human(entity: &session::ContextGraphEntity) -> String { + let mut lines = vec![ + format!("Context graph entity #{}", entity.id), + format!("Type: {}", entity.entity_type), + format!("Name: {}", entity.name), + ]; + if let Some(path) = &entity.path { + lines.push(format!("Path: {path}")); + } + if let Some(session_id) = &entity.session_id { + lines.push(format!("Session: {}", short_session(session_id))); + } + if entity.summary.is_empty() { + lines.push("Summary: none recorded".to_string()); + } else { + lines.push(format!("Summary: {}", entity.summary)); + } + if entity.metadata.is_empty() { + lines.push("Metadata: none recorded".to_string()); + } else { + lines.push("Metadata:".to_string()); + for (key, value) in &entity.metadata { + lines.push(format!("- {key}={value}")); + } + } + lines.push(format!( + "Updated: {}", + entity.updated_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + lines.join("\n") +} + +fn format_graph_entities_human( + entities: &[session::ContextGraphEntity], + include_session: bool, +) -> String { + if entities.is_empty() { + return "No context graph entities found.".to_string(); + } + + let mut lines = vec![format!("Context graph entities: {}", entities.len())]; + for entity in entities { + let mut line = format!("- #{} [{}] {}", entity.id, entity.entity_type, entity.name); + if include_session { + line.push_str(&format!( + " | {}", + entity + .session_id + .as_deref() + .map(short_session) + .unwrap_or_else(|| "global".to_string()) + )); + } + if let Some(path) = &entity.path { + line.push_str(&format!(" | {path}")); + } + lines.push(line); + if !entity.summary.is_empty() { + lines.push(format!(" summary {}", entity.summary)); + } + } + + lines.join("\n") +} + +fn format_graph_relation_human(relation: &session::ContextGraphRelation) -> String { + let mut lines = vec![ + format!("Context graph relation #{}", relation.id), + format!( + "Edge: #{} [{}] {} -> #{} [{}] {}", + relation.from_entity_id, + relation.from_entity_type, + relation.from_entity_name, + relation.to_entity_id, + relation.to_entity_type, + relation.to_entity_name + ), + format!("Relation: {}", relation.relation_type), + ]; + if let Some(session_id) = &relation.session_id { + lines.push(format!("Session: {}", short_session(session_id))); + } + if relation.summary.is_empty() { + lines.push("Summary: none recorded".to_string()); + } else { + lines.push(format!("Summary: {}", relation.summary)); + } + lines.push(format!( + "Created: {}", + relation.created_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + lines.join("\n") +} + +fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) -> String { + if relations.is_empty() { + return "No context graph relations found.".to_string(); + } + + let mut lines = vec![format!("Context graph relations: {}", relations.len())]; + for relation in relations { + lines.push(format!( + "- #{} {} -> {} [{}]", + relation.id, relation.from_entity_name, relation.to_entity_name, relation.relation_type + )); + if !relation.summary.is_empty() { + lines.push(format!(" summary {}", relation.summary)); + } + } + lines.join("\n") +} + +fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String { + let mut lines = vec![format_graph_entity_human(&detail.entity)]; + lines.push(String::new()); + lines.push(format!("Outgoing relations: {}", detail.outgoing.len())); + if detail.outgoing.is_empty() { + lines.push("- none".to_string()); + } else { + for relation in &detail.outgoing { + lines.push(format!( + "- [{}] {} -> #{} {}", + relation.relation_type, + detail.entity.name, + relation.to_entity_id, + relation.to_entity_name + )); + if !relation.summary.is_empty() { + lines.push(format!(" summary {}", relation.summary)); + } + } + } + lines.push(format!("Incoming relations: {}", detail.incoming.len())); + if detail.incoming.is_empty() { + lines.push("- none".to_string()); + } else { + for relation in &detail.incoming { + lines.push(format!( + "- [{}] #{} {} -> {}", + relation.relation_type, + relation.from_entity_id, + relation.from_entity_name, + detail.entity.name + )); + if !relation.summary.is_empty() { + lines.push(format!(" summary {}", relation.summary)); + } + } + } + lines.join("\n") +} + fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> String { let mut lines = Vec::new(); lines.push(format!( @@ -2228,15 +2579,19 @@ fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: & } fn parse_template_vars(values: &[String]) -> Result> { + parse_key_value_pairs(values, "template vars") +} + +fn parse_key_value_pairs(values: &[String], label: &str) -> Result> { let mut vars = BTreeMap::new(); for value in values { let (key, raw_value) = value .split_once('=') - .ok_or_else(|| anyhow::anyhow!("template vars must use key=value form: {value}"))?; + .ok_or_else(|| anyhow::anyhow!("{label} must use key=value form: {value}"))?; let key = key.trim(); let raw_value = raw_value.trim(); if key.is_empty() || raw_value.is_empty() { - anyhow::bail!("template vars must use non-empty key=value form: {value}"); + anyhow::bail!("{label} must use non-empty key=value form: {value}"); } vars.insert(key.to_string(), raw_value.to_string()); } @@ -2557,6 +2912,19 @@ mod tests { ); } + #[test] + fn parse_key_value_pairs_rejects_empty_values() { + let error = parse_key_value_pairs(&["language=".to_string()], "graph metadata") + .expect_err("invalid metadata should fail"); + + assert!( + error + .to_string() + .contains("graph metadata must use non-empty key=value form"), + "unexpected error: {error}" + ); + } + #[test] fn cli_parses_team_command() { let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"]) @@ -3614,6 +3982,53 @@ mod tests { } } + #[test] + fn cli_parses_graph_add_entity_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "add-entity", + "--session-id", + "latest", + "--type", + "file", + "--name", + "dashboard.rs", + "--path", + "ecc2/src/tui/dashboard.rs", + "--summary", + "Primary TUI surface", + "--meta", + "language=rust", + "--json", + ]) + .expect("graph add-entity should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::AddEntity { + session_id, + entity_type, + name, + path, + summary, + metadata, + json, + }, + }) => { + assert_eq!(session_id.as_deref(), Some("latest")); + assert_eq!(entity_type, "file"); + assert_eq!(name, "dashboard.rs"); + assert_eq!(path.as_deref(), Some("ecc2/src/tui/dashboard.rs")); + assert_eq!(summary, "Primary TUI surface"); + assert_eq!(metadata, vec!["language=rust"]); + assert!(json); + } + _ => panic!("expected graph add-entity subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -3638,6 +4053,64 @@ mod tests { assert!(text.contains("alternative memory only")); } + #[test] + fn format_graph_entity_detail_human_renders_relations() { + let detail = session::ContextGraphEntityDetail { + entity: session::ContextGraphEntity { + id: 7, + session_id: Some("sess-12345678".to_string()), + entity_type: "function".to_string(), + name: "render_metrics".to_string(), + path: Some("ecc2/src/tui/dashboard.rs".to_string()), + summary: "Renders the metrics pane".to_string(), + metadata: BTreeMap::from([("language".to_string(), "rust".to_string())]), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }, + outgoing: vec![session::ContextGraphRelation { + id: 9, + session_id: Some("sess-12345678".to_string()), + from_entity_id: 7, + from_entity_type: "function".to_string(), + from_entity_name: "render_metrics".to_string(), + to_entity_id: 10, + to_entity_type: "type".to_string(), + to_entity_name: "MetricsSnapshot".to_string(), + relation_type: "returns".to_string(), + summary: "Produces the rendered metrics model".to_string(), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }], + incoming: vec![session::ContextGraphRelation { + id: 8, + session_id: Some("sess-12345678".to_string()), + from_entity_id: 6, + from_entity_type: "file".to_string(), + from_entity_name: "dashboard.rs".to_string(), + to_entity_id: 7, + to_entity_type: "function".to_string(), + to_entity_name: "render_metrics".to_string(), + relation_type: "contains".to_string(), + summary: "Dashboard owns the render path".to_string(), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }], + }; + + let text = format_graph_entity_detail_human(&detail); + assert!(text.contains("Context graph entity #7")); + assert!(text.contains("Outgoing relations: 1")); + assert!(text.contains("[returns] render_metrics -> #10 MetricsSnapshot")); + assert!(text.contains("Incoming relations: 1")); + assert!(text.contains("[contains] #6 dashboard.rs -> render_metrics")); + } + #[test] fn cli_parses_coordination_status_json_flag() { let cli = Cli::try_parse_from(["ecc", "coordination-status", "--json"]) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 301f3384..30ddc6da 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -6,6 +6,7 @@ pub mod store; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::fmt; use std::path::Path; use std::path::PathBuf; @@ -154,6 +155,41 @@ pub struct DecisionLogEntry { pub timestamp: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphEntity { + pub id: i64, + pub session_id: Option, + pub entity_type: String, + pub name: String, + pub path: Option, + pub summary: String, + pub metadata: BTreeMap, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphRelation { + pub id: i64, + pub session_id: Option, + pub from_entity_id: i64, + pub from_entity_type: String, + pub from_entity_name: String, + pub to_entity_id: i64, + pub to_entity_type: String, + pub to_entity_name: String, + pub relation_type: String, + pub summary: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphEntityDetail { + pub entity: ContextGraphEntity, + pub outgoing: Vec, + pub incoming: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FileActivityAction { diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 8d028e76..551ed77d 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; use serde::Serialize; use std::cmp::Reverse; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -13,9 +13,10 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ - default_project_label, default_task_group_label, normalize_group_label, DecisionLogEntry, - FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, SessionMessage, - SessionMetrics, SessionState, WorktreeInfo, + default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity, + ContextGraphEntityDetail, ContextGraphRelation, DecisionLogEntry, FileActivityAction, + FileActivityEntry, Session, SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, + WorktreeInfo, }; pub struct StateStore { @@ -234,6 +235,30 @@ impl StateStore { timestamp TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS context_graph_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + entity_key TEXT NOT NULL UNIQUE, + entity_type TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT, + summary TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS context_graph_relations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + from_entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, + to_entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, + relation_type TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(from_entity_id, to_entity_id, relation_type) + ); + CREATE TABLE IF NOT EXISTS pending_worktree_queue ( session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, repo_root TEXT NOT NULL, @@ -288,6 +313,12 @@ impl StateStore { ON session_output(session_id, id); CREATE INDEX IF NOT EXISTS idx_decision_log_session ON decision_log(session_id, timestamp, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_entities_session + ON context_graph_entities(session_id, entity_type, updated_at, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_relations_from + ON context_graph_relations(from_entity_id, created_at, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_relations_to + ON context_graph_relations(to_entity_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at); CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at @@ -1652,6 +1683,241 @@ impl StateStore { Ok(entries) } + pub fn upsert_context_entity( + &self, + session_id: Option<&str>, + entity_type: &str, + name: &str, + path: Option<&str>, + summary: &str, + metadata: &BTreeMap, + ) -> Result { + let entity_type = entity_type.trim(); + if entity_type.is_empty() { + return Err(anyhow::anyhow!("Context graph entity type cannot be empty")); + } + let name = name.trim(); + if name.is_empty() { + return Err(anyhow::anyhow!("Context graph entity name cannot be empty")); + } + + let normalized_path = path.map(str::trim).filter(|value| !value.is_empty()); + let summary = summary.trim(); + let entity_key = context_graph_entity_key(entity_type, name, normalized_path); + let metadata_json = serde_json::to_string(metadata) + .context("Failed to serialize context graph metadata")?; + let timestamp = chrono::Utc::now().to_rfc3339(); + + self.conn.execute( + "INSERT INTO context_graph_entities ( + session_id, entity_key, entity_type, name, path, summary, metadata_json, created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) + ON CONFLICT(entity_key) DO UPDATE SET + session_id = COALESCE(excluded.session_id, context_graph_entities.session_id), + summary = CASE + WHEN excluded.summary <> '' THEN excluded.summary + ELSE context_graph_entities.summary + END, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at", + rusqlite::params![ + session_id, + entity_key, + entity_type, + name, + normalized_path, + summary, + metadata_json, + timestamp, + ], + )?; + + self.conn + .query_row( + "SELECT id, session_id, entity_type, name, path, summary, metadata_json, created_at, updated_at + FROM context_graph_entities + WHERE entity_key = ?1", + rusqlite::params![entity_key], + map_context_graph_entity, + ) + .map_err(Into::into) + } + + pub fn list_context_entities( + &self, + session_id: Option<&str>, + entity_type: Option<&str>, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, session_id, entity_type, name, path, summary, metadata_json, created_at, updated_at + FROM context_graph_entities + WHERE (?1 IS NULL OR session_id = ?1) + AND (?2 IS NULL OR entity_type = ?2) + ORDER BY updated_at DESC, id DESC + LIMIT ?3", + )?; + + let entries = stmt + .query_map( + rusqlite::params![session_id, entity_type, limit as i64], + map_context_graph_entity, + )? + .collect::, _>>()?; + + Ok(entries) + } + + pub fn get_context_entity_detail( + &self, + entity_id: i64, + relation_limit: usize, + ) -> Result> { + let entity = self + .conn + .query_row( + "SELECT id, session_id, entity_type, name, path, summary, metadata_json, created_at, updated_at + FROM context_graph_entities + WHERE id = ?1", + rusqlite::params![entity_id], + map_context_graph_entity, + ) + .optional()?; + + let Some(entity) = entity else { + return Ok(None); + }; + + let mut outgoing_stmt = self.conn.prepare( + "SELECT r.id, r.session_id, + r.from_entity_id, src.entity_type, src.name, + r.to_entity_id, dst.entity_type, dst.name, + r.relation_type, r.summary, r.created_at + FROM context_graph_relations r + JOIN context_graph_entities src ON src.id = r.from_entity_id + JOIN context_graph_entities dst ON dst.id = r.to_entity_id + WHERE r.from_entity_id = ?1 + ORDER BY r.created_at DESC, r.id DESC + LIMIT ?2", + )?; + let outgoing = outgoing_stmt + .query_map( + rusqlite::params![entity_id, relation_limit as i64], + map_context_graph_relation, + )? + .collect::, _>>()?; + + let mut incoming_stmt = self.conn.prepare( + "SELECT r.id, r.session_id, + r.from_entity_id, src.entity_type, src.name, + r.to_entity_id, dst.entity_type, dst.name, + r.relation_type, r.summary, r.created_at + FROM context_graph_relations r + JOIN context_graph_entities src ON src.id = r.from_entity_id + JOIN context_graph_entities dst ON dst.id = r.to_entity_id + WHERE r.to_entity_id = ?1 + ORDER BY r.created_at DESC, r.id DESC + LIMIT ?2", + )?; + let incoming = incoming_stmt + .query_map( + rusqlite::params![entity_id, relation_limit as i64], + map_context_graph_relation, + )? + .collect::, _>>()?; + + Ok(Some(ContextGraphEntityDetail { + entity, + outgoing, + incoming, + })) + } + + pub fn upsert_context_relation( + &self, + session_id: Option<&str>, + from_entity_id: i64, + to_entity_id: i64, + relation_type: &str, + summary: &str, + ) -> Result { + let relation_type = relation_type.trim(); + if relation_type.is_empty() { + return Err(anyhow::anyhow!( + "Context graph relation type cannot be empty" + )); + } + let summary = summary.trim(); + let timestamp = chrono::Utc::now().to_rfc3339(); + + self.conn.execute( + "INSERT INTO context_graph_relations ( + session_id, from_entity_id, to_entity_id, relation_type, summary, created_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(from_entity_id, to_entity_id, relation_type) DO UPDATE SET + session_id = COALESCE(excluded.session_id, context_graph_relations.session_id), + summary = CASE + WHEN excluded.summary <> '' THEN excluded.summary + ELSE context_graph_relations.summary + END", + rusqlite::params![ + session_id, + from_entity_id, + to_entity_id, + relation_type, + summary, + timestamp, + ], + )?; + + self.conn + .query_row( + "SELECT r.id, r.session_id, + r.from_entity_id, src.entity_type, src.name, + r.to_entity_id, dst.entity_type, dst.name, + r.relation_type, r.summary, r.created_at + FROM context_graph_relations r + JOIN context_graph_entities src ON src.id = r.from_entity_id + JOIN context_graph_entities dst ON dst.id = r.to_entity_id + WHERE r.from_entity_id = ?1 + AND r.to_entity_id = ?2 + AND r.relation_type = ?3", + rusqlite::params![from_entity_id, to_entity_id, relation_type], + map_context_graph_relation, + ) + .map_err(Into::into) + } + + pub fn list_context_relations( + &self, + entity_id: Option, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT r.id, r.session_id, + r.from_entity_id, src.entity_type, src.name, + r.to_entity_id, dst.entity_type, dst.name, + r.relation_type, r.summary, r.created_at + FROM context_graph_relations r + JOIN context_graph_entities src ON src.id = r.from_entity_id + JOIN context_graph_entities dst ON dst.id = r.to_entity_id + WHERE (?1 IS NULL OR r.from_entity_id = ?1 OR r.to_entity_id = ?1) + ORDER BY r.created_at DESC, r.id DESC + LIMIT ?2", + )?; + + let relations = stmt + .query_map( + rusqlite::params![entity_id, limit as i64], + map_context_graph_relation, + )? + .collect::, _>>()?; + + Ok(relations) + } + pub fn daemon_activity(&self) -> Result { self.conn .query_row( @@ -2509,6 +2775,71 @@ fn map_decision_log_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + let metadata_json = row + .get::<_, Option>(6)? + .unwrap_or_else(|| "{}".to_string()); + let metadata = serde_json::from_str(&metadata_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(error)) + })?; + let created_at = parse_store_timestamp(row.get::<_, String>(7)?, 7)?; + let updated_at = parse_store_timestamp(row.get::<_, String>(8)?, 8)?; + + Ok(ContextGraphEntity { + id: row.get(0)?, + session_id: row.get(1)?, + entity_type: row.get(2)?, + name: row.get(3)?, + path: row.get(4)?, + summary: row.get(5)?, + metadata, + created_at, + updated_at, + }) +} + +fn map_context_graph_relation(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let created_at = parse_store_timestamp(row.get::<_, String>(10)?, 10)?; + + Ok(ContextGraphRelation { + id: row.get(0)?, + session_id: row.get(1)?, + from_entity_id: row.get(2)?, + from_entity_type: row.get(3)?, + from_entity_name: row.get(4)?, + to_entity_id: row.get(5)?, + to_entity_type: row.get(6)?, + to_entity_name: row.get(7)?, + relation_type: row.get(8)?, + summary: row.get(9)?, + created_at, + }) +} + +fn parse_store_timestamp( + raw: String, + column: usize, +) -> rusqlite::Result> { + chrono::DateTime::parse_from_rfc3339(&raw) + .map(|value| value.with_timezone(&chrono::Utc)) + .map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + Box::new(error), + ) + }) +} + +fn context_graph_entity_key(entity_type: &str, name: &str, path: Option<&str>) -> String { + format!( + "{}::{}::{}", + entity_type.trim().to_ascii_lowercase(), + name.trim().to_ascii_lowercase(), + path.unwrap_or("").trim() + ) +} + fn file_overlap_is_relevant(current: &FileActivityEntry, other: &FileActivityEntry) -> bool { current.path == other.path && !(matches!(current.action, FileActivityAction::Read) @@ -3194,6 +3525,156 @@ mod tests { Ok(()) } + #[test] + fn upsert_and_filter_context_graph_entities() -> Result<()> { + let tempdir = TestDir::new("store-context-entities")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "context graph".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let mut metadata = BTreeMap::new(); + metadata.insert("language".to_string(), "rust".to_string()); + let file = db.upsert_context_entity( + Some("session-1"), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "Primary dashboard surface", + &metadata, + )?; + let updated = db.upsert_context_entity( + Some("session-1"), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "Updated dashboard summary", + &metadata, + )?; + let decision = db.upsert_context_entity( + None, + "decision", + "Prefer SQLite graph storage", + None, + "Keeps graph queryable from CLI and TUI", + &BTreeMap::new(), + )?; + + assert_eq!(file.id, updated.id); + assert_eq!(updated.summary, "Updated dashboard summary"); + + let session_entities = db.list_context_entities(Some("session-1"), Some("file"), 10)?; + assert_eq!(session_entities.len(), 1); + assert_eq!(session_entities[0].id, file.id); + assert_eq!( + session_entities[0].metadata.get("language"), + Some(&"rust".to_string()) + ); + + let all_entities = db.list_context_entities(None, None, 10)?; + assert_eq!(all_entities.len(), 2); + assert!(all_entities.iter().any(|entity| entity.id == decision.id)); + + Ok(()) + } + + #[test] + fn context_graph_detail_includes_incoming_and_outgoing_relations() -> Result<()> { + let tempdir = TestDir::new("store-context-relations")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "context graph".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let file = db.upsert_context_entity( + Some("session-1"), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "", + &BTreeMap::new(), + )?; + let function = db.upsert_context_entity( + Some("session-1"), + "function", + "render_metrics", + Some("ecc2/src/tui/dashboard.rs"), + "", + &BTreeMap::new(), + )?; + let decision = db.upsert_context_entity( + Some("session-1"), + "decision", + "Persist graph in sqlite", + None, + "", + &BTreeMap::new(), + )?; + + db.upsert_context_relation( + Some("session-1"), + file.id, + function.id, + "contains", + "Dashboard file contains metrics rendering logic", + )?; + db.upsert_context_relation( + Some("session-1"), + decision.id, + function.id, + "drives", + "Storage choice drives the function implementation", + )?; + + let detail = db + .get_context_entity_detail(function.id, 10)? + .expect("detail should exist"); + assert_eq!(detail.entity.name, "render_metrics"); + assert_eq!(detail.incoming.len(), 2); + assert!(detail.outgoing.is_empty()); + + let relation_types = detail + .incoming + .iter() + .map(|relation| relation.relation_type.as_str()) + .collect::>(); + assert!(relation_types.contains(&"contains")); + assert!(relation_types.contains(&"drives")); + + let filtered_relations = db.list_context_relations(Some(function.id), 10)?; + assert_eq!(filtered_relations.len(), 2); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index e137653c..3bf6ce12 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -11384,8 +11384,9 @@ diff --git a/src/lib.rs b/src/lib.rs .clone() .expect("template launch should set an operator note"); assert!( - operator_note - .contains("launched template feature_development (2/2 step(s)) for stabilize auth callback"), + operator_note.contains( + "launched template feature_development (2/2 step(s)) for stabilize auth callback" + ), "unexpected operator note: {operator_note}" ); assert_eq!(dashboard.sessions.len(), 2);