feat: add quarantine retention lifecycle (#2049)

This commit is contained in:
Carlos Florêncio
2026-07-30 10:44:51 +01:00
committed by GitHub
parent d05ba00b9f
commit 6ba9f1b0fd
35 changed files with 1848 additions and 95 deletions
@@ -70,7 +70,11 @@ func provideStorageComposition(
homeDir: cfg.ResolvedHomeDir(),
}
cachedOverview := storagepkg.NewOverviewCache(overview)
providers := storageCleanupProviders(settings, workspaceFactory, goCache, dockerProvider)
quarantine := &workspaceQuarantineController{
settings: settings, store: store, factory: workspaceFactory, homeDir: cfg.ResolvedHomeDir(),
activity: coordinator,
}
providers := storageCleanupProviders(settings, workspaceFactory, goCache, dockerProvider, quarantine)
runner := storagepkg.NewRunner(storagepkg.RunnerConfig{
Activity: coordinator, Store: store, Providers: providers, Overview: cachedOverview,
})
@@ -79,10 +83,6 @@ func provideStorageComposition(
Scheduler: scheduler, Settings: settings, Worker: taskSvc,
Reconciler: &workspaceReconciler{settings: settings, factory: workspaceFactory},
})
quarantine := &workspaceQuarantineController{
settings: settings, store: store, factory: workspaceFactory, homeDir: cfg.ResolvedHomeDir(),
activity: coordinator,
}
operations := storagepkg.NewOperations(storagepkg.OperationsConfig{
Settings: settings, Store: store, Jobs: tracker, Activity: coordinator,
Providers: providers, Overview: cachedOverview, GoCache: goCache, Quarantine: quarantine,
@@ -191,6 +191,21 @@ type namedCleanupProvider struct {
cleanup func(context.Context) (map[string]any, error)
}
type quarantinePurger interface {
Purge(context.Context, storagepkg.QuarantinePurgeScope, string) (storagepkg.QuarantinePurgeResult, error)
}
type quarantineCleanupProvider struct {
purger quarantinePurger
}
func (p quarantineCleanupProvider) Name() string { return "quarantine" }
func (p quarantineCleanupProvider) Cleanup(ctx context.Context) (map[string]any, error) {
result, err := p.purger.Purge(ctx, storagepkg.QuarantinePurgeScopeEligible, storagepkg.QuarantineConfirmationEligible)
return toMap(result), err
}
type goCacheCleanupProvider struct {
provider *gocache.Provider
}
@@ -215,8 +230,10 @@ func storageCleanupProviders(
workspaceFactory workspaceFactory,
goCache *gocache.Provider,
docker *dockerstore.Provider,
quarantine quarantinePurger,
) []storagepkg.CleanupProvider {
return []storagepkg.CleanupProvider{
quarantineCleanupProvider{purger: quarantine},
workspaceCleanupAdapter(settings, workspaceFactory),
goCacheCleanupProvider{provider: goCache},
dockerContainerCleanupAdapter(settings, docker),
@@ -312,11 +329,67 @@ type workspaceQuarantineController struct {
type quarantineEntryStore interface {
GetQuarantineEntry(context.Context, string) (storagepkg.QuarantineEntry, error)
ListQuarantineEntries(context.Context, bool) ([]storagepkg.QuarantineEntry, error)
TransitionQuarantineEntry(
context.Context, string, storagepkg.QuarantineState, string,
) (storagepkg.QuarantineEntry, error)
}
func (c *workspaceQuarantineController) Purge(
ctx context.Context,
scope storagepkg.QuarantinePurgeScope,
confirmation string,
) (storagepkg.QuarantinePurgeResult, error) {
result := storagepkg.QuarantinePurgeResult{Scope: scope}
force := false
switch scope {
case storagepkg.QuarantinePurgeScopeEligible:
if confirmation != storagepkg.QuarantineConfirmationEligible {
return result, fmt.Errorf("%w: eligible quarantine purge requires %s confirmation", storagepkg.ErrValidation, storagepkg.QuarantineConfirmationEligible)
}
case storagepkg.QuarantinePurgeScopeAll:
if confirmation != storagepkg.QuarantineConfirmationForce {
return result, storagepkg.ErrForceDeleteConfirmation
}
force = true
default:
return result, fmt.Errorf("%w: unknown quarantine purge scope %q", storagepkg.ErrValidation, scope)
}
entries, err := c.store.ListQuarantineEntries(ctx, false)
if err != nil {
return result, err
}
now := time.Now().UTC()
var purgeErrs []error
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return result, err
}
result.Considered++
if !force && now.Before(entry.DeleteAfter) {
result.Protected++
result.ProtectedBytes += entry.SizeBytes
continue
}
var deleted storagepkg.QuarantineEntry
if force {
deleted, err = c.PermanentDeleteForce(ctx, entry.ID, confirmation)
} else {
deleted, err = c.PermanentDelete(ctx, entry.ID, storagepkg.QuarantineConfirmationDelete)
}
if err != nil {
result.Failed++
result.FailedBytes += entry.SizeBytes
result.Failures = append(result.Failures, storagepkg.QuarantinePurgeFailure{ID: entry.ID, Error: err.Error()})
purgeErrs = append(purgeErrs, fmt.Errorf("%s: %w", entry.ID, err))
continue
}
result.Deleted++
result.DeletedBytes += deleted.SizeBytes
}
return result, errors.Join(purgeErrs...)
}
const (
goCacheOwnershipManaged = "managed"
goCacheOwnershipAdopted = "adopted"
@@ -362,12 +435,32 @@ func (c *workspaceQuarantineController) PermanentDelete(
ctx context.Context,
id string,
confirmation string,
) (storagepkg.QuarantineEntry, error) {
return c.permanentDelete(ctx, id, confirmation, false)
}
func (c *workspaceQuarantineController) PermanentDeleteForce(
ctx context.Context,
id string,
confirmation string,
) (storagepkg.QuarantineEntry, error) {
return c.permanentDelete(ctx, id, confirmation, true)
}
func (c *workspaceQuarantineController) permanentDelete(
ctx context.Context,
id string,
confirmation string,
force bool,
) (storagepkg.QuarantineEntry, error) {
entry, err := c.store.GetQuarantineEntry(ctx, id)
if err != nil {
return storagepkg.QuarantineEntry{}, err
}
if entry.ResourceType == storagepkg.ResourceTypeGoCache {
if force {
return c.deleteGoCacheForce(ctx, entry, confirmation)
}
return c.deleteGoCache(ctx, entry, confirmation)
}
if entry.ResourceType != storagepkg.ResourceTypeTaskWorkspace {
@@ -377,6 +470,9 @@ func (c *workspaceQuarantineController) PermanentDelete(
if err != nil {
return storagepkg.QuarantineEntry{}, err
}
if force {
return c.factory(settings).PermanentDeleteForce(ctx, id, confirmation)
}
return c.factory(settings).PermanentDelete(ctx, id, confirmation)
}
@@ -482,13 +578,34 @@ func (c *workspaceQuarantineController) deleteGoCache(
entry storagepkg.QuarantineEntry,
confirmation string,
) (storagepkg.QuarantineEntry, error) {
if confirmation != "DELETE" {
return c.deleteGoCacheWithRetention(ctx, entry, confirmation, false)
}
func (c *workspaceQuarantineController) deleteGoCacheForce(
ctx context.Context,
entry storagepkg.QuarantineEntry,
confirmation string,
) (storagepkg.QuarantineEntry, error) {
return c.deleteGoCacheWithRetention(ctx, entry, confirmation, true)
}
func (c *workspaceQuarantineController) deleteGoCacheWithRetention(
ctx context.Context,
entry storagepkg.QuarantineEntry,
confirmation string,
force bool,
) (storagepkg.QuarantineEntry, error) {
if force {
if confirmation != storagepkg.QuarantineConfirmationForce {
return storagepkg.QuarantineEntry{}, storagepkg.ErrForceDeleteConfirmation
}
} else if confirmation != storagepkg.QuarantineConfirmationDelete {
return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: quarantine deletion requires DELETE confirmation", storagepkg.ErrValidation)
}
if err := c.validateGoCacheEntry(ctx, entry); err != nil {
return storagepkg.QuarantineEntry{}, err
}
if time.Now().UTC().Before(entry.DeleteAfter) {
if !force && time.Now().UTC().Before(entry.DeleteAfter) {
return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: quarantine retention deadline has not elapsed", storagepkg.ErrConflict)
}
if err := c.rejectAmbiguousMissingGoCachePayload(entry); err != nil {
@@ -498,7 +615,7 @@ func (c *workspaceQuarantineController) deleteGoCache(
return storagepkg.QuarantineEntry{}, fmt.Errorf("delete quarantined Go cache: %w", err)
}
deleted, err := c.store.TransitionQuarantineEntry(
ctx, entry.ID, storagepkg.QuarantineStateDeleted, "",
context.WithoutCancel(ctx), entry.ID, storagepkg.QuarantineStateDeleted, "",
)
if err != nil {
return storagepkg.QuarantineEntry{}, fmt.Errorf("persist Go-cache deletion: %w", err)
@@ -393,6 +393,118 @@ func TestQuarantineControllerRetriesFailedGoCacheDelete(t *testing.T) {
}
}
func TestQuarantineControllerForceDeletesProtectedGoCache(t *testing.T) {
home := t.TempDir()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", "entry-cache")
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(quarantined, "artifact"), []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
settings, store := newStorageMaintenanceStores(t)
entry := createGoCacheQuarantineEntry(t, store, original, quarantined, time.Now().UTC().Add(time.Hour))
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
if _, err := controller.PermanentDeleteForce(context.Background(), entry.ID, "DELETE"); !errors.Is(err, storagepkg.ErrForceDeleteConfirmation) {
t.Fatalf("wrong force confirmation error = %v, want %v", err, storagepkg.ErrForceDeleteConfirmation)
}
deleted, err := controller.PermanentDeleteForce(context.Background(), entry.ID, "DELETE ALL NOW")
if err != nil {
t.Fatalf("PermanentDeleteForce: %v", err)
}
if deleted.State != storagepkg.QuarantineStateDeleted {
t.Fatalf("state = %q, want deleted", deleted.State)
}
if _, err := os.Stat(quarantined); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("quarantine path still exists: %v", err)
}
}
func TestQuarantineControllerPurgeEligibleReportsProtectedAndDeleted(t *testing.T) {
home := t.TempDir()
settings, store := newStorageMaintenanceStores(t)
protected := storagepkg.QuarantineEntry{
ID: "protected-workspace", ResourceType: storagepkg.ResourceTypeTaskWorkspace,
OriginalPath: filepath.Join(home, "tasks", "protected"),
QuarantinePath: filepath.Join(home, "trash", "tasks", "protected-workspace"),
SizeBytes: 17, State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC(), DeleteAfter: time.Now().UTC().Add(time.Hour),
}
if err := os.MkdirAll(protected.QuarantinePath, 0o700); err != nil {
t.Fatal(err)
}
if err := store.CreateQuarantineEntry(context.Background(), &protected); err != nil {
t.Fatal(err)
}
eligible := createGoCacheQuarantineEntryWithID(
t, store, home, "eligible-cache", time.Now().UTC().Add(-time.Hour),
)
controller := &workspaceQuarantineController{settings: settings, store: store, homeDir: home}
result, err := controller.Purge(context.Background(), storagepkg.QuarantinePurgeScopeEligible, "DELETE ELIGIBLE")
if err != nil {
t.Fatalf("Purge: %v", err)
}
if result.Considered != 2 || result.Deleted != 1 || result.Protected != 1 || result.Failed != 0 {
t.Fatalf("purge result = %#v, want considered=2 deleted=1 protected=1 failed=0", result)
}
if result.DeletedBytes != eligible.SizeBytes || result.ProtectedBytes != protected.SizeBytes {
t.Fatalf("purge bytes = deleted:%d protected:%d, want deleted:%d protected:%d", result.DeletedBytes, result.ProtectedBytes, eligible.SizeBytes, protected.SizeBytes)
}
if _, err := os.Stat(eligible.QuarantinePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("eligible quarantine path still exists: %v", err)
}
if _, err := os.Stat(protected.QuarantinePath); err != nil {
t.Fatalf("protected quarantine path changed: %v", err)
}
}
func TestQuarantineCleanupProviderPurgesEligibleEntries(t *testing.T) {
purger := &recordingQuarantinePurger{}
provider := quarantineCleanupProvider{purger: purger}
result, err := provider.Cleanup(context.Background())
if err != nil {
t.Fatalf("Cleanup: %v", err)
}
if purger.scope != storagepkg.QuarantinePurgeScopeEligible || purger.confirmation != "DELETE ELIGIBLE" {
t.Fatalf("purge request = scope:%q confirmation:%q", purger.scope, purger.confirmation)
}
if result["deleted"] != float64(2) || result["protected"] != float64(1) {
t.Fatalf("provider result = %#v, want deleted=2 protected=1", result)
}
}
func TestStorageCleanupProvidersIncludesQuarantineProvider(t *testing.T) {
providers := storageCleanupProviders(nil, nil, nil, nil, &recordingQuarantinePurger{})
if len(providers) != 6 {
t.Fatalf("provider count = %d, want 6", len(providers))
}
if providers[0].Name() != "quarantine" {
t.Fatalf("first provider = %q, want quarantine", providers[0].Name())
}
if providers[1].Name() != "workspaces" || providers[2].Name() != "go_cache" {
t.Fatalf("provider order = %q, %q, want workspaces, go_cache", providers[1].Name(), providers[2].Name())
}
}
type recordingQuarantinePurger struct {
scope storagepkg.QuarantinePurgeScope
confirmation string
}
func (p *recordingQuarantinePurger) Purge(
_ context.Context,
scope storagepkg.QuarantinePurgeScope,
confirmation string,
) (storagepkg.QuarantinePurgeResult, error) {
p.scope = scope
p.confirmation = confirmation
return storagepkg.QuarantinePurgeResult{Deleted: 2, Protected: 1}, nil
}
func TestQuarantineControllerDoesNotTreatPopulatedReplacementAsRestoredPayload(t *testing.T) {
states := []storagepkg.QuarantineState{
storagepkg.QuarantineStateQuarantined,
@@ -539,6 +651,12 @@ func (s *failingTransitionQuarantineStore) GetQuarantineEntry(
return s.entry, nil
}
func (s *failingTransitionQuarantineStore) ListQuarantineEntries(
context.Context, bool,
) ([]storagepkg.QuarantineEntry, error) {
return []storagepkg.QuarantineEntry{s.entry}, nil
}
func (s *failingTransitionQuarantineStore) TransitionQuarantineEntry(
_ context.Context,
_ string,
@@ -712,6 +830,40 @@ func createGoCacheQuarantineEntry(
return entry
}
func createGoCacheQuarantineEntryWithID(
t *testing.T,
store *storagepkg.Store,
home string,
id string,
deleteAfter time.Time,
) storagepkg.QuarantineEntry {
t.Helper()
original := filepath.Join(home, "cache", "go-build")
quarantined := filepath.Join(home, "trash", "go-cache", id)
if err := os.MkdirAll(quarantined, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(quarantined, "artifact"), []byte("cache"), 0o600); err != nil {
t.Fatal(err)
}
ownership := "managed"
metadata, err := json.Marshal(map[string]string{"ownership": ownership})
if err != nil {
t.Fatal(err)
}
entry := storagepkg.QuarantineEntry{
ID: id, ResourceType: storagepkg.ResourceTypeGoCache,
OriginalPath: original, QuarantinePath: quarantined,
State: storagepkg.QuarantineStateQuarantined,
QuarantinedAt: time.Now().UTC().Add(-2 * time.Hour), DeleteAfter: deleteAfter,
Metadata: metadata,
}
if err := store.CreateQuarantineEntry(context.Background(), &entry); err != nil {
t.Fatal(err)
}
return entry
}
func markGoCacheQuarantineFailed(t *testing.T, store *storagepkg.Store, id string) {
t.Helper()
if _, err := store.TransitionQuarantineEntry(
+8 -5
View File
@@ -29,10 +29,13 @@ const (
// Job is a tracked unit of long-running work.
type Job struct {
ID string `json:"id"`
Kind string `json:"kind"`
State State `json:"state"`
Message string `json:"message,omitempty"`
ID string `json:"id"`
Kind string `json:"kind"`
State State `json:"state"`
Message string `json:"message,omitempty"`
// Result may contain partial progress even when State is failed. A job
// closure can return both a result and an error so best-effort operations
// can report committed work alongside the failure.
Result map[string]interface{} `json:"result,omitempty"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at,omitempty"`
@@ -93,13 +96,13 @@ func (t *Tracker) run(ctx context.Context, job *Job, fn func(ctx context.Context
t.transition(ctx, job.ID, func(j *Job) {
j.EndedAt = time.Now().UTC()
j.Result = result
if err != nil {
j.State = StateFailed
j.Message = err.Error()
return
}
j.State = StateSucceeded
j.Result = result
})
}
@@ -28,6 +28,7 @@ type Mutations interface {
RunNow(context.Context, []string, bool) (string, error)
RestoreQuarantine(context.Context, string) (QuarantineEntry, error)
DeleteQuarantine(context.Context, string, string) (string, error)
PurgeQuarantine(context.Context, QuarantinePurgeScope, string) (string, error)
}
type Capabilities struct {
@@ -86,6 +87,7 @@ func RegisterRoutes(group *gin.RouterGroup, handler *Handler) {
group.GET("/storage/runs", handler.listRuns)
group.GET("/storage/quarantine", handler.listQuarantine)
group.POST("/storage/quarantine/:id/restore", handler.restoreQuarantine)
group.DELETE("/storage/quarantine", handler.deleteQuarantineBulk)
group.DELETE("/storage/quarantine/:id", handler.deleteQuarantine)
}
@@ -172,7 +174,7 @@ type confirmationRequest struct {
func (h *Handler) deleteQuarantine(c *gin.Context) {
var request confirmationRequest
if err := c.ShouldBindJSON(&request); err != nil || request.Confirm != "DELETE" {
if err := c.ShouldBindJSON(&request); err != nil || request.Confirm != QuarantineConfirmationDelete {
c.JSON(http.StatusBadRequest, gin.H{"error": "quarantine deletion requires DELETE confirmation"})
return
}
@@ -180,6 +182,27 @@ func (h *Handler) deleteQuarantine(c *gin.Context) {
writeAcceptedJob(c, id, err)
}
type bulkQuarantineRequest struct {
Scope QuarantinePurgeScope `json:"scope" binding:"required"`
Confirm string `json:"confirm" binding:"required"`
}
func (h *Handler) deleteQuarantineBulk(c *gin.Context) {
var request bulkQuarantineRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if (request.Scope == QuarantinePurgeScopeEligible && request.Confirm != QuarantineConfirmationEligible) ||
(request.Scope == QuarantinePurgeScopeAll && request.Confirm != QuarantineConfirmationForce) ||
(request.Scope != QuarantinePurgeScopeEligible && request.Scope != QuarantinePurgeScopeAll) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid quarantine purge scope or confirmation"})
return
}
id, err := h.config.Mutations.PurgeQuarantine(c.Request.Context(), request.Scope, request.Confirm)
writeAcceptedJob(c, id, err)
}
func writeAcceptedJob(c *gin.Context, id string, err error) {
if err != nil {
writeMutationError(c, err)
@@ -71,6 +71,54 @@ func TestGetStorageReturnsSnapshotAnalyzedAt(t *testing.T) {
}
}
func TestDeleteQuarantineBulkValidatesConfirmation(t *testing.T) {
gin.SetMode(gin.TestMode)
mutations := &recordingMutations{}
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{Mutations: mutations}))
for _, test := range []struct {
body string
want int
}{
{`{"scope":"eligible","confirm":"DELETE ALL NOW"}`, http.StatusBadRequest},
{`{"scope":"all","confirm":"DELETE ELIGIBLE"}`, http.StatusBadRequest},
{`{"scope":"eligible","confirm":"DELETE ELIGIBLE"}`, http.StatusAccepted},
{`{"scope":"all","confirm":"DELETE ALL NOW"}`, http.StatusAccepted},
} {
request := httptest.NewRequest(http.MethodDelete, "/api/v1/system/storage/quarantine", strings.NewReader(test.body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != test.want {
t.Fatalf("body %s status = %d, want %d", test.body, response.Code, test.want)
}
}
if mutations.purgeCalls != 2 {
t.Fatalf("purge calls = %d, want 2", mutations.purgeCalls)
}
}
type recordingMutations struct{ purgeCalls int }
func (m *recordingMutations) AdoptGoCache(context.Context, string, string) (StorageMaintenanceSettings, Capabilities, error) {
return DefaultSettings(), Capabilities{}, nil
}
func (m *recordingMutations) Analyze(context.Context) (string, error) { return "job", nil }
func (m *recordingMutations) RunNow(context.Context, []string, bool) (string, error) {
return "job", nil
}
func (m *recordingMutations) RestoreQuarantine(context.Context, string) (QuarantineEntry, error) {
return QuarantineEntry{}, nil
}
func (m *recordingMutations) DeleteQuarantine(context.Context, string, string) (string, error) {
return "job", nil
}
func (m *recordingMutations) PurgeQuarantine(context.Context, QuarantinePurgeScope, string) (string, error) {
m.purgeCalls++
return "job", nil
}
type failingSettingsManager struct{ err error }
func (f failingSettingsManager) GetSettings(context.Context) (StorageMaintenanceSettings, error) {
@@ -24,6 +24,7 @@ type GoCacheAdopter interface {
type QuarantineController interface {
Restore(context.Context, string) (QuarantineEntry, error)
PermanentDelete(context.Context, string, string) (QuarantineEntry, error)
Purge(context.Context, QuarantinePurgeScope, string) (QuarantinePurgeResult, error)
}
type OperationsConfig struct {
@@ -122,7 +123,7 @@ func (o *Operations) RestoreQuarantine(ctx context.Context, id string) (Quaranti
}
func (o *Operations) DeleteQuarantine(ctx context.Context, id, confirmation string) (string, error) {
if confirmation != "DELETE" {
if confirmation != QuarantineConfirmationDelete {
return "", validationError("quarantine deletion requires DELETE confirmation")
}
if o.config.Quarantine == nil {
@@ -144,6 +145,35 @@ func (o *Operations) DeleteQuarantine(ctx context.Context, id, confirmation stri
}), nil
}
func (o *Operations) PurgeQuarantine(
ctx context.Context,
scope QuarantinePurgeScope,
confirmation string,
) (string, error) {
switch scope {
case QuarantinePurgeScopeEligible:
if confirmation != QuarantineConfirmationEligible {
return "", validationError("eligible quarantine purge requires %s confirmation", QuarantineConfirmationEligible)
}
case QuarantinePurgeScopeAll:
if confirmation != QuarantineConfirmationForce {
return "", validationError("forced quarantine purge requires %s confirmation", QuarantineConfirmationForce)
}
default:
return "", validationError("unknown quarantine purge scope %q", scope)
}
if o.config.Quarantine == nil {
return "", errors.New("quarantine provider is unavailable")
}
return o.startTracked(ctx, JobKindQuarantineDelete, func(jobCtx context.Context, _ string) (map[string]any, error) {
result, err := o.config.Quarantine.Purge(jobCtx, scope, confirmation)
if result.Deleted > 0 {
o.invalidateOverview()
}
return valueMap(result), err
}), nil
}
func (o *Operations) invalidateOverview() {
if o.config.Overview != nil {
o.config.Overview.Invalidate()
@@ -319,6 +319,54 @@ func TestDeleteQuarantineInvalidatesOverviewAfterSuccess(t *testing.T) {
}
}
func TestPurgeQuarantineValidatesScopeAndConfirmation(t *testing.T) {
controller := &recordingQuarantineController{}
operations := NewOperations(OperationsConfig{Quarantine: controller})
for _, test := range []struct {
scope QuarantinePurgeScope
confirmation string
}{
{QuarantinePurgeScopeEligible, "DELETE ALL NOW"},
{QuarantinePurgeScopeAll, "DELETE ELIGIBLE"},
{"unknown", "DELETE ELIGIBLE"},
} {
if jobID, err := operations.PurgeQuarantine(context.Background(), test.scope, test.confirmation); !errors.Is(err, ErrValidation) || jobID != "" {
t.Fatalf("PurgeQuarantine(%q, %q) = (%q, %v), want validation error and no job", test.scope, test.confirmation, jobID, err)
}
}
if controller.purgeCalls != 0 {
t.Fatalf("purge calls = %d, want 0", controller.purgeCalls)
}
}
func TestPurgeQuarantineTracksPartialResultAndInvalidatesOverview(t *testing.T) {
overview := &recordingRefreshOverview{}
controller := &recordingQuarantineController{
purgeResult: QuarantinePurgeResult{
Scope: QuarantinePurgeScopeEligible, Considered: 2, Deleted: 1, DeletedBytes: 42,
Protected: 1, ProtectedBytes: 8, Failed: 0, FailedBytes: 0,
},
purgeErr: errors.New("one entry failed"),
}
tracker := jobs.NewTracker(nil, newOperationsTestLogger(t))
operations := NewOperations(OperationsConfig{
Jobs: tracker, Overview: overview, Quarantine: controller,
})
jobID, err := operations.PurgeQuarantine(context.Background(), QuarantinePurgeScopeEligible, "DELETE ELIGIBLE")
if err != nil {
t.Fatalf("PurgeQuarantine: %v", err)
}
waitForJobState(t, tracker, jobID, jobs.StateFailed)
job := tracker.Get(jobID)
if job.Result["deleted"] != float64(1) || job.Result["deleted_bytes"] != float64(42) {
t.Fatalf("job result = %#v, want partial purge result", job.Result)
}
if overview.invalidateCalls != 1 {
t.Fatalf("overview invalidations = %d, want 1", overview.invalidateCalls)
}
}
func TestAnalyzeForcesOverviewRefresh(t *testing.T) {
connection := newSQLite(t)
pool := db.NewPool(connection, connection)
@@ -369,6 +417,9 @@ func waitForJobState(t *testing.T, tracker *jobs.Tracker, id string, state jobs.
type recordingQuarantineController struct {
deleteCalls int
purgeCalls int
purgeResult QuarantinePurgeResult
purgeErr error
}
type recordingGoCacheAdopter struct{}
@@ -449,6 +500,15 @@ func (c *recordingQuarantineController) PermanentDelete(
return QuarantineEntry{}, nil
}
func (c *recordingQuarantineController) Purge(
context.Context,
QuarantinePurgeScope,
string,
) (QuarantinePurgeResult, error) {
c.purgeCalls++
return c.purgeResult, c.purgeErr
}
func newOperationsTestLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewFromZap(zap.NewNop())
@@ -0,0 +1,29 @@
package storage
type QuarantinePurgeScope string
const (
QuarantinePurgeScopeEligible QuarantinePurgeScope = "eligible"
QuarantinePurgeScopeAll QuarantinePurgeScope = "all"
QuarantineConfirmationDelete = "DELETE"
QuarantineConfirmationEligible = "DELETE ELIGIBLE"
QuarantineConfirmationForce = "DELETE ALL NOW"
)
type QuarantinePurgeFailure struct {
ID string `json:"id"`
Error string `json:"error"`
}
type QuarantinePurgeResult struct {
Scope QuarantinePurgeScope `json:"scope"`
Considered int `json:"considered"`
Deleted int `json:"deleted"`
DeletedBytes int64 `json:"deleted_bytes"`
Protected int `json:"protected"`
ProtectedBytes int64 `json:"protected_bytes"`
Failed int `json:"failed"`
FailedBytes int64 `json:"failed_bytes"`
Failures []QuarantinePurgeFailure `json:"failures,omitempty"`
}
@@ -11,6 +11,7 @@ var ErrValidation = errors.New("storage maintenance settings validation")
var (
ErrAdoptionRequired = errors.New("go cache adoption requires the dedicated endpoint")
ErrDedicatedDockerConfirmation = errors.New("dedicated Docker acknowledgement requires confirmation")
ErrForceDeleteConfirmation = errors.New("quarantine force deletion requires DELETE ALL NOW confirmation")
ErrInvalidPersistedSettings = errors.New("invalid persisted storage maintenance settings")
)
@@ -340,7 +340,28 @@ func (p *Provider) PermanentDelete(
id string,
confirmation string,
) (storage.QuarantineEntry, error) {
if confirmation != "DELETE" {
return p.permanentDelete(ctx, id, confirmation, false)
}
func (p *Provider) PermanentDeleteForce(
ctx context.Context,
id string,
confirmation string,
) (storage.QuarantineEntry, error) {
return p.permanentDelete(ctx, id, confirmation, true)
}
func (p *Provider) permanentDelete(
ctx context.Context,
id string,
confirmation string,
force bool,
) (storage.QuarantineEntry, error) {
if force {
if confirmation != storage.QuarantineConfirmationForce {
return storage.QuarantineEntry{}, storage.ErrForceDeleteConfirmation
}
} else if confirmation != storage.QuarantineConfirmationDelete {
return storage.QuarantineEntry{}, ErrDeleteConfirmation
}
if p.config.Store == nil {
@@ -353,13 +374,13 @@ func (p *Provider) PermanentDelete(
if err := p.validateEntryPaths(entry); err != nil {
return entry, err
}
if p.config.Now().Before(entry.DeleteAfter) {
if !force && p.config.Now().Before(entry.DeleteAfter) {
return entry, fmt.Errorf("%w: quarantine retention deadline has not elapsed", storage.ErrConflict)
}
if _, err := directorySizeNoFollow(entry.QuarantinePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return entry, fmt.Errorf("validate quarantined workspace: %w", err)
}
if p.config.Pruner != nil && !p.config.Now().Before(entry.DeleteAfter) {
if p.config.Pruner != nil {
if err := p.config.Pruner.PruneQuarantinedWorkspace(ctx, entry); err != nil {
_, _ = p.config.Store.TransitionQuarantineEntry(ctx, id, storage.QuarantineStateFailed, err.Error())
return entry, fmt.Errorf("prune stale Git worktree registration: %w", err)
@@ -369,7 +390,7 @@ func (p *Provider) PermanentDelete(
_, _ = p.config.Store.TransitionQuarantineEntry(ctx, id, storage.QuarantineStateFailed, err.Error())
return entry, fmt.Errorf("delete quarantined workspace: %w", err)
}
deleted, err := p.config.Store.TransitionQuarantineEntry(ctx, id, storage.QuarantineStateDeleted, "")
deleted, err := p.config.Store.TransitionQuarantineEntry(context.WithoutCancel(ctx), id, storage.QuarantineStateDeleted, "")
if err != nil {
return entry, fmt.Errorf("persist workspace deletion: %w", err)
}
@@ -574,6 +574,36 @@ func TestPermanentDeleteBeforeRetentionReturnsConflictAndKeepsQuarantine(t *test
}
}
func TestPermanentDeleteForceBypassesRetentionWithDedicatedConfirmation(t *testing.T) {
provider, root, store := newProviderFixture(t, Inventory{Complete: true}, nil)
pruner := &recordingPruner{}
provider.config.Pruner = pruner
candidate := createOwnedCandidate(t, root, "force-delete-task_abc", OwnershipMarker{
TaskID: "force-delete-task", TaskDirName: "force-delete-task_abc", LayoutVersion: LayoutVersionSemantic,
})
old := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC)
if err := os.Chtimes(candidate, old, old); err != nil {
t.Fatalf("Chtimes: %v", err)
}
if _, err := provider.Cleanup(context.Background()); err != nil {
t.Fatalf("Cleanup: %v", err)
}
entry := store.entries["entry-1"]
if _, err := provider.PermanentDeleteForce(context.Background(), entry.ID, "DELETE"); !errors.Is(err, storage.ErrForceDeleteConfirmation) {
t.Fatalf("wrong force confirmation error = %v, want %v", err, storage.ErrForceDeleteConfirmation)
}
if _, err := provider.PermanentDeleteForce(context.Background(), entry.ID, "DELETE ALL NOW"); err != nil {
t.Fatalf("PermanentDeleteForce: %v", err)
}
if pruner.calls != 1 {
t.Fatalf("pruner calls = %d, want 1", pruner.calls)
}
if _, err := os.Stat(entry.QuarantinePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("quarantine path still exists: %v", err)
}
}
func TestReconcileRecreatesMissingRecordFromQuarantineManifest(t *testing.T) {
provider, root, store := newProviderFixture(t, Inventory{Complete: true}, nil)
candidate := createOwnedCandidate(t, root, "reconcile-task_abc", OwnershipMarker{TaskID: "reconcile-task", TaskDirName: "reconcile-task_abc", LayoutVersion: LayoutVersionSemantic})
@@ -217,6 +217,10 @@ func (f *fakeStorageMutations) DeleteQuarantine(context.Context, string, string)
return "delete-job", f.deleteErr
}
func (f *fakeStorageMutations) PurgeQuarantine(context.Context, storage.QuarantinePurgeScope, string) (string, error) {
return "purge-job", nil
}
type emptyStorageOverview struct{}
func (emptyStorageOverview) Get(context.Context) (storage.OverviewSnapshot, error) {
@@ -12,14 +12,14 @@ import {
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { Input } from "@kandev/ui/input";
import type { StorageQuarantineEntry } from "@/lib/types/system";
import type { StorageQuarantineEntry, StorageQuarantinePurgeScope } from "@/lib/types/system";
type ConfirmationDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
phrase: "DEDICATED" | "ADOPT" | "DELETE";
phrase: "DEDICATED" | "ADOPT" | "DELETE" | "DELETE ELIGIBLE" | "DELETE ALL NOW";
actionLabel: string;
actionTestId: string;
destructive?: boolean;
@@ -113,3 +113,35 @@ export function PermanentDeleteDialog({
/>
);
}
export function QuarantinePurgeDialog({
scope,
eligibleCount,
protectedCount,
...props
}: Pick<ConfirmationDialogProps, "open" | "onOpenChange" | "onConfirm"> & {
scope: StorageQuarantinePurgeScope;
eligibleCount: number;
protectedCount: number;
}) {
const eligible = scope === "eligible";
return (
<ConfirmationDialog
{...props}
title={eligible ? "Clear eligible quarantine" : "Force clear all quarantine"}
description={
eligible
? `This will permanently remove ${eligibleCount} eligible item${eligibleCount === 1 ? "" : "s"}. ${protectedCount} protected item${protectedCount === 1 ? " remains" : "s remain"} until ${protectedCount === 1 ? "its" : "their"} retention deadline${protectedCount === 1 ? "" : "s"}.`
: "This attempts to permanently remove every quarantined item immediately and cannot be undone for entries that succeed. Failed entries may remain visible and retryable."
}
phrase={eligible ? "DELETE ELIGIBLE" : "DELETE ALL NOW"}
actionLabel={eligible ? "Clear eligible" : "Force clear all"}
actionTestId={
eligible
? "storage-quarantine-clear-eligible-confirm"
: "storage-quarantine-force-clear-confirm"
}
destructive
/>
);
}
@@ -277,9 +277,16 @@ export function StorageMaintenanceSettings() {
<StorageQuarantineCard
entries={controller.quarantine}
deleteJobId={controller.deleteJob?.id}
deleteJobActive={
controller.deleteJob?.state === "queued" || controller.deleteJob?.state === "running"
}
disabledReason={actionDisabledReason}
schedulingEnabled={controller.overview?.settings.enabled ?? false}
checkIntervalHours={controller.overview?.settings.check_interval_hours ?? 24}
onRestore={controller.restore}
onDelete={controller.permanentlyDelete}
onClearEligible={controller.clearEligible}
onForceClearAll={controller.forceClearAll}
/>
</div>
);
@@ -1,102 +1,232 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Badge } from "@kandev/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
import { IconRestore, IconTrash } from "@tabler/icons-react";
import type { StorageQuarantineEntry } from "@/lib/types/system";
import type { StorageQuarantineEntry, StorageQuarantinePurgeScope } from "@/lib/types/system";
import { JobProgressIndicator } from "../job-progress-indicator";
import { PermanentDeleteDialog } from "./storage-confirmation-dialogs";
import { PermanentDeleteDialog, QuarantinePurgeDialog } from "./storage-confirmation-dialogs";
import { StorageActionButton } from "./storage-action-button";
import { StorageSettingHelp } from "./storage-setting-help";
import { formatGigabytes } from "./storage-units";
import {
formatQuarantineDeadline,
isQuarantineEligible,
quarantineCounts,
quarantineDeleteAfter,
} from "./storage-quarantine";
const MAX_TIMER_DELAY_MS = 2_147_483_647;
type Props = {
entries: StorageQuarantineEntry[];
deleteJobId?: string;
deleteJobActive?: boolean;
disabledReason?: string;
schedulingEnabled: boolean;
checkIntervalHours: number;
onRestore: (id: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onClearEligible: () => Promise<void>;
onForceClearAll: () => Promise<void>;
};
function QuarantineEntryRow({
entry,
now,
disabledReason,
onRestore,
onDelete,
}: {
entry: StorageQuarantineEntry;
now: Date;
disabledReason?: string;
onRestore: (id: string) => Promise<void>;
onDelete: (entry: StorageQuarantineEntry) => void;
}) {
const eligible = isQuarantineEligible(entry, now);
return (
<div className="min-w-0 rounded-lg border p-3" data-testid={`storage-quarantine-${entry.id}`}>
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{entry.resource_type.replace("_", " ")}</Badge>
<span className="text-xs text-muted-foreground">
{formatGigabytes(entry.size_bytes)}
</span>
</div>
<p className="break-all font-mono text-xs">{entry.original_path}</p>
<p className="break-all text-[11px] text-muted-foreground">
Trash: {entry.quarantine_path}
</p>
{entry.last_error && (
<p className="break-words text-xs text-red-500">{entry.last_error}</p>
)}
<p className="text-xs text-muted-foreground">
{eligible ? (
<span className="text-emerald-600">Eligible now</span>
) : (
<>
Protected until{" "}
<time dateTime={entry.delete_after}>{formatQuarantineDeadline(entry)}</time>
</>
)}
</p>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row">
<StorageActionButton
variant="outline"
disabledReason={disabledReason}
onClick={() => void onRestore(entry.id)}
data-testid={`storage-quarantine-${entry.id}-restore`}
>
<IconRestore className="size-4" /> Restore
</StorageActionButton>
<StorageActionButton
variant="destructive"
disabledReason={
disabledReason ??
(eligible ? undefined : `Retention ends ${formatQuarantineDeadline(entry)}.`)
}
onClick={() => onDelete(entry)}
data-testid={`storage-quarantine-${entry.id}-delete`}
>
<IconTrash className="size-4" /> Delete
</StorageActionButton>
</div>
</div>
</div>
);
}
function QuarantineHeader({
entries,
counts,
deleteJobId,
bulkDisabledReason,
schedulingEnabled,
checkIntervalHours,
onPurge,
}: {
entries: StorageQuarantineEntry[];
counts: ReturnType<typeof quarantineCounts>;
deleteJobId?: string;
bulkDisabledReason?: string;
schedulingEnabled: boolean;
checkIntervalHours: number;
onPurge: (scope: StorageQuarantinePurgeScope) => void;
}) {
return (
<CardHeader>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-1 text-base">
Quarantine
<StorageSettingHelp label="Quarantine">
Quarantine is Kandev's recoverable holding area. Orphan task workspaces and rotated Go
caches are moved here before deletion. You can restore an item during its retention
period; after the deadline, a later maintenance run may permanently delete it.
</StorageSettingHelp>
</CardTitle>
<JobProgressIndicator
kind="storage-quarantine-delete"
jobId={deleteJobId}
successLabel="Deletion complete"
testId="storage-delete-job"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<StorageActionButton
variant="outline"
className="w-full sm:w-auto"
disabled={counts.eligible === 0 || entries.length === 0}
disabledReason={
bulkDisabledReason ??
(counts.eligible === 0 ? "No quarantine entries are eligible yet." : undefined)
}
onClick={() => onPurge("eligible")}
data-testid="storage-quarantine-clear-eligible"
>
Clear eligible
</StorageActionButton>
<StorageActionButton
variant="destructive"
className="w-full sm:w-auto"
disabled={entries.length === 0}
disabledReason={bulkDisabledReason}
onClick={() => onPurge("all")}
data-testid="storage-quarantine-force-clear"
>
Force clear all
</StorageActionButton>
</div>
<CardDescription>
Cleanup moves recoverable data here first instead of deleting it immediately. Restore an
item if you still need it, or delete it permanently when you are certain. {counts.eligible}{" "}
eligible now · {counts.protected} protected
</CardDescription>
<p className="text-xs text-muted-foreground" data-testid="storage-quarantine-schedule-copy">
{schedulingEnabled
? `Eligible entries are removed by the first successful maintenance run after their deadline (every ${checkIntervalHours} hours when the idle gate allows it).`
: "Automatic quarantine cleanup is off. Run maintenance manually or use a quarantine action when you are ready."}
</p>
</CardHeader>
);
}
export function StorageQuarantineCard({
entries,
deleteJobId,
deleteJobActive,
disabledReason,
schedulingEnabled,
checkIntervalHours,
onRestore,
onDelete,
onClearEligible,
onForceClearAll,
}: Props) {
const [deleteEntry, setDeleteEntry] = useState<StorageQuarantineEntry | null>(null);
const [purgeScope, setPurgeScope] = useState<StorageQuarantinePurgeScope | null>(null);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const nextDeadline = entries
.map(quarantineDeleteAfter)
.map((deadline) => deadline.getTime())
.filter((deadline) => deadline > now.getTime())
.sort((left, right) => left - right)[0];
if (nextDeadline === undefined) return;
const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nextDeadline - now.getTime() + 1));
const timer = setTimeout(() => setNow(new Date()), delay);
return () => clearTimeout(timer);
}, [entries, now]);
const counts = quarantineCounts(entries, now);
const bulkDisabledReason =
disabledReason ?? (deleteJobActive ? "A quarantine cleanup is still running." : undefined);
return (
<Card className="min-w-0" data-testid="storage-quarantine-card">
<CardHeader>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-1 text-base">
Quarantine
<StorageSettingHelp label="Quarantine">
Quarantine is Kandev's recoverable holding area. Orphan task workspaces and rotated Go
caches are moved here before deletion. You can restore an item during its retention
period; after the deadline, a later maintenance run may permanently delete it.
</StorageSettingHelp>
</CardTitle>
<JobProgressIndicator
kind="storage-quarantine-delete"
jobId={deleteJobId}
successLabel="Deletion complete"
testId="storage-delete-job"
/>
</div>
<CardDescription>
Cleanup moves recoverable data here first instead of deleting it immediately. Restore an
item if you still need it, or delete it permanently when you are certain.
</CardDescription>
</CardHeader>
<QuarantineHeader
entries={entries}
counts={counts}
deleteJobId={deleteJobId}
bulkDisabledReason={bulkDisabledReason}
schedulingEnabled={schedulingEnabled}
checkIntervalHours={checkIntervalHours}
onPurge={setPurgeScope}
/>
<CardContent className="space-y-3">
{entries.length === 0 && (
<p className="text-sm text-muted-foreground">No restorable quarantined resources.</p>
)}
{entries.map((entry) => (
<div
<QuarantineEntryRow
key={entry.id}
className="min-w-0 rounded-lg border p-3"
data-testid={`storage-quarantine-${entry.id}`}
>
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{entry.resource_type.replace("_", " ")}</Badge>
<span className="text-xs text-muted-foreground">
{formatGigabytes(entry.size_bytes)}
</span>
</div>
<p className="break-all font-mono text-xs">{entry.original_path}</p>
<p className="break-all text-[11px] text-muted-foreground">
Trash: {entry.quarantine_path}
</p>
{entry.last_error && (
<p className="break-words text-xs text-red-500">{entry.last_error}</p>
)}
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row">
<StorageActionButton
variant="outline"
disabledReason={disabledReason}
onClick={() => void onRestore(entry.id)}
data-testid={`storage-quarantine-${entry.id}-restore`}
>
<IconRestore className="size-4" /> Restore
</StorageActionButton>
<StorageActionButton
variant="destructive"
disabledReason={disabledReason}
onClick={() => setDeleteEntry(entry)}
data-testid={`storage-quarantine-${entry.id}-delete`}
>
<IconTrash className="size-4" /> Delete
</StorageActionButton>
</div>
</div>
</div>
entry={entry}
now={now}
disabledReason={bulkDisabledReason}
onRestore={onRestore}
onDelete={setDeleteEntry}
/>
))}
</CardContent>
<PermanentDeleteDialog
@@ -110,6 +240,22 @@ export function StorageQuarantineCard({
setDeleteEntry(null);
}}
/>
{purgeScope && (
<QuarantinePurgeDialog
scope={purgeScope}
eligibleCount={counts.eligible}
protectedCount={counts.protected}
open
onOpenChange={(open) => {
if (!open) setPurgeScope(null);
}}
onConfirm={() => {
if (purgeScope === "eligible") void onClearEligible();
else void onForceClearAll();
setPurgeScope(null);
}}
/>
)}
</Card>
);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { StorageQuarantineEntry } from "@/lib/types/system";
import { isQuarantineEligible, quarantineCounts } from "./storage-quarantine";
const entry = (id: string, deleteAfter: string, bytes: number): StorageQuarantineEntry => ({
id,
resource_type: "task_workspace",
original_path: `/tmp/${id}`,
quarantine_path: `/tmp/trash/${id}`,
size_bytes: bytes,
state: "quarantined",
quarantined_at: "2026-07-29T00:00:00Z",
delete_after: deleteAfter,
last_error: "",
metadata: {},
});
describe("storage quarantine eligibility", () => {
const now = new Date("2026-07-29T12:00:00Z");
it("treats the retention deadline as inclusive", () => {
expect(isQuarantineEligible(entry("at", "2026-07-29T12:00:00Z", 1), now)).toBe(true);
expect(isQuarantineEligible(entry("later", "2026-07-29T12:00:01Z", 1), now)).toBe(false);
});
it("aggregates eligible and protected counts and bytes", () => {
expect(
quarantineCounts(
[entry("old", "2026-07-28T12:00:00Z", 10), entry("new", "2026-07-30T12:00:00Z", 20)],
now,
),
).toEqual({ eligible: 1, eligibleBytes: 10, protected: 1, protectedBytes: 20 });
});
});
@@ -0,0 +1,32 @@
import type { StorageQuarantineEntry } from "@/lib/types/system";
export function quarantineDeleteAfter(entry: StorageQuarantineEntry): Date {
return new Date(entry.delete_after);
}
export function isQuarantineEligible(entry: StorageQuarantineEntry, now = new Date()): boolean {
return quarantineDeleteAfter(entry).getTime() <= now.getTime();
}
export function quarantineCounts(entries: StorageQuarantineEntry[], now = new Date()) {
return entries.reduce(
(counts, entry) => {
if (isQuarantineEligible(entry, now)) {
counts.eligible += 1;
counts.eligibleBytes += entry.size_bytes;
} else {
counts.protected += 1;
counts.protectedBytes += entry.size_bytes;
}
return counts;
},
{ eligible: 0, eligibleBytes: 0, protected: 0, protectedBytes: 0 },
);
}
export function formatQuarantineDeadline(entry: StorageQuarantineEntry): string {
return quarantineDeleteAfter(entry).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
@@ -1,4 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import type { Route } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { seedManagedGoCache } from "../../helpers/storage-maintenance";
@@ -173,4 +174,59 @@ test.describe("Mobile storage maintenance", () => {
await testPage.unroute(overviewPattern, holdOverview);
}
});
test("keeps both quarantine cleanup actions reachable on a phone", async ({
testPage,
prCapture,
}) => {
const entry = {
id: "mobile-protected",
resource_type: "task_workspace",
original_path: "/tmp/mobile-protected",
quarantine_path: "/tmp/trash/mobile-protected",
size_bytes: 1024,
state: "quarantined",
quarantined_at: "2026-07-29T00:00:00Z",
delete_after: new Date(Date.now() + 86_400_000).toISOString(),
last_error: "",
metadata: {},
};
await testPage.route("**/api/v1/system/storage/quarantine", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ entries: [entry] }),
});
return;
}
expect(route.request().method()).toBe("DELETE");
expect(route.request().postDataJSON()).toEqual({ scope: "all", confirm: "DELETE ALL NOW" });
await route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({ job_id: "mobile-force-purge" }),
});
});
await testPage.goto("/settings/system/storage");
await expect(testPage.getByTestId("storage-quarantine-force-clear")).toBeVisible();
await testPage.getByTestId("storage-quarantine-card").scrollIntoViewIfNeeded();
await prCapture.screenshot("quarantine-actions", {
caption: "Mobile quarantine keeps both cleanup actions reachable",
});
if (process.env.CAPTURE_PR_ASSETS) {
await testPage.getByTestId("storage-quarantine-card").screenshot({
path: path.resolve(process.cwd(), ".pr-assets/mobile-quarantine-card.png"),
});
}
await testPage.getByTestId("storage-quarantine-force-clear").tap();
await testPage
.getByTestId("storage-quarantine-force-clear-confirm-confirmation")
.fill("DELETE ALL NOW");
await testPage.getByTestId("storage-quarantine-force-clear-confirm").tap();
await expect(testPage.getByText("Forced quarantine cleanup started")).toBeVisible();
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
});
});
@@ -252,4 +252,75 @@ test.describe("System storage maintenance", () => {
{ timeout: 20_000 },
);
});
test("shows quarantine deadlines and clears only eligible entries", async ({
testPage,
prCapture,
}) => {
const entries = [
{
id: "eligible-entry",
resource_type: "task_workspace",
original_path: "/tmp/eligible",
quarantine_path: "/tmp/trash/eligible",
size_bytes: 1024,
state: "quarantined",
quarantined_at: "2026-07-20T00:00:00Z",
delete_after: new Date(Date.now() - 60_000).toISOString(),
last_error: "",
metadata: {},
},
{
id: "protected-entry",
resource_type: "task_workspace",
original_path: "/tmp/protected",
quarantine_path: "/tmp/trash/protected",
size_bytes: 2048,
state: "quarantined",
quarantined_at: "2026-07-29T00:00:00Z",
delete_after: new Date(Date.now() + 86_400_000).toISOString(),
last_error: "",
metadata: {},
},
];
await testPage.route("**/api/v1/system/storage/quarantine", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ entries }),
});
return;
}
expect(route.request().method()).toBe("DELETE");
expect(route.request().postDataJSON()).toEqual({
scope: "eligible",
confirm: "DELETE ELIGIBLE",
});
await route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({ job_id: "eligible-purge" }),
});
});
await testPage.goto("/settings/system/storage");
await expect(
testPage.getByTestId("storage-quarantine-eligible-entry").getByText("Eligible now"),
).toBeVisible();
await expect(testPage.getByText(/Protected until/)).toBeVisible();
await expect(testPage.getByTestId("storage-quarantine-protected-entry-delete")).toBeDisabled();
await testPage.getByTestId("storage-quarantine-card").scrollIntoViewIfNeeded();
await prCapture.screenshot("quarantine-deadlines", {
caption: "Desktop quarantine shows eligibility and protected retention deadlines",
});
await testPage.getByTestId("storage-quarantine-clear-eligible").click();
await testPage
.getByTestId("storage-quarantine-clear-eligible-confirm-confirmation")
.fill("DELETE ELIGIBLE");
await testPage.getByTestId("storage-quarantine-clear-eligible-confirm").click();
await expect(testPage.getByText("Eligible quarantine cleanup started")).toBeVisible();
await prCapture.screenshot("quarantine-clear-eligible", {
caption: "Desktop typed confirmation starts eligible-only quarantine cleanup",
});
});
});
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
adopt: vi.fn(),
analyze: vi.fn(),
deleteEntry: vi.fn(),
purge: vi.fn(),
fetchJob: vi.fn(),
fetchOverview: vi.fn(),
fetchQuarantine: vi.fn(),
@@ -27,6 +28,7 @@ vi.mock("@/lib/api/domains/system-api", () => ({
adoptStorageGoCache: mocks.adopt,
analyzeStorage: mocks.analyze,
deleteStorageQuarantine: mocks.deleteEntry,
purgeStorageQuarantine: mocks.purge,
fetchSystemJob: mocks.fetchJob,
fetchStorageOverview: mocks.fetchOverview,
fetchStorageQuarantine: mocks.fetchQuarantine,
@@ -116,6 +118,7 @@ beforeEach(() => {
mocks.save.mockResolvedValue({ settings });
// Keep cleanup jobs deterministic for controller action tests.
mocks.run.mockResolvedValue({ job_id: cleanupJobId });
mocks.purge.mockResolvedValue({ job_id: "purge-job" });
});
describe("useStorageMaintenance", () => {
@@ -140,6 +143,19 @@ describe("useStorageMaintenance", () => {
});
});
it("starts eligible and forced quarantine bulk jobs", async () => {
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
await waitFor(() => expect(result.current.overview).toEqual(overview));
await act(async () => {
await result.current.clearEligible();
await result.current.forceClearAll();
});
expect(mocks.purge).toHaveBeenNthCalledWith(1, "eligible");
expect(mocks.purge).toHaveBeenNthCalledWith(2, "all");
});
it("rejects failed saves so the settings coordinator can keep the draft dirty", async () => {
mocks.save.mockRejectedValueOnce(new Error("save unavailable"));
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
@@ -19,6 +19,7 @@ import {
fetchStorageOverview,
fetchStorageQuarantine,
fetchStorageRuns,
purgeStorageQuarantine,
restoreStorageQuarantine,
runStorageMaintenance,
saveStorageSettings,
@@ -27,6 +28,7 @@ import type {
StorageBusyResource,
StorageBusyResponse,
StorageMaintenanceSettings,
StorageQuarantinePurgeScope,
SystemJob,
} from "@/lib/types/system";
import { useSystemJob } from "./use-system-jobs";
@@ -39,6 +41,7 @@ export type StoragePendingAction =
| "adopt"
| "restore"
| "delete"
| "purge"
| null;
export interface StorageBusyState {
@@ -189,6 +192,31 @@ function useStorageDeleteAction(
);
}
function useStorageBulkDeleteAction(
perform: ReturnType<typeof useStorageActionRunner>["perform"],
toast: ReturnType<typeof useToast>["toast"],
clearBusy: () => void,
setDeleteJobId: Dispatch<SetStateAction<string | null>>,
) {
return useCallback(
async (scope: StorageQuarantinePurgeScope) => {
clearBusy();
return perform("purge", async () => {
const accepted = await purgeStorageQuarantine(scope);
setDeleteJobId(accepted.job_id);
toast({
title:
scope === "eligible"
? "Eligible quarantine cleanup started"
: "Forced quarantine cleanup started",
variant: "success",
});
});
},
[clearBusy, perform, setDeleteJobId, toast],
);
}
function useStorageActions(reload: Reload) {
const { toast } = useToast();
const { pendingAction, error, setError, finishLoading, perform } = useStorageActionRunner();
@@ -265,6 +293,7 @@ function useStorageActions(reload: Reload) {
[clearBusy, perform, reload, toast],
);
const permanentlyDelete = useStorageDeleteAction(perform, toast, clearBusy, setDeleteJobId);
const purge = useStorageBulkDeleteAction(perform, toast, clearBusy, setDeleteJobId);
return {
pendingAction,
@@ -279,6 +308,8 @@ function useStorageActions(reload: Reload) {
adopt,
restore,
permanentlyDelete,
clearEligible: useCallback(() => purge("eligible"), [purge]),
forceClearAll: useCallback(() => purge("all"), [purge]),
analysisJob,
cleanupJob,
deleteJob,
@@ -33,6 +33,7 @@ import {
fetchStorageOverview,
fetchStorageQuarantine,
fetchStorageRuns,
purgeStorageQuarantine,
restoreStorageQuarantine,
runStorageMaintenance,
saveStorageSettings,
@@ -380,6 +381,24 @@ describe("storage maintenance", () => {
expect(response.job_id).toBe("delete-job");
});
it("uses typed confirmations for bulk quarantine purge", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "eligible-job" }));
await purgeStorageQuarantine("eligible");
expect(lastCall().url).toBe(`${BASE}/storage/quarantine`);
expect(method()).toBe("DELETE");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
scope: "eligible",
confirm: "DELETE ELIGIBLE",
});
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "all-job" }));
await purgeStorageQuarantine("all");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
scope: "all",
confirm: "DELETE ALL NOW",
});
});
it("starts analysis, selected cleanup, and restore operations", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ job_id: "analysis" }))
+18
View File
@@ -16,6 +16,7 @@ import type {
StorageMaintenanceSettings,
StorageOverviewResponse,
StorageQuarantineEntry,
StorageQuarantinePurgeScope,
StorageSettingsResponse,
} from "@/lib/types/system";
@@ -311,3 +312,20 @@ export function deleteStorageQuarantine(
},
);
}
export function purgeStorageQuarantine(
scope: StorageQuarantinePurgeScope,
options?: ApiRequestOptions,
): Promise<JobAcceptResponse> {
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/storage/quarantine`, {
...options,
init: {
...(options?.init ?? {}),
method: "DELETE",
body: JSON.stringify({
scope,
confirm: scope === "eligible" ? "DELETE ELIGIBLE" : "DELETE ALL NOW",
}),
},
});
}
+19
View File
@@ -298,6 +298,25 @@ export interface StorageQuarantineEntry {
metadata: Record<string, unknown>;
}
export type StorageQuarantinePurgeScope = "eligible" | "all";
export interface StorageQuarantinePurgeFailure {
id: string;
error: string;
}
export interface StorageQuarantinePurgeResult {
scope: StorageQuarantinePurgeScope;
considered: number;
deleted: number;
deleted_bytes: number;
protected: number;
protected_bytes: number;
failed: number;
failed_bytes: number;
failures?: StorageQuarantinePurgeFailure[];
}
export interface StorageOverviewResponse {
settings: StorageMaintenanceSettings;
capabilities: StorageCapabilities;
@@ -0,0 +1,58 @@
# ADR-2026-07-29-quarantine-retention-override: Make Quarantine Retention Overridable but Visible
**Status:** accepted
**Date:** 2026-07-29
**Area:** backend, frontend
## Context
Storage maintenance atomically moves owned task workspaces and Go caches into quarantine, but the
initial implementation enforced each entry's retention deadline without showing that deadline in
the Storage page. It also exposed an enabled delete action before the deadline and described
automatic deletion that no maintenance provider performed. Operators need predictable automatic
reclamation and a deliberate way to recover disk space immediately when they accept losing the
restore window.
## Decision
The persisted `storage_quarantine_entries.delete_after` timestamp remains the normal permanent
deletion boundary. Individual deletion, **Clear eligible**, and scheduled or full manual storage
maintenance delete only entries at or after that timestamp.
The Storage page shows the deletion-eligibility timestamp and whether automatic cleanup is enabled.
Scheduled maintenance and full manual **Run now** include a typed quarantine provider that
permanently deletes eligible entries. When scheduling is disabled, no separate quarantine sweeper
runs; eligible entries remain until a full manual run or an explicit quarantine action.
Install operators also receive a separate **Force clear all** action. It requires the stronger
server-validated confirmation phrase `DELETE ALL NOW` and may permanently delete active quarantine
entries before their retention deadlines. This override bypasses only the retention clock. Resource
ownership, path containment, symlink, state-transition, and Git worktree-pruning safety checks
remain mandatory.
Bulk deletion is best-effort across entries. Its durable System job reports deleted, protected, and
failed counts and bytes; any entry that fails remains visible and retryable.
## Consequences
- Operators can see the earliest safe deletion time and understand whether reclamation is automatic
or requires a manual action.
- Normal maintenance restores the intended bounded quarantine lifecycle instead of retaining
expired entries indefinitely.
- A deliberate install-wide override can reclaim all quarantine space immediately, at the cost of
losing protected restore windows.
- Disabling scheduled maintenance also disables automatic quarantine deletion, so the UI and
public documentation must make that dependency explicit.
- The force action needs stronger confirmation and regression coverage proving it cannot bypass
ownership or path-safety validation.
## Alternatives Considered
- **Never allow retention bypass.** Rejected because an operator facing disk exhaustion needs an
explicit way to trade recovery for immediate reclamation.
- **Make every delete action bypass retention after confirmation.** Rejected because it makes the
normal row action undermine the configured safety policy.
- **Run a dedicated quarantine sweeper while scheduled maintenance is disabled.** Rejected because
disabling destructive scheduling should stop unattended permanent deletion.
- **Keep quarantine deletion manual-only.** Rejected because expired data would continue to
accumulate despite an enabled storage-maintenance schedule.
+1
View File
@@ -86,3 +86,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-07-28-visible-wip-overflow-queues | [Separate Visible Queueing From WIP Admission](2026-07-28-visible-wip-overflow-queues.md) | proposed | backend, frontend, protocol, workflow | 2026-07-28 |
| 2026-07-28-coarse-running-busy-signal | [Restore Coarse Running Prompt Admission](2026-07-28-coarse-running-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-28 |
| 2026-07-29-agent-stall-user-controlled-recovery | [Keep Agent Stall Recovery User Controlled](2026-07-29-agent-stall-user-controlled-recovery.md) | accepted | backend, frontend, protocol | 2026-07-29 |
| 2026-07-29-quarantine-retention-override | [Make Quarantine Retention Overridable but Visible](2026-07-29-quarantine-retention-override.md) | accepted | backend, frontend | 2026-07-29 |
+230
View File
@@ -0,0 +1,230 @@
---
spec: docs/specs/system-page/storage-maintenance.md
created: 2026-07-29
status: complete
---
# Implementation Plan: Quarantine Lifecycle
## Overview
Complete the quarantine lifecycle by adding a typed best-effort purge operation, running eligible
purges during full maintenance, and exposing separate retention-respecting and retention-bypassing
bulk actions. Then wire the API through the existing System job flow, make eligibility and
automatic-cleanup timing visible on desktop and mobile, update operator documentation, and prove the
flows with focused backend, frontend, and Playwright coverage.
No schema migration is required: `storage_quarantine_entries.delete_after`, state, size, and error
fields already contain the durable data needed for eligibility and result accounting.
---
## Backend
### Typed purge result and deletion modes
- Add `apps/backend/internal/system/storage/quarantine_purge.go` with
`QuarantinePurgeScope` (`eligible`, `all`), `QuarantinePurgeFailure`, and
`QuarantinePurgeResult`.
- Extend the backend quarantine controller contract with a bulk purge method that returns considered,
deleted, protected, and failed counts/bytes plus per-entry failures.
- Iterate all active `quarantined|failed` entries deterministically. Eligible-only purge skips future
`delete_after` entries; force purge attempts every active entry.
- Continue after entry failures and return the complete result together with an aggregate error so
successful deletions remain committed while the System job or maintenance run is visibly failed.
### Resource deletion safety
- Refactor `apps/backend/internal/system/storage/workspaces/provider.go` so normal and forced
permanent deletion share path, state, pruning, and filesystem safety validation. The force path
skips only the retention comparison.
- Refactor Go-cache deletion in
`apps/backend/internal/backendapp/storage_maintenance.go` through the same retention-mode boundary.
Preserve ownership metadata validation, managed-trash containment, replacement-path ambiguity
checks, and state transitions for both modes.
- Require `DELETE ALL NOW` at the external force boundary; never use the generic maintenance
`force` flag as a retention override.
### Maintenance provider composition
- Create the `workspaceQuarantineController` before assembling cleanup providers in
`apps/backend/internal/backendapp/storage_maintenance.go`.
- Add a typed `quarantine` cleanup provider to `storageCleanupProviders`. Its ordinary `Cleanup`
purges only eligible entries and returns `QuarantinePurgeResult` in the maintenance-run result.
- Include the provider in scheduled and full manual runs. Existing explicit selections such as
`resources: ["go_cache"]` continue to select only the named provider and do not purge unrelated
quarantine entries.
- Keep the provider inside the existing maintenance activity lease so task admission can preempt a
long purge. Provider errors remain isolated by `Runner.runProviders`.
### Bulk HTTP operation
- Extend `apps/backend/internal/system/storage/operations.go` with
`PurgeQuarantine(ctx, scope, confirmation)`, validate the scope/phrase pair before starting work,
and run it as `storage-quarantine-delete`.
- Register `DELETE /api/v1/system/storage/quarantine` in
`apps/backend/internal/system/storage/handler.go` with body
`{scope, confirm}`. Preserve `DELETE /quarantine/:id` and its retention `409`.
- Return `400` for unknown scopes or mismatched confirmation phrases. A started bulk purge returns
`202 {job_id}` and publishes its structured result through the existing System job tracker.
- Invalidate the cached storage overview after any successful deletion, including a partially
successful job.
---
## Frontend
### Types, API client, and domain controller
- Add the bulk scope/result types to `apps/web/lib/types/system.ts`.
- Add `purgeStorageQuarantine(scope)` to
`apps/web/lib/api/domains/system-api.ts`, mapping `eligible` to `DELETE ELIGIBLE` and `all` to
`DELETE ALL NOW`.
- Extend `apps/web/hooks/domains/system/use-storage-maintenance.ts` with one shared bulk-delete job
path, terminal refresh, success/error feedback, and actions for `clearEligible` and
`forceClearAll`.
- Keep individual deletion wired to `DELETE`; the UI will only enable that action for entries whose
deadline has elapsed.
### Quarantine card
- Update `apps/web/components/settings/system/storage/storage-quarantine-card.tsx` to derive and
display `eligible now` versus `protected until <localized timestamp>` from each persisted
`delete_after` value using semantic `<time dateTime>` markup.
- Show card-level eligible/protected counts and explain:
- scheduling enabled: eligible entries are removed by the first successful maintenance run after
their deadline, subject to the idle gate;
- scheduling disabled: automatic cleanup is off and a full manual run or quarantine action is
required.
- Disable the per-row Delete action before its deadline with a visible disabled reason.
- Add **Clear eligible** and **Force clear all** actions. Disable **Clear eligible** when no entry is
eligible and disable both when the quarantine list is empty or another conflicting storage action
is active.
- Extend `storage-confirmation-dialogs.tsx` with separate bulk confirmations:
`DELETE ELIGIBLE` names how many eligible items will be removed and how many remain protected;
`DELETE ALL NOW` warns that every restore window will be lost.
- Wire scheduling state and the two controller actions through
`storage-maintenance-settings.tsx`.
### Mobile design contract
- **Desktop outcome and mobile entry:** both use **Settings → System → Storage** and provide row
deadlines, eligible/protected totals, individual restore/delete, **Clear eligible**, and
**Force clear all**.
- **Nearest shipped exemplar:** the current `StorageQuarantineCard`, responsive Storage page, typed
`ConfirmationDialog`, and `mobile-storage-maintenance.spec.ts`.
- **Phone hierarchy and primary action:** keep the inline quarantine card as the single vertical
scroll surface; show status immediately under each path, then full-width row actions. Put the safe
**Clear eligible** action before the destructive force action.
- **Presentation choice:** use the existing viewport-contained `AlertDialog` for the short typed
confirmations. A drawer would add navigation depth without improving this focused irreversible
decision.
- **Geometry:** the page remains the only scroll owner, action controls are at least 44 pixels high,
dialogs remain within the dynamic viewport, long paths wrap, and document horizontal overflow
stays zero. No fixed mobile control is introduced, so additional safe-area positioning is not
required.
- **Shared behavior:** eligibility derivation, counts, controller actions, API calls, job tracking,
and confirmation semantics are shared across viewports; only responsive layout classes differ.
---
## Public Documentation
- Update `docs/public/operations.md` to explain the retention deadline, automatic purge dependency
on scheduled/full manual maintenance, **Clear eligible**, and the irreversible
**Force clear all** override.
- State that `delete_after` is an earliest safe deletion time, not a guaranteed exact deletion
instant, because idle-gate admission and preemption can delay a run.
---
## Tests
- **Eligible versus forced resource deletion**
- **Files:** `apps/backend/internal/system/storage/workspaces/provider_test.go`,
`apps/backend/internal/backendapp/storage_maintenance_test.go`
- **How:** table-driven tests prove normal deletion rejects future deadlines, force deletion
bypasses only the deadline, and both modes retain path/ownership/pruner validation.
- **Best-effort bulk result**
- **File:** `apps/backend/internal/backendapp/storage_maintenance_test.go`
- **How:** real temporary workspace/Go-cache entries cover eligible, protected, and invalid
payloads; assert counts/bytes, aggregate error, retained failures, and committed successes.
- **Automatic maintenance purge**
- **Files:** `apps/backend/internal/backendapp/storage_maintenance_test.go`,
`apps/backend/internal/system/storage/runner_test.go`
- **How:** production composition and runner tests prove full runs invoke `quarantine`, explicit
`go_cache` selection does not, and a purge failure does not prevent later providers.
- **Bulk API validation and job result**
- **Files:** `apps/backend/internal/system/storage/handler_test.go`,
`apps/backend/internal/system/storage/operations_test.go`
- **How:** HTTP tests cover both scope/confirmation pairs and `400` mismatches; operations tests
wait for the tracked job and assert result, overview invalidation, and partial failure state.
- **Frontend API/controller**
- **Files:** `apps/web/lib/api/domains/system-api.test.ts`,
`apps/web/hooks/domains/system/use-storage-maintenance.test.tsx`
- **How:** assert exact request bodies, job tracking, terminal reload, and action-specific toasts.
- **Eligibility and responsive card**
- **Files:** new
`apps/web/components/settings/system/storage/storage-quarantine-card.test.tsx`,
existing `storage-maintenance-settings.test.tsx`
- **How:** fake current time around the deadline; assert semantic timestamps, counts, scheduling
copy, disabled row deletion, both confirmation phrases, and controller wiring.
---
## E2E Tests
- **Desktop protected lifecycle**
- **File:** `apps/web/e2e/tests/system/storage-maintenance.spec.ts`
- **Scenario:** quarantine a real orphan workspace, verify its protected deadline and disabled
row delete, then confirm **Force clear all** and assert the quarantine payload and filesystem
data are removed.
- **Desktop eligible bulk contract**
- **File:** `apps/web/e2e/tests/system/storage-maintenance.spec.ts`
- **Scenario:** present eligible and protected entries, confirm **Clear eligible**, and verify the
completed-job feedback plus the protected remainder. Backend package tests provide the
time-controlled filesystem integration when E2E setup cannot age a persisted entry safely.
- **Mobile capability and geometry**
- **File:** `apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`
- **Scenario:** review eligibility, complete both typed bulk-confirmation paths through touch
targets, and assert no document horizontal overflow.
Final E2E verification rebuilds production artifacts through:
```bash
cd apps/web && pnpm e2e:run tests/system/storage-maintenance.spec.ts tests/system/mobile-storage-maintenance.spec.ts
```
## Validation Evidence
- Backend: `go test ./internal/system/... ./internal/backendapp` passed, including the storage
system, job tracker, route, and maintenance packages.
- Frontend: 63 focused Vitest tests, `pnpm run typecheck`, and `pnpm run lint` passed.
- Documentation: public-doc validation tests and the validator passed.
- E2E: focused desktop Chromium and mobile-chrome quarantine flows passed against the production
bundle; the full PR E2E workflow also passed.
- Review follow-up: cancellation, deadline refresh, shared confirmation constants, partial failed
job result documentation, and force-clear failure wording are covered by the remediation commit.
---
## Implementation Waves and Task Status
All tasks are sequential because they extend the same purge contract and job/UI flow.
- [x] [Task 01 — Backend purge engine](task-01-backend-purge-engine.md)
- [x] [Task 02 — Bulk quarantine API](task-02-bulk-quarantine-api.md)
- [x] [Task 03 — Frontend quarantine domain](task-03-frontend-quarantine-domain.md)
- [x] [Task 04 — Responsive quarantine UI](task-04-responsive-quarantine-ui.md)
- [x] [Task 05 — Operator documentation](task-05-operator-documentation.md)
- [x] [Task 06 — Desktop and mobile E2E](task-06-storage-quarantine-e2e.md)
## Risks
- Force deletion must remain a retention-only override; sharing a boolean too low in the filesystem
layer could accidentally skip unrelated validation.
- Bulk jobs can partially succeed. Job state, result details, cached overview invalidation, and UI
reload must agree so deleted rows do not linger and failed rows remain actionable.
- Scheduled deletion time is conditional on the idle gate. UI copy must show the exact eligibility
timestamp without promising a run time the scheduler cannot guarantee.
- A resource-specific maintenance selection must not silently acquire the new quarantine provider.
@@ -0,0 +1,64 @@
---
id: "01-backend-purge-engine"
title: "Backend purge engine"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 01: Backend purge engine
Implement the typed quarantine purge engine and add it to full storage maintenance.
## Acceptance
- Eligible purge deletes only entries at or after `delete_after`; forced purge attempts all active
entries while bypassing no ownership, path, state, symlink, or pruner validation.
- Purge continues across entry failures and returns complete deleted/protected/failed counts and
bytes while leaving failed entries retryable.
- Scheduled and full manual runs include the `quarantine` provider; explicit resource selections
remain limited to the selected provider.
## Verification
```bash
make -C apps/backend test
```
Completed: `go test ./internal/system/... ./internal/backendapp` passed before PR publication;
the remediation reruns the focused storage packages after the review fixes.
## Files likely touched
- `apps/backend/internal/system/storage/quarantine_purge.go`
- `apps/backend/internal/system/storage/workspaces/provider.go`
- `apps/backend/internal/system/storage/workspaces/provider_test.go`
- `apps/backend/internal/backendapp/storage_maintenance.go`
- `apps/backend/internal/backendapp/storage_maintenance_test.go`
- `apps/backend/internal/system/storage/runner_test.go`
## Dependencies
None.
## Parallelism
Sequential; Task 02 consumes the purge types and controller contract.
## Inputs
- Spec: quarantine behavior, state machine, failure modes, and persistence guarantees
- Plan: Typed purge result, resource deletion safety, and maintenance provider composition
- ADR: `docs/decisions/2026-07-29-quarantine-retention-override.md`
## Risks
- Retention bypass must not flow into path/ownership validation.
- Provider cancellation must leave unprocessed entries active and retryable.
## Output contract
Report purge types and dispatch, provider ordering/selection behavior, files changed, exact test
results, blockers/risks, and update this task plus `plan.md` status.
@@ -0,0 +1,63 @@
---
id: "02-bulk-quarantine-api"
title: "Bulk quarantine API"
status: done
wave: 2
depends_on: ["01-backend-purge-engine"]
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 02: Bulk quarantine API
Expose eligible and forced bulk purge through the existing tracked System job contract.
## Acceptance
- `DELETE /api/v1/system/storage/quarantine` accepts only the two specified scope/confirmation
pairs and returns `400` without starting a job for every mismatch.
- Accepted requests return a `storage-quarantine-delete` job whose result contains the complete
typed purge summary; partial deletion failures produce a failed job without rolling back
successful entries.
- Existing per-entry deletion still returns `409` before retention and cached overview data is
invalidated whenever any entry is deleted.
## Verification
```bash
make -C apps/backend test
```
Completed: the storage API and operations tests passed before PR publication and are rerun with
the remediation checks after review fixes.
## Files likely touched
- `apps/backend/internal/system/storage/operations.go`
- `apps/backend/internal/system/storage/operations_test.go`
- `apps/backend/internal/system/storage/handler.go`
- `apps/backend/internal/system/storage/handler_test.go`
## Dependencies
Task 01.
## Parallelism
Sequential; Task 03 consumes the final HTTP contract.
## Inputs
- Spec: bulk API surface, permissions, failure modes, and scenarios
- Plan: Bulk HTTP operation
- Task 01 purge types and controller method
## Risks
- Route ordering must preserve collection delete and `/:id` delete behavior.
- Partial success must still invalidate the overview before the aggregate error finishes the job.
## Output contract
Report request validation, job result/error semantics, overview invalidation, files changed, exact
test results, blockers/risks, and update this task plus `plan.md` status.
@@ -0,0 +1,59 @@
---
id: "03-frontend-quarantine-domain"
title: "Frontend quarantine domain"
status: done
wave: 3
depends_on: ["02-bulk-quarantine-api"]
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 03: Frontend quarantine domain
Wire the bulk purge contract through shared frontend types, API calls, job tracking, and controller
actions.
## Acceptance
- API calls emit the exact eligible/all confirmation payloads and preserve encoded per-entry paths.
- The storage controller exposes `clearEligible` and `forceClearAll`, tracks their shared
`storage-quarantine-delete` job, refreshes overview/history/quarantine on terminal state, and
reports action-specific feedback.
- Existing individual restore and eligible deletion behavior remains intact.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- lib/api/domains/system-api.test.ts hooks/domains/system/use-storage-maintenance.test.tsx
```
## Files likely touched
- `apps/web/lib/types/system.ts`
- `apps/web/lib/api/domains/system-api.ts`
- `apps/web/lib/api/domains/system-api.test.ts`
- `apps/web/hooks/domains/system/use-storage-maintenance.ts`
- `apps/web/hooks/domains/system/use-storage-maintenance.test.tsx`
## Dependencies
Task 02.
## Parallelism
Sequential; Task 04 consumes the controller surface.
## Inputs
- Spec: API surface and bulk-result scenarios
- Plan: Types, API client, and domain controller
- Task 02 final request/response contract
## Risks
- Fast jobs may finish before WebSocket delivery; polling and terminal reload must remain active.
## Output contract
Report frontend contract shapes, controller actions/job lifecycle, files changed, exact test
results, blockers/risks, and update this task plus `plan.md` status.
@@ -0,0 +1,63 @@
---
id: "04-responsive-quarantine-ui"
title: "Responsive quarantine UI"
status: done
wave: 4
depends_on: ["03-frontend-quarantine-domain"]
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 04: Responsive quarantine UI
Make retention eligibility, automatic-cleanup conditions, and both bulk actions understandable and
touch-accessible in the existing Storage page.
## Acceptance
- Every row shows a semantic localized eligibility timestamp/status and disables individual Delete
while protected; card totals and scheduling copy accurately describe automatic cleanup.
- **Clear eligible** and **Force clear all** use distinct count-aware typed confirmations and shared
controller actions, with correct empty/active-job disabled states.
- Desktop and phone retain the same capability; phone actions are at least 44 pixels high, paths
wrap, dialogs stay viewport-contained, and no horizontal document scrolling is introduced.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- components/settings/system/storage/storage-quarantine-card.test.tsx components/settings/system/storage/storage-maintenance-settings.test.tsx
cd apps/web && pnpm run typecheck
```
## Files likely touched
- `apps/web/components/settings/system/storage/storage-quarantine-card.tsx`
- `apps/web/components/settings/system/storage/storage-quarantine-card.test.tsx`
- `apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx`
- `apps/web/components/settings/system/storage/storage-maintenance-settings.tsx`
- `apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx`
## Dependencies
Task 03.
## Parallelism
Sequential; Task 06 exercises the rendered contract.
## Inputs
- Spec: quarantine UI and mobile scenarios
- Plan: Quarantine card and Mobile design contract
- Mobile exemplar:
`apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`
## Risks
- Relative status must not obscure the absolute `delete_after` time.
- Client time comparisons need deterministic fake-time component tests.
## Output contract
Report desktop/mobile composition, eligibility and confirmation behavior, rendered checks, files
changed, exact test results, blockers/risks, and update this task plus `plan.md` status.
@@ -0,0 +1,54 @@
---
id: "05-operator-documentation"
title: "Operator documentation"
status: done
wave: 5
depends_on: ["04-responsive-quarantine-ui"]
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 05: Operator documentation
Update the public Operations guide for the completed quarantine lifecycle.
## Acceptance
- The guide explains the retention deadline, eligible automatic purge, scheduling-disabled
behavior, **Clear eligible**, and the irreversible **Force clear all** override.
- It distinguishes the earliest safe deletion timestamp from an exact promised cleanup time and
states that force bypasses only retention.
## Verification
```bash
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
## Files likely touched
- `docs/public/operations.md`
## Dependencies
Task 04 so published labels match the final UI.
## Parallelism
Sequential.
## Inputs
- Spec: quarantine behavior and out-of-scope timing guarantees
- Plan: Public Documentation
- ADR: `docs/decisions/2026-07-29-quarantine-retention-override.md`
## Risks
- Documentation must not imply a dedicated sweeper runs when scheduling is disabled.
## Output contract
Report public wording, links if changed, exact validation results, blockers/risks, and update this
task plus `plan.md` status.
@@ -0,0 +1,59 @@
---
id: "06-storage-quarantine-e2e"
title: "Storage quarantine E2E"
status: done
wave: 6
depends_on:
- "04-responsive-quarantine-ui"
- "05-operator-documentation"
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 06: Storage quarantine E2E
Prove the user-visible quarantine lifecycle on desktop and Pixel 5 mobile layouts.
## Acceptance
- Desktop coverage shows a protected real quarantine entry, its deadline, and successful
force-clear removal; eligible-only coverage verifies protected remainder and completed-job
feedback.
- Mobile coverage reaches both bulk confirmations with touch interactions, asserts the distinct
phrases/outcomes, and proves no document horizontal overflow.
- Production Vite and Go artifacts are rebuilt by the managed runner before the focused specs run.
## Verification
```bash
cd apps/web && pnpm e2e:run tests/system/storage-maintenance.spec.ts tests/system/mobile-storage-maintenance.spec.ts
```
## Files likely touched
- `apps/web/e2e/tests/system/storage-maintenance.spec.ts`
- `apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`
## Dependencies
Tasks 04 and 05.
## Parallelism
Sequential final validation.
## Inputs
- Spec: desktop and mobile quarantine scenarios
- Plan: E2E Tests and Mobile design contract
- Existing orphan-workspace and managed-Go-cache seed helpers
## Risks
- E2E setup cannot wait through a production retention period; use the real filesystem path for
force deletion and keep time-controlled eligible deletion integration in backend tests.
## Output contract
Report scenarios, production-build runner command/result, screenshots or failure artifacts
inspected, files changed, blockers/risks, and update this task plus `plan.md` status.
+17 -4
View File
@@ -87,10 +87,23 @@ snapshot was last analyzed. Select **Analyze** to force a fresh scan; restarting
clears the in-memory snapshot.
Scheduled cleanup is disabled by default and runs only after the configured resource-idle quiet
period. Orphaned task workspaces move into Kandev's quarantine before permanent deletion; review
the quarantine list to restore an entry or request deletion as a background job. Host-wide Docker
build-cache and unused-image cleanup remain disabled until you confirm that Kandev owns a dedicated
Docker daemon.
period. Orphaned task workspaces and rotated Go caches move into Kandev's quarantine before
permanent deletion. Each entry shows its `delete_after` retention deadline: **Delete** and
**Clear eligible** cannot remove it before that time. The deadline is the earliest safe deletion
time, not an exact promise—the first successful scheduled or full manual maintenance run after the
deadline performs the purge, subject to the idle gate and any preemption.
Use **Clear eligible** to remove only entries whose deadlines have passed. It reports protected
entries that remain. **Force clear all** requires typing `DELETE ALL NOW` and attempts to permanently
remove every active quarantine entry, discarding restore windows for entries that are successfully
deleted. Safety-validation or deletion failures may leave entries visible and retryable. This
override bypasses only the retention timestamp; path, ownership, state, and filesystem safety
checks still apply.
If scheduled cleanup is disabled, no independent quarantine sweeper runs: use a full **Run now** or
one of the quarantine actions when you want cleanup.
Host-wide Docker build-cache and unused-image cleanup remain disabled until you confirm that Kandev
owns a dedicated Docker daemon.
Do not enable those rules on a daemon shared with unrelated workloads.
Host-local agents inherit the Kandev service's `TMPDIR`, `TMP`, and `TEMP` values unchanged. If the
+95 -5
View File
@@ -1,7 +1,7 @@
---
status: building
created: 2026-07-14
updated: 2026-07-27
updated: 2026-07-29
owner: cfl
---
@@ -29,7 +29,8 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
Go build cache, Docker cleanup, and quarantine safety. Every option includes focusable,
pointer-accessible help that explains what it can change, when it runs, and which safety checks
apply. Threshold and path fields are disabled while their parent cleanup option is disabled;
quarantine retention remains independently editable because it also governs existing entries.
quarantine retention remains independently editable because it governs entries created by future
cleanup even when the other resource rules are disabled.
- Read-only analysis is available even when scheduled maintenance is disabled. It reports total
task workspace bytes alongside active and orphan-candidate bytes, active quarantined count and
bytes, the managed Go cache, the service user's default Go cache when it is a distinct path,
@@ -78,11 +79,27 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
cleanup immediately.
- Kandev invokes typed, built-in maintenance providers. Users cannot configure arbitrary shell
commands to run as the Kandev service account.
- The Quarantine section shows each entry's exact deletion-eligibility timestamp and whether it is
protected or eligible now. It explains that the timestamp is the earliest deletion time, while
the actual automatic deletion occurs on the first successful scheduled maintenance run after
that time. When scheduling is disabled, it says that automatic deletion is off and names full
manual **Run now** or an explicit quarantine action as the available cleanup paths.
- Scheduled and full manual maintenance runs permanently delete eligible quarantine entries.
Resource-specific manual runs do not delete unrelated quarantine entries.
- **Clear eligible** permanently deletes every active quarantine entry whose retention deadline has
elapsed and leaves protected entries intact. Its result reports deleted, protected, and failed
entry counts and bytes.
- A separate **Force clear all** action may delete protected and eligible entries together after the
user types `DELETE ALL NOW`. The force action bypasses only retention; it never bypasses resource
ownership, path containment, symlink, state-transition, or Git worktree-pruning validation.
### Task cleanup and orphan workspaces
Decision: [ADR-2026-07-19-workspace-symlink-entries](../../decisions/2026-07-19-workspace-symlink-entries.md)
Retention override:
[ADR-2026-07-29-quarantine-retention-override](../../decisions/2026-07-29-quarantine-retention-override.md)
- Archive, delete, cascade, workspace-delete, and quick-chat expiration persist a task resource
cleanup intent before mutating or removing the task row. Cleanup inventory needed after a
task deletion is captured in that intent and is not dependent on foreign-keyed rows surviving.
@@ -109,11 +126,14 @@ Decision: [ADR-2026-07-19-workspace-symlink-entries](../../decisions/2026-07-19-
`~/.kandev/trash/tasks/`; they are not immediately deleted. Quarantine entries record their
original path, size, task/workspace identity when known, and permanent-deletion deadline.
- The Storage page describes quarantine as a recoverable holding area, explains when Kandev uses
it, and distinguishes restore from immediate permanent deletion.
it, and distinguishes retention-protected deletion from the explicitly forced bulk override.
- The default orphan grace period is seven days and the default quarantine retention is seven
additional days. Both are configurable in whole hours and apply to scheduled and manual runs.
- Quarantine never deletes a Git branch. Permanent deletion removes the quarantined files and
prunes stale Git worktree registration only after the retention deadline.
- Scheduled and full manual maintenance include a quarantine provider that permanently deletes
entries whose deadlines have elapsed. A resource-specific manual run, such as **Clean Go cache**,
does not purge unrelated quarantine entries.
- Users can restore a quarantined task workspace to its original path while that path is free.
A path conflict fails closed and leaves the quarantine entry intact.
@@ -332,6 +352,14 @@ POST /api/v1/system/storage/quarantine/:id/restore
DELETE /api/v1/system/storage/quarantine/:id
body: { confirm: "DELETE" }
-> 202 { job_id }
-> 409 before the entry's delete_after timestamp
DELETE /api/v1/system/storage/quarantine
body: {
scope: "eligible" | "all",
confirm: "DELETE ELIGIBLE" | "DELETE ALL NOW"
}
-> 202 { job_id }
```
`capabilities` reports the managed Go path, whether Go-cache adoption is available, Docker
@@ -345,6 +373,25 @@ window and replaces the cached snapshot only when the forced analysis succeeds.
Storage operations use the existing `system.job.update` WebSocket event and polling fallback.
Job kinds are `storage-analysis`, `storage-cleanup`, and `storage-quarantine-delete`.
Bulk quarantine jobs expose this result shape:
```json
{
"scope": "eligible|all",
"considered": 4,
"deleted": 2,
"deleted_bytes": 2048,
"protected": 1,
"protected_bytes": 1024,
"failed": 1,
"failures": [{ "id": "...", "error": "..." }]
}
```
`scope: "eligible"` requires `DELETE ELIGIBLE` and skips entries whose `delete_after` timestamp is
still in the future. `scope: "all"` requires `DELETE ALL NOW` and may bypass that timestamp. The
server rejects mismatched scope/confirmation pairs with `400`.
`busy_resources` uses stable machine-readable `kind` values and plain-language `label` values.
The response exposes activity categories, not task names, prompts, paths, or other session content.
`force_available` is true only when `force: true` can bypass the reported current task activity;
@@ -387,6 +434,11 @@ at the next interval. Provider failure is isolated: a Docker failure does not ro
quarantine or prevent a later Go-cache provider from running, but the overall run is `failed` and
records each provider result.
The quarantine provider runs during scheduled and full manual maintenance. It evaluates the
persisted `delete_after` value at run time and attempts permanent deletion only for eligible
entries. If any entry deletion fails, successful deletions remain committed, the failed entry
remains visible and retryable, and the maintenance run records the quarantine provider failure.
### Task cleanup intent
```text
@@ -409,8 +461,10 @@ quarantined -> restored
## Permissions
Storage routes use the same install-user authorization as other System pages. Adopting an external
Go cache, acknowledging a dedicated Docker daemon, permanently deleting a quarantine entry, and
enabling host-global Docker cleanup require explicit UI confirmation and server-side validation.
Go cache, acknowledging a dedicated Docker daemon, permanently deleting one or more quarantine
entries, and enabling host-global Docker cleanup require explicit UI confirmation and server-side
validation. **Force clear all** uses the distinct `DELETE ALL NOW` confirmation because it removes
the configured restore window.
## Failure modes
@@ -431,6 +485,11 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
warning, and does not run destructive maintenance.
- A managed Go-cache cleanup failure leaves either the original cache or its quarantined rename
intact; it never recursively deletes outside the configured owned path.
- Bulk quarantine deletion continues after one entry fails. Successful entries remain deleted,
protected entries remain unchanged for eligible-only cleanup, failed entries remain visible with
their last error, and the job finishes as failed with per-entry failure details.
- A force-clear request bypasses only the retention timestamp. Any ownership, containment, symlink,
ambiguous-state, or Git worktree-pruning failure keeps the affected entry and reports the error.
- Docker list/usage failure marks Docker analysis unavailable. Docker prune failure records the
daemon error and does not affect other providers.
- Loss of the dedicated-daemon acknowledgment between analysis and cleanup cancels host-global
@@ -451,6 +510,9 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
cleanup and permanent quarantine deletion do not cascade-delete that history.
- Quarantined data remains restorable until permanent deletion succeeds. A failed permanent delete
remains visible and retryable.
- Expired entries are automatically considered only by scheduled or full manual maintenance.
Disabling scheduling prevents unattended quarantine deletion; it does not start an independent
sweeper or remove existing entries.
- Run history retains the newest 20 completed entries plus all non-terminal entries.
## Scenarios
@@ -497,6 +559,24 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
siblings in the same workspace directory.
- **GIVEN** a quarantined task workspace has not reached its deletion deadline, **WHEN** the user
selects **Restore**, **THEN** it returns to its original path and remains available to the task.
- **GIVEN** protected and eligible quarantine entries, **WHEN** the Storage page renders, **THEN**
each row shows its exact `delete_after` timestamp and protected-or-eligible status, and the page
states whether automatic scheduled cleanup is enabled.
- **GIVEN** protected and eligible quarantine entries, **WHEN** the user confirms **Clear eligible**
with `DELETE ELIGIBLE`, **THEN** every eligible entry is permanently deleted, protected entries
remain, and the completed job reports both groups.
- **GIVEN** protected and eligible quarantine entries, **WHEN** the user confirms **Force clear
all** with `DELETE ALL NOW`, **THEN** Kandev attempts to permanently delete both groups while
retaining every ownership and path-safety check.
- **GIVEN** an eligible quarantine entry and enabled scheduling, **WHEN** the next scheduled
maintenance run acquires the idle gate, **THEN** its quarantine provider permanently deletes the
entry and reports the reclaimed bytes.
- **GIVEN** an eligible quarantine entry and disabled scheduling, **WHEN** no manual maintenance or
quarantine action occurs, **THEN** the entry remains restorable and the page states that
automatic cleanup is off.
- **GIVEN** one invalid quarantine payload among otherwise deletable entries, **WHEN** bulk deletion
runs, **THEN** valid entries are deleted, the invalid entry remains visible, and the job reports
the partial failure.
- **GIVEN** an archived task has a quarantined workspace, **WHEN** the user unarchives it, **THEN**
Kandev restores the quarantined directory before reporting branch recovery.
- **GIVEN** an archive cleanup job is waiting to retry, **WHEN** the task is unarchived, **THEN** the
@@ -534,6 +614,10 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
- **GIVEN** the Storage page is opened on a mobile viewport, **WHEN** the user navigates through the
settings sheet, analyzes storage, and expands a resource result, **THEN** every value and action is
available without horizontal page scrolling or hover-only controls.
- **GIVEN** multiple protected and eligible quarantine entries on a mobile viewport, **WHEN** the
user reviews deadlines and completes either bulk action, **THEN** the same counts, confirmation,
result feedback, and safety behavior are available through at least 44-pixel touch targets
without horizontal page scrolling.
- **GIVEN** settings were saved, **WHEN** the backend restarts, **THEN** the Storage page shows the
persisted policy and the next run uses it.
@@ -546,6 +630,11 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
- Cleaning remote SSH executor filesystems; remote maintenance requires a separate explicit design.
- Restoring uncommitted files after their quarantine retention has expired.
- Automatically cleaning a pre-existing user Go cache without explicit path adoption.
- An independent quarantine sweeper that runs while scheduled maintenance is disabled.
- Promising an exact permanent-deletion instant when the maintenance idle gate may delay or preempt
a scheduled run.
- Letting the force-clear retention override bypass ownership, containment, symlink, state, or Git
worktree-pruning safety checks.
- Age-based or name-based deletion of unmarked `/tmp/kandev-agent/*` directories.
- A Kandev-owned general-purpose sweeper for the operating system's shared temporary directory.
- Guaranteed compatibility with tools that require a fixed, globally unique name in shared temp;
@@ -555,3 +644,4 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
- [Original Storage maintenance implementation](../../plans/storage-maintenance/plan.md)
- [Storage overview cache and settings follow-up](../../plans/storage-overview-cache/plan.md)
- [Quarantine lifecycle follow-up](../../plans/quarantine-lifecycle/plan.md)