agent-comms 9.0.15 → 9.1.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/dist/core/bridge-mesh.d.ts.map +1 -1
- package/dist/core/bridge-mesh.js +1 -0
- package/dist/core/bridge-mesh.js.map +1 -1
- package/dist/core/coordinator-role.d.ts +15 -2
- package/dist/core/coordinator-role.d.ts.map +1 -1
- package/dist/core/coordinator-role.js +109 -11
- package/dist/core/coordinator-role.js.map +1 -1
- package/dist/core/data-frame-routing.d.ts +18 -2
- package/dist/core/data-frame-routing.d.ts.map +1 -1
- package/dist/core/data-frame-routing.js +93 -26
- package/dist/core/data-frame-routing.js.map +1 -1
- package/dist/core/ledger-gaps.d.ts +68 -0
- package/dist/core/ledger-gaps.d.ts.map +1 -0
- package/dist/core/ledger-gaps.js +170 -0
- package/dist/core/ledger-gaps.js.map +1 -0
- package/dist/core/tool.d.ts +6 -0
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +18 -1
- package/dist/core/tool.js.map +1 -1
- package/dist/core/wire-mesh-transport.d.ts +3 -2
- package/dist/core/wire-mesh-transport.d.ts.map +1 -1
- package/dist/core/wire-mesh-transport.js +1 -1
- package/dist/core/wire-mesh-transport.js.map +1 -1
- package/package.json +2 -2
- package/src/core/bridge-mesh.ts +1 -0
- package/src/core/coordinator-role.ts +126 -7
- package/src/core/data-frame-routing.ts +113 -26
- package/src/core/ledger-gaps.ts +234 -0
- package/src/core/tool.ts +30 -0
- package/src/core/wire-mesh-transport.ts +2 -2
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
handleDataHave,
|
|
8
8
|
handleDataRequest,
|
|
9
9
|
} from "wire-mesh-core/domain/data-sync";
|
|
10
|
-
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
10
|
+
import { bytesToHex, deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
11
11
|
import { isDataFrame } from "wire-mesh-core/domain/hub-mailbox";
|
|
12
12
|
import type { DataDomainFrame } from "wire-mesh-core/domain/mesh-session";
|
|
13
13
|
import type {
|
|
@@ -19,6 +19,7 @@ import type { Connection } from "wire-mesh-core/ports/transport";
|
|
|
19
19
|
import type { KeyValueStorage } from "wire-mesh-core/ports/storage";
|
|
20
20
|
import type { AccountLedgerReplica } from "./account-ledger.js";
|
|
21
21
|
import { readMembershipProof } from "./gossip-directory.js";
|
|
22
|
+
import { LedgerGapTracker, type StalledLedgerLog } from "./ledger-gaps.js";
|
|
22
23
|
import type { WireMeshTransportOptions } from "./wire-mesh-transport-options.js";
|
|
23
24
|
|
|
24
25
|
/** The most entries one data-entries reply carries, for every log this side answers for, the account ledger's included: a longer log catches up over several rounds, so the bound only caps one frame's size. */
|
|
@@ -86,6 +87,7 @@ export async function routeDataFrame(
|
|
|
86
87
|
dataStorage: KeyValueStorage | undefined;
|
|
87
88
|
account: Readonly<AccountReplication> | undefined;
|
|
88
89
|
peerSessions: ReadonlyMap<string, unknown>;
|
|
90
|
+
gaps?: Pick<LedgerGapTracker, "noteHave"> | undefined;
|
|
89
91
|
onError: ((error: Error) => void) | undefined;
|
|
90
92
|
}>,
|
|
91
93
|
connection: Readonly<Connection>,
|
|
@@ -105,6 +107,9 @@ export async function routeDataFrame(
|
|
|
105
107
|
ledger.ownsLog(frame.peer)
|
|
106
108
|
) {
|
|
107
109
|
if (!(await deps.account.isAccountPeer(deviceIdHex))) return;
|
|
110
|
+
if (frame.type === "data-have") {
|
|
111
|
+
deps.gaps?.noteHave(deviceIdHex, frame.peer, frame["head-seq"]);
|
|
112
|
+
}
|
|
108
113
|
const reply = await ledger.handleDataFrame(frame);
|
|
109
114
|
if (reply !== null) await connection.send(reply);
|
|
110
115
|
return;
|
|
@@ -133,6 +138,7 @@ export async function routeRelayedDataFrame(
|
|
|
133
138
|
deps: Readonly<{
|
|
134
139
|
account: Readonly<AccountReplication> | undefined;
|
|
135
140
|
hub: Readonly<HubDataChannel> | undefined;
|
|
141
|
+
gaps?: Pick<LedgerGapTracker, "noteHave"> | undefined;
|
|
136
142
|
onError: ((error: Error) => void) | undefined;
|
|
137
143
|
}>,
|
|
138
144
|
fromDeviceHex: string,
|
|
@@ -144,6 +150,9 @@ export async function routeRelayedDataFrame(
|
|
|
144
150
|
const ledger = account.ledger();
|
|
145
151
|
if (ledger?.ownsLog(frame.peer) !== true) return;
|
|
146
152
|
if (!(await account.isAccountPeer(fromDeviceHex))) return;
|
|
153
|
+
if (frame.type === "data-have") {
|
|
154
|
+
deps.gaps?.noteHave(fromDeviceHex, frame.peer, frame["head-seq"]);
|
|
155
|
+
}
|
|
147
156
|
const reply = await ledger.handleDataFrame(frame);
|
|
148
157
|
if (reply !== null) await hub.send(fromDeviceHex, reply);
|
|
149
158
|
} catch (error: unknown) {
|
|
@@ -151,7 +160,15 @@ export async function routeRelayedDataFrame(
|
|
|
151
160
|
}
|
|
152
161
|
}
|
|
153
162
|
|
|
154
|
-
/**
|
|
163
|
+
/** What one round of offering the account ledger found out. */
|
|
164
|
+
export interface LedgerOfferRound {
|
|
165
|
+
/** Whether this side reached any device of the account: an offer went out to at least one, or there was nothing to offer and a device was there to offer it to. False means this machine is offline as far as the account goes, which says nothing about whether any peer withholds anything; an offer to one device that failed does not make it false while another got through. */
|
|
166
|
+
reached: boolean;
|
|
167
|
+
/** This side's head sequence of each log it holds, by log id hex, read once for the round. */
|
|
168
|
+
localHeads: ReadonlyMap<string, number>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Offers every account writer log this machine holds to every device that holds the same account and that this side can reach (agent-comms#344): over a live, trusted data connection, or, for a device it meets only through the relay hub, over a relay pairing. Such a device that is behind asks for what it is missing, which is the data-domain fan-out that carries each machine's grants and revocations to every other. A device reachable both ways is offered the logs once, directly. Called on the gossip cadence, so a machine that was away when an entry was written still catches up the next time it is reachable. A send that fails is reported through onError and the rest carry on; the next round retries it. Resolves with whether any device was reached and with this side's heads, read once. */
|
|
155
172
|
export async function announceAccountLedger(
|
|
156
173
|
deps: Readonly<{
|
|
157
174
|
connectionsByPeer: ReadonlyMap<string, Readonly<Connection>>;
|
|
@@ -160,47 +177,77 @@ export async function announceAccountLedger(
|
|
|
160
177
|
peerSessions: ReadonlyMap<string, unknown>;
|
|
161
178
|
onError: ((error: Error) => void) | undefined;
|
|
162
179
|
}>,
|
|
163
|
-
): Promise<
|
|
180
|
+
): Promise<LedgerOfferRound> {
|
|
164
181
|
const ledger = deps.account.ledger();
|
|
165
|
-
if (ledger === undefined) return;
|
|
182
|
+
if (ledger === undefined) return { reached: false, localHeads: new Map() };
|
|
166
183
|
const frames = await ledger.announcements();
|
|
167
|
-
|
|
184
|
+
const localHeads = new Map<string, number>();
|
|
185
|
+
for (const frame of frames) {
|
|
186
|
+
localHeads.set(bytesToHex(frame.peer), frame["head-seq"]);
|
|
187
|
+
}
|
|
188
|
+
let devices = 0;
|
|
189
|
+
let sent = 0;
|
|
168
190
|
const reportError = (error: unknown): void => {
|
|
169
191
|
deps.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
170
192
|
};
|
|
193
|
+
// A device whose membership cannot be read is reported and skipped: one device that makes this throw must not stop the round from being counted.
|
|
194
|
+
const isAccountPeer = async (deviceHex: string): Promise<boolean> => {
|
|
195
|
+
try {
|
|
196
|
+
return await deps.account.isAccountPeer(deviceHex);
|
|
197
|
+
} catch (error: unknown) {
|
|
198
|
+
reportError(error);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
171
202
|
const direct = new Set<string>();
|
|
172
203
|
const offers: Promise<void>[] = [];
|
|
173
204
|
for (const [deviceHex, connection] of deps.connectionsByPeer) {
|
|
174
205
|
if (!deps.peerSessions.has(deviceHex)) continue;
|
|
175
|
-
if (!(await
|
|
206
|
+
if (!(await isAccountPeer(deviceHex))) continue;
|
|
176
207
|
direct.add(deviceHex);
|
|
208
|
+
devices += 1;
|
|
177
209
|
for (const frame of frames) {
|
|
178
210
|
try {
|
|
179
211
|
await connection.send(frame);
|
|
212
|
+
sent += 1;
|
|
180
213
|
} catch (error: unknown) {
|
|
181
214
|
reportError(error);
|
|
182
215
|
}
|
|
183
216
|
}
|
|
184
217
|
}
|
|
185
218
|
const { hub } = deps;
|
|
186
|
-
if (hub
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
219
|
+
if (hub !== undefined) {
|
|
220
|
+
let hubPeers: readonly string[] = [];
|
|
221
|
+
try {
|
|
222
|
+
hubPeers = hub.peers();
|
|
223
|
+
} catch (error: unknown) {
|
|
224
|
+
reportError(error);
|
|
225
|
+
}
|
|
226
|
+
for (const deviceHex of hubPeers) {
|
|
227
|
+
if (direct.has(deviceHex)) continue;
|
|
228
|
+
if (!(await isAccountPeer(deviceHex))) continue;
|
|
229
|
+
devices += 1;
|
|
230
|
+
// Each device is offered concurrently: a pairing to a device that is not there waits out its own handshake, which must not hold up the others.
|
|
231
|
+
offers.push(
|
|
232
|
+
(async () => {
|
|
233
|
+
for (const frame of frames) {
|
|
234
|
+
try {
|
|
235
|
+
await hub.send(deviceHex, frame);
|
|
236
|
+
sent += 1;
|
|
237
|
+
} catch (error: unknown) {
|
|
238
|
+
reportError(error);
|
|
239
|
+
}
|
|
198
240
|
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
241
|
+
})(),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
202
244
|
}
|
|
203
245
|
await Promise.all(offers);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
reached: sent > 0 || (frames.length === 0 && devices > 0),
|
|
249
|
+
localHeads,
|
|
250
|
+
};
|
|
204
251
|
}
|
|
205
252
|
|
|
206
253
|
export interface DataFrameRouterDeps {
|
|
@@ -226,6 +273,9 @@ export class DataFrameRouter {
|
|
|
226
273
|
|
|
227
274
|
private announceInterval: ReturnType<typeof setInterval> | undefined;
|
|
228
275
|
|
|
276
|
+
/** What each peer says the account's writer logs reach, and the rounds this side has stayed behind it. Undefined for a transport with no account ledger. */
|
|
277
|
+
private readonly gaps: LedgerGapTracker | undefined;
|
|
278
|
+
|
|
229
279
|
/** Whether an announcement round is still running, so a round slowed by an unreachable hub peer is not stacked on by the next tick. */
|
|
230
280
|
private announcing = false;
|
|
231
281
|
|
|
@@ -233,9 +283,24 @@ export class DataFrameRouter {
|
|
|
233
283
|
this.dataStorage = deps.dataStorage;
|
|
234
284
|
const { account } = deps;
|
|
235
285
|
if (account === undefined) return;
|
|
286
|
+
const gaps = new LedgerGapTracker({
|
|
287
|
+
request: async (source, frame) => this.requestFrom(source, frame),
|
|
288
|
+
isLive: (source) =>
|
|
289
|
+
deps.peerSessions.has(source) ||
|
|
290
|
+
(deps.hub?.peers().includes(source) ?? false),
|
|
291
|
+
onError: (error) => {
|
|
292
|
+
deps.onError?.(error);
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
this.gaps = gaps;
|
|
236
296
|
this.announceInterval = setInterval(() => {
|
|
237
297
|
if (this.announcing) return;
|
|
238
298
|
this.announcing = true;
|
|
299
|
+
const report = (error: unknown): void => {
|
|
300
|
+
deps.onError?.(
|
|
301
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
302
|
+
);
|
|
303
|
+
};
|
|
239
304
|
announceAccountLedger({
|
|
240
305
|
connectionsByPeer: this.connectionsByPeer,
|
|
241
306
|
account,
|
|
@@ -243,11 +308,10 @@ export class DataFrameRouter {
|
|
|
243
308
|
peerSessions: deps.peerSessions,
|
|
244
309
|
onError: deps.onError,
|
|
245
310
|
})
|
|
246
|
-
.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
})
|
|
311
|
+
.then(async (offered) =>
|
|
312
|
+
gaps.round(offered.localHeads, { offline: !offered.reached }),
|
|
313
|
+
)
|
|
314
|
+
.catch(report)
|
|
251
315
|
.finally(() => {
|
|
252
316
|
this.announcing = false;
|
|
253
317
|
});
|
|
@@ -255,6 +319,27 @@ export class DataFrameRouter {
|
|
|
255
319
|
this.announceInterval.unref();
|
|
256
320
|
}
|
|
257
321
|
|
|
322
|
+
/** Sends a data-request to one source, over its direct session when it has one and through the relay hub otherwise. */
|
|
323
|
+
private async requestFrom(
|
|
324
|
+
source: string,
|
|
325
|
+
frame: Readonly<DataRequestFrame>,
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const connection = this.connectionsByPeer.get(source);
|
|
328
|
+
if (connection !== undefined && this.deps.peerSessions.has(source)) {
|
|
329
|
+
await connection.send(frame);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (this.deps.hub === undefined) {
|
|
333
|
+
throw new Error(`no route to ${source} to ask it for ledger entries`);
|
|
334
|
+
}
|
|
335
|
+
await this.deps.hub.send(source, frame);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The account ledger's logs this side has stayed behind for several rounds, for whoami. Empty when there is no account ledger. */
|
|
339
|
+
stalledLogs(): StalledLedgerLog[] {
|
|
340
|
+
return this.gaps?.stalled() ?? [];
|
|
341
|
+
}
|
|
342
|
+
|
|
258
343
|
/** See routeDataFrame. */
|
|
259
344
|
async route(connection: Readonly<Connection>, frame: Frame): Promise<void> {
|
|
260
345
|
await routeDataFrame(
|
|
@@ -263,6 +348,7 @@ export class DataFrameRouter {
|
|
|
263
348
|
dataStorage: this.dataStorage,
|
|
264
349
|
account: this.deps.account,
|
|
265
350
|
peerSessions: this.deps.peerSessions,
|
|
351
|
+
gaps: this.gaps,
|
|
266
352
|
onError: this.deps.onError,
|
|
267
353
|
},
|
|
268
354
|
connection,
|
|
@@ -279,6 +365,7 @@ export class DataFrameRouter {
|
|
|
279
365
|
{
|
|
280
366
|
account: this.deps.account,
|
|
281
367
|
hub: this.deps.hub,
|
|
368
|
+
gaps: this.gaps,
|
|
282
369
|
onError: this.deps.onError,
|
|
283
370
|
},
|
|
284
371
|
fromDeviceHex,
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gap detection for the account ledger's replicated writer logs (agent-comms#344), with no wire change. A `data-have` frame already carries the head sequence of the log it names, so each machine can see how far every peer says a log goes. A machine that stays behind that head for several rounds in a row, while the entries it asks for do not arrive, has a log something is withholding or dropping: a revocation could be in the missing part.
|
|
3
|
+
*
|
|
4
|
+
* What is kept: for each log, each live source's own announced head. A source is a peer this side reached over a direct session or over the relay hub. Each replication round compares those heads with this side's own and counts the rounds a log has stayed behind without advancing. At LEDGER_STALL_ROUNDS the log is reported as stalled, and again only if the gap grew and LEDGER_REPORT_REPEAT_ROUNDS rounds have passed, and the entries are asked of every source that announced a higher head, not only the one that answered first. A log that catches up, or whose announcing sources have gone, is forgotten.
|
|
5
|
+
*
|
|
6
|
+
* What is bounded: the logs tracked (MAX_TRACKED_LEDGER_LOGS, preferring logs this side already holds), how far ahead of this side's head an announced head may be (MAX_ANNOUNCED_HEAD_DISTANCE), how many reports of an ignored announcement are remembered, and how often a repeated report is made. Reports of ignored announcements are remembered apart from the logs, so forgetting a log never makes a report repeat.
|
|
7
|
+
*
|
|
8
|
+
* What it cannot see: a single path that withholds everything, including the higher head, since the head is learned from the same frames; a log beyond the tracked limit that this side does not hold; a head more than MAX_ANNOUNCED_HEAD_DISTANCE ahead, which is ignored; a log that is slow but still advancing, which is never behind for LEDGER_STALL_ROUNDS rounds without moving; and a machine that reaches no peer of the account at all, whose rounds are not counted.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { bytesToHex } from "wire-mesh-core/domain/device-id";
|
|
12
|
+
import type { DataRequestFrame } from "wire-mesh-core/generated/protocol";
|
|
13
|
+
|
|
14
|
+
/** How many consecutive replication rounds a log may stay behind an announced head, without a new entry arriving, before it is reported as stalled. */
|
|
15
|
+
export const LEDGER_STALL_ROUNDS = 3;
|
|
16
|
+
|
|
17
|
+
/** How many rounds pass before a gap that has grown since it was last reported is reported again, so a peer that raises its head every round gets one report per interval and not one per round. */
|
|
18
|
+
export const LEDGER_REPORT_REPEAT_ROUNDS = 10;
|
|
19
|
+
|
|
20
|
+
/** The most writer logs tracked at once. The account's logs are one per machine that ever wrote, so this is far above any real account. At the limit a log this side holds takes the place of one it does not, and any other log announced is not tracked, which is reported at most once per LEDGER_REPORT_REPEAT_ROUNDS rounds. */
|
|
21
|
+
export const MAX_TRACKED_LEDGER_LOGS = 256;
|
|
22
|
+
|
|
23
|
+
/** How many out-of-range reports are remembered per tracked log; the oldest is forgotten first. */
|
|
24
|
+
const REMEMBERED_REPORTS_PER_LOG = 4;
|
|
25
|
+
const MAX_REMEMBERED_REPORTS =
|
|
26
|
+
REMEMBERED_REPORTS_PER_LOG * MAX_TRACKED_LEDGER_LOGS;
|
|
27
|
+
|
|
28
|
+
/** The furthest an announced head may be ahead of this side's own and still be believed. Heads count ledger entries, which are grants and revocations, and a reply carries a bounded batch, so a head further ahead than this is not an honest gap: it is not recorded, and is reported once. */
|
|
29
|
+
export const MAX_ANNOUNCED_HEAD_DISTANCE = 1_000_000;
|
|
30
|
+
|
|
31
|
+
/** A log this side is behind, reported once it has stayed so for LEDGER_STALL_ROUNDS rounds. */
|
|
32
|
+
export interface StalledLedgerLog {
|
|
33
|
+
/** The writer log's id, hex. */
|
|
34
|
+
log: string;
|
|
35
|
+
/** This side's head sequence for it. */
|
|
36
|
+
localHead: number;
|
|
37
|
+
/** The highest head any live source announced. */
|
|
38
|
+
announcedHead: number;
|
|
39
|
+
/** The rounds it has stayed behind. */
|
|
40
|
+
rounds: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What the tracker needs from its owner. */
|
|
44
|
+
export interface LedgerGapDeps {
|
|
45
|
+
/** Sends a request for the entries after `frame["from-seq"]` of a log to one source, directly or through the hub. A rejection is reported and does not stop the others. */
|
|
46
|
+
request: (source: string, frame: Readonly<DataRequestFrame>) => Promise<void>;
|
|
47
|
+
/** Whether a source is still reachable: its announced heads are forgotten when it is not. */
|
|
48
|
+
isLive: (source: string) => boolean;
|
|
49
|
+
/** Where a stall is reported, and a failed request. */
|
|
50
|
+
onError: (error: Error) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface LogState {
|
|
54
|
+
/** The log's id as the wire carries it, for the request frames. */
|
|
55
|
+
id: Uint8Array<ArrayBuffer>;
|
|
56
|
+
/** The head each source announced. */
|
|
57
|
+
heads: Map<string, number>;
|
|
58
|
+
/** This side's head at the last round, and how many rounds in a row it has been behind without it moving. */
|
|
59
|
+
lastLocal: number | undefined;
|
|
60
|
+
rounds: number;
|
|
61
|
+
/** The gap reported last, so a report repeats only when the gap grows, and the round it was reported in, so it repeats at most once per LEDGER_REPORT_REPEAT_ROUNDS. */
|
|
62
|
+
reportedGap: number;
|
|
63
|
+
reportedAtRound: number | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What a round is told about how it went. */
|
|
67
|
+
export interface LedgerRoundOptions {
|
|
68
|
+
/** True when this machine reached no peer of the account this round: no offer went out, so nothing was asked of anyone. The round still notices a log that has caught up, but it is not counted as a round the log stayed behind. */
|
|
69
|
+
offline?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class LedgerGapTracker {
|
|
73
|
+
private readonly logs = new Map<string, LogState>();
|
|
74
|
+
private roundNumber = 0;
|
|
75
|
+
private overLimitReportedAtRound: number | undefined;
|
|
76
|
+
/** The logs this side held at the last round, which a log announced at the tracking limit is preferred for. */
|
|
77
|
+
private held: ReadonlySet<string> = new Set();
|
|
78
|
+
/** The `log:source` of every out-of-range announcement reported, oldest first, kept apart from the logs so that a log forgotten and announced again is not reported again. */
|
|
79
|
+
private readonly reportedOutOfRange = new Set<string>();
|
|
80
|
+
|
|
81
|
+
constructor(private readonly deps: Readonly<LedgerGapDeps>) {}
|
|
82
|
+
|
|
83
|
+
/** Records the head `source` announced for a log. Called for every `data-have` an account peer sends for one of the account's logs. */
|
|
84
|
+
noteHave(source: string, log: Uint8Array, head: number): void {
|
|
85
|
+
const key = bytesToHex(log);
|
|
86
|
+
let state = this.logs.get(key);
|
|
87
|
+
if (state === undefined) {
|
|
88
|
+
if (this.logs.size >= MAX_TRACKED_LEDGER_LOGS && !this.makeRoomFor(key)) {
|
|
89
|
+
this.reportOverLimit();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
state = {
|
|
93
|
+
id: Uint8Array.from(log),
|
|
94
|
+
heads: new Map(),
|
|
95
|
+
lastLocal: undefined,
|
|
96
|
+
rounds: 0,
|
|
97
|
+
reportedGap: 0,
|
|
98
|
+
reportedAtRound: undefined,
|
|
99
|
+
};
|
|
100
|
+
this.logs.set(key, state);
|
|
101
|
+
}
|
|
102
|
+
state.heads.set(source, head);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Makes room for a log at the tracking limit by dropping a tracked log this side holds nothing of, and only when this side holds the new one. */
|
|
106
|
+
private makeRoomFor(key: string): boolean {
|
|
107
|
+
if (!this.held.has(key)) return false;
|
|
108
|
+
for (const other of this.logs.keys()) {
|
|
109
|
+
if (this.held.has(other)) continue;
|
|
110
|
+
this.logs.delete(other);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private reportOverLimit(): void {
|
|
117
|
+
const last = this.overLimitReportedAtRound;
|
|
118
|
+
if (
|
|
119
|
+
last !== undefined &&
|
|
120
|
+
this.roundNumber - last < LEDGER_REPORT_REPEAT_ROUNDS
|
|
121
|
+
) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.overLimitReportedAtRound = this.roundNumber;
|
|
125
|
+
this.deps.onError(
|
|
126
|
+
new Error(
|
|
127
|
+
`the account ledger announces more than ${String(MAX_TRACKED_LEDGER_LOGS)} writer logs, which is more than any account has: the logs beyond them that this machine holds nothing of are ignored, so one of them that stays behind is not reported`,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Remembers that an out-of-range announcement was reported, and whether it had been before. */
|
|
133
|
+
private firstReportOfOutOfRange(key: string, source: string): boolean {
|
|
134
|
+
const id = `${key}:${source}`;
|
|
135
|
+
if (this.reportedOutOfRange.has(id)) return false;
|
|
136
|
+
this.reportedOutOfRange.add(id);
|
|
137
|
+
if (this.reportedOutOfRange.size > MAX_REMEMBERED_REPORTS) {
|
|
138
|
+
const [oldest] = this.reportedOutOfRange;
|
|
139
|
+
if (oldest !== undefined) this.reportedOutOfRange.delete(oldest);
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* One replication round: compares every announced head with this side's (`localHeads`, by log id hex, absent for a log this side holds nothing of), counts the rounds each log stays behind without advancing, and on reaching LEDGER_STALL_ROUNDS reports it and asks every source that announced a higher head for the entries. Resolves once the requests have been sent.
|
|
146
|
+
*/
|
|
147
|
+
async round(
|
|
148
|
+
localHeads: ReadonlyMap<string, number>,
|
|
149
|
+
options: Readonly<LedgerRoundOptions> = {},
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
this.roundNumber += 1;
|
|
152
|
+
this.held = new Set(localHeads.keys());
|
|
153
|
+
const requests: Promise<void>[] = [];
|
|
154
|
+
for (const [key, state] of this.logs) {
|
|
155
|
+
const local = localHeads.get(key) ?? 0;
|
|
156
|
+
for (const [source, head] of [...state.heads]) {
|
|
157
|
+
if (!this.deps.isLive(source)) {
|
|
158
|
+
state.heads.delete(source);
|
|
159
|
+
} else if (head - local > MAX_ANNOUNCED_HEAD_DISTANCE) {
|
|
160
|
+
state.heads.delete(source);
|
|
161
|
+
if (this.firstReportOfOutOfRange(key, source)) {
|
|
162
|
+
this.deps.onError(
|
|
163
|
+
new Error(
|
|
164
|
+
`a peer announced head ${String(head)} for account ledger log ${key}, which is out of range for a log this machine holds up to entry ${String(local)} of, and the announcement is ignored`,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
this.reportedOutOfRange.delete(`${key}:${source}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const announced = Math.max(0, ...state.heads.values());
|
|
173
|
+
if (announced <= local) {
|
|
174
|
+
this.logs.delete(key);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (options.offline === true) continue;
|
|
178
|
+
if (state.lastLocal === local) {
|
|
179
|
+
state.rounds += 1;
|
|
180
|
+
} else {
|
|
181
|
+
state.rounds = 1;
|
|
182
|
+
state.reportedGap = 0;
|
|
183
|
+
}
|
|
184
|
+
state.lastLocal = local;
|
|
185
|
+
if (state.rounds < LEDGER_STALL_ROUNDS) continue;
|
|
186
|
+
const gap = announced - local;
|
|
187
|
+
const due =
|
|
188
|
+
state.reportedAtRound === undefined ||
|
|
189
|
+
this.roundNumber - state.reportedAtRound >= LEDGER_REPORT_REPEAT_ROUNDS;
|
|
190
|
+
if (gap > state.reportedGap && due) {
|
|
191
|
+
state.reportedGap = gap;
|
|
192
|
+
state.reportedAtRound = this.roundNumber;
|
|
193
|
+
this.deps.onError(
|
|
194
|
+
new Error(
|
|
195
|
+
`account ledger log ${key} has stayed behind for ${String(state.rounds)} rounds: this machine holds up to entry ${String(local)} and a peer announces ${String(announced)}; a grant or a revocation in the missing entries is not applied here, and the entries are being asked of every peer that announced them`,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
for (const [source, head] of state.heads) {
|
|
200
|
+
if (head <= local) continue;
|
|
201
|
+
requests.push(
|
|
202
|
+
this.deps
|
|
203
|
+
.request(source, {
|
|
204
|
+
type: "data-request",
|
|
205
|
+
peer: state.id,
|
|
206
|
+
"from-seq": local,
|
|
207
|
+
})
|
|
208
|
+
.catch((error: unknown) => {
|
|
209
|
+
this.deps.onError(
|
|
210
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
211
|
+
);
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
await Promise.all(requests);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The logs that have stayed behind for LEDGER_STALL_ROUNDS rounds or more as of the last round, for whoami. */
|
|
220
|
+
stalled(): StalledLedgerLog[] {
|
|
221
|
+
const out: StalledLedgerLog[] = [];
|
|
222
|
+
for (const [key, state] of this.logs) {
|
|
223
|
+
if (state.rounds < LEDGER_STALL_ROUNDS) continue;
|
|
224
|
+
const announced = Math.max(0, ...state.heads.values());
|
|
225
|
+
out.push({
|
|
226
|
+
log: key,
|
|
227
|
+
localHead: state.lastLocal ?? 0,
|
|
228
|
+
announcedHead: announced,
|
|
229
|
+
rounds: state.rounds,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/core/tool.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* 3. Return the result string to the LLM
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { StalledLedgerLog } from "./ledger-gaps.js";
|
|
10
11
|
import { renderAgentList } from "./agent-list-format.js";
|
|
11
12
|
import type { AgentDetailsExchange } from "./agent-details.js";
|
|
12
13
|
import { plainNamer, type Namer, type Naming } from "./naming.js";
|
|
@@ -252,12 +253,17 @@ export interface CommsToolOptions {
|
|
|
252
253
|
readonly getNewerVersionIfAny?: () => string | undefined;
|
|
253
254
|
/** How many stored account ledger entries cannot be read, for whoami; omitted when the store has no account ledger. */
|
|
254
255
|
readonly getUnreadableLedgerEntries?: () => number;
|
|
256
|
+
/** The account ledger's writer logs this side has stayed behind for several rounds, for whoami; omitted when the transport has no account ledger. */
|
|
257
|
+
readonly getStalledLedgerLogs?: () => readonly StalledLedgerLog[];
|
|
255
258
|
/** Fetches a signer's armored PGP public key by fingerprint, used by gateway_redeem_connection_code when the caller supplies a fingerprint but not the key text itself. Defaults to the real keys.openpgp.org lookup (pgp-keyserver.ts); a test that would otherwise trigger real network I/O injects a fake resolver instead, the same reasoning bridge-mesh.ts's own fetchLatestVersion override already establishes for getNewerVersionIfAny above. */
|
|
256
259
|
readonly fetchPgpPublicKeyByFingerprintImpl?: (
|
|
257
260
|
fingerprint: string,
|
|
258
261
|
) => Promise<string>;
|
|
259
262
|
}
|
|
260
263
|
|
|
264
|
+
/** The most stalled-log lines whoami and update print, so a peer cannot make the result as long as it likes. */
|
|
265
|
+
const MAX_STALLED_LEDGER_LINES = 8;
|
|
266
|
+
|
|
261
267
|
export class CommsTool {
|
|
262
268
|
/** Reports this bridge's own web UI address for the web_url action. Assignable post-construction, mirroring MeshStore's own onDelivery/onCoordinatorRoleChanged hooks, because the underlying web server handle isn't known until after this bridge's own tryStartBridgeWebServer() call -- which every real bridge makes after building its CommsTool, not before. Undefined (the default) means this bridge never wires web UI reporting. */
|
|
263
269
|
getWebUrlStatus?: () => WebUrlStatus;
|
|
@@ -266,6 +272,8 @@ export class CommsTool {
|
|
|
266
272
|
private readonly naming: Naming | undefined;
|
|
267
273
|
private readonly getNewerVersionIfAny: (() => string | undefined) | undefined;
|
|
268
274
|
private readonly getUnreadableLedgerEntries: (() => number) | undefined;
|
|
275
|
+
private readonly getStalledLedgerLogs:
|
|
276
|
+
(() => readonly StalledLedgerLog[]) | undefined;
|
|
269
277
|
private readonly fetchPgpPublicKeyByFingerprintImpl: (
|
|
270
278
|
fingerprint: string,
|
|
271
279
|
) => Promise<string>;
|
|
@@ -279,12 +287,14 @@ export class CommsTool {
|
|
|
279
287
|
naming,
|
|
280
288
|
getNewerVersionIfAny,
|
|
281
289
|
getUnreadableLedgerEntries,
|
|
290
|
+
getStalledLedgerLogs,
|
|
282
291
|
fetchPgpPublicKeyByFingerprintImpl = fetchPgpPublicKeyByFingerprint,
|
|
283
292
|
} = options ?? {};
|
|
284
293
|
this.discovery = discovery;
|
|
285
294
|
this.naming = naming;
|
|
286
295
|
this.getNewerVersionIfAny = getNewerVersionIfAny;
|
|
287
296
|
this.getUnreadableLedgerEntries = getUnreadableLedgerEntries;
|
|
297
|
+
this.getStalledLedgerLogs = getStalledLedgerLogs;
|
|
288
298
|
this.fetchPgpPublicKeyByFingerprintImpl =
|
|
289
299
|
fetchPgpPublicKeyByFingerprintImpl;
|
|
290
300
|
}
|
|
@@ -469,6 +479,7 @@ export class CommsTool {
|
|
|
469
479
|
`Account ledger: ${String(unreadableLedger)} unreadable entries; a grant or revocation recorded only in them is not applied here until they are repaired or replaced from another machine's replica`,
|
|
470
480
|
);
|
|
471
481
|
}
|
|
482
|
+
lines.push(...this.stalledLedgerLines());
|
|
472
483
|
const updateAvailable = this.formatUpdateAvailableLine();
|
|
473
484
|
if (updateAvailable !== undefined) lines.push(updateAvailable);
|
|
474
485
|
return {
|
|
@@ -484,6 +495,24 @@ export class CommsTool {
|
|
|
484
495
|
: this.naming.namer(ctx.agentId);
|
|
485
496
|
}
|
|
486
497
|
|
|
498
|
+
/** The `Account ledger log <id> is behind` lines for whoami and update, one per writer log this side has stayed behind, at most MAX_STALLED_LEDGER_LINES of them and then one `Account ledger: <n> more logs are behind` line. The wording is part of the interface: the README states it. */
|
|
499
|
+
private stalledLedgerLines(): string[] {
|
|
500
|
+
const stalled = this.getStalledLedgerLogs?.() ?? [];
|
|
501
|
+
const lines = stalled
|
|
502
|
+
.slice(0, MAX_STALLED_LEDGER_LINES)
|
|
503
|
+
.map(
|
|
504
|
+
(log) =>
|
|
505
|
+
`Account ledger log ${log.log} is behind: this machine holds up to entry ${String(log.localHead)}, a peer announces ${String(log.announcedHead)}, and ${String(log.rounds)} rounds have passed without the missing entries arriving; a grant or a revocation in them is not applied here`,
|
|
506
|
+
);
|
|
507
|
+
if (stalled.length > MAX_STALLED_LEDGER_LINES) {
|
|
508
|
+
lines.push(
|
|
509
|
+
`Account ledger: ${String(stalled.length - MAX_STALLED_LEDGER_LINES)} more logs are behind`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return lines;
|
|
514
|
+
}
|
|
515
|
+
|
|
487
516
|
private async whoami(ctx: Readonly<CommsContext>): Promise<CommsResult> {
|
|
488
517
|
const agent = await this.store.getAgent(ctx.agentId);
|
|
489
518
|
if (!agent) return { content: "Not registered.", isError: true };
|
|
@@ -505,6 +534,7 @@ export class CommsTool {
|
|
|
505
534
|
`Tags: ${agent.tags.join(", ") || "(none)"}`,
|
|
506
535
|
`Rooms: ${agent.subscribedRooms.join(", ") || "(none)"}`,
|
|
507
536
|
];
|
|
537
|
+
lines.push(...this.stalledLedgerLines());
|
|
508
538
|
const updateAvailable = this.formatUpdateAvailableLine();
|
|
509
539
|
if (updateAvailable !== undefined) lines.push(updateAvailable);
|
|
510
540
|
return {
|
|
@@ -239,8 +239,8 @@ export class WireMeshTransport implements MeshTransport {
|
|
|
239
239
|
/** Reads this side's own currently-running cc-peer package version for the next gossip re-advertisement tick (agent-comms#198). Unlike getCurrentPresence/getHostedRooms/getSelfAgentAdvert (constructor-injected, since MeshStore already has a real value for each at construction time), this is a plain mutable field set post-construction -- bridge-mesh.ts assigns it right after building this transport, mirroring MeshStore's own onDelivery/onError/onCoordinatorRoleChanged convention, so this transport doesn't need its own constructor parameter for a fact only two of many bridges ever have. undefined for every bridge that never loads cc-peer at all. */
|
|
240
240
|
getCcPeerVersion: (() => string | undefined) | undefined;
|
|
241
241
|
|
|
242
|
-
/** Answers, stores and offers this side's core/data frames: the room-notice oplog over dataStorage, and the account's replicated grant ledger (agent-comms#344). See DataFrameRouter. */
|
|
243
|
-
|
|
242
|
+
/** Answers, stores and offers this side's core/data frames: the room-notice oplog over dataStorage, and the account's replicated grant ledger (agent-comms#344). See DataFrameRouter. Public for whoami: the router knows which account ledger logs this side has stayed behind for several rounds (ledger-gaps.ts). */
|
|
243
|
+
readonly dataFrames: DataFrameRouter;
|
|
244
244
|
|
|
245
245
|
/** The cross-machine trust boundary (agent-comms#156): gates outbound gossip advertisement (hasAny), inbound directory merge (bare-device trust, a principal's own device, or a verified membership proof, wired into HubSession as admitEntries), the legacy per-device frame path (isTrusted, bare-device trust only), and outbound targeted hub requests (isReachable); see GatewayTrust's own class doc. A real room-domain manage-request relayed through the hub is never gated on this at all since agent-comms#192: hub-session.ts's own dispatchHubRequest relies purely on that verb's own capability-token verification instead. Defaults to a fresh, empty (deny-all) instance when no caller wires one in, matching every existing construction site that predates this feature. */
|
|
246
246
|
private readonly gatewayTrust: GatewayTrustReader;
|