feat: add chat and chat_files cleanup to dbpurge (#23833)

Fixes https://github.com/coder/coder/issues/23910

Adds periodic cleanup of chats and chat files to the dbpurge background
goroutine, with a configurable retention period exposed in the Agent
settings UI.

> 🤖 Written by a Coder Agent. Reviewed by a human.
This commit is contained in:
Cian Johnston
2026-04-08 11:08:09 +01:00
committed by GitHub
parent 3a612898c6
commit 233343c010
26 changed files with 1514 additions and 4 deletions
+14
View File
@@ -3279,6 +3279,20 @@ class ExperimentalApiMethods {
await this.axios.put("/api/experimental/chats/config/workspace-ttl", req);
};
getChatRetentionDays =
async (): Promise<TypesGen.ChatRetentionDaysResponse> => {
const response = await this.axios.get<TypesGen.ChatRetentionDaysResponse>(
"/api/experimental/chats/config/retention-days",
);
return response.data;
};
updateChatRetentionDays = async (
req: TypesGen.UpdateChatRetentionDaysRequest,
): Promise<void> => {
await this.axios.put("/api/experimental/chats/config/retention-days", req);
};
updateChatTemplateAllowlist = async (
req: TypesGen.ChatTemplateAllowlist,
): Promise<void> => {
+16
View File
@@ -770,6 +770,22 @@ export const updateChatWorkspaceTTL = (queryClient: QueryClient) => ({
},
});
const chatRetentionDaysKey = ["chat-retention-days"] as const;
export const chatRetentionDays = () => ({
queryKey: chatRetentionDaysKey,
queryFn: () => API.experimental.getChatRetentionDays(),
});
export const updateChatRetentionDays = (queryClient: QueryClient) => ({
mutationFn: API.experimental.updateChatRetentionDays,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatRetentionDaysKey,
});
},
});
const chatTemplateAllowlistKey = ["chat-template-allowlist"] as const;
export const chatTemplateAllowlist = () => ({
+17
View File
@@ -1903,6 +1903,14 @@ export interface ChatReasoningPart {
readonly text: string;
}
// From codersdk/chats.go
/**
* ChatRetentionDaysResponse contains the current chat retention setting.
*/
export interface ChatRetentionDaysResponse {
readonly retention_days: number;
}
// From codersdk/chats.go
export interface ChatSkillPart {
readonly type: "skill";
@@ -7261,6 +7269,15 @@ export interface UpdateChatRequest {
readonly labels?: Record<string, string>;
}
// From codersdk/chats.go
/**
* UpdateChatRetentionDaysRequest is a request to update the chat
* retention period.
*/
export interface UpdateChatRetentionDaysRequest {
readonly retention_days: number;
}
// From codersdk/chats.go
/**
* UpdateChatSystemPromptRequest is the request body for updating the chat
@@ -3,11 +3,13 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
import {
chatDesktopEnabled,
chatModelConfigs,
chatRetentionDays,
chatSystemPrompt,
chatUserCustomPrompt,
chatWorkspaceTTL,
deleteUserCompactionThreshold,
updateChatDesktopEnabled,
updateChatRetentionDays,
updateChatSystemPrompt,
updateChatWorkspaceTTL,
updateUserChatCustomPrompt,
@@ -44,6 +46,11 @@ const AgentSettingsBehaviorPage: FC = () => {
updateChatWorkspaceTTL(queryClient),
);
const retentionDaysQuery = useQuery(chatRetentionDays());
const saveRetentionDaysMutation = useMutation(
updateChatRetentionDays(queryClient),
);
const modelConfigsQuery = useQuery(chatModelConfigs());
const thresholdsQuery = useQuery(userCompactionThresholds());
@@ -95,6 +102,12 @@ const AgentSettingsBehaviorPage: FC = () => {
onSaveWorkspaceTTL={saveWorkspaceTTLMutation.mutate}
isSavingWorkspaceTTL={saveWorkspaceTTLMutation.isPending}
isSaveWorkspaceTTLError={saveWorkspaceTTLMutation.isError}
retentionDaysData={retentionDaysQuery.data}
isRetentionDaysLoading={retentionDaysQuery.isLoading}
isRetentionDaysLoadError={retentionDaysQuery.isError}
onSaveRetentionDays={saveRetentionDaysMutation.mutate}
isSavingRetentionDays={saveRetentionDaysMutation.isPending}
isSaveRetentionDaysError={saveRetentionDaysMutation.isError}
/>
);
};
@@ -39,6 +39,9 @@ interface AgentSettingsBehaviorPageViewProps {
workspaceTTLData: TypesGen.ChatWorkspaceTTLResponse | undefined;
isWorkspaceTTLLoading: boolean;
isWorkspaceTTLLoadError: boolean;
retentionDaysData: TypesGen.ChatRetentionDaysResponse | undefined;
isRetentionDaysLoading: boolean;
isRetentionDaysLoadError: boolean;
modelConfigsData: TypesGen.ChatModelConfig[] | undefined;
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
@@ -81,6 +84,13 @@ interface AgentSettingsBehaviorPageViewProps {
) => void;
isSavingWorkspaceTTL: boolean;
isSaveWorkspaceTTLError: boolean;
onSaveRetentionDays: (
req: TypesGen.UpdateChatRetentionDaysRequest,
options?: MutationCallbacks,
) => void;
isSavingRetentionDays: boolean;
isSaveRetentionDaysError: boolean;
}
export const AgentSettingsBehaviorPageView: FC<
@@ -93,6 +103,9 @@ export const AgentSettingsBehaviorPageView: FC<
workspaceTTLData,
isWorkspaceTTLLoading,
isWorkspaceTTLLoadError,
retentionDaysData,
isRetentionDaysLoading,
isRetentionDaysLoadError,
modelConfigsData,
modelConfigsError,
isLoadingModelConfigs,
@@ -113,6 +126,9 @@ export const AgentSettingsBehaviorPageView: FC<
onSaveWorkspaceTTL,
isSavingWorkspaceTTL,
isSaveWorkspaceTTLError,
onSaveRetentionDays,
isSavingRetentionDays,
isSaveRetentionDaysError,
}) => {
// ── Local form state ──
const [localEdit, setLocalEdit] = useState<string | null>(null);
@@ -124,6 +140,12 @@ export const AgentSettingsBehaviorPageView: FC<
const [localUserEdit, setLocalUserEdit] = useState<string | null>(null);
const [localTTLMs, setLocalTTLMs] = useState<number | null>(null);
const [autostopToggled, setAutostopToggled] = useState<boolean | null>(null);
const [localRetentionDays, setLocalRetentionDays] = useState<number | null>(
null,
);
const [retentionToggled, setRetentionToggled] = useState<boolean | null>(
null,
);
// Overflow states are pure UI — managed locally in the view.
const [isUserPromptOverflowing, setIsUserPromptOverflowing] = useState(false);
@@ -171,6 +193,18 @@ export const AgentSettingsBehaviorPageView: FC<
const isTTLOverMax = ttlMs > maxTTLMs;
const isTTLZero = isAutostopEnabled && ttlMs === 0;
// ── Retention days derived state ──
const serverRetentionDays = retentionDaysData?.retention_days ?? 30;
const retentionDays = localRetentionDays ?? serverRetentionDays;
const isRetentionEnabled = retentionToggled ?? serverRetentionDays > 0;
const isRetentionDaysDirty =
localRetentionDays !== null && localRetentionDays !== serverRetentionDays;
const isRetentionDaysNegative = isRetentionEnabled && retentionDays < 0;
// Keep in sync with retentionDaysMaximum in coderd/exp_chats.go.
const retentionDaysMaximum = 3650;
const isRetentionDaysOverMax = retentionDays > retentionDaysMaximum;
const isRetentionDaysZero = isRetentionEnabled && retentionDays === 0;
// ── Event handlers ──
const handleSaveSystemPrompt = (event: FormEvent) => {
event.preventDefault();
@@ -245,6 +279,45 @@ export const AgentSettingsBehaviorPageView: FC<
}
};
const resetRetentionState = () => {
setLocalRetentionDays(null);
setRetentionToggled(null);
};
const handleToggleRetention = (checked: boolean) => {
if (checked) {
setRetentionToggled(true);
setLocalRetentionDays(serverRetentionDays > 0 ? serverRetentionDays : 30);
onSaveRetentionDays(
{ retention_days: serverRetentionDays > 0 ? serverRetentionDays : 30 },
{ onSuccess: resetRetentionState, onError: resetRetentionState },
);
} else {
setRetentionToggled(false);
setLocalRetentionDays(0);
onSaveRetentionDays(
{ retention_days: 0 },
{ onSuccess: resetRetentionState, onError: resetRetentionState },
);
}
};
const handleRetentionDaysChange = (value: number) => {
setLocalRetentionDays(value);
if (retentionToggled === null) {
setRetentionToggled(true);
}
};
const handleSaveRetentionDays = (event: FormEvent) => {
event.preventDefault();
if (!isRetentionDaysDirty || isSavingRetentionDays) return;
onSaveRetentionDays(
{ retention_days: localRetentionDays ?? 30 },
{ onSuccess: resetRetentionState },
);
};
return (
<>
<SectionHeader
@@ -520,6 +593,89 @@ export const AgentSettingsBehaviorPageView: FC<
</p>
)}
</form>
<hr className="my-5 border-0 border-t border-solid border-border" />
<form
className="space-y-2"
onSubmit={(event) => void handleSaveRetentionDays(event)}
>
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Conversation Retention Period
</h3>
<AdminBadge />
</div>
<div className="flex items-center justify-between gap-4">
<p className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
Archived conversations and orphaned files older than this are
automatically deleted.
</p>
<Switch
checked={isRetentionEnabled}
onCheckedChange={handleToggleRetention}
aria-label="Enable conversation retention"
disabled={isSavingRetentionDays || isRetentionDaysLoading}
/>
</div>
{isRetentionEnabled && (
<>
<input
type="number"
min={1}
max={3650}
step={1}
aria-label="Conversation retention period in days"
value={retentionDays}
onChange={(event) =>
handleRetentionDaysChange(
Number.parseInt(event.target.value, 10) || 0,
)
}
disabled={isSavingRetentionDays || isRetentionDaysLoading}
className="w-full rounded-lg border border-border bg-surface-primary px-4 py-2 text-[13px] text-content-primary placeholder:text-content-secondary focus:outline-none focus:ring-2 focus:ring-content-link/30"
/>
{isRetentionDaysZero && (
<p className="m-0 text-xs text-content-destructive">
Retention period must be at least 1 day.
</p>
)}
{isRetentionDaysNegative && (
<p className="m-0 text-xs text-content-destructive">
Retention days cannot be negative.
</p>
)}
{isRetentionDaysOverMax && (
<p className="m-0 text-xs text-content-destructive">
Must not exceed {retentionDaysMaximum} days (~10 years).
</p>
)}
<div className="flex justify-end">
<Button
size="sm"
type="submit"
disabled={
isSavingRetentionDays ||
!isRetentionDaysDirty ||
isRetentionDaysNegative ||
isRetentionDaysOverMax ||
isRetentionDaysZero
}
>
Save
</Button>
</div>
</>
)}
{isSaveRetentionDaysError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save retention setting.
</p>
)}
{isRetentionDaysLoadError && (
<p className="m-0 text-xs text-content-destructive">
Failed to load retention setting.
</p>
)}
</form>
</>
)}
<hr className="my-5 border-0 border-t border-solid border-border" />
@@ -182,6 +182,12 @@ const BehaviorRouteElement = () => {
onSaveWorkspaceTTL={fn()}
isSavingWorkspaceTTL={false}
isSaveWorkspaceTTLError={false}
retentionDaysData={{ retention_days: 30 }}
isRetentionDaysLoading={false}
isRetentionDaysLoadError={false}
onSaveRetentionDays={fn()}
isSavingRetentionDays={false}
isSaveRetentionDaysError={false}
onSaveThreshold={fn(async () => undefined)}
onResetThreshold={fn(async () => undefined)}
/>