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`")]
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, value_parser = crate::parsers::parse_list_limit_range)]
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,
132 counts: EmbeddingStatusCounts,
133 coverage: EmbeddingCoverage,
138 elapsed_ms: u64,
139}
140
141#[derive(Serialize, Default)]
142struct EmbeddingStatusCounts {
143 pending: usize,
144 in_progress: usize,
145 done: usize,
146 abandoned: usize,
147}
148
149#[derive(Serialize, Default)]
154struct EmbeddingCoverage {
155 memories_total: i64,
156 memories_with_vec: i64,
157 memories_missing: i64,
161 entities_total: i64,
162 entities_with_vec: i64,
163 entities_missing: i64,
165 chunks_total: i64,
166 chunks_with_vec: i64,
167 chunks_missing: i64,
169}
170
171fn count_table(conn: &rusqlite::Connection, sql: &str) -> i64 {
174 match conn.query_row(sql, [], |r| r.get::<_, i64>(0)) {
175 Ok(n) => n,
176 Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => 0,
177 Err(e) => {
178 tracing::warn!(target: "embedding", error = %e, sql, "coverage count failed");
179 0
180 }
181 }
182}
183
184fn count_missing(conn: &rusqlite::Connection, sql: &str, total_when_absent: i64) -> i64 {
189 match conn.query_row(sql, [], |r| r.get::<_, i64>(0)) {
190 Ok(n) => n,
191 Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => {
192 total_when_absent
193 }
194 Err(e) => {
195 tracing::warn!(target: "embedding", error = %e, sql, "coverage missing-count failed");
196 0
197 }
198 }
199}
200
201#[derive(Serialize)]
202struct EmbeddingListEntry {
203 pending_id: i64,
204 memory_id: i64,
205 name: String,
206 namespace: String,
207 backend_chain: String,
208 last_error: Option<String>,
209 last_exit_code: Option<i32>,
210 last_stderr_tail: Option<String>,
211 attempt_count: i32,
212 status: String,
213 updated_at: i64,
214}
215
216impl From<&PendingEmbedding> for EmbeddingListEntry {
217 fn from(p: &PendingEmbedding) -> Self {
218 Self {
219 pending_id: p.pending_id,
220 memory_id: p.memory_id,
221 name: p.name.clone(),
222 namespace: p.namespace.clone(),
223 backend_chain: p.backend_chain.clone(),
224 last_error: p.last_error.clone(),
225 last_exit_code: p.last_exit_code,
226 last_stderr_tail: p.last_stderr_tail.clone(),
227 attempt_count: p.attempt_count,
228 status: p.status.as_str().to_string(),
229 updated_at: p.updated_at,
230 }
231 }
232}
233
234#[derive(Serialize)]
235struct EmbeddingListOutput {
236 action: &'static str,
237 filter_status: String,
238 count: usize,
239 entries: Vec<EmbeddingListEntry>,
240 elapsed_ms: u64,
241}
242
243#[derive(Serialize)]
244struct EmbeddingAbandonOutput {
245 action: &'static str,
246 pending_id: i64,
247 status: &'static str,
248 elapsed_ms: u64,
249 yes: bool,
250}
251
252pub fn run(args: EmbeddingArgs, llm_backend: LlmBackendChoice) -> Result<(), AppError> {
254 match args.cmd {
255 EmbeddingCmd::Status(a) => run_status(a, llm_backend),
256 EmbeddingCmd::List(a) => run_list(a),
257 EmbeddingCmd::Abandon(a) => run_abandon(a),
258 }
259}
260
261fn open_conn(db: Option<&str>) -> Result<(AppPaths, rusqlite::Connection), AppError> {
262 let paths = AppPaths::resolve(db)?;
263 let conn = open_rw(&paths.db)?;
264 Ok((paths, conn))
265}
266
267pub(crate) fn run_status(
269 args: EmbeddingStatusArgs,
270 llm_backend: LlmBackendChoice,
271) -> Result<(), AppError> {
272 let start = std::time::Instant::now();
273 let (_paths, conn) = open_conn(args.db.as_deref())?;
274
275 let counts = EmbeddingStatusCounts {
276 pending: pending_embeddings::list_by_status(
277 &conn,
278 PendingEmbeddingStatus::Pending,
279 100_000,
280 )?
281 .len(),
282 in_progress: pending_embeddings::list_by_status(
283 &conn,
284 PendingEmbeddingStatus::InProgress,
285 100_000,
286 )?
287 .len(),
288 done: pending_embeddings::list_by_status(&conn, PendingEmbeddingStatus::Done, 100_000)?
289 .len(),
290 abandoned: pending_embeddings::list_by_status(
291 &conn,
292 PendingEmbeddingStatus::Abandoned,
293 100_000,
294 )?
295 .len(),
296 };
297
298 let backend_invoked: &'static str = match llm_backend {
299 LlmBackendChoice::None => "none",
300 LlmBackendChoice::OpenRouter => "openrouter",
301 };
302
303 let memories_total = count_table(
307 &conn,
308 "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
309 );
310 let entities_total = count_table(&conn, "SELECT COUNT(*) FROM entities");
311 let chunks_total = count_table(&conn, "SELECT COUNT(*) FROM memory_chunks");
312 let coverage = EmbeddingCoverage {
313 memories_total,
314 memories_with_vec: count_table(&conn, "SELECT COUNT(*) FROM memory_embeddings"),
315 memories_missing: count_missing(
318 &conn,
319 "SELECT COUNT(*) FROM memories m \
320 LEFT JOIN memory_embeddings me ON me.memory_id = m.id \
321 WHERE me.memory_id IS NULL AND m.deleted_at IS NULL",
322 memories_total,
323 ),
324 entities_total,
325 entities_with_vec: count_table(&conn, "SELECT COUNT(*) FROM entity_embeddings"),
326 entities_missing: count_missing(
327 &conn,
328 "SELECT COUNT(*) FROM entities e \
329 LEFT JOIN entity_embeddings ee ON ee.entity_id = e.id \
330 WHERE ee.entity_id IS NULL",
331 entities_total,
332 ),
333 chunks_total,
334 chunks_with_vec: count_table(&conn, "SELECT COUNT(*) FROM chunk_embeddings"),
335 chunks_missing: count_missing(
336 &conn,
337 "SELECT COUNT(*) FROM memory_chunks c \
338 LEFT JOIN chunk_embeddings ce ON ce.chunk_id = c.id \
339 WHERE ce.chunk_id IS NULL",
340 chunks_total,
341 ),
342 };
343
344 let output = EmbeddingStatusOutput {
345 action: "embedding_status",
346 backend_invoked,
347 counts,
348 coverage,
349 elapsed_ms: start.elapsed().as_millis() as u64,
350 };
351 emit_json_compact(&output)
352}
353
354fn run_list(args: EmbeddingListArgs) -> Result<(), AppError> {
355 let start = std::time::Instant::now();
356 let (_paths, conn) = open_conn(args.db.as_deref())?;
357 let status: PendingEmbeddingStatus = args.status.into();
358 let rows = pending_embeddings::list_by_status(&conn, status, args.limit)?;
359
360 crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
365 applied: args.limit,
366 offset: 0,
367 source: crate::agent_surface::universe::CeilingSource::Flag,
368 kind: crate::agent_surface::universe::CeilingKind::Pagination,
369 universe_total: Some(pending_embeddings::count_by_status(&conn, status)?),
370 });
371
372 let count = rows.len();
373 let entries: Vec<EmbeddingListEntry> = rows.iter().map(EmbeddingListEntry::from).collect();
374 let output = EmbeddingListOutput {
375 action: "embedding_list",
376 filter_status: status.as_str().to_string(),
377 count,
378 entries,
379 elapsed_ms: start.elapsed().as_millis() as u64,
380 };
381 emit_json_compact(&output)
382}
383
384fn run_abandon(args: EmbeddingAbandonArgs) -> Result<(), AppError> {
385 let start = std::time::Instant::now();
386 let (_paths, conn) = open_conn(args.db.as_deref())?;
387 pending_embeddings::abandon(&conn, args.pending_id)?;
388 let output = EmbeddingAbandonOutput {
389 action: "embedding_abandon",
390 pending_id: args.pending_id,
391 status: PendingEmbeddingStatus::Abandoned.as_str(),
392 elapsed_ms: start.elapsed().as_millis() as u64,
393 yes: args.yes,
394 };
395 emit_json_compact(&output)
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
405 fn embedding_status_output_includes_coverage() {
406 let output = EmbeddingStatusOutput {
407 action: "embedding_status",
408 backend_invoked: "openrouter",
409 counts: EmbeddingStatusCounts::default(),
410 coverage: EmbeddingCoverage {
411 memories_total: 10,
412 memories_with_vec: 9,
413 memories_missing: 1,
414 entities_total: 4,
415 entities_with_vec: 4,
416 entities_missing: 0,
417 chunks_total: 7,
418 chunks_with_vec: 7,
419 chunks_missing: 0,
420 },
421 elapsed_ms: 1,
422 };
423 let json = serde_json::to_value(&output).expect("serialize");
424 assert_eq!(json["coverage"]["memories_total"], 10);
425 assert_eq!(json["coverage"]["memories_with_vec"], 9);
426 assert_eq!(json["coverage"]["entities_with_vec"], 4);
427 assert_eq!(json["coverage"]["chunks_with_vec"], 7);
428 assert_eq!(json["coverage"]["memories_missing"], 1);
430 assert_eq!(json["coverage"]["entities_missing"], 0);
431 assert_eq!(json["coverage"]["chunks_missing"], 0);
432 }
433
434 #[test]
437 fn count_missing_counts_gaps_and_falls_back_when_table_absent() {
438 let conn = rusqlite::Connection::open_in_memory().unwrap();
439 conn.execute_batch(
440 "CREATE TABLE entities (id INTEGER PRIMARY KEY, name TEXT);
441 CREATE TABLE entity_embeddings (
442 entity_id INTEGER PRIMARY KEY,
443 embedding BLOB NOT NULL
444 );",
445 )
446 .unwrap();
447 conn.execute(
448 "INSERT INTO entities (id, name) VALUES (1, 'a'), (2, 'b'), (3, 'c')",
449 [],
450 )
451 .unwrap();
452 conn.execute(
453 "INSERT INTO entity_embeddings (entity_id, embedding) VALUES (1, X'00')",
454 [],
455 )
456 .unwrap();
457
458 let missing = count_missing(
459 &conn,
460 "SELECT COUNT(*) FROM entities e \
461 LEFT JOIN entity_embeddings ee ON ee.entity_id = e.id \
462 WHERE ee.entity_id IS NULL",
463 3,
464 );
465 assert_eq!(missing, 2, "2 of 3 entities lack a vector row");
466
467 let missing_absent = count_missing(
469 &conn,
470 "SELECT COUNT(*) FROM entities e \
471 LEFT JOIN chunk_embeddings ce ON ce.chunk_id = e.id \
472 WHERE ce.chunk_id IS NULL",
473 3,
474 );
475 assert_eq!(missing_absent, 3, "absent table must report all missing");
476 }
477
478 #[test]
479 fn status_filter_round_trip() {
480 for f in [
481 EmbeddingStatusFilter::Pending,
482 EmbeddingStatusFilter::InProgress,
483 EmbeddingStatusFilter::Done,
484 EmbeddingStatusFilter::Abandoned,
485 ] {
486 let s: PendingEmbeddingStatus = f.into();
487 assert_eq!(
488 s.as_str(),
489 match f {
490 EmbeddingStatusFilter::Pending => "pending",
491 EmbeddingStatusFilter::InProgress => "in_progress",
492 EmbeddingStatusFilter::Done => "done",
493 EmbeddingStatusFilter::Abandoned => "abandoned",
494 }
495 );
496 }
497 }
498}