max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+379 insertions,
-218 deletions
| @@ -33,13 +33,14 @@ | |||
| 33 | 33 | ||
| 34 | 34 | Read: | |
| 35 | 35 | ||
| 36 | - | - `list_projects`: id, name, type, status. | |
| 37 | - | - `list_tasks(project?, status?, tag?, limit?, offset?)`: compact task rows, | |
| 36 | + | - `list_projects`: id, name, type, status. Every write that takes a project | |
| 37 | + | takes its `project_id`, so this is how a name becomes an id. | |
| 38 | + | - `list_tasks(project_id?, project?, status?, tag?, limit?, offset?)`: compact task rows, | |
| 38 | 39 | paged (default 50, max 200). Descriptions over 240 chars are clipped and the | |
| 39 | 40 | row marked `truncated`; `get_task` has the full text. The reply carries | |
| 40 | 41 | `total` and, while pages remain, `next_offset`. Walk until it is absent. | |
| 41 | 42 | - `get_task(id)`: one task with subtasks and annotations. | |
| 42 | - | - `list_events(from?, to?, project?, recurring_only?, expand?, limit?, offset?)`: | |
| 43 | + | - `list_events(from?, to?, project_id?, project?, recurring_only?, expand?, limit?, offset?)`: | |
| 43 | 44 | calendar events in a window, paged on the same terms as `list_tasks`. Defaults | |
| 44 | 45 | to the next 30 days. See "Events and recurrence" below for what `expand` does. | |
| 45 | 46 | - `get_event(id)`: one event with its full description and recurrence rule. | |
| @@ -52,22 +53,24 @@ | |||
| 52 | 53 | | Tool | Capability | | |
| 53 | 54 | |------|------------| | |
| 54 | 55 | | `create_project(name, type?, description?)`, idempotent on name | `go.project.create` | | |
| 55 | - | | `update_project(project, {status?, type?, description?})`, resolved by name, overlays only the fields you pass | `go.project.update` | | |
| 56 | - | | `create_task({description, project?, tags?, due?, priority?})` | `go.task.create` | | |
| 56 | + | | `update_project(project_id, {name?, status?, type?, description?})`, overlays only the fields you pass, `name` renames | `go.project.update` | | |
| 57 | + | | `create_task({description, project_id?, tags?, due?, priority?})` | `go.task.create` | | |
| 57 | 58 | | `bulk_import_tasks([...])`, the `/dellm` primitive | `go.task.bulk_import` | | |
| 58 | 59 | | `update_task(id, fields)`, overlays only the fields you pass | `go.task.update` | | |
| 59 | 60 | | `complete_task(id)` | `go.task.complete` | | |
| 60 | 61 | | `report_problems([...], source?)`, the `/audit` and `/fuzz` primitive | `go.problem.report` | | |
| 61 | 62 | | `promote_problem(id, description?, priority?)`, problem to task | `go.problem.promote` | | |
| 62 | - | | `update_problem(id, {status?, project?})`, triage state and attribution | `go.problem.update` | | |
| 63 | - | | `create_event({title, start, end?, all_day?, project?, location?, recurrence?, block_type?, reminders?})` | `go.event.create` | | |
| 63 | + | | `update_problem(id, {status?, project_id?})`, triage state and attribution | `go.problem.update` | | |
| 64 | + | | `create_event({title, start, end?, all_day?, project_id?, location?, recurrence?, block_type?, reminders?})` | `go.event.create` | | |
| 64 | 65 | | `bulk_import_events([...], source?)`, deduped on `(source, source_ref)` | `go.event.bulk_import` | | |
| 65 | 66 | | `update_event(id, fields)`, overlays only the fields you pass | `go.event.update` | | |
| 66 | 67 | | `delete_event(id)` | `go.event.delete` | | |
| 67 | 68 | ||
| 68 | 69 | `bulk_import_tasks` dedupes on a `source:` provenance tag (e.g. | |
| 69 | 70 | `source:todo.md:42`), so re-running a migration wave does not double-insert. | |
| 70 | - | Projects referenced by name are resolved, and created if absent. | |
| 71 | + | A project is referenced by `project_id` only, and must already exist: an id that | |
| 72 | + | names nothing is refused rather than stored, and no write creates a project as a | |
| 73 | + | side effect. `create_project` is the only thing that makes one. | |
| 71 | 74 | ||
| 72 | 75 | ## Events and recurrence | |
| 73 | 76 | ||
| @@ -144,8 +147,8 @@ | |||
| 144 | 147 | `update_project` is how a session retires a project: set `status` to `Archived`. | |
| 145 | 148 | There is no `delete_project`; the underlying delete is a hard DELETE with no | |
| 146 | 149 | soft-delete behind it, which is the wrong default to hand a session. Renaming is | |
| 147 | - | not offered either, since the name is how `create_task` and `bulk_import_tasks` | |
| 148 | - | resolve a project. | |
| 150 | + | safe and is what `name` does: a project is keyed by id everywhere, so its name is | |
| 151 | + | a label the user owns and can change without stranding anything filed under it. | |
| 149 | 152 | ||
| 150 | 153 | Enum values are the same words in and out (`SideProject`, `OnHold`), not the | |
| 151 | 154 | UI's display forms (`Side Project`, `On Hold`), so a row from `list_projects` |
| @@ -37,6 +37,22 @@ | |||
| 37 | 37 | }) | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | + | /// Parse a project id string into a [`ProjectId`], or an `InvalidArgs`. | |
| 41 | + | /// | |
| 42 | + | /// Names are the user's label and change; the id is what the backend keys on. | |
| 43 | + | /// The error names `list_projects` because that is the only way to turn a name | |
| 44 | + | /// a caller knows into the id the write tools take. | |
| 45 | + | pub fn parse_project_id(tool: &str, s: &str) -> Result<ProjectId, Error> { | |
| 46 | + | Uuid::parse_str(s.trim()) | |
| 47 | + | .map(ProjectId::from_uuid) | |
| 48 | + | .map_err(|_| Error::InvalidArgs { | |
| 49 | + | tool: tool.to_string(), | |
| 50 | + | message: format!( | |
| 51 | + | "`{s}` is not a valid project id (expected a UUID; call list_projects to resolve a name to its id)" | |
| 52 | + | ), | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | ||
| 40 | 56 | /// Parse a subtask id string into a [`SubtaskId`], or an `InvalidArgs`. | |
| 41 | 57 | pub fn parse_subtask_id(tool: &str, s: &str) -> Result<SubtaskId, Error> { | |
| 42 | 58 | Uuid::parse_str(s.trim()) |
| @@ -11,6 +11,7 @@ | |||
| 11 | 11 | use kberg::Error; | |
| 12 | 12 | use serde_json::{Value, json}; | |
| 13 | 13 | use sqlx::SqlitePool; | |
| 14 | + | use uuid::Uuid; | |
| 14 | 15 | ||
| 15 | 16 | async fn seed_db() -> SqlitePool { | |
| 16 | 17 | let pool = goingson_db_sqlite::init_pool(Some(":memory:")) | |
| @@ -82,11 +83,18 @@ | |||
| 82 | 83 | async fn bulk_import_creates_dedupes_and_reconciles() { | |
| 83 | 84 | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 84 | 85 | ||
| 86 | + | // The project has to exist first: an id is the only way to file a task, and | |
| 87 | + | // nothing creates a project as a side effect of an import any more. | |
| 88 | + | let dellm = call(®, "create_project", json!({ "name": "dellm" })).await["id"] | |
| 89 | + | .as_str() | |
| 90 | + | .unwrap() | |
| 91 | + | .to_string(); | |
| 92 | + | ||
| 85 | 93 | let payload = json!({ | |
| 86 | 94 | "tasks": [ | |
| 87 | - | { "description": "migrate todos", "project": "dellm", "due": "2026-07-15", | |
| 95 | + | { "description": "migrate todos", "project_id": dellm, "due": "2026-07-15", | |
| 88 | 96 | "priority": "High", "tags": ["migration"], "source": "todo.md:10" }, | |
| 89 | - | { "description": "bridge rustdoc", "project": "dellm", "source": "todo.md:20" }, | |
| 97 | + | { "description": "bridge rustdoc", "project_id": dellm, "source": "todo.md:20" }, | |
| 90 | 98 | { "description": "no source, always inserts" } | |
| 91 | 99 | ] | |
| 92 | 100 | }); | |
| @@ -100,7 +108,8 @@ | |||
| 100 | 108 | assert_eq!(second["created"], 1, "only the source-less task re-inserts"); | |
| 101 | 109 | assert_eq!(second["skipped"], 2, "both sourced tasks are skipped"); | |
| 102 | 110 | ||
| 103 | - | // Project was auto-created and reused (not duplicated). | |
| 111 | + | // The named project is the one that was created up front, not a second one | |
| 112 | + | // conjured by the import. | |
| 104 | 113 | let projects = call(®, "list_projects", json!({})).await; | |
| 105 | 114 | let names: Vec<&str> = projects["projects"] | |
| 106 | 115 | .as_array() | |
| @@ -113,8 +122,11 @@ | |||
| 113 | 122 | // Reconciliation: 4 tasks total, filterable by project and tag. | |
| 114 | 123 | let all = call(®, "list_tasks", json!({})).await; | |
| 115 | 124 | assert_eq!(all["count"], 4); | |
| 116 | - | let in_project = call(®, "list_tasks", json!({ "project": "dellm" })).await; | |
| 125 | + | let in_project = call(®, "list_tasks", json!({ "project_id": dellm })).await; | |
| 117 | 126 | assert_eq!(in_project["count"], 2); | |
| 127 | + | // The name filter still reads, and agrees with the id. | |
| 128 | + | let by_name = call(®, "list_tasks", json!({ "project": "dellm" })).await; | |
| 129 | + | assert_eq!(by_name["count"], 2); | |
| 118 | 130 | let tagged = call(®, "list_tasks", json!({ "tag": "migration" })).await; | |
| 119 | 131 | assert_eq!(tagged["count"], 1); | |
| 120 | 132 | ||
| @@ -275,7 +287,7 @@ | |||
| 275 | 287 | ||
| 276 | 288 | // 120 tasks: more than the default page, less than two max pages. | |
| 277 | 289 | let items: Vec<Value> = (0..120) | |
| 278 | - | .map(|i| json!({ "description": format!("task {i}"), "project": "bulk" })) | |
| 290 | + | .map(|i| json!({ "description": format!("task {i}") })) | |
| 279 | 291 | .collect(); | |
| 280 | 292 | call(®, "bulk_import_tasks", json!({ "tasks": items })).await; | |
| 281 | 293 | ||
| @@ -360,28 +372,31 @@ | |||
| 360 | 372 | async fn update_project_overlays_fields_and_rejects_a_bad_status() { | |
| 361 | 373 | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 362 | 374 | ||
| 363 | - | call( | |
| 375 | + | let dawless = call( | |
| 364 | 376 | ®, | |
| 365 | 377 | "create_project", | |
| 366 | 378 | json!({ | |
| 367 | 379 | "name": "dawless", "type": "SideProject", "description": "a synth thing" | |
| 368 | 380 | }), | |
| 369 | 381 | ) | |
| 370 | - | .await; | |
| 382 | + | .await["id"] | |
| 383 | + | .as_str() | |
| 384 | + | .unwrap() | |
| 385 | + | .to_string(); | |
| 371 | 386 | ||
| 372 | 387 | // The motivating case: retire a dormant project without touching the desktop UI. | |
| 373 | 388 | let archived = call( | |
| 374 | 389 | ®, | |
| 375 | 390 | "update_project", | |
| 376 | 391 | json!({ | |
| 377 | - | "project": "dawless", "status": "Archived" | |
| 392 | + | "project_id": dawless, "status": "Archived" | |
| 378 | 393 | }), | |
| 379 | 394 | ) | |
| 380 | 395 | .await; | |
| 381 | 396 | assert_eq!(archived["status"], "Archived"); | |
| 382 | 397 | assert_eq!( | |
| 383 | 398 | archived["name"], "dawless", | |
| 384 | - | "name is the resolution key, untouched" | |
| 399 | + | "an unpassed name is left alone" | |
| 385 | 400 | ); | |
| 386 | 401 | assert_eq!( | |
| 387 | 402 | archived["description"], "a synth thing", | |
| @@ -399,7 +414,7 @@ | |||
| 399 | 414 | let err = reg | |
| 400 | 415 | .call( | |
| 401 | 416 | "update_project", | |
| 402 | - | json!({ "project": "dawless", "status": "archivd" }), | |
| 417 | + | json!({ "project_id": dawless, "status": "archivd" }), | |
| 403 | 418 | Some(&grants), | |
| 404 | 419 | ) | |
| 405 | 420 | .await | |
| @@ -411,16 +426,28 @@ | |||
| 411 | 426 | "the failed call changed nothing" | |
| 412 | 427 | ); | |
| 413 | 428 | ||
| 414 | - | // Unknown project names are an error, never a silent create. | |
| 429 | + | // An id that names nothing is an error, never a silent create. | |
| 415 | 430 | let err = reg | |
| 416 | 431 | .call( | |
| 417 | 432 | "update_project", | |
| 418 | - | json!({ "project": "ghost", "status": "Active" }), | |
| 433 | + | json!({ "project_id": Uuid::new_v4().to_string(), "status": "Active" }), | |
| 419 | 434 | Some(&grants), | |
| 420 | 435 | ) | |
| 421 | 436 | .await | |
| 422 | 437 | .unwrap_err(); | |
| 423 | - | assert!(matches!(err, Error::ToolFailed { .. }), "got {err:?}"); | |
| 438 | + | assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}"); | |
| 439 | + | ||
| 440 | + | // So is a name where an id belongs: the old wire shape fails loudly rather | |
| 441 | + | // than resolving, which is what makes the rename below safe. | |
| 442 | + | let err = reg | |
| 443 | + | .call( | |
| 444 | + | "update_project", | |
| 445 | + | json!({ "project_id": "dawless", "status": "Active" }), | |
| 446 | + | Some(&grants), | |
| 447 | + | ) | |
| 448 | + | .await | |
| 449 | + | .unwrap_err(); | |
| 450 | + | assert!(matches!(err, Error::InvalidArgs { .. }), "got {err:?}"); | |
| 424 | 451 | assert_eq!( | |
| 425 | 452 | call(®, "list_projects", json!({})).await["projects"] | |
| 426 | 453 | .as_array() | |
| @@ -430,13 +457,108 @@ | |||
| 430 | 457 | ); | |
| 431 | 458 | } | |
| 432 | 459 | ||
| 460 | + | /// The case the id migration exists for: a project renamed under its work, with | |
| 461 | + | /// everything filed under it still filed under it afterwards. This is what a | |
| 462 | + | /// name-keyed backend could not do, since the rename stranded every caller | |
| 463 | + | /// holding the old name. | |
| 464 | + | #[tokio::test] | |
| 465 | + | async fn renaming_a_project_keeps_its_work() { | |
| 466 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 467 | + | ||
| 468 | + | let id = call(®, "create_project", json!({ "name": "makeover-egui" })).await["id"] | |
| 469 | + | .as_str() | |
| 470 | + | .unwrap() | |
| 471 | + | .to_string(); | |
| 472 | + | call( | |
| 473 | + | ®, | |
| 474 | + | "create_task", | |
| 475 | + | json!({ "description": "seed the crate", "project_id": id }), | |
| 476 | + | ) | |
| 477 | + | .await; | |
| 478 | + | call( | |
| 479 | + | ®, | |
| 480 | + | "report_problems", | |
| 481 | + | json!({ | |
| 482 | + | "problems": [{ "title": "no renderer yet", "project_id": id }] | |
| 483 | + | }), | |
| 484 | + | ) | |
| 485 | + | .await; | |
| 486 | + | ||
| 487 | + | let renamed = call( | |
| 488 | + | ®, | |
| 489 | + | "update_project", | |
| 490 | + | json!({ "project_id": id, "name": "makeover-immediate" }), | |
| 491 | + | ) | |
| 492 | + | .await; | |
| 493 | + | assert_eq!(renamed["name"], "makeover-immediate"); | |
| 494 | + | assert_eq!(renamed["status"], "Active", "a rename touches nothing else"); | |
| 495 | + | ||
| 496 | + | // The task followed the project rather than being stranded by the old name. | |
| 497 | + | let tasks = call(®, "list_tasks", json!({ "project_id": id })).await; | |
| 498 | + | assert_eq!(tasks["count"], 1); | |
| 499 | + | assert_eq!(tasks["tasks"][0]["project"], "makeover-immediate"); | |
| 500 | + | ||
| 501 | + | // As did the problem. | |
| 502 | + | let problems = call(®, "list_problems", json!({ "project_id": id })).await; | |
| 503 | + | assert_eq!(problems["count"], 1); | |
| 504 | + | ||
| 505 | + | // And the id still writes, which is the whole point. | |
| 506 | + | call( | |
| 507 | + | ®, | |
| 508 | + | "create_task", | |
| 509 | + | json!({ "description": "render to a terminal", "project_id": id }), | |
| 510 | + | ) | |
| 511 | + | .await; | |
| 512 | + | assert_eq!( | |
| 513 | + | call(®, "list_tasks", json!({ "project_id": id })).await["count"], | |
| 514 | + | 2 | |
| 515 | + | ); | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | /// An id that no project answers to must fail loudly. Storing it would leave a | |
| 519 | + | /// row that reads as projectless with no way to tell it from a deliberate one. | |
| 520 | + | #[tokio::test] | |
| 521 | + | async fn writes_reject_a_project_id_that_names_nothing() { | |
| 522 | + | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 523 | + | let grants: HashSet<String> = reg.write_capabilities().into_iter().map(|c| c.id).collect(); | |
| 524 | + | let ghost = Uuid::new_v4().to_string(); | |
| 525 | + | ||
| 526 | + | for (tool, args) in [ | |
| 527 | + | ( | |
| 528 | + | "create_task", | |
| 529 | + | json!({ "description": "orphan", "project_id": ghost }), | |
| 530 | + | ), | |
| 531 | + | ( | |
| 532 | + | "bulk_import_tasks", | |
| 533 | + | json!({ "tasks": [{ "description": "orphan", "project_id": ghost }] }), | |
| 534 | + | ), | |
| 535 | + | ( | |
| 536 | + | "create_event", | |
| 537 | + | json!({ "title": "orphan", "start": "2026-08-03T09:00:00Z", "project_id": ghost }), | |
| 538 | + | ), | |
| 539 | + | ( | |
| 540 | + | "report_problems", | |
| 541 | + | json!({ "problems": [{ "title": "orphan", "project_id": ghost }] }), | |
| 542 | + | ), | |
| 543 | + | ] { | |
| 544 | + | let err = reg.call(tool, args, Some(&grants)).await.unwrap_err(); | |
| 545 | + | assert!( | |
| 546 | + | matches!(err, Error::InvalidArgs { .. }), | |
| 547 | + | "{tool}: expected InvalidArgs, got {err:?}" | |
| 548 | + | ); | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | assert_eq!(call(®, "list_tasks", json!({})).await["count"], 0); | |
| 552 | + | assert_eq!(call(®, "list_problems", json!({})).await["count"], 0); | |
| 553 | + | } | |
| 554 | + | ||
| 433 | 555 | #[tokio::test] | |
| 434 | 556 | async fn update_project_is_capability_gated() { | |
| 435 | 557 | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 436 | 558 | let err = reg | |
| 437 | 559 | .call( | |
| 438 | 560 | "update_project", | |
| 439 | - | json!({ "project": "x", "status": "Archived" }), | |
| 561 | + | json!({ "project_id": Uuid::new_v4().to_string(), "status": "Archived" }), | |
| 440 | 562 | Some(&HashSet::new()), | |
| 441 | 563 | ) | |
| 442 | 564 | .await | |
| @@ -454,16 +576,19 @@ | |||
| 454 | 576 | async fn project_enum_values_round_trip_through_the_write_surface() { | |
| 455 | 577 | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 456 | 578 | ||
| 457 | - | call( | |
| 579 | + | let id = call( | |
| 458 | 580 | ®, | |
| 459 | 581 | "create_project", | |
| 460 | 582 | json!({ "name": "roundtrip", "type": "SideProject" }), | |
| 461 | 583 | ) | |
| 462 | - | .await; | |
| 584 | + | .await["id"] | |
| 585 | + | .as_str() | |
| 586 | + | .unwrap() | |
| 587 | + | .to_string(); | |
| 463 | 588 | call( | |
| 464 | 589 | ®, | |
| 465 | 590 | "update_project", | |
| 466 | - | json!({ "project": "roundtrip", "status": "OnHold" }), | |
| 591 | + | json!({ "project_id": id, "status": "OnHold" }), | |
| 467 | 592 | ) | |
| 468 | 593 | .await; | |
| 469 | 594 | ||
| @@ -479,7 +604,7 @@ | |||
| 479 | 604 | ®, | |
| 480 | 605 | "update_project", | |
| 481 | 606 | json!({ | |
| 482 | - | "project": "roundtrip", | |
| 607 | + | "project_id": id, | |
| 483 | 608 | "type": listed["type"].clone(), | |
| 484 | 609 | "status": listed["status"].clone(), | |
| 485 | 610 | }), | |
| @@ -519,14 +644,19 @@ | |||
| 519 | 644 | async fn report_problems_is_idempotent_and_creates_no_tasks() { | |
| 520 | 645 | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 521 | 646 | ||
| 647 | + | let goingson = call(®, "create_project", json!({ "name": "goingson" })).await["id"] | |
| 648 | + | .as_str() | |
| 649 | + | .unwrap() | |
| 650 | + | .to_string(); | |
| 651 | + | ||
| 522 | 652 | let payload = json!({ | |
| 523 | 653 | "source": "audit", | |
| 524 | 654 | "problems": [ | |
| 525 | 655 | { "title": "sync cascade orphans subtasks", "body": "repository/task.rs", | |
| 526 | 656 | "pain": 4, "scale": 5, "source_ref": "task-repo:cascade", | |
| 527 | - | "project": "goingson", "tags": ["sync"] }, | |
| 657 | + | "project_id": goingson, "tags": ["sync"] }, | |
| 528 | 658 | { "title": "lint scripts unwired", "pain": 1, "scale": 1, | |
| 529 | - | "source_ref": "scripts:lint", "project": "goingson" } | |
| 659 | + | "source_ref": "scripts:lint", "project_id": goingson } | |
| 530 | 660 | ] | |
| 531 | 661 | }); | |
| 532 | 662 | ||
| @@ -557,6 +687,11 @@ | |||
| 557 | 687 | async fn promote_problem_creates_a_linked_task_once() { | |
| 558 | 688 | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 559 | 689 | ||
| 690 | + | let goingson = call(®, "create_project", json!({ "name": "goingson" })).await["id"] | |
| 691 | + | .as_str() | |
| 692 | + | .unwrap() | |
| 693 | + | .to_string(); | |
| 694 | + | ||
| 560 | 695 | call( | |
| 561 | 696 | ®, | |
| 562 | 697 | "report_problems", | |
| @@ -567,7 +702,7 @@ | |||
| 567 | 702 | "body": "blob_sync.rs streams nothing", | |
| 568 | 703 | "pain": 5, "scale": 4, | |
| 569 | 704 | "source_ref": "blob_sync:memory", | |
| 570 | - | "project": "goingson", | |
| 705 | + | "project_id": goingson, | |
| 571 | 706 | "tags": ["perf"] | |
| 572 | 707 | }] | |
| 573 | 708 | }), | |
| @@ -1006,6 +1141,11 @@ | |||
| 1006 | 1141 | async fn create_event_round_trips_a_rich_rule() { | |
| 1007 | 1142 | let reg = tools::registry(Arc::new(Ctx::new(seed_db().await))); | |
| 1008 | 1143 | ||
| 1144 | + | let mnw = call(®, "create_project", json!({ "name": "MNW" })).await["id"] | |
| 1145 | + | .as_str() | |
| 1146 | + | .unwrap() | |
| 1147 | + | .to_string(); | |
| 1148 | + | ||
| 1009 | 1149 | let created = call( | |
| 1010 | 1150 | ®, | |
| 1011 | 1151 | "create_event", | |
| @@ -1013,7 +1153,7 @@ | |||
| 1013 | 1153 | "title": "standup", | |
| 1014 | 1154 | "start": "2026-08-03T09:00:00Z", | |
| 1015 | 1155 | "end": "2026-08-03T09:15:00Z", | |
| 1016 | - | "project": "MNW", | |
| 1156 | + | "project_id": mnw, | |
| 1017 | 1157 | "location": "video", | |
| 1018 | 1158 | "recurrence": { "pattern": "Weekly", "interval": 2, "weekdays": [0, 2] }, | |
| 1019 | 1159 | "reminders": [900, 0, 900] |
| @@ -29,7 +29,7 @@ | |||
| 29 | 29 | //! Without that, a session would ask "do I already have a standup?", be told no, | |
| 30 | 30 | //! and enter a second one. | |
| 31 | 31 | ||
| 32 | - | use std::collections::{HashMap, HashSet}; | |
| 32 | + | use std::collections::HashSet; | |
| 33 | 33 | use std::sync::Arc; | |
| 34 | 34 | ||
| 35 | 35 | use async_trait::async_trait; | |
| @@ -43,13 +43,13 @@ | |||
| 43 | 43 | use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; | |
| 44 | 44 | use serde_json::{Value, json}; | |
| 45 | 45 | ||
| 46 | - | use super::{ensure_project, resolve_project_cached}; | |
| 46 | + | use super::{project_id_arg, project_id_field}; | |
| 47 | 47 | use crate::caps; | |
| 48 | 48 | use crate::context::Ctx; | |
| 49 | 49 | use crate::convert::{ | |
| 50 | 50 | MAX_LIMIT, TZ_KINDS, event_row, event_summary_row, parse_block_type, parse_enum, | |
| 51 | - | parse_event_id, parse_instant, parse_limit, parse_offset, parse_recurrence, parse_reminders, | |
| 52 | - | req_str, | |
| 51 | + | parse_event_id, parse_instant, parse_limit, parse_offset, parse_project_id, parse_recurrence, | |
| 52 | + | parse_reminders, req_str, | |
| 53 | 53 | }; | |
| 54 | 54 | ||
| 55 | 55 | /// Default `external_source` a bulk import stamps on the rows it creates, so a | |
| @@ -92,7 +92,7 @@ | |||
| 92 | 92 | "list_events" | |
| 93 | 93 | } | |
| 94 | 94 | fn description(&self) -> &'static str { | |
| 95 | - | "List calendar events in a time window. `from`/`to` are RFC 3339 timestamps or bare YYYY-MM-DD dates (local midnight); they default to now and 30 days out. By default each recurring event appears once, as the stored series, with its rule under `recurrence`. Pass `expand: true` to get every occurrence in the window instead, each marked `is_recurring_instance` with a synthetic id that only `list_events` understands, never `get_event` or the write tools. Optional filters: `project` (name), `recurring_only`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_event` for the full text." | |
| 95 | + | "List calendar events in a time window. `from`/`to` are RFC 3339 timestamps or bare YYYY-MM-DD dates (local midnight); they default to now and 30 days out. By default each recurring event appears once, as the stored series, with its rule under `recurrence`. Pass `expand: true` to get every occurrence in the window instead, each marked `is_recurring_instance` with a synthetic id that only `list_events` understands, never `get_event` or the write tools. Optional filters: `project_id`, `project` (name, a convenience for reading only), `recurring_only`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_event` for the full text." | |
| 96 | 96 | } | |
| 97 | 97 | fn kind(&self) -> ToolKind { | |
| 98 | 98 | ToolKind::Read | |
| @@ -106,7 +106,8 @@ | |||
| 106 | 106 | "properties": { | |
| 107 | 107 | "from": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight). Defaults to now." }, | |
| 108 | 108 | "to": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight). Defaults to 30 days after `from`." }, | |
| 109 | - | "project": { "type": "string" }, | |
| 109 | + | "project_id": { "type": "string", "description": "Project id (UUID) from list_projects." }, | |
| 110 | + | "project": { "type": "string", "description": "Project name. Reads only, and a rename moves it; prefer project_id." }, | |
| 110 | 111 | "recurring_only": { "type": "boolean" }, | |
| 111 | 112 | "expand": { "type": "boolean", "description": "Materialize recurring occurrences in the window instead of listing series." }, | |
| 112 | 113 | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT }, | |
| @@ -178,8 +179,13 @@ | |||
| 178 | 179 | events.sort_by_key(|e| e.start_time); | |
| 179 | 180 | ||
| 180 | 181 | let project = args.get("project").and_then(Value::as_str); | |
| 182 | + | let project_id = match args.get("project_id").and_then(Value::as_str) { | |
| 183 | + | Some(raw) if !raw.trim().is_empty() => Some(parse_project_id(self.name(), raw)?), | |
| 184 | + | _ => None, | |
| 185 | + | }; | |
| 181 | 186 | let matched: Vec<&Event> = events | |
| 182 | 187 | .iter() | |
| 188 | + | .filter(|e| project_id.is_none_or(|want| e.project_id == Some(want))) | |
| 183 | 189 | .filter(|e| project.is_none_or(|p| e.project_name.as_deref() == Some(p))) | |
| 184 | 190 | .filter(|e| !recurring_only || e.has_recurrence()) | |
| 185 | 191 | .collect(); | |
| @@ -353,7 +359,7 @@ | |||
| 353 | 359 | ctx: &Ctx, | |
| 354 | 360 | tool: &str, | |
| 355 | 361 | item: &Value, | |
| 356 | - | project_cache: &mut HashMap<String, ProjectId>, | |
| 362 | + | seen_projects: &mut HashSet<ProjectId>, | |
| 357 | 363 | ) -> Result<NewEvent> { | |
| 358 | 364 | let title = req_str(tool, item, "title")?.to_string(); | |
| 359 | 365 | let (start_time, end_time) = resolve_span(tool, item)?; | |
| @@ -361,14 +367,7 @@ | |||
| 361 | 367 | let block_type = parse_block_type(tool, item.get("block_type"))?; | |
| 362 | 368 | let (tz_kind, timezone) = parse_tz_kind(tool, item)?; | |
| 363 | 369 | ||
| 364 | - | let project_id = match item.get("project").and_then(Value::as_str) { | |
| 365 | - | Some(name) if !name.trim().is_empty() => Some( | |
| 366 | - | resolve_project_cached(ctx, name, project_cache) | |
| 367 | - | .await | |
| 368 | - | .map_err(|e| fail(tool, e))?, | |
| 369 | - | ), | |
| 370 | - | _ => None, | |
| 371 | - | }; | |
| 370 | + | let project_id = project_id_arg(ctx, tool, item, seen_projects).await?; | |
| 372 | 371 | ||
| 373 | 372 | let mut event = NewEvent { | |
| 374 | 373 | user_id: Some(ctx.user_id), | |
| @@ -424,7 +423,7 @@ | |||
| 424 | 423 | "end": { "type": "string" }, | |
| 425 | 424 | "all_day": { "type": "boolean", "description": "Snap the span to whole local days." }, | |
| 426 | 425 | "description": { "type": "string" }, | |
| 427 | - | "project": { "type": "string", "description": "Project name; created if absent." }, | |
| 426 | + | "project_id": project_id_field(), | |
| 428 | 427 | "location": { "type": "string" }, | |
| 429 | 428 | "recurrence": { | |
| 430 | 429 | "description": "Either a word (None|Daily|Weekly|Monthly) or an object {pattern, interval?, weekdays?, monthly_spec?}. weekdays are 0=Mon..6=Sun. monthly_spec is {type: dayOfMonth, day} or {type: nthWeekday, week, weekday} (week -1 = last)." | |
| @@ -451,7 +450,7 @@ | |||
| 451 | 450 | "create_event" | |
| 452 | 451 | } | |
| 453 | 452 | fn description(&self) -> &'static str { | |
| 454 | - | "Create one calendar event. `title` and `start` are required; `start`/`end` take RFC 3339 or YYYY-MM-DD (local midnight). Pass `all_day: true` to snap the span to whole local days. `project` (name) is resolved to a project, creating it if absent. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; a recurring event is stored once as a series, not as one row per occurrence. Use `bulk_import_events` for more than a few." | |
| 453 | + | "Create one calendar event. `title` and `start` are required; `start`/`end` take RFC 3339 or YYYY-MM-DD (local midnight). Pass `all_day: true` to snap the span to whole local days. `project_id` (from list_projects) files it under a project; omit it for an unfiled event. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; a recurring event is stored once as a series, not as one row per occurrence. Use `bulk_import_events` for more than a few." | |
| 455 | 454 | } | |
| 456 | 455 | fn kind(&self) -> ToolKind { | |
| 457 | 456 | ToolKind::Write(caps::event_create()) | |
| @@ -464,8 +463,8 @@ | |||
| 464 | 463 | }) | |
| 465 | 464 | } | |
| 466 | 465 | async fn call(&self, args: Value) -> Result<ToolCallResult> { | |
| 467 | - | let mut cache = HashMap::new(); | |
| 468 | - | let event = new_event_from(&self.0, self.name(), &args, &mut cache).await?; | |
| 466 | + | let mut seen = HashSet::new(); | |
| 467 | + | let event = new_event_from(&self.0, self.name(), &args, &mut seen).await?; | |
| 469 | 468 | let created = self | |
| 470 | 469 | .0 | |
| 471 | 470 | .events() | |
| @@ -530,7 +529,7 @@ | |||
| 530 | 529 | .unwrap_or(DEFAULT_IMPORT_SOURCE); | |
| 531 | 530 | ||
| 532 | 531 | let repo = self.0.events(); | |
| 533 | - | let mut cache: HashMap<String, ProjectId> = HashMap::new(); | |
| 532 | + | let mut seen_projects: HashSet<ProjectId> = HashSet::new(); | |
| 534 | 533 | let mut seen: HashSet<String> = HashSet::new(); | |
| 535 | 534 | let mut created_ids = Vec::new(); | |
| 536 | 535 | let mut skipped = 0usize; | |
| @@ -561,7 +560,7 @@ | |||
| 561 | 560 | } | |
| 562 | 561 | } | |
| 563 | 562 | ||
| 564 | - | let event = match new_event_from(&self.0, self.name(), item, &mut cache).await { | |
| 563 | + | let event = match new_event_from(&self.0, self.name(), item, &mut seen_projects).await { | |
| 565 | 564 | Ok(event) => event, | |
| 566 | 565 | // Report the offending item by index; a 200-item payload with | |
| 567 | 566 | // one bad date is otherwise a guessing game. | |
| @@ -724,14 +723,10 @@ | |||
| 724 | 723 | if args.get("reminders").is_some() { | |
| 725 | 724 | patch.reminder_offsets_seconds = parse_reminders(args.get("reminders")); | |
| 726 | 725 | } | |
| 727 | - | if let Some(name) = args.get("project").and_then(Value::as_str) | |
| 728 | - | && !name.trim().is_empty() | |
| 729 | - | { | |
| 730 | - | patch.project_id = Some( | |
| 731 | - | ensure_project(&self.0, name) | |
| 732 | - | .await | |
| 733 | - | .map_err(|e| fail(self.name(), e))?, | |
| 734 | - | ); | |
| 726 | + | // Present-but-empty (or null) clears the project, matching update_task. | |
| 727 | + | if args.get("project_id").is_some() { | |
| 728 | + | let mut seen = HashSet::new(); | |
| 729 | + | patch.project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?; | |
| 735 | 730 | } | |
| 736 | 731 | ||
| 737 | 732 | let (start_time, end_time) = overlay_span(self.name(), &args, ¤t)?; |
| @@ -2,14 +2,16 @@ | |||
| 2 | 2 | //! wires them into a single `ToolRegistry`, and holds the few helpers more than | |
| 3 | 3 | //! one tool module needs. | |
| 4 | 4 | ||
| 5 | - | use std::collections::HashMap; | |
| 5 | + | use std::collections::HashSet; | |
| 6 | 6 | use std::sync::Arc; | |
| 7 | 7 | ||
| 8 | + | use goingson_core::ProjectId; | |
| 8 | 9 | use goingson_core::repository::ProjectRepository; | |
| 9 | - | use goingson_core::{NewProject, ProjectId, ProjectStatus, ProjectType}; | |
| 10 | - | use kberg::ToolRegistry; | |
| 10 | + | use kberg::{Error, ToolRegistry}; | |
| 11 | + | use serde_json::{Value, json}; | |
| 11 | 12 | ||
| 12 | 13 | use crate::context::Ctx; | |
| 14 | + | use crate::convert::parse_project_id; | |
| 13 | 15 | ||
| 14 | 16 | mod event; | |
| 15 | 17 | mod problem; | |
| @@ -57,44 +59,91 @@ | |||
| 57 | 59 | r | |
| 58 | 60 | } | |
| 59 | 61 | ||
| 60 | - | // Shared helpers. Tasks and events both take a project by name, and both must | |
| 61 | - | // resolve it the same way or the two surfaces would disagree about what | |
| 62 | - | // `project: "MNW"` means. | |
| 62 | + | // Shared helpers. Tasks, events and problems all attach to a project by id, and | |
| 63 | + | // all must resolve it the same way or the surfaces would disagree about what a | |
| 64 | + | // `project_id` means. | |
| 65 | + | // | |
| 66 | + | // A project is addressed by id, never by name: the name is the user's label and | |
| 67 | + | // is theirs to change, so resolving by it would strand every caller the moment | |
| 68 | + | // someone renamed a project in the app. Nothing here creates a project as a side | |
| 69 | + | // effect either. Name resolution used to do that, which turned a typo into a new | |
| 70 | + | // project rather than an error; `create_project` is now the only way one appears. | |
| 63 | 71 | ||
| 64 | - | /// Resolve a project by name, creating a default `SideProject` if absent. | |
| 65 | - | pub(super) async fn ensure_project( | |
| 72 | + | /// Check that a `project_id` names a project this user owns, returning it. | |
| 73 | + | /// | |
| 74 | + | /// The existence check is the whole point: an id that resolves to nothing would | |
| 75 | + | /// otherwise be stored verbatim and the row would read as projectless forever. | |
| 76 | + | pub(super) async fn verify_project( | |
| 66 | 77 | ctx: &Ctx, | |
| 67 | - | name: &str, | |
| 68 | - | ) -> goingson_core::repository::Result<ProjectId> { | |
| 69 | - | let repo = ctx.projects(); | |
| 70 | - | if let Some(existing) = repo.find_by_name(ctx.user_id, name).await? { | |
| 71 | - | return Ok(existing.id); | |
| 78 | + | tool: &str, | |
| 79 | + | id: ProjectId, | |
| 80 | + | ) -> Result<ProjectId, Error> { | |
| 81 | + | let found = ctx | |
| 82 | + | .projects() | |
| 83 | + | .get_by_id(id, ctx.user_id) | |
| 84 | + | .await | |
| 85 | + | .map_err(|e| Error::ToolFailed { | |
| 86 | + | tool: tool.to_string(), | |
| 87 | + | message: e.to_string(), | |
| 88 | + | })?; | |
| 89 | + | match found { | |
| 90 | + | Some(_) => Ok(id), | |
| 91 | + | None => Err(Error::InvalidArgs { | |
| 92 | + | tool: tool.to_string(), | |
| 93 | + | message: format!("no project with id `{id}` (call list_projects for the ids)"), | |
| 94 | + | }), | |
| 72 | 95 | } | |
| 73 | - | let created = repo | |
| 74 | - | .create( | |
| 75 | - | ctx.user_id, | |
| 76 | - | NewProject { | |
| 77 | - | name: name.to_string(), | |
| 78 | - | description: String::new(), | |
| 79 | - | project_type: ProjectType::SideProject, | |
| 80 | - | status: ProjectStatus::default(), | |
| 81 | - | }, | |
| 82 | - | ) | |
| 83 | - | .await?; | |
| 84 | - | Ok(created.id) | |
| 85 | 96 | } | |
| 86 | 97 | ||
| 87 | - | /// `ensure_project` with a within-call cache, so a bulk import that references | |
| 88 | - | /// the same project 200 times hits the database once. | |
| 89 | - | pub(super) async fn resolve_project_cached( | |
| 98 | + | /// [`verify_project`] with a within-call cache, so a bulk import referencing the | |
| 99 | + | /// same project 200 times checks it once. | |
| 100 | + | pub(super) async fn verify_project_cached( | |
| 90 | 101 | ctx: &Ctx, | |
| 91 | - | name: &str, | |
| 92 | - | cache: &mut HashMap<String, ProjectId>, | |
| 93 | - | ) -> goingson_core::repository::Result<ProjectId> { | |
| 94 | - | if let Some(id) = cache.get(name) { | |
| 95 | - | return Ok(*id); | |
| 102 | + | tool: &str, | |
| 103 | + | id: ProjectId, | |
| 104 | + | seen: &mut HashSet<ProjectId>, | |
| 105 | + | ) -> Result<ProjectId, Error> { | |
| 106 | + | if seen.contains(&id) { | |
| 107 | + | return Ok(id); | |
| 96 | 108 | } | |
| 97 | - | let id = ensure_project(ctx, name).await?; | |
| 98 | - | cache.insert(name.to_string(), id); | |
| 109 | + | verify_project(ctx, tool, id).await?; | |
| 110 | + | seen.insert(id); | |
| 99 | 111 | Ok(id) | |
| 100 | 112 | } | |
| 113 | + | ||
| 114 | + | /// Read an optional `project_id` field off a wire object. | |
| 115 | + | /// | |
| 116 | + | /// Absent, null or empty all mean "no project": absent because the caller said | |
| 117 | + | /// nothing, empty because that is how an update clears one. Anything else must | |
| 118 | + | /// parse as a UUID and name a project that exists. | |
| 119 | + | pub(super) async fn project_id_arg( | |
| 120 | + | ctx: &Ctx, | |
| 121 | + | tool: &str, | |
| 122 | + | item: &Value, | |
| 123 | + | seen: &mut HashSet<ProjectId>, | |
| 124 | + | ) -> Result<Option<ProjectId>, Error> { | |
| 125 | + | let Some(raw) = item.get("project_id") else { | |
| 126 | + | return Ok(None); | |
| 127 | + | }; | |
| 128 | + | if raw.is_null() { | |
| 129 | + | return Ok(None); | |
| 130 | + | } | |
| 131 | + | let raw = raw.as_str().ok_or_else(|| Error::InvalidArgs { | |
| 132 | + | tool: tool.to_string(), | |
| 133 | + | message: format!("`project_id` must be a string (got `{raw}`)"), | |
| 134 | + | })?; | |
| 135 | + | if raw.trim().is_empty() { | |
| 136 | + | return Ok(None); | |
| 137 | + | } | |
| 138 | + | let id = parse_project_id(tool, raw)?; | |
| 139 | + | verify_project_cached(ctx, tool, id, seen).await.map(Some) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// The `project_id` schema fragment, worded once so every tool says the same | |
| 143 | + | /// thing about where an id comes from. | |
| 144 | + | pub(super) fn project_id_field() -> Value { | |
| 145 | + | json!({ | |
| 146 | + | "type": "string", | |
| 147 | + | "description": "Project id (UUID) from list_projects. Names are labels and can be renamed, so they are not accepted here." | |
| 148 | + | }) | |
| 149 | + | } |
| @@ -16,13 +16,13 @@ | |||
| 16 | 16 | //! refreshes its findings rather than duplicating them, and re-reporting | |
| 17 | 17 | //! something already promoted or dismissed does not undo that decision. | |
| 18 | 18 | ||
| 19 | - | use std::collections::HashMap; | |
| 19 | + | use std::collections::HashSet; | |
| 20 | 20 | use std::sync::Arc; | |
| 21 | 21 | ||
| 22 | 22 | use async_trait::async_trait; | |
| 23 | 23 | use chrono::Utc; | |
| 24 | 24 | use goingson_core::models::DbValue; | |
| 25 | - | use goingson_core::repository::{ProblemRepository, ProjectRepository, TaskCrud}; | |
| 25 | + | use goingson_core::repository::{ProblemRepository, TaskCrud}; | |
| 26 | 26 | use goingson_core::{ | |
| 27 | 27 | NewProblem, NewTask, Priority, ProblemFilter, ProblemId, ProblemStatus, ProjectId, | |
| 28 | 28 | }; | |
| @@ -30,9 +30,10 @@ | |||
| 30 | 30 | use serde_json::{Value, json}; | |
| 31 | 31 | use uuid::Uuid; | |
| 32 | 32 | ||
| 33 | + | use super::project_id_arg; | |
| 33 | 34 | use crate::caps; | |
| 34 | 35 | use crate::context::Ctx; | |
| 35 | - | use crate::convert::{parse_enum, parse_tags, req_str}; | |
| 36 | + | use crate::convert::{parse_enum, parse_project_id, parse_tags, req_str}; | |
| 36 | 37 | ||
| 37 | 38 | /// The accepted wire words for a problem's triage state. | |
| 38 | 39 | const PROBLEM_STATUSES: &[&str] = &["Open", "Promoted", "Dismissed", "Resolved"]; | |
| @@ -146,14 +147,7 @@ | |||
| 146 | 147 | }; | |
| 147 | 148 | ||
| 148 | 149 | let project_id = match args.get("project_id").and_then(Value::as_str) { | |
| 149 | - | Some(s) => Some( | |
| 150 | - | Uuid::parse_str(s.trim()) | |
| 151 | - | .map(ProjectId::from_uuid) | |
| 152 | - | .map_err(|_| Error::InvalidArgs { | |
| 153 | - | tool: self.name().to_string(), | |
| 154 | - | message: format!("`{s}` is not a valid project id (expected a UUID)"), | |
| 155 | - | })?, | |
| 156 | - | ), | |
| 150 | + | Some(s) => Some(parse_project_id(self.name(), s)?), | |
| 157 | 151 | None => None, | |
| 158 | 152 | }; | |
| 159 | 153 | ||
| @@ -188,7 +182,7 @@ | |||
| 188 | 182 | "report_problems" | |
| 189 | 183 | } | |
| 190 | 184 | fn description(&self) -> &'static str { | |
| 191 | - | "Report findings as problems (the /audit and /fuzz primitive). Each item: {title, body?, pain?, scale?, source_ref?, project?, tags?, resolved?}. `pain` (how much it hurts a hit user) and `scale` (how broadly it hits) are 1-5, defaulting to 3, and together with age drive the painhours ranking. `source` names the lens (audit, fuzz, deepaudit) and `source_ref` identifies the finding within it; re-reporting the same pair updates that problem instead of duplicating it, and never undoes a promotion or dismissal. Reporting does NOT create tasks: use promote_problem for the ones worth working." | |
| 185 | + | "Report findings as problems (the /audit and /fuzz primitive). Each item: {title, body?, pain?, scale?, source_ref?, project_id?, tags?, resolved?}. `pain` (how much it hurts a hit user) and `scale` (how broadly it hits) are 1-5, defaulting to 3, and together with age drive the painhours ranking. `source` names the lens (audit, fuzz, deepaudit) and `source_ref` identifies the finding within it; re-reporting the same pair updates that problem instead of duplicating it, and never undoes a promotion or dismissal. Reporting does NOT create tasks: use promote_problem for the ones worth working." | |
| 192 | 186 | } | |
| 193 | 187 | fn kind(&self) -> ToolKind { | |
| 194 | 188 | ToolKind::Write(caps::problem_report()) | |
| @@ -208,7 +202,7 @@ | |||
| 208 | 202 | "pain": { "type": "integer", "description": "1-5, how much it hurts a hit user. Default 3." }, | |
| 209 | 203 | "scale": { "type": "integer", "description": "1-5, how broadly it hits. Default 3." }, | |
| 210 | 204 | "source_ref": { "type": "string", "description": "Stable id for this finding within the source (e.g. module:check). Defaults to the title, so a re-run with the same title updates in place." }, | |
| 211 | - | "project": { "type": "string", "description": "Project name; created if absent." }, | |
| 205 | + | "project_id": super::project_id_field(), | |
| 212 | 206 | "tags": { "type": "array", "items": { "type": "string" } }, | |
| 213 | 207 | "resolved": { "type": "boolean", "description": "The source considers this fixed. Settles an untriaged problem; leaves a promoted or dismissed one alone." } | |
| 214 | 208 | }, | |
| @@ -237,7 +231,7 @@ | |||
| 237 | 231 | .to_string(); | |
| 238 | 232 | ||
| 239 | 233 | let repo = self.0.problems(); | |
| 240 | - | let mut project_cache: HashMap<String, ProjectId> = HashMap::new(); | |
| 234 | + | let mut seen_projects: HashSet<ProjectId> = HashSet::new(); | |
| 241 | 235 | let mut reported = Vec::new(); | |
| 242 | 236 | ||
| 243 | 237 | for (idx, item) in items.iter().enumerate() { | |
| @@ -263,14 +257,15 @@ | |||
| 263 | 257 | .unwrap_or(&title) | |
| 264 | 258 | .to_string(); | |
| 265 | 259 | ||
| 266 | - | let project_id = match item.get("project").and_then(Value::as_str) { | |
| 267 | - | Some(name) if !name.trim().is_empty() => Some( | |
| 268 | - | resolve_project_cached(&self.0, name, &mut project_cache) | |
| 269 | - | .await | |
| 270 | - | .map_err(|e| fail(self.name(), e))?, | |
| 271 | - | ), | |
| 272 | - | _ => None, | |
| 273 | - | }; | |
| 260 | + | let project_id = project_id_arg(&self.0, self.name(), item, &mut seen_projects) | |
| 261 | + | .await | |
| 262 | + | .map_err(|e| match e { | |
| 263 | + | Error::InvalidArgs { message, .. } => Error::InvalidArgs { | |
| 264 | + | tool: self.name().to_string(), | |
| 265 | + | message: format!("problems[{idx}]: {message}"), | |
| 266 | + | }, | |
| 267 | + | other => other, | |
| 268 | + | })?; | |
| 274 | 269 | ||
| 275 | 270 | let now = Utc::now(); | |
| 276 | 271 | let problem = repo | |
| @@ -443,7 +438,7 @@ | |||
| 443 | 438 | "properties": { | |
| 444 | 439 | "id": { "type": "string" }, | |
| 445 | 440 | "status": { "type": "string", "description": "Open | Promoted | Dismissed | Resolved" }, | |
| 446 | - | "project": { "type": "string", "description": "Project name to attribute this problem to; created if absent. Pass an empty string to clear." } | |
| 441 | + | "project_id": { "type": "string", "description": "Project id (UUID) from list_projects, attributing this problem to it. Pass an empty string to clear." } | |
| 447 | 442 | }, | |
| 448 | 443 | "required": ["id"] | |
| 449 | 444 | }) | |
| @@ -461,17 +456,9 @@ | |||
| 461 | 456 | message: format!("no problem with id `{id}`"), | |
| 462 | 457 | })?; | |
| 463 | 458 | ||
| 464 | - | if let Some(raw) = args.get("project").and_then(Value::as_str) { | |
| 465 | - | let project_id = if raw.trim().is_empty() { | |
| 466 | - | None | |
| 467 | - | } else { | |
| 468 | - | let mut cache = HashMap::new(); | |
| 469 | - | Some( | |
| 470 | - | resolve_project_cached(&self.0, raw, &mut cache) | |
| 471 | - | .await | |
| 472 | - | .map_err(|e| fail(self.name(), e))?, | |
| 473 | - | ) | |
| 474 | - | }; | |
| 459 | + | if args.get("project_id").is_some() { | |
| 460 | + | let mut seen = HashSet::new(); | |
| 461 | + | let project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?; | |
| 475 | 462 | current = repo | |
| 476 | 463 | .set_project(id, self.0.user_id, project_id) | |
| 477 | 464 | .await | |
| @@ -499,39 +486,3 @@ | |||
| 499 | 486 | )) | |
| 500 | 487 | } | |
| 501 | 488 | } | |
| 502 | - | ||
| 503 | - | /// Resolve a project name to an id, creating it if absent, with a within-call | |
| 504 | - | /// cache so a batch referencing one project repeatedly hits the database once. | |
| 505 | - | /// | |
| 506 | - | /// Mirrors `task.rs`'s helper of the same shape. Kept separate rather than | |
| 507 | - | /// shared because the task module's version is private to it and a cross-module | |
| 508 | - | /// `pub(crate)` here would make two unrelated tool families share a seam neither | |
| 509 | - | /// needs. | |
| 510 | - | async fn resolve_project_cached( | |
| 511 | - | ctx: &Ctx, | |
| 512 | - | name: &str, | |
| 513 | - | cache: &mut HashMap<String, ProjectId>, | |
| 514 | - | ) -> goingson_core::repository::Result<ProjectId> { | |
| 515 | - | if let Some(id) = cache.get(name) { | |
| 516 | - | return Ok(*id); | |
| 517 | - | } | |
| 518 | - | let repo = ctx.projects(); | |
| 519 | - | let id = match repo.find_by_name(ctx.user_id, name).await? { | |
| 520 | - | Some(existing) => existing.id, | |
| 521 | - | None => { | |
| 522 | - | repo.create( | |
| 523 | - | ctx.user_id, | |
| 524 | - | goingson_core::NewProject { | |
| 525 | - | name: name.to_string(), | |
| 526 | - | description: String::new(), | |
| 527 | - | project_type: goingson_core::ProjectType::SideProject, | |
| 528 | - | status: goingson_core::ProjectStatus::default(), | |
| 529 | - | }, | |
| 530 | - | ) | |
| 531 | - | .await? | |
| 532 | - | .id | |
| 533 | - | } | |
| 534 | - | }; | |
| 535 | - | cache.insert(name.to_string(), id); | |
| 536 | - | Ok(id) | |
| 537 | - | } |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | ||
| 22 | 22 | use crate::caps; | |
| 23 | 23 | use crate::context::Ctx; | |
| 24 | - | use crate::convert::{parse_enum, req_str}; | |
| 24 | + | use crate::convert::{parse_enum, parse_project_id, req_str}; | |
| 25 | 25 | ||
| 26 | 26 | /// The accepted wire words, mirroring each enum's `strum` serialize form (which | |
| 27 | 27 | /// is what `FromStr` parses). Listed back to a caller that gets one wrong. | |
| @@ -51,7 +51,7 @@ | |||
| 51 | 51 | "list_projects" | |
| 52 | 52 | } | |
| 53 | 53 | fn description(&self) -> &'static str { | |
| 54 | - | "List all projects (id, name, type, status). Use to resolve a project name to its id before creating tasks." | |
| 54 | + | "List all projects (id, name, type, status). Every write tool that takes a project takes its `project_id`, so this is how a name a human uses becomes the id the backend keys on." | |
| 55 | 55 | } | |
| 56 | 56 | fn kind(&self) -> ToolKind { | |
| 57 | 57 | ToolKind::Read | |
| @@ -172,7 +172,7 @@ | |||
| 172 | 172 | "update_project" | |
| 173 | 173 | } | |
| 174 | 174 | fn description(&self) -> &'static str { | |
| 175 | - | "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." | |
| 175 | + | "Update an existing project, identified by `project_id` (from list_projects). Only the fields you pass change. `name` renames it: the name is a label, so renaming is safe and nothing else has to move. `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." | |
| 176 | 176 | } | |
| 177 | 177 | fn kind(&self) -> ToolKind { | |
| 178 | 178 | ToolKind::Write(caps::project_update()) | |
| @@ -181,36 +181,45 @@ | |||
| 181 | 181 | json!({ | |
| 182 | 182 | "type": "object", | |
| 183 | 183 | "properties": { | |
| 184 | - | "project": { "type": "string", "description": "The project's current name." }, | |
| 184 | + | "project_id": super::project_id_field(), | |
| 185 | + | "name": { "type": "string", "description": "New name for the project. Renaming is safe: nothing resolves a project by name." }, | |
| 185 | 186 | "status": { "type": "string", "description": "Active | OnHold | Completed | Archived" }, | |
| 186 | 187 | "type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" }, | |
| 187 | 188 | "description": { "type": "string" } | |
| 188 | 189 | }, | |
| 189 | - | "required": ["project"] | |
| 190 | + | "required": ["project_id"] | |
| 190 | 191 | }) | |
| 191 | 192 | } | |
| 192 | 193 | async fn call(&self, args: Value) -> Result<ToolCallResult> { | |
| 193 | - | let name = req_str(self.name(), &args, "project")?; | |
| 194 | + | let id = parse_project_id(self.name(), req_str(self.name(), &args, "project_id")?)?; | |
| 194 | 195 | let repo = self.0.projects(); | |
| 195 | 196 | ||
| 196 | - | // Resolve by name, like create_task does, so callers never handle ids. | |
| 197 | 197 | let current = repo | |
| 198 | - | .find_by_name(self.0.user_id, name) | |
| 198 | + | .get_by_id(id, self.0.user_id) | |
| 199 | 199 | .await | |
| 200 | 200 | .map_err(|e| fail(self.name(), e))? | |
| 201 | - | .ok_or_else(|| Error::ToolFailed { | |
| 201 | + | .ok_or_else(|| Error::InvalidArgs { | |
| 202 | 202 | tool: self.name().to_string(), | |
| 203 | - | message: format!("no project named `{name}`"), | |
| 203 | + | message: format!("no project with id `{id}` (call list_projects for the ids)"), | |
| 204 | 204 | })?; | |
| 205 | 205 | ||
| 206 | 206 | // Start from the current project, overlay only the provided fields. | |
| 207 | - | // The name rides through untouched: it is the resolution key. | |
| 208 | 207 | let mut patch = UpdateProject { | |
| 209 | 208 | name: current.name.clone(), | |
| 210 | 209 | description: current.description.clone(), | |
| 211 | 210 | project_type: current.project_type.clone(), | |
| 212 | 211 | status: current.status.clone(), | |
| 213 | 212 | }; | |
| 213 | + | if let Some(name) = args.get("name").and_then(Value::as_str) { | |
| 214 | + | let name = name.trim(); | |
| 215 | + | if name.is_empty() { | |
| 216 | + | return Err(Error::InvalidArgs { | |
| 217 | + | tool: self.name().to_string(), | |
| 218 | + | message: "`name` cannot be empty".to_string(), | |
| 219 | + | }); | |
| 220 | + | } | |
| 221 | + | patch.name = name.to_string(); | |
| 222 | + | } | |
| 214 | 223 | if let Some(status) = args.get("status").and_then(Value::as_str) { | |
| 215 | 224 | patch.status = parse_enum(self.name(), "status", status, PROJECT_STATUSES)?; | |
| 216 | 225 | } | |
| @@ -227,7 +236,7 @@ | |||
| 227 | 236 | .map_err(|e| fail(self.name(), e))? | |
| 228 | 237 | .ok_or_else(|| Error::ToolFailed { | |
| 229 | 238 | tool: self.name().to_string(), | |
| 230 | - | message: format!("project `{name}` vanished during update"), | |
| 239 | + | message: format!("project `{id}` vanished during update"), | |
| 231 | 240 | })?; | |
| 232 | 241 | ||
| 233 | 242 | Ok(ToolCallResult::text( |
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | //! backlog and creates tasks in one capability-gated call, deduping on a | |
| 8 | 8 | //! `source:` provenance tag so a re-run does not double-insert. | |
| 9 | 9 | ||
| 10 | - | use std::collections::{HashMap, HashSet}; | |
| 10 | + | use std::collections::HashSet; | |
| 11 | 11 | use std::sync::Arc; | |
| 12 | 12 | ||
| 13 | 13 | use async_trait::async_trait; | |
| @@ -22,13 +22,13 @@ | |||
| 22 | 22 | use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; | |
| 23 | 23 | use serde_json::{Value, json}; | |
| 24 | 24 | ||
| 25 | - | use super::{ensure_project, resolve_project_cached}; | |
| 25 | + | use super::{project_id_arg, project_id_field}; | |
| 26 | 26 | use crate::caps; | |
| 27 | 27 | use crate::context::Ctx; | |
| 28 | 28 | use crate::convert::{ | |
| 29 | - | MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_recurrence, | |
| 30 | - | parse_subtask_id, parse_tags, parse_task_id, req_str, source_tag, subtask_row, task_row, | |
| 31 | - | task_summary_row, | |
| 29 | + | MAX_LIMIT, parse_due, parse_limit, parse_offset, parse_priority, parse_project_id, | |
| 30 | + | parse_recurrence, parse_subtask_id, parse_tags, parse_task_id, req_str, source_tag, | |
| 31 | + | subtask_row, task_row, task_summary_row, | |
| 32 | 32 | }; | |
| 33 | 33 | ||
| 34 | 34 | /// The `recurrence` schema fragment, identical across the task write tools and | |
| @@ -56,7 +56,7 @@ | |||
| 56 | 56 | "list_tasks" | |
| 57 | 57 | } | |
| 58 | 58 | fn description(&self) -> &'static str { | |
| 59 | - | "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`." | |
| 59 | + | "List tasks as compact rows. Optional filters: `project_id`, `project` (name, a convenience for reading only), `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`." | |
| 60 | 60 | } | |
| 61 | 61 | fn kind(&self) -> ToolKind { | |
| 62 | 62 | ToolKind::Read | |
| @@ -68,7 +68,8 @@ | |||
| 68 | 68 | json!({ | |
| 69 | 69 | "type": "object", | |
| 70 | 70 | "properties": { | |
| 71 | - | "project": { "type": "string" }, | |
| 71 | + | "project_id": { "type": "string", "description": "Project id (UUID) from list_projects." }, | |
| 72 | + | "project": { "type": "string", "description": "Project name. Reads only, and a rename moves it; prefer project_id." }, | |
| 72 | 73 | "status": { "type": "string" }, | |
| 73 | 74 | "tag": { "type": "string" }, | |
| 74 | 75 | "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT }, | |
| @@ -88,6 +89,10 @@ | |||
| 88 | 89 | .map_err(|e| fail(self.name(), e))?; | |
| 89 | 90 | ||
| 90 | 91 | let project = args.get("project").and_then(Value::as_str); | |
| 92 | + | let project_id = match args.get("project_id").and_then(Value::as_str) { | |
| 93 | + | Some(raw) if !raw.trim().is_empty() => Some(parse_project_id(self.name(), raw)?), | |
| 94 | + | _ => None, | |
| 95 | + | }; | |
| 91 | 96 | let status = args | |
| 92 | 97 | .get("status") | |
| 93 | 98 | .and_then(Value::as_str) | |
| @@ -96,6 +101,7 @@ | |||
| 96 | 101 | ||
| 97 | 102 | let matched: Vec<&Task> = tasks | |
| 98 | 103 | .iter() | |
| 104 | + | .filter(|t| project_id.is_none_or(|want| t.project_id == Some(want))) | |
| 99 | 105 | .filter(|t| project.is_none_or(|p| t.project_name.as_deref() == Some(p))) | |
| 100 | 106 | .filter(|t| status.as_ref().is_none_or(|s| &t.status == s)) | |
| 101 | 107 | .filter(|t| tag.is_none_or(|want| t.tags.iter().any(|have| have == want))) | |
| @@ -218,7 +224,7 @@ | |||
| 218 | 224 | "create_task" | |
| 219 | 225 | } | |
| 220 | 226 | fn description(&self) -> &'static str { | |
| 221 | - | "Create one task. A task has a short `title` and an optional longer `description`; the two are never concatenated. Passing only `description` splits it (first line becomes the title, the rest the body), so a single blob of text still lands correctly. `project` (name) is resolved to a project, creating it if absent. `due` accepts RFC 3339 or YYYY-MM-DD; `priority` is High|Medium|Low. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; without a `due` the chain anchors to whenever the task is first completed rather than to a fixed schedule." | |
| 227 | + | "Create one task. A task has a short `title` and an optional longer `description`; the two are never concatenated. Passing only `description` splits it (first line becomes the title, the rest the body), so a single blob of text still lands correctly. `project_id` (from list_projects) files it under a project; omit it for an unfiled task. `due` accepts RFC 3339 or YYYY-MM-DD; `priority` is High|Medium|Low. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; without a `due` the chain anchors to whenever the task is first completed rather than to a fixed schedule." | |
| 222 | 228 | } | |
| 223 | 229 | fn kind(&self) -> ToolKind { | |
| 224 | 230 | ToolKind::Write(caps::task_create()) | |
| @@ -229,7 +235,7 @@ | |||
| 229 | 235 | "properties": { | |
| 230 | 236 | "title": { "type": "string" }, | |
| 231 | 237 | "description": { "type": "string" }, | |
| 232 | - | "project": { "type": "string" }, | |
| 238 | + | "project_id": project_id_field(), | |
| 233 | 239 | "tags": { "type": "array", "items": { "type": "string" } }, | |
| 234 | 240 | "due": { "type": "string" }, | |
| 235 | 241 | "priority": { "type": "string" }, | |
| @@ -245,14 +251,8 @@ | |||
| 245 | 251 | let tags = parse_tags(args.get("tags")); | |
| 246 | 252 | let (recurrence, recurrence_rule) = parse_recurrence(self.name(), args.get("recurrence"))?; | |
| 247 | 253 | ||
| 248 | - | let project_id = match args.get("project").and_then(Value::as_str) { | |
| 249 | - | Some(name) if !name.trim().is_empty() => Some( | |
| 250 | - | ensure_project(&self.0, name) | |
| 251 | - | .await | |
| 252 | - | .map_err(|e| fail(self.name(), e))?, | |
| 253 | - | ), | |
| 254 | - | _ => None, | |
| 255 | - | }; | |
| 254 | + | let mut seen = HashSet::new(); | |
| 255 | + | let project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?; | |
| 256 | 256 | ||
| 257 | 257 | let mut builder = NewTask::builder(String::new()) | |
| 258 | 258 | .title(title) | |
| @@ -291,7 +291,7 @@ | |||
| 291 | 291 | "bulk_import_tasks" | |
| 292 | 292 | } | |
| 293 | 293 | fn description(&self) -> &'static str { | |
| 294 | - | "Create many tasks in one call (the /dellm migration primitive). Each item: {description, project?, tags?, due?, priority?, recurrence?, 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." | |
| 294 | + | "Create many tasks in one call (the /dellm migration primitive). Each item: {description, project_id?, tags?, due?, priority?, recurrence?, source?}. `project_id` comes from list_projects. `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." | |
| 295 | 295 | } | |
| 296 | 296 | fn kind(&self) -> ToolKind { | |
| 297 | 297 | ToolKind::Write(caps::task_bulk_import()) | |
| @@ -306,7 +306,7 @@ | |||
| 306 | 306 | "type": "object", | |
| 307 | 307 | "properties": { | |
| 308 | 308 | "description": { "type": "string" }, | |
| 309 | - | "project": { "type": "string" }, | |
| 309 | + | "project_id": project_id_field(), | |
| 310 | 310 | "tags": { "type": "array", "items": { "type": "string" } }, | |
| 311 | 311 | "due": { "type": "string" }, | |
| 312 | 312 | "priority": { "type": "string" }, | |
| @@ -343,7 +343,7 @@ | |||
| 343 | 343 | .cloned() | |
| 344 | 344 | .collect(); | |
| 345 | 345 | ||
| 346 | - | let mut project_cache: HashMap<String, ProjectId> = HashMap::new(); | |
| 346 | + | let mut seen_projects: HashSet<ProjectId> = HashSet::new(); | |
| 347 | 347 | let mut created_ids = Vec::new(); | |
| 348 | 348 | let mut skipped = 0usize; | |
| 349 | 349 | ||
| @@ -374,14 +374,17 @@ | |||
| 374 | 374 | let (recurrence, recurrence_rule) = | |
| 375 | 375 | parse_recurrence(self.name(), item.get("recurrence"))?; | |
| 376 | 376 | ||
| 377 | - | let project_id = match item.get("project").and_then(Value::as_str) { | |
| 378 | - | Some(name) if !name.trim().is_empty() => Some( | |
| 379 | - | resolve_project_cached(&self.0, name, &mut project_cache) | |
| 380 | - | .await | |
| 381 | - | .map_err(|e| fail(self.name(), e))?, | |
| 382 | - | ), | |
| 383 | - | _ => None, | |
| 384 | - | }; | |
| 377 | + | let project_id = project_id_arg(&self.0, self.name(), item, &mut seen_projects) | |
| 378 | + | .await | |
| 379 | + | .map_err(|e| match e { | |
| 380 | + | // Report the offending item by index; a 200-item payload with | |
| 381 | + | // one stale project id is otherwise a guessing game. | |
| 382 | + | Error::InvalidArgs { message, .. } => Error::InvalidArgs { | |
| 383 | + | tool: self.name().to_string(), | |
| 384 | + | message: format!("tasks[{idx}]: {message}"), | |
| 385 | + | }, | |
| 386 | + | other => other, | |
| 387 | + | })?; | |
| 385 | 388 | ||
| 386 | 389 | let mut builder = NewTask::builder(String::new()) | |
| 387 | 390 | .title(title) | |
| @@ -425,7 +428,7 @@ | |||
| 425 | 428 | "update_task" | |
| 426 | 429 | } | |
| 427 | 430 | fn description(&self) -> &'static str { | |
| 428 | - | "Update fields of an existing task. Only the fields you pass change; the rest keep their current values. Accepts `title`, `description`, `priority`, `due`, `status` (Pending|Started|Completed), `tags`, `project`, `recurrence`, and `commit`. Passing `recurrence` rewrites the rule from this instance forward; instances already completed keep the rule they closed under. `title` is the short label and `description` the longer body; setting one leaves the other alone. `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." | |
| 431 | + | "Update fields of an existing task. Only the fields you pass change; the rest keep their current values. Accepts `title`, `description`, `priority`, `due`, `status` (Pending|Started|Completed), `tags`, `project_id` (empty string unfiles the task), `recurrence`, and `commit`. Passing `recurrence` rewrites the rule from this instance forward; instances already completed keep the rule they closed under. `title` is the short label and `description` the longer body; setting one leaves the other alone. `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." | |
| 429 | 432 | } | |
| 430 | 433 | fn kind(&self) -> ToolKind { | |
| 431 | 434 | ToolKind::Write(caps::task_update()) | |
| @@ -441,7 +444,7 @@ | |||
| 441 | 444 | "due": { "type": "string" }, | |
| 442 | 445 | "status": { "type": "string" }, | |
| 443 | 446 | "tags": { "type": "array", "items": { "type": "string" } }, | |
| 444 | - | "project": { "type": "string" }, | |
| 447 | + | "project_id": project_id_field(), | |
| 445 | 448 | "recurrence": recurrence_field(), | |
| 446 | 449 | "commit": { "type": "string" } | |
| 447 | 450 | }, | |
| @@ -488,16 +491,11 @@ | |||
| 488 | 491 | patch.recurrence = recurrence; | |
| 489 | 492 | patch.recurrence_rule = rule; | |
| 490 | 493 | } | |
| 491 | - | if let Some(name) = args.get("project").and_then(Value::as_str) { | |
| 492 | - | patch.project_id = if name.trim().is_empty() { | |
| 493 | - | None | |
| 494 | - | } else { | |
| 495 | - | Some( | |
| 496 | - | ensure_project(&self.0, name) | |
| 497 | - | .await | |
| 498 | - | .map_err(|e| fail(self.name(), e))?, | |
| 499 | - | ) | |
| 500 | - | }; | |
| 494 | + | // Present-but-empty (or null) clears the project, which is why this keys | |
| 495 | + | // off the field being there rather than off it parsing to an id. | |
| 496 | + | if args.get("project_id").is_some() { | |
| 497 | + | let mut seen = HashSet::new(); | |
| 498 | + | patch.project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?; | |
| 501 | 499 | } | |
| 502 | 500 | ||
| 503 | 501 | let updated = repo |