Skip to main content

max / goingson

go-mcp: add update_project, page list_tasks list_tasks was unbounded: a real project's Pending tasks returned 149 rows of multi-paragraph descriptions and overflowed a tool-result budget, so the front door's main read tool was unusable at real size. Add limit (default 50, max 200) and offset, report total and next_offset, and clip long descriptions with a truncated marker pointing at get_task. Add update_project so a session can retire a project without the desktop UI (the archive wave left five GO projects stuck Active). Resolved by name, no rename (the name is the resolution key for create_task), no delete (the underlying delete is a hard DELETE with no soft-delete behind it). Two bugs surfaced while building it: - list_projects emitted ProjectType::as_str ("Side Project", "On Hold"), the UI display form, which FromStr rejects. A row could not be fed back to a write tool. Emit db_value, which round-trips. - ProjectType has no Writing variant, but create_project advertised one and from_str_or_default silently resolved it to Job while reporting success. Correct the vocabulary and parse enums strictly; an unknown word is now refused rather than defaulted.
Author: Max Johnson <me@maxj.phd> · 2026-07-16 19:07 UTC
Commit: 161108c7753e5b537f0741acd810b4eba9e9a2a3
Parent: 6ecd85e
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(&reg, "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(&reg, "bulk_import_tasks", json!({ "tasks": items })).await;
159 +
160 + // An unparameterized call is bounded by the default, and says so.
161 + let first = call(&reg, "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(&reg, "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(&reg, "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(&reg, "create_task", json!({ "description": long })).await;
196 + let id = created["id"].as_str().unwrap().to_string();
197 +
198 + let listed = &call(&reg, "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(&reg, "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(&reg, "create_task", json!({ "description": "short" })).await;
209 + let rows = call(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "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(&reg, "create_project", json!({ "name": "roundtrip", "type": "SideProject" })).await;
277 + call(&reg, "update_project", json!({ "project": "roundtrip", "status": "OnHold" })).await;
278 +
279 + let listed = &call(&reg, "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(&reg, "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(&reg, "list_projects", json!({})).await;
308 + assert!(listed["projects"].as_array().unwrap().is_empty());
309 + }