Skip to main content

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}