type JsonRecord = Record; export type CronDesktopReminderTone = 'success' | 'warning' | 'danger'; export interface CronDesktopReminderEvent { jobId: string; agentId: string; sessionKey: string; runKey: string; phase: string; tone: CronDesktopReminderTone; summary?: string; error?: string; } export interface CronDesktopReminderJob { id: string; name?: string; state?: { lastStatus?: string; lastError?: string; }; } export interface CronDesktopReminderDisplay { title: string; body: string; tone: CronDesktopReminderTone; route: string; } export interface CronDesktopReminderHandlerDeps { resolveJob?: (jobId: string) => Promise; notify: (display: CronDesktopReminderDisplay, event: CronDesktopReminderEvent) => void; now?: () => number; dedupeMs?: number; } interface ParsedCronSessionKey { agentId: string; jobId: string; runSessionId?: string; fixedSessionKey: string; } function asRecord(value: unknown): JsonRecord | undefined { return value && typeof value === 'object' ? value as JsonRecord : undefined; } function readString(record: JsonRecord | undefined, key: string): string | undefined { const value = record?.[key]; return typeof value === 'string' && value.trim() ? value.trim() : undefined; } function parseCronSessionKey(value: unknown): ParsedCronSessionKey | null { if (typeof value !== 'string' || !value.startsWith('agent:')) return null; const parts = value.split(':'); if (parts.length < 4 || parts[2] !== 'cron') return null; const agentId = parts[1] || 'main'; const jobId = parts[3]; if (!jobId) return null; const runSessionId = parts.length >= 6 && parts[4] === 'run' && parts[5] ? parts[5] : undefined; return { agentId, jobId, ...(runSessionId ? { runSessionId } : {}), fixedSessionKey: `agent:${agentId}:cron:${jobId}`, }; } function getNestedRecord(record: JsonRecord | undefined, key: string): JsonRecord | undefined { return asRecord(record?.[key]); } function isTerminalCronPhase(phase: string | undefined): boolean { if (!phase) return false; return ['completed', 'done', 'finished', 'failed', 'error'].includes(phase.toLowerCase()); } function normalizeStatus(value: string | undefined): CronDesktopReminderTone | undefined { const normalized = value?.trim().toLowerCase(); if (!normalized) return undefined; if (['error', 'failed', 'failure', 'cancelled', 'timeout'].includes(normalized)) return 'danger'; if (['warning', 'warn', 'reconciled', 'partial'].includes(normalized)) return 'warning'; if (['ok', 'success', 'succeeded', 'completed', 'done', 'finished'].includes(normalized)) return 'success'; return undefined; } function firstText(values: Array): string | undefined { for (const value of values) { const text = extractText(value); if (text) return text; } return undefined; } function extractText(value: unknown): string | undefined { if (typeof value === 'string') return value.trim() || undefined; if (Array.isArray(value)) { const text = value.map(extractText).filter(Boolean).join('\n').trim(); return text || undefined; } const record = asRecord(value); if (!record) return undefined; return firstText([ record.content, record.text, record.message, record.summary, record.error, ]); } function inferTone(phase: string | undefined, params: JsonRecord | undefined, data: JsonRecord | undefined): CronDesktopReminderTone { const state = getNestedRecord(params, 'state') ?? getNestedRecord(data, 'state'); const tone = normalizeStatus(readString(data, 'status')) ?? normalizeStatus(readString(params, 'status')) ?? normalizeStatus(readString(data, 'finalStatus')) ?? normalizeStatus(readString(params, 'finalStatus')) ?? normalizeStatus(readString(state, 'status')) ?? normalizeStatus(readString(state, 'lastStatus')) ?? normalizeStatus(phase); if (tone) return tone; if (readString(data, 'error') || readString(params, 'error') || readString(state, 'lastError')) return 'danger'; return 'success'; } function truncateText(value: string | undefined, maxLength: number): string { const clean = (value || '').replace(/\s+/g, ' ').trim(); if (!clean) return ''; if (clean.length <= maxLength) return clean; return `${clean.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; } export function buildCronDesktopReminderEvent(notification: unknown): CronDesktopReminderEvent | null { const root = asRecord(notification); if (readString(root, 'method') !== 'agent') return null; const params = getNestedRecord(root, 'params'); const data = getNestedRecord(params, 'data'); const phase = (readString(data, 'phase') ?? readString(params, 'phase'))?.toLowerCase(); if (!isTerminalCronPhase(phase)) return null; const parsedSession = parseCronSessionKey(readString(params, 'sessionKey') ?? readString(data, 'sessionKey')); if (!parsedSession) return null; const state = getNestedRecord(params, 'state') ?? getNestedRecord(data, 'state'); const error = firstText([ readString(data, 'error'), readString(params, 'error'), readString(state, 'lastError'), ]); const summary = firstText([ readString(data, 'resolvedSummary'), readString(params, 'resolvedSummary'), readString(data, 'summary'), readString(params, 'summary'), data?.message, params?.message, ]); const runId = readString(params, 'runId') ?? readString(data, 'runId') ?? readString(params, 'sessionId') ?? readString(data, 'sessionId') ?? parsedSession.runSessionId ?? parsedSession.fixedSessionKey; return { jobId: parsedSession.jobId, agentId: parsedSession.agentId, sessionKey: parsedSession.fixedSessionKey, runKey: `${parsedSession.jobId}:${runId}`, phase: phase || 'completed', tone: inferTone(phase, params, data), ...(summary ? { summary: truncateText(summary, 180) } : {}), ...(error ? { error: truncateText(error, 180) } : {}), }; } export function buildCronDesktopReminderDisplay( event: CronDesktopReminderEvent, job?: CronDesktopReminderJob, ): CronDesktopReminderDisplay { const jobName = job?.name?.trim() || event.jobId; const jobTone = normalizeStatus(job?.state?.lastStatus); const tone = jobTone === 'danger' ? 'danger' : event.tone; const error = event.error || job?.state?.lastError; const detail = tone === 'danger' ? error || event.summary : event.summary || error; if (tone === 'danger') { return { tone, title: `任务需要处理:${jobName}`, body: truncateText(detail || '任务运行失败,点击查看执行记录。', 220), route: `/tasks?task=${encodeURIComponent(event.jobId)}`, }; } if (tone === 'warning') { return { tone, title: `任务需复核:${jobName}`, body: truncateText(detail || '任务已完成,但存在需要复核的提示。', 220), route: `/tasks?task=${encodeURIComponent(event.jobId)}`, }; } return { tone, title: `任务已完成:${jobName}`, body: truncateText(detail || '结果已生成,点击查看任务会话。', 220), route: `/tasks?task=${encodeURIComponent(event.jobId)}`, }; } export function createCronDesktopReminderHandler(deps: CronDesktopReminderHandlerDeps) { const notifiedRuns = new Map(); const dedupeMs = deps.dedupeMs ?? 10 * 60 * 1000; const now = deps.now ?? Date.now; return async (notification: unknown): Promise => { const event = buildCronDesktopReminderEvent(notification); if (!event) return null; const currentTime = now(); for (const [key, timestamp] of notifiedRuns) { if (currentTime - timestamp > dedupeMs) { notifiedRuns.delete(key); } } const notifiedAt = notifiedRuns.get(event.runKey); if (notifiedAt != null && currentTime - notifiedAt <= dedupeMs) { return null; } notifiedRuns.set(event.runKey, currentTime); const job = await deps.resolveJob?.(event.jobId).catch(() => undefined); const display = buildCronDesktopReminderDisplay(event, job); deps.notify(display, event); return display; }; }