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
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.BullMQOperationDispatcher = exports.RedisOperationStore = void 0;
4
4
  const operations_js_1 = require("../types/operations.js");
5
+ const errors_js_1 = require("./errors.js");
5
6
  const serialization_js_1 = require("./serialization.js");
6
7
  const stats_js_1 = require("./stats.js");
7
8
  const CAS_SCRIPT = `
@@ -12,21 +13,102 @@ if tonumber(decoded.sequence) ~= tonumber(ARGV[2]) then return 0 end
12
13
  redis.call('HSET', KEYS[1], ARGV[1], ARGV[3])
13
14
  return 1
14
15
  `;
16
+ const PLACE_LUA = `
17
+ local function place(key, score, id)
18
+ if score ~= '' then redis.call('ZADD', key, score, id) else redis.call('ZREM', key, id) end
19
+ end
20
+ `;
21
+ const UNIQUE_KEY_LUA = `
22
+ if ARGV[3] ~= '' then
23
+ local owner = redis.call('HGET', KEYS[2], ARGV[3])
24
+ if owner and owner ~= ARGV[1] and redis.call('HEXISTS', KEYS[1], owner) == 1 then return 0 end
25
+ end
26
+ `;
27
+ const CREATE_SCRIPT = `${UNIQUE_KEY_LUA}
28
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
29
+ if ARGV[3] ~= '' then redis.call('HSET', KEYS[2], ARGV[3], ARGV[1]) end
30
+ return 1
31
+ `;
32
+ const INDEXED_CREATE_SCRIPT = `${PLACE_LUA}${UNIQUE_KEY_LUA}
33
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
34
+ if ARGV[3] ~= '' then redis.call('HSET', KEYS[2], ARGV[3], ARGV[1]) end
35
+ place(KEYS[3], ARGV[4], ARGV[1])
36
+ place(KEYS[4], ARGV[5], ARGV[1])
37
+ return 1
38
+ `;
39
+ const INDEXED_CAS_SCRIPT = `${PLACE_LUA}
40
+ local current = redis.call('HGET', KEYS[1], ARGV[1])
41
+ if current == false then return 0 end
42
+ local decoded = cjson.decode(current)
43
+ if tonumber(decoded.sequence) ~= tonumber(ARGV[2]) then return 0 end
44
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[3])
45
+ place(KEYS[2], ARGV[4], ARGV[1])
46
+ place(KEYS[3], ARGV[5], ARGV[1])
47
+ return 1
48
+ `;
49
+ const PRUNE_SCRIPT = `
50
+ local terminal = { ${operations_js_1.TERMINAL_OPERATION_STATUSES.map((status) => `${status} = true`).join(', ')} }
51
+ local removed = 0
52
+ for i = 2, #ARGV do
53
+ local raw = redis.call('HGET', KEYS[1], ARGV[i])
54
+ local keep = false
55
+ if raw then
56
+ local record = cjson.decode(raw)
57
+ if ARGV[1] == 'queued' then
58
+ keep = record.status == 'queued'
59
+ else
60
+ keep = (not terminal[record.status]) and (record.lease ~= nil or record.status == 'running')
61
+ end
62
+ end
63
+ if not keep then
64
+ redis.call('ZREM', KEYS[2], ARGV[i])
65
+ removed = removed + 1
66
+ end
67
+ end
68
+ return removed
69
+ `;
15
70
  class RedisOperationStore {
16
71
  client;
17
72
  prefix;
18
73
  useEval;
74
+ indexed;
19
75
  constructor(client, options = {}) {
20
76
  this.client = client;
21
77
  this.prefix = options.prefix ?? 'nexus-ai-pro:operations:';
22
78
  this.useEval = options.useEval !== false && typeof client.eval === 'function';
79
+ this.indexed = options.index === true;
80
+ if (this.indexed && !(client.zadd && client.zrem && client.zrangebyscore)) {
81
+ throw new TypeError('A Redis operation store with index: true needs zadd, zrem, and zrangebyscore');
82
+ }
23
83
  }
24
84
  async create(record) {
25
85
  (0, serialization_js_1.assertSerializableRecord)(record);
26
- await this.client.hset(this.recordsKey(), record.id, JSON.stringify(record));
86
+ const encoded = JSON.stringify(record);
87
+ const duplicate = () => new errors_js_1.OperationDuplicateError(record.idempotencyKey);
88
+ if (this.useEval && this.client.eval && !this.indexed) {
89
+ const created = await this.client.eval(CREATE_SCRIPT, 2, this.recordsKey(), this.idempotencyKey(), record.id, encoded, record.idempotencyKey ?? '');
90
+ if (Number(created) !== 1)
91
+ throw duplicate();
92
+ return;
93
+ }
94
+ if (this.indexed && this.useEval && this.client.eval) {
95
+ const { queued, lease } = indexScores(record);
96
+ const created = await this.client.eval(INDEXED_CREATE_SCRIPT, 4, this.recordsKey(), this.idempotencyKey(), this.queuedKey(), this.leasesKey(), record.id, encoded, record.idempotencyKey ?? '', queued, lease);
97
+ if (Number(created) !== 1)
98
+ throw duplicate();
99
+ return;
100
+ }
101
+ if (record.idempotencyKey) {
102
+ const owner = await this.client.hget(this.idempotencyKey(), record.idempotencyKey);
103
+ if (owner && owner !== record.id && (await this.client.hget(this.recordsKey(), owner)))
104
+ throw duplicate();
105
+ }
106
+ await this.client.hset(this.recordsKey(), record.id, encoded);
27
107
  if (record.idempotencyKey) {
28
108
  await this.client.hset(this.idempotencyKey(), record.idempotencyKey, record.id);
29
109
  }
110
+ if (this.indexed)
111
+ await this.place(record);
30
112
  }
31
113
  async read(id) {
32
114
  const raw = await this.client.hget(this.recordsKey(), id);
@@ -36,6 +118,11 @@ class RedisOperationStore {
36
118
  (0, serialization_js_1.assertSerializableRecord)(record);
37
119
  const encoded = JSON.stringify(record);
38
120
  if (this.useEval && this.client.eval) {
121
+ if (this.indexed) {
122
+ const { queued, lease } = indexScores(record);
123
+ const result = await this.client.eval(INDEXED_CAS_SCRIPT, 3, this.recordsKey(), this.queuedKey(), this.leasesKey(), record.id, String(expectedSequence), encoded, queued, lease);
124
+ return Number(result) === 1;
125
+ }
39
126
  const result = await this.client.eval(CAS_SCRIPT, 1, this.recordsKey(), record.id, String(expectedSequence), encoded);
40
127
  return Number(result) === 1;
41
128
  }
@@ -43,6 +130,8 @@ class RedisOperationStore {
43
130
  if (!current || current.sequence !== expectedSequence)
44
131
  return false;
45
132
  await this.client.hset(this.recordsKey(), record.id, encoded);
133
+ if (this.indexed)
134
+ await this.place(record);
46
135
  return true;
47
136
  }
48
137
  async delete(id) {
@@ -50,19 +139,22 @@ class RedisOperationStore {
50
139
  if (record?.idempotencyKey)
51
140
  await this.client.hdel(this.idempotencyKey(), record.idempotencyKey);
52
141
  await this.client.hdel(this.recordsKey(), id);
142
+ if (this.indexed) {
143
+ await this.client.zrem?.(this.queuedKey(), id);
144
+ await this.client.zrem?.(this.leasesKey(), id);
145
+ }
53
146
  return record !== undefined;
54
147
  }
55
148
  async claimExpired(now, limit) {
149
+ if (this.indexed) {
150
+ return this.scanIndex('leases', Date.parse(now), limit, (record) => isExpired(record, now), isLeased);
151
+ }
56
152
  const records = await this.list();
57
153
  const expired = [];
58
154
  for (const record of records) {
59
155
  if (expired.length >= limit)
60
156
  break;
61
- if ((0, operations_js_1.isTerminalOperationStatus)(record.status))
62
- continue;
63
- if (record.lease && record.lease.expiresAt > now)
64
- continue;
65
- if (record.status === 'running' || record.lease)
157
+ if (isExpired(record, now))
66
158
  expired.push(record);
67
159
  }
68
160
  return expired;
@@ -77,6 +169,9 @@ class RedisOperationStore {
77
169
  }
78
170
  async listQueued(limit, filter) {
79
171
  const now = new Date().toISOString();
172
+ if (this.indexed) {
173
+ return this.scanIndex('queued', Number.POSITIVE_INFINITY, limit, (record) => (0, stats_js_1.isUnheldQueued)(record, now) && (0, stats_js_1.matchesFilter)(record, filter), (record) => record.status === 'queued');
174
+ }
80
175
  return (await this.list())
81
176
  .filter((record) => (0, stats_js_1.isUnheldQueued)(record, now) && (0, stats_js_1.matchesFilter)(record, filter))
82
177
  .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
@@ -85,14 +180,106 @@ class RedisOperationStore {
85
180
  async stats(now, filter) {
86
181
  return (0, stats_js_1.countRecords)(await this.list(), now, filter);
87
182
  }
183
+ async reindex() {
184
+ if (!this.indexed)
185
+ throw new TypeError('reindex() needs a store created with index: true');
186
+ const records = await this.list();
187
+ for (const record of records) {
188
+ const { queued, lease } = indexScores(record);
189
+ if (queued !== '')
190
+ await this.client.zadd?.(this.queuedKey(), Number(queued), record.id);
191
+ if (lease !== '')
192
+ await this.client.zadd?.(this.leasesKey(), Number(lease), record.id);
193
+ }
194
+ await this.prune('queued', records.filter((record) => record.status !== 'queued').map((record) => record.id));
195
+ await this.prune('leases', records.filter((record) => !isLeased(record)).map((record) => record.id));
196
+ return records.length;
197
+ }
198
+ async scanIndex(index, max, limit, keep, belongs) {
199
+ const found = [];
200
+ if (limit <= 0)
201
+ return found;
202
+ const stale = [];
203
+ const key = index === 'queued' ? this.queuedKey() : this.leasesKey();
204
+ const upper = max === Number.POSITIVE_INFINITY ? '+inf' : max;
205
+ const page = Math.max(16, limit * 4);
206
+ for (let offset = 0; found.length < limit; offset += page) {
207
+ const ids = (await this.client.zrangebyscore?.(key, '-inf', upper, 'LIMIT', offset, page)) ?? [];
208
+ const records = await this.readMany(ids);
209
+ for (const [position, id] of ids.entries()) {
210
+ const record = records[position];
211
+ if (!record || !belongs(record)) {
212
+ stale.push(id);
213
+ continue;
214
+ }
215
+ if (keep(record))
216
+ found.push(record);
217
+ if (found.length >= limit)
218
+ break;
219
+ }
220
+ if (ids.length < page)
221
+ break;
222
+ }
223
+ await this.prune(index, stale);
224
+ return found;
225
+ }
226
+ async readMany(ids) {
227
+ if (ids.length === 0)
228
+ return [];
229
+ const raws = this.client.hmget
230
+ ? await this.client.hmget(this.recordsKey(), ...ids)
231
+ : await Promise.all(ids.map((id) => this.client.hget(this.recordsKey(), id)));
232
+ return raws.map((raw) => (raw ? JSON.parse(raw) : undefined));
233
+ }
234
+ async prune(index, ids) {
235
+ if (ids.length === 0 || !this.useEval || !this.client.eval)
236
+ return;
237
+ const key = index === 'queued' ? this.queuedKey() : this.leasesKey();
238
+ for (let start = 0; start < ids.length; start += 500) {
239
+ await this.client.eval(PRUNE_SCRIPT, 2, this.recordsKey(), key, index, ...ids.slice(start, start + 500));
240
+ }
241
+ }
242
+ async place(record) {
243
+ const { queued, lease } = indexScores(record);
244
+ if (queued !== '')
245
+ await this.client.zadd?.(this.queuedKey(), Number(queued), record.id);
246
+ else
247
+ await this.client.zrem?.(this.queuedKey(), record.id);
248
+ if (lease !== '')
249
+ await this.client.zadd?.(this.leasesKey(), Number(lease), record.id);
250
+ else
251
+ await this.client.zrem?.(this.leasesKey(), record.id);
252
+ }
88
253
  recordsKey() {
89
254
  return `${this.prefix}records`;
90
255
  }
91
256
  idempotencyKey() {
92
257
  return `${this.prefix}idempotency`;
93
258
  }
259
+ queuedKey() {
260
+ return `${this.prefix}queued`;
261
+ }
262
+ leasesKey() {
263
+ return `${this.prefix}leases`;
264
+ }
94
265
  }
95
266
  exports.RedisOperationStore = RedisOperationStore;
267
+ function isExpired(record, now) {
268
+ if ((0, operations_js_1.isTerminalOperationStatus)(record.status))
269
+ return false;
270
+ if (record.lease && record.lease.expiresAt > now)
271
+ return false;
272
+ return record.status === 'running' || record.lease !== undefined;
273
+ }
274
+ function isLeased(record) {
275
+ return !(0, operations_js_1.isTerminalOperationStatus)(record.status) && (record.lease !== undefined || record.status === 'running');
276
+ }
277
+ function indexScores(record) {
278
+ return {
279
+ queued: record.status === 'queued' ? String(Date.parse(record.createdAt)) : '',
280
+ lease: isLeased(record) ? String(record.lease ? Date.parse(record.lease.expiresAt) : 0) : '',
281
+ };
282
+ }
96
283
  class BullMQOperationDispatcher {
97
284
  queue;
98
285
  options;
@@ -96,6 +96,7 @@ class OperationRunner {
96
96
  expiresAt: options.expiresAt,
97
97
  idempotencyKey: options.idempotencyKey,
98
98
  kind: options.kind,
99
+ ...(options.tenantId ? { tenantId: options.tenantId } : {}),
99
100
  traceContext: options.traceContext,
100
101
  metadata: options.metadata,
101
102
  };
@@ -217,7 +218,7 @@ class OperationRunner {
217
218
  const heartbeat = this.startHeartbeat(record, handle);
218
219
  let outcome;
219
220
  try {
220
- const value = await executor(this.buildContext(record, handle));
221
+ const value = await executor(this.buildContext(record, handle, heartbeat.beat));
221
222
  outcome = { ok: true, value };
222
223
  }
223
224
  catch (error) {
@@ -293,7 +294,13 @@ class OperationRunner {
293
294
  startHeartbeat(record, handle) {
294
295
  let latest = record;
295
296
  let stopped = false;
296
- const beat = async () => {
297
+ let inFlight = Promise.resolve();
298
+ const beat = (details) => {
299
+ const next = inFlight.then(() => renew(details));
300
+ inFlight = next.catch(() => undefined);
301
+ return next;
302
+ };
303
+ const renew = async (details) => {
297
304
  if (stopped)
298
305
  return;
299
306
  const current = await this.store.read(latest.id);
@@ -318,6 +325,8 @@ class OperationRunner {
318
325
  heartbeatAt: renewed.updatedAt,
319
326
  };
320
327
  renewed.progress = handle.progress() ?? current.progress;
328
+ if (details !== undefined)
329
+ renewed.heartbeatDetails = details;
321
330
  if (await this.store.update(renewed, current.sequence))
322
331
  latest = renewed;
323
332
  };
@@ -338,24 +347,14 @@ class OperationRunner {
338
347
  beat,
339
348
  };
340
349
  }
341
- buildContext(record, handle) {
350
+ buildContext(record, handle, beat) {
342
351
  return {
343
352
  operationId: record.id,
344
353
  attempt: record.attempt,
345
354
  signal: handle.signal,
346
355
  report: (progress) => handle.report(progress),
347
- heartbeat: async () => {
348
- const current = await this.store.read(record.id);
349
- if (!current || (0, operations_js_1.isTerminalOperationStatus)(current.status))
350
- return;
351
- const renewed = this.advance(current, 'running');
352
- renewed.lease = {
353
- owner: this.owner,
354
- expiresAt: new Date(this.now().getTime() + this.leaseMs).toISOString(),
355
- heartbeatAt: renewed.updatedAt,
356
- };
357
- await this.store.update(renewed, current.sequence);
358
- },
356
+ heartbeat: (details) => beat(details),
357
+ ...(record.heartbeatDetails === undefined ? {} : { previousHeartbeat: record.heartbeatDetails }),
359
358
  traceContext: record.traceContext,
360
359
  metadata: record.metadata,
361
360
  };
@@ -9,6 +9,8 @@ function isUnheldQueued(record, now) {
9
9
  return record.status === 'queued' && (!record.lease || record.lease.expiresAt <= now);
10
10
  }
11
11
  function matchesFilter(record, filter) {
12
+ if (filter?.tenantId !== undefined && record.tenantId !== filter.tenantId)
13
+ return false;
12
14
  return !filter?.kindPrefix || (record.kind ?? '').startsWith(filter.kindPrefix);
13
15
  }
14
16
  function countRecords(records, now, filter) {
@@ -29,7 +31,12 @@ function countRecords(records, now, filter) {
29
31
  }
30
32
  async function operationStats(store, options = {}) {
31
33
  const now = (options.now ?? new Date()).toISOString();
32
- const filter = options.kindPrefix ? { kindPrefix: options.kindPrefix } : undefined;
34
+ const filter = options.kindPrefix || options.tenantId !== undefined
35
+ ? {
36
+ ...(options.kindPrefix ? { kindPrefix: options.kindPrefix } : {}),
37
+ ...(options.tenantId !== undefined ? { tenantId: options.tenantId } : {}),
38
+ }
39
+ : undefined;
33
40
  if (store.stats)
34
41
  return store.stats(now, filter);
35
42
  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;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MemoryOperationStore = exports.operationStats = exports.assertSerializableRecord = void 0;
4
4
  const operations_js_1 = require("../types/operations.js");
5
+ const errors_js_1 = require("./errors.js");
5
6
  const serialization_js_1 = require("./serialization.js");
6
7
  Object.defineProperty(exports, "assertSerializableRecord", { enumerable: true, get: function () { return serialization_js_1.assertSerializableRecord; } });
7
8
  const stats_js_1 = require("./stats.js");
@@ -16,6 +17,12 @@ class MemoryOperationStore {
16
17
  }
17
18
  create(record) {
18
19
  (0, serialization_js_1.assertSerializableRecord)(record);
20
+ if (record.idempotencyKey) {
21
+ const owner = this.byIdempotencyKey.get(record.idempotencyKey);
22
+ if (owner !== undefined && owner !== record.id && this.records.has(owner)) {
23
+ throw new errors_js_1.OperationDuplicateError(record.idempotencyKey);
24
+ }
25
+ }
19
26
  this.records.set(record.id, clone(record));
20
27
  if (record.idempotencyKey)
21
28
  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.
@@ -105,6 +105,7 @@ class CircuitBreaker {
105
105
  this.refreshState(providerName, entry);
106
106
  this.pushWindow(entry, false);
107
107
  entry.consecutiveFailures = 0;
108
+ this.coordinator?.observed(providerName, false);
108
109
  if (entry.state === 'half-open') {
109
110
  entry.halfOpenInFlight = Math.max(0, entry.halfOpenInFlight - 1);
110
111
  entry.halfOpenSuccesses += 1;
@@ -126,6 +127,7 @@ class CircuitBreaker {
126
127
  }
127
128
  this.pushWindow(entry, true);
128
129
  entry.consecutiveFailures += 1;
130
+ this.coordinator?.observed(providerName, true);
129
131
  entry.lastError = error instanceof Error ? error.message : error === undefined ? undefined : String(error);
130
132
  if (entry.state === 'half-open') {
131
133
  entry.halfOpenInFlight = Math.max(0, entry.halfOpenInFlight - 1);
@@ -162,6 +164,7 @@ class CircuitBreaker {
162
164
  ? new Date(entry.openedAt + resetTimeoutMs).toISOString()
163
165
  : undefined,
164
166
  lastError: entry.lastError,
167
+ ...(entry.shared ? { shared: { ...entry.shared } } : {}),
165
168
  };
166
169
  });
167
170
  }
@@ -212,6 +215,7 @@ class CircuitBreaker {
212
215
  entry.openedAt = undefined;
213
216
  entry.consecutiveFailures = 0;
214
217
  entry.window = [];
218
+ entry.shared = undefined;
215
219
  entry.halfOpenInFlight = 0;
216
220
  entry.halfOpenSuccesses = 0;
217
221
  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
  /**
@@ -5,9 +5,37 @@ class MemoryCircuitStateStore {
5
5
  now;
6
6
  states = new Map();
7
7
  probes = new Map();
8
+ windows = new Map();
8
9
  constructor(now = () => Date.now()) {
9
10
  this.now = now;
10
11
  }
12
+ observe(providerName, observation) {
13
+ let window = this.windows.get(providerName);
14
+ if (!window) {
15
+ window = { buckets: new Map(), streak: 0 };
16
+ this.windows.set(providerName, window);
17
+ }
18
+ for (const bucket of observation.buckets) {
19
+ const stored = window.buckets.get(bucket.at) ?? { calls: 0, failures: 0 };
20
+ stored.calls += bucket.calls;
21
+ stored.failures += bucket.failures;
22
+ window.buckets.set(bucket.at, stored);
23
+ }
24
+ window.streak = observation.succeeded ? observation.trailingFailures : window.streak + observation.trailingFailures;
25
+ const keepAfter = observation.now - observation.windowMs - observation.bucketMs;
26
+ const since = observation.since ?? Number.NEGATIVE_INFINITY;
27
+ let calls = 0;
28
+ let failures = 0;
29
+ for (const [at, bucket] of window.buckets) {
30
+ if (at <= keepAfter)
31
+ window.buckets.delete(at);
32
+ else if (at >= since) {
33
+ calls += bucket.calls;
34
+ failures += bucket.failures;
35
+ }
36
+ }
37
+ return { calls, failures, consecutiveFailures: window.streak };
38
+ }
11
39
  read() {
12
40
  return [...this.states.values()].map((state) => ({ ...state }));
13
41
  }
@@ -38,14 +66,54 @@ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
38
66
  redis.call('DEL', KEYS[2])
39
67
  return 1
40
68
  `;
69
+ const OBSERVE_SCRIPT = `
70
+ local key = KEYS[1]
71
+ for i = 6, #ARGV, 3 do
72
+ redis.call('HINCRBY', key, 'c:' .. ARGV[i], ARGV[i + 1])
73
+ redis.call('HINCRBY', key, 'f:' .. ARGV[i], ARGV[i + 2])
74
+ end
75
+ local streak
76
+ if ARGV[1] == '1' then
77
+ redis.call('HSET', key, 'streak', ARGV[2])
78
+ streak = tonumber(ARGV[2])
79
+ else
80
+ streak = tonumber(redis.call('HINCRBY', key, 'streak', ARGV[2]))
81
+ end
82
+ local keepAfter = tonumber(ARGV[3])
83
+ local since = tonumber(ARGV[4])
84
+ local calls, failures = 0, 0
85
+ local fields = redis.call('HGETALL', key)
86
+ for i = 1, #fields, 2 do
87
+ local name = fields[i]
88
+ local kind = string.sub(name, 1, 2)
89
+ if kind == 'c:' or kind == 'f:' then
90
+ local at = tonumber(string.sub(name, 3))
91
+ if at <= keepAfter then
92
+ redis.call('HDEL', key, name)
93
+ elseif at >= since then
94
+ if kind == 'c:' then calls = calls + tonumber(fields[i + 1]) else failures = failures + tonumber(fields[i + 1]) end
95
+ end
96
+ end
97
+ end
98
+ redis.call('PEXPIRE', key, ARGV[5])
99
+ return { calls, failures, streak }
100
+ `;
41
101
  class RedisCircuitStateStore {
42
102
  client;
43
103
  prefix;
44
104
  useEval;
105
+ observe;
45
106
  constructor(client, options = {}) {
46
107
  this.client = client;
47
108
  this.prefix = options.prefix ?? 'nexus-ai-pro:circuits:';
48
109
  this.useEval = options.useEval !== false && typeof client.eval === 'function';
110
+ if (this.useEval)
111
+ this.observe = (providerName, observation) => this.observeWithEval(providerName, observation);
112
+ }
113
+ async observeWithEval(providerName, observation) {
114
+ 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)])));
115
+ const [calls, failures, streak] = (reply ?? []).map(Number);
116
+ return { calls: calls ?? 0, failures: failures ?? 0, consecutiveFailures: streak ?? 0 };
49
117
  }
50
118
  async read() {
51
119
  const all = (await this.client.hgetall(this.statesKey())) ?? {};