@cotal-ai/core 0.68.0 → 0.69.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 (63) 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/connector-config.d.ts +4 -0
  5. package/dist/connector-config.d.ts.map +1 -1
  6. package/dist/connector-config.js +49 -0
  7. package/dist/connector-config.js.map +1 -1
  8. package/dist/endpoint-invoke.d.ts +23 -0
  9. package/dist/endpoint-invoke.d.ts.map +1 -1
  10. package/dist/endpoint-invoke.js +50 -4
  11. package/dist/endpoint-invoke.js.map +1 -1
  12. package/dist/endpoint-serve-kv.d.ts +0 -1
  13. package/dist/endpoint-serve-kv.d.ts.map +1 -1
  14. package/dist/endpoint-serve-kv.js +0 -1
  15. package/dist/endpoint-serve-kv.js.map +1 -1
  16. package/dist/endpoint-service.d.ts +12 -6
  17. package/dist/endpoint-service.d.ts.map +1 -1
  18. package/dist/endpoint-service.js +29 -23
  19. package/dist/endpoint-service.js.map +1 -1
  20. package/dist/endpoint-verbs.d.ts.map +1 -1
  21. package/dist/endpoint-verbs.js +2 -1
  22. package/dist/endpoint-verbs.js.map +1 -1
  23. package/dist/endpoint.d.ts +25 -19
  24. package/dist/endpoint.d.ts.map +1 -1
  25. package/dist/endpoint.js +81 -76
  26. package/dist/endpoint.js.map +1 -1
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/kv-scan.d.ts +1 -3
  32. package/dist/kv-scan.d.ts.map +1 -1
  33. package/dist/kv-scan.js.map +1 -1
  34. package/dist/loopback.d.ts +8 -0
  35. package/dist/loopback.d.ts.map +1 -0
  36. package/dist/loopback.js +17 -0
  37. package/dist/loopback.js.map +1 -0
  38. package/dist/managed-handoff.d.ts.map +1 -1
  39. package/dist/managed-handoff.js +1 -18
  40. package/dist/managed-handoff.js.map +1 -1
  41. package/dist/presence-ages.d.ts +22 -0
  42. package/dist/presence-ages.d.ts.map +1 -0
  43. package/dist/presence-ages.js +33 -0
  44. package/dist/presence-ages.js.map +1 -0
  45. package/dist/provision.d.ts +0 -4
  46. package/dist/provision.d.ts.map +1 -1
  47. package/dist/provision.js +1 -11
  48. package/dist/provision.js.map +1 -1
  49. package/dist/remote-manager-authority.d.ts +8 -5
  50. package/dist/remote-manager-authority.d.ts.map +1 -1
  51. package/dist/remote-manager-authority.js.map +1 -1
  52. package/dist/secret-store.d.ts +6 -2
  53. package/dist/secret-store.d.ts.map +1 -1
  54. package/dist/secret-store.js +9 -6
  55. package/dist/secret-store.js.map +1 -1
  56. package/dist/streams.d.ts.map +1 -1
  57. package/dist/streams.js +0 -1
  58. package/dist/streams.js.map +1 -1
  59. package/dist/subjects.d.ts +5 -0
  60. package/dist/subjects.d.ts.map +1 -1
  61. package/dist/subjects.js +11 -0
  62. package/dist/subjects.js.map +1 -1
  63. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -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();
@@ -1222,7 +1219,6 @@ export class CotalEndpoint extends EventEmitter {
1222
1219
  this.chatSubs.clear();
1223
1220
  this.chatSubDenied.clear();
1224
1221
  this.confirmingChatSubs.clear();
1225
- this.ownConsumerDeletes.clear();
1226
1222
  this.ownWatchDeletePrefixes.clear();
1227
1223
  this.roster.clear();
1228
1224
  // #1356: the presence-refusal record is connection-scoped like everything else torn down here.
@@ -2017,6 +2013,7 @@ export class CotalEndpoint extends EventEmitter {
2017
2013
  return () => { reconnectHandler = undefined; };
2018
2014
  },
2019
2015
  signal: abortController.signal,
2016
+ prepare: opts.prepare,
2020
2017
  });
