/** * EventIngestor — Single runtime entry point for events from agents/tools/workers * * Implements: ingest(durable) → EventStore.append, ingest_ephemeral → EventBus.publish * Per system-detailed-design.md §5.1 and runtime-semantics-v1.md §2. * * Construction-time binding (round5 Wf-A A.3/A.4): * - `event_store` is supplied at construction. The module-level `eventStore` * and `eventIngestor` singletons are removed; SessionManager constructs a * per-session pair and hands them to RuntimeApp. * * Rules: * - Never creates scheduler tasks, permission decisions, or memory promotions itself * - Those are follow-up events emitted by owning services * * @module packages/runtime/src/events/EventIngestor */ import type { RuntimeEvent, EventFilter } from '@aircoding/contracts' import { eventSchemaRegistry, type EventPersistence } from './EventSchemaRegistry.js' import { eventBus, type EventBus } from './EventBus.js' import type { EventStore } from './EventStore.js' // ============================================================================= // Interfaces (for backward compatibility with existing code) // ============================================================================= /** * EventIngestor interface - the single runtime entry point for events * from agents/tools/workers per runtime-semantics §2 and DD §5.1. */ export interface IEventIngestor { /** * Ingest a durable event - validated, stored in EventStore, projected to domain tables. * Throws AirError{kind:"system_error"} on validation failure. */ ingest(event: RuntimeEvent): Promise /** * Ingest an ephemeral event - validated, published to EventBus only. * Does not persist to EventStore or project to domain tables. */ ingest_ephemeral(event: RuntimeEvent): Promise } /** * EventIngestorFactory creates an EventIngestor for a given session. * The actual implementation wires up EventStore, EventBus, and domain projection. */ export interface EventIngestorFactory { /** * Create an EventIngestor for the given session. * The ingestor is bound to a specific session's EventStore. */ createForSession(sessionId: string): IEventIngestor } /** * NullEventIngestor - a no-op implementation for testing or when events aren't needed. */ export class NullEventIngestor implements IEventIngestor { async ingest(_event: RuntimeEvent): Promise { // No-op } async ingest_ephemeral(_event: RuntimeEvent): Promise { // No-op } } /** * Creates a NullEventIngestor instance. */ export function createNullEventIngestor(): IEventIngestor { return new NullEventIngestor() } // ============================================================================= // EventIngestor Implementation // ============================================================================= /** * EventIngestor is the single runtime entry point for all events. * * Flow (runtime-semantics §2): * 1. Validate envelope + schema/version (EventSchemaRegistry) * 2. Look up persistence policy by event.type * 3. If durable: EventStore.append(event) → tx + projection + post-commit publish * 4. If ephemeral: EventBus.publish(event) → live only * * The ingestor never creates scheduler tasks, permission decisions, or memory promotions. * Those are follow-up events emitted by owning services. */ export class EventIngestorImpl implements IEventIngestor { private bus: EventBus private event_store: EventStore constructor(options: { bus?: EventBus event_store: EventStore }) { this.bus = options.bus ?? eventBus this.event_store = options.event_store } /** * Ingest a durable event. * Validates, looks up persistence policy, delegates to EventStore.append. */ async ingest(event: RuntimeEvent): Promise { // Validate event envelope this.validateEnvelope(event) // Get persistence policy const persistence = this.policyFor(event.type, event.version) if (persistence !== 'durable') { throw new Error( `Event ${event.type} is ${persistence}, use ingest_ephemeral() for ephemeral events`, ) } // Delegate to bound EventStore (which handles tx + projection + post-commit publish) await this.event_store.append(event) } /** * Ingest an ephemeral event. * Validates, looks up persistence policy, delegates to EventBus.publish. */ async ingest_ephemeral(event: RuntimeEvent): Promise { // Validate event envelope this.validateEnvelope(event) // Get persistence policy const persistence = this.policyFor(event.type, event.version) if (persistence !== 'ephemeral') { throw new Error( `Event ${event.type} is ${persistence}, use ingest() for durable events`, ) } // Publish directly to EventBus (live transport only) this.bus.publish(event) } /** * Ingest multiple events in batch. */ async ingest_batch(events: RuntimeEvent[], policy: 'durable' | 'ephemeral'): Promise { if (events.length === 0) return for (const event of events) { this.validateEnvelope(event) const eventPersistence = this.policyFor(event.type, event.version) if (eventPersistence !== policy) { throw new Error( `Event ${event.type} has persistence ${eventPersistence}, expected ${policy}`, ) } } if (policy === 'durable') { await this.event_store.append_many(events as RuntimeEvent[]) } else { for (const event of events) { this.bus.publish(event) } } } /** * Query durable events from storage. */ async query(filter: EventFilter): Promise { return this.event_store.query(filter) } /** * Get the persistence policy for an event type. */ policyFor(type: string, version: number): EventPersistence { const persistence = eventSchemaRegistry.getPersistence(type, version) if (persistence === undefined) { throw new Error(`Unknown event type: ${type}@v${version}`) } return persistence } /** * Validate the basic event envelope structure. */ private validateEnvelope(event: RuntimeEvent): void { if (!event.id) throw new Error('Event missing required field: id') if (!event.type) throw new Error('Event missing required field: type') if (event.version === undefined || event.version === null) { throw new Error('Event missing required field: version') } if (!event.timestamp) throw new Error('Event missing required field: timestamp') if (!event.session_id) throw new Error('Event missing required field: session_id') if (!event.source) throw new Error('Event missing required field: source') if (!Array.isArray(event.route)) throw new Error('Event field route must be an array') if (event.payload === undefined) throw new Error('Event missing required field: payload') if (!eventSchemaRegistry.isRegistered(event.type, event.version)) { throw new Error(`Unregistered event type: ${event.type}@v${event.version}`) } } } // Alias for backward compatibility (class — usable as both type and value) export const EventIngestor = EventIngestorImpl export type EventIngestor = EventIngestorImpl // Export type for consumers export type { EventPersistence } from './EventSchemaRegistry.js' // ============================================================================= // Process-bound ingestor binding (round5 Wf-A A.3 mitigation) // // The module-level singleton is gone. SessionManager.open_session sets the // process-bound ingestor exactly once per session. Any code that still // imports `eventIngestor` and calls it BEFORE a session is opened will // receive a clear "no session bound" error, surfacing the architectural // bypass instead of silently writing to a no-op store. // ============================================================================= let _bound_ingestor: EventIngestorImpl | null = null export function bindEventIngestor(ingestor: EventIngestorImpl): void { _bound_ingestor = ingestor } export function unbindEventIngestor(): void { _bound_ingestor = null } /** * Returns the session-bound EventIngestorImpl set by SessionManager.open_session. * Throws if no session has been opened in this process yet. * * Round5 Wf-A note: this is a compatibility shim, not the canonical access * path. New code should receive the ingestor via constructor / RuntimeApp. */ export function getEventIngestor(): EventIngestorImpl { if (!_bound_ingestor) { throw new Error( 'EventIngestor: no session is bound. SessionManager.open_session() must be ' + 'called before any event can be ingested. This guards against the round5 ' + 'C-2 bypass where a module-level singleton wrote to a never-bound EventStore.' ) } return _bound_ingestor } /** * Noop ingestor: silently discards events when no session is available. * Used during pre-session operations like init, where event persistence is not needed. */ const noopIngestor: EventIngestorImpl = { ingest: async () => {}, flush: async () => {}, pending: () => 0, } as unknown as EventIngestorImpl /** * Backward-compat shim: returns a proxy that delegates to the bound ingestor. * If no session is bound, returns a noop ingestor (silently discards events). * This allows pre-session operations like init to use ToolRegistry without noise. */ function resolveBoundOrNoop(): EventIngestorImpl { return _bound_ingestor ?? noopIngestor } export const eventIngestor: EventIngestorImpl = new Proxy({} as EventIngestorImpl, { get(_target, prop) { const target = resolveBoundOrNoop() as unknown as Record const value = target[prop] if (typeof value === 'function') { return (value as (...args: unknown[]) => unknown).bind(target) } return value }, }) as EventIngestorImpl