Skip to main content

meerkat_runtime/
traits.rs

1//! §23 Runtime traits — RuntimeDriver and RuntimeControlPlane.
2//!
3//! These define the interface between surfaces and the runtime control-plane.
4
5use meerkat_core::lifecycle::{InputId, RunId};
6use serde::{Deserialize, Serialize};
7
8use crate::accept::AcceptOutcome;
9use crate::identifiers::LogicalRuntimeId;
10use crate::input::Input;
11use crate::input_state::{InputLifecycleState, InputState, StoredInputState};
12use crate::runtime_event::RuntimeEventEnvelope;
13use crate::runtime_state::RuntimeState;
14
15/// Errors from RuntimeDriver operations.
16#[derive(Debug, Clone, thiserror::Error)]
17#[non_exhaustive]
18pub enum RuntimeDriverError {
19    /// The runtime is not in a state that can accept this operation.
20    #[error("Runtime not ready: {state}")]
21    NotReady { state: RuntimeState },
22
23    /// The runtime was never registered / does not exist.
24    ///
25    /// Distinct from [`RuntimeDriverError::Destroyed`] and
26    /// [`RuntimeDriverError::NotReady`] with a `Destroyed` state: absence means
27    /// the runtime id was never admitted, not that it once existed and was torn
28    /// down.
29    #[error("Runtime not found: {runtime_id}")]
30    NotFound { runtime_id: LogicalRuntimeId },
31
32    /// Input validation failed.
33    #[error("Input validation failed: {reason}")]
34    ValidationFailed { reason: String },
35
36    /// The runtime has been destroyed.
37    #[error("Runtime destroyed")]
38    Destroyed,
39
40    /// Durable recovery state could not be replayed through canonical runtime authority.
41    #[error("Recovery corruption: {reason}")]
42    RecoveryCorruption { reason: String },
43
44    /// Fresh lifecycle observation or actuation is temporarily unavailable.
45    #[error("Runtime recovery should back off: {reason}")]
46    RecoveryBackoff { reason: String },
47
48    /// The exact durable row cannot be safely normalized automatically.
49    #[error("Runtime recovery is repair-blocked: {reason}")]
50    RecoveryRepairBlocked {
51        evidence_digest: Option<String>,
52        reason: String,
53    },
54
55    /// Atomic unregister persistence may already be durable. The live retry
56    /// anchor is retained, but no compensating durable rollback may run.
57    #[error("Unregister finalization outcome is unknown: {reason}")]
58    UnregisterFinalizationOutcomeUnknown { reason: String },
59
60    /// The machine-owned unregister saga still owns this runtime epoch and is
61    /// continuing asynchronously. The caller may retry to join the same saga;
62    /// this is not permission to replace or abandon the owned executor.
63    #[error("Unregister teardown is still in progress for runtime {runtime_id}")]
64    UnregisterInProgress { runtime_id: LogicalRuntimeId },
65
66    /// The machine-owned ordinary-stop cleanup coordinator still owns this
67    /// runtime epoch. The caller may retry to join the same coordinator; this
68    /// is not permission to unregister, replace, or abandon the exact executor.
69    #[error("Runtime stop cleanup is still in progress for runtime {runtime_id}")]
70    RuntimeStopInProgress { runtime_id: LogicalRuntimeId },
71
72    /// The caller's exact durable ownership witness was superseded by another
73    /// runtime owner. Retrying from the same in-memory state is forbidden.
74    #[error("Stale runtime authority: {reason}")]
75    StaleAuthority { reason: String },
76
77    /// Internal error.
78    #[error("Internal error: {0}")]
79    Internal(String),
80}
81
82/// Errors from RuntimeControlPlane operations.
83#[derive(Debug, Clone, thiserror::Error)]
84#[non_exhaustive]
85pub enum RuntimeControlPlaneError {
86    /// Runtime not found.
87    #[error("Runtime not found: {0}")]
88    NotFound(LogicalRuntimeId),
89
90    /// Invalid state for this operation.
91    #[error("Invalid state for operation: {state}")]
92    InvalidState { state: RuntimeState },
93
94    /// Store error.
95    #[error("Store error: {0}")]
96    StoreError(String),
97
98    /// Internal error.
99    #[error("Internal error: {0}")]
100    Internal(String),
101}
102
103/// Report from a recovery operation.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct RecoveryReport {
106    /// How many inputs were recovered.
107    pub inputs_recovered: usize,
108    /// How many inputs were abandoned during recovery.
109    pub inputs_abandoned: usize,
110    /// How many inputs were re-queued.
111    pub inputs_requeued: usize,
112    /// Details of recovery actions.
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub details: Vec<String>,
115}
116
117/// Report from a retire operation.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct RetireReport {
120    /// How many non-terminal inputs were abandoned.
121    pub inputs_abandoned: usize,
122    /// How many inputs are pending drain (will be processed before stopping).
123    #[serde(default)]
124    pub inputs_pending_drain: usize,
125}
126
127/// Report from a reset operation.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ResetReport {
130    /// How many non-terminal inputs were abandoned.
131    pub inputs_abandoned: usize,
132}
133
134/// Report from a recycle operation (reset driver and recover state).
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct RecycleReport {
137    /// How many inputs were transferred to the new instance.
138    pub inputs_transferred: usize,
139}
140
141/// Report from a destroy operation.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct DestroyReport {
144    /// How many non-terminal inputs were abandoned.
145    pub inputs_abandoned: usize,
146}
147
148/// The runtime driver — per-session interface for input acceptance and lifecycle.
149///
150/// Each session gets its own RuntimeDriver instance. The driver manages the
151/// InputState ledger, policy resolution, and input queue for that session.
152#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
153#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
154pub trait RuntimeDriver: Send + Sync {
155    /// Accept an input into the runtime.
156    async fn accept_input(&mut self, input: Input) -> Result<AcceptOutcome, RuntimeDriverError>;
157
158    /// Handle a runtime event (from the event bus).
159    async fn on_runtime_event(
160        &mut self,
161        event: RuntimeEventEnvelope,
162    ) -> Result<(), RuntimeDriverError>;
163
164    /// Recover from a crash/restart.
165    async fn recover(&mut self) -> Result<RecoveryReport, RuntimeDriverError>;
166
167    /// Get the current runtime state.
168    fn runtime_state(&self) -> RuntimeState;
169
170    /// Get the state of a specific input.
171    fn input_state(&self, input_id: &InputId) -> Option<&InputState>;
172
173    /// Get the current DSL-owned lifecycle phase of a specific input.
174    fn input_phase(&self, input_id: &InputId) -> Option<InputLifecycleState>;
175
176    /// Get the current DSL-owned last run association for a specific input.
177    fn input_last_run_id(&self, input_id: &InputId) -> Option<RunId>;
178
179    /// Get the current DSL-owned last boundary sequence for a specific input.
180    fn input_last_boundary_sequence(&self, input_id: &InputId) -> Option<u64>;
181
182    /// Get the persisted shell+seed bundle for a specific input.
183    fn stored_input_state(&self, input_id: &InputId) -> Option<StoredInputState>;
184
185    /// Snapshot of every ledger entry paired with its DSL-owned seed.
186    ///
187    /// The live-runtime witness set for terminal-status evaluation: the same
188    /// facts a persistent store commits at every lifecycle boundary, read
189    /// from the DSL authority instead of disk.
190    fn stored_input_states_snapshot(&self) -> Result<Vec<StoredInputState>, RuntimeDriverError>;
191
192    /// Resolve the machine-owned idempotency-key binding to its input id.
193    ///
194    /// Read-only reconciliation mirror of the generated admission map — it
195    /// decides nothing and never registers a binding (the accept-path
196    /// admission resolution stays the only mutator).
197    fn input_id_for_idempotency_key(&self, idempotency_key: &str) -> Option<InputId>;
198
199    /// List all non-terminal input IDs.
200    fn active_input_ids(&self) -> Vec<InputId>;
201}
202
203/// The runtime control plane — manages multiple runtime instances.
204#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
205#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
206pub trait RuntimeControlPlane: Send + Sync {
207    /// Ingest an input into a specific runtime.
208    async fn ingest(
209        &self,
210        runtime_id: &LogicalRuntimeId,
211        input: Input,
212    ) -> Result<AcceptOutcome, RuntimeControlPlaneError>;
213
214    /// Publish an event to the logical runtime's current incarnation.
215    ///
216    /// This command is session-scoped rather than attachment-originated: the
217    /// current session mutation gate is its linearization point, and the DSL
218    /// transition plus driver callback target that same guarded entry. An
219    /// attachment-originated producer that requires stale-incarnation fencing
220    /// needs an exact-identity API rather than inferring it from this envelope.
221    async fn publish_event(
222        &self,
223        event: RuntimeEventEnvelope,
224    ) -> Result<(), RuntimeControlPlaneError>;
225
226    /// Retire a runtime (no new input, drain existing).
227    async fn retire(
228        &self,
229        runtime_id: &LogicalRuntimeId,
230    ) -> Result<RetireReport, RuntimeControlPlaneError>;
231
232    /// Recycle a runtime (reset driver and recover state).
233    async fn recycle(
234        &self,
235        runtime_id: &LogicalRuntimeId,
236    ) -> Result<RecycleReport, RuntimeControlPlaneError>;
237
238    /// Reset a runtime (abandon all pending input).
239    async fn reset(
240        &self,
241        runtime_id: &LogicalRuntimeId,
242    ) -> Result<ResetReport, RuntimeControlPlaneError>;
243
244    /// Recover a runtime from crash.
245    async fn recover(
246        &self,
247        runtime_id: &LogicalRuntimeId,
248    ) -> Result<RecoveryReport, RuntimeControlPlaneError>;
249
250    /// Get the state of a runtime.
251    async fn runtime_state(
252        &self,
253        runtime_id: &LogicalRuntimeId,
254    ) -> Result<RuntimeState, RuntimeControlPlaneError>;
255
256    /// Destroy a runtime (terminal state, no recovery possible).
257    async fn destroy(
258        &self,
259        runtime_id: &LogicalRuntimeId,
260    ) -> Result<DestroyReport, RuntimeControlPlaneError>;
261
262    /// Load a boundary receipt for verification.
263    async fn load_boundary_receipt(
264        &self,
265        runtime_id: &LogicalRuntimeId,
266        run_id: &RunId,
267        sequence: u64,
268    ) -> Result<Option<meerkat_core::lifecycle::RunBoundaryReceipt>, RuntimeControlPlaneError>;
269}
270
271#[cfg(test)]
272#[allow(clippy::unwrap_used)]
273mod tests {
274    use super::*;
275
276    // Verify traits are object-safe
277    fn _assert_driver_object_safe(_: &dyn RuntimeDriver) {}
278    fn _assert_control_plane_object_safe(_: &dyn RuntimeControlPlane) {}
279
280    #[test]
281    fn runtime_driver_error_display() {
282        let err = RuntimeDriverError::NotReady {
283            state: RuntimeState::Initializing,
284        };
285        assert!(err.to_string().contains("initializing"));
286
287        let err = RuntimeDriverError::ValidationFailed {
288            reason: "bad input".into(),
289        };
290        assert!(err.to_string().contains("bad input"));
291    }
292
293    #[test]
294    fn runtime_control_plane_error_display() {
295        let err = RuntimeControlPlaneError::NotFound(LogicalRuntimeId::new("missing"));
296        assert!(err.to_string().contains("missing"));
297    }
298
299    #[test]
300    fn recovery_report_serde() {
301        let report = RecoveryReport {
302            inputs_recovered: 5,
303            inputs_abandoned: 1,
304            inputs_requeued: 3,
305            details: vec!["requeued 3 staged inputs".into()],
306        };
307        let json = serde_json::to_value(&report).unwrap();
308        let parsed: RecoveryReport = serde_json::from_value(json).unwrap();
309        assert_eq!(parsed.inputs_recovered, 5);
310    }
311}