/** * @pwngh/economy-lab * * Copyright (c) Preston Neal * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ import type { Saga } from '../ports.js'; import type { SignalFeed } from './runtime.js'; /** What {@link detectDeadlockStorm} found: summed retry volume over the window. */ export type DeadlockStormFinding = { signature: 'deadlock-storm'; /** Retry count summed over the window — conflict pressure, not caller-visible failures. */ retries: number; windowMs: number; }; /** One stuck saga from {@link detectStuckSagas}; the poll emits one finding per saga. */ export type StuckSagaFinding = { signature: 'stuck-saga'; /** The saga row as of the poll; the supervisor re-loads it after acting to verify progress. */ saga: Saga; /** Milliseconds since the saga's `updatedAt`. */ ageMs: number; }; /** One mismatch signal from {@link detectIntegrityMismatches}. */ export type IntegrityMismatchFinding = { signature: 'integrity-mismatch'; /** The signal's capture time — the watermark the caller advances past this episode. */ at: number; /** Which side carried the evidence: the verify metric or the mismatch log event. */ channel: 'meter' | 'log'; }; /** What {@link detectEngineStall} found: acquires started with no completion since. */ export type EngineStallFinding = { signature: 'engine-stall'; /** Acquires recorded after the last completed acquire. */ pending: number; /** How long the oldest of them has been waiting. */ oldestWaitMs: number; }; /** What {@link detectTreasuryBreaches} found past the caller's watermark. */ export type TreasuryBreachFinding = { signature: 'treasury-breach'; breaches: number; /** The distinct breach metric names seen: backing, float, or both. */ channels: ReadonlyArray; /** The newest breach signal's capture time — the caller's next watermark. */ newestAt: number; }; /** What {@link detectVelocityAnomaly} found: RISK_DENIED rejection volume over the window. */ export type VelocityAnomalyFinding = { signature: 'velocity-anomaly'; rejections: number; /** Rejections tallied per operation kind tag; untagged signals tally under `unknown`. */ byKind: Readonly>; /** The window's rejection count normalized to a per-minute rate, rounded. */ ratePerMin: number; windowMs: number; }; /** One quiet watchdog from {@link detectSilences}. */ export type SilenceFinding = { signature: 'signal-silence'; /** The declared signal name that went quiet. */ signal: string; /** Time since the later of the last beat and when watching began. */ silentForMs: number; }; /** What {@link detectRetryExhaustion} found: exhausted retry budgets over the window. */ export type RetryExhaustionFinding = { signature: 'retry-exhaustion'; /** Submits that failed to their callers after every retry. */ exhausted: number; /** Exhaustions tallied per engine tag; untagged signals tally under `unknown`. */ byEngine: Readonly>; windowMs: number; }; /** What {@link detectOutboxBacklog} found in the newest relay gauge pair. */ export type OutboxBacklogFinding = { signature: 'outbox-backlog'; /** The newest backlog-age gauge reading. */ ageMs: number; /** The newest backlog-depth gauge reading, or 0 when no depth sample is buffered. */ pending: number; }; /** What {@link detectWebhookReplayStorm} found: dropped duplicate volume over the window. */ export type WebhookReplayStormFinding = { signature: 'webhook-replay-storm'; duplicates: number; /** Duplicates tallied per provider tag; untagged signals tally under `unknown`. */ byProvider: Readonly>; /** Duplicates tallied per catching layer: an edge layer held, an inbox layer means leakage. */ byLayer: Readonly>; windowMs: number; }; /** What {@link detectSlowSeal} found: the slowest completed seal in the window. */ export type SlowSealFinding = { signature: 'checkpoint-seal-slow'; /** The slowest completed seal's duration. */ maxMs: number; /** Completed seals observed in the window. */ samples: number; windowMs: number; }; /** What {@link detectInboxDeadLetters} found past the caller's watermark. */ export type InboxDeadLetterFinding = { signature: 'inbox-dead-letter'; /** Dead-letter log signals counted; row ids are not buffered, so none are carried. */ deadLettered: number; /** The newest dead-letter signal's capture time — the caller's next watermark. */ newestAt: number; }; /** * The union of every finding type, one per incident signature. Each carries the evidence its * runbook starts from; the detectors are stateless, so dedupe and watermarks live with the * caller (the supervisor). */ export type Finding = DeadlockStormFinding | StuckSagaFinding | IntegrityMismatchFinding | EngineStallFinding | TreasuryBreachFinding | VelocityAnomalyFinding | SilenceFinding | RetryExhaustionFinding | OutboxBacklogFinding | WebhookReplayStormFinding | SlowSealFinding | InboxDeadLetterFinding; /** * Sums the named retry metric's values over the trailing window and fires at or above the * threshold, or returns null. Retries measure conflict pressure the retry budget is absorbing, * not failures: callers still succeed while a storm is running, which is why nothing else * surfaces it. */ export declare function detectDeadlockStorm(signals: SignalFeed, now: number, options: { metric: string; windowMs: number; threshold: number; }): DeadlockStormFinding | null; /** * Walks the saga listing and returns one finding per saga that is non-terminal (any state * other than SETTLED or FAILED) and has not been updated for at least `ageMs`. Returns an * empty array when nothing qualifies. Stateless: the same stuck saga is found again on every * poll until it progresses. */ export declare function detectStuckSagas(sagas: { list(): AsyncIterable; }, now: number, options: { ageMs: number; }): Promise>; /** * Counts pool acquires recorded after the last completed acquire; fires when the oldest of * them has waited past the grace period, else returns null. The rule scans the whole buffer, * not a sliding window: a stalled pool emits nothing else, so a window would age the stall's * own evidence out. Clears itself — once an acquire completes, the pending set restarts. */ export declare function detectEngineStall(signals: SignalFeed, now: number, options: { graceMs: number; }): EngineStallFinding | null; /** * Returns one finding per checkpoint-verify mismatch signal strictly newer than * `sinceExclusive`, matching both channels: the verify metric with a mismatch outcome and the * mismatch log event. No threshold — a single mismatch is an incident. The caller advances its * watermark to the newest `at` so an episode is handled once. */ export declare function detectIntegrityMismatches(signals: SignalFeed, sinceExclusive: number): ReadonlyArray; /** * Counts backing- and float-breach metrics strictly newer than `sinceExclusive`; any hit fires * (there is no threshold to tune — a breached backing invariant is always an incident), none * returns null. `newestAt` is the caller's next watermark. */ export declare function detectTreasuryBreaches(signals: SignalFeed, sinceExclusive: number): TreasuryBreachFinding | null; /** * Sums submit rejections whose reason is RISK_DENIED over the trailing window and fires at or * above the threshold, or returns null. The finding tallies per operation kind and carries a * rounded per-minute rate; a spike is read as a fraud signal (a cohort probing the velocity * limits) before it is read as a tuning problem. */ export declare function detectVelocityAnomaly(signals: SignalFeed, now: number, options: { windowMs: number; threshold: number; }): VelocityAnomalyFinding | null; /** * Checks each declared watchdog — a signal the host says beats on a cadence, such as a worker * sweep — and returns one finding per signal silent for more than twice its declared cadence. * Silence is measured from the later of the last beat and `watchStartedAt`, so a worker that * never started is caught too. Returns an empty array when every watchdog is beating. */ export declare function detectSilences(signals: SignalFeed, now: number, watchdogs: ReadonlyArray<{ signal: string; everyMs: number; }>, watchStartedAt: number): ReadonlyArray; /** * Sums exhausted retry-budget signals over the trailing window and fires at or above the * threshold, or returns null. Unlike a deadlock storm these are caller-visible failures: every * exhaustion is a submit that errored back to its caller after the whole budget was spent. The * finding tallies per engine. */ export declare function detectRetryExhaustion(signals: SignalFeed, now: number, options: { windowMs: number; threshold: number; }): RetryExhaustionFinding | null; /** * Reads the newest relay backlog-age gauge sample and fires when it is at or past `ageMs`, or * returns null when no sample is buffered or the newest is under the bound. Only the newest * sample matters — the gauge pair rides each relay run, so an old high reading followed by a * fresh low one means the backlog drained. `pending` comes from the newest depth gauge. */ export declare function detectOutboxBacklog(signals: SignalFeed, options: { ageMs: number; }): OutboxBacklogFinding | null; /** * Sums webhook duplicate counts over the trailing window and fires at or above the threshold, * or returns null. Every counted duplicate was already dropped — no money moved twice — so the * finding measures wasted edge work and provider misbehavior, tallied per provider and per * catching layer. */ export declare function detectWebhookReplayStorm(signals: SignalFeed, now: number, options: { windowMs: number; threshold: number; }): WebhookReplayStormFinding | null; /** * Takes the slowest completed checkpoint seal in the trailing window and fires when it is at * or past the threshold, or returns null when no seal completed or all were under it. Only * sealed outcomes count — a skip or retry says nothing about how the re-derivation is scaling * with table growth, which is what this trend watches. */ export declare function detectSlowSeal(signals: SignalFeed, now: number, options: { thresholdMs: number; windowMs: number; }): SlowSealFinding | null; /** * Counts dead-letter inbox log signals strictly newer than `sinceExclusive` and fires at or * above the threshold, or returns null. Log fields are never buffered, so the dead rows' ids * are invisible here; the remediation lever (the store's reviveDead) picks the oldest dead * rows without needing them. `newestAt` is the caller's next watermark. */ export declare function detectInboxDeadLetters(signals: SignalFeed, sinceExclusive: number, options: { threshold: number; }): InboxDeadLetterFinding | null;