traverse_runtime/router/mod.rs
1//! Governed by spec 016-runtime-placement-router
2//!
3//! `PlacementRouter` is the single public entry point for all capability execution
4//! in `traverse-runtime`. It wires together:
5//!
6//! 1. Placement evaluation ([`PlacementConstraintEvaluator`])
7//! 2. Executor selection ([`CapabilityExecutorRegistry`])
8//! 3. Execution ([`CapabilityExecutor`])
9//! 4. Trace recording ([`TraceStore`])
10//! 5. Conditional event publishing ([`EventBroker`])
11
12use std::{
13 collections::HashMap,
14 sync::{Arc, Mutex},
15 time::Instant,
16};
17
18use chrono::Utc;
19use serde_json::Value;
20use traverse_contracts::{CapabilityContract, ServiceType, ViolationRecord};
21
22use crate::{
23 events::types::{EventBroker, TraverseEvent},
24 executor::{ArtifactType, CapabilityExecutor, ExecutorCapability},
25 placement::{
26 PlacementConstraintEvaluator, PlacementDecision, PlacementError, PlacementRequest,
27 RuntimeSnapshot,
28 },
29 trace::{
30 DurableTraceConfig, PrivateTraceEntry, PublicTraceEntry, TraceOutcome, TraceStore,
31 new_trace_id_and_time,
32 },
33};
34
35use traverse_contracts::ExecutionTarget;
36
37// ---------------------------------------------------------------------------
38// Public types
39// ---------------------------------------------------------------------------
40
41/// Maps [`ArtifactType`] to the appropriate [`CapabilityExecutor`] implementation.
42pub type CapabilityExecutorRegistry = HashMap<ArtifactType, Box<dyn CapabilityExecutor>>;
43
44/// Input to [`PlacementRouter::execute`].
45pub struct RouterRequest {
46 /// Unique capability identifier.
47 pub capability_id: String,
48 /// How the capability is packaged.
49 pub artifact_type: ArtifactType,
50 /// The validated contract for this capability (used for placement evaluation).
51 pub contract: CapabilityContract,
52 /// Optional caller hint for target placement.
53 pub target_hint: Option<ExecutionTarget>,
54 /// Current runtime load snapshot used by the placement evaluator.
55 pub runtime_snapshot: RuntimeSnapshot,
56 /// JSON input payload for the capability.
57 pub input: Value,
58 /// Resolved capability descriptor passed to the executor.
59 pub executor_capability: ExecutorCapability,
60 /// When set, used as the public/private [`TraceStore`] id instead of minting a new UUID.
61 pub trace_id_override: Option<String>,
62}
63
64/// Errors returned by [`PlacementRouter::execute`].
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum RouterError {
67 /// The placement constraint evaluator rejected the request.
68 PlacementFailed(PlacementError),
69 /// No executor is registered for the requested [`ArtifactType`].
70 ExecutorNotFound(String),
71 /// The selected executor returned an error.
72 ExecutionFailed(String),
73 /// Execution violated a governed contract (aggregate violations).
74 ContractViolation(Vec<ViolationRecord>),
75 /// The trace store lock was poisoned.
76 TraceLockPoisoned,
77 /// The trace could not be durably written and this router is configured
78 /// to fail closed on that condition (spec 079 FR-002).
79 DurableTraceWriteFailed(String),
80}
81
82impl std::fmt::Display for RouterError {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 match self {
85 Self::PlacementFailed(e) => write!(f, "placement failed: {e:?}"),
86 Self::ExecutorNotFound(t) => write!(f, "no executor registered for artifact type: {t}"),
87 Self::ExecutionFailed(msg) => write!(f, "execution failed: {msg}"),
88 Self::ContractViolation(violations) => {
89 write!(f, "contract violation: {} violation(s)", violations.len())
90 }
91 Self::TraceLockPoisoned => write!(f, "trace store lock is poisoned"),
92 Self::DurableTraceWriteFailed(msg) => {
93 write!(f, "durable trace write failed: {msg}")
94 }
95 }
96 }
97}
98
99impl std::error::Error for RouterError {}
100
101/// Result of a successful [`PlacementRouter::execute`] call.
102#[derive(Debug)]
103pub struct RouterResponse {
104 /// The JSON output produced by the executor.
105 pub output: Value,
106 /// Events the executor emitted and validated during this call (spec
107 /// 098-capability-event-host-abi), already published to `EventBroker`
108 /// by Step 5 for `Subscribable` capabilities.
109 pub emitted_events: Vec<TraverseEvent>,
110 /// The public trace entry written to the store.
111 pub trace_id: String,
112 /// The placement decision that was made.
113 pub placement_decision: PlacementDecision,
114}
115
116// ---------------------------------------------------------------------------
117// PlacementRouter
118// ---------------------------------------------------------------------------
119
120/// Single orchestrating entry point for all capability execution in Traverse.
121///
122/// Wires together placement evaluation → executor selection → execution →
123/// trace recording → event publishing.
124pub struct PlacementRouter {
125 evaluator: PlacementConstraintEvaluator,
126 executor_registry: CapabilityExecutorRegistry,
127 trace_store: Arc<Mutex<TraceStore>>,
128 event_broker: Arc<dyn EventBroker>,
129 durable_trace: Option<DurableTraceConfig>,
130}
131
132impl PlacementRouter {
133 /// Construct a new [`PlacementRouter`] from injected dependencies. Traces
134 /// are recorded to `trace_store` only (in-memory, process-local) unless
135 /// [`Self::with_durable_trace`] is also called.
136 #[must_use]
137 pub fn new(
138 evaluator: PlacementConstraintEvaluator,
139 executor_registry: CapabilityExecutorRegistry,
140 trace_store: Arc<Mutex<TraceStore>>,
141 event_broker: Arc<dyn EventBroker>,
142 ) -> Self {
143 Self {
144 evaluator,
145 executor_registry,
146 trace_store,
147 event_broker,
148 durable_trace: None,
149 }
150 }
151
152 /// Additionally persist every recorded trace through a durable trace
153 /// journal (spec `079-durable-trace-journal`). Without this, traces are
154 /// recorded to `trace_store` only and are lost on restart.
155 #[must_use]
156 pub fn with_durable_trace(mut self, durable_trace: DurableTraceConfig) -> Self {
157 self.durable_trace = Some(durable_trace);
158 self
159 }
160
161 /// Execute a capability end-to-end.
162 ///
163 /// Steps:
164 /// 1. Evaluate placement constraints — returns [`RouterError::PlacementFailed`] with no trace on failure.
165 /// 2. Select executor by `artifact_type`.
166 /// 3. Run the executor.
167 /// 4. Write public + private trace entries to the store.
168 /// 5. If `service_type == Subscribable`, publish emitted events.
169 ///
170 /// # Errors
171 ///
172 /// Returns [`RouterError`] when any step cannot complete.
173 pub fn execute(&self, request: RouterRequest) -> Result<RouterResponse, RouterError> {
174 let executor = self
175 .executor_registry
176 .get(&request.artifact_type)
177 .ok_or_else(|| RouterError::ExecutorNotFound(format!("{:?}", request.artifact_type)))?;
178 self.execute_with_executor(request, executor.as_ref())
179 }
180
181 /// Execute a capability with an explicitly provided executor.
182 ///
183 /// Used by the live `Runtime::execute` path to bridge a host
184 /// `LocalExecutor` without requiring a `'static` registry entry.
185 ///
186 /// # Errors
187 ///
188 /// Returns [`RouterError`] when any step cannot complete.
189 pub fn execute_with_executor(
190 &self,
191 request: RouterRequest,
192 executor: &dyn CapabilityExecutor,
193 ) -> Result<RouterResponse, RouterError> {
194 // --- Step 1: Placement evaluation ---
195 let placement_req = PlacementRequest {
196 capability_id: request.capability_id.clone(),
197 target_hint: request.target_hint,
198 runtime_snapshot: request.runtime_snapshot,
199 };
200
201 let decision = self
202 .evaluator
203 .evaluate(&placement_req, &request.contract)
204 .map_err(RouterError::PlacementFailed)?;
205
206 let placement_target_str = format!("{:?}", decision.target);
207
208 // --- Step 3: Execute capability ---
209 // Events emitted via `traverse_host::emit_event` (spec
210 // 098-capability-event-host-abi) are already validated
211 // synchronously, at call time, against `request.contract.emits` and
212 // `service_type` by the host function itself (FR-002/FR-003) — no
213 // post-hoc enforcement gate is needed here.
214 let start = Instant::now();
215 let exec_result = executor.execute(&request.executor_capability, &request.input);
216 let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
217
218 let (output, emitted_events, outcome) = match exec_result {
219 Ok(exec_output) => (
220 exec_output.value,
221 exec_output.emitted_events,
222 TraceOutcome::Success,
223 ),
224 Err(e) => return Err(RouterError::ExecutionFailed(format!("{e}"))),
225 };
226
227 // --- Step 4: Write trace ---
228 let (trace_id, time) = match request.trace_id_override {
229 Some(override_id) => (override_id, Utc::now().to_rfc3339()),
230 None => new_trace_id_and_time(),
231 };
232
233 let public_entry = PublicTraceEntry::new(
234 trace_id.clone(),
235 request.capability_id.clone(),
236 placement_target_str,
237 outcome,
238 duration_ms,
239 time,
240 );
241
242 let input_str = serde_json::to_string(&request.input).unwrap_or_default();
243 let output_str = serde_json::to_string(&output).unwrap_or_default();
244 let private_entry =
245 PrivateTraceEntry::new(trace_id.clone(), &input_str, &output_str, duration_ms);
246
247 // Durable write gates the in-memory record when the caller is
248 // configured to fail closed (spec 079 FR-002): a trace that could
249 // not be durably written is not silently kept in-memory-only for an
250 // auditable execution.
251 if let Some(durable) = &self.durable_trace
252 && let Err(error) = durable.sink.record(&public_entry, Some(&private_entry))
253 && durable.fail_closed
254 {
255 return Err(RouterError::DurableTraceWriteFailed(error.to_string()));
256 }
257
258 {
259 let mut store = self
260 .trace_store
261 .lock()
262 .map_err(|_| RouterError::TraceLockPoisoned)?;
263 store.insert(public_entry, Some(private_entry));
264 }
265
266 // --- Step 5: Publish events for Subscribable capabilities ---
267 let published_events = if request.contract.service_type == ServiceType::Subscribable {
268 for event in &emitted_events {
269 // Best-effort: publish errors are logged but do not fail the response.
270 let _ = self.event_broker.publish(event.clone());
271 }
272 emitted_events
273 } else {
274 Vec::new()
275 };
276
277 Ok(RouterResponse {
278 output,
279 emitted_events: published_events,
280 trace_id,
281 placement_decision: decision,
282 })
283 }
284}