feat: add quarantine retention lifecycle (#2049)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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" }))
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user