diff --git a/client/src/components/SidePanel/Schedules/SchedulePanel.tsx b/client/src/components/SidePanel/Schedules/SchedulePanel.tsx index 7f9e5b3d5f..6abbde2b63 100644 --- a/client/src/components/SidePanel/Schedules/SchedulePanel.tsx +++ b/client/src/components/SidePanel/Schedules/SchedulePanel.tsx @@ -9,7 +9,7 @@ import ScheduleCard from './ScheduleCard'; export default function SchedulePanel() { const localize = useLocalize(); - const { data, isLoading } = useSchedulesQuery(); + const { data, isLoading, isError, refetch } = useSchedulesQuery(); const [createOpen, setCreateOpen] = useState(false); const hasCreateAccess = useHasAccess({ @@ -25,6 +25,23 @@ export default function SchedulePanel() { ); } + // A failed query leaves `data` undefined, which the empty-state branch below would + // render as "no scheduled chats yet" — telling the user their schedules are gone when + // the request merely failed. `maxPerUser` is unknown too, so the create button would + // stay enabled and any create would 4xx against a limit we cannot see. + if (isError) { + return ( +
+

+ {localize('com_ui_schedules_error')} +

+ +
+ ); + } + const schedules = data?.schedules ?? []; const maxPerUser = data?.limits.maxPerUser; const atLimit = maxPerUser !== undefined && schedules.length >= maxPerUser; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index ec381595aa..4a189c5b9d 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1707,6 +1707,7 @@ "com_ui_schedule_weekdays": "Weekdays", "com_ui_schedule_weekly": "Weekly", "com_ui_schedules": "Scheduled chats", + "com_ui_schedules_error": "Couldn't load your scheduled chats", "com_ui_schedules_empty": "No scheduled chats yet", "com_ui_schedules_used": "{{used}} of {{max}} schedules used", "com_ui_schema": "Schema", diff --git a/config/delete-user.js b/config/delete-user.js index d7ae20e410..d8ae4e2e23 100644 --- a/config/delete-user.js +++ b/config/delete-user.js @@ -102,6 +102,17 @@ async function gracefulExit(code = 0) { AclEntry.deleteMany({ principalId: user._id }), ]; + // Raise the durable deletion barrier BEFORE counting. A bare count is a + // time-of-check/time-of-use read: a fire can be claimed and accepted between the zero + // result and the deletes below, and this script cannot abort or drain it. The barrier + // is what makes the count meaningful — a live server refuses new fires at the dispatch + // boundary (fireSchedule's isOwnerDeleting probe) from this point on, so anything the + // count then misses cannot have started after it. + await User.updateOne( + { _id: uid, deletionRequestedAt: { $exists: false } }, + { $set: { deletionRequestedAt: new Date() } }, + ); + // REFUSE rather than warn when a scheduled run is in flight. This script talks to the // database directly, so unlike the HTTP deletion paths it cannot abort a live loopback // generation or wait for it to drain. That generation can already have passed its diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 798118e54c..a637452f24 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -3198,10 +3198,15 @@ class GenerationJobManagerClass { } catch (err) { logger.warn(`[GenerationJobManager] Failed to read approval before expiry ${streamId}`, err); } + // Forward the job we ALREADY read. Without it the expiry re-reads the store, which + // is not just a wasted round trip: the preserve decision keys on `scheduleId`, so a + // second read that returns null (or a replacement) would drop a scheduled run's + // retained evidence — the opposite of what the first read established. const expiredCreatedAt = await this._approvals.expireWithIdentity( streamId, actionId, observedJob?.createdAt, + observedJob, ); if (expiredCreatedAt == null) { return false;