Skip to main content

voro_core/
model.rs

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    Parked,
11    Ready,
12    Running,
13    NeedsInput,
14    Review,
15    Waiting,
16    Stalled,
17    Done,
18    Rejected,
19}
20
21impl TaskState {
22    pub const ALL: [TaskState; 10] = [
23        TaskState::Proposed,
24        TaskState::Parked,
25        TaskState::Ready,
26        TaskState::Running,
27        TaskState::NeedsInput,
28        TaskState::Review,
29        TaskState::Waiting,
30        TaskState::Stalled,
31        TaskState::Done,
32        TaskState::Rejected,
33    ];
34
35    pub fn as_str(self) -> &'static str {
36        match self {
37            TaskState::Proposed => "proposed",
38            TaskState::Parked => "parked",
39            TaskState::Ready => "ready",
40            TaskState::Running => "running",
41            TaskState::NeedsInput => "needs-input",
42            TaskState::Review => "review",
43            TaskState::Waiting => "waiting",
44            TaskState::Stalled => "stalled",
45            TaskState::Done => "done",
46            TaskState::Rejected => "rejected",
47        }
48    }
49
50    pub fn parse(s: &str) -> Result<TaskState> {
51        Self::ALL
52            .into_iter()
53            .find(|state| state.as_str() == s)
54            .ok_or_else(|| Error::Invalid(format!("unknown task state '{s}'")))
55    }
56
57    /// Closed states: nothing leaves them, and they do not block dependants.
58    pub fn is_terminal(self) -> bool {
59        matches!(self, TaskState::Done | TaskState::Rejected)
60    }
61}
62
63impl fmt::Display for TaskState {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.pad(self.as_str())
66    }
67}
68
69impl FromSql for TaskState {
70    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
71        let s = value.as_str()?;
72        TaskState::parse(s).map_err(|e| FromSqlError::Other(Box::new(e)))
73    }
74}
75
76impl ToSql for TaskState {
77    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
78        Ok(self.as_str().into())
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
83pub enum Priority {
84    P0,
85    P1,
86    P2,
87    P3,
88}
89
90impl Priority {
91    pub fn from_int(n: i64) -> Result<Priority> {
92        match n {
93            0 => Ok(Priority::P0),
94            1 => Ok(Priority::P1),
95            2 => Ok(Priority::P2),
96            3 => Ok(Priority::P3),
97            _ => Err(Error::Invalid(format!("priority {n} out of range 0-3"))),
98        }
99    }
100
101    pub fn as_int(self) -> i64 {
102        match self {
103            Priority::P0 => 0,
104            Priority::P1 => 1,
105            Priority::P2 => 2,
106            Priority::P3 => 3,
107        }
108    }
109
110    /// The geometric value used by the attention score (DESIGN.md §7).
111    pub fn value(self) -> f64 {
112        match self {
113            Priority::P0 => 8.0,
114            Priority::P1 => 4.0,
115            Priority::P2 => 2.0,
116            Priority::P3 => 1.0,
117        }
118    }
119}
120
121impl fmt::Display for Priority {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        let s = match self {
124            Priority::P0 => "P0",
125            Priority::P1 => "P1",
126            Priority::P2 => "P2",
127            Priority::P3 => "P3",
128        };
129        f.pad(s)
130    }
131}
132
133impl FromSql for Priority {
134    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
135        Priority::from_int(value.as_i64()?).map_err(|e| FromSqlError::Other(Box::new(e)))
136    }
137}
138
139impl ToSql for Priority {
140    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
141        Ok(self.as_int().into())
142    }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub enum DepKind {
147    Blocks,
148    DiscoveredFrom,
149    Parent,
150    Related,
151}
152
153impl DepKind {
154    pub const ALL: [DepKind; 4] = [
155        DepKind::Blocks,
156        DepKind::DiscoveredFrom,
157        DepKind::Parent,
158        DepKind::Related,
159    ];
160
161    pub fn as_str(self) -> &'static str {
162        match self {
163            DepKind::Blocks => "blocks",
164            DepKind::DiscoveredFrom => "discovered-from",
165            DepKind::Parent => "parent",
166            DepKind::Related => "related",
167        }
168    }
169
170    pub fn parse(s: &str) -> Result<DepKind> {
171        Self::ALL
172            .into_iter()
173            .find(|kind| kind.as_str() == s)
174            .ok_or_else(|| Error::Invalid(format!("unknown dep kind '{s}'")))
175    }
176}
177
178impl fmt::Display for DepKind {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.write_str(self.as_str())
181    }
182}
183
184impl FromSql for DepKind {
185    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
186        DepKind::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
187    }
188}
189
190impl ToSql for DepKind {
191    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
192        Ok(self.as_str().into())
193    }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
197pub enum SessionOutcome {
198    Completed,
199    Asked,
200    Failed,
201    Capped,
202    Aborted,
203}
204
205impl SessionOutcome {
206    pub const ALL: [SessionOutcome; 5] = [
207        SessionOutcome::Completed,
208        SessionOutcome::Asked,
209        SessionOutcome::Failed,
210        SessionOutcome::Capped,
211        SessionOutcome::Aborted,
212    ];
213
214    pub fn as_str(self) -> &'static str {
215        match self {
216            SessionOutcome::Completed => "completed",
217            SessionOutcome::Asked => "asked",
218            SessionOutcome::Failed => "failed",
219            SessionOutcome::Capped => "capped",
220            SessionOutcome::Aborted => "aborted",
221        }
222    }
223
224    pub fn parse(s: &str) -> Result<SessionOutcome> {
225        Self::ALL
226            .into_iter()
227            .find(|outcome| outcome.as_str() == s)
228            .ok_or_else(|| Error::Invalid(format!("unknown session outcome '{s}'")))
229    }
230}
231
232impl fmt::Display for SessionOutcome {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        f.write_str(self.as_str())
235    }
236}
237
238impl FromSql for SessionOutcome {
239    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
240        SessionOutcome::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e)))
241    }
242}
243
244impl ToSql for SessionOutcome {
245    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
246        Ok(self.as_str().into())
247    }
248}
249
250/// A project's review medium (DESIGN.md §8/§11a): which of the two media the
251/// unified `pr` action uses to get a review task's diff in front of the
252/// operator. Stored on the project (`projects.review_action`).
253#[derive(Debug, Clone, PartialEq, Eq, Default)]
254pub enum ReviewAction {
255    /// Resolve at use: GitHub when the checkout is a GitHub repo, otherwise
256    /// the configured viewer. Stored as NULL — the unconfigured default.
257    #[default]
258    Auto,
259    /// Always the GitHub PR flow (jump to the tracked PR, or push and create).
260    Pr,
261    /// Always a local viewer from `voro.toml`: the named `[viewers.<name>]`
262    /// when one is given, otherwise the default viewer.
263    Viewer(Option<String>),
264}
265
266impl ReviewAction {
267    /// Parse the stored/CLI form: `auto`, `pr`, `viewer`, or `viewer:<name>`.
268    pub fn parse(s: &str) -> Result<ReviewAction> {
269        match s {
270            "auto" => Ok(ReviewAction::Auto),
271            "pr" => Ok(ReviewAction::Pr),
272            "viewer" => Ok(ReviewAction::Viewer(None)),
273            other => match other.strip_prefix("viewer:") {
274                Some(name) if !name.trim().is_empty() => {
275                    Ok(ReviewAction::Viewer(Some(name.trim().to_string())))
276                }
277                _ => Err(Error::Invalid(format!(
278                    "unknown review action '{s}' — expected auto, pr, viewer, or viewer:<name>"
279                ))),
280            },
281        }
282    }
283
284    /// Resolve the medium once the checkout's GitHub-ness is known. Only `Auto`
285    /// consults the probe's answer.
286    pub fn resolve(&self, on_github: bool) -> ReviewMedium {
287        match self {
288            ReviewAction::Auto if on_github => ReviewMedium::GithubPr,
289            ReviewAction::Auto => ReviewMedium::Viewer(None),
290            ReviewAction::Pr => ReviewMedium::GithubPr,
291            ReviewAction::Viewer(name) => ReviewMedium::Viewer(name.clone()),
292        }
293    }
294
295    /// Whether resolving this action needs the GitHub probe at all, so
296    /// callers can skip the `gh` shell-out when the medium is pinned.
297    pub fn needs_probe(&self) -> bool {
298        matches!(self, ReviewAction::Auto)
299    }
300}
301
302impl fmt::Display for ReviewAction {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        match self {
305            ReviewAction::Auto => f.pad("auto"),
306            ReviewAction::Pr => f.pad("pr"),
307            ReviewAction::Viewer(None) => f.pad("viewer"),
308            ReviewAction::Viewer(Some(name)) => f.pad(&format!("viewer:{name}")),
309        }
310    }
311}
312
313impl FromSql for ReviewAction {
314    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
315        match value {
316            ValueRef::Null => Ok(ReviewAction::Auto),
317            _ => ReviewAction::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e))),
318        }
319    }
320}
321
322impl ToSql for ReviewAction {
323    /// `Auto` writes NULL — absence of configuration — so the column stays
324    /// empty until the operator pins a medium.
325    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
326        match self {
327            ReviewAction::Auto => Ok(rusqlite::types::Null.into()),
328            other => Ok(other.to_string().into()),
329        }
330    }
331}
332
333/// The concrete medium a [`ReviewAction`] resolves to: the single "show me
334/// this task's diff" action, per project (DESIGN.md §8).
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub enum ReviewMedium {
337    /// Jump to / create the GitHub PR.
338    GithubPr,
339    /// Run a `voro.toml` viewer on the checkout; `Some` names a
340    /// `[viewers.<name>]` entry, `None` is the default viewer.
341    Viewer(Option<String>),
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct Project {
346    pub id: i64,
347    pub name: String,
348    pub path: String,
349    pub weight: i64,
350    /// How `pr` shows this project's review diffs (DESIGN.md §8/§11a).
351    pub review_action: ReviewAction,
352}
353
354#[derive(Debug, Clone, PartialEq)]
355pub struct Task {
356    pub id: i64,
357    pub project_id: i64,
358    pub title: String,
359    pub body: String,
360    pub priority: Priority,
361    pub state: TaskState,
362    pub agent: Option<String>,
363    pub question: Option<String>,
364    /// The canonical URL of a GitHub PR tracked on this task (DESIGN.md §11c),
365    /// or `None`. Names the PR's base repo, so it survives forks where the
366    /// checkout's `origin` is not that repo.
367    pub pr_url: Option<String>,
368    /// The git branch this task's work lives on, or `None`. Holds the *intended*
369    /// name dispatch injects into the prompt, later overwritten by the branch
370    /// the agent *reports* — Voro never runs git, it only records what returns.
371    pub branch: Option<String>,
372    pub state_since: String,
373    pub created_at: String,
374    pub closed_at: Option<String>,
375    /// Marks a task no agent can execute — hands-on work at real hardware, say
376    /// (DESIGN.md §3/§6). Dispatch, continuation, `ask`, and the agent override
377    /// refuse it; completion goes `running → done` directly. Default `false`
378    /// means dispatchable.
379    pub human: bool,
380}
381
382/// The verb a task's queue row asks of the human (DESIGN.md §3), derived from
383/// state × fields rather than stored.
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
385pub enum NextAction {
386    /// An untriaged proposal: accept, park, or reject it.
387    Triage,
388    /// A question is waiting; answering it unblocks the work.
389    Answer,
390    /// A review task with no tracked PR: open one from its done-time summary.
391    Pr,
392    /// A review task whose PR is open: review it there.
393    ReviewPr,
394    /// A ready human-only task: only the human can execute it.
395    Do,
396    /// A stalled task: its dispatch died, restart it with the prior
397    /// session's context.
398    Redispatch,
399    /// A ready task an agent can take: hand it to one.
400    Dispatch,
401}
402
403impl NextAction {
404    pub fn as_str(self) -> &'static str {
405        match self {
406            NextAction::Triage => "triage",
407            NextAction::Answer => "answer",
408            NextAction::Pr => "pr",
409            NextAction::ReviewPr => "review PR",
410            NextAction::Do => "do",
411            NextAction::Redispatch => "redispatch",
412            NextAction::Dispatch => "dispatch",
413        }
414    }
415}
416
417impl fmt::Display for NextAction {
418    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419        f.pad(self.as_str())
420    }
421}
422
423impl Task {
424    /// The single next-action derivation (DESIGN.md §3): what the human does
425    /// next, from state × fields. `None` for states that ask nothing of the
426    /// human — `running` belongs to the running strip, `parked`/`done`/`rejected`
427    /// wait on nothing. `stalled` always means a dead agent dispatch, since
428    /// dispatch refuses human tasks. `waiting` is handed off to an external
429    /// party (DESIGN.md §6) and asks nothing of the operator.
430    pub fn next_action(&self) -> Option<NextAction> {
431        match self.state {
432            TaskState::Proposed => Some(NextAction::Triage),
433            TaskState::NeedsInput => Some(NextAction::Answer),
434            TaskState::Review if self.pr_url.is_some() => Some(NextAction::ReviewPr),
435            TaskState::Review => Some(NextAction::Pr),
436            TaskState::Stalled => Some(NextAction::Redispatch),
437            TaskState::Ready if self.human => Some(NextAction::Do),
438            TaskState::Ready => Some(NextAction::Dispatch),
439            TaskState::Running
440            | TaskState::Waiting
441            | TaskState::Parked
442            | TaskState::Done
443            | TaskState::Rejected => None,
444        }
445    }
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
449pub struct Dep {
450    pub task_id: i64,
451    pub depends_on: i64,
452    pub kind: DepKind,
453}
454
455/// A dependency edge resolved for display: the task at the *other* end of the
456/// edge with its current title and state, plus the edge's kind. Which end is
457/// "other" depends on the query — the dependency for
458/// [`Store::deps_by_task`](crate::Store::deps_by_task), the dependant for
459/// [`Store::dependents_by_task`](crate::Store::dependents_by_task).
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct DepRef {
462    pub id: i64,
463    pub title: String,
464    pub state: TaskState,
465    pub kind: DepKind,
466}
467
468impl DepRef {
469    /// The referenced task is not yet in a closed state.
470    pub fn is_open(&self) -> bool {
471        !self.state.is_terminal()
472    }
473}
474
475#[derive(Debug, Clone)]
476pub struct Event {
477    pub id: i64,
478    pub task_id: Option<i64>,
479    pub at: String,
480    pub kind: String,
481    pub detail: Option<String>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct Session {
486    pub id: i64,
487    pub task_id: i64,
488    pub agent: String,
489    pub pid: Option<i64>,
490    /// The agent's own reference for this session (a Claude session UUID, a
491    /// Codex session id, a tmux session name), captured after launch and
492    /// substituted into the agent's attach/resume/continue verb templates.
493    /// `None` when the agent has no capture story or capture failed.
494    pub session_ref: Option<String>,
495    pub log_path: Option<String>,
496    pub started_at: String,
497    pub ended_at: Option<String>,
498    pub outcome: Option<SessionOutcome>,
499}
500
501/// A row of the cockpit's running strip (DESIGN.md §9): one per `running` task,
502/// joined with its open session if it has one. A task with no open session
503/// (started by hand) still shows, with `session_id`/`agent` set to `None`.
504/// `elapsed_secs` is computed in SQL against the database's clock, so the TUI
505/// only has to format it.
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct RunningRow {
508    pub session_id: Option<i64>,
509    pub task_id: i64,
510    pub task_title: String,
511    pub task_state: TaskState,
512    pub agent: Option<String>,
513    pub started_at: String,
514    pub elapsed_secs: i64,
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn task_state_display_honors_width() {
523        assert_eq!(format!("{:11}", TaskState::Ready), "ready      ");
524        assert_eq!(format!("{:>6}", TaskState::Done), "  done");
525        assert_eq!(format!("{:>6}", TaskState::NeedsInput), "needs-input");
526    }
527
528    #[test]
529    fn priority_display_honors_width() {
530        assert_eq!(format!("{:>6}", Priority::P0), "    P0");
531        assert_eq!(format!("{:>6}", Priority::P2), "    P2");
532    }
533
534    fn task_in(state: TaskState, pr_url: Option<&str>, human: bool) -> Task {
535        Task {
536            id: 1,
537            project_id: 1,
538            title: "t".into(),
539            body: String::new(),
540            priority: Priority::P2,
541            state,
542            agent: None,
543            question: None,
544            pr_url: pr_url.map(str::to_string),
545            branch: None,
546            state_since: "2026-01-01T00:00:00Z".into(),
547            created_at: "2026-01-01T00:00:00Z".into(),
548            closed_at: None,
549            human,
550        }
551    }
552
553    #[test]
554    fn next_action_derives_every_arm() {
555        for (state, pr_url, human, expected) in [
556            (TaskState::Proposed, None, false, Some(NextAction::Triage)),
557            (TaskState::NeedsInput, None, false, Some(NextAction::Answer)),
558            (TaskState::Review, None, false, Some(NextAction::Pr)),
559            (
560                TaskState::Review,
561                Some("https://github.com/o/r/pull/1"),
562                false,
563                Some(NextAction::ReviewPr),
564            ),
565            (TaskState::Ready, None, true, Some(NextAction::Do)),
566            (TaskState::Ready, None, false, Some(NextAction::Dispatch)),
567            (
568                TaskState::Stalled,
569                None,
570                false,
571                Some(NextAction::Redispatch),
572            ),
573            (TaskState::Running, None, false, None),
574            (TaskState::Waiting, None, false, None),
575            (TaskState::Parked, None, false, None),
576            (TaskState::Done, None, false, None),
577            (TaskState::Rejected, None, false, None),
578        ] {
579            assert_eq!(
580                task_in(state, pr_url, human).next_action(),
581                expected,
582                "{state} pr_url={pr_url:?} human={human}"
583            );
584        }
585    }
586
587    #[test]
588    fn next_action_ignores_fields_its_arm_does_not_read() {
589        assert_eq!(
590            task_in(TaskState::Proposed, Some("https://x"), true).next_action(),
591            Some(NextAction::Triage)
592        );
593        assert_eq!(
594            task_in(TaskState::NeedsInput, None, true).next_action(),
595            Some(NextAction::Answer)
596        );
597        assert_eq!(
598            task_in(TaskState::Ready, Some("https://x"), false).next_action(),
599            Some(NextAction::Dispatch)
600        );
601    }
602
603    #[test]
604    fn next_action_display_honors_width() {
605        assert_eq!(format!("{:10}", NextAction::Do), "do        ");
606        assert_eq!(format!("{:10}", NextAction::ReviewPr), "review PR ");
607        assert_eq!(format!("{:10}", NextAction::Redispatch), "redispatch");
608    }
609
610    #[test]
611    fn review_action_parses_and_displays_every_form() {
612        for (text, action) in [
613            ("auto", ReviewAction::Auto),
614            ("pr", ReviewAction::Pr),
615            ("viewer", ReviewAction::Viewer(None)),
616            ("viewer:zed", ReviewAction::Viewer(Some("zed".into()))),
617        ] {
618            assert_eq!(ReviewAction::parse(text).unwrap(), action, "{text}");
619            assert_eq!(action.to_string(), text);
620        }
621        assert!(ReviewAction::parse("github").is_err());
622        assert!(ReviewAction::parse("viewer:").is_err());
623        assert!(ReviewAction::parse("viewer:  ").is_err());
624    }
625
626    #[test]
627    fn review_action_resolves_the_medium() {
628        assert_eq!(ReviewAction::Auto.resolve(true), ReviewMedium::GithubPr);
629        assert_eq!(
630            ReviewAction::Auto.resolve(false),
631            ReviewMedium::Viewer(None)
632        );
633        assert!(ReviewAction::Auto.needs_probe());
634
635        assert_eq!(ReviewAction::Pr.resolve(false), ReviewMedium::GithubPr);
636        assert!(!ReviewAction::Pr.needs_probe());
637        assert_eq!(
638            ReviewAction::Viewer(Some("zed".into())).resolve(true),
639            ReviewMedium::Viewer(Some("zed".into()))
640        );
641        assert!(!ReviewAction::Viewer(None).needs_probe());
642    }
643}