1use clap::{Args, Subcommand};
15use serde::Serialize;
16
17use crate::cli::LlmBackendChoice;
18use crate::errors::AppError;
19use crate::output::emit_json_compact;
20use crate::paths::AppPaths;
21use crate::storage::connection::open_rw;
22use crate::storage::pending_embeddings::{self, PendingEmbedding, PendingEmbeddingStatus};
23
24#[derive(Debug, Args)]
25#[command(after_long_help = "EXAMPLES:\n \
26 # Show queue health and counts per status\n \
27 sqlite-graphrag embedding status --json\n\n \
28 # List all pending embeddings waiting for retry\n \
29 sqlite-graphrag embedding list --status pending --json\n\n \
30 # Mark pending_id 7 as abandoned (will not be retried automatically)\n \
31 sqlite-graphrag embedding abandon 7 --yes\n\n \
32 # Note: `embedding retry` requires re-running an LLM subprocess; for full\n \
33 # retry of every pending entry use `enrich --operation re-embed --pending-only`")]
34pub struct EmbeddingArgs {
36 #[command(subcommand)]
38 pub cmd: EmbeddingCmd,
39}
40
41#[derive(Debug, Subcommand)]
43pub enum EmbeddingCmd {
44 Status(EmbeddingStatusArgs),
46 List(EmbeddingListArgs),
48 Abandon(EmbeddingAbandonArgs),
50}
51
52#[derive(Debug, Args)]
54pub struct EmbeddingStatusArgs {
55 #[arg(long)]
57 pub db: Option<String>,
58 #[arg(long, hide = true)]
60 pub json: bool,
61}
62
63#[derive(Debug, Args)]
65pub struct EmbeddingListArgs {
66 #[arg(long)]
68 pub db: Option<String>,
69 #[arg(long, value_enum, default_value_t = EmbeddingStatusFilter::Pending)]
71 pub status: EmbeddingStatusFilter,
72 #[arg(long, default_value_t = 100)]
74 pub limit: usize,
75 #[arg(long, hide = true)]
77 pub json: bool,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
82#[value(rename_all = "snake_case")]
83pub enum EmbeddingStatusFilter {
84 Pending,
86 InProgress,
88 Done,
90 Abandoned,
92}
93
94impl From<EmbeddingStatusFilter> for PendingEmbeddingStatus {
95 fn from(value: EmbeddingStatusFilter) -> Self {
96 match value {
97 EmbeddingStatusFilter::Pending => Self::Pending,
98 EmbeddingStatusFilter::InProgress => Self::InProgress,
99 EmbeddingStatusFilter::Done => Self::Done,
100 EmbeddingStatusFilter::Abandoned => Self::Abandoned,
101 }
102 }
103}
104
105#[derive(Debug, Args)]
107pub struct EmbeddingAbandonArgs {
108 #[arg(long)]
110 pub db: Option<String>,
111 pub pending_id: i64,
113 #[arg(long)]
115 pub yes: bool,
116 #[arg(long, hide = true)]
118 pub json: bool,
119}
120
121#[derive(Serialize)]
122struct EmbeddingStatusOutput {
123 action: &'static str,
124 backend_invoked: &'static str,
129 counts: EmbeddingStatusCounts,
130 coverage: EmbeddingCoverage,
135 elapsed_ms: u64,
136}
137
138#[derive(Serialize, Default)]
139struct EmbeddingStatusCounts {
140 pending: usize,
141 in_progress: usize,
142 done: usize,
143 abandoned: usize,
144}
145
146#[derive(Serialize, Default)]
151struct EmbeddingCoverage {
152 memories_total: i64,
153 memories_with_vec: i64,
154 memories_missing: i64,
158 entities_total: i64,
159 entities_with_vec: i64,
160 entities_missing: i64,
162 chunks_total: i64,
163 chunks_with_vec: i64,
164 chunks_missing: i64,
166}
167
168fn count_table(conn: &rusqlite::Connection, sql: &str) -> i64 {
171 match conn.query_row(sql, [], |r| r.get::<_, i64>(0)) {
172 Ok(n) => n,
173 Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => 0,
174 Err(e) => {
175 tracing::warn!(target: "embedding", error = %e, sql, "coverage count failed");
176 0
177 }
178 }
179}
180
181fn count_missing(conn: &rusqlite::Connection, sql: &str, total_when_absent: i64) -> i64 {
186 match conn.query_row(sql, [], |r| r.get::<_, i64>(0)) {
187 Ok(n) => n,
188 Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => {
189 total_when_absent
190 }
191 Err(e) => {
192 tracing::warn!(target: "embedding", error = %e, sql, "coverage missing-count failed");
193 0
194 }
195 }
196}
197
198#[derive(Serialize)]
199struct EmbeddingListEntry {
200 pending_id: i64,
201 memory_id: i64,
202 name: String,
203 namespace: String,
204 backend_chain: String,
205 last_error: Option<String>,
206 last_exit_code: Option<i32>,
207 last_stderr_tail: Option<String>,
208 attempt_count: i32,
209 status: String,
210 updated_at: i64,
211}
212
213impl From<&PendingEmbedding> for EmbeddingListEntry {
214 fn from(p: &PendingEmbedding) -> Self {
215 Self {
216 pending_id: p.pending_id,
217 memory_id: p.memory_id,
218 name: p.name.clone(),
219 namespace: p.namespace.clone(),
220 backend_chain: p.backend_chain.clone(),
221 last_error: p.last_error.clone(),
222 last_exit_code: p.last_exit_code,
223 last_stderr_tail: p.last_stderr_tail.clone(),
224 attempt_count: p.attempt_count,
225 status: p.status.as_str().to_string(),
226 updated_at: p.updated_at,
227 }
228 }
229}
230
231#[derive(Serialize)]
232struct EmbeddingListOutput {
233 action: &'static str,
234 filter_status: String,
235 count: usize,
236 entries: Vec<EmbeddingListEntry>,
237 elapsed_ms: u64,
238}
239
240#[derive(Serialize)]
241struct EmbeddingAbandonOutput {
242 action: &'static str,
243 pending_id: i64,
244 status: &'static str,
245 elapsed_ms: u64,
246 yes: bool,
247}
248
249pub fn run(args: EmbeddingArgs, llm_backend: LlmBackendChoice) -> Result<(), AppError> {
251 match args.cmd {
252 EmbeddingCmd::Status(a) => run_status(a, llm_backend),
253 EmbeddingCmd::List(a) => run_list(a),
254 EmbeddingCmd::Abandon(a) => run_abandon(a),
255 }
256}
257
258fn open_conn(db: Option<&str>) -> Result<(AppPaths, rusqlite::Connection), AppError> {
259 let paths = AppPaths::resolve(db)?;
260 let conn = open_rw(&paths.db)?;
261 Ok((paths, conn))
262}
263
264pub(crate) fn run_status(args: EmbeddingStatusArgs, llm_backend: LlmBackendChoice) -> Result<(), AppError> {
266 let start = std::time::Instant::now();
267 let (_paths, conn) = open_conn(args.db.as_deref())?;
268
269 let counts = EmbeddingStatusCounts {
270 pending: pending_embeddings::list_by_status(
271 &conn,
272 PendingEmbeddingStatus::Pending,
273 100_000,
274 )?
275 .len(),
276 in_progress: pending_embeddings::list_by_status(
277 &conn,
278 PendingEmbeddingStatus::InProgress,
279 100_000,
280 )?
281 .len(),
282 done: pending_embeddings::list_by_status(&conn, PendingEmbeddingStatus::Done, 100_000)?
283 .len(),
284 abandoned: pending_embeddings::list_by_status(
285 &conn,
286 PendingEmbeddingStatus::Abandoned,
287 100_000,
288 )?
289 .len(),
290 };
291
292 let backend_invoked: &'static str = match llm_backend {
293 LlmBackendChoice::Claude => "claude",
294 LlmBackendChoice::Codex => "codex",
295 LlmBackendChoice::Opencode => "opencode",
296 LlmBackendChoice::None => "none",
297 LlmBackendChoice::OpenRouter => "openrouter",
298 LlmBackendChoice::Auto => "auto",
299 };
300
301 let memories_total = count_table(
305 &conn,
306 "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
307 );
308 let entities_total = count_table(&conn, "SELECT COUNT(*) FROM entities");
309 let chunks_total = count_table(&conn, "SELECT COUNT(*) FROM memory_chunks");
310 let coverage = EmbeddingCoverage {
311 memories_total,
312 memories_with_vec: count_table(&conn, "SELECT COUNT(*) FROM memory_embeddings"),
313 memories_missing: count_missing(
316 &conn,
317 "SELECT COUNT(*) FROM memories m \
318 LEFT JOIN memory_embeddings me ON me.memory_id = m.id \
319 WHERE me.memory_id IS NULL AND m.deleted_at IS NULL",
320 memories_total,
321 ),
322 entities_total,
323 entities_with_vec: count_table(&conn, "SELECT COUNT(*) FROM entity_embeddings"),
324 entities_missing: count_missing(
325 &conn,
326 "SELECT COUNT(*) FROM entities e \
327 LEFT JOIN entity_embeddings ee ON ee.entity_id = e.id \
328 WHERE ee.entity_id IS NULL",
329 entities_total,
330 ),
331 chunks_total,
332 chunks_with_vec: count_table(&conn, "SELECT COUNT(*) FROM chunk_embeddings"),
333 chunks_missing: count_missing(
334 &conn,
335 "SELECT COUNT(*) FROM memory_chunks c \
336 LEFT JOIN chunk_embeddings ce ON ce.chunk_id = c.id \
337 WHERE ce.chunk_id IS NULL",
338 chunks_total,
339 ),
340 };
341
342 let output = EmbeddingStatusOutput {
343 action: "embedding_status",
344 backend_invoked,
345 counts,
346 coverage,
347 elapsed_ms: start.elapsed().as_millis() as u64,
348 };
349 emit_json_compact(&output)
350}
351
352fn run_list(args: EmbeddingListArgs) -> Result<(), AppError> {
353 let start = std::time::Instant::now();
354 let (_paths, conn) = open_conn(args.db.as_deref())?;
355 let status: PendingEmbeddingStatus = args.status.into();
356 let rows = pending_embeddings::list_by_status(&conn, status, args.limit)?;
357 let count = rows.len();
358 let entries: Vec<EmbeddingListEntry> = rows.iter().map(EmbeddingListEntry::from).collect();
359 let output = EmbeddingListOutput {
360 action: "embedding_list",
361 filter_status: status.as_str().to_string(),
362 count,
363 entries,
364 elapsed_ms: start.elapsed().as_millis() as u64,
365 };
366 emit_json_compact(&output)
367}
368
369fn run_abandon(args: EmbeddingAbandonArgs) -> Result<(), AppError> {
370 let start = std::time::Instant::now();
371 let (_paths, conn) = open_conn(args.db.as_deref())?;
372 pending_embeddings::abandon(&conn, args.pending_id)?;
373 let output = EmbeddingAbandonOutput {
374 action: "embedding_abandon",
375 pending_id: args.pending_id,
376 status: PendingEmbeddingStatus::Abandoned.as_str(),
377 elapsed_ms: start.elapsed().as_millis() as u64,
378 yes: args.yes,
379 };
380 emit_json_compact(&output)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
390 fn embedding_status_output_includes_coverage() {
391 let output = EmbeddingStatusOutput {
392 action: "embedding_status",
393 backend_invoked: "openrouter",
394 counts: EmbeddingStatusCounts::default(),
395 coverage: EmbeddingCoverage {
396 memories_total: 10,
397 memories_with_vec: 9,
398 memories_missing: 1,
399 entities_total: 4,
400 entities_with_vec: 4,
401 entities_missing: 0,
402 chunks_total: 7,
403 chunks_with_vec: 7,
404 chunks_missing: 0,
405 },
406 elapsed_ms: 1,
407 };
408 let json = serde_json::to_value(&output).expect("serialize");
409 assert_eq!(json["coverage"]["memories_total"], 10);
410 assert_eq!(json["coverage"]["memories_with_vec"], 9);
411 assert_eq!(json["coverage"]["entities_with_vec"], 4);
412 assert_eq!(json["coverage"]["chunks_with_vec"], 7);
413 assert_eq!(json["coverage"]["memories_missing"], 1);
415 assert_eq!(json["coverage"]["entities_missing"], 0);
416 assert_eq!(json["coverage"]["chunks_missing"], 0);
417 }
418
419 #[test]
422 fn count_missing_counts_gaps_and_falls_back_when_table_absent() {
423 let conn = rusqlite::Connection::open_in_memory().unwrap();
424 conn.execute_batch(
425 "CREATE TABLE entities (id INTEGER PRIMARY KEY, name TEXT);
426 CREATE TABLE entity_embeddings (
427 entity_id INTEGER PRIMARY KEY,
428 embedding BLOB NOT NULL
429 );",
430 )
431 .unwrap();
432 conn.execute(
433 "INSERT INTO entities (id, name) VALUES (1, 'a'), (2, 'b'), (3, 'c')",
434 [],
435 )
436 .unwrap();
437 conn.execute(
438 "INSERT INTO entity_embeddings (entity_id, embedding) VALUES (1, X'00')",
439 [],
440 )
441 .unwrap();
442
443 let missing = count_missing(
444 &conn,
445 "SELECT COUNT(*) FROM entities e \
446 LEFT JOIN entity_embeddings ee ON ee.entity_id = e.id \
447 WHERE ee.entity_id IS NULL",
448 3,
449 );
450 assert_eq!(missing, 2, "2 of 3 entities lack a vector row");
451
452 let missing_absent = count_missing(
454 &conn,
455 "SELECT COUNT(*) FROM entities e \
456 LEFT JOIN chunk_embeddings ce ON ce.chunk_id = e.id \
457 WHERE ce.chunk_id IS NULL",
458 3,
459 );
460 assert_eq!(missing_absent, 3, "absent table must report all missing");
461 }
462
463 #[test]
464 fn status_filter_round_trip() {
465 for f in [
466 EmbeddingStatusFilter::Pending,
467 EmbeddingStatusFilter::InProgress,
468 EmbeddingStatusFilter::Done,
469 EmbeddingStatusFilter::Abandoned,
470 ] {
471 let s: PendingEmbeddingStatus = f.into();
472 assert_eq!(
473 s.as_str(),
474 match f {
475 EmbeddingStatusFilter::Pending => "pending",
476 EmbeddingStatusFilter::InProgress => "in_progress",
477 EmbeddingStatusFilter::Done => "done",
478 EmbeddingStatusFilter::Abandoned => "abandoned",
479 }
480 );
481 }
482 }
483}