@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.
Files changed (101) hide show
  1. package/README.md +23 -7
  2. package/dist/client-runtime.d.ts.map +1 -1
  3. package/dist/client-runtime.js +28 -11
  4. package/dist/client-runtime.js.map +1 -1
  5. package/dist/contract.d.ts +440 -57
  6. package/dist/contract.d.ts.map +1 -1
  7. package/dist/contract.js +256 -18
  8. package/dist/contract.js.map +1 -1
  9. package/dist/contract.types-check.d.ts +109 -13
  10. package/dist/contract.types-check.d.ts.map +1 -1
  11. package/dist/contract.types-check.js +11 -3
  12. package/dist/contract.types-check.js.map +1 -1
  13. package/dist/daemon/runtime/activation.d.ts +1 -1
  14. package/dist/daemon/runtime/activation.d.ts.map +1 -1
  15. package/dist/daemon/runtime/controller.d.ts +1 -1
  16. package/dist/daemon/runtime/controller.d.ts.map +1 -1
  17. package/dist/daemon/runtime/controller.js +44 -10
  18. package/dist/daemon/runtime/controller.js.map +1 -1
  19. package/dist/daemon/runtime/history-export.d.ts +11 -1
  20. package/dist/daemon/runtime/history-export.d.ts.map +1 -1
  21. package/dist/daemon/runtime/history-export.js +4 -0
  22. package/dist/daemon/runtime/history-export.js.map +1 -1
  23. package/dist/daemon/runtime/index.d.ts.map +1 -1
  24. package/dist/daemon/runtime/index.js +1 -2
  25. package/dist/daemon/runtime/index.js.map +1 -1
  26. package/dist/daemon/runtime/pending-delivery.d.ts +49 -0
  27. package/dist/daemon/runtime/pending-delivery.d.ts.map +1 -0
  28. package/dist/daemon/runtime/pending-delivery.js +115 -0
  29. package/dist/daemon/runtime/pending-delivery.js.map +1 -0
  30. package/dist/daemon/runtime/protocol.d.ts +20 -5
  31. package/dist/daemon/runtime/protocol.d.ts.map +1 -1
  32. package/dist/daemon/runtime/protocol.js +58 -24
  33. package/dist/daemon/runtime/protocol.js.map +1 -1
  34. package/dist/endpoint/addressing/index.d.ts +22 -1
  35. package/dist/endpoint/addressing/index.d.ts.map +1 -1
  36. package/dist/endpoint/addressing/index.js +50 -26
  37. package/dist/endpoint/addressing/index.js.map +1 -1
  38. package/dist/endpoint/collective/operation.d.ts +123 -0
  39. package/dist/endpoint/collective/operation.d.ts.map +1 -0
  40. package/dist/endpoint/collective/operation.js +784 -0
  41. package/dist/endpoint/collective/operation.js.map +1 -0
  42. package/dist/endpoint/collective/shared-answers.d.ts +78 -0
  43. package/dist/endpoint/collective/shared-answers.d.ts.map +1 -0
  44. package/dist/endpoint/collective/shared-answers.js +62 -0
  45. package/dist/endpoint/collective/shared-answers.js.map +1 -0
  46. package/dist/endpoint/collective/validation.d.ts +49 -0
  47. package/dist/endpoint/collective/validation.d.ts.map +1 -0
  48. package/dist/endpoint/collective/validation.js +99 -0
  49. package/dist/endpoint/collective/validation.js.map +1 -0
  50. package/dist/endpoint/collective/wire.d.ts +176 -0
  51. package/dist/endpoint/collective/wire.d.ts.map +1 -0
  52. package/dist/endpoint/collective/wire.js +181 -0
  53. package/dist/endpoint/collective/wire.js.map +1 -0
  54. package/dist/endpoint/engine-durability.d.ts +3 -17
  55. package/dist/endpoint/engine-durability.d.ts.map +1 -1
  56. package/dist/endpoint/engine-durability.js +3 -14
  57. package/dist/endpoint/engine-durability.js.map +1 -1
  58. package/dist/endpoint/engine-send.d.ts +14 -16
  59. package/dist/endpoint/engine-send.d.ts.map +1 -1
  60. package/dist/endpoint/engine-send.js +16 -20
  61. package/dist/endpoint/engine-send.js.map +1 -1
  62. package/dist/endpoint/engine-types.d.ts +32 -15
  63. package/dist/endpoint/engine-types.d.ts.map +1 -1
  64. package/dist/endpoint/engine-types.js +1 -5
  65. package/dist/endpoint/engine-types.js.map +1 -1
  66. package/dist/endpoint/engine.d.ts +1 -1
  67. package/dist/endpoint/engine.d.ts.map +1 -1
  68. package/dist/endpoint/engine.js +17 -14
  69. package/dist/endpoint/engine.js.map +1 -1
  70. package/dist/endpoint/protocol/index.d.ts.map +1 -1
  71. package/dist/endpoint/protocol/index.js +5 -8
  72. package/dist/endpoint/protocol/index.js.map +1 -1
  73. package/dist/endpoint/recovery/index.js +2 -2
  74. package/dist/endpoint/recovery/index.js.map +1 -1
  75. package/dist/endpoint/recovery/reanchor/empty.js +1 -1
  76. package/dist/endpoint/recovery/reanchor/empty.js.map +1 -1
  77. package/dist/endpoint/recovery/store.d.ts +1 -1
  78. package/dist/endpoint/recovery/store.d.ts.map +1 -1
  79. package/dist/endpoint/recovery/store.js +9 -5
  80. package/dist/endpoint/recovery/store.js.map +1 -1
  81. package/dist/endpoint/representation-schemas.d.ts +2 -0
  82. package/dist/endpoint/representation-schemas.d.ts.map +1 -1
  83. package/dist/endpoint/representation-schemas.js +2 -2
  84. package/dist/endpoint/representation-schemas.js.map +1 -1
  85. package/dist/endpoint/representation.d.ts +1 -1
  86. package/dist/endpoint/representation.d.ts.map +1 -1
  87. package/dist/endpoint/representation.js +1 -1
  88. package/dist/endpoint/representation.js.map +1 -1
  89. package/dist/harness-mcp-contract.d.ts +189 -57
  90. package/dist/harness-mcp-contract.d.ts.map +1 -1
  91. package/dist/harness-mcp-contract.js +61 -9
  92. package/dist/harness-mcp-contract.js.map +1 -1
  93. package/dist/harness-mcp-wire.d.ts +3 -3
  94. package/dist/harness-mcp-wire.d.ts.map +1 -1
  95. package/dist/harness-mcp-wire.js +38 -28
  96. package/dist/harness-mcp-wire.js.map +1 -1
  97. package/dist/index.d.ts +1 -1
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2 -1
  100. package/dist/index.js.map +1 -1
  101. 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