Skip to main content

sqlite_graphrag/commands/
health.rs

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