Skip to main content

forge_workspace/
tasks.rs

1//! The task cluster on [`Workspace`]: the durable task store accessor
2//! and its mutation helpers, a second `impl` block beside `crons.rs` so
3//! every caller keeps its path.
4//!
5//! The core polices the moves here, so no caller can talk a row into an
6//! impossible state: the verify gate lives in the transition into
7//! completed, closing is completing a root, a claim is one atomic write,
8//! a cancel of a row waiting on the user carries the reason it records,
9//! and every status change lands in the history.
10
11use std::time::SystemTime;
12
13use forge_primitives::tasks::Verify;
14use forge_primitives::tasks::{
15    Attestation, By, ReservedBy, Task, TaskId, TaskStatus, TaskTransition, Waiting, WaitingKind,
16};
17
18use crate::SessionSlot;
19use crate::protocol::RankMove;
20use crate::protocol::SessionUpdate;
21use crate::workspace::Workspace;
22
23/// Why a claim was refused.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub(crate) enum ClaimError {
26    /// No row carries the id (or the epic has nothing ready).
27    NotFound,
28    /// The stated id matched more than one row, each named.
29    Ambiguous(Vec<IdCandidate>),
30    /// The row is held by a live seat, named.
31    OwnedByLiveSeat(String),
32    /// The row is waiting on something, or nothing ready was there to take.
33    NotReady,
34    /// The caller already has a row in progress.
35    AlreadyWorking,
36}
37
38/// Why a move was refused.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub(crate) enum MoveError {
41    /// Completing a root row while its children are open; cancel them
42    /// first to abandon the epic. The children's own SUBJECTS ride it:
43    /// "cancel them first" names nothing a reader can go and do.
44    OpenChildren(Vec<String>),
45    /// The caller does not own the row it is trying to wait.
46    NotOwner,
47    /// No row carries the id.
48    NotFound,
49    /// The stated id matched more than one row, each named.
50    Ambiguous(Vec<IdCandidate>),
51    /// The user's verdict landed on a row that is not waiting on their
52    /// look.
53    NotWaitingVerification,
54    /// The user answered a row that is not waiting on a question.
55    NotWaitingQuestion,
56    /// A completion landed on a row waiting on the user's own look: the
57    /// look is what completes it.
58    WaitingOnLook,
59    /// A completion landed on a row already waiting on something else
60    /// while the row's own completion waits on the user: the gate would
61    /// trade the wait it holds for a look park and leave the status
62    /// reading `waiting`, so the move is refused by name instead. The
63    /// wait's own words ride it.
64    CompletingWhileWaiting(String),
65    /// A patch would clear the look gate a row is bound by: the user's
66    /// own approve is what clears it.
67    ClearingLookGate,
68    /// A cancel landed on a row waiting on the user without the reason
69    /// the withdraw records: the ask leaves with its why on the row,
70    /// never silently.
71    WithdrawNeedsReason,
72    /// A delete would remove a row waiting on the user: the ask is what is
73    /// pending, not the row's life, and the board's answer or approve is
74    /// what settles it. The row's SUBJECT rides it so the refusal says
75    /// which ask.
76    DeletingLook(String),
77    /// Closing a ROOT row would archive a row waiting on the user: removal
78    /// by close is not a way to settle it either. The row's SUBJECT rides
79    /// it, as the delete's does.
80    ClosingLook(String),
81    /// An attestation came from a session that is not the project's lead:
82    /// only the lead may stand behind the user's recorded approval.
83    AttestLeadOnly,
84    /// An attestation stated no quoted approval, so there is nothing to
85    /// stand behind.
86    AttestNeedsEvidence,
87    /// A reopen named a row carrying no lead attestation: there is no
88    /// attested look to strike.
89    NotAttested,
90}
91
92/// Whether a row is parked on the user's own look - the verification wait
93/// the gate lands a user-bound completion on. The completion guard reads
94/// it: only the user's approve completes such a row. A row merely BOUND
95/// by the gate, never parked on it, is work the caller may abandon.
96///
97/// The park belongs to the row's OWN gate (#2423): a verification wait a
98/// row left behind while its gate was inherited from the epic stops
99/// counting, so a seat can complete it rather than being refused by a
100/// look nobody owes.
101fn parked_on_look(task: &Task) -> bool {
102    task.verify == Some(Verify::User)
103        && task.waiting_on.as_ref().is_some_and(|wait| wait.verification)
104}
105
106/// Whether a row presents as waiting on the user: a decision wait or a
107/// verification park. The removal guards and the withdraw door read this
108/// set - an ask on the user leaves by the user's own answer or approve,
109/// or by a reasoned withdraw. The fleet count and the chase's user nudge
110/// derive the same set from the stored fields.
111///
112/// A verification park is the user's only when the row carries its own
113/// gate (#2423): the gate is minted on the row, never inherited from its
114/// epic, so a park an inherited gate left behind is not an ask on the
115/// user and a seat may move the row.
116///
117/// A dependency or resource wait is not this set's, however the rows
118/// around it are gated: the wait is on the task, not the user, and
119/// `verify: user` gates a row's own completion rather than its wait. A
120/// target that parks on the look has not ended, so the chain keeps
121/// waiting, but the waiting row stays the caller's to move or remove
122/// (#2415).
123pub(crate) fn parked_on_user(task: &Task) -> bool {
124    let Some(wait) = task.waiting_on.as_ref() else { return false };
125    if wait.verification {
126        return task.verify == Some(Verify::User);
127    }
128    wait.kind == Some(WaitingKind::Decision)
129}
130
131/// The words a refusal names a wait by: its kind, and the row it waits on
132/// when the wait names one.
133fn wait_words(wait: Option<&Waiting>) -> String {
134    let Some(wait) = wait else { return "a wait".to_owned() };
135    let what = match wait.kind {
136        Some(WaitingKind::Decision) => "a decision",
137        Some(WaitingKind::Dependency) => "a dependency",
138        Some(WaitingKind::Resource) => "a resource",
139        None => "a wait",
140    };
141    match &wait.on {
142        Some(id) => format!("{what} on {}", id.as_str()),
143        None => what.to_owned(),
144    }
145}
146
147/// Append the user's words to a row's detail, keeping what was there. A
148/// blank append writes nothing - an empty detail is no detail, and a
149/// `Some("")` left by a wordless send-back would read as an empty ask.
150fn append_words(task: &mut Task, words: &str) {
151    let words = words.trim();
152    if words.is_empty() {
153        return;
154    }
155    let prior = task.detail.take().unwrap_or_default();
156    task.detail =
157        Some(if prior.trim().is_empty() { words.to_owned() } else { format!("{prior}\n{words}") });
158}
159
160impl std::fmt::Display for ClaimError {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        match self {
163            Self::NotFound => write!(f, "no such row"),
164            Self::Ambiguous(candidates) => write!(f, "{}", ambiguous_words(candidates)),
165            Self::OwnedByLiveSeat(label) => write!(f, "{label} holds this row"),
166            Self::NotReady => write!(f, "nothing ready to claim there"),
167            Self::AlreadyWorking => write!(f, "you already have a row in progress"),
168        }
169    }
170}
171
172impl std::fmt::Display for MoveError {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::OpenChildren(open) => {
176                // The first few by name, the rest counted: an epic with thirty
177                // open children is one line on the board's service line, and a
178                // thirty-subject sentence is not a sentence a reader reads.
179                let named = open.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
180                let rest = open.len().saturating_sub(3);
181                let more = if rest == 0 { String::new() } else { format!(" and {rest} more") };
182                if open.len() == 1 {
183                    write!(
184                        f,
185                        "1 child is still open ({named}); cancel it first to abandon the epic"
186                    )
187                } else {
188                    write!(
189                        f,
190                        "{} children are still open ({named}{more}); cancel them first to abandon the epic",
191                        open.len()
192                    )
193                }
194            }
195            Self::NotOwner => write!(f, "you do not own this row"),
196            Self::NotFound => write!(f, "no such row"),
197            Self::Ambiguous(candidates) => write!(f, "{}", ambiguous_words(candidates)),
198            Self::NotWaitingVerification => write!(f, "this row is not waiting on your look"),
199            Self::NotWaitingQuestion => write!(f, "this row is not waiting on a question"),
200            Self::WaitingOnLook => write!(
201                f,
202                "this row is waiting on the user's look; the board's approve completes it"
203            ),
204            Self::CompletingWhileWaiting(waited) => write!(
205                f,
206                "this row is waiting on {waited}; completing it here would park it on the \
207                 user's look instead of completing it - move it off waiting first (a move to \
208                 in_progress drops the wait), then complete it"
209            ),
210            Self::ClearingLookGate => write!(
211                f,
212                "this row's completion waits on the user's look; an update cannot take that away, \
213                 and the board's approve completes it"
214            ),
215            Self::WithdrawNeedsReason => write!(
216                f,
217                "this row is waiting on the user; withdrawing it needs `detail`, the reason the \
218                 ask is withdrawn, and a cancel stating none - or only what the row already \
219                 says - is refused"
220            ),
221            Self::DeletingLook(subject) => write!(
222                f,
223                "\"{subject}\" is waiting on the user; the board's answer or approve settles it, \
224                 and deleting the row cannot take the ask away"
225            ),
226            Self::ClosingLook(subject) => write!(
227                f,
228                "\"{subject}\" is waiting on the user; the board's answer or approve settles it, \
229                 and closing the epic cannot take the ask away"
230            ),
231            Self::AttestLeadOnly => {
232                write!(f, "only the project lead may attest an approval; this session is a worker")
233            }
234            Self::AttestNeedsEvidence => write!(
235                f,
236                "an attestation needs the approval quoted in `approval`, and it must not be empty"
237            ),
238            Self::NotAttested => {
239                write!(f, "this row carries no lead attestation to reopen")
240            }
241        }
242    }
243}
244
245/// One row an ambiguous id could have named.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub(crate) struct IdCandidate {
248    pub id: TaskId,
249    pub subject: String,
250}
251
252/// Why a stated id did not land on exactly one row.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub(crate) enum IdError {
255    /// No row carries the id, or any id it prefixes.
256    NotFound,
257    /// More than one row's id starts with it, each named.
258    Ambiguous(Vec<IdCandidate>),
259}
260
261/// The rows an ambiguous id matched, as one line: each row's id and what
262/// it is, so a caller picks one without going back to the board.
263fn ambiguous_words(candidates: &[IdCandidate]) -> String {
264    let named = candidates
265        .iter()
266        .map(|candidate| format!("{} ({})", candidate.id.as_str(), candidate.subject))
267        .collect::<Vec<_>>()
268        .join(", ");
269    format!("{} rows match: {named}; use a longer prefix", candidates.len())
270}
271
272impl std::fmt::Display for IdError {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        match self {
275            Self::NotFound => write!(f, "no such row"),
276            Self::Ambiguous(candidates) => write!(f, "{}", ambiguous_words(candidates)),
277        }
278    }
279}
280
281impl From<IdError> for ClaimError {
282    fn from(error: IdError) -> Self {
283        match error {
284            IdError::NotFound => Self::NotFound,
285            IdError::Ambiguous(candidates) => Self::Ambiguous(candidates),
286        }
287    }
288}
289
290impl From<IdError> for MoveError {
291    fn from(error: IdError) -> Self {
292        match error {
293            IdError::NotFound => Self::NotFound,
294            IdError::Ambiguous(candidates) => Self::Ambiguous(candidates),
295        }
296    }
297}
298
299/// Resolve a caller-stated id against a project's live rows: an exact id,
300/// or a unique prefix of one, since a caller is often handed the short
301/// form a person writes.
302fn find_task_index(tasks: &[Task], project_name: &str, stated: &TaskId) -> Result<usize, IdError> {
303    let scoped = || tasks.iter().enumerate().filter(|(_, task)| task.project_name == project_name);
304    if let Some((index, _)) = scoped().find(|(_, task)| task.id == *stated) {
305        return Ok(index);
306    }
307    // The empty string prefixes every id and names no row.
308    if stated.as_str().is_empty() {
309        return Err(IdError::NotFound);
310    }
311    let matched: Vec<usize> = scoped()
312        .filter(|(_, task)| task.id.as_str().starts_with(stated.as_str()))
313        .map(|(index, _)| index)
314        .collect();
315    match matched.as_slice() {
316        [] => Err(IdError::NotFound),
317        [index] => Ok(*index),
318        several => Err(IdError::Ambiguous(
319            several
320                .iter()
321                .map(|index| IdCandidate {
322                    id: tasks[*index].id.clone(),
323                    subject: tasks[*index].subject.clone(),
324                })
325                .collect(),
326        )),
327    }
328}
329
330/// Whether a status is an end: nothing more happens on the row.
331fn is_terminal(status: TaskStatus) -> bool {
332    matches!(status, TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled)
333}
334
335/// The verify a row is bound by: the gate it was MINTED with, or none.
336/// An epic's `verify: user` governs the epic's own completion, never its
337/// children's (#2423) - a child that owes no look is not parked on one
338/// because its epic carries the gate.
339fn bound_verify(row: &Task) -> Verify {
340    row.verify.unwrap_or(Verify::None)
341}
342
343/// The verify the row at `index` is bound by.
344fn effective_verify(tasks: &[Task], index: usize) -> Verify {
345    bound_verify(&tasks[index])
346}
347
348impl Workspace {
349    /// Lock the task list and apply `f`. Every mutation routes through here
350    /// to take the lock, and the mutation site persists what it changed from
351    /// inside its own closure - a row at a time, never the whole in-memory
352    /// set. A boot read that failed leaves that set EMPTY while the db
353    /// handle stays live, so a whole-set write would erase every durable
354    /// row the read never loaded.
355    pub(crate) fn with_tasks_mut<R>(&self, f: impl FnOnce(&mut Vec<Task>) -> R) -> R {
356        let mut tasks = self.tasks.lock();
357        f(&mut tasks)
358    }
359
360    /// Persist one task row, replacing any prior record for its id. Called
361    /// from inside the mutation's lock.
362    fn persist_task(&self, task: &Task) {
363        let guard = self.db.lock();
364        let Some(db) = guard.as_ref() else { return };
365        if let Err(error) = crate::store::tasks::upsert(db, task) {
366            tracing::error!(
367                target: "forge_workspace::tasks",
368                task_id = %task.id.as_str(),
369                %error,
370                "persisting a task to the store failed; it may be lost on restart",
371            );
372        }
373    }
374
375    /// Delete one task row by project and id. Called from inside the
376    /// mutation's lock.
377    fn delete_stored_task(&self, project_name: &str, id: &TaskId) {
378        let guard = self.db.lock();
379        let Some(db) = guard.as_ref() else { return };
380        if let Err(error) = crate::store::tasks::delete(db, project_name, id) {
381            tracing::error!(
382                target: "forge_workspace::tasks",
383                task_id = %id.as_str(),
384                %error,
385                "deleting a task from the store failed; it may come back on restart",
386            );
387        }
388    }
389
390    /// Resolve a caller-stated id to the row it names: an exact id, or a
391    /// unique prefix of one. Backs the delete path, which resolves before
392    /// it removes so an id naming nothing still removes nothing.
393    pub(crate) fn resolve_task_id(
394        &self,
395        project_name: &str,
396        stated: &TaskId,
397    ) -> Result<TaskId, IdError> {
398        let tasks = self.tasks.lock();
399        let index = find_task_index(&tasks, project_name, stated)?;
400        Ok(tasks[index].id.clone())
401    }
402
403    /// The project holding a row whose exact id is `stated`, when that row
404    /// is outside `project_name`. Backs the refusal that keeps an id
405    /// another project owns apart from one no project has; a stated prefix
406    /// resolves only inside the caller's own project, so only a full id is
407    /// classified here.
408    pub(crate) fn task_project_outside(
409        &self,
410        project_name: &str,
411        stated: &TaskId,
412    ) -> Option<String> {
413        let tasks = self.tasks.lock();
414        tasks
415            .iter()
416            .find(|task| task.id == *stated && task.project_name != project_name)
417            .map(|task| task.project_name.clone())
418    }
419
420    /// Append a task and persist, recording its creation in the history.
421    /// Backs `tasks__create`.
422    pub(crate) fn push_task(&self, task: Task) {
423        let project_name = task.project_name.clone();
424        let recorded = task.clone();
425        self.with_tasks_mut(|tasks| {
426            self.persist_task(&task);
427            tasks.push(task);
428        });
429        self.record_transition(&project_name, &recorded.id, None, recorded.status, By::System);
430        self.announce_tasks_changed(&project_name);
431    }
432
433    /// Apply `f` to the task `id` in `project_name`, persist, record the
434    /// transition, and return the record as the write left it (`None`
435    /// when no such task is there). Open to every session in the project.
436    ///
437    /// Rules ride on top of `f`, so no caller can forget them: a
438    /// transition into `completed` on a row minted verify=user lands in
439    /// `waiting` (the verify gate, never inherited from the epic); a
440    /// patch cannot take a user-bound row's look gate away - clearing its
441    /// verify is refused by name; a completion of a row already waiting on
442    /// the user's look is refused by name; a cancel of a row waiting on
443    /// the user is the withdraw door - it records the reason the caller
444    /// states in `detail`, and a cancel without one is refused (#2340);
445    /// a move off `waiting` drops the wait the row was holding, so a
446    /// completion of any other waiting row applies and clears - except on
447    /// a verify=user row, where a seat's completion is refused by name
448    /// while the row waits: the gate would trade its wait for a look park
449    /// and answer with the status unchanged (#2414). The user's own move
450    /// off waiting to completed keeps that path and parks on the look;
451    /// and completing a ROOT row closes it - the whole tree moves to the
452    /// archive, refused while any child is still open or while any row in
453    /// the tree is parked on the user (#2251).
454    pub(crate) fn update_task(
455        &self,
456        project_name: &str,
457        id: &TaskId,
458        by: By,
459        f: impl FnOnce(&mut Task),
460    ) -> Result<Option<Task>, MoveError> {
461        self.move_task(project_name, id, by, true, |task| {
462            f(task);
463            Ok(())
464        })
465    }
466
467    /// The one commit path every task move runs through. `gate` is the
468    /// verify gate: it fires on a transition into `completed`, and the
469    /// user's own approval is the one move that passes it (approving IS
470    /// the verification). `f` may refuse before anything moves.
471    fn move_task(
472        &self,
473        project_name: &str,
474        id: &TaskId,
475        by: By,
476        gate: bool,
477        f: impl FnOnce(&mut Task) -> Result<(), MoveError>,
478    ) -> Result<Option<Task>, MoveError> {
479        let at = SystemTime::now();
480        let outcome = self.with_tasks_mut(|tasks| {
481            let index = match find_task_index(tasks, project_name, id) {
482                Ok(index) => index,
483                // An id naming no row is the caller's own not-found answer;
484                // one naming several is a refusal that names them.
485                Err(IdError::NotFound) => return Ok(None),
486                Err(error) => return Err(error.into()),
487            };
488            let mut next = tasks[index].clone();
489            let prior = next.status;
490            f(&mut next)?;
491
492            // The look gate is the user's to clear: an update that would
493            // clear a row's own gate is refused by name.
494            if gate
495                && effective_verify(tasks, index) == Verify::User
496                && bound_verify(&next) != Verify::User
497            {
498                return Err(MoveError::ClearingLookGate);
499            }
500            // The look is what completes a row waiting on it: a
501            // completion here is refused, naming the look.
502            if next.status == TaskStatus::Completed && parked_on_look(&next) {
503                return Err(MoveError::WaitingOnLook);
504            }
505            // The withdraw door (#2340): a seat cancelling a row waiting
506            // on the user records the reason the same call states in
507            // `detail`, appended to what the row already said - the ask
508            // that goes away keeps its story. A cancel stating nothing is
509            // refused, so the user's pending ask is never dropped silently.
510            if matches!(by, By::Seat(_))
511                && next.status == TaskStatus::Canceled
512                && parked_on_user(&tasks[index])
513            {
514                let reason = next.detail.clone().unwrap_or_default();
515                if reason.trim().is_empty() || next.detail == tasks[index].detail {
516                    return Err(MoveError::WithdrawNeedsReason);
517                }
518                next.detail.clone_from(&tasks[index].detail);
519                append_words(&mut next, &reason);
520            }
521            // A move off `waiting` drops the wait the row was holding.
522            if prior == TaskStatus::Waiting && next.status != TaskStatus::Waiting {
523                next.waiting_on = None;
524            }
525
526            if gate
527                && next.status == TaskStatus::Completed
528                && effective_verify(tasks, index) == Verify::User
529            {
530                // A row already holding another wait would have that wait
531                // traded for a look park, leaving the status reading
532                // `waiting`: the caller sees success and no change.
533                // Refused by name instead (#2414); the user's own moves
534                // keep their path.
535                if matches!(by, By::Seat(_)) && prior == TaskStatus::Waiting {
536                    return Err(MoveError::CompletingWhileWaiting(wait_words(
537                        tasks[index].waiting_on.as_ref(),
538                    )));
539                }
540                next.waiting_on = Some(Waiting {
541                    kind: Some(WaitingKind::Decision),
542                    detail: None,
543                    on: None,
544                    verification: true,
545                    parked: false,
546                    since: Some(at),
547                });
548                next.status = TaskStatus::Waiting;
549            }
550
551            let closing = next.parent.is_none() && next.status == TaskStatus::Completed;
552            let mut removed: Vec<Task> = Vec::new();
553            if closing {
554                let open: Vec<String> = tasks
555                    .iter()
556                    .filter(|t| {
557                        t.project_name == project_name
558                            && t.parent.as_ref() == Some(&next.id)
559                            && !is_terminal(t.status)
560                    })
561                    .map(|t| t.subject.clone())
562                    .collect();
563                if !open.is_empty() {
564                    return Err(MoveError::OpenChildren(open));
565                }
566                next.archived_at = Some(at);
567                // Collect the whole tree, deepest rows included.
568                let mut doomed = vec![next.id.clone()];
569                let mut cursor = 0;
570                while cursor < doomed.len() {
571                    let current = doomed[cursor].clone();
572                    cursor += 1;
573                    for task in tasks.iter() {
574                        if task.project_name == project_name
575                            && task.parent.as_ref() == Some(&current)
576                            && !doomed.contains(&task.id)
577                        {
578                            doomed.push(task.id.clone());
579                        }
580                    }
581                }
582                // The ask a row is waiting on is not the close's to take
583                // either (#2251's class, found reviewing the delete's
584                // guard): the archive would settle it by removal. The
585                // open-children check above sees direct children only, so
586                // this one walks the whole cascade. The moved row is
587                // exempt by id: its stale copy in `tasks` is not what
588                // closes - `next` is, and the gate refuses a parked
589                // completion (the user's own approve clears the wait).
590                if let Some(task) = tasks.iter().find(|t| {
591                    t.project_name == project_name
592                        && t.id != next.id
593                        && doomed.contains(&t.id)
594                        && parked_on_user(t)
595                }) {
596                    return Err(MoveError::ClosingLook(task.subject.clone()));
597                }
598                removed = tasks
599                    .iter()
600                    .filter(|t| t.project_name == project_name && doomed.contains(&t.id))
601                    .cloned()
602                    .collect();
603                for task in &mut removed {
604                    task.archived_at = Some(at);
605                }
606                tasks.retain(|t| !(t.project_name == project_name && doomed.contains(&t.id)));
607            }
608
609            next.updated_at = at;
610            if removed.is_empty() {
611                tasks[index] = next.clone();
612                self.persist_task(&next);
613            } else {
614                if let Some(row) = removed.iter_mut().find(|t| t.id == next.id) {
615                    *row = next.clone();
616                }
617                // The close took the tree out of the live set: each row
618                // leaves the store with it.
619                for task in &removed {
620                    self.delete_stored_task(project_name, &task.id);
621                }
622            }
623            Ok(Some((next, prior, removed)))
624        })?;
625
626        let Some((task, prior, removed)) = outcome else { return Ok(None) };
627        if !removed.is_empty()
628            && let Some(db) = self.db.lock().as_ref()
629            && let Err(error) = crate::store::task_archive::append(db, &removed)
630        {
631            tracing::error!(
632                target: "forge_workspace::tasks",
633                %error,
634                "archiving a closed tree failed; the rows are gone from the live set",
635            );
636        }
637        if prior != task.status {
638            self.record_transition(project_name, &task.id, Some(prior), task.status, by);
639        }
640        if is_terminal(task.status) {
641            self.resolve_wait_on(project_name, &task.id);
642        }
643        self.announce_tasks_changed(project_name);
644        Ok(Some(task))
645    }
646
647    /// The user moves a row's state directly - the drag across the board.
648    /// Any wait the row was holding drops with the move, and the verify
649    /// gate holds for the user's own drag too: a drag into `completed` on a
650    /// verify=user row lands in waiting for a look, not done.
651    pub(crate) fn user_move_task(
652        &self,
653        project_name: &str,
654        id: &TaskId,
655        to: TaskStatus,
656    ) -> Result<Option<Task>, MoveError> {
657        self.update_task(project_name, id, By::User, |task| {
658            task.waiting_on = None;
659            task.status = to;
660        })
661    }
662
663    /// The lead completes a row waiting on the user's look, recording the
664    /// approval the user gave. Only a lead attests, only a look wait is
665    /// the look's to clear (a decision is the user's to answer), and the
666    /// quoted evidence is required. The row completes as the user's own
667    /// approve would, wearing the attestation the board draws.
668    pub(crate) fn attest_task(
669        &self,
670        project_name: &str,
671        id: &TaskId,
672        slot: &SessionSlot,
673        evidence: &str,
674    ) -> Result<Option<Task>, MoveError> {
675        if slot.label() != crate::LEAD_LABEL {
676            return Err(MoveError::AttestLeadOnly);
677        }
678        let quote = evidence.trim();
679        if quote.is_empty() {
680            return Err(MoveError::AttestNeedsEvidence);
681        }
682        let at = SystemTime::now();
683        self.move_task(project_name, id, By::Seat(slot.clone()), false, |task| {
684            if task.status != TaskStatus::Waiting || !parked_on_look(task) {
685                return Err(MoveError::NotWaitingVerification);
686            }
687            task.waiting_on = None;
688            task.verify = Some(Verify::None);
689            task.attested = Some(Attestation { evidence: quote.to_owned(), at });
690            append_words(task, &format!("Lead-attested look, quoted: \"{quote}\""));
691            task.status = TaskStatus::Completed;
692            Ok(())
693        })
694    }
695
696    /// The user reserves a row's pick (or releases it): the sweep leaves a
697    /// reserved row for them rather than dispatching it.
698    pub(crate) fn reserve_task(
699        &self,
700        project_name: &str,
701        id: &TaskId,
702        reserved: bool,
703    ) -> Result<Option<Task>, MoveError> {
704        self.update_task(project_name, id, By::User, |task| {
705            task.reserved = reserved.then_some(ReservedBy::User);
706        })
707    }
708
709    /// The user strikes a lead attestation: the look gate is re-minted
710    /// and the row parks on the user again, recorded in the history.
711    pub(crate) fn reopen_task(
712        &self,
713        project_name: &str,
714        id: &TaskId,
715    ) -> Result<Option<Task>, MoveError> {
716        let at = SystemTime::now();
717        self.move_task(project_name, id, By::User, false, |task| {
718            if task.attested.is_none() {
719                return Err(MoveError::NotAttested);
720            }
721            task.attested = None;
722            task.verify = Some(Verify::User);
723            task.waiting_on = Some(Waiting {
724                kind: Some(WaitingKind::Decision),
725                detail: None,
726                on: None,
727                verification: true,
728                parked: false,
729                since: Some(at),
730            });
731            task.status = TaskStatus::Waiting;
732            append_words(task, "Reopened by the user: the look is owed again.");
733            Ok(())
734        })
735    }
736
737    /// The user's approval of a row waiting on their look: the wait
738    /// clears, the verify gate is satisfied, and the row completes - a
739    /// root closing its tree like any completion.
740    pub(crate) fn approve_task(
741        &self,
742        project_name: &str,
743        id: &TaskId,
744    ) -> Result<Option<Task>, MoveError> {
745        self.move_task(project_name, id, By::User, false, |task| {
746            if task.status != TaskStatus::Waiting
747                || !task.waiting_on.as_ref().is_some_and(|wait| wait.verification)
748            {
749                return Err(MoveError::NotWaitingVerification);
750            }
751            task.waiting_on = None;
752            task.verify = Some(Verify::None);
753            task.status = TaskStatus::Completed;
754            Ok(())
755        })
756    }
757
758    /// The user sends a verified row back: it returns to its owner in
759    /// progress, the words land in the detail, and the attempt ticks.
760    pub(crate) fn send_back_task(
761        &self,
762        project_name: &str,
763        id: &TaskId,
764        words: &str,
765    ) -> Result<Option<Task>, MoveError> {
766        self.move_task(project_name, id, By::User, false, |task| {
767            if task.status != TaskStatus::Waiting
768                || !task.waiting_on.as_ref().is_some_and(|wait| wait.verification)
769            {
770                return Err(MoveError::NotWaitingVerification);
771            }
772            task.waiting_on = None;
773            task.status = TaskStatus::InProgress;
774            task.attempt += 1;
775            append_words(task, words);
776            Ok(())
777        })
778    }
779
780    /// The user answers a row waiting on a question: the row resumes in
781    /// progress with the words in its detail.
782    pub(crate) fn answer_task(
783        &self,
784        project_name: &str,
785        id: &TaskId,
786        words: &str,
787    ) -> Result<Option<Task>, MoveError> {
788        self.move_task(project_name, id, By::User, false, |task| {
789            if task.status != TaskStatus::Waiting
790                || task.waiting_on.as_ref().is_some_and(|wait| wait.verification)
791            {
792                return Err(MoveError::NotWaitingQuestion);
793            }
794            task.waiting_on = None;
795            task.status = TaskStatus::InProgress;
796            append_words(task, words);
797            Ok(())
798        })
799    }
800
801    /// Move a row in its queue: `Top` above everything, `Up` and `Down`
802    /// one place, and `Before` where a drag landed - directly above the row
803    /// it was dropped on, or the end of the queue when it names none. The
804    /// whole queue is renumbered in one write, so the order a reader sees is
805    /// the order the next read returns. The bool says whether the row's
806    /// place changed: a pick-up-and-put-back lands the same position.
807    pub(crate) fn rank_task(
808        &self,
809        project_name: &str,
810        id: &TaskId,
811        to: RankMove,
812    ) -> Result<Option<(Task, bool)>, MoveError> {
813        let at = SystemTime::now();
814        let outcome = self.with_tasks_mut(|tasks| {
815            let index = tasks.iter().position(|t| t.id == *id && t.project_name == project_name)?;
816            // The queue: this project's rows in the order the board reads
817            // them (rank, then creation).
818            let mut queue: Vec<usize> = tasks
819                .iter()
820                .enumerate()
821                .filter(|(_, t)| t.project_name == project_name)
822                .map(|(i, _)| i)
823                .collect();
824            queue.sort_by_key(|i| (tasks[*i].rank.unwrap_or(i64::MAX), tasks[*i].created_at));
825            let position = queue.iter().position(|i| *i == index)?;
826            let moved = queue.remove(position);
827            let target = match to {
828                // A drag's drop position: the row to sit above, looked up in
829                // the queue the removal leaves, so a drag downwards and one
830                // upwards land alike.
831                RankMove::Before(before) => before
832                    .as_deref()
833                    .and_then(|id| queue.iter().position(|i| tasks[*i].id.as_str() == id))
834                    .unwrap_or(queue.len()),
835                RankMove::Top => 0,
836                RankMove::Up => position.saturating_sub(1),
837                // The queue is one shorter for the row that just left it.
838                RankMove::Down => (position + 1).min(queue.len()),
839            };
840            let changed = target != position;
841            queue.insert(target, moved);
842            for (rank, i) in queue.iter().enumerate() {
843                tasks[*i].rank = Some(i64::try_from(rank).unwrap_or(i64::MAX));
844            }
845            tasks[index].updated_at = at;
846            // The queue was renumbered: one write per row it holds.
847            for i in &queue {
848                self.persist_task(&tasks[*i]);
849            }
850            Some((tasks[index].clone(), changed))
851        });
852        if outcome.is_some() {
853            self.announce_tasks_changed(project_name);
854        }
855        Ok(outcome)
856    }
857
858    /// Give a row an owner, or take it back. The returned record carries
859    /// the owner it had before, read under the same lock as the write so a
860    /// concurrent claim cannot slip between the two.
861    pub(crate) fn assign_task(
862        &self,
863        project_name: &str,
864        id: &TaskId,
865        owner: Option<SessionSlot>,
866    ) -> Result<Option<(Task, Option<SessionSlot>)>, MoveError> {
867        let mut prior = None;
868        let moved = self.move_task(project_name, id, By::User, false, |task| {
869            prior.clone_from(&task.owner);
870            task.owner = owner;
871            Ok(())
872        })?;
873        Ok(moved.map(|task| (task, prior)))
874    }
875
876    /// Claim the top of a queue: the row `by_id`, or the first ready row
877    /// of `in_epic` by rank. Owner and in_progress land in one write, the
878    /// attempt ticks, and the claim is refused - by name - for a row a
879    /// live seat holds, a row that is waiting, or a caller already
880    /// working another row.
881    pub(crate) fn claim_task(
882        &self,
883        project_name: &str,
884        slot: &SessionSlot,
885        by_id: Option<&TaskId>,
886        in_epic: Option<&TaskId>,
887    ) -> Result<Task, ClaimError> {
888        let outcome = self.with_tasks_mut(|tasks| {
889            let already = tasks.iter().any(|t| {
890                t.project_name == project_name
891                    && t.status == TaskStatus::InProgress
892                    && t.owner.as_ref().map(SessionSlot::label) == Some(slot.label())
893            });
894            if already {
895                return Err(ClaimError::AlreadyWorking);
896            }
897            let index = match (by_id, in_epic) {
898                (Some(id), _) => find_task_index(tasks, project_name, id)?,
899                (None, Some(epic)) => {
900                    let epic = tasks[find_task_index(tasks, project_name, epic)?].id.clone();
901                    let mut ready: Vec<usize> = tasks
902                        .iter()
903                        .enumerate()
904                        .filter(|(_, t)| {
905                            t.project_name == project_name
906                                && t.parent.as_ref() == Some(&epic)
907                                && t.status == TaskStatus::Pending
908                                && t.waiting_on.is_none()
909                                && t.owner.is_none()
910                                && t.reserved.is_none()
911                        })
912                        .map(|(i, _)| i)
913                        .collect();
914                    ready.sort_by_key(|i| {
915                        (tasks[*i].rank.unwrap_or(i64::MAX), tasks[*i].created_at)
916                    });
917                    ready.first().copied().ok_or(ClaimError::NotReady)?
918                }
919                (None, None) => return Err(ClaimError::NotReady),
920            };
921            let status = tasks[index].status;
922            let waiting = tasks[index].waiting_on.is_some();
923            let reserved = tasks[index].reserved.is_some();
924            if status != TaskStatus::Pending || waiting || reserved {
925                return Err(ClaimError::NotReady);
926            }
927            let holder = tasks[index].owner.clone();
928            if let Some(holder) = holder
929                && holder.label() != slot.label()
930                && self.seat_is_live(&holder)
931            {
932                return Err(ClaimError::OwnedByLiveSeat(holder.label().to_owned()));
933            }
934            let prior = tasks[index].status;
935            tasks[index].owner = Some(slot.clone());
936            tasks[index].status = TaskStatus::InProgress;
937            tasks[index].attempt += 1;
938            tasks[index].updated_at = SystemTime::now();
939            self.persist_task(&tasks[index]);
940            Ok((tasks[index].clone(), prior))
941        });
942        let (task, prior) = outcome?;
943        self.record_transition(
944            project_name,
945            &task.id,
946            Some(prior),
947            task.status,
948            By::Seat(slot.clone()),
949        );
950        self.announce_tasks_changed(project_name);
951        Ok(task)
952    }
953
954    /// Put a row the caller owns into `waiting`, stating what it waits on.
955    pub(crate) fn wait_task(
956        &self,
957        project_name: &str,
958        id: &TaskId,
959        slot: &SessionSlot,
960        kind: WaitingKind,
961        detail: Option<String>,
962        on: Option<&TaskId>,
963    ) -> Result<Task, MoveError> {
964        let at = SystemTime::now();
965        let outcome = self.with_tasks_mut(|tasks| {
966            let index = find_task_index(tasks, project_name, id)?;
967            let owner = tasks[index].owner.as_ref().map(SessionSlot::label).map(str::to_owned);
968            if owner.as_deref() != Some(slot.label()) {
969                return Err(MoveError::NotOwner);
970            }
971            // The blocker resolves the same way, and after the ownership
972            // check: a caller waiting a row that is not its own hears
973            // that, not what its `on` says.
974            let on = match on {
975                Some(stated) => {
976                    Some(tasks[find_task_index(tasks, project_name, stated)?].id.clone())
977                }
978                None => None,
979            };
980            let prior = tasks[index].status;
981            // **A repeated identical wait is the same wait**: keep its start,
982            // so its age and a notification's identity do not move under a
983            // caller re-stating what the row already holds.
984            let same = tasks[index].waiting_on.as_ref().filter(|wait| {
985                wait.kind == Some(kind)
986                    && wait.detail.as_deref() == detail.as_deref()
987                    && wait.on.as_ref() == on.as_ref()
988                    && !wait.verification
989            });
990            let since = same.and_then(|wait| wait.since).unwrap_or(at);
991            tasks[index].waiting_on = Some(Waiting {
992                kind: Some(kind),
993                detail,
994                on,
995                verification: false,
996                parked: false,
997                since: Some(since),
998            });
999            tasks[index].status = TaskStatus::Waiting;
1000            tasks[index].updated_at = at;
1001            self.persist_task(&tasks[index]);
1002            Ok((tasks[index].clone(), prior))
1003        })?;
1004        let (task, prior) = outcome;
1005        if prior != task.status {
1006            self.record_transition(
1007                project_name,
1008                &task.id,
1009                Some(prior),
1010                task.status,
1011                By::Seat(slot.clone()),
1012            );
1013        }
1014        self.announce_tasks_changed(project_name);
1015        Ok(task)
1016    }
1017
1018    /// Clear every wait on `blocker` - any terminal end of it counts, and
1019    /// so does its deletion - and hand the released rows back so the
1020    /// caller can message their owners.
1021    pub(crate) fn resolve_wait_on(&self, project_name: &str, blocker: &TaskId) -> Vec<Task> {
1022        let released = self.with_tasks_mut(|tasks| {
1023            let mut released: Vec<Task> = Vec::new();
1024            for task in tasks.iter_mut() {
1025                if task.project_name != project_name || task.status != TaskStatus::Waiting {
1026                    continue;
1027                }
1028                let waits_on_blocker =
1029                    task.waiting_on.as_ref().and_then(|w| w.on.as_ref()) == Some(blocker);
1030                if !waits_on_blocker {
1031                    continue;
1032                }
1033                task.waiting_on = None;
1034                task.status = TaskStatus::Pending;
1035                task.updated_at = SystemTime::now();
1036                released.push(task.clone());
1037            }
1038            for task in &released {
1039                self.persist_task(task);
1040            }
1041            released
1042        });
1043        for task in &released {
1044            self.record_transition(
1045                project_name,
1046                &task.id,
1047                Some(TaskStatus::Waiting),
1048                TaskStatus::Pending,
1049                By::System,
1050            );
1051        }
1052        if !released.is_empty() {
1053            self.announce_tasks_changed(project_name);
1054        }
1055        released
1056    }
1057
1058    /// Whether a seat has something behind it right now: a registered
1059    /// domain or agent, or a live worker registry row.
1060    pub(crate) fn seat_is_live(&self, slot: &SessionSlot) -> bool {
1061        if self.domain_session_for(slot).is_some() || self.has_agent_for(slot) {
1062            return true;
1063        }
1064        let key = self
1065            .list_projects()
1066            .into_iter()
1067            .find(|view| view.name == slot.project())
1068            .map(|view| view.key);
1069        let Some(key) = key else { return false };
1070        self.list_live_workers(&key).iter().any(|entry| &entry.slot == slot)
1071    }
1072
1073    /// Append one status transition to the durable history.
1074    fn record_transition(
1075        &self,
1076        project_name: &str,
1077        id: &TaskId,
1078        from: Option<TaskStatus>,
1079        to: TaskStatus,
1080        by: By,
1081    ) {
1082        let guard = self.db.lock();
1083        let Some(db) = guard.as_ref() else { return };
1084        let transition = TaskTransition {
1085            task_id: id.clone(),
1086            project_name: project_name.to_owned(),
1087            from,
1088            to,
1089            at: SystemTime::now(),
1090            by,
1091        };
1092        if let Err(error) = crate::store::task_history::append(db, &[transition]) {
1093            tracing::error!(
1094                target: "forge_workspace::tasks",
1095                %error,
1096                "persisting task history failed; a move is missing from the history",
1097            );
1098        }
1099    }
1100
1101    /// Remove the task `id` in `project_name` together with every
1102    /// descendant, persist, and return the records removed (empty when
1103    /// nothing was). Backs `tasks__delete`. The cascade happens here
1104    /// rather than at the call site so a parent can never be deleted out
1105    /// from under children that would then have nobody to close them.
1106    ///
1107    /// A tree holding a row waiting on the user - a verification park or
1108    /// a decision wait - is refused by name (#2251): removal would settle
1109    /// that row by deletion, an ask the update path refuses to unbind,
1110    /// complete, or drop without a reason. The check walks the whole
1111    /// cascade, so deleting the epic above the row is no way around it.
1112    pub(crate) fn remove_task_tree(
1113        &self,
1114        project_name: &str,
1115        id: &TaskId,
1116    ) -> Result<Vec<Task>, MoveError> {
1117        let removed = self.with_tasks_mut(|tasks| {
1118            let mut doomed = vec![id.clone()];
1119            let mut cursor = 0;
1120            while cursor < doomed.len() {
1121                let current = doomed[cursor].clone();
1122                cursor += 1;
1123                for task in tasks.iter() {
1124                    if task.project_name == project_name
1125                        && task.parent.as_ref() == Some(&current)
1126                        && !doomed.contains(&task.id)
1127                    {
1128                        doomed.push(task.id.clone());
1129                    }
1130                }
1131            }
1132            if let Some(task) = tasks.iter().find(|task| {
1133                task.project_name == project_name
1134                    && doomed.contains(&task.id)
1135                    && parked_on_user(task)
1136            }) {
1137                return Err(MoveError::DeletingLook(task.subject.clone()));
1138            }
1139            let mut removed = Vec::new();
1140            tasks.retain(|t| {
1141                let taking = t.project_name == project_name && doomed.contains(&t.id);
1142                if taking {
1143                    removed.push(t.clone());
1144                }
1145                !taking
1146            });
1147            for task in &removed {
1148                self.delete_stored_task(project_name, &task.id);
1149            }
1150            Ok(removed)
1151        })?;
1152        if !removed.is_empty() {
1153            // A removed row can never end, so its waiters are released
1154            // here - the other half of "any terminal end clears the wait".
1155            for task in &removed {
1156                self.resolve_wait_on(project_name, &task.id);
1157            }
1158            self.announce_tasks_changed(project_name);
1159        }
1160        Ok(removed)
1161    }
1162
1163    /// Tell every view the project's task set moved, as the set the write
1164    /// just left. It routes on the project's lead seat, which is the seat a
1165    /// project-scoped section belongs to; a name no project carries has no
1166    /// seat to route on and announces nothing.
1167    pub(crate) fn announce_tasks_changed(&self, project_name: &str) {
1168        let Some(key) = self.lead_slot_for_project(project_name) else {
1169            tracing::debug!(
1170                target: "forge_workspace::tasks",
1171                event_name = "tasks_changed_unroutable",
1172                project = %project_name,
1173                "a task write named a project no project carries; nothing was announced",
1174            );
1175            return;
1176        };
1177        let tasks = self.tasks_for_project(project_name);
1178        let _ = self.update_tx.send(SessionUpdate::TasksChanged { key, tasks });
1179    }
1180
1181    /// The tasks registered for `project_name`. Backs `tasks__list` and
1182    /// the Inspector TASKS snapshot.
1183    pub fn tasks_for_project(&self, project_name: &str) -> Vec<Task> {
1184        self.tasks.lock().iter().filter(|t| t.project_name == project_name).cloned().collect()
1185    }
1186}
1187
1188#[cfg(any(test, feature = "testing"))]
1189impl Workspace {
1190    /// Register a task directly, bypassing the MCP create path.
1191    /// Cross-crate test access to the otherwise `pub(crate)` task store
1192    /// so a view can exercise the Inspector's `refresh_tasks`
1193    /// resolution against a seeded task.
1194    ///
1195    /// The store write without the announcement: a fixture seeds state, it
1196    /// does not perform the write a view is owed a frame for.
1197    pub fn seed_test_task(&self, task: forge_primitives::tasks::Task) {
1198        self.with_tasks_mut(|tasks| {
1199            self.persist_task(&task);
1200            tasks.push(task);
1201        });
1202    }
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207    use super::*;
1208    use crate::SeatUpdate;
1209    use crate::{SessionSlot, SessionUpdate};
1210    use forge_primitives::tasks::{By, Task, TaskId, TaskStatus, Waiting, WaitingKind};
1211    use tempfile::tempdir;
1212
1213    fn install_db(ws: &Workspace, dir: &std::path::Path) {
1214        let db = crate::store::Db::open(&dir.join("db.redb")).expect("open db");
1215        ws.install_db_for_test(db);
1216    }
1217
1218    fn live_worker(ws: &Workspace, project: &str, label: &str) {
1219        let key = ws
1220            .list_projects()
1221            .into_iter()
1222            .find(|view| view.name == project)
1223            .expect("seeded project")
1224            .key
1225            .clone();
1226        ws.insert_live_worker(
1227            &key,
1228            crate::WorkerEntry {
1229                label: label.to_owned(),
1230                charter: "charter".to_owned(),
1231                slot: SessionSlot::worker("TestOrg", project, label),
1232                session_id: None,
1233                status: forge_primitives::WorkerLiveness::Running,
1234                spawned_at: std::time::SystemTime::UNIX_EPOCH,
1235                spawned_by: SessionSlot::lead("TestOrg", project),
1236                needs_tag: false,
1237                is_git_repo_at_spawn: false,
1238                cwd: None,
1239                diagnostic: None,
1240                kick: None,
1241            },
1242        );
1243    }
1244
1245    fn history_of(ws: &Workspace, project: &str) -> Vec<forge_primitives::tasks::TaskTransition> {
1246        let db = ws.db.lock();
1247        crate::store::task_history::list_for_project(db.as_ref().expect("db installed"), project)
1248            .expect("history")
1249    }
1250
1251    fn archived_of(ws: &Workspace, project: &str) -> Vec<Task> {
1252        let db = ws.db.lock();
1253        crate::store::task_archive::list_for_project(db.as_ref().expect("db installed"), project)
1254            .expect("archive")
1255    }
1256
1257    /// The one `TasksChanged` on a test's update stream, as its key and the
1258    /// set it announces.
1259    fn next_tasks_changed(
1260        rx: &mut tokio::sync::mpsc::UnboundedReceiver<SeatUpdate>,
1261    ) -> (SessionSlot, Vec<Task>) {
1262        match rx.try_recv().map(|frame| std::sync::Arc::unwrap_or_clone(frame.update)) {
1263            Ok(SessionUpdate::TasksChanged { key, tasks }) => (key, tasks),
1264            other => panic!("expected a TasksChanged on the stream, got {other:?}"),
1265        }
1266    }
1267
1268    fn subjects(tasks: &[Task]) -> Vec<String> {
1269        tasks.iter().map(|task| task.subject.clone()).collect()
1270    }
1271
1272    fn sample_task(id: &str, project: &str) -> Task {
1273        Task {
1274            id: TaskId::from(id),
1275            project_name: project.to_owned(),
1276            subject: format!("subject {id}"),
1277            active_form: None,
1278            detail: None,
1279            status: TaskStatus::Pending,
1280            owner: None,
1281            parent: None,
1282            waiting_on: None,
1283            estimate: None,
1284            rank: None,
1285            verify: None,
1286            attested: None,
1287            reserved: None,
1288            links: Vec::new(),
1289            attempt: 0,
1290            archived_at: None,
1291            created_at: std::time::SystemTime::UNIX_EPOCH,
1292            updated_at: std::time::SystemTime::UNIX_EPOCH,
1293        }
1294    }
1295
1296    fn sample_task_with_parent(id: &str, parent: Option<&str>, project: &str) -> Task {
1297        Task { parent: parent.map(TaskId::from), ..sample_task(id, project) }
1298    }
1299
1300    /// A row whose subject is its own words, so a refusal naming the rows
1301    /// it matched reads in the assertion that names them too.
1302    fn named_task(id: &str, subject: &str, project: &str) -> Task {
1303        Task { subject: subject.to_owned(), ..sample_task(id, project) }
1304    }
1305
1306    #[test]
1307    fn a_task_can_be_updated_by_a_session_that_does_not_own_it() {
1308        let dir = tempdir().expect("tempdir");
1309        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1310        // A child row: a completing ROOT closes and archives (own test).
1311        let mut seeded = sample_task("t-1", "forge");
1312        seeded.parent = Some(TaskId::from("epic"));
1313        ws.push_task(seeded);
1314        let changed = ws
1315            .update_task(
1316                "forge",
1317                &TaskId::from("t-1"),
1318                By::Seat(SessionSlot::lead("TestOrg", "forge")),
1319                |t| {
1320                    t.status = TaskStatus::Completed;
1321                },
1322            )
1323            .expect("no refusal");
1324        assert!(changed.is_some(), "any session in the project may move a task");
1325        assert_eq!(ws.tasks_for_project("forge")[0].status, TaskStatus::Completed);
1326    }
1327
1328    #[test]
1329    fn tasks_for_project_does_not_leak_across_projects() {
1330        let dir = tempdir().expect("tempdir");
1331        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1332        ws.push_task(sample_task("t-1", "forge"));
1333        assert_eq!(ws.tasks_for_project("forge").len(), 1, "listed for its own project");
1334        assert!(ws.tasks_for_project("other").is_empty(), "scoped by project name");
1335    }
1336
1337    #[test]
1338    fn a_write_persists_to_the_store() {
1339        let dir = tempdir().expect("tempdir");
1340        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1341        let db = crate::store::Db::open(&dir.path().join("db.redb")).expect("open db");
1342        ws.install_db_for_test(db);
1343        ws.push_task(sample_task("t-1", "forge"));
1344        let stored =
1345            crate::store::tasks::list(ws.db.lock().as_ref().expect("db installed")).expect("list");
1346        assert_eq!(stored.len(), 1, "the in-memory set and the store never diverge");
1347    }
1348
1349    /// Deleting is durable, not just in-memory: the tree has to leave the
1350    /// store too, or the next boot reads it back.
1351    ///
1352    /// The two cross-project tasks are what pin the cascade's project
1353    /// guards, and nothing else reaches them: a same-project task outside
1354    /// the tree is never a cascade candidate, and its id is never doomed.
1355    /// One shares the doomed id, so the `retain` would take it without its
1356    /// own project check; the other is a cross-project child of a doomed id
1357    /// whose id collides with a task that has to survive here, so the
1358    /// collection would carry that collision into this project and the
1359    /// retain would then take the survivor.
1360    #[test]
1361    fn deleting_a_task_takes_its_children() {
1362        let dir = tempdir().expect("tempdir");
1363        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1364        let db = crate::store::Db::open(&dir.path().join("db.redb")).expect("open db");
1365        ws.install_db_for_test(db);
1366        ws.push_task(sample_task_with_parent("epic", None, "forge"));
1367        ws.push_task(sample_task_with_parent("sub-a", Some("epic"), "forge"));
1368        ws.push_task(sample_task_with_parent("sub-b", Some("sub-a"), "forge"));
1369        // A task in the SAME project but outside the tree: the cascade must
1370        // take the descendants and nothing else.
1371        ws.push_task(sample_task("sibling", "forge"));
1372        ws.push_task(sample_task("epic", "elsewhere"));
1373        ws.push_task(sample_task_with_parent("sibling", Some("epic"), "elsewhere"));
1374        assert_eq!(
1375            ws.remove_task_tree("forge", &TaskId::from("epic"))
1376                .expect("nothing in the tree waits on a look")
1377                .len(),
1378            3,
1379            "the tree is removed: the epic and its two descendants",
1380        );
1381        let left = ws.tasks_for_project("forge");
1382        assert_eq!(
1383            left.iter().map(|t| t.id.clone()).collect::<Vec<_>>(),
1384            vec![TaskId::from("sibling")],
1385            "children and grandchildren go with the parent, and nothing else does",
1386        );
1387        let stored =
1388            crate::store::tasks::list(ws.db.lock().as_ref().expect("db installed")).expect("list");
1389        assert!(
1390            stored.iter().all(|t| t.project_name != "forge" || t.id == TaskId::from("sibling")),
1391            "the deletion reached the store, not only the in-memory set: {stored:?}",
1392        );
1393        assert_eq!(
1394            ws.tasks_for_project("elsewhere").len(),
1395            2,
1396            "another project keeps both of its tasks, one of them sharing a doomed id",
1397        );
1398    }
1399
1400    /// The failed-boot-read state for the task store: the store holds
1401    /// durable rows, the in-memory set is EMPTY (a `list` failure at boot
1402    /// warned and started with none), and the db handle is live. Every
1403    /// task mutation writes only what it changed - a door that changed
1404    /// nothing writes nothing, and a door that changed something writes
1405    /// its own row - so no write after such a boot can erase the rows the
1406    /// failed read never loaded.
1407    ///
1408    /// Both kinds of door are walked on purpose: a whole-set write that
1409    /// only fires on a successful mutation would slip past a pin that ran
1410    /// the no-op doors alone.
1411    ///
1412    /// Mutant: have any mutation site (or the shared mutation helper) write
1413    /// the whole in-memory set, at a door that changed nothing or one that
1414    /// changed something, where either write takes both seeded rows with it.
1415    #[test]
1416    fn task_mutations_over_a_failed_boot_read_do_not_erase_the_store() {
1417        let dir = tempdir().expect("tempdir");
1418        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1419        ws.seed_test_project("proj", "/tmp/tp-tasks-failed-load");
1420        let db = crate::store::Db::open(&dir.path().join("db.redb")).expect("open db");
1421        // The rows the failed boot read left out of memory: in the store only.
1422        crate::store::tasks::upsert(&db, &sample_task("seed-1", "proj")).expect("seed 1");
1423        crate::store::tasks::upsert(&db, &sample_task("seed-2", "proj")).expect("seed 2");
1424        ws.install_db_for_test(db);
1425        let persisted = || {
1426            crate::store::tasks::list(ws.db.lock().as_ref().expect("db installed")).expect("list")
1427        };
1428        let seeded =
1429            |rows: &[Task]| rows.iter().filter(|t| t.id.as_str().starts_with("seed-")).count();
1430        // The one assertion every door below makes: it wrote its own row at
1431        // most, never the whole in-memory set.
1432        let still_seeded = |door: &str| {
1433            assert_eq!(
1434                seeded(&persisted()),
1435                2,
1436                "after {door} the rows the failed read never loaded must still be there",
1437            );
1438        };
1439        let lead = SessionSlot::lead("TestOrg", "proj");
1440        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
1441        let other = SessionSlot::worker("TestOrg", "proj", "w-2");
1442
1443        ws.push_task(sample_task("created", "proj"));
1444        still_seeded("a create");
1445
1446        // Doors that change nothing: each names a row the failed read never
1447        // loaded, so nothing moves and nothing may be written.
1448        assert!(
1449            ws.update_task("proj", &TaskId::from("seed-1"), By::Seat(lead.clone()), |task| {
1450                task.status = TaskStatus::Completed;
1451            })
1452            .expect("no refusal")
1453            .is_none(),
1454            "precondition: the empty in-memory set holds no such row",
1455        );
1456        still_seeded("an update naming no loaded row");
1457
1458        assert_eq!(
1459            ws.claim_task("proj", &worker, Some(&TaskId::from("seed-1")), None),
1460            Err(ClaimError::NotFound),
1461            "precondition: the empty in-memory set holds no such row to claim",
1462        );
1463        still_seeded("a refused claim");
1464
1465        assert_eq!(
1466            ws.wait_task(
1467                "proj",
1468                &TaskId::from("seed-1"),
1469                &worker,
1470                WaitingKind::Dependency,
1471                None,
1472                None
1473            ),
1474            Err(MoveError::NotFound),
1475            "precondition: the empty in-memory set holds no such row to wait",
1476        );
1477        still_seeded("a refused wait");
1478
1479        assert!(
1480            ws.rank_task("proj", &TaskId::from("seed-1"), RankMove::Top)
1481                .expect("no refusal")
1482                .is_none(),
1483            "precondition: the empty in-memory set holds no such row to rank",
1484        );
1485        still_seeded("a rank naming no loaded row");
1486
1487        assert!(
1488            ws.remove_task_tree("proj", &TaskId::from("seed-1"))
1489                .expect("nothing in the tree waits on a look")
1490                .is_empty(),
1491            "precondition: the empty in-memory set holds no such tree",
1492        );
1493        still_seeded("a delete that took nothing");
1494
1495        assert!(
1496            ws.resolve_wait_on("proj", &TaskId::from("seed-1")).is_empty(),
1497            "precondition: no loaded row waits on it",
1498        );
1499        still_seeded("a wait-release sweep over an unloaded row");
1500
1501        // Doors that DO change something, each on a loaded row: a whole-set
1502        // write gated behind the change takes the unloaded rows with it.
1503        ws.claim_task("proj", &worker, Some(&TaskId::from("created")), None)
1504            .expect("the loaded row is free");
1505        still_seeded("a claim of a loaded row");
1506
1507        ws.wait_task(
1508            "proj",
1509            &TaskId::from("created"),
1510            &worker,
1511            WaitingKind::Dependency,
1512            None,
1513            None,
1514        )
1515        .expect("the owner may wait its own row");
1516        still_seeded("a wait on a loaded row");
1517
1518        ws.rank_task("proj", &TaskId::from("created"), RankMove::Top)
1519            .expect("no refusal")
1520            .expect("the loaded row is there");
1521        still_seeded("a rank of a loaded row");
1522
1523        // The move's two write branches: an update that keeps the row live
1524        // (a child), and one that closes a root and takes its tree with it.
1525        ws.push_task(sample_task_with_parent("child", Some("ghost-epic"), "proj"));
1526        ws.update_task("proj", &TaskId::from("child"), By::Seat(lead.clone()), |task| {
1527            task.status = TaskStatus::Completed;
1528        })
1529        .expect("no refusal")
1530        .expect("the loaded row is there");
1531        still_seeded("an update of a loaded row");
1532
1533        ws.push_task(sample_task("root", "proj"));
1534        ws.update_task("proj", &TaskId::from("root"), By::Seat(lead.clone()), |task| {
1535            task.status = TaskStatus::Completed;
1536        })
1537        .expect("no refusal")
1538        .expect("the loaded row is there");
1539        still_seeded("a completed root closing its tree");
1540
1541        // A released wait: the sweep writes each row it hands back.
1542        ws.push_task(sample_task("blocker", "proj"));
1543        ws.push_task(sample_task("waiter", "proj"));
1544        ws.claim_task("proj", &other, Some(&TaskId::from("waiter")), None)
1545            .expect("the loaded row is free");
1546        ws.wait_task(
1547            "proj",
1548            &TaskId::from("waiter"),
1549            &other,
1550            WaitingKind::Dependency,
1551            None,
1552            Some(&TaskId::from("blocker")),
1553        )
1554        .expect("the owner may wait its own row");
1555        ws.update_task("proj", &TaskId::from("blocker"), By::Seat(lead.clone()), |task| {
1556            task.status = TaskStatus::Canceled;
1557        })
1558        .expect("no refusal")
1559        .expect("the loaded row is there");
1560        still_seeded("a blocker ending that releases a wait");
1561
1562        ws.push_task(sample_task_with_parent("tree-root", None, "proj"));
1563        ws.push_task(sample_task_with_parent("tree-child", Some("tree-root"), "proj"));
1564        assert_eq!(
1565            ws.remove_task_tree("proj", &TaskId::from("tree-root"))
1566                .expect("nothing in the tree waits on a look")
1567                .len(),
1568            2,
1569            "precondition: both loaded rows are removed",
1570        );
1571        still_seeded("a delete of a loaded tree");
1572    }
1573
1574    /// Every task write announces the project's set on the project's lead
1575    /// seat, so the inspector's TASKS section pops on the frame that moved it
1576    /// rather than on the next unrelated read.
1577    ///
1578    /// The three writers are pinned together because they are one section's
1579    /// three doors. Mutants: drop any one emission (the drain is empty), or
1580    /// announce a set taken before the write (the announced set disagrees
1581    /// with the store).
1582    #[test]
1583    fn every_task_write_announces_the_projects_set() {
1584        let dir = tempdir().expect("tempdir");
1585        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1586        ws.seed_test_project("proj", "/tmp/tp-tasks");
1587        let lead = SessionSlot::lead("TestOrg", "proj");
1588
1589        // A child row: completing a ROOT would close and archive the tree,
1590        // which is its own test; this one is about the announcements.
1591        let mut seeded = sample_task("t-1", "proj");
1592        seeded.parent = Some(TaskId::from("epic"));
1593        ws.push_task(seeded);
1594        let (key, tasks) = next_tasks_changed(&mut rx);
1595        assert_eq!(key, lead, "a create routes on the project's lead seat");
1596        assert_eq!(subjects(&tasks), ["subject t-1"], "and carries the set the core now holds");
1597
1598        assert!(
1599            ws.update_task(
1600                "proj",
1601                &TaskId::from("t-1"),
1602                By::Seat(SessionSlot::lead("TestOrg", "proj")),
1603                |task| {
1604                    task.status = TaskStatus::Completed;
1605                }
1606            )
1607            .expect("no refusal")
1608            .is_some()
1609        );
1610        let (key, tasks) = next_tasks_changed(&mut rx);
1611        assert_eq!(key, lead, "an update routes the same way");
1612        assert_eq!(tasks.len(), 1, "and announces the whole set");
1613        assert_eq!(tasks[0].status, TaskStatus::Completed, "the write's own change included");
1614
1615        assert!(
1616            !ws.remove_task_tree("proj", &TaskId::from("t-1"))
1617                .expect("nothing in the tree waits on a look")
1618                .is_empty()
1619        );
1620        let (_, tasks) = next_tasks_changed(&mut rx);
1621        assert!(tasks.is_empty(), "a delete announces the set it left behind");
1622    }
1623
1624    /// A write that changed nothing emits nothing: an update for an id no
1625    /// task carries and a delete of one nothing holds are both refusals, and
1626    /// an announcement would be news about a write that never happened.
1627    ///
1628    /// Mutant: announce unconditionally, where the refused pair is news the
1629    /// section never moved for.
1630    #[test]
1631    fn a_task_write_that_changed_nothing_announces_nothing() {
1632        let dir = tempdir().expect("tempdir");
1633        let (ws, mut rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1634        ws.seed_test_project("proj", "/tmp/tp-tasks-noop");
1635
1636        assert!(
1637            ws.update_task(
1638                "proj",
1639                &TaskId::from("ghost"),
1640                By::Seat(SessionSlot::lead("TestOrg", "proj")),
1641                |task| {
1642                    task.status = TaskStatus::Completed;
1643                }
1644            )
1645            .expect("no refusal")
1646            .is_none(),
1647            "precondition: no task carries the id, so the update is refused",
1648        );
1649        assert!(
1650            ws.remove_task_tree("proj", &TaskId::from("ghost"))
1651                .expect("nothing in the tree waits on a look")
1652                .is_empty(),
1653            "precondition: nothing holds the id, so the delete is refused",
1654        );
1655
1656        let announced = rx.try_recv();
1657        assert!(
1658            announced.is_err(),
1659            "a refused update and a refused delete announce nothing, and this was announced: \
1660             {announced:?}",
1661        );
1662    }
1663
1664    /// A row waiting on the user's look, reached the way the gate produces
1665    /// one: a verify=user row completing.
1666    fn row_awaiting_look(ws: &Workspace, id: &str) {
1667        ws.push_task(Task { verify: Some(Verify::User), ..sample_task(id, "proj") });
1668        ws.update_task("proj", &TaskId::from(id), By::System, |task| {
1669            task.status = TaskStatus::Completed;
1670        })
1671        .expect("no refusal")
1672        .expect("the row is there");
1673    }
1674
1675    #[test]
1676    fn a_user_verdict_completes_with_by_user_in_the_history() {
1677        let dir = tempdir().expect("tempdir");
1678        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1679        ws.seed_test_project("proj", "/tmp/tp-approve");
1680        install_db(&ws, dir.path());
1681        row_awaiting_look(&ws, "t-1");
1682
1683        let approved = ws
1684            .approve_task("proj", &TaskId::from("t-1"))
1685            .expect("no refusal")
1686            .expect("the row is there");
1687        assert_eq!(approved.status, TaskStatus::Completed, "approval completes the row");
1688        assert_eq!(approved.waiting_on, None, "and clears the wait");
1689        assert_eq!(approved.verify, Some(Verify::None), "the gate is satisfied, not re-armed");
1690        let history = history_of(&ws, "proj");
1691        let last = history.last().expect("a last transition");
1692        assert_eq!((last.from, last.to), (Some(TaskStatus::Waiting), TaskStatus::Completed));
1693        assert_eq!(last.by, By::User, "the verdict is stamped as the user's own move");
1694    }
1695
1696    /// The lead's attestation completes a look wait the lead watched the
1697    /// user approve: the quoted words land on the row, the gate is
1698    /// satisfied, and the history stamps the lead's own move.
1699    #[test]
1700    fn a_lead_attests_the_look_with_the_quoted_approval() {
1701        let dir = tempdir().expect("tempdir");
1702        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1703        ws.seed_test_project("proj", "/tmp/tp-attest");
1704        install_db(&ws, dir.path());
1705        row_awaiting_look(&ws, "t-1");
1706        let lead = SessionSlot::lead("TestOrg", "proj");
1707
1708        let attested = ws
1709            .attest_task("proj", &TaskId::from("t-1"), &lead, "looks good, merge it")
1710            .expect("no refusal")
1711            .expect("the row is there");
1712        assert_eq!(attested.status, TaskStatus::Completed, "the attestation completes the row");
1713        assert_eq!(attested.waiting_on, None, "and clears the wait");
1714        assert_eq!(attested.verify, Some(Verify::None), "the gate is satisfied, not re-armed");
1715        let mark = attested.attested.as_ref().expect("the row carries the attestation");
1716        assert_eq!(mark.evidence, "looks good, merge it", "the quote is the record");
1717        assert!(
1718            attested.detail.as_deref().unwrap_or_default().contains("looks good, merge it"),
1719            "the quote lands on the row: {:?}",
1720            attested.detail,
1721        );
1722        let history = history_of(&ws, "proj");
1723        let last = history.last().expect("a last transition");
1724        assert_eq!((last.from, last.to), (Some(TaskStatus::Waiting), TaskStatus::Completed));
1725        assert_eq!(last.by, By::Seat(lead), "the attestation is the lead's own move");
1726    }
1727
1728    /// Only the project's lead stands behind the user's approval.
1729    #[test]
1730    fn a_worker_cannot_attest_a_look() {
1731        let dir = tempdir().expect("tempdir");
1732        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1733        ws.seed_test_project("proj", "/tmp/tp-attest-worker");
1734        row_awaiting_look(&ws, "t-1");
1735        let worker = SessionSlot::worker("TestOrg", "proj", "w1");
1736
1737        let refused = ws
1738            .attest_task("proj", &TaskId::from("t-1"), &worker, "looks good")
1739            .expect_err("a worker cannot attest");
1740        assert_eq!(refused, MoveError::AttestLeadOnly);
1741        let stored = ws.tasks_for_project("proj");
1742        let row = stored.iter().find(|task| task.id == TaskId::from("t-1")).expect("t-1");
1743        assert_eq!(row.status, TaskStatus::Waiting, "the row stays parked");
1744        assert_eq!(row.attested, None, "and carries no attestation");
1745    }
1746
1747    /// The quote is the whole point: an attestation stating nothing is
1748    /// refused rather than recorded as a blank claim.
1749    #[test]
1750    fn an_attestation_needs_the_quoted_approval() {
1751        let dir = tempdir().expect("tempdir");
1752        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1753        ws.seed_test_project("proj", "/tmp/tp-attest-empty");
1754        row_awaiting_look(&ws, "t-1");
1755        let lead = SessionSlot::lead("TestOrg", "proj");
1756
1757        let refused = ws
1758            .attest_task("proj", &TaskId::from("t-1"), &lead, "   ")
1759            .expect_err("an empty approval is nothing to stand behind");
1760        assert_eq!(refused, MoveError::AttestNeedsEvidence);
1761        assert_eq!(ws.tasks_for_project("proj")[0].status, TaskStatus::Waiting);
1762    }
1763
1764    /// A decision is the user's to answer; the lead's door is the look's
1765    /// only.
1766    #[test]
1767    fn a_decision_wait_is_not_the_leads_to_attest() {
1768        let dir = tempdir().expect("tempdir");
1769        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1770        ws.seed_test_project("proj", "/tmp/tp-attest-decision");
1771        ws.push_task(Task {
1772            status: TaskStatus::Waiting,
1773            waiting_on: Some(Waiting {
1774                kind: Some(WaitingKind::Decision),
1775                detail: Some("which shape".to_owned()),
1776                on: None,
1777                verification: false,
1778                parked: false,
1779                since: None,
1780            }),
1781            ..sample_task("t-1", "proj")
1782        });
1783        let lead = SessionSlot::lead("TestOrg", "proj");
1784
1785        let refused = ws
1786            .attest_task("proj", &TaskId::from("t-1"), &lead, "he said yes to something")
1787            .expect_err("a decision is not the lead's to attest");
1788        assert_eq!(refused, MoveError::NotWaitingVerification);
1789        assert_eq!(ws.tasks_for_project("proj")[0].status, TaskStatus::Waiting);
1790    }
1791
1792    /// The user strikes an attestation: the row parks on the look again,
1793    /// the gate is re-minted, and the history records the user's move. A
1794    /// child row, so the completion stays live - an attested ROOT closes
1795    /// its tree as the user's own approve does.
1796    #[test]
1797    fn the_users_reopen_strikes_the_attestation_and_parks_the_row() {
1798        let dir = tempdir().expect("tempdir");
1799        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1800        ws.seed_test_project("proj", "/tmp/tp-reopen");
1801        install_db(&ws, dir.path());
1802        ws.push_task(sample_task("epic", "proj"));
1803        ws.push_task(Task {
1804            verify: Some(Verify::User),
1805            parent: Some(TaskId::from("epic")),
1806            ..sample_task("t-1", "proj")
1807        });
1808        ws.update_task("proj", &TaskId::from("t-1"), By::System, |task| {
1809            task.status = TaskStatus::Completed;
1810        })
1811        .expect("no refusal")
1812        .expect("the row is there");
1813        let lead = SessionSlot::lead("TestOrg", "proj");
1814        ws.attest_task("proj", &TaskId::from("t-1"), &lead, "looks good")
1815            .expect("no refusal")
1816            .expect("the row is there");
1817
1818        let reopened = ws
1819            .reopen_task("proj", &TaskId::from("t-1"))
1820            .expect("no refusal")
1821            .expect("the row is there");
1822        assert_eq!(reopened.status, TaskStatus::Waiting, "the row is back on the look");
1823        assert_eq!(reopened.attested, None, "the attestation is struck");
1824        assert_eq!(reopened.verify, Some(Verify::User), "the gate is re-minted");
1825        assert!(
1826            reopened.waiting_on.as_ref().is_some_and(|wait| wait.verification),
1827            "and parked on the user: {:?}",
1828            reopened.waiting_on,
1829        );
1830        assert!(
1831            reopened.waiting_on.as_ref().and_then(|wait| wait.since).is_some(),
1832            "and the wait starts at the reopen, not whenever it was first parked: {:?}",
1833            reopened.waiting_on,
1834        );
1835        let history = history_of(&ws, "proj");
1836        let last = history.last().expect("a last transition");
1837        assert_eq!((last.from, last.to), (Some(TaskStatus::Completed), TaskStatus::Waiting));
1838        assert_eq!(last.by, By::User, "the reopen is the user's own move");
1839    }
1840
1841    /// A row with no attestation has nothing to reopen.
1842    #[test]
1843    fn a_reopen_without_an_attestation_is_refused() {
1844        let dir = tempdir().expect("tempdir");
1845        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1846        ws.seed_test_project("proj", "/tmp/tp-reopen-none");
1847        row_awaiting_look(&ws, "t-1");
1848
1849        let refused =
1850            ws.reopen_task("proj", &TaskId::from("t-1")).expect_err("nothing was attested");
1851        assert_eq!(refused, MoveError::NotAttested);
1852        assert_eq!(ws.tasks_for_project("proj")[0].status, TaskStatus::Waiting);
1853    }
1854
1855    /// The user drags a row across the board: the state moves, the wait the
1856    /// row was holding drops, and the move is stamped as the user's own.
1857    #[test]
1858    fn a_user_move_moves_the_row_and_clears_its_wait() {
1859        let dir = tempdir().expect("tempdir");
1860        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1861        install_db(&ws, dir.path());
1862        let mut waiting = sample_task("t-1", "forge");
1863        waiting.status = TaskStatus::Waiting;
1864        waiting.waiting_on = Some(Waiting {
1865            kind: Some(WaitingKind::Dependency),
1866            detail: None,
1867            on: None,
1868            verification: false,
1869            parked: false,
1870            since: None,
1871        });
1872        ws.push_task(waiting);
1873
1874        let moved = ws
1875            .user_move_task("forge", &TaskId::from("t-1"), TaskStatus::InProgress)
1876            .expect("no refusal")
1877            .expect("the row is there");
1878        assert_eq!(moved.status, TaskStatus::InProgress, "the drag moved the state");
1879        assert_eq!(moved.waiting_on, None, "and dropped the wait it was holding");
1880        let history = history_of(&ws, "forge");
1881        let last = history.last().expect("a last transition");
1882        assert_eq!((last.from, last.to), (Some(TaskStatus::Waiting), TaskStatus::InProgress));
1883        assert_eq!(last.by, By::User, "the drag is the user's own move");
1884    }
1885
1886    /// The gate holds for the user's own drag too: completing a verify=user
1887    /// row lands it in waiting for a look rather than done.
1888    #[test]
1889    fn a_user_move_still_takes_the_verify_gate() {
1890        let dir = tempdir().expect("tempdir");
1891        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1892        let mut row = sample_task("t-1", "forge");
1893        row.verify = Some(Verify::User);
1894        ws.push_task(row);
1895
1896        let landed = ws
1897            .user_move_task("forge", &TaskId::from("t-1"), TaskStatus::Completed)
1898            .expect("no refusal")
1899            .expect("the row is there");
1900        assert_eq!(landed.status, TaskStatus::Waiting, "the gate lands it in waiting");
1901        assert!(
1902            landed.waiting_on.as_ref().is_some_and(|wait| wait.verification),
1903            "as a verification wait"
1904        );
1905    }
1906
1907    #[test]
1908    fn a_send_back_returns_the_row_and_ticks_the_attempt() {
1909        let dir = tempdir().expect("tempdir");
1910        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1911        ws.seed_test_project("proj", "/tmp/tp-sendback");
1912        install_db(&ws, dir.path());
1913        row_awaiting_look(&ws, "t-1");
1914
1915        let sent = ws
1916            .send_back_task("proj", &TaskId::from("t-1"), "the rail elides wrong")
1917            .expect("no refusal")
1918            .expect("the row is there");
1919        assert_eq!(sent.status, TaskStatus::InProgress, "back to its owner's hands");
1920        assert_eq!(sent.attempt, 1, "the send-back ticks the attempt");
1921        assert_eq!(
1922            sent.detail.as_deref(),
1923            Some("the rail elides wrong"),
1924            "and the words are in the detail",
1925        );
1926    }
1927
1928    /// A wordless send-back appends nothing: an empty detail is no detail,
1929    /// and a `Some("")` there would read as an empty ask on the card.
1930    #[test]
1931    fn a_wordless_send_back_leaves_the_detail_untouched() {
1932        let dir = tempdir().expect("tempdir");
1933        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1934        ws.seed_test_project("proj", "/tmp/tp-sendback-blank");
1935        row_awaiting_look(&ws, "t-1");
1936
1937        let sent = ws
1938            .send_back_task("proj", &TaskId::from("t-1"), "   ")
1939            .expect("no refusal")
1940            .expect("the row is there");
1941        assert_eq!(sent.detail, None, "a blank send-back writes no detail");
1942    }
1943
1944    #[test]
1945    fn an_answer_resumes_a_question_wait() {
1946        let dir = tempdir().expect("tempdir");
1947        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1948        ws.seed_test_project("proj", "/tmp/tp-answer");
1949        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
1950        ws.push_task(Task { owner: Some(worker.clone()), ..sample_task("t-1", "proj") });
1951        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("claim");
1952        ws.wait_task("proj", &TaskId::from("t-1"), &worker, WaitingKind::Decision, None, None)
1953            .expect("wait");
1954
1955        let answered = ws
1956            .answer_task("proj", &TaskId::from("t-1"), "keep the window raise")
1957            .expect("no refusal")
1958            .expect("the row is there");
1959        assert_eq!(answered.status, TaskStatus::InProgress, "the answer resumes the row");
1960        assert_eq!(answered.waiting_on, None);
1961        assert_eq!(answered.detail.as_deref(), Some("keep the window raise"));
1962    }
1963
1964    #[test]
1965    fn a_verdict_on_a_row_not_waiting_for_look_is_refused() {
1966        let dir = tempdir().expect("tempdir");
1967        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1968        ws.seed_test_project("proj", "/tmp/tp-refuse-verdict");
1969        ws.push_task(sample_task("t-1", "proj"));
1970
1971        assert_eq!(
1972            ws.approve_task("proj", &TaskId::from("t-1")),
1973            Err(MoveError::NotWaitingVerification),
1974            "a pending row is not waiting on a look",
1975        );
1976        assert_eq!(
1977            ws.answer_task("proj", &TaskId::from("t-1"), "words"),
1978            Err(MoveError::NotWaitingQuestion),
1979        );
1980    }
1981
1982    #[test]
1983    fn rank_up_swaps_against_the_neighbour() {
1984        let dir = tempdir().expect("tempdir");
1985        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
1986        ws.seed_test_project("proj", "/tmp/tp-rank");
1987        for (id, rank) in [("first", 0), ("second", 1), ("third", 2)] {
1988            ws.push_task(Task { rank: Some(rank), ..sample_task(id, "proj") });
1989        }
1990
1991        let order = || -> Vec<String> {
1992            let mut rows: Vec<(i64, String)> = ws
1993                .tasks_for_project("proj")
1994                .into_iter()
1995                .map(|t| (t.rank.unwrap_or(i64::MAX), t.id.as_str().to_owned()))
1996                .collect();
1997            rows.sort();
1998            rows.into_iter().map(|(_, id)| id).collect()
1999        };
2000
2001        ws.rank_task("proj", &TaskId::from("third"), RankMove::Up).expect("no refusal");
2002        assert_eq!(
2003            order(),
2004            vec!["first", "third", "second"],
2005            "up moves the row one place toward the front, past its neighbour",
2006        );
2007
2008        ws.rank_task("proj", &TaskId::from("third"), RankMove::Down).expect("no refusal");
2009        assert_eq!(
2010            order(),
2011            vec!["first", "second", "third"],
2012            "down moves it one place back, to where it started",
2013        );
2014
2015        ws.rank_task("proj", &TaskId::from("third"), RankMove::Top).expect("no refusal");
2016        let top = ws
2017            .tasks_for_project("proj")
2018            .into_iter()
2019            .min_by_key(|t| t.rank.unwrap_or(i64::MAX))
2020            .expect("a row");
2021        assert_eq!(top.id, TaskId::from("third"), "top puts it above everything");
2022    }
2023
2024    /// A drag's drop position is a rank: the row sits directly above the row
2025    /// it was dropped on, and a drop below everything is the end of the lane.
2026    #[test]
2027    fn a_drop_position_ranks_the_row_where_it_landed() {
2028        let dir = tempdir().expect("tempdir");
2029        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2030        ws.seed_test_project("proj", "/tmp/tp-drop");
2031        for (id, rank) in [("first", 0), ("second", 1), ("third", 2)] {
2032            ws.push_task(Task { rank: Some(rank), ..sample_task(id, "proj") });
2033        }
2034
2035        let order = || -> Vec<String> {
2036            let mut rows: Vec<(i64, String)> = ws
2037                .tasks_for_project("proj")
2038                .into_iter()
2039                .map(|t| (t.rank.unwrap_or(i64::MAX), t.id.as_str().to_owned()))
2040                .collect();
2041            rows.sort();
2042            rows.into_iter().map(|(_, id)| id).collect()
2043        };
2044
2045        // Dragged DOWN past its neighbours: the row it lands above is found
2046        // in the queue the drag leaves, so "first" sits directly above
2047        // "third" rather than at the end behind it.
2048        ws.rank_task("proj", &TaskId::from("first"), RankMove::Before(Some("third".to_owned())))
2049            .expect("no refusal");
2050        assert_eq!(
2051            order(),
2052            vec!["second", "first", "third"],
2053            "the drop lands directly above the row it was dropped on",
2054        );
2055
2056        ws.rank_task("proj", &TaskId::from("second"), RankMove::Before(None)).expect("no refusal");
2057        assert_eq!(
2058            order(),
2059            vec!["first", "third", "second"],
2060            "and a drop below every card is the end of the queue",
2061        );
2062    }
2063
2064    #[test]
2065    fn claim_sets_owner_and_in_progress_in_one_write_and_ticks_the_attempt() {
2066        let dir = tempdir().expect("tempdir");
2067        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2068        ws.seed_test_project("proj", "/tmp/tp-claim");
2069        install_db(&ws, dir.path());
2070        ws.push_task(sample_task("t-1", "proj"));
2071        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2072
2073        let claimed = ws
2074            .claim_task("proj", &worker, Some(&TaskId::from("t-1")), None)
2075            .expect("the row is free");
2076        assert_eq!(claimed.owner.as_ref().map(SessionSlot::label), Some("w-1"));
2077        assert_eq!(claimed.status, TaskStatus::InProgress, "owner and status in one write");
2078        assert_eq!(claimed.attempt, 1, "each claim ticks the attempt");
2079        let history = history_of(&ws, "proj");
2080        assert_eq!(history.len(), 2, "creation and the claim: {history:?}");
2081        let claim = history.last().expect("the claim's transition");
2082        assert_eq!(claim.from, Some(TaskStatus::Pending));
2083        assert_eq!(claim.to, TaskStatus::InProgress);
2084        assert_eq!(claim.by, By::Seat(worker));
2085    }
2086
2087    #[test]
2088    fn a_claim_of_a_live_seats_row_is_refused_by_name() {
2089        let dir = tempdir().expect("tempdir");
2090        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2091        ws.seed_test_project("proj", "/tmp/tp-claim-live");
2092        live_worker(&ws, "proj", "w-1");
2093        let mut task = sample_task("t-1", "proj");
2094        task.owner = Some(SessionSlot::worker("TestOrg", "proj", "w-1"));
2095        ws.push_task(task);
2096        let other = SessionSlot::worker("TestOrg", "proj", "w-2");
2097
2098        assert_eq!(
2099            ws.claim_task("proj", &other, Some(&TaskId::from("t-1")), None),
2100            Err(ClaimError::OwnedByLiveSeat("w-1".to_owned())),
2101            "a live seat's row is refused by name",
2102        );
2103    }
2104
2105    /// A dead owner's row is nobody's: a claim on it is the explicit
2106    /// re-dispatch the design asks for, not a race.
2107    #[test]
2108    fn a_dead_owners_row_can_be_reclaimed() {
2109        let dir = tempdir().expect("tempdir");
2110        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2111        ws.seed_test_project("proj", "/tmp/tp-claim-dead");
2112        let mut task = sample_task("t-1", "proj");
2113        task.owner = Some(SessionSlot::worker("TestOrg", "proj", "gone"));
2114        ws.push_task(task);
2115        let worker = SessionSlot::worker("TestOrg", "proj", "w-2");
2116
2117        let claimed = ws
2118            .claim_task("proj", &worker, Some(&TaskId::from("t-1")), None)
2119            .expect("a dead owner does not hold a row");
2120        assert_eq!(claimed.owner.as_ref().map(SessionSlot::label), Some("w-2"));
2121    }
2122
2123    #[test]
2124    fn a_second_in_progress_row_for_one_worker_is_refused() {
2125        let dir = tempdir().expect("tempdir");
2126        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2127        ws.seed_test_project("proj", "/tmp/tp-claim-wip");
2128        ws.push_task(sample_task("t-1", "proj"));
2129        ws.push_task(sample_task("t-2", "proj"));
2130        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2131        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("first claim");
2132
2133        assert_eq!(
2134            ws.claim_task("proj", &worker, Some(&TaskId::from("t-2")), None),
2135            Err(ClaimError::AlreadyWorking),
2136            "one in progress per worker",
2137        );
2138    }
2139
2140    #[test]
2141    fn claim_by_epic_takes_the_top_ready_row_by_rank() {
2142        let dir = tempdir().expect("tempdir");
2143        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2144        ws.seed_test_project("proj", "/tmp/tp-claim-rank");
2145        ws.push_task(sample_task_with_parent("epic", None, "proj"));
2146        ws.push_task(Task {
2147            rank: Some(20),
2148            ..sample_task_with_parent("late", Some("epic"), "proj")
2149        });
2150        ws.push_task(Task {
2151            rank: Some(10),
2152            ..sample_task_with_parent("early", Some("epic"), "proj")
2153        });
2154        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2155
2156        let claimed = ws
2157            .claim_task("proj", &worker, None, Some(&TaskId::from("epic")))
2158            .expect("a ready row is there");
2159        assert_eq!(claimed.id, TaskId::from("early"), "the top ready row by rank");
2160    }
2161
2162    /// A pick reserved for the user is nobody's to claim: neither by id nor
2163    /// through its epic's ready row (#2423/C).
2164    #[test]
2165    fn a_reserved_pick_is_not_claimable() {
2166        let dir = tempdir().expect("tempdir");
2167        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2168        ws.seed_test_project("proj", "/tmp/tp-claim-reserved");
2169        ws.push_task(sample_task_with_parent("epic", None, "proj"));
2170        // The reserved row OUTRANKS the free one, so only the reservation
2171        // filter can keep the epic pull off it.
2172        ws.push_task(Task {
2173            rank: Some(1),
2174            reserved: Some(ReservedBy::User),
2175            ..sample_task_with_parent("kept", Some("epic"), "proj")
2176        });
2177        ws.push_task(Task {
2178            rank: Some(10),
2179            ..sample_task_with_parent("free", Some("epic"), "proj")
2180        });
2181        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2182
2183        assert_eq!(
2184            ws.claim_task("proj", &worker, Some(&TaskId::from("kept")), None),
2185            Err(ClaimError::NotReady),
2186            "a named reserved row is refused as not ready",
2187        );
2188        let claimed = ws
2189            .claim_task("proj", &worker, None, Some(&TaskId::from("epic")))
2190            .expect("the free row is still there");
2191        assert_eq!(claimed.id, TaskId::from("free"), "the epic pull skips the reserved row");
2192    }
2193
2194    /// The reservation is the user's to set and release, and the write
2195    /// returns the row as it stands after it.
2196    #[test]
2197    fn a_pick_is_reserved_and_released() {
2198        let dir = tempdir().expect("tempdir");
2199        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2200        ws.seed_test_project("proj", "/tmp/tp-reserve");
2201        ws.push_task(sample_task("t-1", "proj"));
2202
2203        let reserved = ws
2204            .reserve_task("proj", &TaskId::from("t-1"), true)
2205            .expect("no refusal")
2206            .expect("the row is there");
2207        assert_eq!(reserved.reserved, Some(ReservedBy::User), "the pick is the user's");
2208        assert_eq!(
2209            ws.tasks_for_project("proj")[0].reserved,
2210            Some(ReservedBy::User),
2211            "and the write persists",
2212        );
2213
2214        let released = ws
2215            .reserve_task("proj", &TaskId::from("t-1"), false)
2216            .expect("no refusal")
2217            .expect("the row is there");
2218        assert_eq!(released.reserved, None, "releasing clears it");
2219    }
2220
2221    /// A worker is handed a row by the short id a person writes, so an
2222    /// id a caller states resolves to the row it names: exactly when the
2223    /// id is complete, and by unique prefix otherwise. Two rows sharing
2224    /// the prefix is a refusal that names them - not the "no such row"
2225    /// that sends the caller back to the board to copy a uuid.
2226    #[test]
2227    fn a_claim_resolves_a_stated_id_by_exact_or_unique_prefix() {
2228        let dir = tempdir().expect("tempdir");
2229        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2230        ws.seed_test_project("proj", "/tmp/tp-claim-prefix");
2231        ws.push_task(named_task("7ed0aaaa-0000-4000-8000-000000000001", "fix the rail", "proj"));
2232        ws.push_task(named_task("7ed0bbbb-0000-4000-8000-000000000002", "add tests", "proj"));
2233        ws.push_task(named_task("epic-7ed0aaaa-0000-4000-8000-000000000003", "the epic", "proj"));
2234        ws.push_task(Task {
2235            parent: Some(TaskId::from("epic-7ed0aaaa-0000-4000-8000-000000000003")),
2236            ..named_task("child-1", "the child", "proj")
2237        });
2238        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2239
2240        let refused = ws
2241            .claim_task("proj", &worker, Some(&TaskId::from("7ed0")), None)
2242            .expect_err("two rows carry that prefix");
2243        let said = refused.to_string();
2244        assert!(said.contains("7ed0aaaa-0000-4000-8000-000000000001"), "one row is named: {said}");
2245        assert!(said.contains("7ed0bbbb-0000-4000-8000-000000000002"), "and the other: {said}");
2246
2247        let unknown = ws
2248            .claim_task("proj", &worker, Some(&TaskId::from("ffff")), None)
2249            .expect_err("no row carries that prefix");
2250        assert!(
2251            unknown.to_string().contains("no such row"),
2252            "an id naming no row answers as it always did: {unknown}",
2253        );
2254
2255        let claimed = ws
2256            .claim_task("proj", &worker, Some(&TaskId::from("7ed0bb")), None)
2257            .expect("one row carries that prefix");
2258        assert_eq!(
2259            claimed.id,
2260            TaskId::from("7ed0bbbb-0000-4000-8000-000000000002"),
2261            "the prefix landed on its row",
2262        );
2263
2264        let other = SessionSlot::worker("TestOrg", "proj", "w-2");
2265        let by_epic = ws
2266            .claim_task("proj", &other, None, Some(&TaskId::from("epic-7ed0")))
2267            .expect("one row carries that prefix");
2268        assert_eq!(
2269            by_epic.id,
2270            TaskId::from("child-1"),
2271            "an epic is named the same way: the prefix pulled its ready child",
2272        );
2273    }
2274
2275    #[test]
2276    fn an_update_resolves_a_stated_id_by_exact_or_unique_prefix() {
2277        let dir = tempdir().expect("tempdir");
2278        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2279        ws.seed_test_project("proj", "/tmp/tp-update-prefix");
2280        // Child rows: completing a ROOT closes and archives, which is its
2281        // own test.
2282        ws.push_task(Task {
2283            parent: Some(TaskId::from("epic")),
2284            ..named_task("7ed0aaaa-0000-4000-8000-000000000001", "fix the rail", "proj")
2285        });
2286        ws.push_task(Task {
2287            parent: Some(TaskId::from("epic")),
2288            ..named_task("7ed0bbbb-0000-4000-8000-000000000002", "add tests", "proj")
2289        });
2290        let seat = By::Seat(SessionSlot::lead("TestOrg", "proj"));
2291
2292        let refused = ws
2293            .update_task("proj", &TaskId::from("7ed0"), seat.clone(), |task| {
2294                task.status = TaskStatus::Completed;
2295            })
2296            .expect_err("two rows carry that prefix");
2297        let said = refused.to_string();
2298        assert!(
2299            said.contains("7ed0aaaa-0000-4000-8000-000000000001")
2300                && said.contains("7ed0bbbb-0000-4000-8000-000000000002"),
2301            "the refusal names both rows it matched: {said}",
2302        );
2303
2304        let moved = ws
2305            .update_task("proj", &TaskId::from("7ed0bb"), seat, |task| {
2306                task.status = TaskStatus::Completed;
2307            })
2308            .expect("no refusal")
2309            .expect("the row is there");
2310        assert_eq!(moved.id, TaskId::from("7ed0bbbb-0000-4000-8000-000000000002"));
2311        assert_eq!(moved.status, TaskStatus::Completed, "the prefix moved its own row");
2312    }
2313
2314    #[test]
2315    fn a_wait_resolves_a_stated_id_by_exact_or_unique_prefix() {
2316        let dir = tempdir().expect("tempdir");
2317        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2318        ws.seed_test_project("proj", "/tmp/tp-wait-prefix");
2319        ws.push_task(named_task("7ed0aaaa-0000-4000-8000-000000000001", "fix the rail", "proj"));
2320        ws.push_task(named_task("7ed0bbbb-0000-4000-8000-000000000002", "add tests", "proj"));
2321        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2322        ws.claim_task("proj", &worker, Some(&TaskId::from("7ed0bb")), None)
2323            .expect("claim by prefix");
2324
2325        // The blocker resolves the same way, and the full id is what
2326        // lands in the wait - the release matches it exactly.
2327        let waited = ws
2328            .wait_task(
2329                "proj",
2330                &TaskId::from("7ed0bb"),
2331                &worker,
2332                WaitingKind::Dependency,
2333                None,
2334                Some(&TaskId::from("7ed0aa")),
2335            )
2336            .expect("the owner may wait its own row");
2337        assert_eq!(waited.id, TaskId::from("7ed0bbbb-0000-4000-8000-000000000002"));
2338        assert_eq!(
2339            waited.waiting_on.as_ref().and_then(|wait| wait.on.clone()),
2340            Some(TaskId::from("7ed0aaaa-0000-4000-8000-000000000001")),
2341            "the blocker is stored as the row its prefix named",
2342        );
2343
2344        let refused = ws
2345            .wait_task("proj", &TaskId::from("7ed0"), &worker, WaitingKind::Decision, None, None)
2346            .expect_err("two rows carry that prefix");
2347        assert!(
2348            refused.to_string().contains("7ed0bbbb-0000-4000-8000-000000000002"),
2349            "the refusal names the rows it matched: {refused}",
2350        );
2351    }
2352
2353    /// The row's owner reads before its blocker does: a caller waiting a
2354    /// row that is not its own hears that, whatever it puts in `on`.
2355    #[test]
2356    fn a_wait_checks_ownership_before_it_resolves_the_blocker() {
2357        let dir = tempdir().expect("tempdir");
2358        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2359        ws.seed_test_project("proj", "/tmp/tp-wait-owner-first");
2360        ws.push_task(sample_task("t-1", "proj"));
2361        let holder = SessionSlot::worker("TestOrg", "proj", "w-1");
2362        ws.claim_task("proj", &holder, Some(&TaskId::from("t-1")), None).expect("claim");
2363
2364        let other = SessionSlot::worker("TestOrg", "proj", "w-2");
2365        assert_eq!(
2366            ws.wait_task(
2367                "proj",
2368                &TaskId::from("t-1"),
2369                &other,
2370                WaitingKind::Dependency,
2371                None,
2372                Some(&TaskId::from("ghost")),
2373            ),
2374            Err(MoveError::NotOwner),
2375            "the refusal is about the row, not the blocker it named",
2376        );
2377    }
2378
2379    /// An exact id is a name, not a prefix of somebody else's: the row it
2380    /// spells wins over the rows it merely starts.
2381    #[test]
2382    fn an_exact_id_wins_over_a_longer_id_it_prefixes() {
2383        let dir = tempdir().expect("tempdir");
2384        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2385        ws.seed_test_project("proj", "/tmp/tp-exact-wins");
2386        ws.push_task(named_task("7ed0", "the exact row", "proj"));
2387        ws.push_task(named_task("7ed0bbbb-0000-4000-8000-000000000002", "the longer row", "proj"));
2388        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2389
2390        let claimed = ws
2391            .claim_task("proj", &worker, Some(&TaskId::from("7ed0")), None)
2392            .expect("the exact id names its row");
2393        assert_eq!(claimed.id, TaskId::from("7ed0"), "the exact row, not the one it prefixes");
2394    }
2395
2396    #[test]
2397    fn a_waiting_row_is_not_claimable_until_its_wait_resolves() {
2398        let dir = tempdir().expect("tempdir");
2399        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2400        ws.seed_test_project("proj", "/tmp/tp-claim-wait");
2401        ws.push_task(sample_task("blocker", "proj"));
2402        ws.push_task(sample_task("t-2", "proj"));
2403        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2404        ws.claim_task("proj", &worker, Some(&TaskId::from("t-2")), None)
2405            .expect("claim the row before waiting it");
2406        ws.wait_task(
2407            "proj",
2408            &TaskId::from("t-2"),
2409            &worker,
2410            WaitingKind::Dependency,
2411            Some("the board read has to land first".to_owned()),
2412            Some(&TaskId::from("blocker")),
2413        )
2414        .expect("the owner may wait its own row");
2415        assert_eq!(
2416            ws.claim_task("proj", &worker, Some(&TaskId::from("t-2")), None),
2417            Err(ClaimError::NotReady),
2418            "a waiting row is not claimable",
2419        );
2420
2421        // The blocker ending - canceled, not completed - clears the wait.
2422        ws.update_task(
2423            "proj",
2424            &TaskId::from("blocker"),
2425            By::Seat(SessionSlot::lead("TestOrg", "proj")),
2426            |task| task.status = TaskStatus::Canceled,
2427        )
2428        .expect("no refusal")
2429        .expect("the blocker is there");
2430        let t2 = ws.tasks_for_project("proj").into_iter().find(|t| t.id == TaskId::from("t-2"));
2431        let t2 = t2.expect("t-2 is live");
2432        assert_eq!(t2.status, TaskStatus::Pending, "a canceled blocker still clears the wait");
2433        assert_eq!(t2.waiting_on, None);
2434        ws.claim_task("proj", &worker, Some(&TaskId::from("t-2")), None)
2435            .expect("and the row is claimable again");
2436    }
2437
2438    /// The live shape: a worker parks its row on a decision wait - the
2439    /// answer is the user's look, typically - and another seat completes
2440    /// the row once that answer is in hand. The completion applies, and
2441    /// the wait clears as its consequence.
2442    #[test]
2443    fn another_seat_completes_a_row_parked_on_a_decision_wait() {
2444        let dir = tempdir().expect("tempdir");
2445        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2446        ws.seed_test_project("proj", "/tmp/tp-complete-parked");
2447        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2448        ws.push_task(Task { owner: Some(worker.clone()), ..sample_task("t-1", "proj") });
2449        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("claim");
2450        ws.wait_task(
2451            "proj",
2452            &TaskId::from("t-1"),
2453            &worker,
2454            WaitingKind::Decision,
2455            Some("the user's look".to_owned()),
2456            None,
2457        )
2458        .expect("the owner may wait its own row");
2459
2460        let moved = ws
2461            .update_task(
2462                "proj",
2463                &TaskId::from("t-1"),
2464                By::Seat(SessionSlot::lead("TestOrg", "proj")),
2465                |task| {
2466                    task.status = TaskStatus::Completed;
2467                },
2468            )
2469            .expect("no refusal")
2470            .expect("the row is there");
2471        assert_eq!(moved.status, TaskStatus::Completed, "the completion applies as asked");
2472        assert_eq!(moved.waiting_on, None, "and the wait clears as its consequence");
2473    }
2474
2475    /// A row already waiting on the user's own look cannot be completed
2476    /// through `tasks__update` - the look is what completes it - and the
2477    /// refusal names the wait it sits on and what clears it, rather than
2478    /// answering with the row still waiting as though the completion had
2479    /// applied.
2480    #[test]
2481    fn completing_a_verification_wait_is_refused_naming_the_look() {
2482        let dir = tempdir().expect("tempdir");
2483        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2484        ws.seed_test_project("proj", "/tmp/tp-refuse-look");
2485        row_awaiting_look(&ws, "t-1");
2486
2487        let refused = ws
2488            .update_task(
2489                "proj",
2490                &TaskId::from("t-1"),
2491                By::Seat(SessionSlot::lead("TestOrg", "proj")),
2492                |task| {
2493                    task.status = TaskStatus::Completed;
2494                },
2495            )
2496            .expect_err("the user's look is what completes a verification wait");
2497        assert_eq!(
2498            refused,
2499            MoveError::WaitingOnLook,
2500            "the look's own refusal, not one that merely mentions a look",
2501        );
2502
2503        let stored = ws.tasks_for_project("proj");
2504        assert_eq!(stored[0].status, TaskStatus::Waiting, "and the row keeps waiting for the look");
2505    }
2506
2507    /// The reported sequence, not just the seeded state (#2414): a worker
2508    /// parks its row on a dependency wait through `tasks__wait`, then
2509    /// another seat completes it. On a verify=user row the completion is
2510    /// refused by name, naming the wait, and the dependency wait survives
2511    /// untouched.
2512    #[test]
2513    fn completing_a_waiting_user_bound_row_is_refused_by_name() {
2514        let dir = tempdir().expect("tempdir");
2515        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2516        ws.seed_test_project("proj", "/tmp/tp-complete-waiting");
2517        ws.push_task(sample_task("blocker", "proj"));
2518        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2519        ws.push_task(Task {
2520            verify: Some(Verify::User),
2521            owner: Some(worker.clone()),
2522            ..sample_task("t-1", "proj")
2523        });
2524        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("claim");
2525        ws.wait_task(
2526            "proj",
2527            &TaskId::from("t-1"),
2528            &worker,
2529            WaitingKind::Dependency,
2530            Some("the board read has to land first".to_owned()),
2531            Some(&TaskId::from("blocker")),
2532        )
2533        .expect("the owner may wait its own row");
2534
2535        let refused = ws
2536            .update_task(
2537                "proj",
2538                &TaskId::from("t-1"),
2539                By::Seat(SessionSlot::lead("TestOrg", "proj")),
2540                |task| {
2541                    task.status = TaskStatus::Completed;
2542                },
2543            )
2544            .expect_err("the gate would trade the dependency wait for a look park");
2545        assert_eq!(
2546            refused,
2547            MoveError::CompletingWhileWaiting("a dependency on blocker".to_owned()),
2548            "the refusal names the wait the row sits on",
2549        );
2550        assert!(
2551            refused.to_string().contains("a dependency on blocker"),
2552            "and the words a caller reads name it too: {refused}",
2553        );
2554        let stored = ws.tasks_for_project("proj");
2555        let row = stored.iter().find(|task| task.id == TaskId::from("t-1")).expect("the row");
2556        assert_eq!(row.status, TaskStatus::Waiting, "the row keeps the status it held");
2557        let held = row.waiting_on.as_ref().expect("the wait it held");
2558        assert_eq!(
2559            (held.kind, held.detail.as_deref(), held.on.as_ref(), held.verification, held.parked),
2560            (
2561                Some(WaitingKind::Dependency),
2562                Some("the board read has to land first"),
2563                Some(&TaskId::from("blocker")),
2564                false,
2565                false
2566            ),
2567            "and the wait it held, not a look park the caller never asked for",
2568        );
2569        assert!(held.since.is_some(), "the stored wait keeps its own start");
2570    }
2571
2572    /// The recovery the refusal advertises (#2414): moving the row off
2573    /// waiting drops the dependency wait, and the next completion parks it
2574    /// on the look the way any user-bound completion does.
2575    #[test]
2576    fn moving_a_dependency_wait_off_waiting_then_completing_parks_on_the_look() {
2577        let dir = tempdir().expect("tempdir");
2578        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2579        ws.seed_test_project("proj", "/tmp/tp-move-then-complete");
2580        ws.push_task(sample_task("blocker", "proj"));
2581        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2582        ws.push_task(Task {
2583            verify: Some(Verify::User),
2584            owner: Some(worker.clone()),
2585            ..sample_task("t-1", "proj")
2586        });
2587        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("claim");
2588        ws.wait_task(
2589            "proj",
2590            &TaskId::from("t-1"),
2591            &worker,
2592            WaitingKind::Dependency,
2593            None,
2594            Some(&TaskId::from("blocker")),
2595        )
2596        .expect("the owner may wait its own row");
2597        let lead = SessionSlot::lead("TestOrg", "proj");
2598
2599        let moved = ws
2600            .update_task("proj", &TaskId::from("t-1"), By::Seat(lead.clone()), |task| {
2601                task.status = TaskStatus::InProgress;
2602            })
2603            .expect("no refusal")
2604            .expect("the row is there");
2605        assert_eq!(moved.status, TaskStatus::InProgress, "the move off waiting lands");
2606        assert_eq!(moved.waiting_on, None, "and drops the dependency wait");
2607
2608        let landed = ws
2609            .update_task("proj", &TaskId::from("t-1"), By::Seat(lead), |task| {
2610                task.status = TaskStatus::Completed;
2611            })
2612            .expect("no refusal")
2613            .expect("the row is there");
2614        assert_eq!(landed.status, TaskStatus::Waiting, "the gate parks the completion");
2615        assert!(
2616            landed.waiting_on.as_ref().is_some_and(|wait| wait.verification),
2617            "on the user's look: {landed:?}",
2618        );
2619    }
2620
2621    /// The refusal is a seat's, not a state's (#2414): the user's own move
2622    /// of a waiting verify=user row to completed still parks it on the
2623    /// look, which is the path the refusal tells a seat to take.
2624    #[test]
2625    fn a_user_move_still_parks_a_waiting_user_bound_row() {
2626        let dir = tempdir().expect("tempdir");
2627        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2628        ws.seed_test_project("proj", "/tmp/tp-user-move-waiting");
2629        ws.push_task(Task {
2630            verify: Some(Verify::User),
2631            status: TaskStatus::Waiting,
2632            waiting_on: Some(Waiting {
2633                kind: Some(WaitingKind::Dependency),
2634                detail: None,
2635                on: None,
2636                verification: false,
2637                parked: false,
2638                since: None,
2639            }),
2640            ..sample_task("t-1", "proj")
2641        });
2642
2643        let landed = ws
2644            .user_move_task("proj", &TaskId::from("t-1"), TaskStatus::Completed)
2645            .expect("no refusal")
2646            .expect("the row is there");
2647        assert_eq!(landed.status, TaskStatus::Waiting, "the drag lands as a look park");
2648        let wait = landed.waiting_on.as_ref().expect("the re-parked look");
2649        assert!(wait.verification, "with the look the user's own completion is owed: {landed:?}");
2650        assert!(
2651            wait.since.is_some(),
2652            "a re-parked look on a row already waiting is stamped, so its age and a \
2653             notification's identity are the new wait's: {landed:?}",
2654        );
2655    }
2656
2657    /// **A pre-upgrade look park is stamped when it is rebuilt.** A row
2658    /// stored before `since` existed holds a value-identical verification
2659    /// park with no stamp; the drag to completed rebuilds it, and the
2660    /// construction site is where the new start is written - a comparison
2661    /// against the old value could not tell the two apart.
2662    #[test]
2663    fn a_pre_upgrade_look_park_is_stamped_when_re_parked() {
2664        let dir = tempdir().expect("tempdir");
2665        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2666        ws.seed_test_project("proj", "/tmp/tp-pre-upgrade-park");
2667        ws.push_task(Task {
2668            verify: Some(Verify::User),
2669            status: TaskStatus::Waiting,
2670            waiting_on: Some(Waiting {
2671                kind: Some(WaitingKind::Decision),
2672                detail: None,
2673                on: None,
2674                verification: true,
2675                parked: false,
2676                since: None,
2677            }),
2678            ..sample_task("t-1", "proj")
2679        });
2680
2681        let landed = ws
2682            .user_move_task("proj", &TaskId::from("t-1"), TaskStatus::Completed)
2683            .expect("no refusal")
2684            .expect("the row is there");
2685        let wait = landed.waiting_on.as_ref().expect("the rebuilt look park");
2686        assert!(wait.verification, "it is still the look: {landed:?}");
2687        assert!(wait.since.is_some(), "the rebuilt park carries its own start: {landed:?}");
2688    }
2689
2690    /// **Re-stating the same wait keeps its start.** A repeated `tasks__wait`
2691    /// with the same kind, words and blocker is the same ask: its age must
2692    /// not reset and its identity must not change into a second
2693    /// notification.
2694    #[test]
2695    fn a_repeated_identical_wait_keeps_its_start() {
2696        let dir = tempdir().expect("tempdir");
2697        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2698        ws.seed_test_project("proj", "/tmp/tp-repeat-wait");
2699        let seat = SessionSlot::worker("TestOrg", "proj", "w-1");
2700        ws.push_task(Task { owner: Some(seat.clone()), ..sample_task("t-1", "proj") });
2701
2702        let first = ws
2703            .wait_task(
2704                "proj",
2705                &TaskId::from("t-1"),
2706                &seat,
2707                WaitingKind::Decision,
2708                Some("sign this off".to_owned()),
2709                None,
2710            )
2711            .expect("the owner may wait its own row");
2712        let started = first.waiting_on.as_ref().and_then(|wait| wait.since).expect("stamped");
2713        let again = ws
2714            .wait_task(
2715                "proj",
2716                &TaskId::from("t-1"),
2717                &seat,
2718                WaitingKind::Decision,
2719                Some("sign this off".to_owned()),
2720                None,
2721            )
2722            .expect("a repeated wait is allowed");
2723        assert_eq!(
2724            again.waiting_on.as_ref().and_then(|wait| wait.since),
2725            Some(started),
2726            "the same wait keeps its start",
2727        );
2728    }
2729
2730    /// The refusal names every wait shape a row can hold (#2414), so the
2731    /// caller learns what it is sitting on and not merely that it waits.
2732    #[test]
2733    fn the_wait_refusal_words_name_each_wait() {
2734        let wait = |kind: Option<WaitingKind>, on: Option<TaskId>| Waiting {
2735            kind,
2736            detail: None,
2737            on,
2738            verification: false,
2739            parked: false,
2740            since: None,
2741        };
2742        assert_eq!(wait_words(None), "a wait", "no stored wait");
2743        assert_eq!(wait_words(Some(&wait(None, None))), "a wait", "a legacy unstated wait");
2744        assert_eq!(
2745            wait_words(Some(&wait(Some(WaitingKind::Decision), None))),
2746            "a decision",
2747            "the question wait a row can be parked on",
2748        );
2749        assert_eq!(wait_words(Some(&wait(Some(WaitingKind::Resource), None))), "a resource");
2750        assert_eq!(
2751            wait_words(Some(&wait(Some(WaitingKind::Dependency), Some(TaskId::from("blocker"))))),
2752            "a dependency on blocker",
2753            "a dependency names its blocker",
2754        );
2755    }
2756
2757    /// The withdraw door (#2340): a seat cancels a row parked on the
2758    /// user's look with the reason it states, which lands appended to
2759    /// what the row already said - the ask that goes away keeps its
2760    /// story.
2761    #[test]
2762    fn a_withdraw_records_the_reason_on_a_parked_row() {
2763        let dir = tempdir().expect("tempdir");
2764        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2765        ws.seed_test_project("proj", "/tmp/tp-withdraw-reason");
2766        let mut row = sample_task("t-1", "proj");
2767        row.verify = Some(Verify::User);
2768        row.detail = Some("the ask".to_owned());
2769        ws.push_task(row);
2770        ws.update_task("proj", &TaskId::from("t-1"), By::System, |task| {
2771            task.status = TaskStatus::Completed;
2772        })
2773        .expect("no refusal")
2774        .expect("the row is there");
2775
2776        let withdrawn = ws
2777            .update_task(
2778                "proj",
2779                &TaskId::from("t-1"),
2780                By::Seat(SessionSlot::lead("TestOrg", "proj")),
2781                |task| {
2782                    task.status = TaskStatus::Canceled;
2783                    task.detail = Some("moot: landed via #2296".to_owned());
2784                },
2785            )
2786            .expect("no refusal")
2787            .expect("the row is there");
2788        assert_eq!(withdrawn.status, TaskStatus::Canceled, "the cancel lands");
2789        assert_eq!(withdrawn.waiting_on, None, "and the withdrawn ask leaves the waiting set");
2790        assert_eq!(
2791            withdrawn.detail.as_deref(),
2792            Some("the ask\nmoot: landed via #2296"),
2793            "the reason lands appended to what the row already said",
2794        );
2795    }
2796
2797    /// A bare status flip on a parked row is refused: the user's pending
2798    /// ask leaves with its reason recorded, never silently.
2799    #[test]
2800    fn a_withdraw_without_a_reason_is_refused() {
2801        let dir = tempdir().expect("tempdir");
2802        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2803        ws.seed_test_project("proj", "/tmp/tp-withdraw-bare");
2804        row_awaiting_look(&ws, "t-1");
2805
2806        let refused = ws
2807            .update_task(
2808                "proj",
2809                &TaskId::from("t-1"),
2810                By::Seat(SessionSlot::lead("TestOrg", "proj")),
2811                |task| {
2812                    task.status = TaskStatus::Canceled;
2813                },
2814            )
2815            .expect_err("a cancel stating no reason is refused");
2816        assert_eq!(refused, MoveError::WithdrawNeedsReason, "the door's own refusal");
2817        let stored = ws.tasks_for_project("proj");
2818        assert_eq!(stored[0].status, TaskStatus::Waiting, "and the row keeps waiting");
2819        assert!(
2820            stored[0].waiting_on.as_ref().is_some_and(|wait| wait.verification),
2821            "with the look it was parked on intact",
2822        );
2823    }
2824
2825    /// The door covers a decision wait too: the ask is on the user there
2826    /// as well. A bare cancel is refused, and a stated reason lands.
2827    #[test]
2828    fn a_withdraw_of_a_decision_wait_records_the_reason() {
2829        let dir = tempdir().expect("tempdir");
2830        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2831        ws.seed_test_project("proj", "/tmp/tp-withdraw-question");
2832        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2833        ws.push_task(Task { owner: Some(worker.clone()), ..sample_task("t-1", "proj") });
2834        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("claim");
2835        ws.wait_task(
2836            "proj",
2837            &TaskId::from("t-1"),
2838            &worker,
2839            WaitingKind::Decision,
2840            Some("which shape".to_owned()),
2841            None,
2842        )
2843        .expect("the owner may wait its own row");
2844
2845        let lead = SessionSlot::lead("TestOrg", "proj");
2846        assert_eq!(
2847            ws.update_task("proj", &TaskId::from("t-1"), By::Seat(lead.clone()), |task| {
2848                task.status = TaskStatus::Canceled;
2849            })
2850            .expect_err("a bare cancel of a question waiting on the user is refused"),
2851            MoveError::WithdrawNeedsReason,
2852        );
2853
2854        let withdrawn = ws
2855            .update_task("proj", &TaskId::from("t-1"), By::Seat(lead), |task| {
2856                task.status = TaskStatus::Canceled;
2857                task.detail = Some("the question dissolved".to_owned());
2858            })
2859            .expect("no refusal")
2860            .expect("the row is there");
2861        assert_eq!(withdrawn.status, TaskStatus::Canceled);
2862        assert_eq!(withdrawn.waiting_on, None, "the withdrawn question leaves the waiting set");
2863        assert_eq!(
2864            withdrawn.detail.as_deref(),
2865            Some("the question dissolved"),
2866            "the reason lands on the row",
2867        );
2868    }
2869
2870    /// The requirement is scoped to the user's waits: a seat cancel of an
2871    /// ordinary wait - and of a row holding no wait at all - lands as it
2872    /// always did, with no reason required.
2873    #[test]
2874    fn a_seat_cancel_of_an_ordinary_row_stays_silent() {
2875        let dir = tempdir().expect("tempdir");
2876        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2877        ws.seed_test_project("proj", "/tmp/tp-cancel-ordinary");
2878        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
2879        ws.push_task(sample_task("blocker", "proj"));
2880        ws.push_task(Task { owner: Some(worker.clone()), ..sample_task("t-1", "proj") });
2881        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("claim");
2882        ws.wait_task(
2883            "proj",
2884            &TaskId::from("t-1"),
2885            &worker,
2886            WaitingKind::Dependency,
2887            Some("the board read first".to_owned()),
2888            Some(&TaskId::from("blocker")),
2889        )
2890        .expect("the owner may wait its own row");
2891
2892        let lead = SessionSlot::lead("TestOrg", "proj");
2893        let cancelled = ws
2894            .update_task("proj", &TaskId::from("t-1"), By::Seat(lead.clone()), |task| {
2895                task.status = TaskStatus::Canceled;
2896            })
2897            .expect("no refusal")
2898            .expect("the row is there");
2899        assert_eq!(cancelled.status, TaskStatus::Canceled);
2900        assert_eq!(cancelled.waiting_on, None, "an ordinary wait drops with the cancel");
2901
2902        // The live shape of the incident's own cleanup: an un-parked row
2903        // cancels by seat like any other work.
2904        let plain = ws
2905            .update_task("proj", &TaskId::from("blocker"), By::Seat(lead), |task| {
2906                task.status = TaskStatus::Canceled;
2907            })
2908            .expect("no refusal")
2909            .expect("the row is there");
2910        assert_eq!(plain.status, TaskStatus::Canceled, "no reason is asked of ordinary rows");
2911    }
2912
2913    /// The user's own drag is not the lead's door: their cancel of a
2914    /// parked row needs no reason - the wait is the user's to withdraw.
2915    #[test]
2916    fn a_users_own_cancel_of_a_parked_row_needs_no_reason() {
2917        let dir = tempdir().expect("tempdir");
2918        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2919        ws.seed_test_project("proj", "/tmp/tp-user-cancel-parked");
2920        row_awaiting_look(&ws, "t-1");
2921
2922        let moved = ws
2923            .user_move_task("proj", &TaskId::from("t-1"), TaskStatus::Canceled)
2924            .expect("no refusal")
2925            .expect("the row is there");
2926        assert_eq!(moved.status, TaskStatus::Canceled, "the user's own move lands");
2927        assert_eq!(moved.waiting_on, None, "and the wait goes with it");
2928    }
2929
2930    /// A child row completes on its own terms under a verify=user epic
2931    /// (#2423): `verify: user` is a property a row is MINTED with, and an
2932    /// epic's gate governs the epic's own completion rather than its
2933    /// children's.
2934    #[test]
2935    fn a_child_of_a_verify_user_epic_completes_without_a_look() {
2936        let dir = tempdir().expect("tempdir");
2937        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2938        ws.seed_test_project("proj", "/tmp/tp-child-gate");
2939        let worker = SessionSlot::worker("TestOrg", "proj", "w1");
2940        ws.push_task(Task { verify: Some(Verify::User), ..sample_task("epic", "proj") });
2941        ws.push_task(sample_task_with_parent("t-1", Some("epic"), "proj"));
2942
2943        let completed = ws
2944            .update_task("proj", &TaskId::from("t-1"), By::Seat(worker), |task| {
2945                task.status = TaskStatus::Completed;
2946            })
2947            .expect("no refusal")
2948            .expect("the row is there");
2949        assert_eq!(completed.status, TaskStatus::Completed, "the child owes no look");
2950        assert_eq!(completed.waiting_on, None, "and nothing parks it");
2951    }
2952
2953    /// The boundary the rule keeps (#2423): the epic's OWN gate still
2954    /// governs the epic's completion - the root parks on the look, and the
2955    /// board's approve is what completes it.
2956    #[test]
2957    fn an_epic_root_still_parks_on_its_own_gate() {
2958        let dir = tempdir().expect("tempdir");
2959        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2960        ws.seed_test_project("proj", "/tmp/tp-epic-gate");
2961        let lead = SessionSlot::lead("TestOrg", "proj");
2962        ws.push_task(Task { verify: Some(Verify::User), ..sample_task("epic", "proj") });
2963
2964        let parked = ws
2965            .update_task("proj", &TaskId::from("epic"), By::Seat(lead), |task| {
2966                task.status = TaskStatus::Completed;
2967            })
2968            .expect("no refusal")
2969            .expect("the row is there");
2970        assert_eq!(parked.status, TaskStatus::Waiting, "the root's own gate parks it");
2971        assert!(
2972            parked.waiting_on.as_ref().is_some_and(|wait| wait.verification),
2973            "the wait is the look's: {:?}",
2974            parked.waiting_on,
2975        );
2976
2977        let approved = ws
2978            .approve_task("proj", &TaskId::from("epic"))
2979            .expect("no refusal")
2980            .expect("the row is there");
2981        assert_eq!(approved.status, TaskStatus::Completed, "the approve completes the epic");
2982    }
2983
2984    /// A park an inherited gate left behind stops reading as a user wait
2985    /// (#2423): the row's own `verify` is unchanged, and a seat's
2986    /// completion lands instead of being refused by the look.
2987    #[test]
2988    fn a_park_minted_by_an_inherited_gate_stops_reading_as_a_user_wait() {
2989        let dir = tempdir().expect("tempdir");
2990        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
2991        ws.seed_test_project("proj", "/tmp/tp-stale-park");
2992        let worker = SessionSlot::worker("TestOrg", "proj", "w1");
2993        ws.push_task(Task { verify: Some(Verify::User), ..sample_task("epic", "proj") });
2994        ws.push_task(Task {
2995            status: TaskStatus::Waiting,
2996            waiting_on: Some(Waiting {
2997                kind: Some(WaitingKind::Decision),
2998                detail: None,
2999                on: None,
3000                verification: true,
3001                parked: false,
3002                since: None,
3003            }),
3004            ..sample_task_with_parent("t-1", Some("epic"), "proj")
3005        });
3006
3007        let completed = ws
3008            .update_task("proj", &TaskId::from("t-1"), By::Seat(worker), |task| {
3009                task.status = TaskStatus::Completed;
3010            })
3011            .expect("an inherited park is not the look's to refuse")
3012            .expect("the row is there");
3013        assert_eq!(completed.status, TaskStatus::Completed, "the child completes");
3014        assert_eq!(completed.waiting_on, None, "and the stale wait drops with the move");
3015    }
3016
3017    /// The look gate is not an update's to clear. A `verify: none` patch on
3018    /// a user-bound row is refused by name, so the two-call path (clear,
3019    /// then complete) has no first step - and the stored row keeps the
3020    /// gate it was minted with.
3021    #[test]
3022    fn an_update_cannot_clear_a_user_verify() {
3023        let dir = tempdir().expect("tempdir");
3024        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3025        ws.seed_test_project("proj", "/tmp/tp-verify-clear");
3026        let lead = SessionSlot::lead("TestOrg", "proj");
3027        ws.push_task(Task { verify: Some(Verify::User), ..sample_task("t-1", "proj") });
3028        ws.push_task(sample_task("t-2", "proj"));
3029
3030        let refused = ws
3031            .update_task("proj", &TaskId::from("t-1"), By::Seat(lead.clone()), |task| {
3032                task.verify = Some(Verify::None);
3033            })
3034            .expect_err("an update cannot clear the user's look gate");
3035        assert_eq!(
3036            refused,
3037            MoveError::ClearingLookGate,
3038            "the clear's own refusal, not another move error",
3039        );
3040
3041        let stored = ws.tasks_for_project("proj");
3042        let own = stored.iter().find(|t| t.id == TaskId::from("t-1")).expect("t-1");
3043        assert_eq!(own.verify, Some(Verify::User), "t-1 keeps its own gate");
3044
3045        // Setting the gate is still the lead's: a row may be armed.
3046        ws.update_task("proj", &TaskId::from("t-2"), By::Seat(lead), |task| {
3047            task.verify = Some(Verify::User);
3048        })
3049        .expect("no refusal")
3050        .expect("the row is there");
3051    }
3052
3053    /// Reparenting never touches a gate (#2423): a child of a verify=user
3054    /// epic carries no gate of its own, so moving it out is not the look's
3055    /// to refuse - and a row carrying its own gate keeps it wherever it
3056    /// moves, since the gate is minted on the row rather than inherited.
3057    #[test]
3058    fn reparenting_does_not_touch_a_row_s_own_gate() {
3059        let dir = tempdir().expect("tempdir");
3060        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3061        ws.seed_test_project("proj", "/tmp/tp-verify-reparent");
3062        let lead = SessionSlot::lead("TestOrg", "proj");
3063        ws.push_task(Task { verify: Some(Verify::User), ..sample_task("gated", "proj") });
3064        ws.push_task(Task { ..sample_task("free", "proj") });
3065        ws.push_task(Task { parent: Some(TaskId::from("gated")), ..sample_task("t-2", "proj") });
3066        ws.push_task(Task {
3067            parent: Some(TaskId::from("gated")),
3068            verify: Some(Verify::User),
3069            ..sample_task("t-3", "proj")
3070        });
3071
3072        ws.update_task("proj", &TaskId::from("t-2"), By::Seat(lead.clone()), |task| {
3073            task.parent = Some(TaskId::from("free"));
3074        })
3075        .expect("no refusal")
3076        .expect("a child never inherited the epic's gate");
3077
3078        let moved = ws
3079            .update_task("proj", &TaskId::from("t-3"), By::Seat(lead), |task| {
3080                task.parent = Some(TaskId::from("free"));
3081            })
3082            .expect("no refusal")
3083            .expect("the row is there");
3084        assert_eq!(moved.verify, Some(Verify::User), "its own gate travels with it");
3085        assert_eq!(moved.parent, Some(TaskId::from("free")), "and the reparent landed");
3086    }
3087
3088    /// The third door around the look gate (#2251): the update path refuses
3089    /// to clear the gate and the board's approve is what completes a row
3090    /// parked on it, so a delete must not settle the row by removal either.
3091    /// The guard rides the removal itself, which is what stops the cascade
3092    /// from stepping around it through the epic above.
3093    #[test]
3094    fn a_delete_cannot_take_a_row_waiting_on_the_look() {
3095        let dir = tempdir().expect("tempdir");
3096        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3097        ws.seed_test_project("proj", "/tmp/tp-delete-look");
3098        ws.push_task(sample_task("epic", "proj"));
3099        ws.push_task(Task {
3100            parent: Some(TaskId::from("epic")),
3101            verify: Some(Verify::User),
3102            ..sample_task("t-1", "proj")
3103        });
3104        ws.update_task("proj", &TaskId::from("t-1"), By::System, |task| {
3105            task.status = TaskStatus::Completed;
3106        })
3107        .expect("no refusal")
3108        .expect("the row is there");
3109
3110        let refused = ws
3111            .remove_task_tree("proj", &TaskId::from("epic"))
3112            .expect_err("the look a child is parked on is not a delete's to take");
3113        assert_eq!(
3114            refused,
3115            MoveError::DeletingLook("subject t-1".to_owned()),
3116            "the refusal names the row whose look is pending",
3117        );
3118        assert_eq!(
3119            subjects(&ws.tasks_for_project("proj")),
3120            ["subject epic".to_owned(), "subject t-1".to_owned()],
3121            "and nothing went: the epic above the row is not a way around",
3122        );
3123    }
3124
3125    /// The delete's guard covers the user's other ask too (#2340): a row
3126    /// waiting on a question is not a removal's to take either - it would
3127    /// settle the ask by deletion, the same way it would settle a look.
3128    #[test]
3129    fn a_delete_cannot_take_a_row_waiting_on_a_question() {
3130        let dir = tempdir().expect("tempdir");
3131        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3132        ws.seed_test_project("proj", "/tmp/tp-delete-question");
3133        ws.push_task(sample_task("epic", "proj"));
3134        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
3135        ws.push_task(sample_task_with_parent("t-1", Some("epic"), "proj"));
3136        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("claim");
3137        ws.wait_task(
3138            "proj",
3139            &TaskId::from("t-1"),
3140            &worker,
3141            WaitingKind::Decision,
3142            Some("which shape".to_owned()),
3143            None,
3144        )
3145        .expect("the owner may wait its own row");
3146
3147        let refused = ws
3148            .remove_task_tree("proj", &TaskId::from("t-1"))
3149            .expect_err("a question waiting on the user is not a delete's to take");
3150        assert_eq!(
3151            refused,
3152            MoveError::DeletingLook("subject t-1".to_owned()),
3153            "the refusal names the row whose ask is pending",
3154        );
3155        assert_eq!(ws.tasks_for_project("proj").len(), 2, "and nothing went");
3156    }
3157
3158    /// The close is the other removal door, and the question park is the
3159    /// other ask (#2340): a grandchild waiting on a question, under a
3160    /// branch already cancelled, is not archived by the root's close.
3161    #[test]
3162    fn a_root_close_cannot_take_a_grandchild_waiting_on_a_question() {
3163        let dir = tempdir().expect("tempdir");
3164        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3165        ws.seed_test_project("proj", "/tmp/tp-close-question");
3166        ws.push_task(sample_task("root", "proj"));
3167        ws.push_task(sample_task_with_parent("branch", Some("root"), "proj"));
3168        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
3169        ws.push_task(sample_task_with_parent("asked", Some("branch"), "proj"));
3170        ws.claim_task("proj", &worker, Some(&TaskId::from("asked")), None).expect("claim");
3171        ws.wait_task(
3172            "proj",
3173            &TaskId::from("asked"),
3174            &worker,
3175            WaitingKind::Decision,
3176            Some("which shape".to_owned()),
3177            None,
3178        )
3179        .expect("the owner may wait its own row");
3180        let lead = SessionSlot::lead("TestOrg", "proj");
3181        ws.update_task("proj", &TaskId::from("branch"), By::Seat(lead.clone()), |task| {
3182            task.status = TaskStatus::Canceled;
3183        })
3184        .expect("no refusal")
3185        .expect("the row is there");
3186
3187        let refused = ws
3188            .update_task("proj", &TaskId::from("root"), By::Seat(lead), |task| {
3189                task.status = TaskStatus::Completed;
3190            })
3191            .expect_err("the archive is not a way to settle a pending question");
3192        assert_eq!(refused, MoveError::ClosingLook("subject asked".to_owned()));
3193        assert_eq!(ws.tasks_for_project("proj").len(), 3, "and nothing was archived");
3194    }
3195
3196    /// The gate protects the look, not the row's life: once the approve has
3197    /// settled it, the epics and rows around it are the caller's to clear,
3198    /// the same as any finished work.
3199    #[test]
3200    fn a_delete_lands_once_the_look_is_given() {
3201        let dir = tempdir().expect("tempdir");
3202        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3203        ws.seed_test_project("proj", "/tmp/tp-delete-approved");
3204        ws.push_task(sample_task("epic", "proj"));
3205        ws.push_task(Task {
3206            parent: Some(TaskId::from("epic")),
3207            verify: Some(Verify::User),
3208            ..sample_task("t-1", "proj")
3209        });
3210        ws.update_task("proj", &TaskId::from("t-1"), By::System, |task| {
3211            task.status = TaskStatus::Completed;
3212        })
3213        .expect("no refusal")
3214        .expect("the row is there");
3215        ws.approve_task("proj", &TaskId::from("t-1")).expect("no refusal").expect("the row");
3216
3217        let removed = ws
3218            .remove_task_tree("proj", &TaskId::from("epic"))
3219            .expect("the look landed; the tree is the caller's to clear");
3220        assert_eq!(subjects(&removed).len(), 2, "the epic and its approved child");
3221    }
3222
3223    /// A row merely BOUND by the user's verify - never parked on the look,
3224    /// because nothing ever tried to complete it - is still work the caller
3225    /// may abandon: the gate fires on the transition into completed, so an
3226    /// unstarted row's delete skips no look.
3227    #[test]
3228    fn a_delete_lands_on_a_user_bound_row_that_never_reached_the_look() {
3229        let dir = tempdir().expect("tempdir");
3230        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3231        ws.seed_test_project("proj", "/tmp/tp-delete-pending");
3232        ws.push_task(Task { verify: Some(Verify::User), ..sample_task("t-1", "proj") });
3233
3234        let removed = ws
3235            .remove_task_tree("proj", &TaskId::from("t-1"))
3236            .expect("no look is pending on an unstarted row");
3237        assert_eq!(removed.len(), 1, "the row went");
3238    }
3239
3240    /// A dependency wait is on the task it names, not on the user: a row's
3241    /// `verify: user` gates that row's own completion, so neither it nor
3242    /// its epic's gate makes the waiting child a user ask the delete must
3243    /// refuse (#2415, #2423).
3244    #[test]
3245    fn a_dependency_wait_under_a_verify_user_epic_is_deletable() {
3246        let dir = tempdir().expect("tempdir");
3247        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3248        ws.seed_test_project("proj", "/tmp/tp-dep-under-epic");
3249        ws.push_task(Task { verify: Some(Verify::User), ..sample_task("epic", "proj") });
3250        ws.push_task(sample_task_with_parent("blocker", Some("epic"), "proj"));
3251        ws.push_task(Task {
3252            status: TaskStatus::Waiting,
3253            waiting_on: Some(Waiting {
3254                kind: Some(WaitingKind::Dependency),
3255                detail: Some("the blocker lands first".to_owned()),
3256                on: Some(TaskId::from("blocker")),
3257                verification: false,
3258                parked: false,
3259                since: None,
3260            }),
3261            ..sample_task_with_parent("t-1", Some("epic"), "proj")
3262        });
3263
3264        let rows = ws.tasks_for_project("proj");
3265        let index = rows.iter().position(|task| task.id == TaskId::from("t-1")).expect("the row");
3266        assert_eq!(
3267            effective_verify(&rows, index),
3268            Verify::None,
3269            "premise: the epic's gate does not bind the waiting child",
3270        );
3271
3272        let removed = ws
3273            .remove_task_tree("proj", &TaskId::from("t-1"))
3274            .expect("a dependency wait is not an ask on the user");
3275        assert_eq!(subjects(&removed), ["subject t-1".to_owned()], "the waiting row went alone");
3276    }
3277
3278    /// A parked row in ANOTHER project sharing the named id is not this
3279    /// delete's business: the guard's project test is what keeps a foreign
3280    /// row from refusing (or being named by) this project's delete.
3281    #[test]
3282    fn a_parked_row_in_another_project_does_not_refuse_the_delete() {
3283        let dir = tempdir().expect("tempdir");
3284        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3285        ws.seed_test_project("proj", "/tmp/tp-delete-foreign");
3286        ws.push_task(Task { verify: Some(Verify::User), ..sample_task("t-1", "elsewhere") });
3287        ws.update_task("elsewhere", &TaskId::from("t-1"), By::System, |task| {
3288            task.status = TaskStatus::Completed;
3289        })
3290        .expect("no refusal")
3291        .expect("the row is there");
3292        ws.push_task(sample_task("t-1", "proj"));
3293
3294        let removed = ws
3295            .remove_task_tree("proj", &TaskId::from("t-1"))
3296            .expect("another project's parked row is not this delete's business");
3297        assert_eq!(removed.len(), 1, "this project's row went");
3298        assert_eq!(ws.tasks_for_project("elsewhere").len(), 1, "and the parked one stayed");
3299    }
3300
3301    /// The close guard's two scoping clauses, one control each: a parked
3302    /// row in ANOTHER project sharing a doomed row's id must not refuse
3303    /// this project's close, and a parked row in the SAME project but
3304    /// outside the closing tree must not either. Without them a close
3305    /// anywhere refuses on an unrelated row's look, naming a subject the
3306    /// caller never touched.
3307    #[test]
3308    fn a_parked_row_outside_the_closing_tree_does_not_refuse_the_close() {
3309        let dir = tempdir().expect("tempdir");
3310        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3311        ws.seed_test_project("proj", "/tmp/tp-close-scope");
3312        // The tree being closed: a root and its child, both unparked.
3313        ws.push_task(sample_task("root", "proj"));
3314        ws.push_task(sample_task_with_parent("child", Some("root"), "proj"));
3315        // A parked row sharing a doomed id, in another project: a child, so
3316        // completing it parks on the look instead of closing a tree of its own.
3317        ws.push_task(Task {
3318            parent: Some(TaskId::from("epic-e")),
3319            verify: Some(Verify::User),
3320            ..sample_task("child", "elsewhere")
3321        });
3322        ws.update_task("elsewhere", &TaskId::from("child"), By::System, |task| {
3323            task.status = TaskStatus::Completed;
3324        })
3325        .expect("no refusal")
3326        .expect("the row is there");
3327        // And a parked row in this project, outside the tree.
3328        ws.push_task(Task {
3329            parent: Some(TaskId::from("epic-p")),
3330            verify: Some(Verify::User),
3331            ..sample_task("outside", "proj")
3332        });
3333        ws.update_task("proj", &TaskId::from("outside"), By::System, |task| {
3334            task.status = TaskStatus::Completed;
3335        })
3336        .expect("no refusal")
3337        .expect("the row is there");
3338        // The root's own child is abandoned, so only the parked rows could
3339        // stand in the close's way.
3340        ws.update_task("proj", &TaskId::from("child"), By::System, |task| {
3341            task.status = TaskStatus::Canceled;
3342        })
3343        .expect("no refusal")
3344        .expect("the row is there");
3345
3346        let closed = ws
3347            .update_task(
3348                "proj",
3349                &TaskId::from("root"),
3350                By::Seat(SessionSlot::lead("TestOrg", "proj")),
3351                |task| {
3352                    task.status = TaskStatus::Completed;
3353                },
3354            )
3355            .expect("neither parked row is the closing tree's to refuse")
3356            .expect("the row is there");
3357        assert_eq!(closed.status, TaskStatus::Completed, "the root closed");
3358        assert_eq!(
3359            subjects(&ws.tasks_for_project("proj")),
3360            ["subject outside".to_owned()],
3361            "the tree went and the parked row outside it stayed",
3362        );
3363        assert_eq!(ws.tasks_for_project("elsewhere").len(), 1, "and the foreign one stayed");
3364    }
3365
3366    /// The close is the other verb that removes rows (#2251's class, found
3367    /// reviewing the delete's guard): completing a ROOT row archives its
3368    /// whole tree, and the open-children check sees direct children only -
3369    /// so a grandchild parked on the look, under a branch already cancelled,
3370    /// used to be archived with its look pending.
3371    #[test]
3372    fn a_root_close_cannot_take_a_grandchild_waiting_on_the_look() {
3373        let dir = tempdir().expect("tempdir");
3374        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3375        ws.seed_test_project("proj", "/tmp/tp-close-look");
3376        ws.push_task(sample_task("root", "proj"));
3377        ws.push_task(sample_task_with_parent("branch", Some("root"), "proj"));
3378        ws.push_task(Task {
3379            parent: Some(TaskId::from("branch")),
3380            verify: Some(Verify::User),
3381            ..sample_task("gated", "proj")
3382        });
3383        let lead = SessionSlot::lead("TestOrg", "proj");
3384        // The grandchild reaches the look and is parked on it.
3385        ws.update_task("proj", &TaskId::from("gated"), By::System, |task| {
3386            task.status = TaskStatus::Completed;
3387        })
3388        .expect("no refusal")
3389        .expect("the row is there");
3390        // The branch is abandoned: terminal, so the open-children check passes.
3391        ws.update_task("proj", &TaskId::from("branch"), By::Seat(lead.clone()), |task| {
3392            task.status = TaskStatus::Canceled;
3393        })
3394        .expect("no refusal")
3395        .expect("the row is there");
3396
3397        let refused = ws
3398            .update_task("proj", &TaskId::from("root"), By::Seat(lead), |task| {
3399                task.status = TaskStatus::Completed;
3400            })
3401            .expect_err("the archive is not a way to settle the parked look");
3402        assert_eq!(refused, MoveError::ClosingLook("subject gated".to_owned()));
3403        assert_eq!(ws.tasks_for_project("proj").len(), 3, "and nothing was archived");
3404    }
3405
3406    #[test]
3407    fn a_verify_user_row_cannot_be_completed_by_any_caller() {
3408        let dir = tempdir().expect("tempdir");
3409        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3410        ws.seed_test_project("proj", "/tmp/tp-verify");
3411        ws.push_task(Task {
3412            verify: Some(forge_primitives::tasks::Verify::User),
3413            ..sample_task("t-1", "proj")
3414        });
3415        let lead = SessionSlot::lead("TestOrg", "proj");
3416
3417        let moved = ws
3418            .update_task("proj", &TaskId::from("t-1"), By::Seat(lead), |task| {
3419                task.status = TaskStatus::Completed;
3420            })
3421            .expect("no refusal")
3422            .expect("the row is there");
3423        assert_eq!(moved.status, TaskStatus::Waiting, "the gate lands it in waiting");
3424        let wait = moved.waiting_on.expect("the wait is the verify gate");
3425        assert_eq!(
3426            (wait.kind, wait.detail, wait.on, wait.verification, wait.parked),
3427            (Some(WaitingKind::Decision), None, None, true, false),
3428            "the wait is the verify gate, which the board draws as approve / send back",
3429        );
3430        assert!(wait.since.is_some(), "the wait is stamped as it begins");
3431    }
3432
3433    #[test]
3434    fn completing_a_root_archives_the_tree_and_a_child_completion_does_not() {
3435        let dir = tempdir().expect("tempdir");
3436        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3437        ws.seed_test_project("proj", "/tmp/tp-archive");
3438        install_db(&ws, dir.path());
3439        ws.push_task(sample_task_with_parent("epic", None, "proj"));
3440        ws.push_task(Task {
3441            status: TaskStatus::Completed,
3442            ..sample_task_with_parent("child", Some("epic"), "proj")
3443        });
3444        let lead = SessionSlot::lead("TestOrg", "proj");
3445
3446        // A child completing on its own archives nothing.
3447        ws.update_task("proj", &TaskId::from("child"), By::Seat(lead.clone()), |task| {
3448            task.status = TaskStatus::Completed;
3449        })
3450        .expect("no refusal");
3451        assert_eq!(ws.tasks_for_project("proj").len(), 2, "a completed child stays live");
3452
3453        // Completing the root closes it: the tree leaves the live set and
3454        // lands in the archive with its stamp.
3455        ws.update_task("proj", &TaskId::from("epic"), By::Seat(lead), |task| {
3456            task.status = TaskStatus::Completed;
3457        })
3458        .expect("no refusal");
3459        assert!(ws.tasks_for_project("proj").is_empty(), "the closed tree is off the live board");
3460        let archived = archived_of(&ws, "proj");
3461        assert_eq!(archived.len(), 2, "the epic and its child went to the archive: {archived:?}");
3462        assert!(archived.iter().all(|t| t.archived_at.is_some()), "each row carries its stamp");
3463    }
3464
3465    #[test]
3466    fn completing_a_root_with_open_children_is_refused() {
3467        let dir = tempdir().expect("tempdir");
3468        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3469        ws.seed_test_project("proj", "/tmp/tp-open-children");
3470        install_db(&ws, dir.path());
3471        ws.push_task(sample_task_with_parent("epic", None, "proj"));
3472        ws.push_task(sample_task_with_parent("child", Some("epic"), "proj"));
3473        let lead = SessionSlot::lead("TestOrg", "proj");
3474
3475        assert_eq!(
3476            ws.update_task("proj", &TaskId::from("epic"), By::Seat(lead), |task| {
3477                task.status = TaskStatus::Completed;
3478            }),
3479            Err(MoveError::OpenChildren(vec!["subject child".to_owned()])),
3480            "the refusal names the child that still has to be cancelled",
3481        );
3482        assert_eq!(ws.tasks_for_project("proj").len(), 2, "nothing moved");
3483        assert!(archived_of(&ws, "proj").is_empty(), "nothing archived");
3484
3485        // Thirty open children is one line on the board's service line: the
3486        // message names the first few and counts the rest rather than
3487        // inlining a thirty-subject sentence.
3488        for n in 0..30 {
3489            ws.push_task(sample_task_with_parent(&format!("c{n}"), Some("epic"), "proj"));
3490        }
3491        let said = ws
3492            .update_task("proj", &TaskId::from("epic"), By::User, |task| {
3493                task.status = TaskStatus::Completed;
3494            })
3495            .expect_err("the root is refused")
3496            .to_string();
3497        assert!(said.contains("28 more"), "the tail was not counted: {said}");
3498        assert!(
3499            !said.contains("c20"),
3500            "the message inlines every subject rather than eliding: {said}"
3501        );
3502    }
3503
3504    #[test]
3505    fn every_move_lands_in_the_history_with_its_by() {
3506        let dir = tempdir().expect("tempdir");
3507        let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.path().to_owned());
3508        ws.seed_test_project("proj", "/tmp/tp-history");
3509        install_db(&ws, dir.path());
3510        ws.push_task(sample_task("t-1", "proj"));
3511        let worker = SessionSlot::worker("TestOrg", "proj", "w-1");
3512        ws.claim_task("proj", &worker, Some(&TaskId::from("t-1")), None).expect("claim");
3513        ws.update_task("proj", &TaskId::from("t-1"), By::Seat(worker.clone()), |task| {
3514            task.status = TaskStatus::Completed;
3515        })
3516        .expect("no refusal");
3517
3518        let history = history_of(&ws, "proj");
3519        assert_eq!(history.len(), 3, "creation, claim and completion: {history:?}");
3520        assert_eq!(
3521            (history[1].from, history[1].to, history[1].by.clone()),
3522            (Some(TaskStatus::Pending), TaskStatus::InProgress, By::Seat(worker.clone())),
3523        );
3524        assert_eq!(
3525            (history[2].from, history[2].to, history[2].by.clone()),
3526            (Some(TaskStatus::InProgress), TaskStatus::Completed, By::Seat(worker)),
3527        );
3528    }
3529}