@xmoxmo/bncr 0.2.4 → 0.2.6
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 +68 -5
- package/package.json +1 -1
- package/src/channel.ts +2985 -1706
- package/src/core/connection-capability.ts +70 -0
- package/src/core/connection-reachability.ts +168 -0
- package/src/core/diagnostics.ts +54 -0
- package/src/core/downlink-health.ts +61 -0
- package/src/core/extended-diagnostics.ts +65 -0
- package/src/core/lease-state.ts +94 -0
- package/src/core/outbox-enqueue.ts +22 -0
- package/src/core/outbox-entry-builders.ts +92 -0
- package/src/core/outbox-file-transfer-bookkeeping.ts +31 -0
- package/src/core/outbox-file-transfer-failure.ts +25 -0
- package/src/core/outbox-file-transfer-guards.ts +66 -0
- package/src/core/outbox-file-transfer-prep.ts +31 -0
- package/src/core/outbox-file-transfer-success.ts +34 -0
- package/src/core/outbox-push-args.ts +67 -0
- package/src/core/outbox-queue.ts +69 -0
- package/src/core/outbox-summary.ts +14 -0
- package/src/core/outbox-text-push-failure.ts +10 -0
- package/src/core/outbox-text-push-guards.ts +51 -0
- package/src/core/outbox-text-push-prep.ts +36 -0
- package/src/core/outbox-text-push-success.ts +62 -0
- package/src/core/policy.ts +9 -0
- package/src/core/register-trace.ts +115 -0
- package/src/core/status.ts +57 -0
- package/src/core/types.ts +1 -0
- package/src/messaging/inbound/commands.ts +318 -75
- package/src/messaging/inbound/dispatch.ts +435 -139
- package/src/messaging/inbound/parse.ts +8 -0
- package/src/messaging/inbound/session-label.ts +115 -0
- package/src/messaging/outbound/diagnostics.ts +262 -0
- package/src/messaging/outbound/media-dedupe.ts +51 -0
- package/src/messaging/outbound/media.ts +3 -1
- package/src/messaging/outbound/queue-selectors.ts +191 -0
- package/src/messaging/outbound/reasons.ts +52 -0
- package/src/messaging/outbound/reply-enqueue.ts +329 -0
- package/src/messaging/outbound/reply-target-policy.ts +13 -0
- package/src/messaging/outbound/retry-policy.ts +142 -0
- package/src/messaging/outbound/send.ts +6 -0
- package/src/messaging/outbound/session-route.ts +34 -5
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { BncrConnection } from './types.ts';
|
|
2
|
+
|
|
3
|
+
type CapabilityConnection = BncrConnection & {
|
|
4
|
+
outboundReadyUntil?: number;
|
|
5
|
+
preferredForOutboundUntil?: number;
|
|
6
|
+
inboundOnly?: boolean;
|
|
7
|
+
lastAckOkAt?: number;
|
|
8
|
+
lastPushTimeoutAt?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function applyOutboundCapability(args: {
|
|
12
|
+
connection: CapabilityConnection;
|
|
13
|
+
at: number;
|
|
14
|
+
outboundReadyTtlMs: number;
|
|
15
|
+
preferredOutboundTtlMs: number;
|
|
16
|
+
outboundReady?: boolean;
|
|
17
|
+
preferredForOutbound?: boolean;
|
|
18
|
+
inboundOnly?: boolean;
|
|
19
|
+
}) {
|
|
20
|
+
const next: CapabilityConnection = { ...args.connection };
|
|
21
|
+
|
|
22
|
+
if (args.inboundOnly === true) {
|
|
23
|
+
next.inboundOnly = true;
|
|
24
|
+
next.outboundReadyUntil = undefined;
|
|
25
|
+
next.preferredForOutboundUntil = undefined;
|
|
26
|
+
} else {
|
|
27
|
+
if (typeof args.inboundOnly === 'boolean') next.inboundOnly = false;
|
|
28
|
+
if (args.outboundReady === true || args.preferredForOutbound === true) {
|
|
29
|
+
next.outboundReadyUntil = args.at + args.outboundReadyTtlMs;
|
|
30
|
+
}
|
|
31
|
+
if (args.preferredForOutbound === true) {
|
|
32
|
+
next.preferredForOutboundUntil = args.at + args.preferredOutboundTtlMs;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findCapabilityConnection(args: {
|
|
40
|
+
accountId: string;
|
|
41
|
+
connId?: string;
|
|
42
|
+
clientId?: string;
|
|
43
|
+
connections: Iterable<[string, BncrConnection]>;
|
|
44
|
+
}) {
|
|
45
|
+
for (const [key, conn] of args.connections) {
|
|
46
|
+
if (conn.accountId !== args.accountId) continue;
|
|
47
|
+
if (args.connId && conn.connId !== args.connId) continue;
|
|
48
|
+
if (args.clientId && conn.clientId !== args.clientId) continue;
|
|
49
|
+
return {
|
|
50
|
+
key,
|
|
51
|
+
connection: conn as CapabilityConnection,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildCapabilitySnapshot(connection: CapabilityConnection) {
|
|
58
|
+
return {
|
|
59
|
+
outboundReadyUntil: connection.outboundReadyUntil || null,
|
|
60
|
+
preferredForOutboundUntil: connection.preferredForOutboundUntil || null,
|
|
61
|
+
inboundOnly: connection.inboundOnly === true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function clearOutboundCapability(connection: CapabilityConnection) {
|
|
66
|
+
const next: CapabilityConnection = { ...connection };
|
|
67
|
+
next.outboundReadyUntil = undefined;
|
|
68
|
+
next.preferredForOutboundUntil = undefined;
|
|
69
|
+
return next;
|
|
70
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { BncrConnection, OutboxEntry } from './types.ts';
|
|
2
|
+
|
|
3
|
+
function finiteNumberOr(value: unknown, fallback: number): number {
|
|
4
|
+
const n = Number(value);
|
|
5
|
+
return Number.isFinite(n) ? n : fallback;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function hasRecentInboundReachability(args: {
|
|
9
|
+
now: number;
|
|
10
|
+
windowMs: number;
|
|
11
|
+
lastInboundAt: number;
|
|
12
|
+
lastActivityAt: number;
|
|
13
|
+
}) {
|
|
14
|
+
const nowMs = finiteNumberOr(args.now, 0);
|
|
15
|
+
const windowMs = finiteNumberOr(args.windowMs, 0);
|
|
16
|
+
const lastReachableAt = Math.max(
|
|
17
|
+
finiteNumberOr(args.lastInboundAt, 0),
|
|
18
|
+
finiteNumberOr(args.lastActivityAt, 0),
|
|
19
|
+
);
|
|
20
|
+
return nowMs > 0 && windowMs > 0 && lastReachableAt > 0 && nowMs - lastReachableAt <= windowMs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveRecentInboundConnIds(args: {
|
|
24
|
+
accountId: string;
|
|
25
|
+
now: number;
|
|
26
|
+
connectTtlMs: number;
|
|
27
|
+
recentInboundReachable: boolean;
|
|
28
|
+
connections: Iterable<BncrConnection>;
|
|
29
|
+
}) {
|
|
30
|
+
const connIds = new Set<string>();
|
|
31
|
+
if (!args.recentInboundReachable) return connIds;
|
|
32
|
+
|
|
33
|
+
const nowMs = finiteNumberOr(args.now, 0);
|
|
34
|
+
const connectTtlMs = finiteNumberOr(args.connectTtlMs, 0);
|
|
35
|
+
if (!nowMs || !connectTtlMs) return connIds;
|
|
36
|
+
|
|
37
|
+
for (const c of args.connections) {
|
|
38
|
+
if (c.accountId !== args.accountId) continue;
|
|
39
|
+
if (!c.connId) continue;
|
|
40
|
+
const lastSeenAt = finiteNumberOr(c.lastSeenAt, 0);
|
|
41
|
+
if (!lastSeenAt) continue;
|
|
42
|
+
if (nowMs - lastSeenAt > connectTtlMs * 2) continue;
|
|
43
|
+
connIds.add(c.connId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return connIds;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isRecentlyReachableConn(args: {
|
|
50
|
+
accountId: string;
|
|
51
|
+
connId?: string;
|
|
52
|
+
clientId?: string;
|
|
53
|
+
recentConnIds: Set<string>;
|
|
54
|
+
activeConnection?: BncrConnection | null;
|
|
55
|
+
}) {
|
|
56
|
+
const cid = String(args.connId || '').trim();
|
|
57
|
+
const client = String(args.clientId || '').trim() || undefined;
|
|
58
|
+
if (!cid) return false;
|
|
59
|
+
if (args.recentConnIds.has(cid)) return true;
|
|
60
|
+
|
|
61
|
+
const active = args.activeConnection;
|
|
62
|
+
if (!active?.connId) return false;
|
|
63
|
+
if (active.accountId !== args.accountId) return false;
|
|
64
|
+
if (active.connId !== cid) return false;
|
|
65
|
+
if (client && active.clientId && active.clientId !== client) return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function hasAlternativeLiveConnection(args: {
|
|
70
|
+
accountId: string;
|
|
71
|
+
now: number;
|
|
72
|
+
connectTtlMs: number;
|
|
73
|
+
currentConnId?: string;
|
|
74
|
+
currentClientId?: string;
|
|
75
|
+
connections: Iterable<BncrConnection>;
|
|
76
|
+
}) {
|
|
77
|
+
const currentConn = String(args.currentConnId || '').trim();
|
|
78
|
+
const currentClient = String(args.currentClientId || '').trim() || undefined;
|
|
79
|
+
const nowMs = finiteNumberOr(args.now, 0);
|
|
80
|
+
const connectTtlMs = finiteNumberOr(args.connectTtlMs, 0);
|
|
81
|
+
if (!nowMs || !connectTtlMs) return false;
|
|
82
|
+
|
|
83
|
+
for (const conn of args.connections) {
|
|
84
|
+
if (conn.accountId !== args.accountId) continue;
|
|
85
|
+
if (!conn.connId) continue;
|
|
86
|
+
const lastSeenAt = finiteNumberOr(conn.lastSeenAt, 0);
|
|
87
|
+
if (!lastSeenAt) continue;
|
|
88
|
+
if (nowMs - lastSeenAt > connectTtlMs) continue;
|
|
89
|
+
const sameConn = !!currentConn && conn.connId === currentConn;
|
|
90
|
+
const sameClient = !currentConn && !!currentClient && conn.clientId === currentClient;
|
|
91
|
+
if (sameConn || sameClient) continue;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type ReachableConnection = BncrConnection & {
|
|
98
|
+
inboundOnly?: boolean;
|
|
99
|
+
preferredForOutboundUntil?: number;
|
|
100
|
+
outboundReadyUntil?: number;
|
|
101
|
+
lastAckOkAt?: number;
|
|
102
|
+
lastPushTimeoutAt?: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function getRevalidatedAttemptReason(args: {
|
|
106
|
+
entry: OutboxEntry;
|
|
107
|
+
connId: string;
|
|
108
|
+
accountId: string;
|
|
109
|
+
now: number;
|
|
110
|
+
connectTtlMs: number;
|
|
111
|
+
recentInboundReachable: boolean;
|
|
112
|
+
connections: Iterable<BncrConnection>;
|
|
113
|
+
}) {
|
|
114
|
+
const targetConnId = String(args.connId || '').trim();
|
|
115
|
+
if (!targetConnId) return null;
|
|
116
|
+
|
|
117
|
+
const nowMs = finiteNumberOr(args.now, 0);
|
|
118
|
+
const connectTtlMs = finiteNumberOr(args.connectTtlMs, 0);
|
|
119
|
+
if (!nowMs || !connectTtlMs) return null;
|
|
120
|
+
|
|
121
|
+
const lastAttemptAt = finiteNumberOr(args.entry.lastAttemptAt, 0);
|
|
122
|
+
for (const rawConn of args.connections) {
|
|
123
|
+
const conn = rawConn as ReachableConnection;
|
|
124
|
+
if (conn.accountId !== args.accountId) continue;
|
|
125
|
+
if (conn.connId !== targetConnId) continue;
|
|
126
|
+
const lastSeenAt = finiteNumberOr(conn.lastSeenAt, 0);
|
|
127
|
+
if (!lastSeenAt) continue;
|
|
128
|
+
if (nowMs - lastSeenAt > connectTtlMs) continue;
|
|
129
|
+
if (conn.inboundOnly === true) continue;
|
|
130
|
+
|
|
131
|
+
const preferredForOutboundUntil = finiteNumberOr(conn.preferredForOutboundUntil, 0);
|
|
132
|
+
const outboundReadyUntil = finiteNumberOr(conn.outboundReadyUntil, 0);
|
|
133
|
+
const lastAckOkAt = finiteNumberOr(conn.lastAckOkAt, 0);
|
|
134
|
+
const lastPushTimeoutAt = finiteNumberOr(conn.lastPushTimeoutAt, 0);
|
|
135
|
+
|
|
136
|
+
const revalidatedByPreferred = preferredForOutboundUntil > nowMs;
|
|
137
|
+
const revalidatedByReady = outboundReadyUntil > nowMs;
|
|
138
|
+
const revalidatedByAck = lastAckOkAt > 0 && lastAckOkAt > lastAttemptAt;
|
|
139
|
+
const revalidatedByFreshReachability =
|
|
140
|
+
args.recentInboundReachable &&
|
|
141
|
+
lastPushTimeoutAt > 0 &&
|
|
142
|
+
lastPushTimeoutAt <= lastAttemptAt &&
|
|
143
|
+
lastSeenAt > lastPushTimeoutAt;
|
|
144
|
+
|
|
145
|
+
if (!revalidatedByPreferred && !revalidatedByReady && !revalidatedByAck && !revalidatedByFreshReachability) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
reason: revalidatedByAck
|
|
151
|
+
? 'ack-after-last-attempt'
|
|
152
|
+
: revalidatedByPreferred
|
|
153
|
+
? 'preferred-ttl'
|
|
154
|
+
: revalidatedByReady
|
|
155
|
+
? 'ready-ttl'
|
|
156
|
+
: 'fresh-reachability',
|
|
157
|
+
lastAttemptAt,
|
|
158
|
+
lastAckOkAt: lastAckOkAt || null,
|
|
159
|
+
lastPushTimeoutAt: lastPushTimeoutAt || null,
|
|
160
|
+
outboundReadyUntil: outboundReadyUntil || null,
|
|
161
|
+
preferredForOutboundUntil: preferredForOutboundUntil || null,
|
|
162
|
+
lastSeenAt,
|
|
163
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { buildBncrPermissionSummary } from './permissions.ts';
|
|
2
|
+
import { probeBncrAccount } from './probe.ts';
|
|
3
|
+
|
|
4
|
+
function finiteNumberOr(value: unknown, fallback: number): number {
|
|
5
|
+
const n = Number(value);
|
|
6
|
+
return Number.isFinite(n) ? n : fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type DiagnosticsPayloadArgs = {
|
|
10
|
+
cfg: any;
|
|
11
|
+
channelId: string;
|
|
12
|
+
accountId: string;
|
|
13
|
+
runtime: any;
|
|
14
|
+
diagnostics: any;
|
|
15
|
+
downlinkHealth: any;
|
|
16
|
+
runtimeFlags: any;
|
|
17
|
+
waiters: { messageAck: number; fileAck: number };
|
|
18
|
+
activeConnections: number;
|
|
19
|
+
invalidOutboxSessionKeys: number;
|
|
20
|
+
legacyAccountResidue: number;
|
|
21
|
+
now: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function buildDiagnosticsPayload(args: DiagnosticsPayloadArgs) {
|
|
25
|
+
const permissions = buildBncrPermissionSummary(args.cfg ?? {});
|
|
26
|
+
const probe = probeBncrAccount({
|
|
27
|
+
accountId: args.accountId,
|
|
28
|
+
connected: Boolean(args.runtime?.connected),
|
|
29
|
+
pending: finiteNumberOr(args.runtime?.meta?.pending, 0),
|
|
30
|
+
deadLetter: finiteNumberOr(args.runtime?.meta?.deadLetter, 0),
|
|
31
|
+
activeConnections: args.activeConnections,
|
|
32
|
+
invalidOutboxSessionKeys: args.invalidOutboxSessionKeys,
|
|
33
|
+
legacyAccountResidue: args.legacyAccountResidue,
|
|
34
|
+
lastActivityAt: args.runtime?.meta?.lastActivityAt ?? null,
|
|
35
|
+
structure: {
|
|
36
|
+
coreComplete: true,
|
|
37
|
+
inboundComplete: true,
|
|
38
|
+
outboundComplete: true,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
channel: args.channelId,
|
|
44
|
+
accountId: args.accountId,
|
|
45
|
+
runtime: args.runtime,
|
|
46
|
+
diagnostics: args.diagnostics,
|
|
47
|
+
downlinkHealth: args.downlinkHealth,
|
|
48
|
+
runtimeFlags: args.runtimeFlags,
|
|
49
|
+
waiters: args.waiters,
|
|
50
|
+
permissions,
|
|
51
|
+
probe,
|
|
52
|
+
now: args.now,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { OutboxEntry } from './types.ts';
|
|
2
|
+
|
|
3
|
+
function finiteNumberOr(value: unknown, fallback: number): number {
|
|
4
|
+
const n = Number(value);
|
|
5
|
+
return Number.isFinite(n) ? n : fallback;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type DownlinkHealthInput = {
|
|
9
|
+
accountId: string;
|
|
10
|
+
now: number;
|
|
11
|
+
outboxEntries: Iterable<OutboxEntry>;
|
|
12
|
+
lastAckOkAt: number | null;
|
|
13
|
+
lastAckTimeoutAt: number | null;
|
|
14
|
+
recentAckTimeoutCount: number;
|
|
15
|
+
activeConnectionCount: number;
|
|
16
|
+
lastInboundAt: number | null;
|
|
17
|
+
lastActivityAt: number | null;
|
|
18
|
+
onlineByConn: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function buildDownlinkHealth(input: DownlinkHealthInput) {
|
|
22
|
+
const pending = Array.from(input.outboxEntries).filter((v) => v.accountId === input.accountId);
|
|
23
|
+
const pendingCount = pending.length;
|
|
24
|
+
const oldestPendingCreatedAt = pending.length
|
|
25
|
+
? Math.min(...pending.map((entry) => finiteNumberOr(entry.createdAt, input.now)))
|
|
26
|
+
: null;
|
|
27
|
+
const oldestPendingAgeMs = oldestPendingCreatedAt
|
|
28
|
+
? Math.max(0, input.now - oldestPendingCreatedAt)
|
|
29
|
+
: 0;
|
|
30
|
+
const lastSignalAt =
|
|
31
|
+
Math.max(finiteNumberOr(input.lastInboundAt, 0), finiteNumberOr(input.lastActivityAt, 0)) || null;
|
|
32
|
+
const inboundHealthy = !!lastSignalAt && input.now - lastSignalAt <= 5 * 60 * 1000;
|
|
33
|
+
const ackRecentlyHealthy =
|
|
34
|
+
!!input.lastAckOkAt && input.now - input.lastAckOkAt <= 5 * 60 * 1000;
|
|
35
|
+
const ackTimeoutRecent =
|
|
36
|
+
!!input.lastAckTimeoutAt && input.now - input.lastAckTimeoutAt <= 5 * 60 * 1000;
|
|
37
|
+
const ackStalled =
|
|
38
|
+
pendingCount > 0 &&
|
|
39
|
+
input.activeConnectionCount === 1 &&
|
|
40
|
+
inboundHealthy &&
|
|
41
|
+
ackTimeoutRecent &&
|
|
42
|
+
input.recentAckTimeoutCount > 0 &&
|
|
43
|
+
!ackRecentlyHealthy;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
pendingOutbox: pendingCount,
|
|
47
|
+
oldestPendingCreatedAt,
|
|
48
|
+
oldestPendingAgeMs,
|
|
49
|
+
lastAckOkAt: input.lastAckOkAt,
|
|
50
|
+
lastAckTimeoutAt: input.lastAckTimeoutAt,
|
|
51
|
+
recentAckTimeoutCount: input.recentAckTimeoutCount,
|
|
52
|
+
activeConnectionCount: input.activeConnectionCount,
|
|
53
|
+
recentInboundReachable: inboundHealthy,
|
|
54
|
+
onlineByConn: input.onlineByConn,
|
|
55
|
+
ackStalled,
|
|
56
|
+
recommendReconnect: ackStalled,
|
|
57
|
+
recommendReason: ackStalled
|
|
58
|
+
? 'single-conn pending outbox with recent ack timeout and no recent ack-ok while inbound/activity is still alive'
|
|
59
|
+
: '',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { RegisterTraceEntry } from './register-trace.ts';
|
|
2
|
+
|
|
3
|
+
type ExtendedDiagnosticsInput = {
|
|
4
|
+
diagnostics: Record<string, any>;
|
|
5
|
+
register: {
|
|
6
|
+
bridgeId: string;
|
|
7
|
+
gatewayPid: number;
|
|
8
|
+
pluginVersion: string | null;
|
|
9
|
+
source: string | null;
|
|
10
|
+
apiInstanceId: string | null;
|
|
11
|
+
registryFingerprint: string | null;
|
|
12
|
+
registerCount: number;
|
|
13
|
+
firstRegisterAt: number | null;
|
|
14
|
+
lastRegisterAt: number | null;
|
|
15
|
+
lastApiRebindAt: number | null;
|
|
16
|
+
apiGeneration: number;
|
|
17
|
+
traceRecent: RegisterTraceEntry[];
|
|
18
|
+
traceSummary: Record<string, any>;
|
|
19
|
+
lastDriftSnapshot: any;
|
|
20
|
+
};
|
|
21
|
+
connection: {
|
|
22
|
+
active: number;
|
|
23
|
+
primaryLeaseId: string | null;
|
|
24
|
+
primaryEpoch: number | null;
|
|
25
|
+
acceptedConnections: number;
|
|
26
|
+
lastConnectAt: number | null;
|
|
27
|
+
lastDisconnectAt: number | null;
|
|
28
|
+
lastActivityAt: number | null;
|
|
29
|
+
lastInboundAt: number | null;
|
|
30
|
+
lastAckAt: number | null;
|
|
31
|
+
recent: Array<{
|
|
32
|
+
leaseId: string;
|
|
33
|
+
epoch: number;
|
|
34
|
+
connectedAt: number;
|
|
35
|
+
lastActivityAt: number | null;
|
|
36
|
+
isPrimary: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
};
|
|
39
|
+
protocol: {
|
|
40
|
+
bridgeVersion: number;
|
|
41
|
+
protocolVersion: number;
|
|
42
|
+
minClientProtocol: number;
|
|
43
|
+
features: Record<string, boolean>;
|
|
44
|
+
};
|
|
45
|
+
stale: Record<string, any>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function buildExtendedDiagnostics(input: ExtendedDiagnosticsInput) {
|
|
49
|
+
return {
|
|
50
|
+
...input.diagnostics,
|
|
51
|
+
register: {
|
|
52
|
+
...input.register,
|
|
53
|
+
traceRecent: input.register.traceRecent.slice(),
|
|
54
|
+
},
|
|
55
|
+
connection: {
|
|
56
|
+
...input.connection,
|
|
57
|
+
recent: input.connection.recent.map((entry) => ({ ...entry })),
|
|
58
|
+
},
|
|
59
|
+
protocol: {
|
|
60
|
+
...input.protocol,
|
|
61
|
+
features: { ...input.protocol.features },
|
|
62
|
+
},
|
|
63
|
+
stale: { ...input.stale },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export type LeaseEventKind =
|
|
2
|
+
| 'connect'
|
|
3
|
+
| 'inbound'
|
|
4
|
+
| 'activity'
|
|
5
|
+
| 'ack'
|
|
6
|
+
| 'file.init'
|
|
7
|
+
| 'file.chunk'
|
|
8
|
+
| 'file.complete'
|
|
9
|
+
| 'file.abort';
|
|
10
|
+
|
|
11
|
+
export type LeaseObservationReason = 'missing' | 'ok' | 'mismatch';
|
|
12
|
+
|
|
13
|
+
export type LeaseObservationResult = {
|
|
14
|
+
stale: boolean;
|
|
15
|
+
reason: LeaseObservationReason;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type StaleCounters = {
|
|
19
|
+
staleConnect: number;
|
|
20
|
+
staleInbound: number;
|
|
21
|
+
staleActivity: number;
|
|
22
|
+
staleAck: number;
|
|
23
|
+
staleFileInit: number;
|
|
24
|
+
staleFileChunk: number;
|
|
25
|
+
staleFileComplete: number;
|
|
26
|
+
staleFileAbort: number;
|
|
27
|
+
lastStaleAt: number | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function observeLeaseState(args: {
|
|
31
|
+
kind: LeaseEventKind;
|
|
32
|
+
params: { leaseId?: string; connectionEpoch?: number };
|
|
33
|
+
currentLeaseId: string | null;
|
|
34
|
+
currentConnectionEpoch: number;
|
|
35
|
+
now: number;
|
|
36
|
+
staleCounters: StaleCounters;
|
|
37
|
+
}): LeaseObservationResult {
|
|
38
|
+
const leaseId = typeof args.params.leaseId === 'string' ? args.params.leaseId.trim() : '';
|
|
39
|
+
const connectionEpoch =
|
|
40
|
+
typeof args.params.connectionEpoch === 'number' ? args.params.connectionEpoch : undefined;
|
|
41
|
+
if (!leaseId && connectionEpoch == null) return { stale: false, reason: 'missing' };
|
|
42
|
+
|
|
43
|
+
const staleByLease = !!leaseId && args.currentLeaseId != null && leaseId !== args.currentLeaseId;
|
|
44
|
+
const staleByEpoch =
|
|
45
|
+
connectionEpoch != null &&
|
|
46
|
+
args.currentConnectionEpoch > 0 &&
|
|
47
|
+
connectionEpoch !== args.currentConnectionEpoch;
|
|
48
|
+
const stale = staleByLease || staleByEpoch;
|
|
49
|
+
if (!stale) return { stale: false, reason: 'ok' };
|
|
50
|
+
|
|
51
|
+
args.staleCounters.lastStaleAt = args.now;
|
|
52
|
+
switch (args.kind) {
|
|
53
|
+
case 'connect':
|
|
54
|
+
args.staleCounters.staleConnect += 1;
|
|
55
|
+
break;
|
|
56
|
+
case 'inbound':
|
|
57
|
+
args.staleCounters.staleInbound += 1;
|
|
58
|
+
break;
|
|
59
|
+
case 'activity':
|
|
60
|
+
args.staleCounters.staleActivity += 1;
|
|
61
|
+
break;
|
|
62
|
+
case 'ack':
|
|
63
|
+
args.staleCounters.staleAck += 1;
|
|
64
|
+
break;
|
|
65
|
+
case 'file.init':
|
|
66
|
+
args.staleCounters.staleFileInit += 1;
|
|
67
|
+
break;
|
|
68
|
+
case 'file.chunk':
|
|
69
|
+
args.staleCounters.staleFileChunk += 1;
|
|
70
|
+
break;
|
|
71
|
+
case 'file.complete':
|
|
72
|
+
args.staleCounters.staleFileComplete += 1;
|
|
73
|
+
break;
|
|
74
|
+
case 'file.abort':
|
|
75
|
+
args.staleCounters.staleFileAbort += 1;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
return { stale: true, reason: 'mismatch' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function matchesTransferOwner(args: {
|
|
82
|
+
ownerConnId?: string;
|
|
83
|
+
ownerClientId?: string;
|
|
84
|
+
connId: string;
|
|
85
|
+
clientId?: string;
|
|
86
|
+
}) {
|
|
87
|
+
const sameConn = !!args.ownerConnId && args.ownerConnId === args.connId;
|
|
88
|
+
const sameClient =
|
|
89
|
+
!args.ownerConnId &&
|
|
90
|
+
!!args.ownerClientId &&
|
|
91
|
+
!!args.clientId &&
|
|
92
|
+
args.ownerClientId === args.clientId;
|
|
93
|
+
return sameConn || sameClient;
|
|
94
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OutboxEntry } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export function buildOutboxEnqueueDebugInfo(args: {
|
|
4
|
+
bridgeId: string;
|
|
5
|
+
entry: OutboxEntry;
|
|
6
|
+
asString: (value: unknown) => string;
|
|
7
|
+
formatDisplayScope: (route: OutboxEntry['route']) => string;
|
|
8
|
+
}) {
|
|
9
|
+
const msg = (args.entry.payload as any)?.message || {};
|
|
10
|
+
const type = args.asString(msg.type || (args.entry.payload as any)?.type || 'unknown');
|
|
11
|
+
const text = args.asString(msg.msg || '');
|
|
12
|
+
return {
|
|
13
|
+
bridge: args.bridgeId,
|
|
14
|
+
messageId: args.entry.messageId,
|
|
15
|
+
accountId: args.entry.accountId,
|
|
16
|
+
sessionKey: args.entry.sessionKey,
|
|
17
|
+
scope: args.formatDisplayScope(args.entry.route),
|
|
18
|
+
type,
|
|
19
|
+
textLen: text.length,
|
|
20
|
+
textPreview: text.slice(0, 120),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { normalizeOutboundReplyToId } from '../messaging/outbound/reply-target-policy.ts';
|
|
2
|
+
import type { BncrRoute, OutboxEntry } from './types.ts';
|
|
3
|
+
|
|
4
|
+
export function buildFileTransferOutboxEntry(args: {
|
|
5
|
+
createMessageId: () => string;
|
|
6
|
+
now: () => number;
|
|
7
|
+
normalizeAccountId: (accountId?: string | null) => string;
|
|
8
|
+
pushEvent: string;
|
|
9
|
+
accountId: string;
|
|
10
|
+
sessionKey: string;
|
|
11
|
+
route: BncrRoute;
|
|
12
|
+
mediaUrl: string;
|
|
13
|
+
mediaLocalRoots?: readonly string[];
|
|
14
|
+
text: string;
|
|
15
|
+
asVoice?: boolean;
|
|
16
|
+
audioAsVoice?: boolean;
|
|
17
|
+
kind?: 'tool' | 'block' | 'final';
|
|
18
|
+
replyToId?: string;
|
|
19
|
+
}): OutboxEntry {
|
|
20
|
+
const messageId = args.createMessageId();
|
|
21
|
+
const createdAt = args.now();
|
|
22
|
+
return {
|
|
23
|
+
messageId,
|
|
24
|
+
accountId: args.normalizeAccountId(args.accountId),
|
|
25
|
+
sessionKey: args.sessionKey,
|
|
26
|
+
route: args.route,
|
|
27
|
+
payload: {
|
|
28
|
+
type: 'message.outbound',
|
|
29
|
+
sessionKey: args.sessionKey,
|
|
30
|
+
_meta: {
|
|
31
|
+
kind: 'file-transfer',
|
|
32
|
+
mediaUrl: args.mediaUrl,
|
|
33
|
+
mediaLocalRoots: args.mediaLocalRoots ? Array.from(args.mediaLocalRoots) : undefined,
|
|
34
|
+
text: args.text,
|
|
35
|
+
asVoice: args.asVoice === true,
|
|
36
|
+
audioAsVoice: args.audioAsVoice === true,
|
|
37
|
+
finalEvent: args.pushEvent,
|
|
38
|
+
replyToId: normalizeOutboundReplyToId({ kind: args.kind, replyToId: args.replyToId }) || undefined,
|
|
39
|
+
messageKind: args.kind,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
createdAt,
|
|
43
|
+
retryCount: 0,
|
|
44
|
+
nextAttemptAt: createdAt,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildTextOutboxEntry(args: {
|
|
49
|
+
createMessageId: () => string;
|
|
50
|
+
now: () => number;
|
|
51
|
+
normalizeAccountId: (accountId?: string | null) => string;
|
|
52
|
+
normalizeReplyToId: (value?: string | null) => string;
|
|
53
|
+
accountId: string;
|
|
54
|
+
sessionKey: string;
|
|
55
|
+
route: BncrRoute;
|
|
56
|
+
text: string;
|
|
57
|
+
kind?: 'tool' | 'block' | 'final';
|
|
58
|
+
replyToId?: string;
|
|
59
|
+
}): OutboxEntry {
|
|
60
|
+
const messageId = args.createMessageId();
|
|
61
|
+
const createdAt = args.now();
|
|
62
|
+
const frame = {
|
|
63
|
+
type: 'message.outbound',
|
|
64
|
+
messageId,
|
|
65
|
+
idempotencyKey: messageId,
|
|
66
|
+
sessionKey: args.sessionKey,
|
|
67
|
+
replyToId: normalizeOutboundReplyToId({ kind: args.kind, replyToId: args.replyToId }) || undefined,
|
|
68
|
+
message: {
|
|
69
|
+
platform: args.route.platform,
|
|
70
|
+
groupId: args.route.groupId,
|
|
71
|
+
userId: args.route.userId,
|
|
72
|
+
type: 'text',
|
|
73
|
+
kind: args.kind,
|
|
74
|
+
msg: args.text,
|
|
75
|
+
path: '',
|
|
76
|
+
base64: '',
|
|
77
|
+
fileName: '',
|
|
78
|
+
},
|
|
79
|
+
ts: createdAt,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
messageId,
|
|
84
|
+
accountId: args.normalizeAccountId(args.accountId),
|
|
85
|
+
sessionKey: args.sessionKey,
|
|
86
|
+
route: args.route,
|
|
87
|
+
payload: frame,
|
|
88
|
+
createdAt,
|
|
89
|
+
retryCount: 0,
|
|
90
|
+
nextAttemptAt: createdAt,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { BncrConnection, OutboxEntry } from './types.ts';
|
|
2
|
+
import { buildPushOkArgs } from './outbox-push-args.ts';
|
|
3
|
+
|
|
4
|
+
export function buildFileTransferPushSuccessArgs(args: {
|
|
5
|
+
entry: OutboxEntry;
|
|
6
|
+
connIds: Iterable<string>;
|
|
7
|
+
owner: BncrConnection | null;
|
|
8
|
+
}) {
|
|
9
|
+
return {
|
|
10
|
+
entry: args.entry,
|
|
11
|
+
connIds: args.connIds,
|
|
12
|
+
ownerConnId: args.owner?.connId,
|
|
13
|
+
ownerClientId: args.owner?.clientId,
|
|
14
|
+
clearLastError: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildFileTransferPushOkArgs(args: {
|
|
19
|
+
entry: OutboxEntry;
|
|
20
|
+
connIds: Iterable<string>;
|
|
21
|
+
recentInboundReachable: boolean;
|
|
22
|
+
event: string;
|
|
23
|
+
}) {
|
|
24
|
+
return buildPushOkArgs({
|
|
25
|
+
entry: args.entry,
|
|
26
|
+
connIds: args.connIds,
|
|
27
|
+
recentInboundReachable: args.recentInboundReachable,
|
|
28
|
+
event: args.event,
|
|
29
|
+
kind: 'file-transfer',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { OutboxEntry } from './types.ts';
|
|
2
|
+
import { buildPushFailureArgs } from './outbox-push-args.ts';
|
|
3
|
+
|
|
4
|
+
export function resolveFileTransferFailureState(args: {
|
|
5
|
+
entry: OutboxEntry;
|
|
6
|
+
error: unknown;
|
|
7
|
+
isRetryableFileTransferError: (error: unknown) => boolean;
|
|
8
|
+
}) {
|
|
9
|
+
const retryable = args.isRetryableFileTransferError(args.error);
|
|
10
|
+
return {
|
|
11
|
+
retryable,
|
|
12
|
+
deadLetterReason: args.entry.lastError || 'file-transfer-failed',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildFileTransferPushFailureArgs(args: {
|
|
17
|
+
entry: OutboxEntry;
|
|
18
|
+
retryable: boolean;
|
|
19
|
+
}) {
|
|
20
|
+
return buildPushFailureArgs({
|
|
21
|
+
entry: args.entry,
|
|
22
|
+
retryable: args.retryable,
|
|
23
|
+
kind: 'file-transfer',
|
|
24
|
+
});
|
|
25
|
+
}
|