@cotal-ai/core 0.85.0 → 0.87.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 (204) hide show
  1. package/dist/agent-file.d.ts.map +1 -1
  2. package/dist/agent-file.js +10 -3
  3. package/dist/agent-file.js.map +1 -1
  4. package/dist/base64url.d.ts +5 -0
  5. package/dist/base64url.d.ts.map +1 -0
  6. package/dist/base64url.js +14 -0
  7. package/dist/base64url.js.map +1 -0
  8. package/dist/browser-control.d.ts +149 -0
  9. package/dist/browser-control.d.ts.map +1 -0
  10. package/dist/browser-control.js +167 -0
  11. package/dist/browser-control.js.map +1 -0
  12. package/dist/browser-validators-build.d.ts +23 -0
  13. package/dist/browser-validators-build.d.ts.map +1 -0
  14. package/dist/browser-validators-build.js +149 -0
  15. package/dist/browser-validators-build.js.map +1 -0
  16. package/dist/canonical-json.d.ts +14 -0
  17. package/dist/canonical-json.d.ts.map +1 -0
  18. package/dist/canonical-json.js +121 -0
  19. package/dist/canonical-json.js.map +1 -0
  20. package/dist/canonical.d.ts +2 -10
  21. package/dist/canonical.d.ts.map +1 -1
  22. package/dist/canonical.js +2 -108
  23. package/dist/canonical.js.map +1 -1
  24. package/dist/channels.d.ts +4 -0
  25. package/dist/channels.d.ts.map +1 -1
  26. package/dist/channels.js +6 -2
  27. package/dist/channels.js.map +1 -1
  28. package/dist/checkpoint-answer.d.ts.map +1 -1
  29. package/dist/checkpoint-answer.js +3 -4
  30. package/dist/checkpoint-answer.js.map +1 -1
  31. package/dist/connector-setup.d.ts +10 -1
  32. package/dist/connector-setup.d.ts.map +1 -1
  33. package/dist/digest-id.d.ts +4 -0
  34. package/dist/digest-id.d.ts.map +1 -0
  35. package/dist/digest-id.js +7 -0
  36. package/dist/digest-id.js.map +1 -0
  37. package/dist/endpoint-action.d.ts +2 -2
  38. package/dist/endpoint-action.d.ts.map +1 -1
  39. package/dist/endpoint-action.js +6 -44
  40. package/dist/endpoint-action.js.map +1 -1
  41. package/dist/endpoint-auth-digest.d.ts +7 -0
  42. package/dist/endpoint-auth-digest.d.ts.map +1 -0
  43. package/dist/endpoint-auth-digest.js +13 -0
  44. package/dist/endpoint-auth-digest.js.map +1 -0
  45. package/dist/endpoint-call.d.ts +176 -0
  46. package/dist/endpoint-call.d.ts.map +1 -0
  47. package/dist/endpoint-call.js +490 -0
  48. package/dist/endpoint-call.js.map +1 -0
  49. package/dist/endpoint-caller.d.ts +113 -0
  50. package/dist/endpoint-caller.d.ts.map +1 -0
  51. package/dist/endpoint-caller.js +260 -0
  52. package/dist/endpoint-caller.js.map +1 -0
  53. package/dist/endpoint-checkpoint.d.ts.map +1 -1
  54. package/dist/endpoint-checkpoint.js +5 -14
  55. package/dist/endpoint-checkpoint.js.map +1 -1
  56. package/dist/endpoint-envelope.d.ts +0 -6
  57. package/dist/endpoint-envelope.d.ts.map +1 -1
  58. package/dist/endpoint-envelope.js +17 -18
  59. package/dist/endpoint-envelope.js.map +1 -1
  60. package/dist/endpoint-goal-follow.d.ts +37 -0
  61. package/dist/endpoint-goal-follow.d.ts.map +1 -0
  62. package/dist/endpoint-goal-follow.js +328 -0
  63. package/dist/endpoint-goal-follow.js.map +1 -0
  64. package/dist/endpoint-grants.d.ts +0 -4
  65. package/dist/endpoint-grants.d.ts.map +1 -1
  66. package/dist/endpoint-grants.js +1 -7
  67. package/dist/endpoint-grants.js.map +1 -1
  68. package/dist/endpoint-guard.d.ts.map +1 -1
  69. package/dist/endpoint-guard.js +2 -10
  70. package/dist/endpoint-guard.js.map +1 -1
  71. package/dist/endpoint-invoke.d.ts +37 -143
  72. package/dist/endpoint-invoke.d.ts.map +1 -1
  73. package/dist/endpoint-invoke.js +60 -605
  74. package/dist/endpoint-invoke.js.map +1 -1
  75. package/dist/endpoint-publish-denial.d.ts +1 -1
  76. package/dist/endpoint-publish-denial.d.ts.map +1 -1
  77. package/dist/endpoint-publish-denial.js +1 -1
  78. package/dist/endpoint-publish-denial.js.map +1 -1
  79. package/dist/endpoint-receipt.d.ts.map +1 -1
  80. package/dist/endpoint-receipt.js +2 -16
  81. package/dist/endpoint-receipt.js.map +1 -1
  82. package/dist/endpoint-signing.d.ts +1 -1
  83. package/dist/endpoint-signing.d.ts.map +1 -1
  84. package/dist/endpoint-signing.js +1 -1
  85. package/dist/endpoint-signing.js.map +1 -1
  86. package/dist/endpoint-subjects.d.ts +4 -0
  87. package/dist/endpoint-subjects.d.ts.map +1 -1
  88. package/dist/endpoint-subjects.js +6 -0
  89. package/dist/endpoint-subjects.js.map +1 -1
  90. package/dist/endpoint-traits.d.ts.map +1 -1
  91. package/dist/endpoint-traits.js +3 -20
  92. package/dist/endpoint-traits.js.map +1 -1
  93. package/dist/endpoint-verbs.d.ts +18 -151
  94. package/dist/endpoint-verbs.d.ts.map +1 -1
  95. package/dist/endpoint-verbs.js +4 -500
  96. package/dist/endpoint-verbs.js.map +1 -1
  97. package/dist/endpoint-work.d.ts.map +1 -1
  98. package/dist/endpoint-work.js +2 -17
  99. package/dist/endpoint-work.js.map +1 -1
  100. package/dist/endpoint.d.ts +113 -24
  101. package/dist/endpoint.d.ts.map +1 -1
  102. package/dist/endpoint.js +330 -78
  103. package/dist/endpoint.js.map +1 -1
  104. package/dist/goal-result-fact.d.ts +13 -0
  105. package/dist/goal-result-fact.d.ts.map +1 -0
  106. package/dist/goal-result-fact.js +40 -0
  107. package/dist/goal-result-fact.js.map +1 -0
  108. package/dist/index.d.ts +5 -0
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +5 -0
  111. package/dist/index.js.map +1 -1
  112. package/dist/issued-accepted.d.ts +27 -0
  113. package/dist/issued-accepted.d.ts.map +1 -0
  114. package/dist/issued-accepted.js +62 -0
  115. package/dist/issued-accepted.js.map +1 -0
  116. package/dist/issued-authority.d.ts +3 -18
  117. package/dist/issued-authority.d.ts.map +1 -1
  118. package/dist/issued-authority.js +4 -55
  119. package/dist/issued-authority.js.map +1 -1
  120. package/dist/jetstream-missing.d.ts +4 -0
  121. package/dist/jetstream-missing.d.ts.map +1 -1
  122. package/dist/jetstream-missing.js +6 -0
  123. package/dist/jetstream-missing.js.map +1 -1
  124. package/dist/kv-scan.d.ts +15 -0
  125. package/dist/kv-scan.d.ts.map +1 -1
  126. package/dist/kv-scan.js +32 -0
  127. package/dist/kv-scan.js.map +1 -1
  128. package/dist/launch-material.d.ts.map +1 -1
  129. package/dist/launch-material.js +6 -2
  130. package/dist/launch-material.js.map +1 -1
  131. package/dist/lease.d.ts +50 -0
  132. package/dist/lease.d.ts.map +1 -1
  133. package/dist/lease.js +71 -0
  134. package/dist/lease.js.map +1 -1
  135. package/dist/lifecycle-state.d.ts +3 -0
  136. package/dist/lifecycle-state.d.ts.map +1 -1
  137. package/dist/lifecycle-state.js +7 -0
  138. package/dist/lifecycle-state.js.map +1 -1
  139. package/dist/manager-caller.d.ts.map +1 -1
  140. package/dist/manager-caller.js +2 -1
  141. package/dist/manager-caller.js.map +1 -1
  142. package/dist/one-line.d.ts +6 -0
  143. package/dist/one-line.d.ts.map +1 -0
  144. package/dist/one-line.js +8 -0
  145. package/dist/one-line.js.map +1 -0
  146. package/dist/peer-label.d.ts +16 -0
  147. package/dist/peer-label.d.ts.map +1 -0
  148. package/dist/peer-label.js +15 -0
  149. package/dist/peer-label.js.map +1 -0
  150. package/dist/permission-denied.d.ts +8 -0
  151. package/dist/permission-denied.d.ts.map +1 -0
  152. package/dist/permission-denied.js +15 -0
  153. package/dist/permission-denied.js.map +1 -0
  154. package/dist/presence-text.d.ts +6 -0
  155. package/dist/presence-text.d.ts.map +1 -0
  156. package/dist/presence-text.js +25 -0
  157. package/dist/presence-text.js.map +1 -0
  158. package/dist/private-tool-results.d.ts +253 -0
  159. package/dist/private-tool-results.d.ts.map +1 -0
  160. package/dist/private-tool-results.js +141 -0
  161. package/dist/private-tool-results.js.map +1 -0
  162. package/dist/provision.d.ts +5 -0
  163. package/dist/provision.d.ts.map +1 -1
  164. package/dist/provision.js +40 -16
  165. package/dist/provision.js.map +1 -1
  166. package/dist/remote-manager-authority.d.ts +17 -0
  167. package/dist/remote-manager-authority.d.ts.map +1 -1
  168. package/dist/remote-manager-authority.js +40 -1
  169. package/dist/remote-manager-authority.js.map +1 -1
  170. package/dist/resolve.d.ts +2 -13
  171. package/dist/resolve.d.ts.map +1 -1
  172. package/dist/resolve.js +2 -12
  173. package/dist/resolve.js.map +1 -1
  174. package/dist/run-driver-grants.d.ts +5 -0
  175. package/dist/run-driver-grants.d.ts.map +1 -1
  176. package/dist/run-driver-grants.js +16 -4
  177. package/dist/run-driver-grants.js.map +1 -1
  178. package/dist/run-journal.d.ts.map +1 -1
  179. package/dist/run-journal.js +1 -6
  180. package/dist/run-journal.js.map +1 -1
  181. package/dist/run-migration.d.ts +21 -0
  182. package/dist/run-migration.d.ts.map +1 -1
  183. package/dist/run-migration.js +2 -27
  184. package/dist/run-migration.js.map +1 -1
  185. package/dist/run-notice.d.ts +20 -0
  186. package/dist/run-notice.d.ts.map +1 -1
  187. package/dist/run-notice.js +3 -28
  188. package/dist/run-notice.js.map +1 -1
  189. package/dist/schema-bounds.d.ts +24 -0
  190. package/dist/schema-bounds.d.ts.map +1 -0
  191. package/dist/schema-bounds.js +74 -0
  192. package/dist/schema-bounds.js.map +1 -0
  193. package/dist/schema-profile.d.ts +11 -24
  194. package/dist/schema-profile.d.ts.map +1 -1
  195. package/dist/schema-profile.js +16 -73
  196. package/dist/schema-profile.js.map +1 -1
  197. package/dist/subjects.d.ts +17 -0
  198. package/dist/subjects.d.ts.map +1 -1
  199. package/dist/subjects.js +30 -2
  200. package/dist/subjects.js.map +1 -1
  201. package/dist/turn-contract.d.ts.map +1 -1
  202. package/dist/turn-contract.js +14 -4
  203. package/dist/turn-contract.js.map +1 -1
  204. package/package.json +14 -1
