@securecoms/agent-bridge 0.2.0 → 0.3.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/dist/bin.d.ts +10 -0
- package/dist/bin.js +1804 -64
- package/dist/bin.js.pre-c1-f803007 +5795 -0
- package/dist/bin.js.pre-c3-096a9a3 +5832 -0
- package/dist/bin.js.pre-ffrebuild-b162d4a +5639 -0
- package/dist/bin.js.pre-kpheal-20260708-150924 +5531 -0
- package/dist/bin.js.pre-kppersist-20260708-152341 +5543 -0
- package/dist/bin.js.pre-p0-20260708-102415 +5196 -0
- package/dist/bin.js.pre-p1b-02f47e1 +5639 -0
- package/dist/bin.js.pre-warmfix-20260708-153705 +5613 -0
- package/dist/bin.js.pretrace-113205 +5395 -0
- package/dist/bridge-agent.d.ts +343 -0
- package/dist/decrypt-error-log-limiter.d.ts +52 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.js +1791 -61
- package/dist/index.js.pre-p0-20260708-102415 +4879 -0
- package/dist/ws-server.d.ts +5 -0
- package/package.json +2 -1
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
|
|
|
2
2
|
import type { BridgeConfig } from "./config.js";
|
|
3
3
|
import { LatencyRecorder } from "./latency.js";
|
|
4
4
|
import type { AttachmentMeta } from "./attachments.js";
|
|
5
|
+
import type { AgentActionNodeType, AgentActionNodeStatus, AgentActionNodeDetail } from "@agentvault/connect-core";
|
|
5
6
|
export interface BridgeMessage {
|
|
6
7
|
channel_id: string;
|
|
7
8
|
message_id: string;
|
|
@@ -60,6 +61,19 @@ export interface BridgeAgentDeps {
|
|
|
60
61
|
* recent (a real-time messaging consumer cares about the newest). Default 1000.
|
|
61
62
|
*/
|
|
62
63
|
maxBuffer?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Review I3/M3: clock seam for the agent.action.* partial-throttle token
|
|
66
|
+
* buckets AND the unsettled-run stale-TTL sweep. Defaults to Date.now.
|
|
67
|
+
* Injected so tests can simulate a full minute (or a stale-run TTL window)
|
|
68
|
+
* of elapsed time without a real wait.
|
|
69
|
+
*/
|
|
70
|
+
actionThrottleNow?: () => number;
|
|
71
|
+
/**
|
|
72
|
+
* C3: randomness seam for self-heal-sweep jitter (fleet decorrelation).
|
|
73
|
+
* Defaults to Math.random. Injected so tests can assert deterministic jittered
|
|
74
|
+
* timer delays.
|
|
75
|
+
*/
|
|
76
|
+
rng?: () => number;
|
|
63
77
|
}
|
|
64
78
|
/** Default inbound-buffer cap (messages) when deps.maxBuffer is unset. */
|
|
65
79
|
export declare const DEFAULT_MAX_BUFFER = 1000;
|
|
@@ -91,6 +105,71 @@ export declare class InvalidPathError extends Error {
|
|
|
91
105
|
readonly filePath: string;
|
|
92
106
|
constructor(filePath: string, cause?: unknown);
|
|
93
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Per-room agent pause toggle (daemon side): the backend rejected a publish
|
|
110
|
+
* with `403 {detail:{error:"agent_paused"}}`, which the SDK's `RestTransport`
|
|
111
|
+
* classifies into {@link AgentPausedError} (Task 8). This is a TERMINAL
|
|
112
|
+
* outcome — an owner/admin has deliberately muted this agent in this room, it
|
|
113
|
+
* is not a transient/network failure — so the daemon rethrows its OWN typed
|
|
114
|
+
* error (mirroring {@link ChannelPendingError} / {@link RateLimitedError}) for
|
|
115
|
+
* `send()`/`sendA2A()`/`sendAttachment()` callers to `instanceof`-check,
|
|
116
|
+
* instead of letting a heal path retry or re-Welcome into a paused room.
|
|
117
|
+
*/
|
|
118
|
+
export declare class AgentPausedSendError extends Error {
|
|
119
|
+
readonly channelId: string;
|
|
120
|
+
constructor(channelId: string);
|
|
121
|
+
}
|
|
122
|
+
/** Thrown by `agent.action.*` when a runId (or nodeId within a run) is unknown
|
|
123
|
+
* or the run has already settled — settle() is terminal, mirroring the client
|
|
124
|
+
* ingest side's "later partials for a settled run are dropped" rule. */
|
|
125
|
+
export declare class UnknownActionRunError extends Error {
|
|
126
|
+
readonly runId: string;
|
|
127
|
+
constructor(runId: string);
|
|
128
|
+
}
|
|
129
|
+
export interface AgentActionStartOpts {
|
|
130
|
+
runId: string;
|
|
131
|
+
type: AgentActionNodeType;
|
|
132
|
+
title: string;
|
|
133
|
+
parentId?: string;
|
|
134
|
+
}
|
|
135
|
+
export interface AgentActionUpdatePatch {
|
|
136
|
+
status?: AgentActionNodeStatus;
|
|
137
|
+
detail?: AgentActionNodeDetail;
|
|
138
|
+
}
|
|
139
|
+
export interface AgentActionFinishPatch {
|
|
140
|
+
status: "done" | "error";
|
|
141
|
+
detail?: AgentActionNodeDetail;
|
|
142
|
+
}
|
|
143
|
+
export interface AgentActionSettleOpts {
|
|
144
|
+
resultMessageId?: string;
|
|
145
|
+
}
|
|
146
|
+
export interface AgentActionWrapOpts {
|
|
147
|
+
title: string;
|
|
148
|
+
type: AgentActionNodeType;
|
|
149
|
+
parentId?: string;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The agent action tree emit surface. A "run" groups the nodes (steps /
|
|
153
|
+
* tool_calls / sub_tasks / results) of one agent turn under `runId`.
|
|
154
|
+
* `start`/`update`/`finish` mutate the run's local node tree and attempt a
|
|
155
|
+
* throttled `partial` send (~1/sec per run — see `flushActionPartial`);
|
|
156
|
+
* `settle` always sends exactly once, carrying the FULL node set (folding in
|
|
157
|
+
* anything the throttle held back) plus an optional `resultMessageId`.
|
|
158
|
+
*/
|
|
159
|
+
export interface AgentActionAPI {
|
|
160
|
+
/** Start a new run scoped to `channelId`. Local-only (no send). */
|
|
161
|
+
begin(channelId: string): string;
|
|
162
|
+
/** Add a node (default status "running") to the run's tree. May emit a throttled partial. */
|
|
163
|
+
start(opts: AgentActionStartOpts): Promise<string>;
|
|
164
|
+
/** Patch an existing node's status/detail. May emit a throttled partial. */
|
|
165
|
+
update(runId: string, nodeId: string, patch: AgentActionUpdatePatch): Promise<void>;
|
|
166
|
+
/** Terminal patch for a node (status "done" | "error"). May emit a throttled partial. */
|
|
167
|
+
finish(runId: string, nodeId: string, patch: AgentActionFinishPatch): Promise<void>;
|
|
168
|
+
/** Flush + freeze the run: sends exactly one `settled` frame with all nodes. */
|
|
169
|
+
settle(runId: string, opts?: AgentActionSettleOpts): Promise<void>;
|
|
170
|
+
/** Auto-capture helper: start a node, run `fn`, finish it done/error from the outcome. */
|
|
171
|
+
wrap<T>(runId: string, fn: () => T | Promise<T>, opts: AgentActionWrapOpts): Promise<T>;
|
|
172
|
+
}
|
|
94
173
|
export declare const BRIDGE_VERSION = "0.0.0";
|
|
95
174
|
export declare class BridgeAgent {
|
|
96
175
|
private readonly cfg;
|
|
@@ -121,6 +200,15 @@ export declare class BridgeAgent {
|
|
|
121
200
|
* per channel on a miss.
|
|
122
201
|
*/
|
|
123
202
|
private readonly channelRoster;
|
|
203
|
+
/**
|
|
204
|
+
* Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
|
|
205
|
+
* cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
|
|
206
|
+
* the onResolvePinnedOwner hook passed to MlsGroupKeyProvider. "anomalous"
|
|
207
|
+
* means the DM roster shape was not exactly one non-self, non-agent
|
|
208
|
+
* counterpart, OR the freshly-fetched key no longer matches an existing pin —
|
|
209
|
+
* either way the hook must fail closed (return null) for that channel.
|
|
210
|
+
*/
|
|
211
|
+
private readonly channelOwner;
|
|
124
212
|
private readonly buffer;
|
|
125
213
|
private readonly maxBuffer;
|
|
126
214
|
/** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
|
|
@@ -135,6 +223,13 @@ export declare class BridgeAgent {
|
|
|
135
223
|
private rateLimitedSends;
|
|
136
224
|
/** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
|
|
137
225
|
private welcomeRequestsSent;
|
|
226
|
+
/** 2026-07-03 latency triage: cumulative delivery pings received on the WS
|
|
227
|
+
* stream. 0 while messages still flow = the stream is dead and inbound is
|
|
228
|
+
* riding the 25s safety floor (surfaced on health()/metrics). */
|
|
229
|
+
private pingsReceived;
|
|
230
|
+
/** True once close() begins — gates the ping-stream-death stderr warning so
|
|
231
|
+
* a normal shutdown doesn't cry wolf. */
|
|
232
|
+
private closingDown;
|
|
138
233
|
/**
|
|
139
234
|
* Track 5: cumulative count of artifact_share messages where download/decrypt/digest
|
|
140
235
|
* failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
|
|
@@ -145,7 +240,87 @@ export declare class BridgeAgent {
|
|
|
145
240
|
private readonly lastWelcomeRequest;
|
|
146
241
|
/** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
|
|
147
242
|
private static readonly REQUEST_WELCOME_COOLDOWN_MS;
|
|
243
|
+
private static readonly GRACEFUL_DRAIN_MS;
|
|
244
|
+
/**
|
|
245
|
+
* P0b (daemon-stability): collapses the onDecryptError console.warn from
|
|
246
|
+
* "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
|
|
247
|
+
* most one line immediately + one summary line per 60s per channel. LOG
|
|
248
|
+
* OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
|
|
249
|
+
* live entirely in the SDK provider and are unaffected by this class.
|
|
250
|
+
*/
|
|
251
|
+
private readonly decryptErrorLogLimiter;
|
|
252
|
+
/**
|
|
253
|
+
* Task 12: proactive self-heal sweep kill-switch. Task 11's held-KP signal
|
|
254
|
+
* only fires reactively, from ingestOnce's Phase-0 pass over PULLED delivery
|
|
255
|
+
* rows — an idle-but-wiped channel therefore loses its NEXT message (the
|
|
256
|
+
* very message that would have proven it diverged). selfHealSweep() closes
|
|
257
|
+
* that gap by proactively walking every non-DM member channel at boot and
|
|
258
|
+
* on an interval. `SECURECOMS_SELF_HEAL_SWEEP=false` disables it, falling
|
|
259
|
+
* back to the delivery-row-driven Task 11 behaviour only.
|
|
260
|
+
*/
|
|
261
|
+
private readonly selfHealSweepEnabled;
|
|
262
|
+
/** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
|
|
263
|
+
private static readonly SELF_HEAL_SWEEP_INTERVAL_MS;
|
|
264
|
+
private static readonly SELF_HEAL_SWEEP_JITTER_MS;
|
|
265
|
+
private selfHealTimer?;
|
|
266
|
+
private bootSweepTimer?;
|
|
267
|
+
private readonly rng;
|
|
148
268
|
private readonly rateFloor;
|
|
269
|
+
/**
|
|
270
|
+
* agent.action.* run state, keyed by runId.
|
|
271
|
+
*
|
|
272
|
+
* Review M3 (bug fix): the ORIGINAL comment here said "deliberately
|
|
273
|
+
* unbounded" — but nothing ever bounded it either. Only actionSettle()
|
|
274
|
+
* deleted an entry; a begun-but-never-settled run (agent crash, bug,
|
|
275
|
+
* dropped/never-sent reply) leaked for the daemon's entire lifetime. Now
|
|
276
|
+
* bounded by MAX_UNSETTLED_ACTION_RUNS (LRU eviction by startedAt) plus a
|
|
277
|
+
* lazy ACTION_RUN_STALE_MS sweep, both applied on every actionBegin() —
|
|
278
|
+
* mirrors the send-side inbound-buffer bound (capBuffer/maxBuffer).
|
|
279
|
+
*/
|
|
280
|
+
private readonly actionRuns;
|
|
281
|
+
/** Review M3: hard cap on concurrently-tracked UNSETTLED runs. Exceeding it
|
|
282
|
+
* evicts the oldest (LRU by startedAt) to make room — never grows past
|
|
283
|
+
* this regardless of how many runs are begun and abandoned. */
|
|
284
|
+
private static readonly MAX_UNSETTLED_ACTION_RUNS;
|
|
285
|
+
/** Review M3: an unsettled run older than this (by startedAt, relative to
|
|
286
|
+
* actionClock()) is swept away on the next actionBegin(), independent of
|
|
287
|
+
* the count-based cap — bounds memory even when begin() calls are rare. */
|
|
288
|
+
private static readonly ACTION_RUN_STALE_MS;
|
|
289
|
+
/** Review I3/M3: injected clock (defaults Date.now) for the action-partial
|
|
290
|
+
* token buckets AND the stale-run sweep — a single seam so tests can
|
|
291
|
+
* simulate elapsed time deterministically. */
|
|
292
|
+
private readonly actionClock;
|
|
293
|
+
/**
|
|
294
|
+
* Per-run token bucket for `agent_action` PARTIAL frames (Global
|
|
295
|
+
* Constraint: do NOT model on the unthrottled reportStatus; do NOT reuse
|
|
296
|
+
* `rateFloor`/`enforceRateFloor`, whose semantics are "throw past
|
|
297
|
+
* capacity" — partials instead COALESCE: a throttled call just leaves its
|
|
298
|
+
* change in `dirty` for the next allowed send to pick up).
|
|
299
|
+
*
|
|
300
|
+
* Review I3 (bug fix): capacity 1 / refill 60-per-minute was EXACTLY the
|
|
301
|
+
* backend's MESSAGING_LIMIT (60/60s per sender) — no headroom. Partials +
|
|
302
|
+
* the settled frame + the agent's real reply + reportStatus all compete
|
|
303
|
+
* for that one shared per-sender budget, so a long run's settled frame or
|
|
304
|
+
* reply could 429 right at the end. Lowered to 30/min (~1 per 2s) so
|
|
305
|
+
* partials alone use at most ~half the identity's send budget, leaving
|
|
306
|
+
* headroom for the settled frame / reply / status traffic riding the SAME
|
|
307
|
+
* cap. Independent of `cfg.rateLimitCapacity` (room/a2a/attachment floor).
|
|
308
|
+
* `settled` frames bypass this bucket entirely — they always send, exactly
|
|
309
|
+
* once, per run.
|
|
310
|
+
*/
|
|
311
|
+
private readonly actionRateFloor;
|
|
312
|
+
/**
|
|
313
|
+
* Review I3 (bug fix): a GLOBAL bucket shared across every run, on top of
|
|
314
|
+
* the per-run bucket above. Without this, N concurrent runs each get their
|
|
315
|
+
* OWN ~30/min budget, so the partial send rate multiplies with agent
|
|
316
|
+
* concurrency and can still blow past the shared 60/min identity edge cap
|
|
317
|
+
* (each run individually "has headroom" but the SUM doesn't). Same
|
|
318
|
+
* capacity/refill as the per-run bucket — it caps the aggregate, not any
|
|
319
|
+
* single run.
|
|
320
|
+
*/
|
|
321
|
+
private readonly globalActionRateFloor;
|
|
322
|
+
/** Public `agent.action.*` surface — assigned once at the end of the constructor. */
|
|
323
|
+
readonly action: AgentActionAPI;
|
|
149
324
|
private readonly waiters;
|
|
150
325
|
private pingHandle?;
|
|
151
326
|
private safetyTimer?;
|
|
@@ -173,13 +348,120 @@ export declare class BridgeAgent {
|
|
|
173
348
|
*/
|
|
174
349
|
private capBuffer;
|
|
175
350
|
start(): Promise<void>;
|
|
351
|
+
/** C3: a random 0..SELF_HEAL_SWEEP_JITTER_MS decorrelation offset. */
|
|
352
|
+
private sweepJitter;
|
|
176
353
|
private reconcileJoin;
|
|
354
|
+
/**
|
|
355
|
+
* The held-KP re-Welcome signal (Task 11), shared by EVERY heal path: the
|
|
356
|
+
* reactive no-local-state branch in ingestOnce(), the proactive
|
|
357
|
+
* selfHealSweep() (Task 12), the onWedged forked-state recovery
|
|
358
|
+
* (healWedgedChannel), and the reset supersede (healSupersedeUnopenable).
|
|
359
|
+
*
|
|
360
|
+
* Before asking the committer to (re-)Welcome us, it RECONCILES the server's
|
|
361
|
+
* published KeyPackage to our held bundle. This is the fix for the observed
|
|
362
|
+
* "fresh DM never replies" AND "reset doesn't work": the committer seals every
|
|
363
|
+
* Welcome to our SERVER-PUBLISHED KP, but that KP had diverged from our
|
|
364
|
+
* locally-HELD bundle, so joinFromWelcome throws "No matching secret found" /
|
|
365
|
+
* "private KeyPackage key no longer held" and the Welcome is discarded. A bare
|
|
366
|
+
* re-Welcome re-seals to the SAME diverged published KP — un-openable again —
|
|
367
|
+
* looping until the channel quarantines. So:
|
|
368
|
+
* - published == held → publishKeyPackage() (no-op while held; mints only if
|
|
369
|
+
* somehow none is held), then re-Welcome re-seals to the current KP we hold.
|
|
370
|
+
* - published != held → forceRepublishKeyPackage() re-syncs the server to a
|
|
371
|
+
* bundle we hold, so the owner's re-Welcome is openable. Orphan-safe: a
|
|
372
|
+
* diverged published KP means every other pending Welcome sealed via the
|
|
373
|
+
* server is un-openable too, so replacing the shared per-device bundle
|
|
374
|
+
* strands nothing that was openable (see heldBundleMatchesPublishedKp).
|
|
375
|
+
* getLastPublishedKpId then names the reconciled KP so the owner re-seals to it.
|
|
376
|
+
* Callers own their cooldown/rate-limit gate and try/catch — this does neither.
|
|
377
|
+
*/
|
|
378
|
+
private signalHeldKpWelcome;
|
|
379
|
+
/**
|
|
380
|
+
* P0b (daemon-stability): the onWedged→heal path. onWedged only hands us a
|
|
381
|
+
* `connect_channel_id` — resolve it to an MLS groupId the SAME way
|
|
382
|
+
* selfHealSweep does, via the read-only getConnectChannelGroup probe
|
|
383
|
+
* (never initConnectChannelGroup, which side-effects a member_device_ids
|
|
384
|
+
* union + commit), then fire the existing held-KP re-Welcome signal.
|
|
385
|
+
* Respects the SAME per-channel cooldown (lastWelcomeRequest /
|
|
386
|
+
* REQUEST_WELCOME_COOLDOWN_MS) used by ingestOnce's Task-11 branch and
|
|
387
|
+
* selfHealSweep — this must never hammer requestWelcome on its own.
|
|
388
|
+
*
|
|
389
|
+
* Best-effort in every sense: if the channel has no resolvable group (no
|
|
390
|
+
* local state to probe from), this logs nothing further and just returns —
|
|
391
|
+
* the channel is left to the next selfHealSweep pass or reactive Task-11
|
|
392
|
+
* branch, mirroring how those two paths already treat an unresolvable
|
|
393
|
+
* group (`continue`, no signal). ANY failure along the way (probe throws,
|
|
394
|
+
* signal throws) is swallowed: a wedge heal must never throw back into the
|
|
395
|
+
* SDK's onWedged caller. Single-shot per wedge episode — full rejoin
|
|
396
|
+
* reliability is P1, not this hook's job.
|
|
397
|
+
*/
|
|
398
|
+
private healWedgedChannel;
|
|
399
|
+
/**
|
|
400
|
+
* P1a self-heal (onSupersedeUnopenable): a fully-verified reset supersede whose
|
|
401
|
+
* Welcome we cannot open — it was sealed to a stale published KeyPackage our
|
|
402
|
+
* held bundle no longer matches ("No matching secret found"). trySupersede has
|
|
403
|
+
* verified owner + attestation + signature, so this is a real reset we WANT; we
|
|
404
|
+
* just can't open THIS Welcome. Ask the committer to re-Welcome us to the reset
|
|
405
|
+
* `groupId` with our CURRENT KeyPackage (signalHeldKpWelcome republishes/ensures
|
|
406
|
+
* a held KP, then requestWelcome), so the owner re-seals a Welcome we can open
|
|
407
|
+
* and the reset converges. The groupId is known (handed in), so no probe needed.
|
|
408
|
+
*
|
|
409
|
+
* Returns true ONLY when a re-Welcome was actually requested — trySupersede then
|
|
410
|
+
* discards the un-openable row. Under the shared per-channel cooldown (or on any
|
|
411
|
+
* failure) returns false, so trySupersede LEAVES the row for a bounded retry
|
|
412
|
+
* rather than dropping it with no replacement in flight. Never throws.
|
|
413
|
+
*/
|
|
414
|
+
private healSupersedeUnopenable;
|
|
415
|
+
/**
|
|
416
|
+
* Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
|
|
417
|
+
* branch only fires reactively — off a delivery ROW that already arrived
|
|
418
|
+
* for a diverged channel. A wiped-but-IDLE channel (no traffic since the
|
|
419
|
+
* wipe) never produces that row, so its next real message would be the
|
|
420
|
+
* first thing lost. This sweep walks every member channel — rooms AND DMs
|
|
421
|
+
* (H2: a wiped, idle DM with no traffic since the wipe otherwise never
|
|
422
|
+
* proactively re-Welcomes, so it stays dark until the owner happens to send)
|
|
423
|
+
* — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS, and, for any with no
|
|
424
|
+
* persisted state even after a reconcile attempt, resolves group_id via
|
|
425
|
+
* the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
|
|
426
|
+
* which would side-effect a member_device_ids union + commit) and applies
|
|
427
|
+
* the same held-KP signal — respecting the existing per-channel cooldown.
|
|
428
|
+
*
|
|
429
|
+
* Each channel is wrapped in its own try/catch: `listChannels` filters at
|
|
430
|
+
* the identity level while `requestWelcome` (via request_welcome) gates at
|
|
431
|
+
* the device level, so a channel where this identity is a member but this
|
|
432
|
+
* DEVICE never held a leaf can 403 on the signal call. That's harmless and
|
|
433
|
+
* must not abort the sweep for every other channel.
|
|
434
|
+
*/
|
|
435
|
+
private selfHealSweep;
|
|
177
436
|
/**
|
|
178
437
|
* Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
|
|
179
438
|
* trip (incrementing the observable counter). `kind` namespaces the bucket so
|
|
180
439
|
* room and a2a traffic on the same channel are tracked separately.
|
|
181
440
|
*/
|
|
182
441
|
private enforceRateFloor;
|
|
442
|
+
/**
|
|
443
|
+
* The ONE seam every application-send path (send/sendA2A/sendAttachment)
|
|
444
|
+
* calls to publish, wrapping the SDK's {@link sendWithSelfHeal} with the
|
|
445
|
+
* daemon's per-room-pause terminal classification (Task 9).
|
|
446
|
+
*
|
|
447
|
+
* `AgentPausedError` (Task 8's SDK classification of the backend's 403
|
|
448
|
+
* `agent_paused`) means an owner/admin has deliberately muted this agent in
|
|
449
|
+
* this room — NOT a transient/network failure. That distinction MUST be
|
|
450
|
+
* made here, before control returns to any caller, so:
|
|
451
|
+
* - it never reaches a heal/self-heal trigger (healWedgedChannel /
|
|
452
|
+
* onWedged re-Welcome are wired off the RECEIVE path via SDK provider
|
|
453
|
+
* callbacks, not off a send failure — this seam guarantees a send
|
|
454
|
+
* failure never grows one either, now or as the code evolves), and
|
|
455
|
+
* - it never gets treated as a generic/retryable failure by a caller —
|
|
456
|
+
* rethrown as the daemon's OWN typed `AgentPausedSendError` (mirroring
|
|
457
|
+
* ChannelPendingError/RateLimitedError, both also bridge-agent.ts
|
|
458
|
+
* locals) so http-server.ts/ws-server.ts can `instanceof`-check it the
|
|
459
|
+
* same way they already do for those two, instead of it falling into
|
|
460
|
+
* their generic "internal error" bucket.
|
|
461
|
+
* Any other error (including StaleEpochError retries, which sendWithSelfHeal
|
|
462
|
+
* already resolves internally) propagates unchanged.
|
|
463
|
+
*/
|
|
464
|
+
private sendWithSelfHealTerminal;
|
|
183
465
|
send(channelId: string, text: string, messageId?: string): Promise<{
|
|
184
466
|
message_id: string;
|
|
185
467
|
status: "sent";
|
|
@@ -223,7 +505,65 @@ export declare class BridgeAgent {
|
|
|
223
505
|
* attribution must NEVER block or drop a message.
|
|
224
506
|
*/
|
|
225
507
|
private refreshChannelRoster;
|
|
508
|
+
/**
|
|
509
|
+
* Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
|
|
510
|
+
* NEVER read the server's `role` field to pick the owner. In a DM the owner
|
|
511
|
+
* is the SINGLE non-self, non-agent counterpart; any other roster shape
|
|
512
|
+
* (zero or 2+ non-agent counterparts) is anomalous and REFUSES (no pin, no
|
|
513
|
+
* supersede eligibility). On the first clean sighting of an unambiguous
|
|
514
|
+
* owner with no existing pin, TOFU-pin its public key. On a later sighting
|
|
515
|
+
* where a pin already exists and the freshly-fetched key differs, this is an
|
|
516
|
+
* anomaly: surface it (log) and refuse — NEVER silently re-pin. Fail-soft:
|
|
517
|
+
* this must never throw into the roster refresh path.
|
|
518
|
+
*/
|
|
519
|
+
private resolveAndPinOwner;
|
|
520
|
+
/**
|
|
521
|
+
* Task 8: the `onResolvePinnedOwner` hook handed to MlsGroupKeyProvider. The
|
|
522
|
+
* SDK's step-0 guard calls this before honoring an owner-authored supersede;
|
|
523
|
+
* returning null makes it fail closed. Returns a value ONLY when (a) the
|
|
524
|
+
* structural owner cache has an unambiguous entry for this channel AND (b) a
|
|
525
|
+
* persisted pin still exists and matches it — a bare in-memory cache hit is
|
|
526
|
+
* never trusted on its own, it is always reconfirmed against the store.
|
|
527
|
+
*/
|
|
528
|
+
private resolvePinnedOwnerHook;
|
|
226
529
|
reportStatus(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
|
|
530
|
+
/** `runId` must exist and not have settled yet — settle() is terminal. */
|
|
531
|
+
private requireActionRun;
|
|
532
|
+
/**
|
|
533
|
+
* Build + send one `agent_action` E2E body over `system_event` (mirrors
|
|
534
|
+
* sendA2A/reportStatus's envelope construction — same self-heal seam). Used
|
|
535
|
+
* for BOTH partial and settled frames; the caller decides whether a send is
|
|
536
|
+
* throttle-gated (partials, via flushActionPartial) or unconditional
|
|
537
|
+
* (settled, always exactly once).
|
|
538
|
+
*/
|
|
539
|
+
private sendActionFrame;
|
|
540
|
+
/**
|
|
541
|
+
* The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
|
|
542
|
+
* If the run has no dirty nodes, no-op. If the per-run bucket has no token,
|
|
543
|
+
* no-op too — the change stays in `dirty` and rides along with whatever the
|
|
544
|
+
* NEXT allowed send captures (a later start/update/finish call, or settle(),
|
|
545
|
+
* which always flushes regardless of the bucket). This is the coalescing:
|
|
546
|
+
* a burst of rapid calls collapses into however many sends the ~1/sec floor
|
|
547
|
+
* actually allows, each carrying every node changed since the last send.
|
|
548
|
+
*/
|
|
549
|
+
private flushActionPartial;
|
|
550
|
+
/** Review M3: drop any unsettled run older than ACTION_RUN_STALE_MS
|
|
551
|
+
* (relative to `now`). Lazy — invoked on every actionBegin() rather than
|
|
552
|
+
* a background timer, mirroring the client store's orphan-TTL pattern. */
|
|
553
|
+
private sweepStaleActionRuns;
|
|
554
|
+
private actionBegin;
|
|
555
|
+
private actionStart;
|
|
556
|
+
private actionUpdate;
|
|
557
|
+
private actionFinish;
|
|
558
|
+
private actionSettle;
|
|
559
|
+
/**
|
|
560
|
+
* Auto-capture seam (Task 4 "auto-capture"): wraps a tool-call / sub-agent
|
|
561
|
+
* invocation so a bare agent gets a start→finish node pair without hand-
|
|
562
|
+
* rolling the begin/start/update/finish calls itself. Success → status
|
|
563
|
+
* "done"; a thrown error → status "error" with the message captured in
|
|
564
|
+
* `detail.error`, then the original error is rethrown (wrap never swallows).
|
|
565
|
+
*/
|
|
566
|
+
private actionWrap;
|
|
227
567
|
protected startReceivePump(): void;
|
|
228
568
|
private ingestOnce;
|
|
229
569
|
drain(channelId: string | undefined, waitSeconds: number): Promise<BridgeMessage[]>;
|
|
@@ -263,6 +603,9 @@ export declare class BridgeAgent {
|
|
|
263
603
|
permanently_skipped: number;
|
|
264
604
|
/** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
|
|
265
605
|
failed_attachments: number;
|
|
606
|
+
/** 2026-07-03 latency triage: delivery pings received on the WS stream.
|
|
607
|
+
* 0 while messages flow = push is dead; inbound rides the 25s floor. */
|
|
608
|
+
delivery_pings: number;
|
|
266
609
|
uptime_s: number;
|
|
267
610
|
version: string;
|
|
268
611
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface DecryptErrorInfo {
|
|
2
|
+
/** MLS epoch the failing row was at, if known. */
|
|
3
|
+
epoch?: number | string;
|
|
4
|
+
/** Delivery queue id the failing row came from, if known. */
|
|
5
|
+
queueId?: string;
|
|
6
|
+
/** The decrypt error's message (already stringified by the caller). */
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DecryptErrorLogLimiterOpts {
|
|
10
|
+
/** Suppression window in ms. Default 60_000 (60s). */
|
|
11
|
+
windowMs?: number;
|
|
12
|
+
/** Clock seam for tests. Defaults to Date.now. */
|
|
13
|
+
now?: () => number;
|
|
14
|
+
/** Log sink. Defaults to console.warn. Injectable so tests can assert on
|
|
15
|
+
* emitted lines without capturing the real console. */
|
|
16
|
+
log?: (line: string) => void;
|
|
17
|
+
}
|
|
18
|
+
/** Default suppression window: 60 seconds. */
|
|
19
|
+
export declare const DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 60000;
|
|
20
|
+
/** Hard cap on distinct error reasons surfaced per channel per window. A wedged
|
|
21
|
+
* channel has a small handful of genuine reasons; beyond this the extra volume
|
|
22
|
+
* is noise (or an adversarial/buggy server cycling messages) and is suppressed. */
|
|
23
|
+
export declare const MAX_DISTINCT_PER_WINDOW = 4;
|
|
24
|
+
export declare class DecryptErrorLogLimiter {
|
|
25
|
+
private readonly windowMs;
|
|
26
|
+
private readonly now;
|
|
27
|
+
private readonly log;
|
|
28
|
+
private readonly windows;
|
|
29
|
+
constructor(opts?: DecryptErrorLogLimiterOpts);
|
|
30
|
+
/**
|
|
31
|
+
* Record one decrypt error for `channelId`. Logs immediately if this is
|
|
32
|
+
* the channel's first error since its window opened (or its prior window
|
|
33
|
+
* had already elapsed, in which case a summary for that just-closed
|
|
34
|
+
* window is flushed first); otherwise the error is counted silently
|
|
35
|
+
* toward the window's eventual summary line.
|
|
36
|
+
*/
|
|
37
|
+
record(channelId: string, info: DecryptErrorInfo): void;
|
|
38
|
+
/**
|
|
39
|
+
* Clear a channel's window (e.g. once the caller learns the channel
|
|
40
|
+
* cleared/healed) so its NEXT error logs immediately as a fresh "first"
|
|
41
|
+
* rather than being folded into a stale window's summary. There is
|
|
42
|
+
* currently no onDecryptSuccess-style hook from the SDK to wire this to
|
|
43
|
+
* automatically (see bridge-agent.ts's onDecryptError wiring comment) —
|
|
44
|
+
* exposed here so a future success signal (or a caller with its own
|
|
45
|
+
* channel-cleared signal) can reset explicitly. In its absence, the
|
|
46
|
+
* window simply rolls over on interval elapse, which is an accepted
|
|
47
|
+
* simplification (see report).
|
|
48
|
+
*/
|
|
49
|
+
reset(channelId: string): void;
|
|
50
|
+
private logFirst;
|
|
51
|
+
private flushSummary;
|
|
52
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* WebSocket returns `{type:"error", error, message, detail?}`. A client gets the
|
|
7
7
|
* same `error` code regardless of which transport it used. (A2A §9.5/§10.6 pattern.)
|
|
8
8
|
*/
|
|
9
|
-
export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "internal";
|
|
9
|
+
export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "unknown_action_run" | "internal";
|
|
10
10
|
export interface BridgeErrorSpec {
|
|
11
11
|
/** HTTP status this code maps to (the WS binding ignores it). */
|
|
12
12
|
http: number;
|