diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 3c926a0ea3..7ea055093d 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -252,6 +252,7 @@ Every long-running goroutine must have a single owner with explicit start and st - **Leak testing:** packages that spawn goroutines add `goleak.VerifyTestMain(m)` in a per-package `TestMain`. New packages of this kind must follow suit. When a third-party background goroutine genuinely can't be drained, suppress it with `goleak.IgnoreTopFunction(...)` and leave a comment explaining why. Currently instrumented: `internal/gateway/websocket/`, `internal/agent/runtime/lifecycle/`, `internal/agentctl/server/process/`, `internal/orchestrator/`, `internal/github/`, `internal/gitlab/`, `internal/jira/`, `internal/linear/`, `internal/integrations/healthpoll/`. ## Backups +- `internal/system/toolretention` owns opt-in payload cleanup; policy and progress share one settings record. Its batches share `internal/system/maintenance` admission with backup, restore/reset, and compaction. Keep preparation cancellable and accepted manual jobs independent of HTTP cancellation. Message replacements must preserve removal markers and activity timestamps; analysis and cleanup share the reducer. Status polling and startup never scan payloads. See [the retention design](../../docs/specs/system-page/system-design/tool-payload-retention.md). - On every SQLite boot, `persistence.Provide` reads `kandev_meta.kandev_version`. If the stored version differs from the binary version (or any user tables exist but no version is recorded), it takes a `VACUUM INTO` snapshot into `backups/` beside the configured SQLite database file before running migrations. `VACUUM INTO` recreates its target, so create staged snapshots inside a same-filesystem private `0700` staging directory and chmod the file to `0600` before validation/install; removing a placeholder and chmodding afterward does not protect the creation window. The default path is `/data/kandev.db`, with backups in `/data/backups/`. - Retention: 2 backups kept (newest two by mtime); older ones are pruned after the snapshot succeeds. - Postgres: backup is skipped with a log line. Use `pg_dump` for Postgres backups. diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index e597287e4c..9d8a166e6a 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1166,6 +1166,18 @@ func startGatewayAndServe( MessageQueue: orchestratorSvc.GetMessageQueue(), MessageQueueConfig: queueConfiguration(cfg), TaskSessions: repos.Task, + ToolPayloadChanged: func(eventCtx context.Context, ids []string) { + for _, id := range ids { + message, err := services.Task.GetMessage(eventCtx, id) + if err != nil { + log.Warn("failed to load retained tool message for publication", zap.Error(err)) + continue + } + if err := services.Task.PublishMessageEvent(eventCtx, events.MessageUpdated, message); err != nil { + log.Warn("failed to publish retained tool message", zap.Error(err)) + } + } + }, }) storageComposition, err := provideStorageCompositionWithDependencies( cfg, dbPool, systemSvc.Jobs, eventBus, lifecycleMgr, services.WorktreeMgr, services.Task, diff --git a/apps/backend/internal/db/sqlguard/exemptions.json b/apps/backend/internal/db/sqlguard/exemptions.json index f734e36f24..b38010c0a7 100644 --- a/apps/backend/internal/db/sqlguard/exemptions.json +++ b/apps/backend/internal/db/sqlguard/exemptions.json @@ -1,5 +1,7 @@ { "exemptions": [ + {"file": "internal/system/toolretention/scan.go", "symbol": "consumeRow", "rule": "sqlite-date-function", "reason": "Tool payload retention rejects non-SQLite engines before scanning; julianday validates legacy SQLite timestamp strings"}, + {"file": "internal/task/repository/sqlite/tool_payload_retention_scan.go", "symbol": "payloadTaskEligibilityTemplate", "rule": "sqlite-date-function", "reason": "PayloadTaskEligible requires the sqlite3 driver before validating legacy timestamps with SQLite date functions"}, {"file": "internal/agent/settings/store/sqlite.go", "symbol": "migrateDropModelCheckConstraint", "rule": "sqlite-catalog", "reason": "SQLite-only migration for a legacy CHECK constraint"}, {"file": "internal/agent/settings/store/sqlite.go", "symbol": "recreateAgentProfilesWithoutModelCheck", "rule": "sqlite-catalog", "reason": "SQLite-only table-recreation migration"}, {"file": "internal/automation/store.go", "symbol": "createTablesSQL", "rule": "boolean-integer", "reason": "The provider schema template is rendered by dialect.MustRenderSchema"}, diff --git a/apps/backend/internal/persistence/snapshot.go b/apps/backend/internal/persistence/snapshot.go index 9a7c408aad..d72dce0e69 100644 --- a/apps/backend/internal/persistence/snapshot.go +++ b/apps/backend/internal/persistence/snapshot.go @@ -33,6 +33,11 @@ func SnapshotSQLite(writer *sqlx.DB, path string) (int64, error) { return snapshotSQLite(writer, path) } +// SnapshotSQLiteContext creates a cancellable SQLite snapshot. +func SnapshotSQLiteContext(ctx context.Context, writer *sqlx.DB, path string) (int64, error) { + return snapshotSQLiteContext(ctx, writer, path) +} + // snapshotSQLite copies the live database to path using VACUUM INTO, // which produces a clean, defragmented snapshot including all WAL frames. // Returns the size of the created file in bytes. diff --git a/apps/backend/internal/system/backups/handler.go b/apps/backend/internal/system/backups/handler.go index da681313f3..941cbaa2cd 100644 --- a/apps/backend/internal/system/backups/handler.go +++ b/apps/backend/internal/system/backups/handler.go @@ -98,7 +98,7 @@ func HandleDelete(svc *Service) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid name"}) return } - if err := svc.Delete(name); err != nil { + if err := svc.DeleteContext(c.Request.Context(), name); err != nil { // Differentiate by cause: pre-reset rejections and not-found // map to client-visible statuses; anything else (filesystem // errors etc.) is a 500 so we don't leak raw storage detail. diff --git a/apps/backend/internal/system/backups/retention_preparation.go b/apps/backend/internal/system/backups/retention_preparation.go new file mode 100644 index 0000000000..fabdd0f95e --- /dev/null +++ b/apps/backend/internal/system/backups/retention_preparation.go @@ -0,0 +1,163 @@ +package backups + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/persistence" + "github.com/kandev/kandev/internal/system/maintenance" +) + +// RetentionReceipt identifies a completely published, integrity-checked backup. +// Callers persist this server-generated receipt with the preparation revision. +type RetentionReceipt struct { + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + CompletedAt time.Time `json:"completed_at"` + SHA256 string `json:"sha256"` +} + +// CreateForRetention holds maintenance admission through snapshot verification +// and publication. It does not authorize retention or prune existing backups. +func (s *Service) CreateForRetention(ctx context.Context) (RetentionReceipt, error) { + return s.createSnapshot(ctx, true) +} + +func (s *Service) createSnapshot(ctx context.Context, verify bool) (RetentionReceipt, error) { + release, err := maintenance.ForPool(s.pool).Acquire(ctx) + if err != nil { + return RetentionReceipt{}, err + } + defer release() + if s.pool == nil || s.pool.Writer() == nil { + return RetentionReceipt{}, fmt.Errorf("no database pool") + } + if err := s.ensureSQLiteRestore(); err != nil { + return RetentionReceipt{}, err + } + return s.writeSnapshot(ctx, verify) +} + +func (s *Service) writeSnapshot(ctx context.Context, verify bool) (RetentionReceipt, error) { + if err := s.ensureBackupsDir(); err != nil { + return RetentionReceipt{}, err + } + s.sweepStaleTmpFiles() + stage, err := os.MkdirTemp(s.backupsDir(), ".snapshot-*") + if err != nil { + return RetentionReceipt{}, err + } + defer func() { _ = os.RemoveAll(stage) }() + path := filepath.Join(stage, "snapshot.db") + size, err := persistence.SnapshotSQLiteContext(ctx, s.pool.Writer(), path) + if err != nil { + return RetentionReceipt{}, err + } + if err := os.Chmod(path, 0600); err != nil { + return RetentionReceipt{}, err + } + var digest string + if verify { + digest, err = verifySnapshot(ctx, path) + if err != nil { + return RetentionReceipt{}, err + } + } + name := fmt.Sprintf("%s%d%s", manualPrefix, time.Now().UTC().UnixNano(), dbSuffix) + if err := ctx.Err(); err != nil { + return RetentionReceipt{}, err + } + if err := os.Rename(path, filepath.Join(s.backupsDir(), name)); err != nil { + return RetentionReceipt{}, err + } + return RetentionReceipt{Name: name, SizeBytes: size, CompletedAt: time.Now().UTC(), SHA256: digest}, nil +} + +// VerifyRetentionBackup checks the persisted receipt against the current backup +// file. Only the backup is read; no integrity scan touches the active database. +// Call before taking a retention batch lease: this method acquires admission. +func (s *Service) VerifyRetentionBackup(ctx context.Context, receipt RetentionReceipt) error { + release, err := maintenance.ForPool(s.pool).Acquire(ctx) + if err != nil { + return err + } + defer release() + return s.VerifyRetentionBackupUnderLease(ctx, receipt) +} + +// VerifyRetentionBackupUnderLease validates a receipt while the caller owns +// maintenance.ForPool(s.pool). Keep the lease until the first batch commits so +// backup deletion cannot interleave between validation and payload removal. +func (s *Service) VerifyRetentionBackupUnderLease(ctx context.Context, receipt RetentionReceipt) error { + if receipt.CompletedAt.IsZero() || receipt.SizeBytes <= 0 || len(receipt.SHA256) != 64 { + return fmt.Errorf("invalid retention backup receipt") + } + path, err := s.resolveSnapshotPath(receipt.Name) + if err != nil { + return err + } + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Size() != receipt.SizeBytes { + return fmt.Errorf("retention backup changed") + } + digest, err := verifySnapshot(ctx, path) + if err != nil { + return err + } + if digest != receipt.SHA256 { + return fmt.Errorf("retention backup changed") + } + return nil +} + +func verifySnapshot(ctx context.Context, path string) (string, error) { + uri := &url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"} + reader, err := sqlx.Open("sqlite3", uri.String()) + if err != nil { + return "", err + } + defer func() { _ = reader.Close() }() + var results []string + if err := reader.SelectContext(ctx, &results, "PRAGMA integrity_check"); err != nil { + return "", fmt.Errorf("verify snapshot: %w", err) + } + if len(results) != 1 || results[0] != "ok" { + return "", fmt.Errorf("snapshot integrity check failed") + } + file, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = file.Close() }() + hash := sha256.New() + if _, err := io.Copy(hash, &contextReader{ctx: ctx, reader: file}); err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r *contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(p) +} diff --git a/apps/backend/internal/system/backups/retention_test.go b/apps/backend/internal/system/backups/retention_test.go new file mode 100644 index 0000000000..10d1cf1190 --- /dev/null +++ b/apps/backend/internal/system/backups/retention_test.go @@ -0,0 +1,292 @@ +package backups + +import ( + "context" + "encoding/json" + "errors" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/system/jobs" + "github.com/kandev/kandev/internal/system/maintenance" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestManualBackupCanceledDoesNotPublish(t *testing.T) { + s, _ := newTestService(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := s.runCreate(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want canceled, got %v", err) + } + snapshots, err := s.List() + if err != nil || len(snapshots) != 0 { + t.Fatalf("published canceled backup: %v %v", snapshots, err) + } +} + +func TestManualBackupIsPrivate(t *testing.T) { + s, dir := newTestService(t) + result, err := s.runCreate(context.Background()) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "backups", result["name"].(string))) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("snapshot permissions = %o", info.Mode().Perm()) + } +} + +func TestRetentionReceiptDetectsMissingChangedAndCorruptSnapshot(t *testing.T) { + for _, mutation := range []string{"missing", "changed", "corrupt", "symlink"} { + t.Run(mutation, func(t *testing.T) { + s, _ := newTestService(t) + receipt, err := s.CreateForRetention(context.Background()) + if err != nil { + t.Fatal(err) + } + if err := s.VerifyRetentionBackup(context.Background(), receipt); err != nil { + t.Fatal(err) + } + path := filepath.Join(s.backupsDir(), receipt.Name) + switch mutation { + case "missing": + err = os.Remove(path) + case "changed": + var snapshot *sqlx.DB + snapshot, err = sqlx.Open("sqlite3", path) + if err == nil { + _, err = snapshot.Exec("UPDATE things SET name='other'") + _ = snapshot.Close() + } + case "corrupt": + err = os.WriteFile(path, make([]byte, receipt.SizeBytes), 0600) + case "symlink": + err = os.Remove(path) + if err == nil { + err = os.Symlink(s.databasePath, path) + } + } + if err != nil { + t.Fatal(err) + } + if err := s.VerifyRetentionBackup(context.Background(), receipt); err == nil { + t.Fatal("accepted invalid snapshot") + } + }) + } +} + +func TestRestoreCanceledBeforeSideEffects(t *testing.T) { + s, _ := newTestService(t) + receipt, err := s.CreateForRetention(context.Background()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + called := false + s.RestoreQuiesce = func() error { called = true; return nil } + _, err = s.runRestore(ctx, filepath.Join(s.backupsDir(), receipt.Name)) + if !errors.Is(err, context.Canceled) || called { + t.Fatalf("err=%v quiesced=%v", err, called) + } +} + +func TestBackupFailureDoesNotPublishOrLeakStaging(t *testing.T) { + s, _ := newTestService(t) + if err := s.pool.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.CreateForRetention(context.Background()); err == nil { + t.Fatal("expected closed database error") + } + entries, err := os.ReadDir(s.backupsDir()) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("failed backup left files: %v", entries) + } +} + +func TestRestoreHoldsMaintenanceAdmissionThroughQuiescence(t *testing.T) { + s, _ := newTestService(t) + receipt, err := s.CreateForRetention(context.Background()) + if err != nil { + t.Fatal(err) + } + stopped := errors.New("stop before pool close") + s.RestoreQuiesce = func() error { + if release, ok := maintenance.ForPool(s.pool).TryAcquire(); ok { + release() + t.Error("restore released admission before quiescence") + } + return stopped + } + _, err = s.runRestore(context.Background(), filepath.Join(s.backupsDir(), receipt.Name)) + if !errors.Is(err, stopped) { + t.Fatal(err) + } + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("failed restore leaked admission") + } + release() +} + +func TestVerifySnapshotRejectsCorruption(t *testing.T) { + path := filepath.Join(t.TempDir(), "invalid.db") + if err := os.WriteFile(path, []byte("not a sqlite database"), 0600); err != nil { + t.Fatal(err) + } + if _, err := verifySnapshot(context.Background(), path); err == nil { + t.Fatal("accepted invalid database") + } +} + +func TestBackupSweepsAbandonedPrivateStaging(t *testing.T) { + s, _ := newTestService(t) + if err := s.ensureBackupsDir(); err != nil { + t.Fatal(err) + } + stage, err := os.MkdirTemp(s.backupsDir(), ".snapshot-*") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stage, "snapshot.db"), []byte("partial"), 0600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-2 * staleTmpAge) + if err := os.Chtimes(stage, old, old); err != nil { + t.Fatal(err) + } + if _, err := s.CreateForRetention(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(stage); !os.IsNotExist(err) { + t.Fatalf("abandoned staging remains: %v", err) + } +} + +func TestDeleteWaitsForMaintenanceAdmission(t *testing.T) { + s, _ := newTestService(t) + receipt, err := s.CreateForRetention(context.Background()) + if err != nil { + t.Fatal(err) + } + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("lease unavailable") + } + defer release() + // Cancellation proves that deletion attempts admission before touching the file. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := s.DeleteContext(ctx, receipt.Name); !errors.Is(err, context.Canceled) { + t.Fatalf("want canceled, got %v", err) + } + if _, err := os.Stat(filepath.Join(s.backupsDir(), receipt.Name)); err != nil { + t.Fatal(err) + } +} + +func TestRetentionReceiptVerificationUnderBatchLease(t *testing.T) { + s, _ := newTestService(t) + receipt, err := s.CreateForRetention(context.Background()) + if err != nil { + t.Fatal(err) + } + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("lease unavailable") + } + defer release() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.VerifyRetentionBackupUnderLease(ctx, receipt); err != nil { + t.Fatal(err) + } + if other, ok := maintenance.ForPool(s.pool).TryAcquire(); ok { + other() + t.Fatal("verification released caller lease") + } +} + +func TestHTTPBackupSurvivesAcceptedRequestCancellation(t *testing.T) { + for _, kind := range []string{"create", "restore"} { + t.Run(kind, func(t *testing.T) { + s, _ := newTestService(t) + path := "/api/v1/system/backups" + if kind == "restore" { + result, err := s.runCreate(context.Background()) + if err != nil { + t.Fatal(err) + } + path += "/" + result["name"].(string) + "/restore" + } + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("lease unavailable") + } + defer release() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"confirm":"RESTORE"}`)).WithContext(ctx) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + newRouter(s).ServeHTTP(response, request) + if response.Code != http.StatusAccepted { + t.Fatalf("response: %d %s", response.Code, response.Body.String()) + } + var body struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + cancel() // net/http cancels the request after the accepted response returns. + release() + waitForJob(t, s.jobs, body.JobID, jobs.StateSucceeded) + }) + } +} + +func TestAcceptedBackupAPIsSurviveCallerCancellation(t *testing.T) { + for _, kind := range []string{"create", "restore"} { + t.Run(kind, func(t *testing.T) { + s, _ := newTestService(t) + snapshot, err := s.runCreate(context.Background()) + if err != nil { + t.Fatal(err) + } + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("lease unavailable") + } + defer release() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var id string + if kind == "create" { + id = s.Create(ctx) + } else { + id, err = s.Restore(ctx, snapshot["name"].(string), RestoreConfirmToken) + if err != nil { + t.Fatal(err) + } + } + cancel() + release() + waitForJob(t, s.jobs, id, jobs.StateSucceeded) + }) + } +} diff --git a/apps/backend/internal/system/backups/store.go b/apps/backend/internal/system/backups/store.go index b9d98f7045..660c9dcf3e 100644 --- a/apps/backend/internal/system/backups/store.go +++ b/apps/backend/internal/system/backups/store.go @@ -15,8 +15,8 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/db/dialect" - "github.com/kandev/kandev/internal/persistence" "github.com/kandev/kandev/internal/system/jobs" + "github.com/kandev/kandev/internal/system/maintenance" ) // Snapshot is the public representation of a backup file on disk. @@ -135,71 +135,34 @@ func (s *Service) List() ([]Snapshot, error) { } // Create starts a job that writes a manual snapshot via VACUUM INTO and -// returns the job ID immediately. +// returns the job ID immediately. The accepted job outlives its caller context. func (s *Service) Create(ctx context.Context) string { - return s.jobs.Start(ctx, "backup-create", func(ctx context.Context) (map[string]interface{}, error) { + return s.jobs.Start(context.WithoutCancel(ctx), "backup-create", func(ctx context.Context) (map[string]interface{}, error) { return s.runCreate(ctx) }) } -func (s *Service) runCreate(_ context.Context) (map[string]interface{}, error) { - if err := s.ensureBackupsDir(); err != nil { - return nil, err - } - // A .tmp sidecar is normally renamed away on success and removed on the - // error paths below, but a crash between SnapshotSQLite and os.Rename - // leaves one behind. classify() hides it from List()/Delete(), so it can - // never be cleaned up through the UI and would leak disk (up to the size - // of the live DB) indefinitely. Sweep any leftovers before writing a new - // one so crash debris is reclaimed on the next manual backup. - s.sweepStaleTmpFiles() - // Nanosecond precision so double-clicks or concurrent /backups POSTs do - // not collide on the same filename and silently overwrite one job's - // snapshot with another. - name := fmt.Sprintf("%s%d%s", manualPrefix, time.Now().UTC().UnixNano(), dbSuffix) - path := filepath.Join(s.backupsDir(), name) - // VACUUM INTO writes the multi-hundred-MB snapshot incrementally. If we - // wrote directly to the final "manual-*.db" name, a concurrent List() - // (the UI refetches immediately after the 202) would os.Stat a - // half-written file and report a truncated size. Write to a ".tmp" - // sidecar first — classify() ignores non-.db suffixes so it is never - // listed — then atomically rename it into place at its full size. - tmpPath := path + tmpSuffix - size, err := persistence.SnapshotSQLite(s.pool.Writer(), tmpPath) +func (s *Service) runCreate(ctx context.Context) (map[string]interface{}, error) { + receipt, err := s.createSnapshot(ctx, false) if err != nil { - _ = os.Remove(tmpPath) return nil, err } - if err := os.Rename(tmpPath, path); err != nil { - _ = os.Remove(tmpPath) - return nil, fmt.Errorf("rename snapshot into place: %w", err) - } - return map[string]interface{}{ - "name": name, - "size_bytes": size, - }, nil + return map[string]interface{}{"name": receipt.Name, "size_bytes": receipt.SizeBytes}, nil } -// staleTmpAge is how old a ".tmp" sidecar must be before the sweep reclaims -// it. Concurrent backup-create jobs are not serialized (jobs.Tracker runs each -// in its own goroutine), so a just-created sidecar may belong to another -// in-flight VACUUM INTO. Only files older than this are treated as crash debris, -// which keeps concurrent creates safe while still reclaiming leaked files. The -// threshold is far above any realistic VACUUM INTO duration. +// staleTmpAge protects recent temporary files while reclaiming crash debris. const staleTmpAge = 10 * time.Minute -// sweepStaleTmpFiles removes leftover ".tmp" VACUUM INTO sidecars from a -// previously crashed runCreate, skipping any modified within staleTmpAge so a -// concurrent create's in-progress sidecar is never deleted out from under it. -// Best-effort: read/stat/remove failures are logged and ignored so a stale -// file never blocks a fresh backup. +// sweepStaleTmpFiles removes abandoned legacy sidecars and private staging +// directories. Creation owns maintenance admission while this sweep runs. func (s *Service) sweepStaleTmpFiles() { entries, err := os.ReadDir(s.backupsDir()) if err != nil { return } for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), tmpSuffix) { + if (e.IsDir() || !strings.HasSuffix(e.Name(), tmpSuffix)) && + (!e.IsDir() || !strings.HasPrefix(e.Name(), ".snapshot-")) { continue } info, err := e.Info() @@ -207,7 +170,7 @@ func (s *Service) sweepStaleTmpFiles() { continue } p := filepath.Join(s.backupsDir(), e.Name()) - if err := os.Remove(p); err != nil && s.log != nil { + if err := os.RemoveAll(p); err != nil && s.log != nil { s.log.Warn("backups: failed to remove stale tmp snapshot", zap.String("path", p), zap.Error(err)) } } @@ -215,6 +178,7 @@ func (s *Service) sweepStaleTmpFiles() { // Restore validates the confirm token, then runs the restore as a job. // Returns the job ID on success, or an error if the token is wrong. +// Accepted restore jobs outlive the caller context. func (s *Service) Restore(ctx context.Context, name, confirm string) (string, error) { if confirm != RestoreConfirmToken { return "", errRestoreConfirm @@ -226,13 +190,18 @@ func (s *Service) Restore(ctx context.Context, name, confirm string) (string, er if err != nil { return "", err } - id := s.jobs.Start(ctx, "restore", func(ctx context.Context) (map[string]interface{}, error) { + id := s.jobs.Start(context.WithoutCancel(ctx), "restore", func(ctx context.Context) (map[string]interface{}, error) { return s.runRestore(ctx, abs) }) return id, nil } -func (s *Service) runRestore(_ context.Context, snapshotPath string) (map[string]interface{}, error) { +func (s *Service) runRestore(ctx context.Context, snapshotPath string) (map[string]interface{}, error) { + release, err := maintenance.ForPool(s.pool).Acquire(ctx) + if err != nil { + return nil, err + } + defer release() if _, err := os.Stat(snapshotPath); err != nil { return nil, fmt.Errorf("snapshot not found: %w", err) } @@ -410,6 +379,21 @@ func (s *Service) writeStagedRestore(snapshotPath, stagedPath string) error { // Delete removes a snapshot file. Refuses to delete pre-reset recovery // snapshots. func (s *Service) Delete(name string) error { + return s.DeleteContext(context.Background(), name) +} + +// DeleteContext removes a snapshot while honoring cancellation while waiting +// for shared maintenance admission. +func (s *Service) DeleteContext(ctx context.Context, name string) error { + return s.deleteContext(ctx, name) +} + +func (s *Service) deleteContext(ctx context.Context, name string) error { + release, err := maintenance.ForPool(s.pool).Acquire(ctx) + if err != nil { + return err + } + defer release() abs, err := s.resolveSnapshotPath(name) if err != nil { return err diff --git a/apps/backend/internal/system/database/admission_test.go b/apps/backend/internal/system/database/admission_test.go new file mode 100644 index 0000000000..c675b401e2 --- /dev/null +++ b/apps/backend/internal/system/database/admission_test.go @@ -0,0 +1,133 @@ +package database + +import ( + "context" + "encoding/json" + "errors" + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/system/jobs" + "github.com/kandev/kandev/internal/system/maintenance" + "testing" +) + +func TestMaintenanceCanceledBehindRetentionLease(t *testing.T) { + for _, name := range []string{"vacuum", "optimize", "reset"} { + t.Run(name, func(t *testing.T) { + s, _, _, _ := newTestService(t) + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("lease unavailable") + } + defer release() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + called := false + s.OrchestratorShutdown = func() { called = true } + var err error + switch name { + case "vacuum": + _, err = s.runVacuum(ctx) + case "optimize": + _, err = s.runOptimize(ctx) + case "reset": + _, err = s.runFactoryReset(ctx) + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("want cancellation, got %v", err) + } + if called { + t.Fatal("reset started without admission") + } + }) + } +} + +func TestResetHoldsMaintenanceAdmissionThroughQuiescence(t *testing.T) { + s, _, _, _ := newTestService(t) + stopped := errors.New("stop before reset") + s.DatabaseQuiesce = func() error { + if release, ok := maintenance.ForPool(s.pool).TryAcquire(); ok { + release() + t.Error("reset did not own admission") + } + return stopped + } + _, err := s.runFactoryReset(context.Background()) + if !errors.Is(err, stopped) { + t.Fatal(err) + } + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("failed reset leaked admission") + } + release() +} + +func TestHTTPMaintenanceSurvivesAcceptedRequestCancellation(t *testing.T) { + for _, name := range []string{"vacuum", "optimize", "reset"} { + t.Run(name, func(t *testing.T) { + s, tracker, _, _ := newTestService(t) + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("lease unavailable") + } + defer release() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + router := gin.New() + switch name { + case "vacuum": + router.POST("/", HandleVacuum(s)) + case "optimize": + router.POST("/", HandleOptimize(s)) + case "reset": + router.POST("/", HandleReset(s)) + } + request := httpPost(t, "/", `{"confirm":"RESET"}`).WithContext(ctx) + response := serveHTTP(router, request) + if response.Code != 202 { + t.Fatalf("response: %d %s", response.Code, response.Body.String()) + } + var body struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + cancel() + release() + waitForState(t, tracker, body.JobID, jobs.StateSucceeded) + }) + } +} + +func TestAcceptedMaintenanceAPIsSurviveCallerCancellation(t *testing.T) { + for _, name := range []string{"vacuum", "optimize", "reset"} { + t.Run(name, func(t *testing.T) { + s, tracker, _, _ := newTestService(t) + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + t.Fatal("lease unavailable") + } + defer release() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var id string + switch name { + case "vacuum": + id = s.Vacuum(ctx) + case "optimize": + id = s.Optimize(ctx) + case "reset": + var err error + id, err = s.FactoryReset(ctx, resetConfirmToken) + if err != nil { + t.Fatal(err) + } + } + cancel() + release() + waitForState(t, tracker, id, jobs.StateSucceeded) + }) + } +} diff --git a/apps/backend/internal/system/database/maintenance.go b/apps/backend/internal/system/database/maintenance.go index b037feaf2f..2380aa8767 100644 --- a/apps/backend/internal/system/database/maintenance.go +++ b/apps/backend/internal/system/database/maintenance.go @@ -6,23 +6,29 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/system/maintenance" ) // Vacuum runs VACUUM via a tracked job and returns the job ID. The job // result includes "size_before" and "size_after" bytes; "reclaimed_bytes" -// is the difference (>= 0). +// is the difference (>= 0). Accepted jobs outlive the caller context. func (s *Service) Vacuum(ctx context.Context) string { - return s.jobs.Start(ctx, "vacuum", func(jobCtx context.Context) (map[string]interface{}, error) { + return s.jobs.Start(context.WithoutCancel(ctx), "vacuum", func(jobCtx context.Context) (map[string]interface{}, error) { return s.runVacuum(jobCtx) }) } -func (s *Service) runVacuum(_ context.Context) (map[string]interface{}, error) { +func (s *Service) runVacuum(ctx context.Context) (map[string]interface{}, error) { + release, err := maintenance.ForPool(s.pool).Acquire(ctx) + if err != nil { + return nil, err + } + defer release() if err := s.requireSQLiteMaintenance("vacuum"); err != nil { return nil, err } before, _ := readDatabaseSize(s.pool.Reader()) - if _, err := s.pool.Writer().Exec("VACUUM"); err != nil { + if _, err := s.pool.Writer().ExecContext(ctx, "VACUUM"); err != nil { return nil, fmt.Errorf("vacuum: %w", err) } after, _ := readDatabaseSize(s.pool.Reader()) @@ -39,18 +45,23 @@ func (s *Service) runVacuum(_ context.Context) (map[string]interface{}, error) { // Optimize runs PRAGMA optimize via a tracked job. PRAGMA optimize is // cheap and idempotent; we still track it so the UI can show progress -// consistently with VACUUM. +// consistently with VACUUM. Accepted jobs outlive the caller context. func (s *Service) Optimize(ctx context.Context) string { - return s.jobs.Start(ctx, "optimize", func(jobCtx context.Context) (map[string]interface{}, error) { + return s.jobs.Start(context.WithoutCancel(ctx), "optimize", func(jobCtx context.Context) (map[string]interface{}, error) { return s.runOptimize(jobCtx) }) } -func (s *Service) runOptimize(_ context.Context) (map[string]interface{}, error) { +func (s *Service) runOptimize(ctx context.Context) (map[string]interface{}, error) { + release, err := maintenance.ForPool(s.pool).Acquire(ctx) + if err != nil { + return nil, err + } + defer release() if err := s.requireSQLiteMaintenance("optimize"); err != nil { return nil, err } - if _, err := s.pool.Writer().Exec("PRAGMA optimize"); err != nil { + if _, err := s.pool.Writer().ExecContext(ctx, "PRAGMA optimize"); err != nil { return nil, fmt.Errorf("pragma optimize: %w", err) } return map[string]interface{}{"status": "ok"}, nil diff --git a/apps/backend/internal/system/database/reset.go b/apps/backend/internal/system/database/reset.go index f02e39bcd3..0853ff783b 100644 --- a/apps/backend/internal/system/database/reset.go +++ b/apps/backend/internal/system/database/reset.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/persistence" + "github.com/kandev/kandev/internal/system/maintenance" ) // resetConfirmToken is the literal value the client must POST as @@ -28,7 +29,7 @@ var ErrResetNotConfirmed = errors.New("factory reset requires confirm=\"RESET\"" // caller must pass confirm == "RESET" — anything else returns // ErrResetNotConfirmed without starting a job. On success the job ID is // returned immediately; the heavy work runs asynchronously via the jobs -// tracker. +// tracker. Accepted jobs outlive the caller context. // // The job: // 1. Calls s.OrchestratorShutdown (if set) to stop running executions. @@ -44,12 +45,17 @@ func (s *Service) FactoryReset(ctx context.Context, confirm string) (string, err if confirm != resetConfirmToken { return "", ErrResetNotConfirmed } - return s.jobs.Start(ctx, "factory-reset", func(jobCtx context.Context) (map[string]interface{}, error) { + return s.jobs.Start(context.WithoutCancel(ctx), "factory-reset", func(jobCtx context.Context) (map[string]interface{}, error) { return s.runFactoryReset(jobCtx) }), nil } -func (s *Service) runFactoryReset(_ context.Context) (map[string]interface{}, error) { +func (s *Service) runFactoryReset(ctx context.Context) (map[string]interface{}, error) { + release, err := maintenance.ForPool(s.pool).Acquire(ctx) + if err != nil { + return nil, err + } + defer release() if err := s.requireSQLiteMaintenance("factory reset"); err != nil { return nil, err } @@ -63,7 +69,7 @@ func (s *Service) runFactoryReset(_ context.Context) (map[string]interface{}, er } } - snapshotPath, err := s.createPreResetSnapshot() + snapshotPath, err := s.createPreResetSnapshot(ctx) if err != nil { return nil, fmt.Errorf("pre-reset snapshot: %w", err) } @@ -107,7 +113,7 @@ func (s *Service) runFactoryReset(_ context.Context) (map[string]interface{}, er // createPreResetSnapshot performs VACUUM INTO into a stable, time-stamped // path inside the backups directory. The "kandev-" prefix is kept so the // existing backup retention regex continues to match the file. -func (s *Service) createPreResetSnapshot() (string, error) { +func (s *Service) createPreResetSnapshot(ctx context.Context) (string, error) { if s.pool == nil { return "", fmt.Errorf("no database pool") } @@ -117,7 +123,7 @@ func (s *Service) createPreResetSnapshot() (string, error) { } name := fmt.Sprintf("kandev-pre-reset-%s.db", strconv.FormatInt(time.Now().UTC().Unix(), 10)) path := filepath.Join(backupDir, name) - if _, err := s.pool.Writer().Exec(`VACUUM INTO ?`, path); err != nil { + if _, err := persistence.SnapshotSQLiteContext(ctx, s.pool.Writer(), path); err != nil { return "", fmt.Errorf("vacuum into %s: %w", path, err) } if s.log != nil { diff --git a/apps/backend/internal/system/maintenance/guard.go b/apps/backend/internal/system/maintenance/guard.go new file mode 100644 index 0000000000..8f227bc8aa --- /dev/null +++ b/apps/backend/internal/system/maintenance/guard.go @@ -0,0 +1,47 @@ +// Package maintenance serializes database maintenance with retention batches. +package maintenance + +import ( + "context" + "github.com/kandev/kandev/internal/db" + "sync" +) + +// Guard admits one maintenance operation or retention batch at a time. +type Guard struct{ token chan struct{} } + +var pools sync.Map + +// ForPool returns the process-lifetime admission guard for a shared pool. +func ForPool(pool *db.Pool) *Guard { + guard, _ := pools.LoadOrStore(pool, &Guard{token: make(chan struct{}, 1)}) + return guard.(*Guard) +} + +// Acquire waits for admission, respecting cancellation. Release is idempotent. +func (g *Guard) Acquire(ctx context.Context) (func(), error) { + if err := ctx.Err(); err != nil { + return nil, err + } + select { + case g.token <- struct{}{}: + release := sync.OnceFunc(func() { <-g.token }) + if err := ctx.Err(); err != nil { + release() + return nil, err + } + return release, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// TryAcquire lets a batch defer immediately while maintenance is running. +func (g *Guard) TryAcquire() (func(), bool) { + select { + case g.token <- struct{}{}: + return sync.OnceFunc(func() { <-g.token }), true + default: + return nil, false + } +} diff --git a/apps/backend/internal/system/maintenance/guard_test.go b/apps/backend/internal/system/maintenance/guard_test.go new file mode 100644 index 0000000000..98f3124202 --- /dev/null +++ b/apps/backend/internal/system/maintenance/guard_test.go @@ -0,0 +1,50 @@ +package maintenance + +import ( + "context" + "errors" + "github.com/kandev/kandev/internal/db" + "testing" +) + +func TestAdmissionIsSharedPerPool(t *testing.T) { + pool := db.NewPool(nil, nil) + g := ForPool(pool) + release, ok := g.TryAcquire() + if !ok { + t.Fatal("first lease unavailable") + } + if _, ok := ForPool(pool).TryAcquire(); ok { + t.Fatal("concurrent admission") + } + other, ok := ForPool(db.NewPool(nil, nil)).TryAcquire() + if !ok { + t.Fatal("unrelated pool blocked") + } + other() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := g.Acquire(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("want canceled, got %v", err) + } + release() + release() + next, ok := g.TryAcquire() + if !ok { + t.Fatal("release did not reopen admission") + } + next() +} + +func TestWaitingAdmissionCancels(t *testing.T) { + g := ForPool(db.NewPool(nil, nil)) + release, _ := g.TryAcquire() + defer release() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { _, err := g.Acquire(ctx); done <- err }() + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 0acbcc1c8f..f917376ea0 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -11,6 +11,7 @@ package system import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -21,6 +22,7 @@ import ( "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/persistence/requiredstores" "github.com/kandev/kandev/internal/system/backups" @@ -37,6 +39,7 @@ import ( systemsettings "github.com/kandev/kandev/internal/system/settings" "github.com/kandev/kandev/internal/system/sleepinhibition" "github.com/kandev/kandev/internal/system/storage" + "github.com/kandev/kandev/internal/system/toolretention" "github.com/kandev/kandev/internal/system/updates" "go.uber.org/zap" ) @@ -68,6 +71,7 @@ type Wiring struct { MessageQueue queuesettings.Target MessageQueueConfig queuesettings.Configuration TaskSessions sleepinhibition.SessionReader + ToolPayloadChanged func(context.Context, []string) } // Service exposes the composed system sub-services. Each field is @@ -88,6 +92,7 @@ type Service struct { Updates *updates.Service Restart restart.Manager Storage *storage.Handler + ToolRetention *toolretention.Service // StorageRuntime owns the scheduler, reconciliation, and durable cleanup worker. StorageRuntime *storage.Runtime Persistence *systempersistence.Handler @@ -115,11 +120,16 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus } dbSvc := database.NewService(pool, databasePath, resetDirs, tracker, log) dbSvc.OrchestratorShutdown = wiring.OrchestratorShutdown - dbSvc.DatabaseQuiesce = wiring.DatabaseQuiesce backupsSvc := backups.NewService(databasePath, pool, tracker, log) backupsSvc.OrchestratorShutdown = wiring.OrchestratorShutdown - backupsSvc.RestoreQuiesce = wiring.RestoreQuiesce + retentionSvc := provideToolRetention(pool, backupsSvc, eventBus, log, wiring) + dbSvc.DatabaseQuiesce = retentionQuiesce(retentionSvc, wiring.DatabaseQuiesce) + restoreQuiesce := wiring.RestoreQuiesce + if restoreQuiesce == nil && wiring.OrchestratorShutdown != nil { + restoreQuiesce = func() error { wiring.OrchestratorShutdown(); return nil } + } + backupsSvc.RestoreQuiesce = retentionQuiesce(retentionSvc, restoreQuiesce) settingsStore := wiring.SystemSettings if settingsStore == nil { @@ -167,12 +177,13 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus } return &Service{ - logger: log, - Info: info.NewService(build.Version, build.Commit, build.BuildTime), - Jobs: tracker, - Disk: disk.NewService(homeDir, tracker, log), - Database: dbSvc, - Backups: backupsSvc, + logger: log, + Info: info.NewService(build.Version, build.Commit, build.BuildTime), + Jobs: tracker, + Disk: disk.NewService(homeDir, tracker, log), + Database: dbSvc, + Backups: backupsSvc, + ToolRetention: retentionSvc, LogBundles: logbundle.New(logbundle.Config{ HomeDir: homeDir, Version: build.Version, Commit: build.Commit, BuildTime: build.BuildTime, Log: log, @@ -220,6 +231,9 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { admin.POST("/database/reset", database.HandleReset(s.Database)) backups.RegisterRoutes(g, admin, s.Backups) + if s.ToolRetention != nil { + toolretention.RegisterRoutes(g, admin, s.ToolRetention) + } if s.FrontendErrors != nil { g.POST("/logs/frontend-errors", frontenderrors.Handle(s.FrontendErrors)) @@ -253,6 +267,9 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { // StartBackground starts the System-owned pollers and reconciliation loops. // They stop when the application cleanup path calls StopBackground. func (s *Service) StartBackground(ctx context.Context) { + if s.ToolRetention != nil { + s.ToolRetention.Start(ctx) + } if s.LogBundles != nil { s.LogBundles.Start(ctx) } @@ -273,6 +290,9 @@ func (s *Service) StartBackground(ctx context.Context) { // StopBackground joins owned storage background workers. func (s *Service) StopBackground() { + if s.ToolRetention != nil { + s.ToolRetention.Stop() + } if s.SleepInhibition != nil { s.SleepInhibition.Stop() } @@ -283,3 +303,55 @@ func (s *Service) StopBackground() { s.StorageRuntime.Stop() } } + +func provideToolRetention(pool *db.Pool, snapshots *backups.Service, eventBus bus.EventBus, log *logger.Logger, wiring Wiring) *toolretention.Service { + return toolretention.New(pool, toolretention.Options{ + CreateBackup: func(ctx context.Context) (string, error) { + receipt, err := snapshots.CreateForRetention(ctx) + if err != nil { + return "", err + } + encoded, err := json.Marshal(receipt) + return string(encoded), err + }, + VerifyBackup: func(ctx context.Context, raw string) error { + var receipt backups.RetentionReceipt + if err := json.Unmarshal([]byte(raw), &receipt); err != nil { + return err + } + return snapshots.VerifyRetentionBackupUnderLease(ctx, receipt) + }, + Changed: wiring.ToolPayloadChanged, + Report: func(ctx context.Context, op *toolretention.Operation) { reportToolRetention(ctx, eventBus, log, op) }, + }) +} + +func retentionQuiesce(service *toolretention.Service, next func() error) func() error { + return func() error { + // Stop cancels pending admission before joining the worker. Restore/reset + // already owns maintenance admission when this callback runs. + service.Stop() + if next != nil { + return next() + } + return nil + } +} + +func reportToolRetention(ctx context.Context, eventBus bus.EventBus, log *logger.Logger, op *toolretention.Operation) { + if eventBus == nil || op == nil { + return + } + state := jobs.State(op.State) + if op.State == "cancelled" { + state = jobs.StateFailed + } + job := &jobs.Job{ID: op.ID, Kind: "tool-payload-retention-" + op.Kind, State: state, StartedAt: op.StartedAt, + Result: map[string]interface{}{"scanned": op.Scanned, "eligible_tasks": op.EligibleTasks, "eligible_messages": op.EligibleMessages, "removed_messages": op.RemovedMessages, "payload_bytes": op.PayloadBytes, "state": op.State}} + if op.FinishedAt != nil { + job.EndedAt = *op.FinishedAt + } + if err := eventBus.Publish(ctx, events.SystemJobUpdate, bus.NewEvent(events.SystemJobUpdate, "tool-payload-retention", job)); err != nil && log != nil { + log.Warn("failed to publish tool payload retention progress", zap.Error(err)) + } +} diff --git a/apps/backend/internal/system/toolretention/admission_integration_test.go b/apps/backend/internal/system/toolretention/admission_integration_test.go new file mode 100644 index 0000000000..97714c5c3a --- /dev/null +++ b/apps/backend/internal/system/toolretention/admission_integration_test.go @@ -0,0 +1,308 @@ +package toolretention + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/require" +) + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-002.3, 002.5 +func TestAdmissionForegroundCommitAheadOfCleanupProtectsPayload(t *testing.T) { + s := testService(t) + original := seedAnalysis(t, s) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + revision := approveTestPolicy(t, s) + _, err := s.Run(ctx, revision) + require.NoError(t, err) + readStarted := make(chan struct{}) + var observed sync.Once + installAdmissionHook(t, s.pool.Reader(), func(c *sqlite3.SQLiteConn) { + c.RegisterAuthorizer(func(op int, table, _, _ string) int { + if op == sqlite3.SQLITE_READ && table == "settings" { + observed.Do(func() { close(readStarted) }) + } + return sqlite3.SQLITE_OK + }) + }) + foreground, err := s.pool.Writer().BeginTxx(ctx, nil) + require.NoError(t, err) + defer func() { _ = foreground.Rollback() }() + _, err = foreground.ExecContext(ctx, `UPDATE task_sessions SET state='RUNNING',updated_at='2099-01-01' WHERE id='session'`) + require.NoError(t, err) + cleanup := admissionCall(t, func() error { return s.stepScan(ctx) }) + awaitAdmissionBarrier(t, ctx, readStarted) + // Cleanup has begun reading while the activity update is still uncommitted. + require.NoError(t, foreground.Commit()) + awaitAdmissionResult(t, ctx, cleanup) + finishAdmissionCleanup(t, ctx, s) + require.Equal(t, original, admissionMetadata(t, s)) + status, err := s.Get(ctx) + require.NoError(t, err) + require.Zero(t, status.Operation.RemovedMessages) +} + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-002.3, 002.5 +func TestAdmissionCleanupCommitAheadOfForegroundPreservesMarker(t *testing.T) { + s := testService(t) + seedAnalysis(t, s) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + revision := approveTestPolicy(t, s) + _, err := s.Run(ctx, revision) + require.NoError(t, err) + written, release := make(chan struct{}), make(chan struct{}) + unblock := sync.OnceFunc(func() { close(release) }) + defer unblock() + var observed sync.Once + installAdmissionHook(t, s.pool.Writer(), func(c *sqlite3.SQLiteConn) { + c.RegisterUpdateHook(func(op int, _, table string, _ int64) { + if op == sqlite3.SQLITE_UPDATE && table == "task_session_messages" { + observed.Do(func() { + close(written) + select { + case <-release: + case <-ctx.Done(): + } + }) + } + }) + }) + cleanup := admissionCall(t, func() error { return s.stepScan(ctx) }) + awaitAdmissionBarrier(t, ctx, written) + foregroundStarted := make(chan struct{}) + foreground := admissionCall(t, func() error { + close(foregroundStarted) + return writeAdmissionActivity(ctx, s, true, 1) + }) + awaitAdmissionBarrier(t, ctx, foregroundStarted) + unblock() + awaitAdmissionResult(t, ctx, cleanup) + awaitAdmissionResult(t, ctx, foreground) + var metadata map[string]any + require.NoError(t, json.Unmarshal([]byte(admissionMetadata(t, s)), &metadata)) + require.Contains(t, metadata, "payload_retention") + var state string + require.NoError(t, s.pool.Reader().Get(&state, `SELECT state FROM task_sessions WHERE id='session'`)) + require.Equal(t, "RUNNING", state) +} + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-001.5, 003.2, 003.3 +func TestAdmissionMultiBatchWritesAndExplicitCompactionEvidence(t *testing.T) { + s := testService(t) + original := seedAnalysis(t, s) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + const messages = batchRows*3 + 7 + seedAdmissionBatches(t, ctx, s, original, messages) + revision := approveTestPolicy(t, s) + _, err := s.Run(ctx, revision) + require.NoError(t, err) + before := measureAdmissionDatabase(t, s) + requests, results := startAdmissionBatches(t, ctx, s) + writes, batches := 0, 0 + for batches < messages+10 { + select { + case requests <- struct{}{}: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + awaitAdmissionResult(t, ctx, results) + batches++ + status, getErr := s.Get(ctx) + require.NoError(t, getErr) + if status.Operation.State != stateRunning { + require.Equal(t, stateSucceeded, status.Operation.State) + break + } + // The worker cannot begin the next batch until this foreground commit completes. + foreground := admissionCall(t, func() error { return writeAdmissionActivity(ctx, s, false, writes+1) }) + awaitAdmissionResult(t, ctx, foreground) + writes++ + } + require.GreaterOrEqual(t, batches, 4) + require.Equal(t, batches-1, writes) + var updated string + require.NoError(t, s.pool.Reader().Get(&updated, `SELECT CAST(updated_at AS TEXT) FROM task_sessions WHERE id='foreground-session'`)) + require.Equal(t, admissionActivityTime(writes), updated) + afterCleanup := measureAdmissionDatabase(t, s) + status, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, stateSucceeded, status.Operation.State) + require.EqualValues(t, messages, status.Operation.RemovedMessages) + require.Equal(t, before.PayloadBytes-afterCleanup.PayloadBytes, status.Operation.PayloadBytes) + require.Greater(t, status.Operation.PayloadBytes, int64(0)) + require.Greater(t, afterCleanup.FreePages, before.FreePages) + require.GreaterOrEqual(t, afterCleanup.FileBytes, before.FileBytes) + _, err = s.pool.Writer().ExecContext(ctx, "VACUUM") + require.NoError(t, err) + afterVacuum := measureAdmissionDatabase(t, s) + require.Less(t, afterVacuum.FileBytes, afterCleanup.FileBytes) + require.Zero(t, afterVacuum.FreePages) + require.Equal(t, afterCleanup.PayloadBytes, afterVacuum.PayloadBytes) + t.Logf("batches=%d foreground_commits=%d logical_removed_bytes=%d page_size_bytes=%d freelist_pages_before=%d after_cleanup=%d after_vacuum=%d main_file_bytes_before=%d after_cleanup=%d after_vacuum=%d compacted_bytes=%d", + batches, writes, status.Operation.PayloadBytes, before.PageSize, before.FreePages, afterCleanup.FreePages, afterVacuum.FreePages, before.FileBytes, afterCleanup.FileBytes, afterVacuum.FileBytes, afterCleanup.FileBytes-afterVacuum.FileBytes) +} + +func installAdmissionHook(t *testing.T, pool *sqlx.DB, install func(*sqlite3.SQLiteConn)) { + t.Helper() + pool.SetMaxOpenConns(1) + pool.SetMaxIdleConns(1) + conn, err := pool.Conn(context.Background()) + require.NoError(t, err) + err = conn.Raw(func(raw any) error { install(raw.(*sqlite3.SQLiteConn)); return nil }) + closeErr := conn.Close() + require.NoError(t, err) + require.NoError(t, closeErr) +} + +func admissionCall(t *testing.T, fn func() error) <-chan error { + t.Helper() + done := make(chan error, 1) + go func() { done <- fn(); close(done) }() + t.Cleanup(func() { <-done }) + return done +} + +func awaitAdmissionBarrier(t *testing.T, ctx context.Context, ready <-chan struct{}) { + t.Helper() + select { + case <-ready: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} + +func awaitAdmissionResult(t *testing.T, ctx context.Context, result <-chan error) { + t.Helper() + select { + case err := <-result: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} + +func admissionMetadata(t *testing.T, s *Service) string { + t.Helper() + var raw string + require.NoError(t, s.pool.Reader().Get(&raw, `SELECT metadata FROM task_session_messages WHERE id='message'`)) + return raw +} + +func finishAdmissionCleanup(t *testing.T, ctx context.Context, s *Service) { + t.Helper() + for i := 0; i < 10; i++ { + status, err := s.Get(ctx) + require.NoError(t, err) + if status.Operation.State != stateRunning { + require.Equal(t, stateSucceeded, status.Operation.State) + return + } + require.NoError(t, s.stepScan(ctx)) + } + t.Fatal("cleanup did not finish") +} + +func writeAdmissionActivity(ctx context.Context, s *Service, checkMarker bool, sequence int) error { + tx, err := s.pool.Writer().BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + session := "foreground-session" + if checkMarker { + session = "session" + var raw string + if err := tx.GetContext(ctx, &raw, `SELECT metadata FROM task_session_messages WHERE id='message'`); err != nil { + return err + } + var metadata map[string]any + if err := json.Unmarshal([]byte(raw), &metadata); err != nil { + return err + } + if _, ok := metadata["payload_retention"]; !ok { + return fmt.Errorf("foreground admitted before removal marker committed") + } + } + result, err := tx.ExecContext(ctx, `UPDATE task_sessions SET state='RUNNING',updated_at=? WHERE id=?`, admissionActivityTime(sequence), session) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed != 1 { + return fmt.Errorf("foreground session update affected %d rows", changed) + } + return tx.Commit() +} + +func admissionActivityTime(sequence int) string { + return time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(sequence) * time.Second).Format("2006-01-02 15:04:05") +} + +func seedAdmissionBatches(t *testing.T, ctx context.Context, s *Service, raw string, count int) { + t.Helper() + tx, err := s.pool.Writer().BeginTxx(ctx, nil) + require.NoError(t, err) + defer func() { _ = tx.Rollback() }() + for i := 1; i < count; i++ { + _, err = tx.ExecContext(ctx, `INSERT INTO task_session_messages(id,task_session_id,task_id,created_at,updated_at,type,metadata,requests_input) VALUES(?,'session','task','2020-01-01','2020-01-01','tool_execute',?,0)`, fmt.Sprintf("generated-%04d", i), raw) + require.NoError(t, err) + } + _, err = tx.ExecContext(ctx, `INSERT INTO tasks VALUES('foreground-task','IN_PROGRESS','',NULL,'2099-01-01','2099-01-01'); + INSERT INTO task_sessions VALUES('foreground-session','foreground-task','RUNNING','2099-01-01',NULL,'2099-01-01')`) + require.NoError(t, err) + require.NoError(t, tx.Commit()) +} + +func startAdmissionBatches(t *testing.T, ctx context.Context, s *Service) (chan<- struct{}, <-chan error) { + t.Helper() + requests, results := make(chan struct{}), make(chan error, 1) + done := make(chan struct{}) + go func() { + defer close(done) + for { + select { + case <-ctx.Done(): + return + case _, ok := <-requests: + if !ok { + return + } + results <- s.stepScan(ctx) + } + } + }() + t.Cleanup(func() { close(requests); <-done }) + return requests, results +} + +type admissionDatabaseMeasurement struct{ FileBytes, PayloadBytes, FreePages, PageSize int64 } + +func measureAdmissionDatabase(t *testing.T, s *Service) admissionDatabaseMeasurement { + t.Helper() + var busy, frames, checkpointed int + require.NoError(t, s.pool.Writer().QueryRow("PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &frames, &checkpointed)) + require.Zero(t, busy) + var sequence int + var schema, path string + require.NoError(t, s.pool.Reader().QueryRow("PRAGMA database_list").Scan(&sequence, &schema, &path)) + info, err := os.Stat(path) + require.NoError(t, err) + result := admissionDatabaseMeasurement{FileBytes: info.Size()} + require.NoError(t, s.pool.Reader().Get(&result.FreePages, "PRAGMA freelist_count")) + require.NoError(t, s.pool.Reader().Get(&result.PageSize, "PRAGMA page_size")) + require.NoError(t, s.pool.Reader().Get(&result.PayloadBytes, `SELECT COALESCE(SUM(length(CAST(metadata AS BLOB))),0) FROM task_session_messages`)) + return result +} diff --git a/apps/backend/internal/system/toolretention/analysis_test.go b/apps/backend/internal/system/toolretention/analysis_test.go new file mode 100644 index 0000000000..4c35d347fe --- /dev/null +++ b/apps/backend/internal/system/toolretention/analysis_test.go @@ -0,0 +1,84 @@ +package toolretention + +import ( + "context" + "encoding/json" + "github.com/stretchr/testify/require" + "strings" + "testing" +) + +func seedAnalysis(t *testing.T, s *Service) string { + t.Helper() + _, err := s.pool.Writer().Exec(` + CREATE TABLE tasks(id TEXT PRIMARY KEY,state TEXT, queued_for_step_id TEXT,queued_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP); + CREATE TABLE task_sessions(id TEXT PRIMARY KEY,task_id TEXT,state TEXT,started_at TIMESTAMP,completed_at TIMESTAMP,updated_at TIMESTAMP); + CREATE INDEX sessions_task ON task_sessions(task_id,id); + CREATE TABLE task_session_turns(id TEXT PRIMARY KEY,task_session_id TEXT,task_id TEXT,completed_at TIMESTAMP,updated_at TIMESTAMP,started_at TIMESTAMP DEFAULT '2020-01-01',created_at TIMESTAMP DEFAULT '2020-01-01',metadata TEXT DEFAULT '{}'); + CREATE INDEX turns_task ON task_session_turns(task_id); + CREATE TABLE task_session_messages(id TEXT PRIMARY KEY,task_session_id TEXT,task_id TEXT,created_at TIMESTAMP,updated_at TIMESTAMP,type TEXT,metadata TEXT,requests_input INTEGER,author_type TEXT DEFAULT 'agent',turn_id TEXT DEFAULT 'turn'); + CREATE INDEX idx_messages_session_updated ON task_session_messages(task_session_id,updated_at); + CREATE INDEX idx_messages_session_created ON task_session_messages(task_session_id,created_at); + CREATE INDEX messages_session ON task_session_messages(task_session_id); + CREATE TABLE queued_messages(task_id TEXT,session_id TEXT); + CREATE TABLE pending_moves(task_id TEXT,session_id TEXT); + CREATE TABLE runs(status TEXT,payload TEXT,session_id TEXT,requested_at TIMESTAMP); + CREATE INDEX idx_run_status_requested ON runs(status,requested_at); + CREATE INDEX idx_messages_task_author_created ON task_session_messages(task_id,author_type,type,created_at DESC); + INSERT INTO tasks VALUES('task','COMPLETED','',NULL,'2020-01-01','2020-01-01'); + INSERT INTO task_sessions VALUES('session','task','COMPLETED','2020-01-01','2020-01-01','2020-01-01'); + INSERT INTO task_session_turns(id,task_session_id,task_id,completed_at,updated_at) VALUES('turn','session','task','2020-01-01','2020-01-01'); + `) + require.NoError(t, err) + raw, _ := json.Marshal(map[string]any{"title": "echo result", "tool_call_id": "tool1", "status": "complete", "normalized": map[string]any{"kind": "shell_exec", "shell_exec": map[string]any{"command": "echo result", "output": map[string]any{"stdout": strings.Repeat("payload", 1000), "exit_code": 0}}}}) + _, err = s.pool.Writer().Exec(`INSERT INTO task_session_messages(id,task_session_id,task_id,created_at,updated_at,type,metadata,requests_input) VALUES('message','session','task','2020-01-01','2020-01-01','tool_execute',?,0)`, string(raw)) + require.NoError(t, err) + return string(raw) +} + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-001.4, 001.5, 003.2 +func TestAnalysisIsReadOnlyAndCountsExactEligiblePayloads(t *testing.T) { + s := testService(t) + before := seedAnalysis(t, s) + ctx := context.Background() + id, err := s.Analyze(ctx, Age{3, "months"}) + require.NoError(t, err) + require.NotEmpty(t, id) + for i := 0; i < 10; i++ { + require.NoError(t, s.stepScan(ctx)) + status, err := s.Get(ctx) + require.NoError(t, err) + if status.Operation.State != "running" { + break + } + } + status, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, "succeeded", status.Operation.State) + require.EqualValues(t, 1, status.Operation.EligibleTasks) + require.EqualValues(t, 1, status.Operation.EligibleMessages) + require.Greater(t, status.Operation.PayloadBytes, int64(6000)) + require.False(t, status.Policy.Enabled) + var after string + require.NoError(t, s.pool.Reader().Get(&after, `SELECT metadata FROM task_session_messages WHERE id='message'`)) + require.Equal(t, before, after) +} + +func TestAnalysisCancellationAndSingleOperation(t *testing.T) { + s := testService(t) + seedAnalysis(t, s) + ctx := context.Background() + id, err := s.Analyze(ctx, Age{3, "months"}) + require.NoError(t, err) + _, err = s.Analyze(ctx, Age{1, "weeks"}) + require.ErrorContains(t, err, "busy") + _, err = s.Cancel(ctx, "wrong") + require.ErrorContains(t, err, "conflict") + status, err := s.Cancel(ctx, id) + require.NoError(t, err) + require.Equal(t, "cancelled", status.Operation.State) + require.NoError(t, s.stepScan(ctx)) + status, err = s.Get(ctx) + require.NoError(t, err) + require.EqualValues(t, 0, status.Operation.PayloadBytes) +} diff --git a/apps/backend/internal/system/toolretention/batch_budget.go b/apps/backend/internal/system/toolretention/batch_budget.go new file mode 100644 index 0000000000..898b6e2a16 --- /dev/null +++ b/apps/backend/internal/system/toolretention/batch_budget.go @@ -0,0 +1,3 @@ +package toolretention + +func rowFitsBatch(used, size int64) bool { return used == 0 || size <= batchBytes-used } diff --git a/apps/backend/internal/system/toolretention/batch_budget_test.go b/apps/backend/internal/system/toolretention/batch_budget_test.go new file mode 100644 index 0000000000..0b3e3c84cf --- /dev/null +++ b/apps/backend/internal/system/toolretention/batch_budget_test.go @@ -0,0 +1,20 @@ +package toolretention + +import "testing" + +func TestBatchByteBudgetAdmitsLargeRowsOnlyAlone(t *testing.T) { + for _, tt := range []struct { + used, size int64 + want bool + }{ + {0, 32 * 1024 * 1024, true}, + {batchBytes - 1, 32 * 1024 * 1024, false}, + {batchBytes - 1, 1, true}, + {batchBytes, 1, false}, + {1, batchBytes, false}, + } { + if got := rowFitsBatch(tt.used, tt.size); got != tt.want { + t.Errorf("used=%d next=%d: got %v want %v", tt.used, tt.size, got, tt.want) + } + } +} diff --git a/apps/backend/internal/system/toolretention/cancel_preparation_test.go b/apps/backend/internal/system/toolretention/cancel_preparation_test.go new file mode 100644 index 0000000000..66633728de --- /dev/null +++ b/apps/backend/internal/system/toolretention/cancel_preparation_test.go @@ -0,0 +1,332 @@ +package toolretention + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +type blockedPreparation struct { + service *Service + status Status + interrupted <-chan struct{} + stopped <-chan struct{} +} + +func startWriterBlockedPreparation(t *testing.T, onInterrupted ...func()) blockedPreparation { + t.Helper() + s := testService(t) + entered := make(chan struct{}) + interrupted := make(chan struct{}) + done := make(chan error, 1) + stopped := make(chan struct{}) + worker, cancel := context.WithCancel(context.Background()) + s.opts.CreateBackup = func(ctx context.Context) (string, error) { + conn, err := s.pool.Writer().Connx(ctx) + if err != nil { + return "", err + } + defer conn.Close() //nolint:errcheck // The connection is returned to the test pool. + close(entered) + <-ctx.Done() + for _, fn := range onInterrupted { + fn() + } + close(interrupted) + return "", ctx.Err() + } + _, err := s.Save(context.Background(), Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: choiceBackup}) + require.NoError(t, err) + go func() { done <- s.tick(worker); close(stopped) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("backup worker did not stop") + } + }) + select { + case <-entered: + case err := <-done: + t.Fatalf("backup did not start: %v", err) + case <-time.After(3 * time.Second): + t.Fatal("backup did not acquire writer") + } + status, err := s.Get(context.Background()) + require.NoError(t, err) + require.Equal(t, stateRunning, status.Preparation.State) + return blockedPreparation{s, status, interrupted, stopped} +} + +func TestInterruptedPreparationPersistenceFailureStaysDisabled(t *testing.T) { + for _, action := range []string{"cancel", "disable"} { + t.Run(action, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + blocked := startWriterBlockedPreparation(t, cancel) + var err error + if action == "cancel" { + _, err = blocked.service.Cancel(ctx, blocked.status.Operation.ID) + } else { + _, err = blocked.service.Save(ctx, Update{Age: blocked.status.Policy.Age, Revision: blocked.status.Policy.Revision}) + } + require.ErrorIs(t, err, context.Canceled) + r, err := readRecord(context.Background(), blocked.service.pool.Reader()) + require.NoError(t, err) + require.False(t, r.Policy.Enabled) + require.Zero(t, r.ApprovedRevision) + require.False(t, approved(&r)) + require.Equal(t, stateRunning, r.Preparation.State) + require.Equal(t, choiceBackup, r.Operation.Kind) + select { + case <-blocked.stopped: + case <-time.After(3 * time.Second): + t.Fatal("backup worker did not stop") + } + require.NoError(t, blocked.service.tick(context.Background())) + status, err := blocked.service.Get(context.Background()) + require.NoError(t, err) + require.Equal(t, stateFailed, status.Preparation.State) + require.Equal(t, stateFailed, status.Operation.State) + require.False(t, status.Policy.Enabled) + _, err = blocked.service.Save(context.Background(), Update{Enabled: true, Age: status.Policy.Age, Revision: status.Policy.Revision, BackupChoice: choiceBackup}) + require.NoError(t, err, "normal worker recovery must make preparation retryable") + }) + } +} + +func TestPreparationInterruptionDoesNotCancelNewerWork(t *testing.T) { + for _, changed := range []string{"operation", "revision"} { + t.Run(changed, func(t *testing.T) { + s := testService(t) + _, err := s.Save(context.Background(), Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: choiceBackup}) + require.NoError(t, err) + observed, err := s.change(context.Background(), func(r *record, _ *sqlx.Tx) error { + r.Preparation.State = stateRunning + r.Operation = &Operation{ID: "original", Kind: choiceBackup, State: stateRunning} + return nil + }) + require.NoError(t, err) + newer, cancel := context.WithCancel(context.Background()) + defer cancel() + s.activeCancel = cancel + s.activeRevision = observed.Policy.Revision + s.activeID = observed.Operation.ID + _, err = s.interruptValidated(context.Background(), func(r *record) error { + _, updateErr := s.change(context.Background(), func(current *record, _ *sqlx.Tx) error { + if changed == "operation" { + current.Operation.ID = "newer-operation" + } else { + current.Policy.Revision++ + } + s.activeID = current.Operation.ID + s.activeRevision = current.Policy.Revision + return nil + }) + return updateErr + }) + if changed == "operation" { + require.ErrorContains(t, err, "conflict") + } else { + require.NoError(t, err) + } + require.NoError(t, newer.Err(), "stale observation must not cancel new work") + }) + } +} + +func TestPreparationHandoffInterruptsWriterHeldByBackup(t *testing.T) { + for _, id := range []string{"", "previous-operation"} { + for _, action := range []string{"cancel", "disable"} { + t.Run(id+"/"+action, func(t *testing.T) { + blocked := startWriterBlockedPreparation(t) + // Reproduce the interval between the durable operation and activeID publication. + blocked.service.mu.Lock() + blocked.service.activeID = id + blocked.service.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var status Status + var err error + if action == "cancel" { + status, err = blocked.service.Cancel(ctx, blocked.status.Operation.ID) + } else { + status, err = blocked.service.Save(ctx, Update{Age: blocked.status.Policy.Age, Revision: blocked.status.Policy.Revision}) + } + require.NoError(t, err) + require.Equal(t, stateCancelled, status.Operation.State) + require.Equal(t, stateNone, status.Preparation.State) + }) + } + } +} + +func TestCancelPreparationInterruptsWriterHeldByBackup(t *testing.T) { + for _, action := range []string{"cancel", "disable"} { + t.Run(action, func(t *testing.T) { + blocked := startWriterBlockedPreparation(t) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var status Status + var err error + if action == "cancel" { + status, err = blocked.service.Cancel(ctx, blocked.status.Operation.ID) + } else { + status, err = blocked.service.Save(ctx, Update{Age: blocked.status.Policy.Age, Revision: blocked.status.Policy.Revision}) + } + require.NoError(t, err, "request must interrupt the backup before acquiring its writer") + select { + case <-blocked.interrupted: + default: + t.Fatal("backup was not interrupted") + } + require.False(t, status.Policy.Enabled) + require.Equal(t, stateNone, status.Preparation.State) + require.Equal(t, stateCancelled, status.Operation.State) + }) + } +} + +func TestInvalidPreparationRequestsDoNotInterruptBackup(t *testing.T) { + blocked := startWriterBlockedPreparation(t) + for _, action := range []string{"wrong operation", "stale revision", "missing backup choice", "invalid age", "invalid choice"} { + t.Run(action, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var err error + want := "conflict" + update := Update{Age: blocked.status.Policy.Age, Revision: blocked.status.Policy.Revision} + switch action { + case "wrong operation": + _, err = blocked.service.Cancel(ctx, "different-operation") + case "stale revision": + update.Revision-- + _, err = blocked.service.Save(ctx, update) + case "missing backup choice": + update.Enabled = true + want = "backup_choice_required" + _, err = blocked.service.Save(ctx, update) + case "invalid age": + update.Age.Value = 0 + want = "invalid_age" + _, err = blocked.service.Save(ctx, update) + case "invalid choice": + update.BackupChoice = "invalid" + want = "invalid_backup_choice" + _, err = blocked.service.Save(ctx, update) + } + require.ErrorContains(t, err, want) + select { + case <-blocked.interrupted: + t.Fatal("invalid request interrupted backup") + default: + } + }) + } +} + +func TestPreparationInterruptionRetainsTransactionalCAS(t *testing.T) { + for _, action := range []string{"cancel revision", "cancel operation", "disable revision"} { + t.Run(action, func(t *testing.T) { + s := testService(t) + _, err := s.Save(context.Background(), Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: choiceBackup}) + require.NoError(t, err) + observed, err := s.change(context.Background(), func(r *record, _ *sqlx.Tx) error { + r.Preparation.State = stateRunning + r.Operation = &Operation{ID: "original", Kind: choiceBackup, State: stateRunning} + return nil + }) + require.NoError(t, err) + s.activeID = observed.Operation.ID + s.activeRevision = observed.Policy.Revision + var replacement record + s.activeCancel = func() { + // Commit a competing request after validation and before the writer CAS. + var updateErr error + replacement, updateErr = s.change(context.Background(), func(r *record, _ *sqlx.Tx) error { + if action == "cancel operation" { + r.Operation.ID = "replacement" + } else { + r.Policy.Revision++ + } + return nil + }) + require.NoError(t, updateErr) + } + if action == "disable revision" { + _, err = s.Save(context.Background(), Update{Age: observed.Policy.Age, Revision: observed.Policy.Revision}) + } else { + _, err = s.Cancel(context.Background(), observed.Operation.ID) + } + require.ErrorContains(t, err, "conflict") + current, err := readRecord(context.Background(), s.pool.Reader()) + require.NoError(t, err) + require.Equal(t, replacement, current, "stale request must not change durable state") + }) + } +} + +func TestPendingPreparationBecomingRunningCanBeInterrupted(t *testing.T) { + for _, previous := range []bool{false, true} { + t.Run(map[bool]string{false: "no operation", true: "cancelled operation"}[previous], func(t *testing.T) { + s := testService(t) + status, err := s.Save(context.Background(), Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: choiceBackup}) + require.NoError(t, err) + if previous { + _, err = s.change(context.Background(), func(r *record, _ *sqlx.Tx) error { + r.Operation = &Operation{ID: "previous", Kind: choiceBackup, State: stateCancelled} + return nil + }) + require.NoError(t, err) + } + entered := make(chan struct{}) + done := make(chan error, 1) + worker, stop := context.WithCancel(context.Background()) + defer stop() + s.opts.CreateBackup = func(ctx context.Context) (string, error) { + conn, err := s.pool.Writer().Connx(ctx) + if err != nil { + return "", err + } + defer conn.Close() //nolint:errcheck // Return the connection after cancellation. + close(entered) + <-ctx.Done() + return "", ctx.Err() + } + t.Cleanup(func() { + stop() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("backup worker did not stop") + } + }) + update := Update{Age: status.Policy.Age, Revision: status.Policy.Revision} + _, err = s.interruptValidated(context.Background(), func(r *record) error { + require.Equal(t, statePending, r.Preparation.State) + _, validationErr := validatePolicyUpdate(r, update, s.opts.Now()) + go func() { done <- s.tick(worker) }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("backup did not acquire writer") + } + return validationErr + }) + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + conn, err := s.pool.Writer().Connx(ctx) + require.NoError(t, err, "pending observation must interrupt the now-running backup") + require.NoError(t, conn.Close()) + status, err = s.Save(ctx, update) + require.NoError(t, err) + require.False(t, status.Policy.Enabled) + require.Equal(t, stateNone, status.Preparation.State) + }) + } +} diff --git a/apps/backend/internal/system/toolretention/handler.go b/apps/backend/internal/system/toolretention/handler.go new file mode 100644 index 0000000000..4462066735 --- /dev/null +++ b/apps/backend/internal/system/toolretention/handler.go @@ -0,0 +1,128 @@ +package toolretention + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/gin-gonic/gin" +) + +// HTTPService is the retention operations exposed by the settings endpoints. +type HTTPService interface { + Get(context.Context) (Status, error) + Save(context.Context, Update) (Status, error) + Analyze(context.Context, Age) (string, error) + Run(context.Context, int64) (string, error) + Cancel(context.Context, string) (Status, error) +} + +// RegisterRoutes keeps status readable while all work requires admin admission. +func RegisterRoutes(read, admin *gin.RouterGroup, service HTTPService) { + const base = "/database/tool-payload-retention" + read.GET(base, func(c *gin.Context) { + status, err := service.Get(c.Request.Context()) + respond(c, http.StatusOK, status, err) + }) + admin.PUT(base, handleSave(service)) + admin.POST(base+"/analyze", func(c *gin.Context) { + var req struct { + Age *Age `json:"age"` + } + if !decodeBody(c, &req) { + return + } + if req.Age == nil { + badRequest(c) + return + } + id, err := service.Analyze(c.Request.Context(), *req.Age) + respond(c, http.StatusAccepted, gin.H{"operation_id": id}, err) + }) + admin.POST(base+"/run", func(c *gin.Context) { + var req struct { + Revision *int64 `json:"revision"` + } + if !decodeBody(c, &req) { + return + } + if req.Revision == nil || *req.Revision < 0 { + badRequest(c) + return + } + id, err := service.Run(c.Request.Context(), *req.Revision) + respond(c, http.StatusAccepted, gin.H{"operation_id": id}, err) + }) + admin.POST(base+"/cancel", func(c *gin.Context) { + var req struct { + OperationID string `json:"operation_id"` + } + if !decodeBody(c, &req) { + return + } + if req.OperationID == "" { + badRequest(c) + return + } + status, err := service.Cancel(c.Request.Context(), req.OperationID) + respond(c, http.StatusOK, status, err) + }) +} + +func handleSave(service HTTPService) gin.HandlerFunc { + return func(c *gin.Context) { + var req struct { + Enabled *bool `json:"enabled"` + Age *Age `json:"age"` + Revision *int64 `json:"revision"` + BackupChoice string `json:"backup_choice,omitempty"` + } + if !decodeBody(c, &req) { + return + } + if req.Enabled == nil || req.Age == nil || req.Revision == nil || *req.Revision < 0 { + badRequest(c) + return + } + status, err := service.Save(c.Request.Context(), Update{Enabled: *req.Enabled, Age: *req.Age, Revision: *req.Revision, BackupChoice: req.BackupChoice}) + respond(c, http.StatusOK, status, err) + } +} + +func decodeBody(c *gin.Context, target any) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + badRequest(c) + return false + } + if err := decoder.Decode(new(any)); err != io.EOF { + badRequest(c) + return false + } + return true +} + +func badRequest(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": "invalid_request"}) } + +func respond(c *gin.Context, status int, result any, err error) { + if err == nil { + c.JSON(status, result) + return + } + code := err.Error() + switch code { + case "invalid_age", "invalid_backup_choice", "backup_choice_required": + status = http.StatusBadRequest + case "conflict", "busy", "preparation_required": + status = http.StatusConflict + case "unsupported": + status = http.StatusNotImplemented + default: + status = http.StatusInternalServerError + code = "internal_error" + } + c.JSON(status, gin.H{"code": code}) +} diff --git a/apps/backend/internal/system/toolretention/handler_composition_test.go b/apps/backend/internal/system/toolretention/handler_composition_test.go new file mode 100644 index 0000000000..b9c9ac059b --- /dev/null +++ b/apps/backend/internal/system/toolretention/handler_composition_test.go @@ -0,0 +1,121 @@ +package toolretention_test + +import ( + "context" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + systemsvc "github.com/kandev/kandev/internal/system" + "github.com/kandev/kandev/internal/system/jobs" + "github.com/kandev/kandev/internal/system/maintenance" + "github.com/kandev/kandev/internal/system/toolretention" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestSystemCompositionRegistersRetentionAuthorization(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "test.db") + writer, err := db.OpenSQLite(path) + require.NoError(t, err) + pool := db.NewPool(sqlx.NewDb(writer, "sqlite3"), sqlx.NewDb(writer, "sqlite3")) + t.Cleanup(func() { _ = writer.Close() }) + log, err := logger.NewFromZap(zap.NewNop()) + require.NoError(t, err) + service := systemsvc.Provide(&config.Config{HomeDir: root, Database: config.DatabaseConfig{Driver: "sqlite", Path: path}}, log, pool, nil, systemsvc.BuildInfo{}, systemsvc.Wiring{}) + router := gin.New() + router.Use(func(c *gin.Context) { + authn.SetOnGin(c, authn.Identity{Role: authn.RoleMember, UserID: "member"}) + c.Next() + }) + service.RegisterRoutes(router, log) + for _, tc := range []struct { + method, suffix string + status int + }{{"GET", "", 200}, {"PUT", "", 403}, {"POST", "/analyze", 403}, {"POST", "/run", 403}, {"POST", "/cancel", 403}} { + w := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, "/api/v1/system/database/tool-payload-retention"+tc.suffix, strings.NewReader(`{}`)) + router.ServeHTTP(w, req) + require.Equal(t, tc.status, w.Code, w.Body.String()) + } + var n int + require.NoError(t, pool.Reader().Get(&n, `SELECT count(*) FROM settings WHERE key='tool_payload_retention'`)) + require.Zero(t, n) +} + +func TestSystemQuiescenceCancelsBackupAdmission(t *testing.T) { + for _, kind := range []string{"reset", "restore"} { + t.Run(kind, func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "test.db") + writer, err := db.OpenSQLite(path) + require.NoError(t, err) + t.Cleanup(func() { _ = writer.Close() }) + pool := db.NewPool(sqlx.NewDb(writer, "sqlite3"), sqlx.NewDb(writer, "sqlite3")) + log, err := logger.NewFromZap(zap.NewNop()) + require.NoError(t, err) + eventBus := bus.NewMemoryEventBus(log) + t.Cleanup(eventBus.Close) + reports := make(chan *jobs.Job, 4) + _, err = eventBus.Subscribe(events.SystemJobUpdate, func(_ context.Context, e *bus.Event) error { + if job, ok := e.Data.(*jobs.Job); ok && job.Kind == "tool-payload-retention-backup" { + select { + case reports <- job: + default: + } + } + return nil + }) + require.NoError(t, err) + quiesced := false + next := func() error { quiesced = true; return nil } + service := systemsvc.Provide(&config.Config{HomeDir: root, Database: config.DatabaseConfig{Driver: "sqlite", Path: path}}, log, pool, eventBus, systemsvc.BuildInfo{}, systemsvc.Wiring{DatabaseQuiesce: next, RestoreQuiesce: next}) + release, ok := maintenance.ForPool(pool).TryAcquire() + require.True(t, ok) + defer release() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + service.ToolRetention.Start(ctx) + defer service.ToolRetention.Stop() + _, err = service.ToolRetention.Save(ctx, toolretention.Update{Enabled: true, Age: toolretention.Age{Value: 3, Unit: "months"}, BackupChoice: "backup"}) + require.NoError(t, err) + select { + case job := <-reports: + require.Equal(t, jobs.StateRunning, job.State) + require.NotContains(t, job.Result, "receipt") + require.Empty(t, service.Jobs.List()) + case <-ctx.Done(): + t.Fatal("preparation did not publish progress") + } + done := make(chan error, 1) + go func() { + if kind == "reset" { + done <- service.Database.DatabaseQuiesce() + } else { + done <- service.Backups.RestoreQuiesce() + } + }() + select { + case err := <-done: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal("quiescence deadlocked behind maintenance admission") + } + require.True(t, quiesced) + snapshots, err := service.Backups.List() + require.NoError(t, err) + require.Empty(t, snapshots) + }) + } +} diff --git a/apps/backend/internal/system/toolretention/handler_test.go b/apps/backend/internal/system/toolretention/handler_test.go new file mode 100644 index 0000000000..313e68a125 --- /dev/null +++ b/apps/backend/internal/system/toolretention/handler_test.go @@ -0,0 +1,120 @@ +package toolretention + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + "github.com/stretchr/testify/require" +) + +const retentionPath = "/api/v1/system/database/tool-payload-retention" + +type handlerFake struct { + calls []string + err error +} + +func (s *handlerFake) Get(context.Context) (Status, error) { + s.calls = append(s.calls, "get") + return defaultRecord().Status, s.err +} +func (s *handlerFake) Save(context.Context, Update) (Status, error) { + s.calls = append(s.calls, "save") + return defaultRecord().Status, s.err +} +func (s *handlerFake) Analyze(context.Context, Age) (string, error) { + s.calls = append(s.calls, "analyze") + return "operation", s.err +} +func (s *handlerFake) Run(context.Context, int64) (string, error) { + s.calls = append(s.calls, "run") + return "operation", s.err +} +func (s *handlerFake) Cancel(context.Context, string) (Status, error) { + s.calls = append(s.calls, "cancel") + return defaultRecord().Status, s.err +} +func retentionRouter(s HTTPService, role authn.Role) *gin.Engine { + r := gin.New() + r.Use(func(c *gin.Context) { authn.SetOnGin(c, authn.Identity{UserID: "caller", Role: role}); c.Next() }) + read := r.Group("/api/v1/system") + admin := read.Group("", authz.RequireOrgScope(authz.ScopeOrgSettingsManage)) + RegisterRoutes(read, admin, s) + return r +} +func retentionRequest(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest(method, retentionPath+path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + return w +} +func TestHandlerMembersOnlyReadStatus(t *testing.T) { + s := &handlerFake{} + r := retentionRouter(s, authn.RoleMember) + require.Equal(t, 200, retentionRequest(r, "GET", "", "").Code) + for _, tc := range []struct{ method, path string }{{"PUT", ""}, {"POST", "/analyze"}, {"POST", "/run"}, {"POST", "/cancel"}} { + require.Equal(t, 403, retentionRequest(r, tc.method, tc.path, `{}`).Code) + } + require.Equal(t, []string{"get"}, s.calls) +} +func TestHandlerErrorCodesAreSanitized(t *testing.T) { + for _, tc := range []struct { + code string + status int + }{{"invalid_age", 400}, {"invalid_backup_choice", 400}, {"backup_choice_required", 400}, {"conflict", 409}, {"busy", 409}, {"preparation_required", 409}, {"unsupported", 501}, {"database secret /private/path", 500}} { + t.Run(tc.code, func(t *testing.T) { + s := &handlerFake{err: errors.New(tc.code)} + r := retentionRouter(s, authn.RoleAdmin) + got := retentionRequest(r, "POST", "/run", `{"revision":0}`) + require.Equal(t, tc.status, got.Code) + code := tc.code + if tc.status == 500 { + code = "internal_error" + } + require.JSONEq(t, `{"code":"`+code+`"}`, got.Body.String()) + }) + } +} +func TestHandlerStrictBodies(t *testing.T) { + for _, body := range []string{`{"revision":0,"extra":1}`, `{"revision":0} {}`, `null`, `{}`, `{"revision":0}` + strings.Repeat(" ", 1<<20)} { + s := &handlerFake{} + r := retentionRouter(s, authn.RoleAdmin) + got := retentionRequest(r, "POST", "/run", body) + require.Equal(t, http.StatusBadRequest, got.Code) + require.Empty(t, s.calls) + } +} +func TestHandlerMutationResults(t *testing.T) { + for _, tc := range []struct { + method, path, body, call string + status int + }{{"PUT", "", `{"enabled":false,"age":{"value":3,"unit":"months"},"revision":0}`, "save", 200}, {"POST", "/analyze", `{"age":{"value":3,"unit":"months"}}`, "analyze", 202}, {"POST", "/run", `{"revision":0}`, "run", 202}, {"POST", "/cancel", `{"operation_id":"operation"}`, "cancel", 200}} { + s := &handlerFake{} + r := retentionRouter(s, authn.RoleAdmin) + got := retentionRequest(r, tc.method, tc.path, tc.body) + require.Equal(t, tc.status, got.Code, got.Body.String()) + require.Equal(t, []string{tc.call}, s.calls) + if tc.status == 202 { + require.JSONEq(t, `{"operation_id":"operation"}`, got.Body.String()) + } + } +} +func TestHandlerGetDoesNotStartAnalysisOrWritePolicy(t *testing.T) { + s := testService(t) + r := retentionRouter(s, authn.RoleMember) + require.Equal(t, 200, retentionRequest(r, "GET", "", "").Code) + var count int + require.NoError(t, s.pool.Reader().Get(&count, `SELECT count(*) FROM settings WHERE key='tool_payload_retention'`)) + require.Zero(t, count) + state, err := s.Get(context.Background()) + require.NoError(t, err) + require.Nil(t, state.Operation) +} diff --git a/apps/backend/internal/system/toolretention/operations.go b/apps/backend/internal/system/toolretention/operations.go new file mode 100644 index 0000000000..2acddf298a --- /dev/null +++ b/apps/backend/internal/system/toolretention/operations.go @@ -0,0 +1,111 @@ +package toolretention + +import ( + "context" + "errors" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "time" +) + +func (s *Service) Analyze(ctx context.Context, age Age) (string, error) { + if err := age.Validate(); err != nil { + return "", err + } + r, err := s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if operationBusy(r) { + return errors.New("busy") + } + return startOperation(ctx, tx, r, kindAnalysis, age, s.opts.Now()) + }) + if err != nil { + return "", err + } + s.notify() + return r.Operation.ID, nil +} + +func (s *Service) Run(ctx context.Context, revision int64) (string, error) { + r, err := s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if r.Policy.Revision != revision { + return errors.New("conflict") + } + if !approved(r) { + return errors.New("preparation_required") + } + if operationBusy(r) { + return errors.New("busy") + } + return startOperation(ctx, tx, r, kindCleanup, r.Policy.Age, s.opts.Now()) + }) + if err != nil { + return "", err + } + s.notify() + return r.Operation.ID, nil +} + +func (s *Service) Cancel(ctx context.Context, id string) (Status, error) { + observed, err := s.interruptValidated(ctx, func(r *record) error { + return validateCancellation(r, id) + }) + if err != nil { + return Status{}, err + } + r, err := s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if r.Policy.Revision != observed.Policy.Revision { + return errors.New("conflict") + } + if err := validateCancellation(r, id); err != nil { + return err + } + finishOperation(r, stateCancelled, "", s.opts.Now()) + if r.Preparation.State == stateRunning || r.Preparation.State == statePending { + r.Preparation = Preparation{State: stateNone} + r.ApprovedRevision = 0 + r.Policy.Enabled = false + } + next := s.opts.Now().Add(24 * time.Hour) + if r.Policy.Enabled && r.Operation.Kind == kindCleanup { + r.NextDueAt = &next + } + return nil + }) + if err == nil { + s.mu.Lock() + if s.activeCancel != nil && s.activeID == id && s.activeRevision == observed.Policy.Revision { + s.activeCancel() + } + s.mu.Unlock() + s.notify() + } + return r.Status, err +} + +func validateCancellation(r *record, id string) error { + if r.Operation == nil || r.Operation.ID != id || r.Operation.State != stateRunning { + return errors.New("conflict") + } + return nil +} + +func approved(r *record) bool { + return r.Policy.Enabled && r.ApprovedRevision == r.Policy.Revision && r.Preparation.State == stateReady && (r.Preparation.Choice == choiceSkip || r.Receipt != "") +} +func operationBusy(r *record) bool { + return (r.Operation != nil && r.Operation.State == stateRunning) || r.Preparation.State == statePending || r.Preparation.State == stateRunning +} + +func startOperation(ctx context.Context, q sqlx.QueryerContext, r *record, kind string, age Age, now time.Time) error { + var schemaVersion int + if err := scanGet(ctx, q, &schemaVersion, `PRAGMA schema_version`); err != nil { + return err + } + var upper string + if err := sqlx.GetContext(ctx, q, &upper, `SELECT COALESCE(MAX(id),'') FROM tasks`); err != nil { + return err + } + r.Operation = &Operation{ID: uuid.NewString(), Kind: kind, State: stateRunning, Age: age, Cutoff: age.Cutoff(now), StartedAt: now, Skipped: map[string]int64{}} + r.Progress = progress{UpperTask: upper, Revision: r.Policy.Revision, Started: true, SchemaVersion: schemaVersion} + return nil +} diff --git a/apps/backend/internal/system/toolretention/policy.go b/apps/backend/internal/system/toolretention/policy.go new file mode 100644 index 0000000000..93195271f8 --- /dev/null +++ b/apps/backend/internal/system/toolretention/policy.go @@ -0,0 +1,41 @@ +package toolretention + +import ( + "errors" + "time" +) + +type Age struct { + Value int `json:"value"` + Unit string `json:"unit"` +} + +type Policy struct { + Enabled bool `json:"enabled"` + Age Age `json:"age"` + Revision int64 `json:"revision"` +} + +func DefaultPolicy() Policy { return Policy{Age: Age{Value: 3, Unit: "months"}} } +func (a Age) Validate() error { + limit := 0 + switch a.Unit { + case "weeks": + limit = 520 + case "months": + limit = 120 + } + if a.Value < 1 || a.Value > limit { + return errors.New("invalid_age") + } + return nil +} +func (a Age) Cutoff(now time.Time) time.Time { + now = now.UTC() + if a.Unit == "weeks" { + return now.AddDate(0, 0, -7*a.Value) + } + month := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), time.UTC).AddDate(0, -a.Value, 0) + day := min(now.Day(), month.AddDate(0, 1, -1).Day()) + return month.AddDate(0, 0, day-1) +} diff --git a/apps/backend/internal/system/toolretention/policy_test.go b/apps/backend/internal/system/toolretention/policy_test.go new file mode 100644 index 0000000000..2ef3913ab4 --- /dev/null +++ b/apps/backend/internal/system/toolretention/policy_test.go @@ -0,0 +1,40 @@ +package toolretention + +import ( + "testing" + "time" +) + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-001.1, 001.2, 001.3 +func TestPolicyDefaultAndAgeValidation(t *testing.T) { + if got := DefaultPolicy(); got.Enabled || got.Age != (Age{3, "months"}) { + t.Fatalf("default policy = %+v, want disabled for three months", got) + } + for _, a := range []Age{{0, "weeks"}, {521, "weeks"}, {121, "months"}, {1, "days"}, {-1, "months"}} { + if a.Validate() == nil { + t.Errorf("accepted invalid age %+v", a) + } + } + for _, a := range []Age{{1, "weeks"}, {520, "weeks"}, {120, "months"}} { + if err := a.Validate(); err != nil { + t.Errorf("rejected valid age %+v: %v", a, err) + } + } +} + +func TestAgeCutoffClampsMonthsAndUsesUTC(t *testing.T) { + for _, tt := range []struct { + now string + age Age + want string + }{ + {"2024-03-31T12:30:00Z", Age{1, "months"}, "2024-02-29T12:30:00Z"}, + {"2025-03-31T12:30:00Z", Age{1, "months"}, "2025-02-28T12:30:00Z"}, + {"2026-01-14T12:30:00+02:00", Age{1, "weeks"}, "2026-01-07T10:30:00Z"}, + } { + now, _ := time.Parse(time.RFC3339, tt.now) + if got := tt.age.Cutoff(now).Format(time.RFC3339); got != tt.want { + t.Errorf("%s: got %s want %s", tt.now, got, tt.want) + } + } +} diff --git a/apps/backend/internal/system/toolretention/preparation.go b/apps/backend/internal/system/toolretention/preparation.go new file mode 100644 index 0000000000..e1b46b6c67 --- /dev/null +++ b/apps/backend/internal/system/toolretention/preparation.go @@ -0,0 +1,107 @@ +package toolretention + +import ( + "context" + "errors" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +func (s *Service) stepPreparation(ctx context.Context) error { + r, err := s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if r.Preparation.State != statePending { + return nil + } + r.Preparation.State = stateRunning + r.Operation = &Operation{ID: uuid.NewString(), Kind: choiceBackup, State: stateRunning, Age: r.Policy.Age, StartedAt: s.opts.Now(), Cutoff: r.Policy.Age.Cutoff(s.opts.Now()), Skipped: map[string]int64{}} + return nil + }) + if err != nil { + return err + } + if r.Preparation.State != stateRunning { + return nil + } + s.mu.Lock() + if s.activeCancel != nil { + s.activeID = r.Operation.ID + s.activeRevision = r.Policy.Revision + } + s.mu.Unlock() + if s.opts.Report != nil { + s.opts.Report(ctx, r.Operation) + } + var receipt string + if r.Preparation.Choice == choiceBackup { + if s.opts.CreateBackup == nil { + err = errors.New("backup_unavailable") + } else { + receipt, err = s.opts.CreateBackup(ctx) + } + if err == nil && receipt == "" { + err = errors.New("backup_unavailable") + } + } + return s.finishPreparation(ctx, r, receipt, err) +} + +func (s *Service) finishPreparation(ctx context.Context, prepared record, receipt string, backupErr error) error { + code := "" + if backupErr != nil { + code = "backup_failed" + } + _, err := s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if r.Policy.Revision != prepared.Policy.Revision || r.Preparation.State != stateRunning || r.Operation == nil || r.Operation.ID != prepared.Operation.ID { + return nil + } + if backupErr != nil { + r.Preparation.State = stateFailed + r.Preparation.Error = code + finishOperation(r, stateFailed, code, s.opts.Now()) + return nil + } + r.Preparation.State = stateReady + r.Preparation.Error = "" + r.Receipt = receipt + r.ApprovedRevision = r.Policy.Revision + r.Policy.Enabled = true + return startOperation(ctx, tx, r, kindCleanup, r.Policy.Age, s.opts.Now()) + }) + if err != nil { + return err + } + if backupErr != nil { + return errors.New(code) + } + return nil +} + +func (s *Service) recoverPreparation(ctx context.Context, observed record) error { + _, err := s.change(ctx, func(r *record, _ *sqlx.Tx) error { + if r.Policy.Revision != observed.Policy.Revision || r.Preparation.State != stateRunning { + return nil + } + observedID, currentID := "", "" + if observed.Operation != nil { + observedID = observed.Operation.ID + } + if r.Operation != nil { + currentID = r.Operation.ID + } + if currentID == observedID { + s.failPreparation(r) + } + return nil + }) + return err +} + +func (s *Service) failPreparation(r *record) { + r.Policy.Enabled = false + r.ApprovedRevision = 0 + r.Receipt = "" + r.NextDueAt = nil + r.Preparation.State = stateFailed + r.Preparation.Error = "backup_interrupted" + finishOperation(r, stateFailed, "backup_interrupted", s.opts.Now()) +} diff --git a/apps/backend/internal/system/toolretention/preparation_recovery_test.go b/apps/backend/internal/system/toolretention/preparation_recovery_test.go new file mode 100644 index 0000000000..878cdf1d40 --- /dev/null +++ b/apps/backend/internal/system/toolretention/preparation_recovery_test.go @@ -0,0 +1,102 @@ +package toolretention + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestPreparationRecoversAfterFinishPersistenceFailure(t *testing.T) { + for _, failure := range []string{"write", "start operation"} { + t.Run(failure, func(t *testing.T) { + s := testService(t) + ctx := context.Background() + calls := 0 + s.opts.CreateBackup = func(context.Context) (string, error) { + calls++ + if failure == "write" { + _, err := s.pool.Writer().Exec(`CREATE TRIGGER fail_finish BEFORE UPDATE ON settings BEGIN SELECT RAISE(FAIL, 'unavailable'); END`) + require.NoError(t, err) + } + return "verified-receipt", nil + } + _, err := s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: choiceBackup}) + require.NoError(t, err) + require.Error(t, s.tick(ctx)) // No tasks table also makes startOperation fail. + if failure == "write" { + _, err = s.pool.Writer().Exec(`DROP TRIGGER fail_finish`) + require.NoError(t, err) + } + require.NoError(t, s.tick(ctx)) + status, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, stateFailed, status.Preparation.State) + require.Equal(t, stateFailed, status.Operation.State) + require.False(t, status.Policy.Enabled) + require.NoError(t, s.tick(ctx)) + require.Equal(t, 1, calls, "recovery must not silently repeat the backup or approve cleanup") + _, err = s.Save(ctx, Update{Enabled: true, Age: status.Policy.Age, Revision: status.Policy.Revision, BackupChoice: choiceBackup}) + require.NoError(t, err, "preparation must be retryable") + }) + } +} + +func TestFailActiveTerminatesPreparation(t *testing.T) { + for _, state := range []string{stateRunning, stateFailed, stateCancelled} { + t.Run(state, func(t *testing.T) { + s := testService(t) + ctx := context.Background() + _, err := s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: choiceBackup}) + require.NoError(t, err) + _, err = s.change(ctx, func(r *record, _ *sqlx.Tx) error { + r.Preparation.State = stateRunning + r.Operation = &Operation{ID: "backup", Kind: choiceBackup, State: state} + return nil + }) + require.NoError(t, err) + s.failActive(ctx) + status, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, stateFailed, status.Preparation.State) + require.Equal(t, stateFailed, status.Operation.State) + require.False(t, status.Policy.Enabled) + }) + } +} + +func TestPreparationRecoveryPreservesReplacement(t *testing.T) { + for _, replacement := range []string{"revision", "operation", "disabled"} { + t.Run(replacement, func(t *testing.T) { + s := testService(t) + ctx := context.Background() + _, err := s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: choiceBackup}) + require.NoError(t, err) + observed, err := s.change(ctx, func(r *record, _ *sqlx.Tx) error { + r.Preparation.State = stateRunning + r.Operation = &Operation{ID: "old-backup", Kind: choiceBackup, State: stateRunning} + return nil + }) + require.NoError(t, err) + newer, err := s.change(ctx, func(r *record, _ *sqlx.Tx) error { + switch replacement { + case "revision": + r.Policy.Revision++ + case "operation": + r.Operation.ID = "new-backup" + case "disabled": + r.Policy.Revision++ + r.Preparation = Preparation{State: stateNone} + r.Operation.State = stateCancelled + } + return nil + }) + require.NoError(t, err) + require.NoError(t, s.recoverPreparation(ctx, observed)) + actual, err := readRecord(ctx, s.pool.Reader()) + require.NoError(t, err) + require.Equal(t, newer, actual) + }) + } +} diff --git a/apps/backend/internal/system/toolretention/preparation_test.go b/apps/backend/internal/system/toolretention/preparation_test.go new file mode 100644 index 0000000000..a3ad28b703 --- /dev/null +++ b/apps/backend/internal/system/toolretention/preparation_test.go @@ -0,0 +1,75 @@ +package toolretention + +import ( + "context" + "errors" + "github.com/stretchr/testify/require" + "testing" +) + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-002.1, 002.2, 002.6 +func TestPreparationGatesInitialCleanup(t *testing.T) { + for _, choice := range []string{"skip", "backup", "failure"} { + t.Run(choice, func(t *testing.T) { + s := testService(t) + seedAnalysis(t, s) + ctx := context.Background() + called := false + s.opts.CreateBackup = func(context.Context) (string, error) { + called = true + if choice == "failure" { + return "", errors.New("disk full secret path") + } + return "verified-receipt", nil + } + requested := choice + if choice == "failure" { + requested = "backup" + } + _, err := s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: requested}) + require.NoError(t, err) + err = s.stepPreparation(ctx) + status, getErr := s.Get(ctx) + require.NoError(t, getErr) + if choice == "failure" { + require.Error(t, err) + require.False(t, status.Policy.Enabled) + require.Equal(t, "failed", status.Preparation.State) + require.NotContains(t, status.Preparation.Error, "secret") + return + } + require.NoError(t, err) + require.Equal(t, choice == "backup", called) + require.True(t, status.Policy.Enabled) + require.Equal(t, "ready", status.Preparation.State) + require.Equal(t, "cleanup", status.Operation.Kind) + require.Equal(t, "running", status.Operation.State) + }) + } +} + +func TestLateBackupCompletionCannotOverrideDisable(t *testing.T) { + s := testService(t) + seedAnalysis(t, s) + ctx := context.Background() + started := make(chan struct{}) + release := make(chan struct{}) + done := make(chan error, 1) + s.opts.CreateBackup = func(context.Context) (string, error) { close(started); <-release; return "receipt", nil } + status, err := s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: "backup"}) + require.NoError(t, err) + go func() { done <- s.stepPreparation(ctx) }() + select { + case <-started: + case err := <-done: + t.Fatalf("preparation returned without backup: %v", err) + } + _, err = s.Save(ctx, Update{Age: Age{3, "months"}, Revision: status.Policy.Revision}) + require.NoError(t, err) + close(release) + require.NoError(t, <-done) + status, err = s.Get(ctx) + require.NoError(t, err) + require.False(t, status.Policy.Enabled) + require.Equal(t, "none", status.Preparation.State) +} diff --git a/apps/backend/internal/system/toolretention/progress_test.go b/apps/backend/internal/system/toolretention/progress_test.go new file mode 100644 index 0000000000..dfc852e544 --- /dev/null +++ b/apps/backend/internal/system/toolretention/progress_test.go @@ -0,0 +1,84 @@ +package toolretention + +import ( + "context" + "fmt" + "github.com/stretchr/testify/require" + "testing" +) + +func TestCleanupCommitsBoundedProgressAndResumesWithoutDoubleCounting(t *testing.T) { + s := testService(t) + raw := seedAnalysis(t, s) + ctx := context.Background() + for i := 0; i < 249; i++ { + _, err := s.pool.Writer().Exec(`INSERT INTO task_session_messages(id,task_session_id,task_id,created_at,updated_at,type,metadata,requests_input) VALUES(?,'session','task','2020-01-01','2020-01-01','tool_execute',?,0)`, fmt.Sprintf("message-%03d", i), raw) + require.NoError(t, err) + } + rev := approveTestPolicy(t, s) + _, err := s.Run(ctx, rev) + require.NoError(t, err) + require.NoError(t, s.stepScan(ctx)) + first, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, "running", first.Operation.State) + require.Greater(t, first.Operation.RemovedMessages, int64(0)) + require.LessOrEqual(t, first.Operation.Scanned, int64(batchRows)) + reopened := New(s.pool) + for i := 0; i < 20; i++ { + require.NoError(t, reopened.stepScan(ctx)) + got, getErr := reopened.Get(ctx) + require.NoError(t, getErr) + if got.Operation.State != "running" { + break + } + } + final, err := reopened.Get(ctx) + require.NoError(t, err) + require.Equal(t, "succeeded", final.Operation.State) + require.EqualValues(t, 250, final.Operation.RemovedMessages) + require.EqualValues(t, 1, final.Operation.EligibleTasks) + require.NoError(t, reopened.stepScan(ctx)) + again, err := reopened.Get(ctx) + require.NoError(t, err) + require.Equal(t, final.Operation, again.Operation) +} + +func TestCancellationDoesNotCancelReplacementOperation(t *testing.T) { + s := New(nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s.activeCancel = cancel + s.activeRevision = 2 + s.activeID = "replacement" + s.cancelOlder(2) + require.NoError(t, ctx.Err()) + s.cancelOperation("old-operation") + require.NoError(t, ctx.Err()) + s.cancelOlder(3) + require.ErrorIs(t, ctx.Err(), context.Canceled) +} + +func TestCompactionInvalidatesAnUnfinishedCursor(t *testing.T) { + s := testService(t) + raw := seedAnalysis(t, s) + ctx := context.Background() + for i := 0; i < 149; i++ { + _, err := s.pool.Writer().Exec(`INSERT INTO task_session_messages(id,task_session_id,task_id,created_at,updated_at,type,metadata,requests_input) VALUES(?,'session','task','2020-01-01','2020-01-01','tool_execute',?,0)`, fmt.Sprintf("message-%03d", i), raw) + require.NoError(t, err) + } + _, err := s.Analyze(ctx, Age{3, "months"}) + require.NoError(t, err) + require.NoError(t, s.stepScan(ctx)) + before, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, "running", before.Operation.State) + _, err = s.pool.Writer().Exec(`VACUUM`) + require.NoError(t, err) + require.NoError(t, s.stepScan(ctx)) + after, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, "partial", after.Operation.State) + require.Equal(t, "database_changed", after.Operation.Error) + require.Equal(t, before.Operation.EligibleMessages, after.Operation.EligibleMessages) +} diff --git a/apps/backend/internal/system/toolretention/runner.go b/apps/backend/internal/system/toolretention/runner.go new file mode 100644 index 0000000000..bf6f97c2fa --- /dev/null +++ b/apps/backend/internal/system/toolretention/runner.go @@ -0,0 +1,92 @@ +package toolretention + +import ( + "context" + "errors" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/system/maintenance" + "maps" + "time" +) + +var errMaintenanceBusy = errors.New("maintenance_busy") + +func (s *Service) stepCleanup(ctx context.Context, id string) error { + release, ok := maintenance.ForPool(s.pool).TryAcquire() + if !ok { + return errMaintenanceBusy + } + defer release() + before, err := readRecord(ctx, s.pool.Reader()) + if err != nil { + return err + } + if !operationMatches(&before, id) { + return nil + } + if !approved(&before) { + return errors.New("preparation_required") + } + handled, err := s.prepareFirstBatch(ctx, before) + if err != nil || handled { + return err + } + var changed []string + _, err = s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if !operationMatches(r, id) { + return nil + } + if !approved(r) || r.Policy.Revision != r.Progress.Revision { + return errors.New("conflict") + } + var scanErr error + changed, scanErr = s.scanBatch(ctx, tx, r, true) + if scanErr != nil { + return scanErr + } + if r.Operation.State != stateRunning { + next := s.opts.Now().Add(24 * time.Hour) + r.NextDueAt = &next + } + return nil + }) + if err == nil && len(changed) > 0 && s.opts.Changed != nil { + s.opts.Changed(ctx, changed) + } + return err +} + +func (s *Service) prepareFirstBatch(ctx context.Context, r record) (bool, error) { + if r.FirstMutation || r.Preparation.Choice != choiceBackup { + return false, nil + } + // Empty batches must not repeatedly hash a multi-gigabyte backup. + op := *r.Operation + op.Skipped = maps.Clone(op.Skipped) + r.Operation = &op + ids, err := s.scanBatch(ctx, s.pool.Reader(), &r, false) + if err != nil { + return false, err + } + if len(ids) == 0 { + return true, s.storeScanProgress(ctx, r) + } + return false, s.checkReceipt(ctx, r) +} + +func operationMatches(r *record, id string) bool { + return r.Operation != nil && r.Operation.ID == id && r.Operation.State == stateRunning +} + +func (s *Service) checkReceipt(ctx context.Context, r record) error { + if r.FirstMutation || r.Preparation.Choice != choiceBackup { + return nil + } + if s.opts.VerifyBackup == nil { + return errors.New("backup_unavailable") + } + if err := s.opts.VerifyBackup(ctx, r.Receipt); err != nil { + return errors.New("backup_verification_failed") + } + return nil +} diff --git a/apps/backend/internal/system/toolretention/runner_test.go b/apps/backend/internal/system/toolretention/runner_test.go new file mode 100644 index 0000000000..0dc3f43e4d --- /dev/null +++ b/apps/backend/internal/system/toolretention/runner_test.go @@ -0,0 +1,108 @@ +package toolretention + +import ( + "context" + "encoding/json" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "testing" +) + +func approveTestPolicy(t *testing.T, s *Service) int64 { + t.Helper() + ctx := context.Background() + status, err := s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: "skip"}) + require.NoError(t, err) + _, err = s.change(ctx, func(r *record, tx *sqlx.Tx) error { + r.Policy.Enabled = true + r.Preparation.State = "ready" + r.ApprovedRevision = r.Policy.Revision + return nil + }) + require.NoError(t, err) + return status.Policy.Revision +} + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-002.3, 002.5, 003.3 +func TestCleanupPreservesMetadataAndRechecksActivity(t *testing.T) { + for _, active := range []bool{false, true} { + t.Run(map[bool]string{false: "remove", true: "protect"}[active], func(t *testing.T) { + s := testService(t) + before := seedAnalysis(t, s) + rev := approveTestPolicy(t, s) + ctx := context.Background() + _, err := s.Run(ctx, rev) + require.NoError(t, err) + if active { + _, err = s.pool.Writer().Exec(`UPDATE task_sessions SET state='RUNNING'`) + require.NoError(t, err) + } + for i := 0; i < 10; i++ { + require.NoError(t, s.stepScan(ctx)) + status, err := s.Get(ctx) + require.NoError(t, err) + if status.Operation.State != "running" { + break + } + } + status, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, "succeeded", status.Operation.State) + var raw, updated string + require.NoError(t, s.pool.Reader().QueryRow(`SELECT metadata,CAST(updated_at AS TEXT) FROM task_session_messages WHERE id='message'`).Scan(&raw, &updated)) + require.Equal(t, "2020-01-01", updated) + if active { + require.Equal(t, before, raw) + require.Zero(t, status.Operation.RemovedMessages) + return + } + var metadata map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &metadata)) + require.Contains(t, metadata, "payload_retention") + require.Equal(t, "tool1", metadata["tool_call_id"]) + require.Equal(t, "echo result", metadata["title"]) + require.EqualValues(t, 1, status.Operation.RemovedMessages) + require.EqualValues(t, len(before)-len(raw), status.Operation.PayloadBytes) + }) + } +} + +func TestDisableStopsUncommittedCleanupAndRestartKeepsCounts(t *testing.T) { + s := testService(t) + before := seedAnalysis(t, s) + rev := approveTestPolicy(t, s) + ctx := context.Background() + _, err := s.Run(ctx, rev) + require.NoError(t, err) + _, err = s.Save(ctx, Update{Age: Age{3, "months"}, Revision: rev}) + require.NoError(t, err) + reopened := New(s.pool) + require.NoError(t, reopened.stepScan(ctx)) + var raw string + require.NoError(t, s.pool.Reader().Get(&raw, `SELECT metadata FROM task_session_messages WHERE id='message'`)) + require.Equal(t, before, raw) + status, err := reopened.Get(ctx) + require.NoError(t, err) + require.False(t, status.Policy.Enabled) + require.Equal(t, "cancelled", status.Operation.State) +} + +func TestEmptyCleanupDoesNotReverifyTheWholeBackup(t *testing.T) { + s := testService(t) + seedAnalysis(t, s) + ctx := context.Background() + s.opts.CreateBackup = func(context.Context) (string, error) { return "verified-receipt", nil } + verified := 0 + s.opts.VerifyBackup = func(context.Context, string) error { verified++; return nil } + _, err := s.pool.Writer().Exec(`UPDATE task_sessions SET state='RUNNING'`) + require.NoError(t, err) + _, err = s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: "backup"}) + require.NoError(t, err) + require.NoError(t, s.stepPreparation(ctx)) + require.NoError(t, s.stepScan(ctx)) + status, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, "succeeded", status.Operation.State) + require.Zero(t, verified) + require.Zero(t, status.Operation.RemovedMessages) +} diff --git a/apps/backend/internal/system/toolretention/scan.go b/apps/backend/internal/system/toolretention/scan.go new file mode 100644 index 0000000000..14413ae1ef --- /dev/null +++ b/apps/backend/internal/system/toolretention/scan.go @@ -0,0 +1,286 @@ +package toolretention + +import ( + "context" + "database/sql" + "errors" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/task/models" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + "time" +) + +const batchRows = 100 +const batchBytes = 8 * 1024 * 1024 +const statementBudget = 200 * time.Millisecond + +var errDatabaseChanged = errors.New("database_changed") + +func (s *Service) stepScan(ctx context.Context) error { + r, err := readRecord(ctx, s.pool.Reader()) + if err != nil { + return err + } + if r.Operation == nil || r.Operation.State != stateRunning || r.Operation.Kind == choiceBackup { + return nil + } + if r.Operation.Kind == kindCleanup { + err = s.stepCleanup(ctx, r.Operation.ID) + } else { + err = s.stepAnalysis(ctx, r) + } + if errors.Is(err, errDatabaseChanged) { + return s.stopChangedScan(ctx, r.Operation.ID) + } + return err +} + +func (s *Service) stepAnalysis(ctx context.Context, r record) error { + _, err := s.scanBatch(ctx, s.pool.Reader(), &r, false) + if err != nil { + return err + } + return s.storeScanProgress(ctx, r) +} + +func (s *Service) storeScanProgress(ctx context.Context, r record) error { + _, err := s.change(ctx, func(current *record, tx *sqlx.Tx) error { + if !operationMatches(current, r.Operation.ID) { + return nil + } + if r.Operation.Kind == kindCleanup && (!approved(current) || current.Policy.Revision != r.Progress.Revision) { + return errors.New("conflict") + } + if err := checkScanSchema(ctx, tx, current); err != nil { + return err + } + current.Operation = r.Operation + current.Progress = r.Progress + if r.Operation.State != stateRunning { + finishOperation(current, r.Operation.State, r.Operation.Error, s.opts.Now()) + if r.Operation.Kind == kindCleanup { + next := s.opts.Now().Add(24 * time.Hour) + current.NextDueAt = &next + } + } + return nil + }) + return err +} + +func scanGet(ctx context.Context, q sqlx.QueryerContext, dest any, query string, args ...any) error { + stmt, cancel := context.WithTimeout(ctx, statementBudget) + defer cancel() + return sqlx.GetContext(stmt, q, dest, query, args...) +} + +func (s *Service) scanBatch(ctx context.Context, q sqlx.QueryerContext, r *record, mutate bool) ([]string, error) { + if err := checkScanSchema(ctx, q, r); err != nil { + return nil, err + } + var changed []string + used := int64(0) + validatedTask := "" + started := time.Now() + for i := 0; i < batchRows && used < batchBytes; i++ { + if i > 0 && time.Since(started) >= statementBudget { + break + } + ready, done, err := s.advanceCursor(ctx, q, r, &validatedTask) + if err != nil { + return nil, err + } + if done { + state, code := stateSucceeded, "" + if r.Operation.Skipped["eligibility_budget"] > 0 { + state, code = statePartial, "eligibility_budget" + } + finishOperation(r, state, code, s.opts.Now()) + break + } + if !ready { + continue + } + id, full, err := s.consumeRow(ctx, q, r, &used, mutate) + if err != nil { + return nil, err + } + if full { + break + } + if id != "" { + changed = append(changed, id) + } + } + return changed, checkScanSchema(ctx, q, r) +} + +func (s *Service) consumeRow(ctx context.Context, q sqlx.QueryerContext, r *record, used *int64, mutate bool) (string, bool, error) { + var row payloadRow + err := scanGet(ctx, q, &row, `SELECT rowid AS row_id,id,type,COALESCE(length(CAST(metadata AS BLOB)),0) AS bytes, + COALESCE(julianday(created_at) IS NOT NULL AND julianday(updated_at) IS NOT NULL AND task_id=?,0) AS valid + FROM task_session_messages WHERE task_session_id=? AND rowid>? AND rowid<=? ORDER BY rowid LIMIT 1`, r.Progress.Task, r.Progress.Session, r.Progress.Message, r.Progress.UpperMessage) + if errors.Is(err, sql.ErrNoRows) { + finishSession(&r.Progress) + return "", false, nil + } + if err != nil { + return "", false, err + } + if rowNeedsNextBatch(row, *used) { + return "", true, nil + } + r.Progress.Message = row.RowID + r.Operation.Scanned++ + if !row.Valid { + r.Operation.Skipped["invalid_message"]++ + return "", false, nil + } + if row.Bytes > models.ToolPayloadMaxBytes { + r.Operation.Skipped["oversize"]++ + return "", false, nil + } + *used += row.Bytes + id, err := s.reduceRow(ctx, q, r, row, mutate) + return id, false, err +} + +func checkScanSchema(ctx context.Context, q sqlx.QueryerContext, r *record) error { + var version int + if err := scanGet(ctx, q, &version, `PRAGMA schema_version`); err != nil { + return err + } + if version != r.Progress.SchemaVersion { + return errDatabaseChanged + } + return nil +} + +func (s *Service) stopChangedScan(ctx context.Context, id string) error { + _, err := s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if !operationMatches(r, id) { + return nil + } + finishOperation(r, statePartial, errDatabaseChanged.Error(), s.opts.Now()) + if r.Policy.Enabled && r.Operation.Kind == kindCleanup { + next := s.opts.Now().Add(24 * time.Hour) + r.NextDueAt = &next + } + return nil + }) + return err +} + +func rowNeedsNextBatch(row payloadRow, used int64) bool { + return row.Valid && row.Bytes <= models.ToolPayloadMaxBytes && !rowFitsBatch(used, row.Bytes) +} + +type payloadRow struct { + RowID int64 `db:"row_id"` + ID string `db:"id"` + Type string `db:"type"` + Bytes int64 `db:"bytes"` + Valid bool `db:"valid"` +} + +func (s *Service) reduceRow(ctx context.Context, q sqlx.QueryerContext, r *record, row payloadRow, mutate bool) (string, error) { + var raw string + if err := scanGet(ctx, q, &raw, `SELECT COALESCE(metadata,'') FROM task_session_messages WHERE rowid=? AND id=?`, row.RowID, row.ID); err != nil { + return "", err + } + reduced, err := models.ReduceToolPayload(row.Type, []byte(raw), r.Operation.StartedAt) + if err != nil { + return "", err + } + if reduced.Reason != "" { + r.Operation.Skipped[reduced.Reason]++ + return "", nil + } + if mutate { + tx, ok := q.(*sqlx.Tx) + if !ok { + return "", errors.New("writer_required") + } + result, err := tx.ExecContext(ctx, tx.Rebind(`UPDATE task_session_messages SET metadata=? WHERE id=? AND metadata=?`), string(reduced.Metadata), row.ID, raw) + if err != nil { + return "", err + } + n, err := result.RowsAffected() + if err != nil { + return "", err + } + if n != 1 { + return "", errors.New("message_conflict") + } + r.Operation.RemovedMessages++ + r.FirstMutation = true + } + r.Operation.EligibleMessages++ + r.Operation.PayloadBytes += reduced.RemovedBytes + if !r.Progress.TaskCounted { + r.Operation.EligibleTasks++ + r.Progress.TaskCounted = true + } + return row.ID, nil +} + +func (s *Service) advanceCursor(ctx context.Context, q sqlx.QueryerContext, r *record, validatedTask *string) (bool, bool, error) { + p := &r.Progress + if p.Task == "" { + err := scanGet(ctx, q, &p.Task, `SELECT id FROM tasks WHERE id>? AND id<=? ORDER BY id LIMIT 1`, p.TaskAfter, p.UpperTask) + if errors.Is(err, sql.ErrNoRows) { + return false, true, nil + } + if err != nil { + return false, false, err + } + } + if *validatedTask != p.Task { + check, cancel := context.WithTimeout(ctx, statementBudget) + eligible, err := tasksqlite.PayloadTaskEligible(check, q, p.Task, r.Operation.Cutoff) + cancel() + if errors.Is(err, tasksqlite.ErrPayloadEligibilityBudget) { + r.Operation.Skipped["eligibility_budget"]++ + finishTask(p) + return false, false, nil + } + if err != nil { + return false, false, err + } + if !eligible { + r.Operation.Skipped["protected_tasks"]++ + finishTask(p) + return false, false, nil + } + *validatedTask = p.Task + } + var err error + if p.Session == "" { + err = scanGet(ctx, q, &p.Session, `SELECT id FROM task_sessions WHERE task_id=? AND id>? ORDER BY id LIMIT 1`, p.Task, p.SessionAfter) + if errors.Is(err, sql.ErrNoRows) { + finishTask(p) + return false, false, nil + } + if err != nil { + return false, false, err + } + err = scanGet(ctx, q, &p.UpperMessage, `SELECT COALESCE(MAX(rowid),0) FROM task_session_messages WHERE task_session_id=?`, p.Session) + } + return true, false, err +} + +func finishSession(p *progress) { + p.SessionAfter = p.Session + p.Session = "" + p.Message = 0 + p.UpperMessage = 0 +} +func finishTask(p *progress) { + p.TaskAfter = p.Task + p.Task = "" + p.Session = "" + p.SessionAfter = "" + p.Message = 0 + p.UpperMessage = 0 + p.TaskCounted = false +} diff --git a/apps/backend/internal/system/toolretention/scheduler.go b/apps/backend/internal/system/toolretention/scheduler.go new file mode 100644 index 0000000000..f4b3b54ac5 --- /dev/null +++ b/apps/backend/internal/system/toolretention/scheduler.go @@ -0,0 +1,184 @@ +package toolretention + +import ( + "context" + "errors" + "github.com/jmoiron/sqlx" + "time" +) + +func (s *Service) Start(ctx context.Context) { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + if !s.supported() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.cancel != nil { + return + } + worker, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.wg.Add(1) + go s.loop(worker) +} + +func (s *Service) Stop() { + s.lifecycleMu.Lock() + defer s.lifecycleMu.Unlock() + s.mu.Lock() + cancel := s.cancel + s.cancel = nil + s.mu.Unlock() + if cancel != nil { + cancel() + s.wg.Wait() + } +} + +func (s *Service) loop(ctx context.Context) { + defer s.wg.Done() + timer := time.NewTimer(time.Minute) + defer timer.Stop() + first := true + var burst time.Time + failures := 0 + for { + select { + case <-ctx.Done(): + return + case <-s.wake: + case <-timer.C: + } + if ctx.Err() != nil { + return + } + if first { + if err := s.reconcile(ctx); err != nil { + timer.Reset(time.Minute) + continue + } + first = false + } + err := s.tick(ctx) + failures = nextFailureCount(failures, err) + if failures >= 5 { + s.failActive(ctx) + failures = 0 + } + timer.Reset(s.nextDelay(ctx, err, &burst)) + } +} + +func nextFailureCount(previous int, err error) int { + if err == nil { + return 0 + } + if errors.Is(err, errMaintenanceBusy) { + return previous + } + return previous + 1 +} + +func (s *Service) nextDelay(ctx context.Context, workErr error, burst *time.Time) time.Duration { + status, err := s.Get(ctx) + if err != nil || workErr != nil || status.Operation == nil || status.Operation.State != stateRunning { + *burst = time.Time{} + return time.Minute + } + now := s.opts.Now() + if burst.IsZero() { + *burst = now + } + if now.Sub(*burst) >= 30*time.Second { + *burst = time.Time{} + return time.Minute + } + return 100 * time.Millisecond +} + +func (s *Service) tick(ctx context.Context) error { + r, err := readRecord(ctx, s.pool.Reader()) + if err != nil { + return err + } + work, cancel := context.WithCancel(ctx) + s.mu.Lock() + s.activeCancel = cancel + s.activeRevision = r.Policy.Revision + s.activeID = "" + if r.Operation != nil { + s.activeID = r.Operation.ID + } + s.mu.Unlock() + defer func() { cancel(); s.mu.Lock(); s.activeCancel = nil; s.activeID = ""; s.mu.Unlock() }() + switch { + case r.Preparation.State == statePending: + err = s.stepPreparation(work) + case r.Preparation.State == stateRunning: + // Ticks are serialized; a running preparation here has no backup worker. + err = s.recoverPreparation(work, r) + case r.Operation != nil && r.Operation.State == stateRunning: + err = s.stepScan(work) + default: + err = s.stepScheduled(work) + } + if err == nil && s.opts.Report != nil { + status, getErr := s.Get(ctx) + if getErr == nil && status.Operation != nil { + s.opts.Report(ctx, status.Operation) + } + } + return err +} + +func (s *Service) stepScheduled(ctx context.Context) error { + r, err := readRecord(ctx, s.pool.Reader()) + if err != nil { + return err + } + if !approved(&r) || operationBusy(&r) || r.NextDueAt == nil || s.opts.Now().Before(*r.NextDueAt) { + return nil + } + _, err = s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if !approved(r) || operationBusy(r) || r.NextDueAt == nil || s.opts.Now().Before(*r.NextDueAt) { + return nil + } + return startOperation(ctx, tx, r, kindCleanup, r.Policy.Age, s.opts.Now()) + }) + return err +} + +func (s *Service) reconcile(ctx context.Context) error { + r, err := readRecord(ctx, s.pool.Reader()) + if err != nil { + return err + } + if r.Preparation.State != stateRunning { + return nil + } + return s.recoverPreparation(ctx, r) +} + +func (s *Service) failActive(ctx context.Context) { + _, _ = s.change(ctx, func(r *record, tx *sqlx.Tx) error { + if r.Preparation.State == stateRunning { + s.failPreparation(r) + return nil + } + if r.Operation == nil || r.Operation.State != stateRunning { + return nil + } + state := stateFailed + if r.Operation.Scanned > 0 { + state = statePartial + } + finishOperation(r, state, "scan_failed", s.opts.Now()) + if r.Policy.Enabled { + next := s.opts.Now().Add(24 * time.Hour) + r.NextDueAt = &next + } + return nil + }) +} diff --git a/apps/backend/internal/system/toolretention/scheduler_test.go b/apps/backend/internal/system/toolretention/scheduler_test.go new file mode 100644 index 0000000000..8a1953b826 --- /dev/null +++ b/apps/backend/internal/system/toolretention/scheduler_test.go @@ -0,0 +1,115 @@ +package toolretention + +import ( + "context" + "errors" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "testing" + "time" +) + +func TestMain(m *testing.M) { goleak.VerifyTestMain(m) } + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-003.1, 003.3, 003.4 +func TestDailyScheduleAndRestart(t *testing.T) { + s := testService(t) + seedAnalysis(t, s) + approveTestPolicy(t, s) + ctx := context.Background() + now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + s.opts.Now = func() time.Time { return now } + _, err := s.change(ctx, func(r *record, tx *sqlx.Tx) error { next := now.Add(time.Hour); r.NextDueAt = &next; return nil }) + require.NoError(t, err) + require.NoError(t, s.stepScheduled(ctx)) + status, err := s.Get(ctx) + require.NoError(t, err) + require.Nil(t, status.Operation) + now = now.Add(time.Hour) + require.NoError(t, s.stepScheduled(ctx)) + status, err = s.Get(ctx) + require.NoError(t, err) + require.NotNil(t, status.Operation) + reopened := New(s.pool, Options{Now: func() time.Time { return now }}) + require.NoError(t, reopened.reconcile(ctx)) + require.NoError(t, reopened.stepScan(ctx)) + status, err = reopened.Get(ctx) + require.NoError(t, err) + require.Equal(t, "succeeded", status.Operation.State) + require.Equal(t, now.Add(24*time.Hour), *status.NextDueAt) + require.NoError(t, reopened.stepScan(ctx)) + again, err := reopened.Get(ctx) + require.NoError(t, err) + require.Equal(t, status.Operation.RemovedMessages, again.Operation.RemovedMessages) +} + +func TestInterruptedBackupRequiresExplicitRetry(t *testing.T) { + s := testService(t) + seedAnalysis(t, s) + ctx := context.Background() + _, err := s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: "backup"}) + require.NoError(t, err) + _, err = s.change(ctx, func(r *record, tx *sqlx.Tx) error { r.Preparation.State = "running"; return nil }) + require.NoError(t, err) + reopened := New(s.pool) + require.NoError(t, reopened.reconcile(ctx)) + status, err := reopened.Get(ctx) + require.NoError(t, err) + require.False(t, status.Policy.Enabled) + require.Equal(t, "failed", status.Preparation.State) + require.Equal(t, "backup_interrupted", status.Preparation.Error) +} + +func TestRuntimeStartStopDoesNotRunMaintenanceDuringReadiness(t *testing.T) { + s := testService(t) + for i := 0; i < 2; i++ { + s.Start(context.Background()) + s.Start(context.Background()) + s.mu.Lock() + running := s.cancel != nil + s.mu.Unlock() + require.True(t, running) + s.Stop() + s.Stop() + } + status, err := s.Get(context.Background()) + require.NoError(t, err) + require.Nil(t, status.Operation) +} + +func TestWorkBudgetStartsWhenWorkArrivesAfterIdle(t *testing.T) { + s := testService(t) + seedAnalysis(t, s) + ctx := context.Background() + now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + s.opts.Now = func() time.Time { return now } + var burst time.Time + require.Equal(t, time.Minute, s.nextDelay(ctx, nil, &burst)) + require.True(t, burst.IsZero()) + _, err := s.Analyze(ctx, Age{3, "months"}) + require.NoError(t, err) + require.Equal(t, 100*time.Millisecond, s.nextDelay(ctx, nil, &burst)) + require.Equal(t, now, burst) + now = now.Add(30 * time.Second) + require.Equal(t, time.Minute, s.nextDelay(ctx, nil, &burst)) + require.True(t, burst.IsZero()) +} + +func TestMaintenanceAdmissionDoesNotCountAsSchedulerFailure(t *testing.T) { + for _, tc := range []struct { + name string + previous int + err error + want int + }{ + {name: "initial deferral", previous: 0, err: errMaintenanceBusy, want: 0}, + {name: "preserves earlier failures", previous: 4, err: errMaintenanceBusy, want: 4}, + {name: "ordinary failure", previous: 4, err: errors.New("scan failed"), want: 5}, + {name: "success clears failures", previous: 4, err: nil, want: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, nextFailureCount(tc.previous, tc.err)) + }) + } +} diff --git a/apps/backend/internal/system/toolretention/service.go b/apps/backend/internal/system/toolretention/service.go new file mode 100644 index 0000000000..8a747b5f05 --- /dev/null +++ b/apps/backend/internal/system/toolretention/service.go @@ -0,0 +1,217 @@ +package toolretention + +import ( + "context" + "errors" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db" + "sync" + "time" +) + +type Options struct { + CreateBackup func(context.Context) (string, error) + VerifyBackup func(context.Context, string) error + Changed func(context.Context, []string) + Report func(context.Context, *Operation) + Now func() time.Time +} + +type Service struct { + pool *db.Pool + opts Options + wake chan struct{} + mu sync.Mutex + lifecycleMu sync.Mutex + cancel context.CancelFunc + activeCancel context.CancelFunc + activeID string + activeRevision int64 + wg sync.WaitGroup +} + +func New(pool *db.Pool, options ...Options) *Service { + o := Options{Now: time.Now} + if len(options) > 0 { + o = options[0] + if o.Now == nil { + o.Now = time.Now + } + } + return &Service{pool: pool, opts: o, wake: make(chan struct{}, 1)} +} + +func (s *Service) supported() bool { + return s.pool != nil && s.pool.Writer() != nil && s.pool.Writer().DriverName() == "sqlite3" +} + +func (s *Service) Get(ctx context.Context) (Status, error) { + if !s.supported() { + r := defaultRecord() + r.Supported = false + return r.Status, nil + } + r, err := readRecord(ctx, s.pool.Reader()) + return r.Status, err +} + +func (s *Service) Save(ctx context.Context, u Update) (Status, error) { + if err := u.Age.Validate(); err != nil { + return Status{}, err + } + if u.BackupChoice != "" && u.BackupChoice != choiceBackup && u.BackupChoice != choiceSkip { + return Status{}, errors.New("invalid_backup_choice") + } + if _, err := s.interruptValidated(ctx, func(r *record) error { + _, err := validatePolicyUpdate(r, u, s.opts.Now()) + return err + }); err != nil { + return Status{}, err + } + r, err := s.change(ctx, func(r *record, tx *sqlx.Tx) error { + needsReview, err := validatePolicyUpdate(r, u, s.opts.Now()) + if err != nil { + return err + } + if r.Operation != nil && r.Operation.State == stateRunning { + finishOperation(r, stateCancelled, "policy_changed", s.opts.Now()) + } + r.Policy = Policy{Enabled: u.Enabled, Age: u.Age, Revision: u.Revision + 1} + r.Progress = progress{} + switch { + case !u.Enabled: + r.Preparation = Preparation{State: stateNone} + r.ApprovedRevision = 0 + r.Receipt = "" + r.NextDueAt = nil + case needsReview: + r.Policy.Enabled = false + r.Preparation = Preparation{State: statePending, Choice: u.BackupChoice} + r.ApprovedRevision = 0 + r.Receipt = "" + r.FirstMutation = false + r.NextDueAt = nil + default: + r.ApprovedRevision = r.Policy.Revision + if r.NextDueAt == nil { + next := s.opts.Now().Add(24 * time.Hour) + r.NextDueAt = &next + } + } + return nil + }) + if err == nil { + s.cancelOlder(r.Policy.Revision) + s.notify() + } + return r.Status, err +} + +func (s *Service) notify() { + select { + case s.wake <- struct{}{}: + default: + } +} +func (s *Service) cancelOlder(revision int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.activeCancel != nil && s.activeRevision < revision { + s.activeCancel() + } +} + +func (s *Service) cancelOperation(id string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.activeCancel != nil && s.activeID == id { + s.activeCancel() + } +} + +// Validate through the reader before interrupting work that may hold the writer. +// Callers must repeat their durable-state checks inside the write transaction. +func (s *Service) interruptValidated(ctx context.Context, validate func(*record) error) (record, error) { + if !s.supported() { + return record{}, errors.New("unsupported") + } + r, err := readRecord(ctx, s.pool.Reader()) + if err != nil { + return r, err + } + if err := validate(&r); err != nil { + return r, err + } + s.mu.Lock() + defer s.mu.Unlock() + if err := ctx.Err(); err != nil { + return r, err + } + if s.activeCancel == nil || s.activeRevision != r.Policy.Revision { + return r, nil + } + if r.Preparation.State == statePending { + return r, s.interruptPreparationHandoff(ctx, r) + } + if r.Operation == nil || r.Operation.State != stateRunning { + return r, nil + } + if s.activeID == r.Operation.ID { + s.activeCancel() + return r, nil + } + return r, s.interruptPreparationHandoff(ctx, r) +} + +// The backup operation is committed before its ID is registered with the worker. +// Hold mu and recheck durable identity before cancelling across that handoff. +func (s *Service) interruptPreparationHandoff(ctx context.Context, observed record) error { + pending := observed.Preparation.State == statePending + if !pending && (observed.Preparation.State != stateRunning || observed.Operation.Kind != choiceBackup) { + return nil + } + current, err := readRecord(ctx, s.pool.Reader()) + if err != nil { + return err + } + if current.Policy.Revision != observed.Policy.Revision { + return errors.New("conflict") + } + if pending && current.Preparation.State == statePending { + s.activeCancel() + return nil + } + if current.Operation == nil || current.Operation.State != stateRunning || + current.Preparation.State != stateRunning || current.Operation.Kind != choiceBackup || + (!pending && current.Operation.ID != observed.Operation.ID) { + return errors.New("conflict") + } + s.activeCancel() + return nil +} + +func finishOperation(r *record, state, code string, now time.Time) { + if r.Operation == nil { + return + } + r.Operation.State = state + r.Operation.Error = code + r.Operation.FinishedAt = &now + if r.Operation.Kind == kindAnalysis { + r.LastAnalysis = r.Operation + } + if r.Operation.Kind == kindCleanup { + r.LastRun = r.Operation + } +} + +func validatePolicyUpdate(r *record, u Update, now time.Time) (bool, error) { + if r.Policy.Revision != u.Revision { + return false, errors.New("conflict") + } + review := u.Enabled && (!r.Policy.Enabled || u.Age.Cutoff(now).After(r.Policy.Age.Cutoff(now))) + if review && u.BackupChoice == "" { + return false, errors.New("backup_choice_required") + } + return review, nil +} diff --git a/apps/backend/internal/system/toolretention/service_test.go b/apps/backend/internal/system/toolretention/service_test.go new file mode 100644 index 0000000000..157fe4416d --- /dev/null +++ b/apps/backend/internal/system/toolretention/service_test.go @@ -0,0 +1,78 @@ +package toolretention + +import ( + "context" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/system/settings" + "github.com/stretchr/testify/require" + "path/filepath" + "testing" +) + +func testService(t *testing.T) *Service { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + w, err := db.OpenSQLite(path) + require.NoError(t, err) + r, err := db.OpenSQLiteReader(path) + if err != nil { + _ = w.Close() + t.Fatal(err) + } + pool := db.NewPool(sqlx.NewDb(w, "sqlite3"), sqlx.NewDb(r, "sqlite3")) + t.Cleanup(func() { require.NoError(t, pool.Close()) }) + _, err = settings.NewStore(pool) + require.NoError(t, err) + return New(pool) +} + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-001.1, 001.7, 002.1 +func TestPolicyPersistenceAndExplicitPreparation(t *testing.T) { + s := testService(t) + ctx := context.Background() + got, err := s.Get(ctx) + require.NoError(t, err) + require.Equal(t, DefaultPolicy(), got.Policy) + _, err = s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}}) + require.ErrorContains(t, err, "backup_choice_required") + got, err = s.Save(ctx, Update{Enabled: true, Age: Age{3, "months"}, BackupChoice: "backup"}) + require.NoError(t, err) + require.False(t, got.Policy.Enabled) + require.Equal(t, "pending", got.Preparation.State) + require.EqualValues(t, 1, got.Policy.Revision) + _, err = s.Save(ctx, Update{Age: Age{4, "months"}}) + require.ErrorContains(t, err, "conflict") + reopened := New(s.pool) + restored, err := reopened.Get(ctx) + require.NoError(t, err) + require.Equal(t, got, restored) + got, err = s.Save(ctx, Update{Age: Age{3, "months"}, Revision: 1}) + require.NoError(t, err) + require.False(t, got.Policy.Enabled) + require.Equal(t, "none", got.Preparation.State) +} + +func TestUnreadablePolicyCannotAuthorizeCleanup(t *testing.T) { + s := testService(t) + _, err := s.pool.Writer().Exec(`INSERT INTO settings(key,value,updated_at) VALUES('tool_payload_retention','garbage',CURRENT_TIMESTAMP)`) + require.NoError(t, err) + _, err = s.Get(context.Background()) + require.Error(t, err) + _, err = s.Save(context.Background(), Update{Enabled: true, Age: Age{1, "weeks"}, BackupChoice: "skip"}) + require.Error(t, err) +} + +func TestUnreadableRuntimeStateFailsClosed(t *testing.T) { + for _, value := range []string{ + `{"version":1,"policy":{"enabled":false,"age":{"value":3,"unit":"months"}},"preparation":{"state":"pending","choice":"unknown"}}`, + `{"version":1,"policy":{"enabled":true,"revision":2,"age":{"value":3,"unit":"months"}},"preparation":{"state":"ready","choice":"skip"},"approved_revision":1}`, + `{"version":1,"policy":{"age":{"value":3,"unit":"months"}},"preparation":{"state":"none"},"operation":{"id":"x","kind":"unknown","state":"running"}}`, + } { + s := testService(t) + _, err := s.pool.Writer().Exec(`INSERT INTO settings(key,value,updated_at) VALUES('tool_payload_retention',?,CURRENT_TIMESTAMP)`, value) + require.NoError(t, err) + _, err = s.Get(context.Background()) + require.Error(t, err) + } +} diff --git a/apps/backend/internal/system/toolretention/states.go b/apps/backend/internal/system/toolretention/states.go new file mode 100644 index 0000000000..22a37fb810 --- /dev/null +++ b/apps/backend/internal/system/toolretention/states.go @@ -0,0 +1,16 @@ +package toolretention + +const ( + stateNone = "none" + statePending = "pending" + stateRunning = "running" + stateReady = "ready" + stateFailed = "failed" + statePartial = "partial" + stateSucceeded = "succeeded" + stateCancelled = "cancelled" + kindAnalysis = "analysis" + kindCleanup = "cleanup" + choiceBackup = "backup" + choiceSkip = "skip" +) diff --git a/apps/backend/internal/system/toolretention/store.go b/apps/backend/internal/system/toolretention/store.go new file mode 100644 index 0000000000..5e563c1b5b --- /dev/null +++ b/apps/backend/internal/system/toolretention/store.go @@ -0,0 +1,104 @@ +package toolretention + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "github.com/jmoiron/sqlx" +) + +const settingsKey = "tool_payload_retention" + +func readRecord(ctx context.Context, q sqlx.QueryerContext) (record, error) { + var raw string + err := sqlx.GetContext(ctx, q, &raw, `SELECT value FROM settings WHERE key=?`, settingsKey) + if errors.Is(err, sql.ErrNoRows) { + return defaultRecord(), nil + } + if err != nil { + return record{}, err + } + var r record + if json.Unmarshal([]byte(raw), &r) != nil || r.Version != 1 || r.Policy.Age.Validate() != nil || r.Policy.Revision < 0 { + return record{}, errors.New("invalid_stored_policy") + } + switch r.Preparation.State { + case stateNone, statePending, stateRunning, stateFailed, stateReady: + default: + return record{}, errors.New("invalid_preparation") + } + if err := validateRecord(r); err != nil { + return record{}, err + } + r.Supported = true + if r.Operation != nil && r.Operation.Skipped == nil { + r.Operation.Skipped = map[string]int64{} + } + return r, nil +} + +func validateRecord(r record) error { + if r.Preparation.State != stateNone && r.Preparation.Choice != choiceBackup && r.Preparation.Choice != choiceSkip { + return errors.New("invalid_preparation") + } + if r.Policy.Enabled && !approved(&r) { + return errors.New("invalid_preparation") + } + if r.Operation == nil { + return nil + } + if r.Operation.ID == "" { + return errors.New("invalid_operation") + } + switch r.Operation.Kind { + case kindAnalysis, kindCleanup, choiceBackup: + default: + return errors.New("invalid_operation") + } + switch r.Operation.State { + case stateRunning, stateSucceeded, stateFailed, statePartial, stateCancelled: + default: + return errors.New("invalid_operation") + } + if r.Operation.State == stateRunning && r.Operation.Kind != choiceBackup && !r.Progress.Started { + return errors.New("invalid_operation") + } + return nil +} + +func writeRecord(ctx context.Context, tx *sqlx.Tx, r record) error { + b, err := json.Marshal(r) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, tx.Rebind(`INSERT INTO settings(key,value,updated_at) VALUES(?,?,CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`), settingsKey, string(b)) + return err +} + +func (s *Service) change(ctx context.Context, fn func(*record, *sqlx.Tx) error) (record, error) { + if !s.supported() { + return record{}, errors.New("unsupported") + } + tx, err := s.pool.Writer().BeginTxx(ctx, nil) + if err != nil { + return record{}, err + } + defer tx.Rollback() //nolint:errcheck // A committed transaction is already closed. + // Acquire the writer lock before reading either policy or task admission. + if _, err = tx.ExecContext(ctx, tx.Rebind(`UPDATE settings SET key=key WHERE key=?`), settingsKey); err != nil { + return record{}, err + } + r, err := readRecord(ctx, tx) + if err != nil { + return r, err + } + if err = fn(&r, tx); err != nil { + return r, err + } + if err = writeRecord(ctx, tx, r); err != nil { + return r, err + } + return r, tx.Commit() +} diff --git a/apps/backend/internal/system/toolretention/types.go b/apps/backend/internal/system/toolretention/types.go new file mode 100644 index 0000000000..b0c4b7eca2 --- /dev/null +++ b/apps/backend/internal/system/toolretention/types.go @@ -0,0 +1,70 @@ +package toolretention + +import "time" + +type Preparation struct { + State string `json:"state"` + Choice string `json:"choice"` + Error string `json:"error,omitempty"` +} + +type Operation struct { + ID string `json:"id"` + Kind string `json:"kind"` + State string `json:"state"` + Age Age `json:"age"` + Scanned int64 `json:"scanned"` + EligibleTasks int64 `json:"eligible_tasks"` + EligibleMessages int64 `json:"eligible_messages"` + RemovedMessages int64 `json:"removed_messages"` + PayloadBytes int64 `json:"payload_bytes"` + Skipped map[string]int64 `json:"skipped"` + Cutoff time.Time `json:"cutoff"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Error string `json:"error,omitempty"` +} + +type Status struct { + Policy Policy `json:"policy"` + Supported bool `json:"supported"` + Preparation Preparation `json:"preparation"` + Operation *Operation `json:"operation,omitempty"` + LastAnalysis *Operation `json:"last_analysis,omitempty"` + LastRun *Operation `json:"last_run,omitempty"` + NextDueAt *time.Time `json:"next_due_at,omitempty"` +} + +type Update struct { + Enabled bool `json:"enabled"` + Age Age `json:"age"` + Revision int64 `json:"revision"` + BackupChoice string `json:"backup_choice,omitempty"` +} + +type progress struct { + SchemaVersion int `json:"schema_version"` + Task string `json:"task"` + TaskAfter string `json:"task_after"` + Session string `json:"session"` + SessionAfter string `json:"session_after"` + Message int64 `json:"message"` + UpperTask string `json:"upper_task"` + UpperMessage int64 `json:"upper_message"` + TaskCounted bool `json:"task_counted"` + Revision int64 `json:"revision"` + Started bool `json:"started"` +} + +type record struct { + Status + Version int `json:"version"` + ApprovedRevision int64 `json:"approved_revision"` + Receipt string `json:"receipt,omitempty"` + FirstMutation bool `json:"first_mutation"` + Progress progress `json:"progress"` +} + +func defaultRecord() record { + return record{Status: Status{Policy: DefaultPolicy(), Supported: true, Preparation: Preparation{State: stateNone}}, Version: 1} +} diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 56fbb94748..ab9534142a 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -295,6 +295,15 @@ func (h *MessageHandlers) httpGetShellOutput(c *gin.Context) { handleNotFound(c, h.logger, err, "message not found") return } + if message.TaskSessionID != c.Param("id") { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + if message.Type == models.MessageTypeToolExecute && models.ToolPayloadRemoved(message.Metadata) { + marker, _ := message.Metadata["payload_retention"].(map[string]any) + c.JSON(http.StatusGone, gin.H{"code": "tool_payload_removed", "message_id": message.ID, "removed_at": marker["removed_at"], "summary": message.Content}) + return + } output, ok := models.ExtractShellExecOutput(message.Metadata) if !ok || message.TaskSessionID != c.Param("id") { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) diff --git a/apps/backend/internal/task/handlers/message_payload_retention_test.go b/apps/backend/internal/task/handlers/message_payload_retention_test.go new file mode 100644 index 0000000000..11698c801a --- /dev/null +++ b/apps/backend/internal/task/handlers/message_payload_retention_test.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "github.com/kandev/kandev/internal/task/models" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "testing" +) + +func TestShellOutputRemovedReturnsGone(t *testing.T) { + repo := &shellOutputMessageRepo{messages: map[string]*models.Message{"removed": {ID: "removed", TaskSessionID: "session-1", Type: models.MessageTypeToolExecute, Metadata: map[string]any{"payload_retention": map[string]any{"version": 1, "removed_at": "2026-09-14T00:00:00Z"}}}}} + router := shellOutputTestRouter(t, repo) + for _, tc := range []struct { + session string + status int + }{{"session-1", http.StatusGone}, {"foreign", http.StatusNotFound}} { + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/task-sessions/"+tc.session+"/messages/removed/shell-output", nil)) + require.Equal(t, tc.status, response.Code) + if tc.status == http.StatusGone { + require.Contains(t, response.Body.String(), `"code":"tool_payload_removed"`) + require.Contains(t, response.Body.String(), `"removed_at":"2026-09-14T00:00:00Z"`) + } + } +} diff --git a/apps/backend/internal/task/models/message_shell_output.go b/apps/backend/internal/task/models/message_shell_output.go index 4a62162023..150e2abdaf 100644 --- a/apps/backend/internal/task/models/message_shell_output.go +++ b/apps/backend/internal/task/models/message_shell_output.go @@ -1,6 +1,9 @@ package models import ( + "encoding/json" + "strconv" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/entityrefs" ) @@ -169,6 +172,9 @@ func intFromAny(raw any) (int, bool) { return int(value), true case float64: return int(value), value == float64(int(value)) + case json.Number: + parsed, err := strconv.ParseInt(string(value), 10, strconv.IntSize) + return int(parsed), err == nil default: return 0, false } diff --git a/apps/backend/internal/task/models/message_shell_output_test.go b/apps/backend/internal/task/models/message_shell_output_test.go index 1810e3995c..d56d623dbd 100644 --- a/apps/backend/internal/task/models/message_shell_output_test.go +++ b/apps/backend/internal/task/models/message_shell_output_test.go @@ -123,3 +123,22 @@ func jsonMetadata(t *testing.T, metadata map[string]any) map[string]any { require.NoError(t, json.Unmarshal(raw, &decoded)) return decoded } + +func TestShellExitCodeJSONNumber(t *testing.T) { + for _, tc := range []struct { + raw string + want int + valid bool + }{ + {"0", 0, true}, {"1", 1, true}, {"-1", -1, true}, + {"1.5", 0, false}, {"9223372036854775808", 0, false}, {"not-a-number", 0, false}, + } { + t.Run(tc.raw, func(t *testing.T) { + value, ok := intFromAny(json.Number(tc.raw)) + require.Equal(t, tc.valid, ok) + if ok { + require.Equal(t, tc.want, value) + } + }) + } +} diff --git a/apps/backend/internal/task/models/tool_payload_retention.go b/apps/backend/internal/task/models/tool_payload_retention.go new file mode 100644 index 0000000000..a72854a90a --- /dev/null +++ b/apps/backend/internal/task/models/tool_payload_retention.go @@ -0,0 +1,269 @@ +package models + +import ( + "encoding/json" + "strconv" + "strings" + "time" +) + +const ToolPayloadMaxBytes = 32 * 1024 * 1024 +const payloadRetentionKey = "payload_retention" +const payloadMalformed = "malformed" + +type PayloadReduction struct { + Metadata []byte + RemovedBytes int64 + Reason string +} +type rawPayloadObject = map[string]json.RawMessage + +// ToolPayloadRemoved reports a persisted removal tombstone, including future versions. +func ToolPayloadRemoved(metadata map[string]any) bool { + _, removed := metadata[payloadRetentionKey] + return removed +} + +func ReduceToolPayload(messageType string, metadata []byte, removedAt time.Time) (PayloadReduction, error) { + result := PayloadReduction{Metadata: metadata} + if len(metadata) > ToolPayloadMaxBytes { + result.Reason = "oversize" + return result, nil + } + root, ok := payloadObject(metadata) + if !ok { + result.Reason = payloadMalformed + return result, nil + } + if _, exists := root[payloadRetentionKey]; exists { + result.Reason = "already_removed" + return result, nil + } + norm, ok := payloadObject(root["normalized"]) + if !ok { + result.Reason = payloadMalformed + return result, nil + } + var kind string + _ = json.Unmarshal(norm["kind"], &kind) + if !supportedPayload(kind, messageType, norm) { + result.Reason = "unsupported" + return result, nil + } + body, ok := payloadObject(norm[kind]) + if !ok { + result.Reason = payloadMalformed + return result, nil + } + if kind == "generic" && !reducibleGeneric(body, root["result"]) { + result.Reason = "unsupported" + return result, nil + } + paths, ok, err := removePayloadFields(kind, body) + if err != nil { + return result, err + } + if !ok { + result.Reason = payloadMalformed + return result, nil + } + if _, exists := root["result"]; exists { + delete(root, "result") + paths = append(paths, "result") + } + if len(paths) == 0 { + result.Reason = "no_payload" + return result, nil + } + if err := updateReducedPayload(root, norm, body, kind); err != nil { + return result, err + } + return encodePayloadRemoval(root, paths, metadata, removedAt) +} + +func supportedPayload(kind, messageType string, norm rawPayloadObject) bool { + expected := map[string]string{"shell_exec": "tool_execute", "read_file": "tool_read", "modify_file": "tool_edit", "code_search": "tool_search", "generic": "tool_call", "http_request": "tool_call"} + return expected[kind] != "" && expected[kind] == messageType && !protectedPayload(norm) +} + +func updateReducedPayload(root, norm, body rawPayloadObject, kind string) error { + encodedBody, err := json.Marshal(body) + if err != nil { + return err + } + norm[kind] = encodedBody + encodedNorm, err := json.Marshal(norm) + if err != nil { + return err + } + root["normalized"] = encodedNorm + return nil +} + +func encodePayloadRemoval(root rawPayloadObject, paths []string, metadata []byte, removedAt time.Time) (PayloadReduction, error) { + marker := map[string]any{"version": 1, "removed_at": removedAt.UTC().Format(time.RFC3339Nano), "removed_fields": paths} + // Fixed-width numeric notation keeps the receipt size independent of the + // positive byte count's decimal width, including power-of-ten boundaries. + marker["removed_bytes"] = json.Number(strconv.FormatFloat(0, 'e', 8, 64)) + markerJSON, err := json.Marshal(marker) + if err != nil { + return PayloadReduction{Metadata: metadata}, err + } + root[payloadRetentionKey] = markerJSON + encoded, err := json.Marshal(root) + if err != nil { + return PayloadReduction{Metadata: metadata}, err + } + delta := int64(len(metadata) - len(encoded)) + if delta <= 0 { + return PayloadReduction{Metadata: metadata, Reason: "no_payload"}, nil + } + marker["removed_bytes"] = json.Number(strconv.FormatFloat(float64(delta), 'e', 8, 64)) + markerJSON, err = json.Marshal(marker) + if err != nil { + return PayloadReduction{Metadata: metadata}, err + } + root[payloadRetentionKey] = markerJSON + encoded, err = json.Marshal(root) + if err != nil { + return PayloadReduction{Metadata: metadata}, err + } + delta = int64(len(metadata) - len(encoded)) + if delta <= 0 { + return PayloadReduction{Metadata: metadata, Reason: "no_payload"}, nil + } + return PayloadReduction{Metadata: encoded, RemovedBytes: delta}, nil +} + +func payloadObject(raw []byte) (rawPayloadObject, bool) { + var obj rawPayloadObject + err := json.Unmarshal(raw, &obj) + return obj, err == nil && obj != nil +} +func protectedPayload(norm rawPayloadObject) bool { + for _, key := range []string{"background_work", "monitor", "create_task", "subagent_task", "show_plan", "manage_todos"} { + if _, ok := norm[key]; ok { + return true + } + } + return false +} +func reducibleGeneric(body rawPayloadObject, result json.RawMessage) bool { + var name string + if json.Unmarshal(body["name"], &name) != nil || name == "" { + return false + } + // MCP control tools and provider plan/task tools have durable result contracts. + lower := strings.ToLower(name) + for _, word := range []string{"kandev", "plan", "todo", "task", "agent", "attachment", "resource", "canvas", "question", "permission"} { + if strings.Contains(lower, word) { + return false + } + } + return plainPayloadResult(body["output"]) && plainPayloadResult(result) +} +func plainPayloadResult(raw json.RawMessage) bool { + if len(raw) == 0 || string(raw) == "null" { + return true + } + var value string + if json.Unmarshal(raw, &value) != nil { + return false + } + // Providers can wrap structured MCP envelopes in result text. + var structured any + if json.Unmarshal([]byte(value), &structured) == nil { + switch structured.(type) { + case map[string]any, []any: + return false + } + } + return true +} +func removePayloadFields(kind string, body rawPayloadObject) ([]string, bool, error) { + prefix := "normalized." + kind + "." + switch kind { + case "generic": + paths, ok := removePayloadKeys(body, prefix, "input", "output") + return paths, ok, nil + case "http_request": + paths, ok := removePayloadKeys(body, prefix, "response") + return paths, ok, nil + case "modify_file": + return removeMutationPayloads(body, prefix) + default: + return removeOutputPayloads(kind, body, prefix) + } +} + +func removePayloadKeys(obj rawPayloadObject, prefix string, keys ...string) ([]string, bool) { + var paths []string + for _, key := range keys { + raw, exists := obj[key] + if !exists { + continue + } + if key != "input" { + var target any = new(string) + if key == "files" { + target = new([]string) + } + if json.Unmarshal(raw, target) != nil { + return nil, false + } + } + delete(obj, key) + paths = append(paths, prefix+key) + } + return paths, true +} + +func removeMutationPayloads(body rawPayloadObject, prefix string) ([]string, bool, error) { + raw, exists := body["mutations"] + if !exists { + return nil, true, nil + } + var mutations []rawPayloadObject + if json.Unmarshal(raw, &mutations) != nil { + return nil, false, nil + } + var paths []string + for _, mutation := range mutations { + if mutation == nil { + return nil, false, nil + } + removed, ok := removePayloadKeys(mutation, prefix+"mutations.*.", "content", "old_content", "new_content", "diff") + if !ok { + return nil, false, nil + } + paths = append(paths, removed...) + } + encoded, err := json.Marshal(mutations) + if err != nil { + return nil, false, err + } + body["mutations"] = encoded + return paths, true, nil +} + +func removeOutputPayloads(kind string, body rawPayloadObject, prefix string) ([]string, bool, error) { + raw, exists := body["output"] + if !exists || string(raw) == "null" { + return nil, true, nil + } + output, ok := payloadObject(raw) + if !ok { + return nil, false, nil + } + fields := map[string][]string{"shell_exec": {"stdout", "stderr"}, "read_file": {"content"}, "code_search": {"files"}} + paths, ok := removePayloadKeys(output, prefix+"output.", fields[kind]...) + if !ok { + return nil, false, nil + } + encoded, err := json.Marshal(output) + if err != nil { + return nil, false, err + } + body["output"] = encoded + return paths, true, nil +} diff --git a/apps/backend/internal/task/models/tool_payload_retention_test.go b/apps/backend/internal/task/models/tool_payload_retention_test.go new file mode 100644 index 0000000000..3c944efe2b --- /dev/null +++ b/apps/backend/internal/task/models/tool_payload_retention_test.go @@ -0,0 +1,85 @@ +package models + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// @covers AC-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-002.3 +func TestReduceToolPayloadFieldMatrix(t *testing.T) { + for _, tc := range []struct{ kind, typ, body string }{ + {"shell_exec", "tool_execute", `"command":"ls","output":{"stdout":"PAYLOAD","stderr":"PAYLOAD","exit_code":0}`}, + {"read_file", "tool_read", `"file_path":"a","output":{"content":"PAYLOAD","line_count":1}`}, + {"modify_file", "tool_edit", `"file_path":"a","mutations":[{"type":"edit","content":"PAYLOAD","old_content":"PAYLOAD","new_content":"PAYLOAD","diff":"PAYLOAD"}]`}, + {"code_search", "tool_search", `"query":"a","output":{"files":["PAYLOAD"],"file_count":1}`}, + {"http_request", "tool_call", `"url":"https://example.com","response":"PAYLOAD"`}, + {"generic", "tool_call", `"name":"ordinary_tool","input":{"arg":"PAYLOAD"},"output":"PAYLOAD"`}, + } { + t.Run(tc.kind, func(t *testing.T) { + raw := []byte(`{"tool_call_id":"id","title":"title","future":9007199254740993,"normalized":{"kind":"` + tc.kind + `","` + tc.kind + `":{` + strings.ReplaceAll(tc.body, "PAYLOAD", strings.Repeat("secret", 200)) + `,"future":9007199254740993}},"result":"` + strings.Repeat("secret", 200) + `"}`) + got, err := ReduceToolPayload(tc.typ, raw, time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Empty(t, got.Reason) + require.NotContains(t, string(got.Metadata), "secret") + require.Contains(t, string(got.Metadata), "9007199254740993") + require.Equal(t, int64(len(raw)-len(got.Metadata)), got.RemovedBytes) + var m map[string]any + require.NoError(t, json.Unmarshal(got.Metadata, &m)) + require.True(t, ToolPayloadRemoved(m)) + again, err := ReduceToolPayload(tc.typ, got.Metadata, time.Now()) + require.NoError(t, err) + require.Equal(t, "already_removed", again.Reason) + require.Equal(t, got.Metadata, again.Metadata) + }) + } +} + +func TestReduceToolPayloadSkipsUnsafeRecords(t *testing.T) { + for _, tc := range []struct{ name, typ, raw, reason string }{ + {"invalid", "tool_call", "{", "malformed"}, + {"permission", "permission_request", `{"normalized":{"kind":"generic","generic":{"name":"ordinary","input":"large"}}}`, "unsupported"}, + {"mismatched kind", "tool_read", `{"normalized":{"kind":"shell_exec","shell_exec":{}}}`, "unsupported"}, + {"background", "tool_execute", `{"normalized":{"kind":"shell_exec","shell_exec":{},"background_work":{}}}`, "unsupported"}, + {"control", "tool_call", `{"normalized":{"kind":"generic","generic":{"name":"create_task_kandev","input":"large"}}}`, "unsupported"}, + {"structured envelope", "tool_call", `{"normalized":{"kind":"generic","generic":{"name":"ordinary","output":{"content":[{"type":"resource","resource":{}}]}}}}`, "unsupported"}, + {"string envelope", "tool_call", `{"normalized":{"kind":"generic","generic":{"name":"ordinary","output":"{\"task\":{}}"}}}`, "unsupported"}, + {"unexpected output envelope", "tool_execute", `{"normalized":{"kind":"shell_exec","shell_exec":{"output":{"stdout":{"resource":"must survive"}}}}}`, "malformed"}, + {"invalid output", "tool_execute", `{"normalized":{"kind":"shell_exec","shell_exec":{"output":[]}}}`, "malformed"}, + {"no net savings", "tool_execute", `{"normalized":{"kind":"shell_exec","shell_exec":{"output":{"stdout":"x"}}}}`, "no_payload"}, + {"empty", "tool_execute", `{"normalized":{"kind":"shell_exec","shell_exec":{}}}`, "no_payload"}, + {"oversize", "tool_execute", strings.Repeat("x", ToolPayloadMaxBytes+1), "oversize"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ReduceToolPayload(tc.typ, []byte(tc.raw), time.Now()) + require.NoError(t, err) + require.Equal(t, tc.reason, got.Reason) + require.Equal(t, []byte(tc.raw), got.Metadata) + require.Zero(t, got.RemovedBytes) + }) + } +} + +func TestRemovalMarkerReportsExactNetBytesAtDecimalBoundaries(t *testing.T) { + for size := 1100; size < 1500; size++ { + raw := []byte(`{"normalized":{"kind":"shell_exec","shell_exec":{"output":{"stdout":"` + strings.Repeat("x", size) + `"}}}}`) + got, err := ReduceToolPayload("tool_execute", raw, time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Empty(t, got.Reason) + var decoded struct { + Marker struct { + Bytes float64 `json:"removed_bytes"` + } `json:"payload_retention"` + } + require.NoError(t, json.Unmarshal(got.Metadata, &decoded)) + require.Equal(t, float64(got.RemovedBytes), decoded.Marker.Bytes, "input size %d", size) + } +} + +func TestToolPayloadRemovedFailsClosedForUnknownMarker(t *testing.T) { + require.False(t, ToolPayloadRemoved(nil)) + require.True(t, ToolPayloadRemoved(map[string]any{"payload_retention": nil})) +} diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index ae24fb0177..3a7ae5bd3b 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -132,7 +132,7 @@ func (r *Repository) insertMessageWithSessionLock( messageType, metadataJSON string, ) error { if !dialect.IsPostgres(r.db.DriverName()) { - return r.insertMessageRow(ctx, r.db, message, requestsInput, messageType, metadataJSON) + return r.insertMessageWithPayloadGuard(ctx, message, requestsInput, messageType, metadataJSON) } tx, err := r.db.BeginTxx(ctx, nil) if err != nil { @@ -961,6 +961,9 @@ func (r *Repository) UpdateMessage(ctx context.Context, message *models.Message) } message.UpdatedAt = time.Now().UTC() + if !dialect.IsPostgres(r.db.DriverName()) { + return r.updateMessageWithPayloadGuard(ctx, message, metadataJSON, requestsInput) + } result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_session_messages SET content = ?, requests_input = ?, type = ?, metadata = ?, updated_at = ? WHERE id = ? diff --git a/apps/backend/internal/task/repository/sqlite/message_payload_replay.go b/apps/backend/internal/task/repository/sqlite/message_payload_replay.go new file mode 100644 index 0000000000..a7acad9fbb --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_payload_replay.go @@ -0,0 +1,123 @@ +package sqlite + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// updateMessageWithPayloadGuard takes the writer before reading the tombstone. +// A stale replacement may update display status, but cannot change the retained +// tool identity, normalized summary, or removal receipt. +func (r *Repository) updateMessageWithPayloadGuard(ctx context.Context, message *models.Message, metadataJSON []byte, requestsInput int) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, tx.Rebind(`UPDATE task_session_messages SET id = id WHERE id = ?`), message.ID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("message not found: %s", message.ID) + } + var raw string + var storedType models.MessageType + var storedUpdatedAt time.Time + if err := tx.QueryRowContext(ctx, tx.Rebind(`SELECT metadata,type,updated_at FROM task_session_messages WHERE id = ?`), message.ID).Scan(&raw, &storedType, &storedUpdatedAt); err != nil { + return err + } + metadataJSON, sanitized, err := mergeRetainedMessageMetadata([]byte(raw), metadataJSON) + if err != nil { + return err + } + nextType := message.Type + if sanitized != nil { + nextType = storedType + } + updatedAt := message.UpdatedAt + if sanitized != nil { + updatedAt = storedUpdatedAt + } + _, err = tx.ExecContext(ctx, tx.Rebind(`UPDATE task_session_messages SET content = ?, requests_input = ?, type = ?, metadata = ?, updated_at = ? WHERE id = ?`), message.Content, requestsInput, string(nextType), string(metadataJSON), updatedAt, message.ID) + if err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + if sanitized != nil { + message.Metadata = sanitized + message.Type = nextType + message.UpdatedAt = storedUpdatedAt + } + return nil +} + +// insertMessageWithPayloadGuard prevents fallback-create and repeated start +// frames from giving a removed tool call a fresh row identity. +func (r *Repository) insertMessageWithPayloadGuard(ctx context.Context, message *models.Message, requestsInput int, messageType, metadataJSON string) error { + toolCallID, _ := message.Metadata["tool_call_id"].(string) + if toolCallID == "" || messageType == string(models.MessageTypePermissionRequest) { + return r.insertMessageRow(ctx, r.db, message, requestsInput, messageType, metadataJSON) + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(`UPDATE task_sessions SET id = id WHERE id = ?`), message.TaskSessionID); err != nil { + return err + } + var removed bool + err = tx.QueryRowContext(ctx, tx.Rebind(`SELECT EXISTS(SELECT 1 FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.tool_call_id') = ? AND type != 'permission_request' AND json_type(metadata, '$.payload_retention') IS NOT NULL)`), message.TaskSessionID, toolCallID).Scan(&removed) + if err != nil { + return err + } + if removed { + return fmt.Errorf("tool_payload_removed: tool call already retained") + } + if err := r.insertMessageRow(ctx, tx, message, requestsInput, messageType, metadataJSON); err != nil { + return err + } + return tx.Commit() +} + +func mergeRetainedMessageMetadata(stored, incoming []byte) ([]byte, map[string]any, error) { + var current map[string]json.RawMessage + if err := json.Unmarshal(stored, ¤t); err != nil { + return nil, nil, fmt.Errorf("decode stored message metadata: %w", err) + } + if _, removed := current["payload_retention"]; !removed { + return incoming, nil, nil + } + var replacement map[string]json.RawMessage + if err := json.Unmarshal(incoming, &replacement); err != nil { + return nil, nil, err + } + for _, key := range []string{"status", "title"} { + if value, ok := replacement[key]; ok { + current[key] = value + } + } + encoded, err := json.Marshal(current) + if err != nil { + return nil, nil, err + } + var sanitized map[string]any + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + if err := decoder.Decode(&sanitized); err != nil { + return nil, nil, err + } + return encoded, sanitized, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/message_payload_replay_postgres_test.go b/apps/backend/internal/task/repository/sqlite/message_payload_replay_postgres_test.go new file mode 100644 index 0000000000..3f82efa2c1 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_payload_replay_postgres_test.go @@ -0,0 +1,28 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestPostgresMessageReplacementKeepsExistingBehavior(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + require.NoError(t, err) + ctx := context.Background() + seedPostgresTask(t, repo, "retention-pg") + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ID: "retention-session-pg", TaskID: "retention-pg"})) + require.NoError(t, repo.CreateTurn(ctx, &models.Turn{ID: "retention-turn-pg", TaskSessionID: "retention-session-pg", TaskID: "retention-pg"})) + message := &models.Message{ID: "retention-message-pg", TaskID: "retention-pg", TaskSessionID: "retention-session-pg", TurnID: "retention-turn-pg", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeToolExecute, Metadata: map[string]any{"payload_retention": map[string]any{"version": 1}}} + require.NoError(t, repo.CreateMessage(ctx, message)) + message.Metadata = map[string]any{"result": "replacement"} + require.NoError(t, repo.UpdateMessage(ctx, message)) + stored, err := repo.GetMessage(ctx, message.ID) + require.NoError(t, err) + require.Equal(t, "replacement", stored.Metadata["result"]) + require.False(t, models.ToolPayloadRemoved(stored.Metadata)) +} diff --git a/apps/backend/internal/task/repository/sqlite/message_payload_replay_test.go b/apps/backend/internal/task/repository/sqlite/message_payload_replay_test.go new file mode 100644 index 0000000000..3b27cd473e --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/message_payload_replay_test.go @@ -0,0 +1,87 @@ +package sqlite + +import ( + "context" + "encoding/json" + "github.com/kandev/kandev/internal/task/models" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestUpdateMessagePreservesPayloadRemovalAfterStaleRead(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-ret", "session-ret", "turn-ret") + message := &models.Message{ID: "ret", TaskID: "task-ret", TaskSessionID: "session-ret", TurnID: "turn-ret", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeToolExecute, Metadata: map[string]any{"tool_call_id": "tool-1", "normalized": map[string]any{"kind": "shell_exec", "shell_exec": map[string]any{"command": "ls", "output": map[string]any{"stdout": "secret"}}}}} + require.NoError(t, repo.CreateMessage(ctx, message)) + stale, err := repo.GetMessage(ctx, message.ID) + require.NoError(t, err) + originalUpdatedAt := stale.UpdatedAt + _, err = repo.db.ExecContext(ctx, `UPDATE task_session_messages SET metadata = ? WHERE id = ?`, `{"tool_call_id":"tool-1","future":9007199254740993,"normalized":{"kind":"shell_exec","shell_exec":{"command":"ls","output":{"exit_code":1}}},"payload_retention":{"version":1,"removed_at":"2026-09-14T00:00:00Z"}}`, message.ID) + require.NoError(t, err) + stale.Metadata["result"] = "secret" + stale.Metadata["status"] = "complete" + stale.Type = models.MessageTypeToolCall + stale.UpdatedAt = originalUpdatedAt.Add(24 * time.Hour) + require.NoError(t, repo.UpdateMessage(ctx, stale)) + current, err := repo.GetMessage(ctx, message.ID) + require.NoError(t, err) + require.True(t, models.ToolPayloadRemoved(current.Metadata)) + require.Equal(t, models.MessageTypeToolExecute, current.Type) + require.Equal(t, originalUpdatedAt, current.UpdatedAt) + require.Equal(t, originalUpdatedAt, stale.UpdatedAt) + require.NotContains(t, current.Metadata, "result") + require.Equal(t, "complete", current.Metadata["status"]) + require.True(t, models.ToolPayloadRemoved(stale.Metadata)) + require.NotContains(t, stale.Metadata, "result") + var raw string + require.NoError(t, repo.db.Get(&raw, `SELECT metadata FROM task_session_messages WHERE id = ?`, message.ID)) + require.Contains(t, raw, "9007199254740993") + require.NotContains(t, raw, "secret") + for _, projected := range []map[string]any{models.ProjectMessageMetadata(stale.Metadata), models.ProjectMessageMetadata(current.Metadata)} { + output := projected["normalized"].(map[string]any)["shell_exec"].(map[string]any)["output"].(map[string]any) + require.Equal(t, 1, output["exit_code"]) + require.NotContains(t, output, "stdout") + require.NotContains(t, projected, "result") + } + api := stale.ToAPI() + var projected map[string]any + encoded, err := json.Marshal(api.Metadata) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(encoded, &projected)) + output := projected["normalized"].(map[string]any)["shell_exec"].(map[string]any)["output"].(map[string]any) + require.Equal(t, float64(1), output["exit_code"]) + require.NotContains(t, output, "stdout") + require.Contains(t, string(encoded), "9007199254740993") + +} + +func TestCreateMessageCannotReplayRemovedToolIdentity(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-ret-create", "session-ret-create", "turn-ret-create") + message := &models.Message{ID: "removed-original", TaskID: "task-ret-create", TaskSessionID: "session-ret-create", TurnID: "turn-ret-create", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeToolExecute, Metadata: map[string]any{"tool_call_id": "same-tool", "payload_retention": map[string]any{"version": 1}}} + require.NoError(t, repo.CreateMessage(ctx, message)) + message.ID = "replay" + message.Metadata = map[string]any{"tool_call_id": "same-tool", "result": "secret"} + require.Error(t, repo.CreateMessage(ctx, message)) + var count int + require.NoError(t, repo.db.Get(&count, `SELECT COUNT(*) FROM task_session_messages WHERE task_session_id = ?`, message.TaskSessionID)) + require.Equal(t, 1, count) + message.ID = "new-call" + message.Metadata["tool_call_id"] = "different-tool" + require.NoError(t, repo.CreateMessage(ctx, message)) +} + +func TestCreateMessageCannotReplayRemovedToolAsDifferentType(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-type", "session-type", "turn-type") + message := &models.Message{ID: "original-type", TaskID: "task-type", TaskSessionID: "session-type", TurnID: "turn-type", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeToolExecute, Metadata: map[string]any{"tool_call_id": "same-tool", "payload_retention": map[string]any{"version": 1}}} + require.NoError(t, repo.CreateMessage(ctx, message)) + message.ID = "retyped" + message.Type = models.MessageTypeMessage + message.Metadata = map[string]any{"tool_call_id": "same-tool", "result": "secret"} + require.Error(t, repo.CreateMessage(ctx, message)) +} diff --git a/apps/backend/internal/task/repository/sqlite/tool_payload_retention_scan.go b/apps/backend/internal/task/repository/sqlite/tool_payload_retention_scan.go new file mode 100644 index 0000000000..43cc2a41a1 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/tool_payload_retention_scan.go @@ -0,0 +1,112 @@ +package sqlite + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jmoiron/sqlx" +) + +// ErrPayloadEligibilityBudget means no eligibility decision was made. The caller +// must retain the task and report a budget skip, never reuse an earlier decision. +var ErrPayloadEligibilityBudget = errors.New("payload eligibility budget exceeded") + +// PayloadTaskEligible checks persisted admission and activity before payload maintenance. +func PayloadTaskEligible(ctx context.Context, q sqlx.QueryerContext, taskID string, cutoff time.Time) (bool, error) { + driver, ok := q.(interface { + DriverName() string + Rebind(string) string + }) + if !ok || driver.DriverName() != "sqlite3" { + return false, errors.New("payload retention requires SQLite") + } + stmt, cancel := context.WithTimeout(ctx, 200*time.Millisecond) + defer cancel() + var eligible bool + err := sqlx.GetContext(stmt, q, &eligible, driver.Rebind(payloadTaskEligibilitySQL), cutoff.UTC().Format(time.RFC3339Nano), taskID) + if errors.Is(stmt.Err(), context.DeadlineExceeded) { + return false, fmt.Errorf("%w: %w", ErrPayloadEligibilityBudget, context.DeadlineExceeded) + } + if err != nil { + return false, err + } + if stmt.Err() != nil { + return false, stmt.Err() + } + return eligible, nil +} + +var payloadTaskEligibilitySQL = buildPayloadTaskEligibilitySQL() + +func buildPayloadTaskEligibilitySQL() string { + query := payloadTaskEligibilityTemplate + for _, column := range []string{"t.created_at", "t.updated_at", "s.started_at", "s.completed_at", "s.updated_at", "m.created_at", "m.updated_at", "tr.started_at", "tr.created_at", "tr.completed_at", "tr.updated_at"} { + query = strings.ReplaceAll(query, "{{"+column+"}}", payloadTimestampBeforeCutoff(column)) + } + return query +} + +// SQLite accepts numeric Julian dates and normalizes impossible calendar days. +// Only recognized calendar dates qualify; offsets are compared chronologically. +func payloadTimestampBeforeCutoff(column string) string { + return fmt.Sprintf(`COALESCE(typeof(%[1]s)='text' + AND substr(%[1]s,1,10) GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + AND (length(%[1]s)=10 OR (length(%[1]s)>=19 AND substr(%[1]s,11,1) IN ('T',' ') + AND substr(%[1]s,12,2) BETWEEN '00' AND '23' AND substr(%[1]s,15,2) BETWEEN '00' AND '59' + AND substr(%[1]s,18,2) BETWEEN '00' AND '59')) + AND date(substr(%[1]s,1,10),'+0 days')=substr(%[1]s,1,10) + AND julianday(%[1]s)>julianday('0001-01-01') AND julianday(%[1]s) + + + + ({ BackupsTable: () => null })); vi.mock("./database-stats-card", () => ({ DatabaseStatsCard: () => null })); vi.mock("./log-viewer", () => ({ LogViewer: () => null })); vi.mock("./retention-settings-card", () => ({ RetentionSettingsCard: () => null })); +vi.mock("./tool-payload-retention-card", () => ({ ToolPayloadRetentionCard: () => null })); afterEach(() => { cleanup(); databaseState.value = null; diff --git a/apps/web/components/settings/system/tool-payload-retention-card.test.tsx b/apps/web/components/settings/system/tool-payload-retention-card.test.tsx new file mode 100644 index 0000000000..a65b2612db --- /dev/null +++ b/apps/web/components/settings/system/tool-payload-retention-card.test.tsx @@ -0,0 +1,224 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { SettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import type { ToolPayloadRetentionStatus } from "@/lib/types/tool-payload-retention"; +import * as api from "@/lib/api/domains/tool-payload-retention-api"; +import { ToolPayloadRetentionCard } from "./tool-payload-retention-card"; +vi.mock("@/lib/api/domains/tool-payload-retention-api"); +let contributor: SettingsSaveContributor; +let admin = true; +vi.mock("@/hooks/domains/auth/use-is-admin", () => ({ useIsAdmin: () => admin })); +vi.mock("@/components/settings/settings-save-provider", () => ({ + useSettingsSaveContributor: (value: SettingsSaveContributor) => { + contributor = value; + }, +})); +const ENABLED_TEST_ID = "tool-payload-enabled"; +const AGE_TEST_ID = "tool-payload-age"; + +const baseline: ToolPayloadRetentionStatus = { + supported: true, + policy: { enabled: false, age: { value: 3, unit: "months" }, revision: 1 }, + preparation: { state: "none", choice: "" }, +}; +beforeEach(() => { + vi.resetAllMocks(); + admin = true; + vi.mocked(api.fetchToolPayloadRetention).mockResolvedValue(baseline); +}); +afterEach(cleanup); +async function open() { + render(); + await screen.findByTestId(ENABLED_TEST_ID); +} +it("requires an explicit backup choice before saving enablement and displays preparing", async () => { + await open(); + fireEvent.click(screen.getByTestId(ENABLED_TEST_ID)); + expect(contributor.isDirty).toBe(true); + expect(contributor.canSave).toBe(false); + fireEvent.click(screen.getByTestId("tool-payload-backup")); + expect(contributor.canSave).toBe(true); + vi.mocked(api.saveToolPayloadRetention).mockResolvedValue({ + ...baseline, + preparation: { state: "pending", choice: "backup" }, + policy: { ...baseline.policy, revision: 2 }, + }); + await act(async () => { + await contributor.save(contributor.revision); + }); + expect(api.saveToolPayloadRetention).toHaveBeenCalledWith({ + ...baseline.policy, + enabled: true, + backup_choice: "backup", + }); + expect(screen.getByTestId(ENABLED_TEST_ID).getAttribute("aria-checked")).toBe("false"); + expect(screen.getByTestId("tool-payload-preparation").textContent).toMatch(/preparing/i); +}); +it("analyzes a disabled draft without saving or enabling", async () => { + await open(); + fireEvent.change(screen.getByTestId(AGE_TEST_ID), { target: { value: "4" } }); + vi.mocked(api.analyzeToolPayloadRetention).mockResolvedValue({ operation_id: "analysis-1" }); + fireEvent.click(screen.getByTestId("tool-payload-analyze")); + await waitFor(() => + expect(api.analyzeToolPayloadRetention).toHaveBeenCalledWith({ value: 4, unit: "months" }), + ); + expect(api.saveToolPayloadRetention).not.toHaveBeenCalled(); + expect(contributor.isDirty).toBe(true); +}); +it("keeps member controls read only", async () => { + admin = false; + await open(); + expect(screen.getByTestId(ENABLED_TEST_ID).hasAttribute("disabled")).toBe(true); + expect(screen.getByTestId("tool-payload-analyze").hasAttribute("disabled")).toBe(true); + expect(api.analyzeToolPayloadRetention).not.toHaveBeenCalled(); +}); +it("rejects invalid ages without losing the disabled default", async () => { + await open(); + fireEvent.change(screen.getByTestId(AGE_TEST_ID), { target: { value: "0" } }); + expect(contributor.canSave).toBe(false); + expect(screen.getByTestId("tool-payload-analyze").hasAttribute("disabled")).toBe(true); +}); + +it("preserves edits made during a pending save against its normalized response", async () => { + await open(); + let resolve!: (value: ToolPayloadRetentionStatus) => void; + vi.mocked(api.saveToolPayloadRetention).mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + fireEvent.change(screen.getByTestId(AGE_TEST_ID), { target: { value: "4" } }); + let saving!: Promise; + act(() => { + saving = Promise.resolve(contributor.save(contributor.revision)); + }); + // A programmatic input can still race a save callback even when controls disable after paint. + fireEvent.change(screen.getByTestId(AGE_TEST_ID), { target: { value: "5" } }); + await act(async () => { + resolve({ + ...baseline, + policy: { ...baseline.policy, revision: 2, age: { value: 4, unit: "months" } }, + }); + await saving; + }); + expect((screen.getByTestId(AGE_TEST_ID) as HTMLInputElement).value).toBe("5"); + expect(contributor.isDirty).toBe(true); +}); +it("shows backup failure after reload and retries the explicitly saved choice", async () => { + vi.mocked(api.fetchToolPayloadRetention).mockResolvedValue({ + ...baseline, + preparation: { state: "failed", choice: "backup", error: "backup_failed" }, + }); + await open(); + expect(screen.getByTestId("tool-payload-preparation").textContent).toMatch(/Preparation failed/); + vi.mocked(api.saveToolPayloadRetention).mockResolvedValue({ + ...baseline, + preparation: { state: "running", choice: "backup" }, + }); + fireEvent.click(screen.getByTestId("tool-payload-retry")); + await waitFor(() => + expect(api.saveToolPayloadRetention).toHaveBeenCalledWith({ + ...baseline.policy, + enabled: true, + backup_choice: "backup", + }), + ); +}); +it("can cancel preparation while policy enablement remains false", async () => { + vi.mocked(api.fetchToolPayloadRetention).mockResolvedValue({ + ...baseline, + preparation: { state: "pending", choice: "backup" }, + }); + await open(); + vi.mocked(api.saveToolPayloadRetention).mockResolvedValue(baseline); + fireEvent.click(screen.getByTestId("tool-payload-cancel-preparation")); + await waitFor(() => expect(api.saveToolPayloadRetention).toHaveBeenCalledWith(baseline.policy)); +}); +it("shows unsupported engine capability and prevents mutations", async () => { + vi.mocked(api.fetchToolPayloadRetention).mockResolvedValue({ ...baseline, supported: false }); + await open(); + expect(screen.getByText(/not supported by this database engine/)).toBeTruthy(); + expect(screen.getByTestId(ENABLED_TEST_ID).hasAttribute("disabled")).toBe(true); +}); + +it("activates the switch through its visible touch label", async () => { + await open(); + fireEvent.click(screen.getByText("Automatic compaction")); + expect(screen.getByTestId(ENABLED_TEST_ID).getAttribute("aria-checked")).toBe("true"); + expect(contributor.canSave).toBe(false); +}); +it("keeps an unsaved draft through conflict refresh and sends its original revision", async () => { + await open(); + vi.useFakeTimers(); + try { + const current = { + ...baseline, + policy: { ...baseline.policy, revision: 2, age: { value: 8, unit: "months" as const } }, + }; + vi.mocked(api.fetchToolPayloadRetention).mockResolvedValue(current); + fireEvent.change(screen.getByTestId(AGE_TEST_ID), { target: { value: "5" } }); + // Poll timer was installed before fake timers; use the visible refresh path after a save conflict. + const { ApiError } = await import("@/lib/api/client"); + vi.mocked(api.saveToolPayloadRetention).mockRejectedValue( + new ApiError("conflict", 409, { code: "conflict" }), + ); + await act(async () => { + await expect(contributor.save(contributor.revision)).rejects.toMatchObject({ status: 409 }); + }); + fireEvent.click(screen.getByRole("button", { name: "Refresh status" })); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect((screen.getByTestId(AGE_TEST_ID) as HTMLInputElement).value).toBe("5"); + expect(api.saveToolPayloadRetention).toHaveBeenCalledWith({ + ...baseline.policy, + age: { value: 5, unit: "months" }, + }); + await act(async () => { + await contributor.discard?.(); + }); + expect((screen.getByTestId(AGE_TEST_ID) as HTMLInputElement).value).toBe("8"); + expect(contributor.isDirty).toBe(false); + } finally { + vi.useRealTimers(); + } +}); +it("labels partial analysis as incomplete and shows protected skip reasons", async () => { + vi.mocked(api.fetchToolPayloadRetention).mockResolvedValue({ + ...baseline, + last_analysis: { + id: "partial-scan", + kind: "analysis", + state: "partial", + scanned: 10, + eligible_tasks: 2, + eligible_messages: 3, + removed_messages: 0, + payload_bytes: 4096, + skipped: { protected_tasks: 4 }, + cutoff: "2025-09-14T00:00:00Z", + started_at: "2026-09-14T00:00:00Z", + finished_at: "2026-09-14T00:01:00Z", + age: { value: 12, unit: "months" }, + }, + }); + await open(); + expect(screen.getByText(/This result is incomplete/)).toBeTruthy(); + expect(screen.getByText("Protected tasks")).toBeTruthy(); + expect(screen.getByText(/This estimate is stale/)).toBeTruthy(); + expect(screen.queryByText(/No removable payloads found/)).toBeNull(); +}); + +it("can disable an enabled policy even after entering an invalid age", async () => { + vi.mocked(api.fetchToolPayloadRetention).mockResolvedValue({ + ...baseline, + policy: { ...baseline.policy, enabled: true }, + }); + await open(); + fireEvent.change(screen.getByTestId(AGE_TEST_ID), { target: { value: "0" } }); + expect(contributor.canSave).toBe(false); + fireEvent.click(screen.getByTestId(ENABLED_TEST_ID)); + expect(contributor.canSave).toBe(true); + expect((screen.getByTestId(AGE_TEST_ID) as HTMLInputElement).value).toBe("3"); +}); diff --git a/apps/web/components/settings/system/tool-payload-retention-card.tsx b/apps/web/components/settings/system/tool-payload-retention-card.tsx new file mode 100644 index 0000000000..b9cd007fef --- /dev/null +++ b/apps/web/components/settings/system/tool-payload-retention-card.tsx @@ -0,0 +1,227 @@ +"use client"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { CardContent } from "@kandev/ui/card"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/settings-card-header"; +import { settingsActionClassName } from "@/components/settings/settings-control"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; +import { useToolPayloadRetention } from "@/hooks/domains/system/use-tool-payload-retention"; +import { useToolPayloadRetentionDraft } from "@/hooks/domains/system/use-tool-payload-retention-draft"; +import { SYSTEM_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/system"; +import { ApiError } from "@/lib/api/client"; +import { + ToolPayloadAutomation, + ToolPayloadBackupReview, + ToolPayloadRetentionFields, +} from "./tool-payload-retention-fields"; +import { ToolPayloadAnalysis, ToolPayloadRetentionResults } from "./tool-payload-retention-results"; + +function errorKey(error: unknown) { + if (error instanceof ApiError) { + if (error.status === 409) return "system:toolPayload.conflict"; + if (error.status === 403) return "system:toolPayload.adminOnly"; + if (error.status === 501) return "system:toolPayload.unsupported"; + const body = error.body as { code?: string } | null; + if ( + ["invalid_stored_policy", "invalid_preparation", "preparation_required"].includes( + body?.code ?? "", + ) + ) + return "system:toolPayload.preparationFailed"; + if (body?.code === "backup_choice_required") return "system:toolPayload.chooseBackup"; + if (error.status === 400) return "system:toolPayload.invalidAge"; + } + return "system:toolPayload.operationFailed"; +} + +function Preparation({ + remote, + canEdit, + dirty, +}: { + remote: ReturnType; + canEdit: boolean; + dirty: boolean; +}) { + const { t } = useTranslation(); + const status = remote.status; + if (!status || status.preparation.state === "none" || status.preparation.state === "ready") + return null; + const failed = status.preparation.state === "failed"; + // The hook owns and renders action errors, including retry failures. + const act = (action: Promise) => { + void action.catch(() => undefined); + }; + return ( +
+

