Skip to main content

max / goingson

19.1 KB · 544 lines History Blame Raw
1 //! Task tools: the read surface (`list_tasks`, `get_task`) and the write
2 //! surface (`create_task`, `bulk_import_tasks`, `update_task`, `complete_task`).
3 //!
4 //! `bulk_import_tasks` is the `/dellm` migration primitive: it takes a parsed
5 //! backlog and creates tasks in one capability-gated call, deduping on a
6 //! `source:` provenance tag so a re-run does not double-insert.
7
8 use std::collections::{HashMap, HashSet};
9 use std::sync::Arc;
10
11 use async_trait::async_trait;
12 use goingson_core::repository::{ProjectRepository, TaskAnnotations, TaskCrud};
13 use goingson_core::models::ParseableEnum;
14 use goingson_core::{
15 NewProject, NewTask, ProjectId, ProjectStatus, ProjectType, Task, TaskStatus, TokenState,
16 UpdateTask, TOKEN_KIND_COMMIT,
17 };
18 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
19 use serde_json::{Value, json};
20
21 use crate::caps;
22 use crate::context::Ctx;
23 use crate::convert::{
24 parse_due, parse_priority, parse_tags, parse_task_id, req_str, source_tag, task_row,
25 };
26
27 fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
28 Error::ToolFailed {
29 tool: tool.to_string(),
30 message: e.to_string(),
31 }
32 }
33
34 // ---------- reads ----------
35
36 pub struct ListTasks(pub Arc<Ctx>);
37
38 #[async_trait]
39 impl Tool for ListTasks {
40 fn name(&self) -> &str {
41 "list_tasks"
42 }
43 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 }
46 fn kind(&self) -> ToolKind {
47 ToolKind::Read
48 }
49 fn small_model_safe(&self) -> bool {
50 true
51 }
52 fn input_schema(&self) -> Value {
53 json!({
54 "type": "object",
55 "properties": {
56 "project": { "type": "string" },
57 "status": { "type": "string" },
58 "tag": { "type": "string" }
59 }
60 })
61 }
62 async fn call(&self, args: Value) -> Result<ToolCallResult> {
63 let tasks = self
64 .0
65 .tasks()
66 .list_all(self.0.user_id)
67 .await
68 .map_err(|e| fail(self.name(), e))?;
69
70 let project = args.get("project").and_then(Value::as_str);
71 let status = args
72 .get("status")
73 .and_then(Value::as_str)
74 .map(TaskStatus::from_str_or_default);
75 let tag = args.get("tag").and_then(Value::as_str);
76
77 let rows: Vec<Value> = tasks
78 .iter()
79 .filter(|t| project.is_none_or(|p| t.project_name.as_deref() == Some(p)))
80 .filter(|t| status.as_ref().is_none_or(|s| &t.status == s))
81 .filter(|t| tag.is_none_or(|want| t.tags.iter().any(|have| have == want)))
82 .map(task_row)
83 .collect();
84
85 Ok(ToolCallResult::text(
86 serde_json::to_string(&json!({ "count": rows.len(), "tasks": rows })).unwrap(),
87 ))
88 }
89 }
90
91 pub struct GetTask(pub Arc<Ctx>);
92
93 #[async_trait]
94 impl Tool for GetTask {
95 fn name(&self) -> &str {
96 "get_task"
97 }
98 fn description(&self) -> &str {
99 "Fetch one task by id, including its subtasks and annotations."
100 }
101 fn kind(&self) -> ToolKind {
102 ToolKind::Read
103 }
104 fn small_model_safe(&self) -> bool {
105 true
106 }
107 fn input_schema(&self) -> Value {
108 json!({
109 "type": "object",
110 "properties": { "id": { "type": "string" } },
111 "required": ["id"]
112 })
113 }
114 async fn call(&self, args: Value) -> Result<ToolCallResult> {
115 let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?;
116 let repo = self.0.tasks();
117 let task = repo
118 .get_by_id(id, self.0.user_id)
119 .await
120 .map_err(|e| fail(self.name(), e))?
121 .ok_or_else(|| Error::ToolFailed {
122 tool: self.name().to_string(),
123 message: format!("no task with id {id}"),
124 })?;
125
126 let subtasks = repo
127 .get_subtasks_for_task(id)
128 .await
129 .map_err(|e| fail(self.name(), e))?;
130 let annotations = repo
131 .get_annotations_for_task(id)
132 .await
133 .map_err(|e| fail(self.name(), e))?;
134
135 let mut row = task_row(&task);
136 row["subtasks"] = json!(
137 subtasks
138 .iter()
139 .map(|s| json!({ "text": s.text, "completed": s.is_completed }))
140 .collect::<Vec<_>>()
141 );
142 row["annotations"] = json!(
143 annotations
144 .iter()
145 .map(|a| json!({ "note": a.note, "created_at": a.timestamp.to_rfc3339() }))
146 .collect::<Vec<_>>()
147 );
148 Ok(ToolCallResult::text(serde_json::to_string(&row).unwrap()))
149 }
150 }
151
152 // ---------- writes ----------
153
154 pub struct CreateTask(pub Arc<Ctx>);
155
156 #[async_trait]
157 impl Tool for CreateTask {
158 fn name(&self) -> &str {
159 "create_task"
160 }
161 fn description(&self) -> &str {
162 "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."
163 }
164 fn kind(&self) -> ToolKind {
165 ToolKind::Write(caps::task_create())
166 }
167 fn input_schema(&self) -> Value {
168 json!({
169 "type": "object",
170 "properties": {
171 "description": { "type": "string" },
172 "project": { "type": "string" },
173 "tags": { "type": "array", "items": { "type": "string" } },
174 "due": { "type": "string" },
175 "priority": { "type": "string" }
176 },
177 "required": ["description"]
178 })
179 }
180 async fn call(&self, args: Value) -> Result<ToolCallResult> {
181 let description = req_str(self.name(), &args, "description")?.to_string();
182 let due = parse_due(self.name(), args.get("due"))?;
183 let priority = parse_priority(args.get("priority"));
184 let tags = parse_tags(args.get("tags"));
185
186 let project_id = match args.get("project").and_then(Value::as_str) {
187 Some(name) if !name.trim().is_empty() => {
188 Some(ensure_project(&self.0, name).await.map_err(|e| fail(self.name(), e))?)
189 }
190 _ => None,
191 };
192
193 let mut builder = NewTask::builder(description).priority(priority).tags(tags);
194 if let Some(pid) = project_id {
195 builder = builder.project_id(pid);
196 }
197 if let Some(d) = due {
198 builder = builder.due(d);
199 }
200
201 let task = self
202 .0
203 .tasks()
204 .create(self.0.user_id, builder.build())
205 .await
206 .map_err(|e| fail(self.name(), e))?;
207
208 Ok(ToolCallResult::text(
209 serde_json::to_string(&json!({ "id": task.id.to_string() })).unwrap(),
210 ))
211 }
212 }
213
214 pub struct BulkImportTasks(pub Arc<Ctx>);
215
216 #[async_trait]
217 impl Tool for BulkImportTasks {
218 fn name(&self) -> &str {
219 "bulk_import_tasks"
220 }
221 fn description(&self) -> &str {
222 "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."
223 }
224 fn kind(&self) -> ToolKind {
225 ToolKind::Write(caps::task_bulk_import())
226 }
227 fn input_schema(&self) -> Value {
228 json!({
229 "type": "object",
230 "properties": {
231 "tasks": {
232 "type": "array",
233 "items": {
234 "type": "object",
235 "properties": {
236 "description": { "type": "string" },
237 "project": { "type": "string" },
238 "tags": { "type": "array", "items": { "type": "string" } },
239 "due": { "type": "string" },
240 "priority": { "type": "string" },
241 "source": { "type": "string" }
242 },
243 "required": ["description"]
244 }
245 }
246 },
247 "required": ["tasks"]
248 })
249 }
250 async fn call(&self, args: Value) -> Result<ToolCallResult> {
251 let items = args
252 .get("tasks")
253 .and_then(Value::as_array)
254 .ok_or_else(|| Error::InvalidArgs {
255 tool: self.name().to_string(),
256 message: "missing array field `tasks`".into(),
257 })?;
258
259 let repo = self.0.tasks();
260
261 // Existing source tags → idempotency. One scan up front.
262 let existing = repo
263 .list_all(self.0.user_id)
264 .await
265 .map_err(|e| fail(self.name(), e))?;
266 let mut seen_sources: HashSet<String> = existing
267 .iter()
268 .flat_map(|t| t.tags.iter())
269 .filter(|tag| tag.starts_with("source:"))
270 .cloned()
271 .collect();
272
273 let mut project_cache: HashMap<String, ProjectId> = HashMap::new();
274 let mut created_ids = Vec::new();
275 let mut skipped = 0usize;
276
277 for (idx, item) in items.iter().enumerate() {
278 let description = item
279 .get("description")
280 .and_then(Value::as_str)
281 .filter(|s| !s.trim().is_empty())
282 .ok_or_else(|| Error::InvalidArgs {
283 tool: self.name().to_string(),
284 message: format!("tasks[{idx}] is missing a non-empty `description`"),
285 })?
286 .to_string();
287
288 let mut tags = parse_tags(item.get("tags"));
289
290 // Provenance / idempotency.
291 if let Some(source) = item.get("source").and_then(Value::as_str) {
292 let tag = source_tag(source);
293 if !seen_sources.insert(tag.clone()) {
294 skipped += 1;
295 continue;
296 }
297 tags.push(tag);
298 }
299
300 let due = parse_due(self.name(), item.get("due"))?;
301 let priority = parse_priority(item.get("priority"));
302
303 let project_id = match item.get("project").and_then(Value::as_str) {
304 Some(name) if !name.trim().is_empty() => {
305 Some(resolve_project_cached(&self.0, name, &mut project_cache).await
306 .map_err(|e| fail(self.name(), e))?)
307 }
308 _ => None,
309 };
310
311 let mut builder = NewTask::builder(description).priority(priority).tags(tags);
312 if let Some(pid) = project_id {
313 builder = builder.project_id(pid);
314 }
315 if let Some(d) = due {
316 builder = builder.due(d);
317 }
318
319 let task = repo
320 .create(self.0.user_id, builder.build())
321 .await
322 .map_err(|e| fail(self.name(), e))?;
323 created_ids.push(task.id.to_string());
324 }
325
326 Ok(ToolCallResult::text(
327 serde_json::to_string(&json!({
328 "created": created_ids.len(),
329 "skipped": skipped,
330 "task_ids": created_ids,
331 }))
332 .unwrap(),
333 ))
334 }
335 }
336
337 pub struct UpdateTaskTool(pub Arc<Ctx>);
338
339 #[async_trait]
340 impl Tool for UpdateTaskTool {
341 fn name(&self) -> &str {
342 "update_task"
343 }
344 fn description(&self) -> &str {
345 "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 `<repo>@<shortsha>` (e.g. `deox@7c236fca8`); use `complete_task` to record the closing commit."
346 }
347 fn kind(&self) -> ToolKind {
348 ToolKind::Write(caps::task_update())
349 }
350 fn input_schema(&self) -> Value {
351 json!({
352 "type": "object",
353 "properties": {
354 "id": { "type": "string" },
355 "description": { "type": "string" },
356 "priority": { "type": "string" },
357 "due": { "type": "string" },
358 "status": { "type": "string" },
359 "tags": { "type": "array", "items": { "type": "string" } },
360 "project": { "type": "string" },
361 "commit": { "type": "string" }
362 },
363 "required": ["id"]
364 })
365 }
366 async fn call(&self, args: Value) -> Result<ToolCallResult> {
367 let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?;
368 let repo = self.0.tasks();
369 let current = repo
370 .get_by_id(id, self.0.user_id)
371 .await
372 .map_err(|e| fail(self.name(), e))?
373 .ok_or_else(|| Error::ToolFailed {
374 tool: self.name().to_string(),
375 message: format!("no task with id {id}"),
376 })?;
377
378 // Start from the current task, overlay only the provided fields.
379 let mut patch = update_from_task(&current);
380 if let Some(desc) = args.get("description").and_then(Value::as_str) {
381 patch.description = desc.to_string();
382 }
383 if args.get("priority").is_some() {
384 patch.priority = parse_priority(args.get("priority"));
385 }
386 if let Some(due) = args.get("due") {
387 patch.due = parse_due(self.name(), Some(due))?;
388 }
389 if let Some(status) = args.get("status").and_then(Value::as_str) {
390 patch.status = TaskStatus::from_str_or_default(status);
391 }
392 if let Some(tags) = args.get("tags") {
393 patch.tags = parse_tags(Some(tags));
394 }
395 if let Some(name) = args.get("project").and_then(Value::as_str) {
396 patch.project_id = if name.trim().is_empty() {
397 None
398 } else {
399 Some(ensure_project(&self.0, name).await.map_err(|e| fail(self.name(), e))?)
400 };
401 }
402
403 let updated = repo
404 .update(id, self.0.user_id, patch)
405 .await
406 .map_err(|e| fail(self.name(), e))?
407 .ok_or_else(|| Error::ToolFailed {
408 tool: self.name().to_string(),
409 message: format!("task {id} vanished during update"),
410 })?;
411
412 // Record an advancing commit (if given) as a non-primary Pending token after
413 // the field update, then re-read so the returned row reflects it. Idempotent
414 // via the deterministic token id.
415 if let Some(commit) = args.get("commit").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) {
416 repo.record_status_token(id, self.0.user_id, TOKEN_KIND_COMMIT, commit, TokenState::Pending, false)
417 .await
418 .map_err(|e| fail(self.name(), e))?;
419 let refreshed = repo
420 .get_by_id(id, self.0.user_id)
421 .await
422 .map_err(|e| fail(self.name(), e))?
423 .unwrap_or(updated);
424 return Ok(ToolCallResult::text(serde_json::to_string(&task_row(&refreshed)).unwrap()));
425 }
426
427 Ok(ToolCallResult::text(serde_json::to_string(&task_row(&updated)).unwrap()))
428 }
429 }
430
431 pub struct CompleteTask(pub Arc<Ctx>);
432
433 #[async_trait]
434 impl Tool for CompleteTask {
435 fn name(&self) -> &str {
436 "complete_task"
437 }
438 fn description(&self) -> &str {
439 "Mark a task complete by id. Optional `commit` records the closing commit, repo-qualified as `<repo>@<shortsha>` (e.g. `deox@7c236fca8`); it is appended to the task's commit list and flagged as the closer."
440 }
441 fn kind(&self) -> ToolKind {
442 ToolKind::Write(caps::task_complete())
443 }
444 fn input_schema(&self) -> Value {
445 json!({
446 "type": "object",
447 "properties": {
448 "id": { "type": "string" },
449 "commit": { "type": "string" }
450 },
451 "required": ["id"]
452 })
453 }
454 async fn call(&self, args: Value) -> Result<ToolCallResult> {
455 let id = parse_task_id(self.name(), req_str(self.name(), &args, "id")?)?;
456 let repo = self.0.tasks();
457 repo
458 .complete(id, self.0.user_id)
459 .await
460 .map_err(|e| fail(self.name(), e))?
461 .ok_or_else(|| Error::ToolFailed {
462 tool: self.name().to_string(),
463 message: format!("no task with id {id}"),
464 })?;
465
466 // Record the closing commit (if given) as the primary status token before
467 // re-reading, so the returned row carries it. A freshly-made commit is not
468 // yet pushed, so it starts Pending. Idempotent via the deterministic token id.
469 if let Some(commit) = args.get("commit").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) {
470 repo.record_status_token(id, self.0.user_id, TOKEN_KIND_COMMIT, commit, TokenState::Pending, true)
471 .await
472 .map_err(|e| fail(self.name(), e))?;
473 }
474
475 let completed = repo
476 .get_by_id(id, self.0.user_id)
477 .await
478 .map_err(|e| fail(self.name(), e))?
479 .ok_or_else(|| Error::ToolFailed {
480 tool: self.name().to_string(),
481 message: format!("task {id} vanished after completion"),
482 })?;
483 Ok(ToolCallResult::text(serde_json::to_string(&task_row(&completed)).unwrap()))
484 }
485 }
486
487 // ---------- helpers ----------
488
489 /// Resolve a project by name, creating a default `SideProject` if absent.
490 async fn ensure_project(ctx: &Ctx, name: &str) -> goingson_core::repository::Result<ProjectId> {
491 let repo = ctx.projects();
492 if let Some(existing) = repo.find_by_name(ctx.user_id, name).await? {
493 return Ok(existing.id);
494 }
495 let created = repo
496 .create(
497 ctx.user_id,
498 NewProject {
499 name: name.to_string(),
500 description: String::new(),
501 project_type: ProjectType::SideProject,
502 status: ProjectStatus::default(),
503 },
504 )
505 .await?;
506 Ok(created.id)
507 }
508
509 /// `ensure_project` with a within-call cache, so a bulk import that references
510 /// the same project 200 times hits the database once.
511 async fn resolve_project_cached(
512 ctx: &Ctx,
513 name: &str,
514 cache: &mut HashMap<String, ProjectId>,
515 ) -> goingson_core::repository::Result<ProjectId> {
516 if let Some(id) = cache.get(name) {
517 return Ok(*id);
518 }
519 let id = ensure_project(ctx, name).await?;
520 cache.insert(name.to_string(), id);
521 Ok(id)
522 }
523
524 /// Build a full [`UpdateTask`] mirroring a task's current state, so a caller can
525 /// overlay just the fields it wants to change.
526 fn update_from_task(t: &Task) -> UpdateTask {
527 UpdateTask {
528 project_id: t.project_id,
529 milestone_id: t.milestone_id,
530 contact_id: t.contact_id,
531 description: t.description.clone(),
532 status: t.status.clone(),
533 priority: t.priority.clone(),
534 due: t.due,
535 tags: t.tags.clone(),
536 recurrence: t.recurrence.clone(),
537 recurrence_rule: t.recurrence_rule.clone(),
538 urgency: t.urgency,
539 scheduled_start: t.scheduled_start,
540 scheduled_duration: t.scheduled_duration,
541 estimated_minutes: t.estimated_minutes,
542 }
543 }
544