Files
gems/opal-task/cmd/sync.go
T
joakim a11f452d3b fix: break sync feedback loop, respect timestamps, surface errors
- Add migration v2: source column on change_log to distinguish local
  vs sync-originated entries, preventing the echo loop where synced
  tasks get re-pushed as local changes
- PushChanges handler now skips save when server version is newer
- Client PushChanges/pushQueuedChanges collect and report marshal errors
  instead of silently dropping them
- De-duplicate getLocalChanges/getLastSyncTime into exported sync
  package functions
- Fix logConflict winner detection via pointer identity instead of
  fragile UUID+timestamp comparison
- Fix sync down to actually parse, save, and tag-sync pulled changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:11:04 +01:00

495 lines
13 KiB
Go

package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"time"
"git.jnss.me/joakim/opal/internal/engine"
"git.jnss.me/joakim/opal/internal/sync"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
var (
quietFlag bool
)
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Sync tasks with remote server",
Long: `Manage synchronization with the opal API server`,
}
// opal sync init
var syncInitCmd = &cobra.Command{
Use: "init",
Short: "Configure sync with server",
Long: `Initialize sync configuration with the opal server.
This will prompt for server URL and API key, then save the configuration.
Examples:
opal sync init
opal sync init --url https://opal.example.com --key oak_xxx`,
Run: func(cmd *cobra.Command, args []string) {
url, _ := cmd.Flags().GetString("url")
key, _ := cmd.Flags().GetString("key")
// Load existing config
cfg, err := engine.GetConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
// Prompt for URL if not provided
if url == "" {
fmt.Print("Enter server URL: ")
reader := bufio.NewReader(os.Stdin)
url, _ = reader.ReadString('\n')
url = strings.TrimSpace(url)
}
// Prompt for API key if not provided
if key == "" {
fmt.Print("Enter API key: ")
reader := bufio.NewReader(os.Stdin)
key, _ = reader.ReadString('\n')
key = strings.TrimSpace(key)
}
// Generate client ID if not exists
clientID := cfg.SyncClientID
if clientID == "" {
clientID = uuid.New().String()
}
// Update config
cfg.SyncEnabled = true
cfg.SyncURL = url
cfg.SyncAPIKey = key
cfg.SyncClientID = clientID
cfg.SyncStrategy = "last-write-wins"
cfg.SyncQueueOffline = true
// Save config
if err := engine.SaveConfig(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error saving config: %v\n", err)
os.Exit(1)
}
// Test connection
client := sync.NewClient(url, key, clientID)
fmt.Println("\n✓ Sync configuration saved")
fmt.Printf(" URL: %s\n", url)
fmt.Printf(" Client ID: %s\n", clientID)
fmt.Printf(" Strategy: %s\n", cfg.SyncStrategy)
// Offer to sync now
fmt.Print("\nRun initial sync now? [y/N]: ")
reader := bufio.NewReader(os.Stdin)
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(strings.ToLower(response))
if response == "y" || response == "yes" {
strategy := sync.ParseStrategy(cfg.SyncStrategy)
// Create progress reporter
var reporter sync.ProgressReporter
if sync.ShouldShowProgress(quietFlag) {
reporter = sync.NewInteractiveProgress(os.Stdout)
} else {
reporter = &sync.NoOpProgress{}
}
result, err := client.Sync(strategy, reporter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error syncing: %v\n", err)
os.Exit(1)
}
result.Display()
}
},
}
// opal sync status
var syncStatusCmd = &cobra.Command{
Use: "status",
Short: "Show sync status",
Long: `Display current sync configuration and status`,
Run: func(cmd *cobra.Command, args []string) {
cfg, err := engine.GetConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
if !cfg.SyncEnabled {
fmt.Println("Sync is not configured.")
fmt.Println("Run 'opal sync init' to configure sync.")
return
}
fmt.Println("Sync Configuration")
fmt.Println("━━━━━━━━━━━━━━━━━━")
fmt.Printf("Enabled: %v\n", cfg.SyncEnabled)
fmt.Printf("URL: %s\n", cfg.SyncURL)
fmt.Printf("Client ID: %s\n", cfg.SyncClientID)
fmt.Printf("Strategy: %s\n", cfg.SyncStrategy)
fmt.Printf("Queue offline: %v\n", cfg.SyncQueueOffline)
// Check queue status
queue, err := sync.NewQueue()
if err == nil && queue.Size() > 0 {
fmt.Printf("\n⚠️ %d changes queued for sync\n", queue.Size())
}
// Get last sync time
db := engine.GetDB()
if db != nil {
var lastSync int64
err := db.QueryRow("SELECT last_sync FROM sync_state WHERE client_id = ?", cfg.SyncClientID).Scan(&lastSync)
if err == nil && lastSync > 0 {
fmt.Printf("\nLast sync: %s\n", formatTimestamp(lastSync))
}
}
// Test connectivity
fmt.Print("\nTesting connection... ")
_ = sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
// Simple connectivity test - we can't call testConnection directly, so we'll just show config
fmt.Println("(run 'opal sync now' to test)")
},
}
// opal sync now (default)
var syncNowCmd = &cobra.Command{
Use: "now",
Short: "Sync with server now",
Long: `Perform bidirectional sync with the server`,
Run: func(cmd *cobra.Command, args []string) {
cfg, err := engine.GetConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
if !cfg.SyncEnabled {
fmt.Println("Sync is not configured.")
fmt.Println("Run 'opal sync init' to configure sync.")
os.Exit(1)
}
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
strategy := sync.ParseStrategy(cfg.SyncStrategy)
// Create progress reporter
var reporter sync.ProgressReporter
if sync.ShouldShowProgress(quietFlag) {
reporter = sync.NewInteractiveProgress(os.Stdout)
} else {
reporter = &sync.NoOpProgress{}
}
result, err := client.Sync(strategy, reporter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
result.Display()
},
}
// opal sync up
var syncUpCmd = &cobra.Command{
Use: "up",
Short: "Push local changes to server",
Long: `Upload local changes to the server`,
Run: func(cmd *cobra.Command, args []string) {
cfg, err := engine.GetConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
if !cfg.SyncEnabled {
fmt.Println("Sync is not configured.")
os.Exit(1)
}
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
// Get local changes
lastSync := sync.GetLastSyncTime(cfg.SyncClientID)
localChanges, err := sync.GetLocalChanges(lastSync)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting local changes: %v\n", err)
os.Exit(1)
}
if len(localChanges) == 0 {
fmt.Println("No local changes to push")
return
}
fmt.Printf("Pushing %d changes...\n", len(localChanges))
if err := client.PushChanges(localChanges); err != nil {
fmt.Fprintf(os.Stderr, "Error pushing changes: %v\n", err)
os.Exit(1)
}
fmt.Println("✓ Changes pushed successfully")
},
}
// opal sync down
var syncDownCmd = &cobra.Command{
Use: "down",
Short: "Pull server changes to local",
Long: `Download changes from the server`,
Run: func(cmd *cobra.Command, args []string) {
cfg, err := engine.GetConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
if !cfg.SyncEnabled {
fmt.Println("Sync is not configured.")
os.Exit(1)
}
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
lastSync := sync.GetLastSyncTime(cfg.SyncClientID)
changes, err := client.PullChanges(lastSync)
if err != nil {
fmt.Fprintf(os.Stderr, "Error pulling changes: %v\n", err)
os.Exit(1)
}
if len(changes) == 0 {
fmt.Println("No changes from server")
return
}
// Parse changes into tasks
tasks, err := client.ParseChanges(changes)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing changes: %v\n", err)
os.Exit(1)
}
// Apply each task locally
var applied int
for _, task := range tasks {
if err := task.Save(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to save task %s: %v\n", task.UUID, err)
continue
}
// Mark as sync-originated to prevent feedback loop
_ = engine.MarkChangeLogAsSync(task.UUID.String())
// Sync tags
savedTask, err := engine.GetTask(task.UUID)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to reload task %s: %v\n", task.UUID, err)
continue
}
currentTags, _ := savedTask.GetTags()
currentSet := make(map[string]bool)
for _, tag := range currentTags {
currentSet[tag] = true
}
desiredSet := make(map[string]bool)
for _, tag := range task.Tags {
desiredSet[tag] = true
}
for tag := range currentSet {
if !desiredSet[tag] {
savedTask.RemoveTag(tag)
}
}
for tag := range desiredSet {
if !currentSet[tag] {
savedTask.AddTag(tag)
}
}
applied++
}
fmt.Printf("✓ Pulled %d changes, applied %d tasks from server\n", len(changes), applied)
},
}
// opal sync log
var syncLogCmd = &cobra.Command{
Use: "log",
Short: "Show conflict resolution log",
Long: `Display the log of sync conflicts and how they were resolved`,
Run: func(cmd *cobra.Command, args []string) {
logPath, err := engine.GetSyncConflictLogPath()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
fmt.Println("No conflicts logged yet.")
return
}
fmt.Fprintf(os.Stderr, "Error reading log: %v\n", err)
os.Exit(1)
}
fmt.Print(string(data))
},
}
// opal sync merge
var syncMergeCmd = &cobra.Command{
Use: "merge",
Short: "Initial database merge",
Long: `Merge local database with server database for first-time sync.
This is used when connecting an existing local database to a server for the first time.
Strategies:
--prefer-local: Upload all local tasks, merge server tasks
--prefer-server: Download all server tasks, merge local tasks
--smart: Merge by UUID, add unique tasks from both sides (default)
Examples:
opal sync merge
opal sync merge --prefer-local
opal sync merge --prefer-server`,
Run: func(cmd *cobra.Command, args []string) {
cfg, err := engine.GetConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
if !cfg.SyncEnabled {
fmt.Println("Sync is not configured.")
fmt.Println("Run 'opal sync init' to configure sync.")
os.Exit(1)
}
preferLocal, _ := cmd.Flags().GetBool("prefer-local")
preferServer, _ := cmd.Flags().GetBool("prefer-server")
strategy := sync.LastWriteWins // Default: smart merge
if preferLocal {
strategy = sync.ClientWins
} else if preferServer {
strategy = sync.ServerWins
}
fmt.Println("Performing initial merge...")
fmt.Printf("Strategy: %s\n\n", sync.StrategyString(strategy))
// Get all local tasks (not just recent changes)
filter := engine.DefaultFilter()
localTasks, err := engine.GetTasks(filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting local tasks: %v\n", err)
os.Exit(1)
}
fmt.Printf("Found %d local tasks\n", len(localTasks))
// Pull all changes from server
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
changes, err := client.PullChanges(0) // Get all changes (since epoch)
if err != nil {
fmt.Fprintf(os.Stderr, "Error pulling from server: %v\n", err)
os.Exit(1)
}
fmt.Printf("Found %d server changes\n", len(changes))
// Parse server tasks
// For initial merge, we'll do a full sync
// Create progress reporter
var reporter sync.ProgressReporter
if sync.ShouldShowProgress(quietFlag) {
reporter = sync.NewInteractiveProgress(os.Stdout)
} else {
reporter = &sync.NoOpProgress{}
}
result, err := client.Sync(strategy, reporter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error during merge: %v\n", err)
os.Exit(1)
}
fmt.Println("\n✓ Initial merge completed")
result.Display()
},
}
func init() {
syncCmd.GroupID = "other"
rootCmd.AddCommand(syncCmd)
syncCmd.AddCommand(syncInitCmd)
syncCmd.AddCommand(syncStatusCmd)
syncCmd.AddCommand(syncNowCmd)
syncCmd.AddCommand(syncUpCmd)
syncCmd.AddCommand(syncDownCmd)
syncCmd.AddCommand(syncLogCmd)
syncCmd.AddCommand(syncMergeCmd)
// Flags
syncInitCmd.Flags().StringP("url", "u", "", "Server URL")
syncInitCmd.Flags().StringP("key", "k", "", "API key")
syncMergeCmd.Flags().Bool("prefer-local", false, "Prefer local database")
syncMergeCmd.Flags().Bool("prefer-server", false, "Prefer server database")
// Global sync flags
syncCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress progress output")
}
func formatTimestamp(ts int64) string {
t := time.Unix(ts, 0)
now := time.Now()
diff := now.Sub(t)
switch {
case diff < time.Minute:
return "just now"
case diff < time.Hour:
m := int(diff.Minutes())
if m == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", m)
case diff < 24*time.Hour:
h := int(diff.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
case diff < 7*24*time.Hour:
d := int(diff.Hours() / 24)
if d == 1 {
return "yesterday"
}
return fmt.Sprintf("%d days ago", d)
default:
return t.Format("2006-01-02 15:04")
}
}