/** * hermes.mjs — AAMOS Hermes Event Fabric adapter * ───────────────────────────────────────────────────────────────────────────── * Thin adapter between domain modules and Hermes. * Publishes events via Redis pub/sub (primary) with JSONL file fallback. * * Event envelope: * { * id: string — UUIDv4 * trace_id: string — propagated across the full operation chain * correlation_id:string — groups related events (e.g. one invoice = many events) * event_type: string — e.g. 'finance.journal.posted' * source: string — service name, e.g. 'aamos-ledger' * tenant_id: string — org/tenant isolation * user_id: string — actor * entity_type: string — e.g. 'journal_entry' * entity_id: string — UUID of the affected entity * decision_source: string — 'system' | 'user' | 'agent' (audit principle) * payload: object — event-specific data * ts: string — ISO-8601 UTC * schema_version: string — '1.0' * } * * Usage: * import { hermes } from './hermes.mjs'; * await hermes.emit('finance.journal.posted', ctx, { entry_id, amount }); * ───────────────────────────────────────────────────────────────────────────── */ import { createClient } from 'redis'; import { appendFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379'; const HERMES_CHANNEL = process.env.HERMES_CHANNEL || 'aamos:hermes'; const LOG_DIR = process.env.HERMES_LOG_DIR || '/opt/amos/data/hermes'; const LOG_FILE = join(LOG_DIR, 'events.jsonl'); const SERVICE = process.env.SERVICE_NAME || 'aamos-unknown'; mkdirSync(LOG_DIR, { recursive: true }); // ── Redis client (lazy, non-blocking) ──────────────────────────────────────── let redis = null; let redisReady = false; let redisDisabled = false; // Once down, don't retry — avoids blocking emit() async function getRedis() { if (redisReady) return redis; if (redisDisabled) return null; try { redis = createClient({ url: REDIS_URL, socket: { connectTimeout: 3000, // fail after 3s instead of blocking forever reconnectStrategy: false, // disable auto-reconnect — we use JSONL fallback }, }); redis.on('error', (e) => { redisReady = false; redisDisabled = true; redis = null; console.warn(`[hermes] Redis error (fallback to JSONL): ${e.message}`); }); redis.on('ready', () => { redisReady = true; redisDisabled = false; }); await redis.connect(); redisReady = true; } catch (e) { console.warn(`[hermes] Redis unavailable (fallback to JSONL): ${e.message}`); redis = null; redisDisabled = true; } return redis; } // ── Event factory ───────────────────────────────────────────────────────────── function makeEvent(event_type, ctx = {}, payload = {}) { return { id: randomUUID(), trace_id: ctx.trace_id || randomUUID(), correlation_id: ctx.correlation_id || randomUUID(), event_type, source: SERVICE, tenant_id: ctx.tenant_id || 'wavult-group', user_id: ctx.user_id || 'system', entity_type: ctx.entity_type || null, entity_id: ctx.entity_id || null, decision_source: ctx.decision_source || 'system', payload, ts: new Date().toISOString(), schema_version: '1.0', }; } // ── Publish ─────────────────────────────────────────────────────────────────── async function emit(event_type, ctx = {}, payload = {}) { const event = makeEvent(event_type, ctx, payload); const raw = JSON.stringify(event); // Always write to JSONL (durable fallback + queryable log) try { appendFileSync(LOG_FILE, raw + '\n'); } catch (e) { console.error('[hermes] JSONL write failed:', e.message); } // Publish to Redis pub/sub const client = await getRedis(); if (client && redisReady) { try { await client.publish(HERMES_CHANNEL, raw); } catch (e) { console.warn('[hermes] Redis publish failed (JSONL still written):', e.message); } } return event; } // ── Subscribe (for services that consume events) ────────────────────────────── async function subscribe(handler) { const client = await getRedis(); if (!client) { console.warn('[hermes] subscribe: Redis unavailable, no live events'); return null; } const sub = client.duplicate(); await sub.connect(); await sub.subscribe(HERMES_CHANNEL, (raw) => { try { const event = JSON.parse(raw); handler(event); } catch (e) { console.warn('[hermes] subscribe parse error:', e.message); } }); console.log(`[hermes] subscribed to ${HERMES_CHANNEL}`); return sub; } export const hermes = { emit, subscribe, makeEvent }; export default hermes;