agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
105 lines (104 loc) • 6.25 kB
TypeScript
/**
* outbox — the one owner of buffered, timed, serialized delivery for the
* batching sinks in this folder: `file`, `cloudwatch` (and `agentcore`, which
* is built on it) and `xray`.
*
* A sink accepts items on its hot path (sync, no I/O) and hands them here. The
* outbox decides WHEN a batch leaves and makes sure only one leaves at a time:
*
* - **One queue, every trigger.** The size trigger, the timer and `drain()`
* (the sink's `flush()`, and so every shutdown) all queue a delivery on
* the same promise chain. Nothing calls `deliver` any other way.
* - **One delivery in flight.** A delivery starts only after the previous
* one settled. A large `fs.promises.appendFile` is several `write()`s; two
* of them interleaved tear NDJSON lines (reported in #223, where the timer
* bypassed the chain). Whatever a sink does around its write — the file
* sink's rotation — runs inside its delivery, so it can never be skipped
* or reordered either. This keeps ONE sink's deliveries from interleaving;
* it does not make a delivery atomic (a disk that fails partway through an
* append can still leave part of it behind).
* - **Oldest first, within the receiver's limits.** A delivery takes the
* oldest waiting items, so items leave in the order they were accepted.
* When a receiving service limits one request (`maxItemsPerDelivery`,
* `maxBytesPerDelivery` — CloudWatch's PutLogEvents), a delivery takes
* only the oldest items that fit, and the rest go in the next delivery,
* right after. That matters BECAUSE of the queue: a slow delivery lets a
* backlog build behind it, which one request may not be allowed to carry.
* Each part settles on its own — shipped, or dropped and reported.
* - **`drain()` is bounded.** It resolves once every item accepted BEFORE
* the call has settled (delivered, or dropped and reported), one the timer
* already had in flight included — then makes one sweep of what arrived
* while it waited. It never chases a producer that keeps emitting: under
* steady traffic it ends after about two deliveries.
* - **A failed batch is dropped and reported once**, then the next batch
* goes. It is never requeued, so a full disk or an outage cannot grow the
* outbox. The queue never rejects: a failure report that itself throws is
* said on the console instead of becoming an unhandled rejection.
*
* What it does NOT do: bound a delivery that never settles. A hung disk holds
* the queue, and `drain()` with it; nothing can cancel an in-flight write.
*
* Role: leaf helper for `adapters/observability/*`. Composed as a field,
* never inherited.
*/
/** What a sink tells its outbox. */
export interface OutboxOptions<T> {
/** The sink's name: it prefixes a refused option and the console line when
* a failure report throws. */
readonly name: string;
/** Size trigger: deliver once `waiting >= maxItems`. Compared as given —
* the meaning each sink's option had before the outbox, so no value that
* worked is refused: `0` or a negative number triggers on every item,
* `2.5` on the third, `NaN` or `Infinity` never (then only the byte
* trigger, the timer and `drain()` deliver). It never limits what a
* delivery takes. */
readonly maxItems: number;
/** Size trigger: deliver once `waitingBytes >= maxBytes`, compared as
* given. Omitted → no byte trigger. */
readonly maxBytes?: number;
/** Most items one delivery takes: a positive whole number — a sink maps its
* own option to one first, and refuses by its own name what cannot map.
* Omitted → everything waiting. */
readonly maxItemsPerDelivery?: number;
/** Most bytes one delivery may carry, as the receiving service counts them:
* for each item, the `bytes` it was added with plus `overheadBytesPerItem`.
* A delivery always takes at least one item, so an item too large for any
* delivery still goes — alone — and the service's answer decides it.
* Omitted → no byte limit. */
readonly maxBytesPerDelivery?: number;
/** Bytes the receiving service adds to each item it counts (CloudWatch
* Logs: 26 per event). Default 0. */
readonly overheadBytesPerItem?: number;
/** Timer trigger for sparse traffic, in ms. `0` disables it: only the size
* trigger and `drain()` deliver. */
readonly flushIntervalMs: number;
/** Does an armed timer keep the process alive? Each sink decides: the file
* sink says no (a local write is never worth holding a process open for),
* the network sinks say yes (an un-stopped one lingers up to
* `flushIntervalMs` so its timer can still ship the tail). */
readonly timerHoldsProcess: boolean;
/** Ship one batch. Never called while another call is still in flight. */
readonly deliver: (batch: readonly T[]) => Promise<void>;
/** Describe a batch `deliver` failed to ship — the report it is dropped
* with. */
readonly dropped: (batch: readonly T[], cause: unknown) => Error;
/** Where that report goes. Read at call time by the sink, so a consumer
* who assigns `_onError` after construction still receives it. */
readonly report: (error: Error) => void;
}
/** The three things a sink does with its outbox. */
export interface Outbox<T> {
/** Accept one item. Sync, never throws, never does I/O. `bytes` counts
* toward the byte trigger. Accepting is the SINK's decision — the outbox
* takes what it is given, including after `stop()`. */
add(item: T, bytes?: number): void;
/** Settle everything accepted before this call — behind whatever is in
* flight — then sweep once what arrived meanwhile. Never rejects. */
drain(): Promise<void>;
/** Cancel the timer and arm no new one. Discards nothing: `drain()`
* afterwards still delivers what was accepted. */
stop(): void;
}
/** Build the outbox one sink owns. A per-delivery cap that could take
* nothing is refused: such a delivery could never finish a drain. */
export declare function createOutbox<T>(opts: OutboxOptions<T>): Outbox<T>;