1use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8use crate::SessionSlot;
9
10#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Attestation {
60 pub evidence: String,
62 pub at: SystemTime,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ReservedBy {
70 User,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct Waiting {
77 pub kind: Option<WaitingKind>,
78 pub detail: Option<String>,
79 pub on: Option<TaskId>,
81 pub verification: bool,
84 #[serde(default)]
87 pub parked: bool,
88 #[serde(default)]
91 pub since: Option<SystemTime>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Estimate {
98 pub words: String,
99 pub secs: u64,
100}
101
102impl Estimate {
103 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum Verify {
126 User,
127 None,
128}
129
130#[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 pub fn for_target(target: &str) -> Self {
150 if target.contains("/pull/") { Self::Pr } else { Self::Path }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct TaskLink {
158 pub kind: LinkKind,
159 pub label: Option<String>,
160 pub target: String,
162 pub state: Option<String>,
164 pub added_at: SystemTime,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct Task {
170 pub id: TaskId,
171 pub project_name: String,
172 pub subject: String,
173 pub active_form: Option<String>,
176 pub detail: Option<String>,
177 pub status: TaskStatus,
178 pub owner: Option<SessionSlot>,
180 pub parent: Option<TaskId>,
182 #[serde(default)]
184 pub waiting_on: Option<Waiting>,
185 pub estimate: Option<Estimate>,
186 #[serde(default)]
188 pub rank: Option<i64>,
189 #[serde(default)]
192 pub verify: Option<Verify>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub attested: Option<Attestation>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub reserved: Option<ReservedBy>,
200 #[serde(default)]
201 pub links: Vec<TaskLink>,
202 #[serde(default)]
204 pub attempt: u32,
205 #[serde(default)]
207 pub archived_at: Option<SystemTime>,
208 pub created_at: SystemTime,
209 pub updated_at: SystemTime,
210}
211
212#[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#[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#[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 WaitResolved,
246 Unaccounted,
247 Death,
248 Retro,
249 EpicClose,
250 UserWaitDigest,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct TaskChase {
260 pub task_id: Option<TaskId>,
261 #[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 #[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 #[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 #[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 #[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 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 #[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}