From 6dbe21fdf91d5c67c188692eb21b6ef660ec3aff Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 6 May 2026 13:42:06 -0700 Subject: [PATCH 1/3] fix(kiloclaw): return graceful 200 for morning-briefing/status when controller too old --- .../routes/platform-morning-briefing.test.ts | 20 +++++++++++++++++++ services/kiloclaw/src/routes/platform.ts | 19 ++++++++++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts index 86469cf811..8578eb9dd8 100644 --- a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts +++ b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts @@ -102,6 +102,26 @@ describe('platform morning-briefing warm-up handling', () => { }); }); + it('returns graceful unavailable payload at 200 when controller predates the status route', async () => { + // The DO returns null when the controller route is missing (controller + // predates the morning-briefing route). The dashboard polls this endpoint + // every 30s, so 404 here would generate continuous user-facing errors. + const getMorningBriefingStatus = vi.fn<() => Promise>().mockResolvedValue(null); + const env = baseEnv({ getMorningBriefingStatus }); + + const response = await platform.request('/morning-briefing/status?userId=user-1', {}, env); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: false, + enabled: false, + desiredEnabled: false, + observedEnabled: false, + reconcileState: 'idle', + code: 'controller_route_unavailable', + }); + }); + it('does not treat 401 as warm-up for enable retries', async () => { const enableMorningBriefing = vi .fn<() => Promise<{ ok: boolean; enabled: boolean }>>() diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index 5f1a6c01cf..9bdfe49737 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -1993,10 +1993,21 @@ platform.get('/morning-briefing/status', async c => { 'getMorningBriefingStatus' ); if (!result) { - return jsonError( - 'Morning Briefing unavailable (controller too old)', - 404, - 'controller_route_unavailable' + // Controller predates this route. The dashboard polls status every 30s, + // so a 404 here would generate continuous user-facing errors. Return a + // typed "unavailable" payload at 200 instead — same shape pattern as + // the gateway_warming_up branch below. + return c.json( + { + ok: false, + enabled: false, + desiredEnabled: false, + observedEnabled: false, + reconcileState: 'idle' as const, + code: 'controller_route_unavailable', + error: 'Morning Briefing unavailable (controller too old)', + }, + 200 ); } return c.json(result, 200); From 81af72bcb72383568e9aa5ac7a524e12664dca18 Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 6 May 2026 13:55:41 -0700 Subject: [PATCH 2/3] fix(kiloclaw): surface controller-too-old as graceful upgrade affordance for Morning Briefing --- .../app/(app)/claw/components/SettingsTab.tsx | 48 +++++++++++++++---- .../morning-briefing-card-state.test.ts | 19 ++++++++ .../components/morning-briefing-card-state.ts | 6 +++ .../routes/platform-morning-briefing.test.ts | 1 + services/kiloclaw/src/routes/platform.ts | 2 +- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index 84ebd463f0..b1c2162d7a 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -501,6 +501,7 @@ function MorningBriefingCard({ fallbackReadiness, isRunning, actionsReady, + onRequestUpgrade, }: { mutations: ClawMutations; briefingStatus: MorningBriefingStatusLite | undefined; @@ -511,6 +512,8 @@ function MorningBriefingCard({ }; isRunning: boolean; actionsReady: boolean; + /** Callback that opens the focused upgrade confirmation flow. */ + onRequestUpgrade?: () => void; }) { const [requestedDay, setRequestedDay] = useState<'today' | 'yesterday' | null>(null); const { data: readData, isFetching: isReading } = useClawReadMorningBriefing(requestedDay, true); @@ -539,12 +542,17 @@ function MorningBriefingCard({ } as const); const hasSchedule = Boolean(briefingStatus?.cron && briefingStatus?.timezone); - const { desiredEnabled, observedEnabled, hasResolvedBriefingToggleState, isWarmupState } = - deriveMorningBriefingCardState({ - isRunning, - actionsReady, - briefingStatus, - }); + const { + desiredEnabled, + observedEnabled, + hasResolvedBriefingToggleState, + isWarmupState, + isControllerOutOfDate, + } = deriveMorningBriefingCardState({ + isRunning, + actionsReady, + briefingStatus, + }); const reconcileState = briefingStatus?.reconcileState ?? 'idle'; const lastReconcileAction = briefingStatus?.lastReconcileAction ?? null; const isTransitioning = @@ -610,8 +618,9 @@ function MorningBriefingCard({ : readySources.length === 0 ? 'No sources are connected yet. Configure GitHub, Linear, or Web Search to generate richer briefings.' : `Connected sources: ${joinFriendlyList(readySources)}. Disconnected sources: ${joinFriendlyList(missingSources)}.`; - const showScheduleDetails = !isWarmupState && hasSchedule && desiredEnabled; - const controlsEnabled = actionsReady && !isWarmupState; + const showScheduleDetails = + !isWarmupState && !isControllerOutOfDate && hasSchedule && desiredEnabled; + const controlsEnabled = actionsReady && !isWarmupState && !isControllerOutOfDate; const canUseBriefingControls = controlsEnabled && desiredEnabled; const lastDelivery = briefingStatus?.lastDelivery ?? []; const showLastDelivery = @@ -635,6 +644,28 @@ function MorningBriefingCard({ return (
+ {isControllerOutOfDate && ( +
+ +
+

Upgrade required

+

+ Morning Briefing requires a newer KiloClaw version. Upgrade to enable scheduling and + delivery. +

+
+ {onRequestUpgrade && ( + + )} +
+ )}
@@ -1925,6 +1956,7 @@ export function SettingsTab({ briefingStatus={morningBriefingStatus} isRunning={isRunning} actionsReady={morningBriefingActionsReady} + onRequestUpgrade={onRequestUpgrade} fallbackReadiness={{ githubConfigured: configuredSecrets.github ?? false, linearConfigured: configuredSecrets.linear ?? false, diff --git a/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.test.ts b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.test.ts index cf1722ffef..3d7d5a6e54 100644 --- a/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.test.ts +++ b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.test.ts @@ -35,4 +35,23 @@ describe('deriveMorningBriefingCardState', () => { expect(state.desiredEnabled).toBe(true); expect(state.observedEnabled).toBe(true); }); + + test('flags controller_route_unavailable and suppresses warmup state', () => { + const state = deriveMorningBriefingCardState({ + isRunning: true, + actionsReady: true, + briefingStatus: { + code: 'controller_route_unavailable', + enabled: false, + desiredEnabled: false, + observedEnabled: false, + reconcileState: 'idle', + }, + }); + + expect(state.isControllerOutOfDate).toBe(true); + // Warmup must be suppressed so the upgrade banner is the only signal — + // otherwise an out-of-date controller would also render as "warming up". + expect(state.isWarmupState).toBe(false); + }); }); diff --git a/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.ts b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.ts index 7f21d8a25a..021588bf13 100644 --- a/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.ts +++ b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.ts @@ -12,6 +12,7 @@ export type MorningBriefingCardState = { hasResolvedBriefingToggleState: boolean; isGatewayWarmupStatus: boolean; isWarmupState: boolean; + isControllerOutOfDate: boolean; }; export function deriveMorningBriefingCardState( @@ -21,11 +22,15 @@ export function deriveMorningBriefingCardState( const observedEnabledValue = input.briefingStatus?.observedEnabled ?? input.briefingStatus?.enabled; const isGatewayWarmupStatus = input.briefingStatus?.code === 'gateway_warming_up'; + const isControllerOutOfDate = input.briefingStatus?.code === 'controller_route_unavailable'; const hasResolvedBriefingToggleState = typeof desiredEnabledValue === 'boolean' && typeof observedEnabledValue === 'boolean'; const desiredEnabled = desiredEnabledValue ?? false; const observedEnabled = observedEnabledValue ?? false; + // Out-of-date controller takes precedence over warmup: the user needs to act + // (upgrade), not wait. Suppress warmup so the two banners don't fight. const isWarmupState = + !isControllerOutOfDate && input.isRunning && (input.actionsReady === false || isGatewayWarmupStatus || !hasResolvedBriefingToggleState); @@ -35,5 +40,6 @@ export function deriveMorningBriefingCardState( hasResolvedBriefingToggleState, isGatewayWarmupStatus, isWarmupState, + isControllerOutOfDate, }; } diff --git a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts index 8578eb9dd8..fbc0f3b137 100644 --- a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts +++ b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts @@ -119,6 +119,7 @@ describe('platform morning-briefing warm-up handling', () => { observedEnabled: false, reconcileState: 'idle', code: 'controller_route_unavailable', + error: 'Morning Briefing unavailable (controller too old)', }); }); diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index 9bdfe49737..35da2dcfa5 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -2003,7 +2003,7 @@ platform.get('/morning-briefing/status', async c => { enabled: false, desiredEnabled: false, observedEnabled: false, - reconcileState: 'idle' as const, + reconcileState: 'idle', code: 'controller_route_unavailable', error: 'Morning Briefing unavailable (controller too old)', }, From 220faba672d5c8f680584b23f1edbeee3af7f78e Mon Sep 17 00:00:00 2001 From: St0rmz1 Date: Wed, 6 May 2026 14:00:23 -0700 Subject: [PATCH 3/3] fix local review findings --- .../app/(app)/claw/components/SettingsTab.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index b1c2162d7a..1b612ffe69 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -561,6 +561,10 @@ function MorningBriefingCard({ mutations.disableMorningBriefing.isPending; const statusLabel = (() => { + if (isControllerOutOfDate) { + return 'Upgrade Required'; + } + if (isWarmupState) { return 'Instance Warming Up'; } @@ -593,13 +597,14 @@ function MorningBriefingCard({ return observedEnabled ? 'Enabled' : 'Disabled'; })(); - const statusVariant = isWarmupState - ? 'secondary' - : statusLabel === 'Instance Stopped' + const statusVariant = + isControllerOutOfDate || isWarmupState ? 'secondary' - : observedEnabled || (isTransitioning && desiredEnabled) - ? 'default' - : 'secondary'; + : statusLabel === 'Instance Stopped' + ? 'secondary' + : observedEnabled || (isTransitioning && desiredEnabled) + ? 'default' + : 'secondary'; const readySources = [ sourceReadiness.github.configured ? 'GitHub' : null, @@ -624,7 +629,11 @@ function MorningBriefingCard({ const canUseBriefingControls = controlsEnabled && desiredEnabled; const lastDelivery = briefingStatus?.lastDelivery ?? []; const showLastDelivery = - !isWarmupState && actionsReady && hasResolvedBriefingToggleState && lastDelivery.length > 0; + !isWarmupState && + !isControllerOutOfDate && + actionsReady && + hasResolvedBriefingToggleState && + lastDelivery.length > 0; const deliveryChannelLabel = { telegram: 'Telegram', discord: 'Discord',