@xmoxmo/bncr 0.3.3 → 0.3.5
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/index.js +7 -3
- package/index.ts +11 -10
- package/openclaw.plugin.json +21 -0
- package/package.json +4 -4
- package/scripts/check-pack.mjs +112 -22
- package/scripts/check-register-drift.mjs +91 -65
- package/scripts/selfcheck.mjs +79 -3
- package/src/channel.ts +549 -810
- package/src/core/accounts.ts +1 -1
- package/src/core/connection-capability.ts +2 -2
- package/src/core/connection-reachability.ts +112 -1
- package/src/core/dead-letter-diagnostics.ts +91 -0
- package/src/core/diagnostic-counters.ts +61 -0
- package/src/core/diagnostics.ts +9 -5
- package/src/core/downlink-health.ts +15 -10
- package/src/core/extended-diagnostics.ts +4 -0
- package/src/core/file-transfer-payloads.ts +1 -4
- package/src/core/logging.ts +98 -0
- package/src/core/outbox-entry-builders.ts +15 -2
- package/src/core/outbox-file-transfer-bookkeeping.ts +1 -1
- package/src/core/outbox-file-transfer-failure.ts +2 -5
- package/src/core/outbox-file-transfer-success.ts +1 -4
- package/src/core/outbox-text-push-failure.ts +2 -4
- package/src/core/outbox-text-push-success.ts +1 -1
- package/src/core/persisted-outbox-entry.ts +53 -0
- package/src/core/probe.ts +33 -13
- package/src/core/register-trace.ts +48 -0
- package/src/core/status-meta.ts +77 -0
- package/src/core/status.ts +50 -57
- package/src/messaging/inbound/commands.ts +42 -94
- package/src/messaging/inbound/dispatch.ts +25 -54
- package/src/messaging/inbound/last-route.ts +46 -0
- package/src/messaging/inbound/native-command.ts +49 -0
- package/src/messaging/inbound/native-reply-delivery.ts +43 -0
- package/src/messaging/inbound/parse.ts +3 -3
- package/src/messaging/inbound/runtime-compat.ts +8 -2
- package/src/messaging/outbound/build-send-action.ts +1 -2
- package/src/messaging/outbound/diagnostics.ts +221 -2
- package/src/messaging/outbound/durable-message-adapter.ts +15 -5
- package/src/messaging/outbound/durable-queue-adapter.ts +3 -1
- package/src/messaging/outbound/media.ts +2 -1
- package/src/messaging/outbound/queue-selectors.ts +19 -6
- package/src/messaging/outbound/reasons.ts +2 -0
- package/src/messaging/outbound/reply-enqueue.ts +29 -2
- package/src/messaging/outbound/reply-target-policy.ts +4 -1
- package/src/messaging/outbound/retry-policy.ts +16 -8
- package/src/messaging/outbound/send-params.ts +56 -0
- package/src/messaging/outbound/session-route.ts +1 -1
- package/src/openclaw/reply-runtime.ts +4 -5
- package/src/openclaw/routing-runtime.ts +0 -1
- package/src/openclaw/runtime-surface.ts +29 -0
- package/src/openclaw/sdk-helpers.ts +4 -1
- package/src/plugin/gateway-methods.ts +2 -0
- package/src/plugin/messaging.ts +2 -9
- package/src/plugin/status.ts +15 -5
- package/src/runtime/outbound-ack-timeout.ts +73 -0
- package/src/runtime/outbound-flags.ts +1 -1
- package/src/runtime/outbox-transitions.ts +4 -4
- package/src/runtime/register-trace-runtime.ts +102 -0
- package/src/runtime/status-snapshots.ts +10 -4
- package/src/runtime/status-worker.ts +78 -13
package/src/core/accounts.ts
CHANGED
|
@@ -56,8 +56,8 @@ export function findCapabilityConnection(args: {
|
|
|
56
56
|
|
|
57
57
|
export function buildCapabilitySnapshot(connection: CapabilityConnection) {
|
|
58
58
|
return {
|
|
59
|
-
outboundReadyUntil: connection.outboundReadyUntil
|
|
60
|
-
preferredForOutboundUntil: connection.preferredForOutboundUntil
|
|
59
|
+
outboundReadyUntil: connection.outboundReadyUntil ?? null,
|
|
60
|
+
preferredForOutboundUntil: connection.preferredForOutboundUntil ?? null,
|
|
61
61
|
inboundOnly: connection.inboundOnly === true,
|
|
62
62
|
};
|
|
63
63
|
}
|
|
@@ -94,6 +94,112 @@ export function hasAlternativeLiveConnection(args: {
|
|
|
94
94
|
return false;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
type OutboundPushCandidateConnection = BncrConnection & {
|
|
98
|
+
inboundOnly?: boolean;
|
|
99
|
+
preferredForOutboundUntil?: number;
|
|
100
|
+
outboundReadyUntil?: number;
|
|
101
|
+
lastAckOkAt?: number;
|
|
102
|
+
lastPushTimeoutAt?: number;
|
|
103
|
+
pushFailureScore?: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function isEligibleOutboundPushConnection(args: {
|
|
107
|
+
connection?: BncrConnection | null;
|
|
108
|
+
now: number;
|
|
109
|
+
connectTtlMs: number;
|
|
110
|
+
}): args is { connection: OutboundPushCandidateConnection; now: number; connectTtlMs: number } {
|
|
111
|
+
const conn = args.connection as OutboundPushCandidateConnection | null | undefined;
|
|
112
|
+
if (!conn?.connId) return false;
|
|
113
|
+
const nowMs = finiteNumberOr(args.now, 0);
|
|
114
|
+
const connectTtlMs = finiteNumberOr(args.connectTtlMs, 0);
|
|
115
|
+
const lastSeenAt = finiteNumberOr(conn.lastSeenAt, 0);
|
|
116
|
+
if (!nowMs || !connectTtlMs || !lastSeenAt) return false;
|
|
117
|
+
if (nowMs - lastSeenAt > connectTtlMs) return false;
|
|
118
|
+
if (conn.inboundOnly === true) return false;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scoreOutboundPushConnection(args: {
|
|
123
|
+
connection: BncrConnection;
|
|
124
|
+
now: number;
|
|
125
|
+
recentInboundConnIds: Set<string>;
|
|
126
|
+
}) {
|
|
127
|
+
const conn = args.connection as OutboundPushCandidateConnection;
|
|
128
|
+
const preferredForOutboundUntil = finiteNumberOr(conn.preferredForOutboundUntil, 0);
|
|
129
|
+
const outboundReadyUntil = finiteNumberOr(conn.outboundReadyUntil, 0);
|
|
130
|
+
const lastPushTimeoutAt = finiteNumberOr(conn.lastPushTimeoutAt, 0);
|
|
131
|
+
const lastAckOkAt = finiteNumberOr(conn.lastAckOkAt, 0);
|
|
132
|
+
const pushFailureScore = finiteNumberOr(conn.pushFailureScore, 0);
|
|
133
|
+
const recentTimeoutPenalty =
|
|
134
|
+
lastPushTimeoutAt > 0 && args.now - lastPushTimeoutAt <= 30_000 ? 1 : 0;
|
|
135
|
+
return {
|
|
136
|
+
preferred: preferredForOutboundUntil > args.now ? 1 : 0,
|
|
137
|
+
ready: outboundReadyUntil > args.now ? 1 : 0,
|
|
138
|
+
recentInbound: args.recentInboundConnIds.has(conn.connId) ? 1 : 0,
|
|
139
|
+
recentTimeoutPenalty,
|
|
140
|
+
pushFailureScore,
|
|
141
|
+
lastAckOkAt,
|
|
142
|
+
lastPushTimeoutAt,
|
|
143
|
+
lastSeenAt: finiteNumberOr(conn.lastSeenAt, 0),
|
|
144
|
+
connectedAt: finiteNumberOr(conn.connectedAt, 0),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function compareOutboundPushConnections(args: {
|
|
149
|
+
a: BncrConnection;
|
|
150
|
+
b: BncrConnection;
|
|
151
|
+
now: number;
|
|
152
|
+
recentInboundConnIds: Set<string>;
|
|
153
|
+
}) {
|
|
154
|
+
const sa = scoreOutboundPushConnection({
|
|
155
|
+
connection: args.a,
|
|
156
|
+
now: args.now,
|
|
157
|
+
recentInboundConnIds: args.recentInboundConnIds,
|
|
158
|
+
});
|
|
159
|
+
const sb = scoreOutboundPushConnection({
|
|
160
|
+
connection: args.b,
|
|
161
|
+
now: args.now,
|
|
162
|
+
recentInboundConnIds: args.recentInboundConnIds,
|
|
163
|
+
});
|
|
164
|
+
if (sb.preferred !== sa.preferred) return sb.preferred - sa.preferred;
|
|
165
|
+
if (sb.ready !== sa.ready) return sb.ready - sa.ready;
|
|
166
|
+
if (sa.recentTimeoutPenalty !== sb.recentTimeoutPenalty)
|
|
167
|
+
return sa.recentTimeoutPenalty - sb.recentTimeoutPenalty;
|
|
168
|
+
if (sa.pushFailureScore !== sb.pushFailureScore) return sa.pushFailureScore - sb.pushFailureScore;
|
|
169
|
+
if (sb.lastAckOkAt !== sa.lastAckOkAt) return sb.lastAckOkAt - sa.lastAckOkAt;
|
|
170
|
+
if (sa.lastPushTimeoutAt !== sb.lastPushTimeoutAt)
|
|
171
|
+
return sa.lastPushTimeoutAt - sb.lastPushTimeoutAt;
|
|
172
|
+
if (sb.recentInbound !== sa.recentInbound) return sb.recentInbound - sa.recentInbound;
|
|
173
|
+
if (sb.lastSeenAt !== sa.lastSeenAt) return sb.lastSeenAt - sa.lastSeenAt;
|
|
174
|
+
return sb.connectedAt - sa.connectedAt;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function selectOrderedOutboundPushConnections(args: {
|
|
178
|
+
accountId: string;
|
|
179
|
+
now: number;
|
|
180
|
+
connectTtlMs: number;
|
|
181
|
+
recentInboundConnIds: Set<string>;
|
|
182
|
+
connections: Iterable<BncrConnection>;
|
|
183
|
+
}) {
|
|
184
|
+
return Array.from(args.connections)
|
|
185
|
+
.filter((c): c is BncrConnection => c.accountId === args.accountId)
|
|
186
|
+
.filter((connection) =>
|
|
187
|
+
isEligibleOutboundPushConnection({
|
|
188
|
+
connection,
|
|
189
|
+
now: args.now,
|
|
190
|
+
connectTtlMs: args.connectTtlMs,
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
.sort((a, b) =>
|
|
194
|
+
compareOutboundPushConnections({
|
|
195
|
+
a,
|
|
196
|
+
b,
|
|
197
|
+
now: args.now,
|
|
198
|
+
recentInboundConnIds: args.recentInboundConnIds,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
97
203
|
type ReachableConnection = BncrConnection & {
|
|
98
204
|
inboundOnly?: boolean;
|
|
99
205
|
preferredForOutboundUntil?: number;
|
|
@@ -142,7 +248,12 @@ export function getRevalidatedAttemptReason(args: {
|
|
|
142
248
|
lastPushTimeoutAt <= lastAttemptAt &&
|
|
143
249
|
lastSeenAt > lastPushTimeoutAt;
|
|
144
250
|
|
|
145
|
-
if (
|
|
251
|
+
if (
|
|
252
|
+
!revalidatedByPreferred &&
|
|
253
|
+
!revalidatedByReady &&
|
|
254
|
+
!revalidatedByAck &&
|
|
255
|
+
!revalidatedByFreshReachability
|
|
256
|
+
) {
|
|
146
257
|
return null;
|
|
147
258
|
}
|
|
148
259
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { summarizeBncrTextPreview } from './logging.ts';
|
|
2
|
+
import { formatDisplayScope } from './targets.ts';
|
|
3
|
+
import type { OutboxEntry } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export type DeadLetterTopReason = { reason: string; count: number };
|
|
6
|
+
|
|
7
|
+
export type BuildDeadLetterDiagnosticsOptions = {
|
|
8
|
+
entries: OutboxEntry[];
|
|
9
|
+
allAccountsTotal: number;
|
|
10
|
+
sinceStart: number;
|
|
11
|
+
cappedAt: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function asString(value: unknown, fallback = ''): string {
|
|
15
|
+
if (typeof value === 'string') return value;
|
|
16
|
+
if (value == null) return fallback;
|
|
17
|
+
return String(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildDeadLetterDiagnostics(options: BuildDeadLetterDiagnosticsOptions) {
|
|
21
|
+
const reasonCounts = new Map<string, number>();
|
|
22
|
+
let oldestAt: number | null = null;
|
|
23
|
+
let newestAt: number | null = null;
|
|
24
|
+
|
|
25
|
+
for (const entry of options.entries) {
|
|
26
|
+
const reason = asString(entry.lastError || 'unknown').trim() || 'unknown';
|
|
27
|
+
reasonCounts.set(reason, (reasonCounts.get(reason) || 0) + 1);
|
|
28
|
+
const createdAt = Number(entry.createdAt);
|
|
29
|
+
if (Number.isFinite(createdAt)) {
|
|
30
|
+
oldestAt = oldestAt === null ? createdAt : Math.min(oldestAt, createdAt);
|
|
31
|
+
newestAt = newestAt === null ? createdAt : Math.max(newestAt, createdAt);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
total: options.entries.length,
|
|
37
|
+
allAccountsTotal: options.allAccountsTotal,
|
|
38
|
+
sinceStart: options.sinceStart,
|
|
39
|
+
cappedAt: options.cappedAt,
|
|
40
|
+
oldestAt,
|
|
41
|
+
newestAt,
|
|
42
|
+
topReasons: Array.from(reasonCounts.entries())
|
|
43
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
44
|
+
.slice(0, 5)
|
|
45
|
+
.map(([reason, count]) => ({ reason, count })),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatDeadLetterTopReasons(topReasons: DeadLetterTopReason[]): string {
|
|
50
|
+
if (!topReasons.length) return '-';
|
|
51
|
+
return topReasons.map((item) => `${item.reason}:${item.count}`).join(',');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseDeadLetterLimit(raw: unknown, defaultValue: number): number {
|
|
55
|
+
const n = Number(raw);
|
|
56
|
+
if (!Number.isFinite(n)) return defaultValue;
|
|
57
|
+
return Math.max(0, Math.min(100, Math.floor(n)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseDeadLetterOffset(raw: unknown, defaultValue: number): number {
|
|
61
|
+
const n = Number(raw);
|
|
62
|
+
if (!Number.isFinite(n)) return defaultValue;
|
|
63
|
+
return Math.max(0, Math.floor(n));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseDeadLetterOlderThan(raw: unknown): number | null {
|
|
67
|
+
if (raw === undefined || raw === null) return null;
|
|
68
|
+
const normalized = typeof raw === 'string' ? raw.trim() : raw;
|
|
69
|
+
if (normalized === '') return null;
|
|
70
|
+
const numeric = Number(normalized);
|
|
71
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
72
|
+
const parsed = Date.parse(String(normalized));
|
|
73
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function summarizeDeadLetterEntry(entry: OutboxEntry) {
|
|
77
|
+
const meta = entry.payload?._meta || {};
|
|
78
|
+
const msg = (entry.payload as any)?.message || {};
|
|
79
|
+
const text = asString(meta.text || msg.msg || '');
|
|
80
|
+
return {
|
|
81
|
+
messageId: entry.messageId,
|
|
82
|
+
accountId: entry.accountId,
|
|
83
|
+
sessionKey: entry.sessionKey,
|
|
84
|
+
route: formatDisplayScope(entry.route),
|
|
85
|
+
kind: asString(meta.kind || (entry.payload as any)?.type || 'message'),
|
|
86
|
+
createdAt: Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : null,
|
|
87
|
+
retryCount: Number.isFinite(Number(entry.retryCount)) ? Number(entry.retryCount) : 0,
|
|
88
|
+
lastError: entry.lastError || null,
|
|
89
|
+
textPreview: summarizeBncrTextPreview(text, 24),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { normalizeAccountId } from './accounts.ts';
|
|
2
|
+
import { parseStrictBncrSessionKey } from './targets.ts';
|
|
3
|
+
import type { OutboxEntry } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export type AccountResidueSessionRoute = { accountId?: string | null };
|
|
6
|
+
|
|
7
|
+
function hasMismatchedAccountId(raw: unknown, expectedAccountId: string): boolean {
|
|
8
|
+
const text = String(raw || '').trim();
|
|
9
|
+
return Boolean(text) && normalizeAccountId(text) !== expectedAccountId;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function countInvalidOutboxSessionKeys(args: {
|
|
13
|
+
accountId: string;
|
|
14
|
+
outboxEntries: Iterable<OutboxEntry>;
|
|
15
|
+
}): number {
|
|
16
|
+
const accountId = normalizeAccountId(args.accountId);
|
|
17
|
+
let count = 0;
|
|
18
|
+
for (const entry of args.outboxEntries) {
|
|
19
|
+
if (normalizeAccountId(entry.accountId) !== accountId) continue;
|
|
20
|
+
if (!parseStrictBncrSessionKey(entry.sessionKey)) count += 1;
|
|
21
|
+
}
|
|
22
|
+
return count;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function countLegacyAccountResidue(args: {
|
|
26
|
+
accountId: string;
|
|
27
|
+
outboxEntries: Iterable<Pick<OutboxEntry, 'accountId'>>;
|
|
28
|
+
deadLetterEntries: Iterable<Pick<OutboxEntry, 'accountId'>>;
|
|
29
|
+
sessionRoutes: Iterable<AccountResidueSessionRoute>;
|
|
30
|
+
lastSessionAccountIds: Iterable<string>;
|
|
31
|
+
lastActivityAccountIds: Iterable<string>;
|
|
32
|
+
lastInboundAccountIds: Iterable<string>;
|
|
33
|
+
lastOutboundAccountIds: Iterable<string>;
|
|
34
|
+
}): number {
|
|
35
|
+
const accountId = normalizeAccountId(args.accountId);
|
|
36
|
+
let count = 0;
|
|
37
|
+
|
|
38
|
+
for (const entry of args.outboxEntries) {
|
|
39
|
+
if (hasMismatchedAccountId(entry.accountId, accountId)) count += 1;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of args.deadLetterEntries) {
|
|
42
|
+
if (hasMismatchedAccountId(entry.accountId, accountId)) count += 1;
|
|
43
|
+
}
|
|
44
|
+
for (const info of args.sessionRoutes) {
|
|
45
|
+
if (hasMismatchedAccountId(info.accountId, accountId)) count += 1;
|
|
46
|
+
}
|
|
47
|
+
for (const key of args.lastSessionAccountIds) {
|
|
48
|
+
if (hasMismatchedAccountId(key, accountId)) count += 1;
|
|
49
|
+
}
|
|
50
|
+
for (const key of args.lastActivityAccountIds) {
|
|
51
|
+
if (hasMismatchedAccountId(key, accountId)) count += 1;
|
|
52
|
+
}
|
|
53
|
+
for (const key of args.lastInboundAccountIds) {
|
|
54
|
+
if (hasMismatchedAccountId(key, accountId)) count += 1;
|
|
55
|
+
}
|
|
56
|
+
for (const key of args.lastOutboundAccountIds) {
|
|
57
|
+
if (hasMismatchedAccountId(key, accountId)) count += 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return count;
|
|
61
|
+
}
|
package/src/core/diagnostics.ts
CHANGED
|
@@ -6,6 +6,10 @@ function finiteNumberOr(value: unknown, fallback: number): number {
|
|
|
6
6
|
return Number.isFinite(n) ? n : fallback;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
function nonNegativeFiniteNumberOr(value: unknown, fallback: number): number {
|
|
10
|
+
return Math.max(0, finiteNumberOr(value, fallback));
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
type DiagnosticsPayloadArgs = {
|
|
10
14
|
cfg: any;
|
|
11
15
|
channelId: string;
|
|
@@ -26,11 +30,11 @@ export function buildDiagnosticsPayload(args: DiagnosticsPayloadArgs) {
|
|
|
26
30
|
const probe = probeBncrAccount({
|
|
27
31
|
accountId: args.accountId,
|
|
28
32
|
connected: Boolean(args.runtime?.connected),
|
|
29
|
-
pending:
|
|
30
|
-
deadLetter:
|
|
31
|
-
activeConnections: args.activeConnections,
|
|
32
|
-
invalidOutboxSessionKeys: args.invalidOutboxSessionKeys,
|
|
33
|
-
legacyAccountResidue: args.legacyAccountResidue,
|
|
33
|
+
pending: nonNegativeFiniteNumberOr(args.runtime?.meta?.pending, 0),
|
|
34
|
+
deadLetter: nonNegativeFiniteNumberOr(args.runtime?.meta?.deadLetter, 0),
|
|
35
|
+
activeConnections: nonNegativeFiniteNumberOr(args.activeConnections, 0),
|
|
36
|
+
invalidOutboxSessionKeys: nonNegativeFiniteNumberOr(args.invalidOutboxSessionKeys, 0),
|
|
37
|
+
legacyAccountResidue: nonNegativeFiniteNumberOr(args.legacyAccountResidue, 0),
|
|
34
38
|
lastActivityAt: args.runtime?.meta?.lastActivityAt ?? null,
|
|
35
39
|
structure: {
|
|
36
40
|
coreComplete: true,
|
|
@@ -5,6 +5,10 @@ function finiteNumberOr(value: unknown, fallback: number): number {
|
|
|
5
5
|
return Number.isFinite(n) ? n : fallback;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
function nonNegativeFiniteNumberOr(value: unknown, fallback: number): number {
|
|
9
|
+
return Math.max(0, finiteNumberOr(value, fallback));
|
|
10
|
+
}
|
|
11
|
+
|
|
8
12
|
type DownlinkHealthInput = {
|
|
9
13
|
accountId: string;
|
|
10
14
|
now: number;
|
|
@@ -24,22 +28,23 @@ export function buildDownlinkHealth(input: DownlinkHealthInput) {
|
|
|
24
28
|
const oldestPendingCreatedAt = pending.length
|
|
25
29
|
? Math.min(...pending.map((entry) => finiteNumberOr(entry.createdAt, input.now)))
|
|
26
30
|
: null;
|
|
27
|
-
const oldestPendingAgeMs =
|
|
28
|
-
? Math.max(0, input.now - oldestPendingCreatedAt)
|
|
29
|
-
: 0;
|
|
31
|
+
const oldestPendingAgeMs =
|
|
32
|
+
oldestPendingCreatedAt !== null ? Math.max(0, input.now - oldestPendingCreatedAt) : 0;
|
|
30
33
|
const lastSignalAt =
|
|
31
|
-
Math.max(finiteNumberOr(input.lastInboundAt, 0), finiteNumberOr(input.lastActivityAt, 0)) ||
|
|
34
|
+
Math.max(finiteNumberOr(input.lastInboundAt, 0), finiteNumberOr(input.lastActivityAt, 0)) ||
|
|
35
|
+
null;
|
|
32
36
|
const inboundHealthy = !!lastSignalAt && input.now - lastSignalAt <= 5 * 60 * 1000;
|
|
33
|
-
const ackRecentlyHealthy =
|
|
34
|
-
!!input.lastAckOkAt && input.now - input.lastAckOkAt <= 5 * 60 * 1000;
|
|
37
|
+
const ackRecentlyHealthy = !!input.lastAckOkAt && input.now - input.lastAckOkAt <= 5 * 60 * 1000;
|
|
35
38
|
const ackTimeoutRecent =
|
|
36
39
|
!!input.lastAckTimeoutAt && input.now - input.lastAckTimeoutAt <= 5 * 60 * 1000;
|
|
40
|
+
const recentAckTimeoutCount = nonNegativeFiniteNumberOr(input.recentAckTimeoutCount, 0);
|
|
41
|
+
const activeConnectionCount = nonNegativeFiniteNumberOr(input.activeConnectionCount, 0);
|
|
37
42
|
const ackStalled =
|
|
38
43
|
pendingCount > 0 &&
|
|
39
|
-
|
|
44
|
+
activeConnectionCount === 1 &&
|
|
40
45
|
inboundHealthy &&
|
|
41
46
|
ackTimeoutRecent &&
|
|
42
|
-
|
|
47
|
+
recentAckTimeoutCount > 0 &&
|
|
43
48
|
!ackRecentlyHealthy;
|
|
44
49
|
|
|
45
50
|
return {
|
|
@@ -48,8 +53,8 @@ export function buildDownlinkHealth(input: DownlinkHealthInput) {
|
|
|
48
53
|
oldestPendingAgeMs,
|
|
49
54
|
lastAckOkAt: input.lastAckOkAt,
|
|
50
55
|
lastAckTimeoutAt: input.lastAckTimeoutAt,
|
|
51
|
-
recentAckTimeoutCount
|
|
52
|
-
activeConnectionCount
|
|
56
|
+
recentAckTimeoutCount,
|
|
57
|
+
activeConnectionCount,
|
|
53
58
|
recentInboundReachable: inboundHealthy,
|
|
54
59
|
onlineByConn: input.onlineByConn,
|
|
55
60
|
ackStalled,
|
|
@@ -22,6 +22,8 @@ type ExtendedDiagnosticsInput = {
|
|
|
22
22
|
traceSummary: Record<string, any>;
|
|
23
23
|
lastDriftSnapshot: any;
|
|
24
24
|
};
|
|
25
|
+
outbound?: Record<string, any>;
|
|
26
|
+
deadLetterSummary?: Record<string, any>;
|
|
25
27
|
connection: {
|
|
26
28
|
active: number;
|
|
27
29
|
primaryLeaseId: string | null;
|
|
@@ -66,6 +68,8 @@ export function buildExtendedDiagnostics(input: ExtendedDiagnosticsInput) {
|
|
|
66
68
|
...input.connection,
|
|
67
69
|
recent: input.connection.recent.map((entry) => ({ ...entry })),
|
|
68
70
|
},
|
|
71
|
+
outbound: input.outbound ? { ...input.outbound } : undefined,
|
|
72
|
+
deadLetterSummary: input.deadLetterSummary ? { ...input.deadLetterSummary } : undefined,
|
|
69
73
|
protocol: {
|
|
70
74
|
...input.protocol,
|
|
71
75
|
features: { ...input.protocol.features },
|
|
@@ -61,10 +61,7 @@ export function buildFileTransferAbortPayload(args: {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export function buildFileTransferCompletePayload(args: {
|
|
65
|
-
transferId: string;
|
|
66
|
-
ts: number;
|
|
67
|
-
}) {
|
|
64
|
+
export function buildFileTransferCompletePayload(args: { transferId: string; ts: number }) {
|
|
68
65
|
return {
|
|
69
66
|
transferId: args.transferId,
|
|
70
67
|
ts: args.ts,
|
package/src/core/logging.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
1
4
|
export type BncrLogLevel = 'info' | 'warn' | 'error';
|
|
2
5
|
export type BncrLogOptions = { debugOnly?: boolean };
|
|
3
6
|
|
|
4
7
|
const BNCR_PREFIX = '[bncr]';
|
|
8
|
+
const DEBUG_TEXT_PREVIEW_LIMIT = 24;
|
|
9
|
+
const DEBUG_HASH_LENGTH = 12;
|
|
10
|
+
|
|
11
|
+
const TEXT_PAYLOAD_KEYS = new Set([
|
|
12
|
+
'caption',
|
|
13
|
+
'fallbackText',
|
|
14
|
+
'messageText',
|
|
15
|
+
'msg',
|
|
16
|
+
'rawText',
|
|
17
|
+
'text',
|
|
18
|
+
]);
|
|
19
|
+
const MEDIA_PAYLOAD_KEYS = new Set(['mediaUrl', 'path']);
|
|
20
|
+
const MEDIA_LIST_PAYLOAD_KEYS = new Set(['mediaUrls', 'mediaList']);
|
|
21
|
+
const SENSITIVE_DEBUG_KEY_PATTERN =
|
|
22
|
+
/(?:authorization|cookie|password|secret|token|api[-_]?key|access[-_]?key|refresh[-_]?key)/i;
|
|
23
|
+
const REDACTED_DEBUG_VALUE = '[redacted]';
|
|
5
24
|
|
|
6
25
|
type DebugGate = () => boolean;
|
|
7
26
|
|
|
@@ -43,6 +62,85 @@ export function formatBncrLogLine(scope: string | undefined, message: string | u
|
|
|
43
62
|
return normalizedMessage ? `${prefix} ${normalizedMessage}` : prefix;
|
|
44
63
|
}
|
|
45
64
|
|
|
65
|
+
function shortHash(raw: string) {
|
|
66
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, DEBUG_HASH_LENGTH);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function summarizeDebugTextValue(raw: string) {
|
|
70
|
+
const compact = String(raw || '')
|
|
71
|
+
.replace(/\s+/g, ' ')
|
|
72
|
+
.trim();
|
|
73
|
+
return {
|
|
74
|
+
preview: summarizeBncrTextPreview(compact, DEBUG_TEXT_PREVIEW_LIMIT),
|
|
75
|
+
length: compact.length,
|
|
76
|
+
sha256: shortHash(compact),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mediaUrlBasename(raw: string) {
|
|
81
|
+
const value = String(raw || '').trim();
|
|
82
|
+
if (!value) return '';
|
|
83
|
+
try {
|
|
84
|
+
const parsed = new URL(value);
|
|
85
|
+
const decodedPath = decodeURIComponent(parsed.pathname || '');
|
|
86
|
+
return path.basename(decodedPath) || parsed.hostname || '';
|
|
87
|
+
} catch {
|
|
88
|
+
return path.basename(value.split(/[?#]/, 1)[0] || value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function summarizeDebugMediaValue(raw: string) {
|
|
93
|
+
const value = String(raw || '').trim();
|
|
94
|
+
if (!value) return '';
|
|
95
|
+
return {
|
|
96
|
+
basename: mediaUrlBasename(value),
|
|
97
|
+
scheme: /^[a-z][a-z0-9+.-]*:/i.exec(value)?.[0].slice(0, -1) || 'path',
|
|
98
|
+
sha256: shortHash(value),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sanitizeDebugValue(key: string, value: unknown): unknown {
|
|
103
|
+
if (SENSITIVE_DEBUG_KEY_PATTERN.test(key)) return REDACTED_DEBUG_VALUE;
|
|
104
|
+
if (typeof value === 'string') {
|
|
105
|
+
if (TEXT_PAYLOAD_KEYS.has(key)) return summarizeDebugTextValue(value);
|
|
106
|
+
if (MEDIA_PAYLOAD_KEYS.has(key)) return summarizeDebugMediaValue(value);
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
if (MEDIA_LIST_PAYLOAD_KEYS.has(key))
|
|
111
|
+
return value.map((item) => summarizeDebugMediaValue(String(item || '')));
|
|
112
|
+
return value.map((item) => sanitizeDebugValue('', item));
|
|
113
|
+
}
|
|
114
|
+
if (value && typeof value === 'object')
|
|
115
|
+
return sanitizeDebugPayload(value as Record<string, unknown>);
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function sanitizeBncrDebugPayload(payload: Record<string, unknown>) {
|
|
120
|
+
return sanitizeDebugPayload(payload);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sanitizeDebugPayload(payload: Record<string, unknown>) {
|
|
124
|
+
const sanitized: Record<string, unknown> = {};
|
|
125
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
126
|
+
sanitized[key] = sanitizeDebugValue(key, value);
|
|
127
|
+
}
|
|
128
|
+
return sanitized;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildBncrDebugJsonMessage(event: string, payload: Record<string, unknown>) {
|
|
132
|
+
return `${event} ${JSON.stringify(sanitizeBncrDebugPayload(payload))}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function summarizeBncrTextPreview(raw: string, limit = 8) {
|
|
136
|
+
const compact = String(raw || '')
|
|
137
|
+
.replace(/\s+/g, ' ')
|
|
138
|
+
.trim();
|
|
139
|
+
if (!compact) return '-';
|
|
140
|
+
const chars = Array.from(compact);
|
|
141
|
+
return chars.length > limit ? `${chars.slice(0, Math.max(1, limit)).join('')}…` : compact;
|
|
142
|
+
}
|
|
143
|
+
|
|
46
144
|
export function emitBncrLog(
|
|
47
145
|
level: BncrLogLevel,
|
|
48
146
|
scope: string | undefined,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OutboundReplyTargetPolicy } from '../messaging/outbound/reply-target-policy.ts';
|
|
1
2
|
import { normalizeOutboundReplyToId } from '../messaging/outbound/reply-target-policy.ts';
|
|
2
3
|
import type { BncrRoute, OutboxEntry } from './types.ts';
|
|
3
4
|
|
|
@@ -16,6 +17,7 @@ export function buildFileTransferOutboxEntry(args: {
|
|
|
16
17
|
audioAsVoice?: boolean;
|
|
17
18
|
kind?: 'tool' | 'block' | 'final';
|
|
18
19
|
replyToId?: string;
|
|
20
|
+
replyTargetPolicy?: OutboundReplyTargetPolicy;
|
|
19
21
|
}): OutboxEntry {
|
|
20
22
|
const messageId = args.createMessageId();
|
|
21
23
|
const createdAt = args.now();
|
|
@@ -35,7 +37,12 @@ export function buildFileTransferOutboxEntry(args: {
|
|
|
35
37
|
asVoice: args.asVoice === true,
|
|
36
38
|
audioAsVoice: args.audioAsVoice === true,
|
|
37
39
|
finalEvent: args.pushEvent,
|
|
38
|
-
replyToId:
|
|
40
|
+
replyToId:
|
|
41
|
+
normalizeOutboundReplyToId({
|
|
42
|
+
kind: args.kind,
|
|
43
|
+
replyToId: args.replyToId,
|
|
44
|
+
replyTargetPolicy: args.replyTargetPolicy,
|
|
45
|
+
}) || undefined,
|
|
39
46
|
messageKind: args.kind,
|
|
40
47
|
},
|
|
41
48
|
},
|
|
@@ -56,6 +63,7 @@ export function buildTextOutboxEntry(args: {
|
|
|
56
63
|
text: string;
|
|
57
64
|
kind?: 'tool' | 'block' | 'final';
|
|
58
65
|
replyToId?: string;
|
|
66
|
+
replyTargetPolicy?: OutboundReplyTargetPolicy;
|
|
59
67
|
}): OutboxEntry {
|
|
60
68
|
const messageId = args.createMessageId();
|
|
61
69
|
const createdAt = args.now();
|
|
@@ -64,7 +72,12 @@ export function buildTextOutboxEntry(args: {
|
|
|
64
72
|
messageId,
|
|
65
73
|
idempotencyKey: messageId,
|
|
66
74
|
sessionKey: args.sessionKey,
|
|
67
|
-
replyToId:
|
|
75
|
+
replyToId:
|
|
76
|
+
normalizeOutboundReplyToId({
|
|
77
|
+
kind: args.kind,
|
|
78
|
+
replyToId: args.replyToId,
|
|
79
|
+
replyTargetPolicy: args.replyTargetPolicy,
|
|
80
|
+
}) || undefined,
|
|
68
81
|
message: {
|
|
69
82
|
platform: args.route.platform,
|
|
70
83
|
groupId: args.route.groupId,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { OutboxEntry } from './types.ts';
|
|
2
1
|
import { buildPushFailureArgs } from './outbox-push-args.ts';
|
|
2
|
+
import type { OutboxEntry } from './types.ts';
|
|
3
3
|
|
|
4
4
|
export function resolveFileTransferFailureState(args: {
|
|
5
5
|
entry: OutboxEntry;
|
|
@@ -13,10 +13,7 @@ export function resolveFileTransferFailureState(args: {
|
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function buildFileTransferPushFailureArgs(args: {
|
|
17
|
-
entry: OutboxEntry;
|
|
18
|
-
retryable: boolean;
|
|
19
|
-
}) {
|
|
16
|
+
export function buildFileTransferPushFailureArgs(args: { entry: OutboxEntry; retryable: boolean }) {
|
|
20
17
|
return buildPushFailureArgs({
|
|
21
18
|
entry: args.entry,
|
|
22
19
|
retryable: args.retryable,
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
+
import { buildPushBroadcastPayload, buildPushRouteSelectArgs } from './outbox-push-args.ts';
|
|
1
2
|
import type { BncrConnection, OutboxEntry } from './types.ts';
|
|
2
|
-
import {
|
|
3
|
-
buildPushBroadcastPayload,
|
|
4
|
-
buildPushRouteSelectArgs,
|
|
5
|
-
} from './outbox-push-args.ts';
|
|
6
3
|
|
|
7
4
|
export function buildFileTransferBroadcastPayload(args: {
|
|
8
5
|
frame: Record<string, unknown>;
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import type { OutboxEntry } from './types.ts';
|
|
2
1
|
import { buildPushFailureArgs } from './outbox-push-args.ts';
|
|
2
|
+
import type { OutboxEntry } from './types.ts';
|
|
3
3
|
|
|
4
|
-
export function buildTextPushFailureArgs(args: {
|
|
5
|
-
entry: OutboxEntry;
|
|
6
|
-
}) {
|
|
4
|
+
export function buildTextPushFailureArgs(args: { entry: OutboxEntry }) {
|
|
7
5
|
return buildPushFailureArgs({
|
|
8
6
|
entry: args.entry,
|
|
9
7
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { BncrConnection, OutboxEntry } from './types.ts';
|
|
2
1
|
import {
|
|
3
2
|
buildPushBroadcastPayload,
|
|
4
3
|
buildPushOkArgs,
|
|
5
4
|
buildPushRouteSelectArgs,
|
|
6
5
|
} from './outbox-push-args.ts';
|
|
6
|
+
import type { BncrConnection, OutboxEntry } from './types.ts';
|
|
7
7
|
|
|
8
8
|
export function buildTextPushBroadcastPayload(args: {
|
|
9
9
|
payload: Record<string, unknown>;
|