@cotal-ai/core 0.18.0 → 0.20.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.
- package/dist/acls.d.ts +0 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +10 -7
- package/dist/acls.js.map +1 -1
- package/dist/agui-kind.d.ts +71 -0
- package/dist/agui-kind.d.ts.map +1 -0
- package/dist/agui-kind.js +95 -0
- package/dist/agui-kind.js.map +1 -0
- package/dist/endpoint-action.d.ts +3 -3
- package/dist/endpoint-action.js +4 -4
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-binding.d.ts +48 -5
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +82 -6
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-cluster.d.ts +20 -0
- package/dist/endpoint-cluster.d.ts.map +1 -1
- package/dist/endpoint-cluster.js +47 -0
- package/dist/endpoint-cluster.js.map +1 -1
- package/dist/endpoint-effects.d.ts +73 -0
- package/dist/endpoint-effects.d.ts.map +1 -0
- package/dist/endpoint-effects.js +46 -0
- package/dist/endpoint-effects.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +32 -1
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +56 -2
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-epname.d.ts +62 -0
- package/dist/endpoint-epname.d.ts.map +1 -0
- package/dist/endpoint-epname.js +273 -0
- package/dist/endpoint-epname.js.map +1 -0
- package/dist/endpoint-error.d.ts +155 -1
- package/dist/endpoint-error.d.ts.map +1 -1
- package/dist/endpoint-error.js +114 -2
- package/dist/endpoint-error.js.map +1 -1
- package/dist/endpoint-goaleff.d.ts +56 -0
- package/dist/endpoint-goaleff.d.ts.map +1 -0
- package/dist/endpoint-goaleff.js +212 -0
- package/dist/endpoint-goaleff.js.map +1 -0
- package/dist/endpoint-grants.d.ts +31 -0
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +47 -0
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +1 -1
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +89 -7
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-journal.d.ts +103 -0
- package/dist/endpoint-journal.d.ts.map +1 -1
- package/dist/endpoint-journal.js +290 -0
- package/dist/endpoint-journal.js.map +1 -1
- package/dist/endpoint-records.d.ts.map +1 -1
- package/dist/endpoint-records.js +37 -0
- package/dist/endpoint-records.js.map +1 -1
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +45 -1
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +12 -3
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +113 -19
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +149 -6
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +330 -14
- package/dist/endpoint.js.map +1 -1
- package/dist/event-channel.d.ts +107 -0
- package/dist/event-channel.d.ts.map +1 -0
- package/dist/event-channel.js +139 -0
- package/dist/event-channel.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +35 -12
- package/dist/membership-feed.js.map +1 -1
- package/dist/parts.d.ts +29 -5
- package/dist/parts.d.ts.map +1 -1
- package/dist/parts.js +46 -3
- package/dist/parts.js.map +1 -1
- package/dist/provision.d.ts +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +38 -0
- package/dist/resolve.js.map +1 -1
- package/dist/streams.d.ts +27 -3
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +28 -4
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +42 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +117 -0
- package/dist/subjects.js.map +1 -1
- package/package.json +5 -2
package/dist/endpoint.js
CHANGED
|
@@ -5,11 +5,13 @@ import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, Author
|
|
|
5
5
|
import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
|
|
6
6
|
import { inspectCredHealth } from "./provision.js";
|
|
7
7
|
import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
|
|
8
|
-
import { EpEnvelopeError } from "./endpoint-envelope.js";
|
|
8
|
+
import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, EP_BIND_REFUSED } from "./endpoint-envelope.js";
|
|
9
|
+
import { isRepeatSafeCommand } from "./endpoint-grants.js";
|
|
10
|
+
import { assertIdToken } from "./endpoint-subjects.js";
|
|
9
11
|
import { liveKvEntries } from "./kv-scan.js";
|
|
10
12
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
11
13
|
import { assertValidName } from "./resolve.js";
|
|
12
|
-
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS } from "./streams.js";
|
|
14
|
+
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS } from "./streams.js";
|
|
13
15
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
|
|
14
16
|
import { Kvm } from "@nats-io/kv";
|
|
15
17
|
import { openMembersRegistry, commitMember, tombstoneMember, activateMember, readMember, listMembers, durableEligible, StaleMembershipWrite, } from "./members.js";
|
|
@@ -199,16 +201,34 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
199
201
|
owner;
|
|
200
202
|
/** This endpoint's actor token (principal half 2) — the connection id in the dev default. */
|
|
201
203
|
actor;
|
|
204
|
+
/** True when {@link actor} was SELF-MINTED at construction — a fresh random token, because the
|
|
205
|
+
* card declared no actor and no id and no creds named one. Such a principal differs on every
|
|
206
|
+
* restart, so nothing can be granted to it in advance and nothing durable may be keyed on it.
|
|
207
|
+
* Exposed via {@link actorIsEphemeral} so a caller deriving a per-agent resource name can refuse
|
|
208
|
+
* the mode instead of silently keying on a value that will not survive the process. */
|
|
209
|
+
actorIsEphemeral;
|
|
202
210
|
/** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
|
|
203
211
|
ownLifecycleUid;
|
|
204
212
|
/** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
|
|
205
213
|
* `failed-precondition` currency refusal (the described incarnation was superseded). */
|
|
206
214
|
resolvedServices = new Map();
|
|
215
|
+
/** How many calls {@link invokeService} has silently recovered from a bind refusal (§13.2) — the
|
|
216
|
+
* class-queue splits this endpoint hit and survived.
|
|
217
|
+
*
|
|
218
|
+
* Counted because it is recovered: handling the split is what makes it invisible, so this is the
|
|
219
|
+
* only evidence the split rate exists. Always on, never behind a flag — a counter you have to
|
|
220
|
+
* enable is not there when the thing you needed it for happened. */
|
|
221
|
+
splitsRecovered = 0;
|
|
207
222
|
/** This endpoint's wire principal (owner + actor tokens, §13.2) — what its minted grant rows
|
|
208
223
|
* pin. Public so a caller can build owner-mode target blocks for {@link invokeService}. */
|
|
209
224
|
get principal() {
|
|
210
225
|
return { owner: this.owner, actor: this.actor };
|
|
211
226
|
}
|
|
227
|
+
/** Class-queue splits this endpoint has hit and silently survived ({@link splitsRecovered}).
|
|
228
|
+
* Pull it, or listen for `split-recovered` — the event can be missed, the count cannot. */
|
|
229
|
+
get splitRecoveryCount() {
|
|
230
|
+
return this.splitsRecovered;
|
|
231
|
+
}
|
|
212
232
|
/** The endpoint's own lifecycle UID, REQUIRED for every lifecycle-keyed messaging resource; absent
|
|
213
233
|
* ⇒ loud refusal naming the operation (the hard cut of SPEC §13.1 — no alias-keyed fallback). */
|
|
214
234
|
requireLifecycleUid(what) {
|
|
@@ -218,6 +238,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
218
238
|
}
|
|
219
239
|
constructor(opts) {
|
|
220
240
|
super();
|
|
241
|
+
/** Did the dev/static branch fall through to a random connId? Set on every path that assigns
|
|
242
|
+
* `connId` so the ephemeral verdict below can never read an unassigned value. */
|
|
243
|
+
let selfMintedConnId = false;
|
|
221
244
|
this.space = opts.space;
|
|
222
245
|
// A display name is the client-side handle a peer is addressed by; reject the reserved `/`
|
|
223
246
|
// (the future owner/name separator) and surrounding whitespace at the one identity choke
|
|
@@ -258,6 +281,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
258
281
|
}
|
|
259
282
|
this.connId = assertInboxConnId(`ibx${randomUUID().replace(/-/g, "")}`);
|
|
260
283
|
this.sentinelCreds = opts.sentinelCreds;
|
|
284
|
+
// User mode's actor is SERVER-AUTHORED (bearer claims or a declared card checked against
|
|
285
|
+
// them), never self-minted — the ephemeral value here is the inbox nonce, which is the
|
|
286
|
+
// connection id and not the principal.
|
|
287
|
+
this.actorIsEphemeral = false;
|
|
261
288
|
}
|
|
262
289
|
else {
|
|
263
290
|
// DEV / STATIC. Connection identity precedence: an explicit card.id, else the creds' identity, else
|
|
@@ -272,16 +299,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
272
299
|
throw new Error("a creds source requires an explicit card.id (no cred to derive the identity from at construction)");
|
|
273
300
|
this.credsSource = opts.creds;
|
|
274
301
|
this.connId = opts.card.id;
|
|
302
|
+
selfMintedConnId = false;
|
|
275
303
|
}
|
|
276
304
|
else {
|
|
277
305
|
const credId = opts.creds ? idFromCreds(opts.creds) : undefined;
|
|
278
306
|
if (opts.card.id && credId && opts.card.id !== credId)
|
|
279
307
|
throw new Error(`card.id ${opts.card.id} != creds identity ${credId} - they must be the same nkey`);
|
|
280
308
|
this.currentCreds = opts.creds;
|
|
309
|
+
selfMintedConnId = opts.card.id === undefined && credId === undefined;
|
|
281
310
|
this.connId = opts.card.id ?? credId ?? randomUUID().replace(/-/g, "");
|
|
282
311
|
}
|
|
283
312
|
this.owner = opts.card.owner ?? DEV_OWNER;
|
|
284
313
|
this.actor = opts.card.actor ?? this.connId;
|
|
314
|
+
// The actor is EPHEMERAL only when it inherited a self-minted connId — a declared `card.actor`
|
|
315
|
+
// is stable even on an otherwise identity-less connection. Recorded here, at the one site the
|
|
316
|
+
// fallback fires, so no caller has to re-derive the precedence rule from the outside.
|
|
317
|
+
this.actorIsEphemeral = opts.card.actor === undefined && selfMintedConnId;
|
|
285
318
|
}
|
|
286
319
|
// The incarnation's lifecycle UID (SPEC §13.1). AUTH mode (JWT creds/bearer) REQUIRES the
|
|
287
320
|
// launcher to supply it — the dm/dlv/chathist durable names must match the exact names the
|
|
@@ -924,6 +957,164 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
924
957
|
await this.publishMsg(chatSubject(this.space, this.owner, this.actor, channel), msg);
|
|
925
958
|
return msg;
|
|
926
959
|
}
|
|
960
|
+
/** The broker's live `max_payload` — the CEILING a frame is measured against, not a budget for a
|
|
961
|
+
* caller's own payload.
|
|
962
|
+
*
|
|
963
|
+
* Exposed because the connection is private and callers outside core (a connector assembling a
|
|
964
|
+
* batched payload) otherwise have no way to learn the ceiling except by failing a publish.
|
|
965
|
+
* Throws rather than guessing a default: a wrong ceiling is worse than none, because it splits
|
|
966
|
+
* either too eagerly or too late and both look like working code.
|
|
967
|
+
*
|
|
968
|
+
* THIS ALONE CANNOT SIZE A MESSAGE. The envelope this endpoint adds after the publish call, and
|
|
969
|
+
* the client's own headers, are charged against the same ceiling and the caller never sees them.
|
|
970
|
+
* Use {@link encodedSize}, which measures what will actually be sent. */
|
|
971
|
+
get maxPayload() {
|
|
972
|
+
const max = this.nc?.info?.max_payload;
|
|
973
|
+
if (typeof max !== "number" || !Number.isFinite(max) || max <= 0)
|
|
974
|
+
throw new Error(`${this.notLiveMsg()} - max_payload is only known while connected`);
|
|
975
|
+
return max;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Verify the PRECONDITION {@link multicastExpecting} depends on: that the chat stream evaluates
|
|
979
|
+
* the subject expectation BEFORE the `Nats-Msg-Id` dedup cache. **Throws if it cannot be
|
|
980
|
+
* guaranteed.** Call before the first serialized append on a given endpoint.
|
|
981
|
+
*
|
|
982
|
+
* **The ordering follows the stream's REPLICATION FACTOR, not the deployment.** A standalone R1
|
|
983
|
+
* stream and an R1 stream inside a real 3-node cluster both refuse a stale expectation with a CAS
|
|
984
|
+
* error; only an R3 stream evaluates dedup first and answers a retry with `duplicate: true`. A
|
|
985
|
+
* check written against cluster size would pass on exactly the deployment that breaks.
|
|
986
|
+
*
|
|
987
|
+
* Every stream Cotal creates is `num_replicas: 1`, from the same canonical config the restore path
|
|
988
|
+
* uses, so the property holds by construction today. This exists because "by construction" is an
|
|
989
|
+
* observation until something checks it: nothing in the wire contract reserves the replica factor.
|
|
990
|
+
* A caller appending under a stale assumption does not fail loudly; it accepts a retry as success
|
|
991
|
+
* and drops a message.
|
|
992
|
+
*
|
|
993
|
+
* Evidence is `smoke:cas-preflight-cluster`, which records the server version it measured against
|
|
994
|
+
* rather than naming one here — the suites resolve `nats-server` from `PATH`, so a hardcoded
|
|
995
|
+
* provenance ages into a claim about a machine that no longer exists.
|
|
996
|
+
*
|
|
997
|
+
* @throws if the stream is unreadable (no `STREAM.INFO` grant, or absent) or reports more than
|
|
998
|
+
* one replica. Never degrades to a warning: the failure it prevents is silent.
|
|
999
|
+
*/
|
|
1000
|
+
async assertExpectationSemantics() {
|
|
1001
|
+
if (!this.jsm)
|
|
1002
|
+
throw new Error(this.notLiveMsg());
|
|
1003
|
+
const stream = chatStream(this.space);
|
|
1004
|
+
let replicas;
|
|
1005
|
+
try {
|
|
1006
|
+
replicas = (await this.jsm.streams.info(stream)).config.num_replicas;
|
|
1007
|
+
}
|
|
1008
|
+
catch (e) {
|
|
1009
|
+
throw new Error(`cannot verify expectation semantics: stream "${stream}" info unavailable (${e.message}). ` +
|
|
1010
|
+
`Serialized appends are refused rather than run on an unverified stream.`);
|
|
1011
|
+
}
|
|
1012
|
+
// `undefined` is NOT treated as 1. A server that does not report the field is a server whose
|
|
1013
|
+
// ordering we have not established, which is the case this check exists for.
|
|
1014
|
+
if (replicas !== 1)
|
|
1015
|
+
throw new Error(`stream "${stream}" reports num_replicas=${String(replicas)}; serialized appends on THIS ` +
|
|
1016
|
+
`stream require 1 — a property of this one stream, not of the broker, so a clustered ` +
|
|
1017
|
+
`deployment is fine so long as this stream is R1, which a cluster can host. ` +
|
|
1018
|
+
`On a replicated stream the dedup cache is consulted before the subject expectation, so a ` +
|
|
1019
|
+
`retry returns a duplicate ack instead of a conflict and a lost message reads as success.`);
|
|
1020
|
+
}
|
|
1021
|
+
/** The envelope {@link multicastExpecting} publishes, built in ONE place so that a frame and any
|
|
1022
|
+
* measurement of that frame cannot describe different messages. The fields this adds — `ts`,
|
|
1023
|
+
* `space`, `from`, `channel` and the normalized `mentions` — are exactly the ones a caller
|
|
1024
|
+
* holding only its parts cannot account for. */
|
|
1025
|
+
casEnvelope(opts) {
|
|
1026
|
+
return {
|
|
1027
|
+
id: opts.id,
|
|
1028
|
+
ts: Date.now(),
|
|
1029
|
+
space: this.space,
|
|
1030
|
+
from: this.ref(),
|
|
1031
|
+
channel: opts.channel,
|
|
1032
|
+
mentions: normalizeMentions(opts.mentions),
|
|
1033
|
+
parts: opts.parts,
|
|
1034
|
+
replyTo: opts.replyTo,
|
|
1035
|
+
contextId: opts.contextId,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* The bytes this frame will ACTUALLY put on the wire, to compare against {@link maxPayload}.
|
|
1040
|
+
*
|
|
1041
|
+
* Caller-side arithmetic is wrong in the dangerous direction: a split sized against the caller's
|
|
1042
|
+
* own payload produces a frame the broker REJECTS, and a rejected truncation makes the loss silent
|
|
1043
|
+
* again — the failure splitting exists to prevent.
|
|
1044
|
+
*
|
|
1045
|
+
* It lives on the surface that BUILDS the envelope so measurement and construction cannot drift
|
|
1046
|
+
* apart unnoticed: it shares {@link casEnvelope} with the publish path, sets the same two headers,
|
|
1047
|
+
* and lets the client's own encoder encode them rather than re-implementing the wire format.
|
|
1048
|
+
* `frame-size.smoke.ts` binary-searches a real broker's ceiling and requires this number to land
|
|
1049
|
+
* on it exactly.
|
|
1050
|
+
*
|
|
1051
|
+
* `expectedLastSubjectSeq` is a parameter because it is a header VALUE: sizing at 0 and publishing
|
|
1052
|
+
* at 123456 differ by five bytes.
|
|
1053
|
+
*
|
|
1054
|
+
* Residual: `ts` is re-stamped at publish, so the two differ in value — not in length until
|
|
1055
|
+
* epoch-millis needs a 14th digit.
|
|
1056
|
+
*/
|
|
1057
|
+
encodedSize(opts) {
|
|
1058
|
+
// The same argument validation the publish path applies, so a caller cannot size a frame that
|
|
1059
|
+
// would have been refused before it ever reached the wire.
|
|
1060
|
+
if (!isConcreteChannel(opts.channel))
|
|
1061
|
+
throw new Error(`cannot publish to wildcard channel "${opts.channel}" - pick a concrete sub-channel`);
|
|
1062
|
+
assertIdToken(opts.id, "publish id");
|
|
1063
|
+
if (!Number.isSafeInteger(opts.expectedLastSubjectSeq) || opts.expectedLastSubjectSeq < 0)
|
|
1064
|
+
throw new Error(`expectedLastSubjectSeq must be a non-negative safe integer, got ${JSON.stringify(opts.expectedLastSubjectSeq)}`);
|
|
1065
|
+
if (!Array.isArray(opts.parts) || opts.parts.length === 0)
|
|
1066
|
+
throw new Error("encodedSize requires at least one part");
|
|
1067
|
+
const mh = headers();
|
|
1068
|
+
mh.set("Nats-Msg-Id", opts.id);
|
|
1069
|
+
mh.set("Nats-Expected-Last-Subject-Sequence", `${opts.expectedLastSubjectSeq}`);
|
|
1070
|
+
// `encode()` is on the client's header implementation but not on the published `MsgHdrs` type,
|
|
1071
|
+
// so it is reached through a cast rather than copied. If a client version drops it, this throws
|
|
1072
|
+
// immediately and loudly — the calibration cell would also fail — instead of returning a number
|
|
1073
|
+
// that is quietly wrong near the ceiling.
|
|
1074
|
+
const headerBytes = mh.encode().length;
|
|
1075
|
+
return headerBytes + Buffer.byteLength(JSON.stringify(this.casEnvelope(opts)), "utf8");
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Multicast with an OPTIMISTIC-CONCURRENCY expectation and a caller-chosen dedup id, returning
|
|
1079
|
+
* the `PubAck` fields instead of discarding them. The serialized-append primitive: two writers
|
|
1080
|
+
* racing one subject cannot interleave, because the loser's expectation no longer holds.
|
|
1081
|
+
*
|
|
1082
|
+
* **Why a separate method rather than options on {@link multicast}.** `multicast` mints a fresh
|
|
1083
|
+
* `id` per call and drops the ack; both are right for ordinary chat and both are fatal to a
|
|
1084
|
+
* caller that must retry an append idempotently. Keeping them apart means no existing caller
|
|
1085
|
+
* changes behaviour, and the stricter validation below applies only where a caller opted in.
|
|
1086
|
+
*
|
|
1087
|
+
* - `id` becomes the JetStream `Nats-Msg-Id`, so the SAME id may be republished on retry and the
|
|
1088
|
+
* server dedups it within the stream's duplicate window. It is validated rather than trusted:
|
|
1089
|
+
* it lands in a wire header, and the dedup cache is **stream-wide**, so a caller-supplied id is
|
|
1090
|
+
* both an injection surface and a way to suppress another publisher's message.
|
|
1091
|
+
* - `expectedLastSubjectSeq` is the sequence this publisher believes is the subject's tip; `0`
|
|
1092
|
+
* means "the subject must be empty". A mismatch throws, and the throw stays classifiable by the
|
|
1093
|
+
* already-public {@link isCasLoss} — the error is deliberately **not wrapped**, since wrapping
|
|
1094
|
+
* would hide the `err_code` that classification reads.
|
|
1095
|
+
*
|
|
1096
|
+
* @throws if the endpoint is not live, the channel is not concrete, `id` is malformed, `parts` is
|
|
1097
|
+
* empty, or `expectedLastSubjectSeq` is not a non-negative safe integer.
|
|
1098
|
+
*/
|
|
1099
|
+
async multicastExpecting(opts) {
|
|
1100
|
+
if (!this.js)
|
|
1101
|
+
throw new Error(this.notLiveMsg());
|
|
1102
|
+
if (!isConcreteChannel(opts.channel))
|
|
1103
|
+
throw new Error(`cannot publish to wildcard channel "${opts.channel}" - pick a concrete sub-channel`);
|
|
1104
|
+
// Reuse the existing id grammar rather than mint a second one: [A-Za-z0-9_-]{1,64} admits a
|
|
1105
|
+
// UUID and rejects every character that could break a wire header (CR, LF, space, colon).
|
|
1106
|
+
assertIdToken(opts.id, "publish id");
|
|
1107
|
+
const expected = opts.expectedLastSubjectSeq;
|
|
1108
|
+
if (!Number.isSafeInteger(expected) || expected < 0)
|
|
1109
|
+
throw new Error(`expectedLastSubjectSeq must be a non-negative safe integer, got ${JSON.stringify(expected)}`);
|
|
1110
|
+
if (!Array.isArray(opts.parts) || opts.parts.length === 0)
|
|
1111
|
+
throw new Error("multicastExpecting requires at least one part");
|
|
1112
|
+
const message = this.casEnvelope(opts);
|
|
1113
|
+
// Publish DIRECTLY rather than through publishMsg: this path must set the expectation and read
|
|
1114
|
+
// the ack, and publishMsg deliberately does neither.
|
|
1115
|
+
const ack = await this.js.publish(chatSubject(this.space, this.owner, this.actor, opts.channel), JSON.stringify(message), { msgID: opts.id, expect: { lastSubjectSequence: expected } });
|
|
1116
|
+
return { message, ack: { seq: ack.seq, duplicate: ack.duplicate === true } };
|
|
1117
|
+
}
|
|
927
1118
|
/** Unicast: direct message to one specific instance. */
|
|
928
1119
|
async unicast(instanceId, text, opts) {
|
|
929
1120
|
const msg = {
|
|
@@ -1071,11 +1262,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1071
1262
|
/** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
|
|
1072
1263
|
* the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
|
|
1073
1264
|
* recompile ({@link resolveService}; cached per endpoint name) — and invoke one command. The
|
|
1074
|
-
* resolve is describe-bound currency
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1265
|
+
* resolve is describe-bound currency, and a call that reaches the wrong incarnation is recovered
|
|
1266
|
+
* two ways depending on WHO caught it — the difference between knowing the command did not run
|
|
1267
|
+
* and only knowing someone answered:
|
|
1268
|
+
* - the RESPONDER fenced it on the request's `bind` (§13.2, an `ok:false` reply marked
|
|
1269
|
+
* {@link replyRefusedBeforeEffect}): the command did not run, so the bind is dropped and the
|
|
1270
|
+
* call re-issued ONCE for any command. If that re-issue cannot be resolved, the refusal
|
|
1271
|
+
* surfaces — still saying the command did not run — naming the resolve failure as why the
|
|
1272
|
+
* repair could not be attempted.
|
|
1273
|
+
* - this CLIENT caught it on the reply ({@link respondedButUnbound}: a different instance,
|
|
1274
|
+
* `failed-precondition`; the same instance at any other epoch, `expired`), which is what a
|
|
1275
|
+
* responder too old to know the field produces. A live instance received and answered it, so
|
|
1276
|
+
* the bind is dropped but the call is re-issued only for a command on the
|
|
1277
|
+
* {@link isRepeatSafeCommand} allowlist; anything else surfaces, since a second attempt could
|
|
1278
|
+
* duplicate its effect.
|
|
1279
|
+
* Errors from the responder come back structurally on the attributed reply
|
|
1280
|
+
* (`reply.ok === false`); transport/validation refusals throw {@link EpEnvelopeError}. */
|
|
1079
1281
|
async invokeService(endpoint, command, args, opts = {}) {
|
|
1080
1282
|
if (!this.nc)
|
|
1081
1283
|
throw new Error(this.notLiveMsg());
|
|
@@ -1092,13 +1294,111 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1092
1294
|
const invokeOpts = { ...(opts.target ? { target: opts.target } : {}), ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}) };
|
|
1093
1295
|
const doInvoke = async () => {
|
|
1094
1296
|
try {
|
|
1095
|
-
|
|
1297
|
+
const r = await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
|
|
1298
|
+
// THE RESPONDER FENCED IT (§13.2 `ai.cotal.ep.bind-refused`): a class member saw the call
|
|
1299
|
+
// was bound to a different incarnation and refused BEFORE running the command.
|
|
1300
|
+
//
|
|
1301
|
+
// Handled here rather than below because it arrives as an ordinary `ok:false` REPLY, not a
|
|
1302
|
+
// throw, so the `respondedButUnbound` recovery never sees it — a long-lived client would
|
|
1303
|
+
// otherwise keep its stale bind and meet the same refusal forever.
|
|
1304
|
+
//
|
|
1305
|
+
// The re-issue is NOT gated on {@link isRepeatSafeCommand}: the responder states the command
|
|
1306
|
+
// did not run, so this is a FIRST attempt, not a second. Exactly once; a second refusal
|
|
1307
|
+
// surfaces.
|
|
1308
|
+
if (r.reply.ok === false && replyRefusedBeforeEffect(r.reply.error)) {
|
|
1309
|
+
// Counted before it is repaired: a recovery that leaves no trace takes the split rate with it.
|
|
1310
|
+
this.splitsRecovered++;
|
|
1311
|
+
// `boundTo` is the other half of `servedBy`: who the handle THOUGHT it was talking to,
|
|
1312
|
+
// against who actually answered. Without it a listener sees that a split was recovered
|
|
1313
|
+
// but not which bind went stale, so it cannot tell one handle's repeated staleness from
|
|
1314
|
+
// splits spread across many — and that is the difference between a handle to drop and a
|
|
1315
|
+
// class that is churning.
|
|
1316
|
+
this.emit("split-recovered", {
|
|
1317
|
+
endpoint, command, servedBy: r.responder, splitsRecovered: this.splitsRecovered,
|
|
1318
|
+
boundTo: (r.reply.error?.details ?? []).find((d) => d.kind === EP_BIND_REFUSED)?.boundTo,
|
|
1319
|
+
});
|
|
1320
|
+
this.resolvedServices.delete(endpoint);
|
|
1321
|
+
// A FAILED RE-ISSUE RETHROWS THE ORIGINAL REFUSAL, not the resolve error: if the endpoint
|
|
1322
|
+
// has since retired, the resolve times out and the caller would otherwise get `no describe
|
|
1323
|
+
// reply within 10000ms` — about a describe it never asked for — losing the one fact that
|
|
1324
|
+
// says its handle is stale AND that nothing ran. The resolve failure is named as the
|
|
1325
|
+
// reason the repair could not be attempted.
|
|
1326
|
+
//
|
|
1327
|
+
// The code must be the refusal's own (`failed-precondition` for a different instance,
|
|
1328
|
+
// `expired` for the same instance at ANY other epoch — the fence does not compare
|
|
1329
|
+
// direction, so a superseded incarnation still answering produces it too); the marker on
|
|
1330
|
+
// any other code is incoherent, so it is rejected rather than trusted (§13.3).
|
|
1331
|
+
const raw = r.reply.error?.code;
|
|
1332
|
+
const refusalCode = raw === "expired" ? "expired"
|
|
1333
|
+
: raw === "failed-precondition" ? "failed-precondition" : undefined;
|
|
1334
|
+
if (refusalCode === undefined)
|
|
1335
|
+
throw new EpEnvelopeError("internal", `${endpoint}.${command} came back marked as refused before it ran, but with code ${String(raw)}; the fence produces only failed-precondition or expired (SPEC 13.2)`, r.reply.error?.details);
|
|
1336
|
+
// ONLY THE RESOLVE IS WRAPPED — do not widen this `try` to cover the re-issue. A re-issue
|
|
1337
|
+
// that PUBLISHED AND RAN can still throw (an unfenced responder answers and the post-reply
|
|
1338
|
+
// currency check raises `respondedButUnbound`); wrapping that as the first hop's refusal
|
|
1339
|
+
// would hand the caller `WAS NOT RUN` for a command that executed, and its next attempt
|
|
1340
|
+
// would be a second one believing it was the first. A re-issue that fails on its own terms
|
|
1341
|
+
// must propagate its OWN error, because `respondedButUnbound` asserts the opposite of what
|
|
1342
|
+
// the fence's marker does: a responder answered and the effect may have landed.
|
|
1343
|
+
let reissueTarget;
|
|
1344
|
+
try {
|
|
1345
|
+
reissueTarget = await resolve();
|
|
1346
|
+
}
|
|
1347
|
+
catch (reissue) {
|
|
1348
|
+
throw new EpEnvelopeError(refusalCode, `${endpoint}.${command} WAS NOT RUN - the incarnation that received it refused it before any effect, and the re-issue could not be resolved: ${reissue instanceof Error ? reissue.message : String(reissue)}. Re-resolve and re-issue when the endpoint is reachable (SPEC 13.2)`, r.reply.error?.details,
|
|
1349
|
+
// §13.3: the message asserts the command did not run, so the FIELD a caller keys on
|
|
1350
|
+
// must assert it too. Omitted, it MUST be read as `unknown`, which is the opposite of
|
|
1351
|
+
// what this path knows: the responder fenced the call before any effect and the
|
|
1352
|
+
// re-issue never went out. Prose is for the reader; this is for the machine.
|
|
1353
|
+
"not-executed");
|
|
1354
|
+
}
|
|
1355
|
+
return await invokeCommand(nc, this.space, reissueTarget, command, args, invokeOpts);
|
|
1356
|
+
}
|
|
1357
|
+
return r;
|
|
1096
1358
|
}
|
|
1097
1359
|
catch (e) {
|
|
1098
|
-
if (!(e instanceof EpEnvelopeError)
|
|
1360
|
+
if (!(e instanceof EpEnvelopeError))
|
|
1361
|
+
throw e;
|
|
1362
|
+
// DO NOT auto-retry a command a responder already ANSWERED. This path covers the responders
|
|
1363
|
+
// WITHOUT the fence above: they ignore `bind`, run the command, and the error is raised
|
|
1364
|
+
// afterwards, so core cannot tell a repair from a duplicate and the allowlist is the only
|
|
1365
|
+
// guard left. (`failed-precondition` here is not only supersession — it also fires when a
|
|
1366
|
+
// DIFFERENT live instance wins the class queue and replies, an ordinary split.)
|
|
1367
|
+
//
|
|
1368
|
+
// Dropping the retry outright is not an option: in a two-manager space roughly half of all
|
|
1369
|
+
// class-queue calls split, so every other `ps` would surface an error this absorbs, and
|
|
1370
|
+
// re-running a read costs nothing.
|
|
1371
|
+
//
|
|
1372
|
+
// So it is gated on an ALLOWLIST (`REPEAT_SAFE_COMMANDS`), keyed by ENDPOINT because this
|
|
1373
|
+
// method is endpoint-agnostic. Polarity matters more than membership: an allowlist fails
|
|
1374
|
+
// CLOSED. Not a `GOAL_BEARING_COMMANDS`-only gate — `purge` is neither goal-bearing nor
|
|
1375
|
+
// convergent, and its second run deletes messages published after the first completed, so
|
|
1376
|
+
// the boundary is "changes anything", which core cannot see: commands carry no idempotency
|
|
1377
|
+
// declaration and every manager command shares `class: "ephemeral"`.
|
|
1378
|
+
//
|
|
1379
|
+
// Keyed on the MARKER, not the error code, because the same fact has two producers: a
|
|
1380
|
+
// DIFFERENT instance answering (`failed-precondition`) and the SAME instance at ANY OTHER
|
|
1381
|
+
// EPOCH (`expired`) — epoch inequality, not "a successor", so a superseded incarnation
|
|
1382
|
+
// still answering produces it too. Same rule for both, or a long-lived client stays bound
|
|
1383
|
+
// to a dead epoch while every call reaches whoever is actually there.
|
|
1384
|
+
if (respondedButUnbound(e)) {
|
|
1385
|
+
// Drop the stale bind FIRST, whichever way this goes: it names an incarnation that is not
|
|
1386
|
+
// the one answering, so every later call would reuse it and meet the same refusal. This is
|
|
1387
|
+
// not a retry — nothing is re-issued by it, and for a command that is not repeat-safe the
|
|
1388
|
+
// next call is the caller's, made after it has verified.
|
|
1389
|
+
this.resolvedServices.delete(endpoint);
|
|
1390
|
+
if (!isRepeatSafeCommand(endpoint, command))
|
|
1391
|
+
throw e;
|
|
1392
|
+
return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
|
|
1393
|
+
}
|
|
1394
|
+
// An UNMARKED `failed-precondition` is the resolve's own refusal, raised before any command
|
|
1395
|
+
// was published, so re-resolving once is a repair. The `replyRefusedBeforeEffect` half keeps
|
|
1396
|
+
// out the refusal THIS method raises after a failed re-issue: it carries that same code, and
|
|
1397
|
+
// would otherwise fall into the re-resolve below as a THIRD attempt at a command whose
|
|
1398
|
+
// second could not even be resolved. A marker means the disposition is already decided,
|
|
1399
|
+
// whichever code carries it.
|
|
1400
|
+
if (e.code !== "failed-precondition" || replyRefusedBeforeEffect(e.toEpError()))
|
|
1099
1401
|
throw e;
|
|
1100
|
-
// The describe-bound incarnation is gone (restart/supersede) — re-resolve once, then invoke
|
|
1101
|
-
// against the CURRENT one; a second failure is the real state and throws.
|
|
1102
1402
|
this.resolvedServices.delete(endpoint);
|
|
1103
1403
|
return await invokeCommand(nc, this.space, await resolve(), command, args, invokeOpts);
|
|
1104
1404
|
}
|
|
@@ -1735,7 +2035,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1735
2035
|
await this.ensureDmDurable(owner, actor, lifecycleUid, {});
|
|
1736
2036
|
}
|
|
1737
2037
|
/** Idempotent-PER-LIFECYCLE create of a `dm_<o>-<a>-<uid>` durable with its ACTIVATION FRONTIER
|
|
1738
|
-
* (SPEC
|
|
2038
|
+
* (SPEC §8). Info-first: an existing durable (a manager-restart re-provision of the SAME uid, or
|
|
1739
2039
|
* the same lifecycle's own restart) is kept as-is, preserving the ORIGINAL frontier — the
|
|
1740
2040
|
* activation moment never moves. A fresh lifecycle captures the DM stream's current `last_seq` and
|
|
1741
2041
|
* starts delivery at frontier+1, so a same-alias successor inherits none of the predecessor's
|
|
@@ -1921,7 +2221,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1921
2221
|
throw new Error("endpoint not started");
|
|
1922
2222
|
if (this.managerLeaseKv)
|
|
1923
2223
|
return this.managerLeaseKv;
|
|
1924
|
-
|
|
2224
|
+
// A JetStream client of its own, so every operation on this bucket carries the lease budget's
|
|
2225
|
+
// attempt deadline rather than the library default. The default is TTL/2, which would let one
|
|
2226
|
+
// attempt spend the whole renew window (see MANAGER_LEASE_ATTEMPT_MS). Scoped to this bucket:
|
|
2227
|
+
// every op on it is a single small keyed request, and nothing else shares this client.
|
|
2228
|
+
const kvm = new Kvm(jetstream(this.nc, { timeout: MANAGER_LEASE_ATTEMPT_MS }));
|
|
1925
2229
|
if (this.authed) {
|
|
1926
2230
|
this.managerLeaseKv = await kvm.open(managerBucket(this.space));
|
|
1927
2231
|
}
|
|
@@ -1952,6 +2256,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1952
2256
|
async renewManagerLease(info, revision) {
|
|
1953
2257
|
return (await this.managerLeaseRegistry()).update(managerLeaseKey(info.instanceId), this.encodeManagerLease({ ...info, since: Date.now() }), revision);
|
|
1954
2258
|
}
|
|
2259
|
+
/** Read THIS instance's OWN lease key, keyed (not the `lease.*` sweep {@link readManagerLease} does).
|
|
2260
|
+
*
|
|
2261
|
+
* `undefined` means the key IS NOT THERE — a definite absence, established by a completed read.
|
|
2262
|
+
* A read that could not be completed THROWS instead, so a caller can tell "it is gone" from "I could
|
|
2263
|
+
* not find out". That distinction is the whole point of the method: a renew that got no answer has
|
|
2264
|
+
* proved nothing, and only a definite answer here may be acted on. */
|
|
2265
|
+
async readOwnManagerLease(instanceId) {
|
|
2266
|
+
const e = await (await this.managerLeaseRegistry()).get(managerLeaseKey(instanceId));
|
|
2267
|
+
if (!e || e.operation !== "PUT")
|
|
2268
|
+
return undefined;
|
|
2269
|
+
return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision };
|
|
2270
|
+
}
|
|
1955
2271
|
/** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
|
|
1956
2272
|
* by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
|
|
1957
2273
|
* no-ops. Keyed per instance, so a release NEVER touches a sibling manager's key (security pin 6). */
|
|
@@ -3483,7 +3799,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3483
3799
|
return; // observers watch but never publish their own record
|
|
3484
3800
|
const p = {
|
|
3485
3801
|
card: this.card,
|
|
3486
|
-
// SPEC §6
|
|
3802
|
+
// SPEC §6: presence carries the incarnation's lifecycle UID (MUST in auth mode from v0.4);
|
|
3487
3803
|
// omitted only where the endpoint has none (a pure operator/daemon connection never registers).
|
|
3488
3804
|
...(this.ownLifecycleUid !== undefined ? { lifecycleUid: this.ownLifecycleUid } : {}),
|
|
3489
3805
|
status: this.status,
|