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
25pub struct Store {
28 pub(crate) conn: Connection,
29}
30
31#[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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
739pub(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
760pub(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
776pub(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 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 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 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 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 assert_eq!(named.state, TaskState::Ready);
964
965 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 assert!(s.project(p.id).is_ok());
1347 }
1348 }
1349
1350 #[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 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 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 #[test]
1403 fn migration_0006_dedupes_open_sessions_and_enforces_the_index() {
1404 let conn = Connection::open_in_memory().unwrap();
1405 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 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 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 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 #[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 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 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 #[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 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 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 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 #[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 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 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 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 let (s, id) = reviewed(Some("feat/x"), None);
1689 assert!(s.incomplete_report_flag(id).unwrap());
1690
1691 let (s, id) = reviewed(None, Some("did the thing"));
1693 assert!(s.incomplete_report_flag(id).unwrap());
1694
1695 let (s, id) = reviewed(Some("feat/x"), Some("did the thing"));
1697 assert!(!s.incomplete_report_flag(id).unwrap());
1698
1699 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 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 s.apply(t.id, Action::Complete(None)).unwrap();
1732 assert!(s.incomplete_report_flag(t.id).unwrap(), "review");
1733
1734 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 #[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 assert!(
1910 (85..=95).contains(&rows[0].elapsed_secs),
1911 "expected ~90s elapsed, got {}",
1912 rows[0].elapsed_secs
1913 );
1914 }
1915
1916 #[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 #[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 #[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 #[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 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 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 #[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 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 a.create_project("alpha", "/tmp/alpha").unwrap();
2060 assert_eq!(a.data_version().unwrap(), start);
2061
2062 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 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 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}