@cotal-ai/core 0.85.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +10 -3
- package/dist/agent-file.js.map +1 -1
- package/dist/base64url.d.ts +5 -0
- package/dist/base64url.d.ts.map +1 -0
- package/dist/base64url.js +14 -0
- package/dist/base64url.js.map +1 -0
- package/dist/browser-control.d.ts +149 -0
- package/dist/browser-control.d.ts.map +1 -0
- package/dist/browser-control.js +167 -0
- package/dist/browser-control.js.map +1 -0
- package/dist/browser-validators-build.d.ts +23 -0
- package/dist/browser-validators-build.d.ts.map +1 -0
- package/dist/browser-validators-build.js +149 -0
- package/dist/browser-validators-build.js.map +1 -0
- package/dist/canonical-json.d.ts +14 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +121 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/canonical.d.ts +2 -10
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +2 -108
- package/dist/canonical.js.map +1 -1
- package/dist/channels.d.ts +4 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +6 -2
- package/dist/channels.js.map +1 -1
- package/dist/checkpoint-answer.d.ts.map +1 -1
- package/dist/checkpoint-answer.js +3 -4
- package/dist/checkpoint-answer.js.map +1 -1
- package/dist/connector-setup.d.ts +10 -1
- package/dist/connector-setup.d.ts.map +1 -1
- package/dist/digest-id.d.ts +4 -0
- package/dist/digest-id.d.ts.map +1 -0
- package/dist/digest-id.js +7 -0
- package/dist/digest-id.js.map +1 -0
- package/dist/endpoint-action.d.ts +2 -2
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +6 -44
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-auth-digest.d.ts +7 -0
- package/dist/endpoint-auth-digest.d.ts.map +1 -0
- package/dist/endpoint-auth-digest.js +13 -0
- package/dist/endpoint-auth-digest.js.map +1 -0
- package/dist/endpoint-call.d.ts +176 -0
- package/dist/endpoint-call.d.ts.map +1 -0
- package/dist/endpoint-call.js +490 -0
- package/dist/endpoint-call.js.map +1 -0
- package/dist/endpoint-caller.d.ts +113 -0
- package/dist/endpoint-caller.d.ts.map +1 -0
- package/dist/endpoint-caller.js +260 -0
- package/dist/endpoint-caller.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -1
- package/dist/endpoint-checkpoint.js +5 -14
- package/dist/endpoint-checkpoint.js.map +1 -1
- package/dist/endpoint-envelope.d.ts +0 -6
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +17 -18
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-goal-follow.d.ts +37 -0
- package/dist/endpoint-goal-follow.d.ts.map +1 -0
- package/dist/endpoint-goal-follow.js +328 -0
- package/dist/endpoint-goal-follow.js.map +1 -0
- package/dist/endpoint-grants.d.ts +0 -4
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +1 -7
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-guard.d.ts.map +1 -1
- package/dist/endpoint-guard.js +2 -10
- package/dist/endpoint-guard.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +37 -143
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +60 -605
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-publish-denial.d.ts +1 -1
- package/dist/endpoint-publish-denial.d.ts.map +1 -1
- package/dist/endpoint-publish-denial.js +1 -1
- package/dist/endpoint-publish-denial.js.map +1 -1
- package/dist/endpoint-receipt.d.ts.map +1 -1
- package/dist/endpoint-receipt.js +2 -16
- package/dist/endpoint-receipt.js.map +1 -1
- package/dist/endpoint-signing.d.ts +1 -1
- package/dist/endpoint-signing.d.ts.map +1 -1
- package/dist/endpoint-signing.js +1 -1
- package/dist/endpoint-signing.js.map +1 -1
- package/dist/endpoint-subjects.d.ts +4 -0
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +6 -0
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint-traits.d.ts.map +1 -1
- package/dist/endpoint-traits.js +3 -20
- package/dist/endpoint-traits.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +18 -151
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +4 -500
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint-work.d.ts.map +1 -1
- package/dist/endpoint-work.js +2 -17
- package/dist/endpoint-work.js.map +1 -1
- package/dist/endpoint.d.ts +113 -24
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +330 -78
- package/dist/endpoint.js.map +1 -1
- package/dist/goal-result-fact.d.ts +13 -0
- package/dist/goal-result-fact.d.ts.map +1 -0
- package/dist/goal-result-fact.js +40 -0
- package/dist/goal-result-fact.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/issued-accepted.d.ts +27 -0
- package/dist/issued-accepted.d.ts.map +1 -0
- package/dist/issued-accepted.js +62 -0
- package/dist/issued-accepted.js.map +1 -0
- package/dist/issued-authority.d.ts +3 -18
- package/dist/issued-authority.d.ts.map +1 -1
- package/dist/issued-authority.js +4 -55
- package/dist/issued-authority.js.map +1 -1
- package/dist/jetstream-missing.d.ts +4 -0
- package/dist/jetstream-missing.d.ts.map +1 -1
- package/dist/jetstream-missing.js +6 -0
- package/dist/jetstream-missing.js.map +1 -1
- package/dist/kv-scan.d.ts +15 -0
- package/dist/kv-scan.d.ts.map +1 -1
- package/dist/kv-scan.js +32 -0
- package/dist/kv-scan.js.map +1 -1
- package/dist/launch-material.d.ts.map +1 -1
- package/dist/launch-material.js +6 -2
- package/dist/launch-material.js.map +1 -1
- package/dist/lease.d.ts +50 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js +71 -0
- package/dist/lease.js.map +1 -1
- package/dist/lifecycle-state.d.ts +3 -0
- package/dist/lifecycle-state.d.ts.map +1 -1
- package/dist/lifecycle-state.js +7 -0
- package/dist/lifecycle-state.js.map +1 -1
- package/dist/manager-caller.d.ts.map +1 -1
- package/dist/manager-caller.js +2 -1
- package/dist/manager-caller.js.map +1 -1
- package/dist/one-line.d.ts +6 -0
- package/dist/one-line.d.ts.map +1 -0
- package/dist/one-line.js +8 -0
- package/dist/one-line.js.map +1 -0
- package/dist/peer-label.d.ts +16 -0
- package/dist/peer-label.d.ts.map +1 -0
- package/dist/peer-label.js +15 -0
- package/dist/peer-label.js.map +1 -0
- package/dist/permission-denied.d.ts +8 -0
- package/dist/permission-denied.d.ts.map +1 -0
- package/dist/permission-denied.js +15 -0
- package/dist/permission-denied.js.map +1 -0
- package/dist/presence-text.d.ts +6 -0
- package/dist/presence-text.d.ts.map +1 -0
- package/dist/presence-text.js +25 -0
- package/dist/presence-text.js.map +1 -0
- package/dist/private-tool-results.d.ts +253 -0
- package/dist/private-tool-results.d.ts.map +1 -0
- package/dist/private-tool-results.js +141 -0
- package/dist/private-tool-results.js.map +1 -0
- package/dist/provision.d.ts +5 -0
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +40 -16
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +17 -0
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +40 -1
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/resolve.d.ts +2 -13
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +2 -12
- package/dist/resolve.js.map +1 -1
- package/dist/run-driver-grants.d.ts +5 -0
- package/dist/run-driver-grants.d.ts.map +1 -1
- package/dist/run-driver-grants.js +16 -4
- package/dist/run-driver-grants.js.map +1 -1
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +1 -6
- package/dist/run-journal.js.map +1 -1
- package/dist/run-migration.d.ts +21 -0
- package/dist/run-migration.d.ts.map +1 -1
- package/dist/run-migration.js +2 -27
- package/dist/run-migration.js.map +1 -1
- package/dist/run-notice.d.ts +20 -0
- package/dist/run-notice.d.ts.map +1 -1
- package/dist/run-notice.js +3 -28
- package/dist/run-notice.js.map +1 -1
- package/dist/schema-bounds.d.ts +24 -0
- package/dist/schema-bounds.d.ts.map +1 -0
- package/dist/schema-bounds.js +74 -0
- package/dist/schema-bounds.js.map +1 -0
- package/dist/schema-profile.d.ts +11 -24
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +16 -73
- package/dist/schema-profile.js.map +1 -1
- package/dist/subjects.d.ts +17 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +30 -2
- package/dist/subjects.js.map +1 -1
- package/dist/turn-contract.d.ts.map +1 -1
- package/dist/turn-contract.js +14 -4
- package/dist/turn-contract.js.map +1 -1
- package/package.json +14 -1
package/dist/endpoint-invoke.js
CHANGED
|
@@ -1,171 +1,55 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The GENERIC caller path: describe an endpoint, fetch its
|
|
3
|
-
* registered contracts from the §13.7 content store, recompile the digest-matching validators,
|
|
4
|
-
* and invoke a named command — WITHOUT the caller compiling the endpoint's schemas ahead of time.
|
|
5
|
-
* This is what a `cotal describe`/`cotal invoke` CLI and every migrated control consumer ride, so
|
|
6
|
-
* a consumer no longer hand-imports the manager's contract module.
|
|
7
|
-
*
|
|
8
|
-
* The trust chain is the §13.7 one, end to end:
|
|
9
|
-
* - `describe` (the reserved, authorization-scoped command every endpoint serves) answers the
|
|
10
|
-
* caller's VISIBLE command set + the registered CLUSTER closure digests;
|
|
11
|
-
* - each cluster document is fetched from the store at its closure digest and VERIFIED
|
|
12
|
-
* (two-stage manifest→root, content-addressed) — the command's input/output CLOSURE digests
|
|
13
|
-
* come from those verified bytes, never a caller assertion;
|
|
14
|
-
* - each schema closure is fetched + PROFILE-recompiled; the recompiled contract's closureDigest
|
|
15
|
-
* MUST equal the registered digest (a store that served the wrong bytes fails here);
|
|
16
|
-
* - the invoke pins those digests, so the responder's digest-bound serve boundary honors exactly
|
|
17
|
-
* the schema the caller validated against.
|
|
18
|
-
*/
|
|
19
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
20
|
-
import { PermissionViolationError } from "@nats-io/transport-node";
|
|
21
|
-
import { openPublishDenialWatch } from "./endpoint-publish-denial.js";
|
|
22
1
|
import { jetstreamManager } from "@nats-io/jetstream";
|
|
23
|
-
import { EpEnvelopeError,
|
|
2
|
+
import { EpEnvelopeError, replyRefusedBeforeEffect, replyTargetUnmapped } from "./endpoint-envelope.js";
|
|
24
3
|
import { compileContract } from "./schema-profile.js";
|
|
25
|
-
import { parseGoalResultFact
|
|
26
|
-
import {
|
|
4
|
+
import { parseGoalResultFact } from "./endpoint-action.js";
|
|
5
|
+
import { submitAndFollowGoalWith } from "./endpoint-goal-follow.js";
|
|
6
|
+
import { contractStoreContext, fetchContractClosure, contractRefToHex, contractArtifactDigestHex } from "./endpoint-contract-store.js";
|
|
27
7
|
import { parseClusterDocument } from "./endpoint-cluster.js";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
import { parseEndpointReply } from "./endpoint-envelope.js";
|
|
32
|
-
const dec = new TextDecoder(), enc = new TextEncoder();
|
|
33
|
-
const nonce = () => randomBytes(24).toString("base64url");
|
|
34
|
-
/** A describe is the reserved read-only discovery bootstrap, so an unanswered request may be
|
|
35
|
-
* re-published within its ORIGINAL deadline. Core NATS does not retain a request sent before a
|
|
36
|
-
* responder subscribes; without this bounded retry, a responder that registers one moment later
|
|
37
|
-
* is invisible until the caller pays the whole deadline. Commands are never retried here. */
|
|
38
|
-
const DESCRIBE_RETRY_MS = 250;
|
|
39
|
-
/** Format a caught value on a path that cannot afford to throw (timer/callback). A
|
|
40
|
-
* poisoned `Error.message` getter or `toString` must not escape the catch; the
|
|
41
|
-
* fallback is the detail, never a reason to skip the rejection. */
|
|
42
|
-
function caughtText(e, fallback) {
|
|
43
|
-
try {
|
|
44
|
-
return e instanceof Error ? e.message : String(e);
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return fallback;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/** Delivery margin above an action owner's accepted readiness budget. Spawn's established generic
|
|
51
|
-
* invariant was 40s client > 30s manager; preserve that measured 10s separation when a connector
|
|
52
|
-
* declares a different budget instead of inventing a second timeout policy. */
|
|
53
|
-
const GOAL_FOLLOW_MARGIN_MS = 10_000;
|
|
8
|
+
import { epScatterService } from "./endpoint-verbs.js";
|
|
9
|
+
import { BIND_SPLIT_REISSUES, dec, describeEndpoint, invokeCommand } from "./endpoint-caller.js";
|
|
10
|
+
export { BIND_SPLIT_REISSUES, describeEndpoint, goalFollowRefusal, invokeCommand } from "./endpoint-caller.js";
|
|
54
11
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
12
|
+
* {@link invokeCommand} with a SPEC 13.2 bind refusal repaired rather than surfaced.
|
|
13
|
+
*
|
|
14
|
+
* An unpinned handle binds the instance that answered its describe, and the invoke is a second,
|
|
15
|
+
* independent trip through the same class queue, so in a multi-instance space another member
|
|
16
|
+
* routinely receives it and refuses before dispatching. That refusal states the command did not
|
|
17
|
+
* run ({@link replyRefusedBeforeEffect}), so re-resolving and re-issuing is a first attempt and is
|
|
18
|
+
* safe for any command, mutations included. A targeted call that reaches a member holding no
|
|
19
|
+
* mapping for its target ({@link replyTargetUnmapped}) is re-issued the same way, because with an
|
|
20
|
+
* instance-local resolver only the member hosting the target can serve it. The re-issue repeats
|
|
21
|
+
* until a member serves the call or {@link BIND_SPLIT_REISSUES} runs out, and then the last refusal
|
|
22
|
+
* surfaces unchanged, or the last no-mapping refusal when there was one.
|
|
23
|
+
*
|
|
24
|
+
* `reresolve` defaults to a fresh class resolve as the handle's caller; a caller that memoizes its
|
|
25
|
+
* handle passes one that drops the memo first, so later calls do not start from the refused bind.
|
|
26
|
+
* When it throws, the refusal surfaces, because it states that nothing ran, which the resolve
|
|
27
|
+
* failure raised in its place would lose.
|
|
28
|
+
*
|
|
29
|
+
* A pinned handle is never repaired: it names its instance, so a refusal from it is that instance
|
|
30
|
+
* answering about itself, and re-resolving on the class rail would reach another one.
|
|
65
31
|
*/
|
|
66
|
-
export async function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
let timer;
|
|
87
|
-
let retryTimer;
|
|
88
|
-
let denialWatch;
|
|
89
|
-
let onAbort;
|
|
90
|
-
const startedAt = performance.now();
|
|
91
|
-
const unanswered = (ms) => new EpEnvelopeError("deadline-exceeded", `no describe reply from ${endpoint}${opts.instanceId !== undefined ? ` instance ${opts.instanceId}` : ""} within ${ms}ms on the ${rail} rail`, [{ kind: EP_UNANSWERED, endpoint, command: "describe", rail }]);
|
|
92
|
-
const cancelled = new Promise((_, reject) => {
|
|
93
|
-
onAbort = () => reject(opts.signal?.reason?.name === "TimeoutError"
|
|
94
|
-
? unanswered(Math.round(performance.now() - startedAt))
|
|
95
|
-
: new EpEnvelopeError("unavailable", `describe(${endpoint}) observation cancelled`));
|
|
96
|
-
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
97
|
-
});
|
|
98
|
-
try {
|
|
99
|
-
// REGISTER THE PERMISSION WATCH BEFORE THE PUBLISH IT IS WATCHING. See
|
|
100
|
-
// {@link openPublishDenialWatch}: a refused publish is otherwise indistinguishable
|
|
101
|
-
// from an unanswered describe.
|
|
102
|
-
denialWatch = openPublishDenialWatch(nc, subject, () => new EpEnvelopeError("permission-denied", `the describe for ${endpoint} was REFUSED BY THE BROKER, not unanswered: this caller's credential does not authorize publishing to "${subject}"${opts.instanceId !== undefined ? ` (the instance rail for ${opts.instanceId}: an instance-addressed call needs a credential minted with that instance, not a class-rail one)` : ""}. The responder may be perfectly healthy; the grant is what is missing (SPEC 13.2)`, undefined, "not-executed"), "describe");
|
|
103
|
-
const got = new Promise((resolve, reject) => {
|
|
104
|
-
sub = nc.subscribe(epCallerReplyFilter(space, caller), {
|
|
105
|
-
callback: (err, msg) => {
|
|
106
|
-
if (err) {
|
|
107
|
-
reject(new EpEnvelopeError("unavailable", `describe reply subscription failed: ${err.message}`));
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
// REQUEST-BIND off the reply SUBJECT first (§13.2): the responder triple + nonce are
|
|
111
|
-
// broker-pinned by the serve publish grant. A reply for a DIFFERENT endpoint, or on a
|
|
112
|
-
// nonce that is not the one we published (the rail is shared across our concurrent
|
|
113
|
-
// requests, and a hostile responder can publish at any nonce), is NOT ours — ignore it
|
|
114
|
-
// and keep waiting, never fail the honest describe on an injected reply.
|
|
115
|
-
const parsed = parseEpSubject(msg.subject);
|
|
116
|
-
if (!parsed || parsed.plane !== "reply" || parsed.endpoint !== endpoint || parsed.nonce !== n)
|
|
117
|
-
return;
|
|
118
|
-
// Then the body: it must parse as an EndpointReply and ECHO our request id on this
|
|
119
|
-
// nonce-scoped rail (§13.3) — the second half of the confused-deputy binding.
|
|
120
|
-
let reply;
|
|
121
|
-
try {
|
|
122
|
-
reply = parseEndpointReply(JSON.parse(dec.decode(msg.data)));
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
return;
|
|
126
|
-
} // a malformed body on our nonce is not a usable answer; wait for a valid one
|
|
127
|
-
if (reply.id !== requestId)
|
|
128
|
-
return;
|
|
129
|
-
resolve({ body: reply, responder: { instanceId: parsed.instanceId, epoch: parsed.epoch } });
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
const request = enc.encode(JSON.stringify(env));
|
|
133
|
-
nc.publish(subject, request);
|
|
134
|
-
// The first publish may precede the responder's subscription during startup. Re-publish the
|
|
135
|
-
// SAME read-only describe under the SAME request binding until one answer wins or the original
|
|
136
|
-
// deadline expires; this neither extends the budget nor retries the command being resolved.
|
|
137
|
-
// `publish` throws synchronously after close/drain. A timer throw escapes the caller's promise
|
|
138
|
-
// and crashes the process, so make transport loss settle this describe instead.
|
|
139
|
-
retryTimer = setInterval(() => {
|
|
140
|
-
try {
|
|
141
|
-
nc.publish(subject, request);
|
|
142
|
-
}
|
|
143
|
-
catch (e) {
|
|
144
|
-
reject(new EpEnvelopeError("unavailable", `the describe retry for ${endpoint} could not publish: ${caughtText(e, "unknown publish failure")}`));
|
|
145
|
-
}
|
|
146
|
-
}, DESCRIBE_RETRY_MS);
|
|
147
|
-
});
|
|
148
|
-
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(unanswered(deadlineMs)), deadlineMs); });
|
|
149
|
-
const { body: reply, responder } = await Promise.race([got, timeout, denialWatch.denied, cancelled]);
|
|
150
|
-
if (reply.ok !== true) {
|
|
151
|
-
// A responder ANSWERED with a refusal: it is rethrown under the responder's own code (which
|
|
152
|
-
// may be `unavailable`) and deliberately without the EP_UNANSWERED marker the deadline above
|
|
153
|
-
// carries, so a consumer keyed on that marker never reads an answering responder as absent.
|
|
154
|
-
const e = reply.error;
|
|
155
|
-
throw new EpEnvelopeError(e?.code ?? "unavailable", `describe(${endpoint}) failed: ${e?.message ?? "unknown"}`);
|
|
32
|
+
export async function invokeRepairingSplit(nc, space, service, command, args, opts, reresolve = () => resolveService(nc, space, service.endpoint, service.caller, { deadlineMs: opts.deadlineMs ?? 10_000, ...(opts.signal ? { signal: opts.signal } : {}) })) {
|
|
33
|
+
let handle = service;
|
|
34
|
+
// A member that looked the target up and found no mapping says more about it than a bind refusal,
|
|
35
|
+
// which says nothing, so a call that runs out of re-issues surfaces the latest such refusal.
|
|
36
|
+
let unmapped;
|
|
37
|
+
for (let reissues = 0;; reissues += 1) {
|
|
38
|
+
const r = await invokeCommand(nc, space, handle, command, args, opts);
|
|
39
|
+
if (replyTargetUnmapped(r.reply.error))
|
|
40
|
+
unmapped = r;
|
|
41
|
+
else if (r.reply.ok !== false || !replyRefusedBeforeEffect(r.reply.error))
|
|
42
|
+
return r;
|
|
43
|
+
if (handle.pinnedInstanceId !== undefined)
|
|
44
|
+
return r;
|
|
45
|
+
if (reissues === BIND_SPLIT_REISSUES)
|
|
46
|
+
return unmapped ?? r;
|
|
47
|
+
try {
|
|
48
|
+
handle = await reresolve();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return unmapped ?? r;
|
|
156
52
|
}
|
|
157
|
-
return { answer: reply.data, responder };
|
|
158
|
-
}
|
|
159
|
-
finally {
|
|
160
|
-
sub?.unsubscribe();
|
|
161
|
-
if (timer !== undefined)
|
|
162
|
-
clearTimeout(timer);
|
|
163
|
-
if (retryTimer !== undefined)
|
|
164
|
-
clearInterval(retryTimer);
|
|
165
|
-
if (onAbort)
|
|
166
|
-
opts.signal?.removeEventListener("abort", onAbort);
|
|
167
|
-
// Release on EVERY exit, success included. See {@link openPublishDenialWatch}.
|
|
168
|
-
denialWatch?.release();
|
|
169
53
|
}
|
|
170
54
|
}
|
|
171
55
|
/** Fetch + verify ONE cluster document from the store at its closure digest (two-stage §13.7:
|
|
@@ -234,7 +118,8 @@ function extractSchemaRefs(bytes) {
|
|
|
234
118
|
* so is this one. High enough that real surfaces overlap freely, low enough to stay a bound. */
|
|
235
119
|
const RESOLVE_MAX_INFLIGHT_READS = 32;
|
|
236
120
|
/** Run `work` over `items` with at most `limit` in flight, preserving RESULT ORDER (the resolved
|
|
237
|
-
* surface must not depend on read timing). Rejects like `Promise.all`: the first failure wins
|
|
121
|
+
* surface must not depend on read timing). Rejects like `Promise.all`: the first failure wins,
|
|
122
|
+
* and no item starts after it. */
|
|
238
123
|
async function pooled(items, limit, work) {
|
|
239
124
|
const out = new Array(items.length);
|
|
240
125
|
let next = 0;
|
|
@@ -243,7 +128,15 @@ async function pooled(items, limit, work) {
|
|
|
243
128
|
const i = next++;
|
|
244
129
|
if (i >= items.length)
|
|
245
130
|
return;
|
|
246
|
-
|
|
131
|
+
try {
|
|
132
|
+
out[i] = await work(items[i]);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
// This failure rejects the pool, so a later item would spend store reads on a result
|
|
136
|
+
// nobody reads. Items already in flight finish on their own.
|
|
137
|
+
next = items.length;
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
247
140
|
}
|
|
248
141
|
};
|
|
249
142
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
@@ -311,447 +204,9 @@ export async function resolveService(nc, space, endpoint, caller, opts = {}) {
|
|
|
311
204
|
commands.set(rc.command, rc);
|
|
312
205
|
return { endpoint: answer.descriptor.endpoint, owner: answer.descriptor.owner, caller, responder, commands, ...(opts.instanceId !== undefined ? { pinnedInstanceId: opts.instanceId } : {}) };
|
|
313
206
|
}
|
|
314
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
* three-manager space about 1 in 1000 where the unrepaired call failed 2 in 3 (#398). An attempt
|
|
318
|
-
* costs an answered describe and invoke, never an elapsed deadline. */
|
|
319
|
-
export const BIND_SPLIT_REISSUES = 16;
|
|
320
|
-
/**
|
|
321
|
-
* INVOKE one named command on a resolved service: validate nothing here (the compiled input
|
|
322
|
-
* contract in {@link epCall}'s request builder gates args before publish, and the responder's
|
|
323
|
-
* digest-bound boundary re-validates), route on the `one` rail, return the attributed reply. A
|
|
324
|
-
* command absent from the resolved surface is `not-found` (the caller cannot see it, or it does
|
|
325
|
-
* not exist); a targeted command needs its `target`.
|
|
326
|
-
*
|
|
327
|
-
* Currency: `opts.currentEpoch` (e.g. the registry-read `serviceEpochReader`) when supplied;
|
|
328
|
-
* otherwise the DESCRIBE-BOUND default - accept exactly the incarnation that answered this
|
|
329
|
-
* service's resolve (its broker-authenticated `instanceId`+`epoch` off the describe reply
|
|
330
|
-
* subject) and refuse `failed-precondition` when a DIFFERENT instance wins the `one` queue
|
|
331
|
-
* (a superseded-or-split responder; re-resolve to adopt a legitimate successor). The bind needs
|
|
332
|
-
* no registry read grant, and it is strictly stronger than no check: two live instances of a
|
|
333
|
-
* single-instance endpoint can never both pass one resolved handle.
|
|
334
|
-
*/
|
|
335
|
-
export async function invokeCommand(nc, space, service, command, args, opts) {
|
|
336
|
-
const resolved = service.commands.get(command);
|
|
337
|
-
if (resolved === undefined)
|
|
338
|
-
throw new EpEnvelopeError("not-found", `command "${command}" is not in ${service.endpoint}'s visible surface; describe lists ${[...service.commands.keys()].sort().join(", ") || "(none)"}`);
|
|
339
|
-
if (resolved.targeted && opts.target === undefined)
|
|
340
|
-
throw new EpEnvelopeError("bad-request", `command "${command}" is targeted (modes: ${resolved.modes.join(", ")}); an invoke needs its target`);
|
|
341
|
-
if (!resolved.targeted && opts.target !== undefined)
|
|
342
|
-
throw new EpEnvelopeError("bad-request", `command "${command}" is untargeted; an invoke must not carry a target`);
|
|
343
|
-
const caller = service.caller;
|
|
344
|
-
// "No args" marshals to the CONTRACT's canonical empty form: absent args ride as null on the
|
|
345
|
-
// wire, so when this command's input rejects null but accepts the empty object (e.g. an
|
|
346
|
-
// all-optional `{type:"object"}` input like despawn's), send `{}` — that IS the caller's
|
|
347
|
-
// intent in that contract's vocabulary (a targeted CLI stop has nothing left after the alias
|
|
348
|
-
// becomes the target block). Contract-derived, never a guess: an input that requires fields
|
|
349
|
-
// accepts neither form and still refuses loud at the pre-publish validation below.
|
|
350
|
-
let sendArgs = args;
|
|
351
|
-
if (sendArgs === undefined && !resolved.contract.input.validate(null) && resolved.contract.input.validate({}))
|
|
352
|
-
sendArgs = {};
|
|
353
|
-
const describeBound = (instanceId) => {
|
|
354
|
-
if (instanceId !== service.responder.instanceId) {
|
|
355
|
-
// The refusal is unchanged; what it SAYS is not. One message used to cover two situations
|
|
356
|
-
// that call for opposite responses, and it described only the rarer one.
|
|
357
|
-
//
|
|
358
|
-
// PINNED: the caller named an instance and a different one answered. Genuinely wrong.
|
|
359
|
-
//
|
|
360
|
-
// UNPINNED: the caller addressed the CLASS. The describe and the invoke are two independent
|
|
361
|
-
// trips through the same anycast queue, so in a multi-instance space they routinely land on
|
|
362
|
-
// different instances and this fires on an ordinary, correct request - not on a supersession.
|
|
363
|
-
// Calling that "superseded-or-split" sends the reader hunting a restart that never happened.
|
|
364
|
-
// The old text's remedy is also not executable by the surfaces that print it most (`stop`,
|
|
365
|
-
// `despawn` and `attach` have no adopt path and no pin), so it advised an action the caller
|
|
366
|
-
// could not take. Say which case this is, and stop asserting a cause that is usually wrong.
|
|
367
|
-
// The marker, not the prose, is what stops an automatic re-invoke: reaching here means a
|
|
368
|
-
// responder ANSWERED (executed or refused; the reply does not say which), so a retry is a
|
|
369
|
-
// second attempt that may duplicate an effect rather than a repair. Callers that recover
|
|
370
|
-
// from `failed-precondition` by re-resolving must consult `respondedButUnbound`.
|
|
371
|
-
// The remedy is stated in core vocabulary (address one instance), never as a CLI flag: core
|
|
372
|
-
// cannot know whether its caller has one, and most `invokeService` callers (a connector's
|
|
373
|
-
// tools, a manifest deploy) do not. The CLI names its own flag when it renders this.
|
|
374
|
-
throw new EpEnvelopeError("failed-precondition", service.pinnedInstanceId !== undefined
|
|
375
|
-
? `the ${service.endpoint} instance ${instanceId} answered but this handle is PINNED to ${service.pinnedInstanceId}; a pinned call names its instance and never accepts another. ${instanceId} did receive and answer the request, so if "${command}" mutates, that effect may already have landed - verify before re-issuing (SPEC 13.2)`
|
|
376
|
-
: `the ${service.endpoint} instance ${instanceId} won the class queue but this UNPINNED handle resolved against ${service.responder.instanceId}; the describe and the invoke are separate trips through the same queue, so in a multi-instance space this is an ordinary split and not necessarily a supersession - the handle cannot currently adopt a different winner. THIS SAYS NOTHING ABOUT WHETHER THE COMMAND RAN: ${instanceId} received the request and answered it, possibly after this error was raised. For a read that is harmless and re-issuing is safe; if "${command}" mutates, verify the outcome ('ps'/'inspect'/roster) before re-issuing, because a retry that assumes failure duplicates the effect. A call that addresses one instance does not split (SPEC 13.2)`, [{ kind: EP_UNBOUND_RESPONDER, endpoint: service.endpoint, command, answeredBy: instanceId, boundTo: service.responder.instanceId, pinned: service.pinnedInstanceId !== undefined }]);
|
|
377
|
-
}
|
|
378
|
-
return service.responder.epoch;
|
|
379
|
-
};
|
|
380
|
-
// `--on`: a PINNED service routes to its exact instance's `inst` rail (the same instance the
|
|
381
|
-
// describe resolved to, at its resolved epoch), never the class `one` queue — so the command reaches
|
|
382
|
-
// the addressed manager in a multi-manager space. Unpinned ⇒ class anycast `one` (unchanged). The
|
|
383
|
-
// describeBound currency check still holds: an inst-routed reply carries that instance's id.
|
|
384
|
-
const route = service.pinnedInstanceId !== undefined
|
|
385
|
-
? { mode: "inst", instanceId: service.pinnedInstanceId, epoch: service.responder.epoch }
|
|
386
|
-
: { mode: "one" };
|
|
387
|
-
// The bind travels WITH the request, so the responder can refuse before running the command
|
|
388
|
-
// rather than leaving `describeBound` to report the split afterwards. It is sent exactly when
|
|
389
|
-
// this handle's own resolve is the currency reference — that is the caller saying "this
|
|
390
|
-
// incarnation or none", and it is the same population the check below already refuses. A caller
|
|
391
|
-
// that supplied its own `currentEpoch` is asking a REGISTRY whether the answerer is current, and
|
|
392
|
-
// deliberately accepts any current member; binding it would refuse calls that succeed today.
|
|
393
|
-
const bind = opts.currentEpoch === undefined
|
|
394
|
-
? { instanceId: service.pinnedInstanceId ?? service.responder.instanceId, epoch: service.responder.epoch }
|
|
395
|
-
: undefined;
|
|
396
|
-
return epCall(nc, space, route, {
|
|
397
|
-
endpoint: service.endpoint, command, contract: resolved.contract, caller,
|
|
398
|
-
...(sendArgs !== undefined ? { args: sendArgs } : {}),
|
|
399
|
-
...(opts.target ? { target: opts.target } : {}),
|
|
400
|
-
...(bind !== undefined ? { bind } : {}),
|
|
401
|
-
...(opts.id !== undefined ? { id: opts.id } : {}),
|
|
402
|
-
}, {
|
|
403
|
-
deadlineMs: opts.deadlineMs ?? 10_000,
|
|
404
|
-
signal: opts.signal,
|
|
405
|
-
currentEpoch: opts.currentEpoch ?? describeBound,
|
|
406
|
-
// What the currency hook returns decides how a stale-epoch refusal is worded and marked: the
|
|
407
|
-
// describe-bound default is this handle's own bind (a responder ahead of it is a successor and
|
|
408
|
-
// the handle is the stale side); a caller-supplied hook is a registry read by epCall's contract.
|
|
409
|
-
currencyReference: opts.currentEpoch ? "registry" : "bind",
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
/** The refusal a goal-following call meets on a handle whose endpoint serves no `goal-result`: an
|
|
413
|
-
* accepted goal could never be read back to its terminal (SPEC 13.6), so nothing is submitted.
|
|
414
|
-
* `undefined` when the handle can follow. */
|
|
415
|
-
export function goalFollowRefusal(service) {
|
|
416
|
-
if (service.commands.has("goal-result"))
|
|
417
|
-
return undefined;
|
|
418
|
-
return {
|
|
419
|
-
reply: { v: 1, id: randomUUID(), ok: false, error: {
|
|
420
|
-
code: "failed-precondition", outcome: "not-executed",
|
|
421
|
-
message: `endpoint "${service.endpoint}" does not support "goal-result"; upgrade manager to enable durable goal following (SPEC 13.6)`,
|
|
422
|
-
} },
|
|
423
|
-
responder: { endpoint: service.endpoint, instanceId: service.responder.instanceId, epoch: service.responder.epoch },
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* {@link invokeCommand} with a SPEC 13.2 bind refusal repaired rather than surfaced.
|
|
428
|
-
*
|
|
429
|
-
* An unpinned handle binds the instance that answered its describe, and the invoke is a second,
|
|
430
|
-
* independent trip through the same class queue, so in a multi-instance space another member
|
|
431
|
-
* routinely receives it and refuses before dispatching. That refusal states the command did not
|
|
432
|
-
* run ({@link replyRefusedBeforeEffect}), so re-resolving and re-issuing is a first attempt and is
|
|
433
|
-
* safe for any command, mutations included. A targeted call that reaches a member holding no
|
|
434
|
-
* mapping for its target ({@link replyTargetUnmapped}) is re-issued the same way, because with an
|
|
435
|
-
* instance-local resolver only the member hosting the target can serve it. The re-issue repeats
|
|
436
|
-
* until a member serves the call or {@link BIND_SPLIT_REISSUES} runs out, and then the last refusal
|
|
437
|
-
* surfaces unchanged, or the last no-mapping refusal when there was one.
|
|
438
|
-
*
|
|
439
|
-
* `reresolve` defaults to a fresh class resolve as the handle's caller; a caller that memoizes its
|
|
440
|
-
* handle passes one that drops the memo first, so later calls do not start from the refused bind.
|
|
441
|
-
* When it throws, the refusal surfaces, because it states that nothing ran, which the resolve
|
|
442
|
-
* failure raised in its place would lose.
|
|
443
|
-
*
|
|
444
|
-
* A pinned handle is never repaired: it names its instance, so a refusal from it is that instance
|
|
445
|
-
* answering about itself, and re-resolving on the class rail would reach another one.
|
|
446
|
-
*/
|
|
447
|
-
export async function invokeRepairingSplit(nc, space, service, command, args, opts, reresolve = () => resolveService(nc, space, service.endpoint, service.caller, { deadlineMs: opts.deadlineMs ?? 10_000, ...(opts.signal ? { signal: opts.signal } : {}) })) {
|
|
448
|
-
let handle = service;
|
|
449
|
-
// A member that looked the target up and found no mapping says more about it than a bind refusal,
|
|
450
|
-
// which says nothing, so a call that runs out of re-issues surfaces the latest such refusal.
|
|
451
|
-
let unmapped;
|
|
452
|
-
for (let reissues = 0;; reissues += 1) {
|
|
453
|
-
const r = await invokeCommand(nc, space, handle, command, args, opts);
|
|
454
|
-
if (replyTargetUnmapped(r.reply.error))
|
|
455
|
-
unmapped = r;
|
|
456
|
-
else if (r.reply.ok !== false || !replyRefusedBeforeEffect(r.reply.error))
|
|
457
|
-
return r;
|
|
458
|
-
if (handle.pinnedInstanceId !== undefined)
|
|
459
|
-
return r;
|
|
460
|
-
if (reissues === BIND_SPLIT_REISSUES)
|
|
461
|
-
return unmapped ?? r;
|
|
462
|
-
try {
|
|
463
|
-
handle = await reresolve();
|
|
464
|
-
}
|
|
465
|
-
catch {
|
|
466
|
-
return unmapped ?? r;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
/** Subscribe before a single submission, then observe its accepted goal through live progress and
|
|
471
|
-
* mediated canonical reads. Local failures before an attributed reply throw; they never invent a
|
|
472
|
-
* responder. Once accepted, observation failures retain that responder and never authorize retry.
|
|
473
|
-
* A terminal other than `succeeded` keeps the acceptance as its data, so the caller still holds the
|
|
474
|
-
* allocated identity of a goal that settled `uncertain` and may yet converge. */
|
|
475
|
-
export async function submitAndFollowGoal(nc, space, endpoint, caller, deadlineMs, submit, opts) {
|
|
476
|
-
if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0)
|
|
477
|
-
throw new EpEnvelopeError("bad-request", "goal following requires a positive integer deadline", undefined, "not-executed");
|
|
478
|
-
const acceptingCaller = { ...caller };
|
|
479
|
-
const terminals = new Map();
|
|
480
|
-
const progressSubject = epGoalProgressGrantRow(space, endpoint, acceptingCaller);
|
|
481
|
-
const work = new AbortController();
|
|
482
|
-
const timers = new Set();
|
|
483
|
-
let activeNc = nc;
|
|
484
|
-
let activeSub;
|
|
485
|
-
let generation = 0;
|
|
486
|
-
let unbindReconnect;
|
|
487
|
-
let completed = false;
|
|
488
|
-
let stopped = opts?.signal?.aborted === true;
|
|
489
|
-
let submitted = false;
|
|
490
|
-
let subError;
|
|
491
|
-
let observationError;
|
|
492
|
-
let wake;
|
|
493
|
-
let abortPhase;
|
|
494
|
-
let needsReconcile = false;
|
|
495
|
-
let reading = false;
|
|
496
|
-
let progressReady = false;
|
|
497
|
-
const aborted = new Promise((resolve) => { abortPhase = resolve; });
|
|
498
|
-
const onAbort = () => {
|
|
499
|
-
stopped = true;
|
|
500
|
-
work.abort();
|
|
501
|
-
abortPhase?.();
|
|
502
|
-
wake?.();
|
|
503
|
-
};
|
|
504
|
-
const phaseError = (code) => new EpEnvelopeError(code, submitted
|
|
505
|
-
? "the goal submission was interrupted while in flight; its outcome is unknown. It may have been accepted or executed; do NOT retry without inspecting 'ps'/'inspect' first (SPEC 13.6)"
|
|
506
|
-
: "goal observation stopped or exceeded its deadline before submission; the manager request WAS NOT RUN", undefined, submitted ? "unknown" : "not-executed");
|
|
507
|
-
let deadline = Date.now() + deadlineMs;
|
|
508
|
-
const bounded = async (operation, until) => {
|
|
509
|
-
if (stopped || completed)
|
|
510
|
-
throw phaseError("unavailable");
|
|
511
|
-
const remaining = until - Date.now();
|
|
512
|
-
if (remaining <= 0)
|
|
513
|
-
throw phaseError("deadline-exceeded");
|
|
514
|
-
let timer;
|
|
515
|
-
try {
|
|
516
|
-
return await Promise.race([
|
|
517
|
-
operation(),
|
|
518
|
-
aborted.then(() => { throw phaseError("unavailable"); }),
|
|
519
|
-
new Promise((_, reject) => {
|
|
520
|
-
timer = setTimeout(() => reject(phaseError("deadline-exceeded")), Math.min(remaining, 2_147_483_647));
|
|
521
|
-
timers.add(timer);
|
|
522
|
-
}),
|
|
523
|
-
]);
|
|
524
|
-
}
|
|
525
|
-
finally {
|
|
526
|
-
if (timer !== undefined) {
|
|
527
|
-
clearTimeout(timer);
|
|
528
|
-
timers.delete(timer);
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
};
|
|
532
|
-
const subscribe = (next) => {
|
|
533
|
-
if (completed || stopped)
|
|
534
|
-
return;
|
|
535
|
-
const mine = ++generation;
|
|
536
|
-
activeSub?.unsubscribe();
|
|
537
|
-
activeNc = next;
|
|
538
|
-
progressReady = false;
|
|
539
|
-
subError = undefined;
|
|
540
|
-
activeSub = next.subscribe(progressSubject, {
|
|
541
|
-
callback: (err, msg) => {
|
|
542
|
-
if (completed || stopped || mine !== generation)
|
|
543
|
-
return;
|
|
544
|
-
if (err) {
|
|
545
|
-
subError ??= err;
|
|
546
|
-
wake?.();
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
let ev;
|
|
550
|
-
try {
|
|
551
|
-
ev = JSON.parse(dec.decode(msg.data));
|
|
552
|
-
}
|
|
553
|
-
catch {
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
if (ev?.phase === "terminal" && typeof ev.goalId === "string") {
|
|
557
|
-
terminals.set(ev.goalId, { state: String(ev.state), data: ev.data });
|
|
558
|
-
wake?.();
|
|
559
|
-
}
|
|
560
|
-
},
|
|
561
|
-
});
|
|
562
|
-
};
|
|
563
|
-
const replaceConnection = (next) => {
|
|
564
|
-
if (completed || stopped)
|
|
565
|
-
return;
|
|
566
|
-
try {
|
|
567
|
-
subscribe(next);
|
|
568
|
-
}
|
|
569
|
-
catch (err) {
|
|
570
|
-
subError = err instanceof Error ? err : new Error(String(err));
|
|
571
|
-
wake?.();
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
const mine = generation;
|
|
575
|
-
void bounded(() => next.flush(), deadline).then(() => {
|
|
576
|
-
if (completed || stopped || mine !== generation)
|
|
577
|
-
return;
|
|
578
|
-
progressReady = true;
|
|
579
|
-
needsReconcile = true;
|
|
580
|
-
wake?.();
|
|
581
|
-
}, (err) => {
|
|
582
|
-
if (completed || stopped || mine !== generation)
|
|
583
|
-
return;
|
|
584
|
-
subError = err instanceof Error ? err : new Error(String(err));
|
|
585
|
-
wake?.();
|
|
586
|
-
});
|
|
587
|
-
};
|
|
588
|
-
try {
|
|
589
|
-
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
590
|
-
if (stopped)
|
|
591
|
-
throw phaseError("unavailable");
|
|
592
|
-
if (opts?.prepare) {
|
|
593
|
-
// The deadline first aborts the prepare's signal, so a describe the prepare started after other
|
|
594
|
-
// steps reports that it drew no reply. A prepare that ignores its signal is cut off a turn later.
|
|
595
|
-
let timer;
|
|
596
|
-
try {
|
|
597
|
-
await Promise.race([opts.prepare(work.signal, deadline), aborted, new Promise((resolve) => {
|
|
598
|
-
timer = setTimeout(() => {
|
|
599
|
-
work.abort(new DOMException("the goal follow's deadline passed", "TimeoutError"));
|
|
600
|
-
setImmediate(resolve);
|
|
601
|
-
}, Math.min(deadline - Date.now(), 2_147_483_647));
|
|
602
|
-
})]);
|
|
603
|
-
work.signal.throwIfAborted();
|
|
604
|
-
}
|
|
605
|
-
catch (err) {
|
|
606
|
-
throw stopped ? phaseError("unavailable") : work.signal.aborted && !unansweredRequest(err) ? phaseError("deadline-exceeded") : err;
|
|
607
|
-
}
|
|
608
|
-
finally {
|
|
609
|
-
clearTimeout(timer);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
subscribe(opts?.currentNc?.() ?? nc);
|
|
613
|
-
unbindReconnect = opts?.onReconnect?.(replaceConnection);
|
|
614
|
-
// A borrowed submit can publish on another connection. Confirm broker interest first,
|
|
615
|
-
// including a replacement that arrived while the first connection's flush was pending.
|
|
616
|
-
for (;;) {
|
|
617
|
-
const preparing = activeNc;
|
|
618
|
-
try {
|
|
619
|
-
await bounded(() => preparing.flush(), deadline);
|
|
620
|
-
}
|
|
621
|
-
catch (err) {
|
|
622
|
-
if (!stopped && preparing !== activeNc)
|
|
623
|
-
continue;
|
|
624
|
-
throw err;
|
|
625
|
-
}
|
|
626
|
-
if (stopped)
|
|
627
|
-
throw phaseError("unavailable");
|
|
628
|
-
const current = opts?.currentNc?.();
|
|
629
|
-
if (current && current !== activeNc)
|
|
630
|
-
subscribe(current);
|
|
631
|
-
if (preparing === activeNc) {
|
|
632
|
-
progressReady = true;
|
|
633
|
-
break;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
if (stopped)
|
|
637
|
-
throw phaseError("unavailable");
|
|
638
|
-
submitted = true;
|
|
639
|
-
let attributed;
|
|
640
|
-
try {
|
|
641
|
-
attributed = await bounded(() => submit(work.signal), deadline);
|
|
642
|
-
}
|
|
643
|
-
catch (err) {
|
|
644
|
-
if (stopped)
|
|
645
|
-
throw phaseError("unavailable");
|
|
646
|
-
if (err instanceof EpEnvelopeError && err.outcome === "not-executed")
|
|
647
|
-
throw err;
|
|
648
|
-
throw new EpEnvelopeError(err instanceof EpEnvelopeError ? err.code : "unavailable", `${err instanceof Error ? err.message : String(err)}; the submission outcome is unknown. It may have been accepted or executed; do NOT retry without inspecting 'ps'/'inspect' first (SPEC 13.6)`, undefined, "unknown");
|
|
649
|
-
}
|
|
650
|
-
if (attributed.reply.ok !== true)
|
|
651
|
-
return attributed;
|
|
652
|
-
const acceptance = attributed.reply.data;
|
|
653
|
-
const goalId = acceptance?.goalId;
|
|
654
|
-
if (typeof goalId !== "string")
|
|
655
|
-
return attributed;
|
|
656
|
-
const acceptedFingerprint = typeof acceptance?.fingerprint === "string" ? acceptance.fingerprint : undefined;
|
|
657
|
-
const readinessDeadlineMs = acceptance?.readinessDeadlineMs;
|
|
658
|
-
const followDeadlineMs = typeof readinessDeadlineMs === "number"
|
|
659
|
-
&& Number.isSafeInteger(readinessDeadlineMs) && readinessDeadlineMs >= 0
|
|
660
|
-
? Math.max(deadlineMs, readinessDeadlineMs + GOAL_FOLLOW_MARGIN_MS) : deadlineMs;
|
|
661
|
-
deadline = Date.now() + followDeadlineMs;
|
|
662
|
-
const reconcileAt = deadline - Math.min(deadlineMs, followDeadlineMs / 2);
|
|
663
|
-
let finalReadRequested = false;
|
|
664
|
-
const reconcile = async () => {
|
|
665
|
-
if (!opts?.reconcile || completed || stopped || reading || !progressReady)
|
|
666
|
-
return;
|
|
667
|
-
reading = true;
|
|
668
|
-
needsReconcile = false;
|
|
669
|
-
try {
|
|
670
|
-
const result = await opts.reconcile(goalId, attributed, {
|
|
671
|
-
caller: { ...acceptingCaller }, signal: work.signal, deadlineMs: Math.max(1, deadline - Date.now()),
|
|
672
|
-
});
|
|
673
|
-
if (completed || stopped || Date.now() >= deadline)
|
|
674
|
-
return;
|
|
675
|
-
if (!result || typeof result !== "object" || result.goalId !== goalId)
|
|
676
|
-
throw new EpEnvelopeError("internal", "goal-result reply is malformed or names a different goal; it cannot authorize this outcome (SPEC 13.6)");
|
|
677
|
-
if (result.result !== undefined) {
|
|
678
|
-
const ref = { endpoint, caller: acceptingCaller, goalId };
|
|
679
|
-
const fact = parseGoalResultFact(result.result, "goal-result reply", ref);
|
|
680
|
-
if (acceptedFingerprint !== undefined && fact.fingerprint !== acceptedFingerprint)
|
|
681
|
-
throw new EpEnvelopeError("internal", "goal result fingerprint does not match the accepted goal (SPEC 13.6)");
|
|
682
|
-
terminals.set(goalId, { state: fact.state, data: fact.data });
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
catch (err) {
|
|
686
|
-
if (!completed && !stopped && Date.now() < deadline)
|
|
687
|
-
observationError = err instanceof Error ? err : new Error(String(err));
|
|
688
|
-
}
|
|
689
|
-
finally {
|
|
690
|
-
reading = false;
|
|
691
|
-
if (!completed && !stopped)
|
|
692
|
-
wake?.();
|
|
693
|
-
}
|
|
694
|
-
};
|
|
695
|
-
const current = opts?.currentNc?.();
|
|
696
|
-
if (current && current !== activeNc && !stopped)
|
|
697
|
-
replaceConnection(current);
|
|
698
|
-
needsReconcile = true; // Also covers a terminal lost before the acceptance arrived.
|
|
699
|
-
while (!terminals.has(goalId) && !stopped && !observationError && !subError && Date.now() < deadline) {
|
|
700
|
-
if (!finalReadRequested && Date.now() >= reconcileAt) {
|
|
701
|
-
finalReadRequested = true;
|
|
702
|
-
needsReconcile = true;
|
|
703
|
-
}
|
|
704
|
-
if (needsReconcile && !reading)
|
|
705
|
-
void reconcile();
|
|
706
|
-
if (terminals.has(goalId) || stopped || observationError || subError)
|
|
707
|
-
break;
|
|
708
|
-
await new Promise((resolve) => {
|
|
709
|
-
const until = finalReadRequested ? deadline : reconcileAt;
|
|
710
|
-
const timer = setTimeout(() => { timers.delete(timer); wake = undefined; resolve(); }, Math.max(0, Math.min(until - Date.now(), 2_147_483_647)));
|
|
711
|
-
timers.add(timer);
|
|
712
|
-
wake = () => { clearTimeout(timer); timers.delete(timer); wake = undefined; resolve(); };
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
const terminal = terminals.get(goalId);
|
|
716
|
-
const observationFailure = (code, reason) => ({
|
|
717
|
-
...attributed,
|
|
718
|
-
reply: { ...attributed.reply, ok: false, data: undefined, error: {
|
|
719
|
-
code, outcome: "unknown",
|
|
720
|
-
message: `the goal "${goalId}" was accepted, but ${reason}. THE GOAL IS UNAFFECTED - it may already have succeeded; this is not evidence the goal failed. Do NOT retry: another submission could duplicate the effect. Inspect 'ps'/'inspect' first (SPEC 13.6)`,
|
|
721
|
-
} },
|
|
722
|
-
});
|
|
723
|
-
if (stopped)
|
|
724
|
-
return observationFailure("unavailable", "the endpoint was stopped while awaiting its outcome");
|
|
725
|
-
if (observationError)
|
|
726
|
-
return observationFailure("unavailable", `following its outcome failed: ${observationError.message}`);
|
|
727
|
-
if (!terminal && subError)
|
|
728
|
-
return subError instanceof PermissionViolationError
|
|
729
|
-
? observationFailure("permission-denied", `this caller is NOT PERMITTED to hear its outcome: the broker refused the per-goal progress subscription \"${progressSubject}\" (${subError.message}); grant this caller the per-goal progress read row`)
|
|
730
|
-
: observationFailure("unavailable", `the per-goal progress subscription \"${progressSubject}\" FAILED (${subError.name}: ${subError.message}); this is NOT a grant refusal, so changing ACLs is the wrong remedy`);
|
|
731
|
-
if (!terminal)
|
|
732
|
-
return observationFailure("deadline-exceeded", `no terminal arrived within ${followDeadlineMs}ms; this is a timeout on the WAIT`);
|
|
733
|
-
if (terminal.state === "succeeded")
|
|
734
|
-
return { ...attributed, reply: { ...attributed.reply, ok: true, data: terminal.data, error: undefined } };
|
|
735
|
-
const d = (terminal.data ?? {});
|
|
736
|
-
const raw = typeof d.error === "string" ? d.error : typeof d.reason === "string" ? d.reason : `the goal settled ${terminal.state}`;
|
|
737
|
-
const details = Array.isArray(d.details) ? d.details : undefined;
|
|
738
|
-
const message = renderLifecycleBlocked(raw, details ? { details } : undefined);
|
|
739
|
-
const fromAccept = lifecycleBlockedFrom(attributed.reply.error);
|
|
740
|
-
const merged = details ?? (fromAccept ? [fromAccept] : undefined);
|
|
741
|
-
return { ...attributed, reply: { ...attributed.reply, ok: false, error: { code: terminal.state, message, ...(merged ? { details: merged } : {}) } } };
|
|
742
|
-
}
|
|
743
|
-
finally {
|
|
744
|
-
completed = true;
|
|
745
|
-
opts?.signal?.removeEventListener("abort", onAbort);
|
|
746
|
-
unbindReconnect?.();
|
|
747
|
-
activeSub?.unsubscribe();
|
|
748
|
-
for (const timer of timers)
|
|
749
|
-
clearTimeout(timer);
|
|
750
|
-
timers.clear();
|
|
751
|
-
wake = undefined;
|
|
752
|
-
work.abort();
|
|
753
|
-
abortPhase?.();
|
|
754
|
-
}
|
|
207
|
+
/** {@link submitAndFollowGoalWith} with the root's synchronous fact verifier. */
|
|
208
|
+
export function submitAndFollowGoal(nc, space, endpoint, caller, deadlineMs, submit, opts) {
|
|
209
|
+
return submitAndFollowGoalWith(parseGoalResultFact, nc, space, endpoint, caller, deadlineMs, submit, opts);
|
|
755
210
|
}
|
|
756
211
|
/**
|
|
757
212
|
* SCATTER one untargeted command to the LIVE class (§13.5): resolve the command's contract off the
|