diff --git a/cli/server.go b/cli/server.go index 98a318cf53..5fc25d2558 100644 --- a/cli/server.go +++ b/cli/server.go @@ -1074,7 +1074,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. defer shutdownConns() // Ensures that old database entries are cleaned up over time! - purger := dbpurge.New(ctx, logger.Named("dbpurge"), options.Database, options.DeploymentValues, options.PrometheusRegistry, &coderAPI.Auditor) + purger := dbpurge.New(ctx, logger.Named("dbpurge"), options.Database, options.DeploymentValues, options.PrometheusRegistry, &coderAPI.Auditor, dbpurge.WithNotificationsEnqueuer(options.NotificationsEnqueuer)) defer purger.Close() // Updates workspace usage diff --git a/coderd/database/dbpurge/dbpurge.go b/coderd/database/dbpurge/dbpurge.go index 61d0ccbd30..38c3ac00ce 100644 --- a/coderd/database/dbpurge/dbpurge.go +++ b/coderd/database/dbpurge/dbpurge.go @@ -1,12 +1,18 @@ package dbpurge import ( + "cmp" "context" + "errors" "io" "net/http" + "slices" + "strconv" "sync/atomic" "time" + "github.com/dustin/go-humanize" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" @@ -15,7 +21,9 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/pproflabel" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/quartz" ) @@ -41,6 +49,12 @@ const ( // chat_files rows carry bytea blobs. chatsBatchSize = 1000 chatFilesBatchSize = 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 + // length; the cap is unrelated to chatAutoArchiveBatchSize, which + // bounds work per tick. + chatAutoArchiveDigestMaxChats = 25 ) // defaultChatAutoArchiveBatchSize bounds how many root chats one @@ -62,12 +76,26 @@ func WithChatAutoArchiveBatchSize(n int32) Option { return func(i *instance) { i.chatAutoArchiveBatchSize = n } } +// WithNotificationsEnqueuer sets the enqueuer used for digest +// notifications. Defaults to notifications.NewNoopEnqueuer(). Panics +// if e is nil: a nil enqueuer would NPE on the first dispatch tick, +// and failing fast at option-apply time surfaces the misuse at +// startup rather than minutes later. +func WithNotificationsEnqueuer(e notifications.Enqueuer) Option { + if e == nil { + panic("developer error: WithNotificationsEnqueuer called with nil enqueuer") + } + return func(i *instance) { i.enqueuer = e } +} + // New creates a new periodically purging database instance. // Callers must Close the returned instance. // // The auditor pointer is loaded on each dispatch tick so runtime // entitlement changes (e.g. toggling the audit-log feature) take -// effect without restarting the process. +// effect without restarting the process. Notifications enqueuer +// defaults to no-op. Use WithNotificationsEnqueuer to pass a real +// one. func New(ctx context.Context, logger slog.Logger, db database.Store, vals *codersdk.DeploymentValues, reg prometheus.Registerer, auditor *atomic.Pointer[audit.Auditor], opts ...Option) io.Closer { closed := make(chan struct{}) @@ -107,6 +135,7 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder vals: vals, clk: quartz.NewReal(), auditor: auditor, + enqueuer: notifications.NewNoopEnqueuer(), iterationDuration: iterationDuration, recordsPurged: recordsPurged, chatAutoArchiveRecords: chatAutoArchiveRecords, @@ -151,30 +180,29 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder // purgeTick performs a single purge iteration. It returns an error if the // purge fails. func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.Time) error { - // Read chat retention config outside the transaction to - // avoid poisoning the tx if the stored value is corrupt. - // A SQL-level cast error (e.g. non-numeric text) puts PG - // into error state, failing all subsequent queries in the - // same transaction. - chatRetentionDays, err := db.GetChatRetentionDays(ctx) - if err != nil { - i.logger.Warn(ctx, "failed to read chat retention config, skipping chat purge", slog.Error(err)) - chatRetentionDays = 0 + // 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 + // iteration is operator-visible via metric and logs. + chatRetentionDays, chatRetentionErr := db.GetChatRetentionDays(ctx) + if chatRetentionErr != nil { + i.logger.Error(ctx, "failed to read chat retention config: skipping chat purge and auto-archive this tick", slog.Error(chatRetentionErr)) } - // Same rationale as chat_retention_days: read outside the tx. - chatAutoArchiveDays, err := db.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays) - if err != nil { - i.logger.Warn(ctx, "failed to read chat auto-archive config, skipping auto-archive", slog.Error(err)) - chatAutoArchiveDays = 0 + chatAutoArchiveDays, chatAutoArchiveErr := db.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays) + if chatAutoArchiveErr != nil { + 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) + // Populated inside the tx; dispatched post-commit. var archivedChats []database.AutoArchiveInactiveChatsRow // Start a transaction to grab advisory lock, we don't want to run // multiple purges at the same time (multiple replicas). - err = db.InTx(func(tx database.Store) error { + err := db.InTx(func(tx database.Store) error { // Acquire a lock to ensure that only one instance of the // purge is running at a time. ok, err := tx.TryAcquireLock(ctx, database.LockIDDBPurge) @@ -276,46 +304,11 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. } } - // Chat retention is configured via site_configs. When - // enabled, old archived chats are deleted first, then - // orphaned chat files. Deleting a chat cascades to - // chat_file_links (removing references) but not to - // chat_files directly, so files from deleted chats - // become orphaned and are caught by DeleteOldChatFiles - // in the same tick. - var purgedChats int64 - var purgedChatFiles int64 - if chatRetentionDays > 0 { - chatRetention := time.Duration(chatRetentionDays) * 24 * time.Hour - deleteChatsBefore := start.Add(-chatRetention) - - purgedChats, err = tx.DeleteOldChats(ctx, database.DeleteOldChatsParams{ - BeforeTime: deleteChatsBefore, - LimitCount: chatsBatchSize, - }) + var purgedChats, purgedChatFiles int64 + if chatConfigErr == nil { + purgedChats, purgedChatFiles, archivedChats, err = i.purgeChatsInTx(ctx, tx, start, chatRetentionDays, chatAutoArchiveDays) if err != nil { - return xerrors.Errorf("failed to delete old chats: %w", err) - } - - purgedChatFiles, err = tx.DeleteOldChatFiles(ctx, database.DeleteOldChatFilesParams{ - BeforeTime: deleteChatsBefore, - LimitCount: chatFilesBatchSize, - }) - if err != nil { - return xerrors.Errorf("failed to delete old chat files: %w", err) - } - } - - // Auto-archive runs after the delete pass so newly - // archived chats aren't eligible for deletion this tick. - if chatAutoArchiveDays > 0 { - archiveCutoff := start.Add(-time.Duration(chatAutoArchiveDays) * 24 * time.Hour) - archivedChats, err = tx.AutoArchiveInactiveChats(ctx, database.AutoArchiveInactiveChatsParams{ - ArchiveCutoff: archiveCutoff, - LimitCount: i.chatAutoArchiveBatchSize, - }) - if err != nil { - return xerrors.Errorf("failed to auto-archive inactive chats: %w", err) + return xerrors.Errorf("failed to purge chats: %w", err) } } @@ -351,15 +344,24 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time. return err } - // Dispatch audits post-commit on a detached context so ticker - // cancellation doesn't interrupt the loop. No timeout: every root - // must be audited to avoid gaps in the trail. Children inherit - // their root's archival decision and are not audited individually, - // matching the manual archive path (patchChat audits the root only). + // Surface the deferred chat-config error so doTick records + // the failed iteration metric. + if chatConfigErr != nil { + return xerrors.Errorf("chat config read failed this tick: %w", chatConfigErr) + } + + // Dispatch audits and digests post-commit. Detached context for audit + // so that ticker cancellation cannot truncate the audit trail. + // Notification enqueue uses the cancellable parent context to avoid + // stalling shutdown. + // Owners with more eligible chats than batch size will get a + // notification per tick until their backlog drains. + // If this is deemed too noisy, users can disable the + // "Chats Auto-Archived" template from their notification preferences. if len(archivedChats) > 0 { i.chatAutoArchiveRecords.Add(float64(len(archivedChats))) - dispatchCtx := context.WithoutCancel(ctx) - i.dispatchChatAutoArchive(dispatchCtx, archivedChats) + auditCtx := context.WithoutCancel(ctx) + i.dispatchChatAutoArchive(auditCtx, ctx, start, chatAutoArchiveDays, chatRetentionDays, archivedChats) } return nil @@ -372,6 +374,7 @@ type instance struct { vals *codersdk.DeploymentValues clk quartz.Clock auditor *atomic.Pointer[audit.Auditor] + enqueuer notifications.Enqueuer iterationDuration *prometheus.HistogramVec recordsPurged *prometheus.CounterVec chatAutoArchiveRecords prometheus.Counter @@ -428,20 +431,69 @@ func chatFromAutoArchiveRow(logger slog.Logger, r database.AutoArchiveInactiveCh } } -// dispatchChatAutoArchive audits every archived root chat. Children -// inherit their root's archival decision and are skipped, matching -// the manual archive path (patchChat audits the root only). Runs on -// a detached context so ticker cancellation cannot truncate the trail. -func (i *instance) dispatchChatAutoArchive(ctx context.Context, archived []database.AutoArchiveInactiveChatsRow) { - auditor := *i.auditor.Load() - for _, row := range archived { - if row.ParentChatID.Valid { - continue // Children inherit root's archival; audit roots only. +// purgeChatsInTx MUST BE CALLED WITH A TRANSACTION +func (i *instance) purgeChatsInTx(ctx context.Context, tx database.Store, start time.Time, chatRetentionDays, chatAutoArchiveDays int32) (purgedChats, purgedChatFiles int64, archivedChats []database.AutoArchiveInactiveChatsRow, err error) { + // Delete old archived chats first, then orphaned files + // (cascade clears chat_file_links but not chat_files). + if chatRetentionDays > 0 { + deleteChatsBefore := start.Add(-time.Duration(chatRetentionDays) * 24 * time.Hour) + purgedChats, err = tx.DeleteOldChats(ctx, database.DeleteOldChatsParams{ + BeforeTime: deleteChatsBefore, + LimitCount: chatsBatchSize, + }) + if err != nil { + return 0, 0, nil, xerrors.Errorf("failed to delete old chats: %w", err) } + + purgedChatFiles, err = tx.DeleteOldChatFiles(ctx, database.DeleteOldChatFilesParams{ + BeforeTime: deleteChatsBefore, + LimitCount: chatFilesBatchSize, + }) + if err != nil { + return 0, 0, nil, xerrors.Errorf("failed to delete old chat files: %w", err) + } + } + + // Auto-archive runs after the delete pass so newly + // archived chats aren't eligible for deletion this tick. + if chatAutoArchiveDays > 0 { + archiveCutoff := start.Add(-time.Duration(chatAutoArchiveDays) * 24 * time.Hour) + archivedChats, err = tx.AutoArchiveInactiveChats(ctx, database.AutoArchiveInactiveChatsParams{ + ArchiveCutoff: archiveCutoff, + LimitCount: i.chatAutoArchiveBatchSize, + }) + if err != nil { + return 0, 0, nil, xerrors.Errorf("failed to auto-archive inactive chats: %w", err) + } + } + return purgedChats, purgedChatFiles, archivedChats, nil +} + +// dispatchChatAutoArchive audits every archived root chat and enqueues one +// notification per owner covering the roots archived in this tick. Children +// inherit their root's archival decision and are skipped for audit, matching +// the manual archive path (patchChat audits the root only). Enqueue is +// per-tick: owners whose backlog spans multiple ticks receive multiple +// notifications; notification_messages dedupe does not collapse them because +// each tick's payload differs. +// +// auditCtx is detached from the ticker so audits always complete. enqueueCtx +// is the cancellable parent: on shutdown we abandon any remaining digests +// rather than blocking Close. +func (i *instance) dispatchChatAutoArchive(auditCtx, enqueueCtx context.Context, tickStart time.Time, autoArchiveDays, retentionDays int32, archived []database.AutoArchiveInactiveChatsRow) { + // Children inherit their root's archival decision and are skipped + // for both audit and digest. Partition once so the two loops + // cannot drift apart if the cascade shape ever changes. + roots := slice.Filter(archived, func(r database.AutoArchiveInactiveChatsRow) bool { + return !r.ParentChatID.Valid + }) + + auditor := *i.auditor.Load() + for _, row := range roots { after := chatFromAutoArchiveRow(i.logger, row) before := after before.Archived = false - audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.Chat]{ + audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.Chat]{ Audit: auditor, Log: i.logger, UserID: row.OwnerID, @@ -450,7 +502,94 @@ func (i *instance) dispatchChatAutoArchive(ctx context.Context, archived []datab Old: before, New: after, Status: http.StatusOK, - AdditionalFields: audit.BackgroundTaskFieldsBytes(ctx, i.logger, audit.BackgroundSubsystemChatAutoArchive), + AdditionalFields: audit.BackgroundTaskFieldsBytes(auditCtx, i.logger, audit.BackgroundSubsystemChatAutoArchive), }) } + + // Group archived roots by owner. Inline because this is the + // only call site and the loop body is self-explanatory. + rootsByOwner := make(map[uuid.UUID][]database.AutoArchiveInactiveChatsRow, len(roots)) + for _, row := range roots { + rootsByOwner[row.OwnerID] = append(rootsByOwner[row.OwnerID], row) + } + + // Sort owner IDs so shutdown abandons a deterministic tail of the dispatch list. + ownerIDs := make([]uuid.UUID, 0, len(rootsByOwner)) + for id := range rootsByOwner { + ownerIDs = append(ownerIDs, id) + } + slices.SortFunc(ownerIDs, func(a, b uuid.UUID) int { + return cmp.Compare(a.String(), b.String()) + }) + + dispatched := 0 + for _, ownerID := range ownerIDs { + // Check between iterations so shutdown unblocks promptly. A + // hung in-flight enqueue is unblocked by enqueueCtx propagating + // cancellation into the DB call. Skipped owners are not + // re-notified on the next tick because AutoArchiveInactiveChats + // only returns rows with archived = false; we accept that + // tradeoff over hanging shutdown. + if err := enqueueCtx.Err(); err != nil { + i.logger.Warn(enqueueCtx, "chat auto-archive digest dispatch canceled", + slog.F("remaining_owners", len(ownerIDs)-dispatched), + slog.Error(err)) + return + } + dispatched++ + + ownerRoots := rootsByOwner[ownerID] + data := buildDigestData(ownerRoots, autoArchiveDays, retentionDays, tickStart) + + // nolint:gocritic // Background digest runs as the notifier subject. + if _, err := i.enqueuer.EnqueueWithData( + dbauthz.AsNotifier(enqueueCtx), + ownerID, + notifications.TemplateChatAutoArchiveDigest, + map[string]string{}, + data, + string(audit.BackgroundSubsystemChatAutoArchive), + ); err != nil { + i.logger.Warn(enqueueCtx, "failed to enqueue chat auto-archive digest", + slog.F("owner_id", ownerID), + slog.Error(err)) + } + } +} + +// buildDigestData builds the notification payload; shape mirrors the +// golden fixtures in coderd/notifications/testdata. Truncation keeps +// the oldest archived roots (created_at ASC from the query) to +// preserve index-driven ordering; revisit if the digest becomes the +// primary surface for reviewing archived chats. +func buildDigestData(rows []database.AutoArchiveInactiveChatsRow, autoArchiveDays, retentionDays int32, tickStart time.Time) map[string]any { + // Cap titles; overflow surfaces as "...and N more" via the template. + overflow := 0 + if len(rows) > chatAutoArchiveDigestMaxChats { + overflow = len(rows) - chatAutoArchiveDigestMaxChats + rows = rows[:chatAutoArchiveDigestMaxChats] + } + + chats := make([]map[string]any, 0, len(rows)) + for _, r := range rows { + chats = append(chats, map[string]any{ + "title": r.Title, + "last_activity_humanized": humanize.RelTime(r.LastActivityAt, tickStart, "ago", "from now"), + }) + } + + // Stringify the int32 config values: the template's + // {{if eq .Data.retention_days "0"}} branch requires both + // operands to share a type, and Go templates do not coerce + // numeric ↔ string. Storing a raw int here would silently + // take the deletion-warning branch on every notification. + data := map[string]any{ + "auto_archive_days": strconv.Itoa(int(autoArchiveDays)), + "retention_days": strconv.Itoa(int(retentionDays)), + "archived_chats": chats, + } + if overflow > 0 { + data["additional_archived_count"] = strconv.Itoa(overflow) + } + return data } diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 69b9d34428..166ed12bc0 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -32,6 +32,9 @@ import ( "github.com/coder/coder/v2/coderd/database/dbrollup" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/notificationsmock" + "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionerd/proto" @@ -177,6 +180,92 @@ 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. + t.Run("FailedChatRetentionRead", 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), xerrors.New("simulated retention read error")). + MinTimes(1) + // Both reads happen before the bail; InTx still runs + // so unrelated purges commit best-effort. + mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays). + Return(int32(0), nil).AnyTimes() + mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). + Return(nil).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 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 retention read failure") + }) + + // Same contract as FailedChatRetentionRead, but the + // auto-archive read is the half that fails. + t.Run("FailedChatAutoArchiveRead", 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), xerrors.New("simulated auto-archive read error")). + MinTimes(1) + // InTx still runs so unrelated purges commit; chat + // passes inside the tx are skipped. + mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")). + Return(nil).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 auto-archive 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 auto-archive read failure") + }) } //nolint:paralleltest // It uses LockIDDBPurge. @@ -2348,15 +2437,19 @@ func TestAutoArchiveInactiveChats(t *testing.T) { auditor := audit.NewMock() auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() done := awaitDoTick(ctx, t, clk) - closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk)) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk)) defer closer.Close() testutil.TryReceive(ctx, t, done) + // Not archived, no audits, no digests. refreshed, err := db.GetChatByID(ctx, staleChat.ID) require.NoError(t, err) require.False(t, refreshed.Archived, "chat should stay active when auto-archive is disabled") + require.Empty(t, auditor.AuditLogs(), "no audit log entries expected") + require.Empty(t, enqueuer.Sent(), "no digest notifications expected") }, }, { @@ -2365,7 +2458,10 @@ func TestAutoArchiveInactiveChats(t *testing.T) { h := newArchiveHarness(t, now) ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps + // Regression guard: ensure that both auto-archive and retention + // are both set to a distinct non-zero value. require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(90))) + require.NoError(t, db.UpsertChatRetentionDays(ctx, int32(30))) // Inactive root: newest message 100 days old. staleChat := createArchiveChat(ctx, t, db, rawDB, deps, "stale-chat", now.Add(-120*24*time.Hour)) @@ -2377,8 +2473,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) { auditor := audit.NewMock() auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() done := awaitDoTick(ctx, t, clk) - closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk)) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk)) defer closer.Close() testutil.TryReceive(ctx, t, done) @@ -2390,6 +2487,7 @@ func TestAutoArchiveInactiveChats(t *testing.T) { require.NoError(t, err) require.False(t, refreshedActive.Archived, "active chat should stay live") + // Exactly one audit entry, for the stale root. logs := auditor.AuditLogs() require.Len(t, logs, 1, "expected one audit entry") require.Equal(t, staleChat.ID, logs[0].ResourceID) @@ -2397,6 +2495,15 @@ func TestAutoArchiveInactiveChats(t *testing.T) { require.Equal(t, database.AuditActionWrite, logs[0].Action) require.Contains(t, string(logs[0].AdditionalFields), "chat_auto_archive", "audit entry must carry the auto-archive subsystem tag") + + // Exactly one digest, addressed to the owner. + sent := enqueuer.Sent() + require.Len(t, sent, 1, "expected one digest notification") + require.Equal(t, notifications.TemplateChatAutoArchiveDigest, sent[0].TemplateID) + require.Equal(t, deps.user.ID, sent[0].UserID) + // Ensure that config-derived fields flow through to payload. + require.Equal(t, "90", sent[0].Data["auto_archive_days"]) + require.Equal(t, "30", sent[0].Data["retention_days"]) }, }, { @@ -2498,8 +2605,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) { auditor := audit.NewMock() auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() done := awaitDoTick(ctx, t, clk) - closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk)) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk)) defer closer.Close() testutil.TryReceive(ctx, t, done) @@ -2512,6 +2620,7 @@ func TestAutoArchiveInactiveChats(t *testing.T) { require.False(t, refreshedChild.Archived, "child must stay active") require.Empty(t, auditor.AuditLogs(), "no chats should be archived") + require.Empty(t, enqueuer.Sent(), "no notifications should be sent") }, }, { @@ -2552,8 +2661,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) { auditor := audit.NewMock() auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() done := awaitDoTick(ctx, t, clk) - closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk)) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk)) defer closer.Close() testutil.TryReceive(ctx, t, done) @@ -2580,6 +2690,12 @@ func TestAutoArchiveInactiveChats(t *testing.T) { logs := auditor.AuditLogs() require.Len(t, logs, 1, "only the completed chat should produce an audit entry") require.Equal(t, completedChat.ID, logs[0].ResourceID) + + // Assert number of sent notifications to catch dispatch regressions. + sent := enqueuer.Sent() + require.Len(t, sent, 1, "expected one digest notification for the completed chat") + require.Equal(t, notifications.TemplateChatAutoArchiveDigest, sent[0].TemplateID) + require.Equal(t, deps.user.ID, sent[0].UserID) }, }, { @@ -2608,8 +2724,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) { auditor := audit.NewMock() auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() done := awaitDoTick(ctx, t, clk) - closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk)) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk)) defer closer.Close() testutil.TryReceive(ctx, t, done) @@ -2628,6 +2745,62 @@ func TestAutoArchiveInactiveChats(t *testing.T) { // One audit entry for the root; the cascaded child is // not audited individually. require.Len(t, auditor.AuditLogs(), 1) + + // Digest should list only the root (one row). + sent := enqueuer.Sent() + require.Len(t, sent, 1) + data := sent[0].Data + require.NotNil(t, data) + chats, ok := data["archived_chats"].([]map[string]any) + require.True(t, ok, "archived_chats should be []map[string]any") + require.Len(t, chats, 1, "digest should only list the root") + require.Equal(t, "root-chat", chats[0]["title"]) + }, + }, + { + name: "DigestOverflowCap", + run: func(t *testing.T) { + // 27 inactive roots exceed chatAutoArchiveDigestMaxChats + // (25). All 27 should archive, but the digest payload + // lists at most 25 titles and surfaces the rest via + // additional_archived_count so the template can render + // "...and N more". + h := newArchiveHarness(t, now) + ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps + + require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30))) + + const total = 27 + for i := range total { + createArchiveChat(ctx, t, db, rawDB, deps, + fmt.Sprintf("stale-%02d", i), + now.Add(-60*24*time.Hour)) + } + + auditor := audit.NewMock() + auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() + done := awaitDoTick(ctx, t, clk) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk)) + defer closer.Close() + testutil.TryReceive(ctx, t, done) + + // All 27 roots archived (one audit each). + require.Len(t, auditor.AuditLogs(), total) + + sent := enqueuer.Sent() + require.Len(t, sent, 1, "one digest per owner") + chats, ok := sent[0].Data["archived_chats"].([]map[string]any) + require.True(t, ok, "archived_chats should be []map[string]any") + require.Len(t, chats, 25, "digest caps titles at 25") + require.Equal(t, "2", sent[0].Data["additional_archived_count"], + "overflow count is total - cap") + // Humanized timestamp is computed from LastActivityAt + // and the tick-start time, not a static fixture, so we + // only assert the suffix the humanizer emits. + humanized, _ := chats[0]["last_activity_humanized"].(string) + require.Contains(t, humanized, "ago", + "last_activity_humanized should be a past relative time") }, }, { @@ -2640,8 +2813,8 @@ func TestAutoArchiveInactiveChats(t *testing.T) { require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30))) - // Two stale roots per owner, backdated well past the - // 30-day cutoff. + // Two stale roots per owner, backdated well past + // the 30-day cutoff. u1Deps := deps u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig} createArchiveChat(ctx, t, db, rawDB, u1Deps, "u1-a", now.Add(-60*24*time.Hour)) @@ -2651,22 +2824,45 @@ func TestAutoArchiveInactiveChats(t *testing.T) { auditor := audit.NewMock() auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() done := awaitDoTick(ctx, t, clk) - closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk)) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk)) defer closer.Close() testutil.TryReceive(ctx, t, done) - // Four audit rows, one per archived root. Each entry - // carries the owning UserID so downstream consumers can + // Four audit rows, one per archived root, attributed + // to the owning user so downstream consumers can // correlate per-owner activity. logs := auditor.AuditLogs() require.Len(t, logs, 4) - byUser := map[uuid.UUID]int{} + auditsByUser := map[uuid.UUID]int{} for _, l := range logs { - byUser[l.UserID]++ + auditsByUser[l.UserID]++ } - require.Equal(t, 2, byUser[deps.user.ID]) - require.Equal(t, 2, byUser[user2.ID]) + require.Equal(t, 2, auditsByUser[deps.user.ID]) + require.Equal(t, 2, auditsByUser[user2.ID]) + + // One digest per owner, each listing only that owner's + // two chats. + sent := enqueuer.Sent() + require.Len(t, sent, 2, "expected one digest per owner") + + byUser := map[uuid.UUID][]string{} + for _, s := range sent { + require.Equal(t, notifications.TemplateChatAutoArchiveDigest, s.TemplateID) + chats, ok := s.Data["archived_chats"].([]map[string]any) + require.True(t, ok, "archived_chats should be []map[string]any") + for _, c := range chats { + title, _ := c["title"].(string) + byUser[s.UserID] = append(byUser[s.UserID], title) + } + } + require.Contains(t, byUser, deps.user.ID) + require.Contains(t, byUser, user2.ID) + slices.Sort(byUser[deps.user.ID]) + slices.Sort(byUser[user2.ID]) + require.Equal(t, []string{"u1-a", "u1-b"}, byUser[deps.user.ID]) + require.Equal(t, []string{"u2-a", "u2-b"}, byUser[user2.ID]) }, }, { @@ -2683,8 +2879,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) { auditor := audit.NewMock() auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() driver := newTickDriver(t, clk) - closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk)) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk)) // Defer driver.close() after closer.Close(): defers // run LIFO, so this frees shutdown's ticker.Stop() // before the dbpurge goroutine blocks on it. @@ -2692,8 +2889,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) { defer driver.close() driver.awaitInitial(ctx, t) - // Tick 1: both archived. + // Tick 1: both archived, one digest. require.Len(t, auditor.AuditLogs(), 2, "tick 1 audits") + require.Len(t, enqueuer.Sent(), 1, "tick 1 digests") // Seed a third stale root between ticks so tick 2 has // genuine work and we can distinguish "ignored already @@ -2702,11 +2900,17 @@ func TestAutoArchiveInactiveChats(t *testing.T) { driver.awaitNext(ctx, t) - // Tick 2: exactly one new audit for the third chat; - // tick 1's rows must not be re-archived. + // Tick 2: exactly one new audit + one new digest for + // the third chat; tick 1's rows must not be re-archived. require.Len(t, auditor.AuditLogs(), 3, "tick 2 cumulative audits") + sent := enqueuer.Sent() + require.Len(t, sent, 2, "tick 2 cumulative digests") + chats, ok := sent[1].Data["archived_chats"].([]map[string]any) + require.True(t, ok, "archived_chats should be []map[string]any") + require.Len(t, chats, 1, "tick 2 digest lists only the new chat") + require.Equal(t, "second-c", chats[0]["title"]) - // All three chats should remain archived. + // First-tick chats stayed archived. for _, id := range []uuid.UUID{firstA.ID, firstB.ID, third.ID} { refreshed, err := db.GetChatByID(ctx, id) require.NoError(t, err) @@ -2718,10 +2922,18 @@ func TestAutoArchiveInactiveChats(t *testing.T) { name: "BatchSizePagination", run: func(t *testing.T) { // With 27 stale roots and batch size 20, tick 1 - // archives 20, tick 2 archives the remaining 7, tick 3 - // archives none. We assert the audit dispatch follows - // the same pattern: no dispatch runs when rows == 0, - // so tick 3 emits no new audits. + // archives 20, tick 2 archives the remaining 7, and + // tick 3 archives none. We assert the dispatch side + // effects (audits, digests) follow the same pattern: + // dispatch only runs when rows > 0, so tick 3 emits + // no new audits or digests. + // + // The two-digest count asserted here is a consequence + // of the per-tick enqueue model, not a product + // invariant. notification_messages dedupe does not + // collapse these because each tick's payload differs. + // If enqueue is ever restructured to one notification + // per owner per day, this assertion changes with it. h := newArchiveHarness(t, now) ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps @@ -2736,8 +2948,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) { auditor := audit.NewMock() auditorPtr := mockAuditorPtr(auditor) + enqueuer := notificationstest.NewFakeEnqueuer() driver := newTickDriver(t, clk) - closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk), dbpurge.WithChatAutoArchiveBatchSize(20)) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk), dbpurge.WithChatAutoArchiveBatchSize(20)) // Defer driver.close() after closer.Close() so trap // cleanup frees shutdown's ticker.Stop() before the // dbpurge goroutine blocks on it. @@ -2745,15 +2958,137 @@ func TestAutoArchiveInactiveChats(t *testing.T) { defer driver.close() driver.awaitInitial(ctx, t) + // Tick 1: first batch (20) archived. require.Len(t, auditor.AuditLogs(), 20, "tick 1 audits") + sent := enqueuer.Sent() + require.Len(t, sent, 1, "tick 1 digests") + chats1, ok := sent[0].Data["archived_chats"].([]map[string]any) + require.True(t, ok, "archived_chats should be []map[string]any") + require.Len(t, chats1, 20, "tick 1 digest lists all 20 titles") + require.NotContains(t, sent[0].Data, "additional_archived_count", + "no overflow when batch <= digest cap; 20 <= 25") driver.awaitNext(ctx, t) + + // Tick 2: remaining 7 archived. require.Len(t, auditor.AuditLogs(), 27, "tick 2 cumulative audits") + sent = enqueuer.Sent() + require.Len(t, sent, 2, "tick 2 cumulative digests") + chats2, ok := sent[1].Data["archived_chats"].([]map[string]any) + require.True(t, ok, "archived_chats should be []map[string]any") + require.Len(t, chats2, 7, "tick 2 digest lists remaining 7") driver.awaitNext(ctx, t) - // Tick 3: nothing left to archive; dispatch is gated - // on len(archivedChats) > 0 so no new audits. + + // Tick 3: nothing left to archive. The dispatch is + // gated on len(archivedChats) > 0, so no new audits + // or digests are produced. If that gate is ever + // removed, update this assertion intentionally. require.Len(t, auditor.AuditLogs(), 27, "tick 3 cumulative audits unchanged") + require.Len(t, enqueuer.Sent(), 2, "tick 3 cumulative digests unchanged") + }, + }, + { + name: "ShutdownCancelsDigestDispatch", + run: func(t *testing.T) { + // Two owners with one stale root each. The first + // EnqueueWithData call blocks until ctx is canceled. + // Closing the purger must propagate cancellation + // into the in-flight call and short-circuit the + // rest of the loop, so Close returns promptly + // instead of hanging on dispatch. + h := newArchiveHarness(t, now) + ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps + user2 := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user2.ID, OrganizationID: deps.org.ID}) + + require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30))) + + u1Deps := deps + u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig} + createArchiveChat(ctx, t, db, rawDB, u1Deps, "u1-stale", now.Add(-60*24*time.Hour)) + createArchiveChat(ctx, t, db, rawDB, u2Deps, "u2-stale", now.Add(-60*24*time.Hour)) + + // Dispatch iterates owner IDs in ascending UUID order (convention). + expectedFirst := deps.user.ID + if user2.ID.String() < deps.user.ID.String() { + expectedFirst = user2.ID + } + + ctrl := gomock.NewController(t) + mockEnq := notificationsmock.NewMockEnqueuer(ctrl) + started := make(chan struct{}) + mockEnq.EXPECT().EnqueueWithData(gomock.Any(), gomock.Eq(expectedFirst), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, _, _ uuid.UUID, _ map[string]string, _ map[string]any, _ string, _ ...uuid.UUID) ([]uuid.UUID, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }).Times(1) + + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), nopAuditorPtr(t), dbpurge.WithNotificationsEnqueuer(mockEnq), dbpurge.WithClock(clk)) + + // Wait for the forced initial tick to reach the first + // enqueue, which then blocks on ctx.Done(). + testutil.TryReceive(ctx, t, started) + + // Blocked enqueue receives ctx cancellation via the parent context. + // Loop-head check abandons the remaining owner instead of trying to enqueue. + done := make(chan error) + go func() { done <- closer.Close() }() + testutil.RequireReceive(ctx, t, done) + }, + }, + { + // A transient enqueue failure for one owner must not abort the dispatch loop. + name: "TransientEnqueueFailureDoesNotAbortLoop", + run: func(t *testing.T) { + h := newArchiveHarness(t, now) + ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps + user2 := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user2.ID, OrganizationID: deps.org.ID}) + + require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30))) + + u1Deps := deps + u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig} + createArchiveChat(ctx, t, db, rawDB, u1Deps, "u1-stale", now.Add(-60*24*time.Hour)) + createArchiveChat(ctx, t, db, rawDB, u2Deps, "u2-stale", now.Add(-60*24*time.Hour)) + + auditor := audit.NewMock() + auditorPtr := mockAuditorPtr(auditor) + + ctrl := gomock.NewController(t) + mockEnq := notificationsmock.NewMockEnqueuer(ctrl) + var calls atomic.Int32 + var successUserID uuid.UUID + mockEnq.EXPECT().EnqueueWithData(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, userID, _ uuid.UUID, _ map[string]string, _ map[string]any, _ string, _ ...uuid.UUID) ([]uuid.UUID, error) { + if calls.Add(1) == 1 { + return nil, xerrors.New("simulated transient enqueue failure") + } + successUserID = userID + return nil, nil + }).Times(2) + + done := awaitDoTick(ctx, t, clk) + closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(mockEnq), dbpurge.WithClock(clk)) + defer closer.Close() + testutil.TryReceive(ctx, t, done) + + // Both owners must have been audited regardless of + // digest enqueue outcomes; the audit and digest + // paths are independent. + require.Len(t, auditor.AuditLogs(), 2, "both archived roots must be audited") + + // gomock's .Times(2) already enforces both calls + // happened; this assertion makes the contract + // explicit at the test site. + require.Equal(t, int32(2), calls.Load(), + "loop must attempt every owner even when one fails") + + // The second attempt succeeded for one of the two owners. + require.Contains(t, []uuid.UUID{deps.user.ID, user2.ID}, successUserID, + "successful digest must belong to one of the two owners") }, }, } diff --git a/coderd/database/migrations/000480_chat_auto_archive_notification_template.down.sql b/coderd/database/migrations/000480_chat_auto_archive_notification_template.down.sql new file mode 100644 index 0000000000..fcd3692485 --- /dev/null +++ b/coderd/database/migrations/000480_chat_auto_archive_notification_template.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = '764031be-4863-4220-867b-6ce1a1b7a5f5'; diff --git a/coderd/database/migrations/000480_chat_auto_archive_notification_template.up.sql b/coderd/database/migrations/000480_chat_auto_archive_notification_template.up.sql new file mode 100644 index 0000000000..64eafba63a --- /dev/null +++ b/coderd/database/migrations/000480_chat_auto_archive_notification_template.up.sql @@ -0,0 +1,34 @@ +-- Template for the per-owner chat auto-archive notification. Enqueue is +-- per-tick (see dbpurge.dispatchChatAutoArchive): owners whose backlog +-- spans multiple ticks receive multiple notifications, and +-- notification_messages dedupe does not collapse them because each +-- tick's payload differs. Users who find this noisy can disable the +-- template from their notification preferences. The SMTP/webhook +-- wrappers prepend "Hi {{.UserName}},", so body_template must not. +INSERT INTO notification_templates ( + id, + name, + title_template, + body_template, + actions, + "group", + method, + kind, + enabled_by_default +) +VALUES ( + '764031be-4863-4220-867b-6ce1a1b7a5f5', + 'Chats Auto-Archived', + E'Chats auto-archived after {{.Data.auto_archive_days}} days of inactivity', + E'The following chats were automatically archived:\n\n{{range .Data.archived_chats}}* "{{.title}}" (last active {{.last_activity_humanized}})\n{{end}}{{with .Data.additional_archived_count}}\n...and {{.}} more.\n\n{{end}}\n{{if eq .Data.retention_days "0"}}You can restore any of them from the Agents page; archived chats are kept indefinitely.{{else}}You can restore any of them from the Agents page within {{.Data.retention_days}} days, after which they will be permanently deleted.{{end}}', + '[ + { + "label": "View chats", + "url": "{{base_url}}/agents?archived=archived" + } + ]'::jsonb, + 'Chat Events', + NULL, + 'system'::notification_template_kind, + true +); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a31d7e3813..49ac2243be 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -64,6 +64,8 @@ type sqlcQuerier interface { // Archives inactive root chats (pinned and already-archived chats skipped), // cascading to children via root_chat_id. Limits apply to roots, not total // rows. Used by dbpurge. + // created_at ASC flows through to dbpurge's digest truncation; see + // buildDigestData in dbpurge.go for the tradeoff rationale. AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error BatchUpdateWorkspaceAgentMetadata(ctx context.Context, arg BatchUpdateWorkspaceAgentMetadataParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7dd5657ee6..98c7383989 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5419,6 +5419,8 @@ type AutoArchiveInactiveChatsRow struct { // Archives inactive root chats (pinned and already-archived chats skipped), // cascading to children via root_chat_id. Limits apply to roots, not total // rows. Used by dbpurge. +// created_at ASC flows through to dbpurge's digest truncation; see +// buildDigestData in dbpurge.go for the tradeoff rationale. func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) { rows, err := q.db.QueryContext(ctx, autoArchiveInactiveChats, arg.ArchiveCutoff, arg.LimitCount) if err != nil { diff --git a/coderd/database/queries/chats.sql b/coderd/database/queries/chats.sql index 09bdda356a..790b9657d6 100644 --- a/coderd/database/queries/chats.sql +++ b/coderd/database/queries/chats.sql @@ -1482,4 +1482,6 @@ SELECT )::timestamptz AS last_activity_at FROM archived a LEFT JOIN to_archive t ON t.id = a.id +-- created_at ASC flows through to dbpurge's digest truncation; see +-- buildDigestData in dbpurge.go for the tradeoff rationale. ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC; diff --git a/coderd/inboxnotifications.go b/coderd/inboxnotifications.go index 454aefee79..e9fc9e34c5 100644 --- a/coderd/inboxnotifications.go +++ b/coderd/inboxnotifications.go @@ -54,6 +54,9 @@ var fallbackIcons = map[uuid.UUID]string{ notifications.TemplateTemplateDeleted: codersdk.InboxNotificationFallbackIconTemplate, notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate, notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate, + + // chat related notifications + notifications.TemplateChatAutoArchiveDigest: codersdk.InboxNotificationFallbackIconOther, } func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification { diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 1754b93b0e..46063d97c6 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -62,3 +62,8 @@ var ( TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1") TemplateTaskResumed = uuid.MustParse("843ee9c3-a8fb-4846-afa9-977bec578649") ) + +// Chat-related events. +var ( + TemplateChatAutoArchiveDigest = uuid.MustParse("764031be-4863-4220-867b-6ce1a1b7a5f5") +) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 33c70b94b5..a59e64be42 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1332,6 +1332,89 @@ func TestNotificationTemplates_Golden(t *testing.T) { Data: map[string]any{}, }, }, + { + // Default branch: multiple visible chats, retention enabled, + // no overflow. Body phrasing is number-neutral so this also + // covers the n>1 grammar shape without a dedicated branch in + // the template. + name: "TemplateChatAutoArchiveDigest", + id: notifications.TemplateChatAutoArchiveDigest, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{}, + Data: map[string]any{ + "auto_archive_days": "90", + "retention_days": "30", + "archived_chats": []map[string]any{ + {"title": "Onboarding kickoff", "last_activity_humanized": "3 months ago"}, + {"title": "Quarterly planning draft", "last_activity_humanized": "4 months ago"}, + }, + }, + }, + }, + { + // Pins the n=1 rendering so future edits to the body cannot + // reintroduce a count-conditional that breaks the singular + // case. The list-introduction sentence and retention sentence + // both use plural-form pronouns ("them", "they") that read + // naturally for a single item. + name: "TemplateChatAutoArchiveDigestSingular", + id: notifications.TemplateChatAutoArchiveDigest, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{}, + Data: map[string]any{ + "auto_archive_days": "90", + "retention_days": "30", + "archived_chats": []map[string]any{ + {"title": "Onboarding kickoff", "last_activity_humanized": "3 months ago"}, + }, + }, + }, + }, + { + // Covers the retention_days="0" indefinite-retention branch. + name: "TemplateChatAutoArchiveDigestRetentionZero", + id: notifications.TemplateChatAutoArchiveDigest, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{}, + Data: map[string]any{ + "auto_archive_days": "90", + "retention_days": "0", + "archived_chats": []map[string]any{ + {"title": "Onboarding kickoff", "last_activity_humanized": "3 months ago"}, + {"title": "Quarterly planning draft", "last_activity_humanized": "4 months ago"}, + }, + }, + }, + }, + { + // Covers the additional_archived_count overflow sentence. + name: "TemplateChatAutoArchiveDigestOverflow", + id: notifications.TemplateChatAutoArchiveDigest, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{}, + Data: map[string]any{ + "auto_archive_days": "90", + "retention_days": "30", + "archived_chats": []map[string]any{ + {"title": "Onboarding kickoff", "last_activity_humanized": "3 months ago"}, + {"title": "Quarterly planning draft", "last_activity_humanized": "4 months ago"}, + }, + "additional_archived_count": "6", + }, + }, + }, } // We must have a test case for every notification_template. This is enforced below: diff --git a/coderd/notifications/notificationsmock/doc.go b/coderd/notifications/notificationsmock/doc.go new file mode 100644 index 0000000000..5f59cbb5eb --- /dev/null +++ b/coderd/notifications/notificationsmock/doc.go @@ -0,0 +1,5 @@ +// Package notificationsmock contains a mocked implementation of the +// notifications.Enqueuer interface for use in tests. +package notificationsmock + +//go:generate mockgen -destination ./notificationsmock.go -package notificationsmock github.com/coder/coder/v2/coderd/notifications Enqueuer diff --git a/coderd/notifications/notificationsmock/notificationsmock.go b/coderd/notifications/notificationsmock/notificationsmock.go new file mode 100644 index 0000000000..4c969e1774 --- /dev/null +++ b/coderd/notifications/notificationsmock/notificationsmock.go @@ -0,0 +1,82 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coder/coder/v2/coderd/notifications (interfaces: Enqueuer) +// +// Generated by this command: +// +// mockgen -destination ./notificationsmock.go -package notificationsmock github.com/coder/coder/v2/coderd/notifications Enqueuer +// + +// Package notificationsmock is a generated GoMock package. +package notificationsmock + +import ( + context "context" + reflect "reflect" + + uuid "github.com/google/uuid" + gomock "go.uber.org/mock/gomock" +) + +// MockEnqueuer is a mock of Enqueuer interface. +type MockEnqueuer struct { + ctrl *gomock.Controller + recorder *MockEnqueuerMockRecorder + isgomock struct{} +} + +// MockEnqueuerMockRecorder is the mock recorder for MockEnqueuer. +type MockEnqueuerMockRecorder struct { + mock *MockEnqueuer +} + +// NewMockEnqueuer creates a new mock instance. +func NewMockEnqueuer(ctrl *gomock.Controller) *MockEnqueuer { + mock := &MockEnqueuer{ctrl: ctrl} + mock.recorder = &MockEnqueuerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEnqueuer) EXPECT() *MockEnqueuerMockRecorder { + return m.recorder +} + +// Enqueue mocks base method. +func (m *MockEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, userID, templateID, labels, createdBy} + for _, a := range targets { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Enqueue", varargs...) + ret0, _ := ret[0].([]uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Enqueue indicates an expected call of Enqueue. +func (mr *MockEnqueuerMockRecorder) Enqueue(ctx, userID, templateID, labels, createdBy any, targets ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, userID, templateID, labels, createdBy}, targets...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enqueue", reflect.TypeOf((*MockEnqueuer)(nil).Enqueue), varargs...) +} + +// EnqueueWithData mocks base method. +func (m *MockEnqueuer) EnqueueWithData(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, userID, templateID, labels, data, createdBy} + for _, a := range targets { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "EnqueueWithData", varargs...) + ret0, _ := ret[0].([]uuid.UUID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EnqueueWithData indicates an expected call of EnqueueWithData. +func (mr *MockEnqueuerMockRecorder) EnqueueWithData(ctx, userID, templateID, labels, data, createdBy any, targets ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, userID, templateID, labels, data, createdBy}, targets...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnqueueWithData", reflect.TypeOf((*MockEnqueuer)(nil).EnqueueWithData), varargs...) +} diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigest.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigest.html.golden new file mode 100644 index 0000000000..5104fb7122 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigest.html.golden @@ -0,0 +1,92 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Chats auto-archived after 90 days of inactivity +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +The following chats were automatically archived: + +"Onboarding kickoff" (last active 3 months ago) +"Quarterly planning draft" (last active 4 months ago) + +You can restore any of them from the Agents page within 30 days, after whic= +h they will be permanently deleted. + + +View chats: http://test.com/agents?archived=3Darchived + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Chats auto-archived after 90 days of inactivity + + +
+
+ 3D"Cod= +
+

