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::MaterializeDefinite<std::sync::Arc<Self::Output>> {
228        use myko::hyphae::JoinExt;
229        let tasks = ctx.query_map(crate::GetAllTasks {}).items();
230        let deps = ctx.query_map(crate::GetAllDependencys {}).items();
231        let changes = ctx.query_map(crate::GetAllStatusChanges {}).items();
232        tasks
233            .join(&deps)
234            .join(&changes)
235            .map(|((tasks, deps), changes)| {
236                std::sync::Arc::new(IssueGraphOut {
237                    graph: build_from_live(tasks, deps, changes),
238                })
239            })
240    }
241}
242
243/// Fold live (Arc-wrapped) entities into the graph: unanchored status per
244/// project, then [`build`]. Shared by the report and its test.
245pub fn build_from_live(
246    tasks: &[std::sync::Arc<Task>],
247    deps: &[std::sync::Arc<Dependency>],
248    changes: &[std::sync::Arc<crate::StatusChange>],
249) -> IssueGraph {
250    let tasks: Vec<Task> = tasks.iter().map(|t| (**t).clone()).collect();
251    let changes: Vec<crate::StatusChange> = changes.iter().map(|c| (**c).clone()).collect();
252
253    let projects: BTreeSet<&str> = tasks.iter().map(|t| t.project_id.as_str()).collect();
254    let mut statuses = BTreeMap::new();
255    for pid in projects {
256        statuses.extend(crate::crossproject::statuses_unanchored(
257            &tasks, &changes, pid,
258        ));
259    }
260    let task_map: BTreeMap<String, Task> =
261        tasks.into_iter().map(|t| (t.id.to_string(), t)).collect();
262    let dep_map: BTreeMap<String, Dependency> = deps
263        .iter()
264        .map(|d| (d.id.to_string(), (**d).clone()))
265        .collect();
266    build(&task_map, &dep_map, &statuses)
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::entities::StatusKind;
273    use crate::resolve::Resolution;
274
275    fn task(id: &str, project: &str) -> Task {
276        Task {
277            id: id.into(),
278            project_id: project.into(),
279            title: format!("task {id}"),
280            body: String::new(),
281            priority: Default::default(),
282            labels: vec![],
283            created_by_dev: "d".into(),
284            created_by_machine: "m".into(),
285            created: "2026-01-01T00:00:00Z".into(),
286        }
287    }
288
289    fn dep(id: &str, blocker: &str, blocked: &str) -> Dependency {
290        Dependency {
291            id: id.into(),
292            project_id: "p".into(),
293            blocker_task_id: blocker.into(),
294            blocked_task_id: blocked.into(),
295            blocker_project_id: None,
296            blocker_ref: None,
297            via: None,
298        }
299    }
300
301    fn open(id: &str) -> (String, ResolvedStatus) {
302        (
303            id.into(),
304            ResolvedStatus {
305                status: Status::Open,
306                resolution: Resolution::Exact,
307            },
308        )
309    }
310
311    fn tasks_of(ts: Vec<Task>) -> BTreeMap<String, Task> {
312        ts.into_iter().map(|t| (t.id.to_string(), t)).collect()
313    }
314    fn deps_of(ds: Vec<Dependency>) -> BTreeMap<String, Dependency> {
315        ds.into_iter().map(|d| (d.id.to_string(), d)).collect()
316    }
317
318    #[test]
319    fn chain_layers_0_1_2() {
320        let tasks = tasks_of(vec![task("a", "p"), task("b", "p"), task("c", "p")]);
321        let deps = deps_of(vec![dep("d1", "a", "b"), dep("d2", "b", "c")]);
322        let statuses = [open("a"), open("b"), open("c")].into_iter().collect();
323        let g = build(&tasks, &deps, &statuses);
324        let layer = |id: &str| g.nodes.iter().find(|n| n.task_id == id).unwrap().layer;
325        assert_eq!(layer("a"), 0);
326        assert_eq!(layer("b"), 1);
327        assert_eq!(layer("c"), 2);
328        assert!(g.unconnected.is_empty());
329        assert!(g.broken_cycles.is_empty());
330    }
331
332    #[test]
333    fn diamond_takes_longest_path() {
334        // a->b, a->c, b->d, c->d : d must be layer 2 (via either arm).
335        let tasks = tasks_of(vec![
336            task("a", "p"),
337            task("b", "p"),
338            task("c", "p"),
339            task("d", "p"),
340        ]);
341        let deps = deps_of(vec![
342            dep("1", "a", "b"),
343            dep("2", "a", "c"),
344            dep("3", "b", "d"),
345            dep("4", "c", "d"),
346        ]);
347        let statuses = ["a", "b", "c", "d"].iter().map(|i| open(i)).collect();
348        let g = build(&tasks, &deps, &statuses);
349        let layer = |id: &str| g.nodes.iter().find(|n| n.task_id == id).unwrap().layer;
350        assert_eq!(layer("d"), 2);
351    }
352
353    #[test]
354    fn cycle_is_broken_and_recorded_without_hanging() {
355        let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
356        let deps = deps_of(vec![dep("1", "a", "b"), dep("2", "b", "a")]);
357        let statuses = [open("a"), open("b")].into_iter().collect();
358        let g = build(&tasks, &deps, &statuses);
359        assert!(!g.broken_cycles.is_empty(), "cycle must be reported");
360        assert_eq!(g.nodes.len(), 2, "both nodes still present");
361    }
362
363    #[test]
364    fn unconnected_tasks_are_excluded_from_nodes() {
365        let tasks = tasks_of(vec![task("a", "p"), task("b", "p"), task("lonely", "p")]);
366        let deps = deps_of(vec![dep("1", "a", "b")]);
367        let statuses = ["a", "b", "lonely"].iter().map(|i| open(i)).collect();
368        let g = build(&tasks, &deps, &statuses);
369        assert!(!g.nodes.iter().any(|n| n.task_id == "lonely"));
370        assert_eq!(g.unconnected, vec!["lonely".to_string()]);
371    }
372
373    #[test]
374    fn closed_blocker_marks_edge_resolved() {
375        let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
376        let deps = deps_of(vec![dep("1", "a", "b")]);
377        let statuses = [
378            (
379                "a".to_string(),
380                ResolvedStatus {
381                    status: Status::Closed,
382                    resolution: Resolution::Facts,
383                },
384            ),
385            open("b"),
386        ]
387        .into_iter()
388        .collect();
389        let g = build(&tasks, &deps, &statuses);
390        assert!(g.edges[0].resolved);
391    }
392
393    #[test]
394    fn cross_project_edge_carries_project_and_via() {
395        let tasks = tasks_of(vec![task("local", "downstream")]);
396        let mut d = dep("1", "foreign", "local");
397        d.blocker_project_id = Some("upstream".into());
398        d.via = Some("cargo: crates.io myko >=4.24.4".into());
399        let deps = deps_of(vec![d]);
400        let statuses = [open("local")].into_iter().collect();
401        let g = build(&tasks, &deps, &statuses);
402        assert_eq!(g.edges[0].blocker_project_id.as_deref(), Some("upstream"));
403        assert_eq!(
404            g.edges[0].via.as_deref(),
405            Some("cargo: crates.io myko >=4.24.4")
406        );
407        // The foreign blocker isn't a known task -> stub node.
408        let stub = g.nodes.iter().find(|n| n.task_id == "foreign").unwrap();
409        assert!(stub.stub);
410        assert_eq!(stub.project_id, "upstream");
411    }
412
413    #[test]
414    fn no_dependencies_yields_empty_graph_all_unconnected() {
415        let tasks = tasks_of(vec![task("a", "p"), task("b", "p")]);
416        let g = build(
417            &tasks,
418            &deps_of(vec![]),
419            &[open("a"), open("b")].into_iter().collect(),
420        );
421        assert!(g.nodes.is_empty());
422        assert!(g.edges.is_empty());
423        assert_eq!(g.unconnected.len(), 2);
424    }
425
426    // Silence unused-import warnings when StatusKind isn't otherwise used.
427    #[allow(dead_code)]
428    fn _uses_status_kind() -> StatusKind {
429        StatusKind::Closed
430    }
431}