feat: chat auto-archive owner digest notifications (#24643)

Depends on #24642

Adds per-owner digest notifications onto the chat auto-archive
subsystem.

Each tick's archived rows are grouped by owner, the top 25 titles per
owner are rendered into a new `Chats Auto-Archived` notification
template, and any remainder surfaces as `and N more`. Each digest is
per-tick, so users with large amounts of purgeable data may get multiple
notifications in sequence (one per user per tick).

The template body branches on `retention_days`: when retention is
disabled (`retention_days=0`), users are told archived chats are kept
indefinitely rather than falsely claiming imminent deletion.

### Changes
- migration `000XXX_chat_auto_archive_notification_template` adds new
notification template
- `dbpurge`: threads `notifications.Enqueuer` through `New`; and
enqueues notification message.
- `cli/server.go`: passes `options.NotificationsEnqueuer` into
`dbpurge.New`.
- `coderd/notifications/events.go`: new `TemplateChatAutoArchiveDigest`
UUID.
- `coderd/inboxnotifications.go`: inbox registration.
- Docs: adds a `Notifications` section to `chat-auto-archive.md`.

> 🤖
This commit is contained in:
Cian Johnston
2026-04-28 08:56:36 +01:00
committed by GitHub
parent bf66f63ac5
commit 70d6efa311
25 changed files with 1341 additions and 103 deletions
+1 -1
View File
@@ -1074,7 +1074,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
defer shutdownConns() defer shutdownConns()
// Ensures that old database entries are cleaned up over time! // 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() defer purger.Close()
// Updates workspace usage // Updates workspace usage
+212 -73
View File
@@ -1,12 +1,18 @@
package dbpurge package dbpurge
import ( import (
"cmp"
"context" "context"
"errors"
"io" "io"
"net/http" "net/http"
"slices"
"strconv"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/dustin/go-humanize"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors" "golang.org/x/xerrors"
@@ -15,7 +21,9 @@ import (
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime" "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/pproflabel"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz" "github.com/coder/quartz"
) )
@@ -41,6 +49,12 @@ const (
// chat_files rows carry bytea blobs. // chat_files rows carry bytea blobs.
chatsBatchSize = 1000 chatsBatchSize = 1000
chatFilesBatchSize = 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 // defaultChatAutoArchiveBatchSize bounds how many root chats one
@@ -62,12 +76,26 @@ func WithChatAutoArchiveBatchSize(n int32) Option {
return func(i *instance) { i.chatAutoArchiveBatchSize = n } 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. // New creates a new periodically purging database instance.
// Callers must Close the returned instance. // Callers must Close the returned instance.
// //
// The auditor pointer is loaded on each dispatch tick so runtime // The auditor pointer is loaded on each dispatch tick so runtime
// entitlement changes (e.g. toggling the audit-log feature) take // 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 { 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{}) closed := make(chan struct{})
@@ -107,6 +135,7 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder
vals: vals, vals: vals,
clk: quartz.NewReal(), clk: quartz.NewReal(),
auditor: auditor, auditor: auditor,
enqueuer: notifications.NewNoopEnqueuer(),
iterationDuration: iterationDuration, iterationDuration: iterationDuration,
recordsPurged: recordsPurged, recordsPurged: recordsPurged,
chatAutoArchiveRecords: chatAutoArchiveRecords, 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 // purgeTick performs a single purge iteration. It returns an error if the
// purge fails. // purge fails.
func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.Time) error { func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.Time) error {
// Read chat retention config outside the transaction to // Read chat configs outside the tx so a corrupt value can't
// avoid poisoning the tx if the stored value is corrupt. // poison subsequent queries. On error we log and stash, then
// A SQL-level cast error (e.g. non-numeric text) puts PG // run unrelated purges best-effort and skip only chat work;
// into error state, failing all subsequent queries in the // purgeTick returns chatConfigErr after the tx so the failed
// same transaction. // iteration is operator-visible via metric and logs.
chatRetentionDays, err := db.GetChatRetentionDays(ctx) chatRetentionDays, chatRetentionErr := db.GetChatRetentionDays(ctx)
if err != nil { if chatRetentionErr != nil {
i.logger.Warn(ctx, "failed to read chat retention config, skipping chat purge", slog.Error(err)) i.logger.Error(ctx, "failed to read chat retention config: skipping chat purge and auto-archive this tick", slog.Error(chatRetentionErr))
chatRetentionDays = 0
} }
// Same rationale as chat_retention_days: read outside the tx. chatAutoArchiveDays, chatAutoArchiveErr := db.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays)
chatAutoArchiveDays, err := db.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays) if chatAutoArchiveErr != nil {
if err != nil { i.logger.Error(ctx, "failed to read chat auto-archive config: skipping chat purge and auto-archive this tick", slog.Error(chatAutoArchiveErr))
i.logger.Warn(ctx, "failed to read chat auto-archive config, skipping auto-archive", slog.Error(err))
chatAutoArchiveDays = 0
} }
chatConfigErr := errors.Join(chatRetentionErr, chatAutoArchiveErr)
// Populated inside the tx; dispatched post-commit. // Populated inside the tx; dispatched post-commit.
var archivedChats []database.AutoArchiveInactiveChatsRow var archivedChats []database.AutoArchiveInactiveChatsRow
// Start a transaction to grab advisory lock, we don't want to run // Start a transaction to grab advisory lock, we don't want to run
// multiple purges at the same time (multiple replicas). // 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 // Acquire a lock to ensure that only one instance of the
// purge is running at a time. // purge is running at a time.
ok, err := tx.TryAcquireLock(ctx, database.LockIDDBPurge) 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 var purgedChats, purgedChatFiles int64
// enabled, old archived chats are deleted first, then if chatConfigErr == nil {
// orphaned chat files. Deleting a chat cascades to purgedChats, purgedChatFiles, archivedChats, err = i.purgeChatsInTx(ctx, tx, start, chatRetentionDays, chatAutoArchiveDays)
// 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,
})
if err != nil { if err != nil {
return xerrors.Errorf("failed to delete old chats: %w", err) return xerrors.Errorf("failed to purge 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)
} }
} }
@@ -351,15 +344,24 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
return err return err
} }
// Dispatch audits post-commit on a detached context so ticker // Surface the deferred chat-config error so doTick records
// cancellation doesn't interrupt the loop. No timeout: every root // the failed iteration metric.
// must be audited to avoid gaps in the trail. Children inherit if chatConfigErr != nil {
// their root's archival decision and are not audited individually, return xerrors.Errorf("chat config read failed this tick: %w", chatConfigErr)
// matching the manual archive path (patchChat audits the root only). }
// 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 { if len(archivedChats) > 0 {
i.chatAutoArchiveRecords.Add(float64(len(archivedChats))) i.chatAutoArchiveRecords.Add(float64(len(archivedChats)))
dispatchCtx := context.WithoutCancel(ctx) auditCtx := context.WithoutCancel(ctx)
i.dispatchChatAutoArchive(dispatchCtx, archivedChats) i.dispatchChatAutoArchive(auditCtx, ctx, start, chatAutoArchiveDays, chatRetentionDays, archivedChats)
} }
return nil return nil
@@ -372,6 +374,7 @@ type instance struct {
vals *codersdk.DeploymentValues vals *codersdk.DeploymentValues
clk quartz.Clock clk quartz.Clock
auditor *atomic.Pointer[audit.Auditor] auditor *atomic.Pointer[audit.Auditor]
enqueuer notifications.Enqueuer
iterationDuration *prometheus.HistogramVec iterationDuration *prometheus.HistogramVec
recordsPurged *prometheus.CounterVec recordsPurged *prometheus.CounterVec
chatAutoArchiveRecords prometheus.Counter chatAutoArchiveRecords prometheus.Counter
@@ -428,20 +431,69 @@ func chatFromAutoArchiveRow(logger slog.Logger, r database.AutoArchiveInactiveCh
} }
} }
// dispatchChatAutoArchive audits every archived root chat. Children // purgeChatsInTx MUST BE CALLED WITH A TRANSACTION
// inherit their root's archival decision and are skipped, matching func (i *instance) purgeChatsInTx(ctx context.Context, tx database.Store, start time.Time, chatRetentionDays, chatAutoArchiveDays int32) (purgedChats, purgedChatFiles int64, archivedChats []database.AutoArchiveInactiveChatsRow, err error) {
// the manual archive path (patchChat audits the root only). Runs on // Delete old archived chats first, then orphaned files
// a detached context so ticker cancellation cannot truncate the trail. // (cascade clears chat_file_links but not chat_files).
func (i *instance) dispatchChatAutoArchive(ctx context.Context, archived []database.AutoArchiveInactiveChatsRow) { if chatRetentionDays > 0 {
auditor := *i.auditor.Load() deleteChatsBefore := start.Add(-time.Duration(chatRetentionDays) * 24 * time.Hour)
for _, row := range archived { purgedChats, err = tx.DeleteOldChats(ctx, database.DeleteOldChatsParams{
if row.ParentChatID.Valid { BeforeTime: deleteChatsBefore,
continue // Children inherit root's archival; audit roots only. 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) after := chatFromAutoArchiveRow(i.logger, row)
before := after before := after
before.Archived = false before.Archived = false
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.Chat]{ audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.Chat]{
Audit: auditor, Audit: auditor,
Log: i.logger, Log: i.logger,
UserID: row.OwnerID, UserID: row.OwnerID,
@@ -450,7 +502,94 @@ func (i *instance) dispatchChatAutoArchive(ctx context.Context, archived []datab
Old: before, Old: before,
New: after, New: after,
Status: http.StatusOK, 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
} }
+361 -26
View File
@@ -32,6 +32,9 @@ import (
"github.com/coder/coder/v2/coderd/database/dbrollup" "github.com/coder/coder/v2/coderd/database/dbrollup"
"github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime" "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/coderd/provisionerdserver"
"github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionerd/proto" "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") 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. //nolint:paralleltest // It uses LockIDDBPurge.
@@ -2348,15 +2437,19 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock() auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor) auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk) 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() defer closer.Close()
testutil.TryReceive(ctx, t, done) testutil.TryReceive(ctx, t, done)
// Not archived, no audits, no digests.
refreshed, err := db.GetChatByID(ctx, staleChat.ID) refreshed, err := db.GetChatByID(ctx, staleChat.ID)
require.NoError(t, err) require.NoError(t, err)
require.False(t, refreshed.Archived, "chat should stay active when auto-archive is disabled") 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, 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) h := newArchiveHarness(t, now)
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps 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.UpsertChatAutoArchiveDays(ctx, int32(90)))
require.NoError(t, db.UpsertChatRetentionDays(ctx, int32(30)))
// Inactive root: newest message 100 days old. // Inactive root: newest message 100 days old.
staleChat := createArchiveChat(ctx, t, db, rawDB, deps, "stale-chat", now.Add(-120*24*time.Hour)) 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() auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor) auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk) 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() defer closer.Close()
testutil.TryReceive(ctx, t, done) testutil.TryReceive(ctx, t, done)
@@ -2390,6 +2487,7 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.False(t, refreshedActive.Archived, "active chat should stay live") require.False(t, refreshedActive.Archived, "active chat should stay live")
// Exactly one audit entry, for the stale root.
logs := auditor.AuditLogs() logs := auditor.AuditLogs()
require.Len(t, logs, 1, "expected one audit entry") require.Len(t, logs, 1, "expected one audit entry")
require.Equal(t, staleChat.ID, logs[0].ResourceID) 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.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Contains(t, string(logs[0].AdditionalFields), "chat_auto_archive", require.Contains(t, string(logs[0].AdditionalFields), "chat_auto_archive",
"audit entry must carry the auto-archive subsystem tag") "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() auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor) auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk) 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() defer closer.Close()
testutil.TryReceive(ctx, t, done) testutil.TryReceive(ctx, t, done)
@@ -2512,6 +2620,7 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
require.False(t, refreshedChild.Archived, "child must stay active") require.False(t, refreshedChild.Archived, "child must stay active")
require.Empty(t, auditor.AuditLogs(), "no chats should be archived") 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() auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor) auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk) 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() defer closer.Close()
testutil.TryReceive(ctx, t, done) testutil.TryReceive(ctx, t, done)
@@ -2580,6 +2690,12 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
logs := auditor.AuditLogs() logs := auditor.AuditLogs()
require.Len(t, logs, 1, "only the completed chat should produce an audit entry") require.Len(t, logs, 1, "only the completed chat should produce an audit entry")
require.Equal(t, completedChat.ID, logs[0].ResourceID) 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() auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor) auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk) 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() defer closer.Close()
testutil.TryReceive(ctx, t, done) testutil.TryReceive(ctx, t, done)
@@ -2628,6 +2745,62 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
// One audit entry for the root; the cascaded child is // One audit entry for the root; the cascaded child is
// not audited individually. // not audited individually.
require.Len(t, auditor.AuditLogs(), 1) 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))) require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30)))
// Two stale roots per owner, backdated well past the // Two stale roots per owner, backdated well past
// 30-day cutoff. // the 30-day cutoff.
u1Deps := deps u1Deps := deps
u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig} u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig}
createArchiveChat(ctx, t, db, rawDB, u1Deps, "u1-a", now.Add(-60*24*time.Hour)) 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() auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor) auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk) 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() defer closer.Close()
testutil.TryReceive(ctx, t, done) testutil.TryReceive(ctx, t, done)
// Four audit rows, one per archived root. Each entry // Four audit rows, one per archived root, attributed
// carries the owning UserID so downstream consumers can // to the owning user so downstream consumers can
// correlate per-owner activity. // correlate per-owner activity.
logs := auditor.AuditLogs() logs := auditor.AuditLogs()
require.Len(t, logs, 4) require.Len(t, logs, 4)
byUser := map[uuid.UUID]int{} auditsByUser := map[uuid.UUID]int{}
for _, l := range logs { for _, l := range logs {
byUser[l.UserID]++ auditsByUser[l.UserID]++
} }
require.Equal(t, 2, byUser[deps.user.ID]) require.Equal(t, 2, auditsByUser[deps.user.ID])
require.Equal(t, 2, byUser[user2.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() auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor) auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
driver := newTickDriver(t, clk) 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 // Defer driver.close() after closer.Close(): defers
// run LIFO, so this frees shutdown's ticker.Stop() // run LIFO, so this frees shutdown's ticker.Stop()
// before the dbpurge goroutine blocks on it. // before the dbpurge goroutine blocks on it.
@@ -2692,8 +2889,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
defer driver.close() defer driver.close()
driver.awaitInitial(ctx, t) 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, 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 // Seed a third stale root between ticks so tick 2 has
// genuine work and we can distinguish "ignored already // genuine work and we can distinguish "ignored already
@@ -2702,11 +2900,17 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
driver.awaitNext(ctx, t) driver.awaitNext(ctx, t)
// Tick 2: exactly one new audit for the third chat; // Tick 2: exactly one new audit + one new digest for
// tick 1's rows must not be re-archived. // the third chat; tick 1's rows must not be re-archived.
require.Len(t, auditor.AuditLogs(), 3, "tick 2 cumulative audits") 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} { for _, id := range []uuid.UUID{firstA.ID, firstB.ID, third.ID} {
refreshed, err := db.GetChatByID(ctx, id) refreshed, err := db.GetChatByID(ctx, id)
require.NoError(t, err) require.NoError(t, err)
@@ -2718,10 +2922,18 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
name: "BatchSizePagination", name: "BatchSizePagination",
run: func(t *testing.T) { run: func(t *testing.T) {
// With 27 stale roots and batch size 20, tick 1 // With 27 stale roots and batch size 20, tick 1
// archives 20, tick 2 archives the remaining 7, tick 3 // archives 20, tick 2 archives the remaining 7, and
// archives none. We assert the audit dispatch follows // tick 3 archives none. We assert the dispatch side
// the same pattern: no dispatch runs when rows == 0, // effects (audits, digests) follow the same pattern:
// so tick 3 emits no new audits. // 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) h := newArchiveHarness(t, now)
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps 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() auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor) auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
driver := newTickDriver(t, clk) 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 // Defer driver.close() after closer.Close() so trap
// cleanup frees shutdown's ticker.Stop() before the // cleanup frees shutdown's ticker.Stop() before the
// dbpurge goroutine blocks on it. // dbpurge goroutine blocks on it.
@@ -2745,15 +2958,137 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
defer driver.close() defer driver.close()
driver.awaitInitial(ctx, t) driver.awaitInitial(ctx, t)
// Tick 1: first batch (20) archived.
require.Len(t, auditor.AuditLogs(), 20, "tick 1 audits") 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) driver.awaitNext(ctx, t)
// Tick 2: remaining 7 archived.
require.Len(t, auditor.AuditLogs(), 27, "tick 2 cumulative audits") 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) 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, 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")
}, },
}, },
} }
@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = '764031be-4863-4220-867b-6ce1a1b7a5f5';
@@ -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
);
+2
View File
@@ -64,6 +64,8 @@ type sqlcQuerier interface {
// Archives inactive root chats (pinned and already-archived chats skipped), // Archives inactive root chats (pinned and already-archived chats skipped),
// cascading to children via root_chat_id. Limits apply to roots, not total // cascading to children via root_chat_id. Limits apply to roots, not total
// rows. Used by dbpurge. // 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) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error)
BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error
BatchUpdateWorkspaceAgentMetadata(ctx context.Context, arg BatchUpdateWorkspaceAgentMetadataParams) error BatchUpdateWorkspaceAgentMetadata(ctx context.Context, arg BatchUpdateWorkspaceAgentMetadataParams) error
+2
View File
@@ -5419,6 +5419,8 @@ type AutoArchiveInactiveChatsRow struct {
// Archives inactive root chats (pinned and already-archived chats skipped), // Archives inactive root chats (pinned and already-archived chats skipped),
// cascading to children via root_chat_id. Limits apply to roots, not total // cascading to children via root_chat_id. Limits apply to roots, not total
// rows. Used by dbpurge. // 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) { func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) {
rows, err := q.db.QueryContext(ctx, autoArchiveInactiveChats, arg.ArchiveCutoff, arg.LimitCount) rows, err := q.db.QueryContext(ctx, autoArchiveInactiveChats, arg.ArchiveCutoff, arg.LimitCount)
if err != nil { if err != nil {
+2
View File
@@ -1482,4 +1482,6 @@ SELECT
)::timestamptz AS last_activity_at )::timestamptz AS last_activity_at
FROM archived a FROM archived a
LEFT JOIN to_archive t ON t.id = a.id 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; ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC;
+3
View File
@@ -54,6 +54,9 @@ var fallbackIcons = map[uuid.UUID]string{
notifications.TemplateTemplateDeleted: codersdk.InboxNotificationFallbackIconTemplate, notifications.TemplateTemplateDeleted: codersdk.InboxNotificationFallbackIconTemplate,
notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate, notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate,
notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate, notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate,
// chat related notifications
notifications.TemplateChatAutoArchiveDigest: codersdk.InboxNotificationFallbackIconOther,
} }
func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification { func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification {
+5
View File
@@ -62,3 +62,8 @@ var (
TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1") TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1")
TemplateTaskResumed = uuid.MustParse("843ee9c3-a8fb-4846-afa9-977bec578649") TemplateTaskResumed = uuid.MustParse("843ee9c3-a8fb-4846-afa9-977bec578649")
) )
// Chat-related events.
var (
TemplateChatAutoArchiveDigest = uuid.MustParse("764031be-4863-4220-867b-6ce1a1b7a5f5")
)
@@ -1332,6 +1332,89 @@ func TestNotificationTemplates_Golden(t *testing.T) {
Data: map[string]any{}, 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: // We must have a test case for every notification_template. This is enforced below:
@@ -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
@@ -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...)
}
@@ -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
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Chats auto-archived after 90 days of inactivity</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Chats auto-archived after 90 days of inactivity
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>The following chats were automatically archived:</p>
<ul>
<li>&ldquo;Onboarding kickoff&rdquo; (last active 3 months ago)<br>
</li>
<li>&ldquo;Quarterly planning draft&rdquo; (last active 4 months ago)<br>
</li>
</ul>
<p>You can restore any of them from the Agents page within 30 days, after w=
hich they will be permanently deleted.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents?archived=3Darchived" style=3D"dis=
play: inline-block; padding: 13px 24px; background-color: #020617; color: #=
f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View chats
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D764=
031be-4863-4220-867b-6ce1a1b7a5f5" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -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
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Chats auto-archived after 90 days of inactivity</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Chats auto-archived after 90 days of inactivity
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>The following chats were automatically archived:</p>
<ul>
<li>&ldquo;Onboarding kickoff&rdquo; (last active 3 months ago)<br>
</li>
<li>&ldquo;Quarterly planning draft&rdquo; (last active 4 months ago)<br>
</li>
</ul>
<p>&hellip;and 6 more.</p>
<p>You can restore any of them from the Agents page within 30 days, after w=
hich they will be permanently deleted.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents?archived=3Darchived" style=3D"dis=
play: inline-block; padding: 13px 24px; background-color: #020617; color: #=
f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View chats
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D764=
031be-4863-4220-867b-6ce1a1b7a5f5" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -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
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Chats auto-archived after 90 days of inactivity</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Chats auto-archived after 90 days of inactivity
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>The following chats were automatically archived:</p>
<ul>
<li>&ldquo;Onboarding kickoff&rdquo; (last active 3 months ago)<br>
</li>
<li>&ldquo;Quarterly planning draft&rdquo; (last active 4 months ago)<br>
</li>
</ul>
<p>You can restore any of them from the Agents page; archived chats are kep=
t indefinitely.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents?archived=3Darchived" style=3D"dis=
play: inline-block; padding: 13px 24px; background-color: #020617; color: #=
f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View chats
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D764=
031be-4863-4220-867b-6ce1a1b7a5f5" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -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
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Chats auto-archived after 90 days of inactivity</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Chats auto-archived after 90 days of inactivity
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>The following chats were automatically archived:</p>
<ul>
<li>&ldquo;Onboarding kickoff&rdquo; (last active 3 months ago)<br>
</li>
</ul>
<p>You can restore any of them from the Agents page within 30 days, after w=
hich they will be permanently deleted.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents?archived=3Darchived" style=3D"dis=
play: inline-block; padding: 13px 24px; background-color: #020617; color: #=
f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View chats
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D764=
031be-4863-4220-867b-6ce1a1b7a5f5" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -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."
}
@@ -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."
}
@@ -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."
}
@@ -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."
}
@@ -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 to retain a conversation long after its last message should pin the
root. 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 ## Interaction with retention
Auto-archive and deletion are two independent controls: 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 Each auto-archived root chat produces an audit log entry with the
background subsystem tag `chat_auto_archive`. Cascaded children are background subsystem tag `chat_auto_archive`. Cascaded children are
not audited individually. The audit entry records the chat ID, owner not audited individually.
ID, and organization ID, and the diff shows `archived` flipping from
`false` to `true`.
@@ -60,6 +60,7 @@ export const Default: Story = {
await Promise.all([ await Promise.all([
// System notification templates // System notification templates
canvas.findByRole("switch", { name: "Chat Events" }),
canvas.findByRole("switch", { name: "Task Events" }), canvas.findByRole("switch", { name: "Task Events" }),
canvas.findByRole("switch", { name: "Template Events" }), canvas.findByRole("switch", { name: "Template Events" }),
canvas.findByRole("switch", { name: "User Events" }), canvas.findByRole("switch", { name: "User Events" }),
@@ -278,6 +278,7 @@ function canSeeNotificationGroup(
return permissions.createUser; return permissions.createUser;
case "Workspace Events": case "Workspace Events":
case "Task Events": case "Task Events":
case "Chat Events":
case "Custom Events": case "Custom Events":
return true; return true;
default: default:
+14
View File
@@ -4804,6 +4804,20 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
kind: "system", kind: "system",
enabled_by_default: false, 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[] = export const MockCustomNotificationTemplates: TypesGen.NotificationTemplate[] =