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;