Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions src/client/shell/dock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ impl StatusTab {
if let Some(head) = super::telemetry::read_git_head_short(root) {
self.telemetry.git.head_commit = Some(head);
}

// Sincronización viva de Engram a 0% de CPU
let project_name = root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "project".to_string());
super::telemetry::refresh_engram_telemetry(&project_name, &mut self.telemetry.engram);

self.is_dirty = false;
}

Expand Down Expand Up @@ -1881,13 +1889,29 @@ fn render_status_content(

if has_engram && cur_y < bottom_limit {
let obs_id = status_tab.telemetry.engram.observations_count;
let mem_str = format!("- 🧠 {ws_name} · ✓ saved #{obs_id}");
let mem_str = if obs_id > 0 {
format!("- 🧠 {ws_name} · ✓ saved #{obs_id}")
} else if status_tab.telemetry.engram.port > 0 {
format!(
"- 🧠 {ws_name} · daemon:{}",
status_tab.telemetry.engram.port
)
} else {
format!("- 🧠 {ws_name} · ✓ engram online")
};

let engram_style = if !status_tab.telemetry.engram.cloud_status.is_empty() {
Style::default().fg(config.palette.green)
} else {
Style::default().fg(config.palette.mauve)
};

buffer.set_stringn(
x + 2,
cur_y,
mem_str,
rail_w.saturating_sub(2) as usize,
Style::default().fg(config.palette.mauve),
engram_style,
);
cur_y += 1;
}
Expand Down Expand Up @@ -3575,4 +3599,43 @@ mod tests {
assert_eq!(expanded_buffer[(97, 0)].symbol(), "»");
assert_eq!(expanded_buffer[(98, 0)].symbol(), "]");
}

#[test]
fn test_status_tab_renders_engram_daemon_status() {
let area = Rect::new(0, 0, 48, 40);
let mut buffer = Buffer::empty(area);
let config = ClientShellConfig::from_config(&crate::config::Config::default());
let mut status_tab = StatusTab::new();

status_tab.telemetry.engram = crate::client::shell::telemetry::EngramTelemetry {
project_name: "telardev".to_string(),
port: 7437,
is_online: true,
observations_count: 0,
cloud_status: "synced".to_string(),
};

render_status_content(
&mut buffer,
0,
0,
40,
48,
&config,
None,
&mut status_tab,
None,
);

let mut rendered_text = String::new();
for y in 0..40 {
for x in 0..48 {
rendered_text.push_str(buffer[(x, y)].symbol());
}
rendered_text.push('\n');
}

assert!(rendered_text.contains("INTEGRATIONS"));
assert!(rendered_text.contains("daemon:7437"));
}
}
173 changes: 173 additions & 0 deletions src/client/shell/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,139 @@ pub fn build_live_git_flow_snapshot(
}
}

// ==============================================================================
// Engram Telemetry & Zero-CPU Daemon Probe
// ==============================================================================

/// Consulta HTTP/1.0 ultra-ligera contra el daemon local de Engram (`127.0.0.1:<port>`)
/// usando únicamente std::net::TcpStream con timeouts acotados (0% dependencias externas).
pub fn query_engram_daemon(port: u16, path: &str) -> Option<serde_json::Value> {
use std::io::{Read, Write};
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let mut stream =
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(80)).ok()?;
let _ = stream.set_read_timeout(Some(std::time::Duration::from_millis(200)));
let _ = stream.set_write_timeout(Some(std::time::Duration::from_millis(100)));

let req = format!("GET {path} HTTP/1.0\r\nHost: 127.0.0.1\r\nAccept: application/json\r\n\r\n");
stream.write_all(req.as_bytes()).ok()?;

let mut buf = Vec::new();
stream.take(64 * 1024).read_to_end(&mut buf).ok()?;
let text = String::from_utf8_lossy(&buf);
let (_head, body) = text.split_once("\r\n\r\n")?;
serde_json::from_str(body).ok()
}

