| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 221 |
|
| 222 |
|
| 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 |
|
| 283 |
|
| 284 |
|
| 285 |
|
| 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 |
|