diff --git a/apps/backend/internal/agent/docker/storage.go b/apps/backend/internal/agent/docker/storage.go index b6c627f8bb..2e656e8079 100644 --- a/apps/backend/internal/agent/docker/storage.go +++ b/apps/backend/internal/agent/docker/storage.go @@ -33,6 +33,7 @@ type ContainerUsage struct { } type DiskUsage struct { + ImageLayerBytes int64 `json:"image_layer_bytes"` BuildCacheBytes int64 `json:"build_cache_bytes"` Images []ImageUsage `json:"images"` Containers []ContainerUsage `json:"containers"` @@ -60,8 +61,9 @@ func (c *Client) DiskUsage(ctx context.Context) (DiskUsage, error) { return DiskUsage{}, fmt.Errorf("read Docker disk usage: %w", err) } result := DiskUsage{ - Images: make([]ImageUsage, 0, len(usage.Images)), - Containers: make([]ContainerUsage, 0, len(usage.Containers)), + ImageLayerBytes: usage.LayersSize, + Images: make([]ImageUsage, 0, len(usage.Images)), + Containers: make([]ContainerUsage, 0, len(usage.Containers)), } for _, cache := range usage.BuildCache { if cache != nil && cache.Size > 0 { diff --git a/apps/backend/internal/agent/docker/storage_test.go b/apps/backend/internal/agent/docker/storage_test.go index 216a66b344..9568db0a4c 100644 --- a/apps/backend/internal/agent/docker/storage_test.go +++ b/apps/backend/internal/agent/docker/storage_test.go @@ -40,7 +40,7 @@ func TestStorageResultJSONUsesSnakeCase(t *testing.T) { value: DiskUsage{ BuildCacheBytes: 30, Images: []ImageUsage{}, Containers: []ContainerUsage{}, }, - want: `{"build_cache_bytes":30,"images":[],"containers":[]}`, + want: `{"image_layer_bytes":0,"build_cache_bytes":30,"images":[],"containers":[]}`, }, { name: "prune result", value: PruneResult{Deleted: 3, BytesReclaimed: 40}, @@ -64,7 +64,8 @@ func TestDiskUsageMapsTypedSDKResponse(t *testing.T) { created := time.Unix(100, 0) lastUsed := time.Unix(200, 0) sdk := &fakeStorageAPI{usage: dockertypes.DiskUsage{ - Images: []*image.Summary{{ID: "image-1", Size: 300, Containers: 0, Created: created.Unix()}}, + LayersSize: 500, + Images: []*image.Summary{{ID: "image-1", Size: 300, Containers: 0, Created: created.Unix()}}, Containers: []*container.Summary{ {ID: "managed", SizeRw: 75, Labels: map[string]string{"kandev.managed": "true"}}, {ID: "unrelated", SizeRw: 125, Labels: map[string]string{"owner": "user"}}, @@ -80,7 +81,8 @@ func TestDiskUsageMapsTypedSDKResponse(t *testing.T) { if err != nil { t.Fatalf("DiskUsage: %v", err) } - if got.BuildCacheBytes != 300 || len(got.Images) != 1 || !got.Images[0].CreatedAt.Equal(created) || + if got.ImageLayerBytes != 500 || got.BuildCacheBytes != 300 || + len(got.Images) != 1 || !got.Images[0].CreatedAt.Equal(created) || len(got.Containers) != 2 || got.Containers[0].WritableBytes != 75 || got.Containers[0].Labels["kandev.managed"] != "true" { t.Fatalf("disk usage = %#v", got) diff --git a/apps/backend/internal/backendapp/storage_inventory.go b/apps/backend/internal/backendapp/storage_inventory.go index 79c8b4f806..21cd8e72f6 100644 --- a/apps/backend/internal/backendapp/storage_inventory.go +++ b/apps/backend/internal/backendapp/storage_inventory.go @@ -27,7 +27,7 @@ func (i *storageInventory) LoadWorkspaceInventory(ctx context.Context) (workspac if i.worktrees == nil || i.lifecycle == nil { return workspaces.Inventory{}, workspaces.ErrInventoryIncomplete } - paths, err := i.worktrees.ListActiveWorktreePaths(ctx) + paths, err := i.activeWorktreePaths(ctx) if err != nil { return workspaces.Inventory{}, err } @@ -52,12 +52,30 @@ func (i *storageInventory) LoadWorkspaceInventory(ctx context.Context) (workspac return inventory, nil } +func (i *storageInventory) activeWorktreePaths(ctx context.Context) ([]string, error) { + paths := make([]string, 0) + query := "SELECT w.worktree_path FROM task_session_worktrees w " + + "INNER JOIN task_sessions s ON s.id = w.session_id " + + "INNER JOIN tasks t ON t.id = s.task_id " + + "WHERE t.archived_at IS NULL AND w.status = 'active' " + + "AND w.deleted_at IS NULL AND w.worktree_path <> ''" + if err := i.reader.SelectContext(ctx, &paths, query); err != nil { + return nil, err + } + return paths, nil +} + func (i *storageInventory) activeWorkspaceRows(ctx context.Context) ([]activeWorkspaceRow, error) { rows := make([]activeWorkspaceRow, 0) query := "SELECT te.task_id AS taskid, COALESCE(t.workspace_id, '') AS workspaceid, " + "te.workspace_path AS workspacepath FROM task_environments te " + "LEFT JOIN tasks t ON t.id = te.task_id " + - "WHERE te.status IN ('creating', 'ready') AND te.workspace_path <> ''" + "WHERE te.status IN ('creating', 'ready') AND te.workspace_path <> '' AND (" + + "(t.id IS NOT NULL AND t.archived_at IS NULL) OR EXISTS (" + + "SELECT 1 FROM task_sessions borrower " + + "INNER JOIN tasks borrower_task ON borrower_task.id = borrower.task_id " + + "WHERE borrower.task_environment_id = te.id AND borrower_task.archived_at IS NULL " + + "AND borrower.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')))" if err := i.reader.SelectContext(ctx, &rows, query); err != nil { return nil, err } diff --git a/apps/backend/internal/backendapp/storage_inventory_test.go b/apps/backend/internal/backendapp/storage_inventory_test.go index b7f40a51e3..efc59a5a32 100644 --- a/apps/backend/internal/backendapp/storage_inventory_test.go +++ b/apps/backend/internal/backendapp/storage_inventory_test.go @@ -3,6 +3,7 @@ package backendapp import ( "context" "path/filepath" + "reflect" "testing" "time" @@ -13,6 +14,87 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +func TestWorkspaceInventoryOnlyProtectsActiveTaskEnvironments(t *testing.T) { + database := newContainerInventoryDatabase(t) + insertContainerInventoryTask(t, database, "active", v1.TaskStateInProgress, false) + insertContainerInventoryTask(t, database, "archived", v1.TaskStateInProgress, true) + insertContainerInventoryTask(t, database, "borrowed-owner", v1.TaskStateInProgress, true) + insertContainerInventoryTask(t, database, "borrower", v1.TaskStateInProgress, false) + for _, item := range []struct { + taskID string + path string + }{ + {taskID: "active", path: "/tasks/active_abc"}, + {taskID: "archived", path: "/tasks/archived_def"}, + {taskID: "borrowed-owner", path: "/tasks/borrowed_jkl"}, + {taskID: "deleted", path: "/tasks/deleted_ghi"}, + } { + if _, err := database.Exec( + "INSERT INTO task_environments (id, task_id, status, workspace_path) VALUES (?, ?, ?, ?)", + "env-"+item.taskID, item.taskID, models.TaskEnvironmentStatusReady, item.path, + ); err != nil { + t.Fatalf("insert task environment for %q: %v", item.taskID, err) + } + } + if _, err := database.Exec( + "INSERT INTO task_sessions (id, task_id, state, task_environment_id) VALUES (?, ?, ?, ?)", + "session-borrower", "borrower", models.TaskSessionStateRunning, "env-borrowed-owner", + ); err != nil { + t.Fatalf("insert borrowed environment session: %v", err) + } + + rows, err := (&storageInventory{reader: database}).activeWorkspaceRows(context.Background()) + if err != nil { + t.Fatalf("activeWorkspaceRows: %v", err) + } + want := []activeWorkspaceRow{ + {TaskID: "active", WorkspaceID: "workspace-active", WorkspacePath: "/tasks/active_abc"}, + { + TaskID: "borrowed-owner", WorkspaceID: "workspace-borrowed-owner", + WorkspacePath: "/tasks/borrowed_jkl", + }, + } + if !reflect.DeepEqual(rows, want) { + t.Fatalf("active workspace rows = %#v, want %#v", rows, want) + } +} + +func TestWorkspaceInventoryOnlyProtectsActiveTaskWorktrees(t *testing.T) { + database := newContainerInventoryDatabase(t) + insertContainerInventoryTask(t, database, "active", v1.TaskStateInProgress, false) + insertContainerInventoryTask(t, database, "archived", v1.TaskStateInProgress, true) + for _, item := range []struct { + taskID string + path string + }{ + {taskID: "active", path: "/tasks/active_abc/repo"}, + {taskID: "archived", path: "/tasks/archived_def/repo"}, + {taskID: "deleted", path: "/tasks/deleted_ghi/repo"}, + } { + sessionID := "session-" + item.taskID + if _, err := database.Exec( + "INSERT INTO task_sessions (id, task_id) VALUES (?, ?)", sessionID, item.taskID, + ); err != nil { + t.Fatalf("insert task session for %q: %v", item.taskID, err) + } + if _, err := database.Exec( + "INSERT INTO task_session_worktrees (id, session_id, status, worktree_path) VALUES (?, ?, 'active', ?)", + "worktree-"+item.taskID, sessionID, item.path, + ); err != nil { + t.Fatalf("insert task worktree for %q: %v", item.taskID, err) + } + } + + paths, err := (&storageInventory{reader: database}).activeWorktreePaths(context.Background()) + if err != nil { + t.Fatalf("activeWorktreePaths: %v", err) + } + want := []string{"/tasks/active_abc/repo"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("active worktree paths = %#v, want %#v", paths, want) + } +} + func TestContainerInventoryRemovability(t *testing.T) { tests := []struct { name string @@ -134,12 +216,27 @@ func newContainerInventoryDatabase(t *testing.T) *sqlx.DB { CREATE TABLE tasks ( id TEXT PRIMARY KEY, state TEXT NOT NULL, + workspace_id TEXT NOT NULL DEFAULT '', archived_at TIMESTAMP ); CREATE TABLE task_environments ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, - status TEXT NOT NULL + status TEXT NOT NULL, + workspace_path TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE task_sessions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'CREATED', + task_environment_id TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE task_session_worktrees ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + status TEXT NOT NULL, + worktree_path TEXT NOT NULL DEFAULT '', + deleted_at TIMESTAMP ); CREATE TABLE executors_running ( id TEXT PRIMARY KEY, @@ -166,8 +263,8 @@ func insertContainerInventoryTask( archivedAt = time.Now().UTC() } if _, err := database.Exec( - "INSERT INTO tasks (id, state, archived_at) VALUES (?, ?, ?)", - taskID, state, archivedAt, + "INSERT INTO tasks (id, state, workspace_id, archived_at) VALUES (?, ?, ?, ?)", + taskID, state, "workspace-"+taskID, archivedAt, ); err != nil { t.Fatalf("insert task %q: %v", taskID, err) } diff --git a/apps/backend/internal/backendapp/storage_maintenance.go b/apps/backend/internal/backendapp/storage_maintenance.go index 818cf5af0a..20e2818367 100644 --- a/apps/backend/internal/backendapp/storage_maintenance.go +++ b/apps/backend/internal/backendapp/storage_maintenance.go @@ -154,6 +154,7 @@ func (o *storageOverview) Summary(ctx context.Context) (storagepkg.Summary, erro Quarantine: summaryValue(quarantineSummary, quarantineErr), Docker: map[string]any{ "available": dockerSummary.Available, "build_cache_bytes": dockerSummary.BuildCacheBytes, + "image_layer_bytes": dockerSummary.ImageLayerBytes, "unused_image_bytes": dockerSummary.UnusedImageBytes, "warnings": dockerSummary.Warnings, "managed_container_count": dockerSummary.ManagedContainerCount, "managed_container_bytes": dockerSummary.ManagedContainerBytes, diff --git a/apps/backend/internal/backendapp/storage_maintenance_test.go b/apps/backend/internal/backendapp/storage_maintenance_test.go index 7e965c7a24..8a7740ed15 100644 --- a/apps/backend/internal/backendapp/storage_maintenance_test.go +++ b/apps/backend/internal/backendapp/storage_maintenance_test.go @@ -42,7 +42,7 @@ func TestStorageOverviewIncludesQuarantineAndManagedContainers(t *testing.T) { t.Fatalf("CreateQuarantineEntry: %v", err) } docker := dockerstore.NewProvider( - &overviewDockerClient{usage: agentdocker.DiskUsage{Containers: []agentdocker.ContainerUsage{ + &overviewDockerClient{usage: agentdocker.DiskUsage{ImageLayerBytes: 128, Containers: []agentdocker.ContainerUsage{ {ID: "managed", WritableBytes: 64, Labels: map[string]string{"kandev.managed": "true"}}, }}}, overviewContainerInventory{}, settings, @@ -72,7 +72,9 @@ func TestStorageOverviewIncludesQuarantineAndManagedContainers(t *testing.T) { t.Fatalf("quarantine summary = %#v", summary.Quarantine) } dockerSummary, ok := summary.Docker.(map[string]any) - if !ok || dockerSummary["managed_container_count"] != 1 || dockerSummary["managed_container_bytes"] != int64(64) { + if !ok || dockerSummary["managed_container_count"] != 1 || + dockerSummary["managed_container_bytes"] != int64(64) || + dockerSummary["image_layer_bytes"] != int64(128) { t.Fatalf("docker summary = %#v", summary.Docker) } diff --git a/apps/backend/internal/system/storage/dockerstore/provider.go b/apps/backend/internal/system/storage/dockerstore/provider.go index 2b5bb8a30f..f1182e0fa9 100644 --- a/apps/backend/internal/system/storage/dockerstore/provider.go +++ b/apps/backend/internal/system/storage/dockerstore/provider.go @@ -43,6 +43,7 @@ type Analysis struct { Available bool `json:"available"` ManagedContainerCount int `json:"managed_container_count"` ManagedContainerBytes int64 `json:"managed_container_bytes"` + ImageLayerBytes int64 `json:"image_layer_bytes"` BuildCacheBytes int64 `json:"build_cache_bytes"` UnusedImageBytes int64 `json:"unused_image_bytes"` Warnings []string `json:"warnings"` @@ -96,6 +97,7 @@ func (p *Provider) Analyze(ctx context.Context) Analysis { Available: true, ManagedContainerCount: managedCount, ManagedContainerBytes: managedBytes, + ImageLayerBytes: usage.ImageLayerBytes, BuildCacheBytes: usage.BuildCacheBytes, UnusedImageBytes: unusedImageBytes(usage.Images, cutoff), } diff --git a/apps/backend/internal/system/storage/dockerstore/provider_test.go b/apps/backend/internal/system/storage/dockerstore/provider_test.go index 40a09fb29e..efec65b1ea 100644 --- a/apps/backend/internal/system/storage/dockerstore/provider_test.go +++ b/apps/backend/internal/system/storage/dockerstore/provider_test.go @@ -32,7 +32,7 @@ func TestAnalysisJSONUsesStorageAPISnakeCase(t *testing.T) { if err != nil { t.Fatalf("Marshal Analysis: %v", err) } - want := `{"available":true,"managed_container_count":1,"managed_container_bytes":2,"build_cache_bytes":3,"unused_image_bytes":4,"warnings":["warn"]}` + want := `{"available":true,"managed_container_count":1,"managed_container_bytes":2,"image_layer_bytes":0,"build_cache_bytes":3,"unused_image_bytes":4,"warnings":["warn"]}` if string(encoded) != want { t.Fatalf("Analysis JSON = %s, want %s", encoded, want) } @@ -76,6 +76,7 @@ func TestCleanupContainersFailsClosedOnInventoryError(t *testing.T) { func TestAnalyzeIsReadOnlyAndReportsUnavailableWithoutError(t *testing.T) { t.Run("read only", func(t *testing.T) { docker := &fakeDockerClient{usage: agentdocker.DiskUsage{ + ImageLayerBytes: 150, BuildCacheBytes: 200, Containers: []agentdocker.ContainerUsage{ {ID: "managed", WritableBytes: 25, Labels: map[string]string{"kandev.managed": "true"}}, @@ -91,7 +92,7 @@ func TestAnalyzeIsReadOnlyAndReportsUnavailableWithoutError(t *testing.T) { provider.now = func() time.Time { return time.Unix(2000000, 0) } got := provider.Analyze(context.Background()) - if !got.Available || got.BuildCacheBytes != 200 || got.UnusedImageBytes != 300 || + if !got.Available || got.ImageLayerBytes != 150 || got.BuildCacheBytes != 200 || got.UnusedImageBytes != 300 || got.ManagedContainerCount != 2 || got.ManagedContainerBytes != 25 { t.Fatalf("analysis = %#v", got) } diff --git a/apps/backend/internal/system/storage/gocache/provider.go b/apps/backend/internal/system/storage/gocache/provider.go index 8c9841f064..cc2eaecaeb 100644 --- a/apps/backend/internal/system/storage/gocache/provider.go +++ b/apps/backend/internal/system/storage/gocache/provider.go @@ -53,10 +53,12 @@ type Provider struct { // Analysis describes the configured cache without changing it. type Analysis struct { - Path string `json:"path"` - SizeBytes int64 `json:"size_bytes"` - Owned bool `json:"owned"` - Enabled bool `json:"enabled"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Owned bool `json:"owned"` + Enabled bool `json:"enabled"` + UnmanagedPath string `json:"unmanaged_path,omitempty"` + UnmanagedSizeBytes int64 `json:"unmanaged_size_bytes,omitempty"` } // CleanupResult describes one cache rotation. @@ -118,7 +120,32 @@ func (p *Provider) Analyze(ctx context.Context) (Analysis, error) { if err != nil { return Analysis{}, err } - return Analysis{Path: cachePath, SizeBytes: size, Owned: owned, Enabled: settings.GoCache.Enabled}, nil + analysis := Analysis{Path: cachePath, SizeBytes: size, Owned: owned, Enabled: settings.GoCache.Enabled} + unmanagedPath, ok := defaultGoCachePath() + if !ok || unmanagedPath == cachePath { + return analysis, nil + } + unmanagedSize, err := directorySizeNoFollow(unmanagedPath) + if err != nil { + return Analysis{}, err + } + analysis.UnmanagedPath = unmanagedPath + analysis.UnmanagedSizeBytes = unmanagedSize + return analysis, nil +} + +func defaultGoCachePath() (string, bool) { + if configured := os.Getenv("GOCACHE"); configured != "" { + if configured == "off" || !filepath.IsAbs(configured) { + return "", false + } + return filepath.Clean(configured), true + } + cacheDir, err := os.UserCacheDir() + if err != nil || !filepath.IsAbs(cacheDir) { + return "", false + } + return filepath.Join(cacheDir, "go-build"), true } // Cleanup rotates an above-threshold cache into Kandev trash. @@ -400,6 +427,14 @@ func rejectSymlink(path string) error { } func directorySize(root string) (int64, error) { + return directorySizeWithSymlinkPolicy(root, false) +} + +func directorySizeNoFollow(root string) (int64, error) { + return directorySizeWithSymlinkPolicy(root, true) +} + +func directorySizeWithSymlinkPolicy(root string, skipSymlinks bool) (int64, error) { if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) { return 0, nil } else if err != nil { @@ -411,6 +446,9 @@ func directorySize(root string) (int64, error) { return walkErr } if entry.Type()&os.ModeSymlink != 0 { + if skipSymlinks { + return nil + } return fmt.Errorf("symlink found in Go cache: %s", entry.Name()) } if path == markerPath(root) { diff --git a/apps/backend/internal/system/storage/gocache/provider_test.go b/apps/backend/internal/system/storage/gocache/provider_test.go index 4644403dbe..b8c6427f1a 100644 --- a/apps/backend/internal/system/storage/gocache/provider_test.go +++ b/apps/backend/internal/system/storage/gocache/provider_test.go @@ -13,11 +13,14 @@ import ( ) func TestAnalysisJSONUsesStorageAPISnakeCase(t *testing.T) { - encoded, err := json.Marshal(Analysis{Path: "/cache", SizeBytes: 42, Owned: true, Enabled: false}) + encoded, err := json.Marshal(Analysis{ + Path: "/cache", SizeBytes: 42, Owned: true, Enabled: false, + UnmanagedPath: "/user-cache", UnmanagedSizeBytes: 24, + }) if err != nil { t.Fatalf("Marshal Analysis: %v", err) } - want := `{"path":"/cache","size_bytes":42,"owned":true,"enabled":false}` + want := `{"path":"/cache","size_bytes":42,"owned":true,"enabled":false,"unmanaged_path":"/user-cache","unmanaged_size_bytes":24}` if string(encoded) != want { t.Fatalf("Analysis JSON = %s, want %s", encoded, want) } @@ -136,6 +139,45 @@ func TestExecutionEnvironmentCreatesOwnedManagedCache(t *testing.T) { } } +func TestAnalyzeReportsUnmanagedDefaultGoCacheReadOnly(t *testing.T) { + home := t.TempDir() + userCache := filepath.Join(t.TempDir(), "go-build") + if err := os.MkdirAll(userCache, 0o755); err != nil { + t.Fatal(err) + } + artifact := filepath.Join(userCache, "artifact") + if err := os.WriteFile(artifact, []byte("user cache bytes"), 0o600); err != nil { + t.Fatal(err) + } + externalArtifact := filepath.Join(t.TempDir(), "external") + if err := os.WriteFile(externalArtifact, []byte("external bytes"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(externalArtifact, filepath.Join(userCache, "external-link")); err != nil { + t.Fatal(err) + } + t.Setenv("GOCACHE", userCache) + provider := New(Config{ + HomeDir: home, TrashDir: filepath.Join(home, "trash"), + Settings: staticSettings{settings: storage.DefaultSettings()}, + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.UnmanagedPath != userCache || + analysis.UnmanagedSizeBytes != int64(len("user cache bytes")) { + t.Fatalf("analysis = %#v, want unmanaged cache %s", analysis, userCache) + } + if _, err := os.Stat(artifact); err != nil { + t.Fatalf("Analyze modified unmanaged cache: %v", err) + } + if _, err := os.Stat(externalArtifact); err != nil { + t.Fatalf("Analyze followed or modified unmanaged cache symlink: %v", err) + } +} + func TestCleanupRotatesOwnedCacheAboveThreshold(t *testing.T) { home := t.TempDir() settings := storage.DefaultSettings() diff --git a/apps/backend/internal/system/storage/workspaces/provider.go b/apps/backend/internal/system/storage/workspaces/provider.go index 6eaa39a00c..99a5d7362b 100644 --- a/apps/backend/internal/system/storage/workspaces/provider.go +++ b/apps/backend/internal/system/storage/workspaces/provider.go @@ -42,25 +42,41 @@ func New(config Config) *Provider { } type candidate struct { - path string - owner OwnershipMarker - size int64 + path string + owner OwnershipMarker + size int64 + measured bool } func (p *Provider) Analyze(ctx context.Context) (Analysis, error) { - candidates, protected, warnings, err := p.classify(ctx) + _, trashTasks, protected, roots, warnings, err := p.classificationInputs(ctx) if err != nil { return Analysis{}, err } analysis := Analysis{Warnings: warnings} - for _, item := range candidates { - analysis.CandidateBytes += item.size - } - for path := range protected { - if size, sizeErr := directorySizeNoSymlinks(path); sizeErr == nil { + for index := range roots { + size, sizeErr := directorySizeNoFollow(roots[index].path) + if sizeErr != nil { + analysis.Warnings = append( + analysis.Warnings, + fmt.Sprintf("measure workspace %s: %v", roots[index].path, sizeErr), + ) + continue + } + roots[index].size = size + roots[index].measured = true + analysis.TotalBytes += size + if _, active := protected[roots[index].path]; active { analysis.ActiveBytes += size } } + candidates, err := p.eligibleCandidates(roots, protected, trashTasks) + if err != nil { + return Analysis{}, err + } + for _, item := range candidates { + analysis.CandidateBytes += item.size + } return analysis, nil } @@ -145,11 +161,14 @@ func (p *Provider) eligibleCandidates( if info.ModTime().After(cutoff) { continue } - size, err := directorySizeNoSymlinks(root.path) - if err != nil { - return nil, fmt.Errorf("measure workspace candidate %s: %w", root.path, err) + if !root.measured { + size, err := directorySizeNoFollow(root.path) + if err != nil { + return nil, fmt.Errorf("measure workspace candidate %s: %w", root.path, err) + } + root.size = size + root.measured = true } - root.size = size candidates = append(candidates, root) } return candidates, nil @@ -337,7 +356,7 @@ func (p *Provider) PermanentDelete( if p.config.Now().Before(entry.DeleteAfter) { return entry, fmt.Errorf("%w: quarantine retention deadline has not elapsed", storage.ErrConflict) } - if _, err := directorySizeNoSymlinks(entry.QuarantinePath); err != nil && !errors.Is(err, os.ErrNotExist) { + 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) { @@ -632,14 +651,15 @@ func looksSemanticTaskDir(name string) bool { return index > 0 && len(name[index+1:]) == 3 } -func directorySizeNoSymlinks(root string) (int64, error) { +// directorySizeNoFollow counts files in root while treating nested symlinks as opaque entries. +func directorySizeNoFollow(root string) (int64, error) { var size int64 err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if entry.Type()&os.ModeSymlink != 0 { - return fmt.Errorf("symlink found at %s", path) + return nil } if entry.Type().IsRegular() { info, err := entry.Info() @@ -705,7 +725,34 @@ func writeJSONFile(path string, value any) error { if err != nil { return err } - return os.WriteFile(path, encoded, 0o600) + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("unsafe JSON control file: %s", path) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect JSON control file %s: %w", path, err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".kandev-control-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(encoded); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("install JSON control file %s: %w", path, err) + } + return nil } func readQuarantineManifest(root string) (quarantineManifest, error) { diff --git a/apps/backend/internal/system/storage/workspaces/provider_test.go b/apps/backend/internal/system/storage/workspaces/provider_test.go index a01f2daaa4..655ba02edd 100644 --- a/apps/backend/internal/system/storage/workspaces/provider_test.go +++ b/apps/backend/internal/system/storage/workspaces/provider_test.go @@ -114,6 +114,49 @@ func TestCleanupFailsClosedWhenInventoryIsIncompleteOrErrors(t *testing.T) { } } +func TestAnalyzeReportsAllWorkspaceBytesIncludingYoungOrphans(t *testing.T) { + provider, root, _ := newProviderFixture(t, Inventory{Complete: true}, nil) + oldOrphan := createOwnedCandidate(t, root, "old-orphan_abc", OwnershipMarker{ + TaskID: "old", TaskDirName: "old-orphan_abc", LayoutVersion: LayoutVersionSemantic, + }) + youngOrphan := createOwnedCandidate(t, root, "young-orphan_def", OwnershipMarker{ + TaskID: "young", TaskDirName: "young-orphan_def", LayoutVersion: LayoutVersionSemantic, + }) + active := createOwnedCandidate(t, root, "active-task_ghi", OwnershipMarker{ + TaskID: "active", TaskDirName: "active-task_ghi", LayoutVersion: LayoutVersionSemantic, + }) + for path, contents := range map[string]string{ + oldOrphan: "old orphan bytes", youngOrphan: "young orphan bytes", active: "active bytes", + } { + if err := os.WriteFile(filepath.Join(path, "artifact"), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + old := provider.config.Now().Add(-8 * 24 * time.Hour) + if err := os.Chtimes(oldOrphan, old, old); err != nil { + t.Fatal(err) + } + provider.config.Inventory = fakeInventorySource{inventory: Inventory{ + Complete: true, EnvironmentPaths: []string{active}, + }} + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + wantTotal := int64(0) + for _, path := range []string{oldOrphan, youngOrphan, active} { + size, sizeErr := directorySizeNoFollow(path) + if sizeErr != nil { + t.Fatal(sizeErr) + } + wantTotal += size + } + if analysis.TotalBytes != wantTotal { + t.Fatalf("TotalBytes = %d, want %d", analysis.TotalBytes, wantTotal) + } +} + func TestCleanupQuarantinesOldOwnedOrphanAndRestoreIsConflictSafe(t *testing.T) { provider, root, store := newProviderFixture(t, Inventory{Complete: true}, nil) candidate := createOwnedCandidate(t, root, "orphan-task_abc", OwnershipMarker{ @@ -282,33 +325,77 @@ func TestCleanupProtectsEveryInventorySourceAndScratchSiblingsIndependently(t *t } } -func TestCleanupPathUncertaintyAbortsBeforeAnyMove(t *testing.T) { +func TestCleanupQuarantinesWorkspaceWithoutFollowingNestedSymlink(t *testing.T) { provider, root, store := newProviderFixture(t, Inventory{Complete: true}, nil) - first := createOwnedCandidate(t, root, "first-task_abc", OwnershipMarker{TaskID: "first", TaskDirName: "first-task_abc", LayoutVersion: LayoutVersionSemantic}) - unsafe := createOwnedCandidate(t, root, "unsafe-task_def", OwnershipMarker{TaskID: "unsafe", TaskDirName: "unsafe-task_def", LayoutVersion: LayoutVersionSemantic}) - for _, path := range []string{first, unsafe} { - old := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC) - if err := os.Chtimes(path, old, old); err != nil { - t.Fatalf("Chtimes: %v", err) - } + candidate := createOwnedCandidate(t, root, "linked-task_abc", OwnershipMarker{ + TaskID: "linked", TaskDirName: "linked-task_abc", LayoutVersion: LayoutVersionSemantic, + }) + external := t.TempDir() + externalArtifact := filepath.Join(external, "keep") + if err := os.WriteFile(externalArtifact, []byte("external"), 0o600); err != nil { + t.Fatal(err) } - if err := os.Symlink(t.TempDir(), filepath.Join(unsafe, "escape")); err != nil { + if err := os.Symlink(external, filepath.Join(candidate, "external-link")); err != nil { t.Fatalf("Symlink: %v", err) } old := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC) - if err := os.Chtimes(unsafe, old, old); err != nil { - t.Fatalf("Chtimes unsafe after symlink: %v", err) + if err := os.Chtimes(candidate, old, old); err != nil { + t.Fatalf("Chtimes candidate: %v", err) } + + result, err := provider.Cleanup(context.Background()) + if err != nil { + t.Fatalf("Cleanup: %v", err) + } + if result.Quarantined != 1 || len(store.entries) != 1 { + t.Fatalf("cleanup result=%#v entries=%#v", result, store.entries) + } + if _, err := os.Stat(externalArtifact); err != nil { + t.Fatalf("cleanup followed nested symlink target: %v", err) + } + entry := store.entries["entry-1"] + if _, err := os.Lstat(filepath.Join(entry.QuarantinePath, "external-link")); err != nil { + t.Fatalf("symlink entry was not quarantined: %v", err) + } + provider.config.Now = func() time.Time { return entry.DeleteAfter } + if _, err := provider.PermanentDelete(context.Background(), entry.ID, "DELETE"); err != nil { + t.Fatalf("PermanentDelete: %v", err) + } + if _, err := os.Stat(externalArtifact); err != nil { + t.Fatalf("permanent deletion followed nested symlink target: %v", err) + } +} + +func TestCleanupRejectsSymlinkedQuarantineManifestWithoutFollowingTarget(t *testing.T) { + provider, root, _ := newProviderFixture(t, Inventory{Complete: true}, nil) + candidate := createOwnedCandidate(t, root, "linked-manifest_abc", OwnershipMarker{ + TaskID: "linked-manifest", TaskDirName: "linked-manifest_abc", LayoutVersion: LayoutVersionSemantic, + }) + externalArtifact := filepath.Join(t.TempDir(), "keep") + const original = "external" + if err := os.WriteFile(externalArtifact, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(externalArtifact, filepath.Join(candidate, quarantineManifestName)); err != nil { + t.Fatalf("Symlink quarantine manifest: %v", err) + } + 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 candidate: %v", err) + } + if _, err := provider.Cleanup(context.Background()); err == nil { - t.Fatal("Cleanup succeeded with symlink uncertainty") + t.Fatal("Cleanup succeeded with a symlinked quarantine manifest") } - if len(store.entries) != 0 { - t.Fatalf("persisted %d entries before validating every candidate", len(store.entries)) + if _, err := os.Stat(candidate); err != nil { + t.Fatalf("candidate moved after manifest validation failed: %v", err) } - for _, path := range []string{first, unsafe} { - if _, err := os.Stat(path); err != nil { - t.Fatalf("candidate moved despite path uncertainty: %v", err) - } + contents, err := os.ReadFile(externalArtifact) + if err != nil { + t.Fatalf("read external target: %v", err) + } + if string(contents) != original { + t.Fatalf("quarantine manifest write followed symlink target: %q", contents) } } diff --git a/apps/backend/internal/system/storage/workspaces/types.go b/apps/backend/internal/system/storage/workspaces/types.go index 8291a709ef..e1b9b9e912 100644 --- a/apps/backend/internal/system/storage/workspaces/types.go +++ b/apps/backend/internal/system/storage/workspaces/types.go @@ -77,6 +77,7 @@ type CleanupResult struct { } type Analysis struct { + TotalBytes int64 `json:"total_bytes"` ActiveBytes int64 `json:"active_bytes"` CandidateBytes int64 `json:"candidate_bytes"` Warnings []string `json:"warnings,omitempty"` diff --git a/apps/web/components/settings/system/storage/storage-overview-card.test.tsx b/apps/web/components/settings/system/storage/storage-overview-card.test.tsx index 921f3d4a0b..5d6b2b6c92 100644 --- a/apps/web/components/settings/system/storage/storage-overview-card.test.tsx +++ b/apps/web/components/settings/system/storage/storage-overview-card.test.tsx @@ -60,11 +60,50 @@ describe("StorageOverviewCard", () => { it("renders unavailable Docker resources without inventing zero usage", () => { render(); - const dockerResourceIds = ["managed-containers", "docker-build-cache", "docker-unused-images"]; + const dockerResourceIds = [ + "managed-containers", + "docker-image-layers", + "docker-build-cache", + "docker-unused-images", + ]; for (const resourceId of dockerResourceIds) { const trigger = screen.getByTestId(`storage-resource-${resourceId}-trigger`); expect(trigger.textContent).toContain("Unavailable"); expect(trigger.textContent).not.toContain("0 B"); } }); + + it("renders total workspace, unmanaged Go cache, and Docker layer usage", () => { + const overview = { + ...degradedOverview, + summary: { + ...degradedOverview.summary, + workspaces: { + total_bytes: 8 * 1024 ** 3, + active_bytes: 2 * 1024 ** 3, + candidate_bytes: 5 * 1024 ** 3, + }, + go_cache: { + ...degradedOverview.summary.go_cache, + unmanaged_path: "/root/.cache/go-build", + unmanaged_size_bytes: 25 * 1024 ** 3, + }, + docker: { + ...degradedOverview.summary.docker, + available: true, + image_layer_bytes: 14 * 1024 ** 3, + }, + }, + } satisfies StorageOverviewResponse; + + render(); + + expect(screen.getByTestId("storage-resource-workspaces-trigger").textContent).toContain("8 GB"); + expect(screen.getByTestId("storage-resource-unmanaged-go-cache-trigger").textContent).toContain( + "25 GB", + ); + expect( + screen.getByTestId("storage-resource-docker-image-layers-trigger").textContent, + ).toContain("14 GB"); + }); }); diff --git a/apps/web/components/settings/system/storage/storage-overview-card.tsx b/apps/web/components/settings/system/storage/storage-overview-card.tsx index 95a2baa03d..077ecfd64c 100644 --- a/apps/web/components/settings/system/storage/storage-overview-card.tsx +++ b/apps/web/components/settings/system/storage/storage-overview-card.tsx @@ -67,8 +67,8 @@ function storageResources(overview: StorageOverviewResponse): StorageResource[] { id: "workspaces", label: "Task workspaces", - value: formatGigabytes(summary.workspaces.candidate_bytes ?? 0), - detail: `Active workspaces use ${formatGigabytes(summary.workspaces.active_bytes ?? 0)}`, + value: formatGigabytes(summary.workspaces.total_bytes ?? 0), + detail: `${formatGigabytes(summary.workspaces.candidate_bytes ?? 0)} reclaimable after the grace period ยท ${formatGigabytes(summary.workspaces.active_bytes ?? 0)} active`, warning: summary.workspaces.warning, }, quarantineResource(summary.quarantine), @@ -89,6 +89,26 @@ function storageResources(overview: StorageOverviewResponse): StorageResource[] detail: summary.go_cache.path ?? overview.capabilities.managed_go_cache_path, warning: summary.go_cache.warning, }, + ...(summary.go_cache.unmanaged_path + ? [ + { + id: "unmanaged-go-cache", + label: "User Go build cache", + value: formatGigabytes(summary.go_cache.unmanaged_size_bytes ?? 0), + detail: summary.go_cache.unmanaged_path, + }, + ] + : []), + { + id: "docker-image-layers", + label: "Docker image layers", + ...dockerMeasurement( + summary.docker.available, + formatGigabytes(summary.docker.image_layer_bytes ?? 0), + overview.capabilities.docker_host || "Default Docker host", + ), + warning: dockerWarning, + }, { id: "docker-build-cache", label: "Docker build cache", diff --git a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts index 72fad46850..7cc3fa5355 100644 --- a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts +++ b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts @@ -43,6 +43,10 @@ test.describe("Mobile storage maintenance", () => { await expect(testPage.getByTestId("storage-analyze")).toHaveText("Analysis complete"); await testPage.getByTestId("storage-resource-workspaces-trigger").click(); await expect(testPage.getByTestId("storage-resource-workspaces")).toBeVisible(); + await testPage.getByTestId("storage-resource-unmanaged-go-cache-trigger").click(); + await expect(testPage.getByTestId("storage-resource-unmanaged-go-cache")).toBeVisible(); + await testPage.getByTestId("storage-resource-docker-image-layers-trigger").click(); + await expect(testPage.getByTestId("storage-resource-docker-image-layers")).toBeVisible(); await testPage.getByTestId("storage-resource-go-cache-trigger").click(); const explicitRequest = testPage.waitForRequest( (request) => diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index ad3ff7a324..0283496eb7 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -201,6 +201,7 @@ export interface StorageCapabilities { } export interface StorageWorkspaceSummary { + total_bytes?: number; active_bytes?: number; candidate_bytes?: number; warnings?: string[]; @@ -213,12 +214,15 @@ export interface StorageGoCacheSummary { size_bytes?: number; owned?: boolean; enabled?: boolean; + unmanaged_path?: string; + unmanaged_size_bytes?: number; available?: boolean; warning?: string; } export interface StorageDockerSummary { available: boolean; + image_layer_bytes?: number; build_cache_bytes: number; unused_image_bytes: number; managed_container_count: number; diff --git a/docs/decisions/2026-07-19-workspace-symlink-entries.md b/docs/decisions/2026-07-19-workspace-symlink-entries.md new file mode 100644 index 0000000000..e78bbae471 --- /dev/null +++ b/docs/decisions/2026-07-19-workspace-symlink-entries.md @@ -0,0 +1,43 @@ +# ADR-2026-07-19-workspace-symlink-entries: Treat Nested Workspace Symlinks as Entries + +**Status:** accepted +**Date:** 2026-07-19 +**Area:** backend, infra + +## Context + +Kandev workspaces routinely contain repository-owned symlinks, including checked-in links such as +`CLAUDE.md -> AGENTS.md` and package-manager links beneath `node_modules`. Storage analysis and +orphan cleanup rejected any nested symlink, which made normal task roots unmeasurable and +permanently ineligible for quarantine even when authoritative inventory classified them as orphaned. + +## Decision + +Workspace storage operations treat a nested symlink as an opaque directory entry and never follow +its target. `internal/system/storage/workspaces` measures roots with `filepath.WalkDir`, skips +symlink entries, quarantines the complete root with an atomic rename, and permanently deletes the +quarantined tree with `os.RemoveAll`; these operations do not traverse nested symlink targets. + +The fail-closed boundary remains unchanged for owned control paths. A symlinked task root, tasks or +trash ancestor, quarantine path, ownership marker, or quarantine manifest is rejected before any +mutation. Authoritative liveness inventory, containment validation, grace periods, and quarantine +retention remain mandatory. + +## Consequences + +- Normal Git and package-manager workspaces can be measured, quarantined, and deleted. +- Files reached only through a nested symlink target are not counted and are never changed. +- Reviewers must preserve no-follow filesystem primitives throughout workspace maintenance; a + future traversal API that follows symlinks must supersede this decision or restore rejection. +- Size totals describe bytes stored inside the workspace tree, excluding target data referenced by + symlinks. + +## Alternatives Considered + +- **Reject every nested symlink.** Rejected because ordinary Kandev repositories then cannot be + analyzed or reclaimed. +- **Resolve each target and allow only links contained by the task root.** Rejected because cleanup + does not need target contents, relative and temporarily broken links are legitimate repository + state, and resolution adds race-prone complexity. +- **Follow symlinks while measuring or deleting.** Rejected because it can count or destroy data + outside Kandev-owned task roots. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index f37f6f0ef6..3c4b79c9d4 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -56,3 +56,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0046 | [Settings route save coordinator](0046-settings-route-save-coordinator.md) | accepted | frontend | 2026-07-14 | | 2026-07-18-turn-configuration-snapshots | [Attribute runtime configuration to turns](2026-07-18-turn-configuration-snapshots.md) | accepted | backend, frontend | 2026-07-18 | | 2026-07-19-reject-mcp-actions-on-raw-websocket | [Reject MCP Actions on the Raw WebSocket](2026-07-19-reject-mcp-actions-on-raw-websocket.md) | accepted | backend, protocol | 2026-07-19 | +| 2026-07-19-workspace-symlink-entries | [Treat Nested Workspace Symlinks as Entries](2026-07-19-workspace-symlink-entries.md) | accepted | backend, infra | 2026-07-19 | diff --git a/docs/specs/system-page/storage-maintenance.md b/docs/specs/system-page/storage-maintenance.md index e33fecbfb4..206c61392e 100644 --- a/docs/specs/system-page/storage-maintenance.md +++ b/docs/specs/system-page/storage-maintenance.md @@ -29,10 +29,11 @@ reclaim that space without maintaining cron or systemd configuration outside Kan 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. -- Read-only analysis is available even when scheduled maintenance is disabled. It reports - active task workspace bytes, orphan-candidate bytes, active quarantined count and bytes, the - managed Go cache, Kandev-managed container count and writable-layer bytes, Docker build cache, - and unused Docker images. +- 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, + Kandev-managed container count and writable-layer bytes, Docker image-layer bytes, Docker build + cache, and unused Docker images. - Scheduled maintenance is install-wide, persists in Kandev's database, and is disabled by default. Enabling it does not require editing the VM, a systemd unit, or environment variables. @@ -66,6 +67,8 @@ reclaim that space without maintaining cron or systemd configuration outside Kan ### Task cleanup and orphan workspaces +Decision: [ADR-2026-07-19-workspace-symlink-entries](../../decisions/2026-07-19-workspace-symlink-entries.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. @@ -81,6 +84,10 @@ reclaim that space without maintaining cron or systemd configuration outside Kan configured orphan grace period. - The authoritative inventory covers both task layouts: `tasks//` and `tasks//`. +- Ready environment rows and active worktree rows protect files while their owning task exists and + is not archived. A ready environment owned by an archived or deleted task remains protected while + a live session of an unarchived task borrows it. Other rows retained for archived-task branch + recovery are historical metadata, not live workspace references. - New task roots contain a Kandev ownership marker with the task ID, workspace ID, task directory name, layout version, and creation time. Legacy unmarked directories remain eligible only when the authoritative inventory and grace-period checks positively classify them as unreferenced. @@ -121,10 +128,12 @@ reclaim that space without maintaining cron or systemd configuration outside Kan - Kandev creates an ownership marker beside the managed cache. It never deletes the default user cache such as `/root/.cache/go-build` unless that exact path was explicitly adopted through the Storage page with a destructive confirmation. -- Analysis reports the managed cache's current bytes. Cleanup rotates the owned cache into - Kandev trash and recreates an empty cache when its size is greater than the configured maximum. - The limit is a cleanup trigger, not a hard quota; the cache can temporarily grow beyond it while - tasks are active. +- Analysis reports the managed cache's current bytes and read-only usage for the service user's + distinct default Go cache (`$GOCACHE` when absolute, otherwise the platform user-cache path). + Reporting the default cache does not adopt it or grant cleanup ownership. Cleanup rotates the + owned cache into Kandev trash and recreates an empty cache when its size is greater than the + configured maximum. The limit is a cleanup trigger, not a hard quota; the cache can temporarily + grow beyond it while tasks are active. - Disabling managed Go cache stops injecting `GOCACHE` into new executions. It does not delete the previously managed cache. Scheduled cleanup and a global manual run with no resource selection leave it untouched; only a manual run whose non-empty selection includes `go_cache` may rotate it. @@ -134,8 +143,9 @@ reclaim that space without maintaining cron or systemd configuration outside Kan - Kandev-owned container cleanup lists only containers labeled `kandev.managed=true`. It removes a stopped container only after the task/runtime inventory positively shows it is orphaned or no longer needed. Running containers are never removed by storage maintenance. -- Analysis reports the count and writable-layer bytes of exactly labeled Kandev-managed containers; - an unavailable usage API degrades Docker analysis without failing the other resource summaries. +- Analysis reports the daemon's image-layer bytes and the count and writable-layer bytes of exactly + labeled Kandev-managed containers; an unavailable usage API degrades Docker analysis without + failing the other resource summaries. - Docker build-cache and unused-image analysis may inspect the configured Docker daemon without changing it. - Docker build-cache cleanup uses the Docker API's age/storage filters and does not invoke @@ -349,8 +359,9 @@ enabling host-global Docker cleanup require explicit UI confirmation and server- - Any authoritative workspace inventory query failure aborts workspace classification and performs no workspace move or deletion. -- Any uncertainty about path ownership, containment, active descendants, symlink traversal, or - task activity keeps the directory and records a warning. +- Any uncertainty about path ownership, containment, active descendants, owned control-path + symlinks, or task activity keeps the directory and records a warning. Nested workspace symlinks + are opaque entries: analysis, quarantine, and deletion never follow their targets. - A quarantine rename failure leaves the original directory untouched and records a failed entry only when the failure can be associated with a durable candidate ID. - A backend crash after rename but before the database update is reconciled at startup by scanning @@ -395,6 +406,13 @@ enabling host-global Docker cleanup require explicit UI confirmation and server- - **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. +- **GIVEN** task roots include active, recent orphan, and grace-eligible orphan directories, + **WHEN** storage analysis runs, **THEN** total workspace bytes include every classified task root + while active and reclaimable bytes remain separate subsets. +- **GIVEN** archived or deleted tasks retain ready environment or active worktree rows for recovery, + **WHEN** storage analysis or cleanup classifies their old directories, **THEN** those historical + rows do not protect the directories from normal orphan grace and quarantine rules unless a live + session of an unarchived task still borrows the environment. - **GIVEN** the worktree inventory query fails, **WHEN** workspace cleanup runs, **THEN** no task directory moves and the run reports the inventory error. - **GIVEN** a multi-repository task has one active descendant worktree, **WHEN** workspace cleanup @@ -416,6 +434,11 @@ enabling host-global Docker cleanup require explicit UI confirmation and server- reports the reclaimed bytes. - **GIVEN** `/root/.cache/go-build` was not explicitly adopted, **WHEN** storage cleanup runs, **THEN** Kandev does not modify it. +- **GIVEN** `/root/.cache/go-build` is the service user's default Go cache and is not adopted, + **WHEN** storage analysis runs, **THEN** its path and bytes are reported read-only while cleanup + remains unavailable for that path. +- **GIVEN** the Docker daemon reports image-layer usage, **WHEN** storage analysis runs, **THEN** + image-layer bytes are shown separately from build-cache and managed-container writable bytes. - **GIVEN** an exited container has `kandev.managed=true` and its task is positively absent, **WHEN** container cleanup runs, **THEN** the container and its attached Kandev volumes are removed. - **GIVEN** an unrelated exited container exists, **WHEN** Kandev container cleanup runs, **THEN**