@moltzap/client 2026.922.2 → 2026.930.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/README.md +23 -7
- package/dist/client-runtime.d.ts.map +1 -1
- package/dist/client-runtime.js +28 -11
- package/dist/client-runtime.js.map +1 -1
- package/dist/contract.d.ts +440 -57
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +256 -18
- package/dist/contract.js.map +1 -1
- package/dist/contract.types-check.d.ts +109 -13
- package/dist/contract.types-check.d.ts.map +1 -1
- package/dist/contract.types-check.js +11 -3
- package/dist/contract.types-check.js.map +1 -1
- package/dist/daemon/runtime/activation.d.ts +1 -1
- package/dist/daemon/runtime/activation.d.ts.map +1 -1
- package/dist/daemon/runtime/controller.d.ts +1 -1
- package/dist/daemon/runtime/controller.d.ts.map +1 -1
- package/dist/daemon/runtime/controller.js +44 -10
- package/dist/daemon/runtime/controller.js.map +1 -1
- package/dist/daemon/runtime/history-export.d.ts +11 -1
- package/dist/daemon/runtime/history-export.d.ts.map +1 -1
- package/dist/daemon/runtime/history-export.js +4 -0
- package/dist/daemon/runtime/history-export.js.map +1 -1
- package/dist/daemon/runtime/index.d.ts.map +1 -1
- package/dist/daemon/runtime/index.js +1 -2
- package/dist/daemon/runtime/index.js.map +1 -1
- package/dist/daemon/runtime/pending-delivery.d.ts +49 -0
- package/dist/daemon/runtime/pending-delivery.d.ts.map +1 -0
- package/dist/daemon/runtime/pending-delivery.js +115 -0
- package/dist/daemon/runtime/pending-delivery.js.map +1 -0
- package/dist/daemon/runtime/protocol.d.ts +20 -5
- package/dist/daemon/runtime/protocol.d.ts.map +1 -1
- package/dist/daemon/runtime/protocol.js +58 -24
- package/dist/daemon/runtime/protocol.js.map +1 -1
- package/dist/endpoint/addressing/index.d.ts +22 -1
- package/dist/endpoint/addressing/index.d.ts.map +1 -1
- package/dist/endpoint/addressing/index.js +50 -26
- package/dist/endpoint/addressing/index.js.map +1 -1
- package/dist/endpoint/collective/operation.d.ts +123 -0
- package/dist/endpoint/collective/operation.d.ts.map +1 -0
- package/dist/endpoint/collective/operation.js +784 -0
- package/dist/endpoint/collective/operation.js.map +1 -0
- package/dist/endpoint/collective/shared-answers.d.ts +78 -0
- package/dist/endpoint/collective/shared-answers.d.ts.map +1 -0
- package/dist/endpoint/collective/shared-answers.js +62 -0
- package/dist/endpoint/collective/shared-answers.js.map +1 -0
- package/dist/endpoint/collective/validation.d.ts +49 -0
- package/dist/endpoint/collective/validation.d.ts.map +1 -0
- package/dist/endpoint/collective/validation.js +99 -0
- package/dist/endpoint/collective/validation.js.map +1 -0
- package/dist/endpoint/collective/wire.d.ts +176 -0
- package/dist/endpoint/collective/wire.d.ts.map +1 -0
- package/dist/endpoint/collective/wire.js +181 -0
- package/dist/endpoint/collective/wire.js.map +1 -0
- package/dist/endpoint/engine-durability.d.ts +3 -17
- package/dist/endpoint/engine-durability.d.ts.map +1 -1
- package/dist/endpoint/engine-durability.js +3 -14
- package/dist/endpoint/engine-durability.js.map +1 -1
- package/dist/endpoint/engine-send.d.ts +14 -16
- package/dist/endpoint/engine-send.d.ts.map +1 -1
- package/dist/endpoint/engine-send.js +16 -20
- package/dist/endpoint/engine-send.js.map +1 -1
- package/dist/endpoint/engine-types.d.ts +32 -15
- package/dist/endpoint/engine-types.d.ts.map +1 -1
- package/dist/endpoint/engine-types.js +1 -5
- package/dist/endpoint/engine-types.js.map +1 -1
- package/dist/endpoint/engine.d.ts +1 -1
- package/dist/endpoint/engine.d.ts.map +1 -1
- package/dist/endpoint/engine.js +17 -14
- package/dist/endpoint/engine.js.map +1 -1
- package/dist/endpoint/protocol/index.d.ts.map +1 -1
- package/dist/endpoint/protocol/index.js +5 -8
- package/dist/endpoint/protocol/index.js.map +1 -1
- package/dist/endpoint/recovery/index.js +2 -2
- package/dist/endpoint/recovery/index.js.map +1 -1
- package/dist/endpoint/recovery/reanchor/empty.js +1 -1
- package/dist/endpoint/recovery/reanchor/empty.js.map +1 -1
- package/dist/endpoint/recovery/store.d.ts +1 -1
- package/dist/endpoint/recovery/store.d.ts.map +1 -1
- package/dist/endpoint/recovery/store.js +9 -5
- package/dist/endpoint/recovery/store.js.map +1 -1
- package/dist/endpoint/representation-schemas.d.ts +2 -0
- package/dist/endpoint/representation-schemas.d.ts.map +1 -1
- package/dist/endpoint/representation-schemas.js +2 -2
- package/dist/endpoint/representation-schemas.js.map +1 -1
- package/dist/endpoint/representation.d.ts +1 -1
- package/dist/endpoint/representation.d.ts.map +1 -1
- package/dist/endpoint/representation.js +1 -1
- package/dist/endpoint/representation.js.map +1 -1
- package/dist/harness-mcp-contract.d.ts +189 -57
- package/dist/harness-mcp-contract.d.ts.map +1 -1
- package/dist/harness-mcp-contract.js +61 -9
- package/dist/harness-mcp-contract.js.map +1 -1
- package/dist/harness-mcp-wire.d.ts +3 -3
- package/dist/harness-mcp-wire.d.ts.map +1 -1
- package/dist/harness-mcp-wire.js +38 -28
- package/dist/harness-mcp-wire.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Collective operations over the post envelope.
|
|
3
|
+
*
|
|
4
|
+
* The daemon holds one `CollectiveOperations` per active identity. It turns
|
|
5
|
+
* each send into the posts its operation certifies and each certified remote
|
|
6
|
+
* post into the item the subscriber receives, or consumes it. Every post an
|
|
7
|
+
* endpoint authors carries its operation as an explicit collective part, so
|
|
8
|
+
* each certified post says which operation it belongs to; a post without a
|
|
9
|
+
* collective part reads as multicast, the operation an omitted `op` names.
|
|
10
|
+
*
|
|
11
|
+
* Collective state lives in daemon memory: an operation open at a daemon
|
|
12
|
+
* restart is lost, and a request whose delivery the member already
|
|
13
|
+
* acknowledged can no longer be answered after one.
|
|
14
|
+
*
|
|
15
|
+
* ```mermaid
|
|
16
|
+
* sequenceDiagram
|
|
17
|
+
* participant RM as Requester host
|
|
18
|
+
* participant RE as Requester endpoint
|
|
19
|
+
* participant ME as Member endpoint
|
|
20
|
+
* participant MM as Member host
|
|
21
|
+
* RM->>RE: send gather to, question, deadline, schema
|
|
22
|
+
* RE->>RE: validate schema, mint id<br>deadline = now + duration
|
|
23
|
+
* RE->>ME: one request post per member
|
|
24
|
+
* alt a request post is refused
|
|
25
|
+
* RE-->>RM: error naming each unreachable member
|
|
26
|
+
* else every request post accepted
|
|
27
|
+
* RE-->>RM: operation id
|
|
28
|
+
* end
|
|
29
|
+
* ME->>MM: collectiveRequest item
|
|
30
|
+
* MM->>ME: send collectiveResponse id, action, content
|
|
31
|
+
* ME->>ME: validate content against the stored schema
|
|
32
|
+
* ME->>RE: response post in the direct conversation
|
|
33
|
+
* RE->>RE: consume, validate and record the answer
|
|
34
|
+
* Note over RE: complete when every member has an outcome<br>or at the deadline
|
|
35
|
+
* RE->>RM: collectiveResult item
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* An all_gather asks a group in its own conversation. Every member endpoint
|
|
39
|
+
* consumes the answers it sees there, so no model sees a peer's answer before
|
|
40
|
+
* the close, and builds its result from exactly the answers the requester's
|
|
41
|
+
* close lists.
|
|
42
|
+
*
|
|
43
|
+
* ```mermaid
|
|
44
|
+
* sequenceDiagram
|
|
45
|
+
* participant RM as Requester host
|
|
46
|
+
* participant RE as Requester endpoint
|
|
47
|
+
* participant ME as Member endpoints
|
|
48
|
+
* participant MM as Member hosts
|
|
49
|
+
* RM->>RE: send all_gather to group, question, deadline, schema
|
|
50
|
+
* RE->>ME: one request post to the group
|
|
51
|
+
* alt the group post is refused or not certified in time
|
|
52
|
+
* RE-->>RM: error naming each unreachable member
|
|
53
|
+
* else request post certified
|
|
54
|
+
* RE-->>RM: operation id
|
|
55
|
+
* end
|
|
56
|
+
* ME->>MM: collectiveRequest item
|
|
57
|
+
* MM->>ME: send collectiveResponse id, action, content
|
|
58
|
+
* ME->>ME: validate content against the stored schema
|
|
59
|
+
* ME->>RE: response post to the group
|
|
60
|
+
* ME->>ME: peer response posts, consumed and recorded by record hash
|
|
61
|
+
* Note over RE: complete when every member has an outcome<br>or at the deadline
|
|
62
|
+
* RE->>ME: close post listing the counted answers' record hashes
|
|
63
|
+
* RE->>RM: collectiveResult item once the close is certified
|
|
64
|
+
* ME->>ME: build the result from exactly the listed answers
|
|
65
|
+
* ME->>MM: collectiveResult item, the same outcomes
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
import { Clock, Duration, Effect, Array as EffectArray, Either, Exit, Fiber, Option, ParseResult, Schema, } from "effect";
|
|
69
|
+
import { randomBytes } from "node:crypto";
|
|
70
|
+
import { AgentAddress, CollectiveError, MAXIMUM_DEADLINE_SECONDS, RequestedSchema, SendError, } from "../../contract.js";
|
|
71
|
+
import { canonicalMessageAddress } from "../addressing/index.js";
|
|
72
|
+
import { keepFirstAnswer, listedAnswers, memberOutcomes, } from "./shared-answers.js";
|
|
73
|
+
import { outcomeOfResponse, validateAnswer } from "./validation.js";
|
|
74
|
+
import { collectiveIdOf, encodeCollectiveContent, FormModeSchema, readCollectiveValue, withoutCollectivePart, } from "./wire.js";
|
|
75
|
+
/**
|
|
76
|
+
* How long a collecting operation's send waits for its request posts before
|
|
77
|
+
* returning. A gather's request still uncertified then is left running: its
|
|
78
|
+
* member answers or is reported as no-answer at the deadline. An all_gather's
|
|
79
|
+
* one group post must be certified by then, or the all_gather cannot start:
|
|
80
|
+
* a cold group's GENESIS needs every member. The bound stays under the MCP
|
|
81
|
+
* SDK's request timeout so a waiting host receives the result rather than a
|
|
82
|
+
* transport timeout.
|
|
83
|
+
*/
|
|
84
|
+
const REQUEST_SEND_WAIT = Duration.seconds(20);
|
|
85
|
+
/**
|
|
86
|
+
* How many Registry lookups a refused all_gather runs at once to name its
|
|
87
|
+
* unreachable members; they are independent, and a group has at most 32.
|
|
88
|
+
*/
|
|
89
|
+
const MEMBER_LOOKUP_CONCURRENCY = 8;
|
|
90
|
+
/**
|
|
91
|
+
* How long past its deadline a member keeps an all_gather request while it
|
|
92
|
+
* waits for the requester's close. The requester closes at the deadline at
|
|
93
|
+
* the latest, so the close normally follows within seconds; the bound frees
|
|
94
|
+
* the request of a requester that never closes.
|
|
95
|
+
*/
|
|
96
|
+
const CLOSE_WAIT = Duration.hours(1);
|
|
97
|
+
/**
|
|
98
|
+
* The longest single sleep a deadline timer takes. A JavaScript timer longer
|
|
99
|
+
* than about 24.8 days never fires, so a longer deadline is reached in steps.
|
|
100
|
+
*/
|
|
101
|
+
const DEADLINE_TIMER_STEP = Duration.days(1);
|
|
102
|
+
/**
|
|
103
|
+
* How far past the longest deadline a sender may state a received request's
|
|
104
|
+
* deadline still lies. It absorbs clock skew between the two endpoints; a
|
|
105
|
+
* request further out is consumed, which bounds how long its state is held
|
|
106
|
+
* and keeps every stored deadline a representable date.
|
|
107
|
+
*/
|
|
108
|
+
const RECEIVED_DEADLINE_ALLOWANCE = Duration.hours(1);
|
|
109
|
+
/** The furthest past `now` a received request's deadline may lie. */
|
|
110
|
+
const RECEIVED_DEADLINE_HORIZON = Duration.toMillis(Duration.sum(Duration.seconds(MAXIMUM_DEADLINE_SECONDS), RECEIVED_DEADLINE_ALLOWANCE));
|
|
111
|
+
const decodeAgentAddress = Schema.decodeUnknownOption(AgentAddress);
|
|
112
|
+
const decodeRequestedSchema = Schema.decodeUnknownOption(RequestedSchema);
|
|
113
|
+
/** A fresh nonce and the id it binds to the requester. */
|
|
114
|
+
const mintCollectiveId = (requester) => Effect.sync(() => {
|
|
115
|
+
const nonce = randomBytes(32).toString("base64url");
|
|
116
|
+
return { id: collectiveIdOf(requester, nonce), nonce };
|
|
117
|
+
});
|
|
118
|
+
const contentInvalid = () => new SendError({ reason: "content-invalid" });
|
|
119
|
+
const collectiveFailure = (id, failure) => new CollectiveError({ id, failure });
|
|
120
|
+
/**
|
|
121
|
+
* Build the collective layer for one active identity.
|
|
122
|
+
* @param ports The daemon's post sending, item queue, scope and identity.
|
|
123
|
+
* @returns Send and classification over state held in daemon memory.
|
|
124
|
+
*/
|
|
125
|
+
export const makeCollectiveOperations = (ports) => {
|
|
126
|
+
const state = {
|
|
127
|
+
ports,
|
|
128
|
+
gathers: new Map(),
|
|
129
|
+
requests: new Map(),
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
send: (input, failureDelivery) => "collectiveResponse" in input
|
|
133
|
+
? reportFailure(state, failureDelivery, respond(state, input.collectiveResponse))
|
|
134
|
+
: sendOperation(state, input, failureDelivery),
|
|
135
|
+
classify: (message) => classify(state, message),
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
function sendOperation(state, input, failureDelivery) {
|
|
139
|
+
const operation = input.collective ?? {};
|
|
140
|
+
switch (operation.op) {
|
|
141
|
+
case undefined:
|
|
142
|
+
case "multicast":
|
|
143
|
+
return multicast(state, input.to, input.text);
|
|
144
|
+
case "gather":
|
|
145
|
+
case "all_gather":
|
|
146
|
+
return reportFailure(state, failureDelivery, gather(state, input.to, input.text, operation));
|
|
147
|
+
default: {
|
|
148
|
+
const exhaustive = operation;
|
|
149
|
+
return exhaustive;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function multicast(state, to, text) {
|
|
154
|
+
return encodeCollectiveContent({ kind: "operation", op: "multicast" }, text).pipe(Effect.mapError(contentInvalid), Effect.flatMap((content) => state.ports.sendPost({ to, content })), Effect.map((post) => ({ postIds: [post.postId] })));
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Deliver a refused collective send's error where the host wants it. With
|
|
158
|
+
* `inbound` the send completes, naming the operation, and the error arrives
|
|
159
|
+
* as an `operationFailed` item carrying the same text.
|
|
160
|
+
*/
|
|
161
|
+
function reportFailure(state, failureDelivery, send) {
|
|
162
|
+
return send.pipe(Effect.catchAll((refused) => {
|
|
163
|
+
switch (failureDelivery) {
|
|
164
|
+
case "result":
|
|
165
|
+
return Effect.fail(refused.error);
|
|
166
|
+
case "inbound":
|
|
167
|
+
return state.ports
|
|
168
|
+
.emit({
|
|
169
|
+
kind: "operationFailed",
|
|
170
|
+
id: refused.id,
|
|
171
|
+
to: refused.to,
|
|
172
|
+
error: refused.error.message,
|
|
173
|
+
})
|
|
174
|
+
.pipe(Effect.as({ operationId: refused.id, postIds: [] }));
|
|
175
|
+
default: {
|
|
176
|
+
const exhaustive = failureDelivery;
|
|
177
|
+
return exhaustive;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
function refusedAs(id, to) {
|
|
183
|
+
return (error) => ({ id, to, error });
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The canonical address a collecting operation's result and failure name, by
|
|
187
|
+
* the rule every send shares, and the members it asks: the one agent of an
|
|
188
|
+
* `agent:` address, or every member of a group but the requester. An
|
|
189
|
+
* all_gather asks a group in its conversation, so an `agent:` address is
|
|
190
|
+
* refused as `membership-invalid`.
|
|
191
|
+
*/
|
|
192
|
+
function gatherAddress(to, self, op) {
|
|
193
|
+
const selfName = self.slice("agent:".length);
|
|
194
|
+
const membershipInvalid = new SendError({ reason: "membership-invalid" });
|
|
195
|
+
return canonicalMessageAddress(to, selfName).pipe(Effect.flatMap((canonical) => {
|
|
196
|
+
if (canonical.kind === "direct") {
|
|
197
|
+
return op === "gather"
|
|
198
|
+
? Effect.succeed({
|
|
199
|
+
to: canonical.address,
|
|
200
|
+
members: [canonical.address],
|
|
201
|
+
})
|
|
202
|
+
: Effect.fail(membershipInvalid);
|
|
203
|
+
}
|
|
204
|
+
const [first, ...rest] = canonical.memberNames
|
|
205
|
+
.filter((name) => name !== selfName)
|
|
206
|
+
.flatMap((name) => Option.toArray(decodeAgentAddress(`agent:${name}`)));
|
|
207
|
+
return first === undefined
|
|
208
|
+
? Effect.fail(membershipInvalid)
|
|
209
|
+
: Effect.succeed({ to: canonical.address, members: [first, ...rest] });
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
/** Check a collecting operation's schema against the MCP form-mode grammar. */
|
|
213
|
+
function formModeSchema(id, requestedSchema) {
|
|
214
|
+
return Schema.decodeUnknown(FormModeSchema)(requestedSchema).pipe(Effect.mapError((error) => collectiveFailure(id, {
|
|
215
|
+
kind: "schema-invalid",
|
|
216
|
+
detail: ParseResult.TreeFormatter.formatErrorSync(error),
|
|
217
|
+
})));
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Validate a gather or all_gather before any post: mint its id, check its
|
|
221
|
+
* schema against the form-mode grammar, name its members, fix its absolute
|
|
222
|
+
* deadline, and build the request content within the content limit.
|
|
223
|
+
*/
|
|
224
|
+
function prepareGather(state, requestedTo, question, operation) {
|
|
225
|
+
return Effect.gen(function* () {
|
|
226
|
+
const { id, nonce } = yield* mintCollectiveId(state.ports.self);
|
|
227
|
+
const { to, members } = yield* gatherAddress(requestedTo, state.ports.self, operation.op).pipe(Effect.mapError(refusedAs(id, requestedTo)));
|
|
228
|
+
const refused = refusedAs(id, to);
|
|
229
|
+
const requestedSchema = yield* formModeSchema(id, operation.requestedSchema).pipe(Effect.mapError(refused));
|
|
230
|
+
const now = yield* Clock.currentTimeMillis;
|
|
231
|
+
const untilDeadline = Duration.toMillis(Duration.seconds(operation.deadline));
|
|
232
|
+
const deadlineAt = now + untilDeadline;
|
|
233
|
+
const content = yield* encodeCollectiveContent({
|
|
234
|
+
kind: "operation",
|
|
235
|
+
op: operation.op,
|
|
236
|
+
id,
|
|
237
|
+
nonce,
|
|
238
|
+
deadlineAt,
|
|
239
|
+
requestedSchema,
|
|
240
|
+
}, question).pipe(Effect.mapError(() => refused(contentInvalid())));
|
|
241
|
+
const open = {
|
|
242
|
+
op: operation.op,
|
|
243
|
+
to,
|
|
244
|
+
question,
|
|
245
|
+
members,
|
|
246
|
+
requestedSchema,
|
|
247
|
+
outcomes: new Map(),
|
|
248
|
+
answerHashes: new Map(),
|
|
249
|
+
};
|
|
250
|
+
return { id, open, content, deadlineAt, untilDeadline };
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Start the operation's deadline timer, open it so answers can be recorded
|
|
255
|
+
* while its requests are still being sent, and send them.
|
|
256
|
+
*/
|
|
257
|
+
function gather(state, requestedTo, question, operation) {
|
|
258
|
+
return Effect.gen(function* () {
|
|
259
|
+
const prepared = yield* prepareGather(state, requestedTo, question, operation);
|
|
260
|
+
const timer = yield* sleepUntil(prepared.deadlineAt).pipe(Effect.zipRight(completeGather(state, prepared.id)), Effect.forkIn(state.ports.scope));
|
|
261
|
+
yield* Effect.sync(() => {
|
|
262
|
+
state.gathers.set(prepared.id, { ...prepared.open, timer });
|
|
263
|
+
});
|
|
264
|
+
const requests = prepared.open.op === "gather"
|
|
265
|
+
? sendRequests(state, prepared)
|
|
266
|
+
: sendGroupRequest(state, prepared);
|
|
267
|
+
const postIds = yield* requests.pipe(Effect.mapError(refusedAs(prepared.id, prepared.open.to)));
|
|
268
|
+
return { operationId: prepared.id, postIds };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/** Sleep until the absolute time `at`, in steps no timer overflows. */
|
|
272
|
+
function sleepUntil(at) {
|
|
273
|
+
return Clock.currentTimeMillis.pipe(Effect.flatMap((now) => now >= at
|
|
274
|
+
? Effect.void
|
|
275
|
+
: Effect.sleep(Duration.min(Duration.millis(at - now), DEADLINE_TIMER_STEP)).pipe(Effect.zipRight(sleepUntil(at)))));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Send one request post per member and wait for them, never past the
|
|
279
|
+
* deadline. Any refusal abandons the gather and names every refused member;
|
|
280
|
+
* a post still pending when the wait ends is left running.
|
|
281
|
+
*/
|
|
282
|
+
function sendRequests(state, prepared) {
|
|
283
|
+
return Effect.gen(function* () {
|
|
284
|
+
const sends = yield* Effect.forEach(prepared.open.members, (member) => state.ports.sendPost({ to: member, content: prepared.content }).pipe(Effect.mapError((error) => ({ member, reason: error.reason })), Effect.either, Effect.forkIn(state.ports.scope)), { concurrency: 1 });
|
|
285
|
+
yield* Fiber.awaitAll(sends).pipe(Effect.timeoutOption(requestWait(state, prepared)));
|
|
286
|
+
const results = yield* settledSends(sends);
|
|
287
|
+
const [refusal, ...refusals] = results.flatMap((result) => Option.toArray(Either.getLeft(result)));
|
|
288
|
+
if (refusal !== undefined) {
|
|
289
|
+
yield* abandonGather(state, prepared.id, sends);
|
|
290
|
+
return yield* Effect.fail(collectiveFailure(prepared.id, {
|
|
291
|
+
kind: "members-unreachable",
|
|
292
|
+
members: [refusal, ...refusals],
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
return results.flatMap((result) => Option.toArray(Either.getRight(result)).map((post) => post.postId));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Send an all_gather's one request post to the group and wait for it, never
|
|
300
|
+
* past the deadline. A refused post, or one still uncertified when the wait
|
|
301
|
+
* ends, abandons the all_gather: the group's GENESIS needs every member. The
|
|
302
|
+
* operation is dropped before its unreachable members are looked up, so a
|
|
303
|
+
* deadline that passes during the lookups cannot close it.
|
|
304
|
+
*/
|
|
305
|
+
function sendGroupRequest(state, prepared) {
|
|
306
|
+
return state.ports
|
|
307
|
+
.sendPost({ to: prepared.open.to, content: prepared.content })
|
|
308
|
+
.pipe(Effect.timeoutFail({
|
|
309
|
+
duration: requestWait(state, prepared),
|
|
310
|
+
onTimeout: () => new SendError({ reason: "certification-unavailable" }),
|
|
311
|
+
}), Effect.map((post) => [post.postId]), Effect.catchAll((error) => forgetGather(state, prepared.id).pipe(Effect.zipRight(unreachableMembers(state, prepared.open.members, error.reason)), Effect.flatMap((members) => Effect.fail(collectiveFailure(prepared.id, {
|
|
312
|
+
kind: "members-unreachable",
|
|
313
|
+
members,
|
|
314
|
+
}))))));
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The members to name for a refused group post. A group post succeeds or
|
|
318
|
+
* fails as a whole, so each member is looked up on its own: the members whose
|
|
319
|
+
* lookup fails are named with their own reason, and when every lookup
|
|
320
|
+
* succeeds each member is named with the group post's reason.
|
|
321
|
+
*/
|
|
322
|
+
function unreachableMembers(state, members, groupReason) {
|
|
323
|
+
return Effect.forEach(members, (member) => state.ports.lookupMember(member).pipe(Effect.flip, Effect.map((error) => ({ member, reason: error.reason })), Effect.option), { concurrency: MEMBER_LOOKUP_CONCURRENCY }).pipe(Effect.map((lookups) => {
|
|
324
|
+
const [first, ...rest] = lookups.flatMap((lookup) => Option.toArray(lookup));
|
|
325
|
+
if (first !== undefined) {
|
|
326
|
+
return [first, ...rest];
|
|
327
|
+
}
|
|
328
|
+
const [member, ...others] = members;
|
|
329
|
+
return [
|
|
330
|
+
{ member, reason: groupReason },
|
|
331
|
+
...others.map((other) => ({ member: other, reason: groupReason })),
|
|
332
|
+
];
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
/** How long a send waits for request posts: the bound, never past the deadline. */
|
|
336
|
+
function requestWait(state, prepared) {
|
|
337
|
+
return Duration.min(state.ports.requestSendWait ?? REQUEST_SEND_WAIT, Duration.millis(prepared.untilDeadline));
|
|
338
|
+
}
|
|
339
|
+
/** The request sends that have finished, each with its post or refusal. */
|
|
340
|
+
function settledSends(sends) {
|
|
341
|
+
return Effect.forEach(sends, (send) => Fiber.poll(send), {
|
|
342
|
+
concurrency: 1,
|
|
343
|
+
}).pipe(Effect.map((polls) => polls.flatMap((poll) => Option.toArray(Option.flatMap(poll, (exit) => Exit.isSuccess(exit) ? Option.some(exit.value) : Option.none())))));
|
|
344
|
+
}
|
|
345
|
+
function abandonGather(state, id, sends) {
|
|
346
|
+
return Effect.forEach(sends, (send) => Fiber.interruptFork(send), {
|
|
347
|
+
concurrency: 1,
|
|
348
|
+
discard: true,
|
|
349
|
+
}).pipe(Effect.zipRight(forgetGather(state, id)));
|
|
350
|
+
}
|
|
351
|
+
/** Drop an operation this endpoint abandons, and stop its deadline timer. */
|
|
352
|
+
function forgetGather(state, id) {
|
|
353
|
+
return Effect.suspend(() => {
|
|
354
|
+
const open = state.gathers.get(id);
|
|
355
|
+
state.gathers.delete(id);
|
|
356
|
+
return open === undefined ? Effect.void : Fiber.interrupt(open.timer);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Complete an operation this endpoint started. A gather emits its result
|
|
361
|
+
* now; an all_gather first posts its close, off the calling fiber because
|
|
362
|
+
* completion can run inside a publication pass that certification waits on.
|
|
363
|
+
* An operation completes once; a later call does nothing. The deadline timer
|
|
364
|
+
* calls it too, so it leaves the timer to its caller.
|
|
365
|
+
*/
|
|
366
|
+
function completeGather(state, id) {
|
|
367
|
+
return Effect.suspend(() => {
|
|
368
|
+
const open = state.gathers.get(id);
|
|
369
|
+
if (open === undefined) {
|
|
370
|
+
return Effect.void;
|
|
371
|
+
}
|
|
372
|
+
state.gathers.delete(id);
|
|
373
|
+
const result = {
|
|
374
|
+
kind: "collectiveResult",
|
|
375
|
+
id,
|
|
376
|
+
to: open.to,
|
|
377
|
+
question: open.question,
|
|
378
|
+
outcomes: memberOutcomes(open.members, open.outcomes),
|
|
379
|
+
};
|
|
380
|
+
switch (open.op) {
|
|
381
|
+
case "gather":
|
|
382
|
+
return state.ports.emit(result);
|
|
383
|
+
case "all_gather":
|
|
384
|
+
return closeAllGather(state, open, result).pipe(Effect.forkIn(state.ports.scope), Effect.asVoid);
|
|
385
|
+
default: {
|
|
386
|
+
const exhaustive = open.op;
|
|
387
|
+
return exhaustive;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Post an all_gather's close, listing the certified record of every answer
|
|
394
|
+
* its result counts, and emit the result once the close is certified, naming
|
|
395
|
+
* the close post. A close that cannot be certified ends the operation as an
|
|
396
|
+
* `operationFailed` item, since members then have no result to agree on.
|
|
397
|
+
*/
|
|
398
|
+
function closeAllGather(state, open, result) {
|
|
399
|
+
const included = open.members.flatMap((member) => Option.toArray(Option.fromNullable(open.answerHashes.get(member))));
|
|
400
|
+
return encodeCollectiveContent({
|
|
401
|
+
kind: "close",
|
|
402
|
+
id: result.id,
|
|
403
|
+
included,
|
|
404
|
+
}).pipe(Effect.mapError(contentInvalid), Effect.flatMap((content) => state.ports.sendPost({ to: open.to, content })), Effect.matchEffect({
|
|
405
|
+
onFailure: (error) => state.ports.emit({
|
|
406
|
+
kind: "operationFailed",
|
|
407
|
+
id: result.id,
|
|
408
|
+
to: open.to,
|
|
409
|
+
error: `collective ${result.id} failed: its close was not certified (${error.reason})`,
|
|
410
|
+
}),
|
|
411
|
+
onSuccess: (post) => state.ports.emit({ ...result, closePostId: post.postId }),
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
function respond(state, response) {
|
|
415
|
+
return Effect.gen(function* () {
|
|
416
|
+
const { id } = response;
|
|
417
|
+
const now = yield* Clock.currentTimeMillis;
|
|
418
|
+
const request = yield* openRequest(state, id, now);
|
|
419
|
+
const refused = refusedAs(id, request.to);
|
|
420
|
+
const value = { kind: "response", ...response };
|
|
421
|
+
const content = yield* responseContent(request, value).pipe(Effect.mapError(refused));
|
|
422
|
+
yield* claimRequest(id, request).pipe(Effect.mapError(refused));
|
|
423
|
+
const post = yield* state.ports.sendPost({ to: request.to, content }).pipe(Effect.tapBoth({
|
|
424
|
+
onFailure: () => settleAnswer(state, id, request, Option.none()),
|
|
425
|
+
onSuccess: (sent) => settleAnswer(state, id, request, Option.some({ recordHash: sent.recordHash, response: value })),
|
|
426
|
+
}), Effect.onInterrupt(() => settleAnswer(state, id, request, Option.none())), Effect.mapError(refused));
|
|
427
|
+
return { operationId: id, postIds: [post.postId] };
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The received request a response answers, refused when this endpoint never
|
|
432
|
+
* received it, it was already answered, or its deadline or close has passed.
|
|
433
|
+
* A refusal for an unknown request is addressed to the local agent, since no
|
|
434
|
+
* requester is known.
|
|
435
|
+
*/
|
|
436
|
+
function openRequest(state, id, now) {
|
|
437
|
+
const request = state.requests.get(id);
|
|
438
|
+
if (request === undefined) {
|
|
439
|
+
return Effect.fail({
|
|
440
|
+
id,
|
|
441
|
+
to: state.ports.self,
|
|
442
|
+
error: collectiveFailure(id, { kind: "request-unknown" }),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const unavailable = requestUnavailable(request, now);
|
|
446
|
+
return unavailable === undefined
|
|
447
|
+
? Effect.succeed(request)
|
|
448
|
+
: Effect.fail({
|
|
449
|
+
id,
|
|
450
|
+
to: request.to,
|
|
451
|
+
error: collectiveFailure(id, { kind: unavailable }),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
function requestUnavailable(request, now) {
|
|
455
|
+
switch (request.state) {
|
|
456
|
+
case "open":
|
|
457
|
+
return now >= request.deadlineAt ? "request-expired" : undefined;
|
|
458
|
+
case "sending":
|
|
459
|
+
case "answered":
|
|
460
|
+
return "request-answered";
|
|
461
|
+
case "closed":
|
|
462
|
+
return "request-expired";
|
|
463
|
+
default: {
|
|
464
|
+
const exhaustive = request.state;
|
|
465
|
+
return exhaustive;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Validate an `accept` answer against the request's stored schema and build
|
|
471
|
+
* the response post's content, which carries no text.
|
|
472
|
+
*/
|
|
473
|
+
function responseContent(request, response) {
|
|
474
|
+
const validated = response.action === "accept"
|
|
475
|
+
? validateAnswer(request.requestedSchema, response.content).pipe(Effect.mapError((error) => collectiveFailure(response.id, {
|
|
476
|
+
kind: "answer-invalid",
|
|
477
|
+
fields: error.failures,
|
|
478
|
+
})))
|
|
479
|
+
: Effect.void;
|
|
480
|
+
return validated.pipe(Effect.zipRight(encodeCollectiveContent(response).pipe(Effect.mapError(contentInvalid))));
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Hold the request's one answer. The check and the claim run without a yield
|
|
484
|
+
* between them, so of two concurrent answers only one is sent.
|
|
485
|
+
*/
|
|
486
|
+
function claimRequest(id, request) {
|
|
487
|
+
return Effect.suspend(() => {
|
|
488
|
+
if (request.state !== "open") {
|
|
489
|
+
return Effect.fail(collectiveFailure(id, { kind: "request-answered" }));
|
|
490
|
+
}
|
|
491
|
+
request.state = "sending";
|
|
492
|
+
return Effect.void;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Settle a request's answer send: answered with the certified answer, or
|
|
497
|
+
* reopened when it was refused. A member's own answer never arrives inbound,
|
|
498
|
+
* so an all_gather member records it here, and applies a close that was
|
|
499
|
+
* waiting for it; after a refused answer that close lists a record this
|
|
500
|
+
* endpoint will not hold, so applying it drops it. A request already closed
|
|
501
|
+
* stays closed: its result is out, and a close delivered again must not
|
|
502
|
+
* apply a second time.
|
|
503
|
+
*/
|
|
504
|
+
function settleAnswer(state, id, request, certified) {
|
|
505
|
+
return Effect.suspend(() => {
|
|
506
|
+
if (request.state === "closed") {
|
|
507
|
+
return Effect.void;
|
|
508
|
+
}
|
|
509
|
+
request.state = Option.isSome(certified) ? "answered" : "open";
|
|
510
|
+
const shared = request.shared;
|
|
511
|
+
if (shared === undefined) {
|
|
512
|
+
return Effect.void;
|
|
513
|
+
}
|
|
514
|
+
if (Option.isSome(certified)) {
|
|
515
|
+
shared.answers.set(state.ports.self, certified.value);
|
|
516
|
+
}
|
|
517
|
+
const held = shared.heldClose;
|
|
518
|
+
shared.heldClose = undefined;
|
|
519
|
+
return held === undefined
|
|
520
|
+
? Effect.void
|
|
521
|
+
: applyClose(state, { id, request, shared }, held);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function classify(state, post) {
|
|
525
|
+
const { message } = post;
|
|
526
|
+
return readCollectiveValue(message.content).pipe(Effect.flatMap(Option.match({
|
|
527
|
+
onNone: () => Effect.succeed(Option.some({ kind: "multicast", message })),
|
|
528
|
+
onSome: (value) => collectiveItem(state, post, value),
|
|
529
|
+
})), Effect.catchTag("CollectivePartInvalidError", (error) => Effect.logWarning(`consumed post ${message.postId} from ${message.sender}: its collective part is ${error.reason}`).pipe(Effect.as(Option.none()))));
|
|
530
|
+
}
|
|
531
|
+
function collectiveItem(state, post, value) {
|
|
532
|
+
switch (value.kind) {
|
|
533
|
+
case "operation":
|
|
534
|
+
return operationItem(state, post.message, value);
|
|
535
|
+
case "response":
|
|
536
|
+
return recordAnswer(state, post, value).pipe(Effect.as(Option.none()));
|
|
537
|
+
case "close":
|
|
538
|
+
return receiveClose(state, post.message, value).pipe(Effect.as(Option.none()));
|
|
539
|
+
default: {
|
|
540
|
+
const exhaustive = value;
|
|
541
|
+
return exhaustive;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function operationItem(state, message, value) {
|
|
546
|
+
switch (value.op) {
|
|
547
|
+
case "multicast":
|
|
548
|
+
return Effect.succeed(multicastItem(message, withoutCollectivePart(message.content)));
|
|
549
|
+
case "gather":
|
|
550
|
+
case "all_gather":
|
|
551
|
+
return requestItem(state, message, value);
|
|
552
|
+
default: {
|
|
553
|
+
const exhaustive = value;
|
|
554
|
+
return exhaustive;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function multicastItem(message, content) {
|
|
559
|
+
return EffectArray.isNonEmptyReadonlyArray(content)
|
|
560
|
+
? Option.some({ kind: "multicast", message: { ...message, content } })
|
|
561
|
+
: Option.none();
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* The request state a request post opens, when it arrived where its
|
|
565
|
+
* operation sends it and its id derives from its sender: a gather's in the
|
|
566
|
+
* member's direct conversation with the requester before its deadline, an
|
|
567
|
+
* all_gather's in a group conversation, whose other members are the ones
|
|
568
|
+
* asked, until `CLOSE_WAIT` past its deadline. A deadline beyond
|
|
569
|
+
* `RECEIVED_DEADLINE_HORIZON` opens nothing.
|
|
570
|
+
*/
|
|
571
|
+
function receivedRequest(message, value, now) {
|
|
572
|
+
if (!trustedRequest(message, value, now)) {
|
|
573
|
+
return Option.none();
|
|
574
|
+
}
|
|
575
|
+
const request = {
|
|
576
|
+
postId: message.postId,
|
|
577
|
+
from: message.sender,
|
|
578
|
+
to: message.address,
|
|
579
|
+
requestedSchema: value.requestedSchema,
|
|
580
|
+
deadlineAt: value.deadlineAt,
|
|
581
|
+
state: "open",
|
|
582
|
+
};
|
|
583
|
+
if (value.op === "gather") {
|
|
584
|
+
return message.kind === "direct" && now < value.deadlineAt
|
|
585
|
+
? Option.some(request)
|
|
586
|
+
: Option.none();
|
|
587
|
+
}
|
|
588
|
+
if (message.kind !== "group" || now >= retainedUntil(request, true)) {
|
|
589
|
+
return Option.none();
|
|
590
|
+
}
|
|
591
|
+
const [first, ...rest] = message.members.filter((member) => member !== message.sender);
|
|
592
|
+
return first === undefined
|
|
593
|
+
? Option.none()
|
|
594
|
+
: Option.some({
|
|
595
|
+
...request,
|
|
596
|
+
shared: {
|
|
597
|
+
question: questionText(message.content),
|
|
598
|
+
members: [first, ...rest],
|
|
599
|
+
answers: new Map(),
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Whether a request's id derives from its sender and its deadline lies
|
|
605
|
+
* within `RECEIVED_DEADLINE_HORIZON`.
|
|
606
|
+
*/
|
|
607
|
+
function trustedRequest(message, value, now) {
|
|
608
|
+
return (value.deadlineAt <= now + RECEIVED_DEADLINE_HORIZON &&
|
|
609
|
+
collectiveIdOf(message.sender, value.nonce) === value.id);
|
|
610
|
+
}
|
|
611
|
+
function questionText(content) {
|
|
612
|
+
return withoutCollectivePart(content)
|
|
613
|
+
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
|
614
|
+
.join("\n");
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Record a received gather or all_gather request and present it as an item.
|
|
618
|
+
* A request `receivedRequest` opens nothing for is consumed. So is a second
|
|
619
|
+
* request post reusing an id this endpoint holds: the answer goes to the
|
|
620
|
+
* conversation of the first, so presenting the second would show the host a
|
|
621
|
+
* different audience. An all_gather request past its deadline is kept
|
|
622
|
+
* without being presented, so a member that catches up late still applies
|
|
623
|
+
* the close and receives the result.
|
|
624
|
+
*/
|
|
625
|
+
function requestItem(state, message, value) {
|
|
626
|
+
return Clock.currentTimeMillis.pipe(Effect.map((now) => {
|
|
627
|
+
const received = receivedRequest(message, value, now);
|
|
628
|
+
const held = state.requests.get(value.id);
|
|
629
|
+
if (Option.isNone(received) ||
|
|
630
|
+
(held ?? received.value).postId !== message.postId) {
|
|
631
|
+
return Option.none();
|
|
632
|
+
}
|
|
633
|
+
if (held === undefined) {
|
|
634
|
+
forgetExpiredRequests(state, now);
|
|
635
|
+
state.requests.set(value.id, received.value);
|
|
636
|
+
}
|
|
637
|
+
return decodeRequestedSchema(value.requestedSchema).pipe(Option.filter(() => now < value.deadlineAt), Option.map((requestedSchema) => ({
|
|
638
|
+
kind: "collectiveRequest",
|
|
639
|
+
id: value.id,
|
|
640
|
+
postId: message.postId,
|
|
641
|
+
from: message.sender,
|
|
642
|
+
to: received.value.to,
|
|
643
|
+
question: questionText(message.content),
|
|
644
|
+
requestedSchema,
|
|
645
|
+
deadlineAt: value.deadlineAt,
|
|
646
|
+
})));
|
|
647
|
+
}));
|
|
648
|
+
}
|
|
649
|
+
/** Forget every request past the time `retainedUntil` gives it. */
|
|
650
|
+
function forgetExpiredRequests(state, now) {
|
|
651
|
+
for (const [id, request] of state.requests) {
|
|
652
|
+
if (now >= retainedUntil(request, request.shared !== undefined)) {
|
|
653
|
+
state.requests.delete(id);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* When this endpoint drops a received request: at its deadline, or for an
|
|
659
|
+
* all_gather `CLOSE_WAIT` later, since its close follows the deadline.
|
|
660
|
+
*/
|
|
661
|
+
function retainedUntil(request, allGather) {
|
|
662
|
+
return request.deadlineAt + (allGather ? Duration.toMillis(CLOSE_WAIT) : 0);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Record an answer. At the requester, only a member's post in the
|
|
666
|
+
* operation's own conversation counts, and only its first; an answer to a
|
|
667
|
+
* completed or unknown operation changes nothing, and the last outstanding
|
|
668
|
+
* answer completes the operation. At an all_gather member, every member's
|
|
669
|
+
* answer in the group conversation is kept by record hash until the close.
|
|
670
|
+
*/
|
|
671
|
+
function recordAnswer(state, post, value) {
|
|
672
|
+
return Effect.suspend(() => {
|
|
673
|
+
const open = state.gathers.get(value.id);
|
|
674
|
+
if (open !== undefined) {
|
|
675
|
+
return recordCountedAnswer(state, open, post, value);
|
|
676
|
+
}
|
|
677
|
+
const request = state.requests.get(value.id);
|
|
678
|
+
if (request?.shared !== undefined) {
|
|
679
|
+
recordPeerAnswer(request, request.shared, post, value);
|
|
680
|
+
}
|
|
681
|
+
return Effect.void;
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
function recordCountedAnswer(state, open, post, value) {
|
|
685
|
+
const { message } = post;
|
|
686
|
+
if (!answeredInConversation(open, message) ||
|
|
687
|
+
!open.members.includes(message.sender) ||
|
|
688
|
+
open.outcomes.has(message.sender)) {
|
|
689
|
+
return Effect.void;
|
|
690
|
+
}
|
|
691
|
+
return outcomeOfResponse(open.requestedSchema, value).pipe(Effect.flatMap((outcome) => {
|
|
692
|
+
open.outcomes.set(message.sender, outcome);
|
|
693
|
+
open.answerHashes.set(message.sender, post.recordHash);
|
|
694
|
+
return open.outcomes.size === open.members.length
|
|
695
|
+
? completeGather(state, value.id).pipe(Effect.zipRight(Fiber.interrupt(open.timer)))
|
|
696
|
+
: Effect.void;
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
/** Whether an answer arrived where the operation's answers are sent. */
|
|
700
|
+
function answeredInConversation(open, message) {
|
|
701
|
+
switch (open.op) {
|
|
702
|
+
case "gather":
|
|
703
|
+
return message.kind === "direct";
|
|
704
|
+
case "all_gather":
|
|
705
|
+
return message.kind === "group" && message.address === open.to;
|
|
706
|
+
default: {
|
|
707
|
+
const exhaustive = open.op;
|
|
708
|
+
return exhaustive;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function recordPeerAnswer(request, shared, post, value) {
|
|
713
|
+
const { message } = post;
|
|
714
|
+
if (request.state === "closed" ||
|
|
715
|
+
message.kind !== "group" ||
|
|
716
|
+
message.address !== request.to) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
keepFirstAnswer(shared, message.sender, {
|
|
720
|
+
recordHash: post.recordHash,
|
|
721
|
+
response: value,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Receive an all_gather's close. Only the requester's close in the group
|
|
726
|
+
* conversation of a request this endpoint holds counts. The held request's id
|
|
727
|
+
* derives from its requester, so a close from any other sender names an id
|
|
728
|
+
* that does not derive from that sender; it, and a close for an id this
|
|
729
|
+
* endpoint does not hold, is consumed and logged. Only the first close in the
|
|
730
|
+
* conversation counts, as at every other member: a later one, including one
|
|
731
|
+
* that arrives while the first is held, changes nothing.
|
|
732
|
+
*/
|
|
733
|
+
function receiveClose(state, message, value) {
|
|
734
|
+
return Effect.suspend(() => {
|
|
735
|
+
const request = state.requests.get(value.id);
|
|
736
|
+
if (request?.shared === undefined ||
|
|
737
|
+
message.kind !== "group" ||
|
|
738
|
+
message.address !== request.to ||
|
|
739
|
+
message.sender !== request.from) {
|
|
740
|
+
return Effect.logWarning(`consumed close ${message.postId} from ${message.sender}: it closes no all_gather this endpoint was asked by its sender`);
|
|
741
|
+
}
|
|
742
|
+
if (request.state === "closed" || request.shared.heldClose !== undefined) {
|
|
743
|
+
return Effect.void;
|
|
744
|
+
}
|
|
745
|
+
return applyClose(state, { id: value.id, request, shared: request.shared }, { postId: message.postId, included: value.included });
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Build a member's all_gather result from exactly the answers a close lists,
|
|
750
|
+
* and emit it.
|
|
751
|
+
*
|
|
752
|
+
* Every listed answer precedes the close in the group conversation's
|
|
753
|
+
* certified chain. The store appends a conversation's records only in chain
|
|
754
|
+
* order and pending deliveries are read in that order, so each peer answer
|
|
755
|
+
* the close lists was recorded before the close is classified. The member's
|
|
756
|
+
* own answer is not delivered inbound: it is recorded when its send returns,
|
|
757
|
+
* and a close that arrives while that send is in flight is held until it
|
|
758
|
+
* returns. A listed record the endpoint still does not hold, or one listed
|
|
759
|
+
* twice, means the close does not describe this endpoint's history, for
|
|
760
|
+
* instance after a daemon restart; it is consumed and logged, and no result
|
|
761
|
+
* is emitted. Once closed, the request keeps no answers.
|
|
762
|
+
*/
|
|
763
|
+
function applyClose(state, asked, close) {
|
|
764
|
+
const { id, request, shared } = asked;
|
|
765
|
+
const { answers, missing, repeated } = listedAnswers(shared, close.included);
|
|
766
|
+
if (missing && request.state === "sending") {
|
|
767
|
+
shared.heldClose = close;
|
|
768
|
+
return Effect.void;
|
|
769
|
+
}
|
|
770
|
+
request.state = "closed";
|
|
771
|
+
shared.answers.clear();
|
|
772
|
+
if (missing || repeated) {
|
|
773
|
+
return Effect.logWarning(`consumed close ${close.postId} of ${id}: it lists answers this endpoint does not hold`);
|
|
774
|
+
}
|
|
775
|
+
return Effect.forEach(answers, (answer) => outcomeOfResponse(request.requestedSchema, answer.response).pipe(Effect.map((outcome) => [answer.member, outcome])), { concurrency: 1 }).pipe(Effect.flatMap((outcomes) => state.ports.emit({
|
|
776
|
+
kind: "collectiveResult",
|
|
777
|
+
id,
|
|
778
|
+
to: request.to,
|
|
779
|
+
question: shared.question,
|
|
780
|
+
outcomes: memberOutcomes(shared.members, new Map(outcomes)),
|
|
781
|
+
closePostId: close.postId,
|
|
782
|
+
})));
|
|
783
|
+
}
|
|
784
|
+
//# sourceMappingURL=operation.js.map
|