Skip to main content

nyx_agent_core/run/
mod.rs

1//! Parallel static-pass dispatcher and run aggregator.
2//!
3//! The runner crate hands the dispatcher a freshly minted [`Run`], a
4//! [`ScanLane`] implementation, and a list of [`WorkspaceHandle`]s (one
5//! per ingested repo). The dispatcher schedules one rayon thread-pool
6//! job per workspace, enforces a per-repo timeout, publishes lifecycle
7//! events through the [`EventSink`] broadcast bus, and aggregates the
8//! per-repo outputs into a single [`RunBundle`].
9//!
10//! Fan-out width defaults to `min(num_cpus / 2, repo_count)` and is
11//! overridable via `[performance] static_concurrency`. The per-repo
12//! budget defaults to 30 minutes and is overridable via
13//! `[performance] per_repo_timeout_secs`. A repo that exhausts its
14//! budget is recorded as
15//! [`RepoOutcome::Inconclusive`] with [`InconclusiveReason::StaticPassTimeout`]
16//! and never blocks the rest of the run.
17
18use 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/// In-process run identifier. The dispatcher mints it before fanning
38/// out per-repo work and threads it through every event and bundle the
39/// run produces.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Run {
42    pub id: String,
43    pub started_at_ms: i64,
44}
45
46impl Run {
47    /// Mint a fresh run id and capture the wall-clock start. The id is
48    /// process-locally unique even when multiple runs collide inside a
49    /// single millisecond.
50    pub fn new() -> Self {
51        Self { id: mint_run_id(), started_at_ms: now_epoch_ms() }
52    }
53
54    /// Build a run with a caller-supplied id. Used by tests that want
55    /// to assert against a known id; not for production code.
56    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/// Why a repo did not produce a full result.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69pub enum InconclusiveReason {
70    /// Per-repo static-pass timeout fired before the scan returned.
71    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/// Outcome bundled with every repo. `D` is the lane's diagnostic shape
83/// (e.g. `nyx_agent_nyx::Diag` in production, `()` in unit tests).
84#[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/// Per-repo block emitted by the dispatcher.
102#[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/// Cross-repo callgraph stub. Records only the participating repos
112/// today; real cross-repo edges land with the cross-repo chain
113/// runner. Until then this is a marker that aggregation happened.
114#[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/// Aggregated output for a whole run.
127#[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    /// Count of repos by outcome tag. Useful for tests and event
140    /// emission.
141    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/// Why the lane could not return a diag list.
169#[derive(Debug, Clone)]
170pub enum ScanLaneError {
171    /// Scan exceeded the dispatcher-provided per-repo timeout.
172    Timeout,
173    /// Scan failed for any other reason. The message is surfaced both
174    /// to the event bus and the [`RepoBundle`].
175    Failed(String),
176}
177
178/// Static-pass scan lane. The default production impl wraps
179/// `nyx_agent_nyx::NyxRunner`; tests use synchronous stubs.
180///
181/// Implementations must drop into and out of any tokio runtime
182/// themselves; the dispatcher invokes `scan_blocking` from a rayon
183/// worker thread.
184pub trait ScanLane<D>: Send + Sync + 'static {
185    /// Run the static pass against the given workspace. The
186    /// dispatcher passes the same `timeout` the operator configured in
187    /// `[performance] per_repo_timeout_secs`; lanes that delegate to
188    /// async I/O (e.g. `nyx scan`) should plumb it through their own
189    /// timeout primitive.
190    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/// Schedules the static pass across every workspace in parallel and
211/// merges the results into a [`RunBundle`].
212#[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    /// Build a dispatcher from the operator's `[performance]` block and
223    /// the planned repo count. The optional [`EventSink`] is the
224    /// broadcast bus; WebSocket subscribers attach by calling
225    /// [`tokio::sync::broadcast::Sender::subscribe`] on the same
226    /// sender without any signature change here.
227    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    /// Test-friendly constructor.
243    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    /// Override the repo list reported in `RunEvent::RunStarted.repos`.
258    /// Callers that ingest before dispatch (CLI / API drive-scan) use
259    /// this to include repos that failed ingest, so subscribers can
260    /// reconstruct the full attempted-repo set from `RunStarted.repos`
261    /// alone without joining `RepoIngestFailed` frames emitted earlier.
262    pub fn with_attempted_repos(mut self, attempted: Vec<String>) -> Self {
263        self.attempted_repos = Some(attempted);
264        self
265    }
266
267    /// Suppress run/project lifecycle frames while preserving per-repo
268    /// static-pass frames. The pentest orchestrator emits lifecycle
269    /// events itself so `RunFinished` reflects the whole run, not just
270    /// the Nyx signal pass.
271    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    /// Drive a project-scoped run.
285    ///
286    /// The `lane` is shared across rayon workers via `Arc`. The
287    /// `workspaces` list is consumed; each [`WorkspaceHandle`] is
288    /// dropped when its rayon job returns, which fires the per-run
289    /// snapshot cleanup installed during ingestion. The returned
290    /// [`RunBundle`] carries only per-repo names + outcomes; it
291    /// does **not** keep workspaces alive past dispatch. Downstream
292    /// callers that need the snapshot to survive (e.g. the sandbox
293    /// lane) must hold their own [`WorkspaceHandle`] clones before
294    /// calling `dispatch_project`.
295    ///
296    /// Lifecycle event order on the bus:
297    /// `RunStarted` → `ProjectStarted` → (per-repo events) →
298    /// `ProjectFinished` → `RunFinished`. Every per-repo event carries
299    /// the project's id so subscribers can group without a side lookup.
300    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            // A closed bus is fine: subscribers may not be attached yet
411            // (the websocket attaches lazily). Discard the send error
412            // so the static pass keeps running.
413            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
527/// Canonical run-id minter. Format: `run-<ms-hex>-<counter-hex>`.
528///
529/// The counter component keeps the id unique even when two runs
530/// start inside the same millisecond. An older `as_secs()`-derived
531/// id collided on the snapshot directory under
532/// `<state>/repos/<name>/snapshots/<run_id>/`.
533pub 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
539/// Build a [`WorkspaceHandle`] from an [`IngestedRepo`]. The dispatcher
540/// owns the handle for the duration of the run, but callers may clone
541/// it cheaply (Arc inside) to keep the snapshot alive for downstream
542/// sandbox / chain consumers.
543pub fn workspace_handle_from(ingested: IngestedRepo) -> WorkspaceHandle {
544    WorkspaceHandle::new(ingested)
545}
546
547/// Test helper that returns the workspace path without exposing the
548/// inner [`IngestedRepo`] to callers outside the crate.
549pub 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        // Override always wins.
605        assert_eq!(resolve_concurrency(Some(3), 8), 3);
606        // Empty repo list -> 1, never 0.
607        assert_eq!(resolve_concurrency(None, 0), 1);
608        // Override of 0 floored to 1 by config layer; dispatcher also
609        // floors defensively.
610        assert_eq!(resolve_concurrency(Some(0), 4), 1);
611        // Default never exceeds repo count.
612        assert!(resolve_concurrency(None, 1) <= 1);
613    }
614
615    #[test]
616    fn two_repos_scan_concurrently_under_sum_of_serial() {
617        // Acceptance: a two-repo scan runs concurrently. We measure
618        // wall clock vs sum-of-serial; with two repos sleeping 200 ms
619        // each at concurrency = 2, the run should finish in ~200 ms,
620        // not ~400 ms.
621        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        // Acceptance: slow-repo simulation under per_repo_timeout = 50ms
653        // marks the repo timed-out without blocking the rest.
654        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                    // Simulate the lane discovering it blew its budget.
664                    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        // Acceptance: re-running on identical sources produces identical
832        // finding IDs. We model "the same scanner output" by returning a
833        // fixed `(path, line, cap, rule)` tuple from the lane and feeding
834        // it through `finding_id_hash` on both runs.
835        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            // Run id is intentionally not folded into finding_id_hash.
860            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}