//! Task dependency commands: the blocking graph. //! //! The edges are the stored truth; readiness, depth, downstream count and cycles //! are derived from them and cached on each task, so nothing here lets the //! frontend set a "blocked" flag directly. The only writes are drawing an edge //! and removing one. use std::sync::Arc; use serde::Serialize; use tauri::State; use tracing::instrument; use goingson_core::{LinkedTaskRef, ProjectId, TaskGraph, TaskId}; use super::ApiError; use super::task::TaskResponse; use crate::state::{AppState, DESKTOP_USER_ID}; /// One end of an edge, as the task detail panel renders it. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct LinkedTaskResponse { pub id: TaskId, pub title: String, pub status: String, /// Whether this task has stopped gating. A blocker list carries every edge /// ever drawn, so this is what separates what is still in the way from what /// the task merely waited for once. pub satisfied: bool, pub project_name: Option, } impl From for LinkedTaskResponse { fn from(r: LinkedTaskRef) -> Self { Self { satisfied: r.is_satisfied(), id: r.id, status: r.status.as_str().to_string(), title: r.title, project_name: r.project_name, } } } /// Both directions of a task's edges, for the detail panel. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TaskDependencyResponse { /// Tasks this one waits on. pub blocked_by: Vec, /// Tasks waiting on this one. pub blocks: Vec, } /// A node in the graph view. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GraphNodeResponse { pub id: TaskId, pub title: String, pub status: String, pub priority: String, pub project_id: Option, pub block_depth: u32, pub unblocks_count: u32, pub in_cycle: bool, pub is_blocked: bool, } /// An edge in the graph view: `blocker` must finish before `blocked`. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GraphEdgeResponse { pub blocked_id: TaskId, pub blocker_id: TaskId, } /// The whole graph for the DAG view. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct TaskGraphResponse { pub nodes: Vec, pub edges: Vec, /// Each cycle as the ids on it. Empty in the healthy case; a non-empty list /// is a defect the view surfaces for repair, since nothing on a cycle can /// ever become ready. pub cycles: Vec>, } impl From for TaskGraphResponse { fn from(g: TaskGraph) -> Self { Self { nodes: g .nodes .into_iter() .map(|n| GraphNodeResponse { is_blocked: n.position.is_blocked() || n.position.in_cycle, block_depth: n.position.block_depth, unblocks_count: n.position.unblocks_count, in_cycle: n.position.in_cycle, id: n.id, title: n.title, status: n.status.as_str().to_string(), priority: n.priority.as_str().to_string(), project_id: n.project_id, }) .collect(), edges: g .edges .into_iter() .map(|e| GraphEdgeResponse { blocked_id: e.blocked_id, blocker_id: e.blocker_id, }) .collect(), cycles: g.cycles, } } } /// Lists a task's blockers and dependents. /// /// # Errors /// /// Returns `DATABASE_ERROR` if either query fails. #[tauri::command] #[instrument(skip_all)] pub async fn get_task_dependencies( state: State<'_, Arc>, id: TaskId, ) -> Result { let blocked_by = state.tasks.list_blockers(DESKTOP_USER_ID, id)?; let blocks = state.tasks.list_dependents(DESKTOP_USER_ID, id)?; Ok(TaskDependencyResponse { blocked_by: blocked_by.into_iter().map(Into::into).collect(), blocks: blocks.into_iter().map(Into::into).collect(), }) } /// Records that `blocker_id` must be completed before `blocked_id` can start. /// /// Idempotent: drawing an existing edge again is a no-op. Returns the dependent /// task, re-read, so the caller sees the position it landed in. /// /// # Errors /// /// Returns `NOT_FOUND` if either task is missing or not the user's. /// Returns `CONFLICT` if the edge would close a dependency cycle; the message /// names the chain already in the way. #[tauri::command] #[instrument(skip_all)] pub async fn add_task_dependency( state: State<'_, Arc>, blocked_id: TaskId, blocker_id: TaskId, ) -> Result { state .tasks .add_dependency(DESKTOP_USER_ID, blocked_id, blocker_id)?; let task = state .tasks .get_by_id(blocked_id, DESKTOP_USER_ID)? .ok_or_else(|| ApiError::not_found("task", blocked_id))?; Ok(TaskResponse::from(task)) } /// Removes a blocking edge. /// /// Use this when the dependency was not real. Completing the blocker is the /// other way an edge stops mattering, and it keeps the record that the wait /// happened. /// /// # Errors /// /// Returns `NOT_FOUND` if there was no such edge, so the caller can tell a /// removal from a no-op. #[tauri::command] #[instrument(skip_all)] pub async fn remove_task_dependency( state: State<'_, Arc>, blocked_id: TaskId, blocker_id: TaskId, ) -> Result { let removed = state .tasks .remove_dependency(DESKTOP_USER_ID, blocked_id, blocker_id)?; if !removed { return Err(ApiError::not_found("dependency", blocked_id)); } let task = state .tasks .get_by_id(blocked_id, DESKTOP_USER_ID)? .ok_or_else(|| ApiError::not_found("task", blocked_id))?; Ok(TaskResponse::from(task)) } /// Fetches the dependency graph for the DAG view. /// /// `project_id` narrows the scope. A project's graph still includes blockers /// living in other projects, because those are the reason its tasks are not /// ready; cutting them would draw a task as available when it is not. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn get_task_graph( state: State<'_, Arc>, project_id: Option, ) -> Result { Ok(state.tasks.task_graph(DESKTOP_USER_ID, project_id)?.into()) } /// Lists the tasks that can be started, up to `depth` blocker-hops back. /// /// `depth` 0 (the default) is work available now. Higher values also return /// what opens after that many more completions, which is what the planning /// views want. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the query fails. #[tauri::command] #[instrument(skip_all)] pub async fn list_ready_tasks( state: State<'_, Arc>, project_id: Option, depth: Option, limit: Option, ) -> Result, ApiError> { let tasks = state .tasks .list_ready(DESKTOP_USER_ID, project_id, depth.unwrap_or(0), limit)?; Ok(tasks.into_iter().map(TaskResponse::from).collect()) } /// Rebuilds the cached graph columns from the edges. /// /// The columns are a derived cache, so this is always safe to run. It is the /// repair path when something has written task state behind the repository, and /// what the frontend calls after a restore. /// /// # Errors /// /// Returns `DATABASE_ERROR` if the rebuild fails. #[tauri::command] #[instrument(skip_all)] pub async fn recompute_task_graph(state: State<'_, Arc>) -> Result { Ok(state.tasks.recompute_graph(DESKTOP_USER_ID)?) }