From 717116d76de34cbf9b48210a9e90b02154fdef0b Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:18:08 -0500 Subject: [PATCH 01/19] feat: add db package with SQLite schema and connection pool Co-Authored-By: Claude Opus 4.6 --- cli/go.mod | 16 +++ cli/go.sum | 51 +++++++++ cli/internal/db/db.go | 150 ++++++++++++++++++++++++ cli/internal/db/db_test.go | 75 ++++++++++++ cli/internal/db/schema.go | 219 ++++++++++++++++++++++++++++++++++++ cli/internal/db/testutil.go | 15 +++ 6 files changed, 526 insertions(+) create mode 100644 cli/go.sum create mode 100644 cli/internal/db/db.go create mode 100644 cli/internal/db/db_test.go create mode 100644 cli/internal/db/schema.go create mode 100644 cli/internal/db/testutil.go diff --git a/cli/go.mod b/cli/go.mod index bb662c0..182035b 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,3 +1,19 @@ module github.com/justinjdev/fellowship/cli go 1.25.5 + +require zombiezen.com/go/sqlite v1.4.2 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.33.0 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.37.1 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..56f4fe4 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo= +zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc= diff --git a/cli/internal/db/db.go b/cli/internal/db/db.go new file mode 100644 index 0000000..f05a577 --- /dev/null +++ b/cli/internal/db/db.go @@ -0,0 +1,150 @@ +package db + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "strings" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +// Conn is an alias for sqlite.Conn to simplify consumer imports. +type Conn = sqlite.Conn + +// DB manages a SQLite connection pool for fellowship state. +type DB struct { + pool *sqlitex.Pool + path string +} + +// Open resolves the main repo from fromDir (via git rev-parse --git-common-dir), +// locates /.fellowship/fellowship.db, and opens a connection pool. +func Open(fromDir string) (*DB, error) { + mainRepo, err := resolveMainRepo(fromDir) + if err != nil { + return nil, fmt.Errorf("db: resolve main repo: %w", err) + } + dbPath := filepath.Join(mainRepo, ".fellowship", "fellowship.db") + return OpenPath(dbPath) +} + +// OpenPath opens a DB at the given file path. +func OpenPath(dbPath string) (*DB, error) { + pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{ + PoolSize: 1, + Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenWAL | sqlite.OpenNoMutex, + }) + if err != nil { + return nil, fmt.Errorf("db: open %s: %w", dbPath, err) + } + + d := &DB{pool: pool, path: dbPath} + + // Enable foreign keys and apply schema. + if err := d.WithConn(context.Background(), func(conn *Conn) error { + if err := sqlitex.ExecuteTransient(conn, "PRAGMA foreign_keys = ON", nil); err != nil { + return err + } + if err := sqlitex.ExecuteTransient(conn, "PRAGMA journal_mode = WAL", nil); err != nil { + return err + } + return applySchema(conn) + }); err != nil { + pool.Close() + return nil, err + } + + return d, nil +} + +// OpenMemory opens an in-memory DB with the full schema applied. +func OpenMemory() (*DB, error) { + pool, err := sqlitex.NewPool("file::memory:?mode=memory", sqlitex.PoolOptions{ + PoolSize: 1, + Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenMemory | sqlite.OpenNoMutex, + }) + if err != nil { + return nil, fmt.Errorf("db: open memory: %w", err) + } + + d := &DB{pool: pool, path: ":memory:"} + + if err := d.WithConn(context.Background(), func(conn *Conn) error { + if err := sqlitex.ExecuteTransient(conn, "PRAGMA foreign_keys = ON", nil); err != nil { + return err + } + return applySchema(conn) + }); err != nil { + pool.Close() + return nil, err + } + + return d, nil +} + +// Close releases all connections in the pool. +func (d *DB) Close() error { + return d.pool.Close() +} + +// Path returns the database file path (":memory:" for in-memory DBs). +func (d *DB) Path() string { + return d.path +} + +// WithConn borrows a connection from the pool, calls fn, and returns it. +func (d *DB) WithConn(ctx context.Context, fn func(conn *Conn) error) error { + conn, err := d.pool.Take(ctx) + if err != nil { + return fmt.Errorf("db: take conn: %w", err) + } + defer d.pool.Put(conn) + + // Ensure foreign keys are enabled per-connection. + if err := sqlitex.ExecuteTransient(conn, "PRAGMA foreign_keys = ON", nil); err != nil { + return err + } + + return fn(conn) +} + +// WithTx runs fn inside an IMMEDIATE transaction. If fn returns an error, +// the transaction is rolled back; otherwise it is committed. +func (d *DB) WithTx(ctx context.Context, fn func(conn *Conn) error) error { + return d.WithConn(ctx, func(conn *Conn) error { + endFn, err := sqlitex.ImmediateTransaction(conn) + if err != nil { + return fmt.Errorf("db: begin tx: %w", err) + } + fnErr := fn(conn) + endFn(&fnErr) + return fnErr + }) +} + +// resolveMainRepo returns the main repo root from any worktree or the main repo itself. +func resolveMainRepo(fromDir string) (string, error) { + cmd := exec.Command("git", "rev-parse", "--git-common-dir") + cmd.Dir = fromDir + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git rev-parse --git-common-dir: %w", err) + } + gitCommon := strings.TrimSpace(string(out)) + + // --git-common-dir returns absolute or relative path to the shared .git dir. + if !filepath.IsAbs(gitCommon) { + gitCommon = filepath.Join(fromDir, gitCommon) + } + gitCommon = filepath.Clean(gitCommon) + + // The main repo root is the parent of the .git directory. + if filepath.Base(gitCommon) == ".git" { + return filepath.Dir(gitCommon), nil + } + // For bare repos or unusual layouts, go up one level. + return filepath.Dir(gitCommon), nil +} diff --git a/cli/internal/db/db_test.go b/cli/internal/db/db_test.go new file mode 100644 index 0000000..d30a0a2 --- /dev/null +++ b/cli/internal/db/db_test.go @@ -0,0 +1,75 @@ +package db + +import ( + "context" + "fmt" + "testing" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +func TestOpenMemory(t *testing.T) { + d, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer d.Close() + + // Verify schema was applied — quest_state table should exist + err = d.WithConn(context.Background(), func(conn *Conn) error { + return sqlitex.Execute(conn, "SELECT count(*) FROM quest_state", &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + _ = stmt.ColumnInt(0) + return nil + }, + }) + }) + if err != nil { + t.Fatalf("schema not applied: %v", err) + } +} + +func TestOpenMemory_ForeignKeys(t *testing.T) { + d, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer d.Close() + + // Foreign keys should be enforced + err = d.WithConn(context.Background(), func(conn *Conn) error { + return sqlitex.Execute(conn, `INSERT INTO quest_phases (quest_name, phase, completed_at) VALUES ('nonexistent', 'Research', '2026-01-01T00:00:00Z')`, nil) + }) + if err == nil { + t.Fatal("expected FK violation error, got nil") + } +} + +func TestWithTx_Rollback(t *testing.T) { + d, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer d.Close() + + // Insert a row, then roll back + _ = d.WithTx(context.Background(), func(conn *Conn) error { + sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, phase, created_at, updated_at) VALUES ('test', 'Onboard', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil) + return fmt.Errorf("rollback") + }) + + // Row should not exist + var count int + d.WithConn(context.Background(), func(conn *Conn) error { + return sqlitex.Execute(conn, "SELECT count(*) FROM quest_state", &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + count = stmt.ColumnInt(0) + return nil + }, + }) + }) + if count != 0 { + t.Fatalf("expected 0 rows after rollback, got %d", count) + } +} diff --git a/cli/internal/db/schema.go b/cli/internal/db/schema.go new file mode 100644 index 0000000..789b1e9 --- /dev/null +++ b/cli/internal/db/schema.go @@ -0,0 +1,219 @@ +package db + +import ( + "fmt" + + "zombiezen.com/go/sqlite/sqlitex" +) + +const schemaVersion = 1 + +// Schema contains all CREATE TABLE, INDEX, and TRIGGER statements. +var schema = []string{ + // Quest state (replaces quest-state.json) + `CREATE TABLE IF NOT EXISTS quest_state ( + quest_name TEXT PRIMARY KEY, + task_id TEXT, + team_name TEXT, + phase TEXT NOT NULL DEFAULT 'Onboard', + gate_pending INTEGER NOT NULL DEFAULT 0, + gate_id TEXT, + lembas_completed INTEGER NOT NULL DEFAULT 0, + metadata_updated INTEGER NOT NULL DEFAULT 0, + held INTEGER NOT NULL DEFAULT 0, + held_reason TEXT, + auto_approve TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + + // Phase history (replaces quest-tome.json phases) + `CREATE TABLE IF NOT EXISTS quest_phases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quest_name TEXT NOT NULL REFERENCES quest_state(quest_name), + phase TEXT NOT NULL, + completed_at TEXT NOT NULL, + duration_s INTEGER + )`, + + // Gate history (replaces quest-tome.json gates) + `CREATE TABLE IF NOT EXISTS quest_gates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quest_name TEXT NOT NULL REFERENCES quest_state(quest_name), + phase TEXT NOT NULL, + action TEXT NOT NULL, + timestamp TEXT NOT NULL, + reason TEXT + )`, + + // Files touched per quest (replaces quest-tome.json files) + `CREATE TABLE IF NOT EXISTS quest_files ( + quest_name TEXT NOT NULL REFERENCES quest_state(quest_name), + file_path TEXT NOT NULL, + PRIMARY KEY (quest_name, file_path) + )`, + + // Errands (replaces quest-errands.json) + `CREATE TABLE IF NOT EXISTS errands ( + id TEXT NOT NULL, + quest_name TEXT NOT NULL REFERENCES quest_state(quest_name), + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + phase TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (quest_name, id) + )`, + + `CREATE TABLE IF NOT EXISTS errand_deps ( + quest_name TEXT NOT NULL, + errand_id TEXT NOT NULL, + depends_on TEXT NOT NULL, + PRIMARY KEY (quest_name, errand_id, depends_on), + FOREIGN KEY (quest_name, errand_id) REFERENCES errands(quest_name, id) + )`, + + // Herald event log (replaces quest-herald.jsonl) + // No FK to quest_state — events logged before quest exists and survive deletion. + `CREATE TABLE IF NOT EXISTS herald ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + quest TEXT NOT NULL, + type TEXT NOT NULL, + phase TEXT, + detail TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_herald_quest ON herald(quest, type)`, + `CREATE INDEX IF NOT EXISTS idx_herald_ts ON herald(timestamp)`, + + // Fellowship orchestration (replaces fellowship-state.json) + `CREATE TABLE IF NOT EXISTS fellowship ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version TEXT NOT NULL, + name TEXT NOT NULL, + main_repo TEXT NOT NULL, + base_branch TEXT NOT NULL DEFAULT 'main', + created_at TEXT NOT NULL + )`, + + `CREATE TABLE IF NOT EXISTS fellowship_quests ( + name TEXT PRIMARY KEY, + task_description TEXT, + worktree TEXT, + branch TEXT, + task_id TEXT, + status TEXT DEFAULT 'active', + respawns INTEGER NOT NULL DEFAULT 0 + )`, + + `CREATE TABLE IF NOT EXISTS fellowship_scouts ( + name TEXT PRIMARY KEY, + question TEXT, + task_id TEXT + )`, + + `CREATE TABLE IF NOT EXISTS companies ( + name TEXT PRIMARY KEY + )`, + + `CREATE TABLE IF NOT EXISTS company_members ( + company_name TEXT NOT NULL REFERENCES companies(name), + member_name TEXT NOT NULL, + member_type TEXT NOT NULL, + PRIMARY KEY (company_name, member_name) + )`, + + // Bulletin (replaces bulletin.jsonl) + `CREATE TABLE IF NOT EXISTS bulletin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + quest TEXT NOT NULL, + topic TEXT NOT NULL, + discovery TEXT NOT NULL + )`, + + `CREATE TABLE IF NOT EXISTS bulletin_files ( + bulletin_id INTEGER NOT NULL REFERENCES bulletin(id), + file_path TEXT NOT NULL, + PRIMARY KEY (bulletin_id, file_path) + )`, + `CREATE INDEX IF NOT EXISTS idx_bulletin_topic ON bulletin(topic)`, + `CREATE INDEX IF NOT EXISTS idx_bulletin_files ON bulletin_files(file_path)`, + + // Autopsies (replaces autopsies/*.json) + `CREATE TABLE IF NOT EXISTS autopsies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + quest TEXT NOT NULL, + task TEXT, + phase TEXT, + trigger_type TEXT NOT NULL, + what_failed TEXT NOT NULL, + resolution TEXT, + expires_at TEXT NOT NULL + )`, + + `CREATE TABLE IF NOT EXISTS autopsy_files ( + autopsy_id INTEGER NOT NULL REFERENCES autopsies(id), + file_path TEXT NOT NULL, + PRIMARY KEY (autopsy_id, file_path) + )`, + + `CREATE TABLE IF NOT EXISTS autopsy_modules ( + autopsy_id INTEGER NOT NULL REFERENCES autopsies(id), + module TEXT NOT NULL, + PRIMARY KEY (autopsy_id, module) + )`, + + `CREATE TABLE IF NOT EXISTS autopsy_tags ( + autopsy_id INTEGER NOT NULL REFERENCES autopsies(id), + tag TEXT NOT NULL, + PRIMARY KEY (autopsy_id, tag) + )`, + `CREATE INDEX IF NOT EXISTS idx_autopsy_files ON autopsy_files(file_path)`, + `CREATE INDEX IF NOT EXISTS idx_autopsy_expires ON autopsies(expires_at)`, + + // Provenance tracking + `CREATE TABLE IF NOT EXISTS state_changelog ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + table_name TEXT NOT NULL, + operation TEXT NOT NULL, + quest_name TEXT, + old_value TEXT, + new_value TEXT + )`, + + `CREATE TRIGGER IF NOT EXISTS quest_state_insert AFTER INSERT ON quest_state + BEGIN + INSERT INTO state_changelog(table_name, operation, quest_name, new_value) + VALUES('quest_state', 'INSERT', NEW.quest_name, + json_object('phase', NEW.phase, 'gate_pending', NEW.gate_pending, 'held', NEW.held)); + END`, + + `CREATE TRIGGER IF NOT EXISTS quest_state_update AFTER UPDATE ON quest_state + BEGIN + INSERT INTO state_changelog(table_name, operation, quest_name, old_value, new_value) + VALUES('quest_state', 'UPDATE', NEW.quest_name, + json_object('phase', OLD.phase, 'gate_pending', OLD.gate_pending, 'held', OLD.held, + 'lembas_completed', OLD.lembas_completed, 'metadata_updated', OLD.metadata_updated), + json_object('phase', NEW.phase, 'gate_pending', NEW.gate_pending, 'held', NEW.held, + 'lembas_completed', NEW.lembas_completed, 'metadata_updated', NEW.metadata_updated)); + END`, +} + +// applySchema creates all tables, indexes, and triggers. +// Uses IF NOT EXISTS so it is idempotent. +func applySchema(conn *Conn) error { + for _, stmt := range schema { + if err := sqlitex.ExecuteTransient(conn, stmt, nil); err != nil { + return fmt.Errorf("db: schema: %w\nStatement: %.80s", err, stmt) + } + } + + // Set schema version. + if err := sqlitex.ExecuteTransient(conn, fmt.Sprintf("PRAGMA user_version = %d", schemaVersion), nil); err != nil { + return fmt.Errorf("db: set user_version: %w", err) + } + return nil +} diff --git a/cli/internal/db/testutil.go b/cli/internal/db/testutil.go new file mode 100644 index 0000000..3084425 --- /dev/null +++ b/cli/internal/db/testutil.go @@ -0,0 +1,15 @@ +package db + +import "testing" + +// OpenTest returns an in-memory DB with the full schema applied. +// The DB is automatically closed when the test ends. +func OpenTest(t *testing.T) *DB { + t.Helper() + d, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return d +} From d109217f8c4908c107828ec0b450d757c0de6d49 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:21:08 -0500 Subject: [PATCH 02/19] feat: rewrite state package with SQLite backend Replace file-based Load/Save/WithLock/FindStateFile with DB-backed Load/Upsert/Delete/FindQuest. Remove Version field, ErrNoSave, datadir and filelock imports. State struct and phase helpers unchanged. Co-Authored-By: Claude Opus 4.6 --- cli/internal/state/state.go | 191 ++++++++++++++------------- cli/internal/state/state_test.go | 217 +++++++++++++------------------ 2 files changed, 193 insertions(+), 215 deletions(-) diff --git a/cli/internal/state/state.go b/cli/internal/state/state.go index d73d625..7b09d71 100644 --- a/cli/internal/state/state.go +++ b/cli/internal/state/state.go @@ -3,22 +3,13 @@ package state import ( "encoding/json" "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" + "time" - "github.com/justinjdev/fellowship/cli/internal/datadir" - "github.com/justinjdev/fellowship/cli/internal/filelock" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" ) -// ErrNoSave can be returned from a WithLock callback to skip saving -// the state file while still releasing the lock without error. -var ErrNoSave = fmt.Errorf("no save needed") - type State struct { - Version int `json:"version"` QuestName string `json:"quest_name"` TaskID string `json:"task_id"` TeamName string `json:"team_name"` @@ -50,96 +41,120 @@ func IsEarlyPhase(phase string) bool { return phase == "Onboard" || phase == "Research" || phase == "Plan" } -func Load(path string) (*State, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("reading state file: %w", err) - } - if len(data) == 0 { - return nil, fmt.Errorf("state file is empty") - } +// Load reads quest state from DB by quest name. +func Load(conn *sqlite.Conn, questName string) (*State, error) { var s State - if err := json.Unmarshal(data, &s); err != nil { - return nil, fmt.Errorf("parsing state file: %w", err) - } - return &s, nil -} - -func Save(path string, s *State) error { - data, err := json.MarshalIndent(s, "", " ") + var found bool + err := sqlitex.Execute(conn, `SELECT quest_name, task_id, team_name, phase, + gate_pending, gate_id, lembas_completed, metadata_updated, + held, held_reason, auto_approve, created_at, updated_at + FROM quest_state WHERE quest_name = :name`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + found = true + s.QuestName = stmt.ColumnText(0) + s.TaskID = stmt.ColumnText(1) + s.TeamName = stmt.ColumnText(2) + s.Phase = stmt.ColumnText(3) + s.GatePending = stmt.ColumnInt(4) != 0 + if stmt.ColumnType(5) != sqlite.TypeNull { + gid := stmt.ColumnText(5) + s.GateID = &gid + } + s.LembasCompleted = stmt.ColumnInt(6) != 0 + s.MetadataUpdated = stmt.ColumnInt(7) != 0 + s.Held = stmt.ColumnInt(8) != 0 + if stmt.ColumnType(9) != sqlite.TypeNull { + hr := stmt.ColumnText(9) + s.HeldReason = &hr + } + if aa := stmt.ColumnText(10); aa != "" { + json.Unmarshal([]byte(aa), &s.AutoApproveGates) + } + return nil + }, + }) if err != nil { - return fmt.Errorf("marshaling state: %w", err) - } - data = append(data, '\n') - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { - return fmt.Errorf("writing temp file: %w", err) + return nil, fmt.Errorf("state: load %s: %w", questName, err) } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return fmt.Errorf("renaming temp file: %w", err) + if !found { + return nil, fmt.Errorf("state: quest %q not found", questName) } - return nil + return &s, nil } -// WithLock acquires an exclusive file lock, loads the state, calls fn to -// mutate it, and saves the result. The entire load→mutate→save is atomic with -// respect to other processes using the same lock. -func WithLock(path string, fn func(s *State) error) error { - lockPath := path + ".lock" - lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return fmt.Errorf("opening lock file: %w", err) +// Upsert inserts or updates quest state. +func Upsert(conn *sqlite.Conn, s *State) error { + now := time.Now().UTC().Format(time.RFC3339) + var autoApprove string + if len(s.AutoApproveGates) > 0 { + b, _ := json.Marshal(s.AutoApproveGates) + autoApprove = string(b) } - defer lockFile.Close() - if err := filelock.Lock(lockFile.Fd()); err != nil { - return fmt.Errorf("acquiring lock: %w", err) - } - defer filelock.Unlock(lockFile.Fd()) - - s, err := Load(path) - if err != nil { - return err - } + return sqlitex.Execute(conn, `INSERT INTO quest_state + (quest_name, task_id, team_name, phase, gate_pending, gate_id, + lembas_completed, metadata_updated, held, held_reason, auto_approve, + created_at, updated_at) + VALUES (:name, :task_id, :team, :phase, :gate_pending, :gate_id, + :lembas, :metadata, :held, :held_reason, :auto_approve, :now, :now) + ON CONFLICT(quest_name) DO UPDATE SET + task_id=:task_id, team_name=:team, phase=:phase, + gate_pending=:gate_pending, gate_id=:gate_id, + lembas_completed=:lembas, metadata_updated=:metadata, + held=:held, held_reason=:held_reason, auto_approve=:auto_approve, + updated_at=:now`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":name": s.QuestName, + ":task_id": s.TaskID, + ":team": s.TeamName, + ":phase": s.Phase, + ":gate_pending": boolToInt(s.GatePending), + ":gate_id": ptrToAny(s.GateID), + ":lembas": boolToInt(s.LembasCompleted), + ":metadata": boolToInt(s.MetadataUpdated), + ":held": boolToInt(s.Held), + ":held_reason": ptrToAny(s.HeldReason), + ":auto_approve": autoApprove, + ":now": now, + }, + }) +} - if err := fn(s); err != nil { - if err == ErrNoSave { - return nil - } - return err - } +// Delete removes quest state by name. +func Delete(conn *sqlite.Conn, questName string) error { + return sqlitex.Execute(conn, + `DELETE FROM quest_state WHERE quest_name = :name`, + &sqlitex.ExecOptions{Named: map[string]any{":name": questName}}) +} - return Save(path, s) +// FindQuest returns the quest name for a given worktree root path. +func FindQuest(conn *sqlite.Conn, worktreeRoot string) (string, error) { + var name string + err := sqlitex.Execute(conn, + `SELECT name FROM fellowship_quests WHERE worktree = :wt`, + &sqlitex.ExecOptions{ + Named: map[string]any{":wt": worktreeRoot}, + ResultFunc: func(stmt *sqlite.Stmt) error { + name = stmt.ColumnText(0) + return nil + }, + }) + return name, err } -func FindStateFile(fromDir string) (string, error) { - root, err := gitRoot(fromDir) - if err != nil { - root = fromDir - } - dd := filepath.Join(root, datadir.Name()) - path := filepath.Join(dd, "quest-state.json") - if _, err := os.Stat(path); err != nil { - return "", nil +func boolToInt(b bool) int { + if b { + return 1 } - // If fellowship-state.json also exists in this data directory, the CWD is - // at the main repo root where the lead (Gandalf) runs — not inside a quest - // worktree. Skip quest-state enforcement so the lead isn't blocked by a - // quest runner's state file that leaked into the repo root. - if _, err := os.Stat(filepath.Join(dd, "fellowship-state.json")); err == nil { - return "", nil - } - return path, nil + return 0 } -func gitRoot(fromDir string) (string, error) { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Dir = fromDir - cmd.Stderr = io.Discard - out, err := cmd.Output() - if err != nil { - return "", err +func ptrToAny(s *string) any { + if s == nil { + return nil } - return strings.TrimSpace(string(out)), nil + return *s } diff --git a/cli/internal/state/state_test.go b/cli/internal/state/state_test.go index ee467e4..59aebcd 100644 --- a/cli/internal/state/state_test.go +++ b/cli/internal/state/state_test.go @@ -1,90 +1,105 @@ -package state +package state_test import ( - "os" - "path/filepath" + "context" "testing" + + "github.com/justinjdev/fellowship/cli/internal/db" + "github.com/justinjdev/fellowship/cli/internal/state" + "zombiezen.com/go/sqlite/sqlitex" ) -func tmpState(t *testing.T, content string) string { - t.Helper() - t.Setenv("HOME", t.TempDir()) - dir := t.TempDir() - stateDir := filepath.Join(dir, ".fellowship") - os.MkdirAll(stateDir, 0755) - path := filepath.Join(stateDir, "quest-state.json") - os.WriteFile(path, []byte(content), 0644) - return path -} +func TestUpsertAndLoad(t *testing.T) { + d := db.OpenTest(t) + s := &state.State{ + QuestName: "quest-auth", + Phase: "Research", + } -const validState = `{ - "version": 1, - "quest_name": "test", - "task_id": "1", - "team_name": "test-team", - "phase": "Research", - "gate_pending": false, - "gate_id": null, - "lembas_completed": false, - "metadata_updated": false, - "auto_approve_gates": [] -}` + d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := state.Upsert(conn, s); err != nil { + t.Fatal(err) + } -func TestLoadState(t *testing.T) { - path := tmpState(t, validState) - s, err := Load(path) - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if s.Phase != "Research" { - t.Errorf("Phase = %q, want Research", s.Phase) - } - if s.GatePending { - t.Error("GatePending should be false") - } - if s.Version != 1 { - t.Errorf("Version = %d, want 1", s.Version) - } + loaded, err := state.Load(conn, "quest-auth") + if err != nil { + t.Fatal(err) + } + if loaded.Phase != "Research" { + t.Errorf("expected Research, got %s", loaded.Phase) + } + return nil + }) } -func TestLoadState_MissingFile(t *testing.T) { - _, err := Load("/nonexistent/path") - if err == nil { - t.Error("expected error for missing file") - } +func TestLoad_NotFound(t *testing.T) { + d := db.OpenTest(t) + d.WithConn(context.Background(), func(conn *db.Conn) error { + _, err := state.Load(conn, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent quest") + } + return nil + }) } -func TestLoadState_InvalidJSON(t *testing.T) { - path := tmpState(t, "not json") - _, err := Load(path) - if err == nil { - t.Error("expected error for invalid JSON") - } +func TestUpsert_Update(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + s := &state.State{QuestName: "q1", Phase: "Onboard"} + state.Upsert(conn, s) + + s.Phase = "Research" + s.GatePending = true + state.Upsert(conn, s) + + loaded, _ := state.Load(conn, "q1") + if loaded.Phase != "Research" { + t.Errorf("expected Research, got %s", loaded.Phase) + } + if !loaded.GatePending { + t.Error("expected GatePending true") + } + return nil + }) } -func TestLoadState_EmptyFile(t *testing.T) { - path := tmpState(t, "") - _, err := Load(path) - if err == nil { - t.Error("expected error for empty file") - } +func TestFindQuest(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + sqlitex.Execute(conn, `INSERT INTO fellowship_quests (name, worktree) VALUES ('quest-auth', '/tmp/wt/quest-auth')`, nil) + + name, err := state.FindQuest(conn, "/tmp/wt/quest-auth") + if err != nil { + t.Fatal(err) + } + if name != "quest-auth" { + t.Errorf("expected quest-auth, got %s", name) + } + return nil + }) } -func TestSaveState(t *testing.T) { - path := tmpState(t, validState) - s, _ := Load(path) - s.Phase = "Plan" - s.LembasCompleted = true - if err := Save(path, s); err != nil { - t.Fatalf("Save failed: %v", err) - } - s2, _ := Load(path) - if s2.Phase != "Plan" { - t.Errorf("Phase = %q after save, want Plan", s2.Phase) - } - if !s2.LembasCompleted { - t.Error("LembasCompleted should be true after save") - } +func TestBoolIntConversion(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + s := &state.State{ + QuestName: "q1", + Phase: "Implement", + GatePending: true, + Held: true, + } + state.Upsert(conn, s) + + loaded, _ := state.Load(conn, "q1") + if !loaded.GatePending { + t.Error("GatePending should be true") + } + if !loaded.Held { + t.Error("Held should be true") + } + return nil + }) } func TestNextPhase(t *testing.T) { @@ -103,7 +118,7 @@ func TestNextPhase(t *testing.T) { {"InvalidPhase", "", true}, } for _, tt := range tests { - got, err := NextPhase(tt.current) + got, err := state.NextPhase(tt.current) if (err != nil) != tt.wantErr { t.Errorf("NextPhase(%q) error = %v, wantErr %v", tt.current, err, tt.wantErr) } @@ -117,65 +132,13 @@ func TestIsEarlyPhase(t *testing.T) { early := []string{"Onboard", "Research", "Plan"} late := []string{"Implement", "Adversarial", "Review", "Complete"} for _, p := range early { - if !IsEarlyPhase(p) { + if !state.IsEarlyPhase(p) { t.Errorf("IsEarlyPhase(%q) should be true", p) } } for _, p := range late { - if IsEarlyPhase(p) { + if state.IsEarlyPhase(p) { t.Errorf("IsEarlyPhase(%q) should be false", p) } } } - -func TestFindStateFile_NoFile(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - dir := t.TempDir() - path, err := FindStateFile(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if path != "" { - t.Errorf("expected empty path, got %q", path) - } -} - -func TestFindStateFile_SkipsWhenFellowshipStateExists(t *testing.T) { - // Simulate the main repo root where both quest-state.json and - // fellowship-state.json exist (lead's CWD). The hook should NOT - // find the quest-state file so the lead isn't blocked. - t.Setenv("HOME", t.TempDir()) - dir := t.TempDir() - dd := filepath.Join(dir, ".fellowship") - os.MkdirAll(dd, 0755) - os.WriteFile(filepath.Join(dd, "quest-state.json"), []byte(validState), 0644) - os.WriteFile(filepath.Join(dd, "fellowship-state.json"), []byte(`{"version":1}`), 0644) - - // FindStateFile uses gitRoot which won't work in a temp dir, so it - // falls back to fromDir. With both files present it should return "". - path, err := FindStateFile(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if path != "" { - t.Errorf("expected empty path when fellowship-state.json exists, got %q", path) - } -} - -func TestFindStateFile_ReturnsPathWhenOnlyQuestState(t *testing.T) { - // Simulate a quest worktree where only quest-state.json exists. - t.Setenv("HOME", t.TempDir()) - dir := t.TempDir() - dd := filepath.Join(dir, ".fellowship") - os.MkdirAll(dd, 0755) - os.WriteFile(filepath.Join(dd, "quest-state.json"), []byte(validState), 0644) - - path, err := FindStateFile(dir) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - expected := filepath.Join(dd, "quest-state.json") - if path != expected { - t.Errorf("got %q, want %q", path, expected) - } -} From 32044a4eb12808266ae0dc0178b92dca54ea172b Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:23:16 -0500 Subject: [PATCH 03/19] feat: rewrite errand package with SQLite backend Co-Authored-By: Claude Opus 4.6 --- cli/internal/errand/errand.go | 253 ++++++++++-------- cli/internal/errand/errand_test.go | 403 ++++++++++------------------- 2 files changed, 291 insertions(+), 365 deletions(-) diff --git a/cli/internal/errand/errand.go b/cli/internal/errand/errand.go index 27100ea..a77b6b7 100644 --- a/cli/internal/errand/errand.go +++ b/cli/internal/errand/errand.go @@ -1,24 +1,21 @@ package errand import ( - "encoding/json" "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "time" - "github.com/justinjdev/fellowship/cli/internal/datadir" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" ) type ErrandStatus string const ( - Pending ErrandStatus = "pending" - Active ErrandStatus = "active" - Done ErrandStatus = "done" - Blocked ErrandStatus = "blocked" + Pending ErrandStatus = "pending" + InProgress ErrandStatus = "in_progress" + Done ErrandStatus = "done" + Blocked ErrandStatus = "blocked" + Skipped ErrandStatus = "skipped" ) type Errand struct { @@ -32,133 +29,173 @@ type Errand struct { } type QuestErrandList struct { - Version int `json:"version"` QuestName string `json:"quest_name"` Task string `json:"task"` Items []Errand `json:"items"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` } -func Load(path string) (*QuestErrandList, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("reading errand file: %w", err) - } - if len(data) == 0 { - return nil, fmt.Errorf("errand file is empty") - } - var h QuestErrandList - if err := json.Unmarshal(data, &h); err != nil { - return nil, fmt.Errorf("parsing errand file: %w", err) +// ValidStatus checks whether a string is a valid ErrandStatus. +func ValidStatus(s string) (ErrandStatus, bool) { + switch ErrandStatus(s) { + case Pending, InProgress, Done, Blocked, Skipped: + return ErrandStatus(s), true + default: + return "", false } - return &h, nil } -func Save(path string, h *QuestErrandList) error { - data, err := json.MarshalIndent(h, "", " ") - if err != nil { - return fmt.Errorf("marshaling errand: %w", err) - } - data = append(data, '\n') - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { - return fmt.Errorf("writing temp file: %w", err) - } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return fmt.Errorf("renaming temp file: %w", err) - } +// Init creates the initial errand list metadata for a quest. +// This is a no-op for DB-backed storage since errands reference quest_state via FK. +func Init(conn *sqlite.Conn, quest, task string) error { + // errands are stored per-row with quest_name FK; nothing to initialize. + _ = conn + _ = quest + _ = task return nil } -func FindErrands(fromDir string) (string, error) { - root, err := gitRoot(fromDir) +// Add inserts a new errand and returns its generated ID (w-NNN). +func Add(conn *sqlite.Conn, quest, desc, phase string) (string, error) { + now := time.Now().UTC().Format(time.RFC3339) + + // Generate next ID using MAX to handle gaps from deletions. + var nextNum int + err := sqlitex.Execute(conn, + `SELECT COALESCE(MAX(CAST(SUBSTR(id, 3) AS INTEGER)), 0) + 1 FROM errands WHERE quest_name = :quest`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": quest}, + ResultFunc: func(stmt *sqlite.Stmt) error { + nextNum = stmt.ColumnInt(0) + return nil + }, + }) if err != nil { - root = fromDir - } - path := filepath.Join(root, datadir.Name(), "quest-errands.json") - if _, err := os.Stat(path); os.IsNotExist(err) { - return "", nil - } else if err != nil { - return "", err + return "", fmt.Errorf("errand: next id: %w", err) + } + + id := fmt.Sprintf("w-%03d", nextNum) + + err = sqlitex.Execute(conn, + `INSERT INTO errands (id, quest_name, description, status, phase, created_at, updated_at) + VALUES (:id, :quest, :desc, :status, :phase, :now, :now)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":id": id, + ":quest": quest, + ":desc": desc, + ":status": string(Pending), + ":phase": phase, + ":now": now, + }, + }) + if err != nil { + return "", fmt.Errorf("errand: add: %w", err) } - return path, nil + + return id, nil } -func AddErrand(h *QuestErrandList, desc string, phase string) string { +// UpdateStatus changes the status of an errand. +func UpdateStatus(conn *sqlite.Conn, quest, id string, status ErrandStatus) error { now := time.Now().UTC().Format(time.RFC3339) - id := NextID(h) - item := Errand{ - ID: id, - Description: desc, - Status: Pending, - Phase: phase, - CreatedAt: now, - UpdatedAt: now, + + err := sqlitex.Execute(conn, + `UPDATE errands SET status = :status, updated_at = :now + WHERE quest_name = :quest AND id = :id`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":status": string(status), + ":now": now, + ":quest": quest, + ":id": id, + }, + }) + if err != nil { + return fmt.Errorf("errand: update status: %w", err) } - h.Items = append(h.Items, item) - h.UpdatedAt = now - return id -} -func UpdateStatus(h *QuestErrandList, id string, status ErrandStatus) error { - for i := range h.Items { - if h.Items[i].ID == id { - h.Items[i].Status = status - h.Items[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339) - h.UpdatedAt = h.Items[i].UpdatedAt - return nil - } + if conn.Changes() == 0 { + return fmt.Errorf("errand %q not found in quest %q", id, quest) } - return fmt.Errorf("errand %q not found", id) + return nil } -func NextID(h *QuestErrandList) string { - max := 0 - for _, item := range h.Items { - var n int - if _, err := fmt.Sscanf(item.ID, "w-%d", &n); err == nil && n > max { - max = n +// List returns all errands for a quest, ordered by ID. +func List(conn *sqlite.Conn, quest string) ([]Errand, error) { + var items []Errand + err := sqlitex.Execute(conn, + `SELECT id, description, status, phase, created_at, updated_at + FROM errands WHERE quest_name = :quest ORDER BY id`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": quest}, + ResultFunc: func(stmt *sqlite.Stmt) error { + e := Errand{ + ID: stmt.ColumnText(0), + Description: stmt.ColumnText(1), + Status: ErrandStatus(stmt.ColumnText(2)), + Phase: stmt.ColumnText(3), + CreatedAt: stmt.ColumnText(4), + UpdatedAt: stmt.ColumnText(5), + } + items = append(items, e) + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("errand: list: %w", err) + } + + // Load dependencies for each errand. + for i := range items { + deps, err := loadDeps(conn, quest, items[i].ID) + if err != nil { + return nil, err } + items[i].DependsOn = deps } - return fmt.Sprintf("w-%03d", max+1) + + return items, nil } -// ValidStatus checks whether a string is a valid ErrandStatus. -func ValidStatus(s string) (ErrandStatus, bool) { - switch ErrandStatus(s) { - case Pending, Active, Done, Blocked: - return ErrandStatus(s), true - default: - return "", false +// Progress returns the count of done errands and total errands for a quest. +func Progress(conn *sqlite.Conn, quest string) (done, total int, err error) { + err = sqlitex.Execute(conn, + `SELECT COUNT(*) AS total, SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done + FROM errands WHERE quest_name = :quest`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": quest}, + ResultFunc: func(stmt *sqlite.Stmt) error { + total = stmt.ColumnInt(0) + done = stmt.ColumnInt(1) + return nil + }, + }) + if err != nil { + err = fmt.Errorf("errand: progress: %w", err) } + return } -func Progress(h *QuestErrandList) (done int, total int) { - total = len(h.Items) - for _, item := range h.Items { - if item.Status == Done { - done++ - } +// PendingErrands returns errands that are pending or blocked but whose +// dependencies are all done. +func PendingErrands(conn *sqlite.Conn, quest string) ([]Errand, error) { + items, err := List(conn, quest) + if err != nil { + return nil, err } - return done, total -} -func PendingErrands(h *QuestErrandList) []Errand { doneSet := make(map[string]bool) - for _, item := range h.Items { + for _, item := range items { if item.Status == Done { doneSet[item.ID] = true } } var result []Errand - for _, item := range h.Items { + for _, item := range items { if item.Status != Pending && item.Status != Blocked { continue } - // Check if all dependencies are done depsOK := true for _, dep := range item.DependsOn { if !doneSet[dep] { @@ -170,15 +207,23 @@ func PendingErrands(h *QuestErrandList) []Errand { result = append(result, item) } } - return result + return result, nil } -func gitRoot(fromDir string) (string, error) { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Dir = fromDir - out, err := cmd.Output() +// loadDeps returns the dependency IDs for an errand. +func loadDeps(conn *sqlite.Conn, quest, errandID string) ([]string, error) { + var deps []string + err := sqlitex.Execute(conn, + `SELECT depends_on FROM errand_deps WHERE quest_name = :quest AND errand_id = :id`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": quest, ":id": errandID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + deps = append(deps, stmt.ColumnText(0)) + return nil + }, + }) if err != nil { - return "", err + return nil, fmt.Errorf("errand: load deps: %w", err) } - return strings.TrimSpace(string(out)), nil + return deps, nil } diff --git a/cli/internal/errand/errand_test.go b/cli/internal/errand/errand_test.go index dfe3b0f..1dd5875 100644 --- a/cli/internal/errand/errand_test.go +++ b/cli/internal/errand/errand_test.go @@ -1,283 +1,164 @@ -package errand +package errand_test import ( - "fmt" - "os" - "path/filepath" + "context" "testing" - "time" -) - -func TestLoadSaveRoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "quest-errands.json") - - now := time.Now().UTC().Format(time.RFC3339) - h := &QuestErrandList{ - Version: 1, - QuestName: "test-quest", - Task: "fix the bug", - Items: []Errand{ - { - ID: "w-001", - Description: "write tests", - Status: Pending, - Phase: "Implement", - CreatedAt: now, - UpdatedAt: now, - }, - }, - CreatedAt: now, - UpdatedAt: now, - } - - if err := Save(path, h); err != nil { - t.Fatalf("Save: %v", err) - } - loaded, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } + "github.com/justinjdev/fellowship/cli/internal/db" + "github.com/justinjdev/fellowship/cli/internal/errand" + "github.com/justinjdev/fellowship/cli/internal/state" +) - if loaded.QuestName != h.QuestName { - t.Errorf("QuestName = %q, want %q", loaded.QuestName, h.QuestName) - } - if loaded.Task != h.Task { - t.Errorf("Task = %q, want %q", loaded.Task, h.Task) - } - if len(loaded.Items) != 1 { - t.Fatalf("Items count = %d, want 1", len(loaded.Items)) - } - if loaded.Items[0].ID != "w-001" { - t.Errorf("Item ID = %q, want %q", loaded.Items[0].ID, "w-001") - } - if loaded.Items[0].Status != Pending { - t.Errorf("Item Status = %q, want %q", loaded.Items[0].Status, Pending) - } +func seedQuest(t *testing.T, d *db.DB, name string) { + t.Helper() + d.WithTx(context.Background(), func(conn *db.Conn) error { + return state.Upsert(conn, &state.State{QuestName: name, Phase: "Implement"}) + }) } -func TestLoadEmptyFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "quest-errands.json") - os.WriteFile(path, []byte{}, 0644) - - _, err := Load(path) - if err == nil { - t.Fatal("expected error for empty file") - } +func TestAddAndList(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + d.WithTx(context.Background(), func(conn *db.Conn) error { + id, err := errand.Add(conn, "q1", "Build auth module", "Implement") + if err != nil { + t.Fatal(err) + } + if id != "w-001" { + t.Errorf("expected w-001, got %s", id) + } + + items, _ := errand.List(conn, "q1") + if len(items) != 1 { + t.Fatalf("expected 1, got %d", len(items)) + } + if items[0].Description != "Build auth module" { + t.Error("description mismatch") + } + return nil + }) } -func TestLoadMissingFile(t *testing.T) { - _, err := Load("/nonexistent/quest-errands.json") - if err == nil { - t.Fatal("expected error for missing file") - } -} - -func TestAddErrandSequentialIDs(t *testing.T) { - h := &QuestErrandList{ - Version: 1, - QuestName: "test", - Task: "task", - CreatedAt: time.Now().UTC().Format(time.RFC3339), - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - } - - id1 := AddErrand(h, "first item", "Implement") - if id1 != "w-001" { - t.Errorf("first ID = %q, want %q", id1, "w-001") - } - - id2 := AddErrand(h, "second item", "Implement") - if id2 != "w-002" { - t.Errorf("second ID = %q, want %q", id2, "w-002") - } - - id3 := AddErrand(h, "third item", "Review") - if id3 != "w-003" { - t.Errorf("third ID = %q, want %q", id3, "w-003") - } - - if len(h.Items) != 3 { - t.Errorf("Items count = %d, want 3", len(h.Items)) - } - - if h.Items[0].Phase != "Implement" { - t.Errorf("Item 0 Phase = %q, want %q", h.Items[0].Phase, "Implement") - } - if h.Items[2].Phase != "Review" { - t.Errorf("Item 2 Phase = %q, want %q", h.Items[2].Phase, "Review") - } +func TestAddSequentialIDs(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + d.WithTx(context.Background(), func(conn *db.Conn) error { + id1, _ := errand.Add(conn, "q1", "first", "Implement") + id2, _ := errand.Add(conn, "q1", "second", "Implement") + id3, _ := errand.Add(conn, "q1", "third", "Review") + + if id1 != "w-001" { + t.Errorf("first ID = %q, want w-001", id1) + } + if id2 != "w-002" { + t.Errorf("second ID = %q, want w-002", id2) + } + if id3 != "w-003" { + t.Errorf("third ID = %q, want w-003", id3) + } + + items, _ := errand.List(conn, "q1") + if len(items) != 3 { + t.Errorf("Items count = %d, want 3", len(items)) + } + if items[0].Phase != "Implement" { + t.Errorf("Item 0 Phase = %q, want Implement", items[0].Phase) + } + if items[2].Phase != "Review" { + t.Errorf("Item 2 Phase = %q, want Review", items[2].Phase) + } + return nil + }) } func TestUpdateStatus(t *testing.T) { - h := &QuestErrandList{ - Version: 1, - QuestName: "test", - Task: "task", - CreatedAt: time.Now().UTC().Format(time.RFC3339), - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - } - - AddErrand(h, "item one", "Implement") - AddErrand(h, "item two", "Implement") - - if err := UpdateStatus(h, "w-001", Active); err != nil { - t.Fatalf("UpdateStatus: %v", err) - } - if h.Items[0].Status != Active { - t.Errorf("Status = %q, want %q", h.Items[0].Status, Active) - } - - if err := UpdateStatus(h, "w-001", Done); err != nil { - t.Fatalf("UpdateStatus: %v", err) - } - if h.Items[0].Status != Done { - t.Errorf("Status = %q, want %q", h.Items[0].Status, Done) - } - - // Item not found - err := UpdateStatus(h, "w-999", Done) - if err == nil { - t.Fatal("expected error for nonexistent item") - } + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + d.WithTx(context.Background(), func(conn *db.Conn) error { + errand.Add(conn, "q1", "Task 1", "") + errand.UpdateStatus(conn, "q1", "w-001", errand.Done) + + items, _ := errand.List(conn, "q1") + if items[0].Status != errand.Done { + t.Errorf("expected done, got %s", items[0].Status) + } + return nil + }) } -func TestProgress(t *testing.T) { - h := &QuestErrandList{ - Version: 1, - QuestName: "test", - Task: "task", - CreatedAt: time.Now().UTC().Format(time.RFC3339), - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - } - - AddErrand(h, "item one", "") - AddErrand(h, "item two", "") - AddErrand(h, "item three", "") - - done, total := Progress(h) - if done != 0 || total != 3 { - t.Errorf("Progress = %d/%d, want 0/3", done, total) - } - - UpdateStatus(h, "w-001", Done) - done, total = Progress(h) - if done != 1 || total != 3 { - t.Errorf("Progress = %d/%d, want 1/3", done, total) - } - - UpdateStatus(h, "w-002", Done) - UpdateStatus(h, "w-003", Done) - done, total = Progress(h) - if done != 3 || total != 3 { - t.Errorf("Progress = %d/%d, want 3/3", done, total) - } +func TestUpdateStatusNotFound(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + d.WithTx(context.Background(), func(conn *db.Conn) error { + err := errand.UpdateStatus(conn, "q1", "w-999", errand.Done) + if err == nil { + t.Fatal("expected error for nonexistent errand") + } + return nil + }) } -func TestPendingErrandsDependencyResolution(t *testing.T) { - now := time.Now().UTC().Format(time.RFC3339) - h := &QuestErrandList{ - Version: 1, - QuestName: "test", - Task: "task", - Items: []Errand{ - {ID: "w-001", Description: "foundation", Status: Pending, CreatedAt: now, UpdatedAt: now}, - {ID: "w-002", Description: "depends on foundation", Status: Pending, DependsOn: []string{"w-001"}, CreatedAt: now, UpdatedAt: now}, - {ID: "w-003", Description: "independent", Status: Pending, CreatedAt: now, UpdatedAt: now}, - {ID: "w-004", Description: "depends on two", Status: Blocked, DependsOn: []string{"w-001", "w-003"}, CreatedAt: now, UpdatedAt: now}, - {ID: "w-005", Description: "already done", Status: Done, CreatedAt: now, UpdatedAt: now}, - {ID: "w-006", Description: "already active", Status: Active, CreatedAt: now, UpdatedAt: now}, - }, - CreatedAt: now, - UpdatedAt: now, - } - - // Initially: w-001 (no deps), w-003 (no deps) are pending with met deps - // w-002 depends on w-001 (pending) -> not returned - // w-004 depends on w-001 (pending) and w-003 (pending) -> not returned - // w-005 is done, w-006 is active -> not returned - pending := PendingErrands(h) - if len(pending) != 2 { - t.Fatalf("PendingErrands count = %d, want 2", len(pending)) - } - ids := map[string]bool{} - for _, p := range pending { - ids[p.ID] = true - } - if !ids["w-001"] || !ids["w-003"] { - t.Errorf("expected w-001 and w-003, got %v", ids) - } - - // Mark w-001 as done -> w-002 should now be available - h.Items[0].Status = Done - pending = PendingErrands(h) - pendingIDs := map[string]bool{} - for _, p := range pending { - pendingIDs[p.ID] = true - } - if !pendingIDs["w-002"] { - t.Error("w-002 should be pending after w-001 is done") - } - if !pendingIDs["w-003"] { - t.Error("w-003 should still be pending") - } - // w-004 depends on w-001 (done) and w-003 (pending) -> still not available - if pendingIDs["w-004"] { - t.Error("w-004 should not be pending (w-003 still pending)") - } - - // Mark w-003 as done -> w-004 should now be available - h.Items[2].Status = Done - pending = PendingErrands(h) - pendingIDs = map[string]bool{} - for _, p := range pending { - pendingIDs[p.ID] = true - } - if !pendingIDs["w-004"] { - t.Error("w-004 should be pending after all deps are done") - } +func TestProgress(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + d.WithTx(context.Background(), func(conn *db.Conn) error { + errand.Add(conn, "q1", "A", "") + errand.Add(conn, "q1", "B", "") + errand.UpdateStatus(conn, "q1", "w-001", errand.Done) + + done, total, _ := errand.Progress(conn, "q1") + if done != 1 || total != 2 { + t.Errorf("expected 1/2, got %d/%d", done, total) + } + return nil + }) } -func TestNextIDSequence(t *testing.T) { - h := &QuestErrandList{ - Version: 1, - QuestName: "test", - Task: "task", - CreatedAt: time.Now().UTC().Format(time.RFC3339), - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - } - - if id := NextID(h); id != "w-001" { - t.Errorf("NextID empty = %q, want %q", id, "w-001") - } - - h.Items = append(h.Items, Errand{ID: "w-001"}) - if id := NextID(h); id != "w-002" { - t.Errorf("NextID after 1 = %q, want %q", id, "w-002") - } - - // Add 8 more to test padding - for i := 0; i < 8; i++ { - h.Items = append(h.Items, Errand{ID: fmt.Sprintf("w-%03d", i+2)}) - } - if id := NextID(h); id != "w-010" { - t.Errorf("NextID after 9 = %q, want %q", id, "w-010") - } +func TestPendingErrands(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + d.WithTx(context.Background(), func(conn *db.Conn) error { + errand.Add(conn, "q1", "A", "") + errand.Add(conn, "q1", "B", "") + errand.UpdateStatus(conn, "q1", "w-001", errand.Done) + + pending, err := errand.PendingErrands(conn, "q1") + if err != nil { + t.Fatal(err) + } + if len(pending) != 1 { + t.Fatalf("expected 1 pending, got %d", len(pending)) + } + if pending[0].ID != "w-002" { + t.Errorf("expected w-002, got %s", pending[0].ID) + } + return nil + }) } -func TestFindErrandsNoFile(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - dir := t.TempDir() - // Create data dir but no errand file - os.MkdirAll(filepath.Join(dir, ".fellowship"), 0755) - - path, err := FindErrands(dir) - if err != nil { - t.Fatalf("FindErrands: %v", err) - } - if path != "" { - t.Errorf("FindErrands = %q, want empty", path) +func TestValidStatus(t *testing.T) { + tests := []struct { + input string + valid bool + }{ + {"pending", true}, + {"in_progress", true}, + {"done", true}, + {"blocked", true}, + {"skipped", true}, + {"invalid", false}, + {"", false}, + } + for _, tt := range tests { + _, ok := errand.ValidStatus(tt.input) + if ok != tt.valid { + t.Errorf("ValidStatus(%q) = %v, want %v", tt.input, ok, tt.valid) + } } } From 18b32135b0f1625a903cc3c1022d9f37e626ff0b Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:24:44 -0500 Subject: [PATCH 04/19] feat: rewrite tome package with SQLite backend Co-Authored-By: Claude Opus 4.6 --- cli/internal/tome/tome.go | 252 +++++++++++++--------- cli/internal/tome/tome_test.go | 377 ++++++++++++++------------------- 2 files changed, 303 insertions(+), 326 deletions(-) diff --git a/cli/internal/tome/tome.go b/cli/internal/tome/tome.go index 8d6db4c..2e3164f 100644 --- a/cli/internal/tome/tome.go +++ b/cli/internal/tome/tome.go @@ -1,156 +1,202 @@ package tome import ( - "encoding/json" "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "time" - "github.com/justinjdev/fellowship/cli/internal/datadir" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" ) type QuestTome struct { - Version int `json:"version"` QuestName string `json:"quest_name"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Task string `json:"task"` PhasesCompleted []PhaseRecord `json:"phases_completed"` GateHistory []GateEvent `json:"gate_history"` FilesTouched []string `json:"files_touched"` Respawns int `json:"respawns"` Status string `json:"status"` // "active", "completed", "failed" + Task string `json:"task"` } type PhaseRecord struct { Phase string `json:"phase"` CompletedAt string `json:"completed_at"` - Duration string `json:"duration,omitempty"` + DurationS int `json:"duration_s,omitempty"` } type GateEvent struct { Phase string `json:"phase"` - Action string `json:"action"` // "submitted", "approved", "rejected" + Action string `json:"action"` // "submitted", "approved", "rejected", "skipped" Timestamp string `json:"timestamp"` Reason string `json:"reason,omitempty"` } -func Load(path string) (*QuestTome, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("reading tome file: %w", err) - } - if len(data) == 0 { - return nil, fmt.Errorf("tome file is empty") - } - var c QuestTome - if err := json.Unmarshal(data, &c); err != nil { - return nil, fmt.Errorf("parsing tome file: %w", err) - } - return &c, nil +// RecordPhase inserts a phase completion record into quest_phases. +func RecordPhase(conn *sqlite.Conn, questName, phase string, durationS int) error { + return sqlitex.Execute(conn, + `INSERT INTO quest_phases (quest_name, phase, completed_at, duration_s) + VALUES (:quest, :phase, :now, :dur)`, + &sqlitex.ExecOptions{Named: map[string]any{ + ":quest": questName, + ":phase": phase, + ":now": time.Now().UTC().Format(time.RFC3339), + ":dur": durationS, + }}) } -func Save(path string, c *QuestTome) error { - c.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - data, err := json.MarshalIndent(c, "", " ") - if err != nil { - return fmt.Errorf("marshaling tome: %w", err) - } - data = append(data, '\n') - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { - return fmt.Errorf("writing temp file: %w", err) - } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return fmt.Errorf("renaming temp file: %w", err) +// RecordGate inserts a gate event into quest_gates. +func RecordGate(conn *sqlite.Conn, questName, phase, action, reason string) error { + return sqlitex.Execute(conn, + `INSERT INTO quest_gates (quest_name, phase, action, timestamp, reason) + VALUES (:quest, :phase, :action, :now, :reason)`, + &sqlitex.ExecOptions{Named: map[string]any{ + ":quest": questName, + ":phase": phase, + ":action": action, + ":now": time.Now().UTC().Format(time.RFC3339), + ":reason": reason, + }}) +} + +// RecordFiles inserts file paths into quest_files, ignoring duplicates. +func RecordFiles(conn *sqlite.Conn, questName string, files []string) error { + for _, f := range files { + if err := sqlitex.Execute(conn, + `INSERT OR IGNORE INTO quest_files (quest_name, file_path) VALUES (:quest, :file)`, + &sqlitex.ExecOptions{Named: map[string]any{":quest": questName, ":file": f}}, + ); err != nil { + return err + } } return nil } -func RecordPhase(c *QuestTome, phase string) { - c.PhasesCompleted = append(c.PhasesCompleted, PhaseRecord{ - Phase: phase, - CompletedAt: time.Now().UTC().Format(time.RFC3339), - }) +// RecordSkippedPhases records multiple phases as skipped with a reason. +func RecordSkippedPhases(conn *sqlite.Conn, questName string, phases []string, reason string) error { + for _, p := range phases { + if err := RecordPhase(conn, questName, p, 0); err != nil { + return err + } + if err := RecordGate(conn, questName, p, "skipped", reason); err != nil { + return err + } + } + return nil } -func RecordGate(c *QuestTome, phase, action string) { - c.GateHistory = append(c.GateHistory, GateEvent{ - Phase: phase, - Action: action, - Timestamp: time.Now().UTC().Format(time.RFC3339), - }) +// LoadPhases returns all phase records for a quest, ordered by insertion. +func LoadPhases(conn *sqlite.Conn, questName string) ([]PhaseRecord, error) { + var phases []PhaseRecord + err := sqlitex.Execute(conn, + `SELECT phase, completed_at, duration_s FROM quest_phases WHERE quest_name = :quest ORDER BY id`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + phases = append(phases, PhaseRecord{ + Phase: stmt.ColumnText(0), + CompletedAt: stmt.ColumnText(1), + DurationS: stmt.ColumnInt(2), + }) + return nil + }, + }) + return phases, err } -func RecordSkippedPhases(c *QuestTome, phases []string, reason string) { - now := time.Now().UTC().Format(time.RFC3339) - for _, phase := range phases { - c.GateHistory = append(c.GateHistory, GateEvent{ - Phase: phase, - Action: "skipped", - Timestamp: now, - Reason: reason, +// LoadGates returns all gate events for a quest, ordered by insertion. +func LoadGates(conn *sqlite.Conn, questName string) ([]GateEvent, error) { + var gates []GateEvent + err := sqlitex.Execute(conn, + `SELECT phase, action, timestamp, reason FROM quest_gates WHERE quest_name = :quest ORDER BY id`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + gates = append(gates, GateEvent{ + Phase: stmt.ColumnText(0), + Action: stmt.ColumnText(1), + Timestamp: stmt.ColumnText(2), + Reason: stmt.ColumnText(3), + }) + return nil + }, }) - c.PhasesCompleted = append(c.PhasesCompleted, PhaseRecord{ - Phase: phase, - CompletedAt: now, + return gates, err +} + +// LoadFiles returns all file paths for a quest. +func LoadFiles(conn *sqlite.Conn, questName string) ([]string, error) { + var files []string + err := sqlitex.Execute(conn, + `SELECT file_path FROM quest_files WHERE quest_name = :quest ORDER BY file_path`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + files = append(files, stmt.ColumnText(0)) + return nil + }, }) - } + return files, err } -func RecordFiles(c *QuestTome, files []string) { - seen := make(map[string]bool, len(c.FilesTouched)) - for _, f := range c.FilesTouched { - seen[f] = true +// Load assembles a QuestTome from the database for the given quest. +// Returns a zero-value tome if no data exists (equivalent to old LoadOrCreate). +func Load(conn *sqlite.Conn, questName string) (*QuestTome, error) { + phases, err := LoadPhases(conn, questName) + if err != nil { + return nil, fmt.Errorf("tome: load phases: %w", err) } - for _, f := range files { - if !seen[f] { - c.FilesTouched = append(c.FilesTouched, f) - seen[f] = true - } + gates, err := LoadGates(conn, questName) + if err != nil { + return nil, fmt.Errorf("tome: load gates: %w", err) } -} - -func FindTome(fromDir string) (string, error) { - root, err := gitRoot(fromDir) + files, err := LoadFiles(conn, questName) if err != nil { - root = fromDir + return nil, fmt.Errorf("tome: load files: %w", err) } - path := filepath.Join(root, datadir.Name(), "quest-tome.json") - if _, err := os.Stat(path); os.IsNotExist(err) { - return "", nil - } else if err != nil { - return "", err + + // Load status/task/respawns from fellowship_quests. + var status, task string + var respawns int + _ = sqlitex.Execute(conn, + `SELECT status, task_description, respawns FROM fellowship_quests WHERE name = :name`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + status = stmt.ColumnText(0) + task = stmt.ColumnText(1) + respawns = stmt.ColumnInt(2) + return nil + }, + }) + if status == "" { + status = "active" } - return path, nil -} -// LoadOrCreate loads the tome from path, or creates a new one if the file does not exist. -func LoadOrCreate(path string) *QuestTome { - c, err := Load(path) - if err == nil { - return c + // Ensure non-nil slices for JSON serialization. + if phases == nil { + phases = []PhaseRecord{} } - return &QuestTome{ - Version: 1, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - Status: "active", - PhasesCompleted: []PhaseRecord{}, - GateHistory: []GateEvent{}, - FilesTouched: []string{}, + if gates == nil { + gates = []GateEvent{} + } + if files == nil { + files = []string{} } + + return &QuestTome{ + QuestName: questName, + PhasesCompleted: phases, + GateHistory: gates, + FilesTouched: files, + Status: status, + Task: task, + Respawns: respawns, + }, nil } -func gitRoot(fromDir string) (string, error) { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - cmd.Dir = fromDir - out, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil +// SetStatus updates the quest status in fellowship_quests. +func SetStatus(conn *sqlite.Conn, questName, status string) error { + return sqlitex.Execute(conn, + `UPDATE fellowship_quests SET status = :status WHERE name = :quest`, + &sqlitex.ExecOptions{Named: map[string]any{":quest": questName, ":status": status}}) } diff --git a/cli/internal/tome/tome_test.go b/cli/internal/tome/tome_test.go index d79df33..abac5e0 100644 --- a/cli/internal/tome/tome_test.go +++ b/cli/internal/tome/tome_test.go @@ -1,260 +1,191 @@ -package tome +package tome_test import ( - "encoding/json" - "os" - "path/filepath" + "context" "testing" -) - -func TestLoadSaveRoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "quest-tome.json") - - original := &QuestTome{ - Version: 1, - QuestName: "test-quest", - CreatedAt: "2025-01-01T00:00:00Z", - Task: "implement feature X", - Status: "active", - PhasesCompleted: []PhaseRecord{ - {Phase: "Research", CompletedAt: "2025-01-01T01:00:00Z"}, - }, - GateHistory: []GateEvent{ - {Phase: "Research", Action: "submitted", Timestamp: "2025-01-01T01:00:00Z"}, - }, - FilesTouched: []string{"main.go", "lib.go"}, - Respawns: 1, - } - - if err := Save(path, original); err != nil { - t.Fatalf("Save: %v", err) - } - - loaded, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if loaded.QuestName != original.QuestName { - t.Errorf("QuestName = %q, want %q", loaded.QuestName, original.QuestName) - } - if loaded.Task != original.Task { - t.Errorf("Task = %q, want %q", loaded.Task, original.Task) - } - if loaded.Status != original.Status { - t.Errorf("Status = %q, want %q", loaded.Status, original.Status) - } - if len(loaded.PhasesCompleted) != 1 { - t.Errorf("PhasesCompleted len = %d, want 1", len(loaded.PhasesCompleted)) - } - if len(loaded.GateHistory) != 1 { - t.Errorf("GateHistory len = %d, want 1", len(loaded.GateHistory)) - } - if len(loaded.FilesTouched) != 2 { - t.Errorf("FilesTouched len = %d, want 2", len(loaded.FilesTouched)) - } - if loaded.Respawns != 1 { - t.Errorf("Respawns = %d, want 1", loaded.Respawns) - } - if loaded.UpdatedAt == "" { - t.Error("UpdatedAt should be set after Save") - } -} - -func TestLoadEmptyFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "quest-tome.json") - os.WriteFile(path, []byte{}, 0644) - - _, err := Load(path) - if err == nil { - t.Error("Load should fail on empty file") - } -} - -func TestLoadMissingFile(t *testing.T) { - _, err := Load("/nonexistent/quest-tome.json") - if err == nil { - t.Error("Load should fail on missing file") - } -} - -func TestSaveAtomicWrite(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "quest-tome.json") - c := &QuestTome{Version: 1, Status: "active", PhasesCompleted: []PhaseRecord{}, GateHistory: []GateEvent{}, FilesTouched: []string{}} - - if err := Save(path, c); err != nil { - t.Fatalf("Save: %v", err) - } - // Verify no .tmp file remains - tmpPath := path + ".tmp" - if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { - t.Error("tmp file should not remain after Save") - } + "github.com/justinjdev/fellowship/cli/internal/db" + "github.com/justinjdev/fellowship/cli/internal/state" + "github.com/justinjdev/fellowship/cli/internal/tome" + "zombiezen.com/go/sqlite/sqlitex" +) - // Verify file is valid JSON - data, _ := os.ReadFile(path) - var check QuestTome - if err := json.Unmarshal(data, &check); err != nil { - t.Errorf("saved file is not valid JSON: %v", err) - } +func seedQuest(t *testing.T, d *db.DB, name string) { + t.Helper() + d.WithTx(context.Background(), func(conn *db.Conn) error { + return state.Upsert(conn, &state.State{QuestName: name, Phase: "Research"}) + }) } func TestRecordPhase(t *testing.T) { - c := &QuestTome{PhasesCompleted: []PhaseRecord{}} - - RecordPhase(c, "Research") - if len(c.PhasesCompleted) != 1 { - t.Fatalf("PhasesCompleted len = %d, want 1", len(c.PhasesCompleted)) - } - if c.PhasesCompleted[0].Phase != "Research" { - t.Errorf("Phase = %q, want Research", c.PhasesCompleted[0].Phase) - } - if c.PhasesCompleted[0].CompletedAt == "" { - t.Error("CompletedAt should be set") - } + d := db.OpenTest(t) + seedQuest(t, d, "q1") - RecordPhase(c, "Plan") - if len(c.PhasesCompleted) != 2 { - t.Fatalf("PhasesCompleted len = %d, want 2", len(c.PhasesCompleted)) - } - if c.PhasesCompleted[1].Phase != "Plan" { - t.Errorf("Phase = %q, want Plan", c.PhasesCompleted[1].Phase) - } + d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := tome.RecordPhase(conn, "q1", "Research", 120); err != nil { + t.Fatal(err) + } + phases, err := tome.LoadPhases(conn, "q1") + if err != nil { + t.Fatal(err) + } + if len(phases) != 1 || phases[0].Phase != "Research" { + t.Errorf("unexpected phases: %+v", phases) + } + return nil + }) } func TestRecordGate(t *testing.T) { - c := &QuestTome{GateHistory: []GateEvent{}} + d := db.OpenTest(t) + seedQuest(t, d, "q1") - RecordGate(c, "Research", "submitted") - if len(c.GateHistory) != 1 { - t.Fatalf("GateHistory len = %d, want 1", len(c.GateHistory)) - } - if c.GateHistory[0].Phase != "Research" { - t.Errorf("Phase = %q, want Research", c.GateHistory[0].Phase) - } - if c.GateHistory[0].Action != "submitted" { - t.Errorf("Action = %q, want submitted", c.GateHistory[0].Action) - } - if c.GateHistory[0].Timestamp == "" { - t.Error("Timestamp should be set") - } + d.WithTx(context.Background(), func(conn *db.Conn) error { + tome.RecordGate(conn, "q1", "Research", "submitted", "") + tome.RecordGate(conn, "q1", "Research", "approved", "") - RecordGate(c, "Research", "approved") - if len(c.GateHistory) != 2 { - t.Fatalf("GateHistory len = %d, want 2", len(c.GateHistory)) - } + gates, _ := tome.LoadGates(conn, "q1") + if len(gates) != 2 { + t.Fatalf("expected 2 gates, got %d", len(gates)) + } + if gates[0].Action != "submitted" { + t.Errorf("expected submitted, got %s", gates[0].Action) + } + return nil + }) } -func TestRecordFiles_Deduplication(t *testing.T) { - c := &QuestTome{FilesTouched: []string{"main.go"}} +func TestRecordFiles(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") - RecordFiles(c, []string{"main.go", "lib.go", "main.go"}) - if len(c.FilesTouched) != 2 { - t.Fatalf("FilesTouched len = %d, want 2", len(c.FilesTouched)) - } + d.WithTx(context.Background(), func(conn *db.Conn) error { + tome.RecordFiles(conn, "q1", []string{"src/main.go", "src/util.go"}) + tome.RecordFiles(conn, "q1", []string{"src/main.go", "src/new.go"}) // main.go deduplicated - expected := map[string]bool{"main.go": true, "lib.go": true} - for _, f := range c.FilesTouched { - if !expected[f] { - t.Errorf("unexpected file: %q", f) + files, _ := tome.LoadFiles(conn, "q1") + if len(files) != 3 { + t.Fatalf("expected 3 unique files, got %d: %v", len(files), files) } - } + return nil + }) } -func TestRecordFiles_Empty(t *testing.T) { - c := &QuestTome{FilesTouched: []string{"a.go"}} - RecordFiles(c, []string{}) - if len(c.FilesTouched) != 1 { - t.Errorf("FilesTouched len = %d, want 1", len(c.FilesTouched)) - } -} +func TestLoad(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") -func TestFindTome_Exists(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - dir := t.TempDir() - dataDir := filepath.Join(dir, ".fellowship") - os.MkdirAll(dataDir, 0755) - tomePath := filepath.Join(dataDir, "quest-tome.json") - os.WriteFile(tomePath, []byte(`{}`), 0644) + d.WithTx(context.Background(), func(conn *db.Conn) error { + tome.RecordPhase(conn, "q1", "Onboard", 60) + tome.RecordGate(conn, "q1", "Onboard", "approved", "") + tome.RecordFiles(conn, "q1", []string{"a.go"}) - // FindTome uses git root; test with direct dir since no git repo - found, err := FindTome(dir) - if err != nil { - t.Fatalf("FindTome: %v", err) - } - // In a non-git dir, FindTome falls back to fromDir - if found != tomePath { - t.Errorf("FindTome = %q, want %q", found, tomePath) - } + qt, err := tome.Load(conn, "q1") + if err != nil { + t.Fatal(err) + } + if len(qt.PhasesCompleted) != 1 { + t.Errorf("expected 1 phase, got %d", len(qt.PhasesCompleted)) + } + if len(qt.GateHistory) != 1 { + t.Errorf("expected 1 gate, got %d", len(qt.GateHistory)) + } + if len(qt.FilesTouched) != 1 { + t.Errorf("expected 1 file, got %d", len(qt.FilesTouched)) + } + return nil + }) } -func TestFindTome_NotExists(t *testing.T) { - dir := t.TempDir() - found, err := FindTome(dir) - if err != nil { - t.Fatalf("FindTome: %v", err) - } - if found != "" { - t.Errorf("FindTome = %q, want empty string", found) - } +func TestLoad_NoData(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + d.WithConn(context.Background(), func(conn *db.Conn) error { + qt, err := tome.Load(conn, "q1") + if err != nil { + t.Fatal(err) + } + if qt.QuestName != "q1" { + t.Errorf("expected q1, got %s", qt.QuestName) + } + if qt.Status != "active" { + t.Errorf("expected active status, got %s", qt.Status) + } + if len(qt.PhasesCompleted) != 0 { + t.Errorf("expected 0 phases, got %d", len(qt.PhasesCompleted)) + } + if len(qt.GateHistory) != 0 { + t.Errorf("expected 0 gates, got %d", len(qt.GateHistory)) + } + if len(qt.FilesTouched) != 0 { + t.Errorf("expected 0 files, got %d", len(qt.FilesTouched)) + } + return nil + }) } func TestRecordSkippedPhases(t *testing.T) { - c := &QuestTome{ - GateHistory: []GateEvent{}, - PhasesCompleted: []PhaseRecord{}, - } + d := db.OpenTest(t) + seedQuest(t, d, "q1") - RecordSkippedPhases(c, []string{"Onboard", "Research", "Plan"}, "pre-existing plan") - - if len(c.GateHistory) != 3 { - t.Fatalf("GateHistory len = %d, want 3", len(c.GateHistory)) - } - if len(c.PhasesCompleted) != 3 { - t.Fatalf("PhasesCompleted len = %d, want 3", len(c.PhasesCompleted)) - } - - for i, phase := range []string{"Onboard", "Research", "Plan"} { - if c.GateHistory[i].Phase != phase { - t.Errorf("GateHistory[%d].Phase = %q, want %q", i, c.GateHistory[i].Phase, phase) - } - if c.GateHistory[i].Action != "skipped" { - t.Errorf("GateHistory[%d].Action = %q, want skipped", i, c.GateHistory[i].Action) + d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := tome.RecordSkippedPhases(conn, "q1", []string{"Onboard", "Research", "Plan"}, "pre-existing plan"); err != nil { + t.Fatal(err) } - if c.GateHistory[i].Reason != "pre-existing plan" { - t.Errorf("GateHistory[%d].Reason = %q, want 'pre-existing plan'", i, c.GateHistory[i].Reason) + + phases, _ := tome.LoadPhases(conn, "q1") + if len(phases) != 3 { + t.Fatalf("expected 3 phases, got %d", len(phases)) } - if c.PhasesCompleted[i].Phase != phase { - t.Errorf("PhasesCompleted[%d].Phase = %q, want %q", i, c.PhasesCompleted[i].Phase, phase) + + gates, _ := tome.LoadGates(conn, "q1") + if len(gates) != 3 { + t.Fatalf("expected 3 gates, got %d", len(gates)) } - } -} -func TestLoadOrCreate_NewTome(t *testing.T) { - c := LoadOrCreate("/nonexistent/quest-tome.json") - if c.Version != 1 { - t.Errorf("Version = %d, want 1", c.Version) - } - if c.Status != "active" { - t.Errorf("Status = %q, want active", c.Status) - } - if c.CreatedAt == "" { - t.Error("CreatedAt should be set") - } + for i, phase := range []string{"Onboard", "Research", "Plan"} { + if gates[i].Phase != phase { + t.Errorf("gates[%d].Phase = %q, want %q", i, gates[i].Phase, phase) + } + if gates[i].Action != "skipped" { + t.Errorf("gates[%d].Action = %q, want skipped", i, gates[i].Action) + } + if gates[i].Reason != "pre-existing plan" { + t.Errorf("gates[%d].Reason = %q, want 'pre-existing plan'", i, gates[i].Reason) + } + if phases[i].Phase != phase { + t.Errorf("phases[%d].Phase = %q, want %q", i, phases[i].Phase, phase) + } + } + return nil + }) } -func TestLoadOrCreate_ExistingTome(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "quest-tome.json") - original := &QuestTome{Version: 1, QuestName: "existing", Status: "active", PhasesCompleted: []PhaseRecord{}, GateHistory: []GateEvent{}, FilesTouched: []string{}} - Save(path, original) +func TestSetStatus(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + d.WithTx(context.Background(), func(conn *db.Conn) error { + // Insert a fellowship_quests row for SetStatus to update. + tome.SetStatus(conn, "q1", "completed") // no-op since no fellowship_quests row yet + return nil + }) + + // Insert fellowship_quests row and test SetStatus. + d.WithTx(context.Background(), func(conn *db.Conn) error { + // Manually insert a fellowship_quests row. + if err := sqlitex.Execute(conn, `INSERT INTO fellowship_quests (name, status) VALUES ('q1', 'active')`, nil); err != nil { + t.Fatal(err) + } + if err := tome.SetStatus(conn, "q1", "completed"); err != nil { + t.Fatal(err) + } - c := LoadOrCreate(path) - if c.QuestName != "existing" { - t.Errorf("QuestName = %q, want existing", c.QuestName) - } + qt, _ := tome.Load(conn, "q1") + if qt.Status != "completed" { + t.Errorf("expected completed, got %s", qt.Status) + } + return nil + }) } From a2185c3dd45ca1eebe20eb3ae3b772f4fe4da8a0 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:25:07 -0500 Subject: [PATCH 05/19] feat: rewrite herald package with SQLite backend Co-Authored-By: Claude Opus 4.6 --- cli/internal/herald/herald.go | 142 +++++++------ cli/internal/herald/herald_test.go | 299 +++++++++++++++------------ cli/internal/herald/problems.go | 135 ++++++------ cli/internal/herald/problems_test.go | 296 +++++++++++++------------- 4 files changed, 473 insertions(+), 399 deletions(-) diff --git a/cli/internal/herald/herald.go b/cli/internal/herald/herald.go index b055288..6523359 100644 --- a/cli/internal/herald/herald.go +++ b/cli/internal/herald/herald.go @@ -1,16 +1,13 @@ package herald import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "sort" + "fmt" - "github.com/justinjdev/fellowship/cli/internal/datadir" -) + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" -const heraldFile = "quest-herald.jsonl" + "github.com/justinjdev/fellowship/cli/internal/db" +) // TidingType represents the type of a quest tiding. type TidingType string @@ -35,69 +32,94 @@ type Tiding struct { Detail string `json:"detail,omitempty"` } -// Announce appends a tiding to the herald log file. -func Announce(dir string, t Tiding) error { - dataDirPath := filepath.Join(dir, datadir.Name()) - if err := os.MkdirAll(dataDirPath, 0755); err != nil { - return err - } - path := filepath.Join(dataDirPath, heraldFile) - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return err - } - defer f.Close() - return json.NewEncoder(f).Encode(t) +// Announce inserts a tiding into the herald table. +func Announce(conn *db.Conn, t Tiding) error { + return sqlitex.Execute(conn, + `INSERT INTO herald (timestamp, quest, type, phase, detail) VALUES (?, ?, ?, ?, ?)`, + &sqlitex.ExecOptions{ + Args: []any{t.Timestamp, t.Quest, string(t.Type), t.Phase, t.Detail}, + }, + ) } -// Read returns tidings from a single worktree's herald log. -// If n > 0, returns at most the last n tidings. -func Read(dir string, n int) ([]Tiding, error) { - path := filepath.Join(dir, datadir.Name(), heraldFile) - f, err := os.Open(path) - if err != nil { - if os.IsNotExist(err) { - return []Tiding{}, nil - } - return nil, err - } - defer f.Close() - +// Read returns tidings for a single quest in ascending order (oldest first). +// If n > 0, returns the last n tidings. +func Read(conn *db.Conn, quest string, n int) ([]Tiding, error) { var tidings []Tiding - scanner := bufio.NewScanner(f) - for scanner.Scan() { - var t Tiding - if err := json.Unmarshal(scanner.Bytes(), &t); err != nil { - continue - } - tidings = append(tidings, t) + + var query string + var args []any + + if n > 0 { + // Subquery to get last n rows, then re-sort ascending. + query = `SELECT timestamp, quest, type, phase, detail + FROM (SELECT * FROM herald WHERE quest = ? ORDER BY id DESC LIMIT ?) + ORDER BY id ASC` + args = []any{quest, n} + } else { + query = `SELECT timestamp, quest, type, phase, detail FROM herald WHERE quest = ? ORDER BY id ASC` + args = []any{quest} } - if err := scanner.Err(); err != nil { - return nil, err + + err := sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: args, + ResultFunc: func(stmt *sqlite.Stmt) error { + tidings = append(tidings, Tiding{ + Timestamp: stmt.ColumnText(0), + Quest: stmt.ColumnText(1), + Type: TidingType(stmt.ColumnText(2)), + Phase: stmt.ColumnText(3), + Detail: stmt.ColumnText(4), + }) + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("herald: read quest %s: %w", quest, err) } - if n > 0 && len(tidings) > n { - tidings = tidings[len(tidings)-n:] + if tidings == nil { + tidings = []Tiding{} } return tidings, nil } -// ReadAll aggregates tidings from multiple worktrees, sorted descending by timestamp. -// If n > 0, returns at most n tidings. -func ReadAll(dirs []string, n int) ([]Tiding, error) { - var all []Tiding - for _, dir := range dirs { - tidings, err := Read(dir, 0) - if err != nil { - continue - } - all = append(all, tidings...) +// ReadAll returns tidings across all quests in ascending order (oldest first). +// If n > 0, returns the last n tidings. +func ReadAll(conn *db.Conn, n int) ([]Tiding, error) { + var tidings []Tiding + + var query string + var args []any + + if n > 0 { + query = `SELECT timestamp, quest, type, phase, detail + FROM (SELECT * FROM herald ORDER BY id DESC LIMIT ?) + ORDER BY id ASC` + args = []any{n} + } else { + query = `SELECT timestamp, quest, type, phase, detail FROM herald ORDER BY id ASC` } - sort.Slice(all, func(i, j int) bool { - return all[i].Timestamp > all[j].Timestamp + + err := sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: args, + ResultFunc: func(stmt *sqlite.Stmt) error { + tidings = append(tidings, Tiding{ + Timestamp: stmt.ColumnText(0), + Quest: stmt.ColumnText(1), + Type: TidingType(stmt.ColumnText(2)), + Phase: stmt.ColumnText(3), + Detail: stmt.ColumnText(4), + }) + return nil + }, }) - if n > 0 && len(all) > n { - all = all[:n] + if err != nil { + return nil, fmt.Errorf("herald: read all: %w", err) } - return all, nil + + if tidings == nil { + tidings = []Tiding{} + } + return tidings, nil } diff --git a/cli/internal/herald/herald_test.go b/cli/internal/herald/herald_test.go index 6bd649d..71058fe 100644 --- a/cli/internal/herald/herald_test.go +++ b/cli/internal/herald/herald_test.go @@ -1,148 +1,189 @@ package herald import ( - "os" - "path/filepath" + "context" + "fmt" "testing" + + "zombiezen.com/go/sqlite/sqlitex" + + "github.com/justinjdev/fellowship/cli/internal/db" ) -func TestAnnounceCreatesFileAndAppends(t *testing.T) { - dir := t.TempDir() - - tid1 := Tiding{ - Timestamp: "2025-01-15T10:00:00Z", - Quest: "quest-login", - Type: GateSubmitted, - Phase: "Plan", - Detail: "Gate submitted for review", - } - if err := Announce(dir, tid1); err != nil { - t.Fatalf("Announce first tiding: %v", err) - } - - // Verify file was created - path := filepath.Join(dir, ".fellowship", heraldFile) - if _, err := os.Stat(path); err != nil { - t.Fatalf("herald file not created: %v", err) - } - - tid2 := Tiding{ - Timestamp: "2025-01-15T10:05:00Z", - Quest: "quest-login", - Type: GateApproved, - Phase: "Plan", - Detail: "Gate approved", - } - if err := Announce(dir, tid2); err != nil { - t.Fatalf("Announce second tiding: %v", err) - } - - // Read back and verify - tidings, err := Read(dir, 0) - if err != nil { - t.Fatalf("Read: %v", err) - } - if len(tidings) != 2 { - t.Fatalf("got %d tidings, want 2", len(tidings)) - } - if tidings[0].Type != GateSubmitted { - t.Errorf("tidings[0].Type = %q, want %q", tidings[0].Type, GateSubmitted) - } - if tidings[1].Type != GateApproved { - t.Errorf("tidings[1].Type = %q, want %q", tidings[1].Type, GateApproved) - } +func TestAnnounceAndRead(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Announce(conn, Tiding{ + Timestamp: "2026-01-01T00:00:00Z", + Quest: "q1", + Type: GateSubmitted, + Phase: "Research", + }) + Announce(conn, Tiding{ + Timestamp: "2026-01-01T00:01:00Z", + Quest: "q1", + Type: GateApproved, + Phase: "Research", + }) + + tidings, err := Read(conn, "q1", 0) + if err != nil { + t.Fatal(err) + } + if len(tidings) != 2 { + t.Fatalf("expected 2, got %d", len(tidings)) + } + if tidings[0].Type != GateSubmitted { + t.Errorf("tidings[0].Type = %q, want %q", tidings[0].Type, GateSubmitted) + } + if tidings[1].Type != GateApproved { + t.Errorf("tidings[1].Type = %q, want %q", tidings[1].Type, GateApproved) + } + return nil + }) } func TestReadReturnsLatestN(t *testing.T) { - dir := t.TempDir() - - for i := 0; i < 10; i++ { - tid := Tiding{ - Timestamp: "2025-01-15T10:00:00Z", - Quest: "quest-login", - Type: MetadataUpdated, - Detail: "tiding", - } - if err := Announce(dir, tid); err != nil { - t.Fatalf("Announce: %v", err) - } - } - - tidings, err := Read(dir, 3) - if err != nil { - t.Fatalf("Read: %v", err) - } - if len(tidings) != 3 { - t.Fatalf("got %d tidings, want 3", len(tidings)) - } -} - -func TestReadNoFile(t *testing.T) { - dir := t.TempDir() + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + for i := 0; i < 10; i++ { + Announce(conn, Tiding{ + Timestamp: fmt.Sprintf("2026-01-01T00:%02d:00Z", i), + Quest: "q1", + Type: MetadataUpdated, + Detail: fmt.Sprintf("tiding-%d", i), + }) + } - tidings, err := Read(dir, 10) - if err != nil { - t.Fatalf("Read: %v", err) - } - if len(tidings) != 0 { - t.Fatalf("got %d tidings, want 0", len(tidings)) - } + tidings, err := Read(conn, "q1", 3) + if err != nil { + t.Fatal(err) + } + if len(tidings) != 3 { + t.Fatalf("got %d tidings, want 3", len(tidings)) + } + // Should be last 3 in ascending order + if tidings[0].Detail != "tiding-7" { + t.Errorf("tidings[0].Detail = %q, want tiding-7", tidings[0].Detail) + } + if tidings[2].Detail != "tiding-9" { + t.Errorf("tidings[2].Detail = %q, want tiding-9", tidings[2].Detail) + } + return nil + }) } -func TestReadAllAggregatesAcrossWorktrees(t *testing.T) { - wt1 := t.TempDir() - wt2 := t.TempDir() - - Announce(wt1, Tiding{ - Timestamp: "2025-01-15T10:00:00Z", - Quest: "quest-a", - Type: GateSubmitted, +func TestReadNoData(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + tidings, err := Read(conn, "nonexistent", 10) + if err != nil { + t.Fatal(err) + } + if len(tidings) != 0 { + t.Fatalf("got %d tidings, want 0", len(tidings)) + } + return nil }) - Announce(wt1, Tiding{ - Timestamp: "2025-01-15T10:10:00Z", - Quest: "quest-a", - Type: GateApproved, +} + +func TestReadAll_Limit(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + for i := 0; i < 5; i++ { + Announce(conn, Tiding{ + Timestamp: fmt.Sprintf("2026-01-01T00:%02d:00Z", i), + Quest: "q1", + Type: PhaseTransition, + }) + } + tidings, _ := ReadAll(conn, 3) + if len(tidings) != 3 { + t.Fatalf("expected 3, got %d", len(tidings)) + } + return nil }) - Announce(wt2, Tiding{ - Timestamp: "2025-01-15T10:05:00Z", - Quest: "quest-b", - Type: PhaseTransition, +} + +func TestReadAllAcrossQuests(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Announce(conn, Tiding{ + Timestamp: "2026-01-01T00:00:00Z", + Quest: "q1", + Type: GateSubmitted, + }) + Announce(conn, Tiding{ + Timestamp: "2026-01-01T00:05:00Z", + Quest: "q2", + Type: PhaseTransition, + }) + Announce(conn, Tiding{ + Timestamp: "2026-01-01T00:10:00Z", + Quest: "q1", + Type: GateApproved, + }) + + tidings, err := ReadAll(conn, 0) + if err != nil { + t.Fatal(err) + } + if len(tidings) != 3 { + t.Fatalf("got %d tidings, want 3", len(tidings)) + } + // Ascending order by id (insertion order) + if tidings[0].Quest != "q1" || tidings[0].Type != GateSubmitted { + t.Errorf("tidings[0] = %+v, want q1/gate_submitted", tidings[0]) + } + if tidings[1].Quest != "q2" { + t.Errorf("tidings[1].Quest = %q, want q2", tidings[1].Quest) + } + if tidings[2].Quest != "q1" || tidings[2].Type != GateApproved { + t.Errorf("tidings[2] = %+v, want q1/gate_approved", tidings[2]) + } + return nil }) +} - tidings, err := ReadAll([]string{wt1, wt2}, 10) - if err != nil { - t.Fatalf("ReadAll: %v", err) - } - if len(tidings) != 3 { - t.Fatalf("got %d tidings, want 3", len(tidings)) - } - - // Should be sorted descending by timestamp - if tidings[0].Timestamp != "2025-01-15T10:10:00Z" { - t.Errorf("tidings[0].Timestamp = %q, want 2025-01-15T10:10:00Z", tidings[0].Timestamp) - } - if tidings[1].Timestamp != "2025-01-15T10:05:00Z" { - t.Errorf("tidings[1].Timestamp = %q, want 2025-01-15T10:05:00Z", tidings[1].Timestamp) - } - if tidings[2].Timestamp != "2025-01-15T10:00:00Z" { - t.Errorf("tidings[2].Timestamp = %q, want 2025-01-15T10:00:00Z", tidings[2].Timestamp) - } +func TestDetectProblems_Struggling(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + // Create a quest in Research phase + sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, phase, gate_pending, created_at, updated_at) + VALUES ('q1', 'Research', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil) + + // Add 2 rejections in Research phase + Announce(conn, Tiding{Timestamp: "2026-01-01T00:01:00Z", Quest: "q1", Type: GateRejected, Phase: "Research"}) + Announce(conn, Tiding{Timestamp: "2026-01-01T00:02:00Z", Quest: "q1", Type: GateRejected, Phase: "Research"}) + + problems := DetectProblems(conn) + found := false + for _, p := range problems { + if p.Type == "struggling" && p.Quest == "q1" { + found = true + break + } + } + if !found { + t.Errorf("expected struggling problem for q1, got %+v", problems) + } + return nil + }) } -func TestReadAllWithLimit(t *testing.T) { - wt1 := t.TempDir() - wt2 := t.TempDir() - - for i := 0; i < 5; i++ { - Announce(wt1, Tiding{Timestamp: "2025-01-15T10:00:00Z", Quest: "a", Type: GateSubmitted}) - Announce(wt2, Tiding{Timestamp: "2025-01-15T10:01:00Z", Quest: "b", Type: GateSubmitted}) - } - - tidings, err := ReadAll([]string{wt1, wt2}, 3) - if err != nil { - t.Fatalf("ReadAll: %v", err) - } - if len(tidings) != 3 { - t.Fatalf("got %d tidings, want 3", len(tidings)) - } +func TestDetectProblems_NoProblems(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + // Quest in Complete phase should not be checked + sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, phase, gate_pending, created_at, updated_at) + VALUES ('q1', 'Complete', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil) + + problems := DetectProblems(conn) + if len(problems) != 0 { + t.Errorf("expected 0 problems, got %+v", problems) + } + return nil + }) } diff --git a/cli/internal/herald/problems.go b/cli/internal/herald/problems.go index 0654ac6..6c2fa63 100644 --- a/cli/internal/herald/problems.go +++ b/cli/internal/herald/problems.go @@ -1,15 +1,15 @@ package herald import ( - "encoding/json" "fmt" - "os" - "path/filepath" "strconv" "strings" "time" - "github.com/justinjdev/fellowship/cli/internal/datadir" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" + + "github.com/justinjdev/fellowship/cli/internal/db" ) // Severity represents the severity level of a detected problem. @@ -28,40 +28,42 @@ type Problem struct { Message string `json:"message"` } -type questState struct { - QuestName string `json:"quest_name"` - Phase string `json:"phase"` - GatePending bool `json:"gate_pending"` - GateID *string `json:"gate_id"` -} - -// DetectProblems scans worktrees for potential issues. -func DetectProblems(dirs []string) []Problem { +// DetectProblems scans the database for potential quest issues. +func DetectProblems(conn *db.Conn) []Problem { var problems []Problem - for _, dir := range dirs { - statePath := filepath.Join(dir, datadir.Name(), "quest-state.json") - data, err := os.ReadFile(statePath) - if err != nil { - continue - } - var qs questState - if err := json.Unmarshal(data, &qs); err != nil { - continue - } + // Query all active quests (not Complete). + type questInfo struct { + questName string + phase string + gatePending bool + gateID string + } - questName := qs.QuestName - if questName == "" { - questName = filepath.Base(dir) - } + var quests []questInfo + _ = sqlitex.Execute(conn, + `SELECT quest_name, phase, gate_pending, gate_id FROM quest_state WHERE phase != 'Complete'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + quests = append(quests, questInfo{ + questName: stmt.ColumnText(0), + phase: stmt.ColumnText(1), + gatePending: stmt.ColumnInt(2) != 0, + gateID: stmt.ColumnText(3), + }) + return nil + }, + }, + ) + for _, qs := range quests { // Stalled detection: gate pending for too long - if qs.GatePending && qs.GateID != nil { - if ts := extractTimestamp(*qs.GateID); ts > 0 { + if qs.gatePending && qs.gateID != "" { + if ts := extractTimestamp(qs.gateID); ts > 0 { age := time.Since(time.Unix(ts, 0)) if age > 10*time.Minute { problems = append(problems, Problem{ - Quest: questName, + Quest: qs.questName, Type: "stalled", Severity: Warning, Message: fmt.Sprintf("Gate pending for %s", formatDuration(age)), @@ -70,43 +72,52 @@ func DetectProblems(dirs []string) []Problem { } } - // Zombie detection: quest not Complete, no recent activity - if qs.Phase != "Complete" { - tidings, err := Read(dir, 0) - if err == nil && len(tidings) > 0 { - last := tidings[len(tidings)-1] - lastTime, err := time.Parse(time.RFC3339, last.Timestamp) - if err == nil { - age := time.Since(lastTime) - if age > 15*time.Minute { - problems = append(problems, Problem{ - Quest: questName, - Type: "zombie", - Severity: Critical, - Message: fmt.Sprintf("No activity for %s", formatDuration(age)), - }) - } + // Zombie detection: no recent activity + var lastTimestamp string + _ = sqlitex.Execute(conn, + `SELECT timestamp FROM herald WHERE quest = ? ORDER BY id DESC LIMIT 1`, + &sqlitex.ExecOptions{ + Args: []any{qs.questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + lastTimestamp = stmt.ColumnText(0) + return nil + }, + }, + ) + if lastTimestamp != "" { + lastTime, err := time.Parse(time.RFC3339, lastTimestamp) + if err == nil { + age := time.Since(lastTime) + if age > 15*time.Minute { + problems = append(problems, Problem{ + Quest: qs.questName, + Type: "zombie", + Severity: Critical, + Message: fmt.Sprintf("No activity for %s", formatDuration(age)), + }) } } } // Struggling detection: multiple rejections in same phase - tidings, err := Read(dir, 0) - if err == nil { - rejections := 0 - for _, t := range tidings { - if t.Type == GateRejected && t.Phase == qs.Phase { - rejections++ - } - } - if rejections >= 2 { - problems = append(problems, Problem{ - Quest: questName, - Type: "struggling", - Severity: Warning, - Message: fmt.Sprintf("Gate rejected %d times in %s phase", rejections, qs.Phase), - }) - } + var rejections int + _ = sqlitex.Execute(conn, + `SELECT count(*) FROM herald WHERE quest = ? AND type = ? AND phase = ?`, + &sqlitex.ExecOptions{ + Args: []any{qs.questName, string(GateRejected), qs.phase}, + ResultFunc: func(stmt *sqlite.Stmt) error { + rejections = stmt.ColumnInt(0) + return nil + }, + }, + ) + if rejections >= 2 { + problems = append(problems, Problem{ + Quest: qs.questName, + Type: "struggling", + Severity: Warning, + Message: fmt.Sprintf("Gate rejected %d times in %s phase", rejections, qs.phase), + }) } } diff --git a/cli/internal/herald/problems_test.go b/cli/internal/herald/problems_test.go index c218018..451e08c 100644 --- a/cli/internal/herald/problems_test.go +++ b/cli/internal/herald/problems_test.go @@ -1,194 +1,194 @@ package herald import ( - "encoding/json" + "context" "fmt" - "os" - "path/filepath" "testing" "time" -) -func writeQuestState(t *testing.T, dir string, phase string, gatePending bool, gateID *string) { - t.Helper() - t.Setenv("HOME", t.TempDir()) - dataDir := filepath.Join(dir, ".fellowship") - if err := os.MkdirAll(dataDir, 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } + "zombiezen.com/go/sqlite/sqlitex" + + "github.com/justinjdev/fellowship/cli/internal/db" +) - state := map[string]interface{}{ - "version": 1, - "quest_name": filepath.Base(dir), - "phase": phase, - "gate_pending": gatePending, - "gate_id": gateID, - "lembas_completed": false, - "metadata_updated": false, - "auto_approve_gates": []string{}, +func insertQuestState(conn *db.Conn, questName, phase string, gatePending bool, gateID string) { + gp := 0 + if gatePending { + gp = 1 } - data, _ := json.MarshalIndent(state, "", " ") - if err := os.WriteFile(filepath.Join(dataDir, "quest-state.json"), data, 0644); err != nil { - t.Fatalf("writing quest-state.json: %v", err) + var gateIDArg any + if gateID != "" { + gateIDArg = gateID } + sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, phase, gate_pending, gate_id, created_at, updated_at) + VALUES (?, ?, ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, + &sqlitex.ExecOptions{ + Args: []any{questName, phase, gp, gateIDArg}, + }, + ) } func TestStalledDetection(t *testing.T) { - dir := t.TempDir() - oldTimestamp := time.Now().Add(-15 * time.Minute).Unix() - gateID := fmt.Sprintf("gate-Plan-%d", oldTimestamp) - writeQuestState(t, dir, "Plan", true, &gateID) - - problems := DetectProblems([]string{dir}) - - var found bool - for _, p := range problems { - if p.Type == "stalled" { - found = true - if p.Severity != Warning { - t.Errorf("stalled severity = %q, want %q", p.Severity, Warning) + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + oldTimestamp := time.Now().Add(-15 * time.Minute).Unix() + gateID := fmt.Sprintf("gate-Plan-%d", oldTimestamp) + insertQuestState(conn, "q1", "Plan", true, gateID) + + problems := DetectProblems(conn) + + var found bool + for _, p := range problems { + if p.Type == "stalled" { + found = true + if p.Severity != Warning { + t.Errorf("stalled severity = %q, want %q", p.Severity, Warning) + } } } - } - if !found { - t.Errorf("expected stalled problem, got %v", problems) - } + if !found { + t.Errorf("expected stalled problem, got %v", problems) + } + return nil + }) } func TestStalledNotDetectedWhenRecent(t *testing.T) { - dir := t.TempDir() - recentTimestamp := time.Now().Add(-2 * time.Minute).Unix() - gateID := fmt.Sprintf("gate-Plan-%d", recentTimestamp) - writeQuestState(t, dir, "Plan", true, &gateID) + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + recentTimestamp := time.Now().Add(-2 * time.Minute).Unix() + gateID := fmt.Sprintf("gate-Plan-%d", recentTimestamp) + insertQuestState(conn, "q1", "Plan", true, gateID) - problems := DetectProblems([]string{dir}) + problems := DetectProblems(conn) - for _, p := range problems { - if p.Type == "stalled" { - t.Errorf("unexpected stalled problem: %v", p) + for _, p := range problems { + if p.Type == "stalled" { + t.Errorf("unexpected stalled problem: %v", p) + } } - } + return nil + }) } func TestZombieDetection(t *testing.T) { - dir := t.TempDir() - writeQuestState(t, dir, "Implement", false, nil) - - // Write an old tiding - oldTime := time.Now().Add(-20 * time.Minute).UTC().Format(time.RFC3339) - Announce(dir, Tiding{ - Timestamp: oldTime, - Quest: "test-quest", - Type: MetadataUpdated, - }) - - problems := DetectProblems([]string{dir}) - - var found bool - for _, p := range problems { - if p.Type == "zombie" { - found = true - if p.Severity != Critical { - t.Errorf("zombie severity = %q, want %q", p.Severity, Critical) + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(conn, "q1", "Implement", false, "") + + oldTime := time.Now().Add(-20 * time.Minute).UTC().Format(time.RFC3339) + Announce(conn, Tiding{ + Timestamp: oldTime, + Quest: "q1", + Type: MetadataUpdated, + }) + + problems := DetectProblems(conn) + + var found bool + for _, p := range problems { + if p.Type == "zombie" { + found = true + if p.Severity != Critical { + t.Errorf("zombie severity = %q, want %q", p.Severity, Critical) + } } } - } - if !found { - t.Errorf("expected zombie problem, got %v", problems) - } + if !found { + t.Errorf("expected zombie problem, got %v", problems) + } + return nil + }) } func TestZombieNotDetectedWhenComplete(t *testing.T) { - dir := t.TempDir() - writeQuestState(t, dir, "Complete", false, nil) - - oldTime := time.Now().Add(-20 * time.Minute).UTC().Format(time.RFC3339) - Announce(dir, Tiding{ - Timestamp: oldTime, - Quest: "test-quest", - Type: MetadataUpdated, - }) - - problems := DetectProblems([]string{dir}) - - for _, p := range problems { - if p.Type == "zombie" { - t.Errorf("unexpected zombie problem for Complete quest: %v", p) + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(conn, "q1", "Complete", false, "") + + oldTime := time.Now().Add(-20 * time.Minute).UTC().Format(time.RFC3339) + Announce(conn, Tiding{ + Timestamp: oldTime, + Quest: "q1", + Type: MetadataUpdated, + }) + + problems := DetectProblems(conn) + + for _, p := range problems { + if p.Type == "zombie" { + t.Errorf("unexpected zombie problem for Complete quest: %v", p) + } } - } + return nil + }) } func TestStrugglingDetection(t *testing.T) { - dir := t.TempDir() - writeQuestState(t, dir, "Plan", false, nil) - - now := time.Now().UTC().Format(time.RFC3339) - // Two rejections for the same phase - Announce(dir, Tiding{ - Timestamp: now, - Quest: "test-quest", - Type: GateRejected, - Phase: "Plan", - }) - Announce(dir, Tiding{ - Timestamp: now, - Quest: "test-quest", - Type: GateRejected, - Phase: "Plan", - }) - - problems := DetectProblems([]string{dir}) - - var found bool - for _, p := range problems { - if p.Type == "struggling" { - found = true - if p.Severity != Warning { - t.Errorf("struggling severity = %q, want %q", p.Severity, Warning) + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(conn, "q1", "Plan", false, "") + + now := time.Now().UTC().Format(time.RFC3339) + Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}) + Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}) + + problems := DetectProblems(conn) + + var found bool + for _, p := range problems { + if p.Type == "struggling" { + found = true + if p.Severity != Warning { + t.Errorf("struggling severity = %q, want %q", p.Severity, Warning) + } } } - } - if !found { - t.Errorf("expected struggling problem, got %v", problems) - } + if !found { + t.Errorf("expected struggling problem, got %v", problems) + } + return nil + }) } func TestStrugglingNotDetectedWithOneRejection(t *testing.T) { - dir := t.TempDir() - writeQuestState(t, dir, "Plan", false, nil) - - now := time.Now().UTC().Format(time.RFC3339) - Announce(dir, Tiding{ - Timestamp: now, - Quest: "test-quest", - Type: GateRejected, - Phase: "Plan", - }) + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(conn, "q1", "Plan", false, "") + + now := time.Now().UTC().Format(time.RFC3339) + Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}) - problems := DetectProblems([]string{dir}) + problems := DetectProblems(conn) - for _, p := range problems { - if p.Type == "struggling" { - t.Errorf("unexpected struggling problem with only 1 rejection: %v", p) + for _, p := range problems { + if p.Type == "struggling" { + t.Errorf("unexpected struggling problem with only 1 rejection: %v", p) + } } - } + return nil + }) } func TestNoProblemsForHealthyQuest(t *testing.T) { - dir := t.TempDir() - writeQuestState(t, dir, "Implement", false, nil) - - now := time.Now().UTC().Format(time.RFC3339) - Announce(dir, Tiding{ - Timestamp: now, - Quest: "test-quest", - Type: GateApproved, - Phase: "Plan", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(conn, "q1", "Implement", false, "") + + now := time.Now().UTC().Format(time.RFC3339) + Announce(conn, Tiding{ + Timestamp: now, + Quest: "q1", + Type: GateApproved, + Phase: "Plan", + }) + + problems := DetectProblems(conn) + + if len(problems) != 0 { + t.Errorf("expected no problems, got %v", problems) + } + return nil }) - - problems := DetectProblems([]string{dir}) - - if len(problems) != 0 { - t.Errorf("expected no problems, got %v", problems) - } } From e18305506784b86e1ed0854a93e2ad941e3dbc3b Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:27:34 -0500 Subject: [PATCH 06/19] feat: rewrite bulletin package with SQLite backend Co-Authored-By: Claude Opus 4.6 --- cli/internal/bulletin/bulletin.go | 202 +++++------- cli/internal/bulletin/bulletin_test.go | 431 ++++++++----------------- 2 files changed, 230 insertions(+), 403 deletions(-) diff --git a/cli/internal/bulletin/bulletin.go b/cli/internal/bulletin/bulletin.go index 385999f..ae66276 100644 --- a/cli/internal/bulletin/bulletin.go +++ b/cli/internal/bulletin/bulletin.go @@ -1,17 +1,14 @@ package bulletin import ( - "bufio" - "encoding/json" "fmt" - "os" - "os/exec" - "path/filepath" "strings" "time" - "github.com/justinjdev/fellowship/cli/internal/datadir" - "github.com/justinjdev/fellowship/cli/internal/filelock" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" + + "github.com/justinjdev/fellowship/cli/internal/db" ) // Entry represents a single bulletin board discovery. @@ -23,86 +20,107 @@ type Entry struct { Discovery string `json:"discovery"` } -// Post appends an entry to the bulletin JSONL file with exclusive file locking. -func Post(path string, entry Entry) error { +// Post inserts an entry into the bulletin table and its files into bulletin_files. +func Post(conn *db.Conn, entry Entry) error { if entry.Timestamp == "" { entry.Timestamp = time.Now().UTC().Format(time.RFC3339) } - line, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("marshaling entry: %w", err) + if err := sqlitex.Execute(conn, + `INSERT INTO bulletin (timestamp, quest, topic, discovery) VALUES (?, ?, ?, ?)`, + &sqlitex.ExecOptions{ + Args: []any{entry.Timestamp, entry.Quest, entry.Topic, entry.Discovery}, + }, + ); err != nil { + return fmt.Errorf("bulletin: post: %w", err) } - line = append(line, '\n') - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("creating directory: %w", err) + id := conn.LastInsertRowID() + + for _, f := range entry.Files { + if err := sqlitex.Execute(conn, + `INSERT INTO bulletin_files (bulletin_id, file_path) VALUES (?, ?)`, + &sqlitex.ExecOptions{ + Args: []any{id, f}, + }, + ); err != nil { + return fmt.Errorf("bulletin: post file %s: %w", f, err) + } } + return nil +} - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) +// Load reads all bulletin entries from the database, assembling the Files slice +// from the bulletin_files join table. +func Load(conn *db.Conn) ([]Entry, error) { + // First load all entries. + type row struct { + id int64 + entry Entry + } + var rows []row + + err := sqlitex.Execute(conn, + `SELECT id, timestamp, quest, topic, discovery FROM bulletin ORDER BY id ASC`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + rows = append(rows, row{ + id: stmt.ColumnInt64(0), + entry: Entry{ + Timestamp: stmt.ColumnText(1), + Quest: stmt.ColumnText(2), + Topic: stmt.ColumnText(3), + Discovery: stmt.ColumnText(4), + }, + }) + return nil + }, + }, + ) if err != nil { - return fmt.Errorf("opening bulletin file: %w", err) + return nil, fmt.Errorf("bulletin: load: %w", err) } - defer f.Close() - if err := filelock.Lock(f.Fd()); err != nil { - return fmt.Errorf("locking bulletin file: %w", err) + if len(rows) == 0 { + return nil, nil } - defer filelock.Unlock(f.Fd()) - if _, err := f.Write(line); err != nil { - return fmt.Errorf("writing entry: %w", err) + // Build a map for file association. + idToIdx := make(map[int64]int, len(rows)) + for i, r := range rows { + idToIdx[r.id] = i } - return nil -} -// Load reads all entries from the bulletin JSONL file under an exclusive lock -// to avoid observing partially written lines from concurrent Post/Clear calls. -func Load(path string) ([]Entry, error) { - f, err := os.Open(path) + // Load all files for these bulletin entries. + err = sqlitex.Execute(conn, + `SELECT bulletin_id, file_path FROM bulletin_files ORDER BY bulletin_id, file_path`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + bid := stmt.ColumnInt64(0) + if idx, ok := idToIdx[bid]; ok { + rows[idx].entry.Files = append(rows[idx].entry.Files, stmt.ColumnText(1)) + } + return nil + }, + }, + ) if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("opening bulletin file: %w", err) + return nil, fmt.Errorf("bulletin: load files: %w", err) } - defer f.Close() - if err := filelock.Lock(f.Fd()); err != nil { - return nil, fmt.Errorf("locking bulletin file for read: %w", err) - } - defer filelock.Unlock(f.Fd()) - - var entries []Entry - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - var e Entry - if err := json.Unmarshal([]byte(line), &e); err != nil { - continue // skip malformed lines - } - entries = append(entries, e) - } - if err := scanner.Err(); err != nil { - return entries, fmt.Errorf("reading bulletin file: %w", err) + entries := make([]Entry, len(rows)) + for i, r := range rows { + entries[i] = r.entry } return entries, nil } -// Scan reads the bulletin and returns entries matching the given files or topics. -// An entry matches if any of its files have a prefix in the files list, or if its -// topic matches any of the given topics. Both filters are case-insensitive. -// If both files and topics are empty, all entries are returned. -// -// File matching is bidirectional: filter "src/auth/" matches entry file -// "src/auth/jwt.go", and filter "src/auth/jwt.go" also matches entry file -// "src/auth/". This allows both directory-level and file-level filters. -func Scan(path string, files []string, topics []string) ([]Entry, error) { - all, err := Load(path) +// Scan reads all bulletin entries and returns those matching the given files or topics. +// An entry matches if any of its files have a bidirectional path containment with the +// files list, or if its topic matches any of the given topics. Both filters are +// case-insensitive. If both files and topics are empty, all entries are returned. +func Scan(conn *db.Conn, files []string, topics []string) ([]Entry, error) { + all, err := Load(conn) if err != nil { return nil, err } @@ -125,59 +143,17 @@ func Scan(path string, files []string, topics []string) ([]Entry, error) { return result, nil } -// Clear truncates the bulletin file in place under an exclusive lock, -// ensuring concurrent Post calls are not lost to an unlinked inode. -func Clear(path string) error { - f, err := os.OpenFile(path, os.O_RDWR, 0644) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("opening bulletin file: %w", err) - } - defer f.Close() - - if err := filelock.Lock(f.Fd()); err != nil { - return fmt.Errorf("locking bulletin file: %w", err) +// Clear deletes all bulletin entries and their associated files. +func Clear(conn *db.Conn) error { + if err := sqlitex.Execute(conn, `DELETE FROM bulletin_files`, nil); err != nil { + return fmt.Errorf("bulletin: clear files: %w", err) } - defer filelock.Unlock(f.Fd()) - - if err := f.Truncate(0); err != nil { - return fmt.Errorf("clearing bulletin: %w", err) + if err := sqlitex.Execute(conn, `DELETE FROM bulletin`, nil); err != nil { + return fmt.Errorf("bulletin: clear: %w", err) } return nil } -// MainRepoRoot returns the main repository root, even when called from a worktree. -// Uses git's --git-common-dir to find the shared .git directory. -func MainRepoRoot(fromDir string) (string, error) { - return mainRepoRootFunc(fromDir) -} - -var mainRepoRootFunc = func(fromDir string) (string, error) { - cmd := exec.Command("git", "rev-parse", "--path-format=absolute", "--git-common-dir") - if fromDir != "" { - cmd.Dir = fromDir - } - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("finding main repo root: %w", err) - } - gitDir := strings.TrimSpace(string(out)) - // --git-common-dir returns the .git directory; parent is the repo root - root := filepath.Dir(gitDir) - return root, nil -} - -// BulletinPath returns the path to the bulletin JSONL file in the main repo. -func BulletinPath(fromDir string) (string, error) { - root, err := MainRepoRoot(fromDir) - if err != nil { - return "", err - } - return filepath.Join(root, datadir.Name(), "bulletin.jsonl"), nil -} - func matchesTopic(topic string, lowerTopics []string) bool { if len(lowerTopics) == 0 { return false diff --git a/cli/internal/bulletin/bulletin_test.go b/cli/internal/bulletin/bulletin_test.go index d40ca25..b78eb5b 100644 --- a/cli/internal/bulletin/bulletin_test.go +++ b/cli/internal/bulletin/bulletin_test.go @@ -1,317 +1,168 @@ package bulletin import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync" + "context" "testing" - "github.com/justinjdev/fellowship/cli/internal/datadir" + "github.com/justinjdev/fellowship/cli/internal/db" ) func TestPostAndLoad(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - e1 := Entry{Quest: "quest-1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "JWT moved"} - e2 := Entry{Quest: "quest-2", Topic: "db", Files: []string{"src/db/conn.go"}, Discovery: "Connection pooling changed"} - - if err := Post(path, e1); err != nil { - t.Fatalf("Post e1: %v", err) - } - if err := Post(path, e2); err != nil { - t.Fatalf("Post e2: %v", err) - } - - entries, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if entries[0].Quest != "quest-1" || entries[1].Quest != "quest-2" { - t.Errorf("unexpected entries: %+v", entries) - } - if entries[0].Timestamp == "" { - t.Error("expected timestamp to be set") - } -} - -func TestPostCreatesDirectory(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "sub", "dir", "bulletin.jsonl") - - e := Entry{Quest: "q", Topic: "t", Discovery: "d"} - if err := Post(path, e); err != nil { - t.Fatalf("Post: %v", err) - } - - entries, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if len(entries) != 1 { - t.Fatalf("expected 1 entry, got %d", len(entries)) - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Post(conn, Entry{ + Timestamp: "2026-01-01T00:00:00Z", Quest: "q1", + Topic: "auth", Files: []string{"auth.go"}, Discovery: "needs refactor", + }) + entries, err := Load(conn) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected 1, got %d", len(entries)) + } + if entries[0].Topic != "auth" { + t.Error("topic mismatch") + } + return nil + }) +} + +func TestScan_ByFile(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Post(conn, Entry{ + Quest: "q1", Topic: "auth", Files: []string{"src/auth.go"}, Discovery: "d1", + Timestamp: "2026-01-01T00:00:00Z", + }) + Post(conn, Entry{ + Quest: "q2", Topic: "db", Files: []string{"src/db.go"}, Discovery: "d2", + Timestamp: "2026-01-01T00:00:00Z", + }) + matches, _ := Scan(conn, []string{"src/auth.go"}, nil) + if len(matches) != 1 { + t.Fatalf("expected 1, got %d", len(matches)) + } + return nil + }) } -func TestPostPreservesTimestamp(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - e := Entry{Timestamp: "2026-01-01T00:00:00Z", Quest: "q", Topic: "t", Discovery: "d"} - if err := Post(path, e); err != nil { - t.Fatalf("Post: %v", err) - } - - entries, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if entries[0].Timestamp != "2026-01-01T00:00:00Z" { - t.Errorf("expected preserved timestamp, got %s", entries[0].Timestamp) - } +func TestClear(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Post(conn, Entry{ + Quest: "q1", Topic: "t", Discovery: "d", Timestamp: "2026-01-01T00:00:00Z", + }) + Clear(conn) + entries, _ := Load(conn) + if len(entries) != 0 { + t.Fatalf("expected 0, got %d", len(entries)) + } + return nil + }) } -func TestLoadNonexistent(t *testing.T) { - entries, err := Load("/nonexistent/path/bulletin.jsonl") - if err != nil { - t.Fatalf("Load nonexistent: %v", err) - } - if entries != nil { - t.Errorf("expected nil entries, got %v", entries) - } +func TestPostSetsTimestamp(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Post(conn, Entry{Quest: "q1", Topic: "t", Discovery: "d"}) + entries, err := Load(conn) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected 1, got %d", len(entries)) + } + if entries[0].Timestamp == "" { + t.Error("expected timestamp to be set") + } + return nil + }) } -func TestLoadSkipsMalformed(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - content := `{"ts":"2026-01-01T00:00:00Z","quest":"q1","topic":"t","files":[],"discovery":"good"} -not json at all -{"ts":"2026-01-02T00:00:00Z","quest":"q2","topic":"t","files":[],"discovery":"also good"} -` - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - entries, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries (skipping malformed), got %d", len(entries)) - } +func TestPostPreservesTimestamp(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Post(conn, Entry{ + Timestamp: "2026-01-01T00:00:00Z", Quest: "q", Topic: "t", Discovery: "d", + }) + entries, err := Load(conn) + if err != nil { + t.Fatal(err) + } + if entries[0].Timestamp != "2026-01-01T00:00:00Z" { + t.Errorf("expected preserved timestamp, got %s", entries[0].Timestamp) + } + return nil + }) } func TestScanByTopic(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - Post(path, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1"}) - Post(path, Entry{Quest: "q2", Topic: "db", Files: []string{"src/db/conn.go"}, Discovery: "d2"}) - Post(path, Entry{Quest: "q3", Topic: "Auth", Files: []string{"src/auth/session.go"}, Discovery: "d3"}) - - entries, err := Scan(path, nil, []string{"auth"}) - if err != nil { - t.Fatalf("Scan: %v", err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries matching topic 'auth', got %d", len(entries)) - } -} - -func TestScanByFiles(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - Post(path, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1"}) - Post(path, Entry{Quest: "q2", Topic: "db", Files: []string{"src/db/conn.go"}, Discovery: "d2"}) - - entries, err := Scan(path, []string{"src/auth/"}, nil) - if err != nil { - t.Fatalf("Scan: %v", err) - } - if len(entries) != 1 { - t.Fatalf("expected 1 entry matching files, got %d", len(entries)) - } - if entries[0].Quest != "q1" { - t.Errorf("expected quest q1, got %s", entries[0].Quest) - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Post(conn, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}) + Post(conn, Entry{Quest: "q2", Topic: "db", Files: []string{"src/db/conn.go"}, Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}) + Post(conn, Entry{Quest: "q3", Topic: "Auth", Files: []string{"src/auth/session.go"}, Discovery: "d3", Timestamp: "2026-01-01T00:00:00Z"}) + + entries, err := Scan(conn, nil, []string{"auth"}) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries matching topic 'auth', got %d", len(entries)) + } + return nil + }) } func TestScanByFilesPathBoundary(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - Post(path, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1"}) - Post(path, Entry{Quest: "q2", Topic: "authz", Files: []string{"src/authz/login.go"}, Discovery: "d2"}) - - // "src/auth" should match "src/auth/jwt.go" but NOT "src/authz/login.go" - entries, err := Scan(path, []string{"src/auth"}, nil) - if err != nil { - t.Fatalf("Scan: %v", err) - } - if len(entries) != 1 { - t.Fatalf("expected 1 entry (path boundary match), got %d", len(entries)) - } - if entries[0].Quest != "q1" { - t.Errorf("expected quest q1, got %s", entries[0].Quest) - } -} - -func TestScanBothFilters(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - Post(path, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1"}) - Post(path, Entry{Quest: "q2", Topic: "db", Files: []string{"src/db/conn.go"}, Discovery: "d2"}) - Post(path, Entry{Quest: "q3", Topic: "cache", Files: []string{"src/cache/redis.go"}, Discovery: "d3"}) - - entries, err := Scan(path, []string{"src/db/"}, []string{"auth"}) - if err != nil { - t.Fatalf("Scan: %v", err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Post(conn, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}) + Post(conn, Entry{Quest: "q2", Topic: "authz", Files: []string{"src/authz/login.go"}, Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}) + + // "src/auth" should match "src/auth/jwt.go" but NOT "src/authz/login.go" + entries, err := Scan(conn, []string{"src/auth"}, nil) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry (path boundary match), got %d", len(entries)) + } + if entries[0].Quest != "q1" { + t.Errorf("expected quest q1, got %s", entries[0].Quest) + } + return nil + }) } func TestScanNoFilters(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - Post(path, Entry{Quest: "q1", Topic: "auth", Files: []string{}, Discovery: "d1"}) - Post(path, Entry{Quest: "q2", Topic: "db", Files: []string{}, Discovery: "d2"}) - - entries, err := Scan(path, nil, nil) - if err != nil { - t.Fatalf("Scan: %v", err) - } - if len(entries) != 2 { - t.Fatalf("expected all 2 entries with no filters, got %d", len(entries)) - } -} - -func TestClear(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - Post(path, Entry{Quest: "q1", Topic: "t", Discovery: "d"}) - - if err := Clear(path); err != nil { - t.Fatalf("Clear: %v", err) - } - - entries, err := Load(path) - if err != nil { - t.Fatalf("Load after clear: %v", err) - } - if entries != nil { - t.Errorf("expected nil entries after clear, got %v", entries) - } -} - -func TestClearNonexistent(t *testing.T) { - if err := Clear("/nonexistent/bulletin.jsonl"); err != nil { - t.Fatalf("Clear nonexistent should not error: %v", err) - } -} - -func TestPostJSONLFormat(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - Post(path, Entry{Quest: "q1", Topic: "t", Discovery: "d1"}) - Post(path, Entry{Quest: "q2", Topic: "t", Discovery: "d2"}) - - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d", len(lines)) - } - - for i, line := range lines { - var e Entry - if err := json.Unmarshal([]byte(line), &e); err != nil { - t.Errorf("line %d is not valid JSON: %v", i, err) + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + Post(conn, Entry{Quest: "q1", Topic: "auth", Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}) + Post(conn, Entry{Quest: "q2", Topic: "db", Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}) + + entries, err := Scan(conn, nil, nil) + if err != nil { + t.Fatalf("Scan: %v", err) } - } -} - -func TestMainRepoRootFuncOverride(t *testing.T) { - orig := mainRepoRootFunc - defer func() { mainRepoRootFunc = orig }() - - mainRepoRootFunc = func(fromDir string) (string, error) { - return "/fake/repo", nil - } - - root, err := MainRepoRoot("") - if err != nil { - t.Fatal(err) - } - if root != "/fake/repo" { - t.Errorf("expected /fake/repo, got %s", root) - } -} - -func TestPostConcurrent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bulletin.jsonl") - - const writers = 16 - var wg sync.WaitGroup - wg.Add(writers) - - for i := 0; i < writers; i++ { - i := i - go func() { - defer wg.Done() - if err := Post(path, Entry{ - Quest: fmt.Sprintf("q-%d", i), - Topic: "auth", - Files: []string{fmt.Sprintf("src/auth/%d.go", i)}, - Discovery: "concurrent write", - }); err != nil { - t.Errorf("Post(%d): %v", i, err) - } - }() - } - - wg.Wait() - - entries, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if len(entries) != writers { - t.Fatalf("expected %d entries, got %d", writers, len(entries)) - } + if len(entries) != 2 { + t.Fatalf("expected all 2 entries with no filters, got %d", len(entries)) + } + return nil + }) } -func TestBulletinPath(t *testing.T) { - orig := mainRepoRootFunc - defer func() { mainRepoRootFunc = orig }() - - mainRepoRootFunc = func(fromDir string) (string, error) { - return "/repo", nil - } - - path, err := BulletinPath("") - if err != nil { - t.Fatal(err) - } - expected := filepath.Join("/repo", datadir.Name(), "bulletin.jsonl") - if path != expected { - t.Errorf("expected %s, got %s", expected, path) - } +func TestLoadEmpty(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + entries, err := Load(conn) + if err != nil { + t.Fatal(err) + } + if entries != nil { + t.Errorf("expected nil entries, got %v", entries) + } + return nil + }) } From 15aeed81c1eb162aad09d61e67b4300ab4ad9edd Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:29:24 -0500 Subject: [PATCH 07/19] feat: rewrite autopsy package with SQLite backend Co-Authored-By: Claude Opus 4.6 --- cli/internal/autopsy/autopsy.go | 523 ++++++++++------- cli/internal/autopsy/autopsy_test.go | 828 ++++++++++++++------------- 2 files changed, 755 insertions(+), 596 deletions(-) diff --git a/cli/internal/autopsy/autopsy.go b/cli/internal/autopsy/autopsy.go index 21423c0..56d000f 100644 --- a/cli/internal/autopsy/autopsy.go +++ b/cli/internal/autopsy/autopsy.go @@ -1,25 +1,22 @@ package autopsy import ( - "crypto/rand" - "encoding/json" "fmt" - "os" "path/filepath" "sort" "strings" "time" - "github.com/justinjdev/fellowship/cli/internal/datadir" - "github.com/justinjdev/fellowship/cli/internal/herald" - "github.com/justinjdev/fellowship/cli/internal/tome" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" ) -const autopsyDir = "autopsies" +// DefaultExpiryDays is the default autopsy TTL when not configured. +const DefaultExpiryDays = 90 // Autopsy represents a structured failure record. type Autopsy struct { - Version int `json:"version"` + ID int64 `json:"id"` Timestamp string `json:"ts"` Quest string `json:"quest"` Task string `json:"task"` @@ -30,9 +27,10 @@ type Autopsy struct { WhatFailed string `json:"what_failed"` Resolution string `json:"resolution,omitempty"` Tags []string `json:"tags"` + ExpiresAt string `json:"expires_at"` } -// CreateInput is the subset of fields the caller provides; version and timestamp are filled in. +// CreateInput is the subset of fields the caller provides; timestamp and expiry are filled in. type CreateInput struct { Quest string `json:"quest"` Task string `json:"task"` @@ -45,206 +43,397 @@ type CreateInput struct { Tags []string `json:"tags,omitempty"` } +// ScanOptions configures which autopsies to match. +type ScanOptions struct { + Files []string + Modules []string + Tags []string +} + var validTriggers = map[string]bool{ "recovery": true, "rejection": true, "abandonment": true, } -// Create validates input, fills in version/timestamp, and writes to the autopsies directory. -func Create(repoRoot string, input *CreateInput) (string, error) { +// Create validates input, inserts the autopsy and its related files/modules/tags into the DB, +// and returns the row ID. +func Create(conn *sqlite.Conn, input *CreateInput) (int64, error) { if input == nil { - return "", fmt.Errorf("input is required") + return 0, fmt.Errorf("input is required") } if input.Quest == "" { - return "", fmt.Errorf("quest is required") + return 0, fmt.Errorf("quest is required") } if input.WhatFailed == "" { - return "", fmt.Errorf("what_failed is required") + return 0, fmt.Errorf("what_failed is required") } if !validTriggers[input.Trigger] { - return "", fmt.Errorf("invalid trigger %q (must be recovery, rejection, or abandonment)", input.Trigger) + return 0, fmt.Errorf("invalid trigger %q (must be recovery, rejection, or abandonment)", input.Trigger) } now := time.Now().UTC() - a := &Autopsy{ - Version: 1, - Timestamp: now.Format(time.RFC3339), - Quest: input.Quest, - Task: input.Task, - Phase: input.Phase, - Trigger: input.Trigger, - Files: input.Files, - Modules: input.Modules, - WhatFailed: input.WhatFailed, - Resolution: input.Resolution, - Tags: input.Tags, - } - if a.Files == nil { - a.Files = []string{} - } - if a.Modules == nil { - a.Modules = []string{} - } - if a.Tags == nil { - a.Tags = []string{} + timestamp := now.Format(time.RFC3339) + expiresAt := now.AddDate(0, 0, DefaultExpiryDays).Format(time.RFC3339) + + err := sqlitex.Execute(conn, + `INSERT INTO autopsies (timestamp, quest, task, phase, trigger_type, what_failed, resolution, expires_at) + VALUES (:ts, :quest, :task, :phase, :trigger, :what_failed, :resolution, :expires_at)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":ts": timestamp, + ":quest": input.Quest, + ":task": input.Task, + ":phase": input.Phase, + ":trigger": input.Trigger, + ":what_failed": input.WhatFailed, + ":resolution": input.Resolution, + ":expires_at": expiresAt, + }, + }) + if err != nil { + return 0, fmt.Errorf("autopsy: insert: %w", err) } - dir := filepath.Join(repoRoot, datadir.Name(), autopsyDir) - if err := os.MkdirAll(dir, 0755); err != nil { - return "", fmt.Errorf("creating autopsies directory: %w", err) - } + id := conn.LastInsertRowID() - randBytes := make([]byte, 4) - if _, err := rand.Read(randBytes); err != nil { - return "", fmt.Errorf("generating autopsy filename suffix: %w", err) + for _, f := range input.Files { + if err := sqlitex.Execute(conn, + `INSERT INTO autopsy_files (autopsy_id, file_path) VALUES (?, ?)`, + &sqlitex.ExecOptions{ + Args: []any{id, f}, + }); err != nil { + return 0, fmt.Errorf("autopsy: insert file: %w", err) + } } - filename := fmt.Sprintf("%s-%s-%x.json", now.Format("20060102T150405"), sanitize(input.Quest), randBytes) - path := filepath.Join(dir, filename) - data, err := json.MarshalIndent(a, "", " ") - if err != nil { - return "", fmt.Errorf("marshaling autopsy: %w", err) + for _, m := range input.Modules { + if err := sqlitex.Execute(conn, + `INSERT INTO autopsy_modules (autopsy_id, module) VALUES (?, ?)`, + &sqlitex.ExecOptions{ + Args: []any{id, m}, + }); err != nil { + return 0, fmt.Errorf("autopsy: insert module: %w", err) + } } - data = append(data, '\n') - if err := os.WriteFile(path, data, 0644); err != nil { - return "", fmt.Errorf("writing autopsy: %w", err) + for _, tag := range input.Tags { + if err := sqlitex.Execute(conn, + `INSERT INTO autopsy_tags (autopsy_id, tag) VALUES (?, ?)`, + &sqlitex.ExecOptions{ + Args: []any{id, tag}, + }); err != nil { + return 0, fmt.Errorf("autopsy: insert tag: %w", err) + } } - return path, nil -} -// ScanOptions configures which autopsies to match. -type ScanOptions struct { - Files []string - Modules []string - Tags []string + return id, nil } -// Scan reads all autopsies from the repo root, prunes expired ones, and returns matches. -func Scan(repoRoot string, opts ScanOptions, expiryDays int) ([]Autopsy, error) { +// Scan queries autopsies from the DB, filtering by files/modules/tags and excluding expired entries. +func Scan(conn *sqlite.Conn, opts ScanOptions, expiryDays int) ([]Autopsy, error) { if len(opts.Files) == 0 && len(opts.Modules) == 0 && len(opts.Tags) == 0 { return nil, fmt.Errorf("at least one of --files, --modules, or --tags is required") } - dir := filepath.Join(repoRoot, datadir.Name(), autopsyDir) - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return []Autopsy{}, nil - } - return nil, fmt.Errorf("reading autopsies directory: %w", err) - } - - cutoff := time.Now().UTC().AddDate(0, 0, -expiryDays) - var matches []Autopsy + // Build a query that joins across the junction tables. + // We select all non-expired autopsies that match any of the filter criteria. + var conditions []string + var args []any - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { - continue + if len(opts.Files) > 0 { + filePlaceholders := make([]string, len(opts.Files)) + for i, f := range opts.Files { + filePlaceholders[i] = "?" + args = append(args, f) } + // Match by exact file path or same directory + conditions = append(conditions, + fmt.Sprintf(`a.id IN ( + SELECT af.autopsy_id FROM autopsy_files af + WHERE af.file_path IN (%s) + OR EXISTS ( + SELECT 1 FROM autopsy_files af2 + WHERE af2.autopsy_id = af.autopsy_id + AND af2.file_path != af.file_path + ) + )`, strings.Join(filePlaceholders, ","))) + // Actually, we need directory-level matching. Let's use a simpler approach: + // For each query file, match autopsies that have a file in the same directory. + conditions = conditions[:len(conditions)-1] // remove the above + args = args[:len(args)-len(opts.Files)] // remove args + + // Use a subquery that checks directory matching + var fileCondParts []string + for _, f := range opts.Files { + // Exact match + args = append(args, f) + fileCondParts = append(fileCondParts, "af.file_path = ?") + + // Same directory match (for files with directories) + dir := filepath.Dir(filepath.ToSlash(f)) + if dir != "." { + args = append(args, dir+"/%") + fileCondParts = append(fileCondParts, "af.file_path LIKE ?") + } - path := filepath.Join(dir, entry.Name()) - a, err := loadAutopsy(path) - if err != nil { - return nil, fmt.Errorf("reading autopsy %s: %w", entry.Name(), err) + // Query file is under a directory prefix in the autopsy + if strings.HasSuffix(f, "/") { + args = append(args, f+"%") + fileCondParts = append(fileCondParts, "af.file_path LIKE ?") + } } + conditions = append(conditions, + fmt.Sprintf("a.id IN (SELECT af.autopsy_id FROM autopsy_files af WHERE %s)", + strings.Join(fileCondParts, " OR "))) + } - ts, err := time.Parse(time.RFC3339, a.Timestamp) - if err != nil { - return nil, fmt.Errorf("parsing autopsy timestamp for %s: %w", entry.Name(), err) + if len(opts.Modules) > 0 { + placeholders := make([]string, len(opts.Modules)) + for i, m := range opts.Modules { + placeholders[i] = "?" + args = append(args, m) } - if ts.Before(cutoff) { - os.Remove(path) // best-effort cleanup; don't abort scan on failure - continue + conditions = append(conditions, + fmt.Sprintf("a.id IN (SELECT am.autopsy_id FROM autopsy_modules am WHERE am.module IN (%s))", + strings.Join(placeholders, ","))) + } + + if len(opts.Tags) > 0 { + placeholders := make([]string, len(opts.Tags)) + for i, tag := range opts.Tags { + placeholders[i] = "?" + args = append(args, tag) } + conditions = append(conditions, + fmt.Sprintf("a.id IN (SELECT at2.autopsy_id FROM autopsy_tags at2 WHERE at2.tag IN (%s))", + strings.Join(placeholders, ","))) + } + + query := fmt.Sprintf( + `SELECT a.id, a.timestamp, a.quest, a.task, a.phase, a.trigger_type, + a.what_failed, a.resolution, a.expires_at + FROM autopsies a + WHERE a.expires_at > datetime('now') + AND (%s) + ORDER BY a.timestamp DESC`, + strings.Join(conditions, " OR ")) + + var autopsies []Autopsy + err := sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ + Args: args, + ResultFunc: func(stmt *sqlite.Stmt) error { + a := Autopsy{ + ID: stmt.ColumnInt64(0), + Timestamp: stmt.ColumnText(1), + Quest: stmt.ColumnText(2), + Task: stmt.ColumnText(3), + Phase: stmt.ColumnText(4), + Trigger: stmt.ColumnText(5), + WhatFailed: stmt.ColumnText(6), + Resolution: stmt.ColumnText(7), + ExpiresAt: stmt.ColumnText(8), + } + autopsies = append(autopsies, a) + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("autopsy: scan: %w", err) + } - if matchesFilters(a, opts) { - matches = append(matches, *a) + // Load files, modules, and tags for each autopsy + for i := range autopsies { + if err := loadAutopsyRelations(conn, &autopsies[i]); err != nil { + return nil, err } } - if matches == nil { - matches = []Autopsy{} + if autopsies == nil { + autopsies = []Autopsy{} } - return matches, nil + return autopsies, nil } -// Infer reconstructs a best-effort autopsy from a quest worktree's external signals. -func Infer(worktreeDir, repoRoot string) (string, error) { - tomePath := filepath.Join(worktreeDir, datadir.Name(), "quest-tome.json") - t, err := tome.Load(tomePath) +// loadAutopsyRelations populates Files, Modules, and Tags for an autopsy. +func loadAutopsyRelations(conn *sqlite.Conn, a *Autopsy) error { + // Files + a.Files = []string{} + if err := sqlitex.Execute(conn, + `SELECT file_path FROM autopsy_files WHERE autopsy_id = ? ORDER BY file_path`, + &sqlitex.ExecOptions{ + Args: []any{a.ID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + a.Files = append(a.Files, stmt.ColumnText(0)) + return nil + }, + }); err != nil { + return fmt.Errorf("autopsy: load files: %w", err) + } + + // Modules + a.Modules = []string{} + if err := sqlitex.Execute(conn, + `SELECT module FROM autopsy_modules WHERE autopsy_id = ? ORDER BY module`, + &sqlitex.ExecOptions{ + Args: []any{a.ID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + a.Modules = append(a.Modules, stmt.ColumnText(0)) + return nil + }, + }); err != nil { + return fmt.Errorf("autopsy: load modules: %w", err) + } + + // Tags + a.Tags = []string{} + if err := sqlitex.Execute(conn, + `SELECT tag FROM autopsy_tags WHERE autopsy_id = ? ORDER BY tag`, + &sqlitex.ExecOptions{ + Args: []any{a.ID}, + ResultFunc: func(stmt *sqlite.Stmt) error { + a.Tags = append(a.Tags, stmt.ColumnText(0)) + return nil + }, + }); err != nil { + return fmt.Errorf("autopsy: load tags: %w", err) + } + + return nil +} + +// Infer reconstructs a best-effort autopsy from quest DB state. +// It queries fellowship_quests for respawns/status, quest_gates for rejections, +// quest_phases for phase history, and quest_files for files touched. +func Infer(conn *sqlite.Conn, questName string) (int64, error) { + // Load quest info from fellowship_quests + var status string + var respawns int + var taskDesc string + found := false + err := sqlitex.Execute(conn, + `SELECT status, respawns, COALESCE(task_description, '') FROM fellowship_quests WHERE name = :name`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + status = stmt.ColumnText(0) + respawns = stmt.ColumnInt(1) + taskDesc = stmt.ColumnText(2) + found = true + return nil + }, + }) if err != nil { - return "", fmt.Errorf("loading tome: %w", err) + return 0, fmt.Errorf("autopsy: query quest: %w", err) + } + if !found { + return 0, fmt.Errorf("quest %q not found", questName) } - // Determine trigger from signals - trigger, whatFailed, err := inferTrigger(worktreeDir, t) + // Determine trigger + trigger, whatFailed, err := inferTriggerFromDB(conn, questName, status, respawns) if err != nil { - return "", err + return 0, err } if trigger == "" { - return "", fmt.Errorf("no failure signals found in worktree") + return 0, fmt.Errorf("no failure signals found for quest %q", questName) + } + + // Get phase from quest_phases (last completed phase) + phase := "unknown" + err = sqlitex.Execute(conn, + `SELECT phase FROM quest_phases WHERE quest_name = :name ORDER BY completed_at DESC LIMIT 1`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + phase = stmt.ColumnText(0) + return nil + }, + }) + if err != nil { + return 0, fmt.Errorf("autopsy: query phases: %w", err) + } + + // Get files touched from quest_files + var files []string + err = sqlitex.Execute(conn, + `SELECT file_path FROM quest_files WHERE quest_name = :name ORDER BY file_path`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + files = append(files, stmt.ColumnText(0)) + return nil + }, + }) + if err != nil { + return 0, fmt.Errorf("autopsy: query files: %w", err) + } + if files == nil { + files = []string{} } - // Derive modules from files_touched - modules := inferModules(t.FilesTouched) + modules := inferModules(files) input := &CreateInput{ - Quest: t.QuestName, - Task: t.Task, - Phase: inferPhase(t), + Quest: questName, + Task: taskDesc, + Phase: phase, Trigger: trigger, - Files: t.FilesTouched, + Files: files, Modules: modules, WhatFailed: whatFailed, } - return Create(repoRoot, input) + return Create(conn, input) } -func inferTrigger(worktreeDir string, t *tome.QuestTome) (string, string, error) { +// inferTriggerFromDB determines the failure trigger by querying DB tables. +func inferTriggerFromDB(conn *sqlite.Conn, questName, status string, respawns int) (string, string, error) { // Check for respawns - if t.Respawns > 0 { - return "recovery", fmt.Sprintf("Quest required %d respawn(s)", t.Respawns), nil - } - - // Check for gate rejections in herald - tidings, err := herald.Read(worktreeDir, 0) - if err != nil && !os.IsNotExist(err) { - return "", "", fmt.Errorf("reading herald: %w", err) + if respawns > 0 { + return "recovery", fmt.Sprintf("Quest required %d respawn(s)", respawns), nil + } + + // Check for gate rejections in quest_gates + var rejectionReason string + var rejectionPhase string + err := sqlitex.Execute(conn, + `SELECT phase, COALESCE(reason, '') FROM quest_gates + WHERE quest_name = :name AND action = 'rejected' + ORDER BY timestamp DESC LIMIT 1`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + rejectionPhase = stmt.ColumnText(0) + rejectionReason = stmt.ColumnText(1) + return nil + }, + }) + if err != nil { + return "", "", fmt.Errorf("autopsy: query gates: %w", err) } - for i := len(tidings) - 1; i >= 0; i-- { - if tidings[i].Type == herald.GateRejected { - detail := tidings[i].Detail - if detail == "" { - detail = fmt.Sprintf("Gate rejected at %s phase", tidings[i].Phase) - } - return "rejection", detail, nil + if rejectionPhase != "" { + detail := rejectionReason + if detail == "" { + detail = fmt.Sprintf("Gate rejected at %s phase", rejectionPhase) } + return "rejection", detail, nil } - // Check for failed/cancelled status in tome - if t.Status == "failed" || t.Status == "cancelled" { - return "abandonment", fmt.Sprintf("Quest %s with status: %s", t.QuestName, t.Status), nil + // Check for failed/cancelled status + if status == "failed" || status == "cancelled" { + return "abandonment", fmt.Sprintf("Quest %s with status: %s", questName, status), nil } return "", "", nil } -func inferPhase(t *tome.QuestTome) string { - if len(t.PhasesCompleted) > 0 { - return t.PhasesCompleted[len(t.PhasesCompleted)-1].Phase - } - return "unknown" -} - +// inferModules derives module names from file paths using the first directory component. func inferModules(files []string) []string { seen := map[string]bool{} for _, f := range files { parts := strings.Split(filepath.ToSlash(f), "/") if len(parts) >= 2 { - // Use the first directory component as the module mod := parts[0] if !seen[mod] { seen[mod] = true @@ -258,73 +447,3 @@ func inferModules(files []string) []string { sort.Strings(modules) return modules } - -func matchesFilters(a *Autopsy, opts ScanOptions) bool { - // File match: exact match, directory containment, or same directory - for _, queryFile := range opts.Files { - for _, autopsyFile := range a.Files { - // Exact match - if queryFile == autopsyFile { - return true - } - // Directory containment (query is a dir prefix of autopsy file or vice versa) - if strings.HasSuffix(queryFile, "/") && strings.HasPrefix(autopsyFile, queryFile) { - return true - } - if strings.HasSuffix(autopsyFile, "/") && strings.HasPrefix(queryFile, autopsyFile) { - return true - } - // Same directory (skip root-level files) - queryDir := filepath.Dir(queryFile) - aDir := filepath.Dir(autopsyFile) - if queryDir != "." && aDir != "." && queryDir == aDir { - return true - } - } - } - - // Module match - for _, queryMod := range opts.Modules { - for _, autopsyMod := range a.Modules { - if queryMod == autopsyMod { - return true - } - } - } - - // Tag match - for _, queryTag := range opts.Tags { - for _, autopsyTag := range a.Tags { - if queryTag == autopsyTag { - return true - } - } - } - - return false -} - -func loadAutopsy(path string) (*Autopsy, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var a Autopsy - if err := json.Unmarshal(data, &a); err != nil { - return nil, err - } - return &a, nil -} - -func sanitize(s string) string { - for _, c := range []string{" ", "/", "\\", ":", "*", "?", "\"", "<", ">", "|"} { - s = strings.ReplaceAll(s, c, "-") - } - if len(s) > 40 { - s = s[:40] - } - return s -} - -// DefaultExpiryDays is the default autopsy TTL when not configured. -const DefaultExpiryDays = 90 diff --git a/cli/internal/autopsy/autopsy_test.go b/cli/internal/autopsy/autopsy_test.go index 75387ca..3b9e3a9 100644 --- a/cli/internal/autopsy/autopsy_test.go +++ b/cli/internal/autopsy/autopsy_test.go @@ -1,445 +1,499 @@ package autopsy import ( - "encoding/json" - "os" - "path/filepath" + "context" "testing" - "time" - "github.com/justinjdev/fellowship/cli/internal/datadir" - "github.com/justinjdev/fellowship/cli/internal/herald" - "github.com/justinjdev/fellowship/cli/internal/tome" + "github.com/justinjdev/fellowship/cli/internal/db" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" ) -func setupTestRepo(t *testing.T) string { - t.Helper() - t.Setenv("HOME", t.TempDir()) // Pin HOME so datadir.Name() returns default - dir := t.TempDir() - os.MkdirAll(filepath.Join(dir, datadir.DefaultName, autopsyDir), 0755) - return dir +func TestCreateAndScan(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + id, err := Create(conn, &CreateInput{ + Quest: "q1", Phase: "Implement", Trigger: "recovery", + Files: []string{"auth.go"}, Modules: []string{"auth"}, + WhatFailed: "tests failed", Tags: []string{"flaky"}, + }) + if err != nil { + t.Fatal(err) + } + if id == 0 { + t.Error("expected non-zero ID") + } + + matches, err := Scan(conn, ScanOptions{Files: []string{"auth.go"}}, 90) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 { + t.Fatalf("expected 1, got %d", len(matches)) + } + return nil + }) } func TestCreate_ValidInput(t *testing.T) { - repo := setupTestRepo(t) - input := &CreateInput{ - Quest: "quest-1", - Task: "Add auth endpoint", - Phase: "Implement", - Trigger: "recovery", - Files: []string{"src/auth/jwt.go"}, - Modules: []string{"auth"}, - WhatFailed: "Middleware caches tokens", - Resolution: "Added cache invalidation", - Tags: []string{"caching"}, - } - - path, err := Create(repo, input) - if err != nil { - t.Fatalf("Create failed: %v", err) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading autopsy file: %v", err) - } - - var a Autopsy - if err := json.Unmarshal(data, &a); err != nil { - t.Fatalf("parsing autopsy: %v", err) - } - - if a.Version != 1 { - t.Errorf("version = %d, want 1", a.Version) - } - if a.Quest != "quest-1" { - t.Errorf("quest = %q, want %q", a.Quest, "quest-1") - } - if a.Trigger != "recovery" { - t.Errorf("trigger = %q, want %q", a.Trigger, "recovery") - } - if a.WhatFailed != "Middleware caches tokens" { - t.Errorf("what_failed = %q", a.WhatFailed) - } - if len(a.Tags) != 1 || a.Tags[0] != "caching" { - t.Errorf("tags = %v, want [caching]", a.Tags) - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + id, err := Create(conn, &CreateInput{ + Quest: "quest-1", + Task: "Add auth endpoint", + Phase: "Implement", + Trigger: "recovery", + Files: []string{"src/auth/jwt.go"}, + Modules: []string{"auth"}, + WhatFailed: "Middleware caches tokens", + Resolution: "Added cache invalidation", + Tags: []string{"caching"}, + }) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if id == 0 { + t.Error("expected non-zero ID") + } + + // Verify we can scan it back + matches, err := Scan(conn, ScanOptions{Tags: []string{"caching"}}, 90) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } + a := matches[0] + if a.Quest != "quest-1" { + t.Errorf("quest = %q, want %q", a.Quest, "quest-1") + } + if a.Trigger != "recovery" { + t.Errorf("trigger = %q, want %q", a.Trigger, "recovery") + } + if a.WhatFailed != "Middleware caches tokens" { + t.Errorf("what_failed = %q", a.WhatFailed) + } + if len(a.Tags) != 1 || a.Tags[0] != "caching" { + t.Errorf("tags = %v, want [caching]", a.Tags) + } + if len(a.Files) != 1 || a.Files[0] != "src/auth/jwt.go" { + t.Errorf("files = %v, want [src/auth/jwt.go]", a.Files) + } + if len(a.Modules) != 1 || a.Modules[0] != "auth" { + t.Errorf("modules = %v, want [auth]", a.Modules) + } + return nil + }) } func TestCreate_MissingQuest(t *testing.T) { - repo := setupTestRepo(t) - _, err := Create(repo, &CreateInput{ - Trigger: "recovery", - WhatFailed: "something", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Create(conn, &CreateInput{ + Trigger: "recovery", + WhatFailed: "something", + }) + if err == nil { + t.Error("expected error for missing quest") + } + return nil }) - if err == nil { - t.Error("expected error for missing quest") - } } func TestCreate_InvalidTrigger(t *testing.T) { - repo := setupTestRepo(t) - _, err := Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "invalid", - WhatFailed: "something", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Create(conn, &CreateInput{ + Quest: "quest-1", + Trigger: "invalid", + WhatFailed: "something", + }) + if err == nil { + t.Error("expected error for invalid trigger") + } + return nil }) - if err == nil { - t.Error("expected error for invalid trigger") - } } func TestCreate_MissingWhatFailed(t *testing.T) { - repo := setupTestRepo(t) - _, err := Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Create(conn, &CreateInput{ + Quest: "quest-1", + Trigger: "recovery", + }) + if err == nil { + t.Error("expected error for missing what_failed") + } + return nil }) - if err == nil { - t.Error("expected error for missing what_failed") - } } func TestCreate_NilInput(t *testing.T) { - repo := setupTestRepo(t) - _, err := Create(repo, nil) - if err == nil { - t.Error("expected error for nil input") - } -} - -func TestCreate_NilSlicesDefaultToEmpty(t *testing.T) { - repo := setupTestRepo(t) - path, err := Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - WhatFailed: "something", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Create(conn, nil) + if err == nil { + t.Error("expected error for nil input") + } + return nil }) - if err != nil { - t.Fatalf("Create failed: %v", err) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading autopsy file: %v", err) - } - var a Autopsy - if err := json.Unmarshal(data, &a); err != nil { - t.Fatalf("parsing autopsy: %v", err) - } - - if a.Files == nil { - t.Error("files should be empty slice, not nil") - } - if a.Modules == nil { - t.Error("modules should be empty slice, not nil") - } - if a.Tags == nil { - t.Error("tags should be empty slice, not nil") - } } func TestScan_MatchByFile(t *testing.T) { - repo := setupTestRepo(t) - Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - Files: []string{"src/auth/jwt.go"}, - WhatFailed: "auth issue", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Create(conn, &CreateInput{ + Quest: "quest-1", + Trigger: "recovery", + Files: []string{"src/auth/jwt.go"}, + WhatFailed: "auth issue", + }) + if err != nil { + t.Fatal(err) + } + + // Same directory should match + matches, err := Scan(conn, ScanOptions{Files: []string{"src/auth/middleware.go"}}, 90) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + if len(matches) != 1 { + t.Errorf("expected 1 match (same directory), got %d", len(matches)) + } + return nil }) - - matches, err := Scan(repo, ScanOptions{Files: []string{"src/auth/middleware.go"}}, 90) - if err != nil { - t.Fatalf("Scan failed: %v", err) - } - if len(matches) != 1 { - t.Errorf("expected 1 match (same directory), got %d", len(matches)) - } } func TestScan_MatchByModule(t *testing.T) { - repo := setupTestRepo(t) - Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - Modules: []string{"auth"}, - WhatFailed: "auth issue", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Create(conn, &CreateInput{ + Quest: "quest-1", + Trigger: "recovery", + Modules: []string{"auth"}, + WhatFailed: "auth issue", + }) + if err != nil { + t.Fatal(err) + } + + matches, err := Scan(conn, ScanOptions{Modules: []string{"auth"}}, 90) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + if len(matches) != 1 { + t.Errorf("expected 1 match, got %d", len(matches)) + } + return nil }) - - matches, err := Scan(repo, ScanOptions{Modules: []string{"auth"}}, 90) - if err != nil { - t.Fatalf("Scan failed: %v", err) - } - if len(matches) != 1 { - t.Errorf("expected 1 match, got %d", len(matches)) - } } func TestScan_MatchByTag(t *testing.T) { - repo := setupTestRepo(t) - Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - Tags: []string{"caching", "auth"}, - WhatFailed: "cache issue", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Create(conn, &CreateInput{ + Quest: "quest-1", + Trigger: "recovery", + Tags: []string{"caching", "auth"}, + WhatFailed: "cache issue", + }) + if err != nil { + t.Fatal(err) + } + + matches, err := Scan(conn, ScanOptions{Tags: []string{"caching"}}, 90) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + if len(matches) != 1 { + t.Errorf("expected 1 match, got %d", len(matches)) + } + return nil }) - - matches, err := Scan(repo, ScanOptions{Tags: []string{"caching"}}, 90) - if err != nil { - t.Fatalf("Scan failed: %v", err) - } - if len(matches) != 1 { - t.Errorf("expected 1 match, got %d", len(matches)) - } } func TestScan_NoMatch(t *testing.T) { - repo := setupTestRepo(t) - Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - Modules: []string{"auth"}, - WhatFailed: "auth issue", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Create(conn, &CreateInput{ + Quest: "quest-1", + Trigger: "recovery", + Modules: []string{"auth"}, + WhatFailed: "auth issue", + }) + if err != nil { + t.Fatal(err) + } + + matches, err := Scan(conn, ScanOptions{Modules: []string{"billing"}}, 90) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + if len(matches) != 0 { + t.Errorf("expected 0 matches, got %d", len(matches)) + } + return nil }) - - matches, err := Scan(repo, ScanOptions{Modules: []string{"billing"}}, 90) - if err != nil { - t.Fatalf("Scan failed: %v", err) - } - if len(matches) != 0 { - t.Errorf("expected 0 matches, got %d", len(matches)) - } -} - -func TestScan_PrunesExpired(t *testing.T) { - repo := setupTestRepo(t) - - // Write an autopsy with an old timestamp - dir := filepath.Join(repo, datadir.DefaultName, autopsyDir) - old := &Autopsy{ - Version: 1, - Timestamp: time.Now().UTC().AddDate(0, 0, -100).Format(time.RFC3339), - Quest: "old-quest", - Trigger: "recovery", - Modules: []string{"auth"}, - Files: []string{}, - Tags: []string{}, - WhatFailed: "old failure", - } - data, _ := json.MarshalIndent(old, "", " ") - os.WriteFile(filepath.Join(dir, "old-autopsy.json"), data, 0644) - - matches, err := Scan(repo, ScanOptions{Modules: []string{"auth"}}, 90) - if err != nil { - t.Fatalf("Scan failed: %v", err) - } - if len(matches) != 0 { - t.Errorf("expired autopsy should be pruned, got %d matches", len(matches)) - } - - // Verify file was deleted - if _, err := os.Stat(filepath.Join(dir, "old-autopsy.json")); !os.IsNotExist(err) { - t.Error("expired autopsy file should be deleted") - } } func TestScan_RequiresFilter(t *testing.T) { - repo := setupTestRepo(t) - _, err := Scan(repo, ScanOptions{}, 90) - if err == nil { - t.Error("expected error when no filters provided") - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Scan(conn, ScanOptions{}, 90) + if err == nil { + t.Error("expected error when no filters provided") + } + return nil + }) } -func TestScan_EmptyDirectory(t *testing.T) { - repo := t.TempDir() // no autopsies dir - matches, err := Scan(repo, ScanOptions{Modules: []string{"auth"}}, 90) - if err != nil { - t.Fatalf("Scan failed: %v", err) - } - if len(matches) != 0 { - t.Errorf("expected 0 matches for empty dir, got %d", len(matches)) - } +func TestScan_ExcludesExpired(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + // Insert an autopsy with an already-expired expires_at + err := sqlitex.Execute(conn, + `INSERT INTO autopsies (timestamp, quest, trigger_type, what_failed, expires_at) + VALUES (datetime('now', '-100 days'), 'old-quest', 'recovery', 'old failure', datetime('now', '-10 days'))`, + nil) + if err != nil { + t.Fatal(err) + } + oldID := conn.LastInsertRowID() + + // Add a module so we can search for it + err = sqlitex.Execute(conn, + `INSERT INTO autopsy_modules (autopsy_id, module) VALUES (?, 'auth')`, + &sqlitex.ExecOptions{Args: []any{oldID}}) + if err != nil { + t.Fatal(err) + } + + matches, err := Scan(conn, ScanOptions{Modules: []string{"auth"}}, 90) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + if len(matches) != 0 { + t.Errorf("expired autopsy should be excluded, got %d matches", len(matches)) + } + return nil + }) } func TestInfer_FromRespawns(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - worktree := t.TempDir() - repo := t.TempDir() - os.MkdirAll(filepath.Join(repo, datadir.DefaultName, autopsyDir), 0755) - - // Write a tome with respawns - tomeDir := filepath.Join(worktree, datadir.DefaultName) - os.MkdirAll(tomeDir, 0755) - qt := &tome.QuestTome{ - Version: 1, - QuestName: "quest-respawned", - Task: "Fix login flow", - Status: "active", - PhasesCompleted: []tome.PhaseRecord{ - {Phase: "Implement", CompletedAt: time.Now().UTC().Format(time.RFC3339)}, - }, - GateHistory: []tome.GateEvent{}, - FilesTouched: []string{"src/auth/login.go", "src/auth/session.go"}, - Respawns: 2, - } - tome.Save(filepath.Join(tomeDir, "quest-tome.json"), qt) - - path, err := Infer(worktree, repo) - if err != nil { - t.Fatalf("Infer failed: %v", err) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading autopsy file: %v", err) - } - var a Autopsy - if err := json.Unmarshal(data, &a); err != nil { - t.Fatalf("parsing autopsy: %v", err) - } - - if a.Trigger != "recovery" { - t.Errorf("trigger = %q, want recovery", a.Trigger) - } - if a.Quest != "quest-respawned" { - t.Errorf("quest = %q", a.Quest) - } - if len(a.Files) != 2 { - t.Errorf("files = %v, want 2 files", a.Files) - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + // Set up fellowship_quests row + err := sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, status, respawns) + VALUES ('quest-respawned', 'Fix login flow', 'active', 2)`, nil) + if err != nil { + t.Fatal(err) + } + + // Set up quest_state (needed for FK in quest_phases/quest_files) + err = sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, created_at, updated_at) + VALUES ('quest-respawned', datetime('now'), datetime('now'))`, nil) + if err != nil { + t.Fatal(err) + } + + // Add phase history + err = sqlitex.Execute(conn, + `INSERT INTO quest_phases (quest_name, phase, completed_at) + VALUES ('quest-respawned', 'Implement', datetime('now'))`, nil) + if err != nil { + t.Fatal(err) + } + + // Add files touched + for _, f := range []string{"src/auth/login.go", "src/auth/session.go"} { + err = sqlitex.Execute(conn, + `INSERT INTO quest_files (quest_name, file_path) VALUES ('quest-respawned', ?)`, + &sqlitex.ExecOptions{Args: []any{f}}) + if err != nil { + t.Fatal(err) + } + } + + id, err := Infer(conn, "quest-respawned") + if err != nil { + t.Fatalf("Infer failed: %v", err) + } + if id == 0 { + t.Error("expected non-zero ID") + } + + // Verify the autopsy + matches, err := Scan(conn, ScanOptions{Files: []string{"src/auth/login.go"}}, 90) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } + a := matches[0] + if a.Trigger != "recovery" { + t.Errorf("trigger = %q, want recovery", a.Trigger) + } + if a.Quest != "quest-respawned" { + t.Errorf("quest = %q", a.Quest) + } + if len(a.Files) != 2 { + t.Errorf("files = %v, want 2 files", a.Files) + } + return nil + }) } func TestInfer_FromRejection(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - worktree := t.TempDir() - repo := t.TempDir() - os.MkdirAll(filepath.Join(repo, datadir.DefaultName, autopsyDir), 0755) - - // Write tome - tomeDir := filepath.Join(worktree, datadir.DefaultName) - os.MkdirAll(tomeDir, 0755) - qt := &tome.QuestTome{ - Version: 1, - QuestName: "quest-rejected", - Task: "Add billing", - Status: "active", - PhasesCompleted: []tome.PhaseRecord{{Phase: "Plan", CompletedAt: time.Now().UTC().Format(time.RFC3339)}}, - GateHistory: []tome.GateEvent{}, - FilesTouched: []string{"src/billing/charge.go"}, - } - tome.Save(filepath.Join(tomeDir, "quest-tome.json"), qt) - - // Write herald with rejection - herald.Announce(worktree, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: "quest-rejected", - Type: herald.GateRejected, - Phase: "Plan", - Detail: "Plan doesn't account for tax calculation", + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + // Set up fellowship_quests + err := sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, status, respawns) + VALUES ('quest-rejected', 'Add billing', 'active', 0)`, nil) + if err != nil { + t.Fatal(err) + } + + // Set up quest_state (for FK) + err = sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, created_at, updated_at) + VALUES ('quest-rejected', datetime('now'), datetime('now'))`, nil) + if err != nil { + t.Fatal(err) + } + + // Add gate rejection + err = sqlitex.Execute(conn, + `INSERT INTO quest_gates (quest_name, phase, action, timestamp, reason) + VALUES ('quest-rejected', 'Plan', 'rejected', datetime('now'), 'Plan doesn''t account for tax calculation')`, nil) + if err != nil { + t.Fatal(err) + } + + // Add phase + err = sqlitex.Execute(conn, + `INSERT INTO quest_phases (quest_name, phase, completed_at) + VALUES ('quest-rejected', 'Plan', datetime('now'))`, nil) + if err != nil { + t.Fatal(err) + } + + // Add files + err = sqlitex.Execute(conn, + `INSERT INTO quest_files (quest_name, file_path) VALUES ('quest-rejected', 'src/billing/charge.go')`, nil) + if err != nil { + t.Fatal(err) + } + + id, err := Infer(conn, "quest-rejected") + if err != nil { + t.Fatalf("Infer failed: %v", err) + } + if id == 0 { + t.Error("expected non-zero ID") + } + + matches, err := Scan(conn, ScanOptions{Files: []string{"src/billing/charge.go"}}, 90) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(matches)) + } + if matches[0].Trigger != "rejection" { + t.Errorf("trigger = %q, want rejection", matches[0].Trigger) + } + if matches[0].WhatFailed != "Plan doesn't account for tax calculation" { + t.Errorf("what_failed = %q", matches[0].WhatFailed) + } + return nil }) - - path, err := Infer(worktree, repo) - if err != nil { - t.Fatalf("Infer failed: %v", err) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading autopsy file: %v", err) - } - var a Autopsy - if err := json.Unmarshal(data, &a); err != nil { - t.Fatalf("parsing autopsy: %v", err) - } - - if a.Trigger != "rejection" { - t.Errorf("trigger = %q, want rejection", a.Trigger) - } - if a.WhatFailed != "Plan doesn't account for tax calculation" { - t.Errorf("what_failed = %q", a.WhatFailed) - } } func TestInfer_FromAbandonment(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - worktree := t.TempDir() - repo := t.TempDir() - os.MkdirAll(filepath.Join(repo, datadir.DefaultName, autopsyDir), 0755) - - tomeDir := filepath.Join(worktree, datadir.DefaultName) - os.MkdirAll(tomeDir, 0755) - qt := &tome.QuestTome{ - Version: 1, - QuestName: "quest-abandoned", - Task: "Migrate DB", - Status: "cancelled", - PhasesCompleted: []tome.PhaseRecord{}, - GateHistory: []tome.GateEvent{}, - FilesTouched: []string{}, - } - tome.Save(filepath.Join(tomeDir, "quest-tome.json"), qt) - - path, err := Infer(worktree, repo) - if err != nil { - t.Fatalf("Infer failed: %v", err) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("reading autopsy file: %v", err) - } - var a Autopsy - if err := json.Unmarshal(data, &a); err != nil { - t.Fatalf("parsing autopsy: %v", err) - } - - if a.Trigger != "abandonment" { - t.Errorf("trigger = %q, want abandonment", a.Trigger) - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + err := sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, status, respawns) + VALUES ('quest-abandoned', 'Migrate DB', 'cancelled', 0)`, nil) + if err != nil { + t.Fatal(err) + } + + err = sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, created_at, updated_at) + VALUES ('quest-abandoned', datetime('now'), datetime('now'))`, nil) + if err != nil { + t.Fatal(err) + } + + id, err := Infer(conn, "quest-abandoned") + if err != nil { + t.Fatalf("Infer failed: %v", err) + } + if id == 0 { + t.Error("expected non-zero ID") + } + + matches, err := Scan(conn, ScanOptions{Modules: []string{"quest-abandoned"}}, 90) + if err != nil { + t.Fatal(err) + } + // No files means no modules, so search by the quest name directly + // Actually, let's just verify via a tag/module-less scan won't work; + // instead query directly + _ = matches + + // Verify the autopsy was created by looking at the DB directly + var trigger string + sqlitex.Execute(conn, + `SELECT trigger_type FROM autopsies WHERE id = ?`, + &sqlitex.ExecOptions{ + Args: []any{id}, + ResultFunc: func(stmt *sqlite.Stmt) error { + trigger = stmt.ColumnText(0) + return nil + }, + }) + if trigger != "abandonment" { + t.Errorf("trigger = %q, want abandonment", trigger) + } + return nil + }) } func TestInfer_NoFailureSignals(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - worktree := t.TempDir() - repo := t.TempDir() - - tomeDir := filepath.Join(worktree, datadir.DefaultName) - os.MkdirAll(tomeDir, 0755) - qt := &tome.QuestTome{ - Version: 1, - QuestName: "quest-ok", - Task: "Add feature", - Status: "active", - PhasesCompleted: []tome.PhaseRecord{}, - GateHistory: []tome.GateEvent{}, - FilesTouched: []string{}, - } - tome.Save(filepath.Join(tomeDir, "quest-tome.json"), qt) - - _, err := Infer(worktree, repo) - if err == nil { - t.Error("expected error when no failure signals found") - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + err := sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, status, respawns) + VALUES ('quest-ok', 'Add feature', 'active', 0)`, nil) + if err != nil { + t.Fatal(err) + } + + _, err = Infer(conn, "quest-ok") + if err == nil { + t.Error("expected error when no failure signals found") + } + return nil + }) } -func TestMatchesFilters_FilePrefix(t *testing.T) { - a := &Autopsy{Files: []string{"src/auth/jwt.go"}} - - // Same directory should match - if !matchesFilters(a, ScanOptions{Files: []string{"src/auth/middleware.go"}}) { - t.Error("same directory should match") - } - - // Parent prefix should match - if !matchesFilters(a, ScanOptions{Files: []string{"src/auth/"}}) { - t.Error("parent prefix should match") - } - - // Different directory should not match - if matchesFilters(a, ScanOptions{Files: []string{"src/billing/charge.go"}}) { - t.Error("different directory should not match") - } +func TestInfer_QuestNotFound(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + _, err := Infer(conn, "nonexistent") + if err == nil { + t.Error("expected error for nonexistent quest") + } + return nil + }) } func TestInferModules(t *testing.T) { @@ -455,17 +509,3 @@ func TestInferModules(t *testing.T) { t.Errorf("expected [auth billing], got %v", modules) } } - -func TestSanitize(t *testing.T) { - if got := sanitize("quest with spaces"); got != "quest-with-spaces" { - t.Errorf("sanitize spaces: got %q", got) - } - if got := sanitize("quest/with/slashes"); got != "quest-with-slashes" { - t.Errorf("sanitize slashes: got %q", got) - } - long := "this-is-a-very-long-quest-name-that-exceeds-the-forty-character-limit" - if got := sanitize(long); len(got) != 40 { - t.Errorf("sanitize long: got length %d, want 40", len(got)) - } -} - From ab9e2c21fa5bbf86dabf52716f7b10b3e6df1584 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:33:10 -0500 Subject: [PATCH 08/19] feat: rewrite dashboard package with SQLite backend Co-Authored-By: Claude Opus 4.6 --- cli/internal/dashboard/fellowship.go | 583 ++++++++++++----- cli/internal/dashboard/fellowship_test.go | 736 ++++++++-------------- cli/internal/dashboard/server.go | 388 +++++++----- cli/internal/dashboard/server_test.go | 160 ++--- cli/internal/eagles/eagles.go | 62 +- 5 files changed, 1009 insertions(+), 920 deletions(-) diff --git a/cli/internal/dashboard/fellowship.go b/cli/internal/dashboard/fellowship.go index abc4fcb..9a1413c 100644 --- a/cli/internal/dashboard/fellowship.go +++ b/cli/internal/dashboard/fellowship.go @@ -1,15 +1,12 @@ package dashboard import ( - "encoding/json" "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "time" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" - "github.com/justinjdev/fellowship/cli/internal/datadir" - "github.com/justinjdev/fellowship/cli/internal/filelock" "github.com/justinjdev/fellowship/cli/internal/errand" "github.com/justinjdev/fellowship/cli/internal/state" ) @@ -28,7 +25,7 @@ type FellowshipState struct { BaseBranch string `json:"base_branch,omitempty"` Quests []QuestEntry `json:"quests"` Scouts []ScoutEntry `json:"scouts"` - Companies []CompanyEntry `json:"companies"` + Companies []CompanyEntry `json:"companies"` } type QuestEntry struct { @@ -64,197 +61,483 @@ type QuestStatus struct { GateID *string `json:"gate_id"` LembasCompleted bool `json:"lembas_completed"` MetadataUpdated bool `json:"metadata_updated"` - ErrandsDone int `json:"errands_done"` - ErrandsTotal int `json:"errands_total"` + ErrandsDone int `json:"errands_done"` + ErrandsTotal int `json:"errands_total"` } type DashboardStatus struct { - Name string `json:"name"` - Quests []QuestStatus `json:"quests"` - Scouts []ScoutEntry `json:"scouts"` + Name string `json:"name"` + Quests []QuestStatus `json:"quests"` + Scouts []ScoutEntry `json:"scouts"` Companies []CompanyEntry `json:"companies"` - PollInterval int `json:"poll_interval"` + PollInterval int `json:"poll_interval"` } -// DiscoverQuests tries fellowship-state.json first, falls back to git worktree list. -func DiscoverQuests(gitRoot string) (*DashboardStatus, error) { - statePath := filepath.Join(gitRoot, datadir.Name(), "fellowship-state.json") - fs, err := LoadFellowshipState(statePath) - if err == nil { - return discoverFromFellowshipState(fs) - } - return discoverFromWorktrees(gitRoot) +// InitFellowship inserts the singleton fellowship row (id=1). +func InitFellowship(conn *sqlite.Conn, name, mainRepo, baseBranch string) error { + now := time.Now().UTC().Format(time.RFC3339) + return sqlitex.Execute(conn, + `INSERT INTO fellowship (id, version, name, main_repo, base_branch, created_at) + VALUES (1, '1', :name, :main_repo, :base_branch, :now) + ON CONFLICT(id) DO UPDATE SET + name=:name, main_repo=:main_repo, base_branch=:base_branch`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":name": name, + ":main_repo": mainRepo, + ":base_branch": baseBranch, + ":now": now, + }, + }) } -// discoverFromFellowshipState reads fellowship state and loads each quest's state. -func discoverFromFellowshipState(fs *FellowshipState) (*DashboardStatus, error) { - status := &DashboardStatus{ - Name: fs.Name, - Quests: []QuestStatus{}, - Scouts: fs.Scouts, - Companies: fs.Companies, - PollInterval: 5, +// LoadFellowship assembles a FellowshipState from the fellowship, fellowship_quests, +// fellowship_scouts, companies, and company_members tables. +func LoadFellowship(conn *sqlite.Conn) (*FellowshipState, error) { + var fs FellowshipState + var found bool + + err := sqlitex.Execute(conn, + `SELECT version, name, main_repo, base_branch, created_at FROM fellowship WHERE id = 1`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + found = true + fs.Version = stmt.ColumnInt(0) + fs.Name = stmt.ColumnText(1) + fs.MainRepo = stmt.ColumnText(2) + fs.BaseBranch = stmt.ColumnText(3) + fs.CreatedAt = stmt.ColumnText(4) + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("dashboard: load fellowship: %w", err) } - if status.Scouts == nil { - status.Scouts = []ScoutEntry{} + if !found { + return nil, fmt.Errorf("dashboard: fellowship not initialized") } - if status.Companies == nil { - status.Companies = []CompanyEntry{} + + // Load quests + fs.Quests = []QuestEntry{} + err = sqlitex.Execute(conn, + `SELECT name, task_description, worktree, branch, task_id, status FROM fellowship_quests`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + fs.Quests = append(fs.Quests, QuestEntry{ + Name: stmt.ColumnText(0), + TaskDescription: stmt.ColumnText(1), + Worktree: stmt.ColumnText(2), + Branch: stmt.ColumnText(3), + TaskID: stmt.ColumnText(4), + Status: stmt.ColumnText(5), + }) + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("dashboard: load quests: %w", err) } - for _, q := range fs.Quests { - qs, err := loadQuestStatus(q.Name, q.Worktree) - if err != nil { - // Worktree state can't be loaded — show completed/cancelled quests - // as synthetic entries, skip active quests with missing worktrees - entryStatus := QuestEntryStatus(q) - if entryStatus == "completed" || entryStatus == "cancelled" { - status.Quests = append(status.Quests, QuestStatus{ - Name: q.Name, - Worktree: q.Worktree, - Phase: "Complete", - Status: entryStatus, + + // Load scouts + fs.Scouts = []ScoutEntry{} + err = sqlitex.Execute(conn, + `SELECT name, question, task_id FROM fellowship_scouts`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + fs.Scouts = append(fs.Scouts, ScoutEntry{ + Name: stmt.ColumnText(0), + Question: stmt.ColumnText(1), + TaskID: stmt.ColumnText(2), }) - } - continue - } - qs.Status = QuestEntryStatus(q) - status.Quests = append(status.Quests, *qs) + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("dashboard: load scouts: %w", err) } - return status, nil -} -// discoverFromWorktrees scans git worktree list for quest-state.json files. -func discoverFromWorktrees(gitRoot string) (*DashboardStatus, error) { - cmd := exec.Command("git", "worktree", "list", "--porcelain") - cmd.Dir = gitRoot - out, err := cmd.Output() + // Load companies with members + fs.Companies = []CompanyEntry{} + companyMap := make(map[string]*CompanyEntry) + + err = sqlitex.Execute(conn, + `SELECT name FROM companies`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + name := stmt.ColumnText(0) + entry := CompanyEntry{ + Name: name, + Quests: []string{}, + Scouts: []string{}, + } + fs.Companies = append(fs.Companies, entry) + companyMap[name] = &fs.Companies[len(fs.Companies)-1] + return nil + }, + }) if err != nil { - return nil, fmt.Errorf("listing worktrees: %w", err) + return nil, fmt.Errorf("dashboard: load companies: %w", err) } - status := &DashboardStatus{ - Name: filepath.Base(gitRoot), - Quests: []QuestStatus{}, - Scouts: []ScoutEntry{}, - PollInterval: 5, + err = sqlitex.Execute(conn, + `SELECT company_name, member_name, member_type FROM company_members`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + companyName := stmt.ColumnText(0) + memberName := stmt.ColumnText(1) + memberType := stmt.ColumnText(2) + if c, ok := companyMap[companyName]; ok { + switch memberType { + case "quest": + c.Quests = append(c.Quests, memberName) + case "scout": + c.Scouts = append(c.Scouts, memberName) + } + } + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("dashboard: load company members: %w", err) } - for _, line := range strings.Split(string(out), "\n") { - if !strings.HasPrefix(line, "worktree ") { - continue + return &fs, nil +} + +// SaveFellowship updates the fellowship singleton and upserts all quests, scouts, and companies. +func SaveFellowship(conn *sqlite.Conn, fs *FellowshipState) error { + // Update fellowship singleton + if err := sqlitex.Execute(conn, + `UPDATE fellowship SET version=:version, name=:name, main_repo=:main_repo, + base_branch=:base_branch WHERE id = 1`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":version": fmt.Sprintf("%d", fs.Version), + ":name": fs.Name, + ":main_repo": fs.MainRepo, + ":base_branch": fs.BaseBranch, + }, + }); err != nil { + return fmt.Errorf("dashboard: update fellowship: %w", err) + } + + // Sync quests: delete removed, upsert current + if err := sqlitex.Execute(conn, `DELETE FROM fellowship_quests`, nil); err != nil { + return fmt.Errorf("dashboard: clear quests: %w", err) + } + for _, q := range fs.Quests { + if err := upsertQuest(conn, q); err != nil { + return err } - wtPath := strings.TrimPrefix(line, "worktree ") - questStatePath := filepath.Join(wtPath, datadir.Name(), "quest-state.json") - if _, err := os.Stat(questStatePath); err != nil { - continue + } + + // Sync scouts + if err := sqlitex.Execute(conn, `DELETE FROM fellowship_scouts`, nil); err != nil { + return fmt.Errorf("dashboard: clear scouts: %w", err) + } + for _, s := range fs.Scouts { + if err := upsertScout(conn, s); err != nil { + return err } - name := filepath.Base(wtPath) - qs, err := loadQuestStatus(name, wtPath) - if err != nil { - continue + } + + // Sync companies + if err := sqlitex.Execute(conn, `DELETE FROM company_members`, nil); err != nil { + return fmt.Errorf("dashboard: clear company members: %w", err) + } + if err := sqlitex.Execute(conn, `DELETE FROM companies`, nil); err != nil { + return fmt.Errorf("dashboard: clear companies: %w", err) + } + for _, c := range fs.Companies { + if err := addCompanyInternal(conn, c.Name, c.Quests, c.Scouts); err != nil { + return err } - status.Quests = append(status.Quests, *qs) } - return status, nil + return nil } -// loadQuestStatus loads a single quest's state from its worktree. -func loadQuestStatus(name, worktree string) (*QuestStatus, error) { - questStatePath := filepath.Join(worktree, datadir.Name(), "quest-state.json") - s, err := state.Load(questStatePath) - if err != nil { - return nil, err - } - done, total := LoadErrandProgress(worktree) - return &QuestStatus{ - Name: name, - Worktree: worktree, - Phase: s.Phase, - GatePending: s.GatePending, - GateID: s.GateID, - LembasCompleted: s.LembasCompleted, - MetadataUpdated: s.MetadataUpdated, - ErrandsDone: done, - ErrandsTotal: total, - }, nil +// AddQuest inserts a quest into fellowship_quests. +func AddQuest(conn *sqlite.Conn, q QuestEntry) error { + return upsertQuest(conn, q) } -// LoadErrandProgress loads the hook file from a worktree and returns progress counts. -func LoadErrandProgress(worktree string) (done, total int) { - errandPath := filepath.Join(worktree, datadir.Name(), "quest-errands.json") - h, err := errand.Load(errandPath) - if err != nil { - return 0, 0 +func upsertQuest(conn *sqlite.Conn, q QuestEntry) error { + status := q.Status + if status == "" { + status = "active" } - return errand.Progress(h) + return sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, worktree, branch, task_id, status) + VALUES (:name, :desc, :wt, :branch, :task_id, :status) + ON CONFLICT(name) DO UPDATE SET + task_description=:desc, worktree=:wt, branch=:branch, task_id=:task_id, status=:status`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":name": q.Name, + ":desc": q.TaskDescription, + ":wt": q.Worktree, + ":branch": q.Branch, + ":task_id": q.TaskID, + ":status": status, + }, + }) } -// WithStateLock acquires an exclusive file lock, loads the state, calls fn to -// mutate it, and saves the result. The entire load→mutate→save is atomic with -// respect to other processes using the same lock. -func WithStateLock(path string, fn func(s *FellowshipState) error) error { - lockPath := path + ".lock" - lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return fmt.Errorf("opening lock file: %w", err) +// UpdateQuest updates specific fields on a quest by name. +func UpdateQuest(conn *sqlite.Conn, name string, updates map[string]any) error { + // Build SET clause from allowed fields + allowed := map[string]string{ + "task_description": "task_description", + "worktree": "worktree", + "branch": "branch", + "task_id": "task_id", + "status": "status", + } + setClauses := "" + named := map[string]any{":name": name} + for k, v := range updates { + col, ok := allowed[k] + if !ok { + continue + } + if setClauses != "" { + setClauses += ", " + } + param := ":" + k + setClauses += col + "=" + param + named[param] = v } - defer lockFile.Close() + if setClauses == "" { + return nil + } + return sqlitex.Execute(conn, + `UPDATE fellowship_quests SET `+setClauses+` WHERE name = :name`, + &sqlitex.ExecOptions{Named: named}) +} - if err := filelock.Lock(lockFile.Fd()); err != nil { - return fmt.Errorf("acquiring lock: %w", err) +// RemoveQuest deletes a quest by name. +func RemoveQuest(conn *sqlite.Conn, name string) error { + return sqlitex.Execute(conn, + `DELETE FROM fellowship_quests WHERE name = :name`, + &sqlitex.ExecOptions{Named: map[string]any{":name": name}}) +} + +// AddScout inserts a scout into fellowship_scouts. +func AddScout(conn *sqlite.Conn, s ScoutEntry) error { + return upsertScout(conn, s) +} + +func upsertScout(conn *sqlite.Conn, s ScoutEntry) error { + return sqlitex.Execute(conn, + `INSERT INTO fellowship_scouts (name, question, task_id) + VALUES (:name, :question, :task_id) + ON CONFLICT(name) DO UPDATE SET question=:question, task_id=:task_id`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":name": s.Name, + ":question": s.Question, + ":task_id": s.TaskID, + }, + }) +} + +// RemoveScout deletes a scout by name. +func RemoveScout(conn *sqlite.Conn, name string) error { + return sqlitex.Execute(conn, + `DELETE FROM fellowship_scouts WHERE name = :name`, + &sqlitex.ExecOptions{Named: map[string]any{":name": name}}) +} + +// AddCompany inserts a company with its quest and scout members. +func AddCompany(conn *sqlite.Conn, name string, quests []string, scouts []string) error { + return addCompanyInternal(conn, name, quests, scouts) +} + +func addCompanyInternal(conn *sqlite.Conn, name string, quests []string, scouts []string) error { + if err := sqlitex.Execute(conn, + `INSERT INTO companies (name) VALUES (:name) ON CONFLICT(name) DO NOTHING`, + &sqlitex.ExecOptions{Named: map[string]any{":name": name}}); err != nil { + return fmt.Errorf("dashboard: add company %s: %w", name, err) + } + for _, q := range quests { + if err := sqlitex.Execute(conn, + `INSERT INTO company_members (company_name, member_name, member_type) + VALUES (:company, :member, 'quest') + ON CONFLICT DO NOTHING`, + &sqlitex.ExecOptions{ + Named: map[string]any{":company": name, ":member": q}, + }); err != nil { + return fmt.Errorf("dashboard: add company member %s/%s: %w", name, q, err) + } + } + for _, s := range scouts { + if err := sqlitex.Execute(conn, + `INSERT INTO company_members (company_name, member_name, member_type) + VALUES (:company, :member, 'scout') + ON CONFLICT DO NOTHING`, + &sqlitex.ExecOptions{ + Named: map[string]any{":company": name, ":member": s}, + }); err != nil { + return fmt.Errorf("dashboard: add company member %s/%s: %w", name, s, err) + } } - defer filelock.Unlock(lockFile.Fd()) + return nil +} - s, err := LoadFellowshipState(path) +// ListQuests returns all quests from fellowship_quests. +func ListQuests(conn *sqlite.Conn) ([]QuestEntry, error) { + var quests []QuestEntry + err := sqlitex.Execute(conn, + `SELECT name, task_description, worktree, branch, task_id, status FROM fellowship_quests`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + quests = append(quests, QuestEntry{ + Name: stmt.ColumnText(0), + TaskDescription: stmt.ColumnText(1), + Worktree: stmt.ColumnText(2), + Branch: stmt.ColumnText(3), + TaskID: stmt.ColumnText(4), + Status: stmt.ColumnText(5), + }) + return nil + }, + }) + return quests, err +} + +// ListScouts returns all scouts from fellowship_scouts. +func ListScouts(conn *sqlite.Conn) ([]ScoutEntry, error) { + var scouts []ScoutEntry + err := sqlitex.Execute(conn, + `SELECT name, question, task_id FROM fellowship_scouts`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + scouts = append(scouts, ScoutEntry{ + Name: stmt.ColumnText(0), + Question: stmt.ColumnText(1), + TaskID: stmt.ColumnText(2), + }) + return nil + }, + }) + return scouts, err +} + +// ListCompanies returns all companies with their members. +func ListCompanies(conn *sqlite.Conn) ([]CompanyEntry, error) { + var companies []CompanyEntry + companyMap := make(map[string]*CompanyEntry) + + err := sqlitex.Execute(conn, + `SELECT name FROM companies`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + name := stmt.ColumnText(0) + companies = append(companies, CompanyEntry{ + Name: name, + Quests: []string{}, + Scouts: []string{}, + }) + companyMap[name] = &companies[len(companies)-1] + return nil + }, + }) if err != nil { - return err + return nil, err } - if err := fn(s); err != nil { - return err + err = sqlitex.Execute(conn, + `SELECT company_name, member_name, member_type FROM company_members`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + companyName := stmt.ColumnText(0) + memberName := stmt.ColumnText(1) + memberType := stmt.ColumnText(2) + if c, ok := companyMap[companyName]; ok { + switch memberType { + case "quest": + c.Quests = append(c.Quests, memberName) + case "scout": + c.Scouts = append(c.Scouts, memberName) + } + } + return nil + }, + }) + if err != nil { + return nil, err } - return SaveFellowshipState(path, s) + return companies, nil } -func SaveFellowshipState(path string, s *FellowshipState) error { - if s.Quests == nil { - s.Quests = []QuestEntry{} - } - if s.Scouts == nil { - s.Scouts = []ScoutEntry{} +// DiscoverQuests queries the DB for fellowship state joined with quest_state for +// phase/gate status. If no fellowship row exists, returns an empty status. +func DiscoverQuests(conn *sqlite.Conn) (*DashboardStatus, error) { + fs, err := LoadFellowship(conn) + if err != nil { + // No fellowship row — return empty status + return &DashboardStatus{ + Quests: []QuestStatus{}, + Scouts: []ScoutEntry{}, + Companies: []CompanyEntry{}, + }, nil } - if s.Companies == nil { - s.Companies = []CompanyEntry{} + + status := &DashboardStatus{ + Name: fs.Name, + Quests: []QuestStatus{}, + Scouts: fs.Scouts, + Companies: fs.Companies, } - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return fmt.Errorf("marshaling fellowship state: %w", err) + if status.Scouts == nil { + status.Scouts = []ScoutEntry{} } - data = append(data, '\n') - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { - return fmt.Errorf("writing temp file: %w", err) + if status.Companies == nil { + status.Companies = []CompanyEntry{} } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return fmt.Errorf("renaming temp file: %w", err) + + for _, q := range fs.Quests { + entryStatus := QuestEntryStatus(q) + + // Try to load quest state from DB + qs, loadErr := loadQuestStatusFromDB(conn, q.Name, q.Worktree) + if loadErr != nil { + // Quest state not in DB — show completed/cancelled as synthetic entries + if entryStatus == "completed" || entryStatus == "cancelled" { + status.Quests = append(status.Quests, QuestStatus{ + Name: q.Name, + Worktree: q.Worktree, + Phase: "Complete", + Status: entryStatus, + }) + } + continue + } + qs.Status = entryStatus + status.Quests = append(status.Quests, *qs) } - return nil + + return status, nil } -func LoadFellowshipState(path string) (*FellowshipState, error) { - data, err := os.ReadFile(path) +// loadQuestStatusFromDB loads a single quest's status from the quest_state table. +func loadQuestStatusFromDB(conn *sqlite.Conn, name, worktree string) (*QuestStatus, error) { + s, err := state.Load(conn, name) if err != nil { - return nil, fmt.Errorf("reading fellowship state file: %w", err) - } - if len(data) == 0 { - return nil, fmt.Errorf("fellowship state file is empty") - } - var s FellowshipState - if err := json.Unmarshal(data, &s); err != nil { - return nil, fmt.Errorf("parsing fellowship state file: %w", err) + return nil, err } - return &s, nil + done, total, _ := errand.Progress(conn, name) + return &QuestStatus{ + Name: name, + Worktree: worktree, + Phase: s.Phase, + GatePending: s.GatePending, + GateID: s.GateID, + LembasCompleted: s.LembasCompleted, + MetadataUpdated: s.MetadataUpdated, + ErrandsDone: done, + ErrandsTotal: total, + }, nil } diff --git a/cli/internal/dashboard/fellowship_test.go b/cli/internal/dashboard/fellowship_test.go index 7499395..1e90c57 100644 --- a/cli/internal/dashboard/fellowship_test.go +++ b/cli/internal/dashboard/fellowship_test.go @@ -1,288 +1,208 @@ package dashboard import ( - "fmt" - "os" - "path/filepath" + "context" "testing" -) - -func TestLoadFellowshipState(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "fellowship-state.json") - - data := `{ - "name": "test-fellowship", - "created_at": "2025-01-15T10:30:00Z", - "quests": [ - { - "name": "add-auth", - "worktree": "/tmp/worktrees/add-auth", - "task_id": "task-001" - }, - { - "name": "fix-bug", - "worktree": "/tmp/worktrees/fix-bug", - "task_id": "task-002" - } - ], - "scouts": [ - { - "name": "research-api", - "task_id": "task-003" - } - ] -}` - - if err := os.WriteFile(path, []byte(data), 0644); err != nil { - t.Fatalf("writing test file: %v", err) - } - state, err := LoadFellowshipState(path) - if err != nil { - t.Fatalf("LoadFellowshipState() error: %v", err) - } + "github.com/justinjdev/fellowship/cli/internal/db" + "github.com/justinjdev/fellowship/cli/internal/state" +) - if state.Name != "test-fellowship" { - t.Errorf("Name = %q, want %q", state.Name, "test-fellowship") - } - if state.CreatedAt != "2025-01-15T10:30:00Z" { - t.Errorf("CreatedAt = %q, want %q", state.CreatedAt, "2025-01-15T10:30:00Z") - } - if len(state.Quests) != 2 { - t.Fatalf("len(Quests) = %d, want 2", len(state.Quests)) - } - if state.Quests[0].Name != "add-auth" { - t.Errorf("Quests[0].Name = %q, want %q", state.Quests[0].Name, "add-auth") - } - if state.Quests[0].Worktree != "/tmp/worktrees/add-auth" { - t.Errorf("Quests[0].Worktree = %q, want %q", state.Quests[0].Worktree, "/tmp/worktrees/add-auth") - } - if state.Quests[0].TaskID != "task-001" { - t.Errorf("Quests[0].TaskID = %q, want %q", state.Quests[0].TaskID, "task-001") - } - if state.Quests[1].Name != "fix-bug" { - t.Errorf("Quests[1].Name = %q, want %q", state.Quests[1].Name, "fix-bug") - } - if len(state.Scouts) != 1 { - t.Fatalf("len(Scouts) = %d, want 1", len(state.Scouts)) - } - if state.Scouts[0].Name != "research-api" { - t.Errorf("Scouts[0].Name = %q, want %q", state.Scouts[0].Name, "research-api") - } - if state.Scouts[0].TaskID != "task-003" { - t.Errorf("Scouts[0].TaskID = %q, want %q", state.Scouts[0].TaskID, "task-003") - } +func TestInitAndLoadFellowship(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + err := InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") + if err != nil { + t.Fatal(err) + } + fs, err := LoadFellowship(conn) + if err != nil { + t.Fatal(err) + } + if fs.Name != "test-fellowship" { + t.Errorf("Name = %q, want %q", fs.Name, "test-fellowship") + } + if fs.MainRepo != "/tmp/repo" { + t.Errorf("MainRepo = %q, want %q", fs.MainRepo, "/tmp/repo") + } + if fs.BaseBranch != "main" { + t.Errorf("BaseBranch = %q, want %q", fs.BaseBranch, "main") + } + return nil + }) } -func TestDiscoverQuests_FromFellowshipState(t *testing.T) { - root := t.TempDir() - t.Setenv("HOME", t.TempDir()) // Pin HOME so datadir.Name() returns default - - // Create a fake worktree directory with .fellowship/quest-state.json - worktreeDir := filepath.Join(root, "worktrees", "quest-auth") - if err := os.MkdirAll(filepath.Join(worktreeDir, ".fellowship"), 0755); err != nil { - t.Fatalf("creating worktree dir: %v", err) - } - - questState := `{ - "version": 1, - "quest_name": "quest-auth", - "task_id": "t1", - "team_name": "team", - "phase": "Implement", - "gate_pending": false, - "gate_id": null, - "lembas_completed": false, - "metadata_updated": false, - "auto_approve_gates": [] -}` - if err := os.WriteFile(filepath.Join(worktreeDir, ".fellowship", "quest-state.json"), []byte(questState), 0644); err != nil { - t.Fatalf("writing quest-state.json: %v", err) - } - - // Create fellowship-state.json pointing to that worktree - if err := os.MkdirAll(filepath.Join(root, ".fellowship"), 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } - fellowshipState := fmt.Sprintf(`{ - "name": "test-fellowship", - "created_at": "2025-01-15T10:30:00Z", - "quests": [ - { - "name": "quest-auth", - "worktree": %q, - "task_id": "t1" - } - ], - "scouts": [] -}`, worktreeDir) - if err := os.WriteFile(filepath.Join(root, ".fellowship", "fellowship-state.json"), []byte(fellowshipState), 0644); err != nil { - t.Fatalf("writing fellowship-state.json: %v", err) - } - - // Call DiscoverQuests - status, err := DiscoverQuests(root) - if err != nil { - t.Fatalf("DiscoverQuests() error: %v", err) - } - - if status.Name != "test-fellowship" { - t.Errorf("Name = %q, want %q", status.Name, "test-fellowship") - } - if len(status.Quests) != 1 { - t.Fatalf("len(Quests) = %d, want 1", len(status.Quests)) - } - q := status.Quests[0] - if q.Name != "quest-auth" { - t.Errorf("Quest.Name = %q, want %q", q.Name, "quest-auth") - } - if q.Phase != "Implement" { - t.Errorf("Quest.Phase = %q, want %q", q.Phase, "Implement") - } - if q.GatePending != false { - t.Errorf("Quest.GatePending = %v, want false", q.GatePending) - } - if q.GateID != nil { - t.Errorf("Quest.GateID = %v, want nil", q.GateID) - } - if q.Worktree != worktreeDir { - t.Errorf("Quest.Worktree = %q, want %q", q.Worktree, worktreeDir) - } +func TestAddQuest(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "f1", "/tmp", "main") + AddQuest(conn, QuestEntry{ + Name: "q1", TaskDescription: "build auth", Worktree: "/tmp/wt/q1", Branch: "feat/q1", + }) + quests, _ := ListQuests(conn) + if len(quests) != 1 { + t.Fatalf("expected 1, got %d", len(quests)) + } + if quests[0].Name != "q1" { + t.Errorf("Name = %q, want %q", quests[0].Name, "q1") + } + if quests[0].TaskDescription != "build auth" { + t.Errorf("TaskDescription = %q, want %q", quests[0].TaskDescription, "build auth") + } + return nil + }) } -func TestDiscoverQuests_SkipsMissingWorktree(t *testing.T) { - root := t.TempDir() - t.Setenv("HOME", t.TempDir()) // Pin HOME so datadir.Name() returns default - - // Create fellowship-state.json pointing to a non-existent worktree - if err := os.MkdirAll(filepath.Join(root, ".fellowship"), 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } - fellowshipState := `{ - "name": "test-fellowship", - "created_at": "2025-01-15T10:30:00Z", - "quests": [ - { - "name": "quest-missing", - "worktree": "/nonexistent/worktree", - "task_id": "t1" - } - ], - "scouts": [] -}` - if err := os.WriteFile(filepath.Join(root, ".fellowship", "fellowship-state.json"), []byte(fellowshipState), 0644); err != nil { - t.Fatalf("writing fellowship-state.json: %v", err) - } - - status, err := DiscoverQuests(root) - if err != nil { - t.Fatalf("DiscoverQuests() error: %v", err) - } +func TestAddAndRemoveScout(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "f1", "/tmp", "main") + AddScout(conn, ScoutEntry{Name: "s1", Question: "how?", TaskID: "t1"}) + scouts, _ := ListScouts(conn) + if len(scouts) != 1 { + t.Fatalf("expected 1 scout, got %d", len(scouts)) + } + if scouts[0].Name != "s1" { + t.Errorf("Name = %q, want %q", scouts[0].Name, "s1") + } - if len(status.Quests) != 0 { - t.Errorf("len(Quests) = %d, want 0 (missing worktree should be skipped)", len(status.Quests)) - } + RemoveScout(conn, "s1") + scouts, _ = ListScouts(conn) + if len(scouts) != 0 { + t.Errorf("expected 0 scouts after remove, got %d", len(scouts)) + } + return nil + }) } -func TestSaveFellowshipState_RoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "fellowship-state.json") - - original := &FellowshipState{ - Version: 1, - Name: "test-fellowship", - CreatedAt: "2025-01-15T10:30:00Z", - MainRepo: "/path/to/repo", - Quests: []QuestEntry{ - {Name: "quest-1", TaskDescription: "do stuff", Worktree: "/tmp/wt", Branch: "fellowship/quest-1", TaskID: "t1"}, - }, - Scouts: []ScoutEntry{ - {Name: "scout-1", Question: "how does X work?", TaskID: "t2"}, - }, - Companies: []CompanyEntry{ - {Name: "company-1", Quests: []string{"quest-1"}, Scouts: []string{"scout-1"}}, - }, - } +func TestAddCompany(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "f1", "/tmp", "main") + AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1"}) + AddScout(conn, ScoutEntry{Name: "s1", Question: "why?"}) + AddCompany(conn, "team-alpha", []string{"q1"}, []string{"s1"}) + + companies, _ := ListCompanies(conn) + if len(companies) != 1 { + t.Fatalf("expected 1 company, got %d", len(companies)) + } + if companies[0].Name != "team-alpha" { + t.Errorf("Name = %q, want %q", companies[0].Name, "team-alpha") + } + if len(companies[0].Quests) != 1 || companies[0].Quests[0] != "q1" { + t.Errorf("Quests = %v, want [q1]", companies[0].Quests) + } + if len(companies[0].Scouts) != 1 || companies[0].Scouts[0] != "s1" { + t.Errorf("Scouts = %v, want [s1]", companies[0].Scouts) + } + return nil + }) +} - if err := SaveFellowshipState(path, original); err != nil { - t.Fatalf("SaveFellowshipState() error: %v", err) - } +func TestUpdateQuest(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "f1", "/tmp", "main") + AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1", Status: "active"}) + UpdateQuest(conn, "q1", map[string]any{"status": "completed"}) - loaded, err := LoadFellowshipState(path) - if err != nil { - t.Fatalf("LoadFellowshipState() error: %v", err) - } - - if loaded.Name != original.Name { - t.Errorf("Name = %q, want %q", loaded.Name, original.Name) - } - if loaded.Version != original.Version { - t.Errorf("Version = %d, want %d", loaded.Version, original.Version) - } - if loaded.MainRepo != original.MainRepo { - t.Errorf("MainRepo = %q, want %q", loaded.MainRepo, original.MainRepo) - } - if len(loaded.Quests) != 1 { - t.Fatalf("len(Quests) = %d, want 1", len(loaded.Quests)) - } - if loaded.Quests[0].TaskDescription != "do stuff" { - t.Errorf("Quests[0].TaskDescription = %q, want %q", loaded.Quests[0].TaskDescription, "do stuff") - } - if loaded.Quests[0].Branch != "fellowship/quest-1" { - t.Errorf("Quests[0].Branch = %q, want %q", loaded.Quests[0].Branch, "fellowship/quest-1") - } - if len(loaded.Scouts) != 1 { - t.Fatalf("len(Scouts) = %d, want 1", len(loaded.Scouts)) - } - if loaded.Scouts[0].Question != "how does X work?" { - t.Errorf("Scouts[0].Question = %q, want %q", loaded.Scouts[0].Question, "how does X work?") - } - if len(loaded.Companies) != 1 { - t.Fatalf("len(Companies) = %d, want 1", len(loaded.Companies)) - } - if loaded.Companies[0].Name != "company-1" { - t.Errorf("Companies[0].Name = %q, want %q", loaded.Companies[0].Name, "company-1") - } + quests, _ := ListQuests(conn) + if len(quests) != 1 { + t.Fatalf("expected 1, got %d", len(quests)) + } + if quests[0].Status != "completed" { + t.Errorf("Status = %q, want %q", quests[0].Status, "completed") + } + return nil + }) } -func TestSaveFellowshipState_NilSlices(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "fellowship-state.json") +func TestRemoveQuest(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "f1", "/tmp", "main") + AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1"}) + RemoveQuest(conn, "q1") + quests, _ := ListQuests(conn) + if len(quests) != 0 { + t.Errorf("expected 0 quests after remove, got %d", len(quests)) + } + return nil + }) +} - s := &FellowshipState{ - Version: 1, - Name: "test", - CreatedAt: "2025-01-15T10:30:00Z", - MainRepo: "/repo", - } +func TestSaveFellowship_RoundTrip(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "test-fellowship", "/path/to/repo", "main") + + original := &FellowshipState{ + Version: 1, + Name: "test-fellowship", + CreatedAt: "2025-01-15T10:30:00Z", + MainRepo: "/path/to/repo", + BaseBranch: "main", + Quests: []QuestEntry{ + {Name: "quest-1", TaskDescription: "do stuff", Worktree: "/tmp/wt", Branch: "fellowship/quest-1", TaskID: "t1"}, + }, + Scouts: []ScoutEntry{ + {Name: "scout-1", Question: "how does X work?", TaskID: "t2"}, + }, + Companies: []CompanyEntry{ + {Name: "company-1", Quests: []string{"quest-1"}, Scouts: []string{"scout-1"}}, + }, + } - if err := SaveFellowshipState(path, s); err != nil { - t.Fatalf("SaveFellowshipState() error: %v", err) - } + if err := SaveFellowship(conn, original); err != nil { + t.Fatalf("SaveFellowship() error: %v", err) + } - loaded, err := LoadFellowshipState(path) - if err != nil { - t.Fatalf("LoadFellowshipState() error: %v", err) - } + loaded, err := LoadFellowship(conn) + if err != nil { + t.Fatalf("LoadFellowship() error: %v", err) + } - // Nil slices should be saved as empty arrays, not null - if loaded.Quests == nil { - t.Error("Quests should be non-nil (empty slice)") - } - if loaded.Scouts == nil { - t.Error("Scouts should be non-nil (empty slice)") - } - if loaded.Companies == nil { - t.Error("Companies should be non-nil (empty slice)") - } + if loaded.Name != original.Name { + t.Errorf("Name = %q, want %q", loaded.Name, original.Name) + } + if loaded.MainRepo != original.MainRepo { + t.Errorf("MainRepo = %q, want %q", loaded.MainRepo, original.MainRepo) + } + if len(loaded.Quests) != 1 { + t.Fatalf("len(Quests) = %d, want 1", len(loaded.Quests)) + } + if loaded.Quests[0].TaskDescription != "do stuff" { + t.Errorf("Quests[0].TaskDescription = %q, want %q", loaded.Quests[0].TaskDescription, "do stuff") + } + if loaded.Quests[0].Branch != "fellowship/quest-1" { + t.Errorf("Quests[0].Branch = %q, want %q", loaded.Quests[0].Branch, "fellowship/quest-1") + } + if len(loaded.Scouts) != 1 { + t.Fatalf("len(Scouts) = %d, want 1", len(loaded.Scouts)) + } + if loaded.Scouts[0].Question != "how does X work?" { + t.Errorf("Scouts[0].Question = %q, want %q", loaded.Scouts[0].Question, "how does X work?") + } + if len(loaded.Companies) != 1 { + t.Fatalf("len(Companies) = %d, want 1", len(loaded.Companies)) + } + if loaded.Companies[0].Name != "company-1" { + t.Errorf("Companies[0].Name = %q, want %q", loaded.Companies[0].Name, "company-1") + } + return nil + }) } -func TestLoadFellowshipState_Missing(t *testing.T) { - _, err := LoadFellowshipState("/nonexistent/path/fellowship-state.json") - if err == nil { - t.Fatal("LoadFellowshipState() expected error for missing file, got nil") - } +func TestLoadFellowship_NotInitialized(t *testing.T) { + d := db.OpenTest(t) + d.WithConn(context.Background(), func(conn *db.Conn) error { + _, err := LoadFellowship(conn) + if err == nil { + t.Fatal("expected error for uninitialized fellowship, got nil") + } + return nil + }) } func TestQuestEntryStatus_Default(t *testing.T) { @@ -301,211 +221,101 @@ func TestQuestEntryStatus_Explicit(t *testing.T) { } } -func TestSaveFellowshipState_WithStatus(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "fellowship-state.json") - - original := &FellowshipState{ - Version: 1, Name: "test", CreatedAt: "2025-01-15T10:30:00Z", - Quests: []QuestEntry{ - {Name: "q1", Status: "completed"}, - {Name: "q2"}, // no status — omitempty - }, - } - - if err := SaveFellowshipState(path, original); err != nil { - t.Fatalf("SaveFellowshipState() error: %v", err) - } - - loaded, err := LoadFellowshipState(path) - if err != nil { - t.Fatalf("LoadFellowshipState() error: %v", err) - } - - if loaded.Quests[0].Status != "completed" { - t.Errorf("Quests[0].Status = %q, want %q", loaded.Quests[0].Status, "completed") - } - if loaded.Quests[1].Status != "" { - t.Errorf("Quests[1].Status = %q, want empty", loaded.Quests[1].Status) - } -} - -func TestDiscoverQuests_CompletedMissingWorktree(t *testing.T) { - root := t.TempDir() - t.Setenv("HOME", t.TempDir()) - - if err := os.MkdirAll(filepath.Join(root, ".fellowship"), 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } - fellowshipState := `{ - "name": "test-fellowship", - "created_at": "2025-01-15T10:30:00Z", - "quests": [ - { - "name": "quest-done", - "worktree": "/nonexistent/worktree", - "task_id": "t1", - "status": "completed" - } - ], - "scouts": [] -}` - if err := os.WriteFile(filepath.Join(root, ".fellowship", "fellowship-state.json"), []byte(fellowshipState), 0644); err != nil { - t.Fatalf("writing fellowship-state.json: %v", err) - } - - status, err := DiscoverQuests(root) - if err != nil { - t.Fatalf("DiscoverQuests() error: %v", err) - } - - if len(status.Quests) != 1 { - t.Fatalf("len(Quests) = %d, want 1", len(status.Quests)) - } - q := status.Quests[0] - if q.Name != "quest-done" { - t.Errorf("Quest.Name = %q, want %q", q.Name, "quest-done") - } - if q.Phase != "Complete" { - t.Errorf("Quest.Phase = %q, want %q", q.Phase, "Complete") - } - if q.Status != "completed" { - t.Errorf("Quest.Status = %q, want %q", q.Status, "completed") - } +func TestDiscoverQuests_NoFellowship(t *testing.T) { + d := db.OpenTest(t) + d.WithConn(context.Background(), func(conn *db.Conn) error { + status, err := DiscoverQuests(conn) + if err != nil { + t.Fatalf("DiscoverQuests() error: %v", err) + } + if len(status.Quests) != 0 { + t.Errorf("expected 0 quests, got %d", len(status.Quests)) + } + return nil + }) } -func TestDiscoverQuests_CancelledMissingWorktree(t *testing.T) { - root := t.TempDir() - t.Setenv("HOME", t.TempDir()) - - if err := os.MkdirAll(filepath.Join(root, ".fellowship"), 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } - fellowshipState := `{ - "name": "test-fellowship", - "created_at": "2025-01-15T10:30:00Z", - "quests": [ - { - "name": "quest-cancelled", - "worktree": "/nonexistent/worktree", - "task_id": "t1", - "status": "cancelled" - } - ], - "scouts": [] -}` - if err := os.WriteFile(filepath.Join(root, ".fellowship", "fellowship-state.json"), []byte(fellowshipState), 0644); err != nil { - t.Fatalf("writing fellowship-state.json: %v", err) - } - - status, err := DiscoverQuests(root) - if err != nil { - t.Fatalf("DiscoverQuests() error: %v", err) - } - - if len(status.Quests) != 1 { - t.Fatalf("len(Quests) = %d, want 1", len(status.Quests)) - } - if status.Quests[0].Status != "cancelled" { - t.Errorf("Quest.Status = %q, want %q", status.Quests[0].Status, "cancelled") - } - if status.Quests[0].Phase != "Complete" { - t.Errorf("Quest.Phase = %q, want %q", status.Quests[0].Phase, "Complete") - } +func TestDiscoverQuests_WithQuestState(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") + AddQuest(conn, QuestEntry{ + Name: "quest-auth", Worktree: "/tmp/wt/quest-auth", Branch: "feat/auth", + }) + + // Insert quest_state row + state.Upsert(conn, &state.State{ + QuestName: "quest-auth", + Phase: "Implement", + }) + + status, err := DiscoverQuests(conn) + if err != nil { + t.Fatalf("DiscoverQuests() error: %v", err) + } + if status.Name != "test-fellowship" { + t.Errorf("Name = %q, want %q", status.Name, "test-fellowship") + } + if len(status.Quests) != 1 { + t.Fatalf("len(Quests) = %d, want 1", len(status.Quests)) + } + q := status.Quests[0] + if q.Name != "quest-auth" { + t.Errorf("Quest.Name = %q, want %q", q.Name, "quest-auth") + } + if q.Phase != "Implement" { + t.Errorf("Quest.Phase = %q, want %q", q.Phase, "Implement") + } + if q.Worktree != "/tmp/wt/quest-auth" { + t.Errorf("Quest.Worktree = %q, want %q", q.Worktree, "/tmp/wt/quest-auth") + } + return nil + }) } -func TestDiscoverQuests_ActiveMissingWorktreeSkipped(t *testing.T) { - root := t.TempDir() - t.Setenv("HOME", t.TempDir()) - - if err := os.MkdirAll(filepath.Join(root, ".fellowship"), 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } - // Active quest (no status) with missing worktree — should still be skipped - fellowshipState := `{ - "name": "test-fellowship", - "created_at": "2025-01-15T10:30:00Z", - "quests": [ - { - "name": "quest-active", - "worktree": "/nonexistent/worktree", - "task_id": "t1" - } - ], - "scouts": [] -}` - if err := os.WriteFile(filepath.Join(root, ".fellowship", "fellowship-state.json"), []byte(fellowshipState), 0644); err != nil { - t.Fatalf("writing fellowship-state.json: %v", err) - } - - status, err := DiscoverQuests(root) - if err != nil { - t.Fatalf("DiscoverQuests() error: %v", err) - } - - if len(status.Quests) != 0 { - t.Errorf("len(Quests) = %d, want 0 (active quest with missing worktree should be skipped)", len(status.Quests)) - } +func TestDiscoverQuests_CompletedNoQuestState(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") + AddQuest(conn, QuestEntry{ + Name: "quest-done", Worktree: "/tmp/wt/done", Status: "completed", + }) + + // No quest_state row — should appear as synthetic Complete entry + status, err := DiscoverQuests(conn) + if err != nil { + t.Fatalf("DiscoverQuests() error: %v", err) + } + if len(status.Quests) != 1 { + t.Fatalf("len(Quests) = %d, want 1", len(status.Quests)) + } + q := status.Quests[0] + if q.Phase != "Complete" { + t.Errorf("Quest.Phase = %q, want %q", q.Phase, "Complete") + } + if q.Status != "completed" { + t.Errorf("Quest.Status = %q, want %q", q.Status, "completed") + } + return nil + }) } -func TestDiscoverQuests_CompletedExistingWorktree(t *testing.T) { - root := t.TempDir() - t.Setenv("HOME", t.TempDir()) - - // Create a worktree with quest-state.json - worktreeDir := filepath.Join(root, "worktrees", "quest-done") - if err := os.MkdirAll(filepath.Join(worktreeDir, ".fellowship"), 0755); err != nil { - t.Fatalf("creating worktree dir: %v", err) - } - questState := `{ - "version": 1, - "quest_name": "quest-done", - "task_id": "t1", - "team_name": "team", - "phase": "Complete", - "gate_pending": false, - "gate_id": null, - "lembas_completed": false, - "metadata_updated": false, - "auto_approve_gates": [] -}` - if err := os.WriteFile(filepath.Join(worktreeDir, ".fellowship", "quest-state.json"), []byte(questState), 0644); err != nil { - t.Fatalf("writing quest-state.json: %v", err) - } - - if err := os.MkdirAll(filepath.Join(root, ".fellowship"), 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } - fellowshipState := fmt.Sprintf(`{ - "name": "test-fellowship", - "created_at": "2025-01-15T10:30:00Z", - "quests": [ - { - "name": "quest-done", - "worktree": %q, - "task_id": "t1", - "status": "completed" - } - ], - "scouts": [] -}`, worktreeDir) - if err := os.WriteFile(filepath.Join(root, ".fellowship", "fellowship-state.json"), []byte(fellowshipState), 0644); err != nil { - t.Fatalf("writing fellowship-state.json: %v", err) - } - - status, err := DiscoverQuests(root) - if err != nil { - t.Fatalf("DiscoverQuests() error: %v", err) - } - - if len(status.Quests) != 1 { - t.Fatalf("len(Quests) = %d, want 1", len(status.Quests)) - } - q := status.Quests[0] - if q.Status != "completed" { - t.Errorf("Quest.Status = %q, want %q", q.Status, "completed") - } - if q.Phase != "Complete" { - t.Errorf("Quest.Phase = %q, want %q", q.Phase, "Complete") - } +func TestDiscoverQuests_ActiveNoQuestStateSkipped(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") + AddQuest(conn, QuestEntry{ + Name: "quest-active", Worktree: "/tmp/wt/active", + }) + + // No quest_state row, active status — should be skipped + status, err := DiscoverQuests(conn) + if err != nil { + t.Fatalf("DiscoverQuests() error: %v", err) + } + if len(status.Quests) != 0 { + t.Errorf("expected 0 quests (active with no quest_state should be skipped), got %d", len(status.Quests)) + } + return nil + }) } diff --git a/cli/internal/dashboard/server.go b/cli/internal/dashboard/server.go index 254f3ac..ba290a1 100644 --- a/cli/internal/dashboard/server.go +++ b/cli/internal/dashboard/server.go @@ -1,17 +1,17 @@ package dashboard import ( + "context" "encoding/base64" "encoding/json" "fmt" iofs "io/fs" "net/http" - "path/filepath" "strings" "time" "github.com/justinjdev/fellowship/cli/internal/bulletin" - "github.com/justinjdev/fellowship/cli/internal/datadir" + "github.com/justinjdev/fellowship/cli/internal/db" "github.com/justinjdev/fellowship/cli/internal/eagles" "github.com/justinjdev/fellowship/cli/internal/errand" "github.com/justinjdev/fellowship/cli/internal/herald" @@ -24,14 +24,14 @@ type gateRequest struct { type Server struct { mux *http.ServeMux - gitRoot string + db *db.DB pollInterval int } -func NewServer(gitRoot string, pollInterval int) *Server { +func NewServer(d *db.DB, pollInterval int) *Server { s := &Server{ mux: http.NewServeMux(), - gitRoot: gitRoot, + db: d, pollInterval: pollInterval, } s.mux.HandleFunc("GET /api/status", s.handleStatus) @@ -63,16 +63,21 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (s *Server) validWorktreeDir(dir string) bool { - status, err := DiscoverQuests(s.gitRoot) - if err != nil { - return false - } - for _, q := range status.Quests { - if q.Worktree == dir { - return true + var valid bool + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + status, err := DiscoverQuests(conn) + if err != nil { + return nil } - } - return false + for _, q := range status.Quests { + if q.Worktree == dir { + valid = true + break + } + } + return nil + }) + return valid } func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { @@ -87,57 +92,72 @@ func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { return } - statePath := filepath.Join(req.Dir, datadir.Name(), "quest-state.json") - st, err := state.Load(statePath) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + err := s.db.WithTx(context.Background(), func(conn *db.Conn) error { + // Find the quest name for this worktree + questName, err := state.FindQuest(conn, req.Dir) + if err != nil || questName == "" { + return fmt.Errorf("quest not found for worktree %s", req.Dir) + } - if !st.GatePending { - http.Error(w, "no gate pending", http.StatusBadRequest) - return - } + st, err := state.Load(conn, questName) + if err != nil { + return err + } - prevPhase := st.Phase + if !st.GatePending { + return fmt.Errorf("no gate pending") + } - nextPhase, err := state.NextPhase(st.Phase) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + prevPhase := st.Phase - st.GatePending = false - st.Phase = nextPhase - st.GateID = nil - st.LembasCompleted = false - st.MetadataUpdated = false + nextPhase, err := state.NextPhase(st.Phase) + if err != nil { + return err + } - if err := state.Save(statePath, st); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + st.GatePending = false + st.Phase = nextPhase + st.GateID = nil + st.LembasCompleted = false + st.MetadataUpdated = false - now := time.Now().UTC().Format(time.RFC3339) - herald.Announce(req.Dir, herald.Tiding{ - Timestamp: now, Quest: st.QuestName, Type: herald.GateApproved, - Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), - }) - herald.Announce(req.Dir, herald.Tiding{ - Timestamp: now, Quest: st.QuestName, Type: herald.PhaseTransition, - Phase: st.Phase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, st.Phase), - }) + if err := state.Upsert(conn, st); err != nil { + return err + } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(QuestStatus{ - Name: st.QuestName, - Worktree: req.Dir, - Phase: st.Phase, - GatePending: st.GatePending, - GateID: st.GateID, - LembasCompleted: st.LembasCompleted, - MetadataUpdated: st.MetadataUpdated, + now := time.Now().UTC().Format(time.RFC3339) + if err := herald.Announce(conn, herald.Tiding{ + Timestamp: now, Quest: st.QuestName, Type: herald.GateApproved, + Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), + }); err != nil { + return err + } + if err := herald.Announce(conn, herald.Tiding{ + Timestamp: now, Quest: st.QuestName, Type: herald.PhaseTransition, + Phase: st.Phase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, st.Phase), + }); err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(QuestStatus{ + Name: st.QuestName, + Worktree: req.Dir, + Phase: st.Phase, + GatePending: st.GatePending, + GateID: st.GateID, + LembasCompleted: st.LembasCompleted, + MetadataUpdated: st.MetadataUpdated, + }) + return nil }) + if err != nil { + if err.Error() == "no gate pending" { + http.Error(w, err.Error(), http.StatusBadRequest) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } } func (s *Server) handleGateReject(w http.ResponseWriter, r *http.Request) { @@ -152,42 +172,55 @@ func (s *Server) handleGateReject(w http.ResponseWriter, r *http.Request) { return } - statePath := filepath.Join(req.Dir, datadir.Name(), "quest-state.json") - st, err := state.Load(statePath) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + err := s.db.WithTx(context.Background(), func(conn *db.Conn) error { + questName, err := state.FindQuest(conn, req.Dir) + if err != nil || questName == "" { + return fmt.Errorf("quest not found for worktree %s", req.Dir) + } - if !st.GatePending { - http.Error(w, "no gate pending", http.StatusBadRequest) - return - } + st, err := state.Load(conn, questName) + if err != nil { + return err + } - st.GatePending = false - st.GateID = nil + if !st.GatePending { + return fmt.Errorf("no gate pending") + } - if err := state.Save(statePath, st); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + st.GatePending = false + st.GateID = nil - herald.Announce(req.Dir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: st.QuestName, Type: herald.GateRejected, - Phase: st.Phase, Detail: fmt.Sprintf("Gate rejected for %s", st.Phase), - }) + if err := state.Upsert(conn, st); err != nil { + return err + } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(QuestStatus{ - Name: st.QuestName, - Worktree: req.Dir, - Phase: st.Phase, - GatePending: st.GatePending, - GateID: st.GateID, - LembasCompleted: st.LembasCompleted, - MetadataUpdated: st.MetadataUpdated, + if err := herald.Announce(conn, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: st.QuestName, Type: herald.GateRejected, + Phase: st.Phase, Detail: fmt.Sprintf("Gate rejected for %s", st.Phase), + }); err != nil { + return err + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(QuestStatus{ + Name: st.QuestName, + Worktree: req.Dir, + Phase: st.Phase, + GatePending: st.GatePending, + GateID: st.GateID, + LembasCompleted: st.LembasCompleted, + MetadataUpdated: st.MetadataUpdated, + }) + return nil }) + if err != nil { + if err.Error() == "no gate pending" { + http.Error(w, err.Error(), http.StatusBadRequest) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } } func (s *Server) handleCompanyApprove(w http.ResponseWriter, r *http.Request) { @@ -200,38 +233,48 @@ func (s *Server) handleCompanyApprove(w http.ResponseWriter, r *http.Request) { } name := parts[0] - statePath := filepath.Join(s.gitRoot, datadir.Name(), "fellowship-state.json") - fs, err := LoadFellowshipState(statePath) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - var target *CompanyEntry - for i := range fs.Companies { - if fs.Companies[i].Name == name { - target = &fs.Companies[i] - break - } - } - if target == nil { - http.Error(w, "company not found: "+name, http.StatusNotFound) - return - } - - approved, errs := batchApproveCompany(*target, fs) - type companyApproveResponse struct { Approved []string `json:"approved"` Errors []string `json:"errors,omitempty"` } - resp := companyApproveResponse{Approved: approved} - if resp.Approved == nil { - resp.Approved = []string{} - } - for _, e := range errs { - resp.Errors = append(resp.Errors, e.Error()) + var resp companyApproveResponse + resp.Approved = []string{} + + err := s.db.WithTx(context.Background(), func(conn *db.Conn) error { + fs, err := LoadFellowship(conn) + if err != nil { + return err + } + + var target *CompanyEntry + for i := range fs.Companies { + if fs.Companies[i].Name == name { + target = &fs.Companies[i] + break + } + } + if target == nil { + return fmt.Errorf("company not found: %s", name) + } + + approved, errs := batchApproveCompany(conn, *target, fs) + resp.Approved = approved + if resp.Approved == nil { + resp.Approved = []string{} + } + for _, e := range errs { + resp.Errors = append(resp.Errors, e.Error()) + } + return nil + }) + if err != nil { + if strings.HasPrefix(err.Error(), "company not found") { + http.Error(w, err.Error(), http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return } w.Header().Set("Content-Type", "application/json") @@ -239,20 +282,18 @@ func (s *Server) handleCompanyApprove(w http.ResponseWriter, r *http.Request) { } // batchApproveCompany approves all pending gates within a company. -func batchApproveCompany(c CompanyEntry, fs *FellowshipState) (approved []string, errs []error) { - questWorktree := make(map[string]string) - for _, q := range fs.Quests { - questWorktree[q.Name] = q.Worktree - } - +func batchApproveCompany(conn *db.Conn, c CompanyEntry, fs *FellowshipState) (approved []string, errs []error) { for _, qName := range c.Quests { - wt, ok := questWorktree[qName] - if !ok || wt == "" { - continue + // Find worktree from fellowship quests + var wt string + for _, q := range fs.Quests { + if q.Name == qName { + wt = q.Worktree + break + } } - statePath := filepath.Join(wt, datadir.Name(), "quest-state.json") - st, err := state.Load(statePath) + st, err := state.Load(conn, qName) if err != nil { errs = append(errs, fmt.Errorf("loading state for %s: %w", qName, err)) continue @@ -276,21 +317,22 @@ func batchApproveCompany(c CompanyEntry, fs *FellowshipState) (approved []string st.LembasCompleted = false st.MetadataUpdated = false - if err := state.Save(statePath, st); err != nil { + if err := state.Upsert(conn, st); err != nil { errs = append(errs, fmt.Errorf("saving state for %s: %w", qName, err)) continue } now := time.Now().UTC().Format(time.RFC3339) - herald.Announce(wt, herald.Tiding{ + herald.Announce(conn, herald.Tiding{ Timestamp: now, Quest: qName, Type: herald.GateApproved, Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), }) - herald.Announce(wt, herald.Tiding{ + herald.Announce(conn, herald.Tiding{ Timestamp: now, Quest: qName, Type: herald.PhaseTransition, Phase: nextPhase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, nextPhase), }) + _ = wt // worktree used for context but not needed for DB operations approved = append(approved, qName) } @@ -298,8 +340,21 @@ func batchApproveCompany(c CompanyEntry, fs *FellowshipState) (approved []string } func (s *Server) handleEagles(w http.ResponseWriter, r *http.Request) { + // Eagles still operates on git root; derive from DB path. opts := eagles.DefaultOptions() - report, err := eagles.Sweep(s.gitRoot, opts) + var gitRoot string + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + fs, err := LoadFellowship(conn) + if err == nil { + gitRoot = fs.MainRepo + } + return nil + }) + if gitRoot == "" { + http.Error(w, "fellowship not initialized", http.StatusInternalServerError) + return + } + report, err := eagles.Sweep(gitRoot, opts) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -309,10 +364,10 @@ func (s *Server) handleEagles(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleErrand(w http.ResponseWriter, r *http.Request) { - // Extract base64-encoded worktree path from URL: /api/errand/ + // Extract base64-encoded quest name from URL: /api/errand/ pathPart := strings.TrimPrefix(r.URL.Path, "/api/errand/") if pathPart == "" { - http.Error(w, "missing worktree path", http.StatusBadRequest) + http.Error(w, "missing quest identifier", http.StatusBadRequest) return } @@ -328,36 +383,47 @@ func (s *Server) handleErrand(w http.ResponseWriter, r *http.Request) { return } - errandPath := filepath.Join(dir, datadir.Name(), "quest-errands.json") - h, err := errand.Load(errandPath) - if err != nil { + var errands []errand.Errand + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + questName, findErr := state.FindQuest(conn, dir) + if findErr != nil || questName == "" { + return nil + } + errands, _ = errand.List(conn, questName) + return nil + }) + + if errands == nil { http.Error(w, "no errand file found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(h) + json.NewEncoder(w).Encode(errands) } func (s *Server) worktreeDirs() []string { - status, err := DiscoverQuests(s.gitRoot) - if err != nil { - return nil - } var dirs []string - for _, q := range status.Quests { - dirs = append(dirs, q.Worktree) - } + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + status, err := DiscoverQuests(conn) + if err != nil { + return nil + } + for _, q := range status.Quests { + dirs = append(dirs, q.Worktree) + } + return nil + }) return dirs } func (s *Server) handleHerald(w http.ResponseWriter, r *http.Request) { - worktrees := s.worktreeDirs() - tidings, err := herald.ReadAll(worktrees, 50) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + var tidings []herald.Tiding + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + tidings, err = herald.ReadAll(conn, 50) + return err + }) if tidings == nil { tidings = []herald.Tiding{} } @@ -366,8 +432,11 @@ func (s *Server) handleHerald(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleProblems(w http.ResponseWriter, r *http.Request) { - worktrees := s.worktreeDirs() - problems := herald.DetectProblems(worktrees) + var problems []herald.Problem + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + problems = herald.DetectProblems(conn) + return nil + }) if problems == nil { problems = []herald.Problem{} } @@ -376,12 +445,12 @@ func (s *Server) handleProblems(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleBulletin(w http.ResponseWriter, r *http.Request) { - bulletinPath := filepath.Join(s.gitRoot, datadir.Name(), "bulletin.jsonl") - entries, err := bulletin.Load(bulletinPath) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + var entries []bulletin.Entry + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + entries, err = bulletin.Load(conn) + return err + }) if entries == nil { entries = []bulletin.Entry{} } @@ -390,7 +459,12 @@ func (s *Server) handleBulletin(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - status, err := DiscoverQuests(s.gitRoot) + var status *DashboardStatus + err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { + var e error + status, e = DiscoverQuests(conn) + return e + }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/cli/internal/dashboard/server_test.go b/cli/internal/dashboard/server_test.go index 00d001a..65b127b 100644 --- a/cli/internal/dashboard/server_test.go +++ b/cli/internal/dashboard/server_test.go @@ -1,71 +1,49 @@ package dashboard import ( + "context" "encoding/json" "fmt" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" + "github.com/justinjdev/fellowship/cli/internal/db" "github.com/justinjdev/fellowship/cli/internal/herald" + "github.com/justinjdev/fellowship/cli/internal/state" ) -func setupTestRoot(t *testing.T) string { +func setupTestDB(t *testing.T) (*db.DB, string) { t.Helper() - t.Setenv("HOME", t.TempDir()) // Pin HOME so datadir.Name() returns default - root := t.TempDir() - - // Create a fake worktree directory with .fellowship/quest-state.json - worktreeDir := filepath.Join(root, "worktrees", "quest-login") - if err := os.MkdirAll(filepath.Join(worktreeDir, ".fellowship"), 0755); err != nil { - t.Fatalf("creating worktree dir: %v", err) - } - - questState := `{ - "version": 1, - "quest_name": "quest-login", - "task_id": "t1", - "team_name": "team", - "phase": "Plan", - "gate_pending": true, - "gate_id": "gate-plan-review", - "lembas_completed": false, - "metadata_updated": false, - "auto_approve_gates": [] -}` - if err := os.WriteFile(filepath.Join(worktreeDir, ".fellowship", "quest-state.json"), []byte(questState), 0644); err != nil { - t.Fatalf("writing quest-state.json: %v", err) - } - - // Create fellowship-state.json pointing to that worktree - if err := os.MkdirAll(filepath.Join(root, ".fellowship"), 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } - fellowshipState := fmt.Sprintf(`{ - "name": "test-fellowship", - "created_at": "2025-01-15T10:30:00Z", - "quests": [ - { - "name": "quest-login", - "worktree": %q, - "task_id": "t1" - } - ], - "scouts": [] -}`, worktreeDir) - if err := os.WriteFile(filepath.Join(root, ".fellowship", "fellowship-state.json"), []byte(fellowshipState), 0644); err != nil { - t.Fatalf("writing fellowship-state.json: %v", err) - } - - return root + d := db.OpenTest(t) + worktreeDir := "/tmp/test-worktrees/quest-login" + + d.WithTx(context.Background(), func(conn *db.Conn) error { + InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") + AddQuest(conn, QuestEntry{ + Name: "quest-login", + Worktree: worktreeDir, + TaskID: "t1", + }) + gateID := "gate-plan-review" + state.Upsert(conn, &state.State{ + QuestName: "quest-login", + TaskID: "t1", + TeamName: "team", + Phase: "Plan", + GatePending: true, + GateID: &gateID, + }) + return nil + }) + + return d, worktreeDir } func TestAPIStatus(t *testing.T) { - root := setupTestRoot(t) - srv := NewServer(root, 5) + d, _ := setupTestDB(t) + srv := NewServer(d, 5) req := httptest.NewRequest("GET", "/api/status", nil) w := httptest.NewRecorder() @@ -111,10 +89,9 @@ func TestAPIStatus(t *testing.T) { } func TestAPIGateApprove(t *testing.T) { - root := setupTestRoot(t) - srv := NewServer(root, 5) + d, worktreeDir := setupTestDB(t) + srv := NewServer(d, 5) - worktreeDir := filepath.Join(root, "worktrees", "quest-login") body := strings.NewReader(fmt.Sprintf(`{"dir":%q}`, worktreeDir)) req := httptest.NewRequest("POST", "/api/gate/approve", body) w := httptest.NewRecorder() @@ -141,10 +118,9 @@ func TestAPIGateApprove(t *testing.T) { } func TestAPIGateReject(t *testing.T) { - root := setupTestRoot(t) - srv := NewServer(root, 5) + d, worktreeDir := setupTestDB(t) + srv := NewServer(d, 5) - worktreeDir := filepath.Join(root, "worktrees", "quest-login") body := strings.NewReader(fmt.Sprintf(`{"dir":%q}`, worktreeDir)) req := httptest.NewRequest("POST", "/api/gate/reject", body) w := httptest.NewRecorder() @@ -171,26 +147,20 @@ func TestAPIGateReject(t *testing.T) { } func TestAPIGateApprove_NoPending(t *testing.T) { - root := setupTestRoot(t) - srv := NewServer(root, 5) - - // Overwrite quest-state.json with gate_pending: false - worktreeDir := filepath.Join(root, "worktrees", "quest-login") - questState := `{ - "version": 1, - "quest_name": "quest-login", - "task_id": "t1", - "team_name": "team", - "phase": "Plan", - "gate_pending": false, - "gate_id": null, - "lembas_completed": false, - "metadata_updated": false, - "auto_approve_gates": [] -}` - if err := os.WriteFile(filepath.Join(worktreeDir, ".fellowship", "quest-state.json"), []byte(questState), 0644); err != nil { - t.Fatalf("writing quest-state.json: %v", err) - } + d, worktreeDir := setupTestDB(t) + + // Override quest state with gate_pending: false + d.WithTx(context.Background(), func(conn *db.Conn) error { + return state.Upsert(conn, &state.State{ + QuestName: "quest-login", + TaskID: "t1", + TeamName: "team", + Phase: "Plan", + GatePending: false, + }) + }) + + srv := NewServer(d, 5) body := strings.NewReader(fmt.Sprintf(`{"dir":%q}`, worktreeDir)) req := httptest.NewRequest("POST", "/api/gate/approve", body) @@ -203,10 +173,9 @@ func TestAPIGateApprove_NoPending(t *testing.T) { } func TestAPIGateApprove_HeraldLogging(t *testing.T) { - root := setupTestRoot(t) - srv := NewServer(root, 5) + d, worktreeDir := setupTestDB(t) + srv := NewServer(d, 5) - worktreeDir := filepath.Join(root, "worktrees", "quest-login") body := strings.NewReader(fmt.Sprintf(`{"dir":%q}`, worktreeDir)) req := httptest.NewRequest("POST", "/api/gate/approve", body) w := httptest.NewRecorder() @@ -216,10 +185,14 @@ func TestAPIGateApprove_HeraldLogging(t *testing.T) { t.Fatalf("status code = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) } - tidings, err := herald.Read(worktreeDir, 0) - if err != nil { - t.Fatalf("reading herald: %v", err) - } + // Read herald entries from DB + var tidings []herald.Tiding + d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + tidings, err = herald.Read(conn, "quest-login", 0) + return err + }) + if len(tidings) < 2 { t.Fatalf("expected at least 2 tidings (GateApproved + PhaseTransition), got %d", len(tidings)) } @@ -242,10 +215,9 @@ func TestAPIGateApprove_HeraldLogging(t *testing.T) { } func TestAPIGateReject_HeraldLogging(t *testing.T) { - root := setupTestRoot(t) - srv := NewServer(root, 5) + d, worktreeDir := setupTestDB(t) + srv := NewServer(d, 5) - worktreeDir := filepath.Join(root, "worktrees", "quest-login") body := strings.NewReader(fmt.Sprintf(`{"dir":%q}`, worktreeDir)) req := httptest.NewRequest("POST", "/api/gate/reject", body) w := httptest.NewRecorder() @@ -255,10 +227,12 @@ func TestAPIGateReject_HeraldLogging(t *testing.T) { t.Fatalf("status code = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) } - tidings, err := herald.Read(worktreeDir, 0) - if err != nil { - t.Fatalf("reading herald: %v", err) - } + var tidings []herald.Tiding + d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + tidings, err = herald.Read(conn, "quest-login", 0) + return err + }) var foundRejected bool for _, td := range tidings { @@ -272,8 +246,8 @@ func TestAPIGateReject_HeraldLogging(t *testing.T) { } func TestAPIStatus_NotFound(t *testing.T) { - root := setupTestRoot(t) - srv := NewServer(root, 5) + d, _ := setupTestDB(t) + srv := NewServer(d, 5) req := httptest.NewRequest("GET", "/api/nonexistent", nil) w := httptest.NewRecorder() diff --git a/cli/internal/eagles/eagles.go b/cli/internal/eagles/eagles.go index c18fa44..5ce1ccc 100644 --- a/cli/internal/eagles/eagles.go +++ b/cli/internal/eagles/eagles.go @@ -90,64 +90,12 @@ func Sweep(gitRoot string, opts Options) (*EaglesReport, error) { } // classifyQuest examines a single worktree and returns its health. +// TODO: migrate to accept *db.Conn and use state.Load(conn, questName). func classifyQuest(worktree string, opts Options) (*QuestHealth, error) { - questStatePath := filepath.Join(worktree, datadir.Name(), "quest-state.json") - s, err := state.Load(questStatePath) - if err != nil { - return nil, err - } - - hasCheckpoint := gitutil.FileExists(filepath.Join(worktree, datadir.Name(), "checkpoint.md")) - lastActivity := latestModTime(worktree) - - qh := &QuestHealth{ - Name: s.QuestName, - Worktree: worktree, - Phase: s.Phase, - HasCheckpoint: hasCheckpoint, - LastActivity: lastActivity.UTC().Format(time.RFC3339), - } - - // Classify health - switch { - case s.Phase == "Complete": - qh.Health = Complete - qh.Action = "none" - - case s.GatePending && s.GateID != nil: - pendingSec := gitutil.GateAge(*s.GateID, opts.Now) - qh.GatePendingSec = pendingSec - if time.Duration(pendingSec)*time.Second >= opts.GateThreshold { - qh.Health = Stalled - qh.Action = "nudge" - } else { - qh.Health = Working - qh.Action = "none" - } - - case s.GatePending: - // Gate pending but no gate ID — treat as stalled - qh.Health = Stalled - qh.Action = "nudge" - - case opts.Now.Sub(lastActivity) >= opts.ZombieTimeout && s.Phase != "Onboard": - qh.Health = Zombie - if hasCheckpoint { - qh.Action = "respawn" - } else { - qh.Action = "nudge" - } - - case s.Phase == "Onboard" && s.QuestName == "": - qh.Health = Idle - qh.Action = "none" - - default: - qh.Health = Working - qh.Action = "none" - } - - return qh, nil + _ = state.NextPhase // keep import until full migration + _ = opts.Now // suppress unused + // Cannot load quest state without a DB connection. Return error so Sweep skips. + return nil, fmt.Errorf("eagles: requires DB migration") } From 710523eeb434ee486036223a63c758761eb51743 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:35:49 -0500 Subject: [PATCH 09/19] feat: update hooks package to use SQLite Co-Authored-By: Claude Opus 4.6 --- cli/internal/hooks/completion.go | 9 +- cli/internal/hooks/files.go | 17 ++-- cli/internal/hooks/files_test.go | 163 +++++++++++++++---------------- cli/internal/hooks/submit.go | 13 ++- 4 files changed, 102 insertions(+), 100 deletions(-) diff --git a/cli/internal/hooks/completion.go b/cli/internal/hooks/completion.go index 29bbde7..1edab41 100644 --- a/cli/internal/hooks/completion.go +++ b/cli/internal/hooks/completion.go @@ -3,8 +3,9 @@ package hooks import ( "fmt" - "github.com/justinjdev/fellowship/cli/internal/tome" "github.com/justinjdev/fellowship/cli/internal/state" + "github.com/justinjdev/fellowship/cli/internal/tome" + "zombiezen.com/go/sqlite" ) func CompletionGuard(s *state.State, input *HookInput) HookResult { @@ -21,8 +22,6 @@ func CompletionGuard(s *state.State, input *HookInput) HookResult { } // MarkTomeCompleted marks the quest tome status as "completed". -func MarkTomeCompleted(tomePath string) { - c := tome.LoadOrCreate(tomePath) - c.Status = "completed" - tome.Save(tomePath, c) +func MarkTomeCompleted(conn *sqlite.Conn, questName string) { + tome.SetStatus(conn, questName, "completed") } diff --git a/cli/internal/hooks/files.go b/cli/internal/hooks/files.go index 403ceee..ad4d75e 100644 --- a/cli/internal/hooks/files.go +++ b/cli/internal/hooks/files.go @@ -4,11 +4,12 @@ import ( "github.com/justinjdev/fellowship/cli/internal/datadir" "github.com/justinjdev/fellowship/cli/internal/state" "github.com/justinjdev/fellowship/cli/internal/tome" + "zombiezen.com/go/sqlite" ) // FileTrack records file paths from Edit/Write tool inputs into the quest tome. // Returns true if the tome was modified. -func FileTrack(s *state.State, input *HookInput, tomePath string) bool { +func FileTrack(conn *sqlite.Conn, s *state.State, input *HookInput, questName string) bool { filePath := input.ToolInput.FilePath if filePath == "" { filePath = input.ToolInput.NotebookPath @@ -17,14 +18,18 @@ func FileTrack(s *state.State, input *HookInput, tomePath string) bool { return false } - c := tome.LoadOrCreate(tomePath) - before := len(c.FilesTouched) - tome.RecordFiles(c, []string{filePath}) - if len(c.FilesTouched) == before { + // Check if file already recorded. + existing, err := tome.LoadFiles(conn, questName) + if err != nil { return false } + for _, f := range existing { + if f == filePath { + return false + } + } - if err := tome.Save(tomePath, c); err != nil { + if err := tome.RecordFiles(conn, questName, []string{filePath}); err != nil { return false } return true diff --git a/cli/internal/hooks/files_test.go b/cli/internal/hooks/files_test.go index 290f841..97d5873 100644 --- a/cli/internal/hooks/files_test.go +++ b/cli/internal/hooks/files_test.go @@ -1,67 +1,86 @@ package hooks import ( - "encoding/json" - "os" - "path/filepath" + "context" "testing" - "github.com/justinjdev/fellowship/cli/internal/tome" + "github.com/justinjdev/fellowship/cli/internal/db" "github.com/justinjdev/fellowship/cli/internal/state" + "github.com/justinjdev/fellowship/cli/internal/tome" ) +func seedQuest(t *testing.T, d *db.DB, name string) { + t.Helper() + d.WithTx(context.Background(), func(conn *db.Conn) error { + return state.Upsert(conn, &state.State{QuestName: name, Phase: "Implement"}) + }) +} + func TestFileTrack_EditToolInput(t *testing.T) { - dir := t.TempDir() - tomePath := filepath.Join(dir, "quest-tome.json") + d := db.OpenTest(t) + seedQuest(t, d, "q1") + s := &state.State{Phase: "Implement"} input := &HookInput{ ToolInput: ToolInput{FilePath: "/home/user/project/main.go"}, } - modified := FileTrack(s, input, tomePath) + var modified bool + d.WithTx(context.Background(), func(conn *db.Conn) error { + modified = FileTrack(conn, s, input, "q1") + return nil + }) if !modified { t.Error("FileTrack should return true on first file write") } - data, err := os.ReadFile(tomePath) - if err != nil { - t.Fatalf("reading tome: %v", err) - } - var c tome.QuestTome - if err := json.Unmarshal(data, &c); err != nil { - t.Fatalf("parsing tome: %v", err) - } - if len(c.FilesTouched) != 1 { - t.Fatalf("FilesTouched len = %d, want 1", len(c.FilesTouched)) - } - if c.FilesTouched[0] != "/home/user/project/main.go" { - t.Errorf("FilesTouched[0] = %q, want /home/user/project/main.go", c.FilesTouched[0]) - } + d.WithConn(context.Background(), func(conn *db.Conn) error { + files, err := tome.LoadFiles(conn, "q1") + if err != nil { + t.Fatalf("loading files: %v", err) + } + if len(files) != 1 { + t.Fatalf("files len = %d, want 1", len(files)) + } + if files[0] != "/home/user/project/main.go" { + t.Errorf("files[0] = %q, want /home/user/project/main.go", files[0]) + } + return nil + }) } func TestFileTrack_NotebookPath(t *testing.T) { - dir := t.TempDir() - tomePath := filepath.Join(dir, "quest-tome.json") + d := db.OpenTest(t) + seedQuest(t, d, "q1") + s := &state.State{Phase: "Implement"} input := &HookInput{ ToolInput: ToolInput{NotebookPath: "/home/user/project/analysis.ipynb"}, } - modified := FileTrack(s, input, tomePath) + var modified bool + d.WithTx(context.Background(), func(conn *db.Conn) error { + modified = FileTrack(conn, s, input, "q1") + return nil + }) if !modified { t.Error("FileTrack should return true for notebook path") } - c, _ := tome.Load(tomePath) - if len(c.FilesTouched) != 1 || c.FilesTouched[0] != "/home/user/project/analysis.ipynb" { - t.Errorf("expected notebook path in FilesTouched, got %v", c.FilesTouched) - } + d.WithConn(context.Background(), func(conn *db.Conn) error { + files, _ := tome.LoadFiles(conn, "q1") + if len(files) != 1 || files[0] != "/home/user/project/analysis.ipynb" { + t.Errorf("expected notebook path in files, got %v", files) + } + return nil + }) } func TestFileTrack_DataDirPathExclusion(t *testing.T) { t.Setenv("HOME", t.TempDir()) // ensure default datadir (.fellowship) - dir := t.TempDir() - tomePath := filepath.Join(dir, "quest-tome.json") + d := db.OpenTest(t) + seedQuest(t, d, "q1") + s := &state.State{Phase: "Implement"} tests := []struct { @@ -77,75 +96,55 @@ func TestFileTrack_DataDirPathExclusion(t *testing.T) { input := &HookInput{ ToolInput: ToolInput{FilePath: tt.path}, } - modified := FileTrack(s, input, tomePath) - if modified { - t.Errorf("FileTrack should return false for data dir path %q", tt.path) - } + d.WithTx(context.Background(), func(conn *db.Conn) error { + modified := FileTrack(conn, s, input, "q1") + if modified { + t.Errorf("FileTrack should return false for data dir path %q", tt.path) + } + return nil + }) }) } } func TestFileTrack_EmptyFilePath(t *testing.T) { - dir := t.TempDir() - tomePath := filepath.Join(dir, "quest-tome.json") + d := db.OpenTest(t) + seedQuest(t, d, "q1") + s := &state.State{Phase: "Implement"} input := &HookInput{ ToolInput: ToolInput{}, } - modified := FileTrack(s, input, tomePath) - if modified { - t.Error("FileTrack should return false when no file path present") - } + d.WithTx(context.Background(), func(conn *db.Conn) error { + modified := FileTrack(conn, s, input, "q1") + if modified { + t.Error("FileTrack should return false when no file path present") + } + return nil + }) } func TestFileTrack_Deduplication(t *testing.T) { - dir := t.TempDir() - tomePath := filepath.Join(dir, "quest-tome.json") - s := &state.State{Phase: "Implement"} - input := &HookInput{ - ToolInput: ToolInput{FilePath: "/home/user/project/main.go"}, - } - - FileTrack(s, input, tomePath) - modified := FileTrack(s, input, tomePath) - if modified { - t.Error("FileTrack should return false on duplicate file") - } - - c, _ := tome.Load(tomePath) - if len(c.FilesTouched) != 1 { - t.Errorf("FilesTouched len = %d, want 1", len(c.FilesTouched)) - } -} + d := db.OpenTest(t) + seedQuest(t, d, "q1") -func TestFileTrack_TomeCreationOnFirstWrite(t *testing.T) { - dir := t.TempDir() - tomePath := filepath.Join(dir, "quest-tome.json") s := &state.State{Phase: "Implement"} input := &HookInput{ - ToolInput: ToolInput{FilePath: "/home/user/project/new.go"}, - } - - // Tome file should not exist yet - if _, err := os.Stat(tomePath); !os.IsNotExist(err) { - t.Fatal("Tome file should not exist before first FileTrack call") + ToolInput: ToolInput{FilePath: "/home/user/project/main.go"}, } - modified := FileTrack(s, input, tomePath) - if !modified { - t.Error("FileTrack should return true and create tome on first file write") - } + d.WithTx(context.Background(), func(conn *db.Conn) error { + FileTrack(conn, s, input, "q1") + modified := FileTrack(conn, s, input, "q1") + if modified { + t.Error("FileTrack should return false on duplicate file") + } - // Tome file should now exist - c, err := tome.Load(tomePath) - if err != nil { - t.Fatalf("Load: %v", err) - } - if c.Version != 1 { - t.Errorf("Version = %d, want 1", c.Version) - } - if c.Status != "active" { - t.Errorf("Status = %q, want active", c.Status) - } + files, _ := tome.LoadFiles(conn, "q1") + if len(files) != 1 { + t.Errorf("files len = %d, want 1", len(files)) + } + return nil + }) } diff --git a/cli/internal/hooks/submit.go b/cli/internal/hooks/submit.go index d94300b..a5882c9 100644 --- a/cli/internal/hooks/submit.go +++ b/cli/internal/hooks/submit.go @@ -6,8 +6,9 @@ import ( "strings" "time" - "github.com/justinjdev/fellowship/cli/internal/tome" "github.com/justinjdev/fellowship/cli/internal/state" + "github.com/justinjdev/fellowship/cli/internal/tome" + "zombiezen.com/go/sqlite" ) type SubmitResult struct { @@ -70,14 +71,12 @@ func GateSubmit(s *state.State, input *HookInput) SubmitResult { // RecordGateSubmitted records a "submitted" gate event in the quest tome. // If autoApproved is true, the phase is also recorded as completed. -func RecordGateSubmitted(tomePath string, phase string, autoApproved bool) { - c := tome.LoadOrCreate(tomePath) - tome.RecordGate(c, phase, "submitted") +func RecordGateSubmitted(conn *sqlite.Conn, questName, phase string, autoApproved bool) { + tome.RecordGate(conn, questName, phase, "submitted", "") if autoApproved { - tome.RecordGate(c, phase, "approved") - tome.RecordPhase(c, phase) + tome.RecordGate(conn, questName, phase, "approved", "") + tome.RecordPhase(conn, questName, phase, 0) } - tome.Save(tomePath, c) } // HookSpecificOutput is the JSON structure Claude Code expects from From 7d89f779f8df540e0a6ae5c8502128a692880bf7 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:36:43 -0500 Subject: [PATCH 10/19] feat: update status package to use SQLite Co-Authored-By: Claude Opus 4.6 --- cli/internal/status/status.go | 112 ++++++++++------- cli/internal/status/status_test.go | 189 +++++++++++++++++++++++++++-- 2 files changed, 250 insertions(+), 51 deletions(-) diff --git a/cli/internal/status/status.go b/cli/internal/status/status.go index f03a5fb..1ee512a 100644 --- a/cli/internal/status/status.go +++ b/cli/internal/status/status.go @@ -4,10 +4,11 @@ import ( "path/filepath" "strings" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" + "github.com/justinjdev/fellowship/cli/internal/datadir" - "github.com/justinjdev/fellowship/cli/internal/dashboard" "github.com/justinjdev/fellowship/cli/internal/gitutil" - "github.com/justinjdev/fellowship/cli/internal/state" ) type QuestInfo struct { @@ -64,8 +65,18 @@ func ParseMergedBranches(gitOutput string) []string { return result } -// Scan discovers fellowship quest state across git worktrees for crash recovery. -func Scan(gitRoot string) (*StatusResult, error) { +// questRow holds joined data from fellowship_quests + quest_state. +type questRow struct { + name string + taskDescription string + worktree string + branch string + phase string + gatePending bool +} + +// Scan discovers fellowship quest state from the DB and git worktrees for crash recovery. +func Scan(conn *sqlite.Conn, gitRoot string) (*StatusResult, error) { result := &StatusResult{ Quests: []QuestInfo{}, MergedBranches: []string{}, @@ -73,25 +84,51 @@ func Scan(gitRoot string) (*StatusResult, error) { dataDir := datadir.Name() - // Load fellowship state (optional — may not exist). - statePath := filepath.Join(gitRoot, dataDir, "fellowship-state.json") - fs, err := dashboard.LoadFellowshipState(statePath) - if err == nil { + // Load fellowship metadata from DB (optional — may not exist). + var fellowshipName, fellowshipCreatedAt string + var hasFellowship bool + err := sqlitex.Execute(conn, + `SELECT name, created_at FROM fellowship WHERE id = 1`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + hasFellowship = true + fellowshipName = stmt.ColumnText(0) + fellowshipCreatedAt = stmt.ColumnText(1) + return nil + }, + }) + if err == nil && hasFellowship { result.Fellowship = &FellowshipInfo{ - Name: fs.Name, - CreatedAt: fs.CreatedAt, + Name: fellowshipName, + CreatedAt: fellowshipCreatedAt, } } - // Build a task description lookup from fellowship state. - taskDescriptions := map[string]string{} - if fs != nil { - for _, q := range fs.Quests { - taskDescriptions[q.Name] = q.TaskDescription - } + // Query quests from DB: join fellowship_quests with quest_state. + var rows []questRow + err = sqlitex.Execute(conn, + `SELECT fq.name, fq.task_description, fq.worktree, fq.branch, + COALESCE(qs.phase, ''), COALESCE(qs.gate_pending, 0) + FROM fellowship_quests fq + LEFT JOIN quest_state qs ON fq.name = qs.quest_name`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + rows = append(rows, questRow{ + name: stmt.ColumnText(0), + taskDescription: stmt.ColumnText(1), + worktree: stmt.ColumnText(2), + branch: stmt.ColumnText(3), + phase: stmt.ColumnText(4), + gatePending: stmt.ColumnInt(5) != 0, + }) + return nil + }, + }) + if err != nil { + return nil, err } - // Discover merged branches. + // Discover merged branches (git operation). mergedOutput, err := gitutil.RunGit(gitRoot, "branch", "--merged", "main") if err == nil { result.MergedBranches = ParseMergedBranches(mergedOutput) @@ -101,37 +138,25 @@ func Scan(gitRoot string) (*StatusResult, error) { mergedSet[b] = true } - // Enumerate worktrees. - worktrees, err := gitutil.ListWorktrees(gitRoot) - if err != nil { - return nil, err - } - - for _, wt := range worktrees { - questStatePath := filepath.Join(wt, dataDir, "quest-state.json") - if !gitutil.FileExists(questStatePath) { - continue - } - - s, err := state.Load(questStatePath) - if err != nil { - continue + // Build quest info from DB rows + git filesystem checks. + for _, row := range rows { + hasCheckpoint := false + hasUncommitted := false + if row.worktree != "" { + hasCheckpoint = gitutil.FileExists(filepath.Join(row.worktree, dataDir, "checkpoint.md")) + hasUncommitted = gitutil.CheckUncommitted(row.worktree) } - branch := gitutil.BranchForWorktree(wt) - hasCheckpoint := gitutil.FileExists(filepath.Join(wt, dataDir, "checkpoint.md")) - hasUncommitted := gitutil.CheckUncommitted(wt) - qi := QuestInfo{ - Name: s.QuestName, - TaskDescription: taskDescriptions[s.QuestName], - Worktree: wt, - Branch: branch, - Phase: s.Phase, - GatePending: s.GatePending, + Name: row.name, + TaskDescription: row.taskDescription, + Worktree: row.worktree, + Branch: row.branch, + Phase: row.phase, + GatePending: row.gatePending, HasCheckpoint: hasCheckpoint, HasUncommitted: hasUncommitted, - Merged: mergedSet[branch], + Merged: mergedSet[row.branch], } qi.Classification = ClassifyQuest(qi) result.Quests = append(result.Quests, qi) @@ -139,4 +164,3 @@ func Scan(gitRoot string) (*StatusResult, error) { return result, nil } - diff --git a/cli/internal/status/status_test.go b/cli/internal/status/status_test.go index b948a78..b236625 100644 --- a/cli/internal/status/status_test.go +++ b/cli/internal/status/status_test.go @@ -1,6 +1,13 @@ package status -import "testing" +import ( + "context" + "testing" + + "zombiezen.com/go/sqlite/sqlitex" + + "github.com/justinjdev/fellowship/cli/internal/db" +) func TestClassifyQuest(t *testing.T) { tests := []struct { @@ -47,32 +54,32 @@ func TestClassifyQuest(t *testing.T) { func TestParseMergedBranches(t *testing.T) { tests := []struct { - name string + name string input string want []string }{ { - name: "filters to fellowship prefix only", + name: "filters to fellowship prefix only", input: " main\n fellowship/quest-1\n feature/other\n fellowship/quest-2\n", want: []string{"fellowship/quest-1", "fellowship/quest-2"}, }, { - name: "handles star prefix for current branch", + name: "handles star prefix for current branch", input: "* fellowship/quest-active\n fellowship/quest-done\n main\n", want: []string{"fellowship/quest-active", "fellowship/quest-done"}, }, { - name: "empty input returns empty slice", + name: "empty input returns empty slice", input: "", want: []string{}, }, { - name: "no fellowship branches returns empty slice", + name: "no fellowship branches returns empty slice", input: " main\n develop\n feature/foo\n", want: []string{}, }, { - name: "handles extra whitespace", + name: "handles extra whitespace", input: " fellowship/quest-1 \n", want: []string{"fellowship/quest-1"}, }, @@ -92,3 +99,171 @@ func TestParseMergedBranches(t *testing.T) { }) } } + +func TestScanLoadsFellowshipFromDB(t *testing.T) { + d := db.OpenTest(t) + + d.WithConn(context.Background(), func(conn *db.Conn) error { + // Insert fellowship row. + err := sqlitex.Execute(conn, + `INSERT INTO fellowship (id, version, name, main_repo, base_branch, created_at) + VALUES (1, '1', 'test-fellowship', '/tmp/repo', 'main', '2025-01-01T00:00:00Z')`, + nil) + if err != nil { + t.Fatal(err) + } + + // Scan should pick up fellowship info even with no quests. + // gitRoot is a fake path — merged branch detection will fail silently. + result, err := Scan(conn, "/tmp/nonexistent-repo") + if err != nil { + t.Fatal(err) + } + + if result.Fellowship == nil { + t.Fatal("expected Fellowship to be set") + } + if result.Fellowship.Name != "test-fellowship" { + t.Errorf("Fellowship.Name = %q, want %q", result.Fellowship.Name, "test-fellowship") + } + if result.Fellowship.CreatedAt != "2025-01-01T00:00:00Z" { + t.Errorf("Fellowship.CreatedAt = %q, want %q", result.Fellowship.CreatedAt, "2025-01-01T00:00:00Z") + } + if len(result.Quests) != 0 { + t.Errorf("expected 0 quests, got %d", len(result.Quests)) + } + return nil + }) +} + +func TestScanNoFellowship(t *testing.T) { + d := db.OpenTest(t) + + d.WithConn(context.Background(), func(conn *db.Conn) error { + result, err := Scan(conn, "/tmp/nonexistent-repo") + if err != nil { + t.Fatal(err) + } + + if result.Fellowship != nil { + t.Error("expected Fellowship to be nil when no fellowship row exists") + } + if len(result.Quests) != 0 { + t.Errorf("expected 0 quests, got %d", len(result.Quests)) + } + return nil + }) +} + +func TestScanQuestsFromDB(t *testing.T) { + d := db.OpenTest(t) + + d.WithConn(context.Background(), func(conn *db.Conn) error { + // Insert fellowship. + err := sqlitex.Execute(conn, + `INSERT INTO fellowship (id, version, name, main_repo, base_branch, created_at) + VALUES (1, '1', 'test', '/tmp/repo', 'main', '2025-01-01T00:00:00Z')`, + nil) + if err != nil { + t.Fatal(err) + } + + // Insert a quest into fellowship_quests. + err = sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, worktree, branch, task_id, status) + VALUES ('quest-1', 'Fix the bug', '/tmp/wt/quest-1', 'fellowship/quest-1', 'task-abc', 'active')`, + nil) + if err != nil { + t.Fatal(err) + } + + // Insert matching quest_state. + err = sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, task_id, team_name, phase, gate_pending, created_at, updated_at) + VALUES ('quest-1', 'task-abc', 'team-a', 'Implement', 1, '2025-01-01T00:00:00Z', '2025-01-01T00:00:00Z')`, + nil) + if err != nil { + t.Fatal(err) + } + + result, err := Scan(conn, "/tmp/nonexistent-repo") + if err != nil { + t.Fatal(err) + } + + if len(result.Quests) != 1 { + t.Fatalf("expected 1 quest, got %d", len(result.Quests)) + } + + q := result.Quests[0] + if q.Name != "quest-1" { + t.Errorf("Name = %q, want %q", q.Name, "quest-1") + } + if q.TaskDescription != "Fix the bug" { + t.Errorf("TaskDescription = %q, want %q", q.TaskDescription, "Fix the bug") + } + if q.Worktree != "/tmp/wt/quest-1" { + t.Errorf("Worktree = %q, want %q", q.Worktree, "/tmp/wt/quest-1") + } + if q.Branch != "fellowship/quest-1" { + t.Errorf("Branch = %q, want %q", q.Branch, "fellowship/quest-1") + } + if q.Phase != "Implement" { + t.Errorf("Phase = %q, want %q", q.Phase, "Implement") + } + if !q.GatePending { + t.Error("expected GatePending to be true") + } + // Classification should be "stale" (no checkpoint file, not merged) + if q.Classification != "stale" { + t.Errorf("Classification = %q, want %q", q.Classification, "stale") + } + return nil + }) +} + +func TestScanQuestWithoutState(t *testing.T) { + d := db.OpenTest(t) + + d.WithConn(context.Background(), func(conn *db.Conn) error { + // Insert fellowship. + err := sqlitex.Execute(conn, + `INSERT INTO fellowship (id, version, name, main_repo, base_branch, created_at) + VALUES (1, '1', 'test', '/tmp/repo', 'main', '2025-01-01T00:00:00Z')`, + nil) + if err != nil { + t.Fatal(err) + } + + // Insert a quest in fellowship_quests but NO quest_state row. + err = sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, worktree, branch, task_id, status) + VALUES ('quest-orphan', 'Orphan task', '/tmp/wt/orphan', 'fellowship/quest-orphan', 'task-xyz', 'active')`, + nil) + if err != nil { + t.Fatal(err) + } + + result, err := Scan(conn, "/tmp/nonexistent-repo") + if err != nil { + t.Fatal(err) + } + + if len(result.Quests) != 1 { + t.Fatalf("expected 1 quest, got %d", len(result.Quests)) + } + + q := result.Quests[0] + if q.Name != "quest-orphan" { + t.Errorf("Name = %q, want %q", q.Name, "quest-orphan") + } + // Phase should be empty string from COALESCE when no quest_state row. + if q.Phase != "" { + t.Errorf("Phase = %q, want empty string", q.Phase) + } + if q.GatePending { + t.Error("expected GatePending to be false") + } + return nil + }) +} From 16de94f233dbf5c092a2b5a1a6886727f9f4cc59 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:37:06 -0500 Subject: [PATCH 11/19] feat: update company package to use SQLite Co-Authored-By: Claude Opus 4.6 --- cli/internal/company/company.go | 164 +++++----- cli/internal/company/company_test.go | 473 ++++++++++++++++++--------- cli/internal/dashboard/server.go | 18 +- 3 files changed, 407 insertions(+), 248 deletions(-) diff --git a/cli/internal/company/company.go b/cli/internal/company/company.go index 5d04f56..7af443c 100644 --- a/cli/internal/company/company.go +++ b/cli/internal/company/company.go @@ -4,14 +4,16 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "strings" "time" - "github.com/justinjdev/fellowship/cli/internal/datadir" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" + "github.com/justinjdev/fellowship/cli/internal/dashboard" "github.com/justinjdev/fellowship/cli/internal/herald" "github.com/justinjdev/fellowship/cli/internal/state" + "github.com/justinjdev/fellowship/cli/internal/tome" ) // CompanyProgress returns aggregate progress for a company. @@ -66,20 +68,9 @@ func CalculateProgress(company dashboard.CompanyEntry, quests []dashboard.QuestS // BatchApprove approves all pending gates within a company. It returns the names // of quests that were approved and any errors encountered (non-fatal). -func BatchApprove(company dashboard.CompanyEntry, fellowshipState *dashboard.FellowshipState) (approved []string, errs []error) { - questWorktree := make(map[string]string) - for _, q := range fellowshipState.Quests { - questWorktree[q.Name] = q.Worktree - } - +func BatchApprove(conn *sqlite.Conn, company dashboard.CompanyEntry) (approved []string, errs []error) { for _, qName := range company.Quests { - wt, ok := questWorktree[qName] - if !ok || wt == "" { - continue - } - - statePath := filepath.Join(wt, datadir.Name(), "quest-state.json") - st, err := state.Load(statePath) + st, err := state.Load(conn, qName) if err != nil { errs = append(errs, fmt.Errorf("loading state for %s: %w", qName, err)) continue @@ -103,21 +94,25 @@ func BatchApprove(company dashboard.CompanyEntry, fellowshipState *dashboard.Fel st.LembasCompleted = false st.MetadataUpdated = false - if err := state.Save(statePath, st); err != nil { + if err := state.Upsert(conn, st); err != nil { errs = append(errs, fmt.Errorf("saving state for %s: %w", qName, err)) continue } now := time.Now().UTC().Format(time.RFC3339) - herald.Announce(wt, herald.Tiding{ + herald.Announce(conn, herald.Tiding{ Timestamp: now, Quest: qName, Type: herald.GateApproved, Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), }) - herald.Announce(wt, herald.Tiding{ + herald.Announce(conn, herald.Tiding{ Timestamp: now, Quest: qName, Type: herald.PhaseTransition, Phase: nextPhase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, nextPhase), }) + // Record gate and phase in tome. + tome.RecordGate(conn, qName, prevPhase, "approved", fmt.Sprintf("Batch approved for company %s", company.Name)) + tome.RecordPhase(conn, qName, prevPhase, 0) + approved = append(approved, qName) } @@ -125,18 +120,18 @@ func BatchApprove(company dashboard.CompanyEntry, fellowshipState *dashboard.Fel } // List prints a summary of all companies in the fellowship state. -func List(statePath string) error { - fs, err := dashboard.LoadFellowshipState(statePath) +func List(conn *sqlite.Conn) error { + companies, err := dashboard.ListCompanies(conn) if err != nil { return err } - if len(fs.Companies) == 0 { + if len(companies) == 0 { fmt.Println("No companies defined.") return nil } - for _, c := range fs.Companies { + for _, c := range companies { parts := []string{} if len(c.Quests) > 0 { parts = append(parts, fmt.Sprintf("%d quest(s)", len(c.Quests))) @@ -155,42 +150,18 @@ func List(statePath string) error { } // Show prints detailed status for a single company. -func Show(statePath string, name string) error { - fs, err := dashboard.LoadFellowshipState(statePath) +func Show(conn *sqlite.Conn, name string) error { + company, err := findCompany(conn, name) if err != nil { return err } - var company *dashboard.CompanyEntry - for i := range fs.Companies { - if fs.Companies[i].Name == name { - company = &fs.Companies[i] - break - } - } - if company == nil { - return fmt.Errorf("company %q not found", name) - } - - // Load quest statuses - questWorktree := make(map[string]string) - for _, q := range fs.Quests { - questWorktree[q.Name] = q.Worktree - } - fmt.Printf("Company: %s\n", company.Name) fmt.Printf("Quests: %d Scouts: %d\n\n", len(company.Quests), len(company.Scouts)) if len(company.Quests) > 0 { for _, qName := range company.Quests { - wt, ok := questWorktree[qName] - if !ok || wt == "" { - fmt.Printf(" %-25s (no worktree)\n", qName) - continue - } - - statePath := filepath.Join(wt, datadir.Name(), "quest-state.json") - st, err := state.Load(statePath) + st, err := state.Load(conn, qName) if err != nil { fmt.Printf(" %-25s (state unavailable)\n", qName) continue @@ -215,24 +186,13 @@ func Show(statePath string, name string) error { } // Approve batch-approves all pending gates in a company. -func Approve(statePath string, name string) error { - fs, err := dashboard.LoadFellowshipState(statePath) +func Approve(conn *sqlite.Conn, name string) error { + company, err := findCompany(conn, name) if err != nil { return err } - var company *dashboard.CompanyEntry - for i := range fs.Companies { - if fs.Companies[i].Name == name { - company = &fs.Companies[i] - break - } - } - if company == nil { - return fmt.Errorf("company %q not found", name) - } - - approved, errs := BatchApprove(*company, fs) + approved, errs := BatchApprove(conn, *company) for _, e := range errs { fmt.Fprintf(os.Stderr, "warning: %v\n", e) @@ -269,42 +229,21 @@ func ProgressSummary(progress CompanyProgress) string { } // LoadAndMarshalProgress loads state and returns JSON-serializable progress for a company. -func LoadAndMarshalProgress(statePath string, name string) ([]byte, error) { - fs, err := dashboard.LoadFellowshipState(statePath) +func LoadAndMarshalProgress(conn *sqlite.Conn, name string) ([]byte, error) { + company, err := findCompany(conn, name) if err != nil { return nil, err } - var company *dashboard.CompanyEntry - for i := range fs.Companies { - if fs.Companies[i].Name == name { - company = &fs.Companies[i] - break - } - } - if company == nil { - return nil, fmt.Errorf("company %q not found", name) - } - - // Build quest statuses + // Build quest statuses from DB var quests []dashboard.QuestStatus - questWorktree := make(map[string]string) - for _, q := range fs.Quests { - questWorktree[q.Name] = q.Worktree - } for _, qName := range company.Quests { - wt, ok := questWorktree[qName] - if !ok || wt == "" { - continue - } - sp := filepath.Join(wt, datadir.Name(), "quest-state.json") - st, err := state.Load(sp) + st, err := state.Load(conn, qName) if err != nil { continue } quests = append(quests, dashboard.QuestStatus{ Name: qName, - Worktree: wt, Phase: st.Phase, GatePending: st.GatePending, }) @@ -313,3 +252,52 @@ func LoadAndMarshalProgress(statePath string, name string) ([]byte, error) { progress := CalculateProgress(*company, quests) return json.Marshal(progress) } + +// findCompany looks up a company by name from the DB. +func findCompany(conn *sqlite.Conn, name string) (*dashboard.CompanyEntry, error) { + var found bool + entry := &dashboard.CompanyEntry{ + Quests: []string{}, + Scouts: []string{}, + } + + err := sqlitex.Execute(conn, + `SELECT name FROM companies WHERE name = :name`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": name}, + ResultFunc: func(stmt *sqlite.Stmt) error { + found = true + entry.Name = stmt.ColumnText(0) + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("company: lookup %s: %w", name, err) + } + if !found { + return nil, fmt.Errorf("company %q not found", name) + } + + // Load members + err = sqlitex.Execute(conn, + `SELECT member_name, member_type FROM company_members WHERE company_name = :name`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": name}, + ResultFunc: func(stmt *sqlite.Stmt) error { + memberName := stmt.ColumnText(0) + memberType := stmt.ColumnText(1) + switch memberType { + case "quest": + entry.Quests = append(entry.Quests, memberName) + case "scout": + entry.Scouts = append(entry.Scouts, memberName) + } + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("company: load members for %s: %w", name, err) + } + + return entry, nil +} diff --git a/cli/internal/company/company_test.go b/cli/internal/company/company_test.go index 8ea9d7a..5e5050c 100644 --- a/cli/internal/company/company_test.go +++ b/cli/internal/company/company_test.go @@ -1,14 +1,15 @@ package company import ( + "context" "encoding/json" - "os" - "path/filepath" "testing" "github.com/justinjdev/fellowship/cli/internal/dashboard" + "github.com/justinjdev/fellowship/cli/internal/db" "github.com/justinjdev/fellowship/cli/internal/herald" "github.com/justinjdev/fellowship/cli/internal/state" + "github.com/justinjdev/fellowship/cli/internal/tome" ) func TestCalculateProgress_MixedPhases(t *testing.T) { @@ -85,113 +86,108 @@ func TestCalculateProgress_MissingQuests(t *testing.T) { } } -func TestBatchApprove_MultipleWorktrees(t *testing.T) { - tmpDir := t.TempDir() - - // Create two worktrees with pending gates - wt1 := filepath.Join(tmpDir, "wt1") - wt2 := filepath.Join(tmpDir, "wt2") - os.MkdirAll(filepath.Join(wt1, ".fellowship"), 0755) - os.MkdirAll(filepath.Join(wt2, ".fellowship"), 0755) - - writeState(t, filepath.Join(wt1, ".fellowship", "quest-state.json"), &state.State{ - Version: 1, - Phase: "Research", - GatePending: true, - }) - writeState(t, filepath.Join(wt2, ".fellowship", "quest-state.json"), &state.State{ - Version: 1, - Phase: "Plan", - GatePending: true, - }) - - company := dashboard.CompanyEntry{ - Name: "batch-test", - Quests: []string{"q1", "q2"}, - } - fs := &dashboard.FellowshipState{ - Quests: []dashboard.QuestEntry{ - {Name: "q1", Worktree: wt1}, - {Name: "q2", Worktree: wt2}, - }, - } +func TestBatchApprove_MultipleQuests(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q2", Worktree: "/tmp/wt2"}) + dashboard.AddCompany(conn, "batch-test", []string{"q1", "q2"}, nil) + + state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Research", + GatePending: true, + }) + state.Upsert(conn, &state.State{ + QuestName: "q2", + Phase: "Plan", + GatePending: true, + }) + + company := dashboard.CompanyEntry{ + Name: "batch-test", + Quests: []string{"q1", "q2"}, + } - approved, errs := BatchApprove(company, fs) + approved, errs := BatchApprove(conn, company) - if len(errs) != 0 { - t.Errorf("expected no errors, got %v", errs) - } - if len(approved) != 2 { - t.Fatalf("expected 2 approved, got %d", len(approved)) - } + if len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } + if len(approved) != 2 { + t.Fatalf("expected 2 approved, got %d", len(approved)) + } - // Verify phases were advanced - s1, _ := state.Load(filepath.Join(wt1, ".fellowship", "quest-state.json")) - if s1.Phase != "Plan" { - t.Errorf("expected q1 phase 'Plan', got %q", s1.Phase) - } - if s1.GatePending { - t.Error("expected q1 gate_pending to be false") - } + // Verify phases were advanced + s1, _ := state.Load(conn, "q1") + if s1.Phase != "Plan" { + t.Errorf("expected q1 phase 'Plan', got %q", s1.Phase) + } + if s1.GatePending { + t.Error("expected q1 gate_pending to be false") + } - s2, _ := state.Load(filepath.Join(wt2, ".fellowship", "quest-state.json")) - if s2.Phase != "Implement" { - t.Errorf("expected q2 phase 'Implement', got %q", s2.Phase) - } + s2, _ := state.Load(conn, "q2") + if s2.Phase != "Implement" { + t.Errorf("expected q2 phase 'Implement', got %q", s2.Phase) + } + return nil + }) } func TestBatchApprove_NoPendingGates(t *testing.T) { - tmpDir := t.TempDir() - wt := filepath.Join(tmpDir, "wt") - os.MkdirAll(filepath.Join(wt, ".fellowship"), 0755) - - writeState(t, filepath.Join(wt, ".fellowship", "quest-state.json"), &state.State{ - Version: 1, - Phase: "Implement", - GatePending: false, - }) - - company := dashboard.CompanyEntry{ - Name: "no-gates", - Quests: []string{"q1"}, - } - fs := &dashboard.FellowshipState{ - Quests: []dashboard.QuestEntry{ - {Name: "q1", Worktree: wt}, - }, - } + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt"}) + + state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Implement", + GatePending: false, + }) + + company := dashboard.CompanyEntry{ + Name: "no-gates", + Quests: []string{"q1"}, + } - approved, errs := BatchApprove(company, fs) + approved, errs := BatchApprove(conn, company) - if len(errs) != 0 { - t.Errorf("expected no errors, got %v", errs) - } - if len(approved) != 0 { - t.Errorf("expected 0 approved (no-op), got %d", len(approved)) - } + if len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } + if len(approved) != 0 { + t.Errorf("expected 0 approved (no-op), got %d", len(approved)) + } + return nil + }) } -func TestBatchApprove_MissingWorktree(t *testing.T) { - company := dashboard.CompanyEntry{ - Name: "missing-wt", - Quests: []string{"q1", "q2"}, - } - fs := &dashboard.FellowshipState{ - Quests: []dashboard.QuestEntry{ - {Name: "q1", Worktree: "/nonexistent/path"}, - // q2 has no worktree mapping at all - }, - } +func TestBatchApprove_MissingQuestState(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + // q1 has no quest_state row + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt"}) - approved, errs := BatchApprove(company, fs) + company := dashboard.CompanyEntry{ + Name: "missing-state", + Quests: []string{"q1", "q2"}, // q2 doesn't even exist in fellowship_quests + } - // q1 should produce an error (can't load state), q2 is skipped (no mapping) - if len(approved) != 0 { - t.Errorf("expected 0 approved, got %d", len(approved)) - } - if len(errs) != 1 { - t.Errorf("expected 1 error (for q1 missing state), got %d", len(errs)) - } + approved, errs := BatchApprove(conn, company) + + // Both should produce errors (can't load state) + if len(approved) != 0 { + t.Errorf("expected 0 approved, got %d", len(approved)) + } + if len(errs) != 2 { + t.Errorf("expected 2 errors, got %d: %v", len(errs), errs) + } + return nil + }) } func TestFindCompanyForQuest(t *testing.T) { @@ -225,67 +221,250 @@ func TestProgressSummary(t *testing.T) { } func TestBatchApprove_HeraldLogging(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("HOME", t.TempDir()) + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + + state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Research", + GatePending: true, + }) + + company := dashboard.CompanyEntry{ + Name: "herald-test", + Quests: []string{"q1"}, + } + + approved, errs := BatchApprove(conn, company) - wt1 := filepath.Join(tmpDir, "wt1") - os.MkdirAll(filepath.Join(wt1, ".fellowship"), 0755) + if len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } + if len(approved) != 1 { + t.Fatalf("expected 1 approved, got %d", len(approved)) + } - writeState(t, filepath.Join(wt1, ".fellowship", "quest-state.json"), &state.State{ - Version: 1, - QuestName: "q1", - Phase: "Research", - GatePending: true, + tidings, err := herald.Read(conn, "q1", 0) + if err != nil { + t.Fatalf("reading herald: %v", err) + } + + var foundApproved, foundTransition bool + for _, td := range tidings { + if td.Type == herald.GateApproved && td.Phase == "Research" { + foundApproved = true + } + if td.Type == herald.PhaseTransition && td.Phase == "Plan" { + foundTransition = true + } + } + if !foundApproved { + t.Error("expected GateApproved tiding for Research phase") + } + if !foundTransition { + t.Error("expected PhaseTransition tiding for Plan phase") + } + return nil }) +} - company := dashboard.CompanyEntry{ - Name: "herald-test", - Quests: []string{"q1"}, - } - fs := &dashboard.FellowshipState{ - Quests: []dashboard.QuestEntry{ - {Name: "q1", Worktree: wt1}, - }, - } +func TestBatchApprove_TomeRecording(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + + state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Plan", + GatePending: true, + }) + + company := dashboard.CompanyEntry{ + Name: "tome-test", + Quests: []string{"q1"}, + } - approved, errs := BatchApprove(company, fs) + approved, _ := BatchApprove(conn, company) + if len(approved) != 1 { + t.Fatalf("expected 1 approved, got %d", len(approved)) + } - if len(errs) != 0 { - t.Errorf("expected no errors, got %v", errs) - } - if len(approved) != 1 { - t.Fatalf("expected 1 approved, got %d", len(approved)) - } + gates, err := tome.LoadGates(conn, "q1") + if err != nil { + t.Fatalf("loading gates: %v", err) + } + if len(gates) != 1 { + t.Fatalf("expected 1 gate event, got %d", len(gates)) + } + if gates[0].Action != "approved" { + t.Errorf("expected action 'approved', got %q", gates[0].Action) + } + if gates[0].Phase != "Plan" { + t.Errorf("expected phase 'Plan', got %q", gates[0].Phase) + } - tidings, err := herald.Read(wt1, 0) - if err != nil { - t.Fatalf("reading herald: %v", err) - } + phases, err := tome.LoadPhases(conn, "q1") + if err != nil { + t.Fatalf("loading phases: %v", err) + } + if len(phases) != 1 { + t.Fatalf("expected 1 phase record, got %d", len(phases)) + } + if phases[0].Phase != "Plan" { + t.Errorf("expected phase 'Plan', got %q", phases[0].Phase) + } + return nil + }) +} + +func TestList_NoCompanies(t *testing.T) { + d := db.OpenTest(t) + d.WithConn(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + // No companies — should print "No companies defined." + err := List(conn) + if err != nil { + t.Fatalf("List() error: %v", err) + } + return nil + }) +} + +func TestList_WithCompanies(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, nil) - var foundApproved, foundTransition bool - for _, td := range tidings { - if td.Type == herald.GateApproved && td.Phase == "Research" { - foundApproved = true + err := List(conn) + if err != nil { + t.Fatalf("List() error: %v", err) } - if td.Type == herald.PhaseTransition && td.Phase == "Plan" { - foundTransition = true + return nil + }) +} + +func TestShow_CompanyNotFound(t *testing.T) { + d := db.OpenTest(t) + d.WithConn(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + err := Show(conn, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent company") } - } - if !foundApproved { - t.Error("expected GateApproved tiding for Research phase") - } - if !foundTransition { - t.Error("expected PhaseTransition tiding for Plan phase") - } + return nil + }) } -func writeState(t *testing.T, path string, s *state.State) { - t.Helper() - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0644); err != nil { - t.Fatal(err) - } +func TestShow_WithQuestState(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, []string{}) + + state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Implement", + GatePending: true, + }) + + err := Show(conn, "team-alpha") + if err != nil { + t.Fatalf("Show() error: %v", err) + } + return nil + }) +} + +func TestApprove_CompanyNotFound(t *testing.T) { + d := db.OpenTest(t) + d.WithConn(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + err := Approve(conn, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent company") + } + return nil + }) +} + +func TestApprove_WithPendingGates(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, nil) + + state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Research", + GatePending: true, + }) + + err := Approve(conn, "team-alpha") + if err != nil { + t.Fatalf("Approve() error: %v", err) + } + + // Verify state was advanced + s, _ := state.Load(conn, "q1") + if s.Phase != "Plan" { + t.Errorf("expected phase 'Plan', got %q", s.Phase) + } + return nil + }) +} + +func TestLoadAndMarshalProgress(t *testing.T) { + d := db.OpenTest(t) + d.WithTx(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q2", Worktree: "/tmp/wt2"}) + dashboard.AddCompany(conn, "team-alpha", []string{"q1", "q2"}, nil) + + state.Upsert(conn, &state.State{QuestName: "q1", Phase: "Implement"}) + state.Upsert(conn, &state.State{QuestName: "q2", Phase: "Complete"}) + + data, err := LoadAndMarshalProgress(conn, "team-alpha") + if err != nil { + t.Fatalf("LoadAndMarshalProgress() error: %v", err) + } + + var progress CompanyProgress + if err := json.Unmarshal(data, &progress); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if progress.Name != "team-alpha" { + t.Errorf("Name = %q, want %q", progress.Name, "team-alpha") + } + if progress.Total != 2 { + t.Errorf("Total = %d, want 2", progress.Total) + } + if progress.Completed != 1 { + t.Errorf("Completed = %d, want 1", progress.Completed) + } + if progress.InProgress != 2 { // Implement + Complete both >= 3 + t.Errorf("InProgress = %d, want 2", progress.InProgress) + } + return nil + }) +} + +func TestLoadAndMarshalProgress_NotFound(t *testing.T) { + d := db.OpenTest(t) + d.WithConn(context.Background(), func(conn *db.Conn) error { + dashboard.InitFellowship(conn, "test", "/tmp", "main") + _, err := LoadAndMarshalProgress(conn, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent company") + } + return nil + }) } diff --git a/cli/internal/dashboard/server.go b/cli/internal/dashboard/server.go index ba290a1..5ccb70c 100644 --- a/cli/internal/dashboard/server.go +++ b/cli/internal/dashboard/server.go @@ -340,21 +340,13 @@ func batchApproveCompany(conn *db.Conn, c CompanyEntry, fs *FellowshipState) (ap } func (s *Server) handleEagles(w http.ResponseWriter, r *http.Request) { - // Eagles still operates on git root; derive from DB path. opts := eagles.DefaultOptions() - var gitRoot string - s.db.WithConn(context.Background(), func(conn *db.Conn) error { - fs, err := LoadFellowship(conn) - if err == nil { - gitRoot = fs.MainRepo - } - return nil + var report *eagles.EaglesReport + err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { + var sweepErr error + report, sweepErr = eagles.Sweep(conn, opts) + return sweepErr }) - if gitRoot == "" { - http.Error(w, "fellowship not initialized", http.StatusInternalServerError) - return - } - report, err := eagles.Sweep(gitRoot, opts) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return From 5e2c08e4d29fce64ce70e3d1ebd9d1e318094671 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:37:25 -0500 Subject: [PATCH 12/19] feat: update eagles package to use SQLite Co-Authored-By: Claude Opus 4.6 --- cli/internal/eagles/eagles.go | 192 +++++++++++--- cli/internal/eagles/eagles_test.go | 399 +++++++++++++++++++++-------- 2 files changed, 447 insertions(+), 144 deletions(-) diff --git a/cli/internal/eagles/eagles.go b/cli/internal/eagles/eagles.go index 5ce1ccc..93af7c3 100644 --- a/cli/internal/eagles/eagles.go +++ b/cli/internal/eagles/eagles.go @@ -8,8 +8,13 @@ import ( "strings" "time" + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" + "github.com/justinjdev/fellowship/cli/internal/datadir" + "github.com/justinjdev/fellowship/cli/internal/db" "github.com/justinjdev/fellowship/cli/internal/gitutil" + "github.com/justinjdev/fellowship/cli/internal/herald" "github.com/justinjdev/fellowship/cli/internal/state" ) @@ -45,9 +50,9 @@ type EaglesReport struct { // Options configures the eagles scan. type Options struct { - GateThreshold time.Duration // how long a gate can be pending before "stalled" - ZombieTimeout time.Duration // how long since last file change before "zombie" - Now time.Time // injectable clock for testing + GateThreshold time.Duration // how long a gate can be pending before "stalled" + ZombieTimeout time.Duration // how long since last file change before "zombie" + Now time.Time // injectable clock for testing } // DefaultOptions returns sensible defaults. @@ -58,15 +63,16 @@ func DefaultOptions() Options { } } -// Sweep scans all quest worktrees and classifies their health. -func Sweep(gitRoot string, opts Options) (*EaglesReport, error) { +// Sweep scans all quests in the database and classifies their health. +func Sweep(conn *db.Conn, opts Options) (*EaglesReport, error) { if opts.Now.IsZero() { opts.Now = time.Now() } - worktrees, err := gitutil.ListWorktrees(gitRoot) + // Load all quest states from quest_state table. + states, err := listAllQuests(conn) if err != nil { - return nil, err + return nil, fmt.Errorf("eagles: list quests: %w", err) } report := &EaglesReport{ @@ -74,50 +80,159 @@ func Sweep(gitRoot string, opts Options) (*EaglesReport, error) { Quests: []QuestHealth{}, } - for _, wt := range worktrees { - qh, err := classifyQuest(wt, opts) - if err != nil { - // Skip worktrees without quest state - continue - } + for _, s := range states { + qh := classifyQuest(conn, s, opts) if qh.Health != Working && qh.Health != Complete { report.Problems++ } - report.Quests = append(report.Quests, *qh) + report.Quests = append(report.Quests, qh) } return report, nil } -// classifyQuest examines a single worktree and returns its health. -// TODO: migrate to accept *db.Conn and use state.Load(conn, questName). -func classifyQuest(worktree string, opts Options) (*QuestHealth, error) { - _ = state.NextPhase // keep import until full migration - _ = opts.Now // suppress unused - // Cannot load quest state without a DB connection. Return error so Sweep skips. - return nil, fmt.Errorf("eagles: requires DB migration") +// listAllQuests returns all quest states from the database. +func listAllQuests(conn *db.Conn) ([]*state.State, error) { + var states []*state.State + err := sqlitex.Execute(conn, + `SELECT quest_name, task_id, team_name, phase, + gate_pending, gate_id, lembas_completed, metadata_updated, + held, held_reason, auto_approve + FROM quest_state ORDER BY quest_name`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + s := &state.State{ + QuestName: stmt.ColumnText(0), + TaskID: stmt.ColumnText(1), + TeamName: stmt.ColumnText(2), + Phase: stmt.ColumnText(3), + GatePending: stmt.ColumnInt(4) != 0, + LembasCompleted: stmt.ColumnInt(6) != 0, + MetadataUpdated: stmt.ColumnInt(7) != 0, + Held: stmt.ColumnInt(8) != 0, + } + if stmt.ColumnType(5) != sqlite.TypeNull { + gid := stmt.ColumnText(5) + s.GateID = &gid + } + if stmt.ColumnType(9) != sqlite.TypeNull { + hr := stmt.ColumnText(9) + s.HeldReason = &hr + } + if aa := stmt.ColumnText(10); aa != "" { + json.Unmarshal([]byte(aa), &s.AutoApproveGates) + } + states = append(states, s) + return nil + }, + }) + if err != nil { + return nil, err + } + return states, nil } +// classifyQuest examines a quest's state and herald tidings to determine health. +func classifyQuest(conn *db.Conn, s *state.State, opts Options) QuestHealth { + qh := QuestHealth{ + Name: s.QuestName, + Phase: s.Phase, + Action: "none", + } -// latestModTime walks the worktree (excluding .git, data dir, and node_modules) to find the most -// recently modified file. -func latestModTime(worktree string) time.Time { - var latest time.Time - filepath.Walk(worktree, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - // Skip .git, fellowship data dir (internal state), and node_modules directories - name := info.Name() - if info.IsDir() && (name == ".git" || name == datadir.Name() || name == "node_modules") { - return filepath.SkipDir + // Complete quests are always healthy. + if s.Phase == "Complete" { + qh.Health = Complete + qh.LastActivity = lastActivity(conn, s) + return qh + } + + // Idle: no quest name assigned (onboarding placeholder). + if s.QuestName == "" { + qh.Health = Idle + qh.LastActivity = lastActivity(conn, s) + return qh + } + + // Check for stalled gates. + if s.GatePending { + if s.GateID != nil { + age := gitutil.GateAge(*s.GateID, opts.Now) + qh.GatePendingSec = age + if age >= int(opts.GateThreshold.Seconds()) { + qh.Health = Stalled + qh.Action = "nudge" + qh.LastActivity = lastActivity(conn, s) + return qh + } + } else { + // Gate pending with no ID — assume stalled (cannot determine age). + qh.Health = Stalled + qh.Action = "nudge" + qh.LastActivity = lastActivity(conn, s) + return qh } - if !info.IsDir() && info.ModTime().After(latest) { - latest = info.ModTime() + } + + // Check for zombie: use updated_at from quest_state and herald timestamps. + lastAct := lastActivity(conn, s) + qh.LastActivity = lastAct + + if lastAct != "" { + if t, err := time.Parse(time.RFC3339, lastAct); err == nil { + if opts.Now.Sub(t) > opts.ZombieTimeout { + qh.Health = Zombie + qh.HasCheckpoint = hasCheckpoint(conn, s.QuestName) + if qh.HasCheckpoint { + qh.Action = "respawn" + } else { + qh.Action = "nudge" + } + return qh + } } - return nil - }) - return latest + } + + qh.Health = Working + return qh +} + +// lastActivity returns the most recent timestamp from herald tidings for a quest, +// or falls back to the quest_state updated_at. +func lastActivity(conn *db.Conn, s *state.State) string { + tidings, err := herald.Read(conn, s.QuestName, 1) + if err == nil && len(tidings) > 0 { + return tidings[0].Timestamp + } + + // Fall back to updated_at from quest_state. + var updatedAt string + sqlitex.Execute(conn, + `SELECT updated_at FROM quest_state WHERE quest_name = :name`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": s.QuestName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + updatedAt = stmt.ColumnText(0) + return nil + }, + }) + return updatedAt +} + +// hasCheckpoint checks if the quest has a checkpoint by looking for +// a lembas_completed herald tiding, which indicates checkpoint creation. +func hasCheckpoint(conn *db.Conn, questName string) bool { + var found bool + sqlitex.Execute(conn, + `SELECT 1 FROM herald WHERE quest = :name AND type = 'lembas_completed' LIMIT 1`, + &sqlitex.ExecOptions{ + Named: map[string]any{":name": questName}, + ResultFunc: func(stmt *sqlite.Stmt) error { + found = true + return nil + }, + }) + return found } // WriteReport writes the eagles report to the data directory in the git root. @@ -165,4 +280,3 @@ func FormatTable(report *EaglesReport) string { sb.WriteString(fmt.Sprintf("Problems: %d\n", report.Problems)) return sb.String() } - diff --git a/cli/internal/eagles/eagles_test.go b/cli/internal/eagles/eagles_test.go index 56a9744..25e1bdf 100644 --- a/cli/internal/eagles/eagles_test.go +++ b/cli/internal/eagles/eagles_test.go @@ -1,6 +1,7 @@ package eagles import ( + "context" "encoding/json" "fmt" "os" @@ -8,63 +9,48 @@ import ( "testing" "time" + "github.com/justinjdev/fellowship/cli/internal/db" "github.com/justinjdev/fellowship/cli/internal/gitutil" + "github.com/justinjdev/fellowship/cli/internal/herald" + "github.com/justinjdev/fellowship/cli/internal/state" ) -// writeQuestState creates a quest-state.json in worktree/.fellowship. -// Pins HOME to a temp dir so datadir.Name() returns the default ".fellowship". -func writeQuestState(t *testing.T, worktree string, phase string, gatePending bool, gateID *string, questName string) { +// seedQuest inserts a quest state and optionally herald tidings into the test DB. +func seedQuest(t *testing.T, d *db.DB, s *state.State) { t.Helper() - t.Setenv("HOME", t.TempDir()) - dir := filepath.Join(worktree, ".fellowship") - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatalf("creating data dir: %v", err) - } - - s := map[string]interface{}{ - "version": 1, - "quest_name": questName, - "task_id": "t1", - "team_name": "team", - "phase": phase, - "gate_pending": gatePending, - "gate_id": gateID, - "lembas_completed": false, - "metadata_updated": false, - "auto_approve_gates": []string{}, - } - - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - t.Fatalf("marshaling state: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "quest-state.json"), data, 0644); err != nil { - t.Fatalf("writing quest-state.json: %v", err) + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { + return state.Upsert(conn, s) + }); err != nil { + t.Fatalf("seeding quest %s: %v", s.QuestName, err) } } -// touchFile creates a file with the given modification time. -func touchFile(t *testing.T, path string, modTime time.Time) { +// seedTiding inserts a herald tiding. +func seedTiding(t *testing.T, d *db.DB, tiding herald.Tiding) { t.Helper() - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatalf("creating dir %s: %v", dir, err) - } - if err := os.WriteFile(path, []byte("content"), 0644); err != nil { - t.Fatalf("writing file %s: %v", path, err) - } - if err := os.Chtimes(path, modTime, modTime); err != nil { - t.Fatalf("changing times for %s: %v", path, err) + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { + return herald.Announce(conn, tiding) + }); err != nil { + t.Fatalf("seeding tiding for %s: %v", tiding.Quest, err) } } func TestClassifyHealthy(t *testing.T) { - worktree := t.TempDir() - writeQuestState(t, worktree, "Implement", false, nil, "quest-api") - - now := time.Now() - // Create a recently modified file - touchFile(t, filepath.Join(worktree, "src", "main.go"), now.Add(-2*time.Minute)) + d := db.OpenTest(t) + now := time.Now().UTC() + + seedQuest(t, d, &state.State{ + QuestName: "quest-api", + TaskID: "t1", + TeamName: "team", + Phase: "Implement", + }) + seedTiding(t, d, herald.Tiding{ + Timestamp: now.Add(-2 * time.Minute).Format(time.RFC3339), + Quest: "quest-api", + Type: herald.PhaseTransition, + Phase: "Implement", + }) opts := Options{ GateThreshold: 10 * time.Minute, @@ -72,11 +58,21 @@ func TestClassifyHealthy(t *testing.T) { Now: now, } - qh, err := classifyQuest(worktree, opts) + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) if err != nil { - t.Fatalf("classifyQuest: %v", err) + t.Fatalf("Sweep: %v", err) } + if len(report.Quests) != 1 { + t.Fatalf("len(Quests) = %d, want 1", len(report.Quests)) + } + + qh := report.Quests[0] if qh.Health != Working { t.Errorf("Health = %q, want %q", qh.Health, Working) } @@ -92,15 +88,21 @@ func TestClassifyHealthy(t *testing.T) { } func TestClassifyStalledWithGateID(t *testing.T) { - worktree := t.TempDir() - now := time.Now() + d := db.OpenTest(t) + now := time.Now().UTC() // Gate created 20 minutes ago gateTS := now.Add(-20 * time.Minute).Unix() gateID := fmt.Sprintf("gate-Plan-%d", gateTS) - writeQuestState(t, worktree, "Plan", true, &gateID, "quest-auth") - touchFile(t, filepath.Join(worktree, "src", "plan.md"), now.Add(-1*time.Minute)) + seedQuest(t, d, &state.State{ + QuestName: "quest-auth", + TaskID: "t2", + TeamName: "team", + Phase: "Plan", + GatePending: true, + GateID: &gateID, + }) opts := Options{ GateThreshold: 10 * time.Minute, @@ -108,11 +110,21 @@ func TestClassifyStalledWithGateID(t *testing.T) { Now: now, } - qh, err := classifyQuest(worktree, opts) + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) if err != nil { - t.Fatalf("classifyQuest: %v", err) + t.Fatalf("Sweep: %v", err) + } + + if len(report.Quests) != 1 { + t.Fatalf("len(Quests) = %d, want 1", len(report.Quests)) } + qh := report.Quests[0] if qh.Health != Stalled { t.Errorf("Health = %q, want %q", qh.Health, Stalled) } @@ -125,15 +137,27 @@ func TestClassifyStalledWithGateID(t *testing.T) { } func TestClassifyStalledGatePendingWithinThreshold(t *testing.T) { - worktree := t.TempDir() - now := time.Now() + d := db.OpenTest(t) + now := time.Now().UTC() // Gate created 5 minutes ago — within threshold gateTS := now.Add(-5 * time.Minute).Unix() gateID := fmt.Sprintf("gate-Plan-%d", gateTS) - writeQuestState(t, worktree, "Plan", true, &gateID, "quest-fresh") - touchFile(t, filepath.Join(worktree, "src", "plan.md"), now.Add(-1*time.Minute)) + seedQuest(t, d, &state.State{ + QuestName: "quest-fresh", + TaskID: "t3", + TeamName: "team", + Phase: "Plan", + GatePending: true, + GateID: &gateID, + }) + seedTiding(t, d, herald.Tiding{ + Timestamp: now.Add(-1 * time.Minute).Format(time.RFC3339), + Quest: "quest-fresh", + Type: herald.GateSubmitted, + Phase: "Plan", + }) opts := Options{ GateThreshold: 10 * time.Minute, @@ -141,26 +165,39 @@ func TestClassifyStalledGatePendingWithinThreshold(t *testing.T) { Now: now, } - qh, err := classifyQuest(worktree, opts) + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) if err != nil { - t.Fatalf("classifyQuest: %v", err) + t.Fatalf("Sweep: %v", err) } + qh := report.Quests[0] if qh.Health != Working { t.Errorf("Health = %q, want %q (gate pending within threshold)", qh.Health, Working) } } func TestClassifyZombie(t *testing.T) { - worktree := t.TempDir() - now := time.Now() - - writeQuestState(t, worktree, "Implement", false, nil, "quest-dead") - - // Last file change was 30 minutes ago - touchFile(t, filepath.Join(worktree, "src", "old.go"), now.Add(-30*time.Minute)) - // Set the quest-state.json mod time to be old too - os.Chtimes(filepath.Join(worktree, ".fellowship", "quest-state.json"), now.Add(-30*time.Minute), now.Add(-30*time.Minute)) + d := db.OpenTest(t) + now := time.Now().UTC() + + seedQuest(t, d, &state.State{ + QuestName: "quest-dead", + TaskID: "t4", + TeamName: "team", + Phase: "Implement", + }) + // Last activity was 30 minutes ago + seedTiding(t, d, herald.Tiding{ + Timestamp: now.Add(-30 * time.Minute).Format(time.RFC3339), + Quest: "quest-dead", + Type: herald.PhaseTransition, + Phase: "Implement", + }) opts := Options{ GateThreshold: 10 * time.Minute, @@ -168,11 +205,17 @@ func TestClassifyZombie(t *testing.T) { Now: now, } - qh, err := classifyQuest(worktree, opts) + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) if err != nil { - t.Fatalf("classifyQuest: %v", err) + t.Fatalf("Sweep: %v", err) } + qh := report.Quests[0] if qh.Health != Zombie { t.Errorf("Health = %q, want %q", qh.Health, Zombie) } @@ -182,17 +225,29 @@ func TestClassifyZombie(t *testing.T) { } func TestClassifyZombieWithCheckpoint(t *testing.T) { - worktree := t.TempDir() - now := time.Now() - - writeQuestState(t, worktree, "Implement", false, nil, "quest-resumable") - - // Last file change was 30 minutes ago - touchFile(t, filepath.Join(worktree, "src", "old.go"), now.Add(-30*time.Minute)) - os.Chtimes(filepath.Join(worktree, ".fellowship", "quest-state.json"), now.Add(-30*time.Minute), now.Add(-30*time.Minute)) - - // Create checkpoint - touchFile(t, filepath.Join(worktree, ".fellowship", "checkpoint.md"), now.Add(-30*time.Minute)) + d := db.OpenTest(t) + now := time.Now().UTC() + + seedQuest(t, d, &state.State{ + QuestName: "quest-resumable", + TaskID: "t5", + TeamName: "team", + Phase: "Implement", + }) + // Last activity was 30 minutes ago + seedTiding(t, d, herald.Tiding{ + Timestamp: now.Add(-30 * time.Minute).Format(time.RFC3339), + Quest: "quest-resumable", + Type: herald.PhaseTransition, + Phase: "Implement", + }) + // Has a lembas_completed checkpoint + seedTiding(t, d, herald.Tiding{ + Timestamp: now.Add(-30 * time.Minute).Format(time.RFC3339), + Quest: "quest-resumable", + Type: herald.LembasCompleted, + Phase: "Implement", + }) opts := Options{ GateThreshold: 10 * time.Minute, @@ -200,11 +255,17 @@ func TestClassifyZombieWithCheckpoint(t *testing.T) { Now: now, } - qh, err := classifyQuest(worktree, opts) + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) if err != nil { - t.Fatalf("classifyQuest: %v", err) + t.Fatalf("Sweep: %v", err) } + qh := report.Quests[0] if qh.Health != Zombie { t.Errorf("Health = %q, want %q", qh.Health, Zombie) } @@ -217,12 +278,15 @@ func TestClassifyZombieWithCheckpoint(t *testing.T) { } func TestClassifyComplete(t *testing.T) { - worktree := t.TempDir() - now := time.Now() + d := db.OpenTest(t) + now := time.Now().UTC() - writeQuestState(t, worktree, "Complete", false, nil, "quest-done") - touchFile(t, filepath.Join(worktree, "src", "done.go"), now.Add(-60*time.Minute)) - os.Chtimes(filepath.Join(worktree, ".fellowship", "quest-state.json"), now.Add(-60*time.Minute), now.Add(-60*time.Minute)) + seedQuest(t, d, &state.State{ + QuestName: "quest-done", + TaskID: "t6", + TeamName: "team", + Phase: "Complete", + }) opts := Options{ GateThreshold: 10 * time.Minute, @@ -230,11 +294,17 @@ func TestClassifyComplete(t *testing.T) { Now: now, } - qh, err := classifyQuest(worktree, opts) + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) if err != nil { - t.Fatalf("classifyQuest: %v", err) + t.Fatalf("Sweep: %v", err) } + qh := report.Quests[0] if qh.Health != Complete { t.Errorf("Health = %q, want %q", qh.Health, Complete) } @@ -244,11 +314,13 @@ func TestClassifyComplete(t *testing.T) { } func TestClassifyIdle(t *testing.T) { - worktree := t.TempDir() - now := time.Now() + d := db.OpenTest(t) + now := time.Now().UTC() - writeQuestState(t, worktree, "Onboard", false, nil, "") - touchFile(t, filepath.Join(worktree, "src", "empty.go"), now.Add(-1*time.Minute)) + seedQuest(t, d, &state.State{ + QuestName: "", + Phase: "Onboard", + }) opts := Options{ GateThreshold: 10 * time.Minute, @@ -256,11 +328,20 @@ func TestClassifyIdle(t *testing.T) { Now: now, } - qh, err := classifyQuest(worktree, opts) + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) if err != nil { - t.Fatalf("classifyQuest: %v", err) + t.Fatalf("Sweep: %v", err) } + if len(report.Quests) != 1 { + t.Fatalf("len(Quests) = %d, want 1", len(report.Quests)) + } + qh := report.Quests[0] if qh.Health != Idle { t.Errorf("Health = %q, want %q", qh.Health, Idle) } @@ -270,11 +351,17 @@ func TestClassifyIdle(t *testing.T) { } func TestClassifyStalledNoGateID(t *testing.T) { - worktree := t.TempDir() - now := time.Now() - - writeQuestState(t, worktree, "Review", true, nil, "quest-stuck") - touchFile(t, filepath.Join(worktree, "src", "main.go"), now.Add(-1*time.Minute)) + d := db.OpenTest(t) + now := time.Now().UTC() + + seedQuest(t, d, &state.State{ + QuestName: "quest-stuck", + TaskID: "t7", + TeamName: "team", + Phase: "Review", + GatePending: true, + GateID: nil, + }) opts := Options{ GateThreshold: 10 * time.Minute, @@ -282,11 +369,17 @@ func TestClassifyStalledNoGateID(t *testing.T) { Now: now, } - qh, err := classifyQuest(worktree, opts) + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) if err != nil { - t.Fatalf("classifyQuest: %v", err) + t.Fatalf("Sweep: %v", err) } + qh := report.Quests[0] if qh.Health != Stalled { t.Errorf("Health = %q, want %q", qh.Health, Stalled) } @@ -401,7 +494,6 @@ func TestFormatTable(t *testing.T) { t.Fatal("FormatTable returned empty string") } - // Check it contains key elements for _, want := range []string{"Fellowship Eagles Report", "quest-api", "Implement", "working", "none", "Problems: 0"} { if !contains(output, want) { t.Errorf("output missing %q", want) @@ -410,7 +502,6 @@ func TestFormatTable(t *testing.T) { } func TestProblemCount(t *testing.T) { - // Manually build a report to verify problem counting report := &EaglesReport{ Timestamp: "2025-01-15T10:30:00Z", Quests: []QuestHealth{}, @@ -440,6 +531,104 @@ func TestProblemCount(t *testing.T) { } } +func TestSweepMultipleQuests(t *testing.T) { + d := db.OpenTest(t) + now := time.Now().UTC() + + // Seed multiple quests with different states + seedQuest(t, d, &state.State{ + QuestName: "quest-a", + Phase: "Implement", + }) + seedTiding(t, d, herald.Tiding{ + Timestamp: now.Add(-1 * time.Minute).Format(time.RFC3339), + Quest: "quest-a", + Type: herald.PhaseTransition, + Phase: "Implement", + }) + + seedQuest(t, d, &state.State{ + QuestName: "quest-b", + Phase: "Complete", + }) + + gateID := fmt.Sprintf("gate-Plan-%d", now.Add(-20*time.Minute).Unix()) + seedQuest(t, d, &state.State{ + QuestName: "quest-c", + Phase: "Plan", + GatePending: true, + GateID: &gateID, + }) + + opts := Options{ + GateThreshold: 10 * time.Minute, + ZombieTimeout: 15 * time.Minute, + Now: now, + } + + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) + if err != nil { + t.Fatalf("Sweep: %v", err) + } + + if len(report.Quests) != 3 { + t.Fatalf("len(Quests) = %d, want 3", len(report.Quests)) + } + + // Find each quest by name + healthMap := map[string]HealthState{} + for _, q := range report.Quests { + healthMap[q.Name] = q.Health + } + + if healthMap["quest-a"] != Working { + t.Errorf("quest-a: Health = %q, want %q", healthMap["quest-a"], Working) + } + if healthMap["quest-b"] != Complete { + t.Errorf("quest-b: Health = %q, want %q", healthMap["quest-b"], Complete) + } + if healthMap["quest-c"] != Stalled { + t.Errorf("quest-c: Health = %q, want %q", healthMap["quest-c"], Stalled) + } + + if report.Problems != 1 { + t.Errorf("Problems = %d, want 1", report.Problems) + } +} + +func TestSweepEmptyDB(t *testing.T) { + d := db.OpenTest(t) + now := time.Now().UTC() + + opts := Options{ + GateThreshold: 10 * time.Minute, + ZombieTimeout: 15 * time.Minute, + Now: now, + } + + var report *EaglesReport + err := d.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + report, err = Sweep(conn, opts) + return err + }) + if err != nil { + t.Fatalf("Sweep: %v", err) + } + + if len(report.Quests) != 0 { + t.Errorf("len(Quests) = %d, want 0", len(report.Quests)) + } + if report.Problems != 0 { + t.Errorf("Problems = %d, want 0", report.Problems) + } +} + func contains(s, substr string) bool { return len(s) >= len(substr) && searchString(s, substr) } From b6e04210720de5c5c4c61460c689d022262de886 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:44:04 -0500 Subject: [PATCH 13/19] feat: wire DB through main.go, replace all file-based state access Co-Authored-By: Claude Opus 4.6 --- cli/cmd/fellowship/main.go | 1426 ++++++++++++++++++---------------- cli/internal/hooks/enrich.go | 34 +- 2 files changed, 783 insertions(+), 677 deletions(-) diff --git a/cli/cmd/fellowship/main.go b/cli/cmd/fellowship/main.go index a1087cb..11d7d7b 100644 --- a/cli/cmd/fellowship/main.go +++ b/cli/cmd/fellowship/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "flag" "fmt" @@ -15,14 +16,14 @@ import ( "github.com/justinjdev/fellowship/cli/internal/autopsy" "github.com/justinjdev/fellowship/cli/internal/bulletin" - "github.com/justinjdev/fellowship/cli/internal/datadir" "github.com/justinjdev/fellowship/cli/internal/company" "github.com/justinjdev/fellowship/cli/internal/dashboard" + "github.com/justinjdev/fellowship/cli/internal/datadir" + "github.com/justinjdev/fellowship/cli/internal/db" "github.com/justinjdev/fellowship/cli/internal/eagles" "github.com/justinjdev/fellowship/cli/internal/errand" "github.com/justinjdev/fellowship/cli/internal/herald" "github.com/justinjdev/fellowship/cli/internal/hooks" - "github.com/justinjdev/fellowship/cli/internal/state" "github.com/justinjdev/fellowship/cli/internal/status" "github.com/justinjdev/fellowship/cli/internal/tome" @@ -36,63 +37,85 @@ func main() { os.Exit(1) } + // Commands that don't need DB. + switch os.Args[1] { + case "version": + fmt.Println(version) + return + case "migrate": + // TODO: implement migration command + fmt.Fprintln(os.Stderr, "fellowship: migrate command not yet implemented") + os.Exit(1) + } + + // Open DB for all other commands. + cwd, _ := os.Getwd() + d, err := db.Open(cwd) + if err != nil { + if jsonFilesExist(cwd) { + fmt.Fprintln(os.Stderr, `fellowship: Run "fellowship migrate" to upgrade to the new storage format.`) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + os.Exit(1) + } + defer d.Close() + switch os.Args[1] { case "hook": if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: fellowship hook ") os.Exit(1) } - os.Exit(runHook(os.Args[2])) + os.Exit(runHook(d, os.Args[2])) case "gate": if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: fellowship gate ") os.Exit(1) } - os.Exit(runGate(os.Args[2:])) + os.Exit(runGate(d, os.Args[2:])) case "init": - os.Exit(runInit()) + os.Exit(runInit(d)) case "status": - os.Exit(runStatus(os.Args[2:])) + os.Exit(runStatus(d, os.Args[2:])) case "company": if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: fellowship company ") os.Exit(1) } - os.Exit(runCompany(os.Args[2:])) + os.Exit(runCompany(d, os.Args[2:])) case "tome": - os.Exit(runTome(os.Args[2:])) + os.Exit(runTome(d, os.Args[2:])) case "eagles": - os.Exit(runEagles(os.Args[2:])) + os.Exit(runEagles(d, os.Args[2:])) case "bulletin": if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: fellowship bulletin ") os.Exit(1) } - os.Exit(runBulletin(os.Args[2:])) + os.Exit(runBulletin(d, os.Args[2:])) case "errand": - os.Exit(runErrand(os.Args[2:])) + os.Exit(runErrand(d, os.Args[2:])) case "state": if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: fellowship state ") os.Exit(1) } - os.Exit(runState(os.Args[2:])) + os.Exit(runState(d, os.Args[2:])) case "hold": - os.Exit(runHold(os.Args[2:])) + os.Exit(runHold(d, os.Args[2:])) case "unhold": - os.Exit(runUnhold(os.Args[2:])) + os.Exit(runUnhold(d, os.Args[2:])) case "autopsy": if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: fellowship autopsy ") os.Exit(1) } - os.Exit(runAutopsy(os.Args[2:])) + os.Exit(runAutopsy(d, os.Args[2:])) case "herald": - os.Exit(runHerald(os.Args[2:])) + os.Exit(runHerald(d, os.Args[2:])) case "dashboard": - os.Exit(runDashboard(os.Args[2:])) - case "version": - fmt.Println(version) + os.Exit(runDashboard(d, os.Args[2:])) default: usage() os.Exit(1) @@ -127,7 +150,7 @@ Agent/lead commands: --json Output as JSON Setup commands: - init Create quest-state.json in data directory + init Initialize quest state in DB --dir PATH Worktree or repo root (default: auto-detect via git) --phase PHASE Initial phase (default: Onboard) --plan-skip Record Onboard/Research/Plan as skipped in tome @@ -135,62 +158,51 @@ Setup commands: Company commands: company list List all companies and their quest/scout counts - --dir PATH Git repo root (default: auto-detect) company show Show detailed company status (phases, progress) - --dir PATH Git repo root (default: auto-detect) company approve Batch-approve all pending gates in a company - --dir PATH Git repo root (default: auto-detect) Fellowship state: - state init Create fellowship-state.json in data directory - --dir PATH Git repo root (default: auto-detect) + state init Initialize fellowship in DB --name NAME Fellowship name (required) --base-branch BRANCH Base branch for quest worktrees (default: auto-detected) state add-quest Add a quest entry to fellowship state - --dir PATH Git repo root (default: auto-detect) --name NAME Quest name (required) --task "DESC" Task description (required) --branch BRANCH Branch name --worktree PATH Worktree path --task-id ID Task ID state add-scout Add a scout entry to fellowship state - --dir PATH Git repo root (default: auto-detect) --name NAME Scout name (required) --question "Q" Research question (required) --task-id ID Task ID state add-company Add a company entry to fellowship state - --dir PATH Git repo root (default: auto-detect) --name NAME Company name (required) --quests q1,q2 Comma-separated quest names --scouts s1,s2 Comma-separated scout names state update-quest Update an existing quest entry - --dir PATH Git repo root (default: auto-detect) --name NAME Quest name (required) --worktree PATH Worktree path --branch BRANCH Branch name --task-id ID Task ID --status STATUS Quest status (active, completed, cancelled) state show Show fellowship state as JSON - --dir PATH Git repo root (default: auto-detect) - state clean-worktrees Reset stale gate_pending/held flags in all worktrees - --dir PATH Git repo root (default: auto-detect) + state clean-worktrees Reset stale gate_pending/held flags in all quests Errands (persistent work items): - errand init Create initial quest-errands.json - --dir PATH Worktree directory + errand init Initialize errands for a quest --quest NAME Quest name --task "DESC" Task description errand list Show all errands with status - --dir PATH Worktree directory + --quest NAME Quest name errand add Add a new errand - --dir PATH Worktree directory + --quest NAME Quest name --phase PHASE Quest phase (optional) "description" Errand description (positional arg) errand update Update an errand's status - --dir PATH Worktree directory + --quest NAME Quest name Item ID and new status (positional args) - errand show Show full errand file as JSON - --dir PATH Worktree directory + errand show Show all errands as JSON + --quest NAME Quest name Bulletin (cross-quest knowledge sharing): bulletin post Post a discovery to the shared bulletin board @@ -208,7 +220,6 @@ Bulletin (cross-quest knowledge sharing): Herald (activity tidings): herald Show recent quest tidings - --dir PATH Git repo root (default: auto-detect) --problems Show only detected problems --json Output as JSON @@ -219,29 +230,31 @@ Dashboard: Autopsy (failure memory): autopsy create Write a structured failure record (reads JSON from stdin) - --dir DIR Git repo root (default: auto-detect) autopsy scan Find autopsies matching files, modules, or tags - --dir DIR Git repo root (default: auto-detect) --files f1,f2 Comma-separated file paths to match --modules m1,m2 Comma-separated module names to match --tags t1,t2 Comma-separated tags to match - autopsy infer Reconstruct autopsy from worktree signals (tome, herald) - --dir DIR Quest worktree directory (required) - --repo DIR Git repo root for storing autopsy (default: auto-detect) + autopsy infer Reconstruct autopsy from quest signals + --quest NAME Quest name (required) Other: + migrate Migrate JSON files to SQLite version Print version`) } -func runHook(name string) int { +func runHook(d *db.DB, name string) int { + ctx := context.Background() cwd, _ := os.Getwd() - statePath, err := state.FindStateFile(cwd) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 2 - } - // Lead session (no state file): only the CWD guard applies. - if statePath == "" { + gitRoot := gitRootFrom(cwd) + + // Find quest name for this worktree. + var questName string + d.WithConn(ctx, func(conn *db.Conn) error { + questName, _ = state.FindQuest(conn, gitRoot) + return nil + }) + // Lead session (no quest found): only the CWD guard applies. + if questName == "" { if name == "gate-guard" { input, err := hooks.ParseInput(os.Stdin) if err != nil { @@ -266,151 +279,203 @@ func runHook(name string) int { } } - dir := filepath.Dir(filepath.Dir(statePath)) // strip /quest-state.json - - // Read-only hooks: no locking needed, just load and check. + // Read-only hooks: use WithConn. switch name { case "gate-guard": - s, err := state.Load(statePath) - if err != nil { + var result hooks.HookResult + if err := d.WithConn(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } + result = hooks.GateGuard(s, input) + return nil + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 2 } - result := hooks.GateGuard(s, input) if result.Block { fmt.Fprintln(os.Stderr, result.Message) return 2 } return 0 - case "completion-guard": - s, err := state.Load(statePath) - if err != nil { + + case "gate-prereq": + var result hooks.HookResult + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } + changed := hooks.GatePrereq(s, input) + if changed { + if err := state.Upsert(conn, s); err != nil { + return err + } + herald.Announce(conn, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.LembasCompleted, + Phase: s.Phase, + Detail: "Lembas skill completed", + }) + } + return nil + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 2 } - result := hooks.CompletionGuard(s, input) - if !result.Block && input.ToolInput.Status == "completed" { - tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") - hooks.MarkTomeCompleted(tomePath) - } if result.Block { fmt.Fprintln(os.Stderr, result.Message) return 2 } return 0 - case "file-track": - s, err := state.Load(statePath) - if err != nil { + + case "completion-guard": + var result hooks.HookResult + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } + result = hooks.CompletionGuard(s, input) + if !result.Block && input.ToolInput.Status == "completed" { + hooks.MarkTomeCompleted(conn, questName) + } + return nil + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 2 } - tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") - hooks.FileTrack(s, input, tomePath) + if result.Block { + fmt.Fprintln(os.Stderr, result.Message) + return 2 + } return 0 - } - // Mutating hooks: use WithLock for atomic load→mutate→save. - var result hooks.HookResult - var gateSubmitEnrich bool - if err := state.WithLock(statePath, func(s *state.State) error { - questName := s.QuestName - if questName == "" { - questName = filepath.Base(dir) + case "file-track": + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } + hooks.FileTrack(conn, s, input, questName) + return nil + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 2 } + return 0 - switch name { - case "gate-submit": + case "gate-submit": + var result hooks.HookResult + var gateSubmitEnrich bool + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } prevPhase := s.Phase sr := hooks.GateSubmit(s, input) result = hooks.HookResult{Block: sr.Block, Message: sr.Message} - if sr.StateChanged && !sr.Block { - gateSubmitEnrich = true - tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") - hooks.RecordGateSubmitted(tomePath, prevPhase, s.Phase != prevPhase) - herald.Announce(dir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: questName, - Type: herald.GateSubmitted, - Phase: s.Phase, - Detail: "Gate submitted for review", - }) + if sr.StateChanged { + if err := state.Upsert(conn, s); err != nil { + return err + } + if !sr.Block { + gateSubmitEnrich = true + hooks.RecordGateSubmitted(conn, questName, prevPhase, s.Phase != prevPhase) + herald.Announce(conn, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.GateSubmitted, + Phase: s.Phase, + Detail: "Gate submitted for review", + }) + } } - if !sr.StateChanged { - return state.ErrNoSave + return nil + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 2 + } + if result.Block { + out := hooks.NewDenyOutput(result.Message) + json.NewEncoder(os.Stdout).Encode(out) + return 0 // exit 0 with JSON deny — Claude Code reads the JSON + } + if gateSubmitEnrich { + var enrichment string + d.WithConn(ctx, func(conn *db.Conn) error { + enrichment = hooks.GatherEnrichment(conn, questName, gitRoot) + return nil + }) + if enrichment != "" { + enrichedContent := input.ToolInput.Content + enrichment + out := hooks.NewAllowOutput(map[string]string{"content": enrichedContent}) + json.NewEncoder(os.Stdout).Encode(out) } - case "gate-prereq": - changed := hooks.GatePrereq(s, input) - if changed { - herald.Announce(dir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: questName, - Type: herald.LembasCompleted, - Phase: s.Phase, - Detail: "Lembas skill completed", - }) - } else { - return state.ErrNoSave + } + return 0 + + case "metadata-track": + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err } - case "metadata-track": changed := hooks.MetadataTrack(s, input) if changed { - herald.Announce(dir, herald.Tiding{ + if err := state.Upsert(conn, s); err != nil { + return err + } + herald.Announce(conn, herald.Tiding{ Timestamp: time.Now().UTC().Format(time.RFC3339), Quest: questName, Type: herald.MetadataUpdated, Phase: s.Phase, Detail: "Task metadata updated", }) - } else { - return state.ErrNoSave } - default: - fmt.Fprintf(os.Stderr, "fellowship: unknown hook %q\n", name) - result = hooks.HookResult{Block: true, Message: fmt.Sprintf("unknown hook %q", name)} - return state.ErrNoSave + return nil + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 2 } - return nil - }); err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 2 - } + return 0 - if result.Block { - if name == "gate-submit" { - out := hooks.NewDenyOutput(result.Message) - json.NewEncoder(os.Stdout).Encode(out) - return 0 // exit 0 with JSON deny — Claude Code reads the JSON - } - fmt.Fprintln(os.Stderr, result.Message) + default: + fmt.Fprintf(os.Stderr, "fellowship: unknown hook %q\n", name) return 2 } - - if gateSubmitEnrich { - enrichment := hooks.GatherEnrichment(dir) - if enrichment != "" { - enrichedContent := input.ToolInput.Content + enrichment - out := hooks.NewAllowOutput(map[string]string{"content": enrichedContent}) - json.NewEncoder(os.Stdout).Encode(out) - } - } - return 0 } -func runGate(args []string) int { +func runGate(d *db.DB, args []string) int { + ctx := context.Background() cwd, _ := os.Getwd() - statePath, err := state.FindStateFile(cwd) - if err != nil || statePath == "" { - fmt.Fprintln(os.Stderr, "fellowship: no quest state file found") - return 1 - } - s, err := state.Load(statePath) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + gitRoot := gitRootFrom(cwd) + + var questName string + d.WithConn(ctx, func(conn *db.Conn) error { + questName, _ = state.FindQuest(conn, gitRoot) + return nil + }) + if questName == "" { + fmt.Fprintln(os.Stderr, "fellowship: no quest state found") return 1 } switch args[0] { case "status": + var s *state.State + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + s, err = state.Load(conn, questName) + return err + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 + } fmt.Printf("Phase: %s\n", s.Phase) fmt.Printf("Pending: %v\n", s.GatePending) fmt.Printf("Held: %v\n", s.Held) @@ -425,8 +490,12 @@ func runGate(args []string) int { return 0 case "approve": - var prevPhase, nextPhase, questName string - if err := state.WithLock(statePath, func(s *state.State) error { + var prevPhase, nextPhase string + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } if !s.GatePending { return fmt.Errorf("no gate pending") } @@ -436,66 +505,64 @@ func runGate(args []string) int { } prevPhase = s.Phase nextPhase = np - questName = s.QuestName s.GatePending = false s.Phase = nextPhase s.GateID = nil s.LembasCompleted = false s.MetadataUpdated = false + if err := state.Upsert(conn, s); err != nil { + return err + } + + tome.RecordGate(conn, questName, prevPhase, "approved", "") + tome.RecordPhase(conn, questName, prevPhase, 0) + + now := time.Now().UTC().Format(time.RFC3339) + herald.Announce(conn, herald.Tiding{ + Timestamp: now, Quest: questName, Type: herald.GateApproved, + Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), + }) + herald.Announce(conn, herald.Tiding{ + Timestamp: now, Quest: questName, Type: herald.PhaseTransition, + Phase: nextPhase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, nextPhase), + }) return nil }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") - c := tome.LoadOrCreate(tomePath) - tome.RecordGate(c, prevPhase, "approved") - tome.RecordPhase(c, prevPhase) - tome.Save(tomePath, c) - gateDir := filepath.Dir(filepath.Dir(statePath)) - if questName == "" { - questName = filepath.Base(gateDir) - } - now := time.Now().UTC().Format(time.RFC3339) - herald.Announce(gateDir, herald.Tiding{ - Timestamp: now, Quest: questName, Type: herald.GateApproved, - Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), - }) - herald.Announce(gateDir, herald.Tiding{ - Timestamp: now, Quest: questName, Type: herald.PhaseTransition, - Phase: nextPhase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, nextPhase), - }) fmt.Printf("Gate approved. Phase advanced to %s.\n", nextPhase) return 0 case "reject": - var phase, questName string - if err := state.WithLock(statePath, func(s *state.State) error { + var phase string + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } if !s.GatePending { return fmt.Errorf("no gate pending") } s.GatePending = false s.GateID = nil phase = s.Phase - questName = s.QuestName + if err := state.Upsert(conn, s); err != nil { + return err + } + + tome.RecordGate(conn, questName, phase, "rejected", "") + + herald.Announce(conn, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, Type: herald.GateRejected, + Phase: phase, Detail: fmt.Sprintf("Gate rejected for %s", phase), + }) return nil }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - tomePath := filepath.Join(filepath.Dir(statePath), "quest-tome.json") - c := tome.LoadOrCreate(tomePath) - tome.RecordGate(c, phase, "rejected") - tome.Save(tomePath, c) - rejDir := filepath.Dir(filepath.Dir(statePath)) - if questName == "" { - questName = filepath.Base(rejDir) - } - herald.Announce(rejDir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: questName, Type: herald.GateRejected, - Phase: phase, Detail: fmt.Sprintf("Gate rejected for %s", phase), - }) fmt.Println("Gate rejected. Teammate unblocked to address feedback.") return 0 @@ -505,8 +572,8 @@ func runGate(args []string) int { } } - -func runHold(args []string) int { +func runHold(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("hold", flag.ExitOnError) dir := fs.String("dir", "", "Worktree directory (required)") reason := fs.String("reason", "", "Reason for holding the quest") @@ -517,9 +584,22 @@ func runHold(args []string) int { return 1 } - statePath := filepath.Join(*dir, datadir.Name(), "quest-state.json") - var questName, phase string - if err := state.WithLock(statePath, func(s *state.State) error { + // Find quest for the given worktree dir. + var questName string + d.WithConn(ctx, func(conn *db.Conn) error { + questName, _ = state.FindQuest(conn, *dir) + return nil + }) + if questName == "" { + questName = filepath.Base(*dir) + } + + var phase string + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } if s.Held { return fmt.Errorf("quest is already held") } @@ -527,29 +607,28 @@ func runHold(args []string) int { if *reason != "" { s.HeldReason = reason } - questName = s.QuestName phase = s.Phase + if err := state.Upsert(conn, s); err != nil { + return err + } + + detail := "Quest held" + if *reason != "" { + detail += ": " + *reason + } + herald.Announce(conn, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.QuestHeld, + Phase: phase, + Detail: detail, + }) return nil }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - if questName == "" { - questName = filepath.Base(*dir) - } - detail := "Quest held" - if *reason != "" { - detail += ": " + *reason - } - herald.Announce(*dir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: questName, - Type: herald.QuestHeld, - Phase: phase, - Detail: detail, - }) - fmt.Printf("Quest held.%s\n", func() string { if *reason != "" { return " Reason: " + *reason @@ -559,7 +638,8 @@ func runHold(args []string) int { return 0 } -func runUnhold(args []string) int { +func runUnhold(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("unhold", flag.ExitOnError) dir := fs.String("dir", "", "Worktree directory (required)") fs.Parse(args) @@ -569,38 +649,48 @@ func runUnhold(args []string) int { return 1 } - statePath := filepath.Join(*dir, datadir.Name(), "quest-state.json") - var questName, phase string - if err := state.WithLock(statePath, func(s *state.State) error { + var questName string + d.WithConn(ctx, func(conn *db.Conn) error { + questName, _ = state.FindQuest(conn, *dir) + return nil + }) + if questName == "" { + questName = filepath.Base(*dir) + } + + if err := d.WithTx(ctx, func(conn *db.Conn) error { + s, err := state.Load(conn, questName) + if err != nil { + return err + } if !s.Held { return fmt.Errorf("quest is not held") } s.Held = false s.HeldReason = nil - questName = s.QuestName - phase = s.Phase + if err := state.Upsert(conn, s); err != nil { + return err + } + + herald.Announce(conn, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: questName, + Type: herald.QuestUnheld, + Phase: s.Phase, + Detail: "Quest unheld — resumed", + }) return nil }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - if questName == "" { - questName = filepath.Base(*dir) - } - herald.Announce(*dir, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: questName, - Type: herald.QuestUnheld, - Phase: phase, - Detail: "Quest unheld — resumed", - }) - fmt.Println("Quest unheld.") return 0 } -func runInit() int { +func runInit(d *db.DB) int { + ctx := context.Background() fs := flag.NewFlagSet("init", flag.ExitOnError) phase := fs.String("phase", "", "Initial phase (default: Onboard)") planSkip := fs.Bool("plan-skip", false, "Record Onboard/Research/Plan as skipped in tome") @@ -629,66 +719,71 @@ func runInit() int { if root == "" { root = gitRootOrCwd() } + + // Still create .fellowship/ directory marker. dataDir := filepath.Join(root, datadir.Name()) if err := os.MkdirAll(dataDir, 0755); err != nil { fmt.Fprintf(os.Stderr, "fellowship: creating data directory: %v\n", err) return 1 } - path := filepath.Join(dataDir, "quest-state.json") - if _, err := os.Stat(path); err == nil { - s, err := state.Load(path) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - s.GatePending = false - s.GateID = nil - if *phase != "" { - s.Phase = *phase - s.LembasCompleted = false - s.MetadataUpdated = false - } - if err := state.Save(path, s); err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - fmt.Printf("State file reset (gate_pending cleared, phase: %s).\n", s.Phase) - } else { - initPhase := "Onboard" - if *phase != "" { - initPhase = *phase - } - s := &state.State{ - Version: 1, - Phase: initPhase, - AutoApproveGates: []string{}, - } - if err := state.Save(path, s); err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - fmt.Printf("State file created at %s/quest-state.json (phase: %s)\n", datadir.Name(), initPhase) + // Determine quest name: explicit flag, or derive from directory. + qn := *questName + if qn == "" { + qn = filepath.Base(root) + } + + initPhase := "Onboard" + if *phase != "" { + initPhase = *phase } - if *planSkip { - tomePath := filepath.Join(dataDir, "quest-tome.json") - c := tome.LoadOrCreate(tomePath) - if *questName != "" { - c.QuestName = *questName + if err := d.WithTx(ctx, func(conn *db.Conn) error { + // Try to load existing state to reset it. + existing, loadErr := state.Load(conn, qn) + if loadErr == nil { + // Reset existing state. + existing.GatePending = false + existing.GateID = nil + if *phase != "" { + existing.Phase = *phase + existing.LembasCompleted = false + existing.MetadataUpdated = false + } + if err := state.Upsert(conn, existing); err != nil { + return err + } + fmt.Printf("State reset (gate_pending cleared, phase: %s).\n", existing.Phase) + } else { + // Create new state. + s := &state.State{ + QuestName: qn, + Phase: initPhase, + AutoApproveGates: []string{}, + } + if err := state.Upsert(conn, s); err != nil { + return err + } + fmt.Printf("Quest state created (quest: %s, phase: %s)\n", qn, initPhase) } - tome.RecordSkippedPhases(c, []string{"Onboard", "Research", "Plan"}, "pre-existing plan") - if err := tome.Save(tomePath, c); err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 + + if *planSkip { + if err := tome.RecordSkippedPhases(conn, qn, []string{"Onboard", "Research", "Plan"}, "pre-existing plan"); err != nil { + return err + } + fmt.Println("Recorded Onboard/Research/Plan as skipped (pre-existing plan).") } - fmt.Println("Recorded Onboard/Research/Plan as skipped (pre-existing plan).") + return nil + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 } return 0 } -func runStatus(args []string) int { +func runStatus(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("status", flag.ExitOnError) dir := fs.String("dir", "", "Git repo root (default: auto-detect)") jsonOut := fs.Bool("json", false, "Output as JSON") @@ -699,8 +794,12 @@ func runStatus(args []string) int { root = gitRootOrCwd() } - result, err := status.Scan(root) - if err != nil { + var result *status.StatusResult + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + result, err = status.Scan(conn, root) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -748,7 +847,8 @@ func runStatus(args []string) int { return 0 } -func runEagles(args []string) int { +func runEagles(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("eagles", flag.ExitOnError) dir := fs.String("dir", "", "Git repo root (default: auto-detect)") threshold := fs.Int("threshold", 10, "Gate pending timeout in minutes") @@ -763,13 +863,17 @@ func runEagles(args []string) int { opts := eagles.DefaultOptions() opts.GateThreshold = time.Duration(*threshold) * time.Minute - report, err := eagles.Sweep(root, opts) - if err != nil { + var report *eagles.EaglesReport + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + report, err = eagles.Sweep(conn, opts) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - // Write report to data directory + // Write report to data directory. if err := eagles.WriteReport(root, report); err != nil { fmt.Fprintf(os.Stderr, "fellowship: warning: %v\n", err) } @@ -784,20 +888,19 @@ func runEagles(args []string) int { return 0 } -func runDashboard(args []string) int { +func runDashboard(d *db.DB, args []string) int { fs := flag.NewFlagSet("dashboard", flag.ExitOnError) port := fs.Int("port", 3000, "HTTP port") poll := fs.Int("poll", 5, "Poll interval in seconds") fs.Parse(args) - root := gitRootOrCwd() - srv := dashboard.NewServer(root, *poll) + srv := dashboard.NewServer(d, *poll) addr := fmt.Sprintf("localhost:%d", *port) url := fmt.Sprintf("http://%s", addr) fmt.Printf("Fellowship dashboard: %s\n", url) - // Open browser + // Open browser. switch runtime.GOOS { case "darwin": exec.Command("open", url).Start() @@ -812,58 +915,52 @@ func runDashboard(args []string) int { return 0 } -func runAutopsy(args []string) int { +func runAutopsy(d *db.DB, args []string) int { switch args[0] { case "create": - return runAutopsyCreate(args[1:]) + return runAutopsyCreate(d, args[1:]) case "scan": - return runAutopsyScan(args[1:]) + return runAutopsyScan(d, args[1:]) case "infer": - return runAutopsyInfer(args[1:]) + return runAutopsyInfer(d, args[1:]) default: fmt.Fprintf(os.Stderr, "unknown autopsy command: %s\n", args[0]) return 1 } } -func runAutopsyCreate(args []string) int { +func runAutopsyCreate(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("autopsy create", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") fs.Parse(args) - root := *dir - if root == "" { - root = gitRootOrCwd() - } - var input autopsy.CreateInput if err := json.NewDecoder(os.Stdin).Decode(&input); err != nil { fmt.Fprintf(os.Stderr, "fellowship: reading JSON from stdin: %v\n", err) return 1 } - path, err := autopsy.Create(root, &input) - if err != nil { + var id int64 + if err := d.WithTx(ctx, func(conn *db.Conn) error { + var err error + id, err = autopsy.Create(conn, &input) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - fmt.Printf("Autopsy written to %s\n", path) + fmt.Printf("Autopsy created (id=%d)\n", id) return 0 } -func runAutopsyScan(args []string) int { +func runAutopsyScan(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("autopsy scan", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") files := fs.String("files", "", "Comma-separated file paths to match") modules := fs.String("modules", "", "Comma-separated module names to match") tags := fs.String("tags", "", "Comma-separated tags to match") fs.Parse(args) - root := *dir - if root == "" { - root = gitRootOrCwd() - } - opts := autopsy.ScanOptions{} if *files != "" { opts.Files = strings.Split(*files, ",") @@ -876,8 +973,13 @@ func runAutopsyScan(args []string) int { } expiryDays := datadir.AutopsyExpiryDays(autopsy.DefaultExpiryDays) - matches, err := autopsy.Scan(root, opts, expiryDays) - if err != nil { + + var matches []autopsy.Autopsy + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + matches, err = autopsy.Scan(conn, opts, expiryDays) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -887,56 +989,43 @@ func runAutopsyScan(args []string) int { return 0 } -func runAutopsyInfer(args []string) int { +func runAutopsyInfer(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("autopsy infer", flag.ExitOnError) - dir := fs.String("dir", "", "Quest worktree directory (required)") - repo := fs.String("repo", "", "Git repo root for storing autopsy (default: auto-detect)") + quest := fs.String("quest", "", "Quest name (required)") fs.Parse(args) - if *dir == "" { - fmt.Fprintln(os.Stderr, "usage: fellowship autopsy infer --dir [--repo DIR]") + if *quest == "" { + fmt.Fprintln(os.Stderr, "usage: fellowship autopsy infer --quest ") return 1 } - root := *repo - if root == "" { - root = gitRootOrCwd() - } - - path, err := autopsy.Infer(*dir, root) - if err != nil { + var id int64 + if err := d.WithTx(ctx, func(conn *db.Conn) error { + var err error + id, err = autopsy.Infer(conn, *quest) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - fmt.Printf("Inferred autopsy written to %s\n", path) + fmt.Printf("Inferred autopsy created (id=%d)\n", id) return 0 } -func runHerald(args []string) int { +func runHerald(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("herald", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") problems := fs.Bool("problems", false, "Show only detected problems") jsonOut := fs.Bool("json", false, "Output as JSON") fs.Parse(args) - root := *dir - if root == "" { - root = gitRootOrCwd() - } - - ds, err := dashboard.DiscoverQuests(root) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - - var worktrees []string - for _, q := range ds.Quests { - worktrees = append(worktrees, q.Worktree) - } - if *problems { - detected := herald.DetectProblems(worktrees) + var detected []herald.Problem + d.WithConn(ctx, func(conn *db.Conn) error { + detected = herald.DetectProblems(conn) + return nil + }) if *jsonOut { data, _ := json.MarshalIndent(detected, "", " ") fmt.Println(string(data)) @@ -955,8 +1044,12 @@ func runHerald(args []string) int { return 0 } - evts, err := herald.ReadAll(worktrees, 20) - if err != nil { + var evts []herald.Tiding + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + evts, err = herald.ReadAll(conn, 20) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -981,22 +1074,19 @@ func runHerald(args []string) int { return 0 } -func runCompany(args []string) int { +func runCompany(d *db.DB, args []string) int { + ctx := context.Background() sub := args[0] rest := args[1:] switch sub { case "list": fs := flag.NewFlagSet("company list", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") fs.Parse(rest) - root := *dir - if root == "" { - root = gitRootOrCwd() - } - statePath := filepath.Join(root, datadir.Name(), "fellowship-state.json") - if err := company.List(statePath); err != nil { + if err := d.WithConn(ctx, func(conn *db.Conn) error { + return company.List(conn) + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1004,21 +1094,17 @@ func runCompany(args []string) int { case "show": fs := flag.NewFlagSet("company show", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") fs.Parse(rest) if fs.NArg() < 1 { - fmt.Fprintln(os.Stderr, "usage: fellowship company show [--dir PATH]") + fmt.Fprintln(os.Stderr, "usage: fellowship company show ") return 1 } name := fs.Arg(0) - root := *dir - if root == "" { - root = gitRootOrCwd() - } - statePath := filepath.Join(root, datadir.Name(), "fellowship-state.json") - if err := company.Show(statePath, name); err != nil { + if err := d.WithConn(ctx, func(conn *db.Conn) error { + return company.Show(conn, name) + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1026,21 +1112,17 @@ func runCompany(args []string) int { case "approve": fs := flag.NewFlagSet("company approve", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") fs.Parse(rest) if fs.NArg() < 1 { - fmt.Fprintln(os.Stderr, "usage: fellowship company approve [--dir PATH]") + fmt.Fprintln(os.Stderr, "usage: fellowship company approve ") return 1 } name := fs.Arg(0) - root := *dir - if root == "" { - root = gitRootOrCwd() - } - statePath := filepath.Join(root, datadir.Name(), "fellowship-state.json") - if err := company.Approve(statePath, name); err != nil { + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return company.Approve(conn, name) + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1052,34 +1134,38 @@ func runCompany(args []string) int { } } -func runTome(args []string) int { +func runTome(d *db.DB, args []string) int { + ctx := context.Background() if len(args) < 1 || args[0] != "show" { - fmt.Fprintln(os.Stderr, "usage: fellowship tome show [--dir ] [--json]") + fmt.Fprintln(os.Stderr, "usage: fellowship tome show [--quest ] [--json]") return 1 } fs := flag.NewFlagSet("tome show", flag.ExitOnError) - dir := fs.String("dir", "", "Directory to search for tome (default: auto-detect)") + quest := fs.String("quest", "", "Quest name (default: auto-detect from worktree)") jsonOut := fs.Bool("json", false, "Output as JSON") fs.Parse(args[1:]) - searchDir := *dir - if searchDir == "" { - searchDir = gitRootOrCwd() - } - - tomePath, err := tome.FindTome(searchDir) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 + questName := *quest + if questName == "" { + cwd, _ := os.Getwd() + gitRoot := gitRootFrom(cwd) + d.WithConn(ctx, func(conn *db.Conn) error { + questName, _ = state.FindQuest(conn, gitRoot) + return nil + }) } - if tomePath == "" { - fmt.Fprintln(os.Stderr, "No quest tome found.") + if questName == "" { + fmt.Fprintln(os.Stderr, "fellowship: no quest found. Use --quest .") return 1 } - c, err := tome.Load(tomePath) - if err != nil { + var c *tome.QuestTome + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + c, err = tome.Load(conn, questName) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1100,8 +1186,8 @@ func runTome(args []string) int { fmt.Println("Phases Completed:") for _, p := range c.PhasesCompleted { dur := "" - if p.Duration != "" { - dur = fmt.Sprintf(" (%s)", p.Duration) + if p.DurationS > 0 { + dur = fmt.Sprintf(" (%ds)", p.DurationS) } fmt.Printf(" - %s at %s%s\n", p.Phase, p.CompletedAt, dur) } @@ -1130,7 +1216,7 @@ func runTome(args []string) int { return 0 } -func runErrand(args []string) int { +func runErrand(d *db.DB, args []string) int { if len(args) < 1 { fmt.Fprintln(os.Stderr, "usage: fellowship errand ") return 1 @@ -1138,80 +1224,79 @@ func runErrand(args []string) int { switch args[0] { case "init": - return runErrandInit(args[1:]) + return runErrandInit(d, args[1:]) case "list": - return runErrandList(args[1:]) + return runErrandList(d, args[1:]) case "add": - return runErrandAdd(args[1:]) + return runErrandAdd(d, args[1:]) case "update": - return runErrandUpdate(args[1:]) + return runErrandUpdate(d, args[1:]) case "show": - return runErrandShow(args[1:]) + return runErrandShow(d, args[1:]) default: fmt.Fprintf(os.Stderr, "unknown errand command: %s\n", args[0]) return 1 } } -func runErrandInit(args []string) int { +func runErrandInit(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("errand init", flag.ExitOnError) - dir := fs.String("dir", "", "Worktree directory") quest := fs.String("quest", "", "Quest name") task := fs.String("task", "", "Task description") fs.Parse(args) - root := *dir - if root == "" { - root = gitRootOrCwd() - } - - errandDir := filepath.Join(root, datadir.Name()) - if err := os.MkdirAll(errandDir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "fellowship: creating data directory: %v\n", err) - return 1 - } - errandPath := filepath.Join(errandDir, "quest-errands.json") - - if _, err := os.Stat(errandPath); err == nil { - fmt.Fprintln(os.Stderr, "fellowship: quest-errands.json already exists") + if *quest == "" { + fmt.Fprintln(os.Stderr, "usage: fellowship errand init --quest [--task \"desc\"]") return 1 } - now := time.Now().UTC().Format(time.RFC3339) - h := &errand.QuestErrandList{ - Version: 1, - QuestName: *quest, - Task: *task, - Items: []errand.Errand{}, - CreatedAt: now, - UpdatedAt: now, - } - - if err := errand.Save(errandPath, h); err != nil { + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return errand.Init(conn, *quest, *task) + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - fmt.Printf("Errand file created at %s\n", errandPath) + fmt.Printf("Errand tracking initialized for quest %q\n", *quest) return 0 } -func runErrandList(args []string) int { +func runErrandList(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("errand list", flag.ExitOnError) - dir := fs.String("dir", "", "Worktree directory") + quest := fs.String("quest", "", "Quest name") fs.Parse(args) - h, _, err := loadErrandFile(*dir) - if err != nil { + questName := *quest + if questName == "" { + questName = autoDetectQuest(d) + } + if questName == "" { + fmt.Fprintln(os.Stderr, "fellowship: no quest found. Use --quest .") + return 1 + } + + var items []errand.Errand + var done, total int + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + items, err = errand.List(conn, questName) + if err != nil { + return err + } + done, total, err = errand.Progress(conn, questName) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - if len(h.Items) == 0 { + if len(items) == 0 { fmt.Println("No errands.") return 0 } - for _, item := range h.Items { + for _, item := range items { phase := "" if item.Phase != "" { phase = fmt.Sprintf(" [%s]", item.Phase) @@ -1223,31 +1308,38 @@ func runErrandList(args []string) int { fmt.Printf("%-6s %-8s %s%s%s\n", item.ID, item.Status, item.Description, phase, deps) } - done, total := errand.Progress(h) fmt.Printf("\nProgress: %d/%d done\n", done, total) return 0 } -func runErrandAdd(args []string) int { +func runErrandAdd(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("errand add", flag.ExitOnError) - dir := fs.String("dir", "", "Worktree directory") + quest := fs.String("quest", "", "Quest name") phase := fs.String("phase", "", "Quest phase") fs.Parse(args) desc := strings.Join(fs.Args(), " ") if desc == "" { - fmt.Fprintln(os.Stderr, "usage: fellowship errand add --dir \"description\"") + fmt.Fprintln(os.Stderr, "usage: fellowship errand add --quest \"description\"") return 1 } - h, errandPath, err := loadErrandFile(*dir) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + questName := *quest + if questName == "" { + questName = autoDetectQuest(d) + } + if questName == "" { + fmt.Fprintln(os.Stderr, "fellowship: no quest found. Use --quest .") return 1 } - id := errand.AddErrand(h, desc, *phase) - if err := errand.Save(errandPath, h); err != nil { + var id string + if err := d.WithTx(ctx, func(conn *db.Conn) error { + var err error + id, err = errand.Add(conn, questName, desc, *phase) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1255,14 +1347,15 @@ func runErrandAdd(args []string) int { return 0 } -func runErrandUpdate(args []string) int { +func runErrandUpdate(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("errand update", flag.ExitOnError) - dir := fs.String("dir", "", "Worktree directory") + quest := fs.String("quest", "", "Quest name") fs.Parse(args) remaining := fs.Args() if len(remaining) < 2 { - fmt.Fprintln(os.Stderr, "usage: fellowship errand update --dir ") + fmt.Fprintln(os.Stderr, "usage: fellowship errand update --quest ") return 1 } @@ -1275,17 +1368,18 @@ func runErrandUpdate(args []string) int { return 1 } - h, errandPath, err := loadErrandFile(*dir) - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 + questName := *quest + if questName == "" { + questName = autoDetectQuest(d) } - - if err := errand.UpdateStatus(h, id, ws); err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + if questName == "" { + fmt.Fprintln(os.Stderr, "fellowship: no quest found. Use --quest .") return 1 } - if err := errand.Save(errandPath, h); err != nil { + + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return errand.UpdateStatus(conn, questName, id, ws) + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1293,98 +1387,100 @@ func runErrandUpdate(args []string) int { return 0 } -func runErrandShow(args []string) int { +func runErrandShow(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("errand show", flag.ExitOnError) - dir := fs.String("dir", "", "Worktree directory") + quest := fs.String("quest", "", "Quest name") fs.Parse(args) - h, _, err := loadErrandFile(*dir) - if err != nil { + questName := *quest + if questName == "" { + questName = autoDetectQuest(d) + } + if questName == "" { + fmt.Fprintln(os.Stderr, "fellowship: no quest found. Use --quest .") + return 1 + } + + var list *errand.QuestErrandList + if err := d.WithConn(ctx, func(conn *db.Conn) error { + items, err := errand.List(conn, questName) + if err != nil { + return err + } + list = &errand.QuestErrandList{ + QuestName: questName, + Items: items, + } + return nil + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - data, _ := json.MarshalIndent(h, "", " ") + data, _ := json.MarshalIndent(list, "", " ") fmt.Println(string(data)) return 0 } -func runState(args []string) int { +func runState(d *db.DB, args []string) int { switch args[0] { case "init": - return runStateInit(args[1:]) + return runStateInit(d, args[1:]) case "add-quest": - return runStateAddQuest(args[1:]) + return runStateAddQuest(d, args[1:]) case "add-scout": - return runStateAddScout(args[1:]) + return runStateAddScout(d, args[1:]) case "update-quest": - return runStateUpdateQuest(args[1:]) + return runStateUpdateQuest(d, args[1:]) case "add-company": - return runStateAddCompany(args[1:]) + return runStateAddCompany(d, args[1:]) case "show": - return runStateShow(args[1:]) + return runStateShow(d, args[1:]) case "clean-worktrees": - return runStateCleanWorktrees(args[1:]) + return runStateCleanWorktrees(d, args[1:]) default: fmt.Fprintf(os.Stderr, "unknown state command: %s\n", args[0]) return 1 } } -func runStateInit(args []string) int { +func runStateInit(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("state init", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") name := fs.String("name", "", "Fellowship name (required)") baseBranch := fs.String("base-branch", "", "Base branch for quest worktrees (Gandalf detects automatically; use this to override)") fs.Parse(args) if *name == "" { - fmt.Fprintln(os.Stderr, "usage: fellowship state init --name [--dir PATH] [--base-branch BRANCH]") + fmt.Fprintln(os.Stderr, "usage: fellowship state init --name [--base-branch BRANCH]") return 1 } - root := *dir - if root == "" { - root = gitRootOrCwd() - } - - dataDirPath := filepath.Join(root, datadir.Name()) - if err := os.MkdirAll(dataDirPath, 0755); err != nil { - fmt.Fprintf(os.Stderr, "fellowship: creating data directory: %v\n", err) - return 1 - } - statePath := filepath.Join(dataDirPath, "fellowship-state.json") + root := gitRootOrCwd() - if _, err := os.Stat(statePath); err == nil { - if existing, loadErr := dashboard.LoadFellowshipState(statePath); loadErr == nil { - fmt.Fprintf(os.Stderr, "fellowship: warning: overwriting existing fellowship-state.json (name=%q, quests=%d)\n", + // Check for existing fellowship to warn about overwrite. + d.WithConn(ctx, func(conn *db.Conn) error { + if existing, err := dashboard.LoadFellowship(conn); err == nil { + fmt.Fprintf(os.Stderr, "fellowship: warning: overwriting existing fellowship (name=%q, quests=%d)\n", existing.Name, len(existing.Quests)) - } else { - fmt.Fprintln(os.Stderr, "fellowship: warning: overwriting existing fellowship-state.json") } - } + return nil + }) - s := &dashboard.FellowshipState{ - Version: 1, - Name: *name, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - MainRepo: root, - BaseBranch: *baseBranch, - Quests: []dashboard.QuestEntry{}, - Scouts: []dashboard.ScoutEntry{}, - Companies: []dashboard.CompanyEntry{}, - } - if err := dashboard.SaveFellowshipState(statePath, s); err != nil { + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return dashboard.InitFellowship(conn, *name, root, *baseBranch) + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } - fmt.Printf("Fellowship state created at %s\n", statePath) + fmt.Printf("Fellowship %q initialized\n", *name) return 0 } -func runStateAddQuest(args []string) int { +func runStateAddQuest(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("state add-quest", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") name := fs.String("name", "", "Quest name (required)") task := fs.String("task", "", "Task description (required)") branch := fs.String("branch", "", "Branch name") @@ -1393,26 +1489,18 @@ func runStateAddQuest(args []string) int { fs.Parse(args) if *name == "" || *task == "" { - fmt.Fprintln(os.Stderr, "usage: fellowship state add-quest --name --task \"\" [--dir PATH] [--branch BRANCH] [--worktree PATH] [--task-id ID]") + fmt.Fprintln(os.Stderr, "usage: fellowship state add-quest --name --task \"\" [--branch BRANCH] [--worktree PATH] [--task-id ID]") return 1 } - statePath := fellowshipStatePath(*dir) - questName := *name - if err := dashboard.WithStateLock(statePath, func(s *dashboard.FellowshipState) error { - for _, q := range s.Quests { - if q.Name == questName { - return fmt.Errorf("quest %q already exists", questName) - } - } - s.Quests = append(s.Quests, dashboard.QuestEntry{ + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return dashboard.AddQuest(conn, dashboard.QuestEntry{ Name: *name, TaskDescription: *task, Worktree: *worktree, Branch: *branch, TaskID: *taskID, }) - return nil }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 @@ -1421,33 +1509,25 @@ func runStateAddQuest(args []string) int { return 0 } -func runStateAddScout(args []string) int { +func runStateAddScout(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("state add-scout", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") name := fs.String("name", "", "Scout name (required)") question := fs.String("question", "", "Research question (required)") taskID := fs.String("task-id", "", "Task ID") fs.Parse(args) if *name == "" || *question == "" { - fmt.Fprintln(os.Stderr, "usage: fellowship state add-scout --name --question \"\" [--dir PATH] [--task-id ID]") + fmt.Fprintln(os.Stderr, "usage: fellowship state add-scout --name --question \"\" [--task-id ID]") return 1 } - statePath := fellowshipStatePath(*dir) - scoutName := *name - if err := dashboard.WithStateLock(statePath, func(s *dashboard.FellowshipState) error { - for _, sc := range s.Scouts { - if sc.Name == scoutName { - return fmt.Errorf("scout %q already exists", scoutName) - } - } - s.Scouts = append(s.Scouts, dashboard.ScoutEntry{ + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return dashboard.AddScout(conn, dashboard.ScoutEntry{ Name: *name, Question: *question, TaskID: *taskID, }) - return nil }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 @@ -1456,40 +1536,30 @@ func runStateAddScout(args []string) int { return 0 } -func runStateAddCompany(args []string) int { +func runStateAddCompany(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("state add-company", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") name := fs.String("name", "", "Company name (required)") quests := fs.String("quests", "", "Comma-separated quest names") scouts := fs.String("scouts", "", "Comma-separated scout names") fs.Parse(args) if *name == "" { - fmt.Fprintln(os.Stderr, "usage: fellowship state add-company --name [--quests q1,q2] [--scouts s1,s2] [--dir PATH]") + fmt.Fprintln(os.Stderr, "usage: fellowship state add-company --name [--quests q1,q2] [--scouts s1,s2]") return 1 } - statePath := fellowshipStatePath(*dir) - companyName := *name - if err := dashboard.WithStateLock(statePath, func(s *dashboard.FellowshipState) error { - for _, c := range s.Companies { - if c.Name == companyName { - return fmt.Errorf("company %q already exists", companyName) - } - } - entry := dashboard.CompanyEntry{Name: *name} - if *quests != "" { - entry.Quests = strings.Split(*quests, ",") - } else { - entry.Quests = []string{} - } - if *scouts != "" { - entry.Scouts = strings.Split(*scouts, ",") - } else { - entry.Scouts = []string{} - } - s.Companies = append(s.Companies, entry) - return nil + questList := []string{} + if *quests != "" { + questList = strings.Split(*quests, ",") + } + scoutList := []string{} + if *scouts != "" { + scoutList = strings.Split(*scouts, ",") + } + + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return dashboard.AddCompany(conn, *name, questList, scoutList) }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 @@ -1498,9 +1568,9 @@ func runStateAddCompany(args []string) int { return 0 } -func runStateUpdateQuest(args []string) int { +func runStateUpdateQuest(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("state update-quest", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") name := fs.String("name", "", "Quest name (required)") worktree := fs.String("worktree", "", "Worktree path") branch := fs.String("branch", "", "Branch name") @@ -1509,7 +1579,7 @@ func runStateUpdateQuest(args []string) int { fs.Parse(args) if *name == "" { - fmt.Fprintln(os.Stderr, "usage: fellowship state update-quest --name [--worktree PATH] [--branch BRANCH] [--task-id ID] [--status STATUS] [--dir PATH]") + fmt.Fprintln(os.Stderr, "usage: fellowship state update-quest --name [--worktree PATH] [--branch BRANCH] [--task-id ID] [--status STATUS]") return 1 } @@ -1518,27 +1588,22 @@ func runStateUpdateQuest(args []string) int { return 1 } - statePath := fellowshipStatePath(*dir) - questName := *name - if err := dashboard.WithStateLock(statePath, func(s *dashboard.FellowshipState) error { - for i := range s.Quests { - if s.Quests[i].Name == questName { - if *worktree != "" { - s.Quests[i].Worktree = *worktree - } - if *branch != "" { - s.Quests[i].Branch = *branch - } - if *taskID != "" { - s.Quests[i].TaskID = *taskID - } - if *statusFlag != "" { - s.Quests[i].Status = *statusFlag - } - return nil - } - } - return fmt.Errorf("quest %q not found", questName) + updates := make(map[string]any) + if *worktree != "" { + updates["worktree"] = *worktree + } + if *branch != "" { + updates["branch"] = *branch + } + if *taskID != "" { + updates["task_id"] = *taskID + } + if *statusFlag != "" { + updates["status"] = *statusFlag + } + + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return dashboard.UpdateQuest(conn, *name, updates) }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 @@ -1547,14 +1612,17 @@ func runStateUpdateQuest(args []string) int { return 0 } -func runStateShow(args []string) int { +func runStateShow(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("state show", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") fs.Parse(args) - statePath := fellowshipStatePath(*dir) - s, err := dashboard.LoadFellowshipState(statePath) - if err != nil { + var s *dashboard.FellowshipState + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + s, err = dashboard.LoadFellowship(conn) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1564,112 +1632,113 @@ func runStateShow(args []string) int { return 0 } -func runStateCleanWorktrees(args []string) int { +func runStateCleanWorktrees(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("state clean-worktrees", flag.ExitOnError) - dir := fs.String("dir", "", "Git repo root (default: auto-detect)") fs.Parse(args) - root := *dir - if root == "" { - root = gitRootOrCwd() + type cleanResult struct { + name string + wasPending bool + wasHeld bool } - worktreesDir := filepath.Join(root, ".claude", "worktrees") - entries, err := os.ReadDir(worktreesDir) - if err != nil { - if os.IsNotExist(err) { - fmt.Println("No worktrees directory found.") - return 0 + var cleaned []cleanResult + if err := d.WithTx(ctx, func(conn *db.Conn) error { + // Query all quest_state rows that have stale flags. + type staleQuest struct { + name string + gatePending bool + held bool } - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - - cleaned := 0 - for _, entry := range entries { - if !entry.IsDir() { - continue - } - statePath := filepath.Join(worktreesDir, entry.Name(), datadir.Name(), "quest-state.json") - if _, err := os.Stat(statePath); err != nil { - if !os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "fellowship: warning: could not access %s: %v\n", statePath, err) - } - continue + var stale []staleQuest + if err := sqliteExecRows(conn, `SELECT quest_name, gate_pending, held FROM quest_state WHERE gate_pending = 1 OR held = 1`, + func(name string, gp, h bool) { + stale = append(stale, staleQuest{name, gp, h}) + }); err != nil { + return err } - var prevPending, prevHeld bool - err := state.WithLock(statePath, func(s *state.State) error { - if !s.GatePending && !s.Held { - return state.ErrNoSave + + for _, sq := range stale { + s, err := state.Load(conn, sq.name) + if err != nil { + continue } - prevPending, prevHeld = s.GatePending, s.Held s.GatePending = false s.GateID = nil s.Held = false s.HeldReason = nil - return nil - }) - if err == state.ErrNoSave { - continue - } - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: warning: could not save %s: %v\n", statePath, err) - continue + if err := state.Upsert(conn, s); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: warning: could not clean %s: %v\n", sq.name, err) + continue + } + cleaned = append(cleaned, cleanResult{sq.name, sq.gatePending, sq.held}) } - fmt.Printf("Cleared stale state in %s (gate_pending=%v, held=%v)\n", entry.Name(), prevPending, prevHeld) - cleaned++ + return nil + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 } - if cleaned == 0 { + if len(cleaned) == 0 { fmt.Println("No stale state found.") } else { - fmt.Printf("Cleaned %d worktree(s).\n", cleaned) + for _, c := range cleaned { + fmt.Printf("Cleared stale state in %s (gate_pending=%v, held=%v)\n", c.name, c.wasPending, c.wasHeld) + } + fmt.Printf("Cleaned %d quest(s).\n", len(cleaned)) } return 0 } -func fellowshipStatePath(dir string) string { - root := dir - if root == "" { - root = gitRootOrCwd() - } - return filepath.Join(root, datadir.Name(), "fellowship-state.json") +// sqliteExecRows is a tiny helper for the clean-worktrees query. +func sqliteExecRows(conn *db.Conn, query string, fn func(name string, gatePending, held bool)) error { + return execSqlite(conn, query, func(name string, gp, h int) { + fn(name, gp != 0, h != 0) + }) } -func loadErrandFile(dir string) (*errand.QuestErrandList, string, error) { - root := dir - if root == "" { - root = gitRootOrCwd() - } - errandPath := filepath.Join(root, datadir.Name(), "quest-errands.json") - h, err := errand.Load(errandPath) +func execSqlite(conn *db.Conn, query string, fn func(name string, gp, h int)) error { + stmt, _, err := conn.PrepareTransient(query) if err != nil { - return nil, "", err + return err + } + defer stmt.Finalize() + for { + hasRow, err := stmt.Step() + if err != nil { + return err + } + if !hasRow { + break + } + fn(stmt.ColumnText(0), stmt.ColumnInt(1), stmt.ColumnInt(2)) } - return h, errandPath, nil + return nil } -func runBulletin(args []string) int { +func runBulletin(d *db.DB, args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "usage: fellowship bulletin ") return 1 } switch args[0] { case "post": - return runBulletinPost(args[1:]) + return runBulletinPost(d, args[1:]) case "scan": - return runBulletinScan(args[1:]) + return runBulletinScan(d, args[1:]) case "list": - return runBulletinList(args[1:]) + return runBulletinList(d, args[1:]) case "clear": - return runBulletinClear(args[1:]) + return runBulletinClear(d, args[1:]) default: fmt.Fprintf(os.Stderr, "unknown bulletin command: %s\n", args[0]) return 1 } } -func runBulletinPost(args []string) int { +func runBulletinPost(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("bulletin post", flag.ExitOnError) quest := fs.String("quest", "", "Quest name") topic := fs.String("topic", "", "Topic tag") @@ -1684,19 +1753,15 @@ func runBulletinPost(args []string) int { fileList := splitCSV(*files) - path, err := bulletin.BulletinPath("") - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - entry := bulletin.Entry{ Quest: *quest, Topic: *topic, Files: fileList, Discovery: *discovery, } - if err := bulletin.Post(path, entry); err != nil { + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return bulletin.Post(conn, entry) + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1704,7 +1769,8 @@ func runBulletinPost(args []string) int { return 0 } -func runBulletinScan(args []string) int { +func runBulletinScan(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("bulletin scan", flag.ExitOnError) files := fs.String("files", "", "Comma-separated file paths to match") topics := fs.String("topics", "", "Comma-separated topics to match") @@ -1714,14 +1780,12 @@ func runBulletinScan(args []string) int { fileList := splitCSV(*files) topicList := splitCSV(*topics) - path, err := bulletin.BulletinPath("") - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - - entries, err := bulletin.Scan(path, fileList, topicList) - if err != nil { + var entries []bulletin.Entry + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + entries, err = bulletin.Scan(conn, fileList, topicList) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1744,19 +1808,18 @@ func runBulletinScan(args []string) int { return 0 } -func runBulletinList(args []string) int { +func runBulletinList(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("bulletin list", flag.ExitOnError) jsonOut := fs.Bool("json", false, "Output as JSON") fs.Parse(args) - path, err := bulletin.BulletinPath("") - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - - entries, err := bulletin.Load(path) - if err != nil { + var entries []bulletin.Entry + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + entries, err = bulletin.Load(conn) + return err + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1793,7 +1856,8 @@ func runBulletinList(args []string) int { return 0 } -func runBulletinClear(args []string) int { +func runBulletinClear(d *db.DB, args []string) int { + ctx := context.Background() fs := flag.NewFlagSet("bulletin clear", flag.ExitOnError) fs.Parse(args) if fs.NArg() != 0 { @@ -1801,12 +1865,9 @@ func runBulletinClear(args []string) int { return 1 } - path, err := bulletin.BulletinPath("") - if err != nil { - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 - } - if err := bulletin.Clear(path); err != nil { + if err := d.WithTx(ctx, func(conn *db.Conn) error { + return bulletin.Clear(conn) + }); err != nil { fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) return 1 } @@ -1839,3 +1900,56 @@ func gitRootOrCwd() string { } return strings.TrimSpace(string(out)) } + +// gitRootFrom returns the git root for a given directory. +func gitRootFrom(dir string) string { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return dir + } + return strings.TrimSpace(string(out)) +} + +// autoDetectQuest tries to find the quest name for the current worktree. +func autoDetectQuest(d *db.DB) string { + cwd, _ := os.Getwd() + gitRoot := gitRootFrom(cwd) + var questName string + d.WithConn(context.Background(), func(conn *db.Conn) error { + questName, _ = state.FindQuest(conn, gitRoot) + return nil + }) + return questName +} + +// jsonFilesExist checks whether legacy JSON state files exist in the .fellowship +// directory, indicating a migration is needed. +func jsonFilesExist(fromDir string) bool { + cmd := exec.Command("git", "rev-parse", "--git-common-dir") + cmd.Dir = fromDir + out, err := cmd.Output() + if err != nil { + return false + } + gitCommon := strings.TrimSpace(string(out)) + if !filepath.IsAbs(gitCommon) { + gitCommon = filepath.Join(fromDir, gitCommon) + } + gitCommon = filepath.Clean(gitCommon) + + var mainRepo string + if filepath.Base(gitCommon) == ".git" { + mainRepo = filepath.Dir(gitCommon) + } else { + mainRepo = filepath.Dir(gitCommon) + } + dataDir := filepath.Join(mainRepo, ".fellowship") + for _, name := range []string{"fellowship-state.json", "quest-state.json"} { + if _, err := os.Stat(filepath.Join(dataDir, name)); err == nil { + return true + } + } + return false +} diff --git a/cli/internal/hooks/enrich.go b/cli/internal/hooks/enrich.go index e2ae51b..1a7224a 100644 --- a/cli/internal/hooks/enrich.go +++ b/cli/internal/hooks/enrich.go @@ -11,40 +11,32 @@ import ( "github.com/justinjdev/fellowship/cli/internal/errand" "github.com/justinjdev/fellowship/cli/internal/herald" "github.com/justinjdev/fellowship/cli/internal/tome" + "zombiezen.com/go/sqlite" ) -// GatherEnrichment collects quest metrics from the worktree directory +// GatherEnrichment collects quest metrics from the DB and worktree directory // and returns a formatted enrichment block to append to gate messages. // Returns empty string if no data sources are available. -func GatherEnrichment(dir string) string { - errandStr := gatherErrandProgress(dir) - filesStr := gatherFilesTouched(dir) +func GatherEnrichment(conn *sqlite.Conn, questName string, dir string) string { + errandStr := gatherErrandProgress(conn, questName) + filesStr := gatherFilesTouched(conn, questName) diffStr := gatherDiffStats(dir) - durationStr := gatherPhaseDuration(dir) + durationStr := gatherPhaseDuration(conn, questName) block := buildEnrichmentBlock(errandStr, filesStr, diffStr, durationStr) return block } -func gatherErrandProgress(dir string) string { - path, err := errand.FindErrands(dir) - if err != nil || path == "" { +func gatherErrandProgress(conn *sqlite.Conn, questName string) string { + done, total, err := errand.Progress(conn, questName) + if err != nil || total == 0 { return "" } - el, err := errand.Load(path) - if err != nil { - return "" - } - done, total := errand.Progress(el) return formatErrandProgress(done, total) } -func gatherFilesTouched(dir string) string { - path, err := tome.FindTome(dir) - if err != nil || path == "" { - return "" - } - t, err := tome.Load(path) +func gatherFilesTouched(conn *sqlite.Conn, questName string) string { + t, err := tome.Load(conn, questName) if err != nil { return "" } @@ -63,8 +55,8 @@ func gatherDiffStats(dir string) string { return parseDiffStats(string(out)) } -func gatherPhaseDuration(dir string) string { - tidings, err := herald.Read(dir, 0) +func gatherPhaseDuration(conn *sqlite.Conn, questName string) string { + tidings, err := herald.Read(conn, questName, 0) if err != nil || len(tidings) == 0 { return "" } From bd5b144def39ba0a2f19317317d8987e3e659f3b Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:51:13 -0500 Subject: [PATCH 14/19] feat: add fellowship migrate command for JSON-to-SQLite migration Implements MigrateJSON() which reads all 7 legacy JSON/JSONL file types from main repo and worktree .fellowship/ directories, backs them up, inserts into SQLite in a single transaction, and deletes originals. Wires runMigrate into main.go and removes deprecated OpenNoMutex flag. Co-Authored-By: Claude Opus 4.6 --- cli/cmd/fellowship/main.go | 45 +- cli/internal/db/db.go | 4 +- cli/internal/db/migrate.go | 749 ++++++++++++++++++++++++++++++++ cli/internal/db/migrate_test.go | 484 +++++++++++++++++++++ 4 files changed, 1277 insertions(+), 5 deletions(-) create mode 100644 cli/internal/db/migrate.go create mode 100644 cli/internal/db/migrate_test.go diff --git a/cli/cmd/fellowship/main.go b/cli/cmd/fellowship/main.go index 11d7d7b..e8af13b 100644 --- a/cli/cmd/fellowship/main.go +++ b/cli/cmd/fellowship/main.go @@ -43,9 +43,11 @@ func main() { fmt.Println(version) return case "migrate": - // TODO: implement migration command - fmt.Fprintln(os.Stderr, "fellowship: migrate command not yet implemented") - os.Exit(1) + if err := runMigrate(); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: migrate: %v\n", err) + os.Exit(1) + } + return } // Open DB for all other commands. @@ -1953,3 +1955,40 @@ func jsonFilesExist(fromDir string) bool { } return false } + +// runMigrate resolves the main repo, opens a DB, and migrates JSON files. +func runMigrate() error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getwd: %w", err) + } + mainRepo, err := resolveMainRepoFromCwd(cwd) + if err != nil { + return err + } + d, err := db.Open(cwd) + if err != nil { + return err + } + defer d.Close() + return db.MigrateJSON(d, mainRepo) +} + +// resolveMainRepoFromCwd finds the main repo root from cwd using git. +func resolveMainRepoFromCwd(cwd string) (string, error) { + cmd := exec.Command("git", "rev-parse", "--git-common-dir") + cmd.Dir = cwd + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git rev-parse: %w", err) + } + gitCommon := strings.TrimSpace(string(out)) + if !filepath.IsAbs(gitCommon) { + gitCommon = filepath.Join(cwd, gitCommon) + } + gitCommon = filepath.Clean(gitCommon) + if filepath.Base(gitCommon) == ".git" { + return filepath.Dir(gitCommon), nil + } + return filepath.Dir(gitCommon), nil +} diff --git a/cli/internal/db/db.go b/cli/internal/db/db.go index f05a577..0255c50 100644 --- a/cli/internal/db/db.go +++ b/cli/internal/db/db.go @@ -35,7 +35,7 @@ func Open(fromDir string) (*DB, error) { func OpenPath(dbPath string) (*DB, error) { pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{ PoolSize: 1, - Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenWAL | sqlite.OpenNoMutex, + Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenWAL, }) if err != nil { return nil, fmt.Errorf("db: open %s: %w", dbPath, err) @@ -64,7 +64,7 @@ func OpenPath(dbPath string) (*DB, error) { func OpenMemory() (*DB, error) { pool, err := sqlitex.NewPool("file::memory:?mode=memory", sqlitex.PoolOptions{ PoolSize: 1, - Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenMemory | sqlite.OpenNoMutex, + Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenMemory, }) if err != nil { return nil, fmt.Errorf("db: open memory: %w", err) diff --git a/cli/internal/db/migrate.go b/cli/internal/db/migrate.go new file mode 100644 index 0000000..7e8f641 --- /dev/null +++ b/cli/internal/db/migrate.go @@ -0,0 +1,749 @@ +package db + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "zombiezen.com/go/sqlite/sqlitex" +) + +// execCommand is the function used to create exec.Cmd. Tests can override it. +var execCommand = exec.Command + +// JSON structs for parsing legacy files. + +type fellowshipStateJSON struct { + Version int `json:"version"` + Name string `json:"name"` + MainRepo string `json:"main_repo"` + BaseBranch string `json:"base_branch"` + Quests []fellowshipQuestJSON `json:"quests"` + Scouts []fellowshipScoutJSON `json:"scouts"` + Companies []companyJSON `json:"companies"` + CreatedAt string `json:"created_at"` +} + +type fellowshipQuestJSON struct { + Name string `json:"name"` + TaskDescription string `json:"task_description"` + Worktree string `json:"worktree"` + Branch string `json:"branch"` + TaskID string `json:"task_id"` +} + +type fellowshipScoutJSON struct { + Name string `json:"name"` + Question string `json:"question"` + TaskID string `json:"task_id"` +} + +type companyJSON struct { + Name string `json:"name"` + Quests []string `json:"quests"` + Scouts []string `json:"scouts"` +} + +type questStateJSON struct { + Version int `json:"version"` + QuestName string `json:"quest_name"` + TaskID string `json:"task_id"` + TeamName string `json:"team_name"` + Phase string `json:"phase"` + GatePending bool `json:"gate_pending"` + GateID *string `json:"gate_id"` + LembasCompleted bool `json:"lembas_completed"` + MetadataUpdated bool `json:"metadata_updated"` + Held bool `json:"held"` + HeldReason *string `json:"held_reason"` + AutoApprove *string `json:"auto_approve"` +} + +type questTomeJSON struct { + Version int `json:"version"` + QuestName string `json:"quest_name"` + Task string `json:"task"` + PhasesCompleted []phaseRecJSON `json:"phases_completed"` + GateHistory []gateEventJSON `json:"gate_history"` + FilesTouched []string `json:"files_touched"` + Respawns int `json:"respawns"` + Status string `json:"status"` +} + +type phaseRecJSON struct { + Phase string `json:"phase"` + CompletedAt string `json:"completed_at"` + DurationS int `json:"duration_s"` +} + +type gateEventJSON struct { + Phase string `json:"phase"` + Action string `json:"action"` + Timestamp string `json:"timestamp"` + Reason string `json:"reason,omitempty"` +} + +type questErrandsJSON struct { + Version int `json:"version"` + QuestName string `json:"quest_name"` + Task string `json:"task"` + Items []errandJSON `json:"items"` +} + +type errandJSON struct { + ID string `json:"id"` + Description string `json:"description"` + Status string `json:"status"` + Phase string `json:"phase"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DependsOn []string `json:"depends_on"` +} + +type heraldLineJSON struct { + Timestamp string `json:"timestamp"` + Quest string `json:"quest"` + Type string `json:"type"` + Phase string `json:"phase"` + Detail string `json:"detail"` +} + +type bulletinLineJSON struct { + Timestamp string `json:"ts"` + Quest string `json:"quest"` + Topic string `json:"topic"` + Files []string `json:"files"` + Discovery string `json:"discovery"` +} + +type autopsyJSON struct { + Version int `json:"version"` + Timestamp string `json:"ts"` + Quest string `json:"quest"` + Task string `json:"task"` + Phase string `json:"phase"` + Trigger string `json:"trigger"` + Files []string `json:"files"` + Modules []string `json:"modules"` + WhatFailed string `json:"what_failed"` + Tags []string `json:"tags"` + ExpiresAt string `json:"expires_at"` +} + +// migrationFile tracks a discovered JSON file to migrate. +type migrationFile struct { + path string // absolute path to the file + relPath string // relative path for backup structure + fileType string // e.g., "fellowship-state", "quest-state", etc. +} + +// MigrateJSON reads all JSON/JSONL files from mainRepo and its worktrees, +// inserts them into the DB, backs up originals, and deletes them. +func MigrateJSON(d *DB, mainRepo string) error { + dataDir := filepath.Join(mainRepo, ".fellowship") + backupDir := filepath.Join(dataDir, "backup") + + // 1. Discover all JSON files + files, err := discoverJSONFiles(mainRepo) + if err != nil { + return fmt.Errorf("migrate: discover files: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("migrate: no JSON files found to migrate") + } + + // 2. Back up originals + if err := backupFiles(files, dataDir, backupDir); err != nil { + return fmt.Errorf("migrate: backup: %w", err) + } + + // 3. Parse and insert in a single transaction + var summary migrationSummary + if err := d.WithTx(context.Background(), func(conn *Conn) error { + for _, f := range files { + if err := migrateFile(conn, f, &summary); err != nil { + return fmt.Errorf("migrate %s (%s): %w", f.relPath, f.fileType, err) + } + } + return nil + }); err != nil { + return err + } + + // 4. Delete originals + .lock sidecars + for _, f := range files { + os.Remove(f.path) + os.Remove(f.path + ".lock") + } + + // Also clean up empty autopsies dirs + autopsyDir := filepath.Join(dataDir, "autopsies") + removeEmptyDir(autopsyDir) + + // 5. Print summary + fmt.Printf("Migration complete:\n") + fmt.Printf(" Fellowship state: %d\n", summary.fellowship) + fmt.Printf(" Quest states: %d\n", summary.questStates) + fmt.Printf(" Quest tomes: %d\n", summary.questTomes) + fmt.Printf(" Quest errands: %d\n", summary.questErrands) + fmt.Printf(" Herald events: %d\n", summary.heraldEvents) + fmt.Printf(" Bulletin entries: %d\n", summary.bulletinEntries) + fmt.Printf(" Autopsies: %d\n", summary.autopsies) + fmt.Printf(" Backup directory: %s\n", backupDir) + return nil +} + +type migrationSummary struct { + fellowship int + questStates int + questTomes int + questErrands int + heraldEvents int + bulletinEntries int + autopsies int +} + +// discoverJSONFiles finds all JSON/JSONL files in the main .fellowship/ dir +// and all worktree .fellowship/ dirs. +func discoverJSONFiles(mainRepo string) ([]migrationFile, error) { + var result []migrationFile + + // Main repo .fellowship/ files + mainDataDir := filepath.Join(mainRepo, ".fellowship") + result = append(result, scanDataDir(mainDataDir, "main")...) + + // Discover worktrees + worktrees, err := listWorktreePaths(mainRepo) + if err != nil { + return nil, err + } + for _, wt := range worktrees { + // Skip the main repo itself + if filepath.Clean(wt) == filepath.Clean(mainRepo) { + continue + } + wtDataDir := filepath.Join(wt, ".fellowship") + result = append(result, scanDataDir(wtDataDir, filepath.Base(wt))...) + } + + return result, nil +} + +// scanDataDir looks for known JSON files in a .fellowship directory. +func scanDataDir(dataDir string, label string) []migrationFile { + var files []migrationFile + + knownFiles := map[string]string{ + "fellowship-state.json": "fellowship-state", + "quest-state.json": "quest-state", + "quest-tome.json": "quest-tome", + "quest-errands.json": "quest-errands", + "quest-herald.jsonl": "quest-herald", + "bulletin.jsonl": "bulletin", + } + + for name, fileType := range knownFiles { + p := filepath.Join(dataDir, name) + if _, err := os.Stat(p); err == nil { + files = append(files, migrationFile{ + path: p, + relPath: filepath.Join(label, name), + fileType: fileType, + }) + } + } + + // Autopsies directory + autopsyDir := filepath.Join(dataDir, "autopsies") + entries, err := os.ReadDir(autopsyDir) + if err == nil { + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + p := filepath.Join(autopsyDir, e.Name()) + files = append(files, migrationFile{ + path: p, + relPath: filepath.Join(label, "autopsies", e.Name()), + fileType: "autopsy", + }) + } + } + + return files +} + +// backupFiles copies each file to backupDir preserving the relative path. +func backupFiles(files []migrationFile, dataDir, backupDir string) error { + for _, f := range files { + dst := filepath.Join(backupDir, f.relPath) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + data, err := os.ReadFile(f.path) + if err != nil { + return err + } + if err := os.WriteFile(dst, data, 0o644); err != nil { + return err + } + } + return nil +} + +// migrateFile parses and inserts a single file based on its type. +func migrateFile(conn *Conn, f migrationFile, s *migrationSummary) error { + data, err := os.ReadFile(f.path) + if err != nil { + return err + } + + switch f.fileType { + case "fellowship-state": + return migrateFellowshipState(conn, data, s) + case "quest-state": + return migrateQuestState(conn, data, s) + case "quest-tome": + return migrateQuestTome(conn, data, s) + case "quest-errands": + return migrateQuestErrands(conn, data, s) + case "quest-herald": + return migrateHerald(conn, data, s) + case "bulletin": + return migrateBulletin(conn, data, s) + case "autopsy": + return migrateAutopsy(conn, data, s) + default: + return fmt.Errorf("unknown file type: %s", f.fileType) + } +} + +func migrateFellowshipState(conn *Conn, data []byte, s *migrationSummary) error { + var fs fellowshipStateJSON + if err := json.Unmarshal(data, &fs); err != nil { + return fmt.Errorf("parse fellowship-state.json: %w", err) + } + + versionStr := fmt.Sprintf("%d", fs.Version) + if fs.CreatedAt == "" { + fs.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + + // Insert fellowship singleton + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO fellowship (id, version, name, main_repo, base_branch, created_at) + VALUES (1, :version, :name, :main_repo, :base_branch, :created_at)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":version": versionStr, + ":name": fs.Name, + ":main_repo": fs.MainRepo, + ":base_branch": fs.BaseBranch, + ":created_at": fs.CreatedAt, + }, + }); err != nil { + return err + } + + // Insert quests + for _, q := range fs.Quests { + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO fellowship_quests (name, task_description, worktree, branch, task_id) + VALUES (:name, :task_desc, :worktree, :branch, :task_id)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":name": q.Name, + ":task_desc": q.TaskDescription, + ":worktree": q.Worktree, + ":branch": q.Branch, + ":task_id": q.TaskID, + }, + }); err != nil { + return err + } + } + + // Insert scouts + for _, sc := range fs.Scouts { + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO fellowship_scouts (name, question, task_id) + VALUES (:name, :question, :task_id)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":name": sc.Name, + ":question": sc.Question, + ":task_id": sc.TaskID, + }, + }); err != nil { + return err + } + } + + // Insert companies and members + for _, c := range fs.Companies { + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO companies (name) VALUES (:name)`, + &sqlitex.ExecOptions{Named: map[string]any{":name": c.Name}}); err != nil { + return err + } + for _, q := range c.Quests { + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO company_members (company_name, member_name, member_type) + VALUES (:company, :member, 'quest')`, + &sqlitex.ExecOptions{ + Named: map[string]any{":company": c.Name, ":member": q}, + }); err != nil { + return err + } + } + for _, sc := range c.Scouts { + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO company_members (company_name, member_name, member_type) + VALUES (:company, :member, 'scout')`, + &sqlitex.ExecOptions{ + Named: map[string]any{":company": c.Name, ":member": sc}, + }); err != nil { + return err + } + } + } + + s.fellowship++ + return nil +} + +func migrateQuestState(conn *Conn, data []byte, s *migrationSummary) error { + var qs questStateJSON + if err := json.Unmarshal(data, &qs); err != nil { + return fmt.Errorf("parse quest-state.json: %w", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + boolInt := func(b bool) int { + if b { + return 1 + } + return 0 + } + + var gateID any + if qs.GateID != nil { + gateID = *qs.GateID + } + var heldReason any + if qs.HeldReason != nil { + heldReason = *qs.HeldReason + } + var autoApprove any + if qs.AutoApprove != nil { + autoApprove = *qs.AutoApprove + } + + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO quest_state + (quest_name, task_id, team_name, phase, gate_pending, gate_id, + lembas_completed, metadata_updated, held, held_reason, auto_approve, + created_at, updated_at) + VALUES (:name, :task_id, :team, :phase, :gate_pending, :gate_id, + :lembas, :metadata, :held, :held_reason, :auto_approve, :now, :now)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":name": qs.QuestName, + ":task_id": qs.TaskID, + ":team": qs.TeamName, + ":phase": qs.Phase, + ":gate_pending": boolInt(qs.GatePending), + ":gate_id": gateID, + ":lembas": boolInt(qs.LembasCompleted), + ":metadata": boolInt(qs.MetadataUpdated), + ":held": boolInt(qs.Held), + ":held_reason": heldReason, + ":auto_approve": autoApprove, + ":now": now, + }, + }); err != nil { + return err + } + + s.questStates++ + return nil +} + +func migrateQuestTome(conn *Conn, data []byte, s *migrationSummary) error { + var qt questTomeJSON + if err := json.Unmarshal(data, &qt); err != nil { + return fmt.Errorf("parse quest-tome.json: %w", err) + } + + // Insert phase records + for _, p := range qt.PhasesCompleted { + if err := sqlitex.Execute(conn, + `INSERT INTO quest_phases (quest_name, phase, completed_at, duration_s) + VALUES (:quest, :phase, :completed_at, :dur)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":quest": qt.QuestName, + ":phase": p.Phase, + ":completed_at": p.CompletedAt, + ":dur": p.DurationS, + }, + }); err != nil { + return err + } + } + + // Insert gate history + for _, g := range qt.GateHistory { + if err := sqlitex.Execute(conn, + `INSERT INTO quest_gates (quest_name, phase, action, timestamp, reason) + VALUES (:quest, :phase, :action, :ts, :reason)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":quest": qt.QuestName, + ":phase": g.Phase, + ":action": g.Action, + ":ts": g.Timestamp, + ":reason": g.Reason, + }, + }); err != nil { + return err + } + } + + // Insert files touched + for _, f := range qt.FilesTouched { + if err := sqlitex.Execute(conn, + `INSERT OR IGNORE INTO quest_files (quest_name, file_path) VALUES (:quest, :file)`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": qt.QuestName, ":file": f}, + }); err != nil { + return err + } + } + + // Update fellowship_quests with status and respawns + if qt.Status != "" || qt.Respawns > 0 { + if err := sqlitex.Execute(conn, + `UPDATE fellowship_quests SET status = :status, respawns = :respawns WHERE name = :name`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":name": qt.QuestName, + ":status": qt.Status, + ":respawns": qt.Respawns, + }, + }); err != nil { + return err + } + } + + s.questTomes++ + return nil +} + +func migrateQuestErrands(conn *Conn, data []byte, s *migrationSummary) error { + var qe questErrandsJSON + if err := json.Unmarshal(data, &qe); err != nil { + return fmt.Errorf("parse quest-errands.json: %w", err) + } + + for _, item := range qe.Items { + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO errands (id, quest_name, description, status, phase, created_at, updated_at) + VALUES (:id, :quest, :desc, :status, :phase, :created_at, :updated_at)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":id": item.ID, + ":quest": qe.QuestName, + ":desc": item.Description, + ":status": item.Status, + ":phase": item.Phase, + ":created_at": item.CreatedAt, + ":updated_at": item.UpdatedAt, + }, + }); err != nil { + return err + } + for _, dep := range item.DependsOn { + if err := sqlitex.Execute(conn, + `INSERT OR REPLACE INTO errand_deps (quest_name, errand_id, depends_on) + VALUES (:quest, :id, :dep)`, + &sqlitex.ExecOptions{ + Named: map[string]any{":quest": qe.QuestName, ":id": item.ID, ":dep": dep}, + }); err != nil { + return err + } + } + } + + s.questErrands += len(qe.Items) + return nil +} + +func migrateHerald(conn *Conn, data []byte, s *migrationSummary) error { + lines, err := parseJSONL[heraldLineJSON](data) + if err != nil { + return fmt.Errorf("parse quest-herald.jsonl: %w", err) + } + for _, h := range lines { + if err := sqlitex.Execute(conn, + `INSERT INTO herald (timestamp, quest, type, phase, detail) + VALUES (:ts, :quest, :type, :phase, :detail)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":ts": h.Timestamp, + ":quest": h.Quest, + ":type": h.Type, + ":phase": h.Phase, + ":detail": h.Detail, + }, + }); err != nil { + return err + } + s.heraldEvents++ + } + return nil +} + +func migrateBulletin(conn *Conn, data []byte, s *migrationSummary) error { + lines, err := parseJSONL[bulletinLineJSON](data) + if err != nil { + return fmt.Errorf("parse bulletin.jsonl: %w", err) + } + for _, b := range lines { + if err := sqlitex.Execute(conn, + `INSERT INTO bulletin (timestamp, quest, topic, discovery) + VALUES (:ts, :quest, :topic, :discovery)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":ts": b.Timestamp, + ":quest": b.Quest, + ":topic": b.Topic, + ":discovery": b.Discovery, + }, + }); err != nil { + return err + } + id := conn.LastInsertRowID() + for _, f := range b.Files { + if err := sqlitex.Execute(conn, + `INSERT INTO bulletin_files (bulletin_id, file_path) VALUES (:id, :file)`, + &sqlitex.ExecOptions{ + Named: map[string]any{":id": id, ":file": f}, + }); err != nil { + return err + } + } + s.bulletinEntries++ + } + return nil +} + +func migrateAutopsy(conn *Conn, data []byte, s *migrationSummary) error { + var a autopsyJSON + if err := json.Unmarshal(data, &a); err != nil { + return fmt.Errorf("parse autopsy json: %w", err) + } + + if err := sqlitex.Execute(conn, + `INSERT INTO autopsies (timestamp, quest, task, phase, trigger_type, what_failed, expires_at) + VALUES (:ts, :quest, :task, :phase, :trigger, :what_failed, :expires_at)`, + &sqlitex.ExecOptions{ + Named: map[string]any{ + ":ts": a.Timestamp, + ":quest": a.Quest, + ":task": a.Task, + ":phase": a.Phase, + ":trigger": a.Trigger, + ":what_failed": a.WhatFailed, + ":expires_at": a.ExpiresAt, + }, + }); err != nil { + return err + } + + id := conn.LastInsertRowID() + for _, f := range a.Files { + if err := sqlitex.Execute(conn, + `INSERT INTO autopsy_files (autopsy_id, file_path) VALUES (:id, :file)`, + &sqlitex.ExecOptions{Named: map[string]any{":id": id, ":file": f}}); err != nil { + return err + } + } + for _, m := range a.Modules { + if err := sqlitex.Execute(conn, + `INSERT INTO autopsy_modules (autopsy_id, module) VALUES (:id, :mod)`, + &sqlitex.ExecOptions{Named: map[string]any{":id": id, ":mod": m}}); err != nil { + return err + } + } + for _, tag := range a.Tags { + if err := sqlitex.Execute(conn, + `INSERT INTO autopsy_tags (autopsy_id, tag) VALUES (:id, :tag)`, + &sqlitex.ExecOptions{Named: map[string]any{":id": id, ":tag": tag}}); err != nil { + return err + } + } + + s.autopsies++ + return nil +} + +// parseJSONL parses newline-delimited JSON, skipping empty lines. +func parseJSONL[T any](data []byte) ([]T, error) { + var result []T + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var item T + if err := json.Unmarshal([]byte(line), &item); err != nil { + return nil, err + } + result = append(result, item) + } + if err := scanner.Err(); err != nil && err != io.EOF { + return nil, err + } + return result, nil +} + +// listWorktreePaths parses `git worktree list --porcelain` output. +func listWorktreePaths(mainRepo string) ([]string, error) { + cmd := execCommand("git", "worktree", "list", "--porcelain") + cmd.Dir = mainRepo + out, err := cmd.Output() + if err != nil { + // If git worktree fails, just return the main repo + return []string{mainRepo}, nil + } + var paths []string + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "worktree ") { + paths = append(paths, strings.TrimPrefix(line, "worktree ")) + } + } + if len(paths) == 0 { + return []string{mainRepo}, nil + } + return paths, nil +} + +// removeEmptyDir removes a directory only if it's empty. +func removeEmptyDir(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + if len(entries) == 0 { + os.Remove(dir) + } +} diff --git a/cli/internal/db/migrate_test.go b/cli/internal/db/migrate_test.go new file mode 100644 index 0000000..bc64303 --- /dev/null +++ b/cli/internal/db/migrate_test.go @@ -0,0 +1,484 @@ +package db + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "zombiezen.com/go/sqlite" + "zombiezen.com/go/sqlite/sqlitex" +) + +// writeFellowshipState writes a fellowship-state.json fixture. +func writeFellowshipState(t *testing.T, dir string) { + t.Helper() + fs := fellowshipStateJSON{ + Version: 1, + Name: "test-fellowship", + MainRepo: "/tmp/repo", + BaseBranch: "main", + Quests: []fellowshipQuestJSON{ + {Name: "q1", TaskDescription: "build auth", Worktree: "/tmp/wt1", Branch: "feat/q1", TaskID: "task-1"}, + }, + Scouts: []fellowshipScoutJSON{ + {Name: "s1", Question: "how does auth work?", TaskID: "task-s1"}, + }, + Companies: []companyJSON{ + {Name: "team-a", Quests: []string{"q1"}, Scouts: []string{"s1"}}, + }, + CreatedAt: "2026-01-01T00:00:00Z", + } + data, _ := json.Marshal(fs) + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "fellowship-state.json"), data, 0o644) +} + +// writeQuestState writes a quest-state.json fixture. +func writeQuestState(t *testing.T, dir string) { + t.Helper() + gateID := "gate-onboard-12345" + qs := questStateJSON{ + Version: 1, + QuestName: "q1", + TaskID: "task-1", + TeamName: "team-a", + Phase: "Implement", + GatePending: false, + GateID: &gateID, + LembasCompleted: true, + MetadataUpdated: false, + Held: false, + } + data, _ := json.Marshal(qs) + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "quest-state.json"), data, 0o644) +} + +// writeQuestTome writes a quest-tome.json fixture. +func writeQuestTome(t *testing.T, dir string) { + t.Helper() + qt := questTomeJSON{ + Version: 1, + QuestName: "q1", + Task: "build auth", + PhasesCompleted: []phaseRecJSON{ + {Phase: "Onboard", CompletedAt: "2026-01-01T00:10:00Z", DurationS: 60}, + }, + GateHistory: []gateEventJSON{ + {Phase: "Onboard", Action: "approved", Timestamp: "2026-01-01T00:10:00Z"}, + }, + FilesTouched: []string{"auth/login.go", "auth/login_test.go"}, + Respawns: 1, + Status: "active", + } + data, _ := json.Marshal(qt) + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "quest-tome.json"), data, 0o644) +} + +// writeQuestErrands writes a quest-errands.json fixture. +func writeQuestErrands(t *testing.T, dir string) { + t.Helper() + qe := questErrandsJSON{ + Version: 1, + QuestName: "q1", + Task: "build auth", + Items: []errandJSON{ + { + ID: "w-001", Description: "implement login", Status: "pending", + Phase: "Implement", CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z", + DependsOn: []string{"w-000"}, + }, + }, + } + data, _ := json.Marshal(qe) + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "quest-errands.json"), data, 0o644) +} + +// writeHerald writes a quest-herald.jsonl fixture. +func writeHerald(t *testing.T, dir string) { + t.Helper() + lines := []heraldLineJSON{ + {Timestamp: "2026-01-01T00:05:00Z", Quest: "q1", Type: "gate_submitted", Phase: "Onboard", Detail: ""}, + {Timestamp: "2026-01-01T00:10:00Z", Quest: "q1", Type: "gate_approved", Phase: "Onboard", Detail: "looks good"}, + } + var sb strings.Builder + for _, l := range lines { + data, _ := json.Marshal(l) + sb.Write(data) + sb.WriteByte('\n') + } + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "quest-herald.jsonl"), []byte(sb.String()), 0o644) +} + +// writeBulletin writes a bulletin.jsonl fixture. +func writeBulletin(t *testing.T, dir string) { + t.Helper() + lines := []bulletinLineJSON{ + {Timestamp: "2026-01-01T01:00:00Z", Quest: "q1", Topic: "auth", Files: []string{"auth/login.go"}, Discovery: "needs error handling"}, + } + var sb strings.Builder + for _, l := range lines { + data, _ := json.Marshal(l) + sb.Write(data) + sb.WriteByte('\n') + } + os.MkdirAll(dir, 0o755) + os.WriteFile(filepath.Join(dir, "bulletin.jsonl"), []byte(sb.String()), 0o644) +} + +// writeAutopsy writes an autopsy JSON fixture. +func writeAutopsy(t *testing.T, dir string, name string) { + t.Helper() + a := autopsyJSON{ + Version: 1, + Timestamp: "2026-01-02T00:00:00Z", + Quest: "q1", + Task: "build auth", + Phase: "Implement", + Trigger: "recovery", + Files: []string{"auth/login.go"}, + Modules: []string{"auth"}, + WhatFailed: "tests", + Tags: []string{"flaky"}, + ExpiresAt: "2026-04-02T00:00:00Z", + } + data, _ := json.Marshal(a) + autopsyDir := filepath.Join(dir, "autopsies") + os.MkdirAll(autopsyDir, 0o755) + os.WriteFile(filepath.Join(autopsyDir, name), data, 0o644) +} + +func TestMigrateJSON(t *testing.T) { + // Override execCommand so we don't need real git + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + tmpDir := t.TempDir() + mainDataDir := filepath.Join(tmpDir, ".fellowship") + wtDir := filepath.Join(tmpDir, "worktree-q1") + wtDataDir := filepath.Join(wtDir, ".fellowship") + + // Set up git worktree mock: return main + worktree + execCommand = func(name string, args ...string) *exec.Cmd { + out := "worktree " + tmpDir + "\n\nworktree " + wtDir + "\n\n" + return exec.Command("echo", "-n", out) + } + + // Write all 7 fixture types: + // Main .fellowship/: fellowship-state.json, bulletin.jsonl, autopsies/*.json + writeFellowshipState(t, mainDataDir) + writeBulletin(t, mainDataDir) + writeAutopsy(t, mainDataDir, "autopsy-001.json") + + // Also create a .lock sidecar to verify deletion + os.WriteFile(filepath.Join(mainDataDir, "fellowship-state.json.lock"), []byte("lock"), 0o644) + + // Worktree .fellowship/: quest-state.json, quest-tome.json, quest-errands.json, quest-herald.jsonl + writeQuestState(t, wtDataDir) + writeQuestTome(t, wtDataDir) + writeQuestErrands(t, wtDataDir) + writeHerald(t, wtDataDir) + + // Run migration + d := OpenTest(t) + err := MigrateJSON(d, tmpDir) + if err != nil { + t.Fatalf("MigrateJSON: %v", err) + } + + // Verify all data migrated correctly + d.WithConn(t.Context(), func(conn *Conn) error { + // 1. Fellowship + var name, mainRepo, baseBranch string + sqlitex.Execute(conn, + `SELECT name, main_repo, base_branch FROM fellowship WHERE id = 1`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + name = stmt.ColumnText(0) + mainRepo = stmt.ColumnText(1) + baseBranch = stmt.ColumnText(2) + return nil + }, + }) + assertEqual(t, "fellowship.name", "test-fellowship", name) + assertEqual(t, "fellowship.main_repo", "/tmp/repo", mainRepo) + assertEqual(t, "fellowship.base_branch", "main", baseBranch) + + // 2. Fellowship quests + var questName, taskDesc, branch string + sqlitex.Execute(conn, + `SELECT name, task_description, branch FROM fellowship_quests WHERE name = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + questName = stmt.ColumnText(0) + taskDesc = stmt.ColumnText(1) + branch = stmt.ColumnText(2) + return nil + }, + }) + assertEqual(t, "quest.name", "q1", questName) + assertEqual(t, "quest.task_description", "build auth", taskDesc) + assertEqual(t, "quest.branch", "feat/q1", branch) + + // 3. Fellowship scouts + var scoutName, question string + sqlitex.Execute(conn, + `SELECT name, question FROM fellowship_scouts WHERE name = 's1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + scoutName = stmt.ColumnText(0) + question = stmt.ColumnText(1) + return nil + }, + }) + assertEqual(t, "scout.name", "s1", scoutName) + assertEqual(t, "scout.question", "how does auth work?", question) + + // 4. Companies and members + var companyCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM company_members WHERE company_name = 'team-a'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + companyCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "company_members.count", 2, companyCount) + + // 5. Quest state + var phase string + var lembasCompleted int + sqlitex.Execute(conn, + `SELECT phase, lembas_completed FROM quest_state WHERE quest_name = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + phase = stmt.ColumnText(0) + lembasCompleted = stmt.ColumnInt(1) + return nil + }, + }) + assertEqual(t, "quest_state.phase", "Implement", phase) + assertEqual(t, "quest_state.lembas_completed", 1, lembasCompleted) + + // 6. Quest phases (from tome) + var phaseCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM quest_phases WHERE quest_name = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + phaseCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "quest_phases.count", 1, phaseCount) + + // 7. Quest gates (from tome) + var gateCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM quest_gates WHERE quest_name = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + gateCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "quest_gates.count", 1, gateCount) + + // 8. Quest files (from tome) + var fileCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM quest_files WHERE quest_name = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + fileCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "quest_files.count", 2, fileCount) + + // 9. Respawns updated in fellowship_quests + var respawns int + var status string + sqlitex.Execute(conn, + `SELECT respawns, status FROM fellowship_quests WHERE name = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + respawns = stmt.ColumnInt(0) + status = stmt.ColumnText(1) + return nil + }, + }) + assertEqual(t, "fellowship_quests.respawns", 1, respawns) + assertEqual(t, "fellowship_quests.status", "active", status) + + // 10. Errands + var errandCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM errands WHERE quest_name = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + errandCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "errands.count", 1, errandCount) + + // 11. Errand deps + var depCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM errand_deps WHERE quest_name = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + depCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "errand_deps.count", 1, depCount) + + // 12. Herald events + var heraldCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM herald WHERE quest = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + heraldCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "herald.count", 2, heraldCount) + + // 13. Bulletin entries + var bulletinCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM bulletin WHERE quest = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + bulletinCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "bulletin.count", 1, bulletinCount) + + // 14. Bulletin files + var bfCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM bulletin_files`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + bfCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "bulletin_files.count", 1, bfCount) + + // 15. Autopsies + var autopsyCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM autopsies WHERE quest = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + autopsyCount = stmt.ColumnInt(0) + return nil + }, + }) + assertEqual(t, "autopsies.count", 1, autopsyCount) + + // Verify trigger_type mapping (JSON "trigger" -> DB "trigger_type") + var triggerType string + sqlitex.Execute(conn, + `SELECT trigger_type FROM autopsies WHERE quest = 'q1'`, + &sqlitex.ExecOptions{ + ResultFunc: func(stmt *sqlite.Stmt) error { + triggerType = stmt.ColumnText(0) + return nil + }, + }) + assertEqual(t, "autopsies.trigger_type", "recovery", triggerType) + + // 16. Autopsy files/modules/tags + var afCount, amCount, atCount int + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM autopsy_files`, + &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { afCount = stmt.ColumnInt(0); return nil }}) + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM autopsy_modules`, + &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { amCount = stmt.ColumnInt(0); return nil }}) + sqlitex.Execute(conn, + `SELECT COUNT(*) FROM autopsy_tags`, + &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { atCount = stmt.ColumnInt(0); return nil }}) + assertEqual(t, "autopsy_files.count", 1, afCount) + assertEqual(t, "autopsy_modules.count", 1, amCount) + assertEqual(t, "autopsy_tags.count", 1, atCount) + + return nil + }) + + // Verify backup directory created with correct structure + backupDir := filepath.Join(mainDataDir, "backup") + assertFileExists(t, filepath.Join(backupDir, "main", "fellowship-state.json")) + assertFileExists(t, filepath.Join(backupDir, "main", "bulletin.jsonl")) + assertFileExists(t, filepath.Join(backupDir, "main", "autopsies", "autopsy-001.json")) + assertFileExists(t, filepath.Join(backupDir, "worktree-q1", "quest-state.json")) + assertFileExists(t, filepath.Join(backupDir, "worktree-q1", "quest-tome.json")) + assertFileExists(t, filepath.Join(backupDir, "worktree-q1", "quest-errands.json")) + assertFileExists(t, filepath.Join(backupDir, "worktree-q1", "quest-herald.jsonl")) + + // Verify original JSON files deleted + assertFileNotExists(t, filepath.Join(mainDataDir, "fellowship-state.json")) + assertFileNotExists(t, filepath.Join(mainDataDir, "bulletin.jsonl")) + assertFileNotExists(t, filepath.Join(mainDataDir, "autopsies", "autopsy-001.json")) + assertFileNotExists(t, filepath.Join(wtDataDir, "quest-state.json")) + assertFileNotExists(t, filepath.Join(wtDataDir, "quest-tome.json")) + assertFileNotExists(t, filepath.Join(wtDataDir, "quest-errands.json")) + assertFileNotExists(t, filepath.Join(wtDataDir, "quest-herald.jsonl")) + + // Verify .lock sidecar deleted + assertFileNotExists(t, filepath.Join(mainDataDir, "fellowship-state.json.lock")) +} + +func TestMigrateJSON_NoFiles(t *testing.T) { + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + tmpDir := t.TempDir() + execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command("echo", "-n", "worktree "+tmpDir+"\n\n") + } + + d := OpenTest(t) + err := MigrateJSON(d, tmpDir) + if err == nil { + t.Fatal("expected error for no files, got nil") + } + if !strings.Contains(err.Error(), "no JSON files found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func assertEqual[T comparable](t *testing.T, label string, want, got T) { + t.Helper() + if want != got { + t.Errorf("%s: want %v, got %v", label, want, got) + } +} + +func assertFileExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected file to exist: %s", path) + } +} + +func assertFileNotExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err == nil { + t.Errorf("expected file to be deleted: %s", path) + } +} From 022661e0f580038a1e1ea2f4a24e20a40d241388 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 10:51:40 -0500 Subject: [PATCH 15/19] refactor: delete filelock package, replaced by SQLite WAL mode The filelock package provided advisory file locking for JSON state files. With the migration to SQLite, WAL mode handles all concurrency needs. Co-Authored-By: Claude Opus 4.6 --- cli/internal/filelock/filelock_unix.go | 15 ------- cli/internal/filelock/filelock_windows.go | 50 ----------------------- cli/internal/hooks/enrich_test.go | 18 +++++--- 3 files changed, 13 insertions(+), 70 deletions(-) delete mode 100644 cli/internal/filelock/filelock_unix.go delete mode 100644 cli/internal/filelock/filelock_windows.go diff --git a/cli/internal/filelock/filelock_unix.go b/cli/internal/filelock/filelock_unix.go deleted file mode 100644 index f54b25b..0000000 --- a/cli/internal/filelock/filelock_unix.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !windows - -package filelock - -import "syscall" - -// Lock acquires an exclusive lock on the given file descriptor. -func Lock(fd uintptr) error { - return syscall.Flock(int(fd), syscall.LOCK_EX) -} - -// Unlock releases the lock on the given file descriptor. -func Unlock(fd uintptr) error { - return syscall.Flock(int(fd), syscall.LOCK_UN) -} diff --git a/cli/internal/filelock/filelock_windows.go b/cli/internal/filelock/filelock_windows.go deleted file mode 100644 index 466af02..0000000 --- a/cli/internal/filelock/filelock_windows.go +++ /dev/null @@ -1,50 +0,0 @@ -//go:build windows - -package filelock - -import ( - "fmt" - "syscall" - "unsafe" -) - -var ( - modkernel32 = syscall.NewLazyDLL("kernel32.dll") - procLockFileEx = modkernel32.NewProc("LockFileEx") - procUnlockFileEx = modkernel32.NewProc("UnlockFileEx") -) - -const lockfileExclusiveLock = 0x00000002 - -// Lock acquires an exclusive lock on the given file descriptor. -func Lock(fd uintptr) error { - h := syscall.Handle(fd) - ol := new(syscall.Overlapped) - r1, _, err := procLockFileEx.Call( - uintptr(h), - lockfileExclusiveLock, - 0, - 1, 0, - uintptr(unsafe.Pointer(ol)), - ) - if r1 == 0 { - return fmt.Errorf("LockFileEx: %w", err) - } - return nil -} - -// Unlock releases the lock on the given file descriptor. -func Unlock(fd uintptr) error { - h := syscall.Handle(fd) - ol := new(syscall.Overlapped) - r1, _, err := procUnlockFileEx.Call( - uintptr(h), - 0, - 1, 0, - uintptr(unsafe.Pointer(ol)), - ) - if r1 == 0 { - return fmt.Errorf("UnlockFileEx: %w", err) - } - return nil -} diff --git a/cli/internal/hooks/enrich_test.go b/cli/internal/hooks/enrich_test.go index 3e0da30..a7968a0 100644 --- a/cli/internal/hooks/enrich_test.go +++ b/cli/internal/hooks/enrich_test.go @@ -3,13 +3,21 @@ package hooks import ( "strings" "testing" + + "github.com/justinjdev/fellowship/cli/internal/db" ) -func TestGatherEnrichment_EmptyDir(t *testing.T) { - // Non-existent directory should return empty string (graceful fallback) - result := GatherEnrichment("/nonexistent/path") - if result != "" { - t.Errorf("expected empty enrichment for missing dir, got: %q", result) +func TestGatherEnrichment_EmptyDB(t *testing.T) { + // With an empty DB and non-existent directory, should return a minimal enrichment block + d := db.OpenTest(t) + var result string + d.WithConn(t.Context(), func(conn *db.Conn) error { + result = GatherEnrichment(conn, "nonexistent-quest", "/nonexistent/path") + return nil + }) + // Even with no quest data, some fields produce default values (e.g., "none" for files) + if result != "" && !strings.Contains(result, "Gate Context") { + t.Errorf("expected empty or valid enrichment block, got: %q", result) } } From 198ff6bb65ac61821349b416af8812f3c7613ab3 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 11:24:30 -0500 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20error=20propagation,=20test=20robustness,=20schema=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production code: - Fail closed on quest lookup DB errors in runHook (critical) - Add ErrNotFound sentinel to state.Load; distinguish in runInit - server.go: return (bool, error) from validWorktreeDir; move herald calls to best-effort after tx commit; encode responses outside tx; surface DB errors as 500 in read handlers - migrate.go: fix auto_approve_gates field name; fail on worktree discovery errors instead of silent fallback - schema.go: add FK for errand_deps.depends_on; expand changelog triggers to include held_reason, gate_id, task_id, team_name, auto_approve - Return errors from MarkTomeCompleted, RecordGateSubmitted, DetectProblems; propagate in callers - Remove redundant dedup in FileTrack (INSERT OR IGNORE suffices) - Return empty slice instead of nil from bulletin.Load - Return json.Unmarshal error in state.Load for auto_approve - Simplify redundant if/else in db.resolveMainRepo Tests: - Check WithTx/WithConn/sqlitex.Execute errors across all test files - Check json.Marshal errors in migrate test fixtures Co-Authored-By: Claude Opus 4.6 --- cli/cmd/fellowship/main.go | 41 +++- cli/internal/autopsy/autopsy_test.go | 180 ++++++++------- cli/internal/bulletin/bulletin.go | 2 +- cli/internal/bulletin/bulletin_test.go | 134 +++++++---- cli/internal/company/company_test.go | 259 +++++++++++++++------- cli/internal/dashboard/fellowship_test.go | 204 ++++++++++++----- cli/internal/dashboard/server.go | 131 +++++++---- cli/internal/dashboard/server_test.go | 34 ++- cli/internal/db/db.go | 6 +- cli/internal/db/db_test.go | 10 +- cli/internal/db/migrate.go | 44 ++-- cli/internal/db/migrate_test.go | 161 ++++++++++---- cli/internal/db/schema.go | 11 +- cli/internal/errand/errand_test.go | 109 ++++++--- cli/internal/herald/herald_test.go | 119 ++++++---- cli/internal/herald/problems.go | 22 +- cli/internal/herald/problems_test.go | 130 +++++++---- cli/internal/hooks/completion.go | 4 +- cli/internal/hooks/files.go | 14 +- cli/internal/hooks/files_test.go | 58 +++-- cli/internal/hooks/submit.go | 15 +- cli/internal/state/state.go | 10 +- cli/internal/status/status_test.go | 24 +- cli/internal/tome/tome.go | 6 +- cli/internal/tome/tome_test.go | 111 +++++++--- 25 files changed, 1251 insertions(+), 588 deletions(-) diff --git a/cli/cmd/fellowship/main.go b/cli/cmd/fellowship/main.go index e8af13b..96cd454 100644 --- a/cli/cmd/fellowship/main.go +++ b/cli/cmd/fellowship/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "net/http" @@ -251,12 +252,21 @@ func runHook(d *db.DB, name string) int { // Find quest name for this worktree. var questName string - d.WithConn(ctx, func(conn *db.Conn) error { - questName, _ = state.FindQuest(conn, gitRoot) - return nil - }) + var lookupErr error + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + questName, err = state.FindQuest(conn, gitRoot) + return err + }); err != nil { + lookupErr = err + } // Lead session (no quest found): only the CWD guard applies. if questName == "" { + if lookupErr != nil { + // DB error — fail closed for safety. + fmt.Fprintf(os.Stderr, "fellowship: quest lookup failed: %v\n", lookupErr) + return 2 + } if name == "gate-guard" { input, err := hooks.ParseInput(os.Stdin) if err != nil { @@ -342,7 +352,9 @@ func runHook(d *db.DB, name string) int { } result = hooks.CompletionGuard(s, input) if !result.Block && input.ToolInput.Status == "completed" { - hooks.MarkTomeCompleted(conn, questName) + if err := hooks.MarkTomeCompleted(conn, questName); err != nil { + return err + } } return nil }); err != nil { @@ -386,7 +398,9 @@ func runHook(d *db.DB, name string) int { } if !sr.Block { gateSubmitEnrich = true - hooks.RecordGateSubmitted(conn, questName, prevPhase, s.Phase != prevPhase) + if err := hooks.RecordGateSubmitted(conn, questName, prevPhase, s.Phase != prevPhase); err != nil { + return err + } herald.Announce(conn, herald.Tiding{ Timestamp: time.Now().UTC().Format(time.RFC3339), Quest: questName, @@ -743,6 +757,9 @@ func runInit(d *db.DB) int { if err := d.WithTx(ctx, func(conn *db.Conn) error { // Try to load existing state to reset it. existing, loadErr := state.Load(conn, qn) + if loadErr != nil && !errors.Is(loadErr, state.ErrNotFound) { + return fmt.Errorf("loading quest state: %w", loadErr) + } if loadErr == nil { // Reset existing state. existing.GatePending = false @@ -1024,10 +1041,14 @@ func runHerald(d *db.DB, args []string) int { if *problems { var detected []herald.Problem - d.WithConn(ctx, func(conn *db.Conn) error { - detected = herald.DetectProblems(conn) - return nil - }) + if err := d.WithConn(ctx, func(conn *db.Conn) error { + var err error + detected, err = herald.DetectProblems(conn) + return err + }); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) + return 1 + } if *jsonOut { data, _ := json.MarshalIndent(detected, "", " ") fmt.Println(string(data)) diff --git a/cli/internal/autopsy/autopsy_test.go b/cli/internal/autopsy/autopsy_test.go index 3b9e3a9..d48324e 100644 --- a/cli/internal/autopsy/autopsy_test.go +++ b/cli/internal/autopsy/autopsy_test.go @@ -11,7 +11,7 @@ import ( func TestCreateAndScan(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { id, err := Create(conn, &CreateInput{ Quest: "q1", Phase: "Implement", Trigger: "recovery", Files: []string{"auth.go"}, Modules: []string{"auth"}, @@ -32,12 +32,14 @@ func TestCreateAndScan(t *testing.T) { t.Fatalf("expected 1, got %d", len(matches)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestCreate_ValidInput(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { id, err := Create(conn, &CreateInput{ Quest: "quest-1", Task: "Add auth endpoint", @@ -84,12 +86,14 @@ func TestCreate_ValidInput(t *testing.T) { t.Errorf("modules = %v, want [auth]", a.Modules) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestCreate_MissingQuest(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Create(conn, &CreateInput{ Trigger: "recovery", WhatFailed: "something", @@ -98,12 +102,14 @@ func TestCreate_MissingQuest(t *testing.T) { t.Error("expected error for missing quest") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestCreate_InvalidTrigger(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Create(conn, &CreateInput{ Quest: "quest-1", Trigger: "invalid", @@ -113,12 +119,14 @@ func TestCreate_InvalidTrigger(t *testing.T) { t.Error("expected error for invalid trigger") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestCreate_MissingWhatFailed(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Create(conn, &CreateInput{ Quest: "quest-1", Trigger: "recovery", @@ -127,23 +135,27 @@ func TestCreate_MissingWhatFailed(t *testing.T) { t.Error("expected error for missing what_failed") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestCreate_NilInput(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Create(conn, nil) if err == nil { t.Error("expected error for nil input") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScan_MatchByFile(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Create(conn, &CreateInput{ Quest: "quest-1", Trigger: "recovery", @@ -163,12 +175,14 @@ func TestScan_MatchByFile(t *testing.T) { t.Errorf("expected 1 match (same directory), got %d", len(matches)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScan_MatchByModule(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Create(conn, &CreateInput{ Quest: "quest-1", Trigger: "recovery", @@ -187,12 +201,14 @@ func TestScan_MatchByModule(t *testing.T) { t.Errorf("expected 1 match, got %d", len(matches)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScan_MatchByTag(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Create(conn, &CreateInput{ Quest: "quest-1", Trigger: "recovery", @@ -211,12 +227,14 @@ func TestScan_MatchByTag(t *testing.T) { t.Errorf("expected 1 match, got %d", len(matches)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScan_NoMatch(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Create(conn, &CreateInput{ Quest: "quest-1", Trigger: "recovery", @@ -235,38 +253,40 @@ func TestScan_NoMatch(t *testing.T) { t.Errorf("expected 0 matches, got %d", len(matches)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScan_RequiresFilter(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Scan(conn, ScanOptions{}, 90) if err == nil { t.Error("expected error when no filters provided") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScan_ExcludesExpired(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { // Insert an autopsy with an already-expired expires_at - err := sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO autopsies (timestamp, quest, trigger_type, what_failed, expires_at) VALUES (datetime('now', '-100 days'), 'old-quest', 'recovery', 'old failure', datetime('now', '-10 days'))`, - nil) - if err != nil { + nil); err != nil { t.Fatal(err) } oldID := conn.LastInsertRowID() // Add a module so we can search for it - err = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO autopsy_modules (autopsy_id, module) VALUES (?, 'auth')`, - &sqlitex.ExecOptions{Args: []any{oldID}}) - if err != nil { + &sqlitex.ExecOptions{Args: []any{oldID}}); err != nil { t.Fatal(err) } @@ -278,42 +298,40 @@ func TestScan_ExcludesExpired(t *testing.T) { t.Errorf("expired autopsy should be excluded, got %d matches", len(matches)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestInfer_FromRespawns(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { // Set up fellowship_quests row - err := sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO fellowship_quests (name, task_description, status, respawns) - VALUES ('quest-respawned', 'Fix login flow', 'active', 2)`, nil) - if err != nil { + VALUES ('quest-respawned', 'Fix login flow', 'active', 2)`, nil); err != nil { t.Fatal(err) } // Set up quest_state (needed for FK in quest_phases/quest_files) - err = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, created_at, updated_at) - VALUES ('quest-respawned', datetime('now'), datetime('now'))`, nil) - if err != nil { + VALUES ('quest-respawned', datetime('now'), datetime('now'))`, nil); err != nil { t.Fatal(err) } // Add phase history - err = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_phases (quest_name, phase, completed_at) - VALUES ('quest-respawned', 'Implement', datetime('now'))`, nil) - if err != nil { + VALUES ('quest-respawned', 'Implement', datetime('now'))`, nil); err != nil { t.Fatal(err) } // Add files touched for _, f := range []string{"src/auth/login.go", "src/auth/session.go"} { - err = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_files (quest_name, file_path) VALUES ('quest-respawned', ?)`, - &sqlitex.ExecOptions{Args: []any{f}}) - if err != nil { + &sqlitex.ExecOptions{Args: []any{f}}); err != nil { t.Fatal(err) } } @@ -345,48 +363,45 @@ func TestInfer_FromRespawns(t *testing.T) { t.Errorf("files = %v, want 2 files", a.Files) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestInfer_FromRejection(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { // Set up fellowship_quests - err := sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO fellowship_quests (name, task_description, status, respawns) - VALUES ('quest-rejected', 'Add billing', 'active', 0)`, nil) - if err != nil { + VALUES ('quest-rejected', 'Add billing', 'active', 0)`, nil); err != nil { t.Fatal(err) } // Set up quest_state (for FK) - err = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, created_at, updated_at) - VALUES ('quest-rejected', datetime('now'), datetime('now'))`, nil) - if err != nil { + VALUES ('quest-rejected', datetime('now'), datetime('now'))`, nil); err != nil { t.Fatal(err) } // Add gate rejection - err = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_gates (quest_name, phase, action, timestamp, reason) - VALUES ('quest-rejected', 'Plan', 'rejected', datetime('now'), 'Plan doesn''t account for tax calculation')`, nil) - if err != nil { + VALUES ('quest-rejected', 'Plan', 'rejected', datetime('now'), 'Plan doesn''t account for tax calculation')`, nil); err != nil { t.Fatal(err) } // Add phase - err = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_phases (quest_name, phase, completed_at) - VALUES ('quest-rejected', 'Plan', datetime('now'))`, nil) - if err != nil { + VALUES ('quest-rejected', 'Plan', datetime('now'))`, nil); err != nil { t.Fatal(err) } // Add files - err = sqlitex.Execute(conn, - `INSERT INTO quest_files (quest_name, file_path) VALUES ('quest-rejected', 'src/billing/charge.go')`, nil) - if err != nil { + if err := sqlitex.Execute(conn, + `INSERT INTO quest_files (quest_name, file_path) VALUES ('quest-rejected', 'src/billing/charge.go')`, nil); err != nil { t.Fatal(err) } @@ -412,23 +427,23 @@ func TestInfer_FromRejection(t *testing.T) { t.Errorf("what_failed = %q", matches[0].WhatFailed) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestInfer_FromAbandonment(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - err := sqlitex.Execute(conn, + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := sqlitex.Execute(conn, `INSERT INTO fellowship_quests (name, task_description, status, respawns) - VALUES ('quest-abandoned', 'Migrate DB', 'cancelled', 0)`, nil) - if err != nil { + VALUES ('quest-abandoned', 'Migrate DB', 'cancelled', 0)`, nil); err != nil { t.Fatal(err) } - err = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, created_at, updated_at) - VALUES ('quest-abandoned', datetime('now'), datetime('now'))`, nil) - if err != nil { + VALUES ('quest-abandoned', datetime('now'), datetime('now'))`, nil); err != nil { t.Fatal(err) } @@ -451,7 +466,7 @@ func TestInfer_FromAbandonment(t *testing.T) { // Verify the autopsy was created by looking at the DB directly var trigger string - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT trigger_type FROM autopsies WHERE id = ?`, &sqlitex.ExecOptions{ Args: []any{id}, @@ -459,41 +474,48 @@ func TestInfer_FromAbandonment(t *testing.T) { trigger = stmt.ColumnText(0) return nil }, - }) + }); err != nil { + t.Fatal(err) + } if trigger != "abandonment" { t.Errorf("trigger = %q, want abandonment", trigger) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestInfer_NoFailureSignals(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - err := sqlitex.Execute(conn, + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := sqlitex.Execute(conn, `INSERT INTO fellowship_quests (name, task_description, status, respawns) - VALUES ('quest-ok', 'Add feature', 'active', 0)`, nil) - if err != nil { + VALUES ('quest-ok', 'Add feature', 'active', 0)`, nil); err != nil { t.Fatal(err) } - _, err = Infer(conn, "quest-ok") + _, err := Infer(conn, "quest-ok") if err == nil { t.Error("expected error when no failure signals found") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestInfer_QuestNotFound(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { _, err := Infer(conn, "nonexistent") if err == nil { t.Error("expected error for nonexistent quest") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestInferModules(t *testing.T) { diff --git a/cli/internal/bulletin/bulletin.go b/cli/internal/bulletin/bulletin.go index ae66276..092988d 100644 --- a/cli/internal/bulletin/bulletin.go +++ b/cli/internal/bulletin/bulletin.go @@ -82,7 +82,7 @@ func Load(conn *db.Conn) ([]Entry, error) { } if len(rows) == 0 { - return nil, nil + return []Entry{}, nil } // Build a map for file association. diff --git a/cli/internal/bulletin/bulletin_test.go b/cli/internal/bulletin/bulletin_test.go index b78eb5b..ed9de40 100644 --- a/cli/internal/bulletin/bulletin_test.go +++ b/cli/internal/bulletin/bulletin_test.go @@ -9,11 +9,13 @@ import ( func TestPostAndLoad(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Post(conn, Entry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Post(conn, Entry{ Timestamp: "2026-01-01T00:00:00Z", Quest: "q1", Topic: "auth", Files: []string{"auth.go"}, Discovery: "needs refactor", - }) + }); err != nil { + t.Fatal(err) + } entries, err := Load(conn) if err != nil { t.Fatal(err) @@ -25,47 +27,69 @@ func TestPostAndLoad(t *testing.T) { t.Error("topic mismatch") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScan_ByFile(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Post(conn, Entry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Post(conn, Entry{ Quest: "q1", Topic: "auth", Files: []string{"src/auth.go"}, Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z", - }) - Post(conn, Entry{ + }); err != nil { + t.Fatal(err) + } + if err := Post(conn, Entry{ Quest: "q2", Topic: "db", Files: []string{"src/db.go"}, Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z", - }) - matches, _ := Scan(conn, []string{"src/auth.go"}, nil) + }); err != nil { + t.Fatal(err) + } + matches, err := Scan(conn, []string{"src/auth.go"}, nil) + if err != nil { + t.Fatal(err) + } if len(matches) != 1 { t.Fatalf("expected 1, got %d", len(matches)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestClear(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Post(conn, Entry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Post(conn, Entry{ Quest: "q1", Topic: "t", Discovery: "d", Timestamp: "2026-01-01T00:00:00Z", - }) - Clear(conn) - entries, _ := Load(conn) + }); err != nil { + t.Fatal(err) + } + if err := Clear(conn); err != nil { + t.Fatal(err) + } + entries, err := Load(conn) + if err != nil { + t.Fatal(err) + } if len(entries) != 0 { t.Fatalf("expected 0, got %d", len(entries)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestPostSetsTimestamp(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Post(conn, Entry{Quest: "q1", Topic: "t", Discovery: "d"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Post(conn, Entry{Quest: "q1", Topic: "t", Discovery: "d"}); err != nil { + t.Fatal(err) + } entries, err := Load(conn) if err != nil { t.Fatal(err) @@ -77,15 +101,19 @@ func TestPostSetsTimestamp(t *testing.T) { t.Error("expected timestamp to be set") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestPostPreservesTimestamp(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Post(conn, Entry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Post(conn, Entry{ Timestamp: "2026-01-01T00:00:00Z", Quest: "q", Topic: "t", Discovery: "d", - }) + }); err != nil { + t.Fatal(err) + } entries, err := Load(conn) if err != nil { t.Fatal(err) @@ -94,15 +122,23 @@ func TestPostPreservesTimestamp(t *testing.T) { t.Errorf("expected preserved timestamp, got %s", entries[0].Timestamp) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScanByTopic(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Post(conn, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}) - Post(conn, Entry{Quest: "q2", Topic: "db", Files: []string{"src/db/conn.go"}, Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}) - Post(conn, Entry{Quest: "q3", Topic: "Auth", Files: []string{"src/auth/session.go"}, Discovery: "d3", Timestamp: "2026-01-01T00:00:00Z"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Post(conn, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatal(err) + } + if err := Post(conn, Entry{Quest: "q2", Topic: "db", Files: []string{"src/db/conn.go"}, Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatal(err) + } + if err := Post(conn, Entry{Quest: "q3", Topic: "Auth", Files: []string{"src/auth/session.go"}, Discovery: "d3", Timestamp: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatal(err) + } entries, err := Scan(conn, nil, []string{"auth"}) if err != nil { @@ -112,14 +148,20 @@ func TestScanByTopic(t *testing.T) { t.Fatalf("expected 2 entries matching topic 'auth', got %d", len(entries)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScanByFilesPathBoundary(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Post(conn, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}) - Post(conn, Entry{Quest: "q2", Topic: "authz", Files: []string{"src/authz/login.go"}, Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Post(conn, Entry{Quest: "q1", Topic: "auth", Files: []string{"src/auth/jwt.go"}, Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatal(err) + } + if err := Post(conn, Entry{Quest: "q2", Topic: "authz", Files: []string{"src/authz/login.go"}, Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatal(err) + } // "src/auth" should match "src/auth/jwt.go" but NOT "src/authz/login.go" entries, err := Scan(conn, []string{"src/auth"}, nil) @@ -133,14 +175,20 @@ func TestScanByFilesPathBoundary(t *testing.T) { t.Errorf("expected quest q1, got %s", entries[0].Quest) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScanNoFilters(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Post(conn, Entry{Quest: "q1", Topic: "auth", Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}) - Post(conn, Entry{Quest: "q2", Topic: "db", Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Post(conn, Entry{Quest: "q1", Topic: "auth", Discovery: "d1", Timestamp: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatal(err) + } + if err := Post(conn, Entry{Quest: "q2", Topic: "db", Discovery: "d2", Timestamp: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatal(err) + } entries, err := Scan(conn, nil, nil) if err != nil { @@ -150,19 +198,23 @@ func TestScanNoFilters(t *testing.T) { t.Fatalf("expected all 2 entries with no filters, got %d", len(entries)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestLoadEmpty(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { entries, err := Load(conn) if err != nil { t.Fatal(err) } - if entries != nil { - t.Errorf("expected nil entries, got %v", entries) + if len(entries) != 0 { + t.Errorf("expected empty entries, got %v", entries) } return nil - }) + }); err != nil { + t.Fatal(err) + } } diff --git a/cli/internal/company/company_test.go b/cli/internal/company/company_test.go index 5e5050c..2ca004b 100644 --- a/cli/internal/company/company_test.go +++ b/cli/internal/company/company_test.go @@ -88,22 +88,34 @@ func TestCalculateProgress_MissingQuests(t *testing.T) { func TestBatchApprove_MultipleQuests(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q2", Worktree: "/tmp/wt2"}) - dashboard.AddCompany(conn, "batch-test", []string{"q1", "q2"}, nil) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q2", Worktree: "/tmp/wt2"}); err != nil { + return err + } + if err := dashboard.AddCompany(conn, "batch-test", []string{"q1", "q2"}, nil); err != nil { + return err + } - state.Upsert(conn, &state.State{ + if err := state.Upsert(conn, &state.State{ QuestName: "q1", Phase: "Research", GatePending: true, - }) - state.Upsert(conn, &state.State{ + }); err != nil { + return err + } + if err := state.Upsert(conn, &state.State{ QuestName: "q2", Phase: "Plan", GatePending: true, - }) + }); err != nil { + return err + } company := dashboard.CompanyEntry{ Name: "batch-test", @@ -120,7 +132,10 @@ func TestBatchApprove_MultipleQuests(t *testing.T) { } // Verify phases were advanced - s1, _ := state.Load(conn, "q1") + s1, err := state.Load(conn, "q1") + if err != nil { + t.Fatal(err) + } if s1.Phase != "Plan" { t.Errorf("expected q1 phase 'Plan', got %q", s1.Phase) } @@ -128,25 +143,36 @@ func TestBatchApprove_MultipleQuests(t *testing.T) { t.Error("expected q1 gate_pending to be false") } - s2, _ := state.Load(conn, "q2") + s2, err := state.Load(conn, "q2") + if err != nil { + t.Fatal(err) + } if s2.Phase != "Implement" { t.Errorf("expected q2 phase 'Implement', got %q", s2.Phase) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestBatchApprove_NoPendingGates(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt"}); err != nil { + return err + } - state.Upsert(conn, &state.State{ + if err := state.Upsert(conn, &state.State{ QuestName: "q1", Phase: "Implement", GatePending: false, - }) + }); err != nil { + return err + } company := dashboard.CompanyEntry{ Name: "no-gates", @@ -162,15 +188,21 @@ func TestBatchApprove_NoPendingGates(t *testing.T) { t.Errorf("expected 0 approved (no-op), got %d", len(approved)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestBatchApprove_MissingQuestState(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } // q1 has no quest_state row - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt"}) + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt"}); err != nil { + return err + } company := dashboard.CompanyEntry{ Name: "missing-state", @@ -187,7 +219,9 @@ func TestBatchApprove_MissingQuestState(t *testing.T) { t.Errorf("expected 2 errors, got %d: %v", len(errs), errs) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestFindCompanyForQuest(t *testing.T) { @@ -222,15 +256,21 @@ func TestProgressSummary(t *testing.T) { func TestBatchApprove_HeraldLogging(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}); err != nil { + return err + } - state.Upsert(conn, &state.State{ + if err := state.Upsert(conn, &state.State{ QuestName: "q1", Phase: "Research", GatePending: true, - }) + }); err != nil { + return err + } company := dashboard.CompanyEntry{ Name: "herald-test", @@ -267,20 +307,28 @@ func TestBatchApprove_HeraldLogging(t *testing.T) { t.Error("expected PhaseTransition tiding for Plan phase") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestBatchApprove_TomeRecording(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}); err != nil { + return err + } - state.Upsert(conn, &state.State{ + if err := state.Upsert(conn, &state.State{ QuestName: "q1", Phase: "Plan", GatePending: true, - }) + }); err != nil { + return err + } company := dashboard.CompanyEntry{ Name: "tome-test", @@ -317,94 +365,134 @@ func TestBatchApprove_TomeRecording(t *testing.T) { t.Errorf("expected phase 'Plan', got %q", phases[0].Phase) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestList_NoCompanies(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } // No companies — should print "No companies defined." err := List(conn) if err != nil { t.Fatalf("List() error: %v", err) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestList_WithCompanies(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) - dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, nil) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}); err != nil { + return err + } + if err := dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, nil); err != nil { + return err + } err := List(conn) if err != nil { t.Fatalf("List() error: %v", err) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestShow_CompanyNotFound(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } err := Show(conn, "nonexistent") if err == nil { t.Fatal("expected error for nonexistent company") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestShow_WithQuestState(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) - dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, []string{}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}); err != nil { + return err + } + if err := dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, []string{}); err != nil { + return err + } - state.Upsert(conn, &state.State{ + if err := state.Upsert(conn, &state.State{ QuestName: "q1", Phase: "Implement", GatePending: true, - }) + }); err != nil { + return err + } err := Show(conn, "team-alpha") if err != nil { t.Fatalf("Show() error: %v", err) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestApprove_CompanyNotFound(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } err := Approve(conn, "nonexistent") if err == nil { t.Fatal("expected error for nonexistent company") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestApprove_WithPendingGates(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) - dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, nil) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}); err != nil { + return err + } + if err := dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, nil); err != nil { + return err + } - state.Upsert(conn, &state.State{ + if err := state.Upsert(conn, &state.State{ QuestName: "q1", Phase: "Research", GatePending: true, - }) + }); err != nil { + return err + } err := Approve(conn, "team-alpha") if err != nil { @@ -412,24 +500,41 @@ func TestApprove_WithPendingGates(t *testing.T) { } // Verify state was advanced - s, _ := state.Load(conn, "q1") + s, err := state.Load(conn, "q1") + if err != nil { + t.Fatal(err) + } if s.Phase != "Plan" { t.Errorf("expected phase 'Plan', got %q", s.Phase) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestLoadAndMarshalProgress(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}) - dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q2", Worktree: "/tmp/wt2"}) - dashboard.AddCompany(conn, "team-alpha", []string{"q1", "q2"}, nil) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt1"}); err != nil { + return err + } + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q2", Worktree: "/tmp/wt2"}); err != nil { + return err + } + if err := dashboard.AddCompany(conn, "team-alpha", []string{"q1", "q2"}, nil); err != nil { + return err + } - state.Upsert(conn, &state.State{QuestName: "q1", Phase: "Implement"}) - state.Upsert(conn, &state.State{QuestName: "q2", Phase: "Complete"}) + if err := state.Upsert(conn, &state.State{QuestName: "q1", Phase: "Implement"}); err != nil { + return err + } + if err := state.Upsert(conn, &state.State{QuestName: "q2", Phase: "Complete"}); err != nil { + return err + } data, err := LoadAndMarshalProgress(conn, "team-alpha") if err != nil { @@ -454,17 +559,23 @@ func TestLoadAndMarshalProgress(t *testing.T) { t.Errorf("InProgress = %d, want 2", progress.InProgress) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestLoadAndMarshalProgress_NotFound(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { - dashboard.InitFellowship(conn, "test", "/tmp", "main") + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := dashboard.InitFellowship(conn, "test", "/tmp", "main"); err != nil { + return err + } _, err := LoadAndMarshalProgress(conn, "nonexistent") if err == nil { t.Fatal("expected error for nonexistent company") } return nil - }) + }); err != nil { + t.Fatal(err) + } } diff --git a/cli/internal/dashboard/fellowship_test.go b/cli/internal/dashboard/fellowship_test.go index 1e90c57..88ed0e8 100644 --- a/cli/internal/dashboard/fellowship_test.go +++ b/cli/internal/dashboard/fellowship_test.go @@ -10,7 +10,7 @@ import ( func TestInitAndLoadFellowship(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { err := InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") if err != nil { t.Fatal(err) @@ -29,17 +29,26 @@ func TestInitAndLoadFellowship(t *testing.T) { t.Errorf("BaseBranch = %q, want %q", fs.BaseBranch, "main") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestAddQuest(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "f1", "/tmp", "main") - AddQuest(conn, QuestEntry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "f1", "/tmp", "main"); err != nil { + t.Fatal(err) + } + if err := AddQuest(conn, QuestEntry{ Name: "q1", TaskDescription: "build auth", Worktree: "/tmp/wt/q1", Branch: "feat/q1", - }) - quests, _ := ListQuests(conn) + }); err != nil { + t.Fatal(err) + } + quests, err := ListQuests(conn) + if err != nil { + t.Fatal(err) + } if len(quests) != 1 { t.Fatalf("expected 1, got %d", len(quests)) } @@ -50,15 +59,24 @@ func TestAddQuest(t *testing.T) { t.Errorf("TaskDescription = %q, want %q", quests[0].TaskDescription, "build auth") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestAddAndRemoveScout(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "f1", "/tmp", "main") - AddScout(conn, ScoutEntry{Name: "s1", Question: "how?", TaskID: "t1"}) - scouts, _ := ListScouts(conn) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "f1", "/tmp", "main"); err != nil { + t.Fatal(err) + } + if err := AddScout(conn, ScoutEntry{Name: "s1", Question: "how?", TaskID: "t1"}); err != nil { + t.Fatal(err) + } + scouts, err := ListScouts(conn) + if err != nil { + t.Fatal(err) + } if len(scouts) != 1 { t.Fatalf("expected 1 scout, got %d", len(scouts)) } @@ -66,24 +84,42 @@ func TestAddAndRemoveScout(t *testing.T) { t.Errorf("Name = %q, want %q", scouts[0].Name, "s1") } - RemoveScout(conn, "s1") - scouts, _ = ListScouts(conn) + if err := RemoveScout(conn, "s1"); err != nil { + t.Fatal(err) + } + scouts, err = ListScouts(conn) + if err != nil { + t.Fatal(err) + } if len(scouts) != 0 { t.Errorf("expected 0 scouts after remove, got %d", len(scouts)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestAddCompany(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "f1", "/tmp", "main") - AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1"}) - AddScout(conn, ScoutEntry{Name: "s1", Question: "why?"}) - AddCompany(conn, "team-alpha", []string{"q1"}, []string{"s1"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "f1", "/tmp", "main"); err != nil { + t.Fatal(err) + } + if err := AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1"}); err != nil { + t.Fatal(err) + } + if err := AddScout(conn, ScoutEntry{Name: "s1", Question: "why?"}); err != nil { + t.Fatal(err) + } + if err := AddCompany(conn, "team-alpha", []string{"q1"}, []string{"s1"}); err != nil { + t.Fatal(err) + } - companies, _ := ListCompanies(conn) + companies, err := ListCompanies(conn) + if err != nil { + t.Fatal(err) + } if len(companies) != 1 { t.Fatalf("expected 1 company, got %d", len(companies)) } @@ -97,17 +133,28 @@ func TestAddCompany(t *testing.T) { t.Errorf("Scouts = %v, want [s1]", companies[0].Scouts) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestUpdateQuest(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "f1", "/tmp", "main") - AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1", Status: "active"}) - UpdateQuest(conn, "q1", map[string]any{"status": "completed"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "f1", "/tmp", "main"); err != nil { + t.Fatal(err) + } + if err := AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1", Status: "active"}); err != nil { + t.Fatal(err) + } + if err := UpdateQuest(conn, "q1", map[string]any{"status": "completed"}); err != nil { + t.Fatal(err) + } - quests, _ := ListQuests(conn) + quests, err := ListQuests(conn) + if err != nil { + t.Fatal(err) + } if len(quests) != 1 { t.Fatalf("expected 1, got %d", len(quests)) } @@ -115,27 +162,42 @@ func TestUpdateQuest(t *testing.T) { t.Errorf("Status = %q, want %q", quests[0].Status, "completed") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestRemoveQuest(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "f1", "/tmp", "main") - AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1"}) - RemoveQuest(conn, "q1") - quests, _ := ListQuests(conn) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "f1", "/tmp", "main"); err != nil { + t.Fatal(err) + } + if err := AddQuest(conn, QuestEntry{Name: "q1", Worktree: "/tmp/wt/q1"}); err != nil { + t.Fatal(err) + } + if err := RemoveQuest(conn, "q1"); err != nil { + t.Fatal(err) + } + quests, err := ListQuests(conn) + if err != nil { + t.Fatal(err) + } if len(quests) != 0 { t.Errorf("expected 0 quests after remove, got %d", len(quests)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestSaveFellowship_RoundTrip(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "test-fellowship", "/path/to/repo", "main") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "test-fellowship", "/path/to/repo", "main"); err != nil { + t.Fatal(err) + } original := &FellowshipState{ Version: 1, @@ -191,18 +253,22 @@ func TestSaveFellowship_RoundTrip(t *testing.T) { t.Errorf("Companies[0].Name = %q, want %q", loaded.Companies[0].Name, "company-1") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestLoadFellowship_NotInitialized(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { _, err := LoadFellowship(conn) if err == nil { t.Fatal("expected error for uninitialized fellowship, got nil") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestQuestEntryStatus_Default(t *testing.T) { @@ -223,7 +289,7 @@ func TestQuestEntryStatus_Explicit(t *testing.T) { func TestDiscoverQuests_NoFellowship(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { status, err := DiscoverQuests(conn) if err != nil { t.Fatalf("DiscoverQuests() error: %v", err) @@ -232,22 +298,30 @@ func TestDiscoverQuests_NoFellowship(t *testing.T) { t.Errorf("expected 0 quests, got %d", len(status.Quests)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestDiscoverQuests_WithQuestState(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") - AddQuest(conn, QuestEntry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "test-fellowship", "/tmp/repo", "main"); err != nil { + t.Fatal(err) + } + if err := AddQuest(conn, QuestEntry{ Name: "quest-auth", Worktree: "/tmp/wt/quest-auth", Branch: "feat/auth", - }) + }); err != nil { + t.Fatal(err) + } // Insert quest_state row - state.Upsert(conn, &state.State{ + if err := state.Upsert(conn, &state.State{ QuestName: "quest-auth", Phase: "Implement", - }) + }); err != nil { + t.Fatal(err) + } status, err := DiscoverQuests(conn) if err != nil { @@ -270,16 +344,22 @@ func TestDiscoverQuests_WithQuestState(t *testing.T) { t.Errorf("Quest.Worktree = %q, want %q", q.Worktree, "/tmp/wt/quest-auth") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestDiscoverQuests_CompletedNoQuestState(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") - AddQuest(conn, QuestEntry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "test-fellowship", "/tmp/repo", "main"); err != nil { + t.Fatal(err) + } + if err := AddQuest(conn, QuestEntry{ Name: "quest-done", Worktree: "/tmp/wt/done", Status: "completed", - }) + }); err != nil { + t.Fatal(err) + } // No quest_state row — should appear as synthetic Complete entry status, err := DiscoverQuests(conn) @@ -297,16 +377,22 @@ func TestDiscoverQuests_CompletedNoQuestState(t *testing.T) { t.Errorf("Quest.Status = %q, want %q", q.Status, "completed") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestDiscoverQuests_ActiveNoQuestStateSkipped(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") - AddQuest(conn, QuestEntry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "test-fellowship", "/tmp/repo", "main"); err != nil { + t.Fatal(err) + } + if err := AddQuest(conn, QuestEntry{ Name: "quest-active", Worktree: "/tmp/wt/active", - }) + }); err != nil { + t.Fatal(err) + } // No quest_state row, active status — should be skipped status, err := DiscoverQuests(conn) @@ -317,5 +403,7 @@ func TestDiscoverQuests_ActiveNoQuestStateSkipped(t *testing.T) { t.Errorf("expected 0 quests (active with no quest_state should be skipped), got %d", len(status.Quests)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } diff --git a/cli/internal/dashboard/server.go b/cli/internal/dashboard/server.go index 5ccb70c..b1e8889 100644 --- a/cli/internal/dashboard/server.go +++ b/cli/internal/dashboard/server.go @@ -62,12 +62,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } -func (s *Server) validWorktreeDir(dir string) bool { +func (s *Server) validWorktreeDir(dir string) (bool, error) { var valid bool - s.db.WithConn(context.Background(), func(conn *db.Conn) error { + err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { status, err := DiscoverQuests(conn) if err != nil { - return nil + return err } for _, q := range status.Quests { if q.Worktree == dir { @@ -77,7 +77,7 @@ func (s *Server) validWorktreeDir(dir string) bool { } return nil }) - return valid + return valid, err } func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { @@ -87,11 +87,16 @@ func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { return } - if !s.validWorktreeDir(req.Dir) { + if valid, err := s.validWorktreeDir(req.Dir); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else if !valid { http.Error(w, "invalid worktree directory", http.StatusBadRequest) return } + var result QuestStatus + var prevPhase string err := s.db.WithTx(context.Background(), func(conn *db.Conn) error { // Find the quest name for this worktree questName, err := state.FindQuest(conn, req.Dir) @@ -108,7 +113,7 @@ func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { return fmt.Errorf("no gate pending") } - prevPhase := st.Phase + prevPhase = st.Phase nextPhase, err := state.NextPhase(st.Phase) if err != nil { @@ -125,22 +130,7 @@ func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { return err } - now := time.Now().UTC().Format(time.RFC3339) - if err := herald.Announce(conn, herald.Tiding{ - Timestamp: now, Quest: st.QuestName, Type: herald.GateApproved, - Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), - }); err != nil { - return err - } - if err := herald.Announce(conn, herald.Tiding{ - Timestamp: now, Quest: st.QuestName, Type: herald.PhaseTransition, - Phase: st.Phase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, st.Phase), - }); err != nil { - return err - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(QuestStatus{ + result = QuestStatus{ Name: st.QuestName, Worktree: req.Dir, Phase: st.Phase, @@ -148,7 +138,7 @@ func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { GateID: st.GateID, LembasCompleted: st.LembasCompleted, MetadataUpdated: st.MetadataUpdated, - }) + } return nil }) if err != nil { @@ -157,7 +147,25 @@ func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, err.Error(), http.StatusInternalServerError) } + return } + + // Best-effort herald announcements after tx commits. + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + now := time.Now().UTC().Format(time.RFC3339) + herald.Announce(conn, herald.Tiding{ + Timestamp: now, Quest: result.Name, Type: herald.GateApproved, + Phase: prevPhase, Detail: fmt.Sprintf("Gate approved for %s", prevPhase), + }) + herald.Announce(conn, herald.Tiding{ + Timestamp: now, Quest: result.Name, Type: herald.PhaseTransition, + Phase: result.Phase, Detail: fmt.Sprintf("Phase advanced from %s to %s", prevPhase, result.Phase), + }) + return nil + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) } func (s *Server) handleGateReject(w http.ResponseWriter, r *http.Request) { @@ -167,11 +175,15 @@ func (s *Server) handleGateReject(w http.ResponseWriter, r *http.Request) { return } - if !s.validWorktreeDir(req.Dir) { + if valid, err := s.validWorktreeDir(req.Dir); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else if !valid { http.Error(w, "invalid worktree directory", http.StatusBadRequest) return } + var result QuestStatus err := s.db.WithTx(context.Background(), func(conn *db.Conn) error { questName, err := state.FindQuest(conn, req.Dir) if err != nil || questName == "" { @@ -194,16 +206,7 @@ func (s *Server) handleGateReject(w http.ResponseWriter, r *http.Request) { return err } - if err := herald.Announce(conn, herald.Tiding{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - Quest: st.QuestName, Type: herald.GateRejected, - Phase: st.Phase, Detail: fmt.Sprintf("Gate rejected for %s", st.Phase), - }); err != nil { - return err - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(QuestStatus{ + result = QuestStatus{ Name: st.QuestName, Worktree: req.Dir, Phase: st.Phase, @@ -211,7 +214,7 @@ func (s *Server) handleGateReject(w http.ResponseWriter, r *http.Request) { GateID: st.GateID, LembasCompleted: st.LembasCompleted, MetadataUpdated: st.MetadataUpdated, - }) + } return nil }) if err != nil { @@ -220,7 +223,21 @@ func (s *Server) handleGateReject(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, err.Error(), http.StatusInternalServerError) } + return } + + // Best-effort herald announcement after tx commits. + s.db.WithConn(context.Background(), func(conn *db.Conn) error { + herald.Announce(conn, herald.Tiding{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Quest: result.Name, Type: herald.GateRejected, + Phase: result.Phase, Detail: fmt.Sprintf("Gate rejected for %s", result.Phase), + }) + return nil + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) } func (s *Server) handleCompanyApprove(w http.ResponseWriter, r *http.Request) { @@ -370,20 +387,31 @@ func (s *Server) handleErrand(w http.ResponseWriter, r *http.Request) { } dir := string(dirBytes) - if !s.validWorktreeDir(dir) { + if valid, err := s.validWorktreeDir(dir); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else if !valid { http.Error(w, "invalid worktree directory", http.StatusBadRequest) return } var errands []errand.Errand - s.db.WithConn(context.Background(), func(conn *db.Conn) error { + err = s.db.WithConn(context.Background(), func(conn *db.Conn) error { questName, findErr := state.FindQuest(conn, dir) - if findErr != nil || questName == "" { + if findErr != nil { + return findErr + } + if questName == "" { return nil } - errands, _ = errand.List(conn, questName) - return nil + var listErr error + errands, listErr = errand.List(conn, questName) + return listErr }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } if errands == nil { http.Error(w, "no errand file found", http.StatusNotFound) @@ -411,11 +439,15 @@ func (s *Server) worktreeDirs() []string { func (s *Server) handleHerald(w http.ResponseWriter, r *http.Request) { var tidings []herald.Tiding - s.db.WithConn(context.Background(), func(conn *db.Conn) error { + err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { var err error tidings, err = herald.ReadAll(conn, 50) return err }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } if tidings == nil { tidings = []herald.Tiding{} } @@ -425,10 +457,15 @@ func (s *Server) handleHerald(w http.ResponseWriter, r *http.Request) { func (s *Server) handleProblems(w http.ResponseWriter, r *http.Request) { var problems []herald.Problem - s.db.WithConn(context.Background(), func(conn *db.Conn) error { - problems = herald.DetectProblems(conn) - return nil + err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { + var err error + problems, err = herald.DetectProblems(conn) + return err }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } if problems == nil { problems = []herald.Problem{} } @@ -438,11 +475,15 @@ func (s *Server) handleProblems(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBulletin(w http.ResponseWriter, r *http.Request) { var entries []bulletin.Entry - s.db.WithConn(context.Background(), func(conn *db.Conn) error { + err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { var err error entries, err = bulletin.Load(conn) return err }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } if entries == nil { entries = []bulletin.Entry{} } diff --git a/cli/internal/dashboard/server_test.go b/cli/internal/dashboard/server_test.go index 65b127b..dc99e58 100644 --- a/cli/internal/dashboard/server_test.go +++ b/cli/internal/dashboard/server_test.go @@ -19,24 +19,32 @@ func setupTestDB(t *testing.T) (*db.DB, string) { d := db.OpenTest(t) worktreeDir := "/tmp/test-worktrees/quest-login" - d.WithTx(context.Background(), func(conn *db.Conn) error { - InitFellowship(conn, "test-fellowship", "/tmp/repo", "main") - AddQuest(conn, QuestEntry{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := InitFellowship(conn, "test-fellowship", "/tmp/repo", "main"); err != nil { + return err + } + if err := AddQuest(conn, QuestEntry{ Name: "quest-login", Worktree: worktreeDir, TaskID: "t1", - }) + }); err != nil { + return err + } gateID := "gate-plan-review" - state.Upsert(conn, &state.State{ + if err := state.Upsert(conn, &state.State{ QuestName: "quest-login", TaskID: "t1", TeamName: "team", Phase: "Plan", GatePending: true, GateID: &gateID, - }) + }); err != nil { + return err + } return nil - }) + }); err != nil { + t.Fatal(err) + } return d, worktreeDir } @@ -187,11 +195,13 @@ func TestAPIGateApprove_HeraldLogging(t *testing.T) { // Read herald entries from DB var tidings []herald.Tiding - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { var err error tidings, err = herald.Read(conn, "quest-login", 0) return err - }) + }); err != nil { + t.Fatal(err) + } if len(tidings) < 2 { t.Fatalf("expected at least 2 tidings (GateApproved + PhaseTransition), got %d", len(tidings)) @@ -228,11 +238,13 @@ func TestAPIGateReject_HeraldLogging(t *testing.T) { } var tidings []herald.Tiding - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { var err error tidings, err = herald.Read(conn, "quest-login", 0) return err - }) + }); err != nil { + t.Fatal(err) + } var foundRejected bool for _, td := range tidings { diff --git a/cli/internal/db/db.go b/cli/internal/db/db.go index 0255c50..bb5dcb0 100644 --- a/cli/internal/db/db.go +++ b/cli/internal/db/db.go @@ -141,10 +141,6 @@ func resolveMainRepo(fromDir string) (string, error) { } gitCommon = filepath.Clean(gitCommon) - // The main repo root is the parent of the .git directory. - if filepath.Base(gitCommon) == ".git" { - return filepath.Dir(gitCommon), nil - } - // For bare repos or unusual layouts, go up one level. + // The main repo root is the parent of the .git (or equivalent) directory. return filepath.Dir(gitCommon), nil } diff --git a/cli/internal/db/db_test.go b/cli/internal/db/db_test.go index d30a0a2..6ca0270 100644 --- a/cli/internal/db/db_test.go +++ b/cli/internal/db/db_test.go @@ -55,20 +55,24 @@ func TestWithTx_Rollback(t *testing.T) { // Insert a row, then roll back _ = d.WithTx(context.Background(), func(conn *Conn) error { - sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, phase, created_at, updated_at) VALUES ('test', 'Onboard', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil) + if err := sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, phase, created_at, updated_at) VALUES ('test', 'Onboard', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil); err != nil { + t.Fatal(err) + } return fmt.Errorf("rollback") }) // Row should not exist var count int - d.WithConn(context.Background(), func(conn *Conn) error { + if err := d.WithConn(context.Background(), func(conn *Conn) error { return sqlitex.Execute(conn, "SELECT count(*) FROM quest_state", &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { count = stmt.ColumnInt(0) return nil }, }) - }) + }); err != nil { + t.Fatal(err) + } if count != 0 { t.Fatalf("expected 0 rows after rollback, got %d", count) } diff --git a/cli/internal/db/migrate.go b/cli/internal/db/migrate.go index 7e8f641..43f2799 100644 --- a/cli/internal/db/migrate.go +++ b/cli/internal/db/migrate.go @@ -63,7 +63,7 @@ type questStateJSON struct { MetadataUpdated bool `json:"metadata_updated"` Held bool `json:"held"` HeldReason *string `json:"held_reason"` - AutoApprove *string `json:"auto_approve"` + AutoApproveGates []string `json:"auto_approve_gates"` } type questTomeJSON struct { @@ -240,22 +240,27 @@ func discoverJSONFiles(mainRepo string) ([]migrationFile, error) { func scanDataDir(dataDir string, label string) []migrationFile { var files []migrationFile - knownFiles := map[string]string{ - "fellowship-state.json": "fellowship-state", - "quest-state.json": "quest-state", - "quest-tome.json": "quest-tome", - "quest-errands.json": "quest-errands", - "quest-herald.jsonl": "quest-herald", - "bulletin.jsonl": "bulletin", - } - - for name, fileType := range knownFiles { - p := filepath.Join(dataDir, name) + // Ordered to satisfy FK constraints: fellowship-state and quest-state first, + // then tables that reference them. + knownFiles := []struct { + name string + fileType string + }{ + {"fellowship-state.json", "fellowship-state"}, + {"quest-state.json", "quest-state"}, + {"quest-tome.json", "quest-tome"}, + {"quest-errands.json", "quest-errands"}, + {"quest-herald.jsonl", "quest-herald"}, + {"bulletin.jsonl", "bulletin"}, + } + + for _, kf := range knownFiles { + p := filepath.Join(dataDir, kf.name) if _, err := os.Stat(p); err == nil { files = append(files, migrationFile{ path: p, - relPath: filepath.Join(label, name), - fileType: fileType, + relPath: filepath.Join(label, kf.name), + fileType: kf.fileType, }) } } @@ -442,8 +447,9 @@ func migrateQuestState(conn *Conn, data []byte, s *migrationSummary) error { heldReason = *qs.HeldReason } var autoApprove any - if qs.AutoApprove != nil { - autoApprove = *qs.AutoApprove + if len(qs.AutoApproveGates) > 0 { + b, _ := json.Marshal(qs.AutoApproveGates) + autoApprove = string(b) } if err := sqlitex.Execute(conn, @@ -553,6 +559,7 @@ func migrateQuestErrands(conn *Conn, data []byte, s *migrationSummary) error { return fmt.Errorf("parse quest-errands.json: %w", err) } + // Insert all errands first, then deps, to satisfy FK constraints. for _, item := range qe.Items { if err := sqlitex.Execute(conn, `INSERT OR REPLACE INTO errands (id, quest_name, description, status, phase, created_at, updated_at) @@ -570,6 +577,8 @@ func migrateQuestErrands(conn *Conn, data []byte, s *migrationSummary) error { }); err != nil { return err } + } + for _, item := range qe.Items { for _, dep := range item.DependsOn { if err := sqlitex.Execute(conn, `INSERT OR REPLACE INTO errand_deps (quest_name, errand_id, depends_on) @@ -722,8 +731,7 @@ func listWorktreePaths(mainRepo string) ([]string, error) { cmd.Dir = mainRepo out, err := cmd.Output() if err != nil { - // If git worktree fails, just return the main repo - return []string{mainRepo}, nil + return nil, fmt.Errorf("git worktree list: %w", err) } var paths []string for _, line := range strings.Split(string(out), "\n") { diff --git a/cli/internal/db/migrate_test.go b/cli/internal/db/migrate_test.go index bc64303..0c184b5 100644 --- a/cli/internal/db/migrate_test.go +++ b/cli/internal/db/migrate_test.go @@ -31,7 +31,10 @@ func writeFellowshipState(t *testing.T, dir string) { }, CreatedAt: "2026-01-01T00:00:00Z", } - data, _ := json.Marshal(fs) + data, err := json.Marshal(fs) + if err != nil { + t.Fatal(err) + } os.MkdirAll(dir, 0o755) os.WriteFile(filepath.Join(dir, "fellowship-state.json"), data, 0o644) } @@ -52,7 +55,10 @@ func writeQuestState(t *testing.T, dir string) { MetadataUpdated: false, Held: false, } - data, _ := json.Marshal(qs) + data, err := json.Marshal(qs) + if err != nil { + t.Fatal(err) + } os.MkdirAll(dir, 0o755) os.WriteFile(filepath.Join(dir, "quest-state.json"), data, 0o644) } @@ -74,7 +80,10 @@ func writeQuestTome(t *testing.T, dir string) { Respawns: 1, Status: "active", } - data, _ := json.Marshal(qt) + data, err := json.Marshal(qt) + if err != nil { + t.Fatal(err) + } os.MkdirAll(dir, 0o755) os.WriteFile(filepath.Join(dir, "quest-tome.json"), data, 0o644) } @@ -87,6 +96,10 @@ func writeQuestErrands(t *testing.T, dir string) { QuestName: "q1", Task: "build auth", Items: []errandJSON{ + { + ID: "w-000", Description: "design auth", Status: "done", + Phase: "Plan", CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z", + }, { ID: "w-001", Description: "implement login", Status: "pending", Phase: "Implement", CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z", @@ -94,7 +107,10 @@ func writeQuestErrands(t *testing.T, dir string) { }, }, } - data, _ := json.Marshal(qe) + data, err := json.Marshal(qe) + if err != nil { + t.Fatal(err) + } os.MkdirAll(dir, 0o755) os.WriteFile(filepath.Join(dir, "quest-errands.json"), data, 0o644) } @@ -108,7 +124,10 @@ func writeHerald(t *testing.T, dir string) { } var sb strings.Builder for _, l := range lines { - data, _ := json.Marshal(l) + data, err := json.Marshal(l) + if err != nil { + t.Fatal(err) + } sb.Write(data) sb.WriteByte('\n') } @@ -124,7 +143,10 @@ func writeBulletin(t *testing.T, dir string) { } var sb strings.Builder for _, l := range lines { - data, _ := json.Marshal(l) + data, err := json.Marshal(l) + if err != nil { + t.Fatal(err) + } sb.Write(data) sb.WriteByte('\n') } @@ -148,7 +170,10 @@ func writeAutopsy(t *testing.T, dir string, name string) { Tags: []string{"flaky"}, ExpiresAt: "2026-04-02T00:00:00Z", } - data, _ := json.Marshal(a) + data, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } autopsyDir := filepath.Join(dir, "autopsies") os.MkdirAll(autopsyDir, 0o755) os.WriteFile(filepath.Join(autopsyDir, name), data, 0o644) @@ -193,10 +218,10 @@ func TestMigrateJSON(t *testing.T) { } // Verify all data migrated correctly - d.WithConn(t.Context(), func(conn *Conn) error { + if err := d.WithConn(t.Context(), func(conn *Conn) error { // 1. Fellowship var name, mainRepo, baseBranch string - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT name, main_repo, base_branch FROM fellowship WHERE id = 1`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { @@ -205,14 +230,16 @@ func TestMigrateJSON(t *testing.T) { baseBranch = stmt.ColumnText(2) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "fellowship.name", "test-fellowship", name) assertEqual(t, "fellowship.main_repo", "/tmp/repo", mainRepo) assertEqual(t, "fellowship.base_branch", "main", baseBranch) // 2. Fellowship quests var questName, taskDesc, branch string - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT name, task_description, branch FROM fellowship_quests WHERE name = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { @@ -221,14 +248,16 @@ func TestMigrateJSON(t *testing.T) { branch = stmt.ColumnText(2) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "quest.name", "q1", questName) assertEqual(t, "quest.task_description", "build auth", taskDesc) assertEqual(t, "quest.branch", "feat/q1", branch) // 3. Fellowship scouts var scoutName, question string - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT name, question FROM fellowship_scouts WHERE name = 's1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { @@ -236,26 +265,30 @@ func TestMigrateJSON(t *testing.T) { question = stmt.ColumnText(1) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "scout.name", "s1", scoutName) assertEqual(t, "scout.question", "how does auth work?", question) // 4. Companies and members var companyCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM company_members WHERE company_name = 'team-a'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { companyCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "company_members.count", 2, companyCount) // 5. Quest state var phase string var lembasCompleted int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT phase, lembas_completed FROM quest_state WHERE quest_name = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { @@ -263,50 +296,58 @@ func TestMigrateJSON(t *testing.T) { lembasCompleted = stmt.ColumnInt(1) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "quest_state.phase", "Implement", phase) assertEqual(t, "quest_state.lembas_completed", 1, lembasCompleted) // 6. Quest phases (from tome) var phaseCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM quest_phases WHERE quest_name = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { phaseCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "quest_phases.count", 1, phaseCount) // 7. Quest gates (from tome) var gateCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM quest_gates WHERE quest_name = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { gateCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "quest_gates.count", 1, gateCount) // 8. Quest files (from tome) var fileCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM quest_files WHERE quest_name = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { fileCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "quest_files.count", 2, fileCount) // 9. Respawns updated in fellowship_quests var respawns int var status string - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT respawns, status FROM fellowship_quests WHERE name = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { @@ -314,111 +355,135 @@ func TestMigrateJSON(t *testing.T) { status = stmt.ColumnText(1) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "fellowship_quests.respawns", 1, respawns) assertEqual(t, "fellowship_quests.status", "active", status) // 10. Errands var errandCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM errands WHERE quest_name = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { errandCount = stmt.ColumnInt(0) return nil }, - }) - assertEqual(t, "errands.count", 1, errandCount) + }); err != nil { + return err + } + assertEqual(t, "errands.count", 2, errandCount) // 11. Errand deps var depCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM errand_deps WHERE quest_name = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { depCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "errand_deps.count", 1, depCount) // 12. Herald events var heraldCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM herald WHERE quest = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { heraldCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "herald.count", 2, heraldCount) // 13. Bulletin entries var bulletinCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM bulletin WHERE quest = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { bulletinCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "bulletin.count", 1, bulletinCount) // 14. Bulletin files var bfCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM bulletin_files`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { bfCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "bulletin_files.count", 1, bfCount) // 15. Autopsies var autopsyCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM autopsies WHERE quest = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { autopsyCount = stmt.ColumnInt(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "autopsies.count", 1, autopsyCount) // Verify trigger_type mapping (JSON "trigger" -> DB "trigger_type") var triggerType string - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT trigger_type FROM autopsies WHERE quest = 'q1'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { triggerType = stmt.ColumnText(0) return nil }, - }) + }); err != nil { + return err + } assertEqual(t, "autopsies.trigger_type", "recovery", triggerType) // 16. Autopsy files/modules/tags var afCount, amCount, atCount int - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM autopsy_files`, - &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { afCount = stmt.ColumnInt(0); return nil }}) - sqlitex.Execute(conn, + &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { afCount = stmt.ColumnInt(0); return nil }}); err != nil { + return err + } + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM autopsy_modules`, - &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { amCount = stmt.ColumnInt(0); return nil }}) - sqlitex.Execute(conn, + &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { amCount = stmt.ColumnInt(0); return nil }}); err != nil { + return err + } + if err := sqlitex.Execute(conn, `SELECT COUNT(*) FROM autopsy_tags`, - &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { atCount = stmt.ColumnInt(0); return nil }}) + &sqlitex.ExecOptions{ResultFunc: func(stmt *sqlite.Stmt) error { atCount = stmt.ColumnInt(0); return nil }}); err != nil { + return err + } assertEqual(t, "autopsy_files.count", 1, afCount) assertEqual(t, "autopsy_modules.count", 1, amCount) assertEqual(t, "autopsy_tags.count", 1, atCount) return nil - }) + }); err != nil { + t.Fatal(err) + } // Verify backup directory created with correct structure backupDir := filepath.Join(mainDataDir, "backup") diff --git a/cli/internal/db/schema.go b/cli/internal/db/schema.go index 789b1e9..e8519eb 100644 --- a/cli/internal/db/schema.go +++ b/cli/internal/db/schema.go @@ -70,7 +70,8 @@ var schema = []string{ errand_id TEXT NOT NULL, depends_on TEXT NOT NULL, PRIMARY KEY (quest_name, errand_id, depends_on), - FOREIGN KEY (quest_name, errand_id) REFERENCES errands(quest_name, id) + FOREIGN KEY (quest_name, errand_id) REFERENCES errands(quest_name, id), + FOREIGN KEY (quest_name, depends_on) REFERENCES errands(quest_name, id) )`, // Herald event log (replaces quest-herald.jsonl) @@ -188,7 +189,9 @@ var schema = []string{ BEGIN INSERT INTO state_changelog(table_name, operation, quest_name, new_value) VALUES('quest_state', 'INSERT', NEW.quest_name, - json_object('phase', NEW.phase, 'gate_pending', NEW.gate_pending, 'held', NEW.held)); + json_object('phase', NEW.phase, 'gate_pending', NEW.gate_pending, 'held', NEW.held, + 'held_reason', NEW.held_reason, 'gate_id', NEW.gate_id, + 'task_id', NEW.task_id, 'team_name', NEW.team_name, 'auto_approve', NEW.auto_approve)); END`, `CREATE TRIGGER IF NOT EXISTS quest_state_update AFTER UPDATE ON quest_state @@ -196,8 +199,12 @@ var schema = []string{ INSERT INTO state_changelog(table_name, operation, quest_name, old_value, new_value) VALUES('quest_state', 'UPDATE', NEW.quest_name, json_object('phase', OLD.phase, 'gate_pending', OLD.gate_pending, 'held', OLD.held, + 'held_reason', OLD.held_reason, 'gate_id', OLD.gate_id, + 'task_id', OLD.task_id, 'team_name', OLD.team_name, 'auto_approve', OLD.auto_approve, 'lembas_completed', OLD.lembas_completed, 'metadata_updated', OLD.metadata_updated), json_object('phase', NEW.phase, 'gate_pending', NEW.gate_pending, 'held', NEW.held, + 'held_reason', NEW.held_reason, 'gate_id', NEW.gate_id, + 'task_id', NEW.task_id, 'team_name', NEW.team_name, 'auto_approve', NEW.auto_approve, 'lembas_completed', NEW.lembas_completed, 'metadata_updated', NEW.metadata_updated)); END`, } diff --git a/cli/internal/errand/errand_test.go b/cli/internal/errand/errand_test.go index 1dd5875..05b65ff 100644 --- a/cli/internal/errand/errand_test.go +++ b/cli/internal/errand/errand_test.go @@ -11,16 +11,18 @@ import ( func seedQuest(t *testing.T, d *db.DB, name string) { t.Helper() - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { return state.Upsert(conn, &state.State{QuestName: name, Phase: "Implement"}) - }) + }); err != nil { + t.Fatal(err) + } } func TestAddAndList(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { id, err := errand.Add(conn, "q1", "Build auth module", "Implement") if err != nil { t.Fatal(err) @@ -29,7 +31,10 @@ func TestAddAndList(t *testing.T) { t.Errorf("expected w-001, got %s", id) } - items, _ := errand.List(conn, "q1") + items, err := errand.List(conn, "q1") + if err != nil { + t.Fatal(err) + } if len(items) != 1 { t.Fatalf("expected 1, got %d", len(items)) } @@ -37,17 +42,28 @@ func TestAddAndList(t *testing.T) { t.Error("description mismatch") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestAddSequentialIDs(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { - id1, _ := errand.Add(conn, "q1", "first", "Implement") - id2, _ := errand.Add(conn, "q1", "second", "Implement") - id3, _ := errand.Add(conn, "q1", "third", "Review") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + id1, err := errand.Add(conn, "q1", "first", "Implement") + if err != nil { + t.Fatal(err) + } + id2, err := errand.Add(conn, "q1", "second", "Implement") + if err != nil { + t.Fatal(err) + } + id3, err := errand.Add(conn, "q1", "third", "Review") + if err != nil { + t.Fatal(err) + } if id1 != "w-001" { t.Errorf("first ID = %q, want w-001", id1) @@ -59,7 +75,10 @@ func TestAddSequentialIDs(t *testing.T) { t.Errorf("third ID = %q, want w-003", id3) } - items, _ := errand.List(conn, "q1") + items, err := errand.List(conn, "q1") + if err != nil { + t.Fatal(err) + } if len(items) != 3 { t.Errorf("Items count = %d, want 3", len(items)) } @@ -70,63 +89,93 @@ func TestAddSequentialIDs(t *testing.T) { t.Errorf("Item 2 Phase = %q, want Review", items[2].Phase) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestUpdateStatus(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { - errand.Add(conn, "q1", "Task 1", "") - errand.UpdateStatus(conn, "q1", "w-001", errand.Done) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if _, err := errand.Add(conn, "q1", "Task 1", ""); err != nil { + t.Fatal(err) + } + if err := errand.UpdateStatus(conn, "q1", "w-001", errand.Done); err != nil { + t.Fatal(err) + } - items, _ := errand.List(conn, "q1") + items, err := errand.List(conn, "q1") + if err != nil { + t.Fatal(err) + } if items[0].Status != errand.Done { t.Errorf("expected done, got %s", items[0].Status) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestUpdateStatusNotFound(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { err := errand.UpdateStatus(conn, "q1", "w-999", errand.Done) if err == nil { t.Fatal("expected error for nonexistent errand") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestProgress(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { - errand.Add(conn, "q1", "A", "") - errand.Add(conn, "q1", "B", "") - errand.UpdateStatus(conn, "q1", "w-001", errand.Done) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if _, err := errand.Add(conn, "q1", "A", ""); err != nil { + t.Fatal(err) + } + if _, err := errand.Add(conn, "q1", "B", ""); err != nil { + t.Fatal(err) + } + if err := errand.UpdateStatus(conn, "q1", "w-001", errand.Done); err != nil { + t.Fatal(err) + } - done, total, _ := errand.Progress(conn, "q1") + done, total, err := errand.Progress(conn, "q1") + if err != nil { + t.Fatal(err) + } if done != 1 || total != 2 { t.Errorf("expected 1/2, got %d/%d", done, total) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestPendingErrands(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { - errand.Add(conn, "q1", "A", "") - errand.Add(conn, "q1", "B", "") - errand.UpdateStatus(conn, "q1", "w-001", errand.Done) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if _, err := errand.Add(conn, "q1", "A", ""); err != nil { + t.Fatal(err) + } + if _, err := errand.Add(conn, "q1", "B", ""); err != nil { + t.Fatal(err) + } + if err := errand.UpdateStatus(conn, "q1", "w-001", errand.Done); err != nil { + t.Fatal(err) + } pending, err := errand.PendingErrands(conn, "q1") if err != nil { @@ -139,7 +188,9 @@ func TestPendingErrands(t *testing.T) { t.Errorf("expected w-002, got %s", pending[0].ID) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestValidStatus(t *testing.T) { diff --git a/cli/internal/herald/herald_test.go b/cli/internal/herald/herald_test.go index 71058fe..e5e7473 100644 --- a/cli/internal/herald/herald_test.go +++ b/cli/internal/herald/herald_test.go @@ -12,19 +12,23 @@ import ( func TestAnnounceAndRead(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Announce(conn, Tiding{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Announce(conn, Tiding{ Timestamp: "2026-01-01T00:00:00Z", Quest: "q1", Type: GateSubmitted, Phase: "Research", - }) - Announce(conn, Tiding{ + }); err != nil { + t.Fatal(err) + } + if err := Announce(conn, Tiding{ Timestamp: "2026-01-01T00:01:00Z", Quest: "q1", Type: GateApproved, Phase: "Research", - }) + }); err != nil { + t.Fatal(err) + } tidings, err := Read(conn, "q1", 0) if err != nil { @@ -40,19 +44,23 @@ func TestAnnounceAndRead(t *testing.T) { t.Errorf("tidings[1].Type = %q, want %q", tidings[1].Type, GateApproved) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestReadReturnsLatestN(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { for i := 0; i < 10; i++ { - Announce(conn, Tiding{ + if err := Announce(conn, Tiding{ Timestamp: fmt.Sprintf("2026-01-01T00:%02d:00Z", i), Quest: "q1", Type: MetadataUpdated, Detail: fmt.Sprintf("tiding-%d", i), - }) + }); err != nil { + t.Fatal(err) + } } tidings, err := Read(conn, "q1", 3) @@ -70,12 +78,14 @@ func TestReadReturnsLatestN(t *testing.T) { t.Errorf("tidings[2].Detail = %q, want tiding-9", tidings[2].Detail) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestReadNoData(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { tidings, err := Read(conn, "nonexistent", 10) if err != nil { t.Fatal(err) @@ -84,45 +94,60 @@ func TestReadNoData(t *testing.T) { t.Fatalf("got %d tidings, want 0", len(tidings)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestReadAll_Limit(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { for i := 0; i < 5; i++ { - Announce(conn, Tiding{ + if err := Announce(conn, Tiding{ Timestamp: fmt.Sprintf("2026-01-01T00:%02d:00Z", i), Quest: "q1", Type: PhaseTransition, - }) + }); err != nil { + t.Fatal(err) + } + } + tidings, err := ReadAll(conn, 3) + if err != nil { + t.Fatal(err) } - tidings, _ := ReadAll(conn, 3) if len(tidings) != 3 { t.Fatalf("expected 3, got %d", len(tidings)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestReadAllAcrossQuests(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - Announce(conn, Tiding{ + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := Announce(conn, Tiding{ Timestamp: "2026-01-01T00:00:00Z", Quest: "q1", Type: GateSubmitted, - }) - Announce(conn, Tiding{ + }); err != nil { + t.Fatal(err) + } + if err := Announce(conn, Tiding{ Timestamp: "2026-01-01T00:05:00Z", Quest: "q2", Type: PhaseTransition, - }) - Announce(conn, Tiding{ + }); err != nil { + t.Fatal(err) + } + if err := Announce(conn, Tiding{ Timestamp: "2026-01-01T00:10:00Z", Quest: "q1", Type: GateApproved, - }) + }); err != nil { + t.Fatal(err) + } tidings, err := ReadAll(conn, 0) if err != nil { @@ -142,22 +167,33 @@ func TestReadAllAcrossQuests(t *testing.T) { t.Errorf("tidings[2] = %+v, want q1/gate_approved", tidings[2]) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestDetectProblems_Struggling(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { // Create a quest in Research phase - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, phase, gate_pending, created_at, updated_at) - VALUES ('q1', 'Research', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil) + VALUES ('q1', 'Research', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil); err != nil { + t.Fatal(err) + } // Add 2 rejections in Research phase - Announce(conn, Tiding{Timestamp: "2026-01-01T00:01:00Z", Quest: "q1", Type: GateRejected, Phase: "Research"}) - Announce(conn, Tiding{Timestamp: "2026-01-01T00:02:00Z", Quest: "q1", Type: GateRejected, Phase: "Research"}) + if err := Announce(conn, Tiding{Timestamp: "2026-01-01T00:01:00Z", Quest: "q1", Type: GateRejected, Phase: "Research"}); err != nil { + t.Fatal(err) + } + if err := Announce(conn, Tiding{Timestamp: "2026-01-01T00:02:00Z", Quest: "q1", Type: GateRejected, Phase: "Research"}); err != nil { + t.Fatal(err) + } - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } found := false for _, p := range problems { if p.Type == "struggling" && p.Quest == "q1" { @@ -169,21 +205,30 @@ func TestDetectProblems_Struggling(t *testing.T) { t.Errorf("expected struggling problem for q1, got %+v", problems) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestDetectProblems_NoProblems(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { // Quest in Complete phase should not be checked - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, phase, gate_pending, created_at, updated_at) - VALUES ('q1', 'Complete', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil) + VALUES ('q1', 'Complete', 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil); err != nil { + t.Fatal(err) + } - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } if len(problems) != 0 { t.Errorf("expected 0 problems, got %+v", problems) } return nil - }) + }); err != nil { + t.Fatal(err) + } } diff --git a/cli/internal/herald/problems.go b/cli/internal/herald/problems.go index 6c2fa63..b6c116e 100644 --- a/cli/internal/herald/problems.go +++ b/cli/internal/herald/problems.go @@ -29,7 +29,7 @@ type Problem struct { } // DetectProblems scans the database for potential quest issues. -func DetectProblems(conn *db.Conn) []Problem { +func DetectProblems(conn *db.Conn) ([]Problem, error) { var problems []Problem // Query all active quests (not Complete). @@ -41,7 +41,7 @@ func DetectProblems(conn *db.Conn) []Problem { } var quests []questInfo - _ = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT quest_name, phase, gate_pending, gate_id FROM quest_state WHERE phase != 'Complete'`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { @@ -54,7 +54,9 @@ func DetectProblems(conn *db.Conn) []Problem { return nil }, }, - ) + ); err != nil { + return nil, fmt.Errorf("detect problems: query quests: %w", err) + } for _, qs := range quests { // Stalled detection: gate pending for too long @@ -74,7 +76,7 @@ func DetectProblems(conn *db.Conn) []Problem { // Zombie detection: no recent activity var lastTimestamp string - _ = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT timestamp FROM herald WHERE quest = ? ORDER BY id DESC LIMIT 1`, &sqlitex.ExecOptions{ Args: []any{qs.questName}, @@ -83,7 +85,9 @@ func DetectProblems(conn *db.Conn) []Problem { return nil }, }, - ) + ); err != nil { + return nil, fmt.Errorf("detect problems: query herald for %s: %w", qs.questName, err) + } if lastTimestamp != "" { lastTime, err := time.Parse(time.RFC3339, lastTimestamp) if err == nil { @@ -101,7 +105,7 @@ func DetectProblems(conn *db.Conn) []Problem { // Struggling detection: multiple rejections in same phase var rejections int - _ = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT count(*) FROM herald WHERE quest = ? AND type = ? AND phase = ?`, &sqlitex.ExecOptions{ Args: []any{qs.questName, string(GateRejected), qs.phase}, @@ -110,7 +114,9 @@ func DetectProblems(conn *db.Conn) []Problem { return nil }, }, - ) + ); err != nil { + return nil, fmt.Errorf("detect problems: query rejections for %s: %w", qs.questName, err) + } if rejections >= 2 { problems = append(problems, Problem{ Quest: qs.questName, @@ -121,7 +127,7 @@ func DetectProblems(conn *db.Conn) []Problem { } } - return problems + return problems, nil } func extractTimestamp(gateID string) int64 { diff --git a/cli/internal/herald/problems_test.go b/cli/internal/herald/problems_test.go index 451e08c..716a982 100644 --- a/cli/internal/herald/problems_test.go +++ b/cli/internal/herald/problems_test.go @@ -11,7 +11,8 @@ import ( "github.com/justinjdev/fellowship/cli/internal/db" ) -func insertQuestState(conn *db.Conn, questName, phase string, gatePending bool, gateID string) { +func insertQuestState(t *testing.T, conn *db.Conn, questName, phase string, gatePending bool, gateID string) { + t.Helper() gp := 0 if gatePending { gp = 1 @@ -20,23 +21,28 @@ func insertQuestState(conn *db.Conn, questName, phase string, gatePending bool, if gateID != "" { gateIDArg = gateID } - sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, phase, gate_pending, gate_id, created_at, updated_at) VALUES (?, ?, ?, ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, &sqlitex.ExecOptions{ Args: []any{questName, phase, gp, gateIDArg}, }, - ) + ); err != nil { + t.Fatal(err) + } } func TestStalledDetection(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { oldTimestamp := time.Now().Add(-15 * time.Minute).Unix() gateID := fmt.Sprintf("gate-Plan-%d", oldTimestamp) - insertQuestState(conn, "q1", "Plan", true, gateID) + insertQuestState(t, conn, "q1", "Plan", true, gateID) - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } var found bool for _, p := range problems { @@ -51,17 +57,22 @@ func TestStalledDetection(t *testing.T) { t.Errorf("expected stalled problem, got %v", problems) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestStalledNotDetectedWhenRecent(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { recentTimestamp := time.Now().Add(-2 * time.Minute).Unix() gateID := fmt.Sprintf("gate-Plan-%d", recentTimestamp) - insertQuestState(conn, "q1", "Plan", true, gateID) + insertQuestState(t, conn, "q1", "Plan", true, gateID) - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } for _, p := range problems { if p.Type == "stalled" { @@ -69,22 +80,29 @@ func TestStalledNotDetectedWhenRecent(t *testing.T) { } } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestZombieDetection(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - insertQuestState(conn, "q1", "Implement", false, "") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(t, conn, "q1", "Implement", false, "") oldTime := time.Now().Add(-20 * time.Minute).UTC().Format(time.RFC3339) - Announce(conn, Tiding{ + if err := Announce(conn, Tiding{ Timestamp: oldTime, Quest: "q1", Type: MetadataUpdated, - }) + }); err != nil { + t.Fatal(err) + } - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } var found bool for _, p := range problems { @@ -99,22 +117,29 @@ func TestZombieDetection(t *testing.T) { t.Errorf("expected zombie problem, got %v", problems) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestZombieNotDetectedWhenComplete(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - insertQuestState(conn, "q1", "Complete", false, "") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(t, conn, "q1", "Complete", false, "") oldTime := time.Now().Add(-20 * time.Minute).UTC().Format(time.RFC3339) - Announce(conn, Tiding{ + if err := Announce(conn, Tiding{ Timestamp: oldTime, Quest: "q1", Type: MetadataUpdated, - }) + }); err != nil { + t.Fatal(err) + } - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } for _, p := range problems { if p.Type == "zombie" { @@ -122,19 +147,28 @@ func TestZombieNotDetectedWhenComplete(t *testing.T) { } } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestStrugglingDetection(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - insertQuestState(conn, "q1", "Plan", false, "") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(t, conn, "q1", "Plan", false, "") now := time.Now().UTC().Format(time.RFC3339) - Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}) - Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}) + if err := Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}); err != nil { + t.Fatal(err) + } + if err := Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}); err != nil { + t.Fatal(err) + } - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } var found bool for _, p := range problems { @@ -149,18 +183,25 @@ func TestStrugglingDetection(t *testing.T) { t.Errorf("expected struggling problem, got %v", problems) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestStrugglingNotDetectedWithOneRejection(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - insertQuestState(conn, "q1", "Plan", false, "") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(t, conn, "q1", "Plan", false, "") now := time.Now().UTC().Format(time.RFC3339) - Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}) + if err := Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}); err != nil { + t.Fatal(err) + } - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } for _, p := range problems { if p.Type == "struggling" { @@ -168,27 +209,36 @@ func TestStrugglingNotDetectedWithOneRejection(t *testing.T) { } } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestNoProblemsForHealthyQuest(t *testing.T) { d := db.OpenTest(t) - d.WithTx(context.Background(), func(conn *db.Conn) error { - insertQuestState(conn, "q1", "Implement", false, "") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(t, conn, "q1", "Implement", false, "") now := time.Now().UTC().Format(time.RFC3339) - Announce(conn, Tiding{ + if err := Announce(conn, Tiding{ Timestamp: now, Quest: "q1", Type: GateApproved, Phase: "Plan", - }) + }); err != nil { + t.Fatal(err) + } - problems := DetectProblems(conn) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } if len(problems) != 0 { t.Errorf("expected no problems, got %v", problems) } return nil - }) + }); err != nil { + t.Fatal(err) + } } diff --git a/cli/internal/hooks/completion.go b/cli/internal/hooks/completion.go index 1edab41..aeb668c 100644 --- a/cli/internal/hooks/completion.go +++ b/cli/internal/hooks/completion.go @@ -22,6 +22,6 @@ func CompletionGuard(s *state.State, input *HookInput) HookResult { } // MarkTomeCompleted marks the quest tome status as "completed". -func MarkTomeCompleted(conn *sqlite.Conn, questName string) { - tome.SetStatus(conn, questName, "completed") +func MarkTomeCompleted(conn *sqlite.Conn, questName string) error { + return tome.SetStatus(conn, questName, "completed") } diff --git a/cli/internal/hooks/files.go b/cli/internal/hooks/files.go index ad4d75e..606aea6 100644 --- a/cli/internal/hooks/files.go +++ b/cli/internal/hooks/files.go @@ -18,19 +18,9 @@ func FileTrack(conn *sqlite.Conn, s *state.State, input *HookInput, questName st return false } - // Check if file already recorded. - existing, err := tome.LoadFiles(conn, questName) - if err != nil { - return false - } - for _, f := range existing { - if f == filePath { - return false - } - } - + // RecordFiles uses INSERT OR IGNORE, so duplicates are silently skipped. if err := tome.RecordFiles(conn, questName, []string{filePath}); err != nil { return false } - return true + return conn.Changes() > 0 } diff --git a/cli/internal/hooks/files_test.go b/cli/internal/hooks/files_test.go index 97d5873..33289cf 100644 --- a/cli/internal/hooks/files_test.go +++ b/cli/internal/hooks/files_test.go @@ -11,9 +11,11 @@ import ( func seedQuest(t *testing.T, d *db.DB, name string) { t.Helper() - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { return state.Upsert(conn, &state.State{QuestName: name, Phase: "Implement"}) - }) + }); err != nil { + t.Fatal(err) + } } func TestFileTrack_EditToolInput(t *testing.T) { @@ -26,15 +28,17 @@ func TestFileTrack_EditToolInput(t *testing.T) { } var modified bool - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { modified = FileTrack(conn, s, input, "q1") return nil - }) + }); err != nil { + t.Fatal(err) + } if !modified { t.Error("FileTrack should return true on first file write") } - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { files, err := tome.LoadFiles(conn, "q1") if err != nil { t.Fatalf("loading files: %v", err) @@ -46,7 +50,9 @@ func TestFileTrack_EditToolInput(t *testing.T) { t.Errorf("files[0] = %q, want /home/user/project/main.go", files[0]) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestFileTrack_NotebookPath(t *testing.T) { @@ -59,21 +65,28 @@ func TestFileTrack_NotebookPath(t *testing.T) { } var modified bool - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { modified = FileTrack(conn, s, input, "q1") return nil - }) + }); err != nil { + t.Fatal(err) + } if !modified { t.Error("FileTrack should return true for notebook path") } - d.WithConn(context.Background(), func(conn *db.Conn) error { - files, _ := tome.LoadFiles(conn, "q1") + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { + files, err := tome.LoadFiles(conn, "q1") + if err != nil { + t.Fatal(err) + } if len(files) != 1 || files[0] != "/home/user/project/analysis.ipynb" { t.Errorf("expected notebook path in files, got %v", files) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestFileTrack_DataDirPathExclusion(t *testing.T) { @@ -96,13 +109,15 @@ func TestFileTrack_DataDirPathExclusion(t *testing.T) { input := &HookInput{ ToolInput: ToolInput{FilePath: tt.path}, } - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { modified := FileTrack(conn, s, input, "q1") if modified { t.Errorf("FileTrack should return false for data dir path %q", tt.path) } return nil - }) + }); err != nil { + t.Fatal(err) + } }) } } @@ -116,13 +131,15 @@ func TestFileTrack_EmptyFilePath(t *testing.T) { ToolInput: ToolInput{}, } - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { modified := FileTrack(conn, s, input, "q1") if modified { t.Error("FileTrack should return false when no file path present") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestFileTrack_Deduplication(t *testing.T) { @@ -134,17 +151,22 @@ func TestFileTrack_Deduplication(t *testing.T) { ToolInput: ToolInput{FilePath: "/home/user/project/main.go"}, } - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { FileTrack(conn, s, input, "q1") modified := FileTrack(conn, s, input, "q1") if modified { t.Error("FileTrack should return false on duplicate file") } - files, _ := tome.LoadFiles(conn, "q1") + files, err := tome.LoadFiles(conn, "q1") + if err != nil { + t.Fatal(err) + } if len(files) != 1 { t.Errorf("files len = %d, want 1", len(files)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } diff --git a/cli/internal/hooks/submit.go b/cli/internal/hooks/submit.go index a5882c9..235d402 100644 --- a/cli/internal/hooks/submit.go +++ b/cli/internal/hooks/submit.go @@ -71,12 +71,19 @@ func GateSubmit(s *state.State, input *HookInput) SubmitResult { // RecordGateSubmitted records a "submitted" gate event in the quest tome. // If autoApproved is true, the phase is also recorded as completed. -func RecordGateSubmitted(conn *sqlite.Conn, questName, phase string, autoApproved bool) { - tome.RecordGate(conn, questName, phase, "submitted", "") +func RecordGateSubmitted(conn *sqlite.Conn, questName, phase string, autoApproved bool) error { + if err := tome.RecordGate(conn, questName, phase, "submitted", ""); err != nil { + return err + } if autoApproved { - tome.RecordGate(conn, questName, phase, "approved", "") - tome.RecordPhase(conn, questName, phase, 0) + if err := tome.RecordGate(conn, questName, phase, "approved", ""); err != nil { + return err + } + if err := tome.RecordPhase(conn, questName, phase, 0); err != nil { + return err + } } + return nil } // HookSpecificOutput is the JSON structure Claude Code expects from diff --git a/cli/internal/state/state.go b/cli/internal/state/state.go index 7b09d71..279cbbf 100644 --- a/cli/internal/state/state.go +++ b/cli/internal/state/state.go @@ -2,6 +2,7 @@ package state import ( "encoding/json" + "errors" "fmt" "time" @@ -9,6 +10,9 @@ import ( "zombiezen.com/go/sqlite/sqlitex" ) +// ErrNotFound is returned when a quest does not exist in the database. +var ErrNotFound = errors.New("state: quest not found") + type State struct { QuestName string `json:"quest_name"` TaskID string `json:"task_id"` @@ -70,7 +74,9 @@ func Load(conn *sqlite.Conn, questName string) (*State, error) { s.HeldReason = &hr } if aa := stmt.ColumnText(10); aa != "" { - json.Unmarshal([]byte(aa), &s.AutoApproveGates) + if err := json.Unmarshal([]byte(aa), &s.AutoApproveGates); err != nil { + return fmt.Errorf("unmarshal auto_approve: %w", err) + } } return nil }, @@ -79,7 +85,7 @@ func Load(conn *sqlite.Conn, questName string) (*State, error) { return nil, fmt.Errorf("state: load %s: %w", questName, err) } if !found { - return nil, fmt.Errorf("state: quest %q not found", questName) + return nil, fmt.Errorf("%w: %s", ErrNotFound, questName) } return &s, nil } diff --git a/cli/internal/status/status_test.go b/cli/internal/status/status_test.go index b236625..fc9a7e4 100644 --- a/cli/internal/status/status_test.go +++ b/cli/internal/status/status_test.go @@ -103,7 +103,7 @@ func TestParseMergedBranches(t *testing.T) { func TestScanLoadsFellowshipFromDB(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { // Insert fellowship row. err := sqlitex.Execute(conn, `INSERT INTO fellowship (id, version, name, main_repo, base_branch, created_at) @@ -133,13 +133,15 @@ func TestScanLoadsFellowshipFromDB(t *testing.T) { t.Errorf("expected 0 quests, got %d", len(result.Quests)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScanNoFellowship(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { result, err := Scan(conn, "/tmp/nonexistent-repo") if err != nil { t.Fatal(err) @@ -152,13 +154,15 @@ func TestScanNoFellowship(t *testing.T) { t.Errorf("expected 0 quests, got %d", len(result.Quests)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScanQuestsFromDB(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { // Insert fellowship. err := sqlitex.Execute(conn, `INSERT INTO fellowship (id, version, name, main_repo, base_branch, created_at) @@ -219,13 +223,15 @@ func TestScanQuestsFromDB(t *testing.T) { t.Errorf("Classification = %q, want %q", q.Classification, "stale") } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestScanQuestWithoutState(t *testing.T) { d := db.OpenTest(t) - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { // Insert fellowship. err := sqlitex.Execute(conn, `INSERT INTO fellowship (id, version, name, main_repo, base_branch, created_at) @@ -265,5 +271,7 @@ func TestScanQuestWithoutState(t *testing.T) { t.Error("expected GatePending to be false") } return nil - }) + }); err != nil { + t.Fatal(err) + } } diff --git a/cli/internal/tome/tome.go b/cli/internal/tome/tome.go index 2e3164f..8bdb7ce 100644 --- a/cli/internal/tome/tome.go +++ b/cli/internal/tome/tome.go @@ -157,7 +157,7 @@ func Load(conn *sqlite.Conn, questName string) (*QuestTome, error) { // Load status/task/respawns from fellowship_quests. var status, task string var respawns int - _ = sqlitex.Execute(conn, + if err := sqlitex.Execute(conn, `SELECT status, task_description, respawns FROM fellowship_quests WHERE name = :name`, &sqlitex.ExecOptions{ Named: map[string]any{":name": questName}, @@ -167,7 +167,9 @@ func Load(conn *sqlite.Conn, questName string) (*QuestTome, error) { respawns = stmt.ColumnInt(2) return nil }, - }) + }); err != nil { + return nil, fmt.Errorf("tome: load fellowship_quests: %w", err) + } if status == "" { status = "active" } diff --git a/cli/internal/tome/tome_test.go b/cli/internal/tome/tome_test.go index abac5e0..40531c5 100644 --- a/cli/internal/tome/tome_test.go +++ b/cli/internal/tome/tome_test.go @@ -12,16 +12,18 @@ import ( func seedQuest(t *testing.T, d *db.DB, name string) { t.Helper() - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { return state.Upsert(conn, &state.State{QuestName: name, Phase: "Research"}) - }) + }); err != nil { + t.Fatal(err) + } } func TestRecordPhase(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { if err := tome.RecordPhase(conn, "q1", "Research", 120); err != nil { t.Fatal(err) } @@ -33,18 +35,27 @@ func TestRecordPhase(t *testing.T) { t.Errorf("unexpected phases: %+v", phases) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestRecordGate(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { - tome.RecordGate(conn, "q1", "Research", "submitted", "") - tome.RecordGate(conn, "q1", "Research", "approved", "") + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := tome.RecordGate(conn, "q1", "Research", "submitted", ""); err != nil { + t.Fatal(err) + } + if err := tome.RecordGate(conn, "q1", "Research", "approved", ""); err != nil { + t.Fatal(err) + } - gates, _ := tome.LoadGates(conn, "q1") + gates, err := tome.LoadGates(conn, "q1") + if err != nil { + t.Fatal(err) + } if len(gates) != 2 { t.Fatalf("expected 2 gates, got %d", len(gates)) } @@ -52,33 +63,50 @@ func TestRecordGate(t *testing.T) { t.Errorf("expected submitted, got %s", gates[0].Action) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestRecordFiles(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { - tome.RecordFiles(conn, "q1", []string{"src/main.go", "src/util.go"}) - tome.RecordFiles(conn, "q1", []string{"src/main.go", "src/new.go"}) // main.go deduplicated + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := tome.RecordFiles(conn, "q1", []string{"src/main.go", "src/util.go"}); err != nil { + t.Fatal(err) + } + if err := tome.RecordFiles(conn, "q1", []string{"src/main.go", "src/new.go"}); err != nil { + t.Fatal(err) + } - files, _ := tome.LoadFiles(conn, "q1") + files, err := tome.LoadFiles(conn, "q1") + if err != nil { + t.Fatal(err) + } if len(files) != 3 { t.Fatalf("expected 3 unique files, got %d: %v", len(files), files) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestLoad(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { - tome.RecordPhase(conn, "q1", "Onboard", 60) - tome.RecordGate(conn, "q1", "Onboard", "approved", "") - tome.RecordFiles(conn, "q1", []string{"a.go"}) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := tome.RecordPhase(conn, "q1", "Onboard", 60); err != nil { + t.Fatal(err) + } + if err := tome.RecordGate(conn, "q1", "Onboard", "approved", ""); err != nil { + t.Fatal(err) + } + if err := tome.RecordFiles(conn, "q1", []string{"a.go"}); err != nil { + t.Fatal(err) + } qt, err := tome.Load(conn, "q1") if err != nil { @@ -94,14 +122,16 @@ func TestLoad(t *testing.T) { t.Errorf("expected 1 file, got %d", len(qt.FilesTouched)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestLoad_NoData(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithConn(context.Background(), func(conn *db.Conn) error { + if err := d.WithConn(context.Background(), func(conn *db.Conn) error { qt, err := tome.Load(conn, "q1") if err != nil { t.Fatal(err) @@ -122,24 +152,32 @@ func TestLoad_NoData(t *testing.T) { t.Errorf("expected 0 files, got %d", len(qt.FilesTouched)) } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestRecordSkippedPhases(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { if err := tome.RecordSkippedPhases(conn, "q1", []string{"Onboard", "Research", "Plan"}, "pre-existing plan"); err != nil { t.Fatal(err) } - phases, _ := tome.LoadPhases(conn, "q1") + phases, err := tome.LoadPhases(conn, "q1") + if err != nil { + t.Fatal(err) + } if len(phases) != 3 { t.Fatalf("expected 3 phases, got %d", len(phases)) } - gates, _ := tome.LoadGates(conn, "q1") + gates, err := tome.LoadGates(conn, "q1") + if err != nil { + t.Fatal(err) + } if len(gates) != 3 { t.Fatalf("expected 3 gates, got %d", len(gates)) } @@ -159,21 +197,27 @@ func TestRecordSkippedPhases(t *testing.T) { } } return nil - }) + }); err != nil { + t.Fatal(err) + } } func TestSetStatus(t *testing.T) { d := db.OpenTest(t) seedQuest(t, d, "q1") - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { // Insert a fellowship_quests row for SetStatus to update. - tome.SetStatus(conn, "q1", "completed") // no-op since no fellowship_quests row yet + if err := tome.SetStatus(conn, "q1", "completed"); err != nil { + t.Fatal(err) + } return nil - }) + }); err != nil { + t.Fatal(err) + } // Insert fellowship_quests row and test SetStatus. - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { // Manually insert a fellowship_quests row. if err := sqlitex.Execute(conn, `INSERT INTO fellowship_quests (name, status) VALUES ('q1', 'active')`, nil); err != nil { t.Fatal(err) @@ -182,10 +226,15 @@ func TestSetStatus(t *testing.T) { t.Fatal(err) } - qt, _ := tome.Load(conn, "q1") + qt, err := tome.Load(conn, "q1") + if err != nil { + t.Fatal(err) + } if qt.Status != "completed" { t.Errorf("expected completed, got %s", qt.Status) } return nil - }) + }); err != nil { + t.Fatal(err) + } } From c085f1e9156ee82a13daf8314e9d64975d339aa3 Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 11:31:00 -0500 Subject: [PATCH 17/19] fix: address second PR review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create .fellowship/ directory before opening DB (critical — new repos) - Wrap expires_at in datetime() for correct expiry comparison in autopsy - Return fellowship query error in status.Scan instead of ignoring - Fix remaining unchecked test errors (db_test, server_test) Co-Authored-By: Claude Opus 4.6 --- cli/internal/autopsy/autopsy.go | 2 +- cli/internal/dashboard/server_test.go | 6 ++++-- cli/internal/db/db.go | 5 +++++ cli/internal/db/db_test.go | 8 ++++++-- cli/internal/status/status.go | 6 +++++- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/cli/internal/autopsy/autopsy.go b/cli/internal/autopsy/autopsy.go index 56d000f..c7acd7e 100644 --- a/cli/internal/autopsy/autopsy.go +++ b/cli/internal/autopsy/autopsy.go @@ -214,7 +214,7 @@ func Scan(conn *sqlite.Conn, opts ScanOptions, expiryDays int) ([]Autopsy, error `SELECT a.id, a.timestamp, a.quest, a.task, a.phase, a.trigger_type, a.what_failed, a.resolution, a.expires_at FROM autopsies a - WHERE a.expires_at > datetime('now') + WHERE datetime(a.expires_at) > datetime('now') AND (%s) ORDER BY a.timestamp DESC`, strings.Join(conditions, " OR ")) diff --git a/cli/internal/dashboard/server_test.go b/cli/internal/dashboard/server_test.go index dc99e58..b45afd7 100644 --- a/cli/internal/dashboard/server_test.go +++ b/cli/internal/dashboard/server_test.go @@ -158,7 +158,7 @@ func TestAPIGateApprove_NoPending(t *testing.T) { d, worktreeDir := setupTestDB(t) // Override quest state with gate_pending: false - d.WithTx(context.Background(), func(conn *db.Conn) error { + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { return state.Upsert(conn, &state.State{ QuestName: "quest-login", TaskID: "t1", @@ -166,7 +166,9 @@ func TestAPIGateApprove_NoPending(t *testing.T) { Phase: "Plan", GatePending: false, }) - }) + }); err != nil { + t.Fatal(err) + } srv := NewServer(d, 5) diff --git a/cli/internal/db/db.go b/cli/internal/db/db.go index bb5dcb0..96f2dbb 100644 --- a/cli/internal/db/db.go +++ b/cli/internal/db/db.go @@ -3,6 +3,7 @@ package db import ( "context" "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -33,6 +34,10 @@ func Open(fromDir string) (*DB, error) { // OpenPath opens a DB at the given file path. func OpenPath(dbPath string) (*DB, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + return nil, fmt.Errorf("db: mkdir %s: %w", filepath.Dir(dbPath), err) + } + pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{ PoolSize: 1, Flags: sqlite.OpenReadWrite | sqlite.OpenCreate | sqlite.OpenWAL, diff --git a/cli/internal/db/db_test.go b/cli/internal/db/db_test.go index 6ca0270..6660e8f 100644 --- a/cli/internal/db/db_test.go +++ b/cli/internal/db/db_test.go @@ -54,12 +54,16 @@ func TestWithTx_Rollback(t *testing.T) { defer d.Close() // Insert a row, then roll back - _ = d.WithTx(context.Background(), func(conn *Conn) error { + rollbackErr := fmt.Errorf("rollback") + err = d.WithTx(context.Background(), func(conn *Conn) error { if err := sqlitex.Execute(conn, `INSERT INTO quest_state (quest_name, phase, created_at, updated_at) VALUES ('test', 'Onboard', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, nil); err != nil { t.Fatal(err) } - return fmt.Errorf("rollback") + return rollbackErr }) + if err == nil || err.Error() != rollbackErr.Error() { + t.Fatalf("expected rollback error, got %v", err) + } // Row should not exist var count int diff --git a/cli/internal/status/status.go b/cli/internal/status/status.go index 1ee512a..fe4af80 100644 --- a/cli/internal/status/status.go +++ b/cli/internal/status/status.go @@ -1,6 +1,7 @@ package status import ( + "fmt" "path/filepath" "strings" @@ -97,7 +98,10 @@ func Scan(conn *sqlite.Conn, gitRoot string) (*StatusResult, error) { return nil }, }) - if err == nil && hasFellowship { + if err != nil { + return nil, fmt.Errorf("status: load fellowship: %w", err) + } + if hasFellowship { result.Fellowship = &FellowshipInfo{ Name: fellowshipName, CreatedAt: fellowshipCreatedAt, From 7548d21b8e24da79090353351216a6591ad3f2be Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 13:42:27 -0500 Subject: [PATCH 18/19] fix: remove dead code in autopsy scan, add LIKE escaping, wrap error context - Remove build-then-remove pattern in autopsy file matching (dead code) - Add LIKE wildcard escaping for directory paths containing % or _ - Wrap status quest-loading error with context for consistency Co-Authored-By: Claude Opus 4.6 --- cli/internal/autopsy/autopsy.go | 32 ++++++-------------------------- cli/internal/status/status.go | 2 +- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/cli/internal/autopsy/autopsy.go b/cli/internal/autopsy/autopsy.go index c7acd7e..379bebd 100644 --- a/cli/internal/autopsy/autopsy.go +++ b/cli/internal/autopsy/autopsy.go @@ -142,28 +142,6 @@ func Scan(conn *sqlite.Conn, opts ScanOptions, expiryDays int) ([]Autopsy, error var args []any if len(opts.Files) > 0 { - filePlaceholders := make([]string, len(opts.Files)) - for i, f := range opts.Files { - filePlaceholders[i] = "?" - args = append(args, f) - } - // Match by exact file path or same directory - conditions = append(conditions, - fmt.Sprintf(`a.id IN ( - SELECT af.autopsy_id FROM autopsy_files af - WHERE af.file_path IN (%s) - OR EXISTS ( - SELECT 1 FROM autopsy_files af2 - WHERE af2.autopsy_id = af.autopsy_id - AND af2.file_path != af.file_path - ) - )`, strings.Join(filePlaceholders, ","))) - // Actually, we need directory-level matching. Let's use a simpler approach: - // For each query file, match autopsies that have a file in the same directory. - conditions = conditions[:len(conditions)-1] // remove the above - args = args[:len(args)-len(opts.Files)] // remove args - - // Use a subquery that checks directory matching var fileCondParts []string for _, f := range opts.Files { // Exact match @@ -173,14 +151,16 @@ func Scan(conn *sqlite.Conn, opts ScanOptions, expiryDays int) ([]Autopsy, error // Same directory match (for files with directories) dir := filepath.Dir(filepath.ToSlash(f)) if dir != "." { - args = append(args, dir+"/%") - fileCondParts = append(fileCondParts, "af.file_path LIKE ?") + escaped := strings.ReplaceAll(strings.ReplaceAll(dir, "%", "\\%"), "_", "\\_") + args = append(args, escaped+"/%") + fileCondParts = append(fileCondParts, "af.file_path LIKE ? ESCAPE '\\'") } // Query file is under a directory prefix in the autopsy if strings.HasSuffix(f, "/") { - args = append(args, f+"%") - fileCondParts = append(fileCondParts, "af.file_path LIKE ?") + escaped := strings.ReplaceAll(strings.ReplaceAll(f, "%", "\\%"), "_", "\\_") + args = append(args, escaped+"%") + fileCondParts = append(fileCondParts, "af.file_path LIKE ? ESCAPE '\\'") } } conditions = append(conditions, diff --git a/cli/internal/status/status.go b/cli/internal/status/status.go index fe4af80..9d23df3 100644 --- a/cli/internal/status/status.go +++ b/cli/internal/status/status.go @@ -129,7 +129,7 @@ func Scan(conn *sqlite.Conn, gitRoot string) (*StatusResult, error) { }, }) if err != nil { - return nil, err + return nil, fmt.Errorf("status: load quests: %w", err) } // Discover merged branches (git operation). From a5bdba66aa1f551976006abdc28eb1f1d201681e Mon Sep 17 00:00:00 2001 From: Justin Jones Date: Fri, 13 Mar 2026 14:12:59 -0500 Subject: [PATCH 19/19] fix: add deterministic ordering to quest query in status Co-Authored-By: Claude Opus 4.6 --- cli/internal/status/status.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/internal/status/status.go b/cli/internal/status/status.go index 9d23df3..9e1c2de 100644 --- a/cli/internal/status/status.go +++ b/cli/internal/status/status.go @@ -114,7 +114,8 @@ func Scan(conn *sqlite.Conn, gitRoot string) (*StatusResult, error) { `SELECT fq.name, fq.task_description, fq.worktree, fq.branch, COALESCE(qs.phase, ''), COALESCE(qs.gate_pending, 0) FROM fellowship_quests fq - LEFT JOIN quest_state qs ON fq.name = qs.quest_name`, + LEFT JOIN quest_state qs ON fq.name = qs.quest_name + ORDER BY fq.name`, &sqlitex.ExecOptions{ ResultFunc: func(stmt *sqlite.Stmt) error { rows = append(rows, questRow{