Skip to main content

oris_evokernel/
core.rs

1//! EvoKernel orchestration: mutation capture, validation, capsule construction, and replay-first reuse.
2
3use 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}