Skip to main content

rite_runtime/
runner.rs

1//! Channel-driven ceremony executor built around the protocol vocabulary.
2//!
3//! Frontends (TUI, console, headless) interact with the executor
4//! exclusively through [`UiCommand`] and [`ExecEvent`]; the transcript is
5//! recorded inline as facts are emitted.
6//!
7//! # Architecture
8//!
9//! ```text
10//!         ┌──────────────────────────┐
11//!         │      Frontend thread     │
12//!         │  (TUI / console / etc.)  │
13//!         └──────────────────────────┘
14//!           ▲                   │
15//!  ExecEvent│                   │UiCommand
16//!           │                   ▼
17//!         ┌──────────────────────────┐
18//!         │      Executor thread     │
19//!         │  ┌────────────────────┐  │
20//!         │  │      Reporter      │──┼─→ TranscriptSink (inline)
21//!         │  └────────────────────┘  │
22//!         │      ▲                   │
23//!         │      │ reporter.fact()   │
24//!         │  ┌────────────────────┐  │
25//!         │  │   Action handlers  │  │
26//!         │  └────────────────────┘  │
27//!         └──────────────────────────┘
28//! ```
29//!
30//! The executor owns the [`TranscriptSink`] for the duration of the run and
31//! finalizes it before returning. Action handlers receive a `&mut Reporter`
32//! and emit transcript-worthy facts through it.
33
34use std::collections::HashMap;
35use std::sync::Arc;
36
37use crossbeam_channel::{Receiver, Sender};
38use rand::TryRng;
39use rand::rngs::SysRng;
40use rite_model::{ActId, ActionType, ArtifactId, Ceremony, MaterialId, OutputId, ParamId, RoleId};
41use rite_sdk::{Backend, BackendError};
42use thiserror::Error;
43
44use crate::actions::ActionMetadata;
45use crate::backend::BackendRegistry;
46use crate::clock::{Clock, SystemClock};
47use crate::entropy::DERIVATION_V1;
48use crate::executor::{
49    ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk,
50};
51use crate::expressions;
52use crate::output_config::OutputConfig;
53use crate::protocol::{ExecEvent, Icon, MaterialOverview, UiCommand, UiSignal};
54use crate::reporter::{Reporter, ReporterError};
55use crate::state::{ExecutionState, HandlerContext, StepResult};
56use crate::step_info::StepInfo;
57use crate::system_info::StartupSnapshot;
58use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink};
59use rite_model::{ErrorRecord, Prompt, StepFact, StepOutcome};
60
61/// Gather the machine entropy `m` that seeds the ceremony entropy source.
62///
63/// Sourced directly from the host OS RNG ([`SysRng`]), independent of any
64/// ceremony backend, so a device the ceremony later challenges cannot
65/// influence its own challenge nonce. A dry run instead returns a fixed,
66/// clearly-labelled sentinel so a re-derived value can never be mistaken for
67/// one produced under real entropy.
68fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> {
69    let mut m = [0u8; 32];
70    if dry_run {
71        for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") {
72            *slot = *byte;
73        }
74        return Ok((m, "dry-run".to_string()));
75    }
76    SysRng
77        .try_fill_bytes(&mut m)
78        .map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
79    Ok((m, "os".to_string()))
80}
81
82/// Errors that may surface from an [`Action`] handler.
83#[derive(Debug, Error)]
84pub enum ActionError {
85    /// Operator requested abort. Propagated from [`Reporter::check_abort`]
86    /// or [`Reporter::prompt`].
87    #[error("ceremony aborted by operator")]
88    Aborted,
89    /// Frontend channel disconnected. The executor unwinds to a failure.
90    #[error("frontend channel disconnected")]
91    Disconnected,
92    /// Transcript sink failed to persist a fact.
93    #[error("transcript write failed: {0}")]
94    Transcript(std::io::Error),
95    /// Underlying backend returned an error.
96    #[error("backend error: {0}")]
97    Backend(#[from] BackendError),
98    /// Catch-all for handler-specific failures.
99    #[error("{0}")]
100    Failed(String),
101}
102
103impl From<ReporterError> for ActionError {
104    fn from(value: ReporterError) -> Self {
105        match value {
106            ReporterError::Aborted => ActionError::Aborted,
107            ReporterError::Disconnected => ActionError::Disconnected,
108            ReporterError::Transcript(e) => ActionError::Transcript(e),
109            ReporterError::NoCurrentStep(_)
110            | ReporterError::DuplicateDraw { .. }
111            | ReporterError::Unseeded
112            | ReporterError::DrawTooLong { .. } => ActionError::Failed(value.to_string()),
113        }
114    }
115}
116
117/// Deserialize the per-step parameter blob into a typed handler-specific struct.
118///
119/// Wraps `serde_json::from_value` and converts the error into
120/// [`ActionError::Failed`] with a consistent "invalid params: …" wording.
121///
122/// # Errors
123///
124/// Returns [`ActionError::Failed`] if `params` cannot be deserialized into `P`.
125pub fn parse_params<P: serde::de::DeserializeOwned>(
126    params: &serde_json::Value,
127) -> Result<P, ActionError> {
128    serde_json::from_value(params.clone())
129        .map_err(|e| ActionError::Failed(format!("invalid params: {e}")))
130}
131
132impl From<ReporterError> for ExecutionError {
133    fn from(value: ReporterError) -> Self {
134        match value {
135            ReporterError::Aborted => ExecutionError::StepAborted(rite_model::StepId::new("")),
136            ReporterError::Disconnected => {
137                ExecutionError::TranscriptError("frontend channel disconnected".to_string())
138            }
139            ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
140            ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()),
141            ReporterError::DuplicateDraw { .. }
142            | ReporterError::Unseeded
143            | ReporterError::DrawTooLong { .. } => ExecutionError::EntropyError(value.to_string()),
144        }
145    }
146}
147
148/// Action trait, every ceremony step handler implements this.
149///
150/// Handlers emit facts and prompts through [`Reporter`] and return a
151/// [`StepResult`]. Transcript evidence is whatever facts the handler
152/// emits via [`Reporter::fact`] during execution; there is no separate
153/// evidence struct.
154pub trait Action: Send + Sync {
155    /// Metadata describing this action (type, category, description).
156    fn metadata(&self) -> ActionMetadata;
157
158    /// Apply per-step parameter defaults before validation.
159    fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
160
161    /// Execute the action.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`ActionError`] when the handler cannot complete, when the
166    /// operator aborts, or when the frontend disappears.
167    fn execute(
168        &self,
169        step: &StepInfo,
170        ctx: &HandlerContext,
171        params: &serde_json::Value,
172        reporter: &mut Reporter<'_>,
173        backend: Option<&mut dyn Backend>,
174    ) -> Result<StepResult, ActionError>;
175}
176
177/// Registry mapping action types to their handlers.
178#[derive(Default)]
179pub struct ActionRegistry {
180    actions: HashMap<ActionType, Arc<dyn Action>>,
181}
182
183impl ActionRegistry {
184    /// Create an empty registry.
185    #[must_use]
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    /// Register an action handler.
191    pub fn register(&mut self, action: Arc<dyn Action>) {
192        let action_type = action.metadata().action_type;
193        self.actions.insert(action_type, action);
194    }
195
196    /// Look up the handler for an action type.
197    #[must_use]
198    pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
199        self.actions.get(action)
200    }
201
202    /// Iterate over registered action types.
203    pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
204        self.actions.keys()
205    }
206
207    /// Return action types used in the execution plan that have no
208    /// registered handler. Results are deduplicated and returned in
209    /// first-occurrence order.
210    #[must_use]
211    pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
212        let mut seen = std::collections::HashSet::new();
213        let mut result = Vec::new();
214        for step in steps {
215            if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
216                result.push(step.action);
217            }
218        }
219        result
220    }
221}
222
223/// Summary returned by [`Executor::run`] on a successful completion.
224#[derive(Debug)]
225pub struct ExecutionSummary {
226    /// Ceremony name from the DSL.
227    pub ceremony_name: String,
228    /// Number of steps that completed successfully.
229    pub steps_completed: usize,
230    /// Final transcript fingerprint.
231    pub transcript_fingerprint: TranscriptFingerprint,
232}
233
234/// Channel-driven executor. One per ceremony run.
235pub struct Executor {
236    ceremony: Ceremony,
237    registry: ActionRegistry,
238    backend_registry: BackendRegistry,
239    output_config: OutputConfig,
240    dry_run: bool,
241    startup: StartupSnapshot,
242    clock: Arc<dyn Clock>,
243}
244
245impl Executor {
246    /// Build an executor for a single ceremony run.
247    ///
248    /// `startup` carries the system identity and device environment the CLI
249    /// gathered at launch; the executor echoes them to the frontend as UI
250    /// signals at ceremony start and does not otherwise inspect them.
251    #[must_use]
252    pub fn new(
253        ceremony: Ceremony,
254        registry: ActionRegistry,
255        backend_registry: BackendRegistry,
256        output_config: OutputConfig,
257        dry_run: bool,
258        startup: StartupSnapshot,
259    ) -> Self {
260        Self {
261            ceremony,
262            registry,
263            backend_registry,
264            output_config,
265            dry_run,
266            startup,
267            clock: Arc::new(SystemClock),
268        }
269    }
270
271    /// Override the run clock. The default is [`SystemClock`]; tests inject a
272    /// fixed or stepping clock to make recorded event times deterministic.
273    #[must_use]
274    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
275        self.clock = clock;
276        self
277    }
278
279    /// Drive the ceremony to completion.
280    ///
281    /// Blocking. Intended to be invoked on a dedicated executor thread
282    /// spawned by the frontend. The `transcript_sink` is finalized before
283    /// this function returns, and its fingerprint is included in the
284    /// returned summary.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`ExecutionError`] for any condition that prevents the
289    /// ceremony from completing, validation failures, missing actions,
290    /// abort, backend errors, or transcript I/O failures.
291    pub fn run(
292        self,
293        cmd_rx: &Receiver<UiCommand>,
294        event_tx: &Sender<ExecEvent>,
295        mut transcript_sink: Box<dyn TranscriptSink>,
296    ) -> Result<ExecutionSummary, ExecutionError> {
297        validate_parameters(&self.ceremony)?;
298
299        let resolved_params: HashMap<ParamId, serde_json::Value> = self
300            .ceremony
301            .parameters
302            .iter()
303            .map(|(id, p)| (id.clone(), p.value.clone()))
304            .collect();
305
306        let roles: HashMap<RoleId, String> = self
307            .ceremony
308            .roles
309            .iter()
310            .map(|(id, r)| (id.clone(), r.name.clone()))
311            .collect();
312
313        let materials_map: HashMap<MaterialId, String> = self
314            .ceremony
315            .materials
316            .iter()
317            .map(|(id, m)| (id.clone(), m.display_name().to_string()))
318            .collect();
319
320        let ceremony_name = self.ceremony.metadata.name.clone();
321        // Keep a clock handle for the terminal facts below: `execute_inner`
322        // consumes `self`, so the reporter and the runner share this clone.
323        let clock = Arc::clone(&self.clock);
324        let mut reporter = Reporter::new(
325            event_tx,
326            cmd_rx,
327            transcript_sink.as_mut(),
328            Arc::clone(&clock),
329        );
330
331        let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
332
333        // Record the terminal fact first so it ends up on disk. Then
334        // finalize the sink (which only returns the chain head, no
335        // sidecar is written) and forward both the fact and the
336        // fingerprint to the UI.
337        match outcome {
338            Ok(counts) => {
339                let at = clock.now();
340                let completed = StepFact::CeremonyCompleted {};
341                transcript_sink
342                    .record(at, &completed)
343                    .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
344                let _ = event_tx.send(ExecEvent::Fact {
345                    at,
346                    fact: completed,
347                });
348                let fingerprint = transcript_sink
349                    .finalize()
350                    .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
351                let _ = event_tx.send(ExecEvent::Finalized {
352                    fingerprint: fingerprint.as_str().to_string(),
353                });
354                Ok(ExecutionSummary {
355                    ceremony_name,
356                    steps_completed: counts.completed,
357                    transcript_fingerprint: fingerprint,
358                })
359            }
360            Err(err) => {
361                let at = clock.now();
362                let record = err.to_error_record();
363                let failed = StepFact::CeremonyFailed { error: record };
364                let _ = transcript_sink.record(at, &failed);
365                let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
366                if let Ok(fingerprint) = transcript_sink.finalize() {
367                    let _ = event_tx.send(ExecEvent::Finalized {
368                        fingerprint: fingerprint.as_str().to_string(),
369                    });
370                }
371                Err(err)
372            }
373        }
374    }
375
376    #[allow(clippy::too_many_lines)]
377    fn execute_inner(
378        mut self,
379        reporter: &mut Reporter<'_>,
380        resolved_params: HashMap<ParamId, serde_json::Value>,
381        roles: HashMap<RoleId, String>,
382        materials_map: HashMap<MaterialId, String>,
383    ) -> Result<StepCounts, ExecutionError> {
384        let dry_run = self.dry_run;
385
386        reporter.fact(StepFact::CeremonyStarted {
387            name: self.ceremony.metadata.name.clone(),
388        })?;
389
390        // Establish the ceremony entropy source before any step can draw from
391        // it. This machine seed is run-metadata (the runner-emitted exception
392        // to "facts come from actions"); human contributions, by contrast, are
393        // authored `gather_entropy` steps that fold into the ratchet later.
394        let (m, source) = gather_machine_entropy(dry_run)?;
395        reporter.seed_entropy(&m);
396        reporter.fact(StepFact::EntropySeeded {
397            m: base16ct::lower::encode_string(&m),
398            source,
399            derivation: DERIVATION_V1.to_string(),
400        })?;
401
402        // Pre-ceremony overview: descriptive metadata for the UI's
403        // Overview screen. Sent as a UI-only signal, not a transcript
404        // fact, because the YAML is the source of truth for these fields
405        // and the run record shouldn't duplicate them.
406        reporter.signal(UiSignal::CeremonyOverview {
407            description: self.ceremony.metadata.description.clone(),
408            materials: self
409                .ceremony
410                .materials
411                .iter()
412                .map(|(_, m)| MaterialOverview::from_material(m))
413                .collect(),
414            step_count: self.ceremony.execution_plan.len(),
415        })?;
416
417        // Machine identity and device environment for the System tab. Both
418        // UI-only: identity that belongs in the transcript is recorded by the
419        // `machine_info` action, not fed in here. `Environment` is emitted
420        // once today but shaped to be re-emitted by a future live observer.
421        reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
422        reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
423
424        // Ceremony-start gate: let the operator review the overview
425        // before any side effect (material loading, first step body). The
426        // prompt is emitted with no current step set, so it carries
427        // `step: None` through to the frontend.
428        if !dry_run {
429            reporter.prompt(&Prompt::Continue {
430                hint: Some("Press Enter to start the ceremony".to_string()),
431            })?;
432        }
433
434        let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
435
436        // Load materials. Pre-step logs are attributed to no specific step.
437        for (id, material) in self.ceremony.materials.iter() {
438            let artifact = load_material_artifact(id.as_str(), material)?;
439            state = state.with_material(ArtifactId::new(id.as_str()), artifact);
440            let display_name = material.display_name();
441            reporter.log(Icon::Checkmark, format!("Loaded material: {display_name}"))?;
442        }
443
444        let has_acts = !self.ceremony.acts.is_empty();
445        let mut current_act: Option<ActId> = None;
446        let mut counts = StepCounts::default();
447
448        // Move the plan out so we can iterate it while still borrowing
449        // other fields of `self.ceremony` (sections, acts, outputs).
450        let plan = std::mem::take(&mut self.ceremony.execution_plan);
451        for step in &plan {
452            // Act boundary
453            if has_acts {
454                let step_act = self
455                    .ceremony
456                    .sections
457                    .get(&step.section)
458                    .and_then(|s| s.act.clone());
459                if step_act != current_act {
460                    if let Some(act_id) = &step_act
461                        && let Some(act) = self.ceremony.acts.get(act_id)
462                    {
463                        reporter.fact(StepFact::ActStarted {
464                            id: act_id.clone(),
465                            label: act
466                                .name
467                                .clone()
468                                .unwrap_or_else(|| act_id.as_str().to_string()),
469                        })?;
470                    }
471                    current_act = step_act;
472                }
473            }
474
475            reporter.set_current_step(Some(step.id.clone()));
476            let role_id = step.role.clone().unwrap_or_else(|| RoleId::new(""));
477            let role_name = self
478                .ceremony
479                .roles
480                .get(&role_id)
481                .map_or_else(|| role_id.as_str().to_string(), |r| r.name.clone());
482            reporter.fact(StepFact::StepStarted {
483                id: step.id.clone(),
484                label: step.step_label.clone(),
485                role: role_id,
486                role_name,
487            })?;
488
489            // Pacing: gate the step body on operator acknowledgement. The
490            // prompt fires with the step header already visible, so the
491            // operator confirms "ready for step X" rather than "what just
492            // happened was fine". `silent` skips it for auto-advancing
493            // bookkeeping steps; `dry_run` skips it for non-interactive
494            // verification.
495            if !step.silent && !dry_run {
496                reporter.prompt(&Prompt::Continue {
497                    hint: Some(format!("Press Enter to start step {}", step.step_label)),
498                })?;
499            }
500
501            // Look up handler
502            let handler = self
503                .registry
504                .get(&step.action)
505                .ok_or(ExecutionError::UnknownAction(step.action))?
506                .clone();
507
508            let ctx = state.handler_context();
509            let step_info = step_info_from(step);
510            let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
511            handler.apply_defaults(&mut params, &step_info);
512
513            let backend = if let Some(name) = &step_info.backend {
514                Some(self.backend_registry.get_mut(name).map_err(|e| {
515                    ExecutionError::StepFailed {
516                        step: step_info.id.clone(),
517                        reason: e.to_string(),
518                    }
519                })?)
520            } else {
521                None
522            };
523
524            let result = handler
525                .execute(&step_info, &ctx, &params, reporter, backend)
526                .map_err(|err| match err {
527                    ActionError::Aborted => ExecutionError::StepAborted(step.id.clone()),
528                    ActionError::Disconnected => ExecutionError::StepFailed {
529                        step: step.id.clone(),
530                        reason: "frontend channel disconnected".to_string(),
531                    },
532                    ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
533                    ActionError::Backend(e) => ExecutionError::BackendError(e),
534                    ActionError::Failed(reason) => ExecutionError::StepFailed {
535                        step: step.id.clone(),
536                        reason,
537                    },
538                })?;
539
540            // StepCompleted
541            reporter.fact(StepFact::StepCompleted {
542                id: step.id.clone(),
543                outcome: result.outcome.clone(),
544            })?;
545
546            if let StepOutcome::Completed { .. } = &result.outcome {
547                counts.completed = counts.completed.saturating_add(1);
548            }
549
550            state = merge_step_result(state, result);
551
552            // Write output-bound artifacts. The DSL declares which artifact
553            // a step `creates`; we promote that to disk if it is also a
554            // ceremony-level output.
555            if let Some(artifact_id) = &step.creates
556                && !dry_run
557            {
558                let output_id = OutputId::new(artifact_id.as_str());
559                if self.ceremony.outputs.contains(&output_id) {
560                    std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
561                        ExecutionError::OutputWriteFailed {
562                            name: "artifacts directory".to_string(),
563                            reason: e.to_string(),
564                        }
565                    })?;
566
567                    let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
568                        ExecutionError::OutputWriteFailed {
569                            name: artifact_id.as_str().to_string(),
570                            reason: "artifact not produced".to_string(),
571                        }
572                    })?;
573
574                    let (path, hash, _size, _mime_type) =
575                        write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
576
577                    reporter.fact(StepFact::ArtifactWritten {
578                        step: step.id.clone(),
579                        name: artifact_id.as_str().to_string(),
580                        path: path.clone(),
581                        sha256: hash,
582                    })?;
583                }
584            }
585        }
586
587        reporter.set_current_step(None);
588        drop(plan);
589
590        Ok(counts)
591    }
592}
593
594#[derive(Default, Debug, Clone, Copy)]
595struct StepCounts {
596    completed: usize,
597}
598
599impl ExecutionError {
600    /// Convert this execution error into a transcript-friendly record.
601    fn to_error_record(&self) -> ErrorRecord {
602        let kind = match self {
603            ExecutionError::ValidationFailed(_) => "validation_failed",
604            ExecutionError::StepAborted(_) => "aborted",
605            ExecutionError::Io(_) => "io",
606            ExecutionError::StepFailed { .. } => "step_failed",
607            ExecutionError::UnknownAction(_) => "unknown_action",
608            ExecutionError::InvalidParams(_) => "invalid_params",
609            ExecutionError::EntropyError(_) => "entropy_error",
610            ExecutionError::MaterialLoadFailed { .. } => "material_load_failed",
611            ExecutionError::OutputWriteFailed { .. } => "output_write_failed",
612            ExecutionError::TranscriptError(_) => "transcript_error",
613            ExecutionError::BackendError(_) => "backend_error",
614        };
615        ErrorRecord::new(kind, self.to_string())
616    }
617}
618
619fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
620    let missing: Vec<_> = ceremony
621        .parameters
622        .iter()
623        .filter(|(_, p)| p.value.is_null())
624        .map(|(id, _)| id.as_str().to_string())
625        .collect();
626    if missing.is_empty() {
627        Ok(())
628    } else {
629        Err(ExecutionError::ValidationFailed(format!(
630            "Parameters missing values: {}",
631            missing.join(", ")
632        )))
633    }
634}
635
636/// Fold a step's produced artifacts into the running [`ExecutionState`].
637fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
638    let mut artifacts = state.artifacts;
639    for (id, value) in result.artifacts {
640        artifacts.insert(id, value);
641    }
642    ExecutionState {
643        params: state.params,
644        roles: state.roles,
645        materials: state.materials,
646        dry_run: state.dry_run,
647        artifacts,
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use crossbeam_channel::unbounded;
654    use rite_model::ActionType;
655
656    use super::*;
657    use crate::actions::ActionCategory;
658    use crate::transcript_sink::InMemorySink;
659
660    struct PingAction;
661
662    impl Action for PingAction {
663        fn metadata(&self) -> ActionMetadata {
664            ActionMetadata {
665                action_type: ActionType::Attest,
666                description: "test action that always succeeds",
667                category: ActionCategory::Verification,
668            }
669        }
670
671        fn execute(
672            &self,
673            _step: &StepInfo,
674            _ctx: &HandlerContext,
675            _params: &serde_json::Value,
676            reporter: &mut Reporter<'_>,
677            _backend: Option<&mut dyn Backend>,
678        ) -> Result<StepResult, ActionError> {
679            reporter.log(Icon::Info, "ping")?;
680            Ok(StepResult::completed("pinged".to_string()))
681        }
682    }
683
684    fn minimal_ceremony() -> Ceremony {
685        let yaml = r#"
686version: "0.2"
687name: "Test"
688roles:
689  participant:
690    person: "Alice"
691sections:
692  main:
693    role: ${role.participant}
694    steps:
695      ping:
696        action: attest
697        silent: true
698        with:
699          statement: "I confirm."
700"#;
701        rite_resolver::resolve(yaml, None)
702            .into_result()
703            .expect("resolve")
704    }
705
706    fn dry_run_output_config() -> OutputConfig {
707        OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
708    }
709
710    #[test]
711    fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
712        let ceremony = minimal_ceremony();
713        let mut registry = ActionRegistry::new();
714        registry.register(Arc::new(PingAction));
715
716        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
717        let (event_tx, event_rx) = unbounded::<ExecEvent>();
718        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
719
720        let executor = Executor::new(
721            ceremony,
722            registry,
723            BackendRegistry::new(),
724            dry_run_output_config(),
725            true,
726            StartupSnapshot::placeholder(),
727        );
728
729        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
730
731        // Drain events. With silent=true and dry_run=true, no prompts fire.
732        let mut facts = Vec::new();
733        while let Ok(ev) = event_rx.recv() {
734            if let ExecEvent::Fact { fact, .. } = ev {
735                facts.push(fact);
736            }
737        }
738
739        let summary = join.join().expect("executor join").expect("run ok");
740        assert_eq!(summary.steps_completed, 1);
741
742        let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
743        assert_eq!(
744            kinds,
745            vec![
746                "ceremony_started",
747                "entropy_seeded",
748                "step_started",
749                "step_completed",
750                "ceremony_completed",
751            ],
752        );
753
754        drop(cmd_tx);
755    }
756
757    #[test]
758    fn run_stamps_every_fact_from_the_injected_clock() {
759        use crate::test_support::{FixedClock, fixed_test_time};
760
761        let mut registry = ActionRegistry::new();
762        registry.register(Arc::new(PingAction));
763
764        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
765        let (event_tx, event_rx) = unbounded::<ExecEvent>();
766        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
767
768        let fixed = fixed_test_time();
769        let executor = Executor::new(
770            minimal_ceremony(),
771            registry,
772            BackendRegistry::new(),
773            dry_run_output_config(),
774            true,
775            StartupSnapshot::placeholder(),
776        )
777        .with_clock(Arc::new(FixedClock(fixed)));
778
779        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
780
781        let mut times = Vec::new();
782        while let Ok(ev) = event_rx.recv() {
783            if let ExecEvent::Fact { at, .. } = ev {
784                times.push(at);
785            }
786        }
787        join.join().expect("executor join").expect("run ok");
788
789        // Every fact carries the injected time, including the terminal
790        // `ceremony_completed`, which the runner emits outside `reporter.fact`.
791        assert!(!times.is_empty());
792        assert!(times.iter().all(|&t| t == fixed));
793
794        drop(cmd_tx);
795    }
796
797    #[derive(Debug, PartialEq, Eq)]
798    enum PacingTrace {
799        CeremonyStarted,
800        StepStarted(String),
801        ContinuePrompt { step: Option<String> },
802        StepCompleted,
803        CeremonyCompleted,
804    }
805
806    /// Two-step ceremony with pacing enabled: each step's body must be
807    /// gated on a `Continue` prompt that the executor emits **between**
808    /// `StepStarted` and the handler. After the last step there must be
809    /// no trailing pause, only `StepCompleted` and `CeremonyCompleted`.
810    #[test]
811    #[allow(clippy::too_many_lines)]
812    fn pacing_prompts_fire_at_step_start_not_step_end() {
813        let yaml = r#"
814version: "0.2"
815name: "Pacing"
816roles:
817  participant:
818    person: "Alice"
819sections:
820  main:
821    role: ${role.participant}
822    steps:
823      first:
824        action: attest
825        with:
826          statement: "first"
827      second:
828        action: attest
829        with:
830          statement: "second"
831"#;
832        let ceremony = rite_resolver::resolve(yaml, None)
833            .into_result()
834            .expect("resolve");
835        let mut registry = ActionRegistry::new();
836        registry.register(Arc::new(PingAction));
837
838        let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
839        let (event_tx, event_rx) = unbounded::<ExecEvent>();
840        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
841
842        let executor = Executor::new(
843            ceremony,
844            registry,
845            BackendRegistry::new(),
846            dry_run_output_config(),
847            false, // dry_run=false so prompts actually fire
848            StartupSnapshot::placeholder(),
849        );
850
851        // Drive the frontend on a worker so the executor's prompt waits
852        // don't deadlock. Acknowledge every Continue that arrives and
853        // collect a trace of the events the executor emitted.
854        let frontend = std::thread::spawn({
855            let cmd_tx = cmd_tx.clone();
856            move || {
857                let mut trace = Vec::new();
858                while let Ok(event) = event_rx.recv() {
859                    match event {
860                        ExecEvent::Fact {
861                            fact: StepFact::CeremonyStarted { .. },
862                            ..
863                        } => {
864                            trace.push(PacingTrace::CeremonyStarted);
865                        }
866                        ExecEvent::Fact {
867                            fact: StepFact::StepStarted { label, .. },
868                            ..
869                        } => {
870                            trace.push(PacingTrace::StepStarted(label));
871                        }
872                        ExecEvent::Fact {
873                            fact: StepFact::StepCompleted { .. },
874                            ..
875                        } => {
876                            trace.push(PacingTrace::StepCompleted);
877                        }
878                        ExecEvent::Fact {
879                            fact: StepFact::CeremonyCompleted { .. },
880                            ..
881                        } => {
882                            trace.push(PacingTrace::CeremonyCompleted);
883                        }
884                        ExecEvent::AwaitPrompt {
885                            prompt_id,
886                            prompt: Prompt::Continue { .. },
887                            step,
888                            ..
889                        } => {
890                            trace.push(PacingTrace::ContinuePrompt {
891                                step: step.map(|s| s.as_str().to_string()),
892                            });
893                            let _ = cmd_tx.send(UiCommand::PromptResponse {
894                                prompt_id,
895                                response: crate::protocol::Response::Acknowledge,
896                            });
897                        }
898                        _ => {}
899                    }
900                }
901                trace
902            }
903        });
904
905        let summary = executor
906            .run(&cmd_rx, &event_tx, sink)
907            .expect("ceremony runs");
908        assert_eq!(summary.steps_completed, 2);
909        // Drop the local senders so the frontend thread sees the
910        // event channel close and returns.
911        drop(event_tx);
912        drop(cmd_tx);
913        let trace = frontend.join().expect("frontend join");
914
915        // Required order:
916        //   CeremonyStarted, Continue{None},
917        //   StepStarted(1), Continue{Some(1)}, StepCompleted,
918        //   StepStarted(2), Continue{Some(2)}, StepCompleted,
919        //   CeremonyCompleted
920        let mut iter = trace.iter();
921        assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
922        assert_eq!(
923            iter.next(),
924            Some(&PacingTrace::ContinuePrompt { step: None })
925        );
926        let first_step = match iter.next() {
927            Some(PacingTrace::StepStarted(label)) => label.clone(),
928            other => panic!("expected first StepStarted, got {other:?}"),
929        };
930        assert_eq!(
931            iter.next(),
932            Some(&PacingTrace::ContinuePrompt {
933                step: Some("first".to_string()),
934            }),
935            "first step's Continue prompt must fire after StepStarted and before the body",
936        );
937        assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
938        let second_step = match iter.next() {
939            Some(PacingTrace::StepStarted(label)) => label.clone(),
940            other => panic!("expected second StepStarted, got {other:?}"),
941        };
942        assert_eq!(
943            iter.next(),
944            Some(&PacingTrace::ContinuePrompt {
945                step: Some("second".to_string()),
946            }),
947        );
948        assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
949        assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
950        assert_eq!(iter.next(), None, "no further events expected");
951        // Steps are auto-numbered when there's only one section.
952        assert_eq!(first_step, "1");
953        assert_eq!(second_step, "2");
954    }
955
956    #[test]
957    fn aborts_when_action_returns_aborted() {
958        struct AbortAction;
959        impl Action for AbortAction {
960            fn metadata(&self) -> ActionMetadata {
961                ActionMetadata {
962                    action_type: ActionType::Attest,
963                    description: "aborts immediately",
964                    category: ActionCategory::Verification,
965                }
966            }
967            fn execute(
968                &self,
969                _step: &StepInfo,
970                _ctx: &HandlerContext,
971                _params: &serde_json::Value,
972                _reporter: &mut Reporter<'_>,
973                _backend: Option<&mut dyn Backend>,
974            ) -> Result<StepResult, ActionError> {
975                Err(ActionError::Aborted)
976            }
977        }
978
979        let ceremony = minimal_ceremony();
980        let mut registry = ActionRegistry::new();
981        registry.register(Arc::new(AbortAction));
982
983        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
984        let (event_tx, event_rx) = unbounded::<ExecEvent>();
985        let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
986
987        let executor = Executor::new(
988            ceremony,
989            registry,
990            BackendRegistry::new(),
991            dry_run_output_config(),
992            true,
993            StartupSnapshot::placeholder(),
994        );
995
996        let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
997
998        let mut got_failed = false;
999        while let Ok(ev) = event_rx.recv() {
1000            if let ExecEvent::Fact {
1001                fact: StepFact::CeremonyFailed { error, .. },
1002                ..
1003            } = ev
1004            {
1005                assert_eq!(error.kind, "aborted");
1006                got_failed = true;
1007            }
1008        }
1009        assert!(got_failed, "expected CeremonyFailed event");
1010
1011        let result = join.join().expect("executor join");
1012        assert!(matches!(result, Err(ExecutionError::StepAborted(_))));
1013    }
1014
1015    fn fact_kind(fact: &StepFact) -> &'static str {
1016        match fact {
1017            StepFact::CeremonyStarted { .. } => "ceremony_started",
1018            StepFact::ActStarted { .. } => "act_started",
1019            StepFact::StepStarted { .. } => "step_started",
1020            StepFact::PromptAnswered { .. } => "prompt_answered",
1021            StepFact::BackendOperation { .. } => "backend_operation",
1022            StepFact::AttestationRecorded { .. } => "attestation_recorded",
1023            StepFact::ArtifactWritten { .. } => "artifact_written",
1024            StepFact::DeviationRecorded { .. } => "deviation_recorded",
1025            StepFact::StepCompleted { .. } => "step_completed",
1026            StepFact::CeremonyCompleted { .. } => "ceremony_completed",
1027            StepFact::CeremonyFailed { .. } => "ceremony_failed",
1028            StepFact::EntropySeeded { .. } => "entropy_seeded",
1029            StepFact::EntropyContributed { .. } => "entropy_contributed",
1030            StepFact::EntropyDrawn { .. } => "entropy_drawn",
1031            _ => "unknown",
1032        }
1033    }
1034}