1use std::collections::HashMap;
35use std::sync::Arc;
36
37use crossbeam_channel::{Receiver, Sender};
38use rand::TryRng;
39use rand::rngs::SysRng;
40use rite_model::{
41 ActId, ActionType, ArtifactId, Ceremony, MaterialId, OutputId, ParamId, RoleId, Step,
42};
43use rite_sdk::{Backend, BackendError, Retriability};
44use thiserror::Error;
45
46use crate::actions::ActionMetadata;
47use crate::backend::BackendRegistry;
48use crate::clock::{Clock, SystemClock};
49use crate::entropy::DERIVATION_V1;
50use crate::executor::{
51 ExecutionError, load_material_artifact, step_info_from, write_artifact_to_disk,
52};
53use crate::expressions;
54use crate::output_config::OutputConfig;
55use crate::protocol::{ExecEvent, Icon, MaterialOverview, Response, UiCommand, UiSignal};
56use crate::reporter::{Reporter, ReporterError};
57use crate::state::{ExecutionState, HandlerContext, StepResult};
58use crate::step_info::StepInfo;
59use crate::system_info::StartupSnapshot;
60use crate::transcript_sink::{TranscriptFingerprint, TranscriptSink};
61use rite_model::{ErrorClass, ErrorRecord, Prompt, RetryPolicy, StepFact, StepOutcome};
62
63fn gather_machine_entropy(dry_run: bool) -> Result<([u8; 32], String), ExecutionError> {
71 let mut m = [0u8; 32];
72 if dry_run {
73 for (slot, byte) in m.iter_mut().zip(b"rite-dry-run-not-real-entropy") {
74 *slot = *byte;
75 }
76 return Ok((m, "dry-run".to_string()));
77 }
78 SysRng
79 .try_fill_bytes(&mut m)
80 .map_err(|e| ExecutionError::EntropyError(e.to_string()))?;
81 Ok((m, "os".to_string()))
82}
83
84#[derive(Debug, Error)]
86pub enum ActionError {
87 #[error("ceremony aborted by operator")]
90 Aborted,
91 #[error("frontend channel disconnected")]
93 Disconnected,
94 #[error("transcript write failed: {0}")]
96 Transcript(std::io::Error),
97 #[error("backend error: {0}")]
99 Backend(#[from] BackendError),
100 #[error("{0}")]
102 Failed(String),
103}
104
105impl From<ReporterError> for ActionError {
106 fn from(value: ReporterError) -> Self {
107 match value {
108 ReporterError::Aborted => ActionError::Aborted,
109 ReporterError::Disconnected => ActionError::Disconnected,
110 ReporterError::Transcript(e) => ActionError::Transcript(e),
111 ReporterError::NoCurrentStep(_)
112 | ReporterError::DuplicateDraw { .. }
113 | ReporterError::Unseeded
114 | ReporterError::DrawTooLong { .. } => ActionError::Failed(value.to_string()),
115 }
116 }
117}
118
119impl ActionError {
120 #[must_use]
130 pub fn retriability(&self) -> Retriability {
131 match self {
132 ActionError::Backend(e) => e.retriability(),
133 ActionError::Aborted
134 | ActionError::Disconnected
135 | ActionError::Transcript(_)
136 | ActionError::Failed(_) => Retriability::Fatal,
137 }
138 }
139}
140
141pub fn parse_params<P: serde::de::DeserializeOwned>(
150 params: &serde_json::Value,
151) -> Result<P, ActionError> {
152 serde_json::from_value(params.clone())
153 .map_err(|e| ActionError::Failed(format!("invalid params: {e}")))
154}
155
156impl From<ReporterError> for ExecutionError {
157 fn from(value: ReporterError) -> Self {
158 match value {
159 ReporterError::Aborted => ExecutionError::StepAborted(rite_model::StepId::new("")),
160 ReporterError::Disconnected => {
161 ExecutionError::TranscriptError("frontend channel disconnected".to_string())
162 }
163 ReporterError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
164 ReporterError::NoCurrentStep(_) => ExecutionError::TranscriptError(value.to_string()),
165 ReporterError::DuplicateDraw { .. }
166 | ReporterError::Unseeded
167 | ReporterError::DrawTooLong { .. } => ExecutionError::EntropyError(value.to_string()),
168 }
169 }
170}
171
172pub trait Action: Send + Sync {
179 fn metadata(&self) -> ActionMetadata;
181
182 fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
184
185 fn execute(
192 &self,
193 step: &StepInfo,
194 ctx: &HandlerContext,
195 params: &serde_json::Value,
196 reporter: &mut Reporter<'_>,
197 backend: Option<&mut dyn Backend>,
198 ) -> Result<StepResult, ActionError>;
199}
200
201#[derive(Default)]
203pub struct ActionRegistry {
204 actions: HashMap<ActionType, Arc<dyn Action>>,
205}
206
207impl ActionRegistry {
208 #[must_use]
210 pub fn new() -> Self {
211 Self::default()
212 }
213
214 pub fn register(&mut self, action: Arc<dyn Action>) {
216 let action_type = action.metadata().action_type;
217 self.actions.insert(action_type, action);
218 }
219
220 #[must_use]
222 pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
223 self.actions.get(action)
224 }
225
226 pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
228 self.actions.keys()
229 }
230
231 #[must_use]
235 pub fn unsupported_actions(&self, steps: &[rite_model::Step]) -> Vec<ActionType> {
236 let mut seen = std::collections::HashSet::new();
237 let mut result = Vec::new();
238 for step in steps {
239 if !self.actions.contains_key(&step.action) && seen.insert(step.action) {
240 result.push(step.action);
241 }
242 }
243 result
244 }
245}
246
247#[derive(Debug)]
249pub struct ExecutionSummary {
250 pub ceremony_name: String,
252 pub steps_completed: usize,
254 pub transcript_fingerprint: TranscriptFingerprint,
256}
257
258pub struct Executor {
260 ceremony: Ceremony,
261 registry: ActionRegistry,
262 backend_registry: BackendRegistry,
263 output_config: OutputConfig,
264 dry_run: bool,
265 startup: StartupSnapshot,
266 clock: Arc<dyn Clock>,
267}
268
269impl Executor {
270 #[must_use]
276 pub fn new(
277 ceremony: Ceremony,
278 registry: ActionRegistry,
279 backend_registry: BackendRegistry,
280 output_config: OutputConfig,
281 dry_run: bool,
282 startup: StartupSnapshot,
283 ) -> Self {
284 Self {
285 ceremony,
286 registry,
287 backend_registry,
288 output_config,
289 dry_run,
290 startup,
291 clock: Arc::new(SystemClock),
292 }
293 }
294
295 #[must_use]
298 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
299 self.clock = clock;
300 self
301 }
302
303 pub fn run(
316 self,
317 cmd_rx: &Receiver<UiCommand>,
318 event_tx: &Sender<ExecEvent>,
319 mut transcript_sink: Box<dyn TranscriptSink>,
320 ) -> Result<ExecutionSummary, ExecutionError> {
321 validate_parameters(&self.ceremony)?;
322
323 let resolved_params: HashMap<ParamId, serde_json::Value> = self
324 .ceremony
325 .parameters
326 .iter()
327 .map(|(id, p)| (id.clone(), p.value.clone()))
328 .collect();
329
330 let roles: HashMap<RoleId, String> = self
331 .ceremony
332 .roles
333 .iter()
334 .map(|(id, r)| (id.clone(), r.name.clone()))
335 .collect();
336
337 let materials_map: HashMap<MaterialId, String> = self
338 .ceremony
339 .materials
340 .iter()
341 .map(|(id, m)| (id.clone(), m.display_name().to_string()))
342 .collect();
343
344 let ceremony_name = self.ceremony.metadata.name.clone();
345 let clock = Arc::clone(&self.clock);
348 let mut reporter = Reporter::new(
349 event_tx,
350 cmd_rx,
351 transcript_sink.as_mut(),
352 Arc::clone(&clock),
353 );
354
355 let outcome = self.execute_inner(&mut reporter, resolved_params, roles, materials_map);
356
357 match outcome {
362 Ok(counts) => {
363 let at = clock.now();
364 let completed = StepFact::CeremonyCompleted {};
365 transcript_sink
366 .record(at, &completed)
367 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
368 let _ = event_tx.send(ExecEvent::Fact {
369 at,
370 fact: completed,
371 });
372 let fingerprint = transcript_sink
373 .finalize()
374 .map_err(|e| ExecutionError::TranscriptError(e.to_string()))?;
375 let _ = event_tx.send(ExecEvent::Finalized {
376 fingerprint: fingerprint.as_str().to_string(),
377 });
378 Ok(ExecutionSummary {
379 ceremony_name,
380 steps_completed: counts.completed,
381 transcript_fingerprint: fingerprint,
382 })
383 }
384 Err(err) => {
385 let at = clock.now();
386 let record = err.to_error_record();
387 let failed = StepFact::CeremonyFailed { error: record };
388 let _ = transcript_sink.record(at, &failed);
389 let _ = event_tx.send(ExecEvent::Fact { at, fact: failed });
390 if let Ok(fingerprint) = transcript_sink.finalize() {
391 let _ = event_tx.send(ExecEvent::Finalized {
392 fingerprint: fingerprint.as_str().to_string(),
393 });
394 }
395 Err(err)
396 }
397 }
398 }
399
400 #[allow(clippy::too_many_lines)]
401 fn execute_inner(
402 mut self,
403 reporter: &mut Reporter<'_>,
404 resolved_params: HashMap<ParamId, serde_json::Value>,
405 roles: HashMap<RoleId, String>,
406 materials_map: HashMap<MaterialId, String>,
407 ) -> Result<StepCounts, ExecutionError> {
408 let dry_run = self.dry_run;
409
410 reporter.fact(StepFact::CeremonyStarted {
411 name: self.ceremony.metadata.name.clone(),
412 })?;
413
414 let (m, source) = gather_machine_entropy(dry_run)?;
419 reporter.seed_entropy(&m);
420 reporter.fact(StepFact::EntropySeeded {
421 m: base16ct::lower::encode_string(&m),
422 source,
423 derivation: DERIVATION_V1.to_string(),
424 })?;
425
426 reporter.signal(UiSignal::CeremonyOverview {
431 description: self.ceremony.metadata.description.clone(),
432 materials: self
433 .ceremony
434 .materials
435 .iter()
436 .map(|(_, m)| MaterialOverview::from_material(m))
437 .collect(),
438 step_count: self.ceremony.execution_plan.len(),
439 })?;
440
441 reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
446 reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
447
448 if !dry_run {
453 reporter.prompt(&Prompt::Continue {
454 hint: Some("Press Enter to start the ceremony".to_string()),
455 })?;
456 }
457
458 let mut state = ExecutionState::new(resolved_params, roles, materials_map, dry_run);
459
460 for (id, material) in self.ceremony.materials.iter() {
462 let artifact = load_material_artifact(id.as_str(), material)?;
463 state = state.with_material(ArtifactId::new(id.as_str()), artifact);
464 let display_name = material.display_name();
465 reporter.log(Icon::Checkmark, format!("Loaded material: {display_name}"))?;
466 }
467
468 let has_acts = !self.ceremony.acts.is_empty();
469 let mut current_act: Option<ActId> = None;
470 let mut counts = StepCounts::default();
471
472 let plan = std::mem::take(&mut self.ceremony.execution_plan);
475 for step in &plan {
476 if has_acts {
478 let step_act = self
479 .ceremony
480 .sections
481 .get(&step.section)
482 .and_then(|s| s.act.clone());
483 if step_act != current_act {
484 if let Some(act_id) = &step_act
485 && let Some(act) = self.ceremony.acts.get(act_id)
486 {
487 reporter.fact(StepFact::ActStarted {
488 id: act_id.clone(),
489 label: act
490 .name
491 .clone()
492 .unwrap_or_else(|| act_id.as_str().to_string()),
493 })?;
494 }
495 current_act = step_act;
496 }
497 }
498
499 reporter.set_current_step(Some(step.id.clone()));
500 let role_id = step.role.clone().unwrap_or_else(|| RoleId::new(""));
501 let role_name = self
502 .ceremony
503 .roles
504 .get(&role_id)
505 .map_or_else(|| role_id.as_str().to_string(), |r| r.name.clone());
506 reporter.fact(StepFact::StepStarted {
507 id: step.id.clone(),
508 label: step.step_label.clone(),
509 role: role_id,
510 role_name,
511 })?;
512
513 if !step.silent && !dry_run {
520 reporter.prompt(&Prompt::Continue {
521 hint: Some(format!("Press Enter to start step {}", step.step_label)),
522 })?;
523 }
524
525 let handler = self
527 .registry
528 .get(&step.action)
529 .ok_or(ExecutionError::UnknownAction(step.action))?
530 .clone();
531
532 let ctx = state.handler_context();
533 let step_info = step_info_from(step);
534 let mut params = expressions::evaluate_expr_value(&step.with, &ctx)?;
535 handler.apply_defaults(&mut params, &step_info);
536
537 let result = execute_step_with_retry(
538 handler.as_ref(),
539 step,
540 &step_info,
541 &ctx,
542 ¶ms,
543 reporter,
544 &mut self.backend_registry,
545 )?;
546
547 reporter.fact(StepFact::StepCompleted {
549 id: step.id.clone(),
550 outcome: result.outcome.clone(),
551 })?;
552
553 if let StepOutcome::Completed { .. } = &result.outcome {
554 counts.completed = counts.completed.saturating_add(1);
555 }
556
557 state = merge_step_result(state, result);
558
559 if let Some(artifact_id) = &step.creates
563 && !dry_run
564 {
565 let output_id = OutputId::new(artifact_id.as_str());
566 if self.ceremony.outputs.contains(&output_id) {
567 std::fs::create_dir_all(self.output_config.artifacts_dir()).map_err(|e| {
568 ExecutionError::OutputWriteFailed {
569 name: "artifacts directory".to_string(),
570 reason: e.to_string(),
571 }
572 })?;
573
574 let artifact_value = state.artifacts.get(artifact_id).ok_or_else(|| {
575 ExecutionError::OutputWriteFailed {
576 name: artifact_id.as_str().to_string(),
577 reason: "artifact not produced".to_string(),
578 }
579 })?;
580
581 let (path, hash, _size, _mime_type) =
582 write_artifact_to_disk(artifact_id, artifact_value, &self.output_config)?;
583
584 reporter.fact(StepFact::ArtifactWritten {
585 step: step.id.clone(),
586 name: artifact_id.as_str().to_string(),
587 path: path.clone(),
588 sha256: hash,
589 })?;
590 }
591 }
592 }
593
594 reporter.set_current_step(None);
595 drop(plan);
596
597 Ok(counts)
598 }
599}
600
601#[derive(Default, Debug, Clone, Copy)]
602struct StepCounts {
603 completed: usize,
604}
605
606fn execute_step_with_retry(
614 handler: &dyn Action,
615 step: &Step,
616 step_info: &StepInfo,
617 ctx: &HandlerContext,
618 params: &serde_json::Value,
619 reporter: &mut Reporter<'_>,
620 backend_registry: &mut BackendRegistry,
621) -> Result<StepResult, ExecutionError> {
622 let mut attempt: u32 = 1;
623 loop {
624 let facts_before = reporter.facts_emitted();
625
626 let backend = if let Some(name) = &step_info.backend {
627 Some(
628 backend_registry
629 .get_mut(name)
630 .map_err(|e| ExecutionError::StepFailed {
631 step: step_info.id.clone(),
632 reason: e.to_string(),
633 })?,
634 )
635 } else {
636 None
637 };
638
639 let action_err = match handler.execute(step_info, ctx, params, reporter, backend) {
640 Ok(result) => return Ok(result),
641 Err(ActionError::Aborted) => return Err(ExecutionError::StepAborted(step.id.clone())),
644 Err(action_err) => action_err,
645 };
646
647 let no_new_facts = reporter.facts_emitted() == facts_before;
650 let retriable = action_err.retriability().is_retriable() && no_new_facts;
651 let exec_err = action_error_to_execution(action_err, &step.id);
652 reporter.fact(StepFact::StepAttemptFailed {
653 step: step.id.clone(),
654 attempt,
655 error: exec_err.to_error_record(),
656 })?;
657
658 let policy_allows = match step.retry {
659 RetryPolicy::Never => false,
660 RetryPolicy::MaxAttempts(max) => attempt < max,
661 RetryPolicy::Prompt => true,
662 };
663
664 if retriable && policy_allows {
665 let answer = reporter.prompt(&Prompt::Confirm {
669 question: format!("Step {} failed: {exec_err}. Retry?", step.step_label),
670 default: Some(false),
671 })?;
672 if matches!(answer, Response::Bool(true)) {
673 attempt = attempt.saturating_add(1);
674 continue;
675 }
676 }
677 return Err(exec_err);
678 }
679}
680
681fn action_error_to_execution(err: ActionError, step_id: &rite_model::StepId) -> ExecutionError {
686 match err {
687 ActionError::Aborted => ExecutionError::StepAborted(step_id.clone()),
688 ActionError::Disconnected => ExecutionError::StepFailed {
689 step: step_id.clone(),
690 reason: "frontend channel disconnected".to_string(),
691 },
692 ActionError::Transcript(e) => ExecutionError::TranscriptError(e.to_string()),
693 ActionError::Backend(e) => ExecutionError::BackendError(e),
694 ActionError::Failed(reason) => ExecutionError::StepFailed {
695 step: step_id.clone(),
696 reason,
697 },
698 }
699}
700
701impl ExecutionError {
702 fn to_error_record(&self) -> ErrorRecord {
708 let (class, kind) = match self {
709 ExecutionError::ValidationFailed(_) => (ErrorClass::Integrity, "validation_failed"),
710 ExecutionError::StepAborted(_) => (ErrorClass::Abort, "aborted"),
711 ExecutionError::Io(_) => (ErrorClass::Integrity, "io"),
712 ExecutionError::StepFailed { .. } => (ErrorClass::Procedural, "step_failed"),
713 ExecutionError::UnknownAction(_) => (ErrorClass::Integrity, "unknown_action"),
714 ExecutionError::InvalidParams(_) => (ErrorClass::Integrity, "invalid_params"),
715 ExecutionError::EntropyError(_) => (ErrorClass::Integrity, "entropy_error"),
716 ExecutionError::MaterialLoadFailed { .. } => {
717 (ErrorClass::Integrity, "material_load_failed")
718 }
719 ExecutionError::OutputWriteFailed { .. } => {
720 (ErrorClass::Integrity, "output_write_failed")
721 }
722 ExecutionError::TranscriptError(_) => (ErrorClass::Integrity, "transcript_error"),
723 ExecutionError::BackendError(e) => {
724 let class = if e.retriability().is_retriable() {
725 ErrorClass::Environmental
726 } else {
727 ErrorClass::Integrity
728 };
729 (class, "backend_error")
730 }
731 };
732 ErrorRecord::new(class, kind, self.to_string())
733 }
734}
735
736fn validate_parameters(ceremony: &Ceremony) -> Result<(), ExecutionError> {
737 let missing: Vec<_> = ceremony
738 .parameters
739 .iter()
740 .filter(|(_, p)| p.value.is_null())
741 .map(|(id, _)| id.as_str().to_string())
742 .collect();
743 if missing.is_empty() {
744 Ok(())
745 } else {
746 Err(ExecutionError::ValidationFailed(format!(
747 "Parameters missing values: {}",
748 missing.join(", ")
749 )))
750 }
751}
752
753fn merge_step_result(state: ExecutionState, result: StepResult) -> ExecutionState {
755 let mut artifacts = state.artifacts;
756 for (id, value) in result.artifacts {
757 artifacts.insert(id, value);
758 }
759 ExecutionState {
760 params: state.params,
761 roles: state.roles,
762 materials: state.materials,
763 dry_run: state.dry_run,
764 artifacts,
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use crossbeam_channel::unbounded;
771 use rite_model::ActionType;
772
773 use super::*;
774 use crate::actions::ActionCategory;
775 use crate::transcript_sink::InMemorySink;
776
777 #[test]
778 fn action_error_retriability_delegates_to_backend() {
779 assert_eq!(
780 ActionError::Backend(BackendError::TokenNotPresent).retriability(),
781 Retriability::Transient,
782 );
783 assert_eq!(
784 ActionError::Backend(BackendError::PinBlocked).retriability(),
785 Retriability::Fatal,
786 );
787 }
788
789 #[test]
790 fn handler_level_errors_are_fatal() {
791 assert_eq!(ActionError::Aborted.retriability(), Retriability::Fatal);
792 assert_eq!(
793 ActionError::Disconnected.retriability(),
794 Retriability::Fatal
795 );
796 assert_eq!(
797 ActionError::Failed("check_value mismatch".into()).retriability(),
798 Retriability::Fatal,
799 );
800 }
801
802 struct PingAction;
803
804 impl Action for PingAction {
805 fn metadata(&self) -> ActionMetadata {
806 ActionMetadata {
807 action_type: ActionType::Attest,
808 description: "test action that always succeeds",
809 category: ActionCategory::Verification,
810 }
811 }
812
813 fn execute(
814 &self,
815 _step: &StepInfo,
816 _ctx: &HandlerContext,
817 _params: &serde_json::Value,
818 reporter: &mut Reporter<'_>,
819 _backend: Option<&mut dyn Backend>,
820 ) -> Result<StepResult, ActionError> {
821 reporter.log(Icon::Info, "ping")?;
822 Ok(StepResult::completed("pinged".to_string()))
823 }
824 }
825
826 fn minimal_ceremony() -> Ceremony {
827 let yaml = r#"
828version: "0.2"
829name: "Test"
830roles:
831 participant:
832 person: "Alice"
833sections:
834 main:
835 role: ${role.participant}
836 steps:
837 ping:
838 action: attest
839 silent: true
840 with:
841 statement: "I confirm."
842"#;
843 rite_resolver::resolve(yaml, None)
844 .into_result()
845 .expect("resolve")
846 }
847
848 fn dry_run_output_config() -> OutputConfig {
849 OutputConfig::new(std::path::PathBuf::from("/tmp/runner-test"))
850 }
851
852 #[test]
853 fn runs_a_minimal_ceremony_and_emits_lifecycle_facts() {
854 let ceremony = minimal_ceremony();
855 let mut registry = ActionRegistry::new();
856 registry.register(Arc::new(PingAction));
857
858 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
859 let (event_tx, event_rx) = unbounded::<ExecEvent>();
860 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
861
862 let executor = Executor::new(
863 ceremony,
864 registry,
865 BackendRegistry::new(),
866 dry_run_output_config(),
867 true,
868 StartupSnapshot::placeholder(),
869 );
870
871 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
872
873 let mut facts = Vec::new();
875 while let Ok(ev) = event_rx.recv() {
876 if let ExecEvent::Fact { fact, .. } = ev {
877 facts.push(fact);
878 }
879 }
880
881 let summary = join.join().expect("executor join").expect("run ok");
882 assert_eq!(summary.steps_completed, 1);
883
884 let kinds: Vec<&'static str> = facts.iter().map(fact_kind).collect();
885 assert_eq!(
886 kinds,
887 vec![
888 "ceremony_started",
889 "entropy_seeded",
890 "step_started",
891 "step_completed",
892 "ceremony_completed",
893 ],
894 );
895
896 drop(cmd_tx);
897 }
898
899 #[test]
900 fn run_stamps_every_fact_from_the_injected_clock() {
901 use crate::test_support::{FixedClock, fixed_test_time};
902
903 let mut registry = ActionRegistry::new();
904 registry.register(Arc::new(PingAction));
905
906 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
907 let (event_tx, event_rx) = unbounded::<ExecEvent>();
908 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
909
910 let fixed = fixed_test_time();
911 let executor = Executor::new(
912 minimal_ceremony(),
913 registry,
914 BackendRegistry::new(),
915 dry_run_output_config(),
916 true,
917 StartupSnapshot::placeholder(),
918 )
919 .with_clock(Arc::new(FixedClock(fixed)));
920
921 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
922
923 let mut times = Vec::new();
924 while let Ok(ev) = event_rx.recv() {
925 if let ExecEvent::Fact { at, .. } = ev {
926 times.push(at);
927 }
928 }
929 join.join().expect("executor join").expect("run ok");
930
931 assert!(!times.is_empty());
934 assert!(times.iter().all(|&t| t == fixed));
935
936 drop(cmd_tx);
937 }
938
939 #[derive(Debug, PartialEq, Eq)]
940 enum PacingTrace {
941 CeremonyStarted,
942 StepStarted(String),
943 ContinuePrompt { step: Option<String> },
944 StepCompleted,
945 CeremonyCompleted,
946 }
947
948 #[test]
953 #[allow(clippy::too_many_lines)]
954 fn pacing_prompts_fire_at_step_start_not_step_end() {
955 let yaml = r#"
956version: "0.2"
957name: "Pacing"
958roles:
959 participant:
960 person: "Alice"
961sections:
962 main:
963 role: ${role.participant}
964 steps:
965 first:
966 action: attest
967 with:
968 statement: "first"
969 second:
970 action: attest
971 with:
972 statement: "second"
973"#;
974 let ceremony = rite_resolver::resolve(yaml, None)
975 .into_result()
976 .expect("resolve");
977 let mut registry = ActionRegistry::new();
978 registry.register(Arc::new(PingAction));
979
980 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
981 let (event_tx, event_rx) = unbounded::<ExecEvent>();
982 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
983
984 let executor = Executor::new(
985 ceremony,
986 registry,
987 BackendRegistry::new(),
988 dry_run_output_config(),
989 false, StartupSnapshot::placeholder(),
991 );
992
993 let frontend = std::thread::spawn({
997 let cmd_tx = cmd_tx.clone();
998 move || {
999 let mut trace = Vec::new();
1000 while let Ok(event) = event_rx.recv() {
1001 match event {
1002 ExecEvent::Fact {
1003 fact: StepFact::CeremonyStarted { .. },
1004 ..
1005 } => {
1006 trace.push(PacingTrace::CeremonyStarted);
1007 }
1008 ExecEvent::Fact {
1009 fact: StepFact::StepStarted { label, .. },
1010 ..
1011 } => {
1012 trace.push(PacingTrace::StepStarted(label));
1013 }
1014 ExecEvent::Fact {
1015 fact: StepFact::StepCompleted { .. },
1016 ..
1017 } => {
1018 trace.push(PacingTrace::StepCompleted);
1019 }
1020 ExecEvent::Fact {
1021 fact: StepFact::CeremonyCompleted { .. },
1022 ..
1023 } => {
1024 trace.push(PacingTrace::CeremonyCompleted);
1025 }
1026 ExecEvent::AwaitPrompt {
1027 prompt_id,
1028 prompt: Prompt::Continue { .. },
1029 step,
1030 ..
1031 } => {
1032 trace.push(PacingTrace::ContinuePrompt {
1033 step: step.map(|s| s.as_str().to_string()),
1034 });
1035 let _ = cmd_tx.send(UiCommand::PromptResponse {
1036 prompt_id,
1037 response: crate::protocol::Response::Acknowledge,
1038 });
1039 }
1040 _ => {}
1041 }
1042 }
1043 trace
1044 }
1045 });
1046
1047 let summary = executor
1048 .run(&cmd_rx, &event_tx, sink)
1049 .expect("ceremony runs");
1050 assert_eq!(summary.steps_completed, 2);
1051 drop(event_tx);
1054 drop(cmd_tx);
1055 let trace = frontend.join().expect("frontend join");
1056
1057 let mut iter = trace.iter();
1063 assert_eq!(iter.next(), Some(&PacingTrace::CeremonyStarted));
1064 assert_eq!(
1065 iter.next(),
1066 Some(&PacingTrace::ContinuePrompt { step: None })
1067 );
1068 let first_step = match iter.next() {
1069 Some(PacingTrace::StepStarted(label)) => label.clone(),
1070 other => panic!("expected first StepStarted, got {other:?}"),
1071 };
1072 assert_eq!(
1073 iter.next(),
1074 Some(&PacingTrace::ContinuePrompt {
1075 step: Some("first".to_string()),
1076 }),
1077 "first step's Continue prompt must fire after StepStarted and before the body",
1078 );
1079 assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1080 let second_step = match iter.next() {
1081 Some(PacingTrace::StepStarted(label)) => label.clone(),
1082 other => panic!("expected second StepStarted, got {other:?}"),
1083 };
1084 assert_eq!(
1085 iter.next(),
1086 Some(&PacingTrace::ContinuePrompt {
1087 step: Some("second".to_string()),
1088 }),
1089 );
1090 assert_eq!(iter.next(), Some(&PacingTrace::StepCompleted));
1091 assert_eq!(iter.next(), Some(&PacingTrace::CeremonyCompleted));
1092 assert_eq!(iter.next(), None, "no further events expected");
1093 assert_eq!(first_step, "1");
1095 assert_eq!(second_step, "2");
1096 }
1097
1098 #[test]
1099 fn aborts_when_action_returns_aborted() {
1100 struct AbortAction;
1101 impl Action for AbortAction {
1102 fn metadata(&self) -> ActionMetadata {
1103 ActionMetadata {
1104 action_type: ActionType::Attest,
1105 description: "aborts immediately",
1106 category: ActionCategory::Verification,
1107 }
1108 }
1109 fn execute(
1110 &self,
1111 _step: &StepInfo,
1112 _ctx: &HandlerContext,
1113 _params: &serde_json::Value,
1114 _reporter: &mut Reporter<'_>,
1115 _backend: Option<&mut dyn Backend>,
1116 ) -> Result<StepResult, ActionError> {
1117 Err(ActionError::Aborted)
1118 }
1119 }
1120
1121 let ceremony = minimal_ceremony();
1122 let mut registry = ActionRegistry::new();
1123 registry.register(Arc::new(AbortAction));
1124
1125 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1126 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1127 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1128
1129 let executor = Executor::new(
1130 ceremony,
1131 registry,
1132 BackendRegistry::new(),
1133 dry_run_output_config(),
1134 true,
1135 StartupSnapshot::placeholder(),
1136 );
1137
1138 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1139
1140 let mut got_failed = false;
1141 while let Ok(ev) = event_rx.recv() {
1142 if let ExecEvent::Fact {
1143 fact: StepFact::CeremonyFailed { error, .. },
1144 ..
1145 } = ev
1146 {
1147 assert_eq!(error.kind, "aborted");
1148 got_failed = true;
1149 }
1150 }
1151 assert!(got_failed, "expected CeremonyFailed event");
1152
1153 let result = join.join().expect("executor join");
1154 assert!(matches!(result, Err(ExecutionError::StepAborted(_))));
1155 }
1156
1157 struct FlakyAction {
1163 fails_remaining: std::sync::Mutex<u32>,
1164 error: fn() -> ActionError,
1165 emit_fact_before_fail: bool,
1166 }
1167
1168 impl FlakyAction {
1169 fn new(fails: u32, error: fn() -> ActionError, emit_fact_before_fail: bool) -> Arc<Self> {
1170 Arc::new(Self {
1171 fails_remaining: std::sync::Mutex::new(fails),
1172 error,
1173 emit_fact_before_fail,
1174 })
1175 }
1176 }
1177
1178 impl Action for FlakyAction {
1179 fn metadata(&self) -> ActionMetadata {
1180 ActionMetadata {
1181 action_type: ActionType::Attest,
1182 description: "flaky test action",
1183 category: ActionCategory::Verification,
1184 }
1185 }
1186
1187 fn execute(
1188 &self,
1189 _step: &StepInfo,
1190 _ctx: &HandlerContext,
1191 _params: &serde_json::Value,
1192 reporter: &mut Reporter<'_>,
1193 _backend: Option<&mut dyn Backend>,
1194 ) -> Result<StepResult, ActionError> {
1195 let mut remaining = self.fails_remaining.lock().expect("lock");
1196 if *remaining == 0 {
1197 return Ok(StepResult::completed("done".to_string()));
1198 }
1199 *remaining = remaining.saturating_sub(1);
1200 if self.emit_fact_before_fail {
1201 reporter.fact(StepFact::DeviationRecorded {
1202 step: None,
1203 text: "side effect".to_string(),
1204 })?;
1205 }
1206 Err((self.error)())
1207 }
1208 }
1209
1210 fn transient() -> ActionError {
1211 ActionError::Backend(BackendError::TokenNotPresent)
1212 }
1213
1214 fn ceremony_with_retry(retry_clause: &str) -> Ceremony {
1215 let yaml = format!(
1216 r#"
1217version: "0.2"
1218name: "Retry"
1219roles:
1220 participant:
1221 person: "Alice"
1222sections:
1223 main:
1224 role: ${{role.participant}}
1225 steps:
1226 work:
1227 action: attest
1228 silent: true
1229 {retry_clause}
1230 with:
1231 statement: "x"
1232"#
1233 );
1234 rite_resolver::resolve(&yaml, None)
1235 .into_result()
1236 .expect("resolve")
1237 }
1238
1239 fn drive(
1243 ceremony: Ceremony,
1244 action: Arc<dyn Action>,
1245 confirm_answer: bool,
1246 ) -> (
1247 Result<ExecutionSummary, ExecutionError>,
1248 Vec<StepFact>,
1249 usize,
1250 ) {
1251 let mut registry = ActionRegistry::new();
1252 registry.register(action);
1253
1254 let (cmd_tx, cmd_rx) = unbounded::<UiCommand>();
1255 let (event_tx, event_rx) = unbounded::<ExecEvent>();
1256 let sink: Box<dyn TranscriptSink> = Box::new(InMemorySink::new());
1257
1258 let executor = Executor::new(
1259 ceremony,
1260 registry,
1261 BackendRegistry::new(),
1262 dry_run_output_config(),
1263 true, StartupSnapshot::placeholder(),
1265 );
1266 let join = std::thread::spawn(move || executor.run(&cmd_rx, &event_tx, sink));
1267
1268 let mut facts = Vec::new();
1269 let mut confirms = 0usize;
1270 while let Ok(ev) = event_rx.recv() {
1271 match ev {
1272 ExecEvent::Fact { fact, .. } => facts.push(fact),
1273 ExecEvent::AwaitPrompt {
1274 prompt_id,
1275 prompt: Prompt::Confirm { .. },
1276 ..
1277 } => {
1278 confirms = confirms.saturating_add(1);
1279 cmd_tx
1280 .send(UiCommand::PromptResponse {
1281 prompt_id,
1282 response: Response::Bool(confirm_answer),
1283 })
1284 .expect("send confirm");
1285 }
1286 _ => {}
1287 }
1288 }
1289 let result = join.join().expect("executor join");
1290 (result, facts, confirms)
1291 }
1292
1293 fn attempt_numbers(facts: &[StepFact]) -> Vec<u32> {
1294 facts
1295 .iter()
1296 .filter_map(|f| match f {
1297 StepFact::StepAttemptFailed { attempt, .. } => Some(*attempt),
1298 _ => None,
1299 })
1300 .collect()
1301 }
1302
1303 #[test]
1304 fn transient_failure_is_retried_then_succeeds() {
1305 let action = FlakyAction::new(1, transient, false);
1306 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1307
1308 let summary = result.expect("run ok");
1309 assert_eq!(summary.steps_completed, 1);
1310 assert_eq!(confirms, 1, "one retry prompt");
1311 assert_eq!(attempt_numbers(&facts), vec![1]);
1312 let StepFact::StepAttemptFailed { error, .. } = facts
1314 .iter()
1315 .find(|f| matches!(f, StepFact::StepAttemptFailed { .. }))
1316 .expect("attempt fact")
1317 else {
1318 unreachable!()
1319 };
1320 assert_eq!(error.class, ErrorClass::Environmental);
1321 assert!(
1322 facts
1323 .iter()
1324 .any(|f| matches!(f, StepFact::StepCompleted { .. }))
1325 );
1326 }
1327
1328 #[test]
1329 fn retry_is_blocked_once_a_fact_was_emitted() {
1330 let action = FlakyAction::new(u32::MAX, transient, true);
1333 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, true);
1334
1335 assert!(result.is_err(), "run fails");
1336 assert_eq!(confirms, 0, "gate blocks the retry prompt");
1337 assert_eq!(attempt_numbers(&facts), vec![1]);
1338 assert!(
1339 facts
1340 .iter()
1341 .any(|f| matches!(f, StepFact::CeremonyFailed { .. }))
1342 );
1343 }
1344
1345 #[test]
1346 fn retry_never_fails_immediately() {
1347 let action = FlakyAction::new(u32::MAX, transient, false);
1348 let (result, facts, confirms) = drive(ceremony_with_retry("retry: never"), action, true);
1349
1350 assert!(result.is_err());
1351 assert_eq!(confirms, 0, "retry: never never prompts");
1352 assert_eq!(attempt_numbers(&facts), vec![1]);
1353 }
1354
1355 #[test]
1356 fn retry_attempts_caps_total_tries() {
1357 let action = FlakyAction::new(u32::MAX, transient, false);
1358 let (result, facts, confirms) =
1359 drive(ceremony_with_retry("retry: {attempts: 2}"), action, true);
1360
1361 assert!(result.is_err());
1362 assert_eq!(confirms, 1);
1365 assert_eq!(attempt_numbers(&facts), vec![1, 2]);
1366 }
1367
1368 #[test]
1369 fn declining_the_retry_prompt_terminates() {
1370 let action = FlakyAction::new(u32::MAX, transient, false);
1371 let (result, facts, confirms) = drive(ceremony_with_retry(""), action, false);
1372
1373 assert!(result.is_err());
1374 assert_eq!(confirms, 1, "prompted once, operator declined");
1375 assert_eq!(attempt_numbers(&facts), vec![1]);
1376 }
1377
1378 fn fact_kind(fact: &StepFact) -> &'static str {
1379 match fact {
1380 StepFact::CeremonyStarted { .. } => "ceremony_started",
1381 StepFact::ActStarted { .. } => "act_started",
1382 StepFact::StepStarted { .. } => "step_started",
1383 StepFact::PromptAnswered { .. } => "prompt_answered",
1384 StepFact::BackendOperation { .. } => "backend_operation",
1385 StepFact::AttestationRecorded { .. } => "attestation_recorded",
1386 StepFact::ArtifactWritten { .. } => "artifact_written",
1387 StepFact::DeviationRecorded { .. } => "deviation_recorded",
1388 StepFact::StepAttemptFailed { .. } => "step_attempt_failed",
1389 StepFact::StepCompleted { .. } => "step_completed",
1390 StepFact::CeremonyCompleted { .. } => "ceremony_completed",
1391 StepFact::CeremonyFailed { .. } => "ceremony_failed",
1392 StepFact::EntropySeeded { .. } => "entropy_seeded",
1393 StepFact::EntropyContributed { .. } => "entropy_contributed",
1394 StepFact::EntropyDrawn { .. } => "entropy_drawn",
1395 _ => "unknown",
1396 }
1397 }
1398}