Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
b7fd186
feat(config): add BackupConfig, R2Config, GDriveConfig structs and ba…
priyanshujain Mar 21, 2026
db35bf5
feat(backup): add Backend interface and local filesystem implementation
priyanshujain Mar 21, 2026
eed622d
feat(backup): add manifest types, load/save, and diff logic
priyanshujain Mar 21, 2026
83b25f4
feat(backup): add file scanner with include/exclude rules
priyanshujain Mar 21, 2026
3a50054
feat(backup): add VACUUM INTO helper for safe SQLite snapshots
priyanshujain Mar 21, 2026
be3e398
feat(backup): add core backup Run() flow
priyanshujain Mar 21, 2026
3e69b30
feat(backup): add R2/S3 storage backend using minio-go
priyanshujain Mar 21, 2026
3908d87
feat(backup): add Google Drive storage backend
priyanshujain Mar 21, 2026
be878fa
feat(backup): add restore, list snapshots, and get manifest
priyanshujain Mar 21, 2026
5e27f6d
feat(backup): add setupBackup to obk setup wizard
priyanshujain Mar 21, 2026
451f612
feat(backup): add backup category to settings TUI
priyanshujain Mar 21, 2026
6ffbb0d
feat(backup): add CLI commands (now, list, status, restore)
priyanshujain Mar 21, 2026
a69437b
feat(backup): add daemon periodic backup job
priyanshujain Mar 21, 2026
8fb4566
test(backup): add unit and integration tests for backup system
priyanshujain Mar 21, 2026
5b9faba
test(settings): update tree structure test for backup category
priyanshujain Mar 21, 2026
fd478f0
fix(backup): fix VacuumInto path collision and make Run() testable
priyanshujain Mar 21, 2026
4eb27be
test(backup): comprehensive tests for Run, Restore, VacuumInto
priyanshujain Mar 21, 2026
1578fe8
fix(settings): validate backup config before enabling
priyanshujain Mar 21, 2026
cb12009
fix(settings): enforce backup settings flow — lock enabled until dest…
priyanshujain Mar 21, 2026
18c5714
feat(settings): add backup wizard flow — destination → credentials → …
priyanshujain Mar 21, 2026
89cbafb
fix(settings): GDrive backup wizard runs OAuth + creates folder autom…
priyanshujain Mar 21, 2026
6e3f2d4
fix(settings): GDrive backup skips folder name prompt, goes straight …
priyanshujain Mar 21, 2026
9196e75
fix(settings): only launch backup wizard when not configured, normal …
priyanshujain Mar 21, 2026
faa5b64
fix(settings): remove Google Drive Folder ID from backup settings dis…
priyanshujain Mar 21, 2026
9b1d34f
feat(backup): add R2 field descriptions and remove GDrive folder prompt
priyanshujain Mar 21, 2026
587c037
feat(settings): hide R2 category when destination is not R2
priyanshujain Mar 21, 2026
881867a
test(settings): update R2 tests for conditional category visibility
priyanshujain Mar 21, 2026
3c91fe5
feat(settings): add triggerBackup callback and IsBackupDestConfigured
priyanshujain Mar 21, 2026
28b189c
feat(backup): transactional wizard with rollback and auto-trigger
priyanshujain Mar 21, 2026
df9290f
feat(backup): wire up triggerBackup callback in settings TUI
priyanshujain Mar 21, 2026
036507b
feat(backup): remove schedule prompt from obk setup
priyanshujain Mar 21, 2026
8922ecc
test(settings): add IsBackupDestConfigured tests
priyanshujain Mar 21, 2026
3772a75
chore(settings): remove unused ensureBackupGDrive helper
priyanshujain Mar 21, 2026
8e3362c
test(tui): add backup wizard unit tests
priyanshujain Mar 21, 2026
62e369c
fix(backup): escape single quotes in VACUUM INTO dest path
priyanshujain Mar 22, 2026
0d56188
fix(backup): add confirmation prompt before restore overwrites files
priyanshujain Mar 22, 2026
64aa153
fix(backup): escape single quotes in GDrive API query strings
priyanshujain Mar 22, 2026
64475f7
refactor(backup): extract shared ResolveBackend to eliminate triplica…
priyanshujain Mar 22, 2026
47d0f46
fix(backup): add random suffix to manifest ID to prevent collisions
priyanshujain Mar 22, 2026
cec44f8
fix(backup): stream file through zstd instead of reading entirely int…
priyanshujain Mar 22, 2026
6f7cb42
refactor(settings): export BackupDest and remove duplicate in tui pac…
priyanshujain Mar 22, 2026
7c65170
fix(backup): skip symlinks in scanner to prevent infinite loops
priyanshujain Mar 22, 2026
0d92ac5
fix(backup): use sentinel error instead of string matching in GDrive …
priyanshujain Mar 22, 2026
5da2d29
chore(settings): remove unused email variable from GDrive setup
priyanshujain Mar 22, 2026
0bac927
test(backup): add formatBytes unit tests
priyanshujain Mar 22, 2026
3576950
test(backup): add test for restore with missing objects
priyanshujain Mar 22, 2026
361f82f
fix(backup): format snapshot dates in obk backup list output
priyanshujain Mar 22, 2026
60af3e8
fix(backup): show time-ago and local time in status, drop hostname
priyanshujain Mar 22, 2026
5c33b5a
docs(architecture): update diagram with current system architecture
priyanshujain Mar 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Config struct {
Slack *SlackConfig `yaml:"slack,omitempty"`
Scheduler *SchedulerConfig `yaml:"scheduler,omitempty"`
Tasks *TasksConfig `yaml:"tasks,omitempty"`
Backup *BackupConfig `yaml:"backup,omitempty"`
}

