Files
AirCoding/packages/runtime/src/events/EventIngestor.ts
AirCoding ae44be31d5 chore: push all design docs, V2 plan specs, and current working state
Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2,
AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code
changes across packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 17:12:29 +08:00

282 lines
9.7 KiB
TypeScript
Executable File

/**
* 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<T>(event: RuntimeEvent<T>): Promise<void>
/**
* Ingest an ephemeral event - validated, published to EventBus only.
* Does not persist to EventStore or project to domain tables.
*/
ingest_ephemeral<T>(event: RuntimeEvent<T>): Promise<void>
}
/**
* 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<T>(_event: RuntimeEvent<T>): Promise<void> {
// No-op
}
async ingest_ephemeral<T>(_event: RuntimeEvent<T>): Promise<void> {
// 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<T>(event: RuntimeEvent<T>): Promise<void> {
// 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<T>(event: RuntimeEvent<T>): Promise<void> {
// 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<T>(events: RuntimeEvent<T>[], policy: 'durable' | 'ephemeral'): Promise<void> {
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<unknown>[])
} else {
for (const event of events) {
this.bus.publish(event)
}
}
}
/**
* Query durable events from storage.
*/
async query(filter: EventFilter): Promise<RuntimeEvent[]> {
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<T>(event: RuntimeEvent<T>): 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<string | symbol, unknown>
const value = target[prop]
if (typeof value === 'function') {
return (value as (...args: unknown[]) => unknown).bind(target)
}
return value
},
}) as EventIngestorImpl