@onlyne/lib 3.0.0 → 3.2.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 +6 -3
- package/dist/awaited.d.ts +43 -0
- package/dist/awaited.d.ts.map +1 -0
- package/dist/awaited.js +82 -0
- package/dist/awaited.js.map +1 -0
- package/dist/client.d.ts +140 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1772 -0
- package/dist/client.js.map +1 -0
- package/dist/core.d.ts +36 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +122 -0
- package/dist/core.js.map +1 -0
- package/dist/delivery.d.ts +120 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +117 -0
- package/dist/delivery.js.map +1 -0
- package/dist/discovery.d.ts +35 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +306 -0
- package/dist/discovery.js.map +1 -0
- package/dist/domains.d.ts +29 -0
- package/dist/domains.d.ts.map +1 -0
- package/dist/domains.js +267 -0
- package/dist/domains.js.map +1 -0
- package/dist/history.d.ts +13 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +143 -0
- package/dist/history.js.map +1 -0
- package/dist/http.d.ts +20 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +88 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +97 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +679 -0
- package/dist/keys.js.map +1 -0
- package/dist/memory.d.ts +9 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +35 -0
- package/dist/memory.js.map +1 -0
- package/dist/messages.d.ts +64 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +480 -0
- package/dist/messages.js.map +1 -0
- package/dist/node.d.ts +26 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +59 -0
- package/dist/node.js.map +1 -0
- package/dist/resolve.d.ts +18 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +37 -0
- package/dist/resolve.js.map +1 -0
- package/dist/resources.d.ts +25 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +782 -0
- package/dist/resources.js.map +1 -0
- package/dist/routes/domains.d.ts +4 -0
- package/dist/routes/domains.d.ts.map +1 -0
- package/dist/routes/domains.js +42 -0
- package/dist/routes/domains.js.map +1 -0
- package/dist/routes/grants.d.ts +8 -0
- package/dist/routes/grants.d.ts.map +1 -0
- package/dist/routes/grants.js +32 -0
- package/dist/routes/grants.js.map +1 -0
- package/dist/routes/public.d.ts +4 -0
- package/dist/routes/public.d.ts.map +1 -0
- package/dist/routes/public.js +62 -0
- package/dist/routes/public.js.map +1 -0
- package/dist/routes/resources.d.ts +8 -0
- package/dist/routes/resources.d.ts.map +1 -0
- package/dist/routes/resources.js +87 -0
- package/dist/routes/resources.js.map +1 -0
- package/dist/routes/shared.d.ts +77 -0
- package/dist/routes/shared.d.ts.map +1 -0
- package/dist/routes/shared.js +349 -0
- package/dist/routes/shared.js.map +1 -0
- package/dist/routes/threads.d.ts +8 -0
- package/dist/routes/threads.d.ts.map +1 -0
- package/dist/routes/threads.js +113 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/trust.d.ts +4 -0
- package/dist/routes/trust.d.ts.map +1 -0
- package/dist/routes/trust.js +36 -0
- package/dist/routes/trust.js.map +1 -0
- package/dist/stream.d.ts +20 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +141 -0
- package/dist/stream.js.map +1 -0
- package/dist/threads.d.ts +79 -0
- package/dist/threads.d.ts.map +1 -0
- package/dist/threads.js +480 -0
- package/dist/threads.js.map +1 -0
- package/dist/transport.d.ts +45 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +175 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +809 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +16 -8
- package/src/client.ts +0 -1549
- package/src/core.ts +0 -114
- package/src/discovery.ts +0 -267
- package/src/domains.ts +0 -210
- package/src/history.ts +0 -129
- package/src/http.ts +0 -841
- package/src/index.ts +0 -18
- package/src/keys.ts +0 -632
- package/src/memory.ts +0 -34
- package/src/messages.ts +0 -398
- package/src/node.ts +0 -56
- package/src/resources.ts +0 -745
- package/src/threads.ts +0 -350
- package/src/transport.ts +0 -204
- package/src/types.ts +0 -383
package/dist/client.js
ADDED
|
@@ -0,0 +1,1772 @@
|
|
|
1
|
+
var _a;
|
|
2
|
+
import { canonical, decimal, fail, key, page, requireValue, utc } from "./core.js";
|
|
3
|
+
import { trustPhase } from "./types.js";
|
|
4
|
+
import { FAILURE_WINDOW_MS, GRANT_RESYNC_MS, MAX_ATTEMPTS, MAX_CURSOR_PAGES, RESOURCE_CHECK_MS, admissionOf, attentionOf, backoffMs, consumptionKey, failureOf, grantResyncKey, inboxKey, isReply, messageDigest, outboundKey, reconcileBackoffKey, replayOrder, todoKey, } from "./delivery.js";
|
|
5
|
+
import { AwaitedReplies } from "./awaited.js";
|
|
6
|
+
import { workRef } from "./routes/shared.js";
|
|
7
|
+
export class OnlyneClient {
|
|
8
|
+
#ctx;
|
|
9
|
+
#transport;
|
|
10
|
+
#messages;
|
|
11
|
+
#threads;
|
|
12
|
+
#resources;
|
|
13
|
+
#domains;
|
|
14
|
+
#trust;
|
|
15
|
+
#loop;
|
|
16
|
+
/** The maintenance cadence the loop uses while durable work is pending or the host just woke it. */
|
|
17
|
+
static ACTIVE_INTERVAL_MS = 2_000;
|
|
18
|
+
/** The floor the loop drops to once the durable ledger holds no pending work. */
|
|
19
|
+
static IDLE_INTERVAL_MS = 30_000;
|
|
20
|
+
/** Live awaits and the tool-result holds they place. */
|
|
21
|
+
#awaiting = new AwaitedReplies();
|
|
22
|
+
/** Last failure line per scope, and what its window suppressed; one map for every repeated line. */
|
|
23
|
+
#failureWindows = new Map();
|
|
24
|
+
constructor(ctx, transport, messages, threads, resources, domains, trust) {
|
|
25
|
+
this.#ctx = ctx;
|
|
26
|
+
this.#transport = transport;
|
|
27
|
+
this.#messages = messages;
|
|
28
|
+
this.#threads = threads;
|
|
29
|
+
this.#resources = resources;
|
|
30
|
+
this.#domains = domains;
|
|
31
|
+
this.#trust = trust;
|
|
32
|
+
const clock = ctx.options.plugins.maintenanceClock;
|
|
33
|
+
if (clock !== undefined) {
|
|
34
|
+
this.#loop = { clock, controller: new AbortController(), active: { intervalMs: _a.ACTIVE_INTERVAL_MS }, handle: undefined, lastPending: true, running: false };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* RFC 0017 §6: the library-owned maintenance loop. One pass runs `maintain`,
|
|
39
|
+
* `recover`, then re-arms: pending durable work keeps the active cadence, an idle
|
|
40
|
+
* ledger drops to the idle floor. Every clock read goes through the injected
|
|
41
|
+
* `MaintenanceClock`. A pass is never re-entered; a wake during a pass schedules
|
|
42
|
+
* the next tick at the active cadence. `notify` carries every pending-work change.
|
|
43
|
+
*/
|
|
44
|
+
runMaintenance() {
|
|
45
|
+
const loop = this.#loop;
|
|
46
|
+
if (loop === undefined)
|
|
47
|
+
fail('unsupported_delivery', 'The host supplied no maintenance clock, so the library cannot own the loop. Pass plugins.maintenanceClock or schedule maintain() yourself.');
|
|
48
|
+
if (loop.handle !== undefined)
|
|
49
|
+
return;
|
|
50
|
+
if (loop.controller.signal.aborted)
|
|
51
|
+
loop.controller = new AbortController();
|
|
52
|
+
this.#arm(loop, _a.ACTIVE_INTERVAL_MS);
|
|
53
|
+
}
|
|
54
|
+
stopMaintenance() {
|
|
55
|
+
const loop = this.#loop;
|
|
56
|
+
if (loop === undefined)
|
|
57
|
+
return;
|
|
58
|
+
loop.controller.abort();
|
|
59
|
+
if (loop.handle === undefined)
|
|
60
|
+
return;
|
|
61
|
+
loop.clock.clearTimer(loop.handle);
|
|
62
|
+
loop.handle = undefined;
|
|
63
|
+
}
|
|
64
|
+
/** @internal Flips the loop to its active cadence: new evidence arrived while it idled. */
|
|
65
|
+
wakeLoop() {
|
|
66
|
+
const loop = this.#loop;
|
|
67
|
+
if (loop === undefined || loop.controller.signal.aborted)
|
|
68
|
+
return;
|
|
69
|
+
if (loop.active.intervalMs !== _a.ACTIVE_INTERVAL_MS)
|
|
70
|
+
this.#arm(loop, _a.ACTIVE_INTERVAL_MS);
|
|
71
|
+
}
|
|
72
|
+
/** @internal Schedules an immediate maintenance pass when new inbound or grant work lands locally. */
|
|
73
|
+
wakeImmediate() {
|
|
74
|
+
const loop = this.#loop;
|
|
75
|
+
if (loop === undefined || loop.controller.signal.aborted || loop.handle === undefined)
|
|
76
|
+
return;
|
|
77
|
+
if (loop.running) {
|
|
78
|
+
loop.rerunImmediate = true;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (loop.active.intervalMs !== 0)
|
|
82
|
+
this.#arm(loop, 0);
|
|
83
|
+
}
|
|
84
|
+
#arm(loop, intervalMs) {
|
|
85
|
+
if (loop.handle !== undefined)
|
|
86
|
+
loop.clock.clearTimer(loop.handle);
|
|
87
|
+
loop.active.intervalMs = intervalMs;
|
|
88
|
+
loop.handle = loop.clock.setTimer(intervalMs, () => this.#tick(loop));
|
|
89
|
+
}
|
|
90
|
+
async #tick(loop) {
|
|
91
|
+
// The timer that fired is spent. Clear it instead of dropping the handle: a host clock
|
|
92
|
+
// that keeps a fired timer registered would otherwise leak one handle per pass, and
|
|
93
|
+
// `stopMaintenance` could only ever clear the newest.
|
|
94
|
+
if (loop.handle !== undefined) {
|
|
95
|
+
loop.clock.clearTimer(loop.handle);
|
|
96
|
+
loop.handle = undefined;
|
|
97
|
+
}
|
|
98
|
+
if (loop.running)
|
|
99
|
+
return this.#arm(loop, _a.ACTIVE_INTERVAL_MS);
|
|
100
|
+
loop.running = true;
|
|
101
|
+
loop.rerunImmediate = false;
|
|
102
|
+
try {
|
|
103
|
+
await this.maintain(loop.controller.signal);
|
|
104
|
+
await this.#recover(loop.controller.signal, true);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A failed pass keeps its owed records; the next pass retries them.
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
loop.running = false;
|
|
111
|
+
}
|
|
112
|
+
let pending = true;
|
|
113
|
+
try {
|
|
114
|
+
pending = await this.hasPendingWork();
|
|
115
|
+
}
|
|
116
|
+
catch { /* unknown keeps the active cadence */ }
|
|
117
|
+
if (pending !== loop.lastPending) {
|
|
118
|
+
loop.lastPending = pending;
|
|
119
|
+
this.#ctx.options.plugins.runtime?.notify?.({ event: 'work-pending', pending });
|
|
120
|
+
}
|
|
121
|
+
if (!loop.controller.signal.aborted) {
|
|
122
|
+
const nextMs = loop.rerunImmediate ? 0 : (pending ? _a.ACTIVE_INTERVAL_MS : _a.IDLE_INTERVAL_MS);
|
|
123
|
+
loop.rerunImmediate = false;
|
|
124
|
+
this.#arm(loop, nextMs);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ---- delivery ---------------------------------------------------------------
|
|
128
|
+
/**
|
|
129
|
+
* Delivers one message under a caller-supplied plan. The plan is never re-derived
|
|
130
|
+
* here: RFC 0005 §2 forbids the library from choosing a delivery mode itself.
|
|
131
|
+
*/
|
|
132
|
+
async send(to, message, plan, options = {}) {
|
|
133
|
+
const ctx = this.#ctx;
|
|
134
|
+
this.wakeLoop();
|
|
135
|
+
if (message.sender !== ctx.options.node_id) {
|
|
136
|
+
fail('forbidden', 'This node may only deliver messages this node authors.');
|
|
137
|
+
}
|
|
138
|
+
if (typeof to !== 'string' || to.length === 0)
|
|
139
|
+
fail('invalid_request', 'A delivery names its target endpoint.');
|
|
140
|
+
if (plan.response === 'await')
|
|
141
|
+
await this.#ensureAwaitable(plan, options.signal);
|
|
142
|
+
// RFC 0005 §3: an await that only this node could answer is a cycle, not a wait.
|
|
143
|
+
if (plan.response === 'await' && (to === ctx.options.node_id || options.expected_author === ctx.options.node_id)) {
|
|
144
|
+
fail('unsupported_delivery', 'This node cannot await a reply only it could author; no message was sent.');
|
|
145
|
+
}
|
|
146
|
+
// `to` is the participant this message addresses. The thread decides where the message is
|
|
147
|
+
// stored: an initiator owns the thread for the whole conversation, so the storing node is
|
|
148
|
+
// the thread owner and not necessarily the addressee.
|
|
149
|
+
const thread = message.meta?.thread ?? await this.#threadOf(message.meta?.in_reply_to);
|
|
150
|
+
// A reply whose original this node cannot see is answered through the reply authority,
|
|
151
|
+
// which resolves the landing from its own record. A reply never opens a conversation.
|
|
152
|
+
if (thread === undefined && message.meta?.in_reply_to !== undefined)
|
|
153
|
+
return this.#sendUntracked(to, message, plan, options);
|
|
154
|
+
if (thread === undefined && to === ctx.options.node_id) {
|
|
155
|
+
// A self-addressed message with no conversation yet lands on this node's own default.
|
|
156
|
+
return this.#admitHere(message, plan);
|
|
157
|
+
}
|
|
158
|
+
const ref = thread ?? await this.#startThread(message);
|
|
159
|
+
const normalized = {
|
|
160
|
+
...message,
|
|
161
|
+
meta: {
|
|
162
|
+
...message.meta,
|
|
163
|
+
thread: ref,
|
|
164
|
+
// The addressee of a directed send is who must be told; a reply already notifies the
|
|
165
|
+
// original author through the reply authority, and `@all` covers every member.
|
|
166
|
+
...(to === ctx.options.node_id || message.meta?.in_reply_to !== undefined
|
|
167
|
+
|| message.meta?.mentions?.includes(to) === true || message.meta?.mentions?.includes('@all') === true
|
|
168
|
+
? {}
|
|
169
|
+
: { mentions: [...(message.meta?.mentions ?? []), to] }),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
if (ref.owner === ctx.options.node_id && to !== ctx.options.node_id) {
|
|
173
|
+
// The addressee can only ever learn this entry through a signed grant or a signed
|
|
174
|
+
// reconcile, so a send that names a peer this node never paired with is refused before
|
|
175
|
+
// anything is stored: it would otherwise read `accepted` while the peer can never see it.
|
|
176
|
+
if (this.#trust !== undefined && ctx.options.plugins.keys !== undefined) {
|
|
177
|
+
const pairing = await this.#trust.entry(to);
|
|
178
|
+
if (pairing === undefined) {
|
|
179
|
+
fail('not_found', `No trust record names ${to}. Pair the nodes (trust_begin) before addressing them; nothing was stored.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const member = await this.#ensureMember(ref, to);
|
|
183
|
+
// The participant is told about the thread now, not on a later maintenance pass: this
|
|
184
|
+
// exchange is also where the participant's own policy answers, so a peer that revoked
|
|
185
|
+
// this node refuses the conversation at the point the sender can still see it.
|
|
186
|
+
try {
|
|
187
|
+
await this.#pushGrant(ref, member);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const failure = failureOf(error);
|
|
191
|
+
return { admission: admissionOf(failure), error: failure };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const outcome = await this.#deliver(ref.owner, normalized, plan, options, to);
|
|
195
|
+
// The storing endpoint is this node whenever it started the conversation; the outcome
|
|
196
|
+
// still names the peer it addresses, or the receipt reads as a message sent to oneself.
|
|
197
|
+
return to !== ref.owner ? { ...outcome, resolved: to } : outcome;
|
|
198
|
+
}
|
|
199
|
+
/** The reply-authority route: the receiver owns the landing, so no thread is named here. */
|
|
200
|
+
async #sendUntracked(to, message, plan, options) {
|
|
201
|
+
return this.#deliver(to, message, plan, options);
|
|
202
|
+
}
|
|
203
|
+
/** Records one outbound request, then transmits it or waits for its answer. */
|
|
204
|
+
async #deliver(target, message, plan, options, addressee) {
|
|
205
|
+
const ctx = this.#ctx;
|
|
206
|
+
const slot = outboundKey(message.sender, message.id);
|
|
207
|
+
const request = {
|
|
208
|
+
idempotency_key: options.idempotency_key ?? ctx.id(),
|
|
209
|
+
retry_until: this.#window(options.retry_until),
|
|
210
|
+
message,
|
|
211
|
+
target_endpoint: target,
|
|
212
|
+
plan,
|
|
213
|
+
};
|
|
214
|
+
const digest = messageDigest(request);
|
|
215
|
+
// Who the send addresses, beside where the entry is stored: an initiator's own thread
|
|
216
|
+
// stores locally, so the record must still name the peer, or the receipt reads as a
|
|
217
|
+
// message the node sent itself.
|
|
218
|
+
const addressed = {
|
|
219
|
+
...(addressee !== undefined && addressee !== target ? { addressee } : {}),
|
|
220
|
+
...(typeof options.requested === 'string' && options.requested !== (addressee ?? target)
|
|
221
|
+
? { requested: options.requested }
|
|
222
|
+
: {}),
|
|
223
|
+
};
|
|
224
|
+
// The same message identity with different content is a conflict, never a rewrite:
|
|
225
|
+
// the slot keeps the first request and the caller sends a new message id instead.
|
|
226
|
+
const record = await ctx.storage.transaction(async (tx) => {
|
|
227
|
+
const stored = await tx.get(slot);
|
|
228
|
+
if (stored !== undefined) {
|
|
229
|
+
if ((stored.digest ?? messageDigest(stored.request)) !== digest) {
|
|
230
|
+
fail('id_conflict', 'This message identity is already bound to different content.');
|
|
231
|
+
}
|
|
232
|
+
// A re-drive of the same message keeps the first request identity, which the peer
|
|
233
|
+
// dedupes on; the consumption identity belongs to the caller waiting now, not to
|
|
234
|
+
// the delivery, so a caller that names another one replaces the stale slot.
|
|
235
|
+
// Acceptance is final; anything else is a case the caller is asking again, which is
|
|
236
|
+
// the one signal that starts a fresh attempt series after a dead letter or a backoff.
|
|
237
|
+
const recontexted = options.context === undefined || stored.local_context === options.context
|
|
238
|
+
? {} : { local_context: options.context };
|
|
239
|
+
if (stored.state === 'accepted') {
|
|
240
|
+
if (Object.keys(recontexted).length === 0)
|
|
241
|
+
return stored;
|
|
242
|
+
const updated = { ...stored, ...recontexted };
|
|
243
|
+
await tx.put(slot, updated);
|
|
244
|
+
return updated;
|
|
245
|
+
}
|
|
246
|
+
const updated = { ...stored, ...recontexted, state: 'pending', attempt: undefined, next_attempt: undefined };
|
|
247
|
+
await tx.put(slot, updated);
|
|
248
|
+
return updated;
|
|
249
|
+
}
|
|
250
|
+
const fresh = {
|
|
251
|
+
request, expected_author: options.expected_author ?? target, state: 'pending', digest,
|
|
252
|
+
created_at: new Date(ctx.now()).toISOString(),
|
|
253
|
+
...addressed,
|
|
254
|
+
...(options.context === undefined ? {} : { local_context: options.context }),
|
|
255
|
+
};
|
|
256
|
+
await tx.put(slot, fresh);
|
|
257
|
+
return fresh;
|
|
258
|
+
});
|
|
259
|
+
if (plan.response !== 'await')
|
|
260
|
+
return this.#transmit(record, options.signal);
|
|
261
|
+
const waiter = this.#awaiting.register(record);
|
|
262
|
+
try {
|
|
263
|
+
return await this.#await(record, waiter, options.signal);
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
this.#awaiting.release(waiter);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Re-drives outbound records still open inside their window (RFC 0003 §3). `unknown`
|
|
271
|
+
* is terminal in meaning but the window still owns another attempt, so the window
|
|
272
|
+
* decides when to stop, not the last recorded state. A record whose window closed is
|
|
273
|
+
* settled as `unknown`, never left looking pending. Each record's own attempt series
|
|
274
|
+
* bounds the re-drive: a scheduled next attempt is respected instead of retried every
|
|
275
|
+
* pass, and a series that reached its limit stays a dead letter until an explicit
|
|
276
|
+
* re-send starts a new one. A caller invoking this directly is asking for a retry now,
|
|
277
|
+
* so it ignores the schedule; the maintenance loop's own pass respects it.
|
|
278
|
+
*/
|
|
279
|
+
async recover(signal) {
|
|
280
|
+
return this.#recover(signal, false);
|
|
281
|
+
}
|
|
282
|
+
async #recover(signal, scheduled) {
|
|
283
|
+
const now = this.#ctx.now();
|
|
284
|
+
const rows = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('outbound') + '/'))
|
|
285
|
+
.map(row => row.value)
|
|
286
|
+
.filter(row => row.digest !== undefined && (row.state === 'pending' || row.state === 'unknown'))
|
|
287
|
+
// `pending` is a request that never left; only a failed one carries a schedule.
|
|
288
|
+
.filter(row => !scheduled || row.state === 'pending' || row.next_attempt === undefined || utc(row.next_attempt) <= now));
|
|
289
|
+
for (const row of rows) {
|
|
290
|
+
if (signal?.aborted)
|
|
291
|
+
return;
|
|
292
|
+
if (utc(row.request.retry_until) <= this.#ctx.now()) {
|
|
293
|
+
if (row.state !== 'unknown') {
|
|
294
|
+
await this.#commit(row, {
|
|
295
|
+
state: 'unknown',
|
|
296
|
+
error: { code: 'retry_window_expired', message: 'The window closed before this node learned the admission.', retryable: false },
|
|
297
|
+
});
|
|
298
|
+
this.#trace({ event: 'outbound.expired', sender: row.request.message.sender, message: row.request.message.id, code: 'retry_window_expired' });
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// A recovered record has no caller left to consume a reply, so recovery re-drives the
|
|
303
|
+
// delivery and lets reconciliation surface any answer through the local inbox.
|
|
304
|
+
this.#trace({ event: 'outbound.redriven', sender: row.request.message.sender, message: row.request.message.id, target: row.request.target_endpoint });
|
|
305
|
+
await this.#transmit({ ...row, state: 'pending' }, signal);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/** One structured line per lifecycle transition; the host decides where the line lands. */
|
|
309
|
+
#trace(event) {
|
|
310
|
+
try {
|
|
311
|
+
this.#ctx.options.plugins.runtime?.trace?.(event);
|
|
312
|
+
}
|
|
313
|
+
catch { /* a tracing fault never breaks delivery */ }
|
|
314
|
+
}
|
|
315
|
+
#injectTrace(entry, record, outcome, reason, context) {
|
|
316
|
+
this.#trace({
|
|
317
|
+
event: 'inbound.inject', thread: entry.thread_ref, seq: entry.seq,
|
|
318
|
+
context: context ?? record.context ?? this.#ctx.options.node_id, outcome,
|
|
319
|
+
...(reason === undefined ? {} : { reason }),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/** Appends to a thread this node hosts: no outbound record, no peer, no network. */
|
|
323
|
+
async #admitHere(message, plan) {
|
|
324
|
+
// A local append cannot await: the answer lands here as another local admit, which no
|
|
325
|
+
// waiter on an outbound delivery observes. Refusing is the only honest answer.
|
|
326
|
+
if (plan.response === 'await') {
|
|
327
|
+
fail('unsupported_delivery', 'A local append cannot await its answer; no message was sent.');
|
|
328
|
+
}
|
|
329
|
+
const receipt = await this.#messages.admit(this.#auth(), {
|
|
330
|
+
idempotency_key: this.#ctx.id(),
|
|
331
|
+
retry_until: this.#window(undefined),
|
|
332
|
+
message,
|
|
333
|
+
target_endpoint: this.#ctx.options.node_id,
|
|
334
|
+
plan,
|
|
335
|
+
});
|
|
336
|
+
return { admission: 'accepted', receipt };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The thread that holds a message this node can see. A reply names its original, and the
|
|
340
|
+
* original's thread is where the whole conversation lives (RFC 0002 §2): the entry this
|
|
341
|
+
* node received, or the receipt of the message this node sent.
|
|
342
|
+
*/
|
|
343
|
+
async #threadOf(original) {
|
|
344
|
+
if (original === undefined)
|
|
345
|
+
return undefined;
|
|
346
|
+
return this.#ctx.storage.transaction(async (tx) => {
|
|
347
|
+
const entry = await tx.get(key('entry-ref', original.sender, original.id));
|
|
348
|
+
if (entry !== undefined)
|
|
349
|
+
return entry.thread_ref;
|
|
350
|
+
const sent = await tx.get(outboundKey(original.sender, original.id));
|
|
351
|
+
if (sent?.receipt !== undefined)
|
|
352
|
+
return sent.receipt.thread_ref;
|
|
353
|
+
// A member's copy of an owner-held message lives in its inbox, not in `entry-ref`.
|
|
354
|
+
const inbox = await tx.scan(key('inbox') + '/');
|
|
355
|
+
const held = inbox.find(row => row.value.attention.message_ref.sender === original.sender && row.value.attention.message_ref.id === original.id);
|
|
356
|
+
return held?.value.attention.thread_ref;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The thread a conversation starts in belongs to the node that started it (RFC 0002 §1),
|
|
361
|
+
* so the initiator records the thread and admits its own message there. The id is the
|
|
362
|
+
* message id: a retried first send resolves the same thread instead of opening a second.
|
|
363
|
+
*/
|
|
364
|
+
async #startThread(message) {
|
|
365
|
+
const owner = this.#ctx.options.node_id;
|
|
366
|
+
const ref = { owner, id: message.id };
|
|
367
|
+
const existing = await this.#ctx.storage.transaction(tx => tx.get(key('thread', owner, ref.id)));
|
|
368
|
+
if (existing === undefined) {
|
|
369
|
+
const title = message.delivery_kind.replace(/\s+/g, ' ').trim().slice(0, 100) || message.msg.slice(0, 100);
|
|
370
|
+
await this.#threads.create(this.#auth(), {
|
|
371
|
+
idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined), title, id: ref.id,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return ref;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Grants the addressee `post` and `read` in a thread this node owns, once, and returns the
|
|
378
|
+
* member row: the grant notice is keyed by the member revision, so the caller can push that
|
|
379
|
+
* exact notice instead of scanning the outbox for one it might not recognize.
|
|
380
|
+
*/
|
|
381
|
+
async #ensureMember(ref, peer) {
|
|
382
|
+
const existing = await this.#ctx.storage.transaction(tx => tx.get(key('member', ref.owner, ref.id, peer)));
|
|
383
|
+
if (existing !== undefined)
|
|
384
|
+
return existing;
|
|
385
|
+
await this.#threads.member(this.#auth(), ref, peer, {}, {
|
|
386
|
+
idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined),
|
|
387
|
+
});
|
|
388
|
+
return this.#ctx.storage.transaction(async (tx) => requireValue(await tx.get(key('member', ref.owner, ref.id, peer))));
|
|
389
|
+
}
|
|
390
|
+
/** Delivers the outstanding notice for one member's revision; none outstanding, nothing to push. */
|
|
391
|
+
async #pushGrant(_ref, member) {
|
|
392
|
+
const notice = await this.#ctx.storage.transaction(tx => tx.get(key('notice', member.revision)));
|
|
393
|
+
if (notice === undefined || notice.acked)
|
|
394
|
+
return;
|
|
395
|
+
await this.#remote(member.peer, 'POST', '/grants', { grant: notice }, undefined, this.#ctx.options.wait_ms ?? 60_000);
|
|
396
|
+
}
|
|
397
|
+
// ---- inbound path -----------------------------------------------------------
|
|
398
|
+
/**
|
|
399
|
+
* Records one inbound attention item, then acts. The durable entry and the local
|
|
400
|
+
* reply record commit before the ack (RFC 0003 §2); only then may a waiter be
|
|
401
|
+
* released, and only by an entry whose author the owner authenticated and whose
|
|
402
|
+
* author is the one expected. Invalid evidence is still acked but resolves nothing.
|
|
403
|
+
*
|
|
404
|
+
* The entry is trusted as-is: this is the runtime binding's own receive path, where
|
|
405
|
+
* the owner's authenticated response is the evidence, not a caller argument. No
|
|
406
|
+
* HTTP route accepts an `Entry` from a peer, so no peer can supply one.
|
|
407
|
+
*/
|
|
408
|
+
async receive(entry, obligation_id, context) {
|
|
409
|
+
this.wakeLoop();
|
|
410
|
+
const attention = attentionOf(entry, obligation_id, this.#ctx.options.node_id);
|
|
411
|
+
const valid = this.#attest(attention, entry.thread_ref.owner);
|
|
412
|
+
let duplicate = false;
|
|
413
|
+
const queued = valid
|
|
414
|
+
? await this.#ctx.storage.transaction(async (tx) => {
|
|
415
|
+
const stored = await tx.get(inboxKey(obligation_id));
|
|
416
|
+
if (stored !== undefined) {
|
|
417
|
+
duplicate = true;
|
|
418
|
+
return stored;
|
|
419
|
+
}
|
|
420
|
+
const record = { attention, ...(context === undefined ? {} : { context }), injected: false };
|
|
421
|
+
await tx.put(inboxKey(obligation_id), record);
|
|
422
|
+
return record;
|
|
423
|
+
})
|
|
424
|
+
: { attention, ...(context === undefined ? {} : { context }), injected: false };
|
|
425
|
+
const relation = entry.message.meta?.relation;
|
|
426
|
+
this.#trace(duplicate
|
|
427
|
+
? { event: 'inbound.duplicate', sender: entry.message.sender, message: entry.message.id }
|
|
428
|
+
: {
|
|
429
|
+
event: 'inbound.received', obligation: obligation_id, thread: entry.thread_ref, seq: entry.seq,
|
|
430
|
+
sender: entry.message.sender, valid, injected: queued.injected,
|
|
431
|
+
...(relation === 'reply' || relation === 'followup' ? { relation } : {}),
|
|
432
|
+
...(queued.context === undefined ? {} : { context: queued.context }),
|
|
433
|
+
});
|
|
434
|
+
// `receipt` and `followup` never release a waiter; only a reply to the original does.
|
|
435
|
+
if (valid)
|
|
436
|
+
this.#settle(entry);
|
|
437
|
+
if (!queued.attention.acked && valid)
|
|
438
|
+
await this.#ack(entry.thread_ref, obligation_id);
|
|
439
|
+
if (valid)
|
|
440
|
+
await this.#consume(entry, queued);
|
|
441
|
+
return queued;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Reconciles unacked attention for every owner this node knows (RFC 0003 §2): the
|
|
445
|
+
* `owner + thread_ref` pairs recorded when a grant notice was accepted, plus the
|
|
446
|
+
* threads this node hosts, which need no grant. The owner's authenticated attention
|
|
447
|
+
* list is the evidence; the ack goes out only after the entry is durable locally.
|
|
448
|
+
* An owner this node cannot reach is reported as a trace record, never thrown: one dead
|
|
449
|
+
* owner must not starve the rest, and a silent skip is the failure nobody can diagnose.
|
|
450
|
+
* Each (owner, thread) carries its own attempt series: a call from the maintenance pass
|
|
451
|
+
* respects the scheduled next attempt, and an exhausted series stays a dead letter so an
|
|
452
|
+
* unreachable owner costs one probe per window instead of one storm per pass. A caller
|
|
453
|
+
* invoking this directly is asking for a retry now, so it ignores the schedule — and still
|
|
454
|
+
* counts the attempt, so a caller that retries in a loop reaches the same dead letter.
|
|
455
|
+
*/
|
|
456
|
+
async reconcile(signal) {
|
|
457
|
+
return this.#reconcile(signal, false);
|
|
458
|
+
}
|
|
459
|
+
async #reconcile(signal, scheduled) {
|
|
460
|
+
const now = this.#ctx.now();
|
|
461
|
+
for (const ref of await this.#knownRefs()) {
|
|
462
|
+
if (signal?.aborted)
|
|
463
|
+
break;
|
|
464
|
+
const backoff = await this.#ctx.storage.transaction(tx => tx.get(reconcileBackoffKey(ref)));
|
|
465
|
+
if (backoff?.dead === true)
|
|
466
|
+
continue;
|
|
467
|
+
if (scheduled && backoff !== undefined && utc(backoff.next_attempt) > now)
|
|
468
|
+
continue;
|
|
469
|
+
try {
|
|
470
|
+
await this.#reconcileThread(ref, signal);
|
|
471
|
+
if (backoff !== undefined)
|
|
472
|
+
await this.#ctx.storage.transaction(tx => tx.delete(reconcileBackoffKey(ref)));
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
if (ref.owner === this.#ctx.options.node_id)
|
|
476
|
+
throw error;
|
|
477
|
+
await this.#reportReconcileFailure(ref, error);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return this.#messages.inbox(this.#auth(), {});
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Records one failed attempt in the thread's series and writes at most one line per window.
|
|
485
|
+
* A failed pull drops nothing, so the series owns the retry; an attempt that reaches the
|
|
486
|
+
* limit parks the thread as unreachable and writes one terminal line instead of the
|
|
487
|
+
* per-window one.
|
|
488
|
+
*/
|
|
489
|
+
async #reportReconcileFailure(ref, error) {
|
|
490
|
+
const failure = failureOf(error);
|
|
491
|
+
const attempt = await this.#ctx.storage.transaction(async (tx) => {
|
|
492
|
+
const slot = reconcileBackoffKey(ref);
|
|
493
|
+
const previous = await tx.get(slot);
|
|
494
|
+
const attempt = (previous?.attempt ?? 0) + 1;
|
|
495
|
+
const next = {
|
|
496
|
+
owner: ref.owner, thread: ref.id, attempt,
|
|
497
|
+
next_attempt: new Date(this.#ctx.now() + backoffMs(attempt)).toISOString(),
|
|
498
|
+
code: failure.code, message: failure.message,
|
|
499
|
+
...(attempt >= MAX_ATTEMPTS ? { dead: true } : {}),
|
|
500
|
+
};
|
|
501
|
+
await tx.put(slot, next);
|
|
502
|
+
return attempt;
|
|
503
|
+
});
|
|
504
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
505
|
+
this.#trace({ event: 'reconcile.unreachable', owner: ref.owner, thread: ref.id, code: failure.code, attempts: attempt });
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
this.#windowedFailure(key(ref.owner, ref.id, failure.code), attempts => ({
|
|
509
|
+
event: 'reconcile.failed', owner: ref.owner, thread: ref.id,
|
|
510
|
+
code: failure.code, message: failure.message, attempts,
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Writes at most one failure line per scope per window: a permanently dead peer stays
|
|
515
|
+
* observable without writing the same line every pass, and the line reports how many
|
|
516
|
+
* suppressed attempts it accounts for.
|
|
517
|
+
*/
|
|
518
|
+
#windowedFailure(slot, line) {
|
|
519
|
+
const now = this.#ctx.now();
|
|
520
|
+
const held = this.#failureWindows.get(slot);
|
|
521
|
+
if (held !== undefined && now - held.at < FAILURE_WINDOW_MS) {
|
|
522
|
+
held.suppressed += 1;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
this.#failureWindows.set(slot, { at: now, suppressed: 0 });
|
|
526
|
+
this.#trace(line((held?.suppressed ?? 0) + 1));
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Delivers outstanding owner grant notices, replays owed todos and entries whose durable
|
|
530
|
+
* record never reached the model, re-asks trusted owners for the memberships they hold for
|
|
531
|
+
* this node, re-drives owed trust statements, then reconciles. Replay follows each thread's
|
|
532
|
+
* own order: `owner`, `id`, then `seq` as a number. One record that fails never starves the
|
|
533
|
+
* rest, and the failures are thrown after the pass with their records still owed for the
|
|
534
|
+
* next one. Caller-scheduled, and the only entry a host without a maintenance clock needs
|
|
535
|
+
* besides `recover`.
|
|
536
|
+
*/
|
|
537
|
+
async maintain(signal) {
|
|
538
|
+
await this.#deliverGrants(signal);
|
|
539
|
+
if (signal?.aborted)
|
|
540
|
+
return;
|
|
541
|
+
await this.#confirmGrants(signal);
|
|
542
|
+
if (signal?.aborted)
|
|
543
|
+
return;
|
|
544
|
+
// Before reconciliation, so rows this repairs are pulled in the same pass.
|
|
545
|
+
await this.#resyncGrants(signal);
|
|
546
|
+
if (signal?.aborted)
|
|
547
|
+
return;
|
|
548
|
+
const failures = [];
|
|
549
|
+
// Owed todos replay before the inbox pass, so a hook that fails now waits for the next
|
|
550
|
+
// pass instead of being retried inside this one.
|
|
551
|
+
await this.#replayTodos(signal, failures);
|
|
552
|
+
if (signal?.aborted)
|
|
553
|
+
return;
|
|
554
|
+
await this.#replayInbox(signal, failures);
|
|
555
|
+
if (signal?.aborted)
|
|
556
|
+
return;
|
|
557
|
+
try {
|
|
558
|
+
await this.#reconcile(signal, true);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
failures.push(error);
|
|
562
|
+
}
|
|
563
|
+
// Trust re-drive belongs to the pass, not to the loop: a host that schedules `maintain`
|
|
564
|
+
// itself must still deliver owed statements (RFC 0017 §3.5).
|
|
565
|
+
try {
|
|
566
|
+
await this.#trust?.redrive(signal);
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
failures.push(error);
|
|
570
|
+
}
|
|
571
|
+
if (failures.length === 1)
|
|
572
|
+
throw failures[0];
|
|
573
|
+
if (failures.length > 1)
|
|
574
|
+
throw new AggregateError(failures, 'Some owed records could not be settled; the rest were.');
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Whether any durable record still needs a local pass: an unacked grant notice, an
|
|
578
|
+
* owed todo, an uninjected inbox row, or an outbound still owed an attempt. A scheduler
|
|
579
|
+
* consults this before running `maintain`, so an idle node costs no rounds; anything
|
|
580
|
+
* that flips this on (an admission, an ack, a failed delivery) also wakes the host.
|
|
581
|
+
* A dead-lettered send is terminal, and a scheduled retry counts only once it is due, so
|
|
582
|
+
* neither holds the active cadence for a peer that is simply away.
|
|
583
|
+
*/
|
|
584
|
+
async hasPendingWork() {
|
|
585
|
+
return this.#ctx.storage.transaction(async (tx) => {
|
|
586
|
+
const now = this.#ctx.now();
|
|
587
|
+
const inbox = await tx.scan(key('inbox') + '/');
|
|
588
|
+
if (inbox.some(row => !row.value.attention.acked || !row.value.injected && row.value.attention.entry !== undefined && row.value.attention.disposition !== 'expired'))
|
|
589
|
+
return true;
|
|
590
|
+
const todos = await tx.scan(key('todo') + '/');
|
|
591
|
+
if (todos.some(row => !row.value.done))
|
|
592
|
+
return true;
|
|
593
|
+
const outbound = await tx.scan(key('outbound') + '/');
|
|
594
|
+
if (outbound.some(row => row.value.digest !== undefined && (row.value.state === 'pending'
|
|
595
|
+
|| (row.value.state === 'unknown' && (row.value.next_attempt === undefined || utc(row.value.next_attempt) <= now)))))
|
|
596
|
+
return true;
|
|
597
|
+
// A grant notice is how a participant learns the thread it must reconcile, so an
|
|
598
|
+
// unacked notice is work: skipping it leaves the thread undiscoverable.
|
|
599
|
+
const notices = await tx.scan(key('notice') + '/');
|
|
600
|
+
if (notices.some(row => row.value.acked !== true))
|
|
601
|
+
return true;
|
|
602
|
+
const grants = await tx.scan(key('grant') + '/');
|
|
603
|
+
if (grants.some(row => row.value.acked !== true))
|
|
604
|
+
return true;
|
|
605
|
+
// An approval whose statement the peer has not confirmed is owed work too: the loop
|
|
606
|
+
// re-drives it once the retry window opens (RFC 0017 §3.5).
|
|
607
|
+
// A backed-off obligation still counts once its delay has passed; until then the idle
|
|
608
|
+
// cadence is enough to notice it, so it does not force the active one.
|
|
609
|
+
const exchanges = await tx.scan(key('trust-outbound') + '/');
|
|
610
|
+
if (exchanges.some(row => row.value.blocked === undefined || row.value.blocked <= now))
|
|
611
|
+
return true;
|
|
612
|
+
return false;
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
/** Replays todos that are owed but whose hook has not succeeded, in thread order. */
|
|
616
|
+
async #replayTodos(signal, failures) {
|
|
617
|
+
const owed = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('todo') + '/')).map(row => row.value).filter(row => !row.done));
|
|
618
|
+
owed.sort((a, b) => replayOrder(a.entry, b.entry));
|
|
619
|
+
for (const row of owed) {
|
|
620
|
+
if (signal?.aborted)
|
|
621
|
+
return;
|
|
622
|
+
try {
|
|
623
|
+
await this.#deliverTodo(row.entry, row.context);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
failures.push(error);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Replays inbox records the model has not seen. A record already delivered is skipped, but
|
|
632
|
+
* an entry durable here still answers a live await, and its owed todo replays above even
|
|
633
|
+
* when the record is already injected.
|
|
634
|
+
*/
|
|
635
|
+
async #replayInbox(signal, failures) {
|
|
636
|
+
const queued = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('inbox') + '/')).map(row => row.value));
|
|
637
|
+
queued.sort((a, b) => replayOrder(a.attention.entry, b.attention.entry));
|
|
638
|
+
for (const record of queued) {
|
|
639
|
+
if (signal?.aborted)
|
|
640
|
+
return;
|
|
641
|
+
const entry = record.attention.entry;
|
|
642
|
+
if (entry === undefined)
|
|
643
|
+
continue;
|
|
644
|
+
// An entry already durable here answers a live await even when the runtime never saw it.
|
|
645
|
+
this.#settle(entry);
|
|
646
|
+
if (record.injected || record.attention.disposition === 'expired')
|
|
647
|
+
continue;
|
|
648
|
+
try {
|
|
649
|
+
await this.#consume(entry, record);
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
failures.push(error);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// ---- the single agent entry --------------------------------------------------
|
|
657
|
+
/**
|
|
658
|
+
* The one call a model makes. SystemOne turns the intent into a typed action, the
|
|
659
|
+
* decision is persisted with its input, then exactly that action runs. With no
|
|
660
|
+
* usable decision the request fails: the library never picks a plan itself.
|
|
661
|
+
*/
|
|
662
|
+
async msg(input, options = {}) {
|
|
663
|
+
if (input == null || typeof input.delivery_kind !== 'string' || typeof input.msg !== 'string') {
|
|
664
|
+
fail('invalid_request', 'msg needs string delivery_kind and msg fields.');
|
|
665
|
+
}
|
|
666
|
+
const action = await this.#decide(input);
|
|
667
|
+
if (action.kind !== 'send')
|
|
668
|
+
return this.#dispatch(action, options);
|
|
669
|
+
const prepared = this.#prepare(input, action);
|
|
670
|
+
const outcome = await this.send(prepared.to, prepared.message, prepared.plan, { ...options, ...(typeof input.meta?.to === 'string' ? { requested: input.meta.to } : {}) });
|
|
671
|
+
return this.#addressed(outcome, input.meta?.to, prepared.to);
|
|
672
|
+
}
|
|
673
|
+
async prepare(input) {
|
|
674
|
+
const action = await this.#decide(input);
|
|
675
|
+
if (action.kind !== 'send')
|
|
676
|
+
fail('invalid_request', 'A draft must describe a message to send.');
|
|
677
|
+
return this.#prepare(input, action);
|
|
678
|
+
}
|
|
679
|
+
async readResources(query, options = {}) {
|
|
680
|
+
const auth = this.#auth();
|
|
681
|
+
const search = new URLSearchParams();
|
|
682
|
+
if ('cursor' in query && query.cursor !== undefined)
|
|
683
|
+
search.set('cursor', query.cursor);
|
|
684
|
+
if ('page_size' in query && query.page_size !== undefined)
|
|
685
|
+
search.set(query.op === 'log' ? 'limit' : 'page_size', String(query.page_size));
|
|
686
|
+
if (query.op === 'log' && query.since !== undefined)
|
|
687
|
+
search.set('since', query.since);
|
|
688
|
+
const suffix = search.size === 0 ? '' : `?${search}`;
|
|
689
|
+
const remote = (owner, path) => this.#remote(owner, 'GET', path + suffix, undefined, options.signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
690
|
+
if (query.op === 'list') {
|
|
691
|
+
const owners = [...new Set(query.owners ?? [this.#ctx.node])];
|
|
692
|
+
if (query.cursor !== undefined && owners.length !== 1)
|
|
693
|
+
fail('invalid_request', 'A resource cursor names exactly one owner.');
|
|
694
|
+
const rows = await Promise.all(owners.map(async (owner) => {
|
|
695
|
+
try {
|
|
696
|
+
const result = owner === this.#ctx.node
|
|
697
|
+
? await this.#resources.list(auth, query)
|
|
698
|
+
: await remote(owner, '/resources');
|
|
699
|
+
return { owner, page: result };
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
if (options.signal?.aborted)
|
|
703
|
+
throw error;
|
|
704
|
+
return { owner, error: failureOf(error) };
|
|
705
|
+
}
|
|
706
|
+
}));
|
|
707
|
+
return { owners: rows };
|
|
708
|
+
}
|
|
709
|
+
const ref = query.resource;
|
|
710
|
+
const local = ref.owner === this.#ctx.node;
|
|
711
|
+
const path = `/resources/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`;
|
|
712
|
+
switch (query.op) {
|
|
713
|
+
case 'describe': return local ? this.#resources.describe(auth, ref) : remote(ref.owner, path);
|
|
714
|
+
case 'log': return local ? this.#resources.log(auth, ref, query) : remote(ref.owner, path + '/log');
|
|
715
|
+
case 'event': return local ? this.#resources.event(auth, ref, query.event_id) : remote(ref.owner, path + `/log/${encodeURIComponent(query.event_id)}`);
|
|
716
|
+
case 'operations': return local ? this.#resources.operations(auth, ref, query) : remote(ref.owner, path + '/operations');
|
|
717
|
+
case 'status': return local ? this.#resources.operation(auth, ref, query.operation_id) : remote(ref.owner, path + `/operations/${encodeURIComponent(query.operation_id)}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
#prepare(input, action) {
|
|
721
|
+
if (action.response === undefined || action.execution === undefined) {
|
|
722
|
+
fail('decision_unavailable', 'A send needs response and execution; no plan was chosen.');
|
|
723
|
+
}
|
|
724
|
+
const meta = input.meta;
|
|
725
|
+
// RFC 0003 §1: the decision may carry the work unit, and a caller may name it in the
|
|
726
|
+
// input meta; either way it becomes the first-class field a receiver dedupes on.
|
|
727
|
+
const work = action.work !== undefined
|
|
728
|
+
? workRef(action.work, 'action.work')
|
|
729
|
+
: meta?.work === undefined ? undefined : workRef(meta.work, 'message.meta.work');
|
|
730
|
+
const relation = action.in_reply_to === undefined ? action.relation : action.relation ?? 'reply';
|
|
731
|
+
const message = {
|
|
732
|
+
id: this.#ctx.id(),
|
|
733
|
+
sender: this.#ctx.options.node_id,
|
|
734
|
+
delivery_kind: input.delivery_kind,
|
|
735
|
+
msg: input.msg,
|
|
736
|
+
...(action.thread === undefined && action.in_reply_to === undefined && action.mentions === undefined
|
|
737
|
+
&& work === undefined && meta === undefined
|
|
738
|
+
? {}
|
|
739
|
+
: {
|
|
740
|
+
meta: {
|
|
741
|
+
...(work === undefined ? {} : { work }),
|
|
742
|
+
...(action.thread === undefined ? {} : { thread: action.thread }),
|
|
743
|
+
...(action.in_reply_to === undefined ? {} : { in_reply_to: action.in_reply_to }),
|
|
744
|
+
...(relation === undefined ? {} : { relation }),
|
|
745
|
+
...(action.mentions === undefined ? {} : { mentions: action.mentions }),
|
|
746
|
+
...(meta === undefined ? {} : { data: meta }),
|
|
747
|
+
},
|
|
748
|
+
}),
|
|
749
|
+
};
|
|
750
|
+
const plan = {
|
|
751
|
+
response: action.response, execution: action.execution,
|
|
752
|
+
...(action.context === undefined ? {} : { context: action.context }),
|
|
753
|
+
};
|
|
754
|
+
// A thread this node owns stores the entry locally; `action.to` is the participant the
|
|
755
|
+
// message addresses, and the two differ whenever an initiator writes to a member.
|
|
756
|
+
return { to: action.to, message, plan };
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* The caller's own spelling beside the node the decision resolved it to. Equal spellings say
|
|
760
|
+
* nothing new; a bare alias that resolved to a peer is the case the receipt cannot show. A
|
|
761
|
+
* `resolved` the send already derived (the addressee of an initiator's own thread) is kept.
|
|
762
|
+
*/
|
|
763
|
+
#addressed(outcome, requested, resolved) {
|
|
764
|
+
const merged = outcome.resolved === undefined ? { ...outcome, resolved } : outcome;
|
|
765
|
+
return typeof requested !== 'string' || requested === resolved ? merged : { ...merged, requested };
|
|
766
|
+
}
|
|
767
|
+
// ---- internals ---------------------------------------------------------------
|
|
768
|
+
#auth() {
|
|
769
|
+
return { actor: this.#ctx.options.node_id };
|
|
770
|
+
}
|
|
771
|
+
/** Persists the decision and its outcome. RFC 0005 §2: a decision the library cannot act on is a recorded failure. */
|
|
772
|
+
async #decide(input) {
|
|
773
|
+
const decision_id = this.#ctx.id();
|
|
774
|
+
const at = new Date(this.#ctx.now()).toISOString();
|
|
775
|
+
let action;
|
|
776
|
+
try {
|
|
777
|
+
action = await this.#ctx.options.plugins.decide(input);
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
const failure = failureOf(error);
|
|
781
|
+
await this.#ctx.storage.transaction(tx => tx.put(key('decision', decision_id), { input, at, error: failure }));
|
|
782
|
+
if (failure.code !== 'decision_unavailable' && failure.code !== 'unavailable')
|
|
783
|
+
throw error;
|
|
784
|
+
fail('decision_unavailable', failure.message);
|
|
785
|
+
}
|
|
786
|
+
if (action == null || typeof action !== 'object' || !['send', 'read', 'thread', 'resource', 'domain'].includes(action.kind)) {
|
|
787
|
+
await this.#ctx.storage.transaction(tx => tx.put(key('decision', decision_id), {
|
|
788
|
+
input, at,
|
|
789
|
+
error: { code: 'decision_unavailable', message: 'The decision is not an executable action.', retryable: true },
|
|
790
|
+
}));
|
|
791
|
+
fail('decision_unavailable', 'The decision is not an executable action.');
|
|
792
|
+
}
|
|
793
|
+
await this.#ctx.storage.transaction(tx => tx.put(key('decision', decision_id), { input, action, at }));
|
|
794
|
+
return action;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Only `await` needs the host capability, and it is checked before sending (RFC 0005 §5).
|
|
798
|
+
* `async` ends at the peer and never holds the call open, so it needs nothing here.
|
|
799
|
+
*/
|
|
800
|
+
async #ensureAwaitable(plan, signal) {
|
|
801
|
+
if (signal?.aborted)
|
|
802
|
+
throw signal.reason ?? new Error('The send was cancelled.');
|
|
803
|
+
const runtime = this.#ctx.options.plugins.runtime;
|
|
804
|
+
if (runtime === undefined || !(await runtime.supports(plan))) {
|
|
805
|
+
fail('unsupported_delivery', 'The host cannot hold this await, so no message was sent.');
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/** RFC 0003 §8: the declared window never exceeds the node's supported maximum. */
|
|
809
|
+
#window(requested) {
|
|
810
|
+
const ceiling = new Date(this.#ctx.now() + (this.#ctx.options.retry_max_ms ?? 86_400_000)).toISOString();
|
|
811
|
+
if (requested === undefined)
|
|
812
|
+
return ceiling;
|
|
813
|
+
return utc(requested) < utc(ceiling) ? requested : ceiling;
|
|
814
|
+
}
|
|
815
|
+
/** One attempt's budget: the wait bound, never past the admission window. */
|
|
816
|
+
#attempt(record) {
|
|
817
|
+
const wait = this.#ctx.options.wait_ms ?? 60_000;
|
|
818
|
+
const remaining = utc(record.request.retry_until) - this.#ctx.now();
|
|
819
|
+
return Math.max(1, Math.min(wait, remaining));
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* A network call that cannot outlive its attempt: the caller's signal cancels it, and
|
|
823
|
+
* even with no signal the attempt ends on its own deadline, so an unreachable peer or
|
|
824
|
+
* a hanging resolver cannot pin a delivery forever (RFC 0005 §3).
|
|
825
|
+
*/
|
|
826
|
+
async #remote(target, method, path, body, signal, budget) {
|
|
827
|
+
const controller = new AbortController();
|
|
828
|
+
const timer = setTimeout(() => controller.abort(new Error('The attempt exceeded its deadline.')), budget);
|
|
829
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
830
|
+
if (signal !== undefined) {
|
|
831
|
+
if (signal.aborted)
|
|
832
|
+
controller.abort(signal.reason);
|
|
833
|
+
else
|
|
834
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
return await this.#transport.request(target, method, path, body, controller.signal);
|
|
838
|
+
}
|
|
839
|
+
finally {
|
|
840
|
+
clearTimeout(timer);
|
|
841
|
+
signal?.removeEventListener('abort', onAbort);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async #transmit(record, signal) {
|
|
845
|
+
const request = record.request;
|
|
846
|
+
try {
|
|
847
|
+
const thread = request.message.meta?.thread;
|
|
848
|
+
// The thread owner stores the entry. When that owner is this node the admission is
|
|
849
|
+
// local: the same service, the same idempotency and the same receipt, no loopback call.
|
|
850
|
+
const receipt = request.target_endpoint === this.#ctx.options.node_id
|
|
851
|
+
? await this.#messages.admit(this.#auth(), request)
|
|
852
|
+
: await this.#remote(request.target_endpoint, 'POST', thread === undefined
|
|
853
|
+
? '/messages'
|
|
854
|
+
: `/threads/${encodeURIComponent(thread.owner)}/${encodeURIComponent(thread.id)}/messages`, request, signal, this.#attempt(record));
|
|
855
|
+
await this.#commit(record, { state: 'accepted', receipt });
|
|
856
|
+
const guarantee = receipt.guarantee;
|
|
857
|
+
this.#trace({
|
|
858
|
+
event: 'outbound.admitted', sender: request.message.sender, message: request.message.id,
|
|
859
|
+
target: request.target_endpoint, ...(record.addressee === undefined ? {} : { resolved: record.addressee }),
|
|
860
|
+
state: 'accepted', ...(typeof guarantee === 'string' ? { guarantee } : {}),
|
|
861
|
+
});
|
|
862
|
+
return { admission: 'accepted', receipt };
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
if (signal?.aborted)
|
|
866
|
+
throw error;
|
|
867
|
+
const failure = failureOf(error);
|
|
868
|
+
const state = admissionOf(failure);
|
|
869
|
+
// A retryable failure is one attempt of a bounded series: the next attempt is scheduled,
|
|
870
|
+
// and an exhausted series becomes a dead letter instead of being re-driven on every pass.
|
|
871
|
+
// A recorded refusal is final and carries no series at all.
|
|
872
|
+
const previous = await this.#ctx.storage.transaction(tx => tx.get(outboundKey(request.message.sender, request.message.id)));
|
|
873
|
+
const attempt = state === 'refused' ? undefined : (previous?.attempt ?? 0) + 1;
|
|
874
|
+
const terminal = attempt !== undefined && attempt >= MAX_ATTEMPTS;
|
|
875
|
+
await this.#commit(record, {
|
|
876
|
+
state: terminal ? 'unreachable' : state,
|
|
877
|
+
error: failure,
|
|
878
|
+
...(attempt === undefined ? {} : { attempt }),
|
|
879
|
+
...(attempt === undefined || terminal ? {} : { next_attempt: new Date(this.#ctx.now() + backoffMs(attempt)).toISOString() }),
|
|
880
|
+
});
|
|
881
|
+
this.#trace({
|
|
882
|
+
event: 'outbound.admitted', sender: request.message.sender, message: request.message.id,
|
|
883
|
+
target: request.target_endpoint, ...(record.addressee === undefined ? {} : { resolved: record.addressee }),
|
|
884
|
+
state: terminal ? 'unreachable' : state, code: failure.code,
|
|
885
|
+
});
|
|
886
|
+
if (terminal)
|
|
887
|
+
this.#trace({
|
|
888
|
+
event: 'outbound.unreachable', sender: request.message.sender, message: request.message.id,
|
|
889
|
+
target: request.target_endpoint, attempts: attempt, code: failure.code,
|
|
890
|
+
});
|
|
891
|
+
const stored = await this.#ctx.storage.transaction(tx => tx.get(outboundKey(request.message.sender, request.message.id)));
|
|
892
|
+
return {
|
|
893
|
+
admission: stored?.state ?? (terminal ? 'unreachable' : state),
|
|
894
|
+
...(stored?.state === 'accepted' ? {} : { error: failure }),
|
|
895
|
+
...(stored?.receipt === undefined ? (record.receipt === undefined ? {} : { receipt: record.receipt }) : { receipt: stored.receipt }),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Deliver, then wait on the one thing that can answer: a reply whose author the owner
|
|
901
|
+
* authenticated. A timeout ends only this wait; it never invents a state for the
|
|
902
|
+
* delivery, so a still-open record stays open for recovery.
|
|
903
|
+
*/
|
|
904
|
+
async #await(record, waiter, signal) {
|
|
905
|
+
const reply = await this.#awaitOutcome(record, waiter, signal);
|
|
906
|
+
if (reply.reply === undefined)
|
|
907
|
+
return reply;
|
|
908
|
+
// A body already confirmed consumed in this context is rendered as arrival facts only
|
|
909
|
+
// (RFC 0008 §5). The sender's own consumption identity decides; the plan's context
|
|
910
|
+
// belongs to the receiver and is never this caller's. A generic caller that names none
|
|
911
|
+
// is the node itself.
|
|
912
|
+
const context = record.local_context ?? this.#ctx.options.node_id;
|
|
913
|
+
const consumed = await this.#alreadyConsumed(reply.reply, context);
|
|
914
|
+
// The body is now in the caller's hands as a tool result: hold it until the host confirms
|
|
915
|
+
// consumption, so no automatic fragment presents the same body in that context again. A
|
|
916
|
+
// wait that is cancelled or times out returns no reply, so it never places a hold.
|
|
917
|
+
if (!consumed) {
|
|
918
|
+
this.#awaiting.hold(reply.reply, context);
|
|
919
|
+
}
|
|
920
|
+
// Returning the body does not by itself write the record: RFC 0008 §5 forbids advancing
|
|
921
|
+
// consumption on a submitted tool result. The host calls `confirmConsumption` once it has
|
|
922
|
+
// observed the model consume the result.
|
|
923
|
+
return { ...reply, ...(consumed ? { reply_body: 'withheld' } : {}) };
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* `confirmConsumption(entry, context?, via = 'fragment'): Promise<void>`
|
|
927
|
+
*
|
|
928
|
+
* The host's confirmation that this entry's body was consumed by the path it names
|
|
929
|
+
* (RFC 0008 §5). One commit writes the shared consumption slot, marks every matching inbox
|
|
930
|
+
* record delivered, and records the todo this consumption owes; the host's todo hook runs
|
|
931
|
+
* outside that commit, and the todo is filed where the model saw the body. `context` names
|
|
932
|
+
* that place; with none, the context the entry landed in decides, then this node. A failing
|
|
933
|
+
* hook leaves the todo owed, so `maintain` replays it, and replaying is safe because the
|
|
934
|
+
* host commits the todo it creates. This also releases the hold an awaited tool result
|
|
935
|
+
* placed on the entry for that context.
|
|
936
|
+
*/
|
|
937
|
+
async confirmConsumption(entry, context, via = 'fragment') {
|
|
938
|
+
const at = new Date(this.#ctx.now()).toISOString();
|
|
939
|
+
const consumedIn = await this.#ctx.storage.transaction(async (tx) => {
|
|
940
|
+
// A row keeps the context the entry landed in; the confirmation context only fills in
|
|
941
|
+
// for a row that names none.
|
|
942
|
+
let original;
|
|
943
|
+
let settled = false;
|
|
944
|
+
let consumable = false;
|
|
945
|
+
for (const row of await tx.scan(key('inbox') + '/')) {
|
|
946
|
+
const held = row.value.attention.entry;
|
|
947
|
+
if (held === undefined)
|
|
948
|
+
continue;
|
|
949
|
+
if (held.thread_ref.owner !== entry.thread_ref.owner || held.thread_ref.id !== entry.thread_ref.id)
|
|
950
|
+
continue;
|
|
951
|
+
if (held.seq !== entry.seq)
|
|
952
|
+
continue;
|
|
953
|
+
if (row.value.settlement !== undefined) {
|
|
954
|
+
settled = true;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
consumable = true;
|
|
958
|
+
original = original ?? row.value.context;
|
|
959
|
+
if (row.value.injected)
|
|
960
|
+
continue;
|
|
961
|
+
const landed = row.value.context ?? context;
|
|
962
|
+
await tx.put(row.key, { ...row.value, injected: true, ...(landed === undefined ? {} : { context: landed }) });
|
|
963
|
+
}
|
|
964
|
+
if (settled && !consumable)
|
|
965
|
+
return undefined;
|
|
966
|
+
const where = context ?? original ?? this.#ctx.options.node_id;
|
|
967
|
+
const slot = consumptionKey(where, entry.thread_ref, entry.seq);
|
|
968
|
+
const stored = await tx.get(slot);
|
|
969
|
+
if (stored?.confirmed !== true) {
|
|
970
|
+
await tx.put(slot, {
|
|
971
|
+
context: where, thread_ref: entry.thread_ref, seq: entry.seq, confirmed: true, via, at,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
await this.#oweTodo(tx, entry, where);
|
|
975
|
+
return where;
|
|
976
|
+
});
|
|
977
|
+
if (consumedIn === undefined) {
|
|
978
|
+
this.#awaiting.forget(entry);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
this.#awaiting.releaseContext(entry, consumedIn);
|
|
982
|
+
this.#trace({ event: 'consumption.confirmed', thread: entry.thread_ref, seq: entry.seq, context: consumedIn, via });
|
|
983
|
+
this.#ctx.options.plugins.runtime?.notify({ event: 'inbound', thread: entry.thread_ref, seq: entry.seq, sender: entry.message.sender });
|
|
984
|
+
await this.#deliverTodo(entry, consumedIn);
|
|
985
|
+
}
|
|
986
|
+
/** `confirmToolResult(entry, context?): Promise<void>`: the same settlement for an awaited tool result. */
|
|
987
|
+
async confirmToolResult(entry, context) {
|
|
988
|
+
await this.confirmConsumption(entry, context, 'tool_result');
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* `isWaitingReply(entry, context?): boolean`
|
|
992
|
+
*
|
|
993
|
+
* Whether this entry answers an `await` that owns its body here, optionally only one whose
|
|
994
|
+
* sender-local context is `context`: a waiter still running, or a reply already returned as
|
|
995
|
+
* a tool result and not yet confirmed consumed. A host defers the automatic fragment on
|
|
996
|
+
* `true`, because the tool result either holds or already presented the same body. A
|
|
997
|
+
* cancelled wait returns no reply, so it holds nothing.
|
|
998
|
+
*/
|
|
999
|
+
isWaitingReply(entry, context) {
|
|
1000
|
+
return this.#awaiting.waiting(entry, context);
|
|
1001
|
+
}
|
|
1002
|
+
async #awaitOutcome(record, waiter, signal) {
|
|
1003
|
+
// The request and the wait start together: a reply that is already durable here, or that
|
|
1004
|
+
// lands while the request is still in flight, answers this call without waiting for the
|
|
1005
|
+
// response. The request keeps running and records its admission when it lands.
|
|
1006
|
+
const budget = this.#attempt(record);
|
|
1007
|
+
const deadline = Date.now() + budget;
|
|
1008
|
+
const sending = this.#transmit(record, signal);
|
|
1009
|
+
const first = await this.#watch(record, waiter, signal, budget, sending);
|
|
1010
|
+
if ('failed' in first) {
|
|
1011
|
+
if (signal?.aborted)
|
|
1012
|
+
return { admission: record.state, wait: 'cancelled' };
|
|
1013
|
+
throw first.failed;
|
|
1014
|
+
}
|
|
1015
|
+
if ('replied' in first)
|
|
1016
|
+
return { admission: record.state, ...(record.receipt === undefined ? {} : { receipt: record.receipt }), reply: first.replied, wait: 'answered' };
|
|
1017
|
+
if ('evidence' in first)
|
|
1018
|
+
return { admission: record.state, ...(record.receipt === undefined ? {} : { receipt: record.receipt }), reply: first.evidence, wait: 'answered' };
|
|
1019
|
+
if ('cancelled' in first)
|
|
1020
|
+
return { admission: record.state, wait: 'cancelled' };
|
|
1021
|
+
// The wait bound can pass just before the request's own deadline fires. Its outcome still
|
|
1022
|
+
// belongs to this call, so collect it rather than leave the attempt unobserved.
|
|
1023
|
+
const delivered = 'delivered' in first ? first.delivered : await sending;
|
|
1024
|
+
if (delivered.admission !== 'accepted')
|
|
1025
|
+
return delivered;
|
|
1026
|
+
// Admitted but unanswered: only a reply or the remaining deadline can end this.
|
|
1027
|
+
const receipt = delivered.receipt !== undefined
|
|
1028
|
+
? { receipt: delivered.receipt }
|
|
1029
|
+
: record.receipt === undefined ? {} : { receipt: record.receipt };
|
|
1030
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
1031
|
+
const second = await this.#watch(record, waiter, signal, remaining, undefined);
|
|
1032
|
+
if ('failed' in second) {
|
|
1033
|
+
if (signal?.aborted)
|
|
1034
|
+
return { admission: 'accepted', ...receipt, wait: 'cancelled' };
|
|
1035
|
+
throw second.failed;
|
|
1036
|
+
}
|
|
1037
|
+
if ('replied' in second)
|
|
1038
|
+
return { admission: 'accepted', ...receipt, reply: second.replied, wait: 'answered' };
|
|
1039
|
+
if ('evidence' in second)
|
|
1040
|
+
return { admission: 'accepted', ...receipt, reply: second.evidence, wait: 'answered' };
|
|
1041
|
+
if ('cancelled' in second)
|
|
1042
|
+
return { admission: 'accepted', ...receipt, wait: 'cancelled' };
|
|
1043
|
+
// The budget passed: one last look at durable evidence, then stop asking.
|
|
1044
|
+
const late = await this.#evidence(record);
|
|
1045
|
+
if (late !== undefined)
|
|
1046
|
+
return { admission: 'accepted', ...receipt, reply: late, wait: 'answered' };
|
|
1047
|
+
return { admission: 'accepted', ...receipt, wait: 'timed_out' };
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* One bounded watch over every signal that can end a wait: a live reply, durable reply
|
|
1051
|
+
* evidence, the request settling, cancellation, or the deadline. Exactly one timer and one
|
|
1052
|
+
* abort listener exist, and both are released whichever signal wins.
|
|
1053
|
+
*/
|
|
1054
|
+
async #watch(record, waiter, signal, budget, sending) {
|
|
1055
|
+
const evidence = this.#evidence(record);
|
|
1056
|
+
return new Promise(resolve => {
|
|
1057
|
+
let done = false;
|
|
1058
|
+
let timer;
|
|
1059
|
+
const onAbort = () => finish({ cancelled: true });
|
|
1060
|
+
const finish = (outcome) => {
|
|
1061
|
+
if (done)
|
|
1062
|
+
return;
|
|
1063
|
+
done = true;
|
|
1064
|
+
clearTimeout(timer);
|
|
1065
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1066
|
+
resolve(outcome);
|
|
1067
|
+
};
|
|
1068
|
+
timer = setTimeout(() => finish({ elapsed: true }), budget);
|
|
1069
|
+
if (signal?.aborted)
|
|
1070
|
+
finish({ cancelled: true });
|
|
1071
|
+
else
|
|
1072
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1073
|
+
void waiter.outcome.then(entry => finish({ replied: entry }));
|
|
1074
|
+
void evidence.then(entry => { if (entry !== undefined)
|
|
1075
|
+
finish({ evidence: entry }); }, error => finish({ failed: error }));
|
|
1076
|
+
if (sending !== undefined) {
|
|
1077
|
+
void sending.then(delivered => finish({ delivered }), error => finish({ failed: error }));
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Acceptance is final. The digest is written with every state change, so recovery and
|
|
1083
|
+
* replay keep recognising the message identity instead of losing it to a bare record.
|
|
1084
|
+
*/
|
|
1085
|
+
async #commit(record, change) {
|
|
1086
|
+
const request = record.request;
|
|
1087
|
+
const digest = record.digest ?? messageDigest(request);
|
|
1088
|
+
await this.#ctx.storage.transaction(async (tx) => {
|
|
1089
|
+
const slot = outboundKey(request.message.sender, request.message.id);
|
|
1090
|
+
const current = await tx.get(slot);
|
|
1091
|
+
if (current?.state === 'accepted')
|
|
1092
|
+
return;
|
|
1093
|
+
const next = { ...record, ...change, digest };
|
|
1094
|
+
if (next.state === 'accepted')
|
|
1095
|
+
delete next.error;
|
|
1096
|
+
// A settled record owes no further attempt, and a dead letter must not carry a schedule
|
|
1097
|
+
// a later pass would read as permission to try again.
|
|
1098
|
+
if (next.state === 'accepted' || next.state === 'refused' || next.state === 'unreachable') {
|
|
1099
|
+
delete next.next_attempt;
|
|
1100
|
+
if (next.state !== 'unreachable')
|
|
1101
|
+
delete next.attempt;
|
|
1102
|
+
}
|
|
1103
|
+
await tx.put(slot, next);
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
/** Legal reply evidence already on this node for the given original (RFC 0005 §3). */
|
|
1107
|
+
async #evidence(record) {
|
|
1108
|
+
const expected = record.expected_author;
|
|
1109
|
+
const found = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('inbox') + '/'))
|
|
1110
|
+
.map(row => row.value.attention.entry)
|
|
1111
|
+
.filter((entry) => entry !== undefined && isReply(record.request.message, entry) && entry.authenticated_author === expected));
|
|
1112
|
+
return found.sort((a, b) => (decimal(a.seq) < decimal(b.seq) ? -1 : decimal(a.seq) > decimal(b.seq) ? 1 : 0))[0];
|
|
1113
|
+
}
|
|
1114
|
+
/** Releases every live await whose original this entry answers, in this process. */
|
|
1115
|
+
#settle(entry) {
|
|
1116
|
+
const settled = this.#awaiting.settle(entry);
|
|
1117
|
+
if (settled > 0)
|
|
1118
|
+
this.#trace({ event: 'await.settled', thread: entry.thread_ref, seq: entry.seq, waiters: settled });
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Owner-authenticated evidence only: the entry's own claims grant nothing. `owner` is
|
|
1122
|
+
* the node the evidence came from, so a remote owner's attention list attests just as
|
|
1123
|
+
* strongly as this node's own threads do, and nothing else does. `local` is the copy this
|
|
1124
|
+
* node already holds, used when the owner's row carries no body.
|
|
1125
|
+
*/
|
|
1126
|
+
#attest(attention, owner, local) {
|
|
1127
|
+
if (attention.recipient !== this.#ctx.options.node_id)
|
|
1128
|
+
return false;
|
|
1129
|
+
if (attention.thread_ref?.owner !== owner)
|
|
1130
|
+
return false;
|
|
1131
|
+
const entry = attention.entry ?? local;
|
|
1132
|
+
if (entry === undefined)
|
|
1133
|
+
return attention.disposition === 'expired';
|
|
1134
|
+
const ref = attention.thread_ref;
|
|
1135
|
+
return entry.thread_ref.owner === ref.owner
|
|
1136
|
+
&& entry.thread_ref.id === ref.id
|
|
1137
|
+
&& entry.seq === attention.seq
|
|
1138
|
+
&& entry.message.sender === attention.message_ref.sender
|
|
1139
|
+
&& entry.message.id === attention.message_ref.id
|
|
1140
|
+
&& entry.authenticated_author === attention.authenticated_author
|
|
1141
|
+
&& entry.message.sender === entry.authenticated_author
|
|
1142
|
+
&& typeof entry.authenticated_author === 'string' && entry.authenticated_author.length > 0
|
|
1143
|
+
&& /^(0|[1-9][0-9]*)$/.test(entry.seq);
|
|
1144
|
+
}
|
|
1145
|
+
async #ack(ref, obligation_id) {
|
|
1146
|
+
const mutation = { idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined) };
|
|
1147
|
+
const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/attention/${encodeURIComponent(obligation_id)}/ack`;
|
|
1148
|
+
let acked;
|
|
1149
|
+
try {
|
|
1150
|
+
if (ref.owner === this.#ctx.options.node_id)
|
|
1151
|
+
await this.#messages.ack(this.#auth(), ref, obligation_id, mutation);
|
|
1152
|
+
else
|
|
1153
|
+
await this.#remote(ref.owner, 'POST', path, mutation, undefined, this.#ctx.options.wait_ms ?? 60_000);
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
// Ack is idempotent and reconciliation re-sends it; the entry stays durable either way.
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
await this.#ctx.storage.transaction(async (tx) => {
|
|
1160
|
+
const stored = await tx.get(inboxKey(obligation_id));
|
|
1161
|
+
if (stored !== undefined && !stored.attention.acked) {
|
|
1162
|
+
await tx.put(inboxKey(obligation_id), { ...stored, attention: { ...stored.attention, acked: true } });
|
|
1163
|
+
acked = { thread: stored.attention.thread_ref, seq: stored.attention.seq };
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
if (acked !== undefined)
|
|
1167
|
+
this.#trace({ event: 'inbound.acked', obligation: obligation_id, thread: acked.thread, seq: acked.seq });
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Consumed is recorded only after the host observed the entry in the context. The
|
|
1171
|
+
* context is the obligation's own, then the plan's, then this node; the host may
|
|
1172
|
+
* answer with the context it really used, and that is the one the todo follows.
|
|
1173
|
+
*/
|
|
1174
|
+
async #consume(entry, record) {
|
|
1175
|
+
if (record.injected) {
|
|
1176
|
+
this.#injectTrace(entry, record, 'skipped', 'injected');
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (await this.#settleResourceAttention(entry, record))
|
|
1180
|
+
return;
|
|
1181
|
+
const runtime = this.#ctx.options.plugins.runtime;
|
|
1182
|
+
if (runtime === undefined) {
|
|
1183
|
+
this.#injectTrace(entry, record, 'skipped', 'no-runtime');
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
const related = entry.message.meta?.in_reply_to;
|
|
1187
|
+
const outbound = related?.sender === this.#ctx.options.node_id
|
|
1188
|
+
? await this.#ctx.storage.transaction(tx => tx.get(outboundKey(related.sender, related.id)))
|
|
1189
|
+
: undefined;
|
|
1190
|
+
const replyContext = outbound !== undefined && outbound.expected_author === entry.authenticated_author
|
|
1191
|
+
&& isReply(outbound.request.message, entry) ? outbound.local_context ?? this.#ctx.options.node_id : undefined;
|
|
1192
|
+
const requested = replyContext ?? record.context ?? record.attention.plan?.context ?? this.#ctx.options.node_id;
|
|
1193
|
+
// An await that owns this reply's body in that context either still holds the call or
|
|
1194
|
+
// already returned it as a tool result. Presenting the same body as a fragment here
|
|
1195
|
+
// would duplicate it; the await's confirmation releases the hold.
|
|
1196
|
+
if (this.isWaitingReply(entry, requested)) {
|
|
1197
|
+
this.#injectTrace(entry, record, 'waiting', 'await-hold', requested);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
// The entry may already be confirmed consumed in that context through an awaited tool
|
|
1201
|
+
// result; presenting it again as a fragment is the duplicate RFC 0008 §5 forbids. The
|
|
1202
|
+
// shared record and the todo it owes are then all that is left to write.
|
|
1203
|
+
if (await this.#alreadyConsumed(entry, requested)) {
|
|
1204
|
+
this.#injectTrace(entry, record, 'consumed', 'already-consumed', requested);
|
|
1205
|
+
await this.confirmConsumption(entry, requested, 'tool_result');
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const injected = await runtime.inject(entry, requested, record.attention.plan);
|
|
1209
|
+
this.#injectTrace(entry, record, injected.outcome ?? (injected.consumed ? 'consumed' : 'delivered'), undefined, injected.context);
|
|
1210
|
+
if (!injected.consumed)
|
|
1211
|
+
return;
|
|
1212
|
+
await this.confirmConsumption(entry, injected.context, 'fragment');
|
|
1213
|
+
}
|
|
1214
|
+
async #settleResourceAttention(entry, record) {
|
|
1215
|
+
const ref = entry.message.meta?.data?.resource_ref;
|
|
1216
|
+
if (typeof ref !== 'object' || ref === null || Array.isArray(ref)
|
|
1217
|
+
|| typeof ref.owner !== 'string' || typeof ref.id !== 'string'
|
|
1218
|
+
|| ref.owner !== entry.authenticated_author)
|
|
1219
|
+
return false;
|
|
1220
|
+
const resource_ref = { owner: ref.owner, id: ref.id };
|
|
1221
|
+
let settlement;
|
|
1222
|
+
const at = new Date(this.#ctx.now()).toISOString();
|
|
1223
|
+
try {
|
|
1224
|
+
const resource = await this.readResources({ op: 'describe', resource: resource_ref }, { signal: AbortSignal.timeout(RESOURCE_CHECK_MS) });
|
|
1225
|
+
if (resource.binding.handler === this.#ctx.node)
|
|
1226
|
+
return false;
|
|
1227
|
+
settlement = { via: 'handover', resource_ref, handler: resource.binding.handler, revision: resource.revision, at };
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
const failure = failureOf(error);
|
|
1231
|
+
if (failure.code !== 'not_found') {
|
|
1232
|
+
const attempts = await this.#ctx.storage.transaction(async (tx) => {
|
|
1233
|
+
const slot = inboxKey(record.attention.obligation_id);
|
|
1234
|
+
const current = await tx.get(slot);
|
|
1235
|
+
if (current === undefined || current.injected)
|
|
1236
|
+
return undefined;
|
|
1237
|
+
const attempts = (current.resource_check_failures ?? 0) + 1;
|
|
1238
|
+
await tx.put(slot, { ...current, resource_check_failures: attempts });
|
|
1239
|
+
return attempts;
|
|
1240
|
+
});
|
|
1241
|
+
if (attempts !== undefined)
|
|
1242
|
+
this.#trace({
|
|
1243
|
+
event: 'inbound.resource-check.failed', thread: entry.thread_ref, seq: entry.seq,
|
|
1244
|
+
resource_ref, code: failure.code, attempts,
|
|
1245
|
+
});
|
|
1246
|
+
throw error;
|
|
1247
|
+
}
|
|
1248
|
+
settlement = { via: 'undeliverable', resource_ref, reason: 'resource-unavailable', at };
|
|
1249
|
+
}
|
|
1250
|
+
const changed = await this.#ctx.storage.transaction(async (tx) => {
|
|
1251
|
+
const slot = inboxKey(record.attention.obligation_id);
|
|
1252
|
+
const current = await tx.get(slot);
|
|
1253
|
+
if (current === undefined || current.injected)
|
|
1254
|
+
return false;
|
|
1255
|
+
await tx.put(slot, { ...current, injected: true, settlement });
|
|
1256
|
+
return true;
|
|
1257
|
+
});
|
|
1258
|
+
if (changed) {
|
|
1259
|
+
this.#awaiting.forget(entry);
|
|
1260
|
+
this.#trace({ event: 'inbound.settled', thread: entry.thread_ref, seq: entry.seq, settlement });
|
|
1261
|
+
this.#ctx.options.plugins.runtime?.notify({ event: 'inbound', thread: entry.thread_ref, seq: entry.seq, sender: entry.message.sender });
|
|
1262
|
+
}
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Records the todo this consumption owes, inside the commit that confirms the consumption.
|
|
1267
|
+
* A host without a todo hook owes none, so no record accrues for it.
|
|
1268
|
+
*/
|
|
1269
|
+
async #oweTodo(tx, entry, context) {
|
|
1270
|
+
if (this.#ctx.options.plugins.runtime?.todo === undefined)
|
|
1271
|
+
return;
|
|
1272
|
+
const slot = todoKey(context, entry.thread_ref, entry.seq);
|
|
1273
|
+
if (await tx.get(slot) === undefined) {
|
|
1274
|
+
await tx.put(slot, { entry, context, done: false });
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Runs the host's todo hook for one owed todo, then marks it done. A failing hook leaves
|
|
1279
|
+
* the record pending for `maintain`; delivering twice is safe because the host commits
|
|
1280
|
+
* the todo it creates.
|
|
1281
|
+
*/
|
|
1282
|
+
async #deliverTodo(entry, context) {
|
|
1283
|
+
const runtime = this.#ctx.options.plugins.runtime;
|
|
1284
|
+
if (runtime?.todo === undefined)
|
|
1285
|
+
return;
|
|
1286
|
+
const slot = todoKey(context, entry.thread_ref, entry.seq);
|
|
1287
|
+
const stored = await this.#ctx.storage.transaction(tx => tx.get(slot));
|
|
1288
|
+
if (stored === undefined || stored.done)
|
|
1289
|
+
return;
|
|
1290
|
+
await runtime.todo(entry, context);
|
|
1291
|
+
await this.#ctx.storage.transaction(async (tx) => {
|
|
1292
|
+
const current = await tx.get(slot);
|
|
1293
|
+
if (current !== undefined)
|
|
1294
|
+
await tx.put(slot, { ...current, done: true });
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
/** Whether the named context already confirmed this entry through any delivery path. */
|
|
1298
|
+
async #alreadyConsumed(entry, context) {
|
|
1299
|
+
const stored = await this.#ctx.storage.transaction(tx => tx.get(consumptionKey(context, entry.thread_ref, entry.seq)));
|
|
1300
|
+
return stored?.confirmed === true;
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* The threads this node was invited into. The rows come from the records it already holds;
|
|
1304
|
+
* each row is then asked of its owner through the same describe read the thread view uses,
|
|
1305
|
+
* which is where the title, the state and the member table live. An owner that cannot be
|
|
1306
|
+
* reached leaves the row in place with the failure attached: an unreachable peer is a
|
|
1307
|
+
* reported outage, never a reason to hide a thread this node belongs to.
|
|
1308
|
+
*/
|
|
1309
|
+
async joinedThreads(options = {}, signal) {
|
|
1310
|
+
const listed = await this.#threads.joined(this.#auth(), options);
|
|
1311
|
+
const items = [];
|
|
1312
|
+
for (const row of listed.items)
|
|
1313
|
+
items.push(await this.#ownerFacts(row, signal));
|
|
1314
|
+
return { ...listed, items };
|
|
1315
|
+
}
|
|
1316
|
+
async #ownerFacts(row, signal) {
|
|
1317
|
+
try {
|
|
1318
|
+
const described = await this.#remote(row.thread_ref.owner, 'GET', `/threads/${encodeURIComponent(row.thread_ref.owner)}/${encodeURIComponent(row.thread_ref.id)}`, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1319
|
+
return { ...row, title: described.thread.title, state: described.thread.state, members: described.members };
|
|
1320
|
+
}
|
|
1321
|
+
catch (error) {
|
|
1322
|
+
if (signal?.aborted)
|
|
1323
|
+
throw error;
|
|
1324
|
+
return { ...row, unavailable: failureOf(error) };
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
/** One thread's record and members: local when this node hosts it, from the owner otherwise. */
|
|
1328
|
+
async describeThread(ref, signal) {
|
|
1329
|
+
if (ref.owner === this.#ctx.options.node_id)
|
|
1330
|
+
return this.#threads.describe(this.#auth(), ref);
|
|
1331
|
+
return this.#remote(ref.owner, 'GET', `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* One page of a thread's entries, read locally when this node hosts the thread and from the
|
|
1335
|
+
* owner otherwise. A member of a joined thread has no local copy of the entries, so the
|
|
1336
|
+
* owner's message route is the only source that can answer for them.
|
|
1337
|
+
*/
|
|
1338
|
+
async threadEntries(ref, query = {}, signal) {
|
|
1339
|
+
if (ref.owner === this.#ctx.options.node_id)
|
|
1340
|
+
return this.#messages.list(this.#auth(), ref, query);
|
|
1341
|
+
const search = new URLSearchParams();
|
|
1342
|
+
for (const [name, value] of Object.entries(query)) {
|
|
1343
|
+
if (value === undefined)
|
|
1344
|
+
continue;
|
|
1345
|
+
search.set(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
1346
|
+
}
|
|
1347
|
+
const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/messages`
|
|
1348
|
+
+ (search.size === 0 ? '' : `?${search}`);
|
|
1349
|
+
return this.#remote(ref.owner, 'GET', path, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1350
|
+
}
|
|
1351
|
+
/** Every owner + thread this node knows, from accepted grant notices plus its own threads. */
|
|
1352
|
+
async #knownRefs() {
|
|
1353
|
+
const granted = await this.#ctx.storage.transaction(async (tx) => (await tx.scan(key('known') + '/')).map(row => row.value.thread_ref));
|
|
1354
|
+
const refs = new Map();
|
|
1355
|
+
for (const ref of granted)
|
|
1356
|
+
refs.set(key(ref.owner, ref.id), ref);
|
|
1357
|
+
const owned = await this.#allPages(cursor => this.#threads.list(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) }));
|
|
1358
|
+
for (const thread of owned)
|
|
1359
|
+
refs.set(key(thread.thread_ref.owner, thread.thread_ref.id), thread.thread_ref);
|
|
1360
|
+
return [...refs.values()];
|
|
1361
|
+
}
|
|
1362
|
+
/** Every item of a paged listing, or a reported failure when the list never terminates. */
|
|
1363
|
+
async #allPages(load) {
|
|
1364
|
+
const all = [];
|
|
1365
|
+
let cursor;
|
|
1366
|
+
for (let fetched = 0; fetched < MAX_CURSOR_PAGES; fetched++) {
|
|
1367
|
+
const page = await load(cursor);
|
|
1368
|
+
all.push(...page.items);
|
|
1369
|
+
cursor = page.next_cursor ?? undefined;
|
|
1370
|
+
if (cursor === undefined)
|
|
1371
|
+
return all;
|
|
1372
|
+
}
|
|
1373
|
+
return fail('cursor_expired', 'The list did not terminate.');
|
|
1374
|
+
}
|
|
1375
|
+
/** The owner's authenticated attention list: remote when the owner is another node. */
|
|
1376
|
+
async #attentionOf(ref, cursor, signal) {
|
|
1377
|
+
if (ref.owner === this.#ctx.options.node_id) {
|
|
1378
|
+
const options = { page_size: 100, ...(cursor === undefined ? {} : { cursor }) };
|
|
1379
|
+
return this.#messages.attention(this.#auth(), ref, options);
|
|
1380
|
+
}
|
|
1381
|
+
const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/attention`
|
|
1382
|
+
+ (cursor === undefined ? '' : `?cursor=${encodeURIComponent(cursor)}`);
|
|
1383
|
+
return this.#remote(ref.owner, 'GET', path, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1384
|
+
}
|
|
1385
|
+
async #reconcileThread(ref, signal) {
|
|
1386
|
+
let cursor;
|
|
1387
|
+
for (let fetched = 0; fetched < MAX_CURSOR_PAGES; fetched++) {
|
|
1388
|
+
if (signal?.aborted)
|
|
1389
|
+
return;
|
|
1390
|
+
const page = await this.#attentionOf(ref, cursor, signal);
|
|
1391
|
+
for (const attention of page.items) {
|
|
1392
|
+
if (signal?.aborted)
|
|
1393
|
+
return;
|
|
1394
|
+
// The list answers for the thread it was asked about; anything else is not evidence
|
|
1395
|
+
// for this thread, however well-formed it looks.
|
|
1396
|
+
if (attention.thread_ref?.id !== ref.id)
|
|
1397
|
+
continue;
|
|
1398
|
+
await this.#store(ref, attention);
|
|
1399
|
+
}
|
|
1400
|
+
cursor = page.next_cursor ?? undefined;
|
|
1401
|
+
if (cursor === undefined)
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
fail('cursor_expired', 'The attention list did not terminate.');
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Merges an owner's attention row into the local record, then acks and consumes it.
|
|
1408
|
+
* An owner that strips the body never takes back a copy this node already holds
|
|
1409
|
+
* (RFC 0003 §2), and an `expired` disposition marks that copy without deleting it.
|
|
1410
|
+
*/
|
|
1411
|
+
async #store(ref, attention) {
|
|
1412
|
+
const obligation_id = typeof attention.obligation_id === 'string' && attention.obligation_id.length > 0
|
|
1413
|
+
? attention.obligation_id
|
|
1414
|
+
: key(ref.owner, ref.id, attention.message_ref.sender, attention.message_ref.id, this.#ctx.options.node_id);
|
|
1415
|
+
const stored = await this.#ctx.storage.transaction(async (tx) => {
|
|
1416
|
+
const previous = await tx.get(inboxKey(obligation_id));
|
|
1417
|
+
// An owner may strip the body from its list; the copy this node already holds is then
|
|
1418
|
+
// the evidence to check the row against, and it is never discarded (RFC 0003 §2).
|
|
1419
|
+
const local = previous?.attention.entry;
|
|
1420
|
+
if (!this.#attest(attention, ref.owner, local))
|
|
1421
|
+
return undefined;
|
|
1422
|
+
const merged = {
|
|
1423
|
+
attention: { ...attention, obligation_id, entry: attention.entry ?? local },
|
|
1424
|
+
// The owner's plan names the context this delivery was addressed to; a row that
|
|
1425
|
+
// arrived through reconciliation must keep it, or the host cannot route it back.
|
|
1426
|
+
...(previous?.context === undefined
|
|
1427
|
+
? (attention.plan?.context === undefined ? {} : { context: attention.plan.context })
|
|
1428
|
+
: { context: previous.context }),
|
|
1429
|
+
injected: previous?.injected === true,
|
|
1430
|
+
...(previous?.settlement === undefined ? {} : { settlement: previous.settlement }),
|
|
1431
|
+
...(previous?.resource_check_failures === undefined ? {} : { resource_check_failures: previous.resource_check_failures }),
|
|
1432
|
+
};
|
|
1433
|
+
if (previous !== undefined && canonical(previous.attention) === canonical(merged.attention)) {
|
|
1434
|
+
return previous;
|
|
1435
|
+
}
|
|
1436
|
+
await tx.put(inboxKey(obligation_id), merged);
|
|
1437
|
+
return merged;
|
|
1438
|
+
});
|
|
1439
|
+
if (stored === undefined)
|
|
1440
|
+
return;
|
|
1441
|
+
if (!stored.attention.acked)
|
|
1442
|
+
await this.#ack(ref, obligation_id);
|
|
1443
|
+
if (stored.attention.entry === undefined)
|
|
1444
|
+
return;
|
|
1445
|
+
this.#settle(stored.attention.entry);
|
|
1446
|
+
if (stored.attention.disposition === 'expired')
|
|
1447
|
+
return;
|
|
1448
|
+
await this.#consume(stored.attention.entry, stored);
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Delivers each outstanding owner notice to its recipient, authenticated as the owner
|
|
1452
|
+
* that issued it. The notice stays unacked here until the recipient's own confirmation
|
|
1453
|
+
* round trip reaches this node's `ackGrant`.
|
|
1454
|
+
*/
|
|
1455
|
+
async #deliverGrants(signal) {
|
|
1456
|
+
const budget = this.#ctx.options.wait_ms ?? 60_000;
|
|
1457
|
+
const notices = await this.#allPages(cursor => this.#threads.notices(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) }));
|
|
1458
|
+
for (const grant of notices) {
|
|
1459
|
+
if (signal?.aborted)
|
|
1460
|
+
return;
|
|
1461
|
+
try {
|
|
1462
|
+
if (grant.recipient === this.#ctx.options.node_id) {
|
|
1463
|
+
// A node already knows the threads it hosts, so its own notice needs no delivery.
|
|
1464
|
+
// The recipient is this node, which is the identity `ackGrant` checks.
|
|
1465
|
+
await this.#threads.ackGrant(this.#auth(), grant.id, {
|
|
1466
|
+
idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined),
|
|
1467
|
+
});
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
await this.#remote(grant.recipient, 'POST', '/grants', { grant }, signal, budget);
|
|
1471
|
+
}
|
|
1472
|
+
catch {
|
|
1473
|
+
// The next pass retries; the notice stays outstanding until the recipient confirms.
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Confirms each grant this node accepted back to the owner that issued it. The owner's
|
|
1479
|
+
* `ackGrant` is the one that closes its notice, so the round trip ends there.
|
|
1480
|
+
*/
|
|
1481
|
+
async #confirmGrants(signal) {
|
|
1482
|
+
const budget = this.#ctx.options.wait_ms ?? 60_000;
|
|
1483
|
+
const received = await this.#allPages(cursor => this.#threads.grants(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) }));
|
|
1484
|
+
for (const grant of received) {
|
|
1485
|
+
if (signal?.aborted)
|
|
1486
|
+
return;
|
|
1487
|
+
if (grant.acked)
|
|
1488
|
+
continue;
|
|
1489
|
+
const mutation = { idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined) };
|
|
1490
|
+
try {
|
|
1491
|
+
if (grant.thread_ref.owner === this.#ctx.options.node_id) {
|
|
1492
|
+
await this.#threads.ackGrant(this.#auth(), grant.id, mutation);
|
|
1493
|
+
}
|
|
1494
|
+
else {
|
|
1495
|
+
await this.#remote(grant.thread_ref.owner, 'POST', `/grants/${encodeURIComponent(grant.id)}/ack`, mutation, signal, budget);
|
|
1496
|
+
}
|
|
1497
|
+
// The owner's notice is closed; the local copy records that so this pass stops
|
|
1498
|
+
// re-confirming a grant the owner already acknowledged.
|
|
1499
|
+
await this.#threads.ackGrantLocally(this.#auth(), grant.thread_ref, grant.id);
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
// Idempotent; the next pass sends the confirmation again.
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Asks every trusted owner, at most once per window, for the memberships it still holds for
|
|
1508
|
+
* this node, and re-records the grants this store no longer has. An owner's notice stays
|
|
1509
|
+
* acknowledged forever, so a participant whose own store was rebuilt never learns those
|
|
1510
|
+
* threads again from a push; the owner's signed answer is the evidence that repairs them.
|
|
1511
|
+
* Only a missing `known` or `grant` row is written, so a healthy thread costs no traffic and
|
|
1512
|
+
* a repeated pass is idempotent. An owner older than this route answers not_found once, and
|
|
1513
|
+
* that answer is the watermark: asking it again inside the window teaches nothing.
|
|
1514
|
+
*/
|
|
1515
|
+
async #resyncGrants(signal) {
|
|
1516
|
+
const peers = await this.#ctx.storage.transaction(async (tx) => {
|
|
1517
|
+
const exchanges = (await tx.scan(key('trust') + '/'))
|
|
1518
|
+
.map(row => row.value)
|
|
1519
|
+
.filter(entry => entry.node_id !== this.#ctx.node && trustPhase(entry) === 'exchanged');
|
|
1520
|
+
const rows = [];
|
|
1521
|
+
for (const entry of exchanges) {
|
|
1522
|
+
rows.push({ node_id: entry.node_id, at: (await tx.get(grantResyncKey(entry.node_id)))?.at });
|
|
1523
|
+
}
|
|
1524
|
+
return rows;
|
|
1525
|
+
});
|
|
1526
|
+
const now = this.#ctx.now();
|
|
1527
|
+
// One probe per peer costs the same budget as a resource binding check, never the full
|
|
1528
|
+
// delivery wait: a slow owner must not hold the pass behind it.
|
|
1529
|
+
const budget = Math.min(this.#ctx.options.wait_ms ?? 60_000, RESOURCE_CHECK_MS);
|
|
1530
|
+
for (const peer of peers) {
|
|
1531
|
+
if (signal?.aborted)
|
|
1532
|
+
return;
|
|
1533
|
+
if (peer.at !== undefined && now - peer.at < GRANT_RESYNC_MS)
|
|
1534
|
+
continue;
|
|
1535
|
+
try {
|
|
1536
|
+
const issued = await this.#allPages(cursor => {
|
|
1537
|
+
const search = new URLSearchParams({ page_size: '100' });
|
|
1538
|
+
if (cursor !== undefined)
|
|
1539
|
+
search.set('cursor', cursor);
|
|
1540
|
+
return this.#remote(peer.node_id, 'GET', `/grants/issued?${search}`, undefined, signal, budget);
|
|
1541
|
+
});
|
|
1542
|
+
// Only a membership this node is the named recipient of, on an owner thread, is the
|
|
1543
|
+
// owner's answer about this node; anything else is not evidence for a local repair.
|
|
1544
|
+
const offered = issued.filter(grant => grant.thread_ref.owner === peer.node_id && grant.recipient === this.#ctx.node);
|
|
1545
|
+
const repair = await this.#ctx.storage.transaction(async (tx) => {
|
|
1546
|
+
const missing = new Set();
|
|
1547
|
+
for (const grant of offered) {
|
|
1548
|
+
const known = await tx.get(key('known', grant.thread_ref.owner, grant.thread_ref.id));
|
|
1549
|
+
const held = await tx.get(key('grant', grant.thread_ref.owner, grant.id));
|
|
1550
|
+
if (known === undefined || held === undefined)
|
|
1551
|
+
missing.add(grant.id);
|
|
1552
|
+
}
|
|
1553
|
+
return missing;
|
|
1554
|
+
});
|
|
1555
|
+
for (const grant of offered) {
|
|
1556
|
+
if (!repair.has(grant.id))
|
|
1557
|
+
continue;
|
|
1558
|
+
const restored = await this.#threads.acceptGrant({ actor: peer.node_id }, grant);
|
|
1559
|
+
this.#trace({ event: 'grant.restored', owner: peer.node_id, thread: restored.thread_ref, grant: restored.id });
|
|
1560
|
+
}
|
|
1561
|
+
await this.#peerAnswered(peer.node_id);
|
|
1562
|
+
}
|
|
1563
|
+
catch (error) {
|
|
1564
|
+
if (signal?.aborted)
|
|
1565
|
+
return;
|
|
1566
|
+
const failure = failureOf(error);
|
|
1567
|
+
if (failure.code === 'not_found') {
|
|
1568
|
+
await this.#peerAnswered(peer.node_id);
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
this.#windowedFailure(key('grant-resync', peer.node_id, failure.code), attempts => ({
|
|
1572
|
+
event: 'grant.resync.failed', owner: peer.node_id, code: failure.code, message: failure.message, attempts,
|
|
1573
|
+
}));
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Records that one trusted owner answered. That answer is the new peer signal a parked
|
|
1579
|
+
* thread waits for, so its dead-lettered retry series starts over.
|
|
1580
|
+
*/
|
|
1581
|
+
async #peerAnswered(peer) {
|
|
1582
|
+
await this.#ctx.storage.transaction(async (tx) => {
|
|
1583
|
+
await tx.put(grantResyncKey(peer), { peer, at: this.#ctx.now() });
|
|
1584
|
+
for (const row of await tx.scan(key('reconcile-backoff') + '/')) {
|
|
1585
|
+
if (row.value.owner === peer)
|
|
1586
|
+
await tx.delete(row.key);
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
async #dispatch(action, options) {
|
|
1591
|
+
const auth = this.#auth();
|
|
1592
|
+
const mutation = () => ({ idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined) });
|
|
1593
|
+
if (action.kind === 'read') {
|
|
1594
|
+
if (action.scope === 'resources')
|
|
1595
|
+
return this.readResources(action.query, options);
|
|
1596
|
+
switch (action.scope) {
|
|
1597
|
+
case 'directory': return this.#directory();
|
|
1598
|
+
case 'neighbors': {
|
|
1599
|
+
// Same rows onlyne://neighbors renders: observed nodes with trust state attached.
|
|
1600
|
+
const peers = this.#ctx.options.plugins.neighbors;
|
|
1601
|
+
if (peers === undefined)
|
|
1602
|
+
fail('unsupported_delivery', 'No neighbor directory is available on this host.');
|
|
1603
|
+
return peers();
|
|
1604
|
+
}
|
|
1605
|
+
case 'threads': return this.#threadsView(action, options);
|
|
1606
|
+
case 'inbox':
|
|
1607
|
+
// A thread-scoped inbox is the owner's attention list for that thread, so the
|
|
1608
|
+
// owner that admitted the messages is asked, exactly like reconciliation does.
|
|
1609
|
+
if (action.thread !== undefined)
|
|
1610
|
+
return this.#attentionOf(action.thread, action.cursor, options.signal);
|
|
1611
|
+
return this.#messages.inbox(auth, {
|
|
1612
|
+
...(action.cursor === undefined ? {} : { cursor: action.cursor }),
|
|
1613
|
+
...(action.page_size === undefined ? {} : { page_size: action.page_size }),
|
|
1614
|
+
});
|
|
1615
|
+
case 'messages':
|
|
1616
|
+
return this.#readThread(action, options);
|
|
1617
|
+
case 'message':
|
|
1618
|
+
case 'evidence':
|
|
1619
|
+
return this.#readMessage(action, options);
|
|
1620
|
+
}
|
|
1621
|
+
return fail('invalid_request', 'Unknown read scope.');
|
|
1622
|
+
}
|
|
1623
|
+
if (action.kind === 'thread') {
|
|
1624
|
+
switch (action.op) {
|
|
1625
|
+
case 'create':
|
|
1626
|
+
return this.#threads.create(auth, { ...mutation(), title: requireValue(action.title, 'invalid_request') });
|
|
1627
|
+
case 'close': return this.#threads.close(auth, requireValue(action.thread, 'invalid_request'), mutation());
|
|
1628
|
+
case 'list': return this.#threads.list(auth);
|
|
1629
|
+
case 'describe': return this.describeThread(requireValue(action.thread, 'invalid_request'), options.signal);
|
|
1630
|
+
case 'member_add':
|
|
1631
|
+
return this.#threads.member(auth, requireValue(action.thread, 'invalid_request'), requireValue(action.to, 'invalid_request'), {}, mutation());
|
|
1632
|
+
case 'member_remove':
|
|
1633
|
+
return this.#threads.member(auth, requireValue(action.thread, 'invalid_request'), requireValue(action.to, 'invalid_request'), { state: 'revoked' }, mutation());
|
|
1634
|
+
}
|
|
1635
|
+
return fail('invalid_request', 'Unknown thread operation.');
|
|
1636
|
+
}
|
|
1637
|
+
if (action.kind === 'resource')
|
|
1638
|
+
return this.#operateResource(action, options);
|
|
1639
|
+
if (action.kind === 'domain') {
|
|
1640
|
+
const domainMutation = () => ({
|
|
1641
|
+
idempotency_key: this.#ctx.id(),
|
|
1642
|
+
retry_until: this.#window(undefined),
|
|
1643
|
+
...(action.expected_revision === undefined ? {} : { expected_revision: action.expected_revision }),
|
|
1644
|
+
});
|
|
1645
|
+
switch (action.op) {
|
|
1646
|
+
case 'create': return this.#domains.create(auth, mutation());
|
|
1647
|
+
case 'join': return this.#domains.join(auth, requireValue(action.domain_id, 'invalid_request'), domainMutation());
|
|
1648
|
+
case 'leave': return this.#domains.leave(auth, requireValue(action.domain_id, 'invalid_request'), domainMutation());
|
|
1649
|
+
case 'list': return this.#domains.list(auth);
|
|
1650
|
+
case 'describe': return this.#domains.describe(auth, requireValue(action.domain_id, 'invalid_request'));
|
|
1651
|
+
case 'discover':
|
|
1652
|
+
return this.#domains.discover(auth, requireValue(action.domain_id, 'invalid_request'), options.signal);
|
|
1653
|
+
}
|
|
1654
|
+
return fail('invalid_request', 'Unknown domain operation.');
|
|
1655
|
+
}
|
|
1656
|
+
return fail('invalid_request', 'Unknown action.');
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Reads a thread over the owner's authenticated endpoint; this node's own threads are
|
|
1660
|
+
* read locally. A remote owner that will not serve the read is a reported failure,
|
|
1661
|
+
* never a silent local fallback.
|
|
1662
|
+
*/
|
|
1663
|
+
async #readThread(action, options) {
|
|
1664
|
+
const ref = requireValue(action.thread, 'invalid_request');
|
|
1665
|
+
const query = {
|
|
1666
|
+
attention: action.attention ?? 'thread',
|
|
1667
|
+
...(action.cursor === undefined ? {} : { cursor: action.cursor }),
|
|
1668
|
+
...(action.page_size === undefined ? {} : { page_size: action.page_size }),
|
|
1669
|
+
...(action.seq === undefined ? {} : { since_seq: action.seq }),
|
|
1670
|
+
...(action.message === undefined ? {} : { related_to: action.message }),
|
|
1671
|
+
};
|
|
1672
|
+
return this.threadEntries(ref, query, options.signal);
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* The threads this node can see in one page: the ones it owns, plus the ones it was invited
|
|
1676
|
+
* into. A joined row carries `read_only: true`; its owner's title, state and member table
|
|
1677
|
+
* come from the describe read, and an unreachable owner leaves the failure on the row
|
|
1678
|
+
* instead of dropping the thread from the list.
|
|
1679
|
+
*/
|
|
1680
|
+
async #threadsView(action, options) {
|
|
1681
|
+
const [owned, joined] = await Promise.all([
|
|
1682
|
+
this.#allPages(cursor => this.#threads.list(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) })),
|
|
1683
|
+
this.#allPages(cursor => this.joinedThreads({ page_size: 100, ...(cursor === undefined ? {} : { cursor }) }, options.signal)),
|
|
1684
|
+
]);
|
|
1685
|
+
const position = (row) => key(row.thread_ref.owner, row.thread_ref.id);
|
|
1686
|
+
const rows = [...owned, ...joined].sort((left, right) => {
|
|
1687
|
+
const a = position(left);
|
|
1688
|
+
const b = position(right);
|
|
1689
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1690
|
+
});
|
|
1691
|
+
return page(rows, {
|
|
1692
|
+
...(action.cursor === undefined ? {} : { cursor: action.cursor }),
|
|
1693
|
+
...(action.page_size === undefined ? {} : { page_size: action.page_size }),
|
|
1694
|
+
}, { kind: 'threads', actor: this.#ctx.options.node_id }, position);
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* One message by reference. `evidence` is this node's own delivery record; a message this
|
|
1698
|
+
* node appended to a thread it hosts never creates one, so its committed entry is the
|
|
1699
|
+
* evidence. Otherwise the owner that admitted the message is the only source, and its
|
|
1700
|
+
* message list is where a referenced entry is found (RFC 0004 §2).
|
|
1701
|
+
*/
|
|
1702
|
+
async #readMessage(action, options) {
|
|
1703
|
+
const ref = requireValue(action.message, 'invalid_request');
|
|
1704
|
+
if (action.scope === 'evidence') {
|
|
1705
|
+
const record = await this.#ctx.storage.transaction(tx => tx.get(outboundKey(ref.sender, ref.id)));
|
|
1706
|
+
if (record !== undefined)
|
|
1707
|
+
return record;
|
|
1708
|
+
// A reply appended to a hosted thread is committed locally and delivered by that
|
|
1709
|
+
// commit; reporting "no evidence" for it reads as a failed send and invites a
|
|
1710
|
+
// duplicate. A message this node received has another sender, so it stays not_found.
|
|
1711
|
+
const hosted = ref.sender === this.#ctx.options.node_id
|
|
1712
|
+
? await this.#ctx.storage.transaction(tx => tx.get(key('entry-ref', ref.sender, ref.id)))
|
|
1713
|
+
: undefined;
|
|
1714
|
+
if (hosted !== undefined) {
|
|
1715
|
+
return { admission: 'accepted', local: true, entry: hosted, thread_ref: hosted.thread_ref, seq: hosted.seq };
|
|
1716
|
+
}
|
|
1717
|
+
fail('not_found', 'This node holds no outbound evidence for that message.');
|
|
1718
|
+
}
|
|
1719
|
+
const hosted = await this.#ctx.storage.transaction(tx => tx.get(key('entry-ref', ref.sender, ref.id)));
|
|
1720
|
+
const thread = action.thread ?? hosted?.thread_ref;
|
|
1721
|
+
if (thread === undefined)
|
|
1722
|
+
fail('not_found', 'This node holds no evidence for that message.');
|
|
1723
|
+
if (thread.owner === this.#ctx.options.node_id)
|
|
1724
|
+
return this.#messages.getByRef(this.#auth(), ref);
|
|
1725
|
+
let cursor;
|
|
1726
|
+
for (let fetched = 0; fetched < MAX_CURSOR_PAGES; fetched++) {
|
|
1727
|
+
if (options.signal?.aborted)
|
|
1728
|
+
break;
|
|
1729
|
+
const search = new URLSearchParams({ attention: 'thread' });
|
|
1730
|
+
if (cursor !== undefined)
|
|
1731
|
+
search.set('cursor', cursor);
|
|
1732
|
+
const path = `/threads/${encodeURIComponent(thread.owner)}/${encodeURIComponent(thread.id)}/messages?${search}`;
|
|
1733
|
+
const page = await this.#remote(thread.owner, 'GET', path, undefined, options.signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1734
|
+
const found = page.items.find(entry => entry.message.sender === ref.sender && entry.message.id === ref.id);
|
|
1735
|
+
if (found !== undefined)
|
|
1736
|
+
return found;
|
|
1737
|
+
cursor = page.next_cursor ?? undefined;
|
|
1738
|
+
if (cursor === undefined)
|
|
1739
|
+
break;
|
|
1740
|
+
}
|
|
1741
|
+
return fail('not_found', 'The owning node did not return that message.');
|
|
1742
|
+
}
|
|
1743
|
+
/** Resource operations route to the resource owner; this node's own resources answer locally. */
|
|
1744
|
+
async #operateResource(action, options) {
|
|
1745
|
+
const request = {
|
|
1746
|
+
idempotency_key: this.#ctx.id(),
|
|
1747
|
+
retry_until: this.#window(undefined),
|
|
1748
|
+
operation_id: this.#ctx.id(),
|
|
1749
|
+
operation: action.operation,
|
|
1750
|
+
...(action.expected_revision === undefined ? {} : { expected_revision: action.expected_revision }),
|
|
1751
|
+
...(action.data === undefined ? {} : { data: action.data }),
|
|
1752
|
+
};
|
|
1753
|
+
if (action.resource.owner === this.#ctx.options.node_id) {
|
|
1754
|
+
return this.#resources.operate(this.#auth(), action.resource, request, options.signal);
|
|
1755
|
+
}
|
|
1756
|
+
const path = `/resources/${encodeURIComponent(action.resource.owner)}/${encodeURIComponent(action.resource.id)}/operations`;
|
|
1757
|
+
return this.#remote(action.resource.owner, 'POST', path, request, options.signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1758
|
+
}
|
|
1759
|
+
/** Local facts read without a peer: this node's identity, description, threads and inbox. */
|
|
1760
|
+
async #directory() {
|
|
1761
|
+
const plugins = this.#ctx.options.plugins;
|
|
1762
|
+
return {
|
|
1763
|
+
node: this.#ctx.options.node_id,
|
|
1764
|
+
description: plugins.directory === undefined ? null : await plugins.directory(),
|
|
1765
|
+
threads: await this.#threads.list(this.#auth(), { page_size: 100 }),
|
|
1766
|
+
inbox: await this.#messages.inbox(this.#auth(), { page_size: 100 }),
|
|
1767
|
+
domains: await this.#domains.list(this.#auth(), { page_size: 100 }),
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
_a = OnlyneClient;
|
|
1772
|
+
//# sourceMappingURL=client.js.map
|