/** * Delivery-history aggregator for `celilo subscribers status` * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 6). * * Reads recent `webhook.delivered` + `webhook.failed` events from * the local event bus, groups by subscriber URL, and produces a * per-subscriber summary the CLI can render directly. Subscribers * that exist in the static-config store but have no delivery * history yet are still listed (with "no deliveries recorded"). * * The aggregation function is pure — takes already-fetched event * payloads + the subscriber config, returns the summary. The disk * wrapper reads from the bus and delegates. */ import { defineEvents, openBus } from '@celilo/event-bus'; import type { Subscriber } from '@celilo/event-bus/build-bus'; import { getEventBusPath } from '../../config/paths'; import { WEBHOOK_DELIVERED_EVENT, WEBHOOK_FAILED_EVENT, type WebhookDeliveryPayload, } from './delivery-events'; import { loadSubscribers } from './subscriber-store'; const NO_SCHEMAS = defineEvents({}); const DEFAULT_HISTORY_LIMIT = 100; /** * Per-bus-event record the aggregator works with. Decoupled from * BusEvent shape so the pure aggregator doesn't import bus types. */ export interface DeliveryRecord { type: typeof WEBHOOK_DELIVERED_EVENT | typeof WEBHOOK_FAILED_EVENT; emittedAt: number; payload: WebhookDeliveryPayload; } export interface FailureSnapshot { emittedAt: number; eventId: string; packageName: string; packageVersion: string; attempts: number; /** Truncated to keep the status output readable. */ error: string; } export interface SubscriberStatus { url: string; label: string; /** Configured match rule, for the operator to compare against actual deliveries. */ match: Subscriber['match']; totals: { delivered: number; failed: number }; successRatePct: number | null; // null when no deliveries yet /** Most recent delivery (success or failure) — undefined when none. */ lastDelivery?: { emittedAt: number; ok: boolean; eventId: string; packageName: string; packageVersion: string; }; /** Most recent N failures, newest first. Capped at 5. */ recentFailures: FailureSnapshot[]; } const RECENT_FAILURES_LIMIT = 5; const ERROR_TRUNCATE = 200; /** * Pure: combine the subscriber config with the bus's delivery * history into a per-subscriber status list. The returned array is * ordered the same way the subscriber config is (no implicit * sorting — operator sees them in the order they were added). */ export function aggregateSubscriberStatus( subscribers: Subscriber[], records: DeliveryRecord[], ): SubscriberStatus[] { // Group records by subscriber URL. Each subscriber gets its own // chronologically-sorted-newest-first array. const byUrl = new Map(); for (const r of records) { const list = byUrl.get(r.payload.subscriberUrl) ?? []; list.push(r); byUrl.set(r.payload.subscriberUrl, list); } for (const list of byUrl.values()) { list.sort((a, b) => b.emittedAt - a.emittedAt); } return subscribers.map((sub) => { const records = byUrl.get(sub.url) ?? []; const delivered = records.filter((r) => r.type === WEBHOOK_DELIVERED_EVENT).length; const failed = records.filter((r) => r.type === WEBHOOK_FAILED_EVENT).length; const total = delivered + failed; const successRatePct = total === 0 ? null : Math.round((delivered / total) * 100); const lastRecord = records[0]; const recentFailures: FailureSnapshot[] = records .filter((r) => r.type === WEBHOOK_FAILED_EVENT) .slice(0, RECENT_FAILURES_LIMIT) .map((r) => ({ emittedAt: r.emittedAt, eventId: r.payload.eventId, packageName: r.payload.packageName, packageVersion: r.payload.packageVersion, attempts: r.payload.attempts, error: (r.payload.error ?? '').slice(0, ERROR_TRUNCATE), })); return { url: sub.url, label: sub.name ?? sub.url, match: sub.match, totals: { delivered, failed }, successRatePct, lastDelivery: lastRecord ? { emittedAt: lastRecord.emittedAt, ok: lastRecord.type === WEBHOOK_DELIVERED_EVENT, eventId: lastRecord.payload.eventId, packageName: lastRecord.payload.packageName, packageVersion: lastRecord.payload.packageVersion, } : undefined, recentFailures, }; }); } /** * Disk wrapper: read recent delivery events from the bus, then * delegate to the pure aggregator. */ export function loadSubscriberStatus(opts: { limit?: number } = {}): SubscriberStatus[] { const subscribers = loadSubscribers(); if (subscribers.length === 0) return []; const limit = opts.limit ?? DEFAULT_HISTORY_LIMIT; const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS }); let records: DeliveryRecord[]; try { const delivered = bus.recentEvents({ type: WEBHOOK_DELIVERED_EVENT, limit }); const failed = bus.recentEvents({ type: WEBHOOK_FAILED_EVENT, limit }); records = [...delivered, ...failed].map((e) => ({ type: e.type as DeliveryRecord['type'], emittedAt: e.emittedAt, payload: e.payload as WebhookDeliveryPayload, })); } finally { bus.close(); } return aggregateSubscriberStatus(subscribers, records); } /** * Render a SubscriberStatus list as multi-line text for the CLI. * Pure — operates on the data the aggregator produces. */ export function formatStatus(items: SubscriberStatus[], now: number = Date.now()): string { if (items.length === 0) { return 'No subscribers configured.'; } const lines: string[] = []; for (const s of items) { lines.push(` ${s.label}`); lines.push(` url: ${s.url}`); const matchParts: string[] = []; if (s.match.registry) matchParts.push(`registry=${s.match.registry}`); if (s.match.tag) matchParts.push(`tag=${s.match.tag}`); if (s.match.packagePattern) matchParts.push(`pkg=${s.match.packagePattern}`); lines.push(` match: ${matchParts.length > 0 ? matchParts.join(', ') : '(any event)'}`); if (s.successRatePct === null) { lines.push(' deliveries: none yet'); } else { lines.push( ` deliveries: ${s.totals.delivered}/${s.totals.delivered + s.totals.failed} ok (${s.successRatePct}%)`, ); } if (s.lastDelivery) { const ago = describeAgo(now - s.lastDelivery.emittedAt); const marker = s.lastDelivery.ok ? '✓' : '✗'; lines.push( ` last delivery: ${marker} ${s.lastDelivery.packageName}@${s.lastDelivery.packageVersion} (${ago})`, ); } if (s.recentFailures.length > 0) { lines.push(` recent failures (${s.recentFailures.length}):`); for (const f of s.recentFailures) { lines.push( ` ${describeAgo(now - f.emittedAt)} — ${f.packageName}@${f.packageVersion}: ${f.error}`, ); } } lines.push(''); } return lines.join('\n').trimEnd(); } /** * Render a millisecond delta as a human-readable "Xm ago" / * "Xh ago" / "Xd ago". Pure. */ export function describeAgo(deltaMs: number): string { if (deltaMs < 0) return 'in the future'; if (deltaMs < 60_000) return `${Math.floor(deltaMs / 1000)}s ago`; if (deltaMs < 60 * 60_000) return `${Math.floor(deltaMs / 60_000)}m ago`; if (deltaMs < 24 * 60 * 60_000) return `${Math.floor(deltaMs / (60 * 60_000))}h ago`; return `${Math.floor(deltaMs / (24 * 60 * 60_000))}d ago`; }