2021
2018
  }
2022
2019
  finally {
@@ -2079,10 +2076,22 @@ export class CotalEndpoint extends EventEmitter {
2079
2076
  }
2080
2077
  return invokeCommand(nc, this.space, service, command, args, { ...invokeOpts, signal });
2081
2078
  };
2082
- const doInvoke = async (signal = opts.signal) => {
2079
+ // A `failed-precondition` from the resolve is its own refusal, raised before any command was
2080
+ // published, so resolving once more is a repair.
2081
+ const resolveFirst = async (signal = opts.signal) => {
2082
+ try {
2083
+ return await resolve(signal);
2084
+ }
2085
+ catch (e) {
2086
+ if (!(e instanceof EpEnvelopeError) || e.code !== "failed-precondition")
2087
+ throw e;
2088
+ return await resolve(signal);
2089
+ }
2090
+ };
2091
+ const doInvoke = async (service, signal = opts.signal) => {
2083
2092
  signal?.throwIfAborted();
2084
2093
  try {
2085
- let r = await invokeResolved(await resolve(signal), signal);
2094
+ let r = await invokeResolved(service, signal);
2086
2095
  // THE RESPONDER FENCED IT (§13.2 `ai.cotal.ep.bind-refused`): a class member saw the call
2087
2096
  // was bound to a different incarnation and refused BEFORE running the command.
2088
2097
  //
@@ -2149,8 +2158,6 @@ export class CotalEndpoint extends EventEmitter {
2149
2158
  return r;
2150
2159
  }
2151
2160
  catch (e) {
2152
- if (!(e instanceof EpEnvelopeError))
2153
- throw e;
2154
2161
  // DO NOT auto-retry a command a responder already ANSWERED. This path covers the responders
2155
2162
  // WITHOUT the fence above: they ignore `bind`, run the command, and the error is raised
2156
2163
  // afterwards, so core cannot tell a repair from a duplicate and the allowlist is the only
@@ -2183,23 +2190,20 @@ export class CotalEndpoint extends EventEmitter {
2183
2190
  throw e;
2184
2191
  return await invokeResolved(await resolve(signal), signal);
2185
2192
  }
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);
2193
+ throw e;
2196
2194
  }
2197
2195
  };
2198
2196
  // P2 item 2 (2b): a goal-bearing command (spawn/launch) follows its acceptance to the terminal so
2199
2197
  // the caller still returns on the real outcome (UX unchanged); every other command replies directly.
2200
2198
  if (!opts.follow)
2201
- return doInvoke();
2202
- return this.followServiceGoal(endpoint, doInvoke, opts.deadlineMs ?? 10_000, { signal: opts.signal });
2199
+ return doInvoke(await resolveFirst());
2200
+ // Resolved before the submission starts: the follow reports an unmarked failure inside the
2201
+ // submission as a command that may have run, and the resolve publishes none of this call.
2202
+ let service;
2203
+ return this.followServiceGoal(endpoint, (signal) => doInvoke(service, signal), opts.deadlineMs ?? 10_000, {
2204
+ signal: opts.signal,
2205
+ prepare: async (signal) => { service = await resolveFirst(signal); },
2206
+ });
2203
2207
  }
