Skip to main content

forge_workspace/mcp/tasks/
facade.rs

1//! `TasksFacade` - the seam between the task MCP tools and workspace
2//! state. The production impl ([`ProdTasksFacade`]) resolves the caller's
3//! project and drives the direct `Workspace` task methods; the mock
4//! records calls for tool tests.
5//!
6//! Task-list mutations are direct `Workspace` methods (lock the `tasks`
7//! mutex + persist), NOT Command-bus dispatches - a task is workspace
8//! state, like the cron list beside it, not a session action.
9
10use std::sync::{Arc, Weak};
11use std::time::SystemTime;
12
13use forge_primitives::tasks::{
14    By, Estimate, LinkKind, Task, TaskId, TaskLink, TaskStatus, WaitingKind,
15};
16
17use crate::SessionSlot;
18use crate::mcp::caller_context::caller_context;
19use crate::tasks::{ClaimError, IdError, MoveError};
20use crate::workspace::Workspace;
21
22/// Why a `tasks__*` call failed.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub(crate) enum TasksError {
25    /// The caller couldn't be mapped to a project (transient race, or the
26    /// session ended). Shouldn't happen for a live session.
27    UnknownCallerProject,
28    /// The estimate words do not parse as a duration; named rather than
29    /// stored so a caller hears it instead of losing the value.
30    BadEstimate(String),
31    /// The core refused the move (open children on a close).
32    Refused(String),
33    /// No task in the caller's project carries that id.
34    NoSuchTask { id: String },
35    /// The id names a row another project owns. The last two are the
36    /// cases a refusal keeps apart: a caller told only "no such task"
37    /// takes a typo and another project's row for the same answer.
38    TaskInAnotherProject { id: String, project: String },
39}
40
41/// One link as a caller states it: the kind is derived from the target's
42/// shape when it is not given.
43#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
44pub(crate) struct TaskLinkDraft {
45    #[serde(default)]
46    pub kind: Option<LinkKind>,
47    #[serde(default)]
48    pub label: Option<String>,
49    pub target: String,
50}
51
52impl TaskLinkDraft {
53    fn to_link(&self, at: SystemTime) -> TaskLink {
54        TaskLink {
55            kind: self.kind.unwrap_or_else(|| LinkKind::for_target(&self.target)),
56            label: self.label.clone(),
57            target: self.target.clone(),
58            state: None,
59            added_at: at,
60        }
61    }
62}
63
64/// A task as `tasks__create` states it: the fields the caller supplies,
65/// before the project, the id and the timestamps are stamped on.
66#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
67pub(crate) struct TaskDraft {
68    pub subject: String,
69    #[serde(default)]
70    pub active_form: Option<String>,
71    #[serde(default)]
72    pub detail: Option<String>,
73    #[serde(default)]
74    pub status: Option<TaskStatus>,
75    /// The label of the session holding it, in the caller's project.
76    /// Absent is `unclaimed`.
77    #[serde(default)]
78    pub owner: Option<String>,
79    #[serde(default)]
80    pub parent: Option<String>,
81    #[serde(default)]
82    pub estimate: Option<String>,
83    /// Queue order; lower reads first.
84    #[serde(default)]
85    pub rank: Option<i64>,
86    /// The gate the row is minted with; an epic's is never inherited.
87    #[serde(default)]
88    pub verify: Option<forge_primitives::tasks::Verify>,
89    /// The user reserved this pick: the sweep leaves it for them.
90    #[serde(default)]
91    pub reserved: Option<forge_primitives::tasks::ReservedBy>,
92    #[serde(default)]
93    pub links: Vec<TaskLinkDraft>,
94}
95
96/// The tree a `tasks__delete` removed: the named task as it stood, and how
97/// many descendants went with it.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub(crate) struct RemovedTaskTree {
100    pub task: Task,
101    pub descendants_removed: usize,
102}
103
104/// The fields `tasks__update` may change. An absent field is left alone.
105#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
106pub(crate) struct TaskPatch {
107    #[serde(default)]
108    pub subject: Option<String>,
109    #[serde(default)]
110    pub active_form: Option<String>,
111    #[serde(default)]
112    pub detail: Option<String>,
113    #[serde(default)]
114    pub status: Option<TaskStatus>,
115    #[serde(default)]
116    pub owner: Option<String>,
117    #[serde(default)]
118    pub parent: Option<String>,
119    #[serde(default)]
120    pub estimate: Option<String>,
121    #[serde(default)]
122    pub rank: Option<i64>,
123    #[serde(default)]
124    pub verify: Option<forge_primitives::tasks::Verify>,
125    /// Whether the lead parks this wait by design: the chase leaves it
126    /// alone rather than nudging it. Applies to a row that is waiting.
127    #[serde(default)]
128    pub parked: Option<bool>,
129    /// Links to attach; a target already present on the row is left as it
130    /// is, so the same PR linked twice stays one link.
131    #[serde(default)]
132    pub links_add: Vec<TaskLinkDraft>,
133    /// Links to take off, by exact target.
134    #[serde(default)]
135    pub links_remove: Vec<String>,
136}
137
138impl TaskPatch {
139    /// Apply the stated fields to `task`. `org` and `project` name the
140    /// caller's project, which is where an owner label is resolved;
141    /// `estimate` arrives pre-parsed so an unparseable one is refused
142    /// before any field moves.
143    fn apply(
144        &self,
145        task: &mut Task,
146        org: &str,
147        project: &str,
148        estimate: Option<Estimate>,
149        at: SystemTime,
150    ) {
151        if let Some(subject) = &self.subject {
152            task.subject.clone_from(subject);
153        }
154        if let Some(active_form) = &self.active_form {
155            task.active_form = Some(active_form.clone());
156        }
157        if let Some(detail) = &self.detail {
158            task.detail = Some(detail.clone());
159        }
160        if let Some(status) = self.status {
161            task.status = status;
162        }
163        if let Some(owner) = &self.owner {
164            task.owner = Some(SessionSlot::new(org, project, owner.clone()));
165        }
166        if let Some(parent) = &self.parent {
167            task.parent = Some(TaskId::from(parent.as_str()));
168        }
169        if let Some(rank) = self.rank {
170            task.rank = Some(rank);
171        }
172        if let Some(verify) = self.verify {
173            task.verify = Some(verify);
174        }
175        if let Some(parked) = self.parked
176            && let Some(wait) = task.waiting_on.as_mut()
177        {
178            wait.parked = parked;
179        }
180        for draft in &self.links_add {
181            if !task.links.iter().any(|l| l.target == draft.target) {
182                task.links.push(draft.to_link(at));
183            }
184        }
185        task.links.retain(|l| !self.links_remove.contains(&l.target));
186        if let Some(estimate) = estimate {
187            task.estimate = Some(estimate);
188        }
189    }
190}
191
192/// The refusal for a stated id that names nothing in the caller's
193/// project: an id another project owns is named as such, so a caller
194/// cannot read "no such task" as proof the row was never there.
195fn missing_task_error(ws: &Workspace, project_name: &str, id: &TaskId) -> TasksError {
196    let stated = id.as_str().to_owned();
197    match ws.task_project_outside(project_name, id) {
198        Some(project) => TasksError::TaskInAnotherProject { id: stated, project },
199        None => TasksError::NoSuchTask { id: stated },
200    }
201}
202
203/// The parent a write stores: the row a stated id names when it lands on
204/// one, the words as stated when it names nothing (a child may point at a
205/// row that is gone), and a refusal naming the rows when it names several.
206fn resolve_parent(
207    ws: &Workspace,
208    project_name: &str,
209    stated: Option<&str>,
210) -> Result<Option<TaskId>, TasksError> {
211    let Some(stated) = stated else { return Ok(None) };
212    match ws.resolve_task_id(project_name, &TaskId::from(stated)) {
213        Ok(id) => Ok(Some(id)),
214        Err(IdError::NotFound) => Ok(Some(TaskId::from(stated))),
215        Err(error @ IdError::Ambiguous(_)) => Err(TasksError::Refused(error.to_string())),
216    }
217}
218
219/// The list's narrowing, one definition for the real path and the mock:
220/// `owner` by label, `parent` by id, `status` by state, `ready` by the
221/// board's own mark.
222fn narrow_rows(
223    rows: Vec<crate::board::BoardRow>,
224    owner: Option<&str>,
225    parent: Option<&TaskId>,
226    status: Option<TaskStatus>,
227    ready: Option<bool>,
228) -> Vec<crate::board::BoardRow> {
229    rows.into_iter()
230        .filter(|row| {
231            owner.is_none_or(|label| row.task.owner.as_ref().is_some_and(|o| o.label() == label))
232        })
233        .filter(|row| parent.is_none_or(|id| row.task.parent.as_ref() == Some(id)))
234        .filter(|row| status.is_none_or(|wanted| row.task.status == wanted))
235        .filter(|row| ready.is_none_or(|wanted| row.marks.ready == wanted))
236        .collect()
237}
238
239/// The task tools' view of the workspace. Sync - task-list mutations are
240/// direct state writes with no async handler to await.
241pub(crate) trait TasksFacade: Send + Sync {
242    /// Register a task for the caller's project and return the new
243    /// record, with its id and timestamps stamped.
244    fn create_task(&self, caller: &SessionSlot, draft: TaskDraft) -> Result<Task, TasksError>;
245
246    /// The caller's project's board rows, narrowed by `owner` (a label),
247    /// `parent` (a task id), `status` and `ready` when supplied.
248    fn list_tasks(
249        &self,
250        caller: &SessionSlot,
251        owner: Option<&str>,
252        parent: Option<&TaskId>,
253        status: Option<TaskStatus>,
254        ready: Option<bool>,
255    ) -> Vec<crate::board::BoardRow>;
256
257    /// Apply `patch` to the task `id` in the caller's project and return
258    /// the record as the write left it. A missing id names its case:
259    /// [`TasksError::NoSuchTask`] when the project has no such id,
260    /// [`TasksError::TaskInAnotherProject`] when another project holds it.
261    /// Open to every session in the project.
262    fn update_task(
263        &self,
264        caller: &SessionSlot,
265        id: &TaskId,
266        patch: TaskPatch,
267    ) -> Result<Task, TasksError>;
268
269    /// Remove the task `id` and its descendants within the caller's
270    /// project, returning the named task as it stood and how many
271    /// descendants went with it. A missing id names its case the way
272    /// [`TasksFacade::update_task`] does.
273    fn delete_task(&self, caller: &SessionSlot, id: &TaskId)
274    -> Result<RemovedTaskTree, TasksError>;
275
276    /// Claim `id`, or the first ready row of `epic` by rank, for the
277    /// caller. Backs `tasks__claim`.
278    fn claim_task(
279        &self,
280        caller: &SessionSlot,
281        id: Option<&str>,
282        epic: Option<&str>,
283    ) -> Result<Task, TasksError>;
284
285    /// Put the caller's row `id` into waiting. Backs `tasks__wait`.
286    fn wait_task(
287        &self,
288        caller: &SessionSlot,
289        id: &TaskId,
290        kind: WaitingKind,
291        detail: Option<String>,
292        on: Option<&TaskId>,
293    ) -> Result<Task, TasksError>;
294
295    /// The lead completes a look wait with the approval the user gave,
296    /// quoted. Backs `tasks__approve`; the core refuses a non-lead.
297    fn attest_task(
298        &self,
299        caller: &SessionSlot,
300        id: &TaskId,
301        approval: &str,
302    ) -> Result<Task, TasksError>;
303}
304
305/// Production facade over `Weak<Workspace>` (weak to avoid a cycle with
306/// the MCP server the workspace owns).
307pub(crate) struct ProdTasksFacade {
308    workspace: Weak<Workspace>,
309}
310
311impl ProdTasksFacade {
312    pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn TasksFacade> {
313        Arc::new(Self { workspace: Arc::downgrade(workspace) })
314    }
315}
316
317impl TasksFacade for ProdTasksFacade {
318    fn create_task(&self, caller: &SessionSlot, draft: TaskDraft) -> Result<Task, TasksError> {
319        let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
320        let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
321        let now = SystemTime::now();
322        let estimate = match draft.estimate.as_deref() {
323            Some(words) => Some(
324                Estimate::parse(words).ok_or_else(|| TasksError::BadEstimate(words.to_owned()))?,
325            ),
326            None => None,
327        };
328        let links = draft.links.iter().map(|draft| draft.to_link(now)).collect();
329        let task = Task {
330            id: TaskId::from(uuid::Uuid::new_v4().to_string()),
331            project_name: cx.project_name.clone(),
332            subject: draft.subject,
333            active_form: draft.active_form,
334            detail: draft.detail,
335            status: draft.status.unwrap_or(TaskStatus::Pending),
336            owner: draft
337                .owner
338                .map(|label| SessionSlot::new(&cx.project_org, &cx.project_name, label)),
339            parent: resolve_parent(&ws, &cx.project_name, draft.parent.as_deref())?,
340            waiting_on: None,
341            estimate,
342            rank: draft.rank,
343            verify: draft.verify,
344            attested: None,
345            reserved: draft.reserved,
346            links,
347            attempt: 0,
348            archived_at: None,
349            created_at: now,
350            updated_at: now,
351        };
352        ws.push_task(task.clone());
353        Ok(task)
354    }
355
356    fn list_tasks(
357        &self,
358        caller: &SessionSlot,
359        owner: Option<&str>,
360        parent: Option<&TaskId>,
361        status: Option<TaskStatus>,
362        ready: Option<bool>,
363    ) -> Vec<crate::board::BoardRow> {
364        let Some(ws) = self.workspace.upgrade() else { return Vec::new() };
365        let Some(cx) = caller_context(&ws, caller) else { return Vec::new() };
366        let rows =
367            ws.board_rows(&cx.project_name, SystemTime::now(), crate::board::DEFAULT_STALE_SECS);
368        // The filter names a row the same way an id does. An id naming
369        // nothing, or several, keeps the literal comparison - the empty
370        // answer a filter gives a name that is not there.
371        let parent = parent.map(|stated| {
372            ws.resolve_task_id(&cx.project_name, stated).unwrap_or_else(|_| stated.clone())
373        });
374        narrow_rows(rows, owner, parent.as_ref(), status, ready)
375    }
376
377    fn update_task(
378        &self,
379        caller: &SessionSlot,
380        id: &TaskId,
381        patch: TaskPatch,
382    ) -> Result<Task, TasksError> {
383        let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
384        let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
385        let estimate = match patch.estimate.as_deref() {
386            Some(words) => Some(
387                Estimate::parse(words).ok_or_else(|| TasksError::BadEstimate(words.to_owned()))?,
388            ),
389            None => None,
390        };
391        let mut patch = patch;
392        if let Some(parent) = resolve_parent(&ws, &cx.project_name, patch.parent.as_deref())? {
393            patch.parent = Some(parent.as_str().to_owned());
394        }
395        let at = SystemTime::now();
396        ws.update_task(&cx.project_name, id, By::Seat(caller.clone()), |task| {
397            patch.apply(task, &cx.project_org, &cx.project_name, estimate, at);
398        })
399        .map_err(|refused| TasksError::Refused(refused.to_string()))?
400        .ok_or_else(|| missing_task_error(&ws, &cx.project_name, id))
401    }
402
403    fn delete_task(
404        &self,
405        caller: &SessionSlot,
406        id: &TaskId,
407    ) -> Result<RemovedTaskTree, TasksError> {
408        let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
409        let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
410        // The stated id resolves before anything is removed: the cascade
411        // also collects orphans pointing at the id, so "something was
412        // removed" would be true for an id that never existed.
413        let resolved = match ws.resolve_task_id(&cx.project_name, id) {
414            Ok(resolved) => resolved,
415            Err(IdError::NotFound) => return Err(missing_task_error(&ws, &cx.project_name, id)),
416            Err(error @ IdError::Ambiguous(_)) => {
417                return Err(TasksError::Refused(error.to_string()));
418            }
419        };
420        let removed = ws
421            .remove_task_tree(&cx.project_name, &resolved)
422            .map_err(|refused| TasksError::Refused(refused.to_string()))?;
423        // The tree can be gone by the time the removal runs only if
424        // another seat removed it between the resolve and the lock.
425        let Some(task) = removed.iter().find(|t| t.id == resolved).cloned() else {
426            return Err(missing_task_error(&ws, &cx.project_name, id));
427        };
428        Ok(RemovedTaskTree { task, descendants_removed: removed.len() - 1 })
429    }
430
431    fn claim_task(
432        &self,
433        caller: &SessionSlot,
434        id: Option<&str>,
435        epic: Option<&str>,
436    ) -> Result<Task, TasksError> {
437        let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
438        let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
439        let by_id = id.map(TaskId::from);
440        let in_epic = epic.map(TaskId::from);
441        match ws.claim_task(&cx.project_name, caller, by_id.as_ref(), in_epic.as_ref()) {
442            Ok(task) => Ok(task),
443            Err(ClaimError::NotFound) => {
444                // A claim states one of the two; the core answers
445                // NotReady when it states neither.
446                let stated = by_id.as_ref().or(in_epic.as_ref());
447                Err(match stated {
448                    Some(stated) => missing_task_error(&ws, &cx.project_name, stated),
449                    None => TasksError::Refused(ClaimError::NotFound.to_string()),
450                })
451            }
452            Err(refused) => Err(TasksError::Refused(refused.to_string())),
453        }
454    }
455
456    fn wait_task(
457        &self,
458        caller: &SessionSlot,
459        id: &TaskId,
460        kind: WaitingKind,
461        detail: Option<String>,
462        on: Option<&TaskId>,
463    ) -> Result<Task, TasksError> {
464        let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
465        let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
466        match ws.wait_task(&cx.project_name, id, caller, kind, detail, on) {
467            Ok(task) => Ok(task),
468            Err(MoveError::NotFound) => {
469                // The row resolves first, so a row that is there means
470                // the blocker is what named nothing.
471                let stated = match ws.resolve_task_id(&cx.project_name, id) {
472                    Ok(_) => on,
473                    Err(_) => Some(id),
474                };
475                Err(match stated {
476                    Some(stated) => missing_task_error(&ws, &cx.project_name, stated),
477                    None => TasksError::Refused(MoveError::NotFound.to_string()),
478                })
479            }
480            Err(refused) => Err(TasksError::Refused(refused.to_string())),
481        }
482    }
483
484    fn attest_task(
485        &self,
486        caller: &SessionSlot,
487        id: &TaskId,
488        approval: &str,
489    ) -> Result<Task, TasksError> {
490        let ws = self.workspace.upgrade().ok_or(TasksError::UnknownCallerProject)?;
491        let cx = caller_context(&ws, caller).ok_or(TasksError::UnknownCallerProject)?;
492        match ws.attest_task(&cx.project_name, id, caller, approval) {
493            Ok(Some(task)) => Ok(task),
494            Ok(None) => Err(missing_task_error(&ws, &cx.project_name, id)),
495            Err(refused) => Err(TasksError::Refused(refused.to_string())),
496        }
497    }
498}
499
500/// One recorded `create_task` call: caller, draft, the record returned.
501#[cfg(test)]
502type CreateCall = (SessionSlot, TaskDraft, Task);
503
504/// A canned record for the mock's default result.
505#[cfg(test)]
506fn mock_task(caller: &SessionSlot) -> Task {
507    let now = SystemTime::UNIX_EPOCH;
508    Task {
509        id: TaskId::from("mock-task-id"),
510        project_name: caller.project().to_owned(),
511        subject: "mock".to_owned(),
512        active_form: None,
513        detail: None,
514        status: TaskStatus::Pending,
515        owner: None,
516        parent: None,
517        waiting_on: None,
518        estimate: None,
519        rank: None,
520        verify: None,
521        attested: None,
522        reserved: None,
523        links: Vec::new(),
524        attempt: 0,
525        archived_at: None,
526        created_at: now,
527        updated_at: now,
528    }
529}
530
531/// The filters one `tasks__list` call was made with.
532#[cfg(test)]
533type ListCall = (Option<String>, Option<String>, Option<TaskStatus>, Option<bool>);
534
535/// One `tasks__claim` call: the caller, the epic and the project it asked for.
536#[cfg(test)]
537type ClaimCall = (SessionSlot, Option<String>, Option<String>);
538
539/// One `tasks__wait` call: the caller, the row, the kind, the detail and the row waited on.
540#[cfg(test)]
541type WaitCall = (SessionSlot, TaskId, WaitingKind, Option<String>, Option<TaskId>);
542
543/// Records calls + returns preloaded results so the tool tests can assert
544/// the tool correctly parses args, resolves the caller, and surfaces
545/// facade results/errors - without a real workspace.
546#[cfg(test)]
547#[derive(Default)]
548pub(crate) struct MockTasksFacade {
549    pub created: parking_lot::Mutex<Vec<CreateCall>>,
550    pub rows: parking_lot::Mutex<Vec<crate::board::BoardRow>>,
551    pub listed: parking_lot::Mutex<Vec<ListCall>>,
552    pub updated: parking_lot::Mutex<Vec<(SessionSlot, TaskId, TaskPatch)>>,
553    pub update_result: parking_lot::Mutex<Option<Result<Task, TasksError>>>,
554    pub deleted: parking_lot::Mutex<Vec<(SessionSlot, TaskId)>>,
555    pub delete_result: parking_lot::Mutex<Option<Result<RemovedTaskTree, TasksError>>>,
556    pub claimed: parking_lot::Mutex<Vec<ClaimCall>>,
557    pub claim_result: parking_lot::Mutex<Option<Result<Task, TasksError>>>,
558    pub waited: parking_lot::Mutex<Vec<WaitCall>>,
559    pub wait_result: parking_lot::Mutex<Option<Result<Task, TasksError>>>,
560    pub attested: parking_lot::Mutex<Vec<(SessionSlot, TaskId, String)>>,
561}
562
563#[cfg(test)]
564impl MockTasksFacade {
565    pub(crate) fn new() -> Self {
566        Self::default()
567    }
568    pub(crate) fn into_arc(self) -> Arc<dyn TasksFacade> {
569        Arc::new(self)
570    }
571}
572
573#[cfg(test)]
574impl TasksFacade for MockTasksFacade {
575    fn create_task(&self, caller: &SessionSlot, draft: TaskDraft) -> Result<Task, TasksError> {
576        let now = SystemTime::UNIX_EPOCH;
577        let task = Task {
578            id: TaskId::from("mock-task-id"),
579            project_name: caller.project().to_owned(),
580            subject: draft.subject.clone(),
581            active_form: draft.active_form.clone(),
582            detail: draft.detail.clone(),
583            status: draft.status.unwrap_or(TaskStatus::Pending),
584            owner: draft
585                .owner
586                .clone()
587                .map(|label| SessionSlot::new(caller.org(), caller.project(), label)),
588            // The mock keeps the parent as stated; resolving it to the row
589            // it names is the prod facade's step, so a tool-level test must
590            // read a prefix here as unresolved.
591            parent: draft.parent.as_deref().map(TaskId::from),
592            waiting_on: None,
593            estimate: draft.estimate.as_deref().and_then(Estimate::parse),
594            rank: draft.rank,
595            verify: draft.verify,
596            attested: None,
597            reserved: draft.reserved,
598            links: draft.links.iter().map(|draft| draft.to_link(now)).collect(),
599            attempt: 0,
600            archived_at: None,
601            created_at: now,
602            updated_at: now,
603        };
604        self.created.lock().push((caller.clone(), draft, task.clone()));
605        Ok(task)
606    }
607
608    fn claim_task(
609        &self,
610        caller: &SessionSlot,
611        id: Option<&str>,
612        epic: Option<&str>,
613    ) -> Result<Task, TasksError> {
614        self.claimed.lock().push((caller.clone(), id.map(str::to_owned), epic.map(str::to_owned)));
615        self.claim_result.lock().clone().unwrap_or_else(|| Ok(mock_task(caller)))
616    }
617
618    fn wait_task(
619        &self,
620        caller: &SessionSlot,
621        id: &TaskId,
622        kind: WaitingKind,
623        detail: Option<String>,
624        on: Option<&TaskId>,
625    ) -> Result<Task, TasksError> {
626        self.waited.lock().push((caller.clone(), id.clone(), kind, detail, on.cloned()));
627        self.wait_result.lock().clone().unwrap_or_else(|| Ok(mock_task(caller)))
628    }
629
630    fn attest_task(
631        &self,
632        caller: &SessionSlot,
633        id: &TaskId,
634        approval: &str,
635    ) -> Result<Task, TasksError> {
636        self.attested.lock().push((caller.clone(), id.clone(), approval.to_owned()));
637        let mut task = mock_task(caller);
638        task.attested = Some(forge_primitives::tasks::Attestation {
639            evidence: approval.to_owned(),
640            at: SystemTime::UNIX_EPOCH,
641        });
642        Ok(task)
643    }
644
645    fn list_tasks(
646        &self,
647        _caller: &SessionSlot,
648        owner: Option<&str>,
649        parent: Option<&TaskId>,
650        status: Option<TaskStatus>,
651        ready: Option<bool>,
652    ) -> Vec<crate::board::BoardRow> {
653        self.listed.lock().push((
654            owner.map(str::to_owned),
655            parent.map(|id| id.as_str().to_owned()),
656            status,
657            ready,
658        ));
659        narrow_rows(self.rows.lock().clone(), owner, parent, status, ready)
660    }
661
662    fn update_task(
663        &self,
664        caller: &SessionSlot,
665        id: &TaskId,
666        patch: TaskPatch,
667    ) -> Result<Task, TasksError> {
668        self.updated.lock().push((caller.clone(), id.clone(), patch));
669        self.update_result
670            .lock()
671            .clone()
672            .unwrap_or_else(|| Err(TasksError::NoSuchTask { id: id.as_str().to_owned() }))
673    }
674
675    fn delete_task(
676        &self,
677        caller: &SessionSlot,
678        id: &TaskId,
679    ) -> Result<RemovedTaskTree, TasksError> {
680        self.deleted.lock().push((caller.clone(), id.clone()));
681        self.delete_result
682            .lock()
683            .clone()
684            .unwrap_or_else(|| Err(TasksError::NoSuchTask { id: id.as_str().to_owned() }))
685    }
686}
687
688/// Owner scoping and list filtering over a real `Workspace` (the mock
689/// above can't exercise `caller_context`).
690#[cfg(test)]
691mod prod_facade_tests {
692    use super::*;
693    use crate::WorkerEntry;
694    use forge_primitives::WorkerLiveness;
695
696    fn worker_entry(project: &str, label: &str) -> WorkerEntry {
697        WorkerEntry {
698            label: label.to_owned(),
699            charter: "review".to_owned(),
700            slot: SessionSlot::worker("TestOrg", project, label),
701            session_id: None,
702            status: WorkerLiveness::Running,
703            spawned_at: SystemTime::UNIX_EPOCH,
704            spawned_by: SessionSlot::lead("TestOrg", project),
705            needs_tag: false,
706            is_git_repo_at_spawn: false,
707            cwd: None,
708            diagnostic: None,
709            kick: None,
710        }
711    }
712
713    fn seeded_task(id: &str, subject: &str) -> Task {
714        Task {
715            id: TaskId::from(id),
716            project_name: "myproj".to_owned(),
717            subject: subject.to_owned(),
718            active_form: None,
719            detail: None,
720            status: TaskStatus::Pending,
721            owner: None,
722            parent: None,
723            waiting_on: None,
724            estimate: None,
725            rank: None,
726            verify: None,
727            attested: None,
728            reserved: None,
729            links: Vec::new(),
730            attempt: 0,
731            archived_at: None,
732            created_at: SystemTime::UNIX_EPOCH,
733            updated_at: SystemTime::UNIX_EPOCH,
734        }
735    }
736
737    fn draft(subject: &str, owner: Option<&str>, parent: Option<&str>) -> TaskDraft {
738        TaskDraft {
739            subject: subject.to_owned(),
740            active_form: None,
741            detail: None,
742            status: None,
743            owner: owner.map(str::to_owned),
744            parent: parent.map(str::to_owned),
745            estimate: None,
746            rank: None,
747            verify: None,
748            reserved: None,
749            links: Vec::new(),
750        }
751    }
752
753    fn fixture() -> (Arc<Workspace>, Arc<dyn TasksFacade>, SessionSlot, SessionSlot) {
754        let (ws, _rx) = Workspace::testing_stub();
755        ws.seed_test_project("myproj", "/tmp/b2-myproj");
756        ws.seed_test_project("otherproj", "/tmp/b2-otherproj");
757        let key =
758            ws.list_projects().into_iter().find(|v| v.name == "myproj").expect("seeded view").key;
759        ws.record_connected_session("/tmp/b2-myproj", "lead-uuid", None);
760        ws.insert_live_worker(&key, worker_entry("myproj", "reviewer"));
761        let facade = ProdTasksFacade::from_arc(&ws);
762        (
763            ws,
764            facade,
765            SessionSlot::lead("TestOrg", "myproj"),
766            SessionSlot::worker("TestOrg", "myproj", "reviewer"),
767        )
768    }
769
770    #[test]
771    fn a_draft_is_stamped_with_the_callers_project_and_id() {
772        let (_ws, facade, lead, _worker) = fixture();
773        let task = facade
774            .create_task(&lead, draft("Merge peers and workers", None, None))
775            .expect("create");
776        assert_eq!(task.project_name, "myproj", "stamped with the caller's project");
777        assert!(!task.id.as_str().is_empty(), "the facade mints the id");
778        assert_eq!(task.status, TaskStatus::Pending, "an unstated status is pending");
779        assert_eq!(task.owner, None, "an unstated owner is unclaimed");
780        assert_eq!(task.created_at, task.updated_at, "a fresh task's stamps agree");
781    }
782
783    #[test]
784    fn list_narrows_by_owner_and_parent_within_the_project() {
785        let (_ws, facade, lead, worker) = fixture();
786        let epic = facade.create_task(&lead, draft("epic", None, None)).expect("epic");
787        facade
788            .create_task(&lead, draft("mine", Some("reviewer"), Some(epic.id.as_str())))
789            .expect("child");
790        facade.create_task(&lead, draft("unclaimed", None, None)).expect("sibling");
791
792        assert_eq!(
793            facade.list_tasks(&lead, None, None, None, None).len(),
794            3,
795            "unfiltered is the whole project"
796        );
797        let by_owner = facade.list_tasks(&lead, Some("reviewer"), None, None, None);
798        assert_eq!(by_owner.len(), 1, "owner narrows to that label");
799        assert_eq!(by_owner[0].task.subject, "mine");
800        let by_parent = facade.list_tasks(&lead, None, Some(&epic.id), None, None);
801        assert_eq!(by_parent.len(), 1, "parent narrows to that task's children");
802        assert_eq!(by_parent[0].task.subject, "mine");
803        assert_eq!(
804            facade.list_tasks(&worker, Some("reviewer"), None, None, None).len(),
805            1,
806            "a worker's list is the same project's set",
807        );
808    }
809
810    /// A task whose parent is gone leaves an orphan pointing at an id that
811    /// is not in the store. Deleting that id must report nothing removed,
812    /// not succeed on the orphan's back - and must not collect the orphan
813    /// on the way, which would destroy work under a name nothing owns.
814    #[test]
815    fn delete_reports_nothing_removed_for_an_id_that_was_never_there() {
816        let (_ws, facade, lead, _worker) = fixture();
817        facade
818            .create_task(&lead, draft("orphan", None, Some("ghost")))
819            .expect("a child pointing at a parent that never existed");
820        assert_eq!(
821            facade.delete_task(&lead, &TaskId::from("ghost")),
822            Err(TasksError::NoSuchTask { id: "ghost".to_owned() }),
823            "an id that was never there is not a successful delete, even with orphans pointing \
824             at it",
825        );
826        assert_eq!(
827            facade.list_tasks(&lead, None, None, None, None).len(),
828            1,
829            "the orphan is not collected under a name nothing owns",
830        );
831    }
832
833    /// An id another project owns is a different answer from an id no
834    /// project has. Both reads refuse, but the refusal has to say which
835    /// case it is: a caller told only "no such task" cannot tell a typo
836    /// from a row sitting on another project's board.
837    #[test]
838    fn a_task_in_another_project_is_not_an_unknown_id() {
839        let (ws, facade, lead, _worker) = fixture();
840        let mut elsewhere = seeded_task("other-1", "another project's row");
841        elsewhere.project_name = "otherproj".to_owned();
842        ws.seed_test_task(elsewhere);
843
844        assert_eq!(
845            facade.update_task(
846                &lead,
847                &TaskId::from("other-1"),
848                TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
849            ),
850            Err(TasksError::TaskInAnotherProject {
851                id: "other-1".to_owned(),
852                project: "otherproj".to_owned(),
853            }),
854            "the update names the project that owns the row",
855        );
856        assert_eq!(
857            facade.delete_task(&lead, &TaskId::from("other-1")),
858            Err(TasksError::TaskInAnotherProject {
859                id: "other-1".to_owned(),
860                project: "otherproj".to_owned(),
861            }),
862            "the delete names the project that owns the row",
863        );
864        let rows = ws.tasks_for_project("otherproj");
865        assert_eq!(rows.len(), 1, "the other project's row is still there: {rows:?}");
866        assert_eq!(
867            rows[0].status,
868            TaskStatus::Pending,
869            "and neither refusal touched the other project's row",
870        );
871    }
872
873    /// A full id no project holds reads as absent, not as another
874    /// project's row - and a PREFIX of a foreign id is absent too: only
875    /// the row's own id names the other project, never a leading part of
876    /// it.
877    #[test]
878    fn an_id_no_project_has_reads_as_absent() {
879        let (ws, facade, lead, _worker) = fixture();
880        let mut elsewhere = seeded_task("other-1", "another project's row");
881        elsewhere.project_name = "otherproj".to_owned();
882        ws.seed_test_task(elsewhere);
883
884        for stated in ["other", "nope"] {
885            assert_eq!(
886                facade.update_task(&lead, &TaskId::from(stated), TaskPatch::default()),
887                Err(TasksError::NoSuchTask { id: stated.to_owned() }),
888                "{stated} names no row in this project",
889            );
890            assert_eq!(
891                facade.delete_task(&lead, &TaskId::from(stated)),
892                Err(TasksError::NoSuchTask { id: stated.to_owned() }),
893                "{stated} names no row in this project",
894            );
895        }
896    }
897
898    /// The claim and wait doors answer the same two cases: an id another
899    /// project owns names that project, an id no project has is absent -
900    /// including when the foreign id is the blocker a wait states.
901    #[test]
902    fn claim_and_wait_refusals_name_the_case() {
903        let (ws, facade, lead, _worker) = fixture();
904        let mut elsewhere = seeded_task("other-1", "another project's row");
905        elsewhere.project_name = "otherproj".to_owned();
906        ws.seed_test_task(elsewhere);
907        let own = facade
908            .create_task(&lead, draft("the waiting row", Some("lead"), None))
909            .expect("create");
910
911        let foreign = || TasksError::TaskInAnotherProject {
912            id: "other-1".to_owned(),
913            project: "otherproj".to_owned(),
914        };
915        assert_eq!(
916            facade.claim_task(&lead, Some("other-1"), None),
917            Err(foreign()),
918            "a claim naming another project's row says which project holds it",
919        );
920        assert_eq!(
921            facade.claim_task(&lead, None, Some("other-1")),
922            Err(foreign()),
923            "an epic naming another project's row says the same",
924        );
925        assert_eq!(
926            facade.wait_task(&lead, &TaskId::from("other-1"), WaitingKind::Resource, None, None),
927            Err(foreign()),
928            "the row a wait names is another project's",
929        );
930        assert_eq!(
931            facade.wait_task(
932                &lead,
933                &own.id,
934                WaitingKind::Dependency,
935                None,
936                Some(&TaskId::from("other-1")),
937            ),
938            Err(foreign()),
939            "the blocker a wait names is another project's",
940        );
941
942        assert_eq!(
943            facade.claim_task(&lead, Some("other"), None),
944            Err(TasksError::NoSuchTask { id: "other".to_owned() }),
945            "a prefix of another project's id names no row here",
946        );
947        assert_eq!(
948            facade.claim_task(&lead, Some("nope"), None),
949            Err(TasksError::NoSuchTask { id: "nope".to_owned() }),
950        );
951        assert_eq!(
952            facade.wait_task(&lead, &TaskId::from("nope"), WaitingKind::Resource, None, None),
953            Err(TasksError::NoSuchTask { id: "nope".to_owned() }),
954            "a missing row is absent, not another project's",
955        );
956        assert_eq!(
957            facade.wait_task(
958                &lead,
959                &own.id,
960                WaitingKind::Dependency,
961                None,
962                Some(&TaskId::from("nope")),
963            ),
964            Err(TasksError::NoSuchTask { id: "nope".to_owned() }),
965            "a missing blocker is absent, not another project's",
966        );
967    }
968
969    /// The update hands back the record it wrote, so the tool can echo a
970    /// task rather than an id: the moved field, the row's own words, and
971    /// the stamp the write just set.
972    ///
973    /// **The seeded row carries an epoch `updated_at`**, so a record
974    /// echoed without the write's own stamp reads as a pass on a fresh
975    /// task and fails here.
976    /// The update patch's `parked` door (#2423/C): it marks a waiting
977    /// row's wait as the user's own deferral, and a row that is not waiting
978    /// has no wait to mark, so the patch leaves it alone rather than
979    /// inventing one.
980    #[test]
981    fn an_update_parks_a_wait_and_only_a_wait() {
982        let (ws, facade, lead, _worker) = fixture();
983        let mut waiting = seeded_task("waiting", "a deferral");
984        waiting.status = TaskStatus::Waiting;
985        waiting.waiting_on = Some(forge_primitives::tasks::Waiting {
986            kind: Some(forge_primitives::tasks::WaitingKind::Decision),
987            detail: None,
988            on: None,
989            verification: false,
990            parked: false,
991            since: None,
992        });
993        ws.seed_test_task(waiting);
994        ws.seed_test_task(seeded_task("plain", "a row with no wait"));
995
996        let parked = facade
997            .update_task(
998                &lead,
999                &TaskId::from("waiting"),
1000                TaskPatch { parked: Some(true), ..TaskPatch::default() },
1001            )
1002            .expect("the waiting row is there");
1003        assert!(
1004            parked.waiting_on.as_ref().is_some_and(|wait| wait.parked),
1005            "the wait is the user's by design: {parked:?}",
1006        );
1007
1008        let untouched = facade
1009            .update_task(
1010                &lead,
1011                &TaskId::from("plain"),
1012                TaskPatch { parked: Some(true), ..TaskPatch::default() },
1013            )
1014            .expect("the plain row is there");
1015        assert_eq!(untouched.waiting_on, None, "a row with no wait invents none");
1016    }
1017
1018    #[test]
1019    fn update_returns_the_record_it_wrote() {
1020        let (ws, facade, lead, _worker) = fixture();
1021        // A child row: completing a ROOT would close and archive it, which
1022        // is a different behaviour with its own test.
1023        let mut seeded = seeded_task("t-1", "before");
1024        seeded.parent = Some(TaskId::from("epic"));
1025        ws.seed_test_task(seeded);
1026
1027        let updated = facade
1028            .update_task(
1029                &lead,
1030                &TaskId::from("t-1"),
1031                TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
1032            )
1033            .expect("the task is there to move");
1034
1035        assert_eq!(updated.id, TaskId::from("t-1"), "the echoed record is the task that moved");
1036        assert_eq!(updated.subject, "before", "carrying the row's own words");
1037        assert_eq!(updated.status, TaskStatus::Completed, "and the state it now holds");
1038        assert!(
1039            updated.updated_at > SystemTime::UNIX_EPOCH,
1040            "the write stamps `updated_at`; a record echoed without it keeps the epoch: {:?}",
1041            updated.updated_at,
1042        );
1043        assert_eq!(
1044            ws.tasks_for_project("myproj")[0].status,
1045            TaskStatus::Completed,
1046            "and it is the stored record, not a copy the write left behind",
1047        );
1048    }
1049
1050    /// A stated id that is a unique PREFIX of the row's own uuid - the
1051    /// row's id without its last character - names the row, and a patch
1052    /// carrying `detail` and `links_add` together applies both in the one
1053    /// call. A prefix is the whole recovery the tools promise; an id
1054    /// corrupted inside is not one, and `the_incidents_corrupted_id_names_no_row`
1055    /// holds that side.
1056    #[test]
1057    fn a_stated_prefix_lands_the_combined_update() {
1058        let (ws, facade, lead, _worker) = fixture();
1059        let id = "2b5387e5-24d6-4ae6-a69f-0f1f86f158f7";
1060        ws.seed_test_task(seeded_task(id, "the row"));
1061        let stated = &id[..id.len() - 1];
1062        assert!(
1063            id.starts_with(stated) && stated != id,
1064            "the stated id is the row's own prefix, one character shorter",
1065        );
1066
1067        let updated = facade
1068            .update_task(
1069                &lead,
1070                &TaskId::from(stated),
1071                TaskPatch {
1072                    detail: Some("why".to_owned()),
1073                    links_add: vec![TaskLinkDraft {
1074                        kind: Some(LinkKind::Pr),
1075                        label: Some("PR #2097".to_owned()),
1076                        target: "https://example.invalid/pull/2097".to_owned(),
1077                    }],
1078                    ..TaskPatch::default()
1079                },
1080            )
1081            .expect("a unique prefix of the row's own id still names the row");
1082
1083        assert_eq!(updated.id, TaskId::from(id), "the prefix resolves to the stored id");
1084        assert_eq!(updated.detail.as_deref(), Some("why"), "the detail lands with the link");
1085        assert_eq!(updated.links.len(), 1, "the link lands with the detail");
1086        assert_eq!(updated.links[0].target, "https://example.invalid/pull/2097");
1087    }
1088
1089    /// The id `tasks__update` was actually refused for: the row's uuid
1090    /// with a character dropped from inside it, which is not a prefix of
1091    /// the stored id. No prefix resolution can name that row - the id is
1092    /// corrupted, not short - so the lookup finds nothing and the row
1093    /// keeps its state.
1094    #[test]
1095    fn the_incidents_corrupted_id_names_no_row() {
1096        let (ws, facade, lead, _worker) = fixture();
1097        let id = "2b5387e5-24d6-4ae6-a69f-0f1f86f158f7";
1098        let stated = "2b5387e5-24d6-4ae6-a69f-0f1f86f1587";
1099        ws.seed_test_task(seeded_task(id, "the row"));
1100        assert!(!id.starts_with(stated), "the drop is interior, so the stated id is no prefix");
1101
1102        let updated = facade
1103            .update_task(
1104                &lead,
1105                &TaskId::from(stated),
1106                TaskPatch {
1107                    detail: Some("why".to_owned()),
1108                    links_add: vec![TaskLinkDraft {
1109                        kind: Some(LinkKind::Pr),
1110                        label: Some("PR #2097".to_owned()),
1111                        target: "https://example.invalid/pull/2097".to_owned(),
1112                    }],
1113                    ..TaskPatch::default()
1114                },
1115            )
1116            .expect_err("a corrupted id names no row");
1117        assert_eq!(
1118            updated,
1119            TasksError::NoSuchTask { id: stated.to_owned() },
1120            "an id with a character dropped from inside names no row",
1121        );
1122
1123        let stored = ws.tasks_for_project("myproj");
1124        assert_eq!(stored[0].detail, None, "the row's detail is untouched");
1125        assert!(stored[0].links.is_empty(), "and no link attaches under a corrupted id");
1126    }
1127
1128    /// The delete hands back the tree it removed: the named task as it
1129    /// stood and how many descendants went with it, so the tool can echo
1130    /// both rather than a bare id.
1131    ///
1132    /// **The store is insertion-ordered and the named task is created
1133    /// last of its tree on purpose.** `c` (the delete target) is created
1134    /// after `b`, and `b` is then re-parented under `c`, so the records
1135    /// removed are `[b, c, d]` - an implementation that answers with "the
1136    /// first thing removed" instead of the id the call named reads `b`
1137    /// here, which is reachable through the tool surface alone.
1138    #[test]
1139    fn delete_returns_the_tree_it_removed() {
1140        let (_ws, facade, lead, _worker) = fixture();
1141        facade.create_task(&lead, draft("sibling", None, None)).expect("sibling");
1142        let b = facade.create_task(&lead, draft("b", None, None)).expect("b");
1143        let c = facade.create_task(&lead, draft("c", None, None)).expect("c");
1144        facade.create_task(&lead, draft("d", None, Some(b.id.as_str()))).expect("d, b's child");
1145        facade
1146            .update_task(
1147                &lead,
1148                &b.id,
1149                TaskPatch { parent: Some(c.id.as_str().to_owned()), ..TaskPatch::default() },
1150            )
1151            .expect("b is there to move");
1152
1153        let removed =
1154            facade.delete_task(&lead, &c.id).expect("the task the call named is there to remove");
1155
1156        assert_eq!(
1157            removed.task.id, c.id,
1158            "the record is the task named by the call, not the first record the cascade removed",
1159        );
1160        assert_eq!(removed.task.subject, "c", "as it stood just before removal");
1161        assert_eq!(removed.descendants_removed, 2, "b and d went with it, and the count says so");
1162        assert_eq!(
1163            facade.list_tasks(&lead, None, None, None, None).len(),
1164            1,
1165            "only the sibling survives"
1166        );
1167    }
1168
1169    /// Delete resolves the stated id the way the other verbs do: a unique
1170    /// prefix lands on its row, and a prefix two rows share refuses with
1171    /// both named rather than removing nothing under a "no such row".
1172    #[test]
1173    fn delete_resolves_a_stated_id_by_exact_or_unique_prefix() {
1174        let (ws, facade, lead, _worker) = fixture();
1175        ws.seed_test_task(seeded_task("7ed0aaaa-0000-4000-8000-000000000001", "fix the rail"));
1176        ws.seed_test_task(seeded_task("7ed0bbbb-0000-4000-8000-000000000002", "add tests"));
1177
1178        let said = match facade.delete_task(&lead, &TaskId::from("7ed0")) {
1179            Err(TasksError::Refused(said)) => said,
1180            other => panic!("a refusal naming the rows it matched, got {other:?}"),
1181        };
1182        assert!(
1183            said.contains("7ed0aaaa-0000-4000-8000-000000000001")
1184                && said.contains("7ed0bbbb-0000-4000-8000-000000000002"),
1185            "the refusal names both rows it matched: {said}",
1186        );
1187
1188        let removed = facade
1189            .delete_task(&lead, &TaskId::from("7ed0bb"))
1190            .expect("one row carries that prefix");
1191        assert_eq!(removed.task.id, TaskId::from("7ed0bbbb-0000-4000-8000-000000000002"));
1192        assert_eq!(removed.task.subject, "add tests", "the prefix removed its own row");
1193        assert_eq!(
1194            facade.list_tasks(&lead, None, None, None, None).len(),
1195            1,
1196            "and left the other row alone",
1197        );
1198    }
1199
1200    /// The look gate reaches the delete door (#2251): a tree holding a row
1201    /// parked on the user's look is refused by name, the row survives, and
1202    /// the refusal is the error the caller reads - never an "ok, nothing
1203    /// removed" that would read as the cascade having worked.
1204    #[test]
1205    fn delete_refuses_a_row_waiting_on_the_look() {
1206        let (_ws, facade, lead, _worker) = fixture();
1207        let gated = facade.create_task(&lead, draft("gated", None, None)).expect("gated");
1208        facade
1209            .update_task(
1210                &lead,
1211                &gated.id,
1212                TaskPatch {
1213                    verify: Some(forge_primitives::tasks::Verify::User),
1214                    ..TaskPatch::default()
1215                },
1216            )
1217            .expect("arm the gate");
1218        facade
1219            .update_task(
1220                &lead,
1221                &gated.id,
1222                TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
1223            )
1224            .expect("the completion lands on the look");
1225
1226        let said = match facade.delete_task(&lead, &gated.id) {
1227            Err(TasksError::Refused(said)) => said,
1228            other => panic!("a refusal naming the row, got {other:?}"),
1229        };
1230        assert!(said.contains("gated"), "the refusal names the row: {said}");
1231        assert_eq!(
1232            facade.list_tasks(&lead, None, None, None, None).len(),
1233            1,
1234            "and the row is still there to be looked at",
1235        );
1236    }
1237
1238    /// A parent is an id too, and the store holds the row it names: a
1239    /// child created - or re-parented - under a short form is the epic's
1240    /// child for every literal comparison the board makes, the claim by
1241    /// epic included.
1242    #[test]
1243    fn a_stated_parent_is_stored_as_the_row_it_names() {
1244        let (ws, facade, lead, worker) = fixture();
1245        let epic = "7ed0aaaa-0000-4000-8000-000000000001";
1246        ws.seed_test_task(seeded_task(epic, "the epic"));
1247
1248        let child =
1249            facade.create_task(&lead, draft("the child", None, Some("7ed0aaaa"))).expect("create");
1250        assert_eq!(
1251            child.parent,
1252            Some(TaskId::from(epic)),
1253            "the store holds the row the stated prefix named",
1254        );
1255
1256        let claimed = facade
1257            .claim_task(&worker, None, Some("7ed0aaaa"))
1258            .expect("the epic's own prefix pulls the child it now holds");
1259        assert_eq!(claimed.id, child.id);
1260
1261        let moved = facade
1262            .update_task(
1263                &lead,
1264                &child.id,
1265                TaskPatch { parent: Some("7ed0aaaa".to_owned()), ..TaskPatch::default() },
1266            )
1267            .expect("the child is there");
1268        assert_eq!(moved.parent, Some(TaskId::from(epic)), "the patch resolves it the same way");
1269    }
1270
1271    /// The other half of the rule: a parent naming nothing stays as
1272    /// stated - a child may point at a row that is gone - and one naming
1273    /// several rows refuses with the rows named.
1274    #[test]
1275    fn a_parent_naming_nothing_stays_and_naming_several_refuses() {
1276        let (ws, facade, lead, _worker) = fixture();
1277        ws.seed_test_task(seeded_task("7ed0aaaa-0000-4000-8000-000000000001", "fix the rail"));
1278        ws.seed_test_task(seeded_task("7ed0bbbb-0000-4000-8000-000000000002", "add tests"));
1279
1280        let orphan = facade
1281            .create_task(&lead, draft("orphan", None, Some("ghost")))
1282            .expect("a parent naming nothing is stored as stated");
1283        assert_eq!(orphan.parent, Some(TaskId::from("ghost")));
1284
1285        let said = match facade.create_task(&lead, draft("ambiguous", None, Some("7ed0"))) {
1286            Err(TasksError::Refused(said)) => said,
1287            other => panic!("a parent naming several rows refuses, got {other:?}"),
1288        };
1289        assert!(
1290            said.contains("7ed0aaaa-0000-4000-8000-000000000001")
1291                && said.contains("7ed0bbbb-0000-4000-8000-000000000002"),
1292            "the refusal names both rows it matched: {said}",
1293        );
1294    }
1295
1296    /// The list's parent filter names a row the same way a write does, so
1297    /// narrowing by the short form a caller holds finds the children.
1298    #[test]
1299    fn list_narrows_by_a_stated_parent_prefix() {
1300        let (ws, facade, lead, _worker) = fixture();
1301        let epic = "7ed0aaaa-0000-4000-8000-000000000001";
1302        ws.seed_test_task(seeded_task(epic, "the epic"));
1303        facade.create_task(&lead, draft("child", None, Some(epic))).expect("child");
1304        facade.create_task(&lead, draft("sibling", None, None)).expect("sibling");
1305
1306        let kids = facade.list_tasks(&lead, None, Some(&TaskId::from("7ed0aaaa")), None, None);
1307        assert_eq!(kids.len(), 1, "the prefix names the parent row: {kids:?}");
1308        assert_eq!(kids[0].task.subject, "child");
1309    }
1310
1311    /// The live shape, at the seam the tools call through: a worker hands
1312    /// its verify=user row to the look - the gate lands it waiting - and
1313    /// the lead's completion of it is refused by name, moving the owner
1314    /// along with the status and all. The live echo of `"status":
1315    /// "waiting"` under a success was this refusal wearing a success, with
1316    /// the row's holder a live seat throughout.
1317    #[test]
1318    fn a_lead_completing_a_workers_row_that_waits_on_the_look_is_refused_by_name() {
1319        let (_ws, facade, lead, worker) = fixture();
1320        let task = facade
1321            .create_task(
1322                &worker,
1323                TaskDraft {
1324                    verify: Some(forge_primitives::tasks::Verify::User),
1325                    ..draft("the parked row", Some("reviewer"), Some("epic"))
1326                },
1327            )
1328            .expect("create");
1329        let handed = facade
1330            .update_task(
1331                &worker,
1332                &task.id,
1333                TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
1334            )
1335            .expect("the row is there");
1336        assert_eq!(
1337            handed.status,
1338            TaskStatus::Waiting,
1339            "precondition: the gate hands the worker's row to the look",
1340        );
1341
1342        match facade.update_task(
1343            &lead,
1344            &task.id,
1345            TaskPatch {
1346                owner: Some("lead".to_owned()),
1347                status: Some(TaskStatus::Completed),
1348                ..TaskPatch::default()
1349            },
1350        ) {
1351            Err(TasksError::Refused(said)) => {
1352                assert!(
1353                    said.contains("waiting on the user's look"),
1354                    "the refusal names the look, not another move error's words: {said}",
1355                );
1356            }
1357            other => panic!(
1358                "the lead's completion of a row waiting on the look is refused, got {other:?}"
1359            ),
1360        }
1361    }
1362
1363    /// The withdraw door at the tool's own seam (#2340): a bare cancel of
1364    /// a row parked on the user is refused by name, and the reason a
1365    /// cancel states lands on the row.
1366    #[test]
1367    fn a_cancel_through_the_facade_records_the_reason_and_a_bare_one_is_refused() {
1368        let (_ws, facade, lead, worker) = fixture();
1369        let task = facade
1370            .create_task(
1371                &worker,
1372                TaskDraft {
1373                    verify: Some(forge_primitives::tasks::Verify::User),
1374                    ..draft("the parked row", Some("reviewer"), Some("epic"))
1375                },
1376            )
1377            .expect("create");
1378        facade
1379            .update_task(
1380                &worker,
1381                &task.id,
1382                TaskPatch { status: Some(TaskStatus::Completed), ..TaskPatch::default() },
1383            )
1384            .expect("the row is there");
1385
1386        match facade.update_task(
1387            &lead,
1388            &task.id,
1389            TaskPatch { status: Some(TaskStatus::Canceled), ..TaskPatch::default() },
1390        ) {
1391            Err(TasksError::Refused(said)) => assert!(
1392                said.contains("withdrawing it needs `detail`"),
1393                "the refusal names the door's requirement: {said}",
1394            ),
1395            other => panic!("a bare cancel of a parked row is refused, got {other:?}"),
1396        }
1397
1398        let withdrawn = facade
1399            .update_task(
1400                &lead,
1401                &task.id,
1402                TaskPatch {
1403                    status: Some(TaskStatus::Canceled),
1404                    detail: Some("moot: landed via #2296".to_owned()),
1405                    ..TaskPatch::default()
1406                },
1407            )
1408            .expect("the row is there");
1409        assert_eq!(withdrawn.status, TaskStatus::Canceled, "the withdraw lands");
1410        assert_eq!(withdrawn.waiting_on, None, "the ask leaves the waiting set");
1411        assert_eq!(
1412            withdrawn.detail.as_deref(),
1413            Some("moot: landed via #2296"),
1414            "and the reason lands on the row",
1415        );
1416    }
1417
1418    /// The live call that walked around the gate: clearing a user-bound
1419    /// row's verify while asking to complete it is refused by name, and
1420    /// the row keeps its gate - so the two-call path has no first step.
1421    #[test]
1422    fn a_clear_of_a_user_verify_is_refused_by_name() {
1423        let (ws, facade, lead, worker) = fixture();
1424        let task = facade
1425            .create_task(
1426                &worker,
1427                TaskDraft {
1428                    verify: Some(forge_primitives::tasks::Verify::User),
1429                    ..draft("the gated row", Some("reviewer"), Some("epic"))
1430                },
1431            )
1432            .expect("create");
1433
1434        let refused = facade.update_task(
1435            &lead,
1436            &task.id,
1437            TaskPatch {
1438                status: Some(TaskStatus::Completed),
1439                verify: Some(forge_primitives::tasks::Verify::None),
1440                ..TaskPatch::default()
1441            },
1442        );
1443        match refused {
1444            Err(TasksError::Refused(said)) => assert!(
1445                said.contains("an update cannot take that away"),
1446                "the refusal says the gate is not the update's to clear: {said}",
1447            ),
1448            other => panic!("the clear-and-complete call is refused, got {other:?}"),
1449        }
1450        let stored = ws.tasks_for_project("myproj");
1451        assert_eq!(
1452            stored[0].verify,
1453            Some(forge_primitives::tasks::Verify::User),
1454            "and the row keeps the gate",
1455        );
1456    }
1457
1458    /// An estimate that is not a duration is refused with its own words,
1459    /// and nothing else in the patch moves: the caller hears the mistake
1460    /// instead of the value being dropped on the floor.
1461    #[test]
1462    fn an_unparseable_estimate_is_refused_and_moves_nothing() {
1463        let (ws, facade, lead, _worker) = fixture();
1464        let task = facade.create_task(&lead, draft("before", None, None)).expect("create");
1465        let refused = facade.update_task(
1466            &lead,
1467            &task.id,
1468            TaskPatch {
1469                subject: Some("after".to_owned()),
1470                estimate: Some("soonish".to_owned()),
1471                ..TaskPatch::default()
1472            },
1473        );
1474        assert_eq!(
1475            refused,
1476            Err(TasksError::BadEstimate("soonish".to_owned())),
1477            "the refusal names the words it could not read",
1478        );
1479        assert_eq!(
1480            ws.tasks_for_project("myproj")[0].subject,
1481            "before",
1482            "and no field moved on the refused write",
1483        );
1484    }
1485
1486    #[test]
1487    fn create_refuses_an_estimate_it_cannot_parse() {
1488        let (_ws, facade, lead, _worker) = fixture();
1489        assert_eq!(
1490            facade.create_task(
1491                &lead,
1492                TaskDraft { estimate: Some("tomorrow".to_owned()), ..draft("x", None, None) },
1493            ),
1494            Err(TasksError::BadEstimate("tomorrow".to_owned())),
1495        );
1496    }
1497
1498    /// `ready` narrows to rows something can start - the one filter that
1499    /// leans on the board's derived mark rather than a stored field.
1500    #[test]
1501    fn ready_narrows_out_rows_something_holds() {
1502        let (ws, facade, lead, _worker) = fixture();
1503        let mut held = seeded_task("t-1", "held");
1504        held.owner = Some(SessionSlot::lead("TestOrg", "myproj"));
1505        held.status = TaskStatus::InProgress;
1506        ws.seed_test_task(held);
1507        ws.seed_test_task(seeded_task("t-2", "free"));
1508
1509        let ready = facade.list_tasks(&lead, None, None, None, Some(true));
1510        assert_eq!(ready.len(), 1, "only the free row is ready: {ready:?}");
1511        assert_eq!(ready[0].task.id, TaskId::from("t-2"));
1512        assert!(ready[0].marks.ready, "and it carries the mark it was filtered by");
1513    }
1514
1515    /// Every field `tasks__update` can state moves, not just the two the
1516    /// other tests happen to use.
1517    #[test]
1518    fn a_patch_moves_every_field_it_can_state() {
1519        let (ws, facade, lead, _worker) = fixture();
1520        let task = facade.create_task(&lead, draft("before", None, None)).expect("create");
1521        facade
1522            .update_task(
1523                &lead,
1524                &task.id,
1525                TaskPatch {
1526                    subject: Some("after".to_owned()),
1527                    active_form: Some("doing".to_owned()),
1528                    detail: Some("why".to_owned()),
1529                    status: Some(TaskStatus::Waiting),
1530                    owner: Some("lead".to_owned()),
1531                    parent: Some("epic".to_owned()),
1532                    links_add: vec![TaskLinkDraft {
1533                        kind: None,
1534                        label: None,
1535                        target: "PR #9".to_owned(),
1536                    }],
1537                    estimate: Some("2d".to_owned()),
1538                    rank: Some(3),
1539                    verify: Some(forge_primitives::tasks::Verify::User),
1540                    ..TaskPatch::default()
1541                },
1542            )
1543            .expect("the task is there to move");
1544        let stored = &ws.tasks_for_project("myproj")[0];
1545        assert_eq!(stored.subject, "after");
1546        assert_eq!(stored.active_form.as_deref(), Some("doing"));
1547        assert_eq!(stored.detail.as_deref(), Some("why"));
1548        assert_eq!(stored.status, TaskStatus::Waiting);
1549        assert_eq!(stored.owner.as_ref().map(SessionSlot::label), Some("lead"));
1550        assert_eq!(stored.parent.as_ref().map(TaskId::as_str), Some("epic"));
1551        assert_eq!(
1552            stored.links.first().map(|l| l.target.as_str()),
1553            Some("PR #9"),
1554            "the stated artifact lands as a link",
1555        );
1556        assert_eq!(
1557            stored.estimate.as_ref().map(|e| e.words.as_str()),
1558            Some("2d"),
1559            "and the estimate keeps its words with its seconds parsed",
1560        );
1561        assert_eq!(stored.estimate.as_ref().map(|e| e.secs), Some(172_800));
1562        assert_eq!(stored.rank, Some(3), "rank moves");
1563        assert_eq!(stored.verify, Some(forge_primitives::tasks::Verify::User), "verify moves");
1564
1565        // The same target twice stays one link, and remove takes it off.
1566        facade
1567            .update_task(
1568                &lead,
1569                &task.id,
1570                TaskPatch {
1571                    links_add: vec![TaskLinkDraft {
1572                        kind: None,
1573                        label: None,
1574                        target: "PR #9".to_owned(),
1575                    }],
1576                    ..TaskPatch::default()
1577                },
1578            )
1579            .expect("the task is there");
1580        assert_eq!(
1581            ws.tasks_for_project("myproj")[0].links.len(),
1582            1,
1583            "a re-added target stays one link",
1584        );
1585        facade
1586            .update_task(
1587                &lead,
1588                &task.id,
1589                TaskPatch { links_remove: vec!["PR #9".to_owned()], ..TaskPatch::default() },
1590            )
1591            .expect("the task is there");
1592        assert!(
1593            ws.tasks_for_project("myproj")[0].links.is_empty(),
1594            "remove takes it off by exact target",
1595        );
1596    }
1597
1598    #[test]
1599    fn update_moves_a_task_owned_by_another_session() {
1600        let (ws, facade, lead, worker) = fixture();
1601        // A child row: a completing root closes and archives (own test).
1602        let task =
1603            facade.create_task(&worker, draft("review this", None, Some("epic"))).expect("create");
1604        facade
1605            .update_task(
1606                &lead,
1607                &task.id,
1608                TaskPatch {
1609                    status: Some(TaskStatus::Completed),
1610                    owner: Some("lead".to_owned()),
1611                    ..TaskPatch::default()
1612                },
1613            )
1614            .expect("the lead completes a worker's task");
1615        let stored = ws.tasks_for_project("myproj");
1616        assert_eq!(stored[0].status, TaskStatus::Completed, "the status moved");
1617        assert_eq!(
1618            stored[0].owner.as_ref().map(SessionSlot::label),
1619            Some("lead"),
1620            "the owner moved to the lead's label",
1621        );
1622    }
1623}