nexus-ai-pro 2.1.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 (194) hide show
  1. package/API_STABILITY.md +38 -0
  2. package/CHANGELOG.md +145 -1
  3. package/README.md +13 -8
  4. package/dist/cache/memory-cache.d.ts +8 -0
  5. package/dist/cache/memory-cache.js +9 -0
  6. package/dist/cache/semantic-cache.d.ts +6 -3
  7. package/dist/cache/semantic-cache.js +5 -2
  8. package/dist/cli/args.js +3 -0
  9. package/dist/cli/db.d.ts +16 -4
  10. package/dist/cli/db.js +169 -22
  11. package/dist/cli/doctor.d.ts +17 -0
  12. package/dist/cli/doctor.js +53 -0
  13. package/dist/cli.js +12 -1
  14. package/dist/core/lifecycle.js +11 -6
  15. package/dist/core/nexus.d.ts +6 -0
  16. package/dist/core/nexus.js +103 -22
  17. package/dist/doctor/index.d.ts +90 -0
  18. package/dist/doctor/index.js +237 -0
  19. package/dist/graph/functional.d.ts +80 -7
  20. package/dist/graph/functional.js +138 -35
  21. package/dist/graph/graph.d.ts +15 -5
  22. package/dist/graph/graph.js +72 -20
  23. package/dist/images/evals.js +4 -2
  24. package/dist/operations/adapters.d.ts +52 -4
  25. package/dist/operations/adapters.js +194 -7
  26. package/dist/operations/runner.js +1 -0
  27. package/dist/operations/stats.js +8 -1
  28. package/dist/operations/store.d.ts +5 -1
  29. package/dist/operations/store.js +7 -0
  30. package/dist/ops/circuit-breaker.d.ts +52 -0
  31. package/dist/ops/circuit-breaker.js +4 -0
  32. package/dist/ops/circuit-store.d.ts +13 -2
  33. package/dist/ops/circuit-store.js +68 -0
  34. package/dist/ops/circuit-sync.d.ts +17 -0
  35. package/dist/ops/circuit-sync.js +86 -0
  36. package/dist/ops/rate-limiter.d.ts +2 -0
  37. package/dist/ops/rate-limiter.js +2 -0
  38. package/dist/postgres/circuits.d.ts +21 -4
  39. package/dist/postgres/circuits.js +65 -4
  40. package/dist/postgres/client.d.ts +0 -2
  41. package/dist/postgres/client.js +0 -4
  42. package/dist/postgres/evaluate.d.ts +16 -4
  43. package/dist/postgres/evaluate.js +17 -5
  44. package/dist/postgres/index.d.ts +32 -11
  45. package/dist/postgres/index.js +65 -31
  46. package/dist/postgres/migrations.d.ts +60 -0
  47. package/dist/postgres/migrations.js +121 -0
  48. package/dist/postgres/operations.d.ts +14 -3
  49. package/dist/postgres/operations.js +45 -15
  50. package/dist/postgres/prompts.d.ts +11 -2
  51. package/dist/postgres/prompts.js +14 -3
  52. package/dist/postgres/rollups.d.ts +35 -0
  53. package/dist/postgres/rollups.js +129 -0
  54. package/dist/postgres/store.d.ts +11 -2
  55. package/dist/postgres/store.js +14 -3
  56. package/dist/postgres/traces.d.ts +13 -3
  57. package/dist/postgres/traces.js +25 -3
  58. package/dist/postgres/vectors.d.ts +11 -2
  59. package/dist/postgres/vectors.js +14 -3
  60. package/dist/router/auto-router.d.ts +34 -7
  61. package/dist/router/auto-router.js +201 -68
  62. package/dist/server/assistant.d.ts +4 -0
  63. package/dist/server/assistant.js +8 -0
  64. package/dist/server/errors.d.ts +12 -0
  65. package/dist/server/errors.js +7 -0
  66. package/dist/server/index.d.ts +1 -1
  67. package/dist/server/index.js +1 -1
  68. package/dist/server/runs.d.ts +24 -1
  69. package/dist/server/runs.js +90 -14
  70. package/dist/sqlite/index.d.ts +16 -3
  71. package/dist/sqlite/index.js +16 -3
  72. package/dist/sqlite/migrations.d.ts +26 -0
  73. package/dist/sqlite/migrations.js +63 -0
  74. package/dist/sqlite/operations.d.ts +14 -3
  75. package/dist/sqlite/operations.js +42 -15
  76. package/dist/sqlite/store.d.ts +9 -2
  77. package/dist/sqlite/store.js +13 -3
  78. package/dist/sqlite/vectors.d.ts +11 -2
  79. package/dist/sqlite/vectors.js +13 -2
  80. package/dist/store/tenant.d.ts +12 -0
  81. package/dist/store/tenant.js +29 -0
  82. package/dist/tenancy/index.d.ts +113 -0
  83. package/dist/tenancy/index.js +233 -0
  84. package/dist/tracing/instrument.js +2 -0
  85. package/dist/types/config.d.ts +24 -2
  86. package/dist/types/graph.d.ts +11 -1
  87. package/dist/types/messages.d.ts +5 -0
  88. package/dist/types/operations.d.ts +6 -0
  89. package/dist/types/response.d.ts +20 -0
  90. package/dist/types/server.d.ts +10 -1
  91. package/dist/utils/schema-migrations.d.ts +97 -0
  92. package/dist/utils/schema-migrations.js +91 -0
  93. package/dist/utils/signals.d.ts +17 -0
  94. package/dist/utils/signals.js +26 -0
  95. package/dist/utils/tenant.d.ts +14 -0
  96. package/dist/utils/tenant.js +16 -0
  97. package/dist/version.d.ts +1 -1
  98. package/dist/version.js +1 -1
  99. package/dist-cjs/cache/memory-cache.d.ts +8 -0
  100. package/dist-cjs/cache/memory-cache.js +9 -0
  101. package/dist-cjs/cache/semantic-cache.d.ts +6 -3
  102. package/dist-cjs/cache/semantic-cache.js +5 -2
  103. package/dist-cjs/cli/args.js +3 -0
  104. package/dist-cjs/cli/db.d.ts +16 -4
  105. package/dist-cjs/cli/db.js +200 -20
  106. package/dist-cjs/cli/doctor.d.ts +17 -0
  107. package/dist-cjs/cli/doctor.js +56 -0
  108. package/dist-cjs/core/lifecycle.js +11 -6
  109. package/dist-cjs/core/nexus.d.ts +6 -0
  110. package/dist-cjs/core/nexus.js +103 -22
  111. package/dist-cjs/doctor/index.d.ts +90 -0
  112. package/dist-cjs/doctor/index.js +276 -0
  113. package/dist-cjs/graph/functional.d.ts +80 -7
  114. package/dist-cjs/graph/functional.js +137 -34
  115. package/dist-cjs/graph/graph.d.ts +15 -5
  116. package/dist-cjs/graph/graph.js +72 -20
  117. package/dist-cjs/images/evals.js +4 -2
  118. package/dist-cjs/operations/adapters.d.ts +52 -4
  119. package/dist-cjs/operations/adapters.js +193 -6
  120. package/dist-cjs/operations/runner.js +1 -0
  121. package/dist-cjs/operations/stats.js +8 -1
  122. package/dist-cjs/operations/store.d.ts +5 -1
  123. package/dist-cjs/operations/store.js +7 -0
  124. package/dist-cjs/ops/circuit-breaker.d.ts +52 -0
  125. package/dist-cjs/ops/circuit-breaker.js +4 -0
  126. package/dist-cjs/ops/circuit-store.d.ts +13 -2
  127. package/dist-cjs/ops/circuit-store.js +68 -0
  128. package/dist-cjs/ops/circuit-sync.d.ts +17 -0
  129. package/dist-cjs/ops/circuit-sync.js +87 -0
  130. package/dist-cjs/ops/rate-limiter.d.ts +2 -0
  131. package/dist-cjs/ops/rate-limiter.js +2 -0
  132. package/dist-cjs/postgres/circuits.d.ts +21 -4
  133. package/dist-cjs/postgres/circuits.js +98 -3
  134. package/dist-cjs/postgres/client.d.ts +0 -2
  135. package/dist-cjs/postgres/client.js +0 -5
  136. package/dist-cjs/postgres/evaluate.d.ts +16 -4
  137. package/dist-cjs/postgres/evaluate.js +50 -4
  138. package/dist-cjs/postgres/index.d.ts +32 -11
  139. package/dist-cjs/postgres/index.js +69 -20
  140. package/dist-cjs/postgres/migrations.d.ts +60 -0
  141. package/dist-cjs/postgres/migrations.js +129 -0
  142. package/dist-cjs/postgres/operations.d.ts +14 -3
  143. package/dist-cjs/postgres/operations.js +78 -14
  144. package/dist-cjs/postgres/prompts.d.ts +11 -2
  145. package/dist-cjs/postgres/prompts.js +47 -2
  146. package/dist-cjs/postgres/rollups.d.ts +35 -0
  147. package/dist-cjs/postgres/rollups.js +168 -0
  148. package/dist-cjs/postgres/store.d.ts +11 -2
  149. package/dist-cjs/postgres/store.js +47 -2
  150. package/dist-cjs/postgres/traces.d.ts +13 -3
  151. package/dist-cjs/postgres/traces.js +58 -2
  152. package/dist-cjs/postgres/vectors.d.ts +11 -2
  153. package/dist-cjs/postgres/vectors.js +47 -2
  154. package/dist-cjs/router/auto-router.d.ts +34 -7
  155. package/dist-cjs/router/auto-router.js +201 -68
  156. package/dist-cjs/server/assistant.d.ts +4 -0
  157. package/dist-cjs/server/assistant.js +8 -0
  158. package/dist-cjs/server/errors.d.ts +12 -0
  159. package/dist-cjs/server/errors.js +9 -1
  160. package/dist-cjs/server/index.d.ts +1 -1
  161. package/dist-cjs/server/index.js +2 -1
  162. package/dist-cjs/server/runs.d.ts +24 -1
  163. package/dist-cjs/server/runs.js +89 -13
  164. package/dist-cjs/sqlite/index.d.ts +16 -3
  165. package/dist-cjs/sqlite/index.js +31 -10
  166. package/dist-cjs/sqlite/migrations.d.ts +26 -0
  167. package/dist-cjs/sqlite/migrations.js +71 -0
  168. package/dist-cjs/sqlite/operations.d.ts +14 -3
  169. package/dist-cjs/sqlite/operations.js +76 -15
  170. package/dist-cjs/sqlite/store.d.ts +9 -2
  171. package/dist-cjs/sqlite/store.js +47 -3
  172. package/dist-cjs/sqlite/vectors.d.ts +11 -2
  173. package/dist-cjs/sqlite/vectors.js +47 -2
  174. package/dist-cjs/store/tenant.d.ts +12 -0
  175. package/dist-cjs/store/tenant.js +33 -0
  176. package/dist-cjs/tenancy/index.d.ts +113 -0
  177. package/dist-cjs/tenancy/index.js +250 -0
  178. package/dist-cjs/tracing/instrument.js +2 -0
  179. package/dist-cjs/types/config.d.ts +24 -2
  180. package/dist-cjs/types/graph.d.ts +11 -1
  181. package/dist-cjs/types/messages.d.ts +5 -0
  182. package/dist-cjs/types/operations.d.ts +6 -0
  183. package/dist-cjs/types/response.d.ts +20 -0
  184. package/dist-cjs/types/server.d.ts +10 -1
  185. package/dist-cjs/utils/schema-migrations.d.ts +97 -0
  186. package/dist-cjs/utils/schema-migrations.js +102 -0
  187. package/dist-cjs/utils/signals.d.ts +17 -0
  188. package/dist-cjs/utils/signals.js +29 -0
  189. package/dist-cjs/utils/tenant.d.ts +14 -0
  190. package/dist-cjs/utils/tenant.js +22 -0
  191. package/dist-cjs/version.d.ts +1 -1
  192. package/dist-cjs/version.js +1 -1
  193. package/llms.txt +4 -3
  194. package/package.json +71 -2
