1use crate::EngineError;
2use chrono::{DateTime, Utc};
3use dpm_model::{
4 ActorId, BasisState, Dependency, DependencyId, DependencyKind, DependencyPolicy, Endpoint,
5 EventTime, Plan, Release, StartBasis, StartRelease, Timeline, WorkItem, WorkItemId, WorkStatus,
6};
7use serde::{Deserialize, Serialize};
8
9mod provisional;
10mod transition;
11pub use transition::{Transition, describe_applicability};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum UnmetGate {
17 Aggregate,
19 ContractNotRatified,
21 Lifecycle {
23 status: WorkStatus,
25 },
26 Blocker {
28 reason: String,
30 },
31 Dependency {
33 dependency: DependencyId,
35 policy: DependencyPolicy,
37 predecessor: WorkItemId,
39 key: String,
41 project: String,
43 asset_keys: Vec<String>,
45 relation: DependencyKind,
47 lag_hours: f64,
49 requires: Endpoint,
51 release: Release,
53 status: WorkStatus,
55 owner: Option<ActorId>,
57 #[serde(default, skip_serializing_if = "StartBasis::is_verified")]
59 start_basis: StartBasis,
60 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
62 accepts_submission: bool,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 attempt: Option<u32>,
66 },
67 BasisInvalidated {
70 dependency: DependencyId,
72 predecessor: WorkItemId,
74 key: String,
76 attempt: u32,
78 state: BasisState,
80 current_attempt: Option<u32>,
82 },
83 Decision {
85 key: String,
87 },
88 Choice {
91 key: String,
93 chosen_at: DateTime<Utc>,
95 },
96 Applicability {
99 applicability: dpm_model::Applicability,
101 },
102}
103
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct ProvisionalRelease {
107 pub dependency: DependencyId,
109 pub predecessor: WorkItemId,
111 pub key: String,
113 pub attempt: u32,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct GateReport {
120 #[serde(default)]
122 pub transition: Transition,
123 pub ready: bool,
125 pub unmet: Vec<UnmetGate>,
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub provisional: Vec<ProvisionalRelease>,
130}
131
132pub fn gate_report(
134 plan: &Plan,
135 work: WorkItemId,
136 transition: Transition,
137 now: DateTime<Utc>,
138) -> Result<GateReport, EngineError> {
139 plan.validate()?;
140 let item = plan
141 .work_items
142 .get(&work)
143 .ok_or(EngineError::MissingWorkItem(work))?;
144 Ok(evaluate(plan, item, transition, &Timeline::at(plan, now)))
145}
146
147pub(crate) fn evaluate(
149 plan: &Plan,
150 work: &WorkItem,
151 transition: Transition,
152 timeline: &Timeline,
153) -> GateReport {
154 let mut unmet = Vec::new();
155 if !work.is_executable() {
156 unmet.push(UnmetGate::Aggregate);
157 }
158 match work.execution.status {
159 status if status == transition.lifecycle() => {}
160 WorkStatus::Proposed => unmet.push(UnmetGate::ContractNotRatified),
161 status => unmet.push(UnmetGate::Lifecycle { status }),
162 }
163 if let Some(reason) = &work.execution.block_reason {
164 unmet.push(UnmetGate::Blocker {
165 reason: reason.clone(),
166 });
167 }
168 let applicability = timeline.applicability(work.id);
169 if !applicability.is_applicable() {
170 unmet.push(UnmetGate::Applicability {
171 applicability: applicability.clone(),
172 });
173 }
174 unmet.extend(choice_gates(plan, work, timeline));
175 let mut provisional = Vec::new();
176 for dep in timeline
177 .incoming(plan, work.id)
178 .filter(|dep| transition.governs(dep.kind))
179 {
180 let Some(item) = plan.work_items.get(&dep.predecessor) else {
181 continue;
182 };
183 let edge = if transition.starts() {
184 timeline.start_edge(plan, dep)
185 } else {
186 StartRelease {
187 release: timeline.edge(plan, dep),
188 attempt: None,
189 provisional: false,
190 }
191 };
192 match (edge.release.released_at(), edge.attempt) {
193 (Some(_), Some(attempt)) if edge.provisional => provisional.push(ProvisionalRelease {
194 dependency: dep.id,
195 predecessor: item.id,
196 key: item.key.to_string(),
197 attempt,
198 }),
199 (None, attempt) => unmet.push(dependency_gate(
200 plan,
201 dep,
202 item,
203 transition,
204 edge.release,
205 attempt,
206 )),
207 (Some(_), _) => {}
208 }
209 }
210 if transition.checks_basis() {
211 unmet.extend(provisional::basis_gates(plan, work));
212 }
213 if transition.checks_decisions() {
214 unmet.extend(
215 timeline
216 .decisions(plan, work.id)
217 .into_iter()
218 .filter(|(_, release)| release.released_at().is_none())
219 .map(|(decision, _)| UnmetGate::Decision {
220 key: decision.key.to_string(),
221 }),
222 );
223 }
224 GateReport {
225 transition,
226 ready: unmet.is_empty(),
227 unmet,
228 provisional,
229 }
230}
231
232fn choice_gates(plan: &Plan, work: &WorkItem, timeline: &Timeline) -> Vec<UnmetGate> {
234 timeline
235 .selecting_choices(plan, work.id)
236 .into_iter()
237 .filter_map(|(decision, at)| match at {
238 EventTime::Recorded(chosen_at) if chosen_at > timeline.now() => {
239 Some(UnmetGate::Choice {
240 key: decision.key.to_string(),
241 chosen_at,
242 })
243 }
244 _ => None,
245 })
246 .collect()
247}
248
249fn dependency_gate(
250 plan: &Plan,
251 dep: &Dependency,
252 item: &WorkItem,
253 transition: Transition,
254 release: Release,
255 attempt: Option<u32>,
256) -> UnmetGate {
257 UnmetGate::Dependency {
258 dependency: dep.id,
259 policy: dep.policy,
260 asset_keys: item
261 .contract
262 .assets
263 .iter()
264 .filter_map(|r| plan.assets.get(&r.asset))
265 .map(|r| r.key.to_string())
266 .collect(),
267 predecessor: item.id,
268 key: item.key.to_string(),
269 project: plan
270 .projects
271 .get(&item.project)
272 .map(|p| p.key.to_string())
273 .unwrap_or_default(),
274 relation: dep.kind,
275 lag_hours: dep.lag_hours,
276 requires: dep.kind.predecessor_endpoint(),
277 release,
278 status: item.execution.status,
279 owner: item.execution.owner.clone(),
280 start_basis: dep.start_basis,
281 accepts_submission: transition.starts() && dep.start_basis == StartBasis::Provisional,
282 attempt,
283 }
284}
285
286impl GateReport {
287 pub fn reasons(&self) -> Vec<String> {
289 self.unmet.iter().map(describe).collect()
290 }
291}
292
293pub(crate) fn summary(unmet: &[UnmetGate]) -> String {
294 unmet.iter().map(describe).collect::<Vec<_>>().join("; ")
295}
296
297pub(crate) fn describe(gate: &UnmetGate) -> String {
299 match gate {
300 UnmetGate::Aggregate => {
301 "aggregate work completes through its prerequisites or children".into()
302 }
303 UnmetGate::ContractNotRatified => "contract not ratified".into(),
304 UnmetGate::Lifecycle { status } => format!("work lifecycle state is {status:?}"),
305 UnmetGate::Blocker { reason } => format!("work is blocked: {reason}"),
306 UnmetGate::Dependency {
307 key,
308 relation,
309 lag_hours,
310 policy,
311 requires,
312 release,
313 start_basis,
314 accepts_submission,
315 attempt,
316 ..
317 } => {
318 let edge = format!("{} {lag_hours:+}h, {policy:?}", relation.abbreviation());
319 let mut text = if *accepts_submission {
320 provisional::describe_start(key, *release, *attempt, &edge, *policy)
321 } else {
322 transition::describe_release(key, *requires, *release, &edge, *policy)
323 };
324 if *start_basis == StartBasis::Provisional && !*accepts_submission {
325 text.push_str("; a provisional edge releases only the start, never finishing on an unverified result");
326 }
327 if *lag_hours < 0.0 {
328 text.push_str(&format!(
329 "; the {lag_hours}h lead shapes the schedule only and never releases work before the event"
330 ));
331 }
332 text
333 }
334 UnmetGate::BasisInvalidated {
335 key,
336 attempt,
337 state,
338 current_attempt,
339 ..
340 } => provisional::describe_invalidated(key, *attempt, state, *current_attempt),
341 UnmetGate::Decision { key } => format!("work awaits decision: {key}"),
342 UnmetGate::Choice { key, chosen_at } => format!(
343 "work applies only through {key}, which selected it at {chosen_at}, after the evaluated time"
344 ),
345 UnmetGate::Applicability { applicability } => {
346 transition::describe_applicability(applicability)
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests;