1use std::collections::{BTreeMap, BTreeSet};
38use std::path::Path;
39
40use serde::{Deserialize, Serialize};
41
42use crate::Engine;
43use crate::binding::{Binding, BuildMode};
44use crate::pipeline::IngestTrigger;
45use crate::pipeline_store::BindingConfigs;
46
47use super::cursor::{source_moved, source_moved_since};
48use super::findings::current_findings;
49use super::resolve::{ResolvedIngest, resolve_binding_run};
50
51pub const MAX_SKIP_LEVEL: u32 = 10;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum OperationKind {
59 Build,
61 Sync,
63 Verify,
65}
66
67impl OperationKind {
68 pub const ALL: [OperationKind; 3] = [
70 OperationKind::Build,
71 OperationKind::Sync,
72 OperationKind::Verify,
73 ];
74
75 pub fn as_wire(&self) -> &'static str {
77 match self {
78 OperationKind::Build => "build",
79 OperationKind::Sync => "sync",
80 OperationKind::Verify => "verify",
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum OperationFilter {
91 Only(OperationKind),
93 Any,
95}
96
97impl OperationFilter {
98 fn admits(self, op: OperationKind) -> bool {
99 match self {
100 OperationFilter::Only(only) => only == op,
101 OperationFilter::Any => true,
102 }
103 }
104}
105
106fn pair_key(binding_id: &str, op: OperationKind) -> String {
109 format!("{binding_id}#{}", op.as_wire())
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
114pub struct BackoffEntry {
115 #[serde(default)]
117 pub skip_remaining: u32,
118 #[serde(default)]
120 pub skip_level: u32,
121 #[serde(default)]
123 pub snapshot: String,
124}
125
126#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Cursor {
132 #[serde(default)]
134 pub last: Option<String>,
135}
136
137pub fn apply_backoff(entry: &mut BackoffEntry, current: &str) -> bool {
148 if !entry.snapshot.is_empty() && current != entry.snapshot {
149 entry.skip_remaining = 0;
150 entry.skip_level = 0;
151 entry.snapshot = current.to_string();
152 return false;
153 }
154 if entry.skip_remaining > 0 {
155 entry.skip_remaining -= 1;
156 return true;
157 }
158 if !entry.snapshot.is_empty() && current == entry.snapshot {
159 entry.skip_level = (entry.skip_level + 1).min(MAX_SKIP_LEVEL);
160 entry.skip_remaining = entry.skip_level;
161 }
162 entry.snapshot = current.to_string();
163 false
164}
165
166pub fn should_skip(
171 mode: BuildMode,
172 source_moved: bool,
173 entry: &mut BackoffEntry,
174 current: &str,
175) -> bool {
176 match mode {
177 BuildMode::OneShot => return false,
178 BuildMode::Discovery => {}
179 }
180 if source_moved {
181 return false;
182 }
183 apply_backoff(entry, current)
184}
185
186fn read_json<T: Default + for<'de> Deserialize<'de>>(cache_root: &Path, name: &str) -> T {
189 std::fs::read(cache_root.join(name))
190 .ok()
191 .and_then(|b| serde_json::from_slice(&b).ok())
192 .unwrap_or_default()
193}
194
195fn write_json<T: Serialize>(cache_root: &Path, name: &str, value: &T) {
196 let _ = std::fs::create_dir_all(cache_root);
197 if let Ok(bytes) = serde_json::to_vec(value) {
198 let _ = std::fs::write(cache_root.join(name), bytes);
199 }
200}
201
202fn read_one_shot_runs(cache_root: &Path) -> BTreeSet<String> {
204 let map: BTreeMap<String, bool> = read_json(cache_root, "ingest-one-shot-runs.json");
205 map.into_iter()
206 .filter(|(_, v)| *v)
207 .map(|(k, _)| k)
208 .collect()
209}
210
211pub fn select_next_due(
215 engine: &Engine,
216 workspace_root: &Path,
217 configs: &BindingConfigs,
218) -> Option<String> {
219 select_next_due_operation(
220 engine,
221 workspace_root,
222 configs,
223 OperationFilter::Only(OperationKind::Build),
224 )
225 .map(|(name, _)| name)
226}
227
228struct Pair<'a> {
230 key: String,
232 ingest: ResolvedIngest,
234 binding: &'a Binding,
236 op: OperationKind,
238}
239
240fn declared_for_loop(binding: &Binding, op: OperationKind) -> bool {
245 match op {
246 OperationKind::Build => binding
247 .operations
248 .build
249 .as_ref()
250 .is_some_and(|b| b.trigger == IngestTrigger::Loop),
251 OperationKind::Sync => binding
252 .operations
253 .sync
254 .as_ref()
255 .is_some_and(|s| s.trigger == IngestTrigger::Loop),
256 OperationKind::Verify => binding
257 .operations
258 .verify
259 .as_ref()
260 .is_some_and(|v| v.trigger == IngestTrigger::Loop),
261 }
262}
263
264fn operation_due(engine: &Engine, workspace_root: &Path, pair: &Pair<'_>) -> bool {
274 match pair.op {
275 OperationKind::Build => true,
276 OperationKind::Sync => {
277 source_moved(engine, &pair.ingest, workspace_root)
278 || current_findings(engine, workspace_root, pair.binding, &pair.ingest)
279 .map(|(_key, findings)| !findings.is_empty())
280 .unwrap_or(false)
281 }
282 OperationKind::Verify => {
283 source_moved_since(engine, &pair.ingest, workspace_root, "verified", true)
284 }
285 }
286}
287
288pub fn select_next_due_operation(
293 engine: &Engine,
294 workspace_root: &Path,
295 configs: &BindingConfigs,
296 filter: OperationFilter,
297) -> Option<(String, OperationKind)> {
298 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
299
300 let one_shot_ran = read_one_shot_runs(&cache_root);
305 let mut eligible: Vec<Pair<'_>> = Vec::new();
306 for record in &configs.bindings {
307 let binding_id = format!("{}/{}", record.mem, record.name);
308 let Ok(ingest) = resolve_binding_run(&binding_id, &record.config) else {
309 continue;
310 };
311 for op in OperationKind::ALL {
312 if !filter.admits(op) || !declared_for_loop(&record.config, op) {
313 continue;
314 }
315 if op == OperationKind::Build
316 && ingest.mode == BuildMode::OneShot
317 && one_shot_ran.contains(&ingest.name)
318 {
319 continue;
320 }
321 eligible.push(Pair {
322 key: pair_key(&ingest.name, op),
323 ingest: ingest.clone(),
324 binding: &record.config,
325 op,
326 });
327 }
328 }
329 eligible.sort_by(|a, b| a.key.cmp(&b.key));
330 let n = eligible.len();
331 if n == 0 {
332 return None;
333 }
334
335 let mut cursor: Cursor = read_json(&cache_root, "ingest-cursor.json");
337 let start = cursor
338 .last
339 .as_ref()
340 .and_then(|last| eligible.iter().position(|p| &p.key == last))
341 .map_or(0, |i| (i + 1) % n);
342 cursor.last = Some(eligible[start].key.clone());
343 write_json(&cache_root, "ingest-cursor.json", &cursor);
344
345 let mut backoff: BTreeMap<String, BackoffEntry> = read_json(&cache_root, "ingest-backoff.json");
349 backoff.retain(|k, _| k.contains('#'));
350 let mut selected = None;
351 for offset in 0..n {
352 let pair = &eligible[(start + offset) % n];
353 if !operation_due(engine, workspace_root, pair) {
354 continue;
355 }
356 let current = engine
357 .mem_head_sha(&pair.ingest.destination_mem)
358 .ok()
359 .flatten()
360 .unwrap_or_default();
361 let (mode, moved) = match pair.op {
367 OperationKind::Build => (
368 pair.ingest.mode,
369 source_moved(engine, &pair.ingest, workspace_root),
370 ),
371 OperationKind::Sync | OperationKind::Verify => (BuildMode::Discovery, false),
372 };
373 let entry = backoff.entry(pair.key.clone()).or_default();
374 if !should_skip(mode, moved, entry, ¤t) {
375 selected = Some((pair.ingest.name.clone(), pair.op));
376 break;
377 }
378 }
379 write_json(&cache_root, "ingest-backoff.json", &backoff);
380 selected
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
391 fn backoff_ramps_and_resets() {
392 let mut e = BackoffEntry::default();
393
394 assert!(!apply_backoff(&mut e, "sha1"));
396 assert_eq!(e.snapshot, "sha1");
397 assert_eq!(e.skip_level, 0);
398
399 assert!(!apply_backoff(&mut e, "sha1"));
401 assert_eq!(e.skip_level, 1);
402 assert_eq!(e.skip_remaining, 1);
403
404 assert!(apply_backoff(&mut e, "sha1"));
406 assert_eq!(e.skip_remaining, 0);
407
408 assert!(!apply_backoff(&mut e, "sha1"));
410 assert_eq!(e.skip_level, 2);
411 assert_eq!(e.skip_remaining, 2);
412
413 assert!(!apply_backoff(&mut e, "sha2"));
415 assert_eq!(e.skip_level, 0);
416 assert_eq!(e.skip_remaining, 0);
417 assert_eq!(e.snapshot, "sha2");
418 }
419
420 #[test]
422 fn backoff_caps_at_max_level() {
423 let mut e = BackoffEntry {
424 skip_level: MAX_SKIP_LEVEL,
425 skip_remaining: 0,
426 snapshot: "s".to_string(),
427 };
428 assert!(!apply_backoff(&mut e, "s")); assert_eq!(e.skip_level, MAX_SKIP_LEVEL, "capped");
430 assert_eq!(e.skip_remaining, MAX_SKIP_LEVEL);
431 }
432
433 #[test]
436 fn should_skip_honours_mode_and_source_movement() {
437 let mut e = BackoffEntry {
438 skip_remaining: 3,
439 skip_level: 3,
440 snapshot: "s".to_string(),
441 };
442 assert!(!should_skip(BuildMode::OneShot, false, &mut e.clone(), "s"));
444 let mut e2 = e.clone();
446 assert!(!should_skip(BuildMode::Discovery, true, &mut e2, "s"));
447 assert_eq!(e2.skip_remaining, 3, "moved source does not touch backoff");
448 assert!(should_skip(BuildMode::Discovery, false, &mut e, "s"));
450 }
451
452 use crate::binding::{
455 BINDING_VERSION, BuildOperation, Operations, SyncOperation, VerifyOperation, hash_binding,
456 };
457 use crate::pipeline::{MediumType, PatternEntry, PatternMode, Source};
458 use crate::pipeline_store::MemPipelineRecord;
459
460 use super::super::findings::{
461 Finding, FindingClass, FindingKey, FindingTarget, FindingsStore, write_findings_store,
462 };
463
464 fn empty_engine() -> Engine {
465 Engine::from_mounts(Vec::new()).unwrap()
466 }
467
468 fn binding_with(operations: Operations) -> Binding {
469 Binding {
470 version: BINDING_VERSION,
471 intent: None,
472 sources: Vec::new(),
473 reference_mems: Vec::new(),
474 destination_mem: "m".to_string(),
475 deny_paths: Vec::new(),
476 coverage_semantics: None,
477 rules: None,
478 prune: None,
479 operations,
480 }
481 }
482
483 fn build_op(trigger: IngestTrigger) -> BuildOperation {
484 BuildOperation {
485 mode: BuildMode::Discovery,
486 trigger,
487 batch_size: 20,
488 post_actions: None,
489 }
490 }
491
492 fn record(name: &str, config: Binding) -> MemPipelineRecord<Binding> {
493 MemPipelineRecord {
494 mem: "m".to_string(),
495 name: name.to_string(),
496 config,
497 }
498 }
499
500 fn configs_of(bindings: Vec<MemPipelineRecord<Binding>>) -> BindingConfigs {
501 BindingConfigs {
502 bindings,
503 quarantined: Vec::new(),
504 }
505 }
506
507 #[test]
511 fn eligibility_requires_block_and_loop_trigger() {
512 let ws = tempfile::tempdir().unwrap();
513 let engine = empty_engine();
514 let configs = configs_of(vec![
515 record(
517 "a",
518 binding_with(Operations {
519 build: Some(build_op(IngestTrigger::Loop)),
520 sync: None,
521 verify: None,
522 }),
523 ),
524 record(
526 "b",
527 binding_with(Operations {
528 build: Some(build_op(IngestTrigger::Manual)),
529 sync: None,
530 verify: None,
531 }),
532 ),
533 record(
535 "c",
536 binding_with(Operations {
537 build: None,
538 sync: Some(SyncOperation {
539 trigger: IngestTrigger::Manual,
540 batch_size: 20,
541 }),
542 verify: Some(VerifyOperation {
543 trigger: IngestTrigger::Manual,
544 batch_size: 20,
545 adjudication_cap: 50,
546 full_resync_every: 20,
547 }),
548 }),
549 ),
550 ]);
551
552 assert_eq!(
554 select_next_due_operation(
555 &engine,
556 ws.path(),
557 &configs,
558 OperationFilter::Only(OperationKind::Build)
559 ),
560 Some(("m/a".to_string(), OperationKind::Build))
561 );
562 assert_eq!(
563 select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any),
564 Some(("m/a".to_string(), OperationKind::Build))
565 );
566 assert_eq!(
568 select_next_due_operation(
569 &engine,
570 ws.path(),
571 &configs,
572 OperationFilter::Only(OperationKind::Sync)
573 ),
574 None
575 );
576 assert_eq!(
577 select_next_due_operation(
578 &engine,
579 ws.path(),
580 &configs,
581 OperationFilter::Only(OperationKind::Verify)
582 ),
583 None
584 );
585 }
586
587 #[test]
591 fn sync_pair_due_only_on_open_findings_when_source_unmoved() {
592 let ws = tempfile::tempdir().unwrap();
593 let engine = empty_engine();
594 let binding = binding_with(Operations {
595 build: None,
596 sync: Some(SyncOperation {
597 trigger: IngestTrigger::Loop,
598 batch_size: 20,
599 }),
600 verify: None,
601 });
602 let configs = configs_of(vec![record("s", binding.clone())]);
603
604 assert_eq!(
606 select_next_due_operation(
607 &engine,
608 ws.path(),
609 &configs,
610 OperationFilter::Only(OperationKind::Sync)
611 ),
612 None
613 );
614
615 let key = FindingKey {
617 binding_hash: hash_binding(&binding),
618 source_head: String::new(),
619 };
620
621 let mut store = FindingsStore {
623 binding: "m/s".to_string(),
624 batches: Vec::new(),
625 };
626 store.record(key.clone(), "0".to_string(), Vec::new());
627 write_findings_store(ws.path(), "m", "s", &store).unwrap();
628 assert_eq!(
629 select_next_due_operation(
630 &engine,
631 ws.path(),
632 &configs,
633 OperationFilter::Only(OperationKind::Sync)
634 ),
635 None
636 );
637
638 store.record(
640 key.clone(),
641 "1".to_string(),
642 vec![Finding {
643 key: key.clone(),
644 facet: "f".to_string(),
645 target: FindingTarget::Artifact {
646 artifact: "a.rs".to_string(),
647 },
648 class: FindingClass::Uncovered,
649 detail: "no anchor".to_string(),
650 created_at: "1".to_string(),
651 }],
652 );
653 write_findings_store(ws.path(), "m", "s", &store).unwrap();
654 assert_eq!(
655 select_next_due_operation(
656 &engine,
657 ws.path(),
658 &configs,
659 OperationFilter::Only(OperationKind::Sync)
660 ),
661 Some(("m/s".to_string(), OperationKind::Sync))
662 );
663
664 let mut stale = FindingsStore {
666 binding: "m/s".to_string(),
667 batches: Vec::new(),
668 };
669 let stale_key = FindingKey {
670 binding_hash: "0000".to_string(),
671 source_head: "old".to_string(),
672 };
673 stale.record(
674 stale_key.clone(),
675 "1".to_string(),
676 vec![Finding {
677 key: stale_key,
678 facet: "f".to_string(),
679 target: FindingTarget::Artifact {
680 artifact: "a.rs".to_string(),
681 },
682 class: FindingClass::Uncovered,
683 detail: "stale".to_string(),
684 created_at: "1".to_string(),
685 }],
686 );
687 write_findings_store(ws.path(), "m", "s", &stale).unwrap();
688 assert_eq!(
689 select_next_due_operation(
690 &engine,
691 ws.path(),
692 &configs,
693 OperationFilter::Only(OperationKind::Sync)
694 ),
695 None,
696 "superseded findings must not pull a sync into rotation"
697 );
698 }
699
700 fn configs_with_live_source(operations: Operations) -> BindingConfigs {
702 let mut binding = binding_with(operations);
703 binding.sources = vec![Source {
704 name: "f".to_string(),
705 medium_type: MediumType::Filesystem,
706 pointer: String::new(),
707 change_detection: Some("mtime".to_string()),
708 scope: vec![PatternEntry {
709 path: "**/*.rs".to_string(),
710 mode: PatternMode::Allow,
711 }],
712 engagement: None,
713 preparation: None,
714 }];
715 BindingConfigs {
716 bindings: vec![record("v", binding)],
717 quarantined: Vec::new(),
718 }
719 }
720
721 #[test]
725 fn verify_pair_due_when_never_verified_with_live_token() {
726 let ws = tempfile::tempdir().unwrap();
727 std::fs::write(ws.path().join("a.rs"), "x").unwrap();
728 let engine = empty_engine();
729 let verify_loop = Operations {
730 build: None,
731 sync: None,
732 verify: Some(VerifyOperation {
733 trigger: IngestTrigger::Loop,
734 batch_size: 20,
735 adjudication_cap: 50,
736 full_resync_every: 20,
737 }),
738 };
739
740 let configs = configs_with_live_source(verify_loop.clone());
742 assert_eq!(
743 select_next_due_operation(
744 &engine,
745 ws.path(),
746 &configs,
747 OperationFilter::Only(OperationKind::Verify)
748 ),
749 Some(("m/v".to_string(), OperationKind::Verify))
750 );
751
752 let mut no_signal = configs_with_live_source(verify_loop);
754 no_signal.bindings[0].config.sources[0].scope.clear();
755 assert_eq!(
756 select_next_due_operation(
757 &engine,
758 ws.path(),
759 &no_signal,
760 OperationFilter::Only(OperationKind::Verify)
761 ),
762 None
763 );
764 }
765
766 #[test]
769 fn any_filter_rotates_across_pairs() {
770 let ws = tempfile::tempdir().unwrap();
771 std::fs::write(ws.path().join("a.rs"), "x").unwrap();
772 let engine = empty_engine();
773
774 let mut configs = configs_with_live_source(Operations {
777 build: None,
778 sync: None,
779 verify: Some(VerifyOperation {
780 trigger: IngestTrigger::Loop,
781 batch_size: 20,
782 adjudication_cap: 50,
783 full_resync_every: 20,
784 }),
785 });
786 configs.bindings.push(record(
787 "a",
788 binding_with(Operations {
789 build: Some(build_op(IngestTrigger::Loop)),
790 sync: None,
791 verify: None,
792 }),
793 ));
794
795 let next = || {
796 select_next_due_operation(&engine, ws.path(), &configs, OperationFilter::Any).unwrap()
797 };
798 assert_eq!(next(), ("m/a".to_string(), OperationKind::Build));
799 assert_eq!(next(), ("m/v".to_string(), OperationKind::Verify));
800 assert_eq!(next(), ("m/a".to_string(), OperationKind::Build));
801 }
802
803 #[test]
807 fn legacy_single_key_backoff_entries_are_discarded() {
808 let ws = tempfile::tempdir().unwrap();
809 let engine = empty_engine();
810 let cache_root = ws.path().join(".memstead.cache").join("ingest");
811 std::fs::create_dir_all(&cache_root).unwrap();
812 let legacy: BTreeMap<String, BackoffEntry> = [(
813 "m/a".to_string(),
814 BackoffEntry {
815 skip_remaining: 5,
816 skip_level: 5,
817 snapshot: "s".to_string(),
818 },
819 )]
820 .into();
821 std::fs::write(
822 cache_root.join("ingest-backoff.json"),
823 serde_json::to_vec(&legacy).unwrap(),
824 )
825 .unwrap();
826
827 let configs = configs_of(vec![record(
828 "a",
829 binding_with(Operations {
830 build: Some(build_op(IngestTrigger::Loop)),
831 sync: None,
832 verify: None,
833 }),
834 )]);
835 assert_eq!(
836 select_next_due_operation(
837 &engine,
838 ws.path(),
839 &configs,
840 OperationFilter::Only(OperationKind::Build)
841 ),
842 Some(("m/a".to_string(), OperationKind::Build)),
843 "a legacy entry's pending skips are discarded, not honoured"
844 );
845
846 let rewritten: BTreeMap<String, BackoffEntry> =
847 serde_json::from_slice(&std::fs::read(cache_root.join("ingest-backoff.json")).unwrap())
848 .unwrap();
849 assert!(!rewritten.contains_key("m/a"), "legacy key pruned");
850 assert!(rewritten.contains_key("m/a#build"), "pair key written");
851 }
852}