Skip to main content

deepstrike_sdk/runtime/
canonical_kernel_step.rs

1//! Durable Rust host for the canonical kernel ABI.
2//!
3//! Core prepares the typed envelope and record; the journal makes that record authoritative; only
4//! then is the planned step committed and exposed to a runner. This module exposes no alternate
5//! input vocabulary or direct-step escape hatch.
6
7use std::sync::{Arc, Mutex};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde_json::Value;
11
12#[cfg(test)]
13use serde_json::json;
14
15use crate::runtime::canonical_kernel::{
16    CanonicalCommit, CanonicalKernel, CanonicalPreparation, InputId, KernelFault, KernelFaultCode,
17    KernelInput, OperationId, PlannedStep, WireEnvelope, WireU64,
18};
19use crate::runtime::kernel_journal::{
20    CheckpointCandidate, InstalledCheckpoint, JournalError, JournalRecordInput, KernelJournal,
21};
22use crate::{Error, Result};
23
24// A moving journal may require several restore/retry passes, but a permanently contended host
25// must fail closed instead of growing the async call stack indefinitely.
26const MAX_TRANSITION_RECONCILIATIONS: usize = 8;
27
28#[derive(Debug, thiserror::Error)]
29pub enum HostTransitionError {
30    #[error("canonical kernel rejected input: {0}")]
31    Rejected(KernelFault),
32    #[error(
33        "canonical record is durable but commit could not be published; runtime rebuilt from journal"
34    )]
35    RebuildRequired,
36    #[error(transparent)]
37    Journal(#[from] JournalError),
38    #[error(transparent)]
39    Other(#[from] Error),
40}
41
42impl From<HostTransitionError> for Error {
43    fn from(value: HostTransitionError) -> Self {
44        match value {
45            HostTransitionError::Rejected(_) | HostTransitionError::RebuildRequired => {
46                Self::Other(value.to_string())
47            }
48            HostTransitionError::Journal(err) => Self::from(err),
49            HostTransitionError::Other(err) => err,
50        }
51    }
52}
53
54#[derive(Debug, Clone)]
55pub struct CanonicalTransition {
56    pub envelope: WireEnvelope,
57    pub step_seq: u64,
58    pub record_digest: String,
59    pub planned_step: PlannedStep,
60    pub checkpoint_advised: bool,
61    pub replayed: bool,
62}
63
64/// The typed prepare → append → commit host boundary for one operation.
65pub struct CanonicalKernelHost {
66    kernel: Mutex<CanonicalKernel>,
67    journal: Arc<dyn KernelJournal>,
68    operation_id: String,
69}
70
71impl CanonicalKernelHost {
72    pub fn new(
73        kernel: CanonicalKernel,
74        journal: Arc<dyn KernelJournal>,
75        operation_id: impl Into<String>,
76    ) -> Result<Self> {
77        let operation_id = operation_id.into();
78        if operation_id.is_empty() {
79            return Err(Error::Other(
80                "canonical kernel operation_id must not be empty".into(),
81            ));
82        }
83        Ok(Self {
84            kernel: Mutex::new(kernel),
85            journal,
86            operation_id,
87        })
88    }
89
90    pub fn operation_id(&self) -> &str {
91        &self.operation_id
92    }
93
94    pub fn journal(&self) -> &Arc<dyn KernelJournal> {
95        &self.journal
96    }
97
98    pub fn lifecycle(&self) -> crate::runtime::canonical_kernel::OperationLifecycle {
99        self.kernel.lock().unwrap().lifecycle()
100    }
101
102    pub fn pending_effects(&self) -> Vec<crate::runtime::canonical_kernel::KernelEffect> {
103        self.kernel
104            .lock()
105            .unwrap()
106            .pending_effects()
107            .cloned()
108            .collect()
109    }
110
111    pub fn terminal(&self) -> Option<crate::runtime::canonical_kernel::KernelTerminal> {
112        self.kernel.lock().unwrap().terminal().cloned()
113    }
114
115    pub fn attempt_id(&self, task_id: &str) -> Option<String> {
116        self.kernel
117            .lock()
118            .unwrap()
119            .attempt_id(task_id)
120            .map(|attempt_id| attempt_id.as_str().to_string())
121    }
122
123    pub fn turn(&self) -> u32 {
124        self.kernel.lock().unwrap().turn()
125    }
126
127    pub fn recovery_content_bytes(&self) -> Option<usize> {
128        self.kernel.lock().unwrap().recovery_content_bytes()
129    }
130
131    pub fn preserved_refs(&self) -> Vec<String> {
132        self.kernel.lock().unwrap().preserved_refs()
133    }
134
135    pub fn count_tokens(&self, text: &str) -> Option<u32> {
136        self.kernel.lock().unwrap().count_tokens(text)
137    }
138
139    pub fn local_subagents_spawned(&self) -> usize {
140        self.kernel.lock().unwrap().local_subagents_spawned() as usize
141    }
142
143    pub fn new_messages(&self) -> Vec<deepstrike_core::types::message::Message> {
144        self.kernel.lock().unwrap().new_messages()
145    }
146
147    /// Stage the exact serialized typed envelope before attempting its durable append.
148    pub async fn transition(&self, envelope: WireEnvelope) -> Result<CanonicalTransition> {
149        if envelope.operation_id.as_str() != self.operation_id {
150            return Err(Error::Other(
151                "canonical envelope operation_id does not match host operation_id".into(),
152            ));
153        }
154        let staged = serde_json::to_string(&envelope).map_err(|error| {
155            Error::Other(format!("canonical envelope is not serializable: {error}"))
156        })?;
157        self.journal
158            .stage_outbound_envelope(&self.operation_id, &staged)
159            .await
160            .map_err(Error::from)?;
161        match self.transition_typed(envelope).await {
162            Ok(transition) => {
163                self.journal
164                    .clear_outbound_envelope(&self.operation_id)
165                    .await
166                    .map_err(Error::from)?;
167                Ok(transition)
168            }
169            Err(
170                error @ (HostTransitionError::Rejected(_) | HostTransitionError::RebuildRequired),
171            ) => {
172                self.journal
173                    .clear_outbound_envelope(&self.operation_id)
174                    .await
175                    .map_err(Error::from)?;
176                Err(error.into())
177            }
178            Err(error) => Err(error.into()),
179        }
180    }
181
182    /// Replays an append-window envelope byte-for-byte after a wake.
183    pub async fn drain_outbound_envelope(&self) -> Result<Option<CanonicalTransition>> {
184        let Some(staged) = self
185            .journal
186            .read_outbound_envelope(&self.operation_id)
187            .await
188            .map_err(Error::from)?
189        else {
190            return Ok(None);
191        };
192        let envelope: WireEnvelope = serde_json::from_str(&staged).map_err(|error| {
193            Error::Other(format!(
194                "staged canonical outbound envelope is malformed: {error}"
195            ))
196        })?;
197        match self.transition_typed(envelope).await {
198            Ok(transition) => {
199                self.journal
200                    .clear_outbound_envelope(&self.operation_id)
201                    .await
202                    .map_err(Error::from)?;
203                Ok(Some(transition))
204            }
205            Err(
206                error @ (HostTransitionError::Rejected(_) | HostTransitionError::RebuildRequired),
207            ) => {
208                self.journal
209                    .clear_outbound_envelope(&self.operation_id)
210                    .await
211                    .map_err(Error::from)?;
212                Err(error.into())
213            }
214            Err(error) => Err(error.into()),
215        }
216    }
217
218    /// Restore the typed kernel solely from the durable journal.
219    pub async fn restore(&self) -> Result<()> {
220        self.restore_typed().await.map_err(Error::from)
221    }
222
223    /// Execute the install → acknowledge → reclaim checkpoint boundary.
224    pub async fn checkpoint(&self) -> Result<InstalledCheckpoint> {
225        self.checkpoint_typed().await.map_err(Error::from)
226    }
227
228    async fn restore_typed(&self) -> std::result::Result<(), HostTransitionError> {
229        let checkpoint = self
230            .journal
231            .latest_checkpoint(&self.operation_id)
232            .await
233            .map_err(HostTransitionError::Journal)?;
234        let records = self
235            .journal
236            .records_after(
237                &self.operation_id,
238                checkpoint
239                    .as_ref()
240                    .map(|checkpoint| checkpoint.covered_head.as_str()),
241            )
242            .await
243            .map_err(HostTransitionError::Journal)?;
244        self.kernel
245            .lock()
246            .unwrap()
247            .restore_bytes(
248                checkpoint
249                    .as_ref()
250                    .map(|checkpoint| checkpoint.checkpoint_bytes.as_slice()),
251                &records
252                    .iter()
253                    .map(|record| record.record_bytes.clone())
254                    .collect::<Vec<_>>(),
255            )
256            .map_err(HostTransitionError::Rejected)?;
257        Ok(())
258    }
259
260    async fn checkpoint_typed(
261        &self,
262    ) -> std::result::Result<InstalledCheckpoint, HostTransitionError> {
263        let candidate = self
264            .kernel
265            .lock()
266            .unwrap()
267            .checkpoint_candidate()
268            .map_err(HostTransitionError::Rejected)?;
269        let previous = self
270            .journal
271            .latest_checkpoint(&self.operation_id)
272            .await
273            .map_err(HostTransitionError::Journal)?;
274        let checkpoint = CheckpointCandidate {
275            checkpoint_id: candidate.ack_token.as_str().to_string(),
276            through_step_seq: candidate.through_step_seq.get(),
277            state_digest: candidate.state_digest.as_str().to_string(),
278            checkpoint_bytes: candidate.checkpoint_bytes.as_slice().to_vec(),
279        };
280        let installed = match self
281            .journal
282            .compare_and_install_checkpoint(
283                &self.operation_id,
284                previous
285                    .as_ref()
286                    .map(|checkpoint| checkpoint.checkpoint_id.as_str()),
287                candidate.covered_head.as_str(),
288                checkpoint,
289            )
290            .await
291        {
292            Ok(installed) => installed,
293            Err(error @ JournalError::CasConflict(_)) => {
294                let winner = self
295                    .journal
296                    .latest_checkpoint(&self.operation_id)
297                    .await
298                    .map_err(HostTransitionError::Journal)?;
299                match winner {
300                    Some(winner) if winner.checkpoint_id == candidate.ack_token.as_str() => winner,
301                    _ => return Err(error.into()),
302                }
303            }
304            Err(error) => return Err(HostTransitionError::Journal(error)),
305        };
306        self.journal
307            .ack_checkpoint(&self.operation_id, &installed.checkpoint_id)
308            .await
309            .map_err(HostTransitionError::Journal)?;
310        self.kernel
311            .lock()
312            .unwrap()
313            .note_checkpoint_acked(&candidate.boundary())
314            .map_err(HostTransitionError::Rejected)?;
315        self.journal
316            .prune_acked_prefix(&self.operation_id)
317            .await
318            .map_err(HostTransitionError::Journal)?;
319        Ok(InstalledCheckpoint {
320            acknowledged: true,
321            ..installed
322        })
323    }
324
325    async fn transition_typed(
326        &self,
327        envelope: WireEnvelope,
328    ) -> std::result::Result<CanonicalTransition, HostTransitionError> {
329        let mut reconciliations = 0;
330        loop {
331            let preparation = self.kernel.lock().unwrap().prepare(&envelope);
332            match preparation {
333                CanonicalPreparation::Rejected(rejected)
334                    if rejected.fault.code == KernelFaultCode::CheckpointRequired =>
335                {
336                    if reconciliations >= MAX_TRANSITION_RECONCILIATIONS {
337                        return Err(HostTransitionError::Other(Error::Other(format!(
338                            "canonical transition still requires a checkpoint after {reconciliations} reconciliations: {}",
339                            rejected.fault.message
340                        ))));
341                    }
342                    reconciliations += 1;
343                    self.checkpoint_typed().await?;
344                }
345                CanonicalPreparation::Rejected(rejected) => {
346                    return Err(HostTransitionError::Rejected(rejected.fault));
347                }
348                CanonicalPreparation::Replayed(replayed) => {
349                    let planned_step = replayed.committed_step.ok_or_else(|| {
350                        HostTransitionError::Other(Error::Other(
351                            "canonical replay has no reproducible planned step".into(),
352                        ))
353                    })?;
354                    return Ok(CanonicalTransition {
355                        envelope,
356                        step_seq: replayed.step_seq.get(),
357                        record_digest: replayed.record_digest.as_str().to_string(),
358                        planned_step,
359                        checkpoint_advised: false,
360                        replayed: true,
361                    });
362                }
363                CanonicalPreparation::Prepared(prepared) => {
364                    let record = prepared.record.clone();
365                    let append = self
366                        .journal
367                        .compare_and_append(
368                            &self.operation_id,
369                            record
370                                .previous_record_digest()
371                                .map(|digest| digest.as_str()),
372                            JournalRecordInput {
373                                step_seq: record.step_seq().get(),
374                                record_digest: record.record_digest().as_str().to_string(),
375                                record_bytes: record.record_bytes().into_vec(),
376                            },
377                        )
378                        .await;
379                    let receipt = match append {
380                        Ok(receipt) => receipt,
381                        Err(error) => {
382                            self.kernel
383                                .lock()
384                                .unwrap()
385                                .abort(&prepared.token)
386                                .map_err(HostTransitionError::Rejected)?;
387                            if error.is_retryable() {
388                                if reconciliations >= MAX_TRANSITION_RECONCILIATIONS {
389                                    return Err(HostTransitionError::Journal(error));
390                                }
391                                reconciliations += 1;
392                                self.restore_typed().await?;
393                                continue;
394                            }
395                            return Err(HostTransitionError::Journal(error));
396                        }
397                    };
398                    let committed: CanonicalCommit = match self
399                        .kernel
400                        .lock()
401                        .unwrap()
402                        .commit(&prepared.token, record.record_digest())
403                    {
404                        Ok(committed) => committed,
405                        Err(_) => {
406                            self.restore_typed().await?;
407                            return Err(HostTransitionError::RebuildRequired);
408                        }
409                    };
410                    if committed.step_seq.get() != receipt.step_seq
411                        || committed.record.record_digest().as_str() != receipt.record_digest
412                    {
413                        self.restore_typed().await?;
414                        return Err(HostTransitionError::RebuildRequired);
415                    }
416                    let checkpoint_advised = committed.checkpoint_advice.is_some();
417                    let transition = CanonicalTransition {
418                        envelope,
419                        step_seq: committed.step_seq.get(),
420                        record_digest: committed.record.record_digest().as_str().to_string(),
421                        planned_step: committed.step,
422                        checkpoint_advised,
423                        replayed: false,
424                    };
425                    if checkpoint_advised {
426                        self.checkpoint_typed().await?;
427                    }
428                    return Ok(transition);
429                }
430            }
431        }
432    }
433
434    pub fn next_observed_at_ms() -> u64 {
435        SystemTime::now()
436            .duration_since(UNIX_EPOCH)
437            .unwrap_or_default()
438            .as_millis() as u64
439    }
440
441    /// Build a typed envelope from a JSON wire input object (the five-class taxonomy).
442    pub async fn transition_input(&self, input: Value) -> Result<CanonicalTransition> {
443        self.transition_input_correlated(
444            input,
445            format!("rust-input-{}", uuid::Uuid::new_v4()),
446            Self::next_observed_at_ms(),
447        )
448        .await
449    }
450
451    /// Build a typed envelope with caller-owned identity and observation time.
452    ///
453    /// Durable hosts use this entry point when the input already has a stable delivery identity.
454    /// Retrying the same input must reuse both values so core can distinguish replay from conflict.
455    pub async fn transition_input_correlated(
456        &self,
457        input: Value,
458        input_id: impl Into<String>,
459        observed_at_ms: u64,
460    ) -> Result<CanonicalTransition> {
461        let input: KernelInput = serde_json::from_value(input)
462            .map_err(|error| Error::Other(format!("canonical wire input is malformed: {error}")))?;
463        let operation_id = OperationId::new(self.operation_id.clone())
464            .map_err(|error| Error::Other(error.to_string()))?;
465        let input_id =
466            InputId::new(input_id.into()).map_err(|error| Error::Other(error.to_string()))?;
467        let envelope =
468            WireEnvelope::new(operation_id, input_id, WireU64::new(observed_at_ms), input);
469        self.transition(envelope).await
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[tokio::test]
478    async fn transitions_and_replays_a_typed_canonical_envelope() {
479        let fixture: serde_json::Value = serde_json::from_str(include_str!(
480            "../../../tests/fixtures/kernel-wire/golden_lifecycle_agent_root.json"
481        ))
482        .expect("fixture");
483        let envelope: WireEnvelope =
484            serde_json::from_value(fixture["links"][0]["envelope"].clone()).expect("envelope");
485        let journal: Arc<dyn KernelJournal> =
486            Arc::new(crate::runtime::kernel_journal::InMemoryKernelJournal::new());
487        let host = CanonicalKernelHost::new(
488            CanonicalKernel::default(),
489            journal,
490            envelope.operation_id.as_str(),
491        )
492        .expect("host");
493
494        let first = host.transition(envelope.clone()).await.expect("transition");
495        assert!(!first.replayed);
496        assert_eq!(first.step_seq, 0);
497        assert!(host.pending_effects().is_empty());
498
499        let replay = host.transition(envelope).await.expect("replay");
500        assert!(replay.replayed);
501        assert_eq!(replay.record_digest, first.record_digest);
502    }
503
504    #[tokio::test]
505    async fn correlated_input_preserves_caller_identity_and_clock() {
506        let journal: Arc<dyn KernelJournal> =
507            Arc::new(crate::runtime::kernel_journal::InMemoryKernelJournal::new());
508        let host =
509            CanonicalKernelHost::new(CanonicalKernel::default(), journal, "op-correlated-input")
510                .expect("host");
511
512        let transition = host
513            .transition_input_correlated(
514                json!({
515                    "kind": "configure_operation",
516                    "config": {
517                        "host_effect_support": { "supported": ["call_provider"] }
518                    }
519                }),
520                "delivery-42",
521                1_700_000_000_123,
522            )
523            .await
524            .expect("transition");
525
526        assert_eq!(transition.envelope.input_id.as_str(), "delivery-42");
527        assert_eq!(transition.envelope.observed_at_ms.get(), 1_700_000_000_123);
528    }
529}