Skip to content

Commit e1e3e55

Browse files
authored
feat(kiloclaw): deliver morning briefing to configured channels (#2813)
* feat(kiloclaw): deliver Morning Briefing to configured channels Send generated briefings to configured Telegram, Discord, and Slack routes with channel-friendly formatting and delivery status reporting so users can receive briefings where they work. Add robust routing, timeout/retry hardening, and run-path warmup handling so generation success is decoupled from delivery flakiness. * refactor(kiloclaw): consolidate morning briefing delivery internals Split command and channel-delivery concerns into dedicated modules, tighten timeout retry semantics, and reuse shared UI typing to reduce schema drift. Add cron JSON compatibility fallback to avoid controller/runtime option skew. * fix(kiloclaw): avoid exposing delivery payloads in UI errors Render morning briefing delivery failures with reason-only text in Settings so stored command errors remain available for diagnostics without leaking message content in the dashboard. * fix(kiloclaw): stabilize morning briefing warmup transitions Treat gateway and briefing readiness as boot-session fresh data to avoid stale Disabled flaps, and clear cached gateway/morning-briefing queries on start, provision, and restarts so controls remain in warmup state until current boot data arrives. * fix(kiloclaw): remove warmup disabled-state flap Stop emitting synthetic enabled=false during morning-briefing warmup and treat gateway_warming_up as authoritative in the dashboard card. Keep warmup badge styling and delivery visibility gated until status fields are resolved to prevent transient Disabled and Last delivery flaps. * fix(kiloclaw): polish morning briefing status metadata UI Move Last delivery beneath Last generated, render channel/status labels with user-friendly capitalization, and top-align action buttons so the card layout stays consistent as metadata lines appear. * fix(kiloclaw): align morning briefing metadata layout Add a topical Morning Briefing icon, keep delivery labels user-friendly and capitalized, and place the source summary in the same content column so metadata lines share a consistent leading edge. * fix(kiloclaw): format last delivery provider labels Render Morning Briefing Last delivery entries as provider-first labels with status in parentheses while keeping the bullet delimiter for readability. * fix(kiloclaw): simplify morning briefing failures section Rename the report heading to Failures and keep the section omitted when no failures exist so the daily briefing body stays concise and focused. * style(web): apply formatting for morning briefing UI * fix(kiloclaw): preserve links with parentheses in delivery text Replace regex-only markdown link expansion with a balanced-parentheses parser so channel messages keep full URLs when links contain nested parentheses. * fix(kiloclaw): stop retrying run requests on timeout Use endpoint-specific warmup retry policy so morning-briefing run no longer retries timeout errors that can overlap in-flight runs and duplicate sends. Return a dedicated run-timeout response code for clients. * test(web): lock warmup state against disabled flap Extract morning briefing card state derivation and add regression coverage for stale enabled values plus gateway_warming_up payloads so the card stays in warmup state instead of flashing Disabled. * fix(kiloclaw): sanitize stored morning briefing delivery errors Store concise delivery failure details instead of full command text to reduce sensitive payload exposure while preserving operator diagnostics. Update lifecycle assertions to match sanitized error persistence. * fix(kiloclaw): tighten briefing run timeout and delivery observability Rename timeout test semantics, add focused delivery-utils unit coverage, and emit structured delivery outcome events for sent/skipped/failed paths. Include latest Morning Briefing warmup/source-summary alignment tweak in the same push-ready set. * refactor(kiloclaw): share morning briefing delivery constants Extract delivery channel/status/reason enums into a shared module and reuse them in gateway response schemas and plugin delivery utilities to prevent drift. * Revert "refactor(kiloclaw): share morning briefing delivery constants" This reverts commit 384cc0d. * refactor(kiloclaw): dedupe delivery enums via shared constants Define morning briefing delivery channel/status/reason enums once and reuse them in both plugin delivery logic and gateway controller response schemas to prevent drift. * fix(kiloclaw): import delivery channels from constants module Resolve plugin pack build failure by importing DELIVERY_CHANNELS directly from delivery-constants instead of delivery-utils, keeping a single source for delivery enum declarations.
1 parent f81c552 commit e1e3e55

19 files changed

Lines changed: 1757 additions & 117 deletions

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

Lines changed: 110 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,23 @@ import {
88
FileCode,
99
Hash,
1010
Info,
11+
Newspaper,
1112
RotateCcw,
1213
Save,
1314
Settings,
1415
ShieldCheck,
1516
Square,
1617
X,
1718
} from 'lucide-react';
18-
import { useEffect, useMemo, useState } from 'react';
19+
import { useEffect, useMemo, useRef, useState } from 'react';
1920
import { OpenclawImportCard } from './OpenclawImportCard';
2021

2122
import { usePostHog } from 'posthog-js/react';
2223
import { toast } from 'sonner';
2324
import { useModelSelectorList } from '@/app/api/openrouter/hooks';
2425
import { useUser } from '@/hooks/useUser';
2526
import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox';
26-
import type { KiloClawDashboardStatus } from '@/lib/kiloclaw/types';
27+
import type { KiloClawDashboardStatus, MorningBriefingStatusLite } from '@/lib/kiloclaw/types';
2728
import { calverAtLeast, cleanVersion } from '@/lib/kiloclaw/version';
2829
import type { useKiloClawMutations } from '@/hooks/useKiloClaw';
2930
import {
@@ -66,6 +67,7 @@ import { Switch } from '@/components/ui/switch';
6667
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
6768
import { DetailTile } from './DetailTile';
6869
import { EMBEDDING_MODELS, DEFAULT_EMBEDDING_MODEL } from './embeddingModels';
70+
import { deriveMorningBriefingCardState } from './morning-briefing-card-state';
6971

7072
import { getEntriesByCategory } from '@kilocode/kiloclaw-secret-catalog';
7173
import { SecretEntrySection } from './SecretEntrySection';
@@ -500,24 +502,7 @@ function MorningBriefingCard({
500502
actionsReady,
501503
}: {
502504
mutations: ClawMutations;
503-
briefingStatus:
504-
| {
505-
enabled?: boolean;
506-
desiredEnabled?: boolean;
507-
observedEnabled?: boolean | null;
508-
reconcileState?: 'idle' | 'in_progress' | 'succeeded' | 'failed';
509-
lastReconcileAction?: 'enable' | 'disable' | null;
510-
code?: string;
511-
cron?: string;
512-
timezone?: string;
513-
lastGeneratedDate?: string | null;
514-
sourceReadiness?: {
515-
github: { configured: boolean; summary: string };
516-
linear: { configured: boolean; summary: string };
517-
web: { configured: boolean; summary: string };
518-
};
519-
}
520-
| undefined;
505+
briefingStatus: MorningBriefingStatusLite | undefined;
521506
fallbackReadiness: {
522507
githubConfigured: boolean;
523508
linearConfigured: boolean;
@@ -553,11 +538,14 @@ function MorningBriefingCard({
553538
} as const);
554539

555540
const hasSchedule = Boolean(briefingStatus?.cron && briefingStatus?.timezone);
556-
const desiredEnabled = briefingStatus?.desiredEnabled ?? briefingStatus?.enabled ?? false;
557-
const observedEnabled = briefingStatus?.observedEnabled ?? briefingStatus?.enabled ?? false;
541+
const { desiredEnabled, observedEnabled, hasResolvedBriefingToggleState, isWarmupState } =
542+
deriveMorningBriefingCardState({
543+
isRunning,
544+
actionsReady,
545+
briefingStatus,
546+
});
558547
const reconcileState = briefingStatus?.reconcileState ?? 'idle';
559548
const lastReconcileAction = briefingStatus?.lastReconcileAction ?? null;
560-
const isWarmupState = isRunning && actionsReady === false;
561549
const isTransitioning =
562550
reconcileState === 'in_progress' ||
563551
mutations.enableMorningBriefing.isPending ||
@@ -596,8 +584,9 @@ function MorningBriefingCard({
596584

597585
return observedEnabled ? 'Enabled' : 'Disabled';
598586
})();
599-
const statusVariant =
600-
statusLabel === 'Instance Stopped'
587+
const statusVariant = isWarmupState
588+
? 'secondary'
589+
: statusLabel === 'Instance Stopped'
601590
? 'secondary'
602591
: observedEnabled || (isTransitioning && desiredEnabled)
603592
? 'default'
@@ -623,25 +612,73 @@ function MorningBriefingCard({
623612
const showScheduleDetails = !isWarmupState && hasSchedule && desiredEnabled;
624613
const controlsEnabled = actionsReady && !isWarmupState;
625614
const canUseBriefingControls = controlsEnabled && desiredEnabled;
615+
const lastDelivery = briefingStatus?.lastDelivery ?? [];
616+
const showLastDelivery =
617+
!isWarmupState && actionsReady && hasResolvedBriefingToggleState && lastDelivery.length > 0;
618+
const deliveryChannelLabel = {
619+
telegram: 'Telegram',
620+
discord: 'Discord',
621+
slack: 'Slack',
622+
} as const;
623+
const deliveryStatusLabel = {
624+
sent: 'Sent',
625+
skipped: 'Skipped',
626+
failed: 'Failed',
627+
} as const;
628+
const deliveryReasonLabel = {
629+
missing_target: 'Missing target',
630+
ambiguous_target: 'Ambiguous target',
631+
send_failed: 'Send failed',
632+
config_unavailable: 'Config unavailable',
633+
} as const;
626634

627635
return (
628636
<div className="rounded-lg border px-4 py-3">
629-
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
630-
<div>
631-
<div className="flex items-center gap-2">
632-
<p className="text-sm font-medium">Morning Briefing</p>
633-
<Badge variant={statusVariant}>{statusLabel}</Badge>
637+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
638+
<div className="flex items-start gap-3">
639+
<Newspaper className="text-muted-foreground h-5 w-5 shrink-0" />
640+
<div>
641+
<div className="flex items-center gap-2">
642+
<p className="text-sm font-medium">Morning Briefing</p>
643+
<Badge variant={statusVariant} className="px-1.5 py-0 text-[10px] leading-4">
644+
{statusLabel}
645+
</Badge>
646+
</div>
647+
{showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && (
648+
<p className="text-muted-foreground text-xs">
649+
{formatMorningBriefingSchedule(briefingStatus.cron, briefingStatus.timezone)}
650+
</p>
651+
)}
652+
{showScheduleDetails && (
653+
<p className="text-muted-foreground text-xs">
654+
Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'}
655+
</p>
656+
)}
657+
{showLastDelivery && (
658+
<p className="text-muted-foreground text-xs">
659+
Last delivery:{' '}
660+
{lastDelivery
661+
.map(entry => {
662+
const channel = deliveryChannelLabel[entry.channel] ?? entry.channel;
663+
const status = deliveryStatusLabel[entry.status] ?? entry.status;
664+
const reason = entry.reason
665+
? (deliveryReasonLabel[entry.reason] ?? entry.reason)
666+
: undefined;
667+
return reason ? `${channel} (${status}: ${reason})` : `${channel} (${status})`;
668+
})
669+
.join(' • ')}
670+
</p>
671+
)}
672+
673+
{isWarmupState && (
674+
<p className="text-muted-foreground mt-2 text-xs">
675+
Instance is still warming up. Morning Briefing controls will become available once
676+
the gateway is fully ready.
677+
</p>
678+
)}
679+
680+
<p className="text-muted-foreground mt-3 text-xs">{sourceSummaryText}</p>
634681
</div>
635-
{showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && (
636-
<p className="text-muted-foreground text-xs">
637-
{formatMorningBriefingSchedule(briefingStatus.cron, briefingStatus.timezone)}
638-
</p>
639-
)}
640-
{showScheduleDetails && (
641-
<p className="text-muted-foreground text-xs">
642-
Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'}
643-
</p>
644-
)}
645682
</div>
646683
<div className="flex flex-wrap gap-2">
647684
<Button
@@ -712,21 +749,12 @@ function MorningBriefingCard({
712749
</div>
713750
</div>
714751

715-
{isWarmupState && (
716-
<p className="text-muted-foreground mt-2 text-xs">
717-
Instance is still warming up. Morning Briefing controls will become available once the
718-
gateway is fully ready.
719-
</p>
720-
)}
721-
722752
{!desiredEnabled && controlsEnabled && (
723753
<p className="text-muted-foreground mt-2 text-xs">
724754
Enable Morning Briefing to get a personalized briefing everyday.
725755
</p>
726756
)}
727757

728-
<p className="text-muted-foreground mt-3 text-xs">{sourceSummaryText}</p>
729-
730758
{requestedDay && (
731759
<div className="mt-3">
732760
{isReading ? (
@@ -1314,10 +1342,40 @@ export function SettingsTab({
13141342
isControllerVersionError,
13151343
} = useClawUpdateAvailable(status);
13161344
const { data: myPin } = useClawMyPin();
1317-
const { data: morningBriefingStatus } = useClawMorningBriefingStatus(true);
1318-
const { data: gatewayReady } = useClawGatewayReady(isRunning);
1345+
const morningBriefingStatusQuery = useClawMorningBriefingStatus(isRunning);
1346+
const morningBriefingStatus = morningBriefingStatusQuery.data;
1347+
const gatewayReadyQuery = useClawGatewayReady(isRunning);
1348+
const gatewayReady = gatewayReadyQuery.data;
13191349
const [confirmDestroy, setConfirmDestroy] = useState(false);
13201350
const [confirmRestore, setConfirmRestore] = useState(false);
1351+
const [bootStartedAtMs, setBootStartedAtMs] = useState<number | null>(null);
1352+
const previousStatusRef = useRef(status.status);
1353+
1354+
useEffect(() => {
1355+
const previousStatus = previousStatusRef.current;
1356+
if (status.status === 'running' && previousStatus !== 'running') {
1357+
setBootStartedAtMs(Date.now());
1358+
}
1359+
if (status.status !== 'running' && previousStatus === 'running') {
1360+
setBootStartedAtMs(null);
1361+
}
1362+
previousStatusRef.current = status.status;
1363+
}, [status.status]);
1364+
1365+
const hasFreshGatewayReady =
1366+
isRunning &&
1367+
(bootStartedAtMs === null || gatewayReadyQuery.dataUpdatedAt >= bootStartedAtMs) &&
1368+
gatewayReadyQuery.dataUpdatedAt > 0;
1369+
const hasFreshMorningBriefingStatus =
1370+
isRunning &&
1371+
(bootStartedAtMs === null || morningBriefingStatusQuery.dataUpdatedAt >= bootStartedAtMs) &&
1372+
morningBriefingStatusQuery.dataUpdatedAt > 0;
1373+
const morningBriefingActionsReady =
1374+
isRunning &&
1375+
hasFreshGatewayReady &&
1376+
hasFreshMorningBriefingStatus &&
1377+
gatewayReady?.ready === true &&
1378+
gatewayReady?.settled === true;
13211379
const hasModelSelectionError = isRunning && isControllerVersionError;
13221380
const modelSelectionError = hasModelSelectionError
13231381
? 'Failed to load the running OpenClaw version. Retry before changing the default model.'
@@ -1867,9 +1925,7 @@ export function SettingsTab({
18671925
mutations={mutations}
18681926
briefingStatus={morningBriefingStatus}
18691927
isRunning={isRunning}
1870-
actionsReady={
1871-
isRunning && gatewayReady?.ready === true && gatewayReady?.settled === true
1872-
}
1928+
actionsReady={morningBriefingActionsReady}
18731929
fallbackReadiness={{
18741930
githubConfigured: configuredSecrets.github ?? false,
18751931
linearConfigured: configuredSecrets.linear ?? false,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test } from '@jest/globals';
2+
import { deriveMorningBriefingCardState } from './morning-briefing-card-state';
3+
4+
describe('deriveMorningBriefingCardState', () => {
5+
test('keeps warmup state when gateway_warming_up payload coexists with stale enabled=false', () => {
6+
const state = deriveMorningBriefingCardState({
7+
isRunning: true,
8+
actionsReady: true,
9+
briefingStatus: {
10+
code: 'gateway_warming_up',
11+
enabled: false,
12+
desiredEnabled: false,
13+
observedEnabled: false,
14+
reconcileState: 'in_progress',
15+
},
16+
});
17+
18+
expect(state.isWarmupState).toBe(true);
19+
expect(state.isGatewayWarmupStatus).toBe(true);
20+
});
21+
22+
test('exits warmup when actions are ready and status is resolved without warmup code', () => {
23+
const state = deriveMorningBriefingCardState({
24+
isRunning: true,
25+
actionsReady: true,
26+
briefingStatus: {
27+
enabled: true,
28+
desiredEnabled: true,
29+
observedEnabled: true,
30+
reconcileState: 'succeeded',
31+
},
32+
});
33+
34+
expect(state.isWarmupState).toBe(false);
35+
expect(state.desiredEnabled).toBe(true);
36+
expect(state.observedEnabled).toBe(true);
37+
});
38+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { MorningBriefingStatusLite } from '@/lib/kiloclaw/types';
2+
3+
export type MorningBriefingCardStateInput = {
4+
isRunning: boolean;
5+
actionsReady: boolean;
6+
briefingStatus: MorningBriefingStatusLite | undefined;
7+
};
8+
9+
export type MorningBriefingCardState = {
10+
desiredEnabled: boolean;
11+
observedEnabled: boolean;
12+
hasResolvedBriefingToggleState: boolean;
13+
isGatewayWarmupStatus: boolean;
14+
isWarmupState: boolean;
15+
};
16+
17+
export function deriveMorningBriefingCardState(
18+
input: MorningBriefingCardStateInput
19+
): MorningBriefingCardState {
20+
const desiredEnabledValue = input.briefingStatus?.desiredEnabled ?? input.briefingStatus?.enabled;
21+
const observedEnabledValue =
22+
input.briefingStatus?.observedEnabled ?? input.briefingStatus?.enabled;
23+
const isGatewayWarmupStatus = input.briefingStatus?.code === 'gateway_warming_up';
24+
const hasResolvedBriefingToggleState =
25+
typeof desiredEnabledValue === 'boolean' && typeof observedEnabledValue === 'boolean';
26+
const desiredEnabled = desiredEnabledValue ?? false;
27+
const observedEnabled = observedEnabledValue ?? false;
28+
const isWarmupState =
29+
input.isRunning &&
30+
(input.actionsReady === false || isGatewayWarmupStatus || !hasResolvedBriefingToggleState);
31+
32+
return {
33+
desiredEnabled,
34+
observedEnabled,
35+
hasResolvedBriefingToggleState,
36+
isGatewayWarmupStatus,
37+
isWarmupState,
38+
};
39+
}

apps/web/src/hooks/useKiloClaw.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ export function useKiloClawMutations() {
160160
]);
161161
};
162162

163+
const clearGatewayAndMorningBriefingCaches = () => {
164+
queryClient.removeQueries({ queryKey: trpc.kiloclaw.gatewayReady.queryKey() });
165+
queryClient.removeQueries({ queryKey: trpc.kiloclaw.getMorningBriefingStatus.queryKey() });
166+
};
167+
163168
// Wipe all instance-scoped caches so no stale data (e.g. gatewayReady
164169
// from the old instance) bleeds into a subsequent re-provision flow.
165170
// removeQueries drops the cached payload entirely; invalidateQueries
@@ -183,13 +188,25 @@ export function useKiloClawMutations() {
183188
};
184189

185190
return {
186-
start: useMutation(trpc.kiloclaw.start.mutationOptions({ onSuccess: invalidateStatus })),
191+
start: useMutation(
192+
trpc.kiloclaw.start.mutationOptions({
193+
onSuccess: async () => {
194+
clearGatewayAndMorningBriefingCaches();
195+
await invalidateStatus();
196+
},
197+
})
198+
),
187199
stop: useMutation(trpc.kiloclaw.stop.mutationOptions({ onSuccess: invalidateStatus })),
188200
destroy: useMutation(
189201
trpc.kiloclaw.destroy.mutationOptions({ onSuccess: resetAllInstanceState })
190202
),
191203
provision: useMutation(
192-
trpc.kiloclaw.provision.mutationOptions({ onSuccess: invalidateStatusAndBilling })
204+
trpc.kiloclaw.provision.mutationOptions({
205+
onSuccess: async () => {
206+
clearGatewayAndMorningBriefingCaches();
207+
await invalidateStatusAndBilling();
208+
},
209+
})
193210
),
194211
cycleInboundEmailAddress: useMutation(
195212
trpc.kiloclaw.cycleInboundEmailAddress.mutationOptions({ onSuccess: invalidateStatus })
@@ -222,6 +239,7 @@ export function useKiloClawMutations() {
222239
restartMachine: useMutation(
223240
trpc.kiloclaw.restartMachine.mutationOptions({
224241
onSuccess: async () => {
242+
clearGatewayAndMorningBriefingCaches();
225243
await invalidateStatus();
226244
await queryClient.invalidateQueries({
227245
queryKey: trpc.kiloclaw.gatewayStatus.queryKey(),
@@ -232,6 +250,7 @@ export function useKiloClawMutations() {
232250
restartOpenClaw: useMutation(
233251
trpc.kiloclaw.restartOpenClaw.mutationOptions({
234252
onSuccess: async () => {
253+
clearGatewayAndMorningBriefingCaches();
235254
await invalidateStatus();
236255
await queryClient.invalidateQueries({
237256
queryKey: trpc.kiloclaw.gatewayStatus.queryKey(),

0 commit comments

Comments
 (0)