@onlyne/lib 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/client.ts +255 -59
- package/src/core.ts +2 -2
- package/src/discovery.ts +26 -18
- package/src/domains.ts +29 -26
- package/src/history.ts +21 -11
- package/src/http.ts +25 -14
- package/src/index.ts +18 -18
- package/src/keys.ts +81 -71
- package/src/memory.ts +1 -1
- package/src/messages.ts +63 -52
- package/src/node.ts +23 -16
- package/src/resolve.ts +39 -0
- package/src/resources.ts +3 -3
- package/src/threads.ts +98 -39
- package/src/transport.ts +18 -11
- package/src/types.ts +54 -9
package/README.md
CHANGED
|
@@ -60,6 +60,8 @@ A resource has one binding — the handler and the highest event it has confirme
|
|
|
60
60
|
|
|
61
61
|
`client.readResources(query)` reads local resources or requests their owner's authenticated endpoint. The `ResourceReadQuery` operations are `list`, `describe`, `log`, `event`, `operations`, and `status`. The `decide` plugin can return `{ kind: 'read', scope: 'resources', query }` to use the same reader through `msg`. Reads create no operation record and do not call a resource driver.
|
|
62
62
|
|
|
63
|
+
A queued entry that projects a resource event (`meta.data.resource_ref`, authenticated by that resource's owner) is checked against the binding before it is injected. If the binding names another handler, the row is settled with `settlement.via === 'handover'`; if this node may no longer read the resource, the row is settled as `undeliverable` with `reason: 'resource-unavailable'`. A transient failure leaves the row owed and is traced as `inbound.resource-check.failed`. Settlement closes the local delivery obligation. It records why delivery ended, and it does not assert that the model read the entry, so no consumption record and no todo are written.
|
|
64
|
+
|
|
63
65
|
`list` accepts concrete `owners`, `page_size`, and `cursor`. Omitted `owners` selects the local node. The result contains `owners: [{ owner, page } | { owner, error }]`, so an unavailable owner remains visible beside successful pages. A cursor selects exactly one owner. The host chooses which owners to include. Other queries name `resource: { owner, id }`. `log` accepts an exclusive `since` event id, `page_size`, and `cursor`. `event` names `event_id`; `status` names `operation_id` and returns the existing operation record. The original actor can read that record after losing resource-read access. `operations` returns only the current actor's records.
|
|
64
66
|
|
|
65
67
|
An operation result is `committed`, `pending`, `aborted`, or `unknown`. `aborted` means the endpoint refused or locally stopped the call and confirmed that no external effect happened; a failure whose side effect cannot be ruled out is `unknown`, keeps the original error in `details`, and stays owed to `node.resources.recover()`, which asks the driver for the outcome by `operation_id`. A caller that stops waiting gets `unknown` while the external call keeps running, and a later driver outcome settles that record. A retried operation is asked for its outcome, never executed a second time.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlyne/lib",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Decentralized agent messaging: threads, durable admission, attention, reply correlation, resources with handover, and an HTTP binding",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/client.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
|
-
import type { Context } from './core.
|
|
2
|
-
import { canonical, decimal, fail, key, requireValue, utc } from './core.
|
|
1
|
+
import type { Context } from './core.ts';
|
|
2
|
+
import { canonical, decimal, fail, key, page, requireValue, utc } from './core.ts';
|
|
3
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.
|
|
9
|
-
import type { MessageService } from './messages.
|
|
10
|
-
import type { ResourceService } from './resources.
|
|
11
|
-
import type { ThreadService } from './threads.
|
|
12
|
-
import type { DomainService } from './domains.
|
|
13
|
-
import type { TrustService } from './keys.
|
|
4
|
+
Action, Attention, Auth, DeliveryPlan, DomainMembership, DomainMutation, Entry, ErrorCode, Failure, Grant, Json, LocalAttention,
|
|
5
|
+
InjectOutcome, JoinedThread, Member, Message, MessageQuery, MessageRef, MsgInput, Mutation, NodeId, Outbound, Page, PageOptions, SendRequest,
|
|
6
|
+
TraceEvent, Thread, ThreadDescription, ThreadRef, Transaction, Transport, MaintenanceClock, PreparedSend,
|
|
7
|
+
ResourceReadQuery, ResourceReadResult, ResourceInventory, Resource, AttentionSettlement,
|
|
8
|
+
} from './types.ts';
|
|
9
|
+
import type { MessageService } from './messages.ts';
|
|
10
|
+
import type { ResourceService } from './resources.ts';
|
|
11
|
+
import type { ThreadService } from './threads.ts';
|
|
12
|
+
import type { DomainService } from './domains.ts';
|
|
13
|
+
import type { TrustService } from './keys.ts';
|
|
14
14
|
|
|
15
15
|
/** Result of one delivery: `admission` is what the peer recorded, `wait` is what this caller observed. */
|
|
16
16
|
export interface SendOutcome {
|
|
17
17
|
receipt?: Outbound['receipt'];
|
|
18
18
|
admission: Outbound['state'];
|
|
19
19
|
reply?: Entry;
|
|
20
|
+
/** The target as the caller spelled it, present only with `resolved` when the two differ. */
|
|
21
|
+
requested?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The node `requested` resolved to. Present whenever the addressee differs from the
|
|
24
|
+
* endpoint that stores the entry — which is this node whenever it started the
|
|
25
|
+
* conversation — so an initiator's receipt never reads as a message the node sent itself.
|
|
26
|
+
*/
|
|
27
|
+
resolved?: NodeId;
|
|
20
28
|
/**
|
|
21
29
|
* Whether the reply body is safe to render. `withheld` means the same body is already
|
|
22
30
|
* confirmed consumed in this context through another path (RFC 0008 §5), so the caller
|
|
@@ -32,6 +40,12 @@ export interface SendOptions {
|
|
|
32
40
|
retry_until?: string;
|
|
33
41
|
idempotency_key?: string;
|
|
34
42
|
expected_author?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The caller's own spelling of the addressee. Recorded on the outbound record beside the
|
|
45
|
+
* resolved node id when the two differ, so the record shows both what was asked and what
|
|
46
|
+
* was resolved (RFC 0005 §4: the receipt reports the landing, not the request).
|
|
47
|
+
*/
|
|
48
|
+
requested?: string;
|
|
35
49
|
/**
|
|
36
50
|
* The sender's own consumption identity for the awaited reply (RFC 0008 §5). It stays
|
|
37
51
|
* local and never stands in for the wire plan's receiver-side context.
|
|
@@ -74,6 +88,10 @@ const TERMINAL: Record<string, true> = {
|
|
|
74
88
|
unsupported_delivery: true, ambiguous_target: true, ambiguous_resource: true,
|
|
75
89
|
};
|
|
76
90
|
const MAX_CURSOR_PAGES = 10_000;
|
|
91
|
+
/** A binding check must not hold the maintenance pass behind an unreachable resource owner. */
|
|
92
|
+
const RESOURCE_CHECK_MS = 5_000;
|
|
93
|
+
/** One reconcile failure line per (owner, thread, code) per window; a dead owner stays observable. */
|
|
94
|
+
const RECONCILE_FAILURE_WINDOW_MS = 300_000;
|
|
77
95
|
|
|
78
96
|
/** Anything that is not a recorded refusal leaves the admission unknown, never "undelivered". */
|
|
79
97
|
function admissionOf(failure: Failure): Outbound['state'] {
|
|
@@ -177,6 +195,8 @@ export class OnlyneClient {
|
|
|
177
195
|
readonly #waiters = new Map<string, Waiter>();
|
|
178
196
|
/** Replies already returned as tool results, per context, held until consumption confirms them. */
|
|
179
197
|
readonly #held = new Map<string, Set<string>>();
|
|
198
|
+
/** Last `reconcile.failed` line per (owner, thread, code), and what the window suppressed. */
|
|
199
|
+
readonly #reconcileFailures = new Map<string, { at: number; suppressed: number }>();
|
|
180
200
|
|
|
181
201
|
constructor(
|
|
182
202
|
ctx: Context, transport: Transport, messages: MessageService, threads: ThreadService,
|
|
@@ -303,19 +323,31 @@ export class OnlyneClient {
|
|
|
303
323
|
},
|
|
304
324
|
};
|
|
305
325
|
if (ref.owner === ctx.options.node_id && to !== ctx.options.node_id) {
|
|
306
|
-
|
|
326
|
+
// The addressee can only ever learn this entry through a signed grant or a signed
|
|
327
|
+
// reconcile, so a send that names a peer this node never paired with is refused before
|
|
328
|
+
// anything is stored: it would otherwise read `accepted` while the peer can never see it.
|
|
329
|
+
if (this.#trust !== undefined && ctx.options.plugins.keys !== undefined) {
|
|
330
|
+
const pairing = await this.#trust.entry(to);
|
|
331
|
+
if (pairing === undefined) {
|
|
332
|
+
fail('not_found', `No trust record names ${to}. Pair the nodes (trust_begin) before addressing them; nothing was stored.`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const member = await this.#ensureMember(ref, to);
|
|
307
336
|
// The participant is told about the thread now, not on a later maintenance pass: this
|
|
308
337
|
// exchange is also where the participant's own policy answers, so a peer that revoked
|
|
309
338
|
// this node refuses the conversation at the point the sender can still see it.
|
|
310
339
|
try {
|
|
311
|
-
await this.#pushGrant(ref,
|
|
340
|
+
await this.#pushGrant(ref, member);
|
|
312
341
|
} catch (error) {
|
|
313
342
|
const failure = failureOf(error);
|
|
314
343
|
return { admission: admissionOf(failure), error: failure };
|
|
315
344
|
}
|
|
316
345
|
}
|
|
317
346
|
|
|
318
|
-
|
|
347
|
+
const outcome = await this.#deliver(ref.owner, normalized, plan, options, to);
|
|
348
|
+
// The storing endpoint is this node whenever it started the conversation; the outcome
|
|
349
|
+
// still names the peer it addresses, or the receipt reads as a message sent to oneself.
|
|
350
|
+
return to !== ref.owner ? { ...outcome, resolved: to } : outcome;
|
|
319
351
|
}
|
|
320
352
|
|
|
321
353
|
/** The reply-authority route: the receiver owns the landing, so no thread is named here. */
|
|
@@ -324,7 +356,7 @@ export class OnlyneClient {
|
|
|
324
356
|
}
|
|
325
357
|
|
|
326
358
|
/** 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> {
|
|
359
|
+
async #deliver(target: string, message: Message, plan: DeliveryPlan, options: SendOptions, addressee?: NodeId): Promise<SendOutcome> {
|
|
328
360
|
const ctx = this.#ctx;
|
|
329
361
|
const slot = outboundKey(message.sender, message.id);
|
|
330
362
|
const request: SendRequest = {
|
|
@@ -335,6 +367,15 @@ export class OnlyneClient {
|
|
|
335
367
|
plan,
|
|
336
368
|
};
|
|
337
369
|
const digest = messageDigest(request);
|
|
370
|
+
// Who the send addresses, beside where the entry is stored: an initiator's own thread
|
|
371
|
+
// stores locally, so the record must still name the peer, or the receipt reads as a
|
|
372
|
+
// message the node sent itself.
|
|
373
|
+
const addressed = {
|
|
374
|
+
...(addressee !== undefined && addressee !== target ? { addressee } : {}),
|
|
375
|
+
...(typeof options.requested === 'string' && options.requested !== (addressee ?? target)
|
|
376
|
+
? { requested: options.requested }
|
|
377
|
+
: {}),
|
|
378
|
+
};
|
|
338
379
|
// The same message identity with different content is a conflict, never a rewrite:
|
|
339
380
|
// the slot keeps the first request and the caller sends a new message id instead.
|
|
340
381
|
const record = await ctx.storage.transaction(async tx => {
|
|
@@ -354,6 +395,7 @@ export class OnlyneClient {
|
|
|
354
395
|
const fresh: StoredOutbound = {
|
|
355
396
|
request, expected_author: options.expected_author ?? target, state: 'pending', digest,
|
|
356
397
|
created_at: new Date(ctx.now()).toISOString(),
|
|
398
|
+
...addressed,
|
|
357
399
|
...(options.context === undefined ? {} : { local_context: options.context }),
|
|
358
400
|
};
|
|
359
401
|
await tx.put(slot, fresh);
|
|
@@ -469,25 +511,24 @@ export class OnlyneClient {
|
|
|
469
511
|
}
|
|
470
512
|
|
|
471
513
|
/**
|
|
472
|
-
* Grants the addressee `post` and `read` in a thread this node owns, once
|
|
473
|
-
*
|
|
514
|
+
* Grants the addressee `post` and `read` in a thread this node owns, once, and returns the
|
|
515
|
+
* member row: the grant notice is keyed by the member revision, so the caller can push that
|
|
516
|
+
* exact notice instead of scanning the outbox for one it might not recognize.
|
|
474
517
|
*/
|
|
475
|
-
async #ensureMember(ref: ThreadRef, peer: string): Promise<
|
|
518
|
+
async #ensureMember(ref: ThreadRef, peer: string): Promise<Member> {
|
|
476
519
|
const existing = await this.#ctx.storage.transaction(tx => tx.get<Member>(key('member', ref.owner, ref.id, peer)));
|
|
477
|
-
if (existing !== undefined) return;
|
|
520
|
+
if (existing !== undefined) return existing;
|
|
478
521
|
await this.#threads.member(this.#auth(), ref, peer, {}, {
|
|
479
522
|
idempotency_key: this.#ctx.id(), retry_until: this.#window(undefined),
|
|
480
523
|
});
|
|
524
|
+
return this.#ctx.storage.transaction(async tx => requireValue(await tx.get<Member>(key('member', ref.owner, ref.id, peer))));
|
|
481
525
|
}
|
|
482
526
|
|
|
483
|
-
/** Delivers
|
|
484
|
-
async #pushGrant(
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
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);
|
|
527
|
+
/** Delivers the outstanding notice for one member's revision; none outstanding, nothing to push. */
|
|
528
|
+
async #pushGrant(_ref: ThreadRef, member: Member): Promise<void> {
|
|
529
|
+
const notice = await this.#ctx.storage.transaction(tx => tx.get<Grant>(key('notice', member.revision)));
|
|
530
|
+
if (notice === undefined || notice.acked) return;
|
|
531
|
+
await this.#remote(member.peer, 'POST', '/grants', { grant: notice }, undefined, this.#ctx.options.wait_ms ?? 60_000);
|
|
491
532
|
}
|
|
492
533
|
|
|
493
534
|
// ---- inbound path -----------------------------------------------------------
|
|
@@ -543,6 +584,8 @@ export class OnlyneClient {
|
|
|
543
584
|
* `owner + thread_ref` pairs recorded when a grant notice was accepted, plus the
|
|
544
585
|
* threads this node hosts, which need no grant. The owner's authenticated attention
|
|
545
586
|
* list is the evidence; the ack goes out only after the entry is durable locally.
|
|
587
|
+
* An owner this node cannot reach is reported as a trace record, never thrown: one dead
|
|
588
|
+
* owner must not starve the rest, and a silent skip is the failure nobody can diagnose.
|
|
546
589
|
*/
|
|
547
590
|
async reconcile(signal?: AbortSignal): Promise<Page<LocalAttention>> {
|
|
548
591
|
for (const ref of await this.#knownRefs()) {
|
|
@@ -551,14 +594,35 @@ export class OnlyneClient {
|
|
|
551
594
|
await this.#reconcileThread(ref, signal);
|
|
552
595
|
} catch (error) {
|
|
553
596
|
if (ref.owner === this.#ctx.options.node_id) throw error;
|
|
554
|
-
|
|
555
|
-
// reconciliation pass retries it, and the local inbox still reflects what landed.
|
|
597
|
+
this.#reportReconcileFailure(ref, error);
|
|
556
598
|
continue;
|
|
557
599
|
}
|
|
558
600
|
}
|
|
559
601
|
return this.#messages.inbox(this.#auth(), {});
|
|
560
602
|
}
|
|
561
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Writes at most one `reconcile.failed` line per (owner, thread, code) per window. A failed
|
|
606
|
+
* pull drops nothing, so the next pass retries the same ref; deduplicating here keeps a
|
|
607
|
+
* permanently dead owner from writing the same line every pass while still accounting for
|
|
608
|
+
* every attempt it folded into the line it did write.
|
|
609
|
+
*/
|
|
610
|
+
#reportReconcileFailure(ref: ThreadRef, error: unknown): void {
|
|
611
|
+
const failure = failureOf(error);
|
|
612
|
+
const slot = key(ref.owner, ref.id, failure.code);
|
|
613
|
+
const now = this.#ctx.now();
|
|
614
|
+
const held = this.#reconcileFailures.get(slot);
|
|
615
|
+
if (held !== undefined && now - held.at < RECONCILE_FAILURE_WINDOW_MS) {
|
|
616
|
+
held.suppressed += 1;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
this.#reconcileFailures.set(slot, { at: now, suppressed: 0 });
|
|
620
|
+
this.#trace({
|
|
621
|
+
event: 'reconcile.failed', owner: ref.owner, thread: ref.id,
|
|
622
|
+
code: failure.code, message: failure.message, attempts: (held?.suppressed ?? 0) + 1,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
562
626
|
/**
|
|
563
627
|
* Delivers outstanding owner grant notices, replays owed todos and entries whose durable
|
|
564
628
|
* record never reached the model, re-drives owed trust statements, then reconciles. Replay
|
|
@@ -679,7 +743,9 @@ export class OnlyneClient {
|
|
|
679
743
|
const action = await this.#decide(input);
|
|
680
744
|
if (action.kind !== 'send') return this.#dispatch(action, options);
|
|
681
745
|
const prepared = this.#prepare(input, action);
|
|
682
|
-
|
|
746
|
+
const outcome = await this.send(prepared.to, prepared.message, prepared.plan,
|
|
747
|
+
{ ...options, ...(typeof input.meta?.to === 'string' ? { requested: input.meta.to } : {}) });
|
|
748
|
+
return this.#addressed(outcome, input.meta?.to, prepared.to);
|
|
683
749
|
}
|
|
684
750
|
|
|
685
751
|
async prepare(input: MsgInput): Promise<PreparedSend> {
|
|
@@ -756,6 +822,16 @@ export class OnlyneClient {
|
|
|
756
822
|
return { to: action.to, message, plan };
|
|
757
823
|
}
|
|
758
824
|
|
|
825
|
+
/**
|
|
826
|
+
* The caller's own spelling beside the node the decision resolved it to. Equal spellings say
|
|
827
|
+
* nothing new; a bare alias that resolved to a peer is the case the receipt cannot show. A
|
|
828
|
+
* `resolved` the send already derived (the addressee of an initiator's own thread) is kept.
|
|
829
|
+
*/
|
|
830
|
+
#addressed(outcome: SendOutcome, requested: Json | undefined, resolved: NodeId): SendOutcome {
|
|
831
|
+
const merged: SendOutcome = outcome.resolved === undefined ? { ...outcome, resolved } : outcome;
|
|
832
|
+
return typeof requested !== 'string' || requested === resolved ? merged : { ...merged, requested };
|
|
833
|
+
}
|
|
834
|
+
|
|
759
835
|
// ---- internals ---------------------------------------------------------------
|
|
760
836
|
|
|
761
837
|
#auth(): Auth {
|
|
@@ -853,7 +929,8 @@ export class OnlyneClient {
|
|
|
853
929
|
const guarantee = (receipt as { guarantee?: unknown }).guarantee;
|
|
854
930
|
this.#trace({
|
|
855
931
|
event: 'outbound.admitted', sender: request.message.sender, message: request.message.id,
|
|
856
|
-
target: request.target_endpoint,
|
|
932
|
+
target: request.target_endpoint, ...(record.addressee === undefined ? {} : { resolved: record.addressee }),
|
|
933
|
+
state: 'accepted', ...(typeof guarantee === 'string' ? { guarantee } : {}),
|
|
857
934
|
});
|
|
858
935
|
return { admission: 'accepted', receipt };
|
|
859
936
|
} catch (error) {
|
|
@@ -862,7 +939,8 @@ export class OnlyneClient {
|
|
|
862
939
|
await this.#commit(record, { state: admissionOf(failure), error: failure });
|
|
863
940
|
this.#trace({
|
|
864
941
|
event: 'outbound.admitted', sender: request.message.sender, message: request.message.id,
|
|
865
|
-
target: request.target_endpoint,
|
|
942
|
+
target: request.target_endpoint, ...(record.addressee === undefined ? {} : { resolved: record.addressee }),
|
|
943
|
+
state: admissionOf(failure), code: failure.code,
|
|
866
944
|
});
|
|
867
945
|
const stored = await this.#ctx.storage.transaction(tx => tx.get<StoredOutbound>(outboundKey(request.message.sender, request.message.id)));
|
|
868
946
|
return {
|
|
@@ -919,16 +997,21 @@ export class OnlyneClient {
|
|
|
919
997
|
// A row keeps the context the entry landed in; the confirmation context only fills in
|
|
920
998
|
// for a row that names none.
|
|
921
999
|
let original: string | undefined;
|
|
1000
|
+
let settled = false;
|
|
1001
|
+
let consumable = false;
|
|
922
1002
|
for (const row of await tx.scan<LocalAttention>(key('inbox') + '/')) {
|
|
923
1003
|
const held = row.value.attention.entry;
|
|
924
1004
|
if (held === undefined) continue;
|
|
925
1005
|
if (held.thread_ref.owner !== entry.thread_ref.owner || held.thread_ref.id !== entry.thread_ref.id) continue;
|
|
926
1006
|
if (held.seq !== entry.seq) continue;
|
|
1007
|
+
if (row.value.settlement !== undefined) { settled = true; continue; }
|
|
1008
|
+
consumable = true;
|
|
927
1009
|
original = original ?? row.value.context;
|
|
928
1010
|
if (row.value.injected) continue;
|
|
929
1011
|
const landed = row.value.context ?? context;
|
|
930
1012
|
await tx.put(row.key, { ...row.value, injected: true, ...(landed === undefined ? {} : { context: landed }) });
|
|
931
1013
|
}
|
|
1014
|
+
if (settled && !consumable) return undefined;
|
|
932
1015
|
const where = context ?? original ?? this.#ctx.options.node_id;
|
|
933
1016
|
const slot = consumptionKey(where, entry.thread_ref, entry.seq);
|
|
934
1017
|
const stored = await tx.get<ConsumptionRecord>(slot);
|
|
@@ -940,6 +1023,10 @@ export class OnlyneClient {
|
|
|
940
1023
|
await this.#oweTodo(tx, entry, where);
|
|
941
1024
|
return where;
|
|
942
1025
|
});
|
|
1026
|
+
if (consumedIn === undefined) {
|
|
1027
|
+
this.#held.delete(entryKey(entry.thread_ref, entry.seq));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
943
1030
|
const identity = entryKey(entry.thread_ref, entry.seq);
|
|
944
1031
|
const held = this.#held.get(identity);
|
|
945
1032
|
if (held !== undefined) {
|
|
@@ -1159,9 +1246,10 @@ export class OnlyneClient {
|
|
|
1159
1246
|
* answer with the context it really used, and that is the one the todo follows.
|
|
1160
1247
|
*/
|
|
1161
1248
|
async #consume(entry: Entry, record: LocalAttention): Promise<void> {
|
|
1249
|
+
if (record.injected) { this.#injectTrace(entry, record, 'skipped', 'injected'); return; }
|
|
1250
|
+
if (await this.#settleResourceAttention(entry, record)) return;
|
|
1162
1251
|
const runtime = this.#ctx.options.plugins.runtime;
|
|
1163
1252
|
if (runtime === undefined) { this.#injectTrace(entry, record, 'skipped', 'no-runtime'); return; }
|
|
1164
|
-
if (record.injected) { this.#injectTrace(entry, record, 'skipped', 'injected'); return; }
|
|
1165
1253
|
const related = entry.message.meta?.in_reply_to;
|
|
1166
1254
|
const outbound = related?.sender === this.#ctx.options.node_id
|
|
1167
1255
|
? await this.#ctx.storage.transaction(tx => tx.get<StoredOutbound>(outboundKey(related.sender, related.id)))
|
|
@@ -1187,6 +1275,55 @@ export class OnlyneClient {
|
|
|
1187
1275
|
await this.confirmConsumption(entry, injected.context, 'fragment');
|
|
1188
1276
|
}
|
|
1189
1277
|
|
|
1278
|
+
async #settleResourceAttention(entry: Entry, record: LocalAttention): Promise<boolean> {
|
|
1279
|
+
const ref = entry.message.meta?.data?.resource_ref;
|
|
1280
|
+
if (typeof ref !== 'object' || ref === null || Array.isArray(ref)
|
|
1281
|
+
|| typeof ref.owner !== 'string' || typeof ref.id !== 'string'
|
|
1282
|
+
|| ref.owner !== entry.authenticated_author) return false;
|
|
1283
|
+
const resource_ref = { owner: ref.owner, id: ref.id };
|
|
1284
|
+
let settlement: AttentionSettlement;
|
|
1285
|
+
const at = new Date(this.#ctx.now()).toISOString();
|
|
1286
|
+
try {
|
|
1287
|
+
const resource = await this.readResources(
|
|
1288
|
+
{ op: 'describe', resource: resource_ref },
|
|
1289
|
+
{ signal: AbortSignal.timeout(RESOURCE_CHECK_MS) },
|
|
1290
|
+
) as Resource;
|
|
1291
|
+
if (resource.binding.handler === this.#ctx.node) return false;
|
|
1292
|
+
settlement = { via: 'handover', resource_ref, handler: resource.binding.handler, revision: resource.revision, at };
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
const failure = failureOf(error);
|
|
1295
|
+
if (failure.code !== 'not_found') {
|
|
1296
|
+
const attempts = await this.#ctx.storage.transaction(async tx => {
|
|
1297
|
+
const slot = inboxKey(record.attention.obligation_id);
|
|
1298
|
+
const current = await tx.get<LocalAttention>(slot);
|
|
1299
|
+
if (current === undefined || current.injected) return undefined;
|
|
1300
|
+
const attempts = (current.resource_check_failures ?? 0) + 1;
|
|
1301
|
+
await tx.put(slot, { ...current, resource_check_failures: attempts });
|
|
1302
|
+
return attempts;
|
|
1303
|
+
});
|
|
1304
|
+
if (attempts !== undefined) this.#trace({
|
|
1305
|
+
event: 'inbound.resource-check.failed', thread: entry.thread_ref, seq: entry.seq,
|
|
1306
|
+
resource_ref, code: failure.code, attempts,
|
|
1307
|
+
});
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
settlement = { via: 'undeliverable', resource_ref, reason: 'resource-unavailable', at };
|
|
1311
|
+
}
|
|
1312
|
+
const changed = await this.#ctx.storage.transaction(async tx => {
|
|
1313
|
+
const slot = inboxKey(record.attention.obligation_id);
|
|
1314
|
+
const current = await tx.get<LocalAttention>(slot);
|
|
1315
|
+
if (current === undefined || current.injected) return false;
|
|
1316
|
+
await tx.put(slot, { ...current, injected: true, settlement });
|
|
1317
|
+
return true;
|
|
1318
|
+
});
|
|
1319
|
+
if (changed) {
|
|
1320
|
+
this.#held.delete(entryKey(entry.thread_ref, entry.seq));
|
|
1321
|
+
this.#trace({ event: 'inbound.settled', thread: entry.thread_ref, seq: entry.seq, settlement });
|
|
1322
|
+
this.#ctx.options.plugins.runtime?.notify({ event: 'inbound', thread: entry.thread_ref, seq: entry.seq, sender: entry.message.sender });
|
|
1323
|
+
}
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1190
1327
|
/**
|
|
1191
1328
|
* Records the todo this consumption owes, inside the commit that confirms the consumption.
|
|
1192
1329
|
* A host without a todo hook owes none, so no record accrues for it.
|
|
@@ -1223,6 +1360,58 @@ export class OnlyneClient {
|
|
|
1223
1360
|
return stored?.confirmed === true;
|
|
1224
1361
|
}
|
|
1225
1362
|
|
|
1363
|
+
/**
|
|
1364
|
+
* The threads this node was invited into. The rows come from the records it already holds;
|
|
1365
|
+
* each row is then asked of its owner through the same describe read the thread view uses,
|
|
1366
|
+
* which is where the title, the state and the member table live. An owner that cannot be
|
|
1367
|
+
* reached leaves the row in place with the failure attached: an unreachable peer is a
|
|
1368
|
+
* reported outage, never a reason to hide a thread this node belongs to.
|
|
1369
|
+
*/
|
|
1370
|
+
async joinedThreads(options: PageOptions = {}, signal?: AbortSignal): Promise<Page<JoinedThread>> {
|
|
1371
|
+
const listed = await this.#threads.joined(this.#auth(), options);
|
|
1372
|
+
const items: JoinedThread[] = [];
|
|
1373
|
+
for (const row of listed.items) items.push(await this.#ownerFacts(row, signal));
|
|
1374
|
+
return { ...listed, items };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async #ownerFacts(row: JoinedThread, signal?: AbortSignal): Promise<JoinedThread> {
|
|
1378
|
+
try {
|
|
1379
|
+
const described = await this.#remote<ThreadDescription>(
|
|
1380
|
+
row.thread_ref.owner, 'GET',
|
|
1381
|
+
`/threads/${encodeURIComponent(row.thread_ref.owner)}/${encodeURIComponent(row.thread_ref.id)}`,
|
|
1382
|
+
undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1383
|
+
return { ...row, title: described.thread.title, state: described.thread.state, members: described.members };
|
|
1384
|
+
} catch (error) {
|
|
1385
|
+
if (signal?.aborted) throw error;
|
|
1386
|
+
return { ...row, unavailable: failureOf(error) };
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** One thread's record and members: local when this node hosts it, from the owner otherwise. */
|
|
1391
|
+
async describeThread(ref: ThreadRef, signal?: AbortSignal): Promise<ThreadDescription> {
|
|
1392
|
+
if (ref.owner === this.#ctx.options.node_id) return this.#threads.describe(this.#auth(), ref);
|
|
1393
|
+
return this.#remote(
|
|
1394
|
+
ref.owner, 'GET', `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`,
|
|
1395
|
+
undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* One page of a thread's entries, read locally when this node hosts the thread and from the
|
|
1400
|
+
* owner otherwise. A member of a joined thread has no local copy of the entries, so the
|
|
1401
|
+
* owner's message route is the only source that can answer for them.
|
|
1402
|
+
*/
|
|
1403
|
+
async threadEntries(ref: ThreadRef, query: MessageQuery = {}, signal?: AbortSignal): Promise<Page<Entry>> {
|
|
1404
|
+
if (ref.owner === this.#ctx.options.node_id) return this.#messages.list(this.#auth(), ref, query);
|
|
1405
|
+
const search = new URLSearchParams();
|
|
1406
|
+
for (const [name, value] of Object.entries(query)) {
|
|
1407
|
+
if (value === undefined) continue;
|
|
1408
|
+
search.set(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
1409
|
+
}
|
|
1410
|
+
const path = `/threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/messages`
|
|
1411
|
+
+ (search.size === 0 ? '' : `?${search}`);
|
|
1412
|
+
return this.#remote(ref.owner, 'GET', path, undefined, signal, this.#ctx.options.wait_ms ?? 60_000);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1226
1415
|
/** Every owner + thread this node knows, from accepted grant notices plus its own threads. */
|
|
1227
1416
|
async #knownRefs(): Promise<ThreadRef[]> {
|
|
1228
1417
|
const granted = await this.#ctx.storage.transaction(async tx =>
|
|
@@ -1299,6 +1488,8 @@ export class OnlyneClient {
|
|
|
1299
1488
|
? (attention.plan?.context === undefined ? {} : { context: attention.plan.context })
|
|
1300
1489
|
: { context: previous.context }),
|
|
1301
1490
|
injected: previous?.injected === true,
|
|
1491
|
+
...(previous?.settlement === undefined ? {} : { settlement: previous.settlement }),
|
|
1492
|
+
...(previous?.resource_check_failures === undefined ? {} : { resource_check_failures: previous.resource_check_failures }),
|
|
1302
1493
|
};
|
|
1303
1494
|
if (previous !== undefined && canonical(previous.attention) === canonical(merged.attention)) {
|
|
1304
1495
|
return previous;
|
|
@@ -1381,7 +1572,7 @@ export class OnlyneClient {
|
|
|
1381
1572
|
if (peers === undefined) fail('unsupported_delivery', 'No neighbor directory is available on this host.');
|
|
1382
1573
|
return peers();
|
|
1383
1574
|
}
|
|
1384
|
-
case 'threads': return this.#
|
|
1575
|
+
case 'threads': return this.#threadsView(action, options);
|
|
1385
1576
|
case 'inbox':
|
|
1386
1577
|
// A thread-scoped inbox is the owner's attention list for that thread, so the
|
|
1387
1578
|
// owner that admitted the messages is asked, exactly like reconciliation does.
|
|
@@ -1404,7 +1595,7 @@ export class OnlyneClient {
|
|
|
1404
1595
|
return this.#threads.create(auth, { ...mutation(), title: requireValue(action.title, 'invalid_request') });
|
|
1405
1596
|
case 'close': return this.#threads.close(auth, requireValue(action.thread, 'invalid_request'), mutation());
|
|
1406
1597
|
case 'list': return this.#threads.list(auth);
|
|
1407
|
-
case 'describe': return this
|
|
1598
|
+
case 'describe': return this.describeThread(requireValue(action.thread, 'invalid_request'), options.signal);
|
|
1408
1599
|
case 'member_add':
|
|
1409
1600
|
return this.#threads.member(auth, requireValue(action.thread, 'invalid_request'), requireValue(action.to, 'invalid_request'), {}, mutation());
|
|
1410
1601
|
case 'member_remove':
|
|
@@ -1440,37 +1631,42 @@ export class OnlyneClient {
|
|
|
1440
1631
|
*/
|
|
1441
1632
|
async #readThread(action: Extract<Action, { kind: 'read'; scope: 'directory' | 'neighbors' | 'threads' | 'inbox' | 'messages' | 'message' | 'evidence' }>, options: MsgOptions): Promise<unknown> {
|
|
1442
1633
|
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
1634
|
const query: MessageQuery = {
|
|
1635
|
+
attention: action.attention ?? 'thread',
|
|
1453
1636
|
...(action.cursor === undefined ? {} : { cursor: action.cursor }),
|
|
1454
1637
|
...(action.page_size === undefined ? {} : { page_size: action.page_size }),
|
|
1455
1638
|
...(action.seq === undefined ? {} : { since_seq: action.seq }),
|
|
1456
1639
|
...(action.message === undefined ? {} : { related_to: action.message }),
|
|
1457
|
-
attention: action.attention ?? 'thread',
|
|
1458
1640
|
};
|
|
1459
|
-
|
|
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);
|
|
1641
|
+
return this.threadEntries(ref, query, options.signal);
|
|
1467
1642
|
}
|
|
1468
1643
|
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1644
|
+
/**
|
|
1645
|
+
* The threads this node can see in one page: the ones it owns, plus the ones it was invited
|
|
1646
|
+
* into. A joined row carries `read_only: true`; its owner's title, state and member table
|
|
1647
|
+
* come from the describe read, and an unreachable owner leaves the failure on the row
|
|
1648
|
+
* instead of dropping the thread from the list.
|
|
1649
|
+
*/
|
|
1650
|
+
async #threadsView(action: PageOptions, options: MsgOptions): Promise<Page<Thread | JoinedThread>> {
|
|
1651
|
+
const [owned, joined] = await Promise.all([
|
|
1652
|
+
this.#allPages(cursor => this.#threads.list(this.#auth(), { page_size: 100, ...(cursor === undefined ? {} : { cursor }) })),
|
|
1653
|
+
this.#allPages(cursor => this.joinedThreads({ page_size: 100, ...(cursor === undefined ? {} : { cursor }) }, options.signal)),
|
|
1654
|
+
]);
|
|
1655
|
+
const position = (row: Thread | JoinedThread): string => key(row.thread_ref.owner, row.thread_ref.id);
|
|
1656
|
+
const rows = [...owned, ...joined].sort((left, right) => {
|
|
1657
|
+
const a = position(left);
|
|
1658
|
+
const b = position(right);
|
|
1659
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1660
|
+
});
|
|
1661
|
+
return page(
|
|
1662
|
+
rows,
|
|
1663
|
+
{
|
|
1664
|
+
...(action.cursor === undefined ? {} : { cursor: action.cursor }),
|
|
1665
|
+
...(action.page_size === undefined ? {} : { page_size: action.page_size }),
|
|
1666
|
+
},
|
|
1667
|
+
{ kind: 'threads', actor: this.#ctx.options.node_id },
|
|
1668
|
+
position,
|
|
1669
|
+
);
|
|
1474
1670
|
}
|
|
1475
1671
|
|
|
1476
1672
|
/**
|
package/src/core.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { AdmissionContext, Auth, ErrorCode, Failure, Mutation, NodeId, NodeOptions, Page, PageOptions, Storage, Transaction, TrustEntry } from './types.
|
|
2
|
-
import { trustPhase } from './types.
|
|
1
|
+
import type { AdmissionContext, Auth, ErrorCode, Failure, Mutation, NodeId, NodeOptions, Page, PageOptions, Storage, Transaction, TrustEntry } from './types.ts';
|
|
2
|
+
import { trustPhase } from './types.ts';
|
|
3
3
|
|
|
4
4
|
const statuses: Record<ErrorCode, number> = {
|
|
5
5
|
invalid_request: 400, unauthenticated: 401, forbidden: 403, not_a_member: 403,
|