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();
523 let enabled_check = |_ns: &str| -> bool { scope_single };
524
525 let report = run_reflection_pass_with_optional_llm(
526 store.as_ref(),
527 llm.as_ref().map(|c| c as &dyn crate::autonomy::AutonomyLlm),
528 keypair.as_ref(),
529 args.namespace.as_deref(),
530 args.max_depth,
531 args.dry_run,
532 enabled_check,
533 )
534 .await;
535
536 if args.json {
537 writeln!(out.stdout, "{}", serde_json::to_string_pretty(&report)?)?;
538 } else {
539 print_reflection_report(&report, out)?;
540 }
541 Ok(())
542}
543
544#[cfg(not(feature = "sal"))]
549#[allow(clippy::unused_async)]
550async fn run_reflect(
551 _db_path: &Path,
552 _args: &CuratorArgs,
553 _app_config: &config::AppConfig,
554 _out: &mut CliOutput<'_>,
555) -> Result<()> {
556 anyhow::bail!(
557 "curator --reflect requires a binary built with --features sal \
558 (the reflection pass operates over the SAL MemoryStore trait)"
559 )
560}
561
562#[cfg(feature = "sal")]
567async fn build_reflect_store(
568 db_path: &Path,
569 args: &CuratorArgs,
570 app_config: &config::AppConfig,
571) -> Result<std::sync::Arc<dyn crate::store::MemoryStore>> {
572 crate::daemon_runtime::build_curator_store(curator_store_url(args), db_path, app_config).await
573}
574
575#[cfg_attr(not(feature = "sal"), allow(dead_code))]
581fn load_curator_keypair_best_effort() -> Option<identity_keypair::AgentKeypair> {
582 let dir = identity_keypair::default_key_dir().ok()?;
583 let listed = identity_keypair::list(&dir).ok()?;
589 let first = listed.into_iter().next()?;
590 identity_keypair::load(&first.agent_id, &dir).ok()
591}
592
593#[cfg_attr(not(feature = "sal"), allow(dead_code))]
594fn print_reflection_report(
595 r: &reflection_pass::ReflectionPassReport,
596 out: &mut CliOutput<'_>,
597) -> Result<()> {
598 writeln!(out.stdout, "reflection pass report")?;
599 writeln!(out.stdout, " started_at: {}", r.started_at)?;
600 writeln!(out.stdout, " completed_at: {}", r.completed_at)?;
601 writeln!(
602 out.stdout,
603 " namespaces_visited: {}",
604 r.namespaces_visited
605 )?;
606 writeln!(
607 out.stdout,
608 " observations_scanned: {}",
609 r.observations_scanned
610 )?;
611 writeln!(out.stdout, " clusters_formed: {}", r.clusters_formed)?;
612 writeln!(
613 out.stdout,
614 " clusters_eligible: {}",
615 r.clusters_eligible
616 )?;
617 writeln!(
618 out.stdout,
619 " reflections_persisted: {}",
620 r.reflections_persisted
621 )?;
622 writeln!(out.stdout, " depth_refusals: {}", r.depth_refusals)?;
623 writeln!(out.stdout, " errors: {}", r.errors.len())?;
624 writeln!(out.stdout, " dry_run: {}", r.dry_run)?;
625 for e in &r.errors {
626 writeln!(out.stdout, " - {e}")?;
627 }
628 for prop in &r.dry_run_proposals {
629 writeln!(
630 out.stdout,
631 " proposal: ns='{}' title='{}' sources={}",
632 prop.namespace,
633 prop.proposed_title,
634 prop.source_ids.len()
635 )?;
636 }
637 Ok(())
638}
639
640fn run_rollback(db_path: &Path, args: &CuratorArgs, out: &mut CliOutput<'_>) -> Result<()> {
641 let conn = db::open(db_path)?;
642
643 if let Some(id) = &args.rollback {
644 let Some(mem) = db::get(&conn, id)? else {
645 anyhow::bail!("rollback entry {id} not found");
646 };
647 let entry: autonomy::RollbackEntry = serde_json::from_str(&mem.content)
648 .context("rollback entry content is not a valid RollbackEntry JSON")?;
649 let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
650 let mut tags = mem.tags.clone();
651 if !tags.iter().any(|t| t == "_reversed") {
652 tags.push("_reversed".to_string());
653 db::update(
654 &conn,
655 &mem.id,
656 None,
657 None,
658 None,
659 None,
660 Some(&tags),
661 None,
662 None,
663 None,
664 None,
665 )?;
666 }
667 writeln!(
668 out.stdout,
669 "rollback {id}: {}",
670 if applied { "applied" } else { "no-op" }
671 )?;
672 return Ok(());
673 }
674
675 if let Some(n) = args.rollback_last {
676 let log = db::list(
677 &conn,
678 Some("_curator/rollback"),
679 None,
680 n.max(1),
681 0,
682 None,
683 None,
684 None,
685 None,
686 None,
687 )?;
688 let mut reversed = 0usize;
689 for mem in &log {
690 if mem.tags.iter().any(|t| t == "_reversed") {
691 continue;
692 }
693 let Ok(entry) = serde_json::from_str::<autonomy::RollbackEntry>(&mem.content) else {
694 continue;
695 };
696 let applied = autonomy::reverse_rollback_entry(&conn, &entry)?;
697 if applied {
698 reversed += 1;
699 let mut tags = mem.tags.clone();
700 tags.push("_reversed".to_string());
701 db::update(
702 &conn,
703 &mem.id,
704 None,
705 None,
706 None,
707 None,
708 Some(&tags),
709 None,
710 None,
711 None,
712 None,
713 )?;
714 }
715 }
716 writeln!(out.stdout, "reversed {reversed} rollback entries")?;
717 return Ok(());
718 }
719
720 anyhow::bail!("run_rollback entered without --rollback or --rollback-last");
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732 use crate::cli::test_utils::TestEnv;
733
734 fn default_args() -> CuratorArgs {
735 CuratorArgs {
736 once: false,
737 daemon: false,
738 interval_secs: crate::SECS_PER_HOUR as u64,
739 max_ops: 100,
740 dry_run: false,
741 include_namespaces: Vec::new(),
742 exclude_namespaces: Vec::new(),
743 json: false,
744 rollback: None,
745 rollback_last: None,
746 reflect: false,
747 namespace: None,
748 max_depth: None,
749 all_namespaces: false,
750 #[cfg(feature = "sal")]
751 store_url: None,
752 }
753 }
754
755 #[tokio::test]
756 async fn test_curator_requires_mode() {
757 let mut env = TestEnv::fresh();
758 let db = env.db_path.clone();
759 let cfg = config::AppConfig::default();
760 let args = default_args();
761 let mut out = env.output();
762 let res = run(&db, &args, &cfg, &mut out).await;
763 assert!(res.is_err());
764 assert!(
765 res.unwrap_err()
766 .to_string()
767 .contains("--once, --daemon, --reflect")
768 );
769 }
770
771 #[tokio::test]
772 async fn test_curator_once_runs_single_sweep_text() {
773 let mut env = TestEnv::fresh();
774 let db = env.db_path.clone();
775 let cfg = config::AppConfig::default();
776 let mut args = default_args();
777 args.once = true;
778 args.dry_run = true;
779 {
780 let mut out = env.output();
781 run(&db, &args, &cfg, &mut out).await.unwrap();
782 }
783 assert!(env.stdout_str().contains("curator cycle report"));
784 }
785
786 #[tokio::test]
787 async fn test_curator_once_json_format() {
788 let mut env = TestEnv::fresh();
789 let db = env.db_path.clone();
790 let cfg = config::AppConfig::default();
791 let mut args = default_args();
792 args.once = true;
793 args.json = true;
794 args.dry_run = true;
795 {
796 let mut out = env.output();
797 run(&db, &args, &cfg, &mut out).await.unwrap();
798 }
799 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
800 assert!(v["dry_run"].as_bool().unwrap());
801 }
802
803 #[tokio::test]
804 async fn test_curator_dry_run_skips_writes() {
805 let mut env = TestEnv::fresh();
806 let db = env.db_path.clone();
807 let cfg = config::AppConfig::default();
808 let mut args = default_args();
809 args.once = true;
810 args.dry_run = true;
811 {
812 let mut out = env.output();
813 run(&db, &args, &cfg, &mut out).await.unwrap();
814 }
815 let s = env.stdout_str();
817 assert!(s.contains("dry_run:") || s.contains("\"dry_run\""));
818 }
819
820 #[tokio::test]
821 async fn test_curator_include_namespaces_filter() {
822 let mut env = TestEnv::fresh();
823 let db = env.db_path.clone();
824 let cfg = config::AppConfig::default();
825 let mut args = default_args();
826 args.once = true;
827 args.dry_run = true;
828 args.include_namespaces = vec!["only-this-ns".to_string()];
829 {
830 let mut out = env.output();
831 run(&db, &args, &cfg, &mut out).await.unwrap();
832 }
833 assert!(env.stdout_str().contains("operations:"));
835 }
836
837 #[tokio::test]
838 async fn test_curator_exclude_namespaces_filter() {
839 let mut env = TestEnv::fresh();
840 let db = env.db_path.clone();
841 let cfg = config::AppConfig::default();
842 let mut args = default_args();
843 args.once = true;
844 args.dry_run = true;
845 args.exclude_namespaces = vec!["skip-me".to_string()];
846 {
847 let mut out = env.output();
848 run(&db, &args, &cfg, &mut out).await.unwrap();
849 }
850 assert!(env.stdout_str().contains("curator cycle report"));
851 }
852
853 #[tokio::test]
854 async fn test_curator_max_ops_cap_respected() {
855 let mut env = TestEnv::fresh();
856 let db = env.db_path.clone();
857 let cfg = config::AppConfig::default();
858 let mut args = default_args();
859 args.once = true;
860 args.dry_run = true;
861 args.max_ops = 0; {
863 let mut out = env.output();
864 run(&db, &args, &cfg, &mut out).await.unwrap();
865 }
866 assert!(env.stdout_str().contains("operations:"));
867 }
868
869 #[tokio::test]
870 async fn test_curator_rollback_id_not_found() {
871 let mut env = TestEnv::fresh();
872 let db = env.db_path.clone();
873 let cfg = config::AppConfig::default();
874 let mut args = default_args();
875 args.rollback = Some("00000000-0000-0000-0000-000000000000".to_string());
876 let mut out = env.output();
877 let res = run(&db, &args, &cfg, &mut out).await;
878 assert!(res.is_err());
879 assert!(res.unwrap_err().to_string().contains("rollback entry"));
880 }
881
882 #[tokio::test]
883 async fn test_curator_rollback_last_zero_entries() {
884 let mut env = TestEnv::fresh();
885 let db = env.db_path.clone();
886 let cfg = config::AppConfig::default();
887 let mut args = default_args();
888 args.rollback_last = Some(5);
889 {
890 let mut out = env.output();
891 run(&db, &args, &cfg, &mut out).await.unwrap();
892 }
893 assert!(env.stdout_str().contains("reversed 0"));
895 }
896
897 fn build_priority_rollback_entry_json(memory_id: &str, before: i32, after: i32) -> String {
903 serde_json::to_string(&autonomy::RollbackEntry::PriorityAdjust {
906 memory_id: memory_id.to_string(),
907 before,
908 after,
909 })
910 .unwrap()
911 }
912
913 fn seed_rollback_entry(db_path: &std::path::Path, content: &str) -> String {
914 let conn = db::open(db_path).expect("db::open");
917 let now = chrono::Utc::now().to_rfc3339();
918 let mut metadata = crate::models::default_metadata();
919 if let Some(obj) = metadata.as_object_mut() {
920 obj.insert(
921 "agent_id".to_string(),
922 serde_json::Value::String("test-agent".to_string()),
923 );
924 }
925 let mem = crate::models::Memory {
926 id: uuid::Uuid::new_v4().to_string(),
927 tier: crate::models::Tier::Mid,
928 namespace: "_curator/rollback".to_string(),
929 title: format!("rollback-{}", uuid::Uuid::new_v4()),
930 content: content.to_string(),
931 tags: vec![],
932 priority: 5,
933 confidence: 1.0,
934 source: "test".to_string(),
935 access_count: 0,
936 created_at: now.clone(),
937 updated_at: now,
938 last_accessed_at: None,
939 expires_at: None,
940 metadata,
941 reflection_depth: 0,
942 memory_kind: crate::models::MemoryKind::Observation,
943 entity_id: None,
944 persona_version: None,
945 citations: Vec::new(),
946 source_uri: None,
947 source_span: None,
948 confidence_source: crate::models::ConfidenceSource::CallerProvided,
949 confidence_signals: None,
950 confidence_decayed_at: None,
951 version: 1,
952 };
953 db::insert(&conn, &mem).expect("db::insert")
954 }
955
956 #[tokio::test]
957 async fn pr9i_curator_rollback_priority_adjust_applies() {
958 let mut env = TestEnv::fresh();
960 let db = env.db_path.clone();
961 let cfg = config::AppConfig::default();
962
963 let target = {
965 let conn = db::open(&db).unwrap();
966 let now = chrono::Utc::now().to_rfc3339();
967 let mut metadata = crate::models::default_metadata();
968 if let Some(obj) = metadata.as_object_mut() {
969 obj.insert(
970 "agent_id".to_string(),
971 serde_json::Value::String("test-agent".to_string()),
972 );
973 }
974 let mem = crate::models::Memory {
975 id: uuid::Uuid::new_v4().to_string(),
976 tier: crate::models::Tier::Mid,
977 namespace: "ns".to_string(),
978 title: "target".to_string(),
979 content: "c".to_string(),
980 tags: vec![],
981 priority: 7,
982 confidence: 1.0,
983 source: "test".to_string(),
984 access_count: 0,
985 created_at: now.clone(),
986 updated_at: now,
987 last_accessed_at: None,
988 expires_at: None,
989 metadata,
990 reflection_depth: 0,
991 memory_kind: crate::models::MemoryKind::Observation,
992 entity_id: None,
993 persona_version: None,
994 citations: Vec::new(),
995 source_uri: None,
996 source_span: None,
997 confidence_source: crate::models::ConfidenceSource::CallerProvided,
998 confidence_signals: None,
999 confidence_decayed_at: None,
1000 version: 1,
1001 };
1002 db::insert(&conn, &mem).unwrap()
1003 };
1004
1005 let entry_json = build_priority_rollback_entry_json(&target, 3, 7);
1007 let entry_id = seed_rollback_entry(&db, &entry_json);
1008
1009 let mut args = default_args();
1011 args.rollback = Some(entry_id.clone());
1012 {
1013 let mut out = env.output();
1014 run(&db, &args, &cfg, &mut out).await.unwrap();
1015 }
1016 let s = env.stdout_str();
1018 assert!(s.contains(&format!("rollback {entry_id}")));
1019 assert!(s.contains("applied"));
1020
1021 let conn = db::open(&db).unwrap();
1023 let target_mem = db::get(&conn, &target).unwrap().unwrap();
1024 assert_eq!(target_mem.priority, 3);
1025
1026 let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
1028 assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
1029 }
1030
1031 #[tokio::test]
1032 async fn pr9i_curator_rollback_last_processes_multiple() {
1033 let mut env = TestEnv::fresh();
1034 let db = env.db_path.clone();
1035 let cfg = config::AppConfig::default();
1036
1037 let t1;
1039 let t2;
1040 {
1041 let conn = db::open(&db).unwrap();
1042 let now = chrono::Utc::now().to_rfc3339();
1043 let mut metadata = crate::models::default_metadata();
1044 if let Some(obj) = metadata.as_object_mut() {
1045 obj.insert(
1046 "agent_id".to_string(),
1047 serde_json::Value::String("test-agent".to_string()),
1048 );
1049 }
1050 let m1 = crate::models::Memory {
1051 id: uuid::Uuid::new_v4().to_string(),
1052 tier: crate::models::Tier::Mid,
1053 namespace: "ns".to_string(),
1054 title: "t1".to_string(),
1055 content: "c1".to_string(),
1056 tags: vec![],
1057 priority: 8,
1058 confidence: 1.0,
1059 source: "test".to_string(),
1060 access_count: 0,
1061 created_at: now.clone(),
1062 updated_at: now.clone(),
1063 last_accessed_at: None,
1064 expires_at: None,
1065 metadata: metadata.clone(),
1066 reflection_depth: 0,
1067 memory_kind: crate::models::MemoryKind::Observation,
1068 entity_id: None,
1069 persona_version: None,
1070 citations: Vec::new(),
1071 source_uri: None,
1072 source_span: None,
1073 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1074 confidence_signals: None,
1075 confidence_decayed_at: None,
1076 version: 1,
1077 };
1078 let m2 = crate::models::Memory {
1079 id: uuid::Uuid::new_v4().to_string(),
1080 tier: crate::models::Tier::Mid,
1081 namespace: "ns".to_string(),
1082 title: "t2".to_string(),
1083 content: "c2".to_string(),
1084 tags: vec![],
1085 priority: 9,
1086 confidence: 1.0,
1087 source: "test".to_string(),
1088 access_count: 0,
1089 created_at: now.clone(),
1090 updated_at: now,
1091 last_accessed_at: None,
1092 expires_at: None,
1093 metadata,
1094 reflection_depth: 0,
1095 memory_kind: crate::models::MemoryKind::Observation,
1096 entity_id: None,
1097 persona_version: None,
1098 citations: Vec::new(),
1099 source_uri: None,
1100 source_span: None,
1101 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1102 confidence_signals: None,
1103 confidence_decayed_at: None,
1104 version: 1,
1105 };
1106 t1 = db::insert(&conn, &m1).unwrap();
1107 t2 = db::insert(&conn, &m2).unwrap();
1108 }
1109
1110 seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t1, 4, 8));
1112 seed_rollback_entry(&db, &build_priority_rollback_entry_json(&t2, 5, 9));
1113 seed_rollback_entry(&db, "{not valid json: at all"); let mut args = default_args();
1117 args.rollback_last = Some(5);
1118 {
1119 let mut out = env.output();
1120 run(&db, &args, &cfg, &mut out).await.unwrap();
1121 }
1122 let s = env.stdout_str();
1124 assert!(s.contains("reversed 2"));
1125
1126 let conn = db::open(&db).unwrap();
1128 assert_eq!(db::get(&conn, &t1).unwrap().unwrap().priority, 4);
1129 assert_eq!(db::get(&conn, &t2).unwrap().unwrap().priority, 5);
1130 }
1131
1132 #[tokio::test]
1133 async fn pr9i_curator_rollback_last_skips_already_reversed() {
1134 let mut env = TestEnv::fresh();
1137 let db = env.db_path.clone();
1138 let cfg = config::AppConfig::default();
1139
1140 let target;
1142 {
1143 let conn = db::open(&db).unwrap();
1144 let now = chrono::Utc::now().to_rfc3339();
1145 let mut metadata = crate::models::default_metadata();
1146 if let Some(obj) = metadata.as_object_mut() {
1147 obj.insert(
1148 "agent_id".to_string(),
1149 serde_json::Value::String("test-agent".to_string()),
1150 );
1151 }
1152 let mem = crate::models::Memory {
1153 id: uuid::Uuid::new_v4().to_string(),
1154 tier: crate::models::Tier::Mid,
1155 namespace: "ns".to_string(),
1156 title: "x".to_string(),
1157 content: "c".to_string(),
1158 tags: vec![],
1159 priority: 7,
1160 confidence: 1.0,
1161 source: "test".to_string(),
1162 access_count: 0,
1163 created_at: now.clone(),
1164 updated_at: now,
1165 last_accessed_at: None,
1166 expires_at: None,
1167 metadata,
1168 reflection_depth: 0,
1169 memory_kind: crate::models::MemoryKind::Observation,
1170 entity_id: None,
1171 persona_version: None,
1172 citations: Vec::new(),
1173 source_uri: None,
1174 source_span: None,
1175 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1176 confidence_signals: None,
1177 confidence_decayed_at: None,
1178 version: 1,
1179 };
1180 target = db::insert(&conn, &mem).unwrap();
1181 }
1182
1183 let entry_json = build_priority_rollback_entry_json(&target, 2, 7);
1185 let entry_id;
1186 {
1187 let conn = db::open(&db).unwrap();
1188 let now = chrono::Utc::now().to_rfc3339();
1189 let mut metadata = crate::models::default_metadata();
1190 if let Some(obj) = metadata.as_object_mut() {
1191 obj.insert(
1192 "agent_id".to_string(),
1193 serde_json::Value::String("test-agent".to_string()),
1194 );
1195 }
1196 let mem = crate::models::Memory {
1197 id: uuid::Uuid::new_v4().to_string(),
1198 tier: crate::models::Tier::Mid,
1199 namespace: "_curator/rollback".to_string(),
1200 title: "preexisting-reversed".to_string(),
1201 content: entry_json,
1202 tags: vec!["_reversed".to_string()],
1203 priority: 5,
1204 confidence: 1.0,
1205 source: "test".to_string(),
1206 access_count: 0,
1207 created_at: now.clone(),
1208 updated_at: now,
1209 last_accessed_at: None,
1210 expires_at: None,
1211 metadata,
1212 reflection_depth: 0,
1213 memory_kind: crate::models::MemoryKind::Observation,
1214 entity_id: None,
1215 persona_version: None,
1216 citations: Vec::new(),
1217 source_uri: None,
1218 source_span: None,
1219 confidence_source: crate::models::ConfidenceSource::CallerProvided,
1220 confidence_signals: None,
1221 confidence_decayed_at: None,
1222 version: 1,
1223 };
1224 entry_id = db::insert(&conn, &mem).unwrap();
1225 }
1226
1227 let mut args = default_args();
1228 args.rollback_last = Some(5);
1229 {
1230 let mut out = env.output();
1231 run(&db, &args, &cfg, &mut out).await.unwrap();
1232 }
1233 let s = env.stdout_str();
1235 assert!(s.contains("reversed 0"));
1236
1237 let conn = db::open(&db).unwrap();
1239 assert_eq!(db::get(&conn, &target).unwrap().unwrap().priority, 7);
1240 let entry_mem = db::get(&conn, &entry_id).unwrap().unwrap();
1242 assert!(entry_mem.tags.iter().any(|t| t == "_reversed"));
1243 }
1244
1245 #[tokio::test]
1246 async fn pr9i_curator_rollback_id_with_malformed_content() {
1247 let mut env = TestEnv::fresh();
1250 let db = env.db_path.clone();
1251 let cfg = config::AppConfig::default();
1252 let entry_id = seed_rollback_entry(&db, "{invalid json");
1253
1254 let mut args = default_args();
1255 args.rollback = Some(entry_id);
1256 let mut out = env.output();
1257 let res = run(&db, &args, &cfg, &mut out).await;
1258 assert!(res.is_err());
1259 let err = res.unwrap_err().to_string();
1260 assert!(
1261 err.contains("rollback") || err.contains("RollbackEntry"),
1262 "expected parse-error message, got: {err}"
1263 );
1264 }
1265
1266 #[test]
1272 fn build_curator_llm_with_keyword_tier_returns_none() {
1273 crate::cli::test_utils::ensure_no_config_env();
1282 let result = build_curator_llm(config::FeatureTier::Keyword);
1283 assert!(result.is_none());
1284 }
1285
1286 #[test]
1287 fn build_curator_llm_with_smart_tier_runs_body() {
1288 crate::cli::test_utils::ensure_no_config_env();
1297 let _ = build_curator_llm(config::FeatureTier::Smart);
1298 }
1300
1301 #[cfg(unix)]
1309 #[tokio::test(flavor = "multi_thread")]
1310 async fn curator_daemon_mode_short_loop_returns_on_shutdown() {
1311 use std::path::PathBuf;
1322 let env = TestEnv::fresh();
1323 let db: PathBuf = env.db_path.clone();
1324 let cfg = config::AppConfig::default();
1325 let mut args = default_args();
1326 args.daemon = true;
1327 args.interval_secs = 60; args.dry_run = true;
1331
1332 let kicker = tokio::spawn(async {
1334 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1335 unsafe {
1337 let pid = libc::getpid();
1338 libc::kill(pid, libc::SIGINT);
1339 }
1340 });
1341
1342 let mut stdout = Vec::<u8>::new();
1343 let mut stderr = Vec::<u8>::new();
1344 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1345 let res = tokio::time::timeout(
1347 std::time::Duration::from_secs(15),
1348 run(&db, &args, &cfg, &mut out),
1349 )
1350 .await;
1351 let _ = kicker.await;
1352 match res {
1356 Ok(Ok(())) => {}
1357 Ok(Err(e)) => panic!("daemon mode errored: {e}"),
1358 Err(_) => {
1359 eprintln!("daemon-mode test timed out; coverage already captured");
1362 }
1363 }
1364 }
1365
1366 #[test]
1367 fn print_curator_report_emits_error_list_lines() {
1368 let mut report = crate::curator::CuratorReport::default();
1374 report.errors = vec!["err A".to_string(), "err B".to_string()];
1375 report.dry_run = true;
1376 let mut stdout = Vec::<u8>::new();
1377 let mut stderr = Vec::<u8>::new();
1378 {
1379 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1380 print_curator_report(&report, &mut out).unwrap();
1381 }
1382 let s = String::from_utf8(stdout).unwrap();
1383 assert!(s.contains("curator cycle report"));
1385 assert!(s.contains("- err A"));
1387 assert!(s.contains("- err B"));
1388 }
1389
1390 #[cfg(feature = "sal")]
1393 #[tokio::test]
1394 async fn reflect_requires_namespace_or_all_namespaces() {
1395 let mut env = TestEnv::fresh();
1396 let db = env.db_path.clone();
1397 let cfg = config::AppConfig::default();
1398 let mut args = default_args();
1399 args.reflect = true;
1400 let mut out = env.output();
1402 let err = run(&db, &args, &cfg, &mut out).await.unwrap_err();
1403 assert!(
1404 err.to_string().contains("--namespace") || err.to_string().contains("--all-namespaces")
1405 );
1406 }
1407
1408 #[cfg(feature = "sal")]
1409 #[tokio::test]
1410 async fn reflect_namespace_and_all_namespaces_mutually_exclusive() {
1411 let mut env = TestEnv::fresh();
1412 let db = env.db_path.clone();
1413 let cfg = config::AppConfig::default();
1414 let mut args = default_args();
1415 args.reflect = true;
1416 args.namespace = Some("ns".to_string());
1417 args.all_namespaces = true;
1418 let mut out = env.output();
1419 let err = run(&db, &args, &cfg, &mut out).await.unwrap_err();
1420 assert!(err.to_string().contains("mutually exclusive"));
1421 }
1422
1423 #[cfg(feature = "sal")]
1424 #[tokio::test]
1425 async fn reflect_no_llm_path_emits_error_in_report() {
1426 let mut env = TestEnv::fresh();
1428 let db = env.db_path.clone();
1429 let mut cfg = config::AppConfig::default();
1430 cfg.tier = Some("keyword".to_string());
1431 let mut args = default_args();
1432 args.reflect = true;
1433 args.namespace = Some("ns".to_string());
1434 args.dry_run = true;
1435 {
1436 let mut out = env.output();
1437 run(&db, &args, &cfg, &mut out).await.unwrap();
1438 }
1439 let s = env.stdout_str();
1440 assert!(s.contains("reflection pass report"));
1441 assert!(s.contains("no LLM client configured"));
1442 }
1443
1444 #[cfg(feature = "sal")]
1445 #[tokio::test]
1446 async fn reflect_no_llm_path_emits_json_report() {
1447 let mut env = TestEnv::fresh();
1449 let db = env.db_path.clone();
1450 let mut cfg = config::AppConfig::default();
1451 cfg.tier = Some("keyword".to_string());
1452 let mut args = default_args();
1453 args.reflect = true;
1454 args.namespace = Some("ns".to_string());
1455 args.dry_run = true;
1456 args.json = true;
1457 {
1458 let mut out = env.output();
1459 run(&db, &args, &cfg, &mut out).await.unwrap();
1460 }
1461 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1462 let errs = v["errors"].as_array().unwrap();
1464 assert!(errs.iter().any(|e| e.as_str().unwrap().contains("no LLM")));
1465 assert!(v["dry_run"].as_bool().unwrap());
1466 }
1467
1468 #[cfg(feature = "sal")]
1469 #[tokio::test]
1470 async fn reflect_all_namespaces_text_output() {
1471 let mut env = TestEnv::fresh();
1473 let db = env.db_path.clone();
1474 let mut cfg = config::AppConfig::default();
1475 cfg.tier = Some("keyword".to_string());
1476 let mut args = default_args();
1477 args.reflect = true;
1478 args.all_namespaces = true;
1479 args.dry_run = true;
1480 {
1481 let mut out = env.output();
1482 run(&db, &args, &cfg, &mut out).await.unwrap();
1483 }
1484 let s = env.stdout_str();
1485 assert!(s.contains("reflection pass report"));
1486 }
1487
1488 #[cfg(feature = "sal")]
1490 #[tokio::test]
1491 async fn store_url_sqlite_once_text_runs_sweep() {
1492 let mut env = TestEnv::fresh();
1496 let db = env.db_path.clone();
1497 let mut cfg = config::AppConfig::default();
1498 cfg.tier = Some("keyword".to_string()); let mut args = default_args();
1500 args.store_url = Some(format!("sqlite://{}", db.display()));
1501 args.once = true;
1502 args.dry_run = true;
1503 {
1504 let mut out = env.output();
1505 run(&db, &args, &cfg, &mut out).await.unwrap();
1506 }
1507 let s = env.stdout_str();
1508 assert!(s.contains("reflection pass report"));
1509 assert!(s.contains("no LLM client configured"));
1510 }
1511
1512 #[cfg(feature = "sal")]
1513 #[tokio::test]
1514 async fn store_url_sqlite_once_json_runs_sweep() {
1515 let mut env = TestEnv::fresh();
1516 let db = env.db_path.clone();
1517 let mut cfg = config::AppConfig::default();
1518 cfg.tier = Some("keyword".to_string());
1519 let mut args = default_args();
1520 args.store_url = Some(format!("sqlite://{}", db.display()));
1521 args.once = true;
1522 args.dry_run = true;
1523 args.json = true;
1524 {
1525 let mut out = env.output();
1526 run(&db, &args, &cfg, &mut out).await.unwrap();
1527 }
1528 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
1529 let errs = v["errors"].as_array().unwrap();
1530 assert!(errs.iter().any(|e| e.as_str().unwrap().contains("no LLM")));
1531 assert!(v["dry_run"].as_bool().unwrap());
1532 }
1533
1534 #[cfg(feature = "sal")]
1540 struct CovStubLlm;
1541 #[cfg(feature = "sal")]
1542 impl crate::autonomy::AutonomyLlm for CovStubLlm {
1543 fn auto_tag(&self, _title: &str, _content: &str) -> anyhow::Result<Vec<String>> {
1544 Ok(Vec::new())
1545 }
1546 fn detect_contradiction(&self, _a: &str, _b: &str) -> anyhow::Result<bool> {
1547 Ok(false)
1548 }
1549 fn summarize_memories(&self, _memories: &[(String, String)]) -> anyhow::Result<String> {
1550 Ok("stub reflection summary".to_string())
1551 }
1552 }
1553
1554 #[cfg(feature = "sal")]
1555 #[tokio::test]
1556 async fn reflection_helper_with_stub_llm_runs_with_llm_branch() {
1557 let env = TestEnv::fresh();
1561 let store = crate::store::sqlite::SqliteStore::open(&env.db_path).expect("open store");
1562 let stub = CovStubLlm;
1563 let args = default_args();
1564 let report = run_reflection_pass_with_optional_llm(
1565 &store,
1566 Some(&stub as &dyn crate::autonomy::AutonomyLlm),
1567 None,
1568 None,
1569 args.max_depth,
1570 true,
1571 |_ns: &str| true,
1572 )
1573 .await;
1574 assert!(report.dry_run);
1577 assert!(
1578 report.errors.is_empty(),
1579 "unexpected errors: {:?}",
1580 report.errors
1581 );
1582 use crate::autonomy::AutonomyLlm;
1585 assert!(stub.auto_tag("t", "c").unwrap().is_empty());
1586 assert!(!stub.detect_contradiction("a", "b").unwrap());
1587 assert_eq!(
1588 stub.summarize_memories(&[("a".to_string(), "b".to_string())])
1589 .unwrap(),
1590 "stub reflection summary"
1591 );
1592 }
1593
1594 #[cfg(feature = "sal")]
1595 #[tokio::test]
1596 async fn reflection_helper_with_none_llm_reports_configured_error() {
1597 let env = TestEnv::fresh();
1599 let store = crate::store::sqlite::SqliteStore::open(&env.db_path).expect("open store");
1600 let report = run_reflection_pass_with_optional_llm(
1601 &store,
1602 None,
1603 None,
1604 Some("ns"),
1605 None,
1606 false,
1607 |_ns: &str| true,
1608 )
1609 .await;
1610 assert!(!report.dry_run);
1611 assert!(
1612 report
1613 .errors
1614 .iter()
1615 .any(|e| e.contains("no LLM client configured"))
1616 );
1617 }
1618
1619 #[cfg(all(feature = "sal", unix))]
1620 #[tokio::test(flavor = "multi_thread")]
1621 async fn store_url_sqlite_daemon_loop_returns_on_shutdown() {
1622 use std::path::PathBuf;
1628 let env = TestEnv::fresh();
1629 let db: PathBuf = env.db_path.clone();
1630 let mut cfg = config::AppConfig::default();
1631 cfg.tier = Some("keyword".to_string());
1632 let mut args = default_args();
1633 args.store_url = Some(format!("sqlite://{}", db.display()));
1634 args.daemon = true;
1635 args.interval_secs = 60;
1636 args.dry_run = true;
1637 let kicker = tokio::spawn(async {
1638 tokio::time::sleep(std::time::Duration::from_millis(3000)).await;
1639 unsafe {
1641 libc::kill(libc::getpid(), libc::SIGINT);
1642 }
1643 });
1644 let mut stdout = Vec::<u8>::new();
1645 let mut stderr = Vec::<u8>::new();
1646 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1647 let res = tokio::time::timeout(
1648 std::time::Duration::from_secs(90),
1649 run(&db, &args, &cfg, &mut out),
1650 )
1651 .await;
1652 let _ = kicker.await;
1653 assert!(res.is_ok(), "SAL daemon did not return within timeout");
1654 assert!(res.unwrap().is_ok());
1655 }
1656
1657 #[test]
1658 fn print_reflection_report_emits_proposals_and_errors() {
1659 let r = crate::curator::reflection_pass::ReflectionPassReport {
1660 started_at: "2026-01-01T00:00:00Z".into(),
1661 completed_at: "2026-01-01T00:00:01Z".into(),
1662 namespaces_visited: 2,
1663 observations_scanned: 5,
1664 clusters_formed: 1,
1665 clusters_eligible: 1,
1666 reflections_persisted: 0,
1667 depth_refusals: 0,
1668 errors: vec!["a problem".to_string()],
1669 dry_run_proposals: vec![crate::curator::reflection_pass::DryRunProposal {
1670 namespace: "app".to_string(),
1671 proposed_title: "[reflection] pattern".to_string(),
1672 source_ids: vec!["a".to_string(), "b".to_string(), "c".to_string()],
1673 }],
1674 dry_run: true,
1675 };
1676 let mut stdout = Vec::<u8>::new();
1677 let mut stderr = Vec::<u8>::new();
1678 {
1679 let mut out = crate::cli::CliOutput::from_std(&mut stdout, &mut stderr);
1680 print_reflection_report(&r, &mut out).unwrap();
1681 }
1682 let s = String::from_utf8(stdout).unwrap();
1683 assert!(s.contains("reflection pass report"));
1684 assert!(s.contains("namespaces_visited:"));
1685 assert!(s.contains("observations_scanned:"));
1686 assert!(s.contains("- a problem"));
1687 assert!(s.contains("proposal: ns='app'"));
1688 assert!(s.contains("sources=3"));
1689 }
1690
1691 #[test]
1692 fn load_curator_keypair_best_effort_returns_some_or_none() {
1693 let _ = load_curator_keypair_best_effort();
1696 }
1697
1698 #[test]
1699 fn build_curator_llm_with_autonomous_tier() {
1700 crate::cli::test_utils::ensure_no_config_env();
1708 let _ = build_curator_llm(config::FeatureTier::Autonomous);
1709 }
1710
1711 #[cfg(feature = "sal")]
1712 #[tokio::test]
1713 async fn reflect_with_seeded_observations_and_no_llm() {
1714 let mut env = TestEnv::fresh();
1718 let db = env.db_path.clone();
1719 let _id = crate::cli::test_utils::seed_memory(&db, "myns", "T", "C");
1720 let mut cfg = config::AppConfig::default();
1721 cfg.tier = Some("keyword".to_string());
1722 let mut args = default_args();
1723 args.reflect = true;
1724 args.all_namespaces = true;
1725 args.dry_run = true;
1726 {
1727 let mut out = env.output();
1728 run(&db, &args, &cfg, &mut out).await.unwrap();
1729 }
1730 assert!(env.stdout_str().contains("reflection pass report"));
1731 }
1732
1733 #[test]
1740 fn qual_2_run_rollback_returns_error_when_no_mode_set() {
1741 let env = TestEnv::fresh();
1742 let db = env.db_path.clone();
1743 let args = default_args();
1744 let mut stdout: Vec<u8> = Vec::new();
1745 let mut stderr: Vec<u8> = Vec::new();
1746 let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1747 let res = run_rollback(&db, &args, &mut out);
1748 assert!(
1749 res.is_err(),
1750 "run_rollback must return Err when both --rollback and --rollback-last are None"
1751 );
1752 let msg = res.unwrap_err().to_string();
1753 assert!(
1754 msg.contains("run_rollback entered without --rollback or --rollback-last"),
1755 "unexpected error message: {msg}"
1756 );
1757 }
1758}