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>
282 lines
9.7 KiB
TypeScript
Executable File
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 |