mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-12 07:37:24 +08:00
feat: integrate ecc2 board observability prototype
This commit is contained in:
@@ -411,6 +411,27 @@ pub struct SessionMetrics {
|
||||
pub cost_usd: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionBoardMeta {
|
||||
pub lane: String,
|
||||
pub project: Option<String>,
|
||||
pub feature: Option<String>,
|
||||
pub issue: Option<String>,
|
||||
pub row_label: Option<String>,
|
||||
pub previous_lane: Option<String>,
|
||||
pub previous_row_label: Option<String>,
|
||||
pub column_index: i64,
|
||||
pub row_index: i64,
|
||||
pub stack_index: i64,
|
||||
pub progress_percent: i64,
|
||||
pub status_detail: Option<String>,
|
||||
pub movement_note: Option<String>,
|
||||
pub activity_kind: Option<String>,
|
||||
pub activity_note: Option<String>,
|
||||
pub handoff_backlog: i64,
|
||||
pub conflict_signal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionMessage {
|
||||
pub id: i64,
|
||||
|
||||
@@ -19,8 +19,8 @@ use super::{
|
||||
ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats,
|
||||
ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry,
|
||||
HarnessKind, RemoteDispatchKind, RemoteDispatchRequest, RemoteDispatchStatus, ScheduledTask,
|
||||
Session, SessionAgentProfile, SessionHarnessInfo, SessionMessage, SessionMetrics, SessionState,
|
||||
WorktreeInfo,
|
||||
Session, SessionAgentProfile, SessionBoardMeta, SessionHarnessInfo, SessionMessage,
|
||||
SessionMetrics, SessionState, WorktreeInfo,
|
||||
};
|
||||
|
||||
pub struct StateStore {
|
||||
@@ -241,6 +241,28 @@ impl StateStore {
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_board (
|
||||
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
lane TEXT NOT NULL,
|
||||
project TEXT,
|
||||
feature TEXT,
|
||||
issue TEXT,
|
||||
row_label TEXT,
|
||||
previous_lane TEXT,
|
||||
previous_row_label TEXT,
|
||||
column_index INTEGER NOT NULL DEFAULT 0,
|
||||
row_index INTEGER NOT NULL DEFAULT 0,
|
||||
stack_index INTEGER NOT NULL DEFAULT 0,
|
||||
progress_percent INTEGER NOT NULL DEFAULT 0,
|
||||
status_detail TEXT,
|
||||
movement_note TEXT,
|
||||
activity_kind TEXT,
|
||||
activity_note TEXT,
|
||||
handoff_backlog INTEGER NOT NULL DEFAULT 0,
|
||||
conflict_signal TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
@@ -386,6 +408,9 @@ impl StateStore {
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session, read);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_output_session
|
||||
ON session_output(session_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_board_lane ON session_board(lane);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_board_coords
|
||||
ON session_board(column_index, row_index, stack_index);
|
||||
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
|
||||
@@ -409,6 +434,8 @@ impl StateStore {
|
||||
",
|
||||
)?;
|
||||
self.ensure_session_columns()?;
|
||||
self.ensure_session_board_columns()?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -482,6 +509,51 @@ impl StateStore {
|
||||
.context("Failed to add output_tokens column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "tokens_used")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN tokens_used INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add tokens_used column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "tool_calls")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN tool_calls INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add tool_calls column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "files_changed")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN files_changed INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add files_changed column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "duration_secs")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN duration_secs INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add duration_secs column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "cost_usd")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN cost_usd REAL NOT NULL DEFAULT 0.0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add cost_usd column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "last_heartbeat_at")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE sessions ADD COLUMN last_heartbeat_at TEXT", [])
|
||||
@@ -496,6 +568,24 @@ impl StateStore {
|
||||
.context("Failed to backfill last_heartbeat_at column")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "worktree_path")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE sessions ADD COLUMN worktree_path TEXT", [])
|
||||
.context("Failed to add worktree_path column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "worktree_branch")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT", [])
|
||||
.context("Failed to add worktree_branch column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "worktree_base")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE sessions ADD COLUMN worktree_base TEXT", [])
|
||||
.context("Failed to add worktree_base column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("tool_log", "hook_event_id")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE tool_log ADD COLUMN hook_event_id TEXT", [])
|
||||
@@ -712,6 +802,103 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_session_board_columns(&self) -> Result<()> {
|
||||
if !self.has_column("session_board", "row_label")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN row_label TEXT", [])
|
||||
.context("Failed to add row_label column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "previous_lane")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN previous_lane TEXT", [])
|
||||
.context("Failed to add previous_lane column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "previous_row_label")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN previous_row_label TEXT", [])
|
||||
.context("Failed to add previous_row_label column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "column_index")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN column_index INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add column_index column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "row_index")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN row_index INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add row_index column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "stack_index")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN stack_index INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add stack_index column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "progress_percent")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN progress_percent INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add progress_percent column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "status_detail")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN status_detail TEXT", [])
|
||||
.context("Failed to add status_detail column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "movement_note")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN movement_note TEXT", [])
|
||||
.context("Failed to add movement_note column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "activity_kind")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN activity_kind TEXT", [])
|
||||
.context("Failed to add activity_kind column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "activity_note")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN activity_note TEXT", [])
|
||||
.context("Failed to add activity_note column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "handoff_backlog")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN handoff_backlog INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add handoff_backlog column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "conflict_signal")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN conflict_signal TEXT", [])
|
||||
.context("Failed to add conflict_signal column to session_board table")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_column(&self, table: &str, column: &str) -> Result<bool> {
|
||||
let pragma = format!("PRAGMA table_info({table})");
|
||||
let mut stmt = self.conn.prepare(&pragma)?;
|
||||
@@ -789,6 +976,7 @@ impl StateStore {
|
||||
session.last_heartbeat_at.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -909,6 +1097,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -949,6 +1138,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -970,6 +1160,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1003,6 +1194,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1030,6 +1222,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1386,6 +1579,7 @@ impl StateStore {
|
||||
session_id,
|
||||
],
|
||||
)?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1437,6 +1631,7 @@ impl StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1522,6 +1717,7 @@ impl StateStore {
|
||||
)?;
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1876,6 +2072,7 @@ impl StateStore {
|
||||
WHERE id = ?2",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id],
|
||||
)?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1979,6 +2176,46 @@ impl StateStore {
|
||||
Ok(harnesses)
|
||||
}
|
||||
|
||||
pub fn list_session_board_meta(&self) -> Result<HashMap<String, SessionBoardMeta>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT session_id, lane, project, feature, issue, row_label,
|
||||
previous_lane, previous_row_label,
|
||||
column_index, row_index, stack_index, progress_percent,
|
||||
status_detail, movement_note, activity_kind, activity_note,
|
||||
handoff_backlog, conflict_signal
|
||||
FROM session_board",
|
||||
)?;
|
||||
|
||||
let meta = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
SessionBoardMeta {
|
||||
lane: row.get(1)?,
|
||||
project: row.get(2)?,
|
||||
feature: row.get(3)?,
|
||||
issue: row.get(4)?,
|
||||
row_label: row.get(5)?,
|
||||
previous_lane: row.get(6)?,
|
||||
previous_row_label: row.get(7)?,
|
||||
column_index: row.get(8)?,
|
||||
row_index: row.get(9)?,
|
||||
stack_index: row.get(10)?,
|
||||
progress_percent: row.get(11)?,
|
||||
status_detail: row.get(12)?,
|
||||
movement_note: row.get(13)?,
|
||||
activity_kind: row.get(14)?,
|
||||
activity_note: row.get(15)?,
|
||||
handoff_backlog: row.get(16)?,
|
||||
conflict_signal: row.get(17)?,
|
||||
},
|
||||
))
|
||||
})?
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
pub fn get_session_harness_info(&self, session_id: &str) -> Result<Option<SessionHarnessInfo>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT harness, detected_harnesses_json, agent_type, working_dir
|
||||
@@ -2008,6 +2245,94 @@ impl StateStore {
|
||||
Ok(self.list_sessions()?.into_iter().next())
|
||||
}
|
||||
|
||||
fn refresh_session_board_meta(&self) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM session_board
|
||||
WHERE session_id NOT IN (SELECT id FROM sessions)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let existing_meta = self.list_session_board_meta().unwrap_or_default();
|
||||
let sessions = self.list_sessions()?;
|
||||
let board_meta = derive_board_meta_map(&sessions);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
for session in sessions {
|
||||
let mut meta = board_meta
|
||||
.get(&session.id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| SessionBoardMeta {
|
||||
lane: board_lane_for_state(&session.state).to_string(),
|
||||
..SessionBoardMeta::default()
|
||||
});
|
||||
if let Some(previous) = existing_meta.get(&session.id) {
|
||||
annotate_board_motion(&mut meta, previous);
|
||||
}
|
||||
if let Some((activity_kind, activity_note)) =
|
||||
self.latest_task_handoff_activity(&session.id)?
|
||||
{
|
||||
meta.activity_kind = Some(activity_kind);
|
||||
meta.activity_note = Some(activity_note);
|
||||
} else {
|
||||
meta.activity_kind = None;
|
||||
meta.activity_note = None;
|
||||
}
|
||||
meta.handoff_backlog = self.unread_task_handoff_count(&session.id)? as i64;
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO session_board (
|
||||
session_id, lane, project, feature, issue, row_label,
|
||||
previous_lane, previous_row_label,
|
||||
column_index, row_index, stack_index, progress_percent,
|
||||
status_detail, movement_note, activity_kind, activity_note,
|
||||
handoff_backlog, conflict_signal, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
lane = excluded.lane,
|
||||
project = excluded.project,
|
||||
feature = excluded.feature,
|
||||
issue = excluded.issue,
|
||||
row_label = excluded.row_label,
|
||||
previous_lane = excluded.previous_lane,
|
||||
previous_row_label = excluded.previous_row_label,
|
||||
column_index = excluded.column_index,
|
||||
row_index = excluded.row_index,
|
||||
stack_index = excluded.stack_index,
|
||||
progress_percent = excluded.progress_percent,
|
||||
status_detail = excluded.status_detail,
|
||||
movement_note = excluded.movement_note,
|
||||
activity_kind = excluded.activity_kind,
|
||||
activity_note = excluded.activity_note,
|
||||
handoff_backlog = excluded.handoff_backlog,
|
||||
conflict_signal = excluded.conflict_signal,
|
||||
updated_at = excluded.updated_at",
|
||||
rusqlite::params![
|
||||
session.id,
|
||||
meta.lane,
|
||||
meta.project,
|
||||
meta.feature,
|
||||
meta.issue,
|
||||
meta.row_label,
|
||||
meta.previous_lane,
|
||||
meta.previous_row_label,
|
||||
meta.column_index,
|
||||
meta.row_index,
|
||||
meta.stack_index,
|
||||
meta.progress_percent,
|
||||
meta.status_detail,
|
||||
meta.movement_note,
|
||||
meta.activity_kind,
|
||||
meta.activity_note,
|
||||
meta.handoff_backlog,
|
||||
meta.conflict_signal,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
|
||||
let sessions = self.list_sessions()?;
|
||||
Ok(sessions
|
||||
@@ -2038,6 +2363,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2048,6 +2374,7 @@ impl StateStore {
|
||||
rusqlite::params![from, to, content, msg_type, chrono::Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
self.sync_context_graph_message(from, to, content, msg_type)?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2318,6 +2645,7 @@ impl StateStore {
|
||||
rusqlite::params![session_id],
|
||||
)?;
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -2327,6 +2655,7 @@ impl StateStore {
|
||||
rusqlite::params![message_id],
|
||||
)?;
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -2345,6 +2674,75 @@ impl StateStore {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn latest_task_handoff_activity(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let latest_handoff = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT from_session, to_session, content
|
||||
FROM messages
|
||||
WHERE msg_type = 'task_handoff'
|
||||
AND (from_session = ?1 OR to_session = ?1)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
rusqlite::params![session_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
Ok(latest_handoff.and_then(|(from_session, to_session, content)| {
|
||||
let context = extract_task_handoff_context(&content)?;
|
||||
let routing_suffix = routing_activity_suffix(&context);
|
||||
|
||||
if session_id == to_session {
|
||||
Some((
|
||||
"received".to_string(),
|
||||
format!(
|
||||
"Received from {}{}",
|
||||
short_session_ref(&from_session),
|
||||
routing_suffix
|
||||
.map(|value| format!(" | {value}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
))
|
||||
} else if session_id == from_session {
|
||||
let (kind, base) = match routing_suffix {
|
||||
Some("spawned") => {
|
||||
("spawned", format!("Spawned {}", short_session_ref(&to_session)))
|
||||
}
|
||||
Some("spawned fallback") => (
|
||||
"spawned_fallback",
|
||||
format!("Spawned fallback {}", short_session_ref(&to_session)),
|
||||
),
|
||||
_ => (
|
||||
"delegated",
|
||||
format!("Delegated to {}", short_session_ref(&to_session)),
|
||||
),
|
||||
};
|
||||
Some((
|
||||
kind.to_string(),
|
||||
format!(
|
||||
"{base}{}",
|
||||
routing_suffix
|
||||
.filter(|value| !value.starts_with("spawned"))
|
||||
.map(|value| format!(" | {value}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn insert_decision(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -3862,6 +4260,411 @@ fn file_activity_action_value(action: &FileActivityAction) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn board_lane_for_state(state: &SessionState) -> &'static str {
|
||||
match state {
|
||||
SessionState::Pending => "Inbox",
|
||||
SessionState::Running => "In Progress",
|
||||
SessionState::Idle => "Review",
|
||||
SessionState::Stale | SessionState::Failed => "Blocked",
|
||||
SessionState::Completed => "Done",
|
||||
SessionState::Stopped => "Stopped",
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_board_scope(session: &Session) -> (Option<String>, Option<String>, Option<String>) {
|
||||
let project = extract_labeled_scope(&session.task, &["project", "roadmap", "epic"]);
|
||||
let feature = extract_labeled_scope(&session.task, &["feature", "workflow", "flow"]);
|
||||
let issue = extract_issue_reference(&session.task);
|
||||
(project, feature, issue)
|
||||
}
|
||||
|
||||
fn derive_board_meta_map(sessions: &[Session]) -> HashMap<String, SessionBoardMeta> {
|
||||
let conflict_signals = derive_board_conflict_signals(sessions);
|
||||
let scopes = sessions
|
||||
.iter()
|
||||
.map(|session| (session.id.clone(), derive_board_scope(session)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut row_specs = scopes
|
||||
.iter()
|
||||
.map(|(session_id, (project, feature, issue))| {
|
||||
let row_label = issue
|
||||
.clone()
|
||||
.or_else(|| feature.clone())
|
||||
.or_else(|| project.clone())
|
||||
.or_else(|| {
|
||||
sessions
|
||||
.iter()
|
||||
.find(|session| &session.id == session_id)
|
||||
.and_then(|session| session.worktree.as_ref())
|
||||
.map(|worktree| worktree.branch.clone())
|
||||
})
|
||||
.unwrap_or_else(|| "General".to_string());
|
||||
|
||||
let row_rank = if issue.is_some() {
|
||||
0
|
||||
} else if feature.is_some() {
|
||||
1
|
||||
} else if project.is_some() {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
|
||||
(session_id.clone(), row_label, row_rank)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
row_specs.sort_by(|left, right| {
|
||||
left.2
|
||||
.cmp(&right.2)
|
||||
.then_with(|| left.1.to_ascii_lowercase().cmp(&right.1.to_ascii_lowercase()))
|
||||
.then_with(|| left.0.cmp(&right.0))
|
||||
});
|
||||
|
||||
let mut row_indices = HashMap::new();
|
||||
let mut next_row_index = 0_i64;
|
||||
for (_, row_label, row_rank) in &row_specs {
|
||||
let key = (*row_rank, row_label.clone());
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = row_indices.entry(key) {
|
||||
entry.insert(next_row_index);
|
||||
next_row_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stack_counts: HashMap<(i64, i64), i64> = HashMap::new();
|
||||
let mut board_meta = HashMap::new();
|
||||
|
||||
for session in sessions {
|
||||
let (project, feature, issue) = scopes
|
||||
.get(&session.id)
|
||||
.cloned()
|
||||
.unwrap_or((None, None, None));
|
||||
let (_, row_label, row_rank) = row_specs
|
||||
.iter()
|
||||
.find(|(session_id, _, _)| session_id == &session.id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (session.id.clone(), "General".to_string(), 4));
|
||||
let column_index = board_column_index(&session.state);
|
||||
let row_index = row_indices
|
||||
.get(&(row_rank, row_label.clone()))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let stack_index = {
|
||||
let entry = stack_counts.entry((column_index, row_index)).or_insert(0);
|
||||
let current = *entry;
|
||||
*entry += 1;
|
||||
current
|
||||
};
|
||||
|
||||
board_meta.insert(
|
||||
session.id.clone(),
|
||||
SessionBoardMeta {
|
||||
lane: board_lane_for_state(&session.state).to_string(),
|
||||
project,
|
||||
feature,
|
||||
issue,
|
||||
row_label: Some(row_label),
|
||||
previous_lane: None,
|
||||
previous_row_label: None,
|
||||
column_index,
|
||||
row_index,
|
||||
stack_index,
|
||||
progress_percent: derive_board_progress_percent(session),
|
||||
status_detail: derive_board_status_detail(session),
|
||||
movement_note: None,
|
||||
activity_kind: None,
|
||||
activity_note: None,
|
||||
handoff_backlog: 0,
|
||||
conflict_signal: conflict_signals.get(&session.id).cloned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
board_meta
|
||||
}
|
||||
|
||||
fn board_column_index(state: &SessionState) -> i64 {
|
||||
match state {
|
||||
SessionState::Pending => 0,
|
||||
SessionState::Running => 1,
|
||||
SessionState::Idle => 2,
|
||||
SessionState::Stale | SessionState::Failed => 3,
|
||||
SessionState::Completed => 4,
|
||||
SessionState::Stopped => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_board_progress_percent(session: &Session) -> i64 {
|
||||
match session.state {
|
||||
SessionState::Pending => 10,
|
||||
SessionState::Running => {
|
||||
if session.metrics.files_changed > 0 {
|
||||
60
|
||||
} else if session.worktree.is_some() || session.metrics.tool_calls > 0 {
|
||||
45
|
||||
} else {
|
||||
25
|
||||
}
|
||||
}
|
||||
SessionState::Idle => 85,
|
||||
SessionState::Stale => 55,
|
||||
SessionState::Completed => 100,
|
||||
SessionState::Failed => 65,
|
||||
SessionState::Stopped => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_board_status_detail(session: &Session) -> Option<String> {
|
||||
let detail = match session.state {
|
||||
SessionState::Pending => "Queued",
|
||||
SessionState::Running => {
|
||||
if session.metrics.files_changed > 0 {
|
||||
"Actively editing"
|
||||
} else if session.worktree.is_some() {
|
||||
"Scoping"
|
||||
} else {
|
||||
"Booting"
|
||||
}
|
||||
}
|
||||
SessionState::Idle => "Awaiting review",
|
||||
SessionState::Stale => "Needs heartbeat",
|
||||
SessionState::Completed => "Task complete",
|
||||
SessionState::Failed => "Blocked by failure",
|
||||
SessionState::Stopped => "Stopped",
|
||||
};
|
||||
|
||||
Some(detail.to_string())
|
||||
}
|
||||
|
||||
fn annotate_board_motion(current: &mut SessionBoardMeta, previous: &SessionBoardMeta) {
|
||||
if previous.lane != current.lane {
|
||||
current.previous_lane = Some(previous.lane.clone());
|
||||
current.previous_row_label = previous.row_label.clone();
|
||||
current.movement_note = Some(match current.lane.as_str() {
|
||||
"Blocked" => "Blocked".to_string(),
|
||||
"Done" => "Completed".to_string(),
|
||||
_ => format!("Moved {} -> {}", previous.lane, current.lane),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if previous.row_label != current.row_label {
|
||||
let from = previous
|
||||
.row_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "General".to_string());
|
||||
let to = current
|
||||
.row_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "General".to_string());
|
||||
current.previous_lane = Some(previous.lane.clone());
|
||||
current.previous_row_label = previous.row_label.clone();
|
||||
current.movement_note = Some(format!("Retargeted {from} -> {to}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_labeled_scope(task: &str, labels: &[&str]) -> Option<String> {
|
||||
let lowered = task.to_ascii_lowercase();
|
||||
|
||||
for label in labels {
|
||||
if let Some(index) = lowered.find(label) {
|
||||
let mut tail = task.get(index + label.len()..)?.trim_start_matches([' ', ':', '-', '#']);
|
||||
if tail.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((candidate, _)) = tail
|
||||
.split_once('|')
|
||||
.or_else(|| tail.split_once(';'))
|
||||
.or_else(|| tail.split_once(','))
|
||||
.or_else(|| tail.split_once('\n'))
|
||||
{
|
||||
tail = candidate;
|
||||
}
|
||||
|
||||
let words = tail
|
||||
.split_whitespace()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':' | '|'))
|
||||
.to_string();
|
||||
|
||||
if !words.is_empty() {
|
||||
return Some(words);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_issue_reference(task: &str) -> Option<String> {
|
||||
let tokens = task
|
||||
.split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | ':' | '(' | ')'))
|
||||
.filter(|token| !token.is_empty());
|
||||
|
||||
for token in tokens {
|
||||
if let Some(stripped) = token.strip_prefix('#') {
|
||||
if !stripped.is_empty() && stripped.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return Some(format!("#{stripped}"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((prefix, suffix)) = token.split_once('-') {
|
||||
if !prefix.is_empty()
|
||||
&& !suffix.is_empty()
|
||||
&& prefix.chars().all(|ch| ch.is_ascii_uppercase())
|
||||
&& suffix.chars().all(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
return Some(token.trim_matches('.').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn derive_board_conflict_signals(sessions: &[Session]) -> HashMap<String, String> {
|
||||
let active_sessions = sessions
|
||||
.iter()
|
||||
.filter(|session| {
|
||||
matches!(
|
||||
session.state,
|
||||
SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut sessions_by_branch: HashMap<String, Vec<&Session>> = HashMap::new();
|
||||
let mut sessions_by_task: HashMap<String, Vec<&Session>> = HashMap::new();
|
||||
let mut sessions_by_scope: HashMap<String, Vec<&Session>> = HashMap::new();
|
||||
|
||||
for session in active_sessions {
|
||||
if let Some(worktree) = session.worktree.as_ref() {
|
||||
sessions_by_branch
|
||||
.entry(worktree.branch.clone())
|
||||
.or_default()
|
||||
.push(session);
|
||||
}
|
||||
|
||||
sessions_by_task
|
||||
.entry(session.task.trim().to_ascii_lowercase())
|
||||
.or_default()
|
||||
.push(session);
|
||||
|
||||
let (project, feature, issue) = derive_board_scope(session);
|
||||
if let Some(scope) = issue.or(feature).or(project).filter(|scope| !scope.is_empty()) {
|
||||
sessions_by_scope.entry(scope).or_default().push(session);
|
||||
}
|
||||
}
|
||||
|
||||
let mut signals = HashMap::new();
|
||||
|
||||
for (branch, grouped_sessions) in sessions_by_branch {
|
||||
if grouped_sessions.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for session in grouped_sessions {
|
||||
append_conflict_signal(&mut signals, &session.id, format!("Shared branch {branch}"));
|
||||
}
|
||||
}
|
||||
|
||||
for (task, grouped_sessions) in sessions_by_task {
|
||||
if grouped_sessions.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for session in grouped_sessions {
|
||||
append_conflict_signal(
|
||||
&mut signals,
|
||||
&session.id,
|
||||
format!("Shared task {}", truncate_task_for_signal(&task)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (scope, grouped_sessions) in sessions_by_scope {
|
||||
if grouped_sessions.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for session in grouped_sessions {
|
||||
append_conflict_signal(
|
||||
&mut signals,
|
||||
&session.id,
|
||||
format!("Shared scope {}", truncate_task_for_signal(&scope)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
signals
|
||||
}
|
||||
|
||||
fn append_conflict_signal(
|
||||
signals: &mut HashMap<String, String>,
|
||||
session_id: &str,
|
||||
next_signal: String,
|
||||
) {
|
||||
let entry = signals.entry(session_id.to_string()).or_default();
|
||||
if entry.is_empty() {
|
||||
*entry = next_signal;
|
||||
return;
|
||||
}
|
||||
|
||||
if !entry.split("; ").any(|existing| existing == next_signal) {
|
||||
entry.push_str("; ");
|
||||
entry.push_str(&next_signal);
|
||||
}
|
||||
}
|
||||
|
||||
fn short_session_ref(session_id: &str) -> String {
|
||||
if session_id.chars().count() <= 12 {
|
||||
session_id.to_string()
|
||||
} else {
|
||||
session_id.chars().take(8).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn routing_activity_suffix(context: &str) -> Option<&'static str> {
|
||||
let normalized = context.to_ascii_lowercase();
|
||||
if normalized.contains("reused idle delegate") {
|
||||
Some("reused idle")
|
||||
} else if normalized.contains("reused active delegate") {
|
||||
Some("reused active")
|
||||
} else if normalized.contains("spawned fallback delegate") {
|
||||
Some("spawned fallback")
|
||||
} else if normalized.contains("spawned new delegate") {
|
||||
Some("spawned")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_task_handoff_context(content: &str) -> Option<String> {
|
||||
if let Some(crate::comms::MessageType::TaskHandoff { context, .. }) = crate::comms::parse(content)
|
||||
{
|
||||
return Some(context);
|
||||
}
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||
value
|
||||
.get("context")
|
||||
.and_then(|context| context.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn truncate_task_for_signal(task: &str) -> String {
|
||||
const LIMIT: usize = 28;
|
||||
let trimmed = task.trim();
|
||||
let count = trimmed.chars().count();
|
||||
if count <= LIMIT {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}...", trimmed.chars().take(LIMIT - 3).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_conflict_incident(row: &rusqlite::Row<'_>) -> rusqlite::Result<ConflictIncident> {
|
||||
let created_at = parse_timestamp_column(row.get::<_, String>(11)?, 11)?;
|
||||
let updated_at = parse_timestamp_column(row.get::<_, String>(12)?, 12)?;
|
||||
|
||||
Reference in New Issue
Block a user