Skip to main content

max / goingson

7.9 KB · 256 lines History Blame Raw
1 //! Task dependency commands: the blocking graph.
2 //!
3 //! The edges are the stored truth; readiness, depth, downstream count and cycles
4 //! are derived from them and cached on each task, so nothing here lets the
5 //! frontend set a "blocked" flag directly. The only writes are drawing an edge
6 //! and removing one.
7
8 use std::sync::Arc;
9
10 use serde::Serialize;
11 use tauri::State;
12 use tracing::instrument;
13
14 use goingson_core::{LinkedTaskRef, ProjectId, TaskGraph, TaskId};
15
16 use super::ApiError;
17 use super::task::TaskResponse;
18 use crate::state::{AppState, DESKTOP_USER_ID};
19
20 /// One end of an edge, as the task detail panel renders it.
21 #[derive(Debug, Serialize)]
22 #[serde(rename_all = "camelCase")]
23 pub struct LinkedTaskResponse {
24 pub id: TaskId,
25 pub title: String,
26 pub status: String,
27 /// Whether this task has stopped gating. A blocker list carries every edge
28 /// ever drawn, so this is what separates what is still in the way from what
29 /// the task merely waited for once.
30 pub satisfied: bool,
31 pub project_name: Option<String>,
32 }
33
34 impl From<LinkedTaskRef> for LinkedTaskResponse {
35 fn from(r: LinkedTaskRef) -> Self {
36 Self {
37 satisfied: r.is_satisfied(),
38 id: r.id,
39 status: r.status.as_str().to_string(),
40 title: r.title,
41 project_name: r.project_name,
42 }
43 }
44 }
45
46 /// Both directions of a task's edges, for the detail panel.
47 #[derive(Debug, Serialize)]
48 #[serde(rename_all = "camelCase")]
49 pub struct TaskDependencyResponse {
50 /// Tasks this one waits on.
51 pub blocked_by: Vec<LinkedTaskResponse>,
52 /// Tasks waiting on this one.
53 pub blocks: Vec<LinkedTaskResponse>,
54 }
55
56 /// A node in the graph view.
57 #[derive(Debug, Serialize)]
58 #[serde(rename_all = "camelCase")]
59 pub struct GraphNodeResponse {
60 pub id: TaskId,
61 pub title: String,
62 pub status: String,
63 pub priority: String,
64 pub project_id: Option<ProjectId>,
65 pub block_depth: u32,
66 pub unblocks_count: u32,
67 pub in_cycle: bool,
68 pub is_blocked: bool,
69 }
70
71 /// An edge in the graph view: `blocker` must finish before `blocked`.
72 #[derive(Debug, Serialize)]
73 #[serde(rename_all = "camelCase")]
74 pub struct GraphEdgeResponse {
75 pub blocked_id: TaskId,
76 pub blocker_id: TaskId,
77 }
78
79 /// The whole graph for the DAG view.
80 #[derive(Debug, Serialize)]
81 #[serde(rename_all = "camelCase")]
82 pub struct TaskGraphResponse {
83 pub nodes: Vec<GraphNodeResponse>,
84 pub edges: Vec<GraphEdgeResponse>,
85 /// Each cycle as the ids on it. Empty in the healthy case; a non-empty list
86 /// is a defect the view surfaces for repair, since nothing on a cycle can
87 /// ever become ready.
88 pub cycles: Vec<Vec<TaskId>>,
89 }
90
91 impl From<TaskGraph> for TaskGraphResponse {
92 fn from(g: TaskGraph) -> Self {
93 Self {
94 nodes: g
95 .nodes
96 .into_iter()
97 .map(|n| GraphNodeResponse {
98 is_blocked: n.position.is_blocked() || n.position.in_cycle,
99 block_depth: n.position.block_depth,
100 unblocks_count: n.position.unblocks_count,
101 in_cycle: n.position.in_cycle,
102 id: n.id,
103 title: n.title,
104 status: n.status.as_str().to_string(),
105 priority: n.priority.as_str().to_string(),
106 project_id: n.project_id,
107 })
108 .collect(),
109 edges: g
110 .edges
111 .into_iter()
112 .map(|e| GraphEdgeResponse {
113 blocked_id: e.blocked_id,
114 blocker_id: e.blocker_id,
115 })
116 .collect(),
117 cycles: g.cycles,
118 }
119 }
120 }
121
122 /// Lists a task's blockers and dependents.
123 ///
124 /// # Errors
125 ///
126 /// Returns `DATABASE_ERROR` if either query fails.
127 #[tauri::command]
128 #[instrument(skip_all)]
129 pub async fn get_task_dependencies(
130 state: State<'_, Arc<AppState>>,
131 id: TaskId,
132 ) -> Result<TaskDependencyResponse, ApiError> {
133 let blocked_by = state.tasks.list_blockers(DESKTOP_USER_ID, id)?;
134 let blocks = state.tasks.list_dependents(DESKTOP_USER_ID, id)?;
135
136 Ok(TaskDependencyResponse {
137 blocked_by: blocked_by.into_iter().map(Into::into).collect(),
138 blocks: blocks.into_iter().map(Into::into).collect(),
139 })
140 }
141
142 /// Records that `blocker_id` must be completed before `blocked_id` can start.
143 ///
144 /// Idempotent: drawing an existing edge again is a no-op. Returns the dependent
145 /// task, re-read, so the caller sees the position it landed in.
146 ///
147 /// # Errors
148 ///
149 /// Returns `NOT_FOUND` if either task is missing or not the user's.
150 /// Returns `CONFLICT` if the edge would close a dependency cycle; the message
151 /// names the chain already in the way.
152 #[tauri::command]
153 #[instrument(skip_all)]
154 pub async fn add_task_dependency(
155 state: State<'_, Arc<AppState>>,
156 blocked_id: TaskId,
157 blocker_id: TaskId,
158 ) -> Result<TaskResponse, ApiError> {
159 state
160 .tasks
161 .add_dependency(DESKTOP_USER_ID, blocked_id, blocker_id)?;
162
163 let task = state
164 .tasks
165 .get_by_id(blocked_id, DESKTOP_USER_ID)?
166 .ok_or_else(|| ApiError::not_found("task", blocked_id))?;
167 Ok(TaskResponse::from(task))
168 }
169
170 /// Removes a blocking edge.
171 ///
172 /// Use this when the dependency was not real. Completing the blocker is the
173 /// other way an edge stops mattering, and it keeps the record that the wait
174 /// happened.
175 ///
176 /// # Errors
177 ///
178 /// Returns `NOT_FOUND` if there was no such edge, so the caller can tell a
179 /// removal from a no-op.
180 #[tauri::command]
181 #[instrument(skip_all)]
182 pub async fn remove_task_dependency(
183 state: State<'_, Arc<AppState>>,
184 blocked_id: TaskId,
185 blocker_id: TaskId,
186 ) -> Result<TaskResponse, ApiError> {
187 let removed = state
188 .tasks
189 .remove_dependency(DESKTOP_USER_ID, blocked_id, blocker_id)?;
190 if !removed {
191 return Err(ApiError::not_found("dependency", blocked_id));
192 }
193
194 let task = state
195 .tasks
196 .get_by_id(blocked_id, DESKTOP_USER_ID)?
197 .ok_or_else(|| ApiError::not_found("task", blocked_id))?;
198 Ok(TaskResponse::from(task))
199 }
200
201 /// Fetches the dependency graph for the DAG view.
202 ///
203 /// `project_id` narrows the scope. A project's graph still includes blockers
204 /// living in other projects, because those are the reason its tasks are not
205 /// ready; cutting them would draw a task as available when it is not.
206 ///
207 /// # Errors
208 ///
209 /// Returns `DATABASE_ERROR` if the query fails.
210 #[tauri::command]
211 #[instrument(skip_all)]
212 pub async fn get_task_graph(
213 state: State<'_, Arc<AppState>>,
214 project_id: Option<ProjectId>,
215 ) -> Result<TaskGraphResponse, ApiError> {
216 Ok(state.tasks.task_graph(DESKTOP_USER_ID, project_id)?.into())
217 }
218
219 /// Lists the tasks that can be started, up to `depth` blocker-hops back.
220 ///
221 /// `depth` 0 (the default) is work available now. Higher values also return
222 /// what opens after that many more completions, which is what the planning
223 /// views want.
224 ///
225 /// # Errors
226 ///
227 /// Returns `DATABASE_ERROR` if the query fails.
228 #[tauri::command]
229 #[instrument(skip_all)]
230 pub async fn list_ready_tasks(
231 state: State<'_, Arc<AppState>>,
232 project_id: Option<ProjectId>,
233 depth: Option<u32>,
234 limit: Option<i64>,
235 ) -> Result<Vec<TaskResponse>, ApiError> {
236 let tasks = state
237 .tasks
238 .list_ready(DESKTOP_USER_ID, project_id, depth.unwrap_or(0), limit)?;
239 Ok(tasks.into_iter().map(TaskResponse::from).collect())
240 }
241
242 /// Rebuilds the cached graph columns from the edges.
243 ///
244 /// The columns are a derived cache, so this is always safe to run. It is the
245 /// repair path when something has written task state behind the repository, and
246 /// what the frontend calls after a restore.
247 ///
248 /// # Errors
249 ///
250 /// Returns `DATABASE_ERROR` if the rebuild fails.
251 #[tauri::command]
252 #[instrument(skip_all)]
253 pub async fn recompute_task_graph(state: State<'_, Arc<AppState>>) -> Result<usize, ApiError> {
254 Ok(state.tasks.recompute_graph(DESKTOP_USER_ID)?)
255 }
256