1#![allow(unused_assignments)] use std::path::PathBuf;
7use std::time::Instant;
8
9use rusqlite::Connection;
10
11use super::args::{EnrichArgs, EnrichMode, EnrichOperation, ReEmbedTarget};
12use super::events::*;
13use super::extraction::find_codex_binary;
14use super::postprocess::take_enrich_backend;
15use super::queue::{
16 enqueue_candidate, item_type_for, item_type_for_key,
17 open_queue_db, reset_stale_processing_claims,
18 skipped_item_keys,
19};
20use super::scan::{
21 self as scan, count_operation_backlog,
22};
23use super::scheduler;
24use super::DEFAULT_RATE_LIMIT_WAIT;
25use crate::commands::ingest_claude::find_claude_binary;
26use crate::errors::AppError;
27use crate::output::emit_json_line as emit_json;
28use crate::paths::AppPaths;
29use crate::storage::connection::{ensure_db_ready, open_rw};
30
31pub fn run(
33 args: &EnrichArgs,
34 llm_backend: crate::cli::LlmBackendChoice,
35 embedding_backend: crate::cli::EmbeddingBackendChoice,
36) -> Result<(), AppError> {
37 if args.print_schema {
39 return crate::print_schema::emit(crate::print_schema::SchemaId::EnrichStatus);
40 }
41
42 if args.ops_gate {
44 for op in scheduler::gate_ops_order() {
45 let mut gate_args = args.clone();
46 gate_args.operation = Some(op);
47 gate_args.ops_gate = false; run(&gate_args, llm_backend, embedding_backend)?;
49 }
50 return Ok(());
51 }
52
53 validate_mode_conditional_flags_enrich(args)?;
56
57 if args.target != ReEmbedTarget::Memories
60 && !matches!(args.operation(), EnrichOperation::ReEmbed)
61 {
62 let target_label = match args.target {
63 ReEmbedTarget::Memories => "memories",
64 ReEmbedTarget::Entities => "entities",
65 ReEmbedTarget::Chunks => "chunks",
66 ReEmbedTarget::All => "all",
67 };
68 return Err(AppError::Validation(
69 crate::i18n::validation::reembed_target_only(target_label),
70 ));
71 }
72
73 if super::status::try_handle_maintenance(args)? {
74 return Ok(());
75 }
76
77 if args.mode() == EnrichMode::OpenRouter && !args.dry_run {
86 let model = args.openrouter_model.as_deref().ok_or_else(|| {
87 AppError::Validation(crate::i18n::validation::openrouter_model_required())
88 })?;
89 let resolved =
90 crate::config::resolve_api_key("openrouter", args.openrouter_api_key.as_deref())
91 .ok_or_else(|| {
92 AppError::Validation(crate::i18n::validation::openrouter_api_key_not_found())
93 })?;
94 crate::embedder::get_openrouter_chat_client(
95 resolved.value,
96 model,
97 args.openrouter_timeout,
98 )?;
99 }
100
101 let started = Instant::now();
102
103 let paths = AppPaths::resolve(args.db.as_deref())?;
104 ensure_db_ready(&paths)?;
105 let conn = open_rw(&paths.db)?;
106 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
107
108 let wait_secs = args.wait_job_singleton;
114 let force_flag = args.force_job_singleton;
115 let _singleton = crate::lock::acquire_job_singleton(
116 crate::lock::JobType::Enrich,
117 &namespace,
118 &paths.db,
119 wait_secs,
120 force_flag,
121 )?;
122
123 let provider_binary = if args.dry_run || matches!(args.operation(), EnrichOperation::ReEmbed) {
126 None
127 } else {
128 Some(match args.mode() {
129 EnrichMode::ClaudeCode => {
130 let bin = find_claude_binary(args.claude_binary.as_deref())?;
131 let version = crate::commands::claude_runner::validate_claude_version(&bin)?;
132 tracing::info!(target: "enrich", binary = %bin.display(), version = %version, "Claude Code binary validated");
133 emit_json(&PhaseEvent {
134 phase: "validate",
135 binary_path: bin.to_str(),
136 version: Some(&version),
137 items_total: None,
138 items_pending: None,
139 llm_parallelism: None,
140 });
141 bin
142 }
143 EnrichMode::Codex => {
144 let bin = find_codex_binary(args.codex_binary.as_deref())?;
145 emit_json(&PhaseEvent {
146 phase: "validate",
147 binary_path: bin.to_str(),
148 version: None,
149 items_total: None,
150 items_pending: None,
151 llm_parallelism: None,
152 });
153 bin
154 }
155 EnrichMode::Opencode => {
156 let bin = crate::commands::opencode_runner::find_opencode_binary_with_override(
157 args.opencode_binary.as_deref(),
158 )?;
159 emit_json(&PhaseEvent {
160 phase: "validate",
161 binary_path: bin.to_str(),
162 version: None,
163 items_total: None,
164 items_pending: None,
165 llm_parallelism: None,
166 });
167 bin
168 }
169 EnrichMode::OpenRouter => {
170 emit_json(&PhaseEvent {
175 phase: "validate",
176 binary_path: None,
177 version: None,
178 items_total: None,
179 items_pending: None,
180 llm_parallelism: None,
181 });
182 PathBuf::new()
183 }
184 })
185 };
186
187 if args.max_load_check && !args.dry_run && crate::system_load::is_system_saturated() {
191 let load = crate::system_load::load_average_one();
192 let n = crate::system_load::ncpus();
193 return Err(AppError::Validation(
194 crate::i18n::validation::system_load_exceeded(load, n),
195 ));
196 }
197
198 if args.preflight_check
205 && !args.dry_run
206 && !matches!(args.operation(), EnrichOperation::ReEmbed)
207 {
208 let preflight_result = run_preflight_probe(args);
209 match preflight_result {
210 PreflightOutcome::Healthy => {
211 tracing::info!(target: "enrich", mode = ?args.mode(), "preflight probe healthy");
212 }
213 PreflightOutcome::RateLimited { reason, suggestion } => {
214 if let Some(fallback) = args.fallback_mode.clone() {
215 if fallback != args.mode() {
216 return Err(AppError::Validation(
226 crate::i18n::validation::preflight_rate_limit_fallback(
227 &format!("{:?}", args.mode()),
228 &reason,
229 &format!("{fallback:?}"),
230 ),
231 ));
232 }
233 return Err(AppError::Validation(
234 crate::i18n::validation::preflight_rate_limit_same_mode(
235 &format!("{:?}", args.mode()),
236 &reason,
237 ),
238 ));
239 }
240 return Err(AppError::Validation(
241 crate::i18n::validation::preflight_rate_limit_suggestion(
242 &format!("{:?}", args.mode()),
243 &reason,
244 suggestion,
245 ),
246 ));
247 }
248 PreflightOutcome::Error(e) => {
249 return Err(e);
250 }
251 }
252 }
253
254 let max_runtime_secs = args.max_runtime.unwrap_or(3600);
260 let until_deadline = Instant::now() + std::time::Duration::from_secs(max_runtime_secs);
261 let pair_scan_ops = matches!(
262 args.operation(),
263 EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges
264 );
265 const ENTITY_CONNECT_SCAN_SOFT_CEILING_SECS: u64 = 120;
267 let scan_deadline = if pair_scan_ops {
268 let soft =
269 Instant::now() + std::time::Duration::from_secs(ENTITY_CONNECT_SCAN_SOFT_CEILING_SECS);
270 Some(soft.min(until_deadline))
271 } else if args.until_empty || args.max_runtime.is_some() {
272 Some(until_deadline)
273 } else {
274 None
275 };
276
277 let pair_op_cli = enrich_operation_cli_name(&args.operation());
278 let mut backlog_degree0_proxy: Option<i64> = None;
279 if pair_scan_ops {
280 let entities_in_namespace: i64 = conn
281 .query_row(
282 "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
283 rusqlite::params![namespace],
284 |r| r.get(0),
285 )
286 .unwrap_or(0);
287 backlog_degree0_proxy =
289 count_operation_backlog(&conn, &args.operation(), &namespace, args.target).ok();
290 emit_json(&ScanStartEvent {
291 phase: "scan_start",
292 operation: pair_op_cli,
293 entities_in_namespace,
294 backlog_degree0_proxy,
295 pair_algorithm: Some("cooccurrence+hub_island"),
296 limit: args.limit,
297 scan_deadline_secs: scan_deadline
298 .map(|d| d.saturating_duration_since(Instant::now()).as_secs()),
299 });
300 }
301
302 let scan_started = Instant::now();
304 let mut scan_result = scan_operation_with_deadline(&conn, &namespace, args, scan_deadline)?;
305 if matches!(args.operation(), EnrichOperation::BodyEnrich) {
314 let q_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
315 if let Ok(q) = open_queue_db(&q_path) {
316 if let Ok(vetoed) = skipped_item_keys(&q, &format!("{:?}", args.operation())) {
317 scan_result.retain(|k| !vetoed.contains(k));
318 }
319 }
320 }
321 let total = scan_result.len();
322 let scan_elapsed_ms = scan_started.elapsed().as_millis() as u64;
323
324 emit_json(&PhaseEvent {
325 phase: "scan",
326 binary_path: None,
327 version: None,
328 items_total: Some(total),
329 items_pending: Some(total),
330 llm_parallelism: Some(args.llm_parallelism),
331 });
332 if pair_scan_ops {
333 emit_json(&serde_json::json!({
334 "phase": "scan_meta",
335 "operation": pair_op_cli,
336 "pair_algorithm": "cooccurrence+hub_island",
337 "items_total": total,
338 "pairs_enqueued_this_scan": total,
339 "backlog_degree0_proxy": backlog_degree0_proxy,
340 "scan_elapsed_ms": scan_elapsed_ms,
341 "scan_aborted_reason": serde_json::Value::Null,
342 }));
343 }
344
345 if args.dry_run {
347 if total == 0 {
350 let name_filter = scan::resolve_name_filter(args).unwrap_or_default();
351 if !name_filter.is_empty() {
352 emit_json(&serde_json::json!({
353 "matched": 0,
354 "hint": "no candidates matched --names/--entity-names/--memory-names for this operation; verify name space (entity vs memory) and predicates (e.g. empty description unless --force-redescribe)",
355 "operation": format!("{:?}", args.operation()),
356 "names_requested": name_filter,
357 }));
358 }
359 }
360 for (idx, key) in scan_result.iter().enumerate() {
361 emit_json(&ItemEvent {
362 item: key,
363 status: "preview",
364 memory_id: None,
365 entity_id: None,
366 entities: None,
367 rels: None,
368 chars_before: None,
369 chars_after: None,
370 cost_usd: None,
371 elapsed_ms: None,
372 error: None,
373 index: idx,
374 total,
375 });
376 }
377 emit_json(&EnrichSummary {
378 summary: true,
379 operation: format!("{:?}", args.operation()),
380 items_total: total,
381 completed: 0,
382 failed: 0,
383 skipped: 0,
384 cost_usd: 0.0,
385 elapsed_ms: started.elapsed().as_millis() as u64,
386 backend_invoked: take_enrich_backend(),
387 waiting: 0,
388 dead: 0,
389 budget_exhausted: None,
390 pairs_remaining_estimate: None,
391 yields: None,
392 preempted_for_gate: None,
393 });
394 return Ok(());
395 }
396
397 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
403 let mut queue_conn = open_queue_db(&queue_path)?;
404
405 {
410 let stale_reset = reset_stale_processing_claims(&queue_conn, args.stale_claim_secs)?;
411 if stale_reset > 0 {
412 tracing::info!(
413 target: "enrich",
414 count = stale_reset,
415 max_age_secs = args.stale_claim_secs,
416 "reset stale processing claims (older than threshold)"
417 );
418 }
419 }
420
421 if args.resume {
422 let reset = queue_conn
423 .execute(
424 "UPDATE queue SET status='pending' WHERE status='processing'",
425 [],
426 )
427 .map_err(|e| {
428 AppError::Validation(crate::i18n::validation::queue_resume_failed(&e))
429 })?;
430 if reset > 0 {
431 tracing::info!(target: "enrich", count = reset, "reset stuck processing items to pending");
432 }
433 }
434
435 if args.retry_failed {
436 let count = queue_conn
437 .execute(
438 "UPDATE queue SET status='pending', attempt=0 WHERE status='failed'",
439 [],
440 )
441 .map_err(|e| {
442 AppError::Validation(crate::i18n::validation::queue_retry_failed_reset_failed(&e))
443 })?;
444 tracing::info!(target: "enrich", count, "retrying failed items");
445 }
446
447 let op_label = format!("{:?}", args.operation());
450 if !args.resume && !args.retry_failed && !args.until_empty {
451 queue_conn
452 .execute(
453 "DELETE FROM queue WHERE operation = ?1 \
454 AND (namespace = ?2 OR namespace = '' OR namespace IS NULL)",
455 rusqlite::params![op_label, namespace],
456 )
457 .map_err(|e| {
458 AppError::Validation(crate::i18n::validation::queue_clear_failed(&e))
459 })?;
460 }
461
462 let item_type = item_type_for(&args.operation());
468 {
469 let tx = queue_conn.transaction()?;
470 let tx_conn: &Connection = &tx;
473 for key in scan_result.iter() {
474 let it = item_type_for_key(key, item_type);
478 enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
479 }
480 tx.commit()?;
481 }
482
483 let parallelism = super::events::resolve_drain_parallelism(args);
484
485 let mut completed = 0usize;
486 #[allow(unused_assignments)]
487 let mut failed = 0usize;
488 #[allow(unused_assignments)]
489 let mut skipped = 0usize;
490 #[allow(unused_assignments)]
491 let mut cost_total = 0.0f64;
492 #[allow(unused_assignments, unused_variables)]
493 let mut oauth_detected = false;
494 let mut counters = super::drain_parallel::DrainCounters::default();
495 let backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
496 let rate_limit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3600);
497 let enrich_started = std::time::Instant::now();
498
499 let provider_timeout = match args.mode() {
500 EnrichMode::ClaudeCode => args.claude_timeout,
501 EnrichMode::Codex => args.codex_timeout,
502 EnrichMode::Opencode => args.opencode_timeout,
503 EnrichMode::OpenRouter => args.openrouter_timeout,
504 };
505
506 let provider_model: Option<&str> = match args.mode() {
507 EnrichMode::ClaudeCode => args.claude_model.as_deref(),
508 EnrichMode::Codex => args.codex_model.as_deref(),
509 EnrichMode::Opencode => args.opencode_model.as_deref(),
510 EnrichMode::OpenRouter => args.openrouter_model.as_deref(),
511 };
512
513 let backoff_clause: &str = if args.ignore_backoff {
517 ""
518 } else {
519 "AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))"
520 };
521
522 emit_json(&ConcurrencyEvent {
525 phase: "concurrency",
526 scan_parallelism: 1,
527 drain_parallelism: parallelism as u32,
528 });
529
530 let mut until_empty_iter: u32 = 0;
538 let yield_every = scheduler::resolve_yield_every_n(args.yield_every_n_items);
539 let mut yield_count: u64 = 0;
540 let mut items_since_yield: usize = 0;
541 let mut preempted_for_gate = false;
543 loop {
546 if args.until_empty {
547 until_empty_iter = until_empty_iter.saturating_add(1);
548 if until_empty_iter > 1 {
549 let mut rescan =
553 scan_operation_with_deadline(&conn, &namespace, args, Some(until_deadline))?;
554 if matches!(args.operation(), EnrichOperation::BodyEnrich) {
559 if let Ok(vetoed) = skipped_item_keys(&queue_conn, &op_label) {
560 rescan.retain(|k| !vetoed.contains(k));
561 }
562 }
563 {
565 let tx = queue_conn.transaction()?;
566 let tx_conn: &Connection = &tx;
567 for key in &rescan {
568 let it = item_type_for_key(key, item_type);
569 enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
570 }
571 tx.commit()?;
572 }
573 }
574 }
575 let completed_before = completed;
576
577 if parallelism > 1 {
581 super::drain_parallel::drain_parallel(
582 args,
583 &paths,
584 &queue_path,
585 &namespace,
586 provider_binary.as_deref(),
587 provider_model,
588 provider_timeout,
589 &op_label,
590 backoff_clause,
591 parallelism,
592 total,
593 llm_backend,
594 embedding_backend,
595 &mut counters,
596 )?;
597 completed = counters.completed;
598 failed = counters.failed;
599 skipped = counters.skipped;
600 cost_total = counters.cost_total;
601 oauth_detected = counters.oauth_detected;
602 } else {
603 super::drain_serial::drain_serial(
604 args,
605 &paths,
606 &conn,
607 &queue_conn,
608 &namespace,
609 provider_binary.as_deref(),
610 provider_model,
611 provider_timeout,
612 &op_label,
613 backoff_clause,
614 item_type,
615 total,
616 llm_backend,
617 embedding_backend,
618 yield_every,
619 &mut counters,
620 &mut items_since_yield,
621 &mut yield_count,
622 &mut preempted_for_gate,
623 enrich_started,
624 until_deadline,
625 rate_limit_deadline,
626 backoff_secs,
627 )?;
628 completed = counters.completed;
629 failed = counters.failed;
630 skipped = counters.skipped;
631 cost_total = counters.cost_total;
632 oauth_detected = counters.oauth_detected;
633 }
634
635 if !args.until_empty {
636 break;
637 }
638 let eligible_remaining: i64 = queue_conn
639 .query_row(
640 &format!("SELECT COUNT(*) FROM queue WHERE status='pending' {backoff_clause}"),
641 [],
642 |r| r.get(0),
643 )
644 .unwrap_or(0);
645 let progressed = completed > completed_before;
646 if std::time::Instant::now() >= until_deadline {
647 tracing::info!(target: "enrich", "until-empty: max-runtime reached, stopping");
648 break;
649 }
650 if !progressed && eligible_remaining == 0 {
651 tracing::info!(target: "enrich", "until-empty: converged (no eligible items remain)");
652 break;
653 }
654 if eligible_remaining == 0 {
655 std::thread::sleep(std::time::Duration::from_secs(1));
657 }
658 } if crate::shutdown_requested() {
668 let reset = queue_conn
669 .execute(
670 "UPDATE queue SET status='pending', claimed_at=NULL WHERE status='processing'",
671 [],
672 )
673 .unwrap_or(0);
674 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
675 tracing::info!(
676 target: "enrich",
677 reset,
678 "graceful shutdown: WAL checkpointed, processing claims reset"
679 );
680 }
681
682 let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
683 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
684
685 let waiting_final: i64 = queue_conn
689 .query_row(
690 "SELECT COUNT(*) FROM queue WHERE status='pending' \
691 AND (operation = ?1 OR operation IS NULL) \
692 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
693 rusqlite::params![op_label],
694 |r| r.get(0),
695 )
696 .unwrap_or(0);
697 let dead_final: i64 = queue_conn
698 .query_row(
699 "SELECT COUNT(*) FROM queue WHERE status='dead' \
700 AND (operation = ?1 OR operation IS NULL)",
701 rusqlite::params![op_label],
702 |r| r.get(0),
703 )
704 .unwrap_or(0);
705
706 emit_json(&EnrichSummary {
707 summary: true,
708 operation: format!("{:?}", args.operation()),
709 items_total: total,
710 completed,
711 failed,
712 skipped,
713 cost_usd: cost_total,
714 elapsed_ms: started.elapsed().as_millis() as u64,
715 backend_invoked: take_enrich_backend(),
716 waiting: waiting_final,
717 dead: dead_final,
718 budget_exhausted: if pair_scan_ops && std::time::Instant::now() >= until_deadline {
719 Some(true)
720 } else {
721 None
722 },
723 pairs_remaining_estimate: backlog_degree0_proxy,
724 yields: if yield_count > 0 { Some(yield_count) } else { None },
725 preempted_for_gate: if preempted_for_gate {
726 Some(true)
727 } else {
728 None
729 },
730 });
731
732 if failed == 0 {
733 let dead: i64 = queue_conn
736 .query_row("SELECT COUNT(*) FROM queue WHERE status='dead'", [], |r| {
737 r.get(0)
738 })
739 .unwrap_or(0);
740 let skipped_remaining: i64 = queue_conn
746 .query_row(
747 "SELECT COUNT(*) FROM queue WHERE status='skipped'",
748 [],
749 |r| r.get(0),
750 )
751 .unwrap_or(0);
752 if dead == 0 && skipped_remaining == 0 {
753 let _ = std::fs::remove_file(&queue_path);
754 }
755 }
756
757 Ok(())
758}
759
760