package/dist/endpoint.js CHANGED
@@ -14,8 +14,8 @@ import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, replyTa
14
14
  import { isRepeatSafeCommand } from "./endpoint-grants.js";
15
15
  import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
16
16
  import { readAcceptedRow } from "./issued-authority.js";
17
- import { liveKvEntries } from "./kv-scan.js";
18
- import { isJetStreamMissing } from "./jetstream-missing.js";
17
+ import { lastKvEntries, liveKvEntries } from "./kv-scan.js";
18
+ import { isConsumerAlreadyExists, isConsumerNotFound, isJetStreamMissing } from "./jetstream-missing.js";
19
19
  import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
20
20
  import { assertValidName } from "./resolve.js";
21
21
  import { EVICT_PRINCIPALS_MAX } from "./evict.js";
@@ -28,7 +28,9 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
28
28
  import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
29
29
  import { openDeliveryRegistry } from "./lease.js";
30
30
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, decodeChannelEntry, } from "./channels.js";
31
- import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
31
+ import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, historyReaderConsumer, calloutReaderId, HISTORY_READERS, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
32
+ import { isPermissionDenied } from "./permission-denied.js";
33
+ export { isPermissionDenied };
32
34
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
33
35
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
34
36
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
@@ -53,6 +55,11 @@ export const MULTI_FILTER_BATCH = 1_000;
53
55
  * issuing one read per channel: a space large enough to need batches must not get the fan-out back
