1use std::collections::BTreeSet;
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use oris_agent_contract::{ExecutionFeedback, MutationProposal as AgentMutationProposal};
11use oris_evolution::{
12 compute_artifact_hash, next_id, stable_hash_json, AssetState, BlastRadius, CandidateSource,
13 Capsule, CapsuleId, EnvFingerprint, EvolutionError, EvolutionEvent, EvolutionStore, Gene,
14 GeneCandidate, MutationId, PreparedMutation, Selector, SelectorInput, StoreBackedSelector,
15 ValidationSnapshot,
16};
17use oris_evolution_network::{EvolutionEnvelope, NetworkAsset};
18use oris_governor::{DefaultGovernor, Governor, GovernorDecision, GovernorInput};
19use oris_kernel::{Kernel, KernelState, RunId};
20use oris_sandbox::{
21 compute_blast_radius, execute_allowed_command, Sandbox, SandboxPolicy, SandboxReceipt,
22};
23use oris_spec::CompiledMutationPlan;
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27pub use oris_evolution::{
28 default_store_root, ArtifactEncoding, AssetState as EvoAssetState,
29 BlastRadius as EvoBlastRadius, CandidateSource as EvoCandidateSource,
30 EnvFingerprint as EvoEnvFingerprint, EvolutionStore as EvoEvolutionStore, JsonlEvolutionStore,
31 MutationArtifact, MutationIntent, MutationTarget, Outcome, RiskLevel,
32 SelectorInput as EvoSelectorInput,
33};
34pub use oris_evolution_network::{
35 FetchQuery, FetchResponse, MessageType, PublishRequest, RevokeNotice,
36};
37pub use oris_governor::{CoolingWindow, GovernorConfig, RevocationReason};
38pub use oris_sandbox::{LocalProcessSandbox, SandboxPolicy as EvoSandboxPolicy};
39pub use oris_spec::{SpecCompileError, SpecCompiler, SpecDocument};
40
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct ValidationPlan {
43 pub profile: String,
44 pub stages: Vec<ValidationStage>,
45}
46
47impl ValidationPlan {
48 pub fn oris_default() -> Self {
49 Self {
50 profile: "oris-default".into(),
51 stages: vec![
52 ValidationStage::Command {
53 program: "cargo".into(),
54 args: vec!["fmt".into(), "--all".into(), "--check".into()],
55 timeout_ms: 60_000,
56 },
57 ValidationStage::Command {
58 program: "cargo".into(),
59 args: vec!["check".into(), "--workspace".into()],
60 timeout_ms: 180_000,
61 },
62 ValidationStage::Command {
63 program: "cargo".into(),
64 args: vec![
65 "test".into(),
66 "-p".into(),
67 "oris-kernel".into(),
68 "-p".into(),
69 "oris-evolution".into(),
70 "-p".into(),
71 "oris-sandbox".into(),
72 "-p".into(),
73 "oris-evokernel".into(),
74 "--lib".into(),
75 ],
76 timeout_ms: 300_000,
77 },
78 ValidationStage::Command {
79 program: "cargo".into(),
80 args: vec![
81 "test".into(),
82 "-p".into(),
83 "oris-runtime".into(),
84 "--lib".into(),
85 ],
86 timeout_ms: 300_000,
87 },
88 ],
89 }
90 }
91}
92
93#[derive(Clone, Debug, Serialize, Deserialize)]
94pub enum ValidationStage {
95 Command {
96 program: String,
97 args: Vec<String>,
98 timeout_ms: u64,
99 },
100}
101
102#[derive(Clone, Debug, Serialize, Deserialize)]
103pub struct ValidationStageReport {
104 pub stage: String,
105 pub success: bool,
106 pub exit_code: Option<i32>,
107 pub duration_ms: u64,
108 pub stdout: String,
109 pub stderr: String,
110}
111
112#[derive(Clone, Debug, Serialize, Deserialize)]
113pub struct ValidationReport {
114 pub success: bool,
115 pub duration_ms: u64,
116 pub stages: Vec<ValidationStageReport>,
117 pub logs: String,
118}
119
120impl ValidationReport {
121 pub fn to_snapshot(&self, profile: &str) -> ValidationSnapshot {
122 ValidationSnapshot {
123 success: self.success,
124 profile: profile.to_string(),
125 duration_ms: self.duration_ms,
126 summary: if self.success {
127 "validation passed".into()
128 } else {
129 "validation failed".into()
130 },
131 }
132 }
133}
134
135#[derive(Debug, Error)]
136pub enum ValidationError {
137 #[error("validation execution failed: {0}")]
138 Execution(String),
139}
140
141#[async_trait]
142pub trait Validator: Send + Sync {
143 async fn run(
144 &self,
145 receipt: &SandboxReceipt,
146 plan: &ValidationPlan,
147 ) -> Result<ValidationReport, ValidationError>;
148}
149
150pub struct CommandValidator {
151 policy: SandboxPolicy,
152}
153
154impl CommandValidator {
155 pub fn new(policy: SandboxPolicy) -> Self {
156 Self { policy }
157 }
158}
159
160#[async_trait]
161impl Validator for CommandValidator {
162 async fn run(
163 &self,
164 receipt: &SandboxReceipt,
165 plan: &ValidationPlan,
166 ) -> Result<ValidationReport, ValidationError> {
167 let started = std::time::Instant::now();
168 let mut stages = Vec::new();
169 let mut success = true;
170 let mut logs = String::new();
171
172 for stage in &plan.stages {
173 match stage {
174 ValidationStage::Command {
175 program,
176 args,
177 timeout_ms,
178 } => {
179 let result = execute_allowed_command(
180 &self.policy,
181 &receipt.workdir,
182 program,
183 args,
184 *timeout_ms,
185 )
186 .await;
187 let report = match result {
188 Ok(output) => ValidationStageReport {
189 stage: format!("{program} {}", args.join(" ")),
190 success: output.success,
191 exit_code: output.exit_code,
192 duration_ms: output.duration_ms,
193 stdout: output.stdout,
194 stderr: output.stderr,
195 },
196 Err(err) => ValidationStageReport {
197 stage: format!("{program} {}", args.join(" ")),
198 success: false,
199 exit_code: None,
200 duration_ms: 0,
201 stdout: String::new(),
202 stderr: err.to_string(),
203 },
204 };
205 if !report.success {
206 success = false;
207 }
208 if !report.stdout.is_empty() {
209 logs.push_str(&report.stdout);
210 logs.push('\n');
211 }
212 if !report.stderr.is_empty() {
213 logs.push_str(&report.stderr);
214 logs.push('\n');
215 }
216 stages.push(report);
217 if !success {
218 break;
219 }
220 }
221 }
222 }
223
224 Ok(ValidationReport {
225 success,
226 duration_ms: started.elapsed().as_millis() as u64,
227 stages,
228 logs,
229 })
230 }
231}
232
233#[derive(Clone, Debug)]
234pub struct ReplayDecision {
235 pub used_capsule: bool,
236 pub capsule_id: Option<CapsuleId>,
237 pub fallback_to_planner: bool,
238 pub reason: String,
239}
240
241#[derive(Debug, Error)]
242pub enum ReplayError {
243 #[error("store error: {0}")]
244 Store(String),
245 #[error("sandbox error: {0}")]
246 Sandbox(String),
247 #[error("validation error: {0}")]
248 Validation(String),
249}
250
251#[async_trait]
252pub trait ReplayExecutor: Send + Sync {
253 async fn try_replay(
254 &self,
255 input: &SelectorInput,
256 policy: &SandboxPolicy,
257 validation: &ValidationPlan,
258 ) -> Result<ReplayDecision, ReplayError>;
259}
260
261pub struct StoreReplayExecutor {
262 pub sandbox: Arc<dyn Sandbox>,
263 pub validator: Arc<dyn Validator>,
264 pub store: Arc<dyn EvolutionStore>,
265 pub selector: Arc<dyn Selector>,
266}
267
268#[async_trait]
269impl ReplayExecutor for StoreReplayExecutor {
270 async fn try_replay(
271 &self,
272 input: &SelectorInput,
273 policy: &SandboxPolicy,
274 validation: &ValidationPlan,
275 ) -> Result<ReplayDecision, ReplayError> {
276 let mut candidates = self.selector.select(input);
277 let mut exact_match = false;
278 if candidates.is_empty() {
279 if let Some(candidate) = exact_match_candidate(self.store.as_ref(), input) {
280 candidates.push(candidate);
281 exact_match = true;
282 }
283 }
284 let Some(best) = candidates.into_iter().next() else {
285 return Ok(ReplayDecision {
286 used_capsule: false,
287 capsule_id: None,
288 fallback_to_planner: true,
289 reason: "no matching gene".into(),
290 });
291 };
292
293 if !exact_match && best.score < 0.82 {
294 return Ok(ReplayDecision {
295 used_capsule: false,
296 capsule_id: None,
297 fallback_to_planner: true,
298 reason: format!("best gene score {:.3} below replay threshold", best.score),
299 });
300 }
301
302 let Some(capsule) = best.capsules.first().cloned() else {
303 return Ok(ReplayDecision {
304 used_capsule: false,
305 capsule_id: None,
306 fallback_to_planner: true,
307 reason: "candidate gene has no capsule".into(),
308 });
309 };
310
311 let Some(mutation) = find_declared_mutation(self.store.as_ref(), &capsule.mutation_id)
312 .map_err(|err| ReplayError::Store(err.to_string()))?
313 else {
314 return Ok(ReplayDecision {
315 used_capsule: false,
316 capsule_id: None,
317 fallback_to_planner: true,
318 reason: "mutation payload missing from store".into(),
319 });
320 };
321
322 let receipt = match self.sandbox.apply(&mutation, policy).await {
323 Ok(receipt) => receipt,
324 Err(err) => {
325 return Ok(ReplayDecision {
326 used_capsule: false,
327 capsule_id: Some(capsule.id.clone()),
328 fallback_to_planner: true,
329 reason: format!("replay patch apply failed: {err}"),
330 })
331 }
332 };
333
334 let report = self
335 .validator
336 .run(&receipt, validation)
337 .await
338 .map_err(|err| ReplayError::Validation(err.to_string()))?;
339 if !report.success {
340 return Ok(ReplayDecision {
341 used_capsule: false,
342 capsule_id: Some(capsule.id.clone()),
343 fallback_to_planner: true,
344 reason: "replay validation failed".into(),
345 });
346 }
347
348 self.store
349 .append_event(EvolutionEvent::CapsuleReused {
350 capsule_id: capsule.id.clone(),
351 gene_id: capsule.gene_id.clone(),
352 run_id: capsule.run_id.clone(),
353 })
354 .map_err(|err| ReplayError::Store(err.to_string()))?;
355
356 Ok(ReplayDecision {
357 used_capsule: true,
358 capsule_id: Some(capsule.id),
359 fallback_to_planner: false,
360 reason: if exact_match {
361 "replayed via exact-match cold-start lookup".into()
362 } else {
363 "replayed via selector".into()
364 },
365 })
366 }
367}
368
369#[derive(Debug, Error)]
370pub enum EvoKernelError {
371 #[error("sandbox error: {0}")]
372 Sandbox(String),
373 #[error("validation error: {0}")]
374 Validation(String),
375 #[error("validation failed")]
376 ValidationFailed(ValidationReport),
377 #[error("store error: {0}")]
378 Store(String),
379}
380
381#[derive(Clone, Debug)]
382pub struct CaptureOutcome {
383 pub capsule: Capsule,
384 pub gene: Gene,
385 pub governor_decision: GovernorDecision,
386}
387
388#[derive(Clone, Debug, Serialize, Deserialize)]
389pub struct ImportOutcome {
390 pub imported_asset_ids: Vec<String>,
391 pub accepted: bool,
392}
393
394#[derive(Clone)]
395pub struct EvolutionNetworkNode {
396 pub store: Arc<dyn EvolutionStore>,
397}
398
399impl EvolutionNetworkNode {
400 pub fn new(store: Arc<dyn EvolutionStore>) -> Self {
401 Self { store }
402 }
403
404 pub fn with_default_store() -> Self {
405 Self {
406 store: Arc::new(JsonlEvolutionStore::new(default_store_root())),
407 }
408 }
409
410 pub fn accept_publish_request(
411 &self,
412 request: &PublishRequest,
413 ) -> Result<ImportOutcome, EvoKernelError> {
414 import_remote_envelope_into_store(
415 self.store.as_ref(),
416 &EvolutionEnvelope::publish(request.sender_id.clone(), request.assets.clone()),
417 )
418 }
419
420 pub fn publish_local_assets(
421 &self,
422 sender_id: impl Into<String>,
423 ) -> Result<EvolutionEnvelope, EvoKernelError> {
424 export_promoted_assets_from_store(self.store.as_ref(), sender_id)
425 }
426
427 pub fn fetch_assets(
428 &self,
429 responder_id: impl Into<String>,
430 query: &FetchQuery,
431 ) -> Result<FetchResponse, EvoKernelError> {
432 fetch_assets_from_store(self.store.as_ref(), responder_id, query)
433 }
434
435 pub fn revoke_assets(&self, notice: &RevokeNotice) -> Result<RevokeNotice, EvoKernelError> {
436 revoke_assets_in_store(self.store.as_ref(), notice)
437 }
438}
439
440pub struct EvoKernel<S: KernelState> {
441 pub kernel: Arc<Kernel<S>>,
442 pub sandbox: Arc<dyn Sandbox>,
443 pub validator: Arc<dyn Validator>,
444 pub store: Arc<dyn EvolutionStore>,
445 pub selector: Arc<dyn Selector>,
446 pub governor: Arc<dyn Governor>,
447 pub sandbox_policy: SandboxPolicy,
448 pub validation_plan: ValidationPlan,
449}
450
451impl<S: KernelState> EvoKernel<S> {
452 pub fn new(
453 kernel: Arc<Kernel<S>>,
454 sandbox: Arc<dyn Sandbox>,
455 validator: Arc<dyn Validator>,
456 store: Arc<dyn EvolutionStore>,
457 ) -> Self {
458 let selector: Arc<dyn Selector> = Arc::new(StoreBackedSelector::new(store.clone()));
459 Self {
460 kernel,
461 sandbox,
462 validator,
463 store,
464 selector,
465 governor: Arc::new(DefaultGovernor::default()),
466 sandbox_policy: SandboxPolicy::oris_default(),
467 validation_plan: ValidationPlan::oris_default(),
468 }
469 }
470
471 pub fn with_selector(mut self, selector: Arc<dyn Selector>) -> Self {
472 self.selector = selector;
473 self
474 }
475
476 pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
477 self.sandbox_policy = policy;
478 self
479 }
480
481 pub fn with_governor(mut self, governor: Arc<dyn Governor>) -> Self {
482 self.governor = governor;
483 self
484 }
485
486 pub fn with_validation_plan(mut self, plan: ValidationPlan) -> Self {
487 self.validation_plan = plan;
488 self
489 }
490
491 pub async fn capture_successful_mutation(
492 &self,
493 run_id: &RunId,
494 mutation: PreparedMutation,
495 ) -> Result<Capsule, EvoKernelError> {
496 Ok(self
497 .capture_mutation_with_governor(run_id, mutation)
498 .await?
499 .capsule)
500 }
501
502 pub async fn capture_mutation_with_governor(
503 &self,
504 run_id: &RunId,
505 mutation: PreparedMutation,
506 ) -> Result<CaptureOutcome, EvoKernelError> {
507 self.store
508 .append_event(EvolutionEvent::MutationDeclared {
509 mutation: mutation.clone(),
510 })
511 .map_err(store_err)?;
512
513 let receipt = match self.sandbox.apply(&mutation, &self.sandbox_policy).await {
514 Ok(receipt) => receipt,
515 Err(err) => {
516 self.store
517 .append_event(EvolutionEvent::MutationRejected {
518 mutation_id: mutation.intent.id.clone(),
519 reason: err.to_string(),
520 })
521 .map_err(store_err)?;
522 return Err(EvoKernelError::Sandbox(err.to_string()));
523 }
524 };
525
526 self.store
527 .append_event(EvolutionEvent::MutationApplied {
528 mutation_id: mutation.intent.id.clone(),
529 patch_hash: receipt.patch_hash.clone(),
530 changed_files: receipt
531 .changed_files
532 .iter()
533 .map(|path| path.to_string_lossy().to_string())
534 .collect(),
535 })
536 .map_err(store_err)?;
537
538 let report = self
539 .validator
540 .run(&receipt, &self.validation_plan)
541 .await
542 .map_err(|err| EvoKernelError::Validation(err.to_string()))?;
543 if !report.success {
544 self.store
545 .append_event(EvolutionEvent::ValidationFailed {
546 mutation_id: mutation.intent.id.clone(),
547 report: report.to_snapshot(&self.validation_plan.profile),
548 gene_id: None,
549 })
550 .map_err(store_err)?;
551 return Err(EvoKernelError::ValidationFailed(report));
552 }
553
554 let projection = self.store.rebuild_projection().map_err(store_err)?;
555 let blast_radius = compute_blast_radius(&mutation.artifact.payload);
556 let success_count = projection
557 .genes
558 .iter()
559 .find(|gene| {
560 gene.id == derive_gene(&mutation, &receipt, &self.validation_plan.profile).id
561 })
562 .map(|existing| {
563 projection
564 .capsules
565 .iter()
566 .filter(|capsule| capsule.gene_id == existing.id)
567 .count() as u64
568 })
569 .unwrap_or(0)
570 + 1;
571 let governor_decision = self.governor.evaluate(GovernorInput {
572 candidate_source: CandidateSource::Local,
573 success_count,
574 blast_radius: blast_radius.clone(),
575 replay_failures: 0,
576 });
577
578 let mut gene = derive_gene(&mutation, &receipt, &self.validation_plan.profile);
579 gene.state = governor_decision.target_state.clone();
580 self.store
581 .append_event(EvolutionEvent::ValidationPassed {
582 mutation_id: mutation.intent.id.clone(),
583 report: report.to_snapshot(&self.validation_plan.profile),
584 gene_id: Some(gene.id.clone()),
585 })
586 .map_err(store_err)?;
587 self.store
588 .append_event(EvolutionEvent::GeneProjected { gene: gene.clone() })
589 .map_err(store_err)?;
590 self.store
591 .append_event(EvolutionEvent::PromotionEvaluated {
592 gene_id: gene.id.clone(),
593 state: governor_decision.target_state.clone(),
594 reason: governor_decision.reason.clone(),
595 })
596 .map_err(store_err)?;
597 if matches!(governor_decision.target_state, AssetState::Promoted) {
598 self.store
599 .append_event(EvolutionEvent::GenePromoted {
600 gene_id: gene.id.clone(),
601 })
602 .map_err(store_err)?;
603 }
604 if let Some(spec_id) = &mutation.intent.spec_id {
605 self.store
606 .append_event(EvolutionEvent::SpecLinked {
607 mutation_id: mutation.intent.id.clone(),
608 spec_id: spec_id.clone(),
609 })
610 .map_err(store_err)?;
611 }
612
613 let mut capsule = build_capsule(
614 run_id,
615 &mutation,
616 &receipt,
617 &report,
618 &self.validation_plan.profile,
619 &gene,
620 &blast_radius,
621 )
622 .map_err(|err| EvoKernelError::Validation(err.to_string()))?;
623 capsule.state = governor_decision.target_state.clone();
624 self.store
625 .append_event(EvolutionEvent::CapsuleCommitted {
626 capsule: capsule.clone(),
627 })
628 .map_err(store_err)?;
629 if matches!(governor_decision.target_state, AssetState::Quarantined) {
630 self.store
631 .append_event(EvolutionEvent::CapsuleQuarantined {
632 capsule_id: capsule.id.clone(),
633 })
634 .map_err(store_err)?;
635 }
636
637 Ok(CaptureOutcome {
638 capsule,
639 gene,
640 governor_decision,
641 })
642 }
643
644 pub async fn capture_from_proposal(
645 &self,
646 run_id: &RunId,
647 proposal: &AgentMutationProposal,
648 diff_payload: String,
649 base_revision: Option<String>,
650 ) -> Result<CaptureOutcome, EvoKernelError> {
651 let intent = MutationIntent {
652 id: next_id("proposal"),
653 intent: proposal.intent.clone(),
654 target: MutationTarget::Paths {
655 allow: proposal.files.clone(),
656 },
657 expected_effect: proposal.expected_effect.clone(),
658 risk: RiskLevel::Low,
659 signals: proposal.files.clone(),
660 spec_id: None,
661 };
662 self.capture_mutation_with_governor(
663 run_id,
664 prepare_mutation(intent, diff_payload, base_revision),
665 )
666 .await
667 }
668
669 pub fn feedback_for_agent(outcome: &CaptureOutcome) -> ExecutionFeedback {
670 ExecutionFeedback {
671 accepted: !matches!(outcome.governor_decision.target_state, AssetState::Revoked),
672 asset_state: Some(format!("{:?}", outcome.governor_decision.target_state)),
673 summary: outcome.governor_decision.reason.clone(),
674 }
675 }
676
677 pub fn export_promoted_assets(
678 &self,
679 sender_id: impl Into<String>,
680 ) -> Result<EvolutionEnvelope, EvoKernelError> {
681 export_promoted_assets_from_store(self.store.as_ref(), sender_id)
682 }
683
684 pub fn import_remote_envelope(
685 &self,
686 envelope: &EvolutionEnvelope,
687 ) -> Result<ImportOutcome, EvoKernelError> {
688 import_remote_envelope_into_store(self.store.as_ref(), envelope)
689 }
690
691 pub fn fetch_assets(
692 &self,
693 responder_id: impl Into<String>,
694 query: &FetchQuery,
695 ) -> Result<FetchResponse, EvoKernelError> {
696 fetch_assets_from_store(self.store.as_ref(), responder_id, query)
697 }
698
699 pub fn revoke_assets(&self, notice: &RevokeNotice) -> Result<RevokeNotice, EvoKernelError> {
700 revoke_assets_in_store(self.store.as_ref(), notice)
701 }
702
703 pub async fn replay_or_fallback(
704 &self,
705 input: SelectorInput,
706 ) -> Result<ReplayDecision, EvoKernelError> {
707 let executor = StoreReplayExecutor {
708 sandbox: self.sandbox.clone(),
709 validator: self.validator.clone(),
710 store: self.store.clone(),
711 selector: self.selector.clone(),
712 };
713 executor
714 .try_replay(&input, &self.sandbox_policy, &self.validation_plan)
715 .await
716 .map_err(|err| EvoKernelError::Validation(err.to_string()))
717 }
718}
719
720pub fn prepare_mutation(
721 intent: MutationIntent,
722 diff_payload: String,
723 base_revision: Option<String>,
724) -> PreparedMutation {
725 PreparedMutation {
726 intent,
727 artifact: MutationArtifact {
728 encoding: ArtifactEncoding::UnifiedDiff,
729 content_hash: compute_artifact_hash(&diff_payload),
730 payload: diff_payload,
731 base_revision,
732 },
733 }
734}
735
736pub fn prepare_mutation_from_spec(
737 plan: CompiledMutationPlan,
738 diff_payload: String,
739 base_revision: Option<String>,
740) -> PreparedMutation {
741 prepare_mutation(plan.mutation_intent, diff_payload, base_revision)
742}
743
744pub fn default_evolution_store() -> Arc<dyn EvolutionStore> {
745 Arc::new(oris_evolution::JsonlEvolutionStore::new(
746 default_store_root(),
747 ))
748}
749
750fn derive_gene(
751 mutation: &PreparedMutation,
752 receipt: &SandboxReceipt,
753 validation_profile: &str,
754) -> Gene {
755 let mut strategy = BTreeSet::new();
756 for file in &receipt.changed_files {
757 if let Some(component) = file.components().next() {
758 strategy.insert(component.as_os_str().to_string_lossy().to_string());
759 }
760 }
761 for token in mutation
762 .artifact
763 .payload
764 .split(|ch: char| !ch.is_ascii_alphanumeric())
765 {
766 if token.len() == 5
767 && token.starts_with('E')
768 && token[1..].chars().all(|ch| ch.is_ascii_digit())
769 {
770 strategy.insert(token.to_string());
771 }
772 }
773 for token in mutation.intent.intent.split_whitespace().take(8) {
774 strategy.insert(token.to_ascii_lowercase());
775 }
776 let strategy = strategy.into_iter().collect::<Vec<_>>();
777 let id = stable_hash_json(&(&mutation.intent.signals, &strategy, validation_profile))
778 .unwrap_or_else(|_| next_id("gene"));
779 Gene {
780 id,
781 signals: mutation.intent.signals.clone(),
782 strategy,
783 validation: vec![validation_profile.to_string()],
784 state: AssetState::Promoted,
785 }
786}
787
788fn build_capsule(
789 run_id: &RunId,
790 mutation: &PreparedMutation,
791 receipt: &SandboxReceipt,
792 report: &ValidationReport,
793 validation_profile: &str,
794 gene: &Gene,
795 blast_radius: &BlastRadius,
796) -> Result<Capsule, EvolutionError> {
797 let env = current_env_fingerprint(&receipt.workdir);
798 let validator_hash = stable_hash_json(report)?;
799 let diff_hash = mutation.artifact.content_hash.clone();
800 let id = stable_hash_json(&(run_id, &gene.id, &diff_hash, &mutation.intent.id))?;
801 Ok(Capsule {
802 id,
803 gene_id: gene.id.clone(),
804 mutation_id: mutation.intent.id.clone(),
805 run_id: run_id.clone(),
806 diff_hash,
807 confidence: 0.7,
808 env,
809 outcome: oris_evolution::Outcome {
810 success: true,
811 validation_profile: validation_profile.to_string(),
812 validation_duration_ms: report.duration_ms,
813 changed_files: receipt
814 .changed_files
815 .iter()
816 .map(|path| path.to_string_lossy().to_string())
817 .collect(),
818 validator_hash,
819 lines_changed: blast_radius.lines_changed,
820 replay_verified: false,
821 },
822 state: AssetState::Promoted,
823 })
824}
825
826fn current_env_fingerprint(workdir: &Path) -> EnvFingerprint {
827 let rustc_version = Command::new("rustc")
828 .arg("--version")
829 .output()
830 .ok()
831 .filter(|output| output.status.success())
832 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
833 .unwrap_or_else(|| "rustc unknown".into());
834 let cargo_lock_hash = fs::read(workdir.join("Cargo.lock"))
835 .ok()
836 .map(|bytes| {
837 let value = String::from_utf8_lossy(&bytes);
838 compute_artifact_hash(&value)
839 })
840 .unwrap_or_else(|| "missing-cargo-lock".into());
841 let target_triple = format!(
842 "{}-unknown-{}",
843 std::env::consts::ARCH,
844 std::env::consts::OS
845 );
846 EnvFingerprint {
847 rustc_version,
848 cargo_lock_hash,
849 target_triple,
850 os: std::env::consts::OS.to_string(),
851 }
852}
853
854fn find_declared_mutation(
855 store: &dyn EvolutionStore,
856 mutation_id: &MutationId,
857) -> Result<Option<PreparedMutation>, EvolutionError> {
858 for stored in store.scan(1)? {
859 if let EvolutionEvent::MutationDeclared { mutation } = stored.event {
860 if &mutation.intent.id == mutation_id {
861 return Ok(Some(mutation));
862 }
863 }
864 }
865 Ok(None)
866}
867
868fn exact_match_candidate(
869 store: &dyn EvolutionStore,
870 input: &SelectorInput,
871) -> Option<GeneCandidate> {
872 let projection = store.rebuild_projection().ok()?;
873 let capsules = projection.capsules.clone();
874 let signal_set = input
875 .signals
876 .iter()
877 .map(|signal| signal.to_ascii_lowercase())
878 .collect::<BTreeSet<_>>();
879 projection.genes.into_iter().find_map(|gene| {
880 if gene.state != AssetState::Promoted {
881 return None;
882 }
883 let gene_signals = gene
884 .signals
885 .iter()
886 .map(|signal| signal.to_ascii_lowercase())
887 .collect::<BTreeSet<_>>();
888 if gene_signals == signal_set {
889 let matched_capsules = capsules
890 .iter()
891 .filter(|capsule| {
892 capsule.gene_id == gene.id && capsule.state == AssetState::Promoted
893 })
894 .cloned()
895 .collect::<Vec<_>>();
896 if matched_capsules.is_empty() {
897 None
898 } else {
899 Some(GeneCandidate {
900 gene,
901 score: 1.0,
902 capsules: matched_capsules,
903 })
904 }
905 } else {
906 None
907 }
908 })
909}
910
911fn export_promoted_assets_from_store(
912 store: &dyn EvolutionStore,
913 sender_id: impl Into<String>,
914) -> Result<EvolutionEnvelope, EvoKernelError> {
915 let projection = store.rebuild_projection().map_err(store_err)?;
916 let mut assets = Vec::new();
917 for gene in projection
918 .genes
919 .into_iter()
920 .filter(|gene| gene.state == AssetState::Promoted)
921 {
922 assets.push(NetworkAsset::Gene { gene });
923 }
924 for capsule in projection
925 .capsules
926 .into_iter()
927 .filter(|capsule| capsule.state == AssetState::Promoted)
928 {
929 assets.push(NetworkAsset::Capsule { capsule });
930 }
931 Ok(EvolutionEnvelope::publish(sender_id, assets))
932}
933
934fn import_remote_envelope_into_store(
935 store: &dyn EvolutionStore,
936 envelope: &EvolutionEnvelope,
937) -> Result<ImportOutcome, EvoKernelError> {
938 if !envelope.verify_content_hash() {
939 return Err(EvoKernelError::Validation(
940 "invalid evolution envelope hash".into(),
941 ));
942 }
943
944 let mut imported_asset_ids = Vec::new();
945 for asset in &envelope.assets {
946 match asset {
947 NetworkAsset::Gene { gene } => {
948 imported_asset_ids.push(gene.id.clone());
949 store
950 .append_event(EvolutionEvent::RemoteAssetImported {
951 source: CandidateSource::Remote,
952 asset_ids: vec![gene.id.clone()],
953 })
954 .map_err(store_err)?;
955 store
956 .append_event(EvolutionEvent::GeneProjected { gene: gene.clone() })
957 .map_err(store_err)?;
958 }
959 NetworkAsset::Capsule { capsule } => {
960 imported_asset_ids.push(capsule.id.clone());
961 store
962 .append_event(EvolutionEvent::RemoteAssetImported {
963 source: CandidateSource::Remote,
964 asset_ids: vec![capsule.id.clone()],
965 })
966 .map_err(store_err)?;
967 let mut quarantined = capsule.clone();
968 quarantined.state = AssetState::Quarantined;
969 store
970 .append_event(EvolutionEvent::CapsuleCommitted {
971 capsule: quarantined.clone(),
972 })
973 .map_err(store_err)?;
974 store
975 .append_event(EvolutionEvent::CapsuleQuarantined {
976 capsule_id: quarantined.id,
977 })
978 .map_err(store_err)?;
979 }
980 NetworkAsset::EvolutionEvent { event } => {
981 store.append_event(event.clone()).map_err(store_err)?;
982 }
983 }
984 }
985
986 Ok(ImportOutcome {
987 imported_asset_ids,
988 accepted: true,
989 })
990}
991
992fn fetch_assets_from_store(
993 store: &dyn EvolutionStore,
994 responder_id: impl Into<String>,
995 query: &FetchQuery,
996) -> Result<FetchResponse, EvoKernelError> {
997 let projection = store.rebuild_projection().map_err(store_err)?;
998 let normalized_signals: Vec<String> = query
999 .signals
1000 .iter()
1001 .map(|signal| signal.trim().to_ascii_lowercase())
1002 .filter(|signal| !signal.is_empty())
1003 .collect();
1004 let matches_any_signal = |candidate: &str| {
1005 if normalized_signals.is_empty() {
1006 return true;
1007 }
1008 let candidate = candidate.to_ascii_lowercase();
1009 normalized_signals
1010 .iter()
1011 .any(|signal| candidate.contains(signal) || signal.contains(&candidate))
1012 };
1013
1014 let matched_genes: Vec<Gene> = projection
1015 .genes
1016 .into_iter()
1017 .filter(|gene| gene.state == AssetState::Promoted)
1018 .filter(|gene| gene.signals.iter().any(|signal| matches_any_signal(signal)))
1019 .collect();
1020 let matched_gene_ids: BTreeSet<String> =
1021 matched_genes.iter().map(|gene| gene.id.clone()).collect();
1022 let matched_capsules: Vec<Capsule> = projection
1023 .capsules
1024 .into_iter()
1025 .filter(|capsule| capsule.state == AssetState::Promoted)
1026 .filter(|capsule| matched_gene_ids.contains(&capsule.gene_id))
1027 .collect();
1028
1029 let mut assets = Vec::new();
1030 for gene in matched_genes {
1031 assets.push(NetworkAsset::Gene { gene });
1032 }
1033 for capsule in matched_capsules {
1034 assets.push(NetworkAsset::Capsule { capsule });
1035 }
1036
1037 Ok(FetchResponse {
1038 sender_id: responder_id.into(),
1039 assets,
1040 })
1041}
1042
1043fn revoke_assets_in_store(
1044 store: &dyn EvolutionStore,
1045 notice: &RevokeNotice,
1046) -> Result<RevokeNotice, EvoKernelError> {
1047 let projection = store.rebuild_projection().map_err(store_err)?;
1048 let requested: BTreeSet<String> = notice
1049 .asset_ids
1050 .iter()
1051 .map(|asset_id| asset_id.trim().to_string())
1052 .filter(|asset_id| !asset_id.is_empty())
1053 .collect();
1054 let mut revoked_gene_ids = BTreeSet::new();
1055 let mut quarantined_capsule_ids = BTreeSet::new();
1056
1057 for gene in &projection.genes {
1058 if requested.contains(&gene.id) {
1059 revoked_gene_ids.insert(gene.id.clone());
1060 }
1061 }
1062 for capsule in &projection.capsules {
1063 if requested.contains(&capsule.id) {
1064 quarantined_capsule_ids.insert(capsule.id.clone());
1065 revoked_gene_ids.insert(capsule.gene_id.clone());
1066 }
1067 }
1068 for capsule in &projection.capsules {
1069 if revoked_gene_ids.contains(&capsule.gene_id) {
1070 quarantined_capsule_ids.insert(capsule.id.clone());
1071 }
1072 }
1073
1074 for gene_id in &revoked_gene_ids {
1075 store
1076 .append_event(EvolutionEvent::GeneRevoked {
1077 gene_id: gene_id.clone(),
1078 reason: notice.reason.clone(),
1079 })
1080 .map_err(store_err)?;
1081 }
1082 for capsule_id in &quarantined_capsule_ids {
1083 store
1084 .append_event(EvolutionEvent::CapsuleQuarantined {
1085 capsule_id: capsule_id.clone(),
1086 })
1087 .map_err(store_err)?;
1088 }
1089
1090 let mut affected_ids: Vec<String> = revoked_gene_ids.into_iter().collect();
1091 affected_ids.extend(quarantined_capsule_ids);
1092 affected_ids.sort();
1093 affected_ids.dedup();
1094
1095 Ok(RevokeNotice {
1096 sender_id: notice.sender_id.clone(),
1097 asset_ids: affected_ids,
1098 reason: notice.reason.clone(),
1099 })
1100}
1101
1102fn store_err(err: EvolutionError) -> EvoKernelError {
1103 EvoKernelError::Store(err.to_string())
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108 use super::*;
1109 use oris_kernel::{
1110 AllowAllPolicy, InMemoryEventStore, KernelMode, KernelState, NoopActionExecutor,
1111 NoopStepFn, StateUpdatedOnlyReducer,
1112 };
1113 use serde::{Deserialize, Serialize};
1114
1115 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
1116 struct TestState;
1117
1118 impl KernelState for TestState {
1119 fn version(&self) -> u32 {
1120 1
1121 }
1122 }
1123
1124 fn temp_workspace(name: &str) -> std::path::PathBuf {
1125 let root =
1126 std::env::temp_dir().join(format!("oris-evokernel-{name}-{}", std::process::id()));
1127 if root.exists() {
1128 fs::remove_dir_all(&root).unwrap();
1129 }
1130 fs::create_dir_all(root.join("src")).unwrap();
1131 fs::write(
1132 root.join("Cargo.toml"),
1133 "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1134 )
1135 .unwrap();
1136 fs::write(root.join("Cargo.lock"), "# lock\n").unwrap();
1137 fs::write(root.join("src/lib.rs"), "pub fn demo() -> usize { 1 }\n").unwrap();
1138 root
1139 }
1140
1141 fn test_kernel() -> Arc<Kernel<TestState>> {
1142 Arc::new(Kernel::<TestState> {
1143 events: Box::new(InMemoryEventStore::new()),
1144 snaps: None,
1145 reducer: Box::new(StateUpdatedOnlyReducer),
1146 exec: Box::new(NoopActionExecutor),
1147 step: Box::new(NoopStepFn),
1148 policy: Box::new(AllowAllPolicy),
1149 effect_sink: None,
1150 mode: KernelMode::Normal,
1151 })
1152 }
1153
1154 fn lightweight_plan() -> ValidationPlan {
1155 ValidationPlan {
1156 profile: "test".into(),
1157 stages: vec![ValidationStage::Command {
1158 program: "git".into(),
1159 args: vec!["--version".into()],
1160 timeout_ms: 5_000,
1161 }],
1162 }
1163 }
1164
1165 fn sample_mutation() -> PreparedMutation {
1166 prepare_mutation(
1167 MutationIntent {
1168 id: "mutation-1".into(),
1169 intent: "add README".into(),
1170 target: MutationTarget::Paths {
1171 allow: vec!["README.md".into()],
1172 },
1173 expected_effect: "repo still builds".into(),
1174 risk: RiskLevel::Low,
1175 signals: vec!["missing readme".into()],
1176 spec_id: None,
1177 },
1178 "\
1179diff --git a/README.md b/README.md
1180new file mode 100644
1181index 0000000..1111111
1182--- /dev/null
1183+++ b/README.md
1184@@ -0,0 +1 @@
1185+# sample
1186"
1187 .into(),
1188 Some("HEAD".into()),
1189 )
1190 }
1191
1192 #[tokio::test]
1193 async fn command_validator_aggregates_stage_reports() {
1194 let workspace = temp_workspace("validator");
1195 let receipt = SandboxReceipt {
1196 mutation_id: "m".into(),
1197 workdir: workspace,
1198 applied: true,
1199 changed_files: Vec::new(),
1200 patch_hash: "hash".into(),
1201 stdout_log: std::env::temp_dir().join("stdout.log"),
1202 stderr_log: std::env::temp_dir().join("stderr.log"),
1203 };
1204 let validator = CommandValidator::new(SandboxPolicy {
1205 allowed_programs: vec!["git".into()],
1206 max_duration_ms: 1_000,
1207 max_output_bytes: 1024,
1208 denied_env_prefixes: Vec::new(),
1209 });
1210 let report = validator
1211 .run(
1212 &receipt,
1213 &ValidationPlan {
1214 profile: "test".into(),
1215 stages: vec![ValidationStage::Command {
1216 program: "git".into(),
1217 args: vec!["--version".into()],
1218 timeout_ms: 1_000,
1219 }],
1220 },
1221 )
1222 .await
1223 .unwrap();
1224 assert_eq!(report.stages.len(), 1);
1225 }
1226
1227 #[tokio::test]
1228 async fn capture_successful_mutation_appends_capsule() {
1229 let workspace = temp_workspace("capture");
1230 let store_root =
1231 std::env::temp_dir().join(format!("oris-evokernel-store-{}", std::process::id()));
1232 if store_root.exists() {
1233 fs::remove_dir_all(&store_root).unwrap();
1234 }
1235 let store: Arc<dyn EvolutionStore> =
1236 Arc::new(oris_evolution::JsonlEvolutionStore::new(&store_root));
1237 let sandbox: Arc<dyn Sandbox> = Arc::new(oris_sandbox::LocalProcessSandbox::new(
1238 "run-1",
1239 &workspace,
1240 std::env::temp_dir(),
1241 ));
1242 let validator: Arc<dyn Validator> = Arc::new(CommandValidator::new(SandboxPolicy {
1243 allowed_programs: vec!["git".into()],
1244 max_duration_ms: 60_000,
1245 max_output_bytes: 1024 * 1024,
1246 denied_env_prefixes: Vec::new(),
1247 }));
1248 let evo = EvoKernel::new(test_kernel(), sandbox, validator, store.clone())
1249 .with_governor(Arc::new(DefaultGovernor::new(
1250 oris_governor::GovernorConfig {
1251 promote_after_successes: 1,
1252 ..Default::default()
1253 },
1254 )))
1255 .with_validation_plan(lightweight_plan())
1256 .with_sandbox_policy(SandboxPolicy {
1257 allowed_programs: vec!["git".into()],
1258 max_duration_ms: 60_000,
1259 max_output_bytes: 1024 * 1024,
1260 denied_env_prefixes: Vec::new(),
1261 });
1262 let capsule = evo
1263 .capture_successful_mutation(&"run-1".into(), sample_mutation())
1264 .await
1265 .unwrap();
1266 let events = store.scan(1).unwrap();
1267 assert!(events
1268 .iter()
1269 .any(|stored| matches!(stored.event, EvolutionEvent::CapsuleCommitted { .. })));
1270 assert!(!capsule.id.is_empty());
1271 }
1272
1273 #[tokio::test]
1274 async fn replay_hit_records_capsule_reused() {
1275 let workspace = temp_workspace("replay");
1276 let store_root =
1277 std::env::temp_dir().join(format!("oris-evokernel-replay-{}", std::process::id()));
1278 if store_root.exists() {
1279 fs::remove_dir_all(&store_root).unwrap();
1280 }
1281 let store: Arc<dyn EvolutionStore> =
1282 Arc::new(oris_evolution::JsonlEvolutionStore::new(&store_root));
1283 let sandbox: Arc<dyn Sandbox> = Arc::new(oris_sandbox::LocalProcessSandbox::new(
1284 "run-2",
1285 &workspace,
1286 std::env::temp_dir(),
1287 ));
1288 let validator: Arc<dyn Validator> = Arc::new(CommandValidator::new(SandboxPolicy {
1289 allowed_programs: vec!["git".into()],
1290 max_duration_ms: 60_000,
1291 max_output_bytes: 1024 * 1024,
1292 denied_env_prefixes: Vec::new(),
1293 }));
1294 let evo = EvoKernel::new(test_kernel(), sandbox, validator, store.clone())
1295 .with_governor(Arc::new(DefaultGovernor::new(
1296 oris_governor::GovernorConfig {
1297 promote_after_successes: 1,
1298 ..Default::default()
1299 },
1300 )))
1301 .with_validation_plan(lightweight_plan())
1302 .with_sandbox_policy(SandboxPolicy {
1303 allowed_programs: vec!["git".into()],
1304 max_duration_ms: 60_000,
1305 max_output_bytes: 1024 * 1024,
1306 denied_env_prefixes: Vec::new(),
1307 });
1308 let capsule = evo
1309 .capture_successful_mutation(&"run-2".into(), sample_mutation())
1310 .await
1311 .unwrap();
1312 let decision = evo
1313 .replay_or_fallback(SelectorInput {
1314 signals: vec!["missing readme".into()],
1315 env: EnvFingerprint {
1316 rustc_version: "rustc".into(),
1317 cargo_lock_hash: "lock".into(),
1318 target_triple: "x86_64-unknown-linux-gnu".into(),
1319 os: std::env::consts::OS.into(),
1320 },
1321 limit: 1,
1322 })
1323 .await
1324 .unwrap();
1325 assert!(decision.used_capsule);
1326 assert_eq!(decision.capsule_id, Some(capsule.id));
1327 assert!(store
1328 .scan(1)
1329 .unwrap()
1330 .iter()
1331 .any(|stored| matches!(stored.event, EvolutionEvent::CapsuleReused { .. })));
1332 }
1333}