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