+ Chats auto-archived after 90 days of inactivity +

+
+

Hi Bobby,

+

The following chats were automatically archived:

+ + + +

You can restore any of them from the Agents page within 30 days, after w= +hich they will be permanently deleted.

+
+
+ =20 + + View chats + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestOverflow.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestOverflow.html.golden new file mode 100644 index 0000000000..4b7236a56e --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestOverflow.html.golden @@ -0,0 +1,96 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Chats auto-archived after 90 days of inactivity +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +The following chats were automatically archived: + +"Onboarding kickoff" (last active 3 months ago) +"Quarterly planning draft" (last active 4 months ago) + +...and 6 more. + +You can restore any of them from the Agents page within 30 days, after whic= +h they will be permanently deleted. + + +View chats: http://test.com/agents?archived=3Darchived + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Chats auto-archived after 90 days of inactivity + + +
+
+ 3D"Cod= +
+

+ Chats auto-archived after 90 days of inactivity +

+
+

Hi Bobby,

+

The following chats were automatically archived:

+ + + +

…and 6 more.

+ +

You can restore any of them from the Agents page within 30 days, after w= +hich they will be permanently deleted.

+
+
+ =20 + + View chats + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestRetentionZero.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestRetentionZero.html.golden new file mode 100644 index 0000000000..10b4b74874 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestRetentionZero.html.golden @@ -0,0 +1,92 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Chats auto-archived after 90 days of inactivity +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +The following chats were automatically archived: + +"Onboarding kickoff" (last active 3 months ago) +"Quarterly planning draft" (last active 4 months ago) + +You can restore any of them from the Agents page; archived chats are kept i= +ndefinitely. + + +View chats: http://test.com/agents?archived=3Darchived + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Chats auto-archived after 90 days of inactivity + + +
+
+ 3D"Cod= +
+

