1use std::path::Path;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22
23use nyx_agent_types::event::{AgentEvent, EventSink, RepoOutcomeTag, RunEvent};
24use rayon::iter::{IntoParallelIterator, ParallelIterator};
25use rayon::ThreadPoolBuilder;
26use serde::{Deserialize, Serialize};
27
28use crate::config::PerformanceConfig;
29use crate::project::Project;
30use crate::repo::IngestedRepo;
31use crate::time::now_epoch_ms;
32
33mod workspace;
34
35pub use workspace::WorkspaceHandle;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Run {
42 pub id: String,
43 pub started_at_ms: i64,
44}
45
46impl Run {
47 pub fn new() -> Self {
51 Self { id: mint_run_id(), started_at_ms: now_epoch_ms() }
52 }
53
54 pub fn with_id(id: impl Into<String>) -> Self {
57 Self { id: id.into(), started_at_ms: now_epoch_ms() }
58 }
59}
60
61impl Default for Run {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum InconclusiveReason {
70 StaticPassTimeout,
72}
73
74impl InconclusiveReason {
75 pub fn as_str(self) -> &'static str {
76 match self {
77 InconclusiveReason::StaticPassTimeout => "StaticPassTimeout",
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
85pub enum RepoOutcome<D> {
86 Success(Vec<D>),
87 Inconclusive(InconclusiveReason),
88 Failed(String),
89}
90
91impl<D> RepoOutcome<D> {
92 pub fn tag(&self) -> RepoOutcomeTag {
93 match self {
94 RepoOutcome::Success(_) => RepoOutcomeTag::Success,
95 RepoOutcome::Inconclusive(_) => RepoOutcomeTag::Inconclusive,
96 RepoOutcome::Failed(_) => RepoOutcomeTag::Failed,
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct RepoBundle<D> {
104 pub repo: String,
105 pub outcome: RepoOutcome<D>,
106 pub started_at_ms: i64,
107 pub finished_at_ms: i64,
108 pub elapsed_ms: i64,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct CrossRepoCallgraphStub {
116 pub nodes: Vec<String>,
117 pub edges: Vec<CrossRepoEdge>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct CrossRepoEdge {
122 pub from_repo: String,
123 pub to_repo: String,
124}
125
126#[derive(Debug, Clone)]
128pub struct RunBundle<D> {
129 pub run_id: String,
130 pub project_id: String,
131 pub started_at_ms: i64,
132 pub finished_at_ms: i64,
133 pub wall_clock_ms: i64,
134 pub per_repo: Vec<RepoBundle<D>>,
135 pub callgraph: CrossRepoCallgraphStub,
136}
137
138impl<D> RunBundle<D> {
139 pub fn counts(&self) -> RunCounts {
142 let mut c = RunCounts::default();
143 for b in &self.per_repo {
144 match b.outcome.tag() {
145 RepoOutcomeTag::Success => c.succeeded += 1,
146 RepoOutcomeTag::Inconclusive => c.inconclusive += 1,
147 RepoOutcomeTag::Failed => c.failed += 1,
148 }
149 }
150 c
151 }
152
153 pub fn iter_successes(&self) -> impl Iterator<Item = (&str, &[D])> {
154 self.per_repo.iter().filter_map(|b| match &b.outcome {
155 RepoOutcome::Success(diags) => Some((b.repo.as_str(), diags.as_slice())),
156 _ => None,
157 })
158 }
159}
160
161#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
162pub struct RunCounts {
163 pub succeeded: u32,
164 pub inconclusive: u32,
165 pub failed: u32,
166}
167
168#[derive(Debug, Clone)]
170pub enum ScanLaneError {
171 Timeout,
173 Failed(String),
176}
177
178pub trait ScanLane<D>: Send + Sync + 'static {
185 fn scan_blocking(
191 &self,
192 workspace: &WorkspaceHandle,
193 timeout: Duration,
194 ) -> Result<Vec<D>, ScanLaneError>;
195}
196
197impl<D, F> ScanLane<D> for F
198where
199 F: Fn(&WorkspaceHandle, Duration) -> Result<Vec<D>, ScanLaneError> + Send + Sync + 'static,
200{
201 fn scan_blocking(
202 &self,
203 workspace: &WorkspaceHandle,
204 timeout: Duration,
205 ) -> Result<Vec<D>, ScanLaneError> {
206 (self)(workspace, timeout)
207 }
208}
209
210#[derive(Debug, Clone)]
213pub struct RunDispatcher {
214 static_concurrency: usize,
215 per_repo_timeout: Duration,
216 event_sink: Option<EventSink>,
217 attempted_repos: Option<Vec<String>>,
218 emit_run_lifecycle: bool,
219}
220
221impl RunDispatcher {
222 pub fn from_config(
228 cfg: &PerformanceConfig,
229 repo_count: usize,
230 event_sink: Option<EventSink>,
231 ) -> Self {
232 let concurrency = resolve_concurrency(cfg.static_concurrency_override(), repo_count);
233 Self {
234 static_concurrency: concurrency,
235 per_repo_timeout: cfg.per_repo_timeout(),
236 event_sink,
237 attempted_repos: None,
238 emit_run_lifecycle: true,
239 }
240 }
241
242 pub fn with_explicit(
244 static_concurrency: usize,
245 per_repo_timeout: Duration,
246 event_sink: Option<EventSink>,
247 ) -> Self {
248 Self {
249 static_concurrency: static_concurrency.max(1),
250 per_repo_timeout,
251 event_sink,
252 attempted_repos: None,
253 emit_run_lifecycle: true,
254 }
255 }
256
257 pub fn with_attempted_repos(mut self, attempted: Vec<String>) -> Self {
263 self.attempted_repos = Some(attempted);
264 self
265 }
266
267 pub fn without_run_lifecycle(mut self) -> Self {
272 self.emit_run_lifecycle = false;
273 self
274 }
275
276 pub fn static_concurrency(&self) -> usize {
277 self.static_concurrency
278 }
279
280 pub fn per_repo_timeout(&self) -> Duration {
281 self.per_repo_timeout
282 }
283
284 pub fn dispatch_project<L, D>(
301 &self,
302 project: &Project,
303 run: Run,
304 lane: Arc<L>,
305 workspaces: Vec<WorkspaceHandle>,
306 ) -> RunBundle<D>
307 where
308 L: ScanLane<D> + ?Sized,
309 D: Send + 'static,
310 {
311 let project_id = project.id.as_str().to_string();
312 let repos = match &self.attempted_repos {
313 Some(list) => list.clone(),
314 None => workspaces.iter().map(|w| w.name().to_string()).collect(),
315 };
316 if self.emit_run_lifecycle {
317 self.emit(AgentEvent::Run {
318 data: RunEvent::RunStarted {
319 run_id: run.id.clone(),
320 project_id: project_id.clone(),
321 repos,
322 started_at_ms: run.started_at_ms,
323 },
324 });
325 self.emit(AgentEvent::Run {
326 data: RunEvent::ProjectStarted {
327 run_id: run.id.clone(),
328 project_id: project_id.clone(),
329 project_name: project.name.clone(),
330 started_at_ms: run.started_at_ms,
331 },
332 });
333 }
334
335 let pool = ThreadPoolBuilder::new()
336 .num_threads(self.static_concurrency)
337 .thread_name(|i| format!("nyx-static-{i}"))
338 .build()
339 .expect("rayon thread pool");
340
341 let wall_start = Instant::now();
342 let dispatcher_view = DispatcherView {
343 run_id: run.id.clone(),
344 project_id: project_id.clone(),
345 timeout: self.per_repo_timeout,
346 event_sink: self.event_sink.clone(),
347 };
348 let lane_for_pool = Arc::clone(&lane);
349 let bundles: Vec<RepoBundle<D>> = pool.install(|| {
350 workspaces
351 .into_par_iter()
352 .map(|ws| run_one_repo(&ws, &lane_for_pool, &dispatcher_view))
353 .collect()
354 });
355 let wall_clock_ms = wall_start.elapsed().as_millis() as i64;
356 let finished_at_ms = now_epoch_ms();
357
358 let mut counts = RunCounts::default();
359 for b in &bundles {
360 match b.outcome.tag() {
361 RepoOutcomeTag::Success => counts.succeeded += 1,
362 RepoOutcomeTag::Inconclusive => counts.inconclusive += 1,
363 RepoOutcomeTag::Failed => counts.failed += 1,
364 }
365 }
366
367 if self.emit_run_lifecycle {
368 self.emit(AgentEvent::Run {
369 data: RunEvent::ProjectFinished {
370 run_id: run.id.clone(),
371 project_id: project_id.clone(),
372 finished_at_ms,
373 },
374 });
375 self.emit(AgentEvent::Run {
376 data: RunEvent::RunFinished {
377 run_id: run.id.clone(),
378 project_id: project_id.clone(),
379 finished_at_ms,
380 wall_clock_ms,
381 succeeded: counts.succeeded,
382 inconclusive: counts.inconclusive,
383 failed: counts.failed,
384 },
385 });
386 }
387
388 let callgraph = CrossRepoCallgraphStub {
389 nodes: bundles
390 .iter()
391 .filter(|b| matches!(b.outcome.tag(), RepoOutcomeTag::Success))
392 .map(|b| b.repo.clone())
393 .collect(),
394 edges: Vec::new(),
395 };
396
397 RunBundle {
398 run_id: run.id,
399 project_id,
400 started_at_ms: run.started_at_ms,
401 finished_at_ms,
402 wall_clock_ms,
403 per_repo: bundles,
404 callgraph,
405 }
406 }
407
408 fn emit(&self, ev: AgentEvent) {
409 if let Some(sink) = &self.event_sink {
410 let _ = sink.send(ev);
414 }
415 }
416}
417
418struct DispatcherView {
419 run_id: String,
420 project_id: String,
421 timeout: Duration,
422 event_sink: Option<EventSink>,
423}
424
425impl DispatcherView {
426 fn emit(&self, ev: AgentEvent) {
427 if let Some(sink) = &self.event_sink {
428 let _ = sink.send(ev);
429 }
430 }
431}
432
433fn run_one_repo<L, D>(
434 workspace: &WorkspaceHandle,
435 lane: &Arc<L>,
436 view: &DispatcherView,
437) -> RepoBundle<D>
438where
439 L: ScanLane<D> + ?Sized,
440{
441 let started_at_ms = now_epoch_ms();
442 let start = Instant::now();
443 view.emit(AgentEvent::Run {
444 data: RunEvent::RepoStarted {
445 run_id: view.run_id.clone(),
446 project_id: view.project_id.clone(),
447 repo: workspace.name().to_string(),
448 started_at_ms,
449 },
450 });
451
452 let result = lane.scan_blocking(workspace, view.timeout);
453 let elapsed_ms = start.elapsed().as_millis() as i64;
454 let finished_at_ms = now_epoch_ms();
455
456 let outcome = match result {
457 Ok(diags) => {
458 view.emit(AgentEvent::Run {
459 data: RunEvent::RepoStaticDone {
460 run_id: view.run_id.clone(),
461 project_id: view.project_id.clone(),
462 repo: workspace.name().to_string(),
463 n_diags: diags.len() as u32,
464 elapsed_ms,
465 },
466 });
467 RepoOutcome::Success(diags)
468 }
469 Err(ScanLaneError::Timeout) => {
470 view.emit(AgentEvent::Run {
471 data: RunEvent::RepoFailed {
472 run_id: view.run_id.clone(),
473 project_id: view.project_id.clone(),
474 repo: workspace.name().to_string(),
475 message: format!("static-pass timeout after {}s", view.timeout.as_secs()),
476 elapsed_ms,
477 },
478 });
479 RepoOutcome::Inconclusive(InconclusiveReason::StaticPassTimeout)
480 }
481 Err(ScanLaneError::Failed(msg)) => {
482 view.emit(AgentEvent::Run {
483 data: RunEvent::RepoFailed {
484 run_id: view.run_id.clone(),
485 project_id: view.project_id.clone(),
486 repo: workspace.name().to_string(),
487 message: msg.clone(),
488 elapsed_ms,
489 },
490 });
491 RepoOutcome::Failed(msg)
492 }
493 };
494
495 view.emit(AgentEvent::Run {
496 data: RunEvent::RepoFinished {
497 run_id: view.run_id.clone(),
498 project_id: view.project_id.clone(),
499 repo: workspace.name().to_string(),
500 outcome: outcome.tag(),
501 elapsed_ms,
502 },
503 });
504
505 RepoBundle {
506 repo: workspace.name().to_string(),
507 outcome,
508 started_at_ms,
509 finished_at_ms,
510 elapsed_ms,
511 }
512}
513
514fn resolve_concurrency(override_value: Option<usize>, repo_count: usize) -> usize {
515 if repo_count == 0 {
516 return 1;
517 }
518 if let Some(n) = override_value {
519 return n.max(1);
520 }
521 let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2);
522 std::cmp::max(1, std::cmp::min(cores / 2, repo_count))
523}
524
525static RUN_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
526
527pub fn mint_run_id() -> String {
534 let ms = now_epoch_ms();
535 let n = RUN_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
536 format!("run-{ms:013x}-{n:08x}")
537}
538
539pub fn workspace_handle_from(ingested: IngestedRepo) -> WorkspaceHandle {
544 WorkspaceHandle::new(ingested)
545}
546
547pub fn workspace_path(handle: &WorkspaceHandle) -> &Path {
550 handle.workspace()
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::project::ProjectId;
557 use crate::repo::{Repo, RepoSource};
558 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
559 use std::thread::sleep;
560 use tokio::sync::broadcast;
561
562 fn handle_for(name: &str, path: &Path) -> WorkspaceHandle {
563 let ingested = IngestedRepo {
564 name: name.to_string(),
565 workspace: path.to_path_buf(),
566 source: RepoSource::LocalPath { path: path.to_path_buf() },
567 snapshot_backend: None,
568 on_disk_git_remote: None,
569 cleanup: None,
570 };
571 WorkspaceHandle::new(ingested)
572 }
573
574 fn test_project() -> Project {
575 Project {
576 id: ProjectId::new("test-project"),
577 name: "test-project".to_string(),
578 description: None,
579 target_base_url: None,
580 env_config: None,
581 runtime_profile: None,
582 default_launch_profile: None,
583 }
584 }
585
586 fn _types_compile_for_repo(_: &Repo) {}
587
588 #[test]
589 fn mint_run_id_is_unique_within_one_ms() {
590 let a = mint_run_id();
591 let b = mint_run_id();
592 assert_ne!(a, b, "counter must distinguish ids minted in the same ms");
593 }
594
595 #[test]
596 fn mint_run_id_is_lexicographically_increasing_within_one_ms() {
597 let a = mint_run_id();
598 let b = mint_run_id();
599 assert!(b > a, "minter must keep run ids monotonic within one ms");
600 }
601
602 #[test]
603 fn resolve_concurrency_falls_back_to_min_of_cores_and_repos() {
604 assert_eq!(resolve_concurrency(Some(3), 8), 3);
606 assert_eq!(resolve_concurrency(None, 0), 1);
608 assert_eq!(resolve_concurrency(Some(0), 4), 1);
611 assert!(resolve_concurrency(None, 1) <= 1);
613 }
614
615 #[test]
616 fn two_repos_scan_concurrently_under_sum_of_serial() {
617 let tmp_a = tempfile::tempdir().expect("a");
622 let tmp_b = tempfile::tempdir().expect("b");
623 let workspaces = vec![handle_for("alpha", tmp_a.path()), handle_for("beta", tmp_b.path())];
624
625 let lane: Arc<dyn ScanLane<()>> =
626 Arc::new(|_w: &WorkspaceHandle, _t: Duration| -> Result<Vec<()>, ScanLaneError> {
627 sleep(Duration::from_millis(200));
628 Ok(Vec::new())
629 });
630 let dispatcher = RunDispatcher::with_explicit(2, Duration::from_secs(5), None);
631 let run = Run::with_id("run-test-concurrent");
632
633 let start = Instant::now();
634 let bundle = dispatcher.dispatch_project::<dyn ScanLane<()>, ()>(
635 &test_project(),
636 run,
637 lane,
638 workspaces,
639 );
640 let elapsed = start.elapsed();
641
642 assert!(
643 elapsed < Duration::from_millis(380),
644 "expected concurrent fan-out (~200ms), got {elapsed:?}"
645 );
646 assert_eq!(bundle.per_repo.len(), 2);
647 assert!(bundle.per_repo.iter().all(|b| matches!(b.outcome.tag(), RepoOutcomeTag::Success)));
648 }
649
650 #[test]
651 fn slow_repo_timeout_does_not_block_fast_repo() {
652 let tmp_fast = tempfile::tempdir().expect("fast");
655 let tmp_slow = tempfile::tempdir().expect("slow");
656 let workspaces =
657 vec![handle_for("fast", tmp_fast.path()), handle_for("slow", tmp_slow.path())];
658
659 let timeout = Duration::from_millis(50);
660 let lane: Arc<dyn ScanLane<()>> = Arc::new(
661 move |w: &WorkspaceHandle, budget: Duration| -> Result<Vec<()>, ScanLaneError> {
662 if w.name() == "slow" {
663 sleep(budget + Duration::from_millis(50));
665 return Err(ScanLaneError::Timeout);
666 }
667 Ok(Vec::new())
668 },
669 );
670 let dispatcher = RunDispatcher::with_explicit(2, timeout, None);
671 let run = Run::with_id("run-test-timeout");
672
673 let bundle = dispatcher.dispatch_project::<dyn ScanLane<()>, ()>(
674 &test_project(),
675 run,
676 lane,
677 workspaces,
678 );
679
680 let fast = bundle.per_repo.iter().find(|b| b.repo == "fast").expect("fast bundle");
681 assert!(
682 matches!(fast.outcome.tag(), RepoOutcomeTag::Success),
683 "fast repo must finish despite slow neighbour timing out"
684 );
685 let slow = bundle.per_repo.iter().find(|b| b.repo == "slow").expect("slow bundle");
686 match &slow.outcome {
687 RepoOutcome::Inconclusive(InconclusiveReason::StaticPassTimeout) => {}
688 other => panic!("expected StaticPassTimeout, got {other:?}"),
689 }
690 let counts = bundle.counts();
691 assert_eq!(counts, RunCounts { succeeded: 1, inconclusive: 1, failed: 0 });
692 }
693
694 #[test]
695 fn run_finished_event_is_published_when_sink_present() {
696 let (tx, mut rx) = broadcast::channel::<AgentEvent>(16);
697 let tmp = tempfile::tempdir().expect("tmp");
698 let workspaces = vec![handle_for("solo", tmp.path())];
699 let lane: Arc<dyn ScanLane<()>> =
700 Arc::new(|_w: &WorkspaceHandle, _t: Duration| -> Result<Vec<()>, ScanLaneError> {
701 Ok(Vec::new())
702 });
703 let dispatcher = RunDispatcher::with_explicit(1, Duration::from_secs(5), Some(tx.clone()));
704 let run = Run::with_id("run-evt");
705 let _ = dispatcher.dispatch_project::<dyn ScanLane<()>, ()>(
706 &test_project(),
707 run,
708 lane,
709 workspaces,
710 );
711
712 let mut saw_run_started = false;
713 let mut saw_project_started = false;
714 let mut saw_repo_started = false;
715 let mut saw_repo_static_done = false;
716 let mut saw_repo_finished = false;
717 let mut saw_project_finished = false;
718 let mut saw_run_finished = false;
719 while let Ok(ev) = rx.try_recv() {
720 if let AgentEvent::Run { data } = ev {
721 match data {
722 RunEvent::RunStarted { .. } => saw_run_started = true,
723 RunEvent::ProjectStarted { .. } => saw_project_started = true,
724 RunEvent::RepoStarted { .. } => saw_repo_started = true,
725 RunEvent::RepoStaticDone { .. } => saw_repo_static_done = true,
726 RunEvent::RepoFinished { .. } => saw_repo_finished = true,
727 RunEvent::ProjectFinished { .. } => saw_project_finished = true,
728 RunEvent::RunFinished { .. } => saw_run_finished = true,
729 _ => {}
730 }
731 }
732 }
733 assert!(saw_run_started);
734 assert!(saw_project_started);
735 assert!(saw_repo_started);
736 assert!(saw_repo_static_done);
737 assert!(saw_repo_finished);
738 assert!(saw_project_finished);
739 assert!(saw_run_finished);
740 }
741
742 #[test]
743 fn run_lifecycle_can_be_suppressed_for_pentest_orchestrator() {
744 let (tx, mut rx) = broadcast::channel::<AgentEvent>(16);
745 let tmp = tempfile::tempdir().expect("tmp");
746 let workspaces = vec![handle_for("solo", tmp.path())];
747 let lane: Arc<dyn ScanLane<()>> =
748 Arc::new(|_w: &WorkspaceHandle, _t: Duration| -> Result<Vec<()>, ScanLaneError> {
749 Ok(Vec::new())
750 });
751 let dispatcher = RunDispatcher::with_explicit(1, Duration::from_secs(5), Some(tx.clone()))
752 .without_run_lifecycle();
753 let run = Run::with_id("run-evt");
754 let _ = dispatcher.dispatch_project::<dyn ScanLane<()>, ()>(
755 &test_project(),
756 run,
757 lane,
758 workspaces,
759 );
760
761 let mut saw_repo_started = false;
762 let mut saw_repo_static_done = false;
763 let mut saw_run_lifecycle = false;
764 while let Ok(ev) = rx.try_recv() {
765 if let AgentEvent::Run { data } = ev {
766 match data {
767 RunEvent::RepoStarted { .. } => saw_repo_started = true,
768 RunEvent::RepoStaticDone { .. } => saw_repo_static_done = true,
769 RunEvent::RunStarted { .. }
770 | RunEvent::ProjectStarted { .. }
771 | RunEvent::ProjectFinished { .. }
772 | RunEvent::RunFinished { .. } => saw_run_lifecycle = true,
773 _ => {}
774 }
775 }
776 }
777 assert!(saw_repo_started);
778 assert!(saw_repo_static_done);
779 assert!(!saw_run_lifecycle);
780 }
781
782 #[test]
783 fn lane_failure_yields_failed_outcome() {
784 let tmp = tempfile::tempdir().expect("tmp");
785 let workspaces = vec![handle_for("broken", tmp.path())];
786 let lane: Arc<dyn ScanLane<()>> =
787 Arc::new(|_w: &WorkspaceHandle, _t: Duration| -> Result<Vec<()>, ScanLaneError> {
788 Err(ScanLaneError::Failed("scanner crashed".to_string()))
789 });
790 let dispatcher = RunDispatcher::with_explicit(1, Duration::from_secs(5), None);
791 let bundle = dispatcher.dispatch_project::<dyn ScanLane<()>, ()>(
792 &test_project(),
793 Run::with_id("run-fail"),
794 lane,
795 workspaces,
796 );
797 let b = bundle.per_repo.into_iter().next().expect("one bundle");
798 match b.outcome {
799 RepoOutcome::Failed(msg) => assert!(msg.contains("scanner crashed")),
800 other => panic!("expected Failed, got {other:?}"),
801 }
802 }
803
804 #[test]
805 fn callgraph_stub_lists_only_successful_repos() {
806 let tmp_a = tempfile::tempdir().expect("a");
807 let tmp_b = tempfile::tempdir().expect("b");
808 let workspaces = vec![handle_for("ok", tmp_a.path()), handle_for("broken", tmp_b.path())];
809 let lane: Arc<dyn ScanLane<u32>> =
810 Arc::new(|w: &WorkspaceHandle, _t: Duration| -> Result<Vec<u32>, ScanLaneError> {
811 if w.name() == "broken" {
812 Err(ScanLaneError::Failed("nope".to_string()))
813 } else {
814 Ok(vec![1, 2, 3])
815 }
816 });
817 let dispatcher = RunDispatcher::with_explicit(2, Duration::from_secs(1), None);
818 let bundle = dispatcher.dispatch_project::<dyn ScanLane<u32>, u32>(
819 &test_project(),
820 Run::with_id("run-cg"),
821 lane,
822 workspaces,
823 );
824 assert_eq!(bundle.callgraph.nodes, vec!["ok".to_string()]);
825 assert!(bundle.callgraph.edges.is_empty(), "cross-repo edges deferred");
826 assert_eq!(bundle.counts().succeeded, 1);
827 }
828
829 #[test]
830 fn rerun_on_identical_sources_produces_identical_finding_ids() {
831 use crate::store::finding_id_hash;
836 let tmp = tempfile::tempdir().expect("tmp");
837 let workspaces = vec![handle_for("solo", tmp.path())];
838 let lane: Arc<dyn ScanLane<(String, i64, String, String)>> = Arc::new(
839 |_w: &WorkspaceHandle,
840 _t: Duration|
841 -> Result<Vec<(String, i64, String, String)>, ScanLaneError> {
842 Ok(vec![
843 ("src/a.rs".to_string(), 7, "sqli".to_string(), "rule-a".to_string()),
844 ("src/b.rs".to_string(), 12, "cmdi".to_string(), "rule-b".to_string()),
845 ])
846 },
847 );
848 let dispatcher = RunDispatcher::with_explicit(1, Duration::from_secs(5), None);
849
850 let ids_from = |run_id: &str, bundle: &RunBundle<(String, i64, String, String)>| {
851 let mut out = Vec::new();
852 for repo_bundle in &bundle.per_repo {
853 if let RepoOutcome::Success(rows) = &repo_bundle.outcome {
854 for (path, line, cap, rule) in rows {
855 out.push(finding_id_hash(&repo_bundle.repo, path, Some(*line), cap, rule));
856 }
857 }
858 }
859 let _ = run_id;
861 out
862 };
863
864 let bundle_a = dispatcher.dispatch_project::<dyn ScanLane<_>, _>(
865 &test_project(),
866 Run::with_id("run-first"),
867 Arc::clone(&lane),
868 workspaces.clone(),
869 );
870 let bundle_b = dispatcher.dispatch_project::<dyn ScanLane<_>, _>(
871 &test_project(),
872 Run::with_id("run-second"),
873 lane,
874 workspaces,
875 );
876 let ids_a = ids_from("run-first", &bundle_a);
877 let ids_b = ids_from("run-second", &bundle_b);
878 assert_eq!(ids_a, ids_b, "finding ids must be run-id independent");
879 assert!(ids_a.iter().all(|h| h.len() == 16), "ids are 16-hex-chars");
880 }
881
882 #[test]
883 fn dispatch_visits_each_repo_exactly_once() {
884 let tmp_a = tempfile::tempdir().expect("a");
885 let tmp_b = tempfile::tempdir().expect("b");
886 let tmp_c = tempfile::tempdir().expect("c");
887 let workspaces = vec![
888 handle_for("a", tmp_a.path()),
889 handle_for("b", tmp_b.path()),
890 handle_for("c", tmp_c.path()),
891 ];
892 let counter = Arc::new(AtomicUsize::new(0));
893 let counter_for_lane = Arc::clone(&counter);
894 let lane: Arc<dyn ScanLane<()>> =
895 Arc::new(move |_w: &WorkspaceHandle, _t: Duration| -> Result<Vec<()>, ScanLaneError> {
896 counter_for_lane.fetch_add(1, AtomicOrdering::Relaxed);
897 Ok(Vec::new())
898 });
899 let dispatcher = RunDispatcher::with_explicit(2, Duration::from_secs(1), None);
900 let bundle = dispatcher.dispatch_project::<dyn ScanLane<()>, ()>(
901 &test_project(),
902 Run::with_id("run-visit"),
903 lane,
904 workspaces,
905 );
906 assert_eq!(counter.load(AtomicOrdering::Relaxed), 3);
907 let mut names: Vec<_> = bundle.per_repo.iter().map(|b| b.repo.clone()).collect();
908 names.sort();
909 assert_eq!(names, vec!["a", "b", "c"]);
910 }
911
912 #[test]
913 fn with_attempted_repos_overrides_run_started_repos_list() {
914 let (tx, mut rx) = broadcast::channel::<AgentEvent>(16);
915 let tmp = tempfile::tempdir().expect("tmp");
916 let workspaces = vec![handle_for("alpha", tmp.path())];
917 let lane: Arc<dyn ScanLane<()>> =
918 Arc::new(|_w: &WorkspaceHandle, _t: Duration| -> Result<Vec<()>, ScanLaneError> {
919 Ok(Vec::new())
920 });
921 let dispatcher = RunDispatcher::with_explicit(1, Duration::from_secs(5), Some(tx.clone()))
922 .with_attempted_repos(vec![
923 "alpha".to_string(),
924 "beta-ingest-failed".to_string(),
925 "gamma-ingest-failed".to_string(),
926 ]);
927 let _ = dispatcher.dispatch_project::<dyn ScanLane<()>, ()>(
928 &test_project(),
929 Run::with_id("run-attempted"),
930 lane,
931 workspaces,
932 );
933 let mut started_repos = None;
934 while let Ok(ev) = rx.try_recv() {
935 if let AgentEvent::Run { data: RunEvent::RunStarted { repos, .. } } = ev {
936 started_repos = Some(repos);
937 break;
938 }
939 }
940 let started_repos = started_repos.expect("RunStarted must be published");
941 assert_eq!(
942 started_repos,
943 vec![
944 "alpha".to_string(),
945 "beta-ingest-failed".to_string(),
946 "gamma-ingest-failed".to_string(),
947 ],
948 );
949 }
950}