diff --git a/internal/hitl/retention.go b/internal/hitl/retention.go new file mode 100644 index 00000000..2746d0f0 --- /dev/null +++ b/internal/hitl/retention.go @@ -0,0 +1,71 @@ +package hitl + +import ( + "time" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + + "go.uber.org/zap" +) + +const retentionPurgeInterval = time.Hour + +// Service manages HITL audit log retention (decided hitl_interrupts rows). +type Service struct { + db *database.DB + cfg *config.Config + logger *zap.Logger +} + +// NewService creates a HITL audit log retention service. +func NewService(db *database.DB, cfg *config.Config, logger *zap.Logger) *Service { + return &Service{db: db, cfg: cfg, logger: logger} +} + +// RetentionDays returns configured retention; 0 means keep forever. +func (s *Service) RetentionDays() int { + if s == nil || s.cfg == nil { + return config.HitlConfig{}.RetentionDaysEffective() + } + return s.cfg.Hitl.RetentionDaysEffective() +} + +// PurgeExpired deletes decided HITL log rows older than retention_days when configured. +func (s *Service) PurgeExpired() { + if s == nil || s.db == nil || s.cfg == nil { + return + } + days := s.cfg.Hitl.RetentionDaysEffective() + if days <= 0 { + return + } + cutoff := time.Now().AddDate(0, 0, -days) + n, err := s.db.PurgeHitlInterruptLogsBefore(cutoff) + if err != nil { + if s.logger != nil { + s.logger.Warn("清理过期人机协同审计日志失败", zap.Error(err)) + } + return + } + if n > 0 && s.logger != nil { + s.logger.Info("已清理过期人机协同审计日志", zap.Int64("deleted", n), zap.Int("retention_days", days)) + } +} + +// StartRetentionLoop periodically purges expired HITL audit log rows. +func StartRetentionLoop(s *Service, logger *zap.Logger) { + if s == nil { + return + } + go func() { + ticker := time.NewTicker(retentionPurgeInterval) + defer ticker.Stop() + for range ticker.C { + s.PurgeExpired() + if logger != nil { + logger.Debug("hitl audit log retention tick completed") + } + } + }() +} diff --git a/internal/hitl/retention_test.go b/internal/hitl/retention_test.go new file mode 100644 index 00000000..f2db086d --- /dev/null +++ b/internal/hitl/retention_test.go @@ -0,0 +1,50 @@ +package hitl + +import ( + "path/filepath" + "testing" + "time" + + appconfig "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + + "go.uber.org/zap" +) + +func TestServicePurgeExpired_respectsZeroRetention(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "hitl.db") + db, err := database.NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS hitl_interrupts ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + mode TEXT NOT NULL, + tool_name TEXT NOT NULL, + status TEXT NOT NULL, + decision TEXT, + created_at DATETIME NOT NULL, + decided_at DATETIME + )`); err != nil { + t.Fatalf("create table: %v", err) + } + + old := time.Now().AddDate(0, 0, -100).UTC().Format(time.RFC3339) + if _, err := db.Exec(`INSERT INTO hitl_interrupts + (id, conversation_id, mode, tool_name, status, decision, created_at, decided_at) + VALUES ('old-1', 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, old, old); err != nil { + t.Fatalf("insert: %v", err) + } + + zero := 0 + svc := NewService(db, &appconfig.Config{ + Hitl: appconfig.HitlConfig{RetentionDays: &zero}, + }, zap.NewNop()) + svc.PurgeExpired() + + if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'old-1'`).Scan(new(string)); err != nil { + t.Fatalf("record should remain when retention_days=0: %v", err) + } +}