Skip to main content

dpm_model/
events.rs

1use chrono::{DateTime, TimeDelta, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Times recorded by a task's own lifecycle commands; never supplied by plan authors.
5///
6/// Each field is written only by the command that performs the transition, using that
7/// operation's timestamp. Work that reached a state before these facts existed has no time here,
8/// and gates treat that event as having occurred at an unknown time.
9#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct ExecutionEvents {
12    /// When the current owner's unstarted reservation began: the claim, or the handoff that moved
13    /// it. The start clears it and a release returns the work unowned, so no event can be recorded
14    /// before the reservation it follows while the start time does not yet order the history.
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub claimed_at: Option<DateTime<Utc>>,
17    /// When the owner started executing the task, as distinct from reserving it.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub started_at: Option<DateTime<Utc>>,
20    /// When the current submission was made; a rejection clears it.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub submitted_at: Option<DateTime<Utc>>,
23    /// When an independent verifier accepted the result; the task's finish event.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub verified_at: Option<DateTime<Utc>>,
26    /// The task started before start times were recorded. Blocking such work sets it, because a
27    /// `Blocked` lifecycle alone cannot say whether the work had started.
28    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
29    pub start_unrecorded: bool,
30}
31
32impl ExecutionEvents {
33    /// Whether no event time has been recorded.
34    #[must_use]
35    pub fn is_empty(&self) -> bool {
36        *self == Self::default()
37    }
38}
39
40/// When an event that has occurred happened.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum EventTime {
44    /// Time recorded by the command that caused the event.
45    Recorded(DateTime<Utc>),
46    /// The event occurred before event times were recorded; its time is unknown.
47    Unrecorded,
48}
49
50impl EventTime {
51    /// The later of two event times; an unknown time is never assumed to be earlier.
52    #[must_use]
53    pub fn latest(self, other: Self) -> Self {
54        match (self, other) {
55            (Self::Recorded(a), Self::Recorded(b)) => Self::Recorded(a.max(b)),
56            _ => Self::Unrecorded,
57        }
58    }
59
60    /// Recorded time, if known.
61    #[must_use]
62    pub fn recorded(self) -> Option<DateTime<Utc>> {
63        match self {
64            Self::Recorded(at) => Some(at),
65            Self::Unrecorded => None,
66        }
67    }
68}
69
70/// One end of an activity; zero-duration milestones start and finish at the same event.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum Endpoint {
74    /// Task start event, or a milestone's reach event.
75    Start,
76    /// Task verification event, or a milestone's reach event.
77    Finish,
78}
79
80/// State of a constraint that waits for an event plus elapsed lag, at one clock reading.
81#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
82#[serde(tag = "state", rename_all = "snake_case")]
83pub enum Release {
84    /// The required event has not occurred.
85    AwaitingEvent,
86    /// The event occurred and positive lag is still elapsing.
87    Elapsing {
88        /// Recorded time of the required event.
89        event_at: DateTime<Utc>,
90        /// Earliest clock reading at which the constraint is satisfied.
91        opens_at: DateTime<Utc>,
92    },
93    /// Positive lag must elapse from an event whose time was never recorded.
94    UnrecordedEventTime,
95    /// The lag cannot be added to the event time within the supported calendar range.
96    LagOutOfRange {
97        /// Recorded time of the required event.
98        event_at: DateTime<Utc>,
99    },
100    /// The constraint is satisfied.
101    Released {
102        /// When it became satisfied: the event time plus any positive lag.
103        at: EventTime,
104    },
105    /// The predecessor was excluded by a choice and the successor is an active-branch join, so
106    /// the constraint is an absent branch rather than a satisfied prerequisite.
107    SkippedBranch {
108        /// When the excluding choice was made.
109        at: EventTime,
110    },
111    /// The predecessor was excluded by a choice; an ordinary constraint from it never releases.
112    NotSelected,
113}
114
115impl Release {
116    /// Evaluate one constraint against an adapter-supplied clock reading.
117    ///
118    /// Positive lag delays release by elapsed time. Negative lag (lead) only shapes the schedule
119    /// projection: execution still waits for the event itself, so it is treated as zero here.
120    #[must_use]
121    pub fn evaluate(event: Option<EventTime>, lag_hours: f64, now: DateTime<Utc>) -> Self {
122        Self::evaluate_with(event, lag_hours, now, hours_after)
123    }
124
125    /// Evaluate one constraint whose positive lag ends at `lag_end(event_at, hours)`, such as
126    /// working hours of a calendar; `None` from it means the lag is out of range.
127    #[must_use]
128    pub fn evaluate_with(
129        event: Option<EventTime>,
130        lag_hours: f64,
131        now: DateTime<Utc>,
132        lag_end: impl FnOnce(DateTime<Utc>, f64) -> Option<DateTime<Utc>>,
133    ) -> Self {
134        let Some(event) = event else {
135            return Self::AwaitingEvent;
136        };
137        let delay = if lag_hours > 0.0 { lag_hours } else { 0.0 };
138        match event {
139            EventTime::Unrecorded if delay > 0.0 => Self::UnrecordedEventTime,
140            EventTime::Unrecorded => Self::Released {
141                at: EventTime::Unrecorded,
142            },
143            EventTime::Recorded(event_at) => match lag_end(event_at, delay) {
144                Some(opens_at) if opens_at <= now => Self::Released {
145                    at: EventTime::Recorded(opens_at),
146                },
147                Some(opens_at) => Self::Elapsing { event_at, opens_at },
148                None => Self::LagOutOfRange { event_at },
149            },
150        }
151    }
152
153    /// Release time when satisfied.
154    #[must_use]
155    pub fn released_at(self) -> Option<EventTime> {
156        match self {
157            Self::Released { at } | Self::SkippedBranch { at } => Some(at),
158            _ => None,
159        }
160    }
161}
162
163/// The instant `hours` elapsed hours after `at`; `None` outside chrono's range.
164pub(crate) fn hours_after(at: DateTime<Utc>, hours: f64) -> Option<DateTime<Utc>> {
165    lag_delta(hours).and_then(|delta| at.checked_add_signed(delta))
166}
167
168/// Millisecond-resolution elapsed lag; `None` when it exceeds the representable range.
169fn lag_delta(hours: f64) -> Option<TimeDelta> {
170    let millis = (hours * 3_600_000.0).round();
171    // chrono's range is far below 2^53 ms, so this bound also keeps the cast exact.
172    if !millis.is_finite() || millis.abs() > 9.0e15 {
173        return None;
174    }
175    TimeDelta::try_milliseconds(millis as i64)
176}
177
178#[cfg(test)]
179mod tests;