54
56
  * under another name. */
55
57
  export const MULTI_FILTER_READ_CONCURRENCY = 4;
58
+ /** A history reader's inactive threshold. It bounds only a reader whose delete never ran (its link
59
+ * dropped, or its process stopped), because every read deletes its readers when it ends. Five
60
+ * minutes is what the nats.js ordered consumers it replaces carried, and it keeps a reader alive
61
+ * across the gap between pulls on a slow link. */
62
+ const HISTORY_READER_INACTIVE_MS = 5 * 60_000;
56
63
  /** A presence bucket that has refused consecutive writes for at least one full presence TTL.
57
64
  * The condition is non-transient: ordinary heartbeat retry has already failed for the whole
58
65
  * liveness window, so callers should report the roster as last-known until a write succeeds. */
@@ -159,6 +166,7 @@ export class CotalEndpoint extends EventEmitter {
159
166
  doRegister;
160
167
  doWatch;
161
168
  doWatchChannels;
169
+ readOnce;
162
170
  doConsume;
163
171
  ackWaitMs;
164
172
  inactiveThresholdMs;
@@ -178,6 +186,14 @@ export class CotalEndpoint extends EventEmitter {
178
186
  * live iterator. See {@link watchDeliveryLease}. */
179
187
  leaseWatchIntent;
180
188
  leaseWatchIter;
189
+ /** Consumer deletes of retired lease watches still in flight. The stop handle cannot await its
190
+ * delete, so {@link stop} and a live-epoch rebuild await these before they close the connection
191
+ * that carries them (#3966). */
192
+ leaseWatchDeletes = new Set();
193
+ /** Lease-watch binds still awaiting the broker. One that lands after {@link stop} or a rebuild
194
+ * moved the epoch retires its watch and starts that delete itself, so the same two paths wait for
195
+ * these first. Each is bounded by the JetStream request timeout. */
196
+ leaseWatchBinds = new Set();
181
197
  /** Plane-3 durable-membership registry KV — lazily opened by the privileged delivery daemon (or a
182
198
  * short-lived provisioner). */
183
199
  membersKv;
@@ -252,9 +268,12 @@ export class CotalEndpoint extends EventEmitter {
252
268
  * EXPECTED async permission violation that joinChannel turns into a clean throw, so watchStatus
253
269
  * suppresses it rather than surfacing a spurious connection error. */
254
270
  confirmingChatSubs = new Set();
255
- /** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches,
256
- * running membership scans and running history reads (see {@link recordOwnWatchConsumer}). */
271
+ /** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches and
272
+ * running membership scans (see {@link recordOwnWatchConsumer}). */
257
273
  ownWatchDeletePrefixes = new Set();
274
+ /** Per stream, the history-reader names of this connection's current reader id and the reads
275
+ * waiting for one (see {@link acquireHistoryReader}). */
276
+ historyReaderSlots = new Map();
258
277
  /** True until the first successful connect completes its boot backfill — distinguishes first-connect
259
278
  * (backfill the boot channels' history) from a reconnect (reopen the core-subs, no re-backfill).
260
279
  * Persists across reconnect (NOT connection-scoped). Replaces the legacy chat-durable consumed-cursor
@@ -294,9 +313,10 @@ export class CotalEndpoint extends EventEmitter {
294
313
  presenceViewState = "unpopulated";
295
314
  /** A presence-watch rebind in flight (see {@link rebindStalePresenceWatch}); one at a time. */
296
315
  presenceRebind;
297
- /** Bumped by every connection-scoped teardown and by {@link stop}. A presence bind that was
298
- * awaiting the broker when the epoch moved belongs to a retired epoch: it releases the
299
- * iterator it got and installs nothing (see {@link startPresenceWatch}). */
316
+ /** Bumped by every connection-scoped teardown and by {@link stop}. A presence or lease-watch bind
317
+ * that was awaiting the broker when the epoch moved belongs to a retired epoch: it releases the
318
+ * iterator it got and installs nothing (see {@link startPresenceWatch} and
319
+ * {@link bindDeliveryLeaseWatch}). */
300
320
  presenceEpoch = 0;
301
321
  /** The current watch was bound onto a bucket with NO keys (see {@link markPresenceBucketEmpty}).
302
322
  * Such a watch cannot deliver until someone writes, so its silence is not staleness. */
@@ -536,6 +556,9 @@ export class CotalEndpoint extends EventEmitter {
536
556
  this.doWatch = opts.watchPresence ?? true;
537
557
  this.doWatchChannels = opts.watchChannels ?? true;
538
558
  this.doConsume = opts.consume ?? true;
559
+ this.readOnce = opts.readOnce ?? false;
560
+ if (this.readOnce && (this.doConsume || this.doRegister))
561
+ throw new Error("EndpointOptions.readOnce requires consume and registerPresence false: a one-time read cannot keep a consumer's channel cache or its own presence current");
539
562
  // Seed the presence mirror so file-default channel modes are visible from the first publish
540
563
  // (not only after the first runtime toggle). Mirror only — delivery reads the connector's state.
541
564
  this.channelModes = opts.channelModes && Object.keys(opts.channelModes).length ? opts.channelModes : undefined;
@@ -1071,7 +1094,9 @@ export class CotalEndpoint extends EventEmitter {
1071
1094
  ? await kvm.open(presenceBucket(this.space))
1072
1095
  : await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs, storage: PRESENCE_STORAGE });
1073
1096
  }
1074
- if (this.doWatch) {
1097
+ if (this.doWatch && this.readOnce)
1098
+ await this.readPresenceOnce();
1099
+ else if (this.doWatch) {
1075
1100
  await this.startPresenceWatch();
1076
1101
  this.sweepTimer = setInterval(() => this.sweep(), Math.max(500, Math.floor(this.ttlMs / 3)));
1077
1102
  }
@@ -1084,12 +1109,12 @@ export class CotalEndpoint extends EventEmitter {
1084
1109
  if (watchChannels || this.doConsume) {
1085
1110
  this.channelKv = await openChannelRegistry(this.nc, this.space, { create: !this.authed });
1086
1111
  if (watchChannels)
1087
- await this.startChannelWatch();
1112
+ await (this.readOnce ? this.readChannelsOnce() : this.startChannelWatch());
1088
1113
  }
1089
1114
  // Rebind the daemon's lease-loss watch onto the fresh connection (#1596). The intent survived
1090
- // clearConnectionScoped; a bind that completes after a stop or another rebuild found no intent
1091
- // and released itself. The replay re-delivers the row's current state, so a change that landed
1092
- // in the null window still reaches the trigger.
1115
+ // clearConnectionScoped; a bind that completes after a stop or another rebuild finds the epoch
1116
+ // moved and retires itself. The replay re-delivers the row's current state, so a change that
1117
+ // landed in the null window still reaches the trigger.
1093
1118
  if (this.leaseWatchIntent)
1094
1119
  await this.bindDeliveryLeaseWatch();
1095
1120
  // FAIL BEFORE PRESENCE (SPEC 13.1): an AUTHED endpoint that will register on the roster or
@@ -1432,7 +1457,7 @@ export class CotalEndpoint extends EventEmitter {
1432
1457
  // Manual reconnect still has a live old epoch: complete broker-consumer cleanup before close.
1433
1458
  // Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup.
1434
1459
  if (oldNc && !oldNc.isClosed())
1435
- await Promise.all([...this.membershipFeedWatches].map((watch) => watch.arm));
1460
+ await Promise.all([...[...this.membershipFeedWatches].map((watch) => watch.arm), this.settleDeliveryLeaseWatches()]);
1436
1461
  this.nc = undefined;
1437
1462
  this.js = undefined;
1438
1463
  this.jsm = undefined;
@@ -1629,6 +1654,7 @@ export class CotalEndpoint extends EventEmitter {
1629
1654
  catch {
1630
1655
  /* best-effort graceful leave */
1631
1656
  }
1657
+ await this.settleDeliveryLeaseWatches();
1632
1658
  await this.closeWithoutLibraryReconnect(this.nc);
1633
1659
  // #1356: stop() does its own teardown and never runs clearConnectionScoped, so clear here too —
1634
1660
  // AFTER the best-effort offline publish above, which would otherwise re-record a refusal on its
@@ -2397,6 +2423,18 @@ export class CotalEndpoint extends EventEmitter {
2397
2423
  this.card.meta = meta;
2398
2424
  await this.publishPresence();
2399
2425
  }
2426
+ /** Overlay the session's working directory onto the card's display-only `meta.cwd`, then
2427
+ * republish presence. For connectors that learn the directory from the harness after launch (e.g.
2428
+ * Claude Code's `SessionStart` hook) rather than from their own process. Advisory display data,
2429
+ * never a routing or access input. The pathname is kept byte for byte: a directory name may hold
2430
+ * or end in whitespace, so it is never trimmed or normalized. An empty string names no directory
2431
+ * and leaves the card unchanged. */
2432
+ async setCardCwd(cwd) {
2433
+ if (!cwd || this.card.meta?.cwd === cwd)
2434
+ return;
2435
+ this.card.meta = { ...(this.card.meta ?? {}), cwd };
2436
+ await this.publishPresence();
2437
+ }
2400
2438
  // ---- channel discovery ---------------------------------------------------
2401
2439
  /** This channel's registry config from the live local cache (undefined if unset). */
2402
2440
  getChannelConfig(channel) {
@@ -2677,8 +2715,6 @@ export class CotalEndpoint extends EventEmitter {
2677
2715
  watch.arm = watch.arm.catch(() => { }).then(async () => {
2678
2716
  await this.disarmMembershipWatch(watch);
2679
2717
  await this.deleteRetainedMembershipConsumer(watch);
2680
- if (watch.consumerStream || watch.consumerName)
2681
- return;
2682
2718
  this.finishMembershipWatchStop(watch);
2683
2719
  }).catch((err) => {
2684
2720
  // A real authorization/server failure remains loud. A terminal close is retained rather than
@@ -2752,16 +2788,16 @@ export class CotalEndpoint extends EventEmitter {
2752
2788
  watch.resolveStop = undefined;
2753
2789
  watch.rejectStop = undefined;
2754
2790
  }
2755
- /** Record the consumer behind one of this connection's own KV watches, membership scans or history
2756
- * reads, and return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
2791
+ /** Record the consumer behind one of this connection's own KV watches or membership scans, and
2792
+ * return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
2757
2793
  * rebuilding it after a stall or a sequence gap, deletes the predecessor itself with no hook before
2758
2794
  * the send, so a profile without the delete row (#691) has that delete refused. A stalled link can
2759
2795
  * time that delete out before its refusal arrives, and nats.js then hands the refusal to no request,
2760
2796
  * so {@link trackRequestDenials} cannot own it and the consumer's name has to. A watch's prefix
2761
2797
  * stays recorded for the connection because a retired watch's last rebuild can be refused after
2762
- * the watch has stopped; {@link readMembership} and {@link drainWindow} retire theirs after their own
2763
- * last delete, which goes out once nats.js has stopped the consumer and so is answered after every
2764
- * predecessor delete. */
2798
+ * the watch has stopped; {@link readMembership} retires its scan's after its own last delete,
2799
+ * which goes out once nats.js has stopped the consumer and so is answered after every predecessor
2800
+ * delete. */
2765
2801
  recordOwnWatchConsumer({ stream_name, name }) {
2766
2802
  const prefix = `$JS.API.CONSUMER.DELETE.${stream_name}.${name.slice(0, name.lastIndexOf("_") + 1)}`;
2767
2803
  this.ownWatchDeletePrefixes.add(prefix);
@@ -2772,10 +2808,10 @@ export class CotalEndpoint extends EventEmitter {
2772
2808
  const cut = subject.lastIndexOf("_") + 1;
2773
2809
  return /^\d+$/.test(subject.slice(cut)) && this.ownWatchDeletePrefixes.has(subject.slice(0, cut));
2774
2810
  }
2775
- /** Delete a history reader's own ephemeral consumer. A refused delete is the designed outcome for
2776
- * the elevated profile, which holds no stream-wide CONSUMER.DELETE (#691): the broker reaps the
2811
+ /** Delete one of this connection's own KV-watch consumers. A refused delete is the designed
2812
+ * outcome for a profile without a stream-wide CONSUMER.DELETE (#691): the broker reaps the
2777
2813
  * consumer at its inactive threshold. */
2778
- async deleteReaderConsumer(consumer) {
2814
+ async deleteOwnConsumer(consumer) {
2779
2815
  try {
2780
2816
  await consumer.delete();
2781
2817
  }
@@ -3068,8 +3104,10 @@ export class CotalEndpoint extends EventEmitter {
3068
3104
  throw new Error(`history limit must be a whole number of messages this server can count exactly, received ${limit}`);
3069
3105
  if (limit <= 0)
3070
3106
  return [];
3071
- const js = jetstream(this.nc);
3107
+ const nc = this.nc;
3108
+ let reader;
3072
3109
  try {
3110
+ reader = await this.acquireHistoryReader(nc, this.jsm ?? (this.jsm = await jetstreamManager(nc)), stream, signal);
3073
3111
  // THE EXACT CEILING, on the already-granted surface. A one-shot consumer with
3074
3112
  // `DeliverPolicy.Last` plus this subject's filter reports the newest MATCHING sequence, and its
3075
3113
  // `num_pending` of zero means the channel is genuinely empty. Using the STREAM's last sequence
@@ -3080,7 +3118,7 @@ export class CotalEndpoint extends EventEmitter {
3080
3118
  // Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
3081
3119
  // needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
3082
3120
  // shipped once from this function and turned every non-admin history read into an empty list.
3083
- const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subjects, signal);
3121
+ const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(reader, subjects, signal);
3084
3122
  if (ceiling < 1)
3085
3123
  return [];
3086
3124
  // Widen from the exact ceiling until a window holds a full page, or until the window IS the
@@ -3122,11 +3160,11 @@ export class CotalEndpoint extends EventEmitter {
3122
3160
  for (;;) {
3123
3161
  signal?.throwIfAborted();
3124
3162
  const start = Math.max(floor, ceiling - span + 1);
3125
- const page = await this.drainWindow(js, stream, subjects, start, ceiling, limit, signal);
3163
+ const page = await this.drainWindow(reader, subjects, start, ceiling, limit, signal);
3126
3164
  if (page.length >= limit)
3127
3165
  return page.slice(-limit);
3128
3166
  if (!floorKnown) {
3129
- floor = Math.max(1, await this.firstMatchingSeq(js, stream, subjects, signal));
3167
+ floor = Math.max(1, await this.firstMatchingSeq(reader, subjects, signal));
3130
3168
  floorKnown = true;
3131
3169
  }
3132
3170
  if (start <= floor)
@@ -3156,13 +3194,175 @@ export class CotalEndpoint extends EventEmitter {
3156
3194
  return [];
3157
3195
  throw e;
3158
3196
  }
3197
+ finally {
3198
+ if (reader)
3199
+ this.releaseHistoryReader(reader);
3200
+ }
3201
+ }
3202
+ /** This connection's history-reader id, the key of its credential's reader deletes
3203
+ * ({@link historyReaderConsumer}). A static credential's is its connId, the nkey it signs with.
3204
+ * Under the auth callout the connId is the inbox nonce this client chose, so the callout keys the
3205
+ * deletes on {@link calloutReaderId} instead: the server's id and the client id it assigned this
3206
+ * connection, which the client reads from the server's INFO and does not choose. */
3207
+ historyReaderId(nc) {
3208
+ if (!this.userMode)
3209
+ return this.connId;
3210
+ const info = nc.info;
3211
+ if (!info)
3212
+ throw new Error("history: the server has not sent its INFO, so this connection has no history reader id yet");
3213
+ return calloutReaderId(info.server_id, info.client_id);
3214
+ }
3215
+ /** Take one of this connection's history-reader names on `stream` ({@link historyReaderConsumer})
3216
+ * for one read. The read holds the name for its whole walk and creates and deletes a consumer
3217
+ * under it at each step, so this connection keeps at most {@link HISTORY_READERS} reader consumers
3218
+ * on a stream (#4123). A read past that many waits for a name, and `signal` ends the wait. A
3219
+ * reconnect under the auth callout changes the reader id, and reads from then on take the new
3220
+ * id's names. */
3221
+ async acquireHistoryReader(nc, jsm, stream, signal) {
3222
+ for (;;) {
3223
+ signal?.throwIfAborted();
3224
+ const readerId = this.historyReaderId(nc);
3225
+ let pool = this.historyReaderSlots.get(stream);
3226
+ if (pool?.readerId !== readerId) {
3227
+ pool = { readerId, free: Array.from({ length: HISTORY_READERS }, (_, i) => HISTORY_READERS - 1 - i), held: 0, waiting: [], refusedUntil: 0 };
3228
+ this.historyReaderSlots.set(stream, pool);
3229
+ }
3230
+ // A read here would leave one more reader it may not delete, so it fails before creating one.
3231
+ if (pool.refusedUntil > Date.now())
3232
+ throw this.readerDeleteRefused(stream);
3233
+ const slot = pool.free.pop();
3234
+ if (slot !== undefined) {
3235
+ pool.held++;
3236
+ return { nc, js: jetstream(nc), jsm, stream, pool, slot, name: historyReaderConsumer(readerId, slot), held: true, left: false };
3237
+ }
3238
+ // No read here holds a name, so none will come back from one: every name is set aside (see
3239
+ // openHistoryReader and closeHistoryReader). Waiting would hang until those expire.
3240
+ if (pool.held === 0)
3241
+ throw new Error(`history: every history reader name this connection has on ${stream} is set aside, held by a consumer it did not create (another process using the same credential, or one that stopped mid-read) or by a reader it could not confirm deleted; they expire ${HISTORY_READER_INACTIVE_MS / 60_000} minutes after their last use`);
3242
+ const waitOn = pool;
3243
+ await new Promise((resolve, reject) => {
3244
+ const wake = () => { signal?.removeEventListener("abort", stop); resolve(); };
3245
+ const stop = () => { waitOn.waiting.splice(waitOn.waiting.indexOf(wake), 1); reject(abortReason(signal)); };
3246
+ waitOn.waiting.push(wake);
3247
+ signal?.addEventListener("abort", stop, { once: true });
3248
+ });
3249
+ }
3250
+ }
3251
+ /** Give a read's history-reader name back and wake one read waiting for a name. A name whose
3252
+ * delete failed is set aside instead. */
3253
+ releaseHistoryReader(reader) {
3254
+ if (!reader.held)
3255
+ return;
3256
+ reader.held = false;
3257
+ const pool = reader.pool;
3258
+ pool.held--;
3259
+ if (!reader.left) {
3260
+ pool.free.push(reader.slot);
3261
+ pool.waiting.shift()?.();
3262
+ return;
3263
+ }
3264
+ this.setAsideHistoryReader(pool, reader.slot);
3265
+ // Every waiter checks again, so one that no running read can now serve fails instead of hanging.
3266
+ if (pool.held === 0)
3267
+ for (const wake of pool.waiting.splice(0))
3268
+ wake();
3269
+ }
3270
+ /** Keep `slot` out of use until a consumer under it would have expired, then free it. */
3271
+ setAsideHistoryReader(pool, slot) {
3272
+ setTimeout(() => { pool.free.push(slot); pool.waiting.shift()?.(); }, HISTORY_READER_INACTIVE_MS).unref();
3273
+ }
3274
+ /** Create the reader consumer for one step of a read, under the read's name. It is a plain pull
3275
+ * consumer: an nats.js ordered consumer renames itself on every rebuild, and the observer and admin
3276
+ * credentials may delete only these exact names.
3277
+ *
3278
+ * Every create carries a fresh description. NATS answers a create whose config equals the existing
3279
+ * consumer's with that consumer, delivery state and all (measured on nats-server 2.15), and a read
3280
+ * repeats the same config on the same name. Without the description a reader left under the name
3281
+ * would come back as if new: a `DeliverPolicy.Last` reader that already delivered its message
3282
+ * reports nothing pending, which reads as an empty channel. With it, a create on a name in use is
3283
+ * refused, and a consumer that does not carry this step's description is never used.
3284
+ *
3285
+ * A consumer already under the name is never deleted, whoever left it: another holder of this
3286
+ * credential, a process that stopped mid-read, or this endpoint when a delete's answer was lost and
3287
+ * the name was taken again since. The read moves to a free name, and the taken one stays out of use
3288
+ * until such a consumer would have expired. */
3289
+ async openHistoryReader(reader, config) {
3290
+ const pool = reader.pool;
3291
+ for (;;) {
3292
+ // A reconnect under the auth callout moved this connection to new reader names, and its
3293
+ // credential may no longer delete this one. Creating under it would leave the reader behind.
3294
+ if (this.historyReaderId(reader.nc) !== pool.readerId)
3295
+ throw new Error(`history: the connection was re-established during the read on ${reader.stream}, so its history reader names changed; the read is not served`);
3296
+ const description = `cotal history read ${randomUUID()}`;
3297
+ let info;
3298
+ try {
3299
+ info = await reader.jsm.consumers.add(reader.stream, {
3300
+ ...config,
3301
+ name: reader.name,
3302
+ description,
3303
+ ack_policy: AckPolicy.None,
3304
+ max_deliver: 1,
3305
+ mem_storage: true,
3306
+ num_replicas: 1,
3307
+ inactive_threshold: nanos(HISTORY_READER_INACTIVE_MS),
3308
+ });
3309
+ }
3310
+ catch (e) {
3311
+ if (!isConsumerAlreadyExists(e))
3312
+ throw e;
3313
+ }
3314
+ if (info?.config.description === description)
3315
+ return reader.js.consumers.getConsumerFromInfo(info);
3316
+ this.setAsideHistoryReader(pool, reader.slot);
3317
+ const next = pool.free.pop();
3318
+ if (next === undefined) {
3319
+ // The read ends holding no name. Every waiter checks again, so one that no running read can
3320
+ // now serve fails instead of hanging.
3321
+ reader.held = false;
3322
+ pool.held--;
3323
+ for (const wake of pool.waiting.splice(0))
3324
+ wake();
3325
+ throw new Error(`history: reader ${reader.name} on ${reader.stream} is held by a consumer this read did not create, and no other reader name is free`);
3326
+ }
3327
+ reader.slot = next;
3328
+ reader.name = historyReaderConsumer(pool.readerId, next);
3329
+ }
3330
+ }
3331
+ /** Delete the read's reader consumer; already gone is the same outcome. A failed delete may still
3332
+ * have run with its answer lost, and the name may be taken again before this endpoint could tell,
3333
+ * so the name is set aside when the read gives it back rather than deleted again later. A refused
3334
+ * delete means the credential was minted before the observer and admin profiles held their reader
3335
+ * deletes. Each read would then leave a reader for the inactive threshold, which is how a polling
3336
+ * dashboard filled the stream's consumer limit (#4123), so the read fails and says to remint
3337
+ * rather than leak quietly, and reads on the stream fail at once until that reader expires. */
3338
+ async closeHistoryReader(reader) {
3339
+ try {
3340
+ await reader.jsm.consumers.delete(reader.stream, reader.name);
3341
+ }
3342
+ catch (e) {
3343
+ if (isConsumerNotFound(e))
3344
+ return;
3345
+ reader.left = true;
3346
+ // No `cause`: a permission-shaped cause would let streamHistory read this as the admin-only
3347
+ // DM denial and return an empty page.
3348
+ if (isPublishPermissionDenied(e)) {
3349
+ if (this.historyReaderId(reader.nc) !== reader.pool.readerId)
3350
+ throw new Error(`history: the connection was re-established during the read on ${reader.stream}, so it may no longer delete its reader ${reader.name}, which expires on its own; the read is not served`);
3351
+ reader.pool.refusedUntil = Date.now() + HISTORY_READER_INACTIVE_MS;
3352
+ throw this.readerDeleteRefused(reader.stream);
3353
+ }
3354
+ throw e;
3355
+ }
3356
+ }
3357
+ readerDeleteRefused(stream) {
3358
+ return new Error(`history: this credential may not delete its own history readers on ${stream}; it predates the observer and admin reader-delete grant, so remint it`);
3159
3359
  }
3160
3360
  /** The oldest stream sequence matching `subject`, or 0 when the subject has no messages.
3161
3361
  * Mirror of {@link lastMatchingSeq}: same CREATE/INFO/NEXT/DELETE surface, `DeliverPolicy.All`
3162
3362
  * instead of `Last`, first delivered seq instead of last. Read credentials already hold this. */
3163
- async firstMatchingSeq(js, stream, subjects, signal) {
3363
+ async firstMatchingSeq(reader, subjects, signal) {
3164
3364
  signal?.throwIfAborted();
3165
- const consumer = await js.consumers.get(stream, {
3365
+ const consumer = await this.openHistoryReader(reader, {
3166
3366
  filter_subjects: subjects,
3167
3367
  deliver_policy: DeliverPolicy.All,
3168
3368
  });
@@ -3185,18 +3385,18 @@ export class CotalEndpoint extends EventEmitter {
3185
3385
  throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
3186
3386
  }
3187
3387
  finally {
3188
- await this.deleteReaderConsumer(consumer);
3388
+ await this.closeHistoryReader(reader);
3189
3389
  }
3190
3390
  }
3191
3391
  /** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
3192
3392
  *
3193
- * One ordered consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
3393
+ * One reader consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
3194
3394
  * (available from the create, before anything is delivered) is 0 for an empty subject, and
3195
3395
  * otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
3196
3396
  * already uses, so no broker authority is added. */
3197
- async lastMatchingSeq(js, stream, subjects, signal) {
3397
+ async lastMatchingSeq(reader, subjects, signal) {
3198
3398
  signal?.throwIfAborted();
3199
- const consumer = await js.consumers.get(stream, {
3399
+ const consumer = await this.openHistoryReader(reader, {
3200
3400
  filter_subjects: subjects,
3201
3401
  deliver_policy: DeliverPolicy.Last,
3202
3402
  });
@@ -3225,19 +3425,21 @@ export class CotalEndpoint extends EventEmitter {
3225
3425
  throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
3226
3426
  }
3227
3427
  finally {
3228
- await this.deleteReaderConsumer(consumer);
3428
+ await this.closeHistoryReader(reader);
3229
3429
  }
3230
3430
  }
3231
3431
  /** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
3232
- * One ephemeral ordered consumer, one batched pull — `AckPolicy.None`, so no per-message ack
3432
+ * One reader consumer, one batched pull — `AckPolicy.None`, so no per-message ack
3233
3433
  * round trip. Fetches exactly the pending count so it returns as soon as the window is
3234
3434
  * delivered rather than blocking for the pull's full expiry. */
3235
- async drainWindow(js, stream, subjects, start, ceiling, limit, signal) {
3435
+ async drainWindow(reader, subjects, start, ceiling, limit, signal) {
3236
3436
  signal?.throwIfAborted();
3237
3437
  const out = [];
3238
- const consumer = await js.consumers.get(stream, { filter_subjects: subjects, opt_start_seq: start });
3239
- // A delivery-sequence gap makes nats.js rebuild this consumer and delete the predecessor itself.
3240
- const readerPrefix = this.recordOwnWatchConsumer(await consumer.info(true));
3438
+ const consumer = await this.openHistoryReader(reader, {
3439
+ filter_subjects: subjects,
3440
+ deliver_policy: DeliverPolicy.StartSequence,
3441
+ opt_start_seq: start,
3442
+ });
3241
3443
  try {
3242
3444
  // A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
3243
3445
  // explicit uncached `info()` this used to call was a round trip for data we already had.
@@ -3263,6 +3465,10 @@ export class CotalEndpoint extends EventEmitter {
3263
3465
  signal?.throwIfAborted();
3264
3466
  for await (const m of iter) {
3265
3467
  delivered++;
3468
+ // A plain consumer does not rebuild itself the way an ordered one did, so a gap in its
3469
+ // delivery sequence is a message this page would silently lack.
3470
+ if (m.info.deliverySequence !== delivered)
3471
+ throw new Error(`history: delivery ${m.info.deliverySequence} arrived where ${delivered} was due on ${subjectLabel(subjects)} - the window has a gap, so the page is not served`);
3266
3472
  if (m.seq >= ceiling) { // reached the page's upper bound
3267
3473
  if (m.seq === ceiling) {
3268
3474
  const msg = historyMessageFromDelivery(m);
@@ -3293,16 +3499,12 @@ export class CotalEndpoint extends EventEmitter {
3293
3499
  return out;
3294
3500
  }
3295
3501
  finally {
3296
- // DELETE THE EPHEMERAL CONSUMER. The pinned client gives an ordered consumer a 5-minute
3297
- // inactive threshold, so leaving them behind is not free: the widening search below can make
3298
- // up to eight per call, and a reload repeats every call the page makes. The dashboard used to
3299
- // make one per channel; since #1210 its activity feed makes one for all of chat and one for
3300
- // DMs, and the single-channel routes still make one each.
3301
- // Left alone that accumulates consumers on the broker until the thresholds expire, and the
3302
- // resulting resource exhaustion would land in streamHistory's catch and read as empty history.
3303
- // The observer and admin profiles may not delete (#691), so the dashboard's readers do live out
3304
- // that threshold; only a profile holding the delete row reclaims them here.
3305
- await this.deleteReaderConsumer(consumer).finally(() => { this.ownWatchDeletePrefixes.delete(readerPrefix); });
3502
+ // DELETE THE READER. Left alone it would stay for its inactive threshold, and the widening
3503
+ // search can run several steps per call while a polling dashboard repeats every call on each
3504
+ // poll. The observer and admin profiles used to hold no delete at all (#691), so a dashboard
3505
+ // polling every ten seconds filled the stream's consumer limit and every later read failed
3506
+ // (#4123). Each reader now goes when its step ends.
3507
+ await this.closeHistoryReader(reader);
3306
3508
  }
3307
3509
  }
3308
3510
  // ---- internals -----------------------------------------------------------
@@ -3700,20 +3902,49 @@ export class CotalEndpoint extends EventEmitter {
3700
3902
  };
3701
3903
  }
3702
3904
  stopDeliveryLeaseWatch() {
3905
+ if (this.leaseWatchIter)
3906
+ this.retireDeliveryLeaseWatch(this.leaseWatchIter);
3907
+ this.leaseWatchIter = undefined;
3908
+ }
3909
+ /** Stop a lease watch and delete its ordered consumer, which the stop alone leaves on the broker
3910
+ * until its five-minute inactive threshold (#3966). A connection that closed on its own cannot
3911
+ * carry the delete, so that consumer is left to the threshold. A failed delete is a warning: the
3912
+ * broker still reaps the consumer, and the daemon's synchronous stop handle has no caller to
3913
+ * reject. */
3914
+ retireDeliveryLeaseWatch(iter) {
3703
3915
  try {
3704
- this.leaseWatchIter?.stop();
3916
+ iter.stop();
3705
3917
  }
3706
3918
  catch { /* already closed with its connection */ }
3707
- this.leaseWatchIter = undefined;
3708
- }
3709
- async bindDeliveryLeaseWatch() {
3919
+ if (!this.nc || this.nc.isClosed())
3920
+ return;
3921
+ const deleting = this.deleteOwnConsumer(kvWatchConsumer(iter)).catch((e) => this.emitRecoverable(new Error(`a retired delivery lease watch consumer was not deleted (${e.message}); the broker reaps it at its inactive threshold`)));
3922
+ this.leaseWatchDeletes.add(deleting);
3923
+ void deleting.then(() => this.leaseWatchDeletes.delete(deleting));
3924
+ }
3925
+ /** Wait for the lease-watch binds still in flight, then for every retired watch's consumer delete.
3926
+ * {@link stop} and a live-epoch rebuild run this before they close the connection those deletes
3927
+ * need (#3966). */
3928
+ async settleDeliveryLeaseWatches() {
3929
+ await Promise.allSettled(this.leaseWatchBinds);
3930
+ await Promise.all(this.leaseWatchDeletes);
3931
+ }
3932
+ /** Bind the lease watch on the current connection. A bind that lands after a newer watch replaced
3933
+ * its intent, or after a stop or connection teardown moved the epoch, retires its watch instead of
3934
+ * installing it: the epoch it was bound for is gone, and a rebuilt one binds its own. */
3935
+ bindDeliveryLeaseWatch() {
3936
+ const binding = this.bindDeliveryLeaseWatchOnce();
3937
+ const settled = () => { this.leaseWatchBinds.delete(binding); };
3938
+ this.leaseWatchBinds.add(binding);
3939
+ void binding.then(settled, settled);
3940
+ return binding;
3941
+ }
3942
+ async bindDeliveryLeaseWatchOnce() {
3710
3943
  const intent = this.leaseWatchIntent;
3944
+ const epoch = this.presenceEpoch;
3711
3945
  const iter = await (await this.deliveryRegistry()).watch({ key: leaseKey(intent.shardIndex) });
3712
- if (this.leaseWatchIntent !== intent) {
3713
- try {
3714
- iter.stop();
3715
- }
3716
- catch { /* already closed */ }
3946
+ if (this.leaseWatchIntent !== intent || epoch !== this.presenceEpoch) {
3947
+ this.retireDeliveryLeaseWatch(iter);
3717
3948
  return;
3718
3949
  }
3719
3950
  this.leaseWatchIter = iter;
@@ -3894,12 +4125,15 @@ export class CotalEndpoint extends EventEmitter {
3894
4125
  * `undefined` means the key IS NOT THERE — a definite absence, established by a completed read.
3895
4126
  * A read that could not be completed THROWS instead, so a caller can tell "it is gone" from "I could
3896
4127
  * not find out". That distinction is the whole point of the method: a renew that got no answer has
3897
- * proved nothing, and only a definite answer here may be acted on. */
4128
+ * proved nothing, and only a definite answer here may be acted on.
4129
+ *
4130
+ * `created` is the broker's write time of the row (epoch ms), which is what the bucket TTL counts
4131
+ * from. */
3898
4132
  async readOwnManagerLease(instanceId) {
3899
4133
  const e = await (await this.managerLeaseRegistry()).get(managerLeaseKey(instanceId));
3900
4134
  if (!e || e.operation !== "PUT")
3901
4135
  return undefined;
3902
- return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision };
4136
+ return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision, created: e.created.getTime() };
3903
4137
  }
3904
4138
  /** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
3905
4139
  * by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
@@ -6145,6 +6379,20 @@ export class CotalEndpoint extends EventEmitter {
6145
6379
  })().catch((e) => this.emit("error", e));
6146
6380
  return info;
6147
6381
  }
6382
+ /** {@link EndpointOptions.readOnce}: fill the roster from one read of the presence bucket. Stale
6383
+ * and offline records resolve as they do in a watch's initial replay. */
6384
+ async readPresenceOnce() {
6385
+ if (!this.kv)
6386
+ return;
6387
+ for (const e of await lastKvEntries(this.kv))
6388
+ this.handleKvEntry(e);
6389
+ this.presenceSnapshotPopulated = true;
6390
+ // No record reached the roster (an empty bucket, or only the TTL canary): nobody is present.
6391
+ if (this.lastPresenceWatchAt === 0)
6392
+ this.markPresenceBucketEmpty();
6393
+ else
6394
+ this.emitPresenceViewIfChanged();
6395
+ }
6148
6396
  /**
6149
6397
  * Replace a presence watch that has gone silent past TTL while the connection is up. The new
6150
6398
  * ordered consumer starts from the bucket's current last-per-subject state, so a peer that is
@@ -6219,6 +6467,13 @@ export class CotalEndpoint extends EventEmitter {
6219
6467
  this.handleChannelEntry(e);
6220
6468
  })().catch((e) => this.emit("error", e));
6221
6469
  }
6470
+ /** {@link EndpointOptions.readOnce}: fill the channel cache from one read of the registry. */
6471
+ async readChannelsOnce() {
6472
+ if (!this.channelKv)
6473
+ return;
6474
+ for (const e of await lastKvEntries(this.channelKv))
6475
+ this.handleChannelEntry(e);
6476
+ }
6222
6477
  handleChannelEntry(e) {
6223
6478
  // Cleared before the decode, so an entry that does not decode resolves to the default as the
6224
6479
  // fresh registry reads do.
@@ -6311,7 +6566,7 @@ export class CotalEndpoint extends EventEmitter {
6311
6566
  }
6312
6567
  if (!isUsablePresence(p)) {
6313
6568
  this.presenceBindingDrops++;
6314
- this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: not a record with a string card.name, a known status and a finite ts`));
6569
+ this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: not a record with a string card.name, a known status, a finite ts, string display fields and a condition with a string code`));
6315
6570
  return;
6316
6571
  }
6317
6572
  this.applyPresence(e.key, p);
@@ -6738,12 +6993,22 @@ const PRESENCE_STATUSES = { idle: true, waiting: true, working: true, offline: t
6738
6993
  * such as `"nope"` makes the liveness arithmetic NaN, and a JSON `1e400` parses as `Infinity`;
6739
6994
  * neither age is ever greater than the TTL, so the row would stay live after its key expired
6740
6995
  * (#2612). `card.name` is the roster sort key: a non-string throws inside the watch loop and
6741
- * ends the watch. `status` decides offline and is what roster consumers read. `card.id` is left
6742
- * to the KV-key check, which rejects anything but the key string. */
6996
+ * ends the watch. `status` decides offline and is what roster consumers read. `card.role`,
6997
+ * `activity`, `attention` and `condition.code` are printed as text, and the peer writes its own
6998
+ * row: an object such as `{"toString":null,"valueOf":null}` throws on conversion and would fail
6999
+ * every reader's roster. A present `condition` must be an object with a string `code`, which SPEC
7000
+ * §6 makes required: `null`, a bare string or an object with no code is dropped too. `card.id` is
7001
+ * left to the KV-key check, which rejects anything but the key string. */
6743
7002
  function isUsablePresence(value) {
6744
7003
  return isRecord(value) && isRecord(value.card) && typeof value.card.name === "string" &&
7004
+ isOptionalString(value.card.role) &&
6745
7005
  typeof value.status === "string" && Object.hasOwn(PRESENCE_STATUSES, value.status) &&
6746
- typeof value.ts === "number" && Number.isFinite(value.ts);
7006
+ typeof value.ts === "number" && Number.isFinite(value.ts) &&
7007
+ isOptionalString(value.activity) && isOptionalString(value.attention) &&
7008
+ (value.condition === undefined || (isRecord(value.condition) && typeof value.condition.code === "string"));
7009
+ }
7010
+ function isOptionalString(value) {
7011
+ return value === undefined || typeof value === "string";
6747
7012
  }
6748
7013
  /** Shallow-equal two per-channel-mode maps (presence dedup): a change must re-emit, so an attention
6749
7014
  * toggle isn't swallowed as a quiet heartbeat. Absent and empty compare equal. */
@@ -6837,19 +7102,6 @@ function describeStatusError(err) {
6837
7102
  }
6838
7103
  return err;
6839
7104
  }
6840
- /** True when a failure is a NATS *permission denial* — the subject is forbidden to this
6841
- * endpoint's creds — rather than a missing responder or a timeout. The two need opposite
6842
- * fixes (grant the capability vs. start/await the service), so callers (e.g. a control
6843
- * request that can't reach the manager) must tell them apart instead of defaulting to
6844
- * "service down". Unwraps a wrapped `cause` and falls back to the server's error text, since
6845
- * a denied publish can surface either as the typed error or inside a request rejection. */
6846
- export function isPermissionDenied(e) {
6847
- if (e instanceof PermissionViolationError)
6848
- return true;
6849
- if (e?.cause instanceof PermissionViolationError)
6850
- return true;
6851
- return /permissions?\s+violation/i.test(String(e?.message ?? ""));
6852
- }
6853
7105
  /** The ordered consumer behind a KV watch, which nats.js keeps on the iterator and the KV types hide. */
6854
7106
  function kvWatchConsumer(iter) {
6855
7107
  const consumer = iter._data;