1#![cfg_attr(not(feature = "sal"), allow(dead_code, unused_imports))]
13
14use crate::cli::CliOutput;
15use crate::curator::reflection_pass;
16use crate::identity::keypair as identity_keypair;
17use crate::{autonomy, config, curator, db, llm};
18use anyhow::{Context, Result};
19use clap::Args;
20use std::path::Path;
21
22#[derive(Args)]
23#[allow(clippy::struct_excessive_bools)]
24pub struct CuratorArgs {
25 #[arg(long, conflicts_with = "daemon")]
27 pub once: bool,
28 #[arg(long)]
31 pub daemon: bool,
32 #[arg(long, default_value_t = crate::SECS_PER_HOUR as u64)]
34 pub interval_secs: u64,
35 #[arg(long, default_value_t = 100)]
37 pub max_ops: usize,
38 #[arg(long)]
40 pub dry_run: bool,
41 #[arg(long = "include-namespace")]
43 pub include_namespaces: Vec<String>,
44 #[arg(long = "exclude-namespace")]
46 pub exclude_namespaces: Vec<String>,
47 #[arg(long)]
49 pub json: bool,
50 #[arg(long, conflicts_with_all = ["once", "daemon"])]
54 pub rollback: Option<String>,
55 #[arg(long)]
58 pub rollback_last: Option<usize>,
59 #[arg(long, conflicts_with_all = ["once", "daemon", "rollback", "rollback_last"])]
65 pub reflect: bool,
66 #[arg(long)]
69 pub namespace: Option<String>,
70 #[arg(long)]
76 pub max_depth: Option<u32>,
77 #[arg(long)]
81 pub all_namespaces: bool,
82 #[cfg(feature = "sal")]
101 #[arg(long, value_name = "URL")]
102 pub store_url: Option<String>,
103}
104
105fn build_curator_llm(tier: config::FeatureTier) -> Option<llm::OllamaClient> {
113 let app_config = config::AppConfig::load();
125 let resolved = app_config.resolve_llm(None, None, None);
126 if matches!(resolved.source, config::ConfigSource::CompiledDefault)
127 && tier.config().llm_model.is_none()
128 {
129 return None;
130 }
131 llm::OllamaClient::build_from_resolved(&resolved)
132 .ok()
133 .flatten()
134}
135
136fn print_curator_report(r: &curator::CuratorReport, out: &mut CliOutput<'_>) -> Result<()> {
137 writeln!(out.stdout, "curator cycle report")?;
138 writeln!(out.stdout, " started_at: {}", r.started_at)?;
139 writeln!(out.stdout, " completed_at: {}", r.completed_at)?;
140 writeln!(out.stdout, " duration_ms: {}", r.cycle_duration_ms)?;
141 writeln!(out.stdout, " memories_scanned: {}", r.memories_scanned)?;
142 writeln!(out.stdout, " memories_eligible: {}", r.memories_eligible)?;
143 writeln!(
144 out.stdout,
145 " operations: {}",
146 r.operations_attempted
147 )?;
148 writeln!(out.stdout, " auto_tagged: {}", r.auto_tagged)?;
149 writeln!(
150 out.stdout,
151 " contradictions: {}",
152 r.contradictions_found
153 )?;
154 writeln!(
155 out.stdout,
156 " skipped (cap): {}",
157 r.operations_skipped_cap
158 )?;
159 writeln!(out.stdout, " errors: {}", r.errors.len())?;
160 writeln!(out.stdout, " dry_run: {}", r.dry_run)?;
161 for e in &r.errors {
162 writeln!(out.stdout, " - {e}")?;
163 }
164 Ok(())
165}
166
167pub async fn run(
169 db_path: &Path,
170 args: &CuratorArgs,
171 app_config: &config::AppConfig,
172 out: &mut CliOutput<'_>,
173) -> Result<()> {
174 if args.rollback.is_some() || args.rollback_last.is_some() {
175 return run_rollback(db_path, args, out);
176 }
177
178 if args.reflect {
179 return run_reflect(db_path, args, app_config, out).await;
180 }
181
182 if !args.once && !args.daemon {
183 anyhow::bail!(
184 "curator requires --once, --daemon, --reflect, --rollback <id>, or --rollback-last N"
185 );
186 }
187
188 #[cfg(feature = "sal")]
195 if curator_store_url(args).is_some() {
196 return run_store_backed_sweep(db_path, args, app_config, out).await;
197 }
198
199 let cfg = curator::CuratorConfig {
200 interval_secs: args.interval_secs,
201 max_ops_per_cycle: args.max_ops,
202 dry_run: args.dry_run,
203 include_namespaces: args.include_namespaces.clone(),
204 exclude_namespaces: args.exclude_namespaces.clone(),
205 compaction: curator::CompactionConfig::default(),
206 };
207
208 let feature_tier = app_config.effective_tier(None);
209 let llm = build_curator_llm(feature_tier);
210
211 if args.once {
212 let conn = db::open(db_path)?;
213 let report = curator::run_once(&conn, llm.as_ref(), &cfg, None)?;
214 if args.json {
215 writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
216 } else {
217 print_curator_report(&report, out)?;
218 }
219 return Ok(());
220 }
221
222 let shutdown = std::sync::Arc::new(tokio::sync::Notify::new());
224 let shutdown_for_signal = shutdown.clone();
225 tokio::spawn(async move {
226 let _ = tokio::signal::ctrl_c().await;
227 shutdown_for_signal.notify_one();
228 });
229
230 crate::daemon_runtime::run_curator_daemon_with_primitives(
237 db_path.to_path_buf(),
238 args.interval_secs,
239 args.max_ops,
240 args.dry_run,
241 args.include_namespaces.clone(),
242 args.exclude_namespaces.clone(),
243 llm.map(std::sync::Arc::new),
244 shutdown,
245 )
246 .await
247}
248
249#[must_use]
256fn curator_store_url(args: &CuratorArgs) -> Option<&str> {
257 #[cfg(feature = "sal")]
258 {
259 args.store_url.as_deref()
260 }
261 #[cfg(not(feature = "sal"))]
262 {
263 let _ = args;
264 None
265 }
266}
267
268#[cfg(feature = "sal")]
283async fn run_store_backed_sweep(
284 db_path: &Path,
285 args: &CuratorArgs,
286 app_config: &config::AppConfig,
287 out: &mut CliOutput<'_>,
288) -> Result<()> {
289 let store =
290 crate::daemon_runtime::build_curator_store(curator_store_url(args), db_path, app_config)
291 .await?;
292
293 let keypair = load_curator_keypair_best_effort();
294 let feature_tier = app_config.effective_tier(None);
295 let llm = build_curator_llm(feature_tier);
296
297 if args.once {
298 let report = store_backed_reflection_sweep(
299 store.as_ref(),
300 llm.as_ref().map(|c| c as &dyn crate::autonomy::AutonomyLlm),
301 keypair.as_ref(),
302 args,
303 )
304 .await;
305 if args.json {
306 writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
307 } else {
308 print_reflection_report(&report, out)?;
309 }
310 return Ok(());
311 }
312
313 let shutdown = std::sync::Arc::new(tokio::sync::Notify::new());
317 let shutdown_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
318 let shutdown_for_signal = shutdown.clone();
319 let flag_for_signal = shutdown_flag.clone();
320 tokio::spawn(async move {
321 let _ = tokio::signal::ctrl_c().await;
322 flag_for_signal.store(true, std::sync::atomic::Ordering::Relaxed);
323 shutdown_for_signal.notify_one();
324 });
325
326 let interval_secs = args.interval_secs.clamp(60, crate::SECS_PER_DAY as u64);
330 tracing::info!(
331 "curator SAL daemon started (store-url backend, interval={interval_secs}s, \
332 max_ops={}, dry_run={})",
333 args.max_ops,
334 args.dry_run,
335 );
336
337 while !shutdown_flag.load(std::sync::atomic::Ordering::Relaxed) {
338 let report = store_backed_reflection_sweep(
339 store.as_ref(),
340 llm.as_ref().map(|c| c as &dyn crate::autonomy::AutonomyLlm),
341 keypair.as_ref(),
342 args,
343 )
344 .await;
345 tracing::info!(
346 "curator SAL cycle: namespaces={} observations={} clusters_eligible={} \
347 reflections_persisted={} depth_refusals={} errors={} (dry_run={})",
348 report.namespaces_visited,
349 report.observations_scanned,
350 report.clusters_eligible,
351 report.reflections_persisted,
352 report.depth_refusals,
353 report.errors.len(),
354 report.dry_run,
355 );
356
357 tokio::select! {
359 () = tokio::time::sleep(std::time::Duration::from_secs(interval_secs)) => {}
360 () = shutdown.notified() => break,
361 }
362 }
363
364 tracing::info!("curator SAL daemon shutdown");
365 Ok(())
366}
367
368#[cfg(feature = "sal")]
380async fn store_backed_reflection_sweep(
381 store: &dyn crate::store::MemoryStore,
382 llm: Option<&dyn crate::autonomy::AutonomyLlm>,
383 keypair: Option<&identity_keypair::AgentKeypair>,
384 args: &CuratorArgs,
385) -> reflection_pass::ReflectionPassReport {
386 run_reflection_pass_with_optional_llm(
389 store,
390 llm,
391 keypair,
392 None,
393 args.max_depth,
394 args.dry_run,
395 |_ns: &str| true,
396 )
397 .await
398}
399
400#[cfg(feature = "sal")]
409async fn run_reflection_pass_with_optional_llm(
410 store: &dyn crate::store::MemoryStore,
411 llm: Option<&dyn crate::autonomy::AutonomyLlm>,
412 keypair: Option<&identity_keypair::AgentKeypair>,
413 namespace: Option<&str>,
414 max_depth: Option<u32>,
415 dry_run: bool,
416 enabled_check: impl Fn(&str) -> bool,
417) -> reflection_pass::ReflectionPassReport {
418 let stamp = || chrono::Utc::now().to_rfc3339();
419 let Some(llm_client) = llm else {
420 let mut empty = reflection_pass::ReflectionPassReport {
421 started_at: stamp(),
422 completed_at: stamp(),
423 dry_run,
424 ..Default::default()
425 };
426 empty.errors.push(
427 "no LLM client configured — set a feature tier that provides an llm_model".into(),
428 );
429 return empty;
430 };
431 match reflection_pass::run_reflection_pass(
432 store,
433 llm_client,
434 keypair,
435 namespace,
436 max_depth,
437 dry_run,
438 enabled_check,
439 )
440 .await
441 {
442 Ok(report) => report,
443 Err(e) => {
444 let mut report = reflection_pass::ReflectionPassReport {
445 started_at: stamp(),
446 completed_at: stamp(),
447 dry_run,
448 ..Default::default()
449 };
450 report.errors.push(format!("reflection pass failed: {e}"));
451 report
452 }
453 }
454}
455
456#[cfg(feature = "sal")]
484async fn run_reflect(
485 db_path: &Path,
486 args: &CuratorArgs,
487 app_config: &config::AppConfig,
488 out: &mut CliOutput<'_>,
489) -> Result<()> {
490 if args.namespace.is_none() && !args.all_namespaces {
491 anyhow::bail!("--reflect requires either --namespace <ns> or --all-namespaces");
492 }
493 if args.namespace.is_some() && args.all_namespaces {
494 anyhow::bail!("--reflect: --namespace and --all-namespaces are mutually exclusive");
495 }
496
497 let store = build_reflect_store(db_path, args, app_config).await?;
503
504 let keypair = load_curator_keypair_best_effort();
512
513 let feature_tier = app_config.effective_tier(None);
514 let llm = build_curator_llm(feature_tier);
515
516 let scope_single = args.namespace.is_some();
524 let enabled_check =
525 |ns: &str| -> bool { scope_single || app_config.reflection_namespace_enabled(ns) };
526
527 let report = run_reflection_pass_with_optional_llm(
528 store.as_ref(),
529 llm.as_ref().map(|c| c as &dyn crate::autonomy::AutonomyLlm),
530 keypair.as_ref(),
531 args.namespace.as_deref(),
532 args.max_depth,
533 args.dry_run,
534 enabled_check,
535 )
536 .await;
537
538 if args.json {
539 writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
540 } else {
541 print_reflection_report(&report, out)?;
542 }
543 Ok(())
544}
545
546#[cfg(not(feature = "sal"))]
551#[allow(clippy::unused_async)]
552async fn run_reflect(
553 _db_path: &Path,
554 _args: &CuratorArgs,
555 _app_config: &config::AppConfig,
556 _out: &mut CliOutput<'_>,
557) -> Result<()> {
558 anyhow::bail!(
559 "curator --reflect requires a binary built with --features sal \
560 (the reflection pass operates over the SAL MemoryStore trait)"
561 )
562}
563
564#[cfg(feature = "sal")]
569async fn build_reflect_store(
570 db_path: &Path,
571 args: &CuratorArgs,
572 app_config: &config::AppConfig,
573) -> Result<std::sync::Arc<dyn crate::store::MemoryStore>> {
574 crate::daemon_runtime::build_curator_store(curator_store_url(args), db_path, app_config).await
575}
576
577#[cfg_attr(not(feature = "sal"), allow(dead_code))]
583fn load_curator_keypair_best_effort() -> Option<identity_keypair::AgentKeypair> {
584 let dir = identity_keypair::default_key_dir().ok()?;
585 let listed = identity_keypair::list(&dir).ok()?;
591 let first = listed.into_iter().next()?;
592 identity_keypair::load(&first.agent_id, &dir).ok()
593}
594
595#[cfg_attr(not(feature = "sal"), allow(dead_code))]
596fn print_reflection_report(
597 r: &reflection_pass::ReflectionPassReport,
598 out: &mut CliOutput<'_>,
599) -> Result<()> {
600 writeln!(out.stdout, "reflection pass report")?;
601 writeln!(out.stdout, " started_at: {}", r.started_at)?;
602 writeln!(out.stdout, " completed_at: {}", r.completed_at)?;
603 writeln!(
604 out.stdout,
605 " namespaces_visited: {}",
606 r.namespaces_visited
607 )?;
608 writeln!(
609 out.stdout,
610 " observations_scanned: {}",
611 r.observations_scanned
612 )?;
613 writeln!(out.stdout, " clusters_formed: {}", r.clusters_formed)?;
614 writeln!(
615 out.stdout,
616 " clusters_eligible: {}",
617 r.clusters_eligible
618 )?;
619 writeln!(
620 out.stdout,
621 " reflections_persisted: {}",
622 r.reflections_persisted
623 )?;
624 writeln!(out.stdout, " depth_refusals: {}", r.depth_refusals)?;
625 writeln!(out.stdout, " errors: {}", r.errors.len())?;
626 writeln!(out.stdout, " dry_run: {}", r.dry_run)?;
627 for e in &r.errors {
628 writeln!(out.stdout, " - {e}")?;
629 }
630 for prop in &r.dry_run_proposals {
631 writeln!(
632 out.stdout,
633 " proposal: ns='{}' title='{}' sources={}",
634 prop.namespace,
635 prop.proposed_title,
636 prop.source_ids.len()
637 )?;
638 }
639 Ok(())
640}
641
642fn run_rollback(db_path: &Path, args: &CuratorArgs, out: &mut CliOutput<'_>) -> Result<()> {
643 let conn = db::open(db_path)?;
644
645 if let Some(id) = &args.rollback {
646 let Some(mem) = db::get(&conn, id)? else {
647 anyhow::bail!("rollback entry {id} not found");
648 };
649 let entry: autonomy::RollbackEntry = serde_json::from_str(&mem.content)
650 .context("rollback entry content is not a valid RollbackEntry JSON")?;
651 let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
652 let mut tags = mem.tags.clone();
653 if !tags.iter().any(|t| t == "_reversed") {
654 tags.push("_reversed".to_string());
655 db::update(
656 &conn,
657 &mem.id,
658 None,
659 None,
660 None,
661 None,
662 Some(&tags),
663 None,
664 None,
665 None,
666 None,
667 )?;
668 }
669 writeln!(
670 out.stdout,
671 "rollback {id}: {}",
672 if applied { "applied" } else { "no-op" }
673 )?;
674 return Ok(());
675 }
676
677 if let Some(n) = args.rollback_last {
678 let log = db::list(
679 &conn,
680 Some("_curator/rollback"),
681 None,
682 n.max(1),
683 0,
684 None,
685 None,
686 None,
687 None,
688 None,
689 )?;
690 let mut reversed = 0usize;
691 for mem in &log {
692 if mem.tags.iter().any(|t| t == "_reversed") {
693 continue;
694 }
695 let Ok(entry) = serde_json::from_str::<autonomy::RollbackEntry>(&mem.content) else {
696 continue;
697 };
698 let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
699 if applied {
700 reversed += 1;
701 let mut tags = mem.tags.clone();
702 tags.push("_reversed".to_string());
703 db::update(
704 &conn,
705 &mem.id,
706 None,
707 None,
708 None,
709 None,
710 Some(&tags),
711 None,
712 None,
713 None,
714 None,
715 )?;
716 }
717 }
718 writeln!(out.stdout, "reversed {reversed} rollback entries")?;
719 return Ok(());
720 }
721
722 anyhow::bail!("run_rollback entered without --rollback or --rollback-last");
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use crate::cli::test_utils::TestEnv;
735
736 fn default_args() -> CuratorArgs {
737 CuratorArgs {
738 once: false,
739 daemon: false,
740 interval_secs: crate::SECS_PER_HOUR as u64,
741 max_ops: 100,
742 dry_run: false,
743 include_namespaces: Vec::new(),
744 exclude_namespaces: Vec::new(),
745 json: false,
746 rollback: None,
747 rollback_last: None,
748 reflect: false,
749 namespace: None,
750 max_depth: None,
751 all_namespaces: false,
752 #[cfg(feature = "sal")]
753 store_url: None,
754 }
755 }
756
757 #[tokio::test]
758 async fn test_curator_requires_mode() {
759 let mut env = TestEnv::fresh();
760 let db = env.db_path.clone();
761 let cfg = config::AppConfig::default();
762 let args = default_args();
763 let mut out = env.output();
764 let res = run(&db, &args, &cfg, &mut out).await;
765 assert!(res.is_err());
766 assert!(
767 res.unwrap_err()
768 .to_string()
769 .contains("--once, --daemon, --reflect")
770 );
771 }
772
773 #[tokio::test]
774 async fn test_curator_once_runs_single_sweep_text() {
775 let mut env = TestEnv::fresh();
776 let db = env.db_path.clone();
777 let cfg = config::AppConfig::default();
778 let mut args = default_args();
779 args.once = true;
780 args.dry_run = true;
781 {
782 let mut out = env.output();
783 run(&db, &args, &cfg, &mut out).await.unwrap();
784 }
785 assert!(env.stdout_str().contains("curator cycle report"));
786 }
787
788 #[tokio::test]
789 async fn test_curator_once_json_format() {
790 let mut env = TestEnv::fresh();
791 let db = env.db_path.clone();
792 let cfg = config::AppConfig::default();
793 let mut args = default_args();
794 args.once = true;
795 args.json = true;
796 args.dry_run = true;
797 {
798 let mut out = env.output();
799 run(&db, &args, &cfg, &mut out).await.unwrap();
800 }
801 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
802 assert!(v["dry_run"].as_bool().unwrap());
803 }
804
805 #[tokio::test]
806 async fn test_curator_dry_run_skips_writes() {
807 let mut env = TestEnv::fresh();
808 let db = env.db_path.clone();
809 let cfg = config::AppConfig::default();
810 let mut args = default_args();
811 args.once = true;
812 args.dry_run = true;
813 {
814 let mut out = env.output();
815 run(&db, &args, &cfg, &mut out).await.unwrap();
816 }
817 let s = env.stdout_str();
819 assert!(s.contains("dry_run:") || s.contains("\"dry_run\""));
820 }
821
822 #[tokio::test]
823 async fn test_curator_include_namespaces_filter() {
824 let mut env = TestEnv::fresh();
825 let db = env.db_path.clone();
826 let cfg = config::AppConfig::default();
827 let mut args = default_args();
828 args.once = true;
829 args.dry_run = true;
830 args.include_namespaces = vec!["only-this-ns".to_string()];
831 {
832 let mut out = env.output();
833 run(&db, &args, &cfg, &mut out).await.unwrap();
834 }
835 assert!(env.stdout_str().contains("operations:"));
837 }
838
839 #[tokio::test]
840 async fn test_curator_exclude_namespaces_filter() {
841 let mut env = TestEnv::fresh();
842 let db = env.db_path.clone();
843 let cfg = config::AppConfig::default();
844 let mut args = default_args();
845 args.once = true;
846 args.dry_run = true;
847 args.exclude_namespaces = vec!["skip-me".to_string()];
848 {
849 let mut out = env.output();
850 run(&db, &args, &cfg, &mut out).await.unwrap();
851 }
852 assert!(env.stdout_str().contains("curator cycle report"));
853 }
854
855 #[tokio::test]
856 async fn test_curator_max_ops_cap_respected() {
857 let mut env = TestEnv::fresh();
858 let db = env.db_path.clone();
859 let cfg = config::AppConfig::default();
860 let mut args = default_args();
861 args.once = true;
862 args.dry_run = true;
863 args.max_ops = 0; {
865 let mut out = env.output();
866 run(&db, &args, &cfg, &mut out).await.unwrap();
867 }
868 assert!(env.stdout_str().contains("operations:"));
869 }
870
871 #[tokio::test]
872 async fn test_curator_rollback_id_not_found() {
873 let mut env = TestEnv::fresh();
874 let db = env.db_path.clone();
875 let cfg = config::AppConfig::default();
876 let mut args = default_args();
877 args.rollback = Some("00000000-0000-0000-0000-000000000000".to_string());
878 let mut out = env.output();
879 let res = run(&db, &args, &cfg, &mut out).await;
880 assert!(res.is_err());
881 assert!(res.unwrap_err().to_string().contains("rollback entry"));
882 }
883
884 #[tokio::test]
885 async fn test_curator_rollback_last_zero_entries() {
886 let mut env = TestEnv::fresh();
887 let db = env.db_path.clone();
888 let cfg = config::AppConfig::default();
889 let mut args = default_args();
890 args.rollback_last = Some(5);
891 {
892 let mut out = env.output();
893 run(&db, &args, &cfg, &mut out).await.unwrap();
894 }
895 assert!(env.stdout_str().contains("reversed 0"));
897 }
898
899 fn build_priority_rollback_entry_json(memory_id: &str, before: i32, after: i32) -> String {
905 serde_json::to_string(&autonomy::RollbackEntry::PriorityAdjust {
908 memory_id: memory_id.to_string(),
909 before,
910 after,
911 })
912 .unwrap()
913 }
914
915 fn seed_rollback_entry(db_path: &std::path::Path, content: &str) -> String {
916 let conn = db::open(db_path).expect("db::open");
919 let now = chrono::Utc::now().to_rfc3339();
920 let mut metadata = crate::models::default_metadata();
921 if let Some(obj) = metadata.as_object_mut() {
922 obj.insert(
923 "agent_id".to_string(),
924 serde_json::Value::String("test-agent".to_string()),
925 );
926 }
927 let mem = crate::models::Memory {
928 id: uuid::Uuid::new_v4().to_string(),
929 tier: crate::models::Tier::Mid,
930 namespace: "_curator/rollback".to_string(),
931 title: format!("rollback-{}", uuid::Uuid::new_v4()),
932 content: content.to_string(),
933 tags: vec![],
934 priority: 5,
935 confidence: 1.0,
936 source: "test".to_string(),
937 access_count: 0,
938 created_at: now.clone(),
939 updated_at: now,
940 last_accessed_at: None,
941 expires_at: None,
942 metadata,
943 reflection_depth: 0,
944 memory_kind: crate::models::MemoryKind::Observation,
945 entity_id: None,
946 persona_version: None,
947 citations: Vec::new(),
948 source_uri: None,
949 source_span: None,
950 confidence_source: crate::models::ConfidenceSource::CallerProvided,
951 confidence_signals: None,
952 confidence_decayed_at: None,
953 version: 1,
954 };
955 db::insert(&conn, &mem).expect("db::insert")
956 }
957
958 #[tokio::test]
959 async fn pr9i_curator_rollback_priority_adjust_applies() {
960 let mut env = TestEnv::fresh();
962 let db = env.db_path.clone();
963 let cfg = config::AppConfig::default();
964
965 let target = {
967 let conn = db::open(&db).unwrap();
968 let now = chrono::Utc::now().to_rfc3339();
969 let mut metadata = crate::models::default_metadata();
970 if let Some(obj) = metadata.as_object_mut() {
971 obj.insert(
972 "agent_id".to_string(),
973 serde_json::Value::String("test-agent".to_string()),
974 );
975 }
976 let mem = crate::models::Memory {
977 id: uuid::Uuid::new_v4().to_string(),
978 tier: crate::models::Tier::Mid,
979 namespace: "ns".to_string(),
980 title: "target".to_string(),
981 content: "c".to_string(),
982 tags: vec![],
983 priority: 7,
984 confidence: 1.0,
985 source: "test".to_string(),
986 access_count: 0,
987 created_at: now.clone(),
988 updated_at: now,
989 last_accessed_at: None,
990 expires_at: None,
991 metadata,
992 reflection_depth: 0,
993 memory_kind: crate::models::MemoryKind::Observation,
994 entity_id: None,
995 persona_version: None,
996 citations: Vec::new(),
997 source_uri: None,
998 source_span: None,
999 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1000 confidence_signals: None,
1001 confidence_decayed_at: None,
1002 version: 1,
1003 };
1004 db::insert(&conn, &mem).unwrap()
1005 };
1006
1007 let entry_json = build_priority_rollback_entry_json(&target, 3, 7);
1009 let entry_id = seed_rollback_entry(&db, &entry_json);
1010
1011 let mut args = default_args();
1013 args.rollback = Some(entry_id.clone());
1014 {
1015 let mut out = env.output();
1016 run(&db, &args, &cfg, &mut out).await.unwrap();
1017 }
1018 let s = env.stdout_str();
1020 assert!(s.contains(&format!("rollback {entry_id}")));
1021 assert!(s.contains("applied"));
1022
1023 let conn = db::open(&db).unwrap();
1025 let target_mem = db::get(&conn, &target).unwrap().unwrap();
1026 assert_eq!(target_mem.priority, 3);
1027
1028 let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
1030 assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
1031 }
1032
1033 #[tokio::test]
1034 async fn pr9i_curator_rollback_last_processes_multiple() {
1035 let mut env = TestEnv::fresh();
1036 let db = env.db_path.clone();
1037 let cfg = config::AppConfig::default();
1038
1039 let t1;
1041 let t2;
1042 {
1043 let conn = db::open(&db).unwrap();
1044 let now = chrono::Utc::now().to_rfc3339();
1045 let mut metadata = crate::models::default_metadata();
1046 if let Some(obj) = metadata.as_object_mut() {
1047 obj.insert(
1048 "agent_id".to_string(),
1049 serde_json::Value::String("test-agent".to_string()),
1050 );
1051 }
1052 let m1 = crate::models::Memory {
1053 id: uuid::Uuid::new_v4().to_string(),
1054 tier: crate::models::Tier::Mid,
1055 namespace: "ns".to_string(),
1056 title: "t1".to_string(),
1057 content: "c1".to_string(),
1058 tags: vec![],
1059 priority: 8,
1060 confidence: 1.0,
1061 source: "test".to_string(),
1062 access_count: 0,
1063 created_at: now.clone(),
1064 updated_at: now.clone(),
1065 last_accessed_at: None,
1066 expires_at: None,
1067 metadata: metadata.clone(),
1068 reflection_depth: 0,
1069 memory_kind: crate::models::MemoryKind::Observation,
1070 entity_id: None,
1071 persona_version: None,
1072 citations: Vec::new(),
1073 source_uri: None,
1074 source_span: None,
1075 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1076 confidence_signals: None,
1077 confidence_decayed_at: None,
1078 version: 1,
1079 };
1080 let m2 = crate::models::Memory {
1081 id: uuid::Uuid::new_v4().to_string(),
1082 tier: crate::models::Tier::Mid,
1083 namespace: "ns".to_string(),
1084 title: "t2".to_string(),
1085 content: "c2".to_string(),
1086 tags: vec![],
1087 priority: 9,
1088 confidence: 1.0,
1089 source: "test".to_string(),
1090 access_count: 0,
1091 created_at: now.clone(),
1092 updated_at: now,
1093 last_accessed_at: None,
1094 expires_at: None,
1095 metadata,
1096 reflection_depth: 0,
1097 memory_kind: crate::models::MemoryKind::Observation,
1098 entity_id: None,
1099 persona_version: None,
1100 citations: Vec::new(),
1101 source_uri: None,
1102 source_span: None,
1103 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1104 confidence_signals: None,
1105 confidence_decayed_at: None,
1106 version: 1,
1107 };
1108 t1 = db::insert(&conn, &m1).unwrap();
1109 t2 = db::insert(&conn, &m2).unwrap();
1110 }
1111
1112 seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t1, 4, 8));
1114 seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t2, 5, 9));
1115 seed_rollback_entry(&db, "{not valid json: at all"); let mut args = default_args();
1119 args.rollback_last = Some(5);
1120 {
1121 let mut out = env.output();
1122 run(&db, &args, &cfg, &mut out).await.unwrap();
1123 }
1124 let s = env.stdout_str();
1126 assert!(s.contains("reversed 2"));
1127
1128 let conn = db::open(&db).unwrap();
1130 assert_eq!(db::get(&conn, &t1).unwrap().unwrap().priority, 4);
1131 assert_eq!(db::get(&conn, &t2).unwrap().unwrap().priority, 5);
1132 }
1133
1134 #[tokio::test]
1135 async fn pr9i_curator_rollback_last_skips_already_reversed() {
1136 let mut env = TestEnv::fresh();
1139 let db = env.db_path.clone();
1140 let cfg = config::AppConfig::default();
1141
1142 let target;
1144 {
1145 let conn = db::open(&db).unwrap();
1146 let now = chrono::Utc::now().to_rfc3339();
1147 let mut metadata = crate::models::default_metadata();
1148 if let Some(obj) = metadata.as_object_mut() {
1149 obj.insert(
1150 "agent_id".to_string(),
1151 serde_json::Value::String("test-agent".to_string()),
1152 );
1153 }
1154 let mem = crate::models::Memory {
1155 id: uuid::Uuid::new_v4().to_string(),
1156 tier: crate::models::Tier::Mid,
1157 namespace: "ns".to_string(),
1158 title: "x".to_string(),
1159 content: "c".to_string(),
1160 tags: vec![],
1161 priority: 7,
1162 confidence: 1.0,
1163 source: "test".to_string(),
1164 access_count: 0,
1165 created_at: now.clone(),
1166 updated_at: now,
1167 last_accessed_at: None,
1168 expires_at: None,
1169 metadata,
1170 reflection_depth: 0,
1171 memory_kind: crate::models::MemoryKind::Observation,
1172 entity_id: None,
1173 persona_version: None,
1174 citations: Vec::new(),
1175 source_uri: None,
1176 source_span: None,
1177 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1178 confidence_signals: None,
1179 confidence_decayed_at: None,
1180 version: 1,
1181 };
1182 target = db::insert(&conn, &mem).unwrap();
1183 }
1184
1185 let entry_json = build_priority_rollback_entry_json(&target, 2, 7);
1187 let entry_id;
1188 {
1189 let conn = db::open(&db).unwrap();
1190 let now = chrono::Utc::now().to_rfc3339();
1191 let mut metadata = crate::models::default_metadata();
1192 if let Some(obj) = metadata.as_object_mut() {
1193 obj.insert(
1194 "agent_id".to_string(),
1195 serde_json::Value::String("test-agent".to_string()),
1196 );
1197 }
1198 let mem = crate::models::Memory {
1199 id: uuid::Uuid::new_v4().to_string(),
1200 tier: crate::models::Tier::Mid,
1201 namespace: "_curator/rollback".to_string(),
1202 title: "preexisting-reversed".to_string(),
1203 content: entry_json,
1204 tags: vec!["_reversed".to_string()],
1205 priority: 5,
1206 confidence: 1.0,
1207 source: "test".to_string(),
1208 access_count: 0,
1209 created_at: now.clone(),
1210 updated_at: now,
1211 last_accessed_at: None,
1212 expires_at: None,
1213 metadata,
1214 reflection_depth: 0,
1215 memory_kind: crate::models::MemoryKind::Observation,
1216 entity_id: None,
1217 persona_version: None,
1218 citations: Vec::new(),
1219 source_uri: None,
1220 source_span: None,
1221 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1222 confidence_signals: None,
1223 confidence_decayed_at: None,
1224 version: 1,
1225 };
1226 entry_id = db::insert(&conn, &mem).unwrap();
1227 }
1228
1229 let mut args = default_args();
1230 args.rollback_last = Some(5);
1231 {
1232 let mut out = env.output();
1233 run(&db, &args, &cfg, &mut out).await.unwrap();
1234 }
1235 let s = env.stdout_str();
1237 assert!(s.contains("reversed 0"));
1238
1239 let conn = db::open(&db).unwrap();
1241 assert_eq!(db::get(&conn, &target).unwrap().unwrap().priority, 7);
1242 let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
1244 assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
1245 }
1246
1247 #[tokio::test]
1248 async fn pr9i_curator_rollback_id_with_malformed_content() {
1249 let mut env = TestEnv::fresh();
1252 let db = env.db_path.clone();
1253 let cfg = config::AppConfig::default();
1254 let entry_id = seed_rollback_entry(&db, "{invalid json");
1255
1256 let mut args = default_args();
1257 args.rollback = Some(entry_id);
1258 let mut out = env.output();
1259 let res = run(&db, &args, &cfg, &mut out).await;
1260 assert!(res.is_err());
1261 let err = res.unwrap_err().to_string();
1262 assert!(
1263 err.contains("rollback") || err.contains("RollbackEntry"),
1264 "expected parse-error message, got: {err}"
1265 );
1266 }
1267
1268 #[test]
1274 fn build_curator_llm_with_keyword_tier_returns_none() {
1275 crate::cli::test_utils::ensure_no_config_env();
1284 let result = build_curator_llm(config::FeatureTier::Keyword);
1285 assert!(result.is_none());
1286 }
1287
1288 #[test]
1289 fn build_curator_llm_with_smart_tier_runs_body() {
1290 crate::cli::test_utils::ensure_no_config_env();
1299 let _ = build_curator_llm(config::FeatureTier::Smart);
1300 }
1302
1303 #[cfg(unix)]
1311 #[tokio::test(flavor = "multi_thread")]
1312 async fn curator_daemon_mode_short_loop_returns_on_shutdown() {
1313 use std::path::PathBuf;
1324 let env = TestEnv::fresh();
1325 let db: PathBuf = env.db_path.clone();
1326 let cfg = config::AppConfig::default();
1327 let mut args = default_args();
1328 args.daemon = true;
1329 args.interval_secs = 60; args.dry_run = true;
1333
1334 let kicker = tokio::spawn(async {
1336 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1337 unsafe {
1339 let pid = libc::getpid();
1340 libc::kill(pid, libc::SIGINT);
1341 }
1342 });
1343
1344 let mut stdout = Vec::<u8>::new();
1345 let mut stderr = Vec::<u8>::new();
1346 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1347 let res = tokio::time::timeout(
1349 std::time::Duration::from_secs(15),
1350 run(&db, &args, &cfg, &mut out),
1351 )
1352 .await;
1353 let _ = kicker.await;
1354 match res {
1358 Ok(Ok(())) => {}
1359 Ok(Err(e)) => panic!("daemon mode errored: {e}"),
1360 Err(_) => {
1361 eprintln!("daemon-mode test timed out; coverage already captured");
1364 }
1365 }
1366 }
1367
1368 #[test]
1369 fn print_curator_report_emits_error_list_lines() {
1370 let mut report = crate::curator::CuratorReport::default();
1376 report.errors = vec!["err A".to_string(), "err B".to_string()];
1377 report.dry_run = true;
1378 let mut stdout = Vec::<u8>::new();
1379 let mut stderr = Vec::<u8>::new();
1380 {
1381 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1382 print_curator_report(&report, &mut out).unwrap();
1383 }
1384 let s = String::from_utf8(stdout).unwrap();
1385 assert!(s.contains("curator cycle report"));
1387 assert!(s.contains("- err A"));
1389 assert!(s.contains("- err B"));
1390 }
1391
1392 #[cfg(feature = "sal")]
1395 #[tokio::test]
1396 async fn reflect_requires_namespace_or_all_namespaces() {
1397 let mut env = TestEnv::fresh();
1398 let db = env.db_path.clone();
1399 let cfg = config::AppConfig::default();
1400 let mut args = default_args();
1401 args.reflect = true;
1402 let mut out = env.output();
1404 let err = run(&db, &args, &cfg, &mut out).await.unwrap_err();
1405 assert!(
1406 err.to_string().contains("--namespace") || err.to_string().contains("--all-namespaces")
1407 );
1408 }
1409
1410 #[cfg(feature = "sal")]
1411 #[tokio::test]
1412 async fn reflect_namespace_and_all_namespaces_mutually_exclusive() {
1413 let mut env = TestEnv::fresh();
1414 let db = env.db_path.clone();
1415 let cfg = config::AppConfig::default();
1416 let mut args = default_args();
1417 args.reflect = true;
1418 args.namespace = Some("ns".to_string());
1419 args.all_namespaces = true;
1420 let mut out = env.output();
1421 let err = run(&db, &args, &cfg, &mut out).await.unwrap_err();
1422 assert!(err.to_string().contains("mutually exclusive"));
1423 }
1424
1425 #[cfg(feature = "sal")]
1426 #[tokio::test]
1427 async fn reflect_no_llm_path_emits_error_in_report() {
1428 let mut env = TestEnv::fresh();
1430 let db = env.db_path.clone();
1431 let mut cfg = config::AppConfig::default();
1432 cfg.tier = Some("keyword".to_string());
1433 let mut args = default_args();
1434 args.reflect = true;
1435 args.namespace = Some("ns".to_string());
1436 args.dry_run = true;
1437 {
1438 let mut out = env.output();
1439 run(&db, &args, &cfg, &mut out).await.unwrap();
1440 }
1441 let s = env.stdout_str();
1442 assert!(s.contains("reflection pass report"));
1443 assert!(s.contains("no LLM client configured"));
1444 }
1445
1446 #[cfg(feature = "sal")]
1447 #[tokio::test]
1448 async fn reflect_no_llm_path_emits_json_report() {
1449 let mut env = TestEnv::fresh();
1451 let db = env.db_path.clone();
1452 let mut cfg = config::AppConfig::default();
1453 cfg.tier = Some("keyword".to_string());
1454 let mut args = default_args();
1455 args.reflect = true;
1456 args.namespace = Some("ns".to_string());
1457 args.dry_run = true;
1458 args.json = true;
1459 {
1460 let mut out = env.output();
1461 run(&db, &args, &cfg, &mut out).await.unwrap();
1462 }
1463 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1464 let errs = v["errors"].as_array().unwrap();
1466 assert!(errs.iter().any(|e| e.as_str().unwrap().contains("no LLM")));
1467 assert!(v["dry_run"].as_bool().unwrap());
1468 }
1469
1470 #[cfg(feature = "sal")]
1471 #[tokio::test]
1472 async fn reflect_all_namespaces_text_output() {
1473 let mut env = TestEnv::fresh();
1475 let db = env.db_path.clone();
1476 let mut cfg = config::AppConfig::default();
1477 cfg.tier = Some("keyword".to_string());
1478 let mut args = default_args();
1479 args.reflect = true;
1480 args.all_namespaces = true;
1481 args.dry_run = true;
1482 {
1483 let mut out = env.output();
1484 run(&db, &args, &cfg, &mut out).await.unwrap();
1485 }
1486 let s = env.stdout_str();
1487 assert!(s.contains("reflection pass report"));
1488 }
1489
1490 #[cfg(feature = "sal")]
1492 #[tokio::test]
1493 async fn store_url_sqlite_once_text_runs_sweep() {
1494 let mut env = TestEnv::fresh();
1498 let db = env.db_path.clone();
1499 let mut cfg = config::AppConfig::default();
1500 cfg.tier = Some("keyword".to_string()); let mut args = default_args();
1502 args.store_url = Some(format!("sqlite://{}", db.display()));
1503 args.once = true;
1504 args.dry_run = true;
1505 {
1506 let mut out = env.output();
1507 run(&db, &args, &cfg, &mut out).await.unwrap();
1508 }
1509 let s = env.stdout_str();
1510 assert!(s.contains("reflection pass report"));
1511 assert!(s.contains("no LLM client configured"));
1512 }
1513
1514 #[cfg(feature = "sal")]
1515 #[tokio::test]
1516 async fn store_url_sqlite_once_json_runs_sweep() {
1517 let mut env = TestEnv::fresh();
1518 let db = env.db_path.clone();
1519 let mut cfg = config::AppConfig::default();
1520 cfg.tier = Some("keyword".to_string());
1521 let mut args = default_args();
1522 args.store_url = Some(format!("sqlite://{}", db.display()));
1523 args.once = true;
1524 args.dry_run = true;
1525 args.json = true;
1526 {
1527 let mut out = env.output();
1528 run(&db, &args, &cfg, &mut out).await.unwrap();
1529 }
1530 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1531 let errs = v["errors"].as_array().unwrap();
1532 assert!(errs.iter().any(|e| e.as_str().unwrap().contains("no LLM")));
1533 assert!(v["dry_run"].as_bool().unwrap());
1534 }
1535
1536 #[cfg(feature = "sal")]
1542 struct CovStubLlm;
1543 #[cfg(feature = "sal")]
1544 impl crate::autonomy::AutonomyLlm for CovStubLlm {
1545 fn auto_tag(&self, _title: &str, _content: &str) -> anyhow::Result<Vec<String>> {
1546 Ok(Vec::new())
1547 }
1548 fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
1549 Ok(false)
1550 }
1551 fn summarize_memories(&self, _memories: &[(String, String)]) -> anyhow::Result<String> {
1552 Ok("stub reflection summary".to_string())
1553 }
1554 }
1555
1556 #[cfg(feature = "sal")]
1557 #[tokio::test]
1558 async fn reflection_helper_with_stub_llm_runs_with_llm_branch() {
1559 let env = TestEnv::fresh();
1563 let store = crate::store::sqlite::SqliteStore::open(&env.db_path).expect("open store");
1564 let stub = CovStubLlm;
1565 let args = default_args();
1566 let report = run_reflection_pass_with_optional_llm(
1567 &store,
1568 Some(&stub as &dyn crate::autonomy::AutonomyLlm),
1569 None,
1570 None,
1571 args.max_depth,
1572 true,
1573 |_ns: &str| true,
1574 )
1575 .await;
1576 assert!(report.dry_run);
1579 assert!(
1580 report.errors.is_empty(),
1581 "unexpected errors: {:?}",
1582 report.errors
1583 );
1584 use crate::autonomy::AutonomyLlm;
1587 assert!(stub.auto_tag("t", "c").unwrap().is_empty());
1588 assert!(!stub.detect_contradiction("a", "b").unwrap());
1589 assert_eq!(
1590 stub.summarize_memories(&[("a".to_string(), "b".to_string())])
1591 .unwrap(),
1592 "stub reflection summary"
1593 );
1594 }
1595
1596 #[cfg(feature = "sal")]
1597 #[tokio::test]
1598 async fn reflection_helper_with_none_llm_reports_configured_error() {
1599 let env = TestEnv::fresh();
1601 let store = crate::store::sqlite::SqliteStore::open(&env.db_path).expect("open store");
1602 let report = run_reflection_pass_with_optional_llm(
1603 &store,
1604 None,
1605 None,
1606 Some("ns"),
1607 None,
1608 false,
1609 |_ns: &str| true,
1610 )
1611 .await;
1612 assert!(!report.dry_run);
1613 assert!(
1614 report
1615 .errors
1616 .iter()
1617 .any(|e| e.contains("no LLM client configured"))
1618 );
1619 }
1620
1621 #[cfg(all(feature = "sal", unix))]
1622 #[tokio::test(flavor = "multi_thread")]
1623 async fn store_url_sqlite_daemon_loop_returns_on_shutdown() {
1624 use std::path::PathBuf;
1630 let env = TestEnv::fresh();
1631 let db: PathBuf = env.db_path.clone();
1632 let mut cfg = config::AppConfig::default();
1633 cfg.tier = Some("keyword".to_string());
1634 let mut args = default_args();
1635 args.store_url = Some(format!("sqlite://{}", db.display()));
1636 args.daemon = true;
1637 args.interval_secs = 60;
1638 args.dry_run = true;
1639 let kicker = tokio::spawn(async {
1640 tokio::time::sleep(std::time::Duration::from_millis(3000)).await;
1641 unsafe {
1643 libc::kill(libc::getpid(), libc::SIGINT);
1644 }
1645 });
1646 let mut stdout = Vec::<u8>::new();
1647 let mut stderr = Vec::<u8>::new();
1648 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1649 let res = tokio::time::timeout(
1650 std::time::Duration::from_secs(90),
1651 run(&db, &args, &cfg, &mut out),
1652 )
1653 .await;
1654 let _ = kicker.await;
1655 assert!(res.is_ok(), "SAL daemon did not return within timeout");
1656 assert!(res.unwrap().is_ok());
1657 }
1658
1659 #[test]
1660 fn print_reflection_report_emits_proposals_and_errors() {
1661 let r = crate::curator::reflection_pass::ReflectionPassReport {
1662 started_at: "2026-01-01T00:00:00Z".into(),
1663 completed_at: "2026-01-01T00:00:01Z".into(),
1664 namespaces_visited: 2,
1665 observations_scanned: 5,
1666 clusters_formed: 1,
1667 clusters_eligible: 1,
1668 reflections_persisted: 0,
1669 depth_refusals: 0,
1670 errors: vec!["a problem".to_string()],
1671 dry_run_proposals: vec![crate::curator::reflection_pass::DryRunProposal {
1672 namespace: "app".to_string(),
1673 proposed_title: "[reflection] pattern".to_string(),
1674 source_ids: vec!["a".to_string(), "b".to_string(), "c".to_string()],
1675 }],
1676 dry_run: true,
1677 };
1678 let mut stdout = Vec::<u8>::new();
1679 let mut stderr = Vec::<u8>::new();
1680 {
1681 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1682 print_reflection_report(&r, &mut out).unwrap();
1683 }
1684 let s = String::from_utf8(stdout).unwrap();
1685 assert!(s.contains("reflection pass report"));
1686 assert!(s.contains("namespaces_visited:"));
1687 assert!(s.contains("observations_scanned:"));
1688 assert!(s.contains("- a problem"));
1689 assert!(s.contains("proposal: ns='app'"));
1690 assert!(s.contains("sources=3"));
1691 }
1692
1693 #[test]
1694 fn load_curator_keypair_best_effort_returns_some_or_none() {
1695 let _ = load_curator_keypair_best_effort();
1698 }
1699
1700 #[test]
1701 fn build_curator_llm_with_autonomous_tier() {
1702 crate::cli::test_utils::ensure_no_config_env();
1710 let _ = build_curator_llm(config::FeatureTier::Autonomous);
1711 }
1712
1713 #[cfg(feature = "sal")]
1714 #[tokio::test]
1715 async fn reflect_with_seeded_observations_and_no_llm() {
1716 let mut env = TestEnv::fresh();
1720 let db = env.db_path.clone();
1721 let _id = crate::cli::test_utils::seed_memory(&db, "myns", "T", "C");
1722 let mut cfg = config::AppConfig::default();
1723 cfg.tier = Some("keyword".to_string());
1724 let mut args = default_args();
1725 args.reflect = true;
1726 args.all_namespaces = true;
1727 args.dry_run = true;
1728 {
1729 let mut out = env.output();
1730 run(&db, &args, &cfg, &mut out).await.unwrap();
1731 }
1732 assert!(env.stdout_str().contains("reflection pass report"));
1733 }
1734
1735 #[test]
1742 fn qual_2_run_rollback_returns_error_when_no_mode_set() {
1743 let env = TestEnv::fresh();
1744 let db = env.db_path.clone();
1745 let args = default_args();
1746 let mut stdout: Vec<u8> = Vec::new();
1747 let mut stderr: Vec<u8> = Vec::new();
1748 let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1749 let res = run_rollback(&db, &args, &mut out);
1750 assert!(
1751 res.is_err(),
1752 "run_rollback must return Err when both --rollback and --rollback-last are None"
1753 );
1754 let msg = res.unwrap_err().to_string();
1755 assert!(
1756 msg.contains("run_rollback entered without --rollback or --rollback-last"),
1757 "unexpected error message: {msg}"
1758 );
1759 }
1760}