agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
543 lines • 23.8 kB
JavaScript
/**
* EventDispatcher — the central event bus (one per runner).
*
* Pattern: Observer (GoF) + Pub/Sub over a typed discriminated union.
* Role: Single flat dispatcher for every event emitted during a run.
* Replaces DOM-style bubbling — we have a central bus by
* construction, so tree propagation is unnecessary.
* Emits: N/A — this IS the emitter. It ROUTES events to listeners.
*
* Semantics:
* - Observers are ALWAYS fire-and-forget (inherited from footprintjs's
* recorder contract). Promise returns are never awaited.
* - Listener errors are caught; they become `agentfootprint.error.fatal`
* events with stage:'observer'. The run continues.
* - Dispatch is O(1) hash lookup by event type.
* - Zero allocation when no listener for an event type AND no wildcard.
* - Dev-mode wraps listeners to warn on async listener Promise return.
* - Lifecycle: subscriptions release via the returned Unsubscribe or an
* AbortSignal (`{ signal }`); `removeAllListeners()` is the bulk
* escape hatch for long-lived server consumers; `listenerCount()` is
* the leak diagnostic. Every removal path prunes emptied buckets and
* detaches abort handlers, so listener storage is bounded by LIVE
* subscriptions — never by subscription history.
*/
import { isDevMode } from 'footprintjs';
/** Shared no-op returned when subscribing to an already-aborted signal —
* the listener was never registered, so unsubscribe has nothing to do. */
const noopUnsubscribe = () => undefined;
/**
* Empty a bucket, detaching each subscription's abort handler from its
* AbortSignal first. Used by removeAllListeners().
*/
function drainBucket(bucket) {
for (const stored of bucket)
stored.cleanup?.();
bucket.clear();
}
// ─── Dispatcher ──────────────────────────────────────────────────────
/**
* How many recent runs' servings a dispatcher keeps for their late facts
* (`EventDispatcher · servingsByRun`). A fact later than that is served under
* the run in force.
*/
const RUN_SERVINGS_KEPT = 32;
/**
* Central event bus. One per executable runner.
*
* Zero-alloc fast path: if `hasListenersFor(type)` is false AND there are
* no wildcards, `dispatch` returns immediately without iteration.
*/
export class EventDispatcher {
byType = new Map();
domainWildcards = new Map();
allWildcards = new Set();
/**
* What the current run's events are served as (`src/redaction/served.ts`),
* installed by the runner at the start of each run. Absent until a run
* opens one (then the instance's declaration serves, `instanceServing`) —
* and a run with no policy installs the identity, so every listener
* receives the very object that was dispatched, as before.
*/
serving;
/**
* Each run's serving by its `runId` — so a fact a run dispatches AFTER the
* next run opened (a late artifact fact, a tool session's teardown report, a
* host's attributed fact; each stamped with its own run's id) is still served
* under ITS run's policy, never the one in force now. An event stamped with
* no run's id (`'consumer-scope'`, a resume's reply) is served under the
* current one. Bounded to the most recent runs ({@link RUN_SERVINGS_KEPT}).
*/
servingsByRun = new Map();
/**
* The serving for an event of NO run dispatched before any run opened one —
* a consumer's `emit`, a host's fact — resolved from what the runner
* DECLARES (`RunnerBase`): its policy, positively none, or `REFUSE` when the
* declaration is unknown. A dispatcher no runner owns carries no run's
* records and declares none: it serves as dispatched.
*/
instanceServing = () => IDENTITY_SERVING;
/**
* Whether a run id names a RUN (as opposed to an address that names none —
* a consumer's own emit, an event before the first run), supplied by the
* runner, which owns the id format (`RunnerBase · isMintedRunId`). A fact
* stamped with a run this dispatcher does not hold a serving for (past the
* bound, or never opened here) is served as the placeholder — never under a
* DIFFERENT run's policy.
*/
namesARun;
/**
* The REAL-value path — the library's own mechanisms that run on events
* (the crash checkpoint, the window's token reading, the reply a host
* streams). Never a record: see `src/redaction/runRedaction.ts`. Separate
* from every consumer bucket on purpose, so `removeAllListeners()` — a
* consumer's lifecycle call — cannot take the agent's machinery with it.
*/
realListeners = new Set();
// ─── Query ────────────────────────────────────────────────────────
/**
* Fast-path check. Returns true when at least one listener would fire
* for this type. Used by emitters to skip event-object allocation.
*/
hasListenersFor(type) {
if (this.allWildcards.size > 0)
return true;
const typed = this.byType.get(type);
if (typed && typed.size > 0)
return true;
const domainKey = this.domainKey(type);
const domain = this.domainWildcards.get(domainKey);
return domain ? domain.size > 0 : false;
}
on(type, listener, options) {
return this.subscribe(type, listener, options);
}
once(type, listener, options) {
return this.subscribe(type, listener, { ...options, once: true });
}
/**
* Shared subscribe path for on()/once(). The public overloads constrain
* `type` to either typed keys or wildcards; internally the dispatcher's
* bucket logic accepts any string and classifies by shape.
*/
subscribe(type, listener, options) {
const signal = options?.signal;
if (signal?.aborted) {
// Already aborted; register nothing — return a no-op unsubscribe.
return noopUnsubscribe;
}
const wrapped = {
fn: wrapForDev(listener, type),
once: options?.once === true,
};
const remove = this.addListener(type, wrapped);
if (!signal)
return remove;
// DOM-parity AbortSignal wiring: abort → unsubscribe, AND every other
// removal path (manual unsubscribe, off(), once-auto-removal,
// removeAllListeners()) → detach the abort handler from the signal,
// so a long-lived, never-aborted signal doesn't accumulate handlers.
const onAbort = () => {
remove();
};
signal.addEventListener('abort', onAbort, { once: true });
wrapped.cleanup = () => {
signal.removeEventListener('abort', onAbort);
};
return () => {
wrapped.cleanup?.();
remove();
};
}
off(type, listener) {
const bucket = this.bucketFor(type);
if (!bucket)
return;
const originalFn = originalsMap.get(listener) ?? listener;
for (const stored of bucket) {
const storedOriginal = originalsMap.get(stored.fn) ?? stored.fn;
if (storedOriginal === originalFn) {
bucket.delete(stored);
stored.cleanup?.();
this.pruneBucket(type, bucket);
return;
}
}
}
/**
* Lifecycle escape hatch — drop EVERY listener (typed, domain-wildcard,
* and `'*'`) in one call. For long-lived server consumers that reuse one
* runner across many requests: when you can't thread an AbortSignal or
* keep every Unsubscribe handle, call this between requests to guarantee
* the dispatcher holds zero subscriptions.
*
* Safe to call mid-dispatch: the bucket currently being iterated
* finishes its already-taken snapshot (same semantics as `off()` during
* dispatch), buckets the in-flight dispatch has NOT yet reached deliver
* nothing (DOM-like "stop now"), and every SUBSEQUENT event sees no
* listeners. Abort handlers registered on consumer AbortSignals via
* `{ signal }` are detached too. Previously returned Unsubscribe
* handles become harmless no-ops.
*/
removeAllListeners() {
for (const bucket of this.byType.values())
drainBucket(bucket);
this.byType.clear();
for (const bucket of this.domainWildcards.values())
drainBucket(bucket);
this.domainWildcards.clear();
drainBucket(this.allWildcards);
}
listenerCount(type) {
if (type !== undefined) {
const bucket = this.bucketFor(type);
return bucket ? bucket.size : 0;
}
let total = this.allWildcards.size;
for (const bucket of this.byType.values())
total += bucket.size;
for (const bucket of this.domainWildcards.values())
total += bucket.size;
return total;
}
// ─── Dispatch ─────────────────────────────────────────────────────
/**
* Route an event to all matching listeners (typed + domain-wildcard + all).
*
* Under a run's redaction policy every listener receives the SERVED event —
* its payload and the identity on its meta through the run's rule
* (`src/redaction/served.ts`); with no policy, the very object dispatched.
*
* Fire-and-forget: any returned Promise is IGNORED. Listener exceptions are
* caught (and logged in dev mode); the run continues regardless.
*/
dispatch(event) {
// A fact the runner built itself carries the value as it was made: the
// real-value path receives that, every listener the served form.
if (this.realListeners.size > 0)
this.deliverReal(event);
this.fanOut(event, true);
}
// ─── The run's redaction (src/redaction/) ─────────────────────────
/**
* Install the serving for events of no run dispatched before any run opened
* one (`instanceServing`) — read at dispatch time, so a declaration made
* after the dispatcher exists still applies. `REFUSE` when the runner's
* declaration is unknown.
*
* @internal
*/
useInstanceServing(serving) {
this.instanceServing = serving;
}
/**
* Install the recogniser of run ids (`namesARun`): a fact stamped with a run
* this dispatcher holds no serving for is then refused — served as the
* placeholder — instead of served under the run in force.
*
* @internal
*/
useRunIdRecogniser(namesARun) {
this.namesARun = namesARun;
}
/**
* Install what this run's events are served as. Called by the runner when a
* run opens (`RunnerBase · openRunRedaction`) and before a resume's reply.
*
* @param serving the run's serving — every run has one: covered by a
* policy, or positively none (the identity, which still
* serves the marks a run makes)
* @param runId the run's id, to serve its late facts by
* @internal
*/
useServing(serving, runId) {
this.serving = serving;
if (runId === undefined)
return;
this.servingsByRun.delete(runId);
this.servingsByRun.set(runId, serving);
while (this.servingsByRun.size > RUN_SERVINGS_KEPT) {
const oldest = this.servingsByRun.keys().next().value;
this.servingsByRun.delete(oldest);
}
}
/**
* Dispatch a fact the runner files about ONE run after its executor returned
* (its pause request) — served under THAT run's serving
* (`runRedaction.ts` · `servingOfExecutor`), never the run in force now: on
* an instance whose runs overlap, the last run opened is another run. A fact
* whose run has no serving (`undefined` — an executor no run of this library
* opened) is refused: served as the placeholder. The real-value path
* receives it as made.
*
* @internal
*/
dispatchForRun(event, serving) {
if (this.realListeners.size > 0)
this.deliverReal(event);
this.fanOut(event, true, (made) => serving === undefined ? refusedEvent(made) : servedBy(serving, made, true));
}
/**
* Dispatch an event whose PAYLOAD was already served at its source — the
* emit helper serves it before `$emit` (`src/redaction/runRedaction.ts` ·
* `emitServed`), so every footprintjs channel and this one share one served
* payload. Only the meta's identity is left to serve here. The real-value
* path already had this event from the source, so it is not fed again.
*
* @internal — the EmitBridge's.
*/
dispatchServed(event) {
this.fanOut(event, false);
}
/**
* Subscribe to the REAL-value path: every event as its producer made it,
* never served. For the library's own mechanisms only — what the agent
* computes on or hands back to its caller (see the field note). Listener
* errors are isolated like any other.
*
* @internal
*/
onRealEvent(listener) {
this.realListeners.add(listener);
return () => {
this.realListeners.delete(listener);
};
}
/** @internal — lets the emit helper skip building an event nobody reads. */
hasRealListeners() {
return this.realListeners.size > 0;
}
/**
* Deliver one event to the real-value path only.
*
* @internal — the emit helper's, and `dispatch`'s.
*/
deliverReal(event) {
for (const listener of [...this.realListeners]) {
try {
listener(event);
}
catch (err) {
if (isDevMode()) {
// eslint-disable-next-line no-console
console.error(`[agentfootprint] Real-value listener for "${event.type}" threw:`, err);
}
}
}
}
// ─── Internals ────────────────────────────────────────────────────
/**
* Route one event to every consumer bucket (typed + domain + all), served
* once — and only when some bucket would receive it, so an event nobody
* listens to costs no walk.
*/
fanOut(event, servePayload, serve) {
const typed = this.byType.get(event.type);
const domainKey = this.domainKey(event.type);
const domain = this.domainWildcards.get(domainKey);
if (!typed?.size && !domain?.size && this.allWildcards.size === 0)
return;
const served = serve !== undefined ? serve(event) : this.served(event, servePayload);
this.fireBucket(typed, served);
// Prune only when once-listeners actually emptied the bucket — keeps the
// hot path free of per-event work (incl. the `${domainKey}.*` string build).
if (typed && typed.size === 0)
this.pruneBucket(event.type, typed);
this.fireBucket(domain, served);
if (domain && domain.size === 0)
this.pruneBucket(`${domainKey}.*`, domain);
this.fireBucket(this.allWildcards, served);
}
/** The event as its resolved serving serves it — the same object when nothing was selected. */
served(event, servePayload) {
const serving = this.servingFor(event);
return serving === REFUSE ? refusedEvent(event) : servedBy(serving, event, servePayload);
}
/**
* What `event` is served under, resolved to one of three states — never
* inferred from an absent value (`redaction/coverage.ts`):
*
* - a fact of a run this dispatcher HOLDS: that run's serving (covered, or
* positively none);
* - a fact of a run it does not hold — evicted past
* {@link RUN_SERVINGS_KEPT}, or never opened here (another instance's,
* an earlier process's): UNKNOWN, refused, whatever the instance
* declares;
* - a fact of NO run (a consumer's emit, a resume's reply): the run in
* force, else what the runner declares (`instanceServing`).
*/
servingFor(event) {
const runId = event.meta?.runId;
if (typeof runId === 'string') {
const run = this.servingsByRun.get(runId);
if (run !== undefined)
return run;
if (this.namesARun?.(runId) === true)
return REFUSE;
}
return this.serving ?? this.instanceServing();
}
addListener(type, stored) {
const bucket = this.ensureBucket(type);
bucket.add(stored);
return () => {
bucket.delete(stored);
this.pruneBucket(type, bucket);
};
}
/**
* Bounded-leak guarantee: a bucket emptied by ANY removal path is
* deleted from its Map so `byType` / `domainWildcards` never retain
* empty Sets for event types subscribed once and released. The
* identity check (`get(...) === bucket`) guards stale Unsubscribe
* closures — they must never delete a NEWER bucket re-created under
* the same key after this one was pruned. (`allWildcards` is a stable
* field, not a Map entry — nothing to prune.)
*/
pruneBucket(type, bucket) {
if (bucket.size > 0 || type === '*')
return;
if (type.endsWith('.*')) {
const key = type.slice(0, -2);
if (this.domainWildcards.get(key) === bucket)
this.domainWildcards.delete(key);
return;
}
if (this.byType.get(type) === bucket)
this.byType.delete(type);
}
ensureBucket(type) {
if (type === '*')
return this.allWildcards;
if (type.endsWith('.*')) {
const key = type.slice(0, -2);
let bucket = this.domainWildcards.get(key);
if (!bucket) {
bucket = new Set();
this.domainWildcards.set(key, bucket);
}
return bucket;
}
let bucket = this.byType.get(type);
if (!bucket) {
bucket = new Set();
this.byType.set(type, bucket);
}
return bucket;
}
bucketFor(type) {
if (type === '*')
return this.allWildcards;
if (type.endsWith('.*'))
return this.domainWildcards.get(type.slice(0, -2));
return this.byType.get(type);
}
fireBucket(bucket, event) {
if (!bucket || bucket.size === 0)
return;
// Snapshot to allow removal during iteration (once-listeners, off(),
// removeAllListeners()). Removal mid-dispatch takes effect for
// SUBSEQUENT events; the in-flight snapshot completes delivery.
const snapshot = [...bucket];
for (const stored of snapshot) {
if (stored.once) {
bucket.delete(stored);
stored.cleanup?.(); // detach abort handler from the consumer's signal
}
try {
stored.fn(event);
}
catch (err) {
// Error isolation — never let a listener break the run. We do not
// re-dispatch here to avoid infinite recursion if an error-listener
// itself throws. Errors are surfaced via console.error in dev mode.
if (isDevMode()) {
// eslint-disable-next-line no-console
console.error(`[agentfootprint] Listener for "${event.type}" threw:`, err);
}
}
}
}
domainKey(eventType) {
// 'agentfootprint.context.injected' → 'agentfootprint.context'
const lastDot = eventType.lastIndexOf('.');
return lastDot === -1 ? eventType : eventType.slice(0, lastDot);
}
}
// ─── Dev-mode async-listener warning wrapper ─────────────────────────
/**
* Original-function map — preserves consumer's function identity across
* dev-mode wrapping so `off(type, originalFn)` still finds and removes
* the stored listener.
*/
// eslint-disable-next-line @typescript-eslint/no-restricted-types -- WeakMap value preserves arbitrary listener identity; narrowing breaks identity equality.
const originalsMap = new WeakMap();
/**
* Wrap a listener in dev mode to warn if it returns a Promise.
* Production pass-through.
*
* Why: consumers occasionally write `on('x', async (e) => await ...)` and
* expect the dispatcher to wait. It does NOT — observers are always
* fire-and-forget. This warning catches the mistake loudly in dev.
*/
function wrapForDev(listener, type) {
if (!isDevMode())
return listener;
const wrapped = (event) => {
const result = listener(event);
if (result && typeof result === 'object' && 'then' in result) {
// eslint-disable-next-line no-console
console.warn(`[agentfootprint] Listener for "${type}" returned a Promise.\n` +
`Observers are NEVER awaited — your Promise will run in the background.\n` +
`If you need back-pressure, collect promises and await AFTER run().\n` +
` See: https://agentfootprint.dev/docs/events/async-listeners`);
// Capture unhandled rejections so they don't vanish silently.
result.catch((err) => {
// eslint-disable-next-line no-console
console.error(`[agentfootprint] Listener Promise for "${type}" rejected:`, err);
});
}
};
originalsMap.set(wrapped, listener);
return wrapped;
}
/** `event` as `serving` serves it — the same object when nothing was selected (or no policy). */
function servedBy(serving, event, servePayload) {
if (!serving.active())
return event;
const payload = servePayload ? serving.payload(event.type, event.payload) : event.payload;
const meta = event.meta === undefined ? event.meta : serving.meta(event.meta);
if (payload === event.payload && meta === event.meta)
return event;
return { ...event, payload, meta };
}
/**
* An event whose run's serving cannot be found, as served: the placeholder for
* a payload and for the identity on its meta — the address (type, run, stage)
* stays, so the record still shows that something happened, and when.
*/
function refusedEvent(event) {
const meta = event.meta;
return {
...event,
payload: REFUSED_PAYLOAD,
...(meta !== undefined && {
meta: {
...meta,
...(meta.principal !== undefined && { principal: REFUSED_PAYLOAD }),
...(meta.tenant !== undefined && { tenant: REFUSED_PAYLOAD }),
},
}),
};
}
/** The placeholder a refused event's payload is served as (`redaction/served.ts` · `SERVED_PLACEHOLDER`). */
const REFUSED_PAYLOAD = '[REDACTED]';
/** The resolution of an event whose run's state is UNKNOWN: refuse it (`refusedEvent`). */
export const REFUSE = Symbol('agentfootprint.dispatcher.refuse');
/** Positively no policy: every event as dispatched. */
const IDENTITY_SERVING = Object.freeze({
active: () => false,
payload: (_type, payload) => payload,
meta: (meta) => meta,
});
//# sourceMappingURL=dispatcher.js.map