1use std::cmp::Ordering;
10
11#[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 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 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 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 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 pub fn is_running(self) -> bool {
108 matches!(self, WorkflowStatus::Running | WorkflowStatus::Paused)
109 }
110}
111
112#[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 pub start_time: Option<i64>,
127 pub close_time: Option<i64>,
128 pub history_length: i64,
129}
130
131impl WorkflowRow {
132 pub fn key(&self) -> (&str, &str) {
137 (self.namespace.as_str(), self.run_id.as_str())
138 }
139}
140
141pub 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
152pub 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
163pub fn find_by_key(rows: &[WorkflowRow], key: (&str, &str)) -> Option<usize> {
169 rows.iter().position(|r| r.key() == key)
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct StatusCounts {
175 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 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
208pub fn humanize_age_ms(millis: i64) -> String {
214 if millis < 0 {
215 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#[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 pub fn tokens(&self) -> &[(String, Vec<u8>)] {
260 &self.tokens
261 }
262
263 pub fn has_more(&self) -> bool {
265 !self.tokens.is_empty()
266 }
267
268 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}