diff --git a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx index 3196ac0b5..0f46d74f8 100644 --- a/apps/web/src/app/(app)/claw/components/SettingsTab.tsx +++ b/apps/web/src/app/(app)/claw/components/SettingsTab.tsx @@ -8,6 +8,7 @@ import { FileCode, Hash, Info, + Newspaper, RotateCcw, Save, Settings, @@ -15,7 +16,7 @@ import { Square, X, } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { OpenclawImportCard } from './OpenclawImportCard'; import { usePostHog } from 'posthog-js/react'; @@ -23,7 +24,7 @@ import { toast } from 'sonner'; import { useModelSelectorList } from '@/app/api/openrouter/hooks'; import { useUser } from '@/hooks/useUser'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; -import type { KiloClawDashboardStatus } from '@/lib/kiloclaw/types'; +import type { KiloClawDashboardStatus, MorningBriefingStatusLite } from '@/lib/kiloclaw/types'; import { calverAtLeast, cleanVersion } from '@/lib/kiloclaw/version'; import type { useKiloClawMutations } from '@/hooks/useKiloClaw'; import { @@ -66,6 +67,7 @@ import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { DetailTile } from './DetailTile'; import { EMBEDDING_MODELS, DEFAULT_EMBEDDING_MODEL } from './embeddingModels'; +import { deriveMorningBriefingCardState } from './morning-briefing-card-state'; import { getEntriesByCategory } from '@kilocode/kiloclaw-secret-catalog'; import { SecretEntrySection } from './SecretEntrySection'; @@ -500,24 +502,7 @@ function MorningBriefingCard({ actionsReady, }: { mutations: ClawMutations; - briefingStatus: - | { - enabled?: boolean; - desiredEnabled?: boolean; - observedEnabled?: boolean | null; - reconcileState?: 'idle' | 'in_progress' | 'succeeded' | 'failed'; - lastReconcileAction?: 'enable' | 'disable' | null; - code?: string; - cron?: string; - timezone?: string; - lastGeneratedDate?: string | null; - sourceReadiness?: { - github: { configured: boolean; summary: string }; - linear: { configured: boolean; summary: string }; - web: { configured: boolean; summary: string }; - }; - } - | undefined; + briefingStatus: MorningBriefingStatusLite | undefined; fallbackReadiness: { githubConfigured: boolean; linearConfigured: boolean; @@ -553,11 +538,14 @@ function MorningBriefingCard({ } as const); const hasSchedule = Boolean(briefingStatus?.cron && briefingStatus?.timezone); - const desiredEnabled = briefingStatus?.desiredEnabled ?? briefingStatus?.enabled ?? false; - const observedEnabled = briefingStatus?.observedEnabled ?? briefingStatus?.enabled ?? false; + const { desiredEnabled, observedEnabled, hasResolvedBriefingToggleState, isWarmupState } = + deriveMorningBriefingCardState({ + isRunning, + actionsReady, + briefingStatus, + }); const reconcileState = briefingStatus?.reconcileState ?? 'idle'; const lastReconcileAction = briefingStatus?.lastReconcileAction ?? null; - const isWarmupState = isRunning && actionsReady === false; const isTransitioning = reconcileState === 'in_progress' || mutations.enableMorningBriefing.isPending || @@ -596,8 +584,9 @@ function MorningBriefingCard({ return observedEnabled ? 'Enabled' : 'Disabled'; })(); - const statusVariant = - statusLabel === 'Instance Stopped' + const statusVariant = isWarmupState + ? 'secondary' + : statusLabel === 'Instance Stopped' ? 'secondary' : observedEnabled || (isTransitioning && desiredEnabled) ? 'default' @@ -623,25 +612,73 @@ function MorningBriefingCard({ const showScheduleDetails = !isWarmupState && hasSchedule && desiredEnabled; const controlsEnabled = actionsReady && !isWarmupState; const canUseBriefingControls = controlsEnabled && desiredEnabled; + const lastDelivery = briefingStatus?.lastDelivery ?? []; + const showLastDelivery = + !isWarmupState && actionsReady && hasResolvedBriefingToggleState && lastDelivery.length > 0; + const deliveryChannelLabel = { + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + } as const; + const deliveryStatusLabel = { + sent: 'Sent', + skipped: 'Skipped', + failed: 'Failed', + } as const; + const deliveryReasonLabel = { + missing_target: 'Missing target', + ambiguous_target: 'Ambiguous target', + send_failed: 'Send failed', + config_unavailable: 'Config unavailable', + } as const; return (
-
-
-
-

Morning Briefing

- {statusLabel} +
+
+ +
+
+

Morning Briefing

+ + {statusLabel} + +
+ {showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && ( +

+ {formatMorningBriefingSchedule(briefingStatus.cron, briefingStatus.timezone)} +

+ )} + {showScheduleDetails && ( +

+ Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'} +

+ )} + {showLastDelivery && ( +

+ Last delivery:{' '} + {lastDelivery + .map(entry => { + const channel = deliveryChannelLabel[entry.channel] ?? entry.channel; + const status = deliveryStatusLabel[entry.status] ?? entry.status; + const reason = entry.reason + ? (deliveryReasonLabel[entry.reason] ?? entry.reason) + : undefined; + return reason ? `${channel} (${status}: ${reason})` : `${channel} (${status})`; + }) + .join(' • ')} +

+ )} + + {isWarmupState && ( +

+ Instance is still warming up. Morning Briefing controls will become available once + the gateway is fully ready. +

+ )} + +

{sourceSummaryText}

- {showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && ( -

- {formatMorningBriefingSchedule(briefingStatus.cron, briefingStatus.timezone)} -

- )} - {showScheduleDetails && ( -

- Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'} -

- )}
- {isWarmupState && ( -

- Instance is still warming up. Morning Briefing controls will become available once the - gateway is fully ready. -

- )} - {!desiredEnabled && controlsEnabled && (

Enable Morning Briefing to get a personalized briefing everyday.

)} -

{sourceSummaryText}

- {requestedDay && (
{isReading ? ( @@ -1314,10 +1342,40 @@ export function SettingsTab({ isControllerVersionError, } = useClawUpdateAvailable(status); const { data: myPin } = useClawMyPin(); - const { data: morningBriefingStatus } = useClawMorningBriefingStatus(true); - const { data: gatewayReady } = useClawGatewayReady(isRunning); + const morningBriefingStatusQuery = useClawMorningBriefingStatus(isRunning); + const morningBriefingStatus = morningBriefingStatusQuery.data; + const gatewayReadyQuery = useClawGatewayReady(isRunning); + const gatewayReady = gatewayReadyQuery.data; const [confirmDestroy, setConfirmDestroy] = useState(false); const [confirmRestore, setConfirmRestore] = useState(false); + const [bootStartedAtMs, setBootStartedAtMs] = useState(null); + const previousStatusRef = useRef(status.status); + + useEffect(() => { + const previousStatus = previousStatusRef.current; + if (status.status === 'running' && previousStatus !== 'running') { + setBootStartedAtMs(Date.now()); + } + if (status.status !== 'running' && previousStatus === 'running') { + setBootStartedAtMs(null); + } + previousStatusRef.current = status.status; + }, [status.status]); + + const hasFreshGatewayReady = + isRunning && + (bootStartedAtMs === null || gatewayReadyQuery.dataUpdatedAt >= bootStartedAtMs) && + gatewayReadyQuery.dataUpdatedAt > 0; + const hasFreshMorningBriefingStatus = + isRunning && + (bootStartedAtMs === null || morningBriefingStatusQuery.dataUpdatedAt >= bootStartedAtMs) && + morningBriefingStatusQuery.dataUpdatedAt > 0; + const morningBriefingActionsReady = + isRunning && + hasFreshGatewayReady && + hasFreshMorningBriefingStatus && + gatewayReady?.ready === true && + gatewayReady?.settled === true; const hasModelSelectionError = isRunning && isControllerVersionError; const modelSelectionError = hasModelSelectionError ? 'Failed to load the running OpenClaw version. Retry before changing the default model.' @@ -1867,9 +1925,7 @@ export function SettingsTab({ mutations={mutations} briefingStatus={morningBriefingStatus} isRunning={isRunning} - actionsReady={ - isRunning && gatewayReady?.ready === true && gatewayReady?.settled === true - } + actionsReady={morningBriefingActionsReady} 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 new file mode 100644 index 000000000..cf1722ffe --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from '@jest/globals'; +import { deriveMorningBriefingCardState } from './morning-briefing-card-state'; + +describe('deriveMorningBriefingCardState', () => { + test('keeps warmup state when gateway_warming_up payload coexists with stale enabled=false', () => { + const state = deriveMorningBriefingCardState({ + isRunning: true, + actionsReady: true, + briefingStatus: { + code: 'gateway_warming_up', + enabled: false, + desiredEnabled: false, + observedEnabled: false, + reconcileState: 'in_progress', + }, + }); + + expect(state.isWarmupState).toBe(true); + expect(state.isGatewayWarmupStatus).toBe(true); + }); + + test('exits warmup when actions are ready and status is resolved without warmup code', () => { + const state = deriveMorningBriefingCardState({ + isRunning: true, + actionsReady: true, + briefingStatus: { + enabled: true, + desiredEnabled: true, + observedEnabled: true, + reconcileState: 'succeeded', + }, + }); + + expect(state.isWarmupState).toBe(false); + expect(state.desiredEnabled).toBe(true); + expect(state.observedEnabled).toBe(true); + }); +}); 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 new file mode 100644 index 000000000..7f21d8a25 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/morning-briefing-card-state.ts @@ -0,0 +1,39 @@ +import type { MorningBriefingStatusLite } from '@/lib/kiloclaw/types'; + +export type MorningBriefingCardStateInput = { + isRunning: boolean; + actionsReady: boolean; + briefingStatus: MorningBriefingStatusLite | undefined; +}; + +export type MorningBriefingCardState = { + desiredEnabled: boolean; + observedEnabled: boolean; + hasResolvedBriefingToggleState: boolean; + isGatewayWarmupStatus: boolean; + isWarmupState: boolean; +}; + +export function deriveMorningBriefingCardState( + input: MorningBriefingCardStateInput +): MorningBriefingCardState { + const desiredEnabledValue = input.briefingStatus?.desiredEnabled ?? input.briefingStatus?.enabled; + const observedEnabledValue = + input.briefingStatus?.observedEnabled ?? input.briefingStatus?.enabled; + const isGatewayWarmupStatus = input.briefingStatus?.code === 'gateway_warming_up'; + const hasResolvedBriefingToggleState = + typeof desiredEnabledValue === 'boolean' && typeof observedEnabledValue === 'boolean'; + const desiredEnabled = desiredEnabledValue ?? false; + const observedEnabled = observedEnabledValue ?? false; + const isWarmupState = + input.isRunning && + (input.actionsReady === false || isGatewayWarmupStatus || !hasResolvedBriefingToggleState); + + return { + desiredEnabled, + observedEnabled, + hasResolvedBriefingToggleState, + isGatewayWarmupStatus, + isWarmupState, + }; +} diff --git a/apps/web/src/hooks/useKiloClaw.ts b/apps/web/src/hooks/useKiloClaw.ts index 4b8f04d99..18ee66dcd 100644 --- a/apps/web/src/hooks/useKiloClaw.ts +++ b/apps/web/src/hooks/useKiloClaw.ts @@ -160,6 +160,11 @@ export function useKiloClawMutations() { ]); }; + const clearGatewayAndMorningBriefingCaches = () => { + queryClient.removeQueries({ queryKey: trpc.kiloclaw.gatewayReady.queryKey() }); + queryClient.removeQueries({ queryKey: trpc.kiloclaw.getMorningBriefingStatus.queryKey() }); + }; + // Wipe all instance-scoped caches so no stale data (e.g. gatewayReady // from the old instance) bleeds into a subsequent re-provision flow. // removeQueries drops the cached payload entirely; invalidateQueries @@ -183,13 +188,25 @@ export function useKiloClawMutations() { }; return { - start: useMutation(trpc.kiloclaw.start.mutationOptions({ onSuccess: invalidateStatus })), + start: useMutation( + trpc.kiloclaw.start.mutationOptions({ + onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); + await invalidateStatus(); + }, + }) + ), stop: useMutation(trpc.kiloclaw.stop.mutationOptions({ onSuccess: invalidateStatus })), destroy: useMutation( trpc.kiloclaw.destroy.mutationOptions({ onSuccess: resetAllInstanceState }) ), provision: useMutation( - trpc.kiloclaw.provision.mutationOptions({ onSuccess: invalidateStatusAndBilling }) + trpc.kiloclaw.provision.mutationOptions({ + onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); + await invalidateStatusAndBilling(); + }, + }) ), cycleInboundEmailAddress: useMutation( trpc.kiloclaw.cycleInboundEmailAddress.mutationOptions({ onSuccess: invalidateStatus }) @@ -222,6 +239,7 @@ export function useKiloClawMutations() { restartMachine: useMutation( trpc.kiloclaw.restartMachine.mutationOptions({ onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); await invalidateStatus(); await queryClient.invalidateQueries({ queryKey: trpc.kiloclaw.gatewayStatus.queryKey(), @@ -232,6 +250,7 @@ export function useKiloClawMutations() { restartOpenClaw: useMutation( trpc.kiloclaw.restartOpenClaw.mutationOptions({ onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); await invalidateStatus(); await queryClient.invalidateQueries({ queryKey: trpc.kiloclaw.gatewayStatus.queryKey(), diff --git a/apps/web/src/hooks/useOrgKiloClaw.ts b/apps/web/src/hooks/useOrgKiloClaw.ts index 4a5ce48e7..815338fb7 100644 --- a/apps/web/src/hooks/useOrgKiloClaw.ts +++ b/apps/web/src/hooks/useOrgKiloClaw.ts @@ -215,6 +215,15 @@ export function useOrgKiloClawMutations( }); }; + const clearGatewayAndMorningBriefingCaches = () => { + queryClient.removeQueries({ + queryKey: trpc.organizations.kiloclaw.gatewayReady.queryKey({ organizationId }), + }); + queryClient.removeQueries({ + queryKey: trpc.organizations.kiloclaw.getMorningBriefingStatus.queryKey({ organizationId }), + }); + }; + // Helper: wrap a raw org mutation so mutate/mutateAsync inject organizationId. // The `any` types are unavoidable here — we're wrapping tRPC mutations generically // to pre-bind organizationId. The final return uses `satisfies` to catch missing keys. @@ -237,7 +246,12 @@ export function useOrgKiloClawMutations( /* eslint-enable @typescript-eslint/no-explicit-any */ const rawStart = useMutation( - trpc.organizations.kiloclaw.start.mutationOptions({ onSuccess: invalidateStatus }) + trpc.organizations.kiloclaw.start.mutationOptions({ + onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); + await invalidateStatus(); + }, + }) ); const rawStop = useMutation( trpc.organizations.kiloclaw.stop.mutationOptions({ onSuccess: invalidateStatus }) @@ -246,7 +260,12 @@ export function useOrgKiloClawMutations( trpc.organizations.kiloclaw.destroy.mutationOptions({ onSuccess: resetAllInstanceState }) ); const rawProvision = useMutation( - trpc.organizations.kiloclaw.provision.mutationOptions({ onSuccess: invalidateStatus }) + trpc.organizations.kiloclaw.provision.mutationOptions({ + onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); + await invalidateStatus(); + }, + }) ); const rawCycleInboundEmailAddress = useMutation( trpc.organizations.kiloclaw.cycleInboundEmailAddress.mutationOptions({ @@ -287,6 +306,7 @@ export function useOrgKiloClawMutations( const rawRestartMachine = useMutation( trpc.organizations.kiloclaw.restartMachine.mutationOptions({ onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); await invalidateStatus(); await queryClient.invalidateQueries({ queryKey: trpc.organizations.kiloclaw.gatewayStatus.queryKey({ organizationId }), @@ -297,6 +317,7 @@ export function useOrgKiloClawMutations( const rawRestartOpenClaw = useMutation( trpc.organizations.kiloclaw.restartOpenClaw.mutationOptions({ onSuccess: async () => { + clearGatewayAndMorningBriefingCaches(); await invalidateStatus(); await queryClient.invalidateQueries({ queryKey: trpc.organizations.kiloclaw.gatewayStatus.queryKey({ organizationId }), diff --git a/apps/web/src/lib/kiloclaw/types.ts b/apps/web/src/lib/kiloclaw/types.ts index 90ac7a3ec..9931c0549 100644 --- a/apps/web/src/lib/kiloclaw/types.ts +++ b/apps/web/src/lib/kiloclaw/types.ts @@ -408,6 +408,30 @@ export type MorningBriefingSourceReadiness = { summary: string; }; +export type MorningBriefingDeliveryResult = { + channel: 'telegram' | 'discord' | 'slack'; + status: 'sent' | 'skipped' | 'failed'; + target?: string; + accountId?: string; + reason?: 'missing_target' | 'ambiguous_target' | 'send_failed' | 'config_unavailable'; + error?: string; +}; + +export type MorningBriefingStatusLite = Pick< + MorningBriefingStatusResponse, + | 'enabled' + | 'desiredEnabled' + | 'observedEnabled' + | 'reconcileState' + | 'lastReconcileAction' + | 'code' + | 'cron' + | 'timezone' + | 'lastGeneratedDate' + | 'sourceReadiness' + | 'lastDelivery' +>; + export type MorningBriefingStatusResponse = { ok: boolean; enabled?: boolean; @@ -427,6 +451,7 @@ export type MorningBriefingStatusResponse = { linear: MorningBriefingSourceReadiness; web: MorningBriefingSourceReadiness; }; + lastDelivery?: MorningBriefingDeliveryResult[]; code?: string; retryAfterSec?: number; error?: string; @@ -441,6 +466,7 @@ export type MorningBriefingActionResponse = { date?: string; filePath?: string; failures?: string[]; + delivery?: MorningBriefingDeliveryResult[]; code?: string; retryAfterSec?: number; error?: string; diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts index 677b95f44..95c501705 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildBriefingMarkdown, + formatBriefingMarkdownForMessage, formatDateKey, offsetDateKey, resolveBriefingPath, @@ -40,11 +41,67 @@ describe('briefing-utils', () => { expect(markdown).toContain('- github: [ok] Fetched 3 issues'); expect(markdown).toContain('## GitHub'); expect(markdown).toContain('- Item 1'); - expect(markdown).toContain('## Failures / Skipped'); + expect(markdown).toContain('## Failures'); + }); + + it('omits failures section when there are no failures', () => { + const markdown = buildBriefingMarkdown({ + dateKey: '2026-04-23', + generatedAt: new Date('2026-04-23T07:00:01Z'), + statuses: [ + { source: 'github', configured: true, ok: true, summary: 'Fetched 1 issue' }, + { source: 'linear', configured: true, ok: true, summary: 'Fetched 1 issue' }, + { source: 'web', configured: true, ok: true, summary: 'Fetched 1 result' }, + ], + sections: [{ title: 'GitHub', lines: ['- Item 1'] }], + failures: [], + }); + + expect(markdown).not.toContain('## Failures'); }); it('builds date-based file paths', () => { const filePath = resolveBriefingPath('/tmp/briefings', '2026-04-23'); expect(filePath.endsWith('/briefings/2026-04-23.md')).toBe(true); }); + + it('adapts briefing markdown into channel-friendly text', () => { + const markdown = [ + '# Morning Briefing - 2026-04-23', + '', + '## GitHub', + '- [Fix flaky build](https://example.com/issue/1) (updated 2026-04-23)', + '', + '## Source Status', + '- github: [ok] Fetched 1 open issue', + '', + '_Generated at 2026-04-23T07:00:01.000Z_', + ].join('\n'); + + const message = formatBriefingMarkdownForMessage(markdown); + + expect(message).toContain('Morning Briefing - 2026-04-23'); + expect(message).toContain('GitHub'); + expect(message).toContain( + '• Fix flaky build - https://example.com/issue/1 (updated 2026-04-23)' + ); + expect(message).toContain('Generated at 2026-04-23T07:00:01.000Z'); + expect(message).not.toContain('# '); + expect(message).not.toContain('['); + }); + + it('keeps markdown links with nested parentheses intact when adapting for messages', () => { + const markdown = [ + '# Morning Briefing - 2026-04-23', + '', + '## Web Search', + '- [Spec page](https://example.com/wiki/Foo_(bar))', + '- [Another page](https://example.com/docs/(deep)/(nested))', + ].join('\n'); + + const message = formatBriefingMarkdownForMessage(markdown); + + expect(message).toContain('• Spec page - https://example.com/wiki/Foo_(bar)'); + expect(message).toContain('• Another page - https://example.com/docs/(deep)/(nested)'); + }); }); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts index f9db8dabc..3e1671a1c 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/briefing-utils.ts @@ -69,7 +69,7 @@ export function buildBriefingMarkdown(params: { if (params.failures.length > 0) { lines.push(''); - lines.push('## Failures / Skipped'); + lines.push('## Failures'); for (const failure of params.failures) { lines.push(`- ${failure}`); } @@ -88,3 +88,90 @@ export function buildBriefingMarkdown(params: { return lines.join('\n'); } + +function expandMarkdownLinks(line: string): string { + let result = ''; + let i = 0; + + while (i < line.length) { + const labelStart = line.indexOf('[', i); + if (labelStart < 0) { + result += line.slice(i); + break; + } + + const labelEnd = line.indexOf(']', labelStart + 1); + if (labelEnd < 0 || line[labelEnd + 1] !== '(') { + result += line.slice(i, labelStart + 1); + i = labelStart + 1; + continue; + } + + let urlEnd = labelEnd + 2; + let depth = 1; + while (urlEnd < line.length && depth > 0) { + const char = line[urlEnd]; + if (char === '(') { + depth += 1; + } else if (char === ')') { + depth -= 1; + } + urlEnd += 1; + } + + if (depth !== 0) { + result += line.slice(i, labelStart + 1); + i = labelStart + 1; + continue; + } + + const label = line.slice(labelStart + 1, labelEnd); + const url = line.slice(labelEnd + 2, urlEnd - 1); + + result += line.slice(i, labelStart); + result += `${label} - ${url}`; + i = urlEnd; + } + + return result; +} + +function convertInlineMarkdownToText(line: string): string { + const withLinksExpanded = expandMarkdownLinks(line); + return withLinksExpanded + .replace(/\[(ok|error|skipped)\]/gi, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/`([^`]+)`/g, '$1'); +} + +export function formatBriefingMarkdownForMessage(markdown: string): string { + const transformedLines = markdown.split(/\r?\n/).map(rawLine => { + const heading = /^#{1,2}\s+(.+)$/.exec(rawLine); + if (heading) { + return heading[1]?.trim() ?? ''; + } + + if (/^_.*_$/.test(rawLine.trim())) { + return rawLine.trim().slice(1, -1); + } + + if (rawLine.startsWith('- ')) { + return `• ${convertInlineMarkdownToText(rawLine.slice(2))}`; + } + + return convertInlineMarkdownToText(rawLine); + }); + + const compacted: string[] = []; + let previousBlank = false; + for (const line of transformedLines) { + const blank = line.trim().length === 0; + if (blank && previousBlank) { + continue; + } + compacted.push(line); + previousBlank = blank; + } + + return compacted.join('\n').trim(); +} diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts new file mode 100644 index 000000000..0bc599651 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/command-utils.ts @@ -0,0 +1,64 @@ +export type CommandCapableRuntime = { + runtime: { + system: { + runCommandWithTimeout: ( + argv: string[], + options: { timeoutMs: number; cwd?: string } + ) => Promise<{ stdout: string; stderr: string; code: number | null }>; + }; + }; +}; + +export class CommandExecutionError extends Error { + readonly argv: string[]; + readonly code: number | null; + readonly stdout: string; + readonly stderr: string; + + constructor(params: { argv: string[]; code: number | null; stdout: string; stderr: string }) { + const detail = params.stderr.trim() || params.stdout.trim() || 'Command failed'; + super(`${params.argv.join(' ')} failed: ${detail}`); + this.name = 'CommandExecutionError'; + this.argv = params.argv; + this.code = params.code; + this.stdout = params.stdout; + this.stderr = params.stderr; + } +} + +export function isTimeoutExecutionError(error: unknown): boolean { + if (!(error instanceof CommandExecutionError)) { + return false; + } + + if (error.code === null) { + return true; + } + + const text = `${error.stderr}\n${error.stdout}\n${error.message}`; + return ( + text.includes('operation was aborted due to timeout') || + text.includes('timed out') || + text.includes('ETIMEDOUT') || + text.includes('AbortError') + ); +} + +export async function runCommand( + api: CommandCapableRuntime, + argv: string[], + timeoutMs = 30_000 +): Promise<{ stdout: string; stderr: string }> { + const result = await api.runtime.system.runCommandWithTimeout(argv, { + timeoutMs, + }); + if (result.code !== 0) { + throw new CommandExecutionError({ + argv, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }); + } + return { stdout: result.stdout, stderr: result.stderr }; +} diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts new file mode 100644 index 000000000..04847c8af --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-constants.ts @@ -0,0 +1,10 @@ +export const DELIVERY_CHANNELS = ['telegram', 'discord', 'slack'] as const; + +export const DELIVERY_STATUSES = ['sent', 'skipped', 'failed'] as const; + +export const DELIVERY_REASONS = [ + 'missing_target', + 'ambiguous_target', + 'send_failed', + 'config_unavailable', +] as const; diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts new file mode 100644 index 000000000..3452fafb1 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { parseStoredDelivery, resolveDeliveryRoute } from './delivery-utils'; + +describe('delivery-utils', () => { + it('parseStoredDelivery ignores malformed entries and keeps valid ones', () => { + const parsed = parseStoredDelivery([ + null, + 123, + { channel: 'telegram', status: 'sent', target: '-5055658641' }, + { channel: 'discord', status: 'unknown' }, + { channel: 'slack', status: 'failed', reason: 'send_failed', error: 'send failed' }, + { channel: 'email', status: 'sent' }, + { channel: 'telegram', status: 'skipped', reason: 'bogus_reason' }, + ]); + + expect(parsed).toEqual([ + { + channel: 'telegram', + status: 'sent', + target: '-5055658641', + }, + { + channel: 'slack', + status: 'failed', + reason: 'send_failed', + error: 'send failed', + }, + { + channel: 'telegram', + status: 'skipped', + }, + ]); + }); + + it('resolveDeliveryRoute infers single discord fallback channel target', () => { + const resolution = resolveDeliveryRoute({ + channel: 'discord', + channelsConfig: { + discord: { + enabled: true, + guilds: { + 'guild-1': { + channels: { + '1234567890': { enabled: true }, + }, + }, + }, + }, + }, + }); + + expect(resolution).toEqual({ + configured: true, + route: { + channel: 'discord', + target: 'channel:1234567890', + }, + }); + }); + + it('resolveDeliveryRoute infers single slack fallback channel target', () => { + const resolution = resolveDeliveryRoute({ + channel: 'slack', + channelsConfig: { + slack: { + enabled: true, + channels: { + C123456: { enabled: true }, + }, + }, + }, + }); + + expect(resolution).toEqual({ + configured: true, + route: { + channel: 'slack', + target: 'channel:C123456', + }, + }); + }); + + it('resolveDeliveryRoute marks ambiguous fallback when multiple discord channels exist', () => { + const resolution = resolveDeliveryRoute({ + channel: 'discord', + channelsConfig: { + discord: { + enabled: true, + guilds: { + 'guild-1': { + channels: { + '123': { enabled: true }, + '456': { enabled: true }, + }, + }, + }, + }, + }, + }); + + expect(resolution).toEqual({ + configured: true, + route: null, + skipReason: 'ambiguous_target', + }); + }); +}); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts new file mode 100644 index 000000000..810771396 --- /dev/null +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/delivery-utils.ts @@ -0,0 +1,377 @@ +import { formatBriefingMarkdownForMessage } from './briefing-utils'; +import { type CommandCapableRuntime, isTimeoutExecutionError, runCommand } from './command-utils'; +import { DELIVERY_CHANNELS, DELIVERY_REASONS, DELIVERY_STATUSES } from './delivery-constants'; + +export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number]; + +export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number]; + +export type DeliveryReason = (typeof DELIVERY_REASONS)[number]; + +export type BriefingDeliveryResult = { + channel: DeliveryChannel; + status: DeliveryStatus; + target?: string; + accountId?: string; + reason?: DeliveryReason; + error?: string; +}; + +type DeliveryRoute = { + channel: DeliveryChannel; + target: string; + accountId?: string; +}; + +type DeliveryApi = CommandCapableRuntime & { + config: unknown; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; +}; + +type SkipReason = Extract; + +export type DeliveryRouteResolution = { + configured: boolean; + route: DeliveryRoute | null; + skipReason?: SkipReason; +}; + +function asObject(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function normalizeDeliveryTarget(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return null; +} + +function toEnabledObjectEntries(value: unknown): Array<[string, Record]> { + const record = asObject(value); + return Object.entries(record) + .filter((entry): entry is [string, Record] => { + const [key, raw] = entry; + if (key.trim() === '' || key === '*') { + return false; + } + return typeof raw === 'object' && raw !== null && !Array.isArray(raw); + }) + .filter(([, raw]) => raw.enabled !== false); +} + +function collectFallbackTargets( + channel: DeliveryChannel, + rawChannelConfig: Record +): string[] { + if (channel === 'telegram') { + return toEnabledObjectEntries(rawChannelConfig.groups).map(([groupId]) => groupId); + } + + if (channel === 'discord') { + const guildEntries = toEnabledObjectEntries(rawChannelConfig.guilds); + return guildEntries.flatMap(([, guildConfig]) => { + const channels = toEnabledObjectEntries(guildConfig.channels); + return channels.map(([channelId]) => `channel:${channelId}`); + }); + } + + const channels = toEnabledObjectEntries(rawChannelConfig.channels); + return channels.map(([channelId]) => `channel:${channelId}`); +} + +export function resolveDeliveryRoute(params: { + channel: DeliveryChannel; + channelsConfig: Record; +}): DeliveryRouteResolution { + const rawChannelConfig = asObject(params.channelsConfig[params.channel]); + if (Object.keys(rawChannelConfig).length === 0 || rawChannelConfig.enabled === false) { + return { configured: false, route: null }; + } + + const accountsConfig = asObject(rawChannelConfig.accounts); + const defaultAccount = asObject(accountsConfig.default); + const defaultAccountTarget = normalizeDeliveryTarget(defaultAccount.defaultTo); + if (defaultAccountTarget) { + return { + configured: true, + route: { + channel: params.channel, + target: defaultAccountTarget, + accountId: 'default', + }, + }; + } + + const topLevelTarget = normalizeDeliveryTarget(rawChannelConfig.defaultTo); + if (topLevelTarget) { + return { + configured: true, + route: { + channel: params.channel, + target: topLevelTarget, + }, + }; + } + + const fallbackTargets = collectFallbackTargets(params.channel, rawChannelConfig); + if (fallbackTargets.length === 1) { + return { + configured: true, + route: { + channel: params.channel, + target: fallbackTargets[0], + }, + }; + } + + return { + configured: true, + route: null, + skipReason: fallbackTargets.length > 1 ? 'ambiguous_target' : 'missing_target', + }; +} + +function readChannelsConfigFromRuntimeConfig(config: unknown): Record | null { + const rawConfig = asObject(config); + if (!Object.prototype.hasOwnProperty.call(rawConfig, 'channels')) { + return null; + } + return asObject(rawConfig.channels); +} + +async function readChannelsConfig(api: DeliveryApi): Promise> { + const fromRuntimeConfig = readChannelsConfigFromRuntimeConfig(api.config); + if (fromRuntimeConfig) { + return fromRuntimeConfig; + } + + let lastError: unknown = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const { stdout } = await runCommand( + api, + ['openclaw', 'config', 'get', 'channels', '--json'], + 60_000 + ); + return asObject(JSON.parse(stdout)); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + if (errorText.includes('Config path not found: channels')) { + return {}; + } + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + + return {}; +} + +function createSendCommand(params: { route: DeliveryRoute; messageText: string }): string[] { + const argv = [ + 'openclaw', + 'message', + 'send', + '--channel', + params.route.channel, + '--target', + params.route.target, + '--message', + params.messageText, + ]; + if (params.route.accountId) { + argv.push('--account', params.route.accountId); + } + return argv; +} + +async function sendWithRetry(api: DeliveryApi, argv: string[]): Promise { + try { + await runCommand(api, argv, 120_000); + return; + } catch (error) { + if (!isTimeoutExecutionError(error)) { + throw error; + } + } + await runCommand(api, argv, 120_000); +} + +function failedDeliveryForAllChannels(errorText: string): BriefingDeliveryResult[] { + return DELIVERY_CHANNELS.map(channel => ({ + channel, + status: 'failed', + reason: 'config_unavailable', + error: errorText, + })); +} + +function summarizeDeliveryError(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + const openclawSendPrefix = 'openclaw message send'; + if (message.startsWith(openclawSendPrefix) && message.includes(' failed: ')) { + const detail = message.slice(message.indexOf(' failed: ') + ' failed: '.length).trim(); + if (detail.length > 0) { + return detail; + } + } + return message; + } + + return String(error); +} + +export function parseStoredDelivery(entries: unknown): BriefingDeliveryResult[] { + if (!Array.isArray(entries)) { + return []; + } + + return entries + .map(entry => asObject(entry)) + .map(entry => { + const channel = + entry.channel === 'telegram' || entry.channel === 'discord' || entry.channel === 'slack' + ? entry.channel + : null; + const status = + entry.status === 'sent' || entry.status === 'skipped' || entry.status === 'failed' + ? entry.status + : null; + if (!channel || !status) { + return null; + } + + const reason = + entry.reason === 'missing_target' || + entry.reason === 'ambiguous_target' || + entry.reason === 'send_failed' || + entry.reason === 'config_unavailable' + ? entry.reason + : undefined; + + return { + channel, + status, + target: typeof entry.target === 'string' ? entry.target : undefined, + accountId: typeof entry.accountId === 'string' ? entry.accountId : undefined, + reason, + error: typeof entry.error === 'string' ? entry.error : undefined, + } satisfies BriefingDeliveryResult; + }) + .filter((entry): entry is BriefingDeliveryResult => entry !== null); +} + +export function formatDeliverySummary(delivery: BriefingDeliveryResult[]): string[] { + if (delivery.length === 0) { + return ['- delivery: no configured messaging channels found']; + } + + return delivery.map(entry => { + const targetSuffix = entry.target ? ` (${entry.target})` : ''; + if (entry.status === 'sent') { + return `- delivery: ${entry.channel} sent${targetSuffix}`; + } + if (entry.status === 'skipped') { + return `- delivery: ${entry.channel} skipped (${entry.reason ?? 'unknown'})`; + } + return `- delivery: ${entry.channel} failed${targetSuffix}${entry.error ? `: ${entry.error}` : ''}`; + }); +} + +export function logDeliveryOutcomeEvents( + api: Pick, + delivery: BriefingDeliveryResult[] +): void { + for (const entry of delivery) { + const reason = entry.reason ?? 'none'; + const target = entry.target ?? 'none'; + const eventLine = + `event=morning_briefing_delivery_outcome` + + ` outcome=${entry.status}` + + ` channel=${entry.channel}` + + ` reason=${reason}` + + ` target=${target}`; + api.logger.info?.(eventLine); + if (entry.status === 'failed') { + const detail = entry.error ?? 'unknown_error'; + api.logger.warn?.( + `event=morning_briefing_delivery_failure channel=${entry.channel} detail=${detail}` + ); + } + } +} + +export async function deliverBriefingToConfiguredChannels( + api: DeliveryApi, + markdown: string +): Promise { + const messageText = formatBriefingMarkdownForMessage(markdown); + if (!messageText) { + return []; + } + + let channelsConfig: Record; + try { + channelsConfig = await readChannelsConfig(api); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + api.logger.warn?.(`Morning briefing delivery config read failed: ${errorText}`); + return failedDeliveryForAllChannels(errorText); + } + + const delivery: BriefingDeliveryResult[] = []; + const routes: DeliveryRoute[] = []; + + for (const channel of DELIVERY_CHANNELS) { + const resolution = resolveDeliveryRoute({ channel, channelsConfig }); + if (!resolution.configured) { + continue; + } + + if (!resolution.route) { + delivery.push({ + channel, + status: 'skipped', + reason: resolution.skipReason ?? 'missing_target', + }); + continue; + } + + routes.push(resolution.route); + } + + for (const route of routes) { + const argv = createSendCommand({ route, messageText }); + try { + await sendWithRetry(api, argv); + delivery.push({ + channel: route.channel, + status: 'sent', + target: route.target, + accountId: route.accountId, + }); + } catch (error) { + delivery.push({ + channel: route.channel, + status: 'failed', + reason: 'send_failed', + target: route.target, + accountId: route.accountId, + error: summarizeDeliveryError(error), + }); + } + } + + return delivery; +} diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts index 8d671dd72..ffe91ed67 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.lifecycle.test.ts @@ -33,7 +33,16 @@ type TestHarness = { commandHandler: (ctx: { args?: string }) => Promise<{ text: string }>; statusHttpHandler: (_req: unknown, res: FakeResponse) => Promise; enableHttpHandler: (req: unknown, res: FakeResponse) => Promise; + runHttpHandler: (_req: unknown, res: FakeResponse) => Promise; cronJobs: CronJob[]; + sentMessages: Array<{ + channel: string; + target: string; + accountId?: string; + message: string; + }>; + loggerInfo: ReturnType; + loggerWarn: ReturnType; runCommandWithTimeout: ReturnType; }; @@ -63,6 +72,12 @@ async function createHarness(options?: { disableCommandFails?: boolean; preloadedConfig?: Record; preloadedStatus?: Record; + githubAuthReady?: boolean; + githubIssues?: Array<{ title: string; url: string; updatedAt?: string }>; + channelsConfig?: Record; + messageSendFailures?: Partial>; + messageSendFailureCounts?: Partial>; + omitRuntimeChannelsConfig?: boolean; }): Promise { const { default: morningBriefingPlugin } = await import('./index'); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'morning-briefing-')); @@ -86,11 +101,81 @@ async function createHarness(options?: { let sequence = 0; const cronJobs: CronJob[] = []; + const sentMessages: Array<{ + channel: string; + target: string; + accountId?: string; + message: string; + }> = []; const runCommandWithTimeout = vi.fn(async (argv: string[]) => { if (argv[0] === 'gh' && argv[1] === 'auth' && argv[2] === 'status') { + if (options?.githubAuthReady) { + return { stdout: '', stderr: '', code: 0 }; + } return { stdout: '', stderr: 'not authenticated', code: 1 }; } + if (argv[0] === 'gh' && argv[1] === 'search' && argv[2] === 'issues') { + return { + stdout: JSON.stringify(options?.githubIssues ?? []), + stderr: '', + code: 0, + }; + } + + if ( + argv[0] === 'openclaw' && + argv[1] === 'config' && + argv[2] === 'get' && + argv[3] === 'channels' + ) { + if (!options?.channelsConfig) { + return { + stdout: '', + stderr: 'Config path not found: channels', + code: 1, + }; + } + return { + stdout: JSON.stringify(options.channelsConfig), + stderr: '', + code: 0, + }; + } + + if (argv[0] === 'openclaw' && argv[1] === 'message' && argv[2] === 'send') { + const channelIndex = argv.indexOf('--channel'); + const targetIndex = argv.indexOf('--target'); + const messageIndex = argv.indexOf('--message'); + const accountIndex = argv.indexOf('--account'); + const channel = channelIndex >= 0 ? (argv[channelIndex + 1] ?? '') : ''; + const target = targetIndex >= 0 ? (argv[targetIndex + 1] ?? '') : ''; + const message = messageIndex >= 0 ? (argv[messageIndex + 1] ?? '') : ''; + const accountId = accountIndex >= 0 ? argv[accountIndex + 1] : undefined; + if (channel && target && message) { + sentMessages.push({ channel, target, accountId, message }); + } + const configuredFailure = + channel === 'telegram' || channel === 'discord' || channel === 'slack' + ? options?.messageSendFailures?.[channel] + : undefined; + const configuredFailureCount = + channel === 'telegram' || channel === 'discord' || channel === 'slack' + ? options?.messageSendFailureCounts?.[channel] + : undefined; + if (configuredFailure && configuredFailureCount && configuredFailureCount > 0) { + if (!options?.messageSendFailureCounts) { + return { stdout: '', stderr: configuredFailure, code: 1 }; + } + options.messageSendFailureCounts[channel] = configuredFailureCount - 1; + return { stdout: '', stderr: configuredFailure, code: 1 }; + } + if (configuredFailure && configuredFailureCount === undefined) { + return { stdout: '', stderr: configuredFailure, code: 1 }; + } + return { stdout: JSON.stringify({ ok: true }), stderr: '', code: 0 }; + } + if (argv[0] === 'openclaw' && argv[1] === 'cron') { const subcommand = argv[2]; @@ -156,6 +241,9 @@ async function createHarness(options?: { let commandHandler: ((ctx: { args?: string }) => Promise<{ text: string }>) | null = null; let statusHttpHandler: ((_req: unknown, res: FakeResponse) => Promise) | null = null; let enableHttpHandler: ((req: unknown, res: FakeResponse) => Promise) | null = null; + let runHttpHandler: ((_req: unknown, res: FakeResponse) => Promise) | null = null; + const loggerInfo = vi.fn(); + const loggerWarn = vi.fn(); morningBriefingPlugin.register({ runtime: { @@ -168,8 +256,9 @@ async function createHarness(options?: { }, config: { agents: { defaults: { userTimezone: 'America/Chicago' } }, + ...(options?.omitRuntimeChannelsConfig ? {} : { channels: options?.channelsConfig ?? {} }), }, - logger: { warn: vi.fn() }, + logger: { info: loggerInfo, warn: loggerWarn }, registerCommand: (def: { handler: (ctx: { args?: string }) => Promise<{ text: string }> }) => { commandHandler = def.handler; }, @@ -181,13 +270,15 @@ async function createHarness(options?: { statusHttpHandler = route.handler; } else if (route.path.endsWith('/enable')) { enableHttpHandler = route.handler; + } else if (route.path.endsWith('/run')) { + runHttpHandler = route.handler; } }, registerTool: vi.fn(), on: vi.fn(), } as never); - if (!commandHandler || !statusHttpHandler || !enableHttpHandler) { + if (!commandHandler || !statusHttpHandler || !enableHttpHandler || !runHttpHandler) { throw new Error('Failed to register command or HTTP handlers'); } @@ -196,7 +287,11 @@ async function createHarness(options?: { commandHandler, statusHttpHandler, enableHttpHandler, + runHttpHandler, cronJobs, + sentMessages, + loggerInfo, + loggerWarn, runCommandWithTimeout, }; } @@ -431,4 +526,420 @@ describe('morning briefing lifecycle', () => { const config = await readJson(path.join(harness.stateDir, 'morning-briefing', 'config.json')); expect(config.timezone).toBe('UTC'); }); + + it('sends adapted briefing message to configured channel targets and persists delivery metadata', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Fix failing deploy workflow', + url: 'https://github.com/Kilo-Org/cloud/issues/123', + updatedAt: '2026-04-24T10:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + defaultTo: '-100123456', + }, + discord: { + enabled: true, + accounts: { + default: { + defaultTo: 'channel:1234567890', + }, + }, + }, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; target?: string; accountId?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-100123456' }), + expect.objectContaining({ + channel: 'discord', + status: 'sent', + target: 'channel:1234567890', + accountId: 'default', + }), + ]) + ); + + expect(harness.sentMessages).toHaveLength(2); + for (const sent of harness.sentMessages) { + expect(sent.message).toContain('Morning Briefing -'); + expect(sent.message).toContain('GitHub'); + expect(sent.message).toContain('• '); + expect(sent.message).not.toContain('# '); + expect(sent.message).toContain('https://github.com/Kilo-Org/cloud/issues/123'); + expect(sent.message).not.toContain('Repository:'); + } + + const statusPayload = new FakeResponse(); + await harness.statusHttpHandler({}, statusPayload); + const statusBody = JSON.parse(statusPayload.body) as { + ok: boolean; + lastDelivery?: Array<{ channel: string; status: string }>; + }; + expect(statusBody.ok).toBe(true); + expect(statusBody.lastDelivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', status: 'sent' }), + expect.objectContaining({ channel: 'discord', status: 'sent' }), + ]) + ); + }); + + it('marks missing default targets as skipped and send errors as failed without failing run', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Investigate queue latency', + url: 'https://github.com/Kilo-Org/cloud/issues/456', + updatedAt: '2026-04-24T12:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + }, + slack: { + enabled: true, + defaultTo: 'channel:C123', + }, + }, + messageSendFailures: { + slack: 'slack send failed', + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; reason?: string; error?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channel: 'telegram', + status: 'skipped', + reason: 'missing_target', + }), + expect.objectContaining({ + channel: 'slack', + status: 'failed', + reason: 'send_failed', + }), + ]) + ); + const slackFailure = payload.delivery?.find(entry => entry.channel === 'slack'); + expect(slackFailure?.error).toBe('slack send failed'); + }); + + it('uses single configured telegram group as fallback target when defaultTo is missing', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Review release checklist', + url: 'https://github.com/Kilo-Org/cloud/issues/789', + updatedAt: '2026-04-24T13:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + groups: { + '-5055658641': { + requireMention: false, + }, + }, + }, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; target?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channel: 'telegram', + status: 'sent', + target: '-5055658641', + }), + ]) + ); + }); + + it('skips with ambiguous_target when multiple fallback destinations are available', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Investigate flaky integration test', + url: 'https://github.com/Kilo-Org/cloud/issues/790', + updatedAt: '2026-04-24T14:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + groups: { + '-5055658641': { + requireMention: false, + }, + '-5055658642': { + requireMention: false, + }, + }, + }, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; reason?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channel: 'telegram', + status: 'skipped', + reason: 'ambiguous_target', + }), + ]) + ); + expect(harness.sentMessages).toHaveLength(0); + }); + + it('uses runtime config channels for delivery without shelling out', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Confirm runtime config path', + url: 'https://github.com/Kilo-Org/cloud/issues/800', + updatedAt: '2026-04-24T15:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + groups: { + '-5055658641': { + requireMention: false, + }, + }, + }, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; target?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-5055658641' }), + ]) + ); + expect( + harness.runCommandWithTimeout.mock.calls.some( + call => + Array.isArray(call[0]) && + call[0][0] === 'openclaw' && + call[0][1] === 'config' && + call[0][2] === 'get' && + call[0][3] === 'channels' + ) + ).toBe(false); + }); + + it('falls back to CLI channel config when runtime channels are unavailable', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Confirm CLI fallback path', + url: 'https://github.com/Kilo-Org/cloud/issues/801', + updatedAt: '2026-04-24T15:10:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + groups: { + '-5055658641': { + requireMention: false, + }, + }, + }, + }, + omitRuntimeChannelsConfig: true, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string; target?: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([ + expect.objectContaining({ channel: 'telegram', status: 'sent', target: '-5055658641' }), + ]) + ); + expect( + harness.runCommandWithTimeout.mock.calls.some( + call => + Array.isArray(call[0]) && + call[0][0] === 'openclaw' && + call[0][1] === 'config' && + call[0][2] === 'get' && + call[0][3] === 'channels' + ) + ).toBe(true); + }); + + it('retries timed-out delivery once before marking send_failed', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Retry flaky channel send', + url: 'https://github.com/Kilo-Org/cloud/issues/900', + updatedAt: '2026-04-25T00:00:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + defaultTo: '-5055658641', + }, + }, + messageSendFailures: { + telegram: 'The operation was aborted due to timeout', + }, + messageSendFailureCounts: { + telegram: 1, + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.body) as { + ok: boolean; + delivery?: Array<{ channel: string; status: string }>; + }; + expect(payload.ok).toBe(true); + expect(payload.delivery).toEqual( + expect.arrayContaining([expect.objectContaining({ channel: 'telegram', status: 'sent' })]) + ); + + const sendCalls = harness.runCommandWithTimeout.mock.calls.filter( + call => + Array.isArray(call[0]) && + call[0][0] === 'openclaw' && + call[0][1] === 'message' && + call[0][2] === 'send' + ); + expect(sendCalls).toHaveLength(2); + expect(sendCalls[0]?.[1]).toMatchObject({ timeoutMs: 120_000 }); + expect(sendCalls[1]?.[1]).toMatchObject({ timeoutMs: 120_000 }); + }); + + it('emits delivery outcome metric logs for sent/skipped/failed results', async () => { + const harness = await createHarness({ + githubAuthReady: true, + githubIssues: [ + { + title: 'Delivery observability smoke check', + url: 'https://github.com/Kilo-Org/cloud/issues/910', + updatedAt: '2026-04-25T00:10:00Z', + }, + ], + channelsConfig: { + telegram: { + enabled: true, + defaultTo: '-5055658641', + }, + discord: { + enabled: true, + }, + slack: { + enabled: true, + defaultTo: 'channel:C123', + }, + }, + messageSendFailures: { + slack: 'slack send failed', + }, + }); + + const response = new FakeResponse(); + await harness.runHttpHandler({}, response); + expect(response.statusCode).toBe(200); + + const infoMessages = harness.loggerInfo.mock.calls.map(call => String(call[0])); + expect( + infoMessages.some(message => + message.includes('event=morning_briefing_delivery_outcome outcome=sent channel=telegram') + ) + ).toBe(true); + expect( + infoMessages.some(message => + message.includes('event=morning_briefing_delivery_outcome outcome=skipped channel=discord') + ) + ).toBe(true); + expect( + infoMessages.some(message => + message.includes('event=morning_briefing_delivery_outcome outcome=failed channel=slack') + ) + ).toBe(true); + + const warnMessages = harness.loggerWarn.mock.calls.map(call => String(call[0])); + expect( + warnMessages.some(message => + message.includes( + 'event=morning_briefing_delivery_failure channel=slack detail=slack send failed' + ) + ) + ).toBe(true); + }); }); diff --git a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts index cb1bc30a8..4c1fed24d 100644 --- a/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts +++ b/services/kiloclaw/plugins/kiloclaw-morning-briefing/src/index.ts @@ -3,6 +3,15 @@ import path from 'node:path'; import { Type } from '@sinclair/typebox'; import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'; import { buildBriefingMarkdown, offsetDateKey, resolveBriefingPath } from './briefing-utils'; +import { + type BriefingDeliveryResult, + deliverBriefingToConfiguredChannels, + formatDeliverySummary, + logDeliveryOutcomeEvents, + parseStoredDelivery, +} from './delivery-utils'; +import { DELIVERY_CHANNELS } from './delivery-constants'; +import { CommandExecutionError, runCommand } from './command-utils'; import { filterEnabledBriefingJobs, pickCanonicalCronJobId, @@ -41,6 +50,7 @@ type StoredStatus = { lastPath: string | null; sourceSummary: Array<{ source: string; configured: boolean; ok: boolean; summary: string }>; failures: string[]; + lastDelivery: BriefingDeliveryResult[]; observedEnabled: boolean | null; reconcileState: 'idle' | 'in_progress' | 'succeeded' | 'failed'; lastReconcileAt: string | null; @@ -131,30 +141,6 @@ async function writeJsonFile(filePath: string, value: unknown): Promise { await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8'); } -async function runCommand( - api: { - runtime: { - system: { - runCommandWithTimeout: ( - argv: string[], - options: { timeoutMs: number; cwd?: string } - ) => Promise<{ stdout: string; stderr: string; code: number | null }>; - }; - }; - }, - argv: string[], - timeoutMs = 30_000 -): Promise<{ stdout: string; stderr: string }> { - const result = await api.runtime.system.runCommandWithTimeout(argv, { - timeoutMs, - }); - if (result.code !== 0) { - const message = result.stderr.trim() || result.stdout.trim() || 'Command failed'; - throw new Error(`${argv.join(' ')} failed: ${message}`); - } - return { stdout: result.stdout, stderr: result.stderr }; -} - async function runCronJson( api: { runtime: { @@ -169,11 +155,24 @@ async function runCronJson( argv: string[] ): Promise> { const [subcommand = ''] = argv; - const jsonUnsupported = subcommand === 'disable'; + const jsonUnsupported = subcommand === 'disable' || subcommand === 'edit'; const command = jsonUnsupported ? ['openclaw', 'cron', ...argv] : ['openclaw', 'cron', ...argv, '--json']; - const { stdout } = await runCommand(api, command, 60_000); + let stdout: string; + try { + ({ stdout } = await runCommand(api, command, 60_000)); + } catch (error) { + if ( + !jsonUnsupported && + error instanceof CommandExecutionError && + error.stderr.includes("unknown option '--json'") + ) { + ({ stdout } = await runCommand(api, ['openclaw', 'cron', ...argv], 60_000)); + } else { + throw error; + } + } try { return asObject(JSON.parse(stdout)); } catch { @@ -228,7 +227,7 @@ async function removeDuplicateBriefingCronJobs( ) => Promise<{ stdout: string; stderr: string; code: number | null }>; }; }; - logger: { warn?: (message: string) => void }; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }, canonicalId: string ): Promise { @@ -268,7 +267,7 @@ function resolveDefaults(api: { } function resolveEffectiveTimezone( - api: { logger: { warn?: (message: string) => void } }, + api: { logger: { info?: (message: string) => void; warn?: (message: string) => void } }, timezone: string, context: 'enable' | 'schedule' | 'date' ): string { @@ -329,6 +328,7 @@ async function readStoredStatus(paths: { statusPath: string }): Promise typeof value === 'string') : [], + lastDelivery: parseStoredDelivery(existing.lastDelivery), observedEnabled: typeof existing.observedEnabled === 'boolean' ? existing.observedEnabled : null, reconcileState: @@ -408,7 +409,7 @@ async function ensureCronJob( ) => Promise<{ stdout: string; stderr: string; code: number | null }>; }; }; - logger: { warn?: (message: string) => void }; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }, config: StoredConfig ): Promise<{ cronJobId: string; cron: string; timezone: string }> { @@ -669,13 +670,7 @@ async function collectWebSearch(api: { configured: true, ok: true, summary: `Fetched ${results.length} web results (${response.provider})`, - sectionLines: results - .slice(0, 6) - .map(item => - item.summary.length > 0 - ? `- [${item.title}](${item.url}) - ${item.summary}` - : `- [${item.title}](${item.url})` - ), + sectionLines: results.slice(0, 6).map(item => `- [${item.title}](${item.url})`), }; } catch (error) { return { @@ -793,6 +788,7 @@ async function generateBriefing( }; }; config: unknown; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }, dateKey: string ): Promise<{ @@ -801,6 +797,7 @@ async function generateBriefing( markdown: string; sources: SourceCollectionResult[]; failures: string[]; + delivery: BriefingDeliveryResult[]; }> { const paths = getStatePaths(api); await ensureStorage(paths); @@ -841,6 +838,20 @@ async function generateBriefing( const filePath = resolveBriefingPath(paths.briefingsDir, dateKey); await fs.writeFile(filePath, markdown, 'utf8'); + let delivery: BriefingDeliveryResult[]; + try { + delivery = await deliverBriefingToConfiguredChannels(api, markdown); + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error); + api.logger.warn?.(`Morning briefing delivery failed unexpectedly: ${errorText}`); + delivery = DELIVERY_CHANNELS.map(channel => ({ + channel, + status: 'failed', + reason: 'config_unavailable', + error: errorText, + })); + } + logDeliveryOutcomeEvents(api, delivery); await patchStoredStatus(paths, { lastGeneratedDate: dateKey, @@ -853,6 +864,7 @@ async function generateBriefing( summary: source.summary, })), failures, + lastDelivery: delivery, }); return { @@ -861,6 +873,7 @@ async function generateBriefing( markdown, sources, failures, + delivery, }; } @@ -900,7 +913,7 @@ async function resolveDateKeyForOffset( }; }; pluginConfig?: Record; - logger: { warn?: (message: string) => void }; + logger: { info?: (message: string) => void; warn?: (message: string) => void }; }, offset: number ): Promise { @@ -944,6 +957,7 @@ async function getStatusSnapshot(api: { linear: { configured: boolean; summary: string }; web: { configured: boolean; summary: string }; }; + lastDelivery: BriefingDeliveryResult[]; reconcileState: 'idle' | 'in_progress' | 'succeeded' | 'failed'; lastReconcileAt: string | null; lastReconcileError: string | null; @@ -971,6 +985,7 @@ async function getStatusSnapshot(api: { linear, web, }, + lastDelivery: status.lastDelivery, reconcileState: status.reconcileState, lastReconcileAt: status.lastReconcileAt, lastReconcileError: status.lastReconcileError, @@ -1218,6 +1233,7 @@ export default definePluginEntry({ `Generated briefing for ${result.dateKey}.`, `- file: ${result.filePath}`, ...result.failures.map(failure => `- note: ${failure}`), + ...formatDeliverySummary(result.delivery), ].join('\n'); } @@ -1322,6 +1338,7 @@ export default definePluginEntry({ `Morning briefing generated for ${result.dateKey}.`, `Saved to ${result.filePath}.`, ...result.failures.map(failure => `Note: ${failure}`), + ...formatDeliverySummary(result.delivery).map(line => line.replace(/^- /, '')), ].join('\n'), }, ], @@ -1468,6 +1485,7 @@ export default definePluginEntry({ date: result.dateKey, filePath: result.filePath, failures: result.failures, + delivery: result.delivery, }); } catch (error) { sendJson(res, 500, { diff --git a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts index e1665a73e..74eb6c496 100644 --- a/services/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/services/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -1,4 +1,9 @@ import { z, type ZodType } from 'zod'; +import { + DELIVERY_CHANNELS, + DELIVERY_REASONS, + DELIVERY_STATUSES, +} from '../../plugins/kiloclaw-morning-briefing/src/delivery-constants'; export type GatewayProcessStatus = { state: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'shutting_down'; @@ -104,6 +109,15 @@ const MorningBriefingSourceReadinessSchema = z.object({ summary: z.string(), }); +const MorningBriefingDeliverySchema = z.object({ + channel: z.enum(DELIVERY_CHANNELS), + status: z.enum(DELIVERY_STATUSES), + target: z.string().optional(), + accountId: z.string().optional(), + reason: z.enum(DELIVERY_REASONS).optional(), + error: z.string().optional(), +}); + export const MorningBriefingStatusResponseSchema = z.object({ ok: z.boolean(), enabled: z.boolean().optional(), @@ -125,6 +139,7 @@ export const MorningBriefingStatusResponseSchema = z.object({ web: MorningBriefingSourceReadinessSchema, }) .optional(), + lastDelivery: z.array(MorningBriefingDeliverySchema).optional(), code: z.string().optional(), retryAfterSec: z.number().int().positive().optional(), error: z.string().optional(), @@ -139,6 +154,7 @@ export const MorningBriefingActionResponseSchema = z.object({ date: z.string().optional(), filePath: z.string().optional(), failures: z.array(z.string()).optional(), + delivery: z.array(MorningBriefingDeliverySchema).optional(), code: z.string().optional(), retryAfterSec: z.number().int().positive().optional(), error: z.string().optional(), diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts index d3b794bd6..ccd206fa0 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { deriveGatewayToken } from '../../auth/gateway-token'; import { createMutableState } from './state'; -import { getGatewayProcessStatus, getMorningBriefingStatus, waitForHealthy } from './gateway'; +import { + getGatewayProcessStatus, + getMorningBriefingStatus, + runMorningBriefing, + waitForHealthy, +} from './gateway'; import { GatewayControllerError } from '../gateway-controller-types'; type FetchMock = ReturnType< @@ -136,7 +141,6 @@ describe('gateway controller routing', () => { expect(result).toEqual({ ok: true, - enabled: false, reconcileState: 'in_progress', error: 'Gateway warming up, retrying shortly.', code: 'gateway_warming_up', @@ -166,4 +170,48 @@ describe('gateway controller routing', () => { } as never) ).rejects.toBeInstanceOf(GatewayControllerError); }); + + it('accepts run response with delivery metadata', async () => { + const state = createMutableState(); + state.provider = 'fly'; + state.sandboxId = 'sandbox-1'; + state.flyAppName = 'test-app'; + state.flyMachineId = 'machine-1'; + + const fetchMock: FetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + ok: true, + date: '2026-04-24', + filePath: '/tmp/morning-briefing/2026-04-24.md', + failures: [], + delivery: [ + { channel: 'telegram', status: 'sent', target: '-100123' }, + { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' }, + ], + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ) + ); + vi.stubGlobal('fetch', fetchMock); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + + const result = await runMorningBriefing(state, { + GATEWAY_TOKEN_SECRET: 'gateway-secret', + FLY_APP_NAME: 'fallback-app', + } as never); + + expect(result).toMatchObject({ + ok: true, + date: '2026-04-24', + delivery: [ + { channel: 'telegram', status: 'sent', target: '-100123' }, + { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' }, + ], + }); + expect(timeoutSpy).toHaveBeenCalledWith(120_000); + }); }); diff --git a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts index e0de463de..164d8208a 100644 --- a/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts +++ b/services/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts @@ -509,7 +509,6 @@ export async function getMorningBriefingStatus( if (isMorningBriefingWarmupControllerError(error)) { return { ok: true, - enabled: false, reconcileState: 'in_progress', error: 'Gateway warming up, retrying shortly.', code: 'gateway_warming_up', @@ -572,7 +571,8 @@ export async function runMorningBriefing( '/_kilo/morning-briefing/run', 'POST', MorningBriefingActionResponseSchema, - {} + {}, + { timeoutMs: 120_000 } ); } catch (error) { if (isErrorUnknownRoute(error)) return null; diff --git a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts index 5b1a31c78..86469cf81 100644 --- a/services/kiloclaw/src/routes/platform-morning-briefing.test.ts +++ b/services/kiloclaw/src/routes/platform-morning-briefing.test.ts @@ -121,4 +121,119 @@ describe('platform morning-briefing warm-up handling', () => { expect(response.status).toBe(500); expect(enableMorningBriefing).toHaveBeenCalledTimes(1); }); + + it('returns delivery metadata from run endpoint', async () => { + const runMorningBriefing = vi.fn<() => Promise>().mockResolvedValue({ + ok: true, + date: '2026-04-24', + filePath: '/tmp/morning-briefing/2026-04-24.md', + failures: [], + delivery: [ + { channel: 'telegram', status: 'sent', target: '-100123' }, + { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' }, + ], + }); + const env = baseEnv({ runMorningBriefing }); + + const response = await platform.request( + '/morning-briefing/run', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: 'user-1' }), + }, + env + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + date: '2026-04-24', + delivery: [ + { channel: 'telegram', status: 'sent', target: '-100123' }, + { channel: 'discord', status: 'skipped', reason: 'ambiguous_target' }, + ], + }); + }); + + it('retries run when gateway is warming up and then succeeds', async () => { + const runMorningBriefing = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('Failed to reach gateway')) + .mockResolvedValueOnce({ + ok: true, + date: '2026-04-24', + filePath: '/tmp/morning-briefing/2026-04-24.md', + failures: [], + }); + const env = baseEnv({ runMorningBriefing }); + + const requestPromise = platform.request( + '/morning-briefing/run', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: 'user-1' }), + }, + env + ); + + await vi.runAllTimersAsync(); + const response = await requestPromise; + + expect(response.status).toBe(200); + expect(runMorningBriefing).toHaveBeenCalledTimes(2); + }); + + it('does not retry run when timeout occurs and returns dedicated timeout code', async () => { + const runMorningBriefing = vi + .fn<() => Promise>() + .mockRejectedValue( + new Error('Gateway controller request failed: The operation was aborted due to timeout') + ); + const env = baseEnv({ runMorningBriefing }); + + const response = await platform.request( + '/morning-briefing/run', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: 'user-1' }), + }, + env + ); + + expect(response.status).toBe(504); + expect(await response.json()).toMatchObject({ + code: 'morning_briefing_run_timeout', + }); + expect(runMorningBriefing).toHaveBeenCalledTimes(1); + }); + + it('returns timeout code for run timeout instead of generic 500', async () => { + const runMorningBriefing = vi + .fn<() => Promise>() + .mockRejectedValue( + new Error('Gateway controller request failed: The operation was aborted due to timeout') + ); + const env = baseEnv({ runMorningBriefing }); + + const requestPromise = platform.request( + '/morning-briefing/run', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ userId: 'user-1' }), + }, + env + ); + + await vi.runAllTimersAsync(); + const response = await requestPromise; + + expect(response.status).toBe(504); + expect(await response.json()).toMatchObject({ + code: 'morning_briefing_run_timeout', + }); + }); }); diff --git a/services/kiloclaw/src/routes/platform.ts b/services/kiloclaw/src/routes/platform.ts index 974a6d92d..5eb755baf 100644 --- a/services/kiloclaw/src/routes/platform.ts +++ b/services/kiloclaw/src/routes/platform.ts @@ -1917,21 +1917,39 @@ const MorningBriefingSetupSchema = z.object({ timezone: z.string().min(1).optional(), }); -function isMorningBriefingWarmupError(err: unknown): boolean { +type MorningBriefingWarmupRetryPolicy = { + includeTimeout: boolean; +}; + +function isMorningBriefingTimeoutError(err: unknown): boolean { const raw = err instanceof Error ? err.message : String(err); const normalized = raw.replace(/^(?:[A-Za-z]+Error:\s*)+/, ''); - return ( + return normalized.includes('operation was aborted due to timeout'); +} + +function isMorningBriefingWarmupError( + err: unknown, + policy: MorningBriefingWarmupRetryPolicy = { includeTimeout: true } +): boolean { + const raw = err instanceof Error ? err.message : String(err); + const normalized = raw.replace(/^(?:[A-Za-z]+Error:\s*)+/, ''); + if ( normalized.includes('Gateway not running') || - normalized.includes('Failed to reach gateway') || - normalized.includes('operation was aborted due to timeout') - ); + normalized.includes('Failed to reach gateway') + ) { + return true; + } + return policy.includeTimeout && isMorningBriefingTimeoutError(err); } async function sleep(ms: number): Promise { await new Promise(resolve => setTimeout(resolve, ms)); } -async function withMorningBriefingWarmupRetry(operation: () => Promise): Promise { +async function withMorningBriefingWarmupRetry( + operation: () => Promise, + policy: MorningBriefingWarmupRetryPolicy = { includeTimeout: true } +): Promise { const delaysMs = [0, 750, 1500]; let lastError: unknown = null; @@ -1944,7 +1962,7 @@ async function withMorningBriefingWarmupRetry(operation: () => Promise): P return await operation(); } catch (err) { lastError = err; - if (!isMorningBriefingWarmupError(err)) { + if (!isMorningBriefingWarmupError(err, policy)) { throw err; } } @@ -1984,7 +2002,6 @@ platform.get('/morning-briefing/status', async c => { return c.json( { ok: true, - enabled: false, reconcileState: 'in_progress', error: 'Gateway warming up, retrying shortly.', code: 'gateway_warming_up', @@ -2078,12 +2095,16 @@ platform.post('/morning-briefing/run', async c => { if ('error' in iidResult) return iidResult.error; try { - const response = await withResolvedDORetry( - c.env, - result.data.userId, - iidResult.instanceId, - stub => stub.runMorningBriefing(), - 'runMorningBriefing' + const response = await withMorningBriefingWarmupRetry( + () => + withResolvedDORetry( + c.env, + result.data.userId, + iidResult.instanceId, + stub => stub.runMorningBriefing(), + 'runMorningBriefing' + ), + { includeTimeout: false } ); if (!response) { return jsonError( @@ -2094,6 +2115,16 @@ platform.post('/morning-briefing/run', async c => { } return c.json(response, 200); } catch (err) { + if (isMorningBriefingWarmupError(err, { includeTimeout: false })) { + return jsonError('Gateway warming up, retrying shortly.', 503, 'gateway_warming_up'); + } + if (isMorningBriefingTimeoutError(err)) { + return jsonError( + 'Morning Briefing run timed out while work may still be in progress. Check Last generated and Last delivery before retrying.', + 504, + 'morning_briefing_run_timeout' + ); + } const { message, status, code } = sanitizeOpenclawConfigError(err, 'morning-briefing/run'); return jsonError(message, status, code); }