2204
2208
  /** Send a durable-membership request to the SERVER-SIDE delivery daemon (`ctl.delivery`) and await its
2205
2209
  * reply. Unlike {@link requestControl}, the reply rides a subject UNDER `ctl.delivery.<id>.>` (not the
@@ -2562,7 +2566,6 @@ export class CotalEndpoint extends EventEmitter {
2562
2566
  let scanPrefix;
2563
2567
  const entries = await liveKvEntries(kv, {
2564
2568
  onConsumer: (info) => { scanPrefix = this.recordOwnWatchConsumer(info); },
2565
- deleteOwnConsumer: (stream, name, del) => this.deleteOwnConsumer(stream, name, del),
2566
2569
  }).finally(() => {
2567
2570
  // The scan's own last delete goes out after nats.js closed its consumer, so every predecessor
2568
2571
  // delete the library sent is answered ahead of it on this connection.
@@ -2644,15 +2647,11 @@ export class CotalEndpoint extends EventEmitter {
2644
2647
  watch.consumer = consumer;
2645
2648
  watch.consumerStream = info.stream_name;
2646
2649
  watch.consumerName = info.name;
2647
- let pending = info.num_pending;
2648
2650
  let iter;
2649
2651
  try {
2650
- iter = await consumer.consume({ callback: (msg) => {
2651
- const isUpdate = pending === 0 || --pending === 0;
2652
- const entry = kv.jmToWatchEntry(msg, isUpdate);
2652
+ iter = await consumer.consume({ callback: () => {
2653
2653
  if (!watch.stopped && watch.consumer === consumer)
2654
2654
  watch.onChange();
2655
- void entry;
2656
2655
  } });
2657
2656
  }
2658
2657
  catch (err) {
@@ -2694,31 +2693,12 @@ export class CotalEndpoint extends EventEmitter {
2694
2693
  watch.resolveStop = undefined;
2695
2694
  watch.rejectStop = undefined;
2696
2695
  }
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
2696
  /** Record the consumer behind one of this connection's own KV watches, membership scans or history
2719
2697
  * reads, and return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
2720
2698
  * 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
2699
+ * the send, so a profile without the delete row (#691) has that delete refused. A stalled link can
2700
+ * time that delete out before its refusal arrives, and nats.js then hands the refusal to no request,
2701
+ * so {@link trackRequestDenials} cannot own it and the consumer's name has to. A watch's prefix
2722
2702
  * stays recorded for the connection because a retired watch's last rebuild can be refused after
2723
2703
  * the watch has stopped; {@link readMembership} and {@link drainWindow} retire theirs after their own
2724
2704
  * last delete, which goes out once nats.js has stopped the consumer and so is answered after every
@@ -2737,9 +2717,8 @@ export class CotalEndpoint extends EventEmitter {
2737
2717
  * the elevated profile, which holds no stream-wide CONSUMER.DELETE (#691): the broker reaps the
2738
2718
  * consumer at its inactive threshold. */
2739
2719
  async deleteReaderConsumer(consumer) {
2740
- const { stream_name, name } = await consumer.info(true);
2741
2720
  try {
2742
- await this.deleteOwnConsumer(stream_name, name, () => consumer.delete());
2721
+ await consumer.delete();
2743
2722
  }
2744
2723
  catch (e) {
2745
2724
  if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound) && !isPublishPermissionDenied(e))
