/ cmd / sessions.go
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  }