Skip to main content

forge_workspace/
board.rs

1//! The board: one answer for every derived fact a reader of the task
2//! list needs.
3//!
4//! Worked time, ages, and the marks (ready, in review, overdue, no
5//! movement, waiting too long, stale, to close) are derived here from the
6//! store, the history and session liveness - never stored. The tools, the
7//! chase and the view surface all read THIS answer, so a mark cannot
8//! disagree between two surfaces.
9
10use std::collections::HashMap;
11use std::time::SystemTime;
12
13use forge_primitives::tasks::{LinkKind, Task, TaskId, TaskStatus, TaskTransition};
14
15use crate::workspace::Workspace;
16
17/// The no-estimate no-movement window when `forge.toml` says nothing.
18pub const DEFAULT_STALE_SECS: u64 = 4 * 3600;
19
20/// How many of a row's own transitions a board row carries.
21///
22/// The newest this many, at a measured ~250 bytes per transition - so a
23/// capped row weighs ~5 KiB and a row has to move more than twenty times
24/// before anything is withheld. The whole count rides beside the tail in
25/// `history_total`, so what the cap withholds is stated rather than silent.
26pub const HISTORY_CAP: usize = 20;
27
28/// A duration as the board's words: `3h`, `12m`, `45s`.
29pub(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/// The derived facts about one row, as the board draws them.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub struct Marks {
43    /// Pending with nothing waiting on it and not reserved for the user.
44    pub ready: bool,
45    /// Has a pull-request link not known to be merged.
46    pub in_review: bool,
47    /// Its worked time is past its estimate.
48    pub overdue: bool,
49    /// No touch for longer than the window.
50    pub no_movement: bool,
51    /// Waiting for longer than the window.
52    pub waiting_too_long: bool,
53    /// Its owner has no session behind it.
54    pub stale: bool,
55    /// Every child terminal, its retro run - an epic the lead may close.
56    pub to_close: bool,
57}
58
59/// One row as the board reads it: the record plus what is derived.
60#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub struct BoardRow {
63    pub task: Task,
64    /// The sum of its in_progress intervals, from the history.
65    pub worked_secs: u64,
66    /// Time since any touch.
67    pub updated_secs_ago: u64,
68    pub marks: Marks,
69    /// A parent's children: how many are completed, of how many.
70    pub rollup: Option<(usize, usize)>,
71    /// A child's parent subject.
72    pub parent_subject: Option<String>,
73    /// The row's own transitions, in time order - the newest
74    /// [`HISTORY_CAP`] of them, which is what the detail drawer draws when
75    /// it opens. The tail beyond the cap is stated by `history_total` rather
76    /// than carried: the walk below already groups the transitions for the
77    /// worked time, and carrying the whole trail costs every `board_rows`
78    /// caller - every board read and every `tasks__list` - the record's full
79    /// weight.
80    ///
81    /// `default` is backward compatibility with older records, and nothing
82    /// more: no server-side pin decodes a `BoardRow`, so nothing in the tree
83    /// depends on it - a capture written before the field can still be read
84    /// wherever one is.
85    #[serde(default)]
86    pub history: Vec<TaskTransition>,
87    /// How many transitions the row has in all - never fewer than
88    /// `history.len()`. The drawer words its `showing the last N of M` from
89    /// it, so a capped tail is stated rather than silent.
90    #[serde(default)]
91    pub history_total: usize,
92}
93
94/// One named miss on a project's fleet row.
95#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum Miss {
98    /// Ready rows and a free worker slot: dispatch is the lead's job.
99    StalledQueue,
100    /// A live worker holding no row; it should be fed or despawned.
101    UnaccountedWorker(String),
102}
103
104/// One wait that is on the person, named for the surface that raises it.
105///
106/// **The board is the store; this is the read.** A decision wait is already a
107/// row with a `waiting_on` block, and the rail draws this beside the count so
108/// the person can see what is being asked without opening the board page.
109#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub struct AttentionItem {
112    /// The task's id: the wait's identity, so a raise is a new id or a new
113    /// `at` rather than a second item for the same ask.
114    pub task: String,
115    /// The seat that raised it, by its session label.
116    pub owner: String,
117    /// The row's subject, in the row's own words.
118    pub subject: String,
119    /// The ask's own words, verbatim.
120    pub words: String,
121    /// When the wait began, stamped as it is raised; a stored row without
122    /// one falls back to its creation.
123    pub at: SystemTime,
124}
125
126/// One project as the fleet page draws it.
127#[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    /// The project's worker cap, resolved (override or default).
133    pub slots: Option<usize>,
134    /// Ready unowned rows.
135    pub queue: usize,
136    /// Rows waiting on the user's decision.
137    pub waiting_on_user: usize,
138    /// The waits themselves, so the mark beside the count can say what is
139    /// being asked without the board page.
140    pub attention: Vec<AttentionItem>,
141    /// Every row the project holds, which the home's header totals.
142    pub rows: usize,
143    pub misses: Vec<Miss>,
144}
145
146/// The sum of a row's in_progress intervals, from its transitions.
147///
148/// `mine` is that one row's transitions, in time order. They are grouped and
149/// sorted once for the whole board rather than filtered per row: the log is
150/// read on every board read and every task write, and scanning it once per row
151/// makes each of those O(rows x history).
152fn 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
171/// The tail of a row's transitions a board row carries: the newest
172/// [`HISTORY_CAP`], in time order. `mine` is that one row's transitions,
173/// already sorted.
174fn 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
187/// The retro marker: a link labelled `retro` on the epic.
188fn retro_ran(task: &Task) -> bool {
189    task.links.iter().any(|l| l.label.as_deref() == Some("retro"))
190}
191
192/// The ask's own words, as the mark and its popover word it.
193///
194/// A detail-less wait - and a verification park, which often carries none -
195/// would otherwise draw a blank line and notify with an empty body; the row's
196/// own detail, the look a park is owed, and the subject are each a truer
197/// answer than nothing.
198fn 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        // Nothing requires a subject to be non-empty; the board words a
211        // detail-less decision the same way rather than drawing a blank.
212        "A decision is waiting on you.".to_owned()
213    } else {
214        subject.to_owned()
215    }
216}
217
218impl Workspace {
219    /// Every live row of `project`, with its derived facts.
220    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        // Every row's own transitions, in time order, from ONE walk of the
232        // log: the read below is per row, and the log grows without bound.
233        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                // A wait parked by design is the user's own deferral: its age
247                // is not a stall (#2472).
248                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    /// One row per project, for the fleet page.
291    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                // The wait's own start, stamped as the wait is raised:
319                // `updated_at` moves on any edit of a waiting row, and a
320                // notification keyed on it would fire again for an edit that
321                // answered nothing. A row stored before the field existed
322                // falls back to its creation, not to that edit.
323                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    /// Live workers of `project` holding no open row.
361    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    /// Two in-progress intervals with a wait between them: only the
483    /// intervals count, and the wait is what the estimate is spared.
484    #[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        // A second row, whose own intervals sit between the first's: the
506        // transitions are grouped once for the whole board, so a grouping that
507        // mixed two rows' logs would show up here and nowhere else.
508        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    /// A row past the cap carries its newest [`HISTORY_CAP`] transitions with
535    /// its true count beside them - and what the row DERIVES still reads the
536    /// whole trail: the worked time is measured before the cap bites, so a
537    /// cap applied to the walk's own list would lose the earliest interval.
538    #[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        // Thirty moves at ten-second beats, in progress on the even ones and
546        // settled on the odd: fifteen ten-second intervals, the first of them
547        // outside the newest twenty.
548        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    /// A row under the cap is untouched: every transition rides, and the
576    /// count is the list's own length.
577    #[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    /// A fresh update does not clear an overrun: the clock is worked
601    /// time, not time since the last touch.
602    #[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    /// A row without an estimate falls back to the no-movement window,
627    /// which is its own mark - not an overdue one.
628    #[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    /// A row can be Pending and still not ready. The wait outlives the status
648    /// a mover left on it - a `tasks__update` to `pending` does not clear
649    /// `waiting_on` - and `claim_task` refuses exactly that row, so a mark
650    /// that read the status alone would say ready where the claim says no.
651    #[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        // The control: the mark can say ready, so the assertion above is
673        // reading the conjunct rather than a mark that never fires.
674        assert!(row(&rows, "t-plain").marks.ready, "the plain row is the one a seeker can take");
675    }
676
677    /// A pick reserved for the user is not ready and not in the queue the
678    /// fleet counts (#2423/C): no seat can take it, so it is no work the
679    /// project is failing to dispatch.
680    #[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    /// A wait parked by design is the user's own deferral, so the row carries
718    /// neither stalled mark however old it is; un-parking restores both from
719    /// its real age (#2472).
720    #[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    /// A withdrawn park leaves the waiting-on-you presentation (#2340):
782    /// the fleet's count drops and the row reads canceled, its reason on
783    /// the record.
784    #[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    /// The fleet's count is the same set the chase digests (#2423): a
824    /// verification park belongs to the row's own gate, so the shape an
825    /// inherited gate left behind is not counted as waiting on the user.
826    #[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    /// A project's fleet row carries the waits it counts, named for the
856    /// surface that raises them (#2756): the row, its seat, its subject, the
857    /// ask's own words, and when it began. A resource wait is counted by
858    /// neither.
859    #[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    /// **The wait is dated from its own start, not the row's last edit.** A
900    /// waiting row that gains a link, is parked or is dragged moves
901    /// `updated_at`, and a notification keyed on that fires again for an edit
902    /// that answered nothing.
903    #[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    /// **A wait stored before the field existed reads as its creation.** The
929    /// fallback is `created_at`, never `updated_at`: a row edited after it
930    /// began must not re-notify.
931    #[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    /// **A wait with no words still says something.** A verification park and
954    /// a detail-less decision wait would otherwise draw a blank line and
955    /// notify with an empty body; the row's detail, the look a park is owed
956    /// and the subject are each a truer answer than nothing.
957    #[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        // Nothing requires a subject to be non-empty: a blank one still has
998        // words rather than an empty notification body.
999        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    /// An epic whose children have all stopped - completed and canceled
1044    /// alike - with its retro run reads to close; the rollup counts the
1045    /// completed ones only.
1046    #[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    /// A completed child keeps an open sibling from closing the epic, and
1078    /// the missing retro keeps it open too.
1079    #[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        // The other half: same epic, every child terminal, no retro yet.
1095        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    /// Free slots beside a non-empty queue is the miss the board names.
1108    #[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}