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    /// An agent is rewriting this proposal's body right now (DESIGN.md §6). It
11    /// is out of the triage queue until the round concludes and the task
12    /// returns to `proposed`.
13    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    /// Closed states: nothing leaves them, and they do not block dependants.
64    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    /// The geometric value used by the attention score (DESIGN.md §7).
117    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/// Which source of liveness is authoritative for a session (DESIGN.md §8),
203/// recorded by the code that spawned the process because only it knows what it
204/// spawned. The two differ in one respect: whether the pid the session row
205/// holds is the work itself or a launcher that spawned it and exited.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
207pub enum LivenessSource {
208    /// The recorded pid *is* the work — a foreground child Voro owns, or an
209    /// agent with no `sessions` verb, where the spawned pid is the only source
210    /// there is. `kill -0` answers.
211    Pid,
212    /// The work belongs to a supervisor the launch handed it to, so the
213    /// recorded pid dies at birth and only the agent's own `sessions` listing
214    /// can say whether the session is still working.
215    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/// How a refine round ended (DESIGN.md §6). It rides the `refining → proposed`
309/// transition, is logged as the detail of a `refine` event, and picks the
310/// outcome the round's session closes with — so the markers on the returned
311/// proposal are derived from the round that just concluded rather than from
312/// the whole history of the task.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
314pub enum RefineOutcome {
315    /// The agent rewrote the body and applied it with `set --body-file`.
316    Applied,
317    /// The agent died without applying anything (reconcile, DESIGN.md §8).
318    Failed,
319    /// The operator quit the session or cancelled the round; nothing landed,
320    /// which is a no-op rather than a failure.
321    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    /// The outcome the round's session closes with, since a session's life
347    /// follows its task (DESIGN.md §8).
348    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    /// The `voro.toml` viewer this project's local diffs open in (DESIGN.md
369    /// §8/§11a): a `[viewers.<name>]` name, or `None` for the default viewer.
370    /// The review keys are static — `g`/`pr` are always the GitHub PR flow,
371    /// `o`/`open` always a local viewer — so this picks no medium, only the
372    /// viewer `o`/`open` resolve for this project.
373    pub viewer: Option<String>,
374    /// Retired (DESIGN.md §5): the project and all its tasks leave the cockpit
375    /// — queue, stats, running strip — until unarchived. Tasks freeze in
376    /// whatever state they hold; only the projects screen still shows the
377    /// project, tagged, so it can be found and unarchived.
378    pub archived: bool,
379}
380
381/// The projects a new task can be created in, in the order to offer them
382/// (DESIGN.md §9). Archived projects are dropped — `Store::create_task` refuses
383/// them (§5), so offering one is offering a choice that can only fail, and in
384/// the `$EDITOR` and planning flows it fails only after the operator has
385/// written the task out. The rest sort by weight descending, which is the one
386/// per-project priority Voro holds (§7), with name ascending inside a weight so
387/// the order is stable. Weight 0 is a snooze rather than a retirement, so a
388/// parked project stays offered and sorts last.
389pub 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/// A checkout a project's work runs in (DESIGN.md §3): the execution target
396/// dispatch, `pr`/`open`, worktree cleanup, and `import` resolve against. A
397/// project owns at least one, exactly one of which is its default.
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct Repo {
400    pub id: i64,
401    pub project_id: i64,
402    /// Unique within the project; what `--repo` and the `repo` verbs name.
403    pub name: String,
404    pub path: String,
405    /// The repo a task with no `repo_id` resolves to. Exactly one per project.
406    pub is_default: bool,
407}
408
409/// A plan or design document a project's work derives from (DESIGN.md §3), and
410/// the thing tasks link to so "which tasks came from this plan?" is a query
411/// rather than a grep over task bodies. Owned by one project — which is where a
412/// relative location resolves — but linkable from a task in any project, since
413/// one plan routinely spawns work across several.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct Doc {
416    pub id: i64,
417    pub project_id: i64,
418    /// Which of the project's checkouts a relative `location` resolves against,
419    /// or `None` for the project's default — the same shape as `Task::repo_id`.
420    /// Always `None` for a URL or an absolute path, which resolve unaided.
421    pub repo_id: Option<i64>,
422    /// An operator-supplied label, or `None` to read as the location itself.
423    pub title: Option<String>,
424    /// A checkout-relative path (preferred, so it survives a checkout move), an
425    /// absolute path outside every checkout, or a URL.
426    pub location: String,
427    pub created_at: String,
428}
429
430impl Doc {
431    /// Whether the location addresses the network rather than a file. A URL is
432    /// handed to a reader verbatim; a path is resolved against a checkout.
433    pub fn is_url(&self) -> bool {
434        location_is_url(&self.location)
435    }
436
437    /// What a rendered row calls this doc: its title when it has one, else the
438    /// location, which is then the only name it has.
439    pub fn label(&self) -> &str {
440        match &self.title {
441            Some(title) => title,
442            None => &self.location,
443        }
444    }
445}
446
447/// Whether a doc location is a URL rather than a path. Deliberately a narrow
448/// scheme test, so a bare `docs/plan.md` never has to be escaped to read as a
449/// path.
450pub 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    /// The repo this task's work runs in, or `None` for the project's default
459    /// (DESIGN.md §3/§8). Resolved through `Store::repo_for_task`; never read
460    /// raw by a consumer that wants a checkout.
461    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    /// The canonical URL of a GitHub PR tracked on this task (DESIGN.md §11c),
469    /// or `None`. Names the PR's base repo, so it survives forks where the
470    /// checkout's `origin` is not that repo.
471    pub pr_url: Option<String>,
472    /// The git branch this task's work lives on, or `None`. Holds the *intended*
473    /// name dispatch injects into the prompt, later overwritten by the branch
474    /// the agent *reports* — Voro never runs git, it only records what returns.
475    pub branch: Option<String>,
476    pub state_since: String,
477    pub created_at: String,
478    pub closed_at: Option<String>,
479    /// Marks a task no agent can execute — hands-on work at real hardware, say
480    /// (DESIGN.md §3/§6). Dispatch, `ask`, and the agent override refuse it;
481    /// completion goes `running → done` directly. Default `false` means
482    /// dispatchable.
483    pub human: bool,
484    /// Marks work that warrants the strongest model its agent offers rather
485    /// than the workhorse (DESIGN.md §8). Read only at dispatch, where it
486    /// picks which model fills the agent's `{model}` placeholder; an agent
487    /// whose templates carry none ignores it. Orthogonal to priority, which
488    /// orders the queue, and to the agent override, which picks which agent
489    /// runs. Default `false` means the workhorse.
490    pub deep: bool,
491}
492
493/// The verb a task's queue row asks of the human (DESIGN.md §3), derived from
494/// state × fields rather than stored.
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
496pub enum NextAction {
497    /// An untriaged proposal: accept, park, or reject it.
498    Triage,
499    /// A question is waiting; answering it unblocks the work.
500    Answer,
501    /// A review task with a branch and no tracked PR: open one from its
502    /// done-time summary.
503    Pr,
504    /// A review task whose PR is open: review it there.
505    ReviewPr,
506    /// A review task with nothing to push — an investigation, a triage, an
507    /// audit whose whole product is its completion summary: read the report
508    /// and close it out.
509    Accept,
510    /// A review task in a checkout no pull request can be opened from: read the
511    /// diff in a local viewer. Never derived from state alone — a caller that
512    /// knows the checkout degrades [`NextAction::Pr`] to it.
513    Open,
514    /// A ready human-only task: only the human can execute it.
515    Do,
516    /// A stalled task: its dispatch died, restart it with the prior
517    /// session's context.
518    Redispatch,
519    /// A ready task an agent can take: hand it to one.
520    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    /// The same verb in a checkout that cannot take a pull request (DESIGN.md
539    /// §8): `pr` there is a recommendation that can only fail, so it degrades
540    /// to the local review path the operator does have. Every other verb is
541    /// forge-independent and passes through. Pure — whether a given checkout
542    /// can take a pull request is decided in the `voro` crate, which owns the
543    /// git and `gh` seams, and handed here.
544    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    /// The branch this task's work lives on, blank treated as absent — the one
560    /// reading of the column both the next-action derivation and
561    /// [`plan_pr`](crate::plan_pr) use, so a task advertised as PR-able is
562    /// exactly one `pr` accepts.
563    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    /// The single next-action derivation (DESIGN.md §3): what the human does
571    /// next, from state × fields. `None` for states that ask nothing of the
572    /// human — `running` and `refining` belong to the running strip,
573    /// `parked`/`done`/`rejected` wait on nothing. `stalled` always means a dead
574    /// agent dispatch, since dispatch refuses human tasks. `waiting` is handed
575    /// off to an external party (DESIGN.md §6) and asks nothing of the operator.
576    ///
577    /// `review` is the one arm that reads past the state (DESIGN.md §6): a
578    /// tracked PR asks to be reviewed, a recorded branch asks for one to be
579    /// opened, and a task carrying neither produced no code at all — its
580    /// summary is the whole deliverable — so the move is *accept*. The summary
581    /// itself is not consulted: it lives in the event log rather than on this
582    /// row, and a task with nothing to push has no other move regardless.
583    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/// A dependency edge resolved for display: the task at the *other* end of the
611/// edge with its current title and state, plus the edge's kind. Which end is
612/// "other" depends on the query — the dependency for
613/// [`Store::deps_by_task`](crate::Store::deps_by_task), the dependant for
614/// [`Store::dependents_by_task`](crate::Store::dependents_by_task).
615#[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    /// The referenced task is not yet in a closed state.
625    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    /// The agent's own reference for this session (a Claude session UUID, a
646    /// Codex session id, a tmux session name), captured after launch and
647    /// substituted into the agent's attach/resume/continue verb templates.
648    /// `None` when the agent has no capture story or capture failed.
649    pub session_ref: Option<String>,
650    /// Which source reconciliation must read this session's liveness by,
651    /// recorded at launch by whichever code spawned the process (DESIGN.md §8).
652    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/// A row of the cockpit's running strip (DESIGN.md §9): one per `running`,
660/// `refining`, or `waiting` task, joined with its open session if it has one. A
661/// task with no open session (started by hand) still shows, with `session_id`/
662/// `agent` `None`. `started_at` is what `elapsed_secs` counts from — the
663/// session for work under way, the hand-off for a `waiting` task — and
664/// `elapsed_secs` is computed in SQL against the database's clock, so the TUI
665/// only has to format it. `pr_url` carries the strip's PR marker.
666#[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            // no branch: nothing to push, so the report is the deliverable
770            (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    /// The `review` arm reads two further columns (DESIGN.md §6). A task with a
800    /// branch has code to push and asks for `pr`; one with none produced only
801    /// its summary — an investigation, an audit — and asks to be accepted,
802    /// since `pr` on it could only refuse.
803    #[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        // a blank branch is no branch, exactly as `plan_pr` reads it
814        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    /// A tracked PR outranks both: there is a diff open to read, however the
824    /// branch column reads.
825    #[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    /// Only `review` reads the branch — no other state's verb moves with it.
837    #[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    /// The one verb that depends on the checkout rather than the task: where no
865    /// pull request can be opened, `pr` reads as the local review path instead
866    /// (DESIGN.md §8). Every other verb is forge-independent and holds still.
867    #[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    /// `open` is never derived from state — a checkout that cannot take a pull
885    /// request is what produces it, and the derivation stays pure.
886    #[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}