1use oris_agent_contract::{AutonomousCandidateSource, AutonomousIntakeInput};
25
26pub trait ContinuousIntakeSource: Send + Sync {
39 fn name(&self) -> &'static str;
41
42 fn candidate_source(&self) -> AutonomousCandidateSource;
44
45 fn extract(&self, raw_lines: &[String], run_identifier: Option<&str>) -> AutonomousIntakeInput;
52}
53
54fn 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#[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#[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#[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#[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 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#[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#[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 #[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 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 #[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 #[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 #[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 #[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 assert!(!input.raw_signals.is_empty());
394 }
395
396 #[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}