@cotal-ai/core 0.68.0 → 0.70.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 (112) hide show
  1. package/dist/agent-file.d.ts.map +1 -1
  2. package/dist/agent-file.js +8 -6
  3. package/dist/agent-file.js.map +1 -1
  4. package/dist/auth-provider.d.ts +0 -1
  5. package/dist/auth-provider.d.ts.map +1 -1
  6. package/dist/connector-config.d.ts +4 -0
  7. package/dist/connector-config.d.ts.map +1 -1
  8. package/dist/connector-config.js +49 -0
  9. package/dist/connector-config.js.map +1 -1
  10. package/dist/contract-manifest.d.ts +20 -0
  11. package/dist/contract-manifest.d.ts.map +1 -0
  12. package/dist/contract-manifest.js +28 -0
  13. package/dist/contract-manifest.js.map +1 -0
  14. package/dist/endpoint-binding.d.ts.map +1 -1
  15. package/dist/endpoint-binding.js +5 -0
  16. package/dist/endpoint-binding.js.map +1 -1
  17. package/dist/endpoint-cluster.d.ts.map +1 -1
  18. package/dist/endpoint-cluster.js +9 -4
  19. package/dist/endpoint-cluster.js.map +1 -1
  20. package/dist/endpoint-contract-store.d.ts +2 -18
  21. package/dist/endpoint-contract-store.d.ts.map +1 -1
  22. package/dist/endpoint-contract-store.js +3 -19
  23. package/dist/endpoint-contract-store.js.map +1 -1
  24. package/dist/endpoint-envelope.d.ts +6 -2
  25. package/dist/endpoint-envelope.d.ts.map +1 -1
  26. package/dist/endpoint-envelope.js +19 -2
  27. package/dist/endpoint-envelope.js.map +1 -1
  28. package/dist/endpoint-error.d.ts +13 -0
  29. package/dist/endpoint-error.d.ts.map +1 -1
  30. package/dist/endpoint-error.js +15 -0
  31. package/dist/endpoint-error.js.map +1 -1
  32. package/dist/endpoint-invoke.d.ts +26 -0
  33. package/dist/endpoint-invoke.d.ts.map +1 -1
  34. package/dist/endpoint-invoke.js +60 -4
  35. package/dist/endpoint-invoke.js.map +1 -1
  36. package/dist/endpoint-serve-kv.d.ts +0 -1
  37. package/dist/endpoint-serve-kv.d.ts.map +1 -1
  38. package/dist/endpoint-serve-kv.js +0 -1
  39. package/dist/endpoint-serve-kv.js.map +1 -1
  40. package/dist/endpoint-serve.d.ts.map +1 -1
  41. package/dist/endpoint-serve.js +4 -3
  42. package/dist/endpoint-serve.js.map +1 -1
  43. package/dist/endpoint-service.d.ts +12 -6
  44. package/dist/endpoint-service.d.ts.map +1 -1
  45. package/dist/endpoint-service.js +29 -23
  46. package/dist/endpoint-service.js.map +1 -1
  47. package/dist/endpoint-verbs.d.ts.map +1 -1
  48. package/dist/endpoint-verbs.js +2 -1
  49. package/dist/endpoint-verbs.js.map +1 -1
  50. package/dist/endpoint.d.ts +31 -22
  51. package/dist/endpoint.d.ts.map +1 -1
  52. package/dist/endpoint.js +117 -90
  53. package/dist/endpoint.js.map +1 -1
  54. package/dist/evict.d.ts.map +1 -1
  55. package/dist/evict.js +2 -1
  56. package/dist/evict.js.map +1 -1
  57. package/dist/identity.d.ts +3 -0
  58. package/dist/identity.d.ts.map +1 -1
  59. package/dist/identity.js +5 -0
  60. package/dist/identity.js.map +1 -1
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +2 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/kv-scan.d.ts +1 -3
  66. package/dist/kv-scan.d.ts.map +1 -1
  67. package/dist/kv-scan.js.map +1 -1
  68. package/dist/loopback.d.ts +8 -0
  69. package/dist/loopback.d.ts.map +1 -0
  70. package/dist/loopback.js +17 -0
  71. package/dist/loopback.js.map +1 -0
  72. package/dist/managed-handoff.d.ts.map +1 -1
  73. package/dist/managed-handoff.js +1 -18
  74. package/dist/managed-handoff.js.map +1 -1
  75. package/dist/presence-ages.d.ts +22 -0
  76. package/dist/presence-ages.d.ts.map +1 -0
  77. package/dist/presence-ages.js +33 -0
  78. package/dist/presence-ages.js.map +1 -0
  79. package/dist/provision.d.ts +23 -28
  80. package/dist/provision.d.ts.map +1 -1
  81. package/dist/provision.js +112 -126
  82. package/dist/provision.js.map +1 -1
  83. package/dist/remote-manager-authority.d.ts +30 -7
  84. package/dist/remote-manager-authority.d.ts.map +1 -1
  85. package/dist/remote-manager-authority.js +23 -15
  86. package/dist/remote-manager-authority.js.map +1 -1
  87. package/dist/resolve.d.ts +15 -2
  88. package/dist/resolve.d.ts.map +1 -1
  89. package/dist/resolve.js +15 -2
  90. package/dist/resolve.js.map +1 -1
  91. package/dist/run-journal.d.ts.map +1 -1
  92. package/dist/run-journal.js +17 -2
  93. package/dist/run-journal.js.map +1 -1
  94. package/dist/run-record.d.ts +6 -8
  95. package/dist/run-record.d.ts.map +1 -1
  96. package/dist/run-record.js.map +1 -1
  97. package/dist/schema-profile.d.ts +11 -0
  98. package/dist/schema-profile.d.ts.map +1 -1
  99. package/dist/schema-profile.js +13 -2
  100. package/dist/schema-profile.js.map +1 -1
  101. package/dist/secret-store.d.ts +6 -2
  102. package/dist/secret-store.d.ts.map +1 -1
  103. package/dist/secret-store.js +9 -6
  104. package/dist/secret-store.js.map +1 -1
  105. package/dist/streams.d.ts.map +1 -1
  106. package/dist/streams.js +0 -1
  107. package/dist/streams.js.map +1 -1
  108. package/dist/subjects.d.ts +5 -0
  109. package/dist/subjects.d.ts.map +1 -1
  110. package/dist/subjects.js +11 -0
  111. package/dist/subjects.js.map +1 -1
  112. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -9,7 +9,7 @@ import { requireBrokerFloor } from "./broker-floor.js";
