Skip to main content

voro_core/
store.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use rusqlite::{Connection, OptionalExtension, params};
5
6use crate::error::{Error, Result};
7use crate::model::{
8    Dep, DepKind, DepRef, Event, Priority, Project, ReviewAction, RunningRow, Session,
9    SessionOutcome, Task, TaskState,
10};
11
12const MIGRATIONS: &[&str] = &[
13    include_str!("../migrations/0001_init.sql"),
14    include_str!("../migrations/0002_rename_backlog_to_parked.sql"),
15    include_str!("../migrations/0003_track_pr.sql"),
16    include_str!("../migrations/0004_add_session_ref.sql"),
17    include_str!("../migrations/0005_add_branch.sql"),
18    include_str!("../migrations/0006_one_open_session_per_task.sql"),
19    include_str!("../migrations/0007_add_human.sql"),
20    include_str!("../migrations/0008_add_stalled_state.sql"),
21    include_str!("../migrations/0009_add_review_action.sql"),
22    include_str!("../migrations/0010_add_waiting_state.sql"),
23];
24
25/// Owns the SQLite database. All writes go through this type; task state in
26/// particular is only ever changed by the transition API in `transition.rs`.
27pub struct Store {
28    pub(crate) conn: Connection,
29}
30
31/// Initial state for a task created by a human. `proposed` is quick capture;
32/// `parked`/`ready` mean the creator has already triaged their own task.
33#[derive(Debug, Clone)]
34pub struct NewTask {
35    pub project_id: i64,
36    pub title: String,
37    pub body: String,
38    pub priority: Priority,
39    pub state: TaskState,
40    pub agent: Option<String>,
41    pub human: bool,
42}
43
44/// Content edits. State is deliberately absent — use `Store::apply`.
45#[derive(Debug, Clone)]
46pub struct TaskEdit {
47    pub title: String,
48    pub body: String,
49    pub priority: Priority,
50    pub agent: Option<String>,
51    pub human: bool,
52}
53
54impl Store {
55    pub fn open(path: &Path) -> Result<Store> {
56        if let Some(dir) = path.parent() {
57            std::fs::create_dir_all(dir)
58                .map_err(|e| Error::Invalid(format!("cannot create {}: {e}", dir.display())))?;
59        }
60        Store::from_connection(Connection::open(path)?)
61    }
62
63    pub fn open_in_memory() -> Result<Store> {
64        Store::from_connection(Connection::open_in_memory()?)
65    }
66
67    /// `$XDG_DATA_HOME/voro/voro.db`, defaulting to `~/.local/share`.
68    pub fn default_db_path() -> PathBuf {
69        let data_home = std::env::var_os("XDG_DATA_HOME")
70            .map(PathBuf::from)
71            .filter(|p| p.is_absolute())
72            .unwrap_or_else(|| {
73                let home = std::env::var_os("HOME")
74                    .map(PathBuf::from)
75                    .unwrap_or_default();
76                home.join(".local/share")
77            });
78        data_home.join("voro/voro.db")
79    }
80
81    fn from_connection(conn: Connection) -> Result<Store> {
82        conn.pragma_update(None, "foreign_keys", true)?;
83        let mut store = Store { conn };
84        store.migrate()?;
85        Ok(store)
86    }
87
88    /// SQLite's `PRAGMA data_version`, which increments whenever another
89    /// connection commits a change to the database. The value is stable across
90    /// commits made on this connection, so a caller can poll it to detect
91    /// external writes without reacting to its own mutations.
92    pub fn data_version(&self) -> Result<i64> {
93        Ok(self
94            .conn
95            .query_row("PRAGMA data_version", [], |r| r.get(0))?)
96    }
97
98    /// Migrations may rebuild tables (SQLite cannot alter CHECK constraints),
99    /// so foreign-key enforcement is suspended for the duration and integrity
100    /// verified afterwards — the procedure SQLite documents for schema changes.
101    fn migrate(&mut self) -> Result<()> {
102        self.conn.pragma_update(None, "foreign_keys", false)?;
103        let applied = self.apply_migrations();
104        let restored = self.conn.pragma_update(None, "foreign_keys", true);
105        applied?;
106        restored?;
107        let violations: i64 =
108            self.conn
109                .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
110                    r.get(0)
111                })?;
112        if violations > 0 {
113            return Err(Error::Invalid(format!(
114                "{violations} foreign key violation(s) after migration"
115            )));
116        }
117        Ok(())
118    }
119
120    fn apply_migrations(&mut self) -> Result<()> {
121        let tx = self.conn.transaction()?;
122        let version: usize =
123            tx.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))? as usize;
124        for (i, sql) in MIGRATIONS.iter().enumerate().skip(version) {
125            tx.execute_batch(sql)?;
126            tx.pragma_update(None, "user_version", (i + 1) as i64)?;
127        }
128        tx.commit()?;
129        Ok(())
130    }
131
132    // --- projects ---
133
134    pub fn create_project(&mut self, name: &str, path: &str) -> Result<Project> {
135        self.conn.execute(
136            "INSERT INTO projects (name, path) VALUES (?1, ?2)",
137            params![name, path],
138        )?;
139        self.project(self.conn.last_insert_rowid())
140    }
141
142    pub fn project(&self, id: i64) -> Result<Project> {
143        self.conn
144            .query_row(
145                "SELECT id, name, path, weight, review_action FROM projects WHERE id = ?1",
146                [id],
147                project_from_row,
148            )
149            .optional()?
150            .ok_or(Error::ProjectNotFound(id))
151    }
152
153    pub fn projects(&self) -> Result<Vec<Project>> {
154        let mut stmt = self
155            .conn
156            .prepare("SELECT id, name, path, weight, review_action FROM projects ORDER BY name")?;
157        let rows = stmt.query_map([], project_from_row)?;
158        Ok(rows.collect::<rusqlite::Result<_>>()?)
159    }
160
161    pub fn set_weight(&mut self, project_id: i64, weight: i64) -> Result<()> {
162        if !(0..=5).contains(&weight) {
163            return Err(Error::Invalid(format!("weight {weight} out of range 0-5")));
164        }
165        let changed = self.conn.execute(
166            "UPDATE projects SET weight = ?1 WHERE id = ?2",
167            params![weight, project_id],
168        )?;
169        if changed == 0 {
170            return Err(Error::ProjectNotFound(project_id));
171        }
172        Ok(())
173    }
174
175    /// Set how `pr` shows this project's review diffs (DESIGN.md §8/§11a).
176    /// `Auto` stores NULL — the medium goes back to being resolved at use.
177    pub fn set_review_action(&mut self, project_id: i64, action: &ReviewAction) -> Result<Project> {
178        let changed = self.conn.execute(
179            "UPDATE projects SET review_action = ?1 WHERE id = ?2",
180            params![action, project_id],
181        )?;
182        if changed == 0 {
183            return Err(Error::ProjectNotFound(project_id));
184        }
185        self.project(project_id)
186    }
187
188    /// Tasks reference a project by id, not name, so renaming is a pure
189    /// label change — no task or dependency is touched.
190    pub fn rename_project(&mut self, project_id: i64, name: &str) -> Result<Project> {
191        let changed = self.conn.execute(
192            "UPDATE projects SET name = ?1 WHERE id = ?2",
193            params![name, project_id],
194        )?;
195        if changed == 0 {
196            return Err(Error::ProjectNotFound(project_id));
197        }
198        self.project(project_id)
199    }
200
201    pub fn set_path(&mut self, project_id: i64, path: &str) -> Result<Project> {
202        let changed = self.conn.execute(
203            "UPDATE projects SET path = ?1 WHERE id = ?2",
204            params![path, project_id],
205        )?;
206        if changed == 0 {
207            return Err(Error::ProjectNotFound(project_id));
208        }
209        self.project(project_id)
210    }
211
212    /// Delete a project outright — only safe when it has no tasks, since tasks
213    /// reference their project by id and deleting would orphan history. A project
214    /// with tasks in any state refuses; weight 0 snoozes without losing history.
215    pub fn delete_project(&mut self, project_id: i64) -> Result<()> {
216        self.project(project_id)?;
217        let task_count: i64 = self.conn.query_row(
218            "SELECT COUNT(*) FROM tasks WHERE project_id = ?1",
219            [project_id],
220            |r| r.get(0),
221        )?;
222        if task_count > 0 {
223            return Err(Error::ProjectHasTasks {
224                id: project_id,
225                count: task_count,
226            });
227        }
228        self.conn
229            .execute("DELETE FROM projects WHERE id = ?1", [project_id])?;
230        Ok(())
231    }
232
233    // --- tasks ---
234
235    pub fn create_task(&mut self, new: NewTask) -> Result<Task> {
236        if !matches!(
237            new.state,
238            TaskState::Proposed | TaskState::Parked | TaskState::Ready
239        ) {
240            return Err(Error::Invalid(format!(
241                "a task cannot be created in state '{}'",
242                new.state
243            )));
244        }
245        if new.human && new.agent.is_some() {
246            return Err(Error::Invalid(
247                "a human-only task cannot carry an agent override — the override only \
248                 selects a dispatch agent, and no agent can execute the task"
249                    .into(),
250            ));
251        }
252        let tx = self.conn.transaction()?;
253        tx.execute(
254            "INSERT INTO tasks (project_id, title, body, priority, state, agent, human,
255                                state_since, created_at)
256             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'), datetime('now'))",
257            params![
258                new.project_id,
259                new.title,
260                new.body,
261                new.priority,
262                new.state,
263                new.agent,
264                new.human
265            ],
266        )?;
267        let id = tx.last_insert_rowid();
268        log_event(&tx, id, "created", Some(new.state.as_str()))?;
269        tx.commit()?;
270        self.task(id)
271    }
272
273    pub fn task(&self, id: i64) -> Result<Task> {
274        get_task(&self.conn, id)?.ok_or(Error::TaskNotFound(id))
275    }
276
277    pub fn tasks(&self) -> Result<Vec<Task>> {
278        let mut stmt = self
279            .conn
280            .prepare(&format!("SELECT {TASK_COLUMNS} FROM tasks ORDER BY id"))?;
281        let rows = stmt.query_map([], task_from_row)?;
282        Ok(rows.collect::<rusqlite::Result<_>>()?)
283    }
284
285    pub fn update_task(&mut self, id: i64, edit: TaskEdit) -> Result<Task> {
286        let current = self.task(id)?;
287        if edit.human && edit.agent.is_some() {
288            return Err(Error::HumanTask {
289                id,
290                reason: "an agent override is meaningless on a task no agent can execute — \
291                         clear one or the other"
292                    .into(),
293            });
294        }
295        // `needs-input`, `review`, and `stalled` are unreachable for human tasks
296        // (§6), so a task sitting in one — or one an agent session is still open
297        // on — is demonstrably agent-executed and cannot be flagged human.
298        if edit.human && !current.human {
299            if matches!(
300                current.state,
301                TaskState::NeedsInput | TaskState::Review | TaskState::Stalled
302            ) {
303                return Err(Error::HumanTask {
304                    id,
305                    reason: format!(
306                        "a task in state '{}' was executed by an agent; resolve it first",
307                        current.state
308                    ),
309                });
310            }
311            let open_sessions: i64 = self.conn.query_row(
312                "SELECT COUNT(*) FROM sessions WHERE task_id = ?1 AND ended_at IS NULL",
313                [id],
314                |r| r.get(0),
315            )?;
316            if open_sessions > 0 {
317                return Err(Error::HumanTask {
318                    id,
319                    reason: "an agent session is still open on it; complete or abort it first"
320                        .into(),
321                });
322            }
323        }
324        self.conn.execute(
325            "UPDATE tasks SET title = ?1, body = ?2, priority = ?3, agent = ?4, human = ?5
326             WHERE id = ?6",
327            params![
328                edit.title,
329                edit.body,
330                edit.priority,
331                edit.agent,
332                edit.human,
333                id
334            ],
335        )?;
336        self.task(id)
337    }
338
339    /// Re-prioritise a task in isolation (DESIGN.md §7). Unlike `update_task`
340    /// this touches only `priority`, and it logs the change. Task state is left
341    /// untouched.
342    pub fn set_priority(&mut self, id: i64, priority: Priority) -> Result<Task> {
343        let changed = self.conn.execute(
344            "UPDATE tasks SET priority = ?1 WHERE id = ?2",
345            params![priority, id],
346        )?;
347        if changed == 0 {
348            return Err(Error::TaskNotFound(id));
349        }
350        log_event(&self.conn, id, "priority", Some(&priority.to_string()))?;
351        self.task(id)
352    }
353
354    /// Track (or, with `None`, untrack) a GitHub PR on a task (DESIGN.md §11c).
355    /// The URL is stored verbatim — validation is the caller's job — and the
356    /// change is logged. Leaves task state untouched.
357    pub fn set_pr(&mut self, id: i64, pr_url: Option<&str>) -> Result<Task> {
358        let changed = self.conn.execute(
359            "UPDATE tasks SET pr_url = ?1 WHERE id = ?2",
360            params![pr_url, id],
361        )?;
362        if changed == 0 {
363            return Err(Error::TaskNotFound(id));
364        }
365        log_event(&self.conn, id, "pr", pr_url.or(Some("cleared")))?;
366        self.task(id)
367    }
368
369    /// Record (or, with `None`, clear) the git branch a task's work lives on —
370    /// the intended name a human sets for dispatch to inject, or the name an
371    /// agent reports through `voro done --branch`. Stored verbatim (Voro never
372    /// runs git) and logged; task state is left untouched.
373    pub fn set_branch(&mut self, id: i64, branch: Option<&str>) -> Result<Task> {
374        let changed = self.conn.execute(
375            "UPDATE tasks SET branch = ?1 WHERE id = ?2",
376            params![branch, id],
377        )?;
378        if changed == 0 {
379            return Err(Error::TaskNotFound(id));
380        }
381        log_event(&self.conn, id, "branch", branch.or(Some("cleared")))?;
382        self.task(id)
383    }
384
385    /// Set or replace a task's completion summary outside `done` (DESIGN.md §8):
386    /// append a `summary` event, which [`latest_summary`] supersedes with, so the
387    /// PR body, detail view, and incomplete-report flag all pick up the newest.
388    /// This amends a stale PR body or supplies a missing `[incomplete report]`
389    /// summary without a `reject` → re-`done` round trip. Allowed only on a
390    /// `running` or `review` task; it never touches `tasks.state`.
391    ///
392    /// [`latest_summary`]: Store::latest_summary
393    pub fn set_summary(&mut self, id: i64, summary: &str) -> Result<Task> {
394        if summary.trim().is_empty() {
395            return Err(Error::Invalid("a summary is required".into()));
396        }
397        let task = self.task(id)?;
398        if !matches!(task.state, TaskState::Running | TaskState::Review) {
399            return Err(Error::Invalid(format!(
400                "a summary can only be set on a running or review task; task {} is {}",
401                id, task.state
402            )));
403        }
404        log_event(&self.conn, id, "summary", Some(summary.trim()))?;
405        self.task(id)
406    }
407
408    // --- deps ---
409
410    pub fn add_dep(&mut self, task_id: i64, depends_on: i64, kind: DepKind) -> Result<()> {
411        if kind != DepKind::Blocks && task_id == depends_on {
412            return Err(Error::Invalid("a task cannot depend on itself".into()));
413        }
414        let tx = self.conn.transaction()?;
415        if kind == DepKind::Blocks {
416            crate::transition::reject_blocks_cycle(&tx, task_id, depends_on)?;
417        }
418        tx.execute(
419            "INSERT INTO deps (task_id, depends_on, kind) VALUES (?1, ?2, ?3)",
420            params![task_id, depends_on, kind],
421        )?;
422        if kind == DepKind::Blocks {
423            crate::transition::reconcile_readiness(&tx, task_id)?;
424        }
425        tx.commit()?;
426        Ok(())
427    }
428
429    pub fn remove_dep(&mut self, task_id: i64, depends_on: i64) -> Result<()> {
430        let tx = self.conn.transaction()?;
431        tx.execute(
432            "DELETE FROM deps WHERE task_id = ?1 AND depends_on = ?2",
433            params![task_id, depends_on],
434        )?;
435        crate::transition::reconcile_readiness(&tx, task_id)?;
436        tx.commit()?;
437        Ok(())
438    }
439
440    /// Every dependency edge of every kind, keyed by the depending task and
441    /// resolved to the dependency's current title and state — the forward
442    /// direction a detail view renders as `blocked by #N`. One query feeds every
443    /// pane, so the render path never issues a per-row lookup.
444    pub fn deps_by_task(&self) -> Result<HashMap<i64, Vec<DepRef>>> {
445        self.dep_refs(
446            "SELECT d.task_id, t.id, t.title, t.state, d.kind
447             FROM deps d JOIN tasks t ON t.id = d.depends_on
448             ORDER BY d.task_id, t.id",
449        )
450    }
451
452    /// The reverse edges: every dependency keyed by the task depended *on*,
453    /// resolved to the depending task — who a task blocks (or spawned).
454    pub fn dependents_by_task(&self) -> Result<HashMap<i64, Vec<DepRef>>> {
455        self.dep_refs(
456            "SELECT d.depends_on, t.id, t.title, t.state, d.kind
457             FROM deps d JOIN tasks t ON t.id = d.task_id
458             ORDER BY d.depends_on, t.id",
459        )
460    }
461
462    fn dep_refs(&self, sql: &str) -> Result<HashMap<i64, Vec<DepRef>>> {
463        let mut stmt = self.conn.prepare(sql)?;
464        let rows = stmt.query_map([], |row| {
465            let key: i64 = row.get(0)?;
466            let dep = DepRef {
467                id: row.get(1)?,
468                title: row.get(2)?,
469                state: row.get(3)?,
470                kind: row.get(4)?,
471            };
472            Ok((key, dep))
473        })?;
474        let mut map: HashMap<i64, Vec<DepRef>> = HashMap::new();
475        for row in rows {
476            let (key, dep) = row?;
477            map.entry(key).or_default().push(dep);
478        }
479        Ok(map)
480    }
481
482    pub fn deps_of(&self, task_id: i64) -> Result<Vec<Dep>> {
483        let mut stmt = self.conn.prepare(
484            "SELECT task_id, depends_on, kind FROM deps WHERE task_id = ?1 ORDER BY depends_on",
485        )?;
486        let rows = stmt.query_map([task_id], |row| {
487            Ok(Dep {
488                task_id: row.get(0)?,
489                depends_on: row.get(1)?,
490                kind: row.get(2)?,
491            })
492        })?;
493        Ok(rows.collect::<rusqlite::Result<_>>()?)
494    }
495
496    // --- sessions ---
497
498    /// Open a session for a running task, stamping `started_at`. `ended_at` and
499    /// `outcome` stay NULL until [`end_session`](Store::end_session).
500    pub fn create_session(
501        &mut self,
502        task_id: i64,
503        agent: &str,
504        pid: Option<i64>,
505        log_path: Option<&str>,
506    ) -> Result<Session> {
507        let id = insert_session(&self.conn, task_id, agent, pid, log_path)?;
508        self.session(id)
509    }
510
511    /// Record the agent's own reference for a session (task #75), captured
512    /// after launch — the row necessarily exists before the reference does,
513    /// so this is an update rather than a `create_session` parameter.
514    pub fn set_session_ref(&mut self, id: i64, session_ref: &str) -> Result<Session> {
515        let changed = self.conn.execute(
516            "UPDATE sessions SET session_ref = ?1 WHERE id = ?2",
517            params![session_ref, id],
518        )?;
519        if changed == 0 {
520            return Err(Error::SessionNotFound(id));
521        }
522        self.session(id)
523    }
524
525    /// Close a session with its outcome, stamping `ended_at`.
526    pub fn end_session(&mut self, id: i64, outcome: SessionOutcome) -> Result<Session> {
527        if set_session_outcome(&self.conn, id, outcome)? == 0 {
528            return Err(Error::SessionNotFound(id));
529        }
530        self.session(id)
531    }
532
533    pub fn session(&self, id: i64) -> Result<Session> {
534        self.conn
535            .query_row(
536                &format!("SELECT {SESSION_COLUMNS} FROM sessions WHERE id = ?1"),
537                [id],
538                session_from_row,
539            )
540            .optional()?
541            .ok_or(Error::SessionNotFound(id))
542    }
543
544    pub fn sessions_for(&self, task_id: i64) -> Result<Vec<Session>> {
545        let mut stmt = self.conn.prepare(&format!(
546            "SELECT {SESSION_COLUMNS} FROM sessions WHERE task_id = ?1 ORDER BY id DESC"
547        ))?;
548        let rows = stmt.query_map([task_id], session_from_row)?;
549        Ok(rows.collect::<rusqlite::Result<_>>()?)
550    }
551
552    /// Every task's newest session, keyed by task id, in one query — what the
553    /// TUI loads per refresh to answer "what is/was this session doing?" without
554    /// querying the store mid-draw. Session ids are monotonic, so `max(id)` is
555    /// the latest.
556    pub fn latest_sessions(&self) -> Result<std::collections::HashMap<i64, Session>> {
557        let mut stmt = self.conn.prepare(&format!(
558            "SELECT {SESSION_COLUMNS} FROM sessions s
559             WHERE s.id = (SELECT max(id) FROM sessions WHERE task_id = s.task_id)"
560        ))?;
561        let rows = stmt.query_map([], session_from_row)?;
562        rows.map(|r| r.map(|s| (s.task_id, s)))
563            .collect::<rusqlite::Result<_>>()
564            .map_err(Into::into)
565    }
566
567    /// Sessions that have not yet ended, newest first.
568    pub fn live_sessions(&self) -> Result<Vec<Session>> {
569        let mut stmt = self.conn.prepare(&format!(
570            "SELECT {SESSION_COLUMNS} FROM sessions WHERE ended_at IS NULL ORDER BY id DESC"
571        ))?;
572        let rows = stmt.query_map([], session_from_row)?;
573        Ok(rows.collect::<rusqlite::Result<_>>()?)
574    }
575
576    /// Whether `task_id` is a `review` task carrying a *partial* completion
577    /// report — exactly one of its branch and summary present (DESIGN.md §8).
578    /// Neither is deliberately not flagged (a planning task produces neither),
579    /// both is complete. Gated on `review`, derived fresh rather than stored.
580    pub fn incomplete_report_flag(&self, task_id: i64) -> Result<bool> {
581        let row: Option<(TaskState, Option<String>)> = self
582            .conn
583            .query_row(
584                "SELECT state, branch FROM tasks WHERE id = ?1",
585                [task_id],
586                |r| Ok((r.get(0)?, r.get(1)?)),
587            )
588            .optional()?;
589        let Some((state, branch)) = row else {
590            return Ok(false);
591        };
592        if state != TaskState::Review {
593            return Ok(false);
594        }
595        let has_branch = branch.is_some();
596        let has_summary = self.latest_summary(task_id)?.is_some();
597        Ok(has_branch != has_summary)
598    }
599
600    /// Rows for the cockpit's running strip (DESIGN.md §9): every `running`
601    /// task, joined with its open session if it has one. The strip filters on
602    /// task *state*, so `review`/`needs-input` tasks (session still open) do not
603    /// appear. A hand-started task with no session shows with `session_id`/
604    /// `agent` `NULL`. The one-open-session invariant (§8) bounds the join to one
605    /// row per task; elapsed is computed in SQL so the TUI only formats it.
606    pub fn running_rows(&self) -> Result<Vec<RunningRow>> {
607        let mut stmt = self.conn.prepare(
608            "SELECT s.id AS session_id, t.id, t.title, t.state, s.agent,
609                    COALESCE(s.started_at, t.state_since) AS since,
610                    CAST(strftime('%s', 'now')
611                         - strftime('%s', COALESCE(s.started_at, t.state_since)) AS INTEGER)
612             FROM tasks t
613             LEFT JOIN sessions s ON s.task_id = t.id AND s.ended_at IS NULL
614             WHERE t.state = 'running'
615             ORDER BY (s.id IS NULL), s.id DESC, t.id DESC",
616        )?;
617        let rows = stmt.query_map([], |row| {
618            Ok(RunningRow {
619                session_id: row.get(0)?,
620                task_id: row.get(1)?,
621                task_title: row.get(2)?,
622                task_state: row.get(3)?,
623                agent: row.get(4)?,
624                started_at: row.get(5)?,
625                elapsed_secs: row.get(6)?,
626            })
627        })?;
628        Ok(rows.collect::<rusqlite::Result<_>>()?)
629    }
630
631    // --- events ---
632
633    /// The most recent completion summary a task recorded (DESIGN.md §8): the
634    /// detail of its newest `summary` event, logged by `done --summary` or
635    /// amended by `set --summary` ([`set_summary`]). This is the PR body when
636    /// `pr` opens a pull request. `None` when the task never carried a summary.
637    ///
638    /// [`set_summary`]: Store::set_summary
639    pub fn latest_summary(&self, task_id: i64) -> Result<Option<String>> {
640        Ok(self
641            .conn
642            .query_row(
643                "SELECT detail FROM events WHERE task_id = ?1 AND kind = 'summary'
644                 ORDER BY id DESC LIMIT 1",
645                [task_id],
646                |r| r.get::<_, Option<String>>(0),
647            )
648            .optional()?
649            .flatten())
650    }
651
652    pub fn events_for(&self, task_id: i64) -> Result<Vec<Event>> {
653        let mut stmt = self.conn.prepare(
654            "SELECT id, task_id, at, kind, detail FROM events WHERE task_id = ?1 ORDER BY id",
655        )?;
656        let rows = stmt.query_map([task_id], |row| {
657            Ok(Event {
658                id: row.get(0)?,
659                task_id: row.get(1)?,
660                at: row.get(2)?,
661                kind: row.get(3)?,
662                detail: row.get(4)?,
663            })
664        })?;
665        Ok(rows.collect::<rusqlite::Result<_>>()?)
666    }
667}
668
669pub(crate) const TASK_COLUMNS: &str = "id, project_id, title, body, priority, state, agent, \
670                                       question, pr_url, branch, state_since, created_at, \
671                                       closed_at, human";
672
673pub(crate) fn get_task(conn: &Connection, id: i64) -> Result<Option<Task>> {
674    Ok(conn
675        .query_row(
676            &format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?1"),
677            [id],
678            task_from_row,
679        )
680        .optional()?)
681}
682
683pub(crate) fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Task> {
684    Ok(Task {
685        id: row.get(0)?,
686        project_id: row.get(1)?,
687        title: row.get(2)?,
688        body: row.get(3)?,
689        priority: row.get(4)?,
690        state: row.get(5)?,
691        agent: row.get(6)?,
692        question: row.get(7)?,
693        pr_url: row.get(8)?,
694        branch: row.get(9)?,
695        state_since: row.get(10)?,
696        created_at: row.get(11)?,
697        closed_at: row.get(12)?,
698        human: row.get(13)?,
699    })
700}
701
702pub(crate) const SESSION_COLUMNS: &str =
703    "id, task_id, agent, pid, session_ref, log_path, started_at, ended_at, outcome";
704
705pub(crate) fn get_session(conn: &Connection, id: i64) -> Result<Option<Session>> {
706    Ok(conn
707        .query_row(
708            &format!("SELECT {SESSION_COLUMNS} FROM sessions WHERE id = ?1"),
709            [id],
710            session_from_row,
711        )
712        .optional()?)
713}
714
715fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Session> {
716    Ok(Session {
717        id: row.get(0)?,
718        task_id: row.get(1)?,
719        agent: row.get(2)?,
720        pid: row.get(3)?,
721        session_ref: row.get(4)?,
722        log_path: row.get(5)?,
723        started_at: row.get(6)?,
724        ended_at: row.get(7)?,
725        outcome: row.get(8)?,
726    })
727}
728
729fn project_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Project> {
730    Ok(Project {
731        id: row.get(0)?,
732        name: row.get(1)?,
733        path: row.get(2)?,
734        weight: row.get(3)?,
735        review_action: row.get(4)?,
736    })
737}
738
739/// Insert a session row, stamping `started_at`, and return its id. Shared by
740/// [`Store::create_session`] and the dispatch/continuation transactions.
741/// Enforces the one-open-session invariant (DESIGN.md §8): opening a new session
742/// first closes any predecessor still open (stamped `aborted`). The partial
743/// unique index is the schema-level backstop.
744pub(crate) fn insert_session(
745    conn: &Connection,
746    task_id: i64,
747    agent: &str,
748    pid: Option<i64>,
749    log_path: Option<&str>,
750) -> Result<i64> {
751    close_open_session(conn, task_id, SessionOutcome::Aborted)?;
752    conn.execute(
753        "INSERT INTO sessions (task_id, agent, pid, log_path, started_at)
754         VALUES (?1, ?2, ?3, ?4, datetime('now'))",
755        params![task_id, agent, pid, log_path],
756    )?;
757    Ok(conn.last_insert_rowid())
758}
759
760/// Close a task's currently-open session, if any, stamping `ended_at` and
761/// `outcome`. The one-open-session invariant (DESIGN.md §8) means this touches
762/// at most one row. Used to supersede a predecessor and to close the session on
763/// a terminal transition. Returns the number of rows closed (0 if none was open).
764pub(crate) fn close_open_session(
765    conn: &Connection,
766    task_id: i64,
767    outcome: SessionOutcome,
768) -> Result<usize> {
769    Ok(conn.execute(
770        "UPDATE sessions SET ended_at = datetime('now'), outcome = ?1
771         WHERE task_id = ?2 AND ended_at IS NULL",
772        params![outcome, task_id],
773    )?)
774}
775
776/// Stamp `ended_at` and record `outcome` on a session, returning the number of
777/// rows changed (0 if the id is unknown). Shared by [`Store::end_session`] and
778/// reconciliation.
779pub(crate) fn set_session_outcome(
780    conn: &Connection,
781    id: i64,
782    outcome: SessionOutcome,
783) -> Result<usize> {
784    Ok(conn.execute(
785        "UPDATE sessions SET ended_at = datetime('now'), outcome = ?1 WHERE id = ?2",
786        params![outcome, id],
787    )?)
788}
789
790pub(crate) fn log_event(
791    conn: &Connection,
792    task_id: i64,
793    kind: &str,
794    detail: Option<&str>,
795) -> Result<()> {
796    conn.execute(
797        "INSERT INTO events (task_id, at, kind, detail) VALUES (?1, datetime('now'), ?2, ?3)",
798        params![task_id, kind, detail],
799    )?;
800    Ok(())
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use crate::transition::{Action, Triage};
807
808    #[test]
809    fn rename_project_updates_name_and_leaves_task_references_intact() {
810        let mut s = Store::open_in_memory().unwrap();
811        let p = s.create_project("old-name", "/tmp/old").unwrap();
812        let task = s
813            .create_task(NewTask {
814                project_id: p.id,
815                title: "t".into(),
816                body: String::new(),
817                priority: Priority::P2,
818                state: TaskState::Ready,
819                agent: None,
820                human: false,
821            })
822            .unwrap();
823
824        let renamed = s.rename_project(p.id, "new-name").unwrap();
825        assert_eq!(renamed.id, p.id);
826        assert_eq!(renamed.name, "new-name");
827
828        // the task still resolves to the same project by id, under its new name
829        let reloaded = s.task(task.id).unwrap();
830        assert_eq!(reloaded.project_id, p.id);
831        assert_eq!(s.project(reloaded.project_id).unwrap().name, "new-name");
832    }
833
834    #[test]
835    fn review_action_defaults_to_auto_and_round_trips() {
836        use crate::model::ReviewAction;
837        let mut s = Store::open_in_memory().unwrap();
838        let p = s.create_project("proj", "/tmp/proj").unwrap();
839        assert_eq!(p.review_action, ReviewAction::Auto);
840
841        let action = ReviewAction::Viewer(Some("zed".into()));
842        let updated = s.set_review_action(p.id, &action).unwrap();
843        assert_eq!(updated.review_action, action);
844        assert_eq!(s.project(p.id).unwrap().review_action, action);
845        assert_eq!(s.projects().unwrap()[0].review_action, action);
846
847        // Auto writes NULL, so the column reads back empty
848        s.set_review_action(p.id, &ReviewAction::Auto).unwrap();
849        assert_eq!(s.project(p.id).unwrap().review_action, ReviewAction::Auto);
850        let raw: Option<String> = s
851            .conn
852            .query_row(
853                "SELECT review_action FROM projects WHERE id = ?1",
854                [p.id],
855                |r| r.get(0),
856            )
857            .unwrap();
858        assert_eq!(raw, None);
859
860        assert!(matches!(
861            s.set_review_action(999, &ReviewAction::Pr),
862            Err(Error::ProjectNotFound(999))
863        ));
864    }
865
866    #[test]
867    fn rename_project_rejects_unknown_id() {
868        let mut s = Store::open_in_memory().unwrap();
869        assert!(matches!(
870            s.rename_project(999, "x"),
871            Err(Error::ProjectNotFound(999))
872        ));
873    }
874
875    #[test]
876    fn set_pr_tracks_clears_and_logs() {
877        let mut s = Store::open_in_memory().unwrap();
878        let p = s.create_project("voro", "/tmp/voro").unwrap();
879        let t = s
880            .create_task(NewTask {
881                project_id: p.id,
882                title: "review me".into(),
883                body: String::new(),
884                priority: Priority::P2,
885                state: TaskState::Ready,
886                agent: None,
887                human: false,
888            })
889            .unwrap();
890        assert!(s.task(t.id).unwrap().pr_url.is_none());
891
892        let tracked = s
893            .set_pr(t.id, Some("https://github.com/acme/widget/pull/42"))
894            .unwrap();
895        assert_eq!(
896            tracked.pr_url.as_deref(),
897            Some("https://github.com/acme/widget/pull/42")
898        );
899        // state is untouched by tracking a PR
900        assert_eq!(tracked.state, TaskState::Ready);
901
902        let cleared = s.set_pr(t.id, None).unwrap();
903        assert!(cleared.pr_url.is_none());
904
905        let events = s.events_for(t.id).unwrap();
906        let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
907        assert_eq!(kinds, vec!["created", "pr", "pr"]);
908        assert!(matches!(s.set_pr(999, None), Err(Error::TaskNotFound(999))));
909    }
910
911    #[test]
912    fn set_priority_updates_leaves_state_and_logs() {
913        let mut s = Store::open_in_memory().unwrap();
914        let p = s.create_project("voro", "/tmp/voro").unwrap();
915        let t = s
916            .create_task(NewTask {
917                project_id: p.id,
918                title: "reprioritise me".into(),
919                body: String::new(),
920                priority: Priority::P2,
921                state: TaskState::Ready,
922                agent: None,
923                human: false,
924            })
925            .unwrap();
926
927        let raised = s.set_priority(t.id, Priority::P0).unwrap();
928        assert_eq!(raised.priority, Priority::P0);
929        // priority is changed in isolation; state is untouched
930        assert_eq!(raised.state, TaskState::Ready);
931
932        let events = s.events_for(t.id).unwrap();
933        let last = events.last().unwrap();
934        assert_eq!(last.kind, "priority");
935        assert_eq!(last.detail.as_deref(), Some("P0"));
936
937        assert!(matches!(
938            s.set_priority(999, Priority::P1),
939            Err(Error::TaskNotFound(999))
940        ));
941    }
942
943    #[test]
944    fn set_branch_records_clears_and_logs() {
945        let mut s = Store::open_in_memory().unwrap();
946        let p = s.create_project("voro", "/tmp/voro").unwrap();
947        let t = s
948            .create_task(NewTask {
949                project_id: p.id,
950                title: "branch me".into(),
951                body: String::new(),
952                priority: Priority::P2,
953                state: TaskState::Ready,
954                agent: None,
955                human: false,
956            })
957            .unwrap();
958        assert!(s.task(t.id).unwrap().branch.is_none());
959
960        let named = s.set_branch(t.id, Some("feat/parser")).unwrap();
961        assert_eq!(named.branch.as_deref(), Some("feat/parser"));
962        // recording a branch never touches task state
963        assert_eq!(named.state, TaskState::Ready);
964
965        // reporting a different branch overwrites the intended one
966        let renamed = s.set_branch(t.id, Some("feat/parser-v2")).unwrap();
967        assert_eq!(renamed.branch.as_deref(), Some("feat/parser-v2"));
968
969        let cleared = s.set_branch(t.id, None).unwrap();
970        assert!(cleared.branch.is_none());
971
972        let events = s.events_for(t.id).unwrap();
973        let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
974        assert_eq!(kinds, vec!["created", "branch", "branch", "branch"]);
975        assert!(matches!(
976            s.set_branch(999, None),
977            Err(Error::TaskNotFound(999))
978        ));
979    }
980
981    // --- the human flag and the agent override are mutually exclusive (§3/§6) ---
982
983    /// A store, a project, and a `NewTask` builder for the human-flag tests.
984    fn human_fixture() -> (Store, i64) {
985        let mut s = Store::open_in_memory().unwrap();
986        let p = s.create_project("voro", "/tmp/voro").unwrap();
987        (s, p.id)
988    }
989
990    fn new_with(project_id: i64, agent: Option<&str>, human: bool) -> NewTask {
991        NewTask {
992            project_id,
993            title: "hands-on".into(),
994            body: String::new(),
995            priority: Priority::P2,
996            state: TaskState::Ready,
997            agent: agent.map(str::to_string),
998            human,
999        }
1000    }
1001
1002    fn edit_of(task: &Task, agent: Option<&str>, human: bool) -> TaskEdit {
1003        TaskEdit {
1004            title: task.title.clone(),
1005            body: task.body.clone(),
1006            priority: task.priority,
1007            agent: agent.map(str::to_string),
1008            human,
1009        }
1010    }
1011
1012    #[test]
1013    fn create_task_refuses_a_human_task_with_an_agent_override() {
1014        let (mut s, p) = human_fixture();
1015        let err = s.create_task(new_with(p, Some("codex"), true)).unwrap_err();
1016        assert!(err.to_string().contains("agent override"), "{err}");
1017        assert!(s.tasks().unwrap().is_empty());
1018
1019        assert!(s.create_task(new_with(p, Some("codex"), false)).is_ok());
1020        let human = s.create_task(new_with(p, None, true)).unwrap();
1021        assert!(human.human);
1022    }
1023
1024    #[test]
1025    fn update_task_guards_the_agent_human_exclusivity_both_ways() {
1026        let (mut s, p) = human_fixture();
1027
1028        // an agent override cannot land on a human task
1029        let human = s.create_task(new_with(p, None, true)).unwrap();
1030        let err = s
1031            .update_task(human.id, edit_of(&human, Some("codex"), true))
1032            .unwrap_err();
1033        assert!(matches!(err, Error::HumanTask { id, .. } if id == human.id));
1034
1035        // ...and the flag cannot land while an override is kept
1036        let agented = s.create_task(new_with(p, Some("codex"), false)).unwrap();
1037        let err = s
1038            .update_task(agented.id, edit_of(&agented, Some("codex"), true))
1039            .unwrap_err();
1040        assert!(matches!(err, Error::HumanTask { id, .. } if id == agented.id));
1041
1042        // clearing the override in the same edit is the designed way through
1043        let flipped = s
1044            .update_task(agented.id, edit_of(&agented, None, true))
1045            .unwrap();
1046        assert!(flipped.human);
1047        assert!(flipped.agent.is_none());
1048    }
1049
1050    #[test]
1051    fn update_task_refuses_flagging_human_in_agent_executed_states() {
1052        use crate::transition::Action;
1053
1054        // needs-input, review, and stalled are unreachable for human tasks
1055        // (§6), so a task already sitting there cannot be flagged as one.
1056        for walk in [TaskState::NeedsInput, TaskState::Review, TaskState::Stalled] {
1057            let (mut s, p) = human_fixture();
1058            let t = s.create_task(new_with(p, None, false)).unwrap();
1059            match walk {
1060                TaskState::NeedsInput => {
1061                    s.apply(t.id, Action::Start).unwrap();
1062                    s.apply(t.id, Action::Ask("A or B?".into())).unwrap();
1063                }
1064                TaskState::Stalled => {
1065                    let (_, session) = s.record_dispatch(t.id, "claude", Some(1), None).unwrap();
1066                    s.reconcile_session(session.id, false, false).unwrap();
1067                }
1068                _ => {
1069                    s.apply(t.id, Action::Start).unwrap();
1070                    s.apply(t.id, Action::Complete(None)).unwrap();
1071                }
1072            }
1073            assert_eq!(s.task(t.id).unwrap().state, walk);
1074            let err = s.update_task(t.id, edit_of(&t, None, true)).unwrap_err();
1075            assert!(
1076                matches!(err, Error::HumanTask { id, .. } if id == t.id),
1077                "{walk}: {err}"
1078            );
1079            assert!(!s.task(t.id).unwrap().human);
1080        }
1081    }
1082
1083    #[test]
1084    fn update_task_refuses_flagging_human_while_a_session_is_open() {
1085        use crate::transition::Action;
1086
1087        let (mut s, p) = human_fixture();
1088        let t = s.create_task(new_with(p, None, false)).unwrap();
1089        s.record_dispatch(t.id, "claude", Some(1), None).unwrap();
1090
1091        let err = s.update_task(t.id, edit_of(&t, None, true)).unwrap_err();
1092        assert!(matches!(err, Error::HumanTask { id, .. } if id == t.id));
1093
1094        // once the session is torn down the flip is allowed again
1095        s.apply(t.id, Action::Abort).unwrap();
1096        let flipped = s.update_task(t.id, edit_of(&t, None, true)).unwrap();
1097        assert!(flipped.human);
1098
1099        // a hand-started running task has no session and can flip freely
1100        let by_hand = s.create_task(new_with(p, None, false)).unwrap();
1101        s.apply(by_hand.id, Action::Start).unwrap();
1102        assert!(
1103            s.update_task(by_hand.id, edit_of(&by_hand, None, true))
1104                .unwrap()
1105                .human
1106        );
1107    }
1108
1109    /// A database from before migration 0007 must open with every existing
1110    /// task dispatchable (`human = 0`), and the CHECK must reject junk.
1111    #[test]
1112    fn migration_0007_defaults_existing_tasks_to_dispatchable() {
1113        let conn = Connection::open_in_memory().unwrap();
1114        for sql in &MIGRATIONS[..6] {
1115            conn.execute_batch(sql).unwrap();
1116        }
1117        conn.pragma_update(None, "user_version", 6).unwrap();
1118        conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
1119            .unwrap();
1120        conn.execute(
1121            "INSERT INTO tasks (project_id, title, state, state_since, created_at)
1122             VALUES (1, 'pre-flag', 'ready', datetime('now'), datetime('now'))",
1123            [],
1124        )
1125        .unwrap();
1126
1127        let store = Store::from_connection(conn).unwrap();
1128        assert!(!store.task(1).unwrap().human);
1129
1130        let junk = store
1131            .conn
1132            .execute("UPDATE tasks SET human = 2 WHERE id = 1", []);
1133        assert!(junk.is_err(), "the CHECK must reject values outside 0/1");
1134    }
1135
1136    #[test]
1137    fn set_summary_appends_a_superseding_summary_event() {
1138        use crate::transition::Action;
1139
1140        let mut s = Store::open_in_memory().unwrap();
1141        let p = s.create_project("voro", "/tmp/voro").unwrap();
1142        let t = s
1143            .create_task(NewTask {
1144                project_id: p.id,
1145                title: "summarise me".into(),
1146                body: String::new(),
1147                priority: Priority::P2,
1148                state: TaskState::Ready,
1149                agent: None,
1150                human: false,
1151            })
1152            .unwrap();
1153
1154        // a running task may record its account before `done`
1155        s.apply(t.id, Action::Start).unwrap();
1156        let updated = s.set_summary(t.id, "  early account  ").unwrap();
1157        assert_eq!(updated.state, TaskState::Running);
1158        assert_eq!(
1159            s.latest_summary(t.id).unwrap().as_deref(),
1160            Some("early account")
1161        );
1162
1163        // in review, a new summary supersedes the done-time one
1164        s.apply(t.id, Action::Complete(Some("done-time".into())))
1165            .unwrap();
1166        let updated = s.set_summary(t.id, "amended for the PR body").unwrap();
1167        assert_eq!(updated.state, TaskState::Review);
1168        assert_eq!(
1169            s.latest_summary(t.id).unwrap().as_deref(),
1170            Some("amended for the PR body")
1171        );
1172
1173        // every account stays on the append-only log
1174        let events = s.events_for(t.id).unwrap();
1175        let summaries = events.iter().filter(|e| e.kind == "summary").count();
1176        assert_eq!(summaries, 3);
1177    }
1178
1179    #[test]
1180    fn set_summary_clears_the_incomplete_report_flag() {
1181        use crate::transition::Action;
1182
1183        // The SessionEnd-fallback shape: review with a branch and no summary.
1184        let mut s = Store::open_in_memory().unwrap();
1185        let p = s.create_project("voro", "/tmp/voro").unwrap();
1186        let t = s
1187            .create_task(NewTask {
1188                project_id: p.id,
1189                title: "half a report".into(),
1190                body: String::new(),
1191                priority: Priority::P2,
1192                state: TaskState::Ready,
1193                agent: None,
1194                human: false,
1195            })
1196            .unwrap();
1197        s.apply(t.id, Action::Start).unwrap();
1198        s.apply(t.id, Action::Complete(None)).unwrap();
1199        s.set_branch(t.id, Some("feat/x")).unwrap();
1200        assert!(s.incomplete_report_flag(t.id).unwrap());
1201
1202        s.set_summary(t.id, "the missing half").unwrap();
1203        assert!(!s.incomplete_report_flag(t.id).unwrap());
1204    }
1205
1206    #[test]
1207    fn set_summary_is_refused_outside_running_and_review() {
1208        use crate::transition::Action;
1209
1210        let mut s = Store::open_in_memory().unwrap();
1211        let p = s.create_project("voro", "/tmp/voro").unwrap();
1212        let t = s
1213            .create_task(NewTask {
1214                project_id: p.id,
1215                title: "not yet".into(),
1216                body: String::new(),
1217                priority: Priority::P2,
1218                state: TaskState::Ready,
1219                agent: None,
1220                human: false,
1221            })
1222            .unwrap();
1223        let err = s.set_summary(t.id, "too early").unwrap_err();
1224        assert!(err.to_string().contains("ready"), "{err}");
1225
1226        s.apply(t.id, Action::Start).unwrap();
1227        s.apply(t.id, Action::Complete(None)).unwrap();
1228        s.apply(t.id, Action::Accept).unwrap();
1229        let err = s.set_summary(t.id, "too late").unwrap_err();
1230        assert!(err.to_string().contains("done"), "{err}");
1231
1232        assert!(s.set_summary(t.id, "   ").is_err());
1233        assert!(matches!(
1234            s.set_summary(999, "x"),
1235            Err(Error::TaskNotFound(999))
1236        ));
1237    }
1238
1239    #[test]
1240    fn set_path_updates_path() {
1241        let mut s = Store::open_in_memory().unwrap();
1242        let p = s.create_project("proj", "/tmp/old").unwrap();
1243        let updated = s.set_path(p.id, "/tmp/new").unwrap();
1244        assert_eq!(updated.path, "/tmp/new");
1245        assert_eq!(s.project(p.id).unwrap().path, "/tmp/new");
1246    }
1247
1248    #[test]
1249    fn set_path_rejects_unknown_id() {
1250        let mut s = Store::open_in_memory().unwrap();
1251        assert!(matches!(
1252            s.set_path(999, "/tmp"),
1253            Err(Error::ProjectNotFound(999))
1254        ));
1255    }
1256
1257    #[test]
1258    fn delete_project_removes_a_taskless_project() {
1259        let mut s = Store::open_in_memory().unwrap();
1260        let p = s.create_project("empty", "/tmp/empty").unwrap();
1261        s.delete_project(p.id).unwrap();
1262        assert!(matches!(s.project(p.id), Err(Error::ProjectNotFound(_))));
1263        assert!(s.projects().unwrap().is_empty());
1264    }
1265
1266    #[test]
1267    fn delete_project_rejects_unknown_id() {
1268        let mut s = Store::open_in_memory().unwrap();
1269        assert!(matches!(
1270            s.delete_project(999),
1271            Err(Error::ProjectNotFound(999))
1272        ));
1273    }
1274
1275    /// Walk a fresh task into `state` through the transition API, mirroring
1276    /// the equivalent helper in `transition.rs`'s own tests.
1277    fn task_in_state(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
1278        use TaskState::*;
1279        let create = |s: &mut Store, state| {
1280            s.create_task(NewTask {
1281                project_id,
1282                title: format!("task in {state}"),
1283                body: String::new(),
1284                priority: Priority::P1,
1285                state,
1286                agent: None,
1287                human: false,
1288            })
1289            .unwrap()
1290            .id
1291        };
1292        match state {
1293            Proposed | Parked | Ready => create(s, state),
1294            Running => {
1295                let id = create(s, Ready);
1296                s.apply(id, Action::Start).unwrap();
1297                id
1298            }
1299            NeedsInput => {
1300                let id = task_in_state(s, project_id, Running);
1301                s.apply(id, Action::Ask("which schema?".into())).unwrap();
1302                id
1303            }
1304            Review => {
1305                let id = task_in_state(s, project_id, Running);
1306                s.apply(id, Action::Complete(None)).unwrap();
1307                id
1308            }
1309            Waiting => {
1310                let id = task_in_state(s, project_id, Review);
1311                s.apply(id, Action::HandOff).unwrap();
1312                id
1313            }
1314            Stalled => {
1315                let id = create(s, Ready);
1316                let (_, session) = s.record_dispatch(id, "claude", Some(1), None).unwrap();
1317                s.reconcile_session(session.id, false, false).unwrap();
1318                id
1319            }
1320            Done => {
1321                let id = task_in_state(s, project_id, Review);
1322                s.apply(id, Action::Accept).unwrap();
1323                id
1324            }
1325            Rejected => {
1326                let id = create(s, Proposed);
1327                s.apply(id, Action::Triage(Triage::Reject)).unwrap();
1328                id
1329            }
1330        }
1331    }
1332
1333    #[test]
1334    fn delete_project_refuses_with_a_task_in_any_state() {
1335        for state in TaskState::ALL {
1336            let mut s = Store::open_in_memory().unwrap();
1337            let p = s.create_project("proj", "/tmp/proj").unwrap();
1338            task_in_state(&mut s, p.id, state);
1339
1340            let err = s.delete_project(p.id).unwrap_err();
1341            assert!(
1342                matches!(err, Error::ProjectHasTasks { id, count } if id == p.id && count == 1),
1343                "state {state}: expected ProjectHasTasks, got {err}"
1344            );
1345            // the refusal must not have touched the project
1346            assert!(s.project(p.id).is_ok());
1347        }
1348    }
1349
1350    /// A database created at schema version 1 (state still named 'backlog')
1351    /// must convert on open: rows renamed, deps/events surviving the table
1352    /// rebuild, version stamped.
1353    #[test]
1354    fn migration_0002_converts_backlog_rows() {
1355        let conn = Connection::open_in_memory().unwrap();
1356        conn.execute_batch(MIGRATIONS[0]).unwrap();
1357        conn.pragma_update(None, "user_version", 1).unwrap();
1358        conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
1359            .unwrap();
1360        conn.execute(
1361            "INSERT INTO tasks (project_id, title, state, state_since, created_at)
1362             VALUES (1, 'blocker', 'ready', datetime('now'), datetime('now')),
1363                    (1, 'waiting', 'backlog', datetime('now'), datetime('now'))",
1364            [],
1365        )
1366        .unwrap();
1367        conn.execute("INSERT INTO deps (task_id, depends_on) VALUES (2, 1)", [])
1368            .unwrap();
1369        conn.execute(
1370            "INSERT INTO events (task_id, at, kind, detail)
1371             VALUES (2, datetime('now'), 'created', 'backlog')",
1372            [],
1373        )
1374        .unwrap();
1375
1376        let store = Store::from_connection(conn).unwrap();
1377        assert_eq!(store.task(2).unwrap().state, TaskState::Parked);
1378        assert_eq!(store.task(1).unwrap().state, TaskState::Ready);
1379        assert_eq!(store.deps_of(2).unwrap().len(), 1);
1380        // the event log is history and keeps its original wording
1381        assert_eq!(
1382            store.events_for(2).unwrap()[0].detail.as_deref(),
1383            Some("backlog")
1384        );
1385        let version: i64 = store
1386            .conn
1387            .query_row("PRAGMA user_version", [], |r| r.get(0))
1388            .unwrap();
1389        assert_eq!(version, MIGRATIONS.len() as i64);
1390        // 0004 gave the sessions table its session_ref column
1391        let refs: i64 = store
1392            .conn
1393            .query_row("SELECT COUNT(session_ref) FROM sessions", [], |r| r.get(0))
1394            .unwrap();
1395        assert_eq!(refs, 0);
1396    }
1397
1398    /// Migration 0006 must dedupe a task that already carries several open
1399    /// sessions — keeping the newest open and closing the rest — before it can
1400    /// create the one-open-session index, and the index must then reject any
1401    /// further second open row.
1402    #[test]
1403    fn migration_0006_dedupes_open_sessions_and_enforces_the_index() {
1404        let conn = Connection::open_in_memory().unwrap();
1405        // apply 0001..=0005, i.e. everything before the invariant migration
1406        for sql in &MIGRATIONS[..5] {
1407            conn.execute_batch(sql).unwrap();
1408        }
1409        conn.pragma_update(None, "user_version", 5).unwrap();
1410        conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
1411            .unwrap();
1412        conn.execute(
1413            "INSERT INTO tasks (project_id, title, state, state_since, created_at)
1414             VALUES (1, 'run me', 'running', datetime('now'), datetime('now'))",
1415            [],
1416        )
1417        .unwrap();
1418        // three open sessions on the one task — the exact duplicate state
1419        for _ in 0..3 {
1420            conn.execute(
1421                "INSERT INTO sessions (task_id, agent, started_at) VALUES (1, 'a', datetime('now'))",
1422                [],
1423            )
1424            .unwrap();
1425        }
1426
1427        let store = Store::from_connection(conn).unwrap();
1428        // only the newest open session survives; the rest are closed `aborted`
1429        let open: Vec<i64> = store
1430            .sessions_for(1)
1431            .unwrap()
1432            .into_iter()
1433            .filter(|s| s.ended_at.is_none())
1434            .map(|s| s.id)
1435            .collect();
1436        assert_eq!(open, vec![3]);
1437        assert_eq!(
1438            store.session(1).unwrap().outcome,
1439            Some(SessionOutcome::Aborted)
1440        );
1441        // and the index now forbids a second open row
1442        let second = store.conn.execute(
1443            "INSERT INTO sessions (task_id, agent, started_at) VALUES (1, 'b', datetime('now'))",
1444            [],
1445        );
1446        assert!(second.is_err());
1447    }
1448
1449    /// Migration 0008 must backfill exactly the tasks the derived redispatch
1450    /// flag used to mark — `ready` with a most recent session ended
1451    /// `failed`/`capped` — into `stalled`, leaving every other shape alone,
1452    /// and must carry 0007's `human` column through the table rebuild.
1453    #[test]
1454    fn migration_0008_backfills_flagged_ready_tasks_to_stalled() {
1455        let conn = Connection::open_in_memory().unwrap();
1456        for sql in &MIGRATIONS[..7] {
1457            conn.execute_batch(sql).unwrap();
1458        }
1459        conn.pragma_update(None, "user_version", 7).unwrap();
1460        conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
1461            .unwrap();
1462        // 1: ready, last session failed          -> stalled
1463        // 2: ready, last session capped          -> stalled
1464        // 3: ready, last session aborted         -> stays ready
1465        // 4: ready, failed session then aborted  -> stays ready (latest wins)
1466        // 5: ready, no sessions                  -> stays ready
1467        // 6: running, last session failed        -> stays running
1468        conn.execute(
1469            "INSERT INTO tasks (project_id, title, state, state_since, created_at)
1470             VALUES (1, 't1', 'ready', datetime('now'), datetime('now')),
1471                    (1, 't2', 'ready', datetime('now'), datetime('now')),
1472                    (1, 't3', 'ready', datetime('now'), datetime('now')),
1473                    (1, 't4', 'ready', datetime('now'), datetime('now')),
1474                    (1, 't5', 'ready', datetime('now'), datetime('now')),
1475                    (1, 't6', 'running', datetime('now'), datetime('now'))",
1476            [],
1477        )
1478        .unwrap();
1479        conn.execute(
1480            "INSERT INTO sessions (task_id, agent, started_at, ended_at, outcome)
1481             VALUES (1, 'a', datetime('now'), datetime('now'), 'failed'),
1482                    (2, 'a', datetime('now'), datetime('now'), 'capped'),
1483                    (3, 'a', datetime('now'), datetime('now'), 'aborted'),
1484                    (4, 'a', datetime('now'), datetime('now'), 'failed'),
1485                    (4, 'a', datetime('now'), datetime('now'), 'aborted'),
1486                    (6, 'a', datetime('now'), datetime('now'), 'failed')",
1487            [],
1488        )
1489        .unwrap();
1490        conn.execute("UPDATE tasks SET human = 1 WHERE id = 5", [])
1491            .unwrap();
1492
1493        let store = Store::from_connection(conn).unwrap();
1494        assert_eq!(store.task(1).unwrap().state, TaskState::Stalled);
1495        assert_eq!(store.task(2).unwrap().state, TaskState::Stalled);
1496        assert_eq!(store.task(3).unwrap().state, TaskState::Ready);
1497        assert_eq!(store.task(4).unwrap().state, TaskState::Ready);
1498        assert_eq!(store.task(5).unwrap().state, TaskState::Ready);
1499        assert_eq!(store.task(6).unwrap().state, TaskState::Running);
1500        // the rebuild carries the human flag and its CHECK across
1501        assert!(store.task(5).unwrap().human);
1502        assert!(!store.task(1).unwrap().human);
1503        let junk = store
1504            .conn
1505            .execute("UPDATE tasks SET human = 2 WHERE id = 5", []);
1506        assert!(junk.is_err(), "the CHECK must reject values outside 0/1");
1507    }
1508
1509    /// Migration 0010 must extend the state CHECK to admit 'waiting' while
1510    /// carrying every existing task through the table rebuild untouched.
1511    #[test]
1512    fn migration_0010_admits_waiting_and_preserves_existing_tasks() {
1513        let conn = Connection::open_in_memory().unwrap();
1514        for sql in &MIGRATIONS[..9] {
1515            conn.execute_batch(sql).unwrap();
1516        }
1517        conn.pragma_update(None, "user_version", 9).unwrap();
1518        conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
1519            .unwrap();
1520        conn.execute(
1521            "INSERT INTO tasks (project_id, title, state, agent, pr_url, branch, human,
1522                                state_since, created_at)
1523             VALUES (1, 'in review', 'review', 'claude', 'https://x/pull/1', 'feat/x', 1,
1524                     datetime('now'), datetime('now'))",
1525            [],
1526        )
1527        .unwrap();
1528
1529        let store = Store::from_connection(conn).unwrap();
1530        // the pre-existing row survives the rebuild with every column intact
1531        let task = store.task(1).unwrap();
1532        assert_eq!(task.state, TaskState::Review);
1533        assert_eq!(task.pr_url.as_deref(), Some("https://x/pull/1"));
1534        assert_eq!(task.branch.as_deref(), Some("feat/x"));
1535        assert!(task.human);
1536
1537        // the widened CHECK now admits 'waiting' and still rejects junk
1538        assert!(
1539            store
1540                .conn
1541                .execute("UPDATE tasks SET state = 'waiting' WHERE id = 1", [])
1542                .is_ok()
1543        );
1544        assert!(
1545            store
1546                .conn
1547                .execute("UPDATE tasks SET state = 'bogus' WHERE id = 1", [])
1548                .is_err()
1549        );
1550
1551        let version: i64 = store
1552            .conn
1553            .query_row("PRAGMA user_version", [], |r| r.get(0))
1554            .unwrap();
1555        assert_eq!(version, MIGRATIONS.len() as i64);
1556    }
1557
1558    /// A project + running task to hang sessions off of.
1559    fn task_fixture(s: &mut Store) -> i64 {
1560        s.conn
1561            .execute(
1562                "INSERT OR IGNORE INTO projects (name, path) VALUES ('voro', '/tmp/voro')",
1563                [],
1564            )
1565            .unwrap();
1566        let project_id: i64 = s
1567            .conn
1568            .query_row("SELECT id FROM projects WHERE name = 'voro'", [], |r| {
1569                r.get(0)
1570            })
1571            .unwrap();
1572        s.conn
1573            .execute(
1574                "INSERT INTO tasks (project_id, title, state, state_since, created_at)
1575                 VALUES (?1, 'run me', 'running', datetime('now'), datetime('now'))",
1576                params![project_id],
1577            )
1578            .unwrap();
1579        s.conn.last_insert_rowid()
1580    }
1581
1582    /// `events_for` must return the audit trail oldest-first (newest last),
1583    /// since that's the order the history popup renders it in.
1584    #[test]
1585    fn events_for_orders_oldest_first() {
1586        use crate::transition::Action;
1587
1588        let mut s = Store::open_in_memory().unwrap();
1589        let p = s.create_project("voro", "/tmp/voro").unwrap();
1590        let task = s
1591            .create_task(NewTask {
1592                project_id: p.id,
1593                title: "trace me".into(),
1594                body: String::new(),
1595                priority: Priority::P2,
1596                state: TaskState::Ready,
1597                agent: None,
1598                human: false,
1599            })
1600            .unwrap();
1601        s.apply(task.id, Action::Start).unwrap();
1602        s.apply(task.id, Action::Ask("A or B?".into())).unwrap();
1603        s.apply(task.id, Action::Answer("B".into())).unwrap();
1604
1605        let events = s.events_for(task.id).unwrap();
1606        let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
1607        assert_eq!(
1608            kinds,
1609            vec![
1610                "created",
1611                "transition",
1612                "transition",
1613                "transition",
1614                "answer"
1615            ]
1616        );
1617        // ids strictly increase with insertion order
1618        assert!(events.windows(2).all(|w| w[0].id < w[1].id));
1619    }
1620
1621    #[test]
1622    fn latest_summary_returns_the_newest_summary_event() {
1623        use crate::transition::Action;
1624
1625        let mut s = Store::open_in_memory().unwrap();
1626        let p = s.create_project("voro", "/tmp/voro").unwrap();
1627        let t = s
1628            .create_task(NewTask {
1629                project_id: p.id,
1630                title: "summary me".into(),
1631                body: String::new(),
1632                priority: Priority::P2,
1633                state: TaskState::Ready,
1634                agent: None,
1635                human: false,
1636            })
1637            .unwrap();
1638        assert_eq!(s.latest_summary(t.id).unwrap(), None);
1639
1640        s.apply(t.id, Action::Start).unwrap();
1641        s.apply(t.id, Action::Complete(Some("first pass".into())))
1642            .unwrap();
1643        assert_eq!(
1644            s.latest_summary(t.id).unwrap().as_deref(),
1645            Some("first pass")
1646        );
1647
1648        // a reject-then-redo records a second summary; the newest wins
1649        s.apply(t.id, Action::RejectWork("redo".into())).unwrap();
1650        s.apply(t.id, Action::Complete(Some("second pass".into())))
1651            .unwrap();
1652        assert_eq!(
1653            s.latest_summary(t.id).unwrap().as_deref(),
1654            Some("second pass")
1655        );
1656    }
1657
1658    #[test]
1659    fn incomplete_report_flag_marks_a_review_task_missing_one_half() {
1660        use crate::transition::Action;
1661
1662        // Helper: a fresh task carried to `review` with the given branch/summary.
1663        fn reviewed(branch: Option<&str>, summary: Option<&str>) -> (Store, i64) {
1664            let mut s = Store::open_in_memory().unwrap();
1665            let p = s.create_project("voro", "/tmp/voro").unwrap();
1666            let t = s
1667                .create_task(NewTask {
1668                    project_id: p.id,
1669                    title: "report me".into(),
1670                    body: String::new(),
1671                    priority: Priority::P2,
1672                    state: TaskState::Ready,
1673                    agent: None,
1674                    human: false,
1675                })
1676                .unwrap();
1677            s.apply(t.id, Action::Start).unwrap();
1678            s.apply(t.id, Action::Complete(summary.map(str::to_string)))
1679                .unwrap();
1680            if let Some(name) = branch {
1681                s.set_branch(t.id, Some(name)).unwrap();
1682            }
1683            (s, t.id)
1684        }
1685
1686        // Branch but no summary — the classic forgotten-summary flake and the
1687        // shape the SessionEnd fallback leaves behind.
1688        let (s, id) = reviewed(Some("feat/x"), None);
1689        assert!(s.incomplete_report_flag(id).unwrap());
1690
1691        // Summary but no branch — the reverse flake.
1692        let (s, id) = reviewed(None, Some("did the thing"));
1693        assert!(s.incomplete_report_flag(id).unwrap());
1694
1695        // Both present — a complete report, not an anomaly.
1696        let (s, id) = reviewed(Some("feat/x"), Some("did the thing"));
1697        assert!(!s.incomplete_report_flag(id).unwrap());
1698
1699        // Neither present — a legitimate no-artifact (e.g. planning) task.
1700        let (s, id) = reviewed(None, None);
1701        assert!(!s.incomplete_report_flag(id).unwrap());
1702    }
1703
1704    #[test]
1705    fn incomplete_report_flag_is_gated_on_review() {
1706        use crate::transition::Action;
1707
1708        // A partial report only counts once the task is in `review`: a running
1709        // task with an intended branch and no summary yet is mid-flight, not a
1710        // finished-but-incomplete report.
1711        let mut s = Store::open_in_memory().unwrap();
1712        let p = s.create_project("voro", "/tmp/voro").unwrap();
1713        let t = s
1714            .create_task(NewTask {
1715                project_id: p.id,
1716                title: "in flight".into(),
1717                body: String::new(),
1718                priority: Priority::P2,
1719                state: TaskState::Ready,
1720                agent: None,
1721                human: false,
1722            })
1723            .unwrap();
1724        s.set_branch(t.id, Some("feat/x")).unwrap();
1725        assert!(!s.incomplete_report_flag(t.id).unwrap(), "ready");
1726
1727        s.apply(t.id, Action::Start).unwrap();
1728        assert!(!s.incomplete_report_flag(t.id).unwrap(), "running");
1729
1730        // Only on reaching review does the missing summary become an anomaly.
1731        s.apply(t.id, Action::Complete(None)).unwrap();
1732        assert!(s.incomplete_report_flag(t.id).unwrap(), "review");
1733
1734        // Accepting past review clears it — no PR is opened from `done`.
1735        s.apply(t.id, Action::Accept).unwrap();
1736        assert!(!s.incomplete_report_flag(t.id).unwrap(), "done");
1737    }
1738
1739    #[test]
1740    fn incomplete_report_flag_is_false_for_a_missing_task() {
1741        let s = Store::open_in_memory().unwrap();
1742        assert!(!s.incomplete_report_flag(999).unwrap());
1743    }
1744
1745    #[test]
1746    fn session_create_end_round_trip() {
1747        let mut s = Store::open_in_memory().unwrap();
1748        let task_id = task_fixture(&mut s);
1749
1750        let opened = s
1751            .create_session(task_id, "claude", Some(4321), Some("/var/log/s.log"))
1752            .unwrap();
1753        assert_eq!(opened.task_id, task_id);
1754        assert_eq!(opened.agent, "claude");
1755        assert_eq!(opened.pid, Some(4321));
1756        assert_eq!(opened.log_path.as_deref(), Some("/var/log/s.log"));
1757        assert!(!opened.started_at.is_empty());
1758        assert!(opened.ended_at.is_none());
1759        assert!(opened.outcome.is_none());
1760
1761        let ended = s.end_session(opened.id, SessionOutcome::Completed).unwrap();
1762        assert_eq!(ended.id, opened.id);
1763        assert!(ended.ended_at.is_some());
1764        assert_eq!(ended.outcome, Some(SessionOutcome::Completed));
1765
1766        assert_eq!(s.session(opened.id).unwrap(), ended);
1767    }
1768
1769    /// `latest_sessions` maps each task to its newest session only, and tasks
1770    /// with no session history stay absent.
1771    #[test]
1772    fn latest_sessions_keeps_only_the_newest_per_task() {
1773        let mut s = Store::open_in_memory().unwrap();
1774        let with_history = task_fixture(&mut s);
1775        let sessionless = task_fixture(&mut s);
1776
1777        let first = s
1778            .create_session(with_history, "claude", None, Some("/var/log/first.log"))
1779            .unwrap();
1780        s.end_session(first.id, SessionOutcome::Failed).unwrap();
1781        let second = s
1782            .create_session(with_history, "codex", None, Some("/var/log/second.log"))
1783            .unwrap();
1784
1785        let latest = s.latest_sessions().unwrap();
1786        assert_eq!(latest.len(), 1);
1787        assert_eq!(latest[&with_history].id, second.id);
1788        assert_eq!(
1789            latest[&with_history].log_path.as_deref(),
1790            Some("/var/log/second.log")
1791        );
1792        assert!(!latest.contains_key(&sessionless));
1793    }
1794
1795    #[test]
1796    fn session_optional_fields_are_null() {
1797        let mut s = Store::open_in_memory().unwrap();
1798        let task_id = task_fixture(&mut s);
1799        let opened = s.create_session(task_id, "codex", None, None).unwrap();
1800        assert!(opened.pid.is_none());
1801        assert!(opened.session_ref.is_none());
1802        assert!(opened.log_path.is_none());
1803    }
1804
1805    #[test]
1806    fn set_session_ref_records_and_rejects_unknown_ids() {
1807        let mut s = Store::open_in_memory().unwrap();
1808        let task_id = task_fixture(&mut s);
1809        let opened = s.create_session(task_id, "claude", None, None).unwrap();
1810        assert!(opened.session_ref.is_none());
1811
1812        let updated = s
1813            .set_session_ref(opened.id, "3f6c0e6e-1111-2222-3333-444455556666")
1814            .unwrap();
1815        assert_eq!(
1816            updated.session_ref.as_deref(),
1817            Some("3f6c0e6e-1111-2222-3333-444455556666")
1818        );
1819        assert_eq!(s.session(opened.id).unwrap(), updated);
1820
1821        assert!(matches!(
1822            s.set_session_ref(999, "x"),
1823            Err(Error::SessionNotFound(999))
1824        ));
1825    }
1826
1827    #[test]
1828    fn end_session_rejects_unknown_id() {
1829        let mut s = Store::open_in_memory().unwrap();
1830        assert!(matches!(
1831            s.end_session(999, SessionOutcome::Aborted),
1832            Err(Error::SessionNotFound(999))
1833        ));
1834    }
1835
1836    #[test]
1837    fn sessions_for_returns_newest_first() {
1838        let mut s = Store::open_in_memory().unwrap();
1839        let task_id = task_fixture(&mut s);
1840        let first = s.create_session(task_id, "claude", None, None).unwrap();
1841        let second = s.create_session(task_id, "claude", None, None).unwrap();
1842
1843        let sessions = s.sessions_for(task_id).unwrap();
1844        assert_eq!(
1845            sessions.iter().map(|s| s.id).collect::<Vec<_>>(),
1846            vec![second.id, first.id]
1847        );
1848    }
1849
1850    #[test]
1851    fn live_sessions_excludes_ended() {
1852        let mut s = Store::open_in_memory().unwrap();
1853        let task_id = task_fixture(&mut s);
1854        let done = s.create_session(task_id, "claude", None, None).unwrap();
1855        let live = s.create_session(task_id, "claude", None, None).unwrap();
1856        s.end_session(done.id, SessionOutcome::Failed).unwrap();
1857
1858        let ids = s.live_sessions().unwrap();
1859        assert_eq!(ids.iter().map(|s| s.id).collect::<Vec<_>>(), vec![live.id]);
1860    }
1861
1862    #[test]
1863    fn running_rows_join_current_task_fields() {
1864        let mut s = Store::open_in_memory().unwrap();
1865        let task_id = task_fixture(&mut s);
1866        let session = s.create_session(task_id, "claude", None, None).unwrap();
1867
1868        let rows = s.running_rows().unwrap();
1869        assert_eq!(rows.len(), 1);
1870        assert_eq!(rows[0].session_id, Some(session.id));
1871        assert_eq!(rows[0].task_id, task_id);
1872        assert_eq!(rows[0].task_title, "run me");
1873        assert_eq!(rows[0].task_state, TaskState::Running);
1874        assert_eq!(rows[0].agent.as_deref(), Some("claude"));
1875        assert!(rows[0].elapsed_secs >= 0);
1876    }
1877
1878    #[test]
1879    fn running_rows_exclude_ended_sessions_and_order_newest_first() {
1880        let mut s = Store::open_in_memory().unwrap();
1881        let task_id = task_fixture(&mut s);
1882        let done = s.create_session(task_id, "claude", None, None).unwrap();
1883        let live = s.create_session(task_id, "codex", None, None).unwrap();
1884        s.end_session(done.id, SessionOutcome::Completed).unwrap();
1885
1886        let rows = s.running_rows().unwrap();
1887        assert_eq!(
1888            rows.iter().map(|r| r.session_id).collect::<Vec<_>>(),
1889            vec![Some(live.id)]
1890        );
1891        assert_eq!(rows[0].agent.as_deref(), Some("codex"));
1892    }
1893
1894    #[test]
1895    fn running_rows_compute_elapsed_from_started_at() {
1896        let mut s = Store::open_in_memory().unwrap();
1897        let task_id = task_fixture(&mut s);
1898        let session = s.create_session(task_id, "claude", None, None).unwrap();
1899        s.conn
1900            .execute(
1901                "UPDATE sessions SET started_at = datetime('now', '-90 seconds') WHERE id = ?1",
1902                params![session.id],
1903            )
1904            .unwrap();
1905
1906        let rows = s.running_rows().unwrap();
1907        assert_eq!(rows.len(), 1);
1908        // allow a couple of seconds of test-execution slack either side
1909        assert!(
1910            (85..=95).contains(&rows[0].elapsed_secs),
1911            "expected ~90s elapsed, got {}",
1912            rows[0].elapsed_secs
1913        );
1914    }
1915
1916    /// A task can be `running` with no live session — started by hand, so no
1917    /// session was ever opened. The running strip must still surface it
1918    /// (DESIGN.md §9), with no session id or agent and elapsed measured from
1919    /// when it entered `running`.
1920    #[test]
1921    fn running_rows_include_running_task_without_live_session() {
1922        let mut s = Store::open_in_memory().unwrap();
1923        let task_id = task_fixture(&mut s);
1924        s.conn
1925            .execute(
1926                "UPDATE tasks SET state_since = datetime('now', '-90 seconds') WHERE id = ?1",
1927                params![task_id],
1928            )
1929            .unwrap();
1930
1931        let rows = s.running_rows().unwrap();
1932        assert_eq!(rows.len(), 1);
1933        assert_eq!(rows[0].session_id, None);
1934        assert_eq!(rows[0].agent, None);
1935        assert_eq!(rows[0].task_id, task_id);
1936        assert_eq!(rows[0].task_state, TaskState::Running);
1937        assert!(
1938            (85..=95).contains(&rows[0].elapsed_secs),
1939            "expected ~90s in running, got {}",
1940            rows[0].elapsed_secs
1941        );
1942    }
1943
1944    /// A running task whose only session has ended is session-less too, so it
1945    /// stays visible rather than dropping off the strip.
1946    #[test]
1947    fn running_rows_include_task_whose_sessions_all_ended() {
1948        let mut s = Store::open_in_memory().unwrap();
1949        let task_id = task_fixture(&mut s);
1950        let done = s.create_session(task_id, "claude", None, None).unwrap();
1951        s.end_session(done.id, SessionOutcome::Failed).unwrap();
1952
1953        let rows = s.running_rows().unwrap();
1954        assert_eq!(rows.len(), 1);
1955        assert_eq!(rows[0].session_id, None);
1956        assert_eq!(rows[0].task_id, task_id);
1957    }
1958
1959    /// Live sessions sort ahead of session-less running tasks, so what an agent
1960    /// is actively driving stays at the top of the strip.
1961    #[test]
1962    fn running_rows_order_live_sessions_before_session_less_tasks() {
1963        let mut s = Store::open_in_memory().unwrap();
1964        let live_task = task_fixture(&mut s);
1965        let session = s.create_session(live_task, "claude", None, None).unwrap();
1966        let orphan_task = task_fixture(&mut s);
1967
1968        let rows = s.running_rows().unwrap();
1969        assert_eq!(rows.len(), 2);
1970        assert_eq!(rows[0].session_id, Some(session.id));
1971        assert_eq!(rows[0].task_id, live_task);
1972        assert_eq!(rows[1].session_id, None);
1973        assert_eq!(rows[1].task_id, orphan_task);
1974    }
1975
1976    /// The strip filters on task state: a task that has left `running` —
1977    /// review, done, rejected — never renders, even a `review` task whose
1978    /// session is deliberately still open (DESIGN.md §8/§9).
1979    #[test]
1980    fn running_rows_exclude_tasks_that_left_running() {
1981        let mut s = Store::open_in_memory().unwrap();
1982        let p = s.create_project("voro", "/tmp/voro").unwrap();
1983        let new = |title: &str| NewTask {
1984            project_id: p.id,
1985            title: title.into(),
1986            body: String::new(),
1987            priority: Priority::P2,
1988            state: TaskState::Ready,
1989            agent: None,
1990            human: false,
1991        };
1992
1993        // review keeps its session open, yet must not appear in the strip
1994        let review = s.create_task(new("review")).unwrap().id;
1995        s.record_dispatch(review, "claude", Some(1), None).unwrap();
1996        s.apply(review, Action::Complete(None)).unwrap();
1997        assert!(s.sessions_for(review).unwrap()[0].ended_at.is_none());
1998
1999        // done and rejected have their sessions closed by the transition
2000        let done = s.create_task(new("done")).unwrap().id;
2001        s.record_dispatch(done, "claude", Some(2), None).unwrap();
2002        s.apply(done, Action::Complete(None)).unwrap();
2003        s.apply(done, Action::Accept).unwrap();
2004
2005        let rejected = s.create_task(new("rejected")).unwrap().id;
2006        s.record_dispatch(rejected, "claude", Some(3), None)
2007            .unwrap();
2008        s.apply(rejected, Action::Abort).unwrap();
2009        s.apply(rejected, Action::Abandon).unwrap();
2010
2011        let running = s.create_task(new("running")).unwrap().id;
2012        s.record_dispatch(running, "claude", Some(4), None).unwrap();
2013
2014        let rows = s.running_rows().unwrap();
2015        assert_eq!(rows.len(), 1);
2016        assert_eq!(rows[0].task_id, running);
2017    }
2018
2019    /// A `done` task left carrying an open session must stay out of the strip
2020    /// purely on its state.
2021    #[test]
2022    fn running_rows_ignore_a_stale_open_session_on_a_closed_task() {
2023        let mut s = Store::open_in_memory().unwrap();
2024        let task_id = task_fixture(&mut s);
2025        s.create_session(task_id, "claude", Some(1), None).unwrap();
2026        s.conn
2027            .execute("UPDATE tasks SET state = 'done' WHERE id = ?1", [task_id])
2028            .unwrap();
2029        assert!(s.running_rows().unwrap().is_empty());
2030    }
2031
2032    #[test]
2033    fn session_outcome_serialises_for_all_variants() {
2034        let mut s = Store::open_in_memory().unwrap();
2035        let task_id = task_fixture(&mut s);
2036        for outcome in SessionOutcome::ALL {
2037            let opened = s.create_session(task_id, "claude", None, None).unwrap();
2038            let ended = s.end_session(opened.id, outcome).unwrap();
2039            assert_eq!(ended.outcome, Some(outcome));
2040        }
2041    }
2042
2043    /// A unique scratch database path under the OS temp dir.
2044    fn scratch_db() -> PathBuf {
2045        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2046        let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2047        std::env::temp_dir().join(format!("voro-dataversion-{}-{n}.db", std::process::id()))
2048    }
2049
2050    #[test]
2051    fn data_version_tracks_external_commits_only() {
2052        let path = scratch_db();
2053        let mut a = Store::open(&path).unwrap();
2054        let mut b = Store::open(&path).unwrap();
2055
2056        let start = a.data_version().unwrap();
2057
2058        // Our own writes must not move the version this connection observes.
2059        a.create_project("alpha", "/tmp/alpha").unwrap();
2060        assert_eq!(a.data_version().unwrap(), start);
2061
2062        // A commit from another connection must move it.
2063        b.create_project("beta", "/tmp/beta").unwrap();
2064        assert_ne!(a.data_version().unwrap(), start);
2065
2066        drop(a);
2067        drop(b);
2068        let _ = std::fs::remove_file(&path);
2069    }
2070
2071    #[test]
2072    fn dep_maps_resolve_both_directions_with_title_state_and_kind() {
2073        use crate::model::{DepKind, DepRef, Priority};
2074        use crate::transition::Action;
2075
2076        let mut s = Store::open_in_memory().unwrap();
2077        let p = s.create_project("voro", "/tmp/voro").unwrap();
2078        let new = |title: &str| NewTask {
2079            project_id: p.id,
2080            title: title.into(),
2081            body: String::new(),
2082            priority: Priority::P2,
2083            state: TaskState::Ready,
2084            agent: None,
2085            human: false,
2086        };
2087        let blocker = s.create_task(new("blocker")).unwrap();
2088        s.apply(blocker.id, Action::Start).unwrap();
2089        s.apply(blocker.id, Action::Complete(None)).unwrap();
2090        s.apply(blocker.id, Action::Accept).unwrap();
2091        let source = s.create_task(new("source")).unwrap();
2092        let task = s.create_task(new("task")).unwrap();
2093        s.add_dep(task.id, blocker.id, DepKind::Blocks).unwrap();
2094        s.add_dep(task.id, source.id, DepKind::DiscoveredFrom)
2095            .unwrap();
2096
2097        // Forward: the task's own deps, every kind, resolved to the
2098        // dependency's title and state.
2099        let deps = s.deps_by_task().unwrap();
2100        assert_eq!(
2101            deps[&task.id],
2102            vec![
2103                DepRef {
2104                    id: blocker.id,
2105                    title: "blocker".into(),
2106                    state: TaskState::Done,
2107                    kind: DepKind::Blocks,
2108                },
2109                DepRef {
2110                    id: source.id,
2111                    title: "source".into(),
2112                    state: TaskState::Ready,
2113                    kind: DepKind::DiscoveredFrom,
2114                },
2115            ]
2116        );
2117        assert!(!deps[&task.id][0].is_open());
2118        assert!(!deps.contains_key(&blocker.id));
2119
2120        // Reverse: keyed by the task depended on, resolving the dependant.
2121        let dependents = s.dependents_by_task().unwrap();
2122        assert_eq!(
2123            dependents[&blocker.id],
2124            vec![DepRef {
2125                id: task.id,
2126                title: "task".into(),
2127                state: TaskState::Ready,
2128                kind: DepKind::Blocks,
2129            }]
2130        );
2131        assert_eq!(dependents[&source.id].len(), 1);
2132        assert_eq!(dependents[&source.id][0].kind, DepKind::DiscoveredFrom);
2133        assert!(!dependents.contains_key(&task.id));
2134    }
2135}