Files
SycunandParallels 470eb5ead1 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>
2026-09-25 14:46:23 +08:00

55 lines
1.5 KiB
Go

package database
import (
"database/sql"
"strings"
"time"
)
// ConversationLastActivity 返回会话最近活动时间;ok=false 表示会话已不存在。
// 供存储清理判断目录是否为孤儿、以及会话是否仍在活跃使用。
func (db *DB) ConversationLastActivity(id string) (time.Time, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return time.Time{}, false, nil
}
var createdAt, updatedAt string
err := db.QueryRow(
"SELECT created_at, updated_at FROM conversations WHERE id = ? LIMIT 1", id,
).Scan(&createdAt, &updatedAt)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
created, updated := parseDBTime(createdAt), parseDBTime(updatedAt)
if created.After(updated) {
return created, true, nil
}
return updated, true, nil
}
// ProjectLastActivity 返回项目最近活动时间;ok=false 表示项目已不存在。
func (db *DB) ProjectLastActivity(id string) (time.Time, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return time.Time{}, false, nil
}
var createdAt, updatedAt string
err := db.QueryRow(
"SELECT created_at, updated_at FROM projects WHERE id = ? LIMIT 1", id,
).Scan(&createdAt, &updatedAt)
if err == sql.ErrNoRows {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
created, updated := parseDBTime(createdAt), parseDBTime(updatedAt)
if created.After(updated) {
return created, true, nil
}
return updated, true, nil
}