mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: auto-archive inactive chats with audit trail (#24642)
Adds a background job in `dbpurge` that periodically archives chats inactive beyond a configurable threshold. Each archived root chat gets a background audit entry tagged `chat_auto_archive`. Disabled by default. * New `AutoArchiveInactiveChats` SQL query with LATERAL last-activity subquery and partial index on archive candidates * `site_configs`-backed `auto_archive_days` setting with admin-only PUT, any-authenticated-user GET * Cascade archive via `root_chat_id`; pinned chats and active threads exempt * Root-only audit dispatch on detached context, matching manual archive (`patchChat`) behavior * 11 subtests covering disabled no-op, boundary, deleted messages, child activity, pinned exemption, multi-owner, idempotency, and batch pagination PR #24643 adds per-owner digest notifications. PR #24704 adds the requisite UI controls. > 🤖
This commit is contained in:
@@ -5330,6 +5330,147 @@ func (q *sqlQuerier) ArchiveChatByID(ctx context.Context, id uuid.UUID) ([]Chat,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const autoArchiveInactiveChats = `-- name: AutoArchiveInactiveChats :many
|
||||
WITH to_archive AS (
|
||||
SELECT
|
||||
c.id,
|
||||
-- Activity = MAX(cm.created_at) across the family, or c.created_at
|
||||
-- when the family has no non-deleted messages.
|
||||
COALESCE(activity.last_activity_at, c.created_at) AS last_activity_at
|
||||
FROM chats c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT MAX(cm.created_at) AS last_activity_at
|
||||
FROM chat_messages cm
|
||||
JOIN chats fc ON fc.id = cm.chat_id
|
||||
WHERE (fc.id = c.id OR fc.root_chat_id = c.id)
|
||||
AND cm.deleted = false
|
||||
) activity ON TRUE
|
||||
WHERE c.archived = false
|
||||
AND c.pin_order = 0
|
||||
AND c.parent_chat_id IS NULL -- roots only
|
||||
AND c.created_at < $1::timestamptz
|
||||
-- New active statuses must be added here to prevent archiving.
|
||||
AND c.status NOT IN ('running', 'pending', 'paused', 'requires_action')
|
||||
AND COALESCE(activity.last_activity_at, c.created_at) < $1::timestamptz
|
||||
-- Sorting by created_at lets Postgres drive the scan from the
|
||||
-- partial index instead of evaluating every LATERAL subquery
|
||||
-- before sorting. All candidates are past the cutoff, so the
|
||||
-- archive order is immaterial once the backlog drains.
|
||||
ORDER BY c.created_at ASC
|
||||
LIMIT $2
|
||||
),
|
||||
archived AS (
|
||||
UPDATE chats c
|
||||
SET archived = true, pin_order = 0, updated_at = NOW()
|
||||
FROM to_archive t
|
||||
WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children
|
||||
AND c.archived = false
|
||||
RETURNING c.id, c.owner_id, c.workspace_id, c.title, c.status, c.worker_id, c.started_at, c.heartbeat_at, c.created_at, c.updated_at, c.parent_chat_id, c.root_chat_id, c.last_model_config_id, c.archived, c.last_error, c.mode, c.mcp_server_ids, c.labels, c.build_id, c.agent_id, c.pin_order, c.last_read_message_id, c.last_injected_context, c.dynamic_tools, c.organization_id, c.plan_mode, c.client_type
|
||||
)
|
||||
SELECT
|
||||
a.id, a.owner_id, a.workspace_id, a.title, a.status, a.worker_id, a.started_at, a.heartbeat_at, a.created_at, a.updated_at, a.parent_chat_id, a.root_chat_id, a.last_model_config_id, a.archived, a.last_error, a.mode, a.mcp_server_ids, a.labels, a.build_id, a.agent_id, a.pin_order, a.last_read_message_id, a.last_injected_context, a.dynamic_tools, a.organization_id, a.plan_mode, a.client_type,
|
||||
-- Children inherit their root's activity so last_activity_at is never null.
|
||||
COALESCE(
|
||||
t.last_activity_at,
|
||||
(SELECT tr.last_activity_at FROM to_archive tr WHERE tr.id = a.root_chat_id),
|
||||
a.created_at
|
||||
)::timestamptz AS last_activity_at
|
||||
FROM archived a
|
||||
LEFT JOIN to_archive t ON t.id = a.id
|
||||
ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC
|
||||
`
|
||||
|
||||
type AutoArchiveInactiveChatsParams struct {
|
||||
ArchiveCutoff time.Time `db:"archive_cutoff" json:"archive_cutoff"`
|
||||
LimitCount int32 `db:"limit_count" json:"limit_count"`
|
||||
}
|
||||
|
||||
type AutoArchiveInactiveChatsRow struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Status ChatStatus `db:"status" json:"status"`
|
||||
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
|
||||
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
|
||||
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
|
||||
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
|
||||
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
|
||||
Archived bool `db:"archived" json:"archived"`
|
||||
LastError sql.NullString `db:"last_error" json:"last_error"`
|
||||
Mode NullChatMode `db:"mode" json:"mode"`
|
||||
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
|
||||
Labels json.RawMessage `db:"labels" json:"labels"`
|
||||
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
|
||||
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
|
||||
PinOrder int32 `db:"pin_order" json:"pin_order"`
|
||||
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
|
||||
LastInjectedContext pqtype.NullRawMessage `db:"last_injected_context" json:"last_injected_context"`
|
||||
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
|
||||
ClientType ChatClientType `db:"client_type" json:"client_type"`
|
||||
LastActivityAt time.Time `db:"last_activity_at" json:"last_activity_at"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []AutoArchiveInactiveChatsRow
|
||||
for rows.Next() {
|
||||
var i AutoArchiveInactiveChatsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.WorkspaceID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.WorkerID,
|
||||
&i.StartedAt,
|
||||
&i.HeartbeatAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentChatID,
|
||||
&i.RootChatID,
|
||||
&i.LastModelConfigID,
|
||||
&i.Archived,
|
||||
&i.LastError,
|
||||
&i.Mode,
|
||||
pq.Array(&i.MCPServerIDs),
|
||||
&i.Labels,
|
||||
&i.BuildID,
|
||||
&i.AgentID,
|
||||
&i.PinOrder,
|
||||
&i.LastReadMessageID,
|
||||
&i.LastInjectedContext,
|
||||
&i.DynamicTools,
|
||||
&i.OrganizationID,
|
||||
&i.PlanMode,
|
||||
&i.ClientType,
|
||||
&i.LastActivityAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const backoffChatDiffStatus = `-- name: BackoffChatDiffStatus :exec
|
||||
UPDATE
|
||||
chat_diff_statuses
|
||||
@@ -20367,6 +20508,22 @@ func (q *sqlQuerier) GetApplicationName(ctx context.Context) (string, error) {
|
||||
return value, err
|
||||
}
|
||||
|
||||
const getChatAutoArchiveDays = `-- name: GetChatAutoArchiveDays :one
|
||||
SELECT COALESCE(
|
||||
(SELECT value::integer FROM site_configs
|
||||
WHERE key = 'agents_chat_auto_archive_days'),
|
||||
$1::integer
|
||||
) :: integer AS auto_archive_days
|
||||
`
|
||||
|
||||
// Auto-archive window in days. 0 disables.
|
||||
func (q *sqlQuerier) GetChatAutoArchiveDays(ctx context.Context, defaultAutoArchiveDays int32) (int32, error) {
|
||||
row := q.db.QueryRowContext(ctx, getChatAutoArchiveDays, defaultAutoArchiveDays)
|
||||
var auto_archive_days int32
|
||||
err := row.Scan(&auto_archive_days)
|
||||
return auto_archive_days, err
|
||||
}
|
||||
|
||||
const getChatDebugLoggingAllowUsers = `-- name: GetChatDebugLoggingAllowUsers :one
|
||||
SELECT
|
||||
COALESCE((SELECT value = 'true' FROM site_configs WHERE key = 'agents_chat_debug_logging_allow_users'), false) :: boolean AS allow_users
|
||||
@@ -20729,6 +20886,18 @@ func (q *sqlQuerier) UpsertApplicationName(ctx context.Context, value string) er
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertChatAutoArchiveDays = `-- name: UpsertChatAutoArchiveDays :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES ('agents_chat_auto_archive_days', CAST($1 AS integer)::text)
|
||||
ON CONFLICT (key) DO UPDATE SET value = CAST($1 AS integer)::text
|
||||
WHERE site_configs.key = 'agents_chat_auto_archive_days'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) UpsertChatAutoArchiveDays(ctx context.Context, autoArchiveDays int32) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertChatAutoArchiveDays, autoArchiveDays)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertChatDebugLoggingAllowUsers = `-- name: UpsertChatDebugLoggingAllowUsers :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES (
|
||||
|
||||
Reference in New Issue
Block a user