From 8e56a3a7f7614b5e26fae1247b8655f933813fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 9 Aug 2026 17:57:47 +0100 Subject: [PATCH] feat(storage): add owned temporary artifact cleanup (#2444) --- .../internal/agent/hostutility/manager.go | 42 +- apps/backend/internal/backendapp/helpers.go | 6 +- .../internal/backendapp/httpserver_test.go | 2 +- apps/backend/internal/backendapp/main.go | 48 +- .../backendapp/storage_maintenance.go | 75 ++- apps/backend/internal/improvekandev/bundle.go | 55 +- .../internal/improvekandev/bundle_test.go | 104 ++- .../backend/internal/improvekandev/handler.go | 17 +- .../internal/system/storage/handler.go | 20 +- apps/backend/internal/system/storage/store.go | 261 +++++++- .../system/storage/store_migrations.go | 17 + .../internal/system/storage/store_test.go | 39 ++ .../tempartifacts/process_alive_unix.go | 19 + .../tempartifacts/process_alive_windows.go | 9 + .../system/storage/tempartifacts/provider.go | 627 ++++++++++++++++++ .../storage/tempartifacts/provider_test.go | 406 ++++++++++++ .../system/storage/tempartifacts/registry.go | 475 +++++++++++++ .../storage/tempartifacts/registry_test.go | 256 +++++++ .../storage-confirmation-dialogs.test.tsx | 13 + .../storage/storage-confirmation-dialogs.tsx | 32 + .../storage-maintenance-settings.test.tsx | 40 ++ .../storage/storage-maintenance-settings.tsx | 22 +- .../storage/storage-overview-card.test.tsx | 61 +- .../system/storage/storage-overview-card.tsx | 96 ++- .../storage/storage-policy-card.test.tsx | 1 + .../storage/storage-quarantine-card.tsx | 1 + .../system/storage/storage-totals.test.ts | 18 +- .../settings/system/storage/storage-totals.ts | 4 + apps/web/e2e/helpers/storage-maintenance.ts | 42 ++ .../system/mobile-storage-maintenance.spec.ts | 56 +- .../tests/system/storage-maintenance.spec.ts | 67 +- .../system/use-storage-maintenance.test.tsx | 13 + .../state/slices/system/system-slice.test.ts | 5 + apps/web/lib/types/system.ts | 19 +- apps/web/src/locales/en/system.json | 15 + apps/web/src/locales/pseudo/system.json | 15 + apps/web/src/locales/pt-pt/system.json | 15 + apps/web/src/locales/zh-cn/system.json | 15 + .../2026-08-08-owned-temp-artifact-cleanup.md | 74 +++ docs/decisions/INDEX.md | 1 + docs/plans/storage-temp-artifacts/plan.md | 181 +++++ .../task-01-temp-ownership-registry.md | 75 +++ .../task-02-temp-artifact-provider-api.md | 76 +++ .../task-03-storage-temp-artifact-ui.md | 81 +++ .../task-04-storage-temp-artifact-e2e.md | 69 ++ .../task-05-storage-temp-artifact-docs.md | 66 ++ docs/public/operations.md | 11 + docs/specs/system-page/storage-maintenance.md | 157 ++++- 48 files changed, 3674 insertions(+), 145 deletions(-) create mode 100644 apps/backend/internal/system/storage/tempartifacts/process_alive_unix.go create mode 100644 apps/backend/internal/system/storage/tempartifacts/process_alive_windows.go create mode 100644 apps/backend/internal/system/storage/tempartifacts/provider.go create mode 100644 apps/backend/internal/system/storage/tempartifacts/provider_test.go create mode 100644 apps/backend/internal/system/storage/tempartifacts/registry.go create mode 100644 apps/backend/internal/system/storage/tempartifacts/registry_test.go create mode 100644 docs/decisions/2026-08-08-owned-temp-artifact-cleanup.md create mode 100644 docs/plans/storage-temp-artifacts/plan.md create mode 100644 docs/plans/storage-temp-artifacts/task-01-temp-ownership-registry.md create mode 100644 docs/plans/storage-temp-artifacts/task-02-temp-artifact-provider-api.md create mode 100644 docs/plans/storage-temp-artifacts/task-03-storage-temp-artifact-ui.md create mode 100644 docs/plans/storage-temp-artifacts/task-04-storage-temp-artifact-e2e.md create mode 100644 docs/plans/storage-temp-artifacts/task-05-storage-temp-artifact-docs.md diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index 4e68f4eec7..ebe09a6f0b 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -20,6 +20,8 @@ import ( agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/system/storage" + "github.com/kandev/kandev/internal/system/storage/tempartifacts" "github.com/kandev/kandev/pkg/agent" ) @@ -42,9 +44,11 @@ type Manager struct { authToken string // per-launch auth token for instance clients log *logger.Logger - parentTmpDir string - cache *cache - modelCache *modelConfigCache + parentTmpDir string + tempArtifacts *tempartifacts.Registry + tempLease *tempartifacts.Lease + cache *cache + modelCache *modelConfigCache mu sync.RWMutex instances map[string]*instance // keyed by agent type @@ -90,6 +94,12 @@ func (m *Manager) SetAuthToken(token string) { m.authToken = token } +func (m *Manager) SetTemporaryArtifactRegistry(registry *tempartifacts.Registry) { + m.mu.Lock() + defer m.mu.Unlock() + m.tempArtifacts = registry +} + // Start boots one warm instance per ACP-capable inference agent and runs an // initial probe against each in parallel. Individual agent failures are // captured in the cache but do not abort the other agents. @@ -116,16 +126,32 @@ func (m *Manager) Start(ctx context.Context) error { if err != nil { return fmt.Errorf("create host utility tmp dir: %w", err) } + m.mu.RLock() + artifactRegistry := m.tempArtifacts + m.mu.RUnlock() + var tempLease *tempartifacts.Lease + if artifactRegistry != nil { + tempLease, err = artifactRegistry.RegisterExisting( + ctx, storage.TemporaryArtifactKindHostUtility, parent, nil, + ) + if err != nil { + _ = os.RemoveAll(parent) + return fmt.Errorf("register host utility tmp dir: %w", err) + } + } m.mu.Lock() if m.stopped { m.mu.Unlock() - if err := os.RemoveAll(parent); err != nil { + if tempLease != nil { + _ = tempLease.Remove(context.Background()) + } else if err := os.RemoveAll(parent); err != nil { m.log.Warn("failed to remove unused host utility parent tmp dir", zap.String("path", parent), zap.Error(err)) } return nil } m.parentTmpDir = parent + m.tempLease = tempLease m.mu.Unlock() m.log.Info("host utility parent tmp dir created", zap.String("path", parent)) @@ -166,6 +192,8 @@ func (m *Manager) Stop(ctx context.Context) { m.instances = make(map[string]*instance) parentTmpDir := m.parentTmpDir m.parentTmpDir = "" + tempLease := m.tempLease + m.tempLease = nil m.mu.Unlock() if cancel != nil { @@ -178,7 +206,11 @@ func (m *Manager) Stop(ctx context.Context) { cancel() } - if parentTmpDir != "" { + if tempLease != nil { + if err := tempLease.Remove(ctx); err != nil { + m.log.Warn("failed to remove host utility parent tmp dir", zap.String("path", parentTmpDir), zap.Error(err)) + } + } else if parentTmpDir != "" { if err := os.RemoveAll(parentTmpDir); err != nil { m.log.Warn("failed to remove host utility parent tmp dir", zap.String("path", parentTmpDir), zap.Error(err)) diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index e23f59703b..0733b919fd 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -79,6 +79,7 @@ import ( spriteshandlers "github.com/kandev/kandev/internal/sprites" sshhandlers "github.com/kandev/kandev/internal/ssh" systemsvc "github.com/kandev/kandev/internal/system" + "github.com/kandev/kandev/internal/system/storage/tempartifacts" taskdto "github.com/kandev/kandev/internal/task/dto" taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/models" @@ -527,6 +528,7 @@ type routeParams struct { services *Services systemSvc *systemsvc.Service workspaceRestorer taskhandlers.WorkspaceQuarantineRestorer + temporaryArtifacts *tempartifacts.Registry runtimeFlagsSvc *runtimeflags.Service dbPool *db.Pool agentSettingsController *agentsettingscontroller.Controller @@ -1218,13 +1220,11 @@ func registerSecondaryRoutes( } } ikHandler := improvekandev.NewHandler(p.taskSvc, p.repoCloner, ghCopier, resolveDefaultWorkspace, p.version, p.log) + ikHandler.SetTemporaryArtifactRegistry(p.temporaryArtifacts) if p.systemSvc != nil { ikHandler.SetLogBundles(p.systemSvc.LogBundles) } improvekandev.RegisterRoutes(p.router, ikHandler) - improvekandev.CleanupStaleBundles(func(path string, err error) { - p.log.Warn("Improve Kandev: failed to clean stale bundle", zap.String("path", path), zap.Error(err)) - }) p.log.Debug("Registered Improve Kandev handlers (HTTP)") } diff --git a/apps/backend/internal/backendapp/httpserver_test.go b/apps/backend/internal/backendapp/httpserver_test.go index 1bbb802ce3..7655169b67 100644 --- a/apps/backend/internal/backendapp/httpserver_test.go +++ b/apps/backend/internal/backendapp/httpserver_test.go @@ -22,7 +22,7 @@ func TestBuildHTTPServerAbortsWhenInterlockTokenGenerationFails(t *testing.T) { newInterimSettingsInterlockToken = func() (string, error) { return "", wantErr } t.Cleanup(func() { newInterimSettingsInterlockToken = original }) - server, err := buildHTTPServer(&config.Config{}, testLogger(t), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + server, err := buildHTTPServer(&config.Config{}, testLogger(t), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) if !errors.Is(err, wantErr) { t.Fatalf("buildHTTPServer error = %v, want %v", err, wantErr) } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 8990c763f0..c0cc607c8a 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -13,6 +13,7 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "sync/atomic" "syscall" "time" @@ -116,6 +117,7 @@ import ( // System pages (status / database / backups / logs / updates / about) systemsvc "github.com/kandev/kandev/internal/system" + "github.com/kandev/kandev/internal/system/storage/tempartifacts" // Database "github.com/kandev/kandev/internal/db" @@ -723,23 +725,6 @@ func startGatewayAndServe( // /api/v1/agent-models/:agentName reads live capability data. agentSettingsController.SetHostUtility(hostUtilityMgr) profileReconciler := agentsettingscontroller.NewProfileReconciler(hostUtilityMgr, agentRegistry, repos.AgentSettings, log) - go func() { - if err := hostUtilityMgr.Start(ctx); err != nil { - log.Warn("host utility manager bootstrap error", zap.Error(err)) - } - // Reconcile profiles against fresh probe results — seeds defaults for - // newly probed agents, heals stale profile models/modes, cleans up - // orphans referencing removed agents. - if err := profileReconciler.Run(ctx); err != nil { - log.Warn("profile reconciler error", zap.Error(err)) - } - }() - addCleanup(func() error { - stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - hostUtilityMgr.Stop(stopCtx) - return nil - }) // Wire Host.InvokeUtilityAgent (ADR 0048): plugins delegate one-shot LLM // calls to the utility agent selected in each plugin's configuration and @@ -820,6 +805,30 @@ func startGatewayAndServe( log.Error("Failed to initialize storage maintenance", zap.Error(err)) return false } + hostUtilityMgr.SetTemporaryArtifactRegistry(storageComposition.tempArtifacts) + hostUtilityCtx, hostUtilityCancel := context.WithCancel(ctx) + var hostUtilityWG sync.WaitGroup + hostUtilityWG.Add(1) + go func() { + defer hostUtilityWG.Done() + if err := hostUtilityMgr.Start(hostUtilityCtx); err != nil { + log.Warn("host utility manager bootstrap error", zap.Error(err)) + } + // Reconcile profiles against fresh probe results — seeds defaults for + // newly probed agents, heals stale profile models/modes, cleans up + // orphans referencing removed agents. + if err := profileReconciler.Run(hostUtilityCtx); err != nil { + log.Warn("profile reconciler error", zap.Error(err)) + } + }() + addCleanup(func() error { + hostUtilityCancel() + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + hostUtilityMgr.Stop(stopCtx) + hostUtilityWG.Wait() + return nil + }) systemSvc.Storage = storageComposition.handler systemSvc.StorageRuntime = storageComposition.runtime if systemSvc.LogBundles != nil { @@ -859,7 +868,8 @@ func startGatewayAndServe( // ============================================ server, err := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, - addCleanup, repoCloner, systemSvc, storageComposition.workspaceRestorer, dbPool, agentRuntimeAvailability) + addCleanup, repoCloner, systemSvc, storageComposition.workspaceRestorer, + storageComposition.tempArtifacts, dbPool, agentRuntimeAvailability) if err != nil { log.Error("Failed to build HTTP server", zap.Error(err)) return false @@ -1760,6 +1770,7 @@ func buildHTTPServer( repoCloner *repoclone.Cloner, systemSvc *systemsvc.Service, workspaceRestorer taskhandlers.WorkspaceQuarantineRestorer, + temporaryArtifacts *tempartifacts.Registry, dbPool *db.Pool, agentRuntimeAvailability *agentctlclient.Availability, ) (*http.Server, error) { @@ -1823,6 +1834,7 @@ func buildHTTPServer( services: services, systemSvc: systemSvc, workspaceRestorer: workspaceRestorer, + temporaryArtifacts: temporaryArtifacts, runtimeFlagsSvc: services.RuntimeFlags, dbPool: dbPool, agentSettingsController: agentSettingsController, diff --git a/apps/backend/internal/backendapp/storage_maintenance.go b/apps/backend/internal/backendapp/storage_maintenance.go index cd713e2013..b56735d216 100644 --- a/apps/backend/internal/backendapp/storage_maintenance.go +++ b/apps/backend/internal/backendapp/storage_maintenance.go @@ -21,6 +21,7 @@ import ( storagepkg "github.com/kandev/kandev/internal/system/storage" "github.com/kandev/kandev/internal/system/storage/dockerstore" "github.com/kandev/kandev/internal/system/storage/gocache" + "github.com/kandev/kandev/internal/system/storage/tempartifacts" "github.com/kandev/kandev/internal/system/storage/workspaces" taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/worktree" @@ -30,6 +31,7 @@ type storageComposition struct { handler *storagepkg.Handler runtime *storagepkg.Runtime workspaceRestorer *workspaceQuarantineController + tempArtifacts *tempartifacts.Registry } func provideStorageComposition( @@ -51,6 +53,19 @@ func provideStorageComposition( if err != nil { return nil, fmt.Errorf("initialize storage store: %w", err) } + tempArtifacts := tempartifacts.NewRegistry(tempartifacts.Config{ + Store: store, TempRoot: os.TempDir(), + }) + if err := tempArtifacts.Reconcile(context.Background()); err != nil { + logError("reconcile temporary artifact registry", err) + } + tempProvider := tempartifacts.NewProvider(tempartifacts.ProviderConfig{ + Registry: tempArtifacts, Store: store, HomeDir: cfg.ResolvedHomeDir(), + TrashDir: filepath.Join(cfg.ResolvedHomeDir(), "trash"), + }) + if err := tempProvider.Reconcile(context.Background()); err != nil { + logError("reconcile temporary artifact quarantine", err) + } coordinator := activity.NewCoordinator(activity.Options{}) taskSvc.SetTaskResourceCleanupActivityGate(&taskCleanupActivityGate{coordinator: coordinator}) goCache := gocache.New(gocache.Config{ @@ -72,14 +87,14 @@ func provideStorageComposition( overview := &storageOverview{ settings: settings, quarantine: store, workspaceFactory: workspaceFactory, goCache: goCache, docker: dockerProvider, dockerClient: dockerClient, dockerHost: cfg.Docker.Host, - homeDir: cfg.ResolvedHomeDir(), + homeDir: cfg.ResolvedHomeDir(), tempArtifacts: tempProvider, } cachedOverview := storagepkg.NewOverviewCache(overview) quarantine := &workspaceQuarantineController{ settings: settings, store: store, factory: workspaceFactory, homeDir: cfg.ResolvedHomeDir(), - activity: coordinator, + activity: coordinator, temporary: tempProvider, } - providers := storageCleanupProviders(settings, workspaceFactory, goCache, dockerProvider, quarantine) + providers := storageCleanupProviders(settings, workspaceFactory, goCache, dockerProvider, quarantine, tempProvider) if taskSvc.AttachmentService() == nil && taskSvc.AttachmentRepository() != nil { attachmentSvc, attachmentErr := taskservice.NewAttachmentService( taskSvc.AttachmentRepository(), cfg.ResolvedHomeDir(), taskSvc.AuthorizeWorkspaceAccess, log, @@ -123,7 +138,7 @@ func provideStorageComposition( Mutations: operations, OnSettingsChanged: runtime.ApplySettings, LogError: logError, }) return &storageComposition{ - handler: handler, runtime: runtime, workspaceRestorer: quarantine, + handler: handler, runtime: runtime, workspaceRestorer: quarantine, tempArtifacts: tempArtifacts, }, nil } @@ -179,6 +194,7 @@ type storageOverview struct { goCache *gocache.Provider goCacheAnalyze func(context.Context) (gocache.Analysis, error) docker *dockerstore.Provider + tempArtifacts *tempartifacts.Provider dockerClient *lazyStorageDocker dockerHost string homeDir string @@ -197,6 +213,8 @@ func (o *storageOverview) Summary(ctx context.Context) (storagepkg.Summary, erro quarantineSummary storagepkg.QuarantineSummary quarantineErr error dockerSummary dockerstore.Analysis + tempSummary tempartifacts.Analysis + tempErr error ) var measurements sync.WaitGroup measurements.Add(4) @@ -226,11 +244,19 @@ func (o *storageOverview) Summary(ctx context.Context) (storagepkg.Summary, erro defer measurements.Done() dockerSummary = o.docker.Analyze(ctx) }() + if o.tempArtifacts != nil { + measurements.Add(1) + go func() { + defer measurements.Done() + tempSummary, tempErr = o.tempArtifacts.Analyze(ctx) + }() + } measurements.Wait() return storagepkg.Summary{ - Workspaces: summaryValue(workspaceSummary, workspaceErr), - GoCache: summaryValue(goCacheSummary, goCacheErr), - Quarantine: summaryValue(quarantineSummary, quarantineErr), + Workspaces: summaryValue(workspaceSummary, workspaceErr), + GoCache: summaryValue(goCacheSummary, goCacheErr), + Quarantine: summaryValue(quarantineSummary, quarantineErr), + TemporaryArtifacts: summaryValue(tempSummary, tempErr), Docker: map[string]any{ "available": dockerSummary.Available, "build_cache_bytes": dockerSummary.BuildCacheBytes, "image_layer_bytes": dockerSummary.ImageLayerBytes, @@ -259,7 +285,8 @@ func (o *storageOverview) SettingsCapabilities( dockerAvailable := o.dockerClient != nil && o.dockerClient.Ping(ctx) == nil return storagepkg.Capabilities{ ManagedGoCachePath: goPath, GoCacheAdoptionAvailable: true, - DockerAvailable: dockerAvailable, DockerHost: o.dockerHost, + TemporaryArtifactsAvailable: o.tempArtifacts != nil, + DockerAvailable: dockerAvailable, DockerHost: o.dockerHost, HostGlobalDockerCleanup: dockerAvailable && settings.Docker.DedicatedDaemonAcknowledged, } } @@ -316,8 +343,9 @@ func storageCleanupProviders( goCache *gocache.Provider, docker *dockerstore.Provider, quarantine quarantinePurger, + temporary ...storagepkg.CleanupProvider, ) []storagepkg.CleanupProvider { - return []storagepkg.CleanupProvider{ + providers := []storagepkg.CleanupProvider{ quarantineCleanupProvider{purger: quarantine}, workspaceCleanupAdapter(settings, workspaceFactory), workspaceDependencyCleanupAdapter(settings, workspaceFactory), @@ -326,6 +354,7 @@ func storageCleanupProviders( dockerBuildCacheCleanupAdapter(settings, docker), dockerImageCleanupAdapter(settings, docker), } + return append(providers, temporary...) } func workspaceDependencyCleanupAdapter( @@ -419,12 +448,13 @@ func (r *workspaceReconciler) Reconcile(ctx context.Context) error { } type workspaceQuarantineController struct { - settings *storagepkg.SettingsStore - store quarantineEntryStore - factory workspaceFactory - homeDir string - activity *activity.Coordinator - rename func(string, string) error + settings *storagepkg.SettingsStore + store quarantineEntryStore + factory workspaceFactory + homeDir string + activity *activity.Coordinator + temporary *tempartifacts.Provider + rename func(string, string) error } type quarantineEntryStore interface { @@ -517,6 +547,12 @@ func (c *workspaceQuarantineController) Restore( if entry.ResourceType == storagepkg.ResourceTypeGoCache { return c.restoreGoCache(ctx, entry) } + if entry.ResourceType == storagepkg.ResourceTypeTemporaryArtifact { + if c.temporary == nil { + return storagepkg.QuarantineEntry{}, errors.New("temporary artifact provider is unavailable") + } + return c.temporary.Restore(ctx, id) + } if entry.ResourceType != storagepkg.ResourceTypeTaskWorkspace { return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: unsupported quarantine resource %q", storagepkg.ErrValidation, entry.ResourceType) } @@ -563,6 +599,15 @@ func (c *workspaceQuarantineController) permanentDelete( } return c.deleteGoCache(ctx, entry, confirmation) } + if entry.ResourceType == storagepkg.ResourceTypeTemporaryArtifact { + if c.temporary == nil { + return storagepkg.QuarantineEntry{}, errors.New("temporary artifact provider is unavailable") + } + if force { + return c.temporary.PermanentDeleteForce(ctx, id, confirmation) + } + return c.temporary.PermanentDelete(ctx, id, confirmation) + } if entry.ResourceType != storagepkg.ResourceTypeTaskWorkspace { return storagepkg.QuarantineEntry{}, fmt.Errorf("%w: unsupported quarantine resource %q", storagepkg.ErrValidation, entry.ResourceType) } diff --git a/apps/backend/internal/improvekandev/bundle.go b/apps/backend/internal/improvekandev/bundle.go index f6d042fef0..584ce2215f 100644 --- a/apps/backend/internal/improvekandev/bundle.go +++ b/apps/backend/internal/improvekandev/bundle.go @@ -4,6 +4,7 @@ package improvekandev import ( + "context" "encoding/json" "errors" "fmt" @@ -11,13 +12,16 @@ import ( "path/filepath" "strings" "time" + + "github.com/kandev/kandev/internal/system/storage" + "github.com/kandev/kandev/internal/system/storage/tempartifacts" ) const ( bundlePrefix = "kandev-improve-" ownerMarkerName = ".kandev-owner.json" diagnosticFileName = "diagnostic-bundle.zip" - staleBundleAge = 24 * time.Hour + bundleLeaseAge = 24 * time.Hour ) type ownerMarker struct { @@ -50,40 +54,27 @@ func createBundleDir(owner string) (string, error) { return dir, nil } -// CleanupStaleBundles removes old server-owned improve-kandev context -// directories. Fresh directories are preserved for in-flight task creation. -func CleanupStaleBundles(onError func(path string, err error)) { - cleanupStaleBundlesIn(os.TempDir(), staleBundleAge, onError) -} - -func cleanupStaleBundlesIn(tempDir string, maxAge time.Duration, onError func(path string, err error)) { - entries, err := os.ReadDir(tempDir) +func createBundleDirWithRegistry( + ctx context.Context, + owner string, + registry *tempartifacts.Registry, +) (string, error) { + dir, err := createBundleDir(owner) if err != nil { - if onError != nil { - onError(tempDir, err) - } - return + return "", err } - cutoff := time.Now().Add(-maxAge) - for _, entry := range entries { - if !entry.IsDir() || !strings.HasPrefix(entry.Name(), bundlePrefix) { - continue - } - path := filepath.Join(tempDir, entry.Name()) - info, err := entry.Info() - if err != nil { - if onError != nil { - onError(path, err) - } - continue - } - if info.ModTime().After(cutoff) { - continue - } - if err := os.RemoveAll(path); err != nil && onError != nil { - onError(path, err) - } + lease, err := registry.RegisterExisting( + ctx, storage.TemporaryArtifactKindImproveBundle, dir, nil, + ) + if err != nil { + _ = os.RemoveAll(dir) + return "", err } + if err := lease.Close(ctx); err != nil { + _ = lease.Remove(context.Background()) + return "", fmt.Errorf("close improve bundle artifact lease: %w", err) + } + return dir, nil } // validateBundleDir accepts only a server-created temp directory whose diff --git a/apps/backend/internal/improvekandev/bundle_test.go b/apps/backend/internal/improvekandev/bundle_test.go index fee617ec4e..866c66fa01 100644 --- a/apps/backend/internal/improvekandev/bundle_test.go +++ b/apps/backend/internal/improvekandev/bundle_test.go @@ -1,11 +1,16 @@ package improvekandev import ( + "context" + "encoding/json" "os" "path/filepath" "strings" "testing" "time" + + "github.com/kandev/kandev/internal/system/storage" + "github.com/kandev/kandev/internal/system/storage/tempartifacts" ) func TestCreateBundleDirWritesOwnerMarker(t *testing.T) { @@ -69,40 +74,75 @@ func TestValidateBundleDir_RejectsBad(t *testing.T) { } } -func TestCleanupStaleBundles_RemovesOnlyStale(t *testing.T) { - root := t.TempDir() +func TestCreateBundleDirWithRegistryDoesNotPersistAuthenticatedOwner(t *testing.T) { + store := &recordingArtifactStore{} + registry := tempartifacts.NewRegistry(tempartifacts.Config{ + Store: store, TempRoot: os.TempDir(), OwnerPID: 1234, RunID: "test-run", + NewID: func() string { return "artifact-1" }, NewToken: func() string { return "token-1" }, + }) + dir, err := createBundleDirWithRegistry(context.Background(), "user-1", registry) + if err != nil { + t.Fatalf("createBundleDirWithRegistry: %v", err) + } + defer func() { _ = os.RemoveAll(dir) }() - stale := filepath.Join(root, bundlePrefix+"old") - if err := os.Mkdir(stale, 0o755); err != nil { - t.Fatalf("mkdir stale: %v", err) + if strings.Contains(string(store.artifact.Metadata), "user-1") { + t.Fatalf("temporary artifact metadata contains authenticated owner: %s", store.artifact.Metadata) } - old := time.Now().Add(-72 * time.Hour) - if err := os.Chtimes(stale, old, old); err != nil { - t.Fatalf("chtimes stale: %v", err) + var metadata map[string]any + if err := json.Unmarshal(store.artifact.Metadata, &metadata); err != nil { + t.Fatalf("decode temporary artifact metadata: %v", err) } - - fresh := filepath.Join(root, bundlePrefix+"new") - if err := os.Mkdir(fresh, 0o755); err != nil { - t.Fatalf("mkdir fresh: %v", err) - } - - other := filepath.Join(root, "unrelated-dir") - if err := os.Mkdir(other, 0o755); err != nil { - t.Fatalf("mkdir other: %v", err) - } - if err := os.Chtimes(other, old, old); err != nil { - t.Fatalf("chtimes other: %v", err) - } - - cleanupStaleBundlesIn(root, 24*time.Hour, nil) - - if _, err := os.Stat(stale); !os.IsNotExist(err) { - t.Errorf("stale bundle %q should have been removed: %v", stale, err) - } - if _, err := os.Stat(fresh); err != nil { - t.Errorf("fresh bundle %q should have been preserved: %v", fresh, err) - } - if _, err := os.Stat(other); err != nil { - t.Errorf("non-bundle dir %q should have been preserved: %v", other, err) + if _, ok := metadata["owner"]; ok { + t.Fatalf("temporary artifact metadata contains owner field: %#v", metadata) } } + +type recordingArtifactStore struct { + artifact storage.TemporaryArtifact +} + +func (s *recordingArtifactStore) CreateTemporaryArtifact(_ context.Context, artifact *storage.TemporaryArtifact) error { + s.artifact = *artifact + return nil +} + +func (s *recordingArtifactStore) GetTemporaryArtifact(_ context.Context, id string) (storage.TemporaryArtifact, error) { + if s.artifact.ID != id { + return storage.TemporaryArtifact{}, storage.ErrNotFound + } + return s.artifact, nil +} + +func (s *recordingArtifactStore) ListTemporaryArtifacts(context.Context) ([]storage.TemporaryArtifact, error) { + if s.artifact.ID == "" { + return nil, nil + } + return []storage.TemporaryArtifact{s.artifact}, nil +} + +func (s *recordingArtifactStore) HeartbeatTemporaryArtifact(_ context.Context, id string, at time.Time) error { + if s.artifact.ID != id || s.artifact.State != storage.TemporaryArtifactStateActive { + return storage.ErrNotFound + } + s.artifact.LastHeartbeatAt = &at + return nil +} + +func (s *recordingArtifactStore) TransitionTemporaryArtifact( + _ context.Context, + id string, + next storage.TemporaryArtifactState, + lastError string, + at time.Time, +) error { + if s.artifact.ID != id { + return storage.ErrNotFound + } + s.artifact.State = next + s.artifact.LastError = lastError + if next == storage.TemporaryArtifactStateClosed { + s.artifact.ClosedAt = &at + } + return nil +} diff --git a/apps/backend/internal/improvekandev/handler.go b/apps/backend/internal/improvekandev/handler.go index 00c8f7a6fa..732f72acf2 100644 --- a/apps/backend/internal/improvekandev/handler.go +++ b/apps/backend/internal/improvekandev/handler.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/system/logbundle" + "github.com/kandev/kandev/internal/system/storage/tempartifacts" taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" ) @@ -87,6 +88,7 @@ type Handler struct { // resolveRemote resolves a local repo path's origin remote. Defaults to // service.ResolveGitRemoteProvider; tests can substitute a fake. resolveRemote remoteResolver + tempArtifacts *tempartifacts.Registry } type diagnosticBundleService interface { @@ -118,6 +120,10 @@ func (h *Handler) SetLogBundles(service diagnosticBundleService) { h.logBundles = service } +func (h *Handler) SetTemporaryArtifactRegistry(registry *tempartifacts.Registry) { + h.tempArtifacts = registry +} + // RegisterRoutes registers the bootstrap and diagnostic-bundle lease endpoints. func RegisterRoutes(router *gin.Engine, h *Handler) { if h == nil { @@ -242,7 +248,7 @@ func (h *Handler) httpBootstrap(c *gin.Context) { return } - dir, err := createBundleDir(identity.UserID) + dir, err := h.createBundleDir(ctx, identity.UserID) if err != nil { h.log.Error("improve-kandev: bundle dir creation failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{errorKey: "failed to create bundle dir"}) @@ -265,6 +271,13 @@ func (h *Handler) httpBootstrap(c *gin.Context) { }) } +func (h *Handler) createBundleDir(ctx context.Context, owner string) (string, error) { + if h.tempArtifacts == nil { + return createBundleDir(owner) + } + return createBundleDirWithRegistry(ctx, owner, h.tempArtifacts) +} + // ensureImproveWorkspace returns the dedicated Improve Kandev workspace. // // - Exists (matched by exact name): returned as-is; create_workspace and @@ -614,7 +627,7 @@ func (h *Handler) httpLeaseBundle(c *gin.Context) { c.JSON(status, gin.H{errorKey: "failed to lease diagnostic bundle"}) return } - time.AfterFunc(staleBundleAge, func() { _ = os.Remove(path) }) + time.AfterFunc(bundleLeaseAge, func() { _ = os.Remove(path) }) c.JSON(http.StatusOK, gin.H{ "path": path, "status": view.Status, "sources": view.Sources, }) diff --git a/apps/backend/internal/system/storage/handler.go b/apps/backend/internal/system/storage/handler.go index 1741356d90..6958948257 100644 --- a/apps/backend/internal/system/storage/handler.go +++ b/apps/backend/internal/system/storage/handler.go @@ -32,18 +32,20 @@ type Mutations interface { } type Capabilities struct { - ManagedGoCachePath string `json:"managed_go_cache_path"` - GoCacheAdoptionAvailable bool `json:"go_cache_adoption_available"` - DockerAvailable bool `json:"docker_available"` - DockerHost string `json:"docker_host"` - HostGlobalDockerCleanup bool `json:"host_global_docker_cleanup_allowed"` + ManagedGoCachePath string `json:"managed_go_cache_path"` + GoCacheAdoptionAvailable bool `json:"go_cache_adoption_available"` + TemporaryArtifactsAvailable bool `json:"temporary_artifacts_available"` + DockerAvailable bool `json:"docker_available"` + DockerHost string `json:"docker_host"` + HostGlobalDockerCleanup bool `json:"host_global_docker_cleanup_allowed"` } type Summary struct { - Workspaces any `json:"workspaces"` - GoCache any `json:"go_cache"` - Quarantine any `json:"quarantine"` - Docker any `json:"docker"` + Workspaces any `json:"workspaces"` + GoCache any `json:"go_cache"` + Quarantine any `json:"quarantine"` + TemporaryArtifacts any `json:"temporary_artifacts"` + Docker any `json:"docker"` } type DiskCapacity struct { diff --git a/apps/backend/internal/system/storage/store.go b/apps/backend/internal/system/storage/store.go index 570a9d8821..25808b47e1 100644 --- a/apps/backend/internal/system/storage/store.go +++ b/apps/backend/internal/system/storage/store.go @@ -55,10 +55,45 @@ type MaintenanceRun struct { type ResourceType string const ( - ResourceTypeTaskWorkspace ResourceType = "task_workspace" - ResourceTypeGoCache ResourceType = "go_cache" + ResourceTypeTaskWorkspace ResourceType = "task_workspace" + ResourceTypeGoCache ResourceType = "go_cache" + ResourceTypeTemporaryArtifact ResourceType = "temporary_artifact" ) +type TemporaryArtifactKind string + +const ( + TemporaryArtifactKindImproveBundle TemporaryArtifactKind = "improve_bundle" + TemporaryArtifactKindHostUtility TemporaryArtifactKind = "host_utility" +) + +type TemporaryArtifactState string + +const ( + TemporaryArtifactStateActive TemporaryArtifactState = "active" + TemporaryArtifactStateClosed TemporaryArtifactState = "closed" + TemporaryArtifactStateAbandoned TemporaryArtifactState = "abandoned" + TemporaryArtifactStateQuarantined TemporaryArtifactState = "quarantined" + TemporaryArtifactStateDeleted TemporaryArtifactState = "deleted" + TemporaryArtifactStateFailed TemporaryArtifactState = "failed" +) + +type TemporaryArtifact struct { + ID string `json:"id"` + Kind TemporaryArtifactKind `json:"kind"` + Path string `json:"path"` + MarkerToken string `json:"marker_token"` + State TemporaryArtifactState `json:"state"` + OwnerPID int64 `json:"owner_pid"` + CreatedAt time.Time `json:"created_at"` + LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + QuarantinedAt *time.Time `json:"quarantined_at,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + LastError string `json:"last_error"` + Metadata json.RawMessage `json:"metadata"` +} + type QuarantineState string const ( @@ -288,6 +323,122 @@ func (s *Store) SummarizeQuarantine(ctx context.Context) (QuarantineSummary, err return summary, nil } +func (s *Store) CreateTemporaryArtifact(ctx context.Context, artifact *TemporaryArtifact) error { + if err := normalizeNewTemporaryArtifact(artifact); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO storage_temp_artifacts + (id, kind, path, marker_token, state, owner_pid, created_at, last_heartbeat_at, + closed_at, quarantined_at, deleted_at, last_error, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), artifact.ID, artifact.Kind, artifact.Path, artifact.MarkerToken, artifact.State, + artifact.OwnerPID, artifact.CreatedAt, artifact.LastHeartbeatAt, artifact.ClosedAt, + artifact.QuarantinedAt, artifact.DeletedAt, artifact.LastError, string(artifact.Metadata)) + if err != nil { + return fmt.Errorf("create temporary artifact: %w", err) + } + return nil +} + +func (s *Store) GetTemporaryArtifact(ctx context.Context, id string) (TemporaryArtifact, error) { + row, err := getTemporaryArtifactRow(ctx, s.ro, id) + if err != nil { + return TemporaryArtifact{}, err + } + return row.artifact(), nil +} + +func (s *Store) ListTemporaryArtifacts(ctx context.Context) ([]TemporaryArtifact, error) { + rows := make([]temporaryArtifactRow, 0) + err := s.ro.SelectContext(ctx, &rows, ` + SELECT id, kind, path, marker_token, state, owner_pid, created_at, last_heartbeat_at, + closed_at, quarantined_at, deleted_at, last_error, metadata + FROM storage_temp_artifacts + ORDER BY created_at ASC, id ASC + `) + if err != nil { + return nil, fmt.Errorf("list temporary artifacts: %w", err) + } + artifacts := make([]TemporaryArtifact, 0, len(rows)) + for _, row := range rows { + artifacts = append(artifacts, row.artifact()) + } + return artifacts, nil +} + +func (s *Store) HeartbeatTemporaryArtifact(ctx context.Context, id string, at time.Time) error { + at = at.UTC() + result, err := s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE storage_temp_artifacts + SET last_heartbeat_at = ?, last_error = '' + WHERE id = ? AND state = ? + `), at, id, TemporaryArtifactStateActive) + if err != nil { + return fmt.Errorf("heartbeat temporary artifact: %w", err) + } + if err := requireOneRow(result); err != nil { + return fmt.Errorf("heartbeat temporary artifact %s: %w", id, err) + } + return nil +} + +func (s *Store) TransitionTemporaryArtifact( + ctx context.Context, + id string, + next TemporaryArtifactState, + lastError string, + at time.Time, +) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin temporary artifact transition: %w", err) + } + defer func() { _ = tx.Rollback() }() + current, err := getTemporaryArtifactRow(ctx, tx, id) + if err != nil { + return err + } + if !validTemporaryArtifactTransition(TemporaryArtifactState(current.State), next) { + return transitionError("temporary artifact", current.State, string(next)) + } + at = at.UTC() + lastHeartbeat := current.LastHeartbeatAt + closedAt := current.ClosedAt + quarantinedAt := current.QuarantinedAt + deletedAt := current.DeletedAt + switch next { + case TemporaryArtifactStateClosed: + closedAt = &at + case TemporaryArtifactStateQuarantined: + quarantinedAt = &at + case TemporaryArtifactStateDeleted: + deletedAt = &at + } + if next != TemporaryArtifactStateActive { + lastHeartbeat = current.LastHeartbeatAt + } + if next != TemporaryArtifactStateFailed { + lastError = "" + } + result, err := tx.ExecContext(ctx, tx.Rebind(` + UPDATE storage_temp_artifacts + SET state = ?, last_heartbeat_at = ?, closed_at = ?, quarantined_at = ?, + deleted_at = ?, last_error = ? + WHERE id = ? AND state = ? + `), next, lastHeartbeat, closedAt, quarantinedAt, deletedAt, lastError, id, current.State) + if err != nil { + return fmt.Errorf("transition temporary artifact: %w", err) + } + if err := requireOneRow(result); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit temporary artifact transition: %w", err) + } + return nil +} + type queryer interface { GetContext(context.Context, any, string, ...any) error Rebind(string) string @@ -370,6 +521,47 @@ func (r quarantineRow) entry() QuarantineEntry { } } +type temporaryArtifactRow struct { + ID string `db:"id"` + Kind string `db:"kind"` + Path string `db:"path"` + MarkerToken string `db:"marker_token"` + State string `db:"state"` + OwnerPID int64 `db:"owner_pid"` + CreatedAt time.Time `db:"created_at"` + LastHeartbeatAt *time.Time `db:"last_heartbeat_at"` + ClosedAt *time.Time `db:"closed_at"` + QuarantinedAt *time.Time `db:"quarantined_at"` + DeletedAt *time.Time `db:"deleted_at"` + LastError string `db:"last_error"` + Metadata string `db:"metadata"` +} + +func getTemporaryArtifactRow(ctx context.Context, conn queryer, id string) (temporaryArtifactRow, error) { + var row temporaryArtifactRow + err := conn.GetContext(ctx, &row, conn.Rebind(` + SELECT id, kind, path, marker_token, state, owner_pid, created_at, last_heartbeat_at, + closed_at, quarantined_at, deleted_at, last_error, metadata + FROM storage_temp_artifacts WHERE id = ? + `), id) + if errors.Is(err, sql.ErrNoRows) { + return temporaryArtifactRow{}, ErrNotFound + } + if err != nil { + return temporaryArtifactRow{}, fmt.Errorf("get temporary artifact: %w", err) + } + return row, nil +} + +func (r temporaryArtifactRow) artifact() TemporaryArtifact { + return TemporaryArtifact{ + ID: r.ID, Kind: TemporaryArtifactKind(r.Kind), Path: r.Path, MarkerToken: r.MarkerToken, + State: TemporaryArtifactState(r.State), OwnerPID: r.OwnerPID, CreatedAt: r.CreatedAt, + LastHeartbeatAt: r.LastHeartbeatAt, ClosedAt: r.ClosedAt, QuarantinedAt: r.QuarantinedAt, + DeletedAt: r.DeletedAt, LastError: r.LastError, Metadata: json.RawMessage(r.Metadata), + } +} + func normalizeNewRun(run *MaintenanceRun) error { if run == nil || run.ID == "" { return validationError("run id is required") @@ -393,7 +585,8 @@ func normalizeNewQuarantineEntry(entry *QuarantineEntry) error { if entry == nil || entry.ID == "" { return validationError("quarantine entry id is required") } - if entry.ResourceType != ResourceTypeTaskWorkspace && entry.ResourceType != ResourceTypeGoCache { + if entry.ResourceType != ResourceTypeTaskWorkspace && entry.ResourceType != ResourceTypeGoCache && + entry.ResourceType != ResourceTypeTemporaryArtifact { return validationError("unknown quarantine resource type %q", entry.ResourceType) } if entry.State != QuarantineStateQuarantined { @@ -423,6 +616,47 @@ func normalizeNewQuarantineEntry(entry *QuarantineEntry) error { return nil } +func normalizeNewTemporaryArtifact(artifact *TemporaryArtifact) error { + if artifact == nil || artifact.ID == "" { + return validationError("temporary artifact id is required") + } + if artifact.Kind != TemporaryArtifactKindImproveBundle && artifact.Kind != TemporaryArtifactKindHostUtility { + return validationError("unknown temporary artifact kind %q", artifact.Kind) + } + if artifact.State != TemporaryArtifactStateActive { + return validationError("new temporary artifact state must be active") + } + if artifact.MarkerToken == "" { + return validationError("temporary artifact marker token is required") + } + if artifact.OwnerPID <= 0 { + return validationError("temporary artifact owner pid must be positive") + } + var err error + artifact.Path, err = normalizeAbsolutePath("temporary artifact path", artifact.Path) + if err != nil { + return err + } + if artifact.CreatedAt.IsZero() { + artifact.CreatedAt = time.Now().UTC() + } + artifact.CreatedAt = artifact.CreatedAt.UTC() + artifact.LastHeartbeatAt = normalizeTimePointer(artifact.LastHeartbeatAt) + artifact.ClosedAt = normalizeTimePointer(artifact.ClosedAt) + artifact.QuarantinedAt = normalizeTimePointer(artifact.QuarantinedAt) + artifact.DeletedAt = normalizeTimePointer(artifact.DeletedAt) + artifact.Metadata = normalizeJSON(artifact.Metadata) + return nil +} + +func normalizeTimePointer(value *time.Time) *time.Time { + if value == nil { + return nil + } + normalized := value.UTC() + return &normalized +} + func normalizeAbsolutePath(field, path string) (string, error) { if path == "" || !filepath.IsAbs(path) { return "", validationError("%s must be an absolute path", field) @@ -446,11 +680,30 @@ func validQuarantineTransition(current, next QuarantineState) bool { return next == QuarantineStateRestored || next == QuarantineStateDeleted || next == QuarantineStateFailed } if current == QuarantineStateFailed { - return next == QuarantineStateRestored || next == QuarantineStateDeleted + return next == QuarantineStateQuarantined || next == QuarantineStateRestored || next == QuarantineStateDeleted } return false } +func validTemporaryArtifactTransition(current, next TemporaryArtifactState) bool { + switch current { + case TemporaryArtifactStateActive: + return next == TemporaryArtifactStateClosed || next == TemporaryArtifactStateAbandoned || + next == TemporaryArtifactStateFailed || next == TemporaryArtifactStateDeleted + case TemporaryArtifactStateClosed, TemporaryArtifactStateAbandoned: + return next == TemporaryArtifactStateQuarantined || next == TemporaryArtifactStateFailed || + next == TemporaryArtifactStateDeleted + case TemporaryArtifactStateFailed: + return next == TemporaryArtifactStateClosed || next == TemporaryArtifactStateQuarantined || + next == TemporaryArtifactStateDeleted + case TemporaryArtifactStateQuarantined: + return next == TemporaryArtifactStateClosed || next == TemporaryArtifactStateDeleted || + next == TemporaryArtifactStateFailed + default: + return false + } +} + func isTerminalRunState(state RunState) bool { return state == RunStateSucceeded || state == RunStateFailed || state == RunStateCancelled || state == RunStateSkippedBusy } diff --git a/apps/backend/internal/system/storage/store_migrations.go b/apps/backend/internal/system/storage/store_migrations.go index adb0e1df98..032cf744a8 100644 --- a/apps/backend/internal/system/storage/store_migrations.go +++ b/apps/backend/internal/system/storage/store_migrations.go @@ -42,6 +42,23 @@ var storageSchemaStatements = []string{ `CREATE UNIQUE INDEX IF NOT EXISTS idx_storage_quarantine_active_original ON storage_quarantine_entries (original_path) WHERE state IN ('quarantined', 'failed')`, + `CREATE TABLE IF NOT EXISTS storage_temp_artifacts ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + marker_token TEXT NOT NULL UNIQUE, + state TEXT NOT NULL, + owner_pid BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL, + last_heartbeat_at TIMESTAMP NULL, + closed_at TIMESTAMP NULL, + quarantined_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, + last_error TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_storage_temp_artifacts_state_created + ON storage_temp_artifacts (state, created_at)`, } func initStorageSchema(conn *sqlx.DB) error { diff --git a/apps/backend/internal/system/storage/store_test.go b/apps/backend/internal/system/storage/store_test.go index f3859ccb54..9a70fb9fd0 100644 --- a/apps/backend/internal/system/storage/store_test.go +++ b/apps/backend/internal/system/storage/store_test.go @@ -179,6 +179,45 @@ func TestStoreQuarantineTransitionsSurviveRecreation(t *testing.T) { } } +func TestStoreTemporaryArtifactLifecycleSurvivesRecreation(t *testing.T) { + conn := newSQLite(t) + pool := db.NewPool(conn, conn) + store := newStorageStore(t, pool) + ctx := context.Background() + createdAt := time.Date(2026, time.August, 1, 12, 0, 0, 0, time.UTC) + artifact := &TemporaryArtifact{ + ID: "artifact-1", Kind: TemporaryArtifactKindImproveBundle, + Path: "/tmp/kandev-improve-artifact-1", MarkerToken: "marker-1", + State: TemporaryArtifactStateActive, OwnerPID: 1234, + CreatedAt: createdAt, LastHeartbeatAt: &createdAt, + Metadata: json.RawMessage(`{"owner":"user-1"}`), + } + if err := store.CreateTemporaryArtifact(ctx, artifact); err != nil { + t.Fatalf("CreateTemporaryArtifact: %v", err) + } + closedAt := createdAt.Add(time.Hour) + if err := store.TransitionTemporaryArtifact( + ctx, artifact.ID, TemporaryArtifactStateClosed, "", closedAt, + ); err != nil { + t.Fatalf("close temporary artifact: %v", err) + } + if err := store.TransitionTemporaryArtifact( + ctx, artifact.ID, TemporaryArtifactStateActive, "", closedAt, + ); !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("closed-to-active transition error = %v, want %v", err, ErrInvalidTransition) + } + + store = newStorageStore(t, pool) + got, err := store.GetTemporaryArtifact(ctx, artifact.ID) + if err != nil { + t.Fatalf("GetTemporaryArtifact after recreation: %v", err) + } + if got.State != TemporaryArtifactStateClosed || got.ClosedAt == nil || + !got.ClosedAt.Equal(closedAt) || string(got.Metadata) != `{"owner":"user-1"}` { + t.Fatalf("temporary artifact = %#v", got) + } +} + func TestStoreQuarantineOriginalPathUniqueWhileActive(t *testing.T) { conn := newSQLite(t) store := newStorageStore(t, db.NewPool(conn, conn)) diff --git a/apps/backend/internal/system/storage/tempartifacts/process_alive_unix.go b/apps/backend/internal/system/storage/tempartifacts/process_alive_unix.go new file mode 100644 index 0000000000..0cabefc159 --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/process_alive_unix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package tempartifacts + +import "syscall" + +func processAlive(pid int64) (alive, known bool) { + if pid <= 0 { + return false, true + } + err := syscall.Kill(int(pid), 0) + if err == nil || err == syscall.EPERM { + return true, true + } + if err == syscall.ESRCH { + return false, true + } + return false, false +} diff --git a/apps/backend/internal/system/storage/tempartifacts/process_alive_windows.go b/apps/backend/internal/system/storage/tempartifacts/process_alive_windows.go new file mode 100644 index 0000000000..7859255f7c --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/process_alive_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package tempartifacts + +func processAlive(pid int64) (alive, known bool) { + // Windows does not expose a portable, permission-safe signal-zero probe. + // Unknown liveness is treated as protected by reconciliation. + return false, false +} diff --git a/apps/backend/internal/system/storage/tempartifacts/provider.go b/apps/backend/internal/system/storage/tempartifacts/provider.go new file mode 100644 index 0000000000..bf663b419f --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/provider.go @@ -0,0 +1,627 @@ +package tempartifacts + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/system/storage" +) + +const staleArtifactAge = 24 * time.Hour + +type QuarantineStore interface { + CreateQuarantineEntry(context.Context, *storage.QuarantineEntry) error + GetQuarantineEntry(context.Context, string) (storage.QuarantineEntry, error) + ListQuarantineEntries(context.Context, bool) ([]storage.QuarantineEntry, error) + TransitionQuarantineEntry(context.Context, string, storage.QuarantineState, string) (storage.QuarantineEntry, error) +} + +type ProviderConfig struct { + Registry *Registry + Store QuarantineStore + HomeDir string + TrashDir string + Retention time.Duration + Now func() time.Time + NewID func() string +} + +type Provider struct { + registry *Registry + store QuarantineStore + homeDir string + trashDir string + retention time.Duration + now func() time.Time + newID func() string +} + +type Analysis struct { + Available bool `json:"available"` + TotalCount int `json:"total_count"` + TotalBytes int64 `json:"total_bytes"` + ActiveCount int `json:"active_count"` + ActiveBytes int64 `json:"active_bytes"` + ProtectedCount int `json:"protected_count"` + ProtectedBytes int64 `json:"protected_bytes"` + StaleCount int `json:"stale_count"` + StaleBytes int64 `json:"stale_bytes"` + SkippedCount int `json:"skipped_count"` + Warnings []string `json:"warnings,omitempty"` +} + +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"` +} + +func NewProvider(config ProviderConfig) *Provider { + now := config.Now + if now == nil { + now = time.Now + } + retention := config.Retention + if retention <= 0 { + retention = 7 * 24 * time.Hour + } + newID := config.NewID + if newID == nil { + newID = uuid.NewString + } + trashDir := config.TrashDir + if trashDir == "" { + trashDir = filepath.Join(config.HomeDir, "trash") + } + return &Provider{ + registry: config.Registry, store: config.Store, homeDir: filepath.Clean(config.HomeDir), + trashDir: filepath.Clean(trashDir), + retention: retention, now: now, newID: newID, + } +} + +func (p *Provider) Name() string { return "temporary_artifacts" } + +// Reconcile repairs the durable lifecycle after an interrupted quarantine +// rename. The filesystem is authoritative only when exactly one of the +// original and quarantine paths exists and the quarantine copy still carries +// the matching owner marker. Ambiguous or unowned paths remain untouched. +func (p *Provider) Reconcile(ctx context.Context) error { + if p.registry == nil || p.store == nil { + return errors.New("temporary artifact provider is unavailable") + } + artifacts, err := p.registry.List(ctx) + if err != nil { + return err + } + byID := make(map[string]storage.TemporaryArtifact, len(artifacts)) + for _, artifact := range artifacts { + byID[artifact.ID] = artifact + } + entries, err := p.store.ListQuarantineEntries(ctx, false) + if err != nil { + return err + } + var problems []error + for _, entry := range entries { + if entry.ResourceType != storage.ResourceTypeTemporaryArtifact { + continue + } + if err := p.reconcileEntry(ctx, entry, byID); err != nil { + problems = append(problems, err) + } + } + return errors.Join(problems...) +} + +func (p *Provider) reconcileEntry( + ctx context.Context, + entry storage.QuarantineEntry, + artifacts map[string]storage.TemporaryArtifact, +) error { + artifactID, err := quarantineArtifactID(entry, artifacts) + if err != nil { + return err + } + artifact, ok := artifacts[artifactID] + if !ok { + return fmt.Errorf("temporary artifact quarantine %s has no lifecycle row", entry.ID) + } + if err := p.validateQuarantineEntry(entry, artifact); err != nil { + return err + } + originalExists, err := pathExists(entry.OriginalPath) + if err != nil { + return fmt.Errorf("inspect temporary artifact original %s: %w", entry.ID, err) + } + quarantineExists, err := pathExists(entry.QuarantinePath) + if err != nil { + return fmt.Errorf("inspect temporary artifact quarantine %s: %w", entry.ID, err) + } + + switch { + case quarantineExists && !originalExists: + return p.reconcileMoved(ctx, entry, artifact) + case originalExists && !quarantineExists: + return p.reconcileUnmoved(ctx, entry, artifact) + case originalExists && quarantineExists: + return fmt.Errorf("temporary artifact quarantine %s has both original and quarantine paths", entry.ID) + default: + return fmt.Errorf("temporary artifact quarantine %s has neither original nor quarantine path", entry.ID) + } +} + +func (p *Provider) reconcileMoved( + ctx context.Context, + entry storage.QuarantineEntry, + artifact storage.TemporaryArtifact, +) error { + if err := p.registry.ValidateMarker(entry.QuarantinePath, artifact); err != nil { + return fmt.Errorf("validate temporary artifact quarantine %s: %w", entry.ID, err) + } + if entry.State == storage.QuarantineStateFailed { + if _, err := p.store.TransitionQuarantineEntry( + ctx, entry.ID, storage.QuarantineStateQuarantined, "", + ); err != nil { + return fmt.Errorf("reconcile temporary artifact quarantine %s: %w", entry.ID, err) + } + } + if artifact.State != storage.TemporaryArtifactStateQuarantined { + if err := p.registry.MarkQuarantined(ctx, artifact.ID); err != nil { + return fmt.Errorf("reconcile temporary artifact lifecycle %s: %w", entry.ID, err) + } + } + return nil +} + +func (p *Provider) reconcileUnmoved( + ctx context.Context, + entry storage.QuarantineEntry, + artifact storage.TemporaryArtifact, +) error { + // The rename did not happen. Release a failed or interrupted intent so the + // original row can be retried without creating a duplicate active-path entry. + if entry.State == storage.QuarantineStateQuarantined { + if _, err := p.store.TransitionQuarantineEntry( + ctx, entry.ID, storage.QuarantineStateFailed, + "quarantine move did not complete before restart", + ); err != nil { + return fmt.Errorf("mark temporary artifact quarantine %s failed: %w", entry.ID, err) + } + } + released, err := storage.ReleaseFailedQuarantineIntent( + ctx, p.store, storage.ResourceTypeTemporaryArtifact, entry.OriginalPath, + ) + if err != nil { + return fmt.Errorf("release temporary artifact quarantine %s: %w", entry.ID, err) + } + if released && (artifact.State == storage.TemporaryArtifactStateFailed || + artifact.State == storage.TemporaryArtifactStateQuarantined) { + if err := p.registry.MarkClosed(ctx, artifact.ID); err != nil { + return fmt.Errorf("restore temporary artifact lifecycle %s: %w", entry.ID, err) + } + } + return nil +} + +func (p *Provider) Analyze(ctx context.Context) (Analysis, error) { + if p.registry == nil || p.store == nil { + return Analysis{Available: false}, errors.New("temporary artifact provider is unavailable") + } + artifacts, err := p.registry.List(ctx) + if err != nil { + return Analysis{Available: false}, err + } + analysis := Analysis{Available: true} + cutoff := p.now().Add(-staleArtifactAge) + for _, artifact := range artifacts { + if artifact.State == storage.TemporaryArtifactStateQuarantined || + artifact.State == storage.TemporaryArtifactStateDeleted { + continue + } + if artifact.State == storage.TemporaryArtifactStateFailed { + analysis.SkippedCount++ + analysis.Warnings = append(analysis.Warnings, temporaryArtifactWarning(artifact)) + continue + } + size, inspectErr := p.inspect(artifact) + if inspectErr != nil { + analysis.SkippedCount++ + analysis.Warnings = append(analysis.Warnings, inspectErr.Error()) + continue + } + analysis.TotalCount++ + analysis.TotalBytes += size + if artifact.State == storage.TemporaryArtifactStateActive { + analysis.ActiveCount++ + analysis.ActiveBytes += size + continue + } + if artifactAge(artifact).After(cutoff) { + analysis.ProtectedCount++ + analysis.ProtectedBytes += size + continue + } + analysis.StaleCount++ + analysis.StaleBytes += size + } + return analysis, nil +} + +func (p *Provider) Cleanup(context.Context) (map[string]any, error) { + return toMap(CleanupResult{Skipped: true, Reason: "manual_only"}), nil +} + +func (p *Provider) CleanupExplicit(ctx context.Context) (map[string]any, error) { + result, err := p.cleanupExplicit(ctx) + return toMap(result), err +} + +func (p *Provider) cleanupExplicit(ctx context.Context) (CleanupResult, error) { + if p.registry == nil || p.store == nil { + return CleanupResult{}, errors.New("temporary artifact provider is unavailable") + } + if err := p.Reconcile(ctx); err != nil { + return CleanupResult{}, err + } + if err := p.releaseRetryableFailures(ctx); err != nil { + return CleanupResult{}, err + } + artifacts, err := p.registry.List(ctx) + if err != nil { + return CleanupResult{}, err + } + if err := p.ensureTrashRoot(); err != nil { + return CleanupResult{}, err + } + cutoff := p.now().Add(-staleArtifactAge) + result := CleanupResult{} + var failures []error + for _, artifact := range artifacts { + if artifact.State != storage.TemporaryArtifactStateClosed && + artifact.State != storage.TemporaryArtifactStateAbandoned { + continue + } + if artifactAge(artifact).After(cutoff) { + continue + } + result.Considered++ + size, inspectErr := p.inspect(artifact) + if inspectErr != nil { + result.Failed++ + result.Warnings = append(result.Warnings, inspectErr.Error()) + failures = append(failures, inspectErr) + continue + } + if err := p.quarantine(ctx, artifact, size); err != nil { + result.Failed++ + result.FailedBytes += size + result.Warnings = append(result.Warnings, err.Error()) + failures = append(failures, err) + continue + } + result.Quarantined++ + result.ReclaimedBytes += size + } + return result, errors.Join(failures...) +} + +func (p *Provider) releaseRetryableFailures(ctx context.Context) error { + artifacts, err := p.registry.List(ctx) + if err != nil { + return err + } + for _, artifact := range artifacts { + if artifact.State != storage.TemporaryArtifactStateFailed { + continue + } + released, releaseErr := storage.ReleaseFailedQuarantineIntent( + ctx, p.store, storage.ResourceTypeTemporaryArtifact, artifact.Path, + ) + if releaseErr != nil || !released { + continue + } + if err := p.registry.MarkClosed(ctx, artifact.ID); err != nil { + return fmt.Errorf("release temporary artifact %s: %w", artifact.ID, err) + } + } + return nil +} + +func (p *Provider) quarantine( + ctx context.Context, + artifact storage.TemporaryArtifact, + size int64, +) error { + id := p.newID() + if id == "" { + return errors.New("temporary artifact quarantine id must not be empty") + } + quarantinePath := filepath.Join(p.trashDir, "temporary-artifacts", id) + if err := storage.ValidateNoSymlinkPath(p.trashDir, filepath.Dir(quarantinePath)); err != nil { + return fmt.Errorf("validate temporary artifact trash: %w", err) + } + if _, err := os.Lstat(quarantinePath); err == nil { + return fmt.Errorf("temporary artifact quarantine destination already exists: %s", quarantinePath) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect temporary artifact quarantine destination: %w", err) + } + now := p.now().UTC() + metadata, _ := json.Marshal(map[string]string{ + "artifact_id": artifact.ID, "kind": string(artifact.Kind), "marker_token": artifact.MarkerToken, + }) + entry := &storage.QuarantineEntry{ + ID: id, ResourceType: storage.ResourceTypeTemporaryArtifact, + OriginalPath: artifact.Path, QuarantinePath: quarantinePath, SizeBytes: size, + State: storage.QuarantineStateQuarantined, QuarantinedAt: now, + DeleteAfter: now.Add(p.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()) + 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()) + return fmt.Errorf("quarantine temporary artifact: %w", err) + } + if err := p.registry.MarkQuarantined(ctx, artifact.ID); err != nil { + _, _ = p.store.TransitionQuarantineEntry(ctx, entry.ID, storage.QuarantineStateFailed, err.Error()) + return fmt.Errorf("persist temporary artifact quarantine: %w", err) + } + return nil +} + +func (p *Provider) Restore(ctx context.Context, id string) (storage.QuarantineEntry, error) { + entry, artifact, err := p.loadQuarantineArtifact(ctx, id) + if err != nil { + return storage.QuarantineEntry{}, err + } + if _, err := os.Lstat(artifact.Path); err == nil { + return entry, fmt.Errorf("%w: temporary artifact restore destination already exists", storage.ErrConflict) + } else if !errors.Is(err, os.ErrNotExist) { + return entry, fmt.Errorf("inspect temporary artifact restore destination: %w", err) + } + if err := storage.ValidateNoSymlinkPath(p.registry.TempRoot(), artifact.Path); err != nil { + return entry, err + } + if err := os.Rename(entry.QuarantinePath, artifact.Path); err != nil { + return entry, fmt.Errorf("restore temporary artifact: %w", err) + } + if err := p.registry.MarkClosed(ctx, artifact.ID); err != nil { + _ = os.Rename(artifact.Path, entry.QuarantinePath) + return entry, fmt.Errorf("persist temporary artifact lifecycle restore: %w", err) + } + restored, err := p.store.TransitionQuarantineEntry(ctx, id, storage.QuarantineStateRestored, "") + if err != nil { + _ = os.Rename(artifact.Path, entry.QuarantinePath) + return entry, fmt.Errorf("persist temporary artifact restore: %w", err) + } + return restored, nil +} + +func (p *Provider) PermanentDelete(ctx context.Context, id, confirmation string) (storage.QuarantineEntry, error) { + return p.permanentDelete(ctx, id, confirmation, false) +} + +func (p *Provider) PermanentDeleteForce(ctx context.Context, id, confirmation string) (storage.QuarantineEntry, error) { + return p.permanentDelete(ctx, id, confirmation, true) +} + +func (p *Provider) permanentDelete( + ctx context.Context, + id, confirmation string, + force bool, +) (storage.QuarantineEntry, error) { + entry, artifact, err := p.loadQuarantineArtifact(ctx, id) + if err != nil { + return storage.QuarantineEntry{}, err + } + if force { + if confirmation != storage.QuarantineConfirmationForce { + return entry, storage.ErrForceDeleteConfirmation + } + } else { + if confirmation != storage.QuarantineConfirmationDelete { + return entry, fmt.Errorf("%w: quarantine deletion requires DELETE confirmation", storage.ErrValidation) + } + if p.now().UTC().Before(entry.DeleteAfter) { + return entry, fmt.Errorf("%w: quarantine retention deadline has not elapsed", storage.ErrConflict) + } + } + if err := p.registry.ValidateMarker(entry.QuarantinePath, artifact); err != nil { + return entry, err + } + if err := os.RemoveAll(entry.QuarantinePath); err != nil { + return entry, fmt.Errorf("delete temporary artifact quarantine: %w", err) + } + deleted, err := p.store.TransitionQuarantineEntry(ctx, id, storage.QuarantineStateDeleted, "") + if err != nil { + return entry, fmt.Errorf("persist temporary artifact deletion: %w", err) + } + if err := p.registry.MarkDeleted(ctx, artifact.ID); err != nil { + return deleted, fmt.Errorf("persist temporary artifact lifecycle deletion: %w", err) + } + return deleted, nil +} + +func (p *Provider) loadQuarantineArtifact( + ctx context.Context, + id string, +) (storage.QuarantineEntry, storage.TemporaryArtifact, error) { + if p.store == nil || p.registry == nil { + return storage.QuarantineEntry{}, storage.TemporaryArtifact{}, errors.New("temporary artifact provider is unavailable") + } + entry, err := p.store.GetQuarantineEntry(ctx, id) + if err != nil { + return storage.QuarantineEntry{}, storage.TemporaryArtifact{}, err + } + if entry.ResourceType != storage.ResourceTypeTemporaryArtifact { + return entry, storage.TemporaryArtifact{}, fmt.Errorf("%w: unsupported quarantine resource %q", storage.ErrValidation, entry.ResourceType) + } + if entry.State != storage.QuarantineStateQuarantined && entry.State != storage.QuarantineStateFailed { + return entry, storage.TemporaryArtifact{}, fmt.Errorf("%w: temporary artifact quarantine entry is %q", storage.ErrConflict, entry.State) + } + artifactID, artifactIDErr := quarantineArtifactID(entry, nil) + if artifactIDErr != nil { + return entry, storage.TemporaryArtifact{}, artifactIDErr + } + artifact, err := p.registry.Get(ctx, artifactID) + if err != nil { + return entry, storage.TemporaryArtifact{}, err + } + expected := filepath.Join(p.trashDir, "temporary-artifacts", entry.ID) + if filepath.Clean(entry.QuarantinePath) != filepath.Clean(expected) || artifact.Path != entry.OriginalPath { + return entry, storage.TemporaryArtifact{}, fmt.Errorf("%w: temporary artifact quarantine paths do not match managed storage", storage.ErrValidation) + } + if err := p.registry.ValidateMarker(entry.QuarantinePath, artifact); err != nil { + return entry, storage.TemporaryArtifact{}, err + } + return entry, artifact, nil +} + +func (p *Provider) inspect(artifact storage.TemporaryArtifact) (int64, error) { + if err := p.registry.Validate(artifact); err != nil { + return 0, fmt.Errorf("skip temporary artifact %s: %w", artifact.Path, err) + } + size, err := directorySizeNoFollow(artifact.Path) + if err != nil { + return 0, fmt.Errorf("measure temporary artifact %s: %w", artifact.Path, err) + } + return size, nil +} + +func (p *Provider) validateQuarantineEntry( + entry storage.QuarantineEntry, + artifact storage.TemporaryArtifact, +) error { + expected := filepath.Join(p.trashDir, "temporary-artifacts", entry.ID) + if artifact.Path != entry.OriginalPath || + filepath.Clean(entry.QuarantinePath) != filepath.Clean(expected) { + return fmt.Errorf("temporary artifact quarantine %s paths do not match managed storage", entry.ID) + } + if !filepath.IsAbs(entry.OriginalPath) || !filepath.IsAbs(entry.QuarantinePath) { + return fmt.Errorf("temporary artifact quarantine %s paths must be absolute", entry.ID) + } + if _, err := os.Lstat(entry.QuarantinePath); err == nil { + if err := storage.ValidateNoSymlinkPath(p.trashDir, filepath.Dir(entry.QuarantinePath)); err != nil { + return fmt.Errorf("validate temporary artifact quarantine path %s: %w", entry.ID, err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect temporary artifact quarantine path %s: %w", entry.ID, err) + } + return nil +} + +func quarantineArtifactID( + entry storage.QuarantineEntry, + artifacts map[string]storage.TemporaryArtifact, +) (string, error) { + var metadata struct { + ArtifactID string `json:"artifact_id"` + } + if len(entry.Metadata) > 0 { + if err := json.Unmarshal(entry.Metadata, &metadata); err != nil { + return "", fmt.Errorf("decode temporary artifact quarantine %s metadata: %w", entry.ID, err) + } + } + if metadata.ArtifactID != "" { + return metadata.ArtifactID, nil + } + // Rows written by the initial implementation used the artifact ID as the + // quarantine ID. New rows carry an explicit artifact_id because one + // artifact can be restored and quarantined again. + if artifacts == nil { + return entry.ID, nil + } + if _, ok := artifacts[entry.ID]; ok { + return entry.ID, nil + } + return "", fmt.Errorf("temporary artifact quarantine %s has no artifact ID", entry.ID) +} + +func pathExists(path string) (bool, error) { + _, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return err == nil, err +} + +func (p *Provider) ensureTrashRoot() error { + if err := storage.ValidateNoSymlinkPath(p.homeDir, p.trashDir); err != nil { + return err + } + if err := os.MkdirAll(p.trashDir, 0o700); err != nil { + return fmt.Errorf("create storage trash: %w", err) + } + root := filepath.Join(p.trashDir, "temporary-artifacts") + if err := storage.ValidateNoSymlinkPath(p.trashDir, root); err != nil { + return err + } + if err := os.MkdirAll(root, 0o700); err != nil { + return fmt.Errorf("create temporary artifact trash: %w", err) + } + return nil +} + +func artifactAge(artifact storage.TemporaryArtifact) time.Time { + if artifact.ClosedAt != nil { + return artifact.ClosedAt.UTC() + } + if artifact.LastHeartbeatAt != nil { + return artifact.LastHeartbeatAt.UTC() + } + return artifact.CreatedAt.UTC() +} + +func temporaryArtifactWarning(artifact storage.TemporaryArtifact) string { + if artifact.LastError == "" { + return fmt.Sprintf("skip temporary artifact %s: lifecycle state is %s", artifact.Path, artifact.State) + } + return fmt.Sprintf("skip temporary artifact %s: %s", artifact.Path, artifact.LastError) +} + +func directorySizeNoFollow(root string) (int64, error) { + var size int64 + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink found at %s", path) + } + if info.Mode().IsRegular() { + size += info.Size() + } + return nil + }) + return size, err +} + +func toMap(value any) map[string]any { + encoded, _ := json.Marshal(value) + result := make(map[string]any) + _ = json.Unmarshal(encoded, &result) + return result +} diff --git a/apps/backend/internal/system/storage/tempartifacts/provider_test.go b/apps/backend/internal/system/storage/tempartifacts/provider_test.go new file mode 100644 index 0000000000..96af0cf854 --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/provider_test.go @@ -0,0 +1,406 @@ +package tempartifacts + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kandev/kandev/internal/system/storage" +) + +func TestProviderCountsOnlyRegisteredStaleArtifactsAndCleansExplicitly(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.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 = old + artifact.ClosedAt = &old + artifacts.artifacts[lease.Path()] = artifact + + unregistered := filepath.Join(root, "kandev-improve-unregistered") + if err := os.Mkdir(unregistered, 0o700); err != nil { + t.Fatalf("mkdir unregistered: %v", err) + } + if err := os.WriteFile(filepath.Join(unregistered, "ignored"), []byte("ignored"), 0o600); err != nil { + t.Fatalf("write unregistered: %v", err) + } + + quarantine := newFakeQuarantineStore() + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Retention: 7 * 24 * time.Hour, Now: func() time.Time { return now }, + NewID: func() string { return "quarantine-1" }, + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.TotalCount != 1 || analysis.StaleCount != 1 || analysis.StaleBytes <= int64(len("bundle")) { + t.Fatalf("analysis = %#v, want only one stale registered artifact", analysis) + } + + result, err := provider.Cleanup(context.Background()) + if err != nil { + t.Fatalf("scheduled Cleanup: %v", err) + } + if result["skipped"] != true || result["quarantined"] != float64(0) { + t.Fatalf("scheduled cleanup = %#v, want manual-only skip", result) + } + if _, err := os.Stat(unregistered); err != nil { + t.Fatalf("unregistered directory changed: %v", err) + } + if _, err := os.Stat(lease.Path()); err != nil { + t.Fatalf("registered artifact changed by scheduled cleanup: %v", err) + } + + explicit, err := provider.cleanupExplicit(context.Background()) + if err != nil { + t.Fatalf("explicit Cleanup: %v", err) + } + if explicit.Quarantined != 1 || explicit.ReclaimedBytes != analysis.StaleBytes { + t.Fatalf("explicit cleanup = %#v", explicit) + } + if _, err := os.Stat(lease.Path()); !os.IsNotExist(err) { + t.Fatalf("original artifact still exists: %v", err) + } + if len(quarantine.entries) != 1 || quarantine.entries["quarantine-1"].ResourceType != storage.ResourceTypeTemporaryArtifact { + t.Fatalf("quarantine entries = %#v", quarantine.entries) + } +} + +func TestProviderProtectsRecentlyClosedArtifact(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, RunID: "test-run", + 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.Fatalf("write bundle: %v", err) + } + if err := lease.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: newFakeQuarantineStore(), HomeDir: home, + Now: func() time.Time { return now }, + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.ProtectedCount != 1 || analysis.ProtectedBytes == 0 || analysis.StaleCount != 0 { + t.Fatalf("analysis = %#v, want recently closed artifact protected", analysis) + } +} + +func TestProviderReconcilesRenameBeforeLifecycleUpdate(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 := lease.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + artifact := artifacts.artifacts[lease.Path()] + quarantineID := "quarantine-1" + quarantinePath := filepath.Join(home, "trash", "temporary-artifacts", quarantineID) + if err := os.MkdirAll(filepath.Dir(quarantinePath), 0o700); err != nil { + t.Fatalf("mkdir quarantine: %v", err) + } + if err := os.Rename(artifact.Path, quarantinePath); err != nil { + t.Fatalf("rename artifact: %v", err) + } + quarantine := newFakeQuarantineStore() + if err := quarantine.CreateQuarantineEntry(context.Background(), &storage.QuarantineEntry{ + ID: quarantineID, ResourceType: storage.ResourceTypeTemporaryArtifact, + OriginalPath: artifact.Path, QuarantinePath: quarantinePath, + State: storage.QuarantineStateQuarantined, QuarantinedAt: now, + DeleteAfter: now.Add(7 * 24 * time.Hour), Metadata: json.RawMessage(`{"artifact_id":"artifact-1"}`), + }); err != nil { + t.Fatalf("CreateQuarantineEntry: %v", err) + } + + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Now: func() time.Time { return now }, + }) + if err := provider.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + reconciled, err := artifacts.GetTemporaryArtifact(context.Background(), artifact.ID) + if err != nil { + t.Fatalf("GetTemporaryArtifact: %v", err) + } + if reconciled.State != storage.TemporaryArtifactStateQuarantined { + t.Fatalf("artifact state = %q, want quarantined", reconciled.State) + } + reconciledEntry, err := quarantine.GetQuarantineEntry(context.Background(), quarantineID) + if err != nil { + t.Fatalf("GetQuarantineEntry: %v", err) + } + if reconciledEntry.State != storage.QuarantineStateQuarantined { + t.Fatalf("quarantine state = %q, want quarantined", reconciledEntry.State) + } +} + +func TestProviderReleasesInterruptedRenameIntentWhenOriginalRemains(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 := lease.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + artifact := artifacts.artifacts[lease.Path()] + quarantineID := "quarantine-1" + quarantinePath := filepath.Join(home, "trash", "temporary-artifacts", quarantineID) + quarantine := newFakeQuarantineStore() + if err := quarantine.CreateQuarantineEntry(context.Background(), &storage.QuarantineEntry{ + ID: quarantineID, ResourceType: storage.ResourceTypeTemporaryArtifact, + OriginalPath: artifact.Path, QuarantinePath: quarantinePath, + State: storage.QuarantineStateQuarantined, QuarantinedAt: now, + DeleteAfter: now.Add(7 * 24 * time.Hour), Metadata: json.RawMessage(`{"artifact_id":"artifact-1"}`), + }); err != nil { + t.Fatalf("CreateQuarantineEntry: %v", err) + } + + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Now: func() time.Time { return now }, + }) + if err := provider.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + reconciledEntry, err := quarantine.GetQuarantineEntry(context.Background(), quarantineID) + if err != nil { + t.Fatalf("GetQuarantineEntry: %v", err) + } + if reconciledEntry.State != storage.QuarantineStateRestored { + t.Fatalf("quarantine state = %q, want restored", reconciledEntry.State) + } + reconciled, err := artifacts.GetTemporaryArtifact(context.Background(), artifact.ID) + if err != nil { + t.Fatalf("GetTemporaryArtifact: %v", err) + } + if reconciled.State != storage.TemporaryArtifactStateClosed { + t.Fatalf("artifact state = %q, want closed", reconciled.State) + } +} + +func TestProviderRestoresQuarantinedLifecycleWhenOriginalRemains(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 := lease.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + artifact := artifacts.artifacts[lease.Path()] + artifact.State = storage.TemporaryArtifactStateQuarantined + artifact.QuarantinedAt = &now + artifacts.artifacts[lease.Path()] = artifact + + quarantineID := "quarantine-1" + quarantinePath := filepath.Join(home, "trash", "temporary-artifacts", quarantineID) + quarantine := newFakeQuarantineStore() + if err := quarantine.CreateQuarantineEntry(context.Background(), &storage.QuarantineEntry{ + ID: quarantineID, ResourceType: storage.ResourceTypeTemporaryArtifact, + OriginalPath: artifact.Path, QuarantinePath: quarantinePath, + State: storage.QuarantineStateQuarantined, QuarantinedAt: now, + DeleteAfter: now.Add(7 * 24 * time.Hour), Metadata: json.RawMessage(`{"artifact_id":"artifact-1"}`), + }); err != nil { + t.Fatalf("CreateQuarantineEntry: %v", err) + } + + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, + Now: func() time.Time { return now }, + }) + if err := provider.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + reconciled, err := artifacts.GetTemporaryArtifact(context.Background(), artifact.ID) + if err != nil { + t.Fatalf("GetTemporaryArtifact: %v", err) + } + if reconciled.State != storage.TemporaryArtifactStateClosed { + t.Fatalf("artifact state = %q, want closed", reconciled.State) + } +} + +func TestProviderCanRequarantineRestoredArtifact(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + clock := now + artifacts := newFakeArtifactStore() + registry := NewRegistry(Config{ + Store: artifacts, TempRoot: root, OwnerPID: 1234, + Now: func() time.Time { return clock }, + 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 := lease.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + artifact := artifacts.artifacts[lease.Path()] + old := now.Add(-25 * time.Hour) + artifact.CreatedAt = old + artifact.ClosedAt = &old + artifacts.artifacts[lease.Path()] = artifact + quarantine := newFakeQuarantineStore() + ids := []string{"quarantine-1", "quarantine-2"} + provider := NewProvider(ProviderConfig{ + Registry: registry, Store: quarantine, HomeDir: home, Now: func() time.Time { return clock }, + NewID: func() string { + id := ids[0] + ids = ids[1:] + return id + }, + }) + if _, err := provider.cleanupExplicit(context.Background()); err != nil { + t.Fatalf("first cleanup: %v", err) + } + if _, err := provider.Restore(context.Background(), "quarantine-1"); err != nil { + t.Fatalf("restore: %v", err) + } + clock = now.Add(25 * time.Hour) + if _, err := provider.cleanupExplicit(context.Background()); err != nil { + t.Fatalf("second cleanup: %v", err) + } + if len(quarantine.entries) != 2 || quarantine.entries["quarantine-2"].State != storage.QuarantineStateQuarantined { + t.Fatalf("quarantine entries after restore/retry = %#v", quarantine.entries) + } +} + +type fakeQuarantineStore struct { + entries map[string]storage.QuarantineEntry +} + +func newFakeQuarantineStore() *fakeQuarantineStore { + return &fakeQuarantineStore{entries: make(map[string]storage.QuarantineEntry)} +} + +func (s *fakeQuarantineStore) CreateQuarantineEntry(_ context.Context, entry *storage.QuarantineEntry) error { + s.entries[entry.ID] = *entry + return nil +} + +func (s *fakeQuarantineStore) GetQuarantineEntry(_ context.Context, id string) (storage.QuarantineEntry, error) { + entry, ok := s.entries[id] + if !ok { + return storage.QuarantineEntry{}, storage.ErrNotFound + } + return entry, nil +} + +func (s *fakeQuarantineStore) ListQuarantineEntries(_ context.Context, includeTerminal bool) ([]storage.QuarantineEntry, error) { + entries := make([]storage.QuarantineEntry, 0, len(s.entries)) + for _, entry := range s.entries { + if !includeTerminal && entry.State != storage.QuarantineStateQuarantined && entry.State != storage.QuarantineStateFailed { + continue + } + entries = append(entries, entry) + } + return entries, nil +} + +func (s *fakeQuarantineStore) TransitionQuarantineEntry( + _ context.Context, id string, next storage.QuarantineState, lastError string, +) (storage.QuarantineEntry, error) { + entry, ok := s.entries[id] + if !ok { + return storage.QuarantineEntry{}, storage.ErrNotFound + } + if !validFakeQuarantineTransition(entry.State, next) { + return storage.QuarantineEntry{}, storage.ErrInvalidTransition + } + entry.State = next + entry.LastError = lastError + if next != storage.QuarantineStateFailed { + entry.LastError = "" + } + completion := time.Now().UTC() + switch next { + case storage.QuarantineStateRestored: + entry.RestoredAt = &completion + case storage.QuarantineStateDeleted: + entry.DeletedAt = &completion + } + s.entries[id] = entry + return entry, nil +} + +func validFakeQuarantineTransition(current, next storage.QuarantineState) bool { + switch current { + case storage.QuarantineStateQuarantined: + return next == storage.QuarantineStateRestored || next == storage.QuarantineStateDeleted || + next == storage.QuarantineStateFailed + case storage.QuarantineStateFailed: + return next == storage.QuarantineStateQuarantined || next == storage.QuarantineStateRestored || + next == storage.QuarantineStateDeleted + default: + return false + } +} diff --git a/apps/backend/internal/system/storage/tempartifacts/registry.go b/apps/backend/internal/system/storage/tempartifacts/registry.go new file mode 100644 index 0000000000..16446b617b --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/registry.go @@ -0,0 +1,475 @@ +// Package tempartifacts tracks exact temporary directories created by the +// Kandev service. A name prefix alone is never sufficient ownership proof. +package tempartifacts + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/system/storage" +) + +const ( + MarkerName = ".kandev-temp-artifact.json" + registryRunIDMetadataKey = "_kandev_registry_run_id" +) + +type Store interface { + CreateTemporaryArtifact(context.Context, *storage.TemporaryArtifact) error + GetTemporaryArtifact(context.Context, string) (storage.TemporaryArtifact, error) + ListTemporaryArtifacts(context.Context) ([]storage.TemporaryArtifact, error) + HeartbeatTemporaryArtifact(context.Context, string, time.Time) error + TransitionTemporaryArtifact(context.Context, string, storage.TemporaryArtifactState, string, time.Time) error +} + +type Config struct { + Store Store + TempRoot string + OwnerPID int64 + RunID string + Now func() time.Time + NewID func() string + NewToken func() string +} + +type Registry struct { + store Store + tempRoot string + ownerPID int64 + runID string + now func() time.Time + newID func() string + newToken func() string +} + +type Lease struct { + registry *Registry + artifact storage.TemporaryArtifact + mu sync.Mutex +} + +type markerData struct { + ID string `json:"id"` + Kind storage.TemporaryArtifactKind `json:"kind"` + Token string `json:"token"` +} + +func NewRegistry(config Config) *Registry { + tempRoot := config.TempRoot + if tempRoot == "" { + tempRoot = os.TempDir() + } + if absolute, err := filepath.Abs(tempRoot); err == nil { + tempRoot = absolute + } + now := config.Now + if now == nil { + now = time.Now + } + newID := config.NewID + if newID == nil { + newID = uuid.NewString + } + newToken := config.NewToken + if newToken == nil { + newToken = uuid.NewString + } + runID := config.RunID + if runID == "" { + runID = uuid.NewString() + } + ownerPID := config.OwnerPID + if ownerPID <= 0 { + ownerPID = int64(os.Getpid()) + } + return &Registry{ + store: config.Store, tempRoot: filepath.Clean(tempRoot), ownerPID: ownerPID, + runID: runID, now: now, newID: newID, newToken: newToken, + } +} + +func (r *Registry) Create( + ctx context.Context, + kind storage.TemporaryArtifactKind, + metadata json.RawMessage, +) (*Lease, error) { + if r.store == nil { + return nil, errors.New("temporary artifact store is required") + } + prefix, err := prefixForKind(kind) + if err != nil { + return nil, err + } + if err := requireRealRoot(r.tempRoot); err != nil { + return nil, err + } + path, err := os.MkdirTemp(r.tempRoot, prefix+"*") + if err != nil { + return nil, fmt.Errorf("create temporary artifact directory: %w", err) + } + if err := os.Chmod(path, 0o700); err != nil { + _ = os.RemoveAll(path) + return nil, fmt.Errorf("protect temporary artifact directory: %w", err) + } + lease, err := r.RegisterExisting(ctx, kind, path, metadata) + if err != nil { + _ = os.RemoveAll(path) + return nil, err + } + return lease, nil +} + +func (r *Registry) RegisterExisting( + ctx context.Context, + kind storage.TemporaryArtifactKind, + path string, + metadata json.RawMessage, +) (*Lease, error) { + if r.store == nil { + return nil, errors.New("temporary artifact store is required") + } + prefix, err := prefixForKind(kind) + if err != nil { + return nil, err + } + path, err = filepath.Abs(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("resolve temporary artifact path: %w", err) + } + if filepath.Dir(path) != r.tempRoot { + return nil, fmt.Errorf("temporary artifact path must be a direct child of %s", r.tempRoot) + } + if !strings.HasPrefix(filepath.Base(path), prefix) { + return nil, fmt.Errorf("temporary artifact path must use prefix %q", prefix) + } + if err := r.validateDirectory(path); err != nil { + return nil, err + } + id, token := r.newID(), r.newToken() + if id == "" || token == "" { + return nil, errors.New("temporary artifact identifiers must not be empty") + } + if err := writeMarker(filepath.Join(path, MarkerName), markerData{ID: id, Kind: kind, Token: token}); err != nil { + return nil, fmt.Errorf("write temporary artifact marker: %w", err) + } + now := r.now().UTC() + artifact := &storage.TemporaryArtifact{ + ID: id, Kind: kind, Path: path, MarkerToken: token, + State: storage.TemporaryArtifactStateActive, OwnerPID: r.ownerPID, + CreatedAt: now, LastHeartbeatAt: &now, Metadata: metadataWithRunID(metadata, r.runID), + } + if err := r.store.CreateTemporaryArtifact(ctx, artifact); err != nil { + _ = os.Remove(filepath.Join(path, MarkerName)) + return nil, err + } + return &Lease{registry: r, artifact: *artifact}, nil +} + +func (r *Registry) List(ctx context.Context) ([]storage.TemporaryArtifact, error) { + if r.store == nil { + return nil, errors.New("temporary artifact store is required") + } + return r.store.ListTemporaryArtifacts(ctx) +} + +func (r *Registry) Get(ctx context.Context, id string) (storage.TemporaryArtifact, error) { + if r.store == nil { + return storage.TemporaryArtifact{}, errors.New("temporary artifact store is required") + } + return r.store.GetTemporaryArtifact(ctx, id) +} + +func (r *Registry) Reconcile(ctx context.Context) error { + artifacts, err := r.List(ctx) + if err != nil { + return err + } + for _, artifact := range artifacts { + if artifact.State != storage.TemporaryArtifactStateActive { + continue + } + if artifact.OwnerPID == r.ownerPID && metadataRunID(artifact.Metadata) == r.runID { + continue + } + if artifact.OwnerPID == r.ownerPID { + if err := r.MarkAbandoned(ctx, artifact.ID, "owner process run is no longer active"); err != nil { + return fmt.Errorf("reconcile temporary artifact %s: %w", artifact.ID, err) + } + continue + } + alive, known := processAlive(artifact.OwnerPID) + if !known || alive { + continue + } + if err := r.MarkAbandoned(ctx, artifact.ID, "owner process is no longer running"); err != nil { + return fmt.Errorf("reconcile temporary artifact %s: %w", artifact.ID, err) + } + } + return nil +} + +func (r *Registry) MarkAbandoned(ctx context.Context, id, reason string) error { + return r.transition(ctx, id, storage.TemporaryArtifactStateAbandoned, reason) +} + +func (r *Registry) MarkClosed(ctx context.Context, id string) error { + return r.transition(ctx, id, storage.TemporaryArtifactStateClosed, "") +} + +func (r *Registry) MarkFailed(ctx context.Context, id, reason string) error { + return r.transition(ctx, id, storage.TemporaryArtifactStateFailed, reason) +} + +func (r *Registry) MarkQuarantined(ctx context.Context, id string) error { + return r.transition(ctx, id, storage.TemporaryArtifactStateQuarantined, "") +} + +func (r *Registry) MarkDeleted(ctx context.Context, id string) error { + return r.transition(ctx, id, storage.TemporaryArtifactStateDeleted, "") +} + +func (r *Registry) transition( + ctx context.Context, + id string, + next storage.TemporaryArtifactState, + lastError string, +) error { + return r.transitionAt(ctx, id, next, lastError, r.now()) +} + +func (r *Registry) transitionAt( + ctx context.Context, + id string, + next storage.TemporaryArtifactState, + lastError string, + at time.Time, +) error { + if r.store == nil { + return errors.New("temporary artifact store is required") + } + return r.store.TransitionTemporaryArtifact(ctx, id, next, lastError, at.UTC()) +} + +func (r *Registry) Validate(artifact storage.TemporaryArtifact) error { + prefix, err := prefixForKind(artifact.Kind) + if err != nil { + return err + } + if filepath.Clean(artifact.Path) != artifact.Path || filepath.Dir(artifact.Path) != r.tempRoot { + return errors.New("temporary artifact path is outside the effective temp root") + } + if !strings.HasPrefix(filepath.Base(artifact.Path), prefix) { + return errors.New("temporary artifact path has an unexpected prefix") + } + if err := r.validateDirectory(artifact.Path); err != nil { + return err + } + return r.ValidateMarker(artifact.Path, artifact) +} + +func (r *Registry) ValidateMarker(root string, artifact storage.TemporaryArtifact) error { + info, err := os.Lstat(root) + if err != nil { + return fmt.Errorf("inspect temporary artifact marker parent: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("temporary artifact marker parent must be a real directory") + } + 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 (r *Registry) validateDirectory(path string) error { + if err := storage.ValidateNoSymlinkPath(r.tempRoot, path); err != nil { + return fmt.Errorf("validate temporary artifact path: %w", err) + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect temporary artifact path: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("temporary artifact root must be a real directory") + } + return nil +} + +func (r *Registry) TempRoot() string { return r.tempRoot } + +func (l *Lease) Path() string { return l.artifact.Path } + +func (l *Lease) Artifact() storage.TemporaryArtifact { + l.mu.Lock() + defer l.mu.Unlock() + return l.artifact +} + +func (l *Lease) Heartbeat(ctx context.Context) error { + l.mu.Lock() + defer l.mu.Unlock() + if l.artifact.State != storage.TemporaryArtifactStateActive { + return errors.New("temporary artifact lease is not active") + } + at := l.registry.now().UTC() + if err := l.registry.store.HeartbeatTemporaryArtifact(ctx, l.artifact.ID, at); err != nil { + return err + } + l.artifact.LastHeartbeatAt = &at + return nil +} + +func (l *Lease) Close(ctx context.Context) error { + l.mu.Lock() + defer l.mu.Unlock() + if l.artifact.State != storage.TemporaryArtifactStateActive { + return nil + } + at := l.registry.now().UTC() + if err := l.registry.transitionAt( + ctx, l.artifact.ID, storage.TemporaryArtifactStateClosed, "", at, + ); err != nil { + return err + } + l.artifact.State = storage.TemporaryArtifactStateClosed + l.artifact.ClosedAt = &at + return nil +} + +func (l *Lease) Remove(ctx context.Context) error { + l.mu.Lock() + defer l.mu.Unlock() + if l.artifact.State == storage.TemporaryArtifactStateDeleted { + return nil + } + if err := l.registry.Validate(l.artifact); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.RemoveAll(l.artifact.Path); err != nil { + return fmt.Errorf("remove temporary artifact: %w", err) + } + if err := l.registry.MarkDeleted(ctx, l.artifact.ID); err != nil { + return err + } + l.artifact.State = storage.TemporaryArtifactStateDeleted + return nil +} + +func metadataWithRunID(metadata json.RawMessage, runID string) json.RawMessage { + fields := make(map[string]json.RawMessage) + trimmed := strings.TrimSpace(string(metadata)) + if trimmed != "" && trimmed != "null" { + if err := json.Unmarshal(metadata, &fields); err != nil || fields == nil { + encoded, _ := json.Marshal(string(metadata)) + fields = map[string]json.RawMessage{"_kandev_metadata": encoded} + } + } + encodedRunID, _ := json.Marshal(runID) + fields[registryRunIDMetadataKey] = encodedRunID + encoded, _ := json.Marshal(fields) + return encoded +} + +func metadataRunID(metadata json.RawMessage) string { + var fields map[string]json.RawMessage + if err := json.Unmarshal(metadata, &fields); err != nil || fields == nil { + return "" + } + var runID string + if err := json.Unmarshal(fields[registryRunIDMetadataKey], &runID); err != nil { + return "" + } + return runID +} + +func writeMarker(path string, marker markerData) error { + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return errors.New("temporary artifact marker is not a regular file") + } + return errors.New("temporary artifact marker already exists") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + tmp := path + ".tmp-" + marker.Token + file, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + removeTemp := true + defer func() { + _ = file.Close() + if removeTemp { + _ = os.Remove(tmp) + } + }() + if err := json.NewEncoder(file).Encode(marker); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + return err + } + removeTemp = false + return nil +} + +func readMarker(path string) (markerData, error) { + info, err := os.Lstat(path) + if err != nil { + return markerData{}, fmt.Errorf("inspect temporary artifact marker: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + return markerData{}, errors.New("temporary artifact marker is not owner-only regular data") + } + file, err := os.Open(path) + if err != nil { + return markerData{}, err + } + defer func() { _ = file.Close() }() + var marker markerData + if err := json.NewDecoder(file).Decode(&marker); err != nil { + return markerData{}, fmt.Errorf("decode temporary artifact marker: %w", err) + } + return marker, nil +} + +func prefixForKind(kind storage.TemporaryArtifactKind) (string, error) { + switch kind { + case storage.TemporaryArtifactKindImproveBundle: + return "kandev-improve-", nil + case storage.TemporaryArtifactKindHostUtility: + return "kandev-host-utility-", nil + default: + return "", fmt.Errorf("unknown temporary artifact kind %q", kind) + } +} + +func requireRealRoot(root string) error { + info, err := os.Lstat(root) + if err != nil { + return fmt.Errorf("inspect temporary artifact root: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("temporary artifact root is not a real directory: %s", root) + } + return nil +} diff --git a/apps/backend/internal/system/storage/tempartifacts/registry_test.go b/apps/backend/internal/system/storage/tempartifacts/registry_test.go new file mode 100644 index 0000000000..89e1fcffe8 --- /dev/null +++ b/apps/backend/internal/system/storage/tempartifacts/registry_test.go @@ -0,0 +1,256 @@ +package tempartifacts + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kandev/kandev/internal/system/storage" +) + +func TestRegistryCreatesAndClosesOwnedArtifact(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, time.August, 8, 12, 0, 0, 0, time.UTC) + store := newFakeArtifactStore() + registry := NewRegistry(Config{ + Store: store, TempRoot: root, OwnerPID: 4242, + 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 filepath.Dir(lease.Path()) != root || filepath.Base(lease.Path()) == "" { + t.Fatalf("artifact path = %q, want direct child of %q", lease.Path(), root) + } + marker := filepath.Join(lease.Path(), MarkerName) + info, err := os.Stat(marker) + if err != nil { + t.Fatalf("marker: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("marker mode = %o, want 600", info.Mode().Perm()) + } + + if err := lease.Heartbeat(context.Background()); err != nil { + t.Fatalf("Heartbeat: %v", err) + } + if err := lease.Close(context.Background()); err != nil { + t.Fatalf("Close: %v", err) + } + artifact, err := store.GetTemporaryArtifact(context.Background(), "artifact-1") + if err != nil { + t.Fatalf("GetTemporaryArtifact: %v", err) + } + if artifact.State != storage.TemporaryArtifactStateClosed || artifact.ClosedAt == nil { + t.Fatalf("artifact after close = %#v", artifact) + } +} + +func TestRegistryRejectsUnsafeOwnedArtifactPaths(t *testing.T) { + root := t.TempDir() + store := newFakeArtifactStore() + registry := NewRegistry(Config{Store: store, TempRoot: root, OwnerPID: 1}) + lease, err := registry.Create(context.Background(), storage.TemporaryArtifactKindHostUtility, nil) + if err != nil { + t.Fatalf("Create: %v", err) + } + artifact := store.artifacts[lease.Path()] + + marker := filepath.Join(lease.Path(), MarkerName) + if err := os.Remove(marker); err != nil { + t.Fatalf("remove marker: %v", err) + } + if err := registry.Validate(artifact); err == nil { + t.Fatal("missing marker was accepted") + } + + if err := writeMarker(marker, markerData{ID: artifact.ID, Kind: artifact.Kind, Token: "wrong"}); err != nil { + t.Fatalf("write mismatched marker: %v", err) + } + if err := registry.Validate(artifact); err == nil { + t.Fatal("mismatched marker was accepted") + } + + if err := os.RemoveAll(lease.Path()); err != nil { + t.Fatalf("remove artifact: %v", err) + } + outside := filepath.Join(root, "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatalf("mkdir outside: %v", err) + } + if err := os.Symlink(outside, lease.Path()); err != nil { + t.Fatalf("replace artifact with symlink: %v", err) + } + if err := registry.Validate(artifact); err == nil { + t.Fatal("symlink replacement was accepted") + } +} + +func TestRegistryReconcilesPreviousRunsAndDeadOwners(t *testing.T) { + root := t.TempDir() + store := newFakeArtifactStore() + oldRegistry := NewRegistry(Config{ + Store: store, TempRoot: root, OwnerPID: 4242, RunID: "old-run", + NewID: func() string { return "old-artifact" }, NewToken: func() string { return "old-token" }, + }) + oldLease, err := oldRegistry.Create(context.Background(), storage.TemporaryArtifactKindImproveBundle, nil) + if err != nil { + t.Fatalf("create old-run artifact: %v", err) + } + + registry := NewRegistry(Config{ + Store: store, TempRoot: root, OwnerPID: 4242, RunID: "current-run", + NewID: func() string { return "current-artifact" }, NewToken: func() string { return "current-token" }, + }) + currentLease, err := registry.Create(context.Background(), storage.TemporaryArtifactKindHostUtility, nil) + if err != nil { + t.Fatalf("create current-run artifact: %v", err) + } + deadPID := int64(os.Getpid()) + 1_000_000 + alive, known := processAlive(deadPID) + canCheckDead := known && !alive + if canCheckDead { + store.artifacts[filepath.Join(root, "kandev-host-utility-dead")] = storage.TemporaryArtifact{ + ID: "dead-artifact", Kind: storage.TemporaryArtifactKindHostUtility, + Path: filepath.Join(root, "kandev-host-utility-dead"), MarkerToken: "dead-token", + State: storage.TemporaryArtifactStateActive, OwnerPID: deadPID, + CreatedAt: time.Now().UTC(), Metadata: nil, + } + } + + if err := registry.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + oldArtifact, err := store.GetTemporaryArtifact(context.Background(), oldLease.Artifact().ID) + if err != nil { + t.Fatalf("get old-run artifact: %v", err) + } + if oldArtifact.State != storage.TemporaryArtifactStateAbandoned { + t.Fatalf("old-run artifact state = %q, want abandoned", oldArtifact.State) + } + currentArtifact, err := store.GetTemporaryArtifact(context.Background(), currentLease.Artifact().ID) + if err != nil { + t.Fatalf("get current-run artifact: %v", err) + } + if currentArtifact.State != storage.TemporaryArtifactStateActive { + t.Fatalf("current-run artifact state = %q, want active", currentArtifact.State) + } + if canCheckDead { + deadArtifact, err := store.GetTemporaryArtifact(context.Background(), "dead-artifact") + if err != nil { + t.Fatalf("get dead-owner artifact: %v", err) + } + if deadArtifact.State != storage.TemporaryArtifactStateAbandoned { + t.Fatalf("dead-owner artifact state = %q, want abandoned", deadArtifact.State) + } + } +} + +type fakeArtifactStore struct { + artifacts map[string]storage.TemporaryArtifact +} + +func newFakeArtifactStore() *fakeArtifactStore { + return &fakeArtifactStore{artifacts: make(map[string]storage.TemporaryArtifact)} +} + +func (s *fakeArtifactStore) CreateTemporaryArtifact(_ context.Context, artifact *storage.TemporaryArtifact) error { + if _, exists := s.artifacts[artifact.Path]; exists { + return errors.New("duplicate artifact path") + } + s.artifacts[artifact.Path] = *artifact + return nil +} + +func (s *fakeArtifactStore) GetTemporaryArtifact(_ context.Context, id string) (storage.TemporaryArtifact, error) { + for _, artifact := range s.artifacts { + if artifact.ID == id { + return artifact, nil + } + } + return storage.TemporaryArtifact{}, storage.ErrNotFound +} + +func (s *fakeArtifactStore) ListTemporaryArtifacts(context.Context) ([]storage.TemporaryArtifact, error) { + artifacts := make([]storage.TemporaryArtifact, 0, len(s.artifacts)) + for _, artifact := range s.artifacts { + artifacts = append(artifacts, artifact) + } + return artifacts, nil +} + +func (s *fakeArtifactStore) HeartbeatTemporaryArtifact(_ context.Context, id string, at time.Time) error { + for path, artifact := range s.artifacts { + if artifact.ID != id || artifact.State != storage.TemporaryArtifactStateActive { + continue + } + artifact.LastHeartbeatAt = &at + s.artifacts[path] = artifact + return nil + } + return storage.ErrNotFound +} + +func (s *fakeArtifactStore) TransitionTemporaryArtifact( + _ context.Context, + id string, + next storage.TemporaryArtifactState, + lastError string, + at time.Time, +) error { + for path, artifact := range s.artifacts { + if artifact.ID != id { + continue + } + if !validFakeTemporaryArtifactTransition(artifact.State, next) { + return storage.ErrInvalidTransition + } + at = at.UTC() + artifact.State = next + artifact.LastError = lastError + if next != storage.TemporaryArtifactStateFailed { + artifact.LastError = "" + } + switch next { + case storage.TemporaryArtifactStateClosed: + artifact.ClosedAt = &at + case storage.TemporaryArtifactStateQuarantined: + artifact.QuarantinedAt = &at + case storage.TemporaryArtifactStateDeleted: + artifact.DeletedAt = &at + } + s.artifacts[path] = artifact + return nil + } + return storage.ErrNotFound +} + +func validFakeTemporaryArtifactTransition( + current, next storage.TemporaryArtifactState, +) bool { + switch current { + case storage.TemporaryArtifactStateActive: + return next == storage.TemporaryArtifactStateClosed || next == storage.TemporaryArtifactStateAbandoned || + next == storage.TemporaryArtifactStateFailed || next == storage.TemporaryArtifactStateDeleted + case storage.TemporaryArtifactStateClosed, storage.TemporaryArtifactStateAbandoned: + return next == storage.TemporaryArtifactStateQuarantined || next == storage.TemporaryArtifactStateFailed || + next == storage.TemporaryArtifactStateDeleted + case storage.TemporaryArtifactStateFailed: + return next == storage.TemporaryArtifactStateClosed || next == storage.TemporaryArtifactStateQuarantined || + next == storage.TemporaryArtifactStateDeleted + case storage.TemporaryArtifactStateQuarantined: + return next == storage.TemporaryArtifactStateClosed || next == storage.TemporaryArtifactStateDeleted || + next == storage.TemporaryArtifactStateFailed + default: + return false + } +} diff --git a/apps/web/components/settings/system/storage/storage-confirmation-dialogs.test.tsx b/apps/web/components/settings/system/storage/storage-confirmation-dialogs.test.tsx index 1c3367e1a6..c2be0e16ef 100644 --- a/apps/web/components/settings/system/storage/storage-confirmation-dialogs.test.tsx +++ b/apps/web/components/settings/system/storage/storage-confirmation-dialogs.test.tsx @@ -6,6 +6,7 @@ import { ExternalGoCacheDialog, PermanentDeleteDialog, QuarantinePurgeDialog, + TemporaryArtifactsDialog, } from "./storage-confirmation-dialogs"; afterEach(cleanup); @@ -63,6 +64,18 @@ describe("storage confirmation dialogs", () => { "This will permanently remove 3 eligible items. 2 protected items remain until their retention deadlines. Type DELETE ELIGIBLE to continue.", ); }); + + it("confirms temporary artifact cleanup without a destructive type gate", () => { + const onConfirm = vi.fn(); + render(); + + expect(screen.getByText(/Eligible registered artifacts/)).toBeTruthy(); + const action = screen.getByTestId("storage-temporary-artifacts-confirm") as HTMLButtonElement; + expect(action.disabled).toBe(false); + fireEvent.click(action); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("textbox")).toBeNull(); + }); }); /** diff --git a/apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx b/apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx index 5953985774..09f45dd014 100644 --- a/apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx +++ b/apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx @@ -116,6 +116,38 @@ export function ExternalGoCacheDialog({ ); } +export function TemporaryArtifactsDialog({ + open, + onOpenChange, + onConfirm, +}: Pick) { + const { t } = useTranslation(); + return ( + + + + {t("system:storageTemporaryArtifactsDialogTitle")} + + {t("system:storageTemporaryArtifactsDialogDescription")} + + + + + {t("common:cancel")} + + + {t("system:storageTemporaryArtifactsDialogAction")} + + + + + ); +} + export function PermanentDeleteDialog({ entry, ...props 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 1cb0e54972..1b6400cf46 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 @@ -46,6 +46,7 @@ const overview = { capabilities: { managed_go_cache_path: "/data/cache/go-build", go_cache_adoption_available: true, + temporary_artifacts_available: true, docker_available: true, docker_host: "", host_global_docker_cleanup_allowed: true, @@ -54,6 +55,18 @@ const overview = { workspaces: { active_bytes: 0, candidate_bytes: 0 }, go_cache: { path: "/data/cache/go-build", size_bytes: 0, owned: true, enabled: false }, quarantine: { count: 0, size_bytes: 0 }, + temporary_artifacts: { + available: true, + total_count: 0, + total_bytes: 0, + active_count: 0, + active_bytes: 0, + protected_count: 0, + protected_bytes: 0, + stale_count: 0, + stale_bytes: 0, + skipped_count: 0, + }, docker: { available: true, build_cache_bytes: 0, @@ -315,6 +328,33 @@ describe("StorageMaintenanceSettings pending policy", () => { expect((screen.getByTestId("storage-go-cache-adopt") as HTMLButtonElement).disabled).toBe(true); }); + + it("confirms stale temporary artifact cleanup with an explicit resource selection", async () => { + const currentController = controller({ + ...overview, + summary: { + ...overview.summary, + temporary_artifacts: { + ...overview.summary.temporary_artifacts, + total_count: 1, + total_bytes: 1024, + stale_count: 1, + stale_bytes: 1024, + }, + }, + }); + mocks.useStorageMaintenance.mockReturnValue(currentController); + + render(, { wrapper: Providers }); + + fireEvent.click(screen.getByTestId("storage-resource-temporary-artifacts-trigger")); + fireEvent.click(screen.getByTestId("storage-temporary-artifacts-clean")); + expect(screen.getByText("Clean stale Kandev artifacts?")).toBeTruthy(); + fireEvent.click(screen.getByTestId("storage-temporary-artifacts-confirm")); + await waitFor(() => + expect(currentController.runNow).toHaveBeenCalledWith(["temporary_artifacts"]), + ); + }); }); describe("StorageMaintenanceSettings coordinated save", () => { 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 0ae639be3f..b78e9f1bb6 100644 --- a/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx +++ b/apps/web/components/settings/system/storage/storage-maintenance-settings.tsx @@ -20,6 +20,7 @@ import { StorageQuarantineCard } from "./storage-quarantine-card"; import { StorageRunHistory } from "./storage-run-history"; import { SettingsTarget } from "../../settings-target"; import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system"; +import { TemporaryArtifactsDialog } from "./storage-confirmation-dialogs"; function StorageJobButtonContent({ job, @@ -285,12 +286,14 @@ function StoragePrimarySections({ draft, setDraft, savedSettings, + onRunTemporaryArtifacts, }: { controller: ReturnType; disabledReason?: string; draft: Settings | null; setDraft: (settings: Settings | null) => void; savedSettings: Settings | null; + onRunTemporaryArtifacts: () => void; }) { const controlsPending = policyPendingAction(controller.pendingAction); const policyLoading = controller.loading?.policy ?? !savedSettings; @@ -309,6 +312,7 @@ function StoragePrimarySections({ error={controller.sectionErrors?.overview} disabledReason={disabledReason} onRunGoCache={() => void controller.runNow(["go_cache"])} + onRunTemporaryArtifacts={onRunTemporaryArtifacts} /> {draft && savedSettings && capabilities && ( @@ -358,9 +362,11 @@ function StorageQuarantineSection({ function StoragePageSections({ controller, disabledReason, + onRunTemporaryArtifacts, }: { controller: ReturnType; disabledReason?: string; + onRunTemporaryArtifacts: () => void; }) { const { draft, setDraft, savedSettings } = useStoragePolicyDraft(controller); return ( @@ -371,6 +377,7 @@ function StoragePageSections({ draft={draft} setDraft={setDraft} savedSettings={savedSettings} + onRunTemporaryArtifacts={onRunTemporaryArtifacts} /> @@ -397,7 +405,19 @@ export function StorageMaintenanceSettings() { - + setTemporaryCleanupOpen(true)} + /> + { + setTemporaryCleanupOpen(false); + void controller.runNow(["temporary_artifacts"]); + }} + /> ); } 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 57fd62445d..e8faeae52b 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 @@ -27,6 +27,7 @@ const degradedOverview = { capabilities: { managed_go_cache_path: "/data/cache/go-build", go_cache_adoption_available: true, + temporary_artifacts_available: false, docker_available: false, docker_host: "", host_global_docker_cleanup_allowed: false, @@ -35,6 +36,7 @@ const degradedOverview = { workspaces: { active_bytes: 0, candidate_bytes: 0 }, go_cache: { path: "/data/cache/go-build", size_bytes: 0, owned: true, enabled: false }, quarantine: { available: false, warning: "quarantine database unavailable" }, + temporary_artifacts: { available: false, warning: "temporary artifact registry unavailable" }, docker: { available: false, build_cache_bytes: 0, @@ -81,7 +83,9 @@ describe("StorageOverviewCard", () => { }); it("renders a degraded quarantine warning without inventing zero usage", () => { - render(); + render(, { + wrapper: TooltipProvider, + }); const trigger = screen.getByTestId("storage-resource-quarantine-trigger"); expect(trigger.textContent).toContain("Unavailable"); @@ -145,6 +149,61 @@ describe("StorageOverviewCard", () => { }); }); +describe("StorageOverviewCard temporary artifacts", () => { + it("renders unavailable temporary artifacts without inventing zero usage", () => { + render(, { + wrapper: TooltipProvider, + }); + + const trigger = screen.getByTestId("storage-resource-temporary-artifacts-trigger"); + expect(trigger.textContent).toContain("Unavailable"); + expect(trigger.textContent).not.toContain("0 B"); + fireEvent.click(trigger); + expect(screen.getByText("temporary artifact registry unavailable")).toBeTruthy(); + }); + + it("offers an explicit quarantine action for stale registered artifacts", () => { + const onRunTemporaryArtifacts = vi.fn(); + const overview = { + ...degradedOverview, + capabilities: { ...degradedOverview.capabilities, temporary_artifacts_available: true }, + summary: { + ...degradedOverview.summary, + temporary_artifacts: { + available: true, + total_count: 2, + total_bytes: 12 * 1024 ** 2, + active_count: 1, + active_bytes: 4 * 1024 ** 2, + protected_count: 1, + protected_bytes: 4 * 1024 ** 2, + stale_count: 1, + stale_bytes: 4 * 1024 ** 2, + skipped_count: 0, + }, + }, + } satisfies StorageOverviewResponse; + + render( + , + { wrapper: TooltipProvider }, + ); + + fireEvent.click(screen.getByTestId("storage-resource-temporary-artifacts-trigger")); + expect(screen.getByTestId("storage-resource-temporary-artifacts").textContent).toContain( + "<0.01 GB eligible", + ); + const cleanButton = screen.getByTestId("storage-temporary-artifacts-clean"); + expect((cleanButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(cleanButton); + expect(onRunTemporaryArtifacts).toHaveBeenCalledTimes(1); + }); +}); + describe("StorageOverviewCard refresh and policy state", () => { it("uses the current saved policy for Go cache cleanup eligibility", () => { const overview = { 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 477fb383ef..a8a8884a29 100644 --- a/apps/web/components/settings/system/storage/storage-overview-card.tsx +++ b/apps/web/components/settings/system/storage/storage-overview-card.tsx @@ -9,6 +9,7 @@ import type { StorageMaintenanceSettings, StorageOverviewResponse, StorageQuarantineSummary, + StorageTemporaryArtifactsSummary, } from "@/lib/types/system"; import { StorageActionButton } from "./storage-action-button"; import { formatGigabytes } from "./storage-units"; @@ -21,6 +22,8 @@ import { storageAnalysisTotal } from "./storage-totals"; * module-level one, so the rows re-render on a locale switch. */ type Translate = (key: string, options?: Record) => string; +const STORAGE_UNAVAILABLE_VALUE_KEY = "system:storageUnavailableValue"; +const TEMPORARY_ARTIFACTS_RESOURCE_ID = "temporary-artifacts"; interface Props { overview: StorageOverviewResponse | null; @@ -29,6 +32,7 @@ interface Props { error?: string | null; disabledReason?: string; onRunGoCache: () => void; + onRunTemporaryArtifacts?: () => void; } interface StorageResource { @@ -63,7 +67,7 @@ function quarantineResource(t: Translate, summary: StorageQuarantineSummary): St return { id: "quarantine", label: t("system:storageQuarantinedResources"), - value: t("system:storageUnavailableValue"), + value: t(STORAGE_UNAVAILABLE_VALUE_KEY), detail: t("system:storageQuarantineUnmeasured"), warning: summary.warning, }; @@ -84,13 +88,71 @@ function dockerMeasurement( ): Pick { if (!available) { return { - value: t("system:storageUnavailableValue"), + value: t(STORAGE_UNAVAILABLE_VALUE_KEY), detail: t("system:storageDockerUnmeasured"), }; } return { value, detail }; } +function temporaryArtifactsResource( + t: Translate, + summary: StorageTemporaryArtifactsSummary, +): StorageResource { + const warnings = [summary.warning, ...(summary.warnings ?? [])].filter(Boolean).join(" · "); + if (summary.available === false) { + return { + id: TEMPORARY_ARTIFACTS_RESOURCE_ID, + label: t("system:storageTemporaryArtifacts"), + value: t(STORAGE_UNAVAILABLE_VALUE_KEY), + detail: t("system:storageTemporaryArtifactsUnavailable"), + warning: warnings || undefined, + }; + } + const stale = summary.stale_count ?? 0; + const active = summary.active_count ?? 0; + const protectedCount = summary.protected_count ?? 0; + const staleBytes = + summary.stale_bytes === undefined + ? undefined + : t("system:storageTemporaryArtifactsStaleBytes", { + value: formatGigabytes(summary.stale_bytes), + }); + return { + id: TEMPORARY_ARTIFACTS_RESOURCE_ID, + label: t("system:storageTemporaryArtifacts"), + value: + summary.total_bytes === undefined + ? t(STORAGE_UNAVAILABLE_VALUE_KEY) + : formatGigabytes(summary.total_bytes), + detail: [ + t("system:storageTemporaryArtifactsStaleCount", { count: stale }), + staleBytes, + t("system:storageTemporaryArtifactsActiveCount", { count: active }), + t("system:storageTemporaryArtifactsProtectedCount", { count: protectedCount }), + ] + .filter(Boolean) + .join(" · "), + warning: warnings || undefined, + }; +} + +function temporaryArtifactsDisabledReason( + t: Translate, + overview: StorageOverviewResponse, + pendingReason?: string, +) { + if (pendingReason) return pendingReason; + const summary = overview.summary.temporary_artifacts; + if (overview.capabilities.temporary_artifacts_available !== true || summary.available === false) { + return t("system:storageTemporaryArtifactsUnavailable"); + } + if ((summary.stale_count ?? 0) === 0) { + return t("system:storageTemporaryArtifactsNoStale"); + } + return undefined; +} + function storageResources(t: Translate, overview: StorageOverviewResponse): StorageResource[] { const { summary } = overview; const dockerWarning = summary.docker.warnings?.join(" · "); @@ -139,6 +201,7 @@ function storageResources(t: Translate, overview: StorageOverviewResponse): Stor }, ] : []), + temporaryArtifactsResource(t, summary.temporary_artifacts), { id: "docker-image-layers", label: t("system:storageDockerImageLayers"), @@ -179,9 +242,17 @@ interface ResourceRowProps { resource: StorageResource; goCacheCleanupDisabledReason?: string; onRunGoCache: () => void; + temporaryArtifactsCleanupDisabledReason?: string; + onRunTemporaryArtifacts: () => void; } -function ResourceRow({ resource, goCacheCleanupDisabledReason, onRunGoCache }: ResourceRowProps) { +function ResourceRow({ + resource, + goCacheCleanupDisabledReason, + onRunGoCache, + temporaryArtifactsCleanupDisabledReason, + onRunTemporaryArtifacts, +}: ResourceRowProps) { const { t } = useTranslation(); return ( @@ -208,6 +279,17 @@ function ResourceRow({ resource, goCacheCleanupDisabledReason, onRunGoCache }: R {t("system:storageCleanGoCache")} )} + {resource.id === TEMPORARY_ARTIFACTS_RESOURCE_ID && ( + + {t("system:storageCleanTemporaryArtifacts")} + + )} ); @@ -220,6 +302,7 @@ export function StorageOverviewCard({ error, disabledReason, onRunGoCache, + onRunTemporaryArtifacts = () => {}, }: Props) { const { t } = useTranslation(); const isLoading = loading ?? overview === null; @@ -239,6 +322,11 @@ export function StorageOverviewCard({ const { summary } = overview; const analyzedAt = formatDateTime(overview.analyzed_at); const cleanupDisabledReason = goCacheDisabledReason(t, overview, disabledReason, settings); + const temporaryArtifactsCleanupDisabledReason = temporaryArtifactsDisabledReason( + t, + overview, + disabledReason, + ); const resources = storageResources(t, overview); const total = storageAnalysisTotal(summary); return ( @@ -287,6 +375,8 @@ export function StorageOverviewCard({ resource={resource} goCacheCleanupDisabledReason={cleanupDisabledReason} onRunGoCache={onRunGoCache} + temporaryArtifactsCleanupDisabledReason={temporaryArtifactsCleanupDisabledReason} + onRunTemporaryArtifacts={onRunTemporaryArtifacts} /> ))} 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 4a6ef4937c..418333630c 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 @@ -26,6 +26,7 @@ const settings: StorageMaintenanceSettings = { const capabilities = { managed_go_cache_path: "/data/cache/go-build", go_cache_adoption_available: true, + temporary_artifacts_available: true, docker_available: true, docker_host: "", host_global_docker_cleanup_allowed: true, diff --git a/apps/web/components/settings/system/storage/storage-quarantine-card.tsx b/apps/web/components/settings/system/storage/storage-quarantine-card.tsx index 61d10b4bd7..18374ac43e 100644 --- a/apps/web/components/settings/system/storage/storage-quarantine-card.tsx +++ b/apps/web/components/settings/system/storage/storage-quarantine-card.tsx @@ -31,6 +31,7 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647; const RESOURCE_TYPE_LABEL_KEYS: Record = { task_workspace: "system:storageResourceTypeTaskWorkspace", go_cache: "system:storageResourceTypeGoCache", + temporary_artifact: "system:storageResourceTypeTemporaryArtifact", }; function resourceTypeLabel( 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 9e9a1f86ed..61957adf88 100644 --- a/apps/web/components/settings/system/storage/storage-totals.test.ts +++ b/apps/web/components/settings/system/storage/storage-totals.test.ts @@ -16,6 +16,18 @@ const completeSummary: StorageSummary = { unmanaged_size_bytes: 4, }, quarantine: { count: 1, size_bytes: 2 }, + temporary_artifacts: { + available: true, + total_count: 1, + total_bytes: 12, + active_count: 0, + active_bytes: 0, + protected_count: 0, + protected_bytes: 0, + stale_count: 1, + stale_bytes: 12, + skipped_count: 0, + }, docker: { available: true, managed_container_count: 1, @@ -28,7 +40,7 @@ const completeSummary: StorageSummary = { describe("storageAnalysisTotal", () => { it("sums non-overlapping top-level measurements", () => { - expect(storageAnalysisTotal(completeSummary)).toEqual({ bytes: 37, partial: false }); + expect(storageAnalysisTotal(completeSummary)).toEqual({ bytes: 49, partial: false }); }); it("excludes workspace subsets and unused Docker images", () => { @@ -51,7 +63,7 @@ describe("storageAnalysisTotal", () => { docker: { ...completeSummary.docker, available: false }, }; - expect(storageAnalysisTotal(summary)).toEqual({ bytes: 4, partial: true }); + expect(storageAnalysisTotal(summary)).toEqual({ bytes: 16, partial: true }); }); it("does not require a user cache when no distinct path is reported", () => { @@ -64,7 +76,7 @@ describe("storageAnalysisTotal", () => { }, }; - expect(storageAnalysisTotal(summary)).toEqual({ bytes: 33, partial: false }); + expect(storageAnalysisTotal(summary)).toEqual({ bytes: 45, partial: false }); }); }); diff --git a/apps/web/components/settings/system/storage/storage-totals.ts b/apps/web/components/settings/system/storage/storage-totals.ts index d589f80cd5..fb4e80af32 100644 --- a/apps/web/components/settings/system/storage/storage-totals.ts +++ b/apps/web/components/settings/system/storage/storage-totals.ts @@ -27,6 +27,10 @@ export function storageAnalysisTotal(summary: StorageSummary): StorageAnalysisTo if (summary.go_cache.unmanaged_path) { addMeasurement(summary.go_cache.unmanaged_size_bytes); } + addMeasurement( + summary.temporary_artifacts.total_bytes, + summary.temporary_artifacts.available !== false, + ); if (summary.docker.available) { addMeasurement(summary.docker.managed_container_bytes); diff --git a/apps/web/e2e/helpers/storage-maintenance.ts b/apps/web/e2e/helpers/storage-maintenance.ts index be8a9fb1ca..b6da931739 100644 --- a/apps/web/e2e/helpers/storage-maintenance.ts +++ b/apps/web/e2e/helpers/storage-maintenance.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import type { Page } from "@playwright/test"; const ABOVE_DEFAULT_LIMIT_BYTES = 16 * 1024 * 1024 * 1024; @@ -13,3 +14,44 @@ export function seedManagedGoCache(tmpDir: string): { artifact: string } { fs.truncateSync(artifact, ABOVE_DEFAULT_LIMIT_BYTES); return { artifact }; } + +export async function mockTemporaryArtifactOverview(page: Page): Promise { + await page.route("**/api/v1/system/storage", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + // Fetch outside Playwright's route response lifecycle. The managed E2E + // runner can dispose a route.fetch() response while a page navigation is + // still settling, which makes response.body() intermittently unavailable. + 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 { + capabilities: Record; + summary: Record; + }; + body.capabilities.temporary_artifacts_available = true; + body.summary.temporary_artifacts = { + available: true, + total_count: 3, + total_bytes: 48 * 1024 * 1024, + active_count: 1, + active_bytes: 16 * 1024 * 1024, + protected_count: 1, + protected_bytes: 16 * 1024 * 1024, + stale_count: 1, + stale_bytes: 16 * 1024 * 1024, + skipped_count: 0, + }; + await route.fulfill({ + status: response.status, + contentType: "application/json", + body: JSON.stringify(body), + }); + }); +} diff --git a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts index 5a9ec54043..8fc299f0ea 100644 --- a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts +++ b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts @@ -2,10 +2,64 @@ import fs from "node:fs"; import path from "node:path"; import type { Route } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; -import { seedManagedGoCache } from "../../helpers/storage-maintenance"; +import { + mockTemporaryArtifactOverview, + seedManagedGoCache, +} from "../../helpers/storage-maintenance"; import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; test.describe("Mobile storage maintenance", () => { + test("keeps temporary artifact cleanup reachable with a touch-sized action", 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-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-cleanup", + kind: "storage-cleanup", + state: "succeeded", + started_at: new Date().toISOString(), + }), + }); + }); + + await testPage.goto("/settings/system/storage"); + const trigger = testPage.getByTestId("storage-resource-temporary-artifacts-trigger"); + await trigger.tap(); + const cleanButton = testPage.getByTestId("storage-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("temporary-artifacts-confirmation", { + caption: "Mobile storage keeps stale artifact cleanup in a reachable confirmation", + }); + await testPage.getByTestId("storage-temporary-artifacts-confirm").tap(); + await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute( + "data-job-state", + "succeeded", + ); + await expect + .poll(() => + testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ) + .toBe(true); + }); + test("explains busy activity and allows Run anyway without horizontal overflow", async ({ testPage, prCapture, diff --git a/apps/web/e2e/tests/system/storage-maintenance.spec.ts b/apps/web/e2e/tests/system/storage-maintenance.spec.ts index 9bd8cce902..0da226c413 100644 --- a/apps/web/e2e/tests/system/storage-maintenance.spec.ts +++ b/apps/web/e2e/tests/system/storage-maintenance.spec.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import type { Route } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; -import { seedManagedGoCache } from "../../helpers/storage-maintenance"; +import { + mockTemporaryArtifactOverview, + seedManagedGoCache, +} from "../../helpers/storage-maintenance"; function seedOrphanWorkspace(tmpDir: string): { root: string; artifact: string } { const root = path.join(tmpDir, ".kandev", "tasks", "e2e-storage-orphan_abc"); @@ -25,6 +28,54 @@ function seedOrphanWorkspace(tmpDir: string): { root: string; artifact: string } } test.describe("System storage maintenance", () => { + test("shows registered temporary artifacts and cleans them only 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-cleanup" }), + }); + }); + await testPage.route("**/api/v1/system/jobs/**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "temporary-artifacts-cleanup", + kind: "storage-cleanup", + state: "succeeded", + started_at: new Date().toISOString(), + }), + }); + }); + + await testPage.goto("/settings/system/storage"); + const trigger = testPage.getByTestId("storage-resource-temporary-artifacts-trigger"); + await expect(trigger).toContainText("0.05 GB"); + await trigger.click(); + await expect(testPage.getByTestId("storage-resource-temporary-artifacts")).toContainText( + "1 stale eligible", + ); + const cleanButton = testPage.getByTestId("storage-temporary-artifacts-clean"); + await expect(cleanButton).toBeEnabled(); + await cleanButton.click(); + await expect(testPage.getByText("Clean stale Kandev artifacts?")).toBeVisible(); + await prCapture.screenshot("temporary-artifacts-confirmation", { + caption: "Desktop storage confirms stale registered artifacts before quarantine", + }); + await testPage.getByTestId("storage-temporary-artifacts-confirm").click(); + await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute( + "data-job-state", + "succeeded", + ); + }); + test("cleans a disabled managed Go cache only through its explicit action", async ({ testPage, backend, @@ -35,7 +86,19 @@ test.describe("System storage maintenance", () => { (response) => new URL(response.url()).pathname === "/api/v1/system/storage", ); await testPage.goto("/settings/system/storage"); - const overview = await (await overviewResponse).json(); + await overviewResponse; + // The preceding temporary-artifact scenario loads and caches a Storage + // snapshot. Force a fresh read after seeding this test's cache so this + // assertion exercises the filesystem fixture rather than the prior page. + await testPage.getByTestId("storage-analyze").click(); + await expect(testPage.getByTestId("storage-analyze")).toHaveAttribute( + "data-job-state", + "succeeded", + ); + const overview = await testPage.evaluate(async () => { + const response = await fetch("/api/v1/system/storage"); + return response.json(); + }); expect(overview.summary.go_cache).toMatchObject({ owned: true }); expect(overview.summary.go_cache.size_bytes).toBeGreaterThan(15 * 1024 * 1024 * 1024); await testPage.getByTestId("storage-resource-go-cache-trigger").click(); diff --git a/apps/web/hooks/domains/system/use-storage-maintenance.test.tsx b/apps/web/hooks/domains/system/use-storage-maintenance.test.tsx index 8f76c363c4..2982f1e30c 100644 --- a/apps/web/hooks/domains/system/use-storage-maintenance.test.tsx +++ b/apps/web/hooks/domains/system/use-storage-maintenance.test.tsx @@ -75,6 +75,7 @@ const overview: StorageOverviewResponse = { capabilities: { managed_go_cache_path: "/data/cache/go-build", go_cache_adoption_available: true, + temporary_artifacts_available: true, docker_available: true, docker_host: "unix:///var/run/docker.sock", host_global_docker_cleanup_allowed: true, @@ -83,6 +84,18 @@ const overview: StorageOverviewResponse = { workspaces: { active_bytes: 10, candidate_bytes: 20 }, go_cache: { path: "/data/cache/go-build", size_bytes: 30, owned: true, enabled: false }, quarantine: { count: 2, size_bytes: 35 }, + temporary_artifacts: { + available: true, + total_count: 0, + total_bytes: 0, + active_count: 0, + active_bytes: 0, + protected_count: 0, + protected_bytes: 0, + stale_count: 0, + stale_bytes: 0, + skipped_count: 0, + }, docker: { available: true, build_cache_bytes: 40, 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 c149e409f2..7b649d5930 100644 --- a/apps/web/lib/state/slices/system/system-slice.test.ts +++ b/apps/web/lib/state/slices/system/system-slice.test.ts @@ -110,6 +110,7 @@ describe("system storage slice", () => { capabilities: { managed_go_cache_path: "/data/cache/go-build", go_cache_adoption_available: true, + temporary_artifacts_available: false, docker_available: false, docker_host: "", host_global_docker_cleanup_allowed: false, @@ -118,6 +119,10 @@ describe("system storage slice", () => { workspaces: { active_bytes: 1, candidate_bytes: 2 }, go_cache: { path: "/data/cache/go-build", size_bytes: 3, owned: true, enabled: false }, quarantine: { count: 0, size_bytes: 0 }, + temporary_artifacts: { + available: false, + warning: "temporary artifact registry unavailable", + }, docker: { available: false, build_cache_bytes: 0, diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index dff14a37a6..f554e0f134 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -295,6 +295,7 @@ export interface StorageMaintenanceSettings { export interface StorageCapabilities { managed_go_cache_path: string; go_cache_adoption_available: boolean; + temporary_artifacts_available: boolean; docker_available: boolean; docker_host: string; host_global_docker_cleanup_allowed: boolean; @@ -344,10 +345,26 @@ export type StorageQuarantineSummary = size_bytes?: never; }; +export interface StorageTemporaryArtifactsSummary { + available?: boolean; + total_count?: number; + total_bytes?: number; + active_count?: number; + active_bytes?: number; + protected_count?: number; + protected_bytes?: number; + stale_count?: number; + stale_bytes?: number; + skipped_count?: number; + warnings?: string[]; + warning?: string; +} + export interface StorageSummary { workspaces: StorageWorkspaceSummary; go_cache: StorageGoCacheSummary; quarantine: StorageQuarantineSummary; + temporary_artifacts: StorageTemporaryArtifactsSummary; docker: StorageDockerSummary; } @@ -383,7 +400,7 @@ export interface StorageMaintenanceRun { export interface StorageQuarantineEntry { id: string; - resource_type: "task_workspace" | "go_cache"; + resource_type: "task_workspace" | "go_cache" | "temporary_artifact"; task_id?: string; workspace_id?: string; original_path: string; diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json index 7aa7c69541..57629c608a 100644 --- a/apps/web/src/locales/en/system.json +++ b/apps/web/src/locales/en/system.json @@ -280,6 +280,7 @@ "storageCleanContainersAria": "Clean Kandev containers", "storageCleanDockerBuildCacheAria": "Clean Docker build cache", "storageCleanGoCache": "Clean Go cache", + "storageCleanTemporaryArtifacts": "Clean stale artifacts", "storageCleaning": "Cleaning...", "storageCleanOrphanWorkspacesAria": "Clean orphan task workspaces", "storageCleanUnusedImagesAria": "Clean unused Docker images", @@ -393,6 +394,7 @@ "storageRefreshFailed": "Refresh storage data: {{message}}", "storageResourceTypeGoCache": "go cache", "storageResourceTypeTaskWorkspace": "task workspace", + "storageResourceTypeTemporaryArtifact": "temporary artifact", "storageRestore": "Restore", "storageRetentionEnds": "Retention ends {{deadline}}.", "storageRunAnyway": "Run anyway", @@ -404,6 +406,19 @@ "storageScheduleTitle": "Schedule", "storageSectionUnavailable": "This storage section could not be loaded", "storageTaskWorkspaces": "Task workspaces", + "storageTemporaryArtifacts": "Kandev temporary artifacts", + "storageTemporaryArtifactsActiveCount_one": "{{count}} active", + "storageTemporaryArtifactsActiveCount_other": "{{count}} active", + "storageTemporaryArtifactsDialogAction": "Move to quarantine", + "storageTemporaryArtifactsDialogDescription": "Eligible registered artifacts will be moved to quarantine and can be restored during the retention period. Active or recent artifacts are protected.", + "storageTemporaryArtifactsDialogTitle": "Clean stale Kandev artifacts?", + "storageTemporaryArtifactsNoStale": "No registered temporary artifacts are older than 24 hours.", + "storageTemporaryArtifactsProtectedCount_one": "{{count}} protected", + "storageTemporaryArtifactsProtectedCount_other": "{{count}} protected", + "storageTemporaryArtifactsStaleCount_one": "{{count}} stale eligible", + "storageTemporaryArtifactsStaleCount_other": "{{count}} stale eligible", + "storageTemporaryArtifactsStaleBytes": "{{value}} eligible", + "storageTemporaryArtifactsUnavailable": "Temporary artifact ownership could not be measured", "storageTitle": "Storage", "storageToastAnalysisStarted": "Storage analysis started", "storageToastCacheAdopted": "Go build cache adopted", diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json index d94069b8d9..786ee31ee7 100644 --- a/apps/web/src/locales/pseudo/system.json +++ b/apps/web/src/locales/pseudo/system.json @@ -280,6 +280,7 @@ "storageCleanContainersAria": "Ćĺēàń Ķàńďēv ćōńţàĩńēŕś", "storageCleanDockerBuildCacheAria": "Ćĺēàń Ďōćķēŕ ƀũĩĺď ćàćĥē", "storageCleanGoCache": "Ćĺēàń Ĝō ćàćĥē", + "storageCleanTemporaryArtifacts": "Ćĺēàń śţàĺē àŕţĩƒàćţś", "storageCleaning": "Ćĺēàńĩńĝ...", "storageCleanOrphanWorkspacesAria": "Ćĺēàń ōŕƥĥàń ţàśķ ŵōŕķśƥàćēś", "storageCleanUnusedImagesAria": "Ćĺēàń ũńũśēď Ďōćķēŕ ĩḿàĝēś", @@ -393,6 +394,7 @@ "storageRefreshFailed": "Ŕēƒŕēśĥ śţōŕàĝē ďàţà: {{message}}", "storageResourceTypeGoCache": "ĝō ćàćĥē", "storageResourceTypeTaskWorkspace": "ţàśķ ŵōŕķśƥàćē", + "storageResourceTypeTemporaryArtifact": "ţēḿƥōŕàŕŷ àŕţĩƒàćţ", "storageRestore": "Ŕēśţōŕē", "storageRetentionEnds": "Ŕēţēńţĩōń ēńďś {{deadline}}.", "storageRunAnyway": "Ŕũń àńŷŵàŷ", @@ -404,6 +406,19 @@ "storageScheduleTitle": "Śćĥēďũĺē", "storageSectionUnavailable": "Ţĥĩś śţōŕàĝē śēćţĩōń ćōũĺď ńōţ ƀē ĺōàďēď", "storageTaskWorkspaces": "Ţàśķ ŵōŕķśƥàćēś", + "storageTemporaryArtifacts": "Ķàńďēv ţēḿƥōŕàŕŷ àŕţĩƒàćţś", + "storageTemporaryArtifactsActiveCount_one": "{{count}} àćţĩvē", + "storageTemporaryArtifactsActiveCount_other": "{{count}} àćţĩvē", + "storageTemporaryArtifactsDialogAction": "Ḿōvē ţō qũàŕàńţĩńē", + "storageTemporaryArtifactsDialogDescription": "Ēĺĩĝĩƀĺē ŕēĝĩśţēŕēď àŕţĩƒàćţś ŵĩĺĺ ƀē ḿōvēď ţō qũàŕàńţĩńē àńď ćàń ƀē ŕēśţōŕēď ďũŕĩńĝ ţĥē ŕēţēńţĩōń ƥēŕĩōď. Àćţĩvē ōŕ ŕēćēńţ àŕţĩƒàćţś àŕē ƥŕōţēćţēď.", + "storageTemporaryArtifactsDialogTitle": "Ćĺēàń śţàĺē Ķàńďēv àŕţĩƒàćţś?", + "storageTemporaryArtifactsNoStale": "Ńō ŕēĝĩśţēŕēď ţēḿƥōŕàŕŷ àŕţĩƒàćţś àŕē ōĺďēŕ ţĥàń 24 ĥōũŕś.", + "storageTemporaryArtifactsProtectedCount_one": "{{count}} ƥŕōţēćţēď", + "storageTemporaryArtifactsProtectedCount_other": "{{count}} ƥŕōţēćţēď", + "storageTemporaryArtifactsStaleCount_one": "{{count}} śţàĺē ēĺĩĝĩƀĺē", + "storageTemporaryArtifactsStaleCount_other": "{{count}} śţàĺē ēĺĩĝĩƀĺē", + "storageTemporaryArtifactsStaleBytes": "{{value}} ēĺĩĝĩƀĺē", + "storageTemporaryArtifactsUnavailable": "Ţēḿƥōŕàŕŷ àŕţĩƒàćţ ōŵńēŕśĥĩƥ ćōũĺď ńōţ ƀē ḿēàśũŕēď", "storageTitle": "Śţōŕàĝē", "storageToastAnalysisStarted": "Śţōŕàĝē àńàĺŷśĩś śţàŕţēď", "storageToastCacheAdopted": "Ĝō ƀũĩĺď ćàćĥē àďōƥţēď", diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json index 574349d19c..d9e66810e4 100644 --- a/apps/web/src/locales/pt-pt/system.json +++ b/apps/web/src/locales/pt-pt/system.json @@ -280,6 +280,7 @@ "storageCleanContainersAria": "Limpar os contentores do Kandev", "storageCleanDockerBuildCacheAria": "Limpar a cache de compilação do Docker", "storageCleanGoCache": "Limpar a cache do Go", + "storageCleanTemporaryArtifacts": "Limpar artefactos antigos", "storageCleaning": "A limpar...", "storageCleanOrphanWorkspacesAria": "Limpar as áreas de trabalho de tarefas órfãs", "storageCleanUnusedImagesAria": "Limpar as imagens Docker não utilizadas", @@ -393,6 +394,7 @@ "storageRefreshFailed": "Atualizar os dados de armazenamento: {{message}}", "storageResourceTypeGoCache": "cache do Go", "storageResourceTypeTaskWorkspace": "área de trabalho da tarefa", + "storageResourceTypeTemporaryArtifact": "artefacto temporário", "storageRestore": "Restaurar", "storageRetentionEnds": "A retenção termina {{deadline}}.", "storageRunAnyway": "Executar mesmo assim", @@ -404,6 +406,19 @@ "storageScheduleTitle": "Agendamento", "storageSectionUnavailable": "Não foi possível carregar esta secção de armazenamento", "storageTaskWorkspaces": "Áreas de trabalho de tarefas", + "storageTemporaryArtifacts": "Artefactos temporários do Kandev", + "storageTemporaryArtifactsActiveCount_one": "{{count}} ativo", + "storageTemporaryArtifactsActiveCount_other": "{{count}} ativos", + "storageTemporaryArtifactsDialogAction": "Mover para a quarentena", + "storageTemporaryArtifactsDialogDescription": "Os artefactos registados elegíveis serão movidos para a quarentena e poderão ser restaurados durante o período de retenção. Os artefactos ativos ou recentes ficam protegidos.", + "storageTemporaryArtifactsDialogTitle": "Limpar artefactos antigos do Kandev?", + "storageTemporaryArtifactsNoStale": "Não existem artefactos temporários registados com mais de 24 horas.", + "storageTemporaryArtifactsProtectedCount_one": "{{count}} protegido", + "storageTemporaryArtifactsProtectedCount_other": "{{count}} protegidos", + "storageTemporaryArtifactsStaleCount_one": "{{count}} antigo elegível", + "storageTemporaryArtifactsStaleCount_other": "{{count}} antigos elegíveis", + "storageTemporaryArtifactsStaleBytes": "{{value}} elegíveis", + "storageTemporaryArtifactsUnavailable": "Não foi possível medir a propriedade dos artefactos temporários", "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 874a802a97..d68313d049 100644 --- a/apps/web/src/locales/zh-cn/system.json +++ b/apps/web/src/locales/zh-cn/system.json @@ -280,6 +280,7 @@ "storageCleanContainersAria": "清理 Kandev 容器", "storageCleanDockerBuildCacheAria": "清理 Docker 构建缓存", "storageCleanGoCache": "清理 Go 缓存", + "storageCleanTemporaryArtifacts": "清理过期临时文件", "storageCleaning": "正在清理…", "storageCleanOrphanWorkspacesAria": "清理孤立任务工作区", "storageCleanUnusedImagesAria": "清理未使用的 Docker 镜像", @@ -393,6 +394,7 @@ "storageRefreshFailed": "刷新存储数据:{{message}}", "storageResourceTypeGoCache": "go 缓存", "storageResourceTypeTaskWorkspace": "任务工作区", + "storageResourceTypeTemporaryArtifact": "临时文件", "storageRestore": "恢复", "storageRetentionEnds": "保留期结束于 {{deadline}}。", "storageRunAnyway": "仍然运行", @@ -404,6 +406,19 @@ "storageScheduleTitle": "计划", "storageSectionUnavailable": "无法加载此存储区域", "storageTaskWorkspaces": "任务工作区", + "storageTemporaryArtifacts": "Kandev 临时文件", + "storageTemporaryArtifactsActiveCount_one": "{{count}} 个活动中", + "storageTemporaryArtifactsActiveCount_other": "{{count}} 个活动中", + "storageTemporaryArtifactsDialogAction": "移入隔离区", + "storageTemporaryArtifactsDialogDescription": "符合条件的已登记临时文件会先移入隔离区,并可在保留期内恢复。活动中或最近创建的文件会受到保护。", + "storageTemporaryArtifactsDialogTitle": "清理过期的 Kandev 临时文件?", + "storageTemporaryArtifactsNoStale": "没有超过 24 小时的已登记临时文件。", + "storageTemporaryArtifactsProtectedCount_one": "{{count}} 个受保护", + "storageTemporaryArtifactsProtectedCount_other": "{{count}} 个受保护", + "storageTemporaryArtifactsStaleCount_one": "{{count}} 个过期可清理", + "storageTemporaryArtifactsStaleCount_other": "{{count}} 个过期可清理", + "storageTemporaryArtifactsStaleBytes": "{{value}} 可清理", + "storageTemporaryArtifactsUnavailable": "无法测量临时文件的所有权", "storageTitle": "存储", "storageToastAnalysisStarted": "已开始存储分析", "storageToastCacheAdopted": "已接管 Go 构建缓存", diff --git a/docs/decisions/2026-08-08-owned-temp-artifact-cleanup.md b/docs/decisions/2026-08-08-owned-temp-artifact-cleanup.md new file mode 100644 index 0000000000..97671e74d0 --- /dev/null +++ b/docs/decisions/2026-08-08-owned-temp-artifact-cleanup.md @@ -0,0 +1,74 @@ +# ADR-2026-08-08-owned-temp-artifact-cleanup: Clean only registered Kandev temporary artifacts + +**Status:** accepted +**Date:** 2026-08-08 +**Area:** backend, frontend, infra, security + +## Context + +Kandev host-local agents intentionally inherit the service's `TMPDIR`, `TMP`, and `TEMP`, so the +operating-system temporary directory can contain both Kandev scratch and unrelated user, tool, CI, +preview, or other-installation data. A name such as `kandev-*`, an old modification time, or a live +path prefix does not prove that a directory belongs to the current Kandev installation. + +The Storage page should still give operators a safe way to reclaim abandoned temporary directories +created by Kandev service components. This requires a narrower ownership boundary than a general +`/tmp` sweep. + +## Decision + +Add a typed `temporary_artifacts` storage provider with these rules: + +- The provider acts only on artifact records created by the current Kandev storage registry. Each + record stores the exact absolute path, artifact kind, opaque artifact ID, lifecycle timestamps, + owner lease/heartbeat state, and a marker filename inside the artifact root. The marker and record + must match before the path is considered. +- The registry accepts only compiled-in service artifact kinds and creates top-level directories + beneath the effective service temporary root. It rejects symlinked roots, nested paths, and + arbitrary caller-supplied prefixes. The first producers are long-lived backend service roots such + as Improve Kandev bundles and the host-utility parent; standalone CLI, preview, CI, and test + harness roots are not implicitly registered. +- An active lease or recent heartbeat protects an artifact. A closed or abandoned artifact becomes + eligible only after a fixed 24-hour stale interval. Unreadable records, missing or mismatched + markers, path replacement, uncertain liveness, and filesystem errors are skipped fail-closed. +- The provider is manual-only in its first release. `POST /storage/run` with the explicit + `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. +- 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. + +This supplements ADR 0045: inherited shared temporary data remains unowned by default, while a +durable registry record plus matching marker creates a deliberately narrow, typed exception for +specific service-created roots. + +## Consequences + +- Operators can reclaim known abandoned service scratch from the Storage page without granting + Kandev authority to recursively delete shared `/tmp` contents. +- The first release may report zero eligible artifacts even when `/tmp` is large; unregistered + folders require their owning producer or host temporary-file policy to clean them. +- Producers must keep registry leases and markers correct, and new long-lived service temp roots + need an explicit registration decision before they become reclaimable. +- Quarantine can temporarily move bytes from `/tmp` into Kandev's trash, but preserves restore and + fail-closed behavior rather than deleting a possibly misclassified root immediately. +- The fixed stale interval is intentionally conservative. Scheduled cleanup or a configurable + temp policy requires a later product and operational decision. + +## Alternatives Considered + +- **Delete `/tmp/kandev-*` by name and age.** Rejected because names do not identify the current + installation and the same temporary root is shared with unrelated work. +- **Scan every file below `TMPDIR` and infer ownership from mtime or process names.** Rejected + because it is a general-purpose sweeper, is vulnerable to path replacement, and cannot prove + task or installation ownership. +- **Move all agent scratch beneath `KANDEV_HOME_DIR`.** Rejected by ADR 0045 because it mixes + transient process data with persistent application state and changes cache-sharing behavior. +- **Use only an on-disk marker without a durable registry record.** Rejected because a stale or + copied marker is not enough to establish lifecycle state after restart; the provider needs an + exact path and lease record owned by the current installation. +- **Enable the provider for scheduled and unscoped manual runs immediately.** Rejected because + shared-temp cleanup needs an explicit operator action and more operational evidence first. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 74ce617679..0532d0edb5 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -133,4 +133,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-07-claude-allowlist-label-bridge | [Use the Claude Allowlist as a Trusted Preview Gate](2026-08-07-claude-allowlist-label-bridge.md) | accepted | infra, workflow, security | 2026-08-07 | | 2026-08-08-agentctl-crash-containment | [Publish Immutable Agent Events and Explicit Runtime Failure State](2026-08-08-agentctl-crash-containment.md) | proposed | backend, frontend, protocol, operations | 2026-08-08 | | 2026-08-08-workspace-scoped-task-create-workflow-memory | [Remember Task-Create Workflows Per Workspace](2026-08-08-workspace-scoped-task-create-workflow-memory.md) | accepted | backend, frontend | 2026-08-08 | +| 2026-08-08-owned-temp-artifact-cleanup | [Clean only registered Kandev temporary artifacts](2026-08-08-owned-temp-artifact-cleanup.md) | accepted | backend, frontend, infra, security | 2026-08-08 | | 2026-08-08-task-owned-worktree-lifetime | [Keep Worktree Ownership at the Task Lifecycle](2026-08-08-task-owned-worktree-lifetime.md) | accepted | backend, frontend | 2026-08-08 | diff --git a/docs/plans/storage-temp-artifacts/plan.md b/docs/plans/storage-temp-artifacts/plan.md new file mode 100644 index 0000000000..b750069c52 --- /dev/null +++ b/docs/plans/storage-temp-artifacts/plan.md @@ -0,0 +1,181 @@ +--- +spec: docs/specs/system-page/storage-maintenance.md +created: 2026-08-08 +status: complete +--- + +# Implementation Plan: Registered Kandev temporary-artifact cleanup + +## Overview + +Add a manual-only `temporary_artifacts` resource to the existing Storage page. The backend first +gets a durable registry and marker contract for exact Kandev service-created temporary roots, then +adds analysis/quarantine/API composition, followed by the responsive resource action and desktop / +mobile coverage. The implementation must preserve the inherited shared-temp boundary: anonymous +`/tmp` folders, caches, preview/CI roots, and other-installation data are never swept. + +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). + +--- + +## Backend + +### Ownership registry and producer lifecycle + +- Add a `storage_temp_artifacts` table and replay-safe migration in + `apps/backend/internal/system/storage/store_migrations.go`, with exact path, allowlisted kind, + artifact ID, marker token, lifecycle state, owner PID/lease timestamps, and metadata. +- Add `apps/backend/internal/system/storage/tempartifacts/` with registry operations that create + top-level directories below the effective service temp root, write an owner-only marker atomically, + persist the record, heartbeat active leases, close normal lifecycles, and reconcile records after + restart. All path checks use `Lstat`/canonical containment and fail closed on symlinks or + uncertainty. +- Migrate backend-owned long-lived roots in `apps/backend/internal/improvekandev/bundle.go` and + `apps/backend/internal/agent/hostutility/manager.go` to the registry. Remove the independent + name/age sweep from `apps/backend/internal/backendapp/helpers.go`; unregistered legacy roots stay + host-policy data. + +### Storage provider and API composition + +- Implement analysis and explicit cleanup in `tempartifacts/provider.go`: report total, active, + stale-candidate, skipped, and unavailable bytes/counts; only closed or abandoned records older + than 24 hours qualify; same-filesystem rename moves a qualifying root to the existing quarantine. +- Extend storage summary/capability types and `storage_quarantine_entries.resource_type` for + `temporary_artifact`. Reuse quarantine retention, restore, and permanent deletion; do not add a + `storage_maintenance` setting in this release. +- Reuse `ExplicitCleanupProvider` so `temporary_artifacts` is no-op for scheduled and unscoped + full runs but performs cleanup for `resources: ["temporary_artifacts"]`. Extend provider + composition in `apps/backend/internal/backendapp/storage_maintenance.go` and keep overview totals + non-overlapping. +- Accept the stable resource name through the existing `POST /api/v1/system/storage/run` contract; + return provider counts/bytes and skipped warnings through the existing run result and preserve + the current activity gate and busy/force behavior. + +--- + +## Frontend + +### Storage analysis and action + +- Extend `apps/web/lib/types/system.ts` with the temporary-artifact summary, capability, quarantine + resource type, and provider result shapes. No new policy field is added. +- Update `apps/web/components/settings/system/storage/storage-totals.ts` so measured temporary + artifact bytes participate once in the non-overlapping total, with a partial result when that + provider is unavailable. +- Add a localized **Kandev temporary artifacts** resource row to + `apps/web/components/settings/system/storage/storage-overview-card.tsx`. Show measured bytes, + stale eligible bytes/count, active protection, skipped warnings, and an explicit + **Clean stale artifacts** action only when cleanup is available. +- Thread `runNow(["temporary_artifacts"])` through the existing Storage controller and add a normal + reversible confirmation in `storage-confirmation-dialogs.tsx`; do not introduce a second page or + a scheduled toggle. Keep busy feedback and `Run anyway` on the existing gate. +- Add the new copy to `en`, `pseudo`, `pt-pt`, and `zh-cn` `system.json` catalogs. All paths remain + i18n-guarded and filesystem values are interpolated rather than translated. + +### Mobile contract + +- Keep the existing one-column Storage accordion and action composition. The temporary-artifact row + must wrap long paths/warnings, retain a 44px-or-larger action target, and remain usable with no + horizontal document overflow at the mobile project viewport. + +--- + +## Tests + +- **Registry persistence and marker safety:** create/close/heartbeat/reconcile registered artifacts, + reject unregistered prefixes, missing or mismatched markers, symlink replacement, path escape, and + cross-install/legacy records. **Files:** + `apps/backend/internal/system/storage/tempartifacts/*_test.go`, + `apps/backend/internal/system/storage/store_test.go`, + `apps/backend/internal/improvekandev/bundle_test.go`, and host-utility lifecycle tests. + **How:** table-driven filesystem tests under `t.TempDir()` plus SQLite migration/restart tests. +- **Provider behavior:** analysis is read-only, active/recent roots are protected, stale roots move + atomically to quarantine, cross-device/error/symlink cases remain untouched, and unscoped/scheduled + runs do not invoke destructive temp cleanup. **Files:** + `apps/backend/internal/system/storage/tempartifacts/provider_test.go`, + `apps/backend/internal/system/storage/operations_test.go`, and + `apps/backend/internal/system/storage/runner_test.go`. + **How:** fake clocks, fake leases, fake rename/filesystem adapters, and provider result assertions. +- **API and composition:** the overview exposes the new summary, `temporary_artifacts` is accepted + only as a valid explicit resource, quarantine rows round-trip the new resource type, and handler + errors preserve `400`/`409` semantics. **Files:** + `apps/backend/internal/system/storage/handler_test.go`, `storage_routes_test.go`, and + `apps/backend/internal/backendapp/storage_test.go`. + **How:** handler-to-service-to-store tests with real SQLite and a fake provider, plus composition + wiring coverage. +- **Frontend rendering/action:** the row shows active versus stale state, the confirmation sends the + exact resource selection, success/error/busy feedback remains localized, and existing Go-cache / + global actions remain unchanged. **Files:** + `apps/web/components/settings/system/storage/storage-overview-card.test.tsx`, + `storage-confirmation-dialogs.test.tsx`, + `storage-totals.test.ts`, + `apps/web/hooks/domains/system/use-storage-maintenance.test.tsx`, and + `apps/web/lib/api/domains/system-api.test.ts`. + **How:** Vitest and Testing Library interaction tests with mocked overview/run responses. + +--- + +## E2E Tests + +- **Scenario:** a current-install registered stale artifact and an unregistered `kandev-*` folder + are present, **WHEN** Storage analysis runs, **THEN** only the registered artifact appears in the + temporary-artifact row and the unregistered folder is not counted or changed. **File:** + `apps/web/e2e/tests/system/storage-maintenance.spec.ts` using the existing Storage API route + fixture pattern plus an explicit-run assertion. +- **Scenario:** the user selects **Clean stale artifacts**, **WHEN** confirmation completes, + **THEN** the request contains `{ resources: ["temporary_artifacts"] }`, the cleanup job completes, + and the result is visible in Quarantine while a global **Run now** leaves the artifact untouched. + **File:** `apps/web/e2e/tests/system/storage-maintenance.spec.ts`. +- **Scenario:** the same row and confirmation are opened from the mobile settings sheet, **WHEN** + the user taps the action, **THEN** the action is reachable at a 44px target and the document has no + horizontal overflow. **File:** `apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`. + +Focused UI commands use the managed production-build runner: + +```bash +cd apps/web && pnpm e2e:run tests/system/storage-maintenance.spec.ts +cd apps/web && pnpm e2e:run tests/system/mobile-storage-maintenance.spec.ts -- --project=mobile-chrome +``` + +--- + +## Verification Results + +- Backend storage, Improve Kandev, host-utility, and backend-app tests passed: + `go test ./internal/system/storage/... ./internal/improvekandev/... ./internal/agent/hostutility/... ./internal/backendapp/... -count=1`. +- Focused frontend tests passed: 71 tests across six files; web typecheck and lint passed. +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` passed. The check reported only the + repository's existing 209 advisory pt-pt/zh-cn parity issues and one existing orphan. +- Desktop Storage Playwright: 7 passed, including the registered-artifact row, scoped cleanup, + quarantine/job feedback, cache isolation, busy override, progressive loading, and settings + persistence scenarios. +- Mobile Storage Playwright: 5 passed, including the scoped action, touch target, and document + overflow assertions. +- Public documentation validation passed: 58 tests and 41 published pages. `git diff --check` + passed. + +--- + +## Implementation Waves And Parallel Candidates + +Wave 1 (sequential backend foundation): + +- [x] [Task 01 — Temp ownership registry and producer lifecycle](task-01-temp-ownership-registry.md) + +Wave 2 (backend provider/API after Task 01): + +- [x] [Task 02 — Temporary-artifact storage provider and API](task-02-temp-artifact-provider-api.md) + +Wave 3 (frontend after Task 02; shared frontend files make this sequential): + +- [x] [Task 03 — Storage temporary-artifact action](task-03-storage-temp-artifact-ui.md) + +Wave 4 (desktop/mobile E2E after Task 03): + +- [x] [Task 04 — Temporary-artifact Storage E2E coverage](task-04-storage-temp-artifact-e2e.md) + +Wave 5 (docs after behavior is implemented): + +- [x] [Task 05 — Temporary-artifact operations documentation](task-05-storage-temp-artifact-docs.md) diff --git a/docs/plans/storage-temp-artifacts/task-01-temp-ownership-registry.md b/docs/plans/storage-temp-artifacts/task-01-temp-ownership-registry.md new file mode 100644 index 0000000000..a27800c19c --- /dev/null +++ b/docs/plans/storage-temp-artifacts/task-01-temp-ownership-registry.md @@ -0,0 +1,75 @@ +--- +id: "01-temp-ownership-registry" +title: "Temp ownership registry and producer lifecycle" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/system-page/storage-maintenance.md" +--- + +# Task 01: Temp ownership registry and producer lifecycle + +Create the durable ownership contract needed before any `/tmp` cleanup can be exposed. The registry +must know the exact path and lifecycle state; a filename prefix or old mtime alone is never enough. + +## Acceptance + +- A registry-created artifact has a durable `storage_temp_artifacts` row and matching owner-only + marker; missing, mismatched, symlinked, escaped, or unregistered roots are not returned as owned. +- Active leases/heartbeats protect roots, normal close removes or closes them safely, and restart + reconciliation leaves uncertain or legacy paths untouched while making stale registered rows + retryable after the 24-hour interval. +- Improve Kandev bundles and the host-utility parent use the registry; the old broad stale-bundle + name/age sweep is removed without changing the inherited `TMPDIR`/`TMP`/`TEMP` behavior. + +## Verification + +```bash +cd apps/backend && go test ./internal/system/storage/... ./internal/improvekandev/... ./internal/agent/hostutility/... ./internal/backendapp/... -count=1 +``` + +## Files likely touched + +- `apps/backend/internal/system/storage/store_migrations.go` +- `apps/backend/internal/system/storage/store.go` +- `apps/backend/internal/system/storage/tempartifacts/registry.go` +- `apps/backend/internal/system/storage/tempartifacts/registry_test.go` +- `apps/backend/internal/improvekandev/bundle.go` +- `apps/backend/internal/improvekandev/bundle_test.go` +- `apps/backend/internal/agent/hostutility/manager.go` +- `apps/backend/internal/agent/hostutility/*_test.go` +- `apps/backend/internal/backendapp/helpers.go` + +## Dependencies + +None. + +## Parallelism + +`sequential`; Task 02 depends on the registry and storage schema. + +## Inputs + +- Storage Maintenance spec sections: Kandev-owned temporary artifacts, Data model, Failure modes, + Persistence guarantees, and Out of scope. +- ADR 0045 and ADR-2026-08-08-owned-temp-artifact-cleanup. +- Existing Improve Kandev owner-marker validation and host-utility process-scoped temp parent. + +## Output contract + +Report schema/migration names, marker and lease invariants, producer lifecycle changes, exact test +results, any platform-specific liveness caveat, and update this task plus the plan checkbox only +after the focused backend tests pass. + +## Results + +- Added the replay-safe `storage_temp_artifacts` SQLite table and lifecycle store methods. +- Added the exact-root registry and owner-only `.kandev-temp-artifact.json` marker contract, + including heartbeat, close, restart reconciliation with per-run identities, symlink/containment + checks, and protected liveness handling. +- Improve Kandev bundle roots and host-utility parent roots now register with the service registry; + the old broad stale-bundle sweep was removed while inherited temp environment behavior remains + unchanged. +- Backend verification passed: + `go test ./internal/system/storage/... ./internal/improvekandev/... ./internal/agent/hostutility/... ./internal/backendapp/... -count=1`. diff --git a/docs/plans/storage-temp-artifacts/task-02-temp-artifact-provider-api.md b/docs/plans/storage-temp-artifacts/task-02-temp-artifact-provider-api.md new file mode 100644 index 0000000000..8025609629 --- /dev/null +++ b/docs/plans/storage-temp-artifacts/task-02-temp-artifact-provider-api.md @@ -0,0 +1,76 @@ +--- +id: "02-temp-artifact-provider-api" +title: "Temporary-artifact storage provider and API" +status: done +wave: 2 +depends_on: ["01-temp-ownership-registry"] +plan: "plan.md" +spec: "../../specs/system-page/storage-maintenance.md" +--- + +# Task 02: Temporary-artifact storage provider and API + +Expose the registered artifacts through the existing Storage overview and run contracts. Cleanup is +an explicit-only provider: scheduled maintenance and an unscoped full run must not delete temp roots. + +## Acceptance + +- `GET /api/v1/system/storage` reports `temporary_artifacts` bytes/counts and capability state while + analysis remains read-only; active/recent and unsafe records are separated from stale candidates. +- `POST /api/v1/system/storage/run` accepts `resources: ["temporary_artifacts"]`, moves only valid + stale roots into quarantine, and returns provider counts/warnings; the empty selection and scheduled + paths do not perform temp cleanup. +- The new `temporary_artifact` quarantine type survives list/restore/purge flows, cross-device or + partial failures remain visible and retryable, and existing activity-gate/force semantics remain. + +## Verification + +```bash +cd apps/backend && go test ./internal/system/storage/... ./internal/backendapp/... -run 'Temporary|TempArtifact|Storage|Quarantine' -count=1 +``` + +## Files likely touched + +- `apps/backend/internal/system/storage/types.go` +- `apps/backend/internal/system/storage/operations.go` +- `apps/backend/internal/system/storage/runner.go` +- `apps/backend/internal/system/storage/handler.go` +- `apps/backend/internal/system/storage/store.go` +- `apps/backend/internal/system/storage/store_migrations.go` +- `apps/backend/internal/system/storage/tempartifacts/provider.go` +- `apps/backend/internal/system/storage/tempartifacts/provider_test.go` +- `apps/backend/internal/system/storage/handler_test.go` +- `apps/backend/internal/system/storage/operations_test.go` +- `apps/backend/internal/system/storage/runner_test.go` +- `apps/backend/internal/backendapp/storage_maintenance.go` +- `apps/backend/internal/backendapp/storage_test.go` + +## Dependencies + +Task 01. + +## Parallelism + +`sequential`; it owns shared storage contracts and backend composition. + +## Inputs + +- Plan Backend sections: Storage provider and API composition. +- Existing `ExplicitCleanupProvider` used by the Go-cache action. +- Existing workspace/Go-cache quarantine and overview-cache implementations. + +## Output contract + +Report the provider resource name, summary/result JSON shapes, default-versus-explicit dispatch +behavior, quarantine integration, exact focused test output, and synchronized task/plan status. + +## Results + +- Added the manual-only `temporary_artifacts` provider with read-only analysis, active/recent + protection, fixed 24-hour stale eligibility, and same-filesystem quarantine moves. +- Added storage summary/capability JSON, explicit resource dispatch, provider results/warnings, and + `temporary_artifact` quarantine restore/permanent-delete integration. +- Crash reconciliation covers rename-before-lifecycle-update and interrupted-rename cases; restored + artifacts can be quarantined again with a fresh quarantine entry ID. +- Backend verification passed: + `go test ./internal/system/storage/... ./internal/improvekandev/... ./internal/agent/hostutility/... ./internal/backendapp/... -count=1`. diff --git a/docs/plans/storage-temp-artifacts/task-03-storage-temp-artifact-ui.md b/docs/plans/storage-temp-artifacts/task-03-storage-temp-artifact-ui.md new file mode 100644 index 0000000000..7b4cb8c475 --- /dev/null +++ b/docs/plans/storage-temp-artifacts/task-03-storage-temp-artifact-ui.md @@ -0,0 +1,81 @@ +--- +id: "03-storage-temp-artifact-ui" +title: "Storage temporary-artifact action" +status: done +wave: 3 +depends_on: ["02-temp-artifact-provider-api"] +plan: "plan.md" +spec: "../../specs/system-page/storage-maintenance.md" +--- + +# Task 03: Storage temporary-artifact action + +Add the new analysis row and explicit cleanup action to the existing Storage page without adding a +second settings surface or a scheduled policy toggle. + +## Acceptance + +- Desktop Storage analysis renders the localized temporary-artifact row with bytes, stale/active + counts, warnings, unavailable state, and a clearly scoped cleanup action. +- The action uses the existing Storage controller and a reversible confirmation, submits exactly + `resources: ["temporary_artifacts"]`, preserves busy/Run-anyway behavior, and leaves Go-cache and + global actions unchanged. +- The same composition is usable on mobile with wrapped content, at least 44px touch targets, and + no new horizontal overflow; all new copy exists in the four system locale catalogs. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +pnpm --filter @kandev/web run typecheck +pnpm --filter @kandev/web test -- storage-overview-card storage-confirmation-dialogs use-storage-maintenance system-api +pnpm --filter @kandev/web lint +``` + +## Files likely touched + +- `apps/web/lib/types/system.ts` +- `apps/web/hooks/domains/system/use-storage-maintenance.ts` +- `apps/web/hooks/domains/system/use-storage-maintenance.test.tsx` +- `apps/web/components/settings/system/storage/storage-overview-card.tsx` +- `apps/web/components/settings/system/storage/storage-overview-card.test.tsx` +- `apps/web/components/settings/system/storage/storage-confirmation-dialogs.tsx` +- `apps/web/components/settings/system/storage/storage-confirmation-dialogs.test.tsx` +- `apps/web/components/settings/system/storage/storage-maintenance-settings.tsx` +- `apps/web/components/settings/system/storage/storage-totals.ts` +- `apps/web/components/settings/system/storage/storage-totals.test.ts` +- `apps/web/src/locales/en/system.json` +- `apps/web/src/locales/pseudo/system.json` +- `apps/web/src/locales/pt-pt/system.json` +- `apps/web/src/locales/zh-cn/system.json` + +## Dependencies + +Task 02. + +## Parallelism + +`sequential`; the overview card, controller, and locale catalogs are shared frontend surfaces. + +## Inputs + +- Plan Frontend sections and the mobile-parity contract. +- Existing Go-cache resource-specific action and confirmation/dialog patterns. +- `apps/web/AGENTS.md` i18n and settings-save rules. + +## Output contract + +Report the rendered states, confirmation/resource payload, locale files, focused typecheck/test/lint +results, mobile layout evidence, and synchronized task/plan status. + +## Results + +- Added localized desktop and mobile Storage rows for Kandev-owned temporary artifacts, including + measured/stale/protected counts, warnings, unavailable state, and explicit cleanup action. +- The confirmation uses the existing controller with exactly + `resources: ["temporary_artifacts"]`; scheduled and unscoped cleanup behavior remains unchanged. +- Added English, pseudo, Portuguese, and Simplified Chinese catalog entries and 44px-compatible + action styling. +- Focused frontend verification passed: 71 Vitest tests across six files, web typecheck, web lint, + `i18n:check`, and `i18n:ratchet`. diff --git a/docs/plans/storage-temp-artifacts/task-04-storage-temp-artifact-e2e.md b/docs/plans/storage-temp-artifacts/task-04-storage-temp-artifact-e2e.md new file mode 100644 index 0000000000..0fb4b08ce4 --- /dev/null +++ b/docs/plans/storage-temp-artifacts/task-04-storage-temp-artifact-e2e.md @@ -0,0 +1,69 @@ +--- +id: "04-storage-temp-artifact-e2e" +title: "Temporary-artifact Storage E2E coverage" +status: done +wave: 4 +depends_on: ["03-storage-temp-artifact-ui"] +plan: "plan.md" +spec: "../../specs/system-page/storage-maintenance.md" +--- + +# Task 04: Temporary-artifact Storage E2E coverage + +Prove the user-facing desktop and mobile behavior through the existing Storage E2E suites. Use the +current API-route fixture pattern for the UI contract; backend filesystem/provider safety is covered +by Task 02's real SQLite/provider tests. + +## Acceptance + +- Desktop coverage shows a registered stale artifact in analysis, ignores an unregistered `kandev-*` + folder, confirms the scoped action with `{ resources: ["temporary_artifacts"] }`, and renders the + resulting quarantine/job feedback without changing the global Run-now contract. +- Mobile coverage opens the row and confirmation from the settings sheet, completes the same scoped + action through touch interaction, and proves `document.documentElement.scrollWidth <= innerWidth`. +- Existing Storage desktop/mobile scenarios (Go-cache explicit cleanup, busy override, progressive + loading, settings persistence) remain green. + +## Verification + +```bash +cd apps +pnpm install --frozen-lockfile +pnpm --filter @kandev/web e2e:run tests/system/storage-maintenance.spec.ts +pnpm --filter @kandev/web e2e:run tests/system/mobile-storage-maintenance.spec.ts -- --project=mobile-chrome +``` + +## Files likely touched + +- `apps/web/e2e/tests/system/storage-maintenance.spec.ts` +- `apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts` +- `apps/web/e2e/helpers/storage-maintenance.ts` (only if shared response/fixture seeding is needed) + +## Dependencies + +Task 03. + +## Parallelism + +`sequential`; E2E depends on the final API shapes and UI test IDs. + +## Inputs + +- Spec Scenarios: temporary-artifact analysis, explicit cleanup, unregistered-path safety, and + mobile Storage behavior. +- Existing storage-maintenance and mobile-storage-maintenance specs/helpers. + +## Output contract + +Report exact desktop/mobile test counts and commands, request payload assertions, quarantine/job +feedback, overflow result, cleanup of any capture/fixture artifacts, and synchronized task/plan +status. + +## Results + +- Desktop production E2E passed: 7 tests, including the registered stale row, unregistered-folder + exclusion, exact scoped cleanup payload, quarantine/job feedback, and existing Storage flows. +- Mobile production E2E passed: 5 tests, including touch interaction, the scoped cleanup flow, and + `document.documentElement.scrollWidth <= innerWidth`. +- The managed fixture avoids backend overview-cache contamination between the seeded temporary + artifact scenario and the existing Go-cache scenario. diff --git a/docs/plans/storage-temp-artifacts/task-05-storage-temp-artifact-docs.md b/docs/plans/storage-temp-artifacts/task-05-storage-temp-artifact-docs.md new file mode 100644 index 0000000000..2ed6737c1c --- /dev/null +++ b/docs/plans/storage-temp-artifacts/task-05-storage-temp-artifact-docs.md @@ -0,0 +1,66 @@ +--- +id: "05-storage-temp-artifact-docs" +title: "Temporary-artifact operations documentation" +status: done +wave: 5 +depends_on: ["04-storage-temp-artifact-e2e"] +plan: "plan.md" +spec: "../../specs/system-page/storage-maintenance.md" +--- + +# Task 05: Temporary-artifact operations documentation + +Reconcile public operator guidance with the new narrow cleanup action. Documentation must make the +distinction between registered service artifacts and shared/unowned temporary data unmistakable. + +## Acceptance + +- `docs/public/operations.md` explains the manual-only **Clean stale artifacts** action, quarantine + behavior, 24-hour stale rule, and the exact categories it will not touch (legacy, caches, preview/ + CI/dev harness, and arbitrary shared `/tmp`). +- The public documentation continues to state that inherited `TMPDIR`/`TMP`/`TEMP` are unchanged and + does not promise that Storage maintenance cleans all folders whose names start with `kandev-`. +- Public-doc tests/validation and `git diff --check` pass, and the task/plan/spec/ADR links remain + internally consistent. + +## Verification + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Files likely touched + +- `docs/public/operations.md` +- `docs/specs/system-page/storage-maintenance.md` (only if implementation reveals wording drift) +- `docs/plans/storage-temp-artifacts/plan.md` +- `docs/plans/storage-temp-artifacts/task-*.md` + +## Dependencies + +Task 04. + +## Parallelism + +`sequential`; documentation is the final reconciliation of shipped behavior and verification. + +## Inputs + +- ADR-2026-08-08-owned-temp-artifact-cleanup. +- `docs/public/operations.md` Storage maintenance section. +- `/docs-maintainer` public-doc validation conventions. + +## Output contract + +Report changed public-doc sections, exact validation output, any wording/spec drift found, and +synchronized task/plan status. Do not broaden the implementation scope to unregistered temp paths. + +## Results + +- Updated `docs/public/operations.md` to document the manual-only cleanup, 24-hour stale rule, + quarantine behavior, and exclusions for arbitrary/shared temp data, caches, legacy roots, and + preview/CI/dev-harness data. +- Public documentation verification passed: `node --test scripts/validate-public-docs.test.mjs` + (58 passed), `node scripts/validate-public-docs.mjs` (41 published pages), and `git diff --check`. diff --git a/docs/public/operations.md b/docs/public/operations.md index 9d35166fe1..b69e1dccec 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -174,6 +174,17 @@ Host-wide Docker build-cache and unused-image cleanup remain disabled until you owns a dedicated Docker daemon. Do not enable those rules on a daemon shared with unrelated workloads. +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 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. + 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 sets them on the service, all host-local agents share those configured locations. This permits diff --git a/docs/specs/system-page/storage-maintenance.md b/docs/specs/system-page/storage-maintenance.md index 337de93c5f..81ffcb24e7 100644 --- a/docs/specs/system-page/storage-maintenance.md +++ b/docs/specs/system-page/storage-maintenance.md @@ -1,7 +1,7 @@ --- status: building created: 2026-07-14 -updated: 2026-08-02 +updated: 2026-08-08 owner: cfl --- @@ -16,6 +16,12 @@ consume the disk until an operator edits the host or runs broad commands such as `docker system prune -a`. Operators need an in-app, ownership-aware way to understand and reclaim that space without maintaining cron or systemd configuration outside Kandev. +The service also creates some longer-lived temporary roots for diagnostics and host +utilities. Operators need a way to reclaim abandoned roots created by this installation, but shared +temporary directories also contain unrelated caches, preview/CI data, developer harnesses, and other +Kandev installations. Cleanup must therefore prove ownership instead of treating a `/tmp` name or +mtime as sufficient evidence. + ## What - Settings includes a **System → Storage** page at `/settings/system/storage` for disk @@ -45,10 +51,11 @@ reclaim that space without maintaining cron or systemd configuration outside Kan cache, and unused Docker images. - Storage analysis shows a total counted size derived from the available non-overlapping top-level measurements: total task workspaces, quarantine, managed and distinct user Go caches, - Kandev-managed container writable layers, Docker image layers, and Docker build cache. Active and - candidate workspace bytes and unused-image bytes remain visible subset measurements and are not - added again. If any top-level measurement is unavailable, the total is visibly identified as - partial rather than presented as complete host disk usage. + registered temporary artifacts, Kandev-managed container writable layers, Docker image layers, and + Docker build cache. Active and candidate workspace/temporary-artifact bytes and unused-image bytes + remain visible subset measurements and are not added again. If any top-level measurement is + unavailable, the total is visibly identified as partial rather than presented as complete host disk + usage. - A successful storage analysis is reused for 15 minutes. Opening or refreshing the Storage page, saving policy settings, and adopting an external Go cache consume that cached snapshot instead of starting another filesystem or Docker scan. Manual **Analyze** always bypasses the cache and @@ -78,6 +85,16 @@ reclaim that space without maintaining cron or systemd configuration outside Kan - The Go-cache analysis row exposes a resource-specific **Clean Go cache** action only when the cache is Kandev-owned and above its configured maximum. That action submits an explicit `go_cache` selection through the same manual-run gate. +- Read-only analysis includes a separate **Kandev temporary artifacts** resource for exact paths + registered by the current Kandev service. It reports total, active, stale-candidate, skipped, and + unavailable bytes/counts without recursively scanning the shared operating-system temp directory. +- The resource row exposes a manual **Clean stale artifacts** action only for registered roots that + 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. - 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: @@ -214,6 +231,34 @@ Retention override: policy or a deliberate one-time maintenance procedure. See [ADR 0045](../../decisions/0045-install-wide-storage-maintenance.md). +### Kandev-owned temporary artifacts + +Decision: [ADR-2026-08-08-owned-temp-artifact-cleanup](../../decisions/2026-08-08-owned-temp-artifact-cleanup.md) + +- A backend service component creates a temporary artifact through the storage registry. The + registry persists an exact absolute path, an allowlisted artifact kind, an opaque artifact ID, + lifecycle timestamps, owner lease/heartbeat state, a per-service-run identity, and metadata in + `storage_temp_artifacts`. +- The registry writes an owner-only `.kandev-temp-artifact.json` marker atomically inside the root. + Analysis and cleanup require the marker's artifact ID and kind to match the durable record, + validate the root with `Lstat`, reject symlink/path replacement, and never follow nested symlink + targets while measuring or moving the root. +- The first registered producers are long-lived backend service roots such as Improve Kandev bundle + directories and the host-utility parent directory. Standalone `acpdbg`/preview commands, E2E and + `dev-isolated` harness roots, CI/PR checkouts, package-manager and compiler caches, generic `tmp.*` + paths, and artifacts from another Kandev installation remain unregistered. +- Normal producer shutdown closes and removes its artifact. After restart, a missing owner lease or + heartbeat, including a same-PID row from an earlier service run, can classify a registered root + as abandoned, but it remains protected until the fixed 24-hour stale interval. Uncertain + liveness, unreadable rows/markers, and missing paths are skipped and reported rather than treated + 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. +- Existing unmarked `/tmp/kandev-agent/*` directories and arbitrary shared temp files remain host + policy data even when their names resemble a registered prefix. + ### Docker storage - Kandev-owned container cleanup lists only containers labeled `kandev.managed=true`. It removes @@ -310,7 +355,7 @@ The UI lists the newest 20 runs. Run rows survive backend restarts. ```text id string primary key -resource_type enum task_workspace | go_cache +resource_type enum task_workspace | go_cache | temporary_artifact task_id string nullable, no foreign key workspace_id string nullable, no foreign key original_path string absolute, normalized, unique while active @@ -325,6 +370,29 @@ last_error string default "" metadata json ownership marker and Git worktree details ``` +### `storage_temp_artifacts` + +```text +id string primary key; opaque artifact ID +kind enum improve_bundle | host_utility +path string absolute, normalized, unique while active +marker_token string matches the owner-only marker in the root +state enum active | closed | abandoned | quarantined | deleted | failed +owner_pid int positive process ID +created_at timestamp +last_heartbeat_at timestamp nullable +closed_at timestamp nullable +quarantined_at timestamp nullable +deleted_at timestamp nullable +last_error string default "" +metadata json producer details plus the registry run identity +``` + +The row is the current installation's ownership record; the marker is a second path-level check. +Unknown kinds, rows whose marker or path no longer matches, and rows whose root is missing are +reconciled without deletion. The fixed 24-hour stale interval is not a persisted setting in this +release. + ## API surface All routes are under the existing authenticated System route group. @@ -382,8 +450,16 @@ DELETE /api/v1/system/storage/quarantine ``` `capabilities` reports the managed Go path, whether Go-cache adoption is available, Docker -availability, configured Docker host, and whether host-global Docker cleanup is allowed. API -responses never expose secret environment values. +availability, configured Docker host, whether host-global Docker cleanup is allowed, and whether the +registered temporary-artifact provider is available. API responses never expose secret environment +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. `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, @@ -482,13 +558,28 @@ quarantined -> restored -> failed -> restored|deleted ``` +### Registered temporary artifact + +```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 +quarantined -> restored|deleted|failed +failed -> quarantined|deleted +``` + +The `active`, `closed`, and `abandoned` states are never eligible before the fixed 24-hour stale interval. +State transitions require the matching durable row and marker; an uncertain transition leaves the +root in place. + ## Permissions Storage routes use the same install-user authorization as other System pages. Adopting an external -Go cache, acknowledging a dedicated Docker daemon, permanently deleting one or more quarantine -entries, and enabling host-global Docker cleanup require explicit UI confirmation and server-side -validation. **Force clear all** uses the distinct `DELETE ALL NOW` confirmation because it removes -the configured restore window. +Go cache, acknowledging a dedicated Docker daemon, moving registered temporary artifacts to +quarantine, permanently deleting one or more quarantine entries, and enabling host-global Docker +cleanup require explicit UI confirmation and server-side validation. **Force clear all** uses the +distinct `DELETE ALL NOW` confirmation because it removes the configured restore window. ## Failure modes @@ -509,6 +600,17 @@ the configured restore window. warning, and does not run destructive maintenance. - A managed Go-cache cleanup failure leaves either the original cache or its quarantined rename intact; it never recursively deletes outside the configured owned path. +- 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 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. +- A recent active lease or heartbeat protects a temporary artifact even when its directory is large. + A stale PID, absent lease, or unreadable liveness signal is not by itself permission to delete; + the fixed stale interval and marker/row checks must also pass. - Bulk quarantine deletion continues after one entry fails. Successful entries remain deleted, protected entries remain unchanged for eligible-only cleanup, failed entries remain visible with their last error, and the job finishes as failed with per-entry failure details. @@ -537,6 +639,13 @@ the configured restore window. cleanup and permanent quarantine deletion do not cascade-delete that history. - Quarantined data remains restorable until permanent deletion succeeds. A failed permanent delete remains visible and retryable. +- Temporary-artifact ownership rows and matching markers survive backend restarts. A producer's + normal close remains idempotent; an interrupted active row is reconciled and can become eligible + only after the stale interval. +- Temporary-artifact analysis is included in the process-local 15-minute overview snapshot. A + successful explicit cleanup invalidates that snapshot after its provider result is persisted. +- A temporary artifact moved to quarantine remains restorable until the existing retention deadline; + the temporary-artifact action itself does not purge unrelated quarantine entries. - Expired entries are automatically considered only by scheduled or full manual maintenance. Disabling scheduling prevents unattended quarantine deletion; it does not start an independent sweeper or remove existing entries. @@ -634,6 +743,26 @@ the configured restore window. - **GIVEN** `/root/.cache/go-build` is the service user's default Go cache and is not adopted, **WHEN** storage analysis runs, **THEN** its path and bytes are reported read-only while cleanup remains unavailable for that path. +- **GIVEN** a current-install temporary-artifact row has a matching marker and is closed or + abandoned beyond 24 hours, **WHEN** Storage analysis runs, **THEN** its bytes/counts appear in the + temporary-artifact resource as a stale candidate without changing the filesystem. +- **GIVEN** an active or recently heartbeating registered artifact, **WHEN** Storage analysis or + cleanup runs, **THEN** it is reported as protected and remains untouched regardless of size. +- **GIVEN** an unregistered `kandev-*` directory, a package/compiler cache, a preview/CI/dev- + isolated root, or legacy `/tmp/kandev-agent/*`, **WHEN** temporary-artifact cleanup runs, **THEN** + 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. +- **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. - **GIVEN** the Kandev service has no temporary-directory variables configured, **WHEN** two host-local agents start, **THEN** neither instance receives an injected `TMPDIR`, `TMP`, or `TEMP` value and their tools use the operating system defaults. @@ -677,12 +806,16 @@ the configured restore window. worktree-pruning safety checks. - Age-based or name-based deletion of unmarked `/tmp/kandev-agent/*` directories. - 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. - 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. ## Implementation plan - [Original Storage maintenance implementation](../../plans/storage-maintenance/plan.md) +- [Registered Kandev temporary-artifact cleanup](../../plans/storage-temp-artifacts/plan.md) - [Storage overview cache and settings follow-up](../../plans/storage-overview-cache/plan.md) - [Quarantine lifecycle follow-up](../../plans/quarantine-lifecycle/plan.md) - [Progressive Storage loading and totals](../../plans/storage-progressive-loading/plan.md)