Skip to main content

dpm_engine/
scope.rs

1//! Query-only workspace scope for `next`.
2//!
3//! Scope narrows which globally ranked candidates are returned; it never changes readiness,
4//! gates, scores or claims. Eligible work outside the scope stays visible in `outside_scope`
5//! so a higher-ranked blocker in another project or repository cannot be hidden by a filter.
6
7use crate::{EngineError, NextWorkCandidate, NextWorkQuery, next_work};
8use dpm_model::{AssetAccess, AssetId, Key, Plan, ProjectId, WorkItem};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeSet;
11use thiserror::Error;
12
13/// Shape version of [`NextWorkResult`]; incremented on any incompatible field change.
14pub const NEXT_RESULT_VERSION: u32 = 1;
15
16/// A scope key that does not name an entity in the current plan.
17#[derive(Debug, Error)]
18pub enum ScopeError {
19    /// Project scope key is not in the plan.
20    #[error("unknown project key {0}")]
21    UnknownProject(String),
22    /// WorkspaceAsset scope key is not in the plan.
23    #[error("unknown asset key {0}")]
24    UnknownResource(String),
25}
26
27/// A resolved scope entry, echoed so callers see exactly which entities were applied.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ScopeMember<I> {
30    /// Stable identity of the entity.
31    pub id: I,
32    /// Human-readable key the caller supplied.
33    pub key: Key,
34}
35
36/// Project and asset membership filter; an empty axis does not filter.
37///
38/// A work item is in scope when it passes every non-empty axis:
39/// - projects: its project is one of the listed projects or a descendant of one;
40/// - assets: it names at least one listed asset, and every asset it writes is listed.
41///   Work naming no assets never positively matches, so it is outside an asset scope.
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct WorkScope {
44    /// Project subtrees to include.
45    pub projects: Vec<ScopeMember<ProjectId>>,
46    /// Assets the returned work must fit.
47    pub assets: Vec<ScopeMember<AssetId>>,
48}
49
50impl WorkScope {
51    /// Resolve human keys against the plan, rejecting unknown keys rather than matching nothing.
52    pub fn resolve<'a>(
53        plan: &Plan,
54        project_keys: impl IntoIterator<Item = &'a str>,
55        asset_keys: impl IntoIterator<Item = &'a str>,
56    ) -> Result<Self, ScopeError> {
57        let projects = unique(project_keys)
58            .into_iter()
59            .map(|key| {
60                plan.projects
61                    .values()
62                    .find(|p| p.key.0 == key)
63                    .map(|p| ScopeMember {
64                        id: p.id,
65                        key: p.key.clone(),
66                    })
67                    .ok_or_else(|| ScopeError::UnknownProject(key.into()))
68            })
69            .collect::<Result<_, _>>()?;
70        let assets = unique(asset_keys)
71            .into_iter()
72            .map(|key| {
73                plan.assets
74                    .values()
75                    .find(|r| r.key.0 == key)
76                    .map(|r| ScopeMember {
77                        id: r.id,
78                        key: r.key.clone(),
79                    })
80                    .ok_or_else(|| ScopeError::UnknownResource(key.into()))
81            })
82            .collect::<Result<_, _>>()?;
83        Ok(Self { projects, assets })
84    }
85
86    /// Whether neither axis restricts membership.
87    pub fn is_unscoped(&self) -> bool {
88        self.projects.is_empty() && self.assets.is_empty()
89    }
90
91    /// Whether the work satisfies every non-empty axis of this scope.
92    pub fn contains(&self, plan: &Plan, work: &WorkItem) -> bool {
93        self.contains_project(plan, work.project) && self.fits_assets(work)
94    }
95
96    fn contains_project(&self, plan: &Plan, project: ProjectId) -> bool {
97        if self.projects.is_empty() {
98            return true;
99        }
100        let roots = self.projects.iter().map(|m| m.id).collect::<BTreeSet<_>>();
101        let mut seen = BTreeSet::new();
102        let mut next = Some(project);
103        while let Some(id) = next {
104            if roots.contains(&id) {
105                return true;
106            }
107            if !seen.insert(id) {
108                return false;
109            }
110            next = plan.projects.get(&id).and_then(|p| p.parent);
111        }
112        false
113    }
114
115    fn fits_assets(&self, work: &WorkItem) -> bool {
116        if self.assets.is_empty() {
117            return true;
118        }
119        let allowed = self.assets.iter().map(|m| m.id).collect::<BTreeSet<_>>();
120        work.contract
121            .assets
122            .iter()
123            .any(|need| allowed.contains(&need.asset))
124            && work
125                .contract
126                .assets
127                .iter()
128                .filter(|need| need.access == AssetAccess::Write)
129                .all(|need| allowed.contains(&need.asset))
130    }
131}
132
133fn unique<'a>(keys: impl IntoIterator<Item = &'a str>) -> BTreeSet<&'a str> {
134    keys.into_iter().collect()
135}
136
137/// A candidate within scope, carrying its position in the unfiltered ranking.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ScopedCandidate {
140    /// One-based rank among all eligible work in the workspace, before scope filtering.
141    pub global_rank: usize,
142    /// Ranking computed on the full graph.
143    #[serde(flatten)]
144    pub candidate: NextWorkCandidate,
145}
146
147/// Eligible work that the scope excluded, kept visible rather than hidden.
148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct OutsideScope {
150    /// Number of eligible work items outside the scope.
151    pub count: usize,
152    /// Eligible outside work ranked above the best in-scope work, or all of it when none is in scope.
153    pub higher_ranked_count: usize,
154    /// Keys of eligible outside work in global rank order; the first `higher_ranked_count` rank higher.
155    pub keys: Vec<Key>,
156}
157
158/// Versioned `next` result: global ranking, then scope, then limit.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct NextWorkResult {
161    /// Shape version; see [`NEXT_RESULT_VERSION`].
162    pub result_version: u32,
163    /// Resolved scope that selected `candidates`.
164    pub scope: WorkScope,
165    /// Capabilities used for eligibility; distinct from scope membership.
166    pub capabilities: BTreeSet<String>,
167    /// Maximum number of returned candidates, applied after scope filtering.
168    pub limit: usize,
169    /// Ready, capability-eligible work across the whole workspace.
170    pub eligible_count: usize,
171    /// Eligible work inside the scope before the limit.
172    pub in_scope_count: usize,
173    /// In-scope candidates in global rank order, truncated to `limit`.
174    pub candidates: Vec<ScopedCandidate>,
175    /// Eligible work excluded by scope.
176    pub outside_scope: OutsideScope,
177    /// Advice for the actor the caller named, such as work it already holds; it never filters or
178    /// reorders candidates, and is absent when no actor was named or no advice applies.
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub advisories: Vec<crate::Advisory>,
181}
182
183/// Rank eligible work on the full graph at an adapter-supplied time, then partition it by scope and
184/// apply the limit.
185pub fn next_in_scope(
186    plan: &Plan,
187    query: &NextWorkQuery,
188    scope: &WorkScope,
189    limit: usize,
190    now: chrono::DateTime<chrono::Utc>,
191) -> Result<NextWorkResult, EngineError> {
192    let ranked = next_work(plan, query, now)?;
193    let eligible_count = ranked.len();
194    let mut inside = Vec::new();
195    let mut outside = OutsideScope::default();
196    let mut best_inside_rank = None;
197    for (index, candidate) in ranked.into_iter().enumerate() {
198        let global_rank = index.wrapping_add(1);
199        if scope.contains(plan, &candidate.work) {
200            best_inside_rank.get_or_insert(global_rank);
201            inside.push(ScopedCandidate {
202                global_rank,
203                candidate,
204            });
205        } else {
206            if best_inside_rank.is_none() {
207                outside.higher_ranked_count = outside.higher_ranked_count.wrapping_add(1);
208            }
209            outside.keys.push(candidate.work.key);
210        }
211    }
212    outside.count = outside.keys.len();
213    let in_scope_count = inside.len();
214    inside.truncate(limit);
215    Ok(NextWorkResult {
216        result_version: NEXT_RESULT_VERSION,
217        scope: scope.clone(),
218        capabilities: query.capabilities.clone(),
219        limit,
220        eligible_count,
221        in_scope_count,
222        candidates: inside,
223        outside_scope: outside,
224        advisories: Vec::new(),
225    })
226}
227
228#[cfg(test)]
229mod tests;