@kubun/plugin-p2p 0.15.0 → 0.16.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/lib/groups/did-cache-seed.d.ts +26 -0
- package/lib/groups/did-cache-seed.js +38 -0
- package/lib/groups/group-crypto.d.ts +8 -9
- package/lib/groups/group-crypto.js +51 -55
- package/lib/groups/group-handlers.d.ts +50 -14
- package/lib/groups/group-handlers.js +190 -24
- package/lib/groups/group-peer-manager.d.ts +52 -12
- package/lib/groups/group-peer-manager.js +215 -58
- package/lib/groups/group-protocols.d.ts +407 -0
- package/lib/groups/group-protocols.js +279 -11
- package/lib/groups/mls-codec.d.ts +19 -13
- package/lib/groups/mls-codec.js +28 -15
- package/lib/groups/peer-presence.d.ts +6 -0
- package/lib/groups/peer-presence.js +5 -0
- package/lib/hub/http-client.js +6 -1
- package/lib/hub/hub-like.js +5 -2
- package/lib/hub/loopback-log-hub.js +4 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +6 -0
- package/lib/hub/peer-scoped-hub-view.js +11 -1
- package/lib/hub/wiring.d.ts +36 -7
- package/lib/hub/wiring.js +18 -0
- package/lib/index.js +95 -20
- package/lib/sync/hub-tunnel-service-listener.d.ts +9 -0
- package/lib/sync/hub-tunnel-service-listener.js +6 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -1
- package/lib/sync/hub-tunnel-sync-listener.js +6 -1
- package/lib/sync/service-tunnel-listeners.d.ts +7 -1
- package/lib/sync/service-tunnel-listeners.js +18 -0
- package/lib/sync/sync-manager.d.ts +1 -5
- package/lib/sync/sync-manager.js +0 -5
- package/lib/sync/tunnel-listeners.d.ts +7 -1
- package/lib/sync/tunnel-listeners.js +18 -0
- package/lib/types.d.ts +15 -1
- package/lib/types.js +4 -0
- package/package.json +48 -47
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ProcedureHandlers } from '@enkaku/server';
|
|
2
|
-
import type
|
|
2
|
+
import { type DIDCache, type OwnIdentity } from '@kokuin/token';
|
|
3
3
|
import type { StoreProvider } from '@kubun/db';
|
|
4
4
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
5
5
|
import type { HLC } from '@kubun/hlc';
|
|
6
6
|
import type { Logger } from '@kubun/logger';
|
|
7
|
+
import type { WorkflowDiscoverOptions, WorkflowDiscoverReply } from '@kubun/plugin-p2p-api';
|
|
7
8
|
import type { ServiceConfig } from '@kubun/plugin-service-api';
|
|
8
9
|
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
9
10
|
import { type GroupPeer, type LaneResult, type PendingCommit } from '@kumiai/rpc';
|
|
@@ -20,6 +21,7 @@ import { type SettleLostControlRequestDeps } from './commit-adoption.js';
|
|
|
20
21
|
import { type ControllerResolverFor } from './credential-wrapping-deps.js';
|
|
21
22
|
import type { P2PEventEmitter } from './events.js';
|
|
22
23
|
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
24
|
+
import { type WorkflowCommandAPI } from './group-handlers.js';
|
|
23
25
|
import { type GroupProtocols } from './group-protocols.js';
|
|
24
26
|
import { type PeerPresence } from './peer-presence.js';
|
|
25
27
|
/**
|
|
@@ -64,6 +66,22 @@ export type GroupPeerManagerParams = {
|
|
|
64
66
|
graph: GraphInternals;
|
|
65
67
|
/** Device-wide monotonic clock shared with the engine and MLS receive path. */
|
|
66
68
|
hlc: HLC;
|
|
69
|
+
/**
|
|
70
|
+
* The engine's identity gate, forwarded to the presence coordinator so a
|
|
71
|
+
* self-started announce awaits verification before it mints under `hlc`.
|
|
72
|
+
* Absent for a by-hand test manager, whose ungated clock needs no wait.
|
|
73
|
+
*/
|
|
74
|
+
ready?: () => Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the device's workflow API, if the workflow plugin is loaded. When it
|
|
77
|
+
* resolves non-null a `workflow` namespace is mounted on each hub peer so
|
|
78
|
+
* co-members can gather this device's remotely-observable instances; null
|
|
79
|
+
* leaves the peer's protocol map unchanged. Async because the engine resolves
|
|
80
|
+
* plugin APIs only after every factory has returned — a peer is created on
|
|
81
|
+
* `groupJoined`, well after that, so it is normally already resolved. Absent
|
|
82
|
+
* for a by-hand test manager, which mounts no workflow lane.
|
|
83
|
+
*/
|
|
84
|
+
getWorkflowAPI?: () => Promise<WorkflowCommandAPI | undefined>;
|
|
67
85
|
/**
|
|
68
86
|
* The engine's future-drift bound, forwarded to the apply path. Optional: a
|
|
69
87
|
* suite that builds a manager by hand falls back to the same default the
|
|
@@ -132,20 +150,17 @@ export type GroupPeerManagerParams = {
|
|
|
132
150
|
serviceServe?: ServeService;
|
|
133
151
|
/** Which services to serve on an inbound service-lane session. */
|
|
134
152
|
services?: Record<string, ServiceConfig>;
|
|
153
|
+
/**
|
|
154
|
+
* Shared, plugin-lifetime DID cache forwarded to the sync- and service-tunnel
|
|
155
|
+
* listeners, which seed it from each group's roster and hand it to every
|
|
156
|
+
* spawned session's Server (see {@link TunnelListenersParams.cache}).
|
|
157
|
+
*/
|
|
158
|
+
cache?: DIDCache;
|
|
135
159
|
/** @see TunnelListenersParams.idleTimeoutMs */
|
|
136
160
|
tunnelIdleTimeoutMs?: number;
|
|
137
161
|
};
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
*
|
|
141
|
-
* The manager owns one reconnecting `HubLike` per hub URL (created lazily on
|
|
142
|
-
* first use, shared across every group bound to that hub via the multi-subscriber
|
|
143
|
-
* adapter) and one `GroupPeer` per (group, hub) pair. A peer exists iff the group
|
|
144
|
-
* is joined AND bound to that hub. Lifecycle mirrors the old `HubRelayManager`,
|
|
145
|
-
* but per (group, hub) instead of single-hub: the wiring phase drives `addGroup`
|
|
146
|
-
* / `removeGroup` / `addBinding` / `removeBinding` from the emitter and holds the
|
|
147
|
-
* unsubscribes. The manager does not subscribe to the emitter itself.
|
|
148
|
-
*/
|
|
162
|
+
/** The directed (non-gather) `workflow/*` procedures {@link GroupPeerManager.requestWorkflow} carries. */
|
|
163
|
+
export type WorkflowRequestMethod = 'workflow/list' | 'workflow/status' | 'workflow/enqueue' | 'workflow/cancel' | 'workflow/retry';
|
|
149
164
|
export type GroupPeerManager = {
|
|
150
165
|
/** Bring up peers for each already-bound hub of each joined group. */
|
|
151
166
|
start: (groupIDs: Array<string>) => Promise<void>;
|
|
@@ -168,6 +183,13 @@ export type GroupPeerManager = {
|
|
|
168
183
|
reconcileTunnelListeners: (groupID: string) => Promise<void>;
|
|
169
184
|
/** Fan a broadcast message out across every hub-peer of a group. */
|
|
170
185
|
broadcast: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
|
|
186
|
+
/**
|
|
187
|
+
* Ask every hub-peer of the group to re-drive a subscription its hub refused — the recovery for a
|
|
188
|
+
* device whose group topics were subscribed on join, before its app-level authorization landed,
|
|
189
|
+
* and so were refused `AuthorizationDeniedError` and latched. Synchronous and idempotent (a no-op
|
|
190
|
+
* on a peer holding nothing refused); the app calls it once it knows the device is authorized.
|
|
191
|
+
*/
|
|
192
|
+
reauthorize: (groupID: string) => void;
|
|
171
193
|
/**
|
|
172
194
|
* This device's presence on the peer lane: what it advertises, and who it asks.
|
|
173
195
|
* The manager owns it because every internal trigger fires from here — a hub
|
|
@@ -175,6 +197,24 @@ export type GroupPeerManager = {
|
|
|
175
197
|
* the group's live peers.
|
|
176
198
|
*/
|
|
177
199
|
presence: PeerPresence;
|
|
200
|
+
/**
|
|
201
|
+
* Fan `workflow/discover` out across every hub-peer of the group and return one
|
|
202
|
+
* entry per DISTINCT responder, deduped by the authenticated `senderDID` the
|
|
203
|
+
* gather envelope carried (self dropped), each `{ senderDID, instances }`. The
|
|
204
|
+
* multi-hub transport behind the caller-facing control-plane API — a member
|
|
205
|
+
* reachable through two hubs answers on both and is counted once.
|
|
206
|
+
*/
|
|
207
|
+
discoverWorkflows: (groupID: string, options?: WorkflowDiscoverOptions) => Promise<Array<WorkflowDiscoverReply>>;
|
|
208
|
+
/**
|
|
209
|
+
* Directed `workflow/*` request to one co-member over the group's hub:
|
|
210
|
+
* `.to(targetDID).request(method, { param })` on a bound hub-peer, retried
|
|
211
|
+
* across the remaining bindings only when a hub THROWS (an infra fault before
|
|
212
|
+
* anything is delivered) — never on a returned business refusal, which is the
|
|
213
|
+
* answer. Backs list/status/enqueue/cancel/retry; returns the raw wire result
|
|
214
|
+
* for the typed pluginAPI method to shape. Throws when the group has no live
|
|
215
|
+
* hub-peer, or rethrows the last hub's error.
|
|
216
|
+
*/
|
|
217
|
+
requestWorkflow: (groupID: string, targetDID: string, method: WorkflowRequestMethod, param?: Record<string, unknown>) => Promise<unknown>;
|
|
178
218
|
/**
|
|
179
219
|
* The peer for the group's canonical commit hub — the single hub a group
|
|
180
220
|
* commits through. Resolves the designated commit hub; falls back to the live
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
1
2
|
import { CREDENTIAL_STORE, getCredentialStore } from '@kubun/store-credential';
|
|
2
3
|
import { getDelegationStore } from '@kubun/store-delegation';
|
|
3
4
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
@@ -19,9 +20,9 @@ import { createCommitJournal } from './commit-journal.js';
|
|
|
19
20
|
import { settleControlRequest } from './control-request.js';
|
|
20
21
|
import { createCredentialWrappingDeps } from './credential-wrapping-deps.js';
|
|
21
22
|
import { createGroupCrypto } from './group-crypto.js';
|
|
22
|
-
import { buildGroupHandlers } from './group-handlers.js';
|
|
23
|
+
import { buildGroupHandlers, composeGroupHandlers } from './group-handlers.js';
|
|
23
24
|
import { createGroupMLS } from './group-mls.js';
|
|
24
|
-
import {
|
|
25
|
+
import { composeGroupProtocols } from './group-protocols.js';
|
|
25
26
|
import { ledgerEntryDigest } from './ledger.js';
|
|
26
27
|
import { bootstrapGroupLedger } from './ledger-adopt.js';
|
|
27
28
|
import { createPeerPresence } from './peer-presence.js';
|
|
@@ -37,20 +38,26 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
|
|
|
37
38
|
switch(message.type){
|
|
38
39
|
case 'delegation:share':
|
|
39
40
|
await peer.protocol('control').dispatch('control/delegationShare', {
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
data: {
|
|
42
|
+
token: message.token,
|
|
43
|
+
hlc: message.hlc
|
|
44
|
+
}
|
|
42
45
|
});
|
|
43
46
|
return;
|
|
44
47
|
case 'delegation:revoke':
|
|
45
48
|
await peer.protocol('control').dispatch('control/delegationRevoke', {
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
data: {
|
|
50
|
+
token: message.token,
|
|
51
|
+
hlc: message.hlc
|
|
52
|
+
}
|
|
48
53
|
});
|
|
49
54
|
return;
|
|
50
55
|
case 'group:leaveRequest':
|
|
51
56
|
await peer.protocol('control').dispatch('control/groupLeaveRequest', {
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
data: {
|
|
58
|
+
memberDID: message.memberDID,
|
|
59
|
+
hlc: message.hlc
|
|
60
|
+
}
|
|
54
61
|
});
|
|
55
62
|
return;
|
|
56
63
|
case 'access-default:set':
|
|
@@ -58,26 +65,32 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
|
|
|
58
65
|
// just its author, so a payload that drops or renames one produces a frame
|
|
59
66
|
// the receiver verifies and correctly rejects.
|
|
60
67
|
await peer.protocol('control').dispatch('control/accessDefaultSet', {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
data: {
|
|
69
|
+
modelID: message.modelID,
|
|
70
|
+
permissionType: message.permissionType,
|
|
71
|
+
rule: message.rule,
|
|
72
|
+
ownerDID: message.ownerDID,
|
|
73
|
+
hlc: message.hlc,
|
|
74
|
+
auth: message.auth
|
|
75
|
+
}
|
|
67
76
|
});
|
|
68
77
|
return;
|
|
69
78
|
case 'access-default:remove':
|
|
70
79
|
await peer.protocol('control').dispatch('control/accessDefaultRemove', {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
data: {
|
|
81
|
+
modelID: message.modelID,
|
|
82
|
+
permissionTypes: message.permissionTypes,
|
|
83
|
+
ownerDID: message.ownerDID,
|
|
84
|
+
hlc: message.hlc,
|
|
85
|
+
auth: message.auth
|
|
86
|
+
}
|
|
76
87
|
});
|
|
77
88
|
return;
|
|
78
89
|
case 'mutation:apply':
|
|
79
90
|
await peer.protocol('sync').dispatch('sync/mutationApply', {
|
|
80
|
-
|
|
91
|
+
data: {
|
|
92
|
+
entries: message.entries
|
|
93
|
+
}
|
|
81
94
|
});
|
|
82
95
|
return;
|
|
83
96
|
case 'peer:announce':
|
|
@@ -85,10 +98,12 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
|
|
|
85
98
|
// lane resolved the sender to; putting it on the wire would create the
|
|
86
99
|
// self-asserted DID field the whole design exists to avoid.
|
|
87
100
|
await peer.protocol('peer').dispatch('peer/announce', {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
data: {
|
|
102
|
+
label: message.label,
|
|
103
|
+
availability: message.availability,
|
|
104
|
+
capabilities: message.capabilities,
|
|
105
|
+
hlc: message.hlc
|
|
106
|
+
}
|
|
92
107
|
});
|
|
93
108
|
return;
|
|
94
109
|
case 'catalog:create':
|
|
@@ -104,23 +119,27 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
|
|
|
104
119
|
// payload that dropped or rewrote one produces a frame the receiver
|
|
105
120
|
// verifies and correctly rejects.
|
|
106
121
|
await peer.protocol('control').dispatch('control/credentialKeyGrant', {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
data: {
|
|
123
|
+
keyID: message.keyID,
|
|
124
|
+
keyVersion: message.keyVersion,
|
|
125
|
+
suite: message.suite,
|
|
126
|
+
ownerDID: message.ownerDID,
|
|
127
|
+
keyOp: message.keyOp,
|
|
128
|
+
keyBranches: message.keyBranches,
|
|
129
|
+
wrapping: message.wrapping,
|
|
130
|
+
entries: message.entries,
|
|
131
|
+
auth: message.auth
|
|
132
|
+
}
|
|
116
133
|
});
|
|
117
134
|
return;
|
|
118
135
|
case 'credential:key-manifest':
|
|
119
136
|
// Both fields ride verbatim -- the token digests its own fields, so
|
|
120
137
|
// reshaping would break signature verification.
|
|
121
138
|
await peer.protocol('control').dispatch('control/credentialKeyManifest', {
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
data: {
|
|
140
|
+
manifest: message.manifest,
|
|
141
|
+
delegationTokens: message.delegationTokens
|
|
142
|
+
}
|
|
124
143
|
});
|
|
125
144
|
return;
|
|
126
145
|
default:
|
|
@@ -137,15 +156,14 @@ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
|
|
|
137
156
|
* True while a rotation has torn the per-epoch protocol runtimes down and not yet
|
|
138
157
|
* rebuilt them.
|
|
139
158
|
*
|
|
140
|
-
* `createGroupPeer.rebuildEpoch` clears its runtime map synchronously and
|
|
141
|
-
* it after awaiting every teardown, and `dispatch` takes no mutex
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* the message because the throw is a bare `Error`.
|
|
159
|
+
* `createGroupPeer.rebuildEpoch` clears its runtime map synchronously and
|
|
160
|
+
* repopulates it after awaiting every teardown, and `dispatch` takes no mutex. So
|
|
161
|
+
* a broadcast arriving inside that window reaches `surfaceFor` with an empty map
|
|
162
|
+
* and gets `Unknown protocol: <name>` — a transient rotation artefact, not a
|
|
163
|
+
* routing mistake. Matched on the message because the throw is a bare `Error`.
|
|
146
164
|
*
|
|
147
|
-
* Announce-on-epoch-change
|
|
148
|
-
*
|
|
165
|
+
* Announce-on-epoch-change publishes exactly at a rotation, so this window is on
|
|
166
|
+
* its normal path rather than at its edge.
|
|
149
167
|
*/ function isEpochRebuilding(error) {
|
|
150
168
|
const message = error instanceof Error ? error.message : String(error);
|
|
151
169
|
return message.startsWith('Unknown protocol: ');
|
|
@@ -580,7 +598,7 @@ export function createGroupPeerManager(params) {
|
|
|
580
598
|
// (createGroupPeer restores the anchored MLS handle + opens the receive drain
|
|
581
599
|
// in its constructor), so this must only run once the group's MLS state is
|
|
582
600
|
// seeded (i.e. after `groupJoined`).
|
|
583
|
-
const instantiatePeer = ({ groupID, hub, processParams, initialEpoch })=>createGroupPeer({
|
|
601
|
+
const instantiatePeer = ({ groupID, hub, processParams, initialEpoch, workflowAPI })=>createGroupPeer({
|
|
584
602
|
hub,
|
|
585
603
|
crypto: createGroupCrypto({
|
|
586
604
|
registry: params.registry,
|
|
@@ -626,17 +644,17 @@ export function createGroupPeerManager(params) {
|
|
|
626
644
|
} : {}
|
|
627
645
|
}),
|
|
628
646
|
localDID: params.localDID,
|
|
629
|
-
|
|
630
|
-
|
|
647
|
+
// The workflow lane is mounted only when this device runs the workflow
|
|
648
|
+
// plugin — composed into FRESH maps, never mutating the shared statics.
|
|
649
|
+
protocols: composeGroupProtocols(workflowAPI != null),
|
|
650
|
+
handlers: composeGroupHandlers(buildGroupHandlers(processParams, groupID), workflowAPI),
|
|
631
651
|
// App frames published while this device was away aged out of the hub's
|
|
632
|
-
// retention before it came back for
|
|
633
|
-
// for good — nothing here replays them.
|
|
652
|
+
// retention before it came back — gone for good, nothing here replays them.
|
|
634
653
|
//
|
|
635
|
-
// TRIGGER — a gap below the retention floor.
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
// fresh one supersedes every lost one.
|
|
654
|
+
// TRIGGER — a gap below the retention floor. Not an error: it means "someone
|
|
655
|
+
// may have said something I will never read", and the peer lane's repair is
|
|
656
|
+
// to re-announce and re-ask, not reconstruct the lost frames. An
|
|
657
|
+
// announcement is idempotent, so a fresh one supersedes every lost one.
|
|
640
658
|
onAppWindowPruned: (event)=>{
|
|
641
659
|
logger?.warn('app frames aged out below the hub retention floor', {
|
|
642
660
|
groupID: event.groupID,
|
|
@@ -661,7 +679,14 @@ export function createGroupPeerManager(params) {
|
|
|
661
679
|
if (peers.has(key)) {
|
|
662
680
|
return;
|
|
663
681
|
}
|
|
664
|
-
|
|
682
|
+
// Resolved in parallel with the store params: the engine memoizes the API,
|
|
683
|
+
// and `createPeer` runs on `groupJoined` — well after the engine's plugin
|
|
684
|
+
// gate closes — so this resolves without blocking. The re-checks below cover
|
|
685
|
+
// this suspension point exactly as they cover `buildProcessParams`.
|
|
686
|
+
const [processParams, workflowAPI] = await Promise.all([
|
|
687
|
+
buildProcessParams(),
|
|
688
|
+
params.getWorkflowAPI?.() ?? Promise.resolve(undefined)
|
|
689
|
+
]);
|
|
665
690
|
// Re-check after the await: a concurrent call may have installed this peer,
|
|
666
691
|
// or `removeGroup` may have left the group, while the store resolved. The
|
|
667
692
|
// emitter handlers (groupJoined/groupLeft/hubBound/hubUnbound) are not
|
|
@@ -680,7 +705,8 @@ export function createGroupPeerManager(params) {
|
|
|
680
705
|
groupID,
|
|
681
706
|
hub: getHubLike(hubURL),
|
|
682
707
|
processParams,
|
|
683
|
-
initialEpoch
|
|
708
|
+
initialEpoch,
|
|
709
|
+
workflowAPI
|
|
684
710
|
});
|
|
685
711
|
peers.set(key, peer);
|
|
686
712
|
let groupSet = bindings.get(groupID);
|
|
@@ -836,6 +862,9 @@ export function createGroupPeerManager(params) {
|
|
|
836
862
|
stores: params.stores,
|
|
837
863
|
localDID: params.localDID,
|
|
838
864
|
hlc: params.hlc,
|
|
865
|
+
...params.ready != null ? {
|
|
866
|
+
ready: params.ready
|
|
867
|
+
} : {},
|
|
839
868
|
// Declaring a profile is what puts this device in co-members' projections,
|
|
840
869
|
// so it is also what makes it dialable — the listeners come up here rather
|
|
841
870
|
// than waiting for the next epoch.
|
|
@@ -880,7 +909,10 @@ export function createGroupPeerManager(params) {
|
|
|
880
909
|
return [];
|
|
881
910
|
}
|
|
882
911
|
try {
|
|
883
|
-
return await throughRotation(()=>peer.protocol('peer').gather('peer/query',
|
|
912
|
+
return await throughRotation(()=>peer.protocol('peer').gather('peer/query', {
|
|
913
|
+
param,
|
|
914
|
+
...gatherOptions
|
|
915
|
+
}), 'gather peer/query', logger);
|
|
884
916
|
} catch (error) {
|
|
885
917
|
logger?.warn('peer query gather failed on a hub', {
|
|
886
918
|
groupID,
|
|
@@ -913,6 +945,9 @@ export function createGroupPeerManager(params) {
|
|
|
913
945
|
localDID: params.localDID,
|
|
914
946
|
runtime: tunnelRuntime,
|
|
915
947
|
syncHandlers: params.syncHandlers,
|
|
948
|
+
...params.cache != null ? {
|
|
949
|
+
cache: params.cache
|
|
950
|
+
} : {},
|
|
916
951
|
tunnelHub: (groupID)=>{
|
|
917
952
|
const hubURL = bindings.get(groupID)?.values().next().value;
|
|
918
953
|
return hubURL == null ? undefined : getHubLike(hubURL);
|
|
@@ -932,6 +967,9 @@ export function createGroupPeerManager(params) {
|
|
|
932
967
|
runtime: tunnelRuntime,
|
|
933
968
|
serve: params.serviceServe,
|
|
934
969
|
services: params.services,
|
|
970
|
+
...params.cache != null ? {
|
|
971
|
+
cache: params.cache
|
|
972
|
+
} : {},
|
|
935
973
|
tunnelHub: (groupID)=>{
|
|
936
974
|
const hubURL = bindings.get(groupID)?.values().next().value;
|
|
937
975
|
return hubURL == null ? undefined : getHubLike(hubURL);
|
|
@@ -1055,7 +1093,9 @@ export function createGroupPeerManager(params) {
|
|
|
1055
1093
|
if (commitPeer == null) {
|
|
1056
1094
|
return 0;
|
|
1057
1095
|
}
|
|
1058
|
-
const replies = await commitPeer.protocol('control').gather('control/policyCatchup', {
|
|
1096
|
+
const replies = await commitPeer.protocol('control').gather('control/policyCatchup', {
|
|
1097
|
+
param: {}
|
|
1098
|
+
});
|
|
1059
1099
|
// Every reply is applied, unlike the ledger's: a member restates only its
|
|
1060
1100
|
// own rules, so two replies are two owners' policies rather than two
|
|
1061
1101
|
// accounts of one thing. Nothing here chooses between them — each token is
|
|
@@ -1114,6 +1154,103 @@ export function createGroupPeerManager(params) {
|
|
|
1114
1154
|
},
|
|
1115
1155
|
addGroup,
|
|
1116
1156
|
presence,
|
|
1157
|
+
async discoverWorkflows (groupID, options) {
|
|
1158
|
+
const groupSet = bindings.get(groupID);
|
|
1159
|
+
if (groupSet == null || groupSet.size === 0) {
|
|
1160
|
+
return [];
|
|
1161
|
+
}
|
|
1162
|
+
// Bound the fan-out like `peer/query`: absent options, the gather runs its
|
|
1163
|
+
// default window; a caller passing a member-count `quorum` returns as soon
|
|
1164
|
+
// as everyone answers rather than waiting it out.
|
|
1165
|
+
const gatherOptions = {
|
|
1166
|
+
...options?.timeoutMs != null ? {
|
|
1167
|
+
timeoutMs: options.timeoutMs
|
|
1168
|
+
} : {},
|
|
1169
|
+
...options?.quorum != null ? {
|
|
1170
|
+
quorum: options.quorum
|
|
1171
|
+
} : {}
|
|
1172
|
+
};
|
|
1173
|
+
// Fan out over every hub-peer concurrently, exactly as `presence.query`
|
|
1174
|
+
// does — a dark hub burns only its own window, not the next one's.
|
|
1175
|
+
const gathered = await Promise.all(Array.from(groupSet, async (hubURL)=>{
|
|
1176
|
+
const peer = peers.get(peerKey(groupID, hubURL));
|
|
1177
|
+
if (peer == null) {
|
|
1178
|
+
return [];
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
return await throughRotation(()=>peer.protocol('workflow').gather('workflow/discover', {
|
|
1182
|
+
param: {},
|
|
1183
|
+
...gatherOptions
|
|
1184
|
+
}), 'gather workflow/discover', logger);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
logger?.warn('workflow discover gather failed on a hub', {
|
|
1187
|
+
groupID,
|
|
1188
|
+
hubURL,
|
|
1189
|
+
error
|
|
1190
|
+
});
|
|
1191
|
+
return [];
|
|
1192
|
+
}
|
|
1193
|
+
}));
|
|
1194
|
+
// Attributed by the authenticated envelope, deduped by DID, self dropped —
|
|
1195
|
+
// the same rule `peer-presence` applies to a `peer/query` reply. A member
|
|
1196
|
+
// reachable via two hubs answers on both; the first wins, the rest are
|
|
1197
|
+
// dropped rather than counted twice.
|
|
1198
|
+
const selfDID = normalizeDID(params.localDID);
|
|
1199
|
+
const seen = new Set();
|
|
1200
|
+
const replies = [];
|
|
1201
|
+
for (const reply of gathered.flat()){
|
|
1202
|
+
const senderDID = normalizeDID(reply.senderDID);
|
|
1203
|
+
if (senderDID === selfDID || seen.has(senderDID)) {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
seen.add(senderDID);
|
|
1207
|
+
// The wire result is a bare `InstanceStatus[]`; the body names no device.
|
|
1208
|
+
replies.push({
|
|
1209
|
+
senderDID,
|
|
1210
|
+
instances: reply.value ?? []
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
return replies;
|
|
1214
|
+
},
|
|
1215
|
+
async requestWorkflow (groupID, targetDID, method, param) {
|
|
1216
|
+
const groupSet = bindings.get(groupID);
|
|
1217
|
+
if (groupSet == null || groupSet.size === 0) {
|
|
1218
|
+
throw new Error(`No hub-peer to reach ${targetDID} for group ${groupID}`);
|
|
1219
|
+
}
|
|
1220
|
+
// First bound hub wins; the rest are fallthrough routes to the SAME
|
|
1221
|
+
// mailbox, tried only when a hub throws before delivering. A returned
|
|
1222
|
+
// refusal is the answer and stops here — it is never a reason to re-send,
|
|
1223
|
+
// which for a mutating command would risk a second execution.
|
|
1224
|
+
let lastError;
|
|
1225
|
+
for (const hubURL of groupSet){
|
|
1226
|
+
const peer = peers.get(peerKey(groupID, hubURL));
|
|
1227
|
+
if (peer == null) {
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
try {
|
|
1231
|
+
return await throughRotation(async ()=>{
|
|
1232
|
+
// Generic invoker: the enkaku client types `request`'s arg per
|
|
1233
|
+
// method, but this carries any of the five directed procedures, so
|
|
1234
|
+
// it is reached through a loose surface and the typed pluginAPI
|
|
1235
|
+
// method shapes the result. The `{ param }` wrapping is the enkaku
|
|
1236
|
+
// request-arg envelope, as `.to(...).request(m, { param })` elsewhere.
|
|
1237
|
+
const client = await peer.protocol('workflow').to(targetDID);
|
|
1238
|
+
return await client.request(method, {
|
|
1239
|
+
param
|
|
1240
|
+
});
|
|
1241
|
+
}, `request ${method}`, logger);
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
lastError = error;
|
|
1244
|
+
logger?.warn('workflow request failed on a hub', {
|
|
1245
|
+
groupID,
|
|
1246
|
+
hubURL,
|
|
1247
|
+
method,
|
|
1248
|
+
error
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
throw lastError ?? new Error(`No hub-peer to reach ${targetDID} for group ${groupID}`);
|
|
1253
|
+
},
|
|
1117
1254
|
async removeGroup (groupID) {
|
|
1118
1255
|
joined.delete(groupID);
|
|
1119
1256
|
await tunnelListeners?.removeGroup(groupID);
|
|
@@ -1160,6 +1297,23 @@ export function createGroupPeerManager(params) {
|
|
|
1160
1297
|
await serviceTunnelListeners?.reconcile(groupID);
|
|
1161
1298
|
},
|
|
1162
1299
|
broadcast: broadcastToPeers,
|
|
1300
|
+
reauthorize (groupID) {
|
|
1301
|
+
// Ask every hub-peer of the group to re-drive any subscription its hub refused. The one case
|
|
1302
|
+
// this exists for: a device subscribes its group topics the instant it joins (`addGroup` ->
|
|
1303
|
+
// `createPeer`), before the APP-level authorization that gates them at the hub has landed, so
|
|
1304
|
+
// the hub answers `AuthorizationDeniedError` and the peer's mux latches it permanent (a busy
|
|
1305
|
+
// retry against an answer would be worse). The app cannot un-latch it by re-joining — the
|
|
1306
|
+
// roster is invisible to this layer — so once the app knows the device is authorized (its
|
|
1307
|
+
// membership row has replicated) it calls this. `GroupPeer.reauthorize()` is synchronous and
|
|
1308
|
+
// idempotent, and a no-op on a peer holding nothing refused, so this just fans it over the
|
|
1309
|
+
// live hub-peers with no error surface of its own. The loopback peer never subscribes a hub,
|
|
1310
|
+
// so it is deliberately skipped.
|
|
1311
|
+
const groupSet = bindings.get(groupID);
|
|
1312
|
+
if (groupSet == null) return;
|
|
1313
|
+
for (const hubURL of groupSet){
|
|
1314
|
+
peers.get(peerKey(groupID, hubURL))?.reauthorize();
|
|
1315
|
+
}
|
|
1316
|
+
},
|
|
1163
1317
|
async selectCommitPeer (groupID) {
|
|
1164
1318
|
const groupSet = bindings.get(groupID);
|
|
1165
1319
|
if (groupSet == null || groupSet.size === 0) {
|
|
@@ -1306,7 +1460,10 @@ export function createGroupPeerManager(params) {
|
|
|
1306
1460
|
return [];
|
|
1307
1461
|
}
|
|
1308
1462
|
try {
|
|
1309
|
-
return await peer.protocol('control').gather('control/ledgerCatchup', {
|
|
1463
|
+
return await peer.protocol('control').gather('control/ledgerCatchup', {
|
|
1464
|
+
param: {},
|
|
1465
|
+
...gatherOptions
|
|
1466
|
+
});
|
|
1310
1467
|
} catch (error) {
|
|
1311
1468
|
logger?.warn('ledger catch-up gather failed on a hub', {
|
|
1312
1469
|
groupID,
|