//! Dependency tools: the blocking graph. //! //! `add_dependency` and `remove_dependency` are the write surface, //! `list_ready_tasks` and `get_task_graph` the read surface. Between them they //! answer the question the task tools cannot: not "what is open" but "what can //! actually be started right now, and what does finishing it free". //! //! Blocked-ness is never a tag here. Inferring it from a `blocked` tag or from //! the words in a description is wrong the moment nobody retags a task after //! its blocker closes. These tools read the edges, so the answer moves on its //! own. use std::sync::Arc; use async_trait::async_trait; use goingson_core::{ProjectId, TaskCrud, TaskDependencies}; use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; use serde_json::{Value, json}; use super::project_id_field; use crate::caps; use crate::context::Ctx; use crate::convert::{ MAX_LIMIT, linked_task_row, parse_limit, parse_project_id, parse_task_id, req_str, task_summary_row, }; fn fail(tool: &str, e: impl std::fmt::Display) -> Error { Error::ToolFailed { tool: tool.to_string(), message: e.to_string(), } } /// Resolve an optional `project_id` argument to a scope. fn project_scope(tool: &str, args: &Value) -> Result> { match args.get("project_id").and_then(Value::as_str) { Some(raw) if !raw.trim().is_empty() => Ok(Some(parse_project_id(tool, raw)?)), _ => Ok(None), } } // reads pub struct ListReadyTasks(pub Arc); #[async_trait] impl Tool for ListReadyTasks { fn name(&self) -> &'static str { "list_ready_tasks" } fn description(&self) -> &'static str { "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." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "project_id": project_id_field(), "depth": { "type": "integer", "minimum": 0, "description": "How many blocker-hops back to include. 0 (the default) is work that can start now." }, "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT } } }) } async fn call(&self, args: Value) -> Result { let limit = parse_limit(self.name(), args.get("limit"))?; let project_id = project_scope(self.name(), &args)?; let depth = args .get("depth") .and_then(Value::as_u64) .unwrap_or(0) .try_into() .unwrap_or(u32::MAX); let tasks = self .0 .tasks() .list_ready( self.0.user_id, project_id, depth, Some(i64::try_from(limit).unwrap_or(i64::MAX)), ) .map_err(|e| fail(self.name(), e))?; let rows: Vec = tasks.iter().map(task_summary_row).collect(); Ok(ToolCallResult::text( serde_json::to_string(&json!({ "count": rows.len(), "depth": depth, "tasks": rows, })) .unwrap(), )) } } pub struct GetTaskGraph(pub Arc); #[async_trait] impl Tool for GetTaskGraph { fn name(&self) -> &'static str { "get_task_graph" } fn description(&self) -> &'static str { "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." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "project_id": project_id_field() } }) } async fn call(&self, args: Value) -> Result { let project_id = project_scope(self.name(), &args)?; let graph = self .0 .tasks() .task_graph(self.0.user_id, project_id) .map_err(|e| fail(self.name(), e))?; let nodes: Vec = graph .nodes .iter() .map(|n| { let mut row = json!({ "id": n.id.to_string(), "title": n.title, "status": n.status.as_str(), "blocked": n.position.is_blocked() || n.position.in_cycle, "block_depth": n.position.block_depth, "unblocks": n.position.unblocks_count, "project_id": n.project_id.map(|p| p.to_string()), }); if n.position.in_cycle { row["in_cycle"] = json!(true); } row }) .collect(); let edges: Vec = graph .edges .iter() .map(|e| { json!({ "blocked_id": e.blocked_id.to_string(), "blocker_id": e.blocker_id.to_string(), }) }) .collect(); let cycles: Vec> = graph .cycles .iter() .map(|c| c.iter().map(ToString::to_string).collect()) .collect(); Ok(ToolCallResult::text( serde_json::to_string(&json!({ "nodes": nodes, "edges": edges, "cycles": cycles, "ready": graph.ready().iter().map(|n| n.id.to_string()).collect::>(), })) .unwrap(), )) } } // writes pub struct AddDependency(pub Arc); #[async_trait] impl Tool for AddDependency { fn name(&self) -> &'static str { "add_dependency" } fn description(&self) -> &'static str { "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." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::task_dependency_add()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "blocked_id": { "type": "string", "description": "The task that has to wait." }, "blocker_id": { "type": "string", "description": "The task it is waiting on." } }, "required": ["blocked_id", "blocker_id"] }) } async fn call(&self, args: Value) -> Result { let blocked_id = parse_task_id(self.name(), req_str(self.name(), &args, "blocked_id")?)?; let blocker_id = parse_task_id(self.name(), req_str(self.name(), &args, "blocker_id")?)?; let repo = self.0.tasks(); repo.add_dependency(self.0.user_id, blocked_id, blocker_id) .map_err(|e| fail(self.name(), e))?; // Read the dependent back so the caller sees the position it landed in // rather than having to ask. Adding an edge is exactly the moment its // depth changes. let blocked = repo .get_by_id(blocked_id, self.0.user_id) .map_err(|e| fail(self.name(), e))?; Ok(ToolCallResult::text( serde_json::to_string(&json!({ "blocked_id": blocked_id.to_string(), "blocker_id": blocker_id.to_string(), "blocked_task": blocked.as_ref().map(task_summary_row), })) .unwrap(), )) } } pub struct RemoveDependency(pub Arc); #[async_trait] impl Tool for RemoveDependency { fn name(&self) -> &'static str { "remove_dependency" } fn description(&self) -> &'static str { "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." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::task_dependency_remove()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "blocked_id": { "type": "string" }, "blocker_id": { "type": "string" } }, "required": ["blocked_id", "blocker_id"] }) } async fn call(&self, args: Value) -> Result { let blocked_id = parse_task_id(self.name(), req_str(self.name(), &args, "blocked_id")?)?; let blocker_id = parse_task_id(self.name(), req_str(self.name(), &args, "blocker_id")?)?; let removed = self .0 .tasks() .remove_dependency(self.0.user_id, blocked_id, blocker_id) .map_err(|e| fail(self.name(), e))?; Ok(ToolCallResult::text( serde_json::to_string(&json!({ "removed": removed, "blocked_id": blocked_id.to_string(), "blocker_id": blocker_id.to_string(), })) .unwrap(), )) } } /// The `blocked_by` / `blocks` block that `get_task` appends to its row. /// /// Lives here rather than in the task module so the projection sits with the /// rest of the graph surface, and so `get_task` gains it by calling one thing. pub(super) fn dependency_block( ctx: &Ctx, tool: &str, task_id: goingson_core::TaskId, ) -> Result { let repo = ctx.tasks(); let blockers = repo .list_blockers(ctx.user_id, task_id) .map_err(|e| fail(tool, e))?; let dependents = repo .list_dependents(ctx.user_id, task_id) .map_err(|e| fail(tool, e))?; Ok(json!({ "blocked_by": blockers.iter().map(linked_task_row).collect::>(), "blocks": dependents.iter().map(linked_task_row).collect::>(), })) }