diff --git a/cli/cmd/fellowship/main.go b/cli/cmd/fellowship/main.go index a1087cb..96cd454 100644 --- a/cli/cmd/fellowship/main.go +++ b/cli/cmd/fellowship/main.go @@ -1,7 +1,9 @@ package main import ( + "context" "encoding/json" + "errors" "flag" "fmt" "net/http" @@ -15,14 +17,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 +38,87 @@ func main() { os.Exit(1) } + // Commands that don't need DB. + switch os.Args[1] { + case "version": + fmt.Println(version) + return + case "migrate": + if err := runMigrate(); err != nil { + fmt.Fprintf(os.Stderr, "fellowship: migrate: %v\n", err) + os.Exit(1) + } + return + } + + // 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 +153,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 +161,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 +223,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 +233,40 @@ 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 + gitRoot := gitRootFrom(cwd) + + // Find quest name for this worktree. + var questName string + 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 state file): only the CWD guard applies. - if statePath == "" { + // 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 { @@ -266,151 +291,207 @@ 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" { + if err := hooks.MarkTomeCompleted(conn, questName); err != nil { + return err + } + } + 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 + 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, + 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 +506,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 +521,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 +588,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 +600,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 +623,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 +654,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 +665,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 +735,74 @@ 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 && !errors.Is(loadErr, state.ErrNotFound) { + return fmt.Errorf("loading quest state: %w", loadErr) } - 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 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) + } + + 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 +813,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 +866,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 +882,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 +907,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 +934,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 +992,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 +1008,47 @@ 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 + 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)) @@ -955,8 +1067,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 +1097,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 +1117,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 +1135,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 +1157,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 +1209,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 +1239,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 +1247,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 +1331,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 +1370,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 +1391,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 +1410,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 +1512,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 +1532,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 +1559,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 +1591,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 +1602,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 +1611,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 +1635,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 +1655,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() - } - - 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 - } - fmt.Fprintf(os.Stderr, "fellowship: %v\n", err) - return 1 + type cleanResult struct { + name string + wasPending bool + wasHeld bool } - cleaned := 0 - for _, entry := range entries { - if !entry.IsDir() { - continue + 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 } - 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 +1776,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 +1792,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 +1803,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 +1831,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 +1879,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 +1888,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 +1923,93 @@ 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 +} + +// 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/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/autopsy/autopsy.go b/cli/internal/autopsy/autopsy.go index 21423c0..379bebd 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,377 @@ 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) - } + // 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 - cutoff := time.Now().UTC().AddDate(0, 0, -expiryDays) - var matches []Autopsy + if len(opts.Files) > 0 { + 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 != "." { + escaped := strings.ReplaceAll(strings.ReplaceAll(dir, "%", "\\%"), "_", "\\_") + args = append(args, escaped+"/%") + fileCondParts = append(fileCondParts, "af.file_path LIKE ? ESCAPE '\\'") + } - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { - continue + // Query file is under a directory prefix in the autopsy + if strings.HasSuffix(f, "/") { + escaped := strings.ReplaceAll(strings.ReplaceAll(f, "%", "\\%"), "_", "\\_") + args = append(args, escaped+"%") + fileCondParts = append(fileCondParts, "af.file_path LIKE ? ESCAPE '\\'") + } } + conditions = append(conditions, + fmt.Sprintf("a.id IN (SELECT af.autopsy_id FROM autopsy_files af WHERE %s)", + strings.Join(fileCondParts, " OR "))) + } - path := filepath.Join(dir, entry.Name()) - a, err := loadAutopsy(path) - if err != nil { - return nil, fmt.Errorf("reading autopsy %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) } + conditions = append(conditions, + fmt.Sprintf("a.id IN (SELECT am.autopsy_id FROM autopsy_modules am WHERE am.module IN (%s))", + strings.Join(placeholders, ","))) + } - 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 ts.Before(cutoff) { - os.Remove(path) // best-effort cleanup; don't abort scan on failure - continue + 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 datetime(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 +} + +// 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 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) +// 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 +427,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..d48324e 100644 --- a/cli/internal/autopsy/autopsy_test.go +++ b/cli/internal/autopsy/autopsy_test.go @@ -1,444 +1,520 @@ 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) + 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"}, + 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 + }); err != nil { + t.Fatal(err) + } } 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) + if err := 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 + }); err != nil { + t.Fatal(err) } } func TestCreate_MissingQuest(t *testing.T) { - repo := setupTestRepo(t) - _, err := Create(repo, &CreateInput{ - Trigger: "recovery", - WhatFailed: "something", - }) - if err == nil { - t.Error("expected error for missing quest") + d := db.OpenTest(t) + if err := 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 + }); err != nil { + t.Fatal(err) } } func TestCreate_InvalidTrigger(t *testing.T) { - repo := setupTestRepo(t) - _, err := Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "invalid", - WhatFailed: "something", - }) - if err == nil { - t.Error("expected error for invalid trigger") + d := db.OpenTest(t) + if err := 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 + }); err != nil { + t.Fatal(err) } } func TestCreate_MissingWhatFailed(t *testing.T) { - repo := setupTestRepo(t) - _, err := Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - }) - if err == nil { - t.Error("expected error for missing what_failed") + d := db.OpenTest(t) + if err := 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 + }); err != nil { + t.Fatal(err) } } 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", - }) - 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") + d := db.OpenTest(t) + 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) { - repo := setupTestRepo(t) - Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - Files: []string{"src/auth/jwt.go"}, - WhatFailed: "auth issue", - }) - - 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)) + d := db.OpenTest(t) + if err := 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 + }); err != nil { + t.Fatal(err) } } func TestScan_MatchByModule(t *testing.T) { - repo := setupTestRepo(t) - Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - Modules: []string{"auth"}, - WhatFailed: "auth issue", - }) - - 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)) + d := db.OpenTest(t) + if err := 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 + }); err != nil { + t.Fatal(err) } } func TestScan_MatchByTag(t *testing.T) { - repo := setupTestRepo(t) - Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - Tags: []string{"caching", "auth"}, - WhatFailed: "cache issue", - }) - - 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)) + d := db.OpenTest(t) + if err := 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 + }); err != nil { + t.Fatal(err) } } func TestScan_NoMatch(t *testing.T) { - repo := setupTestRepo(t) - Create(repo, &CreateInput{ - Quest: "quest-1", - Trigger: "recovery", - Modules: []string{"auth"}, - WhatFailed: "auth issue", - }) - - 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") + d := db.OpenTest(t) + if err := 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 + }); err != nil { + t.Fatal(err) } } 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) + 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_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) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + // Insert an autopsy with an already-expired expires_at + 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); err != nil { + t.Fatal(err) + } + oldID := conn.LastInsertRowID() + + // Add a module so we can search for it + if err := sqlitex.Execute(conn, + `INSERT INTO autopsy_modules (autopsy_id, module) VALUES (?, 'auth')`, + &sqlitex.ExecOptions{Args: []any{oldID}}); 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 + }); err != nil { + t.Fatal(err) } } 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) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + // Set up fellowship_quests row + if err := sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, status, respawns) + 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) + if err := sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, created_at, updated_at) + VALUES ('quest-respawned', datetime('now'), datetime('now'))`, nil); err != nil { + t.Fatal(err) + } + + // Add phase history + if err := sqlitex.Execute(conn, + `INSERT INTO quest_phases (quest_name, phase, completed_at) + 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"} { + if err := sqlitex.Execute(conn, + `INSERT INTO quest_files (quest_name, file_path) VALUES ('quest-respawned', ?)`, + &sqlitex.ExecOptions{Args: []any{f}}); 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 + }); err != nil { + t.Fatal(err) } } 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", - }) - - 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) + d := db.OpenTest(t) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + // Set up fellowship_quests + if err := sqlitex.Execute(conn, + `INSERT INTO fellowship_quests (name, task_description, status, respawns) + VALUES ('quest-rejected', 'Add billing', 'active', 0)`, nil); err != nil { + t.Fatal(err) + } + + // Set up quest_state (for FK) + if err := sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, created_at, updated_at) + VALUES ('quest-rejected', datetime('now'), datetime('now'))`, nil); err != nil { + t.Fatal(err) + } + + // Add gate rejection + 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); err != nil { + t.Fatal(err) + } + + // Add phase + if err := sqlitex.Execute(conn, + `INSERT INTO quest_phases (quest_name, phase, completed_at) + VALUES ('quest-rejected', 'Plan', datetime('now'))`, nil); err != nil { + t.Fatal(err) + } + + // Add files + 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) + } + + 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 + }); err != nil { + t.Fatal(err) } } 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) + 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); err != nil { + t.Fatal(err) + } + + if err := sqlitex.Execute(conn, + `INSERT INTO quest_state (quest_name, created_at, updated_at) + VALUES ('quest-abandoned', datetime('now'), datetime('now'))`, nil); 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 + if err := 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 + }, + }); 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) { - 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) + 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); err != nil { + t.Fatal(err) + } + + _, 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 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) + 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) } } @@ -455,17 +531,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)) - } -} - diff --git a/cli/internal/bulletin/bulletin.go b/cli/internal/bulletin/bulletin.go index 385999f..092988d 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 []Entry{}, 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..ed9de40 100644 --- a/cli/internal/bulletin/bulletin_test.go +++ b/cli/internal/bulletin/bulletin_test.go @@ -1,317 +1,220 @@ 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") + d := db.OpenTest(t) + 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) + } + if len(entries) != 1 { + t.Fatalf("expected 1, got %d", len(entries)) + } + if entries[0].Topic != "auth" { + t.Error("topic mismatch") + } + return nil + }); err != nil { + t.Fatal(err) } } -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)) +func TestScan_ByFile(t *testing.T) { + d := db.OpenTest(t) + 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", + }); 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", + }); 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 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) + 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", + }); 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 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) + 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) + } + 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 + }); err != nil { + t.Fatal(err) } } -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 { +func TestPostPreservesTimestamp(t *testing.T) { + d := db.OpenTest(t) + 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) + } + if entries[0].Timestamp != "2026-01-01T00:00:00Z" { + t.Errorf("expected preserved timestamp, got %s", entries[0].Timestamp) + } + return nil + }); 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 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"}) + d := db.OpenTest(t) + 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(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) + 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 + }); err != nil { + t.Fatal(err) } } 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"}) + d := db.OpenTest(t) + 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) + } - 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)) + // "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 + }); err != nil { + t.Fatal(err) } } 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) + 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) } - } -} - -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 { + entries, err := Scan(conn, 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)) + } + return nil + }); 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)) - } -} - -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 { +func TestLoadEmpty(t *testing.T) { + d := db.OpenTest(t) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + entries, err := Load(conn) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("expected empty entries, got %v", entries) + } + return nil + }); err != nil { t.Fatal(err) } - expected := filepath.Join("/repo", datadir.Name(), "bulletin.jsonl") - if path != expected { - t.Errorf("expected %s, got %s", expected, path) - } } 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..2ca004b 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,112 +86,141 @@ 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, - }) +func TestBatchApprove_MultipleQuests(t *testing.T) { + d := db.OpenTest(t) + 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 + } - company := dashboard.CompanyEntry{ - Name: "batch-test", - Quests: []string{"q1", "q2"}, - } - fs := &dashboard.FellowshipState{ - Quests: []dashboard.QuestEntry{ - {Name: "q1", Worktree: wt1}, - {Name: "q2", Worktree: wt2}, - }, - } + if err := state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Research", + GatePending: true, + }); err != nil { + return err + } + if err := state.Upsert(conn, &state.State{ + QuestName: "q2", + Phase: "Plan", + GatePending: true, + }); err != nil { + return err + } - approved, errs := BatchApprove(company, fs) + company := dashboard.CompanyEntry{ + Name: "batch-test", + Quests: []string{"q1", "q2"}, + } - 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)) - } + approved, errs := BatchApprove(conn, company) - // 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") - } + 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, 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) + } + 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, 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) { - tmpDir := t.TempDir() - wt := filepath.Join(tmpDir, "wt") - os.MkdirAll(filepath.Join(wt, ".fellowship"), 0755) + d := db.OpenTest(t) + 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 + } - writeState(t, filepath.Join(wt, ".fellowship", "quest-state.json"), &state.State{ - Version: 1, - Phase: "Implement", - GatePending: false, - }) + if err := state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Implement", + GatePending: false, + }); err != nil { + return err + } - company := dashboard.CompanyEntry{ - Name: "no-gates", - Quests: []string{"q1"}, - } - fs := &dashboard.FellowshipState{ - Quests: []dashboard.QuestEntry{ - {Name: "q1", Worktree: wt}, - }, - } + 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 + }); err != nil { + t.Fatal(err) } } -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) + 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 + if err := dashboard.AddQuest(conn, dashboard.QuestEntry{Name: "q1", Worktree: "/tmp/wt"}); err != nil { + return err + } - 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 + }); err != nil { + t.Fatal(err) } } @@ -225,67 +255,327 @@ func TestProgressSummary(t *testing.T) { } func TestBatchApprove_HeraldLogging(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("HOME", t.TempDir()) + d := db.OpenTest(t) + 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 + } - wt1 := filepath.Join(tmpDir, "wt1") - os.MkdirAll(filepath.Join(wt1, ".fellowship"), 0755) + if err := state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Research", + GatePending: true, + }); err != nil { + return err + } - writeState(t, filepath.Join(wt1, ".fellowship", "quest-state.json"), &state.State{ - Version: 1, - QuestName: "q1", - Phase: "Research", - GatePending: true, - }) + company := dashboard.CompanyEntry{ + Name: "herald-test", + Quests: []string{"q1"}, + } - company := dashboard.CompanyEntry{ - Name: "herald-test", - Quests: []string{"q1"}, - } - fs := &dashboard.FellowshipState{ - Quests: []dashboard.QuestEntry{ - {Name: "q1", Worktree: wt1}, - }, + approved, errs := BatchApprove(conn, company) + + 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)) + } + + 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 + }); err != nil { + t.Fatal(err) } +} + +func TestBatchApprove_TomeRecording(t *testing.T) { + d := db.OpenTest(t) + 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 := state.Upsert(conn, &state.State{ + QuestName: "q1", + Phase: "Plan", + GatePending: true, + }); err != nil { + return err + } + + company := dashboard.CompanyEntry{ + Name: "tome-test", + Quests: []string{"q1"}, + } + + approved, _ := BatchApprove(conn, company) + if len(approved) != 1 { + t.Fatalf("expected 1 approved, got %d", len(approved)) + } - approved, errs := BatchApprove(company, fs) + 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) + } + + 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 + }); err != nil { + t.Fatal(err) + } +} - if len(errs) != 0 { - t.Errorf("expected no errors, got %v", errs) +func TestList_NoCompanies(t *testing.T) { + d := db.OpenTest(t) + 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) } - if len(approved) != 1 { - t.Fatalf("expected 1 approved, got %d", len(approved)) +} + +func TestList_WithCompanies(t *testing.T) { + d := db.OpenTest(t) + 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) } +} - tidings, err := herald.Read(wt1, 0) - if err != nil { - t.Fatalf("reading herald: %v", err) +func TestShow_CompanyNotFound(t *testing.T) { + d := db.OpenTest(t) + 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) } +} - var foundApproved, foundTransition bool - for _, td := range tidings { - if td.Type == herald.GateApproved && td.Phase == "Research" { - foundApproved = true +func TestShow_WithQuestState(t *testing.T) { + d := db.OpenTest(t) + 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 td.Type == herald.PhaseTransition && td.Phase == "Plan" { - foundTransition = true + if err := dashboard.AddCompany(conn, "team-alpha", []string{"q1"}, []string{}); err != nil { + return err } + + 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) } - if !foundApproved { - t.Error("expected GateApproved tiding for Research phase") +} + +func TestApprove_CompanyNotFound(t *testing.T) { + d := db.OpenTest(t) + 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) } - if !foundTransition { - t.Error("expected PhaseTransition tiding for Plan phase") +} + +func TestApprove_WithPendingGates(t *testing.T) { + d := db.OpenTest(t) + 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 + } + + 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 { + t.Fatalf("Approve() error: %v", err) + } + + // Verify state was advanced + 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 writeState(t *testing.T, path string, s *state.State) { - t.Helper() - data, err := json.MarshalIndent(s, "", " ") - if err != nil { +func TestLoadAndMarshalProgress(t *testing.T) { + d := db.OpenTest(t) + 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 + } + + 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 { + 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 + }); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, data, 0644); err != nil { +} + +func TestLoadAndMarshalProgress_NotFound(t *testing.T) { + d := db.OpenTest(t) + 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.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..88ed0e8 100644 --- a/cli/internal/dashboard/fellowship_test.go +++ b/cli/internal/dashboard/fellowship_test.go @@ -1,287 +1,273 @@ 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) + 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) + } + 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 + }); err != nil { + t.Fatal(err) } } -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) + 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", + }); 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)) + } + 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 + }); err != nil { + t.Fatal(err) } } -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) + 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)) + } + 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)) + 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 TestSaveFellowshipState_RoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "fellowship-state.json") +func TestAddCompany(t *testing.T) { + d := db.OpenTest(t) + 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) + } - 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"}}, - }, + companies, err := ListCompanies(conn) + if err != nil { + t.Fatal(err) + } + 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 + }); err != nil { + t.Fatal(err) } +} - if err := SaveFellowshipState(path, original); err != nil { - t.Fatalf("SaveFellowshipState() error: %v", err) - } +func TestUpdateQuest(t *testing.T) { + d := db.OpenTest(t) + 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) + } - loaded, err := LoadFellowshipState(path) - if err != nil { - t.Fatalf("LoadFellowshipState() error: %v", err) + quests, err := ListQuests(conn) + if err != nil { + t.Fatal(err) + } + 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 + }); err != nil { + t.Fatal(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") +func TestRemoveQuest(t *testing.T) { + d := db.OpenTest(t) + 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 TestSaveFellowshipState_NilSlices(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "fellowship-state.json") +func TestSaveFellowship_RoundTrip(t *testing.T) { + d := db.OpenTest(t) + 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) + } - s := &FellowshipState{ - Version: 1, - Name: "test", - CreatedAt: "2025-01-15T10:30:00Z", - MainRepo: "/repo", - } + 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 + }); err != nil { + t.Fatal(err) } } -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) + 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) } } @@ -301,211 +287,123 @@ 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) + if err := 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 + }); err != nil { + t.Fatal(err) } } -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) - } +func TestDiscoverQuests_WithQuestState(t *testing.T) { + d := db.OpenTest(t) + 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) + } - status, err := DiscoverQuests(root) - if err != nil { - t.Fatalf("DiscoverQuests() error: %v", err) - } + // Insert quest_state row + if err := state.Upsert(conn, &state.State{ + QuestName: "quest-auth", + Phase: "Implement", + }); err != nil { + t.Fatal(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") + 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 + }); err != nil { + t.Fatal(err) } } -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) - } +func TestDiscoverQuests_CompletedNoQuestState(t *testing.T) { + d := db.OpenTest(t) + 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) + } - if len(status.Quests) != 0 { - t.Errorf("len(Quests) = %d, want 0 (active quest with missing worktree should be skipped)", len(status.Quests)) + // 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 + }); err != nil { + t.Fatal(err) } } -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) - } +func TestDiscoverQuests_ActiveNoQuestStateSkipped(t *testing.T) { + d := db.OpenTest(t) + 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) + } - 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") + // 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 + }); err != nil { + t.Fatal(err) } } diff --git a/cli/internal/dashboard/server.go b/cli/internal/dashboard/server.go index 254f3ac..b1e8889 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) @@ -62,17 +62,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } -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 +func (s *Server) validWorktreeDir(dir string) (bool, error) { + var valid bool + err := s.db.WithConn(context.Background(), func(conn *db.Conn) error { + status, err := DiscoverQuests(conn) + if err != nil { + return err } - } - return false + for _, q := range status.Quests { + if q.Worktree == dir { + valid = true + break + } + } + return nil + }) + return valid, err } func (s *Server) handleGateApprove(w http.ResponseWriter, r *http.Request) { @@ -82,62 +87,85 @@ 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 } - 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 - } + 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) + 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) + st.GatePending = false + st.Phase = nextPhase + st.GateID = nil + st.LembasCompleted = false + st.MetadataUpdated = false + + if err := state.Upsert(conn, st); err != nil { + return err + } + + result = 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) + } return } - 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), + // 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(QuestStatus{ - Name: st.QuestName, - Worktree: req.Dir, - Phase: st.Phase, - GatePending: st.GatePending, - GateID: st.GateID, - LembasCompleted: st.LembasCompleted, - MetadataUpdated: st.MetadataUpdated, - }) + json.NewEncoder(w).Encode(result) } func (s *Server) handleGateReject(w http.ResponseWriter, r *http.Request) { @@ -147,47 +175,69 @@ 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 } - 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 - } + 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 == "" { + 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) + st.GatePending = false + st.GateID = nil + + if err := state.Upsert(conn, st); err != nil { + return err + } + + result = 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) + } return } - 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), + // 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(QuestStatus{ - Name: st.QuestName, - Worktree: req.Dir, - Phase: st.Phase, - GatePending: st.GatePending, - GateID: st.GateID, - LembasCompleted: st.LembasCompleted, - MetadataUpdated: st.MetadataUpdated, - }) + json.NewEncoder(w).Encode(result) } func (s *Server) handleCompanyApprove(w http.ResponseWriter, r *http.Request) { @@ -200,38 +250,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 +299,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 +334,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) } @@ -299,7 +358,12 @@ func batchApproveCompany(c CompanyEntry, fs *FellowshipState) (approved []string func (s *Server) handleEagles(w http.ResponseWriter, r *http.Request) { opts := eagles.DefaultOptions() - report, err := eagles.Sweep(s.gitRoot, opts) + 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 err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -309,10 +373,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 } @@ -323,37 +387,63 @@ 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 } - errandPath := filepath.Join(dir, datadir.Name(), "quest-errands.json") - h, err := errand.Load(errandPath) + var errands []errand.Errand + err = s.db.WithConn(context.Background(), func(conn *db.Conn) error { + questName, findErr := state.FindQuest(conn, dir) + if findErr != nil { + return findErr + } + if 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) 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) + var tidings []herald.Tiding + 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 @@ -366,8 +456,16 @@ 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 + 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{} } @@ -376,8 +474,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) + var entries []bulletin.Entry + 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 @@ -390,7 +492,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..b45afd7 100644 --- a/cli/internal/dashboard/server_test.go +++ b/cli/internal/dashboard/server_test.go @@ -1,71 +1,57 @@ 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" + + 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" + 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 } 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 +97,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 +126,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,27 +155,23 @@ 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 + if err := 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, + }) + }); err != nil { + t.Fatal(err) } + srv := NewServer(d, 5) + body := strings.NewReader(fmt.Sprintf(`{"dir":%q}`, worktreeDir)) req := httptest.NewRequest("POST", "/api/gate/approve", body) w := httptest.NewRecorder() @@ -203,10 +183,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 +195,16 @@ 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 + 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)) } @@ -242,10 +227,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,9 +239,13 @@ 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 + 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 @@ -272,8 +260,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/db/db.go b/cli/internal/db/db.go new file mode 100644 index 0000000..96f2dbb --- /dev/null +++ b/cli/internal/db/db.go @@ -0,0 +1,151 @@ +package db + +import ( + "context" + "fmt" + "os" + "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) { + 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, + }) + 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, + }) + 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 (or equivalent) directory. + 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..6660e8f --- /dev/null +++ b/cli/internal/db/db_test.go @@ -0,0 +1,83 @@ +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 + 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 rollbackErr + }) + if err == nil || err.Error() != rollbackErr.Error() { + t.Fatalf("expected rollback error, got %v", err) + } + + // Row should not exist + var count int + 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 new file mode 100644 index 0000000..43f2799 --- /dev/null +++ b/cli/internal/db/migrate.go @@ -0,0 +1,757 @@ +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"` + AutoApproveGates []string `json:"auto_approve_gates"` +} + +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 + + // 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, kf.name), + fileType: kf.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 len(qs.AutoApproveGates) > 0 { + b, _ := json.Marshal(qs.AutoApproveGates) + autoApprove = string(b) + } + + 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) + } + + // 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) + 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 _, 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) + 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 { + return nil, fmt.Errorf("git worktree list: %w", err) + } + 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..0c184b5 --- /dev/null +++ b/cli/internal/db/migrate_test.go @@ -0,0 +1,549 @@ +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, err := json.Marshal(fs) + if err != nil { + t.Fatal(err) + } + 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, err := json.Marshal(qs) + if err != nil { + t.Fatal(err) + } + 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, err := json.Marshal(qt) + if err != nil { + t.Fatal(err) + } + 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-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", + DependsOn: []string{"w-000"}, + }, + }, + } + 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) +} + +// 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, err := json.Marshal(l) + if err != nil { + t.Fatal(err) + } + 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, err := json.Marshal(l) + if err != nil { + t.Fatal(err) + } + 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, 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) +} + +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 + if err := d.WithConn(t.Context(), func(conn *Conn) error { + // 1. Fellowship + var name, mainRepo, baseBranch string + if err := 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 + }, + }); 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 + if err := 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 + }, + }); 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 + if err := 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 + }, + }); 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 + 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 + if err := 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 + }, + }); 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 + 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 + 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 + 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 + if err := 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 + }, + }); err != nil { + return err + } + assertEqual(t, "fellowship_quests.respawns", 1, respawns) + assertEqual(t, "fellowship_quests.status", "active", status) + + // 10. Errands + var errandCount int + 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 + }, + }); err != nil { + return err + } + assertEqual(t, "errands.count", 2, errandCount) + + // 11. Errand deps + var depCount int + 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 + 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 + 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 + 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 + 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 + 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 + if err := sqlitex.Execute(conn, + `SELECT COUNT(*) FROM autopsy_files`, + &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 }}); 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 }}); 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") + 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) + } +} diff --git a/cli/internal/db/schema.go b/cli/internal/db/schema.go new file mode 100644 index 0000000..e8519eb --- /dev/null +++ b/cli/internal/db/schema.go @@ -0,0 +1,226 @@ +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), + FOREIGN KEY (quest_name, depends_on) 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, + '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 + 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, + '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`, +} + +// 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 +} diff --git a/cli/internal/eagles/eagles.go b/cli/internal/eagles/eagles.go index c18fa44..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,102 +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. -func classifyQuest(worktree string, opts Options) (*QuestHealth, error) { - questStatePath := filepath.Join(worktree, datadir.Name(), "quest-state.json") - s, err := state.Load(questStatePath) +// 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 +} - 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), +// 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", } - // Classify health - switch { - case s.Phase == "Complete": + // Complete quests are always healthy. + if s.Phase == "Complete" { qh.Health = Complete - qh.Action = "none" + qh.LastActivity = lastActivity(conn, s) + return qh + } - case s.GatePending && s.GateID != nil: - pendingSec := gitutil.GateAge(*s.GateID, opts.Now) - qh.GatePendingSec = pendingSec - if time.Duration(pendingSec)*time.Second >= opts.GateThreshold { + // 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" - } else { - qh.Health = Working - qh.Action = "none" + qh.LastActivity = lastActivity(conn, s) + return qh } + } - case s.GatePending: - // Gate pending but no gate ID — treat as stalled - qh.Health = Stalled - qh.Action = "nudge" + // Check for zombie: use updated_at from quest_state and herald timestamps. + lastAct := lastActivity(conn, s) + qh.LastActivity = lastAct - case opts.Now.Sub(lastActivity) >= opts.ZombieTimeout && s.Phase != "Onboard": - qh.Health = Zombie - if hasCheckpoint { - qh.Action = "respawn" - } else { - qh.Action = "nudge" + 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 + } } + } - case s.Phase == "Onboard" && s.QuestName == "": - qh.Health = Idle - qh.Action = "none" + qh.Health = Working + return qh +} - default: - qh.Health = Working - qh.Action = "none" +// 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 } - return qh, nil + // 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 } - -// 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 - } - if !info.IsDir() && info.ModTime().After(latest) { - latest = info.ModTime() - } - return nil - }) - return latest +// 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. @@ -217,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) } 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..05b65ff 100644 --- a/cli/internal/errand/errand_test.go +++ b/cli/internal/errand/errand_test.go @@ -1,283 +1,215 @@ -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() + 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 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") + + 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) + } + if id != "w-001" { + t.Errorf("expected w-001, got %s", id) + } + + items, err := errand.List(conn, "q1") + if err != nil { + t.Fatal(err) + } + 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 + }); err != nil { + t.Fatal(err) } } -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") + + 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) + } + 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, err := errand.List(conn, "q1") + if err != nil { + t.Fatal(err) + } + 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 + }); err != nil { + t.Fatal(err) } } 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") + + 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, 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 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) - } +func TestUpdateStatusNotFound(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") - 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) + 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 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") + + 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, 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 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") + + 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 { + 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 + }); err != nil { + t.Fatal(err) } } -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) + } } } 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/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..e5e7473 100644 --- a/cli/internal/herald/herald_test.go +++ b/cli/internal/herald/herald_test.go @@ -1,148 +1,234 @@ package herald import ( - "os" - "path/filepath" + "context" + "fmt" "testing" -) - -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) - } + "zombiezen.com/go/sqlite/sqlitex" - // 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) - } + "github.com/justinjdev/fellowship/cli/internal/db" +) - 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) - } +func TestAnnounceAndRead(t *testing.T) { + d := db.OpenTest(t) + 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", + }); 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) + } - // 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) + 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 + }); err != nil { + t.Fatal(err) } } func TestReadReturnsLatestN(t *testing.T) { - dir := t.TempDir() + d := db.OpenTest(t) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + for i := 0; i < 10; i++ { + 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) + } + } - for i := 0; i < 10; i++ { - tid := Tiding{ - Timestamp: "2025-01-15T10:00:00Z", - Quest: "quest-login", - Type: MetadataUpdated, - Detail: "tiding", + tidings, err := Read(conn, "q1", 3) + if err != nil { + t.Fatal(err) } - if err := Announce(dir, tid); err != nil { - t.Fatalf("Announce: %v", 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 + }); err != nil { + t.Fatal(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 TestReadNoData(t *testing.T) { + d := db.OpenTest(t) + if err := 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 + }); err != nil { + t.Fatal(err) } } -func TestReadNoFile(t *testing.T) { - dir := t.TempDir() - - 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)) +func TestReadAll_Limit(t *testing.T) { + d := db.OpenTest(t) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + for i := 0; i < 5; i++ { + 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) + } + if len(tidings) != 3 { + t.Fatalf("expected 3, got %d", len(tidings)) + } + return nil + }); err != nil { + t.Fatal(err) } } -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, - }) - Announce(wt1, Tiding{ - Timestamp: "2025-01-15T10:10:00Z", - Quest: "quest-a", - Type: GateApproved, - }) - Announce(wt2, Tiding{ - Timestamp: "2025-01-15T10:05:00Z", - Quest: "quest-b", - Type: PhaseTransition, - }) - - 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)) - } +func TestReadAllAcrossQuests(t *testing.T) { + d := db.OpenTest(t) + 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, + }); err != nil { + t.Fatal(err) + } + if err := Announce(conn, Tiding{ + Timestamp: "2026-01-01T00:05:00Z", + Quest: "q2", + Type: PhaseTransition, + }); 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) + } - // 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) + 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 + }); err != nil { + t.Fatal(err) } } -func TestReadAllWithLimit(t *testing.T) { - wt1 := t.TempDir() - wt2 := t.TempDir() +func TestDetectProblems_Struggling(t *testing.T) { + d := db.OpenTest(t) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + // Create a quest in Research phase + 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); err != nil { + t.Fatal(err) + } - 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}) - } + // Add 2 rejections in Research phase + 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) + } - tidings, err := ReadAll([]string{wt1, wt2}, 3) - if err != nil { - t.Fatalf("ReadAll: %v", err) + 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" { + found = true + break + } + } + if !found { + t.Errorf("expected struggling problem for q1, got %+v", problems) + } + return nil + }); err != nil { + t.Fatal(err) } - if len(tidings) != 3 { - t.Fatalf("got %d tidings, want 3", len(tidings)) +} + +func TestDetectProblems_NoProblems(t *testing.T) { + d := db.OpenTest(t) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + // Quest in Complete phase should not be checked + 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); err != nil { + t.Fatal(err) + } + + 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 0654ac6..b6c116e 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,44 @@ 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, error) { 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 + 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 { + quests = append(quests, questInfo{ + questName: stmt.ColumnText(0), + phase: stmt.ColumnText(1), + gatePending: stmt.ColumnInt(2) != 0, + gateID: stmt.ColumnText(3), + }) + 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 - 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,47 +74,60 @@ 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 + if err := 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 + }, + }, + ); 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 { + 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 + 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}, + ResultFunc: func(stmt *sqlite.Stmt) error { + rejections = stmt.ColumnInt(0) + 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, + Type: "struggling", + Severity: Warning, + Message: fmt.Sprintf("Gate rejected %d times in %s phase", rejections, qs.phase), + }) } } - 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 c218018..716a982 100644 --- a/cli/internal/herald/problems_test.go +++ b/cli/internal/herald/problems_test.go @@ -1,194 +1,244 @@ package herald import ( - "encoding/json" + "context" "fmt" - "os" - "path/filepath" "testing" "time" + + "zombiezen.com/go/sqlite/sqlitex" + + "github.com/justinjdev/fellowship/cli/internal/db" ) -func writeQuestState(t *testing.T, dir string, phase string, gatePending bool, gateID *string) { +func insertQuestState(t *testing.T, conn *db.Conn, questName, 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) + gp := 0 + if gatePending { + gp = 1 } - - 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{}, + var gateIDArg any + if gateID != "" { + gateIDArg = gateID } - 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) + 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) { - 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) + 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(t, conn, "q1", "Plan", true, gateID) + + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } + + 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 + }); err != nil { + t.Fatal(err) } } 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) - - problems := DetectProblems([]string{dir}) + d := db.OpenTest(t) + 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(t, conn, "q1", "Plan", true, gateID) + + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } - 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 + }); err != nil { + t.Fatal(err) } } 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) + 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) + if err := Announce(conn, Tiding{ + Timestamp: oldTime, + Quest: "q1", + Type: MetadataUpdated, + }); err != nil { + t.Fatal(err) + } + + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } + + 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 + }); err != nil { + t.Fatal(err) } } 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, - }) + d := db.OpenTest(t) + 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) + if err := Announce(conn, Tiding{ + Timestamp: oldTime, + Quest: "q1", + Type: MetadataUpdated, + }); err != nil { + t.Fatal(err) + } - problems := DetectProblems([]string{dir}) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } - for _, p := range problems { - if p.Type == "zombie" { - t.Errorf("unexpected zombie problem for Complete quest: %v", p) + for _, p := range problems { + if p.Type == "zombie" { + t.Errorf("unexpected zombie problem for Complete quest: %v", p) + } } + return nil + }); err != nil { + t.Fatal(err) } } 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) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(t, conn, "q1", "Plan", false, "") + + now := time.Now().UTC().Format(time.RFC3339) + 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, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } + + 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 + }); err != nil { + t.Fatal(err) } } func TestStrugglingNotDetectedWithOneRejection(t *testing.T) { - dir := t.TempDir() - writeQuestState(t, dir, "Plan", false, nil) + d := db.OpenTest(t) + 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(dir, Tiding{ - Timestamp: now, - Quest: "test-quest", - Type: GateRejected, - Phase: "Plan", - }) + now := time.Now().UTC().Format(time.RFC3339) + if err := Announce(conn, Tiding{Timestamp: now, Quest: "q1", Type: GateRejected, Phase: "Plan"}); err != nil { + t.Fatal(err) + } - problems := DetectProblems([]string{dir}) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } - 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 + }); err != nil { + t.Fatal(err) } } 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) + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + insertQuestState(t, conn, "q1", "Implement", false, "") + + now := time.Now().UTC().Format(time.RFC3339) + if err := Announce(conn, Tiding{ + Timestamp: now, + Quest: "q1", + Type: GateApproved, + Phase: "Plan", + }); err != nil { + t.Fatal(err) + } - problems := DetectProblems([]string{dir}) + problems, err := DetectProblems(conn) + if err != nil { + t.Fatalf("DetectProblems: %v", err) + } - if len(problems) != 0 { - t.Errorf("expected no problems, got %v", problems) + 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 29bbde7..aeb668c 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) error { + return tome.SetStatus(conn, questName, "completed") } 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 "" } 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) } } diff --git a/cli/internal/hooks/files.go b/cli/internal/hooks/files.go index 403ceee..606aea6 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,15 +18,9 @@ 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 { + // RecordFiles uses INSERT OR IGNORE, so duplicates are silently skipped. + if err := tome.RecordFiles(conn, questName, []string{filePath}); err != nil { return false } - - if err := tome.Save(tomePath, c); 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 290f841..33289cf 100644 --- a/cli/internal/hooks/files_test.go +++ b/cli/internal/hooks/files_test.go @@ -1,67 +1,99 @@ 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() + 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) { - 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 + 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") } - 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]) + 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) + } + 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 + }); err != nil { + t.Fatal(err) } } 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 + 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") } - 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) + 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) { 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 +109,64 @@ 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) + 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) } }) } } 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") + 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) { - 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") - } + d := db.OpenTest(t) + seedQuest(t, d, "q1") - c, _ := tome.Load(tomePath) - if len(c.FilesTouched) != 1 { - t.Errorf("FilesTouched len = %d, want 1", len(c.FilesTouched)) - } -} - -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") - } - - modified := FileTrack(s, input, tomePath) - if !modified { - t.Error("FileTrack should return true and create tome on first file write") + ToolInput: ToolInput{FilePath: "/home/user/project/main.go"}, } - // 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) + 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, 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 d94300b..235d402 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,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(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) error { + if err := tome.RecordGate(conn, questName, phase, "submitted", ""); err != nil { + return err + } if autoApproved { - tome.RecordGate(c, phase, "approved") - tome.RecordPhase(c, phase) + if err := tome.RecordGate(conn, questName, phase, "approved", ""); err != nil { + return err + } + if err := tome.RecordPhase(conn, questName, phase, 0); err != nil { + return err + } } - tome.Save(tomePath, c) + 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 d73d625..279cbbf 100644 --- a/cli/internal/state/state.go +++ b/cli/internal/state/state.go @@ -2,23 +2,18 @@ package state import ( "encoding/json" + "errors" "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") +// ErrNotFound is returned when a quest does not exist in the database. +var ErrNotFound = errors.New("state: quest not found") type State struct { - Version int `json:"version"` QuestName string `json:"quest_name"` TaskID string `json:"task_id"` TeamName string `json:"team_name"` @@ -50,96 +45,122 @@ 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 != "" { + if err := json.Unmarshal([]byte(aa), &s.AutoApproveGates); err != nil { + return fmt.Errorf("unmarshal auto_approve: %w", err) + } + } + 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("%w: %s", ErrNotFound, 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) - } - defer lockFile.Close() - - if err := filelock.Lock(lockFile.Fd()); err != nil { - return fmt.Errorf("acquiring lock: %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 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) - } -} diff --git a/cli/internal/status/status.go b/cli/internal/status/status.go index f03a5fb..9e1c2de 100644 --- a/cli/internal/status/status.go +++ b/cli/internal/status/status.go @@ -1,13 +1,15 @@ package status import ( + "fmt" "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 +66,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 +85,55 @@ 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 { + return nil, fmt.Errorf("status: load fellowship: %w", err) + } + if 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 + ORDER BY fq.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, fmt.Errorf("status: load quests: %w", 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 +143,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 +169,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..fc9a7e4 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,179 @@ func TestParseMergedBranches(t *testing.T) { }) } } + +func TestScanLoadsFellowshipFromDB(t *testing.T) { + d := db.OpenTest(t) + + 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) + 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 + }); err != nil { + t.Fatal(err) + } +} + +func TestScanNoFellowship(t *testing.T) { + d := db.OpenTest(t) + + if err := 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 + }); err != nil { + t.Fatal(err) + } +} + +func TestScanQuestsFromDB(t *testing.T) { + d := db.OpenTest(t) + + 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) + 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 + }); err != nil { + t.Fatal(err) + } +} + +func TestScanQuestWithoutState(t *testing.T) { + d := db.OpenTest(t) + + 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) + 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 + }); err != nil { + t.Fatal(err) + } +} diff --git a/cli/internal/tome/tome.go b/cli/internal/tome/tome.go index 8d6db4c..8bdb7ce 100644 --- a/cli/internal/tome/tome.go +++ b/cli/internal/tome/tome.go @@ -1,156 +1,204 @@ 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 + if err := 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 + }, + }); err != nil { + return nil, fmt.Errorf("tome: load fellowship_quests: %w", err) + } + 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..40531c5 100644 --- a/cli/internal/tome/tome_test.go +++ b/cli/internal/tome/tome_test.go @@ -1,260 +1,240 @@ -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() + 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) { - c := &QuestTome{PhasesCompleted: []PhaseRecord{}} + d := db.OpenTest(t) + seedQuest(t, d, "q1") - 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") - } - - 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) + if err := 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 + }); err != nil { + t.Fatal(err) } } 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") - } + 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) + } - RecordGate(c, "Research", "approved") - if len(c.GateHistory) != 2 { - t.Fatalf("GateHistory len = %d, want 2", len(c.GateHistory)) + 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)) + } + if gates[0].Action != "submitted" { + t.Errorf("expected submitted, got %s", gates[0].Action) + } + return nil + }); err != nil { + t.Fatal(err) } } -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)) - } + 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) + } - 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, 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 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) + 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) + } - // 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 + }); err != nil { + t.Fatal(err) } } -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") + + if err := 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 + }); err != nil { + t.Fatal(err) } } func TestRecordSkippedPhases(t *testing.T) { - c := &QuestTome{ - GateHistory: []GateEvent{}, - PhasesCompleted: []PhaseRecord{}, - } - - RecordSkippedPhases(c, []string{"Onboard", "Research", "Plan"}, "pre-existing plan") + d := db.OpenTest(t) + seedQuest(t, d, "q1") - 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)) - } + 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) + } - 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) + phases, err := tome.LoadPhases(conn, "q1") + if err != nil { + t.Fatal(err) } - if c.GateHistory[i].Action != "skipped" { - t.Errorf("GateHistory[%d].Action = %q, want skipped", i, c.GateHistory[i].Action) + if len(phases) != 3 { + t.Fatalf("expected 3 phases, got %d", len(phases)) } - if c.GateHistory[i].Reason != "pre-existing plan" { - t.Errorf("GateHistory[%d].Reason = %q, want 'pre-existing plan'", i, c.GateHistory[i].Reason) + + gates, err := tome.LoadGates(conn, "q1") + if err != nil { + t.Fatal(err) } - if c.PhasesCompleted[i].Phase != phase { - t.Errorf("PhasesCompleted[%d].Phase = %q, want %q", i, c.PhasesCompleted[i].Phase, phase) + if len(gates) != 3 { + t.Fatalf("expected 3 gates, got %d", len(gates)) } + + 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 + }); err != nil { + t.Fatal(err) } } -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") +func TestSetStatus(t *testing.T) { + d := db.OpenTest(t) + seedQuest(t, d, "q1") + + if err := d.WithTx(context.Background(), func(conn *db.Conn) error { + // Insert a fellowship_quests row for SetStatus to update. + if err := tome.SetStatus(conn, "q1", "completed"); err != nil { + t.Fatal(err) + } + return nil + }); err != nil { + t.Fatal(err) } -} -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) + // Insert fellowship_quests row and test SetStatus. + 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) + } + 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, 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) } }