sessions.go
1 package cmd 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "os" 9 "path/filepath" 10 "runtime/debug" 11 "strings" 12 "time" 13 14 "github.com/spf13/cobra" 15 "github.com/spf13/viper" 16 17 "github.com/Kocoro-lab/ShanClaw/internal/audit" 18 "github.com/Kocoro-lab/ShanClaw/internal/client" 19 "github.com/Kocoro-lab/ShanClaw/internal/config" 20 "github.com/Kocoro-lab/ShanClaw/internal/sync" 21 ) 22 23 var sessionsCmd = &cobra.Command{ 24 Use: "sessions", 25 Short: "Manage local sessions (sync to Cloud, search, etc.)", 26 } 27 28 var sessionsSyncCmd = &cobra.Command{ 29 Use: "sync", 30 Short: "Upload modified sessions to Shannon Cloud (opt-in via sync.enabled)", 31 RunE: func(cmd *cobra.Command, args []string) error { 32 if _, err := config.Load(); err != nil { 33 return fmt.Errorf("config: %w", err) 34 } 35 home, err := os.UserHomeDir() 36 if err != nil { 37 return fmt.Errorf("locate home dir: %w", err) 38 } 39 shannonHome := filepath.Join(home, ".shannon") 40 41 cfg := sync.LoadConfig(viper.GetViper()) 42 if !cfg.Enabled && !cfg.DryRun { 43 fmt.Fprintln(cmd.OutOrStdout(), "sync is disabled (sync.enabled=false). Set sync.enabled=true to enable.") 44 return nil 45 } 46 47 var uploader sync.Uploader 48 outboxDir := filepath.Join(shannonHome, "sync_outbox") 49 if cfg.DryRun { 50 uploader = &sync.DryRunUploader{OutboxDir: outboxDir, Now: time.Now} 51 } else { 52 endpoint := sync.ResolveEndpoint(cfg, viper.GetViper()) 53 apiKey := viper.GetString("cloud.api_key") 54 if endpoint == "" || apiKey == "" { 55 return fmt.Errorf("upload endpoint (sync.endpoint or cloud.endpoint) and cloud.api_key must be configured (or set sync.dry_run=true)") 56 } 57 gw := client.NewGatewayClient(endpoint, apiKey) 58 uploader = &sync.CloudUploader{Client: gw} 59 } 60 61 auditLogger, err := audit.NewAuditLogger(filepath.Join(shannonHome, "logs")) 62 if err != nil { 63 return fmt.Errorf("open audit log: %w", err) 64 } 65 defer auditLogger.Close() 66 67 loader := func(dir, id string) ([]byte, error) { 68 return os.ReadFile(filepath.Join(dir, id+".json")) 69 } 70 71 ver := readBuildInfo() 72 73 deps := sync.Deps{ 74 Cfg: cfg, 75 HomeDir: shannonHome, 76 ClientVer: ver, 77 Uploader: uploader, 78 Loader: loader, 79 Audit: &auditAdapter{logger: auditLogger, stdout: cmd.OutOrStdout()}, 80 Now: time.Now, 81 } 82 return sync.Run(context.Background(), deps) 83 }, 84 } 85 86 // auditAdapter bridges *audit.AuditLogger (which logs structured AuditEntry 87 // rows) to sync.AuditLogger (event + free-form fields). The fields map is 88 // JSON-encoded into AuditEntry.InputSummary so the JSON-lines audit log keeps 89 // a single shape. When stdout is non-nil, a human-readable one-line summary 90 // of session_sync events is also printed so CLI users see progress on exit 91 // (sync.Run itself is silent by design). 92 type auditAdapter struct { 93 logger *audit.AuditLogger 94 stdout io.Writer 95 } 96 97 func (a *auditAdapter) Log(event string, fields map[string]any) { 98 if a == nil { 99 return 100 } 101 summary := "" 102 if len(fields) > 0 { 103 if b, err := json.Marshal(fields); err == nil { 104 summary = string(b) 105 } 106 } 107 if a.logger != nil { 108 a.logger.Log(audit.AuditEntry{ 109 Timestamp: time.Now(), 110 ToolName: event, 111 InputSummary: summary, 112 Decision: "info", 113 }) 114 } 115 if a.stdout != nil && event == "session_sync" { 116 fmt.Fprintln(a.stdout, formatSyncSummary(fields)) 117 } 118 } 119 120 // formatSyncSummary renders a single-line human-readable view of a 121 // session_sync audit event. The field names mirror sync.Run's audit call so 122 // any future fields added there will show up as "key=<value>" tail items. 123 func formatSyncSummary(fields map[string]any) string { 124 outcome, _ := fields["outcome"].(string) 125 if outcome == "" { 126 outcome = "unknown" 127 } 128 sent, _ := fields["sent"].(int) 129 accepted, _ := fields["accepted"].(int) 130 rtx, _ := fields["rejected_transient"].(int) 131 rpm, _ := fields["rejected_permanent"].(int) 132 carry, _ := fields["failed_carryover"].(int) 133 reason, _ := fields["reason"].(string) 134 transport, _ := fields["transport_error"].(bool) 135 136 parts := []string{fmt.Sprintf("sync: outcome=%s sent=%d accepted=%d rejected_transient=%d rejected_permanent=%d failed_carryover=%d", 137 outcome, sent, accepted, rtx, rpm, carry)} 138 if reason != "" { 139 parts = append(parts, "reason="+reason) 140 } 141 if transport { 142 parts = append(parts, "transport_error=true") 143 } 144 return strings.Join(parts, " ") 145 } 146 147 func readBuildInfo() string { 148 if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" { 149 return "shanclaw/" + info.Main.Version 150 } 151 return "shanclaw/dev" 152 } 153 154 func init() { 155 sessionsCmd.AddCommand(sessionsSyncCmd) 156 rootCmd.AddCommand(sessionsCmd) 157 }