Skip to main content

voro_core/
store.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use rusqlite::{Connection, OptionalExtension, params};
5
6use crate::error::{Error, Result};
7use crate::model::{
8    Dep, DepKind, DepRef, 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
35/// Whether a path lies inside a Cargo build directory — a `target` component
36/// followed immediately by a profile. Covers `target/debug/voro`,
37/// `target/release/voro`, and the `target/debug/deps/` binaries the test
38/// harness runs, in a worktree or the primary checkout alike.
39fn 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
49/// Write the journal rows for a migration pass (§5). Migrations applied before
50/// the journal existed are backfilled with a NULL `sql`; what this pass applies
51/// is recorded verbatim, signed with the build that applied it and, on a
52/// protected store, with the consent that let it (§5).
53fn 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
80/// How a build signs the journal: crate version and the running executable's
81/// path, which is what identifies the build behind a divergence. A consented
82/// migration of a protected store appends how the consent was given, so even
83/// a `--yes` override leaves a trace.
84fn 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
92/// The way out of a database carrying a migration this build does not have:
93/// the dev store is rebuilt, the operator's is restored from a snapshot.
94fn 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
110/// The way out of a database whose schema is ahead of this build: the dev
111/// store is rebuilt, the operator's is restored from a snapshot.
112fn 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
129/// Owns the SQLite database. All writes go through this type; task state in
130/// particular is only ever changed by the transition API in `transition.rs`.
131pub struct Store {
132    pub(crate) conn: Connection,
133}
134
135/// Drop every row, leaving the schema in place — the reset behind
136/// `voro seed --force`. The CLI is what confines this to the dev store.
137impl 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/// Initial state for a task created by a human. `proposed` is quick capture;
161/// `parked`/`ready` mean the creator has already triaged their own task.
162#[derive(Debug, Clone)]
163pub struct NewTask {
164    pub project_id: i64,
165    /// The repo the task runs in; `None` resolves to the project's default.
166    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/// Content edits. State is deliberately absent — use `Store::apply`.
177#[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    /// Open with consent to migrate a protected store (§5): the TUI's launch
193    /// prompt and `voro migrate` call this after a human has answered, or with
194    /// `--yes` standing in for one. `consent` says how the consent was given
195    /// and is recorded in the journal's `applied_by`. On an unprotected store
196    /// it changes nothing — migration there never needed asking.
197    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    /// `$XDG_DATA_HOME/voro`, defaulting to `~/.local/share/voro`.
219    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    /// The operator's store (DESIGN.md §5), at a path that does not vary with
233    /// how the running binary was built. Dispatch renders `--db` against it,
234    /// and `voro seed` refuses it.
235    pub fn production_db_path() -> PathBuf {
236        Store::data_dir().join("voro.db")
237    }
238
239    /// The store a build out of a `target/` directory opens instead (DESIGN.md
240    /// §5). Seeded on first open and disposable: `voro seed --force` rebuilds
241    /// it, and deleting it costs nothing.
242    pub fn dev_db_path() -> PathBuf {
243        Store::data_dir().join("dev.db")
244    }
245
246    /// Where snapshots taken before a migration land: beside the database they
247    /// protect, so a store opened with `--db` keeps its own history.
248    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    /// True when this binary was run out of a Cargo `target/` directory rather
256    /// than installed. It picks the default store and bounds nothing:
257    /// `cargo install --path` builds a working checkout, unreleased migrations
258    /// and all, into an ordinary install location, where this reads as an
259    /// install. The journal and the counter (§5) are what protect the schema.
260    pub fn is_dev_build() -> bool {
261        std::env::current_exe().is_ok_and(|exe| path_is_cargo_target(&exe))
262    }
263
264    /// The store a bare `voro` opens: the dev one for a dev build, the
265    /// operator's otherwise.
266    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        // Ahead of the version check, which cannot see a divergence.
302        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            // The consent gate (§5). A store with no schema at all is exempt —
314            // a fresh install creates its database silently, and there is
315            // nothing yet to protect.
316            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    /// Whether this store is the operator's (§5): opened at the production
334    /// path, or carrying the `protected` marker a past open there wrote — how
335    /// the property survives a symlink, a moved data directory, or a restored
336    /// copy. Runs before any migration, so it must read a store from before
337    /// `store_meta` existed, where only the path can answer.
338    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    /// `INSERT OR IGNORE` so an already-marked store takes no write at all:
362    /// opening must not bump `data_version` for connections polling it.
363    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    /// Check the journal (§5) against the migrations this build carries. The
372    /// counter reports a database that is *ahead*; this reports one that is
373    /// *different*, which two branches numbering a migration alike produce.
374    /// History predating the journal has a NULL `sql` and is skipped as
375    /// unverifiable.
376    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            // Indices beyond this build's list are the counter's to report.
395            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    /// The store's `user_version`. An open store always reads as the count of
420    /// migrations its build carries; `voro migrate` reports it when there was
421    /// nothing to apply.
422    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    /// Copy the database beside itself before a migration touches it, since a
429    /// migration that renames or drops a column is not reversible from the
430    /// migrated file alone. A database with no schema yet is skipped, and a
431    /// failure to write the copy is reported rather than fatal.
432    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    /// SQLite's `PRAGMA data_version`, which increments whenever another
459    /// connection commits a change to the database. The value is stable across
460    /// commits made on this connection, so a caller can poll it to detect
461    /// external writes without reacting to its own mutations.
462    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    /// Migrations may rebuild tables (SQLite cannot alter CHECK constraints),
469    /// so foreign-key enforcement is suspended for the duration and integrity
470    /// verified afterwards — the procedure SQLite documents for schema changes.
471    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    // --- projects ---
504
505    /// Create a project and its default repo in one transaction, so a project
506    /// with no checkout is never observable (DESIGN.md §5). The repo is named
507    /// after the project; `voro repo` renames nothing, but `repo add` puts
508    /// further checkouts beside it.
509    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    /// Name the `voro.toml` viewer this project's local diffs open in
555    /// (DESIGN.md §8/§11a). `None` stores NULL — no viewer named, so `open`
556    /// falls back to the config's default viewer.
557    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    /// Archive or unarchive a project (DESIGN.md §5). Archiving hides the
577    /// project and all of its tasks from the cockpit views; the tasks
578    /// themselves are not touched — no state change, no event — so unarchiving
579    /// restores the pre-archive view exactly. Refuses a no-op so a typo'd
580    /// second archive is heard rather than silently absorbed.
581    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    /// Tasks reference a project by id, not name, so renaming is a pure
598    /// label change — no task or dependency is touched.
599    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    /// Re-point a project's *default* repo. This is what `voro project path`
611    /// and the projects screen's path field edit — the single-repo spelling of
612    /// `repo path`, kept because a one-repo project is still the common case.
613    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    /// Delete a project outright — only safe when it has no tasks, since tasks
619    /// reference their project by id and deleting would orphan history. A project
620    /// with tasks in any state refuses; weight 0 snoozes without losing history.
621    /// Its repos go with it: no task references them, so nothing is orphaned.
622    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    // --- repos ---
643    //
644    // A project owns at least one repo, exactly one of which is its default
645    // (DESIGN.md §3/§5). The at-most-one-default half is schema-enforced by a
646    // partial unique index; the rest — never zero repos, no deleting the
647    // default or a referenced one — lives here, the same place the state
648    // machine's invariants live, so no interface can bypass them.
649
650    /// A project's repos, default first, then by name.
651    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    /// A project's repo by name. An unknown name errors listing the project's
672    /// repos, so a filing agent gets a correction rather than a wrong checkout.
673    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                // A project always has a default repo — `create_project` makes
697                // it in the same transaction — so the only way here is an id
698                // that names no project at all.
699                Err(e) => e,
700                Ok(_) => Error::Invalid(format!("project {project_id} has no default repo")),
701            })
702    }
703
704    /// The checkout a task's work runs in (DESIGN.md §8): its own repo when it
705    /// names one, else its project's default. The single resolution point —
706    /// dispatch, `pr`/`open`, worktree cleanup, and `import` all come here
707    /// rather than reading `repo_id` themselves.
708    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    /// Add a repo to a project. The first repo of a project is made by
716    /// `create_project`, so one added here is never the default; `set_default`
717    /// promotes it.
718    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    /// Make a repo its project's default. Clearing the old default and setting
747    /// the new one share a transaction, because the partial unique index would
748    /// otherwise refuse the intermediate state.
749    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    /// Remove a repo, refusing the three ways it would leave the store
762    /// inconsistent: the project's last repo (a project always has a
763    /// checkout), its default while others remain (set a new one first), and
764    /// one any task still names (re-point those tasks first).
765    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    /// Re-point a task at a repo of its own project, or back at the default
797    /// with `None`. A repo belonging to another project is refused — a task's
798    /// checkout is chosen from its project's repos, not from every repo.
799    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    // --- docs ---
830    //
831    // A document is a plan a project's work derives from (DESIGN.md §3/§5).
832    // It is *owned* by one project, which is where a relative location resolves
833    // and where `doc list` shows it, but the task edge is deliberately not
834    // constrained to that project: one strategy doc routinely spawns work
835    // across several, and refusing the cross-project link would defeat the
836    // "which tasks came from this plan?" query the table exists for.
837
838    /// Register a document against a project. `location` is a checkout-relative
839    /// path, an absolute path, or a URL; an absolute path that lies inside one
840    /// of the project's checkouts is stored relative to it (DESIGN.md §5), so
841    /// the link survives the checkout moving. `repo` names which checkout a
842    /// relative path resolves against, `None` meaning the project's default.
843    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    /// Reduce an operator-supplied location to what is stored: a URL verbatim
872    /// (and never against a repo, since it resolves unaided), an absolute path
873    /// relativised against the checkout that contains it, and anything else
874    /// left as given. An explicit `repo_id` pins which checkout is meant, and
875    /// an absolute path outside it is refused rather than silently stored whole.
876    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        // An absolute path: prefer the checkout that contains it, so the stored
909        // location survives that checkout moving. The longest matching path
910        // wins, for the case of a repo nested inside another.
911        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            // An explicit --repo said which checkout to read this path from, so
923            // a path outside it is a mistake worth hearing rather than storing.
924            Some(id) => Err(Error::Invalid(format!(
925                "'{location}' is not inside repo '{}' ({})",
926                self.repo(id)?.name,
927                self.repo(id)?.path
928            ))),
929            // Outside every checkout: a legitimate external document, kept
930            // absolute and resolving against no repo.
931            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    /// A project's documents, oldest first — registration order is the closest
947    /// thing a plan library has to a meaningful one.
948    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    /// Every document with the given location, across projects — what a `--doc`
965    /// flag naming a path rather than an id matches. More than one match is
966    /// returned rather than resolved, so the caller can say which ids collided.
967    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    /// Where a document actually is: a URL or absolute path verbatim, and a
977    /// relative one joined onto its checkout. The single resolution point —
978    /// dispatch and every renderer come here rather than joining paths itself.
979    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    /// Remove a document and every task link to it, in one transaction. Unlike
994    /// a repo, a doc is navigational — nothing resolves to nothing when it goes
995    /// — so this unlinks rather than refusing, and returns the tasks it freed
996    /// so the caller can say how far the removal reached.
997    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    /// Link a task to a document. Returns whether the edge was new, so a
1012    /// repeated link reads as a no-op rather than an error — and logs the link
1013    /// on the task's own event trail only when something changed.
1014    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    /// Replace a task's whole document list — what `set --doc` writes, matching
1041    /// `--blocked-by`'s replace semantics so the flag can remove a link as well
1042    /// as add one. Each added and dropped edge is logged individually.
1043    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 &current {
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    /// The documents a task cites, in registration order.
1074    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    /// Every document link keyed by task id, loaded whole — what the TUI reads
1085    /// once per refresh so the render path never queries the store, the same
1086    /// shape as the dependency maps.
1087    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    /// The tasks derived from a document — the "which tasks came from this
1105    /// plan?" query, in id order so a plan's rollout reads chronologically.
1106    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    // --- tasks ---
1117
1118    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        // An archived project accepts no new work through any door — `add`,
1143        // `propose`, and import all create through here (DESIGN.md §5).
1144        let project = self.project(new.project_id)?;
1145        if project.archived {
1146            return Err(Error::ProjectArchived { name: project.name });
1147        }
1148        // A task's checkout is chosen from its own project's repos; NULL means
1149        // the project default, which is what every task created without one gets.
1150        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        // `needs-input`, `review`, and `stalled` are unreachable for human tasks
1213        // (§6), so a task sitting in one — or one an agent session is still open
1214        // on — is demonstrably agent-executed and cannot be flagged human.
1215        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        // A body edit overwrites the task's whole brief in place, so the log
1257        // keeps the text it replaced (DESIGN.md §8) — the append-only audit
1258        // covering the one field whose loss cannot be reconstructed from state.
1259        // Only a real change is logged, since every `set` lands here.
1260        if edit.body != current.body && !current.body.is_empty() {
1261            log_event(&tx, id, "body", Some(&current.body))?;
1262        }
1263        tx.commit()?;
1264        self.task(id)
1265    }
1266
1267    /// Re-prioritise a task in isolation (DESIGN.md §7). Unlike `update_task`
1268    /// this touches only `priority`, and it logs the change. Task state is left
1269    /// untouched.
1270    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    /// Flag a task as warranting the agent's strongest model, or clear the flag
1283    /// (DESIGN.md §8). Like [`set_priority`] this touches one field and logs the
1284    /// change, so the TUI's toggle needs no full edit; task state is untouched.
1285    /// Refused on a human task, which is never dispatched and so has no model.
1286    ///
1287    /// [`set_priority`]: Store::set_priority
1288    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    /// Track (or, with `None`, untrack) a GitHub PR on a task (DESIGN.md §11c).
1312    /// The URL is stored verbatim — validation is the caller's job — and the
1313    /// change is logged. Leaves task state untouched.
1314    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    /// Record (or, with `None`, clear) the git branch a task's work lives on —
1327    /// the intended name a human sets for dispatch to inject, or the name an
1328    /// agent reports through `voro done --branch`. Stored verbatim (Voro never
1329    /// runs git) and logged; task state is left untouched.
1330    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    /// Set or replace a task's completion summary outside `done` (DESIGN.md §8):
1343    /// append a `summary` event, which [`latest_summary`] supersedes with, so the
1344    /// PR body, detail view, and incomplete-report flag all pick up the newest.
1345    /// This amends a stale PR body or supplies a missing `[incomplete report]`
1346    /// summary without a `reject` → re-`done` round trip. Allowed only on a
1347    /// `running` or `review` task; it never touches `tasks.state`.
1348    ///
1349    /// [`latest_summary`]: Store::latest_summary
1350    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    /// How the newest concluded refine round on a task ended (DESIGN.md §6),
1366    /// read off the `refine` event the `refining → proposed` transition logs.
1367    /// `None` for a task no round has ever concluded on. This is what the two
1368    /// row markers are derived from, so a proposal says which of "reworked" and
1369    /// "the rewrite died" it is rather than leaving the operator to notice an
1370    /// absence.
1371    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    /// Whether `task_id` is a `proposed` task whose last refine round rewrote
1386    /// its body (DESIGN.md §6) — what renders the `↻ refined` marker. Gated on
1387    /// `proposed`, so triaging the task clears it, and on the *concluded* round,
1388    /// so a proposal is only marked once the improved body exists. Derived
1389    /// fresh, never stored.
1390    pub fn refined_flag(&self, task_id: i64) -> Result<bool> {
1391        self.refine_marker(task_id, RefineOutcome::Applied)
1392    }
1393
1394    /// The other half of [`refined_flag`]: a `proposed` task whose last refine
1395    /// round died without applying anything, which renders the `⚠ refine
1396    /// failed` marker. Same lifecycle — shown while `proposed`, cleared by
1397    /// triage — because a failed refine must be visibly different from a
1398    /// proposal nobody has refined.
1399    ///
1400    /// [`refined_flag`]: Store::refined_flag
1401    pub fn refine_failed_flag(&self, task_id: i64) -> Result<bool> {
1402        self.refine_marker(task_id, RefineOutcome::Failed)
1403    }
1404
1405    /// Correct the outcome of a round that concluded `failed` but whose rewrite
1406    /// then arrived anyway (DESIGN.md §6): a body replacement landing on a
1407    /// `proposed` task whose last round failed says that round did apply
1408    /// something, however late, so the recorded outcome is superseded by
1409    /// `applied` and the row's marker flips from `⚠ refine failed` to `↻
1410    /// refined`. A rewritten body sitting under a failure marker is worse than
1411    /// no marker at all: it teaches the operator to disbelieve the one signal
1412    /// that exists to say a rewrite they asked for silently never happened.
1413    ///
1414    /// This corrects a *concluded* round and nothing else — the task neither
1415    /// re-enters `refining` nor transitions, and the round's session keeps the
1416    /// outcome the reconciler observed of its process. Returns whether anything
1417    /// was corrected, so a caller can say so; a no-op on any other state or any
1418    /// other last outcome, and idempotent, since the correction it appends is
1419    /// itself the newest outcome.
1420    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    /// The newest refine note recorded on a task — the note that rode the
1447    /// `proposed → refining` transition — for the seed context a refine agent is
1448    /// launched with and for the detail views.
1449    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    // --- deps ---
1463
1464    /// The task a proposal was discovered from (the `discovered-from` edge of
1465    /// §5), if any — the context a sloppy proposal is usually missing, which is
1466    /// what a refine session is seeded with. The newest edge wins if a task
1467    /// somehow carries several.
1468    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    /// Drop one edge. The kind is part of the identity of an edge — a pair may
1508    /// carry several — so removing a blocker must not take the
1509    /// `discovered-from` edge beside it with it.
1510    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    /// Every dependency edge of every kind, keyed by the depending task and
1529    /// resolved to the dependency's current title and state — the forward
1530    /// direction a detail view renders as `blocked by #N`. One query feeds every
1531    /// pane, so the render path never issues a per-row lookup.
1532    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    /// The reverse edges: every dependency keyed by the task depended *on*,
1541    /// resolved to the depending task — who a task blocks (or spawned).
1542    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    // --- sessions ---
1586
1587    /// Open a session for a running task, stamping `started_at`. `ended_at` and
1588    /// `outcome` stay NULL until [`end_session`](Store::end_session).
1589    /// `liveness_source` is which source reconciliation must read the session by
1590    /// (DESIGN.md §8), which only the caller that spawned the process knows.
1591    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    /// Record the agent's own reference for a session, captured
1604    /// after launch — the row necessarily exists before the reference does,
1605    /// so this is an update rather than a `create_session` parameter.
1606    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    /// Record what a confirmed headless send did to a session (DESIGN.md §8):
1618    /// the process now carrying the turn, and — where the agent forked rather
1619    /// than resumed in place — the reference the conversation continues under.
1620    /// One statement, so a reconcile in another window never reads the new
1621    /// reference beside the old process or the reverse.
1622    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    /// Close a session with its outcome, stamping `ended_at`.
1640    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    /// Every task's newest session, keyed by task id, in one query — what the
1667    /// TUI loads per refresh to answer "what is/was this session doing?" without
1668    /// querying the store mid-draw. Session ids are monotonic, so `max(id)` is
1669    /// the latest.
1670    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    /// Sessions that have not yet ended, newest first.
1682    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    /// Whether `task_id` is a `review` task carrying a *half-written* completion
1691    /// report — a branch with no summary (DESIGN.md §8). A summary with no
1692    /// branch is not flagged: an investigation, triage or audit produces no code
1693    /// and its summary is the whole deliverable. Gated on `review`, derived
1694    /// fresh rather than stored.
1695    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    /// Rows for the cockpit's running strip (DESIGN.md §9): every `running`,
1716    /// `refining`, or `waiting` task, joined with its open session if it has
1717    /// one. The strip filters on task *state*, so `review`/`needs-input` tasks
1718    /// (session still open) do not appear, while a refine in flight and a
1719    /// handed-off task both do — an open session does not imply executing
1720    /// the task (§8), and what the strip shows is work in flight that someone
1721    /// else owns. A hand-started task with no session shows with `session_id`/
1722    /// `agent` `NULL`. The one-open-session invariant (§8) bounds the join to one
1723    /// row per task; elapsed is computed in SQL so the TUI only formats it.
1724    ///
1725    /// A `waiting` task measures its elapsed from `state_since` rather than its
1726    /// session: the session opened when the agent started the work, long before
1727    /// the hand-off, and what the operator wants from a handed-off row is how
1728    /// long it has been waiting. Waiting rows sort after the rest, since they
1729    /// are the ones nobody is actively typing into.
1730    ///
1731    /// Archived projects leave the cockpit entirely (§5), the strip included.
1732    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    // --- events ---
1766
1767    /// The most recent completion summary a task recorded (DESIGN.md §8): the
1768    /// detail of its newest `summary` event, logged by `done --summary` or
1769    /// amended by `set --summary` ([`set_summary`]). This is the PR body when
1770    /// `pr` opens a pull request. `None` when the task never carried a summary.
1771    ///
1772    /// [`set_summary`]: Store::set_summary
1773    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    /// Record the branch revision the operator has just reviewed (DESIGN.md
1787    /// §8), so the next look at this task can be narrowed to what the rework
1788    /// added. Written at rejection, when the head is exactly what was judged.
1789    /// The `events` table carries it, so delta re-review costs no column and no
1790    /// migration; a later recording supersedes an earlier one the way a summary
1791    /// does.
1792    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        // Prove the task exists before appending, so a typo'd id leaves no
1798        // orphan row in an append-only log.
1799        self.task(id)?;
1800        log_event(&self.conn, id, crate::review::REVIEWED_EVENT, Some(sha))
1801    }
1802
1803    /// The revision recorded by the newest [`record_reviewed`], or `None` for a
1804    /// task nobody has reviewed and sent back — which is what keeps a first
1805    /// review showing the whole diff.
1806    ///
1807    /// [`record_reviewed`]: Store::record_reviewed
1808    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
1875/// A task's currently-open session, if it has one. The one-open-session
1876/// invariant (DESIGN.md §8) means there is at most one row to find, so this is
1877/// how a transaction learns *which* session it is about to close.
1878pub(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
1934/// The same projection read from a wider row — a join that carries the task id
1935/// alongside the doc columns.
1936fn 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
1947/// Qualify a column list with a table alias, so a joined query can reuse the
1948/// same projection constant its unjoined sibling does.
1949fn 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
1969/// Insert a session row, stamping `started_at`, and return its id. Shared by
1970/// [`Store::create_session`] and the dispatch transaction.
1971/// Enforces the one-open-session invariant (DESIGN.md §8): opening a new session
1972/// first closes any predecessor still open (stamped `aborted`). The partial
1973/// unique index is the schema-level backstop.
1974pub(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
1991/// Close a task's currently-open session, if any, stamping `ended_at` and
1992/// `outcome`. The one-open-session invariant (DESIGN.md §8) means this touches
1993/// at most one row. Used to supersede a predecessor and to close the session on
1994/// a terminal transition. Returns the number of rows closed (0 if none was open).
1995pub(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
2007/// Stamp `ended_at` and record `outcome` on a session, returning the number of
2008/// rows changed (0 if the id is unknown). Shared by [`Store::end_session`] and
2009/// reconciliation.
2010pub(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
2034/// An audit row for a mutation that belongs to no single task — registering or
2035/// removing a document. The `events.task_id` column is nullable exactly for
2036/// this, and the append-only log stays the record of every mutation.
2037pub(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    /// A unique scratch directory per test, cleaned up by the caller.
2051    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        // The remedy is the point of the error: a version mismatch the operator
2100        // cannot act on is the cryptic failure this guard exists to replace.
2101        // Restoring leads, since running the build that migrated it entrenches
2102        // an unreleased schema.
2103        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        // A store one migration short of current, so opening it migrates.
2122        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        // The snapshot is the state *before* the migration, which is the only
2137        // thing that makes it worth keeping.
2138        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    /// The case the counter cannot see: two branches each author a migration
2147    /// at the same index, so the database and the binary agree on the version
2148    /// and disagree on the schema.
2149    #[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        // Rewrite the last applied migration as a rival branch's version of it,
2155        // leaving user_version untouched — exactly what a colliding 0017 does.
2156        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        // The counter alone would have said nothing here.
2173        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    /// History from before the journal existed is recorded as unverifiable
2207    /// rather than invented, and must not read as a divergence.
2208    #[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    /// A store one migration short of current, built by replaying the list —
2245    /// the state a release upgrade or a from-source build finds the operator's
2246    /// store in.
2247    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        // Refused means untouched: no migration applied, no snapshot taken.
2273        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        // The snapshot still precedes a consented migration.
2304        assert!(Store::backup_dir_for(&path).exists());
2305        std::fs::remove_dir_all(&dir).ok();
2306    }
2307
2308    /// The marker, not the path, is what makes a moved or restored copy of the
2309    /// operator's store keep refusing (§5).
2310    #[test]
2311    fn the_protected_marker_travels_with_the_file() {
2312        let dir = scratch("gate-marker");
2313        let path = dir.join("voro.db");
2314        // A full open at its "production" path writes the marker.
2315        open_as_production(&path, None).unwrap();
2316        let moved = dir.join("restored-copy.db");
2317        std::fs::rename(&path, &moved).unwrap();
2318        // Winding the copy back one version makes it pending again; the gate
2319        // fires before any migration would re-apply, so the state is enough.
2320        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    /// An unprotected store — scratch `--db`, the dev store — migrates on open
2350    /// exactly as before the gate existed.
2351    #[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        // the task still resolves to the same project by id, under its new name
2405        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        // Naming no viewer writes NULL, so the column reads back empty
2422        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        // A blank name is a typo, not a way to clear the viewer
2433        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    /// A database from before migration 0018 carries review actions in the
2444    /// pre-split spellings (DESIGN.md §5/§8). Opening it must keep the viewer a
2445    /// project named and read the three spellings that named none as none.
2446    #[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        // state is untouched by tracking a PR
2516        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        // priority is changed in isolation; state is untouched
2548        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        // recording a branch never touches task state
2583        assert_eq!(named.state, TaskState::Ready);
2584
2585        // reporting a different branch overwrites the intended one
2586        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    // --- the human flag and the agent override are mutually exclusive (§3/§6) ---
2602
2603    /// A store, a project, and a `NewTask` builder for the human-flag tests.
2604    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    /// The body is the one field an edit overwrites wholesale, so the log keeps
2648    /// what each edit replaced (DESIGN.md §8) — and only that, since every `set`
2649    /// passes through here whether or not it touched the body.
2650    #[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        // an empty body destroys nothing on its way out
2656        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        // an edit that leaves the body alone logs nothing either
2669        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        // an agent override cannot land on a human task
2702        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        // ...and the flag cannot land while an override is kept
2709        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        // clearing the override in the same edit is the designed way through
2716        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        // needs-input, review, and stalled are unreachable for human tasks
2728        // (§6), so a task already sitting there cannot be flagged as one.
2729        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        // once the session is torn down the flip is allowed again
2771        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        // a hand-started running task has no session and can flip freely
2776        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    // --- the deep flag ---
2786
2787    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    /// Deep only selects a dispatch model, so it is refused on a task no agent
2817    /// can execute — through every door that can set it.
2818    #[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        // clearing the flag on a human task is always allowed
2839        assert!(!s.set_deep(human.id, false).unwrap().deep);
2840    }
2841
2842    /// A database from before migration 0013 must open with every existing
2843    /// task on the workhorse (`deep = 0`), and the CHECK must reject junk.
2844    #[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    /// A database from before migration 0007 must open with every existing
2857    /// task dispatchable (`human = 0`), and the CHECK must reject junk.
2858    #[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        // a running task may record its account before `done`
2904        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        // in review, a new summary supersedes the done-time one
2913        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        // every account stays on the append-only log
2923        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        // The SessionEnd-fallback shape: review with a branch and no summary.
2933        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    // --- repos (DESIGN.md §3/§5) ---
2993
2994    #[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        // The same repo name under a different project is fine.
3015        let other = s.create_project("voro", "/tmp/voro").unwrap();
3016        assert!(s.add_repo(other.id, "oats", "/tmp/oats").is_ok());
3017        // Default first, then by name.
3018        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        // Promoting the other one first clears the way.
3061        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        // Re-pointing the task at the default frees the repo.
3079        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        // The fallback follows the default, not the original checkout.
3096        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    /// Walk a fresh task into `state` through the transition API, mirroring
3172    /// the equivalent helper in `transition.rs`'s own tests.
3173    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            // the refusal must not have touched the project
3259            assert!(s.project(p.id).is_ok());
3260        }
3261    }
3262
3263    // --- archiving a project (DESIGN.md §5) ---
3264
3265    #[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        // a second archive is heard, not absorbed
3276        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        // every creation door — add, propose, import — routes through here
3297        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        // Archiving transitions nothing: every task keeps its state, question,
3338        // and event log, so unarchiving restores the pre-archive view exactly.
3339        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        // archiving hides the strip row; the task itself stays running
3373        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    /// A database from before migration 0011 must open with every existing
3382    /// project active (`archived = 0`), and the CHECK must reject junk.
3383    #[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    /// A database from before migration 0012 must convert in place: every
3403    /// project's old `path` reappears as its default repo, `projects.path` is
3404    /// gone, and existing tasks (all `repo_id` NULL) resolve to exactly the
3405    /// checkouts they had before (DESIGN.md §3/§5).
3406    #[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        // The task kept its checkout without naming a repo.
3434        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        // The column is gone, not merely unread.
3439        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        // The one-default invariant is schema-enforced from here on.
3447        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    /// A database from before migration 0015 must open with every dependency
3460    /// edge intact, and accept a second edge of another kind between a pair the
3461    /// old primary key allowed only one edge for.
3462    #[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    /// A database created at schema version 1 (state still named 'backlog')
3501    /// must convert on open: rows renamed, deps/events surviving the table
3502    /// rebuild, version stamped.
3503    #[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        // the event log is history and keeps its original wording
3531        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        // 0004 gave the sessions table its session_ref column
3541        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    /// Migration 0006 must dedupe a task that already carries several open
3549    /// sessions — keeping the newest open and closing the rest — before it can
3550    /// create the one-open-session index, and the index must then reject any
3551    /// further second open row.
3552    #[test]
3553    fn migration_0006_dedupes_open_sessions_and_enforces_the_index() {
3554        let conn = Connection::open_in_memory().unwrap();
3555        // apply 0001..=0005, i.e. everything before the invariant migration
3556        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        // three open sessions on the one task — the exact duplicate state
3569        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        // only the newest open session survives; the rest are closed `aborted`
3579        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        // and the index now forbids a second open row
3592        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    /// Migration 0008 must backfill exactly the tasks the derived redispatch
3600    /// flag used to mark — `ready` with a most recent session ended
3601    /// `failed`/`capped` — into `stalled`, leaving every other shape alone,
3602    /// and must carry 0007's `human` column through the table rebuild.
3603    #[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        // 1: ready, last session failed          -> stalled
3613        // 2: ready, last session capped          -> stalled
3614        // 3: ready, last session aborted         -> stays ready
3615        // 4: ready, failed session then aborted  -> stays ready (latest wins)
3616        // 5: ready, no sessions                  -> stays ready
3617        // 6: running, last session failed        -> stays running
3618        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        // the rebuild carries the human flag and its CHECK across
3651        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    /// Migration 0010 must extend the state CHECK to admit 'waiting' while
3660    /// carrying every existing task through the table rebuild untouched.
3661    #[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        // the pre-existing row survives the rebuild with every column intact
3681        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        // the widened CHECK now admits 'waiting' and still rejects junk
3688        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    /// A database from before migration 0016 must open with every existing task
3709    /// intact, and the widened CHECK must admit `refining` while still rejecting
3710    /// junk (DESIGN.md §6).
3711    #[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        // the pre-existing row survives the rebuild with every column intact
3737        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    /// A project + running task to hang sessions off of.
3766    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    /// `events_for` must return the audit trail oldest-first (newest last),
3787    /// since that's the order the history popup renders it in.
3788    #[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        // ids strictly increase with insertion order
3818        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        // a reject-then-redo records a second summary; the newest wins
3851        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    /// The reviewed revision is what delta re-review compares against
3861    /// (DESIGN.md §8): absent until the operator sends work back, superseded by
3862    /// each later rejection, and carried on the event log alone.
3863    #[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        // and it never touches task state
3890        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        // Helper: a fresh task carried to `review` with the given branch/summary.
3898        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        // The still-anomalous half report: a branch but no summary — the classic
3924        // forgotten-summary flake and the shape the SessionEnd fallback leaves.
3925        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        // The legitimate no-code report: a summary and no branch, as an
3932        // investigation, triage or audit ends. The summary is the deliverable,
3933        // so this is a complete report and must not be flagged.
3934        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        // Both present — a complete report, not an anomaly.
3941        let (s, id) = reviewed(Some("feat/x"), Some("did the thing"));
3942        assert!(!s.incomplete_report_flag(id).unwrap());
3943
3944        // Neither present — a legitimate no-artifact (e.g. planning) task.
3945        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        // A partial report only counts once the task is in `review`: a running
3954        // task with an intended branch and no summary yet is mid-flight, not a
3955        // finished-but-incomplete report.
3956        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        // Only on reaching review does the missing summary become an anomaly.
3978        s.apply(t.id, Action::Complete(None)).unwrap();
3979        assert!(s.incomplete_report_flag(t.id).unwrap(), "review");
3980
3981        // Accepting past review clears it — no PR is opened from `done`.
3982        s.apply(t.id, Action::Accept).unwrap();
3983        assert!(!s.incomplete_report_flag(t.id).unwrap(), "done");
3984    }
3985
3986    /// A proposal, its priority and deps recorded so a refine can be shown to
3987    /// leave both alone.
3988    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    /// A refine round launch (DESIGN.md §6): the note rides the transition, the
4008    /// task leaves the triage queue for `refining`, and everything else about
4009    /// it — priority, deps, the body being rewritten — is untouched.
4010    #[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        // Neither marker shows while the round is in flight — there is nothing
4041        // to say about a rewrite that has not happened yet.
4042        assert!(!s.refined_flag(t.id).unwrap());
4043        assert!(!s.refine_failed_flag(t.id).unwrap());
4044    }
4045
4046    /// The interactive flavour carries no note, so nothing is logged for one —
4047    /// the brief is the conversation itself.
4048    #[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    /// Each conclusion picks the marker the returned proposal carries, and the
4059    /// newest round wins — a failed round after a successful one says so.
4060    #[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        // Triage is what clears the markers — both are gated on `proposed`.
4083        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    /// The late-rewrite backstop (DESIGN.md §6): a round concluded `failed`
4089    /// whose rewrite lands afterwards has its outcome corrected to applied, so
4090    /// the improved body is not read under a marker saying no rewrite happened.
4091    #[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        // A correction transitions nothing and reopens nothing.
4108        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        // Idempotent: the correction is itself the newest outcome.
4115        assert!(!s.correct_late_refine(t.id).unwrap());
4116    }
4117
4118    /// The correction is confined to the one case it exists for: any other last
4119    /// outcome, and any state but `proposed`, is left alone.
4120    #[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        // A task nobody ever refined, and a failed round already triaged away.
4136        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    /// A concluded round closes its session with the matching outcome, whichever
4153    /// trigger fired (DESIGN.md §6/§8).
4154    #[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    /// The round's guard rails: a task past `proposed`/`ready` cannot start
4175    /// one, and one that is not `refining` cannot conclude one.
4176    #[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        // The refused launch wrote nothing — no session, no state change.
4193        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        // A plain blocker is not a parent: only `discovered-from` carries the
4211        // context a proposal was written against.
4212        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    /// `latest_sessions` maps each task to its newest session only, and tasks
4253    /// with no session history stay absent.
4254    #[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    /// Each launch records which source reconciliation must read it by
4327    /// (DESIGN.md §8), and it survives the round trip: a headless
4328    /// launch under a supervisor is listing-authoritative, an interactive round
4329    /// is not, and neither is inferred from anything else on the row.
4330    #[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        // A ref captured later says nothing about the source: that was decided
4353        // at launch, which is the whole point of recording it.
4354        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    /// The dispatch and refine transactions carry the flavour through to the
4362    /// row they open, so the reconciler reads what the launcher spawned.
4363    #[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    /// A database from before migration 0017 must open with every existing
4426    /// session listing-authoritative — what a dispatch of an agent with a
4427    /// `sessions` verb already was, and the direction that leaves a session
4428    /// alone rather than finalising a live one — and the CHECK must reject a
4429    /// source that is neither.
4430    #[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    /// A confirmed send moves the session's process to the one carrying the
4472    /// turn, and follows the fork when the agent opened a new reference — but a
4473    /// send that resumed in place must not blank the reference it already had.
4474    #[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        // allow a couple of seconds of test-execution slack either side
4598        assert!(
4599            (85..=95).contains(&rows[0].elapsed_secs),
4600            "expected ~90s elapsed, got {}",
4601            rows[0].elapsed_secs
4602        );
4603    }
4604
4605    /// A task can be `running` with no live session — started by hand, so no
4606    /// session was ever opened. The running strip must still surface it
4607    /// (DESIGN.md §9), with no session id or agent and elapsed measured from
4608    /// when it entered `running`.
4609    #[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    /// A running task whose only session has ended is session-less too, so it
4634    /// stays visible rather than dropping off the strip.
4635    #[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    /// Live sessions sort ahead of session-less running tasks, so what an agent
4651    /// is actively driving stays at the top of the strip.
4652    #[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    /// A refine round is work under way, so it rides the strip beside dispatched
4670    /// tasks — with the round's session and the elapsed time since it opened —
4671    /// and leaves it the moment the round concludes (DESIGN.md §6/§9).
4672    #[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    /// A hand-off is work in flight someone else owns, so it rides the strip
4699    /// too (DESIGN.md §9) — but its elapsed counts from the hand-off rather
4700    /// than from the session, which opened when the agent started the work and
4701    /// says nothing about how long the PR has been sitting there.
4702    #[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    /// Work an agent is driving sorts ahead of the hand-offs, which are the
4757    /// rows nobody is typing into.
4758    #[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    /// Archiving retires the whole project from the cockpit (DESIGN.md §5), and
4777    /// the strip's newest row kind is no exception.
4778    #[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    /// The strip filters on task state: a task that has left `running` —
4791    /// review, done, rejected — never renders, even a `review` task whose
4792    /// session is deliberately still open (DESIGN.md §8/§9).
4793    #[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        // review keeps its session open, yet must not appear in the strip
4810        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        // done and rejected have their sessions closed by the transition
4817        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    /// A `done` task left carrying an open session must stay out of the strip
4839    /// purely on its state.
4840    #[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    /// A unique scratch database path under the OS temp dir.
4866    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        // Our own writes must not move the version this connection observes.
4884        a.create_project("alpha", "/tmp/alpha").unwrap();
4885        assert_eq!(a.data_version().unwrap(), start);
4886
4887        // A commit from another connection must move it.
4888        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        // Forward: the task's own deps, every kind, resolved to the
4925        // dependency's title and state.
4926        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        // Reverse: keyed by the task depended on, resolving the dependant.
4948        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    // --- docs (DESIGN.md §3/§5) ---
4964
4965    #[test]
4966    fn a_doc_links_tasks_across_projects_and_answers_both_directions() {
4967        // The case the table exists for: one plan doc spawning work in several
4968        // projects, so the link cannot be constrained to the doc's own project.
4969        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        // A repeated link is a no-op rather than an error.
4983        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        // A no-op link writes nothing, so the trail records changes only.
5011        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        // Replace, not append: `a` goes as `b` arrives.
5032        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        // Clearing the list is the empty replacement.
5052        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        // Storing it relative is what makes the link survive the checkout
5058        // moving, which is why an operator may paste an absolute path.
5059        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        // ...and it follows the checkout when that moves.
5068        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        // The longest containing checkout wins for an absolute path, so a repo
5090        // nested inside another is not swallowed by its parent.
5091        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        // A URL resolves on its own, so pairing it with a checkout is refused
5114        // rather than silently ignored.
5115        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        // With --repo given, though, a path outside it is a mistake, not an
5132        // external document.
5133        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        // With no title, the location is the only name it has.
5147        assert_eq!(doc.label(), "docs/plan.md");
5148        assert!(s.create_doc(p.id, None, "docs/plan.md", None).is_err());
5149        // The same relative location under another project is a different doc,
5150        // since it resolves against that project's checkout.
5151        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        // Unlike a repo, a doc is navigational — nothing resolves to nothing
5161        // when it goes — so removal frees its links instead of being refused.
5162        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        // A refused replacement leaves the list as it was.
5191        assert!(s.docs_for_task(t.id).unwrap().is_empty());
5192    }
5193
5194    /// A database from before migration 0014 must open with every existing
5195    /// task intact and no documents registered — docs are purely additive.
5196    #[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}