@@ -3,7 +3,8 @@ import type { OperationDispatcher, OperationRecord, OperationStore, OperationSto
3
3
  * The Redis commands the operation store needs.
4
4
  *
5
5
  * Structural rather than tied to one client, so `ioredis`, `node-redis`, or a cluster proxy all
6
- * satisfy it without this package depending on any of them.
6
+ * satisfy it without this package depending on any of them. The sorted-set commands are needed only
7
+ * with `index: true`; `ioredis` has them all.
7
8
  */
8
9
  export interface RedisOperationLikeClient {
9
10
  /** Reads a hash field. */
@@ -16,6 +17,14 @@ export interface RedisOperationLikeClient {
16
17
  hvals(key: string): Promise<string[]> | string[];
17
18
  /** Optional CAS primitive. When absent the store falls back to a read-compare-write. */
18
19
  eval?(script: string, numKeys: number, ...args: string[]): Promise<unknown> | unknown;
20
+ /** Reads several hash fields in one round trip. Used by the index when present. */
21
+ hmget?(key: string, ...fields: string[]): Promise<Array<string | null>> | Array<string | null>;
22
+ /** Adds a sorted-set member. Needed with `index: true`. */
23
+ zadd?(key: string, score: number, member: string): Promise<unknown> | unknown;
24
+ /** Removes a sorted-set member. Needed with `index: true`. */
25
+ zrem?(key: string, member: string): Promise<unknown> | unknown;
26
+ /** Members by score, as `ZRANGEBYSCORE key min max LIMIT offset count`. Needed with `index: true`. */
27
+ zrangebyscore?(key: string, min: number | string, max: number | string, limit: 'LIMIT', offset: number, count: number): Promise<string[]> | string[];
19
28
  }
20
29
  /** Options for the Redis operation store. */
21
30
  export interface RedisOperationStoreOptions {
@@ -23,6 +32,19 @@ export interface RedisOperationStoreOptions {
23
32
  prefix?: string;
24
33
  /** Disables the Lua compare-and-set even when the client exposes `eval`. */
25
34
  useEval?: boolean;
35
+ /**
36
+ * Keeps queued work in a sorted set by age, and leased work in one by lease expiry, so
37
+ * `listQueued()` and `claimExpired()` read the oldest few records instead of every one: a claim
38
+ * costs the same with 100,000 operations queued as with 1,000. The records stay the source of
39
+ * truth; an index entry only says where to look, and one that no longer matches its record is
40
+ * skipped and removed.
41
+ *
42
+ * Off by default, because a 2.1 worker writes records without index entries. Turn it on once every
43
+ * worker sharing the prefix runs 2.2, then call `reindex()` once to index what is already there.
44
+ * Needs `zadd`, `zrem`, and `zrangebyscore`; with `eval`, a record and its index entries change in
45
+ * one step.
46
+ */
47
+ index?: boolean;
26
48
  }
27
49
  /**
28
50
  * Redis-backed operation storage, so a submitted operation survives a restart.
@@ -36,8 +58,13 @@ export declare class RedisOperationStore<TResult = unknown> implements Operation
36
58
  private readonly client;
37
59
  private readonly prefix;
38
60
  private readonly useEval;
61
+ private readonly indexed;
39
62
  constructor(client: RedisOperationLikeClient, options?: RedisOperationStoreOptions);
40
- /** Stores a new record and indexes its idempotency key. Refuses records carrying raw bytes. */
63
+ /**
64
+ * Stores a new record and indexes its idempotency key. Throws `OperationDuplicateError` when another
65
+ * record holds the key, checked in the same Lua call as the write when the client has `eval`.
66
+ * Refuses records carrying raw bytes.
67
+ */
41
68
  create(record: OperationRecord<TResult>): Promise<void>;
42
69
  /** Reads a record. */
43
70
  read(id: string): Promise<OperationRecord<TResult> | undefined>;
@@ -50,19 +77,40 @@ export declare class RedisOperationStore<TResult = unknown> implements Operation
50
77
  delete(id: string): Promise<boolean>;
51
78
  /**
52
79
  * Records whose lease has expired, or running records without one, up to `limit`, for another
53
- * worker to take over.
80
+ * worker to take over. With the index, reads only records whose lease has lapsed.
54
81
  */
55
82
  claimExpired(now: string, limit: number): Promise<Array<OperationRecord<TResult>>>;
56
83
  /** Finds the record that claimed an idempotency key. */
57
84
  findByIdempotencyKey(key: string): Promise<OperationRecord<TResult> | undefined>;
58
85
  /** Every record. */
59
86
  list(): Promise<Array<OperationRecord<TResult>>>;
60
- /** Queued records no worker holds, oldest first. Reads every record, as `list()` does. */
87
+ /**
88
+ * Queued records no worker holds, oldest first. With the index, reads them oldest first from the
89
+ * sorted set and stops at `limit`; without it, reads every record, as `list()` does.
90
+ */
61
91
  listQueued(limit: number, filter?: OperationStoreFilter): Promise<Array<OperationRecord<TResult>>>;
62
92
  /** Counts unfinished records by status. Reads every record, as `list()` does. */
63
93
  stats(now: string, filter?: OperationStoreFilter): Promise<OperationStoreStats>;
94
+ /**
95
+ * Indexes every record already stored, for turning `index` on over existing data. Safe to run
96
+ * while workers are busy, and to run again: it only adds entries, then removes those that no
97
+ * longer match their record, checking each record as it does. Resolves to how many records it read.
98
+ */
99
+ reindex(): Promise<number>;
100
+ /**
101
+ * Reads one index in score order up to `max`, a page at a time, keeping records that pass `keep`
102
+ * until `limit`. Entries whose record is gone or no longer `belongs` are removed afterwards.
103
+ */
104
+ private scanIndex;
105
+ private readMany;
106
+ /** Removes index entries that no longer describe their record. Only with `eval`, where the check is atomic. */
107
+ private prune;
108
+ /** Writes a record's index entries without `eval`, after the record itself. */
109
+ private place;
64
110
  private recordsKey;
65
111
  private idempotencyKey;
112
+ private queuedKey;
113
+ private leasesKey;
66
114
  }
67
115
  /** The part of a BullMQ `Queue` the dispatcher needs. */
68
116
  export interface BullMQLikeOperationQueue {
@@ -1,4 +1,5 @@
1
- import { isTerminalOperationStatus } from '../types/operations.js';
1
+ import { isTerminalOperationStatus, TERMINAL_OPERATION_STATUSES } 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
  const CAS_SCRIPT = `
@@ -9,21 +10,102 @@ if tonumber(decoded.sequence) ~= tonumber(ARGV[2]) then return 0 end
9
10
  redis.call('HSET', KEYS[1], ARGV[1], ARGV[3])
10
11
  return 1
11
12
  `;
13
+ const PLACE_LUA = `
14
+ local function place(key, score, id)
15
+ if score ~= '' then redis.call('ZADD', key, score, id) else redis.call('ZREM', key, id) end
16
+ end
17
+ `;
18
+ const UNIQUE_KEY_LUA = `
19
+ if ARGV[3] ~= '' then
20
+ local owner = redis.call('HGET', KEYS[2], ARGV[3])
21
+ if owner and owner ~= ARGV[1] and redis.call('HEXISTS', KEYS[1], owner) == 1 then return 0 end
22
+ end
23
+ `;
24
+ const CREATE_SCRIPT = `${UNIQUE_KEY_LUA}
25
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
26
+ if ARGV[3] ~= '' then redis.call('HSET', KEYS[2], ARGV[3], ARGV[1]) end
27
+ return 1
28
+ `;
29
+ const INDEXED_CREATE_SCRIPT = `${PLACE_LUA}${UNIQUE_KEY_LUA}
30
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
31
+ if ARGV[3] ~= '' then redis.call('HSET', KEYS[2], ARGV[3], ARGV[1]) end
32
+ place(KEYS[3], ARGV[4], ARGV[1])
33
+ place(KEYS[4], ARGV[5], ARGV[1])
34
+ return 1
35
+ `;
36
+ const INDEXED_CAS_SCRIPT = `${PLACE_LUA}
37
+ local current = redis.call('HGET', KEYS[1], ARGV[1])
38
+ if current == false then return 0 end
39
+ local decoded = cjson.decode(current)
40
+ if tonumber(decoded.sequence) ~= tonumber(ARGV[2]) then return 0 end
41
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[3])
42
+ place(KEYS[2], ARGV[4], ARGV[1])
43
+ place(KEYS[3], ARGV[5], ARGV[1])
44
+ return 1
45
+ `;
46
+ const PRUNE_SCRIPT = `
47
+ local terminal = { ${TERMINAL_OPERATION_STATUSES.map((status) => `${status} = true`).join(', ')} }
48
+ local removed = 0
49
+ for i = 2, #ARGV do
50
+ local raw = redis.call('HGET', KEYS[1], ARGV[i])
51
+ local keep = false
52
+ if raw then
53
+ local record = cjson.decode(raw)
54
+ if ARGV[1] == 'queued' then
55
+ keep = record.status == 'queued'
56
+ else
57
+ keep = (not terminal[record.status]) and (record.lease ~= nil or record.status == 'running')
58
+ end
59
+ end
60
+ if not keep then
61
+ redis.call('ZREM', KEYS[2], ARGV[i])
62
+ removed = removed + 1
63
+ end
64
+ end
65
+ return removed
66
+ `;
12
67
  export class RedisOperationStore {
13
68
  client;
14
69
  prefix;
15
70
  useEval;
71
+ indexed;
16
72
  constructor(client, options = {}) {
17
73
  this.client = client;
18
74
  this.prefix = options.prefix ?? 'nexus-ai-pro:operations:';
19
75
  this.useEval = options.useEval !== false && typeof client.eval === 'function';
76
+ this.indexed = options.index === true;
77
+ if (this.indexed && !(client.zadd && client.zrem && client.zrangebyscore)) {
78
+ throw new TypeError('A Redis operation store with index: true needs zadd, zrem, and zrangebyscore');
79
+ }
20
80
  }
21
81
  async create(record) {
22
82
  assertSerializableRecord(record);
23
- await this.client.hset(this.recordsKey(), record.id, JSON.stringify(record));
83
+ const encoded = JSON.stringify(record);
84
+ const duplicate = () => new OperationDuplicateError(record.idempotencyKey);
85
+ if (this.useEval && this.client.eval && !this.indexed) {
86
+ const created = await this.client.eval(CREATE_SCRIPT, 2, this.recordsKey(), this.idempotencyKey(), record.id, encoded, record.idempotencyKey ?? '');
87
+ if (Number(created) !== 1)
88
+ throw duplicate();
89
+ return;
90
+ }
91
+ if (this.indexed && this.useEval && this.client.eval) {
92
+ const { queued, lease } = indexScores(record);
93
+ 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);
94
+ if (Number(created) !== 1)
95
+ throw duplicate();
96
+ return;
97
+ }
98
+ if (record.idempotencyKey) {
99
+ const owner = await this.client.hget(this.idempotencyKey(), record.idempotencyKey);
100
+ if (owner && owner !== record.id && (await this.client.hget(this.recordsKey(), owner)))
101
+ throw duplicate();
102
+ }
103
+ await this.client.hset(this.recordsKey(), record.id, encoded);
24
104
  if (record.idempotencyKey) {
25
105
  await this.client.hset(this.idempotencyKey(), record.idempotencyKey, record.id);
26
106
  }
107
+ if (this.indexed)
108
+ await this.place(record);
27
109
  }
28
110
  async read(id) {
29
111
  const raw = await this.client.hget(this.recordsKey(), id);
@@ -33,6 +115,11 @@ export class RedisOperationStore {
33
115
  assertSerializableRecord(record);
34
116
  const encoded = JSON.stringify(record);
35
117
  if (this.useEval && this.client.eval) {
118
+ if (this.indexed) {
119
+ const { queued, lease } = indexScores(record);
120
+ const result = await this.client.eval(INDEXED_CAS_SCRIPT, 3, this.recordsKey(), this.queuedKey(), this.leasesKey(), record.id, String(expectedSequence), encoded, queued, lease);
121
+ return Number(result) === 1;
122
+ }
36
123
  const result = await this.client.eval(CAS_SCRIPT, 1, this.recordsKey(), record.id, String(expectedSequence), encoded);
37
124
  return Number(result) === 1;
38
125
  }
@@ -40,6 +127,8 @@ export class RedisOperationStore {
40
127
  if (!current || current.sequence !== expectedSequence)
41
128
  return false;
42
129
  await this.client.hset(this.recordsKey(), record.id, encoded);
130
+ if (this.indexed)
131
+ await this.place(record);
43
132
  return true;
44
133
  }
45
134
  async delete(id) {
@@ -47,19 +136,22 @@ export class RedisOperationStore {
47
136
  if (record?.idempotencyKey)
48
137
  await this.client.hdel(this.idempotencyKey(), record.idempotencyKey);
49
138
  await this.client.hdel(this.recordsKey(), id);
139
+ if (this.indexed) {
140
+ await this.client.zrem?.(this.queuedKey(), id);
141
+ await this.client.zrem?.(this.leasesKey(), id);
142
+ }
50
143
  return record !== undefined;
51
144
  }
52
145
  async claimExpired(now, limit) {
146
+ if (this.indexed) {
147
+ return this.scanIndex('leases', Date.parse(now), limit, (record) => isExpired(record, now), isLeased);
148
+ }
53
149
  const records = await this.list();
54
150
  const expired = [];
55
151
  for (const record of records) {
56
152
  if (expired.length >= limit)
57
153
  break;
58
- if (isTerminalOperationStatus(record.status))
59
- continue;
60
- if (record.lease && record.lease.expiresAt > now)
61
- continue;
62
- if (record.status === 'running' || record.lease)
154
+ if (isExpired(record, now))
63
155
  expired.push(record);
64
156
  }
65
157
  return expired;
@@ -74,6 +166,9 @@ export class RedisOperationStore {
74
166
  }
75
167
  async listQueued(limit, filter) {
76
168
  const now = new Date().toISOString();
169
+ if (this.indexed) {
170
+ return this.scanIndex('queued', Number.POSITIVE_INFINITY, limit, (record) => isUnheldQueued(record, now) && matchesFilter(record, filter), (record) => record.status === 'queued');
171
+ }
77
172
  return (await this.list())
78
173
  .filter((record) => isUnheldQueued(record, now) && matchesFilter(record, filter))
79
174
  .sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
@@ -82,12 +177,104 @@ export class RedisOperationStore {
82
177
  async stats(now, filter) {
83
178
  return countRecords(await this.list(), now, filter);
84
179
  }
180
+ async reindex() {
181
+ if (!this.indexed)
182
+ throw new TypeError('reindex() needs a store created with index: true');
183
+ const records = await this.list();
184
+ for (const record of records) {
185
+ const { queued, lease } = indexScores(record);
186
+ if (queued !== '')
187
+ await this.client.zadd?.(this.queuedKey(), Number(queued), record.id);
188
+ if (lease !== '')
189
+ await this.client.zadd?.(this.leasesKey(), Number(lease), record.id);
190
+ }
191
+ await this.prune('queued', records.filter((record) => record.status !== 'queued').map((record) => record.id));
192
+ await this.prune('leases', records.filter((record) => !isLeased(record)).map((record) => record.id));
193
+ return records.length;
194
+ }
195
+ async scanIndex(index, max, limit, keep, belongs) {
196
+ const found = [];
197
+ if (limit <= 0)
198
+ return found;
199
+ const stale = [];
200
+ const key = index === 'queued' ? this.queuedKey() : this.leasesKey();
201
+ const upper = max === Number.POSITIVE_INFINITY ? '+inf' : max;
202
+ const page = Math.max(16, limit * 4);
203
+ for (let offset = 0; found.length < limit; offset += page) {
204
+ const ids = (await this.client.zrangebyscore?.(key, '-inf', upper, 'LIMIT', offset, page)) ?? [];
205
+ const records = await this.readMany(ids);
206
+ for (const [position, id] of ids.entries()) {
207
+ const record = records[position];
208
+ if (!record || !belongs(record)) {
209
+ stale.push(id);
210
+ continue;
211
+ }
212
+ if (keep(record))
213
+ found.push(record);
214
+ if (found.length >= limit)
215
+ break;
216
+ }
217
+ if (ids.length < page)
218
+ break;
219
+ }
220
+ await this.prune(index, stale);
221
+ return found;
222
+ }
223
+ async readMany(ids) {
224
+ if (ids.length === 0)
225
+ return [];
226
+ const raws = this.client.hmget
227
+ ? await this.client.hmget(this.recordsKey(), ...ids)
228
+ : await Promise.all(ids.map((id) => this.client.hget(this.recordsKey(), id)));
229
+ return raws.map((raw) => (raw ? JSON.parse(raw) : undefined));
230
+ }
231
+ async prune(index, ids) {
232
+ if (ids.length === 0 || !this.useEval || !this.client.eval)
233
+ return;
234
+ const key = index === 'queued' ? this.queuedKey() : this.leasesKey();
235
+ for (let start = 0; start < ids.length; start += 500) {
236
+ await this.client.eval(PRUNE_SCRIPT, 2, this.recordsKey(), key, index, ...ids.slice(start, start + 500));
237
+ }
238
+ }
239
+ async place(record) {
240
+ const { queued, lease } = indexScores(record);
241
+ if (queued !== '')
242
+ await this.client.zadd?.(this.queuedKey(), Number(queued), record.id);
243
+ else
244
+ await this.client.zrem?.(this.queuedKey(), record.id);
245
+ if (lease !== '')
246
+ await this.client.zadd?.(this.leasesKey(), Number(lease), record.id);
247
+ else
248
+ await this.client.zrem?.(this.leasesKey(), record.id);
249
+ }
85
250
  recordsKey() {
86
251
  return `${this.prefix}records`;
87
252
  }
88
253
  idempotencyKey() {
89
254
  return `${this.prefix}idempotency`;
90
255
  }
256
+ queuedKey() {
257
+ return `${this.prefix}queued`;
258
+ }
259
+ leasesKey() {
260
+ return `${this.prefix}leases`;
261
+ }
262
+ }
263
+ function isExpired(record, now) {
264
+ if (isTerminalOperationStatus(record.status))
265
+ return false;
266
+ if (record.lease && record.lease.expiresAt > now)
267
+ return false;
268
+ return record.status === 'running' || record.lease !== undefined;
269
+ }
270
+ function isLeased(record) {
271
+ return !isTerminalOperationStatus(record.status) && (record.lease !== undefined || record.status === 'running');
272
+ }
273
+ function indexScores(record) {
274
+ return {
275
+ queued: record.status === 'queued' ? String(Date.parse(record.createdAt)) : '',
276
+ lease: isLeased(record) ? String(record.lease ? Date.parse(record.lease.expiresAt) : 0) : '',
277
+ };
91
278
  }
92
279
  export class BullMQOperationDispatcher {
93
280
  queue;
@@ -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
  };
@@ -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
  /**