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}