Skip to main content

oris_intake/
continuous.rs

1//! Continuous autonomous intake sources.
2//!
3//! This module provides the `ContinuousIntakeSource` trait and concrete
4//! implementations for CI failures, test regressions, lint/compile regressions,
5//! and runtime panics.  Each implementation normalises raw diagnostic text into
6//! an [`AutonomousIntakeInput`] ready to be fed to
7//! `EvoKernel::discover_autonomous_candidates()`.
8//!
9//! # Architecture
10//!
11//! ```text
12//! Raw diagnostic text / log lines
13//!         |
14//!         v
15//! ContinuousIntakeSource::extract(lines) -> AutonomousIntakeInput
16//!         |
17//!         v
18//! EvoKernel::discover_autonomous_candidates(input) -> AutonomousIntakeOutput
19//! ```
20//!
21//! No mutation, no proposal, and no execution happen here.  This layer is
22//! strictly discovery and classification.
23
24use oris_agent_contract::{AutonomousCandidateSource, AutonomousIntakeInput};
25
26// ── Trait ─────────────────────────────────────────────────────────────────────
27
28/// A source that can extract an [`AutonomousIntakeInput`] from raw diagnostic
29/// lines without any caller-supplied issue metadata.
30///
31/// Implementors are expected to:
32/// - Filter and normalise relevant lines from the raw input.
33/// - Return a stable `source_id` based on the raw content so that duplicate
34///   runs collapse to the same identity.
35/// - Emit an [`AutonomousIntakeInput`] even when no relevant signals are found
36///   (with an empty `raw_signals` vec); the kernel treats this as an immediate
37///   fail-closed unsupported input.
38pub trait ContinuousIntakeSource: Send + Sync {
39    /// Human-readable name of this source (for logging / metrics).
40    fn name(&self) -> &'static str;
41
42    /// The [`AutonomousCandidateSource`] variant this implementation covers.
43    fn candidate_source(&self) -> AutonomousCandidateSource;
44
45    /// Extract a normalized [`AutonomousIntakeInput`] from `raw_lines`.
46    ///
47    /// `run_identifier` is an opaque string supplied by the caller (e.g. a CI
48    /// run ID, log stream name, or timestamp) used to form the `source_id`.
49    /// When `None`, the implementation must derive a stable identifier from
50    /// the content itself.
51    fn extract(&self, raw_lines: &[String], run_identifier: Option<&str>) -> AutonomousIntakeInput;
52}
53
54// ── Helpers ───────────────────────────────────────────────────────────────────
55
56/// Derive a stable source_id from the raw lines when no explicit run
57/// identifier is available.
58fn content_derived_source_id(prefix: &str, lines: &[String]) -> String {
59    use std::collections::hash_map::DefaultHasher;
60    use std::hash::{Hash, Hasher};
61    let mut hasher = DefaultHasher::new();
62    for l in lines {
63        l.hash(&mut hasher);
64    }
65    format!("{prefix}:{:016x}", hasher.finish())
66}
67
68fn make_source_id(prefix: &str, lines: &[String], run_id: Option<&str>) -> String {
69    match run_id {
70        Some(id) if !id.is_empty() => format!("{prefix}:{id}"),
71        _ => content_derived_source_id(prefix, lines),
72    }
73}
74
75// ── CiFailureSource ───────────────────────────────────────────────────────────
76
77/// Extracts autonomous intake signals from CI failure logs (GitHub Actions,
78/// GitLab CI, or any CI system that emits build/test output to stdout).
79///
80/// Relevant lines are those that contain Rust compiler errors (`error[E…]`),
81/// `FAILED`, `error:`, or `panicked at`.
82#[derive(Clone, Debug, Default)]
83pub struct CiFailureSource;
84
85impl ContinuousIntakeSource for CiFailureSource {
86    fn name(&self) -> &'static str {
87        "ci_failure"
88    }
89
90    fn candidate_source(&self) -> AutonomousCandidateSource {
91        AutonomousCandidateSource::CiFailure
92    }
93
94    fn extract(&self, raw_lines: &[String], run_id: Option<&str>) -> AutonomousIntakeInput {
95        let relevant: Vec<String> = raw_lines
96            .iter()
97            .filter(|l| {
98                let lo = l.to_ascii_lowercase();
99                lo.contains("error[e") || lo.contains("error:") || lo.contains("failed")
100            })
101            .cloned()
102            .collect();
103        AutonomousIntakeInput {
104            source_id: make_source_id("ci-failure", raw_lines, run_id),
105            candidate_source: AutonomousCandidateSource::CiFailure,
106            raw_signals: relevant,
107        }
108    }
109}
110
111// ── TestRegressionSource ──────────────────────────────────────────────────────
112
113/// Extracts autonomous intake signals from `cargo test` output.
114///
115/// Relevant lines are those that contain `FAILED`, `panicked at`, or
116/// `test … … FAILED`.
117#[derive(Clone, Debug, Default)]
118pub struct TestRegressionSource;
119
120impl ContinuousIntakeSource for TestRegressionSource {
121    fn name(&self) -> &'static str {
122        "test_regression"
123    }
124
125    fn candidate_source(&self) -> AutonomousCandidateSource {
126        AutonomousCandidateSource::TestRegression
127    }
128
129    fn extract(&self, raw_lines: &[String], run_id: Option<&str>) -> AutonomousIntakeInput {
130        let relevant: Vec<String> = raw_lines
131            .iter()
132            .filter(|l| {
133                let lo = l.to_ascii_lowercase();
134                lo.contains("failed") || lo.contains("panicked at") || lo.contains("test result")
135            })
136            .cloned()
137            .collect();
138        AutonomousIntakeInput {
139            source_id: make_source_id("test-regression", raw_lines, run_id),
140            candidate_source: AutonomousCandidateSource::TestRegression,
141            raw_signals: relevant,
142        }
143    }
144}
145
146// ── LintRegressionSource ──────────────────────────────────────────────────────
147
148/// Extracts autonomous intake signals from `cargo clippy` output.
149///
150/// Relevant lines contain `warning:`, `error:`, or `help:` annotations.
151#[derive(Clone, Debug, Default)]
152pub struct LintRegressionSource;
153
154impl ContinuousIntakeSource for LintRegressionSource {
155    fn name(&self) -> &'static str {
156        "lint_regression"
157    }
158
159    fn candidate_source(&self) -> AutonomousCandidateSource {
160        AutonomousCandidateSource::LintRegression
161    }
162
163    fn extract(&self, raw_lines: &[String], run_id: Option<&str>) -> AutonomousIntakeInput {
164        let relevant: Vec<String> = raw_lines
165            .iter()
166            .filter(|l| {
167                let lo = l.to_ascii_lowercase();
168                lo.contains("warning:") || lo.contains("error:") || lo.contains("help:")
169            })
170            .cloned()
171            .collect();
172        AutonomousIntakeInput {
173            source_id: make_source_id("lint-regression", raw_lines, run_id),
174            candidate_source: AutonomousCandidateSource::LintRegression,
175            raw_signals: relevant,
176        }
177    }
178}
179
180// ── CompileRegressionSource ───────────────────────────────────────────────────
181
182/// Extracts autonomous intake signals from `cargo build` or `rustc` output.
183///
184/// Relevant lines contain `error[E…]` compiler error codes.
185#[derive(Clone, Debug, Default)]
186pub struct CompileRegressionSource;
187
188impl ContinuousIntakeSource for CompileRegressionSource {
189    fn name(&self) -> &'static str {
190        "compile_regression"
191    }
192
193    fn candidate_source(&self) -> AutonomousCandidateSource {
194        AutonomousCandidateSource::CompileRegression
195    }
196
197    fn extract(&self, raw_lines: &[String], run_id: Option<&str>) -> AutonomousIntakeInput {
198        let relevant: Vec<String> = raw_lines
199            .iter()
200            .filter(|l| {
201                let lo = l.to_ascii_lowercase();
202                // Rust compiler errors have the form `error[Exxxxx]`
203                lo.contains("error[e") || lo.contains("aborting due to")
204            })
205            .cloned()
206            .collect();
207        AutonomousIntakeInput {
208            source_id: make_source_id("compile-regression", raw_lines, run_id),
209            candidate_source: AutonomousCandidateSource::CompileRegression,
210            raw_signals: relevant,
211        }
212    }
213}
214
215// ── RuntimePanicSource ────────────────────────────────────────────────────────
216
217/// Extracts autonomous intake signals from runtime panic output.
218///
219/// Relevant lines contain `panicked at`, `thread '…' panicked`, or
220/// `SIGSEGV` / `SIGABRT` indicators.
221///
222/// Note: `AutonomousCandidateSource::RuntimeIncident` is currently **not**
223/// mapped to a `BoundedTaskClass` by the kernel classifier, so intake from
224/// this source will return a fail-closed `UnsupportedSignalClass` candidate.
225/// This behaviour is intentional and reflects the current autonomy boundary.
226#[derive(Clone, Debug, Default)]
227pub struct RuntimePanicSource;
228
229impl ContinuousIntakeSource for RuntimePanicSource {
230    fn name(&self) -> &'static str {
231        "runtime_panic"
232    }
233
234    fn candidate_source(&self) -> AutonomousCandidateSource {
235        AutonomousCandidateSource::RuntimeIncident
236    }
237
238    fn extract(&self, raw_lines: &[String], run_id: Option<&str>) -> AutonomousIntakeInput {
239        let relevant: Vec<String> = raw_lines
240            .iter()
241            .filter(|l| {
242                let lo = l.to_ascii_lowercase();
243                lo.contains("panicked at")
244                    || lo.contains("thread '")
245                    || lo.contains("sigsegv")
246                    || lo.contains("sigabrt")
247            })
248            .cloned()
249            .collect();
250        AutonomousIntakeInput {
251            source_id: make_source_id("runtime-panic", raw_lines, run_id),
252            candidate_source: AutonomousCandidateSource::RuntimeIncident,
253            raw_signals: relevant,
254        }
255    }
256}
257
258// ── Tests ─────────────────────────────────────────────────────────────────────
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn lines(raw: &[&str]) -> Vec<String> {
265        raw.iter().map(|s| s.to_string()).collect()
266    }
267
268    // ── CiFailureSource ───────────────────────────────────────────────────
269
270    #[test]
271    fn continuous_intake_ci_failure_extracts_error_lines() {
272        let source = CiFailureSource;
273        let raw = lines(&[
274            "running 12 tests",
275            "error[E0382]: borrow of moved value: `x`",
276            "test foo ... FAILED",
277            "test bar ... ok",
278        ]);
279        let input = source.extract(&raw, Some("run-42"));
280        assert_eq!(input.candidate_source, AutonomousCandidateSource::CiFailure);
281        assert_eq!(input.source_id, "ci-failure:run-42");
282        assert!(!input.raw_signals.is_empty());
283        assert!(input.raw_signals.iter().any(|s| s.contains("error[E0382]")));
284    }
285
286    #[test]
287    fn continuous_intake_ci_failure_empty_on_clean_output() {
288        let source = CiFailureSource;
289        let raw = lines(&["running 3 tests", "test a ... ok", "3 tests passed"]);
290        let input = source.extract(&raw, Some("run-clean"));
291        // No error lines → empty raw_signals; kernel will fail-closed on empty input
292        assert!(input.raw_signals.is_empty());
293    }
294
295    #[test]
296    fn continuous_intake_ci_failure_stable_source_id_without_run_id() {
297        let source = CiFailureSource;
298        let raw = lines(&["error: something went wrong"]);
299        let id1 = source.extract(&raw, None).source_id;
300        let id2 = source.extract(&raw, None).source_id;
301        assert_eq!(
302            id1, id2,
303            "source_id must be deterministic for the same content"
304        );
305    }
306
307    // ── TestRegressionSource ──────────────────────────────────────────────
308
309    #[test]
310    fn continuous_intake_test_regression_captures_failed_lines() {
311        let source = TestRegressionSource;
312        let raw = lines(&[
313            "test tests::my_test ... FAILED",
314            "thread 'main' panicked at 'assertion failed'",
315            "test other::test ... ok",
316        ]);
317        let input = source.extract(&raw, Some("push-abc123"));
318        assert_eq!(
319            input.candidate_source,
320            AutonomousCandidateSource::TestRegression
321        );
322        assert!(!input.raw_signals.is_empty());
323    }
324
325    #[test]
326    fn continuous_intake_test_regression_dedup_key_stable() {
327        let source = TestRegressionSource;
328        let raw = lines(&["FAILED: test_foo"]);
329        let a = source.extract(&raw, None);
330        let b = source.extract(&raw, None);
331        assert_eq!(a.source_id, b.source_id);
332    }
333
334    // ── LintRegressionSource ──────────────────────────────────────────────
335
336    #[test]
337    fn continuous_intake_lint_regression_captures_warnings() {
338        let source = LintRegressionSource;
339        let raw = lines(&[
340            "warning: unused variable `x`",
341            "  --> src/lib.rs:10:5",
342            "error: unused import",
343        ]);
344        let input = source.extract(&raw, Some("lint-01"));
345        assert_eq!(
346            input.candidate_source,
347            AutonomousCandidateSource::LintRegression
348        );
349        assert!(input.raw_signals.len() >= 2);
350    }
351
352    #[test]
353    fn continuous_intake_lint_regression_empty_on_no_warnings() {
354        let source = LintRegressionSource;
355        let raw = lines(&["  --> src/lib.rs:10:5", "= note: something"]);
356        let input = source.extract(&raw, None);
357        assert!(input.raw_signals.is_empty());
358    }
359
360    // ── CompileRegressionSource ───────────────────────────────────────────
361
362    #[test]
363    fn continuous_intake_compile_regression_captures_error_codes() {
364        let source = CompileRegressionSource;
365        let raw = lines(&[
366            "error[E0277]: the trait bound `Foo: Bar` is not satisfied",
367            "aborting due to 1 previous error",
368            "  --> src/main.rs:5:10",
369        ]);
370        let input = source.extract(&raw, Some("build-99"));
371        assert!(input.raw_signals.len() >= 2);
372        assert_eq!(
373            input.candidate_source,
374            AutonomousCandidateSource::CompileRegression
375        );
376    }
377
378    // ── RuntimePanicSource ────────────────────────────────────────────────
379
380    #[test]
381    fn continuous_intake_runtime_panic_captures_panic_lines() {
382        let source = RuntimePanicSource;
383        let raw = lines(&[
384            "thread 'main' panicked at 'index out of bounds'",
385            "note: run with RUST_BACKTRACE=1",
386        ]);
387        let input = source.extract(&raw, Some("incident-7"));
388        assert_eq!(
389            input.candidate_source,
390            AutonomousCandidateSource::RuntimeIncident
391        );
392        // RuntimeIncident is not yet mapped to a BoundedTaskClass but signals are captured
393        assert!(!input.raw_signals.is_empty());
394    }
395
396    // ── Unsupported / fail-closed behaviour ──────────────────────────────
397
398    #[test]
399    fn continuous_intake_runtime_panic_source_maps_to_runtime_incident() {
400        let source = RuntimePanicSource::default();
401        assert_eq!(
402            source.candidate_source(),
403            AutonomousCandidateSource::RuntimeIncident
404        );
405    }
406
407    #[test]
408    fn continuous_intake_all_sources_have_stable_names() {
409        let sources: Vec<Box<dyn ContinuousIntakeSource>> = vec![
410            Box::new(CiFailureSource),
411            Box::new(TestRegressionSource),
412            Box::new(LintRegressionSource),
413            Box::new(CompileRegressionSource),
414            Box::new(RuntimePanicSource),
415        ];
416        for src in &sources {
417            assert!(!src.name().is_empty());
418        }
419    }
420}