Skip to main content

tmprl_core/
workflow.rs

1//! The workflow domain model.
2//!
3//! These types live here rather than in `tmprl-client` because logic hangs off them,
4//! status ordering, relative ages, merge-sorting a multi-namespace fan-out, re-finding the
5//! cursor after a refresh. All of that is computable without a server or a terminal, so it
6//! belongs in the crate that needs neither to be tested. `tmprl-client` maps protobuf into
7//! these; nothing above it ever sees a generated type.
8
9use std::cmp::Ordering;
10
11/// Execution status, mirroring `temporal.api.enums.v1.WorkflowExecutionStatus`.
12///
13/// This is a hand-written mirror rather than a re-export so that `tmprl-core` stays free of
14/// the generated protos. The conversion in `tmprl-client` matches on the proto enum
15/// exhaustively, so a status added by Temporal is a compile error there, not a blank cell
16/// here.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
18pub enum WorkflowStatus {
19    #[default]
20    Unspecified,
21    Running,
22    Completed,
23    Failed,
24    Canceled,
25    Terminated,
26    ContinuedAsNew,
27    TimedOut,
28    Paused,
29}
30
31impl WorkflowStatus {
32    /// Every status, in the order the header renders them: the ones an operator is looking
33    /// for first. "How many are broken" is the question people open this screen to answer.
34    pub const DISPLAY_ORDER: [WorkflowStatus; 9] = [
35        WorkflowStatus::Running,
36        WorkflowStatus::Failed,
37        WorkflowStatus::TimedOut,
38        WorkflowStatus::Terminated,
39        WorkflowStatus::Canceled,
40        WorkflowStatus::Completed,
41        WorkflowStatus::ContinuedAsNew,
42        WorkflowStatus::Paused,
43        WorkflowStatus::Unspecified,
44    ];
45
46    /// The name Temporal uses in a visibility query, and in the `GROUP BY` payloads that
47    /// `CountWorkflowExecutions` returns. Round-trips with [`WorkflowStatus::parse`].
48    pub fn query_name(self) -> &'static str {
49        match self {
50            WorkflowStatus::Unspecified => "Unspecified",
51            WorkflowStatus::Running => "Running",
52            WorkflowStatus::Completed => "Completed",
53            WorkflowStatus::Failed => "Failed",
54            WorkflowStatus::Canceled => "Canceled",
55            WorkflowStatus::Terminated => "Terminated",
56            WorkflowStatus::ContinuedAsNew => "ContinuedAsNew",
57            WorkflowStatus::TimedOut => "TimedOut",
58            WorkflowStatus::Paused => "Paused",
59        }
60    }
61
62    /// A glyph, so status is legible without colour. `NO_COLOR`, a 16-colour terminal and a
63    /// colour-blind reader all get the same information as everyone else.
64    pub fn glyph(self) -> char {
65        match self {
66            WorkflowStatus::Unspecified => '?',
67            WorkflowStatus::Running => '●',
68            WorkflowStatus::Completed => '✓',
69            WorkflowStatus::Failed => '✗',
70            WorkflowStatus::Canceled => '⊘',
71            WorkflowStatus::Terminated => '■',
72            WorkflowStatus::ContinuedAsNew => '↻',
73            WorkflowStatus::TimedOut => '◔',
74            WorkflowStatus::Paused => '‖',
75        }
76    }
77
78    /// Parse the name Temporal returns. Accepts the query spelling (`ContinuedAsNew`) and
79    /// the proto spelling (`WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW`), because the two
80    /// arrive from different RPCs and it is not worth making callers care which.
81    pub fn parse(s: &str) -> Option<Self> {
82        let t = s.trim().trim_matches('"');
83        let squashed: String = t
84            .chars()
85            .filter(|c| c.is_ascii_alphanumeric())
86            .map(|c| c.to_ascii_lowercase())
87            .collect();
88        let squashed = squashed
89            .strip_prefix("workflowexecutionstatus")
90            .unwrap_or(&squashed);
91        WorkflowStatus::DISPLAY_ORDER
92            .iter()
93            .copied()
94            .find(|s| {
95                let name: String = s
96                    .query_name()
97                    .chars()
98                    .map(|c| c.to_ascii_lowercase())
99                    .collect();
100                name == squashed
101            })
102            .filter(|_| !squashed.is_empty())
103    }
104
105    /// Whether the execution is still open. Closed workflows never change again, which is
106    /// what lets the list cache them across a refresh.
107    pub fn is_running(self) -> bool {
108        matches!(self, WorkflowStatus::Running | WorkflowStatus::Paused)
109    }
110}
111
112/// One row of the workflow table.
113///
114/// `namespace` is carried on the row rather than held once for the whole list because a
115/// multi-namespace fan-out merges rows from several namespaces into one table, and a row
116/// that cannot say where it came from is not actionable.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct WorkflowRow {
119    pub namespace: String,
120    pub workflow_id: String,
121    pub run_id: String,
122    pub workflow_type: String,
123    pub task_queue: String,
124    pub status: WorkflowStatus,
125    /// Epoch milliseconds. `None` when the server did not set it, which is rare but legal.
126    pub start_time: Option<i64>,
127    pub close_time: Option<i64>,
128    pub history_length: i64,
129}
130
131impl WorkflowRow {
132    /// Identity of a row across refreshes and across namespaces.
133    ///
134    /// A run id is unique within a namespace but not across a fan-out, so the key is the
135    /// pair. This is what the cursor is anchored to. See [`find_by_key`].
136    pub fn key(&self) -> (&str, &str) {
137        (self.namespace.as_str(), self.run_id.as_str())
138    }
139}
140
141/// Newest first, which is the order the workflow list is read in.
142///
143/// Ties break on the row key so the order is total: a merge of several namespaces that
144/// started workflows in the same millisecond must not shuffle between refreshes.
145pub fn by_start_time_desc(a: &WorkflowRow, b: &WorkflowRow) -> Ordering {
146    b.start_time
147        .cmp(&a.start_time)
148        .then_with(|| a.namespace.cmp(&b.namespace))
149        .then_with(|| a.run_id.cmp(&b.run_id))
150}
151
152/// Merge per-namespace pages into one table, newest first.
153///
154/// This sorts rather than merges pre-sorted runs, because there is nothing to merge: the
155/// server does not order `ListWorkflowExecutions`, and standard visibility rejects an
156/// `ORDER BY` clause. Ordering is entirely tmprl's job. See [`WorkflowList`].
157pub fn merge_by_start_time(pages: Vec<Vec<WorkflowRow>>) -> Vec<WorkflowRow> {
158    let mut all: Vec<WorkflowRow> = pages.into_iter().flatten().collect();
159    all.sort_by(by_start_time_desc);
160    all
161}
162
163/// Where a row with this key ended up after a refresh.
164///
165/// The workflow list is live: rows appear above the cursor while you are reading, so a
166/// cursor stored as a row index silently points at a different workflow a second later.
167/// Anchoring to the key and re-finding it is the whole fix.
168pub fn find_by_key(rows: &[WorkflowRow], key: (&str, &str)) -> Option<usize> {
169    rows.iter().position(|r| r.key() == key)
170}
171
172/// Counts per status for the list header, from `CountWorkflowExecutions ... GROUP BY`.
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct StatusCounts {
175    /// The server's total. Grouped counts are approximate and may sum to less than this,
176    /// which is documented Temporal behaviour, so the total is kept separately rather than
177    /// derived.
178    pub total: i64,
179    counts: Vec<(WorkflowStatus, i64)>,
180}
181
182impl StatusCounts {
183    pub fn new(total: i64, counts: impl IntoIterator<Item = (WorkflowStatus, i64)>) -> Self {
184        let mut counts: Vec<(WorkflowStatus, i64)> = counts.into_iter().collect();
185        counts.sort_by_key(|(s, _)| {
186            WorkflowStatus::DISPLAY_ORDER
187                .iter()
188                .position(|d| d == s)
189                .unwrap_or(usize::MAX)
190        });
191        Self { total, counts }
192    }
193
194    /// Non-zero counts, in display order.
195    pub fn iter(&self) -> impl Iterator<Item = (WorkflowStatus, i64)> + '_ {
196        self.counts.iter().copied().filter(|(_, n)| *n > 0)
197    }
198
199    pub fn get(&self, status: WorkflowStatus) -> i64 {
200        self.counts
201            .iter()
202            .find(|(s, _)| *s == status)
203            .map(|(_, n)| *n)
204            .unwrap_or(0)
205    }
206}
207
208/// A short, fixed-width age like `4s`, `12m`, `3h`, `9d`.
209///
210/// The workflow list is a dense table on a terminal that may be 80 columns wide, so this
211/// trades precision for a column that never wraps. Exact timestamps belong in the detail
212/// view, where there is room for them.
213pub fn humanize_age_ms(millis: i64) -> String {
214    if millis < 0 {
215        // Clock skew between the server and this machine. Better to show `0s` than a
216        // negative age that looks like a bug in the table.
217        return "0s".into();
218    }
219    let secs = millis / 1000;
220    match secs {
221        s if s < 60 => format!("{s}s"),
222        s if s < 3600 => format!("{}m", s / 60),
223        s if s < 86_400 => format!("{}h", s / 3600),
224        s => format!("{}d", s / 86_400),
225    }
226}
227
228/// The workflow table as it accumulates pages.
229///
230/// Infinite scroll appends pages; a refresh or a new query replaces them. Two properties
231/// have to hold no matter which happened:
232///
233/// * **Sorted newest-first.** The server does not order `ListWorkflowExecutions`, and the
234///   dev server's standard visibility store rejects `ORDER BY` outright, so the ordering is
235///   this type's job rather than the query's.
236/// * **No duplicates.** Pages are snapshots of a set that is changing underneath them, so
237///   the same execution can legitimately arrive on two pages. A table that shows a workflow
238///   twice makes the operator doubt the whole screen.
239#[derive(Debug, Clone, Default)]
240pub struct WorkflowList {
241    rows: Vec<WorkflowRow>,
242    tokens: Vec<(String, Vec<u8>)>,
243}
244
245impl WorkflowList {
246    pub fn rows(&self) -> &[WorkflowRow] {
247        &self.rows
248    }
249
250    pub fn len(&self) -> usize {
251        self.rows.len()
252    }
253
254    pub fn is_empty(&self) -> bool {
255        self.rows.is_empty()
256    }
257
258    /// Per-namespace continuation tokens to send with the next page request.
259    pub fn tokens(&self) -> &[(String, Vec<u8>)] {
260        &self.tokens
261    }
262
263    /// Whether any namespace still has pages left.
264    pub fn has_more(&self) -> bool {
265        !self.tokens.is_empty()
266    }
267
268    /// Start over: a new query, or a refresh of the current one.
269    pub fn reset(&mut self, rows: Vec<WorkflowRow>, tokens: Vec<(String, Vec<u8>)>) {
270        self.rows.clear();
271        self.tokens = tokens;
272        self.insert_sorted(rows);
273    }
274
275    /// Add the next page.
276    pub fn append(&mut self, rows: Vec<WorkflowRow>, tokens: Vec<(String, Vec<u8>)>) {
277        self.tokens = tokens;
278        self.insert_sorted(rows);
279    }
280
281    /// Where the row with this key sits now, if it is still listed.
282    pub fn position_of(&self, key: (&str, &str)) -> Option<usize> {
283        find_by_key(&self.rows, key)
284    }
285
286    fn insert_sorted(&mut self, rows: Vec<WorkflowRow>) {
287        self.rows.extend(rows);
288        self.rows.sort_by(by_start_time_desc);
289        // `by_start_time_desc` breaks ties on the key, so duplicates are adjacent.
290        self.rows.dedup_by(|a, b| a.key() == b.key());
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn row(ns: &str, run: &str, start: Option<i64>) -> WorkflowRow {
299        WorkflowRow {
300            namespace: ns.into(),
301            workflow_id: format!("wf-{run}"),
302            run_id: run.into(),
303            workflow_type: "T".into(),
304            task_queue: "q".into(),
305            status: WorkflowStatus::Running,
306            start_time: start,
307            close_time: None,
308            history_length: 3,
309        }
310    }
311
312    #[test]
313    fn status_names_round_trip() {
314        for s in WorkflowStatus::DISPLAY_ORDER {
315            assert_eq!(WorkflowStatus::parse(s.query_name()), Some(s));
316        }
317    }
318
319    #[test]
320    fn status_parses_both_spellings_temporal_uses() {
321        // `GROUP BY` payloads arrive quoted, and the proto spelling turns up in errors.
322        assert_eq!(
323            WorkflowStatus::parse("\"ContinuedAsNew\""),
324            Some(WorkflowStatus::ContinuedAsNew)
325        );
326        assert_eq!(
327            WorkflowStatus::parse("WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW"),
328            Some(WorkflowStatus::ContinuedAsNew)
329        );
330        assert_eq!(
331            WorkflowStatus::parse("  running  "),
332            Some(WorkflowStatus::Running)
333        );
334        assert_eq!(WorkflowStatus::parse("nonsense"), None);
335        assert_eq!(WorkflowStatus::parse(""), None);
336    }
337
338    #[test]
339    fn every_status_has_a_distinct_glyph() {
340        // Two statuses sharing a glyph would make the column ambiguous for exactly the
341        // readers the glyph column exists for.
342        let mut g: Vec<char> = WorkflowStatus::DISPLAY_ORDER
343            .iter()
344            .map(|s| s.glyph())
345            .collect();
346        g.sort_unstable();
347        let before = g.len();
348        g.dedup();
349        assert_eq!(before, g.len(), "duplicate status glyph");
350    }
351
352    #[test]
353    fn display_order_covers_every_status() {
354        // A status missing here would be silently dropped from the header counts.
355        assert_eq!(
356            WorkflowStatus::DISPLAY_ORDER.len(),
357            9,
358            "DISPLAY_ORDER must list every WorkflowStatus variant"
359        );
360        let mut seen = WorkflowStatus::DISPLAY_ORDER.to_vec();
361        seen.sort_unstable();
362        seen.dedup();
363        assert_eq!(seen.len(), 9, "DISPLAY_ORDER repeats a status");
364    }
365
366    #[test]
367    fn merge_orders_newest_first_across_namespaces() {
368        let merged = merge_by_start_time(vec![
369            vec![row("a", "a2", Some(200)), row("a", "a1", Some(100))],
370            vec![row("b", "b3", Some(300)), row("b", "b0", Some(50))],
371        ]);
372        let ids: Vec<&str> = merged.iter().map(|r| r.run_id.as_str()).collect();
373        assert_eq!(ids, ["b3", "a2", "a1", "b0"]);
374    }
375
376    #[test]
377    fn merge_is_stable_when_start_times_collide() {
378        // Same millisecond, different namespaces: the order must not depend on which page
379        // happened to arrive first, or rows shuffle under the cursor on every refresh.
380        let one = merge_by_start_time(vec![
381            vec![row("b", "x", Some(100))],
382            vec![row("a", "y", Some(100))],
383        ]);
384        let two = merge_by_start_time(vec![
385            vec![row("a", "y", Some(100))],
386            vec![row("b", "x", Some(100))],
387        ]);
388        assert_eq!(one, two);
389    }
390
391    #[test]
392    fn rows_without_a_start_time_sort_last() {
393        let merged = merge_by_start_time(vec![
394            [row("a", "none", None), row("a", "has", Some(10))].into(),
395        ]);
396        assert_eq!(merged[0].run_id, "has");
397    }
398
399    #[test]
400    fn the_cursor_follows_its_run_id_when_rows_shift() {
401        let before = [row("a", "r1", Some(100)), row("a", "r2", Some(90))];
402        let key = before[0].key();
403        let key = (key.0.to_string(), key.1.to_string());
404
405        // A newer workflow arrives at the top, pushing everything down one row.
406        let after = vec![
407            row("a", "r0", Some(110)),
408            row("a", "r1", Some(100)),
409            row("a", "r2", Some(90)),
410        ];
411        assert_eq!(
412            find_by_key(&after, (&key.0, &key.1)),
413            Some(1),
414            "the cursor must follow the run id, not stay on index 0"
415        );
416    }
417
418    #[test]
419    fn a_vanished_row_reports_no_position() {
420        let rows = vec![row("a", "r1", Some(100))];
421        assert_eq!(find_by_key(&rows, ("a", "gone")), None);
422        // A run id from another namespace must not match.
423        assert_eq!(find_by_key(&rows, ("other", "r1")), None);
424    }
425
426    #[test]
427    fn counts_render_in_display_order_and_skip_zeroes() {
428        let c = StatusCounts::new(
429            10,
430            [
431                (WorkflowStatus::Completed, 6),
432                (WorkflowStatus::Running, 3),
433                (WorkflowStatus::Failed, 1),
434                (WorkflowStatus::Canceled, 0),
435            ],
436        );
437        let got: Vec<_> = c.iter().map(|(s, n)| (s.query_name(), n)).collect();
438        assert_eq!(got, [("Running", 3), ("Failed", 1), ("Completed", 6)]);
439        assert_eq!(c.total, 10);
440        assert_eq!(c.get(WorkflowStatus::Failed), 1);
441        assert_eq!(c.get(WorkflowStatus::TimedOut), 0);
442    }
443
444    #[test]
445    fn ages_are_short_enough_for_a_narrow_column() {
446        assert_eq!(humanize_age_ms(4_000), "4s");
447        assert_eq!(humanize_age_ms(59_999), "59s");
448        assert_eq!(humanize_age_ms(60_000), "1m");
449        assert_eq!(humanize_age_ms(3_600_000), "1h");
450        assert_eq!(humanize_age_ms(86_400_000), "1d");
451        // Server clock ahead of ours must not render as a negative age.
452        assert_eq!(humanize_age_ms(-5_000), "0s");
453    }
454}
455
456#[cfg(test)]
457mod list_tests {
458    use super::*;
459
460    fn row(ns: &str, run: &str, start: i64) -> WorkflowRow {
461        WorkflowRow {
462            namespace: ns.into(),
463            workflow_id: format!("wf-{run}"),
464            run_id: run.into(),
465            workflow_type: "T".into(),
466            task_queue: "q".into(),
467            status: WorkflowStatus::Running,
468            start_time: Some(start),
469            close_time: None,
470            history_length: 1,
471        }
472    }
473
474    fn ids(list: &WorkflowList) -> Vec<&str> {
475        list.rows().iter().map(|r| r.run_id.as_str()).collect()
476    }
477
478    #[test]
479    fn an_empty_list_has_nothing_more_to_fetch() {
480        let list = WorkflowList::default();
481        assert!(list.is_empty() && !list.has_more() && list.rows().is_empty());
482    }
483
484    #[test]
485    fn appended_pages_stay_sorted_newest_first() {
486        // The server returns pages in no particular order, so a later page routinely
487        // contains rows that belong above rows already on screen.
488        let mut list = WorkflowList::default();
489        list.reset(vec![row("a", "r2", 200)], vec![("a".into(), vec![1])]);
490        list.append(vec![row("a", "r3", 300), row("a", "r1", 100)], vec![]);
491
492        assert_eq!(ids(&list), ["r3", "r2", "r1"]);
493        assert!(!list.has_more(), "an empty token list ends the scroll");
494    }
495
496    #[test]
497    fn a_row_arriving_on_two_pages_is_listed_once() {
498        // Pages are snapshots of a set that changes underneath them, so overlap is normal.
499        let mut list = WorkflowList::default();
500        list.reset(vec![row("a", "r1", 100)], vec![("a".into(), vec![1])]);
501        list.append(vec![row("a", "r1", 100), row("a", "r0", 50)], vec![]);
502        assert_eq!(ids(&list), ["r1", "r0"]);
503    }
504
505    #[test]
506    fn the_same_run_id_in_two_namespaces_is_two_rows() {
507        // Run ids are unique per namespace, not globally: deduplicating on the run id
508        // alone would silently hide a row in a fan-out.
509        let mut list = WorkflowList::default();
510        list.reset(
511            vec![row("a", "shared", 100), row("b", "shared", 90)],
512            vec![],
513        );
514        assert_eq!(list.len(), 2);
515    }
516
517    #[test]
518    fn reset_drops_the_previous_query_s_rows() {
519        let mut list = WorkflowList::default();
520        list.reset(vec![row("a", "old", 100)], vec![("a".into(), vec![1])]);
521        list.reset(vec![row("a", "new", 200)], vec![]);
522        assert_eq!(ids(&list), ["new"]);
523        assert!(
524            !list.has_more(),
525            "reset must clear the old continuation token"
526        );
527    }
528
529    #[test]
530    fn the_cursor_key_survives_a_page_landing_above_it() {
531        let mut list = WorkflowList::default();
532        list.reset(vec![row("a", "r1", 100)], vec![("a".into(), vec![1])]);
533        assert_eq!(list.position_of(("a", "r1")), Some(0));
534
535        list.append(vec![row("a", "r9", 900)], vec![]);
536        assert_eq!(
537            list.position_of(("a", "r1")),
538            Some(1),
539            "the anchored row moved down; its key must still find it"
540        );
541        assert_eq!(list.position_of(("a", "gone")), None);
542    }
543
544    #[test]
545    fn tokens_track_which_namespaces_still_have_pages() {
546        let mut list = WorkflowList::default();
547        list.reset(
548            vec![row("a", "r1", 100)],
549            vec![("a".into(), vec![1]), ("b".into(), vec![2])],
550        );
551        assert!(list.has_more());
552        assert_eq!(list.tokens().len(), 2);
553
554        // Namespace `a` exhausts; `b` still has pages.
555        list.append(vec![row("b", "r2", 90)], vec![("b".into(), vec![3])]);
556        assert_eq!(list.tokens(), &[("b".to_string(), vec![3])]);
557        assert!(list.has_more());
558    }
559}