/// Resuelve la ruta al archivo de base de datos de Engram (`engram.db`)
pub fn resolve_engram_db_path() -> Option<std::path::PathBuf> {
// 1. Variable de entorno explícita
if let Ok(p) = std::env::var("ENGRAM_DB_PATH") {
let p_buf = std::path::PathBuf::from(p.trim());
if p_buf.is_file() {
return Some(p_buf);
}
}

// 2. Directorio ~/.engram/engram.db en USERPROFILE o HOME
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
.map(std::path::PathBuf::from);

if let Some(home_dir) = home {
let candidate = home_dir.join(".engram").join("engram.db");
if candidate.is_file() {
return Some(candidate);
}
}

// 3. En Windows, revisar unidades de disco estándar para instalaciones portables
#[cfg(windows)]
{
for drive in ["C:\\", "D:\\"] {
let candidate = std::path::Path::new(drive)
.join(".engram")
.join("engram.db");
if candidate.is_file() {
return Some(candidate);
}
}
}

None
}

/// Inspecciona el encabezado SQLite de 100 bytes para verificar que sea una base válida
/// y extraer el conteo de páginas de la base de datos sin requerir C o rusqlite.
pub fn inspect_engram_db_header(db_path: &std::path::Path) -> Option<(u32, u64)> {
use std::io::Read;
let mut file = std::fs::File::open(db_path).ok()?;
let mut header = [0u8; 100];
file.read_exact(&mut header).ok()?;

// Verificar firma SQLite 3: "SQLite format 3\0"
if &header[0..16] != b"SQLite format 3\0" {
return None;
}

let page_count = u32::from_be_bytes([header[28], header[29], header[30], header[31]]);
let mtime = db_path
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);

Some((page_count, mtime))
}

/// Sincroniza la telemetría viva de Engram:
/// - Sonda al daemon local (port 7437 o ENGRAM_PORT)
/// - Si no responde, verifica presencia y firma del archivo local engram.db
pub fn refresh_engram_telemetry(project_name: &str, current: &mut EngramTelemetry) {
let port: u16 = std::env::var("ENGRAM_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(7437);

current.project_name = project_name.to_string();

// 1. Sonda rápida al daemon local
if query_engram_daemon(port, "/health")
.and_then(|health| {
health
.get("status")
.and_then(|s| s.as_str())
.map(|s| s == "ok")
})
.unwrap_or(false)
{
current.is_online = true;
current.port = port;

if let Some(phase) =
query_engram_daemon(port, &format!("/sync/status?project={project_name}"))
.as_ref()
.and_then(|sync_val| sync_val.get("phase"))
.and_then(|s| s.as_str())
{
current.cloud_status = phase.to_string();
}
return;
}

// 2. Si el daemon HTTP no está respondiendo, verificar existencia física de la base de datos
if resolve_engram_db_path()
.and_then(|db_path| inspect_engram_db_header(&db_path))
.is_some()
{
current.is_online = true;
current.port = 0;
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -716,4 +849,44 @@ mod tests {
assert_eq!(clean_file_basename("src/client/shell/dock.rs"), "dock.rs");
assert_eq!(clean_file_basename("report.md"), "report.md");
}

#[test]
fn test_query_engram_daemon_offline_graceful() {
// Un puerto no enlazado debe fallar de inmediato sin bloquear ni entrar en pánico
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let unused_port = listener.local_addr().unwrap().port();
drop(listener);
let res = query_engram_daemon(unused_port, "/health");
assert!(res.is_none());
}

#[test]
fn test_inspect_engram_db_header_validation() {
let temp_dir =
std::env::temp_dir().join(format!("telar-engram-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&temp_dir);
let _ = std::fs::create_dir_all(&temp_dir);

let fake_db = temp_dir.join("engram.db");

// 1. Archivo inválido (no SQLite)
std::fs::write(&fake_db, b"not a sqlite db").unwrap();
assert!(inspect_engram_db_header(&fake_db).is_none());

// 2. Archivo válido con encabezado SQLite 3
let mut header = vec![0u8; 100];
header[0..16].copy_from_slice(b"SQLite format 3\0");
// Page size = 4096 (0x1000)
header[16] = 0x10;
header[17] = 0x00;
// Page count = 42
header[28..32].copy_from_slice(&42u32.to_be_bytes());

std::fs::write(&fake_db, &header).unwrap();
let (pages, mtime) = inspect_engram_db_header(&fake_db).expect("valid sqlite header");
assert_eq!(pages, 42);
assert!(mtime > 0);

let _ = std::fs::remove_dir_all(&temp_dir);
}
}
Loading