feat: add ecc2 shared context graph cli

This commit is contained in:
Affaan Mustafa 2026-04-10 03:50:21 -07:00
parent 194bf605c2
commit 8653d6d5d5
4 changed files with 999 additions and 8 deletions

View File

@ -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<String>,
/// 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<String>,
/// Short human summary
#[arg(long, default_value = "")]
summary: String,
/// Metadata in key=value form
#[arg(long = "meta")]
metadata: Vec<String>,
/// 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<String>,
/// 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<String>,
/// Filter by entity type
#[arg(long = "type")]
entity_type: Option<String>,
/// 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<i64>,
/// 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<BTreeMap<String, String>> {
parse_key_value_pairs(values, "template vars")
}
fn parse_key_value_pairs(values: &[String], label: &str) -> Result<BTreeMap<String, String>> {
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"])

View File

@ -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<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphEntity {
pub id: i64,
pub session_id: Option<String>,
pub entity_type: String,
pub name: String,
pub path: Option<String>,
pub summary: String,
pub metadata: BTreeMap<String, String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphRelation {
pub id: i64,
pub session_id: Option<String>,
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<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphEntityDetail {
pub entity: ContextGraphEntity,
pub outgoing: Vec<ContextGraphRelation>,
pub incoming: Vec<ContextGraphRelation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FileActivityAction {

View File

@ -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<String, String>,
) -> Result<ContextGraphEntity> {
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<Vec<ContextGraphEntity>> {
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::<Result<Vec<_>, _>>()?;
Ok(entries)
}
pub fn get_context_entity_detail(
&self,
entity_id: i64,
relation_limit: usize,
) -> Result<Option<ContextGraphEntityDetail>> {
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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
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<ContextGraphRelation> {
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<i64>,
limit: usize,
) -> Result<Vec<ContextGraphRelation>> {
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::<Result<Vec<_>, _>>()?;
Ok(relations)
}
pub fn daemon_activity(&self) -> Result<DaemonActivity> {
self.conn
.query_row(
@ -2509,6 +2775,71 @@ fn map_decision_log_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<DecisionL
})
}
fn map_context_graph_entity(row: &rusqlite::Row<'_>) -> rusqlite::Result<ContextGraphEntity> {
let metadata_json = row
.get::<_, Option<String>>(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<ContextGraphRelation> {
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<chrono::Utc>> {
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::<Vec<_>>();
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")?;

View File

@ -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);