diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index 84ebd463f..1b612ffe6 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 = @@ -553,6 +561,10 @@ function MorningBriefingCard({ mutations.disableMorningBriefing.isPending; const statusLabel = (() => { + if (isControllerOutOfDate) { + return 'Upgrade Required'; + } + if (isWarmupState) { return 'Instance Warming Up'; } @@ -585,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, @@ -610,12 +623,17 @@ 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 = - !isWarmupState && actionsReady && hasResolvedBriefingToggleState && lastDelivery.length > 0; + !isWarmupState && + !isControllerOutOfDate && + actionsReady && + hasResolvedBriefingToggleState && + lastDelivery.length > 0; const deliveryChannelLabel = { telegram: 'Telegram', discord: 'Discord', @@ -635,6 +653,28 @@ function MorningBriefingCard({ return (
+ {isControllerOutOfDate && ( +
+ +
+

Upgrade required

+

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

+
+ {onRequestUpgrade && ( + + )} +
+ )}
@@ -1925,6 +1965,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 cf1722ffe..3d7d5a6e5 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 7f21d8a25..021588bf1 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 86469cf81..fbc0f3b13 100644 --- a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts +++ b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts @@ -102,6 +102,27 @@ 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', + error: 'Morning Briefing unavailable (controller too old)', + }); + }); + 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 5f1a6c01c..35da2dcfa 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', + code: 'controller_route_unavailable', + error: 'Morning Briefing unavailable (controller too old)', + }, + 200 ); } return c.json(result, 200);