diff --git a/coderd/coderd.go b/coderd/coderd.go index 56f2b47b05..ddb97d66fc 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1216,6 +1216,8 @@ func New(options *Options) *API { r.Put("/workspace-ttl", api.putChatWorkspaceTTL) r.Get("/retention-days", api.getChatRetentionDays) r.Put("/retention-days", api.putChatRetentionDays) + r.Get("/debug-retention-days", api.getChatDebugRetentionDays) + r.Put("/debug-retention-days", api.putChatDebugRetentionDays) r.Get("/auto-archive-days", api.getChatAutoArchiveDays) r.Put("/auto-archive-days", api.putChatAutoArchiveDays) r.Get("/template-allowlist", api.getChatTemplateAllowlist) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e7467245ec..9badded7e0 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2110,6 +2110,13 @@ func (q *querier) DeleteOldAuditLogs(ctx context.Context, arg database.DeleteOld return q.db.DeleteOldAuditLogs(ctx, arg) } +func (q *querier) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { + return 0, err + } + return q.db.DeleteOldChatDebugRuns(ctx, arg) +} + func (q *querier) DeleteOldChatFiles(ctx context.Context, arg database.DeleteOldChatFilesParams) (int64, error) { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { return 0, err @@ -2682,6 +2689,17 @@ func (q *querier) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, erro return q.db.GetChatDebugLoggingAllowUsers(ctx) } +func (q *querier) GetChatDebugRetentionDays(ctx context.Context, defaultDebugRetentionDays int32) (int32, error) { + // Chat debug retention is a deployment-wide config read by dbpurge. + // Only requires a valid actor in context. The HTTP GET handler + // allows any authenticated user; the PUT handler enforces admin + // access (policy.ActionUpdate on ResourceDeploymentConfig). + if _, ok := ActorFromContext(ctx); !ok { + return 0, ErrNoActor + } + return q.db.GetChatDebugRetentionDays(ctx, defaultDebugRetentionDays) +} + func (q *querier) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) { run, err := q.db.GetChatDebugRunByID(ctx, id) if err != nil { @@ -7528,6 +7546,13 @@ func (q *querier) UpsertChatDebugLoggingAllowUsers(ctx context.Context, allowUse return q.db.UpsertChatDebugLoggingAllowUsers(ctx, allowUsers) } +func (q *querier) UpsertChatDebugRetentionDays(ctx context.Context, debugRetentionDays int32) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertChatDebugRetentionDays(ctx, debugRetentionDays) +} + func (q *querier) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index fd935bc015..795a0e6641 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -742,6 +742,10 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatFileMetadataByChatID(gomock.Any(), file.ID).Return(rows, nil).AnyTimes() check.Args(file.ID).Asserts(rbac.ResourceChat.WithOwner(file.OwnerID.String()).InOrg(file.OrganizationID).WithID(file.ID), policy.ActionRead).Returns(rows) })) + s.Run("DeleteOldChatDebugRuns", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), database.DeleteOldChatDebugRunsParams{}).Return(int64(0), nil).AnyTimes() + check.Args(database.DeleteOldChatDebugRunsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete) + })) s.Run("DeleteOldChatFiles", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().DeleteOldChatFiles(gomock.Any(), database.DeleteOldChatFilesParams{}).Return(int64(0), nil).AnyTimes() check.Args(database.DeleteOldChatFilesParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete) @@ -762,6 +766,14 @@ func (s *MethodTestSuite) TestChats() { dbm.EXPECT().GetChatAutoArchiveDays(gomock.Any(), gomock.Any()).Return(int32(90), nil).AnyTimes() check.Args(int32(90)).Asserts() })) + s.Run("GetChatDebugRetentionDays", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().GetChatDebugRetentionDays(gomock.Any(), int32(7)).Return(int32(7), nil).AnyTimes() + check.Args(int32(7)).Asserts().Returns(int32(7)) + })) + s.Run("UpsertChatDebugRetentionDays", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { + dbm.EXPECT().UpsertChatDebugRetentionDays(gomock.Any(), int32(7)).Return(nil).AnyTimes() + check.Args(int32(7)).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("UpsertChatAutoArchiveDays", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().UpsertChatAutoArchiveDays(gomock.Any(), int32(90)).Return(nil).AnyTimes() check.Args(int32(90)).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index f6088fa0f5..125e86b2a4 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -632,6 +632,14 @@ func (m queryMetricsStore) DeleteOldAuditLogs(ctx context.Context, arg database. return r0, r1 } +func (m queryMetricsStore) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.DeleteOldChatDebugRuns(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteOldChatDebugRuns").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteOldChatDebugRuns").Inc() + return r0, r1 +} + func (m queryMetricsStore) DeleteOldChatFiles(ctx context.Context, arg database.DeleteOldChatFilesParams) (int64, error) { start := time.Now() r0, r1 := m.s.DeleteOldChatFiles(ctx, arg) @@ -1200,6 +1208,14 @@ func (m queryMetricsStore) GetChatDebugLoggingAllowUsers(ctx context.Context) (b return r0, r1 } +func (m queryMetricsStore) GetChatDebugRetentionDays(ctx context.Context, defaultDebugRetentionDays int32) (int32, error) { + start := time.Now() + r0, r1 := m.s.GetChatDebugRetentionDays(ctx, defaultDebugRetentionDays) + m.queryLatencies.WithLabelValues("GetChatDebugRetentionDays").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatDebugRetentionDays").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) { start := time.Now() r0, r1 := m.s.GetChatDebugRunByID(ctx, id) @@ -5384,6 +5400,14 @@ func (m queryMetricsStore) UpsertChatDebugLoggingAllowUsers(ctx context.Context, return r0 } +func (m queryMetricsStore) UpsertChatDebugRetentionDays(ctx context.Context, debugRetentionDays int32) error { + start := time.Now() + r0 := m.s.UpsertChatDebugRetentionDays(ctx, debugRetentionDays) + m.queryLatencies.WithLabelValues("UpsertChatDebugRetentionDays").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertChatDebugRetentionDays").Inc() + return r0 +} + func (m queryMetricsStore) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error { start := time.Now() r0 := m.s.UpsertChatDesktopEnabled(ctx, enableDesktop) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e6c8e0858f..bfb29d8559 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1058,6 +1058,21 @@ func (mr *MockStoreMockRecorder) DeleteOldAuditLogs(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAuditLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAuditLogs), ctx, arg) } +// DeleteOldChatDebugRuns mocks base method. +func (m *MockStore) DeleteOldChatDebugRuns(ctx context.Context, arg database.DeleteOldChatDebugRunsParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOldChatDebugRuns", ctx, arg) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOldChatDebugRuns indicates an expected call of DeleteOldChatDebugRuns. +func (mr *MockStoreMockRecorder) DeleteOldChatDebugRuns(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldChatDebugRuns", reflect.TypeOf((*MockStore)(nil).DeleteOldChatDebugRuns), ctx, arg) +} + // DeleteOldChatFiles mocks base method. func (m *MockStore) DeleteOldChatFiles(ctx context.Context, arg database.DeleteOldChatFilesParams) (int64, error) { m.ctrl.T.Helper() @@ -2207,6 +2222,21 @@ func (mr *MockStoreMockRecorder) GetChatDebugLoggingAllowUsers(ctx any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugLoggingAllowUsers", reflect.TypeOf((*MockStore)(nil).GetChatDebugLoggingAllowUsers), ctx) } +// GetChatDebugRetentionDays mocks base method. +func (m *MockStore) GetChatDebugRetentionDays(ctx context.Context, defaultDebugRetentionDays int32) (int32, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatDebugRetentionDays", ctx, defaultDebugRetentionDays) + ret0, _ := ret[0].(int32) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatDebugRetentionDays indicates an expected call of GetChatDebugRetentionDays. +func (mr *MockStoreMockRecorder) GetChatDebugRetentionDays(ctx, defaultDebugRetentionDays any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatDebugRetentionDays", reflect.TypeOf((*MockStore)(nil).GetChatDebugRetentionDays), ctx, defaultDebugRetentionDays) +} + // GetChatDebugRunByID mocks base method. func (m *MockStore) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (database.ChatDebugRun, error) { m.ctrl.T.Helper() @@ -10114,6 +10144,20 @@ func (mr *MockStoreMockRecorder) UpsertChatDebugLoggingAllowUsers(ctx, allowUser return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDebugLoggingAllowUsers", reflect.TypeOf((*MockStore)(nil).UpsertChatDebugLoggingAllowUsers), ctx, allowUsers) } +// UpsertChatDebugRetentionDays mocks base method. +func (m *MockStore) UpsertChatDebugRetentionDays(ctx context.Context, debugRetentionDays int32) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertChatDebugRetentionDays", ctx, debugRetentionDays) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertChatDebugRetentionDays indicates an expected call of UpsertChatDebugRetentionDays. +func (mr *MockStoreMockRecorder) UpsertChatDebugRetentionDays(ctx, debugRetentionDays any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertChatDebugRetentionDays", reflect.TypeOf((*MockStore)(nil).UpsertChatDebugRetentionDays), ctx, debugRetentionDays) +} + // UpsertChatDesktopEnabled mocks base method. func (m *MockStore) UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index 38c3ac00ce..ac98e0ddbf 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -45,10 +45,13 @@ const ( // long enough to cover the maximum interval of a heartbeat event (currently // 1 hour) plus some buffer. maxTelemetryHeartbeatAge = 24 * time.Hour - // Chat batch sizes stay smaller than audit/connection log batches because - // chat_files rows carry bytea blobs. + // Chat and chat file batch sizes stay smaller than audit/connection + // log batches because chat_files rows carry bytea blobs. chatsBatchSize = 1000 chatFilesBatchSize = 1000 + // Chat debug run deletions can cascade into steps with large JSONB + // payloads, so they use the same conservative batch size. + chatDebugRunsBatchSize = 1000 // chatAutoArchiveDigestMaxChats bounds how many chat titles a // single digest body lists. Past the cap, surplus titles are // summarized as "...and N more". 25 is a readable email-friendly @@ -181,9 +184,11 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder // purge fails. func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.Time) error { // Read chat configs outside the tx so a corrupt value can't - // poison subsequent queries. On error we log and stash, then - // run unrelated purges best-effort and skip only chat work; - // purgeTick returns chatConfigErr after the tx so the failed + // poison subsequent queries. On config read errors, log and stash + // the error, then run unrelated purges best-effort. Retention and + // auto-archive errors skip only the conversation purge and + // auto-archive work. Debug retention errors skip only the debug + // purge. purgeTick returns chatConfigErr after the tx so the failed // iteration is operator-visible via metric and logs. chatRetentionDays, chatRetentionErr := db.GetChatRetentionDays(ctx) if chatRetentionErr != nil { @@ -195,7 +200,13 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. i.logger.Error(ctx, "failed to read chat auto-archive config: skipping chat purge and auto-archive this tick", slog.Error(chatAutoArchiveErr)) } - chatConfigErr := errors.Join(chatRetentionErr, chatAutoArchiveErr) + chatDebugRetentionDays, chatDebugRetentionErr := db.GetChatDebugRetentionDays(ctx, codersdk.DefaultChatDebugRetentionDays) + if chatDebugRetentionErr != nil { + i.logger.Error(ctx, "failed to read chat debug retention config: skipping chat debug purge this tick", slog.Error(chatDebugRetentionErr)) + } + + chatRetentionConfigErr := errors.Join(chatRetentionErr, chatAutoArchiveErr) + chatConfigErr := errors.Join(chatRetentionConfigErr, chatDebugRetentionErr) // Populated inside the tx; dispatched post-commit. var archivedChats []database.AutoArchiveInactiveChatsRow @@ -304,13 +315,26 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. } } - var purgedChats, purgedChatFiles int64 - if chatConfigErr == nil { + var purgedChats, purgedChatFiles, purgedChatDebugRuns int64 + if chatRetentionConfigErr == nil { purgedChats, purgedChatFiles, archivedChats, err = i.purgeChatsInTx(ctx, tx, start, chatRetentionDays, chatAutoArchiveDays) if err != nil { return xerrors.Errorf("failed to purge chats: %w", err) } } + if chatDebugRetentionErr == nil && chatDebugRetentionDays > 0 { + deleteChatDebugRunsBefore := start.Add(-time.Duration(chatDebugRetentionDays) * 24 * time.Hour) + // updated_at is the retention clock, so the window starts after + // the run stops being written to. There is intentionally no + // finished_at guard, so abandoned in-flight rows can be purged. + purgedChatDebugRuns, err = tx.DeleteOldChatDebugRuns(ctx, database.DeleteOldChatDebugRunsParams{ + BeforeTime: deleteChatDebugRunsBefore, + LimitCount: chatDebugRunsBatchSize, + }) + if err != nil { + return xerrors.Errorf("failed to delete old chat debug runs: %w", err) + } + } i.logger.Debug(ctx, "purged old database entries", slog.F("workspace_agent_logs", purgedWorkspaceAgentLogs), @@ -320,14 +344,11 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. slog.F("audit_logs", purgedAuditLogs), slog.F("chats", purgedChats), slog.F("chat_files", purgedChatFiles), + slog.F("chat_debug_runs", purgedChatDebugRuns), slog.F("auto_archived_chats", len(archivedChats)), slog.F("duration", i.clk.Since(start)), ) - if i.iterationDuration != nil { - duration := i.clk.Since(start) - i.iterationDuration.WithLabelValues("true").Observe(duration.Seconds()) - } if i.recordsPurged != nil { i.recordsPurged.WithLabelValues("workspace_agent_logs").Add(float64(purgedWorkspaceAgentLogs)) i.recordsPurged.WithLabelValues("expired_api_keys").Add(float64(expiredAPIKeys)) @@ -335,9 +356,17 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. i.recordsPurged.WithLabelValues("connection_logs").Add(float64(purgedConnectionLogs)) i.recordsPurged.WithLabelValues("audit_logs").Add(float64(purgedAuditLogs)) i.recordsPurged.WithLabelValues("chats").Add(float64(purgedChats)) + i.recordsPurged.WithLabelValues("chat_debug_runs").Add(float64(purgedChatDebugRuns)) i.recordsPurged.WithLabelValues("chat_files").Add(float64(purgedChatFiles)) } + // chatConfigErr is returned after the tx, so do not record this + // iteration as successful when only the deferred config read failed. + if i.iterationDuration != nil && chatConfigErr == nil { + duration := i.clk.Since(start) + i.iterationDuration.WithLabelValues("true").Observe(duration.Seconds()) + } + return nil }, database.DefaultTXOptions().WithID("db_purge")) if err != nil { diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 9d02aba6e1..bb4c17fc4a 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -61,6 +61,7 @@ func TestPurge(t *testing.T) { mDB := dbmock.NewMockStore(gomock.NewController(t)) mDB.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(0), nil).AnyTimes() mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays).Return(int32(0), nil).AnyTimes() + mDB.EXPECT().GetChatDebugRetentionDays(gomock.Any(), codersdk.DefaultChatDebugRetentionDays).Return(int32(0), nil).AnyTimes() mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).Return(nil).Times(2) purger := dbpurge.New(context.Background(), testutil.Logger(t), mDB, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), nopAuditorPtr(t), dbpurge.WithClock(clk)) <-done // wait for doTick() to run. @@ -139,12 +140,57 @@ func TestMetrics(t *testing.T) { }) require.GreaterOrEqual(t, chats, 0) + chatDebugRuns := promhelp.CounterValue(t, reg, "coderd_dbpurge_records_purged_total", prometheus.Labels{ + "record_type": "chat_debug_runs", + }) + require.GreaterOrEqual(t, chatDebugRuns, 0) + chatFiles := promhelp.CounterValue(t, reg, "coderd_dbpurge_records_purged_total", prometheus.Labels{ "record_type": "chat_files", }) require.GreaterOrEqual(t, chatFiles, 0) }) + t.Run("LockNotAcquiredSkipsIterationMetric", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + reg := prometheus.NewRegistry() + clk := quartz.NewMock(t) + now := clk.Now() + clk.Set(now).MustWait(ctx) + + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + mDB.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(0), nil).AnyTimes() + mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays). + Return(int32(0), nil).AnyTimes() + mDB.EXPECT().GetChatDebugRetentionDays(gomock.Any(), codersdk.DefaultChatDebugRetentionDays). + Return(int32(0), nil).AnyTimes() + mDB.EXPECT().TryAcquireLock(gomock.Any(), int64(database.LockIDDBPurge)).Return(false, nil).AnyTimes() + mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). + DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { + return f(mDB) + }).MinTimes(1) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + done := awaitDoTick(ctx, t, clk) + closer := dbpurge.New(ctx, logger, mDB, &codersdk.DeploymentValues{}, reg, nopAuditorPtr(t), dbpurge.WithClock(clk)) + defer closer.Close() + testutil.TryReceive(ctx, t, done) + + successHist := promhelp.MetricValue(t, reg, "coderd_dbpurge_iteration_duration_seconds", prometheus.Labels{ + "success": "true", + }) + require.Nil(t, successHist, "lock contention should not record a successful purge iteration") + + failedHist := promhelp.MetricValue(t, reg, "coderd_dbpurge_iteration_duration_seconds", prometheus.Labels{ + "success": "false", + }) + require.Nil(t, failedHist, "lock contention should not record a failed purge iteration") + }) + t.Run("FailedIteration", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() @@ -158,6 +204,8 @@ func TestMetrics(t *testing.T) { mDB := dbmock.NewMockStore(ctrl) mDB.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(0), nil).AnyTimes() mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays).Return(int32(0), nil).AnyTimes() + mDB.EXPECT().GetChatDebugRetentionDays(gomock.Any(), codersdk.DefaultChatDebugRetentionDays). + Return(int32(0), nil).AnyTimes() mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). Return(xerrors.New("simulated database error")). MinTimes(1) @@ -181,9 +229,9 @@ func TestMetrics(t *testing.T) { require.Nil(t, successHist, "should not have success=true metric on failure") }) - // A failed retention read must not block unrelated purges, - // but must skip the chat passes and surface as a failed - // iteration via the metric. + // A failed retention read must not block unrelated or chat debug + // purges, but must skip the conversation purge and auto-archive + // passes and surface as a failed iteration via the metric. t.Run("FailedChatRetentionRead", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() @@ -198,12 +246,24 @@ func TestMetrics(t *testing.T) { mDB.EXPECT().GetChatRetentionDays(gomock.Any()). Return(int32(0), xerrors.New("simulated retention read error")). MinTimes(1) - // Both reads happen before the bail; InTx still runs - // so unrelated purges commit best-effort. + // All reads happen before the bail; InTx still runs so unrelated + // purges and chat debug purge commit best-effort. mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays). Return(int32(0), nil).AnyTimes() + mDB.EXPECT().GetChatDebugRetentionDays(gomock.Any(), codersdk.DefaultChatDebugRetentionDays). + Return(int32(7), nil).AnyTimes() + mDB.EXPECT().TryAcquireLock(gomock.Any(), int64(database.LockIDDBPurge)).Return(true, nil).AnyTimes() + mDB.EXPECT().DeleteOldWorkspaceAgentStats(gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldProvisionerDaemons(gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldNotificationMessages(gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldChatDebugRuns(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatDebugRunsParams{})).Return(int64(0), nil).MinTimes(1) mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). - Return(nil).MinTimes(1) + DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { + return f(mDB) + }).MinTimes(1) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) @@ -242,6 +302,8 @@ func TestMetrics(t *testing.T) { mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays). Return(int32(0), xerrors.New("simulated auto-archive read error")). MinTimes(1) + mDB.EXPECT().GetChatDebugRetentionDays(gomock.Any(), codersdk.DefaultChatDebugRetentionDays). + Return(int32(0), nil).AnyTimes() // InTx still runs so unrelated purges commit; chat // passes inside the tx are skipped. mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). @@ -266,6 +328,59 @@ func TestMetrics(t *testing.T) { }) require.Nil(t, successHist, "should not have success=true metric on auto-archive read failure") }) + + // Same contract as the other chat config reads, but debug retention + // read failures skip only debug purging. + t.Run("FailedChatDebugRetentionRead", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + reg := prometheus.NewRegistry() + clk := quartz.NewMock(t) + now := clk.Now() + clk.Set(now).MustWait(ctx) + + ctrl := gomock.NewController(t) + mDB := dbmock.NewMockStore(ctrl) + mDB.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(30), nil).AnyTimes() + mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays). + Return(int32(0), nil).AnyTimes() + mDB.EXPECT().GetChatDebugRetentionDays(gomock.Any(), codersdk.DefaultChatDebugRetentionDays). + Return(int32(0), xerrors.New("simulated chat debug retention read error")). + MinTimes(1) + mDB.EXPECT().TryAcquireLock(gomock.Any(), int64(database.LockIDDBPurge)).Return(true, nil).AnyTimes() + mDB.EXPECT().DeleteOldWorkspaceAgentStats(gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldProvisionerDaemons(gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldNotificationMessages(gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().ExpirePrebuildsAPIKeys(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldTelemetryLocks(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldAuditLogConnectionEvents(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mDB.EXPECT().DeleteOldChats(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatsParams{})).Return(int64(0), nil).MinTimes(1) + mDB.EXPECT().DeleteOldChatFiles(gomock.Any(), gomock.AssignableToTypeOf(database.DeleteOldChatFilesParams{})).Return(int64(0), nil).MinTimes(1) + mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). + DoAndReturn(func(f func(database.Store) error, _ *database.TxOptions) error { + return f(mDB) + }).MinTimes(1) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + done := awaitDoTick(ctx, t, clk) + closer := dbpurge.New(ctx, logger, mDB, &codersdk.DeploymentValues{}, reg, nopAuditorPtr(t), dbpurge.WithClock(clk)) + defer closer.Close() + testutil.TryReceive(ctx, t, done) + + hist := promhelp.HistogramValue(t, reg, "coderd_dbpurge_iteration_duration_seconds", prometheus.Labels{ + "success": "false", + }) + require.NotNil(t, hist) + require.Greater(t, hist.GetSampleCount(), uint64(0), + "failed chat debug retention read must record a failed iteration") + + successHist := promhelp.MetricValue(t, reg, "coderd_dbpurge_iteration_duration_seconds", prometheus.Labels{ + "success": "true", + }) + require.Nil(t, successHist, "should not have success=true metric on chat debug retention read failure") + }) } //nolint:paralleltest // It uses LockIDDBPurge. @@ -1815,6 +1930,201 @@ func mockAuditorPtr(m *audit.MockAuditor) *atomic.Pointer[audit.Auditor] { return &p } +//nolint:paralleltest // It uses LockIDDBPurge. +func TestPurgeChatDebugRuns(t *testing.T) { + now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + + type chatDebugDeps struct { + user database.User + org database.Organization + modelConfig database.ChatModelConfig + } + // setupChatDebugDeps creates the user, organization, and chat model config dependencies needed for the chat debug retention test. + setupChatDebugDeps := func(t *testing.T, db database.Store) chatDebugDeps { + t.Helper() + user := dbgen.User(t, db, database.User{}) + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.ID, + OrganizationID: org.ID, + }) + _ = dbgen.ChatProvider(t, db, database.ChatProvider{ + Provider: "openai", + DisplayName: "OpenAI", + }) + modelConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ + Provider: "openai", + Model: "test-model", + ContextLimit: 8192, + }) + return chatDebugDeps{user: user, org: org, modelConfig: modelConfig} + } + createChat := func(ctx context.Context, t *testing.T, db database.Store, rawDB *sql.DB, deps chatDebugDeps, archived bool, updatedAt time.Time) database.Chat { + t.Helper() + chat := dbgen.Chat(t, db, database.Chat{ + OrganizationID: deps.org.ID, + OwnerID: deps.user.ID, + LastModelConfigID: deps.modelConfig.ID, + Title: "debug-retention-test-chat", + }) + if archived { + _, err := db.ArchiveChatByID(ctx, chat.ID) + require.NoError(t, err) + } + _, err := rawDB.ExecContext(ctx, "UPDATE chats SET updated_at = $1 WHERE id = $2", updatedAt, chat.ID) + require.NoError(t, err) + return chat + } + createDebugRunWithStep := func(ctx context.Context, t *testing.T, db database.Store, chatID uuid.UUID, updatedAt time.Time, finished bool) database.ChatDebugRun { + t.Helper() + run, err := db.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{ + ChatID: chatID, + Kind: string(codersdk.ChatDebugRunKindChatTurn), + Status: string(codersdk.ChatDebugStatusInProgress), + Provider: sql.NullString{String: "openai", Valid: true}, + Model: sql.NullString{String: "gpt-4o-mini", Valid: true}, + StartedAt: sql.NullTime{Time: updatedAt.Add(-time.Minute), Valid: true}, + UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true}, + }) + require.NoError(t, err) + _, err = db.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{ + RunID: run.ID, + ChatID: run.ChatID, + StepNumber: 1, + Operation: string(codersdk.ChatDebugStepOperationStream), + Status: string(codersdk.ChatDebugStatusCompleted), + StartedAt: sql.NullTime{Time: updatedAt.Add(-time.Minute), Valid: true}, + UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true}, + FinishedAt: sql.NullTime{Time: updatedAt, Valid: true}, + }) + require.NoError(t, err) + if finished { + run, err = db.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{ + Status: sql.NullString{String: string(codersdk.ChatDebugStatusCompleted), Valid: true}, + FinishedAt: sql.NullTime{Time: updatedAt, Valid: true}, + Now: updatedAt, + ID: run.ID, + ChatID: run.ChatID, + }) + require.NoError(t, err) + } + return run + } + countDebugSteps := func(ctx context.Context, t *testing.T, rawDB *sql.DB, runID uuid.UUID) int { + t.Helper() + var count int + err := rawDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM chat_debug_steps WHERE run_id = $1", runID).Scan(&count) + require.NoError(t, err) + return count + } + + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "DeletesOldRunsAndCascadedSteps", + run: func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + clk := quartz.NewMock(t) + clk.Set(now).MustWait(ctx) + + db, _, rawDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure()) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + reg := prometheus.NewRegistry() + deps := setupChatDebugDeps(t, db) + require.NoError(t, db.UpsertChatDebugRetentionDays(ctx, int32(7))) + + chat := createChat(ctx, t, db, rawDB, deps, false, now) + oldRun := createDebugRunWithStep(ctx, t, db, chat.ID, now.Add(-8*24*time.Hour), true) + recentRun := createDebugRunWithStep(ctx, t, db, chat.ID, now.Add(-6*24*time.Hour), true) + unfinishedOldRun := createDebugRunWithStep(ctx, t, db, chat.ID, now.Add(-9*24*time.Hour), false) + + done := awaitDoTick(ctx, t, clk) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, reg, nopAuditorPtr(t), dbpurge.WithClock(clk)) + defer closer.Close() + testutil.TryReceive(ctx, t, done) + + chatDebugRuns := promhelp.CounterValue(t, reg, "coderd_dbpurge_records_purged_total", prometheus.Labels{ + "record_type": "chat_debug_runs", + }) + require.Greater(t, chatDebugRuns, 0, "chat debug purge counter should record deleted runs") + + _, err := db.GetChatDebugRunByID(ctx, oldRun.ID) + require.ErrorIs(t, err, sql.ErrNoRows, "old finished run should be deleted") + require.Zero(t, countDebugSteps(ctx, t, rawDB, oldRun.ID), "old run steps should cascade") + + _, err = db.GetChatDebugRunByID(ctx, unfinishedOldRun.ID) + require.ErrorIs(t, err, sql.ErrNoRows, "old unfinished run should be deleted") + require.Zero(t, countDebugSteps(ctx, t, rawDB, unfinishedOldRun.ID), "old unfinished run steps should cascade") + + _, err = db.GetChatDebugRunByID(ctx, recentRun.ID) + require.NoError(t, err, "recent run should remain") + require.Equal(t, 1, countDebugSteps(ctx, t, rawDB, recentRun.ID), "recent run step should remain") + }, + }, + { + name: "RetentionDisabledKeepsOldRuns", + run: func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + clk := quartz.NewMock(t) + clk.Set(now).MustWait(ctx) + + db, _, rawDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure()) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + deps := setupChatDebugDeps(t, db) + require.NoError(t, db.UpsertChatDebugRetentionDays(ctx, int32(0))) + + chat := createChat(ctx, t, db, rawDB, deps, false, now) + oldRun := createDebugRunWithStep(ctx, t, db, chat.ID, now.Add(-90*24*time.Hour), true) + + done := awaitDoTick(ctx, t, clk) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), nopAuditorPtr(t), dbpurge.WithClock(clk)) + defer closer.Close() + testutil.TryReceive(ctx, t, done) + + _, err := db.GetChatDebugRunByID(ctx, oldRun.ID) + require.NoError(t, err, "old run should remain when retention is disabled") + require.Equal(t, 1, countDebugSteps(ctx, t, rawDB, oldRun.ID), "old run step should remain") + }, + }, + { + name: "ChatCascadeDeletesDebugRows", + run: func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + clk := quartz.NewMock(t) + clk.Set(now).MustWait(ctx) + + db, _, rawDB := dbtestutil.NewDBWithSQLDB(t, dbtestutil.WithDumpOnFailure()) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + deps := setupChatDebugDeps(t, db) + require.NoError(t, db.UpsertChatRetentionDays(ctx, int32(30))) + require.NoError(t, db.UpsertChatDebugRetentionDays(ctx, int32(0))) + + oldArchivedChat := createChat(ctx, t, db, rawDB, deps, true, now.Add(-31*24*time.Hour)) + run := createDebugRunWithStep(ctx, t, db, oldArchivedChat.ID, now, true) + + done := awaitDoTick(ctx, t, clk) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), nopAuditorPtr(t), dbpurge.WithClock(clk)) + defer closer.Close() + testutil.TryReceive(ctx, t, done) + + _, err := db.GetChatByID(ctx, oldArchivedChat.ID) + require.ErrorIs(t, err, sql.ErrNoRows, "old archived chat should be deleted") + _, err = db.GetChatDebugRunByID(ctx, run.ID) + require.ErrorIs(t, err, sql.ErrNoRows, "chat deletion should cascade to debug runs") + require.Zero(t, countDebugSteps(ctx, t, rawDB, run.ID), "chat deletion should cascade to debug steps") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest // subtests use LockIDDBPurge. + tt.run(t) + }) + } +} + //nolint:paralleltest // It uses LockIDDBPurge. func TestDeleteOldChatFiles(t *testing.T) { now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) @@ -1949,7 +2259,7 @@ func TestDeleteOldChatFiles(t *testing.T) { // Old archived chat should be gone. _, err = db.GetChatByID(ctx, oldChat.ID) - require.Error(t, err, "old archived chat should be deleted") + require.ErrorIs(t, err, sql.ErrNoRows, "old archived chat should be deleted") // Its messages should be gone too (CASCADE). msgs, err := db.GetChatMessagesByChatID(ctx, database.GetChatMessagesByChatIDParams{ diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 533e750d96..491bbee16b 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -3787,6 +3787,8 @@ CREATE UNIQUE INDEX idx_chat_debug_runs_id_chat ON chat_debug_runs USING btree ( CREATE INDEX idx_chat_debug_runs_stale ON chat_debug_runs USING btree (updated_at) WHERE (finished_at IS NULL); +CREATE INDEX idx_chat_debug_runs_updated_at ON chat_debug_runs USING btree (updated_at); + CREATE INDEX idx_chat_debug_steps_chat_assistant_msg ON chat_debug_steps USING btree (chat_id, assistant_message_id) WHERE (assistant_message_id IS NOT NULL); CREATE INDEX idx_chat_debug_steps_chat_tip ON chat_debug_steps USING btree (chat_id, history_tip_message_id); diff --git a/coderd/database/migrations/000487_chat_debug_runs_updated_at_index.down.sql b/coderd/database/migrations/000487_chat_debug_runs_updated_at_index.down.sql new file mode 100644 index 0000000000..6715127ad6 --- /dev/null +++ b/coderd/database/migrations/000487_chat_debug_runs_updated_at_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_chat_debug_runs_updated_at; diff --git a/coderd/database/migrations/000487_chat_debug_runs_updated_at_index.up.sql b/coderd/database/migrations/000487_chat_debug_runs_updated_at_index.up.sql new file mode 100644 index 0000000000..b891f0c53e --- /dev/null +++ b/coderd/database/migrations/000487_chat_debug_runs_updated_at_index.up.sql @@ -0,0 +1 @@ +CREATE INDEX idx_chat_debug_runs_updated_at ON chat_debug_runs (updated_at); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index de52a2d698..795c9a7af1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -146,6 +146,11 @@ type sqlcQuerier interface { // connection events (connect, disconnect, open, close) which are handled // separately by DeleteOldAuditLogConnectionEvents. DeleteOldAuditLogs(ctx context.Context, arg DeleteOldAuditLogsParams) (int64, error) + // updated_at is the retention clock, so the window starts after the run + // stops being written to. + // Intentionally no finished_at IS NOT NULL guard: abandoned in-flight rows + // older than the cutoff are also purged. + DeleteOldChatDebugRuns(ctx context.Context, arg DeleteOldChatDebugRunsParams) (int64, error) // TODO(cian): Add indexes on chats(archived, updated_at) and // chat_files(created_at) for purge query performance. // See: https://github.com/coder/internal/issues/1438 @@ -300,6 +305,8 @@ type sqlcQuerier interface { // allows users to opt into chat debug logging when the deployment does // not already force debug logging on globally. GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, error) + // Chat debug run retention window in days. 0 disables. + GetChatDebugRetentionDays(ctx context.Context, defaultDebugRetentionDays int32) (int32, error) GetChatDebugRunByID(ctx context.Context, id uuid.UUID) (ChatDebugRun, error) // Returns the most recent debug runs for a chat, ordered newest-first. // Callers must supply an explicit limit to avoid unbounded result sets. @@ -846,6 +853,8 @@ type sqlcQuerier interface { InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) + // updated_at is the retention clock used by DeleteOldChatDebugRuns. + // Set it on every write to keep retention semantics correct. InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error) // The CTE atomically locks the parent run via UPDATE, bumps its // updated_at (eliminating a separate TouchChatDebugRunUpdatedAt @@ -1073,6 +1082,7 @@ type sqlcQuerier interface { // write-once-finalize pattern where fields are set at creation // or finalization and never cleared back to NULL. The @now // parameter keeps updated_at under the caller's clock. + // updated_at is also the retention clock used by DeleteOldChatDebugRuns. // // finished_at is enforced as write-once at the SQL level: once // populated it cannot be overwritten by a later call. Callers @@ -1229,6 +1239,7 @@ type sqlcQuerier interface { // UpsertChatDebugLoggingAllowUsers updates the runtime admin setting that // allows users to opt into chat debug logging. UpsertChatDebugLoggingAllowUsers(ctx context.Context, allowUsers bool) error + UpsertChatDebugRetentionDays(ctx context.Context, debugRetentionDays int32) error UpsertChatDesktopEnabled(ctx context.Context, enableDesktop bool) error UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDiffStatusParams) (ChatDiffStatus, error) UpsertChatDiffStatusReference(ctx context.Context, arg UpsertChatDiffStatusReferenceParams) (ChatDiffStatus, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 04c2c27752..c18e34bb19 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2993,6 +2993,37 @@ func (q *sqlQuerier) DeleteChatDebugDataByChatID(ctx context.Context, arg Delete return result.RowsAffected() } +const deleteOldChatDebugRuns = `-- name: DeleteOldChatDebugRuns :execrows +WITH deletable AS ( + SELECT id, chat_id + FROM chat_debug_runs + WHERE updated_at < $1::timestamptz + ORDER BY updated_at ASC + LIMIT $2::int +) +DELETE FROM chat_debug_runs +USING deletable +WHERE chat_debug_runs.id = deletable.id + AND chat_debug_runs.chat_id = deletable.chat_id +` + +type DeleteOldChatDebugRunsParams struct { + BeforeTime time.Time `db:"before_time" json:"before_time"` + LimitCount int32 `db:"limit_count" json:"limit_count"` +} + +// updated_at is the retention clock, so the window starts after the run +// stops being written to. +// Intentionally no finished_at IS NOT NULL guard: abandoned in-flight rows +// older than the cutoff are also purged. +func (q *sqlQuerier) DeleteOldChatDebugRuns(ctx context.Context, arg DeleteOldChatDebugRunsParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteOldChatDebugRuns, arg.BeforeTime, arg.LimitCount) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const finalizeStaleChatDebugRows = `-- name: FinalizeStaleChatDebugRows :one WITH finalized_runs AS ( UPDATE chat_debug_runs @@ -3236,6 +3267,8 @@ type InsertChatDebugRunParams struct { FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"` } +// updated_at is the retention clock used by DeleteOldChatDebugRuns. +// Set it on every write to keep retention semantics correct. func (q *sqlQuerier) InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error) { row := q.db.QueryRowContext(ctx, insertChatDebugRun, arg.ChatID, @@ -3503,6 +3536,7 @@ type UpdateChatDebugRunParams struct { // write-once-finalize pattern where fields are set at creation // or finalization and never cleared back to NULL. The @now // parameter keeps updated_at under the caller's clock. +// updated_at is also the retention clock used by DeleteOldChatDebugRuns. // // finished_at is enforced as write-once at the SQL level: once // populated it cannot be overwritten by a later call. Callers @@ -20595,6 +20629,22 @@ func (q *sqlQuerier) GetChatDebugLoggingAllowUsers(ctx context.Context) (bool, e return allow_users, err } +const getChatDebugRetentionDays = `-- name: GetChatDebugRetentionDays :one +SELECT COALESCE( + (SELECT value::integer FROM site_configs + WHERE key = 'agents_chat_debug_retention_days'), + $1::integer +) :: integer AS debug_retention_days +` + +// Chat debug run retention window in days. 0 disables. +func (q *sqlQuerier) GetChatDebugRetentionDays(ctx context.Context, defaultDebugRetentionDays int32) (int32, error) { + row := q.db.QueryRowContext(ctx, getChatDebugRetentionDays, defaultDebugRetentionDays) + var debug_retention_days int32 + err := row.Scan(&debug_retention_days) + return debug_retention_days, err +} + const getChatDesktopEnabled = `-- name: GetChatDesktopEnabled :one SELECT COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_desktop_enabled'), false) :: boolean AS enable_desktop @@ -21027,6 +21077,18 @@ func (q *sqlQuerier) UpsertChatDebugLoggingAllowUsers(ctx context.Context, allow return err } +const upsertChatDebugRetentionDays = `-- name: UpsertChatDebugRetentionDays :exec +INSERT INTO site_configs (key, value) +VALUES ('agents_chat_debug_retention_days', CAST($1 AS integer)::text) +ON CONFLICT (key) DO UPDATE SET value = CAST($1 AS integer)::text +WHERE site_configs.key = 'agents_chat_debug_retention_days' +` + +func (q *sqlQuerier) UpsertChatDebugRetentionDays(ctx context.Context, debugRetentionDays int32) error { + _, err := q.db.ExecContext(ctx, upsertChatDebugRetentionDays, debugRetentionDays) + return err +} + const upsertChatDesktopEnabled = `-- name: UpsertChatDesktopEnabled :exec INSERT INTO site_configs (key, value) VALUES ( diff --git a/coderd/database/queries/chatdebug.sql b/coderd/database/queries/chatdebug.sql index ad73722807..daadc8823f 100644 --- a/coderd/database/queries/chatdebug.sql +++ b/coderd/database/queries/chatdebug.sql @@ -1,3 +1,5 @@ +-- updated_at is the retention clock used by DeleteOldChatDebugRuns. +-- Set it on every write to keep retention semantics correct. -- name: InsertChatDebugRun :one INSERT INTO chat_debug_runs ( chat_id, @@ -39,6 +41,7 @@ RETURNING *; -- write-once-finalize pattern where fields are set at creation -- or finalization and never cleared back to NULL. The @now -- parameter keeps updated_at under the caller's clock. +-- updated_at is also the retention clock used by DeleteOldChatDebugRuns. -- -- finished_at is enforced as write-once at the SQL level: once -- populated it cannot be overwritten by a later call. Callers @@ -246,6 +249,23 @@ DELETE FROM chat_debug_runs WHERE chat_id = @chat_id::uuid AND id IN (SELECT id FROM affected_runs); +-- updated_at is the retention clock, so the window starts after the run +-- stops being written to. +-- Intentionally no finished_at IS NOT NULL guard: abandoned in-flight rows +-- older than the cutoff are also purged. +-- name: DeleteOldChatDebugRuns :execrows +WITH deletable AS ( + SELECT id, chat_id + FROM chat_debug_runs + WHERE updated_at < @before_time::timestamptz + ORDER BY updated_at ASC + LIMIT @limit_count::int +) +DELETE FROM chat_debug_runs +USING deletable +WHERE chat_debug_runs.id = deletable.id + AND chat_debug_runs.chat_id = deletable.chat_id; + -- name: FinalizeStaleChatDebugRows :one -- Marks orphaned in-progress rows as interrupted so they do not stay -- in a non-terminal state forever. The NOT IN list must match the diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 60cc968689..709cd287ca 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -358,6 +358,20 @@ VALUES ('agents_chat_retention_days', CAST(@retention_days AS integer)::text) ON CONFLICT (key) DO UPDATE SET value = CAST(@retention_days AS integer)::text WHERE site_configs.key = 'agents_chat_retention_days'; +-- name: GetChatDebugRetentionDays :one +-- Chat debug run retention window in days. 0 disables. +SELECT COALESCE( + (SELECT value::integer FROM site_configs + WHERE key = 'agents_chat_debug_retention_days'), + @default_debug_retention_days::integer +) :: integer AS debug_retention_days; + +-- name: UpsertChatDebugRetentionDays :exec +INSERT INTO site_configs (key, value) +VALUES ('agents_chat_debug_retention_days', CAST(@debug_retention_days AS integer)::text) +ON CONFLICT (key) DO UPDATE SET value = CAST(@debug_retention_days AS integer)::text +WHERE site_configs.key = 'agents_chat_debug_retention_days'; + -- name: GetChatAutoArchiveDays :one -- Auto-archive window in days. 0 disables. SELECT COALESCE( diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 33cd0e7183..d4dc4451ad 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -5248,6 +5248,57 @@ func (api *API) putChatRetentionDays(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } +// getChatDebugRetentionDays returns the deployment-wide chat debug run +// retention window. Any authenticated user can read it; writes require admin. +// +//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler. +func (api *API) getChatDebugRetentionDays(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + retentionDays, err := api.Database.GetChatDebugRetentionDays(ctx, codersdk.DefaultChatDebugRetentionDays) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get chat debug retention days.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatDebugRetentionDaysResponse{ + DebugRetentionDays: retentionDays, + }) +} + +// Keep in sync with the validation schema in +// site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx. +const chatDebugRetentionDaysMaximum = 3650 // ~10 years + +// putChatDebugRetentionDays updates the deployment-wide chat debug run +// retention window. Admin-only. +func (api *API) putChatDebugRetentionDays(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Forbidden(rw) + return + } + var req codersdk.UpdateChatDebugRetentionDaysRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + if req.DebugRetentionDays < 0 || req.DebugRetentionDays > chatDebugRetentionDaysMaximum { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Chat debug retention days must be between 0 and %d.", chatDebugRetentionDaysMaximum), + }) + return + } + if err := api.Database.UpsertChatDebugRetentionDays(ctx, req.DebugRetentionDays); err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update chat debug retention days.", + Detail: err.Error(), + }) + return + } + rw.WriteHeader(http.StatusNoContent) +} + // getChatAutoArchiveDays returns the deployment-wide auto-archive // window. Any authenticated user can read it (same as retention // days); writes require admin. diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index e3de80e28e..d15083d6b7 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -12103,6 +12103,63 @@ func TestChatRetentionDays(t *testing.T) { requireSDKError(t, err, http.StatusBadRequest) } +func TestChatDebugRetentionDays(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + adminClient := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + // Default value is DefaultChatDebugRetentionDays when nothing has + // been configured. + resp, err := adminClient.GetChatDebugRetentionDays(ctx) + require.NoError(t, err, "get default") + require.Equal(t, codersdk.DefaultChatDebugRetentionDays, resp.DebugRetentionDays, "default should match DefaultChatDebugRetentionDays") + + // Admin can set debug retention days to 14. + err = adminClient.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{ + DebugRetentionDays: 14, + }) + require.NoError(t, err, "admin set 14") + + resp, err = adminClient.GetChatDebugRetentionDays(ctx) + require.NoError(t, err, "get after set") + require.Equal(t, int32(14), resp.DebugRetentionDays, "should return 14") + + // Non-admin member can read the value. + memberResp, err := memberClient.GetChatDebugRetentionDays(ctx) + require.NoError(t, err, "member read") + require.Equal(t, int32(14), memberResp.DebugRetentionDays, "member sees same value") + + // Non-admin member cannot write. + err = memberClient.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{DebugRetentionDays: 7}) + requireSDKError(t, err, http.StatusForbidden) + + // Admin can disable chat debug retention purge by setting 0. + err = adminClient.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{ + DebugRetentionDays: 0, + }) + require.NoError(t, err, "admin set 0") + + resp, err = adminClient.GetChatDebugRetentionDays(ctx) + require.NoError(t, err, "get after zero") + require.Equal(t, int32(0), resp.DebugRetentionDays, "should be 0 after disable") + + // Validation: negative value is rejected. + err = adminClient.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{ + DebugRetentionDays: -1, + }) + requireSDKError(t, err, http.StatusBadRequest) + + // Validation: exceeding the 3650-day maximum is rejected. + err = adminClient.UpdateChatDebugRetentionDays(ctx, codersdk.UpdateChatDebugRetentionDaysRequest{ + DebugRetentionDays: 3651, // chatDebugRetentionDaysMaximum + 1; keep in sync with coderd/exp_chats.go. + }) + requireSDKError(t, err, http.StatusBadRequest) +} + func TestChatAutoArchiveDays(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/codersdk/chats.go b/codersdk/chats.go index 5169f467f6..035eef6c9b 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -945,6 +945,11 @@ const DefaultChatWorkspaceTTL = 0 // auto-archival. const DefaultChatAutoArchiveDays int32 = 0 +// DefaultChatDebugRetentionDays is the default chat debug run retention +// window, in days, applied when no site config row exists. Set the +// config value to zero to disable the purge. +const DefaultChatDebugRetentionDays int32 = 30 + // ChatWorkspaceTTLResponse is the response for getting the chat // workspace TTL setting. type ChatWorkspaceTTLResponse struct { @@ -972,6 +977,18 @@ type UpdateChatRetentionDaysRequest struct { RetentionDays int32 `json:"retention_days"` } +// ChatDebugRetentionDaysResponse contains the current chat debug run +// retention setting. +type ChatDebugRetentionDaysResponse struct { + DebugRetentionDays int32 `json:"debug_retention_days"` +} + +// UpdateChatDebugRetentionDaysRequest is a request to update the chat +// debug run retention period. +type UpdateChatDebugRetentionDaysRequest struct { + DebugRetentionDays int32 `json:"debug_retention_days"` +} + // ChatAutoArchiveDaysResponse contains the current chat auto-archive setting. type ChatAutoArchiveDaysResponse struct { AutoArchiveDays int32 `json:"auto_archive_days"` @@ -2462,6 +2479,34 @@ func (c *ExperimentalClient) UpdateChatRetentionDays(ctx context.Context, req Up return nil } +// GetChatDebugRetentionDays returns the configured chat debug run +// retention period. +func (c *ExperimentalClient) GetChatDebugRetentionDays(ctx context.Context) (ChatDebugRetentionDaysResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/debug-retention-days", nil) + if err != nil { + return ChatDebugRetentionDaysResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ChatDebugRetentionDaysResponse{}, ReadBodyAsError(res) + } + var resp ChatDebugRetentionDaysResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpdateChatDebugRetentionDays updates the chat debug run retention period. +func (c *ExperimentalClient) UpdateChatDebugRetentionDays(ctx context.Context, req UpdateChatDebugRetentionDaysRequest) error { + res, err := c.Request(ctx, http.MethodPut, "/api/experimental/chats/config/debug-retention-days", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // GetChatAutoArchiveDays returns the configured chat auto-archive period. func (c *ExperimentalClient) GetChatAutoArchiveDays(ctx context.Context) (ChatAutoArchiveDaysResponse, error) { res, err := c.Request(ctx, http.MethodGet, "/api/experimental/chats/config/auto-archive-days", nil) diff --git a/docs/ai-coder/agents/platform-controls/chat-debug-retention.md b/docs/ai-coder/agents/platform-controls/chat-debug-retention.md new file mode 100644 index 0000000000..b715800988 --- /dev/null +++ b/docs/ai-coder/agents/platform-controls/chat-debug-retention.md @@ -0,0 +1,46 @@ +# Chat Debug Data Retention + +Coder Agents automatically cleans up old chat debug data to manage database +growth. Debug data includes persisted debug runs and their associated debug +steps. + +This setting is independent from [conversation data retention](./chat-retention.md), +which only purges archived conversations and orphaned files. + +## How it works + +A background process removes debug runs older than the configured retention +period. When a debug run is deleted, its debug steps are deleted via cascade. + +The retention clock uses the debug run's `updated_at` value, which reflects the +last write to the debug run. It does not use the chat archive time. If a debug +run remains in progress for an unusually long period, such as after broken +finalization, it can still be purged once its `updated_at` value is older than +the cutoff. + +## Configuration + +Navigate to the **Agents** page, open **Settings**, and select the +**Lifecycle** tab to configure chat debug data retention. The default is 30 days. +Set the value to `0` to disable debug data retention entirely. The maximum value +is `3650` days. + +Use the experimental admin API to read or update the value: + +```text +GET /api/experimental/chats/config/debug-retention-days +PUT /api/experimental/chats/config/debug-retention-days +``` + +## Interaction with conversation retention + +Conversation retention and debug data retention are orthogonal controls: + +| Control | What it deletes | Default | +|------------------------|-------------------------------------------------------------|---------| +| Conversation retention | Archived conversations and orphaned files | 30 days | +| Debug data retention | Debug runs and debug steps, based on debug run `updated_at` | 30 days | + +Deleting a chat still deletes its debug data immediately via cascade, regardless +of the debug retention window. Unarchiving a chat does not restore debug data +that was already purged. diff --git a/docs/ai-coder/agents/platform-controls/chat-retention.md b/docs/ai-coder/agents/platform-controls/chat-retention.md index 2b12df9af3..d6454104e4 100644 --- a/docs/ai-coder/agents/platform-controls/chat-retention.md +++ b/docs/ai-coder/agents/platform-controls/chat-retention.md @@ -8,6 +8,9 @@ Conversations become eligible for purging only after they are archived. Old conversations can be archived manually, or automatically. See [Auto-Archive](./chat-auto-archive.md) for how the two controls interact. +Debug run and step cleanup is controlled separately. See +[Chat Debug Data Retention](./chat-debug-retention.md). + ## How it works A background process runs approximately every 10 minutes to remove expired @@ -25,9 +28,12 @@ Navigate to the **Agents** page, open **Settings**, and select the **Behavior** tab to configure the conversation retention period. The default is 30 days. Use the toggle to disable retention entirely. -The retention period is stored as the `agents_chat_retention_days` key in the -`site_configs` table and can also be managed via the API at -`/api/experimental/chats/config/retention-days`. +Use the experimental admin API to read or update the value: + +```text +GET /api/experimental/chats/config/retention-days +PUT /api/experimental/chats/config/retention-days +``` ## What gets deleted diff --git a/docs/manifest.json b/docs/manifest.json index 67f17b0ad9..2bd7b4f068 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1043,6 +1043,12 @@ "path": "./ai-coder/agents/platform-controls/chat-retention.md", "state": ["beta"] }, + { + "title": "Debug Data Retention", + "description": "Automatic cleanup of old chat debug data", + "path": "./ai-coder/agents/platform-controls/chat-debug-retention.md", + "state": ["beta"] + }, { "title": "Auto-Archive", "description": "Automatic archiving of inactive conversations", diff --git a/site/src/api/api.ts b/site/src/api/api.ts index db0b637682..0e4c777e42 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3451,6 +3451,24 @@ class ExperimentalApiMethods { await this.axios.put("/api/experimental/chats/config/retention-days", req); }; + getChatDebugRetentionDays = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/config/debug-retention-days", + ); + return response.data; + }; + + updateChatDebugRetentionDays = async ( + req: TypesGen.UpdateChatDebugRetentionDaysRequest, + ): Promise => { + await this.axios.put( + "/api/experimental/chats/config/debug-retention-days", + req, + ); + }; + getChatAutoArchiveDays = async (): Promise => { const response = diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 2131576f3d..670cf68a16 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -1422,6 +1422,22 @@ export const updateChatRetentionDays = (queryClient: QueryClient) => ({ }, }); +const chatDebugRetentionDaysKey = ["chat-debug-retention-days"] as const; + +export const chatDebugRetentionDays = () => ({ + queryKey: chatDebugRetentionDaysKey, + queryFn: () => API.experimental.getChatDebugRetentionDays(), +}); + +export const updateChatDebugRetentionDays = (queryClient: QueryClient) => ({ + mutationFn: API.experimental.updateChatDebugRetentionDays, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: chatDebugRetentionDaysKey, + }); + }, +}); + const chatAutoArchiveDaysKey = ["chat-auto-archive-days"] as const; export const chatAutoArchiveDays = () => ({ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a4824cabdc..3a327a5ec0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1520,6 +1520,15 @@ export interface ChatDebugLoggingAdminSettings { readonly forced_by_deployment: boolean; } +// From codersdk/chats.go +/** + * ChatDebugRetentionDaysResponse contains the current chat debug run + * retention setting. + */ +export interface ChatDebugRetentionDaysResponse { + readonly debug_retention_days: number; +} + // From codersdk/chats.go /** * ChatDebugRun is the detailed run response returned by the run-detail @@ -3568,6 +3577,14 @@ export interface DebugProfileOptions { */ export const DefaultChatAutoArchiveDays = 0; +// From codersdk/chats.go +/** + * DefaultChatDebugRetentionDays is the default chat debug run retention + * window, in days, applied when no site config row exists. Set the + * config value to zero to disable the purge. + */ +export const DefaultChatDebugRetentionDays = 30; + // From codersdk/chats.go /** * DefaultChatWorkspaceTTL is the default TTL for chat workspaces. @@ -7918,6 +7935,15 @@ export interface UpdateChatDebugLoggingAllowUsersRequest { readonly allow_users: boolean; } +// From codersdk/chats.go +/** + * UpdateChatDebugRetentionDaysRequest is a request to update the chat + * debug run retention period. + */ +export interface UpdateChatDebugRetentionDaysRequest { + readonly debug_retention_days: number; +} + // From codersdk/chats.go /** * UpdateChatDesktopEnabledRequest is the request to update the desktop setting. diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx index 6bac0827f3..26a1f60389 100644 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePage.tsx @@ -2,9 +2,11 @@ import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { chatAutoArchiveDays, + chatDebugRetentionDays, chatRetentionDays, chatWorkspaceTTL, updateChatAutoArchiveDays, + updateChatDebugRetentionDays, updateChatRetentionDays, updateChatWorkspaceTTL, } from "#/api/queries/chats"; @@ -27,6 +29,10 @@ const AgentSettingsLifecyclePage: FC = () => { ...chatAutoArchiveDays(), enabled: permissions.editDeploymentConfig, }); + const debugRetentionDaysQuery = useQuery({ + ...chatDebugRetentionDays(), + enabled: permissions.editDeploymentConfig, + }); const saveWorkspaceTTLMutation = useMutation( updateChatWorkspaceTTL(queryClient), ); @@ -36,6 +42,9 @@ const AgentSettingsLifecyclePage: FC = () => { const saveAutoArchiveDaysMutation = useMutation( updateChatAutoArchiveDays(queryClient), ); + const saveDebugRetentionDaysMutation = useMutation( + updateChatDebugRetentionDays(queryClient), + ); return ( @@ -52,6 +61,12 @@ const AgentSettingsLifecyclePage: FC = () => { onSaveRetentionDays={saveRetentionDaysMutation.mutate} isSavingRetentionDays={saveRetentionDaysMutation.isPending} isSaveRetentionDaysError={saveRetentionDaysMutation.isError} + debugRetentionDaysData={debugRetentionDaysQuery.data} + isDebugRetentionDaysLoading={debugRetentionDaysQuery.isLoading} + isDebugRetentionDaysLoadError={debugRetentionDaysQuery.isError} + onSaveDebugRetentionDays={saveDebugRetentionDaysMutation.mutate} + isSavingDebugRetentionDays={saveDebugRetentionDaysMutation.isPending} + isSaveDebugRetentionDaysError={saveDebugRetentionDaysMutation.isError} autoArchiveDaysData={autoArchiveDaysQuery.data} isAutoArchiveDaysLoading={autoArchiveDaysQuery.isLoading} isAutoArchiveDaysLoadError={autoArchiveDaysQuery.isError} diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx index da443368a2..187929e207 100644 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.stories.tsx @@ -18,6 +18,12 @@ const baseArgs: AgentSettingsLifecyclePageViewProps = { onSaveRetentionDays: fn(), isSavingRetentionDays: false, isSaveRetentionDaysError: false, + debugRetentionDaysData: { debug_retention_days: 30 }, + isDebugRetentionDaysLoading: false, + isDebugRetentionDaysLoadError: false, + onSaveDebugRetentionDays: fn(), + isSavingDebugRetentionDays: false, + isSaveDebugRetentionDaysError: false, autoArchiveDaysData: { auto_archive_days: 0 }, isAutoArchiveDaysLoading: false, isAutoArchiveDaysLoadError: false, @@ -471,7 +477,7 @@ export const RetentionToggleOnSavesDefault: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); const toggle = await canvas.findByRole("switch", { - name: /retention/i, + name: "Enable conversation retention", }); expect(toggle).not.toBeChecked(); @@ -520,7 +526,7 @@ export const RetentionToggleOffSavesDisabled: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); const toggle = await canvas.findByRole("switch", { - name: /retention/i, + name: "Enable conversation retention", }); expect(toggle).toBeChecked(); @@ -624,3 +630,207 @@ export const RetentionBelowMin: Story = { }); }, }; + +export const DebugRetentionLoadedDefault: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText("Chat Debug Data Retention"); + await canvas.findByText(/debug runs and debug steps/i); + await canvas.findByText(/does not control chat message retention/i); + + const toggle = await canvas.findByRole("switch", { + name: "Enable chat debug data retention", + }); + expect(toggle).toBeChecked(); + + const input = await canvas.findByLabelText( + "Chat debug data retention period in days", + ); + expect(input).toHaveValue(30); + }, +}; + +export const DebugRetentionToggleOffSavesDisabled: Story = { + args: { + debugRetentionDaysData: { debug_retention_days: 30 }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable chat debug data retention", + }); + expect(toggle).toBeChecked(); + + await userEvent.click(toggle); + await waitFor(() => { + expect(args.onSaveDebugRetentionDays).toHaveBeenCalledWith( + { debug_retention_days: 0 }, + expect.anything(), + ); + }); + }, +}; + +export const DebugRetentionToggleOnSavesDefault: Story = { + args: { + debugRetentionDaysData: { debug_retention_days: 0 }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable chat debug data retention", + }); + expect(toggle).not.toBeChecked(); + + const debugRetentionForm = toggle.closest("form"); + if (!(debugRetentionForm instanceof HTMLFormElement)) { + throw new Error("Expected debug retention toggle to live inside a form."); + } + + await userEvent.click(toggle); + + await waitFor(() => { + expect(args.onSaveDebugRetentionDays).toHaveBeenNthCalledWith( + 1, + { debug_retention_days: 30 }, + expect.anything(), + ); + }); + + const input = await within(debugRetentionForm).findByLabelText( + "Chat debug data retention period in days", + ); + expect(input).toHaveValue(30); + }, +}; + +export const DebugRetentionEditDaysAndSave: Story = { + args: { + debugRetentionDaysData: { debug_retention_days: 30 }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const input = await canvas.findByLabelText( + "Chat debug data retention period in days", + ); + const debugRetentionForm = input.closest("form"); + if (!(debugRetentionForm instanceof HTMLFormElement)) { + throw new Error("Expected debug retention input to live inside a form."); + } + + await userEvent.clear(input); + await userEvent.type(input, "14"); + + const saveButton = within(debugRetentionForm).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(saveButton).toBeEnabled(); + }); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(args.onSaveDebugRetentionDays).toHaveBeenCalledWith( + { debug_retention_days: 14 }, + expect.anything(), + ); + }); + }, +}; + +export const DebugRetentionExceedsMax: Story = { + args: { + debugRetentionDaysData: { debug_retention_days: 30 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByLabelText( + "Chat debug data retention period in days", + ); + const debugRetentionForm = input.closest("form"); + if (!(debugRetentionForm instanceof HTMLFormElement)) { + throw new Error("Expected debug retention input to live inside a form."); + } + + await userEvent.clear(input); + await userEvent.type(input, "3651"); + + const saveButton = within(debugRetentionForm).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(input).toBeInvalid(); + expect(saveButton).toBeDisabled(); + }); + await userEvent.tab(); + await waitFor(() => { + expect( + canvas.getByText(/must not exceed 3650 days/i), + ).toBeInTheDocument(); + }); + }, +}; + +export const DebugRetentionBelowMin: Story = { + args: { + debugRetentionDaysData: { debug_retention_days: 30 }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByLabelText( + "Chat debug data retention period in days", + ); + const debugRetentionForm = input.closest("form"); + if (!(debugRetentionForm instanceof HTMLFormElement)) { + throw new Error("Expected debug retention input to live inside a form."); + } + + await userEvent.clear(input); + await userEvent.type(input, "0"); + + const saveButton = within(debugRetentionForm).getByRole("button", { + name: "Save", + }); + await waitFor(() => { + expect(input).toBeInvalid(); + expect(saveButton).toBeDisabled(); + }); + await userEvent.tab(); + await waitFor(() => { + expect(canvas.getByText(/at least 1 day/i)).toBeInTheDocument(); + }); + }, +}; + +export const DebugRetentionSaveError: Story = { + args: { + debugRetentionDaysData: { debug_retention_days: 30 }, + isSaveDebugRetentionDaysError: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + await canvas.findByText("Failed to save chat debug retention setting."), + ).toBeInTheDocument(); + }, +}; + +export const DebugRetentionLoadError: Story = { + args: { + debugRetentionDaysData: undefined, + isDebugRetentionDaysLoadError: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = await canvas.findByRole("switch", { + name: "Enable chat debug data retention", + }); + expect(toggle).toBeChecked(); + expect( + await canvas.findByLabelText("Chat debug data retention period in days"), + ).toHaveValue(30); + expect( + await canvas.findByText("Failed to load chat debug retention setting."), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx index dce4972294..699b4e658b 100644 --- a/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx +++ b/site/src/pages/AgentsPage/AgentSettingsLifecyclePageView.tsx @@ -2,6 +2,7 @@ import type { FC } from "react"; import type { UseMutateFunction } from "react-query"; import type * as TypesGen from "#/api/typesGenerated"; import { AutoArchiveSettings } from "./components/AutoArchiveSettings"; +import { DebugRetentionSettings } from "./components/DebugRetentionSettings"; import { RetentionPeriodSettings } from "./components/RetentionPeriodSettings"; import { SectionHeader } from "./components/SectionHeader"; import { WorkspaceAutostopSettings } from "./components/WorkspaceAutostopSettings"; @@ -29,6 +30,17 @@ export interface AgentSettingsLifecyclePageViewProps { >; isSavingRetentionDays: boolean; isSaveRetentionDaysError: boolean; + debugRetentionDaysData: TypesGen.ChatDebugRetentionDaysResponse | undefined; + isDebugRetentionDaysLoading: boolean; + isDebugRetentionDaysLoadError: boolean; + onSaveDebugRetentionDays: UseMutateFunction< + void, + Error, + TypesGen.UpdateChatDebugRetentionDaysRequest, + unknown + >; + isSavingDebugRetentionDays: boolean; + isSaveDebugRetentionDaysError: boolean; autoArchiveDaysData: TypesGen.ChatAutoArchiveDaysResponse | undefined; isAutoArchiveDaysLoading: boolean; isAutoArchiveDaysLoadError: boolean; @@ -57,6 +69,12 @@ export const AgentSettingsLifecyclePageView: FC< onSaveRetentionDays, isSavingRetentionDays, isSaveRetentionDaysError, + debugRetentionDaysData, + isDebugRetentionDaysLoading, + isDebugRetentionDaysLoadError, + onSaveDebugRetentionDays, + isSavingDebugRetentionDays, + isSaveDebugRetentionDaysError, autoArchiveDaysData, isAutoArchiveDaysLoading, isAutoArchiveDaysLoadError, @@ -94,6 +112,14 @@ export const AgentSettingsLifecyclePageView: FC< isSavingRetentionDays={isSavingRetentionDays} isSaveRetentionDaysError={isSaveRetentionDaysError} /> + ); }; diff --git a/site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx b/site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx new file mode 100644 index 0000000000..c8e44c269c --- /dev/null +++ b/site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx @@ -0,0 +1,195 @@ +import { useFormik } from "formik"; +import type { FC } from "react"; +import { useState } from "react"; +import * as Yup from "yup"; +import type * as TypesGen from "#/api/typesGenerated"; +import { DefaultChatDebugRetentionDays } from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { Input } from "#/components/Input/Input"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { Switch } from "#/components/Switch/Switch"; +import { + TemporarySavedState, + useTemporarySavedState, +} from "./TemporarySavedState"; + +interface MutationCallbacks { + onSuccess?: () => void; + onError?: () => void; +} + +interface DebugRetentionSettingsProps { + debugRetentionDaysData: TypesGen.ChatDebugRetentionDaysResponse | undefined; + isDebugRetentionDaysLoading: boolean; + isDebugRetentionDaysLoadError: boolean; + onSaveDebugRetentionDays: ( + req: TypesGen.UpdateChatDebugRetentionDaysRequest, + options?: MutationCallbacks, + ) => void; + isSavingDebugRetentionDays: boolean; + isSaveDebugRetentionDaysError: boolean; +} + +// Keep in sync with chatDebugRetentionDaysMaximum in coderd/exp_chats.go. +const validationSchema = Yup.object({ + debug_retention_days: Yup.number() + .integer("Debug retention days must be a whole number.") + .min(1, "Debug retention period must be at least 1 day.") + .max(3650, "Must not exceed 3650 days (~10 years).") + .required("Debug retention days is required."), +}); + +export const DebugRetentionSettings: FC = ({ + debugRetentionDaysData, + isDebugRetentionDaysLoading, + isDebugRetentionDaysLoadError, + onSaveDebugRetentionDays, + isSavingDebugRetentionDays, + isSaveDebugRetentionDaysError, +}) => { + const [debugRetentionToggled, setDebugRetentionToggled] = useState< + boolean | null + >(null); + const { isSavedVisible, showSavedState } = useTemporarySavedState(); + + const serverDebugRetentionDays = + debugRetentionDaysData?.debug_retention_days ?? + DefaultChatDebugRetentionDays; + const isDebugRetentionEnabled = + debugRetentionToggled ?? serverDebugRetentionDays > 0; + + const form = useFormik({ + initialValues: { debug_retention_days: serverDebugRetentionDays }, + enableReinitialize: true, + validationSchema, + onSubmit: (values, helpers) => { + onSaveDebugRetentionDays( + { debug_retention_days: values.debug_retention_days }, + { + onSuccess: () => { + showSavedState(); + setDebugRetentionToggled(null); + helpers.resetForm(); + }, + }, + ); + }, + }); + + const resetDebugRetentionState = () => { + setDebugRetentionToggled(null); + form.resetForm(); + }; + + const handleToggleDebugRetention = (checked: boolean) => { + if (checked) { + const days = + serverDebugRetentionDays > 0 + ? serverDebugRetentionDays + : DefaultChatDebugRetentionDays; + setDebugRetentionToggled(true); + void form.setFieldValue("debug_retention_days", days); + onSaveDebugRetentionDays( + { debug_retention_days: days }, + { + onSuccess: resetDebugRetentionState, + onError: resetDebugRetentionState, + }, + ); + } else { + setDebugRetentionToggled(false); + void form.setFieldValue("debug_retention_days", 0); + onSaveDebugRetentionDays( + { debug_retention_days: 0 }, + { + onSuccess: resetDebugRetentionState, + onError: resetDebugRetentionState, + }, + ); + } + }; + + return ( +
+
+
+

+ Chat Debug Data Retention +

+
+ +
+

+ Chat debug runs and debug steps older than this are automatically + deleted. This does not control chat message retention. +

+ {isDebugRetentionEnabled && ( + <> +
+ + + Days + +
+ {form.errors.debug_retention_days && + form.touched.debug_retention_days && ( +

+ {form.errors.debug_retention_days} +

+ )} +
+ {(form.dirty || isSavedVisible || isSavingDebugRetentionDays) && + (isSavedVisible ? ( + + ) : ( + + ))} +
+ + )} + {isSaveDebugRetentionDaysError && ( +

+ Failed to save chat debug retention setting. +

+ )} + {isDebugRetentionDaysLoadError && ( +

+ Failed to load chat debug retention setting. +

+ )} +
+ ); +};