Skip to main content

dpm_engine/
context.rs

1use dpm_model::{
2    Artifact, Decision, DecisionId, Dependency, Plan, Project, Requirement, Risk, WorkItem,
3    WorkItemId, WorkLink,
4};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7
8/// Resolved domain records needed by an agent to execute a work contract without guessing references.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ExecutionContext {
11    /// Assets named in this work contract, independent of local checkout paths.
12    pub assets: Vec<dpm_model::WorkspaceAsset>,
13    /// Project owning the selected work.
14    pub project: Project,
15    /// Work-package ancestors, nearest first.
16    pub parents: Vec<WorkItem>,
17    /// Requirements explicitly implemented by this work.
18    pub requirements: Vec<Requirement>,
19    /// Evidence attached to this work, its direct predecessors, or contextual decisions.
20    pub artifacts: Vec<Artifact>,
21    /// Direct or inherited gates and related decisions, including their reasons and sources.
22    pub decisions: Vec<Decision>,
23    /// Risks related to this work or its containing work packages.
24    pub risks: Vec<Risk>,
25    /// Incoming and outgoing temporal relationships, with identity, policy and any waiver.
26    pub dependencies: Vec<Dependency>,
27    /// Non-gating links in which this work is the source or target.
28    pub links: Vec<WorkLink>,
29    /// Directly dependent work items.
30    pub successors: Vec<WorkItem>,
31    /// External tracker objects linked to this work or its packages; never evidence or gates.
32    #[serde(default)]
33    pub external_references: Vec<dpm_model::ExternalReference>,
34}
35
36/// Work and its containing packages, nearest first; decisions and risks attach through any of them.
37pub(crate) fn lineage<'a>(plan: &'a Plan, work: &'a WorkItem) -> Vec<&'a WorkItem> {
38    let mut items = vec![work];
39    let mut parent = work.parent;
40    while let Some(item) = parent.and_then(|id| plan.work_items.get(&id)) {
41        items.push(item);
42        parent = item.parent;
43    }
44    items
45}
46
47/// Whether a decision belongs to the context of work with these lineage IDs: it gates, relates
48/// to, or decides the applicability of the work or a containing package.
49pub(crate) fn decision_applies(
50    plan: &Plan,
51    decision: &Decision,
52    lineage: &BTreeSet<WorkItemId>,
53) -> bool {
54    !decision.blocks.is_disjoint(lineage)
55        || !decision.related_work.is_disjoint(lineage)
56        || lineage.iter().any(|id| {
57            plan.work_items
58                .get(id)
59                .and_then(|w| w.contract.condition.as_ref())
60                .is_some_and(|c| c.decision == decision.id)
61        })
62}
63
64/// Decisions linked to the lineage plus every replacement reachable through `supersedes`.
65///
66/// A replacement need not repeat the old decision's work links, so work that was linked only to a
67/// superseded choice would otherwise lose sight of the choice that now applies to it.
68pub(crate) fn applicable_decisions(
69    plan: &Plan,
70    lineage: &BTreeSet<WorkItemId>,
71) -> BTreeSet<DecisionId> {
72    let mut selected: BTreeSet<_> = plan
73        .decisions
74        .values()
75        .filter(|d| decision_applies(plan, d, lineage))
76        .map(|d| d.id)
77        .collect();
78    loop {
79        let before = selected.len();
80        let replacements: Vec<_> = plan
81            .decisions
82            .values()
83            .filter(|d| d.supersedes.is_some_and(|old| selected.contains(&old)))
84            .map(|d| d.id)
85            .collect();
86        selected.extend(replacements);
87        if selected.len() == before {
88            return selected;
89        }
90    }
91}
92
93pub(crate) fn execution_context(
94    plan: &Plan,
95    work: &WorkItem,
96) -> Result<ExecutionContext, crate::EngineError> {
97    let lineage = lineage(plan, work);
98    let ancestors: BTreeSet<_> = lineage.iter().map(|w| w.id).collect();
99    let parents: Vec<_> = lineage.iter().skip(1).map(|w| (*w).clone()).collect();
100    let decisions: Vec<_> = applicable_decisions(plan, &ancestors)
101        .into_iter()
102        .filter_map(|id| plan.decisions.get(&id).cloned())
103        .collect();
104    let mut artifact_ids = work.execution.artifact_ids.clone();
105    for decision in &decisions {
106        artifact_ids.extend(&decision.artifact_ids);
107    }
108    for dependency in &plan.dependencies {
109        if dependency.successor == work.id
110            && let Some(predecessor) = plan.work_items.get(&dependency.predecessor)
111        {
112            artifact_ids.extend(&predecessor.execution.artifact_ids);
113        }
114    }
115    let project = plan.projects.get(&work.project).cloned().ok_or_else(|| {
116        crate::EngineError::InvalidCommand {
117            entity: work.key.to_string(),
118            reason: format!(
119                "work names project {} the plan does not contain",
120                work.project
121            ),
122        }
123    })?;
124    Ok(ExecutionContext {
125        assets: work
126            .contract
127            .assets
128            .iter()
129            .filter_map(|r| plan.assets.get(&r.asset).cloned())
130            .collect(),
131        project,
132        parents,
133        requirements: work
134            .contract
135            .requirement_ids
136            .iter()
137            .filter_map(|id| plan.requirements.get(id).cloned())
138            .collect(),
139        artifacts: artifact_ids
140            .iter()
141            .filter_map(|id| plan.artifacts.get(id).cloned())
142            .collect(),
143        decisions,
144        risks: plan
145            .risks
146            .values()
147            .filter(|r| !r.related_work.is_disjoint(&ancestors))
148            .cloned()
149            .collect(),
150        dependencies: plan
151            .dependencies
152            .iter()
153            .filter(|d| d.predecessor == work.id || d.successor == work.id)
154            .cloned()
155            .collect(),
156        links: plan
157            .links
158            .iter()
159            .filter(|l| l.source == work.id || l.target == work.id)
160            .cloned()
161            .collect(),
162        successors: plan
163            .dependencies
164            .iter()
165            .filter(|d| d.predecessor == work.id)
166            .filter_map(|d| plan.work_items.get(&d.successor).cloned())
167            .collect(),
168        external_references: plan
169            .external_references
170            .values()
171            .filter(|r| r.links.iter().any(|l| ancestors.contains(&l.work)))
172            .cloned()
173            .collect(),
174    })
175}
176
177#[cfg(test)]
178mod tests;