mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-05 08:43:29 +08:00
feat(ecc2): implement live output streaming per agent (#774)
- PTY output capture via tokio::process with stdout/stderr piping - Ring buffer (1000 lines) per session - Output pane wired to show selected session with auto-scroll - Broadcast channel for output events
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
||||
use super::{Session, SessionMetrics, SessionState};
|
||||
|
||||
pub struct StateStore {
|
||||
@@ -11,6 +13,8 @@ pub struct StateStore {
|
||||
impl StateStore {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
conn.busy_timeout(Duration::from_secs(5))?;
|
||||
let store = Self { conn };
|
||||
store.init_schema()?;
|
||||
Ok(store)
|
||||
@@ -58,9 +62,19 @@ impl StateStore {
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_output (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
stream TEXT NOT NULL,
|
||||
line TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_log_session ON tool_log(session_id);
|
||||
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);
|
||||
",
|
||||
)?;
|
||||
self.ensure_session_columns()?;
|
||||
@@ -97,7 +111,10 @@ impl StateStore {
|
||||
session.agent_type,
|
||||
session.state.to_string(),
|
||||
session.pid.map(i64::from),
|
||||
session.worktree.as_ref().map(|w| w.path.to_string_lossy().to_string()),
|
||||
session
|
||||
.worktree
|
||||
.as_ref()
|
||||
.map(|w| w.path.to_string_lossy().to_string()),
|
||||
session.worktree.as_ref().map(|w| w.branch.clone()),
|
||||
session.worktree.as_ref().map(|w| w.base_branch.clone()),
|
||||
session.created_at.to_rfc3339(),
|
||||
@@ -190,8 +207,8 @@ impl StateStore {
|
||||
let state = SessionState::from_db_value(&state_str);
|
||||
|
||||
let worktree_path: Option<String> = row.get(5)?;
|
||||
let worktree = worktree_path.map(|p| super::WorktreeInfo {
|
||||
path: std::path::PathBuf::from(p),
|
||||
let worktree = worktree_path.map(|path| super::WorktreeInfo {
|
||||
path: PathBuf::from(path),
|
||||
branch: row.get::<_, String>(6).unwrap_or_default(),
|
||||
base_branch: row.get::<_, String>(7).unwrap_or_default(),
|
||||
});
|
||||
@@ -234,8 +251,9 @@ impl StateStore {
|
||||
let sessions = self.list_sessions()?;
|
||||
Ok(sessions
|
||||
.into_iter()
|
||||
.find(|s| s.id == id || s.id.starts_with(id)))
|
||||
.find(|session| session.id == id || session.id.starts_with(id)))
|
||||
}
|
||||
|
||||
pub fn send_message(&self, from: &str, to: &str, content: &str, msg_type: &str) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO messages (from_session, to_session, content, msg_type, timestamp)
|
||||
@@ -244,15 +262,76 @@ impl StateStore {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append_output_line(
|
||||
&self,
|
||||
session_id: &str,
|
||||
stream: OutputStream,
|
||||
line: &str,
|
||||
) -> Result<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO session_output (session_id, stream, line, timestamp)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![session_id, stream.as_str(), line, now],
|
||||
)?;
|
||||
|
||||
self.conn.execute(
|
||||
"DELETE FROM session_output
|
||||
WHERE session_id = ?1
|
||||
AND id NOT IN (
|
||||
SELECT id
|
||||
FROM session_output
|
||||
WHERE session_id = ?1
|
||||
ORDER BY id DESC
|
||||
LIMIT ?2
|
||||
)",
|
||||
rusqlite::params![session_id, OUTPUT_BUFFER_LIMIT as i64],
|
||||
)?;
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_output_lines(&self, session_id: &str, limit: usize) -> Result<Vec<OutputLine>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT stream, line
|
||||
FROM (
|
||||
SELECT id, stream, line
|
||||
FROM session_output
|
||||
WHERE session_id = ?1
|
||||
ORDER BY id DESC
|
||||
LIMIT ?2
|
||||
)
|
||||
ORDER BY id ASC",
|
||||
)?;
|
||||
|
||||
let lines = stmt
|
||||
.query_map(rusqlite::params![session_id, limit as i64], |row| {
|
||||
let stream: String = row.get(0)?;
|
||||
let text: String = row.get(1)?;
|
||||
|
||||
Ok(OutputLine {
|
||||
stream: OutputStream::from_db_value(&stream),
|
||||
text,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(lines)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::{Session, SessionMetrics, SessionState};
|
||||
use chrono::{Duration, Utc};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct TestDir {
|
||||
path: PathBuf,
|
||||
@@ -286,7 +365,7 @@ mod tests {
|
||||
state,
|
||||
pid: None,
|
||||
worktree: None,
|
||||
created_at: now - Duration::minutes(1),
|
||||
created_at: now - ChronoDuration::minutes(1),
|
||||
updated_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
}
|
||||
@@ -346,4 +425,37 @@ mod tests {
|
||||
assert!(column_names.iter().any(|column| column == "pid"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_output_line_keeps_latest_buffer_window() -> Result<()> {
|
||||
let tempdir = TestDir::new("store-output")?;
|
||||
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||
let now = Utc::now();
|
||||
|
||||
db.insert_session(&Session {
|
||||
id: "session-1".to_string(),
|
||||
task: "buffer output".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
state: SessionState::Running,
|
||||
pid: None,
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
for index in 0..(OUTPUT_BUFFER_LIMIT + 5) {
|
||||
db.append_output_line("session-1", OutputStream::Stdout, &format!("line-{index}"))?;
|
||||
}
|
||||
|
||||
let lines = db.get_output_lines("session-1", OUTPUT_BUFFER_LIMIT)?;
|
||||
let texts: Vec<_> = lines.iter().map(|line| line.text.as_str()).collect();
|
||||
|
||||
assert_eq!(lines.len(), OUTPUT_BUFFER_LIMIT);
|
||||
assert_eq!(texts.first().copied(), Some("line-5"));
|
||||
let expected_last_line = format!("line-{}", OUTPUT_BUFFER_LIMIT + 4);
|
||||
assert_eq!(texts.last().copied(), Some(expected_last_line.as_str()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user