nexus-ai-pro 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/API_STABILITY.md +65 -0
  2. package/CHANGELOG.md +267 -1
  3. package/MIGRATING.md +1 -1
  4. package/README.md +17 -8
  5. package/dist/agent/create-agent.d.ts +9 -1
  6. package/dist/agent/create-agent.js +40 -1
  7. package/dist/cache/memory-cache.d.ts +8 -0
  8. package/dist/cache/memory-cache.js +9 -0
  9. package/dist/cache/semantic-cache.d.ts +6 -3
  10. package/dist/cache/semantic-cache.js +5 -2
  11. package/dist/cli/args.js +3 -0
  12. package/dist/cli/db.d.ts +16 -4
  13. package/dist/cli/db.js +169 -22
  14. package/dist/cli/doctor.d.ts +17 -0
  15. package/dist/cli/doctor.js +53 -0
  16. package/dist/cli/graph.d.ts +9 -0
  17. package/dist/cli/graph.js +47 -0
  18. package/dist/cli.js +21 -2
  19. package/dist/core/lifecycle.js +11 -6
  20. package/dist/core/nexus.d.ts +12 -2
  21. package/dist/core/nexus.js +106 -23
  22. package/dist/doctor/index.d.ts +90 -0
  23. package/dist/doctor/index.js +237 -0
  24. package/dist/evals/judge.d.ts +11 -0
  25. package/dist/evals/judge.js +33 -0
  26. package/dist/evaluate/index.d.ts +1 -0
  27. package/dist/evaluate/index.js +1 -0
  28. package/dist/evaluate/provenance.d.ts +22 -0
  29. package/dist/evaluate/provenance.js +10 -0
  30. package/dist/evaluate/run.js +20 -4
  31. package/dist/graph/durability.d.ts +27 -0
  32. package/dist/graph/durability.js +66 -0
  33. package/dist/graph/errors.d.ts +30 -4
  34. package/dist/graph/errors.js +18 -2
  35. package/dist/graph/event-stream.d.ts +75 -0
  36. package/dist/graph/event-stream.js +289 -0
  37. package/dist/graph/functional.d.ts +80 -7
  38. package/dist/graph/functional.js +138 -35
  39. package/dist/graph/graph.d.ts +56 -8
  40. package/dist/graph/graph.js +331 -49
  41. package/dist/graph/index.d.ts +4 -2
  42. package/dist/graph/index.js +3 -1
  43. package/dist/graph/lint.d.ts +57 -0
  44. package/dist/graph/lint.js +141 -0
  45. package/dist/graph/run-control.d.ts +28 -0
  46. package/dist/graph/run-control.js +26 -0
  47. package/dist/images/evals.js +4 -2
  48. package/dist/operations/adapters.d.ts +52 -4
  49. package/dist/operations/adapters.js +194 -7
  50. package/dist/operations/runner.js +14 -15
  51. package/dist/operations/stats.js +8 -1
  52. package/dist/operations/store.d.ts +5 -1
  53. package/dist/operations/store.js +7 -0
  54. package/dist/ops/circuit-breaker.d.ts +52 -0
  55. package/dist/ops/circuit-breaker.js +4 -0
  56. package/dist/ops/circuit-store.d.ts +13 -2
  57. package/dist/ops/circuit-store.js +68 -0
  58. package/dist/ops/circuit-sync.d.ts +17 -0
  59. package/dist/ops/circuit-sync.js +86 -0
  60. package/dist/ops/health.d.ts +24 -0
  61. package/dist/ops/health.js +38 -10
  62. package/dist/ops/index.d.ts +1 -1
  63. package/dist/ops/index.js +1 -1
  64. package/dist/ops/rate-limit-adapters.d.ts +23 -0
  65. package/dist/ops/rate-limit-adapters.js +34 -0
  66. package/dist/ops/rate-limiter.d.ts +14 -2
  67. package/dist/ops/rate-limiter.js +31 -0
  68. package/dist/postgres/circuits.d.ts +21 -4
  69. package/dist/postgres/circuits.js +65 -4
  70. package/dist/postgres/client.d.ts +0 -2
  71. package/dist/postgres/client.js +0 -4
  72. package/dist/postgres/evaluate.d.ts +16 -4
  73. package/dist/postgres/evaluate.js +17 -5
  74. package/dist/postgres/index.d.ts +32 -11
  75. package/dist/postgres/index.js +65 -31
  76. package/dist/postgres/migrations.d.ts +60 -0
  77. package/dist/postgres/migrations.js +121 -0
  78. package/dist/postgres/operations.d.ts +14 -3
  79. package/dist/postgres/operations.js +45 -15
  80. package/dist/postgres/prompts.d.ts +11 -2
  81. package/dist/postgres/prompts.js +14 -3
  82. package/dist/postgres/rollups.d.ts +35 -0
  83. package/dist/postgres/rollups.js +129 -0
  84. package/dist/postgres/store.d.ts +11 -2
  85. package/dist/postgres/store.js +14 -3
  86. package/dist/postgres/traces.d.ts +13 -3
  87. package/dist/postgres/traces.js +25 -3
  88. package/dist/postgres/vectors.d.ts +11 -2
  89. package/dist/postgres/vectors.js +14 -3
  90. package/dist/router/auto-router.d.ts +34 -7
  91. package/dist/router/auto-router.js +201 -68
  92. package/dist/server/assistant.d.ts +44 -0
  93. package/dist/server/assistant.js +39 -0
  94. package/dist/server/errors.d.ts +12 -0
  95. package/dist/server/errors.js +7 -0
  96. package/dist/server/index.d.ts +1 -1
  97. package/dist/server/index.js +1 -1
  98. package/dist/server/runs.d.ts +28 -1
  99. package/dist/server/runs.js +110 -14
  100. package/dist/sqlite/index.d.ts +16 -3
  101. package/dist/sqlite/index.js +16 -3
  102. package/dist/sqlite/migrations.d.ts +26 -0
  103. package/dist/sqlite/migrations.js +63 -0
  104. package/dist/sqlite/operations.d.ts +14 -3
  105. package/dist/sqlite/operations.js +42 -15
  106. package/dist/sqlite/store.d.ts +9 -2
  107. package/dist/sqlite/store.js +13 -3
  108. package/dist/sqlite/vectors.d.ts +11 -2
  109. package/dist/sqlite/vectors.js +13 -2
  110. package/dist/store/tenant.d.ts +12 -0
  111. package/dist/store/tenant.js +29 -0
  112. package/dist/tenancy/index.d.ts +113 -0
  113. package/dist/tenancy/index.js +233 -0
  114. package/dist/testing/record.d.ts +42 -0
  115. package/dist/testing/record.js +57 -5
  116. package/dist/tracing/index.d.ts +3 -3
  117. package/dist/tracing/index.js +2 -2
  118. package/dist/tracing/instrument.js +30 -0
  119. package/dist/tracing/otlp.d.ts +91 -0
  120. package/dist/tracing/otlp.js +124 -0
  121. package/dist/tracing/rollups.d.ts +100 -0
  122. package/dist/tracing/rollups.js +139 -0
  123. package/dist/tracing/stores.d.ts +17 -0
  124. package/dist/tracing/stores.js +23 -0
  125. package/dist/tracing/tracer.d.ts +50 -4
  126. package/dist/tracing/tracer.js +85 -6
  127. package/dist/types/config.d.ts +33 -2
  128. package/dist/types/evaluate.d.ts +37 -0
  129. package/dist/types/graph.d.ts +354 -9
  130. package/dist/types/graph.js +3 -1
  131. package/dist/types/messages.d.ts +5 -0
  132. package/dist/types/operations.d.ts +22 -2
  133. package/dist/types/response.d.ts +20 -0
  134. package/dist/types/server.d.ts +18 -0
  135. package/dist/types/tracing.d.ts +11 -0
  136. package/dist/utils/schema-migrations.d.ts +97 -0
  137. package/dist/utils/schema-migrations.js +91 -0
  138. package/dist/utils/signals.d.ts +17 -0
  139. package/dist/utils/signals.js +26 -0
  140. package/dist/utils/tenant.d.ts +14 -0
  141. package/dist/utils/tenant.js +16 -0
  142. package/dist/version.d.ts +5 -0
  143. package/dist/version.js +1 -0
  144. package/dist-cjs/agent/create-agent.d.ts +9 -1
  145. package/dist-cjs/agent/create-agent.js +40 -1
  146. package/dist-cjs/cache/memory-cache.d.ts +8 -0
  147. package/dist-cjs/cache/memory-cache.js +9 -0
  148. package/dist-cjs/cache/semantic-cache.d.ts +6 -3
  149. package/dist-cjs/cache/semantic-cache.js +5 -2
  150. package/dist-cjs/cli/args.js +3 -0
  151. package/dist-cjs/cli/db.d.ts +16 -4
  152. package/dist-cjs/cli/db.js +200 -20
  153. package/dist-cjs/cli/doctor.d.ts +17 -0
  154. package/dist-cjs/cli/doctor.js +56 -0
  155. package/dist-cjs/cli/graph.d.ts +9 -0
  156. package/dist-cjs/cli/graph.js +50 -0
  157. package/dist-cjs/core/lifecycle.js +11 -6
  158. package/dist-cjs/core/nexus.d.ts +12 -2
  159. package/dist-cjs/core/nexus.js +106 -23
  160. package/dist-cjs/doctor/index.d.ts +90 -0
  161. package/dist-cjs/doctor/index.js +276 -0
  162. package/dist-cjs/evals/judge.d.ts +11 -0
  163. package/dist-cjs/evals/judge.js +33 -0
  164. package/dist-cjs/evaluate/index.d.ts +1 -0
  165. package/dist-cjs/evaluate/index.js +3 -1
  166. package/dist-cjs/evaluate/provenance.d.ts +22 -0
  167. package/dist-cjs/evaluate/provenance.js +14 -0
  168. package/dist-cjs/evaluate/run.js +20 -4
  169. package/dist-cjs/graph/durability.d.ts +27 -0
  170. package/dist-cjs/graph/durability.js +70 -0
  171. package/dist-cjs/graph/errors.d.ts +30 -4
  172. package/dist-cjs/graph/errors.js +20 -3
  173. package/dist-cjs/graph/event-stream.d.ts +75 -0
  174. package/dist-cjs/graph/event-stream.js +295 -0
  175. package/dist-cjs/graph/functional.d.ts +80 -7
  176. package/dist-cjs/graph/functional.js +137 -34
  177. package/dist-cjs/graph/graph.d.ts +56 -8
  178. package/dist-cjs/graph/graph.js +330 -48
  179. package/dist-cjs/graph/index.d.ts +4 -2
  180. package/dist-cjs/graph/index.js +7 -1
  181. package/dist-cjs/graph/lint.d.ts +57 -0
  182. package/dist-cjs/graph/lint.js +144 -0
  183. package/dist-cjs/graph/run-control.d.ts +28 -0
  184. package/dist-cjs/graph/run-control.js +30 -0
  185. package/dist-cjs/images/evals.js +4 -2
  186. package/dist-cjs/operations/adapters.d.ts +52 -4
  187. package/dist-cjs/operations/adapters.js +193 -6
  188. package/dist-cjs/operations/runner.js +14 -15
  189. package/dist-cjs/operations/stats.js +8 -1
  190. package/dist-cjs/operations/store.d.ts +5 -1
  191. package/dist-cjs/operations/store.js +7 -0
  192. package/dist-cjs/ops/circuit-breaker.d.ts +52 -0
  193. package/dist-cjs/ops/circuit-breaker.js +4 -0
  194. package/dist-cjs/ops/circuit-store.d.ts +13 -2
  195. package/dist-cjs/ops/circuit-store.js +68 -0
  196. package/dist-cjs/ops/circuit-sync.d.ts +17 -0
  197. package/dist-cjs/ops/circuit-sync.js +87 -0
  198. package/dist-cjs/ops/health.d.ts +24 -0
  199. package/dist-cjs/ops/health.js +38 -10
  200. package/dist-cjs/ops/index.d.ts +1 -1
  201. package/dist-cjs/ops/rate-limit-adapters.d.ts +23 -0
  202. package/dist-cjs/ops/rate-limit-adapters.js +35 -1
  203. package/dist-cjs/ops/rate-limiter.d.ts +14 -2
  204. package/dist-cjs/ops/rate-limiter.js +32 -0
  205. package/dist-cjs/postgres/circuits.d.ts +21 -4
  206. package/dist-cjs/postgres/circuits.js +98 -3
  207. package/dist-cjs/postgres/client.d.ts +0 -2
  208. package/dist-cjs/postgres/client.js +0 -5
  209. package/dist-cjs/postgres/evaluate.d.ts +16 -4
  210. package/dist-cjs/postgres/evaluate.js +50 -4
  211. package/dist-cjs/postgres/index.d.ts +32 -11
  212. package/dist-cjs/postgres/index.js +69 -20
  213. package/dist-cjs/postgres/migrations.d.ts +60 -0
  214. package/dist-cjs/postgres/migrations.js +129 -0
  215. package/dist-cjs/postgres/operations.d.ts +14 -3
  216. package/dist-cjs/postgres/operations.js +78 -14
  217. package/dist-cjs/postgres/prompts.d.ts +11 -2
  218. package/dist-cjs/postgres/prompts.js +47 -2
  219. package/dist-cjs/postgres/rollups.d.ts +35 -0
  220. package/dist-cjs/postgres/rollups.js +168 -0
  221. package/dist-cjs/postgres/store.d.ts +11 -2
  222. package/dist-cjs/postgres/store.js +47 -2
  223. package/dist-cjs/postgres/traces.d.ts +13 -3
  224. package/dist-cjs/postgres/traces.js +58 -2
  225. package/dist-cjs/postgres/vectors.d.ts +11 -2
  226. package/dist-cjs/postgres/vectors.js +47 -2
  227. package/dist-cjs/router/auto-router.d.ts +34 -7
  228. package/dist-cjs/router/auto-router.js +201 -68
  229. package/dist-cjs/server/assistant.d.ts +44 -0
  230. package/dist-cjs/server/assistant.js +39 -0
  231. package/dist-cjs/server/errors.d.ts +12 -0
  232. package/dist-cjs/server/errors.js +9 -1
  233. package/dist-cjs/server/index.d.ts +1 -1
  234. package/dist-cjs/server/index.js +2 -1
  235. package/dist-cjs/server/runs.d.ts +28 -1
  236. package/dist-cjs/server/runs.js +109 -13
  237. package/dist-cjs/sqlite/index.d.ts +16 -3
  238. package/dist-cjs/sqlite/index.js +31 -10
  239. package/dist-cjs/sqlite/migrations.d.ts +26 -0
  240. package/dist-cjs/sqlite/migrations.js +71 -0
  241. package/dist-cjs/sqlite/operations.d.ts +14 -3
  242. package/dist-cjs/sqlite/operations.js +76 -15
  243. package/dist-cjs/sqlite/store.d.ts +9 -2
  244. package/dist-cjs/sqlite/store.js +47 -3
  245. package/dist-cjs/sqlite/vectors.d.ts +11 -2
  246. package/dist-cjs/sqlite/vectors.js +47 -2
  247. package/dist-cjs/store/tenant.d.ts +12 -0
  248. package/dist-cjs/store/tenant.js +33 -0
  249. package/dist-cjs/tenancy/index.d.ts +113 -0
  250. package/dist-cjs/tenancy/index.js +250 -0
  251. package/dist-cjs/testing/record.d.ts +42 -0
  252. package/dist-cjs/testing/record.js +57 -5
  253. package/dist-cjs/tracing/index.d.ts +3 -3
  254. package/dist-cjs/tracing/index.js +4 -1
  255. package/dist-cjs/tracing/instrument.js +30 -0
  256. package/dist-cjs/tracing/otlp.d.ts +91 -0
  257. package/dist-cjs/tracing/otlp.js +129 -0
  258. package/dist-cjs/tracing/rollups.d.ts +100 -0
  259. package/dist-cjs/tracing/rollups.js +147 -0
  260. package/dist-cjs/tracing/stores.d.ts +17 -0
  261. package/dist-cjs/tracing/stores.js +24 -0
  262. package/dist-cjs/tracing/tracer.d.ts +50 -4
  263. package/dist-cjs/tracing/tracer.js +87 -6
  264. package/dist-cjs/types/config.d.ts +33 -2
  265. package/dist-cjs/types/evaluate.d.ts +37 -0
  266. package/dist-cjs/types/graph.d.ts +354 -9
  267. package/dist-cjs/types/graph.js +3 -1
  268. package/dist-cjs/types/messages.d.ts +5 -0
  269. package/dist-cjs/types/operations.d.ts +22 -2
  270. package/dist-cjs/types/response.d.ts +20 -0
  271. package/dist-cjs/types/server.d.ts +18 -0
  272. package/dist-cjs/types/tracing.d.ts +11 -0
  273. package/dist-cjs/utils/schema-migrations.d.ts +97 -0
  274. package/dist-cjs/utils/schema-migrations.js +102 -0
  275. package/dist-cjs/utils/signals.d.ts +17 -0
  276. package/dist-cjs/utils/signals.js +29 -0
  277. package/dist-cjs/utils/tenant.d.ts +14 -0
  278. package/dist-cjs/utils/tenant.js +22 -0
  279. package/dist-cjs/version.d.ts +5 -0
  280. package/dist-cjs/version.js +4 -0
  281. package/llms.txt +4 -3
  282. package/package.json +110 -2
