@cotal-ai/core 0.11.6 → 0.13.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 +31 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +105 -17
- package/dist/acls.js.map +1 -1
- package/dist/agent-file.js +1 -1
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +90 -10
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/backup-config.d.ts +55 -0
- package/dist/backup-config.d.ts.map +1 -0
- package/dist/backup-config.js +243 -0
- package/dist/backup-config.js.map +1 -0
- package/dist/backup.d.ts +86 -0
- package/dist/backup.d.ts.map +1 -0
- package/dist/backup.js +497 -0
- package/dist/backup.js.map +1 -0
- package/dist/broker-floor.d.ts +34 -0
- package/dist/broker-floor.d.ts.map +1 -0
- package/dist/broker-floor.js +39 -0
- package/dist/broker-floor.js.map +1 -0
- package/dist/canonical.d.ts +24 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +159 -0
- package/dist/canonical.js.map +1 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +8 -1
- package/dist/channels.js.map +1 -1
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint-action.d.ts +424 -0
- package/dist/endpoint-action.d.ts.map +1 -0
- package/dist/endpoint-action.js +910 -0
- package/dist/endpoint-action.js.map +1 -0
- package/dist/endpoint-binding.d.ts +344 -0
- package/dist/endpoint-binding.d.ts.map +1 -0
- package/dist/endpoint-binding.js +753 -0
- package/dist/endpoint-binding.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts +292 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -0
- package/dist/endpoint-checkpoint.js +818 -0
- package/dist/endpoint-checkpoint.js.map +1 -0
- package/dist/endpoint-cluster.d.ts +95 -0
- package/dist/endpoint-cluster.d.ts.map +1 -0
- package/dist/endpoint-cluster.js +194 -0
- package/dist/endpoint-cluster.js.map +1 -0
- package/dist/endpoint-contract-store.d.ts +90 -0
- package/dist/endpoint-contract-store.d.ts.map +1 -0
- package/dist/endpoint-contract-store.js +348 -0
- package/dist/endpoint-contract-store.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +165 -0
- package/dist/endpoint-envelope.d.ts.map +1 -0
- package/dist/endpoint-envelope.js +346 -0
- package/dist/endpoint-envelope.js.map +1 -0
- package/dist/endpoint-grants.d.ts +123 -0
- package/dist/endpoint-grants.d.ts.map +1 -0
- package/dist/endpoint-grants.js +215 -0
- package/dist/endpoint-grants.js.map +1 -0
- package/dist/endpoint-guard.d.ts +222 -0
- package/dist/endpoint-guard.d.ts.map +1 -0
- package/dist/endpoint-guard.js +468 -0
- package/dist/endpoint-guard.js.map +1 -0
- package/dist/endpoint-handle.d.ts +176 -0
- package/dist/endpoint-handle.d.ts.map +1 -0
- package/dist/endpoint-handle.js +608 -0
- package/dist/endpoint-handle.js.map +1 -0
- package/dist/endpoint-journal.d.ts +225 -0
- package/dist/endpoint-journal.d.ts.map +1 -0
- package/dist/endpoint-journal.js +431 -0
- package/dist/endpoint-journal.js.map +1 -0
- package/dist/endpoint-receipt.d.ts +225 -0
- package/dist/endpoint-receipt.d.ts.map +1 -0
- package/dist/endpoint-receipt.js +417 -0
- package/dist/endpoint-receipt.js.map +1 -0
- package/dist/endpoint-records.d.ts +243 -0
- package/dist/endpoint-records.d.ts.map +1 -0
- package/dist/endpoint-records.js +591 -0
- package/dist/endpoint-records.js.map +1 -0
- package/dist/endpoint-serve.d.ts +176 -0
- package/dist/endpoint-serve.d.ts.map +1 -0
- package/dist/endpoint-serve.js +446 -0
- package/dist/endpoint-serve.js.map +1 -0
- package/dist/endpoint-service.d.ts +518 -0
- package/dist/endpoint-service.d.ts.map +1 -0
- package/dist/endpoint-service.js +973 -0
- package/dist/endpoint-service.js.map +1 -0
- package/dist/endpoint-session.d.ts +398 -0
- package/dist/endpoint-session.d.ts.map +1 -0
- package/dist/endpoint-session.js +834 -0
- package/dist/endpoint-session.js.map +1 -0
- package/dist/endpoint-signing.d.ts +105 -0
- package/dist/endpoint-signing.d.ts.map +1 -0
- package/dist/endpoint-signing.js +190 -0
- package/dist/endpoint-signing.js.map +1 -0
- package/dist/endpoint-subjects.d.ts +222 -0
- package/dist/endpoint-subjects.d.ts.map +1 -0
- package/dist/endpoint-subjects.js +381 -0
- package/dist/endpoint-subjects.js.map +1 -0
- package/dist/endpoint-supervisor.d.ts +19 -0
- package/dist/endpoint-supervisor.d.ts.map +1 -0
- package/dist/endpoint-supervisor.js +12 -0
- package/dist/endpoint-supervisor.js.map +1 -0
- package/dist/endpoint-traits.d.ts +225 -0
- package/dist/endpoint-traits.d.ts.map +1 -0
- package/dist/endpoint-traits.js +469 -0
- package/dist/endpoint-traits.js.map +1 -0
- package/dist/endpoint-verbs.d.ts +277 -0
- package/dist/endpoint-verbs.d.ts.map +1 -0
- package/dist/endpoint-verbs.js +576 -0
- package/dist/endpoint-verbs.js.map +1 -0
- package/dist/endpoint-virtual.d.ts +308 -0
- package/dist/endpoint-virtual.d.ts.map +1 -0
- package/dist/endpoint-virtual.js +440 -0
- package/dist/endpoint-virtual.js.map +1 -0
- package/dist/endpoint-work.d.ts +304 -0
- package/dist/endpoint-work.d.ts.map +1 -0
- package/dist/endpoint-work.js +728 -0
- package/dist/endpoint-work.js.map +1 -0
- package/dist/endpoint.d.ts +66 -22
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +289 -89
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +95 -32
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +193 -3
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +4 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/members.d.ts +4 -4
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +12 -12
- package/dist/members.js.map +1 -1
- package/dist/provision.d.ts +78 -20
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +214 -37
- package/dist/provision.js.map +1 -1
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/safe-pattern.d.ts +33 -0
- package/dist/safe-pattern.d.ts.map +1 -0
- package/dist/safe-pattern.js +486 -0
- package/dist/safe-pattern.js.map +1 -0
- package/dist/schema-profile.d.ts +93 -0
- package/dist/schema-profile.d.ts.map +1 -0
- package/dist/schema-profile.js +284 -0
- package/dist/schema-profile.js.map +1 -0
- package/dist/secret-store.d.ts +46 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/secret-store.js +2 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/space-auth.d.ts +9 -0
- package/dist/space-auth.d.ts.map +1 -0
- package/dist/space-auth.js +126 -0
- package/dist/space-auth.js.map +1 -0
- package/dist/streams.d.ts +14 -10
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +47 -76
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +100 -40
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +136 -60
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import { jetstream, jetstreamManager } from "@nats-io/jetstream";
|
|
2
|
+
import { headers as natsHeaders } from "@nats-io/transport-node";
|
|
3
|
+
import { canonicalJson, rawDigest } from "./canonical.js";
|
|
4
|
+
import { EpEnvelopeError } from "./endpoint-envelope.js";
|
|
5
|
+
import { epwSubject, epfSubject, assertBoundedOwner, assertLifecycleToken } from "./endpoint-subjects.js";
|
|
6
|
+
import { RECORD_KINDS, recordSpecKey, createRecordEntry, updateRecordEntry, openRecordsBucket } from "./endpoint-records.js";
|
|
7
|
+
import { epfStreamName, readLastFact } from "./endpoint-journal.js";
|
|
8
|
+
// The §13.12 resource names and consumer configs live in endpoint-binding.ts (the single source
|
|
9
|
+
// of the stream/durable table: epwStreamName, poolDurable, poolConsumerConfig); this module is
|
|
10
|
+
// the lease/commit/reconcile SEMANTICS over them.
|
|
11
|
+
import { epwStreamName } from "./endpoint-binding.js";
|
|
12
|
+
/** Bond the resources to one space by CONSTRUCTION: the JetStream client, the manager, and the
|
|
13
|
+
* records KV (the space's own bucket) all derive from the ONE connection passed in — four
|
|
14
|
+
* already-separated resources are not accepted, so the advertised bond is real, not asserted.
|
|
15
|
+
* The returned context is FROZEN (no later swap) and BRANDED: every seam accepts only a
|
|
16
|
+
* context this constructor built, so a hand-assembled structural look-alike (the cross-space
|
|
17
|
+
* mixup the bond exists to prevent) is rejected at the consuming boundary. */
|
|
18
|
+
export async function workPoolContext(nc, space) {
|
|
19
|
+
if (nc === null || typeof nc !== "object" || typeof nc.close !== "function")
|
|
20
|
+
throw new EpEnvelopeError("failed-precondition", "a work-pool context is constructed from ONE binding-layer connection; separate resources are never accepted (SPEC 13.4)");
|
|
21
|
+
if (typeof space !== "string" || space.length === 0)
|
|
22
|
+
throw new EpEnvelopeError("failed-precondition", "a work-pool context needs a space");
|
|
23
|
+
const js = jetstream(nc);
|
|
24
|
+
const jsm = await jetstreamManager(nc);
|
|
25
|
+
const kv = await openRecordsBucket(nc, space);
|
|
26
|
+
const ctx = Object.freeze({ kv, js, jsm, space });
|
|
27
|
+
BRANDED_CONTEXTS.add(ctx);
|
|
28
|
+
return ctx;
|
|
29
|
+
}
|
|
30
|
+
const BRANDED_CONTEXTS = new WeakSet();
|
|
31
|
+
function assertCtx(ctx) {
|
|
32
|
+
if (!BRANDED_CONTEXTS.has(ctx))
|
|
33
|
+
throw new EpEnvelopeError("failed-precondition", `the work-pool context was not constructed by workPoolContext(); a hand-assembled resource bundle never authorizes - the space bond is constructed, not asserted (SPEC 13.4)`);
|
|
34
|
+
}
|
|
35
|
+
/** The brand assertion, exported for sibling modules composing over this context (the §13.6
|
|
36
|
+
* virtual admission/occupancy seams): every seam that accepts a WorkPoolContext enforces the
|
|
37
|
+
* constructed bond, none trusts a structural look-alike. */
|
|
38
|
+
export function assertWorkPoolContext(ctx) {
|
|
39
|
+
assertCtx(ctx);
|
|
40
|
+
}
|
|
41
|
+
/** Snapshot the item ref to a validated, DETACHED copy at seam entry, BEFORE the first await:
|
|
42
|
+
* a caller-shared mutable ref can otherwise split one operation's identity across its lease
|
|
43
|
+
* CAS and its terminal publish (settle item A, publish item B). Every seam works only on
|
|
44
|
+
* this copy. */
|
|
45
|
+
function snapshotRef(ref) {
|
|
46
|
+
// Every property is READ EXACTLY ONCE: a getter answering differently between a validation
|
|
47
|
+
// read and a copy read must not split what was checked from what is used.
|
|
48
|
+
if (ref === null || typeof ref !== "object")
|
|
49
|
+
throw new EpEnvelopeError("failed-precondition", `a work-item ref must carry string endpoint/pool and a full acceptance identity (SPEC 13.2)`);
|
|
50
|
+
const endpoint = ref.endpoint;
|
|
51
|
+
const pool = ref.pool;
|
|
52
|
+
const a = ref.acceptance;
|
|
53
|
+
if (typeof endpoint !== "string" || typeof pool !== "string" || a === null || typeof a !== "object")
|
|
54
|
+
throw new EpEnvelopeError("failed-precondition", `a work-item ref must carry string endpoint/pool and a full acceptance identity (SPEC 13.2)`);
|
|
55
|
+
const owner = a.owner;
|
|
56
|
+
const actor = a.actor;
|
|
57
|
+
const uid = a.uid;
|
|
58
|
+
const id = a.id;
|
|
59
|
+
if (typeof owner !== "string" || typeof actor !== "string" || typeof uid !== "string" || typeof id !== "string")
|
|
60
|
+
throw new EpEnvelopeError("failed-precondition", `a work-item ref must carry string endpoint/pool and a full acceptance identity (SPEC 13.2)`);
|
|
61
|
+
return { endpoint, pool, acceptance: { owner, actor, uid, id } };
|
|
62
|
+
}
|
|
63
|
+
/** The item's stored subject (`epw.<e>.<pool>.<cOwner>.<cActor>.<cUid>.<id>`). */
|
|
64
|
+
export function workItemSubject(space, ref) {
|
|
65
|
+
return epwSubject(space, ref.endpoint, ref.pool, ref.acceptance);
|
|
66
|
+
}
|
|
67
|
+
/** The item's terminal-fact subject (`epf.<e>.wrk.<pool>.<acceptance identity>`, §13.2). */
|
|
68
|
+
export function workTerminalSubject(space, ref) {
|
|
69
|
+
return epfSubject(space, ref.endpoint, ["wrk", ref.pool, ref.acceptance.owner, ref.acceptance.actor, ref.acceptance.uid, ref.acceptance.id]);
|
|
70
|
+
}
|
|
71
|
+
function leaseKeyOf(ref) {
|
|
72
|
+
return recordSpecKey(RECORD_KINDS.lease, [ref.endpoint, ref.pool, ref.acceptance.owner, ref.acceptance.actor, ref.acceptance.uid, ref.acceptance.id]);
|
|
73
|
+
}
|
|
74
|
+
function assertSafeInt(v, what) {
|
|
75
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0)
|
|
76
|
+
throw new EpEnvelopeError("failed-precondition", `${what} must be a non-negative safe integer; got ${JSON.stringify(v)}`);
|
|
77
|
+
return v;
|
|
78
|
+
}
|
|
79
|
+
/** Execution coordinates (sourceSeq/attempt/fencingToken) are POSITIVE at issue, parse, and
|
|
80
|
+
* commit — symmetrically. Zero is reserved for the worker-less never-leased expiry sentinel;
|
|
81
|
+
* admitting it anywhere else would durably poison the terminal rail (parseWorkTerminalFact refuses
|
|
82
|
+
* zero coordinates, so a zero-coordinate settle could never be projected). */
|
|
83
|
+
function assertPositiveInt(v, what) {
|
|
84
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 1)
|
|
85
|
+
throw new EpEnvelopeError("failed-precondition", `${what} must be a positive safe integer; got ${JSON.stringify(v)} (SPEC 13.5)`);
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
88
|
+
/** The no-message result of a subject-confined last-by-subject read (JetStream err_code 10037):
|
|
89
|
+
* the ONLY error that means "genuinely absent". Every other failure (permission, transport,
|
|
90
|
+
* server) is `unavailable`, never fabricated as absence. */
|
|
91
|
+
function isNoMessage(e) {
|
|
92
|
+
return e?.code === 10037;
|
|
93
|
+
}
|
|
94
|
+
/** The CANONICAL acceptance→item-bytes derivation (§13.6): the ONE deterministic projection of a
|
|
95
|
+
* pool-routed {@link AcceptanceFact} into the EPW stored bytes — work identity + input ONLY
|
|
96
|
+
* (`v`/`id`/`fingerprint`/`sourceSeq`/`workExpiry`/`caller`/`request`; never a lease, token, or
|
|
97
|
+
* decision metadata), RFC-8785 canonical JSON so two independent derivations are BYTE-IDENTICAL.
|
|
98
|
+
* Every first enqueue AND every reconciliation re-enqueue MUST derive through this function:
|
|
99
|
+
* {@link enqueueWorkItem}'s idempotency is same-subject AND same-bytes, so a canonicalizer and a
|
|
100
|
+
* drain repairing its crash-before-enqueue must agree byte-for-byte or the repair fails loud as
|
|
101
|
+
* a mixup. Refuses a non-pool route or a missing work horizon (those never enqueue). */
|
|
102
|
+
export function workItemBytesOf(acceptance) {
|
|
103
|
+
if (!acceptance.route.startsWith("pool."))
|
|
104
|
+
throw new EpEnvelopeError("failed-precondition", `work item bytes derive only from a pool-routed acceptance; route is "${acceptance.route}" (SPEC 13.6)`);
|
|
105
|
+
if (typeof acceptance.workExpiry !== "number" || !Number.isSafeInteger(acceptance.workExpiry))
|
|
106
|
+
throw new EpEnvelopeError("failed-precondition", `a pool-routed acceptance must pin an integer workExpiry; the stored item carries its absolute horizon (SPEC 13.8)`);
|
|
107
|
+
return new TextEncoder().encode(canonicalJson({
|
|
108
|
+
v: 1,
|
|
109
|
+
id: acceptance.id,
|
|
110
|
+
fingerprint: acceptance.fingerprint,
|
|
111
|
+
sourceSeq: acceptance.sourceSeq,
|
|
112
|
+
workExpiry: acceptance.workExpiry,
|
|
113
|
+
caller: acceptance.caller,
|
|
114
|
+
request: acceptance.request,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
/** Enqueue a pool item (the canonicalizer's seam, §13.6): CREATE-ONLY per acceptance-identity
|
|
118
|
+
* subject, so acceptance→enqueue spanning two streams stays idempotent — a duplicate or
|
|
119
|
+
* reconciliation re-enqueue of the same item loses its CAS harmlessly. The bytes are the
|
|
120
|
+
* acceptance-derived work identity + input ONLY (never a lease/token; {@link workItemBytesOf}
|
|
121
|
+
* is the canonical derivation). A CAS loss is only a
|
|
122
|
+
* benign duplicate if the stored bytes are BYTE-IDENTICAL to the ones offered (same
|
|
123
|
+
* acceptance-derived work): a differing prior body under the same identity is a canonicalizer
|
|
124
|
+
* mixup and fails loud, never silently executes the wrong input. */
|
|
125
|
+
export async function enqueueWorkItem(ctx, itemRef, itemBytes) {
|
|
126
|
+
assertCtx(ctx);
|
|
127
|
+
const ref = snapshotRef(itemRef);
|
|
128
|
+
if (!(itemBytes instanceof Uint8Array))
|
|
129
|
+
throw new EpEnvelopeError("failed-precondition", `itemBytes must be a Uint8Array (the acceptance-derived stored bytes, SPEC 13.6)`);
|
|
130
|
+
const bytes = new Uint8Array(itemBytes); // real copy detached at entry: the published body and the CAS-loss identity check read the SAME bytes. NOT .slice() — a Node Buffer (Buffer extends Uint8Array, so it passes the guard above) .slice()s to an ALIASING view, which a caller mutating itemBytes during the publish await would still corrupt; new Uint8Array(...) always copies into a fresh ArrayBuffer.
|
|
131
|
+
const h = natsHeaders();
|
|
132
|
+
h.set("Nats-Expected-Last-Subject-Sequence", "0");
|
|
133
|
+
const subject = workItemSubject(ctx.space, ref);
|
|
134
|
+
try {
|
|
135
|
+
const pa = await ctx.js.publish(subject, bytes, { headers: h });
|
|
136
|
+
return { enqueued: true, seq: pa.seq };
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
const code = e?.code;
|
|
140
|
+
if (code !== 10071 && code !== 10164)
|
|
141
|
+
throw e;
|
|
142
|
+
// A prior enqueue exists (or existed and was consumed). If a live entry remains, verify its
|
|
143
|
+
// bytes match; a consumed-and-gone entry (no message) is a settled/in-flight item the caller
|
|
144
|
+
// reconciles, not a mismatch.
|
|
145
|
+
let stored;
|
|
146
|
+
// LEADER-SERVED STREAM.MSG.GET, never a follower Direct Get (SPEC 13.6:1797-1799): this read
|
|
147
|
+
// gates the enqueue decision (a stale follower miss would re-arm settled work), so it is a
|
|
148
|
+
// fencing read and must go to the stream leader that just rejected the CAS — read-your-writes.
|
|
149
|
+
try {
|
|
150
|
+
stored = await ctx.jsm.streams.getMessage(epwStreamName(ctx.space), { last_by_subj: subject });
|
|
151
|
+
}
|
|
152
|
+
catch (ge) {
|
|
153
|
+
if (isNoMessage(ge))
|
|
154
|
+
return { enqueued: false };
|
|
155
|
+
throw new EpEnvelopeError("unavailable", `the enqueue CAS lost and the stored item is not readable to verify identity (SPEC 13.6): ${ge?.message ?? String(ge)}`);
|
|
156
|
+
}
|
|
157
|
+
if (stored !== null && rawDigest(stored.data) !== rawDigest(bytes)) // the DETACHED bytes, the ones actually published — not the caller-owned itemBytes a mutation could have changed during the publish await
|
|
158
|
+
throw new EpEnvelopeError("conflict", `an item with acceptance identity "${ref.acceptance.id}" is already enqueued with DIFFERENT bytes; idempotency is same-subject AND same-bytes; a differing body is a canonicalizer mixup, never silently accepted (SPEC 13.6)`);
|
|
159
|
+
return { enqueued: false };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Detach + validate a caller-supplied worker at seam entry, reading each property EXACTLY
|
|
163
|
+
* ONCE (single-read: a getter answering differently between a validation read and a
|
|
164
|
+
* construction read must not split the principal that was checked from the one persisted). */
|
|
165
|
+
function assertWorker(w, what) {
|
|
166
|
+
const kind = w.kind;
|
|
167
|
+
const owner = w.owner;
|
|
168
|
+
const actor = w.actor;
|
|
169
|
+
const lifecycleUid = w.lifecycleUid;
|
|
170
|
+
assertBoundedOwner(owner, `${what} owner`);
|
|
171
|
+
assertBoundedOwner(actor, `${what} actor`);
|
|
172
|
+
assertLifecycleToken(lifecycleUid, `${what} lifecycleUid`);
|
|
173
|
+
if (kind === "endpoint") {
|
|
174
|
+
const epoch = w.epoch;
|
|
175
|
+
if (!Number.isSafeInteger(epoch) || epoch < 0)
|
|
176
|
+
throw new EpEnvelopeError("failed-precondition", `${what} is an endpoint worker but carries no valid epoch; an endpoint worker's process epoch is its fence (SPEC 13.8)`);
|
|
177
|
+
return Object.freeze({ kind: "endpoint", owner, actor, lifecycleUid, epoch });
|
|
178
|
+
}
|
|
179
|
+
if (kind !== "agent")
|
|
180
|
+
throw new EpEnvelopeError("failed-precondition", `${what} has an unknown worker kind; a worker is "agent" or "endpoint" (SPEC 13.5)`);
|
|
181
|
+
return Object.freeze({ kind: "agent", owner, actor, lifecycleUid });
|
|
182
|
+
}
|
|
183
|
+
function assertClosedKeys(o, allowed, what) {
|
|
184
|
+
for (const k of Object.keys(o))
|
|
185
|
+
if (!allowed.includes(k))
|
|
186
|
+
throw new EpEnvelopeError("internal", `${what} carries unknown field ${JSON.stringify(k)}; a closed schema admits no extras - garbled state never authorizes (SPEC 13.4)`);
|
|
187
|
+
}
|
|
188
|
+
function parseWorker(raw, key) {
|
|
189
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
190
|
+
throw new EpEnvelopeError("internal", `lease record ${key} carries a non-object worker; garbled mediated lease state never authorizes (SPEC 13.5)`);
|
|
191
|
+
const w = raw;
|
|
192
|
+
if (typeof w.owner !== "string" || typeof w.actor !== "string" || typeof w.lifecycleUid !== "string" || (w.kind !== "agent" && w.kind !== "endpoint"))
|
|
193
|
+
throw new EpEnvelopeError("internal", `lease record ${key} worker is missing its kind/principal/lifecycle binding; garbled state never authorizes (SPEC 13.5)`);
|
|
194
|
+
if (w.kind === "endpoint" && (typeof w.epoch !== "number" || !Number.isSafeInteger(w.epoch) || w.epoch < 0))
|
|
195
|
+
throw new EpEnvelopeError("internal", `lease record ${key} endpoint worker has no valid epoch; garbled state never authorizes (SPEC 13.5)`);
|
|
196
|
+
assertClosedKeys(w, w.kind === "endpoint" ? ["kind", "owner", "actor", "lifecycleUid", "epoch"] : ["kind", "owner", "actor", "lifecycleUid"], `worker on ${key}`);
|
|
197
|
+
return w.kind === "endpoint"
|
|
198
|
+
? { kind: "endpoint", owner: w.owner, actor: w.actor, lifecycleUid: w.lifecycleUid, epoch: w.epoch }
|
|
199
|
+
: { kind: "agent", owner: w.owner, actor: w.actor, lifecycleUid: w.lifecycleUid };
|
|
200
|
+
}
|
|
201
|
+
function sameWorker(a, b) {
|
|
202
|
+
if (a === undefined || b === undefined)
|
|
203
|
+
return false;
|
|
204
|
+
return a.kind === b.kind && a.owner === b.owner && a.actor === b.actor && a.lifecycleUid === b.lifecycleUid
|
|
205
|
+
&& (a.kind !== "endpoint" || (b.kind === "endpoint" && a.epoch === b.epoch));
|
|
206
|
+
}
|
|
207
|
+
/** Closed per-state/per-disposition validation (§13.4/§13.5): a `leased` lease MUST carry a
|
|
208
|
+
* worker + positive attempt/sourceSeq and NO settlement fields; a `settled:committed` MUST
|
|
209
|
+
* carry a worker + a present `outcome` + committedTs + positive execution coordinates (its
|
|
210
|
+
* terminal fact derives from them); `settled:expired|retired` carry committedTs, KEEP their
|
|
211
|
+
* worker when one was ever leased (workerless = never-leased), and never carry an outcome.
|
|
212
|
+
* A retired settlement additionally carries its op/target binding. Unknown fields and garbled
|
|
213
|
+
* cross-variant state never authorize. */
|
|
214
|
+
function parseLease(raw, key) {
|
|
215
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
216
|
+
throw new EpEnvelopeError("internal", `lease record ${key} is not an object; garbled mediated lease state never authorizes (SPEC 13.5)`);
|
|
217
|
+
const o = raw;
|
|
218
|
+
if (o.v !== 1 || (o.state !== "leased" && o.state !== "settled"))
|
|
219
|
+
throw new EpEnvelopeError("internal", `lease record ${key} has an unknown version/state; garbled state never authorizes (SPEC 13.5)`);
|
|
220
|
+
assertClosedKeys(o, ["v", "state", "sourceSeq", "attempt", "worker", "fencingToken", "leaseDeadline", "workExpiry", "disposition", "outcome", "opId", "targetUid", "committedTs"], `lease record ${key}`);
|
|
221
|
+
for (const [name, v] of [["sourceSeq", o.sourceSeq], ["attempt", o.attempt], ["fencingToken", o.fencingToken], ["leaseDeadline", o.leaseDeadline], ["workExpiry", o.workExpiry]])
|
|
222
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0)
|
|
223
|
+
throw new EpEnvelopeError("internal", `lease record ${key} field ${name} is not a safe integer; garbled state never authorizes (SPEC 13.5)`);
|
|
224
|
+
const committed = o.state === "settled" && o.disposition === "committed";
|
|
225
|
+
const expired = o.state === "settled" && o.disposition === "expired";
|
|
226
|
+
const retired = o.state === "settled" && o.disposition === "retired";
|
|
227
|
+
if (o.state === "settled" && !committed && !expired && !retired)
|
|
228
|
+
throw new EpEnvelopeError("internal", `settled lease record ${key} has no valid disposition; garbled state never authorizes (SPEC 13.5)`);
|
|
229
|
+
if (o.state === "leased" && (o.disposition !== undefined || o.outcome !== undefined || o.opId !== undefined || o.targetUid !== undefined || o.committedTs !== undefined))
|
|
230
|
+
throw new EpEnvelopeError("internal", `leased lease record ${key} carries settlement fields; garbled cross-variant state never authorizes (SPEC 13.5)`);
|
|
231
|
+
if ((expired || retired) && o.outcome !== undefined)
|
|
232
|
+
throw new EpEnvelopeError("internal", `${o.disposition} lease record ${key} carries an outcome; garbled cross-variant state never authorizes (SPEC 13.5)`);
|
|
233
|
+
if (retired) {
|
|
234
|
+
if (typeof o.opId !== "string" || typeof o.targetUid !== "string")
|
|
235
|
+
throw new EpEnvelopeError("internal", `retired lease record ${key} carries no op/target binding; garbled state never authorizes (SPEC 13.1)`);
|
|
236
|
+
assertLifecycleToken(o.opId, "retired lease opId");
|
|
237
|
+
assertLifecycleToken(o.targetUid, "retired lease targetUid");
|
|
238
|
+
}
|
|
239
|
+
else if (o.opId !== undefined || o.targetUid !== undefined) {
|
|
240
|
+
throw new EpEnvelopeError("internal", `${String(o.disposition ?? o.state)} lease record ${key} carries retirement fields; garbled cross-variant state never authorizes (SPEC 13.5)`);
|
|
241
|
+
}
|
|
242
|
+
const needsWorker = o.state === "leased" || committed;
|
|
243
|
+
// An EXECUTED coordinate set (any record that carries a worker) is positive across ALL THREE
|
|
244
|
+
// coordinates; only a workerless never-leased expiry/retirement sentinel may carry zeros.
|
|
245
|
+
const executed = needsWorker || o.worker !== undefined;
|
|
246
|
+
if (executed && (o.attempt < 1 || o.sourceSeq < 1 || o.fencingToken < 1))
|
|
247
|
+
throw new EpEnvelopeError("internal", `lease record ${key} is ${o.state}/${String(o.disposition ?? "")} with zero execution coordinates (attempt/sourceSeq/fencingToken); garbled state never authorizes (SPEC 13.5)`);
|
|
248
|
+
if (committed && !("outcome" in o))
|
|
249
|
+
throw new EpEnvelopeError("internal", `committed lease record ${key} carries no outcome; garbled state never authorizes (SPEC 13.5)`);
|
|
250
|
+
if (o.state === "settled" && (typeof o.committedTs !== "number" || !Number.isSafeInteger(o.committedTs) || o.committedTs < 0))
|
|
251
|
+
throw new EpEnvelopeError("internal", `settled lease record ${key} has no valid committedTs; garbled state never authorizes (SPEC 13.5)`);
|
|
252
|
+
const worker = needsWorker || o.worker !== undefined ? parseWorker(o.worker, key) : undefined;
|
|
253
|
+
return {
|
|
254
|
+
v: 1, state: o.state, sourceSeq: o.sourceSeq, attempt: o.attempt,
|
|
255
|
+
...(worker !== undefined ? { worker } : {}),
|
|
256
|
+
fencingToken: o.fencingToken,
|
|
257
|
+
leaseDeadline: o.leaseDeadline, workExpiry: o.workExpiry,
|
|
258
|
+
...(o.state === "settled" ? { disposition: o.disposition, outcome: o.outcome, opId: o.opId, targetUid: o.targetUid, committedTs: o.committedTs } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function decodeJson(value, key) {
|
|
262
|
+
try {
|
|
263
|
+
return JSON.parse(new TextDecoder().decode(value));
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
throw new EpEnvelopeError("internal", `record ${key} does not decode as JSON: ${e.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Issue (or idempotently re-issue) the item's lease for the owner's CURRENT delivery — the
|
|
270
|
+
* reserved `lease` command's handler seam, driven ONLY by the pool-owning endpoint after it
|
|
271
|
+
* fetched the item off its own durable (§13.5).
|
|
272
|
+
*
|
|
273
|
+
* First-wins idempotent CAS per (item, attempt):
|
|
274
|
+
* - no record → create `leased {attempt, worker, fencingToken: 1, leaseDeadline, workExpiry}`;
|
|
275
|
+
* - the recorded attempt EQUALS this delivery → the SAME lease returns unchanged (no
|
|
276
|
+
* reassignment within an attempt; the commit gate binds to the RECORDED worker);
|
|
277
|
+
* - the recorded attempt is OLDER → redelivery advanced: revision-pinned update to the new
|
|
278
|
+
* attempt with `fencingToken + 1`;
|
|
279
|
+
* - the recorded attempt is NEWER → the caller's delivery is stale (`expired`).
|
|
280
|
+
* Refusals before touching state: EXPIRED work (`now >= workExpiry`, settled by reconciliation,
|
|
281
|
+
* never leased) and a SETTLED lease (`state === "settled"` — a committed item can never be
|
|
282
|
+
* leased again, fenced on the SAME key, no cross-store read). `leaseDeadline` is CLAMPED to
|
|
283
|
+
* `workExpiry` so no valid lease outlives the horizon. A DEL marker on the lease refuses. */
|
|
284
|
+
export async function leaseWorkItem(ctx, args) {
|
|
285
|
+
assertCtx(ctx);
|
|
286
|
+
// Snapshot the FULL operation input to detached, validated locals at entry, BEFORE the first
|
|
287
|
+
// await; nothing below reads args again (a live args object mutated across an await must not
|
|
288
|
+
// move any coordinate mid-seam).
|
|
289
|
+
const ref = snapshotRef(args.ref);
|
|
290
|
+
const worker = assertWorker(args.worker, "lease worker");
|
|
291
|
+
const sourceSeq = assertPositiveInt(args.sourceSeq, "sourceSeq"); // positive at ISSUE: a zero here would create a lease parseLease refuses forever
|
|
292
|
+
const now = assertSafeInt(args.now, "now");
|
|
293
|
+
const workExpiry = assertSafeInt(args.workExpiry, "workExpiry");
|
|
294
|
+
const attempt = args.attempt;
|
|
295
|
+
const leaseTtlMs = args.leaseTtlMs;
|
|
296
|
+
if (!Number.isSafeInteger(attempt) || attempt < 1)
|
|
297
|
+
throw new EpEnvelopeError("failed-precondition", `attempt must be a positive delivery count; got ${JSON.stringify(attempt)} (SPEC 13.5)`);
|
|
298
|
+
if (!Number.isSafeInteger(leaseTtlMs) || leaseTtlMs <= 0)
|
|
299
|
+
throw new EpEnvelopeError("failed-precondition", `leaseTtlMs must be a positive integer; got ${JSON.stringify(leaseTtlMs)}`);
|
|
300
|
+
if (now >= workExpiry)
|
|
301
|
+
throw new EpEnvelopeError("expired", `the item's workExpiry (${workExpiry}) has passed at the owner clock (${now}); expired work is settled by reconciliation, never leased (SPEC 13.6/13.8)`);
|
|
302
|
+
const leaseDeadline = Math.min(now + leaseTtlMs, workExpiry); // no valid lease outlives the horizon
|
|
303
|
+
const key = leaseKeyOf(ref);
|
|
304
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
305
|
+
const entry = await ctx.kv.get(key);
|
|
306
|
+
if (!entry || entry.operation !== "PUT") {
|
|
307
|
+
if (entry && entry.operation !== "PUT")
|
|
308
|
+
throw new EpEnvelopeError("failed-precondition", `the lease record ${key} carries a ${entry.operation} marker; a deletion never resets an authoritative lease (SPEC 13.5)`);
|
|
309
|
+
const lease = { v: 1, state: "leased", sourceSeq, attempt, worker, fencingToken: 1, leaseDeadline, workExpiry };
|
|
310
|
+
try {
|
|
311
|
+
await createRecordEntry(ctx.kv, key, lease);
|
|
312
|
+
return lease;
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
316
|
+
continue;
|
|
317
|
+
throw e;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const stored = parseLease(decodeJson(entry.value, key), key);
|
|
321
|
+
// Settlement DOMINATES every binding check: a never-leased expired settlement carries
|
|
322
|
+
// sentinel coordinates (sourceSeq 0), so binding refusals on a settled item would mislead.
|
|
323
|
+
if (stored.state === "settled")
|
|
324
|
+
throw new EpEnvelopeError("failed-precondition", `the item is already settled ${stored.disposition}; a committed item can never be leased again; observe the terminal and ack the redelivery without effect (SPEC 13.5/13.6)`);
|
|
325
|
+
if (stored.sourceSeq !== sourceSeq)
|
|
326
|
+
throw new EpEnvelopeError("conflict", `the lease for this acceptance identity binds stream sequence ${stored.sourceSeq}, not ${sourceSeq}; a request id becomes new work only after workExpiry AND fact retention pass (SPEC 13.8)`);
|
|
327
|
+
// `workExpiry` is IDENTITY-BOUND: it is set once from the AcceptanceFact and every later
|
|
328
|
+
// lease call for the same execution MUST carry the SAME horizon (a redelivery cannot extend
|
|
329
|
+
// or shorten the absolute work expiry, SPEC 13.8).
|
|
330
|
+
if (stored.workExpiry !== workExpiry)
|
|
331
|
+
throw new EpEnvelopeError("conflict", `the lease binds workExpiry ${stored.workExpiry} but this call supplies ${workExpiry}; the absolute work horizon is fixed at acceptance and never re-set (SPEC 13.8)`);
|
|
332
|
+
if (stored.attempt === attempt)
|
|
333
|
+
return stored; // first-wins: the still-current attempt's lease, verbatim
|
|
334
|
+
if (stored.attempt > attempt)
|
|
335
|
+
throw new EpEnvelopeError("expired", `attempt ${attempt} is superseded: redelivery advanced this item to attempt ${stored.attempt} (SPEC 13.5)`);
|
|
336
|
+
const next = { v: 1, state: "leased", sourceSeq, attempt, worker, fencingToken: stored.fencingToken + 1, leaseDeadline, workExpiry };
|
|
337
|
+
try {
|
|
338
|
+
await updateRecordEntry(ctx.kv, key, next, entry.revision);
|
|
339
|
+
return next;
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
343
|
+
continue;
|
|
344
|
+
throw e;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
throw new EpEnvelopeError("conflict", `the lease record ${key} moved twice during one lease call; re-read and re-decide (SPEC 13.8)`);
|
|
348
|
+
}
|
|
349
|
+
/** Validate a terminal fact fully AND bind it to the subject it was read from (§13.4): a garbled
|
|
350
|
+
* or mis-subjected fact never counts as authoritative settlement (which would suppress all
|
|
351
|
+
* future leasing). Exported as the shared codec: the retirement cleaner (§13.1), which holds no
|
|
352
|
+
* pool-owner context, validates the winners it reads through this same seam. */
|
|
353
|
+
export function parseWorkTerminalFact(raw, subject, ref) {
|
|
354
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
355
|
+
throw new EpEnvelopeError("internal", `work terminal fact on ${subject} is not an object; garbled state never authorizes (SPEC 13.6)`);
|
|
356
|
+
const o = raw;
|
|
357
|
+
if (o.v !== 1 || (o.disposition !== "committed" && o.disposition !== "expired" && o.disposition !== "retired"))
|
|
358
|
+
throw new EpEnvelopeError("internal", `work terminal fact on ${subject} has an unknown version/disposition (SPEC 13.6)`);
|
|
359
|
+
if (o.pool !== ref.pool)
|
|
360
|
+
throw new EpEnvelopeError("internal", `work terminal fact on ${subject} names pool ${JSON.stringify(o.pool)}, not ${ref.pool}; a mis-subjected fact never authorizes (SPEC 13.4)`);
|
|
361
|
+
const cr = o.caller;
|
|
362
|
+
if (!cr || typeof cr !== "object" || cr.owner !== ref.acceptance.owner || cr.actor !== ref.acceptance.actor || cr.uid !== ref.acceptance.uid || cr.id !== ref.acceptance.id)
|
|
363
|
+
throw new EpEnvelopeError("internal", `work terminal fact on ${subject} carries a caller identity other than its subject's acceptance identity (SPEC 13.4)`);
|
|
364
|
+
assertClosedKeys(cr, ["owner", "actor", "uid", "id"], `terminal-fact caller on ${subject}`);
|
|
365
|
+
if (typeof o.ts !== "number" || !Number.isSafeInteger(o.ts) || o.ts < 0)
|
|
366
|
+
throw new EpEnvelopeError("internal", `work terminal fact on ${subject} has no valid ts (SPEC 13.6)`);
|
|
367
|
+
if (o.disposition === "committed") {
|
|
368
|
+
assertClosedKeys(o, ["v", "disposition", "pool", "caller", "sourceSeq", "attempt", "fencingToken", "worker", "outcome", "ts"], `committed terminal fact on ${subject}`);
|
|
369
|
+
for (const [n, v] of [["sourceSeq", o.sourceSeq], ["attempt", o.attempt], ["fencingToken", o.fencingToken]])
|
|
370
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 1)
|
|
371
|
+
throw new EpEnvelopeError("internal", `committed terminal fact on ${subject} field ${n} is not a positive safe integer; a committed item was delivered and fenced (SPEC 13.6)`);
|
|
372
|
+
if (!("outcome" in o))
|
|
373
|
+
throw new EpEnvelopeError("internal", `committed terminal fact on ${subject} carries no outcome; garbled state never authorizes (SPEC 13.6)`);
|
|
374
|
+
return {
|
|
375
|
+
v: 1, disposition: "committed", pool: ref.pool, caller: ref.acceptance,
|
|
376
|
+
sourceSeq: o.sourceSeq, attempt: o.attempt, fencingToken: o.fencingToken,
|
|
377
|
+
worker: parseWorker(o.worker, subject), outcome: o.outcome, ts: o.ts,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (o.disposition === "retired") {
|
|
381
|
+
assertClosedKeys(o, ["v", "disposition", "pool", "caller", "opId", "targetUid", "ts"], `retired terminal fact on ${subject}`);
|
|
382
|
+
if (typeof o.opId !== "string" || typeof o.targetUid !== "string")
|
|
383
|
+
throw new EpEnvelopeError("internal", `retired terminal fact on ${subject} carries no op/target binding (SPEC 13.1)`);
|
|
384
|
+
try {
|
|
385
|
+
assertLifecycleToken(o.opId, "retired terminal opId");
|
|
386
|
+
assertLifecycleToken(o.targetUid, "retired terminal targetUid");
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
throw new EpEnvelopeError("internal", `retired terminal fact on ${subject} carries a malformed op/target binding: ${e.message} (SPEC 13.1)`);
|
|
390
|
+
}
|
|
391
|
+
return { v: 1, disposition: "retired", pool: ref.pool, caller: ref.acceptance, opId: o.opId, targetUid: o.targetUid, ts: o.ts };
|
|
392
|
+
}
|
|
393
|
+
assertClosedKeys(o, ["v", "disposition", "pool", "caller", "workExpiry", "ts"], `expired terminal fact on ${subject}`);
|
|
394
|
+
if (typeof o.workExpiry !== "number" || !Number.isSafeInteger(o.workExpiry) || o.workExpiry < 0)
|
|
395
|
+
throw new EpEnvelopeError("internal", `expired terminal fact on ${subject} has no valid workExpiry (SPEC 13.6)`);
|
|
396
|
+
return { v: 1, disposition: "expired", pool: ref.pool, caller: ref.acceptance, workExpiry: o.workExpiry, ts: o.ts };
|
|
397
|
+
}
|
|
398
|
+
/** Read the item's cached terminal state (leader-served last-by-subject: the CAS-loser read
|
|
399
|
+
* needs read-your-writes, §13.4). `undefined` when the item has no terminal yet. */
|
|
400
|
+
export async function readWorkTerminal(ctx, itemRef) {
|
|
401
|
+
assertCtx(ctx);
|
|
402
|
+
const ref = snapshotRef(itemRef);
|
|
403
|
+
const subject = workTerminalSubject(ctx.space, ref);
|
|
404
|
+
const raw = await readLastFact(ctx.jsm, epfStreamName(ctx.space), subject);
|
|
405
|
+
return raw === undefined ? undefined : parseWorkTerminalFact(raw, subject, ref);
|
|
406
|
+
}
|
|
407
|
+
/** Build the terminal fact a settled lease derives. */
|
|
408
|
+
function terminalOf(ref, lease) {
|
|
409
|
+
if (lease.disposition === "committed") {
|
|
410
|
+
if (lease.worker === undefined || lease.committedTs === undefined)
|
|
411
|
+
throw new EpEnvelopeError("internal", `a committed lease for ${ref.acceptance.id} is missing its worker/committedTs; garbled state never authorizes (SPEC 13.5)`);
|
|
412
|
+
return { v: 1, disposition: "committed", pool: ref.pool, caller: ref.acceptance, sourceSeq: lease.sourceSeq, attempt: lease.attempt, fencingToken: lease.fencingToken, worker: lease.worker, outcome: lease.outcome, ts: lease.committedTs };
|
|
413
|
+
}
|
|
414
|
+
if (lease.disposition === "retired") {
|
|
415
|
+
if (lease.opId === undefined || lease.targetUid === undefined || lease.committedTs === undefined)
|
|
416
|
+
throw new EpEnvelopeError("internal", `a retired lease for ${ref.acceptance.id} is missing its op/target/committedTs; garbled state never authorizes (SPEC 13.1)`);
|
|
417
|
+
return { v: 1, disposition: "retired", pool: ref.pool, caller: ref.acceptance, opId: lease.opId, targetUid: lease.targetUid, ts: lease.committedTs };
|
|
418
|
+
}
|
|
419
|
+
return { v: 1, disposition: "expired", pool: ref.pool, caller: ref.acceptance, workExpiry: lease.workExpiry, ts: lease.committedTs ?? 0 };
|
|
420
|
+
}
|
|
421
|
+
/** Publish a settled lease's terminal fact create-only. A lost CAS reads the winner and PROVES
|
|
422
|
+
* it canonically equals the fact this caller derived from the authoritative settled lease —
|
|
423
|
+
* every legitimate publisher derives from that same lease, so a mismatch is a foreign/garbled
|
|
424
|
+
* write and fails loud instead of being returned as "the winner". */
|
|
425
|
+
async function publishTerminal(ctx, ref, fact) {
|
|
426
|
+
const subject = workTerminalSubject(ctx.space, ref);
|
|
427
|
+
const res = await publishCreateOnly(ctx.js, subject, new TextEncoder().encode(JSON.stringify(fact)));
|
|
428
|
+
if (res.won)
|
|
429
|
+
return { won: true, fact };
|
|
430
|
+
const winner = await readWorkTerminal(ctx, ref);
|
|
431
|
+
if (winner === undefined)
|
|
432
|
+
throw new EpEnvelopeError("internal", `the terminal CAS for ${subject} was lost but no winning fact is readable (SPEC 13.4)`);
|
|
433
|
+
if (canonicalJson(winner) !== canonicalJson(fact))
|
|
434
|
+
throw new EpEnvelopeError("internal", `the terminal CAS winner on ${subject} does not match the fact derived from the authoritative settled lease; a foreign/garbled terminal never authorizes (SPEC 13.4)`);
|
|
435
|
+
return { won: false, fact: winner };
|
|
436
|
+
}
|
|
437
|
+
/** Settle a claimed item — the reserved `commit` command's handler seam, driven ONLY by the
|
|
438
|
+
* pool-owning endpoint on behalf of the broker-authenticated commit caller (§13.5). Gate order
|
|
439
|
+
* against the OWNER-RECORDED lease and the owner's clock: execution binding (sourceSeq) →
|
|
440
|
+
* token currency (attempt + fencingToken) → the caller IS the lease's bound worker → FRESH
|
|
441
|
+
* epoch currency for an endpoint worker → `now < workExpiry` → `now < leaseDeadline`. Then the
|
|
442
|
+
* FENCE: a revision-pinned CAS advances the lease `leased → settled{committed, outcome}` — the
|
|
443
|
+
* SAME key a redelivery-advance contends on, so a stale attempt cannot slip a commit in after
|
|
444
|
+
* reassignment. Only after winning that CAS is the terminal fact (derived from the settled
|
|
445
|
+
* lease) published. A lost lease CAS means the lease advanced or settled concurrently: a
|
|
446
|
+
* same-tuple settle is a DUPLICATE (return the cached terminal, which DOMINATES lease-expiry —
|
|
447
|
+
* a true duplicate always sees its cached outcome, §13.5); anything else is `expired`/`conflict`.
|
|
448
|
+
*
|
|
449
|
+
* `resolveCurrentEpoch` freshly resolves an endpoint worker's CURRENT process epoch from trusted
|
|
450
|
+
* authority (the lifecycle mapping) — a required seam for endpoint workers, absent for agents. */
|
|
451
|
+
export async function commitWorkItem(ctx, args) {
|
|
452
|
+
assertCtx(ctx);
|
|
453
|
+
// Snapshot EVERY operation input at entry, BEFORE the first await: a caller-shared mutable
|
|
454
|
+
// ref/tuple/outcome/clock/resolver can otherwise split one commit's identity across the lease
|
|
455
|
+
// CAS and the terminal publish (settle item A, publish item B) or move the validated clock.
|
|
456
|
+
const ref = snapshotRef(args.ref);
|
|
457
|
+
// Read the lease reference ONCE into a local, then validate/copy its fields from that one
|
|
458
|
+
// reference: a shifting `args.lease` getter must not splice three separately-read fields into
|
|
459
|
+
// a composite tuple no single input ever carried.
|
|
460
|
+
const leaseArg = args.lease;
|
|
461
|
+
const tuple = {
|
|
462
|
+
sourceSeq: assertPositiveInt(leaseArg?.sourceSeq, "lease.sourceSeq"),
|
|
463
|
+
attempt: assertPositiveInt(leaseArg?.attempt, "lease.attempt"),
|
|
464
|
+
fencingToken: assertPositiveInt(leaseArg?.fencingToken, "lease.fencingToken"),
|
|
465
|
+
};
|
|
466
|
+
const outcomeSnapshot = JSON.parse(canonicalJson(args.outcome === undefined ? null : args.outcome));
|
|
467
|
+
const now = assertSafeInt(args.now, "now");
|
|
468
|
+
const caller = assertWorker(args.caller, "commit caller");
|
|
469
|
+
const resolveCurrentEpoch = args.resolveCurrentEpoch;
|
|
470
|
+
const epochResolveBudgetMs = args.epochResolveBudgetMs;
|
|
471
|
+
const key = leaseKeyOf(ref);
|
|
472
|
+
const entry = await ctx.kv.get(key);
|
|
473
|
+
if (!entry || entry.operation !== "PUT")
|
|
474
|
+
throw new EpEnvelopeError("failed-precondition", `no lease is recorded for this item (${key}); a commit settles only owner-assigned work (SPEC 13.5)`);
|
|
475
|
+
const stored = parseLease(decodeJson(entry.value, key), key);
|
|
476
|
+
// A DUPLICATE (same worker, same lease tuple) always observes its cached terminal — the cache
|
|
477
|
+
// lookup DOMINATES lease-expiry, so a slow-but-legitimate retry after its own commit never
|
|
478
|
+
// sees `expired` (§13.5:1517-1519). A stale/foreign caller does NOT get here (identity + tuple
|
|
479
|
+
// must match the settled lease).
|
|
480
|
+
if (stored.state === "settled") {
|
|
481
|
+
const dup = stored.sourceSeq === tuple.sourceSeq && stored.attempt === tuple.attempt
|
|
482
|
+
&& stored.fencingToken === tuple.fencingToken && sameWorker(stored.worker, caller);
|
|
483
|
+
if (dup)
|
|
484
|
+
return publishTerminal(ctx, ref, terminalOf(ref, stored)); // idempotent republish; won:false once it already exists
|
|
485
|
+
throw new EpEnvelopeError("conflict", `the item is already settled ${stored.disposition} under a different attempt/worker; this commit is superseded (SPEC 13.5)`);
|
|
486
|
+
}
|
|
487
|
+
// parseLease guarantees a `leased` record carries its worker; narrow for the checks below.
|
|
488
|
+
const bound = stored.worker;
|
|
489
|
+
if (bound === undefined)
|
|
490
|
+
throw new EpEnvelopeError("internal", `leased record ${key} has no worker; garbled state never authorizes (SPEC 13.5)`);
|
|
491
|
+
if (stored.sourceSeq !== tuple.sourceSeq)
|
|
492
|
+
throw new EpEnvelopeError("expired", `the commit names stream sequence ${tuple.sourceSeq} but the lease binds ${stored.sourceSeq}; a stale execution binding never settles (SPEC 13.5)`);
|
|
493
|
+
if (stored.attempt !== tuple.attempt || stored.fencingToken !== tuple.fencingToken)
|
|
494
|
+
throw new EpEnvelopeError("expired", `stale fencing: the commit carries (attempt ${tuple.attempt}, token ${tuple.fencingToken}) but the lease is (attempt ${stored.attempt}, token ${stored.fencingToken}) (SPEC 13.5)`);
|
|
495
|
+
if (!sameWorker(bound, caller))
|
|
496
|
+
throw new EpEnvelopeError("permission-denied", `the commit caller is not the lease's bound worker (${bound.owner}.${bound.actor}/${bound.lifecycleUid}); the binding is owner-recorded at assignment (SPEC 13.5)`);
|
|
497
|
+
// FRESH lifecycle/epoch currency for an endpoint worker (§13.8): a superseded process cannot
|
|
498
|
+
// settle its predecessor's lease.
|
|
499
|
+
if (bound.kind === "endpoint") {
|
|
500
|
+
if (typeof resolveCurrentEpoch !== "function")
|
|
501
|
+
throw new EpEnvelopeError("failed-precondition", `committing an endpoint worker's item requires a fresh-epoch resolver (SPEC 13.8: lifecycle/epoch currency is validated at the commit boundary)`);
|
|
502
|
+
const budget = epochResolveBudgetMs ?? 5_000;
|
|
503
|
+
if (!Number.isSafeInteger(budget) || budget <= 0)
|
|
504
|
+
throw new EpEnvelopeError("failed-precondition", `epochResolveBudgetMs must be a positive integer; got ${JSON.stringify(epochResolveBudgetMs)}`);
|
|
505
|
+
const current = await resolveWithBudget(resolveCurrentEpoch(bound), budget);
|
|
506
|
+
if (current !== null && (typeof current !== "number" || !Number.isSafeInteger(current) || current < 0))
|
|
507
|
+
throw new EpEnvelopeError("internal", `the fresh-epoch resolver returned ${JSON.stringify(current)}; a non-integer epoch never authorizes (SPEC 13.8)`);
|
|
508
|
+
if (current === null)
|
|
509
|
+
throw new EpEnvelopeError("expired", `the endpoint worker's lifecycle is retired/unknown; a retired worker cannot commit (SPEC 13.8)`);
|
|
510
|
+
if (current !== bound.epoch)
|
|
511
|
+
throw new EpEnvelopeError("expired", `the endpoint worker's lease bound epoch ${bound.epoch} but the current process epoch is ${current}; a superseded process cannot settle (SPEC 13.8)`);
|
|
512
|
+
}
|
|
513
|
+
if (now >= stored.workExpiry)
|
|
514
|
+
throw new EpEnvelopeError("expired", `the item's workExpiry (${stored.workExpiry}) has passed at the owner clock (${now}); the item is dead, leased or not (SPEC 13.8)`);
|
|
515
|
+
if (now >= stored.leaseDeadline)
|
|
516
|
+
throw new EpEnvelopeError("expired", `the lease expired at ${stored.leaseDeadline} (owner clock ${now}); expiry revokes the claim AT that deadline (SPEC 13.5)`);
|
|
517
|
+
// THE FENCE: advance the lease to settled on its OWN revision. A concurrent redelivery-advance
|
|
518
|
+
// or expiry-settle contends on this exact revision; the loser re-reads and re-decides.
|
|
519
|
+
const settled = { ...stored, state: "settled", disposition: "committed", outcome: outcomeSnapshot, committedTs: now };
|
|
520
|
+
try {
|
|
521
|
+
await updateRecordEntry(ctx.kv, key, settled, entry.revision);
|
|
522
|
+
}
|
|
523
|
+
catch (e) {
|
|
524
|
+
if (!(e instanceof EpEnvelopeError && e.code === "conflict"))
|
|
525
|
+
throw e;
|
|
526
|
+
const now2 = parseLease(decodeJson((await ctx.kv.get(key)).value, key), key);
|
|
527
|
+
if (now2.state === "settled" && now2.sourceSeq === tuple.sourceSeq && now2.attempt === tuple.attempt && sameWorker(now2.worker, caller))
|
|
528
|
+
return publishTerminal(ctx, ref, terminalOf(ref, now2)); // our own settle raced in; idempotent
|
|
529
|
+
if (now2.state === "settled")
|
|
530
|
+
throw new EpEnvelopeError("conflict", `the item settled ${now2.disposition} concurrently under a different attempt/worker; this commit is superseded (SPEC 13.5)`);
|
|
531
|
+
throw new EpEnvelopeError("expired", `a concurrent redelivery advanced the lease to attempt ${now2.attempt}; this commit is superseded (SPEC 13.5)`);
|
|
532
|
+
}
|
|
533
|
+
return publishTerminal(ctx, ref, terminalOf(ref, settled));
|
|
534
|
+
}
|
|
535
|
+
/** Bound the fresh-epoch resolver await: past the budget the commit REFUSES `unavailable`
|
|
536
|
+
* (fail-closed, retryable) instead of hanging on a stuck lifecycle authority. */
|
|
537
|
+
async function resolveWithBudget(p, budgetMs) {
|
|
538
|
+
// race Promise.resolve(p) UNCONDITIONALLY: a non-native/cross-realm thenable is not an
|
|
539
|
+
// `instanceof Promise`, and returning it directly would await it unbounded.
|
|
540
|
+
let timer;
|
|
541
|
+
const deadline = new Promise((_, reject) => {
|
|
542
|
+
timer = setTimeout(() => reject(new EpEnvelopeError("unavailable", `the fresh-epoch resolver did not answer within ${budgetMs}ms; a stuck lifecycle authority is a bounded refusal, never a hung commit (SPEC 13.8)`)), budgetMs);
|
|
543
|
+
});
|
|
544
|
+
try {
|
|
545
|
+
return await Promise.race([Promise.resolve(p), deadline]);
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
clearTimeout(timer);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async function publishCreateOnly(js, subject, bytes) {
|
|
552
|
+
const h = natsHeaders();
|
|
553
|
+
h.set("Nats-Expected-Last-Subject-Sequence", "0");
|
|
554
|
+
try {
|
|
555
|
+
await js.publish(subject, bytes, { headers: h });
|
|
556
|
+
return { won: true };
|
|
557
|
+
}
|
|
558
|
+
catch (e) {
|
|
559
|
+
const code = e?.code;
|
|
560
|
+
if (code === 10071 || code === 10164)
|
|
561
|
+
return { won: false };
|
|
562
|
+
throw e;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** Decide and repair one item against the §13.6 predicate — the canonicalizer's reconciliation
|
|
566
|
+
* seam (§13.9 row), for an ACCEPTED pool-routed item. The LEASE is consulted as the settlement
|
|
567
|
+
* arbiter so reconciliation never contradicts a committing worker:
|
|
568
|
+
* 1. a terminal `wrk` fact exists → SETTLED (the owner acks without effect);
|
|
569
|
+
* 2. the lease is `settled` (a commit fenced it, maybe crashed before publishing) → publish
|
|
570
|
+
* the derived terminal (idempotent) → SETTLED — recovery, never re-enqueued;
|
|
571
|
+
* 3. `now >= workExpiry` → the item is DEAD, leased or not: fence it by CAS-settling the lease
|
|
572
|
+
* `expired` (racing a live commit on the SAME key; a lost CAS re-reads the winner), then
|
|
573
|
+
* publish the derived terminal → EXPIRED-SETTLED; with no lease record, a worker-less
|
|
574
|
+
* `settled:expired` lease is CAS-CREATED on the same key first, so a racing FIRST lease
|
|
575
|
+
* contends there instead of assigning dead work behind the expiry;
|
|
576
|
+
* 4. a live pool entry exists (subject-confined LEADER-SERVED STREAM.MSG.GET, §13.6:1797-1799 —
|
|
577
|
+
* a fencing read whose stale follower miss would re-arm settled work) → LIVE;
|
|
578
|
+
* 5. else re-check the terminal (a commit may have landed since step 1), then re-enqueue the
|
|
579
|
+
* SAME acceptance-derived bytes create-only — the ONLY re-enqueueable state.
|
|
580
|
+
* Fail-closed preconditions: a DEL/PURGE marker on the lease REFUSES before any classification
|
|
581
|
+
* (reconciling over a deletion could recreate authoritative state), and whenever a lease record
|
|
582
|
+
* exists the caller-supplied `workExpiry` must EQUAL the persisted horizon (§13.8) — a mis-wired
|
|
583
|
+
* reconciliation never expires a live lease against a foreign horizon. */
|
|
584
|
+
export async function reconcileWorkItem(ctx, args) {
|
|
585
|
+
assertCtx(ctx);
|
|
586
|
+
// Snapshot the FULL operation input at entry (ref, clock, horizon, and a DETACHED copy of the
|
|
587
|
+
// item bytes): nothing below reads args again, so a mid-flight mutation cannot re-enqueue
|
|
588
|
+
// different bytes or move the horizon between classification and repair.
|
|
589
|
+
const ref = snapshotRef(args.ref);
|
|
590
|
+
const now = assertSafeInt(args.now, "now");
|
|
591
|
+
const workExpiry = assertSafeInt(args.workExpiry, "workExpiry");
|
|
592
|
+
if (!(args.itemBytes instanceof Uint8Array))
|
|
593
|
+
throw new EpEnvelopeError("failed-precondition", `itemBytes must be a Uint8Array (the acceptance-derived stored bytes, SPEC 13.6)`);
|
|
594
|
+
const itemBytes = new Uint8Array(args.itemBytes); // real copy (NOT .slice(): a Node Buffer aliases through .slice(), defeating the detachment; new Uint8Array(...) always copies)
|
|
595
|
+
const key = leaseKeyOf(ref);
|
|
596
|
+
// A DEL/PURGE marker on the lease is REFUSED before any classification (same fail-closed rule
|
|
597
|
+
// as lease/commit): reconciling over a deletion could recreate authoritative state.
|
|
598
|
+
const readLease = (entry) => {
|
|
599
|
+
if (!entry)
|
|
600
|
+
return undefined;
|
|
601
|
+
if (entry.operation !== "PUT")
|
|
602
|
+
throw new EpEnvelopeError("failed-precondition", `the lease record ${key} carries a ${entry.operation} marker; a deletion never resets an authoritative lease - reconcile the store (SPEC 13.5)`);
|
|
603
|
+
const rec = parseLease(decodeJson(entry.value, key), key);
|
|
604
|
+
// The horizon is IDENTITY-BOUND (§13.8): whenever a record exists, the caller-supplied
|
|
605
|
+
// workExpiry must equal the persisted one before ANY classification - a mis-wired
|
|
606
|
+
// reconciliation must not expire a live lease early (or late) against a foreign horizon.
|
|
607
|
+
if (rec.workExpiry !== workExpiry)
|
|
608
|
+
throw new EpEnvelopeError("conflict", `the lease binds workExpiry ${rec.workExpiry} but this reconcile supplies ${workExpiry}; the absolute work horizon is fixed at acceptance and never re-set (SPEC 13.8)`);
|
|
609
|
+
return rec;
|
|
610
|
+
};
|
|
611
|
+
const terminal = await readWorkTerminal(ctx, ref);
|
|
612
|
+
if (terminal !== undefined)
|
|
613
|
+
return { state: terminal.disposition === "expired" ? "expired-settled" : "settled", fact: terminal };
|
|
614
|
+
const leaseEntry = await ctx.kv.get(key);
|
|
615
|
+
const lease = readLease(leaseEntry);
|
|
616
|
+
// (2) a commit fenced the lease but may have crashed before publishing — finalize its terminal.
|
|
617
|
+
if (lease?.state === "settled") {
|
|
618
|
+
const pub = await publishTerminal(ctx, ref, terminalOf(ref, lease));
|
|
619
|
+
return { state: pub.fact.disposition === "expired" ? "expired-settled" : "settled", fact: pub.fact };
|
|
620
|
+
}
|
|
621
|
+
// (3) dead item: the LEASE KEY is the arbiter either way. With a lease, CAS-settle it expired
|
|
622
|
+
// on its revision (a live commit contends on the SAME key); with NO lease, CAS-CREATE a
|
|
623
|
+
// worker-less settled:expired record create-only, so a racing FIRST lease loses its create and
|
|
624
|
+
// observes the settlement instead of assigning dead work. Only a settled lease derives the
|
|
625
|
+
// terminal — the EPF fact never leads the lease.
|
|
626
|
+
if (now >= workExpiry) {
|
|
627
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
628
|
+
const entry = pass === 0 ? leaseEntry : await ctx.kv.get(key);
|
|
629
|
+
const cur = readLease(entry);
|
|
630
|
+
if (cur?.state === "settled") { // a commit/expiry won; finalize the winner's terminal
|
|
631
|
+
const pub = await publishTerminal(ctx, ref, terminalOf(ref, cur));
|
|
632
|
+
return { state: pub.fact.disposition === "expired" ? "expired-settled" : "settled", fact: pub.fact };
|
|
633
|
+
}
|
|
634
|
+
const settledExpired = cur !== undefined
|
|
635
|
+
? { ...cur, state: "settled", disposition: "expired", committedTs: now }
|
|
636
|
+
: { v: 1, state: "settled", sourceSeq: 0, attempt: 0, fencingToken: 0, leaseDeadline: workExpiry, workExpiry, disposition: "expired", committedTs: now };
|
|
637
|
+
try {
|
|
638
|
+
if (cur !== undefined)
|
|
639
|
+
await updateRecordEntry(ctx.kv, key, settledExpired, entry.revision);
|
|
640
|
+
else
|
|
641
|
+
await createRecordEntry(ctx.kv, key, settledExpired);
|
|
642
|
+
}
|
|
643
|
+
catch (e) {
|
|
644
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
645
|
+
continue; // a lease/commit raced in on the same key; re-read and re-decide
|
|
646
|
+
throw e;
|
|
647
|
+
}
|
|
648
|
+
const pub = await publishTerminal(ctx, ref, terminalOf(ref, settledExpired));
|
|
649
|
+
return { state: "expired-settled", fact: pub.fact };
|
|
650
|
+
}
|
|
651
|
+
throw new EpEnvelopeError("conflict", `the lease record ${key} moved twice during one reconcile; re-read and re-decide (SPEC 13.8)`);
|
|
652
|
+
}
|
|
653
|
+
if (await liveEntryExists(ctx, ref))
|
|
654
|
+
return { state: "live" };
|
|
655
|
+
// (5) re-check the terminal (a commit may have landed during the live probe) before re-enqueue.
|
|
656
|
+
const late = await readWorkTerminal(ctx, ref);
|
|
657
|
+
if (late !== undefined)
|
|
658
|
+
return { state: late.disposition === "expired" ? "expired-settled" : "settled", fact: late };
|
|
659
|
+
const re = await enqueueWorkItem(ctx, ref, itemBytes);
|
|
660
|
+
if (!re.enqueued) {
|
|
661
|
+
if (await liveEntryExists(ctx, ref))
|
|
662
|
+
return { state: "live" };
|
|
663
|
+
const after = await readWorkTerminal(ctx, ref);
|
|
664
|
+
if (after !== undefined)
|
|
665
|
+
return { state: after.disposition === "expired" ? "expired-settled" : "settled", fact: after };
|
|
666
|
+
throw new EpEnvelopeError("failed-precondition", `the item is not settled, not expired, not live, and its create-only re-enqueue is refused by the stream's per-subject history; needs operator reconciliation (SPEC 13.6)`);
|
|
667
|
+
}
|
|
668
|
+
return { state: "re-enqueued", seq: re.seq };
|
|
669
|
+
}
|
|
670
|
+
/** Settle a still-live pool item as `retired` for the §13.1 exact-pool cleaner. Unlike ordinary
|
|
671
|
+
* expiry reconciliation, retirement is target-bound and may settle unexpired work, but it still
|
|
672
|
+
* uses the lease key as the single arbiter: a racing commit, lease advance, or cleaner settlement
|
|
673
|
+
* all contend on this revision before any terminal fact is published. */
|
|
674
|
+
export async function retireWorkItem(ctx, args) {
|
|
675
|
+
assertCtx(ctx);
|
|
676
|
+
const ref = snapshotRef(args.ref);
|
|
677
|
+
const workExpiry = assertSafeInt(args.workExpiry, "workExpiry");
|
|
678
|
+
const opId = assertLifecycleToken(args.opId, "retirement opId");
|
|
679
|
+
const targetUid = assertLifecycleToken(args.targetUid, "retirement targetUid");
|
|
680
|
+
const now = assertSafeInt(args.now, "now");
|
|
681
|
+
const terminal = await readWorkTerminal(ctx, ref);
|
|
682
|
+
if (terminal !== undefined)
|
|
683
|
+
return { won: false, fact: terminal };
|
|
684
|
+
const key = leaseKeyOf(ref);
|
|
685
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
686
|
+
const entry = await ctx.kv.get(key);
|
|
687
|
+
if (entry !== undefined && entry !== null && entry.operation !== "PUT")
|
|
688
|
+
throw new EpEnvelopeError("failed-precondition", `the lease record ${key} carries a ${entry.operation} marker; a deletion never resets an authoritative lease (SPEC 13.5)`);
|
|
689
|
+
const cur = entry === undefined || entry === null ? undefined : parseLease(decodeJson(entry.value, key), key);
|
|
690
|
+
if (cur?.state === "settled")
|
|
691
|
+
return publishTerminal(ctx, ref, terminalOf(ref, cur));
|
|
692
|
+
if (cur !== undefined && cur.workExpiry !== workExpiry)
|
|
693
|
+
throw new EpEnvelopeError("conflict", `the lease binds workExpiry ${cur.workExpiry} but retirement supplies ${workExpiry}; the absolute work horizon is fixed at acceptance and never re-set (SPEC 13.8)`);
|
|
694
|
+
const retired = cur !== undefined
|
|
695
|
+
? { ...cur, state: "settled", disposition: "retired", opId, targetUid, committedTs: now }
|
|
696
|
+
: { v: 1, state: "settled", sourceSeq: 0, attempt: 0, fencingToken: 0, leaseDeadline: workExpiry, workExpiry, disposition: "retired", opId, targetUid, committedTs: now };
|
|
697
|
+
try {
|
|
698
|
+
if (cur !== undefined)
|
|
699
|
+
await updateRecordEntry(ctx.kv, key, retired, entry.revision);
|
|
700
|
+
else
|
|
701
|
+
await createRecordEntry(ctx.kv, key, retired);
|
|
702
|
+
}
|
|
703
|
+
catch (e) {
|
|
704
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
705
|
+
continue;
|
|
706
|
+
throw e;
|
|
707
|
+
}
|
|
708
|
+
return publishTerminal(ctx, ref, terminalOf(ref, retired));
|
|
709
|
+
}
|
|
710
|
+
throw new EpEnvelopeError("conflict", `the lease record ${key} moved twice during one retirement settle; re-read and re-decide (SPEC 13.8)`);
|
|
711
|
+
}
|
|
712
|
+
/** The §13.6 liveness read: the subject-confined LEADER-SERVED last-by-subject probe on the
|
|
713
|
+
* item's own subject (an acked item has LEFT the WorkQueue; an in-flight one remains readable).
|
|
714
|
+
* STREAM.MSG.GET, never a follower Direct Get (SPEC 13.6:1797-1799): the result gates the
|
|
715
|
+
* re-enqueue decision, so a stale follower miss would re-arm settled work. ONLY the broker's
|
|
716
|
+
* no-message result is absence; every other failure is `unavailable`, never fabricated as "no
|
|
717
|
+
* live entry" (which would drive an incorrect re-enqueue). */
|
|
718
|
+
async function liveEntryExists(ctx, ref) {
|
|
719
|
+
try {
|
|
720
|
+
return (await ctx.jsm.streams.getMessage(epwStreamName(ctx.space), { last_by_subj: workItemSubject(ctx.space, ref) })) !== null;
|
|
721
|
+
}
|
|
722
|
+
catch (e) {
|
|
723
|
+
if (isNoMessage(e))
|
|
724
|
+
return false;
|
|
725
|
+
throw new EpEnvelopeError("unavailable", `the reconciliation liveness probe failed (a failed observation is never absence, SPEC 13.6): ${e?.message ?? String(e)}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
//# sourceMappingURL=endpoint-work.js.map
|