//! Task tools: the read surface (`list_tasks`, `get_task`) and the write //! surface (`create_task`, `bulk_import_tasks`, `update_task`, `complete_task`). //! //! `bulk_import_tasks` is the `/dellm` migration primitive: it takes a parsed //! backlog and creates tasks in one capability-gated call, deduping on a //! `source:` provenance tag so a re-run does not double-insert. use std::collections::{HashMap, HashSet}; use std::sync::Arc; use async_trait::async_trait; use goingson_core::models::ParseableEnum; use goingson_core::repository::{ProjectRepository, TaskAnnotations, TaskCrud}; use goingson_core::{ NewProject, NewTask, ProjectId, ProjectStatus, ProjectType, TOKEN_KIND_COMMIT, Task, TaskStatus, TokenState, UpdateTask, }; use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; use serde_json::{Value, json}; use crate::caps; use crate::context::Ctx; use crate::convert::{ MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_tags, parse_task_id, req_str, source_tag, task_row, task_summary_row, }; fn fail(tool: &str, e: impl std::fmt::Display) -> Error { Error::ToolFailed { tool: tool.to_string(), message: e.to_string(), } } // reads pub struct ListTasks(pub Arc); #[async_trait] impl Tool for ListTasks { fn name(&self) -> &str { "list_tasks" } fn description(&self) -> &str { "List tasks as compact rows. Optional filters: `project` (name), `status` (Pending|Started|Completed), `tag`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_task` for the full text. The reply carries `total` (rows matching the filters) and, when more remain, `next_offset`." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "project": { "type": "string" }, "status": { "type": "string" }, "tag": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT }, "offset": { "type": "integer", "minimum": 0 } } }) } async fn call(&self, args: Value) -> Result { let limit = parse_limit(self.name(), args.get("limit"))?; let offset = parse_offset(self.name(), args.get("offset"))?; let tasks = self .0 .tasks() .list_all(self.0.user_id) .await .map_err(|e| fail(self.name(), e))?; let project = args.get("project").and_then(Value::as_str); let status = args .get("status") .and_then(Value::as_str) .map(TaskStatus::from_str_or_default); let tag = args.get("tag").and_then(Value::as_str); let matched: Vec<&Task> = tasks .iter() .filter(|t| project.is_none_or(|p| t.project_name.as_deref() == Some(p))) .filter(|t| status.as_ref().is_none_or(|s| &t.status == s)) .filter(|t| tag.is_none_or(|want| t.tags.iter().any(|have| have == want))) .collect(); let total = matched.len(); let rows: Vec = matched .into_iter() .skip(offset) .take(limit) .map(task_summary_row) .collect(); let mut reply = json!({ "count": rows.len(), "total": total, "offset": offset, "tasks": rows, }); // Only present when a page remains, so its absence is the stop condition. let next = offset.saturating_add(reply["count"].as_u64().unwrap_or(0) as usize); if next < total { reply["next_offset"] = json!(next); } Ok(ToolCallResult::text(serde_json::to_string(&reply).unwrap())) } } pub struct GetTask(pub Arc); #[async_trait] impl Tool for GetTask { fn name(&self) -> &str { "get_task" } fn description(&self) -> &str { "Fetch one task by id, including its subtasks and annotations." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }) } async fn call(&self, args: Value) -> Result { let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?; let repo = self.0.tasks(); let task = repo .get_by_id(id, self.0.user_id) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("no task with id {id}"), })?; let subtasks = repo .get_subtasks_for_task(id) .await .map_err(|e| fail(self.name(), e))?; let annotations = repo .get_annotations_for_task(id) .await .map_err(|e| fail(self.name(), e))?; let mut row = task_row(&task); row["subtasks"] = json!( subtasks .iter() .map(|s| json!({ "text": s.text, "completed": s.is_completed })) .collect::>() ); row["annotations"] = json!( annotations .iter() .map(|a| json!({ "note": a.note, "created_at": a.timestamp.to_rfc3339() })) .collect::>() ); Ok(ToolCallResult::text(serde_json::to_string(&row).unwrap())) } } // writes pub struct CreateTask(pub Arc); #[async_trait] impl Tool for CreateTask { fn name(&self) -> &str { "create_task" } fn description(&self) -> &str { "Create one task. `project` (name) is resolved to a project, creating it if absent. `due` accepts RFC 3339 or YYYY-MM-DD; `priority` is High|Medium|Low." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::task_create()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "description": { "type": "string" }, "project": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "due": { "type": "string" }, "priority": { "type": "string" } }, "required": ["description"] }) } async fn call(&self, args: Value) -> Result { let description = req_str(self.name(), &args, "description")?.to_string(); let due = parse_due(self.name(), args.get("due"))?; let priority = parse_priority(args.get("priority")); let tags = parse_tags(args.get("tags")); let project_id = match args.get("project").and_then(Value::as_str) { Some(name) if !name.trim().is_empty() => Some( ensure_project(&self.0, name) .await .map_err(|e| fail(self.name(), e))?, ), _ => None, }; let mut builder = NewTask::builder(description).priority(priority).tags(tags); if let Some(pid) = project_id { builder = builder.project_id(pid); } if let Some(d) = due { builder = builder.due(d); } let task = self .0 .tasks() .create(self.0.user_id, builder.build()) .await .map_err(|e| fail(self.name(), e))?; Ok(ToolCallResult::text( serde_json::to_string(&json!({ "id": task.id.to_string() })).unwrap(), )) } } pub struct BulkImportTasks(pub Arc); #[async_trait] impl Tool for BulkImportTasks { fn name(&self) -> &str { "bulk_import_tasks" } fn description(&self) -> &str { "Create many tasks in one call (the /dellm migration primitive). Each item: {description, project?, tags?, due?, priority?, source?}. `source` is a provenance key (e.g. file:line); an item whose source tag already exists is skipped, so re-running is idempotent. Returns created/skipped counts and the new task ids." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::task_bulk_import()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "tasks": { "type": "array", "items": { "type": "object", "properties": { "description": { "type": "string" }, "project": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "due": { "type": "string" }, "priority": { "type": "string" }, "source": { "type": "string" } }, "required": ["description"] } } }, "required": ["tasks"] }) } async fn call(&self, args: Value) -> Result { let items = args.get("tasks") .and_then(Value::as_array) .ok_or_else(|| Error::InvalidArgs { tool: self.name().to_string(), message: "missing array field `tasks`".into(), })?; let repo = self.0.tasks(); // Existing source tags → idempotency. One scan up front. let existing = repo .list_all(self.0.user_id) .await .map_err(|e| fail(self.name(), e))?; let mut seen_sources: HashSet = existing .iter() .flat_map(|t| t.tags.iter()) .filter(|tag| tag.starts_with("source:")) .cloned() .collect(); let mut project_cache: HashMap = HashMap::new(); let mut created_ids = Vec::new(); let mut skipped = 0usize; for (idx, item) in items.iter().enumerate() { let description = item .get("description") .and_then(Value::as_str) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| Error::InvalidArgs { tool: self.name().to_string(), message: format!("tasks[{idx}] is missing a non-empty `description`"), })? .to_string(); let mut tags = parse_tags(item.get("tags")); // Provenance / idempotency. if let Some(source) = item.get("source").and_then(Value::as_str) { let tag = source_tag(source); if !seen_sources.insert(tag.clone()) { skipped += 1; continue; } tags.push(tag); } let due = parse_due(self.name(), item.get("due"))?; let priority = parse_priority(item.get("priority")); let project_id = match item.get("project").and_then(Value::as_str) { Some(name) if !name.trim().is_empty() => Some( resolve_project_cached(&self.0, name, &mut project_cache) .await .map_err(|e| fail(self.name(), e))?, ), _ => None, }; let mut builder = NewTask::builder(description).priority(priority).tags(tags); if let Some(pid) = project_id { builder = builder.project_id(pid); } if let Some(d) = due { builder = builder.due(d); } let task = repo .create(self.0.user_id, builder.build()) .await .map_err(|e| fail(self.name(), e))?; created_ids.push(task.id.to_string()); } Ok(ToolCallResult::text( serde_json::to_string(&json!({ "created": created_ids.len(), "skipped": skipped, "task_ids": created_ids, })) .unwrap(), )) } } pub struct UpdateTaskTool(pub Arc); #[async_trait] impl Tool for UpdateTaskTool { fn name(&self) -> &str { "update_task" } fn description(&self) -> &str { "Update fields of an existing task. Only the fields you pass change; the rest keep their current values. Accepts `description`, `priority`, `due`, `status` (Pending|Started|Completed), `tags`, `project`, and `commit`. `commit` appends an advancing commit to the task's commit list, repo-qualified as `@` (e.g. `deox@7c236fca8`); use `complete_task` to record the closing commit." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::task_update()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "id": { "type": "string" }, "description": { "type": "string" }, "priority": { "type": "string" }, "due": { "type": "string" }, "status": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "project": { "type": "string" }, "commit": { "type": "string" } }, "required": ["id"] }) } async fn call(&self, args: Value) -> Result { let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?; let repo = self.0.tasks(); let current = repo .get_by_id(id, self.0.user_id) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("no task with id {id}"), })?; // Start from the current task, overlay only the provided fields. let mut patch = update_from_task(¤t); if let Some(desc) = args.get("description").and_then(Value::as_str) { patch.description = desc.to_string(); } if args.get("priority").is_some() { patch.priority = parse_priority(args.get("priority")); } if let Some(due) = args.get("due") { patch.due = parse_due(self.name(), Some(due))?; } if let Some(status) = args.get("status").and_then(Value::as_str) { patch.status = TaskStatus::from_str_or_default(status); } if let Some(tags) = args.get("tags") { patch.tags = parse_tags(Some(tags)); } if let Some(name) = args.get("project").and_then(Value::as_str) { patch.project_id = if name.trim().is_empty() { None } else { Some( ensure_project(&self.0, name) .await .map_err(|e| fail(self.name(), e))?, ) }; } let updated = repo .update(id, self.0.user_id, patch) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("task {id} vanished during update"), })?; // Record an advancing commit (if given) as a non-primary Pending token after // the field update, then re-read so the returned row reflects it. Idempotent // via the deterministic token id. if let Some(commit) = args .get("commit") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()) { repo.record_status_token( id, self.0.user_id, TOKEN_KIND_COMMIT, commit, TokenState::Pending, false, ) .await .map_err(|e| fail(self.name(), e))?; let refreshed = repo .get_by_id(id, self.0.user_id) .await .map_err(|e| fail(self.name(), e))? .unwrap_or(updated); return Ok(ToolCallResult::text( serde_json::to_string(&task_row(&refreshed)).unwrap(), )); } Ok(ToolCallResult::text( serde_json::to_string(&task_row(&updated)).unwrap(), )) } } pub struct CompleteTask(pub Arc); #[async_trait] impl Tool for CompleteTask { fn name(&self) -> &str { "complete_task" } fn description(&self) -> &str { "Mark a task complete by id. Optional `commit` records the closing commit, repo-qualified as `@` (e.g. `deox@7c236fca8`); it is appended to the task's commit list and flagged as the closer." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::task_complete()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "id": { "type": "string" }, "commit": { "type": "string" } }, "required": ["id"] }) } async fn call(&self, args: Value) -> Result { let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?; let repo = self.0.tasks(); repo.complete(id, self.0.user_id) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("no task with id {id}"), })?; // Record the closing commit (if given) as the primary status token before // re-reading, so the returned row carries it. A freshly-made commit is not // yet pushed, so it starts Pending. Idempotent via the deterministic token id. if let Some(commit) = args .get("commit") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()) { repo.record_status_token( id, self.0.user_id, TOKEN_KIND_COMMIT, commit, TokenState::Pending, true, ) .await .map_err(|e| fail(self.name(), e))?; } let completed = repo .get_by_id(id, self.0.user_id) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("task {id} vanished after completion"), })?; Ok(ToolCallResult::text( serde_json::to_string(&task_row(&completed)).unwrap(), )) } } // helpers /// Resolve a project by name, creating a default `SideProject` if absent. async fn ensure_project(ctx: &Ctx, name: &str) -> goingson_core::repository::Result { let repo = ctx.projects(); if let Some(existing) = repo.find_by_name(ctx.user_id, name).await? { return Ok(existing.id); } let created = repo .create( ctx.user_id, NewProject { name: name.to_string(), description: String::new(), project_type: ProjectType::SideProject, status: ProjectStatus::default(), }, ) .await?; Ok(created.id) } /// `ensure_project` with a within-call cache, so a bulk import that references /// the same project 200 times hits the database once. async fn resolve_project_cached( ctx: &Ctx, name: &str, cache: &mut HashMap, ) -> goingson_core::repository::Result { if let Some(id) = cache.get(name) { return Ok(*id); } let id = ensure_project(ctx, name).await?; cache.insert(name.to_string(), id); Ok(id) } /// Build a full [`UpdateTask`] mirroring a task's current state, so a caller can /// overlay just the fields it wants to change. fn update_from_task(t: &Task) -> UpdateTask { UpdateTask { project_id: t.project_id, milestone_id: t.milestone_id, contact_id: t.contact_id, description: t.description.clone(), status: t.status.clone(), priority: t.priority.clone(), due: t.due, tags: t.tags.clone(), recurrence: t.recurrence.clone(), recurrence_rule: t.recurrence_rule.clone(), urgency: t.urgency, scheduled_start: t.scheduled_start, scheduled_duration: t.scheduled_duration, estimated_minutes: t.estimated_minutes, } }