diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 468c293..0e2a693 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -88,6 +88,18 @@ func main() { defer syncCircuitBreakerService.Stop() } + // Start cleanup timer service if enabled (enabled by default) + if cfg.LogCleanup != nil && cfg.LogCleanup.Enabled != nil && *cfg.LogCleanup.Enabled { + cleanupTimerService := daemon.NewCleanupTimerService(cfg) + if err := cleanupTimerService.Start(ctx); err != nil { + slog.Error("Failed to start cleanup timer service", slog.Any("err", err)) + } else { + slog.Info("Cleanup timer service started", + slog.Int64("thresholdMB", cfg.LogCleanup.ThresholdMB)) + defer cleanupTimerService.Stop() + } + } + go daemon.SocketTopicProcessor(msg) // Start CCUsage service if enabled (v1 - ccusage CLI based) diff --git a/commands/gc.go b/commands/gc.go index 6fde20a..a5d2996 100644 --- a/commands/gc.go +++ b/commands/gc.go @@ -13,8 +13,6 @@ import ( "go.opentelemetry.io/otel/trace" ) -const logFileSizeThreshold int64 = 50 * 1024 * 1024 // 50 MB - var GCCommand *cli.Command = &cli.Command{ Name: "gc", Usage: "clean internal storage", @@ -34,52 +32,6 @@ var GCCommand *cli.Command = &cli.Command{ Action: commandGC, } -// cleanLogFile removes a log file if it exceeds the threshold or if force is true. -// Returns the size of the deleted file (0 if not deleted or file doesn't exist). -func cleanLogFile(filePath string, threshold int64, force bool) (int64, error) { - info, err := os.Stat(filePath) - if os.IsNotExist(err) { - return 0, nil - } - if err != nil { - return 0, fmt.Errorf("failed to stat file %s: %w", filePath, err) - } - - fileSize := info.Size() - if !force && fileSize < threshold { - return 0, nil - } - - if err := os.Remove(filePath); err != nil { - return 0, fmt.Errorf("failed to remove file %s: %w", filePath, err) - } - - slog.Info("cleaned log file", slog.String("file", filePath), slog.Int64("size_bytes", fileSize)) - return fileSize, nil -} - -// cleanLargeLogFiles checks all log files and removes those exceeding the size threshold. -// If force is true, removes all log files regardless of size. -func cleanLargeLogFiles(force bool) (int64, error) { - logFiles := []string{ - model.GetLogFilePath(), - model.GetHeartbeatLogFilePath(), - model.GetSyncPendingFilePath(), - } - - var totalFreed int64 - for _, filePath := range logFiles { - freed, err := cleanLogFile(filePath, logFileSizeThreshold, force) - if err != nil { - slog.Warn("failed to clean log file", slog.String("file", filePath), slog.Any("err", err)) - continue - } - totalFreed += freed - } - - return totalFreed, nil -} - // backupAndWriteFile backs up the existing file and writes new content. func backupAndWriteFile(filePath string, content []byte) error { backupFile := filePath + ".bak" @@ -231,9 +183,17 @@ func commandGC(c *cli.Context) error { return nil } + // Get config for threshold + cfg, err := configService.ReadConfigFile(ctx) + if err != nil { + slog.Warn("failed to read config, using default threshold", slog.Any("err", err)) + cfg.LogCleanup = &model.LogCleanup{ThresholdMB: 100} + } + thresholdBytes := cfg.LogCleanup.ThresholdMB * 1024 * 1024 + // Clean log files: force clean if --withLog, otherwise only clean large files forceCleanLogs := c.Bool("withLog") - freedBytes, err := cleanLargeLogFiles(forceCleanLogs) + freedBytes, err := model.CleanLargeLogFiles(thresholdBytes, forceCleanLogs) if err != nil { slog.Warn("error during log cleanup", slog.Any("err", err)) } diff --git a/commands/track_test.go b/commands/track_test.go index a813744..a8e6ced 100644 --- a/commands/track_test.go +++ b/commands/track_test.go @@ -122,11 +122,16 @@ func (s *trackTestSuite) TestTrackWithSendData() { })) defer server.Close() cs := model.NewMockConfigService(s.T()) + truthy := true mockedConfig := model.ShellTimeConfig{ Token: "TOKEN001", APIEndpoint: server.URL, FlushCount: 7, GCTime: 8, + LogCleanup: &model.LogCleanup{ + Enabled: &truthy, + ThresholdMB: 100, + }, } cs.On("ReadConfigFile", mock.Anything).Return(mockedConfig, nil) model.UserShellTimeConfig = mockedConfig diff --git a/daemon/cleanup_timer.go b/daemon/cleanup_timer.go new file mode 100644 index 0000000..d7d029e --- /dev/null +++ b/daemon/cleanup_timer.go @@ -0,0 +1,103 @@ +package daemon + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/malamtime/cli/model" +) + +const ( + // CleanupInterval is the interval for log cleanup (24 hours) + CleanupInterval = 24 * time.Hour +) + +// CleanupTimerService handles periodic cleanup of large log files +type CleanupTimerService struct { + config model.ShellTimeConfig + ticker *time.Ticker + stopChan chan struct{} + wg sync.WaitGroup +} + +// NewCleanupTimerService creates a new cleanup timer service +func NewCleanupTimerService(config model.ShellTimeConfig) *CleanupTimerService { + return &CleanupTimerService{ + config: config, + stopChan: make(chan struct{}), + } +} + +// Start begins the periodic cleanup job +func (s *CleanupTimerService) Start(ctx context.Context) error { + s.ticker = time.NewTicker(CleanupInterval) + s.wg.Add(1) + + go func() { + defer s.wg.Done() + + // NOTE: Do not run at startup, only on timer + // This avoids slowing daemon startup and prevents cleanup on restart loops + + for { + select { + case <-s.ticker.C: + s.cleanup(ctx) + case <-s.stopChan: + return + case <-ctx.Done(): + return + } + } + }() + + slog.Info("Cleanup timer service started", + slog.Duration("interval", CleanupInterval), + slog.Int64("thresholdMB", s.config.LogCleanup.ThresholdMB)) + return nil +} + +// Stop stops the cleanup service +func (s *CleanupTimerService) Stop() { + if s.ticker != nil { + s.ticker.Stop() + } + close(s.stopChan) + s.wg.Wait() + slog.Info("Cleanup timer service stopped") +} + +// cleanup performs the log cleanup +func (s *CleanupTimerService) cleanup(ctx context.Context) { + thresholdBytes := s.config.LogCleanup.ThresholdMB * 1024 * 1024 + + slog.Debug("Starting scheduled log cleanup", + slog.Int64("thresholdMB", s.config.LogCleanup.ThresholdMB)) + + var totalFreed int64 + + // Clean CLI log files + freedCLI, err := model.CleanLargeLogFiles(thresholdBytes, false) + if err != nil { + slog.Warn("error during CLI log cleanup", slog.Any("err", err)) + } + totalFreed += freedCLI + + // Clean daemon log files (macOS only) + freedDaemon, err := model.CleanDaemonLogFiles(thresholdBytes, false) + if err != nil { + slog.Warn("error during daemon log cleanup", slog.Any("err", err)) + } + totalFreed += freedDaemon + + if totalFreed > 0 { + slog.Info("scheduled log cleanup completed", + slog.Int64("totalFreedBytes", totalFreed), + slog.Int64("cliFreedBytes", freedCLI), + slog.Int64("daemonFreedBytes", freedDaemon)) + } else { + slog.Debug("scheduled log cleanup completed, no files exceeded threshold") + } +} diff --git a/model/config.go b/model/config.go index 087ca63..f9b2bdb 100644 --- a/model/config.go +++ b/model/config.go @@ -86,9 +86,18 @@ func mergeConfig(base, local *ShellTimeConfig) { if local.CCOtel != nil { base.CCOtel = local.CCOtel } + if local.LogCleanup != nil { + base.LogCleanup = local.LogCleanup + } if local.SocketPath != "" { base.SocketPath = local.SocketPath } + if local.CodeTracking != nil { + base.CodeTracking = local.CodeTracking + } + if local.LogCleanup != nil { + base.LogCleanup = local.LogCleanup + } } func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigOption) (config ShellTimeConfig, err error) { @@ -177,6 +186,21 @@ func (cs *configService) ReadConfigFile(ctx context.Context, opts ...ReadConfigO config.SocketPath = DefaultSocketPath } + // Initialize LogCleanup with defaults if not present (enabled by default with 100MB threshold) + if config.LogCleanup == nil { + config.LogCleanup = &LogCleanup{ + Enabled: &truthy, + ThresholdMB: 100, + } + } else { + if config.LogCleanup.Enabled == nil { + config.LogCleanup.Enabled = &truthy + } + if config.LogCleanup.ThresholdMB == 0 { + config.LogCleanup.ThresholdMB = 100 + } + } + // Save to cache cs.mu.Lock() cs.cachedConfig = &config diff --git a/model/config_test.go b/model/config_test.go index dfc3271..0396ac5 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -142,3 +142,162 @@ FlushCount = 10` }) } } + +func TestLogCleanupDefaults(t *testing.T) { + // Create a temporary directory for test configs + tmpDir, err := os.MkdirTemp("", "shelltime-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create config file without LogCleanup section + baseConfigPath := filepath.Join(tmpDir, "config.toml") + baseConfig := `Token = 'test-token' +APIEndpoint = 'https://api.test.com'` + err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644) + require.NoError(t, err) + + cs := NewConfigService(baseConfigPath) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // Verify LogCleanup defaults are applied + require.NotNil(t, config.LogCleanup, "LogCleanup should be initialized with defaults") + assert.True(t, *config.LogCleanup.Enabled, "LogCleanup.Enabled should default to true") + assert.Equal(t, int64(100), config.LogCleanup.ThresholdMB, "LogCleanup.ThresholdMB should default to 100") +} + +func TestLogCleanupCustomValues(t *testing.T) { + // Create a temporary directory for test configs + tmpDir, err := os.MkdirTemp("", "shelltime-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create config file with custom LogCleanup settings + baseConfigPath := filepath.Join(tmpDir, "config.toml") + baseConfig := `Token = 'test-token' +APIEndpoint = 'https://api.test.com' + +[logCleanup] +enabled = false +thresholdMB = 200` + err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644) + require.NoError(t, err) + + cs := NewConfigService(baseConfigPath) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // Verify custom LogCleanup values are used + require.NotNil(t, config.LogCleanup, "LogCleanup should be present") + assert.False(t, *config.LogCleanup.Enabled, "LogCleanup.Enabled should be false") + assert.Equal(t, int64(200), config.LogCleanup.ThresholdMB, "LogCleanup.ThresholdMB should be 200") +} + +func TestLogCleanupPartialConfig(t *testing.T) { + testCases := []struct { + name string + config string + expectedEnabled bool + expectedThreshold int64 + }{ + { + name: "Only enabled set to false", + config: `Token = 'test-token' +[logCleanup] +enabled = false`, + expectedEnabled: false, + expectedThreshold: 100, // default + }, + { + name: "Only threshold set", + config: `Token = 'test-token' +[logCleanup] +thresholdMB = 50`, + expectedEnabled: true, // default + expectedThreshold: 50, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "shelltime-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + baseConfigPath := filepath.Join(tmpDir, "config.toml") + err = os.WriteFile(baseConfigPath, []byte(tc.config), 0644) + require.NoError(t, err) + + cs := NewConfigService(baseConfigPath) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + require.NotNil(t, config.LogCleanup, "LogCleanup should be present") + assert.Equal(t, tc.expectedEnabled, *config.LogCleanup.Enabled, "LogCleanup.Enabled mismatch") + assert.Equal(t, tc.expectedThreshold, config.LogCleanup.ThresholdMB, "LogCleanup.ThresholdMB mismatch") + }) + } +} + +func TestLogCleanupMergeFromLocal(t *testing.T) { + // Create a temporary directory for test configs + tmpDir, err := os.MkdirTemp("", "shelltime-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create base config file with LogCleanup + baseConfigPath := filepath.Join(tmpDir, "config.toml") + baseConfig := `Token = 'base-token' +[logCleanup] +enabled = true +thresholdMB = 100` + err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644) + require.NoError(t, err) + + // Create local config file that overrides LogCleanup + localConfigPath := filepath.Join(tmpDir, "config.local.toml") + localConfig := `[logCleanup] +enabled = false +thresholdMB = 500` + err = os.WriteFile(localConfigPath, []byte(localConfig), 0644) + require.NoError(t, err) + + cs := NewConfigService(baseConfigPath) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // Verify local config overrides base LogCleanup + require.NotNil(t, config.LogCleanup, "LogCleanup should be present") + assert.False(t, *config.LogCleanup.Enabled, "LogCleanup.Enabled should be overridden by local config") + assert.Equal(t, int64(500), config.LogCleanup.ThresholdMB, "LogCleanup.ThresholdMB should be overridden by local config") +} + +func TestCodeTrackingMergeFromLocal(t *testing.T) { + // Create a temporary directory for test configs + tmpDir, err := os.MkdirTemp("", "shelltime-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create base config file with CodeTracking disabled + baseConfigPath := filepath.Join(tmpDir, "config.toml") + baseConfig := `Token = 'base-token' +[codeTracking] +enabled = false` + err = os.WriteFile(baseConfigPath, []byte(baseConfig), 0644) + require.NoError(t, err) + + // Create local config file that enables CodeTracking + localConfigPath := filepath.Join(tmpDir, "config.local.toml") + localConfig := `[codeTracking] +enabled = true` + err = os.WriteFile(localConfigPath, []byte(localConfig), 0644) + require.NoError(t, err) + + cs := NewConfigService(baseConfigPath) + config, err := cs.ReadConfigFile(context.Background()) + require.NoError(t, err) + + // Verify local config overrides base CodeTracking + require.NotNil(t, config.CodeTracking, "CodeTracking should be present") + assert.True(t, *config.CodeTracking.Enabled, "CodeTracking.Enabled should be overridden by local config") +} diff --git a/model/log_cleanup.go b/model/log_cleanup.go new file mode 100644 index 0000000..8c6ebfb --- /dev/null +++ b/model/log_cleanup.go @@ -0,0 +1,86 @@ +package model + +import ( + "fmt" + "log/slog" + "os" + "runtime" +) + +// CleanLogFile removes a log file if it exceeds the threshold or if force is true. +// thresholdBytes: size threshold in bytes (e.g., config.LogCleanup.ThresholdMB * 1024 * 1024) +// Returns the size of the deleted file (0 if not deleted or file doesn't exist). +func CleanLogFile(filePath string, thresholdBytes int64, force bool) (int64, error) { + info, err := os.Stat(filePath) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("failed to stat file %s: %w", filePath, err) + } + + fileSize := info.Size() + if !force && fileSize < thresholdBytes { + return 0, nil + } + + if err := os.Remove(filePath); err != nil { + return 0, fmt.Errorf("failed to remove file %s: %w", filePath, err) + } + + slog.Info("cleaned log file", slog.String("file", filePath), slog.Int64("size_bytes", fileSize)) + return fileSize, nil +} + +// CleanLargeLogFiles checks CLI log files and removes those exceeding the size threshold. +// thresholdBytes: size threshold in bytes +// If force is true, removes all log files regardless of size. +func CleanLargeLogFiles(thresholdBytes int64, force bool) (int64, error) { + logFiles := []string{ + GetLogFilePath(), + GetHeartbeatLogFilePath(), + GetSyncPendingFilePath(), + } + + var totalFreed int64 + for _, filePath := range logFiles { + freed, err := CleanLogFile(filePath, thresholdBytes, force) + if err != nil { + slog.Warn("failed to clean log file", slog.String("file", filePath), slog.Any("err", err)) + continue + } + totalFreed += freed + } + + return totalFreed, nil +} + +// CleanDaemonLogFiles cleans daemon-specific log files. +// On macOS, daemon logs go to ~/.shelltime/logs/shelltime-daemon.{log,err} +// On Linux, daemon logs go to systemd journal and can't be cleaned from here. +// thresholdBytes: size threshold in bytes +// If force is true, removes all log files regardless of size. +func CleanDaemonLogFiles(thresholdBytes int64, force bool) (int64, error) { + // Only clean daemon logs on macOS (darwin) + // On Linux, daemon uses systemd journal which is managed by journald + if runtime.GOOS != "darwin" { + return 0, nil + } + + logFiles := []string{ + GetDaemonLogFilePath(), + GetDaemonErrFilePath(), + } + + var totalFreed int64 + for _, filePath := range logFiles { + freed, err := CleanLogFile(filePath, thresholdBytes, force) + if err != nil { + slog.Warn("failed to clean daemon log file", slog.String("file", filePath), slog.Any("err", err)) + continue + } + totalFreed += freed + } + + return totalFreed, nil +} diff --git a/model/path.go b/model/path.go index ac16ccc..e797d4c 100644 --- a/model/path.go +++ b/model/path.go @@ -76,3 +76,18 @@ func GetBinFolderPath() string { func GetHooksFolderPath() string { return GetStoragePath("hooks") } + +// GetDaemonLogsPath returns the path to the daemon logs folder +func GetDaemonLogsPath() string { + return GetStoragePath("logs") +} + +// GetDaemonLogFilePath returns the path to the daemon output log file (macOS) +func GetDaemonLogFilePath() string { + return GetStoragePath("logs", "shelltime-daemon.log") +} + +// GetDaemonErrFilePath returns the path to the daemon error log file (macOS) +func GetDaemonErrFilePath() string { + return GetStoragePath("logs", "shelltime-daemon.err") +} diff --git a/model/types.go b/model/types.go index af2f2ec..77a4516 100644 --- a/model/types.go +++ b/model/types.go @@ -37,6 +37,12 @@ type CodeTracking struct { Enabled *bool `toml:"enabled"` } +// LogCleanup configuration for automatic log file cleanup +type LogCleanup struct { + Enabled *bool `toml:"enabled"` // default: true (enabled by default) + ThresholdMB int64 `toml:"thresholdMB"` // default: 100 MB +} + type ShellTimeConfig struct { Token string APIEndpoint string @@ -78,6 +84,9 @@ type ShellTimeConfig struct { // CodeTracking configuration for coding activity heartbeat tracking CodeTracking *CodeTracking `toml:"codeTracking"` + // LogCleanup configuration for automatic log file cleanup in daemon + LogCleanup *LogCleanup `toml:"logCleanup"` + // SocketPath is the path to the Unix domain socket used for communication // between the CLI and the daemon. SocketPath string `toml:"socketPath"` @@ -105,9 +114,10 @@ var DefaultConfig = ShellTimeConfig{ Encrypted: nil, AI: DefaultAIConfig, Exclude: []string{}, - CCUsage: nil, - CCOtel: nil, - CodeTracking: nil, + CCUsage: nil, + CCOtel: nil, + CodeTracking: nil, + LogCleanup: nil, SocketPath: DefaultSocketPath, }