1use std::collections::HashMap;
11use std::time::SystemTime;
12
13use forge_primitives::tasks::{LinkKind, Task, TaskId, TaskStatus, TaskTransition};
14
15use crate::workspace::Workspace;
16
17pub const DEFAULT_STALE_SECS: u64 = 4 * 3600;
19
20pub const HISTORY_CAP: usize = 20;
27
28pub(crate) fn fmt_secs(secs: u64) -> String {
30 if secs >= 3_600 {
31 format!("{}h", secs / 3_600)
32 } else if secs >= 60 {
33 format!("{}m", secs / 60)
34 } else {
35 format!("{secs}s")
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub struct Marks {
43 pub ready: bool,
45 pub in_review: bool,
47 pub overdue: bool,
49 pub no_movement: bool,
51 pub waiting_too_long: bool,
53 pub stale: bool,
55 pub to_close: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub struct BoardRow {
63 pub task: Task,
64 pub worked_secs: u64,
66 pub updated_secs_ago: u64,
68 pub marks: Marks,
69 pub rollup: Option<(usize, usize)>,
71 pub parent_subject: Option<String>,
73 #[serde(default)]
86 pub history: Vec<TaskTransition>,
87 #[serde(default)]
91 pub history_total: usize,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum Miss {
98 StalledQueue,
100 UnaccountedWorker(String),
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub struct AttentionItem {
112 pub task: String,
115 pub owner: String,
117 pub subject: String,
119 pub words: String,
121 pub at: SystemTime,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub struct FleetRow {
130 pub project: String,
131 pub live_workers: usize,
132 pub slots: Option<usize>,
134 pub queue: usize,
136 pub waiting_on_user: usize,
138 pub attention: Vec<AttentionItem>,
141 pub rows: usize,
143 pub misses: Vec<Miss>,
144}
145
146fn worked_secs(mine: &[&TaskTransition], now: SystemTime) -> u64 {
153 let mut total = 0u64;
154 let mut working_since: Option<SystemTime> = None;
155 for transition in mine {
156 match (working_since, transition.to) {
157 (None, TaskStatus::InProgress) => working_since = Some(transition.at),
158 (Some(since), to) if to != TaskStatus::InProgress => {
159 total += transition.at.duration_since(since).map_or(0, |d| d.as_secs());
160 working_since = None;
161 }
162 _ => {}
163 }
164 }
165 if let Some(since) = working_since {
166 total += now.duration_since(since).map_or(0, |d| d.as_secs());
167 }
168 total
169}
170
171fn capped_history(mine: &[&TaskTransition]) -> Vec<TaskTransition> {
175 let start = mine.len().saturating_sub(HISTORY_CAP);
176 mine.get(start..).unwrap_or(mine).iter().map(|t| (*t).clone()).collect()
177}
178
179fn secs_ago(at: SystemTime, now: SystemTime) -> u64 {
180 now.duration_since(at).map_or(0, |d| d.as_secs())
181}
182
183fn is_terminal(status: TaskStatus) -> bool {
184 matches!(status, TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled)
185}
186
187fn retro_ran(task: &Task) -> bool {
189 task.links.iter().any(|l| l.label.as_deref() == Some("retro"))
190}
191
192fn attention_words(task: &Task) -> String {
199 let said = task.waiting_on.as_ref().and_then(|wait| wait.detail.as_deref());
200 for words in [said, task.detail.as_deref()] {
201 if let Some(words) = words.map(str::trim).filter(|words| !words.is_empty()) {
202 return words.to_owned();
203 }
204 }
205 if task.waiting_on.as_ref().is_some_and(|wait| wait.verification) {
206 return "the look is owed".to_owned();
207 }
208 let subject = task.subject.trim();
209 if subject.is_empty() {
210 "A decision is waiting on you.".to_owned()
213 } else {
214 subject.to_owned()
215 }
216}
217
218impl Workspace {
219 pub fn board_rows(&self, project: &str, now: SystemTime, stale_secs: u64) -> Vec<BoardRow> {
221 let tasks = self.tasks_for_project(project);
222 let history = {
223 let guard = self.db.lock();
224 match guard.as_ref() {
225 Some(db) => {
226 crate::store::task_history::list_for_project(db, project).unwrap_or_default()
227 }
228 None => Vec::new(),
229 }
230 };
231 let mut by_task: HashMap<&TaskId, Vec<&TaskTransition>> = HashMap::new();
234 for transition in &history {
235 by_task.entry(&transition.task_id).or_default().push(transition);
236 }
237 for mine in by_task.values_mut() {
238 mine.sort_by_key(|t| t.at);
239 }
240 tasks
241 .iter()
242 .map(|task| {
243 let mine = by_task.get(&task.id).map_or(&[][..], Vec::as_slice);
244 let worked = worked_secs(mine, now);
245 let updated_secs_ago = secs_ago(task.updated_at, now);
246 let parked = task.waiting_on.as_ref().is_some_and(|wait| wait.parked);
249 let children: Vec<&Task> =
250 tasks.iter().filter(|c| c.parent.as_ref() == Some(&task.id)).collect();
251 let children_terminal =
252 !children.is_empty() && children.iter().all(|c| is_terminal(c.status));
253 let marks = Marks {
254 ready: task.status == TaskStatus::Pending
255 && task.waiting_on.is_none()
256 && task.reserved.is_none(),
257 in_review: task
258 .links
259 .iter()
260 .any(|l| l.kind == LinkKind::Pr && l.state.as_deref() != Some("merged")),
261 overdue: task.estimate.as_ref().is_some_and(|e| e.secs > 0 && worked > e.secs),
262 no_movement: !parked && updated_secs_ago > stale_secs,
263 waiting_too_long: task.status == TaskStatus::Waiting
264 && !parked
265 && updated_secs_ago > stale_secs,
266 stale: task.owner.as_ref().is_some_and(|owner| !self.seat_is_live(owner)),
267 to_close: task.parent.is_none() && children_terminal && retro_ran(task),
268 };
269 BoardRow {
270 task: task.clone(),
271 worked_secs: worked,
272 updated_secs_ago,
273 marks,
274 rollup: (!children.is_empty()).then(|| {
275 (
276 children.iter().filter(|c| c.status == TaskStatus::Completed).count(),
277 children.len(),
278 )
279 }),
280 parent_subject: task.parent.as_ref().and_then(|id| {
281 tasks.iter().find(|p| p.id == *id).map(|p| p.subject.clone())
282 }),
283 history: capped_history(mine),
284 history_total: mine.len(),
285 }
286 })
287 .collect()
288 }
289
290 pub fn fleet_rows(&self) -> Vec<FleetRow> {
292 self.list_projects()
293 .into_iter()
294 .map(|view| {
295 let name = view.name.clone();
296 let tasks = self.tasks_for_project(&name);
297 let live_workers = self.list_live_workers(&view.key).len();
298 let slots = self
299 .config_arc()
300 .projects
301 .iter()
302 .find(|p| p.name == name)
303 .and_then(|p| p.max_workers)
304 .unwrap_or(crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT);
305 let queue = tasks
306 .iter()
307 .filter(|t| {
308 t.status == TaskStatus::Pending
309 && t.owner.is_none()
310 && t.waiting_on.is_none()
311 && t.reserved.is_none()
312 })
313 .count();
314 let waiting_on_user = tasks
315 .iter()
316 .filter(|t| t.status == TaskStatus::Waiting && crate::tasks::parked_on_user(t))
317 .count();
318 let attention = tasks
324 .iter()
325 .filter(|t| t.status == TaskStatus::Waiting && crate::tasks::parked_on_user(t))
326 .map(|t| AttentionItem {
327 task: t.id.as_str().to_owned(),
328 owner: t
329 .owner
330 .as_ref()
331 .map_or_else(|| "lead".to_owned(), |seat| seat.label().to_owned()),
332 subject: t.subject.clone(),
333 words: attention_words(t),
334 at: t
335 .waiting_on
336 .as_ref()
337 .and_then(|wait| wait.since)
338 .unwrap_or(t.created_at),
339 })
340 .collect();
341 let mut misses = Vec::new();
342 if queue > 0 && live_workers < slots {
343 misses.push(Miss::StalledQueue);
344 }
345 misses.extend(self.unaccounted(&name).into_iter().map(Miss::UnaccountedWorker));
346 FleetRow {
347 project: name,
348 live_workers,
349 slots: Some(slots),
350 queue,
351 waiting_on_user,
352 attention,
353 rows: tasks.len(),
354 misses,
355 }
356 })
357 .collect()
358 }
359
360 pub fn unaccounted(&self, project: &str) -> Vec<String> {
362 let key =
363 self.list_projects().into_iter().find(|view| view.name == project).map(|view| view.key);
364 let Some(key) = key else { return Vec::new() };
365 let mut open: Vec<String> = self
366 .tasks_for_project(project)
367 .iter()
368 .filter(|t| {
369 matches!(
370 t.status,
371 TaskStatus::Pending | TaskStatus::InProgress | TaskStatus::Waiting
372 )
373 })
374 .filter_map(|t| t.owner.as_ref().map(|o| o.label().to_owned()))
375 .collect();
376 open.sort();
377 open.dedup();
378 let mut labels: Vec<String> = self
379 .list_live_workers(&key)
380 .into_iter()
381 .filter(|entry| !open.contains(&entry.label))
382 .map(|entry| entry.label)
383 .collect();
384 labels.sort();
385 labels
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use std::time::{Duration, SystemTime};
392
393 use super::*;
394 use crate::SessionSlot;
395 use forge_primitives::tasks::{
396 By, Estimate, LinkKind, Task, TaskId, TaskLink, TaskStatus, TaskTransition, Verify,
397 Waiting, WaitingKind,
398 };
399 use tempfile::tempdir;
400
401 const PROJECT: &str = "proj";
402 const WINDOW: u64 = 4 * 3600;
403
404 fn epoch(secs: u64) -> SystemTime {
405 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
406 }
407
408 fn fixture(dir: &std::path::Path) -> std::sync::Arc<Workspace> {
409 let (ws, _rx) = Workspace::testing_stub_with_config_dir(dir.to_owned());
410 ws.seed_test_project(PROJECT, "/tmp/tp-board");
411 let db = crate::store::Db::open(&dir.join("db.redb")).expect("open db");
412 ws.install_db_for_test(db);
413 ws
414 }
415
416 fn task(id: &str, status: TaskStatus) -> Task {
417 Task {
418 id: TaskId::from(id),
419 project_name: PROJECT.to_owned(),
420 subject: format!("subject {id}"),
421 active_form: None,
422 detail: None,
423 status,
424 owner: None,
425 parent: None,
426 waiting_on: None,
427 estimate: None,
428 rank: None,
429 verify: None,
430 attested: None,
431 reserved: None,
432 links: Vec::new(),
433 attempt: 0,
434 archived_at: None,
435 created_at: epoch(0),
436 updated_at: epoch(0),
437 }
438 }
439
440 fn transition(id: &str, from: TaskStatus, to: TaskStatus, at: u64) -> TaskTransition {
441 TaskTransition {
442 task_id: TaskId::from(id),
443 project_name: PROJECT.to_owned(),
444 from: Some(from),
445 to,
446 at: epoch(at),
447 by: By::System,
448 }
449 }
450
451 fn live_worker(ws: &Workspace, label: &str) {
452 let key = ws
453 .list_projects()
454 .into_iter()
455 .find(|view| view.name == PROJECT)
456 .expect("seeded project")
457 .key
458 .clone();
459 ws.insert_live_worker(
460 &key,
461 crate::WorkerEntry {
462 label: label.to_owned(),
463 charter: "charter".to_owned(),
464 slot: SessionSlot::worker("TestOrg", PROJECT, label),
465 session_id: None,
466 status: forge_primitives::WorkerLiveness::Running,
467 spawned_at: epoch(0),
468 spawned_by: SessionSlot::lead("TestOrg", PROJECT),
469 needs_tag: false,
470 is_git_repo_at_spawn: false,
471 cwd: None,
472 diagnostic: None,
473 kick: None,
474 },
475 );
476 }
477
478 fn row<'a>(rows: &'a [BoardRow], id: &str) -> &'a BoardRow {
479 rows.iter().find(|row| row.task.id == TaskId::from(id)).expect("the row is on the board")
480 }
481
482 #[test]
485 fn worked_time_is_the_sum_of_in_progress_intervals() {
486 let dir = tempdir().expect("tempdir");
487 let ws = fixture(dir.path());
488 let mut running = task("t-1", TaskStatus::InProgress);
489 running.estimate = Some(Estimate { words: "1m".to_owned(), secs: 60 });
490 running.updated_at = epoch(45);
491 ws.seed_test_task(running);
492 {
493 let db = ws.db.lock();
494 crate::store::task_history::append(
495 db.as_ref().expect("db"),
496 &[
497 transition("t-1", TaskStatus::Pending, TaskStatus::InProgress, 10),
498 transition("t-1", TaskStatus::InProgress, TaskStatus::Waiting, 20),
499 transition("t-1", TaskStatus::Waiting, TaskStatus::InProgress, 30),
500 ],
501 )
502 .expect("history");
503 }
504
505 let mut other = task("t-2", TaskStatus::InProgress);
509 other.updated_at = epoch(40);
510 ws.seed_test_task(other);
511 {
512 let db = ws.db.lock();
513 crate::store::task_history::append(
514 db.as_ref().expect("db"),
515 &[transition("t-2", TaskStatus::Pending, TaskStatus::InProgress, 35)],
516 )
517 .expect("history");
518 }
519
520 let rows = ws.board_rows(PROJECT, epoch(45), WINDOW);
521 assert_eq!(
522 row(&rows, "t-1").worked_secs,
523 25,
524 "10s in the first interval and 15s into the second; the wait is not worked time",
525 );
526 assert!(!row(&rows, "t-1").marks.overdue, "25s of a 60s estimate is not overdue");
527 assert_eq!(
528 row(&rows, "t-2").worked_secs,
529 10,
530 "the second row is measured from its own transitions, not its neighbour's",
531 );
532 }
533
534 #[test]
539 fn a_row_past_the_cap_carries_its_newest_and_its_true_count() {
540 let dir = tempdir().expect("tempdir");
541 let ws = fixture(dir.path());
542 let mut running = task("t-1", TaskStatus::Pending);
543 running.updated_at = epoch(1_000);
544 ws.seed_test_task(running);
545 let moves: Vec<TaskTransition> = (0..30)
549 .map(|i| {
550 let (from, to) = if i % 2 == 0 {
551 (TaskStatus::Pending, TaskStatus::InProgress)
552 } else {
553 (TaskStatus::InProgress, TaskStatus::Pending)
554 };
555 transition("t-1", from, to, (i + 1) * 10)
556 })
557 .collect();
558 {
559 let db = ws.db.lock();
560 crate::store::task_history::append(db.as_ref().expect("db"), &moves).expect("history");
561 }
562
563 let rows = ws.board_rows(PROJECT, epoch(1_000), WINDOW);
564 let row = row(&rows, "t-1");
565 assert_eq!(row.history_total, 30, "the count is the row's whole trail");
566 assert_eq!(row.history.len(), HISTORY_CAP, "the carry is the newest {HISTORY_CAP}");
567 assert_eq!(
568 row.history.first().map(|t| t.at),
569 Some(epoch(110)),
570 "the newest {HISTORY_CAP} start at the eleventh move, not the first",
571 );
572 assert_eq!(row.worked_secs, 150, "the cap does not change what the row derives");
573 }
574
575 #[test]
578 fn a_short_history_rides_whole() {
579 let dir = tempdir().expect("tempdir");
580 let ws = fixture(dir.path());
581 ws.seed_test_task(task("t-1", TaskStatus::Pending));
582 {
583 let db = ws.db.lock();
584 crate::store::task_history::append(
585 db.as_ref().expect("db"),
586 &[
587 transition("t-1", TaskStatus::Pending, TaskStatus::InProgress, 10),
588 transition("t-1", TaskStatus::InProgress, TaskStatus::Completed, 20),
589 ],
590 )
591 .expect("history");
592 }
593
594 let rows = ws.board_rows(PROJECT, epoch(100), WINDOW);
595 let row = row(&rows, "t-1");
596 assert_eq!(row.history.len(), 2, "a short trail rides whole");
597 assert_eq!(row.history_total, 2, "and its count is the list's own length");
598 }
599
600 #[test]
603 fn an_update_does_not_clear_an_overrun() {
604 let dir = tempdir().expect("tempdir");
605 let ws = fixture(dir.path());
606 let mut running = task("t-1", TaskStatus::InProgress);
607 running.estimate = Some(Estimate { words: "10s".to_owned(), secs: 10 });
608 running.updated_at = epoch(100);
609 ws.seed_test_task(running);
610 {
611 let db = ws.db.lock();
612 crate::store::task_history::append(
613 db.as_ref().expect("db"),
614 &[transition("t-1", TaskStatus::Pending, TaskStatus::InProgress, 10)],
615 )
616 .expect("history");
617 }
618
619 let rows = ws.board_rows(PROJECT, epoch(100), WINDOW);
620 assert!(
621 row(&rows, "t-1").marks.overdue,
622 "90s of worked time against a 10s estimate is overdue, however fresh the touch",
623 );
624 }
625
626 #[test]
629 fn a_row_without_an_estimate_uses_the_no_movement_window() {
630 let dir = tempdir().expect("tempdir");
631 let ws = fixture(dir.path());
632 let mut stale_row = task("old", TaskStatus::Pending);
633 stale_row.updated_at = epoch(0);
634 let mut fresh_row = task("new", TaskStatus::Pending);
635 fresh_row.updated_at = epoch(100);
636 fresh_row.id = TaskId::from("new");
637 ws.seed_test_task(stale_row);
638 ws.seed_test_task(fresh_row);
639
640 let now = epoch(WINDOW + 1);
641 let rows = ws.board_rows(PROJECT, now, WINDOW);
642 assert!(row(&rows, "old").marks.no_movement, "a day without a touch is no movement");
643 assert!(!row(&rows, "old").marks.overdue, "and it is not called overdue");
644 assert!(!row(&rows, "new").marks.no_movement, "a fresh touch is movement");
645 }
646
647 #[test]
652 fn a_pending_row_holding_a_wait_is_not_ready() {
653 let dir = tempdir().expect("tempdir");
654 let ws = fixture(dir.path());
655 let mut held = task("t-held", TaskStatus::Pending);
656 held.waiting_on = Some(Waiting {
657 kind: Some(WaitingKind::Dependency),
658 detail: None,
659 on: None,
660 verification: false,
661 parked: false,
662 since: None,
663 });
664 ws.seed_test_task(held);
665 ws.seed_test_task(task("t-plain", TaskStatus::Pending));
666
667 let rows = ws.board_rows(PROJECT, epoch(10), WINDOW);
668 assert!(
669 !row(&rows, "t-held").marks.ready,
670 "a wait the status does not show still blocks a claim",
671 );
672 assert!(row(&rows, "t-plain").marks.ready, "the plain row is the one a seeker can take");
675 }
676
677 #[test]
681 fn a_reserved_pick_is_not_ready_and_not_in_the_queue() {
682 let dir = tempdir().expect("tempdir");
683 let ws = fixture(dir.path());
684 ws.seed_test_task(Task {
685 reserved: Some(forge_primitives::tasks::ReservedBy::User),
686 ..task("t-kept", TaskStatus::Pending)
687 });
688 ws.seed_test_task(task("t-plain", TaskStatus::Pending));
689
690 let rows = ws.board_rows(PROJECT, epoch(10), WINDOW);
691 assert!(!row(&rows, "t-kept").marks.ready, "a reserved pick never reads ready");
692 assert!(row(&rows, "t-plain").marks.ready, "the plain row is the control");
693 let fleet = ws.fleet_rows().into_iter().find(|r| r.project == PROJECT).expect("the row");
694 assert_eq!(fleet.queue, 1, "only the plain row is dispatchable, so no stall is owed");
695 }
696
697 #[test]
698 fn a_waiting_row_past_the_window_carries_waiting_too_long() {
699 let dir = tempdir().expect("tempdir");
700 let ws = fixture(dir.path());
701 let mut waiting = task("t-1", TaskStatus::Waiting);
702 waiting.waiting_on = Some(Waiting {
703 kind: Some(WaitingKind::Decision),
704 detail: Some("which shape".to_owned()),
705 on: None,
706 verification: false,
707 parked: false,
708 since: None,
709 });
710 waiting.updated_at = epoch(0);
711 ws.seed_test_task(waiting);
712
713 let rows = ws.board_rows(PROJECT, epoch(WINDOW + 1), WINDOW);
714 assert!(row(&rows, "t-1").marks.waiting_too_long, "the wait's own age is the signal");
715 }
716
717 #[test]
721 fn a_parked_wait_carries_neither_stalled_mark() {
722 let dir = tempdir().expect("tempdir");
723 let ws = fixture(dir.path());
724 let wait = |parked: bool| Waiting {
725 kind: Some(WaitingKind::Resource),
726 detail: Some("needs the owner's dashboard".to_owned()),
727 on: None,
728 verification: false,
729 parked,
730 since: None,
731 };
732 let mut parked = task("t-parked", TaskStatus::Waiting);
733 parked.waiting_on = Some(wait(true));
734 parked.updated_at = epoch(0);
735 ws.seed_test_task(parked);
736 let mut unsettled = task("t-unparked", TaskStatus::Waiting);
737 unsettled.waiting_on = Some(wait(false));
738 unsettled.updated_at = epoch(0);
739 ws.seed_test_task(unsettled);
740
741 let rows = ws.board_rows(PROJECT, epoch(WINDOW + 1), WINDOW);
742 assert!(
743 !row(&rows, "t-parked").marks.no_movement,
744 "a parked wait is a deferral, not a row nobody has moved",
745 );
746 assert!(
747 !row(&rows, "t-parked").marks.waiting_too_long,
748 "and its age is no stall while it is parked",
749 );
750 assert!(
751 row(&rows, "t-unparked").marks.no_movement,
752 "the un-parked control keeps the no-movement mark",
753 );
754 assert!(row(&rows, "t-unparked").marks.waiting_too_long, "and keeps the wait-stall mark");
755
756 ws.update_task(PROJECT, &TaskId::from("t-parked"), By::System, |task| {
757 if let Some(wait) = task.waiting_on.as_mut() {
758 wait.parked = false;
759 }
760 })
761 .expect("no refusal")
762 .expect("the row is there");
763 let updated_at = ws
764 .tasks_for_project(PROJECT)
765 .into_iter()
766 .find(|task| task.id == TaskId::from("t-parked"))
767 .expect("the row survived")
768 .updated_at;
769 let later = updated_at + Duration::from_secs(WINDOW + 1);
770 let rows = ws.board_rows(PROJECT, later, WINDOW);
771 assert!(
772 row(&rows, "t-parked").marks.no_movement,
773 "un-parked, its real age is the mark again",
774 );
775 assert!(
776 row(&rows, "t-parked").marks.waiting_too_long,
777 "both marks come back with the park gone",
778 );
779 }
780
781 #[test]
785 fn a_withdrawn_park_leaves_the_waiting_presentation() {
786 let dir = tempdir().expect("tempdir");
787 let ws = fixture(dir.path());
788 let mut row = task("t-1", TaskStatus::Pending);
789 row.verify = Some(Verify::User);
790 ws.seed_test_task(row);
791 ws.update_task(PROJECT, &TaskId::from("t-1"), By::System, |t| {
792 t.status = TaskStatus::Completed;
793 })
794 .expect("no refusal")
795 .expect("the row is there");
796
797 let on_you = |ws: &Workspace| {
798 ws.fleet_rows()
799 .into_iter()
800 .find(|r| r.project == PROJECT)
801 .expect("the project's row")
802 .waiting_on_user
803 };
804 assert_eq!(on_you(&ws), 1, "precondition: the park presents as waiting on the user");
805
806 let withdrawn = ws
807 .update_task(
808 PROJECT,
809 &TaskId::from("t-1"),
810 By::Seat(SessionSlot::lead("TestOrg", PROJECT)),
811 |t| {
812 t.status = TaskStatus::Canceled;
813 t.detail = Some("moot".to_owned());
814 },
815 )
816 .expect("no refusal")
817 .expect("the row is there");
818 assert_eq!(withdrawn.status, TaskStatus::Canceled, "the row shows canceled");
819 assert_eq!(on_you(&ws), 0, "and it leaves the waiting-on-you presentation");
820 assert_eq!(withdrawn.detail.as_deref(), Some("moot"), "its reason travels on the record");
821 }
822
823 #[test]
827 fn a_verification_park_without_an_own_gate_is_not_counted() {
828 let dir = tempdir().expect("tempdir");
829 let ws = fixture(dir.path());
830 let parked = |id: &str, verify: Option<Verify>| {
831 let mut row = task(id, TaskStatus::Waiting);
832 row.verify = verify;
833 row.waiting_on = Some(Waiting {
834 kind: Some(WaitingKind::Decision),
835 detail: None,
836 on: None,
837 verification: true,
838 parked: false,
839 since: None,
840 });
841 row
842 };
843 ws.seed_test_task(parked("t-own", Some(Verify::User)));
844 ws.seed_test_task(parked("t-inherited", None));
845
846 let on_you = ws
847 .fleet_rows()
848 .into_iter()
849 .find(|r| r.project == PROJECT)
850 .expect("the project's row")
851 .waiting_on_user;
852 assert_eq!(on_you, 1, "the own-gate park counts; the inherited shape does not");
853 }
854
855 #[test]
860 fn a_fleet_row_carries_the_waits_it_counts() {
861 let dir = tempdir().expect("tempdir");
862 let ws = fixture(dir.path());
863 let mut decision = task("t-decision", TaskStatus::Waiting);
864 decision.subject = "ship the parser".to_owned();
865 decision.updated_at = epoch(1_700_000_500);
866 decision.owner = Some(SessionSlot::worker("TestOrg", PROJECT, "w-1"));
867 decision.waiting_on = Some(Waiting {
868 kind: Some(WaitingKind::Decision),
869 detail: Some("sign this off".to_owned()),
870 on: None,
871 verification: false,
872 parked: false,
873 since: Some(epoch(1_700_000_000)),
874 });
875 ws.seed_test_task(decision);
876 let mut resource = task("t-resource", TaskStatus::Waiting);
877 resource.waiting_on = Some(Waiting {
878 kind: Some(WaitingKind::Resource),
879 detail: Some("a CI run".to_owned()),
880 on: None,
881 verification: false,
882 parked: false,
883 since: None,
884 });
885 ws.seed_test_task(resource);
886
887 let row =
888 ws.fleet_rows().into_iter().find(|r| r.project == PROJECT).expect("the project's row");
889 assert_eq!(row.waiting_on_user, 1, "only the decision wait is on the user");
890 assert_eq!(row.attention.len(), 1, "and only it is carried");
891 let item = &row.attention[0];
892 assert_eq!(item.task, "t-decision", "the row's own id");
893 assert_eq!(item.owner, "w-1", "the seat that raised it");
894 assert_eq!(item.subject, "ship the parser", "the row's subject");
895 assert_eq!(item.words, "sign this off", "the ask's own words");
896 assert_eq!(item.at, epoch(1_700_000_000), "the moment the wait began");
897 }
898
899 #[test]
904 fn a_wait_is_dated_from_its_own_start_not_its_last_edit() {
905 let dir = tempdir().expect("tempdir");
906 let ws = fixture(dir.path());
907 let mut decision = task("t-decision", TaskStatus::Waiting);
908 decision.updated_at = epoch(1_700_000_500);
909 decision.waiting_on = Some(Waiting {
910 kind: Some(WaitingKind::Decision),
911 detail: Some("sign this off".to_owned()),
912 on: None,
913 verification: false,
914 parked: false,
915 since: Some(epoch(1_700_000_000)),
916 });
917 ws.seed_test_task(decision);
918
919 let row =
920 ws.fleet_rows().into_iter().find(|r| r.project == PROJECT).expect("the project's row");
921 assert_eq!(
922 row.attention[0].at,
923 epoch(1_700_000_000),
924 "the wait's own start, not the edit that moved the row",
925 );
926 }
927
928 #[test]
932 fn a_wait_with_no_stamp_falls_back_to_its_creation() {
933 let dir = tempdir().expect("tempdir");
934 let ws = fixture(dir.path());
935 let mut decision = task("t-decision", TaskStatus::Waiting);
936 decision.created_at = epoch(1_700_000_000);
937 decision.updated_at = epoch(1_700_000_500);
938 decision.waiting_on = Some(Waiting {
939 kind: Some(WaitingKind::Decision),
940 detail: Some("sign this off".to_owned()),
941 on: None,
942 verification: false,
943 parked: false,
944 since: None,
945 });
946 ws.seed_test_task(decision);
947
948 let row =
949 ws.fleet_rows().into_iter().find(|r| r.project == PROJECT).expect("the project's row");
950 assert_eq!(row.attention[0].at, epoch(1_700_000_000), "the row's creation, not its edit");
951 }
952
953 #[test]
958 fn a_wait_without_words_falls_back_to_the_row() {
959 let dir = tempdir().expect("tempdir");
960 let ws = fixture(dir.path());
961
962 let mut blank = task("t-blank", TaskStatus::Waiting);
963 blank.detail = Some("the plan is in the row".to_owned());
964 blank.waiting_on = Some(Waiting {
965 kind: Some(WaitingKind::Decision),
966 detail: Some(" ".to_owned()),
967 on: None,
968 verification: false,
969 parked: false,
970 since: None,
971 });
972 ws.seed_test_task(blank);
973
974 let mut look = task("t-look", TaskStatus::Waiting);
975 look.verify = Some(Verify::User);
976 look.waiting_on = Some(Waiting {
977 kind: Some(WaitingKind::Decision),
978 detail: None,
979 on: None,
980 verification: true,
981 parked: false,
982 since: None,
983 });
984 ws.seed_test_task(look);
985
986 let mut bare = task("t-bare", TaskStatus::Waiting);
987 bare.waiting_on = Some(Waiting {
988 kind: Some(WaitingKind::Decision),
989 detail: None,
990 on: None,
991 verification: false,
992 parked: false,
993 since: None,
994 });
995 ws.seed_test_task(bare);
996
997 let mut nameless = task("t-nameless", TaskStatus::Waiting);
1000 nameless.subject = " ".to_owned();
1001 nameless.waiting_on = Some(Waiting {
1002 kind: Some(WaitingKind::Decision),
1003 detail: None,
1004 on: None,
1005 verification: false,
1006 parked: false,
1007 since: None,
1008 });
1009 ws.seed_test_task(nameless);
1010
1011 let row =
1012 ws.fleet_rows().into_iter().find(|r| r.project == PROJECT).expect("the project's row");
1013 let words = |task: &str| {
1014 row.attention.iter().find(|item| item.task == task).expect("the item").words.clone()
1015 };
1016 assert_eq!(words("t-blank"), "the plan is in the row", "a blank detail takes the row's");
1017 assert_eq!(words("t-look"), "the look is owed", "a verification park says what it wants");
1018 assert_eq!(
1019 words("t-nameless"),
1020 "A decision is waiting on you.",
1021 "a blank subject still words the ask"
1022 );
1023 assert_eq!(words("t-bare"), "subject t-bare", "nothing anywhere takes the subject");
1024 }
1025
1026 #[test]
1027 fn a_worker_live_with_no_open_row_is_unaccounted() {
1028 let dir = tempdir().expect("tempdir");
1029 let ws = fixture(dir.path());
1030 live_worker(&ws, "w-1");
1031 assert_eq!(
1032 ws.unaccounted(PROJECT),
1033 vec!["w-1".to_owned()],
1034 "a live worker holding no row is unaccounted",
1035 );
1036
1037 let mut held = task("t-1", TaskStatus::Pending);
1038 held.owner = Some(SessionSlot::worker("TestOrg", PROJECT, "w-1"));
1039 ws.seed_test_task(held);
1040 assert!(ws.unaccounted(PROJECT).is_empty(), "a held row accounts for its worker");
1041 }
1042
1043 #[test]
1047 fn an_epic_whose_children_are_all_terminal_reads_to_close() {
1048 let dir = tempdir().expect("tempdir");
1049 let ws = fixture(dir.path());
1050 let mut epic = task("epic", TaskStatus::InProgress);
1051 epic.links.push(TaskLink {
1052 kind: LinkKind::Path,
1053 label: Some("retro".to_owned()),
1054 target: "docs/retro.md".to_owned(),
1055 state: None,
1056 added_at: epoch(0),
1057 });
1058 ws.seed_test_task(epic);
1059 let mut done = task("sub-a", TaskStatus::Completed);
1060 done.parent = Some(TaskId::from("epic"));
1061 let mut dropped = task("sub-b", TaskStatus::Canceled);
1062 dropped.parent = Some(TaskId::from("epic"));
1063 ws.seed_test_task(done);
1064 ws.seed_test_task(dropped);
1065
1066 let rows = ws.board_rows(PROJECT, epoch(10), WINDOW);
1067 let epic_row = row(&rows, "epic");
1068 assert!(epic_row.marks.to_close, "every child is terminal and the retro ran");
1069 assert_eq!(
1070 epic_row.rollup,
1071 Some((1, 2)),
1072 "canceled is terminal for closing but never counted done",
1073 );
1074 assert_eq!(row(&rows, "sub-a").parent_subject.as_deref(), Some("subject epic"));
1075 }
1076
1077 #[test]
1080 fn an_epic_with_an_open_child_or_no_retro_does_not_read_to_close() {
1081 let dir = tempdir().expect("tempdir");
1082 let ws = fixture(dir.path());
1083 ws.seed_test_task(task("epic", TaskStatus::InProgress));
1084 let mut done = task("sub-a", TaskStatus::Completed);
1085 done.parent = Some(TaskId::from("epic"));
1086 ws.seed_test_task(done);
1087 let mut open = task("sub-b", TaskStatus::Pending);
1088 open.parent = Some(TaskId::from("epic"));
1089 ws.seed_test_task(open);
1090
1091 let rows = ws.board_rows(PROJECT, epoch(10), WINDOW);
1092 assert!(!row(&rows, "epic").marks.to_close, "an open child holds the epic open");
1093
1094 ws.update_task(PROJECT, &TaskId::from("sub-b"), By::System, |t| {
1096 t.status = TaskStatus::Canceled;
1097 })
1098 .expect("no refusal")
1099 .expect("the child is there");
1100 let rows = ws.board_rows(PROJECT, epoch(10), WINDOW);
1101 assert!(
1102 !row(&rows, "epic").marks.to_close,
1103 "terminal children without a retro still hold the epic open",
1104 );
1105 }
1106
1107 #[test]
1109 fn a_stalled_queue_is_a_miss_on_the_fleet() {
1110 let dir = tempdir().expect("tempdir");
1111 let ws = fixture(dir.path());
1112 live_worker(&ws, "w-1");
1113 ws.seed_test_task(task("queued", TaskStatus::Pending));
1114
1115 let fleet = ws.fleet_rows();
1116 let row = fleet.iter().find(|row| row.project == PROJECT).expect("the project's row");
1117 assert_eq!(row.queue, 1, "one ready unowned row is the queue");
1118 assert_eq!(row.slots, Some(2), "the default worker cap");
1119 assert!(
1120 row.misses.contains(&Miss::StalledQueue),
1121 "a free slot beside a queue is a miss: {row:?}",
1122 );
1123 assert!(
1124 row.misses.contains(&Miss::UnaccountedWorker("w-1".to_owned())),
1125 "and the worker holding nothing is named",
1126 );
1127 }
1128}