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