@cotal-ai/core 0.61.0 → 0.63.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 (56) hide show
  1. package/dist/connector-config.d.ts +31 -1
  2. package/dist/connector-config.d.ts.map +1 -1
  3. package/dist/connector-config.js +63 -7
  4. package/dist/connector-config.js.map +1 -1
  5. package/dist/connector-setup.d.ts +11 -0
  6. package/dist/connector-setup.d.ts.map +1 -1
  7. package/dist/endpoint-reconcile.d.ts.map +1 -1
  8. package/dist/endpoint-reconcile.js +20 -15
  9. package/dist/endpoint-reconcile.js.map +1 -1
  10. package/dist/endpoint-serve-kv.d.ts +4 -3
  11. package/dist/endpoint-serve-kv.d.ts.map +1 -1
  12. package/dist/endpoint-serve-kv.js +5 -4
  13. package/dist/endpoint-serve-kv.js.map +1 -1
  14. package/dist/endpoint-service.d.ts +13 -6
  15. package/dist/endpoint-service.d.ts.map +1 -1
  16. package/dist/endpoint-service.js +20 -8
  17. package/dist/endpoint-service.js.map +1 -1
  18. package/dist/endpoint.d.ts +38 -4
  19. package/dist/endpoint.d.ts.map +1 -1
  20. package/dist/endpoint.js +127 -34
  21. package/dist/endpoint.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/issued-authority.d.ts +22 -0
  27. package/dist/issued-authority.d.ts.map +1 -1
  28. package/dist/issued-authority.js +46 -1
  29. package/dist/issued-authority.js.map +1 -1
  30. package/dist/kv-scan.d.ts +9 -0
  31. package/dist/kv-scan.d.ts.map +1 -1
  32. package/dist/kv-scan.js +5 -2
  33. package/dist/kv-scan.js.map +1 -1
  34. package/dist/managed-handoff.d.ts +48 -0
  35. package/dist/managed-handoff.d.ts.map +1 -0
  36. package/dist/managed-handoff.js +137 -0
  37. package/dist/managed-handoff.js.map +1 -0
  38. package/dist/provision.d.ts +4 -0
  39. package/dist/provision.d.ts.map +1 -1
  40. package/dist/provision.js +27 -37
  41. package/dist/provision.js.map +1 -1
  42. package/dist/remote-manager-authority.d.ts +129 -1
  43. package/dist/remote-manager-authority.d.ts.map +1 -1
  44. package/dist/remote-manager-authority.js +192 -31
  45. package/dist/remote-manager-authority.js.map +1 -1
  46. package/dist/runtime.d.ts +56 -2
  47. package/dist/runtime.d.ts.map +1 -1
  48. package/dist/runtime.js +24 -1
  49. package/dist/runtime.js.map +1 -1
  50. package/dist/subjects.d.ts +3 -0
  51. package/dist/subjects.d.ts.map +1 -1
  52. package/dist/subjects.js +5 -0
  53. package/dist/subjects.js.map +1 -1
  54. package/dist/types.d.ts +7 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -245,6 +245,12 @@ export class CotalEndpoint extends EventEmitter {
245
245
  * EXPECTED async permission violation that joinChannel turns into a clean throw, so watchStatus
246
246
  * suppresses it rather than surfacing a spurious connection error. */
247
247
  confirmingChatSubs = new Set();
248
+ /** Delete subjects of this endpoint's own consumers whose refusal watchStatus has not consumed yet
249
+ * (see {@link deleteOwnConsumer}). */
250
+ ownConsumerDeletes = new Set();
251
+ /** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches,
252
+ * running membership scans and running history reads (see {@link recordOwnWatchConsumer}). */
253
+ ownWatchDeletePrefixes = new Set();
248
254
  /** True until the first successful connect completes its boot backfill — distinguishes first-connect
249
255
  * (backfill the boot channels' history) from a reconnect (reopen the core-subs, no re-backfill).
250
256
  * Persists across reconnect (NOT connection-scoped). Replaces the legacy chat-durable consumed-cursor
@@ -296,6 +302,10 @@ export class CotalEndpoint extends EventEmitter {
296
302
  presenceRebindAt = 0;
297
303
  status = "idle";
298
304
  activity;
305
+ /** When {@link status} or {@link activity} last changed. Heartbeats republish it unchanged. */
306
+ statusSince = Date.now();
307
+ /** When {@link activity} last changed. A status change leaves it alone. */
308
+ activitySince;
299
309
  /** Last harness-reported work progress. Carried by the next heartbeat, never published per event. */
300
310
  activeAt;
301
311
  condition;
@@ -1207,6 +1217,8 @@ export class CotalEndpoint extends EventEmitter {
1207
1217
  this.chatSubs.clear();
1208
1218
  this.chatSubDenied.clear();
1209
1219
  this.confirmingChatSubs.clear();
1220
+ this.ownConsumerDeletes.clear();
1221
+ this.ownWatchDeletePrefixes.clear();
1210
1222
  this.roster.clear();
1211
1223
  // #1356: the presence-refusal record is connection-scoped like everything else torn down here.
1212
1224
  // It says "the broker on THIS connection refuses writes to this bucket", so it cannot outlive the
@@ -2246,10 +2258,14 @@ export class CotalEndpoint extends EventEmitter {
2246
2258
  }
2247
2259
  }
2248
2260
  async setActivity(activity) {
2261
+ if (activity !== this.activity)
2262
+ this.statusSince = this.activitySince = Date.now();
2249
2263
  this.activity = activity;
2250
2264
  await this.publishPresence();
2251
2265
  }
2252
2266
  async setStatus(status) {
2267
+ if (status !== this.status)
2268
+ this.statusSince = Date.now();
2253
2269
  this.status = status;
2254
2270
  await this.publishPresence();
2255
2271
  }
@@ -2525,7 +2541,17 @@ export class CotalEndpoint extends EventEmitter {
2525
2541
  // measured at 30-34s for 89 entries against a mesh at 534ms RTT. `liveKvEntries` is ~3 round
2526
2542
  // trips regardless of N, and (unlike the old loop) refuses to return a truncated view rather
2527
2543
  // than reporting a partial roster as the whole one.
2528
- for (const e of await liveKvEntries(kv)) {
2544
+ let scanPrefix;
2545
+ const entries = await liveKvEntries(kv, {
2546
+ onConsumer: (info) => { scanPrefix = this.recordOwnWatchConsumer(info); },
2547
+ deleteOwnConsumer: (stream, name, del) => this.deleteOwnConsumer(stream, name, del),
2548
+ }).finally(() => {
2549
+ // The scan's own last delete goes out after nats.js closed its consumer, so every predecessor
2550
+ // delete the library sent is answered ahead of it on this connection.
2551
+ if (scanPrefix)
2552
+ this.ownWatchDeletePrefixes.delete(scanPrefix);
2553
+ });
2554
+ for (const e of entries) {
2529
2555
  if (e.key === MEMBERSHIP_FEED_KEY) {
2530
2556
  try {
2531
2557
  asOf = e.json().observedAt;
@@ -2594,6 +2620,7 @@ export class CotalEndpoint extends EventEmitter {
2594
2620
  const cc = kv._buildCC(">", KvWatchInclude.LastValue, { headers_only: false });
2595
2621
  const consumer = await kv.js.consumers.getPushConsumer(kv.stream, cc);
2596
2622
  const info = await consumer.info(true);
2623
+ this.recordOwnWatchConsumer(info);
2597
2624
  // The broker resource exists now. Record its identity BEFORE consume() so a concurrent stop or
2598
2625
  // connection close always leaves enough state for strict cleanup or fresh-epoch retry.
2599
2626
  watch.consumer = consumer;
@@ -2649,19 +2676,72 @@ export class CotalEndpoint extends EventEmitter {
2649
2676
  watch.resolveStop = undefined;
2650
2677
  watch.rejectStop = undefined;
2651
2678
  }
2652
- /** Delete one membership-watch consumer, swallowing ONLY already-gone. */
2679
+ /** Run one delete of a consumer this endpoint created. A profile without the delete row is refused
2680
+ * by design (#691), and nats.js reports a refused request twice: as the request's rejection, which
2681
+ * the caller handles, and on the connection status, which watchStatus would emit as an `error`.
2682
+ * A refused subject stays recorded until watchStatus drops that echo, since the two settle in
2683
+ * either order. */
2684
+ async deleteOwnConsumer(stream, name, del) {
2685
+ const subject = `$JS.API.CONSUMER.DELETE.${stream}.${name}`;
2686
+ this.ownConsumerDeletes.add(subject);
2687
+ let refused = false;
2688
+ try {
2689
+ return await del();
2690
+ }
2691
+ catch (err) {
2692
+ refused = isPublishPermissionDenied(err);
2693
+ throw err;
2694
+ }
2695
+ finally {
2696
+ if (!refused)
2697
+ this.ownConsumerDeletes.delete(subject);
2698
+ }
2699
+ }
2700
+ /** Record the consumer behind one of this connection's own KV watches, membership scans or history
2701
+ * reads, and return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
2702
+ * rebuilding it after a stall or a sequence gap, deletes the predecessor itself with no hook before
2703
+ * the send, so a profile without the delete row (#691) has that delete refused. A watch's prefix
2704
+ * stays recorded for the connection because a retired watch's last rebuild can be refused after
2705
+ * the watch has stopped; {@link readMembership} and {@link drainWindow} retire theirs after their own
2706
+ * last delete, which goes out once nats.js has stopped the consumer and so is answered after every
2707
+ * predecessor delete. */
2708
+ recordOwnWatchConsumer({ stream_name, name }) {
2709
+ const prefix = `$JS.API.CONSUMER.DELETE.${stream_name}.${name.slice(0, name.lastIndexOf("_") + 1)}`;
2710
+ this.ownWatchDeletePrefixes.add(prefix);
2711
+ return prefix;
2712
+ }
2713
+ /** Whether `subject` deletes `<prefix>_<serial>` of a consumer {@link recordOwnWatchConsumer} recorded. */
2714
+ isOwnWatchDelete(subject) {
2715
+ const cut = subject.lastIndexOf("_") + 1;
2716
+ return /^\d+$/.test(subject.slice(cut)) && this.ownWatchDeletePrefixes.has(subject.slice(0, cut));
2717
+ }
2718
+ /** Delete a history reader's own ephemeral consumer. A refused delete is the designed outcome for
2719
+ * the elevated profile, which holds no stream-wide CONSUMER.DELETE (#691): the broker reaps the
2720
+ * consumer at its inactive threshold. */
2721
+ async deleteReaderConsumer(consumer) {
2722
+ const { stream_name, name } = await consumer.info(true);
2723
+ try {
2724
+ await this.deleteOwnConsumer(stream_name, name, () => consumer.delete());
2725
+ }
2726
+ catch (e) {
2727
+ if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound) && !isPublishPermissionDenied(e))
2728
+ throw e;
2729
+ }
2730
+ }
2731
+ /** Delete one membership-watch consumer, swallowing ONLY already-gone and a refused delete. */
2653
2732
  async deleteMembershipConsumer(jsm, stream, name) {
2654
2733
  try {
2655
- return await jsm.consumers.delete(stream, name);
2734
+ return await this.deleteOwnConsumer(stream, name, () => jsm.consumers.delete(stream, name));
2656
2735
  }
2657
2736
  catch (err) {
2658
- if (err.code === 404 || /(consumer|stream) not found/i.test(err.message))
2737
+ if (membershipConsumerReleased(err))
2659
2738
  return true;
2660
2739
  throw err;
2661
2740
  }
2662
2741
  }
2663
- /** Stop the local iterator AND delete its ordered consumer. The admin/observer grant already holds
2664
- * the bucket-scoped consumer-delete row, so a reconnect leaves no five-minute predecessor. */
2742
+ /** Stop the local iterator AND delete its ordered consumer. The admin/observer profile holds no
2743
+ * consumer delete on this bucket (#691), so its refused delete leaves the consumer to the broker's
2744
+ * five-minute inactive threshold. */
2665
2745
  async disarmMembershipWatch(watch) {
2666
2746
  const iter = watch.iter;
2667
2747
  const consumer = watch.consumer;
@@ -2673,14 +2753,15 @@ export class CotalEndpoint extends EventEmitter {
2673
2753
  catch { /* already closed */ }
2674
2754
  if (consumer) {
2675
2755
  try {
2676
- const deleted = await consumer.delete();
2756
+ const { stream_name, name } = await consumer.info(true);
2757
+ const deleted = await this.deleteOwnConsumer(stream_name, name, () => consumer.delete());
2677
2758
  if (deleted) {
2678
2759
  watch.consumerStream = undefined;
2679
2760
  watch.consumerName = undefined;
2680
2761
  }
2681
2762
  }
2682
2763
  catch (err) {
2683
- if (err.code === 404 || /(consumer|stream) not found/i.test(err.message)) {
2764
+ if (membershipConsumerReleased(err)) {
2684
2765
  watch.consumerStream = undefined;
2685
2766
  watch.consumerName = undefined;
2686
2767
  }
@@ -3049,13 +3130,7 @@ export class CotalEndpoint extends EventEmitter {
3049
3130
  throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
3050
3131
  }
3051
3132
  finally {
3052
- try {
3053
- await consumer.delete();
3054
- }
3055
- catch (e) {
3056
- if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
3057
- throw e;
3058
- }
3133
+ await this.deleteReaderConsumer(consumer);
3059
3134
  }
3060
3135
  }
3061
3136
  /** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
@@ -3095,13 +3170,7 @@ export class CotalEndpoint extends EventEmitter {
3095
3170
  throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
3096
3171
  }
3097
3172
  finally {
3098
- try {
3099
- await consumer.delete();
3100
- }
3101
- catch (e) {
3102
- if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
3103
- throw e;
3104
- }
3173
+ await this.deleteReaderConsumer(consumer);
3105
3174
  }
3106
3175
  }
3107
3176
  /** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
@@ -3112,6 +3181,8 @@ export class CotalEndpoint extends EventEmitter {
3112
3181
  signal?.throwIfAborted();
3113
3182
  const out = [];
3114
3183
  const consumer = await js.consumers.get(stream, { filter_subjects: subjects, opt_start_seq: start });
3184
+ // A delivery-sequence gap makes nats.js rebuild this consumer and delete the predecessor itself.
3185
+ const readerPrefix = this.recordOwnWatchConsumer(await consumer.info(true));
3115
3186
  try {
3116
3187
  // A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
3117
3188
  // explicit uncached `info()` this used to call was a round trip for data we already had.
@@ -3174,13 +3245,9 @@ export class CotalEndpoint extends EventEmitter {
3174
3245
  // DMs, and the single-channel routes still make one each.
3175
3246
  // Left alone that accumulates consumers on the broker until the thresholds expire, and the
3176
3247
  // resulting resource exhaustion would land in streamHistory's catch and read as empty history.
3177
- try {
3178
- await consumer.delete();
3179
- }
3180
- catch (e) {
3181
- if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
3182
- throw e;
3183
- }
3248
+ // The observer and admin profiles may not delete (#691), so the dashboard's readers do live out
3249
+ // that threshold; only a profile holding the delete row reclaims them here.
3250
+ await this.deleteReaderConsumer(consumer).finally(() => { this.ownWatchDeletePrefixes.delete(readerPrefix); });
3184
3251
  }
3185
3252
  }
3186
3253
  // ---- internals -----------------------------------------------------------
@@ -3224,6 +3291,12 @@ export class CotalEndpoint extends EventEmitter {
3224
3291
  // and turns into a clean throw — it is not a connection error to surface.
3225
3292
  if (s.error instanceof PermissionViolationError && this.confirmingChatSubs.has(s.error.subject))
3226
3293
  continue;
3294
+ // The echo of a refused delete of this endpoint's own consumer: one its caller already handled,
3295
+ // or the predecessor nats.js deleted while rebuilding one of this connection's watches, scans or
3296
+ // history reads.
3297
+ if (s.error instanceof PermissionViolationError && s.error.operation === "publish"
3298
+ && (this.ownConsumerDeletes.delete(s.error.subject) || this.isOwnWatchDelete(s.error.subject)))
3299
+ continue;
3227
3300
  this.emit("error", describeStatusError(s.error));
3228
3301
  }
3229
3302
  })().catch((e) => {
@@ -5838,14 +5911,17 @@ export class CotalEndpoint extends EventEmitter {
5838
5911
  condition: this.condition,
5839
5912
  environment: this.environment,
5840
5913
  activity: this.activity,
5914
+ statusSince: this.statusSince,
5915
+ activitySince: this.activitySince,
5841
5916
  activeAt: this.activeAt,
5842
5917
  attention: this.attentionMode,
5843
5918
  channelModes: this.channelModes,
5844
5919
  ts: Date.now(),
5845
5920
  };
5846
- // Wire contract (SPEC §6): an OFFLINE record must not carry the advisory attention fields. Scrub at
5847
- // the publisher — this covers stop(), setStatus("offline"), and any future offline publish site, so
5848
- // the raw KV record is compliant, not only the observer-side roster materialization.
5921
+ // Wire contract (SPEC §6): an OFFLINE record must not carry the advisory attention fields or
5922
+ // `statusSince`. Scrub at the publisher — this covers stop(), setStatus("offline"), and any future
5923
+ // offline publish site, so the raw KV record is compliant, not only the observer-side roster
5924
+ // materialization.
5849
5925
  const record = this.status === "offline" ? this.toOffline(p) : p;
5850
5926
  // #1356: this put can still be in flight when a teardown runs, because a rebind reaches
5851
5927
  // publishPresence through onPresenceBucketEmpty and no teardown awaits that flight. Take the same
@@ -5968,6 +6044,7 @@ export class CotalEndpoint extends EventEmitter {
5968
6044
  let hydrated;
5969
6045
  this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
5970
6046
  const iter = await this.kv.watch();
6047
+ this.recordOwnWatchConsumer(await kvWatchConsumer(iter).info(true));
5971
6048
  if (epoch !== this.presenceEpoch) {
5972
6049
  try {
5973
6050
  iter.stop();
@@ -6065,6 +6142,7 @@ export class CotalEndpoint extends EventEmitter {
6065
6142
  if (!this.channelKv)
6066
6143
  return;
6067
6144
  const iter = await this.channelKv.watch();
6145
+ this.recordOwnWatchConsumer(await kvWatchConsumer(iter).info(true));
6068
6146
  this.channelWatchIter = iter;
6069
6147
  void (async () => {
6070
6148
  for await (const e of iter)
@@ -6236,9 +6314,11 @@ export class CotalEndpoint extends EventEmitter {
6236
6314
  }
6237
6315
  /** Materialize an OFFLINE presence record: drop the advisory attention fields. An offline peer must
6238
6316
  * not show a stale `[focus]` or "locally muted #x" hint — SPEC: attention removed on offline sweep,
6239
- * channel modes reset on restart. card/activity/ts are kept. */
6317
+ * channel modes reset on restart. `statusSince` goes too: it dated the live status, and an observer
6318
+ * that derives offline from a stale heartbeat does not know when the peer left. card/activity/ts
6319
+ * are kept. */
6240
6320
  toOffline(p) {
6241
- return { ...p, status: "offline", attention: undefined, channelModes: undefined };
6321
+ return { ...p, status: "offline", statusSince: undefined, attention: undefined, channelModes: undefined };
6242
6322
  }
6243
6323
  /** Mark a known peer offline (on KV delete/purge), keeping it in the roster. */
6244
6324
  markOffline(id) {
@@ -6676,6 +6756,19 @@ export function isPermissionDenied(e) {
6676
6756
  function isJetStreamMissing(e, ...codes) {
6677
6757
  return e instanceof JetStreamApiError && codes.includes(e.code);
6678
6758
  }
6759
+ /** The ordered consumer behind a KV watch, which nats.js keeps on the iterator and the KV types hide. */
6760
+ function kvWatchConsumer(iter) {
6761
+ const consumer = iter._data;
6762
+ if (!consumer)
6763
+ throw new Error("KV watch carries no consumer - the pinned nats.js changed shape");
6764
+ return consumer;
6765
+ }
6766
+ /** A membership-watch delete that leaves nothing to retry: the consumer is already gone, or the broker
6767
+ * refused the delete because the reading profile holds no stream-wide CONSUMER.DELETE (#691) and
6768
+ * reaps the consumer at its inactive threshold. */
6769
+ function membershipConsumerReleased(err) {
6770
+ return err.code === 404 || /(consumer|stream) not found/i.test(err.message) || isPublishPermissionDenied(err);
6771
+ }
6679
6772
  /** The signal's exact abort reason, or the platform-standard AbortError when none was supplied. */
6680
6773
  function abortReason(signal) {
6681
6774
  return signal?.reason instanceof Error