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, Doc, Event, LivenessSource, Priority, Project, RefineOutcome, Repo,
9 RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url,
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 include_str!("../migrations/0011_add_archived.sql"),
24 include_str!("../migrations/0012_repos.sql"),
25 include_str!("../migrations/0013_add_deep.sql"),
26 include_str!("../migrations/0014_docs.sql"),
27 include_str!("../migrations/0015_dep_kind_in_key.sql"),
28 include_str!("../migrations/0016_add_refining_state.sql"),
29 include_str!("../migrations/0017_schema_migrations.sql"),
30 include_str!("../migrations/0018_project_viewer.sql"),
31 include_str!("../migrations/0019_session_liveness_source.sql"),
32 include_str!("../migrations/0020_store_meta.sql"),
33];
34
35fn path_is_cargo_target(path: &Path) -> bool {
40 let parts: Vec<_> = path
41 .components()
42 .map(|c| c.as_os_str().to_string_lossy().into_owned())
43 .collect();
44 parts
45 .windows(2)
46 .any(|pair| pair[0] == "target" && (pair[1] == "debug" || pair[1] == "release"))
47}
48
49fn record_in_journal(tx: &Connection, from_version: usize, consent: Option<&str>) -> Result<()> {
54 let found: i64 = tx.query_row(
55 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'",
56 [],
57 |row| row.get(0),
58 )?;
59 if found == 0 {
60 return Ok(());
61 }
62 for idx in 1..=from_version {
63 tx.execute(
64 "INSERT OR IGNORE INTO schema_migrations (idx, sql, applied_at, applied_by)
65 VALUES (?1, NULL, datetime('now'), NULL)",
66 params![idx as i64],
67 )?;
68 }
69 let by = applied_by(consent);
70 for idx in (from_version + 1)..=MIGRATIONS.len() {
71 tx.execute(
72 "INSERT OR REPLACE INTO schema_migrations (idx, sql, applied_at, applied_by)
73 VALUES (?1, ?2, datetime('now'), ?3)",
74 params![idx as i64, MIGRATIONS[idx - 1], by],
75 )?;
76 }
77 Ok(())
78}
79
80fn applied_by(consent: Option<&str>) -> String {
85 let exe = std::env::current_exe()
86 .map(|p| p.display().to_string())
87 .unwrap_or_else(|_| "an unknown executable".to_string());
88 let via = consent.map(|c| format!(", {c}")).unwrap_or_default();
89 format!("voro {} at {exe}{via}", env!("CARGO_PKG_VERSION"))
90}
91
92fn remedy_for_divergence(path: Option<&Path>) -> String {
95 if path.is_some_and(|p| p == Store::dev_db_path()) {
96 format!(
97 "It is the dev store, which is disposable — rebuild it at this build's schema with \
98 `voro seed --force`, or delete {} and it will be reseeded on the next run.",
99 Store::dev_db_path().display()
100 )
101 } else {
102 format!(
103 "Restore the snapshot taken before that migration from {}; failing that, run the \
104 build named above, which is the only one whose schema matches this database.",
105 Store::backup_dir_for(path.unwrap_or(&Store::production_db_path())).display()
106 )
107 }
108}
109
110fn remedy_for_schema_ahead(path: Option<&Path>) -> String {
113 if path.is_some_and(|p| p == Store::dev_db_path()) {
114 format!(
115 "It is the dev store, which is disposable — rebuild it at this build's schema with \
116 `voro seed --force`, or delete {} and it will be reseeded on the next run.",
117 Store::dev_db_path().display()
118 )
119 } else {
120 format!(
121 "Restore a pre-migration snapshot from {}; failing that, run the build that migrated \
122 it — though if that build was never released, doing so entrenches a schema no other \
123 build can open.",
124 Store::backup_dir_for(path.unwrap_or(&Store::production_db_path())).display()
125 )
126 }
127}
128
129pub struct Store {
132 pub(crate) conn: Connection,
133}
134
135impl Store {
138 pub fn truncate_all(&mut self) -> Result<()> {
139 let tx = self.conn.transaction()?;
140 tx.pragma_update(None, "foreign_keys", false)?;
141 for table in [
142 "task_docs",
143 "docs",
144 "deps",
145 "events",
146 "sessions",
147 "tasks",
148 "repos",
149 "projects",
150 ] {
151 tx.execute(&format!("DELETE FROM {table}"), [])?;
152 }
153 tx.execute("DELETE FROM sqlite_sequence", []).ok();
154 tx.commit()?;
155 self.conn.pragma_update(None, "foreign_keys", true)?;
156 Ok(())
157 }
158}
159
160#[derive(Debug, Clone)]
163pub struct NewTask {
164 pub project_id: i64,
165 pub repo_id: Option<i64>,
167 pub title: String,
168 pub body: String,
169 pub priority: Priority,
170 pub state: TaskState,
171 pub agent: Option<String>,
172 pub human: bool,
173 pub deep: bool,
174}
175
176#[derive(Debug, Clone)]
178pub struct TaskEdit {
179 pub title: String,
180 pub body: String,
181 pub priority: Priority,
182 pub agent: Option<String>,
183 pub human: bool,
184 pub deep: bool,
185}
186
187impl Store {
188 pub fn open(path: &Path) -> Result<Store> {
189 Store::open_with_consent(path, None)
190 }
191
192 pub fn open_migrate(path: &Path, consent: &str) -> Result<Store> {
198 Store::open_with_consent(path, Some(consent))
199 }
200
201 fn open_with_consent(path: &Path, consent: Option<&str>) -> Result<Store> {
202 if let Some(dir) = path.parent() {
203 std::fs::create_dir_all(dir)
204 .map_err(|e| Error::Invalid(format!("cannot create {}: {e}", dir.display())))?;
205 }
206 Store::open_at(
207 Connection::open(path)?,
208 path,
209 &Store::production_db_path(),
210 consent,
211 )
212 }
213
214 pub fn open_in_memory() -> Result<Store> {
215 Store::from_connection_at(Connection::open_in_memory()?, None)
216 }
217
218 pub fn data_dir() -> PathBuf {
220 let data_home = std::env::var_os("XDG_DATA_HOME")
221 .map(PathBuf::from)
222 .filter(|p| p.is_absolute())
223 .unwrap_or_else(|| {
224 let home = std::env::var_os("HOME")
225 .map(PathBuf::from)
226 .unwrap_or_default();
227 home.join(".local/share")
228 });
229 data_home.join("voro")
230 }
231
232 pub fn production_db_path() -> PathBuf {
236 Store::data_dir().join("voro.db")
237 }
238
239 pub fn dev_db_path() -> PathBuf {
243 Store::data_dir().join("dev.db")
244 }
245
246 pub fn backup_dir_for(path: &Path) -> PathBuf {
249 path.parent()
250 .filter(|p| !p.as_os_str().is_empty())
251 .unwrap_or(Path::new("."))
252 .join("backups")
253 }
254
255 pub fn is_dev_build() -> bool {
261 std::env::current_exe().is_ok_and(|exe| path_is_cargo_target(&exe))
262 }
263
264 pub fn default_db_path() -> PathBuf {
267 if Store::is_dev_build() {
268 Store::dev_db_path()
269 } else {
270 Store::production_db_path()
271 }
272 }
273
274 #[cfg(test)]
275 fn from_connection(conn: Connection) -> Result<Store> {
276 Store::from_connection_at(conn, None)
277 }
278
279 fn from_connection_at(conn: Connection, path: Option<&Path>) -> Result<Store> {
280 Store::open_at_opt(conn, path, &Store::production_db_path(), None)
281 }
282
283 fn open_at(
284 conn: Connection,
285 path: &Path,
286 production: &Path,
287 consent: Option<&str>,
288 ) -> Result<Store> {
289 Store::open_at_opt(conn, Some(path), production, consent)
290 }
291
292 fn open_at_opt(
293 conn: Connection,
294 path: Option<&Path>,
295 production: &Path,
296 consent: Option<&str>,
297 ) -> Result<Store> {
298 conn.pragma_update(None, "foreign_keys", true)?;
299 let mut store = Store { conn };
300 let version = store.schema_version()?;
301 store.verify_journal(path)?;
303 if version > MIGRATIONS.len() {
304 return Err(Error::SchemaAhead {
305 version,
306 known: MIGRATIONS.len(),
307 remedy: remedy_for_schema_ahead(path),
308 });
309 }
310 if version < MIGRATIONS.len()
311 && let Some(path) = path
312 {
313 if version > 0 && consent.is_none() && store.is_protected(path, production)? {
317 return Err(Error::MigrationsPending {
318 path: path.to_path_buf(),
319 pending: MIGRATIONS.len() - version,
320 version,
321 known: MIGRATIONS.len(),
322 });
323 }
324 store.snapshot(path, version)?;
325 }
326 store.migrate(consent)?;
327 if path == Some(production) {
328 store.mark_protected()?;
329 }
330 Ok(store)
331 }
332
333 fn is_protected(&self, path: &Path, production: &Path) -> Result<bool> {
339 if path == production {
340 return Ok(true);
341 }
342 let has_meta: i64 = self.conn.query_row(
343 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'store_meta'",
344 [],
345 |row| row.get(0),
346 )?;
347 if has_meta == 0 {
348 return Ok(false);
349 }
350 let marked: Option<String> = self
351 .conn
352 .query_row(
353 "SELECT value FROM store_meta WHERE key = 'protected'",
354 [],
355 |row| row.get(0),
356 )
357 .optional()?;
358 Ok(marked.as_deref() == Some("1"))
359 }
360
361 fn mark_protected(&self) -> Result<()> {
364 self.conn.execute(
365 "INSERT OR IGNORE INTO store_meta (key, value) VALUES ('protected', '1')",
366 [],
367 )?;
368 Ok(())
369 }
370
371 fn verify_journal(&self, path: Option<&Path>) -> Result<()> {
377 if !self.has_journal()? {
378 return Ok(());
379 }
380 let mut stmt = self.conn.prepare(
381 "SELECT idx, sql, applied_at, applied_by FROM schema_migrations
382 WHERE sql IS NOT NULL ORDER BY idx",
383 )?;
384 let rows = stmt.query_map([], |row| {
385 Ok((
386 row.get::<_, i64>(0)? as usize,
387 row.get::<_, String>(1)?,
388 row.get::<_, String>(2)?,
389 row.get::<_, Option<String>>(3)?,
390 ))
391 })?;
392 for row in rows {
393 let (idx, applied, applied_at, applied_by) = row?;
394 let Some(carried) = MIGRATIONS.get(idx - 1) else {
396 continue;
397 };
398 if applied != *carried {
399 return Err(Error::SchemaDiverged {
400 idx,
401 applied_at,
402 applied_by: applied_by.unwrap_or_else(|| "an unrecorded build".to_string()),
403 remedy: remedy_for_divergence(path),
404 });
405 }
406 }
407 Ok(())
408 }
409
410 fn has_journal(&self) -> Result<bool> {
411 let found: i64 = self.conn.query_row(
412 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'",
413 [],
414 |row| row.get(0),
415 )?;
416 Ok(found > 0)
417 }
418
419 pub fn schema_version(&self) -> Result<usize> {
423 Ok(self
424 .conn
425 .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))? as usize)
426 }
427
428 fn snapshot(&self, path: &Path, version: usize) -> Result<()> {
433 if version == 0 || !path.exists() {
434 return Ok(());
435 }
436 let stamp: String =
437 self.conn
438 .query_row("SELECT strftime('%Y%m%d-%H%M%S', 'now')", [], |row| {
439 row.get(0)
440 })?;
441 let stem = path
442 .file_stem()
443 .map(|s| s.to_string_lossy().into_owned())
444 .unwrap_or_else(|| "voro".to_string());
445 let dir = Store::backup_dir_for(path);
446 let target = dir.join(format!("{stem}-v{version}-{stamp}.db"));
447 let copied = std::fs::create_dir_all(&dir).and_then(|()| std::fs::copy(path, &target));
448 if let Err(e) = copied {
449 eprintln!(
450 "voro: could not snapshot {} before migrating to schema {}: {e}",
451 path.display(),
452 MIGRATIONS.len()
453 );
454 }
455 Ok(())
456 }
457
458 pub fn data_version(&self) -> Result<i64> {
463 Ok(self
464 .conn
465 .query_row("PRAGMA data_version", [], |r| r.get(0))?)
466 }
467
468 fn migrate(&mut self, consent: Option<&str>) -> Result<()> {
472 self.conn.pragma_update(None, "foreign_keys", false)?;
473 let applied = self.apply_migrations(consent);
474 let restored = self.conn.pragma_update(None, "foreign_keys", true);
475 applied?;
476 restored?;
477 let violations: i64 =
478 self.conn
479 .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
480 r.get(0)
481 })?;
482 if violations > 0 {
483 return Err(Error::Invalid(format!(
484 "{violations} foreign key violation(s) after migration"
485 )));
486 }
487 Ok(())
488 }
489
490 fn apply_migrations(&mut self, consent: Option<&str>) -> Result<()> {
491 let tx = self.conn.transaction()?;
492 let version: usize =
493 tx.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))? as usize;
494 for (i, sql) in MIGRATIONS.iter().enumerate().skip(version) {
495 tx.execute_batch(sql)?;
496 tx.pragma_update(None, "user_version", (i + 1) as i64)?;
497 }
498 record_in_journal(&tx, version, consent)?;
499 tx.commit()?;
500 Ok(())
501 }
502
503 pub fn create_project(&mut self, name: &str, path: &str) -> Result<Project> {
510 let tx = self.conn.transaction()?;
511 tx.execute("INSERT INTO projects (name) VALUES (?1)", params![name])?;
512 let id = tx.last_insert_rowid();
513 tx.execute(
514 "INSERT INTO repos (project_id, name, path, is_default) VALUES (?1, ?2, ?3, 1)",
515 params![id, name, path],
516 )?;
517 tx.commit()?;
518 self.project(id)
519 }
520
521 pub fn project(&self, id: i64) -> Result<Project> {
522 self.conn
523 .query_row(
524 &format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"),
525 [id],
526 project_from_row,
527 )
528 .optional()?
529 .ok_or(Error::ProjectNotFound(id))
530 }
531
532 pub fn projects(&self) -> Result<Vec<Project>> {
533 let mut stmt = self.conn.prepare(&format!(
534 "SELECT {PROJECT_COLUMNS} FROM projects ORDER BY name"
535 ))?;
536 let rows = stmt.query_map([], project_from_row)?;
537 Ok(rows.collect::<rusqlite::Result<_>>()?)
538 }
539
540 pub fn set_weight(&mut self, project_id: i64, weight: i64) -> Result<()> {
541 if !(0..=5).contains(&weight) {
542 return Err(Error::Invalid(format!("weight {weight} out of range 0-5")));
543 }
544 let changed = self.conn.execute(
545 "UPDATE projects SET weight = ?1 WHERE id = ?2",
546 params![weight, project_id],
547 )?;
548 if changed == 0 {
549 return Err(Error::ProjectNotFound(project_id));
550 }
551 Ok(())
552 }
553
554 pub fn set_viewer(&mut self, project_id: i64, viewer: Option<&str>) -> Result<Project> {
558 let viewer = match viewer.map(str::trim) {
559 Some("") => {
560 return Err(Error::Invalid(
561 "viewer name is required — name no viewer to use the default one".into(),
562 ));
563 }
564 named => named,
565 };
566 let changed = self.conn.execute(
567 "UPDATE projects SET viewer = ?1 WHERE id = ?2",
568 params![viewer, project_id],
569 )?;
570 if changed == 0 {
571 return Err(Error::ProjectNotFound(project_id));
572 }
573 self.project(project_id)
574 }
575
576 pub fn set_archived(&mut self, project_id: i64, archived: bool) -> Result<Project> {
582 let project = self.project(project_id)?;
583 if project.archived == archived {
584 return Err(Error::Invalid(format!(
585 "project '{}' is {} archived",
586 project.name,
587 if archived { "already" } else { "not" }
588 )));
589 }
590 self.conn.execute(
591 "UPDATE projects SET archived = ?1 WHERE id = ?2",
592 params![archived, project_id],
593 )?;
594 self.project(project_id)
595 }
596
597 pub fn rename_project(&mut self, project_id: i64, name: &str) -> Result<Project> {
600 let changed = self.conn.execute(
601 "UPDATE projects SET name = ?1 WHERE id = ?2",
602 params![name, project_id],
603 )?;
604 if changed == 0 {
605 return Err(Error::ProjectNotFound(project_id));
606 }
607 self.project(project_id)
608 }
609
610 pub fn set_default_repo_path(&mut self, project_id: i64, path: &str) -> Result<Repo> {
614 let repo = self.default_repo(project_id)?;
615 self.set_repo_path(repo.id, path)
616 }
617
618 pub fn delete_project(&mut self, project_id: i64) -> Result<()> {
623 self.project(project_id)?;
624 let task_count: i64 = self.conn.query_row(
625 "SELECT COUNT(*) FROM tasks WHERE project_id = ?1",
626 [project_id],
627 |r| r.get(0),
628 )?;
629 if task_count > 0 {
630 return Err(Error::ProjectHasTasks {
631 id: project_id,
632 count: task_count,
633 });
634 }
635 let tx = self.conn.transaction()?;
636 tx.execute("DELETE FROM repos WHERE project_id = ?1", [project_id])?;
637 tx.execute("DELETE FROM projects WHERE id = ?1", [project_id])?;
638 tx.commit()?;
639 Ok(())
640 }
641
642 pub fn repos(&self, project_id: i64) -> Result<Vec<Repo>> {
652 let mut stmt = self.conn.prepare(&format!(
653 "SELECT {REPO_COLUMNS} FROM repos WHERE project_id = ?1
654 ORDER BY is_default DESC, name"
655 ))?;
656 let rows = stmt.query_map([project_id], repo_from_row)?;
657 Ok(rows.collect::<rusqlite::Result<_>>()?)
658 }
659
660 pub fn repo(&self, id: i64) -> Result<Repo> {
661 self.conn
662 .query_row(
663 &format!("SELECT {REPO_COLUMNS} FROM repos WHERE id = ?1"),
664 [id],
665 repo_from_row,
666 )
667 .optional()?
668 .ok_or(Error::RepoIdNotFound(id))
669 }
670
671 pub fn repo_by_name(&self, project_id: i64, name: &str) -> Result<Repo> {
674 let repos = self.repos(project_id)?;
675 repos
676 .into_iter()
677 .find(|r| r.name == name)
678 .ok_or_else(|| Error::RepoNotFound {
679 project: self.project(project_id).map(|p| p.name).unwrap_or_default(),
680 name: name.to_string(),
681 known: self.repo_names(project_id),
682 })
683 }
684
685 pub fn default_repo(&self, project_id: i64) -> Result<Repo> {
686 self.conn
687 .query_row(
688 &format!(
689 "SELECT {REPO_COLUMNS} FROM repos WHERE project_id = ?1 AND is_default = 1"
690 ),
691 [project_id],
692 repo_from_row,
693 )
694 .optional()?
695 .ok_or_else(|| match self.project(project_id) {
696 Err(e) => e,
700 Ok(_) => Error::Invalid(format!("project {project_id} has no default repo")),
701 })
702 }
703
704 pub fn repo_for_task(&self, task: &Task) -> Result<Repo> {
709 match task.repo_id {
710 Some(id) => self.repo(id),
711 None => self.default_repo(task.project_id),
712 }
713 }
714
715 pub fn add_repo(&mut self, project_id: i64, name: &str, path: &str) -> Result<Repo> {
719 self.project(project_id)?;
720 if name.trim().is_empty() {
721 return Err(Error::Invalid("a repo name is required".into()));
722 }
723 if self.repos(project_id)?.iter().any(|r| r.name == name) {
724 return Err(Error::Invalid(format!(
725 "project already has a repo named '{name}'"
726 )));
727 }
728 self.conn.execute(
729 "INSERT INTO repos (project_id, name, path, is_default) VALUES (?1, ?2, ?3, 0)",
730 params![project_id, name, path],
731 )?;
732 self.repo(self.conn.last_insert_rowid())
733 }
734
735 pub fn set_repo_path(&mut self, repo_id: i64, path: &str) -> Result<Repo> {
736 let changed = self.conn.execute(
737 "UPDATE repos SET path = ?1 WHERE id = ?2",
738 params![path, repo_id],
739 )?;
740 if changed == 0 {
741 return Err(Error::RepoIdNotFound(repo_id));
742 }
743 self.repo(repo_id)
744 }
745
746 pub fn set_default_repo(&mut self, repo_id: i64) -> Result<Repo> {
750 let repo = self.repo(repo_id)?;
751 let tx = self.conn.transaction()?;
752 tx.execute(
753 "UPDATE repos SET is_default = 0 WHERE project_id = ?1",
754 [repo.project_id],
755 )?;
756 tx.execute("UPDATE repos SET is_default = 1 WHERE id = ?1", [repo_id])?;
757 tx.commit()?;
758 self.repo(repo_id)
759 }
760
761 pub fn delete_repo(&mut self, repo_id: i64) -> Result<()> {
766 let repo = self.repo(repo_id)?;
767 let project = self.project(repo.project_id)?;
768 if self.repos(repo.project_id)?.len() == 1 {
769 return Err(Error::LastRepo {
770 project: project.name,
771 name: repo.name,
772 });
773 }
774 if repo.is_default {
775 return Err(Error::DefaultRepo {
776 project: project.name,
777 name: repo.name,
778 });
779 }
780 let used: i64 = self.conn.query_row(
781 "SELECT COUNT(*) FROM tasks WHERE repo_id = ?1",
782 [repo_id],
783 |r| r.get(0),
784 )?;
785 if used > 0 {
786 return Err(Error::RepoInUse {
787 name: repo.name,
788 count: used,
789 });
790 }
791 self.conn
792 .execute("DELETE FROM repos WHERE id = ?1", [repo_id])?;
793 Ok(())
794 }
795
796 pub fn set_task_repo(&mut self, task_id: i64, repo_id: Option<i64>) -> Result<Task> {
800 let task = self.task(task_id)?;
801 if let Some(id) = repo_id {
802 let repo = self.repo(id)?;
803 if repo.project_id != task.project_id {
804 return Err(Error::Invalid(format!(
805 "repo '{}' belongs to another project",
806 repo.name
807 )));
808 }
809 }
810 self.conn.execute(
811 "UPDATE tasks SET repo_id = ?1 WHERE id = ?2",
812 params![repo_id, task_id],
813 )?;
814 self.task(task_id)
815 }
816
817 fn repo_names(&self, project_id: i64) -> String {
818 self.repos(project_id)
819 .map(|repos| {
820 repos
821 .into_iter()
822 .map(|r| r.name)
823 .collect::<Vec<_>>()
824 .join(", ")
825 })
826 .unwrap_or_default()
827 }
828
829 pub fn create_doc(
844 &mut self,
845 project_id: i64,
846 repo_id: Option<i64>,
847 location: &str,
848 title: Option<&str>,
849 ) -> Result<Doc> {
850 self.project(project_id)?;
851 let (location, repo_id) = self.normalise_location(project_id, repo_id, location)?;
852 if self
853 .docs(project_id)?
854 .iter()
855 .any(|d| d.location == location)
856 {
857 return Err(Error::Invalid(format!(
858 "this project already has a document at '{location}'"
859 )));
860 }
861 self.conn.execute(
862 "INSERT INTO docs (project_id, repo_id, title, location, created_at)
863 VALUES (?1, ?2, ?3, ?4, datetime('now'))",
864 params![project_id, repo_id, title, location],
865 )?;
866 let doc = self.doc(self.conn.last_insert_rowid())?;
867 log_global_event(&self.conn, "doc-added", Some(&doc.location))?;
868 Ok(doc)
869 }
870
871 fn normalise_location(
877 &self,
878 project_id: i64,
879 repo_id: Option<i64>,
880 location: &str,
881 ) -> Result<(String, Option<i64>)> {
882 let location = location.trim();
883 if location.is_empty() {
884 return Err(Error::Invalid("a document path or URL is required".into()));
885 }
886 if let Some(id) = repo_id {
887 let repo = self.repo(id)?;
888 if repo.project_id != project_id {
889 return Err(Error::Invalid(format!(
890 "repo '{}' belongs to another project",
891 repo.name
892 )));
893 }
894 }
895 if location_is_url(location) {
896 if repo_id.is_some() {
897 return Err(Error::Invalid(
898 "a URL resolves on its own — drop --repo, which only picks the checkout a \
899 relative path is read from"
900 .into(),
901 ));
902 }
903 return Ok((location.to_string(), None));
904 }
905 if !Path::new(location).is_absolute() {
906 return Ok((location.to_string(), repo_id));
907 }
908 let mut repos = match repo_id {
912 Some(id) => vec![self.repo(id)?],
913 None => self.repos(project_id)?,
914 };
915 repos.sort_by_key(|r| std::cmp::Reverse(r.path.len()));
916 for repo in &repos {
917 if let Ok(rel) = Path::new(location).strip_prefix(&repo.path) {
918 return Ok((rel.to_string_lossy().into_owned(), Some(repo.id)));
919 }
920 }
921 match repo_id {
922 Some(id) => Err(Error::Invalid(format!(
925 "'{location}' is not inside repo '{}' ({})",
926 self.repo(id)?.name,
927 self.repo(id)?.path
928 ))),
929 None => Ok((location.to_string(), None)),
932 }
933 }
934
935 pub fn doc(&self, id: i64) -> Result<Doc> {
936 self.conn
937 .query_row(
938 &format!("SELECT {DOC_COLUMNS} FROM docs WHERE id = ?1"),
939 [id],
940 doc_from_row,
941 )
942 .optional()?
943 .ok_or(Error::DocNotFound(id))
944 }
945
946 pub fn docs(&self, project_id: i64) -> Result<Vec<Doc>> {
949 let mut stmt = self.conn.prepare(&format!(
950 "SELECT {DOC_COLUMNS} FROM docs WHERE project_id = ?1 ORDER BY id"
951 ))?;
952 let rows = stmt.query_map([project_id], doc_from_row)?;
953 Ok(rows.collect::<rusqlite::Result<_>>()?)
954 }
955
956 pub fn all_docs(&self) -> Result<Vec<Doc>> {
957 let mut stmt = self
958 .conn
959 .prepare(&format!("SELECT {DOC_COLUMNS} FROM docs ORDER BY id"))?;
960 let rows = stmt.query_map([], doc_from_row)?;
961 Ok(rows.collect::<rusqlite::Result<_>>()?)
962 }
963
964 pub fn docs_at(&self, location: &str) -> Result<Vec<Doc>> {
968 let location = location.trim();
969 Ok(self
970 .all_docs()?
971 .into_iter()
972 .filter(|d| d.location == location)
973 .collect())
974 }
975
976 pub fn resolve_doc(&self, doc: &Doc) -> Result<String> {
980 if doc.is_url() || Path::new(&doc.location).is_absolute() {
981 return Ok(doc.location.clone());
982 }
983 let repo = match doc.repo_id {
984 Some(id) => self.repo(id)?,
985 None => self.default_repo(doc.project_id)?,
986 };
987 Ok(Path::new(&repo.path)
988 .join(&doc.location)
989 .to_string_lossy()
990 .into_owned())
991 }
992
993 pub fn delete_doc(&mut self, doc_id: i64) -> Result<Vec<i64>> {
998 let doc = self.doc(doc_id)?;
999 let linked = self.tasks_for_doc(doc_id)?;
1000 let tx = self.conn.transaction()?;
1001 for task in &linked {
1002 log_event(&tx, task.id, "doc-unlinked", Some(doc.label()))?;
1003 }
1004 tx.execute("DELETE FROM task_docs WHERE doc_id = ?1", [doc_id])?;
1005 tx.execute("DELETE FROM docs WHERE id = ?1", [doc_id])?;
1006 log_global_event(&tx, "doc-removed", Some(&doc.location))?;
1007 tx.commit()?;
1008 Ok(linked.into_iter().map(|t| t.id).collect())
1009 }
1010
1011 pub fn link_doc(&mut self, task_id: i64, doc_id: i64) -> Result<bool> {
1015 self.task(task_id)?;
1016 let doc = self.doc(doc_id)?;
1017 let changed = self.conn.execute(
1018 "INSERT OR IGNORE INTO task_docs (task_id, doc_id) VALUES (?1, ?2)",
1019 params![task_id, doc_id],
1020 )?;
1021 if changed > 0 {
1022 log_event(&self.conn, task_id, "doc-linked", Some(doc.label()))?;
1023 }
1024 Ok(changed > 0)
1025 }
1026
1027 pub fn unlink_doc(&mut self, task_id: i64, doc_id: i64) -> Result<bool> {
1028 self.task(task_id)?;
1029 let doc = self.doc(doc_id)?;
1030 let changed = self.conn.execute(
1031 "DELETE FROM task_docs WHERE task_id = ?1 AND doc_id = ?2",
1032 params![task_id, doc_id],
1033 )?;
1034 if changed > 0 {
1035 log_event(&self.conn, task_id, "doc-unlinked", Some(doc.label()))?;
1036 }
1037 Ok(changed > 0)
1038 }
1039
1040 pub fn set_task_docs(&mut self, task_id: i64, doc_ids: &[i64]) -> Result<Vec<Doc>> {
1044 self.task(task_id)?;
1045 let wanted: Vec<Doc> = doc_ids
1046 .iter()
1047 .map(|id| self.doc(*id))
1048 .collect::<Result<_>>()?;
1049 let current = self.docs_for_task(task_id)?;
1050 let tx = self.conn.transaction()?;
1051 for doc in ¤t {
1052 if !wanted.iter().any(|d| d.id == doc.id) {
1053 tx.execute(
1054 "DELETE FROM task_docs WHERE task_id = ?1 AND doc_id = ?2",
1055 params![task_id, doc.id],
1056 )?;
1057 log_event(&tx, task_id, "doc-unlinked", Some(doc.label()))?;
1058 }
1059 }
1060 for doc in &wanted {
1061 if !current.iter().any(|d| d.id == doc.id) {
1062 tx.execute(
1063 "INSERT INTO task_docs (task_id, doc_id) VALUES (?1, ?2)",
1064 params![task_id, doc.id],
1065 )?;
1066 log_event(&tx, task_id, "doc-linked", Some(doc.label()))?;
1067 }
1068 }
1069 tx.commit()?;
1070 self.docs_for_task(task_id)
1071 }
1072
1073 pub fn docs_for_task(&self, task_id: i64) -> Result<Vec<Doc>> {
1075 let mut stmt = self.conn.prepare(&format!(
1076 "SELECT {} FROM docs d JOIN task_docs td ON td.doc_id = d.id
1077 WHERE td.task_id = ?1 ORDER BY d.id",
1078 prefixed(DOC_COLUMNS, "d")
1079 ))?;
1080 let rows = stmt.query_map([task_id], doc_from_row)?;
1081 Ok(rows.collect::<rusqlite::Result<_>>()?)
1082 }
1083
1084 pub fn docs_by_task(&self) -> Result<HashMap<i64, Vec<Doc>>> {
1088 let mut stmt = self.conn.prepare(&format!(
1089 "SELECT td.task_id, {} FROM docs d JOIN task_docs td ON td.doc_id = d.id
1090 ORDER BY td.task_id, d.id",
1091 prefixed(DOC_COLUMNS, "d")
1092 ))?;
1093 let rows = stmt.query_map([], |row| {
1094 Ok((row.get::<_, i64>(0)?, doc_from_row_at(row, 1)?))
1095 })?;
1096 let mut map: HashMap<i64, Vec<Doc>> = HashMap::new();
1097 for row in rows {
1098 let (task_id, doc) = row?;
1099 map.entry(task_id).or_default().push(doc);
1100 }
1101 Ok(map)
1102 }
1103
1104 pub fn tasks_for_doc(&self, doc_id: i64) -> Result<Vec<Task>> {
1107 let mut stmt = self.conn.prepare(&format!(
1108 "SELECT {} FROM tasks t JOIN task_docs td ON td.task_id = t.id
1109 WHERE td.doc_id = ?1 ORDER BY t.id",
1110 prefixed(TASK_COLUMNS, "t")
1111 ))?;
1112 let rows = stmt.query_map([doc_id], task_from_row)?;
1113 Ok(rows.collect::<rusqlite::Result<_>>()?)
1114 }
1115
1116 pub fn create_task(&mut self, new: NewTask) -> Result<Task> {
1119 if !matches!(
1120 new.state,
1121 TaskState::Proposed | TaskState::Parked | TaskState::Ready
1122 ) {
1123 return Err(Error::Invalid(format!(
1124 "a task cannot be created in state '{}'",
1125 new.state
1126 )));
1127 }
1128 if new.human && new.agent.is_some() {
1129 return Err(Error::Invalid(
1130 "a human-only task cannot carry an agent override — the override only \
1131 selects a dispatch agent, and no agent can execute the task"
1132 .into(),
1133 ));
1134 }
1135 if new.human && new.deep {
1136 return Err(Error::Invalid(
1137 "a human-only task cannot be deep — deep only selects a dispatch model, \
1138 and no agent can execute the task"
1139 .into(),
1140 ));
1141 }
1142 let project = self.project(new.project_id)?;
1145 if project.archived {
1146 return Err(Error::ProjectArchived { name: project.name });
1147 }
1148 if let Some(repo_id) = new.repo_id {
1151 let repo = self.repo(repo_id)?;
1152 if repo.project_id != new.project_id {
1153 return Err(Error::Invalid(format!(
1154 "repo '{}' belongs to another project",
1155 repo.name
1156 )));
1157 }
1158 }
1159 let tx = self.conn.transaction()?;
1160 tx.execute(
1161 "INSERT INTO tasks (project_id, repo_id, title, body, priority, state, agent, human,
1162 deep, state_since, created_at)
1163 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'), datetime('now'))",
1164 params![
1165 new.project_id,
1166 new.repo_id,
1167 new.title,
1168 new.body,
1169 new.priority,
1170 new.state,
1171 new.agent,
1172 new.human,
1173 new.deep
1174 ],
1175 )?;
1176 let id = tx.last_insert_rowid();
1177 log_event(&tx, id, "created", Some(new.state.as_str()))?;
1178 tx.commit()?;
1179 self.task(id)
1180 }
1181
1182 pub fn task(&self, id: i64) -> Result<Task> {
1183 get_task(&self.conn, id)?.ok_or(Error::TaskNotFound(id))
1184 }
1185
1186 pub fn tasks(&self) -> Result<Vec<Task>> {
1187 let mut stmt = self
1188 .conn
1189 .prepare(&format!("SELECT {TASK_COLUMNS} FROM tasks ORDER BY id"))?;
1190 let rows = stmt.query_map([], task_from_row)?;
1191 Ok(rows.collect::<rusqlite::Result<_>>()?)
1192 }
1193
1194 pub fn update_task(&mut self, id: i64, edit: TaskEdit) -> Result<Task> {
1195 let current = self.task(id)?;
1196 if edit.human && edit.agent.is_some() {
1197 return Err(Error::HumanTask {
1198 id,
1199 reason: "an agent override is meaningless on a task no agent can execute — \
1200 clear one or the other"
1201 .into(),
1202 });
1203 }
1204 if edit.human && edit.deep {
1205 return Err(Error::HumanTask {
1206 id,
1207 reason: "the deep flag is meaningless on a task no agent can execute — it \
1208 only selects a dispatch model; clear one or the other"
1209 .into(),
1210 });
1211 }
1212 if edit.human && !current.human {
1216 if matches!(
1217 current.state,
1218 TaskState::NeedsInput | TaskState::Review | TaskState::Stalled
1219 ) {
1220 return Err(Error::HumanTask {
1221 id,
1222 reason: format!(
1223 "a task in state '{}' was executed by an agent; resolve it first",
1224 current.state
1225 ),
1226 });
1227 }
1228 let open_sessions: i64 = self.conn.query_row(
1229 "SELECT COUNT(*) FROM sessions WHERE task_id = ?1 AND ended_at IS NULL",
1230 [id],
1231 |r| r.get(0),
1232 )?;
1233 if open_sessions > 0 {
1234 return Err(Error::HumanTask {
1235 id,
1236 reason: "an agent session is still open on it; complete or abort it first"
1237 .into(),
1238 });
1239 }
1240 }
1241 let tx = self.conn.transaction()?;
1242 tx.execute(
1243 "UPDATE tasks SET title = ?1, body = ?2, priority = ?3, agent = ?4, human = ?5,
1244 deep = ?6
1245 WHERE id = ?7",
1246 params![
1247 edit.title,
1248 edit.body,
1249 edit.priority,
1250 edit.agent,
1251 edit.human,
1252 edit.deep,
1253 id
1254 ],
1255 )?;
1256 if edit.body != current.body && !current.body.is_empty() {
1261 log_event(&tx, id, "body", Some(¤t.body))?;
1262 }
1263 tx.commit()?;
1264 self.task(id)
1265 }
1266
1267 pub fn set_priority(&mut self, id: i64, priority: Priority) -> Result<Task> {
1271 let changed = self.conn.execute(
1272 "UPDATE tasks SET priority = ?1 WHERE id = ?2",
1273 params![priority, id],
1274 )?;
1275 if changed == 0 {
1276 return Err(Error::TaskNotFound(id));
1277 }
1278 log_event(&self.conn, id, "priority", Some(&priority.to_string()))?;
1279 self.task(id)
1280 }
1281
1282 pub fn set_deep(&mut self, id: i64, deep: bool) -> Result<Task> {
1289 let task = self.task(id)?;
1290 if deep && task.human {
1291 return Err(Error::HumanTask {
1292 id,
1293 reason: "the deep flag only selects a dispatch model, and no agent can \
1294 execute the task"
1295 .into(),
1296 });
1297 }
1298 self.conn.execute(
1299 "UPDATE tasks SET deep = ?1 WHERE id = ?2",
1300 params![deep, id],
1301 )?;
1302 log_event(
1303 &self.conn,
1304 id,
1305 "deep",
1306 Some(if deep { "set" } else { "cleared" }),
1307 )?;
1308 self.task(id)
1309 }
1310
1311 pub fn set_pr(&mut self, id: i64, pr_url: Option<&str>) -> Result<Task> {
1315 let changed = self.conn.execute(
1316 "UPDATE tasks SET pr_url = ?1 WHERE id = ?2",
1317 params![pr_url, id],
1318 )?;
1319 if changed == 0 {
1320 return Err(Error::TaskNotFound(id));
1321 }
1322 log_event(&self.conn, id, "pr", pr_url.or(Some("cleared")))?;
1323 self.task(id)
1324 }
1325
1326 pub fn set_branch(&mut self, id: i64, branch: Option<&str>) -> Result<Task> {
1331 let changed = self.conn.execute(
1332 "UPDATE tasks SET branch = ?1 WHERE id = ?2",
1333 params![branch, id],
1334 )?;
1335 if changed == 0 {
1336 return Err(Error::TaskNotFound(id));
1337 }
1338 log_event(&self.conn, id, "branch", branch.or(Some("cleared")))?;
1339 self.task(id)
1340 }
1341
1342 pub fn set_summary(&mut self, id: i64, summary: &str) -> Result<Task> {
1351 if summary.trim().is_empty() {
1352 return Err(Error::Invalid("a summary is required".into()));
1353 }
1354 let task = self.task(id)?;
1355 if !matches!(task.state, TaskState::Running | TaskState::Review) {
1356 return Err(Error::Invalid(format!(
1357 "a summary can only be set on a running or review task; task {} is {}",
1358 id, task.state
1359 )));
1360 }
1361 log_event(&self.conn, id, "summary", Some(summary.trim()))?;
1362 self.task(id)
1363 }
1364
1365 pub fn latest_refine_outcome(&self, task_id: i64) -> Result<Option<RefineOutcome>> {
1372 let detail: Option<String> = self
1373 .conn
1374 .query_row(
1375 "SELECT detail FROM events WHERE task_id = ?1 AND kind = 'refine'
1376 ORDER BY id DESC LIMIT 1",
1377 [task_id],
1378 |r| r.get::<_, Option<String>>(0),
1379 )
1380 .optional()?
1381 .flatten();
1382 detail.map(|d| RefineOutcome::parse(&d)).transpose()
1383 }
1384
1385 pub fn refined_flag(&self, task_id: i64) -> Result<bool> {
1391 self.refine_marker(task_id, RefineOutcome::Applied)
1392 }
1393
1394 pub fn refine_failed_flag(&self, task_id: i64) -> Result<bool> {
1402 self.refine_marker(task_id, RefineOutcome::Failed)
1403 }
1404
1405 pub fn correct_late_refine(&mut self, task_id: i64) -> Result<bool> {
1421 if !self.refine_failed_flag(task_id)? {
1422 return Ok(false);
1423 }
1424 log_event(
1425 &self.conn,
1426 task_id,
1427 "refine",
1428 Some(RefineOutcome::Applied.as_str()),
1429 )?;
1430 Ok(true)
1431 }
1432
1433 fn refine_marker(&self, task_id: i64, wanted: RefineOutcome) -> Result<bool> {
1434 let state: Option<TaskState> = self
1435 .conn
1436 .query_row("SELECT state FROM tasks WHERE id = ?1", [task_id], |r| {
1437 r.get(0)
1438 })
1439 .optional()?;
1440 if state != Some(TaskState::Proposed) {
1441 return Ok(false);
1442 }
1443 Ok(self.latest_refine_outcome(task_id)? == Some(wanted))
1444 }
1445
1446 pub fn latest_refine_note(&self, task_id: i64) -> Result<Option<String>> {
1450 Ok(self
1451 .conn
1452 .query_row(
1453 "SELECT detail FROM events WHERE task_id = ?1 AND kind = 'refined'
1454 ORDER BY id DESC LIMIT 1",
1455 [task_id],
1456 |r| r.get::<_, Option<String>>(0),
1457 )
1458 .optional()?
1459 .flatten())
1460 }
1461
1462 pub fn discovered_from(&self, task_id: i64) -> Result<Option<Task>> {
1469 let parent: Option<i64> = self
1470 .conn
1471 .query_row(
1472 "SELECT depends_on FROM deps
1473 WHERE task_id = ?1 AND kind = 'discovered-from'
1474 ORDER BY depends_on DESC LIMIT 1",
1475 [task_id],
1476 |r| r.get(0),
1477 )
1478 .optional()?;
1479 parent.map(|id| self.task(id)).transpose()
1480 }
1481
1482 pub fn add_dep(&mut self, task_id: i64, depends_on: i64, kind: DepKind) -> Result<()> {
1483 if kind != DepKind::Blocks && task_id == depends_on {
1484 return Err(Error::Invalid("a task cannot depend on itself".into()));
1485 }
1486 let tx = self.conn.transaction()?;
1487 if kind == DepKind::Blocks {
1488 crate::transition::reject_blocks_cycle(&tx, task_id, depends_on)?;
1489 }
1490 let inserted = tx.execute(
1491 "INSERT INTO deps (task_id, depends_on, kind) VALUES (?1, ?2, ?3)
1492 ON CONFLICT (task_id, depends_on, kind) DO NOTHING",
1493 params![task_id, depends_on, kind],
1494 )?;
1495 if inserted == 0 {
1496 return Err(Error::Invalid(format!(
1497 "#{task_id} already has a {kind} dependency on #{depends_on}"
1498 )));
1499 }
1500 if kind == DepKind::Blocks {
1501 crate::transition::reconcile_readiness(&tx, task_id)?;
1502 }
1503 tx.commit()?;
1504 Ok(())
1505 }
1506
1507 pub fn remove_dep(&mut self, task_id: i64, depends_on: i64, kind: DepKind) -> Result<()> {
1511 let tx = self.conn.transaction()?;
1512 let removed = tx.execute(
1513 "DELETE FROM deps WHERE task_id = ?1 AND depends_on = ?2 AND kind = ?3",
1514 params![task_id, depends_on, kind],
1515 )?;
1516 if removed == 0 {
1517 return Err(Error::Invalid(format!(
1518 "#{task_id} has no {kind} dependency on #{depends_on}"
1519 )));
1520 }
1521 if kind == DepKind::Blocks {
1522 crate::transition::reconcile_readiness(&tx, task_id)?;
1523 }
1524 tx.commit()?;
1525 Ok(())
1526 }
1527
1528 pub fn deps_by_task(&self) -> Result<HashMap<i64, Vec<DepRef>>> {
1533 self.dep_refs(
1534 "SELECT d.task_id, t.id, t.title, t.state, d.kind
1535 FROM deps d JOIN tasks t ON t.id = d.depends_on
1536 ORDER BY d.task_id, t.id, d.kind",
1537 )
1538 }
1539
1540 pub fn dependents_by_task(&self) -> Result<HashMap<i64, Vec<DepRef>>> {
1543 self.dep_refs(
1544 "SELECT d.depends_on, t.id, t.title, t.state, d.kind
1545 FROM deps d JOIN tasks t ON t.id = d.task_id
1546 ORDER BY d.depends_on, t.id, d.kind",
1547 )
1548 }
1549
1550 fn dep_refs(&self, sql: &str) -> Result<HashMap<i64, Vec<DepRef>>> {
1551 let mut stmt = self.conn.prepare(sql)?;
1552 let rows = stmt.query_map([], |row| {
1553 let key: i64 = row.get(0)?;
1554 let dep = DepRef {
1555 id: row.get(1)?,
1556 title: row.get(2)?,
1557 state: row.get(3)?,
1558 kind: row.get(4)?,
1559 };
1560 Ok((key, dep))
1561 })?;
1562 let mut map: HashMap<i64, Vec<DepRef>> = HashMap::new();
1563 for row in rows {
1564 let (key, dep) = row?;
1565 map.entry(key).or_default().push(dep);
1566 }
1567 Ok(map)
1568 }
1569
1570 pub fn deps_of(&self, task_id: i64) -> Result<Vec<Dep>> {
1571 let mut stmt = self.conn.prepare(
1572 "SELECT task_id, depends_on, kind FROM deps WHERE task_id = ?1
1573 ORDER BY depends_on, kind",
1574 )?;
1575 let rows = stmt.query_map([task_id], |row| {
1576 Ok(Dep {
1577 task_id: row.get(0)?,
1578 depends_on: row.get(1)?,
1579 kind: row.get(2)?,
1580 })
1581 })?;
1582 Ok(rows.collect::<rusqlite::Result<_>>()?)
1583 }
1584
1585 pub fn create_session(
1592 &mut self,
1593 task_id: i64,
1594 agent: &str,
1595 pid: Option<i64>,
1596 liveness_source: LivenessSource,
1597 log_path: Option<&str>,
1598 ) -> Result<Session> {
1599 let id = insert_session(&self.conn, task_id, agent, pid, liveness_source, log_path)?;
1600 self.session(id)
1601 }
1602
1603 pub fn set_session_ref(&mut self, id: i64, session_ref: &str) -> Result<Session> {
1607 let changed = self.conn.execute(
1608 "UPDATE sessions SET session_ref = ?1 WHERE id = ?2",
1609 params![session_ref, id],
1610 )?;
1611 if changed == 0 {
1612 return Err(Error::SessionNotFound(id));
1613 }
1614 self.session(id)
1615 }
1616
1617 pub fn record_session_send(
1623 &mut self,
1624 id: i64,
1625 session_ref: Option<&str>,
1626 pid: i64,
1627 ) -> Result<Session> {
1628 let changed = self.conn.execute(
1629 "UPDATE sessions SET pid = ?1, session_ref = COALESCE(?2, session_ref)
1630 WHERE id = ?3",
1631 params![pid, session_ref, id],
1632 )?;
1633 if changed == 0 {
1634 return Err(Error::SessionNotFound(id));
1635 }
1636 self.session(id)
1637 }
1638
1639 pub fn end_session(&mut self, id: i64, outcome: SessionOutcome) -> Result<Session> {
1641 if set_session_outcome(&self.conn, id, outcome)? == 0 {
1642 return Err(Error::SessionNotFound(id));
1643 }
1644 self.session(id)
1645 }
1646
1647 pub fn session(&self, id: i64) -> Result<Session> {
1648 self.conn
1649 .query_row(
1650 &format!("SELECT {SESSION_COLUMNS} FROM sessions WHERE id = ?1"),
1651 [id],
1652 session_from_row,
1653 )
1654 .optional()?
1655 .ok_or(Error::SessionNotFound(id))
1656 }
1657
1658 pub fn sessions_for(&self, task_id: i64) -> Result<Vec<Session>> {
1659 let mut stmt = self.conn.prepare(&format!(
1660 "SELECT {SESSION_COLUMNS} FROM sessions WHERE task_id = ?1 ORDER BY id DESC"
1661 ))?;
1662 let rows = stmt.query_map([task_id], session_from_row)?;
1663 Ok(rows.collect::<rusqlite::Result<_>>()?)
1664 }
1665
1666 pub fn latest_sessions(&self) -> Result<std::collections::HashMap<i64, Session>> {
1671 let mut stmt = self.conn.prepare(&format!(
1672 "SELECT {SESSION_COLUMNS} FROM sessions s
1673 WHERE s.id = (SELECT max(id) FROM sessions WHERE task_id = s.task_id)"
1674 ))?;
1675 let rows = stmt.query_map([], session_from_row)?;
1676 rows.map(|r| r.map(|s| (s.task_id, s)))
1677 .collect::<rusqlite::Result<_>>()
1678 .map_err(Into::into)
1679 }
1680
1681 pub fn live_sessions(&self) -> Result<Vec<Session>> {
1683 let mut stmt = self.conn.prepare(&format!(
1684 "SELECT {SESSION_COLUMNS} FROM sessions WHERE ended_at IS NULL ORDER BY id DESC"
1685 ))?;
1686 let rows = stmt.query_map([], session_from_row)?;
1687 Ok(rows.collect::<rusqlite::Result<_>>()?)
1688 }
1689
1690 pub fn incomplete_report_flag(&self, task_id: i64) -> Result<bool> {
1696 let row: Option<(TaskState, Option<String>)> = self
1697 .conn
1698 .query_row(
1699 "SELECT state, branch FROM tasks WHERE id = ?1",
1700 [task_id],
1701 |r| Ok((r.get(0)?, r.get(1)?)),
1702 )
1703 .optional()?;
1704 let Some((state, branch)) = row else {
1705 return Ok(false);
1706 };
1707 if state != TaskState::Review {
1708 return Ok(false);
1709 }
1710 let has_branch = branch.is_some();
1711 let has_summary = self.latest_summary(task_id)?.is_some();
1712 Ok(has_branch && !has_summary)
1713 }
1714
1715 pub fn running_rows(&self) -> Result<Vec<RunningRow>> {
1733 let mut stmt = self.conn.prepare(
1734 "WITH strip AS (
1735 SELECT s.id AS session_id, t.id AS task_id, t.title, t.state,
1736 s.agent, t.pr_url,
1737 CASE WHEN t.state = 'waiting' THEN t.state_since
1738 ELSE COALESCE(s.started_at, t.state_since) END AS since
1739 FROM tasks t
1740 JOIN projects p ON p.id = t.project_id
1741 LEFT JOIN sessions s ON s.task_id = t.id AND s.ended_at IS NULL
1742 WHERE t.state IN ('running','refining','waiting') AND p.archived = 0
1743 )
1744 SELECT session_id, task_id, title, state, agent, pr_url, since,
1745 CAST(strftime('%s', 'now') - strftime('%s', since) AS INTEGER)
1746 FROM strip
1747 ORDER BY (state = 'waiting'), (session_id IS NULL),
1748 session_id DESC, task_id DESC",
1749 )?;
1750 let rows = stmt.query_map([], |row| {
1751 Ok(RunningRow {
1752 session_id: row.get(0)?,
1753 task_id: row.get(1)?,
1754 task_title: row.get(2)?,
1755 task_state: row.get(3)?,
1756 agent: row.get(4)?,
1757 pr_url: row.get(5)?,
1758 started_at: row.get(6)?,
1759 elapsed_secs: row.get(7)?,
1760 })
1761 })?;
1762 Ok(rows.collect::<rusqlite::Result<_>>()?)
1763 }
1764
1765 pub fn latest_summary(&self, task_id: i64) -> Result<Option<String>> {
1774 Ok(self
1775 .conn
1776 .query_row(
1777 "SELECT detail FROM events WHERE task_id = ?1 AND kind = 'summary'
1778 ORDER BY id DESC LIMIT 1",
1779 [task_id],
1780 |r| r.get::<_, Option<String>>(0),
1781 )
1782 .optional()?
1783 .flatten())
1784 }
1785
1786 pub fn record_reviewed(&mut self, id: i64, sha: &str) -> Result<()> {
1793 let sha = sha.trim();
1794 if sha.is_empty() {
1795 return Err(Error::Invalid("a reviewed revision is required".into()));
1796 }
1797 self.task(id)?;
1800 log_event(&self.conn, id, crate::review::REVIEWED_EVENT, Some(sha))
1801 }
1802
1803 pub fn last_reviewed(&self, task_id: i64) -> Result<Option<String>> {
1809 Ok(self
1810 .conn
1811 .query_row(
1812 "SELECT detail FROM events WHERE task_id = ?1 AND kind = ?2
1813 ORDER BY id DESC LIMIT 1",
1814 params![task_id, crate::review::REVIEWED_EVENT],
1815 |r| r.get::<_, Option<String>>(0),
1816 )
1817 .optional()?
1818 .flatten())
1819 }
1820
1821 pub fn events_for(&self, task_id: i64) -> Result<Vec<Event>> {
1822 let mut stmt = self.conn.prepare(
1823 "SELECT id, task_id, at, kind, detail FROM events WHERE task_id = ?1 ORDER BY id",
1824 )?;
1825 let rows = stmt.query_map([task_id], |row| {
1826 Ok(Event {
1827 id: row.get(0)?,
1828 task_id: row.get(1)?,
1829 at: row.get(2)?,
1830 kind: row.get(3)?,
1831 detail: row.get(4)?,
1832 })
1833 })?;
1834 Ok(rows.collect::<rusqlite::Result<_>>()?)
1835 }
1836}
1837
1838pub(crate) const TASK_COLUMNS: &str = "id, project_id, title, body, priority, state, agent, \
1839 question, pr_url, branch, state_since, created_at, \
1840 closed_at, human, repo_id, deep";
1841
1842pub(crate) fn get_task(conn: &Connection, id: i64) -> Result<Option<Task>> {
1843 Ok(conn
1844 .query_row(
1845 &format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?1"),
1846 [id],
1847 task_from_row,
1848 )
1849 .optional()?)
1850}
1851
1852pub(crate) fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Task> {
1853 Ok(Task {
1854 id: row.get(0)?,
1855 project_id: row.get(1)?,
1856 title: row.get(2)?,
1857 body: row.get(3)?,
1858 priority: row.get(4)?,
1859 state: row.get(5)?,
1860 agent: row.get(6)?,
1861 question: row.get(7)?,
1862 pr_url: row.get(8)?,
1863 branch: row.get(9)?,
1864 state_since: row.get(10)?,
1865 created_at: row.get(11)?,
1866 closed_at: row.get(12)?,
1867 human: row.get(13)?,
1868 repo_id: row.get(14)?,
1869 deep: row.get(15)?,
1870 })
1871}
1872
1873pub(crate) const SESSION_COLUMNS: &str = "id, task_id, agent, pid, session_ref, liveness_source, log_path, started_at, ended_at, outcome";
1874
1875pub(crate) fn get_open_session(conn: &Connection, task_id: i64) -> Result<Option<Session>> {
1879 Ok(conn
1880 .query_row(
1881 &format!(
1882 "SELECT {SESSION_COLUMNS} FROM sessions
1883 WHERE task_id = ?1 AND ended_at IS NULL"
1884 ),
1885 [task_id],
1886 session_from_row,
1887 )
1888 .optional()?)
1889}
1890
1891pub(crate) fn get_session(conn: &Connection, id: i64) -> Result<Option<Session>> {
1892 Ok(conn
1893 .query_row(
1894 &format!("SELECT {SESSION_COLUMNS} FROM sessions WHERE id = ?1"),
1895 [id],
1896 session_from_row,
1897 )
1898 .optional()?)
1899}
1900
1901fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Session> {
1902 Ok(Session {
1903 id: row.get(0)?,
1904 task_id: row.get(1)?,
1905 agent: row.get(2)?,
1906 pid: row.get(3)?,
1907 session_ref: row.get(4)?,
1908 liveness_source: row.get(5)?,
1909 log_path: row.get(6)?,
1910 started_at: row.get(7)?,
1911 ended_at: row.get(8)?,
1912 outcome: row.get(9)?,
1913 })
1914}
1915
1916pub(crate) const PROJECT_COLUMNS: &str = "id, name, weight, viewer, archived";
1917
1918fn project_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Project> {
1919 Ok(Project {
1920 id: row.get(0)?,
1921 name: row.get(1)?,
1922 weight: row.get(2)?,
1923 viewer: row.get(3)?,
1924 archived: row.get(4)?,
1925 })
1926}
1927
1928pub(crate) const DOC_COLUMNS: &str = "id, project_id, repo_id, title, location, created_at";
1929
1930fn doc_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Doc> {
1931 doc_from_row_at(row, 0)
1932}
1933
1934fn doc_from_row_at(row: &rusqlite::Row<'_>, at: usize) -> rusqlite::Result<Doc> {
1937 Ok(Doc {
1938 id: row.get(at)?,
1939 project_id: row.get(at + 1)?,
1940 repo_id: row.get(at + 2)?,
1941 title: row.get(at + 3)?,
1942 location: row.get(at + 4)?,
1943 created_at: row.get(at + 5)?,
1944 })
1945}
1946
1947fn prefixed(columns: &str, alias: &str) -> String {
1950 columns
1951 .split(", ")
1952 .map(|c| format!("{alias}.{c}"))
1953 .collect::<Vec<_>>()
1954 .join(", ")
1955}
1956
1957pub(crate) const REPO_COLUMNS: &str = "id, project_id, name, path, is_default";
1958
1959fn repo_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Repo> {
1960 Ok(Repo {
1961 id: row.get(0)?,
1962 project_id: row.get(1)?,
1963 name: row.get(2)?,
1964 path: row.get(3)?,
1965 is_default: row.get(4)?,
1966 })
1967}
1968
1969pub(crate) fn insert_session(
1975 conn: &Connection,
1976 task_id: i64,
1977 agent: &str,
1978 pid: Option<i64>,
1979 liveness_source: LivenessSource,
1980 log_path: Option<&str>,
1981) -> Result<i64> {
1982 close_open_session(conn, task_id, SessionOutcome::Aborted)?;
1983 conn.execute(
1984 "INSERT INTO sessions (task_id, agent, pid, liveness_source, log_path, started_at)
1985 VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'))",
1986 params![task_id, agent, pid, liveness_source, log_path],
1987 )?;
1988 Ok(conn.last_insert_rowid())
1989}
1990
1991pub(crate) fn close_open_session(
1996 conn: &Connection,
1997 task_id: i64,
1998 outcome: SessionOutcome,
1999) -> Result<usize> {
2000 Ok(conn.execute(
2001 "UPDATE sessions SET ended_at = datetime('now'), outcome = ?1
2002 WHERE task_id = ?2 AND ended_at IS NULL",
2003 params![outcome, task_id],
2004 )?)
2005}
2006
2007pub(crate) fn set_session_outcome(
2011 conn: &Connection,
2012 id: i64,
2013 outcome: SessionOutcome,
2014) -> Result<usize> {
2015 Ok(conn.execute(
2016 "UPDATE sessions SET ended_at = datetime('now'), outcome = ?1 WHERE id = ?2",
2017 params![outcome, id],
2018 )?)
2019}
2020
2021pub(crate) fn log_event(
2022 conn: &Connection,
2023 task_id: i64,
2024 kind: &str,
2025 detail: Option<&str>,
2026) -> Result<()> {
2027 conn.execute(
2028 "INSERT INTO events (task_id, at, kind, detail) VALUES (?1, datetime('now'), ?2, ?3)",
2029 params![task_id, kind, detail],
2030 )?;
2031 Ok(())
2032}
2033
2034pub(crate) fn log_global_event(conn: &Connection, kind: &str, detail: Option<&str>) -> Result<()> {
2038 conn.execute(
2039 "INSERT INTO events (task_id, at, kind, detail)
2040 VALUES (NULL, datetime('now'), ?1, ?2)",
2041 params![kind, detail],
2042 )?;
2043 Ok(())
2044}
2045
2046#[cfg(test)]
2047mod schema_guard_tests {
2048 use super::*;
2049
2050 fn scratch(tag: &str) -> PathBuf {
2052 tempfile::Builder::new()
2053 .prefix(&format!("voro-store-{tag}-"))
2054 .tempdir()
2055 .unwrap()
2056 .keep()
2057 }
2058
2059 #[test]
2060 fn a_cargo_build_directory_is_recognised_in_every_profile() {
2061 for exe in [
2062 "/home/u/proj/target/debug/voro",
2063 "/home/u/proj/target/release/voro",
2064 "/home/u/proj/target/debug/deps/voro-1a2b3c",
2065 "/home/u/proj/.claude/worktrees/feature/target/debug/voro",
2066 ] {
2067 assert!(
2068 path_is_cargo_target(Path::new(exe)),
2069 "{exe} should be a dev build"
2070 );
2071 }
2072 for exe in [
2073 "/home/u/.cargo/bin/voro",
2074 "/usr/local/bin/voro",
2075 "/opt/target-practice/voro",
2076 ] {
2077 assert!(
2078 !path_is_cargo_target(Path::new(exe)),
2079 "{exe} should not be a dev build"
2080 );
2081 }
2082 }
2083
2084 #[test]
2085 fn a_database_from_the_future_is_refused_with_a_way_out() {
2086 let dir = scratch("future");
2087 let path = dir.join("voro.db");
2088 Store::open(&path).unwrap();
2089 Connection::open(&path)
2090 .unwrap()
2091 .pragma_update(None, "user_version", (MIGRATIONS.len() + 1) as i64)
2092 .unwrap();
2093
2094 let message = match Store::open(&path) {
2095 Ok(_) => panic!("a store from the future should not open"),
2096 Err(e) => e.to_string(),
2097 };
2098 assert!(message.contains("schema version"), "{message}");
2099 assert!(
2104 message.contains("Restore a pre-migration snapshot"),
2105 "{message}"
2106 );
2107 std::fs::remove_dir_all(&dir).ok();
2108 }
2109
2110 #[test]
2111 fn the_dev_store_is_told_to_reseed_rather_than_reinstall() {
2112 let remedy = remedy_for_schema_ahead(Some(&Store::dev_db_path()));
2113 assert!(remedy.contains("voro seed --force"), "{remedy}");
2114 assert!(!remedy.contains("cargo install"), "{remedy}");
2115 }
2116
2117 #[test]
2118 fn a_migration_snapshots_the_database_beside_it_first() {
2119 let dir = scratch("snapshot");
2120 let path = dir.join("voro.db");
2121 let conn = Connection::open(&path).unwrap();
2123 conn.execute_batch(MIGRATIONS[0]).unwrap();
2124 conn.pragma_update(None, "user_version", 1i64).unwrap();
2125 drop(conn);
2126
2127 Store::open(&path).unwrap();
2128
2129 let backups: Vec<_> = std::fs::read_dir(Store::backup_dir_for(&path))
2130 .unwrap()
2131 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2132 .collect();
2133 assert_eq!(backups.len(), 1, "{backups:?}");
2134 assert!(backups[0].starts_with("voro-v1-"), "{backups:?}");
2135
2136 let saved = Connection::open(Store::backup_dir_for(&path).join(&backups[0])).unwrap();
2139 let version: i64 = saved
2140 .query_row("PRAGMA user_version", [], |r| r.get(0))
2141 .unwrap();
2142 assert_eq!(version, 1);
2143 std::fs::remove_dir_all(&dir).ok();
2144 }
2145
2146 #[test]
2150 fn a_migration_applied_from_a_different_branch_is_refused_at_the_same_version() {
2151 let dir = scratch("diverged");
2152 let path = dir.join("voro.db");
2153 Store::open(&path).unwrap();
2154 Connection::open(&path)
2157 .unwrap()
2158 .execute(
2159 "UPDATE schema_migrations SET sql = ?1, applied_by = ?2 WHERE idx = ?3",
2160 params![
2161 "ALTER TABLE projects RENAME COLUMN review_action TO viewer;",
2162 "voro 0.1.0 at /home/u/.claude/worktrees/project-viewer/target/debug/voro",
2163 MIGRATIONS.len() as i64
2164 ],
2165 )
2166 .unwrap();
2167
2168 let message = match Store::open(&path) {
2169 Ok(_) => panic!("a divergent schema should not open"),
2170 Err(e) => e.to_string(),
2171 };
2172 let version: i64 = Connection::open(&path)
2174 .unwrap()
2175 .query_row("PRAGMA user_version", [], |r| r.get(0))
2176 .unwrap();
2177 assert_eq!(version, MIGRATIONS.len() as i64);
2178 assert!(message.contains("project-viewer"), "{message}");
2179 assert!(message.contains("Restore"), "{message}");
2180 std::fs::remove_dir_all(&dir).ok();
2181 }
2182
2183 #[test]
2184 fn the_journal_records_what_was_applied_and_by_whom() {
2185 let dir = scratch("journal");
2186 let path = dir.join("voro.db");
2187 Store::open(&path).unwrap();
2188
2189 let conn = Connection::open(&path).unwrap();
2190 let rows: i64 = conn
2191 .query_row("SELECT COUNT(*) FROM schema_migrations", [], |r| r.get(0))
2192 .unwrap();
2193 assert_eq!(rows, MIGRATIONS.len() as i64);
2194 let (sql, by): (String, String) = conn
2195 .query_row(
2196 "SELECT sql, applied_by FROM schema_migrations WHERE idx = 1",
2197 [],
2198 |r| Ok((r.get(0)?, r.get(1)?)),
2199 )
2200 .unwrap();
2201 assert_eq!(sql, MIGRATIONS[0]);
2202 assert!(by.starts_with("voro "), "{by}");
2203 std::fs::remove_dir_all(&dir).ok();
2204 }
2205
2206 #[test]
2209 fn pre_journal_history_is_backfilled_unverifiable_and_opens_cleanly() {
2210 let dir = scratch("backfill");
2211 let path = dir.join("voro.db");
2212 let conn = Connection::open(&path).unwrap();
2213 for sql in &MIGRATIONS[..MIGRATIONS.len() - 1] {
2214 conn.execute_batch(sql).unwrap();
2215 }
2216 conn.pragma_update(None, "user_version", (MIGRATIONS.len() - 1) as i64)
2217 .unwrap();
2218 drop(conn);
2219
2220 Store::open(&path).unwrap();
2221 Store::open(&path).unwrap();
2222
2223 let conn = Connection::open(&path).unwrap();
2224 let unverifiable: i64 = conn
2225 .query_row(
2226 "SELECT COUNT(*) FROM schema_migrations WHERE sql IS NULL",
2227 [],
2228 |r| r.get(0),
2229 )
2230 .unwrap();
2231 assert_eq!(unverifiable, (MIGRATIONS.len() - 1) as i64);
2232 std::fs::remove_dir_all(&dir).ok();
2233 }
2234
2235 #[test]
2236 fn a_fresh_database_is_not_snapshotted() {
2237 let dir = scratch("fresh");
2238 let path = dir.join("voro.db");
2239 Store::open(&path).unwrap();
2240 assert!(!Store::backup_dir_for(&path).exists());
2241 std::fs::remove_dir_all(&dir).ok();
2242 }
2243
2244 fn store_at_previous_version(path: &Path) {
2248 let conn = Connection::open(path).unwrap();
2249 for sql in &MIGRATIONS[..MIGRATIONS.len() - 1] {
2250 conn.execute_batch(sql).unwrap();
2251 }
2252 conn.pragma_update(None, "user_version", (MIGRATIONS.len() - 1) as i64)
2253 .unwrap();
2254 }
2255
2256 fn open_as_production(path: &Path, consent: Option<&str>) -> Result<Store> {
2257 Store::open_at(Connection::open(path).unwrap(), path, path, consent)
2258 }
2259
2260 #[test]
2261 fn the_production_store_refuses_to_migrate_without_consent() {
2262 let dir = scratch("gate-refuse");
2263 let path = dir.join("voro.db");
2264 store_at_previous_version(&path);
2265
2266 let message = match open_as_production(&path, None) {
2267 Ok(_) => panic!("a protected store with pending migrations should not open"),
2268 Err(e) => e.to_string(),
2269 };
2270 assert!(message.contains("pending migration"), "{message}");
2271 assert!(message.contains("voro migrate"), "{message}");
2272 let version: i64 = Connection::open(&path)
2274 .unwrap()
2275 .query_row("PRAGMA user_version", [], |r| r.get(0))
2276 .unwrap();
2277 assert_eq!(version, (MIGRATIONS.len() - 1) as i64);
2278 assert!(!Store::backup_dir_for(&path).exists());
2279 std::fs::remove_dir_all(&dir).ok();
2280 }
2281
2282 #[test]
2283 fn consent_migrates_the_production_store_and_is_journalled() {
2284 let dir = scratch("gate-consent");
2285 let path = dir.join("voro.db");
2286 store_at_previous_version(&path);
2287
2288 open_as_production(&path, Some("via voro migrate --yes")).unwrap();
2289
2290 let conn = Connection::open(&path).unwrap();
2291 let version: i64 = conn
2292 .query_row("PRAGMA user_version", [], |r| r.get(0))
2293 .unwrap();
2294 assert_eq!(version, MIGRATIONS.len() as i64);
2295 let by: String = conn
2296 .query_row(
2297 "SELECT applied_by FROM schema_migrations WHERE idx = ?1",
2298 [MIGRATIONS.len() as i64],
2299 |r| r.get(0),
2300 )
2301 .unwrap();
2302 assert!(by.contains("via voro migrate --yes"), "{by}");
2303 assert!(Store::backup_dir_for(&path).exists());
2305 std::fs::remove_dir_all(&dir).ok();
2306 }
2307
2308 #[test]
2311 fn the_protected_marker_travels_with_the_file() {
2312 let dir = scratch("gate-marker");
2313 let path = dir.join("voro.db");
2314 open_as_production(&path, None).unwrap();
2316 let moved = dir.join("restored-copy.db");
2317 std::fs::rename(&path, &moved).unwrap();
2318 Connection::open(&moved)
2321 .unwrap()
2322 .pragma_update(None, "user_version", (MIGRATIONS.len() - 1) as i64)
2323 .unwrap();
2324
2325 assert!(matches!(
2326 Store::open(&moved),
2327 Err(Error::MigrationsPending { .. })
2328 ));
2329 std::fs::remove_dir_all(&dir).ok();
2330 }
2331
2332 #[test]
2333 fn a_fresh_production_store_is_created_silently_and_marked() {
2334 let dir = scratch("gate-fresh");
2335 let path = dir.join("voro.db");
2336 let store = open_as_production(&path, None).unwrap();
2337 let marked: String = store
2338 .conn
2339 .query_row(
2340 "SELECT value FROM store_meta WHERE key = 'protected'",
2341 [],
2342 |r| r.get(0),
2343 )
2344 .unwrap();
2345 assert_eq!(marked, "1");
2346 std::fs::remove_dir_all(&dir).ok();
2347 }
2348
2349 #[test]
2352 fn an_unprotected_store_still_migrates_silently() {
2353 let dir = scratch("gate-scratch");
2354 let path = dir.join("scratch.db");
2355 store_at_previous_version(&path);
2356
2357 let store = Store::open(&path).unwrap();
2358 assert_eq!(store.schema_version().unwrap(), MIGRATIONS.len());
2359 std::fs::remove_dir_all(&dir).ok();
2360 }
2361}
2362
2363#[cfg(test)]
2364mod tests {
2365 use super::*;
2366 use crate::transition::{Action, Triage};
2367
2368 fn new_ready(project_id: i64) -> NewTask {
2369 NewTask {
2370 project_id,
2371 repo_id: None,
2372 title: "t".into(),
2373 body: String::new(),
2374 priority: Priority::P2,
2375 state: TaskState::Ready,
2376 agent: None,
2377 human: false,
2378 deep: false,
2379 }
2380 }
2381
2382 #[test]
2383 fn rename_project_updates_name_and_leaves_task_references_intact() {
2384 let mut s = Store::open_in_memory().unwrap();
2385 let p = s.create_project("old-name", "/tmp/old").unwrap();
2386 let task = s
2387 .create_task(NewTask {
2388 project_id: p.id,
2389 repo_id: None,
2390 title: "t".into(),
2391 body: String::new(),
2392 priority: Priority::P2,
2393 state: TaskState::Ready,
2394 agent: None,
2395 human: false,
2396 deep: false,
2397 })
2398 .unwrap();
2399
2400 let renamed = s.rename_project(p.id, "new-name").unwrap();
2401 assert_eq!(renamed.id, p.id);
2402 assert_eq!(renamed.name, "new-name");
2403
2404 let reloaded = s.task(task.id).unwrap();
2406 assert_eq!(reloaded.project_id, p.id);
2407 assert_eq!(s.project(reloaded.project_id).unwrap().name, "new-name");
2408 }
2409
2410 #[test]
2411 fn project_viewer_defaults_to_none_and_round_trips() {
2412 let mut s = Store::open_in_memory().unwrap();
2413 let p = s.create_project("proj", "/tmp/proj").unwrap();
2414 assert_eq!(p.viewer, None);
2415
2416 let updated = s.set_viewer(p.id, Some("zed")).unwrap();
2417 assert_eq!(updated.viewer.as_deref(), Some("zed"));
2418 assert_eq!(s.project(p.id).unwrap().viewer.as_deref(), Some("zed"));
2419 assert_eq!(s.projects().unwrap()[0].viewer.as_deref(), Some("zed"));
2420
2421 s.set_viewer(p.id, None).unwrap();
2423 assert_eq!(s.project(p.id).unwrap().viewer, None);
2424 let raw: Option<String> = s
2425 .conn
2426 .query_row("SELECT viewer FROM projects WHERE id = ?1", [p.id], |r| {
2427 r.get(0)
2428 })
2429 .unwrap();
2430 assert_eq!(raw, None);
2431
2432 assert!(matches!(
2434 s.set_viewer(p.id, Some(" ")),
2435 Err(Error::Invalid(_))
2436 ));
2437 assert!(matches!(
2438 s.set_viewer(999, Some("zed")),
2439 Err(Error::ProjectNotFound(999))
2440 ));
2441 }
2442
2443 #[test]
2447 fn migration_0018_reads_review_actions_as_viewer_names() {
2448 let conn = Connection::open_in_memory().unwrap();
2449 for sql in &MIGRATIONS[..17] {
2450 conn.execute_batch(sql).unwrap();
2451 }
2452 conn.pragma_update(None, "user_version", 17).unwrap();
2453 conn.execute(
2454 "INSERT INTO projects (name, review_action) VALUES
2455 ('named', 'viewer:zed'),
2456 ('bare-viewer', 'viewer'),
2457 ('auto', 'auto'),
2458 ('pinned-to-pr', 'pr'),
2459 ('unset', NULL)",
2460 [],
2461 )
2462 .unwrap();
2463
2464 let mut store = Store::from_connection(conn).unwrap();
2465 let viewer_of = |store: &mut Store, name: &str| {
2466 store
2467 .projects()
2468 .unwrap()
2469 .into_iter()
2470 .find(|p| p.name == name)
2471 .unwrap()
2472 .viewer
2473 };
2474 assert_eq!(viewer_of(&mut store, "named").as_deref(), Some("zed"));
2475 for named_none in ["bare-viewer", "auto", "pinned-to-pr", "unset"] {
2476 assert_eq!(viewer_of(&mut store, named_none), None, "{named_none}");
2477 }
2478 }
2479
2480 #[test]
2481 fn rename_project_rejects_unknown_id() {
2482 let mut s = Store::open_in_memory().unwrap();
2483 assert!(matches!(
2484 s.rename_project(999, "x"),
2485 Err(Error::ProjectNotFound(999))
2486 ));
2487 }
2488
2489 #[test]
2490 fn set_pr_tracks_clears_and_logs() {
2491 let mut s = Store::open_in_memory().unwrap();
2492 let p = s.create_project("voro", "/tmp/voro").unwrap();
2493 let t = s
2494 .create_task(NewTask {
2495 project_id: p.id,
2496 repo_id: None,
2497 title: "review me".into(),
2498 body: String::new(),
2499 priority: Priority::P2,
2500 state: TaskState::Ready,
2501 agent: None,
2502 human: false,
2503 deep: false,
2504 })
2505 .unwrap();
2506 assert!(s.task(t.id).unwrap().pr_url.is_none());
2507
2508 let tracked = s
2509 .set_pr(t.id, Some("https://github.com/acme/widget/pull/42"))
2510 .unwrap();
2511 assert_eq!(
2512 tracked.pr_url.as_deref(),
2513 Some("https://github.com/acme/widget/pull/42")
2514 );
2515 assert_eq!(tracked.state, TaskState::Ready);
2517
2518 let cleared = s.set_pr(t.id, None).unwrap();
2519 assert!(cleared.pr_url.is_none());
2520
2521 let events = s.events_for(t.id).unwrap();
2522 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
2523 assert_eq!(kinds, vec!["created", "pr", "pr"]);
2524 assert!(matches!(s.set_pr(999, None), Err(Error::TaskNotFound(999))));
2525 }
2526
2527 #[test]
2528 fn set_priority_updates_leaves_state_and_logs() {
2529 let mut s = Store::open_in_memory().unwrap();
2530 let p = s.create_project("voro", "/tmp/voro").unwrap();
2531 let t = s
2532 .create_task(NewTask {
2533 project_id: p.id,
2534 repo_id: None,
2535 title: "reprioritise me".into(),
2536 body: String::new(),
2537 priority: Priority::P2,
2538 state: TaskState::Ready,
2539 agent: None,
2540 human: false,
2541 deep: false,
2542 })
2543 .unwrap();
2544
2545 let raised = s.set_priority(t.id, Priority::P0).unwrap();
2546 assert_eq!(raised.priority, Priority::P0);
2547 assert_eq!(raised.state, TaskState::Ready);
2549
2550 let events = s.events_for(t.id).unwrap();
2551 let last = events.last().unwrap();
2552 assert_eq!(last.kind, "priority");
2553 assert_eq!(last.detail.as_deref(), Some("P0"));
2554
2555 assert!(matches!(
2556 s.set_priority(999, Priority::P1),
2557 Err(Error::TaskNotFound(999))
2558 ));
2559 }
2560
2561 #[test]
2562 fn set_branch_records_clears_and_logs() {
2563 let mut s = Store::open_in_memory().unwrap();
2564 let p = s.create_project("voro", "/tmp/voro").unwrap();
2565 let t = s
2566 .create_task(NewTask {
2567 project_id: p.id,
2568 repo_id: None,
2569 title: "branch me".into(),
2570 body: String::new(),
2571 priority: Priority::P2,
2572 state: TaskState::Ready,
2573 agent: None,
2574 human: false,
2575 deep: false,
2576 })
2577 .unwrap();
2578 assert!(s.task(t.id).unwrap().branch.is_none());
2579
2580 let named = s.set_branch(t.id, Some("feat/parser")).unwrap();
2581 assert_eq!(named.branch.as_deref(), Some("feat/parser"));
2582 assert_eq!(named.state, TaskState::Ready);
2584
2585 let renamed = s.set_branch(t.id, Some("feat/parser-v2")).unwrap();
2587 assert_eq!(renamed.branch.as_deref(), Some("feat/parser-v2"));
2588
2589 let cleared = s.set_branch(t.id, None).unwrap();
2590 assert!(cleared.branch.is_none());
2591
2592 let events = s.events_for(t.id).unwrap();
2593 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
2594 assert_eq!(kinds, vec!["created", "branch", "branch", "branch"]);
2595 assert!(matches!(
2596 s.set_branch(999, None),
2597 Err(Error::TaskNotFound(999))
2598 ));
2599 }
2600
2601 fn human_fixture() -> (Store, i64) {
2605 let mut s = Store::open_in_memory().unwrap();
2606 let p = s.create_project("voro", "/tmp/voro").unwrap();
2607 (s, p.id)
2608 }
2609
2610 fn new_with(project_id: i64, agent: Option<&str>, human: bool) -> NewTask {
2611 NewTask {
2612 project_id,
2613 repo_id: None,
2614 title: "hands-on".into(),
2615 body: String::new(),
2616 priority: Priority::P2,
2617 state: TaskState::Ready,
2618 agent: agent.map(str::to_string),
2619 human,
2620 deep: false,
2621 }
2622 }
2623
2624 fn edit_of(task: &Task, agent: Option<&str>, human: bool) -> TaskEdit {
2625 TaskEdit {
2626 title: task.title.clone(),
2627 body: task.body.clone(),
2628 priority: task.priority,
2629 agent: agent.map(str::to_string),
2630 human,
2631 deep: false,
2632 }
2633 }
2634
2635 #[test]
2636 fn create_task_refuses_a_human_task_with_an_agent_override() {
2637 let (mut s, p) = human_fixture();
2638 let err = s.create_task(new_with(p, Some("codex"), true)).unwrap_err();
2639 assert!(err.to_string().contains("agent override"), "{err}");
2640 assert!(s.tasks().unwrap().is_empty());
2641
2642 assert!(s.create_task(new_with(p, Some("codex"), false)).is_ok());
2643 let human = s.create_task(new_with(p, None, true)).unwrap();
2644 assert!(human.human);
2645 }
2646
2647 #[test]
2651 fn update_task_logs_the_body_it_replaced_and_nothing_else() {
2652 let (mut s, p) = human_fixture();
2653 let task = s.create_task(new_with(p, None, false)).unwrap();
2654
2655 let write = TaskEdit {
2657 body: "the brief".into(),
2658 ..edit_of(&task, None, false)
2659 };
2660 let task = s.update_task(task.id, write).unwrap();
2661 assert!(
2662 !s.events_for(task.id)
2663 .unwrap()
2664 .iter()
2665 .any(|e| e.kind == "body")
2666 );
2667
2668 let retitle = TaskEdit {
2670 title: "renamed".into(),
2671 ..edit_of(&task, None, false)
2672 };
2673 let task = s.update_task(task.id, retitle).unwrap();
2674 assert!(
2675 !s.events_for(task.id)
2676 .unwrap()
2677 .iter()
2678 .any(|e| e.kind == "body")
2679 );
2680
2681 let rewrite = TaskEdit {
2682 body: "a rewrite".into(),
2683 ..edit_of(&task, None, false)
2684 };
2685 let task = s.update_task(task.id, rewrite).unwrap();
2686 assert_eq!(task.body, "a rewrite");
2687 let logged: Vec<String> = s
2688 .events_for(task.id)
2689 .unwrap()
2690 .into_iter()
2691 .filter(|e| e.kind == "body")
2692 .map(|e| e.detail.unwrap_or_default())
2693 .collect();
2694 assert_eq!(logged, vec!["the brief".to_string()]);
2695 }
2696
2697 #[test]
2698 fn update_task_guards_the_agent_human_exclusivity_both_ways() {
2699 let (mut s, p) = human_fixture();
2700
2701 let human = s.create_task(new_with(p, None, true)).unwrap();
2703 let err = s
2704 .update_task(human.id, edit_of(&human, Some("codex"), true))
2705 .unwrap_err();
2706 assert!(matches!(err, Error::HumanTask { id, .. } if id == human.id));
2707
2708 let agented = s.create_task(new_with(p, Some("codex"), false)).unwrap();
2710 let err = s
2711 .update_task(agented.id, edit_of(&agented, Some("codex"), true))
2712 .unwrap_err();
2713 assert!(matches!(err, Error::HumanTask { id, .. } if id == agented.id));
2714
2715 let flipped = s
2717 .update_task(agented.id, edit_of(&agented, None, true))
2718 .unwrap();
2719 assert!(flipped.human);
2720 assert!(flipped.agent.is_none());
2721 }
2722
2723 #[test]
2724 fn update_task_refuses_flagging_human_in_agent_executed_states() {
2725 use crate::transition::Action;
2726
2727 for walk in [TaskState::NeedsInput, TaskState::Review, TaskState::Stalled] {
2730 let (mut s, p) = human_fixture();
2731 let t = s.create_task(new_with(p, None, false)).unwrap();
2732 match walk {
2733 TaskState::NeedsInput => {
2734 s.apply(t.id, Action::Start).unwrap();
2735 s.apply(t.id, Action::Ask("A or B?".into())).unwrap();
2736 }
2737 TaskState::Stalled => {
2738 let (_, session) = s
2739 .record_dispatch(t.id, "claude", Some(1), LivenessSource::Pid, None)
2740 .unwrap();
2741 s.reconcile_session(session.id, false, false).unwrap();
2742 }
2743 _ => {
2744 s.apply(t.id, Action::Start).unwrap();
2745 s.apply(t.id, Action::Complete(None)).unwrap();
2746 }
2747 }
2748 assert_eq!(s.task(t.id).unwrap().state, walk);
2749 let err = s.update_task(t.id, edit_of(&t, None, true)).unwrap_err();
2750 assert!(
2751 matches!(err, Error::HumanTask { id, .. } if id == t.id),
2752 "{walk}: {err}"
2753 );
2754 assert!(!s.task(t.id).unwrap().human);
2755 }
2756 }
2757
2758 #[test]
2759 fn update_task_refuses_flagging_human_while_a_session_is_open() {
2760 use crate::transition::Action;
2761
2762 let (mut s, p) = human_fixture();
2763 let t = s.create_task(new_with(p, None, false)).unwrap();
2764 s.record_dispatch(t.id, "claude", Some(1), LivenessSource::Pid, None)
2765 .unwrap();
2766
2767 let err = s.update_task(t.id, edit_of(&t, None, true)).unwrap_err();
2768 assert!(matches!(err, Error::HumanTask { id, .. } if id == t.id));
2769
2770 s.apply(t.id, Action::Abort).unwrap();
2772 let flipped = s.update_task(t.id, edit_of(&t, None, true)).unwrap();
2773 assert!(flipped.human);
2774
2775 let by_hand = s.create_task(new_with(p, None, false)).unwrap();
2777 s.apply(by_hand.id, Action::Start).unwrap();
2778 assert!(
2779 s.update_task(by_hand.id, edit_of(&by_hand, None, true))
2780 .unwrap()
2781 .human
2782 );
2783 }
2784
2785 fn deep_new(project_id: i64, human: bool, deep: bool) -> NewTask {
2788 NewTask {
2789 deep,
2790 ..new_with(project_id, None, human)
2791 }
2792 }
2793
2794 #[test]
2795 fn set_deep_toggles_the_flag_and_logs_it() {
2796 let (mut s, p) = human_fixture();
2797 let t = s.create_task(deep_new(p, false, false)).unwrap();
2798 assert!(!t.deep);
2799
2800 assert!(s.set_deep(t.id, true).unwrap().deep);
2801 assert!(!s.set_deep(t.id, false).unwrap().deep);
2802
2803 let kinds: Vec<String> = s
2804 .events_for(t.id)
2805 .unwrap()
2806 .into_iter()
2807 .map(|e| e.kind)
2808 .collect();
2809 assert_eq!(kinds, vec!["created", "deep", "deep"]);
2810 assert!(matches!(
2811 s.set_deep(999, true),
2812 Err(Error::TaskNotFound(999))
2813 ));
2814 }
2815
2816 #[test]
2819 fn a_human_task_cannot_be_deep() {
2820 let (mut s, p) = human_fixture();
2821
2822 let err = s.create_task(deep_new(p, true, true)).unwrap_err();
2823 assert!(err.to_string().contains("deep"), "{err}");
2824 assert!(s.tasks().unwrap().is_empty());
2825
2826 let human = s.create_task(deep_new(p, true, false)).unwrap();
2827 let err = s.set_deep(human.id, true).unwrap_err();
2828 assert!(matches!(err, Error::HumanTask { id, .. } if id == human.id));
2829 assert!(!s.task(human.id).unwrap().deep);
2830
2831 let edit = TaskEdit {
2832 deep: true,
2833 ..edit_of(&human, None, true)
2834 };
2835 let err = s.update_task(human.id, edit).unwrap_err();
2836 assert!(matches!(err, Error::HumanTask { id, .. } if id == human.id));
2837
2838 assert!(!s.set_deep(human.id, false).unwrap().deep);
2840 }
2841
2842 #[test]
2845 fn deep_defaults_off_and_is_constrained() {
2846 let (mut s, p) = human_fixture();
2847 let t = s.create_task(deep_new(p, false, false)).unwrap();
2848 assert!(!t.deep);
2849 assert!(
2850 s.conn
2851 .execute("UPDATE tasks SET deep = 2 WHERE id = ?1", [t.id])
2852 .is_err()
2853 );
2854 }
2855
2856 #[test]
2859 fn migration_0007_defaults_existing_tasks_to_dispatchable() {
2860 let conn = Connection::open_in_memory().unwrap();
2861 for sql in &MIGRATIONS[..6] {
2862 conn.execute_batch(sql).unwrap();
2863 }
2864 conn.pragma_update(None, "user_version", 6).unwrap();
2865 conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
2866 .unwrap();
2867 conn.execute(
2868 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
2869 VALUES (1, 'pre-flag', 'ready', datetime('now'), datetime('now'))",
2870 [],
2871 )
2872 .unwrap();
2873
2874 let store = Store::from_connection(conn).unwrap();
2875 assert!(!store.task(1).unwrap().human);
2876
2877 let junk = store
2878 .conn
2879 .execute("UPDATE tasks SET human = 2 WHERE id = 1", []);
2880 assert!(junk.is_err(), "the CHECK must reject values outside 0/1");
2881 }
2882
2883 #[test]
2884 fn set_summary_appends_a_superseding_summary_event() {
2885 use crate::transition::Action;
2886
2887 let mut s = Store::open_in_memory().unwrap();
2888 let p = s.create_project("voro", "/tmp/voro").unwrap();
2889 let t = s
2890 .create_task(NewTask {
2891 project_id: p.id,
2892 repo_id: None,
2893 title: "summarise me".into(),
2894 body: String::new(),
2895 priority: Priority::P2,
2896 state: TaskState::Ready,
2897 agent: None,
2898 human: false,
2899 deep: false,
2900 })
2901 .unwrap();
2902
2903 s.apply(t.id, Action::Start).unwrap();
2905 let updated = s.set_summary(t.id, " early account ").unwrap();
2906 assert_eq!(updated.state, TaskState::Running);
2907 assert_eq!(
2908 s.latest_summary(t.id).unwrap().as_deref(),
2909 Some("early account")
2910 );
2911
2912 s.apply(t.id, Action::Complete(Some("done-time".into())))
2914 .unwrap();
2915 let updated = s.set_summary(t.id, "amended for the PR body").unwrap();
2916 assert_eq!(updated.state, TaskState::Review);
2917 assert_eq!(
2918 s.latest_summary(t.id).unwrap().as_deref(),
2919 Some("amended for the PR body")
2920 );
2921
2922 let events = s.events_for(t.id).unwrap();
2924 let summaries = events.iter().filter(|e| e.kind == "summary").count();
2925 assert_eq!(summaries, 3);
2926 }
2927
2928 #[test]
2929 fn set_summary_clears_the_incomplete_report_flag() {
2930 use crate::transition::Action;
2931
2932 let mut s = Store::open_in_memory().unwrap();
2934 let p = s.create_project("voro", "/tmp/voro").unwrap();
2935 let t = s
2936 .create_task(NewTask {
2937 project_id: p.id,
2938 repo_id: None,
2939 title: "half a report".into(),
2940 body: String::new(),
2941 priority: Priority::P2,
2942 state: TaskState::Ready,
2943 agent: None,
2944 human: false,
2945 deep: false,
2946 })
2947 .unwrap();
2948 s.apply(t.id, Action::Start).unwrap();
2949 s.apply(t.id, Action::Complete(None)).unwrap();
2950 s.set_branch(t.id, Some("feat/x")).unwrap();
2951 assert!(s.incomplete_report_flag(t.id).unwrap());
2952
2953 s.set_summary(t.id, "the missing half").unwrap();
2954 assert!(!s.incomplete_report_flag(t.id).unwrap());
2955 }
2956
2957 #[test]
2958 fn set_summary_is_refused_outside_running_and_review() {
2959 use crate::transition::Action;
2960
2961 let mut s = Store::open_in_memory().unwrap();
2962 let p = s.create_project("voro", "/tmp/voro").unwrap();
2963 let t = s
2964 .create_task(NewTask {
2965 project_id: p.id,
2966 repo_id: None,
2967 title: "not yet".into(),
2968 body: String::new(),
2969 priority: Priority::P2,
2970 state: TaskState::Ready,
2971 agent: None,
2972 human: false,
2973 deep: false,
2974 })
2975 .unwrap();
2976 let err = s.set_summary(t.id, "too early").unwrap_err();
2977 assert!(err.to_string().contains("ready"), "{err}");
2978
2979 s.apply(t.id, Action::Start).unwrap();
2980 s.apply(t.id, Action::Complete(None)).unwrap();
2981 s.apply(t.id, Action::Accept).unwrap();
2982 let err = s.set_summary(t.id, "too late").unwrap_err();
2983 assert!(err.to_string().contains("done"), "{err}");
2984
2985 assert!(s.set_summary(t.id, " ").is_err());
2986 assert!(matches!(
2987 s.set_summary(999, "x"),
2988 Err(Error::TaskNotFound(999))
2989 ));
2990 }
2991
2992 #[test]
2995 fn creating_a_project_creates_its_default_repo() {
2996 let mut s = Store::open_in_memory().unwrap();
2997 let p = s.create_project("voro", "/tmp/voro").unwrap();
2998 let repos = s.repos(p.id).unwrap();
2999 assert_eq!(repos.len(), 1);
3000 assert_eq!(repos[0].name, "voro");
3001 assert_eq!(repos[0].path, "/tmp/voro");
3002 assert!(repos[0].is_default);
3003 assert_eq!(s.default_repo(p.id).unwrap().id, repos[0].id);
3004 }
3005
3006 #[test]
3007 fn added_repos_are_not_default_and_names_are_unique_per_project() {
3008 let mut s = Store::open_in_memory().unwrap();
3009 let p = s.create_project("odm", "/tmp/odm").unwrap();
3010 let oats = s.add_repo(p.id, "oats", "/tmp/oats").unwrap();
3011 assert!(!oats.is_default);
3012 assert!(s.add_repo(p.id, "oats", "/tmp/elsewhere").is_err());
3013 assert!(s.add_repo(p.id, " ", "/tmp/blank").is_err());
3014 let other = s.create_project("voro", "/tmp/voro").unwrap();
3016 assert!(s.add_repo(other.id, "oats", "/tmp/oats").is_ok());
3017 let names: Vec<_> = s.repos(p.id).unwrap().into_iter().map(|r| r.name).collect();
3019 assert_eq!(names, vec!["odm", "oats"]);
3020 }
3021
3022 #[test]
3023 fn only_one_repo_is_ever_default() {
3024 let mut s = Store::open_in_memory().unwrap();
3025 let p = s.create_project("odm", "/tmp/odm").unwrap();
3026 let oats = s.add_repo(p.id, "oats", "/tmp/oats").unwrap();
3027 let promoted = s.set_default_repo(oats.id).unwrap();
3028 assert!(promoted.is_default);
3029 let defaults = s
3030 .repos(p.id)
3031 .unwrap()
3032 .into_iter()
3033 .filter(|r| r.is_default)
3034 .count();
3035 assert_eq!(defaults, 1);
3036 assert_eq!(s.default_repo(p.id).unwrap().name, "oats");
3037 }
3038
3039 #[test]
3040 fn a_projects_last_repo_cannot_be_deleted() {
3041 let mut s = Store::open_in_memory().unwrap();
3042 let p = s.create_project("odm", "/tmp/odm").unwrap();
3043 let only = s.default_repo(p.id).unwrap();
3044 assert!(matches!(
3045 s.delete_repo(only.id),
3046 Err(Error::LastRepo { .. })
3047 ));
3048 }
3049
3050 #[test]
3051 fn the_default_repo_cannot_be_deleted_while_others_remain() {
3052 let mut s = Store::open_in_memory().unwrap();
3053 let p = s.create_project("odm", "/tmp/odm").unwrap();
3054 let oats = s.add_repo(p.id, "oats", "/tmp/oats").unwrap();
3055 let default = s.default_repo(p.id).unwrap();
3056 assert!(matches!(
3057 s.delete_repo(default.id),
3058 Err(Error::DefaultRepo { .. })
3059 ));
3060 s.set_default_repo(oats.id).unwrap();
3062 s.delete_repo(default.id).unwrap();
3063 assert_eq!(s.repos(p.id).unwrap().len(), 1);
3064 }
3065
3066 #[test]
3067 fn a_repo_a_task_names_cannot_be_deleted() {
3068 let mut s = Store::open_in_memory().unwrap();
3069 let p = s.create_project("odm", "/tmp/odm").unwrap();
3070 let oats = s.add_repo(p.id, "oats", "/tmp/oats").unwrap();
3071 let mut new = new_ready(p.id);
3072 new.repo_id = Some(oats.id);
3073 let t = s.create_task(new).unwrap();
3074 assert!(matches!(
3075 s.delete_repo(oats.id),
3076 Err(Error::RepoInUse { count: 1, .. })
3077 ));
3078 s.set_task_repo(t.id, None).unwrap();
3080 s.delete_repo(oats.id).unwrap();
3081 }
3082
3083 #[test]
3084 fn a_task_resolves_its_own_repo_then_the_project_default() {
3085 let mut s = Store::open_in_memory().unwrap();
3086 let p = s.create_project("odm", "/tmp/odm").unwrap();
3087 let oats = s.add_repo(p.id, "oats", "/tmp/oats").unwrap();
3088 let plain = s.create_task(new_ready(p.id)).unwrap();
3089 assert_eq!(s.repo_for_task(&plain).unwrap().path, "/tmp/odm");
3090
3091 let pointed = s.set_task_repo(plain.id, Some(oats.id)).unwrap();
3092 assert_eq!(pointed.repo_id, Some(oats.id));
3093 assert_eq!(s.repo_for_task(&pointed).unwrap().path, "/tmp/oats");
3094
3095 let back = s.set_task_repo(plain.id, None).unwrap();
3097 s.set_default_repo(oats.id).unwrap();
3098 assert_eq!(s.repo_for_task(&back).unwrap().path, "/tmp/oats");
3099 }
3100
3101 #[test]
3102 fn a_task_cannot_name_another_projects_repo() {
3103 let mut s = Store::open_in_memory().unwrap();
3104 let odm = s.create_project("odm", "/tmp/odm").unwrap();
3105 let voro = s.create_project("voro", "/tmp/voro").unwrap();
3106 let foreign = s.default_repo(voro.id).unwrap();
3107 let mut new = new_ready(odm.id);
3108 new.repo_id = Some(foreign.id);
3109 assert!(s.create_task(new).is_err());
3110
3111 let t = s.create_task(new_ready(odm.id)).unwrap();
3112 assert!(s.set_task_repo(t.id, Some(foreign.id)).is_err());
3113 }
3114
3115 #[test]
3116 fn an_unknown_repo_name_errors_listing_the_projects_repos() {
3117 let mut s = Store::open_in_memory().unwrap();
3118 let p = s.create_project("odm", "/tmp/odm").unwrap();
3119 s.add_repo(p.id, "oats", "/tmp/oats").unwrap();
3120 let err = s.repo_by_name(p.id, "nope").unwrap_err().to_string();
3121 assert!(err.contains("odm"), "{err}");
3122 assert!(err.contains("oats"), "{err}");
3123 }
3124
3125 #[test]
3126 fn deleting_a_project_takes_its_repos_with_it() {
3127 let mut s = Store::open_in_memory().unwrap();
3128 let p = s.create_project("odm", "/tmp/odm").unwrap();
3129 s.add_repo(p.id, "oats", "/tmp/oats").unwrap();
3130 s.delete_project(p.id).unwrap();
3131 assert!(s.repos(p.id).unwrap().is_empty());
3132 }
3133
3134 #[test]
3135 fn set_path_updates_the_default_repo() {
3136 let mut s = Store::open_in_memory().unwrap();
3137 let p = s.create_project("proj", "/tmp/old").unwrap();
3138 let updated = s.set_default_repo_path(p.id, "/tmp/new").unwrap();
3139 assert_eq!(updated.path, "/tmp/new");
3140 assert!(updated.is_default);
3141 assert_eq!(s.default_repo(p.id).unwrap().path, "/tmp/new");
3142 }
3143
3144 #[test]
3145 fn set_path_rejects_unknown_id() {
3146 let mut s = Store::open_in_memory().unwrap();
3147 assert!(matches!(
3148 s.set_default_repo_path(999, "/tmp"),
3149 Err(Error::ProjectNotFound(999))
3150 ));
3151 }
3152
3153 #[test]
3154 fn delete_project_removes_a_taskless_project() {
3155 let mut s = Store::open_in_memory().unwrap();
3156 let p = s.create_project("empty", "/tmp/empty").unwrap();
3157 s.delete_project(p.id).unwrap();
3158 assert!(matches!(s.project(p.id), Err(Error::ProjectNotFound(_))));
3159 assert!(s.projects().unwrap().is_empty());
3160 }
3161
3162 #[test]
3163 fn delete_project_rejects_unknown_id() {
3164 let mut s = Store::open_in_memory().unwrap();
3165 assert!(matches!(
3166 s.delete_project(999),
3167 Err(Error::ProjectNotFound(999))
3168 ));
3169 }
3170
3171 fn task_in_state(s: &mut Store, project_id: i64, state: TaskState) -> i64 {
3174 use TaskState::*;
3175 let create = |s: &mut Store, state| {
3176 s.create_task(NewTask {
3177 project_id,
3178 repo_id: None,
3179 title: format!("task in {state}"),
3180 body: String::new(),
3181 priority: Priority::P1,
3182 state,
3183 agent: None,
3184 human: false,
3185 deep: false,
3186 })
3187 .unwrap()
3188 .id
3189 };
3190 match state {
3191 Proposed | Parked | Ready => create(s, state),
3192 Refining => {
3193 let id = create(s, Proposed);
3194 s.record_refine_launch(
3195 id,
3196 "thin body",
3197 "claude",
3198 Some(1),
3199 LivenessSource::Pid,
3200 None,
3201 )
3202 .unwrap();
3203 id
3204 }
3205 Running => {
3206 let id = create(s, Ready);
3207 s.apply(id, Action::Start).unwrap();
3208 id
3209 }
3210 NeedsInput => {
3211 let id = task_in_state(s, project_id, Running);
3212 s.apply(id, Action::Ask("which schema?".into())).unwrap();
3213 id
3214 }
3215 Review => {
3216 let id = task_in_state(s, project_id, Running);
3217 s.apply(id, Action::Complete(None)).unwrap();
3218 id
3219 }
3220 Waiting => {
3221 let id = task_in_state(s, project_id, Review);
3222 s.apply(id, Action::HandOff).unwrap();
3223 id
3224 }
3225 Stalled => {
3226 let id = create(s, Ready);
3227 let (_, session) = s
3228 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
3229 .unwrap();
3230 s.reconcile_session(session.id, false, false).unwrap();
3231 id
3232 }
3233 Done => {
3234 let id = task_in_state(s, project_id, Review);
3235 s.apply(id, Action::Accept).unwrap();
3236 id
3237 }
3238 Rejected => {
3239 let id = create(s, Proposed);
3240 s.apply(id, Action::Triage(Triage::Reject)).unwrap();
3241 id
3242 }
3243 }
3244 }
3245
3246 #[test]
3247 fn delete_project_refuses_with_a_task_in_any_state() {
3248 for state in TaskState::ALL {
3249 let mut s = Store::open_in_memory().unwrap();
3250 let p = s.create_project("proj", "/tmp/proj").unwrap();
3251 task_in_state(&mut s, p.id, state);
3252
3253 let err = s.delete_project(p.id).unwrap_err();
3254 assert!(
3255 matches!(err, Error::ProjectHasTasks { id, count } if id == p.id && count == 1),
3256 "state {state}: expected ProjectHasTasks, got {err}"
3257 );
3258 assert!(s.project(p.id).is_ok());
3260 }
3261 }
3262
3263 #[test]
3266 fn set_archived_round_trips_and_refuses_noops() {
3267 let mut s = Store::open_in_memory().unwrap();
3268 let p = s.create_project("retiring", "/tmp/retiring").unwrap();
3269 assert!(!p.archived);
3270
3271 let archived = s.set_archived(p.id, true).unwrap();
3272 assert!(archived.archived);
3273 assert!(s.projects().unwrap()[0].archived);
3274
3275 let err = s.set_archived(p.id, true).unwrap_err();
3277 assert!(err.to_string().contains("already archived"), "{err}");
3278
3279 let restored = s.set_archived(p.id, false).unwrap();
3280 assert!(!restored.archived);
3281 let err = s.set_archived(p.id, false).unwrap_err();
3282 assert!(err.to_string().contains("not archived"), "{err}");
3283
3284 assert!(matches!(
3285 s.set_archived(999, true),
3286 Err(Error::ProjectNotFound(999))
3287 ));
3288 }
3289
3290 #[test]
3291 fn create_task_refuses_an_archived_project() {
3292 let mut s = Store::open_in_memory().unwrap();
3293 let p = s.create_project("retired", "/tmp/retired").unwrap();
3294 s.set_archived(p.id, true).unwrap();
3295
3296 for state in [TaskState::Proposed, TaskState::Parked, TaskState::Ready] {
3298 let err = s
3299 .create_task(NewTask {
3300 project_id: p.id,
3301 repo_id: None,
3302 title: "too late".into(),
3303 body: String::new(),
3304 priority: Priority::P2,
3305 state,
3306 agent: None,
3307 human: false,
3308 deep: false,
3309 })
3310 .unwrap_err();
3311 assert!(
3312 matches!(&err, Error::ProjectArchived { name } if name == "retired"),
3313 "{state}: {err}"
3314 );
3315 }
3316 assert!(s.tasks().unwrap().is_empty());
3317
3318 s.set_archived(p.id, false).unwrap();
3319 assert!(
3320 s.create_task(NewTask {
3321 project_id: p.id,
3322 repo_id: None,
3323 title: "welcome back".into(),
3324 body: String::new(),
3325 priority: Priority::P2,
3326 state: TaskState::Ready,
3327 agent: None,
3328 human: false,
3329 deep: false,
3330 })
3331 .is_ok()
3332 );
3333 }
3334
3335 #[test]
3336 fn archive_freezes_task_states_and_history_and_unarchive_restores_them() {
3337 let mut s = Store::open_in_memory().unwrap();
3340 let p = s.create_project("retiring", "/tmp/retiring").unwrap();
3341 let tasks: Vec<i64> = TaskState::ALL
3342 .iter()
3343 .map(|state| task_in_state(&mut s, p.id, *state))
3344 .collect();
3345 let before: Vec<Task> = tasks.iter().map(|id| s.task(*id).unwrap()).collect();
3346 let events_before: Vec<usize> = tasks
3347 .iter()
3348 .map(|id| s.events_for(*id).unwrap().len())
3349 .collect();
3350
3351 s.set_archived(p.id, true).unwrap();
3352 let frozen: Vec<Task> = tasks.iter().map(|id| s.task(*id).unwrap()).collect();
3353 assert_eq!(frozen, before);
3354
3355 s.set_archived(p.id, false).unwrap();
3356 let after: Vec<Task> = tasks.iter().map(|id| s.task(*id).unwrap()).collect();
3357 assert_eq!(after, before);
3358 let events_after: Vec<usize> = tasks
3359 .iter()
3360 .map(|id| s.events_for(*id).unwrap().len())
3361 .collect();
3362 assert_eq!(events_after, events_before);
3363 }
3364
3365 #[test]
3366 fn running_rows_exclude_archived_projects() {
3367 let mut s = Store::open_in_memory().unwrap();
3368 let p = s.create_project("retiring", "/tmp/retiring").unwrap();
3369 let id = task_in_state(&mut s, p.id, TaskState::Running);
3370 assert_eq!(s.running_rows().unwrap().len(), 1);
3371
3372 s.set_archived(p.id, true).unwrap();
3374 assert!(s.running_rows().unwrap().is_empty());
3375 assert_eq!(s.task(id).unwrap().state, TaskState::Running);
3376
3377 s.set_archived(p.id, false).unwrap();
3378 assert_eq!(s.running_rows().unwrap()[0].task_id, id);
3379 }
3380
3381 #[test]
3384 fn migration_0011_defaults_existing_projects_to_active() {
3385 let conn = Connection::open_in_memory().unwrap();
3386 for sql in &MIGRATIONS[..10] {
3387 conn.execute_batch(sql).unwrap();
3388 }
3389 conn.pragma_update(None, "user_version", 10).unwrap();
3390 conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
3391 .unwrap();
3392
3393 let store = Store::from_connection(conn).unwrap();
3394 assert!(!store.project(1).unwrap().archived);
3395
3396 let junk = store
3397 .conn
3398 .execute("UPDATE projects SET archived = 2 WHERE id = 1", []);
3399 assert!(junk.is_err(), "the CHECK must reject values outside 0/1");
3400 }
3401
3402 #[test]
3407 fn migration_0012_turns_each_project_path_into_its_default_repo() {
3408 let conn = Connection::open_in_memory().unwrap();
3409 for sql in &MIGRATIONS[..11] {
3410 conn.execute_batch(sql).unwrap();
3411 }
3412 conn.pragma_update(None, "user_version", 11).unwrap();
3413 conn.execute(
3414 "INSERT INTO projects (name, path) VALUES ('odm', '/tmp/odm'), ('voro', '/tmp/voro')",
3415 [],
3416 )
3417 .unwrap();
3418 conn.execute(
3419 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
3420 VALUES (1, 'old', 'ready', datetime('now'), datetime('now'))",
3421 [],
3422 )
3423 .unwrap();
3424
3425 let store = Store::from_connection(conn).unwrap();
3426 for (id, name, path) in [(1, "odm", "/tmp/odm"), (2, "voro", "/tmp/voro")] {
3427 let repos = store.repos(id).unwrap();
3428 assert_eq!(repos.len(), 1);
3429 assert_eq!(repos[0].name, name);
3430 assert_eq!(repos[0].path, path);
3431 assert!(repos[0].is_default);
3432 }
3433 let task = store.task(1).unwrap();
3435 assert_eq!(task.repo_id, None);
3436 assert_eq!(store.repo_for_task(&task).unwrap().path, "/tmp/odm");
3437
3438 assert!(
3440 store
3441 .conn
3442 .query_row("SELECT path FROM projects WHERE id = 1", [], |r| r
3443 .get::<_, String>(0))
3444 .is_err()
3445 );
3446 assert!(
3448 store
3449 .conn
3450 .execute(
3451 "INSERT INTO repos (project_id, name, path, is_default)
3452 VALUES (1, 'second', '/tmp/second', 1)",
3453 [],
3454 )
3455 .is_err()
3456 );
3457 }
3458
3459 #[test]
3463 fn migration_0015_widens_the_dep_key_without_losing_edges() {
3464 let conn = Connection::open_in_memory().unwrap();
3465 for sql in &MIGRATIONS[..14] {
3466 conn.execute_batch(sql).unwrap();
3467 }
3468 conn.pragma_update(None, "user_version", 14).unwrap();
3469 conn.execute("INSERT INTO projects (name) VALUES ('voro')", [])
3470 .unwrap();
3471 conn.execute(
3472 "INSERT INTO repos (project_id, name, path, is_default)
3473 VALUES (1, 'voro', '/tmp/voro', 1)",
3474 [],
3475 )
3476 .unwrap();
3477 conn.execute(
3478 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
3479 VALUES (1, 'source', 'ready', datetime('now'), datetime('now')),
3480 (1, 'spawned', 'ready', datetime('now'), datetime('now'))",
3481 [],
3482 )
3483 .unwrap();
3484 conn.execute(
3485 "INSERT INTO deps (task_id, depends_on, kind) VALUES (2, 1, 'discovered-from')",
3486 [],
3487 )
3488 .unwrap();
3489
3490 let mut store = Store::from_connection(conn).unwrap();
3491 let carried = store.deps_of(2).unwrap();
3492 assert_eq!(carried.len(), 1);
3493 assert_eq!(carried[0].kind, DepKind::DiscoveredFrom);
3494
3495 store.set_blocks_deps(2, &[1]).unwrap();
3496 let kinds: Vec<DepKind> = store.deps_of(2).unwrap().iter().map(|d| d.kind).collect();
3497 assert_eq!(kinds, vec![DepKind::Blocks, DepKind::DiscoveredFrom]);
3498 }
3499
3500 #[test]
3504 fn migration_0002_converts_backlog_rows() {
3505 let conn = Connection::open_in_memory().unwrap();
3506 conn.execute_batch(MIGRATIONS[0]).unwrap();
3507 conn.pragma_update(None, "user_version", 1).unwrap();
3508 conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
3509 .unwrap();
3510 conn.execute(
3511 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
3512 VALUES (1, 'blocker', 'ready', datetime('now'), datetime('now')),
3513 (1, 'waiting', 'backlog', datetime('now'), datetime('now'))",
3514 [],
3515 )
3516 .unwrap();
3517 conn.execute("INSERT INTO deps (task_id, depends_on) VALUES (2, 1)", [])
3518 .unwrap();
3519 conn.execute(
3520 "INSERT INTO events (task_id, at, kind, detail)
3521 VALUES (2, datetime('now'), 'created', 'backlog')",
3522 [],
3523 )
3524 .unwrap();
3525
3526 let store = Store::from_connection(conn).unwrap();
3527 assert_eq!(store.task(2).unwrap().state, TaskState::Parked);
3528 assert_eq!(store.task(1).unwrap().state, TaskState::Ready);
3529 assert_eq!(store.deps_of(2).unwrap().len(), 1);
3530 assert_eq!(
3532 store.events_for(2).unwrap()[0].detail.as_deref(),
3533 Some("backlog")
3534 );
3535 let version: i64 = store
3536 .conn
3537 .query_row("PRAGMA user_version", [], |r| r.get(0))
3538 .unwrap();
3539 assert_eq!(version, MIGRATIONS.len() as i64);
3540 let refs: i64 = store
3542 .conn
3543 .query_row("SELECT COUNT(session_ref) FROM sessions", [], |r| r.get(0))
3544 .unwrap();
3545 assert_eq!(refs, 0);
3546 }
3547
3548 #[test]
3553 fn migration_0006_dedupes_open_sessions_and_enforces_the_index() {
3554 let conn = Connection::open_in_memory().unwrap();
3555 for sql in &MIGRATIONS[..5] {
3557 conn.execute_batch(sql).unwrap();
3558 }
3559 conn.pragma_update(None, "user_version", 5).unwrap();
3560 conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
3561 .unwrap();
3562 conn.execute(
3563 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
3564 VALUES (1, 'run me', 'running', datetime('now'), datetime('now'))",
3565 [],
3566 )
3567 .unwrap();
3568 for _ in 0..3 {
3570 conn.execute(
3571 "INSERT INTO sessions (task_id, agent, started_at) VALUES (1, 'a', datetime('now'))",
3572 [],
3573 )
3574 .unwrap();
3575 }
3576
3577 let store = Store::from_connection(conn).unwrap();
3578 let open: Vec<i64> = store
3580 .sessions_for(1)
3581 .unwrap()
3582 .into_iter()
3583 .filter(|s| s.ended_at.is_none())
3584 .map(|s| s.id)
3585 .collect();
3586 assert_eq!(open, vec![3]);
3587 assert_eq!(
3588 store.session(1).unwrap().outcome,
3589 Some(SessionOutcome::Aborted)
3590 );
3591 let second = store.conn.execute(
3593 "INSERT INTO sessions (task_id, agent, started_at) VALUES (1, 'b', datetime('now'))",
3594 [],
3595 );
3596 assert!(second.is_err());
3597 }
3598
3599 #[test]
3604 fn migration_0008_backfills_flagged_ready_tasks_to_stalled() {
3605 let conn = Connection::open_in_memory().unwrap();
3606 for sql in &MIGRATIONS[..7] {
3607 conn.execute_batch(sql).unwrap();
3608 }
3609 conn.pragma_update(None, "user_version", 7).unwrap();
3610 conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
3611 .unwrap();
3612 conn.execute(
3619 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
3620 VALUES (1, 't1', 'ready', datetime('now'), datetime('now')),
3621 (1, 't2', 'ready', datetime('now'), datetime('now')),
3622 (1, 't3', 'ready', datetime('now'), datetime('now')),
3623 (1, 't4', 'ready', datetime('now'), datetime('now')),
3624 (1, 't5', 'ready', datetime('now'), datetime('now')),
3625 (1, 't6', 'running', datetime('now'), datetime('now'))",
3626 [],
3627 )
3628 .unwrap();
3629 conn.execute(
3630 "INSERT INTO sessions (task_id, agent, started_at, ended_at, outcome)
3631 VALUES (1, 'a', datetime('now'), datetime('now'), 'failed'),
3632 (2, 'a', datetime('now'), datetime('now'), 'capped'),
3633 (3, 'a', datetime('now'), datetime('now'), 'aborted'),
3634 (4, 'a', datetime('now'), datetime('now'), 'failed'),
3635 (4, 'a', datetime('now'), datetime('now'), 'aborted'),
3636 (6, 'a', datetime('now'), datetime('now'), 'failed')",
3637 [],
3638 )
3639 .unwrap();
3640 conn.execute("UPDATE tasks SET human = 1 WHERE id = 5", [])
3641 .unwrap();
3642
3643 let store = Store::from_connection(conn).unwrap();
3644 assert_eq!(store.task(1).unwrap().state, TaskState::Stalled);
3645 assert_eq!(store.task(2).unwrap().state, TaskState::Stalled);
3646 assert_eq!(store.task(3).unwrap().state, TaskState::Ready);
3647 assert_eq!(store.task(4).unwrap().state, TaskState::Ready);
3648 assert_eq!(store.task(5).unwrap().state, TaskState::Ready);
3649 assert_eq!(store.task(6).unwrap().state, TaskState::Running);
3650 assert!(store.task(5).unwrap().human);
3652 assert!(!store.task(1).unwrap().human);
3653 let junk = store
3654 .conn
3655 .execute("UPDATE tasks SET human = 2 WHERE id = 5", []);
3656 assert!(junk.is_err(), "the CHECK must reject values outside 0/1");
3657 }
3658
3659 #[test]
3662 fn migration_0010_admits_waiting_and_preserves_existing_tasks() {
3663 let conn = Connection::open_in_memory().unwrap();
3664 for sql in &MIGRATIONS[..9] {
3665 conn.execute_batch(sql).unwrap();
3666 }
3667 conn.pragma_update(None, "user_version", 9).unwrap();
3668 conn.execute("INSERT INTO projects (name, path) VALUES ('p', '/tmp')", [])
3669 .unwrap();
3670 conn.execute(
3671 "INSERT INTO tasks (project_id, title, state, agent, pr_url, branch, human,
3672 state_since, created_at)
3673 VALUES (1, 'in review', 'review', 'claude', 'https://x/pull/1', 'feat/x', 1,
3674 datetime('now'), datetime('now'))",
3675 [],
3676 )
3677 .unwrap();
3678
3679 let store = Store::from_connection(conn).unwrap();
3680 let task = store.task(1).unwrap();
3682 assert_eq!(task.state, TaskState::Review);
3683 assert_eq!(task.pr_url.as_deref(), Some("https://x/pull/1"));
3684 assert_eq!(task.branch.as_deref(), Some("feat/x"));
3685 assert!(task.human);
3686
3687 assert!(
3689 store
3690 .conn
3691 .execute("UPDATE tasks SET state = 'waiting' WHERE id = 1", [])
3692 .is_ok()
3693 );
3694 assert!(
3695 store
3696 .conn
3697 .execute("UPDATE tasks SET state = 'bogus' WHERE id = 1", [])
3698 .is_err()
3699 );
3700
3701 let version: i64 = store
3702 .conn
3703 .query_row("PRAGMA user_version", [], |r| r.get(0))
3704 .unwrap();
3705 assert_eq!(version, MIGRATIONS.len() as i64);
3706 }
3707
3708 #[test]
3712 fn migration_0016_admits_refining_and_preserves_existing_tasks() {
3713 let conn = Connection::open_in_memory().unwrap();
3714 for sql in &MIGRATIONS[..15] {
3715 conn.execute_batch(sql).unwrap();
3716 }
3717 conn.pragma_update(None, "user_version", 15).unwrap();
3718 conn.execute("INSERT INTO projects (name) VALUES ('voro')", [])
3719 .unwrap();
3720 conn.execute(
3721 "INSERT INTO repos (project_id, name, path, is_default)
3722 VALUES (1, 'voro', '/tmp/voro', 1)",
3723 [],
3724 )
3725 .unwrap();
3726 conn.execute(
3727 "INSERT INTO tasks (project_id, repo_id, title, state, agent, pr_url, branch,
3728 human, deep, state_since, created_at)
3729 VALUES (1, 1, 'in review', 'review', 'claude', 'https://x/pull/1', 'feat/x', 1, 1,
3730 datetime('now'), datetime('now'))",
3731 [],
3732 )
3733 .unwrap();
3734
3735 let store = Store::from_connection(conn).unwrap();
3736 let task = store.task(1).unwrap();
3738 assert_eq!(task.state, TaskState::Review);
3739 assert_eq!(task.pr_url.as_deref(), Some("https://x/pull/1"));
3740 assert_eq!(task.branch.as_deref(), Some("feat/x"));
3741 assert_eq!(task.repo_id, Some(1));
3742 assert!(task.human);
3743 assert!(task.deep);
3744
3745 assert!(
3746 store
3747 .conn
3748 .execute("UPDATE tasks SET state = 'refining' WHERE id = 1", [])
3749 .is_ok()
3750 );
3751 assert!(
3752 store
3753 .conn
3754 .execute("UPDATE tasks SET state = 'bogus' WHERE id = 1", [])
3755 .is_err()
3756 );
3757
3758 let version: i64 = store
3759 .conn
3760 .query_row("PRAGMA user_version", [], |r| r.get(0))
3761 .unwrap();
3762 assert_eq!(version, MIGRATIONS.len() as i64);
3763 }
3764
3765 fn task_fixture(s: &mut Store) -> i64 {
3767 s.conn
3768 .execute("INSERT OR IGNORE INTO projects (name) VALUES ('voro')", [])
3769 .unwrap();
3770 let project_id: i64 = s
3771 .conn
3772 .query_row("SELECT id FROM projects WHERE name = 'voro'", [], |r| {
3773 r.get(0)
3774 })
3775 .unwrap();
3776 s.conn
3777 .execute(
3778 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
3779 VALUES (?1, 'run me', 'running', datetime('now'), datetime('now'))",
3780 params![project_id],
3781 )
3782 .unwrap();
3783 s.conn.last_insert_rowid()
3784 }
3785
3786 #[test]
3789 fn events_for_orders_oldest_first() {
3790 use crate::transition::Action;
3791
3792 let mut s = Store::open_in_memory().unwrap();
3793 let p = s.create_project("voro", "/tmp/voro").unwrap();
3794 let task = s
3795 .create_task(NewTask {
3796 project_id: p.id,
3797 repo_id: None,
3798 title: "trace me".into(),
3799 body: String::new(),
3800 priority: Priority::P2,
3801 state: TaskState::Ready,
3802 agent: None,
3803 human: false,
3804 deep: false,
3805 })
3806 .unwrap();
3807 s.apply(task.id, Action::Start).unwrap();
3808 s.apply(task.id, Action::Ask("A or B?".into())).unwrap();
3809 s.apply(task.id, Action::Resume).unwrap();
3810
3811 let events = s.events_for(task.id).unwrap();
3812 let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect();
3813 assert_eq!(
3814 kinds,
3815 vec!["created", "transition", "transition", "transition"]
3816 );
3817 assert!(events.windows(2).all(|w| w[0].id < w[1].id));
3819 }
3820
3821 #[test]
3822 fn latest_summary_returns_the_newest_summary_event() {
3823 use crate::transition::Action;
3824
3825 let mut s = Store::open_in_memory().unwrap();
3826 let p = s.create_project("voro", "/tmp/voro").unwrap();
3827 let t = s
3828 .create_task(NewTask {
3829 project_id: p.id,
3830 repo_id: None,
3831 title: "summary me".into(),
3832 body: String::new(),
3833 priority: Priority::P2,
3834 state: TaskState::Ready,
3835 agent: None,
3836 human: false,
3837 deep: false,
3838 })
3839 .unwrap();
3840 assert_eq!(s.latest_summary(t.id).unwrap(), None);
3841
3842 s.apply(t.id, Action::Start).unwrap();
3843 s.apply(t.id, Action::Complete(Some("first pass".into())))
3844 .unwrap();
3845 assert_eq!(
3846 s.latest_summary(t.id).unwrap().as_deref(),
3847 Some("first pass")
3848 );
3849
3850 s.apply(t.id, Action::RejectWork("redo".into())).unwrap();
3852 s.apply(t.id, Action::Complete(Some("second pass".into())))
3853 .unwrap();
3854 assert_eq!(
3855 s.latest_summary(t.id).unwrap().as_deref(),
3856 Some("second pass")
3857 );
3858 }
3859
3860 #[test]
3864 fn last_reviewed_supersedes_and_starts_absent() {
3865 let mut s = Store::open_in_memory().unwrap();
3866 let p = s.create_project("voro", "/tmp/voro").unwrap();
3867 let t = s
3868 .create_task(NewTask {
3869 project_id: p.id,
3870 repo_id: None,
3871 title: "review me".into(),
3872 body: String::new(),
3873 priority: Priority::P2,
3874 state: TaskState::Ready,
3875 agent: None,
3876 human: false,
3877 deep: false,
3878 })
3879 .unwrap();
3880 assert_eq!(s.last_reviewed(t.id).unwrap(), None);
3881
3882 s.record_reviewed(t.id, " aaaa1111 ").unwrap();
3883 assert_eq!(s.last_reviewed(t.id).unwrap().as_deref(), Some("aaaa1111"));
3884 s.record_reviewed(t.id, "bbbb2222").unwrap();
3885 assert_eq!(s.last_reviewed(t.id).unwrap().as_deref(), Some("bbbb2222"));
3886
3887 assert!(s.record_reviewed(t.id, " ").is_err());
3888 assert!(s.record_reviewed(999, "aaaa1111").is_err());
3889 assert_eq!(s.task(t.id).unwrap().state, TaskState::Ready);
3891 }
3892
3893 #[test]
3894 fn incomplete_report_flag_marks_a_review_task_with_a_branch_and_no_summary() {
3895 use crate::transition::Action;
3896
3897 fn reviewed(branch: Option<&str>, summary: Option<&str>) -> (Store, i64) {
3899 let mut s = Store::open_in_memory().unwrap();
3900 let p = s.create_project("voro", "/tmp/voro").unwrap();
3901 let t = s
3902 .create_task(NewTask {
3903 project_id: p.id,
3904 repo_id: None,
3905 title: "report me".into(),
3906 body: String::new(),
3907 priority: Priority::P2,
3908 state: TaskState::Ready,
3909 agent: None,
3910 human: false,
3911 deep: false,
3912 })
3913 .unwrap();
3914 s.apply(t.id, Action::Start).unwrap();
3915 s.apply(t.id, Action::Complete(summary.map(str::to_string)))
3916 .unwrap();
3917 if let Some(name) = branch {
3918 s.set_branch(t.id, Some(name)).unwrap();
3919 }
3920 (s, t.id)
3921 }
3922
3923 let (s, id) = reviewed(Some("feat/x"), None);
3926 assert!(
3927 s.incomplete_report_flag(id).unwrap(),
3928 "half report: branch, no summary"
3929 );
3930
3931 let (s, id) = reviewed(None, Some("already fixed by PR #96; nothing to do"));
3935 assert!(
3936 !s.incomplete_report_flag(id).unwrap(),
3937 "no-code report: summary, no branch"
3938 );
3939
3940 let (s, id) = reviewed(Some("feat/x"), Some("did the thing"));
3942 assert!(!s.incomplete_report_flag(id).unwrap());
3943
3944 let (s, id) = reviewed(None, None);
3946 assert!(!s.incomplete_report_flag(id).unwrap());
3947 }
3948
3949 #[test]
3950 fn incomplete_report_flag_is_gated_on_review() {
3951 use crate::transition::Action;
3952
3953 let mut s = Store::open_in_memory().unwrap();
3957 let p = s.create_project("voro", "/tmp/voro").unwrap();
3958 let t = s
3959 .create_task(NewTask {
3960 project_id: p.id,
3961 repo_id: None,
3962 title: "in flight".into(),
3963 body: String::new(),
3964 priority: Priority::P2,
3965 state: TaskState::Ready,
3966 agent: None,
3967 human: false,
3968 deep: false,
3969 })
3970 .unwrap();
3971 s.set_branch(t.id, Some("feat/x")).unwrap();
3972 assert!(!s.incomplete_report_flag(t.id).unwrap(), "ready");
3973
3974 s.apply(t.id, Action::Start).unwrap();
3975 assert!(!s.incomplete_report_flag(t.id).unwrap(), "running");
3976
3977 s.apply(t.id, Action::Complete(None)).unwrap();
3979 assert!(s.incomplete_report_flag(t.id).unwrap(), "review");
3980
3981 s.apply(t.id, Action::Accept).unwrap();
3983 assert!(!s.incomplete_report_flag(t.id).unwrap(), "done");
3984 }
3985
3986 fn proposal(s: &mut Store, title: &str) -> Task {
3989 let p = s.projects().unwrap().first().cloned().unwrap_or_else(|| {
3990 s.create_project("voro", "/tmp/voro").unwrap();
3991 s.projects().unwrap().remove(0)
3992 });
3993 s.create_task(NewTask {
3994 project_id: p.id,
3995 repo_id: None,
3996 title: title.into(),
3997 body: "thin body".into(),
3998 priority: Priority::P2,
3999 state: TaskState::Proposed,
4000 agent: None,
4001 human: false,
4002 deep: false,
4003 })
4004 .unwrap()
4005 }
4006
4007 #[test]
4011 fn record_refine_launch_moves_the_task_and_logs_the_note() {
4012 let mut s = Store::open_in_memory().unwrap();
4013 let blocker = proposal(&mut s, "blocker");
4014 let t = proposal(&mut s, "refine me");
4015 s.add_dep(t.id, blocker.id, DepKind::Blocks).unwrap();
4016 let before = s.task(t.id).unwrap();
4017
4018 let (after, session) = s
4019 .record_refine_launch(
4020 t.id,
4021 " name the files it touches ",
4022 "claude",
4023 Some(4321),
4024 LivenessSource::Pid,
4025 Some("/var/log/refine.log"),
4026 )
4027 .unwrap();
4028
4029 assert_eq!(after.state, TaskState::Refining);
4030 assert_eq!(after.priority, before.priority);
4031 assert_eq!(after.body, before.body);
4032 assert_eq!(s.deps_of(t.id).unwrap().len(), 1);
4033 assert_eq!(session.pid, Some(4321));
4034 assert_eq!(session.log_path.as_deref(), Some("/var/log/refine.log"));
4035 assert!(session.ended_at.is_none());
4036 assert_eq!(
4037 s.latest_refine_note(t.id).unwrap().as_deref(),
4038 Some("name the files it touches")
4039 );
4040 assert!(!s.refined_flag(t.id).unwrap());
4043 assert!(!s.refine_failed_flag(t.id).unwrap());
4044 }
4045
4046 #[test]
4049 fn a_note_less_refine_launch_logs_no_note() {
4050 let mut s = Store::open_in_memory().unwrap();
4051 let t = proposal(&mut s, "refine me");
4052 s.record_refine_launch(t.id, "", "claude", Some(1), LivenessSource::Pid, None)
4053 .unwrap();
4054 assert_eq!(s.latest_refine_note(t.id).unwrap(), None);
4055 assert_eq!(s.task(t.id).unwrap().state, TaskState::Refining);
4056 }
4057
4058 #[test]
4061 fn the_markers_read_the_round_that_just_concluded() {
4062 use crate::transition::{Action, Triage};
4063
4064 let mut s = Store::open_in_memory().unwrap();
4065 let t = proposal(&mut s, "refine me");
4066
4067 for (outcome, refined, failed) in [
4068 (RefineOutcome::Applied, true, false),
4069 (RefineOutcome::Failed, false, true),
4070 (RefineOutcome::Cancelled, false, false),
4071 (RefineOutcome::Applied, true, false),
4072 ] {
4073 s.record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None)
4074 .unwrap();
4075 let after = s.conclude_refine(t.id, outcome).unwrap();
4076 assert_eq!(after.state, TaskState::Proposed, "{outcome}");
4077 assert_eq!(s.refined_flag(t.id).unwrap(), refined, "{outcome}");
4078 assert_eq!(s.refine_failed_flag(t.id).unwrap(), failed, "{outcome}");
4079 assert_eq!(s.latest_refine_outcome(t.id).unwrap(), Some(outcome));
4080 }
4081
4082 s.apply(t.id, Action::Triage(Triage::Parked)).unwrap();
4084 assert!(!s.refined_flag(t.id).unwrap());
4085 assert!(!s.refine_failed_flag(t.id).unwrap());
4086 }
4087
4088 #[test]
4092 fn a_late_rewrite_corrects_a_failed_round_to_applied() {
4093 let mut s = Store::open_in_memory().unwrap();
4094 let t = proposal(&mut s, "refine me");
4095 s.record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None)
4096 .unwrap();
4097 s.conclude_refine(t.id, RefineOutcome::Failed).unwrap();
4098 assert!(s.refine_failed_flag(t.id).unwrap());
4099
4100 assert!(s.correct_late_refine(t.id).unwrap());
4101 assert!(s.refined_flag(t.id).unwrap());
4102 assert!(!s.refine_failed_flag(t.id).unwrap());
4103 assert_eq!(
4104 s.latest_refine_outcome(t.id).unwrap(),
4105 Some(RefineOutcome::Applied)
4106 );
4107 assert_eq!(s.task(t.id).unwrap().state, TaskState::Proposed);
4109 assert_eq!(
4110 s.sessions_for(t.id).unwrap()[0].outcome,
4111 Some(SessionOutcome::Failed),
4112 "the session keeps the outcome the reconciler observed"
4113 );
4114 assert!(!s.correct_late_refine(t.id).unwrap());
4116 }
4117
4118 #[test]
4121 fn correcting_a_round_is_a_no_op_off_the_failed_case() {
4122 use crate::transition::{Action, Triage};
4123
4124 for outcome in [RefineOutcome::Applied, RefineOutcome::Cancelled] {
4125 let mut s = Store::open_in_memory().unwrap();
4126 let t = proposal(&mut s, "refine me");
4127 s.record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None)
4128 .unwrap();
4129 s.conclude_refine(t.id, outcome).unwrap();
4130
4131 assert!(!s.correct_late_refine(t.id).unwrap(), "{outcome}");
4132 assert_eq!(s.latest_refine_outcome(t.id).unwrap(), Some(outcome));
4133 }
4134
4135 let mut s = Store::open_in_memory().unwrap();
4137 let t = proposal(&mut s, "never refined");
4138 assert!(!s.correct_late_refine(t.id).unwrap());
4139 assert_eq!(s.latest_refine_outcome(t.id).unwrap(), None);
4140
4141 s.record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None)
4142 .unwrap();
4143 s.conclude_refine(t.id, RefineOutcome::Failed).unwrap();
4144 s.apply(t.id, Action::Triage(Triage::Ready)).unwrap();
4145 assert!(!s.correct_late_refine(t.id).unwrap());
4146 assert_eq!(
4147 s.latest_refine_outcome(t.id).unwrap(),
4148 Some(RefineOutcome::Failed)
4149 );
4150 }
4151
4152 #[test]
4155 fn concluding_a_round_closes_its_session_with_the_matching_outcome() {
4156 for (outcome, session_outcome) in [
4157 (RefineOutcome::Applied, SessionOutcome::Completed),
4158 (RefineOutcome::Failed, SessionOutcome::Failed),
4159 (RefineOutcome::Cancelled, SessionOutcome::Aborted),
4160 ] {
4161 let mut s = Store::open_in_memory().unwrap();
4162 let t = proposal(&mut s, "refine me");
4163 let (_, session) = s
4164 .record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None)
4165 .unwrap();
4166
4167 s.conclude_refine(t.id, outcome).unwrap();
4168 let closed = s.session(session.id).unwrap();
4169 assert!(closed.ended_at.is_some(), "{outcome}");
4170 assert_eq!(closed.outcome, Some(session_outcome), "{outcome}");
4171 }
4172 }
4173
4174 #[test]
4177 fn refine_transitions_are_refused_from_the_wrong_state() {
4178 use crate::transition::{Action, Triage};
4179
4180 let mut s = Store::open_in_memory().unwrap();
4181 let t = proposal(&mut s, "refine me");
4182 assert!(matches!(
4183 s.conclude_refine(t.id, RefineOutcome::Applied),
4184 Err(Error::InvalidTransition { .. })
4185 ));
4186
4187 s.apply(t.id, Action::Triage(Triage::Parked)).unwrap();
4188 assert!(matches!(
4189 s.record_refine_launch(t.id, "too late", "claude", None, LivenessSource::Pid, None),
4190 Err(Error::InvalidTransition { .. })
4191 ));
4192 assert_eq!(s.task(t.id).unwrap().state, TaskState::Parked);
4194 assert!(s.sessions_for(t.id).unwrap().is_empty());
4195 }
4196
4197 #[test]
4198 fn discovered_from_resolves_the_parent_proposal() {
4199 let mut s = Store::open_in_memory().unwrap();
4200 let parent = proposal(&mut s, "parent");
4201 let child = proposal(&mut s, "child");
4202 assert!(s.discovered_from(child.id).unwrap().is_none());
4203
4204 s.add_dep(child.id, parent.id, DepKind::DiscoveredFrom)
4205 .unwrap();
4206 assert_eq!(
4207 s.discovered_from(child.id).unwrap().map(|t| t.id),
4208 Some(parent.id)
4209 );
4210 let blocker = proposal(&mut s, "blocker");
4213 assert!(s.discovered_from(blocker.id).unwrap().is_none());
4214 }
4215
4216 #[test]
4217 fn incomplete_report_flag_is_false_for_a_missing_task() {
4218 let s = Store::open_in_memory().unwrap();
4219 assert!(!s.incomplete_report_flag(999).unwrap());
4220 }
4221
4222 #[test]
4223 fn session_create_end_round_trip() {
4224 let mut s = Store::open_in_memory().unwrap();
4225 let task_id = task_fixture(&mut s);
4226
4227 let opened = s
4228 .create_session(
4229 task_id,
4230 "claude",
4231 Some(4321),
4232 LivenessSource::Pid,
4233 Some("/var/log/s.log"),
4234 )
4235 .unwrap();
4236 assert_eq!(opened.task_id, task_id);
4237 assert_eq!(opened.agent, "claude");
4238 assert_eq!(opened.pid, Some(4321));
4239 assert_eq!(opened.log_path.as_deref(), Some("/var/log/s.log"));
4240 assert!(!opened.started_at.is_empty());
4241 assert!(opened.ended_at.is_none());
4242 assert!(opened.outcome.is_none());
4243
4244 let ended = s.end_session(opened.id, SessionOutcome::Completed).unwrap();
4245 assert_eq!(ended.id, opened.id);
4246 assert!(ended.ended_at.is_some());
4247 assert_eq!(ended.outcome, Some(SessionOutcome::Completed));
4248
4249 assert_eq!(s.session(opened.id).unwrap(), ended);
4250 }
4251
4252 #[test]
4255 fn latest_sessions_keeps_only_the_newest_per_task() {
4256 let mut s = Store::open_in_memory().unwrap();
4257 let with_history = task_fixture(&mut s);
4258 let sessionless = task_fixture(&mut s);
4259
4260 let first = s
4261 .create_session(
4262 with_history,
4263 "claude",
4264 None,
4265 LivenessSource::Pid,
4266 Some("/var/log/first.log"),
4267 )
4268 .unwrap();
4269 s.end_session(first.id, SessionOutcome::Failed).unwrap();
4270 let second = s
4271 .create_session(
4272 with_history,
4273 "codex",
4274 None,
4275 LivenessSource::Pid,
4276 Some("/var/log/second.log"),
4277 )
4278 .unwrap();
4279
4280 let latest = s.latest_sessions().unwrap();
4281 assert_eq!(latest.len(), 1);
4282 assert_eq!(latest[&with_history].id, second.id);
4283 assert_eq!(
4284 latest[&with_history].log_path.as_deref(),
4285 Some("/var/log/second.log")
4286 );
4287 assert!(!latest.contains_key(&sessionless));
4288 }
4289
4290 #[test]
4291 fn session_optional_fields_are_null() {
4292 let mut s = Store::open_in_memory().unwrap();
4293 let task_id = task_fixture(&mut s);
4294 let opened = s
4295 .create_session(task_id, "codex", None, LivenessSource::Pid, None)
4296 .unwrap();
4297 assert!(opened.pid.is_none());
4298 assert!(opened.session_ref.is_none());
4299 assert!(opened.log_path.is_none());
4300 }
4301
4302 #[test]
4303 fn set_session_ref_records_and_rejects_unknown_ids() {
4304 let mut s = Store::open_in_memory().unwrap();
4305 let task_id = task_fixture(&mut s);
4306 let opened = s
4307 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4308 .unwrap();
4309 assert!(opened.session_ref.is_none());
4310
4311 let updated = s
4312 .set_session_ref(opened.id, "3f6c0e6e-1111-2222-3333-444455556666")
4313 .unwrap();
4314 assert_eq!(
4315 updated.session_ref.as_deref(),
4316 Some("3f6c0e6e-1111-2222-3333-444455556666")
4317 );
4318 assert_eq!(s.session(opened.id).unwrap(), updated);
4319
4320 assert!(matches!(
4321 s.set_session_ref(999, "x"),
4322 Err(Error::SessionNotFound(999))
4323 ));
4324 }
4325
4326 #[test]
4331 fn a_session_records_the_liveness_source_it_was_launched_with() {
4332 let mut s = Store::open_in_memory().unwrap();
4333 let task_id = task_fixture(&mut s);
4334 let listing = s
4335 .create_session(task_id, "claude", Some(1), LivenessSource::Listing, None)
4336 .unwrap();
4337 assert_eq!(listing.liveness_source, LivenessSource::Listing);
4338 assert_eq!(
4339 s.session(listing.id).unwrap().liveness_source,
4340 LivenessSource::Listing
4341 );
4342
4343 let pid = s
4344 .create_session(task_id, "manual", Some(1), LivenessSource::Pid, None)
4345 .unwrap();
4346 assert_eq!(pid.liveness_source, LivenessSource::Pid);
4347 assert_eq!(
4348 s.live_sessions().unwrap()[0].liveness_source,
4349 pid.liveness_source
4350 );
4351
4352 s.set_session_ref(pid.id, "uuid").unwrap();
4355 assert_eq!(
4356 s.session(pid.id).unwrap().liveness_source,
4357 LivenessSource::Pid
4358 );
4359 }
4360
4361 #[test]
4364 fn dispatch_and_refine_launches_carry_their_liveness_source() {
4365 let mut s = Store::open_in_memory().unwrap();
4366 let p = s.create_project("proj", "/tmp/proj").unwrap();
4367 let ready = s
4368 .create_task(NewTask {
4369 project_id: p.id,
4370 repo_id: None,
4371 title: "run me".into(),
4372 body: String::new(),
4373 priority: Priority::P1,
4374 state: TaskState::Ready,
4375 agent: None,
4376 human: false,
4377 deep: false,
4378 })
4379 .unwrap();
4380 let (_, dispatched) = s
4381 .record_dispatch(ready.id, "claude", Some(1), LivenessSource::Listing, None)
4382 .unwrap();
4383 assert_eq!(dispatched.liveness_source, LivenessSource::Listing);
4384
4385 let proposal = s
4386 .create_task(NewTask {
4387 project_id: p.id,
4388 repo_id: None,
4389 title: "sloppy".into(),
4390 body: String::new(),
4391 priority: Priority::P2,
4392 state: TaskState::Proposed,
4393 agent: None,
4394 human: false,
4395 deep: false,
4396 })
4397 .unwrap();
4398 let (_, headless) = s
4399 .record_refine_launch(
4400 proposal.id,
4401 "name the files",
4402 "claude",
4403 Some(2),
4404 LivenessSource::Listing,
4405 None,
4406 )
4407 .unwrap();
4408 assert_eq!(headless.liveness_source, LivenessSource::Listing);
4409
4410 s.conclude_refine(proposal.id, RefineOutcome::Cancelled)
4411 .unwrap();
4412 let (_, interactive) = s
4413 .record_refine_launch(
4414 proposal.id,
4415 "",
4416 "claude",
4417 Some(3),
4418 LivenessSource::Pid,
4419 None,
4420 )
4421 .unwrap();
4422 assert_eq!(interactive.liveness_source, LivenessSource::Pid);
4423 }
4424
4425 #[test]
4431 fn migration_0017_defaults_existing_sessions_to_the_listing() {
4432 let conn = Connection::open_in_memory().unwrap();
4433 for sql in &MIGRATIONS[..16] {
4434 conn.execute_batch(sql).unwrap();
4435 }
4436 conn.pragma_update(None, "user_version", 16).unwrap();
4437 conn.execute("INSERT INTO projects (name) VALUES ('p')", [])
4438 .unwrap();
4439 conn.execute(
4440 "INSERT INTO repos (project_id, name, path, is_default)
4441 VALUES (1, 'p', '/tmp/p', 1)",
4442 [],
4443 )
4444 .unwrap();
4445 conn.execute(
4446 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
4447 VALUES (1, 'dispatched', 'running', datetime('now'), datetime('now'))",
4448 [],
4449 )
4450 .unwrap();
4451 conn.execute(
4452 "INSERT INTO sessions (task_id, agent, pid, started_at)
4453 VALUES (1, 'claude', 4242, datetime('now'))",
4454 [],
4455 )
4456 .unwrap();
4457
4458 let store = Store::from_connection(conn).unwrap();
4459 assert_eq!(
4460 store.session(1).unwrap().liveness_source,
4461 LivenessSource::Listing
4462 );
4463
4464 let junk = store.conn.execute(
4465 "UPDATE sessions SET liveness_source = 'guess' WHERE id = 1",
4466 [],
4467 );
4468 assert!(junk.is_err(), "the CHECK must reject an unknown source");
4469 }
4470
4471 #[test]
4475 fn record_session_send_moves_the_pid_and_follows_a_fork() {
4476 let mut s = Store::open_in_memory().unwrap();
4477 let task_id = task_fixture(&mut s);
4478 let opened = s
4479 .create_session(task_id, "claude", Some(1234), LivenessSource::Pid, None)
4480 .unwrap();
4481 s.set_session_ref(opened.id, "first-ref").unwrap();
4482
4483 let resumed = s.record_session_send(opened.id, None, 4321).unwrap();
4484 assert_eq!(resumed.pid, Some(4321));
4485 assert_eq!(resumed.session_ref.as_deref(), Some("first-ref"));
4486
4487 let forked = s
4488 .record_session_send(opened.id, Some("forked-ref"), 5678)
4489 .unwrap();
4490 assert_eq!(forked.pid, Some(5678));
4491 assert_eq!(forked.session_ref.as_deref(), Some("forked-ref"));
4492 assert_eq!(s.session(opened.id).unwrap(), forked);
4493
4494 assert!(matches!(
4495 s.record_session_send(999, None, 1),
4496 Err(Error::SessionNotFound(999))
4497 ));
4498 }
4499
4500 #[test]
4501 fn end_session_rejects_unknown_id() {
4502 let mut s = Store::open_in_memory().unwrap();
4503 assert!(matches!(
4504 s.end_session(999, SessionOutcome::Aborted),
4505 Err(Error::SessionNotFound(999))
4506 ));
4507 }
4508
4509 #[test]
4510 fn sessions_for_returns_newest_first() {
4511 let mut s = Store::open_in_memory().unwrap();
4512 let task_id = task_fixture(&mut s);
4513 let first = s
4514 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4515 .unwrap();
4516 let second = s
4517 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4518 .unwrap();
4519
4520 let sessions = s.sessions_for(task_id).unwrap();
4521 assert_eq!(
4522 sessions.iter().map(|s| s.id).collect::<Vec<_>>(),
4523 vec![second.id, first.id]
4524 );
4525 }
4526
4527 #[test]
4528 fn live_sessions_excludes_ended() {
4529 let mut s = Store::open_in_memory().unwrap();
4530 let task_id = task_fixture(&mut s);
4531 let done = s
4532 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4533 .unwrap();
4534 let live = s
4535 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4536 .unwrap();
4537 s.end_session(done.id, SessionOutcome::Failed).unwrap();
4538
4539 let ids = s.live_sessions().unwrap();
4540 assert_eq!(ids.iter().map(|s| s.id).collect::<Vec<_>>(), vec![live.id]);
4541 }
4542
4543 #[test]
4544 fn running_rows_join_current_task_fields() {
4545 let mut s = Store::open_in_memory().unwrap();
4546 let task_id = task_fixture(&mut s);
4547 let session = s
4548 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4549 .unwrap();
4550
4551 let rows = s.running_rows().unwrap();
4552 assert_eq!(rows.len(), 1);
4553 assert_eq!(rows[0].session_id, Some(session.id));
4554 assert_eq!(rows[0].task_id, task_id);
4555 assert_eq!(rows[0].task_title, "run me");
4556 assert_eq!(rows[0].task_state, TaskState::Running);
4557 assert_eq!(rows[0].agent.as_deref(), Some("claude"));
4558 assert!(rows[0].elapsed_secs >= 0);
4559 }
4560
4561 #[test]
4562 fn running_rows_exclude_ended_sessions_and_order_newest_first() {
4563 let mut s = Store::open_in_memory().unwrap();
4564 let task_id = task_fixture(&mut s);
4565 let done = s
4566 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4567 .unwrap();
4568 let live = s
4569 .create_session(task_id, "codex", None, LivenessSource::Pid, None)
4570 .unwrap();
4571 s.end_session(done.id, SessionOutcome::Completed).unwrap();
4572
4573 let rows = s.running_rows().unwrap();
4574 assert_eq!(
4575 rows.iter().map(|r| r.session_id).collect::<Vec<_>>(),
4576 vec![Some(live.id)]
4577 );
4578 assert_eq!(rows[0].agent.as_deref(), Some("codex"));
4579 }
4580
4581 #[test]
4582 fn running_rows_compute_elapsed_from_started_at() {
4583 let mut s = Store::open_in_memory().unwrap();
4584 let task_id = task_fixture(&mut s);
4585 let session = s
4586 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4587 .unwrap();
4588 s.conn
4589 .execute(
4590 "UPDATE sessions SET started_at = datetime('now', '-90 seconds') WHERE id = ?1",
4591 params![session.id],
4592 )
4593 .unwrap();
4594
4595 let rows = s.running_rows().unwrap();
4596 assert_eq!(rows.len(), 1);
4597 assert!(
4599 (85..=95).contains(&rows[0].elapsed_secs),
4600 "expected ~90s elapsed, got {}",
4601 rows[0].elapsed_secs
4602 );
4603 }
4604
4605 #[test]
4610 fn running_rows_include_running_task_without_live_session() {
4611 let mut s = Store::open_in_memory().unwrap();
4612 let task_id = task_fixture(&mut s);
4613 s.conn
4614 .execute(
4615 "UPDATE tasks SET state_since = datetime('now', '-90 seconds') WHERE id = ?1",
4616 params![task_id],
4617 )
4618 .unwrap();
4619
4620 let rows = s.running_rows().unwrap();
4621 assert_eq!(rows.len(), 1);
4622 assert_eq!(rows[0].session_id, None);
4623 assert_eq!(rows[0].agent, None);
4624 assert_eq!(rows[0].task_id, task_id);
4625 assert_eq!(rows[0].task_state, TaskState::Running);
4626 assert!(
4627 (85..=95).contains(&rows[0].elapsed_secs),
4628 "expected ~90s in running, got {}",
4629 rows[0].elapsed_secs
4630 );
4631 }
4632
4633 #[test]
4636 fn running_rows_include_task_whose_sessions_all_ended() {
4637 let mut s = Store::open_in_memory().unwrap();
4638 let task_id = task_fixture(&mut s);
4639 let done = s
4640 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4641 .unwrap();
4642 s.end_session(done.id, SessionOutcome::Failed).unwrap();
4643
4644 let rows = s.running_rows().unwrap();
4645 assert_eq!(rows.len(), 1);
4646 assert_eq!(rows[0].session_id, None);
4647 assert_eq!(rows[0].task_id, task_id);
4648 }
4649
4650 #[test]
4653 fn running_rows_order_live_sessions_before_session_less_tasks() {
4654 let mut s = Store::open_in_memory().unwrap();
4655 let live_task = task_fixture(&mut s);
4656 let session = s
4657 .create_session(live_task, "claude", None, LivenessSource::Pid, None)
4658 .unwrap();
4659 let orphan_task = task_fixture(&mut s);
4660
4661 let rows = s.running_rows().unwrap();
4662 assert_eq!(rows.len(), 2);
4663 assert_eq!(rows[0].session_id, Some(session.id));
4664 assert_eq!(rows[0].task_id, live_task);
4665 assert_eq!(rows[1].session_id, None);
4666 assert_eq!(rows[1].task_id, orphan_task);
4667 }
4668
4669 #[test]
4673 fn running_rows_include_a_refining_task() {
4674 let mut s = Store::open_in_memory().unwrap();
4675 let t = proposal(&mut s, "refine me");
4676 let (_, session) = s
4677 .record_refine_launch(
4678 t.id,
4679 "thin body",
4680 "claude",
4681 Some(1),
4682 LivenessSource::Pid,
4683 None,
4684 )
4685 .unwrap();
4686
4687 let rows = s.running_rows().unwrap();
4688 assert_eq!(rows.len(), 1);
4689 assert_eq!(rows[0].task_id, t.id);
4690 assert_eq!(rows[0].task_state, TaskState::Refining);
4691 assert_eq!(rows[0].session_id, Some(session.id));
4692 assert_eq!(rows[0].agent.as_deref(), Some("claude"));
4693
4694 s.conclude_refine(t.id, RefineOutcome::Applied).unwrap();
4695 assert!(s.running_rows().unwrap().is_empty());
4696 }
4697
4698 #[test]
4703 fn running_rows_measure_a_waiting_task_from_the_hand_off() {
4704 let mut s = Store::open_in_memory().unwrap();
4705 let p = s.create_project("voro", "/tmp/voro").unwrap();
4706 let id = s
4707 .create_task(NewTask {
4708 project_id: p.id,
4709 repo_id: None,
4710 title: "handed off".into(),
4711 body: String::new(),
4712 priority: Priority::P2,
4713 state: TaskState::Ready,
4714 agent: None,
4715 human: false,
4716 deep: false,
4717 })
4718 .unwrap()
4719 .id;
4720 let (_, opened) = s
4721 .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None)
4722 .unwrap();
4723 s.apply(id, Action::Complete(None)).unwrap();
4724 s.apply(id, Action::HandOff).unwrap();
4725 s.set_pr(id, Some("https://github.com/o/r/pull/7")).unwrap();
4726 let session = opened.id;
4727 s.conn
4728 .execute(
4729 "UPDATE sessions SET started_at = datetime('now', '-2 hours') WHERE id = ?1",
4730 params![session],
4731 )
4732 .unwrap();
4733 s.conn
4734 .execute(
4735 "UPDATE tasks SET state_since = datetime('now', '-90 seconds') WHERE id = ?1",
4736 params![id],
4737 )
4738 .unwrap();
4739
4740 let rows = s.running_rows().unwrap();
4741 assert_eq!(rows.len(), 1);
4742 assert_eq!(rows[0].task_id, id);
4743 assert_eq!(rows[0].task_state, TaskState::Waiting);
4744 assert_eq!(rows[0].session_id, Some(session));
4745 assert_eq!(
4746 rows[0].pr_url.as_deref(),
4747 Some("https://github.com/o/r/pull/7")
4748 );
4749 assert!(
4750 (85..=95).contains(&rows[0].elapsed_secs),
4751 "expected ~90s waiting, got {} (the session's own age is 2h)",
4752 rows[0].elapsed_secs
4753 );
4754 }
4755
4756 #[test]
4759 fn running_rows_sort_waiting_after_work_under_way() {
4760 let mut s = Store::open_in_memory().unwrap();
4761 let p = s.create_project("voro", "/tmp/voro").unwrap();
4762 let waiting = task_in_state(&mut s, p.id, TaskState::Waiting);
4763 let running = task_in_state(&mut s, p.id, TaskState::Running);
4764 let refining = task_in_state(&mut s, p.id, TaskState::Refining);
4765
4766 let rows = s.running_rows().unwrap();
4767 assert_eq!(rows.len(), 3);
4768 assert_eq!(rows.last().unwrap().task_id, waiting);
4769 let ahead: Vec<i64> = rows[..2].iter().map(|r| r.task_id).collect();
4770 assert!(
4771 ahead.contains(&running) && ahead.contains(&refining),
4772 "{ahead:?}"
4773 );
4774 }
4775
4776 #[test]
4779 fn running_rows_exclude_a_waiting_task_in_an_archived_project() {
4780 let mut s = Store::open_in_memory().unwrap();
4781 let p = s.create_project("retiring", "/tmp/retiring").unwrap();
4782 let id = task_in_state(&mut s, p.id, TaskState::Waiting);
4783 assert_eq!(s.running_rows().unwrap().len(), 1);
4784
4785 s.set_archived(p.id, true).unwrap();
4786 assert!(s.running_rows().unwrap().is_empty());
4787 assert_eq!(s.task(id).unwrap().state, TaskState::Waiting);
4788 }
4789
4790 #[test]
4794 fn running_rows_exclude_tasks_that_left_running() {
4795 let mut s = Store::open_in_memory().unwrap();
4796 let p = s.create_project("voro", "/tmp/voro").unwrap();
4797 let new = |title: &str| NewTask {
4798 project_id: p.id,
4799 repo_id: None,
4800 title: title.into(),
4801 body: String::new(),
4802 priority: Priority::P2,
4803 state: TaskState::Ready,
4804 agent: None,
4805 human: false,
4806 deep: false,
4807 };
4808
4809 let review = s.create_task(new("review")).unwrap().id;
4811 s.record_dispatch(review, "claude", Some(1), LivenessSource::Pid, None)
4812 .unwrap();
4813 s.apply(review, Action::Complete(None)).unwrap();
4814 assert!(s.sessions_for(review).unwrap()[0].ended_at.is_none());
4815
4816 let done = s.create_task(new("done")).unwrap().id;
4818 s.record_dispatch(done, "claude", Some(2), LivenessSource::Pid, None)
4819 .unwrap();
4820 s.apply(done, Action::Complete(None)).unwrap();
4821 s.apply(done, Action::Accept).unwrap();
4822
4823 let rejected = s.create_task(new("rejected")).unwrap().id;
4824 s.record_dispatch(rejected, "claude", Some(3), LivenessSource::Pid, None)
4825 .unwrap();
4826 s.apply(rejected, Action::Abort).unwrap();
4827 s.apply(rejected, Action::Abandon).unwrap();
4828
4829 let running = s.create_task(new("running")).unwrap().id;
4830 s.record_dispatch(running, "claude", Some(4), LivenessSource::Pid, None)
4831 .unwrap();
4832
4833 let rows = s.running_rows().unwrap();
4834 assert_eq!(rows.len(), 1);
4835 assert_eq!(rows[0].task_id, running);
4836 }
4837
4838 #[test]
4841 fn running_rows_ignore_a_stale_open_session_on_a_closed_task() {
4842 let mut s = Store::open_in_memory().unwrap();
4843 let task_id = task_fixture(&mut s);
4844 s.create_session(task_id, "claude", Some(1), LivenessSource::Pid, None)
4845 .unwrap();
4846 s.conn
4847 .execute("UPDATE tasks SET state = 'done' WHERE id = ?1", [task_id])
4848 .unwrap();
4849 assert!(s.running_rows().unwrap().is_empty());
4850 }
4851
4852 #[test]
4853 fn session_outcome_serialises_for_all_variants() {
4854 let mut s = Store::open_in_memory().unwrap();
4855 let task_id = task_fixture(&mut s);
4856 for outcome in SessionOutcome::ALL {
4857 let opened = s
4858 .create_session(task_id, "claude", None, LivenessSource::Pid, None)
4859 .unwrap();
4860 let ended = s.end_session(opened.id, outcome).unwrap();
4861 assert_eq!(ended.outcome, Some(outcome));
4862 }
4863 }
4864
4865 fn scratch_db() -> PathBuf {
4867 tempfile::Builder::new()
4868 .prefix("voro-dataversion-")
4869 .tempdir()
4870 .unwrap()
4871 .keep()
4872 .join("voro.db")
4873 }
4874
4875 #[test]
4876 fn data_version_tracks_external_commits_only() {
4877 let path = scratch_db();
4878 let mut a = Store::open(&path).unwrap();
4879 let mut b = Store::open(&path).unwrap();
4880
4881 let start = a.data_version().unwrap();
4882
4883 a.create_project("alpha", "/tmp/alpha").unwrap();
4885 assert_eq!(a.data_version().unwrap(), start);
4886
4887 b.create_project("beta", "/tmp/beta").unwrap();
4889 assert_ne!(a.data_version().unwrap(), start);
4890
4891 drop(a);
4892 drop(b);
4893 let _ = std::fs::remove_file(&path);
4894 }
4895
4896 #[test]
4897 fn dep_maps_resolve_both_directions_with_title_state_and_kind() {
4898 use crate::model::{DepKind, DepRef, Priority};
4899 use crate::transition::Action;
4900
4901 let mut s = Store::open_in_memory().unwrap();
4902 let p = s.create_project("voro", "/tmp/voro").unwrap();
4903 let new = |title: &str| NewTask {
4904 project_id: p.id,
4905 repo_id: None,
4906 title: title.into(),
4907 body: String::new(),
4908 priority: Priority::P2,
4909 state: TaskState::Ready,
4910 agent: None,
4911 human: false,
4912 deep: false,
4913 };
4914 let blocker = s.create_task(new("blocker")).unwrap();
4915 s.apply(blocker.id, Action::Start).unwrap();
4916 s.apply(blocker.id, Action::Complete(None)).unwrap();
4917 s.apply(blocker.id, Action::Accept).unwrap();
4918 let source = s.create_task(new("source")).unwrap();
4919 let task = s.create_task(new("task")).unwrap();
4920 s.add_dep(task.id, blocker.id, DepKind::Blocks).unwrap();
4921 s.add_dep(task.id, source.id, DepKind::DiscoveredFrom)
4922 .unwrap();
4923
4924 let deps = s.deps_by_task().unwrap();
4927 assert_eq!(
4928 deps[&task.id],
4929 vec![
4930 DepRef {
4931 id: blocker.id,
4932 title: "blocker".into(),
4933 state: TaskState::Done,
4934 kind: DepKind::Blocks,
4935 },
4936 DepRef {
4937 id: source.id,
4938 title: "source".into(),
4939 state: TaskState::Ready,
4940 kind: DepKind::DiscoveredFrom,
4941 },
4942 ]
4943 );
4944 assert!(!deps[&task.id][0].is_open());
4945 assert!(!deps.contains_key(&blocker.id));
4946
4947 let dependents = s.dependents_by_task().unwrap();
4949 assert_eq!(
4950 dependents[&blocker.id],
4951 vec![DepRef {
4952 id: task.id,
4953 title: "task".into(),
4954 state: TaskState::Ready,
4955 kind: DepKind::Blocks,
4956 }]
4957 );
4958 assert_eq!(dependents[&source.id].len(), 1);
4959 assert_eq!(dependents[&source.id][0].kind, DepKind::DiscoveredFrom);
4960 assert!(!dependents.contains_key(&task.id));
4961 }
4962
4963 #[test]
4966 fn a_doc_links_tasks_across_projects_and_answers_both_directions() {
4967 let mut s = Store::open_in_memory().unwrap();
4970 let plan = s.create_project("augere", "/tmp/augere").unwrap();
4971 let other = s.create_project("mote", "/tmp/mote").unwrap();
4972 let doc = s
4973 .create_doc(plan.id, None, "docs/strategy.md", Some("Strategy"))
4974 .unwrap();
4975
4976 let a = s.create_task(new_ready(plan.id)).unwrap();
4977 let b = s.create_task(new_ready(other.id)).unwrap();
4978 let c = s.create_task(new_ready(other.id)).unwrap();
4979 for task in [&a, &b, &c] {
4980 assert!(s.link_doc(task.id, doc.id).unwrap());
4981 }
4982 assert!(!s.link_doc(a.id, doc.id).unwrap());
4984
4985 let derived: Vec<i64> = s
4986 .tasks_for_doc(doc.id)
4987 .unwrap()
4988 .into_iter()
4989 .map(|t| t.id)
4990 .collect();
4991 assert_eq!(derived, vec![a.id, b.id, c.id]);
4992 assert_eq!(s.docs_for_task(b.id).unwrap(), vec![doc.clone()]);
4993 assert_eq!(s.docs_by_task().unwrap()[&c.id], vec![doc.clone()]);
4994
4995 assert!(s.unlink_doc(b.id, doc.id).unwrap());
4996 assert!(!s.unlink_doc(b.id, doc.id).unwrap());
4997 assert_eq!(s.tasks_for_doc(doc.id).unwrap().len(), 2);
4998 assert!(s.docs_for_task(b.id).unwrap().is_empty());
4999 }
5000
5001 #[test]
5002 fn every_doc_link_and_unlink_lands_on_the_task_event_trail() {
5003 let mut s = Store::open_in_memory().unwrap();
5004 let p = s.create_project("voro", "/tmp/voro").unwrap();
5005 let doc = s.create_doc(p.id, None, "docs/DESIGN.md", None).unwrap();
5006 let t = s.create_task(new_ready(p.id)).unwrap();
5007
5008 s.link_doc(t.id, doc.id).unwrap();
5009 s.unlink_doc(t.id, doc.id).unwrap();
5010 s.unlink_doc(t.id, doc.id).unwrap();
5012
5013 let kinds: Vec<String> = s
5014 .events_for(t.id)
5015 .unwrap()
5016 .into_iter()
5017 .map(|e| e.kind)
5018 .collect();
5019 assert_eq!(kinds, vec!["created", "doc-linked", "doc-unlinked"]);
5020 }
5021
5022 #[test]
5023 fn set_task_docs_replaces_the_whole_list_and_logs_both_directions() {
5024 let mut s = Store::open_in_memory().unwrap();
5025 let p = s.create_project("voro", "/tmp/voro").unwrap();
5026 let one = s.create_doc(p.id, None, "docs/a.md", None).unwrap();
5027 let two = s.create_doc(p.id, None, "docs/b.md", None).unwrap();
5028 let t = s.create_task(new_ready(p.id)).unwrap();
5029
5030 s.set_task_docs(t.id, &[one.id]).unwrap();
5031 let now = s.set_task_docs(t.id, &[two.id]).unwrap();
5033 assert_eq!(now, vec![two.clone()]);
5034
5035 let events: Vec<(String, Option<String>)> = s
5036 .events_for(t.id)
5037 .unwrap()
5038 .into_iter()
5039 .map(|e| (e.kind, e.detail))
5040 .collect();
5041 assert_eq!(
5042 events,
5043 vec![
5044 ("created".into(), Some("ready".into())),
5045 ("doc-linked".into(), Some("docs/a.md".into())),
5046 ("doc-unlinked".into(), Some("docs/a.md".into())),
5047 ("doc-linked".into(), Some("docs/b.md".into())),
5048 ]
5049 );
5050
5051 assert!(s.set_task_docs(t.id, &[]).unwrap().is_empty());
5053 }
5054
5055 #[test]
5056 fn an_absolute_path_inside_a_checkout_is_stored_relative_to_it() {
5057 let mut s = Store::open_in_memory().unwrap();
5060 let p = s.create_project("augere", "/tmp/augere").unwrap();
5061 let doc = s
5062 .create_doc(p.id, None, "/tmp/augere/docs/strategy.md", None)
5063 .unwrap();
5064 assert_eq!(doc.location, "docs/strategy.md");
5065 assert_eq!(s.resolve_doc(&doc).unwrap(), "/tmp/augere/docs/strategy.md");
5066
5067 s.set_default_repo_path(p.id, "/srv/augere").unwrap();
5069 assert_eq!(
5070 s.resolve_doc(&s.doc(doc.id).unwrap()).unwrap(),
5071 "/srv/augere/docs/strategy.md"
5072 );
5073 }
5074
5075 #[test]
5076 fn a_relative_doc_resolves_against_the_repo_it_names() {
5077 let mut s = Store::open_in_memory().unwrap();
5078 let p = s.create_project("odm", "/tmp/odm").unwrap();
5079 let oats = s.add_repo(p.id, "oats", "/tmp/oats").unwrap();
5080
5081 let default = s.create_doc(p.id, None, "docs/plan.md", None).unwrap();
5082 assert_eq!(s.resolve_doc(&default).unwrap(), "/tmp/odm/docs/plan.md");
5083
5084 let named = s
5085 .create_doc(p.id, Some(oats.id), "notes/plan.md", None)
5086 .unwrap();
5087 assert_eq!(s.resolve_doc(&named).unwrap(), "/tmp/oats/notes/plan.md");
5088
5089 let nested = s.add_repo(p.id, "inner", "/tmp/odm/vendor").unwrap();
5092 let doc = s
5093 .create_doc(p.id, None, "/tmp/odm/vendor/docs/x.md", None)
5094 .unwrap();
5095 assert_eq!(doc.repo_id, Some(nested.id));
5096 assert_eq!(doc.location, "docs/x.md");
5097 }
5098
5099 #[test]
5100 fn a_url_resolves_verbatim_and_takes_no_repo() {
5101 let mut s = Store::open_in_memory().unwrap();
5102 let p = s.create_project("voro", "/tmp/voro").unwrap();
5103 let repo = s.default_repo(p.id).unwrap();
5104
5105 let doc = s
5106 .create_doc(p.id, None, "https://example.com/plan", Some("Plan"))
5107 .unwrap();
5108 assert!(doc.is_url());
5109 assert!(doc.repo_id.is_none());
5110 assert_eq!(s.resolve_doc(&doc).unwrap(), "https://example.com/plan");
5111 assert_eq!(doc.label(), "Plan");
5112
5113 assert!(
5116 s.create_doc(p.id, Some(repo.id), "https://example.com/other", None)
5117 .is_err()
5118 );
5119 }
5120
5121 #[test]
5122 fn a_doc_outside_every_checkout_stays_absolute() {
5123 let mut s = Store::open_in_memory().unwrap();
5124 let p = s.create_project("voro", "/tmp/voro").unwrap();
5125 let doc = s
5126 .create_doc(p.id, None, "/etc/notes/plan.md", None)
5127 .unwrap();
5128 assert_eq!(doc.location, "/etc/notes/plan.md");
5129 assert!(doc.repo_id.is_none());
5130 assert_eq!(s.resolve_doc(&doc).unwrap(), "/etc/notes/plan.md");
5131 let repo = s.default_repo(p.id).unwrap();
5134 assert!(
5135 s.create_doc(p.id, Some(repo.id), "/etc/notes/other.md", None)
5136 .is_err()
5137 );
5138 }
5139
5140 #[test]
5141 fn a_doc_is_registered_once_per_project_and_labels_itself() {
5142 let mut s = Store::open_in_memory().unwrap();
5143 let p = s.create_project("voro", "/tmp/voro").unwrap();
5144 let other = s.create_project("mote", "/tmp/mote").unwrap();
5145 let doc = s.create_doc(p.id, None, "docs/plan.md", None).unwrap();
5146 assert_eq!(doc.label(), "docs/plan.md");
5148 assert!(s.create_doc(p.id, None, "docs/plan.md", None).is_err());
5149 let twin = s.create_doc(other.id, None, "docs/plan.md", None).unwrap();
5152 assert_eq!(s.docs_at("docs/plan.md").unwrap().len(), 2);
5153 assert_ne!(doc.id, twin.id);
5154 assert_eq!(s.docs(p.id).unwrap(), vec![doc]);
5155 assert!(s.create_doc(p.id, None, " ", None).is_err());
5156 }
5157
5158 #[test]
5159 fn removing_a_doc_unlinks_its_tasks_rather_than_refusing() {
5160 let mut s = Store::open_in_memory().unwrap();
5163 let p = s.create_project("voro", "/tmp/voro").unwrap();
5164 let doc = s.create_doc(p.id, None, "docs/plan.md", None).unwrap();
5165 let a = s.create_task(new_ready(p.id)).unwrap();
5166 let b = s.create_task(new_ready(p.id)).unwrap();
5167 s.link_doc(a.id, doc.id).unwrap();
5168 s.link_doc(b.id, doc.id).unwrap();
5169
5170 let freed = s.delete_doc(doc.id).unwrap();
5171 assert_eq!(freed, vec![a.id, b.id]);
5172 assert!(s.doc(doc.id).is_err());
5173 assert!(s.docs_for_task(a.id).unwrap().is_empty());
5174 assert!(s.docs_by_task().unwrap().is_empty());
5175 assert_eq!(
5176 s.events_for(a.id).unwrap().last().unwrap().kind,
5177 "doc-unlinked"
5178 );
5179 }
5180
5181 #[test]
5182 fn linking_names_a_task_and_a_doc_that_exist() {
5183 let mut s = Store::open_in_memory().unwrap();
5184 let p = s.create_project("voro", "/tmp/voro").unwrap();
5185 let doc = s.create_doc(p.id, None, "docs/plan.md", None).unwrap();
5186 let t = s.create_task(new_ready(p.id)).unwrap();
5187 assert!(s.link_doc(999, doc.id).is_err());
5188 assert!(s.link_doc(t.id, 999).is_err());
5189 assert!(s.set_task_docs(t.id, &[999]).is_err());
5190 assert!(s.docs_for_task(t.id).unwrap().is_empty());
5192 }
5193
5194 #[test]
5197 fn migration_0014_leaves_existing_tasks_untouched() {
5198 let conn = Connection::open_in_memory().unwrap();
5199 for sql in &MIGRATIONS[..13] {
5200 conn.execute_batch(sql).unwrap();
5201 }
5202 conn.pragma_update(None, "user_version", 13).unwrap();
5203 conn.execute("INSERT INTO projects (name) VALUES ('legacy')", [])
5204 .unwrap();
5205 conn.execute(
5206 "INSERT INTO repos (project_id, name, path, is_default)
5207 VALUES (1, 'legacy', '/tmp/legacy', 1)",
5208 [],
5209 )
5210 .unwrap();
5211 conn.execute(
5212 "INSERT INTO tasks (project_id, title, state, state_since, created_at)
5213 VALUES (1, 'old work', 'ready', datetime('now'), datetime('now'))",
5214 [],
5215 )
5216 .unwrap();
5217
5218 let store = Store::from_connection(conn).unwrap();
5219 let task = store.task(1).unwrap();
5220 assert_eq!(task.title, "old work");
5221 assert_eq!(task.state, TaskState::Ready);
5222 assert!(store.all_docs().unwrap().is_empty());
5223 assert!(store.docs_for_task(1).unwrap().is_empty());
5224 }
5225}