Files
Agent-System/internal/workspace/gc.go
T
weichen 2de4eda16d feat(workspace): register workspaces and gate cleanup by receipts
Track workspace size and age, expose an admin list and cleanup route, and refuse TTL or quota deletion until a frozen manifest matches verified receipts.
2026-09-21 14:58:21 +00:00

108 lines
2.7 KiB
Go

package workspace
import (
"context"
"sort"
"time"
)
// GCReport is the outcome of one retention pass. Directories without a matching
// frozen manifest and verified receipts are left in place.
type GCReport struct {
Deleted []string `json:"deleted"`
Blocked []string `json:"blocked"`
Bytes int64 `json:"bytes"`
}
// CollectGarbage removes expired or over-quota workspaces only when
// AssertCleanupAllowed accepts the stored manifest and receipts.
func (r *FileRegistry) CollectGarbage(ctx context.Context, now time.Time, ttl time.Duration, quota int64, cap *Capability) (GCReport, error) {
if err := r.Discover(); err != nil {
return GCReport{}, err
}
list, err := r.List()
if err != nil {
return GCReport{}, err
}
if cap == nil {
cap = NewCapability(r.dataRoot, nil)
}
var report GCReport
for _, rec := range list {
report.Bytes += rec.Bytes
}
expired := map[string]bool{}
if ttl > 0 {
for _, rec := range list {
if now.Sub(rec.CreatedAt) > ttl {
expired[rec.ID] = true
}
}
}
over := quota > 0 && report.Bytes > quota
if !over && len(expired) == 0 {
return report, nil
}
candidates := append([]Record(nil), list...)
sort.Slice(candidates, func(i, j int) bool { return candidates[i].LastSeen.Before(candidates[j].LastSeen) })
for _, rec := range candidates {
if ctx.Err() != nil {
return report, ctx.Err()
}
need := expired[rec.ID] || (quota > 0 && report.Bytes > quota)
if !need {
continue
}
if err := r.deleteIfAllowed(ctx, cap, rec); err != nil {
report.Blocked = append(report.Blocked, rec.ID)
continue
}
report.Deleted = append(report.Deleted, rec.ID)
report.Bytes -= rec.Bytes
if report.Bytes < 0 {
report.Bytes = 0
}
}
return report, nil
}
// CleanupOne deletes one workspace when its manifest and receipts match.
func (r *FileRegistry) CleanupOne(ctx context.Context, id string, cap *Capability) error {
rec, ok, err := r.Get(id)
if err != nil {
return err
}
if !ok {
return ErrWorkDirNotFound
}
if cap == nil {
cap = NewCapability(r.dataRoot, nil)
}
return r.deleteIfAllowed(ctx, cap, rec)
}
func (r *FileRegistry) deleteIfAllowed(ctx context.Context, cap *Capability, rec Record) error {
if rec.Frozen == nil {
return ErrCleanupBlocked
}
ref := WorkspaceRef{ID: rec.ID, SchemaVersion: SchemaVersion, RunID: rec.RunID}
if err := cap.Cleanup(ctx, ref, *rec.Frozen, rec.Receipts); err != nil {
return err
}
return r.Delete(rec.ID)
}
// SetRetention stores the frozen manifest and receipts required before deletion.
func (r *FileRegistry) SetRetention(id string, frozen FrozenManifest, receipts []RetentionReceipt) error {
rec, ok, err := r.Get(id)
if err != nil {
return err
}
if !ok {
rec.ID = id
}
rec.Frozen = &frozen
rec.Receipts = receipts
return r.Upsert(rec)
}