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