diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index e524feedae..d2f7cbbed0 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -27,6 +27,7 @@ export function setActiveChatInstance(instanceId: string | null) { // Keep in sync with the `data` payloads emitted by: // - services/notifications/src/dos/NotificationChannelDO.ts (chat) // - services/notifications/src/lib/notifications-service.ts (instance-lifecycle) +// - services/notifications/src/lib/scheduled-action-push.ts (scheduled-action) const notificationDataSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('chat'), @@ -37,6 +38,16 @@ const notificationDataSchema = z.discriminatedUnion('type', [ event: z.enum(['ready', 'start_failed']), instanceId: z.string().min(1), }), + z.object({ + type: z.literal('scheduled-action'), + event: z.enum([ + 'scheduled_restart_notice', + 'scheduled_restart_cancelled', + 'scheduled_version_change_notice', + 'scheduled_version_change_cancelled', + ]), + instanceId: z.string().min(1), + }), ]); type NotificationData = z.infer; diff --git a/apps/web/src/app/(app)/claw/components/ClawInstanceOverview.tsx b/apps/web/src/app/(app)/claw/components/ClawInstanceOverview.tsx index 19786e036e..98094f2083 100644 --- a/apps/web/src/app/(app)/claw/components/ClawInstanceOverview.tsx +++ b/apps/web/src/app/(app)/claw/components/ClawInstanceOverview.tsx @@ -9,6 +9,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { Card, CardContent } from '@/components/ui/card'; import { InstanceControls } from './InstanceControls'; import { InstanceTab } from './InstanceTab'; +import { KiloClawScheduledActionBanner } from './KiloClawScheduledActionBanner'; import { useClawContext } from './ClawContext'; export function ClawInstanceOverview({ @@ -63,6 +64,11 @@ export function ClawInstanceOverview({ )} + + **". Null = use the generic "Your bot" phrasing (matches + * the email's behavior when no name is set). + */ + instanceName: string | null; +}; + +function formatScheduledAt(iso: string): string { + // INTENTIONAL: this banner renders LOCAL time + the user's timezone + // abbreviation (e.g., "5/4/2026, 6:55 PM PDT"). The email and push + // surfaces render the same instant in UTC ("May 4, 2026, 6:55 PM + // UTC") — see apps/web/src/app/api/internal/kiloclaw/ + // scheduled-action-side-effects/route.ts and + // services/notifications/src/lib/scheduled-action-push.ts. The two + // strings will not match character-for-character, but each labels + // its zone explicitly, so a user comparing the banner to the email + // knows they're seeing the same instant in two zones — the banner + // is local-friendly, the email is portable across recipients in + // different zones. + // + // This is a 'use client' component, so toLocaleString runs in the + // user's browser, not on the server, and the runtime locale/zone + // are stable per user. + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const dateStr = d.toLocaleString(); + const tzPart = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }) + .formatToParts(d) + .find(p => p.type === 'timeZoneName')?.value; + return tzPart ? `${dateStr} ${tzPart}` : dateStr; + } catch { + return iso; + } +} + +export function KiloClawScheduledActionBanner({ scheduledAction, instanceName }: Props) { + if (!scheduledAction) return null; + + // Bake the period into the timestamp span so it doesn't wrap to its + // own line when the column narrows. Same fix as the per-instance + // admin indicator. + const when = `${formatScheduledAt(scheduledAction.scheduledAt)}.`; + const isVersionChange = scheduledAction.actionType === 'version_change'; + const targetLabel = + isVersionChange && scheduledAction.targetImageTag + ? scheduledAction.targetOpenclawVersion + ? `${scheduledAction.targetImageTag} (OpenClaw ${scheduledAction.targetOpenclawVersion})` + : scheduledAction.targetImageTag + : null; + const namedBot = instanceName?.trim() ? ( + <> + Your bot {instanceName.trim()} + + ) : ( + <>Your bot + ); + + return ( + + + + {isVersionChange ? ( + <> + {namedBot} is scheduled to upgrade + {targetLabel ? ( + <> + {' '} + to {targetLabel} + + ) : null}{' '} + at {when} It will be briefly offline during the + upgrade. + + ) : ( + <> + {namedBot} is scheduled to restart at {when} It will + be briefly offline during the restart. + + )} + + + ); +} diff --git a/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts b/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts index ebc7df3245..24ac7f3149 100644 --- a/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts +++ b/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts @@ -45,6 +45,7 @@ const baseStatus: KiloClawDashboardStatus = { instanceId: null, inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai', inboundEmailEnabled: true, + scheduledAction: null, }; describe('withStatusQueryBoundary', () => { diff --git a/apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts b/apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts index 7d7316a41c..812e87ddcb 100644 --- a/apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts +++ b/apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts @@ -43,6 +43,7 @@ const baseStatus: KiloClawDashboardStatus = { instanceId: 'instance-1', inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai', inboundEmailEnabled: true, + scheduledAction: null, }; function createStatus(instanceId: string | null = 'instance-1'): KiloClawDashboardStatus { diff --git a/apps/web/src/app/admin/components/KiloclawInstances/BulkChangeVersionDialog.tsx b/apps/web/src/app/admin/components/KiloclawInstances/BulkChangeVersionDialog.tsx index f8d2005806..992bcc530e 100644 --- a/apps/web/src/app/admin/components/KiloclawInstances/BulkChangeVersionDialog.tsx +++ b/apps/web/src/app/admin/components/KiloclawInstances/BulkChangeVersionDialog.tsx @@ -29,7 +29,12 @@ import { toast } from 'sonner'; import type { inferRouterOutputs } from '@trpc/server'; import type { RootRouter } from '@/routers/root-router'; import type { AdminKiloclawInstance } from '@/routers/admin-kiloclaw-instances-router'; -import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form'; +import { + defaultScheduledAt, + defaultNotifyFormState, + type NotifyFormState, +} from '@/lib/kiloclaw/scheduled-action-form'; +import { ScheduleNotifyFields } from '../KiloclawScheduler/ScheduleNotifyFields'; type RouterOutputs = inferRouterOutputs; type ListVersionsItem = RouterOutputs['admin']['kiloclawVersions']['listVersions']['items'][number]; @@ -74,6 +79,7 @@ export function BulkChangeVersionDialog({ const [failedSectionOpen, setFailedSectionOpen] = useState(true); const [mode, setMode] = useState<'now' | 'scheduled'>('now'); const [scheduledAt, setScheduledAt] = useState(defaultScheduledAt); + const [notify, setNotify] = useState(defaultNotifyFormState); // Reset form whenever the dialog reopens. Keeps state from leaking // between independent admin actions. @@ -84,6 +90,7 @@ export function BulkChangeVersionDialog({ setConfirmInput(''); setResult(null); setMode('now'); + setNotify(defaultNotifyFormState()); } }, [open]); @@ -220,6 +227,11 @@ export function BulkChangeVersionDialog({ imageTag: targetTag, overridePins, scheduledAt: local.toISOString(), + notify: notify.notify, + noticeLeadHours: notify.noticeLeadHours, + noticeSubject: notify.noticeSubject, + noticeBody: notify.noticeBody, + noticeChannels: notify.noticeChannels, }); }; @@ -280,15 +292,7 @@ export function BulkChangeVersionDialog({ - - - - - Notifications aren't implemented yet — end users get no warning before their - session is interrupted at the scheduled time. Use cautiously on customer - instances until the notifications work lands. - - +
+ diff --git a/apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx b/apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx index a1e84a25c5..0625ebf1b5 100644 --- a/apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx +++ b/apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx @@ -78,7 +78,12 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; import { toastPinMutationResult } from '@/lib/kiloclaw/pin-sync-toast'; -import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form'; +import { + defaultScheduledAt, + defaultNotifyFormState, + type NotifyFormState, +} from '@/lib/kiloclaw/scheduled-action-form'; +import { ScheduleNotifyFields } from '../KiloclawScheduler/ScheduleNotifyFields'; import { AdminFileEditor } from './AdminFileEditor'; import { KiloCliRunCard } from './KiloCliRunCard'; import { BumpVolumeTo15GbButton } from './BumpVolumeTo15GbDialog'; @@ -1261,6 +1266,8 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) { const [changeVersionMode, setChangeVersionMode] = useState<'now' | 'scheduled'>('now'); const [changeVersionScheduledAt, setChangeVersionScheduledAt] = useState(defaultScheduledAt); + const [changeVersionNotify, setChangeVersionNotify] = + useState(defaultNotifyFormState); const [upgradeLatestConfirmOpen, setUpgradeLatestConfirmOpen] = useState(false); const [resizePhase, setResizePhase] = useState< 'idle' | 'stopping' | 'resizing' | 'starting' | 'waiting' | 'done' | 'error' @@ -3432,6 +3439,7 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) { if (!open) { setChangeVersionSelectedTag(''); setChangeVersionMode('now'); + setChangeVersionNotify(defaultNotifyFormState()); } }} > @@ -3498,15 +3506,7 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) { Applies immediately. End-user session is interrupted with no notice. - - - - - Notifications aren't implemented yet — the end user gets no warning before - their session is interrupted at the scheduled time. Use cautiously on customer - instances until the notifications work lands. - - + @@ -3524,6 +3524,12 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) { Fires on the next instance reconcile alarm tick after this time (cadence ~5 minutes for running instances). Treat as a "no earlier than" bound.

+
@@ -3619,6 +3625,11 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) { imageTag: changeVersionSelectedTag, overridePins: !!changeVersionPinData, scheduledAt: local.toISOString(), + notify: changeVersionNotify.notify, + noticeLeadHours: changeVersionNotify.noticeLeadHours, + noticeSubject: changeVersionNotify.noticeSubject, + noticeBody: changeVersionNotify.noticeBody, + noticeChannels: changeVersionNotify.noticeChannels, }); }} disabled={ diff --git a/apps/web/src/app/admin/components/KiloclawScheduler/KiloclawSchedulerTab.tsx b/apps/web/src/app/admin/components/KiloclawScheduler/KiloclawSchedulerTab.tsx index 172ee2cf49..6f27a06307 100644 --- a/apps/web/src/app/admin/components/KiloclawScheduler/KiloclawSchedulerTab.tsx +++ b/apps/web/src/app/admin/components/KiloclawScheduler/KiloclawSchedulerTab.tsx @@ -49,7 +49,12 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { formatRelativeTime } from '../KiloclawInstances/shared'; -import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form'; +import { + defaultScheduledAt, + defaultNotifyFormState, + type NotifyFormState, +} from '@/lib/kiloclaw/scheduled-action-form'; +import { ScheduleNotifyFields } from './ScheduleNotifyFields'; // Per design.md: every status badge is `bg-{color}-500/20 text-{color}-400 // ring-1 ring-{color}-500/20`. Color assignments are fixed by domain — @@ -74,6 +79,7 @@ export function KiloclawSchedulerTab() { const [restartInstanceId, setRestartInstanceId] = useState(''); const [restartScheduledAt, setRestartScheduledAt] = useState(defaultScheduledAt); const [restartReason, setRestartReason] = useState(''); + const [restartNotify, setRestartNotify] = useState(defaultNotifyFormState); // Version-change form state const [vcInstanceId, setVcInstanceId] = useState(''); @@ -81,6 +87,7 @@ export function KiloclawSchedulerTab() { const [vcOverridePins, setVcOverridePins] = useState(false); const [vcScheduledAt, setVcScheduledAt] = useState(defaultScheduledAt); const [vcReason, setVcReason] = useState(''); + const [vcNotify, setVcNotify] = useState(defaultNotifyFormState); // Client-side sort over the current page of listScheduledActions. // The list is paginated server-side (limit 50) and ordered by @@ -174,6 +181,14 @@ export function KiloclawSchedulerTab() { }) ); + // Manual notice-sweep trigger. Calls the kiloclaw worker route that + // synchronously runs runScheduledActionNoticesSweep. Used to verify + // notice copy in dev (where the cron does not fire) and on demand + // in production after creating a test schedule. + const runNoticeSweep = useMutation( + trpc.admin.kiloclawInstances.runNoticeSweepNow.mutationOptions() + ); + const onSubmitRestart = (e: React.FormEvent) => { e.preventDefault(); // Convert datetime-local (no zone) to ISO with the user's local zone @@ -186,6 +201,11 @@ export function KiloclawSchedulerTab() { instanceIds: [restartInstanceId.trim()], scheduledAt: local.toISOString(), reason: restartReason.trim() || undefined, + notify: restartNotify.notify, + noticeLeadHours: restartNotify.noticeLeadHours, + noticeSubject: restartNotify.noticeSubject, + noticeBody: restartNotify.noticeBody, + noticeChannels: restartNotify.noticeChannels, }, { onSuccess: () => { @@ -206,6 +226,11 @@ export function KiloclawSchedulerTab() { overridePins: vcOverridePins, scheduledAt: local.toISOString(), reason: vcReason.trim() || undefined, + notify: vcNotify.notify, + noticeLeadHours: vcNotify.noticeLeadHours, + noticeSubject: vcNotify.noticeSubject, + noticeBody: vcNotify.noticeBody, + noticeChannels: vcNotify.noticeChannels, }, { onSuccess: () => { @@ -232,9 +257,9 @@ export function KiloclawSchedulerTab() { hibernated). Treat the chosen time as a "no earlier than" bound, not an exact fire time.
- Notifications: not implemented yet. End users get no warning before - their session is interrupted at the scheduled time. Use cautiously on customer instances - until the notifications work lands. + Notifications: email, in-app banner, and mobile push are dispatched by + the notice sweep at a 1-minute cadence. Configure or disable per schedule using the + "Notify users" controls below.
@@ -277,6 +302,12 @@ export function KiloclawSchedulerTab() { /> +
+
+
+ {runNoticeSweep.data && ( +

+ Last run: processed={runNoticeSweep.data.processed}, sent= + {runNoticeSweep.data.sent}, failed={runNoticeSweep.data.failed}, recovered= + {runNoticeSweep.data.recovered}, voidedStale={runNoticeSweep.data.voidedStale} +

+ )} + {runNoticeSweep.error && ( +

+ {runNoticeSweep.error instanceof Error + ? runNoticeSweep.error.message + : 'Unknown error'} +

+ )}
diff --git a/apps/web/src/app/admin/components/KiloclawScheduler/ScheduleNotifyFields.tsx b/apps/web/src/app/admin/components/KiloclawScheduler/ScheduleNotifyFields.tsx new file mode 100644 index 0000000000..df9a09230c --- /dev/null +++ b/apps/web/src/app/admin/components/KiloclawScheduler/ScheduleNotifyFields.tsx @@ -0,0 +1,158 @@ +'use client'; + +/** + * Inline notification controls for the scheduled-action create dialogs. + * + * Rendered on the Scheduled tab of the per-instance Change Version + * dialog, the bulk Change Version dialog, and the Scheduler tab forms. + * The fields are visible by default — the underlying behavior is to + * notify, and admins should see exactly what users will receive before + * clicking Schedule. + * + * Surfaces: + * - notify checkbox (default ON) + * - lead-hours numeric input (range 0..168, default 24) + * - channel checkboxes (all selected by default) + * - optional admin-authored subject + body + * + * When notify is unchecked the dependent fields visually fade and stop + * mattering — the backend ignores them when notify=false. + */ + +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + NOTICE_CHANNELS, + NOTICE_CHANNEL_LABELS, + type NoticeChannel, + type NotifyFormState, +} from '@/lib/kiloclaw/scheduled-action-form'; + +type Props = { + /** Unique prefix so multiple instances on the same page have stable ids. */ + idPrefix: string; + state: NotifyFormState; + onChange: (next: NotifyFormState) => void; + disabled?: boolean; +}; + +export function ScheduleNotifyFields({ idPrefix, state, onChange, disabled }: Props) { + const notifyId = `${idPrefix}-notify`; + const leadId = `${idPrefix}-notice-lead-hours`; + const subjectId = `${idPrefix}-notice-subject`; + const bodyId = `${idPrefix}-notice-body`; + const dim = !state.notify; + + const toggleChannel = (channel: NoticeChannel, checked: boolean) => { + const next = checked + ? Array.from(new Set([...state.noticeChannels, channel])) + : state.noticeChannels.filter(c => c !== channel); + // Block deselecting the last channel; the backend rejects an empty + // array and admins can always uncheck `notify` to disable instead. + if (next.length === 0) return; + onChange({ ...state, noticeChannels: next }); + }; + + return ( +
+
+ onChange({ ...state, notify: checked === true })} + disabled={disabled} + /> +
+ +

+ Sends a heads-up before the action fires. Recommended for any customer instance. Uncheck + for internal/dev instances with no real end user. +

+
+
+ +
+
+
+ + + onChange({ + ...state, + noticeLeadHours: Math.max(0, Math.min(168, Number(e.target.value) || 0)), + }) + } + disabled={disabled || dim} + /> +

+ Range 0–168. Notice fires when now() reaches scheduled time minus this many hours. +

+
+ +
+ +
+ {NOTICE_CHANNELS.map(channel => { + const checked = state.noticeChannels.includes(channel); + return ( + + ); + })} +
+
+
+ +
+ + onChange({ ...state, noticeSubject: e.target.value })} + disabled={disabled || dim} + maxLength={120} + placeholder="Leave blank to use default subject" + /> +
+ +
+ +