Skip to main content

max / goingson

11.2 KB · 304 lines History Blame Raw
1 //! Dependency tools: the blocking graph.
2 //!
3 //! `add_dependency` and `remove_dependency` are the write surface,
4 //! `list_ready_tasks` and `get_task_graph` the read surface. Between them they
5 //! answer the question the task tools cannot: not "what is open" but "what can
6 //! actually be started right now, and what does finishing it free".
7 //!
8 //! Blocked-ness is never a tag here. Inferring it from a `blocked` tag or from
9 //! the words in a description is wrong the moment nobody retags a task after
10 //! its blocker closes. These tools read the edges, so the answer moves on its
11 //! own.
12
13 use std::sync::Arc;
14
15 use async_trait::async_trait;
16 use goingson_core::{ProjectId, TaskCrud, TaskDependencies};
17 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
18 use serde_json::{Value, json};
19
20 use super::project_id_field;
21 use crate::caps;
22 use crate::context::Ctx;
23 use crate::convert::{
24 MAX_LIMIT, linked_task_row, parse_limit, parse_project_id, parse_task_id, req_str,
25 task_summary_row,
26 };
27
28 fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
29 Error::ToolFailed {
30 tool: tool.to_string(),
31 message: e.to_string(),
32 }
33 }
34
35 /// Resolve an optional `project_id` argument to a scope.
36 fn project_scope(tool: &str, args: &Value) -> Result<Option<ProjectId>> {
37 match args.get("project_id").and_then(Value::as_str) {
38 Some(raw) if !raw.trim().is_empty() => Ok(Some(parse_project_id(tool, raw)?)),
39 _ => Ok(None),
40 }
41 }
42
43 // reads
44
45 pub struct ListReadyTasks(pub Arc<Ctx>);
46
47 #[async_trait]
48 impl Tool for ListReadyTasks {
49 fn name(&self) -> &'static str {
50 "list_ready_tasks"
51 }
52 fn description(&self) -> &'static str {
53 "List the tasks that can actually be started, newest blockers accounted for. Unlike `list_tasks` this reads the dependency graph: a task waiting on something unfinished is excluded. `depth` (default 0) is how many blocker-hops back to include, so 0 is work available now, 1 also returns what opens after one more completion, and so on. Rows come back shallowest first, then by urgency, so the top row is the best thing to start. Each row carries `unblocks` (how many tasks finishing it would free) and `block_depth`. Optional `project_id` narrows the scope; `limit` caps the reply (default 50, max 200). Snoozed, completed, and deleted tasks never appear."
54 }
55 fn kind(&self) -> ToolKind {
56 ToolKind::Read
57 }
58 fn small_model_safe(&self) -> bool {
59 true
60 }
61 fn input_schema(&self) -> Value {
62 json!({
63 "type": "object",
64 "properties": {
65 "project_id": project_id_field(),
66 "depth": {
67 "type": "integer",
68 "minimum": 0,
69 "description": "How many blocker-hops back to include. 0 (the default) is work that can start now."
70 },
71 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT }
72 }
73 })
74 }
75 async fn call(&self, args: Value) -> Result<ToolCallResult> {
76 let limit = parse_limit(self.name(), args.get("limit"))?;
77 let project_id = project_scope(self.name(), &args)?;
78 let depth = args
79 .get("depth")
80 .and_then(Value::as_u64)
81 .unwrap_or(0)
82 .try_into()
83 .unwrap_or(u32::MAX);
84
85 let tasks = self
86 .0
87 .tasks()
88 .list_ready(
89 self.0.user_id,
90 project_id,
91 depth,
92 Some(i64::try_from(limit).unwrap_or(i64::MAX)),
93 )
94 .map_err(|e| fail(self.name(), e))?;
95
96 let rows: Vec<Value> = tasks.iter().map(task_summary_row).collect();
97 Ok(ToolCallResult::text(
98 serde_json::to_string(&json!({
99 "count": rows.len(),
100 "depth": depth,
101 "tasks": rows,
102 }))
103 .unwrap(),
104 ))
105 }
106 }
107
108 pub struct GetTaskGraph(pub Arc<Ctx>);
109
110 #[async_trait]
111 impl Tool for GetTaskGraph {
112 fn name(&self) -> &'static str {
113 "get_task_graph"
114 }
115 fn description(&self) -> &'static str {
116 "Fetch the dependency graph as nodes and edges in one call, for reasoning about ordering across a whole project. Optional `project_id` narrows it; a project's graph still includes blockers that live in other projects, since those are the reason its tasks are not ready. Tasks with no dependencies either way are omitted. Each node carries `block_depth` and `unblocks`; each edge is `{blocked_id, blocker_id}` meaning the blocker must finish first. `cycles` is normally empty and, when it is not, names the tasks in each loop: those can never become ready and need an edge removed."
117 }
118 fn kind(&self) -> ToolKind {
119 ToolKind::Read
120 }
121 fn small_model_safe(&self) -> bool {
122 true
123 }
124 fn input_schema(&self) -> Value {
125 json!({
126 "type": "object",
127 "properties": { "project_id": project_id_field() }
128 })
129 }
130 async fn call(&self, args: Value) -> Result<ToolCallResult> {
131 let project_id = project_scope(self.name(), &args)?;
132 let graph = self
133 .0
134 .tasks()
135 .task_graph(self.0.user_id, project_id)
136 .map_err(|e| fail(self.name(), e))?;
137
138 let nodes: Vec<Value> = graph
139 .nodes
140 .iter()
141 .map(|n| {
142 let mut row = json!({
143 "id": n.id.to_string(),
144 "title": n.title,
145 "status": n.status.as_str(),
146 "blocked": n.position.is_blocked() || n.position.in_cycle,
147 "block_depth": n.position.block_depth,
148 "unblocks": n.position.unblocks_count,
149 "project_id": n.project_id.map(|p| p.to_string()),
150 });
151 if n.position.in_cycle {
152 row["in_cycle"] = json!(true);
153 }
154 row
155 })
156 .collect();
157
158 let edges: Vec<Value> = graph
159 .edges
160 .iter()
161 .map(|e| {
162 json!({
163 "blocked_id": e.blocked_id.to_string(),
164 "blocker_id": e.blocker_id.to_string(),
165 })
166 })
167 .collect();
168
169 let cycles: Vec<Vec<String>> = graph
170 .cycles
171 .iter()
172 .map(|c| c.iter().map(ToString::to_string).collect())
173 .collect();
174
175 Ok(ToolCallResult::text(
176 serde_json::to_string(&json!({
177 "nodes": nodes,
178 "edges": edges,
179 "cycles": cycles,
180 "ready": graph.ready().iter().map(|n| n.id.to_string()).collect::<Vec<_>>(),
181 }))
182 .unwrap(),
183 ))
184 }
185 }
186
187 // writes
188
189 pub struct AddDependency(pub Arc<Ctx>);
190
191 #[async_trait]
192 impl Tool for AddDependency {
193 fn name(&self) -> &'static str {
194 "add_dependency"
195 }
196 fn description(&self) -> &'static str {
197 "Record that one task blocks another: `blocker_id` must be completed before `blocked_id` can start. Both ids come from `list_tasks` or `create_task`. Idempotent, so recording the same edge twice is a no-op rather than a duplicate. Refused if the edge would close a dependency cycle, and the error names the chain already in the way. Completing the blocker frees the dependent automatically; there is no second call to make and no tag to update."
198 }
199 fn kind(&self) -> ToolKind {
200 ToolKind::Write(caps::task_dependency_add())
201 }
202 fn input_schema(&self) -> Value {
203 json!({
204 "type": "object",
205 "properties": {
206 "blocked_id": { "type": "string", "description": "The task that has to wait." },
207 "blocker_id": { "type": "string", "description": "The task it is waiting on." }
208 },
209 "required": ["blocked_id", "blocker_id"]
210 })
211 }
212 async fn call(&self, args: Value) -> Result<ToolCallResult> {
213 let blocked_id = parse_task_id(self.name(), req_str(self.name(), &args, "blocked_id")?)?;
214 let blocker_id = parse_task_id(self.name(), req_str(self.name(), &args, "blocker_id")?)?;
215
216 let repo = self.0.tasks();
217 repo.add_dependency(self.0.user_id, blocked_id, blocker_id)
218 .map_err(|e| fail(self.name(), e))?;
219
220 // Read the dependent back so the caller sees the position it landed in
221 // rather than having to ask. Adding an edge is exactly the moment its
222 // depth changes.
223 let blocked = repo
224 .get_by_id(blocked_id, self.0.user_id)
225 .map_err(|e| fail(self.name(), e))?;
226
227 Ok(ToolCallResult::text(
228 serde_json::to_string(&json!({
229 "blocked_id": blocked_id.to_string(),
230 "blocker_id": blocker_id.to_string(),
231 "blocked_task": blocked.as_ref().map(task_summary_row),
232 }))
233 .unwrap(),
234 ))
235 }
236 }
237
238 pub struct RemoveDependency(pub Arc<Ctx>);
239
240 #[async_trait]
241 impl Tool for RemoveDependency {
242 fn name(&self) -> &'static str {
243 "remove_dependency"
244 }
245 fn description(&self) -> &'static str {
246 "Remove a blocking edge, so `blocked_id` no longer waits on `blocker_id`. Use this when the dependency turned out not to be real; completing the blocker is the other way an edge stops mattering, and it keeps the record that the wait happened. Reports `removed: false` if there was no such edge."
247 }
248 fn kind(&self) -> ToolKind {
249 ToolKind::Write(caps::task_dependency_remove())
250 }
251 fn input_schema(&self) -> Value {
252 json!({
253 "type": "object",
254 "properties": {
255 "blocked_id": { "type": "string" },
256 "blocker_id": { "type": "string" }
257 },
258 "required": ["blocked_id", "blocker_id"]
259 })
260 }
261 async fn call(&self, args: Value) -> Result<ToolCallResult> {
262 let blocked_id = parse_task_id(self.name(), req_str(self.name(), &args, "blocked_id")?)?;
263 let blocker_id = parse_task_id(self.name(), req_str(self.name(), &args, "blocker_id")?)?;
264
265 let removed = self
266 .0
267 .tasks()
268 .remove_dependency(self.0.user_id, blocked_id, blocker_id)
269 .map_err(|e| fail(self.name(), e))?;
270
271 Ok(ToolCallResult::text(
272 serde_json::to_string(&json!({
273 "removed": removed,
274 "blocked_id": blocked_id.to_string(),
275 "blocker_id": blocker_id.to_string(),
276 }))
277 .unwrap(),
278 ))
279 }
280 }
281
282 /// The `blocked_by` / `blocks` block that `get_task` appends to its row.
283 ///
284 /// Lives here rather than in the task module so the projection sits with the
285 /// rest of the graph surface, and so `get_task` gains it by calling one thing.
286 pub(super) fn dependency_block(
287 ctx: &Ctx,
288 tool: &str,
289 task_id: goingson_core::TaskId,
290 ) -> Result<Value> {
291 let repo = ctx.tasks();
292 let blockers = repo
293 .list_blockers(ctx.user_id, task_id)
294 .map_err(|e| fail(tool, e))?;
295 let dependents = repo
296 .list_dependents(ctx.user_id, task_id)
297 .map_err(|e| fail(tool, e))?;
298
299 Ok(json!({
300 "blocked_by": blockers.iter().map(linked_task_row).collect::<Vec<_>>(),
301 "blocks": dependents.iter().map(linked_task_row).collect::<Vec<_>>(),
302 }))
303 }
304