Skip to main content

forge_primitives/
tasks.rs

1//! Task records: the shape a declared piece of live work takes as it
2//! crosses crate boundaries.
3
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8use crate::SessionSlot;
9
10/// Identifies a task within its project.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct TaskId(String);
13
14impl TaskId {
15    pub fn as_str(&self) -> &str {
16        &self.0
17    }
18}
19
20impl From<String> for TaskId {
21    fn from(value: String) -> Self {
22        Self(value)
23    }
24}
25
26impl From<&str> for TaskId {
27    fn from(value: &str) -> Self {
28        Self(value.to_owned())
29    }
30}
31
32/// Where a task is in its life: `waiting` says what it waits on, and
33/// `failed` and `canceled` are terminal without being done.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum TaskStatus {
37    Pending,
38    InProgress,
39    Waiting,
40    Completed,
41    Failed,
42    Canceled,
43}
44
45/// What a waiting row waits on. A decision routes to the user; a
46/// dependency and a resource route to the lead.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum WaitingKind {
50    Decision,
51    Dependency,
52    Resource,
53}
54
55/// A lead's record of a look approval it witnessed, written when the lead
56/// completes a look wait on the user's behalf. Only a lead attests, and
57/// only a look: a decision is the user's to answer.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Attestation {
60    /// The approval quoted from the user, as the lead read it.
61    pub evidence: String,
62    pub at: SystemTime,
63}
64
65/// Who reserved a row's pick. The sweep leaves a reserved pick for the
66/// user rather than dispatching it.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ReservedBy {
70    User,
71}
72
73/// Why a row cannot proceed. `kind` is absent only for a row migrated from
74/// the old `blocked` spelling, which the board draws as an unstated wait.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct Waiting {
77    pub kind: Option<WaitingKind>,
78    pub detail: Option<String>,
79    /// The task this row waits on, for a dependency.
80    pub on: Option<TaskId>,
81    /// The verify gate: the board's action is approve / send back rather
82    /// than answer.
83    pub verification: bool,
84    /// The lead classified this wait as parked by design - the user's own
85    /// deferral - so the chase leaves it alone rather than nudging it.
86    #[serde(default)]
87    pub parked: bool,
88    /// When the wait began, stamped as it is raised. `None` on a row stored
89    /// before the field existed, which reads back as its creation.
90    #[serde(default)]
91    pub since: Option<SystemTime>,
92}
93
94/// A duration estimate: the words a reader sees ("2h", "1d") and the
95/// seconds the chase compares against worked time.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Estimate {
98    pub words: String,
99    pub secs: u64,
100}
101
102impl Estimate {
103    /// Parse "30m" / "2h" / "1d" / "1w": digits then one unit, nothing
104    /// else. The one grammar the tools, the store's migration and the
105    /// board all read.
106    pub fn parse(words: &str) -> Option<Estimate> {
107        let words = words.trim();
108        let split = words.find(|c: char| !c.is_ascii_digit())?;
109        let (digits, unit) = words.split_at(split);
110        let n: u64 = digits.parse().ok()?;
111        let secs = match unit {
112            "m" => 60,
113            "h" => 3_600,
114            "d" => 86_400,
115            "w" => 604_800,
116            _ => return None,
117        };
118        (n > 0).then(|| Estimate { words: words.to_owned(), secs: n * secs })
119    }
120}
121
122/// Whether a row's completion waits on the user's look.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum Verify {
126    User,
127    None,
128}
129
130/// What a link points at. Generic to any VCS: a GitHub pull request and a
131/// GitLab merge request are both `Pr`.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum LinkKind {
135    Spec,
136    Plan,
137    Issue,
138    Pr,
139    Branch,
140    Path,
141    Other,
142}
143
144impl LinkKind {
145    /// Classify a bare target by its shape: a target containing `/pull/`
146    /// reads `Pr`, everything else `Path`. Nothing decides on the kind, so a
147    /// miss costs a drawn word - and the key is GitHub's own path, so a
148    /// GitLab merge request reads `Path` until a shape names it.
149    pub fn for_target(target: &str) -> Self {
150        if target.contains("/pull/") { Self::Pr } else { Self::Path }
151    }
152}
153
154/// One reference a row carries: the epic's spec and plan, a task's issues
155/// and PRs, anything else.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct TaskLink {
158    pub kind: LinkKind,
159    pub label: Option<String>,
160    /// A url or a path.
161    pub target: String,
162    /// Read back where forge can (open / closed / merged).
163    pub state: Option<String>,
164    pub added_at: SystemTime,
165}
166
167/// One piece of live work in one project.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct Task {
170    pub id: TaskId,
171    pub project_name: String,
172    pub subject: String,
173    /// The in-progress wording. The renderer uses it for a running row,
174    /// so it is carried rather than derived.
175    pub active_form: Option<String>,
176    pub detail: Option<String>,
177    pub status: TaskStatus,
178    /// The session holding it. `None` is `unclaimed`.
179    pub owner: Option<SessionSlot>,
180    /// The task this one belongs under, in the same project.
181    pub parent: Option<TaskId>,
182    /// Why this row cannot proceed, when `status` is waiting.
183    #[serde(default)]
184    pub waiting_on: Option<Waiting>,
185    pub estimate: Option<Estimate>,
186    /// Queue order; lower reads first, ties break by creation.
187    #[serde(default)]
188    pub rank: Option<i64>,
189    /// The user-look gate the row was minted with. An epic's gate governs
190    /// the epic's own completion; a child never inherits it.
191    #[serde(default)]
192    pub verify: Option<Verify>,
193    /// The lead's attestation when it completed the look on the user's
194    /// behalf; `None` for the user's own approve and for rows never gated.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub attested: Option<Attestation>,
197    /// The user reserved this row's pick: the sweep leaves it for them.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub reserved: Option<ReservedBy>,
200    #[serde(default)]
201    pub links: Vec<TaskLink>,
202    /// Claims and send-backs, so a loop is visible.
203    #[serde(default)]
204    pub attempt: u32,
205    /// Set when the tree archived; archived rows are history, not live work.
206    #[serde(default)]
207    pub archived_at: Option<SystemTime>,
208    pub created_at: SystemTime,
209    pub updated_at: SystemTime,
210}
211
212/// Who moved a row: the seat that called, the user from the board, or the
213/// core itself (a cleared dependency, the verify gate, archiving).
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216pub enum By {
217    Seat(SessionSlot),
218    User,
219    System,
220}
221
222/// One status transition, appended to the history the board's worked time
223/// and the retro's metrics are computed from.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225pub struct TaskTransition {
226    pub task_id: TaskId,
227    pub project_name: String,
228    pub from: Option<TaskStatus>,
229    pub to: TaskStatus,
230    pub at: SystemTime,
231    pub by: By,
232}
233
234/// A chase rung that fired. Each fires once per row (or seat) per
235/// threshold; the chase log is what makes that a fact rather than a hope.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum ChaseRung {
239    EstimateNudge,
240    Escalate,
241    QueueStall,
242    WaitStall,
243    /// A wait resolved: the waiter hears once that the thing it waited on
244    /// has ended.
245    WaitResolved,
246    Unaccounted,
247    Death,
248    Retro,
249    EpicClose,
250    /// One row parked on the user, untouched: one nudge per row per wait
251    /// episode asks the lead to act on it - the chase never spawns a
252    /// session for it.
253    UserWaitDigest,
254}
255
256/// One fired chase: the row it was about (absent for a seat-level rung),
257/// the rung's own subject where a row id does not identify it, and when.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct TaskChase {
260    pub task_id: Option<TaskId>,
261    /// The rung's subject where a row id does not identify it: the
262    /// worker a seat-level rung names, or the estimate a nudge fired
263    /// against - so a restated estimate arms the nudge again. `default`
264    /// reads a row written before the field.
265    #[serde(default)]
266    pub subject: Option<String>,
267    pub project_name: String,
268    pub rung: ChaseRung,
269    pub at: SystemTime,
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn sample_task() -> Task {
277        Task {
278            id: TaskId::from("t-1"),
279            project_name: "forge".to_owned(),
280            subject: "Merge peers and workers into agents".to_owned(),
281            active_form: Some("Merging peers and workers into agents".to_owned()),
282            detail: Some("One agents__ MCP family.".to_owned()),
283            status: TaskStatus::InProgress,
284            owner: Some(SessionSlot::worker("Busytools", "forge", "agents-merge")),
285            parent: None,
286            estimate: Some(Estimate { words: "1d".to_owned(), secs: 86_400 }),
287            rank: Some(10),
288            verify: Some(Verify::User),
289            attested: Some(Attestation {
290                evidence: "looks good".to_owned(),
291                at: std::time::SystemTime::UNIX_EPOCH,
292            }),
293            reserved: Some(ReservedBy::User),
294            links: vec![TaskLink {
295                kind: LinkKind::Pr,
296                label: Some("PR #1889".to_owned()),
297                target: "https://example.invalid/pull/1889".to_owned(),
298                state: Some("open".to_owned()),
299                added_at: std::time::SystemTime::UNIX_EPOCH,
300            }],
301            waiting_on: None,
302            attempt: 2,
303            archived_at: None,
304            created_at: std::time::SystemTime::UNIX_EPOCH,
305            updated_at: std::time::SystemTime::UNIX_EPOCH,
306        }
307    }
308
309    #[test]
310    fn task_round_trips_through_json_with_every_field_set() {
311        let task = sample_task();
312        let json = serde_json::to_vec(&task).expect("serialize");
313        let back: Task = serde_json::from_slice(&json).expect("deserialize");
314        assert_eq!(back, task, "every field survives a store round trip");
315    }
316
317    #[test]
318    fn a_waiting_row_carries_its_kind_and_the_task_it_waits_on() {
319        let waiting = Waiting {
320            kind: Some(WaitingKind::Dependency),
321            detail: Some("the board read has to land first".to_owned()),
322            on: Some(TaskId::from("t-9")),
323            verification: false,
324            parked: false,
325            since: None,
326        };
327        let json = serde_json::to_vec(&waiting).expect("serialize");
328        let back: Waiting = serde_json::from_slice(&json).expect("deserialize");
329        assert_eq!(back, waiting, "a dependency wait keeps its blocker");
330    }
331
332    /// The migration's unstated wait: a stored `blocked` row lands here, and
333    /// the board draws it as a miss rather than a blank.
334    #[test]
335    fn waiting_without_a_kind_is_representable() {
336        let waiting = Waiting {
337            kind: None,
338            detail: None,
339            on: None,
340            verification: false,
341            parked: false,
342            since: None,
343        };
344        let json = serde_json::to_vec(&waiting).expect("serialize");
345        let back: Waiting = serde_json::from_slice(&json).expect("deserialize");
346        assert_eq!(back.kind, None);
347    }
348
349    #[test]
350    fn an_estimate_keeps_its_words_and_its_seconds() {
351        let estimate = Estimate { words: "1d".to_owned(), secs: 86_400 };
352        let json = serde_json::to_vec(&estimate).expect("serialize");
353        let back: Estimate = serde_json::from_slice(&json).expect("deserialize");
354        assert_eq!(
355            back, estimate,
356            "the words are what a reader sees; the seconds are what the chase reads",
357        );
358    }
359
360    #[test]
361    fn a_link_keeps_its_kind_label_target_and_state() {
362        let link = TaskLink {
363            kind: LinkKind::Pr,
364            label: Some("PR #1889".to_owned()),
365            target: "https://example.invalid/pull/1".to_owned(),
366            state: Some("open".to_owned()),
367            added_at: std::time::SystemTime::UNIX_EPOCH,
368        };
369        let json = serde_json::to_vec(&link).expect("serialize");
370        let back: TaskLink = serde_json::from_slice(&json).expect("deserialize");
371        assert_eq!(back, link);
372    }
373
374    /// A bare target's shape is all a fold can know: a pull-request url
375    /// reads `Pr`, everything else `Path`. Nothing reads the kind to make
376    /// a decision, so a miss costs a drawn word, not a step.
377    #[test]
378    fn a_target_is_classified_from_its_shape() {
379        assert_eq!(
380            LinkKind::for_target("https://github.com/busytools/forge/pull/1889"),
381            LinkKind::Pr,
382        );
383        assert_eq!(LinkKind::for_target("docs/superpowers/specs/x.md"), LinkKind::Path);
384    }
385
386    /// The grammar the tools and the store share: digits then one unit.
387    #[test]
388    fn an_estimate_parses_its_grammar() {
389        assert_eq!(Estimate::parse("30m"), Some(Estimate { words: "30m".to_owned(), secs: 1_800 }));
390        assert_eq!(Estimate::parse("2h"), Some(Estimate { words: "2h".to_owned(), secs: 7_200 }));
391        assert_eq!(Estimate::parse("1d"), Some(Estimate { words: "1d".to_owned(), secs: 86_400 }));
392        assert_eq!(Estimate::parse("1w"), Some(Estimate { words: "1w".to_owned(), secs: 604_800 }));
393        for refused in ["soonish", "1x", "0h", "d", "1 d", ""] {
394            assert_eq!(Estimate::parse(refused), None, "{refused:?} is not a duration");
395        }
396    }
397
398    #[test]
399    fn a_transition_records_from_to_at_and_by() {
400        let transition = TaskTransition {
401            task_id: TaskId::from("t-1"),
402            project_name: "forge".to_owned(),
403            from: Some(TaskStatus::Pending),
404            to: TaskStatus::InProgress,
405            at: std::time::SystemTime::UNIX_EPOCH,
406            by: By::Seat(SessionSlot::worker("Busytools", "forge", "task-board")),
407        };
408        let json = serde_json::to_vec(&transition).expect("serialize");
409        let back: TaskTransition = serde_json::from_slice(&json).expect("deserialize");
410        assert_eq!(back, transition);
411        let json = serde_json::to_vec(&By::System).expect("serialize");
412        assert_eq!(serde_json::from_slice::<By>(&json).expect("deserialize"), By::System);
413    }
414
415    /// A chase may belong to a seat rather than a row - the unaccounted
416    /// rung names a worker, not a task.
417    #[test]
418    fn a_chase_records_its_rung_and_may_have_no_task() {
419        let chase = TaskChase {
420            task_id: None,
421            subject: Some("w-1".to_owned()),
422            project_name: "forge".to_owned(),
423            rung: ChaseRung::Unaccounted,
424            at: std::time::SystemTime::UNIX_EPOCH,
425        };
426        let json = serde_json::to_vec(&chase).expect("serialize");
427        let back: TaskChase = serde_json::from_slice(&json).expect("deserialize");
428        assert_eq!(back, chase);
429        // **A row written before the field still reads.** The log's rows
430        // outlive the struct that wrote them, so a decode that demanded the
431        // key would unread every chase already stored.
432        let mut older: serde_json::Value = serde_json::from_slice(&json).expect("the row as data");
433        older.as_object_mut().expect("a row is an object").remove("subject");
434        let read: TaskChase =
435            serde_json::from_value(older).expect("a row written before the field decodes");
436        assert_eq!(read.subject, None, "and carries no subject");
437        assert_eq!(
438            serde_json::to_string(&ChaseRung::EstimateNudge).expect("serialize"),
439            "\"estimate_nudge\"",
440            "the rung's spelling is what the store reads back",
441        );
442        assert_eq!(
443            serde_json::to_string(&ChaseRung::UserWaitDigest).expect("serialize"),
444            "\"user_wait_digest\"",
445            "the newest rung's spelling is what the store reads back",
446        );
447    }
448
449    /// The spelling each status takes in the store and in the tools'
450    /// `status` enum. Rows are written with no schema on disk, so a renamed
451    /// spelling silently unreads every task already written under the old
452    /// one.
453    #[test]
454    fn the_status_spellings_the_store_and_the_tools_share_are_stable() {
455        let spellings: Vec<String> = [
456            TaskStatus::Pending,
457            TaskStatus::InProgress,
458            TaskStatus::Waiting,
459            TaskStatus::Completed,
460            TaskStatus::Failed,
461            TaskStatus::Canceled,
462        ]
463        .into_iter()
464        .map(|status| serde_json::to_string(&status).expect("serialize"))
465        .collect();
466        assert_eq!(
467            spellings,
468            [
469                "\"pending\"",
470                "\"in_progress\"",
471                "\"waiting\"",
472                "\"completed\"",
473                "\"failed\"",
474                "\"canceled\""
475            ],
476            "the status spellings the tools offer and the store reads back",
477        );
478    }
479}