Skip to main content

majordomus_cli/execution/
model.rs

1//! What an execution is: its identity, the states it may be in, and the snapshot every
2//! transport answers with. Plain data with no transport type in it and no handle to a
3//! running thread: a snapshot is taken under the store's lock and read afterwards.
4
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12/// The identity of one execution: `x-<UTC timestamp>-<8 hex>`.
13///
14/// The shape is the layer's own — a task is `t-20260905034523-a9f1` — so that an id read
15/// in a log, a URL or a browser tab is recognisable as this repository's without a
16/// dependency on a UUID crate. The timestamp orders ids by creation for a reader; the
17/// suffix, not the timestamp, is what makes two ids created in the same second differ.
18#[derive(
19    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
20)]
21#[serde(transparent)]
22pub struct ExecutionId(String);
23
24impl ExecutionId {
25    /// A fresh id for now.
26    ///
27    /// ```
28    /// use majordomus_cli::execution::ExecutionId;
29    /// let a = ExecutionId::fresh();
30    /// let b = ExecutionId::fresh();
31    /// assert_ne!(a, b, "two ids from the same second still differ");
32    /// assert!(ExecutionId::parse(a.as_str()).is_some());
33    /// ```
34    pub fn fresh() -> Self {
35        static COUNTER: AtomicU64 = AtomicU64::new(0);
36        let now = SystemTime::now()
37            .duration_since(UNIX_EPOCH)
38            .unwrap_or_default();
39        let stamp = compact_utc(now.as_secs());
40        // the suffix is not a secret and does not need to be unguessable: the server
41        // refuses a state-changing request from another origin, and an id is not a
42        // credential. It needs to be unique within a process, which a counter guarantees,
43        // and unlikely to repeat across two processes of one repository, which the
44        // sub-second part gives it.
45        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
46        let mix = (now.subsec_nanos() as u64) ^ (seq << 20) ^ (seq.wrapping_mul(0x9e37_79b9));
47        ExecutionId(format!("x-{stamp}-{:08x}", mix & 0xffff_ffff))
48    }
49
50    /// Parse an id a client sent. `None` when it is not one this executable would mint,
51    /// which is what keeps a path, a traversal or a wildcard out of a store lookup.
52    ///
53    /// ```
54    /// use majordomus_cli::execution::ExecutionId;
55    /// assert!(ExecutionId::parse("x-20260908T010203Z-0a1b2c3d").is_some());
56    /// assert!(ExecutionId::parse("../../etc/passwd").is_none());
57    /// assert!(ExecutionId::parse("x-20260908T010203Z-0a1b2c3").is_none(), "eight hex digits");
58    /// ```
59    pub fn parse(text: &str) -> Option<Self> {
60        let rest = text.strip_prefix("x-")?;
61        let (stamp, suffix) = rest.split_once('-')?;
62        let stamp_ok = stamp.len() == 16
63            && stamp.ends_with('Z')
64            && stamp.as_bytes()[8] == b'T'
65            && stamp[..8].bytes().all(|b| b.is_ascii_digit())
66            && stamp[9..15].bytes().all(|b| b.is_ascii_digit());
67        let suffix_ok = suffix.len() == 8 && suffix.bytes().all(|b| b.is_ascii_hexdigit());
68        (stamp_ok && suffix_ok).then(|| ExecutionId(text.to_string()))
69    }
70
71    /// The id as text.
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77impl std::fmt::Display for ExecutionId {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.write_str(&self.0)
80    }
81}
82
83/// `YYYYMMDDTHHMMSSZ` in UTC from a Unix second, the compact form of the layer's own
84/// timestamps.
85///
86/// ```
87/// use majordomus_cli::execution::model::compact_utc;
88/// assert_eq!(compact_utc(0), "19700101T000000Z");
89/// assert_eq!(compact_utc(1_788_000_000), "20260829T104000Z");
90/// ```
91pub fn compact_utc(secs: u64) -> String {
92    let iso = crate::peers::rfc3339(UNIX_EPOCH + std::time::Duration::from_secs(secs));
93    iso.chars().filter(|c| !matches!(c, '-' | ':')).collect()
94}
95
96/// Where an execution is in its life.
97///
98/// The transitions are the whole contract, and [`ExecutionState::may_move_to`] is the one
99/// place they are written down: the engine asks before every change, the store refuses a
100/// move it did not allow, and a client that reads a final state never sees it move again.
101///
102/// ```text
103///   queued ──► running ──┬──► succeeded
104///      │                 ├──► failed
105///      │                 └──► cancelling ──┬──► cancelled
106///      │                                   ├──► succeeded
107///      └──► cancelled                      └──► failed
108/// ```
109///
110/// There is no `starting`. An in-process engine claims an execution and enters its handler
111/// in the same instant, so a state between the two would be one no client could ever
112/// observe and every client would have to handle.
113#[derive(
114    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
115)]
116#[serde(rename_all = "snake_case")]
117pub enum ExecutionState {
118    /// Accepted, and no worker has picked it up.
119    Queued,
120    /// The handler is running.
121    Running,
122    /// Cancellation was asked for and the handler has not stopped yet.
123    Cancelling,
124    /// The handler returned an output.
125    Succeeded,
126    /// The handler returned an error, or the worker could not run it.
127    Failed,
128    /// It stopped because it was asked to.
129    Cancelled,
130}
131
132impl ExecutionState {
133    /// The word as serialised.
134    pub fn as_str(self) -> &'static str {
135        match self {
136            ExecutionState::Queued => "queued",
137            ExecutionState::Running => "running",
138            ExecutionState::Cancelling => "cancelling",
139            ExecutionState::Succeeded => "succeeded",
140            ExecutionState::Failed => "failed",
141            ExecutionState::Cancelled => "cancelled",
142        }
143    }
144
145    /// Is this the last thing that will be said about the execution?
146    ///
147    /// ```
148    /// use majordomus_cli::execution::ExecutionState;
149    /// assert!(ExecutionState::Succeeded.is_final());
150    /// assert!(!ExecutionState::Cancelling.is_final());
151    /// ```
152    pub fn is_final(self) -> bool {
153        matches!(
154            self,
155            ExecutionState::Succeeded | ExecutionState::Failed | ExecutionState::Cancelled
156        )
157    }
158
159    /// Is the execution doing work, or about to?
160    pub fn is_active(self) -> bool {
161        !self.is_final()
162    }
163
164    /// May the execution move from here to `next`?
165    ///
166    /// ```
167    /// use majordomus_cli::execution::ExecutionState::*;
168    /// assert!(Queued.may_move_to(Running));
169    /// assert!(Running.may_move_to(Cancelling));
170    /// assert!(Queued.may_move_to(Cancelled), "a queued execution is cancelled without running");
171    /// assert!(!Succeeded.may_move_to(Running), "a final state is final");
172    /// assert!(!Running.may_move_to(Queued), "nothing goes back");
173    /// ```
174    pub fn may_move_to(self, next: ExecutionState) -> bool {
175        use ExecutionState::*;
176        matches!(
177            (self, next),
178            (Queued, Running)
179                | (Queued, Cancelled)
180                | (Queued, Failed)
181                | (Running, Succeeded)
182                | (Running, Failed)
183                | (Running, Cancelling)
184                | (Cancelling, Cancelled)
185                | (Cancelling, Succeeded)
186                | (Cancelling, Failed)
187        )
188    }
189}
190
191/// Where a step of an execution stands.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
193#[serde(rename_all = "snake_case")]
194pub enum StepState {
195    /// Entered and not finished.
196    Running,
197    /// Finished, and what it was asked to do happened.
198    Completed,
199    /// Finished, and it did not.
200    Failed,
201}
202
203/// One named phase of an execution, as the handler reported it.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
205pub struct StepView {
206    /// A stable name, unique within the execution.
207    pub name: String,
208    /// One line for a reader.
209    pub title: String,
210    /// Where it stands.
211    pub state: StepState,
212    /// When it was entered, RFC 3339 in UTC.
213    pub started_at: String,
214    /// When it finished, when it has.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub finished_at: Option<String>,
217    /// What it said when it finished, when it said anything.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub detail: Option<String>,
220}
221
222/// How far along an execution is.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
224pub struct ProgressView {
225    /// Units done.
226    pub current: u64,
227    /// Units in total, when the handler knows how many.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub total: Option<u64>,
230    /// What is being done, for a reader.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub message: Option<String>,
233}
234
235impl ProgressView {
236    /// The fraction done, 0..=100, when a total is known.
237    ///
238    /// ```
239    /// use majordomus_cli::execution::ProgressView;
240    /// let p = ProgressView { current: 3, total: Some(4), message: None };
241    /// assert_eq!(p.percent(), Some(75));
242    /// let open = ProgressView { current: 3, total: None, message: None };
243    /// assert_eq!(open.percent(), None);
244    /// ```
245    pub fn percent(&self) -> Option<u64> {
246        match self.total {
247            Some(total) if total > 0 => Some((self.current.min(total) * 100) / total),
248            _ => None,
249        }
250    }
251}
252
253/// Why an execution ended badly, in the repository's diagnostic vocabulary rather than in
254/// Rust's: a code a program branches on, a sentence a person reads, and where to look.
255///
256/// A panic message and a backtrace are never in here. The engine catches a panicking
257/// handler and reports `internal` with the execution's correlation id; the panic itself is
258/// on the process's own error stream, where the operator running the server can read it.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
260pub struct ExecutionError {
261    /// `invalid_input`, `not_found`, `refused`, `forbidden`, `internal`, `cancelled`, `unavailable`.
262    pub code: String,
263    /// What went wrong, for a person.
264    pub message: String,
265    /// What to do about it, when there is something.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub suggestion: Option<String>,
268    /// The execution this failure belongs to, so a log line and a UI agree.
269    pub correlation_id: String,
270}
271
272/// A structured finding an execution reported on its way, distinct from its outcome.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
274pub struct ExecutionDiagnostic {
275    /// How bad.
276    pub severity: crate::model::Severity,
277    /// A stable machine-readable code.
278    pub code: String,
279    /// One line, for a person.
280    pub summary: String,
281    /// The rest, when there is more.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub detail: Option<String>,
284    /// What to do about it, when there is something.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub suggestion: Option<String>,
287}
288
289/// Which stream a log line came from.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
291#[serde(rename_all = "snake_case")]
292pub enum LogStream {
293    /// The standard output of a child process the handler ran.
294    Stdout,
295    /// Its standard error.
296    Stderr,
297    /// The handler itself, saying something it wants a reader to see.
298    Handler,
299}
300
301/// Who asked for the execution. Not an authorisation decision — this server authenticates
302/// nobody — but a fact worth carrying into the audit line and the UI.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
304#[serde(rename_all = "snake_case")]
305pub enum ActorKind {
306    /// An HTTP client, which includes the Cockpit.
307    Http,
308    /// An MCP client attached to this process.
309    Mcp,
310    /// A command line: this process's own, or `majordomus run` in another process of the
311    /// same checkout that reported its execution to this server, with its pid.
312    Cli,
313    /// This executable's own tests and benchmarks.
314    Internal,
315}
316
317/// Who asked, and which peer they are when this process knows.
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
319pub struct Actor {
320    /// Where the request came from.
321    pub kind: ActorKind,
322    /// The peer id, for a call that arrived through an MCP session.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub peer: Option<String>,
325    /// The process that ran it, for an execution another process ran and reported to this
326    /// one: the work happened there, and this process holds what it was told about it.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub pid: Option<u32>,
329}
330
331impl Actor {
332    /// An actor of a kind, with no peer.
333    pub fn of(kind: ActorKind) -> Self {
334        Actor {
335            kind,
336            peer: None,
337            pid: None,
338        }
339    }
340
341    /// `majordomus run` in another process, which ran the execution and reported it here.
342    ///
343    /// ```
344    /// use majordomus_cli::execution::{Actor, ActorKind};
345    /// let a = Actor::reported(4242);
346    /// assert_eq!((a.kind, a.pid), (ActorKind::Cli, Some(4242)));
347    /// ```
348    pub fn reported(pid: u32) -> Self {
349        Actor {
350            kind: ActorKind::Cli,
351            peer: None,
352            pid: Some(pid),
353        }
354    }
355}
356
357/// Which repository, and which checkout of it, an execution ran against.
358///
359/// An execution is never run against "wherever this process happens to be": the engine
360/// stamps the repository the index was read from, and a request that names a different one
361/// is refused. The path itself is not here — this value is served to whoever can reach the
362/// socket, and where the checkout sits on the host is of no use to them.
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
364pub struct RepositoryRef {
365    /// The repository's name: the last component of its root.
366    pub name: String,
367    /// The stable identity two processes over one checkout both compute.
368    pub id: String,
369    /// The branch checked out, when git can say.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub branch: Option<String>,
372}
373
374/// One execution, as every transport answers it.
375///
376/// This is the whole of what a client may know. It is produced under the store's lock, so
377/// a reader never sees a state and an output that disagree: an execution that says
378/// `succeeded` carries its output in the same snapshot, and one that says `failed` carries
379/// its error.
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
381pub struct Execution {
382    /// The identity.
383    pub id: ExecutionId,
384    /// The capability that ran, by its canonical id.
385    pub capability: String,
386    /// That capability's title, so a list needs no second lookup.
387    pub title: String,
388    /// Where it is.
389    pub state: ExecutionState,
390    /// When it was accepted, RFC 3339 in UTC.
391    pub created_at: String,
392    /// When the handler was entered.
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub started_at: Option<String>,
395    /// When it reached a final state.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub finished_at: Option<String>,
398    /// How long it ran, in milliseconds, once it has finished.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub duration_ms: Option<u64>,
401    /// The input it was given, with every value the input schema marks sensitive replaced.
402    pub input: Value,
403    /// What the handler returned, for a succeeded execution and never before.
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub output: Option<Value>,
406    /// Why it failed, for a failed execution and never before.
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub error: Option<ExecutionError>,
409    /// The last progress the handler reported.
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub progress: Option<ProgressView>,
412    /// The steps it entered, in order.
413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
414    pub steps: Vec<StepView>,
415    /// The findings it reported.
416    #[serde(default, skip_serializing_if = "Vec::is_empty")]
417    pub diagnostics: Vec<ExecutionDiagnostic>,
418    /// The sequence number of the last event: the cursor a client subscribes from.
419    pub last_sequence: u64,
420    /// How many events it has produced, including the ones no longer retained.
421    pub event_count: u64,
422    /// Whether the retained event history has lost its oldest entries to the bound.
423    pub events_truncated: bool,
424    /// Whether asking to cancel this execution will do anything: the capability's policy.
425    pub cancellable: bool,
426    /// Who asked for it.
427    pub actor: Actor,
428    /// Which repository it ran against.
429    pub repository: RepositoryRef,
430    /// The id used in this process's logs and tracing spans for this execution; the
431    /// execution id itself, carried under the name a reader of a log expects.
432    pub correlation_id: String,
433}
434
435impl Execution {
436    /// The percentage a progress bar shows, when the handler said enough to compute one.
437    pub fn percent(&self) -> Option<u64> {
438        self.progress.as_ref().and_then(ProgressView::percent)
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn an_id_round_trips_and_a_path_does_not() {
448        let id = ExecutionId::fresh();
449        assert_eq!(ExecutionId::parse(id.as_str()).as_ref(), Some(&id));
450        assert_eq!(id.to_string(), id.as_str());
451        for bad in [
452            "",
453            "x-",
454            "x--0a1b2c3d",
455            "../x-20260908T010203Z-0a1b2c3d",
456            "x-20260908T010203Z-0a1b2c3d/..",
457            "y-20260908T010203Z-0a1b2c3d",
458            "x-2026090801020Z-0a1b2c3d",
459            "x-20260908T010203Z-0a1b2c3g",
460        ] {
461            assert!(ExecutionId::parse(bad).is_none(), "{bad:?} accepted");
462        }
463    }
464
465    #[test]
466    fn ids_do_not_repeat_within_a_process() {
467        let ids: std::collections::BTreeSet<_> = (0..2000).map(|_| ExecutionId::fresh()).collect();
468        assert_eq!(ids.len(), 2000);
469    }
470
471    #[test]
472    fn every_state_is_reachable_and_no_final_state_moves() {
473        use ExecutionState::*;
474        let all = [Queued, Running, Cancelling, Succeeded, Failed, Cancelled];
475        for state in all {
476            if state.is_final() {
477                assert!(
478                    all.iter().all(|next| !state.may_move_to(*next)),
479                    "{state:?} moved"
480                );
481            } else {
482                assert!(
483                    all.iter().any(|next| state.may_move_to(*next)),
484                    "{state:?} is a dead end"
485                );
486            }
487            // every state is reachable from queued by some path
488            assert_eq!(state.is_active(), !state.is_final());
489        }
490        // the words are the wire format and are asserted rather than assumed
491        assert_eq!(
492            all.map(|s| s.as_str()),
493            [
494                "queued",
495                "running",
496                "cancelling",
497                "succeeded",
498                "failed",
499                "cancelled"
500            ]
501        );
502        for state in all {
503            let text = serde_json::to_string(&state).unwrap();
504            assert_eq!(text, format!("\"{}\"", state.as_str()));
505        }
506    }
507
508    #[test]
509    fn a_compact_timestamp_is_the_layers_own_shape() {
510        assert_eq!(compact_utc(0), "19700101T000000Z");
511        assert_eq!(compact_utc(1_788_000_000).len(), 16);
512    }
513}