1use std::fmt;
2
3use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
4
5use crate::error::{Error, Result};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum TaskState {
9 Proposed,
10 Refining,
14 Parked,
15 Ready,
16 Running,
17 NeedsInput,
18 Review,
19 Waiting,
20 Stalled,
21 Done,
22 Rejected,
23}
24
25impl TaskState {
26 pub const ALL: [TaskState; 11] = [
27 TaskState::Proposed,
28 TaskState::Refining,
29 TaskState::Parked,
30 TaskState::Ready,
31 TaskState::Running,
32 TaskState::NeedsInput,
33 TaskState::Review,
34 TaskState::Waiting,
35 TaskState::Stalled,
36 TaskState::Done,
37 TaskState::Rejected,
38 ];
39
40 pub fn as_str(self) -> &'static str {
41 match self {
42 TaskState::Proposed => "proposed",
43 TaskState::Refining => "refining",
44 TaskState::Parked => "parked",
45 TaskState::Ready => "ready",
46 TaskState::Running => "running",
47 TaskState::NeedsInput => "needs-input",
48 TaskState::Review => "review",
49 TaskState::Waiting => "waiting",
50 TaskState::Stalled => "stalled",
51 TaskState::Done => "done",
52 TaskState::Rejected => "rejected",
53 }
54 }
55
56 pub fn parse(s: &str) -> Result<TaskState> {
57 Self::ALL
58 .into_iter()
59 .find(|state| state.as_str() == s)
60 .ok_or_else(|| Error::Invalid(format!("unknown task state '{s}'")))
61 }
62
63 pub fn is_terminal(self) -> bool {
65 matches!(self, TaskState::Done | TaskState::Rejected)
66 }
67}
68
69impl fmt::Display for TaskState {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.pad(self.as_str())
72 }
73}
74
75impl FromSql for TaskState {
76 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
77 let s = value.as_str()?;
78 TaskState::parse(s).map_err(|e| FromSqlError::Other(Box::new(e)))
79 }
80}
81
82impl ToSql for TaskState {
83 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
84 Ok(self.as_str().into())
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
89pub enum Priority {
90 P0,
91 P1,
92 P2,
93 P3,
94}
95
96impl Priority {
97 pub fn from_int(n: i64) -> Result<Priority> {
98 match n {
99 0 => Ok(Priority::P0),
100 1 => Ok(Priority::P1),
101 2 => Ok(Priority::P2),
102 3 => Ok(Priority::P3),
103 _ => Err(Error::Invalid(format!("priority {n} out of range 0-3"))),
104 }
105 }
106
107 pub fn as_int(self) -> i64 {
108 match self {
109 Priority::P0 => 0,
110 Priority::P1 => 1,
111 Priority::P2 => 2,
112 Priority::P3 => 3,
113 }
114 }
115
116 pub fn value(self) -> f64 {
118 match self {
119 Priority::P0 => 8.0,
120 Priority::P1 => 4.0,
121 Priority::P2 => 2.0,
122 Priority::P3 => 1.0,
123 }
124 }
125}
126
127impl fmt::Display for Priority {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 let s = match self {
130 Priority::P0 => "P0",
131 Priority::P1 => "P1",
132 Priority::P2 => "P2",
133 Priority::P3 => "P3",
134 };
135 f.pad(s)
136 }
137}
138
139impl FromSql for Priority {
140 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
141 Priority::from_int(value.as_i64()?).map_err(|e| FromSqlError::Other(Box::new(e)))
142 }
143}
144
145impl ToSql for Priority {
146 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
147 Ok(self.as_int().into())
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub enum DepKind {
153 Blocks,
154 DiscoveredFrom,
155 Parent,
156 Related,
157}
158
159impl DepKind {
160 pub const ALL: [DepKind; 4] = [
161 DepKind::Blocks,
162 DepKind::DiscoveredFrom,
163 DepKind::Parent,
164 DepKind::Related,
165 ];
166
167 pub fn as_str(self) -> &'static str {
168 match self {
169 DepKind::Blocks => "blocks",
170 DepKind::DiscoveredFrom => "discovered-from",
171 DepKind::Parent => "parent",
172 DepKind::Related => "related",
173 }
174 }
175
176 pub fn parse(s: &str) -> Result<DepKind> {
177 Self::ALL
178 .into_iter()
179 .find(|kind| kind.as_str() == s)
180 .ok_or_else(|| Error::Invalid(format!("unknown dep kind '{s}'")))
181 }
182}
183
184impl fmt::Display for DepKind {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 f.write_str(self.as_str())
187 }
188}
189
190impl FromSql for DepKind {
191 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
192 DepKind::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
193 }
194}
195
196impl ToSql for DepKind {
197 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
198 Ok(self.as_str().into())
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
207pub enum LivenessSource {
208 Pid,
212 Listing,
216}
217
218impl LivenessSource {
219 pub const ALL: [LivenessSource; 2] = [LivenessSource::Pid, LivenessSource::Listing];
220
221 pub fn as_str(self) -> &'static str {
222 match self {
223 LivenessSource::Pid => "pid",
224 LivenessSource::Listing => "listing",
225 }
226 }
227
228 pub fn parse(s: &str) -> Result<LivenessSource> {
229 Self::ALL
230 .into_iter()
231 .find(|source| source.as_str() == s)
232 .ok_or_else(|| Error::Invalid(format!("unknown liveness source '{s}'")))
233 }
234}
235
236impl fmt::Display for LivenessSource {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 f.write_str(self.as_str())
239 }
240}
241
242impl FromSql for LivenessSource {
243 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
244 LivenessSource::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
245 }
246}
247
248impl ToSql for LivenessSource {
249 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
250 Ok(self.as_str().into())
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
255pub enum SessionOutcome {
256 Completed,
257 Asked,
258 Failed,
259 Capped,
260 Aborted,
261}
262
263impl SessionOutcome {
264 pub const ALL: [SessionOutcome; 5] = [
265 SessionOutcome::Completed,
266 SessionOutcome::Asked,
267 SessionOutcome::Failed,
268 SessionOutcome::Capped,
269 SessionOutcome::Aborted,
270 ];
271
272 pub fn as_str(self) -> &'static str {
273 match self {
274 SessionOutcome::Completed => "completed",
275 SessionOutcome::Asked => "asked",
276 SessionOutcome::Failed => "failed",
277 SessionOutcome::Capped => "capped",
278 SessionOutcome::Aborted => "aborted",
279 }
280 }
281
282 pub fn parse(s: &str) -> Result<SessionOutcome> {
283 Self::ALL
284 .into_iter()
285 .find(|outcome| outcome.as_str() == s)
286 .ok_or_else(|| Error::Invalid(format!("unknown session outcome '{s}'")))
287 }
288}
289
290impl fmt::Display for SessionOutcome {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 f.write_str(self.as_str())
293 }
294}
295
296impl FromSql for SessionOutcome {
297 fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
298 SessionOutcome::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
299 }
300}
301
302impl ToSql for SessionOutcome {
303 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
304 Ok(self.as_str().into())
305 }
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
314pub enum RefineOutcome {
315 Applied,
317 Failed,
319 Cancelled,
322}
323
324impl RefineOutcome {
325 pub const ALL: [RefineOutcome; 3] = [
326 RefineOutcome::Applied,
327 RefineOutcome::Failed,
328 RefineOutcome::Cancelled,
329 ];
330
331 pub fn as_str(self) -> &'static str {
332 match self {
333 RefineOutcome::Applied => "applied",
334 RefineOutcome::Failed => "failed",
335 RefineOutcome::Cancelled => "cancelled",
336 }
337 }
338
339 pub fn parse(s: &str) -> Result<RefineOutcome> {
340 Self::ALL
341 .into_iter()
342 .find(|outcome| outcome.as_str() == s)
343 .ok_or_else(|| Error::Invalid(format!("unknown refine outcome '{s}'")))
344 }
345
346 pub fn session_outcome(self) -> SessionOutcome {
349 match self {
350 RefineOutcome::Applied => SessionOutcome::Completed,
351 RefineOutcome::Failed => SessionOutcome::Failed,
352 RefineOutcome::Cancelled => SessionOutcome::Aborted,
353 }
354 }
355}
356
357impl fmt::Display for RefineOutcome {
358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359 f.write_str(self.as_str())
360 }
361}
362
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub struct Project {
365 pub id: i64,
366 pub name: String,
367 pub weight: i64,
368 pub viewer: Option<String>,
374 pub archived: bool,
379}
380
381pub fn projects_for_new_task(projects: &[Project]) -> Vec<&Project> {
390 let mut offered: Vec<&Project> = projects.iter().filter(|p| !p.archived).collect();
391 offered.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.name.cmp(&b.name)));
392 offered
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct Repo {
400 pub id: i64,
401 pub project_id: i64,
402 pub name: String,
404 pub path: String,
405 pub is_default: bool,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct Doc {
416 pub id: i64,
417 pub project_id: i64,
418 pub repo_id: Option<i64>,
422 pub title: Option<String>,
424 pub location: String,
427 pub created_at: String,
428}
429
430impl Doc {
431 pub fn is_url(&self) -> bool {
434 location_is_url(&self.location)
435 }
436
437 pub fn label(&self) -> &str {
440 match &self.title {
441 Some(title) => title,
442 None => &self.location,
443 }
444 }
445}
446
447pub fn location_is_url(location: &str) -> bool {
451 location.starts_with("http://") || location.starts_with("https://")
452}
453
454#[derive(Debug, Clone, PartialEq)]
455pub struct Task {
456 pub id: i64,
457 pub project_id: i64,
458 pub repo_id: Option<i64>,
462 pub title: String,
463 pub body: String,
464 pub priority: Priority,
465 pub state: TaskState,
466 pub agent: Option<String>,
467 pub question: Option<String>,
468 pub pr_url: Option<String>,
472 pub branch: Option<String>,
476 pub state_since: String,
477 pub created_at: String,
478 pub closed_at: Option<String>,
479 pub human: bool,
484 pub deep: bool,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
496pub enum NextAction {
497 Triage,
499 Answer,
501 Pr,
504 ReviewPr,
506 Accept,
510 Open,
514 Do,
516 Redispatch,
519 Dispatch,
521}
522
523impl NextAction {
524 pub fn as_str(self) -> &'static str {
525 match self {
526 NextAction::Triage => "triage",
527 NextAction::Answer => "answer",
528 NextAction::Pr => "pr",
529 NextAction::ReviewPr => "review PR",
530 NextAction::Accept => "accept",
531 NextAction::Open => "open",
532 NextAction::Do => "do",
533 NextAction::Redispatch => "redispatch",
534 NextAction::Dispatch => "dispatch",
535 }
536 }
537
538 pub fn without_pull_requests(self) -> NextAction {
545 match self {
546 NextAction::Pr => NextAction::Open,
547 other => other,
548 }
549 }
550}
551
552impl fmt::Display for NextAction {
553 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
554 f.pad(self.as_str())
555 }
556}
557
558impl Task {
559 pub fn branch_name(&self) -> Option<&str> {
564 self.branch
565 .as_deref()
566 .map(str::trim)
567 .filter(|b| !b.is_empty())
568 }
569
570 pub fn next_action(&self) -> Option<NextAction> {
584 match self.state {
585 TaskState::Proposed => Some(NextAction::Triage),
586 TaskState::NeedsInput => Some(NextAction::Answer),
587 TaskState::Review if self.pr_url.is_some() => Some(NextAction::ReviewPr),
588 TaskState::Review if self.branch_name().is_some() => Some(NextAction::Pr),
589 TaskState::Review => Some(NextAction::Accept),
590 TaskState::Stalled => Some(NextAction::Redispatch),
591 TaskState::Ready if self.human => Some(NextAction::Do),
592 TaskState::Ready => Some(NextAction::Dispatch),
593 TaskState::Running
594 | TaskState::Refining
595 | TaskState::Waiting
596 | TaskState::Parked
597 | TaskState::Done
598 | TaskState::Rejected => None,
599 }
600 }
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct Dep {
605 pub task_id: i64,
606 pub depends_on: i64,
607 pub kind: DepKind,
608}
609
610#[derive(Debug, Clone, PartialEq, Eq)]
616pub struct DepRef {
617 pub id: i64,
618 pub title: String,
619 pub state: TaskState,
620 pub kind: DepKind,
621}
622
623impl DepRef {
624 pub fn is_open(&self) -> bool {
626 !self.state.is_terminal()
627 }
628}
629
630#[derive(Debug, Clone)]
631pub struct Event {
632 pub id: i64,
633 pub task_id: Option<i64>,
634 pub at: String,
635 pub kind: String,
636 pub detail: Option<String>,
637}
638
639#[derive(Debug, Clone, PartialEq, Eq)]
640pub struct Session {
641 pub id: i64,
642 pub task_id: i64,
643 pub agent: String,
644 pub pid: Option<i64>,
645 pub session_ref: Option<String>,
650 pub liveness_source: LivenessSource,
653 pub log_path: Option<String>,
654 pub started_at: String,
655 pub ended_at: Option<String>,
656 pub outcome: Option<SessionOutcome>,
657}
658
659#[derive(Debug, Clone, PartialEq, Eq)]
667pub struct RunningRow {
668 pub session_id: Option<i64>,
669 pub task_id: i64,
670 pub task_title: String,
671 pub task_state: TaskState,
672 pub agent: Option<String>,
673 pub pr_url: Option<String>,
674 pub started_at: String,
675 pub elapsed_secs: i64,
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn task_state_display_honors_width() {
684 assert_eq!(format!("{:11}", TaskState::Ready), "ready ");
685 assert_eq!(format!("{:>6}", TaskState::Done), " done");
686 assert_eq!(format!("{:>6}", TaskState::NeedsInput), "needs-input");
687 }
688
689 #[test]
690 fn priority_display_honors_width() {
691 assert_eq!(format!("{:>6}", Priority::P0), " P0");
692 assert_eq!(format!("{:>6}", Priority::P2), " P2");
693 }
694
695 fn project(name: &str, weight: i64, archived: bool) -> Project {
696 Project {
697 id: 1,
698 name: name.into(),
699 weight,
700 viewer: None,
701 archived,
702 }
703 }
704
705 #[test]
706 fn new_task_projects_drop_the_archived_at_any_weight() {
707 let projects = [
708 project("live", 1, false),
709 project("retired-heavy", 5, true),
710 project("retired-parked", 0, true),
711 ];
712 let offered = projects_for_new_task(&projects);
713 assert_eq!(
714 offered.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
715 ["live"]
716 );
717 }
718
719 #[test]
720 fn new_task_projects_sort_by_weight_then_name() {
721 let projects = [
722 project("beta", 3, false),
723 project("parked", 0, false),
724 project("alpha", 3, false),
725 project("heaviest", 5, false),
726 ];
727 let offered = projects_for_new_task(&projects);
728 assert_eq!(
729 offered.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
730 ["heaviest", "alpha", "beta", "parked"]
731 );
732 }
733
734 fn task_in(state: TaskState, pr_url: Option<&str>, human: bool) -> Task {
735 task_with(state, pr_url, human, None)
736 }
737
738 fn task_with(
739 state: TaskState,
740 pr_url: Option<&str>,
741 human: bool,
742 branch: Option<&str>,
743 ) -> Task {
744 Task {
745 id: 1,
746 project_id: 1,
747 repo_id: None,
748 title: "t".into(),
749 body: String::new(),
750 priority: Priority::P2,
751 state,
752 agent: None,
753 question: None,
754 pr_url: pr_url.map(str::to_string),
755 branch: branch.map(str::to_string),
756 state_since: "2026-01-01T00:00:00Z".into(),
757 created_at: "2026-01-01T00:00:00Z".into(),
758 closed_at: None,
759 human,
760 deep: false,
761 }
762 }
763
764 #[test]
765 fn next_action_derives_every_arm() {
766 for (state, pr_url, human, expected) in [
767 (TaskState::Proposed, None, false, Some(NextAction::Triage)),
768 (TaskState::NeedsInput, None, false, Some(NextAction::Answer)),
769 (TaskState::Review, None, false, Some(NextAction::Accept)),
771 (
772 TaskState::Review,
773 Some("https://github.com/o/r/pull/1"),
774 false,
775 Some(NextAction::ReviewPr),
776 ),
777 (TaskState::Ready, None, true, Some(NextAction::Do)),
778 (TaskState::Ready, None, false, Some(NextAction::Dispatch)),
779 (
780 TaskState::Stalled,
781 None,
782 false,
783 Some(NextAction::Redispatch),
784 ),
785 (TaskState::Running, None, false, None),
786 (TaskState::Waiting, None, false, None),
787 (TaskState::Parked, None, false, None),
788 (TaskState::Done, None, false, None),
789 (TaskState::Rejected, None, false, None),
790 ] {
791 assert_eq!(
792 task_in(state, pr_url, human).next_action(),
793 expected,
794 "{state} pr_url={pr_url:?} human={human}"
795 );
796 }
797 }
798
799 #[test]
804 fn the_review_verb_follows_the_branch() {
805 assert_eq!(
806 task_with(TaskState::Review, None, false, Some("feat/x")).next_action(),
807 Some(NextAction::Pr)
808 );
809 assert_eq!(
810 task_with(TaskState::Review, None, false, None).next_action(),
811 Some(NextAction::Accept)
812 );
813 for blank in ["", " "] {
815 assert_eq!(
816 task_with(TaskState::Review, None, false, Some(blank)).next_action(),
817 Some(NextAction::Accept),
818 "{blank:?}"
819 );
820 }
821 }
822
823 #[test]
826 fn a_tracked_pr_outranks_the_branch() {
827 for branch in [None, Some("feat/x")] {
828 assert_eq!(
829 task_with(TaskState::Review, Some("https://x"), false, branch).next_action(),
830 Some(NextAction::ReviewPr),
831 "{branch:?}"
832 );
833 }
834 }
835
836 #[test]
838 fn the_branch_moves_no_other_verb() {
839 for state in TaskState::ALL.iter().filter(|s| **s != TaskState::Review) {
840 assert_eq!(
841 task_with(*state, None, false, Some("feat/x")).next_action(),
842 task_in(*state, None, false).next_action(),
843 "{state}"
844 );
845 }
846 }
847
848 #[test]
849 fn next_action_ignores_fields_its_arm_does_not_read() {
850 assert_eq!(
851 task_in(TaskState::Proposed, Some("https://x"), true).next_action(),
852 Some(NextAction::Triage)
853 );
854 assert_eq!(
855 task_in(TaskState::NeedsInput, None, true).next_action(),
856 Some(NextAction::Answer)
857 );
858 assert_eq!(
859 task_in(TaskState::Ready, Some("https://x"), false).next_action(),
860 Some(NextAction::Dispatch)
861 );
862 }
863
864 #[test]
868 fn without_pull_requests_degrades_pr_and_nothing_else() {
869 assert_eq!(NextAction::Pr.without_pull_requests(), NextAction::Open);
870 for verb in [
871 NextAction::Triage,
872 NextAction::Answer,
873 NextAction::ReviewPr,
874 NextAction::Accept,
875 NextAction::Open,
876 NextAction::Do,
877 NextAction::Redispatch,
878 NextAction::Dispatch,
879 ] {
880 assert_eq!(verb.without_pull_requests(), verb, "{verb}");
881 }
882 }
883
884 #[test]
887 fn open_is_not_derived_from_state() {
888 for state in TaskState::ALL {
889 for pr_url in [None, Some("https://x")] {
890 for human in [false, true] {
891 assert_ne!(
892 task_in(state, pr_url, human).next_action(),
893 Some(NextAction::Open),
894 "{state} pr_url={pr_url:?} human={human}"
895 );
896 }
897 }
898 }
899 }
900
901 #[test]
902 fn next_action_display_honors_width() {
903 assert_eq!(format!("{:10}", NextAction::Do), "do ");
904 assert_eq!(format!("{:10}", NextAction::ReviewPr), "review PR ");
905 assert_eq!(format!("{:10}", NextAction::Redispatch), "redispatch");
906 }
907}