1use 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
61fn 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#[derive(Debug, Error)]
84pub enum ActionError {
85 #[error("ceremony aborted by operator")]
88 Aborted,
89 #[error("frontend channel disconnected")]
91 Disconnected,
92 #[error("transcript write failed: {0}")]
94 Transcript(std::io::Error),
95 #[error("backend error: {0}")]
97 Backend(#[from] BackendError),
98 #[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
117pub 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
148pub trait Action: Send + Sync {
155 fn metadata(&self) -> ActionMetadata;
157
158 fn apply_defaults(&self, _params: &mut serde_json::Value, _step: &StepInfo) {}
160
161 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#[derive(Default)]
179pub struct ActionRegistry {
180 actions: HashMap<ActionType, Arc<dyn Action>>,
181}
182
183impl ActionRegistry {
184 #[must_use]
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 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 #[must_use]
198 pub fn get(&self, action: &ActionType) -> Option<&Arc<dyn Action>> {
199 self.actions.get(action)
200 }
201
202 pub fn action_types(&self) -> impl Iterator<Item = &ActionType> {
204 self.actions.keys()
205 }
206
207 #[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#[derive(Debug)]
225pub struct ExecutionSummary {
226 pub ceremony_name: String,
228 pub steps_completed: usize,
230 pub transcript_fingerprint: TranscriptFingerprint,
232}
233
234pub 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 #[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 #[must_use]
274 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
275 self.clock = clock;
276 self
277 }
278
279 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 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 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 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 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 reporter.signal(UiSignal::SystemInfo(Box::new(self.startup.system.clone())))?;
422 reporter.signal(UiSignal::Environment(self.startup.environment.clone()))?;
423
424 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 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 let plan = std::mem::take(&mut self.ceremony.execution_plan);
451 for step in &plan {
452 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 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 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, ¶ms, 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 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 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 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
636fn 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 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 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 #[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, StartupSnapshot::placeholder(),
849 );
850
851 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(event_tx);
912 drop(cmd_tx);
913 let trace = frontend.join().expect("frontend join");
914
915 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 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}