Skip to main content

aor_runtime/
lib.rs

1//! aor-runtime — the AgentOps-Runtime assembly crate.
2//!
3//! The only crate allowed to know every gate. It owns registration order,
4//! the observe/sweep engine, and the store/artifact wiring. Gate policy
5//! stays inside each `aor-<gate>` crate; host adapters stay outside.
6
7use aor::{
8    ArtifactSink, Event, EventKind, Finding, Gate, GateContext, GateDescriptor, Record, Result,
9    Severity, Store,
10};
11
12/// Every registered gate, in stable order.
13pub fn gates() -> Vec<Box<dyn Gate>> {
14    vec![
15        Box::new(aor_close::CloseGate),
16        Box::new(aor_decision::DecisionGate),
17        Box::new(aor_dispatch::DispatchGate),
18        Box::new(aor_handoff::HandoffGate),
19        Box::new(aor_payload::PayloadGate),
20        Box::new(aor_resource::ResourceGate),
21        Box::new(aor_verify::VerifyGate),
22        Box::new(aor_wait::WaitGate),
23    ]
24}
25
26/// Every registered gate descriptor, in stable order.
27pub fn gate_descriptors() -> Vec<&'static GateDescriptor> {
28    gates().iter().map(|g| g.descriptor()).collect()
29}
30
31/// The assembled runtime: gates over a shared store + artifact sink.
32/// One event turns into per-gate findings plus emitted records; the raw
33/// observation is persisted unconditionally so the SQLite store refreshes
34/// alongside every observation.
35pub struct Runtime {
36    store: Box<dyn Store>,
37    artifacts: Box<dyn ArtifactSink>,
38    gates: Vec<Box<dyn Gate>>,
39}
40
41struct Ctx<'a> {
42    store: &'a dyn Store,
43    artifacts: &'a dyn ArtifactSink,
44    emitted: Vec<Record>,
45}
46
47impl GateContext for Ctx<'_> {
48    fn store(&self) -> &dyn Store {
49        self.store
50    }
51    fn artifacts(&self) -> &dyn ArtifactSink {
52        self.artifacts
53    }
54    fn emit(&mut self, record: Record) {
55        self.emitted.push(record);
56    }
57}
58
59impl Runtime {
60    pub fn new(store: Box<dyn Store>, artifacts: Box<dyn ArtifactSink>) -> Self {
61        Self {
62            store,
63            artifacts,
64            gates: gates(),
65        }
66    }
67
68    /// Route one host event through every gate. Returns all findings;
69    /// emitted records and the raw observation are persisted.
70    pub fn dispatch(&self, event: &Event) -> Result<Vec<Finding>> {
71        self.store
72            .append_observation(&format!("{:?}", event.kind), &event.detail, event.ts_ms)?;
73        let mut findings = Vec::new();
74        for gate in &self.gates {
75            let mut ctx = Ctx {
76                store: &*self.store,
77                artifacts: &*self.artifacts,
78                emitted: Vec::new(),
79            };
80            findings.extend(gate.observe(event, &mut ctx)?);
81            for record in ctx.emitted {
82                self.store.append_record(&record)?;
83            }
84        }
85        Ok(findings)
86    }
87
88    /// Periodic sweep across every gate (stale harvest, reconciliation).
89    /// Sweep itself is an observation row so patrol cadence is auditable.
90    pub fn sweep(&self) -> Result<Vec<Finding>> {
91        self.store
92            .append_observation("sweep", &serde_json::Value::Null, aor::now_ms())?;
93        let mut findings = Vec::new();
94        for gate in &self.gates {
95            let mut ctx = Ctx {
96                store: &*self.store,
97                artifacts: &*self.artifacts,
98                emitted: Vec::new(),
99            };
100            findings.extend(gate.sweep(&mut ctx)?);
101            for record in ctx.emitted {
102                self.store.append_record(&record)?;
103            }
104        }
105        Ok(findings)
106    }
107
108    pub fn store(&self) -> &dyn Store {
109        &*self.store
110    }
111
112    /// Convenience: wrap a host tool call as a Dispatch/ToolCall event and
113    /// dispatch it. Adapters call `dispatch` directly with richer kinds.
114    pub fn observe_tool(&self, tool: &str, args: serde_json::Value) -> Result<Vec<Finding>> {
115        self.dispatch(&Event::new(
116            EventKind::ToolCall,
117            serde_json::json!({ "tool": tool, "args": args }),
118        ))
119    }
120}
121
122/// Render findings for CLI/projection consumers.
123pub fn render_findings(findings: &[Finding]) -> String {
124    findings
125        .iter()
126        .map(|f| {
127            format!(
128                "[{}] ({}) {}",
129                f.gate,
130                match f.severity {
131                    Severity::Info => "info",
132                    Severity::Warn => "warn",
133                    Severity::High => "HIGH",
134                },
135                f.message
136            )
137        })
138        .collect::<Vec<_>>()
139        .join("\n")
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use aor::mem::{MemArtifacts, MemStore};
146    use std::collections::HashSet;
147
148    fn rt() -> (Runtime, std::sync::Arc<MemStore>) {
149        let store = std::sync::Arc::new(MemStore::new());
150        let rt = Runtime::new(
151            Box::new(ArcStore(store.clone())),
152            Box::new(MemArtifacts::new()),
153        );
154        (rt, store)
155    }
156
157    /// MemStore behind Arc so tests can inspect what the runtime wrote.
158    struct ArcStore(std::sync::Arc<MemStore>);
159    impl Store for ArcStore {
160        fn append_record(&self, r: &Record) -> Result<i64> {
161            self.0.append_record(r)
162        }
163        fn records(&self, gate: Option<&str>, limit: usize) -> Result<Vec<Record>> {
164            self.0.records(gate, limit)
165        }
166        fn append_observation(
167            &self,
168            kind: &str,
169            detail: &serde_json::Value,
170            ts_ms: u64,
171        ) -> Result<i64> {
172            self.0.append_observation(kind, detail, ts_ms)
173        }
174        fn counter_hit(&self, key: &str, ts_ms: u64) -> Result<()> {
175            self.0.counter_hit(key, ts_ms)
176        }
177        fn counter_count(&self, key: &str, since_ms: u64) -> Result<u64> {
178            self.0.counter_count(key, since_ms)
179        }
180        fn upsert_todo(&self, t: &aor::Todo) -> Result<()> {
181            self.0.upsert_todo(t)
182        }
183        fn todos(&self, state: Option<&str>) -> Result<Vec<aor::Todo>> {
184            self.0.todos(state)
185        }
186        fn append_memory(&self, m: &aor::MemoryEntry) -> Result<i64> {
187            self.0.append_memory(m)
188        }
189        fn memory(&self, kind: Option<&str>, limit: usize) -> Result<Vec<aor::MemoryEntry>> {
190            self.0.memory(kind, limit)
191        }
192    }
193
194    #[test]
195    fn every_gate_declares_at_least_one_profile() {
196        for d in gate_descriptors() {
197            assert!(!d.profiles.is_empty(), "gate {} has no profiles", d.name);
198        }
199    }
200
201    #[test]
202    fn gate_names_are_unique() {
203        let names: HashSet<_> = gate_descriptors().iter().map(|d| d.name).collect();
204        assert_eq!(names.len(), gate_descriptors().len());
205    }
206
207    #[test]
208    fn no_gate_declares_a_channel_twice() {
209        for d in gate_descriptors() {
210            let channels: HashSet<_> = d.profiles.iter().map(|p| p.enforcement).collect();
211            assert_eq!(
212                channels.len(),
213                d.profiles.len(),
214                "gate {} repeats an enforcement channel",
215                d.name
216            );
217        }
218    }
219
220    #[test]
221    fn dispatch_persists_raw_observation() {
222        let (rt, store) = rt();
223        let _ = rt.dispatch(&Event::new(
224            EventKind::ToolCall,
225            serde_json::json!({"tool": "x"}),
226        ));
227        // observation stream receives the event even when no gate fires
228        assert!(store.counter_count("nope", 0).is_ok());
229        drop(store);
230    }
231
232    #[test]
233    fn sweep_runs_without_panic() {
234        let (rt, _store) = rt();
235        rt.sweep().expect("sweep ok");
236    }
237}