From 32c5d0796fbf7de8cf667548208bb8193d72bb66 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Mon, 4 May 2026 13:17:48 -0700 Subject: [PATCH 01/15] feat(kiloclaw): scheduled-action notifications framework --- .../claw/components/ClawInstanceOverview.tsx | 6 + .../ClawOnboardingFakeWalkthrough.tsx | 1 + .../KiloClawScheduledActionBanner.tsx | 87 + .../withStatusQueryBoundary.test.ts | 1 + .../claw/new/ClawNewClient.state.test.ts | 1 + .../BulkChangeVersionDialog.tsx | 30 +- .../KiloclawInstanceDetail.tsx | 30 +- .../KiloclawSchedulerTab.tsx | 73 +- .../ScheduleNotifyFields.tsx | 158 + .../scheduled-action-side-effects/route.ts | 182 + .../emails/clawScheduledRestartCancelled.html | 138 + .../emails/clawScheduledRestartNotice.html | 157 + .../clawScheduledVersionChangeCancelled.html | 138 + .../clawScheduledVersionChangeNotice.html | 166 + apps/web/src/lib/email.ts | 7 + .../lib/kiloclaw/kiloclaw-internal-client.ts | 18 + .../src/lib/kiloclaw/scheduled-action-form.ts | 34 + apps/web/src/lib/kiloclaw/types.ts | 17 + .../admin-kiloclaw-instances-router.ts | 142 +- apps/web/src/routers/kiloclaw-router.ts | 62 +- .../organization-kiloclaw-router.ts | 6 + .../migrations/0110_strong_metal_master.sql | 13 + .../db/src/migrations/meta/0110_snapshot.json | 18704 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema-types.ts | 32 + packages/db/src/schema.ts | 54 + services/kiloclaw/src/index.ts | 21 + .../kiloclaw/src/notifications-binding.ts | 26 + services/kiloclaw/src/routes/platform.ts | 18 + .../src/scheduled/scheduled-action-notices.ts | 331 + services/kiloclaw/wrangler.jsonc | 7 + .../src/lib/notifications-service.ts | 39 + .../src/lib/scheduled-action-push.ts | 163 + 33 files changed, 20822 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/components/KiloClawScheduledActionBanner.tsx create mode 100644 apps/web/src/app/admin/components/KiloclawScheduler/ScheduleNotifyFields.tsx create mode 100644 apps/web/src/app/api/internal/kiloclaw/scheduled-action-side-effects/route.ts create mode 100644 apps/web/src/emails/clawScheduledRestartCancelled.html create mode 100644 apps/web/src/emails/clawScheduledRestartNotice.html create mode 100644 apps/web/src/emails/clawScheduledVersionChangeCancelled.html create mode 100644 apps/web/src/emails/clawScheduledVersionChangeNotice.html create mode 100644 packages/db/src/migrations/0110_strong_metal_master.sql create mode 100644 packages/db/src/migrations/meta/0110_snapshot.json create mode 100644 services/kiloclaw/src/scheduled/scheduled-action-notices.ts create mode 100644 services/notifications/src/lib/scheduled-action-push.ts 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 { + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); + } 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 9c478097fa..3d29c0b64b 100644 --- a/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts +++ b/apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts @@ -44,6 +44,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 e64aaa12db..1d5bdd21fb 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 @@ -42,6 +42,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..2f9ba676fd 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' @@ -3498,15 +3505,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 +3523,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 +3624,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..6780b0dbd3 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: () => { @@ -277,6 +302,12 @@ export function KiloclawSchedulerTab() { />
+
+
+
+ {runNoticeSweep.data && ( +

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

+ )} + {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" + /> +
+ +
+ +