@kubun/plugin-p2p 0.15.1 → 0.16.1
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/context/group.js +8 -0
- package/lib/context/join.js +27 -0
- package/lib/context/peer.js +2 -2
- package/lib/context/sync.js +1 -1
- 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 +46 -12
- package/lib/groups/group-peer-manager.js +209 -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/hub-like.js +7 -6
- package/lib/hub/loopback-log-hub.js +4 -1
- package/lib/hub/wiring.d.ts +29 -6
- package/lib/hub/wiring.js +15 -0
- package/lib/index.js +79 -16
- package/lib/schema.js +2 -1
- package/lib/sync/sync-manager.d.ts +1 -5
- package/lib/sync/sync-manager.js +0 -5
- 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
|
|
@@ -141,17 +159,8 @@ export type GroupPeerManagerParams = {
|
|
|
141
159
|
/** @see TunnelListenersParams.idleTimeoutMs */
|
|
142
160
|
tunnelIdleTimeoutMs?: number;
|
|
143
161
|
};
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
* The manager owns one reconnecting `HubLike` per hub URL (created lazily on
|
|
148
|
-
* first use, shared across every group bound to that hub via the multi-subscriber
|
|
149
|
-
* adapter) and one `GroupPeer` per (group, hub) pair. A peer exists iff the group
|
|
150
|
-
* is joined AND bound to that hub. Lifecycle mirrors the old `HubRelayManager`,
|
|
151
|
-
* but per (group, hub) instead of single-hub: the wiring phase drives `addGroup`
|
|
152
|
-
* / `removeGroup` / `addBinding` / `removeBinding` from the emitter and holds the
|
|
153
|
-
* unsubscribes. The manager does not subscribe to the emitter itself.
|
|
154
|
-
*/
|
|
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';
|
|
155
164
|
export type GroupPeerManager = {
|
|
156
165
|
/** Bring up peers for each already-bound hub of each joined group. */
|
|
157
166
|
start: (groupIDs: Array<string>) => Promise<void>;
|
|
@@ -174,6 +183,13 @@ export type GroupPeerManager = {
|
|
|
174
183
|
reconcileTunnelListeners: (groupID: string) => Promise<void>;
|
|
175
184
|
/** Fan a broadcast message out across every hub-peer of a group. */
|
|
176
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;
|
|
177
193
|
/**
|
|
178
194
|
* This device's presence on the peer lane: what it advertises, and who it asks.
|
|
179
195
|
* The manager owns it because every internal trigger fires from here — a hub
|
|
@@ -181,6 +197,24 @@ export type GroupPeerManager = {
|
|
|
181
197
|
* the group's live peers.
|
|
182
198
|
*/
|
|
183
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>;
|
|
184
218
|
/**
|
|
185
219
|
* The peer for the group's canonical commit hub — the single hub a group
|
|
186
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,
|
|
@@ -1061,7 +1093,9 @@ export function createGroupPeerManager(params) {
|
|
|
1061
1093
|
if (commitPeer == null) {
|
|
1062
1094
|
return 0;
|
|
1063
1095
|
}
|
|
1064
|
-
const replies = await commitPeer.protocol('control').gather('control/policyCatchup', {
|
|
1096
|
+
const replies = await commitPeer.protocol('control').gather('control/policyCatchup', {
|
|
1097
|
+
param: {}
|
|
1098
|
+
});
|
|
1065
1099
|
// Every reply is applied, unlike the ledger's: a member restates only its
|
|
1066
1100
|
// own rules, so two replies are two owners' policies rather than two
|
|
1067
1101
|
// accounts of one thing. Nothing here chooses between them — each token is
|
|
@@ -1120,6 +1154,103 @@ export function createGroupPeerManager(params) {
|
|
|
1120
1154
|
},
|
|
1121
1155
|
addGroup,
|
|
1122
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
|
+
},
|
|
1123
1254
|
async removeGroup (groupID) {
|
|
1124
1255
|
joined.delete(groupID);
|
|
1125
1256
|
await tunnelListeners?.removeGroup(groupID);
|
|
@@ -1166,6 +1297,23 @@ export function createGroupPeerManager(params) {
|
|
|
1166
1297
|
await serviceTunnelListeners?.reconcile(groupID);
|
|
1167
1298
|
},
|
|
1168
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
|
+
},
|
|
1169
1317
|
async selectCommitPeer (groupID) {
|
|
1170
1318
|
const groupSet = bindings.get(groupID);
|
|
1171
1319
|
if (groupSet == null || groupSet.size === 0) {
|
|
@@ -1312,7 +1460,10 @@ export function createGroupPeerManager(params) {
|
|
|
1312
1460
|
return [];
|
|
1313
1461
|
}
|
|
1314
1462
|
try {
|
|
1315
|
-
return await peer.protocol('control').gather('control/ledgerCatchup', {
|
|
1463
|
+
return await peer.protocol('control').gather('control/ledgerCatchup', {
|
|
1464
|
+
param: {},
|
|
1465
|
+
...gatherOptions
|
|
1466
|
+
});
|
|
1316
1467
|
} catch (error) {
|
|
1317
1468
|
logger?.warn('ledger catch-up gather failed on a hub', {
|
|
1318
1469
|
groupID,
|