nixfleet_state_machine/transitions/
failed.rs

1//! Transitions from `Failed`. Legal events:
2//!
3//! - `LocalRollbackCompleted` — agent has executed rollback per manifest
4//!   policy; drives `Failed → Reverted`. Single signed source of truth is
5//!   the manifest; CP issued no signal (RFC-0005 §4.1).
6//! - `RemoteRollbackComplete` — CP mirror sees the same; emits
7//!   `RemoteInsertQuarantine` for the bad closure on the channel.
8//!
9//! For `halt-only` policy, `Failed` is terminal — no rollback event
10//! arrives, operator action lifts it via a new signed manifest.
11
12use chrono::{DateTime, Utc};
13use nixfleet_proto::RolloutPolicy;
14
15use crate::effect::{Effect, OutboundAgentEvent};
16use crate::error::TransitionError;
17use crate::event::Event;
18use crate::state::{HostRolloutState, HostState};
19
20use super::illegal;
21
22pub(super) fn handle(
23    mut state: HostRolloutState,
24    event: Event,
25    _now: DateTime<Utc>,
26    _policy: &RolloutPolicy,
27) -> Result<(HostRolloutState, Vec<Effect>), TransitionError> {
28    match event {
29        Event::LocalRollbackCompleted {
30            reverted_to_closure,
31            exit_code,
32            completed_at,
33            seq,
34        } => {
35            let from = state.state;
36            state.state = HostState::Reverted;
37            state.reverted_at = Some(completed_at);
38            state.reverted_to = Some(reverted_to_closure.clone());
39            state.current_closure = Some(reverted_to_closure.clone());
40            state.last_event_seq = seq;
41
42            let effects = vec![
43                Effect::LocalEmitEvent {
44                    rollout_id: state.rollout_id.clone(),
45                    payload: OutboundAgentEvent::RollbackComplete {
46                        reverted_to_closure,
47                        exit_code,
48                        completed_at,
49                        seq,
50                    },
51                    durable: true,
52                },
53                Effect::RecordTransition {
54                    host: state.hostname.clone(),
55                    rollout_id: state.rollout_id.clone(),
56                    from,
57                    to: HostState::Reverted,
58                    at: completed_at,
59                },
60            ];
61            Ok((state, effects))
62        }
63        Event::RemoteRollbackComplete {
64            reverted_to_closure,
65            exit_code,
66            completed_at,
67            seq,
68        } => {
69            let from = state.state;
70            let bad_closure = state.target_closure.clone();
71            let channel = state.channel.clone();
72            state.state = HostState::Reverted;
73            state.reverted_at = Some(completed_at);
74            state.reverted_to = Some(reverted_to_closure.clone());
75            state.current_closure = Some(reverted_to_closure.clone());
76            state.last_event_seq = seq;
77
78            let effects = vec![
79                Effect::RemoteAppendEventLog {
80                    host: state.hostname.clone(),
81                    rollout_id: state.rollout_id.clone(),
82                    payload: OutboundAgentEvent::RollbackComplete {
83                        reverted_to_closure,
84                        exit_code,
85                        completed_at,
86                        seq,
87                    },
88                },
89                Effect::RemoteInsertQuarantine {
90                    channel,
91                    closure: bad_closure,
92                },
93                Effect::RecordTransition {
94                    host: state.hostname.clone(),
95                    rollout_id: state.rollout_id.clone(),
96                    from,
97                    to: HostState::Reverted,
98                    at: completed_at,
99                },
100            ];
101            Ok((state, effects))
102        }
103
104        other => Err(illegal(&state, &other)),
105    }
106}