fix: report storage usage and reclaim archived workspaces (#1813)
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user