@@ -93,6 +93,7 @@ export class OperationRunner {
93
93
  expiresAt: options.expiresAt,
94
94
  idempotencyKey: options.idempotencyKey,
95
95
  kind: options.kind,
96
+ ...(options.tenantId ? { tenantId: options.tenantId } : {}),
96
97
  traceContext: options.traceContext,
97
98
  metadata: options.metadata,
98
99
  };
@@ -214,7 +215,7 @@ export class OperationRunner {
214
215
  const heartbeat = this.startHeartbeat(record, handle);
215
216
  let outcome;
216
217
  try {
217
- const value = await executor(this.buildContext(record, handle));
218
+ const value = await executor(this.buildContext(record, handle, heartbeat.beat));
218
219
  outcome = { ok: true, value };
219
220
  }
220
221
  catch (error) {
@@ -290,7 +291,13 @@ export class OperationRunner {
290
291
  startHeartbeat(record, handle) {
291
292
  let latest = record;
292
293
  let stopped = false;
293
- const beat = async () => {
294
+ let inFlight = Promise.resolve();
295
+ const beat = (details) => {
296
+ const next = inFlight.then(() => renew(details));
297
+ inFlight = next.catch(() => undefined);
298
+ return next;
299
+ };
300
+ const renew = async (details) => {
294
301
  if (stopped)
295
302
  return;
296
303
  const current = await this.store.read(latest.id);
@@ -315,6 +322,8 @@ export class OperationRunner {
315
322
  heartbeatAt: renewed.updatedAt,
316
323
  };
317
324
  renewed.progress = handle.progress() ?? current.progress;
325
+ if (details !== undefined)
326
+ renewed.heartbeatDetails = details;
318
327
  if (await this.store.update(renewed, current.sequence))
319
328
  latest = renewed;
320
329
  };
@@ -335,24 +344,14 @@ export class OperationRunner {
335
344
  beat,
336
345
  };
337
346
  }
338
- buildContext(record, handle) {
347
+ buildContext(record, handle, beat) {
339
348
  return {
340
349
  operationId: record.id,
341
350
  attempt: record.attempt,
342
351
  signal: handle.signal,
343
352
  report: (progress) => handle.report(progress),
344
- heartbeat: async () => {
345
- const current = await this.store.read(record.id);
346
- if (!current || isTerminalOperationStatus(current.status))
347
- return;
348
- const renewed = this.advance(current, 'running');
349
- renewed.lease = {
350
- owner: this.owner,
351
- expiresAt: new Date(this.now().getTime() + this.leaseMs).toISOString(),
352
- heartbeatAt: renewed.updatedAt,
353
- };
354
- await this.store.update(renewed, current.sequence);
355
- },
353
+ heartbeat: (details) => beat(details),
354
+ ...(record.heartbeatDetails === undefined ? {} : { previousHeartbeat: record.heartbeatDetails }),
356
355
  traceContext: record.traceContext,
357
356
  metadata: record.metadata,
358
357
  };
@@ -3,6 +3,8 @@ export function isUnheldQueued(record, now) {
3
3
  return record.status === 'queued' && (!record.lease || record.lease.expiresAt <= now);
4
4
  }
5
5
  export function matchesFilter(record, filter) {
6
+ if (filter?.tenantId !== undefined && record.tenantId !== filter.tenantId)
7
+ return false;
6
8
  return !filter?.kindPrefix || (record.kind ?? '').startsWith(filter.kindPrefix);
7
9
  }
8
10
  export function countRecords(records, now, filter) {
@@ -23,7 +25,12 @@ export function countRecords(records, now, filter) {
23
25
  }
24
26
  export async function operationStats(store, options = {}) {
25
27
  const now = (options.now ?? new Date()).toISOString();
26
- const filter = options.kindPrefix ? { kindPrefix: options.kindPrefix } : undefined;
28
+ const filter = options.kindPrefix || options.tenantId !== undefined
29
+ ? {
30
+ ...(options.kindPrefix ? { kindPrefix: options.kindPrefix } : {}),
31
+ ...(options.tenantId !== undefined ? { tenantId: options.tenantId } : {}),
32
+ }
33
+ : undefined;
27
34
  if (store.stats)
28
35
  return store.stats(now, filter);
29
36
  if (store.list)
@@ -19,7 +19,11 @@ export declare class MemoryOperationStore<TResult = unknown> implements Operatio
19
19
  private readonly byIdempotencyKey;
20
20
  private readonly maxRecords;
21
21
  constructor(options?: MemoryOperationStoreOptions);
22
- /** Stores a new record. Refuses records carrying raw bytes. */
22
+ /**
23
+ * Stores a new record. Throws `OperationDuplicateError` when another record already holds its
24
+ * idempotency key, as every shared store does, so two submissions racing under one key cannot both
25
+ * start. Refuses records carrying raw bytes.
26
+ */
23
27
  create(record: OperationRecord<TResult>): void;
24
28
  /** Reads a record. */
25
29
  read(id: string): OperationRecord<TResult> | undefined;
@@ -1,4 +1,5 @@
1
1
  import { isTerminalOperationStatus } from '../types/operations.js';
2
+ import { OperationDuplicateError } from './errors.js';
2
3
  import { assertSerializableRecord } from './serialization.js';
3
4
  import { countRecords, isUnheldQueued, matchesFilter } from './stats.js';
4
5
  export { assertSerializableRecord };
@@ -12,6 +13,12 @@ export class MemoryOperationStore {
12
13
  }
13
14
  create(record) {
14
15
  assertSerializableRecord(record);
16
+ if (record.idempotencyKey) {
17
+ const owner = this.byIdempotencyKey.get(record.idempotencyKey);
18
+ if (owner !== undefined && owner !== record.id && this.records.has(owner)) {
19
+ throw new OperationDuplicateError(record.idempotencyKey);
20
+ }
21
+ }
15
22
  this.records.set(record.id, clone(record));
16
23
  if (record.idempotencyKey)
17
24
  this.byIdempotencyKey.set(record.idempotencyKey, record.id);
@@ -41,6 +41,15 @@ export interface CircuitBreakerConfig {
41
41
  * `nexus-ai-pro/ops/circuit-store` and `nexus-ai-pro/postgres/circuits`.
42
42
  */
43
43
  store?: CircuitStateStore;
44
+ /**
45
+ * Counts every worker's calls together, so nine failures spread over three workers count as nine.
46
+ * Each worker adds the calls it saw to a rolling window in the `store` when it syncs, and opens the
47
+ * circuit everywhere once the shared count crosses `failureThreshold` (failures since any worker's
48
+ * last success) or `failureRateThreshold`. Checks stay synchronous: a worker learns the shared count
49
+ * on its next sync, at most `syncIntervalMs` late. Needs a store with `observe()`; every bundled one
50
+ * has it. Off by default, when each worker counts only its own calls.
51
+ */
52
+ shareObservations?: boolean;
44
53
  /** Names this worker in shared state. Defaults to a random id. */
45
54
  workerId?: string;
46
55
  /** Shared state is refreshed at most this often, on the next check that needs it. Defaults to 1 second. */
@@ -85,6 +94,41 @@ export interface CircuitSnapshot {
85
94
  retryAt?: string;
86
95
  /** The most recent error, as text. */
87
96
  lastError?: string;
97
+ /** Every worker's calls together, as of this worker's last sync, with `shareObservations`. */
98
+ shared?: CircuitWindow;
99
+ }
100
+ /** Calls one worker saw since it last reported, which `CircuitStateStore.observe()` adds to the shared window. */
101
+ export interface CircuitObservation {
102
+ /** Calls and failures per window bucket, by the bucket's start in epoch milliseconds. */
103
+ buckets: Array<{
104
+ at: number;
105
+ calls: number;
106
+ failures: number;
107
+ }>;
108
+ /** Whether any of the calls succeeded, which ends the shared failure streak before `trailingFailures` is added. */
109
+ succeeded: boolean;
110
+ /** Failures after the last success, or every failure when none succeeded. */
111
+ trailingFailures: number;
112
+ /** The rolling window, in milliseconds. */
113
+ windowMs: number;
114
+ /** The width of one bucket. A bucket that ended before `now - windowMs` is dropped. */
115
+ bucketMs: number;
116
+ /** Epoch milliseconds now, by the breaker's clock. */
117
+ now: number;
118
+ /**
119
+ * Epoch milliseconds of the circuit's last close. Buckets that started before it are not counted, so
120
+ * failures that opened a circuit do not reopen it the moment a probe closes it.
121
+ */
122
+ since?: number;
123
+ }
124
+ /** A provider's calls across every worker, inside the rolling window. */
125
+ export interface CircuitWindow {
126
+ /** Calls counted. */
127
+ calls: number;
128
+ /** Of those, failures. */
129
+ failures: number;
130
+ /** Failures since any worker last reported a success. */
131
+ consecutiveFailures: number;
88
132
  }
89
133
  /** A circuit decision as every worker sees it. */
90
134
  export interface SharedCircuitState {
@@ -119,6 +163,12 @@ export interface CircuitStateStore {
119
163
  * recovering provider is probed once rather than by every replica at the same moment.
120
164
  */
121
165
  claimProbe(providerName: string, owner: string, ttlMs: number): Promise<boolean> | boolean;
166
+ /**
167
+ * Adds one worker's calls to a provider's shared window, and returns the window across every
168
+ * worker. Optional; `shareObservations` needs it. Each call must add atomically, so two workers
169
+ * reporting at once both count.
170
+ */
171
+ observe?(providerName: string, observation: CircuitObservation): Promise<CircuitWindow> | CircuitWindow;
122
172
  }
123
173
  /** @internal A circuit's local state. Shared with the coordinator module, never exported publicly. */
124
174
  export interface CircuitEntry {
@@ -139,6 +189,8 @@ export interface CircuitEntry {
139
189
  probeClaimed: boolean;
140
190
  /** Why the circuit last changed state. */
141
191
  reason?: string;
192
+ /** The shared window from the last sync, with `shareObservations`. */
193
+ shared?: CircuitWindow;
142
194
  }
143
195
  /**
144
196
  * Trips routing away from a provider that is failing.
@@ -69,6 +69,7 @@ export class CircuitBreaker {
69
69
  this.refreshState(providerName, entry);
70
70
  this.pushWindow(entry, false);
71
71
  entry.consecutiveFailures = 0;
72
+ this.coordinator?.observed(providerName, false);
72
73
  if (entry.state === 'half-open') {
73
74
  entry.halfOpenInFlight = Math.max(0, entry.halfOpenInFlight - 1);
74
75
  entry.halfOpenSuccesses += 1;
@@ -90,6 +91,7 @@ export class CircuitBreaker {
90
91
  }
91
92
  this.pushWindow(entry, true);
92
93
  entry.consecutiveFailures += 1;
94
+ this.coordinator?.observed(providerName, true);
93
95
  entry.lastError = error instanceof Error ? error.message : error === undefined ? undefined : String(error);
94
96
  if (entry.state === 'half-open') {
95
97
  entry.halfOpenInFlight = Math.max(0, entry.halfOpenInFlight - 1);
@@ -126,6 +128,7 @@ export class CircuitBreaker {
126
128
  ? new Date(entry.openedAt + resetTimeoutMs).toISOString()
127
129
  : undefined,
128
130
  lastError: entry.lastError,
131
+ ...(entry.shared ? { shared: { ...entry.shared } } : {}),
129
132
  };
130
133
  });
131
134
  }
@@ -176,6 +179,7 @@ export class CircuitBreaker {
176
179
  entry.openedAt = undefined;
177
180
  entry.consecutiveFailures = 0;
178
181
  entry.window = [];
182
+ entry.shared = undefined;
179
183
  entry.halfOpenInFlight = 0;
180
184
  entry.halfOpenSuccesses = 0;
181
185
  entry.probeClaimed = false;
@@ -1,5 +1,5 @@
1
- import type { CircuitStateStore, SharedCircuitState } from './circuit-breaker.js';
2
- export type { CircuitStateStore, SharedCircuitState } from './circuit-breaker.js';
1
+ import type { CircuitObservation, CircuitStateStore, CircuitWindow, SharedCircuitState } from './circuit-breaker.js';
2
+ export type { CircuitObservation, CircuitStateStore, CircuitWindow, SharedCircuitState, } from './circuit-breaker.js';
3
3
  /**
4
4
  * Shared circuit state in one process.
5
5
  *
@@ -10,7 +10,10 @@ export declare class MemoryCircuitStateStore implements CircuitStateStore {
10
10
  private readonly now;
11
11
  private readonly states;
12
12
  private readonly probes;
13
+ private readonly windows;
13
14
  constructor(now?: () => number);
15
+ /** Adds one worker's calls to a provider's window and returns the window across every worker. */
16
+ observe(providerName: string, observation: CircuitObservation): CircuitWindow;
14
17
  /** Every provider's shared circuit state. */
15
18
  read(): SharedCircuitState[];
16
19
  /**
@@ -66,7 +69,15 @@ export declare class RedisCircuitStateStore implements CircuitStateStore {
66
69
  private readonly client;
67
70
  private readonly prefix;
68
71
  private readonly useEval;
72
+ /**
73
+ * Adds one worker's calls to a provider's shared window in one Lua call, and returns the window
74
+ * across every worker. Present only when the client has `eval`, since the add and the sum must
75
+ * happen together; without it, `shareObservations` has nothing to call and each worker counts
76
+ * its own calls.
77
+ */
78
+ readonly observe?: (providerName: string, observation: CircuitObservation) => Promise<CircuitWindow>;
69
79
  constructor(client: RedisCircuitLikeClient, options?: RedisCircuitStateStoreOptions);
80
+ private observeWithEval;
70
81
  /** Every provider's shared circuit state. Unreadable entries are skipped. */
71
82
  read(): Promise<SharedCircuitState[]>;
72
83
  /**
@@ -2,9 +2,37 @@ export class MemoryCircuitStateStore {
2
2
  now;
3
3
  states = new Map();
4
4
  probes = new Map();
5
+ windows = new Map();
5
6
  constructor(now = () => Date.now()) {
6
7
  this.now = now;
7
8
  }
9
+ observe(providerName, observation) {
10
+ let window = this.windows.get(providerName);
11
+ if (!window) {
12
+ window = { buckets: new Map(), streak: 0 };
13
+ this.windows.set(providerName, window);
14
+ }
15
+ for (const bucket of observation.buckets) {
16
+ const stored = window.buckets.get(bucket.at) ?? { calls: 0, failures: 0 };
17
+ stored.calls += bucket.calls;
18
+ stored.failures += bucket.failures;
19
+ window.buckets.set(bucket.at, stored);
20
+ }
21
+ window.streak = observation.succeeded ? observation.trailingFailures : window.streak + observation.trailingFailures;
22
+ const keepAfter = observation.now - observation.windowMs - observation.bucketMs;
23
+ const since = observation.since ?? Number.NEGATIVE_INFINITY;
24
+ let calls = 0;
25
+ let failures = 0;
26
+ for (const [at, bucket] of window.buckets) {
27
+ if (at <= keepAfter)
28
+ window.buckets.delete(at);
29
+ else if (at >= since) {
30
+ calls += bucket.calls;
31
+ failures += bucket.failures;
32
+ }
33
+ }
34
+ return { calls, failures, consecutiveFailures: window.streak };
35
+ }
8
36
  read() {
9
37
  return [...this.states.values()].map((state) => ({ ...state }));
10
38
  }
@@ -34,14 +62,54 @@ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
34
62
  redis.call('DEL', KEYS[2])
35
63
  return 1
36
64
  `;
65
+ const OBSERVE_SCRIPT = `
66
+ local key = KEYS[1]
67
+ for i = 6, #ARGV, 3 do
68
+ redis.call('HINCRBY', key, 'c:' .. ARGV[i], ARGV[i + 1])
69
+ redis.call('HINCRBY', key, 'f:' .. ARGV[i], ARGV[i + 2])
70
+ end
71
+ local streak
72
+ if ARGV[1] == '1' then
73
+ redis.call('HSET', key, 'streak', ARGV[2])
74
+ streak = tonumber(ARGV[2])
75
+ else
76
+ streak = tonumber(redis.call('HINCRBY', key, 'streak', ARGV[2]))
77
+ end
78
+ local keepAfter = tonumber(ARGV[3])
79
+ local since = tonumber(ARGV[4])
80
+ local calls, failures = 0, 0
81
+ local fields = redis.call('HGETALL', key)
82
+ for i = 1, #fields, 2 do
83
+ local name = fields[i]
84
+ local kind = string.sub(name, 1, 2)
85
+ if kind == 'c:' or kind == 'f:' then
86
+ local at = tonumber(string.sub(name, 3))
87
+ if at <= keepAfter then
88
+ redis.call('HDEL', key, name)
89
+ elseif at >= since then
90
+ if kind == 'c:' then calls = calls + tonumber(fields[i + 1]) else failures = failures + tonumber(fields[i + 1]) end
91
+ end
92
+ end
93
+ end
94
+ redis.call('PEXPIRE', key, ARGV[5])
95
+ return { calls, failures, streak }
96
+ `;
37
97
  export class RedisCircuitStateStore {
38
98
  client;
39
99
  prefix;
40
100
  useEval;
101
+ observe;
41
102
  constructor(client, options = {}) {
42
103
  this.client = client;
43
104
  this.prefix = options.prefix ?? 'nexus-ai-pro:circuits:';
44
105
  this.useEval = options.useEval !== false && typeof client.eval === 'function';
106
+ if (this.useEval)
107
+ this.observe = (providerName, observation) => this.observeWithEval(providerName, observation);
108
+ }
109
+ async observeWithEval(providerName, observation) {
110
+ const reply = (await this.client.eval(OBSERVE_SCRIPT, 1, `${this.prefix}window:${providerName}`, observation.succeeded ? '1' : '0', String(observation.trailingFailures), String(observation.now - observation.windowMs - observation.bucketMs), String(observation.since ?? -1), String(observation.windowMs * 2 + observation.bucketMs), ...observation.buckets.flatMap((bucket) => [String(bucket.at), String(bucket.calls), String(bucket.failures)])));
111
+ const [calls, failures, streak] = (reply ?? []).map(Number);
112
+ return { calls: calls ?? 0, failures: failures ?? 0, consecutiveFailures: streak ?? 0 };
45
113
  }
46
114
  async read() {
47
115
  const all = (await this.client.hgetall(this.statesKey())) ?? {};
@@ -26,6 +26,8 @@ export declare class CircuitCoordinator {
26
26
  private lastSyncAt;
27
27
  /** False after a store failure, until a sync succeeds; the breaker then decides locally. */
28
28
  private reachable;
29
+ /** Calls not yet added to the shared window, per provider, with `shareObservations`. */
30
+ private readonly unreported;
29
31
  constructor(host: CircuitHost);
30
32
  /**
31
33
  * Whether this worker may go half-open once a cooldown has elapsed. With a reachable store only
@@ -35,6 +37,11 @@ export declare class CircuitCoordinator {
35
37
  /** Starts a background sync when the last one is older than the interval. Never waits. */
36
38
  tick(): void;
37
39
  sync(): Promise<void>;
40
+ /**
41
+ * Counts one call toward the shared window, to be added on the next sync. Cheap and synchronous:
42
+ * a check never waits for the store.
43
+ */
44
+ observed(providerName: string, failed: boolean): void;
38
45
  /** Publishes a transition this worker made. */
39
46
  published(providerName: string, entry: CircuitEntry, to: 'open' | 'closed', reason: string): void;
40
47
  /** Publishes an operator reset, so every worker closes the circuit rather than only this one. */
@@ -45,6 +52,14 @@ export declare class CircuitCoordinator {
45
52
  */
46
53
  reconcile(): void;
47
54
  flush(): Promise<void>;
55
+ /**
56
+ * Adds this worker's unreported calls to each provider's shared window, then opens a closed circuit
57
+ * whose shared count crossed a threshold. The batch is taken before the first await, so calls made
58
+ * while it is in flight wait for the next sync rather than being lost or counted twice.
59
+ */
60
+ private report;
61
+ /** Opens a closed circuit whose shared window crossed the breaker's thresholds. */
62
+ private judge;
48
63
  /** Takes a decision another worker published, when it is newer than what this worker knows. */
49
64
  private adopt;
50
65
  /**
@@ -55,3 +70,5 @@ export declare class CircuitCoordinator {
55
70
  private publish;
56
71
  private failed;
57
72
  }
73
+ /** The width of one shared-window bucket: a twelfth of the window, so a bucket rolling out moves the count by little. */
74
+ export declare function bucketWidth(windowMs: number): number;
@@ -4,6 +4,7 @@ export class CircuitCoordinator {
4
4
  syncing;
5
5
  lastSyncAt = Number.NEGATIVE_INFINITY;
6
6
  reachable = true;
7
+ unreported = new Map();
7
8
  constructor(host) {
8
9
  this.host = host;
9
10
  }
@@ -26,6 +27,8 @@ export class CircuitCoordinator {
26
27
  this.syncing = (async () => {
27
28
  try {
28
29
  this.lastSyncAt = this.host.now();
30
+ if (this.unreported.size > 0)
31
+ await this.report(store);
29
32
  for (const shared of await store.read())
30
33
  this.adopt(shared);
31
34
  const resetTimeoutMs = this.host.config.resetTimeoutMs ?? 30_000;
@@ -51,6 +54,29 @@ export class CircuitCoordinator {
51
54
  })();
52
55
  return this.syncing;
53
56
  }
57
+ observed(providerName, failed) {
58
+ if (!this.host.config.shareObservations || !this.host.config.store?.observe)
59
+ return;
60
+ const bucketMs = bucketWidth(this.host.config.windowMs ?? 60_000);
61
+ const at = Math.floor(this.host.now() / bucketMs) * bucketMs;
62
+ let pending = this.unreported.get(providerName);
63
+ if (!pending) {
64
+ pending = { buckets: new Map(), succeeded: false, trailing: 0 };
65
+ this.unreported.set(providerName, pending);
66
+ }
67
+ const bucket = pending.buckets.get(at) ?? { calls: 0, failures: 0 };
68
+ bucket.calls += 1;
69
+ if (failed)
70
+ bucket.failures += 1;
71
+ pending.buckets.set(at, bucket);
72
+ if (failed)
73
+ pending.trailing += 1;
74
+ else {
75
+ pending.succeeded = true;
76
+ pending.trailing = 0;
77
+ }
78
+ this.tick();
79
+ }
54
80
  published(providerName, entry, to, reason) {
55
81
  entry.changedAt = this.stampAfter(entry.changedAt);
56
82
  this.publish({
@@ -72,6 +98,23 @@ export class CircuitCoordinator {
72
98
  reconcile() {
73
99
  for (const name of [...this.host.names()]) {
74
100
  const entry = this.host.entry(name);
101
+ if (this.host.config.shareObservations && this.host.config.store?.observe && entry.window.length > 0) {
102
+ const bucketMs = bucketWidth(this.host.config.windowMs ?? 60_000);
103
+ const buckets = new Map();
104
+ for (const call of entry.window) {
105
+ const at = Math.floor(call.at / bucketMs) * bucketMs;
106
+ const bucket = buckets.get(at) ?? { calls: 0, failures: 0 };
107
+ bucket.calls += 1;
108
+ if (call.failed)
109
+ bucket.failures += 1;
110
+ buckets.set(at, bucket);
111
+ }
112
+ this.unreported.set(name, {
113
+ buckets,
114
+ succeeded: entry.window.some((call) => !call.failed),
115
+ trailing: entry.consecutiveFailures,
116
+ });
117
+ }
75
118
  if (entry.state === 'open' && entry.changedAt === Number.NEGATIVE_INFINITY) {
76
119
  this.published(name, entry, 'open', entry.reason ?? 'opened while shared state was loading');
77
120
  }
@@ -81,6 +124,46 @@ export class CircuitCoordinator {
81
124
  async flush() {
82
125
  await Promise.all([...this.pending]);
83
126
  }
127
+ async report(store) {
128
+ if (!store.observe || this.unreported.size === 0)
129
+ return;
130
+ const batches = [...this.unreported];
131
+ this.unreported.clear();
132
+ const windowMs = this.host.config.windowMs ?? 60_000;
133
+ for (const [name, pending] of batches) {
134
+ const entry = this.host.entry(name);
135
+ const window = await store.observe(name, {
136
+ buckets: [...pending.buckets].map(([at, bucket]) => ({ at, calls: bucket.calls, failures: bucket.failures })),
137
+ succeeded: pending.succeeded,
138
+ trailingFailures: pending.trailing,
139
+ windowMs,
140
+ bucketMs: bucketWidth(windowMs),
141
+ now: this.host.now(),
142
+ ...(entry.state === 'closed' && Number.isFinite(entry.changedAt) ? { since: entry.changedAt } : {}),
143
+ });
144
+ entry.shared = {
145
+ calls: window.calls,
146
+ failures: window.failures,
147
+ consecutiveFailures: window.consecutiveFailures,
148
+ };
149
+ this.judge(name, entry, window);
150
+ }
151
+ }
152
+ judge(name, entry, window) {
153
+ if (entry.state !== 'closed')
154
+ return;
155
+ const config = this.host.config;
156
+ if (window.consecutiveFailures >= (config.failureThreshold ?? 5)) {
157
+ this.host.transition(name, entry, 'open', 'shared failure threshold reached');
158
+ return;
159
+ }
160
+ const rate = config.failureRateThreshold;
161
+ if (rate !== undefined &&
162
+ window.calls >= (config.minimumThroughput ?? 10) &&
163
+ window.failures / window.calls >= rate) {
164
+ this.host.transition(name, entry, 'open', 'shared failure rate threshold reached');
165
+ }
166
+ }
84
167
  adopt(shared) {
85
168
  const entry = this.host.entry(shared.providerName);
86
169
  if (shared.updatedAt <= entry.changedAt)
@@ -120,3 +203,6 @@ export class CircuitCoordinator {
120
203
  }
121
204
  }
122
205
  }
206
+ export function bucketWidth(windowMs) {
207
+ return Math.max(1, Math.floor(windowMs / 12));
208
+ }
@@ -21,7 +21,18 @@ export interface ProviderHealthSnapshot {
21
21
  * latency up to 30.
22
22
  */
23
23
  score: number;
24
+ /**
25
+ * `unknown` with no recent calls to judge by, `unhealthy` past the failure threshold or under the
26
+ * minimum score, `degraded` when calls are failing or slow but not enough to stop routing to it,
27
+ * and `healthy` otherwise. Always set by `ProviderHealthMonitor`; optional so a hand-built snapshot
28
+ * still type-checks.
29
+ */
30
+ status?: ProviderHealthStatus;
31
+ /** True when the last call is older than `observationTtlMs`, so the figures above are old news. */
32
+ stale?: boolean;
24
33
  }
34
+ /** Where a provider stands, by what its recent calls say. */
35
+ export type ProviderHealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
25
36
  /** Health tracking for providers, used to route around unhealthy ones. */
26
37
  export interface HealthConfig {
27
38
  /** Turns tracking on. Off by default, and then nothing is recorded. */
@@ -30,12 +41,22 @@ export interface HealthConfig {
30
41
  failureThreshold?: number;
31
42
  /** Score below which a provider is unhealthy. Defaults to 20. */
32
43
  minScore?: number;
44
+ /**
45
+ * How long a call's outcome counts, in milliseconds. A provider whose last call is older is
46
+ * `unknown`, and is routed to as a provider never seen is, rather than kept down for a failure
47
+ * hours ago or kept up for a success days ago. `checkProviders({ staleOnly: true })` refreshes it.
48
+ * Defaults to never expiring, as before.
49
+ */
50
+ observationTtlMs?: number;
51
+ /** Replaces the system clock, for tests. */
52
+ now?: () => Date;
33
53
  }
34
54
  /** Tracks provider health from call outcomes and scores each provider for routing. */
35
55
  export declare class ProviderHealthMonitor {
36
56
  private config;
37
57
  private states;
38
58
  constructor(config?: HealthConfig);
59
+ private nowDate;
39
60
  /** Records a successful call and its latency. */
40
61
  recordSuccess(providerName: string, latencyMs: number): void;
41
62
  /** Records a failed call. */
@@ -46,6 +67,9 @@ export declare class ProviderHealthMonitor {
46
67
  score(providerName: string): number;
47
68
  /** Whether a provider is healthy. */
48
69
  isHealthy(providerName: string): boolean;
70
+ /** Whether a provider's figures are too old to judge by, or there are none. */
71
+ isUnknown(providerName: string): boolean;
72
+ private isStale;
49
73
  private snapshotOne;
50
74
  private state;
51
75
  }
@@ -4,23 +4,30 @@ export class ProviderHealthMonitor {
4
4
  constructor(config = {}) {
5
5
  this.config = config;
6
6
  }
7
+ nowDate() {
8
+ return this.config.now?.() ?? new Date();
9
+ }
7
10
  recordSuccess(providerName, latencyMs) {
8
11
  if (!this.config.enabled)
9
12
  return;
10
13
  const state = this.state(providerName);
11
14
  state.successes += 1;
12
15
  state.consecutiveFailures = 0;
16
+ if (this.isStale(state))
17
+ Object.assign(state, freshState());
13
18
  state.avgLatencyMs = state.avgLatencyMs ? state.avgLatencyMs * 0.7 + latencyMs * 0.3 : latencyMs;
14
- state.lastCheckedAt = new Date().toISOString();
19
+ state.lastCheckedAt = this.nowDate().toISOString();
15
20
  }
16
21
  recordFailure(providerName, error) {
17
22
  if (!this.config.enabled)
18
23
  return;
19
24
  const state = this.state(providerName);
25
+ if (this.isStale(state))
26
+ state.consecutiveFailures = 0;
20
27
  state.failures += 1;
21
28
  state.consecutiveFailures += 1;
22
29
  state.lastError = error instanceof Error ? error.message : String(error);
23
- state.lastCheckedAt = new Date().toISOString();
30
+ state.lastCheckedAt = this.nowDate().toISOString();
24
31
  }
25
32
  snapshot(providerName) {
26
33
  const names = providerName ? [providerName] : [...this.states.keys()];
@@ -32,16 +39,39 @@ export class ProviderHealthMonitor {
32
39
  isHealthy(providerName) {
33
40
  return this.snapshotOne(providerName).healthy;
34
41
  }
42
+ isUnknown(providerName) {
43
+ return this.snapshotOne(providerName).status === 'unknown';
44
+ }
45
+ isStale(state) {
46
+ const ttl = this.config.observationTtlMs;
47
+ if (ttl === undefined || !state.lastCheckedAt)
48
+ return false;
49
+ return this.nowDate().getTime() - Date.parse(state.lastCheckedAt) > ttl;
50
+ }
35
51
  snapshotOne(providerName) {
36
- const state = this.state(providerName);
52
+ const recorded = this.state(providerName);
53
+ const stale = this.isStale(recorded);
54
+ const state = stale
55
+ ? { ...freshState(), lastError: recorded.lastError, lastCheckedAt: recorded.lastCheckedAt }
56
+ : recorded;
37
57
  const failureThreshold = this.config.failureThreshold || 3;
38
58
  const failurePenalty = Math.min(60, state.consecutiveFailures * 20);
39
59
  const latencyPenalty = Math.min(30, state.avgLatencyMs / 1000);
40
60
  const score = Math.max(0, 100 - failurePenalty - latencyPenalty);
41
61
  const minScore = this.config.minScore ?? 20;
62
+ const healthy = state.consecutiveFailures < failureThreshold && score >= minScore;
63
+ const seen = recorded.successes + recorded.failures > 0;
42
64
  return {
43
65
  providerName,
44
- healthy: state.consecutiveFailures < failureThreshold && score >= minScore,
66
+ healthy,
67
+ status: stale || !seen
68
+ ? 'unknown'
69
+ : !healthy
70
+ ? 'unhealthy'
71
+ : state.consecutiveFailures > 0 || score < 70
72
+ ? 'degraded'
73
+ : 'healthy',
74
+ stale,
45
75
  successes: state.successes,
46
76
  failures: state.failures,
47
77
  consecutiveFailures: state.consecutiveFailures,
@@ -54,14 +84,12 @@ export class ProviderHealthMonitor {
54
84
  state(providerName) {
55
85
  let state = this.states.get(providerName);
56
86
  if (!state) {
57
- state = {
58
- successes: 0,
59
- failures: 0,
60
- consecutiveFailures: 0,
61
- avgLatencyMs: 0,
62
- };
87
+ state = freshState();
63
88
  this.states.set(providerName, state);
64
89
  }
65
90
  return state;
66
91
  }
67
92
  }
93
+ function freshState() {
94
+ return { successes: 0, failures: 0, consecutiveFailures: 0, avgLatencyMs: 0 };
95
+ }