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