1use 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 #[arg(long, default_value_t = false)]
18 pub json: bool,
19 #[arg(long, value_parser = ["json", "text"], hide = true)]
21 pub format: Option<String>,
22}
23
24#[derive(Serialize)]
25struct HealthCounts {
26 memories: i64,
27 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 schema_version: u32,
60 missing_entities: Vec<String>,
63 wal_size_mb: f64,
65 journal_mode: String,
67 checks: Vec<HealthCheck>,
68 elapsed_ms: u64,
69}
70
71fn 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; let _ = args.format; 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 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 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 let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
191
192 let model_dir = paths.models.join("models--intfloat--multilingual-e5-small");
194 let model_ok = model_dir.exists();
195
196 let mut checks: Vec<HealthCheck> = Vec::new();
198
199 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 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 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}