majordomus_cli/session/state.rs
1//! Where an episode is in its life, and the transitions that move it there.
2//!
3//! # Why this is a type and not a convention
4//!
5//! The lifecycle used to be expressed as the presence and absence of files: an episode is
6//! open because `state/sessions-open/<key>.yaml` exists, and closed because it does not and
7//! a record does. That representation has no state it refuses to be in. It cannot say that
8//! a closed episode may not be closed again — which is how one episode in this repository
9//! came to have four immutable records — and it cannot say that a transition is legal
10//! without also performing it, which is how every check of the form "is this allowed" ended
11//! up written separately at each call site, in shell, three times, with the task guard ADR
12//! 0052 removed embedded in one of them.
13//!
14//! So the transitions are the contract and [`EpisodeState::may_move_to`] is the one place
15//! they are written down: the store asks before every change, and a caller that reads a
16//! terminal state never sees it move again. The same shape as
17//! [`crate::execution::ExecutionState`], for the same reason and deliberately in the same
18//! style — a reader who has met one has met the other.
19//!
20//! ```text
21//! Opening ──► Open ──┬──► Detached ──┬──► Open (resume)
22//! │ └──► Closed (close, or recover)
23//! ├──► Open (checkpoint, repeated start: no move)
24//! └──► Closed (close, or recover)
25//! ```
26//!
27//! # What is not in the signature
28//!
29//! There is no task. [`EpisodeState::may_move_to`] takes a target state and nothing else;
30//! [`Transition`] carries no task field. This is the whole of ADR 0052 expressed as a type:
31//! the defect it removed was `outcome != active -> skip` on the path that writes an
32//! episode's artefacts, and in this model there is nowhere to put it. A task's outcome may
33//! change what a continuation record *says* — that belongs to the record's composition, not
34//! to the state machine — and it may never decide whether one is written.
35//!
36//! ```
37//! use majordomus_cli::session::{EpisodeState, Transition};
38//!
39//! // the whole machine, asked rather than drawn
40//! assert!(EpisodeState::Open.may_move_to(EpisodeState::Closed));
41//! assert!(!EpisodeState::Closed.may_move_to(EpisodeState::Open));
42//! assert_eq!(Transition::Recover.from_to(), (EpisodeState::Detached, EpisodeState::Closed));
43//! ```
44
45use schemars::JsonSchema;
46use serde::{Deserialize, Serialize};
47
48/// Where an episode is: minted, open, detached from the connection that held it, or closed
49/// into the immutable record.
50///
51/// ```
52/// use majordomus_cli::session::EpisodeState;
53///
54/// assert!(EpisodeState::Open.may_move_to(EpisodeState::Closed));
55/// assert!(!EpisodeState::Closed.may_move_to(EpisodeState::Open), "a record is immutable");
56/// assert!(EpisodeState::Open.may_move_to(EpisodeState::Open), "a resume is not a move");
57/// assert!(EpisodeState::Closed.is_final());
58/// ```
59#[derive(
60 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
61)]
62#[serde(rename_all = "snake_case")]
63pub enum EpisodeState {
64 /// An identity has been minted and nothing durable has been written yet. It exists so
65 /// that a crash between minting an id and writing the open record is a state the model
66 /// can name rather than an episode that never happened.
67 Opening,
68 /// The open record stands. The worker is here, or was recently.
69 Open,
70 /// The connection that held the episode went away, and the episode did not. A dropped
71 /// socket is the most ordinary thing that happens to a session; an episode that closed
72 /// itself on every disconnect would lose the work of every worker whose network
73 /// hiccuped. It is still resumable, and it is still recoverable if nobody resumes it.
74 Detached,
75 /// The immutable record has been published. Terminal, and terminal is the point: the
76 /// four records of `s-20260909152316-024f` all exist because nothing in the previous
77 /// representation could say this.
78 Closed,
79}
80
81impl EpisodeState {
82 /// May an episode in this state move to `target`?
83 ///
84 /// The whole transition table, and the only copy of it. Note what the signature does
85 /// not take: there is no task, no outcome and no policy. A transition of an episode is
86 /// a fact about the episode.
87 ///
88 /// ```
89 /// use majordomus_cli::session::EpisodeState::*;
90 ///
91 /// assert!(Opening.may_move_to(Open));
92 /// assert!(Open.may_move_to(Detached) && Detached.may_move_to(Open));
93 /// assert!(Open.may_move_to(Closed) && Detached.may_move_to(Closed));
94 ///
95 /// // an episode that never opened cannot be closed into a record of what it did
96 /// assert!(!Opening.may_move_to(Closed));
97 /// // and nothing leaves the terminal state
98 /// for target in [Opening, Open, Detached, Closed] {
99 /// assert!(!Closed.may_move_to(target));
100 /// }
101 /// ```
102 pub fn may_move_to(self, target: EpisodeState) -> bool {
103 use EpisodeState::*;
104 match (self, target) {
105 (Closed, _) => false,
106 (Opening, Open) => true,
107 (Opening, _) => false,
108 (Open, Open) | (Open, Detached) | (Open, Closed) => true,
109 (Open, Opening) => false,
110 (Detached, Open) | (Detached, Closed) => true,
111 (Detached, Opening) | (Detached, Detached) => false,
112 }
113 }
114
115 /// Is this a state an episode never leaves? True of `closed` alone, which is what a
116 /// store asks before it writes and a reader asks before it waits for a change.
117 ///
118 /// ```
119 /// use majordomus_cli::session::EpisodeState;
120 /// assert!(EpisodeState::Closed.is_final());
121 /// assert!(!EpisodeState::Detached.is_final(), "a detached episode can still be resumed");
122 /// ```
123 pub fn is_final(self) -> bool {
124 matches!(self, EpisodeState::Closed)
125 }
126
127 /// The word as serialised, which is the word every projection of the machine carries
128 /// and the word a person reads in `session status`.
129 ///
130 /// ```
131 /// use majordomus_cli::session::EpisodeState;
132 /// assert_eq!(EpisodeState::Detached.as_str(), "detached");
133 /// assert_eq!(EpisodeState::Closed.as_str(), "closed");
134 /// ```
135 pub fn as_str(self) -> &'static str {
136 match self {
137 EpisodeState::Opening => "opening",
138 EpisodeState::Open => "open",
139 EpisodeState::Detached => "detached",
140 EpisodeState::Closed => "closed",
141 }
142 }
143
144 /// Every state, in the order a reader meets them. The projection of the machine is
145 /// built from this rather than from a list written a second time somewhere else.
146 pub const ALL: [EpisodeState; 4] = [
147 EpisodeState::Opening,
148 EpisodeState::Open,
149 EpisodeState::Detached,
150 EpisodeState::Closed,
151 ];
152}
153
154/// What moved an episode. The vocabulary the ADR 0052 audit needs: `open`, `resume`,
155/// `checkpoint`, `detach`, `close`, `recover`.
156///
157/// One of them does not change the state, and it is a transition anyway. A checkpoint
158/// leaves an open episode open — but it is one of the two events whose *absence* is what
159/// the audit was looking for, and an event that is modelled as "nothing happened" cannot
160/// be counted. The six days of silence were six days in which `session.started` and
161/// `session.closed` kept arriving and the two events that produce the records a worker
162/// resumes from did not. A vocabulary in which those two are not events is a vocabulary in
163/// which that outage is invisible.
164///
165/// ```
166/// use majordomus_cli::session::{EpisodeState, Transition};
167///
168/// // a checkpoint is a transition that does not move the state, and says so
169/// let c = Transition::Checkpoint;
170/// assert_eq!(c.from_to(), (EpisodeState::Open, EpisodeState::Open));
171/// assert!(!c.moves_state());
172///
173/// // a resume is canonically the return to a detached episode; a provider's start event
174/// // arriving at one that is already open is the same transition seen from `Open`, which
175/// // the machine allows without a move
176/// assert_eq!(Transition::Resume.from_to(), (EpisodeState::Detached, EpisodeState::Open));
177/// assert!(EpisodeState::Open.may_move_to(EpisodeState::Open));
178///
179/// // and every transition's own move is one the machine allows
180/// for t in Transition::ALL {
181/// let (from, to) = t.from_to();
182/// assert!(from.may_move_to(to), "{} is not a legal move", t.as_str());
183/// }
184/// ```
185#[derive(
186 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
187)]
188#[serde(rename_all = "snake_case")]
189pub enum Transition {
190 /// The episode begins: an id is minted and the open record is written.
191 Open,
192 /// The worker came back to a detached episode. A provider's start event also fires
193 /// again on a resume and on a compaction against an episode that is still open; that
194 /// is the same transition observed from `Open`, and the honest answer to "your episode
195 /// is already open" is to keep it rather than to open a second one.
196 Resume,
197 /// A progress note was recorded against the episode. An artefact of the episode and
198 /// not of a task (ADR 0052): a checkpoint outside a task records `task: none` and is a
199 /// normal record, not a refusal.
200 Checkpoint,
201 /// The connection went away without an end event.
202 Detach,
203 /// The worker ended the episode and the immutable record was published.
204 Close,
205 /// Nobody ended it and its last sign of life is older than the stranded threshold, so
206 /// maintenance closed it. The record says so: an episode closed this way is
207 /// `interrupted`, because a reader resuming from it needs to know it may be incomplete.
208 Recover,
209}
210
211impl Transition {
212 /// The states this transition runs between: where an episode must be for it to apply,
213 /// and where it is afterwards.
214 ///
215 /// ```
216 /// use majordomus_cli::session::{EpisodeState, Transition};
217 /// assert_eq!(Transition::Close.from_to(), (EpisodeState::Open, EpisodeState::Closed));
218 /// assert_eq!(Transition::Detach.from_to(), (EpisodeState::Open, EpisodeState::Detached));
219 /// ```
220 pub fn from_to(self) -> (EpisodeState, EpisodeState) {
221 use EpisodeState as S;
222 match self {
223 Transition::Open => (S::Opening, S::Open),
224 Transition::Resume => (S::Detached, S::Open),
225 Transition::Checkpoint => (S::Open, S::Open),
226 Transition::Detach => (S::Open, S::Detached),
227 Transition::Close => (S::Open, S::Closed),
228 Transition::Recover => (S::Detached, S::Closed),
229 }
230 }
231
232 /// Does this transition change the state, or is it an event inside one? A checkpoint
233 /// is the second kind, and it is a transition anyway: an event modelled as "nothing
234 /// happened" cannot be counted, and counting it is how a stopped writer becomes visible.
235 ///
236 /// ```
237 /// use majordomus_cli::session::Transition;
238 /// assert!(!Transition::Checkpoint.moves_state());
239 /// assert!(Transition::Close.moves_state());
240 /// ```
241 pub fn moves_state(self) -> bool {
242 let (from, to) = self.from_to();
243 from != to
244 }
245
246 /// The word as serialised, which is what a projection of the machine carries and what
247 /// a ledger event's name is read against.
248 ///
249 /// ```
250 /// use majordomus_cli::session::Transition;
251 /// assert_eq!(Transition::Recover.as_str(), "recover");
252 /// assert_eq!(Transition::Checkpoint.as_str(), "checkpoint");
253 /// ```
254 pub fn as_str(self) -> &'static str {
255 match self {
256 Transition::Open => "open",
257 Transition::Resume => "resume",
258 Transition::Checkpoint => "checkpoint",
259 Transition::Detach => "detach",
260 Transition::Close => "close",
261 Transition::Recover => "recover",
262 }
263 }
264
265 /// Every transition, in lifecycle order.
266 pub const ALL: [Transition; 6] = [
267 Transition::Open,
268 Transition::Resume,
269 Transition::Checkpoint,
270 Transition::Detach,
271 Transition::Close,
272 Transition::Recover,
273 ];
274}
275
276/// The state machine as data, so that every surface projects the same machine instead of
277/// drawing its own.
278///
279/// ```
280/// use majordomus_cli::session::Machine;
281///
282/// let m = Machine::describe();
283/// assert!(m.states.iter().filter(|s| s.terminal).count() == 1);
284/// assert!(m.independent_of.iter().any(|note| note.contains("task")));
285/// ```
286///
287/// This is what `session.machine` answers. It exists because the same diagram is currently
288/// redrawn in `docs/CONTINUITY.md`, in `lib/session.sh`'s comments and in the Cockpit, and
289/// three drawings of one machine drift — the `lib/session.sh` one still had the task guard
290/// in it after ADR 0052 removed it from the code.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
292pub struct Machine {
293 /// Every state, with what it means and whether it is terminal.
294 pub states: Vec<StateView>,
295 /// Every transition, with the states it runs between.
296 pub transitions: Vec<TransitionView>,
297 /// What the machine deliberately does not depend on, stated so that a reader does not
298 /// have to infer an absence. One entry today: task state.
299 pub independent_of: Vec<String>,
300}
301
302/// One state of the machine: its word, whether an episode ever leaves it, and where it may
303/// go from there.
304///
305/// ```
306/// use majordomus_cli::session::{EpisodeState, Machine, StateView};
307///
308/// let m = Machine::describe();
309/// let closed: &StateView =
310/// m.states.iter().find(|s| s.state == EpisodeState::Closed).expect("a state");
311/// assert!(closed.terminal && closed.may_move_to.is_empty());
312/// ```
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
314pub struct StateView {
315 /// The state's word.
316 pub state: EpisodeState,
317 /// Whether an episode ever leaves it.
318 pub terminal: bool,
319 /// The states an episode in this one may move to, in canonical order.
320 pub may_move_to: Vec<EpisodeState>,
321}
322
323/// One transition of the machine: its word, the states it runs between, and whether it
324/// moves the state at all.
325///
326/// ```
327/// use majordomus_cli::session::{Machine, Transition, TransitionView};
328///
329/// let m = Machine::describe();
330/// let close: &TransitionView = m
331/// .transitions
332/// .iter()
333/// .find(|t| t.transition == Transition::Close)
334/// .expect("a transition");
335/// assert!(close.moves_state);
336/// ```
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
338pub struct TransitionView {
339 /// The transition's word.
340 pub transition: Transition,
341 /// Where it starts.
342 pub from: EpisodeState,
343 /// Where it ends.
344 pub to: EpisodeState,
345 /// False for `resume` and `checkpoint`, which are events inside a state.
346 pub moves_state: bool,
347}
348
349impl Machine {
350 /// The machine, derived from the two enums above and from nothing else.
351 ///
352 /// ```
353 /// use majordomus_cli::session::{EpisodeState, Machine, Transition};
354 ///
355 /// let m = Machine::describe();
356 /// assert_eq!(m.states.len(), EpisodeState::ALL.len());
357 /// assert_eq!(m.transitions.len(), Transition::ALL.len());
358 /// assert!(m.states.iter().filter(|s| s.terminal).count() == 1, "one terminal state");
359 /// assert!(m.independent_of.iter().any(|s| s.contains("task")));
360 /// ```
361 pub fn describe() -> Self {
362 let states = EpisodeState::ALL
363 .iter()
364 .map(|state| StateView {
365 state: *state,
366 terminal: state.is_final(),
367 may_move_to: EpisodeState::ALL
368 .iter()
369 .copied()
370 .filter(|t| state.may_move_to(*t))
371 .collect(),
372 })
373 .collect();
374 let transitions = Transition::ALL
375 .iter()
376 .map(|t| {
377 let (from, to) = t.from_to();
378 TransitionView {
379 transition: *t,
380 from,
381 to,
382 moves_state: t.moves_state(),
383 }
384 })
385 .collect();
386 Machine {
387 states,
388 transitions,
389 independent_of: vec![
390 "task state: an episode's lifecycle does not depend on a task's outcome, \
391 and may_move_to takes no task (ADR 0052)"
392 .to_string(),
393 ],
394 }
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn the_terminal_state_is_terminal_from_everywhere() {
404 for target in EpisodeState::ALL {
405 assert!(
406 !EpisodeState::Closed.may_move_to(target),
407 "closed -> {} is reachable",
408 target.as_str()
409 );
410 }
411 assert_eq!(
412 EpisodeState::ALL
413 .iter()
414 .filter(|s| s.is_final())
415 .collect::<Vec<_>>()
416 .len(),
417 1
418 );
419 }
420
421 #[test]
422 fn every_state_except_the_terminal_one_reaches_closed() {
423 // The property that matters for recovery: an episode nobody ended must always be
424 // closable. `Opening` reaches it through `Open`.
425 assert!(EpisodeState::Open.may_move_to(EpisodeState::Closed));
426 assert!(EpisodeState::Detached.may_move_to(EpisodeState::Closed));
427 assert!(EpisodeState::Opening.may_move_to(EpisodeState::Open));
428 }
429
430 #[test]
431 fn the_machine_is_derived_and_carries_no_second_list() {
432 let m = Machine::describe();
433 let words: Vec<&str> = m.states.iter().map(|s| s.state.as_str()).collect();
434 assert_eq!(words, ["opening", "open", "detached", "closed"]);
435 for view in &m.states {
436 assert_eq!(view.terminal, view.may_move_to.is_empty());
437 }
438 }
439
440 #[test]
441 fn a_resume_and_a_checkpoint_are_events_and_not_moves() {
442 assert!(!Transition::Checkpoint.moves_state());
443 assert!(
444 Transition::Resume.moves_state(),
445 "from detached it is a move"
446 );
447 assert!(Transition::Close.moves_state());
448 assert!(Transition::Recover.moves_state());
449 }
450}