Skip to main content

tmprl_core/
history.rs

1//! Turning a flat event log into the thing a human wants to read.
2//!
3//! Temporal sends a workflow's history as an ordered list of events linked only by integer
4//! back-references. An activity that was scheduled, started and completed is three rows on
5//! the wire and *one thing* to a reader. Reconstructing that is, per
6//! `docs/ARCHITECTURE.md`, the hardest part of the port.
7//!
8//! The split of labour:
9//!
10//! * `tmprl-client` maps each protobuf event onto a [`NormalizedEvent`] through one
11//!   exhaustive match. The generated types stop there.
12//! * This module folds those into [`Group`]s. It is pure, so the grouping rules, the part
13//!   that is actually easy to get wrong, are tested with hand-built events and no server.
14
15use crate::payload::Payload;
16
17/// What kind of thing an event is about. Drives icons, filtering and the outline.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub enum Category {
20    /// The workflow execution itself: started, completed, signalled, terminated.
21    Workflow,
22    /// Workflow task, the worker polling and responding. Noise most of the time, which is
23    /// why the compact view can fold it away.
24    WorkflowTask,
25    Activity,
26    Timer,
27    ChildWorkflow,
28    /// Signals and cancellation aimed at *another* workflow.
29    ExternalWorkflow,
30    Update,
31    Nexus,
32    Marker,
33    SearchAttributes,
34}
35
36impl Category {
37    /// Whether this is machinery rather than something the workflow author wrote. The
38    /// compact view hides these until asked.
39    pub fn is_plumbing(self) -> bool {
40        matches!(self, Category::WorkflowTask)
41    }
42}
43
44/// Where an event sits in the life of the thing it belongs to.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Role {
47    /// Opens a group: scheduled, initiated, started-by-us.
48    Opens,
49    /// Neither opens nor closes, a worker picked the task up, a cancel was requested.
50    Continues,
51    /// Closes a group: completed, failed, timed out, cancelled.
52    Closes,
53}
54
55/// How something ended. `Pending` means it has not.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum Outcome {
58    #[default]
59    Pending,
60    Completed,
61    Failed,
62    Canceled,
63    TimedOut,
64    Terminated,
65    ContinuedAsNew,
66    Rejected,
67}
68
69impl Outcome {
70    /// Whether this outcome is one a reader is hunting for. The minimap and the problem
71    /// list are built from this.
72    pub fn is_failure(self) -> bool {
73        matches!(
74            self,
75            Outcome::Failed | Outcome::TimedOut | Outcome::Terminated | Outcome::Rejected
76        )
77    }
78
79    pub fn label(self) -> &'static str {
80        match self {
81            Outcome::Pending => "Pending",
82            Outcome::Completed => "Completed",
83            Outcome::Failed => "Failed",
84            Outcome::Canceled => "Canceled",
85            Outcome::TimedOut => "TimedOut",
86            Outcome::Terminated => "Terminated",
87            Outcome::ContinuedAsNew => "ContinuedAsNew",
88            Outcome::Rejected => "Rejected",
89        }
90    }
91}
92
93/// Which group an event belongs to.
94///
95/// Groups are keyed by the id of the event that opened them, because that is the one
96/// identifier every back-reference in the protocol actually points at. Keying by a
97/// user-facing name instead (an activity id, say) would need a lookup that can fail, and
98/// would merge two genuinely separate schedulings of the same name into one row.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
100pub enum GroupRef {
101    /// The workflow execution itself.
102    Workflow,
103    /// The group opened by this event id.
104    Opened(i64),
105}
106
107/// One protobuf history event, flattened.
108///
109/// Deliberately not a 60-variant mirror of the protobuf `oneof`. Everything downstream
110/// needs, what it is about, which group it joins, whether it opens or closes that group,
111/// how it ended, is extracted by the mapping in `tmprl-client`, so this module and the
112/// views never touch a generated type or re-derive the same facts.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct NormalizedEvent {
115    pub id: i64,
116    /// Epoch milliseconds.
117    pub time: Option<i64>,
118    /// The protobuf event name, e.g. `ActivityTaskScheduled`. Kept verbatim because it is
119    /// what Temporal's own docs, the CLI and the web UI all call it.
120    pub name: &'static str,
121    pub category: Category,
122    pub group: GroupRef,
123    pub role: Role,
124    pub outcome: Outcome,
125    /// What the event is about: an activity type, a timer id, a signal name.
126    pub subject: String,
127    /// Attempt number, where the protocol reports one. A retry does *not* produce a second
128    /// scheduling event, the count lives here.
129    pub attempt: Option<i32>,
130    /// Failure message, when the event carries one.
131    pub failure: Option<String>,
132    /// Detail rows for the expanded view, in protocol order.
133    pub fields: Vec<(&'static str, String)>,
134    /// Payloads this event carries, labelled, `input`, `result`, `details[1]`. Labels are
135    /// owned because an argument list needs an index in them.
136    pub payloads: Vec<(String, Payload)>,
137}
138
139impl NormalizedEvent {
140    /// A minimal event, for tests and for the arms of the mapping that carry nothing else.
141    pub fn new(
142        id: i64,
143        name: &'static str,
144        category: Category,
145        group: GroupRef,
146        role: Role,
147    ) -> Self {
148        Self {
149            id,
150            time: None,
151            name,
152            category,
153            group,
154            role,
155            outcome: Outcome::Pending,
156            subject: String::new(),
157            attempt: None,
158            failure: None,
159            fields: Vec::new(),
160            payloads: Vec::new(),
161        }
162    }
163
164    pub fn with_time(mut self, time: Option<i64>) -> Self {
165        self.time = time;
166        self
167    }
168
169    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
170        self.subject = subject.into();
171        self
172    }
173
174    pub fn with_outcome(mut self, outcome: Outcome) -> Self {
175        self.outcome = outcome;
176        self
177    }
178}
179
180/// Several events that are one thing.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct Group {
183    pub key: GroupRef,
184    pub category: Category,
185    /// From the opening event, the activity type, timer id, child workflow id.
186    pub subject: String,
187    /// Every member event id, in history order.
188    pub events: Vec<i64>,
189    pub started_at: Option<i64>,
190    /// `None` while the group is still open.
191    pub ended_at: Option<i64>,
192    pub outcome: Outcome,
193    /// Highest attempt seen. 1 unless something was retried.
194    pub attempts: i32,
195    pub failure: Option<String>,
196}
197
198impl Group {
199    /// Still running: nothing has closed it.
200    pub fn is_open(&self) -> bool {
201        self.ended_at.is_none() && self.outcome == Outcome::Pending
202    }
203
204    /// Wall-clock duration, once it has ended.
205    pub fn duration_ms(&self) -> Option<i64> {
206        Some(self.ended_at? - self.started_at?)
207    }
208
209    /// The events whose payloads describe the group: the one that opened it and the one that
210    /// closed it. The middle of a group is task plumbing and carries nothing.
211    ///
212    /// A group still open has a single event, where those two are the same one, so the pair
213    /// is deduplicated rather than showing that event's input twice.
214    pub fn payload_ends(&self) -> Vec<i64> {
215        let mut ends: Vec<i64> = [self.events.first(), self.events.last()]
216            .into_iter()
217            .flatten()
218            .copied()
219            .collect();
220        ends.dedup();
221        ends
222    }
223
224    /// The id of the event that opened this group, for jumping to it.
225    pub fn first_event(&self) -> Option<i64> {
226        self.events.first().copied()
227    }
228}
229
230/// Fold normalised events into groups, in the order the groups were opened.
231///
232/// A single forward pass: every event names its own group, so nothing here needs to look
233/// ahead or resolve a name to an id. Events are expected in history order, which is the
234/// order the server sends them.
235///
236/// Events whose group was never opened, the first page of a history that starts mid-run,
237/// or a back-reference to an event Temporal has since archived, are not dropped. They open
238/// a group of their own, so a truncated history renders as a partial group rather than as
239/// nothing at all.
240pub fn group_events(events: &[NormalizedEvent]) -> Vec<Group> {
241    let mut groups: Vec<Group> = Vec::new();
242    // Parallel to `groups`, so a lookup is by key without hashing a small collection.
243    let mut index: Vec<GroupRef> = Vec::new();
244
245    for ev in events {
246        let at = match index.iter().position(|k| *k == ev.group) {
247            Some(at) => at,
248            None => {
249                groups.push(Group {
250                    key: ev.group,
251                    category: ev.category,
252                    subject: ev.subject.clone(),
253                    events: Vec::new(),
254                    started_at: ev.time,
255                    ended_at: None,
256                    outcome: Outcome::Pending,
257                    attempts: 1,
258                    failure: None,
259                });
260                index.push(ev.group);
261                groups.len() - 1
262            }
263        };
264        let g = &mut groups[at];
265
266        g.events.push(ev.id);
267        if let Some(n) = ev.attempt {
268            g.attempts = g.attempts.max(n);
269        }
270        // The opening event is the one that names the group. A later event may carry a
271        // subject too (a child workflow's run id, say) but must not rename it.
272        if ev.role == Role::Opens && !ev.subject.is_empty() && g.subject.is_empty() {
273            g.subject = ev.subject.clone();
274        }
275        if ev.failure.is_some() {
276            g.failure = ev.failure.clone();
277        }
278        if ev.role == Role::Closes {
279            g.ended_at = ev.time;
280            g.outcome = ev.outcome;
281        }
282    }
283
284    groups
285}
286
287/// Append only the events we do not already hold.
288///
289/// Returns how many were actually new.
290///
291/// Follow mode re-reads from the last continuation token it saw, which replays the events
292/// after that point, and a resumed follow replays whatever page the token sat in. History is
293/// append-only with strictly ascending ids, so "new" is exactly "id greater than the highest
294/// we hold", no set, no scan of what we already have.
295pub fn merge_events(existing: &mut Vec<NormalizedEvent>, incoming: Vec<NormalizedEvent>) -> usize {
296    let highest = existing.last().map(|e| e.id).unwrap_or(i64::MIN);
297    let before = existing.len();
298    existing.extend(incoming.into_iter().filter(|e| e.id > highest));
299    existing.len() - before
300}
301
302/// The event a reset would actually go back to, at or before `at`.
303///
304/// Temporal only resets to a **completed workflow task**: the point where the workflow's
305/// state is well defined and can be replayed forward. Any other event is not a valid reset
306/// point, and asking for one is an error from the server.
307///
308/// The reader almost never has the cursor on a workflow task, because those are the plumbing
309/// the outline folds away by default. So "reset to here" resolves backwards to the last
310/// completed workflow task at or before the selected event, and the confirmation shows the
311/// id it resolved to, so the target moves but never silently.
312pub fn reset_point(events: &[NormalizedEvent], at: i64) -> Option<i64> {
313    events
314        .iter()
315        .filter(|e| e.id <= at)
316        .rfind(|e| e.category == Category::WorkflowTask && e.outcome == Outcome::Completed)
317        .map(|e| e.id)
318}
319
320/// Groups that failed, timed out or were terminated, in history order.
321///
322/// This is what the problem list and the minimap read: on a long history the interesting
323/// question is "where did it go wrong", and scrolling to find out does not scale.
324pub fn failures(groups: &[Group]) -> Vec<&Group> {
325    groups.iter().filter(|g| g.outcome.is_failure()).collect()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn an_open_group_lists_its_single_event_once() {
334        // First and last are the same event while a group is still open; showing the pair
335        // blindly would print that event's input twice in the payload pane.
336        let g = Group {
337            key: GroupRef::Workflow,
338            category: Category::Workflow,
339            subject: "PayloadProbe".into(),
340            events: vec![1],
341            started_at: None,
342            ended_at: None,
343            outcome: Outcome::Pending,
344            attempts: 1,
345            failure: None,
346        };
347        assert_eq!(g.payload_ends(), vec![1]);
348    }
349
350    #[test]
351    fn a_closed_group_lists_the_event_that_opened_and_the_one_that_closed_it() {
352        let g = Group {
353            key: GroupRef::Opened(5),
354            category: Category::Activity,
355            subject: "ChargeCard".into(),
356            events: vec![5, 6, 7],
357            started_at: None,
358            ended_at: None,
359            outcome: Outcome::Completed,
360            attempts: 1,
361            failure: None,
362        };
363        assert_eq!(g.payload_ends(), vec![5, 7], "the middle carries nothing");
364    }
365
366    fn ev(id: i64, name: &'static str, group: GroupRef, role: Role, time: i64) -> NormalizedEvent {
367        NormalizedEvent::new(id, name, Category::Activity, group, role).with_time(Some(time))
368    }
369
370    /// The worked example from `docs/ARCHITECTURE.md`: an activity that was retried once
371    /// and then succeeded. Three events on the wire, one thing to a reader.
372    fn retried_activity() -> Vec<NormalizedEvent> {
373        let mut scheduled = ev(
374            5,
375            "ActivityTaskScheduled",
376            GroupRef::Opened(5),
377            Role::Opens,
378            1_000,
379        )
380        .with_subject("ChargeCard");
381        scheduled.fields.push(("activityId", "charge".into()));
382
383        let mut started = ev(
384            6,
385            "ActivityTaskStarted",
386            GroupRef::Opened(5),
387            Role::Continues,
388            2_000,
389        );
390        // A retry does not schedule again: the attempt count rides on the started event.
391        started.attempt = Some(2);
392        started.failure = Some("card declined".into());
393
394        let completed = ev(
395            7,
396            "ActivityTaskCompleted",
397            GroupRef::Opened(5),
398            Role::Closes,
399            41_000,
400        )
401        .with_outcome(Outcome::Completed);
402
403        vec![scheduled, started, completed]
404    }
405
406    #[test]
407    fn three_events_become_one_group() {
408        let groups = group_events(&retried_activity());
409        assert_eq!(groups.len(), 1);
410
411        let g = &groups[0];
412        assert_eq!(g.key, GroupRef::Opened(5));
413        assert_eq!(g.subject, "ChargeCard");
414        assert_eq!(g.events, [5, 6, 7]);
415        assert_eq!(g.outcome, Outcome::Completed);
416        assert_eq!(g.attempts, 2, "the retry must be visible on the group");
417        assert_eq!(g.started_at, Some(1_000));
418        assert_eq!(g.ended_at, Some(41_000));
419        assert_eq!(g.duration_ms(), Some(40_000));
420        assert!(!g.is_open());
421    }
422
423    #[test]
424    fn a_group_with_no_closing_event_is_still_running() {
425        let events = &retried_activity()[..2];
426        let groups = group_events(events);
427        assert!(groups[0].is_open());
428        assert_eq!(groups[0].outcome, Outcome::Pending);
429        assert_eq!(
430            groups[0].duration_ms(),
431            None,
432            "a running group has no duration"
433        );
434    }
435
436    #[test]
437    fn interleaved_groups_do_not_bleed_into_each_other() {
438        // Two activities in flight at once is the normal case, and the events arrive
439        // interleaved. Grouping by arrival order rather than by back-reference would
440        // scramble them.
441        let events = vec![
442            ev(
443                5,
444                "ActivityTaskScheduled",
445                GroupRef::Opened(5),
446                Role::Opens,
447                100,
448            )
449            .with_subject("A"),
450            ev(
451                6,
452                "ActivityTaskScheduled",
453                GroupRef::Opened(6),
454                Role::Opens,
455                110,
456            )
457            .with_subject("B"),
458            ev(
459                7,
460                "ActivityTaskStarted",
461                GroupRef::Opened(6),
462                Role::Continues,
463                120,
464            ),
465            ev(
466                8,
467                "ActivityTaskStarted",
468                GroupRef::Opened(5),
469                Role::Continues,
470                130,
471            ),
472            ev(
473                9,
474                "ActivityTaskFailed",
475                GroupRef::Opened(6),
476                Role::Closes,
477                140,
478            )
479            .with_outcome(Outcome::Failed),
480            ev(
481                10,
482                "ActivityTaskCompleted",
483                GroupRef::Opened(5),
484                Role::Closes,
485                150,
486            )
487            .with_outcome(Outcome::Completed),
488        ];
489        let groups = group_events(&events);
490
491        assert_eq!(groups.len(), 2);
492        // Ordered by when each group opened, not by when it closed.
493        assert_eq!(groups[0].subject, "A");
494        assert_eq!(groups[0].events, [5, 8, 10]);
495        assert_eq!(groups[0].outcome, Outcome::Completed);
496        assert_eq!(groups[1].subject, "B");
497        assert_eq!(groups[1].events, [6, 7, 9]);
498        assert_eq!(groups[1].outcome, Outcome::Failed);
499    }
500
501    #[test]
502    fn workflow_level_events_share_one_group() {
503        let events = vec![
504            NormalizedEvent::new(
505                1,
506                "WorkflowExecutionStarted",
507                Category::Workflow,
508                GroupRef::Workflow,
509                Role::Opens,
510            )
511            .with_time(Some(10))
512            .with_subject("OrderWorkflow"),
513            NormalizedEvent::new(
514                2,
515                "WorkflowExecutionSignaled",
516                Category::Workflow,
517                GroupRef::Workflow,
518                Role::Continues,
519            )
520            .with_time(Some(20)),
521            NormalizedEvent::new(
522                3,
523                "WorkflowExecutionCompleted",
524                Category::Workflow,
525                GroupRef::Workflow,
526                Role::Closes,
527            )
528            .with_time(Some(30))
529            .with_outcome(Outcome::Completed),
530        ];
531        let groups = group_events(&events);
532        assert_eq!(groups.len(), 1);
533        assert_eq!(groups[0].key, GroupRef::Workflow);
534        assert_eq!(groups[0].subject, "OrderWorkflow");
535        assert_eq!(groups[0].outcome, Outcome::Completed);
536    }
537
538    #[test]
539    fn an_orphaned_event_opens_its_own_group_rather_than_vanishing() {
540        // A history page that starts mid-run refers back to events it does not contain.
541        // Dropping those would render a page as empty and look like a bug in tmprl.
542        let events = vec![
543            ev(
544                42,
545                "ActivityTaskCompleted",
546                GroupRef::Opened(5),
547                Role::Closes,
548                900,
549            )
550            .with_outcome(Outcome::Completed),
551        ];
552        let groups = group_events(&events);
553        assert_eq!(groups.len(), 1);
554        assert_eq!(groups[0].events, [42]);
555        assert_eq!(groups[0].outcome, Outcome::Completed);
556    }
557
558    #[test]
559    fn a_later_event_does_not_rename_its_group() {
560        let events = vec![
561            ev(
562                5,
563                "ActivityTaskScheduled",
564                GroupRef::Opened(5),
565                Role::Opens,
566                10,
567            )
568            .with_subject("real"),
569            ev(
570                6,
571                "ActivityTaskStarted",
572                GroupRef::Opened(5),
573                Role::Continues,
574                20,
575            )
576            .with_subject("other"),
577        ];
578        assert_eq!(group_events(&events)[0].subject, "real");
579    }
580
581    #[test]
582    fn the_last_failure_on_a_group_is_the_one_kept() {
583        let mut first = ev(
584            6,
585            "ActivityTaskStarted",
586            GroupRef::Opened(5),
587            Role::Continues,
588            20,
589        );
590        first.failure = Some("first".into());
591        let mut last = ev(
592            7,
593            "ActivityTaskFailed",
594            GroupRef::Opened(5),
595            Role::Closes,
596            30,
597        );
598        last.failure = Some("final".into());
599        last.outcome = Outcome::Failed;
600
601        let groups = group_events(&[
602            ev(
603                5,
604                "ActivityTaskScheduled",
605                GroupRef::Opened(5),
606                Role::Opens,
607                10,
608            ),
609            first,
610            last,
611        ]);
612        assert_eq!(groups[0].failure.as_deref(), Some("final"));
613    }
614
615    #[test]
616    fn failures_are_findable_without_scrolling() {
617        let events = vec![
618            ev(
619                1,
620                "ActivityTaskScheduled",
621                GroupRef::Opened(1),
622                Role::Opens,
623                10,
624            )
625            .with_subject("ok"),
626            ev(
627                2,
628                "ActivityTaskCompleted",
629                GroupRef::Opened(1),
630                Role::Closes,
631                20,
632            )
633            .with_outcome(Outcome::Completed),
634            ev(
635                3,
636                "ActivityTaskScheduled",
637                GroupRef::Opened(3),
638                Role::Opens,
639                30,
640            )
641            .with_subject("bad"),
642            ev(
643                4,
644                "ActivityTaskTimedOut",
645                GroupRef::Opened(3),
646                Role::Closes,
647                40,
648            )
649            .with_outcome(Outcome::TimedOut),
650        ];
651        let groups = group_events(&events);
652        let bad = failures(&groups);
653        assert_eq!(bad.len(), 1);
654        assert_eq!(bad[0].subject, "bad");
655    }
656
657    #[test]
658    fn every_outcome_agrees_with_itself_about_being_a_failure() {
659        for (o, fail) in [
660            (Outcome::Pending, false),
661            (Outcome::Completed, false),
662            (Outcome::Canceled, false),
663            (Outcome::ContinuedAsNew, false),
664            (Outcome::Failed, true),
665            (Outcome::TimedOut, true),
666            (Outcome::Terminated, true),
667            (Outcome::Rejected, true),
668        ] {
669            assert_eq!(o.is_failure(), fail, "{} classified wrongly", o.label());
670        }
671    }
672
673    #[test]
674    fn replayed_events_are_not_appended_twice() {
675        // Follow mode resumes from a continuation token, which replays the page that token
676        // sat in. Appending blindly would list the same events twice and inflate every
677        // group's event count.
678        let mut held: Vec<NormalizedEvent> = retried_activity();
679        assert_eq!(held.len(), 3);
680
681        let replay = retried_activity();
682        assert_eq!(merge_events(&mut held, replay), 0, "nothing was new");
683        assert_eq!(held.len(), 3);
684
685        // A genuinely new event lands.
686        let fresh = vec![ev(
687            8,
688            "TimerStarted",
689            GroupRef::Opened(8),
690            Role::Opens,
691            50_000,
692        )];
693        assert_eq!(merge_events(&mut held, fresh), 1);
694        assert_eq!(held.len(), 4);
695    }
696
697    #[test]
698    fn a_partial_replay_keeps_only_the_tail() {
699        let mut held: Vec<NormalizedEvent> = retried_activity();
700        // The server replays from event 6 and adds 8 and 9.
701        let mut incoming = retried_activity()[1..].to_vec();
702        incoming.push(ev(
703            8,
704            "TimerStarted",
705            GroupRef::Opened(8),
706            Role::Opens,
707            50_000,
708        ));
709        incoming.push(ev(
710            9,
711            "TimerFired",
712            GroupRef::Opened(8),
713            Role::Closes,
714            60_000,
715        ));
716
717        assert_eq!(merge_events(&mut held, incoming), 2);
718        let ids: Vec<i64> = held.iter().map(|e| e.id).collect();
719        assert_eq!(ids, [5, 6, 7, 8, 9]);
720    }
721
722    #[test]
723    fn merging_into_an_empty_history_keeps_everything() {
724        let mut held = Vec::new();
725        assert_eq!(merge_events(&mut held, retried_activity()), 3);
726        assert_eq!(held.len(), 3);
727    }
728
729    #[test]
730    fn a_reset_resolves_back_to_the_last_completed_workflow_task() {
731        // The cursor is almost never on a workflow task (those are folded away) so "reset
732        // to here" has to walk back to the nearest valid point.
733        let events = vec![
734            NormalizedEvent::new(1, "S", Category::Workflow, GroupRef::Workflow, Role::Opens),
735            NormalizedEvent::new(
736                2,
737                "WTS",
738                Category::WorkflowTask,
739                GroupRef::Opened(2),
740                Role::Opens,
741            ),
742            NormalizedEvent::new(
743                3,
744                "WTC",
745                Category::WorkflowTask,
746                GroupRef::Opened(2),
747                Role::Closes,
748            )
749            .with_outcome(Outcome::Completed),
750            NormalizedEvent::new(
751                4,
752                "ATS",
753                Category::Activity,
754                GroupRef::Opened(4),
755                Role::Opens,
756            ),
757            NormalizedEvent::new(
758                5,
759                "ATC",
760                Category::Activity,
761                GroupRef::Opened(4),
762                Role::Closes,
763            )
764            .with_outcome(Outcome::Completed),
765        ];
766        assert_eq!(
767            reset_point(&events, 5),
768            Some(3),
769            "back to the workflow task"
770        );
771        assert_eq!(reset_point(&events, 3), Some(3), "already on one");
772        assert_eq!(reset_point(&events, 2), None, "nothing completed yet");
773    }
774
775    #[test]
776    fn a_failed_workflow_task_is_not_a_reset_point() {
777        // Only a *completed* task leaves the workflow in a state that can be replayed
778        // forward; the server rejects anything else.
779        let events = vec![
780            NormalizedEvent::new(
781                2,
782                "WTS",
783                Category::WorkflowTask,
784                GroupRef::Opened(2),
785                Role::Opens,
786            ),
787            NormalizedEvent::new(
788                3,
789                "WTF",
790                Category::WorkflowTask,
791                GroupRef::Opened(2),
792                Role::Closes,
793            )
794            .with_outcome(Outcome::Failed),
795        ];
796        assert_eq!(reset_point(&events, 3), None);
797    }
798
799    #[test]
800    fn a_reset_takes_the_latest_valid_point_not_the_first() {
801        let events = vec![
802            NormalizedEvent::new(
803                3,
804                "WTC",
805                Category::WorkflowTask,
806                GroupRef::Opened(2),
807                Role::Closes,
808            )
809            .with_outcome(Outcome::Completed),
810            NormalizedEvent::new(
811                9,
812                "WTC",
813                Category::WorkflowTask,
814                GroupRef::Opened(8),
815                Role::Closes,
816            )
817            .with_outcome(Outcome::Completed),
818            NormalizedEvent::new(
819                12,
820                "ATC",
821                Category::Activity,
822                GroupRef::Opened(10),
823                Role::Closes,
824            )
825            .with_outcome(Outcome::Completed),
826        ];
827        assert_eq!(reset_point(&events, 12), Some(9));
828        assert_eq!(reset_point(&events, 8), Some(3));
829    }
830
831    #[test]
832    fn an_empty_history_groups_to_nothing() {
833        assert!(group_events(&[]).is_empty());
834        assert!(failures(&[]).is_empty());
835    }
836}