Skip to main content

sqlite_graphrag/commands/
health.rs

1use crate::errors::AppError;
2use crate::i18n::erros;
3use crate::output;
4use crate::paths::AppPaths;
5use crate::storage::connection::open_ro;
6use serde::Serialize;
7use std::fs;
8use std::time::Instant;
9
10#[derive(clap::Args)]
11pub struct HealthArgs {
12    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
13    pub db: Option<String>,
14    /// Flag explícita de saída JSON. Aceita como no-op pois o output já é JSON por default.
15    #[arg(long, default_value_t = false)]
16    pub json: bool,
17    /// Formato de saída: "json" ou "text". JSON é sempre emitido no stdout independente do valor.
18    #[arg(long, value_parser = ["json", "text"], hide = true)]
19    pub format: Option<String>,
20}
21
22#[derive(Serialize)]
23struct HealthCounts {
24    memories: i64,
25    entities: i64,
26    relationships: i64,
27    vec_memories: i64,
28}
29
30#[derive(Serialize)]
31struct HealthCheck {
32    name: String,
33    ok: bool,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    detail: Option<String>,
36}
37
38#[derive(Serialize)]
39struct HealthResponse {
40    status: String,
41    integrity: String,
42    integrity_ok: bool,
43    schema_ok: bool,
44    vec_memories_ok: bool,
45    vec_entities_ok: bool,
46    vec_chunks_ok: bool,
47    fts_ok: bool,
48    model_ok: bool,
49    counts: HealthCounts,
50    db_path: String,
51    db_size_bytes: u64,
52    /// MAX(version) da tabela refinery_schema_history — número da última migração aplicada.
53    /// Distinto de PRAGMA schema_version (DDL counter SQLite) e PRAGMA user_version
54    /// (valor canônico SCHEMA_USER_VERSION de __debug_schema).
55    schema_version: u32,
56    /// Lista de entidades referenciadas por memórias mas ausentes na tabela de entidades.
57    /// Vazio em DB saudável. Conforme contrato documentado em AGENT_PROTOCOL.md.
58    missing_entities: Vec<String>,
59    /// Tamanho do WAL file em MB (0.0 se WAL não existe ou journal_mode != wal).
60    wal_size_mb: f64,
61    /// Modo de journaling do SQLite (wal, delete, truncate, persist, memory, off).
62    journal_mode: String,
63    checks: Vec<HealthCheck>,
64    elapsed_ms: u64,
65}
66
67/// Verifica se uma tabela (incluindo virtuais) existe em sqlite_master.
68fn table_exists(conn: &rusqlite::Connection, table_name: &str) -> bool {
69    conn.query_row(
70        "SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'shadow') AND name = ?1",
71        rusqlite::params![table_name],
72        |r| r.get::<_, i64>(0),
73    )
74    .unwrap_or(0)
75        > 0
76}
77
78pub fn run(args: HealthArgs) -> Result<(), AppError> {
79    let inicio = Instant::now();
80    let _ = args.json; // --json é no-op pois output já é JSON por default
81    let _ = args.format; // --format é no-op; JSON sempre emitido no stdout
82    let paths = AppPaths::resolve(args.db.as_deref())?;
83
84    if !paths.db.exists() {
85        return Err(AppError::NotFound(erros::banco_nao_encontrado(
86            &paths.db.display().to_string(),
87        )));
88    }
89
90    let conn = open_ro(&paths.db)?;
91
92    let integrity: String = conn.query_row("PRAGMA integrity_check;", [], |r| r.get(0))?;
93    let integrity_ok = integrity == "ok";
94
95    if !integrity_ok {
96        let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
97        output::emit_json(&HealthResponse {
98            status: "degraded".to_string(),
99            integrity: integrity.clone(),
100            integrity_ok: false,
101            schema_ok: false,
102            vec_memories_ok: false,
103            vec_entities_ok: false,
104            vec_chunks_ok: false,
105            fts_ok: false,
106            model_ok: false,
107            counts: HealthCounts {
108                memories: 0,
109                entities: 0,
110                relationships: 0,
111                vec_memories: 0,
112            },
113            db_path: paths.db.display().to_string(),
114            db_size_bytes,
115            schema_version: 0,
116            missing_entities: vec![],
117            wal_size_mb: 0.0,
118            journal_mode: "unknown".to_string(),
119            checks: vec![HealthCheck {
120                name: "integrity".to_string(),
121                ok: false,
122                detail: Some(integrity),
123            }],
124            elapsed_ms: inicio.elapsed().as_millis() as u64,
125        })?;
126        return Err(AppError::Database(rusqlite::Error::SqliteFailure(
127            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT),
128            Some("integrity check failed".to_string()),
129        )));
130    }
131
132    let memories_count: i64 = conn.query_row(
133        "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
134        [],
135        |r| r.get(0),
136    )?;
137    let entities_count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
138    let relationships_count: i64 =
139        conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?;
140    let vec_memories_count: i64 =
141        conn.query_row("SELECT COUNT(*) FROM vec_memories", [], |r| r.get(0))?;
142
143    let status = "ok";
144
145    let schema_version: u32 = conn
146        .query_row(
147            "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
148            [],
149            |r| r.get::<_, i64>(0),
150        )
151        .unwrap_or(0) as u32;
152
153    let schema_ok = schema_version > 0;
154
155    // Verifica tabelas vetoriais via sqlite_master
156    let vec_memories_ok = table_exists(&conn, "vec_memories");
157    let vec_entities_ok = table_exists(&conn, "vec_entities");
158    let vec_chunks_ok = table_exists(&conn, "vec_chunks");
159    let fts_ok = table_exists(&conn, "fts_memories");
160
161    // Detecta entidades órfãs referenciadas por memórias mas ausentes na tabela entities.
162    let mut missing_entities: Vec<String> = Vec::new();
163    let mut stmt = conn.prepare(
164        "SELECT DISTINCT me.entity_id
165         FROM memory_entities me
166         LEFT JOIN entities e ON e.id = me.entity_id
167         WHERE e.id IS NULL",
168    )?;
169    let orphans: Vec<i64> = stmt
170        .query_map([], |r| r.get(0))?
171        .collect::<Result<Vec<_>, _>>()?;
172    for id in orphans {
173        missing_entities.push(format!("entity_id={id}"));
174    }
175
176    let journal_mode: String = conn
177        .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
178        .unwrap_or_else(|_| "unknown".to_string());
179
180    let wal_size_mb = fs::metadata(format!("{}-wal", paths.db.display()))
181        .map(|m| m.len() as f64 / 1024.0 / 1024.0)
182        .unwrap_or(0.0);
183
184    // Tamanho do arquivo de banco em bytes
185    let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
186
187    // Verifica se o modelo ONNX está presente no cache
188    let model_dir = paths.models.join("models--intfloat--multilingual-e5-small");
189    let model_ok = model_dir.exists();
190
191    // Monta array de checks para diagnóstico detalhado
192    let mut checks: Vec<HealthCheck> = Vec::new();
193
194    // Neste ponto integrity_ok é sempre true (DB corrompido retorna cedo acima).
195    checks.push(HealthCheck {
196        name: "integrity".to_string(),
197        ok: true,
198        detail: None,
199    });
200
201    checks.push(HealthCheck {
202        name: "schema_version".to_string(),
203        ok: schema_ok,
204        detail: if schema_ok {
205            None
206        } else {
207            Some(format!("schema_version={schema_version} (esperado >0)"))
208        },
209    });
210
211    checks.push(HealthCheck {
212        name: "vec_memories".to_string(),
213        ok: vec_memories_ok,
214        detail: if vec_memories_ok {
215            None
216        } else {
217            Some("tabela vec_memories ausente em sqlite_master".to_string())
218        },
219    });
220
221    checks.push(HealthCheck {
222        name: "vec_entities".to_string(),
223        ok: vec_entities_ok,
224        detail: if vec_entities_ok {
225            None
226        } else {
227            Some("tabela vec_entities ausente em sqlite_master".to_string())
228        },
229    });
230
231    checks.push(HealthCheck {
232        name: "vec_chunks".to_string(),
233        ok: vec_chunks_ok,
234        detail: if vec_chunks_ok {
235            None
236        } else {
237            Some("tabela vec_chunks ausente em sqlite_master".to_string())
238        },
239    });
240
241    checks.push(HealthCheck {
242        name: "fts_memories".to_string(),
243        ok: fts_ok,
244        detail: if fts_ok {
245            None
246        } else {
247            Some("tabela fts_memories ausente em sqlite_master".to_string())
248        },
249    });
250
251    checks.push(HealthCheck {
252        name: "model_onnx".to_string(),
253        ok: model_ok,
254        detail: if model_ok {
255            None
256        } else {
257            Some(format!(
258                "modelo ausente em {}; execute 'sqlite-graphrag models download'",
259                model_dir.display()
260            ))
261        },
262    });
263
264    let response = HealthResponse {
265        status: status.to_string(),
266        integrity,
267        integrity_ok,
268        schema_ok,
269        vec_memories_ok,
270        vec_entities_ok,
271        vec_chunks_ok,
272        fts_ok,
273        model_ok,
274        counts: HealthCounts {
275            memories: memories_count,
276            entities: entities_count,
277            relationships: relationships_count,
278            vec_memories: vec_memories_count,
279        },
280        db_path: paths.db.display().to_string(),
281        db_size_bytes,
282        schema_version,
283        missing_entities,
284        wal_size_mb,
285        journal_mode,
286        checks,
287        elapsed_ms: inicio.elapsed().as_millis() as u64,
288    };
289
290    output::emit_json(&response)?;
291
292    Ok(())
293}
294
295#[cfg(test)]
296mod testes {
297    use super::*;
298
299    #[test]
300    fn health_check_serializa_todos_os_campos_novos() {
301        let resposta = HealthResponse {
302            status: "ok".to_string(),
303            integrity: "ok".to_string(),
304            integrity_ok: true,
305            schema_ok: true,
306            vec_memories_ok: true,
307            vec_entities_ok: true,
308            vec_chunks_ok: true,
309            fts_ok: true,
310            model_ok: false,
311            counts: HealthCounts {
312                memories: 5,
313                entities: 3,
314                relationships: 2,
315                vec_memories: 5,
316            },
317            db_path: "/tmp/test.sqlite".to_string(),
318            db_size_bytes: 4096,
319            schema_version: 5,
320            elapsed_ms: 0,
321            missing_entities: vec![],
322            wal_size_mb: 0.0,
323            journal_mode: "wal".to_string(),
324            checks: vec![
325                HealthCheck {
326                    name: "integrity".to_string(),
327                    ok: true,
328                    detail: None,
329                },
330                HealthCheck {
331                    name: "model_onnx".to_string(),
332                    ok: false,
333                    detail: Some("modelo ausente".to_string()),
334                },
335            ],
336        };
337
338        let json = serde_json::to_value(&resposta).unwrap();
339        assert_eq!(json["status"], "ok");
340        assert_eq!(json["integrity_ok"], true);
341        assert_eq!(json["schema_ok"], true);
342        assert_eq!(json["vec_memories_ok"], true);
343        assert_eq!(json["vec_entities_ok"], true);
344        assert_eq!(json["vec_chunks_ok"], true);
345        assert_eq!(json["fts_ok"], true);
346        assert_eq!(json["model_ok"], false);
347        assert_eq!(json["db_size_bytes"], 4096u64);
348        assert!(json["checks"].is_array());
349        assert_eq!(json["checks"].as_array().unwrap().len(), 2);
350
351        // Verifica que detail está ausente quando ok=true (skip_serializing_if)
352        let integrity_check = &json["checks"][0];
353        assert_eq!(integrity_check["name"], "integrity");
354        assert_eq!(integrity_check["ok"], true);
355        assert!(integrity_check.get("detail").is_none());
356
357        // Verifica que detail está presente quando ok=false
358        let model_check = &json["checks"][1];
359        assert_eq!(model_check["name"], "model_onnx");
360        assert_eq!(model_check["ok"], false);
361        assert_eq!(model_check["detail"], "modelo ausente");
362    }
363
364    #[test]
365    fn health_check_sem_detail_omite_campo() {
366        let check = HealthCheck {
367            name: "vec_memories".to_string(),
368            ok: true,
369            detail: None,
370        };
371        let json = serde_json::to_value(&check).unwrap();
372        assert!(
373            json.get("detail").is_none(),
374            "campo detail deve ser omitido quando None"
375        );
376    }
377
378    #[test]
379    fn health_check_com_detail_serializa_campo() {
380        let check = HealthCheck {
381            name: "fts_memories".to_string(),
382            ok: false,
383            detail: Some("tabela fts_memories ausente".to_string()),
384        };
385        let json = serde_json::to_value(&check).unwrap();
386        assert_eq!(json["detail"], "tabela fts_memories ausente");
387    }
388}