fix: report storage usage and reclaim archived workspaces (#1813)

This commit is contained in:
Carlos Florêncio
2026-07-20 09:35:57 +01:00
committed by GitHub
parent 89dc8d1938
commit 110c2efcb1
20 changed files with 545 additions and 71 deletions
@@ -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 {
@@ -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)
@@ -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
}
@@ -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)
}
@@ -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,
@@ -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)
}
@@ -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),
}
@@ -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)
}
@@ -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) {
@@ -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()
@@ -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) {
@@ -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)
}
}
@@ -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"`
@@ -60,11 +60,50 @@ describe("StorageOverviewCard", () => {
it("renders unavailable Docker resources without inventing zero usage", () => {
render(<StorageOverviewCard overview={degradedOverview} onRunGoCache={vi.fn()} />);
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(<StorageOverviewCard overview={overview} onRunGoCache={vi.fn()} />);
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");
});
});
@@ -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",
@@ -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) =>
+4
View File
@@ -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;
@@ -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.
+1
View File
@@ -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 |
+35 -12
View File
@@ -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/<semantic-task-dir>/<repo>` and `tasks/<workspace-id>/<task-id>`.
- 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**