feat: explain busy storage cleanup and allow override (#1993)
This commit is contained in:
@@ -19,6 +19,12 @@ Intent -> Spec -> Plan + task files -> User model switch -> Sequential TDD imple
|
||||
Do not replace the spec, plan, or task files with chat-only notes. Do not skip
|
||||
from intent to code unless the user explicitly opts out of this workflow.
|
||||
|
||||
When a user explicitly asks to proceed with feature planning, complete the whole
|
||||
strong-model design package — spec, plan, and task files — before pausing for
|
||||
review or a model switch. Pause after a standalone spec only when the user asks
|
||||
to review that spec before planning, or when a material open question blocks a
|
||||
safe plan.
|
||||
|
||||
## Phase 0: Track The Work
|
||||
|
||||
Keep a visible task list and mark each phase as it completes:
|
||||
|
||||
@@ -104,6 +104,9 @@ The read-only `pr-poller` is the sole repository-defined exception: use it only
|
||||
after the user explicitly asks to wait for or monitor PR updates.
|
||||
|
||||
Use the user's strong model for specs, plans, task files, and high-risk design.
|
||||
When the user asks to proceed with feature planning, produce the spec, plan,
|
||||
and task files as one design package; pause after a spec only when the user
|
||||
explicitly requests spec review or a material open question prevents planning.
|
||||
At the completed-plan checkpoint, ask the user to manually switch the main
|
||||
session to a lower-cost implementation model. Detailed feature, fix,
|
||||
validation, and delegation routing lives in the relevant skills, especially
|
||||
|
||||
@@ -28,6 +28,14 @@ const (
|
||||
KindMaintenanceRunning Kind = "maintenance_running"
|
||||
)
|
||||
|
||||
// BusyResource is a privacy-safe description of host activity that can block
|
||||
// destructive storage maintenance. It intentionally contains a category, not
|
||||
// task/session identity or command content.
|
||||
type BusyResource struct {
|
||||
Kind Kind `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Now func() time.Time
|
||||
}
|
||||
@@ -138,13 +146,30 @@ func (c *Coordinator) AcquireTask(ctx context.Context, kind Kind) (*TaskLease, e
|
||||
func (c *Coordinator) TryAcquireMaintenance(
|
||||
ctx context.Context,
|
||||
quietPeriod time.Duration,
|
||||
) (*MaintenanceLease, []Kind, error) {
|
||||
return c.tryAcquireMaintenance(ctx, quietPeriod, false)
|
||||
}
|
||||
|
||||
// TryAcquireMaintenanceForce admits manual maintenance alongside task work
|
||||
// that is already active. It still rejects another maintenance run, and the
|
||||
// returned lease remains preemptible by task work admitted afterwards.
|
||||
func (c *Coordinator) TryAcquireMaintenanceForce(
|
||||
ctx context.Context,
|
||||
) (*MaintenanceLease, []Kind, error) {
|
||||
return c.tryAcquireMaintenance(ctx, 0, true)
|
||||
}
|
||||
|
||||
func (c *Coordinator) tryAcquireMaintenance(
|
||||
ctx context.Context,
|
||||
quietPeriod time.Duration,
|
||||
ignoreActive bool,
|
||||
) (*MaintenanceLease, []Kind, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.maintenance != nil {
|
||||
return nil, []Kind{KindMaintenanceRunning}, ErrBusy
|
||||
}
|
||||
if busy := c.busyKindsLocked(); len(busy) > 0 {
|
||||
if busy := c.busyKindsLocked(); !ignoreActive && len(busy) > 0 {
|
||||
return nil, busy, ErrBusy
|
||||
}
|
||||
if quietPeriod > 0 && c.now().UTC().Sub(c.lastActivity) < quietPeriod {
|
||||
@@ -162,6 +187,14 @@ func (c *Coordinator) BusyKinds() []Kind {
|
||||
return c.busyKindsLocked()
|
||||
}
|
||||
|
||||
// BusyResources returns the current activity categories with labels suitable
|
||||
// for a user-facing maintenance warning.
|
||||
func (c *Coordinator) BusyResources() []BusyResource {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return BusyResourcesForKinds(c.busyKindsLocked())
|
||||
}
|
||||
|
||||
func (c *Coordinator) releaseTask(kind Kind) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -198,3 +231,42 @@ func (c *Coordinator) busyKindsLocked() []Kind {
|
||||
sort.Slice(busy, func(i, j int) bool { return busy[i] < busy[j] })
|
||||
return busy
|
||||
}
|
||||
|
||||
// BusyResourcesForKinds converts stable activity kinds into user-facing
|
||||
// descriptions without exposing task/session identity.
|
||||
func BusyResourcesForKinds(kinds []Kind) []BusyResource {
|
||||
resources := make([]BusyResource, 0, len(kinds))
|
||||
for _, kind := range kinds {
|
||||
resources = append(resources, BusyResource{Kind: kind, Label: busyLabel(kind)})
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
||||
func busyLabel(kind Kind) string {
|
||||
switch kind {
|
||||
case KindExecutionStarting:
|
||||
return "An agent execution is starting"
|
||||
case KindExecutionPreparing:
|
||||
return "An agent execution is preparing"
|
||||
case KindExecutionRunning:
|
||||
return "An agent execution is running"
|
||||
case KindExecutionStopping:
|
||||
return "An agent execution is stopping"
|
||||
case KindShellCommand:
|
||||
return "A shell command is running"
|
||||
case KindTestCommand:
|
||||
return "A test command is running"
|
||||
case KindSetupScript:
|
||||
return "A setup script is running"
|
||||
case KindCleanupScript:
|
||||
return "A cleanup script is running"
|
||||
case KindDockerImageBuild:
|
||||
return "A Docker image build is running"
|
||||
case KindMaintenanceRunning:
|
||||
return "Another storage maintenance run is running"
|
||||
case KindQuietPeriod:
|
||||
return "Kandev is in its configured idle period"
|
||||
default:
|
||||
return "Kandev host activity is running"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,3 +96,63 @@ func TestMaintenanceAlreadyRunningReportsMaintenanceKind(t *testing.T) {
|
||||
t.Fatalf("busy kinds = %v, want [%s]", busy, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcedMaintenanceIgnoresCurrentTaskButRemainsPreemptible(t *testing.T) {
|
||||
coordinator := NewCoordinator(Options{})
|
||||
activeTask, err := coordinator.AcquireTask(context.Background(), KindTestCommand)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireTask: %v", err)
|
||||
}
|
||||
defer activeTask.Release()
|
||||
|
||||
maintenance, busy, err := coordinator.TryAcquireMaintenanceForce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireMaintenanceForce: %v", err)
|
||||
}
|
||||
if len(busy) != 0 {
|
||||
t.Fatalf("forced maintenance busy kinds = %v, want empty", busy)
|
||||
}
|
||||
|
||||
admitted := make(chan *TaskLease, 1)
|
||||
go func() {
|
||||
lease, acquireErr := coordinator.AcquireTask(context.Background(), KindShellCommand)
|
||||
if acquireErr == nil {
|
||||
admitted <- lease
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-maintenance.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("new task admission did not preempt forced maintenance")
|
||||
}
|
||||
select {
|
||||
case <-admitted:
|
||||
t.Fatal("new task admitted before forced maintenance drained")
|
||||
default:
|
||||
}
|
||||
maintenance.Release()
|
||||
select {
|
||||
case lease := <-admitted:
|
||||
lease.Release()
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("new task was not admitted after forced maintenance drained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusyResourcesExposeStableLabels(t *testing.T) {
|
||||
coordinator := NewCoordinator(Options{})
|
||||
lease, err := coordinator.AcquireTask(context.Background(), KindDockerImageBuild)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireTask: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
resources := coordinator.BusyResources()
|
||||
if len(resources) != 1 {
|
||||
t.Fatalf("BusyResources = %v, want one resource", resources)
|
||||
}
|
||||
if resources[0].Kind != KindDockerImageBuild || resources[0].Label == "" {
|
||||
t.Fatalf("BusyResources = %#v, want docker build resource with label", resources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,7 +469,10 @@ func (c *workspaceQuarantineController) acquireGoCacheMaintenance(
|
||||
}
|
||||
lease, busy, err := c.activity.TryAcquireMaintenance(ctx, 0)
|
||||
if errors.Is(err, activity.ErrBusy) {
|
||||
return nil, &storagepkg.BusyError{Resources: busy}
|
||||
return nil, &storagepkg.BusyError{
|
||||
Resources: activity.BusyResourcesForKinds(busy),
|
||||
ForceAvailable: false,
|
||||
}
|
||||
}
|
||||
return lease, err
|
||||
}
|
||||
|
||||
@@ -297,6 +297,9 @@ func TestQuarantineControllerRetainsGoCacheWhileTaskActivityIsRunning(t *testing
|
||||
if !errors.As(err, &busy) {
|
||||
t.Fatalf("Restore error = %v, want BusyError", err)
|
||||
}
|
||||
if busy.ForceAvailable || len(busy.Resources) != 1 || busy.Resources[0].Label == "" {
|
||||
t.Fatalf("busy response = %#v, want labeled non-overridable resource", busy)
|
||||
}
|
||||
if _, err := os.Stat(artifact); err != nil {
|
||||
t.Fatalf("quarantined cache changed while task active: %v", err)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type QuarantineLister interface {
|
||||
type Mutations interface {
|
||||
AdoptGoCache(context.Context, string, string) (StorageMaintenanceSettings, Capabilities, error)
|
||||
Analyze(context.Context) (string, error)
|
||||
RunNow(context.Context, []string) (string, error)
|
||||
RunNow(context.Context, []string, bool) (string, error)
|
||||
RestoreQuarantine(context.Context, string) (QuarantineEntry, error)
|
||||
DeleteQuarantine(context.Context, string, string) (string, error)
|
||||
}
|
||||
@@ -129,6 +129,7 @@ func (h *Handler) adoptGoCache(c *gin.Context) {
|
||||
|
||||
type runRequest struct {
|
||||
Resources []string `json:"resources"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
func (h *Handler) analyze(c *gin.Context) {
|
||||
@@ -144,10 +145,13 @@ func (h *Handler) runNow(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
id, err := h.config.Mutations.RunNow(c.Request.Context(), request.Resources)
|
||||
id, err := h.config.Mutations.RunNow(c.Request.Context(), request.Resources, request.Force)
|
||||
var busy *BusyError
|
||||
if errors.As(err, &busy) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": busy.Error(), "busy_resources": busy.Resources})
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": busy.Error(), "busy_resources": busy.Resources,
|
||||
"force_available": busy.ForceAvailable,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeAcceptedJob(c, id, err)
|
||||
|
||||
@@ -87,7 +87,7 @@ type OverviewRefresher interface {
|
||||
Refresh(context.Context) (OverviewSnapshot, error)
|
||||
}
|
||||
|
||||
func (o *Operations) RunNow(ctx context.Context, resources []string) (string, error) {
|
||||
func (o *Operations) RunNow(ctx context.Context, resources []string, force bool) (string, error) {
|
||||
settings, err := o.config.Settings.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -96,13 +96,13 @@ func (o *Operations) RunNow(ctx context.Context, resources []string) (string, er
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := o.preflight(ctx); err != nil {
|
||||
if err := o.preflight(ctx, force); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return o.startTracked(ctx, JobKindCleanup, func(jobCtx context.Context, id string) (map[string]any, error) {
|
||||
runner := NewRunner(RunnerConfig{
|
||||
Activity: o.config.Activity, Store: o.config.Store, Providers: providers,
|
||||
NewID: func() string { return id }, Overview: o.config.Overview,
|
||||
Force: force, NewID: func() string { return id }, Overview: o.config.Overview,
|
||||
})
|
||||
run, runErr := runner.Run(jobCtx, RunTriggerManual, settings)
|
||||
return runResultMap(run), runErr
|
||||
@@ -150,10 +150,20 @@ func (o *Operations) invalidateOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Operations) preflight(ctx context.Context) error {
|
||||
lease, busy, err := o.config.Activity.TryAcquireMaintenance(ctx, 0)
|
||||
func (o *Operations) preflight(ctx context.Context, force bool) error {
|
||||
var lease *activity.MaintenanceLease
|
||||
var busy []activity.Kind
|
||||
var err error
|
||||
if force {
|
||||
lease, busy, err = o.config.Activity.TryAcquireMaintenanceForce(ctx)
|
||||
} else {
|
||||
lease, busy, err = o.config.Activity.TryAcquireMaintenance(ctx, 0)
|
||||
}
|
||||
if errors.Is(err, activity.ErrBusy) {
|
||||
return &BusyError{Resources: busy}
|
||||
return &BusyError{
|
||||
Resources: activity.BusyResourcesForKinds(busy),
|
||||
ForceAvailable: forceAvailable(busy),
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestRunNowIgnoresQuietPeriodWithoutActiveTaskWork(t *testing.T) {
|
||||
Jobs: tracker, Activity: coordinator, Providers: []CleanupProvider{provider},
|
||||
})
|
||||
|
||||
jobID, err := operations.RunNow(context.Background(), nil)
|
||||
jobID, err := operations.RunNow(context.Background(), nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RunNow: %v", err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestRunNowInvalidatesOverviewAfterSuccess(t *testing.T) {
|
||||
Overview: overview,
|
||||
})
|
||||
|
||||
jobID, err := operations.RunNow(context.Background(), nil)
|
||||
jobID, err := operations.RunNow(context.Background(), nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RunNow: %v", err)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestRunNowRejectsCurrentTaskActivity(t *testing.T) {
|
||||
Jobs: jobs.NewTracker(nil, newOperationsTestLogger(t)), Activity: coordinator,
|
||||
})
|
||||
|
||||
jobID, err := operations.RunNow(context.Background(), nil)
|
||||
jobID, err := operations.RunNow(context.Background(), nil, false)
|
||||
var busyErr *BusyError
|
||||
if !errors.As(err, &busyErr) {
|
||||
t.Fatalf("RunNow error = %v, want BusyError", err)
|
||||
@@ -109,6 +109,71 @@ func TestRunNowRejectsCurrentTaskActivity(t *testing.T) {
|
||||
if jobID != "" {
|
||||
t.Fatalf("busy RunNow job ID = %q, want empty", jobID)
|
||||
}
|
||||
if !busyErr.ForceAvailable || len(busyErr.Resources) != 1 || busyErr.Resources[0].Label == "" {
|
||||
t.Fatalf("busy response = %#v, want labeled force-available resource", busyErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowForceRunsBesideCurrentTaskActivity(t *testing.T) {
|
||||
connection := newSQLite(t)
|
||||
pool := db.NewPool(connection, connection)
|
||||
rawSettings, err := systemsettings.NewStore(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("new settings store: %v", err)
|
||||
}
|
||||
coordinator := activity.NewCoordinator(activity.Options{})
|
||||
taskLease, err := coordinator.AcquireTask(context.Background(), activity.KindTestCommand)
|
||||
if err != nil {
|
||||
t.Fatalf("AcquireTask: %v", err)
|
||||
}
|
||||
defer taskLease.Release()
|
||||
provider := &signallingCleanupProvider{called: make(chan struct{})}
|
||||
tracker := jobs.NewTracker(nil, newOperationsTestLogger(t))
|
||||
operations := NewOperations(OperationsConfig{
|
||||
Settings: NewSettingsStore(rawSettings), Store: newStorageStore(t, pool), Jobs: tracker,
|
||||
Activity: coordinator, Providers: []CleanupProvider{provider},
|
||||
})
|
||||
|
||||
jobID, err := operations.RunNow(context.Background(), nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("forced RunNow: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-provider.called:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("forced cleanup did not run beside active task")
|
||||
}
|
||||
waitForJobState(t, tracker, jobID, jobs.StateSucceeded)
|
||||
}
|
||||
|
||||
func TestRunNowForceBlockedByExistingMaintenanceIsNotForceAvailable(t *testing.T) {
|
||||
connection := newSQLite(t)
|
||||
pool := db.NewPool(connection, connection)
|
||||
rawSettings, err := systemsettings.NewStore(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("new settings store: %v", err)
|
||||
}
|
||||
coordinator := activity.NewCoordinator(activity.Options{})
|
||||
held, _, err := coordinator.TryAcquireMaintenance(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireMaintenance: %v", err)
|
||||
}
|
||||
defer held.Release()
|
||||
operations := NewOperations(OperationsConfig{
|
||||
Settings: NewSettingsStore(rawSettings), Store: newStorageStore(t, pool), Activity: coordinator,
|
||||
})
|
||||
|
||||
jobID, err := operations.RunNow(context.Background(), nil, true)
|
||||
var busyErr *BusyError
|
||||
if !errors.As(err, &busyErr) {
|
||||
t.Fatalf("forced RunNow error = %v, want BusyError", err)
|
||||
}
|
||||
if jobID != "" {
|
||||
t.Fatalf("busy forced RunNow job ID = %q, want empty", jobID)
|
||||
}
|
||||
if busyErr.ForceAvailable || len(busyErr.Resources) != 1 || busyErr.Resources[0].Kind != activity.KindMaintenanceRunning {
|
||||
t.Fatalf("busy response = %#v, want non-overridable maintenance resource", busyErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowMarksPreemptedTrackedJobFailed(t *testing.T) {
|
||||
@@ -129,7 +194,7 @@ func TestRunNowMarksPreemptedTrackedJobFailed(t *testing.T) {
|
||||
Jobs: tracker, Activity: coordinator, Providers: []CleanupProvider{provider},
|
||||
})
|
||||
|
||||
jobID, err := operations.RunNow(context.Background(), nil)
|
||||
jobID, err := operations.RunNow(context.Background(), nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RunNow: %v", err)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type RunnerConfig struct {
|
||||
Store RunStore
|
||||
Providers []CleanupProvider
|
||||
Overview OverviewInvalidator
|
||||
Force bool
|
||||
NewID func() string
|
||||
Now func() time.Time
|
||||
}
|
||||
@@ -41,6 +42,7 @@ type Runner struct {
|
||||
store RunStore
|
||||
providers []CleanupProvider
|
||||
overview OverviewInvalidator
|
||||
force bool
|
||||
newID func() string
|
||||
now func() time.Time
|
||||
}
|
||||
@@ -48,10 +50,11 @@ type Runner struct {
|
||||
const terminalTransitionTimeout = 5 * time.Second
|
||||
|
||||
type BusyError struct {
|
||||
Resources []activity.Kind
|
||||
Resources []activity.BusyResource `json:"busy_resources"`
|
||||
ForceAvailable bool `json:"force_available"`
|
||||
}
|
||||
|
||||
func (e *BusyError) Error() string { return "storage maintenance is busy" }
|
||||
func (e *BusyError) Error() string { return "storage cleanup is blocked by active Kandev work" }
|
||||
|
||||
func NewRunner(config RunnerConfig) *Runner {
|
||||
newID := config.NewID
|
||||
@@ -64,7 +67,7 @@ func NewRunner(config RunnerConfig) *Runner {
|
||||
}
|
||||
return &Runner{
|
||||
activity: config.Activity, store: config.Store, providers: config.Providers,
|
||||
overview: config.Overview, newID: newID, now: now,
|
||||
overview: config.Overview, force: config.Force, newID: newID, now: now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +81,7 @@ func (r *Runner) Run(
|
||||
return MaintenanceRun{}, err
|
||||
}
|
||||
quietPeriod := quietPeriodForTrigger(trigger, settings)
|
||||
lease, busy, err := r.activity.TryAcquireMaintenance(ctx, quietPeriod)
|
||||
lease, busy, err := r.acquireMaintenance(ctx, quietPeriod, trigger)
|
||||
if errors.Is(err, activity.ErrBusy) {
|
||||
result := marshalRunResult(map[string]any{"busy_resources": busy})
|
||||
run, transitionErr := r.transitionRun(ctx, run.ID, RunStateSkippedBusy, result, "host resources are busy")
|
||||
@@ -86,7 +89,10 @@ func (r *Runner) Run(
|
||||
return MaintenanceRun{}, transitionErr
|
||||
}
|
||||
if trigger == RunTriggerManual {
|
||||
return run, &BusyError{Resources: busy}
|
||||
return run, &BusyError{
|
||||
Resources: activity.BusyResourcesForKinds(busy),
|
||||
ForceAvailable: forceAvailable(busy),
|
||||
}
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
@@ -109,6 +115,26 @@ func (r *Runner) Run(
|
||||
return r.finishRun(ctx, run.ID, lease.Context(), result, runErr)
|
||||
}
|
||||
|
||||
func (r *Runner) acquireMaintenance(
|
||||
ctx context.Context,
|
||||
quietPeriod time.Duration,
|
||||
trigger RunTrigger,
|
||||
) (*activity.MaintenanceLease, []activity.Kind, error) {
|
||||
if r.force && trigger == RunTriggerManual {
|
||||
return r.activity.TryAcquireMaintenanceForce(ctx)
|
||||
}
|
||||
return r.activity.TryAcquireMaintenance(ctx, quietPeriod)
|
||||
}
|
||||
|
||||
func forceAvailable(kinds []activity.Kind) bool {
|
||||
for _, kind := range kinds {
|
||||
if kind == activity.KindMaintenanceRunning {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(kinds) > 0
|
||||
}
|
||||
|
||||
func quietPeriodForTrigger(trigger RunTrigger, settings StorageMaintenanceSettings) time.Duration {
|
||||
if trigger == RunTriggerManual {
|
||||
return 0
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/kandev/kandev/internal/agent/runtime/activity"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
systemsettings "github.com/kandev/kandev/internal/system/settings"
|
||||
@@ -95,6 +96,25 @@ func TestStorageAsyncRoutesAndBusyResponse(t *testing.T) {
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("busy run status = %d, want 409", response.Code)
|
||||
}
|
||||
var busyBody struct {
|
||||
BusyResources []activity.BusyResource `json:"busy_resources"`
|
||||
ForceAvailable bool `json:"force_available"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &busyBody); err != nil {
|
||||
t.Fatalf("decode busy response: %v", err)
|
||||
}
|
||||
if len(busyBody.BusyResources) != 1 || busyBody.BusyResources[0].Label == "" || !busyBody.ForceAvailable {
|
||||
t.Fatalf("busy response = %#v, want labeled force-available blocker", busyBody)
|
||||
}
|
||||
|
||||
mutations.busy = false
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/v1/system/storage/run", bytes.NewBufferString(`{"force":true}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response = httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusAccepted || !mutations.force {
|
||||
t.Fatalf("forced run status = %d force=%v, want 202/true", response.Code, mutations.force)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageRestoreConflictReturnsConflict(t *testing.T) {
|
||||
@@ -167,6 +187,7 @@ func newStorageRoutesTestRouterWithMutations(t *testing.T, mutations storage.Mut
|
||||
|
||||
type fakeStorageMutations struct {
|
||||
busy bool
|
||||
force bool
|
||||
restoreErr error
|
||||
deleteErr error
|
||||
}
|
||||
@@ -177,9 +198,13 @@ func (f *fakeStorageMutations) AdoptGoCache(_ context.Context, _ string, _ strin
|
||||
|
||||
func (f *fakeStorageMutations) Analyze(context.Context) (string, error) { return "analysis-job", nil }
|
||||
|
||||
func (f *fakeStorageMutations) RunNow(context.Context, []string) (string, error) {
|
||||
func (f *fakeStorageMutations) RunNow(_ context.Context, _ []string, force bool) (string, error) {
|
||||
f.force = force
|
||||
if f.busy {
|
||||
return "", &storage.BusyError{}
|
||||
return "", &storage.BusyError{
|
||||
Resources: []activity.BusyResource{{Kind: activity.KindTestCommand, Label: "A test command is running"}},
|
||||
ForceAvailable: true,
|
||||
}
|
||||
}
|
||||
return "cleanup-job", nil
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ function Providers({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function
|
||||
describe("StorageMaintenanceSettings", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -212,6 +213,37 @@ describe("StorageMaintenanceSettings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageMaintenanceSettings busy feedback", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.useSystemJob.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("explains busy activity and exposes the direct Run anyway action", () => {
|
||||
const runAnyway = vi.fn();
|
||||
mocks.useStorageMaintenance.mockReturnValue({
|
||||
...controller(overview),
|
||||
busy: {
|
||||
resources: [
|
||||
{ kind: "execution_running", label: "An agent execution is running" },
|
||||
{ kind: "test_command", label: "A test command is running" },
|
||||
],
|
||||
forceAvailable: true,
|
||||
},
|
||||
runAnyway,
|
||||
});
|
||||
|
||||
render(<StorageMaintenanceSettings />, { wrapper: Providers });
|
||||
|
||||
expect(screen.getByText("An agent execution is running")).toBeTruthy();
|
||||
expect(screen.getByText("A test command is running")).toBeTruthy();
|
||||
expect(screen.getByText(/may disrupt this active work/i)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run anyway" }));
|
||||
expect(runAnyway).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageMaintenanceSettings pending policy", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { IconAlertTriangle, IconCheck, IconPlayerPlay, IconRefresh } from "@tabler/icons-react";
|
||||
import { useStorageMaintenance } from "@/hooks/domains/system/use-storage-maintenance";
|
||||
import {
|
||||
useStorageMaintenance,
|
||||
type StorageBusyState,
|
||||
} from "@/hooks/domains/system/use-storage-maintenance";
|
||||
import type { StorageMaintenanceSettings as Settings, SystemJob } from "@/lib/types/system";
|
||||
import { useSettingsSaveContributor } from "../../settings-save-provider";
|
||||
import { StorageActionButton } from "./storage-action-button";
|
||||
@@ -123,6 +126,62 @@ function StorageActions({
|
||||
);
|
||||
}
|
||||
|
||||
function StorageActionFeedback({
|
||||
controller,
|
||||
}: {
|
||||
controller: ReturnType<typeof useStorageMaintenance>;
|
||||
}) {
|
||||
if (controller.busy) {
|
||||
return (
|
||||
<StorageBusyFeedback busy={controller.busy} onRunAnyway={() => void controller.runAnyway()} />
|
||||
);
|
||||
}
|
||||
if (!controller.error) return null;
|
||||
return (
|
||||
<Alert variant="destructive" data-testid="storage-error">
|
||||
<IconAlertTriangle className="size-4" />
|
||||
<AlertTitle>Storage action failed</AlertTitle>
|
||||
<AlertDescription className="break-words">{controller.error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageBusyFeedback({
|
||||
busy,
|
||||
onRunAnyway,
|
||||
}: {
|
||||
busy: StorageBusyState;
|
||||
onRunAnyway: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Alert variant="destructive" data-testid="storage-busy">
|
||||
<IconAlertTriangle className="size-4" />
|
||||
<AlertTitle>Storage cleanup found active Kandev work</AlertTitle>
|
||||
<AlertDescription className="break-words">
|
||||
<p>Cleanup may disrupt the following work:</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{busy.resources.map((resource) => (
|
||||
<li key={resource.kind}>{resource.label}</li>
|
||||
))}
|
||||
</ul>
|
||||
{busy.forceAvailable && (
|
||||
<>
|
||||
<p className="mt-3">Running cleanup anyway may disrupt this active work.</p>
|
||||
<StorageActionButton
|
||||
variant="outline"
|
||||
className="mt-3 w-full sm:w-auto"
|
||||
onClick={onRunAnyway}
|
||||
data-testid="storage-run-anyway"
|
||||
>
|
||||
<IconPlayerPlay className="size-4" /> Run anyway
|
||||
</StorageActionButton>
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function serializeSettings(settings: Settings | null): string {
|
||||
return settings ? JSON.stringify(settings) : "loading";
|
||||
}
|
||||
@@ -195,13 +254,7 @@ export function StorageMaintenanceSettings() {
|
||||
<div className="min-w-0 space-y-6" data-testid="storage-settings-page">
|
||||
<StorageActions controller={controller} disabledReason={actionDisabledReason} />
|
||||
|
||||
{controller.error && (
|
||||
<Alert variant="destructive" data-testid="storage-error">
|
||||
<IconAlertTriangle className="size-4" />
|
||||
<AlertTitle>Storage action failed</AlertTitle>
|
||||
<AlertDescription className="break-words">{controller.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<StorageActionFeedback controller={controller} />
|
||||
|
||||
<div className="min-w-0 space-y-4" data-testid="storage-primary-sections">
|
||||
<StorageOverviewCard
|
||||
|
||||
@@ -5,6 +5,53 @@ import { seedManagedGoCache } from "../../helpers/storage-maintenance";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
|
||||
test.describe("Mobile storage maintenance", () => {
|
||||
test("explains busy activity and allows Run anyway without horizontal overflow", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
let runAttempts = 0;
|
||||
await testPage.route("**/api/v1/system/storage/run", async (route) => {
|
||||
runAttempts += 1;
|
||||
if (runAttempts === 1) {
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: "storage cleanup is blocked by active Kandev work",
|
||||
busy_resources: [{ kind: "test_command", label: "A test command is running" }],
|
||||
force_available: true,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "mobile-force-cleanup" }),
|
||||
});
|
||||
});
|
||||
|
||||
await testPage.goto("/settings/system/storage");
|
||||
await testPage.getByTestId("storage-run-now").tap();
|
||||
await expect(testPage.getByTestId("storage-busy")).toContainText("A test command is running");
|
||||
await expect(testPage.getByTestId("storage-run-anyway")).toBeVisible();
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
await prCapture.screenshot("busy-feedback", {
|
||||
caption: "Mobile storage cleanup keeps the warning and Run anyway action in one column",
|
||||
fullPage: true,
|
||||
});
|
||||
const forceRequest = testPage.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === "/api/v1/system/storage/run" &&
|
||||
request.postDataJSON()?.force === true,
|
||||
);
|
||||
await testPage.getByTestId("storage-run-anyway").tap();
|
||||
expect((await forceRequest).postDataJSON()).toEqual({ force: true });
|
||||
});
|
||||
|
||||
test("opens Storage from mobile navigation and analyzes without horizontal overflow", async ({
|
||||
testPage,
|
||||
backend,
|
||||
|
||||
@@ -174,6 +174,7 @@ test.describe("System storage maintenance", () => {
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
@@ -224,7 +225,31 @@ test.describe("System storage maintenance", () => {
|
||||
status: 409,
|
||||
body: { busy_resources: expect.any(Array) },
|
||||
});
|
||||
await expect(testPage.getByTestId("storage-error")).toContainText(/busy|active/i);
|
||||
await expect(testPage.getByTestId("storage-run-now")).not.toHaveAttribute("data-job-state");
|
||||
expect(responseBody.force_available).toBe(true);
|
||||
expect(responseBody.busy_resources[0]).toMatchObject({
|
||||
kind: expect.any(String),
|
||||
label: expect.any(String),
|
||||
});
|
||||
await expect(testPage.getByTestId("storage-busy")).toContainText(
|
||||
responseBody.busy_resources[0].label,
|
||||
);
|
||||
await expect(testPage.getByTestId("storage-busy")).toContainText(/may disrupt/i);
|
||||
await prCapture.screenshot("busy-feedback", {
|
||||
caption: "Desktop storage cleanup explains the active work and offers Run anyway",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
const forceRequest = testPage.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === "/api/v1/system/storage/run",
|
||||
);
|
||||
await testPage.getByTestId("storage-run-anyway").click();
|
||||
expect((await forceRequest).postDataJSON()).toEqual({ force: true });
|
||||
await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute(
|
||||
"data-job-state",
|
||||
"succeeded",
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import type { StorageMaintenanceSettings, StorageOverviewResponse } from "@/lib/types/system";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -91,6 +92,8 @@ const cleanupJob = {
|
||||
state: "running",
|
||||
started_at: "2026-07-15T00:00:00Z",
|
||||
};
|
||||
const STORAGE_BUSY_ERROR_MESSAGE = "storage cleanup is blocked by active Kandev work";
|
||||
const TEST_COMMAND_BUSY_LABEL = "A test command is running";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -183,6 +186,84 @@ describe("useStorageMaintenance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStorageMaintenance busy feedback", () => {
|
||||
it("retains labeled busy feedback and reruns the same resources with force", async () => {
|
||||
mocks.run.mockRejectedValueOnce(
|
||||
new ApiError(STORAGE_BUSY_ERROR_MESSAGE, 409, {
|
||||
busy_resources: [{ kind: "test_command", label: TEST_COMMAND_BUSY_LABEL }],
|
||||
force_available: true,
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
|
||||
await waitFor(() => expect(result.current.overview).toEqual(overview));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runNow(["go_cache"]);
|
||||
});
|
||||
expect(result.current.busy).toEqual({
|
||||
resources: [{ kind: "test_command", label: TEST_COMMAND_BUSY_LABEL }],
|
||||
forceAvailable: true,
|
||||
resourceSelection: ["go_cache"],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runAnyway();
|
||||
});
|
||||
expect(mocks.run).toHaveBeenNthCalledWith(2, ["go_cache"], true);
|
||||
});
|
||||
|
||||
it("restores busy feedback when the forced retry is rejected", async () => {
|
||||
const initialBusyError = new ApiError(STORAGE_BUSY_ERROR_MESSAGE, 409, {
|
||||
busy_resources: [{ kind: "test_command", label: TEST_COMMAND_BUSY_LABEL }],
|
||||
force_available: true,
|
||||
});
|
||||
const forcedBusyError = new ApiError(STORAGE_BUSY_ERROR_MESSAGE, 409, {
|
||||
busy_resources: [{ kind: "maintenance_running", label: "Storage maintenance is running" }],
|
||||
force_available: false,
|
||||
});
|
||||
mocks.run.mockRejectedValueOnce(initialBusyError).mockRejectedValueOnce(forcedBusyError);
|
||||
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
|
||||
await waitFor(() => expect(result.current.overview).toEqual(overview));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runNow(["go_cache"]);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.runAnyway();
|
||||
});
|
||||
|
||||
expect(result.current.busy).toEqual({
|
||||
resources: [{ kind: "maintenance_running", label: "Storage maintenance is running" }],
|
||||
forceAvailable: false,
|
||||
resourceSelection: ["go_cache"],
|
||||
});
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Storage action failed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears stale busy feedback when another storage action starts", async () => {
|
||||
mocks.run.mockRejectedValueOnce(
|
||||
new ApiError(STORAGE_BUSY_ERROR_MESSAGE, 409, {
|
||||
busy_resources: [{ kind: "test_command", label: TEST_COMMAND_BUSY_LABEL }],
|
||||
force_available: true,
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
|
||||
await waitFor(() => expect(result.current.overview).toEqual(overview));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runNow();
|
||||
});
|
||||
expect(result.current.busy).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save(settings);
|
||||
});
|
||||
expect(result.current.busy).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useStorageMaintenance pending action tracking", () => {
|
||||
it("returns to a pending resource action after an overlapping save finishes", async () => {
|
||||
const pendingRun = deferred<{ job_id: string }>();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import {
|
||||
adoptStorageGoCache,
|
||||
analyzeStorage,
|
||||
@@ -22,7 +23,12 @@ import {
|
||||
runStorageMaintenance,
|
||||
saveStorageSettings,
|
||||
} from "@/lib/api/domains/system-api";
|
||||
import type { StorageMaintenanceSettings, SystemJob } from "@/lib/types/system";
|
||||
import type {
|
||||
StorageBusyResource,
|
||||
StorageBusyResponse,
|
||||
StorageMaintenanceSettings,
|
||||
SystemJob,
|
||||
} from "@/lib/types/system";
|
||||
import { useSystemJob } from "./use-system-jobs";
|
||||
|
||||
export type StoragePendingAction =
|
||||
@@ -35,6 +41,12 @@ export type StoragePendingAction =
|
||||
| "delete"
|
||||
| null;
|
||||
|
||||
export interface StorageBusyState {
|
||||
resources: StorageBusyResource[];
|
||||
forceAvailable: boolean;
|
||||
resourceSelection?: string[];
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -43,6 +55,22 @@ function isTerminal(state?: string): boolean {
|
||||
return state === "succeeded" || state === "failed";
|
||||
}
|
||||
|
||||
function busyStateFromError(error: unknown, resourceSelection?: string[]): StorageBusyState | null {
|
||||
if (!(error instanceof ApiError) || error.status !== 409) return null;
|
||||
const body = error.body as Partial<StorageBusyResponse> | null;
|
||||
if (!body || !Array.isArray(body.busy_resources)) return null;
|
||||
const resources = body.busy_resources.filter(
|
||||
(resource): resource is StorageBusyResource =>
|
||||
Boolean(resource) && typeof resource.kind === "string" && typeof resource.label === "string",
|
||||
);
|
||||
if (resources.length === 0) return null;
|
||||
return {
|
||||
resources,
|
||||
forceAvailable: body.force_available === true,
|
||||
resourceSelection,
|
||||
};
|
||||
}
|
||||
|
||||
export function settingsWithDockerAcknowledgement(
|
||||
settings: StorageMaintenanceSettings,
|
||||
acknowledged: boolean,
|
||||
@@ -105,19 +133,16 @@ function useStorageActionRunner() {
|
||||
return { pendingAction, error, setError, finishLoading, perform };
|
||||
}
|
||||
|
||||
function useStorageActions(reload: Reload) {
|
||||
const { toast } = useToast();
|
||||
const { pendingAction, error, setError, finishLoading, perform } = useStorageActionRunner();
|
||||
const [analysisJobId, setAnalysisJobId] = useState<string | null>(null);
|
||||
const [cleanupJobId, setCleanupJobId] = useState<string | null>(null);
|
||||
const [deleteJobId, setDeleteJobId] = useState<string | null>(null);
|
||||
const analysisJob = useSystemJob(analysisJobId);
|
||||
const cleanupJob = useSystemJob(cleanupJobId);
|
||||
const deleteJob = useSystemJob(deleteJobId);
|
||||
|
||||
function useStoragePolicyActions(
|
||||
perform: ReturnType<typeof useStorageActionRunner>["perform"],
|
||||
reload: Reload,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
clearBusy: () => void,
|
||||
) {
|
||||
const save = useCallback(
|
||||
async (settings: StorageMaintenanceSettings, confirmation?: "DEDICATED") =>
|
||||
perform(
|
||||
async (settings: StorageMaintenanceSettings, confirmation?: "DEDICATED") => {
|
||||
clearBusy();
|
||||
return perform(
|
||||
"save",
|
||||
async () => {
|
||||
await saveStorageSettings(settings, confirmation);
|
||||
@@ -125,61 +150,121 @@ function useStorageActions(reload: Reload) {
|
||||
toast({ title: "Storage policy saved", variant: "success" });
|
||||
},
|
||||
true,
|
||||
),
|
||||
[perform, reload, toast],
|
||||
);
|
||||
},
|
||||
[clearBusy, perform, reload, toast],
|
||||
);
|
||||
|
||||
const adopt = useCallback(
|
||||
async (path: string) =>
|
||||
perform("adopt", async () => {
|
||||
async (path: string) => {
|
||||
clearBusy();
|
||||
return perform("adopt", async () => {
|
||||
await adoptStorageGoCache(path);
|
||||
await reload();
|
||||
toast({ title: "Go build cache adopted", variant: "success" });
|
||||
}),
|
||||
[perform, reload, toast],
|
||||
});
|
||||
},
|
||||
[clearBusy, perform, reload, toast],
|
||||
);
|
||||
|
||||
const analyze = useCallback(
|
||||
async () =>
|
||||
perform("analyze", async () => {
|
||||
const accepted = await analyzeStorage();
|
||||
setAnalysisJobId(accepted.job_id);
|
||||
toast({ title: "Storage analysis started", variant: "success" });
|
||||
}),
|
||||
[perform, toast],
|
||||
return { save, adopt };
|
||||
}
|
||||
|
||||
function useStorageDeleteAction(
|
||||
perform: ReturnType<typeof useStorageActionRunner>["perform"],
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
clearBusy: () => void,
|
||||
setDeleteJobId: Dispatch<SetStateAction<string | null>>,
|
||||
) {
|
||||
return useCallback(
|
||||
async (id: string) => {
|
||||
clearBusy();
|
||||
return perform("delete", async () => {
|
||||
const accepted = await deleteStorageQuarantine(id);
|
||||
setDeleteJobId(accepted.job_id);
|
||||
toast({ title: "Permanent deletion started", variant: "success" });
|
||||
});
|
||||
},
|
||||
[clearBusy, perform, setDeleteJobId, toast],
|
||||
);
|
||||
}
|
||||
|
||||
function useStorageActions(reload: Reload) {
|
||||
const { toast } = useToast();
|
||||
const { pendingAction, error, setError, finishLoading, perform } = useStorageActionRunner();
|
||||
const [analysisJobId, setAnalysisJobId] = useState<string | null>(null);
|
||||
const [cleanupJobId, setCleanupJobId] = useState<string | null>(null);
|
||||
const [deleteJobId, setDeleteJobId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<StorageBusyState | null>(null);
|
||||
const analysisJob = useSystemJob(analysisJobId);
|
||||
const cleanupJob = useSystemJob(cleanupJobId);
|
||||
const deleteJob = useSystemJob(deleteJobId);
|
||||
const clearBusy = useCallback(() => setBusy(null), []);
|
||||
const { save, adopt } = useStoragePolicyActions(perform, reload, toast, clearBusy);
|
||||
|
||||
const analyze = useCallback(async () => {
|
||||
clearBusy();
|
||||
return perform("analyze", async () => {
|
||||
const accepted = await analyzeStorage();
|
||||
setAnalysisJobId(accepted.job_id);
|
||||
toast({ title: "Storage analysis started", variant: "success" });
|
||||
});
|
||||
}, [clearBusy, perform, toast]);
|
||||
|
||||
const runNow = useCallback(
|
||||
async (resources?: string[]) => {
|
||||
setCleanupJobId(null);
|
||||
clearBusy();
|
||||
return perform("run", async () => {
|
||||
const accepted = await runStorageMaintenance(resources);
|
||||
setCleanupJobId(accepted.job_id);
|
||||
toast({ title: "Storage maintenance started", variant: "success" });
|
||||
try {
|
||||
const accepted = await runStorageMaintenance(resources);
|
||||
setCleanupJobId(accepted.job_id);
|
||||
toast({ title: "Storage maintenance started", variant: "success" });
|
||||
} catch (error) {
|
||||
const nextBusy = busyStateFromError(error, resources);
|
||||
if (nextBusy) {
|
||||
setBusy(nextBusy);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
[perform, toast],
|
||||
[clearBusy, perform, toast],
|
||||
);
|
||||
const runAnyway = useCallback(async () => {
|
||||
if (!busy?.forceAvailable) return;
|
||||
const resources = busy.resourceSelection;
|
||||
clearBusy();
|
||||
setCleanupJobId(null);
|
||||
return perform("run", async () => {
|
||||
try {
|
||||
const accepted = await runStorageMaintenance(resources, true);
|
||||
setCleanupJobId(accepted.job_id);
|
||||
toast({ title: "Storage maintenance started", variant: "success" });
|
||||
} catch (error) {
|
||||
const nextBusy = busyStateFromError(error, resources);
|
||||
if (nextBusy) {
|
||||
setBusy(nextBusy);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}, [busy, clearBusy, perform, toast]);
|
||||
|
||||
const restore = useCallback(
|
||||
async (id: string) =>
|
||||
perform("restore", async () => {
|
||||
async (id: string) => {
|
||||
clearBusy();
|
||||
return perform("restore", async () => {
|
||||
await restoreStorageQuarantine(id);
|
||||
await reload();
|
||||
toast({ title: "Quarantined resource restored", variant: "success" });
|
||||
}),
|
||||
[perform, reload, toast],
|
||||
);
|
||||
|
||||
const permanentlyDelete = useCallback(
|
||||
async (id: string) =>
|
||||
perform("delete", async () => {
|
||||
const accepted = await deleteStorageQuarantine(id);
|
||||
setDeleteJobId(accepted.job_id);
|
||||
toast({ title: "Permanent deletion started", variant: "success" });
|
||||
}),
|
||||
[perform, toast],
|
||||
});
|
||||
},
|
||||
[clearBusy, perform, reload, toast],
|
||||
);
|
||||
const permanentlyDelete = useStorageDeleteAction(perform, toast, clearBusy, setDeleteJobId);
|
||||
|
||||
return {
|
||||
pendingAction,
|
||||
@@ -189,6 +274,8 @@ function useStorageActions(reload: Reload) {
|
||||
save,
|
||||
analyze,
|
||||
runNow,
|
||||
runAnyway,
|
||||
busy,
|
||||
adopt,
|
||||
restore,
|
||||
permanentlyDelete,
|
||||
|
||||
@@ -383,11 +383,17 @@ describe("storage maintenance", () => {
|
||||
it("starts analysis, selected cleanup, and restore operations", async () => {
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(jsonResponse({ job_id: "analysis" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ job_id: "cleanup" }))
|
||||
.mockResolvedValueOnce(jsonResponse({ entry: { id: "restored" } }));
|
||||
.mockResolvedValueOnce(jsonResponse({ job_id: "cleanup" }));
|
||||
expect((await analyzeStorage()).job_id).toBe("analysis");
|
||||
expect((await runStorageMaintenance(["workspaces"])).job_id).toBe("cleanup");
|
||||
expect(JSON.parse(String(lastCall().init?.body))).toEqual({ resources: ["workspaces"] });
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "forced-cleanup" }));
|
||||
expect((await runStorageMaintenance(["workspaces"], true)).job_id).toBe("forced-cleanup");
|
||||
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
|
||||
resources: ["workspaces"],
|
||||
force: true,
|
||||
});
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ entry: { id: "restored" } }));
|
||||
expect((await restoreStorageQuarantine("entry-1")).id).toBe("restored");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,6 +247,7 @@ export function analyzeStorage(options?: ApiRequestOptions): Promise<JobAcceptRe
|
||||
|
||||
export function runStorageMaintenance(
|
||||
resources?: string[],
|
||||
force = false,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<JobAcceptResponse> {
|
||||
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/storage/run`, {
|
||||
@@ -254,7 +255,10 @@ export function runStorageMaintenance(
|
||||
init: {
|
||||
...(options?.init ?? {}),
|
||||
method: "POST",
|
||||
body: JSON.stringify(resources?.length ? { resources } : {}),
|
||||
body: JSON.stringify({
|
||||
...(resources?.length ? { resources } : {}),
|
||||
...(force ? { force: true } : {}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,6 +259,17 @@ export type StorageRunState =
|
||||
| "cancelled"
|
||||
| "skipped_busy";
|
||||
|
||||
export interface StorageBusyResource {
|
||||
kind: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface StorageBusyResponse {
|
||||
error: string;
|
||||
busy_resources: StorageBusyResource[];
|
||||
force_available: boolean;
|
||||
}
|
||||
|
||||
export interface StorageMaintenanceRun {
|
||||
id: string;
|
||||
trigger: "scheduled" | "manual" | "analysis";
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
spec: docs/specs/system-page/storage-maintenance.md
|
||||
created: 2026-07-27
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Storage Busy Feedback and Override
|
||||
|
||||
## Overview
|
||||
|
||||
Make a blocked manual cleanup explain the Kandev activity categories that hold the gate, then let
|
||||
the operator explicitly run the same cleanup alongside current task activity. Preserve the
|
||||
install-wide mutual exclusion that prevents overlapping maintenance runs, and keep future task work
|
||||
able to cancel maintenance as it does today. The work proceeds from backend activity/API semantics,
|
||||
through the shared frontend controller and Storage page, to desktop and Pixel 5 coverage.
|
||||
|
||||
## Backend
|
||||
|
||||
### Activity-gate and manual-run contract
|
||||
|
||||
- Extend `apps/backend/internal/agent/runtime/activity/coordinator.go` with a typed public busy
|
||||
resource shape that retains the stable `Kind` and supplies the user-facing label. Preserve the
|
||||
existing kind values and sorting, but do not expose task prompts, paths, IDs, or names.
|
||||
- Add an explicit manual-maintenance acquisition mode that may ignore already-active task work but
|
||||
still rejects `maintenance_running`. It must install the normal maintenance lease so a task that
|
||||
begins after the force run can cancel it through `AcquireTask`.
|
||||
- Thread an explicit `force` flag through `apps/backend/internal/system/storage/operations.go` and
|
||||
`runner.go`. Normal manual and scheduled paths retain their current admission behavior; only a
|
||||
manual force request skips the current-activity check.
|
||||
- Extend the `Mutations` interface and `runRequest` in
|
||||
`apps/backend/internal/system/storage/handler.go`. A busy response returns typed
|
||||
`busy_resources` and `force_available`; accepted forced runs retain the existing `202 { job_id }`
|
||||
response.
|
||||
|
||||
### Backend tests
|
||||
|
||||
- Update `apps/backend/internal/agent/runtime/activity/coordinator_test.go` to prove force admission
|
||||
coexists with active task work, refuses an existing maintenance lease, and remains preemptible by
|
||||
subsequently admitted task activity.
|
||||
- Extend `apps/backend/internal/system/storage/operations_test.go`, `runner_test.go`,
|
||||
`handler_test.go`, and `storage_routes_test.go` for the typed 409 response, force=false rejection,
|
||||
force=true job start, and maintenance-running refusal.
|
||||
|
||||
## Frontend
|
||||
|
||||
### API, types, and domain controller
|
||||
|
||||
- Add the typed busy response/resource contracts to `apps/web/lib/types/system.ts` and let
|
||||
`runStorageMaintenance` in `apps/web/lib/api/domains/system-api.ts` send an optional `force` body
|
||||
field without changing callers that omit it.
|
||||
- In `apps/web/hooks/domains/system/use-storage-maintenance.ts`, inspect `ApiError.body` for the
|
||||
Storage 409 shape. Keep normal failures as ordinary action errors, but expose a structured busy
|
||||
state and a `runAnyway` action that repeats the original resource selection with `force: true`.
|
||||
|
||||
### Storage page and mobile contract
|
||||
|
||||
- Update `apps/web/components/settings/system/storage/storage-maintenance-settings.tsx` to replace
|
||||
the generic busy alert with a plain-language list of the reported activity labels, a disruption
|
||||
warning, and a distinct **Run anyway** button when `force_available` is true. An existing
|
||||
maintenance run remains informational with no override control.
|
||||
- Desktop keeps the busy feedback directly below the existing Analyze/Run-now action bar. On phones,
|
||||
the existing one-column Storage actions layout remains the nearest shipped exemplar: the warning
|
||||
and full-width, 44px **Run anyway** action stack inline with the page, need no drawer or dialog,
|
||||
preserve the document as the single scroll owner, and retain zero horizontal overflow.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Activity and operation semantics:** force cleanup runs beside existing task activity, cannot run
|
||||
beside maintenance, and later task work still cancels it. **Files:**
|
||||
`apps/backend/internal/agent/runtime/activity/coordinator_test.go`,
|
||||
`apps/backend/internal/system/storage/operations_test.go`, and `runner_test.go`.
|
||||
**How:** deterministic lease/channel tests plus existing tracked-job tests.
|
||||
- **HTTP contract:** the Storage route returns labeled blockers and availability on 409, accepts
|
||||
`force: true`, and does not change the ordinary 202 job response. **Files:**
|
||||
`apps/backend/internal/system/storage/handler_test.go` and `storage_routes_test.go`.
|
||||
- **Controller and rendered feedback:** the hook preserves resource selection after a structured
|
||||
409, sends force only from **Run anyway**, and the settings page renders warning/list/button only
|
||||
when supplied by the backend. **Files:**
|
||||
`apps/web/hooks/domains/system/use-storage-maintenance.test.tsx`,
|
||||
`apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx`, and
|
||||
`apps/web/lib/api/domains/system-api.test.ts`.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** a running task blocks manual cleanup. **File:**
|
||||
`apps/web/e2e/tests/system/storage-maintenance.spec.ts`. **Verify:** the page identifies the
|
||||
active work, exposes **Run anyway**, sends `{ force: true }`, and receives a normal cleanup job.
|
||||
- **Scenario:** the same busy feedback is usable on Pixel 5. **File:**
|
||||
`apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`. **Verify:** the warning and
|
||||
override remain visible/tappable, start the cleanup, and leave document horizontal overflow at
|
||||
zero.
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Backend activity and API contract](task-01-backend-activity-api.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [Task 02: Storage busy feedback and override UI](task-02-frontend-busy-override.md)
|
||||
|
||||
Wave 3:
|
||||
|
||||
- [x] [Task 03: Desktop and mobile override coverage](task-03-e2e-busy-override.md)
|
||||
|
||||
## Risks
|
||||
|
||||
- A forced cleanup can disrupt the active work it names; the inline warning must remain explicit
|
||||
and the feature must never silently retry with force.
|
||||
- Bypassing an active task must not become a bypass for an existing maintenance run; the latter
|
||||
remains an unavailable override.
|
||||
- The activity coordinator currently knows categories rather than user/task identity. The API must
|
||||
stay at that privacy-safe category granularity unless a later, separately designed feature adds
|
||||
activity identity.
|
||||
|
||||
## Validation Results
|
||||
|
||||
- Backend activity/storage/system race tests: passed (107 tests).
|
||||
- Backend backendapp/storage/system integration tests: passed (312 tests).
|
||||
- Web Storage unit tests: passed (57 tests).
|
||||
- Web typecheck: passed.
|
||||
- Managed desktop/mobile Storage E2E: passed (7 tests).
|
||||
- Commit hooks and GitHub PR checks: passed.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id: "01-backend-activity-api"
|
||||
title: "Backend activity and API contract"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/system-page/storage-maintenance.md"
|
||||
---
|
||||
|
||||
# Task 01: Backend Activity and API Contract
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A blocked manual Storage run returns stable busy kinds with user-facing labels and declares
|
||||
whether a force request is available.
|
||||
- `force: true` starts a cleanup beside current task activity, but never beside another maintenance
|
||||
run; a later task acquisition still cancels the forced maintenance lease.
|
||||
- Existing scheduled and non-forced manual admission behavior, run persistence, and 202 job shape
|
||||
remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/backend && go test ./internal/agent/runtime/activity ./internal/system/storage ./internal/system
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/agent/runtime/activity/coordinator.go`
|
||||
- `apps/backend/internal/agent/runtime/activity/coordinator_test.go`
|
||||
- `apps/backend/internal/system/storage/runner.go`
|
||||
- `apps/backend/internal/system/storage/runner_test.go`
|
||||
- `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`
|
||||
- `apps/backend/internal/system/storage_routes_test.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: Manual Run now, API surface, and busy-override scenarios
|
||||
- Plan: Backend and Tests
|
||||
- Existing activity lease behavior in `internal/agent/runtime/activity/coordinator.go`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report acceptance status, changed request/response shapes, focused test results, risks, and update
|
||||
this task plus `plan.md` to `done` in the same conversation.
|
||||
|
||||
## Validation Results
|
||||
|
||||
- `go test ./internal/agent/runtime/activity ./internal/system/storage ./internal/system -count=1 -race`: passed.
|
||||
- `go test ./internal/backendapp ./internal/system/storage ./internal/system -count=1`: passed.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: "02-frontend-busy-override"
|
||||
title: "Storage busy feedback and override UI"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-backend-activity-api"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/system-page/storage-maintenance.md"
|
||||
---
|
||||
|
||||
# Task 02: Storage Busy Feedback and Override UI
|
||||
|
||||
## Acceptance
|
||||
|
||||
- The Storage controller recognizes the typed 409 response, retains the original cleanup resource
|
||||
selection, and sends `force: true` only after the user selects **Run anyway**.
|
||||
- The settings page names every reported active category, warns about disruption, and renders the
|
||||
direct override only when the backend says it is available.
|
||||
- The existing phone one-column action composition presents the warning and an accessible, full-width
|
||||
override button without a dialog, hover-only behavior, or horizontal overflow.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run \
|
||||
hooks/domains/system/use-storage-maintenance.test.tsx \
|
||||
components/settings/system/storage/storage-maintenance-settings.test.tsx \
|
||||
lib/api/domains/system-api.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
## 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`
|
||||
- `apps/web/components/settings/system/storage/storage-maintenance-settings.tsx`
|
||||
- `apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 defines the typed 409 response and the `force` request field.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: API surface and busy-override scenarios
|
||||
- Plan: Frontend and Storage page/mobile contract
|
||||
- Existing `ApiError.body` handling in `apps/web/lib/api/client.ts`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report acceptance status, UI/API contract changes, focused test results, mobile evidence or blocker,
|
||||
risks, and update this task plus `plan.md` to `done` in the same conversation.
|
||||
|
||||
## Validation Results
|
||||
|
||||
- Focused web tests for the hook, Storage settings, and system API: passed (57 tests).
|
||||
- `cd apps/web && pnpm run typecheck`: passed.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: "03-e2e-busy-override"
|
||||
title: "Desktop and mobile override coverage"
|
||||
status: done
|
||||
wave: 3
|
||||
depends_on: ["01-backend-activity-api", "02-frontend-busy-override"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/system-page/storage-maintenance.md"
|
||||
---
|
||||
|
||||
# Task 03: Desktop and Mobile Override Coverage
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop E2E proves a running task yields labeled busy feedback, then **Run anyway** starts the
|
||||
normal cleanup job with `force: true`.
|
||||
- Pixel 5 E2E proves the same action is visible and tappable in the Storage page’s one-column flow,
|
||||
completes the user outcome, and leaves document horizontal overflow at zero.
|
||||
- The maintenance-running case remains unbypassable when practical to cover at the public route;
|
||||
otherwise the backend contract test is named as its coverage.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e:run --project chromium tests/system/storage-maintenance.spec.ts
|
||||
cd apps/web && pnpm e2e:run --project mobile-chrome 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`
|
||||
- `apps/web/e2e/helpers/storage-maintenance.ts` only if a deterministic activity fixture is needed
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tasks 01 and 02.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: busy-override scenarios
|
||||
- Plan: E2E Tests and mobile contract
|
||||
- Existing active-task busy scenario in `tests/system/storage-maintenance.spec.ts`
|
||||
|
||||
## Output contract
|
||||
|
||||
Report acceptance status, exact E2E command results, screenshots/traces on failure, risks, and
|
||||
update this task plus `plan.md` to `done` in the same conversation.
|
||||
|
||||
## Validation Results
|
||||
|
||||
- Desktop Storage E2E: passed (4 tests in the focused suite).
|
||||
- Pixel 5 Storage E2E: passed (3 tests in the focused suite).
|
||||
@@ -77,7 +77,9 @@ The supported SQLite layout for the System database and restore pages is the der
|
||||
|
||||
Open **Settings > System > Storage** to inspect Kandev-managed disk usage and configure cleanup.
|
||||
**Analyze** is read-only. **Run now** applies only the enabled cleanup rules and refuses to start
|
||||
while task resources are active or another maintenance run owns the cleanup gate.
|
||||
while another maintenance run owns the cleanup gate. If task resources are active, the page names
|
||||
the active work and offers **Run anyway** after an explicit disruption warning. Use that override
|
||||
only when the active task work can tolerate cleanup running alongside it.
|
||||
|
||||
Storage analysis results are cached in the running backend for 15 minutes, so page reloads and
|
||||
policy saves reuse the displayed snapshot instead of scanning disk again. The page shows when that
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
status: shipped
|
||||
status: building
|
||||
created: 2026-07-14
|
||||
updated: 2026-07-23
|
||||
updated: 2026-07-27
|
||||
owner: cfl
|
||||
---
|
||||
|
||||
@@ -54,8 +54,13 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
|
||||
context, waits for the active provider operation to stop, and then proceeds. Maintenance
|
||||
never races a newly admitted task.
|
||||
- Manual **Run now** uses the same mutual-exclusion/current-activity gate, but does not wait out
|
||||
the configured quiet period. It returns a visible busy result when task activity is current or
|
||||
another maintenance run holds the gate, and has no force-while-busy mode.
|
||||
the configured quiet period. When current Kandev activity blocks it, the page names each activity
|
||||
type it found (for example, a running agent session or test command), warns that cleanup can
|
||||
disrupt that work, and offers a distinct **Run anyway** action directly in the busy state.
|
||||
- **Run anyway** starts the requested manual cleanup alongside the activity that originally blocked
|
||||
it. It skips only the current-activity admission check: it neither stops the activity nor allows
|
||||
two storage-maintenance runs to overlap. New task work may still preempt the cleanup through the
|
||||
normal maintenance cancellation path.
|
||||
- The Go-cache analysis row exposes a resource-specific **Clean Go cache** action only when the
|
||||
cache is Kandev-owned and above its configured maximum. That action submits an explicit
|
||||
`go_cache` selection through the same manual-run gate.
|
||||
@@ -307,9 +312,13 @@ POST /api/v1/system/storage/analyze
|
||||
-> 202 { job_id }
|
||||
|
||||
POST /api/v1/system/storage/run
|
||||
body: { resources?: string[] }
|
||||
body: { resources?: string[], force?: boolean }
|
||||
-> 202 { job_id }
|
||||
-> 409 { error, busy_resources[] } when the idle gate is unavailable
|
||||
-> 409 {
|
||||
error: string,
|
||||
busy_resources: [{ kind: string, label: string }],
|
||||
force_available: boolean
|
||||
} when current activity or another maintenance run holds the gate
|
||||
|
||||
GET /api/v1/system/storage/runs?limit=20
|
||||
-> { runs: StorageMaintenanceRun[] }
|
||||
@@ -336,6 +345,13 @@ 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`.
|
||||
|
||||
`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;
|
||||
it is false when another storage-maintenance run already holds the install-wide maintenance lease.
|
||||
When `force: true` is accepted, the response remains the ordinary `202 { job_id }` cleanup-job
|
||||
contract.
|
||||
|
||||
The task unarchive response may additionally include:
|
||||
|
||||
```json
|
||||
@@ -452,6 +468,14 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
|
||||
arrives, **THEN** the run is recorded as `skipped_busy` and no provider changes resources.
|
||||
- **GIVEN** maintenance holds the idle gate, **WHEN** a new task launch arrives, **THEN** maintenance
|
||||
is cancelled and the launch proceeds only after the active provider stops.
|
||||
- **GIVEN** a running agent session or command blocks manual cleanup, **WHEN** the user selects
|
||||
**Run now**, **THEN** the page names the activity categories found, warns that cleanup can disrupt
|
||||
them, and shows **Run anyway** without opening a confirmation dialog.
|
||||
- **GIVEN** a manual cleanup is blocked only by current task activity, **WHEN** the user selects
|
||||
**Run anyway**, **THEN** Kandev starts the requested cleanup with `force: true` while the existing
|
||||
activity continues and records the normal cleanup-job result.
|
||||
- **GIVEN** a manual cleanup is blocked by another storage-maintenance run, **WHEN** the user views
|
||||
the busy feedback, **THEN** it identifies maintenance as the blocker and does not offer a bypass.
|
||||
- **GIVEN** an unreferenced task directory older than the orphan grace period contains
|
||||
`node_modules`, **WHEN** workspace cleanup runs with a successful authoritative inventory,
|
||||
**THEN** the whole task root moves to quarantine and its measured bytes appear in the run result.
|
||||
|
||||
Reference in New Issue
Block a user