func (c *Config) ResolvedMode() Mode {
Expand Down Expand Up @@ -207,6 +208,25 @@ type TasksConfig struct {
Storage StorageConfig `yaml:"storage,omitempty"`
}

type BackupConfig struct {
Enabled bool `yaml:"enabled,omitempty"`
Schedule string `yaml:"schedule,omitempty"` // "6h", "12h", "24h", or "" (manual)
Destination string `yaml:"destination,omitempty"` // "r2" or "gdrive"
R2 *R2Config `yaml:"r2,omitempty"`
GDrive *GDriveConfig `yaml:"gdrive,omitempty"`
}

type R2Config struct {
Bucket string `yaml:"bucket,omitempty"`
Endpoint string `yaml:"endpoint,omitempty"`
AccessKeyRef string `yaml:"access_key_ref,omitempty"` // e.g. "keychain:obk/r2-access-key"
SecretKeyRef string `yaml:"secret_key_ref,omitempty"` // e.g. "keychain:obk/r2-secret-key"
}

type GDriveConfig struct {
FolderID string `yaml:"folder_id,omitempty"`
}

type StorageConfig struct {
Driver string `yaml:"driver,omitempty"` // "sqlite" or "postgres"
DSN string `yaml:"dsn,omitempty"`
Expand Down
12 changes: 12 additions & 0 deletions config/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,15 @@ func CleanScratch(sessionID string) error {
func LearningsDir() string {
return filepath.Join(Dir(), "learnings")
}

func BackupDir() string {
return filepath.Join(Dir(), "backup")
}

func BackupStagingDir() string {
return filepath.Join(BackupDir(), "staging")
}

func BackupLastManifestPath() string {
return filepath.Join(BackupDir(), "last_manifest.json")
}
87 changes: 87 additions & 0 deletions daemon/jobs/backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package jobs

import (
"context"
"fmt"
"log/slog"
"net/http"

"github.com/riverqueue/river"

"github.com/73ai/openbotkit/config"
"github.com/73ai/openbotkit/oauth/google"
"github.com/73ai/openbotkit/provider"
backupsvc "github.com/73ai/openbotkit/service/backup"
)

type BackupArgs struct{}

func (BackupArgs) Kind() string { return "backup" }

type BackupWorker struct {
river.WorkerDefaults[BackupArgs]
Cfg *config.Config
}

func (w *BackupWorker) Work(ctx context.Context, job *river.Job[BackupArgs]) error {
if w.Cfg.Backup == nil || !w.Cfg.Backup.Enabled {
slog.Info("backup: not enabled, skipping")
return nil
}

if !config.IsSourceLinked("backup") {
slog.Info("backup: not linked, skipping")
return nil
}

slog.Info("starting backup job")

backend, err := backupsvc.ResolveBackend(ctx, backendOpts(w.Cfg))
if err != nil {
return fmt.Errorf("resolve backend: %w", err)
}

svc := backupsvc.New(backend, config.Dir())
result, err := svc.Run(ctx)
if err != nil {
return fmt.Errorf("backup: %w", err)
}

slog.Info("backup complete",
"changed", result.Changed,
"skipped", result.Skipped,
"uploaded", result.Uploaded,
"duration", result.Duration,
)
return nil
}

func backendOpts(cfg *config.Config) backupsvc.ResolveBackendOpts {
opts := backupsvc.ResolveBackendOpts{
ResolveCred: provider.ResolveAPIKey,
BackupDest: cfg.Backup.Destination,
GoogleClient: func(ctx context.Context, gcfg backupsvc.GoogleClientConfig) (*http.Client, error) {
gp := google.New(google.Config{
CredentialsFile: cfg.GoogleCredentialsFile(),
TokenDBPath: cfg.GoogleTokenDBPath(),
})
accounts, err := gp.Accounts(ctx)
if err != nil || len(accounts) == 0 {
return nil, fmt.Errorf("no Google account found")
}
return gp.Client(ctx, accounts[0], gcfg.Scopes)
},
}
if cfg.Backup.R2 != nil {
opts.R2Bucket = cfg.Backup.R2.Bucket
opts.R2Endpoint = cfg.Backup.R2.Endpoint
opts.R2AccessRef = cfg.Backup.R2.AccessKeyRef
opts.R2SecretRef = cfg.Backup.R2.SecretKeyRef
}
if cfg.Backup.GDrive != nil {
opts.GDriveFolderID = cfg.Backup.GDrive.FolderID
}
return opts
}

var _ river.Worker[BackupArgs] = (*BackupWorker)(nil)
36 changes: 26 additions & 10 deletions daemon/river.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,26 +42,42 @@ func newRiverClient(ctx context.Context, cfg *config.Config, notifier *SyncNotif
})
river.AddWorker(workers, &jobs.ReminderWorker{})
river.AddWorker(workers, &jobs.ScheduledTaskWorker{Cfg: cfg})
river.AddWorker(workers, &jobs.BackupWorker{Cfg: cfg})

period, err := time.ParseDuration(cfg.Daemon.GmailSyncPeriod)
if err != nil {
period = 15 * time.Minute
}

periodicJobs := []*river.PeriodicJob{
river.NewPeriodicJob(
river.PeriodicInterval(period),
func() (river.JobArgs, *river.InsertOpts) {
return jobs.GmailSyncArgs{}, nil
},
&river.PeriodicJobOpts{RunOnStart: true},
),
}

if cfg.Backup != nil && cfg.Backup.Enabled && cfg.Backup.Schedule != "" {
backupPeriod, err := time.ParseDuration(cfg.Backup.Schedule)
if err == nil {
periodicJobs = append(periodicJobs, river.NewPeriodicJob(
river.PeriodicInterval(backupPeriod),
func() (river.JobArgs, *river.InsertOpts) {
return jobs.BackupArgs{}, nil
},
&river.PeriodicJobOpts{RunOnStart: false},
))
}
}

riverCfg := &river.Config{
Queues: map[string]river.QueueConfig{
river.QueueDefault: {MaxWorkers: 5},
},
Workers: workers,
PeriodicJobs: []*river.PeriodicJob{
river.NewPeriodicJob(
river.PeriodicInterval(period),
func() (river.JobArgs, *river.InsertOpts) {
return jobs.GmailSyncArgs{}, nil
},
&river.PeriodicJobOpts{RunOnStart: true},
),
},
Workers: workers,
PeriodicJobs: periodicJobs,
}

client, err := river.NewClient(driver, riverCfg)
Expand Down
Loading
Loading