feat: add runtime storage cleanup with per-category retention (#310) (#313)

Runtime artifacts (agent workspaces, tool-output spill, C2 payloads, chat
uploads, workflow checkpoints, diagnostic logs) previously accumulated
without bound: most were only removed when a conversation or project was
deleted, and tmp/c2 plus workflow checkpoints were never removed at all.

Add a storage cleaner with named per-category tasks (Gitea-style), a
settings page tab, and a background sweep that is off by default so
upgrading never deletes existing data.

Safety properties, since mis-deleting live task data costs far more than
the disk saved:
- dry-run is the default; a real cleanup requires dry_run=false together
  with confirm=true at the API layer, not just a frontend dialog
- sessions active within active_grace_hours are always skipped, and a
  failed activity lookup skips conservatively (fail closed)
- directories whose conversation/project no longer exists are reclaimed
  as orphans after orphan_grace_days
- scanners never follow symlinks and every candidate path is confined to
  its category root; deletion renames to a .tmp-for-deletion marker first
  so a crash leaves recoverable residue instead of a half-deleted dir
- storage:* permissions are admin-only; without the grantSystemRolePermissions
  skip the default branch would have given operators an irreversible
  file-deletion right

Also fix two confirmed leaks: DeleteConversation left chat_uploads files
on disk (their rows already vanished via ON DELETE CASCADE), and workflow
checkpoints had no deletion path at all.

Co-authored-by: Parallels <parallels@kali-linux-2025-2.localdomain>
This commit is contained in:
SycunandParallels authored and GitHub committed 2026-09-25 14:46:23 +08:00
1 parent e9b6e0d86e
commit 470eb5ead1
28 files changed
+3356 -1

No files matched your search

+127
View File
@@ -37,6 +37,7 @@ type Config struct {
Auth AuthConfig `yaml:"auth"`
Audit AuditConfig `yaml:"audit,omitempty" json:"audit,omitempty"`
Monitor MonitorConfig `yaml:"monitor,omitempty" json:"monitor,omitempty"`
Storage StorageConfig `yaml:"storage,omitempty" json:"storage,omitempty"`
ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"`
Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"`
C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用
@@ -1370,6 +1371,132 @@ func (m MonitorConfig) RetentionDaysEffective() int {
return *m.RetentionDays
}
// 运行空间垃圾清理的类别键。顺序即前端展示顺序,勿依赖 map 迭代顺序。
const (
StorageCategoryWorkspace = "workspace"
StorageCategoryReduction = "reduction"
StorageCategoryConversationArtifact = "conversation_artifacts"
StorageCategoryPlantask = "plantask"
StorageCategoryC2Artifacts = "c2_artifacts"
StorageCategoryChatUploads = "chat_uploads"
StorageCategoryWorkflowCheckpoints = "workflow_checkpoints"
StorageCategoryDiagnosticLogs = "diagnostic_logs"
)
// StorageCategoryOrder 列出全部可清理类别,供 UI 与报表稳定排序。
var StorageCategoryOrder = []string{
StorageCategoryWorkspace,
StorageCategoryReduction,
StorageCategoryConversationArtifact,
StorageCategoryPlantask,
StorageCategoryC2Artifacts,
StorageCategoryChatUploads,
StorageCategoryWorkflowCheckpoints,
StorageCategoryDiagnosticLogs,
}
// StorageCategoryDefaults 各类别默认保留天数。取较短值的是纯派生产物
// (reduction/checkpoint),取较长值的是可能仍需人工回看的上传件。
var StorageCategoryDefaults = map[string]int{
StorageCategoryWorkspace: 30,
StorageCategoryReduction: 7,
StorageCategoryConversationArtifact: 30,
StorageCategoryPlantask: 30,
StorageCategoryC2Artifacts: 30,
StorageCategoryChatUploads: 90,
StorageCategoryWorkflowCheckpoints: 7,
StorageCategoryDiagnosticLogs: 14,
}
// StorageCategoryConfig 单个清理类别的策略覆盖。
type StorageCategoryConfig struct {
// Enabled 省略时默认 true;显式 false 表示该类别既不自动清理也不出现在手动清理范围内。
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
// RetentionDays 省略时使用 StorageCategoryDefaults;0 表示不按保留期清理(孤儿目录仍会回收)。
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
}
// StorageConfig 运行空间垃圾清理策略。
// 自动清理默认关闭:与 Argo ttlStrategy / K8s ttlSecondsAfterFinished 的 unset 语义一致,
// 升级后不会在管理员不知情的情况下删除既有数据。
type StorageConfig struct {
// AutoClean 省略或 false 关闭后台自动清理;显式 true 才启用。
AutoClean *bool `yaml:"auto_clean,omitempty" json:"auto_clean,omitempty"`
// IntervalMinutes 后台清理轮询间隔;省略默认 60,最小 5。
IntervalMinutes *int `yaml:"interval_minutes,omitempty" json:"interval_minutes,omitempty"`
// OrphanGraceDays 会话/项目已删除但目录残留时的最小保留天数;省略默认 1。
OrphanGraceDays *int `yaml:"orphan_grace_days,omitempty" json:"orphan_grace_days,omitempty"`
// ActiveGraceHours 最近有活动的会话一律跳过清理;省略默认 24。
ActiveGraceHours *int `yaml:"active_grace_hours,omitempty" json:"active_grace_hours,omitempty"`
// Categories 按类别覆盖策略;未列出的类别使用内置默认值。
Categories map[string]StorageCategoryConfig `yaml:"categories,omitempty" json:"categories,omitempty"`
}
// AutoCleanEffective returns true only when storage.auto_clean is explicitly true.
func (s StorageConfig) AutoCleanEffective() bool {
return s.AutoClean != nil && *s.AutoClean
}
// IntervalMinutesEffective returns the background sweep interval; defaults to 60, floor 5.
func (s StorageConfig) IntervalMinutesEffective() int {
if s.IntervalMinutes == nil {
return 60
}
if *s.IntervalMinutes < 5 {
return 5
}
return *s.IntervalMinutes
}
// OrphanGraceDaysEffective returns the minimum age before an orphaned session dir is reclaimed; defaults to 1.
func (s StorageConfig) OrphanGraceDaysEffective() int {
if s.OrphanGraceDays == nil {
return 1
}
if *s.OrphanGraceDays < 0 {
return 0
}
return *s.OrphanGraceDays
}
// ActiveGraceHoursEffective returns the recent-activity protection window; defaults to 24, floor 1.
func (s StorageConfig) ActiveGraceHoursEffective() int {
if s.ActiveGraceHours == nil {
return 24
}
if *s.ActiveGraceHours < 1 {
return 1
}
return *s.ActiveGraceHours
}
// CategoryEnabled reports whether a category participates in cleanup; unknown keys default to false.
func (s StorageConfig) CategoryEnabled(key string) bool {
if _, known := StorageCategoryDefaults[key]; !known {
return false
}
if c, ok := s.Categories[key]; ok && c.Enabled != nil {
return *c.Enabled
}
return true
}
// CategoryRetentionDays returns the effective retention for a category; unknown keys yield 0 (keep forever).
func (s StorageConfig) CategoryRetentionDays(key string) int {
def, known := StorageCategoryDefaults[key]
if !known {
return 0
}
c, ok := s.Categories[key]
if !ok || c.RetentionDays == nil {
return def
}
if *c.RetentionDays < 0 {
return 0
}
return *c.RetentionDays
}
// AuditConfig platform operation audit log settings (not chat/tool execution bodies).
type AuditConfig struct {
// Enabled nil or true enables persistence; explicit false disables.