f2e83e7b9a
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
192 lines
6.3 KiB
Go
192 lines
6.3 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/persistence"
|
|
)
|
|
|
|
// resetConfirmToken is the literal value the client must POST as
|
|
// {"confirm": "..."} to authorise a factory reset. Anything else is
|
|
// rejected with a 400 / typed error.
|
|
const resetConfirmToken = "RESET"
|
|
|
|
// ErrResetNotConfirmed is returned when the confirm value supplied to
|
|
// FactoryReset does not equal the resetConfirmToken sentinel.
|
|
var ErrResetNotConfirmed = errors.New("factory reset requires confirm=\"RESET\"")
|
|
|
|
// FactoryReset orchestrates a destructive wipe of the kandev install. The
|
|
// caller must pass confirm == "RESET" — anything else returns
|
|
// ErrResetNotConfirmed without starting a job. On success the job ID is
|
|
// returned immediately; the heavy work runs asynchronously via the jobs
|
|
// tracker.
|
|
//
|
|
// The job:
|
|
// 1. Calls s.OrchestratorShutdown (if set) to stop running executions.
|
|
// 2. Calls s.DatabaseQuiesce (if set) to stop database-backed workers.
|
|
// 3. Snapshots the live DB to the sibling backups directory.
|
|
// 4. Drops every user table from the SQLite schema (kandev_meta is kept).
|
|
// 5. os.RemoveAll on worktrees/repos/sessions/tasks/quick-chat subdirs.
|
|
//
|
|
// On success the result map exposes {"snapshot_path", "tables_dropped",
|
|
// "restart_required": true}. The frontend dialog uses restart_required to
|
|
// prompt the user to quit and relaunch Kandev — no auto re-exec.
|
|
func (s *Service) FactoryReset(ctx context.Context, confirm string) (string, error) {
|
|
if confirm != resetConfirmToken {
|
|
return "", ErrResetNotConfirmed
|
|
}
|
|
return s.jobs.Start(ctx, "factory-reset", func(jobCtx context.Context) (map[string]interface{}, error) {
|
|
return s.runFactoryReset(jobCtx)
|
|
}), nil
|
|
}
|
|
|
|
func (s *Service) runFactoryReset(_ context.Context) (map[string]interface{}, error) {
|
|
if err := s.requireSQLiteMaintenance("factory reset"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if s.OrchestratorShutdown != nil {
|
|
s.OrchestratorShutdown()
|
|
}
|
|
if s.DatabaseQuiesce != nil {
|
|
if err := s.DatabaseQuiesce(); err != nil {
|
|
return nil, fmt.Errorf("quiesce database workers: %w", err)
|
|
}
|
|
}
|
|
|
|
snapshotPath, err := s.createPreResetSnapshot()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pre-reset snapshot: %w", err)
|
|
}
|
|
|
|
// Delete the delivery-ledger and run-outcome activation keys BEFORE
|
|
// dropping any user table (docs/specs/task-delivery-ledger/spec.md,
|
|
// "Reset parity"). kandev_meta itself survives the drop loop below, so
|
|
// a stale activation key left behind would point at a boot instant
|
|
// before data that no longer exists — a post-reset task with no
|
|
// ledger row would then read as "observed as nothing" rather than
|
|
// "not observed". Ordering matters because the drop loop is not
|
|
// transactional: if key deletion fails, the reset fails here and no
|
|
// table is dropped, which is the fail-safe order — the alternative
|
|
// (drop first, delete keys second) can leave a present key pointing
|
|
// at data a later step just removed.
|
|
if err := persistence.DeleteKeys(s.pool.Writer(),
|
|
"telemetry.delivery_ledger.activated_at", "telemetry.run_outcome.activated_at",
|
|
); err != nil {
|
|
return nil, fmt.Errorf("delete activation keys: %w", err)
|
|
}
|
|
|
|
dropped, err := dropUserTables(s.pool.Writer())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("drop tables: %w", err)
|
|
}
|
|
|
|
if err := s.wipeSubdirs(); err != nil {
|
|
return nil, fmt.Errorf("wipe subdirs: %w", err)
|
|
}
|
|
|
|
// Intentionally no auto-restart. The frontend dialog reads
|
|
// restart_required from the job result and asks the user to quit and
|
|
// relaunch the app.
|
|
return map[string]interface{}{
|
|
"snapshot_path": snapshotPath,
|
|
"tables_dropped": dropped,
|
|
"restart_required": true,
|
|
}, nil
|
|
}
|
|
|
|
// createPreResetSnapshot performs VACUUM INTO into a stable, time-stamped
|
|
// path inside the backups directory. The "kandev-" prefix is kept so the
|
|
// existing backup retention regex continues to match the file.
|
|
func (s *Service) createPreResetSnapshot() (string, error) {
|
|
if s.pool == nil {
|
|
return "", fmt.Errorf("no database pool")
|
|
}
|
|
backupDir := s.backupsDir()
|
|
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
|
return "", fmt.Errorf("mkdir backups: %w", err)
|
|
}
|
|
name := fmt.Sprintf("kandev-pre-reset-%s.db", strconv.FormatInt(time.Now().UTC().Unix(), 10))
|
|
path := filepath.Join(backupDir, name)
|
|
if _, err := s.pool.Writer().Exec(`VACUUM INTO ?`, path); err != nil {
|
|
return "", fmt.Errorf("vacuum into %s: %w", path, err)
|
|
}
|
|
if s.log != nil {
|
|
s.log.Info("pre-reset snapshot created", zap.String("path", path))
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// dropUserTables drops every row in sqlite_master that is a non-internal
|
|
// table other than kandev_meta. Returns the count of tables dropped.
|
|
func dropUserTables(writer *sqlx.DB) (int, error) {
|
|
rows, err := writer.Query(`
|
|
SELECT name FROM sqlite_master
|
|
WHERE type = 'table'
|
|
AND name NOT LIKE 'sqlite_%'
|
|
AND name != 'kandev_meta'
|
|
`)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("enumerate user tables: %w", err)
|
|
}
|
|
|
|
var names []string
|
|
for rows.Next() {
|
|
var n string
|
|
if scanErr := rows.Scan(&n); scanErr != nil {
|
|
_ = rows.Close()
|
|
return 0, fmt.Errorf("scan table name: %w", scanErr)
|
|
}
|
|
names = append(names, n)
|
|
}
|
|
if closeErr := rows.Close(); closeErr != nil {
|
|
return 0, fmt.Errorf("close rows: %w", closeErr)
|
|
}
|
|
|
|
// FK enforcement is on for the writer pool — defer constraint checks so
|
|
// drop order doesn't matter. SQLite ignores PRAGMA foreign_keys inside a
|
|
// transaction, so toggle it at the connection level.
|
|
if _, err := writer.Exec("PRAGMA foreign_keys = OFF"); err != nil {
|
|
return 0, fmt.Errorf("disable foreign keys: %w", err)
|
|
}
|
|
defer func() { _, _ = writer.Exec("PRAGMA foreign_keys = ON") }()
|
|
|
|
for _, n := range names {
|
|
if _, err := writer.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %q", n)); err != nil {
|
|
return 0, fmt.Errorf("drop table %s: %w", n, err)
|
|
}
|
|
}
|
|
return len(names), nil
|
|
}
|
|
|
|
// wipeSubdirs removes the worktrees/repos/sessions/tasks/quick-chat
|
|
// subdirs configured at construction. Empty paths are skipped so partial
|
|
// wiring is harmless in tests.
|
|
func (s *Service) wipeSubdirs() error {
|
|
targets := []string{
|
|
s.dirs.Worktrees,
|
|
s.dirs.Repos,
|
|
s.dirs.Sessions,
|
|
s.dirs.Tasks,
|
|
s.dirs.QuickChat,
|
|
}
|
|
for _, t := range targets {
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if err := os.RemoveAll(t); err != nil {
|
|
return fmt.Errorf("remove %s: %w", t, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|