9
9
  import { inspectCredHealth } from "./provision.js";
10
10
  import { parseSecretStoreIdentity, } from "./secret-store.js";
11
11
  import { BIND_SPLIT_REISSUES, resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
12
- import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, EP_BIND_REFUSED } from "./endpoint-envelope.js";
12
+ import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, replyTargetUnmapped, EP_BIND_REFUSED } from "./endpoint-envelope.js";
13
13
  import { isRepeatSafeCommand } from "./endpoint-grants.js";
14
14
  import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
15
15
  import { readAcceptedRow } from "./issued-authority.js";
@@ -249,9 +249,6 @@ export class CotalEndpoint extends EventEmitter {
249
249
  * EXPECTED async permission violation that joinChannel turns into a clean throw, so watchStatus
250
250
  * suppresses it rather than surfacing a spurious connection error. */
251
251
  confirmingChatSubs = new Set();
252
- /** Delete subjects of this endpoint's own consumers whose refusal watchStatus has not consumed yet
253
- * (see {@link deleteOwnConsumer}). */
254
- ownConsumerDeletes = new Set();
255
252
  /** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches,
256
253
  * running membership scans and running history reads (see {@link recordOwnWatchConsumer}). */
257
254
  ownWatchDeletePrefixes = new Set();
@@ -366,8 +363,9 @@ export class CotalEndpoint extends EventEmitter {
366
363
  /** Active goal followers awaiting outcome, tracked so endpoint stop can cancel them immediately
367
364
  * and connection replacement / reconnect can trigger reconciliation. */
368
365
  activeGoalFollowers = new Set();
369
- /** How many calls {@link invokeService} has silently recovered from a bind refusal (§13.2) — the
370
- * class-queue splits this endpoint hit and survived.
366
+ /** How many calls {@link invokeService} has silently recovered from a bind refusal (§13.2) or a
367
+ * member holding no mapping for the target (§13.3) — the class-queue splits this endpoint hit
368
+ * and survived.
371
369
  *
372
370
  * Counted because it is recovered: handling the split is what makes it invisible, so this is the
373
371
  * only evidence the split rate exists. Always on, never behind a flag — a counter you have to
@@ -1222,7 +1220,6 @@ export class CotalEndpoint extends EventEmitter {
1222
1220
  this.chatSubs.clear();
1223
1221
  this.chatSubDenied.clear();
1224
1222
  this.confirmingChatSubs.clear();
1225
- this.ownConsumerDeletes.clear();
1226
1223
  this.ownWatchDeletePrefixes.clear();
1227
1224
  this.roster.clear();
1228
1225
  // #1356: the presence-refusal record is connection-scoped like everything else torn down here.
@@ -1911,8 +1908,9 @@ export class CotalEndpoint extends EventEmitter {
1911
1908
  this.emit("error", new Error(`rejected ${service} request on ${m.subject}: reply target "${m.reply ?? "(none)"}" is not under the sender's own reply subtree`));
1912
1909
  continue;
1913
1910
  }
1914
- let reply;
1911
+ let body;
1915
1912
  try {
1913
+ let reply;
1916
1914
  const req = m.json();
1917
1915
  // Authenticity guard (fail closed): control is the most privileged surface
1918
1916
  // (start/stop). The sender is encoded in the subject (ctl.<svc>.<sender>), which
@@ -1927,15 +1925,25 @@ export class CotalEndpoint extends EventEmitter {
1927
1925
  else {
1928
1926
  reply = await handler(req);
1929
1927
  }
1928
+ body = JSON.stringify(reply);
1930
1929
  }
1931
1930
  catch (e) {
1932
- reply = { ok: false, error: e.message };
1931
+ body = JSON.stringify({ ok: false, error: e.message });
1933
1932
  }
1934
1933
  try {
1935
- m.respond(JSON.stringify(reply));
1934
+ m.respond(body);
1936
1935
  }
1937
- catch {
1938
- /* no reply inbox */
1936
+ catch (e) {
1937
+ // `respond` signals a missing reply inbox by returning false; it throws when the reply exceeds the
1938
+ // broker's `max_payload`. Silence would leave the caller a bare timeout it cannot tell apart from
1939
+ // a dead service.
1940
+ const refused = { ok: false, error: `the ${service} reply is ${Buffer.byteLength(body)} bytes and was not published: ${e.message}` };
1941
+ try {
1942
+ m.respond(JSON.stringify(refused));
1943
+ }
1944
+ catch (err) {
1945
+ this.emit("error", new Error(`could not answer ${service} request on ${m.subject}: ${refused.error}, and the refusal failed too: ${err.message}`));
1946
+ }
1939
1947
  }
1940
1948
  }
1941
1949
  })().catch((e) => this.emit("error", e));
