Skip to content

Commit c1da624

Browse files
authored
Kiloclaw scheduled action notifications (#3038)
* feat(kiloclaw): scheduled-action notifications framework * fix(kiloclaw): scheduled-action notification review fixes * fix(kiloclaw): notification review fixes (per-row try/catch + index alignment + 422 on missing email) * fix(kiloclaw): notification review fixes (concurrency + race comment + collapse migrations) * fix(kiloclaw): notification review fixes (soft-fail 422 + tz consistency + push tests) * fix(kiloclaw): claim-before-dispatch + sweep tests + migration tidy * fix(kiloclaw): notification dispatch timeout + propagate recovered count * fix(kiloclaw): void pending notices on cancel + refresh stale Scheduler copy * fix(kiloclaw): close timing leak + survive orphaned user records * fix(kiloclaw): tighten secret compare + harden sweep boundaries * fix(claw): add missing scheduledAction:null to onboarding test fixture * fix(kiloclaw): honor notify settings + apply-state + mobile push payload * fix(kiloclaw): close apply-race in notice claim CAS * fix(kiloclaw): close cancellation race, finalize stale notices, scope subject override * fix(kiloclaw): couple cancellation creation to successful markSent
1 parent 62e195a commit c1da624

39 files changed

Lines changed: 22170 additions & 50 deletions

apps/mobile/src/lib/notifications.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function setActiveChatInstance(instanceId: string | null) {
2727
// Keep in sync with the `data` payloads emitted by:
2828
// - services/notifications/src/dos/NotificationChannelDO.ts (chat)
2929
// - services/notifications/src/lib/notifications-service.ts (instance-lifecycle)
30+
// - services/notifications/src/lib/scheduled-action-push.ts (scheduled-action)
3031
const notificationDataSchema = z.discriminatedUnion('type', [
3132
z.object({
3233
type: z.literal('chat'),
@@ -37,6 +38,16 @@ const notificationDataSchema = z.discriminatedUnion('type', [
3738
event: z.enum(['ready', 'start_failed']),
3839
instanceId: z.string().min(1),
3940
}),
41+
z.object({
42+
type: z.literal('scheduled-action'),
43+
event: z.enum([
44+
'scheduled_restart_notice',
45+
'scheduled_restart_cancelled',
46+
'scheduled_version_change_notice',
47+
'scheduled_version_change_cancelled',
48+
]),
49+
instanceId: z.string().min(1),
50+
}),
4051
]);
4152

4253
type NotificationData = z.infer<typeof notificationDataSchema>;

apps/web/src/app/(app)/claw/components/ClawInstanceOverview.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
99
import { Card, CardContent } from '@/components/ui/card';
1010
import { InstanceControls } from './InstanceControls';
1111
import { InstanceTab } from './InstanceTab';
12+
import { KiloClawScheduledActionBanner } from './KiloClawScheduledActionBanner';
1213
import { useClawContext } from './ClawContext';
1314

1415
export function ClawInstanceOverview({
@@ -63,6 +64,11 @@ export function ClawInstanceOverview({
6364
</Alert>
6465
)}
6566

67+
<KiloClawScheduledActionBanner
68+
scheduledAction={status.scheduledAction}
69+
instanceName={status.name}
70+
/>
71+
6672
<Card>
6773
<CardContent className="border-b p-5">
6874
<InstanceControls

apps/web/src/app/(app)/claw/components/ClawOnboardingFakeWalkthrough.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const fakeStatus = {
7272
instanceId: 'fake-instance',
7373
inboundEmailAddress: null,
7474
inboundEmailEnabled: false,
75+
scheduledAction: null,
7576
} satisfies PopulatedClawStatus;
7677

7778
export function ClawOnboardingFakeWalkthrough({

apps/web/src/app/(app)/claw/components/ClawOnboardingFlow.state.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ function createStatus(status: KiloClawDashboardStatus['status']): KiloClawDashbo
5252
instanceId: null,
5353
inboundEmailAddress: null,
5454
inboundEmailEnabled: false,
55+
scheduledAction: null,
5556
};
5657
}
5758

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use client';
2+
3+
/**
4+
* In-workspace banner that surfaces the soonest pending scheduled
5+
* admin action on this user's instance. Reads from the `scheduledAction`
6+
* field on `kiloclaw.getStatus`. The field is null when nothing is
7+
* pending, so the banner self-hides.
8+
*
9+
* Cancellation does NOT render here: once an action is cancelled the
10+
* `scheduledAction` field returns null and the banner disappears. Users
11+
* learn about the cancellation via email and mobile push (the
12+
* `cancelled`-kind notifications), which are dispatched only when a
13+
* notice was previously sent.
14+
*/
15+
16+
import { CalendarClock } from 'lucide-react';
17+
import { Alert, AlertDescription } from '@/components/ui/alert';
18+
import type { KiloClawScheduledActionStatusBlock } from '@/lib/kiloclaw/types';
19+
20+
type Props = {
21+
scheduledAction: KiloClawScheduledActionStatusBlock | null;
22+
/**
23+
* The user's name for the bot, when set. Renders as "Your bot
24+
* **<name>**". Null = use the generic "Your bot" phrasing (matches
25+
* the email's behavior when no name is set).
26+
*/
27+
instanceName: string | null;
28+
};
29+
30+
function formatScheduledAt(iso: string): string {
31+
// INTENTIONAL: this banner renders LOCAL time + the user's timezone
32+
// abbreviation (e.g., "5/4/2026, 6:55 PM PDT"). The email and push
33+
// surfaces render the same instant in UTC ("May 4, 2026, 6:55 PM
34+
// UTC") — see apps/web/src/app/api/internal/kiloclaw/
35+
// scheduled-action-side-effects/route.ts and
36+
// services/notifications/src/lib/scheduled-action-push.ts. The two
37+
// strings will not match character-for-character, but each labels
38+
// its zone explicitly, so a user comparing the banner to the email
39+
// knows they're seeing the same instant in two zones — the banner
40+
// is local-friendly, the email is portable across recipients in
41+
// different zones.
42+
//
43+
// This is a 'use client' component, so toLocaleString runs in the
44+
// user's browser, not on the server, and the runtime locale/zone
45+
// are stable per user.
46+
try {
47+
const d = new Date(iso);
48+
if (Number.isNaN(d.getTime())) return iso;
49+
const dateStr = d.toLocaleString();
50+
const tzPart = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
51+
.formatToParts(d)
52+
.find(p => p.type === 'timeZoneName')?.value;
53+
return tzPart ? `${dateStr} ${tzPart}` : dateStr;
54+
} catch {
55+
return iso;
56+
}
57+
}
58+
59+
export function KiloClawScheduledActionBanner({ scheduledAction, instanceName }: Props) {
60+
if (!scheduledAction) return null;
61+
62+
// Bake the period into the timestamp span so it doesn't wrap to its
63+
// own line when the column narrows. Same fix as the per-instance
64+
// admin indicator.
65+
const when = `${formatScheduledAt(scheduledAction.scheduledAt)}.`;
66+
const isVersionChange = scheduledAction.actionType === 'version_change';
67+
const targetLabel =
68+
isVersionChange && scheduledAction.targetImageTag
69+
? scheduledAction.targetOpenclawVersion
70+
? `${scheduledAction.targetImageTag} (OpenClaw ${scheduledAction.targetOpenclawVersion})`
71+
: scheduledAction.targetImageTag
72+
: null;
73+
const namedBot = instanceName?.trim() ? (
74+
<>
75+
Your bot <strong>{instanceName.trim()}</strong>
76+
</>
77+
) : (
78+
<>Your bot</>
79+
);
80+
81+
return (
82+
<Alert className="border-yellow-500/30 bg-yellow-500/5">
83+
<CalendarClock className="h-4 w-4 text-yellow-400" />
84+
<AlertDescription>
85+
{isVersionChange ? (
86+
<>
87+
{namedBot} is scheduled to upgrade
88+
{targetLabel ? (
89+
<>
90+
{' '}
91+
to <code className="font-mono text-xs">{targetLabel}</code>
92+
</>
93+
) : null}{' '}
94+
at <span className="font-mono">{when}</span> It will be briefly offline during the
95+
upgrade.
96+
</>
97+
) : (
98+
<>
99+
{namedBot} is scheduled to restart at <span className="font-mono">{when}</span> It will
100+
be briefly offline during the restart.
101+
</>
102+
)}
103+
</AlertDescription>
104+
</Alert>
105+
);
106+
}

apps/web/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const baseStatus: KiloClawDashboardStatus = {
4545
instanceId: null,
4646
inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai',
4747
inboundEmailEnabled: true,
48+
scheduledAction: null,
4849
};
4950

5051
describe('withStatusQueryBoundary', () => {

apps/web/src/app/(app)/claw/new/ClawNewClient.state.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const baseStatus: KiloClawDashboardStatus = {
4343
instanceId: 'instance-1',
4444
inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai',
4545
inboundEmailEnabled: true,
46+
scheduledAction: null,
4647
};
4748

4849
function createStatus(instanceId: string | null = 'instance-1'): KiloClawDashboardStatus {

apps/web/src/app/admin/components/KiloclawInstances/BulkChangeVersionDialog.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ import { toast } from 'sonner';
2929
import type { inferRouterOutputs } from '@trpc/server';
3030
import type { RootRouter } from '@/routers/root-router';
3131
import type { AdminKiloclawInstance } from '@/routers/admin-kiloclaw-instances-router';
32-
import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form';
32+
import {
33+
defaultScheduledAt,
34+
defaultNotifyFormState,
35+
type NotifyFormState,
36+
} from '@/lib/kiloclaw/scheduled-action-form';
37+
import { ScheduleNotifyFields } from '../KiloclawScheduler/ScheduleNotifyFields';
3338

3439
type RouterOutputs = inferRouterOutputs<RootRouter>;
3540
type ListVersionsItem = RouterOutputs['admin']['kiloclawVersions']['listVersions']['items'][number];
@@ -74,6 +79,7 @@ export function BulkChangeVersionDialog({
7479
const [failedSectionOpen, setFailedSectionOpen] = useState(true);
7580
const [mode, setMode] = useState<'now' | 'scheduled'>('now');
7681
const [scheduledAt, setScheduledAt] = useState<string>(defaultScheduledAt);
82+
const [notify, setNotify] = useState<NotifyFormState>(defaultNotifyFormState);
7783

7884
// Reset form whenever the dialog reopens. Keeps state from leaking
7985
// between independent admin actions.
@@ -84,6 +90,7 @@ export function BulkChangeVersionDialog({
8490
setConfirmInput('');
8591
setResult(null);
8692
setMode('now');
93+
setNotify(defaultNotifyFormState());
8794
}
8895
}, [open]);
8996

@@ -220,6 +227,11 @@ export function BulkChangeVersionDialog({
220227
imageTag: targetTag,
221228
overridePins,
222229
scheduledAt: local.toISOString(),
230+
notify: notify.notify,
231+
noticeLeadHours: notify.noticeLeadHours,
232+
noticeSubject: notify.noticeSubject,
233+
noticeBody: notify.noticeBody,
234+
noticeChannels: notify.noticeChannels,
223235
});
224236
};
225237

@@ -280,15 +292,7 @@ export function BulkChangeVersionDialog({
280292
</AlertDescription>
281293
</Alert>
282294
</TabsContent>
283-
<TabsContent value="scheduled" className="mt-3 space-y-2">
284-
<Alert>
285-
<AlertTriangle className="h-4 w-4" />
286-
<AlertDescription>
287-
Notifications aren't implemented yet — end users get no warning before their
288-
session is interrupted at the scheduled time. Use cautiously on customer
289-
instances until the notifications work lands.
290-
</AlertDescription>
291-
</Alert>
295+
<TabsContent value="scheduled" className="mt-3 space-y-3">
292296
<div className="space-y-2">
293297
<Label htmlFor="bulk-scheduled-at">Scheduled at (local time)</Label>
294298
<Input
@@ -308,6 +312,12 @@ export function BulkChangeVersionDialog({
308312
Per-instance outcome (applied / skipped / failed) shows up in the Scheduler tab as
309313
the action progresses.
310314
</p>
315+
<ScheduleNotifyFields
316+
idPrefix="bulk"
317+
state={notify}
318+
onChange={setNotify}
319+
disabled={isPending}
320+
/>
311321
</TabsContent>
312322
</Tabs>
313323

apps/web/src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ import {
7878
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
7979
import { toast } from 'sonner';
8080
import { toastPinMutationResult } from '@/lib/kiloclaw/pin-sync-toast';
81-
import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form';
81+
import {
82+
defaultScheduledAt,
83+
defaultNotifyFormState,
84+
type NotifyFormState,
85+
} from '@/lib/kiloclaw/scheduled-action-form';
86+
import { ScheduleNotifyFields } from '../KiloclawScheduler/ScheduleNotifyFields';
8287
import { AdminFileEditor } from './AdminFileEditor';
8388
import { KiloCliRunCard } from './KiloCliRunCard';
8489
import { BumpVolumeTo15GbButton } from './BumpVolumeTo15GbDialog';
@@ -1261,6 +1266,8 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
12611266
const [changeVersionMode, setChangeVersionMode] = useState<'now' | 'scheduled'>('now');
12621267
const [changeVersionScheduledAt, setChangeVersionScheduledAt] =
12631268
useState<string>(defaultScheduledAt);
1269+
const [changeVersionNotify, setChangeVersionNotify] =
1270+
useState<NotifyFormState>(defaultNotifyFormState);
12641271
const [upgradeLatestConfirmOpen, setUpgradeLatestConfirmOpen] = useState(false);
12651272
const [resizePhase, setResizePhase] = useState<
12661273
'idle' | 'stopping' | 'resizing' | 'starting' | 'waiting' | 'done' | 'error'
@@ -3432,6 +3439,7 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
34323439
if (!open) {
34333440
setChangeVersionSelectedTag('');
34343441
setChangeVersionMode('now');
3442+
setChangeVersionNotify(defaultNotifyFormState());
34353443
}
34363444
}}
34373445
>
@@ -3498,15 +3506,7 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
34983506
<TabsContent value="now" className="text-muted-foreground mt-3 text-xs">
34993507
Applies immediately. End-user session is interrupted with no notice.
35003508
</TabsContent>
3501-
<TabsContent value="scheduled" className="mt-3 space-y-2">
3502-
<Alert>
3503-
<AlertTriangle className="h-4 w-4" />
3504-
<AlertDescription>
3505-
Notifications aren't implemented yet — the end user gets no warning before
3506-
their session is interrupted at the scheduled time. Use cautiously on customer
3507-
instances until the notifications work lands.
3508-
</AlertDescription>
3509-
</Alert>
3509+
<TabsContent value="scheduled" className="mt-3 space-y-3">
35103510
<label htmlFor="change-version-scheduled-at" className="text-sm font-medium">
35113511
Scheduled at (local time)
35123512
</label>
@@ -3524,6 +3524,12 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
35243524
Fires on the next instance reconcile alarm tick after this time (cadence ~5
35253525
minutes for running instances). Treat as a "no earlier than" bound.
35263526
</p>
3527+
<ScheduleNotifyFields
3528+
idPrefix="change-version"
3529+
state={changeVersionNotify}
3530+
onChange={setChangeVersionNotify}
3531+
disabled={isSchedulingVersionChange}
3532+
/>
35273533
</TabsContent>
35283534
</Tabs>
35293535

@@ -3619,6 +3625,11 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
36193625
imageTag: changeVersionSelectedTag,
36203626
overridePins: !!changeVersionPinData,
36213627
scheduledAt: local.toISOString(),
3628+
notify: changeVersionNotify.notify,
3629+
noticeLeadHours: changeVersionNotify.noticeLeadHours,
3630+
noticeSubject: changeVersionNotify.noticeSubject,
3631+
noticeBody: changeVersionNotify.noticeBody,
3632+
noticeChannels: changeVersionNotify.noticeChannels,
36223633
});
36233634
}}
36243635
disabled={

0 commit comments

Comments
 (0)