fix: align SQLite backups with database path (#2686)
This commit is contained in:
@@ -249,11 +249,11 @@ Every long-running goroutine must have a single owner with explicit start and st
|
||||
- **Leak testing:** packages that spawn goroutines add `goleak.VerifyTestMain(m)` in a per-package `TestMain`. New packages of this kind must follow suit. When a third-party background goroutine genuinely can't be drained, suppress it with `goleak.IgnoreTopFunction(...)` and leave a comment explaining why. Currently instrumented: `internal/gateway/websocket/`, `internal/agent/runtime/lifecycle/`, `internal/agentctl/server/process/`, `internal/orchestrator/`, `internal/github/`, `internal/gitlab/`, `internal/jira/`, `internal/linear/`, `internal/integrations/healthpoll/`.
|
||||
|
||||
## Backups
|
||||
|
||||
- On every SQLite boot, `persistence.Provide` reads `kandev_meta.kandev_version`. If the stored version differs from the binary version (or any user tables exist but no version is recorded), it takes a `VACUUM INTO` snapshot into `<data-dir>/backups/` before running migrations.
|
||||
- On every SQLite boot, `persistence.Provide` reads `kandev_meta.kandev_version`. If the stored version differs from the binary version (or any user tables exist but no version is recorded), it takes a `VACUUM INTO` snapshot into `backups/` beside the configured SQLite database file before running migrations. The default path is `<home>/data/kandev.db`, with backups in `<home>/data/backups/`.
|
||||
- Retention: 2 backups kept (newest two by mtime); older ones are pruned after the snapshot succeeds.
|
||||
- Postgres: backup is skipped with a log line. Use `pg_dump` for Postgres backups.
|
||||
- Boot aborts if the backup fails — the pool is closed and `Provide` returns an error.
|
||||
- Restore quiesces scheduling, active executions, and database-backed workers, validates the SQLite checkpoint result, and closes the shared pool. It quarantines the configured database and sidecars, installs the staged file, and restores the originals if replacement fails. The frontend requires a restart before database-backed work resumes; PostgreSQL restore is rejected.
|
||||
- After all repos complete `initSchema`, `cmd/kandev/storage.go:recordSchemaVersion` writes the current binary version into `kandev_meta` (non-fatal; a failure just means the next boot will take a fresh snapshot).
|
||||
- Migration logging: `db.MigrateLogger.Apply(name, stmt)` — success logs Info, "already exists" / "duplicate column name" is silently swallowed, anything else logs Warn but never returns an error (preserving the existing swallow-error contract).
|
||||
- Schema replay handling: use `internal/db` helpers such as `IsDuplicateColumnError` / `IsAlreadyExistsError` instead of local error-string matching. When adding or changing startup schema code, include fresh-DB plus same-DB replay tests for SQLite; add the same env-gated Postgres replay coverage when the path supports Postgres. See `docs/decisions/0027-replayable-schema-migrations.md`.
|
||||
|
||||
@@ -316,7 +316,7 @@ func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCl
|
||||
addCleanup(cleanup)
|
||||
eventBus := eventBusProvider.Bus
|
||||
|
||||
return startServices(ctx, cfg, log, addCleanup, eventBus, runCleanups)
|
||||
return startServices(ctx, cfg, log, addCleanup, eventBus, runCleanups, cancel)
|
||||
}
|
||||
|
||||
// applyStartupRuntimeFlags resolves persisted runtime-flag overrides and
|
||||
@@ -344,6 +344,7 @@ func startServices( //nolint:cyclop
|
||||
addCleanup func(func() error),
|
||||
eventBus bus.EventBus,
|
||||
runCleanups func(),
|
||||
cancelContext context.CancelFunc,
|
||||
) bool {
|
||||
// ============================================
|
||||
// TASK SERVICE
|
||||
@@ -437,7 +438,7 @@ func startServices( //nolint:cyclop
|
||||
}
|
||||
|
||||
return startAgentInfrastructure(ctx, cfg, log, addCleanup, eventBus, agentRuntimeAvailability,
|
||||
dbPool, repos, services, agentSettingsController, agentRegistry, agentctlBinaryPath, runCleanups)
|
||||
dbPool, repos, services, agentSettingsController, agentRegistry, agentctlBinaryPath, runCleanups, cancelContext)
|
||||
}
|
||||
|
||||
// startAgentInfrastructure initializes the agent lifecycle manager, worktree, orchestrator,
|
||||
@@ -458,7 +459,22 @@ func startAgentInfrastructure(
|
||||
agentRegistry *registry.Registry,
|
||||
agentctlBinaryPath string,
|
||||
runCleanups func(),
|
||||
cancelContext context.CancelFunc,
|
||||
) bool {
|
||||
restoreCleanups := make([]func() error, 0)
|
||||
addRuntimeCleanup := func(fn func() error) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
var stopOnce sync.Once
|
||||
var stopErr error
|
||||
stop := func() error {
|
||||
stopOnce.Do(func() { stopErr = fn() })
|
||||
return stopErr
|
||||
}
|
||||
addCleanup(stop)
|
||||
restoreCleanups = append(restoreCleanups, stop)
|
||||
}
|
||||
userSecretStore := secrets.NewUserVisibleStore(repos.Secrets)
|
||||
// ============================================
|
||||
// AGENT MANAGER
|
||||
@@ -490,7 +506,7 @@ func startAgentInfrastructure(
|
||||
return false
|
||||
}
|
||||
services.WorktreeMgr = worktreeMgr
|
||||
addCleanup(worktreeCleanup)
|
||||
addRuntimeCleanup(worktreeCleanup)
|
||||
log.Info("Worktree Manager initialized",
|
||||
zap.Bool("enabled", cfg.Worktree.Enabled))
|
||||
|
||||
@@ -630,7 +646,7 @@ func startAgentInfrastructure(
|
||||
services.GitLab.SetTaskAuthorizer(services.Task)
|
||||
glPoller := gitlabpkg.NewPoller(services.GitLab, eventBus, log)
|
||||
glPoller.Start(ctx)
|
||||
addCleanup(func() error { glPoller.Stop(); return nil })
|
||||
addRuntimeCleanup(func() error { glPoller.Stop(); return nil })
|
||||
log.Info("GitLab poller started")
|
||||
}
|
||||
// Bind only the path-returning orchestrator seam after its clone pipeline
|
||||
@@ -646,11 +662,11 @@ func startAgentInfrastructure(
|
||||
if lifecycleErr != nil {
|
||||
log.Warn("Azure DevOps lifecycle cleanup unavailable", zap.Error(lifecycleErr))
|
||||
} else {
|
||||
addCleanup(azureLifecycle.Close)
|
||||
addRuntimeCleanup(azureLifecycle.Close)
|
||||
}
|
||||
azurePoller := azuredevopspkg.NewPoller(services.AzureDevOps, log)
|
||||
azurePoller.Start(ctx)
|
||||
addCleanup(func() error { azurePoller.Stop(); return nil })
|
||||
addRuntimeCleanup(func() error { azurePoller.Stop(); return nil })
|
||||
log.Info("Azure DevOps auth poller started")
|
||||
}
|
||||
|
||||
@@ -662,7 +678,7 @@ func startAgentInfrastructure(
|
||||
orchestratorSvc.SetJiraService(&jiraServiceAdapter{svc: services.Jira})
|
||||
jiraPoller := jirapkg.NewPoller(services.Jira, log)
|
||||
jiraPoller.Start(ctx)
|
||||
addCleanup(func() error { jiraPoller.Stop(); return nil })
|
||||
addRuntimeCleanup(func() error { jiraPoller.Stop(); return nil })
|
||||
}
|
||||
|
||||
// Start Linear poller. Mirrors the Jira shape: auth-health probe plus an
|
||||
@@ -672,7 +688,7 @@ func startAgentInfrastructure(
|
||||
orchestratorSvc.SetLinearService(&linearServiceAdapter{svc: services.Linear})
|
||||
linearPoller := linearpkg.NewPoller(services.Linear, log)
|
||||
linearPoller.Start(ctx)
|
||||
addCleanup(func() error { linearPoller.Stop(); return nil })
|
||||
addRuntimeCleanup(func() error { linearPoller.Stop(); return nil })
|
||||
}
|
||||
|
||||
// Start Sentry poller: an auth-health probe plus an issue-watch loop that
|
||||
@@ -682,7 +698,7 @@ func startAgentInfrastructure(
|
||||
orchestratorSvc.SetSentryService(&sentryServiceAdapter{svc: services.Sentry})
|
||||
sentryPoller := sentrypkg.NewPoller(services.Sentry, log)
|
||||
sentryPoller.Start(ctx)
|
||||
addCleanup(func() error { sentryPoller.Stop(); return nil })
|
||||
addRuntimeCleanup(func() error { sentryPoller.Stop(); return nil })
|
||||
}
|
||||
|
||||
// Start workflow-sync poller: periodically pulls workflow definition
|
||||
@@ -691,18 +707,18 @@ func startAgentInfrastructure(
|
||||
if services.WorkflowSync != nil {
|
||||
workflowSyncPoller := workflowsyncpkg.NewPoller(services.WorkflowSync, log)
|
||||
workflowSyncPoller.Start(ctx)
|
||||
addCleanup(func() error { workflowSyncPoller.Stop(); return nil })
|
||||
addRuntimeCleanup(func() error { workflowSyncPoller.Stop(); return nil })
|
||||
log.Info("Workflow sync poller started")
|
||||
}
|
||||
|
||||
// Start the plugin system's event delivery and health monitor
|
||||
// background loops.
|
||||
if services.Plugins != nil {
|
||||
startPluginsSubsystems(ctx, services.Plugins, lifecycleMgr, eventBus, log, addCleanup)
|
||||
startPluginsSubsystems(ctx, services.Plugins, lifecycleMgr, eventBus, log, addRuntimeCleanup)
|
||||
}
|
||||
|
||||
return startGatewayAndServe(ctx, cfg, log, eventBus, agentRuntimeAvailability, dbPool, repos, services,
|
||||
agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath, addCleanup, runCleanups)
|
||||
agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath, addRuntimeCleanup, runCleanups, cancelContext, restoreCleanups)
|
||||
}
|
||||
|
||||
// startOrchestratorAndAutomationConsumers establishes the event-consumer
|
||||
@@ -743,6 +759,8 @@ func startGatewayAndServe(
|
||||
agentctlBinaryPath string,
|
||||
addCleanup func(func() error),
|
||||
runCleanups func(),
|
||||
cancelContext context.CancelFunc,
|
||||
restoreCleanups []func() error,
|
||||
) bool {
|
||||
// ============================================
|
||||
// WEBSOCKET GATEWAY
|
||||
@@ -885,6 +903,24 @@ func startGatewayAndServe(
|
||||
ctx, repos, services, eventBus, orchestratorSvc, runProcessorSvc, log,
|
||||
)
|
||||
addCleanup(scheduling.Stop)
|
||||
var restoreQuiesceOnce sync.Once
|
||||
var restoreQuiesceErr error
|
||||
restoreQuiesce := func() error {
|
||||
restoreQuiesceOnce.Do(func() {
|
||||
workers := make([]func() error, 0, len(restoreCleanups))
|
||||
for i := len(restoreCleanups) - 1; i >= 0; i-- {
|
||||
workers = append(workers, restoreCleanups[i])
|
||||
}
|
||||
restoreQuiesceErr = quiesceForRestore(
|
||||
cancelContext,
|
||||
scheduling.Stop,
|
||||
orchestratorSvc.Stop,
|
||||
func() error { return stopLifecycleManager(lifecycleMgr, log) },
|
||||
workers,
|
||||
)
|
||||
})
|
||||
return restoreQuiesceErr
|
||||
}
|
||||
|
||||
// Wire subscription usage provider into the office agents service so the
|
||||
// /agents/:id/utilization endpoint can fetch live utilization data.
|
||||
@@ -910,6 +946,7 @@ func startGatewayAndServe(
|
||||
BuildTime: BuildTime,
|
||||
}, systemsvc.Wiring{
|
||||
OrchestratorShutdown: func() { _ = orchestratorSvc.Stop() },
|
||||
RestoreQuiesce: restoreQuiesce,
|
||||
MessageQueue: orchestratorSvc.GetMessageQueue(),
|
||||
TaskSessions: repos.Task,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package backendapp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// quiesceForRestore stops every runtime that can write to the shared database
|
||||
// before the restore path checkpoints and closes its pool. It keeps going
|
||||
// after an individual stop failure so callers receive the complete failure
|
||||
// set and no remaining worker is left running by an early return.
|
||||
func quiesceForRestore(
|
||||
cancelContext func(),
|
||||
stopScheduling func() error,
|
||||
stopOrchestrator func() error,
|
||||
stopAgents func() error,
|
||||
stopWorkers []func() error,
|
||||
) error {
|
||||
if cancelContext != nil {
|
||||
cancelContext()
|
||||
}
|
||||
var errs []error
|
||||
if stopScheduling != nil {
|
||||
if err := stopScheduling(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("stop scheduling: %w", err))
|
||||
}
|
||||
}
|
||||
if stopOrchestrator != nil {
|
||||
if err := stopOrchestrator(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("stop orchestrator: %w", err))
|
||||
}
|
||||
}
|
||||
if stopAgents != nil {
|
||||
if err := stopAgents(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("stop agents: %w", err))
|
||||
}
|
||||
}
|
||||
for _, stopWorker := range stopWorkers {
|
||||
if stopWorker == nil {
|
||||
continue
|
||||
}
|
||||
if err := stopWorker(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("stop database-backed worker: %w", err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package backendapp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuiesceForRestoreStopsAllWritersAfterFailures(t *testing.T) {
|
||||
firstErr := errors.New("scheduler failed")
|
||||
secondErr := errors.New("worker failed")
|
||||
var calls []string
|
||||
mark := func(name string) func() error {
|
||||
return func() error {
|
||||
calls = append(calls, name)
|
||||
if name == "scheduling" {
|
||||
return firstErr
|
||||
}
|
||||
if name == "worker" {
|
||||
return secondErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
err := quiesceForRestore(
|
||||
func() { calls = append(calls, "cancel") },
|
||||
mark("scheduling"),
|
||||
mark("orchestrator"),
|
||||
mark("agents"),
|
||||
[]func() error{mark("worker")},
|
||||
)
|
||||
|
||||
if !errors.Is(err, firstErr) || !errors.Is(err, secondErr) {
|
||||
t.Fatalf("error = %v, want both stop errors", err)
|
||||
}
|
||||
wantCalls := []string{"cancel", "scheduling", "orchestrator", "agents", "worker"}
|
||||
if !reflect.DeepEqual(calls, wantCalls) {
|
||||
t.Fatalf("calls = %v, want %v", calls, wantCalls)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func HandleRestore(svc *Service) gin.HandlerFunc {
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
id, err := svc.Restore(c.Request.Context(), name, body.Confirm)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRestoreConfirm) {
|
||||
if errors.Is(err, errRestoreConfirm) || errors.Is(err, errRestoreUnsupported) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
func TestConfiguredDatabasePath_RestoreReplacesConfiguredFilename(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir database dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(databasePath, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("write original database: %v", err)
|
||||
}
|
||||
backupDir := filepath.Join(filepath.Dir(databasePath), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir backup dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "manual-1.db"), []byte("restored"), 0o644); err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
|
||||
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
||||
svc := NewService(databasePath, nil, tracker, newTestLogger(t))
|
||||
jobID, err := svc.Restore(context.Background(), "manual-1.db", RestoreConfirmToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
job := waitForJob(t, tracker, jobID, jobs.StateSucceeded)
|
||||
if job.State != jobs.StateSucceeded {
|
||||
t.Fatalf("state = %s, want succeeded; message = %s", job.State, job.Message)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read restored database: %v", err)
|
||||
}
|
||||
if string(got) != "restored" {
|
||||
t.Fatalf("database bytes = %q, want restored", got)
|
||||
}
|
||||
if _, err := os.Stat(databasePath + ".new"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("staged restore remains, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "data", "kandev.db")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("default database unexpectedly exists, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDatabasePath_RestoreQuiescesPoolAndReplacesWALSidecars(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
writerRaw, err := db.OpenSQLite(databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite writer: %v", err)
|
||||
}
|
||||
readerRaw, err := db.OpenSQLiteReader(databasePath)
|
||||
if err != nil {
|
||||
_ = writerRaw.Close()
|
||||
t.Fatalf("open sqlite reader: %v", err)
|
||||
}
|
||||
pool := db.NewPool(sqlx.NewDb(writerRaw, "sqlite3"), sqlx.NewDb(readerRaw, "sqlite3"))
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
if _, err := pool.Writer().Exec(`CREATE TABLE things (id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := pool.Writer().Exec(`INSERT INTO things (value) VALUES ('before')`); err != nil {
|
||||
t.Fatalf("insert row: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(databasePath + "-wal"); err != nil {
|
||||
t.Fatalf("expected WAL sidecar before restore: %v", err)
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(filepath.Dir(databasePath), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir backup dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "manual-1.db"), []byte("restored"), 0o644); err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
|
||||
shutdownCalls := 0
|
||||
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
||||
svc := NewService(databasePath, pool, tracker, newTestLogger(t))
|
||||
svc.OrchestratorShutdown = func() { shutdownCalls++ }
|
||||
jobID, err := svc.Restore(context.Background(), "manual-1.db", RestoreConfirmToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
job := waitForJob(t, tracker, jobID, jobs.StateSucceeded)
|
||||
if job.State != jobs.StateSucceeded {
|
||||
t.Fatalf("state = %s, want succeeded; message = %s", job.State, job.Message)
|
||||
}
|
||||
if shutdownCalls != 1 {
|
||||
t.Errorf("OrchestratorShutdown calls = %d, want 1", shutdownCalls)
|
||||
}
|
||||
if _, err := os.Stat(databasePath + "-wal"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("WAL sidecar remains, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(databasePath + "-shm"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("SHM sidecar remains, stat error = %v", err)
|
||||
}
|
||||
if err := pool.Writer().Ping(); err == nil {
|
||||
t.Error("database writer remains open after restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRejectsPostgresWithoutClosingPool(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir database dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(databasePath, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("write original database: %v", err)
|
||||
}
|
||||
backupDir := filepath.Join(filepath.Dir(databasePath), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir backup dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "manual-1.db"), []byte("restored"), 0o644); err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
|
||||
pgRaw, err := sqlx.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open fake postgres pool: %v", err)
|
||||
}
|
||||
pg := sqlx.NewDb(pgRaw.DB, "pgx")
|
||||
pool := db.NewPool(pg, pg)
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
if err := pool.Writer().Ping(); err != nil {
|
||||
t.Fatalf("ping fake postgres pool before restore: %v", err)
|
||||
}
|
||||
|
||||
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
||||
svc := NewService(databasePath, pool, tracker, newTestLogger(t))
|
||||
if _, err := svc.Restore(context.Background(), "manual-1.db", RestoreConfirmToken); err == nil {
|
||||
t.Fatal("Restore accepted a PostgreSQL pool")
|
||||
}
|
||||
if err := pool.Writer().Ping(); err != nil {
|
||||
t.Fatalf("PostgreSQL pool was closed after rejected restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreCheckpointBusyLeavesDatabaseAndSidecarsUntouched(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
writerRaw, err := db.OpenSQLite(databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite writer: %v", err)
|
||||
}
|
||||
readerRaw, err := db.OpenSQLiteReader(databasePath)
|
||||
if err != nil {
|
||||
_ = writerRaw.Close()
|
||||
t.Fatalf("open sqlite reader: %v", err)
|
||||
}
|
||||
pool := db.NewPool(sqlx.NewDb(writerRaw, "sqlite3"), sqlx.NewDb(readerRaw, "sqlite3"))
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
if _, err := pool.Writer().Exec(`CREATE TABLE things (id INTEGER PRIMARY KEY, value TEXT)`); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := pool.Writer().Exec(`INSERT INTO things (value) VALUES ('before')`); err != nil {
|
||||
t.Fatalf("insert row: %v", err)
|
||||
}
|
||||
readerTx, err := pool.Reader().Beginx()
|
||||
if err != nil {
|
||||
t.Fatalf("begin reader transaction: %v", err)
|
||||
}
|
||||
rows, err := readerTx.Queryx(`SELECT value FROM things`)
|
||||
if err != nil {
|
||||
_ = readerTx.Rollback()
|
||||
t.Fatalf("hold reader snapshot: %v", err)
|
||||
}
|
||||
if !rows.Next() {
|
||||
_ = rows.Close()
|
||||
_ = readerTx.Rollback()
|
||||
t.Fatal("reader snapshot returned no row")
|
||||
}
|
||||
if _, err := pool.Writer().Exec(`PRAGMA busy_timeout = 0`); err != nil {
|
||||
t.Fatalf("disable checkpoint busy timeout: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = rows.Close()
|
||||
_ = readerTx.Rollback()
|
||||
})
|
||||
|
||||
walPath := databasePath + "-wal"
|
||||
shmPath := databasePath + "-shm"
|
||||
walBefore, err := os.ReadFile(walPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read WAL before restore: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(shmPath); err != nil {
|
||||
t.Fatalf("stat SHM before restore: %v", err)
|
||||
}
|
||||
backupDir := filepath.Join(filepath.Dir(databasePath), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir backup dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "manual-1.db"), []byte("restored"), 0o644); err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
|
||||
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
||||
svc := NewService(databasePath, pool, tracker, newTestLogger(t))
|
||||
jobID, err := svc.Restore(context.Background(), "manual-1.db", RestoreConfirmToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
job := waitForJob(t, tracker, jobID, jobs.StateFailed)
|
||||
if job.State != jobs.StateFailed {
|
||||
t.Fatalf("state = %s, want failed; message = %s", job.State, job.Message)
|
||||
}
|
||||
|
||||
if got, err := os.ReadFile(walPath); err != nil {
|
||||
t.Fatalf("read WAL after rejected restore: %v", err)
|
||||
} else if string(got) != string(walBefore) {
|
||||
t.Fatal("WAL changed after a busy checkpoint")
|
||||
}
|
||||
if _, err := os.Stat(shmPath); err != nil {
|
||||
t.Fatalf("SHM sidecar was removed after a busy checkpoint: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(databasePath); err != nil {
|
||||
t.Fatalf("read database after rejected restore: %v", err)
|
||||
} else if len(got) == 0 || string(got) == "restored" {
|
||||
t.Fatal("database was replaced after a busy checkpoint")
|
||||
}
|
||||
if err := pool.Writer().Ping(); err != nil {
|
||||
t.Fatalf("database pool closed after a busy checkpoint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreReplacementFailureRestoresOriginalFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir database dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(databasePath, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("write original database: %v", err)
|
||||
}
|
||||
walPath := databasePath + "-wal"
|
||||
shmPath := databasePath + "-shm"
|
||||
if err := os.WriteFile(walPath, []byte("wal-before"), 0o644); err != nil {
|
||||
t.Fatalf("write WAL sidecar: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(shmPath, []byte("shm-before"), 0o644); err != nil {
|
||||
t.Fatalf("write SHM sidecar: %v", err)
|
||||
}
|
||||
backupDir := filepath.Join(filepath.Dir(databasePath), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir backup dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "manual-1.db"), []byte("restored"), 0o644); err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
|
||||
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
||||
svc := NewService(databasePath, nil, tracker, newTestLogger(t))
|
||||
stagedPath := databasePath + ".new"
|
||||
if err := os.WriteFile(stagedPath, []byte("restored"), 0o644); err != nil {
|
||||
t.Fatalf("write staged database: %v", err)
|
||||
}
|
||||
rename := func(oldPath, newPath string) error {
|
||||
if oldPath == stagedPath {
|
||||
return errors.New("injected replacement failure")
|
||||
}
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
if err := svc.replaceDatabaseWith(stagedPath, rename); err == nil {
|
||||
t.Fatal("replaceDatabaseWith succeeded after injected replacement failure")
|
||||
}
|
||||
|
||||
if got, err := os.ReadFile(databasePath); err != nil {
|
||||
t.Fatalf("read original database after failed restore: %v", err)
|
||||
} else if string(got) != "original" {
|
||||
t.Fatalf("database bytes = %q, want original", got)
|
||||
}
|
||||
if got, err := os.ReadFile(walPath); err != nil {
|
||||
t.Fatalf("read WAL after failed restore: %v", err)
|
||||
} else if string(got) != "wal-before" {
|
||||
t.Fatalf("WAL bytes = %q, want wal-before", got)
|
||||
}
|
||||
if got, err := os.ReadFile(shmPath); err != nil {
|
||||
t.Fatalf("read SHM after failed restore: %v", err)
|
||||
} else if string(got) != "shm-before" {
|
||||
t.Fatalf("SHM bytes = %q, want shm-before", got)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package backups manages user-facing SQLite snapshots stored under
|
||||
// <data-dir>/backups/. The package distinguishes two kinds of snapshots
|
||||
// Package backups manages user-facing SQLite snapshots stored in backups/
|
||||
// beside the configured SQLite database. The package distinguishes two kinds of snapshots
|
||||
// purely by filename prefix:
|
||||
//
|
||||
// - "kandev-<...>.db" -- AUTO snapshots written by the persistence layer
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/db/dialect"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
@@ -35,48 +36,57 @@ const RestoreConfirmToken = "RESTORE"
|
||||
// errRestoreConfirm is exported so handlers can map it to HTTP 400.
|
||||
var errRestoreConfirm = errors.New("restore requires confirm=RESTORE")
|
||||
|
||||
var errRestoreUnsupported = errors.New("restore is only supported for SQLite")
|
||||
|
||||
// ErrInvalidName is returned for filenames that contain path separators,
|
||||
// "..", or absolute prefixes.
|
||||
var ErrInvalidName = errors.New("invalid backup name")
|
||||
|
||||
// Service owns access to the <data-dir>/backups directory and exposes the
|
||||
// list/create/restore/delete/download API.
|
||||
// Service owns access to the backups directory beside the configured database
|
||||
// file and exposes the list/create/restore/delete/download API.
|
||||
//
|
||||
// Restore intentionally does not attempt to re-exec the backend: the staged
|
||||
// DB file is written in place and the user is told (via the frontend dialog)
|
||||
// to quit and relaunch Kandev to load the restored data. The previous
|
||||
// syscall.Exec approach was brittle under desktop launchers and `make dev`
|
||||
// watchers, and left the web UI disconnected from a fresh backend.
|
||||
// to quit and relaunch Kandev to load the restored data. Before replacing the
|
||||
// file, restore quiesces scheduling, active executions, and database-backed
|
||||
// workers, checkpoints and closes the SQLite pool, then replaces the main
|
||||
// database and sidecars with rollback protection. The previous syscall.Exec
|
||||
// approach was brittle under desktop launchers and `make dev` watchers, and
|
||||
// left the web UI disconnected from a fresh backend.
|
||||
type Service struct {
|
||||
dataDir string
|
||||
pool *db.Pool
|
||||
jobs *jobs.Tracker
|
||||
log *logger.Logger
|
||||
databasePath string
|
||||
pool *db.Pool
|
||||
jobs *jobs.Tracker
|
||||
log *logger.Logger
|
||||
|
||||
// RestoreQuiesce stops scheduling, active executions, and database-backed
|
||||
// workers before restore closes the shared database pool. Wired by the
|
||||
// backend composition root; tests may leave it nil.
|
||||
RestoreQuiesce func() error
|
||||
|
||||
// OrchestratorShutdown is the legacy reset/restore hook. Restore uses it
|
||||
// only when RestoreQuiesce is not wired.
|
||||
OrchestratorShutdown func()
|
||||
|
||||
// failWritesForTest, when true, causes Restore's staged-write step to
|
||||
// fail before kandev.db is touched. Only set by tests.
|
||||
// fail before the configured database file is touched. Only set by tests.
|
||||
failWritesForTest bool
|
||||
}
|
||||
|
||||
// NewService constructs a Service. The backups directory under dataDir is
|
||||
// created lazily by methods that need it.
|
||||
func NewService(dataDir string, pool *db.Pool, tracker *jobs.Tracker, log *logger.Logger) *Service {
|
||||
// NewService constructs a Service. The backups directory beside databasePath
|
||||
// is created lazily by methods that need it.
|
||||
func NewService(databasePath string, pool *db.Pool, tracker *jobs.Tracker, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
dataDir: dataDir,
|
||||
pool: pool,
|
||||
jobs: tracker,
|
||||
log: log,
|
||||
databasePath: databasePath,
|
||||
pool: pool,
|
||||
jobs: tracker,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// backupsDir returns the absolute path to the snapshots directory.
|
||||
// backupsDir returns the snapshots directory beside the configured database.
|
||||
func (s *Service) backupsDir() string {
|
||||
return filepath.Join(s.dataDir, "backups")
|
||||
}
|
||||
|
||||
// dbPath returns the absolute path to the live SQLite database file.
|
||||
func (s *Service) dbPath() string {
|
||||
return filepath.Join(s.dataDir, "kandev.db")
|
||||
return filepath.Join(filepath.Dir(s.databasePath), "backups")
|
||||
}
|
||||
|
||||
// ensureBackupsDir mkdirs the backups directory.
|
||||
@@ -84,7 +94,7 @@ func (s *Service) ensureBackupsDir() error {
|
||||
return os.MkdirAll(s.backupsDir(), 0o755)
|
||||
}
|
||||
|
||||
// List enumerates the snapshots in <data-dir>/backups, classifying each
|
||||
// List enumerates the snapshots in the sibling backups directory, classifying each
|
||||
// .db file as auto or manual. Non-.db files and unrecognised prefixes are
|
||||
// skipped silently. Always returns a non-nil slice.
|
||||
func (s *Service) List() ([]Snapshot, error) {
|
||||
@@ -206,6 +216,9 @@ func (s *Service) Restore(ctx context.Context, name, confirm string) (string, er
|
||||
if confirm != RestoreConfirmToken {
|
||||
return "", errRestoreConfirm
|
||||
}
|
||||
if err := s.ensureSQLiteRestore(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
abs, err := s.resolveSnapshotPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -220,14 +233,26 @@ func (s *Service) runRestore(_ context.Context, snapshotPath string) (map[string
|
||||
if _, err := os.Stat(snapshotPath); err != nil {
|
||||
return nil, fmt.Errorf("snapshot not found: %w", err)
|
||||
}
|
||||
stagedPath := s.dbPath() + ".new"
|
||||
stagedPath := s.databasePath + ".new"
|
||||
if err := s.writeStagedRestore(snapshotPath, stagedPath); err != nil {
|
||||
_ = os.Remove(stagedPath)
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Rename(stagedPath, s.dbPath()); err != nil {
|
||||
if s.RestoreQuiesce != nil {
|
||||
if err := s.RestoreQuiesce(); err != nil {
|
||||
_ = os.Remove(stagedPath)
|
||||
return nil, fmt.Errorf("quiesce database for restore: %w", err)
|
||||
}
|
||||
} else if s.OrchestratorShutdown != nil {
|
||||
s.OrchestratorShutdown()
|
||||
}
|
||||
if err := s.quiesceDatabase(); err != nil {
|
||||
_ = os.Remove(stagedPath)
|
||||
return nil, fmt.Errorf("atomic rename failed: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.replaceDatabase(stagedPath); err != nil {
|
||||
_ = os.Remove(stagedPath)
|
||||
return nil, err
|
||||
}
|
||||
// Intentionally no auto-restart. The frontend dialog reads
|
||||
// restart_required from the job result and prompts the user to quit and
|
||||
@@ -238,6 +263,120 @@ func (s *Service) runRestore(_ context.Context, snapshotPath string) (map[string
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureSQLiteRestore() error {
|
||||
if s.pool == nil || s.pool.Writer() == nil {
|
||||
return nil
|
||||
}
|
||||
driver := s.pool.Writer().DriverName()
|
||||
if driver != dialect.SQLite3 {
|
||||
return fmt.Errorf("%w: %s driver", errRestoreUnsupported, driver)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// quiesceDatabase flushes pending SQLite WAL frames and closes the shared
|
||||
// pool before the configured database file is replaced. Closing the pool is
|
||||
// intentional: the frontend receives restart_required and must relaunch the
|
||||
// backend before it accepts database-backed work again.
|
||||
func (s *Service) quiesceDatabase() error {
|
||||
if err := s.ensureSQLiteRestore(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.pool == nil || s.pool.Writer() == nil {
|
||||
return nil
|
||||
}
|
||||
writer := s.pool.Writer()
|
||||
var busy, logFrames, checkpointed int
|
||||
if err := writer.QueryRowx("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointed); err != nil {
|
||||
return fmt.Errorf("checkpoint sqlite WAL: %w", err)
|
||||
}
|
||||
if busy != 0 {
|
||||
return fmt.Errorf("checkpoint sqlite WAL: busy (%d frames remain)", logFrames-checkpointed)
|
||||
}
|
||||
if err := s.pool.Close(); err != nil {
|
||||
return fmt.Errorf("close database pool: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type restoreOriginalFile struct {
|
||||
livePath string
|
||||
quarantinePath string
|
||||
}
|
||||
|
||||
// replaceDatabase quarantines the live database and its sidecars before
|
||||
// installing the staged file. If installation fails, every quarantined file
|
||||
// is moved back before the error is returned. The quarantine is in the same
|
||||
// directory so each rename is atomic on the same filesystem.
|
||||
func (s *Service) replaceDatabase(stagedPath string) error {
|
||||
return s.replaceDatabaseWith(stagedPath, os.Rename)
|
||||
}
|
||||
|
||||
func (s *Service) replaceDatabaseWith(stagedPath string, rename func(string, string) error) error {
|
||||
quarantineBase, err := createRestoreQuarantine(filepath.Dir(s.databasePath), filepath.Base(s.databasePath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create restore quarantine: %w", err)
|
||||
}
|
||||
|
||||
originals := []restoreOriginalFile{
|
||||
{livePath: s.databasePath, quarantinePath: quarantineBase},
|
||||
{livePath: s.databasePath + "-wal", quarantinePath: quarantineBase + "-wal"},
|
||||
{livePath: s.databasePath + "-shm", quarantinePath: quarantineBase + "-shm"},
|
||||
}
|
||||
moved := make([]restoreOriginalFile, 0, len(originals))
|
||||
rollback := func(cause error) error {
|
||||
var rollbackErrs []error
|
||||
for i := len(moved) - 1; i >= 0; i-- {
|
||||
original := moved[i]
|
||||
if err := rename(original.quarantinePath, original.livePath); err != nil {
|
||||
rollbackErrs = append(rollbackErrs, fmt.Errorf("restore %s: %w", original.livePath, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(cause, errors.Join(rollbackErrs...))
|
||||
}
|
||||
|
||||
for _, original := range originals {
|
||||
if _, err := os.Lstat(original.livePath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return rollback(fmt.Errorf("inspect %s: %w", original.livePath, err))
|
||||
}
|
||||
if err := rename(original.livePath, original.quarantinePath); err != nil {
|
||||
return rollback(fmt.Errorf("quarantine %s: %w", original.livePath, err))
|
||||
}
|
||||
moved = append(moved, original)
|
||||
}
|
||||
|
||||
if err := rename(stagedPath, s.databasePath); err != nil {
|
||||
return rollback(fmt.Errorf("atomic rename failed: %w", err))
|
||||
}
|
||||
|
||||
for _, original := range moved {
|
||||
if err := os.Remove(original.quarantinePath); err != nil && !errors.Is(err, os.ErrNotExist) && s.log != nil {
|
||||
s.log.Warn("backups: failed to remove quarantined database file",
|
||||
zap.String("path", original.quarantinePath), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createRestoreQuarantine(dir, base string) (string, error) {
|
||||
f, err := os.CreateTemp(dir, "."+base+".restore-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := f.Name()
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", err
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// writeStagedRestore copies snapshotPath to stagedPath. Honors
|
||||
// failWritesForTest so tests can simulate a mid-restore failure that
|
||||
// leaves the original DB untouched.
|
||||
@@ -304,7 +443,7 @@ func (s *Service) OpenForDownload(name string) (*os.File, int64, error) {
|
||||
// separators, no "..", no absolute prefix), confirms it matches a
|
||||
// recognised snapshot prefix (so unrelated files dropped into the backups
|
||||
// directory cannot be restored/downloaded/deleted), and that it resolves
|
||||
// inside the backups directory. Returns the absolute path.
|
||||
// inside the backups directory. Returns the joined path.
|
||||
func (s *Service) resolveSnapshotPath(name string) (string, error) {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "", ErrInvalidName
|
||||
|
||||
@@ -51,7 +51,7 @@ func newTestService(t *testing.T) (*Service, string) {
|
||||
t.Helper()
|
||||
pool, dataDir := newTestPool(t)
|
||||
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
||||
svc := NewService(dataDir, pool, tracker, newTestLogger(t))
|
||||
svc := NewService(filepath.Join(dataDir, "kandev.db"), pool, tracker, newTestLogger(t))
|
||||
return svc, dataDir
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -108,7 +109,9 @@ func TestOptimize_StartsJobAndSucceeds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMaintenanceOperationsRejectPostgres(t *testing.T) {
|
||||
svc := NewService(newFakePostgresStatsPool(t), t.TempDir(), ResetDirs{}, nil, nil)
|
||||
svc := NewService(
|
||||
newFakePostgresStatsPool(t), filepath.Join(t.TempDir(), "kandev.db"), ResetDirs{}, nil, nil,
|
||||
)
|
||||
shutdownCalled := false
|
||||
svc.OrchestratorShutdown = func() { shutdownCalled = true }
|
||||
|
||||
@@ -157,7 +160,9 @@ func TestMaintenanceOperationsRejectPostgres(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMaintenanceOperationsRejectNilWriter(t *testing.T) {
|
||||
svc := NewService(db.NewPool(nil, nil), t.TempDir(), ResetDirs{}, nil, nil)
|
||||
svc := NewService(
|
||||
db.NewPool(nil, nil), filepath.Join(t.TempDir(), "kandev.db"), ResetDirs{}, nil, nil,
|
||||
)
|
||||
|
||||
_, err := svc.runVacuum(context.Background())
|
||||
if err == nil || err.Error() != "vacuum: no database pool" {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/system/jobs"
|
||||
)
|
||||
|
||||
func TestConfiguredDatabasePath_StatsUseSiblingFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir database dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(databasePath+"-wal", []byte("wal-bytes"), 0o644); err != nil {
|
||||
t.Fatalf("write wal: %v", err)
|
||||
}
|
||||
backupDir := filepath.Join(filepath.Dir(databasePath), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir backup dir: %v", err)
|
||||
}
|
||||
backupPath := filepath.Join(backupDir, "manual-1.db")
|
||||
if err := os.WriteFile(backupPath, []byte("snapshot"), 0o644); err != nil {
|
||||
t.Fatalf("write snapshot: %v", err)
|
||||
}
|
||||
backupTime := time.Now().Add(-time.Minute)
|
||||
if err := os.Chtimes(backupPath, backupTime, backupTime); err != nil {
|
||||
t.Fatalf("set snapshot time: %v", err)
|
||||
}
|
||||
|
||||
svc := NewService(nil, databasePath, ResetDirs{}, nil, nil)
|
||||
stats, err := svc.Stats()
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.Path != databasePath {
|
||||
t.Errorf("Path = %q, want %q", stats.Path, databasePath)
|
||||
}
|
||||
if stats.WALSizeBytes != int64(len("wal-bytes")) {
|
||||
t.Errorf("WALSizeBytes = %d, want %d", stats.WALSizeBytes, len("wal-bytes"))
|
||||
}
|
||||
if stats.LastBackupAt == nil {
|
||||
t.Fatal("LastBackupAt is nil, want the custom backup time")
|
||||
}
|
||||
if stats.LastBackupAt.Before(backupTime.Add(-time.Second)) {
|
||||
t.Errorf("LastBackupAt = %v, want at least %v", *stats.LastBackupAt, backupTime.Add(-time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDatabasePath_FactoryResetUsesSiblingBackupDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
if err := os.MkdirAll(filepath.Dir(databasePath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir database dir: %v", err)
|
||||
}
|
||||
writerRaw, err := db.OpenSQLite(databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite writer: %v", err)
|
||||
}
|
||||
readerRaw, err := db.OpenSQLiteReader(databasePath)
|
||||
if err != nil {
|
||||
_ = writerRaw.Close()
|
||||
t.Fatalf("open sqlite reader: %v", err)
|
||||
}
|
||||
pool := db.NewPool(sqlx.NewDb(writerRaw, "sqlite3"), sqlx.NewDb(readerRaw, "sqlite3"))
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE kandev_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '')`,
|
||||
`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
} {
|
||||
if _, err := pool.Writer().Exec(statement); err != nil {
|
||||
t.Fatalf("seed schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
||||
svc := NewService(pool, databasePath, ResetDirs{}, tracker, newTestLogger(t))
|
||||
jobID, err := svc.FactoryReset(context.Background(), resetConfirmToken)
|
||||
if err != nil {
|
||||
t.Fatalf("FactoryReset: %v", err)
|
||||
}
|
||||
job := waitForState(t, tracker, jobID, jobs.StateSucceeded)
|
||||
snapshotPath, ok := job.Result["snapshot_path"].(string)
|
||||
if !ok || snapshotPath == "" {
|
||||
t.Fatalf("snapshot_path = %#v, want path", job.Result["snapshot_path"])
|
||||
}
|
||||
wantDir := filepath.Join(filepath.Dir(databasePath), "backups")
|
||||
if filepath.Dir(snapshotPath) != wantDir {
|
||||
t.Errorf("snapshot dir = %q, want %q", filepath.Dir(snapshotPath), wantDir)
|
||||
}
|
||||
if _, err := os.Stat(snapshotPath); err != nil {
|
||||
t.Errorf("snapshot missing: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ var ErrResetNotConfirmed = errors.New("factory reset requires confirm=\"RESET\""
|
||||
//
|
||||
// The job:
|
||||
// 1. Calls s.OrchestratorShutdown (if set) to stop running executions.
|
||||
// 2. Snapshots the live DB to <dataDir>/backups/kandev-pre-reset-<unix>.db.
|
||||
// 2. Snapshots the live DB to the sibling backups directory.
|
||||
// 3. Drops every user table from the SQLite schema (kandev_meta is kept).
|
||||
// 4. os.RemoveAll on worktrees/repos/sessions/tasks/quick-chat subdirs.
|
||||
//
|
||||
@@ -86,7 +86,7 @@ func (s *Service) createPreResetSnapshot() (string, error) {
|
||||
if s.pool == nil {
|
||||
return "", fmt.Errorf("no database pool")
|
||||
}
|
||||
backupDir := filepath.Join(s.dataDir, "backups")
|
||||
backupDir := s.backupsDir()
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("mkdir backups: %w", err)
|
||||
}
|
||||
|
||||
@@ -61,32 +61,34 @@ type ResetDirs struct {
|
||||
// quit and relaunch Kandev. The previous syscall.Exec approach was brittle
|
||||
// under desktop launchers and `make dev` watchers.
|
||||
type Service struct {
|
||||
pool *db.Pool
|
||||
dataDir string
|
||||
dbPath string
|
||||
dirs ResetDirs
|
||||
jobs *jobs.Tracker
|
||||
log *logger.Logger
|
||||
pool *db.Pool
|
||||
databasePath string
|
||||
dirs ResetDirs
|
||||
jobs *jobs.Tracker
|
||||
log *logger.Logger
|
||||
|
||||
// OrchestratorShutdown stops the orchestrator and active executions before
|
||||
// the factory-reset job runs. Wired by cmd/kandev. Tests pass a no-op.
|
||||
OrchestratorShutdown func()
|
||||
}
|
||||
|
||||
// NewService constructs a Service. dataDir is the resolved kandev data
|
||||
// directory (the SQLite file lives at <dataDir>/kandev.db, backups under
|
||||
// <dataDir>/backups). dirs lists the on-disk subtrees factory-reset wipes.
|
||||
func NewService(pool *db.Pool, dataDir string, dirs ResetDirs, j *jobs.Tracker, log *logger.Logger) *Service {
|
||||
// NewService constructs a Service for the configured SQLite database path.
|
||||
// The sibling backups directory is derived from databasePath. dirs lists the
|
||||
// on-disk subtrees factory-reset wipes.
|
||||
func NewService(pool *db.Pool, databasePath string, dirs ResetDirs, j *jobs.Tracker, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
pool: pool,
|
||||
dataDir: dataDir,
|
||||
dbPath: filepath.Join(dataDir, "kandev.db"),
|
||||
dirs: dirs,
|
||||
jobs: j,
|
||||
log: log,
|
||||
pool: pool,
|
||||
databasePath: databasePath,
|
||||
dirs: dirs,
|
||||
jobs: j,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) backupsDir() string {
|
||||
return filepath.Join(filepath.Dir(s.databasePath), "backups")
|
||||
}
|
||||
|
||||
// Stats returns the current database stats. SQLite size is computed from
|
||||
// PRAGMA page_count * page_size (cheaper than os.Stat on hot writes and more
|
||||
// accurate during a VACUUM that creates a sibling file). Postgres size is
|
||||
@@ -95,7 +97,7 @@ func (s *Service) Stats() (Stats, error) {
|
||||
driver := s.databaseDriver()
|
||||
out := Stats{Driver: driver}
|
||||
if driver == databaseDriverSQLite {
|
||||
out.Path = s.dbPath
|
||||
out.Path = s.databasePath
|
||||
}
|
||||
|
||||
if s.pool != nil {
|
||||
@@ -113,11 +115,11 @@ func (s *Service) Stats() (Stats, error) {
|
||||
}
|
||||
|
||||
if driver == databaseDriverSQLite {
|
||||
if wal, err := walSize(s.dbPath); err == nil {
|
||||
if wal, err := walSize(s.databasePath); err == nil {
|
||||
out.WALSizeBytes = wal
|
||||
}
|
||||
|
||||
if last := lastBackupAt(filepath.Join(s.dataDir, "backups")); !last.IsZero() {
|
||||
if last := lastBackupAt(s.backupsDir()); !last.IsZero() {
|
||||
out.LastBackupAt = &last
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func newTestService(t *testing.T) (*Service, *jobs.Tracker, *stubBus, string) {
|
||||
t.Fatalf("write sentinel: %v", err)
|
||||
}
|
||||
}
|
||||
svc := NewService(pool, dataDir, dirs, tracker, log)
|
||||
svc := NewService(pool, filepath.Join(dataDir, "kandev.db"), dirs, tracker, log)
|
||||
return svc, tracker, stub, dataDir
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestStats_ReturnsPathSizeAndSchemaVersion(t *testing.T) {
|
||||
|
||||
func TestStats_PostgresDoesNotUseSQLitePragmas(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
svc := NewService(newFakePostgresStatsPool(t), dataDir, ResetDirs{}, nil, nil)
|
||||
svc := NewService(newFakePostgresStatsPool(t), filepath.Join(dataDir, "kandev.db"), ResetDirs{}, nil, nil)
|
||||
|
||||
stats, err := svc.Stats()
|
||||
if err != nil {
|
||||
|
||||
@@ -51,10 +51,13 @@ type BuildInfo struct {
|
||||
|
||||
// Wiring supplies the runtime hooks and repositories owned by the wider
|
||||
// application. OrchestratorShutdown stops in-flight agent executions before
|
||||
// destructive resets; TaskSessions is the authoritative session reader used
|
||||
// by the install-wide sleep-inhibition service.
|
||||
// destructive resets. RestoreQuiesce stops the complete database-backed
|
||||
// runtime before a SQLite restore closes the shared pool. TaskSessions is the
|
||||
// authoritative session reader used by the install-wide sleep-inhibition
|
||||
// service.
|
||||
type Wiring struct {
|
||||
OrchestratorShutdown func()
|
||||
RestoreQuiesce func() error
|
||||
MessageQueue queuesettings.Target
|
||||
TaskSessions sleepinhibition.SessionReader
|
||||
}
|
||||
@@ -89,6 +92,10 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus
|
||||
tracker := jobs.NewTracker(eventBus, log)
|
||||
dataDir := cfg.ResolvedDataDir()
|
||||
homeDir := cfg.ResolvedHomeDir()
|
||||
databasePath := cfg.Database.Path
|
||||
if databasePath == "" {
|
||||
databasePath = filepath.Join(dataDir, "kandev.db")
|
||||
}
|
||||
|
||||
resetDirs := database.ResetDirs{
|
||||
Worktrees: filepath.Join(homeDir, "worktrees"),
|
||||
@@ -97,10 +104,12 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus
|
||||
Tasks: filepath.Join(homeDir, "tasks"),
|
||||
QuickChat: filepath.Join(homeDir, "quick-chat"),
|
||||
}
|
||||
dbSvc := database.NewService(pool, dataDir, resetDirs, tracker, log)
|
||||
dbSvc := database.NewService(pool, databasePath, resetDirs, tracker, log)
|
||||
dbSvc.OrchestratorShutdown = wiring.OrchestratorShutdown
|
||||
|
||||
backupsSvc := backups.NewService(dataDir, pool, tracker, log)
|
||||
backupsSvc := backups.NewService(databasePath, pool, tracker, log)
|
||||
backupsSvc.OrchestratorShutdown = wiring.OrchestratorShutdown
|
||||
backupsSvc.RestoreQuiesce = wiring.RestoreQuiesce
|
||||
|
||||
settingsStore, err := systemsettings.NewStore(pool)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
)
|
||||
|
||||
func TestConfiguredDatabasePath_ProvideListsSiblingBackups(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
root := t.TempDir()
|
||||
homeDir := filepath.Join(root, "home")
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
pool := openSystemDatabasePathPool(t, databasePath)
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
|
||||
backupDir := filepath.Join(filepath.Dir(databasePath), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir custom backups: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(backupDir, "manual-1.db"), []byte("custom"), 0o644); err != nil {
|
||||
t.Fatalf("write custom backup: %v", err)
|
||||
}
|
||||
oldBackupDir := filepath.Join(homeDir, "data", "backups")
|
||||
if err := os.MkdirAll(oldBackupDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir old backups: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(oldBackupDir, "manual-old.db"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatalf("write old backup: %v", err)
|
||||
}
|
||||
|
||||
log, err := logger.NewFromZap(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
HomeDir: homeDir,
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
Path: databasePath,
|
||||
},
|
||||
}
|
||||
service := Provide(cfg, log, pool, nil, BuildInfo{}, Wiring{})
|
||||
router := gin.New()
|
||||
service.RegisterRoutes(router, log)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/system/backups", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Snapshots []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"snapshots"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload.Snapshots) != 1 || payload.Snapshots[0].Name != "manual-1.db" {
|
||||
t.Fatalf("snapshots = %+v, want only custom backup", payload.Snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvideWiresRestoreQuiesce(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
databasePath := filepath.Join(root, "custom", "named.db")
|
||||
pool := openSystemDatabasePathPool(t, databasePath)
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
log, err := logger.NewFromZap(zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("logger: %v", err)
|
||||
}
|
||||
calls := 0
|
||||
cfg := &config.Config{
|
||||
HomeDir: filepath.Join(root, "home"),
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
Path: databasePath,
|
||||
},
|
||||
}
|
||||
service := Provide(cfg, log, pool, nil, BuildInfo{}, Wiring{
|
||||
RestoreQuiesce: func() error {
|
||||
calls++
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if service.Backups.RestoreQuiesce == nil {
|
||||
t.Fatal("Backups.RestoreQuiesce is nil")
|
||||
}
|
||||
if err := service.Backups.RestoreQuiesce(); err != nil {
|
||||
t.Fatalf("RestoreQuiesce: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("RestoreQuiesce calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func openSystemDatabasePathPool(t *testing.T, databasePath string) *db.Pool {
|
||||
t.Helper()
|
||||
writerRaw, err := db.OpenSQLite(databasePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite writer: %v", err)
|
||||
}
|
||||
readerRaw, err := db.OpenSQLiteReader(databasePath)
|
||||
if err != nil {
|
||||
_ = writerRaw.Close()
|
||||
t.Fatalf("open sqlite reader: %v", err)
|
||||
}
|
||||
return db.NewPool(sqlx.NewDb(writerRaw, "sqlite3"), sqlx.NewDb(readerRaw, "sqlite3"))
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
|
||||
import { restoreBackup } from "@/lib/api/domains/system-api";
|
||||
import { useSystemJob } from "@/hooks/domains/system/use-system-jobs";
|
||||
import { useKandevRestart } from "@/hooks/domains/system/use-kandev-restart";
|
||||
import { RestartProgressDialog } from "./restart-progress-dialog";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -124,8 +126,9 @@ function ConfirmView({
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessView({ name, onClose }: { name: string; onClose: () => void }) {
|
||||
function SuccessView({ name }: { name: string }) {
|
||||
const { t } = useTranslation();
|
||||
const restart = useKandevRestart();
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@@ -137,22 +140,27 @@ function SuccessView({ name, onClose }: { name: string; onClose: () => void }) {
|
||||
<span>
|
||||
<Trans i18nKey="system:restoreCompleteBody" values={{ name }}>
|
||||
<code className="font-mono">{name}</code> has been written over the current database.
|
||||
Quit and relaunch Kandev to load the restored data - the running backend still holds
|
||||
connections to the previous version and will serve stale rows until you restart.
|
||||
Restart Kandev before you continue. The database pool is closed until the restart. If
|
||||
automatic restart is unavailable, quit and relaunch Kandev manually.
|
||||
</Trans>
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer"
|
||||
data-testid="system-restore-close"
|
||||
onClick={() => void restart.start()}
|
||||
disabled={restart.isRestarting}
|
||||
className="w-full cursor-pointer sm:w-auto"
|
||||
data-testid="system-restore-restart"
|
||||
>
|
||||
{t("system:close")}
|
||||
{t("system:agentRuntimeUnavailableRestart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<RestartProgressDialog
|
||||
phase={restart.phase}
|
||||
errorMessage={restart.errorMessage}
|
||||
onDismiss={restart.dismiss}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -174,7 +182,7 @@ export function RestoreDialog({ open, onOpenChange, name }: Props) {
|
||||
const enabled = typed === CONFIRM_TOKEN && !submitting && !succeeded;
|
||||
|
||||
const handleClose = (next: boolean) => {
|
||||
if (submitting) return;
|
||||
if (submitting || succeeded) return;
|
||||
if (!next) {
|
||||
setTyped("");
|
||||
setRequestError(null);
|
||||
@@ -201,7 +209,7 @@ export function RestoreDialog({ open, onOpenChange, name }: Props) {
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent data-testid="system-restore-dialog">
|
||||
{succeeded ? (
|
||||
<SuccessView name={name} onClose={() => handleClose(false)} />
|
||||
<SuccessView name={name} />
|
||||
) : (
|
||||
<ConfirmView
|
||||
name={name}
|
||||
|
||||
@@ -4,12 +4,32 @@ import { activateLocale } from "@/lib/i18n";
|
||||
import { FactoryResetDialog } from "./factory-reset-dialog";
|
||||
import { RestoreDialog } from "./restore-dialog";
|
||||
|
||||
const restoreDialogTestState = vi.hoisted(() => ({
|
||||
job: null as { state: string; message?: string } | null,
|
||||
restart: {
|
||||
phase: "idle" as const,
|
||||
errorMessage: null as string | null,
|
||||
isRestarting: false,
|
||||
start: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/system/use-system-jobs", () => ({
|
||||
useSystemJob: () => null,
|
||||
useSystemJob: () => restoreDialogTestState.job,
|
||||
useSystemJobs: () => [],
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
vi.mock("@/hooks/domains/system/use-kandev-restart", () => ({
|
||||
useKandevRestart: () => restoreDialogTestState.restart,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
restoreDialogTestState.job = null;
|
||||
restoreDialogTestState.restart.start.mockReset();
|
||||
restoreDialogTestState.restart.dismiss.mockReset();
|
||||
});
|
||||
|
||||
/**
|
||||
* Both dialogs gate their confirm button on `typed === CONFIRM_TOKEN` and send
|
||||
@@ -59,6 +79,17 @@ describe("system type-to-confirm dialogs", () => {
|
||||
fireEvent.change(input, { target: { value: "RESTORE" } });
|
||||
expect(confirm.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a restart after a successful restore", () => {
|
||||
restoreDialogTestState.job = { state: "succeeded" };
|
||||
render(<RestoreDialog open onOpenChange={vi.fn()} name="snapshot-1.db" />);
|
||||
|
||||
expect(screen.getByTestId("system-restore-restart")).toBeTruthy();
|
||||
expect(screen.queryByTestId("system-restore-close")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("system-restore-restart"));
|
||||
expect(restoreDialogTestState.restart.start).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"restartUnsupportedHelp": "Automatic restart is not available in deploy previews, unmanaged terminal runs, or launch modes without a restart supervisor.",
|
||||
"restoreAction": "Restore",
|
||||
"restoreBody": "Restore <1>{{name}}</1> over the current database. After the staged copy is in place you will be asked to quit and relaunch Kandev so the new data is loaded fresh - the backend does not auto-restart.",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> has been written over the current database. Quit and relaunch Kandev to load the restored data - the running backend still holds connections to the previous version and will serve stale rows until you restart.",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> has been written over the current database. Restart Kandev before you continue. The database pool is closed until the restart. If automatic restart is unavailable, quit and relaunch Kandev manually.",
|
||||
"restoreCompleteTitle": "Restore complete",
|
||||
"restoreFailed": "Restore failed",
|
||||
"restorePlaceholder": "Type {{token}} to confirm",
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"restartUnsupportedHelp": "Àũţōḿàţĩć ŕēśţàŕţ ĩś ńōţ àvàĩĺàƀĺē ĩń ďēƥĺōŷ ƥŕēvĩēŵś, ũńḿàńàĝēď ţēŕḿĩńàĺ ŕũńś, ōŕ ĺàũńćĥ ḿōďēś ŵĩţĥōũţ à ŕēśţàŕţ śũƥēŕvĩśōŕ.",
|
||||
"restoreAction": "Ŕēśţōŕē",
|
||||
"restoreBody": "Ŕēśţōŕē <1>{{name}}</1> ōvēŕ ţĥē ćũŕŕēńţ ďàţàƀàśē. Àƒţēŕ ţĥē śţàĝēď ćōƥŷ ĩś ĩń ƥĺàćē ŷōũ ŵĩĺĺ ƀē àśķēď ţō qũĩţ àńď ŕēĺàũńćĥ Ķàńďēv śō ţĥē ńēŵ ďàţà ĩś ĺōàďēď ƒŕēśĥ - ţĥē ƀàćķēńď ďōēś ńōţ àũţō-ŕēśţàŕţ.",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> ĥàś ƀēēń ŵŕĩţţēń ōvēŕ ţĥē ćũŕŕēńţ ďàţàƀàśē. Qũĩţ àńď ŕēĺàũńćĥ Ķàńďēv ţō ĺōàď ţĥē ŕēśţōŕēď ďàţà - ţĥē ŕũńńĩńĝ ƀàćķēńď śţĩĺĺ ĥōĺďś ćōńńēćţĩōńś ţō ţĥē ƥŕēvĩōũś vēŕśĩōń àńď ŵĩĺĺ śēŕvē śţàĺē ŕōŵś ũńţĩĺ ŷōũ ŕēśţàŕţ.",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> ĥàś ƀēēń ŵŕĩţţēń ōvēŕ ţĥē ćũŕŕēńţ ďàţàƀàśē. Ŕēśţàŕţ Ķàńďēv ƀēƒōŕē ŷōũ ćōńţĩńũē. Ţĥē ďàţàƀàśē ƥōōĺ ĩś ćĺōśēď ũńţĩĺ ţĥē ŕēśţàŕţ. Ĩƒ àũţōḿàţĩć ŕēśţàŕţ ĩś ũńàvàĩĺàƀĺē, qũĩţ àńď ŕēĺàũńćĥ Ķàńďēv ḿàńũàĺĺŷ.",
|
||||
"restoreCompleteTitle": "Ŕēśţōŕē ćōḿƥĺēţē",
|
||||
"restoreFailed": "Ŕēśţōŕē ƒàĩĺēď",
|
||||
"restorePlaceholder": "Ţŷƥē {{token}} ţō ćōńƒĩŕḿ",
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
"restartUnsupportedHelp": "O reinício automático não está disponível em pré-visualizações de implementação, execuções em terminal não geridas ou modos de arranque sem supervisor de reinício.",
|
||||
"restoreAction": "Restaurar",
|
||||
"restoreBody": "Restaurar <1>{{name}}</1> por cima da base de dados atual. Depois de a cópia preparada estar no lugar, ser-lhe-á pedido que feche e reabra o Kandev para que os novos dados sejam carregados de raiz - o backend não reinicia automaticamente.",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> foi escrito por cima da base de dados atual. Feche e reabra o Kandev para carregar os dados restaurados - o backend em execução mantém ligações à versão anterior e continuará a devolver dados desatualizados até reiniciar.",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> foi escrito por cima da base de dados atual. Reinicie o Kandev antes de continuar. O conjunto de ligações à base de dados está fechado até ao reinício. Se o reinício automático não estiver disponível, feche e reabra o Kandev manualmente.",
|
||||
"restoreCompleteTitle": "Restauro concluído",
|
||||
"restoreFailed": "Não foi possível restaurar",
|
||||
"restorePlaceholder": "Escreva {{token}} para confirmar",
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
"restartUnsupportedHelp": "在部署预览、非托管终端运行或不含重启监督程序的启动模式下,自动重启不可用。",
|
||||
"restoreAction": "恢复",
|
||||
"restoreBody": "将 <1>{{name}}</1> 覆盖到当前数据库。暂存副本就绪后,需要你退出并重新启动 Kandev 以加载新数据;后端不会自动重启。",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> 已写入当前数据库。请退出并重新启动 Kandev 以加载恢复的数据;正在运行的后端仍持有旧版本连接,重启前会提供过期行。",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> 已写入当前数据库。请先重启 Kandev,再继续操作。数据库连接池在重启前保持关闭。如果自动重启不可用,请手动退出并重新启动 Kandev。",
|
||||
"restoreCompleteTitle": "恢复完成",
|
||||
"restoreFailed": "恢复失败",
|
||||
"restorePlaceholder": "输入 {{token}} 以确认",
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
"restartUnsupportedHelp": "在部署預覽、非託管終端執行或不含重啓監督程序的啓動模式下,自動重啓不可用。",
|
||||
"restoreAction": "恢復",
|
||||
"restoreBody": "將 <1>{{name}}</1> 覆蓋到目前數據庫。暫存副本就緒後,需要你退出並重新啓動 Kandev 以載入新數據;後端不會自動重啓。",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> 已寫入目前數據庫。請退出並重新啓動 Kandev 以載入恢復的數據;正在執行的後端仍持有舊版本連接,重啓前會提供過期行。",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> 已寫入目前數據庫。請先重啓 Kandev,再繼續操作。數據庫連接池在重啓前保持關閉。如果自動重啓不可用,請手動退出並重新啓動 Kandev。",
|
||||
"restoreCompleteTitle": "恢復完成",
|
||||
"restoreFailed": "恢復失敗",
|
||||
"restorePlaceholder": "輸入 {{token}} 以確認",
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
"restartUnsupportedHelp": "在部署預覽、非託管終端執行或不含重啟監督程式的啟動模式下,自動重啟不可用。",
|
||||
"restoreAction": "恢復",
|
||||
"restoreBody": "將 <1>{{name}}</1> 覆蓋到目前資料庫。暫存副本就緒後,需要你退出並重新啟動 Kandev 以載入新資料;後端不會自動重啟。",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> 已寫入目前資料庫。請退出並重新啟動 Kandev 以載入恢復的資料;正在執行的後端仍持有舊版本連線,重啟前會提供過期行。",
|
||||
"restoreCompleteBody": "<0>{{name}}</0> 已寫入目前資料庫。請先重啟 Kandev,再繼續操作。資料庫連線池在重啟前保持關閉。如果自動重啟不可用,請手動退出並重新啟動 Kandev。",
|
||||
"restoreCompleteTitle": "恢復完成",
|
||||
"restoreFailed": "恢復失敗",
|
||||
"restorePlaceholder": "輸入 {{token}} 以確認",
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
spec: docs/specs/system-page/spec.md
|
||||
created: 2026-08-15
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: SQLite backups follow the database path
|
||||
|
||||
## Overview
|
||||
|
||||
Resolve the live SQLite path once in the System composer. Pass that exact path to the Database and Backups services. Both services derive one sibling `backups/` directory from the path.
|
||||
|
||||
This change aligns manual, pre-reset, and pre-migration snapshots. It also makes restore replace the configured file, including a custom filename, after quiescing the SQLite pool and removing stale WAL sidecars.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
`persistence.Provide` opens `cfg.Database.Path` and writes pre-migration snapshots under `filepath.Dir(dbPath)/backups`. `system.Provide` instead passes `cfg.ResolvedDataDir()` to the Database and Backups services.
|
||||
|
||||
Both System services then reconstruct `<data-dir>/kandev.db`. They do not receive the live path. This split causes list, create, download, restore, database statistics, and pre-reset snapshots to use the wrong location.
|
||||
|
||||
Manual create still snapshots the live writer pool. Its contents are current, but its output location is wrong. Restore replaces a different file and cannot affect the live custom database.
|
||||
|
||||
A focused test configured `<custom>/custom-name.db` and placed `manual-1.db` under `<custom>/backups/`. `system.Provide(...).Backups.List()` returned an empty list. The command failed at the expected assertion:
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/system -run '^TestReproIssue2679BackupsFollowDatabasePath$' -count=1 -v
|
||||
```
|
||||
|
||||
The throwaway test was removed after reproduction.
|
||||
|
||||
## Backend
|
||||
|
||||
### System composition
|
||||
|
||||
Update `apps/backend/internal/system/system.go` to select the SQLite database path once. Use `cfg.Database.Path` when it is set. Otherwise, use `<ResolvedDataDir>/kandev.db`.
|
||||
|
||||
Pass the exact file path to `database.NewService` and `backups.NewService`. Keep home-owned reset directories unchanged.
|
||||
|
||||
### Backups service
|
||||
|
||||
Update `apps/backend/internal/system/backups/store.go` so `Service` owns `databasePath`. Derive the backup directory with `filepath.Join(filepath.Dir(databasePath), "backups")`.
|
||||
|
||||
Use `databasePath` directly for restore staging and replacement. Do not infer the filename `kandev.db`.
|
||||
|
||||
Before replacement, quiesce scheduling, active executions, and database-backed workers. Validate the `wal_checkpoint(TRUNCATE)` result, close the shared SQLite pool, quarantine the main file and its `-wal`/`-shm` sidecars, and restore the quarantine if staged installation fails. Reject PostgreSQL restore before staging or shutdown. The frontend requires a restart after restore, so the closed pool prevents stale WAL frames from replaying onto the restored file.
|
||||
|
||||
### Database service
|
||||
|
||||
Update `apps/backend/internal/system/database/stats.go` so `NewService` accepts the exact database path. Derive its backup directory from the path.
|
||||
|
||||
Use the exact path for the Database page, WAL statistics, last-backup time, and pre-reset snapshots. Update `apps/backend/internal/system/database/reset.go` to use the derived backup directory.
|
||||
|
||||
## Frontend
|
||||
|
||||
Update the restore success dialog to state that the pool is closed and database-backed work is unavailable until restart. Use the existing restart action, with quit-and-relaunch guidance when automatic restart is unavailable.
|
||||
|
||||
## Public documentation
|
||||
|
||||
Remove the warning that System Database and Backups ignore `KANDEV_DATABASE_PATH`. Document the sibling backup directory and exact restore target.
|
||||
|
||||
Update the CLI reference, configuration reference, operations guide, feature-status table, and introductory storage text. State that old misrouted snapshots do not move automatically.
|
||||
|
||||
Update `apps/backend/AGENTS.md` so the backend backup convention names the configured SQLite path. Do not change the historical ADR.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** the composed backup handler lists snapshots beside a custom database path.
|
||||
**File:** `apps/backend/internal/system/system_database_path_test.go`.
|
||||
**How:** construct `system.Provide` with a custom filename, register the routes, request the backup list, and assert the sibling snapshot appears.
|
||||
- **What:** restore stages and replaces the exact custom database filename.
|
||||
**File:** `apps/backend/internal/system/backups/path_test.go`.
|
||||
**How:** restore known bytes through the service and assert the configured file changes, the quiescence hook runs, the pool closes, the replacement sidecars are gone, and the default-home file remains absent. Add regressions for PostgreSQL rejection, busy checkpoint preservation, and rollback after replacement failure.
|
||||
- **What:** restore quiescence stops every database writer before pool closure.
|
||||
**File:** `apps/backend/internal/backendapp/restore_quiesce_test.go`.
|
||||
**How:** assert scheduling, orchestrator, agents, and registered workers are all attempted and all stop errors are returned.
|
||||
- **What:** the restore success dialog directs the user to restart.
|
||||
**File:** `apps/web/components/settings/system/system-confirmation-dialogs.test.tsx`.
|
||||
**How:** render a succeeded restore job and assert the restart action is present while the normal close action is absent.
|
||||
- **What:** database statistics and pre-reset snapshots use the custom path and its sibling backup directory.
|
||||
**File:** `apps/backend/internal/system/database/path_test.go`.
|
||||
**How:** assert the reported path, WAL lookup, last-backup time, and pre-reset snapshot parent.
|
||||
- **What:** default-path behavior remains unchanged.
|
||||
**Files:** existing Backups and Database package tests.
|
||||
**How:** update constructor inputs to exact database paths and run all `internal/system/...` tests.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
No new browser test is planned. The dialog keeps the existing shared responsive composition; this remediation changes localized success copy and invokes the existing restart flow. The focused component test covers the changed rendered action.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- `cd apps/backend && go test -race ./internal/system/backups ./internal/system ./internal/backendapp -run 'TestRestore|TestConfiguredDatabasePath|TestProvideWiresRestoreQuiesce|TestQuiesceForRestore' -count=1`
|
||||
passed 11 restore-safety tests across 3 packages.
|
||||
- `cd apps/backend && go test ./internal/system/... ./internal/backendapp -count=1`
|
||||
passed 844 tests across 20 packages.
|
||||
- `cd apps/backend && make lint` passed with 0 issues.
|
||||
- `cd apps && pnpm --filter @kandev/web exec vitest run components/settings/system/system-confirmation-dialogs.test.tsx`
|
||||
passed 5 UI tests.
|
||||
- `cd apps && pnpm --filter @kandev/web run i18n:check`, `i18n:ratchet`, and `typecheck`
|
||||
passed.
|
||||
- `rg -n 'KANDEV_DATABASE_PATH|data/backups|database path|backup caveat' docs/public apps/backend/AGENTS.md`
|
||||
confirmed the corrected terminology and intentional default-path references.
|
||||
- `node --test scripts/validate-public-docs.test.mjs` passed 61 tests.
|
||||
- `node scripts/validate-public-docs.mjs` validated 41 published docs pages.
|
||||
- `git diff --check` passed.
|
||||
|
||||
### Restore safety remediation
|
||||
|
||||
- Red phase: PostgreSQL restore was accepted, a busy checkpoint was treated as success, and sidecars were deleted before a replacement failure could roll back.
|
||||
- Green phase: focused backend restore, system wiring, and quiescence tests pass after rejecting PostgreSQL, validating checkpoint rows, and using rollback-capable quarantine replacement.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1 (sequential):
|
||||
|
||||
- [x] [task-01-backend-database-path](task-01-backend-database-path.md)
|
||||
|
||||
Wave 2 (sequential):
|
||||
|
||||
- [x] [task-02-document-database-path](task-02-document-database-path.md)
|
||||
|
||||
The documentation task depends on the final backend behavior. No task is parallel-safe.
|
||||
|
||||
## Risks
|
||||
|
||||
- Existing manual snapshots can remain under the old default directory. Automatic movement is unsafe because Kandev cannot prove which database produced each file.
|
||||
- A custom database filename must remain exact. Joining `kandev.db` to its parent preserves part of the bug.
|
||||
- The System Status disk-usage service remains home-based. It does not count sibling backups outside the resolved home.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Moving or merging old snapshot directories.
|
||||
- Changing snapshot filename families or retention rules.
|
||||
- Changing PostgreSQL backup behavior.
|
||||
- Changing the System Status disk-usage path model.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
id: "01-backend-database-path"
|
||||
title: "Route System backups to the SQLite path"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/system-page/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Route System backups to the SQLite path
|
||||
|
||||
## Intent
|
||||
|
||||
Make the System Database and Backups services use the live SQLite file path. Keep all snapshot families in one sibling `backups/` directory.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A custom SQLite filename is the reported database path and the restore destination.
|
||||
- Manual, pre-reset, and pre-migration snapshots use the `backups/` directory beside that file.
|
||||
- Restore quiesces scheduling, active executions, and database-backed workers, validates the checkpoint result, closes the SQLite pool, and uses rollback-capable quarantine replacement. PostgreSQL restore is rejected before staging or shutdown. A restart is required before database-backed work resumes.
|
||||
- The default `<home>/data/kandev.db` behavior and existing backup safety rules remain unchanged.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/system/system.go`
|
||||
- `apps/backend/internal/system/system_database_path_test.go`
|
||||
- `apps/backend/internal/system/backups/store.go`
|
||||
- `apps/backend/internal/system/backups/store_test.go`
|
||||
- `apps/backend/internal/system/backups/path_test.go`
|
||||
- `apps/backend/internal/system/database/stats.go`
|
||||
- `apps/backend/internal/system/database/stats_test.go`
|
||||
- `apps/backend/internal/system/database/reset.go`
|
||||
- `apps/backend/internal/system/database/path_test.go`
|
||||
- `apps/backend/internal/backendapp/restore_quiesce_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. The constructors and the System composer share one path contract.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: `docs/specs/system-page/spec.md`, Database, Backups, scenarios, failure modes, and persistence guarantees.
|
||||
- Plan: `plan.md`, confirmed root cause and backend sections.
|
||||
- Existing pre-migration rule: `apps/backend/internal/persistence/provider.go` derives `backups/` from the live database path.
|
||||
- Issue: `https://github.com/kdlbs/kandev/issues/2679`.
|
||||
|
||||
## Verification
|
||||
|
||||
Write the custom-path regression cases first. Run them before production changes and make sure that they fail for the path split:
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/system/... -run 'Test.*(Configured|Custom)DatabasePath' -count=1 -v
|
||||
```
|
||||
|
||||
After the fix, run the custom-path regressions and all affected System packages:
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/system/... -run 'Test.*(Configured|Custom)DatabasePath' -count=1 -v
|
||||
cd apps/backend && go test ./internal/system/... -count=1
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the exact changed files and test counts. Include the expected red failure, green results, and cleanup evidence. Update this task and `plan.md` in the same conversation.
|
||||
|
||||
## Results
|
||||
|
||||
- Red phase: the four custom-path regressions failed on the old path split. The
|
||||
composer listed the old default backup, restore could not reach the custom
|
||||
filename, stats missed the custom WAL and backup, and reset used the wrong
|
||||
parent directory.
|
||||
- Green phase: `cd apps/backend && go test -race ./internal/system/backups
|
||||
./internal/system ./internal/backendapp -run 'TestRestore|TestConfiguredDatabasePath|TestProvideWiresRestoreQuiesce|TestQuiesceForRestore' -count=1`
|
||||
passed 11 restore-safety tests across 3 packages, including restore quiescing.
|
||||
- Full affected suite: `cd apps/backend && go test ./internal/system/... ./internal/backendapp -count=1`
|
||||
passed 844 tests across 20 packages.
|
||||
- `cd apps/backend && make lint` passed with 0 issues.
|
||||
- Changed files: `system.go`, `system_database_path_test.go`,
|
||||
`backups/store.go`, `backups/store_test.go`, `backups/path_test.go`,
|
||||
`database/stats.go`, `database/stats_test.go`, `database/reset.go`,
|
||||
`database/maintenance_test.go`, and `database/path_test.go`.
|
||||
- `git diff --check` passed.
|
||||
|
||||
### Restore safety remediation
|
||||
|
||||
- Red phase: the PostgreSQL path closed the pool, busy checkpoint rows were ignored, and replacement failures deleted live sidecars.
|
||||
- Green phase: restore safety tests pass for PostgreSQL rejection, busy checkpoint preservation, rollback of the main file and sidecars, and the complete quiescence callback.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
id: "02-document-database-path"
|
||||
title: "Document the SQLite backup location"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-backend-database-path"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/system-page/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Document the SQLite backup location
|
||||
|
||||
## Intent
|
||||
|
||||
Replace the documented custom-path limitation with the corrected System behavior. Keep the legacy-snapshot and home-storage boundaries explicit.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Public references state that SQLite snapshots use `backups/` beside the configured database file.
|
||||
- Restore documentation names `<configured-database-path>.new` and the exact configured destination.
|
||||
- Restore documentation explains that scheduling, active executions, and database-backed workers stop, the SQLite pool closes, replacement is rollback-safe, PostgreSQL restore is rejected, and an immediate restart is required.
|
||||
- Public and agent guidance states that old misrouted snapshots do not move automatically.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `docs/public/cli.md`
|
||||
- `docs/public/configuration.md`
|
||||
- `docs/public/operations.md`
|
||||
- `docs/public/feature-status.md`
|
||||
- `docs/public/use-kandev.md`
|
||||
- `apps/backend/AGENTS.md`
|
||||
- `apps/web/components/settings/system/restore-dialog.tsx`
|
||||
- `apps/web/src/locales/*/system.json`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 must establish the final backend behavior.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. These pages document the result of Task 01.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: `docs/specs/system-page/spec.md`, configured database path scenarios and persistence guarantees.
|
||||
- Plan: `plan.md`, public documentation and risk sections.
|
||||
- Docs type: `configuration.md` and `cli.md` are references. `operations.md` is a how-to guide. `feature-status.md` is a reference. `use-kandev.md` is a tutorial.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the focused terminology search and public-doc validators:
|
||||
|
||||
```bash
|
||||
rg -n 'KANDEV_DATABASE_PATH|data/backups|database path|backup caveat' docs/public apps/backend/AGENTS.md
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Output contract
|
||||
|
||||
Report each changed public page and its primary documentation type. Report validator results and remaining intentional `data/backups` references. Update this task and `plan.md` in the same conversation.
|
||||
|
||||
## Restore safety remediation
|
||||
|
||||
- Updated the public and backend guidance for checkpoint validation, quarantine rollback, PostgreSQL rejection, and the closed-pool restart requirement.
|
||||
- Updated the localized restore success dialog to invoke the existing restart flow and to provide manual quit-and-relaunch guidance.
|
||||
|
||||
## Results
|
||||
|
||||
- Public docs updated:
|
||||
- `docs/public/cli.md` (reference)
|
||||
- `docs/public/configuration.md` (reference)
|
||||
- `docs/public/operations.md` (how-to and recovery guide)
|
||||
- `docs/public/feature-status.md` (reference)
|
||||
- `docs/public/use-kandev.md` (tutorial)
|
||||
- Backend guidance updated: `apps/backend/AGENTS.md` now names the configured
|
||||
SQLite path and its sibling backup directory.
|
||||
- `rg -n 'KANDEV_DATABASE_PATH|data/backups|database path|backup caveat'
|
||||
docs/public apps/backend/AGENTS.md` passed. Remaining `data/backups`
|
||||
references describe the default path, development safety snapshots, or the
|
||||
home-based System Status walk.
|
||||
- `node --test scripts/validate-public-docs.test.mjs` passed 61 tests.
|
||||
- `node scripts/validate-public-docs.mjs` validated 41 published docs pages.
|
||||
- `git diff --check` passed.
|
||||
+2
-2
@@ -244,7 +244,7 @@ Flags take precedence over the equivalent port variables. `KANDEV_BACKEND_PORT`
|
||||
| `KANDEV_BACKEND_PORT` | unset | Backend port when `--port` is absent. |
|
||||
| `KANDEV_PORT` | unset | Compatibility backend-port alias. |
|
||||
| `KANDEV_HOME_DIR` | `~/.kandev` | Root for application data, tasks, repositories, logs, and launcher state. |
|
||||
| `KANDEV_DATABASE_PATH` | `<home>/data/kandev.db` | Advanced SQLite path override. See the backup caveat in [Configuration](./configuration.md). |
|
||||
| `KANDEV_DATABASE_PATH` | `<home>/data/kandev.db` | Advanced SQLite path override. System backups use the sibling `backups/` directory. See [Configuration](./configuration.md). |
|
||||
| `KANDEV_LOG_LEVEL` | `warn` from the launcher | Explicit backend log level; overrides `--verbose` and `--debug` log-level selection. |
|
||||
| `KANDEV_HEALTH_TIMEOUT_MS` | `45000` | Positive integer startup-health timeout. Invalid or non-positive values fall back to 45 seconds. |
|
||||
| `KANDEV_NO_BROWSER` | unset | The exact value `1` suppresses browser opening. |
|
||||
@@ -266,7 +266,7 @@ lists locations and retention.
|
||||
|
||||
## Data and cleanup
|
||||
|
||||
The default persistent root is `~/.kandev` (on Windows, `.kandev` below the user's profile directory). The SQLite database normally resides at `<home>/data/kandev.db`; repositories, task worktrees, logs, and encrypted settings also live below the home root.
|
||||
The default persistent root is `~/.kandev` (on Windows, `.kandev` below the user's profile directory). The SQLite database normally resides at `<home>/data/kandev.db`; its snapshots reside at `<home>/data/backups/`. With `KANDEV_DATABASE_PATH`, Kandev uses `backups/` beside the configured database file. Repositories, task worktrees, logs, and encrypted settings also live below the home root.
|
||||
|
||||
Runtime program files live in the Homebrew Cellar, the global npm dependency tree, or npm's `_npx` cache. They are not application data. `npm config get cache` and `npm root -g` show the latter two roots.
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ The default host exposes the server on every interface even though the CLI print
|
||||
|
||||
SQLite is the supported default and enables WAL mode. PostgreSQL deployments must provision the database, network policy, TLS trust, backups, and credentials before starting Kandev. Passing the password in an environment variable avoids putting it in YAML but still exposes it to processes/administrators allowed to inspect the environment; use your platform's secret injection controls.
|
||||
|
||||
`database.path` is an advanced override. Persistence honors it, but the current **Settings → System → Database** and **Backups** services derive their displayed path, WAL files, backup source, and restore destination from `<home>/data/kandev.db`. Those UI operations are therefore not reliable for a custom SQLite location. Use operator-managed backup/restore for a custom path and verify the actual database path printed at startup.
|
||||
`database.path` is an advanced SQLite file-path override. The **Settings → System → Database** and **Backups** pages use that exact file, its WAL files, and the sibling `backups/` directory. Restore stages `<configured-database-path>.new`, quiesces scheduling and active workers, validates the checkpoint result, closes the SQLite pool, and uses rollback-capable quarantine replacement for the configured file and WAL sidecars. Restart Kandev immediately after a successful restore. When the override is empty, the default path remains `<home>/data/kandev.db` and the backup directory remains `<home>/data/backups/`. Kandev does not move snapshots from another directory automatically. The System restore endpoint is SQLite-only; use PostgreSQL recovery tools for PostgreSQL.
|
||||
|
||||
One backend owns a Kandev home at a time. When SQLite uses a custom path outside that home, the backend also owns that database path, so separate homes alone do not permit concurrent backends against one SQLite file. Use a separate home and database for an intentional second instance. Ownership is released when the backend exits.
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande
|
||||
| Native desktop app | Dependency-bound | macOS, Windows, and Linux artifacts and a Tauri shell are implemented. Signing trust and update behavior differ by platform and release artifact. |
|
||||
| Kubernetes deployment guide | Experimental | The guide describes one deployment approach; Kandev does not ship a Kubernetes executor or operator. |
|
||||
| Statistics and host resource metrics | Supported | Workspace stats and on-demand CPU/memory/disk display are shipped. Open **Stats** from the desktop sidebar or from **Menu → Utilities → Stats** on phones. Execution-environment metrics depend on executor/runtime support. |
|
||||
| SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots under the data root. Retention, free space, file permissions, and off-host copies remain operator responsibilities. |
|
||||
| SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots in `backups/` beside the configured database file. The default is `data/backups/`; Kandev does not move snapshots from another directory. Retention, free space, file permissions, and off-host copies remain operator responsibilities. |
|
||||
| Update checking and applying updates | Dependency-bound | Checking is shipped. Package-manager installs update through their package manager; backend self-update is service-install-specific; desktop updater support varies by artifact/platform. |
|
||||
| Feature Toggle settings | Supported | The page exposes Office and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. |
|
||||
| Office mode | In progress | Production defaults `KANDEV_FEATURES_OFFICE=false`; development and E2E enable it for implementation/testing. Its routes, agents, labels, documents, dependencies, routines, skills, routing, costs, and approvals may change between releases. |
|
||||
|
||||
@@ -107,7 +107,7 @@ To recover capacity in one session, expand its queue chip in the task workbench.
|
||||
| --- | --- |
|
||||
| `data/kandev.db`, `-wal`, `-shm` | Default SQLite database and transient WAL files |
|
||||
| `data/master.key` | Owner-only AES-256 key used to decrypt secrets stored in the database; a database copy without the matching key cannot recover those secret values |
|
||||
| `data/backups/` | SQLite manual, pre-upgrade, and pre-reset snapshots |
|
||||
| `data/backups/` | SQLite snapshots when the database uses the default path |
|
||||
| `tasks/` and legacy `worktrees/` | Managed Git worktrees and per-task files; may contain uncommitted or untracked work |
|
||||
| `repos/` | Kandev-managed source clones |
|
||||
| `sessions/`, `quick-chat/`, `agent-sessions/` | Session history, ephemeral workspaces, and isolated agent homes when used |
|
||||
@@ -117,7 +117,7 @@ To recover capacity in one session, expand its queue chip in the task workbench.
|
||||
|
||||
Database snapshots do not contain Git worktrees, clones, the master key, service metadata, or provider-side objects. Native agent and `gh` login files also normally live in the service user's home outside `~/.kandev` (for example `~/.codex` and `~/.config/gh`). The official container instead sets `HOME=/data/home`, so those CLI credentials live on its mounted volume.
|
||||
|
||||
The supported SQLite layout for the System database and restore pages is the derived `<home>/data/kandev.db`. `database.path` can point the persistence layer elsewhere, but the current System page still derives its displayed path, WAL path, and restore destination from `<home>/data/kandev.db`. Treat a custom SQLite path as operator-managed: back it up and restore it with SQLite-aware tooling while Kandev is stopped.
|
||||
The System Database and Backups pages use the configured SQLite file path. They use `backups/` under the parent directory of that file. The default remains `<home>/data/kandev.db` with snapshots in `<home>/data/backups/`. A custom path can place the database and snapshots outside the Kandev home. Kandev does not move snapshots from another directory automatically.
|
||||
|
||||
## Storage maintenance
|
||||
|
||||
@@ -231,7 +231,7 @@ the Kandev service first provides the clearest maintenance boundary.
|
||||
|
||||
SQLite is the default and is appropriate for a desktop, CLI, service, or single-replica container installation. Kandev uses one writer connection and a read pool in WAL mode. Only one Kandev backend should own the file.
|
||||
|
||||
Open **Settings > System > Database** to see database size, WAL size, schema version, path, and the newest modification time among regular entries in `data/backups`. That timestamp is a filesystem hint, not proof of a valid snapshot: an unrelated or temporary file in the directory can affect it. SQLite exposes three maintenance actions:
|
||||
Open **Settings > System > Database** to see database size, WAL size, schema version, path, and the newest modification time among regular entries in the sibling `backups/` directory. That timestamp is a filesystem hint, not proof of a valid snapshot: an unrelated or temporary file in the directory can affect it. SQLite exposes three maintenance actions:
|
||||
|
||||
- **Optimize** runs `PRAGMA optimize`. It is quick and updates planner statistics.
|
||||
- **Vacuum** runs `VACUUM`, compacts the file, and reports bytes reclaimed. It can need substantial temporary disk and can block writes, so run it during a quiet period.
|
||||
@@ -307,12 +307,12 @@ Adapt the source for a custom `--home-dir`. This archive still does not include
|
||||
|
||||
### Restore a System snapshot
|
||||
|
||||
The supported UI flow applies to the default SQLite path:
|
||||
The UI flow applies to the configured SQLite path:
|
||||
|
||||
1. Stop or finish active agent sessions and preserve unpushed work.
|
||||
2. Open **Settings > System > Backups**, choose **Restore**, type `RESTORE`, and confirm.
|
||||
3. Kandev copies the selected snapshot to `data/kandev.db.new` and atomically renames it over `data/kandev.db`.
|
||||
4. Quit and relaunch Kandev immediately. The running backend retains connections to the old database inode and can otherwise serve or write stale state.
|
||||
3. Kandev stops scheduling, active executions, and database-backed workers. It copies the selected snapshot to `<configured-database-path>.new`, validates the SQLite checkpoint result, and closes the pool. It then quarantines the configured database and its `-wal`/`-shm` sidecars before installing the staged file. If installation fails, Kandev restores the quarantined files.
|
||||
4. Click **Restart Kandev** in the success dialog. If automatic restart is unavailable, quit and relaunch Kandev manually. The backend must restart before database-backed work resumes.
|
||||
5. Check `/health`, **System > Status**, database schema version, secrets, and representative tasks.
|
||||
|
||||
Restore does not roll back worktrees or remote/provider state. A database may therefore refer to files, containers, pull requests, or credentials from a different point in time. Reconcile them before restarting automation.
|
||||
@@ -337,7 +337,7 @@ Restart one backend, allow schema initialization to complete, then validate it a
|
||||
In **Settings > System > Database**, click **Factory reset**, type `RESET`, and confirm. This is SQLite-only. The job:
|
||||
|
||||
1. stops the orchestrator;
|
||||
2. creates `data/backups/kandev-pre-reset-<unix>.db`;
|
||||
2. creates `<database-dir>/backups/kandev-pre-reset-<unix>.db` beside the configured SQLite file;
|
||||
3. drops every SQLite user table while retaining `kandev_meta`;
|
||||
4. removes managed `worktrees/`, `repos/`, `sessions/`, `tasks/`, and `quick-chat/` trees;
|
||||
5. requires a manual quit and relaunch.
|
||||
@@ -374,7 +374,7 @@ When reporting an incident, record timestamp/timezone, Kandev version and commit
|
||||
|
||||
## Disk use and environment cleanup
|
||||
|
||||
**Settings > System > Status** walks `data`, worktrees, repositories, sessions, tasks, quick chat, and backups. Results are cached for two hours; **Refresh** forces a new single-flight walk. Permission failures appear as warnings. The displayed total intentionally counts `data/backups` both inside the `data` row and again as the separate `backups` row, so use filesystem or volume metrics for quota enforcement.
|
||||
**Settings > System > Status** walks `data`, worktrees, repositories, sessions, tasks, quick chat, and the default `data/backups` directory. Results are cached for two hours; **Refresh** forces a new single-flight walk. Permission failures appear as warnings. Backup files outside the resolved home are not included in the total. The displayed total intentionally counts `data/backups` both inside the `data` row and again as the separate `backups` row, so use filesystem or volume metrics for quota enforcement.
|
||||
|
||||
Archiving or deleting a task stops active sessions and starts durable asynchronous cleanup. Depending on executor, cleanup can delete a managed worktree and its local branch, remove a container, destroy a Sprite, reap a host-local agent's process tree, or attempt to stop the remote SSH controller and remove only its per-session runtime directory. Failed cleanup remains retryable across a backend restart. Kandev does not sweep arbitrary files from the shared temporary directory during archive or delete. SSH process/session cleanup is best-effort when the connection is failing, and the task directory always remains for deliberate, audited cleanup; there is no automatic sweeper for it today. The task can disappear from the UI before cleanup finishes.
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ container installs have no Nightly channel.
|
||||
|
||||
The launcher selects the platform runtime, starts the Go backend and agent runtime, serves the web app, and opens its local URL. The preferred backend port is `38429`; if it is unavailable, the launcher chooses another free port and prints the actual URL. Use `kandev --headless` (or `KANDEV_NO_BROWSER=1`) when a browser must not open.
|
||||
|
||||
By default, persistent state is under `~/.kandev`, including the SQLite database, repository materializations, sessions, logs, and backups. `KANDEV_HOME_DIR` relocates that root; `KANDEV_DATABASE_PATH` overrides only the database. Kandev creates its data directory with owner-only permissions and rejects symlinked components in that path, but file permissions do not replace host access controls.
|
||||
By default, persistent state is under `~/.kandev`, including the SQLite database, repository materializations, sessions, logs, and backups. `KANDEV_HOME_DIR` relocates that root. `KANDEV_DATABASE_PATH` overrides the database file and moves System snapshots to `backups/` beside that file. Kandev creates its data directory with owner-only permissions and rejects symlinked components in that path, but file permissions do not replace host access controls. Kandev does not move snapshots from another directory automatically.
|
||||
|
||||
See the [CLI reference](cli.md) for commands, port and logging flags, data paths, environment variables, and update behavior. Other supported entry points are the [desktop app](desktop-app.md), [service](run-as-a-service.md), and [Docker deployment](docker.md). Read [Security and trust](security.md) before making any installation remotely reachable.
|
||||
|
||||
|
||||
@@ -26,12 +26,17 @@ A new **System** group is added to the existing settings sidebar (`apps/web/comp
|
||||
- Install-wide runtime flags, risk metadata, environment locks, and restart handling.
|
||||
- The full contract lives in [Feature Toggles](../feature-toggles/spec.md).
|
||||
3. **Database** — `/settings/system/database`
|
||||
- Read-only: database driver, database size, and schema version. SQLite additionally shows file path, WAL size, and last-backup timestamp (from `<data-dir>/backups/`). Postgres shows database-level size and does not render SQLite file/WAL/backup rows.
|
||||
- Read-only: database driver, database size, and schema version. SQLite additionally shows the configured file path, WAL size, and last-backup timestamp from the sibling `backups/` directory. Postgres shows database-level size and does not render SQLite file/WAL/backup rows.
|
||||
- SQLite-only **VACUUM** button — safe, reclaims space; shows progress and final size delta.
|
||||
- SQLite-only **Optimize** button — runs `PRAGMA optimize`; shows progress.
|
||||
- SQLite-only **Factory Reset** button — destructive. Wipes the full kandev install (database + worktrees + repo clones + session dirs + tasks dir + quick-chat dir), equivalent to deleting `~/.kandev/` and starting over. Opens a modal that requires (a) typing the literal string `RESET` to enable the confirm button, (b) acknowledging that the backend will create a pre-reset snapshot first and then restart. Server-side: stop orchestrator and running executions → snapshot DB to `<data-dir>/backups/pre-reset-<ts>.db` → drop DB tables and re-run migrations → `rm -rf` the worktrees/repos/sessions/tasks/quick-chat subdirs → restart backend process. The frontend disables the UI and waits for the backend to come back, then routes to the empty onboarding state.
|
||||
- SQLite-only **Factory Reset** button. This action is destructive.
|
||||
- It wipes the database, worktrees, repository clones, session directories, task directories, and Quick Chat directories.
|
||||
- The modal requires the literal string `RESET`. The user also acknowledges the pre-reset snapshot and required restart.
|
||||
- The server stops active work and creates `<database-dir>/backups/pre-reset-<ts>.db`.
|
||||
- The server drops the database tables, runs migrations, removes managed directories, and restarts.
|
||||
- The frontend disables the UI and waits for the backend. It then shows the empty onboarding state.
|
||||
4. **Backups** — `/settings/system/backups`
|
||||
- Lists existing snapshots in `<data-dir>/backups/` with name, size, and mtime.
|
||||
- Lists existing snapshots in `<database-dir>/backups/` with name, size, and mtime. `<database-dir>` is the parent of the configured SQLite database path.
|
||||
- Per-row actions: **Download**, **Restore** (gated like Factory Reset), **Delete** (confirm-only).
|
||||
- **Create snapshot** button at the top of the list; uses the existing `VACUUM INTO` path.
|
||||
5. **Logs** — `/settings/system/logs`
|
||||
@@ -196,6 +201,9 @@ The page reads the JSON statically; no backend endpoint is needed.
|
||||
- **GIVEN** the user clicks **VACUUM** on the Database page, **WHEN** the operation completes, **THEN** the DB size delta is shown ("Reclaimed 12.3 MB"), the page Database stats refresh, and a transient info issue appears on the Status page.
|
||||
- **GIVEN** the backend uses Postgres, **WHEN** the user opens the Database page, **THEN** the page shows the PostgreSQL driver and database size without issuing SQLite-only PRAGMA queries, and SQLite-only maintenance controls are not rendered.
|
||||
- **GIVEN** the user clicks **Factory Reset**, types `RESET`, and confirms, **WHEN** the backend executes, **THEN** a fresh snapshot is created first, all tables are dropped and migrations re-run, the backend restarts, and the frontend redirects to the empty onboarding state once it reconnects.
|
||||
- **GIVEN** `KANDEV_DATABASE_PATH` points to `<custom>/named.db`, **WHEN** the user opens Database and Backups, **THEN** Database reports that exact path and Backups lists snapshots from `<custom>/backups/`.
|
||||
- **GIVEN** `KANDEV_DATABASE_PATH` points to `<custom>/named.db`, **WHEN** a restore succeeds, **THEN** Kandev stages `<custom>/named.db.new`, stops active executions, closes the SQLite pool, removes stale WAL sidecars, and replaces `<custom>/named.db`.
|
||||
- **GIVEN** a snapshot exists only in the old default directory, **WHEN** a custom database path is active, **THEN** Kandev does not list or move it.
|
||||
- **GIVEN** the backend cannot reach GitHub, **WHEN** the poller fires, **THEN** the failure is logged but the previous `latest_version` and `latest_version_checked_at` remain in `kandev_meta`; the Updates page surfaces the stale value with a "Last checked <time>" subtitle.
|
||||
- **GIVEN** the user clicks **Check now** twice within 30 seconds, **WHEN** the second click fires, **THEN** the endpoint returns `429 Too Many Requests` and the UI shows "Already checked, try again in <N>s".
|
||||
- **GIVEN** retained backend logs and a connected browser with local console history, **WHEN** the user downloads diagnostics from `/settings/system/logs`, **THEN** one ZIP contains separate backend/frontend directories and a manifest describing both captures.
|
||||
@@ -242,8 +250,8 @@ The Message Queue settings GET is readable by members, while its PATCH is admin-
|
||||
- **Disk walk failure** (permission error on a subdir) — return the partial result with a `warnings: [...]` array per subdir; the page renders a per-row warning icon.
|
||||
- **VACUUM failure** — DB is unaffected (VACUUM is atomic); the job ends `failed` with the SQLite error string. Status page shows a recoverable error issue.
|
||||
- **Non-SQLite maintenance call** — `vacuum`, `optimize`, and `reset` jobs fail with `not supported for <driver> driver` before running SQLite-only SQL; factory reset also rejects before stopping active executions.
|
||||
- **Factory reset failure mid-run** — the pre-reset snapshot remains in `<data-dir>/backups/`; the user can restore it from the Backups page on next boot. Recovery is documented inline in the failure UI.
|
||||
- **Restore failure** — original DB file is left untouched; restore writes to a temp file and atomic-renames only on success.
|
||||
- **Factory reset failure mid-run** — the pre-reset snapshot remains in the sibling SQLite backup directory. The user can restore it from the Backups page on the next boot. Recovery is documented inline in the failure UI.
|
||||
- **Restore failure** — the original DB file and SQLite sidecars remain available when checkpointing is busy, and are restored from quarantine when replacement fails; restore stages the snapshot, validates the checkpoint result, quarantines the originals, and rolls them back if installation fails. PostgreSQL restore is rejected before staging or shutdown.
|
||||
- **Log file missing / unreadable** — bundle creation continues with available
|
||||
sources, marks the result partial, and records the missing/unreadable backend
|
||||
source in `manifest.json`.
|
||||
@@ -251,12 +259,14 @@ The Message Queue settings GET is readable by members, while its PATCH is admin-
|
||||
## Persistence guarantees
|
||||
|
||||
- `kandev_meta` writes are SQLite-atomic.
|
||||
- Snapshot files in `<data-dir>/backups/` are created via `VACUUM INTO <tmp>` then atomic-renamed; partial files cannot appear.
|
||||
- Restore writes the snapshot to `<data-dir>/kandev.db.new` and atomic-renames over `kandev.db` after closing all pool connections; on failure the original file is intact.
|
||||
- The SQLite backup directory is `backups/` under the parent of the configured database path. The default remains `<home>/data/backups/`.
|
||||
- Snapshot files in the SQLite backup directory are created via `VACUUM INTO <tmp>` then atomic-renamed. Partial files cannot appear.
|
||||
- Restore writes the snapshot to `<configured-database-path>.new`, stops scheduling, active executions, and database-backed workers, checkpoints and closes the SQLite pool, then quarantines the main file and `-wal`/`-shm` sidecars before installing the staged file. A failed replacement restores the quarantine. The frontend requires an immediate backend restart before database-backed work resumes.
|
||||
- Kandev does not migrate snapshots from another backup directory. It cannot prove that those files belong to the configured database.
|
||||
- The existing boot-time backup retention (newest 2) is preserved, but **only auto-snapshots are subject to it**. Snapshots are distinguished by filename prefix:
|
||||
- `auto-<version>-<ts>.db` — created automatically on a version-change boot or as the pre-reset snapshot before factory reset. Pruned by the existing "keep newest 2" rule (operating only on files with the `auto-` prefix).
|
||||
- `kandev-<version>-<ts>.db` — created automatically on a version-change boot or as the pre-reset snapshot before factory reset. Pruned by the existing "keep newest 2" rule (operating only on files with the `kandev-` prefix).
|
||||
- `manual-<ts>.db` — created by the user clicking **Create snapshot** in the Backups page. Never auto-pruned; lives until the user deletes it explicitly from the same page.
|
||||
- The existing pre-migration backup path is updated to use the `auto-` prefix (a one-shot rename of existing snapshots happens on first boot after this change, treating any extension-less / unprefixed `*.db` file under `<data-dir>/backups/` as `auto-<existing-name>` for retention purposes).
|
||||
- Existing snapshots are not renamed or moved. Kandev cannot verify that an unprefixed or legacy snapshot belongs to the active configured database.
|
||||
|
||||
## Out of scope (v1)
|
||||
|
||||
@@ -273,6 +283,7 @@ The Message Queue settings GET is readable by members, while its PATCH is admin-
|
||||
- Live log tail via WS subscription.
|
||||
- Selective reset (drop only tasks, only sessions, etc.) — only if users actually need finer-grained recovery than Restore-from-snapshot.
|
||||
- Backup scheduling (e.g., daily snapshot) beyond the existing pre-migration trigger.
|
||||
- Backup files outside the resolved home are not included in System Status disk-usage totals.
|
||||
- Health-issue → action wiring (e.g., a "VACUUM" CTA inline on the "Database is XYZ MB" health issue).
|
||||
|
||||
## Open questions
|
||||
|
||||
Reference in New Issue
Block a user