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.
This commit is contained in:
Michael Suchacz
2026-05-05 22:37:13 +02:00
committed by GitHub
parent 57a6421670
commit 2874d4b4cd
27 changed files with 1298 additions and 24 deletions
+2
View File
@@ -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)
+25
View File
@@ -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
+12
View File
@@ -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)
+24
View File
@@ -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)
+44
View File
@@ -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()
+41 -12
View File
@@ -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 {
+317 -7
View File
@@ -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{
+2
View File
@@ -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);
@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_chat_debug_runs_updated_at;
@@ -0,0 +1 @@
CREATE INDEX idx_chat_debug_runs_updated_at ON chat_debug_runs (updated_at);
+11
View File
@@ -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)
+62
View File
@@ -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 (
+20
View File
@@ -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
+14
View File
@@ -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(
+51
View File
@@ -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.
+57
View File
@@ -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)
+45
View File
@@ -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)
@@ -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.
@@ -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
+6
View File
@@ -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",
+18
View File
@@ -3451,6 +3451,24 @@ class ExperimentalApiMethods {
await this.axios.put("/api/experimental/chats/config/retention-days", req);
};
getChatDebugRetentionDays =
async (): Promise<TypesGen.ChatDebugRetentionDaysResponse> => {
const response =
await this.axios.get<TypesGen.ChatDebugRetentionDaysResponse>(
"/api/experimental/chats/config/debug-retention-days",
);
return response.data;
};
updateChatDebugRetentionDays = async (
req: TypesGen.UpdateChatDebugRetentionDaysRequest,
): Promise<void> => {
await this.axios.put(
"/api/experimental/chats/config/debug-retention-days",
req,
);
};
getChatAutoArchiveDays =
async (): Promise<TypesGen.ChatAutoArchiveDaysResponse> => {
const response =
+16
View File
@@ -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 = () => ({
+26
View File
@@ -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.
@@ -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 (
<RequirePermission isFeatureVisible={permissions.editDeploymentConfig}>
@@ -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}
@@ -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();
},
};
@@ -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}
/>
<DebugRetentionSettings
debugRetentionDaysData={debugRetentionDaysData}
isDebugRetentionDaysLoading={isDebugRetentionDaysLoading}
isDebugRetentionDaysLoadError={isDebugRetentionDaysLoadError}
onSaveDebugRetentionDays={onSaveDebugRetentionDays}
isSavingDebugRetentionDays={isSavingDebugRetentionDays}
isSaveDebugRetentionDaysError={isSaveDebugRetentionDaysError}
/>
</div>
);
};
@@ -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<DebugRetentionSettingsProps> = ({
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 (
<form className="flex flex-col gap-2" onSubmit={form.handleSubmit}>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<h3 className="m-0 text-sm font-semibold text-content-primary">
Chat Debug Data Retention
</h3>
</div>
<Switch
checked={isDebugRetentionEnabled}
onCheckedChange={handleToggleDebugRetention}
aria-label="Enable chat debug data retention"
disabled={isSavingDebugRetentionDays || isDebugRetentionDaysLoading}
/>
</div>
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Chat debug runs and debug steps older than this are automatically
deleted. This does not control chat message retention.
</p>
{isDebugRetentionEnabled && (
<>
<div className="flex gap-2">
<Input
type="number"
name="debug_retention_days"
min={1}
max={3650}
step={1}
aria-label="Chat debug data retention period in days"
value={form.values.debug_retention_days}
onChange={form.handleChange}
onBlur={form.handleBlur}
aria-invalid={Boolean(form.errors.debug_retention_days)}
disabled={
isSavingDebugRetentionDays || isDebugRetentionDaysLoading
}
className="flex-1"
/>
<span className="flex h-10 w-[120px] items-center px-3 text-sm text-content-secondary">
Days
</span>
</div>
{form.errors.debug_retention_days &&
form.touched.debug_retention_days && (
<p className="m-0 text-xs text-content-destructive">
{form.errors.debug_retention_days}
</p>
)}
<div className="mt-2 flex min-h-6 justify-end">
{(form.dirty || isSavedVisible || isSavingDebugRetentionDays) &&
(isSavedVisible ? (
<TemporarySavedState />
) : (
<Button
size="xs"
type="submit"
disabled={
isSavingDebugRetentionDays ||
!form.dirty ||
Boolean(form.errors.debug_retention_days)
}
>
{isSavingDebugRetentionDays && (
<Spinner loading className="h-4 w-4" />
)}
Save
</Button>
))}
</div>
</>
)}
{isSaveDebugRetentionDaysError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save chat debug retention setting.
</p>
)}
{isDebugRetentionDaysLoadError && (
<p className="m-0 text-xs text-content-destructive">
Failed to load chat debug retention setting.
</p>
)}
</form>
);
};