1use 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#[derive(Debug, Clone, PartialEq, Eq)]
25pub(crate) enum ClaimError {
26 NotFound,
28 Ambiguous(Vec<IdCandidate>),
30 OwnedByLiveSeat(String),
32 NotReady,
34 AlreadyWorking,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub(crate) enum MoveError {
41 OpenChildren(Vec<String>),
45 NotOwner,
47 NotFound,
49 Ambiguous(Vec<IdCandidate>),
51 NotWaitingVerification,
54 NotWaitingQuestion,
56 WaitingOnLook,
59 CompletingWhileWaiting(String),
65 ClearingLookGate,
68 WithdrawNeedsReason,
72 DeletingLook(String),
77 ClosingLook(String),
81 AttestLeadOnly,
84 AttestNeedsEvidence,
87 NotAttested,
90}
91
92fn 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
106pub(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
131fn 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
147fn 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 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#[derive(Debug, Clone, PartialEq, Eq)]
247pub(crate) struct IdCandidate {
248 pub id: TaskId,
249 pub subject: String,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
254pub(crate) enum IdError {
255 NotFound,
257 Ambiguous(Vec<IdCandidate>),
259}
260
261fn 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
299fn 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 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
330fn is_terminal(status: TaskStatus) -> bool {
332 matches!(status, TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled)
333}
334
335fn bound_verify(row: &Task) -> Verify {
340 row.verify.unwrap_or(Verify::None)
341}
342
343fn effective_verify(tasks: &[Task], index: usize) -> Verify {
345 bound_verify(&tasks[index])
346}
347
348impl Workspace {
349 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 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 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 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 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 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 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 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 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 if gate
495 && effective_verify(tasks, index) == Verify::User
496 && bound_verify(&next) != Verify::User
497 {
498 return Err(MoveError::ClearingLookGate);
499 }
500 if next.status == TaskStatus::Completed && parked_on_look(&next) {
503 return Err(MoveError::WaitingOnLook);
504 }
505 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 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 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 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(¤t)
576 && !doomed.contains(&task.id)
577 {
578 doomed.push(task.id.clone());
579 }
580 }
581 }
582 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(¤t)
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 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 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 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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 ws.push_task(sample_task("root", "proj"));
3314 ws.push_task(sample_task_with_parent("child", Some("root"), "proj"));
3315 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 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 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 #[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 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 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 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 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 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}