+ {t(failed ? "system:toolPayload.preparationFailed" : "system:toolPayload.preparing")} +

+
+ {failed && status.preparation.choice && ( + + )} + +
+
+ ); +} + +type Remote = ReturnType; +type Model = ReturnType; +const actionClass = settingsActionClassName("cursor-pointer"); +// The domain hook records failures for the inline error summary. +function act(action: Promise) { + void action.catch(() => undefined); +} + +function CleanupActions({ remote, model }: { remote: Remote; model: Model }) { + const { t } = useTranslation(); + const status = remote.status; + if (!status) return null; + const operationId = + remote.acceptedId || (status.operation?.state === "running" ? status.operation.id : undefined); + const canRun = + model.canEdit && status.policy.enabled && !model.dirty && !remote.active && !remote.pending; + return ( +
+ + {operationId && !remote.preparing && ( + + )} +
+ ); +} +function RetentionError({ remote }: { remote: Remote }) { + const { t } = useTranslation(); + if (remote.error == null) return null; + return ( +
+

{t(errorKey(remote.error))}

+ +
+ ); +} +function RetentionContent({ + remote, + model, + admin, +}: { + remote: Remote; + model: Model; + admin: boolean; +}) { + const { t } = useTranslation(); + const status = remote.status; + const draft = model.draft; + if (!status || !draft) return null; + return ( + <> + {!status.supported &&

{t("system:toolPayload.unsupported")}

} + {!admin &&

{t("system:toolPayload.adminOnly")}

} +
+ + +
+

+ {t("system:toolPayload.ageHelp")} +

+
+ {(remote.pending || remote.acceptedId) && ( +

{t("system:toolPayload.pending")}

+ )} + +
+ + + + + +

{t("system:toolPayload.compactionHelp")}

+ + ); +} +export function ToolPayloadRetentionCard() { + const { t } = useTranslation(); + const remote = useToolPayloadRetention(); + const admin = useIsAdmin(); + const model = useToolPayloadRetentionDraft(remote, admin); + return ( + + + + {!remote.status && !remote.error &&

{t("settings:loading")}

} + + +
+
+ ); +} diff --git a/apps/web/components/settings/system/tool-payload-retention-fields.tsx b/apps/web/components/settings/system/tool-payload-retention-fields.tsx new file mode 100644 index 0000000000..cda2f81849 --- /dev/null +++ b/apps/web/components/settings/system/tool-payload-retention-fields.tsx @@ -0,0 +1,124 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@kandev/ui/input"; +import { Switch } from "@kandev/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; +import { settingsControlClassName } from "@/components/settings/settings-control"; +import type { useToolPayloadRetentionDraft } from "@/hooks/domains/system/use-tool-payload-retention-draft"; + +type Draft = ReturnType; +export function ToolPayloadRetentionFields({ model, pending }: { model: Draft; pending: boolean }) { + const { t } = useTranslation(); + const { draft, setDraft, canEdit } = model; + if (!draft) return null; + const disabled = !canEdit || pending; + return ( + <> +
+ +
+ + setDraft({ ...draft, age: { ...draft.age, value: Number(e.target.value) } }) + } + /> + +
+ {model.invalid && ( +

+ {t("system:toolPayload.invalidAge")} +

+ )} +
+ + ); +} +export function ToolPayloadAutomation({ model, pending }: { model: Draft; pending: boolean }) { + const { t } = useTranslation(); + const { draft, canEdit } = model; + if (!draft) return null; + const disabled = !canEdit || pending; + return ( +
+ +

{t("system:toolPayload.schedule")}

+

{t("system:toolPayload.firstRun")}

+
+ ); +} +export function ToolPayloadBackupReview({ model, pending }: { model: Draft; pending: boolean }) { + const { t } = useTranslation(); + if (!model.needsChoice) return null; + return ( +
+ {t("system:toolPayload.beforeCleanup")} +

{t("system:toolPayload.backupHelp")}

+ { + if (value === "backup" || value === "skip") model.setChoice(value); + }} + > + {(["backup", "skip"] as const).map((choice) => ( + + ))} + +
+ ); +} diff --git a/apps/web/components/settings/system/tool-payload-retention-results.tsx b/apps/web/components/settings/system/tool-payload-retention-results.tsx new file mode 100644 index 0000000000..07499dbeb3 --- /dev/null +++ b/apps/web/components/settings/system/tool-payload-retention-results.tsx @@ -0,0 +1,161 @@ +import { useTranslation } from "react-i18next"; +import type { + ToolPayloadAge, + ToolPayloadOperation, + ToolPayloadRetentionStatus, +} from "@/lib/types/tool-payload-retention"; +import { sameAge } from "@/hooks/domains/system/use-tool-payload-retention-draft"; +import { formatBytes } from "@/lib/utils/format-bytes"; +import { formatDateTime } from "@/lib/i18n/formats"; +import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; + +export function retentionDate(value?: string) { + return value && parseTurnTimestamp(value) !== null ? formatDateTime(value) : ""; +} +const skipReasons = new Set([ + "eligibility_budget", + "protected_tasks", + "oversize", + "already_removed", + "no_payload", + "unsupported", + "malformed", + "invalid_message", +]); +function SkippedItems({ skipped }: { skipped: Record }) { + const { t } = useTranslation(); + return ( +
+ {Object.entries(skipped) + .filter(([, count]) => count > 0) + .map(([reason, count]) => ( +
+
+ {t(`system:toolPayload.skipReason.${skipReasons.has(reason) ? reason : "other"}`)} +
+
{count}
+
+ ))} +
+ ); +} +function OperationOutcome({ operation }: { operation: ToolPayloadOperation }) { + const { t } = useTranslation(); + if (operation.kind === "analysis") { + if (operation.state === "succeeded" && operation.payload_bytes === 0) + return

{t("system:toolPayload.empty")}

; + return ( +

{t("system:toolPayload.estimate", { size: formatBytes(operation.payload_bytes) })}

+ ); + } + return ( +

+ {t("system:toolPayload.removed", { + count: operation.removed_messages, + size: formatBytes(operation.payload_bytes), + })} +

+ ); +} +function OperationResult({ operation }: { operation: ToolPayloadOperation }) { + const { t } = useTranslation(); + if (operation.kind === "backup") + return

{t("system:toolPayload.preparing")}

; + const skipped = Object.values(operation.skipped ?? {}).reduce((sum, value) => sum + value, 0); + return ( +
+

+ {t(`system:toolPayload.${operation.kind}`)}: {t(`system:toolPayload.${operation.state}`)} + {operation.finished_at && <> · {retentionDate(operation.finished_at)}} +

+ +
+ + {t( + operation.kind === "analysis" + ? "system:toolPayload.analysisDetails" + : "system:toolPayload.runDetails", + )} + +

+ {t("system:toolPayload.progress", { + scanned: operation.scanned, + tasks: operation.eligible_tasks, + messages: operation.eligible_messages, + })} +

+ {skipped > 0 && ( + <> +

{t("system:toolPayload.skipped", { count: skipped })}

+ + + )} +

+ {t("system:toolPayload.window", { + start: retentionDate(operation.started_at), + end: retentionDate(operation.finished_at) || t("system:toolPayload.running"), + cutoff: retentionDate(operation.cutoff), + })} +

+
+ {operation.state === "partial" &&

{t("system:toolPayload.partialHelp")}

} + {operation.error && ( +

{t("system:toolPayload.operationFailed")}

+ )} +
+ ); +} +export function ToolPayloadAnalysis({ + status, + age, +}: { + status: ToolPayloadRetentionStatus; + age: ToolPayloadAge; +}) { + const { t } = useTranslation(); + const analysis = status.last_analysis; + const finished = analysis?.finished_at; + const stale = Boolean( + analysis && + (!sameAge(analysis.age, age) || + !finished || + parseTurnTimestamp(finished) === null || + Date.now() - Date.parse(finished) > 86400000), + ); + return ( +
+
+ {!analysis ? ( +

{t("system:toolPayload.notAnalyzed")}

+ ) : ( + <> + {stale &&

{t("system:toolPayload.stale")}

} + + + )} +
+ {status.operation?.state === "running" && } +
+ ); +} +export function ToolPayloadRetentionResults({ status }: { status: ToolPayloadRetentionStatus }) { + const { t } = useTranslation(); + return ( +
+
+

{t("system:toolPayload.lastCleanup")}

+ {status.last_run ? ( + + ) : ( +

{t("system:toolPayload.never")}

+ )} +
+

+ {t("system:toolPayload.nextCheck")}:{" "} + {status.policy.enabled + ? retentionDate(status.next_due_at) || t("system:toolPayload.pending") + : t("system:toolPayload.disabled")} +

+
+ ); +} diff --git a/apps/web/components/task/chat/message-renderer.test.tsx b/apps/web/components/task/chat/message-renderer.test.tsx index 73e43d2a00..f1ccee3247 100644 --- a/apps/web/components/task/chat/message-renderer.test.tsx +++ b/apps/web/components/task/chat/message-renderer.test.tsx @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MessageRenderer } from "./message-renderer"; import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http"; +const REMOVED_PAYLOAD = "removed secret"; + const fallbackOpenFile = vi.hoisted(() => vi.fn()); const useOpenFileAtLine = vi.hoisted(() => vi.fn((onOpenFile: ((path: string) => void) | undefined) => (path: string) => onOpenFile?.(path)), @@ -117,3 +119,35 @@ describe("MessageRenderer markdown file links", () => { expect(fallbackOpenFile).not.toHaveBeenCalled(); }); }); + +it.each(["tool_read", "tool_edit", "tool_search", "tool_call"])( + "renders removed %s details without old payloads", + (type) => { + const view = render( + , + ); + expect(view.getByText("Retained title")).toBeTruthy(); + expect(view.getByText("Completed")).toBeTruthy(); + expect(view.getByTestId("tool-payload-removed").textContent).toMatch(/Tool details removed on/); + expect(view.queryByText(REMOVED_PAYLOAD)).toBeNull(); + view.unmount(); + }, +); diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 9ba82de42a..879f7608de 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -18,6 +18,8 @@ import { ToolCallMessage } from "@/components/task/chat/messages/tool-call-messa import { ToolEditMessage } from "@/components/task/chat/messages/tool-edit-message"; import { ToolReadMessage } from "@/components/task/chat/messages/tool-read-message"; import { ToolSearchMessage } from "@/components/task/chat/messages/tool-search-message"; +import { payloadRetentionMarker } from "@/lib/utils/tool-payload-retention"; +import { ToolPayloadRemovedMessage } from "./messages/tool-payload-removed-message"; import { ToolExecuteMessage } from "@/components/task/chat/messages/tool-execute-message"; import { ThinkingMessage } from "@/components/task/chat/messages/thinking-message"; import { TodoMessage } from "@/components/task/chat/messages/todo-message"; @@ -214,6 +216,11 @@ type MessageAdapter = { }; const adapters: MessageAdapter[] = [ + { + matches: (comment) => + comment.type !== "tool_execute" && Boolean(payloadRetentionMarker(comment.metadata)), + render: (comment) => , + }, { matches: (comment) => comment.type === "thinking", render: (comment, ctx) => ( diff --git a/apps/web/components/task/chat/messages/shell-output-disclosure.tsx b/apps/web/components/task/chat/messages/shell-output-disclosure.tsx index 5ca41ed2c0..d36e3b397d 100644 --- a/apps/web/components/task/chat/messages/shell-output-disclosure.tsx +++ b/apps/web/components/task/chat/messages/shell-output-disclosure.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; +import { isToolPayloadRemovedError } from "@/lib/utils/tool-payload-retention"; import { useShellCommandOutput } from "@/hooks/domains/session/use-shell-command-output"; import type { ShellCommandOutput, ShellCommandOutputSnapshot } from "@/lib/api/domains/session-api"; import { cn } from "@/lib/utils"; @@ -70,6 +71,13 @@ function ResultDetails({ snapshot }: { snapshot: ShellCommandOutputSnapshot }) { ); } +function emptyOutputLabel(snapshotStatus?: string, messageStatus?: string) { + return normalizeToolCallStatus(snapshotStatus) === "running" && + !isTerminalToolCallStatus(messageStatus) + ? t("task:noCommandOutputYet") + : t("task:noCommandOutput"); +} + function DisclosureContent({ snapshot, isLoading, @@ -84,12 +92,11 @@ function DisclosureContent({ messageStatus?: string; }) { const { t } = useTranslation(); + if (isToolPayloadRemovedError(error)) { + return

{t("task:toolPayloadRemoved")}

; + } const hasTranscript = Boolean(snapshot?.output.stdout || snapshot?.output.stderr); - const emptyLabel = - normalizeToolCallStatus(snapshot?.status) === "running" && - !isTerminalToolCallStatus(messageStatus) - ? t("task:noCommandOutputYet") - : t("task:noCommandOutput"); + const emptyLabel = emptyOutputLabel(snapshot?.status, messageStatus); return (
diff --git a/apps/web/components/task/chat/messages/tool-execute-message.test.tsx b/apps/web/components/task/chat/messages/tool-execute-message.test.tsx index 209b084039..2279d84f0e 100644 --- a/apps/web/components/task/chat/messages/tool-execute-message.test.tsx +++ b/apps/web/components/task/chat/messages/tool-execute-message.test.tsx @@ -4,6 +4,8 @@ import { sessionId, taskId, type Message } from "@/lib/types/http"; import type { UseShellCommandOutputResult } from "@/hooks/domains/session/use-shell-command-output"; import { ToolExecuteMessage } from "./tool-execute-message"; +const COMMAND_TEST_ID = "tool-execute-command"; + const useShellOutputMock = vi.hoisted(() => vi.fn()); const runningMessageID = "message-running"; const outputUpdatedAt = "2026-07-16T12:00:02Z"; @@ -87,7 +89,7 @@ describe("ToolExecuteMessage command row", () => { />, ); - expect(screen.getByTestId("tool-execute-command")).toBeTruthy(); + expect(screen.getByTestId(COMMAND_TEST_ID)).toBeTruthy(); expect(screen.queryByRole("button", { name: /show command output/i })).toBeNull(); expect(useShellOutputMock).not.toHaveBeenCalled(); }); @@ -102,7 +104,7 @@ describe("ToolExecuteMessage command row", () => { />, ); - const command = screen.getByTestId("tool-execute-command"); + const command = screen.getByTestId(COMMAND_TEST_ID); expect(command.textContent).toBe("printf normalized-command"); expect(command.className).toContain("whitespace-pre-wrap"); expect(screen.getByText("/workspace/with/a/long/path")).toBeTruthy(); @@ -118,7 +120,7 @@ describe("ToolExecuteMessage command row", () => { it("falls back to message content when the normalized command is absent", () => { render(); - expect(screen.getByTestId("tool-execute-command").textContent).toBe("fallback message content"); + expect(screen.getByTestId(COMMAND_TEST_ID).textContent).toBe("fallback message content"); }); it("renders loading and empty snapshot states only after opening", () => { @@ -267,3 +269,35 @@ describe("ToolExecuteMessage cancelled result", () => { expect(screen.getByText("Exit code 0").className).toContain("text-muted-foreground"); }); }); + +it("replaces an expanded cached transcript with the removal notice while retaining command and status", () => { + const original = executeMessage("complete", retainedOutputSummary({ exit_code: 0 })); + useShellOutputMock.mockReturnValue( + hookResult({ + snapshot: { + message_id: original.id, + status: "complete", + updated_at: outputUpdatedAt, + output: { stdout: "removed secret output" }, + }, + }), + ); + const view = render(); + openOutput(); + expect(screen.getByText("removed secret output")).toBeTruthy(); + view.rerender( + , + ); + expect(screen.queryByText("removed secret output")).toBeNull(); + expect(screen.getByText(/Tool details removed on/)).toBeTruthy(); + expect(screen.getByTestId(COMMAND_TEST_ID).textContent).toBe("printf normalized-command"); + expect(screen.getByLabelText("Command succeeded")).toBeTruthy(); +}); diff --git a/apps/web/components/task/chat/messages/tool-execute-message.tsx b/apps/web/components/task/chat/messages/tool-execute-message.tsx index 201956aa96..cddccfd4f3 100644 --- a/apps/web/components/task/chat/messages/tool-execute-message.tsx +++ b/apps/web/components/task/chat/messages/tool-execute-message.tsx @@ -1,5 +1,7 @@ "use client"; +import { payloadRetentionMarker } from "@/lib/utils/tool-payload-retention"; +import { ToolPayloadRemovedNotice } from "./tool-payload-removed-message"; import { memo } from "react"; import { IconCheck, IconTerminal, IconX } from "@tabler/icons-react"; import { GridSpinner } from "@/components/grid-spinner"; @@ -89,13 +91,17 @@ export const ToolExecuteMessage = memo(function ToolExecuteMessage({
)} - {hasProjectedShellOutput(shellExec?.output) && ( - + {payloadRetentionMarker(comment.metadata) ? ( + + ) : ( + hasProjectedShellOutput(shellExec?.output) && ( + + ) )} diff --git a/apps/web/components/task/chat/messages/tool-payload-removed-message.tsx b/apps/web/components/task/chat/messages/tool-payload-removed-message.tsx new file mode 100644 index 0000000000..54c361b259 --- /dev/null +++ b/apps/web/components/task/chat/messages/tool-payload-removed-message.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from "react-i18next"; +import { IconTool } from "@tabler/icons-react"; +import type { Message } from "@/lib/types/http"; +import { payloadRetentionMarker } from "@/lib/utils/tool-payload-retention"; +import { normalizeToolCallStatus } from "@/lib/utils/tool-call-status"; +import { formatDateTime } from "@/lib/i18n/formats"; +import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions"; +import type { ToolCallMetadata } from "../types"; + +export function ToolPayloadRemovedNotice({ metadata }: { metadata: unknown }) { + const { t } = useTranslation(); + const marker = payloadRetentionMarker(metadata); + const date = marker?.removed_at; + return ( +

+ {date && parseTurnTimestamp(date) !== null + ? t("task:toolPayloadRemovedAt", { date: formatDateTime(date) }) + : t("task:toolPayloadRemoved")} +

+ ); +} + +function retainedSummary(metadata: ToolCallMetadata | undefined) { + const normalized = metadata?.normalized; + if (!normalized) return undefined; + return ( + normalized.read_file?.file_path || + normalized.modify_file?.file_path || + normalized.code_search?.query || + normalized.http_request?.url || + normalized.generic?.name + ); +} + +export function ToolPayloadRemovedMessage({ comment }: { comment: Message }) { + const { t } = useTranslation(); + const metadata = comment.metadata as ToolCallMetadata | undefined; + const status = normalizeToolCallStatus(metadata?.status); + const summary = retainedSummary(metadata); + const title = + typeof comment.metadata?.title === "string" ? comment.metadata.title : comment.content; + const statuses = { + complete: "task:completed", + error: "task:error", + cancelled: "task:cancelled", + running: "task:running", + pending: "system:toolPayload.pending", + } as const; + return ( +
+ +
+
+ {title} + {status && {t(statuses[status])}} +
+ + {summary && ( +

+ {summary} +

+ )} +
+
+ ); +} diff --git a/apps/web/e2e/helpers/tool-payload-retention.ts b/apps/web/e2e/helpers/tool-payload-retention.ts new file mode 100644 index 0000000000..05d8783ce3 --- /dev/null +++ b/apps/web/e2e/helpers/tool-payload-retention.ts @@ -0,0 +1,219 @@ +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { expect, type Locator, type Page } from "@playwright/test"; +import { DatabaseSync } from "./node-sqlite"; +import type { ApiClient } from "./api-client"; +import type { SeedData } from "../fixtures/test-base"; +import type { ToolPayloadRetentionStatus } from "../../lib/types/tool-payload-retention"; + +export const RETENTION_API = "/api/v1/system/database/tool-payload-retention"; +export const RETENTION_ROUTE = "/settings/system/data-storage"; +export const PAYLOAD_TEXT = "retention fixture output\n".repeat(1024); +export const TOOL_COMMAND = "printf retention-fixture"; + +function fixtureDatabase(tmpDir: string) { + if (!/^kandev-e2e-\d+-/.test(path.basename(tmpDir))) { + throw new Error("retention seeding requires a disposable E2E worker directory"); + } + return path.join(tmpDir, "kandev.db"); +} + +type RetentionFixture = { taskId: string; sessionId: string; messageId: string; turnId: string }; + +/** Only the worker's disposable database is opened, never an operator database. */ +export async function seedRetentionTask( + tmpDir: string, + api: ApiClient, + seed: SeedData, + recent: boolean, +): Promise { + const task = await api.createTask( + seed.workspaceId, + recent ? "Recent retention task" : "Old retention task", + { + workflow_id: seed.workflowId, + workflow_step_id: seed.startStepId, + }, + ); + const fixture = { + taskId: task.id, + sessionId: randomUUID(), + messageId: randomUUID(), + turnId: randomUUID(), + }; + const old = new Date(Date.now() - 400 * 86400000).toISOString(); + const activity = recent ? new Date().toISOString() : old; + const metadata = JSON.stringify({ + tool_call_id: randomUUID(), + status: "complete", + retained_fixture: true, + normalized: { + kind: "shell_exec", + shell_exec: { + command: TOOL_COMMAND, + work_dir: "/tmp", + output: { stdout: PAYLOAD_TEXT, stderr: "", exit_code: 0 }, + }, + }, + }); + const db = new DatabaseSync(fixtureDatabase(tmpDir)); + try { + db.exec("PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON; BEGIN IMMEDIATE"); + db.prepare("UPDATE tasks SET state='COMPLETED', created_at=?, updated_at=? WHERE id=?").run( + old, + old, + task.id, + ); + db.prepare( + `INSERT INTO task_sessions (id,task_id,state,started_at,completed_at,updated_at,is_primary) + VALUES (?,?,'COMPLETED',?,?,?,1)`, + ).run(fixture.sessionId, task.id, old, old, old); + db.prepare( + `INSERT INTO task_session_turns (id,task_session_id,task_id,started_at,completed_at,created_at,updated_at) + VALUES (?,?,?,?,?,?,?)`, + ).run(fixture.turnId, fixture.sessionId, task.id, old, old, old, old); + db.prepare( + `INSERT INTO task_session_messages (id,task_session_id,task_id,turn_id,author_type,content,type,metadata,created_at,updated_at) + VALUES (?,?,?,?,'agent',?,'tool_execute',?,?,?)`, + ).run( + fixture.messageId, + fixture.sessionId, + task.id, + fixture.turnId, + TOOL_COMMAND, + metadata, + old, + activity, + ); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } finally { + db.close(); + } + return fixture; +} + +export function readRetentionMessage(tmpDir: string, messageId: string) { + const db = new DatabaseSync(fixtureDatabase(tmpDir), { readOnly: true }); + try { + const row = db + .prepare( + "SELECT id, task_session_id, turn_id, type, content, metadata FROM task_session_messages WHERE id=?", + ) + .get(messageId) as { + id: string; + task_session_id: string; + turn_id: string; + type: string; + content: string; + metadata: string; + }; + expect(row).toBeTruthy(); + return { ...row, metadata: JSON.parse(row.metadata) }; + } finally { + db.close(); + } +} + +export async function retentionStatus(page: Page): Promise { + const response = await page.request.get(RETENTION_API); + expect(response.ok(), await response.text()).toBe(true); + return response.json(); +} + +export async function resetRetention(page: Page) { + const { policy } = await retentionStatus(page); + const response = await page.request.put(RETENTION_API, { + data: { ...policy, enabled: false, age: { value: 3, unit: "months" } }, + }); + expect(response.ok(), await response.text()).toBe(true); +} + +export async function press(locator: Locator, touch = false) { + if (touch) await locator.tap(); + else await locator.click(); +} + +export async function analyzeRetention(page: Page, touch = false) { + const accepted = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `${RETENTION_API}/analyze` && r.request().method() === "POST", + ); + await press(page.getByTestId("tool-payload-analyze"), touch); + const response = await accepted; + expect(response.status()).toBe(202); + const { operation_id: id } = await response.json(); + await expect + .poll( + async () => { + const status = await retentionStatus(page); + return status.last_analysis?.id === id ? status.last_analysis.state : "pending"; + }, + { timeout: 30_000 }, + ) + .toBe("succeeded"); + await expect(page.getByTestId("tool-payload-estimate")).toContainText("Completed"); +} + +export async function activateRetention(page: Page, choice: "backup" | "skip", touch = false) { + await press(page.locator('label[for="tool-payload-enabled"]'), touch); + await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled(); + await expect(page.getByTestId("tool-payload-backup")).not.toBeChecked(); + await expect(page.getByTestId("tool-payload-skip")).not.toBeChecked(); + const choiceTarget = page.getByTestId(`tool-payload-${choice}`).locator(".."); + if (touch) await expectTouchTarget(choiceTarget); + await press(choiceTarget, touch); + if (touch) + await expectTouchTarget(page.getByRole("button", { name: "Save changes", exact: true })); + const saved = page.waitForResponse( + (r) => new URL(r.url()).pathname === RETENTION_API && r.request().method() === "PUT", + ); + await press(page.getByRole("button", { name: "Save changes", exact: true }), touch); + const response = await saved; + expect(response.ok(), await response.text()).toBe(true); + expect(response.request().postDataJSON()).toMatchObject({ enabled: true, backup_choice: choice }); + await expect + .poll(async () => (await retentionStatus(page)).policy.enabled, { timeout: 30_000 }) + .toBe(true); + await page.reload(); + await expect(page.getByTestId("tool-payload-enabled")).toBeChecked(); +} + +export async function runRetention(page: Page, touch = false) { + const accepted = page.waitForResponse( + (r) => new URL(r.url()).pathname === `${RETENTION_API}/run` && r.request().method() === "POST", + ); + await expect(page.getByTestId("tool-payload-run")).toBeEnabled(); + await press(page.getByTestId("tool-payload-run"), touch); + const response = await accepted; + expect(response.status()).toBe(202); + const { operation_id: id } = await response.json(); + await expect + .poll( + async () => { + const status = await retentionStatus(page); + return status.last_run?.id === id ? status.last_run.state : "pending"; + }, + { timeout: 30_000 }, + ) + .toBe("succeeded"); + await expect(page.getByTestId("tool-payload-last-run")).toContainText("Completed"); +} + +export async function expectTouchTarget(locator: Locator) { + // Center the control clear of the sticky settings header and Save bar. + await locator.evaluate((el) => el.scrollIntoView({ block: "center", behavior: "instant" })); + const bounds = await locator.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.height).toBeGreaterThanOrEqual(44); + expect(bounds!.width).toBeGreaterThanOrEqual(44); + expect( + await locator.evaluate((el) => { + const box = el.getBoundingClientRect(); + const hit = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); + return box.left >= 0 && box.right <= innerWidth && (hit === el || el.contains(hit)); + }), + ).toBe(true); +} diff --git a/apps/web/e2e/tests/auth/data-storage-gating-helpers.ts b/apps/web/e2e/tests/auth/data-storage-gating-helpers.ts index 29fd016248..39496c7dc2 100644 --- a/apps/web/e2e/tests/auth/data-storage-gating-helpers.ts +++ b/apps/web/e2e/tests/auth/data-storage-gating-helpers.ts @@ -43,7 +43,7 @@ type Snapshot = { name: string; kind: string }; /** * Creates a manual snapshot as the admin and resolves its filename. Create is - * asynchronous (202 + job id), so poll the listing until the file lands: the + * asynchronous (202 + job id), so wait for the job and inspect its result: the * member specs need a real row to prove the listing survives the gate. */ export async function createSnapshotAsAdmin( @@ -53,19 +53,27 @@ export async function createSnapshotAsAdmin( const created = await adminContext.request.post(`${baseUrl}/api/v1/system/backups`); expect(created.status(), await created.text()).toBe(202); - let name = ""; + const { job_id: jobId } = (await created.json()) as { job_id: string }; + let job: { state: string; message?: string; result?: { name?: string } } = { state: "queued" }; await expect .poll( async () => { - const snapshots = await listSnapshots(adminContext.request, baseUrl); - const manual = snapshots.find((snapshot) => snapshot.kind === "manual"); - name = manual?.name ?? ""; - return name; + const response = await adminContext.request.get(`${baseUrl}/api/v1/system/jobs/${jobId}`); + expect(response.ok(), await response.text()).toBe(true); + job = await response.json(); + return ["succeeded", "failed"].includes(job.state); }, { timeout: 20_000 }, ) - .not.toBe(""); - return name; + .toBe(true); + expect(job.state, `Snapshot job ${jobId}: ${job.message ?? JSON.stringify(job)}`).toBe( + "succeeded", + ); + const name = job.result?.name; + expect(name).toBeTruthy(); + const snapshots = await listSnapshots(adminContext.request, baseUrl); + expect(snapshots).toContainEqual(expect.objectContaining({ name, kind: "manual" })); + return name!; } export async function listSnapshots( @@ -98,6 +106,7 @@ export async function expectMemberApiGating( "/storage/settings", "/storage/runs", "/storage/quarantine", + "/database/tool-payload-retention", ]) { const res = await api.get(`${system}${path}`); expect(res.status(), `GET ${path}: ${await res.text()}`).toBe(200); @@ -110,6 +119,33 @@ export async function expectMemberApiGating( const denials: Array<[string, Promise<{ status(): number; text(): Promise }>]> = [ ["POST /backups", api.post(`${system}/backups`)], + [ + "PUT /database/tool-payload-retention", + api.put(`${system}/database/tool-payload-retention`, { + data: { + enabled: true, + age: { value: 3, unit: "months" }, + revision: 1, + backup_choice: "skip", + }, + }), + ], + [ + "POST /database/tool-payload-retention/analyze", + api.post(`${system}/database/tool-payload-retention/analyze`, { + data: { age: { value: 3, unit: "months" } }, + }), + ], + [ + "POST /database/tool-payload-retention/run", + api.post(`${system}/database/tool-payload-retention/run`, { data: { revision: 1 } }), + ], + [ + "POST /database/tool-payload-retention/cancel", + api.post(`${system}/database/tool-payload-retention/cancel`, { + data: { operation_id: "member-denied" }, + }), + ], [ "POST /backups/:name/restore", api.post(`${system}/backups/${snapshotName}/restore`, { data: { confirm: "RESTORE" } }), diff --git a/apps/web/e2e/tests/auth/mobile-system-data-storage-member-gating.spec.ts b/apps/web/e2e/tests/auth/mobile-system-data-storage-member-gating.spec.ts index 221c363a6c..d50cea17f8 100644 --- a/apps/web/e2e/tests/auth/mobile-system-data-storage-member-gating.spec.ts +++ b/apps/web/e2e/tests/auth/mobile-system-data-storage-member-gating.spec.ts @@ -66,6 +66,16 @@ test.describe.serial("Data & Logs and Storage member gating (mobile)", () => { timeout: 15_000, }); await expect(page.getByTestId("system-backups-admin-only")).toBeVisible(); + await expect(page.getByTestId("tool-payload-retention-card")).toBeVisible(); + for (const id of [ + "tool-payload-enabled", + "tool-payload-age", + "tool-payload-unit", + "tool-payload-analyze", + "tool-payload-run", + ]) { + await expect(page.getByTestId(id)).toBeDisabled(); + } await expect(page.getByTestId("system-backups-create")).toHaveCount(0); await expect(page.getByTestId("system-backups-download")).toHaveCount(0); await expect(page.getByTestId("system-backups-restore")).toHaveCount(0); @@ -125,6 +135,8 @@ test.describe.serial("Data & Logs and Storage member gating (mobile)", () => { expect(box!.height).toBeGreaterThanOrEqual(44); } await expect(page.getByTestId("system-backups-admin-only")).toHaveCount(0); + await expect(page.getByTestId("tool-payload-analyze")).toBeEnabled(); + await expect(page.getByTestId("tool-payload-enabled")).toBeEnabled(); await page.goto(STORAGE_ROUTE); await expect(page.getByTestId("storage-analyze")).toBeEnabled(); diff --git a/apps/web/e2e/tests/auth/system-data-storage-member-gating.spec.ts b/apps/web/e2e/tests/auth/system-data-storage-member-gating.spec.ts index ee0da1c366..53909f099c 100644 --- a/apps/web/e2e/tests/auth/system-data-storage-member-gating.spec.ts +++ b/apps/web/e2e/tests/auth/system-data-storage-member-gating.spec.ts @@ -62,6 +62,16 @@ test.describe.serial("Data & Logs and Storage member gating", () => { await expect(row.first()).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("system-backups-name").first()).toHaveText(snapshotName); await expect(page.getByTestId("system-backups-admin-only")).toBeVisible(); + await expect(page.getByTestId("tool-payload-retention-card")).toBeVisible(); + for (const id of [ + "tool-payload-enabled", + "tool-payload-age", + "tool-payload-unit", + "tool-payload-analyze", + "tool-payload-run", + ]) { + await expect(page.getByTestId(id)).toBeDisabled(); + } // Every backups control that answers 403 is gone, not merely disabled. await expect(page.getByTestId("system-backups-create")).toHaveCount(0); @@ -95,6 +105,8 @@ test.describe.serial("Data & Logs and Storage member gating", () => { await expect(page.getByTestId("system-backups-restore").first()).toBeVisible(); await expect(page.getByTestId("system-backups-delete").first()).toBeVisible(); await expect(page.getByTestId("system-backups-admin-only")).toHaveCount(0); + await expect(page.getByTestId("tool-payload-analyze")).toBeEnabled(); + await expect(page.getByTestId("tool-payload-enabled")).toBeEnabled(); await expect(page.getByTestId("storage-analyze")).toHaveCount(0); await expect(page.getByTestId("storage-run-now")).toHaveCount(0); diff --git a/apps/web/e2e/tests/system/mobile-tool-payload-retention.spec.ts b/apps/web/e2e/tests/system/mobile-tool-payload-retention.spec.ts new file mode 100644 index 0000000000..3aac9ef792 --- /dev/null +++ b/apps/web/e2e/tests/system/mobile-tool-payload-retention.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from "../../fixtures/test-base"; +import { + activateRetention, + analyzeRetention, + expectTouchTarget, + readRetentionMessage, + resetRetention, + RETENTION_ROUTE, + runRetention, + seedRetentionTask, + TOOL_COMMAND, +} from "../../helpers/tool-payload-retention"; + +test.describe("Tool payload retention on phones", () => { + for (const width of [320, 390]) { + test(`completes cleanup by touch at ${width}px without clipping`, async ({ + testPage: page, + backend, + apiClient, + seedData, + }) => { + await page.setViewportSize({ width, height: 844 }); + await resetRetention(page); + const old = await seedRetentionTask(backend.tmpDir, apiClient, seedData, false); + const recent = await seedRetentionTask(backend.tmpDir, apiClient, seedData, true); + const protectedOriginal = readRetentionMessage(backend.tmpDir, recent.messageId); + try { + await page.goto(RETENTION_ROUTE); + await expect(page.getByTestId("tool-payload-enabled")).not.toBeChecked(); + for (const id of ["tool-payload-age", "tool-payload-unit", "tool-payload-analyze"]) { + await expectTouchTarget(page.getByTestId(id)); + } + await expectTouchTarget(page.locator('label[for="tool-payload-enabled"]')); + await expect( + page.getByText("Checks every 24 hours while Kandev is running."), + ).toBeVisible(); + const ageBox = await page.getByTestId("tool-payload-age").boundingBox(); + const unitBox = await page.getByTestId("tool-payload-unit").boundingBox(); + expect(Math.abs(ageBox!.y - unitBox!.y)).toBeLessThan(2); + await analyzeRetention(page, true); + await page.getByTestId("tool-payload-retention-card").screenshot({ + path: `/tmp/compaction-mobile-${width}.png`, + }); + const details = page.getByTestId("tool-payload-estimate").locator("details"); + await expect(details).not.toHaveAttribute("open"); + await details.locator("summary").tap(); + await expect(details).toHaveAttribute("open"); + await details.locator("summary").tap(); + await page.getByTestId("tool-payload-age").fill("26"); + await page.getByTestId("tool-payload-unit").tap(); + await page.getByRole("option", { name: "Weeks", exact: true }).tap(); + await expect(page.getByTestId("tool-payload-estimate")).toContainText( + "This estimate is stale", + ); + await analyzeRetention(page, true); + await activateRetention(page, "skip", true); + await expect(page.getByTestId("tool-payload-age")).toHaveValue("26"); + await expectTouchTarget(page.getByTestId("tool-payload-run")); + await runRetention(page, true); + const reduced = readRetentionMessage(backend.tmpDir, old.messageId); + expect(reduced.metadata.payload_retention.version).toBe(1); + expect(reduced.metadata.normalized.shell_exec.output).not.toHaveProperty("stdout"); + expect(reduced.metadata.normalized.shell_exec.command).toBe(TOOL_COMMAND); + expect(readRetentionMessage(backend.tmpDir, recent.messageId)).toEqual(protectedOriginal); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)) + .toBe(true); + await page.reload(); + await expect(page.getByTestId("tool-payload-enabled")).toBeChecked(); + await expect(page.getByTestId("tool-payload-last-run")).toContainText("Completed"); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)) + .toBe(true); + await page.goto(`/t/${old.taskId}`); + await expect(page.getByTestId("tool-execute-command")).toHaveText(TOOL_COMMAND); + await expect(page.getByText(/^Tool details removed on /)).toBeVisible(); + await page.reload(); + await expect(page.getByText(/^Tool details removed on /)).toBeVisible(); + } finally { + await resetRetention(page); + await apiClient.deleteTask(old.taskId); + await apiClient.deleteTask(recent.taskId); + } + }); + } +}); diff --git a/apps/web/e2e/tests/system/tool-payload-retention.spec.ts b/apps/web/e2e/tests/system/tool-payload-retention.spec.ts new file mode 100644 index 0000000000..bf96f7be55 --- /dev/null +++ b/apps/web/e2e/tests/system/tool-payload-retention.spec.ts @@ -0,0 +1,131 @@ +import path from "node:path"; +import { test, expect } from "../../fixtures/test-base"; +import { DatabaseSync } from "../../helpers/node-sqlite"; +import { + activateRetention, + analyzeRetention, + PAYLOAD_TEXT, + readRetentionMessage, + resetRetention, + retentionStatus, + RETENTION_ROUTE, + runRetention, + seedRetentionTask, + TOOL_COMMAND, +} from "../../helpers/tool-payload-retention"; + +test.describe("Tool payload retention", () => { + for (const choice of ["backup", "skip"] as const) { + test(`analyzes while disabled and cleans old payloads after explicit ${choice}`, async ({ + testPage: page, + backend, + apiClient, + seedData, + }) => { + await resetRetention(page); + const old = await seedRetentionTask(backend.tmpDir, apiClient, seedData, false); + const recent = await seedRetentionTask(backend.tmpDir, apiClient, seedData, true); + const original = readRetentionMessage(backend.tmpDir, old.messageId); + const protectedOriginal = readRetentionMessage(backend.tmpDir, recent.messageId); + const backupsBefore = await page.request.get("/api/v1/system/backups"); + expect(backupsBefore.ok()).toBe(true); + const before = (await backupsBefore.json()).snapshots as { name: string }[]; + try { + await page.goto(RETENTION_ROUTE); + await expect(page.getByTestId("tool-payload-enabled")).not.toBeChecked(); + const age = await page.getByTestId("tool-payload-age").boundingBox(); + const unit = await page.getByTestId("tool-payload-unit").boundingBox(); + expect(age!.width).toBeLessThanOrEqual(90); + expect(unit!.width).toBeLessThanOrEqual(140); + expect(Math.abs(age!.y - unit!.y)).toBeLessThan(2); + const toggle = await page.getByTestId("tool-payload-enabled").boundingBox(); + const label = await page.getByText("Automatic compaction", { exact: true }).boundingBox(); + expect(label!.x - toggle!.x - toggle!.width).toBeLessThanOrEqual(12); + await expect( + page.getByText("Checks every 24 hours while Kandev is running."), + ).toBeVisible(); + await expect(page.getByTestId("tool-payload-run")).toBeDisabled(); + await analyzeRetention(page); + await page.getByTestId("tool-payload-retention-card").screenshot({ + path: `/tmp/compaction-desktop-${choice}.png`, + }); + const analyzed = await retentionStatus(page); + expect(analyzed.policy.enabled).toBe(false); + expect(analyzed.last_analysis?.eligible_messages).toBeGreaterThanOrEqual(1); + expect(analyzed.last_analysis?.payload_bytes).toBeGreaterThan(1024); + expect(readRetentionMessage(backend.tmpDir, old.messageId)).toEqual(original); + expect(readRetentionMessage(backend.tmpDir, recent.messageId)).toEqual(protectedOriginal); + + await activateRetention(page, choice); + await expect(page.getByTestId("tool-payload-age")).toHaveValue("3"); + await expect(page.getByTestId("tool-payload-unit")).toContainText("Months"); + const listing = await page.request.get("/api/v1/system/backups"); + expect(listing.ok()).toBe(true); + const after = (await listing.json()).snapshots as { name: string }[]; + const added = after.filter( + (snapshot) => !before.some((item) => item.name === snapshot.name), + ); + if (choice === "backup") { + expect(added.length).toBeGreaterThan(0); + const snapshot = new DatabaseSync(path.join(backend.tmpDir, "backups", added[0].name), { + readOnly: true, + }); + try { + const row = snapshot + .prepare("SELECT metadata FROM task_session_messages WHERE id=?") + .get(old.messageId) as { metadata: string }; + expect(JSON.parse(row.metadata)).toEqual(original.metadata); + } finally { + snapshot.close(); + } + } else expect(added).toHaveLength(0); + + await runRetention(page); + const reduced = readRetentionMessage(backend.tmpDir, old.messageId); + expect(reduced).toMatchObject({ + id: old.messageId, + task_session_id: old.sessionId, + turn_id: old.turnId, + type: "tool_execute", + content: TOOL_COMMAND, + metadata: { + status: "complete", + retained_fixture: true, + tool_call_id: original.metadata.tool_call_id, + payload_retention: { version: 1 }, + normalized: { + kind: "shell_exec", + shell_exec: { command: TOOL_COMMAND, output: { exit_code: 0 } }, + }, + }, + }); + expect(reduced.metadata.normalized.shell_exec.output).not.toHaveProperty("stdout"); + expect(reduced.metadata.normalized.shell_exec.output).not.toHaveProperty("stderr"); + expect(readRetentionMessage(backend.tmpDir, recent.messageId)).toEqual(protectedOriginal); + await page.goto(`/t/${old.taskId}`); + await expect(page.getByTestId("tool-execute-command")).toHaveText(TOOL_COMMAND); + await expect(page.getByText(/^Tool details removed on /)).toBeVisible(); + await expect(page.getByLabel("Command succeeded", { exact: true })).toBeVisible(); + await expect(page.getByText(PAYLOAD_TEXT.trim(), { exact: true })).toHaveCount(0); + await page.reload(); + await expect(page.getByText(/^Tool details removed on /)).toBeVisible(); + await expect(page.getByTestId("tool-execute-command")).toHaveText(TOOL_COMMAND); + } finally { + await resetRetention(page); + const listing = await page.request.get("/api/v1/system/backups"); + expect(listing.ok()).toBe(true); + const snapshots = (await listing.json()).snapshots as { name: string }[]; + for (const snapshot of snapshots.filter( + (item) => !before.some((saved) => saved.name === item.name), + )) { + const removed = await page.request.delete( + `/api/v1/system/backups/${encodeURIComponent(snapshot.name)}`, + ); + expect(removed.ok(), await removed.text()).toBe(true); + } + await apiClient.deleteTask(old.taskId); + await apiClient.deleteTask(recent.taskId); + } + }); + } +}); diff --git a/apps/web/hooks/domains/session/use-shell-command-output.test.ts b/apps/web/hooks/domains/session/use-shell-command-output.test.ts index a120fd4a5f..b40a375776 100644 --- a/apps/web/hooks/domains/session/use-shell-command-output.test.ts +++ b/apps/web/hooks/domains/session/use-shell-command-output.test.ts @@ -263,3 +263,23 @@ describe("useShellCommandOutput terminal lifecycle", () => { }, ); }); + +it("drops cached output and stops retrying when the server reports removal", async () => { + const { ApiError } = await import("@/lib/api/client"); + fetchOutputMock + .mockResolvedValueOnce(snapshot("running", "deleted payload")) + .mockRejectedValue(new ApiError("gone", 410, { code: "tool_payload_removed" })); + const { result } = renderHook(() => + useShellCommandOutput({ + sessionId: "session-1", + messageId: "message-1", + isOpen: true, + messageStatus: "running", + }), + ); + await flushPromises(); + await act(async () => vi.advanceTimersByTime(1000)); + expect(result.current.snapshot).toBeNull(); + await act(async () => vi.advanceTimersByTime(10000)); + expect(fetchOutputMock).toHaveBeenCalledTimes(2); +}); diff --git a/apps/web/hooks/domains/session/use-shell-command-output.ts b/apps/web/hooks/domains/session/use-shell-command-output.ts index 450544689a..0d8e3ca3c1 100644 --- a/apps/web/hooks/domains/session/use-shell-command-output.ts +++ b/apps/web/hooks/domains/session/use-shell-command-output.ts @@ -1,3 +1,4 @@ +import { isToolPayloadRemovedError } from "@/lib/utils/tool-payload-retention"; import { useCallback, useEffect, useRef, useState } from "react"; import { fetchShellCommandOutput, @@ -104,6 +105,13 @@ export function useShellCommandOutput({ } catch (requestError) { if (operation.generation !== generation || controller.signal.aborted) return; operation.controller = null; + if (isToolPayloadRemovedError(requestError)) { + snapshotRef.current = null; + setSnapshot(null); + setError(requestError as Error); + setIsLoading(false); + return; + } failureCount += 1; setError(asError(requestError)); setIsLoading(false); diff --git a/apps/web/hooks/domains/system/use-tool-payload-retention-draft.ts b/apps/web/hooks/domains/system/use-tool-payload-retention-draft.ts new file mode 100644 index 0000000000..706b7e151b --- /dev/null +++ b/apps/web/hooks/domains/system/use-tool-payload-retention-draft.ts @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import type { + ToolPayloadAge, + ToolPayloadBackupChoice, + ToolPayloadPolicy, +} from "@/lib/types/tool-payload-retention"; +import type { useToolPayloadRetention } from "./use-tool-payload-retention"; + +export const sameAge = (a: ToolPayloadAge, b: ToolPayloadAge) => + a.unit === b.unit && a.value === b.value; +export function validAge(age: ToolPayloadAge) { + return ( + Number.isInteger(age.value) && age.value >= 1 && age.value <= (age.unit === "weeks" ? 520 : 120) + ); +} +function cutoff(age: ToolPayloadAge) { + const date = new Date(); + if (age.unit === "weeks") date.setUTCDate(date.getUTCDate() - age.value * 7); + else { + const day = date.getUTCDate(); + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() - age.value); + const last = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate(); + date.setUTCDate(Math.min(day, last)); + } + return date.getTime(); +} +const serialize = (value: unknown) => JSON.stringify(value); + +function needsBackupReview(draft: ToolPayloadPolicy | null, saved?: ToolPayloadPolicy) { + if (!draft?.enabled || !saved) return false; + if (!saved.enabled) return true; + return !sameAge(draft.age, saved.age) && cutoff(draft.age) > cutoff(saved.age); +} +function acceptSavedDraft( + current: ToolPayloadPolicy | null, + submitted: ToolPayloadPolicy, + saved: ToolPayloadPolicy, +) { + if (!current || serialize(current) === serialize(submitted)) return saved; + return { ...current, revision: saved.revision }; +} +function validationKey(canEdit: boolean, invalid: boolean, needsChoice: boolean, choice: string) { + if (!canEdit) return "system:toolPayload.adminOnly"; + if (invalid) return "system:toolPayload.invalidAge"; + if (needsChoice && !choice) return "system:toolPayload.chooseBackup"; + return null; +} + +function toggleEnabled( + current: ToolPayloadPolicy | null, + enabled: boolean, + saved?: ToolPayloadPolicy, +) { + if (!current) return current; + const age = !enabled && !validAge(current.age) && saved ? saved.age : current.age; + return { ...current, enabled, age }; +} + +export function useToolPayloadRetentionDraft( + remote: ReturnType, + admin: boolean, +) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(null); + const [choice, setChoice] = useState(""); + const saved = remote.status?.policy; + const baseline = useRef(null); + useEffect(() => { + if (!saved) return; + setDraft((current) => + !current || serialize(current) === serialize(baseline.current) ? saved : current, + ); + baseline.current = saved; + }, [saved]); + const dirty = Boolean(draft && saved && serialize(draft) !== serialize(saved)); + const needsChoice = needsBackupReview(draft, saved); + const invalid = !draft || !validAge(draft.age); + const canEdit = admin && Boolean(remote.status?.supported); + const validation = validationKey(canEdit, invalid, needsChoice, choice); + const invalidReason = validation ? t(validation) : undefined; + useSettingsSaveContributor({ + id: "system:tool-payload-retention", + order: 26, + revision: serialize({ draft, choice }), + isDirty: dirty, + canSave: canEdit && !remote.pending && !invalid && (!needsChoice || Boolean(choice)), + invalidReason, + save: async () => { + if (!draft || invalid || !canEdit || (needsChoice && !choice)) return; + const submitted = draft; + const next = await remote.save({ + ...submitted, + ...(needsChoice && choice ? { backup_choice: choice } : {}), + }); + setDraft((current) => acceptSavedDraft(current, submitted, next.policy)); + setChoice(""); + }, + discard: () => { + if (saved) setDraft(saved); + setChoice(""); + void remote.refresh(); + }, + }); + const setEnabled = (enabled: boolean) => + setDraft((current) => toggleEnabled(current, enabled, saved)); + return { + setEnabled, + draft, + setDraft, + choice, + setChoice, + needsChoice, + dirty, + invalid, + canEdit, + invalidReason, + }; +} diff --git a/apps/web/hooks/domains/system/use-tool-payload-retention.test.ts b/apps/web/hooks/domains/system/use-tool-payload-retention.test.ts new file mode 100644 index 0000000000..4065872c35 --- /dev/null +++ b/apps/web/hooks/domains/system/use-tool-payload-retention.test.ts @@ -0,0 +1,164 @@ +import { act, renderHook, waitFor, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import * as api from "@/lib/api/domains/tool-payload-retention-api"; +import { useToolPayloadRetention } from "./use-tool-payload-retention"; +import type { + ToolPayloadOperation, + ToolPayloadRetentionStatus, +} from "@/lib/types/tool-payload-retention"; +vi.mock("@/lib/api/domains/tool-payload-retention-api"); +const status: ToolPayloadRetentionStatus = { + supported: true, + policy: { enabled: false, age: { value: 3, unit: "months" }, revision: 0 }, + preparation: { state: "none", choice: "" }, +}; +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(api.fetchToolPayloadRetention).mockResolvedValue(status); +}); +afterEach(cleanup); +it("loads the disabled policy without launching an analysis", async () => { + const { result } = renderHook(useToolPayloadRetention); + await waitFor(() => expect(result.current.status).toEqual(status)); + expect(api.analyzeToolPayloadRetention).not.toHaveBeenCalled(); +}); +it("does not let a GET started before saving overwrite the PUT response", async () => { + const { result } = renderHook(useToolPayloadRetention); + await waitFor(() => expect(result.current.status).toEqual(status)); + const old = deferred(); + vi.mocked(api.fetchToolPayloadRetention).mockReturnValueOnce(old.promise); + let reload!: Promise; + act(() => { + reload = result.current.reload(); + }); + const saved = { + ...status, + policy: { ...status.policy, revision: 1, age: { value: 4, unit: "months" as const } }, + }; + vi.mocked(api.saveToolPayloadRetention).mockResolvedValue(saved); + await act(async () => { + await result.current.save(saved.policy); + }); + await act(async () => { + old.resolve(status); + await reload; + }); + expect(result.current.status).toEqual(saved); +}); + +it("serializes same-tick commands and keeps pending until the initiating request settles", async () => { + const { result } = renderHook(useToolPayloadRetention); + await waitFor(() => expect(result.current.status).toEqual(status)); + const request = deferred<{ operation_id: string }>(); + vi.mocked(api.analyzeToolPayloadRetention).mockReturnValueOnce(request.promise); + let first!: Promise; + await act(async () => { + first = result.current.analyze(status.policy.age); + await expect(result.current.analyze(status.policy.age)).rejects.toMatchObject({ status: 409 }); + await result.current.reload(); + }); + expect(result.current.pending).toBe(true); + expect(api.analyzeToolPayloadRetention).toHaveBeenCalledTimes(1); + await act(async () => { + request.resolve({ operation_id: "scan" }); + await first; + }); + expect(result.current.pending).toBe(false); + expect(result.current.active).toBe(true); +}); +it("keeps saved policy on failure and releases the mutation lock for retry", async () => { + const { result } = renderHook(useToolPayloadRetention); + await waitFor(() => expect(result.current.status).toEqual(status)); + const error = new Error("offline"); + vi.mocked(api.saveToolPayloadRetention) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(status); + await act(async () => { + await expect(result.current.save(status.policy)).rejects.toBe(error); + }); + expect(result.current.status).toEqual(status); + expect(result.current.pending).toBe(false); + expect(result.current.error).toBe(error); + await act(async () => { + await result.current.save(status.policy); + }); + expect(result.current.error).toBeNull(); +}); +it("cancels the accepted operation and applies the returned status", async () => { + const { result } = renderHook(useToolPayloadRetention); + await waitFor(() => expect(result.current.status).toEqual(status)); + vi.mocked(api.runToolPayloadRetention).mockResolvedValue({ operation_id: "cleanup" }); + await act(async () => { + await result.current.run(0); + }); + vi.mocked(api.cancelToolPayloadRetention).mockResolvedValue(status); + await act(async () => { + await result.current.cancel("cleanup"); + }); + expect(result.current.active).toBe(false); + expect(result.current.acceptedId).toBeNull(); +}); + +it("polls an accepted command and clears pending even when bounded history replaced its id", async () => { + vi.useFakeTimers(); + try { + const { result, unmount } = renderHook(useToolPayloadRetention); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const operation: ToolPayloadOperation = { + id: "already-finished", + kind: "analysis", + state: "running", + age: status.policy.age, + scanned: 0, + eligible_tasks: 0, + eligible_messages: 0, + removed_messages: 0, + payload_bytes: 0, + skipped: {}, + cutoff: "2026-01-01T00:00:00.000Z", + started_at: "2026-01-01T00:00:00.000Z", + }; + const replacement: ToolPayloadOperation = { + ...operation, + id: "replacement", + state: "succeeded", + finished_at: "2026-01-01T00:01:00.000Z", + }; + vi.mocked(api.fetchToolPayloadRetention) + .mockResolvedValueOnce({ ...status, operation }) + .mockResolvedValue({ ...status, last_analysis: replacement }); + vi.mocked(api.analyzeToolPayloadRetention).mockResolvedValue({ + operation_id: "already-finished", + }); + await act(async () => { + await result.current.analyze(status.policy.age); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.active).toBe(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(result.current.active).toBe(false); + expect(result.current.acceptedId).toBeNull(); + const calls = vi.mocked(api.fetchToolPayloadRetention).mock.calls.length; + unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + expect(api.fetchToolPayloadRetention).toHaveBeenCalledTimes(calls); + } finally { + vi.useRealTimers(); + } +}); diff --git a/apps/web/hooks/domains/system/use-tool-payload-retention.ts b/apps/web/hooks/domains/system/use-tool-payload-retention.ts new file mode 100644 index 0000000000..82a54f9e51 --- /dev/null +++ b/apps/web/hooks/domains/system/use-tool-payload-retention.ts @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ApiError } from "@/lib/api/client"; +import * as api from "@/lib/api/domains/tool-payload-retention-api"; +import type { + ToolPayloadAge, + ToolPayloadPolicyUpdate, + ToolPayloadRetentionStatus, +} from "@/lib/types/tool-payload-retention"; + +type Lifetime = { + epoch: number; + mounted: boolean; + mutating: boolean; + reading: Promise | null; + generation: number; +}; +type AcceptedOperation = { id: string; kind: "analysis" | "cleanup" }; +type Updates = { pending: (value: boolean) => void; error: (cause: unknown) => void }; + +function operationObserved(status: ToolPayloadRetentionStatus, accepted: AcceptedOperation) { + if (status.operation?.id === accepted.id) return true; + const latest = accepted.kind === "analysis" ? status.last_analysis : status.last_run; + return latest != null; +} + +function loadStatus( + owner: Lifetime, + accept: (value: ToolPayloadRetentionStatus) => void, + fail: Updates["error"], +) { + if (owner.mutating) return Promise.resolve(); + if (owner.reading) return owner.reading; + const { epoch, generation } = owner; + const current = () => owner.mounted && owner.epoch === epoch && owner.generation === generation; + const request = api + .fetchToolPayloadRetention() + .then((next) => { + if (current()) accept(next); + }) + .catch((cause: unknown) => { + if (current()) fail(cause); + }) + .finally(() => { + if (owner.reading === request) owner.reading = null; + }); + owner.reading = request; + return request; +} +async function performMutation( + owner: Lifetime, + request: () => Promise, + accept: (value: T) => void, + updates: Updates, +) { + // i18n-exempt: machine-only conflict; the card renders a translated error category. + if (owner.mutating) throw new ApiError("busy", 409, { code: "busy" }); + owner.mutating = true; + owner.generation++; + owner.reading = null; + const { epoch } = owner; + const current = () => owner.mounted && owner.epoch === epoch; + updates.pending(true); + updates.error(null); + try { + const result = await request(); + if (current()) accept(result); + return result; + } catch (cause) { + if (current()) updates.error(cause); + throw cause; + } finally { + owner.mutating = false; + if (current()) updates.pending(false); + } +} +function statusPollingInterval(active: boolean, preparing: boolean) { + if (preparing) return 2000; + if (active) return 5000; + return 30000; +} +function useStatusPolling(reload: () => Promise, active: boolean, preparing: boolean) { + const interval = statusPollingInterval(active, preparing); + useEffect(() => { + let stopped = false; + let timer: ReturnType; + const poll = async () => { + await reload(); + if (!stopped) timer = setTimeout(poll, interval); + }; + timer = setTimeout(poll, interval); + return () => { + stopped = true; + clearTimeout(timer); + }; + }, [interval, reload]); +} +function useAcceptedOperationRefresh(acceptedId: string | null, reload: () => Promise) { + useEffect(() => { + if (!acceptedId) return; + const timer = setTimeout(() => void reload(), 0); + return () => clearTimeout(timer); + }, [acceptedId, reload]); +} +function useRetentionLifetime(owner: { current: Lifetime }, reload: () => Promise) { + useEffect(() => { + const lifetime = owner.current; + lifetime.mounted = true; + void reload(); + return () => { + lifetime.mounted = false; + lifetime.epoch++; + lifetime.reading = null; + }; + }, [owner, reload]); +} +export function useToolPayloadRetention() { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [acceptedId, setAcceptedId] = useState(null); + const acceptedOperation = useRef(null); + const owner = useRef({ + epoch: 0, + mounted: false, + mutating: false, + reading: null, + generation: 0, + }); + const reload = useCallback( + () => + loadStatus( + owner.current, + (next) => { + setStatus(next); + const accepted = acceptedOperation.current; + if (!accepted || operationObserved(next, accepted)) { + acceptedOperation.current = null; + setAcceptedId(null); + } + }, + setError, + ), + [], + ); + const refresh = useCallback(() => { + setError(null); + return reload(); + }, [reload]); + useAcceptedOperationRefresh(acceptedId, reload); + useRetentionLifetime(owner, reload); + const preparing = + status?.preparation.state === "pending" || status?.preparation.state === "running"; + const active = Boolean(acceptedId || preparing || status?.operation?.state === "running"); + useStatusPolling(reload, active, preparing); + const perform = useCallback( + (request: () => Promise, accept: (value: T) => void) => + performMutation(owner.current, request, accept, { pending: setPending, error: setError }), + [], + ); + const acceptStatus = useCallback((next: ToolPayloadRetentionStatus) => { + acceptedOperation.current = null; + setStatus(next); + setAcceptedId(null); + }, []); + const acceptOperation = useCallback( + (result: { operation_id: string }, kind: AcceptedOperation["kind"]) => { + acceptedOperation.current = { id: result.operation_id, kind }; + setAcceptedId(result.operation_id); + }, + [], + ); + const save = useCallback( + (policy: ToolPayloadPolicyUpdate) => + perform(() => api.saveToolPayloadRetention(policy), acceptStatus), + [perform, acceptStatus], + ); + const analyze = useCallback( + (age: ToolPayloadAge) => + perform( + () => api.analyzeToolPayloadRetention(age), + (result) => acceptOperation(result, "analysis"), + ), + [perform, acceptOperation], + ); + const run = useCallback( + (revision: number) => + perform( + () => api.runToolPayloadRetention(revision), + (result) => acceptOperation(result, "cleanup"), + ), + [perform, acceptOperation], + ); + const cancel = useCallback( + (id: string) => perform(() => api.cancelToolPayloadRetention(id), acceptStatus), + [perform, acceptStatus], + ); + return { + status, + error, + pending, + active, + preparing, + acceptedId, + reload, + refresh, + save, + analyze, + run, + cancel, + }; +} diff --git a/apps/web/lib/api/domains/tool-payload-retention-api.ts b/apps/web/lib/api/domains/tool-payload-retention-api.ts new file mode 100644 index 0000000000..cac491cfab --- /dev/null +++ b/apps/web/lib/api/domains/tool-payload-retention-api.ts @@ -0,0 +1,26 @@ +import { fetchJson } from "@/lib/api/client"; +import type { + ToolPayloadAge, + ToolPayloadPolicyUpdate, + ToolPayloadRetentionStatus, +} from "@/lib/types/tool-payload-retention"; + +const BASE = "/api/v1/system/database/tool-payload-retention"; +export const fetchToolPayloadRetention = () => + fetchJson(BASE, { cache: "no-store" }); +export const saveToolPayloadRetention = (policy: ToolPayloadPolicyUpdate) => + fetchJson(BASE, { + init: { method: "PUT", body: JSON.stringify(policy) }, + }); +export const analyzeToolPayloadRetention = (age: ToolPayloadAge) => + fetchJson<{ operation_id: string }>(`${BASE}/analyze`, { + init: { method: "POST", body: JSON.stringify({ age }) }, + }); +export const runToolPayloadRetention = (revision: number) => + fetchJson<{ operation_id: string }>(`${BASE}/run`, { + init: { method: "POST", body: JSON.stringify({ revision }) }, + }); +export const cancelToolPayloadRetention = (operation_id: string) => + fetchJson(`${BASE}/cancel`, { + init: { method: "POST", body: JSON.stringify({ operation_id }) }, + }); diff --git a/apps/web/lib/settings-discovery/catalog/system.ts b/apps/web/lib/settings-discovery/catalog/system.ts index 82405c6573..78c49697c5 100644 --- a/apps/web/lib/settings-discovery/catalog/system.ts +++ b/apps/web/lib/settings-discovery/catalog/system.ts @@ -10,6 +10,7 @@ export const SYSTEM_ABOUT_SETTINGS_HREF = `${SYSTEM_SETTINGS_HREF}/about`; export const SYSTEM_SETTINGS_TARGETS = { database: "setting-system-database", retention: "setting-system-retention", + toolPayloadRetention: "setting-system-tool-payload-retention", backups: "setting-system-backups", logs: "setting-system-logs", licenses: "setting-system-licenses", @@ -70,6 +71,16 @@ export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ targetId: SYSTEM_SETTINGS_TARGETS.retention, order: 622, }, + { + id: "system-tool-payload-retention", + kind: "section", + labelKey: "system:toolPayload.title", + parentId: SYSTEM_DATA_STORAGE_DISCOVERY_ID, + groupId: "system", + href: SYSTEM_DATA_STORAGE_SETTINGS_HREF, + targetId: SYSTEM_SETTINGS_TARGETS.toolPayloadRetention, + order: 622.5, + }, { id: "system-backups", kind: "section", diff --git a/apps/web/lib/state/slices/session/message-signature.ts b/apps/web/lib/state/slices/session/message-signature.ts index c58457ab09..6cb6c09e37 100644 --- a/apps/web/lib/state/slices/session/message-signature.ts +++ b/apps/web/lib/state/slices/session/message-signature.ts @@ -1,3 +1,4 @@ +import { payloadRetentionMarker } from "@/lib/utils/tool-payload-retention"; import type { Message } from "@/lib/types/http"; const SEP = "\u0000"; @@ -62,7 +63,7 @@ export function signatureOf(message: Message): string { const cached = signatureCache.get(message); if (cached !== undefined) return cached; const signature = message.updated_at - ? `u:${message.updated_at}:p${message.prompt_index ?? ""}` + ? `u:${message.updated_at}:p${message.prompt_index ?? ""}:r${JSON.stringify(payloadRetentionMarker(message.metadata))}` : contentHashSignature(message); signatureCache.set(message, signature); return signature; @@ -88,6 +89,13 @@ export function reconcileMessages(prev: Message[] | undefined, next: Message[]): // Carry forward a known prompt_index when the incoming payload omits it. const carried = next.map((message) => { const previous = prevById.get(message.id); + if ( + previous && + payloadRetentionMarker(previous.metadata) && + !payloadRetentionMarker(message.metadata) + ) { + message = { ...message, metadata: previous.metadata }; + } if ( previous && message.prompt_index === undefined && diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts index 245076d35a..8e3480497d 100644 --- a/apps/web/lib/state/slices/session/session-slice.ts +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -1,3 +1,4 @@ +import { payloadRetentionMarker } from "@/lib/utils/tool-payload-retention"; /* eslint-disable max-lines -- session state intentionally keeps its coordinated actions together. */ import type { StateCreator } from "zustand"; import { original } from "immer"; @@ -74,6 +75,12 @@ function applyMessageMeta( // eslint-disable-next-line @typescript-eslint/no-explicit-any function mergeMessageFields(target: Record, source: Record) { for (const key of Object.keys(source)) { + if ( + key === "metadata" && + payloadRetentionMarker(target.metadata) && + !payloadRetentionMarker(source.metadata) + ) + continue; if (source[key] !== undefined) { target[key] = source[key]; } @@ -286,7 +293,10 @@ function buildMessageActions(set: ImmerSet) { meta?: Parameters[2], ) => set((draft) => { - draft.messages.bySession[sessionId] = messages; + draft.messages.bySession[sessionId] = reconcileMessages( + draft.messages.bySession[sessionId], + messages, + ); ensureMessageMeta(draft.messages.metaBySession, sessionId); if (meta) applyMessageMeta(draft.messages.metaBySession, sessionId, meta); }), diff --git a/apps/web/lib/state/slices/session/session-slice.update-messages.test.ts b/apps/web/lib/state/slices/session/session-slice.update-messages.test.ts index 852eaf4bdb..813add3295 100644 --- a/apps/web/lib/state/slices/session/session-slice.update-messages.test.ts +++ b/apps/web/lib/state/slices/session/session-slice.update-messages.test.ts @@ -105,3 +105,34 @@ describe("updateMessages", () => { expect(store.getState().messagePrompts.bySession[SESSION][0].content).toBe("after"); }); }); + +it("keeps a committed removal marker through stale message events and HTTP snapshots", () => { + const store = makeStore(); + const original = makeMessage("tool", "Command", SESSION, { + type: "tool_execute", + metadata: { normalized: { shell_exec: { output: { stdout: "removed payload" } } } }, + }); + store.getState().setMessages(SESSION, [original]); + const removed = { + ...original, + metadata: { + payload_retention: { version: 1, removed_at: "2026-09-14T00:00:00Z" }, + normalized: { shell_exec: { output: { exit_code: 0 } } }, + }, + }; + store.getState().updateMessage(removed); + store.getState().updateMessage(original); + expect(store.getState().messages.bySession[SESSION][0].metadata).toEqual(removed.metadata); + store.getState().mergeMessages(SESSION, [original]); + expect(store.getState().messages.bySession[SESSION][0].metadata).toEqual(removed.metadata); + store.getState().setMessages(SESSION, [original]); + expect(store.getState().messages.bySession[SESSION][0].metadata).toEqual(removed.metadata); +}); +it("accepts a removal marker even when maintenance preserves message timestamps", () => { + const store = makeStore(); + const original = makeMessage("tool", "Command"); + store.getState().setMessages(SESSION, [original]); + const metadata = { payload_retention: { version: 1, removed_at: "2026-09-14T00:00:00Z" } }; + store.getState().mergeMessages(SESSION, [{ ...original, metadata }]); + expect(store.getState().messages.bySession[SESSION][0].metadata).toEqual(metadata); +}); diff --git a/apps/web/lib/types/tool-payload-retention.ts b/apps/web/lib/types/tool-payload-retention.ts new file mode 100644 index 0000000000..076ff62083 --- /dev/null +++ b/apps/web/lib/types/tool-payload-retention.ts @@ -0,0 +1,35 @@ +export type ToolPayloadAge = { value: number; unit: "weeks" | "months" }; +export type ToolPayloadPolicy = { enabled: boolean; age: ToolPayloadAge; revision: number }; +export type ToolPayloadBackupChoice = "backup" | "skip"; +export type ToolPayloadOperation = { + id: string; + kind: "analysis" | "cleanup" | "backup"; + state: "running" | "succeeded" | "failed" | "partial" | "cancelled"; + scanned: number; + eligible_tasks: number; + eligible_messages: number; + removed_messages: number; + payload_bytes: number; + skipped: Record; + cutoff: string; + started_at: string; + finished_at?: string; + error?: string; + age: ToolPayloadAge; +}; +export type ToolPayloadRetentionStatus = { + supported: boolean; + policy: ToolPayloadPolicy; + preparation: { + state: "none" | "pending" | "running" | "failed" | "ready"; + choice: ToolPayloadBackupChoice | ""; + error?: string; + }; + operation?: ToolPayloadOperation; + last_analysis?: ToolPayloadOperation; + last_run?: ToolPayloadOperation; + next_due_at?: string; +}; +export type ToolPayloadPolicyUpdate = ToolPayloadPolicy & { + backup_choice?: ToolPayloadBackupChoice; +}; diff --git a/apps/web/lib/utils/tool-payload-retention.ts b/apps/web/lib/utils/tool-payload-retention.ts new file mode 100644 index 0000000000..96f91c2899 --- /dev/null +++ b/apps/web/lib/utils/tool-payload-retention.ts @@ -0,0 +1,24 @@ +import { ApiError } from "@/lib/api/client"; + +export type PayloadRetentionMarker = { version: number; removed_at: string }; + +export function payloadRetentionMarker(metadata: unknown): PayloadRetentionMarker | null { + if (!metadata || typeof metadata !== "object" || !("payload_retention" in metadata)) return null; + const marker = metadata.payload_retention; + if (!marker || typeof marker !== "object" || !("version" in marker) || !("removed_at" in marker)) + return null; + return typeof marker.version === "number" && typeof marker.removed_at === "string" + ? (marker as PayloadRetentionMarker) + : null; +} + +export function isToolPayloadRemovedError(error: unknown): boolean { + return ( + error instanceof ApiError && + error.status === 410 && + typeof error.body === "object" && + error.body !== null && + "code" in error.body && + error.body.code === "tool_payload_removed" + ); +} diff --git a/apps/web/src/locales/en/system.json b/apps/web/src/locales/en/system.json index 7f71ac0168..622406f6a9 100644 --- a/apps/web/src/locales/en/system.json +++ b/apps/web/src/locales/en/system.json @@ -623,5 +623,68 @@ "versionCurrent": "Current", "versionLatest": "Latest", "versionTitle": "Version", - "versionViewUpdates": "View updates" + "versionViewUpdates": "View updates", + "toolPayload.title": "Messages compaction", + "toolPayload.description": "Remove old tool inputs and outputs while keeping message metadata.", + "toolPayload.enabled": "Automatic compaction", + "toolPayload.age": "Tasks inactive for", + "toolPayload.unit": "Retention period unit", + "toolPayload.weeks": "Weeks", + "toolPayload.months": "Months", + "toolPayload.ageHelp": "Active tasks, queued work, and tasks waiting for input are excluded.", + "toolPayload.invalidAge": "Enter 1–520 whole weeks or 1–120 whole months.", + "toolPayload.beforeCleanup": "Before the first compaction", + "toolPayload.backupHelp": "Choose a backup or explicitly continue without one. A selected backup must succeed before compaction starts. Backups use additional space. Restoring a backup rolls back the whole database, including newer changes.", + "toolPayload.backup": "Create backup (recommended)", + "toolPayload.skip": "Continue without backup", + "toolPayload.chooseBackup": "Choose whether to create a backup before saving.", + "toolPayload.analyze": "Analyze savings", + "toolPayload.notAnalyzed": "Not analyzed. Preview savings without enabling compaction.", + "toolPayload.stale": "This estimate is stale or uses a different period. Run analysis again.", + "toolPayload.empty": "No removable tool details found for this period.", + "toolPayload.estimate": "Estimated tool details reduction: {{size}}", + "toolPayload.progress": "Scanned: {{scanned}} messages. Eligible: {{tasks}} tasks, {{messages}} messages.", + "toolPayload.removed_one": "Removed details from {{count}} message. Payload reduction: {{size}}.", + "toolPayload.removed_other": "Removed details from {{count}} messages. Payload reduction: {{size}}.", + "toolPayload.skipped_one": "Skipped: {{count}} item.", + "toolPayload.skipped_other": "Skipped: {{count}} items.", + "toolPayload.window": "Scan window: {{start}} to {{end}}. Activity cutoff: {{cutoff}}.", + "toolPayload.compactionHelp": "Estimates measure removed tool details. Space is freed for database reuse. Use Compact database in the Database section to shrink the file. Existing backups keep their size.", + "toolPayload.lastCleanup": "Last run", + "toolPayload.never": "Never", + "toolPayload.nextCheck": "Next check", + "toolPayload.disabled": "Disabled", + "toolPayload.pending": "Waiting for the operation to start.", + "toolPayload.preparing": "Preparing compaction. Compaction is not enabled yet.", + "toolPayload.preparationFailed": "Preparation failed. Compaction remains blocked. Retry or cancel preparation.", + "toolPayload.retryBackup": "Retry preparation", + "toolPayload.cancelPreparation": "Cancel preparation and disable compaction", + "toolPayload.run": "Compact messages now", + "toolPayload.cancel": "Cancel operation", + "toolPayload.refresh": "Refresh status", + "toolPayload.conflict": "The policy changed or another operation is active. Refresh status and discard the draft to use the latest policy before retrying.", + "toolPayload.adminOnly": "Only administrators can change this policy or run analysis and compaction.", + "toolPayload.unsupported": "Messages compaction is not supported by this database engine.", + "toolPayload.operationFailed": "The operation could not complete. Refresh status and retry.", + "toolPayload.analysis": "Analysis", + "toolPayload.cleanup": "Compaction", + "toolPayload.running": "Running", + "toolPayload.succeeded": "Completed", + "toolPayload.failed": "Failed", + "toolPayload.partial": "Partial", + "toolPayload.cancelled": "Cancelled", + "toolPayload.partialHelp": "This result is incomplete. Reported savings include only processed items.", + "toolPayload.skipReason.protected_tasks": "Protected tasks", + "toolPayload.skipReason.oversize": "Details exceed the compaction limit", + "toolPayload.skipReason.already_removed": "Details already removed", + "toolPayload.skipReason.no_payload": "No payload bytes to recover", + "toolPayload.skipReason.unsupported": "Unsupported or protected tool details", + "toolPayload.skipReason.malformed": "Unrecognized tool details", + "toolPayload.skipReason.invalid_message": "Invalid message", + "toolPayload.skipReason.other": "Other skipped items", + "toolPayload.skipReason.eligibility_budget": "Eligibility check timed out; task kept", + "toolPayload.schedule": "Checks every 24 hours while Kandev is running.", + "toolPayload.firstRun": "First run starts after backup preparation.", + "toolPayload.analysisDetails": "Analysis details", + "toolPayload.runDetails": "Run details" } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index a71d6339ca..874bb24e1f 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -2180,5 +2180,7 @@ "primarySessionChangedRetry": "The primary session changed. Try Run again.", "planCommentMigrationFailed": "Some saved plan comments could not be restored.", "planCommentMigrationNeedsPlan": "Create or restore a plan to recover saved comments.", - "planCommentMigrationPending": "Saved plan comments are still being restored. Your message is kept." + "planCommentMigrationPending": "Saved plan comments are still being restored. Your message is kept.", + "toolPayloadRemovedAt": "Tool details removed on {{date}}.", + "toolPayloadRemoved": "Tool details removed." } diff --git a/apps/web/src/locales/pseudo/system.json b/apps/web/src/locales/pseudo/system.json index e3e1c612ed..78b1b1760a 100644 --- a/apps/web/src/locales/pseudo/system.json +++ b/apps/web/src/locales/pseudo/system.json @@ -623,5 +623,68 @@ "versionCurrent": "Ćũŕŕēńţ", "versionLatest": "Ĺàţēśţ", "versionTitle": "Vēŕśĩōń", - "versionViewUpdates": "Vĩēŵ ũƥďàţēś" + "versionViewUpdates": "Vĩēŵ ũƥďàţēś", + "toolPayload.title": "Ḿēśśàĝēś ćōḿƥàćţĩōń", + "toolPayload.description": "Ŕēḿōvē ōĺď ţōōĺ ĩńƥũţś àńď ōũţƥũţś ŵĥĩĺē ķēēƥĩńĝ ḿēśśàĝē ḿēţàďàţà.", + "toolPayload.enabled": "Àũţōḿàţĩć ćōḿƥàćţĩōń", + "toolPayload.age": "Ţàśķś ĩńàćţĩvē ƒōŕ", + "toolPayload.unit": "Ŕēţēńţĩōń ƥēŕĩōď ũńĩţ", + "toolPayload.weeks": "Ŵēēķś", + "toolPayload.months": "Ḿōńţĥś", + "toolPayload.ageHelp": "Àćţĩvē ţàśķś, qũēũēď ŵōŕķ, àńď ţàśķś ŵàĩţĩńĝ ƒōŕ ĩńƥũţ àŕē ēxćĺũďēď.", + "toolPayload.invalidAge": "Ēńţēŕ 1–520 ŵĥōĺē ŵēēķś ōŕ 1–120 ŵĥōĺē ḿōńţĥś.", + "toolPayload.beforeCleanup": "Ɓēƒōŕē ţĥē ƒĩŕśţ ćōḿƥàćţĩōń", + "toolPayload.backupHelp": "Ćĥōōśē à ƀàćķũƥ ōŕ ēxƥĺĩćĩţĺŷ ćōńţĩńũē ŵĩţĥōũţ ōńē. À śēĺēćţēď ƀàćķũƥ ḿũśţ śũććēēď ƀēƒōŕē ćōḿƥàćţĩōń śţàŕţś. Ɓàćķũƥś ũśē àďďĩţĩōńàĺ śƥàćē. Ŕēśţōŕĩńĝ à ƀàćķũƥ ŕōĺĺś ƀàćķ ţĥē ŵĥōĺē ďàţàƀàśē, ĩńćĺũďĩńĝ ńēŵēŕ ćĥàńĝēś.", + "toolPayload.backup": "Ćŕēàţē ƀàćķũƥ (ŕēćōḿḿēńďēď)", + "toolPayload.skip": "Ćōńţĩńũē ŵĩţĥōũţ ƀàćķũƥ", + "toolPayload.chooseBackup": "Ćĥōōśē ŵĥēţĥēŕ ţō ćŕēàţē à ƀàćķũƥ ƀēƒōŕē śàvĩńĝ.", + "toolPayload.analyze": "Àńàĺŷźē śàvĩńĝś", + "toolPayload.notAnalyzed": "Ńōţ àńàĺŷźēď. Ƥŕēvĩēŵ śàvĩńĝś ŵĩţĥōũţ ēńàƀĺĩńĝ ćōḿƥàćţĩōń.", + "toolPayload.stale": "Ţĥĩś ēśţĩḿàţē ĩś śţàĺē ōŕ ũśēś à ďĩƒƒēŕēńţ ƥēŕĩōď. Ŕũń àńàĺŷśĩś àĝàĩń.", + "toolPayload.empty": "Ńō ŕēḿōvàƀĺē ţōōĺ ďēţàĩĺś ƒōũńď ƒōŕ ţĥĩś ƥēŕĩōď.", + "toolPayload.estimate": "Ēśţĩḿàţēď ţōōĺ ďēţàĩĺś ŕēďũćţĩōń: {{size}}", + "toolPayload.progress": "Śćàńńēď: {{scanned}} ḿēśśàĝēś. Ēĺĩĝĩƀĺē: {{tasks}} ţàśķś, {{messages}} ḿēśśàĝēś.", + "toolPayload.removed_one": "Ŕēḿōvēď ďēţàĩĺś ƒŕōḿ {{count}} ḿēśśàĝē. Ƥàŷĺōàď ŕēďũćţĩōń: {{size}}.", + "toolPayload.removed_other": "Ŕēḿōvēď ďēţàĩĺś ƒŕōḿ {{count}} ḿēśśàĝēś. Ƥàŷĺōàď ŕēďũćţĩōń: {{size}}.", + "toolPayload.skipped_one": "Śķĩƥƥēď: {{count}} ĩţēḿ.", + "toolPayload.skipped_other": "Śķĩƥƥēď: {{count}} ĩţēḿś.", + "toolPayload.window": "Śćàń ŵĩńďōŵ: {{start}} ţō {{end}}. Àćţĩvĩţŷ ćũţōƒƒ: {{cutoff}}.", + "toolPayload.compactionHelp": "Ēśţĩḿàţēś ḿēàśũŕē ŕēḿōvēď ţōōĺ ďēţàĩĺś. Śƥàćē ĩś ƒŕēēď ƒōŕ ďàţàƀàśē ŕēũśē. Ũśē Ćōḿƥàćţ ďàţàƀàśē ĩń ţĥē Ďàţàƀàśē śēćţĩōń ţō śĥŕĩńķ ţĥē ƒĩĺē. Ēxĩśţĩńĝ ƀàćķũƥś ķēēƥ ţĥēĩŕ śĩźē.", + "toolPayload.lastCleanup": "Ĺàśţ ŕũń", + "toolPayload.never": "Ńēvēŕ", + "toolPayload.nextCheck": "Ńēxţ ćĥēćķ", + "toolPayload.disabled": "Ďĩśàƀĺēď", + "toolPayload.pending": "Ŵàĩţĩńĝ ƒōŕ ţĥē ōƥēŕàţĩōń ţō śţàŕţ.", + "toolPayload.preparing": "Ƥŕēƥàŕĩńĝ ćōḿƥàćţĩōń. Ćōḿƥàćţĩōń ĩś ńōţ ēńàƀĺēď ŷēţ.", + "toolPayload.preparationFailed": "Ƥŕēƥàŕàţĩōń ƒàĩĺēď. Ćōḿƥàćţĩōń ŕēḿàĩńś ƀĺōćķēď. Ŕēţŕŷ ōŕ ćàńćēĺ ƥŕēƥàŕàţĩōń.", + "toolPayload.retryBackup": "Ŕēţŕŷ ƥŕēƥàŕàţĩōń", + "toolPayload.cancelPreparation": "Ćàńćēĺ ƥŕēƥàŕàţĩōń àńď ďĩśàƀĺē ćōḿƥàćţĩōń", + "toolPayload.run": "Ćōḿƥàćţ ḿēśśàĝēś ńōŵ", + "toolPayload.cancel": "Ćàńćēĺ ōƥēŕàţĩōń", + "toolPayload.refresh": "Ŕēƒŕēśĥ śţàţũś", + "toolPayload.conflict": "Ţĥē ƥōĺĩćŷ ćĥàńĝēď ōŕ àńōţĥēŕ ōƥēŕàţĩōń ĩś àćţĩvē. Ŕēƒŕēśĥ śţàţũś àńď ďĩśćàŕď ţĥē ďŕàƒţ ţō ũśē ţĥē ĺàţēśţ ƥōĺĩćŷ ƀēƒōŕē ŕēţŕŷĩńĝ.", + "toolPayload.adminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćĥàńĝē ţĥĩś ƥōĺĩćŷ ōŕ ŕũń àńàĺŷśĩś àńď ćōḿƥàćţĩōń.", + "toolPayload.unsupported": "Ḿēśśàĝēś ćōḿƥàćţĩōń ĩś ńōţ śũƥƥōŕţēď ƀŷ ţĥĩś ďàţàƀàśē ēńĝĩńē.", + "toolPayload.operationFailed": "Ţĥē ōƥēŕàţĩōń ćōũĺď ńōţ ćōḿƥĺēţē. Ŕēƒŕēśĥ śţàţũś àńď ŕēţŕŷ.", + "toolPayload.analysis": "Àńàĺŷśĩś", + "toolPayload.cleanup": "Ćōḿƥàćţĩōń", + "toolPayload.running": "Ŕũńńĩńĝ", + "toolPayload.succeeded": "Ćōḿƥĺēţēď", + "toolPayload.failed": "Ƒàĩĺēď", + "toolPayload.partial": "Ƥàŕţĩàĺ", + "toolPayload.cancelled": "Ćàńćēĺĺēď", + "toolPayload.partialHelp": "Ţĥĩś ŕēśũĺţ ĩś ĩńćōḿƥĺēţē. Ŕēƥōŕţēď śàvĩńĝś ĩńćĺũďē ōńĺŷ ƥŕōćēśśēď ĩţēḿś.", + "toolPayload.skipReason.protected_tasks": "Ƥŕōţēćţēď ţàśķś", + "toolPayload.skipReason.oversize": "Ďēţàĩĺś ēxćēēď ţĥē ćōḿƥàćţĩōń ĺĩḿĩţ", + "toolPayload.skipReason.already_removed": "Ďēţàĩĺś àĺŕēàďŷ ŕēḿōvēď", + "toolPayload.skipReason.no_payload": "Ńō ƥàŷĺōàď ƀŷţēś ţō ŕēćōvēŕ", + "toolPayload.skipReason.unsupported": "Ũńśũƥƥōŕţēď ōŕ ƥŕōţēćţēď ţōōĺ ďēţàĩĺś", + "toolPayload.skipReason.malformed": "Ũńŕēćōĝńĩźēď ţōōĺ ďēţàĩĺś", + "toolPayload.skipReason.invalid_message": "Ĩńvàĺĩď ḿēśśàĝē", + "toolPayload.skipReason.other": "Ōţĥēŕ śķĩƥƥēď ĩţēḿś", + "toolPayload.skipReason.eligibility_budget": "Ēĺĩĝĩƀĩĺĩţŷ ćĥēćķ ţĩḿēď ōũţ; ţàśķ ķēƥţ", + "toolPayload.schedule": "Ćĥēćķś ēvēŕŷ 24 ĥōũŕś ŵĥĩĺē Ķàńďēv ĩś ŕũńńĩńĝ.", + "toolPayload.firstRun": "Ƒĩŕśţ ŕũń śţàŕţś àƒţēŕ ƀàćķũƥ ƥŕēƥàŕàţĩōń.", + "toolPayload.analysisDetails": "Àńàĺŷśĩś ďēţàĩĺś", + "toolPayload.runDetails": "Ŕũń ďēţàĩĺś" } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 170c7ea7e1..45922acddc 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -2180,5 +2180,7 @@ "primarySessionChangedRetry": "Ţĥē ƥŕĩḿàŕŷ śēśśĩōń ćĥàńĝēď. Ţŕŷ Ŕũń àĝàĩń.", "planCommentMigrationFailed": "Śōḿē śàvēď ƥĺàń ćōḿḿēńţś ćōũĺď ńōţ ƀē ŕēśţōŕēď.", "planCommentMigrationNeedsPlan": "Ćŕēàţē ōŕ ŕēśţōŕē à ƥĺàń ţō ŕēćōvēŕ śàvēď ćōḿḿēńţś.", - "planCommentMigrationPending": "Śàvēď ƥĺàń ćōḿḿēńţś àŕē śţĩĺĺ ƀēĩńĝ ŕēśţōŕēď. Ŷōũŕ ḿēśśàĝē ĩś ķēƥţ." + "planCommentMigrationPending": "Śàvēď ƥĺàń ćōḿḿēńţś àŕē śţĩĺĺ ƀēĩńĝ ŕēśţōŕēď. Ŷōũŕ ḿēśśàĝē ĩś ķēƥţ.", + "toolPayloadRemovedAt": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď ōń {{date}}.", + "toolPayloadRemoved": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď." } diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json index 14eed6c522..e8a0371de2 100644 --- a/apps/web/src/locales/pt-pt/system.json +++ b/apps/web/src/locales/pt-pt/system.json @@ -623,5 +623,68 @@ "messageQueueAutoMergeDescription": "Define a predefinição para todas as sessões. Uma sessão segue este valor até alterar a Junção automática na fila dessa sessão.", "messageQueueAutoMergeNotice": "Se uma mensagem tiver outra origem ou tarefa, modelo, modo, metadados ou limites partilhados incompatíveis, permanece como mensagem em fila separada. Isto aplica-se apenas a novas admissões e não altera o controlo de junção manual.", "messageQueueAutoMergeToggleLabel": "Juntar automaticamente mensagens consecutivas", - "navDataStorage": "Dados e registos" + "navDataStorage": "Dados e registos", + "toolPayload.title": "Compactação de mensagens", + "toolPayload.description": "Remova entradas e resultados antigos de ferramentas, mantendo os metadados das mensagens.", + "toolPayload.enabled": "Compactação automática", + "toolPayload.age": "Tarefas inativas há", + "toolPayload.unit": "Unidade do período de retenção", + "toolPayload.weeks": "Semanas", + "toolPayload.months": "Meses", + "toolPayload.ageHelp": "As tarefas ativas, o trabalho em fila e as tarefas à espera de resposta são excluídos.", + "toolPayload.invalidAge": "Introduza 1–520 semanas inteiras ou 1–120 meses inteiros.", + "toolPayload.beforeCleanup": "Antes da primeira compactação", + "toolPayload.backupHelp": "Escolha uma cópia de segurança ou continue explicitamente sem uma. A cópia selecionada tem de ser concluída antes da limpeza. As cópias ocupam espaço adicional. Restaurar uma cópia repõe toda a base de dados, incluindo alterações mais recentes.", + "toolPayload.backup": "Criar cópia de segurança (recomendado)", + "toolPayload.skip": "Continuar sem cópia de segurança", + "toolPayload.chooseBackup": "Escolha se pretende criar uma cópia de segurança antes de guardar.", + "toolPayload.analyze": "Analisar espaço recuperável", + "toolPayload.notAnalyzed": "Ainda não analisado. Veja a poupança estimada sem ativar a compactação.", + "toolPayload.stale": "Esta estimativa está desatualizada ou usa um período diferente. Execute novamente a análise.", + "toolPayload.empty": "Não foram encontrados detalhes de ferramentas removíveis neste período.", + "toolPayload.estimate": "Redução estimada dos detalhes de ferramentas: {{size}}", + "toolPayload.progress": "Analisadas: {{scanned}} mensagens. Elegíveis: {{tasks}} tarefas, {{messages}} mensagens.", + "toolPayload.removed_one": "Detalhes removidos de {{count}} mensagem. Redução dos dados: {{size}}.", + "toolPayload.removed_other": "Detalhes removidos de {{count}} mensagens. Redução dos dados: {{size}}.", + "toolPayload.skipped_one": "Ignorado: {{count}} item.", + "toolPayload.skipped_other": "Ignorados: {{count}} itens.", + "toolPayload.window": "Período da análise: {{start}} a {{end}}. Limite de atividade: {{cutoff}}.", + "toolPayload.compactionHelp": "As estimativas medem os bytes dos dados, não o tamanho do ficheiro da base de dados. A limpeza liberta espaço para reutilização. Use Compactar base de dados na secção Base de dados para reduzir o ficheiro. As cópias existentes mantêm o seu tamanho.", + "toolPayload.lastCleanup": "Última execução", + "toolPayload.never": "Nunca", + "toolPayload.nextCheck": "Próxima verificação", + "toolPayload.disabled": "Desativada", + "toolPayload.pending": "A aguardar o início da operação.", + "toolPayload.preparing": "A preparar a limpeza. A limpeza ainda não está ativada.", + "toolPayload.preparationFailed": "A preparação falhou. A limpeza continua bloqueada. Repita ou cancele a preparação.", + "toolPayload.retryBackup": "Repetir preparação", + "toolPayload.cancelPreparation": "Cancelar preparação e desativar limpeza", + "toolPayload.run": "Compactar mensagens agora", + "toolPayload.cancel": "Cancelar operação", + "toolPayload.refresh": "Atualizar estado", + "toolPayload.conflict": "A política mudou ou existe outra operação ativa. Atualize o estado e descarte o rascunho para usar a política mais recente antes de tentar novamente.", + "toolPayload.adminOnly": "Apenas administradores podem alterar esta política ou executar análises e limpezas.", + "toolPayload.unsupported": "Este motor de base de dados não suporta a limpeza de dados das ferramentas.", + "toolPayload.operationFailed": "Não foi possível concluir a operação. Atualize o estado e tente novamente.", + "toolPayload.analysis": "Análise", + "toolPayload.cleanup": "Compactação", + "toolPayload.running": "Em execução", + "toolPayload.succeeded": "Concluída", + "toolPayload.failed": "Falhou", + "toolPayload.partial": "Parcial", + "toolPayload.cancelled": "Cancelada", + "toolPayload.partialHelp": "Este resultado está incompleto. O espaço recuperado indicado inclui apenas os itens processados.", + "toolPayload.skipReason.protected_tasks": "Tarefas protegidas", + "toolPayload.skipReason.oversize": "Os detalhes excedem o limite de limpeza", + "toolPayload.skipReason.already_removed": "Detalhes já removidos", + "toolPayload.skipReason.no_payload": "Sem dados recuperáveis", + "toolPayload.skipReason.unsupported": "Detalhes de ferramentas não suportados ou protegidos", + "toolPayload.skipReason.malformed": "Detalhes de ferramentas não reconhecidos", + "toolPayload.skipReason.invalid_message": "Mensagem inválida", + "toolPayload.skipReason.other": "Outros itens ignorados", + "toolPayload.skipReason.eligibility_budget": "A verificação de elegibilidade excedeu o tempo limite; tarefa mantida", + "toolPayload.schedule": "Verifica a cada 24 horas enquanto o Kandev está em execução.", + "toolPayload.firstRun": "A primeira execução começa após a preparação da cópia de segurança.", + "toolPayload.analysisDetails": "Detalhes da análise", + "toolPayload.runDetails": "Detalhes da execução" } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index fc0f96908a..1f696bdd8b 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -2180,5 +2180,7 @@ "primarySessionChangedRetry": "A sessão principal foi alterada. Tente executar novamente.", "planCommentMigrationFailed": "Não foi possível restaurar alguns comentários do plano guardados.", "planCommentMigrationNeedsPlan": "Crie ou restaure um plano para recuperar os comentários guardados.", - "planCommentMigrationPending": "Os comentários do plano guardados ainda estão a ser restaurados. A sua mensagem é mantida." + "planCommentMigrationPending": "Os comentários do plano guardados ainda estão a ser restaurados. A sua mensagem é mantida.", + "toolPayloadRemovedAt": "Detalhes da ferramenta removidos em {{date}}.", + "toolPayloadRemoved": "Detalhes da ferramenta removidos." } diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json index 45e56ec086..e4846f0d18 100644 --- a/apps/web/src/locales/zh-cn/system.json +++ b/apps/web/src/locales/zh-cn/system.json @@ -623,5 +623,68 @@ "versionViewUpdates": "查看更新", "messageQueueAutoMergeDescription": "设置所有会话的默认值。会话会沿用此值,直到您在该会话的队列中更改自动合并。", "messageQueueAutoMergeNotice": "如果某条消息的来源不同,或任务、模型、模式、元数据或共享限制不兼容,它会保持为单独的排队消息。这只适用于新接收的消息,不会改变手动合并控制。", - "messageQueueAutoMergeToggleLabel": "自动合并连续消息" + "messageQueueAutoMergeToggleLabel": "自动合并连续消息", + "toolPayload.title": "消息精简", + "toolPayload.description": "移除旧的工具输入和输出,同时保留消息元数据。", + "toolPayload.enabled": "自动精简", + "toolPayload.age": "任务未活动时长", + "toolPayload.unit": "保留期限单位", + "toolPayload.weeks": "周", + "toolPayload.months": "月", + "toolPayload.ageHelp": "不处理活跃任务、排队工作和等待输入的任务。", + "toolPayload.invalidAge": "请输入 1–520 个整周或 1–120 个整月。", + "toolPayload.beforeCleanup": "首次精简前", + "toolPayload.backupHelp": "选择创建备份或明确选择不备份。所选备份必须成功后才能开始清理。备份占用额外空间。恢复备份会回滚整个数据库,包括较新的更改。", + "toolPayload.backup": "创建备份(推荐)", + "toolPayload.skip": "不备份并继续", + "toolPayload.chooseBackup": "保存前请选择是否创建备份。", + "toolPayload.analyze": "分析可节省空间", + "toolPayload.notAnalyzed": "尚未分析。无需启用精简即可预览预计节省的空间。", + "toolPayload.stale": "此估算已过期或使用了不同期限。请重新分析。", + "toolPayload.empty": "此时间段内未找到可移除的工具详情。", + "toolPayload.estimate": "预计减少的工具详情大小:{{size}}", + "toolPayload.progress": "已扫描 {{scanned}} 条消息。符合条件:{{tasks}} 个任务,{{messages}} 条消息。", + "toolPayload.removed_one": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", + "toolPayload.removed_other": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", + "toolPayload.skipped_one": "已跳过 {{count}} 项。", + "toolPayload.skipped_other": "已跳过 {{count}} 项。", + "toolPayload.window": "扫描时间:{{start}} 至 {{end}}。活动截止时间:{{cutoff}}。", + "toolPayload.compactionHelp": "估算衡量详细数据字节数,并非数据库文件大小。清理释放空间供重用。使用“数据库”部分的“压缩数据库”缩小文件。现有备份大小不变。", + "toolPayload.lastCleanup": "上次运行", + "toolPayload.never": "从未", + "toolPayload.nextCheck": "下次检查", + "toolPayload.disabled": "已禁用", + "toolPayload.pending": "正在等待操作开始。", + "toolPayload.preparing": "正在准备清理。清理尚未启用。", + "toolPayload.preparationFailed": "准备失败。清理仍被阻止。请重试或取消准备。", + "toolPayload.retryBackup": "重试准备", + "toolPayload.cancelPreparation": "取消准备并禁用清理", + "toolPayload.run": "立即精简消息", + "toolPayload.cancel": "取消操作", + "toolPayload.refresh": "刷新状态", + "toolPayload.conflict": "策略已更改或其他操作正在运行。请刷新状态并放弃草稿,使用最新策略后重试。", + "toolPayload.adminOnly": "只有管理员可以更改此策略或运行分析与清理。", + "toolPayload.unsupported": "此数据库引擎不支持工具详细数据清理。", + "toolPayload.operationFailed": "操作未能完成。请刷新状态后重试。", + "toolPayload.analysis": "分析", + "toolPayload.cleanup": "精简", + "toolPayload.running": "正在运行", + "toolPayload.succeeded": "已完成", + "toolPayload.failed": "失败", + "toolPayload.partial": "部分完成", + "toolPayload.cancelled": "已取消", + "toolPayload.partialHelp": "此结果不完整。所报告的节省空间仅包含已处理项。", + "toolPayload.skipReason.protected_tasks": "受保护的任务", + "toolPayload.skipReason.oversize": "详细数据超过清理限制", + "toolPayload.skipReason.already_removed": "详细数据已删除", + "toolPayload.skipReason.no_payload": "无可回收的详细数据", + "toolPayload.skipReason.unsupported": "不支持或受保护的工具详细数据", + "toolPayload.skipReason.malformed": "无法识别的工具详细数据", + "toolPayload.skipReason.invalid_message": "无效消息", + "toolPayload.skipReason.other": "其他已跳过项", + "toolPayload.skipReason.eligibility_budget": "资格检查超时;任务已保留", + "toolPayload.schedule": "Kandev 运行期间每 24 小时检查一次。", + "toolPayload.firstRun": "首次运行将在备份准备完成后开始。", + "toolPayload.analysisDetails": "分析详情", + "toolPayload.runDetails": "运行详情" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 09374c4579..fbb5944ab8 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -2180,5 +2180,7 @@ "primarySessionChangedRetry": "主会话已更改。请再次运行。", "planCommentMigrationFailed": "无法恢复部分已保存的计划评论。", "planCommentMigrationNeedsPlan": "请创建或恢复计划,以恢复已保存的评论。", - "planCommentMigrationPending": "仍在恢复已保存的计划评论。您的消息会保留。" + "planCommentMigrationPending": "仍在恢复已保存的计划评论。您的消息会保留。", + "toolPayloadRemovedAt": "工具详细数据已于 {{date}} 删除。", + "toolPayloadRemoved": "工具详细数据已删除。" } diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json index 9ed2cca40a..4873b12bad 100644 --- a/apps/web/src/locales/zh-hk/system.json +++ b/apps/web/src/locales/zh-hk/system.json @@ -623,5 +623,68 @@ "versionViewUpdates": "查看更新", "messageQueueAutoMergeDescription": "設定所有工作階段的預設值。工作階段會沿用此值,直到您在該工作階段的佇列中更改自動合併。", "messageQueueAutoMergeNotice": "如果某條訊息的來源不同,或任務、模型、模式、元數據或共享限制不兼容,它會保持為單獨的排隊訊息。這隻適用於新接收的訊息,不會改變手動合併控制。", - "messageQueueAutoMergeToggleLabel": "自動合併連續訊息" + "messageQueueAutoMergeToggleLabel": "自動合併連續訊息", + "toolPayload.title": "訊息精簡", + "toolPayload.description": "移除舊的工具輸入和輸出,同時保留訊息元數據。", + "toolPayload.enabled": "自動精簡", + "toolPayload.age": "任务未活动时长", + "toolPayload.unit": "保留期限单位", + "toolPayload.weeks": "周", + "toolPayload.months": "月", + "toolPayload.ageHelp": "不處理活躍任務、排隊工作和等待輸入的任務。", + "toolPayload.invalidAge": "请输入 1–520 个整周或 1–120 个整月。", + "toolPayload.beforeCleanup": "首次精簡前", + "toolPayload.backupHelp": "选择创建备份或明确选择不备份。所选备份必须成功后才能开始清理。备份占用额外空间。恢复备份会回滚整个数据库,包括较新的更改。", + "toolPayload.backup": "创建备份(推荐)", + "toolPayload.skip": "不备份并继续", + "toolPayload.chooseBackup": "保存前请选择是否创建备份。", + "toolPayload.analyze": "分析可节省空间", + "toolPayload.notAnalyzed": "尚未分析。無需啓用精簡即可預覽預計節省的空間。", + "toolPayload.stale": "此估算已过期或使用了不同期限。请重新分析。", + "toolPayload.empty": "此時間段內未找到可移除的工具詳情。", + "toolPayload.estimate": "預計減少的工具詳情大小:{{size}}", + "toolPayload.progress": "已扫描 {{scanned}} 条消息。符合条件:{{tasks}} 个任务,{{messages}} 条消息。", + "toolPayload.removed_one": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", + "toolPayload.removed_other": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", + "toolPayload.skipped_one": "已跳过 {{count}} 项。", + "toolPayload.skipped_other": "已跳过 {{count}} 项。", + "toolPayload.window": "扫描时间:{{start}} 至 {{end}}。活动截止时间:{{cutoff}}。", + "toolPayload.compactionHelp": "估算衡量详细数据字节数,并非数据库文件大小。清理释放空间供重用。使用“数据库”部分的“压缩数据库”缩小文件。现有备份大小不变。", + "toolPayload.lastCleanup": "上次執行", + "toolPayload.never": "从未", + "toolPayload.nextCheck": "下次检查", + "toolPayload.disabled": "已禁用", + "toolPayload.pending": "正在等待操作开始。", + "toolPayload.preparing": "正在準備清理。清理尚未啟用。", + "toolPayload.preparationFailed": "准备失败。清理仍被阻止。请重试或取消准备。", + "toolPayload.retryBackup": "重试准备", + "toolPayload.cancelPreparation": "取消准备并禁用清理", + "toolPayload.run": "立即精簡訊息", + "toolPayload.cancel": "取消操作", + "toolPayload.refresh": "刷新状态", + "toolPayload.conflict": "策略已更改或其他操作正在运行。请刷新状态并放弃草稿,使用最新策略后重试。", + "toolPayload.adminOnly": "只有管理员可以更改此策略或运行分析与清理。", + "toolPayload.unsupported": "此数据库引擎不支持工具详细数据清理。", + "toolPayload.operationFailed": "操作未能完成。请刷新状态后重试。", + "toolPayload.analysis": "分析", + "toolPayload.cleanup": "精簡", + "toolPayload.running": "正在运行", + "toolPayload.succeeded": "已完成", + "toolPayload.failed": "失败", + "toolPayload.partial": "部分完成", + "toolPayload.cancelled": "已取消", + "toolPayload.partialHelp": "此结果不完整。所报告的节省空间仅包含已处理项。", + "toolPayload.skipReason.protected_tasks": "受保護的任務", + "toolPayload.skipReason.oversize": "詳細資料超過清理限制", + "toolPayload.skipReason.already_removed": "詳細資料已刪除", + "toolPayload.skipReason.no_payload": "無可回收的詳細資料", + "toolPayload.skipReason.unsupported": "不支援或受保護的工具詳細資料", + "toolPayload.skipReason.malformed": "無法識別的工具詳細資料", + "toolPayload.skipReason.invalid_message": "無效訊息", + "toolPayload.skipReason.other": "其他已跳過項目", + "toolPayload.skipReason.eligibility_budget": "資格檢查逾時;任務已保留", + "toolPayload.schedule": "Kandev 執行期間每 24 小時檢查一次。", + "toolPayload.firstRun": "首次執行將在備份準備完成後開始。", + "toolPayload.analysisDetails": "分析詳情", + "toolPayload.runDetails": "執行詳情" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index f2f418fc54..cc1e44749f 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -2180,5 +2180,7 @@ "primarySessionChangedRetry": "主工作階段已更改。請再次執行。", "planCommentMigrationFailed": "無法恢復部分已儲存的計劃評論。", "planCommentMigrationNeedsPlan": "請建立或恢復計劃,以恢復已儲存的評論。", - "planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。" + "planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。", + "toolPayloadRemovedAt": "工具詳細數據已於 {{date}} 刪除。", + "toolPayloadRemoved": "工具詳細數據已刪除。" } diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json index a5e85afac5..6b4ceaa5e1 100644 --- a/apps/web/src/locales/zh-tw/system.json +++ b/apps/web/src/locales/zh-tw/system.json @@ -623,5 +623,68 @@ "versionViewUpdates": "檢視更新", "messageQueueAutoMergeDescription": "設定所有工作階段的預設值。工作階段會沿用此值,直到您在該工作階段的佇列中更改自動合併。", "messageQueueAutoMergeNotice": "如果某條訊息的來源不同,或任務、模型、模式、後設資料或共享限制不相容,它會保持為單獨的排隊訊息。這隻適用於新接收的訊息,不會改變手動合併控制。", - "messageQueueAutoMergeToggleLabel": "自動合併連續訊息" + "messageQueueAutoMergeToggleLabel": "自動合併連續訊息", + "toolPayload.title": "訊息精簡", + "toolPayload.description": "移除舊的工具輸入和輸出,同時保留訊息後設資料。", + "toolPayload.enabled": "自動精簡", + "toolPayload.age": "任务未活动时长", + "toolPayload.unit": "保留期限单位", + "toolPayload.weeks": "周", + "toolPayload.months": "月", + "toolPayload.ageHelp": "不處理活躍任務、排隊工作和等待輸入的任務。", + "toolPayload.invalidAge": "请输入 1–520 个整周或 1–120 个整月。", + "toolPayload.beforeCleanup": "首次精簡前", + "toolPayload.backupHelp": "选择创建备份或明确选择不备份。所选备份必须成功后才能开始清理。备份占用额外空间。恢复备份会回滚整个数据库,包括较新的更改。", + "toolPayload.backup": "创建备份(推荐)", + "toolPayload.skip": "不备份并继续", + "toolPayload.chooseBackup": "保存前请选择是否创建备份。", + "toolPayload.analyze": "分析可节省空间", + "toolPayload.notAnalyzed": "尚未分析。無需啟用精簡即可預覽預計節省的空間。", + "toolPayload.stale": "此估算已过期或使用了不同期限。请重新分析。", + "toolPayload.empty": "此時間段內未找到可移除的工具詳情。", + "toolPayload.estimate": "預計減少的工具詳情大小:{{size}}", + "toolPayload.progress": "已扫描 {{scanned}} 条消息。符合条件:{{tasks}} 个任务,{{messages}} 条消息。", + "toolPayload.removed_one": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", + "toolPayload.removed_other": "已删除 {{count}} 条消息的详细数据。数据减少:{{size}}。", + "toolPayload.skipped_one": "已跳过 {{count}} 项。", + "toolPayload.skipped_other": "已跳过 {{count}} 项。", + "toolPayload.window": "扫描时间:{{start}} 至 {{end}}。活动截止时间:{{cutoff}}。", + "toolPayload.compactionHelp": "估算衡量详细数据字节数,并非数据库文件大小。清理释放空间供重用。使用“数据库”部分的“压缩数据库”缩小文件。现有备份大小不变。", + "toolPayload.lastCleanup": "上次執行", + "toolPayload.never": "从未", + "toolPayload.nextCheck": "下次检查", + "toolPayload.disabled": "已禁用", + "toolPayload.pending": "正在等待操作开始。", + "toolPayload.preparing": "正在準備清理。清理尚未啟用。", + "toolPayload.preparationFailed": "准备失败。清理仍被阻止。请重试或取消准备。", + "toolPayload.retryBackup": "重试准备", + "toolPayload.cancelPreparation": "取消准备并禁用清理", + "toolPayload.run": "立即精簡訊息", + "toolPayload.cancel": "取消操作", + "toolPayload.refresh": "刷新状态", + "toolPayload.conflict": "策略已更改或其他操作正在运行。请刷新状态并放弃草稿,使用最新策略后重试。", + "toolPayload.adminOnly": "只有管理员可以更改此策略或运行分析与清理。", + "toolPayload.unsupported": "此数据库引擎不支持工具详细数据清理。", + "toolPayload.operationFailed": "操作未能完成。请刷新状态后重试。", + "toolPayload.analysis": "分析", + "toolPayload.cleanup": "精簡", + "toolPayload.running": "正在运行", + "toolPayload.succeeded": "已完成", + "toolPayload.failed": "失败", + "toolPayload.partial": "部分完成", + "toolPayload.cancelled": "已取消", + "toolPayload.partialHelp": "此结果不完整。所报告的节省空间仅包含已处理项。", + "toolPayload.skipReason.protected_tasks": "受保護的任務", + "toolPayload.skipReason.oversize": "詳細資料超過清理限制", + "toolPayload.skipReason.already_removed": "詳細資料已刪除", + "toolPayload.skipReason.no_payload": "無可回收的詳細資料", + "toolPayload.skipReason.unsupported": "不支援或受保護的工具詳細資料", + "toolPayload.skipReason.malformed": "無法識別的工具詳細資料", + "toolPayload.skipReason.invalid_message": "無效訊息", + "toolPayload.skipReason.other": "其他已跳過項目", + "toolPayload.skipReason.eligibility_budget": "資格檢查逾時;任務已保留", + "toolPayload.schedule": "Kandev 執行期間每 24 小時檢查一次。", + "toolPayload.firstRun": "首次執行將在備份準備完成後開始。", + "toolPayload.analysisDetails": "分析詳情", + "toolPayload.runDetails": "執行詳情" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 29c40e8fb1..78aa394e37 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -2180,5 +2180,7 @@ "primarySessionChangedRetry": "主工作階段已更改。請再次執行。", "planCommentMigrationFailed": "無法恢復部分已儲存的計劃評論。", "planCommentMigrationNeedsPlan": "請建立或恢復計劃,以恢復已儲存的評論。", - "planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。" + "planCommentMigrationPending": "仍在恢復已儲存的計劃評論。您的訊息會保留。", + "toolPayloadRemovedAt": "工具詳細資料已於 {{date}} 刪除。", + "toolPayloadRemoved": "工具詳細資料已刪除。" } diff --git a/docs/decisions/2026-09-14-tool-payload-removal-policy.md b/docs/decisions/2026-09-14-tool-payload-removal-policy.md new file mode 100644 index 0000000000..54d017a839 --- /dev/null +++ b/docs/decisions/2026-09-14-tool-payload-removal-policy.md @@ -0,0 +1,61 @@ +# ADR-2026-09-14-tool-payload-removal-policy: Tool payload removal policy + +**Status:** accepted +**Date:** 2026-09-14 +**Area:** backend + +## Context + +Large tool results increase database storage and backup work. The administrator +wants an optional age policy that removes payloads but retains conversation metadata. +The existing Office retention policy deletes a different class of records. + +Removing full tool rows loses ordering, tool identity, and replay deduplication. +Replacing all normalized data also loses control metadata and structured records. +Database payload reduction and filesystem reclamation are different operations. + +## Decision + +Use an independent policy, disabled by default, on Data & Logs settings. +Use explicit field removal rules and retain tool rows and identity metadata. +Store a durable removal marker and enforce it at message replacement boundaries. +Delayed events must not restore the removed payload. + +Offer analysis without requiring enablement. Require a backup choice before the +first cleanup. A selected backup must complete verification before deletion. +Run cleanup in bounded batches after readiness. Keep full compaction explicit. + +Use task inactivity as the age basis and protect all active or queued work. +Retain tasks whose eligibility cannot be proved within the query budget. +Report these passes as partial so their estimates cannot imply full coverage. + +## Alternatives Considered + +- **Compress payloads:** Retains all history, but requires compatible encoding, + reads, exports, and migrations. It does not implement the requested removal policy. +- **Move payloads to cold storage:** Preserves details but adds storage credentials, + availability, retrieval, and a separate retention lifecycle. +- **Delete tool messages:** Removes more row/index overhead but breaks the metadata + preservation requirement and loses replay identities. +- **Remove every normalized field:** Saves more bytes but loses control records, + attachments, and useful summaries. Explicit rules bound the removal contract. +- **Use creation age:** Easier to explain, but an old task can contain recent work. +- **Compact every pass:** Shrinks the file but introduces large database rewrites + and foreground contention into a periodic maintenance job. + +## Consequences + +Removed details are unavailable in conversations. Restoration uses an existing +whole-database backup and can replace newer data. Existing backups remain large. + +The policy cannot promise an exact file-size reduction. Unknown payload formats +remain intact until supported rules exist. New formats need explicit coverage. + +Implementation and validation use disposable fixtures. Recording this decision +does not enable cleanup on an existing installation. + +## References + +- [Requirements](../specs/system-page/requirements/tool-payload-retention.md) +- [System design](../specs/system-page/system-design/tool-payload-retention.md) +- [Implementation plan](../plans/tool-payload-retention/plan.md) diff --git a/docs/plans/tool-payload-retention/plan.md b/docs/plans/tool-payload-retention/plan.md new file mode 100644 index 0000000000..1e942ebc8e --- /dev/null +++ b/docs/plans/tool-payload-retention/plan.md @@ -0,0 +1,341 @@ +--- +created: 2026-09-14 +status: completed +requirements: + - REQ-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-001 + - REQ-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-002 + - REQ-SYSTEM-PAGE-TOOL-PAYLOAD-RETENTION-003 +system_design: + - ../../specs/system-page/system-design/tool-payload-retention.md +legacy_specs: [] +--- + +# Tool Payload Retention Implementation Plan + +## Overview + +Add optional cleanup of old tool payloads to `/settings/system/data-storage`. +Keep tool messages and metadata. Provide savings analysis, a backup choice, +initial cleanup, and daily cleanup under the saved policy. + +The user subsequently authorized implementation and native subagent support. +The primary session owns integration and verification. No production database +maintenance, commit, push, or deployment is included. + +## Inputs and assumptions + +- [Requirements](../../specs/system-page/requirements/tool-payload-retention.md) +- [System design](../../specs/system-page/system-design/tool-payload-retention.md) +- [Decision](../../decisions/2026-09-14-tool-payload-removal-policy.md) +- Age basis: task inactivity, with conservative task/session update protection. +- Initial age: three calendar months. Cleanup remains disabled by default. +- Initial engine: SQLite. Analysis is optional. The backup choice is explicit. + +Previous copy-only audits motivated the feature but do not provide its live +savings estimate. Different removal rules and eligibility change the result. +Do not reuse prior database-wide estimates as a promise for this policy. + +## Scope + +Include policy persistence, supported field removal, analysis, backup preparation, +guarded writes, replay protection, removed-output rendering, scheduling, desktop +and phone settings, access control, localization, and public documentation. + +Exclude compression, cold storage, attachment deletion, full-message deletion, +automatic compaction, and changes to Office/filesystem retention settings. + +## Technical approach + +Use one reducer for analysis and cleanup. Ground candidate queries in the existing +session message indexes. Preserve marker state at the repository write boundary. +Persist only singleton policy/runtime records and bounded progress. + +Compose `internal/system/toolretention` through the system service. Reuse backup +creation and job notifications, while retaining durable preparation state. +Keep all analysis and cleanup work outside startup readiness. + +Add an independent settings card and save contributor. Use inline preparation +review and existing backup/compaction controls. Reuse the same state model for +desktop and phone. No extra release flag is proposed: the setting defaults off. + +## ASCII UI preview + +These views specify hierarchy, actions, and states. Spacing and sample values +are illustrative. Product copy must use localization keys. + +### UI-01: Desktop, disabled policy with optional estimate + +Entry: Settings > System > Data & Logs. The existing Database section precedes +the retention area. Office history retains its independent card. + +```text +Tool payload cleanup [Off] +Keep tool messages and summaries. Remove old details. +Tasks inactive for [3] [Months v] [Analyze savings] + +Estimated payload reduction: + tool messages across tasks +Analyzed