Skip to main content

levi_core/
graph.rs

1//! The cross-project blocking graph (spec
2//! 2026-07-21-cross-project-graph-design, Surface 2).
3//!
4//! Only tasks that participate in a dependency become nodes — with ~200
5//! tasks and a handful of edges, drawing every task would be a cloud of
6//! unconnected dots. Everything unconnected is returned separately for the
7//! backlog list. Layout is a deterministic longest-path layering so a sparse
8//! DAG renders identically every time instead of jittering.
9
10use std::collections::{BTreeMap, BTreeSet, VecDeque};
11
12use myko::prelude::*;
13
14use crate::entities::{Dependency, Priority, Task};
15use crate::resolve::{ResolvedStatus, Status};
16
17#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
18pub struct GraphNode {
19    pub task_id: String,
20    pub project_id: String,
21    pub title: String,
22    pub priority: Priority,
23    pub status: Status,
24    /// 0 = blocked by nothing in the graph; deepest-blocker + 1 otherwise.
25    pub layer: usize,
26    /// True when the task is referenced by an edge but the hub has never seen
27    /// it (a foreign blocker not yet synced) — rendered as a stub.
28    pub stub: bool,
29}
30
31#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
32pub struct GraphEdge {
33    pub blocker_task_id: String,
34    pub blocked_task_id: String,
35    /// None = same project as the blocked task.
36    pub blocker_project_id: Option<String>,
37    pub via: Option<String>,
38    /// The blocker is closed: this edge is the actionable "verify and start".
39    pub resolved: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
43pub struct IssueGraph {
44    pub nodes: Vec<GraphNode>,
45    pub edges: Vec<GraphEdge>,
46    /// Task ids with no dependency edge — the backlog, not drawn in the graph.
47    pub unconnected: Vec<String>,
48    /// (blocker, blocked) edges dropped to break a cycle, for the UI to flag.
49    pub broken_cycles: Vec<(String, String)>,
50}
51
52/// Report output: the whole graph, computed on the hub and sent to the
53/// dashboard so it never pulls raw entities. Defined here (not in the
54/// wasm-gated `hub` module) so the wasm client can call and deserialize it.
55#[myko_report_output]
56pub struct IssueGraphOut {
57    pub graph: IssueGraph,
58}
59
60/// The cross-project blocking graph report. The `compute` handler is
61/// hub-only (below); constructing and deserializing the report is
62/// client-side, which is why the struct lives here.
63#[myko_report(IssueGraphOut)]
64pub struct IssueGraphReport {}
65
66/// Build the graph. `statuses` maps task id -> resolved status (any precision;
67/// the graph only reads the open/closed bit). Tasks referenced by an edge but
68/// absent from `tasks` become stub nodes rather than dropped edges.
69pub fn build(
70    tasks: &BTreeMap<String, Task>,
71    deps: &BTreeMap<String, Dependency>,
72    statuses: &BTreeMap<String, ResolvedStatus>,
73) -> IssueGraph {
74    // Distinct edges (dedup: the same block can be recorded more than once).
75    let mut edges: Vec<GraphEdge> = Vec::new();
76    let mut edge_keys: BTreeSet<(String, String)> = BTreeSet::new();
77    for dep in deps.values() {
78        let key = (dep.blocker_task_id.clone(), dep.blocked_task_id.clone());
79        if !edge_keys.insert(key) {
80            continue;
81        }
82        let resolved = statuses
83            .get(&dep.blocker_task_id)
84            .is_some_and(|s| s.status == Status::Closed);
85        edges.push(GraphEdge {
86            blocker_task_id: dep.blocker_task_id.clone(),
87            blocked_task_id: dep.blocked_task_id.clone(),
88            blocker_project_id: dep.blocker_project_id.clone(),
89            via: dep.via.clone(),
90            resolved,
91        });
92    }
93
94    // Node set: every task touched by an edge.
95    let mut in_graph: BTreeSet<String> = BTreeSet::new();
96    for e in &edges {
97        in_graph.insert(e.blocker_task_id.clone());
98        in_graph.insert(e.blocked_task_id.clone());
99    }
100
101    // Adjacency (blocker -> blocked) and in-degree, for layering.
102    let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
103    let mut indeg: BTreeMap<String, usize> = in_graph.iter().map(|id| (id.clone(), 0)).collect();
104    for e in &edges {
105        children
106            .entry(e.blocker_task_id.clone())
107            .or_default()
108            .push(e.blocked_task_id.clone());
109        *indeg.get_mut(&e.blocked_task_id).unwrap() += 1;
110    }
111
112    let (layer, broken_cycles) = layer_nodes(&in_graph, &children, indeg, &edges);
113
114    let mut nodes: Vec<GraphNode> = in_graph
115        .iter()
116        .map(|id| match tasks.get(id) {
117            Some(t) => GraphNode {
118                task_id: id.clone(),
119                project_id: t.project_id.clone(),
120                title: t.title.clone(),
121                priority: t.priority,
122                status: statuses.get(id).map(|s| s.status).unwrap_or(Status::Open),
123                layer: layer[id],
124                stub: false,
125            },
126            // Edge references a task the hub hasn't seen: keep it as a stub.
127            None => GraphNode {
128                task_id: id.clone(),
129                project_id: blocker_project_of(id, deps).unwrap_or_default(),
130                title: format!("lv-{}", &id[..id.len().min(8)]),
131                priority: Priority::P2,
132                status: Status::Open,
133                layer: layer[id],
134                stub: true,
135            },
136        })
137        .collect();
138    // Stable render order: layer, then project, then priority, then id.
139    nodes.sort_by(|a, b| {
140        (a.layer, &a.project_id, a.priority.rank(), &a.task_id).cmp(&(
141            b.layer,
142            &b.project_id,
143            b.priority.rank(),
144            &b.task_id,
145        ))
146    });
147
148    let unconnected: Vec<String> = tasks
149        .keys()
150        .filter(|id| !in_graph.contains(*id))
151        .cloned()
152        .collect();
153
154    IssueGraph {
155        nodes,
156        edges,
157        unconnected,
158        broken_cycles,
159    }
160}
161
162/// Longest-path layering via Kahn's algorithm. Any edge still unresolved when
163/// the queue drains sits on a cycle; it is dropped (recorded) and its target
164/// released, so the layout terminates and the cycle is surfaced.
165fn layer_nodes(
166    in_graph: &BTreeSet<String>,
167    children: &BTreeMap<String, Vec<String>>,
168    mut indeg: BTreeMap<String, usize>,
169    edges: &[GraphEdge],
170) -> (BTreeMap<String, usize>, Vec<(String, String)>) {
171    let mut layer: BTreeMap<String, usize> = in_graph.iter().map(|id| (id.clone(), 0)).collect();
172    let mut queue: VecDeque<String> = in_graph
173        .iter()
174        .filter(|id| indeg[*id] == 0)
175        .cloned()
176        .collect();
177    let mut settled: BTreeSet<String> = BTreeSet::new();
178
179    while let Some(id) = queue.pop_front() {
180        settled.insert(id.clone());
181        if let Some(kids) = children.get(&id) {
182            for kid in kids {
183                let next = layer[&id] + 1;
184                if next > layer[kid] {
185                    layer.insert(kid.clone(), next);
186                }
187                let d = indeg.get_mut(kid).unwrap();
188                *d -= 1;
189                if *d == 0 {
190                    queue.push_back(kid.clone());
191                }
192            }
193        }
194    }
195
196    // Anything not settled is on a cycle. Report the edges into the unsettled
197    // set; their layers keep whatever longest path reached them.
198    let mut broken_cycles: Vec<(String, String)> = edges
199        .iter()
200        .filter(|e| !settled.contains(&e.blocked_task_id))
201        .map(|e| (e.blocker_task_id.clone(), e.blocked_task_id.clone()))
202        .collect();
203    broken_cycles.sort();
204    broken_cycles.dedup();
205    (layer, broken_cycles)
206}
207
208fn blocker_project_of(task_id: &str, deps: &BTreeMap<String, Dependency>) -> Option<String> {
209    deps.values()
210        .find(|d| d.blocker_task_id == task_id)
211        .and_then(|d| d.blocker_project_id.clone())
212}
213
214/// Hub-side computation: join the live Tasks / Dependencys / StatusChanges,
215/// resolve unanchored status per project, and fold into the graph. No
216/// CommitFacts are touched, so the output stays small regardless of history.
217///
218/// Not cfg-gated: `ReportParams` (which the client needs to *call* the
219/// report) requires `ReportHandler`, so this impl must exist on wasm too —
220/// the client never invokes `compute`, it asks the hub to.
221impl myko::report::ReportHandler for IssueGraphReport {
222    type Output = IssueGraphOut;
223
224    fn compute(
225        &self,
226        ctx: myko::report::ReportContext,
227    ) -> impl myko::hyphae::Materialize<std::sync::Arc<Self::Output>, myko::hyphae::Definite> {
228        use myko::hyphae::JoinExt;
229        let tasks = ctx.query_map(crate::GetAllTasks {}).items().materialize();
230        let deps = ctx
231            .query_map(crate::GetAllDependencys {})
232            .items()
233            .materialize();
234        let changes = ctx
235            .query_map(crate::GetAllStatusChanges {})
236            .items()
237            .materialize();
238        tasks
239            .join(deps)
240            .materialize()
241            .join(changes)
242            .map(|((tasks, deps), changes)| {
243                std::sync::Arc::new(IssueGraphOut {
244                    graph: build_from_live(tasks, deps, changes),
245                })
246            })
247    }
248}
249
250/// Fold live (Arc-wrapped) entities into the graph: unanchored status per
251/// project, then [`build`]. Shared by the report and its test.
252pub fn build_from_live(
253    tasks: &[std::sync::Arc<Task>],
254    deps: &[std::sync::Arc<Dependency>],
255    changes: &[std::sync::Arc<crate::StatusChange>],
256) -> IssueGraph {
257    let tasks: Vec<Task> = tasks.iter().map(|t| (**t).clone()).collect();
258    let changes: Vec<crate::StatusChange> = changes.iter().map(|c| (**c).clone()).collect();
259
260    let projects: BTreeSet<&str> = tasks.iter().map(|t| t.project_id.as_str()).collect();
261    let mut statuses = BTreeMap::new();
262    for pid in projects {
263        statuses.extend(crate::crossproject::statuses_unanchored(
264            &tasks, &changes, pid,
265        ));
266    }
267    let task_map: BTreeMap<String, Task> =
268        tasks.into_iter().map(|t| (t.id.to_string(), t)).collect();
269    let dep_map: BTreeMap<String, Dependency> = deps
270        .iter()
271        .map(|d| (d.id.to_string(), (**d).clone()))
272        .collect();
273    build(&task_map, &dep_map, &statuses)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::entities::StatusKind;
280    use crate::resolve::Resolution;
281
282    fn task(id: &str, project: &str) -> Task {
283        Task {
284            id: id.into(),
285            project_id: project.into(),
286            title: format!("task {id}"),
287            body: String::new(),
288            priority: Default::default(),
289            labels: vec![],
290            created_by_dev: "d".into(),
291            created_by_machine: "m".into(),
292            created: "2026-01-01T00:00:00Z".into(),
293        }
294    }
295
296    fn dep(id: &str, blocker: &str, blocked: &str) -> Dependency {
297        Dependency {
298            id: id.into(),
299            project_id: "p".into(),
300            blocker_task_id: blocker.into(),
301            blocked_task_id: blocked.into(),
302            blocker_project_id: None,
303            blocker_ref: None,
304            via: None,
305        }
306    }
307
308    fn open(id: &str) -> (String, ResolvedStatus) {
309        (
310            id.into(),
311            ResolvedStatus {
312                status: Status::Open,
313                resolution: Resolution::Exact,
314            },
315        )
316    }
317
318    fn tasks_of(ts: Vec<Task>) -> BTreeMap<String, Task> {
319        ts.into_iter().map(|t| (t.id.to_string(), t)).collect()
320    }
321    fn deps_of(ds: Vec<Dependency>) -> BTreeMap<String, Dependency> {
322        ds.into_iter().map(|d| (d.id.to_string(), d)).collect()
323    }
324
325    #[test]
326    fn chain_layers_0_1_2() {
327        let tasks = tasks_of(vec![task("a", "p"), task("b", "p"), task("c", "p")]);
328        let deps = deps_of(vec![dep("d1", "a", "b"), dep("d2", "b", "c")]);
329        let statuses = [open("a"), open("b"), open("c")].into_iter().collect();
330        let g = build(&tasks, &deps, &statuses);
331        let layer = |id: &str| g.nodes.iter().find(|n| n.task_id == id).unwrap().layer;
332        assert_eq!(layer("a"), 0);
333        assert_eq!(layer("b"), 1);
334        assert_eq!(layer("c"), 2);
335        assert!(g.unconnected.is_empty());
336        assert!(g.broken_cycles.is_empty());
337    }
338
339    #[test]
340    fn diamond_takes_longest_path() {
341        // a->b, a->c, b->d, c->d : d must be layer 2 (via either arm).
342        let tasks = tasks_of(vec![
343            task("a", "p"),
344            task("b", "p"),
345            task("c", "p"),
346            task("d", "p"),
347        ]);
348        let deps = deps_of(vec![
349            dep("1", "a", "b"),
350            dep("2", "a", "c"),
351            dep("3", "b", "d"),
352            dep("4", "c", "d"),
353        ]);
354        let statuses = ["a", "b", "c", "d"].iter().map(|i| open(i)).collect();
355        let g = build(&tasks, &deps, &statuses);
356        let layer = |id: &str| g.nodes.iter().find(|n| n.task_id == id).unwrap().layer;
357        assert_eq!(layer("d"), 2);
358    }
359
360    #[test]
361    fn cycle_is_broken_and_recorded_without_hanging() {
362        let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
363        let deps = deps_of(vec![dep("1", "a", "b"), dep("2", "b", "a")]);
364        let statuses = [open("a"), open("b")].into_iter().collect();
365        let g = build(&tasks, &deps, &statuses);
366        assert!(!g.broken_cycles.is_empty(), "cycle must be reported");
367        assert_eq!(g.nodes.len(), 2, "both nodes still present");
368    }
369
370    #[test]
371    fn unconnected_tasks_are_excluded_from_nodes() {
372        let tasks = tasks_of(vec![task("a", "p"), task("b", "p"), task("lonely", "p")]);
373        let deps = deps_of(vec![dep("1", "a", "b")]);
374        let statuses = ["a", "b", "lonely"].iter().map(|i| open(i)).collect();
375        let g = build(&tasks, &deps, &statuses);
376        assert!(!g.nodes.iter().any(|n| n.task_id == "lonely"));
377        assert_eq!(g.unconnected, vec!["lonely".to_string()]);
378    }
379
380    #[test]
381    fn closed_blocker_marks_edge_resolved() {
382        let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
383        let deps = deps_of(vec![dep("1", "a", "b")]);
384        let statuses = [
385            (
386                "a".to_string(),
387                ResolvedStatus {
388                    status: Status::Closed,
389                    resolution: Resolution::Facts,
390                },
391            ),
392            open("b"),
393        ]
394        .into_iter()
395        .collect();
396        let g = build(&tasks, &deps, &statuses);
397        assert!(g.edges[0].resolved);
398    }
399
400    #[test]
401    fn cross_project_edge_carries_project_and_via() {
402        let tasks = tasks_of(vec![task("local", "downstream")]);
403        let mut d = dep("1", "foreign", "local");
404        d.blocker_project_id = Some("upstream".into());
405        d.via = Some("cargo: crates.io myko >=4.24.4".into());
406        let deps = deps_of(vec![d]);
407        let statuses = [open("local")].into_iter().collect();
408        let g = build(&tasks, &deps, &statuses);
409        assert_eq!(g.edges[0].blocker_project_id.as_deref(), Some("upstream"));
410        assert_eq!(
411            g.edges[0].via.as_deref(),
412            Some("cargo: crates.io myko >=4.24.4")
413        );
414        // The foreign blocker isn't a known task -> stub node.
415        let stub = g.nodes.iter().find(|n| n.task_id == "foreign").unwrap();
416        assert!(stub.stub);
417        assert_eq!(stub.project_id, "upstream");
418    }
419
420    #[test]
421    fn no_dependencies_yields_empty_graph_all_unconnected() {
422        let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
423        let g = build(
424            &tasks,
425            &deps_of(vec![]),
426            &[open("a"), open("b")].into_iter().collect(),
427        );
428        assert!(g.nodes.is_empty());
429        assert!(g.edges.is_empty());
430        assert_eq!(g.unconnected.len(), 2);
431    }
432
433    // Silence unused-import warnings when StatusKind isn't otherwise used.
434    #[allow(dead_code)]
435    fn _uses_status_kind() -> StatusKind {
436        StatusKind::Closed
437    }
438}