@securecoms/agent-bridge 0.3.2 → 0.5.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 +21 -1
- package/dist/bin.js +1073 -90
- package/dist/bridge-agent.d.ts +68 -7
- package/dist/config.d.ts +7 -0
- package/dist/http-server.d.ts +42 -0
- package/dist/index.js +837 -63
- package/dist/memory/hermes-provider.d.ts +21 -0
- package/dist/memory/hermes-session-map.d.ts +8 -0
- package/dist/memory/hermes-store.d.ts +32 -0
- package/dist/memory/hermes-test-fixture.d.ts +5 -0
- package/dist/memory/openclaw-provider.d.ts +24 -0
- package/dist/memory/openclaw-session-log.d.ts +15 -0
- package/dist/memory/openclaw-session-map.d.ts +7 -0
- package/dist/memory/provider.d.ts +29 -0
- package/dist/memory-taps.d.ts +13 -0
- package/dist/preflight-modern.cjs +35 -0
- package/dist/preflight-modern.d.cts +1 -0
- package/dist/preflight-upgrade.d.cts +30 -0
- package/dist/preflight.cjs +277 -0
- package/dist/preflight.d.cts +14 -0
- package/dist/service/config.d.ts +1 -0
- package/dist/service/install-prep.d.ts +68 -0
- package/package.json +3 -5
- package/dist/attachments.js +0 -49
- package/dist/auth.js +0 -56
- package/dist/bin.js.pre-c1-f803007 +0 -5795
- package/dist/bin.js.pre-c3-096a9a3 +0 -5832
- package/dist/bin.js.pre-ffrebuild-b162d4a +0 -5639
- package/dist/bin.js.pre-kpheal-20260708-150924 +0 -5531
- package/dist/bin.js.pre-kppersist-20260708-152341 +0 -5543
- package/dist/bin.js.pre-p0-20260708-102415 +0 -5196
- package/dist/bin.js.pre-p1b-02f47e1 +0 -5639
- package/dist/bin.js.pre-warmfix-20260708-153705 +0 -5613
- package/dist/bin.js.pretrace-113205 +0 -5395
- package/dist/bridge-agent.js +0 -831
- package/dist/config.js +0 -74
- package/dist/coordination.js +0 -63
- package/dist/errors.js +0 -25
- package/dist/http-server.js +0 -220
- package/dist/index.js.pre-p0-20260708-102415 +0 -4879
- package/dist/latency.js +0 -87
- package/dist/rate-limit.js +0 -56
- package/dist/service/config.js +0 -26
- package/dist/service/install.js +0 -60
- package/dist/service/launchd.js +0 -30
- package/dist/service/systemd.js +0 -25
- package/dist/ws-server.js +0 -254
package/dist/bridge-agent.js
DELETED
|
@@ -1,831 +0,0 @@
|
|
|
1
|
-
import { RestTransport, RestMlsBackend, SoftwareKeyCustody, MlsGroupKeyProvider, buildOutboundEnvelope, sendWithSelfHeal, KeyedInboxCipher, deriveMlsStoreKey, } from "@agentvault/connect-sdk-ts";
|
|
2
|
-
import { SqliteMlsStateStore } from "@agentvault/connect-mls-store";
|
|
3
|
-
import { decryptFile, encryptFile, computeFileDigest, base64ToBytes, bytesToBase64 } from "@agentvault/crypto";
|
|
4
|
-
import { promises as fsp } from "node:fs";
|
|
5
|
-
import { join, dirname } from "node:path";
|
|
6
|
-
import { LatencyRecorder } from "./latency.js";
|
|
7
|
-
import { shouldProcessRoomMessage } from "./coordination.js";
|
|
8
|
-
import { TokenBucketFloor } from "./rate-limit.js";
|
|
9
|
-
import { materializeAttachment, prepareAttachment } from "./attachments.js";
|
|
10
|
-
/** Default inbound-buffer cap (messages) when deps.maxBuffer is unset. */
|
|
11
|
-
export const DEFAULT_MAX_BUFFER = 1000;
|
|
12
|
-
/**
|
|
13
|
-
* TTL for the Phase 2 channelMeta cache. `is_dm` is immutable for a channel_id,
|
|
14
|
-
* so this exists only so a cached `name` (the room display name used by the
|
|
15
|
-
* @mention filter) re-resolves after a channel rename. Lazy expiry on read.
|
|
16
|
-
*/
|
|
17
|
-
export const CHANNEL_META_TTL_MS = 5 * 60_000;
|
|
18
|
-
/**
|
|
19
|
-
* TTL for the Phase 2 channel ROSTER cache (channel_id → identity_id → name /
|
|
20
|
-
* is_agent), used to attribute decrypted messages by sender. Parallels
|
|
21
|
-
* CHANNEL_META_TTL_MS: a member's display_name can change (rename) and the
|
|
22
|
-
* member set can change, so a stale roster re-resolves on a later cycle. Lazy
|
|
23
|
-
* expiry on read. Resolution is best-effort and fail-soft — a miss leaves
|
|
24
|
-
* sender_name undefined and NEVER blocks or drops a message.
|
|
25
|
-
*/
|
|
26
|
-
export const CHANNEL_ROSTER_TTL_MS = 5 * 60_000;
|
|
27
|
-
const ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
|
|
28
|
-
export class ChannelPendingError extends Error {
|
|
29
|
-
channelId;
|
|
30
|
-
constructor(channelId) {
|
|
31
|
-
super(`channel ${channelId} is not yet admitted (awaiting Welcome)`);
|
|
32
|
-
this.channelId = channelId;
|
|
33
|
-
this.name = "ChannelPendingError";
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
export class RateLimitedError extends Error {
|
|
37
|
-
channelId;
|
|
38
|
-
retryAfterMs;
|
|
39
|
-
constructor(channelId, retryAfterMs) {
|
|
40
|
-
super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs}ms)`);
|
|
41
|
-
this.channelId = channelId;
|
|
42
|
-
this.retryAfterMs = retryAfterMs;
|
|
43
|
-
this.name = "RateLimitedError";
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
export class InvalidPathError extends Error {
|
|
47
|
-
filePath;
|
|
48
|
-
constructor(filePath, cause) {
|
|
49
|
-
super(`cannot read attachment file: ${filePath}`);
|
|
50
|
-
this.filePath = filePath;
|
|
51
|
-
this.name = "InvalidPathError";
|
|
52
|
-
if (cause !== undefined)
|
|
53
|
-
this.cause = cause;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
export const BRIDGE_VERSION = "0.0.0"; // surfaced by /healthz; bump with package.json
|
|
57
|
-
function hexToBytes(h) {
|
|
58
|
-
return new Uint8Array(Buffer.from(h, "hex"));
|
|
59
|
-
}
|
|
60
|
-
export class BridgeAgent {
|
|
61
|
-
cfg;
|
|
62
|
-
transport;
|
|
63
|
-
provider;
|
|
64
|
-
backend; // stored for peek-then-join in ingestOnce
|
|
65
|
-
custody;
|
|
66
|
-
fetchImpl;
|
|
67
|
-
joinState = new Map();
|
|
68
|
-
/**
|
|
69
|
-
* Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
|
|
70
|
-
* by the multi-agent room @mention filter at ingest. Populated/refreshed from a
|
|
71
|
-
* SINGLE listChannels() per ingest cycle on a cache miss. A channel that cannot
|
|
72
|
-
* be resolved (network error, or not present in the listing) stays UNKNOWN → the
|
|
73
|
-
* filter fails OPEN.
|
|
74
|
-
*
|
|
75
|
-
* `is_dm` is immutable for a given channel_id (no misclassification risk), so the
|
|
76
|
-
* TTL exists only to keep `name` fresh after a channel rename — an entry older
|
|
77
|
-
* than CHANNEL_META_TTL_MS is treated as a miss and re-resolved on the next cycle.
|
|
78
|
-
*/
|
|
79
|
-
channelMeta = new Map();
|
|
80
|
-
/**
|
|
81
|
-
* Phase 2 (sender attribution): per-channel roster cache
|
|
82
|
-
* (channel_id → identity_id → { name, is_agent }) resolved from the GET
|
|
83
|
-
* members endpoint. Same lazy/TTL/fail-soft discipline as channelMeta: an
|
|
84
|
-
* unresolved channel (network error, or no entry) leaves sender_name
|
|
85
|
-
* undefined and never blocks ingest. Resolved AT MOST ONCE per ingest cycle
|
|
86
|
-
* per channel on a miss.
|
|
87
|
-
*/
|
|
88
|
-
channelRoster = new Map();
|
|
89
|
-
buffer = [];
|
|
90
|
-
maxBuffer;
|
|
91
|
-
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
92
|
-
droppedMessages = 0;
|
|
93
|
-
/**
|
|
94
|
-
* Cumulative count of ROOM messages dropped by the @mention filter (item 3 /
|
|
95
|
-
* ADR-0002 ethos). Surfaced on health()/metrics() so an agent correctly filtering
|
|
96
|
-
* most room traffic is distinguishable from an idle/wedged one.
|
|
97
|
-
*/
|
|
98
|
-
filteredRoomMessages = 0;
|
|
99
|
-
/** Cumulative count of sends shed by the per-channel rate-limit floor (surfaced on health()/metrics()). */
|
|
100
|
-
rateLimitedSends = 0;
|
|
101
|
-
/** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
|
|
102
|
-
welcomeRequestsSent = 0;
|
|
103
|
-
/**
|
|
104
|
-
* Track 5: cumulative count of artifact_share messages where download/decrypt/digest
|
|
105
|
-
* failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
|
|
106
|
-
* counter is incremented. Surfaced on health()/metrics().
|
|
107
|
-
*/
|
|
108
|
-
failedAttachments = 0;
|
|
109
|
-
/** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
|
|
110
|
-
lastWelcomeRequest = new Map();
|
|
111
|
-
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
112
|
-
static REQUEST_WELCOME_COOLDOWN_MS = 120_000;
|
|
113
|
-
rateFloor;
|
|
114
|
-
waiters = [];
|
|
115
|
-
pingHandle;
|
|
116
|
-
safetyTimer;
|
|
117
|
-
listeners = new Set();
|
|
118
|
-
startedAt = 0;
|
|
119
|
-
store;
|
|
120
|
-
latency;
|
|
121
|
-
/** Track 5: injectable blob download fn (default: fetch-based). */
|
|
122
|
-
downloadBlobImpl;
|
|
123
|
-
/** Track 5: injectable blob upload fn (default: fetch-based). */
|
|
124
|
-
uploadBlobImpl;
|
|
125
|
-
/** Track 5: directory where received attachments are saved. */
|
|
126
|
-
attachmentsDirImpl;
|
|
127
|
-
constructor(cfg, deps = {}) {
|
|
128
|
-
this.cfg = cfg;
|
|
129
|
-
const seed = hexToBytes(cfg.signingPrivateKey);
|
|
130
|
-
this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
|
|
131
|
-
this.transport =
|
|
132
|
-
deps.transport ??
|
|
133
|
-
new RestTransport({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, connectIdentityId: cfg.identityId });
|
|
134
|
-
this.backend =
|
|
135
|
-
deps.backend ??
|
|
136
|
-
new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
|
|
137
|
-
this.store = new SqliteMlsStateStore({
|
|
138
|
-
dbPath: cfg.storePath,
|
|
139
|
-
identityId: cfg.identityId,
|
|
140
|
-
cipherFor: () => new KeyedInboxCipher(deriveMlsStoreKey(seed)),
|
|
141
|
-
});
|
|
142
|
-
this.provider = new MlsGroupKeyProvider({
|
|
143
|
-
store: this.store,
|
|
144
|
-
backend: this.backend,
|
|
145
|
-
deviceId: cfg.deviceId,
|
|
146
|
-
identity: new TextEncoder().encode(cfg.identityId),
|
|
147
|
-
// ADR-0002: count + log agent-side decrypt failures. Without this hook the
|
|
148
|
-
// daemon was 100% blind to a wedged channel (it "looked idle"); the
|
|
149
|
-
// provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
|
|
150
|
-
onDecryptError: (row, err) => {
|
|
151
|
-
const cid = row.connect_channel_id ?? "(unmapped)";
|
|
152
|
-
console.warn(`[bridge] decrypt error on channel ${cid} (queue ${row.queue_id}, epoch ${row.epoch}): ${err instanceof Error ? err.message : String(err)}`);
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
156
|
-
this.latency = deps.latency ?? new LatencyRecorder();
|
|
157
|
-
this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
158
|
-
this.rateFloor = new TokenBucketFloor({
|
|
159
|
-
capacity: cfg.rateLimitCapacity ?? 20,
|
|
160
|
-
refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20,
|
|
161
|
-
});
|
|
162
|
-
// Track 5: attachment receive deps
|
|
163
|
-
const self = this;
|
|
164
|
-
this.downloadBlobImpl = deps.downloadBlob ?? async function (blobId) {
|
|
165
|
-
const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
|
|
166
|
-
const res = await self.fetchImpl(url, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
|
|
167
|
-
if (!res.ok)
|
|
168
|
-
throw new Error(`download blob failed: ${res.status}`);
|
|
169
|
-
return new Uint8Array(await res.arrayBuffer());
|
|
170
|
-
};
|
|
171
|
-
this.uploadBlobImpl = deps.uploadBlob ?? async function (ciphertext, channelId) {
|
|
172
|
-
const url = `${cfg.apiBase}/api/v1/attachments?channel_id=${encodeURIComponent(channelId)}&caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
|
|
173
|
-
const res = await self.fetchImpl(url, {
|
|
174
|
-
method: "POST",
|
|
175
|
-
headers: { "content-type": "application/octet-stream", authorization: `Bearer ${cfg.apiKey}` },
|
|
176
|
-
body: ciphertext,
|
|
177
|
-
});
|
|
178
|
-
if (!res.ok)
|
|
179
|
-
throw new Error(`upload blob failed: ${res.status}`);
|
|
180
|
-
const j = (await res.json());
|
|
181
|
-
return String(j.attachment_id);
|
|
182
|
-
};
|
|
183
|
-
this.attachmentsDirImpl = deps.attachmentsDir ?? join(dirname(cfg.storePath), "attachments");
|
|
184
|
-
}
|
|
185
|
-
/**
|
|
186
|
-
* Enforce the inbound-buffer cap with a DROP-OLDEST policy.
|
|
187
|
-
*
|
|
188
|
-
* The buffer is uniformly ordered oldest→newest: ingestOnce() appends freshly
|
|
189
|
-
* decrypted inbound at the BACK (newest), and requeueFront() unshifts un-acked
|
|
190
|
-
* messages at the FRONT — those re-buffered messages were consumed earlier, so
|
|
191
|
-
* they are by definition OLDER than whatever already sits in the buffer. In
|
|
192
|
-
* every push path the newest messages therefore live at the back. To honour
|
|
193
|
-
* "the most-recent survive" we always shed from the FRONT, then record the
|
|
194
|
-
* shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
|
|
195
|
-
*/
|
|
196
|
-
capBuffer() {
|
|
197
|
-
if (this.maxBuffer <= 0)
|
|
198
|
-
return;
|
|
199
|
-
const overflow = this.buffer.length - this.maxBuffer;
|
|
200
|
-
if (overflow > 0) {
|
|
201
|
-
this.buffer.splice(0, overflow); // drop the oldest, keep the newest maxBuffer
|
|
202
|
-
this.droppedMessages += overflow;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
async start() {
|
|
206
|
-
this.startedAt = Date.now();
|
|
207
|
-
// B1 carry-forward: a restart loses the in-memory pending bundle, so always
|
|
208
|
-
// re-publish so the owner's cycle-A admission watcher (re)adds this leaf.
|
|
209
|
-
await this.provider.publishKeyPackage();
|
|
210
|
-
this.startReceivePump();
|
|
211
|
-
}
|
|
212
|
-
async reconcileJoin(channelId) {
|
|
213
|
-
if (this.joinState.get(channelId) === "JOINED")
|
|
214
|
-
return true;
|
|
215
|
-
try {
|
|
216
|
-
// Reconcile, not a plain join. joinAndReconcile = ensureSession(join)
|
|
217
|
-
// (consume the Welcome, or early-return if we already hold state) THEN
|
|
218
|
-
// drain pending commits so we converge on the group's CURRENT epoch.
|
|
219
|
-
// A plain ensureSession(join) lands at the Welcome's epoch and never
|
|
220
|
-
// advances: if the owner adds another member after we joined (a commit
|
|
221
|
-
// bumps the epoch), every later message arrives at an epoch we don't
|
|
222
|
-
// hold, fails to decrypt, and piles up unconsumed — the channel goes
|
|
223
|
-
// silent for the agent even though it is a member. Reconciling on
|
|
224
|
-
// (re)join applies that commit and unsticks it.
|
|
225
|
-
await this.provider.joinAndReconcile(channelId, this.cfg.enclaveId);
|
|
226
|
-
this.joinState.set(channelId, "JOINED");
|
|
227
|
-
return true;
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
// "no Welcome available yet" — the owner watcher hasn't added us. Stay
|
|
231
|
-
// PENDING; the pump MUST NOT die. Retried on the next cycle/use.
|
|
232
|
-
this.joinState.set(channelId, "PENDING");
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
|
|
238
|
-
* trip (incrementing the observable counter). `kind` namespaces the bucket so
|
|
239
|
-
* room and a2a traffic on the same channel are tracked separately.
|
|
240
|
-
*/
|
|
241
|
-
enforceRateFloor(kind, channelId) {
|
|
242
|
-
const key = `${kind}:${channelId}`;
|
|
243
|
-
if (!this.rateFloor.tryConsume(key)) {
|
|
244
|
-
this.rateLimitedSends++;
|
|
245
|
-
throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
async send(channelId, text, messageId) {
|
|
249
|
-
const meta = this.freshMeta(channelId);
|
|
250
|
-
if (meta && !meta.is_dm)
|
|
251
|
-
this.enforceRateFloor("room", channelId);
|
|
252
|
-
const joined = await this.reconcileJoin(channelId);
|
|
253
|
-
if (!joined)
|
|
254
|
-
throw new ChannelPendingError(channelId);
|
|
255
|
-
const id = messageId ?? crypto.randomUUID();
|
|
256
|
-
const sendStart = performance.now();
|
|
257
|
-
// Self-heal-on-send (Phase 1.1) via the SHARED SDK seam. reconcileJoin only
|
|
258
|
-
// converges on the FIRST join (it early-returns once JOINED), so a bridge
|
|
259
|
-
// that fell behind because a peer committed would seal at a stale epoch and
|
|
260
|
-
// be rejected with an epoch-stale 400. sendWithSelfHeal reconciles per send
|
|
261
|
-
// (drain pending commits) and retries once on a stale 400.
|
|
262
|
-
// send.encrypt = MLS application-message seal (XChaCha20-Poly1305 over the
|
|
263
|
-
// group's current epoch secret). send.publish = relay to the backend over
|
|
264
|
-
// the persistent transport. send.total = the full agent-visible send cost.
|
|
265
|
-
await sendWithSelfHeal(this.provider, channelId, (epoch) => this.latency.time("send.encrypt", () => buildOutboundEnvelope({
|
|
266
|
-
custody: this.custody,
|
|
267
|
-
groupKey: this.provider,
|
|
268
|
-
messageId: id,
|
|
269
|
-
channelId,
|
|
270
|
-
enclaveId: this.cfg.enclaveId,
|
|
271
|
-
senderId: this.cfg.identityId,
|
|
272
|
-
senderType: "agent",
|
|
273
|
-
type: "text",
|
|
274
|
-
bodyPlaintext: JSON.stringify({ text }),
|
|
275
|
-
bindingId: "",
|
|
276
|
-
bindingVersion: "0.0.0",
|
|
277
|
-
agentSkillId: null,
|
|
278
|
-
epoch,
|
|
279
|
-
traceparent: ZERO_TRACEPARENT,
|
|
280
|
-
references: [],
|
|
281
|
-
stage5Outcome: "allow",
|
|
282
|
-
stage5Evaluators: [],
|
|
283
|
-
})), (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope)));
|
|
284
|
-
this.latency.record("send.total", performance.now() - sendStart);
|
|
285
|
-
return { message_id: id, status: "sent" };
|
|
286
|
-
}
|
|
287
|
-
async sendA2A(channelId, a2a, messageId) {
|
|
288
|
-
this.enforceRateFloor("a2a", channelId);
|
|
289
|
-
const joined = await this.reconcileJoin(channelId);
|
|
290
|
-
if (!joined)
|
|
291
|
-
throw new ChannelPendingError(channelId);
|
|
292
|
-
const id = messageId ?? crypto.randomUUID();
|
|
293
|
-
const sendStart = performance.now();
|
|
294
|
-
// Self-heal-on-send (Phase 1.1) via the SHARED SDK seam (mirrors send()).
|
|
295
|
-
await sendWithSelfHeal(this.provider, channelId, (epoch) => this.latency.time("send.encrypt", () => buildOutboundEnvelope({
|
|
296
|
-
custody: this.custody,
|
|
297
|
-
groupKey: this.provider,
|
|
298
|
-
messageId: id,
|
|
299
|
-
channelId,
|
|
300
|
-
enclaveId: this.cfg.enclaveId,
|
|
301
|
-
senderId: this.cfg.identityId,
|
|
302
|
-
senderType: "agent",
|
|
303
|
-
type: "a2a",
|
|
304
|
-
bodyPlaintext: JSON.stringify({ type: "a2a", a2a_version: a2a.a2a_version, kind: a2a.kind, object: a2a.object }),
|
|
305
|
-
bindingId: "",
|
|
306
|
-
bindingVersion: "0.0.0",
|
|
307
|
-
agentSkillId: null,
|
|
308
|
-
epoch,
|
|
309
|
-
traceparent: ZERO_TRACEPARENT,
|
|
310
|
-
references: [],
|
|
311
|
-
stage5Outcome: "allow",
|
|
312
|
-
stage5Evaluators: [],
|
|
313
|
-
})), (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope)));
|
|
314
|
-
this.latency.record("send.total", performance.now() - sendStart);
|
|
315
|
-
return { message_id: id, status: "sent" };
|
|
316
|
-
}
|
|
317
|
-
sendDeps() {
|
|
318
|
-
return {
|
|
319
|
-
uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
|
|
320
|
-
encryptFile,
|
|
321
|
-
bytesToBase64,
|
|
322
|
-
readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b)),
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
async sendAttachment(channelId, filePath, caption) {
|
|
326
|
-
this.enforceRateFloor("attachment", channelId);
|
|
327
|
-
const joined = await this.reconcileJoin(channelId);
|
|
328
|
-
if (!joined)
|
|
329
|
-
throw new ChannelPendingError(channelId);
|
|
330
|
-
// prepareAttachment reads, encrypts, and uploads the file — wrap readFile failures
|
|
331
|
-
// as InvalidPathError so the HTTP layer can return a clear 400 instead of 500.
|
|
332
|
-
let body;
|
|
333
|
-
try {
|
|
334
|
-
body = await prepareAttachment(filePath, caption, channelId, this.sendDeps());
|
|
335
|
-
}
|
|
336
|
-
catch (e) {
|
|
337
|
-
if (e.code === "ENOENT" || e.code === "EACCES" || e.code === "ENOTDIR" || e.code === "EISDIR") {
|
|
338
|
-
throw new InvalidPathError(filePath, e);
|
|
339
|
-
}
|
|
340
|
-
throw e;
|
|
341
|
-
}
|
|
342
|
-
const id = crypto.randomUUID();
|
|
343
|
-
const sendStart = performance.now();
|
|
344
|
-
await sendWithSelfHeal(this.provider, channelId, (epoch) => this.latency.time("send.encrypt", () => buildOutboundEnvelope({
|
|
345
|
-
custody: this.custody,
|
|
346
|
-
groupKey: this.provider,
|
|
347
|
-
messageId: id,
|
|
348
|
-
channelId,
|
|
349
|
-
enclaveId: this.cfg.enclaveId,
|
|
350
|
-
senderId: this.cfg.identityId,
|
|
351
|
-
senderType: "agent",
|
|
352
|
-
type: "artifact_share",
|
|
353
|
-
bodyPlaintext: JSON.stringify(body),
|
|
354
|
-
bindingId: "",
|
|
355
|
-
bindingVersion: "0.0.0",
|
|
356
|
-
agentSkillId: null,
|
|
357
|
-
epoch,
|
|
358
|
-
traceparent: ZERO_TRACEPARENT,
|
|
359
|
-
references: [],
|
|
360
|
-
stage5Outcome: "allow",
|
|
361
|
-
stage5Evaluators: [],
|
|
362
|
-
})), (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope)));
|
|
363
|
-
this.latency.record("send.total", performance.now() - sendStart);
|
|
364
|
-
return { message_id: id, status: "sent" };
|
|
365
|
-
}
|
|
366
|
-
async listChannels() {
|
|
367
|
-
const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
368
|
-
const res = await this.fetchImpl(url, {
|
|
369
|
-
headers: { authorization: `Bearer ${this.cfg.apiKey}` },
|
|
370
|
-
});
|
|
371
|
-
if (!res.ok)
|
|
372
|
-
throw new Error(`list_channels failed: ${res.status}`);
|
|
373
|
-
const rows = (await res.json());
|
|
374
|
-
return rows.map((r) => ({
|
|
375
|
-
channel_id: r.channel_id,
|
|
376
|
-
name: r.name ?? null,
|
|
377
|
-
is_dm: r.is_dm,
|
|
378
|
-
joined: true,
|
|
379
|
-
}));
|
|
380
|
-
}
|
|
381
|
-
/** A live channelMeta entry, or undefined if missing OR expired past the TTL. */
|
|
382
|
-
freshMeta(channelId) {
|
|
383
|
-
const hit = this.channelMeta.get(channelId);
|
|
384
|
-
if (!hit)
|
|
385
|
-
return undefined;
|
|
386
|
-
// Lazy TTL expiry: a stale entry is treated as a miss so `name` re-resolves
|
|
387
|
-
// on the next cycle's single refresh. (is_dm is immutable; this is for name.)
|
|
388
|
-
if (Date.now() - hit.at >= CHANNEL_META_TTL_MS)
|
|
389
|
-
return undefined;
|
|
390
|
-
return { is_dm: hit.is_dm, name: hit.name };
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
393
|
-
* One-shot, whole-listing refresh of channelMeta. Called AT MOST ONCE per
|
|
394
|
-
* ingest cycle (the caller guards with a cycle-local "refreshed" flag), so a
|
|
395
|
-
* burst of K unknown channels fires a single listChannels() instead of K. Any
|
|
396
|
-
* listChannels failure is swallowed (UNKNOWN → fail open) so ingest never
|
|
397
|
-
* blocks on the network.
|
|
398
|
-
*/
|
|
399
|
-
async refreshChannelMeta() {
|
|
400
|
-
try {
|
|
401
|
-
const now = Date.now();
|
|
402
|
-
const channels = await this.listChannels();
|
|
403
|
-
for (const c of channels) {
|
|
404
|
-
this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at: now });
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
catch {
|
|
408
|
-
/* network error → leave cache as-is → unresolved channels fail open */
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
/** A live roster for a channel, or undefined if missing OR expired past the TTL. */
|
|
412
|
-
freshRoster(channelId) {
|
|
413
|
-
const hit = this.channelRoster.get(channelId);
|
|
414
|
-
if (!hit)
|
|
415
|
-
return undefined;
|
|
416
|
-
if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS)
|
|
417
|
-
return undefined;
|
|
418
|
-
return hit.members;
|
|
419
|
-
}
|
|
420
|
-
/**
|
|
421
|
-
* Resolve one channel's roster (members + display names + is-agent) from the
|
|
422
|
-
* GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
|
|
423
|
-
* cycle per channel on a cache miss. Fail-soft: any error leaves the cache
|
|
424
|
-
* as-is so the affected senders stay unresolved (sender_name undefined) —
|
|
425
|
-
* attribution must NEVER block or drop a message.
|
|
426
|
-
*/
|
|
427
|
-
async refreshChannelRoster(channelId) {
|
|
428
|
-
try {
|
|
429
|
-
const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(channelId)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
|
|
430
|
-
const res = await this.fetchImpl(url, {
|
|
431
|
-
headers: { authorization: `Bearer ${this.cfg.apiKey}` },
|
|
432
|
-
});
|
|
433
|
-
if (!res.ok)
|
|
434
|
-
return; // fail-soft: leave unresolved
|
|
435
|
-
const rows = (await res.json());
|
|
436
|
-
const members = new Map();
|
|
437
|
-
for (const r of rows) {
|
|
438
|
-
members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
|
|
439
|
-
}
|
|
440
|
-
this.channelRoster.set(channelId, { members, at: Date.now() });
|
|
441
|
-
}
|
|
442
|
-
catch {
|
|
443
|
-
/* network/parse error → leave cache as-is → senders fail soft (unresolved) */
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
async reportStatus(channelId, state, detail) {
|
|
447
|
-
const joined = await this.reconcileJoin(channelId);
|
|
448
|
-
if (!joined)
|
|
449
|
-
throw new ChannelPendingError(channelId);
|
|
450
|
-
// Self-heal-on-send (Phase 1.1) via the SHARED SDK seam (mirrors send()).
|
|
451
|
-
await sendWithSelfHeal(this.provider, channelId, (epoch) => buildOutboundEnvelope({
|
|
452
|
-
custody: this.custody,
|
|
453
|
-
groupKey: this.provider,
|
|
454
|
-
messageId: crypto.randomUUID(),
|
|
455
|
-
channelId,
|
|
456
|
-
enclaveId: this.cfg.enclaveId,
|
|
457
|
-
senderId: this.cfg.identityId,
|
|
458
|
-
senderType: "agent",
|
|
459
|
-
type: "system_event",
|
|
460
|
-
bodyPlaintext: JSON.stringify({
|
|
461
|
-
v: 1,
|
|
462
|
-
type: "agent_status",
|
|
463
|
-
state,
|
|
464
|
-
at: new Date().toISOString(),
|
|
465
|
-
...(detail ? { detail } : {}),
|
|
466
|
-
}),
|
|
467
|
-
bindingId: "",
|
|
468
|
-
bindingVersion: "0.0.0",
|
|
469
|
-
agentSkillId: null,
|
|
470
|
-
epoch,
|
|
471
|
-
traceparent: ZERO_TRACEPARENT,
|
|
472
|
-
references: [],
|
|
473
|
-
stage5Outcome: "allow",
|
|
474
|
-
stage5Evaluators: [],
|
|
475
|
-
}), (envelope) => this.transport.publish(envelope));
|
|
476
|
-
}
|
|
477
|
-
startReceivePump() {
|
|
478
|
-
// Device-scoped WS ping stream (channel arg ignored by RestTransport).
|
|
479
|
-
const sub = this.transport.subscribeDeliveryPings("");
|
|
480
|
-
this.pingHandle = { close: sub.close };
|
|
481
|
-
void (async () => {
|
|
482
|
-
try {
|
|
483
|
-
for await (const _ping of sub.pings) {
|
|
484
|
-
await this.ingestOnce();
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
catch {
|
|
488
|
-
/* stream closed — pump exits cleanly */
|
|
489
|
-
}
|
|
490
|
-
})();
|
|
491
|
-
// Safety floor: drain every 25s even if a ping is missed.
|
|
492
|
-
this.safetyTimer = setInterval(() => {
|
|
493
|
-
void this.ingestOnce();
|
|
494
|
-
}, 25_000);
|
|
495
|
-
}
|
|
496
|
-
async ingestOnce() {
|
|
497
|
-
// Phase 0: peek at pending delivery rows to find channel IDs. Reconcile
|
|
498
|
-
// join for each unjoined channel so that receiveOnce can load the MLS state
|
|
499
|
-
// and decrypt application messages. Without this, application decryption
|
|
500
|
-
// silently fails because the MLS group manager hasn't been initialised for
|
|
501
|
-
// the channel yet (the Welcome must be consumed via ensureSession first).
|
|
502
|
-
const cycleStart = performance.now();
|
|
503
|
-
try {
|
|
504
|
-
const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
|
|
505
|
-
const channelIds = new Set();
|
|
506
|
-
for (const row of rows) {
|
|
507
|
-
if (row.connect_channel_id)
|
|
508
|
-
channelIds.add(row.connect_channel_id);
|
|
509
|
-
}
|
|
510
|
-
for (const cid of channelIds) {
|
|
511
|
-
if (this.joinState.get(cid) !== "JOINED") {
|
|
512
|
-
await this.reconcileJoin(cid);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
// Self-heal: a channel we received delivery for but still have NO local MLS
|
|
516
|
-
// state for (after attempting a join) is diverged — ask the committer to
|
|
517
|
-
// re-Welcome us. Rate-limited per channel. (No string-matching; the
|
|
518
|
-
// persisted-state check is the signal.) Use hasPersistedChannelState (store
|
|
519
|
-
// + in-memory), NOT the in-memory-only hasChannelState: a healthy agent
|
|
520
|
-
// whose manager is merely cold this cycle (e.g. a stray-welcome-only cycle)
|
|
521
|
-
// has persisted state and must NOT trigger a spurious heal/epoch-churn.
|
|
522
|
-
for (const row of rows) {
|
|
523
|
-
const cid = row.connect_channel_id;
|
|
524
|
-
const gid = row.group_id;
|
|
525
|
-
if (!cid || !gid)
|
|
526
|
-
continue;
|
|
527
|
-
if (await this.provider.hasPersistedChannelState(cid))
|
|
528
|
-
continue;
|
|
529
|
-
const now = performance.now();
|
|
530
|
-
const last = this.lastWelcomeRequest.get(cid) ?? -Infinity;
|
|
531
|
-
if (now - last < BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)
|
|
532
|
-
continue;
|
|
533
|
-
this.lastWelcomeRequest.set(cid, now);
|
|
534
|
-
try {
|
|
535
|
-
await this.backend.requestWelcome(gid, this.cfg.deviceId);
|
|
536
|
-
this.welcomeRequestsSent++;
|
|
537
|
-
}
|
|
538
|
-
catch { /* best-effort; retried next eligible cycle */ }
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
catch {
|
|
542
|
-
/* best-effort; receiveOnce proceeds regardless */
|
|
543
|
-
}
|
|
544
|
-
let entries;
|
|
545
|
-
try {
|
|
546
|
-
// recv.decrypt = pull + MLS open of any queued application messages
|
|
547
|
-
// (symmetric AEAD over the sender's epoch secret) for this device.
|
|
548
|
-
entries = await this.latency.time("recv.decrypt", () => this.provider.receiveOnce(this.cfg.deviceId));
|
|
549
|
-
}
|
|
550
|
-
catch {
|
|
551
|
-
return; // never let a receive error kill the pump
|
|
552
|
-
}
|
|
553
|
-
// Only record a cycle when there was actually something to process — empty
|
|
554
|
-
// poll ticks (the 25s safety drain) would otherwise dominate the metric.
|
|
555
|
-
if (!entries.length)
|
|
556
|
-
return;
|
|
557
|
-
// S-1: resolve channel metadata AT MOST ONCE per cycle. If any entry's
|
|
558
|
-
// channel is missing/expired in the cache, do a SINGLE listChannels() for the
|
|
559
|
-
// whole cycle and set metaRefreshed so no second call fires this tick. Any
|
|
560
|
-
// channel still unknown after that one refresh fails OPEN (buffers) with no
|
|
561
|
-
// further calls. ≤1 listChannels() per ingest cycle.
|
|
562
|
-
let metaRefreshed = false;
|
|
563
|
-
if (entries.some((m) => this.freshMeta(m.channelId) === undefined)) {
|
|
564
|
-
await this.refreshChannelMeta();
|
|
565
|
-
metaRefreshed = true;
|
|
566
|
-
}
|
|
567
|
-
// S-1 (roster): resolve each distinct channel's roster AT MOST ONCE per
|
|
568
|
-
// cycle on a miss, so a burst across K channels fires at most K member
|
|
569
|
-
// fetches (one per channel) and a single channel with N messages fires
|
|
570
|
-
// exactly one. Fail-soft inside refreshChannelRoster (a miss leaves
|
|
571
|
-
// sender_name undefined; never blocks ingest).
|
|
572
|
-
const rosterRefreshed = new Set();
|
|
573
|
-
for (const m of entries) {
|
|
574
|
-
if (rosterRefreshed.has(m.channelId))
|
|
575
|
-
continue;
|
|
576
|
-
rosterRefreshed.add(m.channelId);
|
|
577
|
-
if (this.freshRoster(m.channelId) === undefined) {
|
|
578
|
-
await this.refreshChannelRoster(m.channelId);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
const acked = [];
|
|
582
|
-
for (const m of entries) {
|
|
583
|
-
try {
|
|
584
|
-
const parsed = JSON.parse(new TextDecoder().decode(m.plaintext));
|
|
585
|
-
// Handle non-text messages: artifact_share → materialize; others → skip+ack
|
|
586
|
-
if (parsed.type && parsed.type !== "text") {
|
|
587
|
-
if (parsed.type === "artifact_share") {
|
|
588
|
-
// Track 5: fail-soft — a download/decrypt/digest failure acks the message
|
|
589
|
-
// (no redelivery), increments the counter, and logs but never throws.
|
|
590
|
-
try {
|
|
591
|
-
const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
|
|
592
|
-
const senderType = m.envelope.sender_type;
|
|
593
|
-
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : undefined;
|
|
594
|
-
const meta = await materializeAttachment(parsed, { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId }, {
|
|
595
|
-
downloadBlob: this.downloadBlobImpl,
|
|
596
|
-
decryptFile,
|
|
597
|
-
computeFileDigest,
|
|
598
|
-
base64ToBytes,
|
|
599
|
-
mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => undefined),
|
|
600
|
-
writeFile: (p, data) => fsp.writeFile(p, data),
|
|
601
|
-
});
|
|
602
|
-
this.buffer.push({
|
|
603
|
-
channel_id: m.channelId,
|
|
604
|
-
message_id: m.messageId,
|
|
605
|
-
sender_id: m.envelope.sender_id,
|
|
606
|
-
text: typeof parsed.text === "string"
|
|
607
|
-
? parsed.text
|
|
608
|
-
: "",
|
|
609
|
-
ts: m.receivedAt,
|
|
610
|
-
sender_name: senderName,
|
|
611
|
-
sender_is_agent: senderIsAgent,
|
|
612
|
-
attachments: [meta],
|
|
613
|
-
});
|
|
614
|
-
this.capBuffer();
|
|
615
|
-
}
|
|
616
|
-
catch (e) {
|
|
617
|
-
this.failedAttachments++;
|
|
618
|
-
process.stderr.write(`[bridge] attachment failed on ${m.messageId}: ${e.message}\n`);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
// other non-text control/status messages: skip (still ack below)
|
|
622
|
-
acked.push(m.queueId);
|
|
623
|
-
continue;
|
|
624
|
-
}
|
|
625
|
-
if (typeof parsed.text === "string") {
|
|
626
|
-
// Phase 2 — multi-agent room @mention filter. Classify the channel:
|
|
627
|
-
// DM → always buffer (1:1, no filtering)
|
|
628
|
-
// Room → buffer only if the @mention filter says this agent should
|
|
629
|
-
// see it; a message @mentioning a *different* agent is
|
|
630
|
-
// skipped (but still acked so it isn't redelivered forever)
|
|
631
|
-
// UNKNOWN→ fail OPEN: buffer (never silently drop the unclassifiable)
|
|
632
|
-
// Metadata was resolved once at the top of the cycle (metaRefreshed).
|
|
633
|
-
// freshMeta is a pure cache read here — no per-message network call.
|
|
634
|
-
void metaRefreshed;
|
|
635
|
-
const meta = this.freshMeta(m.channelId);
|
|
636
|
-
// sender_type is the on-wire envelope field (see envelope-builder /
|
|
637
|
-
// fake-backend). The @mention filter passes it through; Phase 2 also
|
|
638
|
-
// surfaces it on BridgeMessage as sender_is_agent (same derivation,
|
|
639
|
-
// not recomputed differently).
|
|
640
|
-
const senderType = m.envelope.sender_type;
|
|
641
|
-
const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : undefined;
|
|
642
|
-
let shouldBuffer = true;
|
|
643
|
-
if (meta && meta.is_dm === false) {
|
|
644
|
-
shouldBuffer = shouldProcessRoomMessage(parsed.text, this.cfg.agentName ?? this.cfg.identityId, this.cfg.identityId, { senderIsAgent, roomDisplayName: meta.name ?? undefined });
|
|
645
|
-
if (!shouldBuffer)
|
|
646
|
-
this.filteredRoomMessages++; // item 3: observable filtering
|
|
647
|
-
}
|
|
648
|
-
if (shouldBuffer) {
|
|
649
|
-
// Phase 2: attribute the sender. Roster lookup is a pure cache
|
|
650
|
-
// read here (resolved once per cycle above) — never a per-message
|
|
651
|
-
// network call. Unresolved → sender_name undefined (fail-soft;
|
|
652
|
-
// consumer falls back to sender_id). NEVER drop/delay on a miss.
|
|
653
|
-
const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
|
|
654
|
-
this.buffer.push({
|
|
655
|
-
channel_id: m.channelId,
|
|
656
|
-
message_id: m.messageId,
|
|
657
|
-
sender_id: m.envelope.sender_id,
|
|
658
|
-
text: parsed.text,
|
|
659
|
-
ts: m.receivedAt,
|
|
660
|
-
sender_name: senderName,
|
|
661
|
-
sender_is_agent: senderIsAgent,
|
|
662
|
-
});
|
|
663
|
-
// Bound the buffer: a never-draining consumer must not OOM the daemon.
|
|
664
|
-
// Newest was just appended at the back → shed the oldest from the front.
|
|
665
|
-
this.capBuffer();
|
|
666
|
-
}
|
|
667
|
-
// Filtered-out room messages are still acked below (no redelivery loop).
|
|
668
|
-
}
|
|
669
|
-
acked.push(m.queueId);
|
|
670
|
-
}
|
|
671
|
-
catch {
|
|
672
|
-
// non-JSON / unparseable: ack to avoid redelivery loop
|
|
673
|
-
acked.push(m.queueId);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
if (acked.length) {
|
|
677
|
-
try {
|
|
678
|
-
await this.latency.time("recv.ack", () => this.provider.ackDelivery(this.cfg.deviceId, acked));
|
|
679
|
-
}
|
|
680
|
-
catch {
|
|
681
|
-
/* retried next cycle */
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
// recv.cycle = the full receive-and-decrypt cost for a non-empty tick
|
|
685
|
-
// (pull + decrypt + parse + ack), i.e. the recipient half of the envelope.
|
|
686
|
-
this.latency.record("recv.cycle", performance.now() - cycleStart);
|
|
687
|
-
if (this.buffer.length) {
|
|
688
|
-
for (const l of this.listeners)
|
|
689
|
-
l();
|
|
690
|
-
const w = this.waiters.splice(0);
|
|
691
|
-
for (const r of w)
|
|
692
|
-
r();
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
async drain(channelId, waitSeconds) {
|
|
696
|
-
let got = this.takeBuffered(channelId);
|
|
697
|
-
if (got.length || waitSeconds <= 0)
|
|
698
|
-
return got;
|
|
699
|
-
const cap = Math.min(waitSeconds, 50) * 1000;
|
|
700
|
-
await new Promise((resolve) => {
|
|
701
|
-
// review FATAL-3: one-shot guard — timer and ingestOnce both call wake();
|
|
702
|
-
// the first wins, the second is a no-op, preventing a late timer from
|
|
703
|
-
// splicing an unrelated waiter under a concurrent drain.
|
|
704
|
-
let fired = false;
|
|
705
|
-
let timer;
|
|
706
|
-
const wake = () => {
|
|
707
|
-
if (fired)
|
|
708
|
-
return;
|
|
709
|
-
fired = true;
|
|
710
|
-
clearTimeout(timer);
|
|
711
|
-
const i = this.waiters.indexOf(wake);
|
|
712
|
-
if (i >= 0)
|
|
713
|
-
this.waiters.splice(i, 1);
|
|
714
|
-
resolve();
|
|
715
|
-
};
|
|
716
|
-
timer = setTimeout(wake, cap);
|
|
717
|
-
this.waiters.push(wake);
|
|
718
|
-
});
|
|
719
|
-
return this.takeBuffered(channelId);
|
|
720
|
-
}
|
|
721
|
-
/** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
|
|
722
|
-
subscribe(listener) {
|
|
723
|
-
this.listeners.add(listener);
|
|
724
|
-
return () => this.listeners.delete(listener);
|
|
725
|
-
}
|
|
726
|
-
/** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
|
|
727
|
-
takeBuffered(channelId) {
|
|
728
|
-
if (channelId === undefined)
|
|
729
|
-
return this.buffer.splice(0);
|
|
730
|
-
const keep = [];
|
|
731
|
-
const out = [];
|
|
732
|
-
for (const m of this.buffer)
|
|
733
|
-
(m.channel_id === channelId ? out : keep).push(m);
|
|
734
|
-
this.buffer.length = 0;
|
|
735
|
-
this.buffer.push(...keep);
|
|
736
|
-
return out;
|
|
737
|
-
}
|
|
738
|
-
/**
|
|
739
|
-
* Put messages back at the front (SSE/disconnect write-failure recovery).
|
|
740
|
-
*
|
|
741
|
-
* The re-buffered messages are older un-acked deliveries, so they go at the
|
|
742
|
-
* FRONT (oldest). A large un-acked backlog re-buffered on reconnect is exactly
|
|
743
|
-
* the OOM case Phase 1 item 4 targets, so this path is capped too. capBuffer()
|
|
744
|
-
* sheds from the front, preserving the newest maxBuffer messages across the
|
|
745
|
-
* combined [requeued-older..., existing-newer...] buffer.
|
|
746
|
-
*/
|
|
747
|
-
requeueFront(msgs) {
|
|
748
|
-
if (msgs.length) {
|
|
749
|
-
this.buffer.unshift(...msgs);
|
|
750
|
-
this.capBuffer();
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
/**
|
|
754
|
-
* Poke subscribers + waiters to drain the buffer now. Used after a
|
|
755
|
-
* disconnect re-buffers un-acked messages so a still-connected consumer
|
|
756
|
-
* (e.g. a replacement WS) redelivers them without waiting for new inbound.
|
|
757
|
-
* Mirrors the notify tail of ingestOnce.
|
|
758
|
-
*/
|
|
759
|
-
wakeListeners() {
|
|
760
|
-
for (const l of this.listeners)
|
|
761
|
-
l();
|
|
762
|
-
const w = this.waiters.splice(0);
|
|
763
|
-
for (const r of w)
|
|
764
|
-
r();
|
|
765
|
-
}
|
|
766
|
-
health() {
|
|
767
|
-
let joined = 0;
|
|
768
|
-
for (const s of this.joinState.values())
|
|
769
|
-
if (s === "JOINED")
|
|
770
|
-
joined++;
|
|
771
|
-
return {
|
|
772
|
-
status: "ok",
|
|
773
|
-
identity_id: this.cfg.identityId,
|
|
774
|
-
joined_channels: joined,
|
|
775
|
-
buffered: this.buffer.length,
|
|
776
|
-
dropped: this.droppedMessages,
|
|
777
|
-
filtered_room: this.filteredRoomMessages,
|
|
778
|
-
rate_limited: this.rateLimitedSends,
|
|
779
|
-
permanently_skipped: this.provider.permanentlySkippedCount(),
|
|
780
|
-
failed_attachments: this.failedAttachments,
|
|
781
|
-
uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1000) : 0,
|
|
782
|
-
version: BRIDGE_VERSION,
|
|
783
|
-
};
|
|
784
|
-
}
|
|
785
|
-
/**
|
|
786
|
-
* Loopback observability snapshot served at GET /v1/metrics.
|
|
787
|
-
*
|
|
788
|
-
* `stages`: per-stage SecureComs-envelope latency percentiles (ms), measured
|
|
789
|
-
* locally and monotonically — send.encrypt / send.publish / send.total (the
|
|
790
|
-
* sender half) and recv.pull / recv.decrypt / recv.ack / recv.cycle (the
|
|
791
|
-
* recipient half). Excludes the agent's LLM time entirely.
|
|
792
|
-
*
|
|
793
|
-
* `errors` (ADR-0002): per-channel decrypt-error / stuck snapshot from the MLS
|
|
794
|
-
* provider, so a wedged channel is OBSERVABLE here instead of looking idle. A
|
|
795
|
-
* channel appears only once it has had a decrypt error; `stuck:true` means
|
|
796
|
-
* repeated failures with no intervening successful decrypt.
|
|
797
|
-
*
|
|
798
|
-
* `dropped` (Phase 1 item 4): cumulative count of inbound messages shed by the
|
|
799
|
-
* bounded-buffer cap. A daemon silently dropping messages under backpressure is
|
|
800
|
-
* exactly the blindness ADR-0002 eliminates, so the shed is surfaced here (and
|
|
801
|
-
* on health()) rather than swallowed.
|
|
802
|
-
*
|
|
803
|
-
* `filteredRoomMessages` (item 3 / ADR-0002 ethos): cumulative ROOM messages
|
|
804
|
-
* dropped by the @mention filter. A silently-filtered room message produces no
|
|
805
|
-
* other signal, so an agent correctly filtering 95% of room traffic would look
|
|
806
|
-
* identical to an idle/wedged one without this counter.
|
|
807
|
-
*
|
|
808
|
-
* `permanentlySkipped` (ADR-0002 ethos): cumulative count of inbound rows
|
|
809
|
-
* acked-and-dropped because they were provably past-epoch undecryptable
|
|
810
|
-
* (forward-secrecy secret gone), so a self-healed poison row is observable
|
|
811
|
-
* here instead of silently dropped.
|
|
812
|
-
*/
|
|
813
|
-
metrics() {
|
|
814
|
-
return {
|
|
815
|
-
stages: this.latency.snapshot(),
|
|
816
|
-
errors: this.provider.errorStats(),
|
|
817
|
-
dropped: this.droppedMessages,
|
|
818
|
-
filteredRoomMessages: this.filteredRoomMessages,
|
|
819
|
-
rateLimitedSends: this.rateLimitedSends,
|
|
820
|
-
permanentlySkipped: this.provider.permanentlySkippedCount(),
|
|
821
|
-
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
822
|
-
failedAttachments: this.failedAttachments,
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
async close() {
|
|
826
|
-
this.pingHandle?.close();
|
|
827
|
-
if (this.safetyTimer)
|
|
828
|
-
clearInterval(this.safetyTimer);
|
|
829
|
-
this.store.close(); // review SERIOUS-2: release the DatabaseSync handle/WAL
|
|
830
|
-
}
|
|
831
|
-
}
|