@@ -2749,7 +2728,7 @@ export class CotalEndpoint extends EventEmitter {
2749
2728
  /** Delete one membership-watch consumer, swallowing ONLY already-gone and a refused delete. */
2750
2729
  async deleteMembershipConsumer(jsm, stream, name) {
2751
2730
  try {
2752
- return await this.deleteOwnConsumer(stream, name, () => jsm.consumers.delete(stream, name));
2731
+ return await jsm.consumers.delete(stream, name);
2753
2732
  }
2754
2733
  catch (err) {
2755
2734
  if (membershipConsumerReleased(err))
@@ -2771,8 +2750,7 @@ export class CotalEndpoint extends EventEmitter {
2771
2750
  catch { /* already closed */ }
2772
2751
  if (consumer) {
2773
2752
  try {
2774
- const { stream_name, name } = await consumer.info(true);
2775
- const deleted = await this.deleteOwnConsumer(stream_name, name, () => consumer.delete());
2753
+ const deleted = await consumer.delete();
2776
2754
  if (deleted) {
2777
2755
  watch.consumerStream = undefined;
2778
2756
  watch.consumerName = undefined;
@@ -3271,15 +3249,18 @@ export class CotalEndpoint extends EventEmitter {
3271
3249
  // ---- internals -----------------------------------------------------------
3272
3250
  /**
3273
3251
  * 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).
3252
+ * publish permission violations *only* here (subscription ones too), never on the
3253
+ * failing call — so without this an over-tight ACL silently drops the agent's
3254
+ * traffic and it just looks "absent". A denied request is the exception: nats.js also
3255
+ * rejects that request with the denial, so its caller owns it (see {@link trackRequestDenials}).
3256
+ * We annotate permission denials explicitly so a denial is never mistaken for absence
3257
+ * (which already has a benign cause: MCP reconnect).
3278
3258
  */
3279
3259
  watchStatus() {
3280
3260
  const nc = this.nc;
3281
3261
  if (!nc)
3282
3262
  return;
3263
+ const handedToRequest = this.trackRequestDenials(nc);
3283
3264
  void (async () => {
3284
3265
  for await (const s of nc.status()) {
3285
3266
  // A rebuild can replace `this.nc` before the old iterator finishes. Late disconnect/close
@@ -3309,11 +3290,12 @@ export class CotalEndpoint extends EventEmitter {
3309
3290
  // and turns into a clean throw — it is not a connection error to surface.
3310
3291
  if (s.error instanceof PermissionViolationError && this.confirmingChatSubs.has(s.error.subject))
3311
3292
  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.
3293
+ if (s.error instanceof PermissionViolationError && handedToRequest.has(s.error))
3294
+ continue;
3295
+ // The predecessor nats.js deleted while rebuilding one of this connection's watches, scans or
3296
+ // history reads, whose refusal can arrive after that delete timed out.
3315
3297
  if (s.error instanceof PermissionViolationError && s.error.operation === "publish"
3316
- && (this.ownConsumerDeletes.delete(s.error.subject) || this.isOwnWatchDelete(s.error.subject)))
3298
+ && this.isOwnWatchDelete(s.error.subject))
3317
3299
  continue;
3318
3300
  this.emit("error", describeStatusError(s.error));
3319
3301
  }
@@ -3337,6 +3319,29 @@ export class CotalEndpoint extends EventEmitter {
3337
3319
  return;
3338
3320
  this.emit("transport", { connected: true, server: nc.getServer() });
3339
3321
  }
3322
+ /** Wrap `nc.request` to record each publish denial nats.js rejects one of this connection's requests
3323
+ * with. nats.js rejects the pending request on the denied subject with the denial and then dispatches
3324
+ * that same error instance on the connection status, so the caller that received it (an empty history
3325
+ * read, a refused consumer delete, nats.js's own delete of a rebuilt watch's predecessor) owns it and
3326
+ * the status loop skips it. nats.js settles the request before it dispatches the status and this
3327
+ * reaction sits directly on the promise it returns, so it runs before the status loop body, which is
3328
+ * further microtasks away behind the status iterator. Any other denial is a different instance and is
3329
+ * not recorded, including one on the same subject and one that arrives after its request timed out.
3330
+ * A refused request inbox is rejected into every pending request but breaks every later one, so it
3331
+ * is not recorded either. */
3332
+ trackRequestDenials(nc) {
3333
+ const handed = new WeakSet();
3334
+ const request = nc.request.bind(nc);
3335
+ nc.request = (subject, payload, opts) => {
3336
+ const reply = request(subject, payload, opts);
3337
+ reply.catch(({ cause }) => {
3338
+ if (cause instanceof PermissionViolationError && cause.operation === "publish")
3339
+ handed.add(cause);
3340
+ });
3341
+ return reply;
3342
+ };
3343
+ return handed;
3344
+ }
3340
3345
  /** The error message for a guard that finds the endpoint unbound: "reconnecting" during a
3341
3346
  * rebuild's null window OR an inter-retry backoff (so a concurrent op reports the real
3342
3347
  * reason, not "not started" — `reestablishing` spans the whole retry loop incl. backoff),
@@ -6041,28 +6046,29 @@ export class CotalEndpoint extends EventEmitter {
6041
6046
  stuck: this.presenceWriteEscalated,
6042
6047
  };
6043
6048
  }
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
6049
+ /** Bind a presence watch on the current connection. Resolves the bound consumer's info when
6050
+ * the watch was installed, undefined when the endpoint stopped or rebuilt while the bind was
6051
+ * in flight: that bind's iterator is released here and nothing is installed, because the
6052
+ * epoch that asked for it is gone and the epoch that replaced it binds its own watch through
6048
6053
  * {@link connectAndBind}. Without this fence a bind that completes after {@link stop} would
6049
6054
  * resurrect a watch on a stopped endpoint, and one that completes after a rebuild would
6050
6055
  * overwrite the fresh epoch's watch with a dead-connection iterator. */
6051
6056
  async startPresenceWatch() {
6052
6057
  if (!this.kv)
6053
- return false;
6058
+ return undefined;
6054
6059
  const epoch = this.presenceEpoch;
6055
6060
  let hydrated;
6056
6061
  this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
6057
6062
  const iter = await this.kv.watch();
6058
- this.recordOwnWatchConsumer(await kvWatchConsumer(iter).info(true));
6063
+ const info = await kvWatchConsumer(iter).info(true);
6064
+ this.recordOwnWatchConsumer(info);
6059
6065
  if (epoch !== this.presenceEpoch) {
6060
6066
  try {
6061
6067
  iter.stop();
6062
6068
  }
6063
6069
  catch { /* its connection may already be gone */ }
6064
6070
  hydrated();
6065
- return false;
6071
+ return undefined;
6066
6072
  }
6067
6073
  this.presenceWatchIter = iter;
6068
6074
  void (async () => {
@@ -6083,7 +6089,7 @@ export class CotalEndpoint extends EventEmitter {
6083
6089
  }
6084
6090
  hydrated();
6085
6091
  })().catch((e) => this.emit("error", e));
6086
- return true;
6092
+ return info;
6087
6093
  }
6088
6094
  /**
6089
6095
  * Replace a presence watch that has gone silent past TTL while the connection is up. The new
@@ -6111,11 +6117,11 @@ export class CotalEndpoint extends EventEmitter {
6111
6117
  // one a held link never answers must leave the old watch in place: on a plain stall that
6112
6118
  // watch is the one that recovers by itself, and its replay is still guarded against
6113
6119
  // expired PUTs. Only a successfully bound watch retires its predecessor.
6114
- const installed = await this.startPresenceWatch();
6120
+ const info = await this.startPresenceWatch();
6115
6121
  // Retired mid-bind (stop or rebuild moved the epoch): the late iterator is already
6116
6122
  // released and the old watch was torn down by whoever moved the epoch. Nothing to
6117
6123
  // retire, nothing to report.
6118
- if (!installed)
6124
+ if (!info)
6119
6125
  return;
6120
6126
  if (old && old !== this.presenceWatchIter) {
6121
6127
  try {
@@ -6124,11 +6130,10 @@ export class CotalEndpoint extends EventEmitter {
6124
6130
  catch { /* already closed with its consumer */ }
6125
6131
  }
6126
6132
  // 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)
6133
+ // `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. The
6134
+ // bind's consumer info carries the initial pending count for that one fact; nats.js's KV
6135
+ // watch placed the isUpdate marker from the same `info(true)`.
6136
+ if (info.num_pending === 0)
6132
6137
  await this.onPresenceBucketEmpty();
6133
6138
  this.emit("warning", new Error(`presence watch silent for ${silentMs}ms with the connection up; rebound it from the bucket's current state`));
6134
6139
  }