From 2874d4b4cd7281ceb66066078a315e7eeb5f52f5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 5 May 2026 22:37:13 +0200 Subject: [PATCH] feat: add chat debug retention purge (#24943) > Mux is acting on Mike's behalf. Adds configurable retention for chat debug data, including the purge query, updated_at index, site config, experimental API, SDK types, frontend lifecycle setting, and docs. The purge deletes debug runs older than the configured retention window and relies on existing cascades to delete steps. The default retention is 30 days, and setting the value to 0 disables the purge. --- coderd/coderd.go | 2 + coderd/database/dbauthz/dbauthz.go | 25 ++ coderd/database/dbauthz/dbauthz_test.go | 12 + coderd/database/dbmetrics/querymetrics.go | 24 ++ coderd/database/dbmock/dbmock.go | 44 +++ coderd/database/dbpurge/dbpurge.go | 53 ++- coderd/database/dbpurge/dbpurge_test.go | 324 +++++++++++++++++- coderd/database/dump.sql | 2 + ..._chat_debug_runs_updated_at_index.down.sql | 1 + ...87_chat_debug_runs_updated_at_index.up.sql | 1 + coderd/database/querier.go | 11 + coderd/database/queries.sql.go | 62 ++++ coderd/database/queries/chatdebug.sql | 20 ++ coderd/database/queries/siteconfig.sql | 14 + coderd/exp_chats.go | 51 +++ coderd/exp_chats_test.go | 57 +++ codersdk/chats.go | 45 +++ .../platform-controls/chat-debug-retention.md | 46 +++ .../platform-controls/chat-retention.md | 12 +- docs/manifest.json | 6 + site/src/api/api.ts | 18 + site/src/api/queries/chats.ts | 16 + site/src/api/typesGenerated.ts | 26 ++ .../AgentsPage/AgentSettingsLifecyclePage.tsx | 15 + ...AgentSettingsLifecyclePageView.stories.tsx | 214 +++++++++++- .../AgentSettingsLifecyclePageView.tsx | 26 ++ .../components/DebugRetentionSettings.tsx | 195 +++++++++++ 27 files changed, 1298 insertions(+), 24 deletions(-) create mode 100644 coderd/database/migrations/000487_chat_debug_runs_updated_at_index.down.sql create mode 100644 coderd/database/migrations/000487_chat_debug_runs_updated_at_index.up.sql create mode 100644 docs/ai-coder/agents/platform-controls/chat-debug-retention.md create mode 100644 site/src/pages/AgentsPage/components/DebugRetentionSettings.tsx 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. +

+ )} +
+ ); +};