+ Chats auto-archived after 90 days of inactivity +

+
+

Hi Bobby,

+

The following chats were automatically archived:

+ + + +

You can restore any of them from the Agents page; archived chats are kep= +t indefinitely.

+
+
+ =20 + + View chats + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestSingular.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestSingular.html.golden new file mode 100644 index 0000000000..70d179ceb9 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateChatAutoArchiveDigestSingular.html.golden @@ -0,0 +1,89 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Chats auto-archived after 90 days of inactivity +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +The following chats were automatically archived: + +"Onboarding kickoff" (last active 3 months ago) + +You can restore any of them from the Agents page within 30 days, after whic= +h they will be permanently deleted. + + +View chats: http://test.com/agents?archived=3Darchived + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Chats auto-archived after 90 days of inactivity + + +
+
+ 3D"Cod= +
+

+ Chats auto-archived after 90 days of inactivity +

+
+

Hi Bobby,

+

The following chats were automatically archived:

+ + + +

You can restore any of them from the Agents page within 30 days, after w= +hich they will be permanently deleted.

+
+
+ =20 + + View chats + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigest.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigest.json.golden new file mode 100644 index 0000000000..192a0c47c3 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigest.json.golden @@ -0,0 +1,39 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Chats Auto-Archived", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View chats", + "url": "http://test.com/agents?archived=archived" + } + ], + "labels": {}, + "data": { + "archived_chats": [ + { + "last_activity_humanized": "3 months ago", + "title": "Onboarding kickoff" + }, + { + "last_activity_humanized": "4 months ago", + "title": "Quarterly planning draft" + } + ], + "auto_archive_days": "90", + "retention_days": "30" + }, + "targets": null + }, + "title": "Chats auto-archived after 90 days of inactivity", + "title_markdown": "Chats auto-archived after 90 days of inactivity", + "body": "The following chats were automatically archived:\n\n\"Onboarding kickoff\" (last active 3 months ago)\n\"Quarterly planning draft\" (last active 4 months ago)\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted.", + "body_markdown": "The following chats were automatically archived:\n\n* \"Onboarding kickoff\" (last active 3 months ago)\n* \"Quarterly planning draft\" (last active 4 months ago)\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestOverflow.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestOverflow.json.golden new file mode 100644 index 0000000000..06703b8b3a --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestOverflow.json.golden @@ -0,0 +1,40 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Chats Auto-Archived", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View chats", + "url": "http://test.com/agents?archived=archived" + } + ], + "labels": {}, + "data": { + "additional_archived_count": "6", + "archived_chats": [ + { + "last_activity_humanized": "3 months ago", + "title": "Onboarding kickoff" + }, + { + "last_activity_humanized": "4 months ago", + "title": "Quarterly planning draft" + } + ], + "auto_archive_days": "90", + "retention_days": "30" + }, + "targets": null + }, + "title": "Chats auto-archived after 90 days of inactivity", + "title_markdown": "Chats auto-archived after 90 days of inactivity", + "body": "The following chats were automatically archived:\n\n\"Onboarding kickoff\" (last active 3 months ago)\n\"Quarterly planning draft\" (last active 4 months ago)\n\n...and 6 more.\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted.", + "body_markdown": "The following chats were automatically archived:\n\n* \"Onboarding kickoff\" (last active 3 months ago)\n* \"Quarterly planning draft\" (last active 4 months ago)\n\n...and 6 more.\n\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestRetentionZero.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestRetentionZero.json.golden new file mode 100644 index 0000000000..0e1400e842 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestRetentionZero.json.golden @@ -0,0 +1,39 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Chats Auto-Archived", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View chats", + "url": "http://test.com/agents?archived=archived" + } + ], + "labels": {}, + "data": { + "archived_chats": [ + { + "last_activity_humanized": "3 months ago", + "title": "Onboarding kickoff" + }, + { + "last_activity_humanized": "4 months ago", + "title": "Quarterly planning draft" + } + ], + "auto_archive_days": "90", + "retention_days": "0" + }, + "targets": null + }, + "title": "Chats auto-archived after 90 days of inactivity", + "title_markdown": "Chats auto-archived after 90 days of inactivity", + "body": "The following chats were automatically archived:\n\n\"Onboarding kickoff\" (last active 3 months ago)\n\"Quarterly planning draft\" (last active 4 months ago)\n\nYou can restore any of them from the Agents page; archived chats are kept indefinitely.", + "body_markdown": "The following chats were automatically archived:\n\n* \"Onboarding kickoff\" (last active 3 months ago)\n* \"Quarterly planning draft\" (last active 4 months ago)\n\nYou can restore any of them from the Agents page; archived chats are kept indefinitely." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestSingular.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestSingular.json.golden new file mode 100644 index 0000000000..2793812db0 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateChatAutoArchiveDigestSingular.json.golden @@ -0,0 +1,35 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Chats Auto-Archived", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View chats", + "url": "http://test.com/agents?archived=archived" + } + ], + "labels": {}, + "data": { + "archived_chats": [ + { + "last_activity_humanized": "3 months ago", + "title": "Onboarding kickoff" + } + ], + "auto_archive_days": "90", + "retention_days": "30" + }, + "targets": null + }, + "title": "Chats auto-archived after 90 days of inactivity", + "title_markdown": "Chats auto-archived after 90 days of inactivity", + "body": "The following chats were automatically archived:\n\n\"Onboarding kickoff\" (last active 3 months ago)\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted.", + "body_markdown": "The following chats were automatically archived:\n\n* \"Onboarding kickoff\" (last active 3 months ago)\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted." +} \ No newline at end of file diff --git a/docs/ai-coder/agents/platform-controls/chat-auto-archive.md b/docs/ai-coder/agents/platform-controls/chat-auto-archive.md index ad458dbda5..9483a4512e 100644 --- a/docs/ai-coder/agents/platform-controls/chat-auto-archive.md +++ b/docs/ai-coder/agents/platform-controls/chat-auto-archive.md @@ -26,6 +26,15 @@ root regardless of individual pin status. Admins and users who want to retain a conversation long after its last message should pin the root. +## Notifications + +When your chats are auto-archived, you receive a digest notification +listing the titles of the archived conversations and the +auto-archive window currently configured. + +If you find the digest noisy, you can disable the "Chats +Auto-Archived" notification entirely from your notification preferences. + ## Interaction with retention Auto-archive and deletion are two independent controls: @@ -79,6 +88,4 @@ set `auto_archive_days` back to `0`. Each auto-archived root chat produces an audit log entry with the background subsystem tag `chat_auto_archive`. Cascaded children are -not audited individually. The audit entry records the chat ID, owner -ID, and organization ID, and the diff shows `archived` flipping from -`false` to `true`. +not audited individually. diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx index ce90dc4109..b3098464d6 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.stories.tsx @@ -60,6 +60,7 @@ export const Default: Story = { await Promise.all([ // System notification templates + canvas.findByRole("switch", { name: "Chat Events" }), canvas.findByRole("switch", { name: "Task Events" }), canvas.findByRole("switch", { name: "Template Events" }), canvas.findByRole("switch", { name: "User Events" }), diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 14e6862ebb..3600af6aae 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -278,6 +278,7 @@ function canSeeNotificationGroup( return permissions.createUser; case "Workspace Events": case "Task Events": + case "Chat Events": case "Custom Events": return true; default: diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 049ae4b3cb..cefb418029 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4804,6 +4804,20 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] = kind: "system", enabled_by_default: false, }, + { + id: "764031be-4863-4220-867b-6ce1a1b7a5f5", + name: "Chats Auto-Archived", + title_template: + "Chats auto-archived after {{.Data.auto_archive_days}} days of inactivity", + body_template: + 'The following chats were automatically archived:\n\n{{range .Data.archived_chats}}* "{{.title}}" (last active {{.last_activity_humanized}})\n{{end}}', + actions: + '[{"label": "View chats", "url": "{{base_url}}/agents?archived=archived"}]', + group: "Chat Events", + method: "", + kind: "system", + enabled_by_default: true, + }, ]; export const MockCustomNotificationTemplates: TypesGen.NotificationTemplate[] =