diff --git a/apps/backend/internal/backendapp/storage_maintenance.go b/apps/backend/internal/backendapp/storage_maintenance.go index e02c4795da..aa5ecc0c0c 100644 --- a/apps/backend/internal/backendapp/storage_maintenance.go +++ b/apps/backend/internal/backendapp/storage_maintenance.go @@ -27,6 +27,7 @@ import ( "github.com/kandev/kandev/internal/system/storage/filescan" "github.com/kandev/kandev/internal/system/storage/gocache" "github.com/kandev/kandev/internal/system/storage/tempartifacts" + "github.com/kandev/kandev/internal/system/storage/tempstore" "github.com/kandev/kandev/internal/system/storage/workspaces" taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/worktree" @@ -167,8 +168,9 @@ func prepareStorageDependencies( tempProvider := tempartifacts.NewProvider(tempartifacts.ProviderConfig{ Registry: tempArtifacts, Store: store, HomeDir: cfg.ResolvedHomeDir(), TrashDir: filepath.Join(cfg.ResolvedHomeDir(), "trash"), - Scanner: scanner, + Scanner: scanner, Settings: settings, }) + systemTemporary := tempstore.New(systemTemporaryConfig(scanner)) if err := tempProvider.Reconcile(context.Background()); err != nil { logError("reconcile temporary artifact quarantine", err) } @@ -197,6 +199,7 @@ func prepareStorageDependencies( settings: settings, quarantine: store, workspaceFactory: workspaceFactory, goCache: goCache, docker: dockerProvider, dockerClient: dockerClient, dockerHost: cfg.Docker.Host, homeDir: cfg.ResolvedHomeDir(), tempArtifacts: tempProvider, database: database, + systemTemporary: systemTemporary, } cachedOverview := newStorageOverviewCache(overview, eventBus, log, logError) quarantine := &workspaceQuarantineController{ @@ -212,6 +215,17 @@ func prepareStorageDependencies( }, nil } +func systemTemporaryConfig(scanner *filescan.Limiter) tempstore.Config { + config := tempstore.Config{Scanner: scanner} + if root := os.Getenv("KANDEV_E2E_SYSTEM_TEMP_ROOT"); root != "" { + // The browser fixture supplies a disposable root so E2E analysis never + // reads the host's shared temporary directory. + config.EffectiveRoot = root + config.UnixRoot = root + } + return config +} + func newStorageOverviewCache( overview *storageOverview, eventBus bus.EventBus, @@ -317,6 +331,7 @@ type storageOverview struct { goCacheAnalyze func(context.Context) (gocache.Analysis, error) docker *dockerstore.Provider tempArtifacts *tempartifacts.Provider + systemTemporary *tempstore.Provider dockerClient *lazyStorageDocker dockerHost string homeDir string @@ -360,6 +375,8 @@ func (o *storageOverview) summary( databaseErr error backupSummary databasestore.Measurement backupErr error + systemTempSummary tempstore.Analysis + systemTempErr error ) var measurements sync.WaitGroup measurements.Add(6) @@ -424,6 +441,25 @@ func (o *storageOverview) summary( reporter.start(storagepkg.StorageSourceTemporaryArtifacts) reporter.complete(storagepkg.StorageSourceTemporaryArtifacts, tempSummary, nil) } + if o.systemTemporary != nil { + measurements.Add(1) + go func() { + defer measurements.Done() + reporter.start(storagepkg.StorageSourceSystemTemporary) + systemTempSummary, systemTempErr = o.analyzeSystemTemporary(ctx, reporter) + reporter.complete( + storagepkg.StorageSourceSystemTemporary, + summaryValue(systemTempSummary, systemTempErr), systemTempErr, + ) + }() + } else { + systemTempSummary = tempstore.Analysis{ + Status: tempstore.StatusNotApplicable, IncludedInTotal: false, + Roots: []tempstore.RootMeasurement{}, Reason: "system temporary provider unavailable", + } + reporter.start(storagepkg.StorageSourceSystemTemporary) + reporter.complete(storagepkg.StorageSourceSystemTemporary, systemTempSummary, nil) + } measurements.Wait() if err := ctx.Err(); err != nil { return storagepkg.Summary{}, err @@ -432,6 +468,7 @@ func (o *storageOverview) summary( workspaceSummary, workspaceErr, goCacheSummary, goCacheErr, quarantineSummary, quarantineErr, tempSummary, tempErr, dockerSummary, databaseSummary, databaseErr, backupSummary, backupErr, + systemTempSummary, systemTempErr, ), nil } @@ -449,12 +486,15 @@ func summaryFromMeasurements( databaseErr error, backupSummary databasestore.Measurement, backupErr error, + systemTempSummary tempstore.Analysis, + systemTempErr error, ) storagepkg.Summary { return storagepkg.Summary{ Workspaces: summaryValue(workspaceSummary, workspaceErr), GoCache: summaryValue(goCacheSummary, goCacheErr), Quarantine: summaryValue(quarantineSummary, quarantineErr), TemporaryArtifacts: summaryValue(tempSummary, tempErr), + SystemTemporary: summaryValue(systemTempSummary, systemTempErr), Docker: dockerSummaryMap(dockerSummary), Database: databaseMeasurementValue(databaseSummary, databaseErr), DatabaseBackups: databaseMeasurementValue(backupSummary, backupErr), @@ -528,10 +568,15 @@ type temporaryArtifactsProgressAnalyzer interface { AnalyzeWithProgress(context.Context, func(filescan.Progress)) (tempartifacts.Analysis, error) } +type systemTemporaryProgressAnalyzer interface { + AnalyzeWithProgress(context.Context, func(filescan.Progress)) (tempstore.Analysis, error) +} + var ( _ workspaceProgressAnalyzer = (*workspaces.Provider)(nil) _ goCacheProgressAnalyzer = (*gocache.Provider)(nil) _ temporaryArtifactsProgressAnalyzer = (*tempartifacts.Provider)(nil) + _ systemTemporaryProgressAnalyzer = (*tempstore.Provider)(nil) ) func (o *storageOverview) analyzeWorkspaces( @@ -567,6 +612,15 @@ func (o *storageOverview) analyzeTemporaryArtifacts( ) } +func (o *storageOverview) analyzeSystemTemporary( + ctx context.Context, + reporter *storageProgressReporter, +) (tempstore.Analysis, error) { + return o.systemTemporary.AnalyzeWithProgress( + ctx, reporter.filesystem(storagepkg.StorageSourceSystemTemporary), + ) +} + func dockerSummaryMap(summary dockerstore.Analysis) map[string]any { return map[string]any{ "available": summary.Available, "build_cache_bytes": summary.BuildCacheBytes, diff --git a/apps/backend/internal/backendapp/storage_maintenance_test.go b/apps/backend/internal/backendapp/storage_maintenance_test.go index d879d8b5d6..b43505fe02 100644 --- a/apps/backend/internal/backendapp/storage_maintenance_test.go +++ b/apps/backend/internal/backendapp/storage_maintenance_test.go @@ -23,6 +23,7 @@ import ( "github.com/kandev/kandev/internal/system/storage/dockerstore" "github.com/kandev/kandev/internal/system/storage/filescan" "github.com/kandev/kandev/internal/system/storage/gocache" + "github.com/kandev/kandev/internal/system/storage/tempstore" "github.com/kandev/kandev/internal/system/storage/workspaces" ) @@ -80,6 +81,10 @@ func TestStorageOverviewIncludesQuarantineAndManagedContainers(t *testing.T) { dockerSummary["image_layer_bytes"] != int64(128) { t.Fatalf("docker summary = %#v", summary.Docker) } + systemTemporary, ok := summary.SystemTemporary.(tempstore.Analysis) + if !ok || systemTemporary.Status != tempstore.StatusNotApplicable || systemTemporary.Roots == nil { + t.Fatalf("unavailable system temporary summary = %#v, want an empty roots array", summary.SystemTemporary) + } overview.quarantine = failingQuarantineSummarizer{err: errors.New("quarantine unavailable")} degraded, err := overview.Summary(context.Background()) @@ -95,6 +100,51 @@ func TestStorageOverviewIncludesQuarantineAndManagedContainers(t *testing.T) { } } +func TestSystemTemporaryConfigUsesDisposableE2ERoot(t *testing.T) { + root := t.TempDir() + t.Setenv("KANDEV_E2E_SYSTEM_TEMP_ROOT", root) + + configured := systemTemporaryConfig(filescan.NewLimiter(1)) + if configured.EffectiveRoot != root || configured.UnixRoot != root { + t.Fatalf("system temporary config = %#v, want disposable root %q", configured, root) + } +} + +func TestStorageOverviewIncludesInformationalSystemTemporaryFootprint(t *testing.T) { + root := t.TempDir() + writeStorageTestFile(t, filepath.Join(root, "temporary-file"), 23) + settings, store := newStorageMaintenanceStores(t) + overview := &storageOverview{ + settings: settings, quarantine: store, + workspaceFactory: func(current storagepkg.StorageMaintenanceSettings) *workspaces.Provider { + return workspaces.New(workspaces.Config{ + TasksRoot: filepath.Join(root, "tasks"), TrashRoot: filepath.Join(root, "trash"), + Inventory: overviewWorkspaceInventory{}, Store: store, + GracePeriod: time.Duration(current.OrphanGraceHours) * time.Hour, + Retention: time.Duration(current.QuarantineRetentionHours) * time.Hour, + }) + }, + goCache: gocache.New(gocache.Config{HomeDir: root, TrashDir: filepath.Join(root, "trash"), Settings: settings, Store: store}), + docker: dockerstore.NewProvider(&overviewDockerClient{}, overviewContainerInventory{}, settings), + systemTemporary: tempstore.New(tempstore.Config{ + GOOS: "windows", EffectiveRoot: root, + RootResolver: func(context.Context) ([]tempstore.RootCandidate, error) { + return []tempstore.RootCandidate{{RequestedPath: root}}, nil + }, + }), + homeDir: root, + } + + summary, err := overview.Summary(context.Background()) + if err != nil { + t.Fatalf("Summary: %v", err) + } + temporary, ok := summary.SystemTemporary.(tempstore.Analysis) + if !ok || temporary.SizeBytes == nil || *temporary.SizeBytes != 23 || temporary.IncludedInTotal { + t.Fatalf("system temporary summary = %#v, want informational 23-byte measurement", summary.SystemTemporary) + } +} + func TestStorageOverviewIncludesDatabaseAndBackupMeasurements(t *testing.T) { root := t.TempDir() databasePath := filepath.Join(root, "data", "kandev.db") @@ -228,6 +278,7 @@ func TestStorageOverviewReportsProgressForEachSource(t *testing.T) { storagepkg.StorageSourceGoCache, storagepkg.StorageSourceQuarantine, storagepkg.StorageSourceTemporaryArtifacts, + storagepkg.StorageSourceSystemTemporary, storagepkg.StorageSourceDocker, storagepkg.StorageSourceDatabase, storagepkg.StorageSourceDatabaseBackups, diff --git a/apps/backend/internal/system/storage/analysis_state.go b/apps/backend/internal/system/storage/analysis_state.go index 53d71f0517..a37f7ee1f9 100644 --- a/apps/backend/internal/system/storage/analysis_state.go +++ b/apps/backend/internal/system/storage/analysis_state.go @@ -27,6 +27,7 @@ const ( StorageSourceGoCache = "go_cache" StorageSourceQuarantine = "quarantine" StorageSourceTemporaryArtifacts = "temporary_artifacts" + StorageSourceSystemTemporary = "system_temporary" StorageSourceDocker = "docker" StorageSourceDatabase = "database" StorageSourceDatabaseBackups = "database_backups" @@ -37,6 +38,7 @@ var storageAnalysisSources = [...]string{ StorageSourceGoCache, StorageSourceQuarantine, StorageSourceTemporaryArtifacts, + StorageSourceSystemTemporary, StorageSourceDocker, StorageSourceDatabase, StorageSourceDatabaseBackups, diff --git a/apps/backend/internal/system/storage/filescan/measure.go b/apps/backend/internal/system/storage/filescan/measure.go index 434803e46a..dba3e21888 100644 --- a/apps/backend/internal/system/storage/filescan/measure.go +++ b/apps/backend/internal/system/storage/filescan/measure.go @@ -24,9 +24,17 @@ type Root struct { SymlinkPolicy SymlinkPolicy MissingOK bool Exclude func(string, fs.DirEntry) bool + ShouldSkip func(string, fs.DirEntry) (bool, error) + Validate func() error OverlapRoots []string } +type MeasureOptions struct { + TolerateEntryErrors bool + CountSkipped bool + MaxWarnings int +} + type ProgressPhase string const ( @@ -50,6 +58,9 @@ type Result struct { Bytes int64 OverlappedBytes int64 Err error + Partial bool + SkippedCount int + Warnings []string } type Limiter struct { @@ -69,6 +80,7 @@ type partition struct { partitionIndex int root Root path string + skip bool } type rootPlan struct { @@ -81,6 +93,9 @@ type partitionResult struct { bytes int64 overlappedBytes int64 err error + partial bool + skippedCount int + warnings []string } type progressTracker struct { @@ -97,14 +112,26 @@ type progressTracker struct { } func (l *Limiter) Measure(ctx context.Context, roots []Root, notify func(Progress)) []Result { + return l.MeasureWithOptions(ctx, roots, MeasureOptions{}, notify) +} + +func (l *Limiter) MeasureWithOptions( + ctx context.Context, + roots []Root, + options MeasureOptions, + notify func(Progress), +) []Result { if l == nil { l = NewLimiter(4) } + if options.MaxWarnings <= 0 { + options.MaxWarnings = 10 + } plans := make([]rootPlan, len(roots)) partitions := make([]partition, 0) rootPartitionCounts := make([]int, len(roots)) for index, root := range roots { - planned, err := planRoot(ctx, index, root) + planned, err := planRoot(ctx, index, root, options) plans[index] = rootPlan{root: root, partitions: planned, err: err} if err == nil { rootPartitionCounts[index] = len(planned) @@ -130,7 +157,7 @@ func (l *Limiter) Measure(ctx context.Context, roots []Root, notify func(Progres } } if len(partitions) > 0 { - l.measurePartitions(ctx, partitions, partitionResults, tracker) + l.measurePartitions(ctx, partitions, partitionResults, tracker, options) } for index, plan := range plans { if plan.err != nil { @@ -139,11 +166,25 @@ func (l *Limiter) Measure(ctx context.Context, roots []Root, notify func(Progres var errs []error for _, measured := range partitionResults[index] { if measured.err != nil { + if options.TolerateEntryErrors { + results[index].Bytes += measured.bytes + results[index].OverlappedBytes += measured.overlappedBytes + results[index].Partial = true + results[index].SkippedCount += measured.skippedCount + results[index].Warnings = appendBounded( + results[index].Warnings, measured.warnings, options.MaxWarnings, + ) + } errs = append(errs, measured.err) continue } results[index].Bytes += measured.bytes results[index].OverlappedBytes += measured.overlappedBytes + results[index].Partial = results[index].Partial || measured.partial + results[index].SkippedCount += measured.skippedCount + results[index].Warnings = appendBounded( + results[index].Warnings, measured.warnings, options.MaxWarnings, + ) } results[index].Err = errors.Join(errs...) } @@ -155,6 +196,7 @@ func (l *Limiter) measurePartitions( partitions []partition, partitionResults [][]partitionResult, tracker *progressTracker, + options MeasureOptions, ) { workerCount := l.maxPartitions if workerCount > len(partitions) { @@ -171,7 +213,7 @@ func (l *Limiter) measurePartitions( go func() { defer workers.Done() for item := range jobs { - measured := l.measurePartition(ctx, item, tracker) + measured := l.measurePartition(ctx, item, tracker, options) partitionResults[item.rootIndex][item.partitionIndex] = measured } }() @@ -179,16 +221,21 @@ func (l *Limiter) measurePartitions( workers.Wait() } -func (l *Limiter) measurePartition(ctx context.Context, item partition, tracker *progressTracker) partitionResult { +func (l *Limiter) measurePartition( + ctx context.Context, + item partition, + tracker *progressTracker, + options MeasureOptions, +) partitionResult { if err := l.acquire(ctx); err != nil { tracker.partitionCompleted(item, 0, err) return partitionResult{err: err} } tracker.partitionStarted(item) - bytes, overlappedBytes, err := walkPartition(ctx, item.root, item.path) - tracker.partitionCompleted(item, bytes, err) + measured := walkPartition(ctx, item.root, item.path, item.skip, options) + tracker.partitionCompleted(item, measured.bytes, measured.err) l.release() - return partitionResult{bytes: bytes, overlappedBytes: overlappedBytes, err: err} + return measured } func (l *Limiter) acquire(ctx context.Context) error { @@ -204,10 +251,15 @@ func (l *Limiter) release() { <-l.slots } -func planRoot(ctx context.Context, rootIndex int, root Root) ([]partition, error) { +func planRoot(ctx context.Context, rootIndex int, root Root, options MeasureOptions) ([]partition, error) { if err := ctx.Err(); err != nil { return nil, err } + if root.Validate != nil { + if err := root.Validate(); err != nil { + return nil, err + } + } info, err := os.Lstat(root.Path) if errors.Is(err, os.ErrNotExist) && root.MissingOK { return nil, nil @@ -217,6 +269,9 @@ func planRoot(ctx context.Context, rootIndex int, root Root) ([]partition, error } if info.Mode()&os.ModeSymlink != 0 { if root.SymlinkPolicy == SkipSymlinks { + if options.CountSkipped { + return []partition{{rootIndex: rootIndex, root: root, path: root.Path, skip: true}}, nil + } return nil, nil } return nil, fmt.Errorf("symlink found at %s", root.Path) @@ -224,10 +279,15 @@ func planRoot(ctx context.Context, rootIndex int, root Root) ([]partition, error if !info.IsDir() { return []partition{{rootIndex: rootIndex, root: root, path: root.Path}}, nil } - return planDirectory(ctx, rootIndex, root) + return planDirectory(ctx, rootIndex, root, options) } -func planDirectory(ctx context.Context, rootIndex int, root Root) ([]partition, error) { +func planDirectory( + ctx context.Context, + rootIndex int, + root Root, + options MeasureOptions, +) ([]partition, error) { entries, err := os.ReadDir(root.Path) if err != nil { return nil, err @@ -238,10 +298,24 @@ func planDirectory(ctx context.Context, rootIndex int, root Root) ([]partition, return nil, err } path := filepath.Join(root.Path, entry.Name()) - if root.Exclude != nil && root.Exclude(path, entry) { + skip, err := shouldSkip(root, path, entry) + if err != nil { + return nil, err + } + if skip { + if options.CountSkipped { + partitions = append(partitions, partition{ + rootIndex: rootIndex, partitionIndex: len(partitions), root: root, path: path, skip: true, + }) + } continue } if entry.Type()&os.ModeSymlink != 0 && root.SymlinkPolicy == SkipSymlinks { + if options.CountSkipped { + partitions = append(partitions, partition{ + rootIndex: rootIndex, partitionIndex: len(partitions), root: root, path: path, skip: true, + }) + } continue } partitions = append(partitions, partition{ @@ -254,48 +328,165 @@ func planDirectory(ctx context.Context, rootIndex int, root Root) ([]partition, return partitions, nil } -func walkPartition(ctx context.Context, root Root, path string) (int64, int64, error) { - var total int64 - var overlapped int64 +func walkPartition( + ctx context.Context, + root Root, + path string, + skip bool, + options MeasureOptions, +) partitionResult { + result := partitionResult{} + if skip { + if options.CountSkipped { + result.partial = true + result.skippedCount = 1 + } + return result + } + if root.Validate != nil { + if err := root.Validate(); err != nil { + result.err = err + return result + } + } err := filepath.WalkDir(path, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if err := ctx.Err(); err != nil { - return err - } - if root.Exclude != nil && root.Exclude(path, entry) { - if entry.IsDir() { - return fs.SkipDir - } - return nil - } - if entry.Type()&os.ModeSymlink != 0 { - if root.SymlinkPolicy == SkipSymlinks { - return nil - } - return fmt.Errorf("symlink found at %s", path) - } - if !entry.Type().IsRegular() { - return nil - } - if err := ctx.Err(); err != nil { - return err - } - info, err := entry.Info() - if err != nil { - return err - } - total += info.Size() - if pathWithinAny(path, root.OverlapRoots) { - overlapped += info.Size() - } - return nil + return walkEntry(ctx, root, path, entry, walkErr, &result, options) }) if err != nil { - return 0, 0, err + result.err = err } - return total, overlapped, nil + return result +} + +func walkEntry( + ctx context.Context, + root Root, + path string, + entry fs.DirEntry, + walkErr error, + result *partitionResult, + options MeasureOptions, +) error { + if walkErr != nil { + return tolerateWalkError(path, entry, walkErr, result, options) + } + if err := ctx.Err(); err != nil { + return err + } + skip, err := shouldSkip(root, path, entry) + if err != nil { + return err + } + if skip { + return skipEntry(entry, result, options) + } + if entry.Type()&os.ModeSymlink != 0 { + return handleSymlink(path, root, result, options) + } + if entry.IsDir() { + return nil + } + if !entry.Type().IsRegular() { + markSkipped(result, options) + return nil + } + if err := ctx.Err(); err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return tolerateInfoError(path, err, result, options) + } + result.bytes += info.Size() + if pathWithinAny(path, root.OverlapRoots) { + result.overlappedBytes += info.Size() + } + return nil +} + +func tolerateWalkError( + path string, + entry fs.DirEntry, + walkErr error, + result *partitionResult, + options MeasureOptions, +) error { + if !options.TolerateEntryErrors { + return walkErr + } + result.partial = true + result.skippedCount++ + result.warnings = appendWarning(result.warnings, fmt.Errorf("%s: %w", path, walkErr), options.MaxWarnings) + if entry != nil && entry.IsDir() { + return fs.SkipDir + } + return nil +} + +func skipEntry(entry fs.DirEntry, result *partitionResult, options MeasureOptions) error { + markSkipped(result, options) + if entry.IsDir() { + return fs.SkipDir + } + return nil +} + +func handleSymlink(path string, root Root, result *partitionResult, options MeasureOptions) error { + if root.SymlinkPolicy != SkipSymlinks { + return fmt.Errorf("symlink found at %s", path) + } + markSkipped(result, options) + return nil +} + +func tolerateInfoError( + path string, + infoErr error, + result *partitionResult, + options MeasureOptions, +) error { + if !options.TolerateEntryErrors { + return infoErr + } + result.partial = true + result.skippedCount++ + result.warnings = appendWarning(result.warnings, fmt.Errorf("%s: %w", path, infoErr), options.MaxWarnings) + return nil +} + +func markSkipped(result *partitionResult, options MeasureOptions) { + if !options.CountSkipped { + return + } + result.partial = true + result.skippedCount++ +} + +func shouldSkip(root Root, path string, entry fs.DirEntry) (bool, error) { + if root.Exclude != nil && root.Exclude(path, entry) { + return true, nil + } + if root.ShouldSkip != nil { + return root.ShouldSkip(path, entry) + } + return false, nil +} + +func appendWarning(warnings []string, err error, limit int) []string { + if err == nil || len(warnings) >= limit { + return warnings + } + return append(warnings, err.Error()) +} + +func appendBounded(warnings, additions []string, limit int) []string { + for _, warning := range additions { + if len(warnings) >= limit { + break + } + warnings = append(warnings, warning) + } + return warnings } func pathWithinAny(path string, roots []string) bool { diff --git a/apps/backend/internal/system/storage/filescan/measure_test.go b/apps/backend/internal/system/storage/filescan/measure_test.go index 993f3a8510..7ab47c2b61 100644 --- a/apps/backend/internal/system/storage/filescan/measure_test.go +++ b/apps/backend/internal/system/storage/filescan/measure_test.go @@ -169,6 +169,116 @@ func TestMeasureRejectsSymlinkAndSupportsMissingRoots(t *testing.T) { } } +func TestMeasureWithOptionsUsesRootSkipAndValidationHooks(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "included"), []byte("included"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "excluded"), []byte("excluded"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "included"), filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + validated := false + results := NewLimiter(1).MeasureWithOptions(context.Background(), []Root{{ + Path: root, + ShouldSkip: func(path string, _ os.DirEntry) (bool, error) { + return filepath.Base(path) == "excluded", nil + }, + Validate: func() error { + validated = true + return nil + }, + }}, MeasureOptions{TolerateEntryErrors: true, CountSkipped: true}, nil) + if len(results) != 1 || results[0].Err != nil { + t.Fatalf("measurement = %#v, want one successful result", results) + } + if results[0].Bytes != int64(len("included")) || !validated { + t.Fatalf("measurement = %#v, validated = %v", results[0], validated) + } + if !results[0].Partial || results[0].SkippedCount != 2 { + t.Fatalf("measurement = %#v, want two skipped entries and a partial result", results[0]) + } +} + +func TestMeasureWithOptionsCountsOnlyActuallySkippedEntries(t *testing.T) { + empty := t.TempDir() + results := NewLimiter(1).MeasureWithOptions( + context.Background(), + []Root{{Path: empty}}, + MeasureOptions{TolerateEntryErrors: true, CountSkipped: true}, + nil, + ) + if len(results) != 1 || results[0].Err != nil { + t.Fatalf("empty root measurement = %#v, want one successful result", results) + } + if results[0].Bytes != 0 || results[0].Partial || results[0].SkippedCount != 0 { + t.Fatalf("empty root measurement = %#v, want complete zero-byte result", results[0]) + } + + root := t.TempDir() + nested := filepath.Join(root, "nested") + if err := os.Mkdir(nested, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "data"), []byte("nested data"), 0o600); err != nil { + t.Fatal(err) + } + results = NewLimiter(1).MeasureWithOptions( + context.Background(), + []Root{{Path: root}}, + MeasureOptions{TolerateEntryErrors: true, CountSkipped: true}, + nil, + ) + if len(results) != 1 || results[0].Err != nil { + t.Fatalf("nested root measurement = %#v, want one successful result", results) + } + if results[0].Bytes != int64(len("nested data")) || results[0].Partial || results[0].SkippedCount != 0 { + t.Fatalf("nested root measurement = %#v, want complete nested-file result", results[0]) + } +} + +func TestMeasureWithOptionsPreservesBytesFromInterruptedTolerantPartition(t *testing.T) { + root := t.TempDir() + partition := filepath.Join(root, "partition") + if err := os.Mkdir(partition, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(partition, "a-known"), []byte("known bytes"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(partition, "b-cancel"), []byte("unread bytes"), 0o600); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + results := NewLimiter(1).MeasureWithOptions( + ctx, + []Root{{ + Path: root, + ShouldSkip: func(path string, _ os.DirEntry) (bool, error) { + if filepath.Base(path) == "b-cancel" { + cancel() + } + return false, nil + }, + }}, + MeasureOptions{TolerateEntryErrors: true, CountSkipped: true}, + nil, + ) + if len(results) != 1 { + t.Fatalf("result count = %d, want one result", len(results)) + } + if !errors.Is(results[0].Err, context.Canceled) { + t.Fatalf("interrupted measurement error = %v, want context.Canceled", results[0].Err) + } + if results[0].Bytes != int64(len("known bytes")) || !results[0].Partial { + t.Fatalf("interrupted measurement = %#v, want preserved sampled bytes and partial status", results[0]) + } +} + func TestProgressTrackerNotifiesInSnapshotOrder(t *testing.T) { var ( events []Progress diff --git a/apps/backend/internal/system/storage/handler.go b/apps/backend/internal/system/storage/handler.go index f1c7721a6a..c853a21270 100644 --- a/apps/backend/internal/system/storage/handler.go +++ b/apps/backend/internal/system/storage/handler.go @@ -50,6 +50,7 @@ type Summary struct { GoCache any `json:"go_cache"` Quarantine any `json:"quarantine"` TemporaryArtifacts any `json:"temporary_artifacts"` + SystemTemporary any `json:"system_temporary"` Docker any `json:"docker"` Database any `json:"database"` DatabaseBackups any `json:"database_backups"` diff --git a/apps/backend/internal/system/storage/operations.go b/apps/backend/internal/system/storage/operations.go index 83b79e9d3a..56b3cad4d2 100644 --- a/apps/backend/internal/system/storage/operations.go +++ b/apps/backend/internal/system/storage/operations.go @@ -290,6 +290,18 @@ func (p explicitCleanupSelection) Cleanup(ctx context.Context) (map[string]any, return p.explicit.CleanupExplicit(ctx) } +func (p explicitCleanupSelection) CleanupWithSettings( + ctx context.Context, + settings StorageMaintenanceSettings, +) (map[string]any, error) { + if snapshotProvider, ok := p.explicit.(interface { + CleanupExplicitWithSettings(context.Context, StorageMaintenanceSettings) (map[string]any, error) + }); ok { + return snapshotProvider.CleanupExplicitWithSettings(ctx, settings) + } + return p.explicit.CleanupExplicit(ctx) +} + func runResultMap(run MaintenanceRun) map[string]any { result := valueMap(run.Result) result["run_id"] = run.ID diff --git a/apps/backend/internal/system/storage/overview_cache.go b/apps/backend/internal/system/storage/overview_cache.go index 7869425fae..0edde93120 100644 --- a/apps/backend/internal/system/storage/overview_cache.go +++ b/apps/backend/internal/system/storage/overview_cache.go @@ -250,6 +250,7 @@ func (c *OverviewCache) recordLegacyProgress(flight *overviewFlight, summary Sum {Source: StorageSourceGoCache, State: SourceStateReady, Value: summary.GoCache}, {Source: StorageSourceQuarantine, State: SourceStateReady, Value: summary.Quarantine}, {Source: StorageSourceTemporaryArtifacts, State: SourceStateReady, Value: summary.TemporaryArtifacts}, + {Source: StorageSourceSystemTemporary, State: SourceStateReady, Value: summary.SystemTemporary}, {Source: StorageSourceDocker, State: SourceStateReady, Value: summary.Docker}, {Source: StorageSourceDatabase, State: SourceStateReady, Value: summary.Database}, {Source: StorageSourceDatabaseBackups, State: SourceStateReady, Value: summary.DatabaseBackups}, @@ -490,6 +491,9 @@ func summaryFromSourceValues(values map[string]any) *Summary { if value, ok := values[StorageSourceTemporaryArtifacts]; ok { summary.TemporaryArtifacts = value } + if value, ok := values[StorageSourceSystemTemporary]; ok { + summary.SystemTemporary = value + } if value, ok := values[StorageSourceDocker]; ok { summary.Docker = value } diff --git a/apps/backend/internal/system/storage/overview_cache_progress_test.go b/apps/backend/internal/system/storage/overview_cache_progress_test.go index 04c2e5cac7..421430a47d 100644 --- a/apps/backend/internal/system/storage/overview_cache_progress_test.go +++ b/apps/backend/internal/system/storage/overview_cache_progress_test.go @@ -107,11 +107,11 @@ func TestNewAnalysisStateIncludesDatabaseSources(t *testing.T) { t.Fatalf("source %q progress = %#v, want pending", source, progress) } } - if len(storageAnalysisSources) != 7 { - t.Fatalf("source list length = %d, want 7", len(storageAnalysisSources)) + if len(storageAnalysisSources) != 8 { + t.Fatalf("source list length = %d, want 8", len(storageAnalysisSources)) } if state.Progress.TotalSources != len(storageAnalysisSources) { - t.Fatalf("total sources = %d, want 7", state.Progress.TotalSources) + t.Fatalf("total sources = %d, want 8", state.Progress.TotalSources) } } diff --git a/apps/backend/internal/system/storage/runner.go b/apps/backend/internal/system/storage/runner.go index e5a1987580..7f204a53d6 100644 --- a/apps/backend/internal/system/storage/runner.go +++ b/apps/backend/internal/system/storage/runner.go @@ -22,6 +22,13 @@ type CleanupProvider interface { Cleanup(ctx context.Context) (map[string]any, error) } +// SettingsSnapshotCleanupProvider receives the immutable settings captured when +// a maintenance run was queued. Providers use it to keep eligibility and +// retention decisions consistent for the lifetime of the run. +type SettingsSnapshotCleanupProvider interface { + CleanupWithSettings(context.Context, StorageMaintenanceSettings) (map[string]any, error) +} + // ExplicitCleanupProvider may opt into behavior reserved for a specifically named manual run. type ExplicitCleanupProvider interface { CleanupExplicit(ctx context.Context) (map[string]any, error) @@ -111,7 +118,7 @@ func (r *Runner) Run( if _, err := r.transitionRun(ctx, run.ID, RunStateRunning, nil, ""); err != nil { return MaintenanceRun{}, err } - result, runErr := r.runProviders(lease.Context()) + result, runErr := r.runProviders(lease.Context(), settings) return r.finishRun(ctx, run.ID, lease.Context(), result, runErr) } @@ -161,14 +168,25 @@ func (r *Runner) createRun( return run, nil } -func (r *Runner) runProviders(ctx context.Context) (map[string]any, error) { +func (r *Runner) runProviders( + ctx context.Context, + settings StorageMaintenanceSettings, +) (map[string]any, error) { results := make(map[string]any, len(r.providers)) var errs []error for _, provider := range r.providers { if ctx.Err() != nil { break } - providerResult, err := provider.Cleanup(ctx) + var ( + providerResult map[string]any + err error + ) + if snapshotProvider, ok := provider.(SettingsSnapshotCleanupProvider); ok { + providerResult, err = snapshotProvider.CleanupWithSettings(ctx, settings) + } else { + providerResult, err = provider.Cleanup(ctx) + } entry := map[string]any{"result": providerResult} if err != nil { entry["error"] = err.Error() diff --git a/apps/backend/internal/system/storage/runner_test.go b/apps/backend/internal/system/storage/runner_test.go index fcb4071153..4d7072a892 100644 --- a/apps/backend/internal/system/storage/runner_test.go +++ b/apps/backend/internal/system/storage/runner_test.go @@ -107,6 +107,30 @@ func TestScheduledRunnerPersistsSkippedBusy(t *testing.T) { } } +func TestRunnerPassesCapturedSettingsSnapshotToCleanupProvider(t *testing.T) { + provider := &snapshotCleanupProvider{} + runner := NewRunner(RunnerConfig{ + Activity: activity.NewCoordinator(activity.Options{}), + Store: &recordingRunStore{}, + Providers: []CleanupProvider{ + provider, + }, + NewID: func() string { return "settings-snapshot" }, + }) + settings := DefaultSettings() + settings.Enabled = true + settings.IdleForMinutes = 0 + settings.TemporaryArtifacts.Enabled = true + settings.QuarantineRetentionHours = 48 + + if _, err := runner.Run(context.Background(), RunTriggerScheduled, settings); err != nil { + t.Fatalf("Run: %v", err) + } + if provider.received != settings { + t.Fatalf("provider received settings = %#v, want captured snapshot %#v", provider.received, settings) + } +} + func TestTaskAdmissionCancelsProviderBeforeProceeding(t *testing.T) { coordinator := activity.NewCoordinator(activity.Options{}) provider := &cancellableProvider{started: make(chan struct{}), stopped: make(chan struct{})} @@ -254,6 +278,10 @@ type recordingCleanupProvider struct { calls int } +type snapshotCleanupProvider struct { + received StorageMaintenanceSettings +} + type cancelAfterCreateRunStore struct { cancel context.CancelFunc transitions []RunState @@ -313,6 +341,20 @@ func (p *recordingCleanupProvider) Cleanup(context.Context) (map[string]any, err return map[string]any{"cleaned": true}, nil } +func (p *snapshotCleanupProvider) Name() string { return "settings-snapshot" } + +func (p *snapshotCleanupProvider) Cleanup(context.Context) (map[string]any, error) { + return nil, errors.New("runner did not use settings snapshot") +} + +func (p *snapshotCleanupProvider) CleanupWithSettings( + _ context.Context, + settings StorageMaintenanceSettings, +) (map[string]any, error) { + p.received = settings + return map[string]any{"cleaned": true}, nil +} + func (p *cancellableProvider) Name() string { return "cancellable" } func (p *cancellableProvider) Cleanup(ctx context.Context) (map[string]any, error) { diff --git a/apps/backend/internal/system/storage/settings_test.go b/apps/backend/internal/system/storage/settings_test.go index e006ef26df..0f67fe9419 100644 --- a/apps/backend/internal/system/storage/settings_test.go +++ b/apps/backend/internal/system/storage/settings_test.go @@ -27,6 +27,9 @@ func TestDefaultSettingsDisablesWorkspaceDependencyCleanup(t *testing.T) { if !strings.Contains(string(raw), `"dependency_cleanup_enabled":false`) { t.Fatalf("defaults = %s, want dependency cleanup disabled", raw) } + if !strings.Contains(string(raw), `"temporary_artifacts":{"enabled":false}`) { + t.Fatalf("defaults = %s, want scheduled temporary cleanup disabled", raw) + } } func TestSettingsStoreMissingUsesDisabledDefaults(t *testing.T) { @@ -44,6 +47,7 @@ func TestSettingsStoreMissingUsesDisabledDefaults(t *testing.T) { QuarantineRetentionHours: 168, Workspaces: WorkspaceSettings{Enabled: true}, KandevContainers: ResourceSettings{Enabled: true}, + TemporaryArtifacts: ResourceSettings{Enabled: false}, GoCache: GoCacheSettings{ Enabled: false, MaxBytes: 16106127360, diff --git a/apps/backend/internal/system/storage/tempartifacts/cross_device_other.go b/apps/backend/internal/system/storage/tempartifacts/cross_device_other.go new file mode 100644 index 0000000000..9954a8a468 --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/cross_device_other.go @@ -0,0 +1,5 @@ +//go:build !aix && !android && !darwin && !dragonfly && !freebsd && !hurd && !illumos && !linux && !netbsd && !openbsd && !solaris && !windows + +package tempartifacts + +func isCrossDeviceError(error) bool { return false } diff --git a/apps/backend/internal/system/storage/tempartifacts/cross_device_unix.go b/apps/backend/internal/system/storage/tempartifacts/cross_device_unix.go new file mode 100644 index 0000000000..427d3a9bd9 --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/cross_device_unix.go @@ -0,0 +1,12 @@ +//go:build aix || android || darwin || dragonfly || freebsd || hurd || illumos || linux || netbsd || openbsd || solaris + +package tempartifacts + +import ( + "errors" + "syscall" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, syscall.EXDEV) +} diff --git a/apps/backend/internal/system/storage/tempartifacts/cross_device_windows.go b/apps/backend/internal/system/storage/tempartifacts/cross_device_windows.go new file mode 100644 index 0000000000..04dd5a1918 --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/cross_device_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package tempartifacts + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE) +} diff --git a/apps/backend/internal/system/storage/tempartifacts/provider.go b/apps/backend/internal/system/storage/tempartifacts/provider.go index b4d3900be4..251ee3ca9e 100644 --- a/apps/backend/internal/system/storage/tempartifacts/provider.go +++ b/apps/backend/internal/system/storage/tempartifacts/provider.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "os" "path/filepath" @@ -25,6 +26,10 @@ type QuarantineStore interface { TransitionQuarantineEntry(context.Context, string, storage.QuarantineState, string) (storage.QuarantineEntry, error) } +type SettingsReader interface { + GetSettings(context.Context) (storage.StorageMaintenanceSettings, error) +} + type ProviderConfig struct { Registry *Registry Store QuarantineStore @@ -33,8 +38,10 @@ type ProviderConfig struct { Retention time.Duration Now func() time.Time NewID func() string + Rename func(string, string) error Scanner *filescan.Limiter OnProgress func(filescan.Progress) + Settings SettingsReader } type Provider struct { @@ -45,8 +52,10 @@ type Provider struct { retention time.Duration now func() time.Time newID func() string + rename func(string, string) error scanner *filescan.Limiter onProgress func(filescan.Progress) + settings SettingsReader } type Analysis struct { @@ -64,14 +73,22 @@ type Analysis struct { } type CleanupResult struct { - Skipped bool `json:"skipped"` - Reason string `json:"reason,omitempty"` - Considered int `json:"considered"` - Quarantined int `json:"quarantined"` - ReclaimedBytes int64 `json:"reclaimed_bytes"` - Failed int `json:"failed"` - FailedBytes int64 `json:"failed_bytes"` - Warnings []string `json:"warnings,omitempty"` + Skipped bool `json:"skipped"` + Reason string `json:"reason,omitempty"` + Considered int `json:"considered"` + Quarantined int `json:"quarantined"` + ReclaimedBytes int64 `json:"reclaimed_bytes"` + QuarantinedBytes int64 `json:"quarantined_bytes"` + Failed int `json:"failed"` + FailedBytes int64 `json:"failed_bytes"` + Warnings []string `json:"warnings,omitempty"` +} + +func retentionFromSettings(settings storage.StorageMaintenanceSettings, fallback time.Duration) time.Duration { + if settings.QuarantineRetentionHours <= 0 { + return fallback + } + return time.Duration(settings.QuarantineRetentionHours) * time.Hour } func NewProvider(config ProviderConfig) *Provider { @@ -87,6 +104,10 @@ func NewProvider(config ProviderConfig) *Provider { if newID == nil { newID = uuid.NewString } + rename := config.Rename + if rename == nil { + rename = os.Rename + } trashDir := config.TrashDir if trashDir == "" { trashDir = filepath.Join(config.HomeDir, "trash") @@ -95,7 +116,8 @@ func NewProvider(config ProviderConfig) *Provider { registry: config.Registry, store: config.Store, homeDir: filepath.Clean(config.HomeDir), trashDir: filepath.Clean(trashDir), retention: retention, now: now, newID: newID, - scanner: config.Scanner, onProgress: config.OnProgress, + rename: rename, scanner: config.Scanner, onProgress: config.OnProgress, + settings: config.Settings, } } @@ -300,8 +322,26 @@ func (p *Provider) AnalyzeWithProgress( return copy.Analyze(ctx) } -func (p *Provider) Cleanup(context.Context) (map[string]any, error) { - return toMap(CleanupResult{Skipped: true, Reason: "manual_only"}), nil +func (p *Provider) Cleanup(ctx context.Context) (map[string]any, error) { + if p.settings == nil { + return toMap(CleanupResult{Skipped: true, Reason: "manual_only"}), nil + } + settings, err := p.settings.GetSettings(ctx) + if err != nil { + return nil, err + } + return p.CleanupWithSettings(ctx, settings) +} + +func (p *Provider) CleanupWithSettings( + ctx context.Context, + settings storage.StorageMaintenanceSettings, +) (map[string]any, error) { + if !settings.TemporaryArtifacts.Enabled { + return toMap(CleanupResult{Skipped: true, Reason: "disabled"}), nil + } + result, err := p.cleanupExplicitWithRetention(ctx, retentionFromSettings(settings, p.retention)) + return toMap(result), err } func (p *Provider) CleanupExplicit(ctx context.Context) (map[string]any, error) { @@ -309,10 +349,35 @@ func (p *Provider) CleanupExplicit(ctx context.Context) (map[string]any, error) return toMap(result), err } +func (p *Provider) CleanupExplicitWithSettings( + ctx context.Context, + settings storage.StorageMaintenanceSettings, +) (map[string]any, error) { + result, err := p.cleanupExplicitWithRetention( + ctx, retentionFromSettings(settings, p.retention), + ) + return toMap(result), err +} + func (p *Provider) cleanupExplicit(ctx context.Context) (CleanupResult, error) { + retention := p.retention + if p.settings != nil { + settings, err := p.settings.GetSettings(ctx) + if err != nil { + return CleanupResult{}, err + } + retention = retentionFromSettings(settings, retention) + } + return p.cleanupExplicitWithRetention(ctx, retention) +} + +func (p *Provider) cleanupExplicitWithRetention(ctx context.Context, retention time.Duration) (CleanupResult, error) { if p.registry == nil || p.store == nil { return CleanupResult{}, errors.New("temporary artifact provider is unavailable") } + if err := p.registry.Reconcile(ctx); err != nil { + return CleanupResult{}, err + } if err := p.Reconcile(ctx); err != nil { return CleanupResult{}, err } @@ -345,7 +410,7 @@ func (p *Provider) cleanupExplicit(ctx context.Context) (CleanupResult, error) { failures = append(failures, inspectErr) continue } - if err := p.quarantine(ctx, artifact, size); err != nil { + if err := p.quarantine(ctx, artifact, size, retention, cutoff); err != nil { result.Failed++ result.FailedBytes += size result.Warnings = append(result.Warnings, err.Error()) @@ -354,6 +419,7 @@ func (p *Provider) cleanupExplicit(ctx context.Context) (CleanupResult, error) { } result.Quarantined++ result.ReclaimedBytes += size + result.QuarantinedBytes += size } return result, errors.Join(failures...) } @@ -384,7 +450,14 @@ func (p *Provider) quarantine( ctx context.Context, artifact storage.TemporaryArtifact, size int64, + retention time.Duration, + cutoff time.Time, ) error { + validated, err := p.revalidate(ctx, artifact, cutoff) + if err != nil { + return err + } + artifact = validated.artifact id := p.newID() if id == "" { return errors.New("temporary artifact quarantine id must not be empty") @@ -406,18 +479,23 @@ func (p *Provider) quarantine( ID: id, ResourceType: storage.ResourceTypeTemporaryArtifact, OriginalPath: artifact.Path, QuarantinePath: quarantinePath, SizeBytes: size, State: storage.QuarantineStateQuarantined, QuarantinedAt: now, - DeleteAfter: now.Add(p.retention), Metadata: metadata, + DeleteAfter: now.Add(retention), Metadata: metadata, } if err := p.store.CreateQuarantineEntry(ctx, entry); err != nil { return fmt.Errorf("persist temporary artifact quarantine intent: %w", err) } - if err := p.registry.Validate(artifact); err != nil { - _, _ = p.store.TransitionQuarantineEntry(ctx, entry.ID, storage.QuarantineStateFailed, err.Error()) + current, err := p.revalidate(ctx, artifact, cutoff) + if err != nil { + p.markQuarantineFailed(ctx, entry, artifact, err) return err } - if err := os.Rename(artifact.Path, quarantinePath); err != nil { - _, _ = p.store.TransitionQuarantineEntry(ctx, entry.ID, storage.QuarantineStateFailed, err.Error()) - _ = p.registry.MarkFailed(ctx, artifact.ID, err.Error()) + if !sameFile(validated.info, current.info) { + err := fmt.Errorf("temporary artifact %s changed on disk during quarantine", artifact.Path) + p.markQuarantineFailed(ctx, entry, artifact, err) + return err + } + if err := p.moveToQuarantine(current.artifact, quarantinePath, current.info); err != nil { + p.markQuarantineFailed(ctx, entry, artifact, err) return fmt.Errorf("quarantine temporary artifact: %w", err) } if err := p.registry.MarkQuarantined(ctx, artifact.ID); err != nil { @@ -427,6 +505,312 @@ func (p *Provider) quarantine( return nil } +func (p *Provider) markQuarantineFailed( + ctx context.Context, + entry *storage.QuarantineEntry, + artifact storage.TemporaryArtifact, + err error, +) { + if entry == nil { + return + } + if entry.State != storage.QuarantineStateFailed { + _, _ = p.store.TransitionQuarantineEntry(ctx, entry.ID, storage.QuarantineStateFailed, err.Error()) + } + if artifact.State != storage.TemporaryArtifactStateFailed { + _ = p.registry.MarkFailed(ctx, artifact.ID, err.Error()) + } +} + +func (p *Provider) moveToQuarantine( + artifact storage.TemporaryArtifact, + quarantinePath string, + validatedInfo os.FileInfo, +) error { + if err := p.validateArtifactIdentity(artifact, validatedInfo); err != nil { + return err + } + renameErr := p.rename(artifact.Path, quarantinePath) + if renameErr == nil { + if err := p.validateMovedArtifact(quarantinePath, validatedInfo, artifact); err != nil { + return err + } + return nil + } + if !isCrossDeviceError(renameErr) { + return renameErr + } + return p.copyToQuarantine(artifact, quarantinePath, validatedInfo) +} + +func (p *Provider) copyToQuarantine( + artifact storage.TemporaryArtifact, + quarantinePath string, + validatedInfo os.FileInfo, +) error { + temporaryPath, err := os.MkdirTemp(filepath.Dir(quarantinePath), "."+filepath.Base(quarantinePath)+".tmp-") + if err != nil { + return fmt.Errorf("create cross-device quarantine staging directory: %w", err) + } + published := false + defer func() { + if !published { + _ = os.RemoveAll(temporaryPath) + } + }() + if err := copyOwnedDirectory(artifact.Path, temporaryPath); err != nil { + return fmt.Errorf("copy temporary artifact for quarantine: %w", err) + } + if err := syncDirectory(temporaryPath); err != nil { + return fmt.Errorf("sync temporary artifact quarantine: %w", err) + } + if err := p.validateArtifactIdentity(artifact, validatedInfo); err != nil { + return err + } + if err := p.rename(temporaryPath, quarantinePath); err != nil { + return fmt.Errorf("publish cross-device quarantine: %w", err) + } + published = true + if err := syncDirectory(filepath.Dir(quarantinePath)); err != nil { + return fmt.Errorf("sync published temporary artifact quarantine: %w", err) + } + if err := p.validateMovedArtifact(quarantinePath, nil, artifact); err != nil { + return err + } + if err := p.validateArtifactIdentity(artifact, validatedInfo); err != nil { + if cleanupErr := p.removePublishedArtifact(quarantinePath, artifact); cleanupErr != nil { + return errors.Join(err, cleanupErr) + } + return err + } + if err := p.removeValidatedArtifact(artifact.Path, validatedInfo, artifact); err != nil { + return fmt.Errorf("remove original temporary artifact after quarantine copy: %w", err) + } + return nil +} + +func (p *Provider) validateArtifactIdentity( + artifact storage.TemporaryArtifact, + validatedInfo os.FileInfo, +) error { + info, err := os.Lstat(artifact.Path) + if err != nil { + return fmt.Errorf("inspect temporary artifact %s before quarantine: %w", artifact.Path, err) + } + if !sameFile(info, validatedInfo) { + return fmt.Errorf("temporary artifact %s changed on disk during quarantine", artifact.Path) + } + if err := p.registry.Validate(artifact); err != nil { + return fmt.Errorf("validate temporary artifact %s before quarantine: %w", artifact.Path, err) + } + return nil +} + +func (p *Provider) validateMovedArtifact( + path string, + validatedInfo os.FileInfo, + artifact storage.TemporaryArtifact, +) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect quarantined temporary artifact: %w", err) + } + if validatedInfo != nil && !sameFile(info, validatedInfo) { + return errors.New("quarantine destination does not retain the validated artifact identity") + } + if err := p.registry.ValidateMarker(path, artifact); err != nil { + return fmt.Errorf("validate quarantined temporary artifact: %w", err) + } + return nil +} + +func sameFile(left, right os.FileInfo) bool { + return left != nil && right != nil && os.SameFile(left, right) +} + +func copyOwnedDirectory(source, destination string) error { + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("temporary artifact contains symlink %s", path) + } + relative, err := filepath.Rel(source, path) + if err != nil { + return err + } + if relative == "." { + return nil + } + target := filepath.Join(destination, relative) + info, err := entry.Info() + if err != nil { + return err + } + if entry.IsDir() { + if err := os.MkdirAll(target, info.Mode().Perm()); err != nil { + return err + } + return os.Chmod(target, info.Mode().Perm()) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("temporary artifact contains unsupported entry %s", path) + } + return copyOwnedFile(path, target, info.Mode().Perm()) + }) +} + +func copyOwnedFile(source, destination string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { + return err + } + input, err := os.Open(source) + if err != nil { + return err + } + defer func() { _ = input.Close() }() + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + copyErr := error(nil) + if _, copyErr = io.Copy(output, input); copyErr == nil { + copyErr = output.Sync() + } + closeErr := output.Close() + if copyErr != nil { + _ = os.Remove(destination) + return copyErr + } + if closeErr != nil { + _ = os.Remove(destination) + return closeErr + } + return nil +} + +func (p *Provider) removePublishedArtifact(path string, artifact storage.TemporaryArtifact) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if err := validatePublishedMarker(path, artifact); err != nil { + return err + } + return p.removeValidatedArtifact(path, info, artifact) +} + +func (p *Provider) removeValidatedArtifact( + path string, + validatedInfo os.FileInfo, + artifact storage.TemporaryArtifact, +) error { + if err := p.validatePathIdentity(path, validatedInfo, artifact); err != nil { + return err + } + tombstone, err := os.MkdirTemp(filepath.Dir(path), "."+filepath.Base(path)+".delete-") + if err != nil { + return fmt.Errorf("create deletion staging directory: %w", err) + } + if err := os.Remove(tombstone); err != nil { + return fmt.Errorf("prepare deletion staging path: %w", err) + } + if err := p.rename(path, tombstone); err != nil { + return fmt.Errorf("move validated path to deletion staging: %w", err) + } + movedInfo, err := os.Lstat(tombstone) + if err != nil { + return fmt.Errorf("inspect deletion staging path: %w", err) + } + if !sameFile(movedInfo, validatedInfo) { + return errors.New("deletion staging path does not retain the validated artifact identity") + } + if err := p.registry.ValidateMarker(tombstone, artifact); err != nil { + return fmt.Errorf("validate deletion staging path: %w", err) + } + if err := os.RemoveAll(tombstone); err != nil { + return fmt.Errorf("remove deletion staging path: %w", err) + } + return nil +} + +func validatePublishedMarker(path string, artifact storage.TemporaryArtifact) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("quarantine destination is not a real directory") + } + return validateMarkerFile(path, artifact) +} + +func validateMarkerFile(root string, artifact storage.TemporaryArtifact) error { + marker, err := readMarker(filepath.Join(root, MarkerName)) + if err != nil { + return err + } + if marker.ID != artifact.ID || marker.Kind != artifact.Kind || marker.Token != artifact.MarkerToken { + return errors.New("temporary artifact marker does not match the registry") + } + return nil +} + +func (p *Provider) revalidate( + ctx context.Context, + expected storage.TemporaryArtifact, + cutoff time.Time, +) (validatedArtifact, error) { + current, err := p.registry.Get(ctx, expected.ID) + if err != nil { + return validatedArtifact{}, fmt.Errorf("reload temporary artifact %s: %w", expected.ID, err) + } + if current.ID != expected.ID || + current.Kind != expected.Kind || + current.Path != expected.Path || + current.MarkerToken != expected.MarkerToken || + current.OwnerPID != expected.OwnerPID || + !current.CreatedAt.Equal(expected.CreatedAt) || + !sameTime(current.LastHeartbeatAt, expected.LastHeartbeatAt) || + !sameTime(current.ClosedAt, expected.ClosedAt) { + return validatedArtifact{}, fmt.Errorf( + "temporary artifact %s changed since candidate selection", expected.ID, + ) + } + if current.State != storage.TemporaryArtifactStateClosed && + current.State != storage.TemporaryArtifactStateAbandoned { + return validatedArtifact{}, fmt.Errorf( + "skip temporary artifact %s: lifecycle state is %s", current.Path, current.State, + ) + } + if artifactAge(current).After(cutoff) { + return validatedArtifact{}, fmt.Errorf( + "skip temporary artifact %s: lifecycle age is below the cleanup threshold", current.Path, + ) + } + if err := p.registry.Validate(current); err != nil { + return validatedArtifact{}, fmt.Errorf("validate temporary artifact %s: %w", current.Path, err) + } + info, err := os.Lstat(current.Path) + if err != nil { + return validatedArtifact{}, fmt.Errorf("inspect temporary artifact %s: %w", current.Path, err) + } + return validatedArtifact{artifact: current, info: info}, nil +} + +type validatedArtifact struct { + artifact storage.TemporaryArtifact + info os.FileInfo +} + +func sameTime(left, right *time.Time) bool { + if left == nil || right == nil { + return left == right + } + return left.Equal(*right) +} + func (p *Provider) Restore(ctx context.Context, id string) (storage.QuarantineEntry, error) { entry, artifact, err := p.loadQuarantineArtifact(ctx, id) if err != nil { @@ -487,7 +871,16 @@ func (p *Provider) permanentDelete( if err := p.registry.ValidateMarker(entry.QuarantinePath, artifact); err != nil { return entry, err } - if err := os.RemoveAll(entry.QuarantinePath); err != nil { + validatedInfo, err := os.Lstat(entry.QuarantinePath) + if err != nil { + return entry, fmt.Errorf("inspect temporary artifact quarantine: %w", err) + } + if err := p.validateQuarantineIdentity(entry, artifact, validatedInfo); err != nil { + p.markQuarantineFailed(ctx, &entry, artifact, err) + return entry, err + } + if err := p.removeValidatedArtifact(entry.QuarantinePath, validatedInfo, artifact); err != nil { + p.markQuarantineFailed(ctx, &entry, artifact, err) return entry, fmt.Errorf("delete temporary artifact quarantine: %w", err) } deleted, err := p.store.TransitionQuarantineEntry(ctx, id, storage.QuarantineStateDeleted, "") @@ -500,6 +893,35 @@ func (p *Provider) permanentDelete( return deleted, nil } +func (p *Provider) validateQuarantineIdentity( + entry storage.QuarantineEntry, + artifact storage.TemporaryArtifact, + validatedInfo os.FileInfo, +) error { + if err := p.validatePathIdentity(entry.QuarantinePath, validatedInfo, artifact); err != nil { + return fmt.Errorf("validate temporary artifact quarantine before deletion: %w", err) + } + return nil +} + +func (p *Provider) validatePathIdentity( + path string, + validatedInfo os.FileInfo, + artifact storage.TemporaryArtifact, +) error { + currentInfo, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect validated path %s: %w", path, err) + } + if !sameFile(validatedInfo, currentInfo) { + return fmt.Errorf("validated path changed on disk: %s", path) + } + if err := p.registry.ValidateMarker(path, artifact); err != nil { + return err + } + return nil +} + func (p *Provider) loadQuarantineArtifact( ctx context.Context, id string, diff --git a/apps/backend/internal/system/storage/tempartifacts/provider_safety_linux_test.go b/apps/backend/internal/system/storage/tempartifacts/provider_safety_linux_test.go new file mode 100644 index 0000000000..6f9889005b --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/provider_safety_linux_test.go @@ -0,0 +1,184 @@ +//go:build linux + +package tempartifacts + +import ( + "context" + "errors" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/kandev/kandev/internal/system/storage" +) + +func TestProviderRejectsArtifactReplacementAfterValidation(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + artifacts := newFakeArtifactStore() + registry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: 1234, + Now: func() time.Time { return now }, NewID: func() string { return "artifact-1" }, + NewToken: func() string { return "token-1" }, + }) + lease, err := registry.Create(context.Background(), storage.TemporaryArtifactKindImproveBundle, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := os.WriteFile(filepath.Join(lease.Path(), "bundle"), []byte("original"), 0o600); err != nil { + t.Fatalf("write bundle: %v", err) + } + if err := lease.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + artifact := artifacts.artifacts[lease.Path()] + old := now.Add(-25 * time.Hour) + artifact.CreatedAt, artifact.ClosedAt = old, &old + artifacts.artifacts[lease.Path()] = artifact + + quarantine := newFakeQuarantineStore() + replacementBackup := lease.Path() + ".original" + quarantine.onCreate = func() { + if err := os.Rename(lease.Path(), replacementBackup); err != nil { + t.Fatalf("replace original artifact: %v", err) + } + if err := os.Mkdir(lease.Path(), 0o700); err != nil { + t.Fatalf("create replacement artifact: %v", err) + } + if err := writeMarker(filepath.Join(lease.Path(), MarkerName), markerData{ + ID: artifact.ID, Kind: artifact.Kind, Token: artifact.MarkerToken, + }); err != nil { + t.Fatalf("write replacement marker: %v", err) + } + } + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Now: func() time.Time { return now }, NewID: func() string { return "quarantine-1" }, + }) + + result, err := provider.cleanupExplicit(context.Background()) + if err == nil { + t.Fatal("cleanup succeeded after the artifact directory was replaced") + } + if result.Failed != 1 || quarantine.entries["quarantine-1"].State != storage.QuarantineStateFailed { + t.Fatalf("cleanup result = %#v, quarantine = %#v", result, quarantine.entries["quarantine-1"]) + } + if _, err := os.Stat(lease.Path()); err != nil { + t.Fatalf("replacement artifact was removed: %v", err) + } + if _, err := os.Stat(replacementBackup); err != nil { + t.Fatalf("original artifact backup missing: %v", err) + } +} + +func TestProviderQuarantinesAcrossFilesystemsWithValidatedCopy(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + artifacts := newFakeArtifactStore() + registry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: 1234, + Now: func() time.Time { return now }, NewID: func() string { return "artifact-1" }, + NewToken: func() string { return "token-1" }, + }) + lease, err := registry.Create(context.Background(), storage.TemporaryArtifactKindHostUtility, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := os.MkdirAll(filepath.Join(lease.Path(), "nested"), 0o700); err != nil { + t.Fatalf("create nested directory: %v", err) + } + if err := os.WriteFile(filepath.Join(lease.Path(), "nested", "cache"), []byte("copied"), 0o600); err != nil { + t.Fatalf("write cache: %v", err) + } + if err := lease.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + artifact := artifacts.artifacts[lease.Path()] + old := now.Add(-25 * time.Hour) + artifact.CreatedAt, artifact.ClosedAt = old, &old + artifacts.artifacts[lease.Path()] = artifact + + quarantine := newFakeQuarantineStore() + renameCalls := 0 + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Now: func() time.Time { return now }, NewID: func() string { return "quarantine-1" }, + Rename: func(source, destination string) error { + renameCalls++ + if renameCalls == 1 { + return syscall.EXDEV + } + return os.Rename(source, destination) + }, + }) + + result, err := provider.cleanupExplicit(context.Background()) + if err != nil { + t.Fatalf("cleanup: %v", err) + } + if result.Quarantined != 1 || renameCalls != 3 { + t.Fatalf("cleanup result = %#v, rename calls = %d", result, renameCalls) + } + entry := quarantine.entries["quarantine-1"] + content, err := os.ReadFile(filepath.Join(entry.QuarantinePath, "nested", "cache")) + if err != nil { + t.Fatalf("read quarantined copy: %v", err) + } + if string(content) != "copied" { + t.Fatalf("quarantined content = %q, want copied content", content) + } + if _, err := os.Stat(lease.Path()); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("original artifact still exists: %v", err) + } + if artifacts.artifacts[lease.Path()].State != storage.TemporaryArtifactStateQuarantined { + t.Fatalf("artifact state = %q, want quarantined", artifacts.artifacts[lease.Path()].State) + } +} + +func TestProviderDeletionRejectsReplacedValidatedPath(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + artifacts := newFakeArtifactStore() + registry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: 1234, + Now: func() time.Time { return now }, NewID: func() string { return "artifact-1" }, + NewToken: func() string { return "token-1" }, + }) + lease, err := registry.Create(context.Background(), storage.TemporaryArtifactKindImproveBundle, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + artifact := lease.Artifact() + validatedInfo, err := os.Lstat(lease.Path()) + if err != nil { + t.Fatalf("Lstat: %v", err) + } + backup := lease.Path() + ".validated" + if err := os.Rename(lease.Path(), backup); err != nil { + t.Fatalf("rename original: %v", err) + } + if err := os.Mkdir(lease.Path(), 0o700); err != nil { + t.Fatalf("create replacement: %v", err) + } + if err := writeMarker(filepath.Join(lease.Path(), MarkerName), markerData{ + ID: artifact.ID, Kind: artifact.Kind, Token: artifact.MarkerToken, + }); err != nil { + t.Fatalf("write replacement marker: %v", err) + } + + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: newFakeQuarantineStore(), HomeDir: home, + Now: func() time.Time { return now }, + }) + if err := provider.removeValidatedArtifact(lease.Path(), validatedInfo, artifact); err == nil { + t.Fatal("deletion accepted a replacement directory") + } + if _, err := os.Stat(lease.Path()); err != nil { + t.Fatalf("replacement directory was removed: %v", err) + } +} diff --git a/apps/backend/internal/system/storage/tempartifacts/provider_test.go b/apps/backend/internal/system/storage/tempartifacts/provider_test.go index 96af0cf854..92daac48d1 100644 --- a/apps/backend/internal/system/storage/tempartifacts/provider_test.go +++ b/apps/backend/internal/system/storage/tempartifacts/provider_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "testing" "time" @@ -124,6 +125,243 @@ func TestProviderProtectsRecentlyClosedArtifact(t *testing.T) { } } +func TestProviderScheduledCleanupFollowsSavedTemporaryArtifactPolicy(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + artifacts := newFakeArtifactStore() + registry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: 1234, + Now: func() time.Time { return now }, NewID: func() string { return "artifact-1" }, + NewToken: func() string { return "token-1" }, + }) + lease, err := registry.Create(context.Background(), storage.TemporaryArtifactKindImproveBundle, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := os.WriteFile(filepath.Join(lease.Path(), "bundle.zip"), []byte("bundle"), 0o600); err != nil { + t.Fatal(err) + } + if err := lease.Close(context.Background()); err != nil { + t.Fatal(err) + } + artifact := artifacts.artifacts[lease.Path()] + old := now.Add(-25 * time.Hour) + artifact.CreatedAt, artifact.ClosedAt = old, &old + artifacts.artifacts[lease.Path()] = artifact + + quarantine := newFakeQuarantineStore() + settings := storage.DefaultSettings() + settings.TemporaryArtifacts.Enabled = true + settings.QuarantineRetentionHours = 48 + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Settings: testSettingsReader{settings: settings}, + Now: func() time.Time { return now }, NewID: func() string { return "quarantine-1" }, + }) + + result, err := provider.Cleanup(context.Background()) + if err != nil { + t.Fatalf("scheduled Cleanup: %v", err) + } + if result["skipped"] != false || result["quarantined"] != float64(1) { + t.Fatalf("scheduled cleanup = %#v, want one quarantined artifact", result) + } + quarantinedBytes, ok := result["quarantined_bytes"].(float64) + if !ok || quarantinedBytes <= 0 || quarantinedBytes != result["reclaimed_bytes"] { + t.Fatalf("scheduled cleanup bytes = %#v, want moved bytes in both result fields", result) + } + entry := quarantine.entries["quarantine-1"] + if !entry.DeleteAfter.Equal(now.Add(48 * time.Hour)) { + t.Fatalf("delete deadline = %s, want saved 48-hour retention", entry.DeleteAfter) + } +} + +func TestProviderScheduledCleanupReconcilesOwnerDeathWithoutRestart(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + artifacts := newFakeArtifactStore() + owner := exec.Command("sh", "-c", "sleep 30") + if err := owner.Start(); err != nil { + t.Fatalf("start owner process: %v", err) + } + t.Cleanup(func() { + if owner.ProcessState == nil { + _ = owner.Process.Kill() + _ = owner.Wait() + } + }) + + oldRegistry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: int64(owner.Process.Pid), RunID: "owner-run", + Now: func() time.Time { return now }, NewID: func() string { return "artifact-1" }, + NewToken: func() string { return "token-1" }, + }) + lease, err := oldRegistry.Create(context.Background(), storage.TemporaryArtifactKindImproveBundle, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := os.WriteFile(filepath.Join(lease.Path(), "bundle"), []byte("bundle"), 0o600); err != nil { + t.Fatal(err) + } + artifact := artifacts.artifacts[lease.Path()] + old := now.Add(-25 * time.Hour) + artifact.CreatedAt, artifact.LastHeartbeatAt = old, &old + artifacts.artifacts[lease.Path()] = artifact + + settings := storage.DefaultSettings() + settings.TemporaryArtifacts.Enabled = true + quarantine := newFakeQuarantineStore() + currentRegistry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: int64(os.Getpid()), RunID: "current-run", + Now: func() time.Time { return now }, NewID: func() string { return "current-artifact" }, + NewToken: func() string { return "current-token" }, + }) + provider := NewProvider(ProviderConfig{ + Registry: currentRegistry, Store: quarantine, HomeDir: home, + Settings: testSettingsReader{settings: settings}, Now: func() time.Time { return now }, + NewID: func() string { return "quarantine-1" }, + }) + + first, err := provider.Cleanup(context.Background()) + if err != nil { + t.Fatalf("cleanup while owner is alive: %v", err) + } + if first["quarantined"] != float64(0) { + t.Fatalf("cleanup while owner is alive = %#v, want protected active artifact", first) + } + + if err := owner.Process.Kill(); err != nil { + t.Fatalf("kill owner process: %v", err) + } + _ = owner.Wait() + + second, err := provider.Cleanup(context.Background()) + if err != nil { + t.Fatalf("cleanup after owner death: %v", err) + } + if second["quarantined"] != float64(1) { + t.Fatalf("cleanup after owner death = %#v, want one quarantined artifact", second) + } + third, err := provider.Cleanup(context.Background()) + if err != nil { + t.Fatalf("repeated cleanup after owner death: %v", err) + } + if third["quarantined"] != float64(0) { + t.Fatalf("repeated cleanup = %#v, want no duplicate quarantine", third) + } + if artifacts.artifacts[lease.Path()].State != storage.TemporaryArtifactStateQuarantined { + t.Fatalf("artifact state = %q, want quarantined", artifacts.artifacts[lease.Path()].State) + } +} + +func TestProviderExplicitCleanupBypassesDisabledTemporaryArtifactPolicy(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + artifacts := newFakeArtifactStore() + registry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: 1234, + Now: func() time.Time { return now }, NewID: func() string { return "artifact-1" }, + NewToken: func() string { return "token-1" }, + }) + lease, err := registry.Create(context.Background(), storage.TemporaryArtifactKindHostUtility, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := os.WriteFile(filepath.Join(lease.Path(), "cache"), []byte("cache"), 0o600); err != nil { + t.Fatal(err) + } + if err := lease.Close(context.Background()); err != nil { + t.Fatal(err) + } + artifact := artifacts.artifacts[lease.Path()] + old := now.Add(-25 * time.Hour) + artifact.CreatedAt, artifact.ClosedAt = old, &old + artifacts.artifacts[lease.Path()] = artifact + + settings := storage.DefaultSettings() + settings.TemporaryArtifacts.Enabled = false + quarantine := newFakeQuarantineStore() + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Settings: testSettingsReader{settings: settings}, + Now: func() time.Time { return now }, NewID: func() string { return "quarantine-1" }, + }) + + scheduled, err := provider.Cleanup(context.Background()) + if err != nil { + t.Fatalf("scheduled Cleanup: %v", err) + } + if scheduled["skipped"] != true || scheduled["reason"] != "disabled" { + t.Fatalf("scheduled cleanup = %#v, want disabled skip", scheduled) + } + + explicit, err := provider.CleanupExplicit(context.Background()) + if err != nil { + t.Fatalf("explicit Cleanup: %v", err) + } + if explicit["quarantined"] != float64(1) { + t.Fatalf("explicit cleanup = %#v, want one quarantined artifact", explicit) + } + if _, err := os.Stat(lease.Path()); !os.IsNotExist(err) { + t.Fatalf("original artifact still exists: %v", err) + } +} + +func TestProviderRevalidatesLifecycleBeforeQuarantineRename(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + artifacts := newFakeArtifactStore() + registry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: 1234, + Now: func() time.Time { return now }, NewID: func() string { return "artifact-1" }, + NewToken: func() string { return "token-1" }, + }) + lease, err := registry.Create(context.Background(), storage.TemporaryArtifactKindImproveBundle, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := os.WriteFile(filepath.Join(lease.Path(), "bundle"), []byte("bundle"), 0o600); err != nil { + t.Fatal(err) + } + if err := lease.Close(context.Background()); err != nil { + t.Fatal(err) + } + artifact := artifacts.artifacts[lease.Path()] + old := now.Add(-25 * time.Hour) + artifact.CreatedAt, artifact.ClosedAt = old, &old + artifacts.artifacts[lease.Path()] = artifact + + quarantine := newFakeQuarantineStore() + quarantine.onCreate = func() { + current := artifacts.artifacts[lease.Path()] + recent := now.Add(-time.Hour) + current.ClosedAt = &recent + artifacts.artifacts[lease.Path()] = current + } + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Now: func() time.Time { return now }, NewID: func() string { return "quarantine-1" }, + }) + + result, err := provider.cleanupExplicit(context.Background()) + if err == nil { + t.Fatal("cleanup succeeded after lifecycle timestamp changed") + } + if result.Failed != 1 || len(result.Warnings) != 1 { + t.Fatalf("cleanup result = %#v, want one revalidation failure", result) + } + if _, err := os.Stat(lease.Path()); err != nil { + t.Fatalf("original artifact was changed: %v", err) + } + if quarantine.entries["quarantine-1"].State != storage.QuarantineStateFailed { + t.Fatalf("quarantine entry = %#v, want failed intent", quarantine.entries["quarantine-1"]) + } +} + func TestProviderReconcilesRenameBeforeLifecycleUpdate(t *testing.T) { root := t.TempDir() home := t.TempDir() @@ -335,7 +573,8 @@ func TestProviderCanRequarantineRestoredArtifact(t *testing.T) { } type fakeQuarantineStore struct { - entries map[string]storage.QuarantineEntry + entries map[string]storage.QuarantineEntry + onCreate func() } func newFakeQuarantineStore() *fakeQuarantineStore { @@ -344,6 +583,9 @@ func newFakeQuarantineStore() *fakeQuarantineStore { func (s *fakeQuarantineStore) CreateQuarantineEntry(_ context.Context, entry *storage.QuarantineEntry) error { s.entries[entry.ID] = *entry + if s.onCreate != nil { + s.onCreate() + } return nil } @@ -404,3 +646,11 @@ func validFakeQuarantineTransition(current, next storage.QuarantineState) bool { return false } } + +type testSettingsReader struct { + settings storage.StorageMaintenanceSettings +} + +func (s testSettingsReader) GetSettings(context.Context) (storage.StorageMaintenanceSettings, error) { + return s.settings, nil +} diff --git a/apps/backend/internal/system/storage/tempartifacts/sync_directory_unix.go b/apps/backend/internal/system/storage/tempartifacts/sync_directory_unix.go new file mode 100644 index 0000000000..a58091d00e --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/sync_directory_unix.go @@ -0,0 +1,20 @@ +//go:build !windows + +package tempartifacts + +import ( + "fmt" + "os" +) + +func syncDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return fmt.Errorf("open directory for sync: %w", err) + } + defer func() { _ = directory.Close() }() + if err := directory.Sync(); err != nil { + return fmt.Errorf("sync directory: %w", err) + } + return nil +} diff --git a/apps/backend/internal/system/storage/tempartifacts/sync_directory_windows.go b/apps/backend/internal/system/storage/tempartifacts/sync_directory_windows.go new file mode 100644 index 0000000000..37a3ae624d --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/sync_directory_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package tempartifacts + +func syncDirectory(string) error { return nil } diff --git a/apps/backend/internal/system/storage/tempstore/mount_darwin.go b/apps/backend/internal/system/storage/tempstore/mount_darwin.go new file mode 100644 index 0000000000..e3b77c955d --- /dev/null +++ b/apps/backend/internal/system/storage/tempstore/mount_darwin.go @@ -0,0 +1,36 @@ +//go:build darwin + +package tempstore + +import ( + "fmt" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +type mountReader struct{} + +func newMountReader() MountReader { return mountReader{} } + +func (mountReader) Identity(path string) (string, error) { + var stat unix.Statfs_t + if err := unix.Statfs(path, &stat); err != nil { + return "", fmt.Errorf("stat temporary mount %s: %w", path, err) + } + mountpoint := strings.TrimRight(stringFromBytes(stat.Mntonname[:]), string(filepath.Separator)) + if mountpoint == "" { + mountpoint = string(filepath.Separator) + } + return filepath.Clean(mountpoint), nil +} + +func stringFromBytes(value []byte) string { + for index, item := range value { + if item == 0 { + return string(value[:index]) + } + } + return string(value) +} diff --git a/apps/backend/internal/system/storage/tempstore/mount_linux.go b/apps/backend/internal/system/storage/tempstore/mount_linux.go new file mode 100644 index 0000000000..7367f36508 --- /dev/null +++ b/apps/backend/internal/system/storage/tempstore/mount_linux.go @@ -0,0 +1,132 @@ +//go:build linux + +package tempstore + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type mountReader struct { + readMountInfo func() ([]byte, error) +} + +func newMountReader() MountReader { + return &mountReader{readMountInfo: func() ([]byte, error) { + return os.ReadFile("/proc/self/mountinfo") + }} +} + +func (r *mountReader) Identity(path string) (string, error) { + canonical, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + mounts, err := r.load() + if err != nil { + return "", err + } + return identityForPath(canonical, mounts) +} + +func (r *mountReader) Snapshot() (MountReader, error) { + mounts, err := r.load() + if err != nil { + return nil, err + } + return mountTable{mounts: mounts}, nil +} + +type mountTable struct { + mounts []mountEntry +} + +func (t mountTable) Identity(path string) (string, error) { + canonical, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", err + } + return identityForPath(canonical, t.mounts) +} + +func identityForPath(canonical string, mounts []mountEntry) (string, error) { + best := "" + bestIdentity := "" + for _, mount := range mounts { + if pathWithin(mount.mountpoint, canonical) && len(mount.mountpoint) > len(best) { + best = mount.mountpoint + bestIdentity = mount.identity + } + } + if best == "" || bestIdentity == "" { + return "", fmt.Errorf("mount identity is unavailable for %s", canonical) + } + return bestIdentity, nil +} + +type mountEntry struct { + mountpoint string + identity string +} + +func (r *mountReader) load() ([]mountEntry, error) { + readMountInfo := r.readMountInfo + if readMountInfo == nil { + readMountInfo = func() ([]byte, error) { + return os.ReadFile("/proc/self/mountinfo") + } + } + data, err := readMountInfo() + if err != nil { + return nil, fmt.Errorf("read mount information: %w", err) + } + mounts := make([]mountEntry, 0) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 6 { + continue + } + root, err := unescapeMountInfo(fields[3]) + if err != nil { + return nil, err + } + mountpoint, err := unescapeMountInfo(fields[4]) + if err != nil { + return nil, err + } + mountpoint, err = filepath.Abs(filepath.Clean(mountpoint)) + if err != nil { + return nil, err + } + identity := strings.Join([]string{fields[0], fields[1], fields[2], root, mountpoint}, "\x00") + mounts = append(mounts, mountEntry{mountpoint: mountpoint, identity: identity}) + } + if len(mounts) == 0 { + return nil, errors.New("mount information is empty") + } + return mounts, nil +} + +func unescapeMountInfo(value string) (string, error) { + var builder strings.Builder + for index := 0; index < len(value); index++ { + if value[index] != '\\' { + builder.WriteByte(value[index]) + continue + } + if index+3 >= len(value) { + return "", fmt.Errorf("invalid mount path escape %q", value) + } + code, err := strconv.ParseUint(value[index+1:index+4], 8, 8) + if err != nil { + return "", fmt.Errorf("decode mount path %q: %w", value, err) + } + builder.WriteByte(byte(code)) + index += 3 + } + return builder.String(), nil +} diff --git a/apps/backend/internal/system/storage/tempstore/mount_linux_test.go b/apps/backend/internal/system/storage/tempstore/mount_linux_test.go new file mode 100644 index 0000000000..6ee964d466 --- /dev/null +++ b/apps/backend/internal/system/storage/tempstore/mount_linux_test.go @@ -0,0 +1,94 @@ +//go:build linux + +package tempstore + +import ( + "fmt" + "strings" + "testing" +) + +func TestMountReaderRefreshesForNestedMountsBetweenScans(t *testing.T) { + tables := [][]byte{ + []byte(mountInfoLine("100", "1", "0:1", "/", "/tmp")), + []byte(mountInfoLine("100", "1", "0:1", "/", "/tmp") + + mountInfoLine("200", "100", "0:2", "/", "/tmp/nested")), + } + reader := mountReader{readMountInfo: sequenceMountTables(t, tables)} + + parentIdentity, err := reader.Identity("/tmp/work") + if err != nil { + t.Fatalf("parent identity: %v", err) + } + nestedIdentity, err := reader.Identity("/tmp/nested/file") + if err != nil { + t.Fatalf("nested identity: %v", err) + } + if parentIdentity == nestedIdentity { + t.Fatalf("nested mount identity = %q, want refreshed identity distinct from parent %q", nestedIdentity, parentIdentity) + } + if !strings.HasPrefix(nestedIdentity, "200\x00") { + t.Fatalf("nested mount identity = %q, want mount ID 200", nestedIdentity) + } +} + +func TestMountReaderDetectsReplacementAtExistingMountpoint(t *testing.T) { + tables := [][]byte{ + []byte(mountInfoLine("100", "1", "0:1", "/", "/tmp")), + []byte(mountInfoLine("300", "1", "0:3", "/", "/tmp")), + } + reader := mountReader{readMountInfo: sequenceMountTables(t, tables)} + + oldIdentity, err := reader.Identity("/tmp/work") + if err != nil { + t.Fatalf("old identity: %v", err) + } + newIdentity, err := reader.Identity("/tmp/work") + if err != nil { + t.Fatalf("new identity: %v", err) + } + if oldIdentity == newIdentity { + t.Fatalf("replacement identity remained %q", oldIdentity) + } + if !strings.HasPrefix(newIdentity, "300\x00") { + t.Fatalf("replacement identity = %q, want mount ID 300", newIdentity) + } +} + +func TestMountReaderSnapshotReusesOneLoadedTable(t *testing.T) { + reads := 0 + reader := mountReader{readMountInfo: func() ([]byte, error) { + reads++ + return []byte(mountInfoLine("100", "1", "0:1", "/", "/tmp")), nil + }} + + snapshot, err := reader.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + for _, path := range []string{"/tmp/one", "/tmp/two"} { + if _, err := snapshot.Identity(path); err != nil { + t.Fatalf("snapshot identity %s: %v", path, err) + } + } + if reads != 1 { + t.Fatalf("mount table reads = %d, want one snapshot read", reads) + } +} + +func sequenceMountTables(t *testing.T, tables [][]byte) func() ([]byte, error) { + t.Helper() + index := 0 + return func() ([]byte, error) { + if index >= len(tables) { + return nil, fmt.Errorf("mount table sequence exhausted at call %d", index) + } + table := tables[index] + index++ + return table, nil + } +} + +func mountInfoLine(id, parent, device, root, mountpoint string) string { + return fmt.Sprintf("%s %s %s %s %s rw - testfs tmpfs rw\n", id, parent, device, root, mountpoint) +} diff --git a/apps/backend/internal/system/storage/tempstore/mount_other_unix.go b/apps/backend/internal/system/storage/tempstore/mount_other_unix.go new file mode 100644 index 0000000000..e851965de0 --- /dev/null +++ b/apps/backend/internal/system/storage/tempstore/mount_other_unix.go @@ -0,0 +1,13 @@ +//go:build !windows && !linux && !darwin + +package tempstore + +import "errors" + +type mountReader struct{} + +func newMountReader() MountReader { return mountReader{} } + +func (mountReader) Identity(string) (string, error) { + return "", errors.New("temporary mount identity is unsupported on this platform") +} diff --git a/apps/backend/internal/system/storage/tempstore/mount_windows.go b/apps/backend/internal/system/storage/tempstore/mount_windows.go new file mode 100644 index 0000000000..a66c188724 --- /dev/null +++ b/apps/backend/internal/system/storage/tempstore/mount_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package tempstore + +import ( + "fmt" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +type mountReader struct{} + +func newMountReader() MountReader { return mountReader{} } + +func (mountReader) Identity(path string) (string, error) { + input, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + buffer := make([]uint16, 32768) + if err := windows.GetVolumePathName(input, &buffer[0], uint32(len(buffer))); err != nil { + return "", fmt.Errorf("resolve temporary volume %s: %w", path, err) + } + volume := windows.UTF16ToString(buffer) + if strings.TrimSpace(volume) == "" { + return "", fmt.Errorf("temporary volume is empty for %s", path) + } + return filepath.Clean(volume), nil +} diff --git a/apps/backend/internal/system/storage/tempstore/provider.go b/apps/backend/internal/system/storage/tempstore/provider.go new file mode 100644 index 0000000000..2a4e7c62aa --- /dev/null +++ b/apps/backend/internal/system/storage/tempstore/provider.go @@ -0,0 +1,585 @@ +// Package tempstore measures the server-selected system temporary folders. +package tempstore + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/kandev/kandev/internal/system/storage/filescan" +) + +const ( + defaultScanDeadline = 60 * time.Second + windowsGOOS = "windows" +) + +type Status string + +const ( + StatusMeasured Status = "measured" + StatusPartial Status = "partial" + StatusUnavailable Status = "unavailable" + StatusNotApplicable Status = "not_applicable" +) + +type RootCandidate struct { + RequestedPath string + Optional bool +} + +type MountReader interface { + Identity(string) (string, error) +} + +// MountSnapshotter can provide one mount table for a traversal boundary. A +// snapshot avoids reparsing the host mount table for every file while the +// guard still refreshes it when entering a directory and before a partition. +type MountSnapshotter interface { + Snapshot() (MountReader, error) +} + +type Scanner interface { + MeasureWithOptions( + context.Context, + []filescan.Root, + filescan.MeasureOptions, + func(filescan.Progress), + ) []filescan.Result +} + +type Config struct { + EffectiveRoot string + UnixRoot string + GOOS string + RootResolver func(context.Context) ([]RootCandidate, error) + Mounts MountReader + Scanner Scanner + Deadline time.Duration + OnProgress func(filescan.Progress) +} + +type RootMeasurement struct { + RequestedPath string `json:"requested_path"` + Path string `json:"path"` + Aliases []string `json:"aliases,omitempty"` + Status Status `json:"status"` + SizeBytes *int64 `json:"size_bytes,omitempty"` + SkippedCount int `json:"skipped_count,omitempty"` + Reason string `json:"reason,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type Analysis struct { + Status Status `json:"status"` + Roots []RootMeasurement `json:"roots"` + SizeBytes *int64 `json:"size_bytes,omitempty"` + IncludedInTotal bool `json:"included_in_total"` + Reason string `json:"reason,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type Provider struct { + effectiveRoot string + unixRoot string + goos string + rootResolver func(context.Context) ([]RootCandidate, error) + mounts MountReader + scanner Scanner + deadline time.Duration + onProgress func(filescan.Progress) +} + +func New(config Config) *Provider { + goos := config.GOOS + if goos == "" { + goos = runtime.GOOS + } + effectiveRoot := config.EffectiveRoot + if effectiveRoot == "" { + effectiveRoot = os.TempDir() + } + unixRoot := config.UnixRoot + if unixRoot == "" { + unixRoot = "/tmp" + } + mounts := config.Mounts + if mounts == nil { + mounts = newMountReader() + } + scanner := config.Scanner + if scanner == nil { + scanner = filescan.NewLimiter(4) + } + deadline := config.Deadline + if deadline <= 0 { + deadline = defaultScanDeadline + } + resolver := config.RootResolver + if resolver == nil { + resolver = func(context.Context) ([]RootCandidate, error) { + candidates := []RootCandidate{{RequestedPath: effectiveRoot}} + if goos != windowsGOOS && unixRoot != "" { + candidates = append(candidates, RootCandidate{RequestedPath: unixRoot, Optional: true}) + } + return candidates, nil + } + } + return &Provider{ + effectiveRoot: effectiveRoot, unixRoot: unixRoot, goos: goos, + rootResolver: resolver, mounts: mounts, scanner: scanner, + deadline: deadline, onProgress: config.OnProgress, + } +} + +func NewProvider(config Config) *Provider { return New(config) } + +func (p *Provider) AnalyzeWithProgress( + ctx context.Context, + onProgress func(filescan.Progress), +) (Analysis, error) { + copy := *p + copy.onProgress = onProgress + return copy.Analyze(ctx) +} + +func (p *Provider) Analyze(ctx context.Context) (Analysis, error) { + if err := ctx.Err(); err != nil { + return Analysis{}, err + } + candidates, err := p.resolveRoots(ctx) + if err != nil { + return Analysis{ + Status: StatusUnavailable, IncludedInTotal: false, + Reason: "root_resolution_failed", Warnings: []string{err.Error()}, + }, err + } + plans := p.planRoots(candidates) + analysis := Analysis{IncludedInTotal: false, Roots: make([]RootMeasurement, len(plans))} + scanRoots := make([]filescan.Root, 0, len(plans)) + scanPlans := make([]int, 0, len(plans)) + for index := range plans { + analysis.Roots[index] = plans[index].measurement + if plans[index].scan == nil { + continue + } + scanPlans = append(scanPlans, index) + scanRoots = append(scanRoots, *plans[index].scan) + } + if len(scanRoots) > 0 { + scanCtx, cancel := context.WithTimeout(ctx, p.deadline) + results := p.scanner.MeasureWithOptions( + scanCtx, scanRoots, + filescan.MeasureOptions{TolerateEntryErrors: true, CountSkipped: true, MaxWarnings: 10}, + p.onProgress, + ) + scanDeadline := errors.Is(scanCtx.Err(), context.DeadlineExceeded) + cancel() + if err := ctx.Err(); err != nil { + return analysis, err + } + if err := scanCtx.Err(); err != nil && !errors.Is(err, context.Canceled) && !scanDeadline { + return analysis, err + } + for scanIndex, planIndex := range scanPlans { + result := filescan.Result{} + if scanIndex < len(results) { + result = results[scanIndex] + } else { + result.Err = errors.New("temporary root measurement returned no result") + } + analysis.Roots[planIndex] = measurementFromResult( + analysis.Roots[planIndex], result, scanDeadline, + ) + } + } + return summarize(analysis), nil +} + +func (p *Provider) resolveRoots(ctx context.Context) ([]RootCandidate, error) { + if p == nil || p.rootResolver == nil { + return nil, errors.New("temporary root resolver is unavailable") + } + return p.rootResolver(ctx) +} + +type rootPlan struct { + measurement RootMeasurement + scan *filescan.Root + canonical string + identity string +} + +func (p *Provider) planRoots(candidates []RootCandidate) []rootPlan { + plans := make([]rootPlan, 0, len(candidates)) + for _, candidate := range candidates { + requested, err := absoluteClean(candidate.RequestedPath) + if err != nil { + plans = append(plans, unavailablePlan(candidate.RequestedPath, candidate.Optional, err)) + continue + } + _, err = os.Lstat(requested) + if errors.Is(err, os.ErrNotExist) { + if candidate.Optional { + plans = append(plans, rootPlan{measurement: RootMeasurement{ + RequestedPath: requested, Path: requested, Status: StatusNotApplicable, + Reason: "root_not_present", + }}) + } else { + plans = append(plans, unavailablePlan(requested, false, errors.New("root does not exist"))) + } + continue + } + if err != nil { + plans = append(plans, unavailablePlan(requested, candidate.Optional, err)) + continue + } + canonical, err := filepath.EvalSymlinks(requested) + if err != nil { + plans = append(plans, unavailablePlan(requested, candidate.Optional, err)) + continue + } + canonical, err = absoluteClean(canonical) + if err != nil { + plans = append(plans, unavailablePlan(requested, candidate.Optional, err)) + continue + } + targetInfo, err := os.Stat(canonical) + if err != nil { + plans = append(plans, unavailablePlan(requested, candidate.Optional, err)) + continue + } + if !targetInfo.IsDir() { + plans = append(plans, unavailablePlan(requested, candidate.Optional, errors.New("root is not a directory"))) + continue + } + if isFilesystemRoot(canonical) { + plans = append(plans, unavailablePlanWithReason(requested, canonical, "unsafe_root")) + continue + } + identity, err := p.mounts.Identity(canonical) + if err != nil { + plans = append(plans, unavailablePlan(requested, candidate.Optional, err)) + continue + } + guard := &mountGuard{ + path: canonical, identity: identity, mounts: p.mounts, windows: p.goos == windowsGOOS, + } + measurement := RootMeasurement{ + RequestedPath: requested, Path: canonical, Status: StatusMeasured, + } + scan := &filescan.Root{ + Path: canonical, SymlinkPolicy: filescan.SkipSymlinks, + ShouldSkip: guard.shouldSkip, Validate: guard.validate, + } + plans = addRootPlan(plans, rootPlan{ + measurement: measurement, scan: scan, canonical: canonical, identity: identity, + }, p.goos == windowsGOOS) + } + return plans +} + +func absoluteClean(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", errors.New("temporary root path is empty") + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + return filepath.Clean(abs), nil +} + +func unavailablePlan(requested string, optional bool, err error) rootPlan { + if optional && errors.Is(err, os.ErrNotExist) { + return rootPlan{measurement: RootMeasurement{ + RequestedPath: requested, Path: requested, Status: StatusNotApplicable, + Reason: "root_not_present", + }} + } + return rootPlan{measurement: RootMeasurement{ + RequestedPath: requested, Path: requested, Status: StatusUnavailable, + Reason: "measurement_unavailable", Warnings: []string{err.Error()}, + }} +} + +func unavailablePlanWithReason(requested, path, reason string) rootPlan { + return rootPlan{measurement: RootMeasurement{ + RequestedPath: requested, Path: path, Status: StatusUnavailable, Reason: reason, + }} +} + +func addRootPlan(plans []rootPlan, candidate rootPlan, windows bool) []rootPlan { + for index := range plans { + existing := &plans[index] + if existing.canonical == "" { + continue + } + if samePath(existing.canonical, candidate.canonical, windows) { + existing.measurement.Aliases = append(existing.measurement.Aliases, candidate.measurement.RequestedPath) + return plans + } + if !sameMount(existing.identity, candidate.identity, windows) { + continue + } + if pathWithin(existing.canonical, candidate.canonical) { + existing.measurement.Aliases = append(existing.measurement.Aliases, candidate.measurement.RequestedPath) + return plans + } + } + + nested := make([]int, 0) + for index := range plans { + existing := plans[index] + if existing.canonical != "" && + sameMount(existing.identity, candidate.identity, windows) && + pathWithin(candidate.canonical, existing.canonical) { + nested = append(nested, index) + } + } + if len(nested) == 0 { + return append(plans, candidate) + } + + aliases := append([]string(nil), candidate.measurement.Aliases...) + for _, index := range nested { + existing := plans[index].measurement + aliases = append(aliases, existing.RequestedPath) + aliases = append(aliases, existing.Aliases...) + } + candidate.measurement.Aliases = aliases + first := nested[0] + result := make([]rootPlan, 0, len(plans)-len(nested)+1) + for index, plan := range plans { + if index == first { + result = append(result, candidate) + } + if containsIndex(nested, index) { + continue + } + result = append(result, plan) + } + return result +} + +func containsIndex(indices []int, target int) bool { + for _, index := range indices { + if index == target { + return true + } + } + return false +} + +type mountGuard struct { + mu sync.Mutex + path string + identity string + mounts MountReader + windows bool + snapshot MountReader +} + +func (g *mountGuard) refreshSnapshotLocked() error { + snapshotter, ok := g.mounts.(MountSnapshotter) + if !ok { + g.snapshot = nil + return nil + } + snapshot, err := snapshotter.Snapshot() + if err != nil { + return fmt.Errorf("refresh temporary mount table: %w", err) + } + if snapshot == nil { + return errors.New("temporary mount snapshot is empty") + } + g.snapshot = snapshot + return nil +} + +func (g *mountGuard) validate() error { + g.mu.Lock() + defer g.mu.Unlock() + + canonical, err := filepath.EvalSymlinks(g.path) + if err != nil { + return fmt.Errorf("temporary root changed: %w", err) + } + canonical, err = absoluteClean(canonical) + if err != nil || !samePath(canonical, g.path, g.windows) { + return fmt.Errorf("temporary root was replaced: %s", g.path) + } + if err := g.refreshSnapshotLocked(); err != nil { + return err + } + mounts := g.snapshot + if mounts == nil { + mounts = g.mounts + } + identity, err := mounts.Identity(g.path) + if err != nil { + return fmt.Errorf("inspect temporary root mount: %w", err) + } + if !sameMount(identity, g.identity, g.windows) { + return fmt.Errorf("temporary root mount changed: %s", g.path) + } + return nil +} + +func (g *mountGuard) shouldSkip(path string, entry fs.DirEntry) (bool, error) { + g.mu.Lock() + defer g.mu.Unlock() + + if g.snapshot == nil || (entry != nil && entry.IsDir()) { + if err := g.refreshSnapshotLocked(); err != nil { + return false, err + } + } + mounts := g.snapshot + if mounts == nil { + mounts = g.mounts + } + rootIdentity, err := mounts.Identity(g.path) + if err != nil { + return false, fmt.Errorf("inspect temporary root mount %s: %w", g.path, err) + } + if !sameMount(rootIdentity, g.identity, g.windows) { + return false, fmt.Errorf("temporary root mount changed: %s", g.path) + } + if entry != nil && entry.Type()&os.ModeSymlink != 0 { + return false, nil + } + identity, err := mounts.Identity(path) + if err != nil { + return false, fmt.Errorf("inspect temporary path mount %s: %w", path, err) + } + return !sameMount(identity, g.identity, g.windows), nil +} + +func measurementFromResult( + measurement RootMeasurement, + result filescan.Result, + deadline bool, +) RootMeasurement { + measurement.SkippedCount = result.SkippedCount + measurement.Warnings = appendBounded(nil, result.Warnings, 10) + if result.Err != nil { + if deadline && errors.Is(result.Err, context.DeadlineExceeded) { + size := result.Bytes + measurement.Status = StatusPartial + measurement.SizeBytes = &size + measurement.Reason = "deadline" + measurement.Warnings = appendBounded( + measurement.Warnings, []string{result.Err.Error()}, 10, + ) + return measurement + } + measurement.Status = StatusUnavailable + measurement.SizeBytes = nil + measurement.Reason = "measurement_unavailable" + measurement.Warnings = appendBounded(measurement.Warnings, []string{result.Err.Error()}, 10) + return measurement + } + size := result.Bytes + measurement.SizeBytes = &size + if result.Partial { + measurement.Status = StatusPartial + measurement.Reason = "some_entries_unmeasured" + } + return measurement +} + +func summarize(analysis Analysis) Analysis { + var measured, partial, unavailable, notApplicable int + var total int64 + for index := range analysis.Roots { + root := &analysis.Roots[index] + switch root.Status { + case StatusMeasured: + measured++ + case StatusPartial: + partial++ + case StatusUnavailable: + unavailable++ + case StatusNotApplicable: + notApplicable++ + } + if root.SizeBytes != nil { + total += *root.SizeBytes + } + analysis.Warnings = appendBounded(analysis.Warnings, root.Warnings, 10) + } + switch { + case measured+partial > 0: + size := total + analysis.SizeBytes = &size + analysis.Reason = "informational_overlap" + for _, root := range analysis.Roots { + if root.Reason == "deadline" { + analysis.Reason = "deadline" + break + } + } + if partial > 0 || unavailable > 0 { + analysis.Status = StatusPartial + } else { + analysis.Status = StatusMeasured + } + case unavailable > 0: + analysis.Status = StatusUnavailable + analysis.Reason = "measurement_unavailable" + case notApplicable > 0: + analysis.Status = StatusNotApplicable + analysis.Reason = "root_not_present" + default: + analysis.Status = StatusNotApplicable + analysis.Reason = "no_selected_roots" + } + return analysis +} + +func appendBounded(values, additions []string, limit int) []string { + for _, addition := range additions { + if len(values) >= limit { + break + } + if addition != "" { + values = append(values, addition) + } + } + return values +} + +func sameMount(left, right string, windows bool) bool { + if windows { + return strings.EqualFold(left, right) + } + return left == right +} + +func samePath(left, right string, windows bool) bool { + if windows { + return strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) + } + return filepath.Clean(left) == filepath.Clean(right) +} + +func isFilesystemRoot(path string) bool { + clean := filepath.Clean(path) + volume := filepath.VolumeName(clean) + return clean == volume+string(filepath.Separator) +} + +func pathWithin(root, path string) bool { + root = filepath.Clean(root) + path = filepath.Clean(path) + relative, err := filepath.Rel(root, path) + return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/apps/backend/internal/system/storage/tempstore/provider_test.go b/apps/backend/internal/system/storage/tempstore/provider_test.go new file mode 100644 index 0000000000..d558329605 --- /dev/null +++ b/apps/backend/internal/system/storage/tempstore/provider_test.go @@ -0,0 +1,289 @@ +package tempstore + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kandev/kandev/internal/system/storage/filescan" +) + +type testMountReader struct { + identity func(string) (string, error) +} + +func (r testMountReader) Identity(path string) (string, error) { + if r.identity == nil { + return "mount", nil + } + return r.identity(path) +} + +type dynamicMountReader struct { + nested string + mounted bool +} + +func (r *dynamicMountReader) Identity(path string) (string, error) { + if r.mounted && pathWithin(r.nested, path) { + return "nested", nil + } + return "root", nil +} + +type testScanner struct { + measure func(context.Context, []filescan.Root, filescan.MeasureOptions, func(filescan.Progress)) []filescan.Result +} + +func (s testScanner) MeasureWithOptions( + ctx context.Context, + roots []filescan.Root, + options filescan.MeasureOptions, + notify func(filescan.Progress), +) []filescan.Result { + return s.measure(ctx, roots, options, notify) +} + +func TestAnalyzeTemporaryRootsScopesAndCollapsesAliases(t *testing.T) { + effective := t.TempDir() + if err := os.WriteFile(filepath.Join(effective, "owned"), []byte("owned"), 0o600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(t.TempDir(), "alias") + if err := os.Symlink(effective, alias); err != nil { + t.Fatal(err) + } + + provider := NewProvider(Config{ + GOOS: "linux", + Scanner: filescan.NewLimiter(1), + Mounts: testMountReader{}, + RootResolver: func(context.Context) ([]RootCandidate, error) { + return []RootCandidate{ + {RequestedPath: alias}, + {RequestedPath: effective}, + }, nil + }, + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.Status != StatusMeasured || analysis.SizeBytes == nil || *analysis.SizeBytes != int64(len("owned")) { + t.Fatalf("analysis = %#v, want one measured root", analysis) + } + if analysis.IncludedInTotal { + t.Fatal("temporary footprint contributes to classified total") + } + if len(analysis.Roots) != 1 { + t.Fatalf("roots = %#v, want one collapsed effective root", analysis.Roots) + } + if len(analysis.Roots[0].Aliases) != 1 || analysis.Roots[0].Path != effective { + t.Fatalf("collapsed root = %#v", analysis.Roots[0]) + } +} + +func TestAnalyzeTemporaryRootsCollapsesNestedRootsRegardlessOfOrder(t *testing.T) { + parent := t.TempDir() + child := filepath.Join(parent, "nested") + if err := os.Mkdir(child, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, "parent"), []byte("parent"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(child, "child"), []byte("child"), 0o600); err != nil { + t.Fatal(err) + } + + provider := NewProvider(Config{ + GOOS: "linux", + Mounts: testMountReader{}, + RootResolver: func(context.Context) ([]RootCandidate, error) { + return []RootCandidate{ + {RequestedPath: child}, + {RequestedPath: parent}, + }, nil + }, + Scanner: filescan.NewLimiter(1), + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.SizeBytes == nil || *analysis.SizeBytes != int64(len("parentchild")) { + t.Fatalf("size = %v, want one parent traversal", analysis.SizeBytes) + } + if len(analysis.Roots) != 1 || len(analysis.Roots[0].Aliases) != 1 { + t.Fatalf("roots = %#v, want nested child collapsed into parent", analysis.Roots) + } +} + +func TestAnalyzeTemporaryRefreshesNestedMountBoundaryBetweenScans(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "nested") + if err := os.Mkdir(nested, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "parent"), []byte("parent"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "child"), []byte("child"), 0o600); err != nil { + t.Fatal(err) + } + + mounts := &dynamicMountReader{nested: nested} + provider := NewProvider(Config{ + GOOS: "linux", + Mounts: mounts, + Scanner: filescan.NewLimiter(1), + RootResolver: func(context.Context) ([]RootCandidate, error) { + return []RootCandidate{{RequestedPath: root}}, nil + }, + }) + + first, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("first Analyze: %v", err) + } + if first.Status != StatusMeasured || first.SizeBytes == nil || *first.SizeBytes != int64(len("parentchild")) { + t.Fatalf("first analysis = %#v, want complete parent and child footprint", first) + } + + mounts.mounted = true + second, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("second Analyze: %v", err) + } + if second.Status != StatusPartial || second.SizeBytes == nil || *second.SizeBytes != int64(len("parent")) { + t.Fatalf("second analysis = %#v, want parent-only partial footprint", second) + } + if len(second.Roots) != 1 || second.Roots[0].SkippedCount != 1 { + t.Fatalf("second roots = %#v, want one skipped nested mount", second.Roots) + } +} + +func TestMountGuardFailsClosedWhenRootMountIsReplaced(t *testing.T) { + root := t.TempDir() + guard := mountGuard{ + path: root, identity: "original", mounts: testMountReader{ + identity: func(string) (string, error) { return "replacement", nil }, + }, + } + if _, err := guard.shouldSkip(root, nil); err == nil { + t.Fatal("root mount replacement was accepted") + } +} + +func TestAnalyzeTemporaryReportsPartialAndUnavailableWithoutZeroingBytes(t *testing.T) { + available := t.TempDir() + missing := filepath.Join(t.TempDir(), "missing") + provider := NewProvider(Config{ + GOOS: "linux", + Mounts: testMountReader{}, + RootResolver: func(context.Context) ([]RootCandidate, error) { + return []RootCandidate{ + {RequestedPath: available}, + {RequestedPath: missing}, + }, nil + }, + Scanner: testScanner{measure: func( + _ context.Context, + roots []filescan.Root, + _ filescan.MeasureOptions, + _ func(filescan.Progress), + ) []filescan.Result { + if len(roots) != 1 { + t.Fatalf("scanner roots = %d, want one available root", len(roots)) + } + return []filescan.Result{ + {Bytes: 17, Partial: true, SkippedCount: 2, Warnings: []string{"unreadable entry"}}, + } + }}, + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.Status != StatusPartial || analysis.SizeBytes == nil || *analysis.SizeBytes != 17 { + t.Fatalf("analysis = %#v, want partial 17-byte footprint", analysis) + } + if len(analysis.Warnings) == 0 || analysis.Roots[0].SkippedCount != 2 { + t.Fatalf("analysis warnings/partial root = %#v", analysis) + } + if analysis.Roots[1].Status != StatusUnavailable || analysis.Roots[1].SizeBytes != nil { + t.Fatalf("unavailable root = %#v, want no size", analysis.Roots[1]) + } +} + +func TestAnalyzeTemporaryMarksMissingOptionalRootNotApplicable(t *testing.T) { + effective := t.TempDir() + provider := NewProvider(Config{ + GOOS: "linux", + Mounts: testMountReader{}, + RootResolver: func(context.Context) ([]RootCandidate, error) { + return []RootCandidate{ + {RequestedPath: effective}, + {RequestedPath: filepath.Join(t.TempDir(), "tmp"), Optional: true}, + }, nil + }, + Scanner: filescan.NewLimiter(1), + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if len(analysis.Roots) != 2 || analysis.Roots[1].Status != StatusNotApplicable { + t.Fatalf("roots = %#v, want optional missing root not applicable", analysis.Roots) + } +} + +func TestAnalyzeTemporaryHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + provider := NewProvider(Config{RootResolver: func(context.Context) ([]RootCandidate, error) { + return []RootCandidate{{RequestedPath: t.TempDir()}}, nil + }}) + if _, err := provider.Analyze(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("Analyze error = %v, want context.Canceled", err) + } +} + +func TestAnalyzeTemporaryDeadlineReturnsPartialSample(t *testing.T) { + root := t.TempDir() + provider := NewProvider(Config{ + GOOS: "linux", + Mounts: testMountReader{}, + Deadline: time.Millisecond, + RootResolver: func(context.Context) ([]RootCandidate, error) { + return []RootCandidate{{RequestedPath: root}}, nil + }, + Scanner: testScanner{measure: func( + ctx context.Context, + _ []filescan.Root, + _ filescan.MeasureOptions, + _ func(filescan.Progress), + ) []filescan.Result { + <-ctx.Done() + return []filescan.Result{{Bytes: 17, Err: ctx.Err()}} + }}, + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.Status != StatusPartial || analysis.SizeBytes == nil || *analysis.SizeBytes != 17 { + t.Fatalf("analysis = %#v, want partial 17-byte sample", analysis) + } + if analysis.Reason != "deadline" || analysis.Roots[0].Reason != "deadline" { + t.Fatalf("analysis = %#v, want deadline reason", analysis) + } +} diff --git a/apps/backend/internal/system/storage/types.go b/apps/backend/internal/system/storage/types.go index 01b359b72a..711f5246d0 100644 --- a/apps/backend/internal/system/storage/types.go +++ b/apps/backend/internal/system/storage/types.go @@ -58,6 +58,7 @@ type StorageMaintenanceSettings struct { QuarantineRetentionHours int `json:"quarantine_retention_hours"` Workspaces WorkspaceSettings `json:"workspaces"` KandevContainers ResourceSettings `json:"kandev_containers"` + TemporaryArtifacts ResourceSettings `json:"temporary_artifacts"` GoCache GoCacheSettings `json:"go_cache"` Docker DockerSettings `json:"docker"` } @@ -70,6 +71,7 @@ func DefaultSettings() StorageMaintenanceSettings { QuarantineRetentionHours: 168, Workspaces: WorkspaceSettings{Enabled: true}, KandevContainers: ResourceSettings{Enabled: true}, + TemporaryArtifacts: ResourceSettings{Enabled: false}, GoCache: GoCacheSettings{ MaxBytes: 16106127360, }, diff --git a/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx b/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx index 139ce4643a..9d17ecc6b9 100644 --- a/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx +++ b/apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx @@ -101,7 +101,7 @@ const overview = { refresh_due_at: "2099-07-23T12:15:00Z", stale: false, error: null, - progress: { completed_sources: 7, total_sources: 7, sources: {} }, + progress: { completed_sources: 8, total_sources: 8, sources: {} }, partial_summary: null, }, analyzed_at: "2026-07-23T12:00:00Z", diff --git a/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx b/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx index c8fb2b1760..0d2b72d472 100644 --- a/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx +++ b/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx @@ -21,7 +21,7 @@ import { } from "./storage-gating"; import { StorageDiskCapacityCard } from "./storage-disk-capacity-card"; import { StorageOverviewCard } from "./storage-overview-card"; -import { StoragePolicyCard } from "./storage-policy-card"; +import { StoragePolicyCard } from "./storage-policy-card-root"; import { StorageQuarantineCard } from "./storage-quarantine-card"; import { StorageRunHistory } from "./storage-run-history"; import { SettingsTarget } from "../../settings-target"; @@ -325,6 +325,7 @@ function StoragePrimarySections({ onChange={setDraft} onAdopt={controller.adopt} onCleanDependencies={() => void controller.runNow(["workspace_dependencies"])} + onCleanTemporaryArtifacts={onRunTemporaryArtifacts} /> )} diff --git a/apps/web/components/settings/system/storage/storage-overview-card.test.tsx b/apps/web/components/settings/system/storage/storage-overview-card.test.tsx index 357884483f..086ab1a982 100644 --- a/apps/web/components/settings/system/storage/storage-overview-card.test.tsx +++ b/apps/web/components/settings/system/storage/storage-overview-card.test.tsx @@ -56,7 +56,7 @@ const degradedOverview = { refresh_due_at: "2099-07-23T12:15:00Z", stale: false, error: null, - progress: { completed_sources: 7, total_sources: 7, sources: {} }, + progress: { completed_sources: 8, total_sources: 8, sources: {} }, partial_summary: null, } satisfies StorageAnalysisState, analyzed_at: "2026-07-23T12:00:00Z", @@ -67,6 +67,7 @@ const DATABASE_PATH = "/data/kandev.db"; const DATABASE_BACKUP_PATH = "/data/backups"; const DATABASE_TRIGGER_TEST_ID = "storage-resource-database-trigger"; const DATABASE_BACKUPS_TRIGGER_TEST_ID = "storage-resource-database-backups-trigger"; +const STORAGE_ANALYSIS_TOTAL_TEST_ID = "storage-analysis-total"; afterEach(cleanup); @@ -161,13 +162,68 @@ describe("StorageOverviewCard", () => { expect( screen.getByTestId("storage-resource-docker-image-layers-trigger").textContent, ).toContain("14 GB"); - expect(screen.getByTestId("storage-analysis-total").textContent).toContain( + expect(screen.getByTestId(STORAGE_ANALYSIS_TOTAL_TEST_ID).textContent).toContain( "Total counted: 47 GB", ); expect(screen.getByTestId("storage-analysis-total-partial")).toBeTruthy(); }); }); +describe("StorageOverviewCard system temporary resources", () => { + it("shows system temporary roots as informational and excludes them from the total", () => { + const overview = { + ...degradedOverview, + summary: { + ...degradedOverview.summary, + workspaces: { total_bytes: 5 * 1024 ** 3, active_bytes: 0, candidate_bytes: 0 }, + go_cache: { ...degradedOverview.summary.go_cache, size_bytes: 2 * 1024 ** 3 }, + quarantine: { count: 0, size_bytes: 3 * 1024 ** 3 }, + temporary_artifacts: { + available: true, + total_count: 1, + total_bytes: 4 * 1024 ** 3, + stale_count: 0, + }, + docker: { + ...degradedOverview.summary.docker, + available: true, + }, + system_temporary: { + status: "partial", + size_bytes: 100 * 1024 ** 3, + included_in_total: false, + roots: [ + { + requested_path: "/tmp", + path: "/tmp", + status: "partial", + size_bytes: 100 * 1024 ** 3, + skipped_count: 2, + }, + ], + warnings: ["Some entries could not be measured"], + }, + }, + } satisfies StorageOverviewResponse; + + render(); + + const trigger = screen.getByTestId("storage-resource-system-temporary-trigger"); + expect(trigger.textContent).toContain("100 GB"); + fireEvent.click(trigger); + expect(screen.getByTestId("storage-resource-system-temporary").textContent).toContain("/tmp"); + expect(screen.getByTestId("storage-resource-system-temporary").textContent).toContain( + "Partial", + ); + expect(screen.getByTestId("storage-resource-system-temporary").textContent).toContain( + "2 entries skipped", + ); + expect(screen.getByTestId(STORAGE_ANALYSIS_TOTAL_TEST_ID).textContent).toContain( + "Total counted: 14 GB", + ); + }); +}); + describe("StorageOverviewCard database footprint", () => { it("renders database rows with paths, scope details, and total contribution", () => { const overview = { @@ -190,8 +246,8 @@ describe("StorageOverviewCard database footprint", () => { analysis: { ...degradedOverview.analysis, progress: { - completed_sources: 7, - total_sources: 7, + completed_sources: 8, + total_sources: 8, sources: { database: { state: "ready", completed_items: 1, bytes_scanned: 5 * 1024 ** 3 }, database_backups: { state: "ready", completed_items: 1, bytes_scanned: 3 * 1024 ** 3 }, @@ -210,7 +266,7 @@ describe("StorageOverviewCard database footprint", () => { expect(screen.getByTestId("storage-resource-database-backups").textContent).toContain( DATABASE_BACKUP_PATH, ); - expect(screen.getByTestId("storage-analysis-total").textContent).toContain( + expect(screen.getByTestId(STORAGE_ANALYSIS_TOTAL_TEST_ID).textContent).toContain( "Total counted: 8 GB", ); expect(screen.getByTestId("storage-analysis-scope").textContent).toContain( @@ -273,7 +329,7 @@ describe("StorageOverviewCard database progress", () => { state: "scanning", progress: { completed_sources: 2, - total_sources: 7, + total_sources: 8, sources: { database: { state: "pending", completed_items: 0, bytes_scanned: 0 }, database_backups: { @@ -402,7 +458,7 @@ describe("StorageOverviewCard refresh and policy state", () => { stale: false, progress: { completed_sources: 1, - total_sources: 7, + total_sources: 8, sources: { workspaces: { state: "ready", completed_items: 3, total_items: 3, bytes_scanned: 42 }, go_cache: { state: "scanning", completed_items: 1, total_items: 4, bytes_scanned: 10 }, @@ -425,7 +481,7 @@ describe("StorageOverviewCard refresh and policy state", () => { render(); - expect(screen.getByTestId("storage-analysis-total").textContent).toContain( + expect(screen.getByTestId(STORAGE_ANALYSIS_TOTAL_TEST_ID).textContent).toContain( "Counted so far: 2 GB", ); expect(screen.getByTestId("storage-analysis-total-partial")).toBeTruthy(); diff --git a/apps/web/components/settings/system/storage/storage-overview-card.tsx b/apps/web/components/settings/system/storage/storage-overview-card.tsx index d0e069d949..45a2b8cb2c 100644 --- a/apps/web/components/settings/system/storage/storage-overview-card.tsx +++ b/apps/web/components/settings/system/storage/storage-overview-card.tsx @@ -107,7 +107,16 @@ function ResourceRow({ -

{resource.detail}

+

{resource.detail}

+ {resource.detailLines && resource.detailLines.length > 0 && ( +
+ {resource.detailLines.map((line, index) => ( +

+ {line} +

+ ))} +
+ )} {resource.warning &&

{resource.warning}

} {resource.id === "go-cache" && ( ) => stri export const TEMPORARY_ARTIFACTS_RESOURCE_ID = "temporary-artifacts"; export const DATABASE_RESOURCE_ID = "database"; export const DATABASE_BACKUPS_RESOURCE_ID = "database-backups"; +export const SYSTEM_TEMPORARY_RESOURCE_ID = "system-temporary"; export interface StorageResource { id: string; label: string; value: string; detail: string; + detailLines?: string[]; warning?: string; source?: string; } @@ -327,6 +330,116 @@ function temporaryArtifactsResourceOrPending( return { ...temporaryArtifactsResource(t, temporaryArtifacts), source: "temporary_artifacts" }; } +function temporaryRootStatus( + t: Translate, + status: StorageSystemTemporarySummary["status"], +): string { + switch (status) { + case "measured": + return t("system:storageSystemTemporaryComplete"); + case "partial": + return t("system:storageSystemTemporaryPartial"); + case "unavailable": + return t("system:storageSystemTemporaryUnavailable"); + case "not_applicable": + return t("system:storageSystemTemporaryNotApplicable"); + } +} + +function temporaryRootLine( + t: Translate, + root: NonNullable[number], +): string { + const path = + root.requested_path === root.path + ? root.path + : t("system:storageSystemTemporaryResolvedRoot", { + requested: root.requested_path, + resolved: root.path, + }); + let value: string; + if (root.status === "unavailable" || root.status === "not_applicable") { + value = temporaryRootStatus(t, root.status); + } else if (root.size_bytes === undefined) { + value = t(STORAGE_UNAVAILABLE_VALUE_KEY); + } else { + value = formatGigabytes(root.size_bytes); + } + const line = t("system:storageSystemTemporaryRoot", { + path, + size: value, + status: temporaryRootStatus(t, root.status), + }); + if (!root.skipped_count) return line; + return [line, t("system:storageSystemTemporarySkippedCount", { count: root.skipped_count })].join( + " · ", + ); +} + +function temporarySummaryValue(t: Translate, summary: StorageSystemTemporarySummary): string { + if (summary.status === "unavailable") { + return t(STORAGE_UNAVAILABLE_VALUE_KEY); + } + if (summary.status === "not_applicable") { + return t("system:storageNotApplicableValue"); + } + if (summary.size_bytes === undefined) { + return t(STORAGE_UNAVAILABLE_VALUE_KEY); + } + return formatGigabytes(summary.size_bytes); +} + +function temporarySummaryDetail( + t: Translate, + status: StorageSystemTemporarySummary["status"], +): string { + switch (status) { + case "partial": + return t("system:storageSystemTemporaryPartialDetail"); + case "unavailable": + return t("system:storageSystemTemporaryUnavailableDetail"); + case "not_applicable": + return t("system:storageSystemTemporaryNotApplicableDetail"); + case "measured": + return t("system:storageSystemTemporaryCompleteDetail"); + } +} + +function systemTemporaryResource( + t: Translate, + summary: StorageSystemTemporarySummary, +): StorageResource { + const warnings = (summary.warnings ?? []).filter(Boolean).join(" · "); + const value = temporarySummaryValue(t, summary); + const statusDetail = temporarySummaryDetail(t, summary.status); + return { + id: SYSTEM_TEMPORARY_RESOURCE_ID, + label: t("system:storageSystemTemporaryFolders"), + value, + detail: [t("system:storageSystemTemporaryInformational"), statusDetail].join(" "), + detailLines: summary.roots.map((root) => temporaryRootLine(t, root)), + warning: warnings || undefined, + source: "system_temporary", + }; +} + +function systemTemporaryResourceOrPending( + t: Translate, + summary: StorageSummaryPartial["system_temporary"], + progress?: StorageSourceProgress, +): StorageResource { + if (!summary) { + return pendingStorageResource( + t, + SYSTEM_TEMPORARY_RESOURCE_ID, + t("system:storageSystemTemporaryFolders"), + progress, + "system_temporary", + ); + } + return systemTemporaryResource(t, summary); +} + function dockerResources( t: Translate, docker: DockerSummary | null | undefined, @@ -400,6 +513,7 @@ export function storageResources( source: "database_backups", }), quarantineResourceOrPending(t, summary.quarantine, progress.quarantine), + systemTemporaryResourceOrPending(t, summary.system_temporary, progress.system_temporary), ...goCacheResources( t, summary.go_cache, diff --git a/apps/web/components/settings/system/storage/storage-policy-card-root.tsx b/apps/web/components/settings/system/storage/storage-policy-card-root.tsx new file mode 100644 index 0000000000..c54dc86788 --- /dev/null +++ b/apps/web/components/settings/system/storage/storage-policy-card-root.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography"; +import { settingsWithDockerAcknowledgement } from "@/hooks/domains/system/use-storage-maintenance"; +import { DedicatedDockerDialog, ExternalGoCacheDialog } from "./storage-confirmation-dialogs"; +import { + DockerSection, + GoCacheSection, + QuarantineSection, + ScheduleSection, + TemporaryArtifactsSection, + WorkspaceSection, +} from "./storage-policy-card"; +import type { PolicySectionProps, StoragePolicyCardProps } from "./storage-policy-card"; + +export function StoragePolicyCard({ + settings, + savedSettings, + capabilities, + pending, + pendingReason, + onChange, + onAdopt, + onCleanDependencies, + onCleanTemporaryArtifacts, +}: StoragePolicyCardProps) { + const { t } = useTranslation(); + const [dockerDialogOpen, setDockerDialogOpen] = useState(false); + const [adoptionDialogOpen, setAdoptionDialogOpen] = useState(false); + const savedAdoptionPath = savedSettings.go_cache.adopted_path; + const [adoptionPath, setAdoptionPath] = useState(savedAdoptionPath); + const previousSavedAdoptionPath = useRef(savedAdoptionPath); + + useEffect(() => { + const previousPath = previousSavedAdoptionPath.current; + setAdoptionPath((currentPath) => + currentPath === previousPath ? savedAdoptionPath : currentPath, + ); + previousSavedAdoptionPath.current = savedAdoptionPath; + }, [savedAdoptionPath]); + + const sectionProps: PolicySectionProps = { + settings, + savedSettings, + capabilities, + pending, + pendingReason, + onChange, + }; + + return ( +
+
+

{t("system:storagePolicyTitle")}

+

+ {t("system:storagePolicyDescription")} +

+
+
+ + + setAdoptionDialogOpen(true)} + /> + setDockerDialogOpen(true)} /> + + +
+ { + const next = settingsWithDockerAcknowledgement(settings, true); + onChange(next); + setDockerDialogOpen(false); + }} + /> + { + void onAdopt(adoptionPath.trim()); + setAdoptionDialogOpen(false); + }} + /> +
+ ); +} diff --git a/apps/web/components/settings/system/storage/storage-policy-card.test.tsx b/apps/web/components/settings/system/storage/storage-policy-card.test.tsx index 418333630c..bed6227b89 100644 --- a/apps/web/components/settings/system/storage/storage-policy-card.test.tsx +++ b/apps/web/components/settings/system/storage/storage-policy-card.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from "@testing-library/rea import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { StorageMaintenanceSettings } from "@/lib/types/system"; -import { StoragePolicyCard } from "./storage-policy-card"; +import { StoragePolicyCard } from "./storage-policy-card-root"; const settings: StorageMaintenanceSettings = { enabled: false, @@ -164,6 +164,43 @@ describe("External Go cache path", () => { }); }); +describe("Temporary artifact policy", () => { + it("stages the opt-in scheduled cleanup setting", () => { + const onChange = vi.fn(); + renderCard(false, onChange); + + fireEvent.click(screen.getByTestId("storage-temporary-artifacts-enabled")); + + expect(onChange).toHaveBeenCalledWith({ + ...settings, + temporary_artifacts: { enabled: true }, + }); + expect( + screen.getAllByText("Registered inactive artifacts only. Minimum age: 24 hours.").length, + ).toBeGreaterThan(0); + }); + + it("keeps the manual cleanup action reachable when scheduled cleanup is off", () => { + const clean = vi.fn(); + render( + + + , + ); + + fireEvent.click(screen.getByTestId("storage-policy-temporary-artifacts-clean")); + expect(clean).toHaveBeenCalledTimes(1); + }); +}); + describe("StoragePolicyCard", () => { it("shows the dependency allowlist and keeps cleanup opt-in", () => { const onChange = renderCard(); @@ -306,7 +343,7 @@ describe("StoragePolicyCard dependency action", () => { }); }); -describe("StoragePolicyCard interactions", () => { +describe("StoragePolicyCard pending interactions", () => { it("disables policy controls while an action is pending", () => { renderCard(true); @@ -339,7 +376,9 @@ describe("StoragePolicyCard interactions", () => { true, ); }); +}); +describe("StoragePolicyCard interactions", () => { it("disables child fields when their cleanup option is off", () => { renderCard(false, vi.fn(), { ...settings, @@ -376,7 +415,14 @@ describe("StoragePolicyCard interactions", () => { it("renders each maintenance group as a separate card", () => { renderCard(); - for (const section of ["schedule", "workspaces", "go-cache", "docker", "quarantine"]) { + for (const section of [ + "schedule", + "workspaces", + "go-cache", + "docker", + "quarantine", + "temporary-artifacts", + ]) { expect( screen.getByTestId(`storage-policy-section-${section}`).getAttribute("data-slot"), ).toBe("card"); @@ -404,10 +450,11 @@ describe("StoragePolicyCard interactions", () => { "Go build cache", "Docker cleanup", "Quarantine safety", + "Temporary artifacts", ]) { expect(screen.getByText(heading)).toBeTruthy(); } - expect(screen.getAllByLabelText(/^More information about /)).toHaveLength(18); + expect(screen.getAllByLabelText(/^More information about /)).toHaveLength(19); }); }); diff --git a/apps/web/components/settings/system/storage/storage-policy-card.tsx b/apps/web/components/settings/system/storage/storage-policy-card.tsx index a93d019af8..531bf115c2 100644 --- a/apps/web/components/settings/system/storage/storage-policy-card.tsx +++ b/apps/web/components/settings/system/storage/storage-policy-card.tsx @@ -1,18 +1,17 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import { Switch } from "@kandev/ui/switch"; +import { IconTrash } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; -import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography"; import { settingsWithDockerAcknowledgement } from "@/hooks/domains/system/use-storage-maintenance"; import type { StorageCapabilities, StorageMaintenanceSettings } from "@/lib/types/system"; -import { DedicatedDockerDialog, ExternalGoCacheDialog } from "./storage-confirmation-dialogs"; import { StorageAdoptionField } from "./storage-adoption-field"; import { NumberField, PolicySection, SettingRow } from "./storage-policy-fields"; import { bytesToGigabytes, gigabytesToBytes } from "./storage-units"; import { StorageWorkspaceDependencySettings } from "./storage-workspace-dependency-settings"; +import { StorageActionButton } from "./storage-action-button"; -type Props = { +export type StoragePolicyCardProps = { settings: StorageMaintenanceSettings; savedSettings: StorageMaintenanceSettings; capabilities: StorageCapabilities; @@ -25,10 +24,11 @@ type Props = { onChange: (settings: StorageMaintenanceSettings) => void; onAdopt: (path: string) => Promise; onCleanDependencies?: () => void; + onCleanTemporaryArtifacts?: () => void; }; -type PolicySectionProps = Pick< - Props, +export type PolicySectionProps = Pick< + StoragePolicyCardProps, "settings" | "savedSettings" | "capabilities" | "onChange" | "pending" | "pendingReason" >; @@ -40,7 +40,12 @@ function settingIsDirty( return !Object.is(select(settings), select(savedSettings)); } -function ScheduleSection({ settings, savedSettings, pending, onChange }: PolicySectionProps) { +export function ScheduleSection({ + settings, + savedSettings, + pending, + onChange, +}: PolicySectionProps) { const { t } = useTranslation(); const enabledDirty = settingIsDirty(settings, savedSettings, (value) => value.enabled); const intervalDirty = settingIsDirty( @@ -99,14 +104,14 @@ function ScheduleSection({ settings, savedSettings, pending, onChange }: PolicyS ); } -function WorkspaceSection({ +export function WorkspaceSection({ settings, savedSettings, pending, pendingReason, onChange, onCleanDependencies, -}: PolicySectionProps & Pick) { +}: PolicySectionProps & Pick) { const { t } = useTranslation(); const workspacesDirty = settingIsDirty( settings, @@ -186,7 +191,7 @@ function WorkspaceSection({ ); } -function GoCacheSection({ +export function GoCacheSection({ settings, savedSettings, capabilities, @@ -385,7 +390,7 @@ function DockerImageSettings({ ); } -function DockerSection({ +export function DockerSection({ settings, savedSettings, capabilities, @@ -455,7 +460,12 @@ function DockerSection({ ); } -function QuarantineSection({ settings, savedSettings, pending, onChange }: PolicySectionProps) { +export function QuarantineSection({ + settings, + savedSettings, + pending, + onChange, +}: PolicySectionProps) { const { t } = useTranslation(); const retentionDirty = settingIsDirty( settings, @@ -488,104 +498,69 @@ function QuarantineSection({ settings, savedSettings, pending, onChange }: Polic ); } -export function StoragePolicyCard({ +export function TemporaryArtifactsSection({ settings, savedSettings, - capabilities, pending, pendingReason, onChange, - onAdopt, - onCleanDependencies, -}: Props) { + onCleanTemporaryArtifacts, +}: PolicySectionProps & Pick) { const { t } = useTranslation(); - const [dockerDialogOpen, setDockerDialogOpen] = useState(false); - const [adoptionDialogOpen, setAdoptionDialogOpen] = useState(false); - const savedAdoptionPath = savedSettings.go_cache.adopted_path; - const [adoptionPath, setAdoptionPath] = useState(savedAdoptionPath); - const previousSavedAdoptionPath = useRef(savedAdoptionPath); - - useEffect(() => { - const previousPath = previousSavedAdoptionPath.current; - setAdoptionPath((currentPath) => - currentPath === previousPath ? savedAdoptionPath : currentPath, - ); - previousSavedAdoptionPath.current = savedAdoptionPath; - }, [savedAdoptionPath]); - + const enabled = settings.temporary_artifacts?.enabled ?? false; + const savedEnabled = savedSettings.temporary_artifacts?.enabled ?? false; + const cleanupDisabledReason = pending + ? (pendingReason ?? t("system:storageActionPending")) + : pendingReason; return ( -
-
-

{t("system:storagePolicyTitle")}

-

- {t("system:storagePolicyDescription")} -

-
-
- - - setAdoptionDialogOpen(true)} - /> - setDockerDialogOpen(true)} - /> - -
- { - const next = settingsWithDockerAcknowledgement(settings, true); - onChange(next); - setDockerDialogOpen(false); - }} + + { + if (event.target !== event.currentTarget) return; + onChange({ ...settings, temporary_artifacts: { enabled: !enabled } }); + }} + > + + onChange({ ...settings, temporary_artifacts: { enabled: nextEnabled } }) + } + aria-label={t("system:storageCleanStaleTemporaryArtifacts")} + data-testid="storage-temporary-artifacts-enabled" + data-settings-dirty={enabled !== savedEnabled} + /> + + } /> - { - void onAdopt(adoptionPath.trim()); - setAdoptionDialogOpen(false); - }} - /> -
+
+

{t("system:storageTemporaryArtifactsPolicyAge")}

+

{t("system:storageTemporaryArtifactsPolicyQuarantine")}

+

{t("system:storageTemporaryArtifactsPolicyShared")}

+
+ {onCleanTemporaryArtifacts && ( + + {t("system:storageCleanStaleTemporaryArtifactsAction")} + + )} + ); } diff --git a/apps/web/components/settings/system/storage/storage-run-history.test.tsx b/apps/web/components/settings/system/storage/storage-run-history.test.tsx index 0c77830aaa..24544e0d7f 100644 --- a/apps/web/components/settings/system/storage/storage-run-history.test.tsx +++ b/apps/web/components/settings/system/storage/storage-run-history.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { StorageRunHistory } from "./storage-run-history"; @@ -21,4 +21,34 @@ describe("StorageRunHistory", () => { ); expect(screen.queryByTestId("storage-run-history-spinner")).toBeNull(); }); + + it("describes temporary artifact bytes as moved to quarantine", () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("storage-run-run-1").querySelector("button")!); + expect(screen.getByTestId("storage-temporary-artifacts-result").textContent).toContain( + "2 GB moved to quarantine.", + ); + expect(screen.getByTestId("storage-temporary-artifacts-result").textContent).toContain( + "Space is freed after permanent deletion.", + ); + }); }); diff --git a/apps/web/components/settings/system/storage/storage-run-history.tsx b/apps/web/components/settings/system/storage/storage-run-history.tsx index 3fea699fa5..60e30f532d 100644 --- a/apps/web/components/settings/system/storage/storage-run-history.tsx +++ b/apps/web/components/settings/system/storage/storage-run-history.tsx @@ -5,12 +5,57 @@ import { Spinner } from "@kandev/ui/spinner"; import { useTranslation } from "react-i18next"; import { formatDateTime } from "@/lib/i18n/formats"; import type { StorageMaintenanceRun } from "@/lib/types/system"; +import { formatGigabytes } from "./storage-units"; function dateLabel(value: string): string { const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? value : formatDateTime(parsed); } +function recordValue(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function temporaryArtifactResult(result: Record): { + quarantinedBytes: number; + failed: number; +} | null { + const outer = recordValue(result.result) ?? result; + const provider = recordValue(outer.temporary_artifacts); + const cleanup = recordValue(provider?.result) ?? provider; + if (!cleanup) return null; + const rawBytes = cleanup.quarantined_bytes ?? cleanup.reclaimed_bytes; + if (typeof rawBytes !== "number" || !Number.isFinite(rawBytes) || rawBytes < 0) return null; + const rawFailed = cleanup.failed; + return { + quarantinedBytes: rawBytes, + failed: typeof rawFailed === "number" && Number.isFinite(rawFailed) ? rawFailed : 0, + }; +} + +function TemporaryArtifactRunResult({ result }: { result: Record }) { + const { t } = useTranslation(); + const cleanup = temporaryArtifactResult(result); + if (!cleanup) return null; + return ( +
+

+ {t("system:storageTemporaryArtifactsQuarantinedResult", { + size: formatGigabytes(cleanup.quarantinedBytes), + })} +

+

+ {t("system:storageTemporaryArtifactsFreedAfterDelete")} +

+ {cleanup.failed > 0 && ( +

{t("system:storageTemporaryArtifactsMoveFailed")}

+ )} +
+ ); +} + export function StorageRunHistory({ runs, loading = false, @@ -75,6 +120,7 @@ function StorageRunHistoryContent({ {run.message &&

{run.message}

} +
               {JSON.stringify(run.result, null, 2)}
             
diff --git a/apps/web/components/settings/system/storage/storage-totals.test.ts b/apps/web/components/settings/system/storage/storage-totals.test.ts index 0b683d340d..fb7c8e754a 100644 --- a/apps/web/components/settings/system/storage/storage-totals.test.ts +++ b/apps/web/components/settings/system/storage/storage-totals.test.ts @@ -103,7 +103,9 @@ describe("storageAnalysisTotal", () => { expect(storageAnalysisTotal(summary)).toEqual({ bytes: 12, partial: true }); }); +}); +describe("storageAnalysisTotal database cases", () => { it("adds measured database and backup bytes exactly once", () => { expect( storageAnalysisTotal({ @@ -133,6 +135,20 @@ describe("storageAnalysisTotal", () => { ).toEqual({ bytes: 49, partial: false }); }); + it("excludes the informational system temporary footprint from Total counted", () => { + expect( + storageAnalysisTotal({ + ...completeSummary, + system_temporary: { + status: "measured", + size_bytes: 500, + included_in_total: false, + roots: [], + }, + }), + ).toEqual({ bytes: 52, partial: false }); + }); + it("adds only the counted portion of a partial database footprint", () => { expect( storageAnalysisTotal({ diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts index 8d2775f756..a35632bc27 100644 --- a/apps/web/e2e/fixtures/backend.ts +++ b/apps/web/e2e/fixtures/backend.ts @@ -324,11 +324,13 @@ export const backendFixture = base.extend({ await runOwnedBackendFixture(tmpDir, async (registerProcess) => { const homeDir = path.join(tmpDir, ".kandev"); + const systemTemporaryRoot = path.join(tmpDir, "system-temporary"); const dbPath = path.join(tmpDir, "kandev.db"); const worktreeBase = path.join(tmpDir, "worktrees"); const repoCloneBase = path.join(tmpDir, "managed-repos"); fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(systemTemporaryRoot, { recursive: true }); fs.mkdirSync(worktreeBase, { recursive: true }); fs.mkdirSync(repoCloneBase, { recursive: true }); @@ -400,6 +402,7 @@ export const backendFixture = base.extend({ KANDEV_SERVER_PORT: String(backendPort), KANDEV_WEB_DIST_DIR: WEB_DIST_DIR, KANDEV_DATABASE_PATH: dbPath, + KANDEV_E2E_SYSTEM_TEMP_ROOT: systemTemporaryRoot, // Profile selector. KANDEV_E2E_MOCK=true tells the backend to // apply the `e2e:` profile from profiles.yaml at startup — // which sets the mock agent and third-party provider flags, diff --git a/apps/web/e2e/helpers/storage-maintenance.ts b/apps/web/e2e/helpers/storage-maintenance.ts index 66305a7605..a3b086a68f 100644 --- a/apps/web/e2e/helpers/storage-maintenance.ts +++ b/apps/web/e2e/helpers/storage-maintenance.ts @@ -15,6 +15,62 @@ export function seedManagedGoCache(tmpDir: string): { artifact: string } { return { artifact }; } +export function seedSystemTemporaryFile( + tmpDir: string, + name = "fixture.txt", +): { + root: string; + file: string; +} { + const root = path.join(tmpDir, "system-temporary"); + const file = path.join(root, name); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(file, "system-temporary-fixture"); + return { root, file }; +} + +export async function mockPartialSystemTemporaryOverview(page: Page, root: string): Promise { + await page.route("**/api/v1/system/storage", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const requestHeaders = route.request().headers(); + const response = await fetch(route.request().url(), { + headers: { + ...(requestHeaders.accept ? { accept: requestHeaders.accept } : {}), + ...(requestHeaders.cookie ? { cookie: requestHeaders.cookie } : {}), + }, + }); + const body = JSON.parse(await response.text()) as { + summary: Record | null; + }; + body.summary ??= {}; + body.summary.system_temporary = { + status: "partial", + size_bytes: 24, + included_in_total: false, + reason: "informational_overlap", + roots: [ + { + requested_path: root, + path: root, + status: "partial", + size_bytes: 24, + skipped_count: 1, + warnings: ["fixture entry could not be measured"], + }, + ], + warnings: ["One fixture entry was skipped."], + }; + await route.fulfill({ + status: response.status, + contentType: "application/json", + body: JSON.stringify(body), + }); + }); +} + export async function mockTemporaryArtifactOverview(page: Page): Promise { await page.route("**/api/v1/system/storage", async (route) => { if (route.request().method() !== "GET") { @@ -148,8 +204,8 @@ function updateDatabaseStorageBody( error: null, progress: { ...currentProgress, - completed_sources: 7, - total_sources: 7, + completed_sources: 8, + total_sources: 8, sources: { ...currentSources, database: { @@ -257,6 +313,12 @@ export async function mockProgressiveStorageOverview(page: Page): Promise<{ total_items: 1, bytes_scanned: 3 * 1024 ** 3, }, + system_temporary: { + state: "ready", + completed_items: 1, + total_items: 1, + bytes_scanned: 1024, + }, }; body.analysis = completed ? { @@ -269,7 +331,7 @@ export async function mockProgressiveStorageOverview(page: Page): Promise<{ refresh_due_at: refreshDueAt, stale: false, error: null, - progress: { completed_sources: 7, total_sources: 7, sources: sourceProgress }, + progress: { completed_sources: 8, total_sources: 8, sources: sourceProgress }, partial_summary: null, } : { @@ -284,7 +346,7 @@ export async function mockProgressiveStorageOverview(page: Page): Promise<{ error: null, progress: { completed_sources: 1, - total_sources: 7, + total_sources: 8, sources: { ...sourceProgress, go_cache: { @@ -298,6 +360,7 @@ export async function mockProgressiveStorageOverview(page: Page): Promise<{ docker: { state: "pending", completed_items: 0, bytes_scanned: 0 }, database: { state: "pending", completed_items: 0, bytes_scanned: 0 }, database_backups: { state: "pending", completed_items: 0, bytes_scanned: 0 }, + system_temporary: { state: "pending", completed_items: 0, bytes_scanned: 0 }, }, }, partial_summary: { @@ -354,6 +417,20 @@ export async function mockProgressiveStorageOverview(page: Page): Promise<{ path: "/data/backups", included_in_total: true, }, + system_temporary: { + status: "measured", + size_bytes: 1024, + included_in_total: false, + reason: "informational_overlap", + roots: [ + { + requested_path: "/data/tmp", + path: "/data/tmp", + status: "measured", + size_bytes: 1024, + }, + ], + }, } : null; body.analyzed_at = completed ? completedAt : null; diff --git a/apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts b/apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts new file mode 100644 index 0000000000..c8aaf3d5f9 --- /dev/null +++ b/apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from "../../fixtures/test-base"; +import { + mockPartialSystemTemporaryOverview, + mockTemporaryArtifactOverview, + seedSystemTemporaryFile, +} from "../../helpers/storage-maintenance"; + +test.describe("Mobile system temporary folders", () => { + test("keeps partial root details readable without horizontal overflow", async ({ + testPage, + backend, + prCapture, + }) => { + const fixture = seedSystemTemporaryFile(backend.tmpDir, "mobile-partial.txt"); + await mockPartialSystemTemporaryOverview(testPage, fixture.root); + await testPage.goto("/settings/system/storage"); + + const trigger = testPage.getByTestId("storage-resource-system-temporary-trigger"); + await trigger.tap(); + const resource = testPage.getByTestId("storage-resource-system-temporary"); + await expect(resource).toContainText("Partial"); + await expect(resource).toContainText(fixture.root); + await expect(resource).toContainText( + "Read-only. This footprint can overlap counted categories.", + ); + await prCapture.screenshot("mobile-system-temporary-partial", { + caption: "Mobile storage keeps partial temporary-folder details inline", + }); + await expect + .poll(() => + testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ) + .toBe(true); + }); + + test("keeps policy persistence and explicit cleanup reachable by touch", async ({ + testPage, + prCapture, + }) => { + await mockTemporaryArtifactOverview(testPage); + await testPage.route("**/api/v1/system/storage/run", async (route) => { + expect(route.request().postDataJSON()).toEqual({ resources: ["temporary_artifacts"] }); + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ job_id: "mobile-temporary-artifacts-policy-cleanup" }), + }); + }); + await testPage.route("**/api/v1/system/jobs/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "mobile-temporary-artifacts-policy-cleanup", + kind: "storage-cleanup", + state: "succeeded", + started_at: new Date().toISOString(), + }), + }); + }); + + await testPage.goto("/settings/system/storage"); + const toggleTarget = testPage.getByTestId("storage-temporary-artifacts-enabled-target"); + const toggleBox = await toggleTarget.boundingBox(); + expect(toggleBox).not.toBeNull(); + expect(toggleBox!.width).toBeGreaterThanOrEqual(44); + expect(toggleBox!.height).toBeGreaterThanOrEqual(44); + await toggleTarget.tap({ position: { x: 2, y: 2 } }); + await testPage.getByRole("button", { name: "Save changes" }).tap(); + await expect(testPage.getByText("Storage policy saved")).toBeVisible(); + await testPage.reload(); + await expect(testPage.getByTestId("storage-temporary-artifacts-enabled")).toHaveAttribute( + "aria-checked", + "true", + ); + + const cleanButton = testPage.getByTestId("storage-policy-temporary-artifacts-clean"); + await expect(cleanButton).toBeVisible(); + const box = await cleanButton.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + await cleanButton.tap(); + await expect(testPage.getByText("Clean stale Kandev artifacts?")).toBeVisible(); + await prCapture.screenshot("mobile-system-temporary-cleanup-confirmation", { + caption: "Mobile storage keeps explicit registered cleanup in a touch-sized flow", + }); + await testPage.getByTestId("storage-temporary-artifacts-confirm").tap(); + await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute( + "data-job-state", + "succeeded", + { timeout: 30_000 }, + ); + await expect + .poll(() => + testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ) + .toBe(true); + }); +}); diff --git a/apps/web/e2e/tests/system/storage-temporary-folders.spec.ts b/apps/web/e2e/tests/system/storage-temporary-folders.spec.ts new file mode 100644 index 0000000000..0a5bd47746 --- /dev/null +++ b/apps/web/e2e/tests/system/storage-temporary-folders.spec.ts @@ -0,0 +1,131 @@ +import fs from "node:fs"; +import { test, expect } from "../../fixtures/test-base"; +import { + mockPartialSystemTemporaryOverview, + mockTemporaryArtifactOverview, + seedSystemTemporaryFile, +} from "../../helpers/storage-maintenance"; + +test.describe("System temporary folders", () => { + test("measures the disposable root again after Analyze and excludes it from the total", async ({ + testPage, + backend, + prCapture, + }) => { + const fixture = seedSystemTemporaryFile(backend.tmpDir, "initial.txt"); + await testPage.goto("/settings/system/storage"); + + const initial = await testPage.evaluate(async () => { + const response = await fetch("/api/v1/system/storage"); + return response.json(); + }); + const initialBytes = initial.summary?.system_temporary?.size_bytes ?? 0; + fs.writeFileSync(`${fixture.root}/after-refresh.txt`, "temporary-refresh-fixture"); + + await testPage.getByTestId("storage-analyze").click(); + await expect(testPage.getByTestId("storage-analyze")).toHaveAttribute( + "data-job-state", + "succeeded", + { timeout: 30_000 }, + ); + await expect + .poll(async () => { + const overview = await testPage.evaluate(async () => { + const response = await fetch("/api/v1/system/storage"); + return response.json(); + }); + return overview.summary?.system_temporary?.size_bytes ?? 0; + }) + .toBeGreaterThan(initialBytes); + + const trigger = testPage.getByTestId("storage-resource-system-temporary-trigger"); + await expect(trigger).toContainText("GB"); + await trigger.click(); + await expect(testPage.getByTestId("storage-resource-system-temporary")).toContainText( + "Read-only. This footprint can overlap counted categories.", + ); + await expect(testPage.getByTestId("storage-resource-system-temporary")).toContainText( + fixture.root, + ); + const refreshed = await testPage.evaluate(async () => { + const response = await fetch("/api/v1/system/storage"); + return response.json(); + }); + expect(refreshed.summary.system_temporary.included_in_total).toBe(false); + await prCapture.screenshot("system-temporary-folders", { + caption: + "Desktop storage shows the refreshed system temporary footprint outside the counted total", + }); + }); + + test("shows partial measurement details and the informational overlap policy", async ({ + testPage, + backend, + prCapture, + }) => { + const fixture = seedSystemTemporaryFile(backend.tmpDir, "partial.txt"); + await mockPartialSystemTemporaryOverview(testPage, fixture.root); + await testPage.goto("/settings/system/storage"); + + const trigger = testPage.getByTestId("storage-resource-system-temporary-trigger"); + await expect(trigger).toContainText("<0.01 GB"); + await trigger.click(); + const resource = testPage.getByTestId("storage-resource-system-temporary"); + await expect(resource).toContainText("Partial"); + await expect(resource).toContainText(fixture.root); + await expect(resource).toContainText("One fixture entry was skipped."); + await prCapture.screenshot("system-temporary-partial", { + caption: "Desktop storage explains a partial system temporary measurement", + }); + }); + + test("persists the opt-in policy and confirms explicit cleanup through quarantine", async ({ + testPage, + prCapture, + }) => { + await mockTemporaryArtifactOverview(testPage); + await testPage.route("**/api/v1/system/storage/run", async (route) => { + expect(route.request().method()).toBe("POST"); + expect(route.request().postDataJSON()).toEqual({ resources: ["temporary_artifacts"] }); + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ job_id: "temporary-artifacts-policy-cleanup" }), + }); + }); + await testPage.route("**/api/v1/system/jobs/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "temporary-artifacts-policy-cleanup", + kind: "storage-cleanup", + state: "succeeded", + started_at: new Date().toISOString(), + }), + }); + }); + + await testPage.goto("/settings/system/storage"); + await testPage.getByTestId("storage-temporary-artifacts-enabled").click(); + await testPage.getByRole("button", { name: "Save changes" }).click(); + await expect(testPage.getByText("Storage policy saved")).toBeVisible(); + await testPage.reload(); + await expect(testPage.getByTestId("storage-temporary-artifacts-enabled")).toHaveAttribute( + "aria-checked", + "true", + ); + + await testPage.getByTestId("storage-policy-temporary-artifacts-clean").click(); + await expect(testPage.getByText("Clean stale Kandev artifacts?")).toBeVisible(); + await prCapture.screenshot("system-temporary-cleanup-confirmation", { + caption: "Desktop storage confirms registered artifact cleanup before quarantine", + }); + await testPage.getByTestId("storage-temporary-artifacts-confirm").click(); + await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute( + "data-job-state", + "succeeded", + { timeout: 30_000 }, + ); + }); +}); diff --git a/apps/web/hooks/domains/system/use-storage-maintenance.test-fixtures.tsx b/apps/web/hooks/domains/system/use-storage-maintenance.test-fixtures.tsx index 1acdfce04c..0355470cfe 100644 --- a/apps/web/hooks/domains/system/use-storage-maintenance.test-fixtures.tsx +++ b/apps/web/hooks/domains/system/use-storage-maintenance.test-fixtures.tsx @@ -69,7 +69,7 @@ export const overview: StorageOverviewResponse = { refresh_due_at: "2099-07-23T12:15:00Z", stale: false, error: null, - progress: { completed_sources: 7, total_sources: 7, sources: {} }, + progress: { completed_sources: 8, total_sources: 8, sources: {} }, partial_summary: null, } satisfies StorageAnalysisState, analyzed_at: "2026-07-23T12:00:00Z", diff --git a/apps/web/lib/state/slices/system/system-slice.test.ts b/apps/web/lib/state/slices/system/system-slice.test.ts index 50a579c0cd..ef24efea1e 100644 --- a/apps/web/lib/state/slices/system/system-slice.test.ts +++ b/apps/web/lib/state/slices/system/system-slice.test.ts @@ -95,7 +95,7 @@ const ANALYSIS = { refresh_due_at: "2026-05-18T00:15:00Z", stale: false, error: null, - progress: { completed_sources: 7, total_sources: 7, sources: {} }, + progress: { completed_sources: 8, total_sources: 8, sources: {} }, partial_summary: null, } as const; diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index de9a056838..09949372fb 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -289,6 +289,7 @@ export interface StorageMaintenanceSettings { quarantine_retention_hours: number; workspaces: StorageWorkspaceSettings; kandev_containers: StorageResourceSettings; + temporary_artifacts?: StorageResourceSettings; go_cache: StorageGoCacheSettings; docker: StorageDockerSettings; } @@ -361,6 +362,28 @@ export interface StorageTemporaryArtifactsSummary { warning?: string; } +export type StorageTemporaryRootStatus = "measured" | "partial" | "unavailable" | "not_applicable"; + +export interface StorageTemporaryRootMeasurement { + requested_path: string; + path: string; + aliases?: string[]; + status: StorageTemporaryRootStatus; + size_bytes?: number; + skipped_count?: number; + reason?: string; + warnings?: string[]; +} + +export interface StorageSystemTemporarySummary { + status: StorageTemporaryRootStatus; + roots: StorageTemporaryRootMeasurement[]; + size_bytes?: number; + included_in_total: false; + reason?: string; + warnings?: string[]; +} + export type StorageFootprintMeasurementStatus = "measured" | "unavailable" | "not_applicable"; export type StorageFootprintMeasurement = @@ -386,6 +409,7 @@ export interface StorageSummary { go_cache: StorageGoCacheSummary; quarantine: StorageQuarantineSummary; temporary_artifacts: StorageTemporaryArtifactsSummary; + system_temporary?: StorageSystemTemporarySummary; docker: StorageDockerSummary; database?: StorageFootprintMeasurement; database_backups?: StorageFootprintMeasurement; @@ -396,6 +420,7 @@ export type StorageSummaryPartial = { go_cache?: StorageGoCacheSummary | null; quarantine?: StorageQuarantineSummary | null; temporary_artifacts?: StorageTemporaryArtifactsSummary | null; + system_temporary?: StorageSystemTemporarySummary | null; docker?: StorageDockerSummary | null; database?: StorageFootprintMeasurement | null; database_backups?: StorageFootprintMeasurement | null; diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json index 15fc837052..63bda93369 100644 --- a/apps/web/src/locales/en/system.json +++ b/apps/web/src/locales/en/system.json @@ -459,6 +459,32 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} stale eligible", "storageTemporaryArtifactsStaleBytes": "{{value}} eligible", "storageTemporaryArtifactsUnavailable": "Temporary artifact ownership could not be measured", + "storageTemporaryArtifactsPolicyTitle": "Temporary artifacts", + "storageTemporaryArtifactsPolicyDescription": "Optionally clean registered Kandev artifacts during scheduled maintenance.", + "storageCleanStaleTemporaryArtifacts": "Clean stale Kandev temporary artifacts", + "storageTemporaryArtifactsPolicySummary": "Registered inactive artifacts only. Minimum age: 24 hours.", + "storageTemporaryArtifactsPolicyHelp": "Only registered artifacts that are inactive and at least 24 hours old are eligible. Files move to quarantine before permanent deletion. Shared and legacy temporary files remain untouched.", + "storageTemporaryArtifactsPolicyAge": "Registered inactive artifacts only. Minimum age: 24 hours.", + "storageTemporaryArtifactsPolicyQuarantine": "Files move to quarantine before permanent deletion.", + "storageTemporaryArtifactsPolicyShared": "Shared temporary files are excluded.", + "storageCleanStaleTemporaryArtifactsAction": "Clean stale artifacts", + "storageTemporaryArtifactsQuarantinedResult": "{{size}} moved to quarantine.", + "storageTemporaryArtifactsFreedAfterDelete": "Space is freed after permanent deletion.", + "storageTemporaryArtifactsMoveFailed": "The artifact could not be moved to quarantine.", + "storageSystemTemporaryFolders": "System temporary folders", + "storageSystemTemporaryInformational": "Read-only. This footprint can overlap counted categories.", + "storageSystemTemporaryComplete": "Complete", + "storageSystemTemporaryPartial": "Partial", + "storageSystemTemporaryUnavailable": "Unavailable", + "storageSystemTemporaryNotApplicable": "Not applicable", + "storageSystemTemporaryCompleteDetail": "All selected entries were measured.", + "storageSystemTemporaryPartialDetail": "Some entries could not be measured.", + "storageSystemTemporaryUnavailableDetail": "The folder could not be measured.", + "storageSystemTemporaryNotApplicableDetail": "The folder is not present.", + "storageSystemTemporaryResolvedRoot": "Requested path {{requested}} resolves to {{resolved}}.", + "storageSystemTemporaryRoot": "{{path}}: {{size}} · {{status}}", + "storageSystemTemporarySkippedCount_one": "{{count}} entry skipped", + "storageSystemTemporarySkippedCount_other": "{{count}} entries skipped", "storageTitle": "Storage", "storageToastAnalysisStarted": "Storage analysis started", "storageToastCacheAdopted": "Go build cache adopted", diff --git a/apps/web/src/locales/pseudo/canvases.json b/apps/web/src/locales/pseudo/canvases.json index efe131f513..d0808f6351 100644 --- a/apps/web/src/locales/pseudo/canvases.json +++ b/apps/web/src/locales/pseudo/canvases.json @@ -89,20 +89,20 @@ "permissionWriteMessages": "Ŵŕĩţē ţàśķ ḿēśśàĝēś", "permissionEventTaskUpdated": "Ŕēćēĩvē ţàśķ ũƥďàţē ēvēńţś", "permissionEventWorkflowUpdated": "Ŕēćēĩvē ŵōŕķƒĺōŵ ũƥďàţē ēvēńţś", - "unsupportedPermission": "Ŭńśũƥƥōŕţēď ƥēŕḿĩśśĩōń: {{value}}", + "unsupportedPermission": "Ũńśũƥƥōŕţēď ƥēŕḿĩśśĩōń: {{value}}", "selectRelease": "Śēĺēćţ à ŕēĺēàśē ţō ŕēvĩēŵ", - "releaseOption": "{{date}} ({{status}}) ŕēvĩēŵ", + "releaseOption": "{{date}} ({{status}})", "releaseDate": "Ćŕēàţēď", "releaseStatus": "Śţàţũś", "newPermission": "Ńēŵ", "statusPrevious": "Ƥŕēvĩōũś", - "unsupportedReleaseStatus": "Ŭńśũƥƥōŕţēď ŕēĺēàśē śţàţũś: {{status}}", - "dateUnavailable": "Đàţē ũńàvàĩĺàƀĺē", + "unsupportedReleaseStatus": "Ũńśũƥƥōŕţēď ŕēĺēàśē śţàţũś: {{status}}", + "dateUnavailable": "Ďàţē ũńàvàĩĺàƀĺē", "sourceUnavailable": "Śōũŕćē ũńàvàĩĺàƀĺē", "sourceActorTaskAgent": "Ţàśķ àĝēńţ", - "sourceActorUser": "Ŭśēŕ", - "sourceActorSystem": "Ҡàńďēv", - "sourceActorUnknown": "Ŭńķńōŵń àũţĥōŕ", + "sourceActorUser": "Ũśēŕ", + "sourceActorSystem": "Ķàńďēv", + "sourceActorUnknown": "Ũńķńōŵń àũţĥōŕ", "archivedCanvasActionHelp": "Ţĥĩś ćàńvàś ĩś àŕćĥĩvēď. Ŕēśţōŕē ĩţ ƒŕōḿ ŵōŕķśƥàćē śēţţĩńĝś ƀēƒōŕē ćĥàńĝĩńĝ ĩţ.", "disabledCanvasActionHelp": "Ţĥĩś ćàńvàś ĩś ďĩśàƀĺēď. Ēńàƀĺē ĩţ ƀēƒōŕē ćĥàńĝĩńĝ ĩţ." } diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json index 987dd2a7d2..7711697a8e 100644 --- a/apps/web/src/locales/pseudo/system.json +++ b/apps/web/src/locales/pseudo/system.json @@ -459,6 +459,32 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} śţàĺē ēĺĩĝĩƀĺē", "storageTemporaryArtifactsStaleBytes": "{{value}} ēĺĩĝĩƀĺē", "storageTemporaryArtifactsUnavailable": "Ţēḿƥōŕàŕŷ àŕţĩƒàćţ ōŵńēŕśĥĩƥ ćōũĺď ńōţ ƀē ḿēàśũŕēď", + "storageTemporaryArtifactsPolicyTitle": "Ţēḿƥōŕàŕŷ àŕţĩƒàćţś", + "storageTemporaryArtifactsPolicyDescription": "Ōƥţĩōńàĺĺŷ ćĺēàń ŕēĝĩśţēŕēď Ķàńďēv àŕţĩƒàćţś ďũŕĩńĝ śćĥēďũĺēď ḿàĩńţēńàńćē.", + "storageCleanStaleTemporaryArtifacts": "Ćĺēàń śţàĺē Ķàńďēv ţēḿƥōŕàŕŷ àŕţĩƒàćţś", + "storageTemporaryArtifactsPolicySummary": "Ŕēĝĩśţēŕēď ĩńàćţĩvē àŕţĩƒàćţś ōńĺŷ. Ḿĩńĩḿũḿ àĝē: 24 ĥōũŕś.", + "storageTemporaryArtifactsPolicyHelp": "Ōńĺŷ ŕēĝĩśţēŕēď àŕţĩƒàćţś ţĥàţ àŕē ĩńàćţĩvē àńď àţ ĺēàśţ 24 ĥōũŕś ōĺď àŕē ēĺĩĝĩƀĺē. Ƒĩĺēś ḿōvē ţō qũàŕàńţĩńē ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń. Śĥàŕēď àńď ĺēĝàćŷ ţēḿƥōŕàŕŷ ƒĩĺēś ŕēḿàĩń ũńţōũćĥēď.", + "storageTemporaryArtifactsPolicyAge": "Ŕēĝĩśţēŕēď ĩńàćţĩvē àŕţĩƒàćţś ōńĺŷ. Ḿĩńĩḿũḿ àĝē: 24 ĥōũŕś.", + "storageTemporaryArtifactsPolicyQuarantine": "Ƒĩĺēś ḿōvē ţō qũàŕàńţĩńē ƀēƒōŕē ƥēŕḿàńēńţ ďēĺēţĩōń.", + "storageTemporaryArtifactsPolicyShared": "Śĥàŕēď ţēḿƥōŕàŕŷ ƒĩĺēś àŕē ēxćĺũďēď.", + "storageCleanStaleTemporaryArtifactsAction": "Ćĺēàń śţàĺē àŕţĩƒàćţś", + "storageTemporaryArtifactsQuarantinedResult": "{{size}} ḿōvēď ţō qũàŕàńţĩńē.", + "storageTemporaryArtifactsFreedAfterDelete": "Śƥàćē ĩś ƒŕēēď àƒţēŕ ƥēŕḿàńēńţ ďēĺēţĩōń.", + "storageTemporaryArtifactsMoveFailed": "Ţĥē àŕţĩƒàćţ ćōũĺď ńōţ ƀē ḿōvēď ţō qũàŕàńţĩńē.", + "storageSystemTemporaryFolders": "Śŷśţēḿ ţēḿƥōŕàŕŷ ƒōĺďēŕś", + "storageSystemTemporaryInformational": "Ŕēàď-ōńĺŷ. Ţĥĩś ƒōōţƥŕĩńţ ćàń ōvēŕĺàƥ ćōũńţēď ćàţēĝōŕĩēś.", + "storageSystemTemporaryComplete": "Ćōḿƥĺēţē", + "storageSystemTemporaryPartial": "Ƥàŕţĩàĺ", + "storageSystemTemporaryUnavailable": "Ũńàvàĩĺàƀĺē", + "storageSystemTemporaryNotApplicable": "Ńōţ àƥƥĺĩćàƀĺē", + "storageSystemTemporaryCompleteDetail": "Àĺĺ śēĺēćţēď ēńţŕĩēś ŵēŕē ḿēàśũŕēď.", + "storageSystemTemporaryPartialDetail": "Śōḿē ēńţŕĩēś ćōũĺď ńōţ ƀē ḿēàśũŕēď.", + "storageSystemTemporaryUnavailableDetail": "Ţĥē ƒōĺďēŕ ćōũĺď ńōţ ƀē ḿēàśũŕēď.", + "storageSystemTemporaryNotApplicableDetail": "Ţĥē ƒōĺďēŕ ĩś ńōţ ƥŕēśēńţ.", + "storageSystemTemporaryResolvedRoot": "Ŕēqũēśţēď ƥàţĥ {{requested}} ŕēśōĺvēś ţō {{resolved}}.", + "storageSystemTemporaryRoot": "{{path}}: {{size}} · {{status}}", + "storageSystemTemporarySkippedCount_one": "{{count}} ēńţŕŷ śķĩƥƥēď", + "storageSystemTemporarySkippedCount_other": "{{count}} ēńţŕĩēś śķĩƥƥēď", "storageTitle": "Śţōŕàĝē", "storageToastAnalysisStarted": "Śţōŕàĝē àńàĺŷśĩś śţàŕţēď", "storageToastCacheAdopted": "Ĝō ƀũĩĺď ćàćĥē àďōƥţēď", diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json index 8888980961..dc9122adfa 100644 --- a/apps/web/src/locales/pt-pt/system.json +++ b/apps/web/src/locales/pt-pt/system.json @@ -454,6 +454,32 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} antigos elegíveis", "storageTemporaryArtifactsStaleBytes": "{{value}} elegíveis", "storageTemporaryArtifactsUnavailable": "Não foi possível medir a propriedade dos artefactos temporários", + "storageTemporaryArtifactsPolicyTitle": "Artefactos temporários", + "storageTemporaryArtifactsPolicyDescription": "Limpe opcionalmente artefactos Kandev registados durante a manutenção agendada.", + "storageCleanStaleTemporaryArtifacts": "Limpar artefactos temporários antigos do Kandev", + "storageTemporaryArtifactsPolicySummary": "Apenas artefactos registados e inativos. Idade mínima: 24 horas.", + "storageTemporaryArtifactsPolicyHelp": "Apenas os artefactos registados, inativos e com pelo menos 24 horas são elegíveis. Os ficheiros são movidos para a quarentena antes da eliminação permanente. Os ficheiros temporários partilhados e antigos permanecem intactos.", + "storageTemporaryArtifactsPolicyAge": "Apenas artefactos registados e inativos. Idade mínima: 24 horas.", + "storageTemporaryArtifactsPolicyQuarantine": "Os ficheiros são movidos para a quarentena antes da eliminação permanente.", + "storageTemporaryArtifactsPolicyShared": "Os ficheiros temporários partilhados são excluídos.", + "storageCleanStaleTemporaryArtifactsAction": "Limpar artefactos antigos", + "storageTemporaryArtifactsQuarantinedResult": "{{size}} movidos para a quarentena.", + "storageTemporaryArtifactsFreedAfterDelete": "O espaço é libertado após a eliminação permanente.", + "storageTemporaryArtifactsMoveFailed": "Não foi possível mover o artefacto para a quarentena.", + "storageSystemTemporaryFolders": "Pastas temporárias do sistema", + "storageSystemTemporaryInformational": "Apenas informativo. Esta utilização pode sobrepor-se às categorias contabilizadas.", + "storageSystemTemporaryComplete": "Completo", + "storageSystemTemporaryPartial": "Parcial", + "storageSystemTemporaryUnavailable": "Indisponível", + "storageSystemTemporaryNotApplicable": "Não aplicável", + "storageSystemTemporaryCompleteDetail": "Todas as entradas selecionadas foram medidas.", + "storageSystemTemporaryPartialDetail": "Não foi possível medir algumas entradas.", + "storageSystemTemporaryUnavailableDetail": "Não foi possível medir a pasta.", + "storageSystemTemporaryNotApplicableDetail": "A pasta não está presente.", + "storageSystemTemporaryResolvedRoot": "O caminho pedido {{requested}} resolve para {{resolved}}.", + "storageSystemTemporaryRoot": "{{path}}: {{size}} · {{status}}", + "storageSystemTemporarySkippedCount_one": "{{count}} entrada ignorada", + "storageSystemTemporarySkippedCount_other": "{{count}} entradas ignoradas", "storageTitle": "Armazenamento", "storageToastAnalysisStarted": "Análise do armazenamento iniciada", "storageToastCacheAdopted": "Cache de compilação do Go adotada", diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json index 39372455b0..333cbe57e8 100644 --- a/apps/web/src/locales/zh-cn/system.json +++ b/apps/web/src/locales/zh-cn/system.json @@ -456,6 +456,32 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} 个过期可清理", "storageTemporaryArtifactsStaleBytes": "{{value}} 可清理", "storageTemporaryArtifactsUnavailable": "无法测量临时文件的所有权", + "storageTemporaryArtifactsPolicyTitle": "临时文件", + "storageTemporaryArtifactsPolicyDescription": "可选择在计划维护期间清理已注册的 Kandev 文件。", + "storageCleanStaleTemporaryArtifacts": "清理过期的 Kandev 临时文件", + "storageTemporaryArtifactsPolicySummary": "仅限已注册的非活动文件。最短期限:24 小时。", + "storageTemporaryArtifactsPolicyHelp": "只有已注册、处于非活动状态且至少存在 24 小时的文件才符合条件。文件会先移入隔离区,然后才会永久删除。共享和旧版临时文件保持不变。", + "storageTemporaryArtifactsPolicyAge": "仅限已注册的非活动文件。最短期限:24 小时。", + "storageTemporaryArtifactsPolicyQuarantine": "文件会先移入隔离区,然后才会永久删除。", + "storageTemporaryArtifactsPolicyShared": "共享临时文件会被排除。", + "storageCleanStaleTemporaryArtifactsAction": "清理过期文件", + "storageTemporaryArtifactsQuarantinedResult": "{{size}} 已移入隔离区。", + "storageTemporaryArtifactsFreedAfterDelete": "永久删除后才会释放空间。", + "storageTemporaryArtifactsMoveFailed": "无法将文件移入隔离区。", + "storageSystemTemporaryFolders": "系统临时文件夹", + "storageSystemTemporaryInformational": "仅供参考。此占用量可能与已计入的类别重叠。", + "storageSystemTemporaryComplete": "已完成", + "storageSystemTemporaryPartial": "部分完成", + "storageSystemTemporaryUnavailable": "不可用", + "storageSystemTemporaryNotApplicable": "不适用", + "storageSystemTemporaryCompleteDetail": "所有选定条目均已测量。", + "storageSystemTemporaryPartialDetail": "部分条目无法测量。", + "storageSystemTemporaryUnavailableDetail": "无法测量此文件夹。", + "storageSystemTemporaryNotApplicableDetail": "此文件夹不存在。", + "storageSystemTemporaryResolvedRoot": "请求路径 {{requested}} 解析为 {{resolved}}。", + "storageSystemTemporaryRoot": "{{path}}:{{size}} · {{status}}", + "storageSystemTemporarySkippedCount_one": "已跳过 {{count}} 个条目", + "storageSystemTemporarySkippedCount_other": "已跳过 {{count}} 个条目", "storageTitle": "存储", "storageToastAnalysisStarted": "已开始存储分析", "storageToastCacheAdopted": "已接管 Go 构建缓存", diff --git a/apps/web/src/locales/zh-hk/canvases.json b/apps/web/src/locales/zh-hk/canvases.json index 42a3909046..1c0a9be6f0 100644 --- a/apps/web/src/locales/zh-hk/canvases.json +++ b/apps/web/src/locales/zh-hk/canvases.json @@ -83,14 +83,14 @@ "permissionExternalOrigins": "外部來源", "permissionExternalOrigin": "外部 HTTPS 來源", "permissionSharedState": "讀寫共享畫布狀態", - "permissionReadTasks": "讀取工作項目資料", - "permissionReadWorkflows": "讀取工作流程資料", - "permissionWriteTasks": "寫入工作項目資料", - "permissionWriteMessages": "寫入工作項目訊息", - "permissionEventTaskUpdated": "接收工作項目更新事件", + "permissionReadTasks": "讀取任務數據", + "permissionReadWorkflows": "讀取工作流程數據", + "permissionWriteTasks": "寫入任務數據", + "permissionWriteMessages": "寫入任務訊息", + "permissionEventTaskUpdated": "接收任務更新事件", "permissionEventWorkflowUpdated": "接收工作流程更新事件", "unsupportedPermission": "不支援的權限:{{value}}", - "selectRelease": "選擇要檢視的版本", + "selectRelease": "選擇要查看的版本", "releaseOption": "{{date}}({{status}})", "releaseDate": "建立時間", "releaseStatus": "狀態", @@ -99,8 +99,8 @@ "unsupportedReleaseStatus": "不支援的版本狀態:{{status}}", "dateUnavailable": "日期不可用", "sourceUnavailable": "來源不可用", - "sourceActorTaskAgent": "工作項目代理", - "sourceActorUser": "使用者", + "sourceActorTaskAgent": "任務代理", + "sourceActorUser": "用戶", "sourceActorSystem": "Kandev", "sourceActorUnknown": "未知作者", "archivedCanvasActionHelp": "此畫布已歸檔。請先在工作區設定中恢復它,然後再進行更改。", diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json index 7b102c3d92..0a38bf443b 100644 --- a/apps/web/src/locales/zh-hk/system.json +++ b/apps/web/src/locales/zh-hk/system.json @@ -456,6 +456,32 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} 個過期可清理", "storageTemporaryArtifactsStaleBytes": "{{value}} 可清理", "storageTemporaryArtifactsUnavailable": "無法測量臨時檔案的所有權", + "storageTemporaryArtifactsPolicyTitle": "臨時檔案", + "storageTemporaryArtifactsPolicyDescription": "可選擇在計劃維護期間清理已註冊的 Kandev 檔案。", + "storageCleanStaleTemporaryArtifacts": "清理過期的 Kandev 臨時檔案", + "storageTemporaryArtifactsPolicySummary": "僅限已註冊的非活動檔案。最短期限:24 小時。", + "storageTemporaryArtifactsPolicyHelp": "只有已註冊、處於非活動狀態且至少存在 24 小時的檔案才符合條件。檔案會先移入隔離區,然後才會永久刪除。共享和舊版臨時檔案保持不變。", + "storageTemporaryArtifactsPolicyAge": "僅限已註冊的非活動檔案。最短期限:24 小時。", + "storageTemporaryArtifactsPolicyQuarantine": "檔案會先移入隔離區,然後才會永久刪除。", + "storageTemporaryArtifactsPolicyShared": "共享臨時檔案會被排除。", + "storageCleanStaleTemporaryArtifactsAction": "清理過期檔案", + "storageTemporaryArtifactsQuarantinedResult": "{{size}} 已移入隔離區。", + "storageTemporaryArtifactsFreedAfterDelete": "永久刪除後才會釋放空間。", + "storageTemporaryArtifactsMoveFailed": "無法將檔案移入隔離區。", + "storageSystemTemporaryFolders": "系統臨時資料夾", + "storageSystemTemporaryInformational": "僅供參考。此佔用量可能與已計入的類別重疊。", + "storageSystemTemporaryComplete": "已完成", + "storageSystemTemporaryPartial": "部分完成", + "storageSystemTemporaryUnavailable": "不可用", + "storageSystemTemporaryNotApplicable": "不適用", + "storageSystemTemporaryCompleteDetail": "所有選定條目均已測量。", + "storageSystemTemporaryPartialDetail": "部分條目無法測量。", + "storageSystemTemporaryUnavailableDetail": "無法測量此資料夾。", + "storageSystemTemporaryNotApplicableDetail": "此資料夾不存在。", + "storageSystemTemporaryResolvedRoot": "請求路徑 {{requested}} 解析為 {{resolved}}。", + "storageSystemTemporaryRoot": "{{path}}:{{size}} · {{status}}", + "storageSystemTemporarySkippedCount_one": "已略過 {{count}} 個條目", + "storageSystemTemporarySkippedCount_other": "已略過 {{count}} 個條目", "storageTitle": "存儲", "storageToastAnalysisStarted": "已開始存儲分析", "storageToastCacheAdopted": "已接管 Go 構建快取", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index f4c19df508..a10dd0ada0 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -404,8 +404,8 @@ "deleteLayoutConfirm": "刪除 {{name}}?", "deleteSession": "刪除工作階段?", "deleteTaskConfirm": "刪除「{{taskTitle}}」。此操作無法撤銷。", - "deletePreflightChecking": "正在檢查工作區變更...", - "deletePreflightError": "無法檢查工作區變更。請重試。", + "deletePreflightChecking": "正在檢查工作區更改...", + "deletePreflightError": "無法檢查工作區更改。請重試。", "retryDeletePreflight": "重試", "deleteDirtyWorktreeTitle": "刪除任務需要你的確認", "deleteDirtyWorktreeDescription": "任務仍會顯示。請檢查本地更改,選擇丟棄,然後重試。", @@ -882,14 +882,14 @@ "moveHere": "移到此處", "moveWithOptions": "帶選項移動", "workflowMoveOptionsTitle": "帶選項移到{{step}}", - "workflowMoveOptionsDescription": "這些覆寫設定只在任務進入目標步驟時套用一次。", + "workflowMoveOptionsDescription": "這些覆蓋設定只在任務進入目標步驟時應用一次。", "workflowMoveOptionsForStep": "開啟移至{{step}}的選項", "workflowMoveOptions": "選項", - "workflowMoveResetContext": "進入前清除代理程式上下文", - "workflowMoveSkipStepPrompt": "略過步驟提示", - "workflowMoveSkipStepPromptHint": "不傳送該步驟的提示或任務描述。若填寫一次性指令,則僅傳送這些指令;若未填寫,則不啟動代理程式。", + "workflowMoveResetContext": "進入前清除代理上下文", + "workflowMoveSkipStepPrompt": "跳過步驟提示", + "workflowMoveSkipStepPromptHint": "不傳送該步驟的提示或任務描述。若填寫一次性指令,則僅傳送這些指令;若未填寫,則不啓動代理。", "workflowMoveInstructions": "一次性指令", - "workflowMoveInstructionsPlaceholder": "為接收任務的代理程式新增指令", + "workflowMoveInstructionsPlaceholder": "為接收任務的代理新增指令", "workflowMoveApply": "移動", "moveTaskToTheNextWorkflow": "將任務移至下一工作流程步驟", "moveTo": "移動到", diff --git a/apps/web/src/locales/zh-tw/canvases.json b/apps/web/src/locales/zh-tw/canvases.json index cb991c2578..9037b6b873 100644 --- a/apps/web/src/locales/zh-tw/canvases.json +++ b/apps/web/src/locales/zh-tw/canvases.json @@ -82,7 +82,7 @@ "permissionEvents": "事件", "permissionExternalOrigins": "外部來源", "permissionExternalOrigin": "外部 HTTPS 來源", - "permissionSharedState": "讀寫共用畫布狀態", + "permissionSharedState": "讀寫共享畫布狀態", "permissionReadTasks": "讀取任務資料", "permissionReadWorkflows": "讀取工作流程資料", "permissionWriteTasks": "寫入任務資料", diff --git a/apps/web/src/locales/zh-tw/executors.json b/apps/web/src/locales/zh-tw/executors.json index f6b2c0039f..8bf05d9bf9 100644 --- a/apps/web/src/locales/zh-tw/executors.json +++ b/apps/web/src/locales/zh-tw/executors.json @@ -425,8 +425,8 @@ "portableConfigNotFound": "未找到", "portableConfigNotFoundHint": "這只是編輯提示。你可以先儲存設定,稍後再新增該檔案。", "portableConfigTitle": "代理程式設定檔", - "allowUserNamespacesTitle": "使用者命名空間支援", - "allowUserNamespacesDescription": "允許在容器內建立無特權的使用者命名空間和執行掛載操作。啟用類似 bwrap 的沙箱工具。", + "allowUserNamespacesTitle": "使用者名稱空間支援", + "allowUserNamespacesDescription": "允許在容器內建立無特權的使用者名稱空間和執行掛載操作。啟用類似 bwrap 的沙箱工具。", "allowUserNamespacesNote": "此設定僅影響新建立的容器。現有任務環境必須重置才能使更改生效。", - "allowUserNamespacesWarning": "這會為選擇啟用的容器停用 Docker 預設的 AppArmor 限制。僅為需要使用者命名空間沙箱的可信代理執行環境啟用。" + "allowUserNamespacesWarning": "這會為選擇啟用的容器停用 Docker 預設的 AppArmor 限制。僅為需要使用者名稱空間沙箱的可信代理執行環境啟用。" } diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json index 501133bd12..0776cdd777 100644 --- a/apps/web/src/locales/zh-tw/system.json +++ b/apps/web/src/locales/zh-tw/system.json @@ -456,6 +456,32 @@ "storageTemporaryArtifactsStaleCount_other": "{{count}} 個過期可清理", "storageTemporaryArtifactsStaleBytes": "{{value}} 可清理", "storageTemporaryArtifactsUnavailable": "無法測量臨時檔案的所有權", + "storageTemporaryArtifactsPolicyTitle": "臨時檔案", + "storageTemporaryArtifactsPolicyDescription": "可選擇在計劃維護期間清理已註冊的 Kandev 檔案。", + "storageCleanStaleTemporaryArtifacts": "清理過期的 Kandev 臨時檔案", + "storageTemporaryArtifactsPolicySummary": "僅限已註冊的非活動檔案。最短期限:24 小時。", + "storageTemporaryArtifactsPolicyHelp": "只有已註冊、處於非活動狀態且至少存在 24 小時的檔案才符合條件。檔案會先移入隔離區,然後才會永久刪除。共享和舊版臨時檔案保持不變。", + "storageTemporaryArtifactsPolicyAge": "僅限已註冊的非活動檔案。最短期限:24 小時。", + "storageTemporaryArtifactsPolicyQuarantine": "檔案會先移入隔離區,然後才會永久刪除。", + "storageTemporaryArtifactsPolicyShared": "共享臨時檔案會被排除。", + "storageCleanStaleTemporaryArtifactsAction": "清理過期檔案", + "storageTemporaryArtifactsQuarantinedResult": "{{size}} 已移入隔離區。", + "storageTemporaryArtifactsFreedAfterDelete": "永久刪除後才會釋放空間。", + "storageTemporaryArtifactsMoveFailed": "無法將檔案移入隔離區。", + "storageSystemTemporaryFolders": "系統臨時資料夾", + "storageSystemTemporaryInformational": "僅供參考。此佔用量可能與已計入的類別重疊。", + "storageSystemTemporaryComplete": "已完成", + "storageSystemTemporaryPartial": "部分完成", + "storageSystemTemporaryUnavailable": "不可用", + "storageSystemTemporaryNotApplicable": "不適用", + "storageSystemTemporaryCompleteDetail": "所有選定條目均已測量。", + "storageSystemTemporaryPartialDetail": "部分條目無法測量。", + "storageSystemTemporaryUnavailableDetail": "無法測量此資料夾。", + "storageSystemTemporaryNotApplicableDetail": "此資料夾不存在。", + "storageSystemTemporaryResolvedRoot": "請求路徑 {{requested}} 解析為 {{resolved}}。", + "storageSystemTemporaryRoot": "{{path}}:{{size}} · {{status}}", + "storageSystemTemporarySkippedCount_one": "已略過 {{count}} 個條目", + "storageSystemTemporarySkippedCount_other": "已略過 {{count}} 個條目", "storageTitle": "儲存", "storageToastAnalysisStarted": "已開始儲存分析", "storageToastCacheAdopted": "已接管 Go 構建快取", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index c060d273df..d25367feec 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -404,8 +404,8 @@ "deleteLayoutConfirm": "刪除 {{name}}?", "deleteSession": "刪除工作階段?", "deleteTaskConfirm": "刪除「{{taskTitle}}」。此操作無法撤銷。", - "deletePreflightChecking": "正在檢查工作樹變更...", - "deletePreflightError": "無法檢查工作樹變更。請重試。", + "deletePreflightChecking": "正在檢查工作區更改...", + "deletePreflightError": "無法檢查工作區更改。請重試。", "retryDeletePreflight": "重試", "deleteDirtyWorktreeTitle": "刪除任務需要你的確認", "deleteDirtyWorktreeDescription": "任務仍會顯示。請檢查本地更改,選擇丟棄,然後重試。", @@ -882,14 +882,14 @@ "moveHere": "移到此處", "moveWithOptions": "帶選項移動", "workflowMoveOptionsTitle": "帶選項移到{{step}}", - "workflowMoveOptionsDescription": "這些覆寫設定只在任務進入目標步驟時套用一次。", + "workflowMoveOptionsDescription": "這些覆蓋設定只在任務進入目標步驟時應用一次。", "workflowMoveOptionsForStep": "開啟移至{{step}}的選項", "workflowMoveOptions": "選項", - "workflowMoveResetContext": "進入前清除代理程式上下文", - "workflowMoveSkipStepPrompt": "略過步驟提示", - "workflowMoveSkipStepPromptHint": "不傳送該步驟的提示或任務描述。若填寫一次性指令,則僅傳送這些指令;若未填寫,則不啟動代理程式。", + "workflowMoveResetContext": "進入前清除代理上下文", + "workflowMoveSkipStepPrompt": "跳過步驟提示", + "workflowMoveSkipStepPromptHint": "不傳送該步驟的提示或任務描述。若填寫一次性指令,則僅傳送這些指令;若未填寫,則不啟動代理。", "workflowMoveInstructions": "一次性指令", - "workflowMoveInstructionsPlaceholder": "為接收任務的代理程式新增指令", + "workflowMoveInstructionsPlaceholder": "為接收任務的代理新增指令", "workflowMoveApply": "移動", "moveTaskToTheNextWorkflow": "將任務移至下一工作流程步驟", "moveTo": "移動到", diff --git a/apps/web/src/locales/zh-tw/workspaces.json b/apps/web/src/locales/zh-tw/workspaces.json index efc39bbc56..fc8fb053c0 100644 --- a/apps/web/src/locales/zh-tw/workspaces.json +++ b/apps/web/src/locales/zh-tw/workspaces.json @@ -171,7 +171,7 @@ "repositorySetsTaskDefault": "任務預設值({{branch}})", "repositorySetsTaskDefaultNoBranch": "任務預設值", "repositorySetsBaseBranchLabel": "儲存的基礎分支", - "repositorySetsBaseBranchFor": "{{name}} 的已儲存基礎分支", + "repositorySetsBaseBranchFor": "{{name}} 的儲存基礎分支", "repositorySetsBranchUnavailable": "不可用", "repositorySetsBranchUnavailableReason": "此儲存的分支不可用。請選擇其他分支,或重置為任務預設值。", "repositorySetsMemberMissing": "該儲存庫已不在此工作區中。", diff --git a/docs/decisions/2026-08-08-owned-temp-artifact-cleanup.md b/docs/decisions/2026-08-08-owned-temp-artifact-cleanup.md index 97671e74d0..bf5f48729a 100644 --- a/docs/decisions/2026-08-08-owned-temp-artifact-cleanup.md +++ b/docs/decisions/2026-08-08-owned-temp-artifact-cleanup.md @@ -35,8 +35,10 @@ Add a typed `temporary_artifacts` storage provider with these rules: `resources: ["temporary_artifacts"]` selection invokes it; scheduled maintenance and an unscoped full manual run do not delete these artifacts. No new persisted policy toggle is added. - Eligible roots are moved by same-filesystem rename into the existing Kandev quarantine before - permanent deletion. Cross-device moves have no copy/delete fallback. Existing quarantine - retention, restore, and permanent-delete safety rules apply. + permanent deletion. Cross-device moves use a verified staged copy under quarantine and remove the + original only after publication and filesystem identity revalidation. A failed copy or validation + leaves the original intact. Existing quarantine retention, restore, and permanent-delete safety + rules apply. The later temporary-storage policy decision adds the opt-in scheduled path. - Legacy `/tmp/kandev-agent/*`, generic `tmp.*` paths, dependency and package caches, Go/Node/ Playwright caches, PR/preview folders, E2E/dev-isolated roots, and artifacts belonging to another Kandev installation remain outside the provider. diff --git a/docs/decisions/2026-09-11-temporary-storage-visibility-policy.md b/docs/decisions/2026-09-11-temporary-storage-visibility-policy.md new file mode 100644 index 0000000000..addbee15b1 --- /dev/null +++ b/docs/decisions/2026-09-11-temporary-storage-visibility-policy.md @@ -0,0 +1,48 @@ +# ADR-2026-09-11-temporary-storage-visibility-policy: Separate temporary storage visibility from cleanup ownership + +**Status:** accepted +**Date:** 2026-09-11 +**Area:** backend, frontend, security + +## Context + +Before this decision, storage analysis omitted unregistered temporary files. Operators could not see +shared `/tmp` pressure in its classified categories, and scheduled runs always skipped the existing +artifact provider. + +## Decision + +Measure resolved service temporary folders read-only, including a distinct Unix `/tmp`. +Show this footprint separately from Total counted because it overlaps classified resources. +Do not infer cleanup authority from a path appearing in analysis. + +Add an off-by-default policy for scheduled cleanup of registered Kandev temporary artifacts. +Keep explicit cleanup available independently of that option. +Preserve matching registry markers, inactive lifecycle, the 24-hour stale interval, and recoverable quarantine. +Keep unregistered files and legacy roots outside cleanup authority. + +This decision amends the manual-only and cross-device quarantine restrictions in +[the owned-artifact decision](2026-08-08-owned-temp-artifact-cleanup.md). That decision remains +the authority for artifact ownership, lifecycle checks, and the other quarantine safety rules. + +## Consequences + +Operators gain broader visibility and recurring cleanup of verified artifacts. +The general temporary footprint is informational and cannot be added to classified totals. +Large shared caches can remain visible without becoming removable through Kandev. +Cross-filesystem quarantine uses a verified staged copy and removes the source only after identity +revalidation. Copy or validation failures preserve the source and leave a failed intent for retry. +Quarantine moves bytes but frees no space until permanent deletion. + +## Alternatives Considered + +- Broad age-based cleanup: rejected because age and names do not prove inactivity or ownership. +- Automatic adoption of legacy roots: rejected because those roots lack trustworthy lifecycle records. +- Keep all temporary storage manual-only: rejected because registered artifacts already have a constrained ownership model. +- Add every temporary byte to Total counted: rejected because configured storage roots can overlap. +- Add configurable paths or shell cleanup commands: excluded because that creates a separate host administration contract. + +## Related specification + +[Temporary storage design](../specs/system-page/system-design/storage-temporary-folders.md) +maps the implemented behavior to requirements and completed work orders. diff --git a/docs/plans/storage-analysis-progress/plan.md b/docs/plans/storage-analysis-progress/plan.md index 74ba149054..9b6f38f497 100644 --- a/docs/plans/storage-analysis-progress/plan.md +++ b/docs/plans/storage-analysis-progress/plan.md @@ -18,6 +18,10 @@ stream coarse progress to the Storage page. The order protects the cache contract. The scanner lands first, the backend state contract follows, and the web work consumes that contract. +The [temporary storage visibility and cleanup package](../storage-temporary-folders/plan.md) +extends this completed source and progress contract with an informational system temporary-folder +source. + ## Scope ### In scope diff --git a/docs/plans/storage-database-footprint/plan.md b/docs/plans/storage-database-footprint/plan.md index 3b92a12017..e53965855a 100644 --- a/docs/plans/storage-database-footprint/plan.md +++ b/docs/plans/storage-database-footprint/plan.md @@ -21,6 +21,9 @@ The system-page system owns this package because it owns the existing storage an existing capability with active requirement `REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-002`. [System design](../../specs/system-page/system-design/storage-database-footprint.md) defines the measurement and presentation contract. +The [temporary storage visibility and cleanup package](../storage-temporary-folders/plan.md) +extends the same overview with a separate informational temporary-folder source. + ## Scope ### In scope diff --git a/docs/plans/storage-maintenance/plan.md b/docs/plans/storage-maintenance/plan.md index 56800a8157..7138cbf687 100644 --- a/docs/plans/storage-maintenance/plan.md +++ b/docs/plans/storage-maintenance/plan.md @@ -24,6 +24,10 @@ caches can be reused. The superseded marker, snapshot, Storage-provider, UI, and managed Go-cache defaults and injection remain unchanged. A separate test-harness task addresses the observed accumulation of `kandev-e2e-*` roots. +The current [temporary storage visibility and cleanup package](../storage-temporary-folders/plan.md) +extends this completed maintenance system with read-only system temporary-folder visibility and an +opt-in policy for verified registered artifacts. + --- ## Backend diff --git a/docs/plans/storage-temp-artifacts/plan.md b/docs/plans/storage-temp-artifacts/plan.md index b5de847610..1daa0aa3dc 100644 --- a/docs/plans/storage-temp-artifacts/plan.md +++ b/docs/plans/storage-temp-artifacts/plan.md @@ -18,6 +18,10 @@ The architectural boundary is recorded in [ADR-2026-08-08-owned-temp-artifact-cleanup](../../decisions/2026-08-08-owned-temp-artifact-cleanup.md), which supplements [ADR 0045](../../decisions/0045-install-wide-storage-maintenance.md). +The later [temporary storage visibility and cleanup package](../storage-temporary-folders/plan.md) +adds read-only system temporary-folder visibility and the disabled-by-default scheduled cleanup +policy while preserving this package's ownership and quarantine boundaries. + --- ## Backend diff --git a/docs/plans/storage-temporary-folders/plan.md b/docs/plans/storage-temporary-folders/plan.md new file mode 100644 index 0000000000..355a22d6ff --- /dev/null +++ b/docs/plans/storage-temporary-folders/plan.md @@ -0,0 +1,195 @@ +--- +created: 2026-09-11 +status: complete +requirements: + - REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003 + - REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-004 +system_design: + - ../../specs/system-page/system-design/storage-temporary-folders.md +legacy_specs: [] +--- + +# Implementation Plan: Temporary storage visibility and cleanup + +## Overview + +Deliver temporary-folder visibility first, then optional cleanup of registered artifacts, then integrated browser evidence. +Each slice preserves the distinction between measured storage and cleanup ownership. +All work stays sequential in the primary session. + +- [Requirements](../../specs/system-page/requirements/storage-maintenance.md): active extensions 003 and 004. +- [System design](../../specs/system-page/system-design/storage-temporary-folders.md). +- [Accepted decision](../../decisions/2026-09-11-temporary-storage-visibility-policy.md). + +## Scope + +### In scope + +- Effective service temp folder and distinct Unix `/tmp`, measured without mutation. +- Informational footprint, partial states, cached progress, and wrapped path details. +- Off-by-default saved cleanup option for existing registered artifact kinds. +- Fresh ownership and filesystem identity checks, saved retention, safe cross-filesystem quarantine, + quarantine result wording, and desktop/phone access. + +### Out of scope + +- General shared-temp cleanup, arbitrary paths, shell commands, legacy adoption, and new producers. +- Direct deletion before retention and changing agent temp variables. +- Host-wide reconciliation, hard-link deduplication, and physical-block accounting. + +## Technical approach + +Task 01 adds `tempstore`, bounded scanner options, overview projection, and resource rendering. +The broad footprint is excluded from Total counted, so existing overlap attribution stays intact. +Task 02 extends the saved settings document and the existing `tempartifacts.Provider`. +Both explicit and scheduled actions share eligibility and quarantine behavior. +Task 03 supplies browser evidence and reconciles operator documentation. + +### Companion packages + +The completed packages remain historical evidence: +[owned artifacts](../storage-temp-artifacts/plan.md), +[progressive analysis](../storage-analysis-progress/plan.md), +[database footprint](../storage-database-footprint/plan.md), +and [original maintenance](../storage-maintenance/plan.md). +Do not reopen their completed work orders or overwrite their recorded test counts. +This package supersedes their manual-only expectation. The affected current design passages and +companion manifests link this completed package. + +## ASCII UI preview + +UI-01: Settings > Storage, analysis expanded. Proposed desktop composition: + +```text +Storage analysis [Analyze] +Total counted: + +v System temporary folders Read-only + Informational. Can overlap counted categories. + /tmp Complete + Partial + Some entries could not be measured. + +> Kandev temporary artifacts +``` + +UI-01 phone: one page scroll owner, root details beneath the value: + +```text +Storage analysis +[Analyze] +Total counted: + +v System temporary folders + Read-only + Informational. Can overlap + counted categories. + /tmp + Complete + + Partial + Some entries could not + be measured. +``` + +UI-02: Settings > Storage, policy. Shared semantic order: + +```text +Temporary artifacts +Clean stale Kandev temporary artifacts [off] +Only registered, inactive artifacts older than 24 hours. +Files move to quarantine before permanent deletion. +Shared temporary files are excluded. + +[Clean stale artifacts] +[Save changes] (existing page action) + +Result: moved to quarantine. +Space is freed after permanent deletion. +``` + +On phones, the switch remains beside its wrapped label. +The manual action fills the available width and has a 44-pixel minimum hit target. +Desktop actions retain normal 28-pixel density. +Required explanations stay inline, with no tooltip dependency or new overlay. +The existing Storage accordion is the mobile exemplar. + +Loading: the root row shows Measuring, never zero before a result. +Empty: a complete zero measurement shows 0 GB. +Partial: the measured bytes remain visible with a Partial label. +Unavailable: no byte value appears; Analyze can retry. +Policy disabled: manual cleanup remains available to an authorized user. +Busy or unauthorized: existing disabled reasons apply to mutations. +Cleanup failure: report the failed move and preserve the original. + +These structures are required by AC-003.3, .5, .6, .8 and AC-004.7, .8. +Here, AC prefixes mean `AC-SYSTEM-PAGE-STORAGE-MAINTENANCE`. +Spacing and placeholder values are illustrative. + +## Tests + +The test names below record implementation evidence for the completed package. + +| Criteria | Evidence | +| --- | --- | +| 003.1, .2, .7, .9 | `tempstore/provider_test.go: TestAnalyzeTemporaryRoots`, alias/nesting, refreshed nested-mount, and root-replacement tests | +| 003.5, .6 | `TestAnalyzeTemporaryPartialResults`, `TestAnalyzeTemporaryCancellation`, scanner empty/nested-root and interrupted-partition tests, overview progress/cache tests | +| 003.3, .4, .8 | `storage-totals.test.ts` informational exclusion and `storage-overview-card.test.tsx` state/path cases | +| 004.1, .2, .3 | settings roundtrip tests and `TestProviderCleanupPolicyMatrix` | +| 004.4, .5, .6 | `TestCleanupRevalidatesArtifact`, owner-death per-run reconciliation, uncertain-owner, replacement, age, and EXDEV staged-copy cases | +| 004.7, .8 | retention/recovery tests, run-history wording, policy persistence, and phone flows | + +Task 01 and Task 02 add TDD tests for their changed logic. +Tests use disposable files and injected clocks, owner probes, and mount information. + +## E2E tests + +Add `tests/system/storage-temporary-folders.spec.ts` for chromium. +Add `tests/system/mobile-storage-temporary-folders.spec.ts` for mobile-chrome. +Cover all visible states in UI-01 and UI-02 using isolated fixtures. +Use real backend analysis against injected fixture roots for the refresh case. +Use controlled responses only for partial/unavailable presentation and long paths. +Provider integration tests prove mutation safety separately from browser presentation. + +The phone flow inspects paths, saves the option, runs explicit cleanup, and reads the result. +Assert no page horizontal overflow and actual 44-pixel action targets. +Capture and inspect a phone screenshot during the guarded run. + +## Work orders + +- [x] [Task 01: Temporary-folder visibility](task-01-temporary-folder-visibility.md) +- [x] [Task 02: Owned temporary cleanup policy](task-02-owned-temporary-cleanup-policy.md) +- [x] [Task 03: Temporary storage flow evidence](task-03-temporary-storage-flow-evidence.md) + +## Verification results + +Design validation passed on 2026-09-11: + +- `rtk python3 scripts/lint-spec-files.test.py`: 36 tests passed. +- `rtk python3 scripts/lint-spec-files.py --all`: all specification files passed. +- `rtk git diff --check`: passed. +- Work-order requirement IDs, document links, and whitespace: passed. +- System index size: 2199 bytes, within its limit. + +Implementation and rendered verification completed on 2026-09-12: + +- Backend storage and backend-app tests passed for the temporary-folder scanner, overview wiring, + saved cleanup policy, lifecycle revalidation, owner-death reconciliation, and quarantine result + contract: 1,139 tests across 9 packages. +- Focused web tests passed for overview rendering, totals, policy settings, maintenance settings, + and run history. Web typecheck and i18n checks passed. +- Chromium temporary-storage E2E passed: 3 tests. Mobile Chromium temporary-storage E2E passed: + 2 tests, including phone screenshot and overflow checks. +- Public documentation and specification validation passed. `git diff --check` passed. +- Generated E2E diagnostics were removed; no diagnostic artifacts remain. +- The repository-wide `make -C apps/backend test` command still has unrelated existing failures in + process probing, config discovery, office migrations, and an orchestrator test panic. + +## Risks + +- Shared temp traversal can be slow or incomplete; the source has its own deadline and partial state. +- Mount identity differs by platform; unsupported discovery must fail closed. +- A partial informational measurement must not silently become zero or alter classified totals. +- Cleanup cannot reclaim unregistered caches, even when those dominate the broad footprint. +- Cross-filesystem quarantine stages a verified copy and temporarily needs space in both locations; + failures preserve the source and leave a retryable failed intent. diff --git a/docs/plans/storage-temporary-folders/task-01-temporary-folder-visibility.md b/docs/plans/storage-temporary-folders/task-01-temporary-folder-visibility.md new file mode 100644 index 0000000000..3440a72b66 --- /dev/null +++ b/docs/plans/storage-temporary-folders/task-01-temporary-folder-visibility.md @@ -0,0 +1,115 @@ +--- +id: "01-temporary-folder-visibility" +title: "Temporary-folder visibility" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003 +acceptance_criteria: + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.1 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.2 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.3 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.4 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.5 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.6 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.7 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.8 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.9 +system_design: + - ../../specs/system-page/system-design/storage-temporary-folders.md +--- + +# Task 01: Temporary-folder visibility + +## Summary + +Add the read-only temporary-folder footprint from provider through desktop and phone analysis. +Keep it visibly separate from the classified total. + +## In scope + +- Root resolution, duplicate/nested roots, mount boundaries, partial errors, deadline, and bounded scanning. +- Overview source identity, cache/progress projection, API types, resource rows, and translations. +- Unit and integration tests for scope, read-only behavior, rendering, and aggregate exclusion. + +## Out of scope + +Cleanup policy, new artifact producers, public docs publication, and unrelated source attribution. + +## Acceptance + +- The source measures only server-selected roots and never mutates them or traverses unsafe boundaries. +- Cached and progressive responses preserve partial/unavailable distinctions. +- Desktop and phone show root details without adding this footprint to Total counted. + +## ASCII UI preview + +UI-01 excerpt, [full desktop/phone preview](plan.md#ascii-ui-preview): + +```text +v System temporary folders + Read-only + Informational. Can overlap counted categories. + /tmp + Complete | Partial | Unavailable +``` + +Map to AC-003.3, .5, .6, .8. +Phones wrap paths and retain one page scroll owner and 44-pixel row targets. + +## Verification + +Run from repository root. In a fresh worktree, install dependencies once from `apps/`. + +```bash +(cd apps && rtk pnpm install --frozen-lockfile) +(cd apps/backend && rtk go test ./internal/system/storage/...) +(cd apps/backend && rtk go test ./internal/backendapp -run 'Test(Storage|OverviewCache)') +(cd apps/web && rtk pnpm exec vitest run components/settings/system/storage/storage-overview-card.test.tsx components/settings/system/storage/storage-totals.test.ts) +(cd apps/web && rtk pnpm run typecheck) +(cd apps/web && rtk pnpm run i18n:zh-hant) +(cd apps/web && rtk pnpm run i18n:check) +(cd apps/web && rtk pnpm run i18n:ratchet) +``` + +Task 03 owns rendered browser evidence after integration. +The dependency install is unnecessary when this worktree already has a valid install. + +## Files likely touched + +- New `apps/backend/internal/system/storage/tempstore/` provider and platform mount readers. +- `apps/backend/internal/system/storage/filescan/measure.go` and tests. +- `apps/backend/internal/backendapp/storage_maintenance.go` and tests. +- `apps/backend/internal/system/storage/handler.go`, `analysis_state.go`, `overview_cache.go`, and tests. +- `apps/web/lib/types/system.ts`. +- `apps/web/components/settings/system/storage/storage-overview-resources.ts`, `storage-overview-card.tsx`, `storage-totals.ts`, and tests. +- `apps/web/src/locales/` and affected source-count fixtures. + +## Dependencies + +None. Read the existing database provider and progressive cache as implementation patterns. + +## Risks + +Do not weaken strict scanner callers while adding tolerant temp traversal. +A Windows or macOS mount reader needs focused platform tests even when Linux is the development host. + +## Parallelism + +sequential + +## Inputs + +[Requirements](../../specs/system-page/requirements/storage-maintenance.md), requirement 003. +[Design](../../specs/system-page/system-design/storage-temporary-folders.md), Read-only reader through Overview contract and Presentation. + +## Results + +Implemented. Added the isolated system temporary-folder provider, bounded tolerant scanning, +overview source/progress/cache wiring, localized resource rendering, and desktop/mobile contracts. +Focused backend and frontend tests, typecheck, and i18n validation passed. Review remediation added +real-scanner coverage for empty and nested regular directories, preserved sampled bytes from an +interrupted tolerant partition, and verified refreshed injected mount tables for new nested mounts +and same-path replacement. The changed backend packages pass 1,139 tests across 9 packages. diff --git a/docs/plans/storage-temporary-folders/task-02-owned-temporary-cleanup-policy.md b/docs/plans/storage-temporary-folders/task-02-owned-temporary-cleanup-policy.md new file mode 100644 index 0000000000..645fc13b49 --- /dev/null +++ b/docs/plans/storage-temporary-folders/task-02-owned-temporary-cleanup-policy.md @@ -0,0 +1,120 @@ +--- +id: "02-owned-temporary-cleanup-policy" +title: "Owned temporary cleanup policy" +status: completed +wave: 2 +depends_on: ["01-temporary-folder-visibility"] +plan: "plan.md" +requirements: + - REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-004 +acceptance_criteria: + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.1 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.2 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.3 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.4 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.5 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.6 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.7 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.8 +system_design: + - ../../specs/system-page/system-design/storage-temporary-folders.md +--- + +# Task 02: Owned temporary cleanup policy + +## Summary + +Allow operators to save an opt-in cleanup policy for registered temporary artifacts. +Keep manual cleanup available and preserve the ownership and quarantine checks. + +## In scope + +- Add the disabled-by-default nested setting and provider settings injection. +- Reconcile ownership per run, reload candidate state, and apply saved retention. +- Reuse explicit cleanup for scheduled/full runs after policy checks. +- Revalidate filesystem identity for every mutation and support a verified staged-copy fallback when + the quarantine destination is on another filesystem. +- Add localized policy controls and accurate quarantine result wording. +- TDD coverage for policy selection, lifecycle protection, recovery, and API compatibility. + +## Out of scope + +Shared-file cleanup, legacy adoption, and configurable age. + +## Acceptance + +- The policy matrix covers disabled, enabled, explicit, full-manual, scheduled, and unrelated-resource runs. +- Every mutation uses fresh ownership and lifecycle evidence, with protected files unchanged in failure tests. +- Saved policy survives reload and uses the existing authorization, dirty-state, busy, and result flows. + +## ASCII UI preview + +UI-02 excerpt, [full preview](plan.md#ascii-ui-preview): + +```text +Temporary artifacts +Clean stale Kandev temporary artifacts [off] +Registered inactive artifacts only. Minimum age: 24 hours. +Files move to quarantine. Shared files are excluded. +[Clean stale artifacts] +Result: moved to quarantine. +``` + +Map to AC-004.7 and .8. Phone labels wrap and the manual action uses full width. +Keep help inline and actions at least 44 pixels on phones. + +## Verification + +Run from repository root after Task 01 installed dependencies. + +```bash +(cd apps/backend && rtk go test ./internal/system/storage/...) +(cd apps/backend && rtk go test ./internal/backendapp -run 'Test(Storage|OverviewCache)') +(cd apps/web && rtk pnpm exec vitest run components/settings/system/storage/storage-policy-card.test.tsx components/settings/system/storage/storage-maintenance-settings.test.tsx components/settings/system/storage/storage-run-history.test.tsx) +(cd apps/web && rtk pnpm run typecheck) +(cd apps/web && rtk pnpm run i18n:zh-hant) +(cd apps/web && rtk pnpm run i18n:check) +(cd apps/web && rtk pnpm run i18n:ratchet) +``` + +Task 03 owns rendered browser evidence. + +## Files likely touched + +- `apps/backend/internal/system/storage/types.go`, `settings.go`, and tests. +- `apps/backend/internal/system/storage/tempartifacts/provider.go`, `registry.go`, and tests. +- `apps/backend/internal/backendapp/storage_maintenance.go` and tests. +- `apps/backend/internal/system/storage/operations_test.go` and `scheduler_test.go`. +- `apps/web/lib/types/system.ts` and `hooks/domains/system/use-storage-maintenance.ts`. +- `apps/web/components/settings/system/storage/storage-policy-card.tsx`, `storage-maintenance-settings.tsx`, `storage-run-history.tsx`, and tests. +- `apps/web/src/locales/`. + +## Dependencies + +Task 01. Preserve its informational source and broad read-only boundary. + +## Risks + +A settings or filesystem snapshot can become stale before mutation; the provider revalidates both +the captured run settings boundary and the artifact identity before each mutation. +The compatibility result field must not cause the UI to describe moved bytes as freed space. + +## Parallelism + +sequential + +## Inputs + +[Requirements](../../specs/system-page/requirements/storage-maintenance.md), requirement 004. +[Design](../../specs/system-page/system-design/storage-temporary-folders.md), Cleanup policy through Presentation. +Existing `tempartifacts` tests and storage settings patch tests. + +## Results + +Implemented. Added the disabled-by-default saved policy, scheduled/full-run opt-in, explicit +cleanup bypass, captured-run settings and retention, fresh lifecycle and ownership revalidation +before mutation, filesystem identity protection, a verified staged-copy fallback for cross-device +quarantine, localized policy controls, and moved-versus-freed result wording. Backend and focused +frontend tests passed. Review remediation now reconciles registry owner liveness before every +scheduled candidate selection; the owner-death and repeated scheduled-cleanup regression passes +without restarting the service. diff --git a/docs/plans/storage-temporary-folders/task-03-temporary-storage-flow-evidence.md b/docs/plans/storage-temporary-folders/task-03-temporary-storage-flow-evidence.md new file mode 100644 index 0000000000..7e6b944f7a --- /dev/null +++ b/docs/plans/storage-temporary-folders/task-03-temporary-storage-flow-evidence.md @@ -0,0 +1,123 @@ +--- +id: "03-temporary-storage-flow-evidence" +title: "Temporary storage flow evidence" +status: completed +wave: 3 +depends_on: ["02-owned-temporary-cleanup-policy"] +plan: "plan.md" +requirements: + - REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003 + - REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-004 +acceptance_criteria: + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.1 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.3 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.4 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.5 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.6 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.8 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.9 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.1 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.2 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.3 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.7 + - AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.8 +system_design: + - ../../specs/system-page/system-design/storage-temporary-folders.md +--- + +# Task 03: Temporary storage flow evidence + +## Summary + +Prove desktop and phone behavior against isolated data. +Reconcile current specifications and public operations guidance with the implemented behavior. + +## In scope + +- Desktop and phone analysis, refresh, error, policy-save, explicit-cleanup, and result scenarios. +- Fixture-only roots for real backend scans and deterministic registered-artifact fixtures for cleanup. +- Public operations documentation and current-design/manual-only wording. +- Final requirement, design, ADR, and package status reconciliation after evidence passes. + +## Out of scope + +Production cleanup, new provider behavior, broad QA, and unrelated UI changes. + +## Acceptance + +- Both viewports complete the same operator flows, including disabled schedule and manual cleanup. +- An Analyze refresh observes added disposable temp data without changing it. +- Documentation describes actual scope and limits, with all required commands recorded. + +## ASCII UI preview + +UI-01/UI-02 completion states, [full preview](plan.md#ascii-ui-preview): + +```text +System temporary folders Partial +Some entries could not be measured. +[Analyze] + +Temporary artifacts [on] +[Clean stale artifacts] + moved to quarantine. +``` + +Match the phone wrapping, one-scroll layout, and touch geometry in the plan. +Inspect a rendered phone screenshot, not only element visibility. + +## Verification + +Run sequentially from repository root. +The guarded E2E runner rebuilds the web application and backend. + +```bash +(cd apps/web && rtk pnpm e2e:run --host --project chromium -- tests/system/storage-temporary-folders.spec.ts) +(cd apps/web && rtk pnpm e2e:run --host --project mobile-chrome -- tests/system/mobile-storage-temporary-folders.spec.ts) +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +rtk python3 scripts/lint-spec-files.test.py +rtk python3 scripts/lint-spec-files.py --all +rtk git diff --check +``` + +Keep all setup and teardown inside disposable fixture ownership. +A controlled response can prove rendering but cannot substitute for backend cleanup safety evidence. + +## Files likely touched + +- New `apps/web/e2e/tests/system/storage-temporary-folders.spec.ts`. +- New `apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts`. +- `apps/web/e2e/helpers/storage-maintenance.ts` and fixture setup for injected temp roots. +- `docs/public/operations.md`, principally its temporary-artifact explanation. +- `docs/specs/system-page/system-design/storage-maintenance-01.md`, `storage-maintenance-02.md`, `storage-maintenance-03.md`. +- The paired requirements, new design, proposed ADR, decision index, and this package. +- Companion plan manifests named in `plan.md`, adding successor links only. + +## Dependencies + +Tasks 01 and 02. + +## Risks + +Do not point browser fixtures at the host temporary folder. +Only advertise scheduled cleanup after backend policy tests and browser persistence checks pass. + +## Parallelism + +sequential + +## Inputs + +[Requirements](../../specs/system-page/requirements/storage-maintenance.md), extensions 003 and 004. +[Design](../../specs/system-page/system-design/storage-temporary-folders.md). +The mobile storage and database-footprint E2E patterns. +Use the repository e2e, mobile-parity, and docs-maintainer skills during execution. + +## Results + +Implemented. Added isolated desktop and mobile browser coverage, reconciled the current designs, +accepted the storage visibility policy decision, and updated public operations guidance. Chromium +temporary-storage E2E passed with 3 desktop and 2 mobile tests; documentation and specification +validation passed. The final backend remediation suite also passed 1,139 tests across 9 +packages; the repository-wide backend command retains its documented unrelated existing failures. diff --git a/docs/public/operations.md b/docs/public/operations.md index cf6275656c..f82348cfad 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -296,16 +296,27 @@ Do not enable those rules on a daemon shared with unrelated workloads. ![Settings > System > Storage showing Docker cleanup controls, cache retention, unused image cleanup, and quarantine safety.](../screenshots/system-docker-cleanup.png) -The Storage page also reports **Kandev temporary artifacts** created by services that need a -short-lived directory under the host temporary root. Each current artifact is registered in the -Kandev database and carries an owner-only marker in its exact directory. Active artifacts and -artifacts created within the last 24 hours are protected. **Clean stale artifacts** is a manual-only -action: it moves eligible, registered artifacts into Kandev quarantine on the same filesystem so -they can be restored during the configured retention period. It does not permanently delete them. +The Storage page also reports **System temporary folders** as a read-only footprint. It measures the +service's effective temporary folder and, on Unix, `/tmp` when it resolves to a distinct folder. +Resolved roots, measured size, and partial or unavailable status are shown. This footprint is +informational and can overlap counted categories, so it is excluded from **Total counted**. It has +no cleanup action and does not claim ownership of any path. + +The page separately reports **Kandev temporary artifacts** created by services that need a short-lived +directory under the host temporary root. Each current artifact is registered in the Kandev database +and carries an owner-only marker in its exact directory. Active artifacts and artifacts created +within the last 24 hours are protected. The **Clean stale Kandev temporary artifacts** policy is off +by default. When enabled, scheduled maintenance and a full **Run now** may move eligible, registered +artifacts into Kandev quarantine. Kandev uses a same-filesystem rename when possible. If the +quarantine is on another filesystem, it stages and verifies a copy before publishing it and removes +the original only after revalidating its identity. An explicit **Clean stale artifacts** action is +available even when the policy is off. Quarantine entries can be restored during the configured +retention period; this action does not permanently delete them or immediately free disk space. The action does not inspect or claim arbitrary `/tmp` entries, shared caches, Node or Playwright -caches, preview/CI/dev-harness directories, or temporary data belonging to another Kandev -installation. A missing registry or failed measurement is shown as unavailable rather than as -zero usage. The inherited `TMPDIR`, `TMP`, and `TEMP` behavior below is unchanged. +caches, preview/CI/dev-harness directories, temporary data belonging to another Kandev +installation, or legacy `/tmp/kandev-agent/*` directories. A missing registry or failed measurement +is shown as unavailable rather than as zero usage. The inherited `TMPDIR`, `TMP`, and `TEMP` +behavior below is unchanged. Host-local agents inherit the Kandev service's `TMPDIR`, `TMP`, and `TEMP` values unchanged. If the service leaves them unset, agent tools use their normal operating-system defaults; if an operator diff --git a/docs/specs/system-page/requirements/storage-maintenance.md b/docs/specs/system-page/requirements/storage-maintenance.md index e5cfa57e26..c8d1bf1fe9 100644 --- a/docs/specs/system-page/requirements/storage-maintenance.md +++ b/docs/specs/system-page/requirements/storage-maintenance.md @@ -2,7 +2,7 @@ status: active system: system-page created: 2026-07-14 -updated: 2026-09-10 +updated: 2026-09-12 owners: - cfl --- @@ -73,8 +73,53 @@ Remote database sizing, additional cleanup controls, backup retention changes, d host-wide reconciliation, and additional categories such as logs are outside this extension. The existing application size-unit convention remains unchanged. +### REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003: System temporary storage visibility + +**Status:** Active. + +**Intent:** Operators can inspect shared temporary storage without granting cleanup ownership. + +#### Acceptance criteria + +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.1:** Analysis shall measure the service temporary folder and, on Unix, `/tmp` when it resolves to a distinct folder. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.2:** Each folder shall show its resolved path, measured size, and completeness. Aliases and nested roots shall not increase the combined measurement twice. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.3:** The temporary-folder measurement shall appear as an informational footprint outside Total counted. Its explanation shall identify overlap with classified resources. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.4:** Registered Kandev artifacts shall retain their separate classification and existing contribution to Total counted. Shared and legacy files shall not appear as reclaimable Kandev artifacts. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.5:** Analysis shall join existing scan progress, caching, and Analyze refresh. Slow temporary-folder scans shall not delay policy, history, or completed sources. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.6:** Unreadable, disappearing, or unscanned entries shall produce visible partial results. An unavailable folder shall not appear as measured zero. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.7:** Analysis shall not read file contents, follow nested symlinks, cross nested mounts, change ownership, or remove files. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.8:** Desktop and phone users shall inspect sizes, long paths, and limitations without horizontal page scrolling. Copy shall use the selected language. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.9:** Existing storage access restrictions shall apply. Analysis requests shall not accept arbitrary paths from clients. + +### REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-004: Scheduled cleanup of owned temporary artifacts + +**Status:** Active. + +**Intent:** Operators can include verified Kandev artifacts in maintenance while shared files remain protected. + +#### Acceptance criteria + +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.1:** A persisted temporary-artifact cleanup option shall default to disabled, including after upgrade. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.2:** When the option is enabled, scheduled and full manual maintenance shall consider registered artifacts. Scheduling shall still require the global schedule option. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.3:** Explicit temporary-artifact cleanup shall remain available with the option disabled. Other resource-specific actions shall not clean temporary artifacts. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.4:** Cleanup shall require verified ownership, an inactive lifecycle, and at least 24 hours since closure or abandonment. Uncertain ownership or liveness shall protect the artifact. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.5:** Cleanup shall revalidate eligibility at mutation time. Busy overrides shall never bypass ownership, liveness, age, or path checks. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.6:** Eligible artifacts shall enter recoverable quarantine. A same-filesystem move shall use rename; a cross-filesystem move shall use a staged, verified copy under quarantine, publish it atomically, and remove the original only after source identity is revalidated. Copy, publication, or identity failures shall leave the original intact and report a failure. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.7:** Results shall distinguish quarantined bytes from freed space. Existing retention, restore, cancellation, and run-history behavior shall remain available. +- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-004.8:** Desktop and phone users shall inspect cleanup scope, save the option, run explicit cleanup, and inspect its result. Existing mutation restrictions shall apply. + +#### Exclusions + +Shared-file deletion, legacy-directory adoption, arbitrary cleanup paths, configurable stale age, +new artifact producers, remote temporary folders, and changes to inherited agent temporary variables +are outside this extension. Whole-host disk reconciliation remains outside storage analysis. + ## System design +The implemented temporary-storage extension is defined in +[Temporary storage visibility and cleanup](../system-design/storage-temporary-folders.md). +Its [implementation package](../../../plans/storage-temporary-folders/plan.md) contains completed work orders. + The draft database extension is defined in [Database storage analysis](../system-design/storage-database-footprint.md). The migrated technical source is split into [part 1](../system-design/storage-maintenance-01.md), [part 2](../system-design/storage-maintenance-02.md), [part 3](../system-design/storage-maintenance-03.md). diff --git a/docs/specs/system-page/system-design/storage-maintenance-01.md b/docs/specs/system-page/system-design/storage-maintenance-01.md index 2a2e4353b3..9cc7d94164 100644 --- a/docs/specs/system-page/system-design/storage-maintenance-01.md +++ b/docs/specs/system-page/system-design/storage-maintenance-01.md @@ -111,9 +111,10 @@ mtime as sufficient evidence. are closed or abandoned, have no recent owner lease/heartbeat, and are older than a fixed 24-hour stale interval. The action uses an explicit `temporary_artifacts` selection through the existing activity gate and a reversible confirmation. -- Temporary-artifact cleanup is manual-only in the first release. Scheduled maintenance and an - unscoped full **Run now** never delete this resource, and `storage_maintenance` gains no temp - toggle. A future scheduled opt-in requires a separate operational decision. +- Temporary-artifact cleanup is disabled by default. When the saved temporary-artifact policy is + enabled, scheduled maintenance and an unscoped full **Run now** also consider this provider. + Explicit `temporary_artifacts` cleanup remains available independently of the option. The + provider still moves eligible roots to quarantine and never claims shared or legacy files. - Manual **Analyze** is read-only, does not require the idle gate, and never changes files, containers, images, caches, or database rows. - Each cleanup resource has its own enablement and threshold. The initial defaults are: @@ -281,8 +282,10 @@ Decision: [ADR-2026-08-08-owned-temp-artifact-cleanup](../../../decisions/2026-0 as ownership. - Explicit cleanup atomically renames an eligible root on the same filesystem into `/trash/temporary-artifacts/` and records a `temporary_artifact` quarantine entry. - A cross-device rename has no copy/delete fallback. Restore is allowed while the original path is - free; permanent deletion follows the existing quarantine retention and path-safety rules. + When the destination is on another filesystem, Kandev stages and verifies a copy under quarantine, + publishes it atomically, and removes the original only after revalidating its identity. Restore is + allowed while the original path is free; permanent deletion follows the existing quarantine + retention and path-safety rules. - Existing unmarked `/tmp/kandev-agent/*` directories and arbitrary shared temp files remain host policy data even when their names resemble a registered prefix. diff --git a/docs/specs/system-page/system-design/storage-maintenance-02.md b/docs/specs/system-page/system-design/storage-maintenance-02.md index d25a2b975e..dfaf979ab8 100644 --- a/docs/specs/system-page/system-design/storage-maintenance-02.md +++ b/docs/specs/system-page/system-design/storage-maintenance-02.md @@ -198,11 +198,12 @@ registered temporary-artifact provider is available. API responses never expose values. The existing `POST /storage/run` resource selection accepts `temporary_artifacts` in addition to the -existing provider names. It is an explicit-only selection: an empty `resources` list and scheduled -maintenance invoke the provider's no-op path and never move temporary roots. The temporary-artifact -summary contains `total_bytes`, `active_bytes`, `protected_bytes`, `stale_bytes`, `total_count`, -`active_count`, `protected_count`, `stale_count`, `skipped_count`, `available`, and an optional -`warnings` array. +existing provider names. An explicit selection always invokes cleanup independently of the saved +option. An empty `resources` list and scheduled maintenance invoke the provider's no-op path when +the option is disabled; when it is enabled, they use the same eligibility and quarantine path. The +temporary-artifact summary contains `total_bytes`, `active_bytes`, `protected_bytes`, `stale_bytes`, +`total_count`, `active_count`, `protected_count`, `stale_count`, `skipped_count`, `available`, and an +optional `warnings` array. `GET /storage/settings` is the lightweight policy-read contract. It reads persisted settings and capabilities without requesting an overview snapshot or invoking filesystem, Go-cache, quarantine, @@ -306,8 +307,8 @@ quarantined -> restored ```text active -> closed producer releases the lease normally -> abandoned restart/reconciliation finds no live lease -closed -> quarantined explicit temporary_artifacts cleanup -abandoned -> quarantined explicit cleanup after the stale interval +closed -> quarantined enabled scheduled/full or explicit cleanup +abandoned -> quarantined enabled scheduled/full or explicit cleanup after the stale interval quarantined -> restored|deleted|failed failed -> quarantined|deleted ``` @@ -350,8 +351,10 @@ distinct `DELETE ALL NOW` confirmation because it removes the configured restore - A temporary-artifact registry read, marker read, lease/heartbeat check, or ownership/path validation failure keeps that root in place and records a skipped warning. There is no fallback to prefix, mtime, or process-name classification. -- A temporary-artifact rename failure, including a cross-device rename, leaves the original root - untouched. The provider never copies and then deletes a root as a quarantine fallback. +- A temporary-artifact rename failure leaves the original root untouched. For a cross-device rename, + the provider copies only verified directories and regular files into a temporary quarantine sibling, + syncs and atomically publishes that copy, then removes the original only after another identity + validation. Copy, publication, or validation failures preserve the original root. - A backend crash after a temporary-artifact rename but before the lifecycle or quarantine state update is reconciled from the durable row, matching marker, and trash path. An unmatched path is retained rather than guessed into a quarantine entry. diff --git a/docs/specs/system-page/system-design/storage-maintenance-03.md b/docs/specs/system-page/system-design/storage-maintenance-03.md index 622398e0a0..8367754019 100644 --- a/docs/specs/system-page/system-design/storage-maintenance-03.md +++ b/docs/specs/system-page/system-design/storage-maintenance-03.md @@ -22,6 +22,9 @@ This design preserves the technical source detail for `REQ-SYSTEM-PAGE-STORAGE-M ## Migrated source detail +The [temporary storage visibility and cleanup design](storage-temporary-folders.md) supersedes the +temporary-storage passages below where its implemented policy differs. + ## Scenarios - **GIVEN** scheduled maintenance has never been configured, **WHEN** Kandev starts as a systemd @@ -132,13 +135,19 @@ This design preserves the technical source detail for `REQ-SYSTEM-PAGE-STORAGE-M it is neither counted as owned nor modified. - **GIVEN** a stale registered artifact and no current activity blocker, **WHEN** the user confirms **Clean stale artifacts**, **THEN** the request uses `resources: ["temporary_artifacts"]`, the root - moves by same-filesystem rename into quarantine, and the result appears in Quarantine. -- **GIVEN** the user selects unscoped **Run now** or scheduled maintenance, **WHEN** a stale - registered temporary artifact exists, **THEN** the artifact remains in place because this provider - is explicit-only. -- **GIVEN** a registered artifact has a missing/mismatched marker, a symlinked root, a path escape, - or a cross-device quarantine destination, **WHEN** analysis or cleanup runs, **THEN** Kandev - reports the safety warning and leaves the original path unchanged. + moves into quarantine by validated same-filesystem rename or the safe cross-filesystem staging + path, and the result appears in Quarantine. +- **GIVEN** the temporary-artifact policy is disabled, **WHEN** the user selects unscoped **Run now** + or scheduled maintenance, **THEN** a stale registered temporary artifact remains in place. +- **GIVEN** the temporary-artifact policy is enabled, **WHEN** unscoped **Run now** or scheduled + maintenance runs, **THEN** an eligible stale registered artifact uses the same validated + quarantine path as explicit cleanup. +- **GIVEN** a registered artifact has a missing/mismatched marker, a symlinked root, or a path escape, + **WHEN** analysis or cleanup runs, **THEN** Kandev reports the safety warning and leaves the + original path unchanged. +- **GIVEN** a registered artifact has a cross-device quarantine destination, **WHEN** cleanup runs, + **THEN** Kandev stages and verifies a copy under quarantine, publishes it atomically, and removes + the original only after source identity revalidation; any failure leaves the original unchanged. - **GIVEN** a quarantined temporary artifact's original path is free, **WHEN** the user selects **Restore**, **THEN** Kandev restores it through the existing quarantine flow before retention expiry; unrelated quarantine entries are not purged by the resource-specific run. @@ -187,7 +196,7 @@ This design preserves the technical source detail for `REQ-SYSTEM-PAGE-STORAGE-M - A Kandev-owned general-purpose sweeper for the operating system's shared temporary directory. - Cleaning unregistered `kandev-*` roots from another installation or from standalone preview, CI, E2E, or `dev-isolated` harnesses. -- A persisted temporary-artifact threshold or scheduled cleanup toggle in the first release. +- A persisted temporary-artifact age threshold or a general shared temporary cleanup policy. - Guaranteed compatibility with tools that require a fixed, globally unique name in shared temp; those tools need a scoped path override when a real collision is observed. diff --git a/docs/specs/system-page/system-design/storage-temporary-folders.md b/docs/specs/system-page/system-design/storage-temporary-folders.md new file mode 100644 index 0000000000..0c83c09e34 --- /dev/null +++ b/docs/specs/system-page/system-design/storage-temporary-folders.md @@ -0,0 +1,175 @@ +--- +status: current +system: system-page +requirements: + - REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003 + - REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-004 +created: 2026-09-11 +owners: + - kandev +--- + +# Temporary storage visibility and cleanup + +## Purpose and status + +System-page owns install-wide storage inventory, maintenance settings, and quarantine. +This extension adds a broad read-only footprint and an optional policy for existing owned artifacts. +It does not turn discovered paths into cleanup candidates. + +This design is implemented. System temporary folders are measured read-only, and the general +footprint is informational. Registered-artifact cleanup remains disabled by default; an enabled +saved policy permits scheduled and full manual maintenance to quarantine eligible artifacts. +The [policy decision](../../../decisions/2026-09-11-temporary-storage-visibility-policy.md) +records the amendment to the existing ownership decision. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003` | Read-only reader, Overview contract, Presentation | +| `REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-004` | Cleanup policy, Mutation safety, Presentation | + +## Read-only reader + +Add `internal/system/storage/tempstore`, separate from `tempartifacts`. +Backend composition supplies `os.TempDir()` and the Unix `/tmp` candidate. +Windows supplies only its effective service temporary folder. +No HTTP parameter, environment dump, or user-supplied path enters this reader. + +Resolve configured root aliases once. Preserve requested and resolved paths for display. +Deduplicate canonical roots and collapse nested candidates into one scan root. +Reject a filesystem root as an effective temporary folder with `unsafe_root`. +Unreadable roots remain unavailable. A missing optional Unix `/tmp` is not applicable. +A missing effective temporary root is unavailable. + +Use file lengths, matching current storage measurements. Do not read contents. +Reuse the process-wide four-partition `filescan.Limiter` from backend composition. +Extend its read-only options for tolerant per-entry errors and mount exclusion. +Existing strict callers retain their behavior. Do not create another worker pool. + +Read only regular-file metadata. Skip symlinks, sockets, devices, and named pipes. +Reject root replacement after resolution. Never traverse a nested symlink target. +Skip nested mounts, including bind mounts, using platform mount identity. +Device identity alone cannot identify same-device bind mounts. +When mount identity cannot be established, mark the affected root unavailable. +The Linux reader uses fresh process mount-table snapshots. It refreshes at directory and partition +boundaries, reuses the boundary snapshot for file entries, and revalidates the root mount identity +before each partition. Other supported platforms use their native mount information. +Keep these platform readers behind injected interfaces for deterministic tests. + +Apply a 60-second deadline to this source, within the existing overview deadline. +Check cancellation between entries and partitions. Bound diagnostic examples to ten paths per root. +Retain counts for all omitted, unreadable, or disappearing entries. +Permission failures, disappearance, mount exclusions, and deadline exhaustion produce partial measurements. +A partial size means successfully sampled bytes only. It is never a final folder total. +File lengths remain a changing sample, not allocated blocks or a transactional snapshot. + +## Overview contract + +Add the proposed source identity `system_temporary` to `storageAnalysisSources`. +Extend `Summary`, `summaryFromMeasurements`, `summaryFromSourceValues`, and cache compatibility mapping. +Add this optional field to the frontend `StorageSummary` and partial summary types. +Do not change the existing `temporary_artifacts` identity or its cleanup resource name. + +The new result contains: + +| Field | Meaning | +| --- | --- | +| `status` | `measured`, `partial`, `unavailable`, or `not_applicable` | +| `roots` | Deduplicated root results with paths, status, and optional `size_bytes` | +| `size_bytes` | Combined sampled bytes, present for measured or partial results | +| `included_in_total` | Always false for this informational footprint | +| `reason` | Stable reason, including `informational_overlap`, `unsafe_root`, or `deadline` | +| `warnings` | Bounded authorized diagnostics and complete omission counts | + +The footprint can overlap workspaces, database files, caches, quarantine, and registered artifacts. +Therefore, it remains outside `storageAnalysisTotal` by construction. +Its label and visible explanation distinguish it from the classified total. +This preserves existing measured-file attribution without inventing a host-wide ownership hierarchy. +Failure of this informational source does not change completeness of the classified total. +It still marks its own source and the scan outcome as partial or failed. + +Publish progress through existing scan revisions and coarse notifications. +Keep paths and measurements in authorized HTTP responses. Events carry no new filesystem data. +Source failure preserves successful sibling results and the last successful snapshot. +Missing fields from an older response remain unknown, never measured zero. +Existing refresh coalescing, stale-result rejection, and 15-minute cache timing remain authoritative. + +## Cleanup policy + +Add `TemporaryArtifacts ResourceSettings` with JSON key `temporary_artifacts` +to `StorageMaintenanceSettings`. The nested `enabled` field defaults to false. +Use the existing settings document, normalization, patch, and save boundaries. +No storage table migration, public environment variable, or release flag is required. +Old documents normalize the absent field to disabled. Updates preserve unrelated saved settings. + +Inject the existing settings reader into `tempartifacts.ProviderConfig`. +`Provider.Cleanup` reads the saved policy and returns a disabled no-op unless enabled. +Enabled cleanup and `CleanupExplicit` share one eligibility and quarantine path. +Explicit cleanup bypasses only this option. It retains every activity and safety check. +The existing global schedule, idle gate, admission control, cancellation, and runner serialization apply. +Policy changes take effect on subsequent runs without restart. + +The runner passes the captured maintenance settings snapshot to the provider. Use that snapshot for +the cleanup option and quarantine retention for the whole run. +Do not retain the provider constructor's default when the operator saved another retention value. +Keep the fixed 24-hour stale interval. Do not add an age editor in this package. + +## Mutation safety + +Reconcile registry ownership before candidate selection on each cleanup run. +Immediately before mutation, reload the exact artifact row and validate lifecycle timestamps and marker identity. +Reject active artifacts, uncertain liveness, missing rows, replaced paths, and mismatched markers. +Serialize lifecycle transitions with mutation through the existing maintenance/ownership boundaries. +When a producer can still mutate a candidate, protect it rather than infer inactivity from file age. +The startup-only reconciliation is insufficient for newly abandoned owners during a long service run. + +Keep same-filesystem rename into `/trash/temporary-artifacts/`. When the source +and quarantine are on different filesystems, stage a copy under the quarantine parent, copy only +real directories and regular files, sync the copied data, revalidate the source identity, and +atomically publish the staging directory. Remove the original only after publication and another +identity check. Any copy, publication, or identity failure leaves the original intact and records a +failed quarantine intent. +Bind the validated filesystem identity to each rename or deletion so a replacement path cannot be +accepted because it has the same marker. +Retain restart reconciliation, failed-intent retry, restore, and permanent deletion behavior. +The new schedule cannot bypass registry validation through `Run anyway`. + +Display quarantined bytes as moved bytes. They are not immediately freed disk space. +Preserve the legacy `reclaimed_bytes` result field for compatibility if required by existing consumers. +Add an explicit `quarantined_bytes` field for new UI copy, or derive it from the successful quarantine entries. +Permanent deletion retains its existing reporting and retention deadline. + +## Presentation + +Add a System temporary folders disclosure beside Kandev temporary artifacts in the analysis section. +Show full folder size and the visible note: `Informational. Can overlap counted categories.` +Expanded content shows resolved roots, partial status, and measurement limitations. +Do not add a cleanup button to the broad-folder row. + +Add a Temporary artifacts policy card with an off-by-default switch. +Its label is `Clean stale Kandev temporary artifacts`. +Visible help states the 24-hour interval, registered scope, quarantine delay, and shared-file exclusion. +Retain the explicit cleanup action with clear scope and busy/result states. +Use the existing dirty-state, save, authorization, and disabled-reason patterns. + +Desktop and phone share the domain hook, policy state, resource model, and action handlers. +The nearest shipped exemplar is the Storage accordion covered by `mobile-storage-maintenance.spec.ts`. +The mobile entry is Settings > Storage. Root details remain inline because they are short reference content. +Keep one page scroll owner. Paths wrap within the resource detail. +Keep required help visible inline. No tooltip or overlay is needed to understand cleanup scope. +Phone controls have at least 44-pixel hit targets. Fine-pointer buttons retain 28-pixel density. +No fixed toolbar or new navigation route is required. +All new copy uses `system` translations in all five supported language catalogs. + +## Verification and delivery + +Use isolated roots and injected mount/liveness failures for backend tests. +Browser fixtures must inject disposable temp roots, never inspect or mutate the host `/tmp`. +Desktop and phone flows cover analysis, refresh, partial results, policy persistence, and explicit cleanup. +Real provider tests prove that cleanup preserves unregistered and active files. + +The [implementation plan](../../../plans/storage-temporary-folders/plan.md) records the completed +work orders and evidence. Completed companion plans remain historical records and link this +successor package. The public operations guidance describes the shipped scope and limits.