1use std::sync::Arc;
21use std::time::SystemTime;
22
23use forge_sdk::mcp::server::McpServerBuilder;
24use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
25
26use forge_primitives::tasks::{LinkKind, Task, TaskId, TaskStatus, WaitingKind};
27
28use crate::SessionSlot;
29use crate::mcp::tasks::facade::{TaskDraft, TaskPatch, TasksError, TasksFacade};
30
31pub(crate) mod facade;
32
33pub(crate) fn add_tools(
37 builder: McpServerBuilder,
38 facade: Arc<dyn TasksFacade>,
39 slot: SessionSlot,
40 lead: bool,
41) -> McpServerBuilder {
42 let create = Create { facade: facade.clone(), slot: slot.clone() };
43 let update = Update { facade: facade.clone(), slot: slot.clone() };
44 let list = List { facade: facade.clone(), slot: slot.clone() };
45 let delete = Delete { facade: facade.clone(), slot: slot.clone() };
46 let claim = Claim { facade: facade.clone(), slot: slot.clone() };
47 let wait = Wait { facade: facade.clone(), slot: slot.clone() };
48 let builder = builder.tool(create).tool(update).tool(list).tool(delete).tool(claim).tool(wait);
49 if lead { builder.tool(Approve { facade, slot }) } else { builder }
52}
53
54fn tool_error(text: String) -> ToolOutput {
55 ToolOutput::error(text)
56}
57
58fn fmt_rfc3339(t: SystemTime) -> String {
60 time::OffsetDateTime::from(t)
61 .format(&time::format_description::well_known::Rfc3339)
62 .unwrap_or_else(|_| "unknown".to_owned())
63}
64
65fn status_str(status: TaskStatus) -> &'static str {
67 match status {
68 TaskStatus::Pending => "pending",
69 TaskStatus::InProgress => "in_progress",
70 TaskStatus::Waiting => "waiting",
71 TaskStatus::Completed => "completed",
72 TaskStatus::Failed => "failed",
73 TaskStatus::Canceled => "canceled",
74 }
75}
76
77fn task_to_json(task: &Task) -> serde_json::Value {
80 let mut map = serde_json::Map::new();
81 map.insert("id".to_owned(), serde_json::json!(task.id.as_str()));
82 map.insert("project".to_owned(), serde_json::json!(task.project_name));
83 map.insert("subject".to_owned(), serde_json::json!(task.subject));
84 map.insert("status".to_owned(), serde_json::json!(status_str(task.status)));
85 let artifact = task
93 .links
94 .iter()
95 .find(|l| matches!(l.kind, LinkKind::Pr | LinkKind::Path))
96 .map(|l| l.label.clone().unwrap_or_else(|| l.target.clone()));
97 for (key, value) in [
98 ("active_form", task.active_form.as_deref()),
99 ("detail", task.detail.as_deref()),
100 ("artifact", artifact.as_deref()),
101 ("estimate", task.estimate.as_ref().map(|e| e.words.as_str())),
102 ("parent", task.parent.as_ref().map(TaskId::as_str)),
103 ("owner", task.owner.as_ref().map(SessionSlot::label)),
104 ] {
105 if let Some(value) = value {
106 map.insert(key.to_owned(), serde_json::json!(value));
107 }
108 }
109 for (key, value) in
110 [("verify", task.verify.map(verify_str)), ("reserved", task.reserved.map(|_| "user"))]
111 {
112 if let Some(value) = value {
113 map.insert(key.to_owned(), serde_json::json!(value));
114 }
115 }
116 if let Some(attested) = &task.attested {
117 map.insert(
118 "attested".to_owned(),
119 serde_json::json!({
120 "evidence": attested.evidence,
121 "at": fmt_rfc3339(attested.at),
122 }),
123 );
124 }
125 map.insert("created_at".to_owned(), serde_json::json!(fmt_rfc3339(task.created_at)));
126 map.insert("updated_at".to_owned(), serde_json::json!(fmt_rfc3339(task.updated_at)));
127 serde_json::Value::Object(map)
128}
129
130fn verify_str(verify: forge_primitives::tasks::Verify) -> &'static str {
132 match verify {
133 forge_primitives::tasks::Verify::User => "user",
134 forge_primitives::tasks::Verify::None => "none",
135 }
136}
137
138fn format_tasks_error(err: &TasksError) -> String {
139 match err {
140 TasksError::UnknownCallerProject => {
141 "couldn't resolve your project; is this session attached to a forge.toml project?"
142 .to_owned()
143 }
144 TasksError::BadEstimate(words) => {
145 format!("\"{words}\" is not a duration; use 30m, 2h, 1d or 1w")
146 }
147 TasksError::Refused(why) => {
148 format!("the move was refused: {why}")
149 }
150 TasksError::NoSuchTask { id } => {
151 format!("no task with id {id} in your project")
152 }
153 TasksError::TaskInAnotherProject { id, project } => {
154 format!("task {id} exists in project {project}, not in your project")
155 }
156 }
157}
158
159struct Create {
160 facade: Arc<dyn TasksFacade>,
161 slot: SessionSlot,
162}
163
164#[async_trait::async_trait]
165impl Tool for Create {
166 fn name(&self) -> &'static str {
167 "tasks__create"
168 }
169
170 fn description(&self) -> &'static str {
171 "Declare a piece of live work in YOUR project's task list. `subject` is the row's text \
172 and the only required field; `active_form` is the in-progress wording (\"Adding tests\" \
173 for \"Add tests\"), shown while the task is running. `owner` is the label of the session \
174 holding it - a worker's label, or \"lead\" - and omitting it leaves the task unclaimed. \
175 A lead creating a row for a worker assigns it at creation: the worker's label in \
176 `owner`, and the kick says not to claim it. A row left in the lead's own hands \
177 refuses the worker's `tasks__claim` while the lead is live, and a row left in \
178 progress under a seat that goes is chased as having no live owner once the board \
179 marks it stale. \
180 `parent` names another task in the same project this one belongs under, `estimate` a \
181 duration, `rank` the queue order, `verify` whether completion waits on the user, \
182 `links` the references it carries (issues, PRs, specs, paths), `detail` free prose. \
183 `status` defaults to pending. A row whose deliverable IS the user's own read or \
184 decision - a report the user asked to read, a verdict the user asked to give - is \
185 minted `verify: \"user\"` explicitly: it ends parked on the user, and the board's \
186 approve is what completes it. Only the lead mints this gate; a worker hands the ask \
187 to its lead instead. The gate is minted on the row that owes the look; an \
188 epic's `verify: \"user\"` governs the epic's own completion, and a child never \
189 inherits it - a child that owes no look completes on its own terms. `reserved: \
190 \"user\"` reserves the pick for the user: the board sweep leaves it, it never reads \
191 ready, and no seat's claim takes it. Returns the stored task \
192 with its id (use it with tasks__update / tasks__delete). Any session in the project \
193 may call this."
194 }
195
196 fn input_schema(&self) -> serde_json::Value {
197 serde_json::json!({
198 "type": "object",
199 "properties": {
200 "subject": {
201 "type": "string",
202 "description": "The task's row text.",
203 },
204 "active_form": {
205 "type": "string",
206 "description": "The in-progress wording, shown while the task is running.",
207 },
208 "detail": {
209 "type": "string",
210 "description": "Free prose: the why.",
211 },
212 "status": {
213 "type": "string",
214 "enum": ["pending", "in_progress", "waiting", "completed", "failed", "canceled"],
215 "description": "Where the task starts. Defaults to pending, the board's \
216 Backlog: work kept to look at later, not by itself an \
217 assignment. The sweep dispatches only unowned ready rows, \
218 so take ownership to set one aside.",
219 },
220 "owner": {
221 "type": "string",
222 "description": "The label of the session holding the task, in your project \
223 (\"lead\" for the lead). Omit for unclaimed.",
224 },
225 "parent": {
226 "type": "string",
227 "description": "The id of the task this one belongs under, in the same \
228 project; a full id or any unique prefix of one.",
229 },
230 "estimate": {
231 "type": "string",
232 "description": "A duration estimate (e.g. \"1d\").",
233 },
234 "rank": {
235 "type": "integer",
236 "description": "Queue order; lower reads first.",
237 },
238 "verify": {
239 "type": "string",
240 "enum": ["user", "none"],
241 "description": "Whether the row's own completion waits on the user's look. \
242 The gate is minted on the row; a child never inherits \
243 its epic's. A row whose deliverable IS the user's own \
244 read or decision states `user`; anything else owes no \
245 look, even under a `user` epic. The gate is the lead's \
246 to mint; a worker asks its lead instead.",
247 },
248 "reserved": {
249 "type": "string",
250 "enum": ["user"],
251 "description": "Set `user` to reserve this pick for the user: the board \
252 sweep leaves a reserved row for them instead of \
253 dispatching it.",
254 },
255 "links": {
256 "type": "array",
257 "description": "References this row carries: a spec, a plan, an issue, a PR, \
258 a branch, a path. The kind is derived from the target when \
259 omitted.",
260 "items": {
261 "type": "object",
262 "properties": {
263 "kind": {
264 "type": "string",
265 "enum": ["spec", "plan", "issue", "pr", "branch", "path", "other"],
266 },
267 "label": { "type": "string" },
268 "target": { "type": "string" },
269 },
270 "required": ["target"],
271 },
272 },
273 },
274 "required": ["subject"],
275 "additionalProperties": false,
276 })
277 }
278
279 async fn call(&self, input: ToolInput) -> ToolOutput {
280 let draft: TaskDraft = match serde_json::from_value(input.value) {
281 Ok(draft) => draft,
282 Err(err) => return tool_error(format!("invalid arguments: {err}")),
283 };
284 match self.facade.create_task(&self.slot, draft) {
285 Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
286 Ok(json) => ToolOutput::text(json),
287 Err(err) => tool_error(format!("response serialization failed: {err}")),
288 },
289 Err(err) => tool_error(format_tasks_error(&err)),
290 }
291 }
292}
293
294struct Update {
295 facade: Arc<dyn TasksFacade>,
296 slot: SessionSlot,
297}
298
299#[derive(serde::Deserialize)]
300struct UpdateArgs {
301 id: String,
302 #[serde(flatten)]
303 patch: TaskPatch,
304}
305
306#[async_trait::async_trait]
307impl Tool for Update {
308 fn name(&self) -> &'static str {
309 "tasks__update"
310 }
311
312 fn description(&self) -> &'static str {
313 "Change a task in YOUR project by id, stating only the fields to move - everything else \
314 is left alone. `status` is the usual one (pending / in_progress / waiting / completed / \
315 failed / canceled), and `owner` takes a session label (\"lead\" for the lead) or the \
316 label of a worker. Pending is the board's Backlog: work kept to look at later, not by \
317 itself an assignment and not a promise to start. A pending row that reads ready and \
318 unowned is dispatchable; take ownership to set it aside. \
319 The board is for running work: a landed row with no children and \
320 no epic above it is DELETED in the same pass (tasks__delete), never left completed; \
321 a landed row under an epic - open or already terminal - is completed and left \
322 terminal, because the epic reads `to close` only once every child is terminal (a \
323 verify=user child parks in waiting on the user and counts only once the user \
324 approves); and completing the epic's ROOT row closes it and archives the whole \
325 tree. Any session in the \
326 project may move any of its tasks, so a lead can \
327 complete a worker's task and a worker can put a row it holds into waiting - and a row \
328 waiting on the user's own look cannot be completed here: the board's approve completes \
329 it, and a cancel of a row waiting on the user is the withdraw door once the ask has \
330 gone moot: it needs `detail`, the reason the ask is withdrawn, which lands on the row, \
331 and a cancel stating none - or only what the row already says - is refused. Any \
332 other move off `waiting` drops the wait the row held - except a completion on a row \
333 whose completion waits on the user: while it is waiting on something else the gate \
334 would trade that wait for a look park, so the completion is refused by name; move it \
335 off waiting first, then complete it. When the row you end is named by another row's \
336 dependency wait, move every waiter off that wait in the same pass - a wait left on an \
337 ended row reads that its target has ended indefinitely. `verify: \"user\"` \
338 may be set here by the lead, but not taken away: a row carrying `user` keeps that \
339 gate until the user's approve, so clearing it is refused rather than taken as a way \
340 around the look. Only the lead mints this gate; a worker hands the ask to its lead \
341 instead. \
342 The gate is minted on the row, never inherited from its epic, so reparenting changes \
343 no gate. A task is \
344 named by its full id or any unique prefix of one; an exact id wins over any longer id \
345 that starts with it, and a prefix matching more than one row is refused with the \
346 matching rows named. An unknown id is an error, not a no-op. Returns the task as the \
347 write left it."
348 }
349
350 fn input_schema(&self) -> serde_json::Value {
351 serde_json::json!({
352 "type": "object",
353 "properties": {
354 "id": {
355 "type": "string",
356 "description": "The task id to change, or any unique prefix of one.",
357 },
358 "subject": { "type": "string", "description": "New row text." },
359 "active_form": {
360 "type": "string",
361 "description": "New in-progress wording.",
362 },
363 "detail": { "type": "string", "description": "New detail prose." },
364 "status": {
365 "type": "string",
366 "enum": ["pending", "in_progress", "waiting", "completed", "failed", "canceled"],
367 "description": "The task's new status. `completed` is not a resting state \
368 for a landed row with no children and no epic above it: \
369 it is deleted in the same pass. A landed row under an \
370 epic - open or already terminal - stays completed and \
371 terminal so the epic can read `to close`; a verify=user \
372 row parks in waiting on the user first.",
373 },
374 "owner": {
375 "type": "string",
376 "description": "The label of the session now holding the task.",
377 },
378 "parent": {
379 "type": "string",
380 "description": "The id of the task this one now belongs under, or any \
381 unique prefix of one.",
382 },
383 "rank": { "type": "integer", "description": "New queue order; lower reads first." },
384 "verify": {
385 "type": "string",
386 "enum": ["user", "none"],
387 "description": "Whether the row's own completion waits on the user's look. \
388 Can be set here by the lead; a worker asks its lead \
389 instead. A `user` gate is the user's to clear: an update \
390 cannot clear it. The gate is minted on the row, so moving \
391 the row changes nothing.",
392 },
393 "parked": {
394 "type": "boolean",
395 "description": "Mark a waiting row's wait as parked by design - the user's \
396 own deferral - so the chase leaves it alone. Only a \
397 waiting row takes it.",
398 },
399 "links_add": {
400 "type": "array",
401 "description": "Links to attach; a target already on the row is left alone.",
402 "items": {
403 "type": "object",
404 "properties": {
405 "kind": {
406 "type": "string",
407 "enum": ["spec", "plan", "issue", "pr", "branch", "path", "other"],
408 },
409 "label": { "type": "string" },
410 "target": { "type": "string" },
411 },
412 "required": ["target"],
413 },
414 },
415 "links_remove": {
416 "type": "array",
417 "description": "Links to take off, by exact target.",
418 "items": { "type": "string" },
419 },
420 "estimate": { "type": "string", "description": "A duration estimate." },
421 },
422 "required": ["id"],
423 "additionalProperties": false,
424 })
425 }
426
427 async fn call(&self, input: ToolInput) -> ToolOutput {
428 let args: UpdateArgs = match serde_json::from_value(input.value) {
429 Ok(args) => args,
430 Err(err) => return tool_error(format!("invalid arguments: {err}")),
431 };
432 match self.facade.update_task(&self.slot, &TaskId::from(args.id.as_str()), args.patch) {
433 Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
434 Ok(json) => ToolOutput::text(json),
435 Err(err) => tool_error(format!("response serialization failed: {err}")),
436 },
437 Err(err) => tool_error(format_tasks_error(&err)),
438 }
439 }
440}
441
442struct List {
443 facade: Arc<dyn TasksFacade>,
444 slot: SessionSlot,
445}
446
447#[derive(serde::Deserialize)]
448struct ListArgs {
449 #[serde(default)]
450 owner: Option<String>,
451 #[serde(default)]
452 parent: Option<String>,
453 #[serde(default)]
454 status: Option<TaskStatus>,
455 #[serde(default)]
456 ready: Option<bool>,
457}
458
459fn waiting_kind_str(kind: WaitingKind) -> &'static str {
460 match kind {
461 WaitingKind::Decision => "decision",
462 WaitingKind::Dependency => "dependency",
463 WaitingKind::Resource => "resource",
464 }
465}
466
467fn board_row_to_json(row: &crate::board::BoardRow) -> serde_json::Value {
470 let mut map = match task_to_json(&row.task) {
471 serde_json::Value::Object(map) => map,
472 other => return other,
473 };
474 map.insert("worked".to_owned(), serde_json::json!(crate::board::fmt_secs(row.worked_secs)));
475 map.insert(
476 "updated_ago".to_owned(),
477 serde_json::json!(crate::board::fmt_secs(row.updated_secs_ago)),
478 );
479 let mut marks: Vec<&str> = Vec::new();
480 if row.marks.ready {
481 marks.push("ready");
482 }
483 if row.marks.in_review {
484 marks.push("in_review");
485 }
486 if row.marks.overdue {
487 marks.push("overdue");
488 }
489 if row.marks.no_movement {
490 marks.push("no_movement");
491 }
492 if row.marks.waiting_too_long {
493 marks.push("waiting_too_long");
494 }
495 if row.marks.stale {
496 marks.push("stale");
497 }
498 if row.marks.to_close {
499 marks.push("to_close");
500 }
501 map.insert("marks".to_owned(), serde_json::json!(marks));
502 if let Some(wait) = &row.task.waiting_on {
503 map.insert(
504 "waiting".to_owned(),
505 serde_json::json!({
506 "kind": wait.kind.map(waiting_kind_str),
507 "detail": wait.detail,
508 "on": wait.on.as_ref().map(TaskId::as_str),
509 "verification": wait.verification,
510 "parked": wait.parked,
511 }),
512 );
513 }
514 if let Some((done, total)) = row.rollup {
515 map.insert("rollup".to_owned(), serde_json::json!(format!("{done}/{total}")));
516 }
517 if let Some(parent_subject) = &row.parent_subject {
518 map.insert("parent_subject".to_owned(), serde_json::json!(parent_subject));
519 }
520 serde_json::Value::Object(map)
521}
522
523#[async_trait::async_trait]
524impl Tool for List {
525 fn name(&self) -> &'static str {
526 "tasks__list"
527 }
528
529 fn description(&self) -> &'static str {
530 "List the board of YOUR project: every live row as a whole record, plus what the board \
531 derives - `worked` time against the estimate, how long since any touch, and the marks \
532 (ready, in_review, overdue, no_movement, waiting_too_long, stale, to_close) - under a \
533 dated line, so no session does date arithmetic. Narrow with `owner` (a session label), \
534 `parent` (a task id), `status`, or `ready` (true = pending with nothing waiting on \
535 it and not reserved for the user; ownership is NOT part of the mark, so read `owner` \
536 too when the question is an unowned row). \
537 An empty array means your project has no tasks in flight, or that your project could \
538 not be resolved, or that this run could not read its stored tasks. Any session in the \
539 project may call this."
540 }
541
542 fn input_schema(&self) -> serde_json::Value {
543 serde_json::json!({
544 "type": "object",
545 "properties": {
546 "owner": {
547 "type": "string",
548 "description": "Only tasks held by this session label.",
549 },
550 "parent": {
551 "type": "string",
552 "description": "Only the tasks under this task id, or any unique prefix \
553 of one.",
554 },
555 "status": {
556 "type": "string",
557 "enum": ["pending", "in_progress", "waiting", "completed", "failed", "canceled"],
558 "description": "Only tasks in this state.",
559 },
560 "ready": {
561 "type": "boolean",
562 "description": "True lists only rows something can start: pending, \
563 with nothing waiting on it, and not reserved for the \
564 user. Ownership is not part of the mark: read `owner` \
565 for an unowned row.",
566 },
567 },
568 "additionalProperties": false,
569 })
570 }
571
572 async fn call(&self, input: ToolInput) -> ToolOutput {
573 let args: ListArgs = match serde_json::from_value(input.value) {
574 Ok(args) => args,
575 Err(err) => return tool_error(format!("invalid arguments: {err}")),
576 };
577 let parent = args.parent.as_deref().map(TaskId::from);
578 let rows = self.facade.list_tasks(
579 &self.slot,
580 args.owner.as_deref(),
581 parent.as_ref(),
582 args.status,
583 args.ready,
584 );
585 let arr: Vec<serde_json::Value> = rows.iter().map(board_row_to_json).collect();
586 match serde_json::to_string_pretty(&serde_json::Value::Array(arr)) {
587 Ok(json) => ToolOutput::text(format!("now {}\n{json}", fmt_rfc3339(SystemTime::now()))),
588 Err(err) => tool_error(format!("task-list serialization failed: {err}")),
589 }
590 }
591}
592
593struct Delete {
594 facade: Arc<dyn TasksFacade>,
595 slot: SessionSlot,
596}
597
598#[derive(serde::Deserialize)]
599struct DeleteArgs {
600 id: String,
601}
602
603#[async_trait::async_trait]
604impl Tool for Delete {
605 fn name(&self) -> &'static str {
606 "tasks__delete"
607 }
608
609 fn description(&self) -> &'static str {
610 "Remove a task in YOUR project by id together with its children in the same call, so a \
611 parent never leaves subtasks pointing at a task that is gone. Use this to clear work \
612 that is over: the board is for running work, so a landed row - its work merged, \
613 delivered or concluded - with no children and no epic above it is deleted in the \
614 same pass, never left completed; a landed row under an epic - open or already \
615 terminal - is completed and left terminal instead, so the epic can read `to close` \
616 (a verify=user row parks in waiting on the user first). Nothing \
617 is archived. A row waiting on the user is not a delete's to \
618 take - the board's answer or approve settles it, and deleting it (or a tree holding \
619 it) is refused rather than taken as a way around the ask. A dependency wait is not \
620 such an ask: the row waits on the task it names, not on the user, even when the epic \
621 is `verify: user`. When the row you delete is named by another row's dependency wait, \
622 move every waiter off that wait in the same pass - a wait left on a deleted row reads \
623 that its target has ended indefinitely. \
624 An ask that has gone moot is withdrawn, not removed: `tasks__update` cancels it with \
625 the reason it goes away, and the row records why. The task is named by its \
626 full id or any unique \
627 prefix of one; an exact id wins over any longer id that starts with it, and a prefix \
628 matching more than one row is refused with the matching rows named. An unknown id is an \
629 error, not a no-op. Returns the removed task and how many descendants went with it. Any \
630 session in the project may call this."
631 }
632
633 fn input_schema(&self) -> serde_json::Value {
634 serde_json::json!({
635 "type": "object",
636 "properties": {
637 "id": {
638 "type": "string",
639 "description": "The task id to remove, or any unique prefix of one.",
640 },
641 },
642 "required": ["id"],
643 "additionalProperties": false,
644 })
645 }
646
647 async fn call(&self, input: ToolInput) -> ToolOutput {
648 let args: DeleteArgs = match serde_json::from_value(input.value) {
649 Ok(args) => args,
650 Err(err) => return tool_error(format!("invalid arguments: {err}")),
651 };
652 match self.facade.delete_task(&self.slot, &TaskId::from(args.id.as_str())) {
653 Ok(removed) => {
654 let mut envelope =
655 crate::mcp::deleted::removed_record(&task_to_json(&removed.task));
656 envelope["descendants_removed"] = serde_json::json!(removed.descendants_removed);
657 match serde_json::to_string_pretty(&envelope) {
658 Ok(json) => ToolOutput::text(json),
659 Err(err) => tool_error(format!("response serialization failed: {err}")),
660 }
661 }
662 Err(err) => tool_error(format_tasks_error(&err)),
663 }
664 }
665}
666
667struct Claim {
668 facade: Arc<dyn TasksFacade>,
669 slot: SessionSlot,
670}
671
672#[derive(serde::Deserialize)]
673struct ClaimArgs {
674 #[serde(default)]
675 id: Option<String>,
676 #[serde(default)]
677 epic: Option<String>,
678}
679
680#[async_trait::async_trait]
681impl Tool for Claim {
682 fn name(&self) -> &'static str {
683 "tasks__claim"
684 }
685
686 fn description(&self) -> &'static str {
687 "Claim a task in YOUR project: one atomic write that makes you its owner and marks it \
688 in progress, ticking the attempt. Pass `id` for a named row, or `epic` to pull the top \
689 ready row of that epic by rank - exactly one of the two. `id` and `epic` each take a \
690 full task id or any unique prefix of one; an exact id wins over any longer id that \
691 starts with it, and a prefix matching more than one row is refused with the matching \
692 rows named. Refused when a live seat holds the row (named), when it is not pending, \
693 when it is reserved for the user (`reserved: \"user\"` - the sweep's pick and nobody \
694 else's), or when you already have a row in progress: one in progress per worker. \
695 Claiming is for unassigned rows: a row a lead created for you already names you as \
696 `owner`, so work it without claiming. Returns the claimed record."
697 }
698
699 fn input_schema(&self) -> serde_json::Value {
700 serde_json::json!({
701 "type": "object",
702 "properties": {
703 "id": {
704 "type": "string",
705 "description": "The task id to claim, or any unique prefix of one.",
706 },
707 "epic": {
708 "type": "string",
709 "description": "The epic whose top ready row to pull, by rank; a full id or \
710 any unique prefix of one.",
711 },
712 },
713 "additionalProperties": false,
714 })
715 }
716
717 async fn call(&self, input: ToolInput) -> ToolOutput {
718 let args: ClaimArgs = match serde_json::from_value(input.value) {
719 Ok(args) => args,
720 Err(err) => return tool_error(format!("invalid arguments: {err}")),
721 };
722 match (&args.id, &args.epic) {
723 (Some(_), None) | (None, Some(_)) => {}
724 _ => return tool_error("state exactly one of id or epic".to_owned()),
725 }
726 match self.facade.claim_task(&self.slot, args.id.as_deref(), args.epic.as_deref()) {
727 Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
728 Ok(json) => ToolOutput::text(json),
729 Err(err) => tool_error(format!("response serialization failed: {err}")),
730 },
731 Err(err) => tool_error(format_tasks_error(&err)),
732 }
733 }
734}
735
736struct Wait {
737 facade: Arc<dyn TasksFacade>,
738 slot: SessionSlot,
739}
740
741#[derive(serde::Deserialize)]
742struct WaitArgs {
743 id: String,
744 kind: WaitingKind,
745 #[serde(default)]
746 detail: Option<String>,
747 #[serde(default)]
748 on: Option<String>,
749}
750
751#[async_trait::async_trait]
752impl Tool for Wait {
753 fn name(&self) -> &'static str {
754 "tasks__wait"
755 }
756
757 fn description(&self) -> &'static str {
758 "Put one of YOUR rows into waiting and state what it waits on. `kind` is decision \
759 (which routes to the user), dependency (another task: put its id in `on`), or resource \
760 (an account, a CI run, a machine). A decision wait is the at-convenience channel to \
761 the user: a lead who can continue but wants the person's look puts the ask here on a \
762 board row rather than saying it in prose, because prose is not a surface and a wait \
763 left in prose is invisible once your turn ends. Do not use it to park work the \
764 decision model, your peers or the review can settle. A dependency wait is on the \
765 task it names, not on the user. A blocker parked on the user's look has not ended, so the chain keeps \
766 waiting until the user approves it. The waiting row is not itself waiting on the \
767 user: a row's `verify: user` gates its own completion, not its dependency \
768 wait, so the row stays movable and deletable. Route a decision to the user when the user \
769 asked for a hold, when something is still genuinely ambiguous after the decision model, \
770 peer opinions and the team's own review, or when the person should look at the work when \
771 they come back. Only the lead mints a \
772 user-decision wait: a worker that believes the call is the user's hands it to its \
773 lead, because a decision a worker parks outlives its answer and the lead cannot close \
774 it afterward. `detail` is the words. \
775 A wait on the user that has gone moot is withdrawn, not stranded: a lead cancels the \
776 row with the reason it goes away (`tasks__update`, `detail`), and the row records why. \
777 The row \
778 and the `on` blocker are each named by a full id or any unique prefix of one; an exact \
779 id wins over any longer id that starts with it, and a prefix matching more than one row \
780 is refused with the matching rows named. The row must be yours. Refused for a row you \
781 do not hold, or one that is not there. Returns the record in waiting."
782 }
783
784 fn input_schema(&self) -> serde_json::Value {
785 serde_json::json!({
786 "type": "object",
787 "properties": {
788 "id": {
789 "type": "string",
790 "description": "The task id to put into waiting, or any unique prefix of one.",
791 },
792 "kind": {
793 "type": "string",
794 "enum": ["decision", "dependency", "resource"],
795 "description": "What it waits on; the kind decides where the unblock lands.",
796 },
797 "detail": {
798 "type": "string",
799 "description": "The words: what exactly is being waited for. For a decision: \
800 why it needs the user specifically, what is already decided, \
801 what is open, what their answer changes, and the links to \
802 read - a bare question with no why is its own failure, and \
803 the flag exists to make the return cheap. A wait is a flag, \
804 not a stop: carry on with your next held row and let the \
805 answer resume this one, unless the user asked for a stop.",
806 },
807 "on": {
808 "type": "string",
809 "description": "For a dependency: the task id it waits on, or any unique \
810 prefix of one.",
811 },
812 },
813 "required": ["id", "kind"],
814 "additionalProperties": false,
815 })
816 }
817
818 async fn call(&self, input: ToolInput) -> ToolOutput {
819 let args: WaitArgs = match serde_json::from_value(input.value) {
820 Ok(args) => args,
821 Err(err) => return tool_error(format!("invalid arguments: {err}")),
822 };
823 if args.kind == WaitingKind::Dependency && args.on.is_none() {
824 return tool_error("a dependency wait needs `on`, the task it waits on".to_owned());
825 }
826 let on = args.on.as_deref().map(TaskId::from);
827 match self.facade.wait_task(
828 &self.slot,
829 &TaskId::from(args.id.as_str()),
830 args.kind,
831 args.detail,
832 on.as_ref(),
833 ) {
834 Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
835 Ok(json) => ToolOutput::text(json),
836 Err(err) => tool_error(format!("response serialization failed: {err}")),
837 },
838 Err(err) => tool_error(format_tasks_error(&err)),
839 }
840 }
841}
842
843struct Approve {
844 facade: Arc<dyn TasksFacade>,
845 slot: SessionSlot,
846}
847
848#[derive(serde::Deserialize)]
849struct ApproveArgs {
850 id: String,
851 approval: String,
853}
854
855#[async_trait::async_trait]
856impl Tool for Approve {
857 fn name(&self) -> &'static str {
858 "tasks__approve"
859 }
860
861 fn description(&self) -> &'static str {
862 "Lead-only. Complete a row waiting on the user's LOOK by recording the approval the \
863 user already gave, quoted from the chat or the look record. `approval` is that quote \
864 and must not be empty: it lands in the row's detail and is the evidence the board \
865 draws as lead-attested. This door is for a look only - never a decision or a \
866 question, which are the user's to answer - and only the project lead may call it. \
867 A verification wait on a row with no `verify: \"user\"` gate of its own is a stale \
868 park, not a look: never attest it, and clear it with a move off waiting, or a \
869 withdraw only when the ask is moot. \
870 The user's own approve, send-back and cancel doors stay; the user's reopen strikes \
871 the attestation, re-mints the look gate and parks the row on them again. A task is \
872 named by its full id or any unique prefix of one. Refused when the row is not \
873 waiting on a look, when the quote is empty, or when the caller is not the lead."
874 }
875
876 fn input_schema(&self) -> serde_json::Value {
877 serde_json::json!({
878 "type": "object",
879 "properties": {
880 "id": {
881 "type": "string",
882 "description": "The row waiting on the look, or any unique prefix of its id.",
883 },
884 "approval": {
885 "type": "string",
886 "description": "The user's approval, quoted as they gave it (e.g. \
887 \"Looks right - merge\"). Required and non-empty.",
888 },
889 },
890 "required": ["id", "approval"],
891 "additionalProperties": false,
892 })
893 }
894
895 async fn call(&self, input: ToolInput) -> ToolOutput {
896 let args: ApproveArgs = match serde_json::from_value(input.value) {
897 Ok(args) => args,
898 Err(err) => return tool_error(format!("invalid arguments: {err}")),
899 };
900 let approval = args.approval.trim();
901 if approval.is_empty() {
902 return tool_error(
903 "an attestation needs the user's approval quoted in `approval`; it must not be \
904 empty"
905 .to_owned(),
906 );
907 }
908 match self.facade.attest_task(&self.slot, &TaskId::from(args.id.as_str()), approval) {
909 Ok(task) => match serde_json::to_string_pretty(&task_to_json(&task)) {
910 Ok(json) => ToolOutput::text(json),
911 Err(err) => tool_error(format!("response serialization failed: {err}")),
912 },
913 Err(err) => tool_error(format_tasks_error(&err)),
914 }
915 }
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921 use crate::mcp::tasks::facade::{MockTasksFacade, RemovedTaskTree};
922 use crate::mcp::test_support::text_of;
923 use forge_primitives::tasks::{Estimate, TaskLink};
924
925 fn lead_slot() -> SessionSlot {
926 SessionSlot::lead("TestOrg", "myproj")
927 }
928
929 fn input(value: serde_json::Value) -> ToolInput {
930 ToolInput { value }
931 }
932
933 #[tokio::test]
934 async fn create_persists_a_task_for_the_callers_project() {
935 let facade = Arc::new(MockTasksFacade::default());
936 let out = Create { facade: facade.clone(), slot: lead_slot() }
937 .call(input(serde_json::json!({ "subject": "Merge peers and workers into agents" })))
938 .await;
939 assert!(!out.is_error, "create succeeds: {out:?}");
940 let calls = facade.created.lock();
941 assert_eq!(calls.len(), 1, "one task created");
942 assert_eq!(calls[0].1.subject, "Merge peers and workers into agents");
943 assert_eq!(calls[0].2.project_name, "myproj", "stamped with the caller's project");
944 }
945
946 #[tokio::test]
947 async fn create_threads_every_optional_field_to_the_facade() {
948 let facade = Arc::new(MockTasksFacade::default());
949 let out = Create { facade: facade.clone(), slot: lead_slot() }
950 .call(input(serde_json::json!({
951 "subject": "Merge peers and workers",
952 "active_form": "Merging peers and workers",
953 "detail": "One agents family.",
954 "status": "in_progress",
955 "owner": "agents-merge",
956 "parent": "epic",
957 "rank": 10,
958 "verify": "user",
959 "reserved": "user",
960 "links": [
961 { "kind": "issue", "label": "PR #1173", "target": "https://example.invalid/pull/1173" },
962 { "target": "docs/plan.md" }
963 ],
964 "estimate": "1d",
965 })))
966 .await;
967 assert!(!out.is_error, "create succeeds: {out:?}");
968 let calls = facade.created.lock();
969 let draft = &calls[0].1;
970 assert_eq!(draft.active_form.as_deref(), Some("Merging peers and workers"));
971 assert_eq!(draft.detail.as_deref(), Some("One agents family."));
972 assert_eq!(draft.status, Some(TaskStatus::InProgress));
973 assert_eq!(draft.owner.as_deref(), Some("agents-merge"));
974 assert_eq!(draft.parent.as_deref(), Some("epic"));
975 assert_eq!(draft.rank, Some(10));
976 assert_eq!(draft.verify, Some(forge_primitives::tasks::Verify::User));
977 assert_eq!(
978 draft.reserved,
979 Some(forge_primitives::tasks::ReservedBy::User),
980 "the reserved pick reaches the facade",
981 );
982 assert_eq!(draft.links.len(), 2);
983 assert_eq!(draft.links[0].kind, Some(LinkKind::Issue));
984 assert_eq!(
985 draft.links[1].kind, None,
986 "an omitted kind is derived at the facade, not guessed here",
987 );
988 assert_eq!(draft.links[1].target, "docs/plan.md");
989 assert_eq!(draft.estimate.as_deref(), Some("1d"));
990 }
991
992 #[tokio::test]
993 async fn create_rejects_a_missing_subject_without_touching_the_facade() {
994 let facade = Arc::new(MockTasksFacade::default());
995 let out = Create { facade: facade.clone(), slot: lead_slot() }
996 .call(input(serde_json::json!({ "detail": "no subject" })))
997 .await;
998 assert!(out.is_error, "subject is required");
999 assert!(facade.created.lock().is_empty(), "invalid input never reaches the facade");
1000 }
1001
1002 #[tokio::test]
1003 async fn approve_threads_the_quote_and_the_caller_to_the_facade() {
1004 let facade = Arc::new(MockTasksFacade::default());
1005 let out = Approve { facade: facade.clone(), slot: lead_slot() }
1006 .call(input(serde_json::json!({ "id": "t-1", "approval": " looks good " })))
1007 .await;
1008 assert!(!out.is_error, "approve succeeds: {out:?}");
1009 let calls = facade.attested.lock();
1010 assert_eq!(calls.len(), 1, "one attestation reached the facade");
1011 assert_eq!(calls[0].1, TaskId::from("t-1"), "the row the caller named");
1012 assert_eq!(calls[0].2, "looks good", "the quote is trimmed, not dropped");
1013 }
1014
1015 #[tokio::test]
1016 async fn approve_refuses_an_empty_quote_without_touching_the_facade() {
1017 let facade = Arc::new(MockTasksFacade::default());
1018 let out = Approve { facade: facade.clone(), slot: lead_slot() }
1019 .call(input(serde_json::json!({ "id": "t-1", "approval": " " })))
1020 .await;
1021 assert!(out.is_error, "an empty approval is nothing to stand behind");
1022 assert!(facade.attested.lock().is_empty(), "invalid input never reaches the facade");
1023 }
1024
1025 #[test]
1029 fn the_approve_description_states_the_lead_door() {
1030 let approve = Approve { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1031 for clause in [
1032 "Lead-only",
1033 "never a decision or a question",
1034 "must not be empty",
1035 "the user's reopen strikes the attestation",
1036 ] {
1037 assert!(
1038 approve.description().contains(clause),
1039 "tasks__approve does not state the door ({clause}): {}",
1040 approve.description(),
1041 );
1042 }
1043 assert_eq!(
1044 approve.input_schema()["required"],
1045 serde_json::json!(["id", "approval"]),
1046 "the quote is required at the schema",
1047 );
1048 }
1049
1050 #[test]
1055 fn the_approve_description_separates_a_stale_park_from_the_look() {
1056 let approve = Approve { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1057 assert!(
1058 approve.description().contains(
1059 "A verification wait on a row with no `verify: \"user\"` gate of its own is a \
1060 stale park, not a look"
1061 ),
1062 "the stale park is separated from the look: {}",
1063 approve.description(),
1064 );
1065 assert!(
1066 approve.description().contains(
1067 "never attest it, and clear it with a move off waiting, or a withdraw only \
1068 when the ask is moot"
1069 ),
1070 "the stale park's own door and its moot condition are pinned: {}",
1071 approve.description(),
1072 );
1073 }
1074
1075 #[tokio::test]
1076 async fn update_reports_a_missing_id_rather_than_succeeding_silently() {
1077 let facade = Arc::new(MockTasksFacade::default());
1078 let out = Update { facade, slot: lead_slot() }
1079 .call(input(serde_json::json!({ "id": "nope", "status": "completed" })))
1080 .await;
1081 assert!(out.is_error, "an unknown id is an error, not a no-op");
1082 assert!(
1083 text_of(&out).contains("nope"),
1084 "the refusal names the id it was given, so the caller can see what it sent: {out:?}",
1085 );
1086 }
1087
1088 #[tokio::test]
1092 async fn update_refusals_name_the_case() {
1093 let facade = Arc::new(MockTasksFacade::default());
1094 let tool = Update { facade: facade.clone(), slot: lead_slot() };
1095
1096 *facade.update_result.lock() = Some(Err(TasksError::TaskInAnotherProject {
1097 id: "t-9".to_owned(),
1098 project: "otherproj".to_owned(),
1099 }));
1100 let elsewhere =
1101 tool.call(input(serde_json::json!({ "id": "t-9", "status": "pending" }))).await;
1102 assert!(elsewhere.is_error, "another project's row is refused");
1103 assert_eq!(
1104 text_of(&elsewhere),
1105 "task t-9 exists in project otherproj, not in your project",
1106 "the refusal names the project that owns the row",
1107 );
1108
1109 *facade.update_result.lock() = Some(Err(TasksError::NoSuchTask { id: "t-9".to_owned() }));
1110 let absent =
1111 tool.call(input(serde_json::json!({ "id": "t-9", "status": "pending" }))).await;
1112 assert!(absent.is_error, "an absent id is refused");
1113 assert_eq!(
1114 text_of(&absent),
1115 "no task with id t-9 in your project",
1116 "an id no project has must not read as another project's row",
1117 );
1118 }
1119
1120 #[tokio::test]
1124 async fn update_echoes_the_updated_record() {
1125 let facade = Arc::new(MockTasksFacade::default());
1126 let mut task = sample_task();
1127 task.status = TaskStatus::InProgress;
1128 task.owner = Some(SessionSlot::lead("TestOrg", "myproj"));
1129 *facade.update_result.lock() = Some(Ok(task));
1130 let out = Update { facade, slot: lead_slot() }
1131 .call(input(serde_json::json!({ "id": "t-1", "status": "in_progress" })))
1132 .await;
1133 assert!(!out.is_error, "update succeeds: {out:?}");
1134 let json: serde_json::Value =
1135 serde_json::from_str(text_of(&out)).expect("the result is the task record, not prose");
1136 assert_eq!(json["id"], "t-1", "the record names the task it moved: {json}");
1137 assert_eq!(json["subject"], "Merge peers and workers");
1138 assert_eq!(json["status"], "in_progress", "and the state it now holds");
1139 assert_eq!(json["owner"], "lead");
1140 }
1141
1142 #[tokio::test]
1148 async fn delete_echoes_the_removed_record_and_the_cascade_count() {
1149 let facade = Arc::new(MockTasksFacade::default());
1150 *facade.delete_result.lock() =
1151 Some(Ok(RemovedTaskTree { task: sample_task(), descendants_removed: 2 }));
1152 let out = Delete { facade, slot: lead_slot() }
1153 .call(input(serde_json::json!({ "id": "t-1" })))
1154 .await;
1155 assert!(!out.is_error, "delete succeeds: {out:?}");
1156 assert_eq!(out.blocks.len(), 1, "the envelope stays one text block: {out:?}");
1157 let json: serde_json::Value =
1158 serde_json::from_str(text_of(&out)).expect("the result is the adopted envelope");
1159 assert_eq!(json["status"], "deleted");
1160 assert_eq!(
1161 json["removed"],
1162 task_to_json(&sample_task()),
1163 "the echo is the whole record, not a hand-picked subset: {json}",
1164 );
1165 assert_eq!(json["descendants_removed"], 2);
1166 }
1167
1168 #[tokio::test]
1172 async fn a_delete_with_no_children_still_states_the_count() {
1173 let facade = Arc::new(MockTasksFacade::default());
1174 *facade.delete_result.lock() =
1175 Some(Ok(RemovedTaskTree { task: sample_task(), descendants_removed: 0 }));
1176 let out = Delete { facade, slot: lead_slot() }
1177 .call(input(serde_json::json!({ "id": "t-1" })))
1178 .await;
1179 assert!(!out.is_error, "delete succeeds: {out:?}");
1180 let json: serde_json::Value =
1181 serde_json::from_str(text_of(&out)).expect("the result is the adopted envelope");
1182 assert_eq!(json["descendants_removed"], 0, "a leaf states its count: {json}");
1183 }
1184
1185 #[tokio::test]
1186 async fn update_states_only_the_fields_it_was_given() {
1187 let facade = Arc::new(MockTasksFacade::default());
1188 *facade.update_result.lock() = Some(Ok(sample_task()));
1189 let out = Update { facade: facade.clone(), slot: lead_slot() }
1190 .call(input(serde_json::json!({
1191 "id": "t-1",
1192 "status": "waiting",
1193 "detail": "why",
1194 "links_add": [{ "target": "x" }]
1195 })))
1196 .await;
1197 assert!(!out.is_error, "update succeeds: {out:?}");
1198 let patch = &facade.updated.lock()[0].2;
1199 assert_eq!(patch.status, Some(TaskStatus::Waiting));
1200 assert_eq!(patch.detail.as_deref(), Some("why"), "the detail lands with the link");
1201 assert_eq!(patch.links_add.len(), 1, "the stated link reaches the facade");
1202 assert_eq!(patch.links_add[0].target, "x");
1203 assert_eq!(patch.subject, None, "an unstated field is left alone");
1204 assert_eq!(patch.owner, None, "an unstated field is left alone");
1205 }
1206
1207 fn sample_task() -> Task {
1208 Task {
1209 id: TaskId::from("t-1"),
1210 project_name: "myproj".to_owned(),
1211 subject: "Merge peers and workers".to_owned(),
1212 active_form: None,
1213 detail: None,
1214 status: TaskStatus::Pending,
1215 owner: None,
1216 parent: None,
1217 waiting_on: None,
1218 estimate: None,
1219 rank: None,
1220 verify: None,
1221 attested: None,
1222 reserved: None,
1223 links: Vec::new(),
1224 attempt: 0,
1225 archived_at: None,
1226 created_at: SystemTime::UNIX_EPOCH,
1227 updated_at: SystemTime::UNIX_EPOCH,
1228 }
1229 }
1230
1231 #[test]
1236 fn the_status_spellings_agree_across_the_output_and_both_schemas() {
1237 for (status, spelling) in [
1238 (TaskStatus::Pending, "pending"),
1239 (TaskStatus::InProgress, "in_progress"),
1240 (TaskStatus::Waiting, "waiting"),
1241 (TaskStatus::Completed, "completed"),
1242 (TaskStatus::Failed, "failed"),
1243 (TaskStatus::Canceled, "canceled"),
1244 ] {
1245 assert_eq!(status_str(status), spelling, "a result reads {status:?} as {spelling}");
1246 }
1247 for schema in [
1248 Create { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() }.input_schema(),
1249 Update { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() }.input_schema(),
1250 ] {
1251 let spelled: Vec<&str> = schema["properties"]["status"]["enum"]
1252 .as_array()
1253 .expect("the status property carries an enum")
1254 .iter()
1255 .filter_map(serde_json::Value::as_str)
1256 .collect();
1257 assert_eq!(
1258 spelled,
1259 ["pending", "in_progress", "waiting", "completed", "failed", "canceled"],
1260 "a caller must be able to send back what it read",
1261 );
1262 }
1263
1264 let description =
1270 Update { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() }.description();
1271 for spelling in ["pending", "in_progress", "waiting", "completed", "failed", "canceled"] {
1272 assert!(
1273 description.contains(spelling),
1274 "the description does not name {spelling}, which the schema accepts",
1275 );
1276 }
1277 assert!(
1278 !description.contains("blocked"),
1279 "the description names a status the enum no longer carries",
1280 );
1281 }
1282
1283 #[test]
1288 fn a_task_block_carries_the_named_fields() {
1289 let task = Task {
1290 active_form: Some("Merging".to_owned()),
1291 detail: Some("why".to_owned()),
1292 estimate: Some(Estimate { words: "1d".to_owned(), secs: 86_400 }),
1293 links: vec![TaskLink {
1294 kind: LinkKind::Pr,
1295 label: Some("PR #9".to_owned()),
1296 target: "https://example.invalid/pull/9".to_owned(),
1297 state: None,
1298 added_at: SystemTime::UNIX_EPOCH,
1299 }],
1300 parent: Some(TaskId::from("epic")),
1301 owner: Some(SessionSlot::lead("TestOrg", "myproj")),
1302 ..sample_task()
1303 };
1304 let json = task_to_json(&task);
1305 for key in [
1306 "id",
1307 "project",
1308 "subject",
1309 "status",
1310 "active_form",
1311 "detail",
1312 "artifact",
1313 "estimate",
1314 "parent",
1315 "owner",
1316 "created_at",
1317 "updated_at",
1318 ] {
1319 assert!(json.get(key).is_some(), "the task block carries {key}: {json}");
1320 }
1321 for (key, value) in [
1322 ("id", "t-1"),
1323 ("project", "myproj"),
1324 ("subject", "Merge peers and workers"),
1325 ("status", "pending"),
1326 ("active_form", "Merging"),
1327 ("detail", "why"),
1328 ("artifact", "PR #9"),
1329 ("estimate", "1d"),
1330 ("parent", "epic"),
1331 ("owner", "lead"),
1332 ] {
1333 assert_eq!(
1334 json.get(key).and_then(serde_json::Value::as_str),
1335 Some(value),
1336 "the block's {key} is the task's own {key}: {json}",
1337 );
1338 }
1339 }
1340
1341 #[test]
1346 fn an_unclaimed_task_omits_its_owner() {
1347 let json = task_to_json(&sample_task());
1348 assert!(json.get("owner").is_none(), "an unclaimed task carries no owner key: {json}");
1349 }
1350
1351 #[test]
1352 fn a_claimed_task_names_its_owner_by_label() {
1353 let task = Task {
1354 owner: Some(SessionSlot::worker("TestOrg", "myproj", "steward")),
1355 ..sample_task()
1356 };
1357 let json = task_to_json(&task);
1358 assert_eq!(json.get("owner").and_then(serde_json::Value::as_str), Some("steward"));
1359 }
1360
1361 #[test]
1369 fn the_list_description_owns_up_to_every_empty_result() {
1370 let desc =
1371 List { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() }.description();
1372 for clause in [
1373 "An empty array means your project has no tasks in flight",
1374 "or that your project could not be resolved",
1375 "or that this run could not read its stored tasks",
1376 ] {
1377 assert!(desc.contains(clause), "the list description owes {clause:?}: {desc}");
1378 }
1379 }
1380
1381 #[test]
1387 fn the_wait_description_carries_the_attention_rule() {
1388 let wait = Wait { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1389 let desc = wait.description();
1390 for clause in [
1391 "Route a decision to the user when the user asked for a hold",
1392 "something is still genuinely ambiguous after the decision model, peer opinions and \
1393 the team's own review",
1394 "when the person should look at the work when they come back",
1395 ] {
1396 assert!(desc.contains(clause), "the wait description owes {clause:?}: {desc}");
1397 }
1398 let detail = wait.input_schema()["properties"]["detail"]["description"]
1399 .as_str()
1400 .expect("the detail property carries a description")
1401 .to_owned();
1402 for clause in [
1403 "why it needs the user specifically, what is already decided, what is open, what \
1404 their answer changes, and the links to read",
1405 "a bare question with no why is its own failure",
1406 "A wait is a flag, not a stop",
1407 "unless the user asked for a stop",
1408 ] {
1409 assert!(detail.contains(clause), "the detail line owes {clause:?}: {detail}");
1410 }
1411 }
1412
1413 #[test]
1418 fn every_task_tool_description_states_the_id_prefix_rule() {
1419 let facade = MockTasksFacade::new().into_arc();
1420 let slot = lead_slot();
1421 let claim = Claim { facade: facade.clone(), slot: slot.clone() };
1422 let update = Update { facade: facade.clone(), slot: slot.clone() };
1423 let wait = Wait { facade: facade.clone(), slot: slot.clone() };
1424 let delete = Delete { facade: facade.clone(), slot: slot.clone() };
1425 let create = Create { facade: facade.clone(), slot: slot.clone() };
1426 let list = List { facade: facade.clone(), slot: slot.clone() };
1427 for (name, description, schema, also_named) in [
1428 ("tasks__claim", claim.description(), claim.input_schema(), &["epic"] as &[&str]),
1429 ("tasks__update", update.description(), update.input_schema(), &[]),
1430 ("tasks__wait", wait.description(), wait.input_schema(), &["on"]),
1431 ("tasks__delete", delete.description(), delete.input_schema(), &[]),
1432 ] {
1433 for clause in [
1434 "unique prefix of one",
1435 "an exact id wins over any longer id that starts with it",
1438 "refused with the matching rows named",
1439 ] {
1440 assert!(
1441 description.contains(clause),
1442 "{name} does not state the rule ({clause}): {description}",
1443 );
1444 }
1445 for property_name in ["id"].into_iter().chain(also_named.iter().copied()) {
1446 let property =
1447 schema["properties"][property_name]["description"].as_str().unwrap_or_else(
1448 || panic!("{name} carries no {property_name} property description"),
1449 );
1450 assert!(
1451 property.contains("unique prefix"),
1452 "{name}'s {property_name} property does not carry the rule: {property}",
1453 );
1454 }
1455 }
1456 for (name, schema) in [
1459 ("tasks__create", create.input_schema()),
1460 ("tasks__update", update.input_schema()),
1461 ("tasks__list", list.input_schema()),
1462 ] {
1463 let property = schema["properties"]["parent"]["description"]
1464 .as_str()
1465 .unwrap_or_else(|| panic!("{name} carries no parent property description"));
1466 assert!(
1467 property.contains("unique prefix"),
1468 "{name}'s parent property does not carry the rule: {property}",
1469 );
1470 }
1471 }
1472
1473 #[test]
1480 fn the_delete_description_states_the_ask_gate() {
1481 let facade = MockTasksFacade::new().into_arc();
1482 let slot = lead_slot();
1483 let delete = Delete { facade, slot };
1484 let description = delete.description();
1485 for clause in [
1486 "A row waiting on the user is not a delete's to take",
1487 "the board's answer or approve settles it",
1488 "refused rather than taken as a way around the ask",
1489 ] {
1490 assert!(
1491 description.contains(clause),
1492 "tasks__delete does not state the rule ({clause}): {description}",
1493 );
1494 }
1495 }
1496
1497 #[test]
1502 fn the_wait_and_delete_descriptions_state_the_dependency_chain() {
1503 let facade = MockTasksFacade::new().into_arc();
1504 let slot = lead_slot();
1505 let wait = Wait { facade: facade.clone(), slot: slot.clone() };
1506 let delete = Delete { facade, slot };
1507 for clause in [
1508 "A dependency wait is on the task it names, not on the user",
1509 "A blocker parked on the user's look has not ended, so the chain keeps waiting \
1510 until the user approves it",
1511 "a row's `verify: user` gates its own completion, not its dependency wait",
1512 "the row stays movable and deletable",
1513 ] {
1514 assert!(
1515 wait.description().contains(clause),
1516 "tasks__wait does not state the chain ({clause}): {}",
1517 wait.description(),
1518 );
1519 }
1520 assert!(
1521 delete.description().contains("A dependency wait is not such an ask"),
1522 "tasks__delete does not separate a dependency wait from the user's asks: {}",
1523 delete.description(),
1524 );
1525 }
1526
1527 #[test]
1531 fn the_wait_description_states_the_at_convenience_channel() {
1532 let facade = MockTasksFacade::new().into_arc();
1533 let wait = Wait { facade, slot: lead_slot() };
1534 for clause in [
1535 "A decision wait is the at-convenience channel to the user",
1536 "puts the ask here on a board row rather than saying it in prose",
1537 "prose is not a surface",
1538 ] {
1539 assert!(
1540 wait.description().contains(clause),
1541 "tasks__wait does not state the channel ({clause}): {}",
1542 wait.description(),
1543 );
1544 }
1545 }
1546
1547 #[test]
1552 fn the_update_and_delete_descriptions_state_the_waiter_release() {
1553 let facade = MockTasksFacade::new().into_arc();
1554 let slot = lead_slot();
1555 let update = Update { facade: facade.clone(), slot: slot.clone() };
1556 let delete = Delete { facade, slot };
1557 for (name, description, remedy) in [
1558 (
1559 "tasks__update",
1560 update.description(),
1561 "When the row you end is named by another row's dependency wait, move every \
1562 waiter off that wait in the same pass - a wait left on an ended row reads \
1563 that its target has ended indefinitely",
1564 ),
1565 (
1566 "tasks__delete",
1567 delete.description(),
1568 "When the row you delete is named by another row's dependency wait, move every \
1569 waiter off that wait in the same pass - a wait left on a deleted row reads \
1570 that its target has ended indefinitely",
1571 ),
1572 ] {
1573 assert!(
1574 description.contains(remedy),
1575 "{name} does not state the waiter release: {description}",
1576 );
1577 }
1578 }
1579
1580 #[test]
1585 fn the_task_descriptions_state_the_withdraw_door() {
1586 let facade = MockTasksFacade::new().into_arc();
1587 let slot = lead_slot();
1588 let update = Update { facade: facade.clone(), slot: slot.clone() };
1589 let delete = Delete { facade: facade.clone(), slot: slot.clone() };
1590 let wait = Wait { facade, slot };
1591 for (name, description, clauses) in [
1592 (
1593 "tasks__update",
1594 update.description(),
1595 &[
1596 "a cancel of a row waiting on the user is the withdraw door once the ask has",
1597 "it needs `detail`, the reason the ask is withdrawn, which lands on the row",
1598 "and a cancel stating none - or only what the row already says - is refused",
1599 ] as &[&str],
1600 ),
1601 (
1602 "tasks__delete",
1603 delete.description(),
1604 &[
1605 "An ask that has gone moot is withdrawn, not removed",
1606 "the reason it goes away, and the row records why",
1607 ],
1608 ),
1609 (
1610 "tasks__wait",
1611 wait.description(),
1612 &[
1613 "A wait on the user that has gone moot is withdrawn, not stranded",
1614 "row with the reason it goes away (`tasks__update`, `detail`), and the row \
1615 records why",
1616 ],
1617 ),
1618 ] {
1619 for clause in clauses {
1620 assert!(
1621 description.contains(clause),
1622 "{name} does not state the door ({clause}): {description}",
1623 );
1624 }
1625 }
1626 }
1627
1628 #[test]
1634 fn the_task_text_says_pending_is_the_backlog() {
1635 let facade = MockTasksFacade::new().into_arc();
1636 let slot = lead_slot();
1637 let update = Update { facade: facade.clone(), slot: slot.clone() };
1638 let create = Create { facade, slot };
1639 let description = update.description();
1640 for clause in [
1641 "Pending is the board's Backlog: work kept to look at later, not by itself an \
1642 assignment and not a promise to start",
1643 "A pending row that reads ready and unowned is dispatchable; take ownership to set \
1644 it aside",
1645 ] {
1646 assert!(
1647 description.contains(clause),
1648 "tasks__update does not state what pending means ({clause}): {description}",
1649 );
1650 }
1651 let status = create.input_schema()["properties"]["status"]["description"]
1652 .as_str()
1653 .expect("the status property carries a description")
1654 .to_owned();
1655 for clause in [
1656 "Defaults to pending, the board's Backlog: work kept to look at later, not by \
1657 itself an assignment",
1658 "The sweep dispatches only unowned ready rows, so take ownership to set one aside",
1659 ] {
1660 assert!(
1661 status.contains(clause),
1662 "tasks__create's status property owes the backlog where a row is filed \
1663 ({clause}): {status}",
1664 );
1665 }
1666 }
1667
1668 #[test]
1678 fn the_task_descriptions_state_delete_on_landing_and_the_epic_carve_out() {
1679 let facade = MockTasksFacade::new().into_arc();
1680 let slot = lead_slot();
1681 let update = Update { facade: facade.clone(), slot: slot.clone() };
1682 let delete = Delete { facade, slot };
1683 for (name, description, clause) in [
1684 (
1685 "tasks__update",
1686 update.description(),
1687 "The board is for running work: a landed row with no children and no epic \
1688 above it is DELETED in the same pass (tasks__delete), never left completed; \
1689 a landed row under an epic - open or already terminal - is completed and \
1690 left terminal, because the epic reads `to close` only once every child is \
1691 terminal (a verify=user child parks in waiting on the user and counts only \
1692 once the user approves); and completing the epic's ROOT row closes it and \
1693 archives the whole tree",
1694 ),
1695 (
1696 "tasks__delete",
1697 delete.description(),
1698 "a landed row - its work merged, delivered or concluded - with no children \
1699 and no epic above it is deleted in the same pass, never left completed; a \
1700 landed row under an epic - open or already terminal - is completed and left \
1701 terminal instead, so the epic can read `to close` (a verify=user row parks \
1702 in waiting on the user first)",
1703 ),
1704 ] {
1705 assert!(
1706 description.contains(clause),
1707 "{name} does not state the landing rule ({clause}): {description}",
1708 );
1709 }
1710 let status = update.input_schema()["properties"]["status"]["description"]
1711 .as_str()
1712 .expect("the status property carries a description")
1713 .to_owned();
1714 for clause in [
1715 "`completed` is not a resting state for a landed row with no children and no \
1716 epic above it",
1717 "A landed row under an epic - open or already terminal - stays completed and \
1718 terminal so the epic can read `to close`; a verify=user row parks in waiting on \
1719 the user first",
1720 ] {
1721 assert!(
1722 status.contains(clause),
1723 "the status property owes the rule where the caller picks it ({clause}): \
1724 {status}",
1725 );
1726 }
1727 }
1728
1729 #[test]
1734 fn the_update_description_states_the_waiting_completion_refusal() {
1735 let update = Update { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1736 let description = update.description();
1737 for clause in [
1738 "except a completion on a row whose completion waits on the user",
1739 "the gate would trade that wait for a look park, so the completion is refused by \
1740 name",
1741 "move it off waiting first, then complete it",
1742 ] {
1743 assert!(
1744 description.contains(clause),
1745 "tasks__update does not state the refusal ({clause}): {description}",
1746 );
1747 }
1748 }
1749
1750 #[test]
1754 fn the_update_verify_door_keeps_the_mint_with_the_lead() {
1755 let update = Update { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1756 assert!(
1757 update.description().contains(
1758 "Only the lead mints this gate; a worker hands the ask to its lead instead"
1759 ),
1760 "the update description keeps the gate with the lead: {}",
1761 update.description(),
1762 );
1763 let verify = update.input_schema()["properties"]["verify"]["description"]
1764 .as_str()
1765 .expect("the verify property carries a description")
1766 .to_owned();
1767 assert!(
1768 verify.contains("Can be set here by the lead; a worker asks its lead instead"),
1769 "the verify property keeps the gate with the lead: {verify}",
1770 );
1771 }
1772
1773 #[test]
1779 fn the_create_description_states_the_user_read_mint_never_inherited() {
1780 let create = Create { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1781 let description = create.description();
1782 for clause in [
1783 "A row whose deliverable IS the user's own read or decision - a report the user \
1784 asked to read, a verdict the user asked to give - is minted `verify: \"user\"` \
1785 explicitly: it ends parked on the user, and the board's approve is what completes \
1786 it",
1787 "Only the lead mints this gate; a worker hands the ask to its lead instead",
1788 "an epic's `verify: \"user\"` governs the epic's own completion, and a child never \
1789 inherits it",
1790 "a child that owes no look completes on its own terms",
1791 ] {
1792 assert!(
1793 description.contains(clause),
1794 "tasks__create does not state the mint ({clause}): {description}",
1795 );
1796 }
1797 let verify = create.input_schema()["properties"]["verify"]["description"]
1798 .as_str()
1799 .expect("the verify property carries a description")
1800 .to_owned();
1801 for clause in [
1802 "The gate is minted on the row; a child never inherits its epic's",
1803 "anything else owes no look, even under a `user` epic",
1804 "The gate is the lead's to mint; a worker asks its lead instead",
1805 ] {
1806 assert!(
1807 verify.contains(clause),
1808 "the verify property owes the mint where the caller sets it ({clause}): {verify}",
1809 );
1810 }
1811 }
1812
1813 #[test]
1818 fn the_wait_description_keeps_the_user_decision_mint_with_the_lead() {
1819 let wait = Wait { facade: MockTasksFacade::new().into_arc(), slot: lead_slot() };
1820 for clause in [
1821 "Only the lead mints a user-decision wait",
1822 "a worker that believes the call is the user's hands it to its lead",
1823 "a decision a worker parks outlives its answer and the lead cannot close it afterward",
1824 ] {
1825 assert!(
1826 wait.description().contains(clause),
1827 "tasks__wait does not keep the ask with the lead ({clause}): {}",
1828 wait.description(),
1829 );
1830 }
1831 }
1832
1833 #[test]
1838 fn the_create_and_claim_descriptions_state_the_assign_at_spawn_pattern() {
1839 let facade = MockTasksFacade::new().into_arc();
1840 let slot = lead_slot();
1841 let create = Create { facade: facade.clone(), slot: slot.clone() };
1842 let claim = Claim { facade, slot };
1843 for clause in [
1844 "A lead creating a row for a worker assigns it at creation",
1845 "the kick says not to claim it",
1846 "refuses the worker's `tasks__claim` while the lead is live",
1847 "chased as having no live owner once the board marks it stale",
1848 ] {
1849 assert!(
1850 create.description().contains(clause),
1851 "tasks__create does not state the pattern ({clause}): {}",
1852 create.description(),
1853 );
1854 }
1855 for clause in [
1856 "Claiming is for unassigned rows",
1857 "a row a lead created for you already names you as `owner`",
1858 "work it without claiming",
1859 ] {
1860 assert!(
1861 claim.description().contains(clause),
1862 "tasks__claim does not state the pattern ({clause}): {}",
1863 claim.description(),
1864 );
1865 }
1866 }
1867
1868 #[tokio::test]
1869 async fn list_returns_project_tasks() {
1870 let facade = Arc::new(MockTasksFacade::default());
1871 let out = List { facade, slot: lead_slot() }.call(input(serde_json::json!({}))).await;
1872 assert!(!out.is_error, "list succeeds: {out:?}");
1873 }
1874
1875 fn board_row(task: Task) -> crate::board::BoardRow {
1876 crate::board::BoardRow {
1877 task,
1878 worked_secs: 0,
1879 updated_secs_ago: 0,
1880 marks: crate::board::Marks {
1881 ready: false,
1882 in_review: false,
1883 overdue: false,
1884 no_movement: false,
1885 waiting_too_long: false,
1886 stale: false,
1887 to_close: false,
1888 },
1889 rollup: None,
1890 parent_subject: None,
1891 history: Vec::new(),
1892 history_total: 0,
1893 }
1894 }
1895
1896 #[tokio::test]
1899 async fn the_list_leads_with_a_dated_line() {
1900 let facade = Arc::new(MockTasksFacade::default());
1901 let out = List { facade, slot: lead_slot() }.call(input(serde_json::json!({}))).await;
1902 assert!(!out.is_error, "list succeeds: {out:?}");
1903 let text = text_of(&out);
1904 let first = text.lines().next().expect("a first line");
1905 assert!(first.starts_with("now "), "the list opens with the date: {first}");
1906 assert!(first.contains('T'), "in RFC3339: {first}");
1907 }
1908
1909 #[tokio::test]
1910 async fn a_listed_row_carries_worked_time_and_its_marks() {
1911 let facade = Arc::new(MockTasksFacade::default());
1912 let mut row = board_row(sample_task());
1913 row.worked_secs = 3 * 3_600;
1914 row.updated_secs_ago = 12 * 60;
1915 row.marks.in_review = true;
1916 row.marks.overdue = true;
1917 facade.rows.lock().push(row);
1918 let out = List { facade, slot: lead_slot() }.call(input(serde_json::json!({}))).await;
1919 assert!(!out.is_error, "list succeeds: {out:?}");
1920 let text = text_of(&out);
1921 let body = text.split_once('\n').expect("a header line then the rows").1;
1922 let json: serde_json::Value =
1923 serde_json::from_str(body).expect("the body is the rows array");
1924 assert_eq!(json[0]["worked"], "3h", "worked time in the board's words: {json}");
1925 assert_eq!(json[0]["updated_ago"], "12m");
1926 let marks: Vec<&str> = json[0]["marks"]
1927 .as_array()
1928 .expect("marks array")
1929 .iter()
1930 .filter_map(serde_json::Value::as_str)
1931 .collect();
1932 assert!(marks.contains(&"overdue"), "marks name the facts: {marks:?}");
1933 assert!(marks.contains(&"in_review"), "marks name the facts: {marks:?}");
1934 }
1935
1936 #[tokio::test]
1937 async fn ready_narrows_to_rows_nothing_holds() {
1938 let facade = Arc::new(MockTasksFacade::default());
1939 let mut held = board_row(sample_task());
1940 held.task.owner = Some(SessionSlot::lead("TestOrg", "myproj"));
1941 let mut free = board_row(Task { id: TaskId::from("t-2"), ..sample_task() });
1942 free.marks.ready = true;
1943 facade.rows.lock().extend([held, free]);
1944 let out = List { facade: facade.clone(), slot: lead_slot() }
1945 .call(input(serde_json::json!({ "ready": true })))
1946 .await;
1947 assert!(!out.is_error, "list succeeds: {out:?}");
1948 let text = text_of(&out);
1949 assert!(text.contains("t-2"), "the ready row is listed: {text}");
1950 assert!(!text.contains("t-1"), "the held row is not: {text}");
1951 assert_eq!(facade.listed.lock()[0].3, Some(true), "and the filter was threaded");
1952 }
1953
1954 #[tokio::test]
1955 async fn delete_reports_a_missing_id_rather_than_succeeding_silently() {
1956 let facade = Arc::new(MockTasksFacade::default());
1958 let out = Delete { facade, slot: lead_slot() }
1959 .call(input(serde_json::json!({ "id": "nope" })))
1960 .await;
1961 assert!(out.is_error, "an unknown id is an error, not a no-op");
1962 }
1963
1964 #[tokio::test]
1967 async fn delete_refusals_name_the_case() {
1968 let facade = Arc::new(MockTasksFacade::default());
1969 let tool = Delete { facade: facade.clone(), slot: lead_slot() };
1970
1971 *facade.delete_result.lock() = Some(Err(TasksError::TaskInAnotherProject {
1972 id: "t-9".to_owned(),
1973 project: "otherproj".to_owned(),
1974 }));
1975 let elsewhere = tool.call(input(serde_json::json!({ "id": "t-9" }))).await;
1976 assert!(elsewhere.is_error, "another project's row is refused");
1977 assert_eq!(
1978 text_of(&elsewhere),
1979 "task t-9 exists in project otherproj, not in your project",
1980 "the refusal names the project that owns the row",
1981 );
1982
1983 *facade.delete_result.lock() = Some(Err(TasksError::NoSuchTask { id: "t-9".to_owned() }));
1984 let absent = tool.call(input(serde_json::json!({ "id": "t-9" }))).await;
1985 assert!(absent.is_error, "an absent id is refused");
1986 assert_eq!(
1987 text_of(&absent),
1988 "no task with id t-9 in your project",
1989 "an id no project has must not read as another project's row",
1990 );
1991 }
1992
1993 #[test]
1994 fn tool_names_are_the_task_family() {
1995 let facade = MockTasksFacade::new().into_arc();
2001 let slot = lead_slot();
2002 let create = Create { facade: facade.clone(), slot: slot.clone() };
2003 let update = Update { facade: facade.clone(), slot: slot.clone() };
2004 let list = List { facade: facade.clone(), slot: slot.clone() };
2005 let delete = Delete { facade: facade.clone(), slot: slot.clone() };
2006 let claim = Claim { facade: facade.clone(), slot: slot.clone() };
2007 let wait = Wait { facade, slot };
2008 assert_eq!(create.name(), "tasks__create");
2009 assert_eq!(update.name(), "tasks__update");
2010 assert_eq!(list.name(), "tasks__list");
2011 assert_eq!(delete.name(), "tasks__delete");
2012 assert_eq!(claim.name(), "tasks__claim");
2013 assert_eq!(wait.name(), "tasks__wait");
2014 }
2015
2016 #[tokio::test]
2017 async fn claim_by_epic_threads_the_epic_and_echoes_the_row() {
2018 let facade = Arc::new(MockTasksFacade::default());
2019 let mut task = sample_task();
2020 task.status = TaskStatus::InProgress;
2021 task.owner = Some(SessionSlot::worker("TestOrg", "myproj", "w-1"));
2022 *facade.claim_result.lock() = Some(Ok(task));
2023 let slot = SessionSlot::worker("TestOrg", "myproj", "w-1");
2024 let out = Claim { facade: facade.clone(), slot }
2025 .call(input(serde_json::json!({
2026 "epic": "epic-1"
2027 })))
2028 .await;
2029 assert!(!out.is_error, "claim succeeds: {out:?}");
2030 let calls = facade.claimed.lock();
2031 assert_eq!(calls[0].1, None, "no id carried");
2032 assert_eq!(calls[0].2.as_deref(), Some("epic-1"));
2033 let json: serde_json::Value =
2034 serde_json::from_str(text_of(&out)).expect("the result is the claimed record");
2035 assert_eq!(json["status"], "in_progress");
2036 assert_eq!(json["owner"], "w-1");
2037 }
2038
2039 #[tokio::test]
2040 async fn claim_with_both_id_and_epic_is_refused_without_touching_the_facade() {
2041 let facade = Arc::new(MockTasksFacade::default());
2042 let out = Claim { facade: facade.clone(), slot: lead_slot() }
2043 .call(input(serde_json::json!({ "id": "t-1", "epic": "epic-1" })))
2044 .await;
2045 assert!(out.is_error, "exactly one of id or epic");
2046 let out = Claim { facade: facade.clone(), slot: lead_slot() }
2047 .call(input(serde_json::json!({})))
2048 .await;
2049 assert!(out.is_error, "neither is not one either");
2050 assert!(facade.claimed.lock().is_empty(), "invalid input never reaches the facade");
2051 }
2052
2053 #[tokio::test]
2057 async fn claim_refusals_name_the_case() {
2058 let facade = Arc::new(MockTasksFacade::default());
2059 let tool = Claim { facade: facade.clone(), slot: lead_slot() };
2060
2061 *facade.claim_result.lock() = Some(Err(TasksError::TaskInAnotherProject {
2062 id: "t-9".to_owned(),
2063 project: "otherproj".to_owned(),
2064 }));
2065 let elsewhere = tool.call(input(serde_json::json!({ "id": "t-9" }))).await;
2066 assert!(elsewhere.is_error, "another project's row is refused");
2067 assert_eq!(
2068 text_of(&elsewhere),
2069 "task t-9 exists in project otherproj, not in your project",
2070 "the refusal names the project that owns the row",
2071 );
2072
2073 *facade.claim_result.lock() = Some(Err(TasksError::NoSuchTask { id: "t-9".to_owned() }));
2074 let absent = tool.call(input(serde_json::json!({ "id": "t-9" }))).await;
2075 assert!(absent.is_error, "an absent id is refused");
2076 assert_eq!(
2077 text_of(&absent),
2078 "no task with id t-9 in your project",
2079 "an id no project has must not read as another project's row",
2080 );
2081 }
2082
2083 #[tokio::test]
2086 async fn a_refused_claim_surfaces_the_cores_words() {
2087 let facade = Arc::new(MockTasksFacade::default());
2088 *facade.claim_result.lock() =
2089 Some(Err(TasksError::Refused("w-1 holds this row".to_owned())));
2090 let out = Claim { facade, slot: lead_slot() }
2091 .call(input(serde_json::json!({ "id": "t-1" })))
2092 .await;
2093 assert!(out.is_error, "the claim is refused");
2094 assert!(
2095 text_of(&out).contains("w-1 holds this row"),
2096 "and the refusal names the holder: {:?}",
2097 text_of(&out),
2098 );
2099 }
2100
2101 #[tokio::test]
2104 async fn wait_refusals_name_the_case() {
2105 let facade = Arc::new(MockTasksFacade::default());
2106 let tool = Wait { facade: facade.clone(), slot: lead_slot() };
2107
2108 *facade.wait_result.lock() = Some(Err(TasksError::TaskInAnotherProject {
2109 id: "t-9".to_owned(),
2110 project: "otherproj".to_owned(),
2111 }));
2112 let elsewhere =
2113 tool.call(input(serde_json::json!({ "id": "t-9", "kind": "resource" }))).await;
2114 assert!(elsewhere.is_error, "another project's row is refused");
2115 assert_eq!(
2116 text_of(&elsewhere),
2117 "task t-9 exists in project otherproj, not in your project",
2118 "the refusal names the project that owns the row",
2119 );
2120
2121 *facade.wait_result.lock() = Some(Err(TasksError::NoSuchTask { id: "t-9".to_owned() }));
2122 let absent = tool.call(input(serde_json::json!({ "id": "t-9", "kind": "resource" }))).await;
2123 assert!(absent.is_error, "an absent id is refused");
2124 assert_eq!(
2125 text_of(&absent),
2126 "no task with id t-9 in your project",
2127 "an id no project has must not read as another project's row",
2128 );
2129 }
2130
2131 #[tokio::test]
2132 async fn wait_threads_kind_detail_and_blocker() {
2133 let facade = Arc::new(MockTasksFacade::default());
2134 let out = Wait { facade: facade.clone(), slot: lead_slot() }
2135 .call(input(serde_json::json!({
2136 "id": "t-1",
2137 "kind": "dependency",
2138 "detail": "the board read has to land first",
2139 "on": "t-9",
2140 })))
2141 .await;
2142 assert!(!out.is_error, "wait succeeds: {out:?}");
2143 let calls = facade.waited.lock();
2144 assert_eq!(calls[0].2, WaitingKind::Dependency);
2145 assert_eq!(calls[0].3.as_deref(), Some("the board read has to land first"));
2146 assert_eq!(calls[0].4, Some(TaskId::from("t-9")));
2147 }
2148
2149 #[tokio::test]
2150 async fn a_dependency_wait_without_on_is_refused() {
2151 let facade = Arc::new(MockTasksFacade::default());
2152 let out = Wait { facade: facade.clone(), slot: lead_slot() }
2153 .call(input(serde_json::json!({ "id": "t-1", "kind": "dependency" })))
2154 .await;
2155 assert!(out.is_error, "a dependency wait names its blocker");
2156 assert!(facade.waited.lock().is_empty(), "invalid input never reaches the facade");
2157 }
2158}