max / goingson
7 files changed,
+424 insertions,
-24 deletions
| @@ -33,7 +33,10 @@ Default bind is `127.0.0.1:7337`; the MCP endpoint is `POST /mcp`. | |||
| 33 | 33 | Read: | |
| 34 | 34 | ||
| 35 | 35 | - `list_projects` — id, name, type, status. | |
| 36 | - | - `list_tasks(project?, status?, tag?)` — compact task rows. | |
| 36 | + | - `list_tasks(project?, status?, tag?, limit?, offset?)` — compact task rows, | |
| 37 | + | paged (default 50, max 200). Descriptions over 240 chars are clipped and the | |
| 38 | + | row marked `truncated`; `get_task` has the full text. The reply carries | |
| 39 | + | `total` and, while pages remain, `next_offset` — walk until it is absent. | |
| 37 | 40 | - `get_task(id)` — one task with subtasks and annotations. | |
| 38 | 41 | ||
| 39 | 42 | Write (each gated on a capability id): | |
| @@ -41,6 +44,7 @@ Write (each gated on a capability id): | |||
| 41 | 44 | | Tool | Capability | | |
| 42 | 45 | |------|------------| | |
| 43 | 46 | | `create_project(name, type?, description?)` — idempotent on name | `go.project.create` | | |
| 47 | + | | `update_project(project, {status?, type?, description?})` — resolved by name, overlays only the fields you pass | `go.project.update` | | |
| 44 | 48 | | `create_task({description, project?, tags?, due?, priority?})` | `go.task.create` | | |
| 45 | 49 | | `bulk_import_tasks([...])` — the `/dellm` primitive | `go.task.bulk_import` | | |
| 46 | 50 | | `update_task(id, fields)` — overlays only the fields you pass | `go.task.update` | | |
| @@ -50,6 +54,17 @@ Write (each gated on a capability id): | |||
| 50 | 54 | `source:todo.md:42`), so re-running a migration wave does not double-insert. | |
| 51 | 55 | Projects referenced by name are resolved, and created if absent. | |
| 52 | 56 | ||
| 57 | + | `update_project` is how a session retires a project: set `status` to `Archived`. | |
| 58 | + | There is no `delete_project` — the underlying delete is a hard DELETE with no | |
| 59 | + | soft-delete behind it, which is the wrong default to hand a session. Renaming is | |
| 60 | + | not offered either, since the name is how `create_task` and `bulk_import_tasks` | |
| 61 | + | resolve a project. | |
| 62 | + | ||
| 63 | + | Enum values are the same words in and out (`SideProject`, `OnHold`), not the | |
| 64 | + | UI's display forms (`Side Project`, `On Hold`), so a row from `list_projects` | |
| 65 | + | can be fed straight back to `update_project`. An unknown word is refused rather | |
| 66 | + | than defaulted. | |
| 67 | + | ||
| 53 | 68 | ## Wiring into a Claude session | |
| 54 | 69 | ||
| 55 | 70 | Register the running server as an HTTP MCP server, then grant the capabilities |
| @@ -6,6 +6,7 @@ | |||
| 6 | 6 | use kberg::WriteCapability; | |
| 7 | 7 | ||
| 8 | 8 | pub const PROJECT_CREATE: &str = "go.project.create"; | |
| 9 | + | pub const PROJECT_UPDATE: &str = "go.project.update"; | |
| 9 | 10 | pub const TASK_CREATE: &str = "go.task.create"; | |
| 10 | 11 | pub const TASK_BULK_IMPORT: &str = "go.task.bulk_import"; | |
| 11 | 12 | pub const TASK_UPDATE: &str = "go.task.update"; | |
| @@ -14,6 +15,9 @@ pub const TASK_COMPLETE: &str = "go.task.complete"; | |||
| 14 | 15 | pub fn project_create() -> WriteCapability { | |
| 15 | 16 | WriteCapability::new(PROJECT_CREATE, "create a project") | |
| 16 | 17 | } | |
| 18 | + | pub fn project_update() -> WriteCapability { | |
| 19 | + | WriteCapability::new(PROJECT_UPDATE, "update an existing project") | |
| 20 | + | } | |
| 17 | 21 | pub fn task_create() -> WriteCapability { | |
| 18 | 22 | WriteCapability::new(TASK_CREATE, "create one task") | |
| 19 | 23 | } |
| @@ -56,6 +56,27 @@ pub fn parse_due(tool: &str, value: Option<&Value>) -> Result<Option<DateTime<Ut | |||
| 56 | 56 | }) | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | /// Parse an enum argument strictly, listing the accepted words on failure. | |
| 60 | + | /// | |
| 61 | + | /// Deliberately not [`ParseableEnum::from_str_or_default`]: that fallback exists | |
| 62 | + | /// so database reads and UI input never fail, but here a typo'd word would | |
| 63 | + | /// silently become the `Default` variant. A caller asking for `Archived` and | |
| 64 | + | /// silently getting `Active` would be told the write succeeded. | |
| 65 | + | pub fn parse_enum<T: std::str::FromStr>( | |
| 66 | + | tool: &str, | |
| 67 | + | field: &str, | |
| 68 | + | raw: &str, | |
| 69 | + | allowed: &[&str], | |
| 70 | + | ) -> Result<T, Error> { | |
| 71 | + | raw.trim().parse::<T>().map_err(|_| Error::InvalidArgs { | |
| 72 | + | tool: tool.to_string(), | |
| 73 | + | message: format!( | |
| 74 | + | "`{raw}` is not a valid `{field}` (expected one of: {})", | |
| 75 | + | allowed.join(", ") | |
| 76 | + | ), | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | ||
| 59 | 80 | /// Parse a priority word (`High`/`Medium`/`Low`, case-insensitive), defaulting | |
| 60 | 81 | /// to `Medium` when absent. Never errors — an unknown word falls back rather | |
| 61 | 82 | /// than blocking a migration. | |
| @@ -66,6 +87,39 @@ pub fn parse_priority(value: Option<&Value>) -> Priority { | |||
| 66 | 87 | } | |
| 67 | 88 | } | |
| 68 | 89 | ||
| 90 | + | /// Parse a `limit`, defaulting to [`DEFAULT_LIMIT`] and clamping to | |
| 91 | + | /// [`MAX_LIMIT`]. A non-integer or zero limit errors rather than silently | |
| 92 | + | /// falling back, since a caller that asked for a page size is reasoning about | |
| 93 | + | /// it. An over-large one clamps: the cap is the server's call, not a mistake. | |
| 94 | + | pub fn parse_limit(tool: &str, value: Option<&Value>) -> Result<usize, Error> { | |
| 95 | + | let Some(raw) = value else { | |
| 96 | + | return Ok(DEFAULT_LIMIT); | |
| 97 | + | }; | |
| 98 | + | if raw.is_null() { | |
| 99 | + | return Ok(DEFAULT_LIMIT); | |
| 100 | + | } | |
| 101 | + | let n = raw.as_u64().filter(|n| *n > 0).ok_or_else(|| Error::InvalidArgs { | |
| 102 | + | tool: tool.to_string(), | |
| 103 | + | message: format!("`limit` must be a positive integer (got `{raw}`)"), | |
| 104 | + | })?; | |
| 105 | + | Ok((n as usize).min(MAX_LIMIT)) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /// Parse an `offset`, defaulting to 0. | |
| 109 | + | pub fn parse_offset(tool: &str, value: Option<&Value>) -> Result<usize, Error> { | |
| 110 | + | let Some(raw) = value else { | |
| 111 | + | return Ok(0); | |
| 112 | + | }; | |
| 113 | + | if raw.is_null() { | |
| 114 | + | return Ok(0); | |
| 115 | + | } | |
| 116 | + | let n = raw.as_u64().ok_or_else(|| Error::InvalidArgs { | |
| 117 | + | tool: tool.to_string(), | |
| 118 | + | message: format!("`offset` must be a non-negative integer (got `{raw}`)"), | |
| 119 | + | })?; | |
| 120 | + | Ok(n as usize) | |
| 121 | + | } | |
| 122 | + | ||
| 69 | 123 | /// Read a `tags` argument shaped as a JSON array of strings. | |
| 70 | 124 | pub fn parse_tags(value: Option<&Value>) -> Vec<String> { | |
| 71 | 125 | value | |
| @@ -95,6 +149,42 @@ pub fn priority_word(p: &Priority) -> &'static str { | |||
| 95 | 149 | } | |
| 96 | 150 | } | |
| 97 | 151 | ||
| 152 | + | /// Longest description a `list_tasks` row carries before it is clipped. | |
| 153 | + | /// Task descriptions here run to multi-paragraph specs; a full-project listing | |
| 154 | + | /// of them overflows a tool-result budget long before the row count does. | |
| 155 | + | pub const SUMMARY_DESCRIPTION_CHARS: usize = 240; | |
| 156 | + | ||
| 157 | + | /// Default and maximum page sizes for `list_tasks`. The default is what an | |
| 158 | + | /// unparameterized call gets, so the old unbounded behaviour cannot come back | |
| 159 | + | /// by omission. | |
| 160 | + | pub const DEFAULT_LIMIT: usize = 50; | |
| 161 | + | pub const MAX_LIMIT: usize = 200; | |
| 162 | + | ||
| 163 | + | /// Clip a description to [`SUMMARY_DESCRIPTION_CHARS`] on a char boundary. | |
| 164 | + | /// Returns the text and whether it was clipped. | |
| 165 | + | fn clip(s: &str) -> (String, bool) { | |
| 166 | + | let mut chars = s.chars(); | |
| 167 | + | let head: String = chars.by_ref().take(SUMMARY_DESCRIPTION_CHARS).collect(); | |
| 168 | + | if chars.next().is_none() { | |
| 169 | + | (head, false) | |
| 170 | + | } else { | |
| 171 | + | (head, true) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// Row projection for `list_tasks`: [`task_row`] with the description clipped. | |
| 176 | + | /// A clipped row carries `"truncated": true` so a caller knows the full text is | |
| 177 | + | /// a `get_task` away rather than assuming it has read the whole spec. | |
| 178 | + | pub fn task_summary_row(t: &Task) -> Value { | |
| 179 | + | let mut row = task_row(t); | |
| 180 | + | let (description, truncated) = clip(&t.description); | |
| 181 | + | if truncated { | |
| 182 | + | row["description"] = json!(description); | |
| 183 | + | row["truncated"] = json!(true); | |
| 184 | + | } | |
| 185 | + | row | |
| 186 | + | } | |
| 187 | + | ||
| 98 | 188 | /// Compact JSON projection of a task for the read tools. | |
| 99 | 189 | pub fn task_row(t: &Task) -> Value { | |
| 100 | 190 | json!({ |
| @@ -10,7 +10,7 @@ use crate::context::Ctx; | |||
| 10 | 10 | mod project; | |
| 11 | 11 | mod task; | |
| 12 | 12 | ||
| 13 | - | pub use project::{CreateProject, ListProjects}; | |
| 13 | + | pub use project::{CreateProject, ListProjects, UpdateProjectTool}; | |
| 14 | 14 | pub use task::{BulkImportTasks, CompleteTask, CreateTask, GetTask, ListTasks, UpdateTaskTool}; | |
| 15 | 15 | ||
| 16 | 16 | /// Build the full go-mcp tool surface over a shared context. | |
| @@ -22,6 +22,7 @@ pub fn registry(ctx: Arc<Ctx>) -> ToolRegistry { | |||
| 22 | 22 | r.register(GetTask(ctx.clone())); | |
| 23 | 23 | // Writes (capability-gated). | |
| 24 | 24 | r.register(CreateProject(ctx.clone())); | |
| 25 | + | r.register(UpdateProjectTool(ctx.clone())); | |
| 25 | 26 | r.register(CreateTask(ctx.clone())); | |
| 26 | 27 | r.register(BulkImportTasks(ctx.clone())); | |
| 27 | 28 | r.register(UpdateTaskTool(ctx.clone())); |
| @@ -1,18 +1,34 @@ | |||
| 1 | - | //! Project tools: `list_projects` (read) and `create_project` (write, | |
| 2 | - | //! idempotent on name). | |
| 1 | + | //! Project tools: `list_projects` (read), `create_project` (write, idempotent | |
| 2 | + | //! on name), and `update_project` (write, overlays the fields you pass). | |
| 3 | + | //! | |
| 4 | + | //! There is deliberately no `delete_project`: the repository delete is a hard | |
| 5 | + | //! DELETE with no soft-delete behind it. `Archived` is the non-destructive | |
| 6 | + | //! retire path, and it is what a session should reach for. | |
| 7 | + | //! | |
| 8 | + | //! Enums cross the wire as [`DbValue::db_value`], not `as_str`. `as_str` is the | |
| 9 | + | //! UI's display string ("Side Project", "On Hold") and does not round-trip | |
| 10 | + | //! through `FromStr`, so emitting it would hand a caller a value its own write | |
| 11 | + | //! tools reject. | |
| 3 | 12 | ||
| 4 | 13 | use std::sync::Arc; | |
| 5 | 14 | ||
| 6 | 15 | use async_trait::async_trait; | |
| 7 | 16 | use goingson_core::repository::ProjectRepository; | |
| 8 | - | use goingson_core::models::ParseableEnum; | |
| 9 | - | use goingson_core::{NewProject, ProjectStatus, ProjectType}; | |
| 17 | + | use goingson_core::models::DbValue; | |
| 18 | + | use goingson_core::{NewProject, ProjectStatus, ProjectType, UpdateProject}; | |
| 10 | 19 | use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; | |
| 11 | 20 | use serde_json::{Value, json}; | |
| 12 | 21 | ||
| 13 | 22 | use crate::caps; | |
| 14 | 23 | use crate::context::Ctx; | |
| 15 | - | use crate::convert::req_str; | |
| 24 | + | use crate::convert::{parse_enum, req_str}; | |
| 25 | + | ||
| 26 | + | /// The accepted wire words, mirroring each enum's `strum` serialize form (which | |
| 27 | + | /// is what `FromStr` parses). Listed back to a caller that gets one wrong. | |
| 28 | + | const PROJECT_TYPES: &[&str] = &[ | |
| 29 | + | "Job", "SideProject", "Company", "Essay", "Article", "Painting", "Other", | |
| 30 | + | ]; | |
| 31 | + | const PROJECT_STATUSES: &[&str] = &["Active", "OnHold", "Completed", "Archived"]; | |
| 16 | 32 | ||
| 17 | 33 | fn fail(tool: &str, e: impl std::fmt::Display) -> Error { | |
| 18 | 34 | Error::ToolFailed { | |
| @@ -53,8 +69,8 @@ impl Tool for ListProjects { | |||
| 53 | 69 | json!({ | |
| 54 | 70 | "id": p.id.to_string(), | |
| 55 | 71 | "name": p.name, | |
| 56 | - | "type": p.project_type.as_str(), | |
| 57 | - | "status": p.status.as_str(), | |
| 72 | + | "type": p.project_type.db_value(), | |
| 73 | + | "status": p.status.db_value(), | |
| 58 | 74 | }) | |
| 59 | 75 | }) | |
| 60 | 76 | .collect(); | |
| @@ -72,7 +88,7 @@ impl Tool for CreateProject { | |||
| 72 | 88 | "create_project" | |
| 73 | 89 | } | |
| 74 | 90 | fn description(&self) -> &str { | |
| 75 | - | "Create a project. Idempotent on name: if a project with the same name exists, its id is returned instead of creating a duplicate. `type` is one of Job, SideProject, Company, Writing (defaults to SideProject)." | |
| 91 | + | "Create a project. Idempotent on name: if a project with the same name exists, its id is returned instead of creating a duplicate. `type` is one of Job, SideProject, Company, Essay, Article, Painting, Other (defaults to SideProject)." | |
| 76 | 92 | } | |
| 77 | 93 | fn kind(&self) -> ToolKind { | |
| 78 | 94 | ToolKind::Write(caps::project_create()) | |
| @@ -82,7 +98,7 @@ impl Tool for CreateProject { | |||
| 82 | 98 | "type": "object", | |
| 83 | 99 | "properties": { | |
| 84 | 100 | "name": { "type": "string" }, | |
| 85 | - | "type": { "type": "string", "description": "Job | SideProject | Company | Writing" }, | |
| 101 | + | "type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" }, | |
| 86 | 102 | "description": { "type": "string" } | |
| 87 | 103 | }, | |
| 88 | 104 | "required": ["name"] | |
| @@ -107,11 +123,10 @@ impl Tool for CreateProject { | |||
| 107 | 123 | )); | |
| 108 | 124 | } | |
| 109 | 125 | ||
| 110 | - | let project_type = args | |
| 111 | - | .get("type") | |
| 112 | - | .and_then(Value::as_str) | |
| 113 | - | .map(ProjectType::from_str_or_default) | |
| 114 | - | .unwrap_or(ProjectType::SideProject); | |
| 126 | + | let project_type = match args.get("type").and_then(Value::as_str) { | |
| 127 | + | Some(t) => parse_enum(self.name(), "type", t, PROJECT_TYPES)?, | |
| 128 | + | None => ProjectType::SideProject, | |
| 129 | + | }; | |
| 115 | 130 | let description = args | |
| 116 | 131 | .get("description") | |
| 117 | 132 | .and_then(Value::as_str) | |
| @@ -140,3 +155,94 @@ impl Tool for CreateProject { | |||
| 140 | 155 | )) | |
| 141 | 156 | } | |
| 142 | 157 | } | |
| 158 | + | ||
| 159 | + | /// Named `…Tool` to leave [`UpdateProject`] to the domain model, matching | |
| 160 | + | /// `UpdateTaskTool` in `task.rs`. | |
| 161 | + | pub struct UpdateProjectTool(pub Arc<Ctx>); | |
| 162 | + | ||
| 163 | + | #[async_trait] | |
| 164 | + | impl Tool for UpdateProjectTool { | |
| 165 | + | fn name(&self) -> &str { | |
| 166 | + | "update_project" | |
| 167 | + | } | |
| 168 | + | fn description(&self) -> &str { | |
| 169 | + | "Update an existing project, resolved by `project` (its name). Only the fields you pass change. `status` is Active|OnHold|Completed|Archived — Archived is the non-destructive way to retire a project. `type` is Job|SideProject|Company|Essay|Article|Painting|Other. Renaming is not offered: the name is how create_task and bulk_import_tasks resolve a project, so a rename would strand callers." | |
| 170 | + | } | |
| 171 | + | fn kind(&self) -> ToolKind { | |
| 172 | + | ToolKind::Write(caps::project_update()) | |
| 173 | + | } | |
| 174 | + | fn input_schema(&self) -> Value { | |
| 175 | + | json!({ | |
| 176 | + | "type": "object", | |
| 177 | + | "properties": { | |
| 178 | + | "project": { "type": "string", "description": "The project's current name." }, | |
| 179 | + | "status": { "type": "string", "description": "Active | OnHold | Completed | Archived" }, | |
| 180 | + | "type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" }, | |
| 181 | + | "description": { "type": "string" } | |
| 182 | + | }, | |
| 183 | + | "required": ["project"] | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | async fn call(&self, args: Value) -> Result<ToolCallResult> { | |
| 187 | + | let name = req_str(self.name(), &args, "project")?; | |
| 188 | + | let repo = self.0.projects(); | |
| 189 | + | ||
| 190 | + | // Resolve by name, like create_task does, so callers never handle ids. | |
| 191 | + | let current = repo | |
| 192 | + | .find_by_name(self.0.user_id, name) | |
| 193 | + | .await | |
| 194 | + | .map_err(|e| fail(self.name(), e))? | |
| 195 | + | .ok_or_else(|| Error::ToolFailed { | |
| 196 | + | tool: self.name().to_string(), | |
| 197 | + | message: format!("no project named `{name}`"), | |
| 198 | + | })?; | |
| 199 | + | ||
| 200 | + | // Start from the current project, overlay only the provided fields. | |
| 201 | + | // The name rides through untouched: it is the resolution key. | |
| 202 | + | let mut patch = UpdateProject { | |
| 203 | + | name: current.name.clone(), | |
| 204 | + | description: current.description.clone(), | |
| 205 | + | project_type: current.project_type.clone(), | |
| 206 | + | status: current.status.clone(), | |
| 207 | + | }; | |
| 208 | + | if let Some(status) = args.get("status").and_then(Value::as_str) { | |
| 209 | + | patch.status = parse_enum( | |
| 210 | + | self.name(), | |
| 211 | + | "status", | |
| 212 | + | status, | |
| 213 | + | PROJECT_STATUSES, | |
| 214 | + | )?; | |
| 215 | + | } | |
| 216 | + | if let Some(ty) = args.get("type").and_then(Value::as_str) { | |
| 217 | + | patch.project_type = parse_enum( | |
| 218 | + | self.name(), | |
| 219 | + | "type", | |
| 220 | + | ty, | |
| 221 | + | PROJECT_TYPES, | |
| 222 | + | )?; | |
| 223 | + | } | |
| 224 | + | if let Some(desc) = args.get("description").and_then(Value::as_str) { | |
| 225 | + | patch.description = desc.to_string(); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | let updated = repo | |
| 229 | + | .update(current.id, self.0.user_id, patch) | |
| 230 | + | .await | |
| 231 | + | .map_err(|e| fail(self.name(), e))? | |
| 232 | + | .ok_or_else(|| Error::ToolFailed { | |
| 233 | + | tool: self.name().to_string(), | |
| 234 | + | message: format!("project `{name}` vanished during update"), | |
| 235 | + | })?; | |
| 236 | + | ||
| 237 | + | Ok(ToolCallResult::text( | |
| 238 | + | serde_json::to_string(&json!({ | |
| 239 | + | "id": updated.id.to_string(), | |
| 240 | + | "name": updated.name, | |
| 241 | + | "type": updated.project_type.db_value(), | |
| 242 | + | "status": updated.status.db_value(), | |
| 243 | + | "description": updated.description, | |
| 244 | + | })) | |
| 245 | + | .unwrap(), | |
| 246 | + | )) | |
| 247 | + | } | |
| 248 | + | } |
| @@ -21,7 +21,8 @@ use serde_json::{Value, json}; | |||
| 21 | 21 | use crate::caps; | |
| 22 | 22 | use crate::context::Ctx; | |
| 23 | 23 | use crate::convert::{ | |
| 24 | - | parse_due, parse_priority, parse_tags, parse_task_id, req_str, source_tag, task_row, | |
| 24 | + | MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_tags, parse_task_id, | |
| 25 | + | req_str, source_tag, task_row, task_summary_row, | |
| 25 | 26 | }; | |
| 26 | 27 | ||
| 27 | 28 | fn fail(tool: &str, e: impl std::fmt::Display) -> Error { | |
| @@ -41,7 +42,7 @@ impl Tool for ListTasks { | |||
| 41 | 42 | "list_tasks" | |
| 42 | 43 | } | |
| 43 | 44 | fn description(&self) -> &str { | |
| 44 | - | "List tasks as compact rows. Optional filters: `project` (name), `status` (Pending|Started|Completed), `tag`. Use for reconciling a migration." | |
| 45 | + | "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`." | |
| 45 | 46 | } | |
| 46 | 47 | fn kind(&self) -> ToolKind { | |
| 47 | 48 | ToolKind::Read | |
| @@ -55,11 +56,16 @@ impl Tool for ListTasks { | |||
| 55 | 56 | "properties": { | |
| 56 | 57 | "project": { "type": "string" }, | |
| 57 | 58 | "status": { "type": "string" }, | |
| 58 | - | "tag": { "type": "string" } | |
| 59 | + | "tag": { "type": "string" }, | |
| 60 | + | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT }, | |
| 61 | + | "offset": { "type": "integer", "minimum": 0 } | |
| 59 | 62 | } | |
| 60 | 63 | }) | |
| 61 | 64 | } | |
| 62 | 65 | async fn call(&self, args: Value) -> Result<ToolCallResult> { | |
| 66 | + | let limit = parse_limit(self.name(), args.get("limit"))?; | |
| 67 | + | let offset = parse_offset(self.name(), args.get("offset"))?; | |
| 68 | + | ||
| 63 | 69 | let tasks = self | |
| 64 | 70 | .0 | |
| 65 | 71 | .tasks() | |
| @@ -74,17 +80,34 @@ impl Tool for ListTasks { | |||
| 74 | 80 | .map(TaskStatus::from_str_or_default); | |
| 75 | 81 | let tag = args.get("tag").and_then(Value::as_str); | |
| 76 | 82 | ||
| 77 | - | let rows: Vec<Value> = tasks | |
| 83 | + | let matched: Vec<&Task> = tasks | |
| 78 | 84 | .iter() | |
| 79 | 85 | .filter(|t| project.is_none_or(|p| t.project_name.as_deref() == Some(p))) | |
| 80 | 86 | .filter(|t| status.as_ref().is_none_or(|s| &t.status == s)) | |
| 81 | 87 | .filter(|t| tag.is_none_or(|want| t.tags.iter().any(|have| have == want))) | |
| 82 | - | .map(task_row) | |
| 83 | 88 | .collect(); | |
| 84 | 89 | ||
| 85 | - | Ok(ToolCallResult::text( | |
| 86 | - | serde_json::to_string(&json!({ "count": rows.len(), "tasks": rows })).unwrap(), | |
| 87 | - | )) | |
| 90 | + | let total = matched.len(); | |
| 91 | + | let rows: Vec<Value> = matched | |
| 92 | + | .into_iter() | |
| 93 | + | .skip(offset) | |
| 94 | + | .take(limit) | |
| 95 | + | .map(task_summary_row) | |
| 96 | + | .collect(); | |
| 97 | + | ||
| 98 | + | let mut reply = json!({ | |
| 99 | + | "count": rows.len(), | |
| 100 | + | "total": total, | |
| 101 | + | "offset": offset, | |
| 102 | + | "tasks": rows, | |
| 103 | + | }); | |
| 104 | + | // Only present when a page remains, so its absence is the stop condition. | |
| 105 | + | let next = offset.saturating_add(reply["count"].as_u64().unwrap_or(0) as usize); | |
| 106 | + | if next < total { | |
| 107 | + | reply["next_offset"] = json!(next); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | Ok(ToolCallResult::text(serde_json::to_string(&reply).unwrap())) | |
| 88 | 111 | } | |
| 89 | 112 | } | |
| 90 | 113 |
| @@ -146,3 +146,164 @@ async fn get_update_and_complete_round_trip() { | |||
| 146 | 146 | let done = call(®, "complete_task", json!({ "id": id })).await; | |
| 147 | 147 | assert_eq!(done["status"], "Completed"); | |
| 148 | 148 | } | |
| 149 | + | ||
| 150 | + | #[tokio::test] | |
| 151 | + | async fn list_tasks_pages_and_clips_long_descriptions() { | |
| 152 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 153 | + | ||
| 154 | + | // 120 tasks: more than the default page, less than two max pages. | |
| 155 | + | let items: Vec<Value> = (0..120) | |
| 156 | + | .map(|i| json!({ "description": format!("task {i}"), "project": "bulk" })) | |
| 157 | + | .collect(); | |
| 158 | + | call(®, "bulk_import_tasks", json!({ "tasks": items })).await; | |
| 159 | + | ||
| 160 | + | // An unparameterized call is bounded by the default, and says so. | |
| 161 | + | let first = call(®, "list_tasks", json!({})).await; | |
| 162 | + | assert_eq!(first["count"], 50, "default limit bounds an unasked-for page"); | |
| 163 | + | assert_eq!(first["total"], 120, "total reports every matching row"); | |
| 164 | + | assert_eq!(first["offset"], 0); | |
| 165 | + | assert_eq!(first["next_offset"], 50); | |
| 166 | + | ||
| 167 | + | // Walking next_offset visits every row exactly once and then stops. | |
| 168 | + | let mut seen: HashSet<String> = HashSet::new(); | |
| 169 | + | let mut offset = 0u64; | |
| 170 | + | loop { | |
| 171 | + | let page = call(®, "list_tasks", json!({ "limit": 30, "offset": offset })).await; | |
| 172 | + | for t in page["tasks"].as_array().unwrap() { | |
| 173 | + | assert!(seen.insert(t["id"].as_str().unwrap().to_string()), "row repeated across pages"); | |
| 174 | + | } | |
| 175 | + | match page["next_offset"].as_u64() { | |
| 176 | + | Some(next) => offset = next, | |
| 177 | + | None => break, | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | assert_eq!(seen.len(), 120, "paging covered every row"); | |
| 181 | + | ||
| 182 | + | // The cap clamps rather than erroring; a bogus limit errors rather than defaulting. | |
| 183 | + | let clamped = call(®, "list_tasks", json!({ "limit": 5_000 })).await; | |
| 184 | + | assert_eq!(clamped["count"], 120, "clamped to max, which exceeds the row count here"); | |
| 185 | + | let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect(); | |
| 186 | + | let err = reg.call("list_tasks", json!({ "limit": 0 }), Some(&grants)).await.unwrap_err(); | |
| 187 | + | assert!(matches!(err, Error::InvalidArgs { .. }), "zero limit is a typo, not a default"); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | #[tokio::test] | |
| 191 | + | async fn list_tasks_clips_a_long_description_but_get_task_keeps_it() { | |
| 192 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 193 | + | ||
| 194 | + | let long = "x".repeat(1_000); | |
| 195 | + | let created = call(®, "create_task", json!({ "description": long })).await; | |
| 196 | + | let id = created["id"].as_str().unwrap().to_string(); | |
| 197 | + | ||
| 198 | + | let listed = &call(®, "list_tasks", json!({})).await["tasks"][0]; | |
| 199 | + | assert_eq!(listed["truncated"], true, "a clipped row admits it is clipped"); | |
| 200 | + | assert_eq!(listed["description"].as_str().unwrap().chars().count(), 240); | |
| 201 | + | ||
| 202 | + | // get_task is the escape hatch the clip points at: full text, no marker. | |
| 203 | + | let got = call(®, "get_task", json!({ "id": id })).await; | |
| 204 | + | assert_eq!(got["description"].as_str().unwrap().chars().count(), 1_000); | |
| 205 | + | assert!(got.get("truncated").is_none()); | |
| 206 | + | ||
| 207 | + | // A short description is untouched and unmarked. | |
| 208 | + | call(®, "create_task", json!({ "description": "short" })).await; | |
| 209 | + | let rows = call(®, "list_tasks", json!({})).await; | |
| 210 | + | let short = rows["tasks"].as_array().unwrap().iter() | |
| 211 | + | .find(|t| t["description"] == "short").expect("short task listed"); | |
| 212 | + | assert!(short.get("truncated").is_none()); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | #[tokio::test] | |
| 216 | + | async fn update_project_overlays_fields_and_rejects_a_bad_status() { | |
| 217 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 218 | + | ||
| 219 | + | call(®, "create_project", json!({ | |
| 220 | + | "name": "dawless", "type": "SideProject", "description": "a synth thing" | |
| 221 | + | })).await; | |
| 222 | + | ||
| 223 | + | // The motivating case: retire a dormant project without touching the desktop UI. | |
| 224 | + | let archived = call(®, "update_project", json!({ | |
| 225 | + | "project": "dawless", "status": "Archived" | |
| 226 | + | })).await; | |
| 227 | + | assert_eq!(archived["status"], "Archived"); | |
| 228 | + | assert_eq!(archived["name"], "dawless", "name is the resolution key, untouched"); | |
| 229 | + | assert_eq!(archived["description"], "a synth thing", "unpassed fields keep their value"); | |
| 230 | + | assert_eq!(archived["type"], "SideProject"); | |
| 231 | + | ||
| 232 | + | // It stuck, and is visible to a fresh read. | |
| 233 | + | let listed = call(®, "list_projects", json!({})).await; | |
| 234 | + | assert_eq!(listed["projects"][0]["status"], "Archived"); | |
| 235 | + | ||
| 236 | + | // A typo'd status must fail loudly. Defaulting it would silently flip the | |
| 237 | + | // project back to Active while reporting success. | |
| 238 | + | let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect(); | |
| 239 | + | let err = reg | |
| 240 | + | .call("update_project", json!({ "project": "dawless", "status": "archivd" }), Some(&grants)) | |
| 241 | + | .await | |
| 242 | + | .unwrap_err(); | |
| 243 | + | assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}"); | |
| 244 | + | let after = call(®, "list_projects", json!({})).await; | |
| 245 | + | assert_eq!(after["projects"][0]["status"], "Archived", "the failed call changed nothing"); | |
| 246 | + | ||
| 247 | + | // Unknown project names are an error, never a silent create. | |
| 248 | + | let err = reg | |
| 249 | + | .call("update_project", json!({ "project": "ghost", "status": "Active" }), Some(&grants)) | |
| 250 | + | .await | |
| 251 | + | .unwrap_err(); | |
| 252 | + | assert!(matches!(err, Error::ToolFailed { .. }), "got {err:?}"); | |
| 253 | + | assert_eq!(call(®, "list_projects", json!({})).await["projects"].as_array().unwrap().len(), 1); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[tokio::test] | |
| 257 | + | async fn update_project_is_capability_gated() { | |
| 258 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 259 | + | let err = reg | |
| 260 | + | .call("update_project", json!({ "project": "x", "status": "Archived" }), Some(&HashSet::new())) | |
| 261 | + | .await | |
| 262 | + | .unwrap_err(); | |
| 263 | + | match err { | |
| 264 | + | Error::CapabilityDenied { capability, .. } => assert_eq!(capability, "go.project.update"), | |
| 265 | + | other => panic!("expected CapabilityDenied, got {other:?}"), | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /// The values the read tools emit must be the values the write tools accept. | |
| 270 | + | /// `ProjectType::as_str` is a display string ("Side Project") that `FromStr` | |
| 271 | + | /// rejects, so emitting it would break any caller that echoes a row back. | |
| 272 | + | #[tokio::test] | |
| 273 | + | async fn project_enum_values_round_trip_through_the_write_surface() { | |
| 274 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 275 | + | ||
| 276 | + | call(®, "create_project", json!({ "name": "roundtrip", "type": "SideProject" })).await; | |
| 277 | + | call(®, "update_project", json!({ "project": "roundtrip", "status": "OnHold" })).await; | |
| 278 | + | ||
| 279 | + | let listed = &call(®, "list_projects", json!({})).await["projects"][0]; | |
| 280 | + | assert_eq!(listed["type"], "SideProject", "not the display form `Side Project`"); | |
| 281 | + | assert_eq!(listed["status"], "OnHold", "not the display form `On Hold`"); | |
| 282 | + | ||
| 283 | + | // Feeding the emitted row straight back must be accepted verbatim. | |
| 284 | + | let echoed = call(®, "update_project", json!({ | |
| 285 | + | "project": "roundtrip", | |
| 286 | + | "type": listed["type"].clone(), | |
| 287 | + | "status": listed["status"].clone(), | |
| 288 | + | })).await; | |
| 289 | + | assert_eq!(echoed["type"], "SideProject"); | |
| 290 | + | assert_eq!(echoed["status"], "OnHold"); | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// `Writing` is not a ProjectType variant. It used to parse via | |
| 294 | + | /// `from_str_or_default` into `Job` (the derived Default) and report success. | |
| 295 | + | #[tokio::test] | |
| 296 | + | async fn create_project_rejects_an_unknown_type_instead_of_defaulting() { | |
| 297 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 298 | + | let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect(); | |
| 299 | + | ||
| 300 | + | let err = reg | |
| 301 | + | .call("create_project", json!({ "name": "essays", "type": "Writing" }), Some(&grants)) | |
| 302 | + | .await | |
| 303 | + | .unwrap_err(); | |
| 304 | + | assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}"); | |
| 305 | + | ||
| 306 | + | // And the rejected call created nothing. | |
| 307 | + | let listed = call(®, "list_projects", json!({})).await; | |
| 308 | + | assert!(listed["projects"].as_array().unwrap().is_empty()); | |
| 309 | + | } |