@@ -2017,6 +2025,7 @@ export class CotalEndpoint extends EventEmitter {
2017
2025
  return () => { reconnectHandler = undefined; };
2018
2026
  },
2019
2027
  signal: abortController.signal,
2028
+ prepare: opts.prepare,
2020
2029
  });
2021
2030
  }
2022
2031
  finally {
@@ -2035,7 +2044,9 @@ export class CotalEndpoint extends EventEmitter {
2035
2044
  * call re-issued for any command, up to {@link BIND_SPLIT_REISSUES} times while each re-issue
2036
2045
  * is refused the same way. If a re-issue cannot be resolved, the refusal
2037
2046
  * surfaces — still saying the command did not run — naming the resolve failure as why the
2038
- * repair could not be attempted.
2047
+ * repair could not be attempted. An unpinned targeted call that reached a member holding no
2048
+ * mapping for its target ({@link replyTargetUnmapped}) is repaired the same way, and when the
2049
+ * re-issues run out, the latest such refusal is the one surfaced.
2039
2050
  * - this CLIENT caught it on the reply ({@link respondedButUnbound}: a different instance,
2040
2051
  * `failed-precondition`; the same instance at any other epoch, `expired`), which is what a
2041
2052
  * responder too old to know the field produces. A live instance received and answered it, so
@@ -2079,10 +2090,22 @@ export class CotalEndpoint extends EventEmitter {
2079
2090
  }
2080
2091
  return invokeCommand(nc, this.space, service, command, args, { ...invokeOpts, signal });
2081
2092
  };
2082
- const doInvoke = async (signal = opts.signal) => {
2093
+ // A `failed-precondition` from the resolve is its own refusal, raised before any command was
2094
+ // published, so resolving once more is a repair.
2095
+ const resolveFirst = async (signal = opts.signal) => {
2096
+ try {
2097
+ return await resolve(signal);
2098
+ }
2099
+ catch (e) {
2100
+ if (!(e instanceof EpEnvelopeError) || e.code !== "failed-precondition")
2101
+ throw e;
2102
+ return await resolve(signal);
2103
+ }
2104
+ };
2105
+ const doInvoke = async (service, signal = opts.signal) => {
2083
2106
  signal?.throwIfAborted();
2084
2107
  try {
2085
- let r = await invokeResolved(await resolve(signal), signal);
2108
+ let r = await invokeResolved(service, signal);
2086
2109
  // THE RESPONDER FENCED IT (§13.2 `ai.cotal.ep.bind-refused`): a class member saw the call
2087
2110
  // was bound to a different incarnation and refused BEFORE running the command.
2088
2111
  //
@@ -2094,7 +2117,14 @@ export class CotalEndpoint extends EventEmitter {
2094
2117
  // did not run, so each re-issue is a FIRST attempt, not a second. It repeats up to the bound
2095
2118
  // the CLI uses, because the re-issue rides the same class queue and splits again at the same
2096
2119
  // rate; repairing once left a quarter of all calls in a two-manager space failing (#443).
2097
- for (let reissues = 0; reissues < BIND_SPLIT_REISSUES && r.reply.ok === false && replyRefusedBeforeEffect(r.reply.error); reissues++) {
2120
+ // A member that holds no mapping for the target also ran nothing, and on the class rail a
2121
+ // sibling may host it. A pinned call named its instance, so that refusal is its answer.
2122
+ // That member looked the target up, so when the re-issues run out its refusal is surfaced
2123
+ // in place of a trailing bind refusal, which says nothing about the target.
2124
+ const unmappedHere = (x) => opts.instanceId === undefined && replyTargetUnmapped(x.reply.error);
2125
+ let unmapped = unmappedHere(r) ? r : undefined;
2126
+ for (let reissues = 0; reissues < BIND_SPLIT_REISSUES && r.reply.ok === false
2127
+ && (replyRefusedBeforeEffect(r.reply.error) || unmappedHere(r)); reissues++) {
2098
2128
  // Counted before it is repaired: a recovery that leaves no trace takes the split rate with it.
2099
2129
  this.splitsRecovered++;
2100
2130
  // `boundTo` is the other half of `servedBy`: who the handle THOUGHT it was talking to,
@@ -2145,12 +2175,12 @@ export class CotalEndpoint extends EventEmitter {
2145
2175
  "not-executed");
2146
2176
  }
2147
2177
  r = await invokeResolved(reissueTarget, signal);
2178
+ if (unmappedHere(r))
2179
+ unmapped = r;
2148
2180
  }
2149
- return r;
2181
+ return replyRefusedBeforeEffect(r.reply.error) ? unmapped ?? r : r;
2150
2182
  }
2151
2183
  catch (e) {
2152
- if (!(e instanceof EpEnvelopeError))
2153
- throw e;
2154
2184
  // DO NOT auto-retry a command a responder already ANSWERED. This path covers the responders
2155
2185
  // WITHOUT the fence above: they ignore `bind`, run the command, and the error is raised
2156
2186
  // afterwards, so core cannot tell a repair from a duplicate and the allowlist is the only
@@ -2183,23 +2213,20 @@ export class CotalEndpoint extends EventEmitter {
2183
2213
  throw e;
2184
2214
  return await invokeResolved(await resolve(signal), signal);
2185
2215
  }
2186
- // An UNMARKED `failed-precondition` is the resolve's own refusal, raised before any command
2187
- // was published, so re-resolving once is a repair. The `replyRefusedBeforeEffect` half keeps
2188
- // out the refusal THIS method raises after a failed re-issue: it carries that same code, and
2189
- // would otherwise fall into the re-resolve below as a THIRD attempt at a command whose
2190
- // second could not even be resolved. A marker means the disposition is already decided,
2191
- // whichever code carries it.
2192
- if (e.code !== "failed-precondition" || replyRefusedBeforeEffect(e.toEpError()))
2193
- throw e;
2194
- this.resolvedServices.delete(endpoint);
2195
- return await invokeResolved(await resolve(signal), signal);
2216
+ throw e;
2196
2217
  }
2197
2218
  };
2198
2219
  // P2 item 2 (2b): a goal-bearing command (spawn/launch) follows its acceptance to the terminal so
2199
2220
  // the caller still returns on the real outcome (UX unchanged); every other command replies directly.
2200
2221
  if (!opts.follow)
2201
- return doInvoke();
2202
- return this.followServiceGoal(endpoint, doInvoke, opts.deadlineMs ?? 10_000, { signal: opts.signal });
2222
+ return doInvoke(await resolveFirst());
2223
+ // Resolved before the submission starts: the follow reports an unmarked failure inside the
2224
+ // submission as a command that may have run, and the resolve publishes none of this call.
2225
+ let service;
2226
+ return this.followServiceGoal(endpoint, (signal) => doInvoke(service, signal), opts.deadlineMs ?? 10_000, {
2227
+ signal: opts.signal,
2228
+ prepare: async (signal) => { service = await resolveFirst(signal); },
2229
+ });
2203
2230
  }
2204
2231
  /** Send a durable-membership request to the SERVER-SIDE delivery daemon (`ctl.delivery`) and await its
2205
2232
  * reply. Unlike {@link requestControl}, the reply rides a subject UNDER `ctl.delivery.<id>.>` (not the
@@ -2562,7 +2589,6 @@ export class CotalEndpoint extends EventEmitter {
2562
2589
  let scanPrefix;
2563
2590
  const entries = await liveKvEntries(kv, {
2564
2591
  onConsumer: (info) => { scanPrefix = this.recordOwnWatchConsumer(info); },
2565
- deleteOwnConsumer: (stream, name, del) => this.deleteOwnConsumer(stream, name, del),
2566
2592
  }).finally(() => {
2567
2593
  // The scan's own last delete goes out after nats.js closed its consumer, so every predecessor
2568
2594
  // delete the library sent is answered ahead of it on this connection.
@@ -2644,15 +2670,11 @@ export class CotalEndpoint extends EventEmitter {
2644
2670
  watch.consumer = consumer;
2645
2671
  watch.consumerStream = info.stream_name;
2646
2672
  watch.consumerName = info.name;
2647
- let pending = info.num_pending;
2648
2673
  let iter;
2649
2674
  try {
2650
- iter = await consumer.consume({ callback: (msg) => {
2651
- const isUpdate = pending === 0 || --pending === 0;
2652
- const entry = kv.jmToWatchEntry(msg, isUpdate);
2675
+ iter = await consumer.consume({ callback: () => {
2653
2676
  if (!watch.stopped && watch.consumer === consumer)
2654
2677
  watch.onChange();
2655
- void entry;
2656
2678
  } });
2657
2679
  }
2658
2680
  catch (err) {
@@ -2694,31 +2716,12 @@ export class CotalEndpoint extends EventEmitter {
2694
2716
  watch.resolveStop = undefined;
2695
2717
  watch.rejectStop = undefined;
2696
2718
  }
2697
- /** Run one delete of a consumer this endpoint created. A profile without the delete row is refused
2698
- * by design (#691), and nats.js reports a refused request twice: as the request's rejection, which
2699
- * the caller handles, and on the connection status, which watchStatus would emit as an `error`.
2700
- * A refused subject stays recorded until watchStatus drops that echo, since the two settle in
2701
- * either order. */
2702
- async deleteOwnConsumer(stream, name, del) {
2703
- const subject = `$JS.API.CONSUMER.DELETE.${stream}.${name}`;
2704
- this.ownConsumerDeletes.add(subject);
2705
- let refused = false;
2706
- try {
2707
- return await del();
2708
- }
2709
- catch (err) {
2710
- refused = isPublishPermissionDenied(err);
2711
- throw err;
2712
- }
2713
- finally {
2714
- if (!refused)
2715
- this.ownConsumerDeletes.delete(subject);
2716
- }
2717
- }
2718
2719
  /** Record the consumer behind one of this connection's own KV watches, membership scans or history
2719
2720
  * reads, and return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
2720
2721
  * rebuilding it after a stall or a sequence gap, deletes the predecessor itself with no hook before
2721
- * the send, so a profile without the delete row (#691) has that delete refused. A watch's prefix
2722
+ * the send, so a profile without the delete row (#691) has that delete refused. A stalled link can
2723
+ * time that delete out before its refusal arrives, and nats.js then hands the refusal to no request,
2724
+ * so {@link trackRequestDenials} cannot own it and the consumer's name has to. A watch's prefix
2722
2725
  * stays recorded for the connection because a retired watch's last rebuild can be refused after
2723
2726
  * the watch has stopped; {@link readMembership} and {@link drainWindow} retire theirs after their own
2724
2727
  * last delete, which goes out once nats.js has stopped the consumer and so is answered after every
@@ -2737,9 +2740,8 @@ export class CotalEndpoint extends EventEmitter {
2737
2740
  * the elevated profile, which holds no stream-wide CONSUMER.DELETE (#691): the broker reaps the
2738
2741
  * consumer at its inactive threshold. */
2739
2742
  async deleteReaderConsumer(consumer) {
2740
- const { stream_name, name } = await consumer.info(true);
2741
2743
  try {
2742
- await this.deleteOwnConsumer(stream_name, name, () => consumer.delete());
2744
+ await consumer.delete();
2743
2745
  }
2744
2746
  catch (e) {
2745
2747
  if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound) && !isPublishPermissionDenied(e))
@@ -2749,7 +2751,7 @@ export class CotalEndpoint extends EventEmitter {
2749
2751
  /** Delete one membership-watch consumer, swallowing ONLY already-gone and a refused delete. */
2750
2752
  async deleteMembershipConsumer(jsm, stream, name) {
2751
2753
  try {
2752
- return await this.deleteOwnConsumer(stream, name, () => jsm.consumers.delete(stream, name));
2754
+ return await jsm.consumers.delete(stream, name);
2753
2755
  }
2754
2756
  catch (err) {
2755
2757
  if (membershipConsumerReleased(err))
@@ -2771,8 +2773,7 @@ export class CotalEndpoint extends EventEmitter {
2771
2773
  catch { /* already closed */ }
2772
2774
  if (consumer) {
2773
2775
  try {
2774
- const { stream_name, name } = await consumer.info(true);
2775
- const deleted = await this.deleteOwnConsumer(stream_name, name, () => consumer.delete());
2776
+ const deleted = await consumer.delete();
2776
2777
  if (deleted) {
2777
2778
  watch.consumerStream = undefined;
2778
2779
  watch.consumerName = undefined;
@@ -3271,15 +3272,18 @@ export class CotalEndpoint extends EventEmitter {
3271
3272
  // ---- internals -----------------------------------------------------------
3272
3273
  /**
3273
3274
  * Surface the connection's async status errors on our `error` event. NATS reports
3274
- * publish permission violations *only* here (subscription/request ones too), never on
3275
- * the failing call — so without this an over-tight ACL silently drops the agent's
3276
- * traffic and it just looks "absent". We annotate permission denials explicitly so a
3277
- * denial is never mistaken for absence (which already has a benign cause: MCP reconnect).
3275
+ * publish permission violations *only* here (subscription ones too), never on the
3276
+ * failing call — so without this an over-tight ACL silently drops the agent's
3277
+ * traffic and it just looks "absent". A denied request is the exception: nats.js also
3278
+ * rejects that request with the denial, so its caller owns it (see {@link trackRequestDenials}).
3279
+ * We annotate permission denials explicitly so a denial is never mistaken for absence
3280
+ * (which already has a benign cause: MCP reconnect).
3278
3281
  */
3279
3282
  watchStatus() {
3280
3283
  const nc = this.nc;
3281
3284
  if (!nc)
3282
3285
  return;
3286
+ const handedToRequest = this.trackRequestDenials(nc);
3283
3287
  void (async () => {
3284
3288
  for await (const s of nc.status()) {
3285
3289
  // A rebuild can replace `this.nc` before the old iterator finishes. Late disconnect/close
@@ -3309,11 +3313,12 @@ export class CotalEndpoint extends EventEmitter {
3309
3313
  // and turns into a clean throw — it is not a connection error to surface.
3310
3314
  if (s.error instanceof PermissionViolationError && this.confirmingChatSubs.has(s.error.subject))
3311
3315
  continue;
3312
- // The echo of a refused delete of this endpoint's own consumer: one its caller already handled,
3313
- // or the predecessor nats.js deleted while rebuilding one of this connection's watches, scans or
3314
- // history reads.
3316
+ if (s.error instanceof PermissionViolationError && handedToRequest.has(s.error))
3317
+ continue;
3318
+ // The predecessor nats.js deleted while rebuilding one of this connection's watches, scans or
3319
+ // history reads, whose refusal can arrive after that delete timed out.
3315
3320
  if (s.error instanceof PermissionViolationError && s.error.operation === "publish"
3316
- && (this.ownConsumerDeletes.delete(s.error.subject) || this.isOwnWatchDelete(s.error.subject)))
3321
+ && this.isOwnWatchDelete(s.error.subject))
3317
3322
  continue;
3318
3323
  this.emit("error", describeStatusError(s.error));
3319
3324
  }
@@ -3337,6 +3342,29 @@ export class CotalEndpoint extends EventEmitter {
3337
3342
  return;
3338
3343
  this.emit("transport", { connected: true, server: nc.getServer() });
3339
3344
  }
3345
+ /** Wrap `nc.request` to record each publish denial nats.js rejects one of this connection's requests
3346
+ * with. nats.js rejects the pending request on the denied subject with the denial and then dispatches
3347
+ * that same error instance on the connection status, so the caller that received it (an empty history
3348
+ * read, a refused consumer delete, nats.js's own delete of a rebuilt watch's predecessor) owns it and
3349
+ * the status loop skips it. nats.js settles the request before it dispatches the status and this
3350
+ * reaction sits directly on the promise it returns, so it runs before the status loop body, which is
3351
+ * further microtasks away behind the status iterator. Any other denial is a different instance and is
3352
+ * not recorded, including one on the same subject and one that arrives after its request timed out.
3353
+ * A refused request inbox is rejected into every pending request but breaks every later one, so it
3354
+ * is not recorded either. */
3355
+ trackRequestDenials(nc) {
3356
+ const handed = new WeakSet();
3357
+ const request = nc.request.bind(nc);
3358
+ nc.request = (subject, payload, opts) => {
3359
+ const reply = request(subject, payload, opts);
3360
+ reply.catch(({ cause }) => {
3361
+ if (cause instanceof PermissionViolationError && cause.operation === "publish")
3362
+ handed.add(cause);
3363
+ });
3364
+ return reply;
3365
+ };
3366
+ return handed;
3367
+ }
3340
3368
  /** The error message for a guard that finds the endpoint unbound: "reconnecting" during a
3341
3369
  * rebuild's null window OR an inter-retry backoff (so a concurrent op reports the real
3342
3370
  * reason, not "not started" — `reestablishing` spans the whole retry loop incl. backoff),
@@ -4356,9 +4384,8 @@ export class CotalEndpoint extends EventEmitter {
4356
4384
  const reachedStart = page.length <= limit;
4357
4385
  const wanted = reachedStart ? page : page.slice(-limit);
4358
4386
  // A page that cannot be SENT is not a page. The reply rides one NATS message, so `limit` alone is
4359
- // the wrong bound: 200 large messages serialize past `max_payload`, `m.respond` throws inside
4360
- // `serveControl`'s swallow, and the caller sees a bare request timeout it cannot tell apart from a
4361
- // dead daemon. Measured, not predicted: 200 x ~6 KB timed out at 5s with the message "timeout".
4387
+ // the wrong bound: 200 large messages serialize past `max_payload`, and `serveControl` can then only
4388
+ // answer with a refusal in place of the page.
4362
4389
  //
4363
4390
  // So bound by BYTES too, keeping the NEWEST that fit — which needs no new vocabulary, because
4364
4391
  // `complete: false` already means "older history remains behind this page". Trimming here is the
@@ -6041,28 +6068,29 @@ export class CotalEndpoint extends EventEmitter {
6041
6068
  stuck: this.presenceWriteEscalated,
6042
6069
  };
6043
6070
  }
6044
- /** Bind a presence watch on the current connection. Resolves true when the watch was
6045
- * installed, false when the endpoint stopped or rebuilt while the bind was in flight: that
6046
- * bind's iterator is released here and nothing is installed, because the epoch that asked
6047
- * for it is gone and the epoch that replaced it binds its own watch through
6071
+ /** Bind a presence watch on the current connection. Resolves the bound consumer's info when
6072
+ * the watch was installed, undefined when the endpoint stopped or rebuilt while the bind was
6073
+ * in flight: that bind's iterator is released here and nothing is installed, because the
6074
+ * epoch that asked for it is gone and the epoch that replaced it binds its own watch through
6048
6075
  * {@link connectAndBind}. Without this fence a bind that completes after {@link stop} would
6049
6076
  * resurrect a watch on a stopped endpoint, and one that completes after a rebuild would
6050
6077
  * overwrite the fresh epoch's watch with a dead-connection iterator. */
6051
6078
  async startPresenceWatch() {
6052
6079
  if (!this.kv)
6053
- return false;
6080
+ return undefined;
6054
6081
  const epoch = this.presenceEpoch;
6055
6082
  let hydrated;
6056
6083
  this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
6057
6084
  const iter = await this.kv.watch();
6058
- this.recordOwnWatchConsumer(await kvWatchConsumer(iter).info(true));
6085
+ const info = await kvWatchConsumer(iter).info(true);
6086
+ this.recordOwnWatchConsumer(info);
6059
6087
  if (epoch !== this.presenceEpoch) {
6060
6088
  try {
6061
6089
  iter.stop();
6062
6090
  }
6063
6091
  catch { /* its connection may already be gone */ }
6064
6092
  hydrated();
6065
- return false;
6093
+ return undefined;
6066
6094
  }
6067
6095
  this.presenceWatchIter = iter;
6068
6096
  void (async () => {
@@ -6083,7 +6111,7 @@ export class CotalEndpoint extends EventEmitter {
6083
6111
  }
6084
6112
  hydrated();
6085
6113
  })().catch((e) => this.emit("error", e));
6086
- return true;
6114
+ return info;
6087
6115
  }
6088
6116
  /**
6089
6117
  * Replace a presence watch that has gone silent past TTL while the connection is up. The new
@@ -6111,11 +6139,11 @@ export class CotalEndpoint extends EventEmitter {
6111
6139
  // one a held link never answers must leave the old watch in place: on a plain stall that
6112
6140
  // watch is the one that recovers by itself, and its replay is still guarded against
6113
6141
  // expired PUTs. Only a successfully bound watch retires its predecessor.
6114
- const installed = await this.startPresenceWatch();
6142
+ const info = await this.startPresenceWatch();
6115
6143
  // Retired mid-bind (stop or rebuild moved the epoch): the late iterator is already
6116
6144
  // released and the old watch was torn down by whoever moved the epoch. Nothing to
6117
6145
  // retire, nothing to report.
6118
- if (!installed)
6146
+ if (!info)
6119
6147
  return;
6120
6148
  if (old && old !== this.presenceWatchIter) {
6121
6149
  try {
@@ -6124,11 +6152,10 @@ export class CotalEndpoint extends EventEmitter {
6124
6152
  catch { /* already closed with its consumer */ }
6125
6153
  }
6126
6154
  // A bucket with no keys replays nothing, so the new watch cannot refresh
6127
- // `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. Read
6128
- // the consumer's initial pending count for that one fact; nats.js's KV watch computed it
6129
- // from the same `info(true)` it used to place the isUpdate marker.
6130
- const pending = this.presenceWatchIter?._data?._info?.num_pending;
6131
- if (pending === 0)
6155
+ // `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. The
6156
+ // bind's consumer info carries the initial pending count for that one fact; nats.js's KV
6157
+ // watch placed the isUpdate marker from the same `info(true)`.
6158
+ if (info.num_pending === 0)
6132
6159
  await this.onPresenceBucketEmpty();
6133
6160
  this.emit("warning", new Error(`presence watch silent for ${silentMs}ms with the connection up; rebound it from the bucket's current state`));
6134
6161
  }