//! The task blocking graph: edges, per-task graph position, and the whole-graph //! projection the DAG view and `get_task_graph` read. //! //! One relation only. An edge says `blocker` must be Completed before `blocked` //! can be started. See `migrations/sqlite/065_task_dependencies.sql` for why //! there is no second edge kind and why this is not built on //! [`Subtask::linked_task_id`](super::Subtask::linked_task_id). //! //! Nothing here stores blocked-ness as a fact about a task. A task is blocked //! because of the edges around it, and [`GraphPosition`] is the recomputed //! summary of that, never an independently editable flag. use crate::id_types::{TaskDependencyId, TaskId}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; /// Fixed namespace UUID for GoingsOn dependency-edge ids (generated once, never /// changes). See [`TaskDependency::deterministic_id`]. const GOINGSON_TASK_DEPENDENCY_NS: uuid::Uuid = uuid::Uuid::from_bytes([ 0x84, 0x1b, 0x2e, 0xc5, 0x7f, 0x36, 0x5a, 0x90, 0xb4, 0x28, 0x1d, 0x6f, 0x39, 0xa7, 0xc0, 0x1e, ]); /// One edge of the blocking graph: `blocker_id` gates `blocked_id`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskDependency { /// Deterministic identifier (UUID v5 of `:`). pub id: TaskDependencyId, /// The task that has to wait. pub blocked_id: TaskId, /// The task it is waiting on. pub blocker_id: TaskId, /// When the edge was drawn. pub created_at: DateTime, } impl TaskDependency { /// The deterministic row id for a `(blocker, blocked)` pair. /// /// Content-derived and pure, so two devices that draw the same edge while /// offline produce the same id and the changelog's id-keyed upsert collapses /// them to one row. The pair is ordered in the key (blocker first), so the /// reverse edge is a different id, which it must be: the reverse edge is a /// different, and generally wrong, claim. pub fn deterministic_id(blocker_id: TaskId, blocked_id: TaskId) -> TaskDependencyId { let key = format!("{blocker_id}:{blocked_id}"); TaskDependencyId::from(uuid::Uuid::new_v5( &GOINGSON_TASK_DEPENDENCY_NS, key.as_bytes(), )) } } /// A task named as the other end of an edge: enough to render a blocker or /// dependent in a list without fetching the whole task. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LinkedTaskRef { /// The task at the other end. pub id: TaskId, /// Its short label. pub title: String, /// Its lifecycle status, which is what decides whether it still gates. pub status: super::TaskStatus, /// Its project's name, for cross-project edges (a blocker is frequently in /// another project, and an unqualified title reads as if it were local). pub project_name: Option, } impl LinkedTaskRef { /// Whether this task still gates anything downstream. /// /// Only `Completed` satisfies a dependency in the ordinary way. `Deleted` /// also stops gating, because a task that no longer exists cannot be waited /// on and leaving the edge live would strand its dependents forever. That is /// a real event rather than a quiet one, so the repository reports edges /// satisfied this way instead of just dropping them. pub fn is_satisfied(&self) -> bool { matches!( self.status, super::TaskStatus::Completed | super::TaskStatus::Deleted ) } } /// Where a task sits in the graph. Recomputed from the edges, cached on the /// task row, never edited directly. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GraphPosition { /// Sequential steps before this task can start. `0` means ready now. /// /// The LONGEST chain of incomplete blockers ahead of the task, not the /// shortest: a task waits for every one of its blockers, so the chain that /// decides when it opens is the slowest one. pub block_depth: u32, /// How many tasks are transitively downstream, and so are freed by /// finishing this one. The critical-path signal. pub unblocks_count: u32, /// Whether this task sits on a dependency cycle. /// /// The repository refuses to draw a cycle, but two devices can each add a /// legal edge that only forms one once merged. A task in a cycle can never /// become ready, so it reads as blocked and is reported here for repair /// rather than being silently dropped from every view. pub in_cycle: bool, } impl GraphPosition { /// Whether anything incomplete is currently in this task's way. pub fn is_blocked(&self) -> bool { self.block_depth > 0 } /// Whether this task can be started right now. pub fn is_ready(&self) -> bool { self.block_depth == 0 } } /// The whole blocking graph for a scope (a project, or a user's tasks), as the /// DAG view and `get_task_graph` want it: nodes with their positions, the edges /// between them, and whatever cycles are present. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskGraph { /// Every task in scope that touches at least one edge, plus its position. pub nodes: Vec, /// The edges among those nodes. pub edges: Vec, /// Each cycle found, as the task ids on it. Empty in the healthy case. pub cycles: Vec>, } impl TaskGraph { /// The tasks that can be started right now, most-unblocking first. /// /// This is the ordering the ready-work views want: among tasks that are /// equally startable, the one that frees the most downstream work is the one /// worth starting. pub fn ready(&self) -> Vec<&TaskGraphNode> { let mut ready: Vec<&TaskGraphNode> = self .nodes .iter() .filter(|n| n.position.is_ready()) .collect(); ready.sort_by_key(|n| std::cmp::Reverse(n.position.unblocks_count)); ready } /// Whether the graph is a DAG. False means sync merged a cycle into it. pub fn is_acyclic(&self) -> bool { self.cycles.is_empty() } } /// One task in a [`TaskGraph`]. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TaskGraphNode { /// The task. pub id: TaskId, /// Its short label. pub title: String, /// Its lifecycle status. pub status: super::TaskStatus, /// Its priority. pub priority: super::Priority, /// Its project, if filed. pub project_id: Option, /// Its graph position. pub position: GraphPosition, } /// What a task still waits on, judged against a day plan. /// /// The blocking rule everywhere else is absolute: a task is unavailable until /// its blockers are done. Inside a plan that is too strict, because the plan is /// itself a statement about order. Scheduling A says A happens today, which is /// exactly the thing that makes it reasonable to also schedule B. /// /// So a plan widens the "has this stopped gating" test from `Completed` to /// "completed, or already in this plan". The widening is not stored and is not a /// property of the task; it holds only for the plan it was computed against. /// /// This cascades without special-casing. With A blocking B blocking C, /// scheduling A unlocks B, and scheduling B unlocks C, because each pass reads /// the plan as it now stands. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlanGate { /// The blockers that are still unfinished, in the order the edges were /// drawn. Never empty: a task with nothing in its way has no gate at all. pub after: Vec, /// Whether every unfinished blocker is itself in the plan, which is what /// makes this task schedulable despite being blocked. pub unlocked_by_plan: bool, /// Whether a blocker in the plan is scheduled to start later than this task /// does. /// /// Presence-only gating permits this on purpose: enforcing the order would /// mean a task leaving the plan as it is dragged, which fights the person /// rearranging their day. The incoherence is reported instead, and the /// marker naming the blocker is the nudge. pub out_of_order: bool, } impl PlanGate { /// The blocker to name in a one-line marker: the first still-unfinished one. /// /// One name rather than a list because this renders inside a task chip. The /// count carries the rest. pub fn lead_blocker(&self) -> Option<&LinkedTaskRef> { self.after.first() } } /// Why an edge could not be drawn. /// /// Both variants are refusals rather than failures: the caller asked for /// something the graph cannot hold, and the useful reply names the path that /// makes it impossible. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DependencyRejection { /// The edge would close a cycle. Carries the existing path from the /// prospective blocked task back to the prospective blocker, so the caller /// can show which chain is already in the way instead of just refusing. WouldCycle { path: Vec }, /// A task cannot block itself. SelfEdge { task_id: TaskId }, } impl std::fmt::Display for DependencyRejection { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::WouldCycle { path } => { let chain: Vec = path.iter().map(ToString::to_string).collect(); write!( f, "that edge would close a dependency cycle: {} already depends on it", chain.join(" -> ") ) } Self::SelfEdge { task_id } => write!(f, "task {task_id} cannot block itself"), } } } impl std::error::Error for DependencyRejection {} #[cfg(test)] mod tests { use super::*; use crate::models::{Priority, TaskStatus}; fn node(title: &str, depth: u32, unblocks: u32) -> TaskGraphNode { TaskGraphNode { id: TaskId::new(), title: title.to_string(), status: TaskStatus::Pending, priority: Priority::Medium, project_id: None, position: GraphPosition { block_depth: depth, unblocks_count: unblocks, in_cycle: false, }, } } #[test] fn edge_id_is_stable_content_derived_and_directional() { let a = TaskId::new(); let b = TaskId::new(); assert_eq!( TaskDependency::deterministic_id(a, b), TaskDependency::deterministic_id(a, b), "same pair must yield the same id so two devices converge" ); assert_ne!( TaskDependency::deterministic_id(a, b), TaskDependency::deterministic_id(b, a), "the reverse edge is a different claim and must be a different row" ); assert_eq!( TaskDependency::deterministic_id(a, b) .as_uuid() .get_version_num(), 5 ); } #[test] fn a_position_is_blocked_exactly_when_it_has_depth() { let ready = GraphPosition::default(); assert!(ready.is_ready()); assert!(!ready.is_blocked()); let waiting = GraphPosition { block_depth: 1, ..Default::default() }; assert!(waiting.is_blocked()); assert!(!waiting.is_ready()); } #[test] fn completed_and_deleted_blockers_stop_gating_but_nothing_else_does() { let mut r = LinkedTaskRef { id: TaskId::new(), title: "blocker".into(), status: TaskStatus::Pending, project_name: None, }; assert!(!r.is_satisfied()); r.status = TaskStatus::Started; assert!(!r.is_satisfied(), "started is not finished"); r.status = TaskStatus::Completed; assert!(r.is_satisfied()); r.status = TaskStatus::Deleted; assert!( r.is_satisfied(), "a deleted blocker would otherwise strand its dependents forever" ); } #[test] fn ready_returns_only_startable_nodes_most_unblocking_first() { let graph = TaskGraph { nodes: vec![ node("frees one", 0, 1), node("waiting", 2, 9), node("frees four", 0, 4), node("frees none", 0, 0), ], edges: vec![], cycles: vec![], }; let ready: Vec<&str> = graph.ready().iter().map(|n| n.title.as_str()).collect(); assert_eq!( ready, vec!["frees four", "frees one", "frees none"], "blocked nodes are excluded however much they would free" ); } #[test] fn a_graph_with_a_cycle_is_not_acyclic() { let mut graph = TaskGraph::default(); assert!(graph.is_acyclic(), "an empty graph is trivially a DAG"); graph.cycles.push(vec![TaskId::new(), TaskId::new()]); assert!(!graph.is_acyclic()); } #[test] fn a_cycle_rejection_names_the_path_already_in_the_way() { let a = TaskId::new(); let b = TaskId::new(); let msg = DependencyRejection::WouldCycle { path: vec![a, b] }.to_string(); assert!(msg.contains(&a.to_string()) && msg.contains(&b.to_string())); assert!(msg.contains("->"), "the path reads as a chain"); } }