UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

547 lines • 24.1 kB
"use strict"; /** * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.REFUSE = exports.EventDispatcher = void 0; const footprintjs_1 = require("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. */ 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 ((0, footprintjs_1.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 === exports.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 exports.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 ((0, footprintjs_1.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); } } exports.EventDispatcher = EventDispatcher; // ─── 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 (!(0, footprintjs_1.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`). */ exports.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