@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.
Files changed (41) hide show
  1. package/README.md +68 -5
  2. package/package.json +1 -1
  3. package/src/channel.ts +2985 -1706
  4. package/src/core/connection-capability.ts +70 -0
  5. package/src/core/connection-reachability.ts +168 -0
  6. package/src/core/diagnostics.ts +54 -0
  7. package/src/core/downlink-health.ts +61 -0
  8. package/src/core/extended-diagnostics.ts +65 -0
  9. package/src/core/lease-state.ts +94 -0
  10. package/src/core/outbox-enqueue.ts +22 -0
  11. package/src/core/outbox-entry-builders.ts +92 -0
  12. package/src/core/outbox-file-transfer-bookkeeping.ts +31 -0
  13. package/src/core/outbox-file-transfer-failure.ts +25 -0
  14. package/src/core/outbox-file-transfer-guards.ts +66 -0
  15. package/src/core/outbox-file-transfer-prep.ts +31 -0
  16. package/src/core/outbox-file-transfer-success.ts +34 -0
  17. package/src/core/outbox-push-args.ts +67 -0
  18. package/src/core/outbox-queue.ts +69 -0
  19. package/src/core/outbox-summary.ts +14 -0
  20. package/src/core/outbox-text-push-failure.ts +10 -0
  21. package/src/core/outbox-text-push-guards.ts +51 -0
  22. package/src/core/outbox-text-push-prep.ts +36 -0
  23. package/src/core/outbox-text-push-success.ts +62 -0
  24. package/src/core/policy.ts +9 -0
  25. package/src/core/register-trace.ts +115 -0
  26. package/src/core/status.ts +57 -0
  27. package/src/core/types.ts +1 -0
  28. package/src/messaging/inbound/commands.ts +318 -75
  29. package/src/messaging/inbound/dispatch.ts +435 -139
  30. package/src/messaging/inbound/parse.ts +8 -0
  31. package/src/messaging/inbound/session-label.ts +115 -0
  32. package/src/messaging/outbound/diagnostics.ts +262 -0
  33. package/src/messaging/outbound/media-dedupe.ts +51 -0
  34. package/src/messaging/outbound/media.ts +3 -1
  35. package/src/messaging/outbound/queue-selectors.ts +191 -0
  36. package/src/messaging/outbound/reasons.ts +52 -0
  37. package/src/messaging/outbound/reply-enqueue.ts +329 -0
  38. package/src/messaging/outbound/reply-target-policy.ts +13 -0
  39. package/src/messaging/outbound/retry-policy.ts +142 -0
  40. package/src/messaging/outbound/send.ts +6 -0
  41. package/src/messaging/outbound/session-route.ts +34 -5
@@ -0,0 +1,115 @@
1
+ import {
2
+ recordSessionMetaFromInbound,
3
+ updateSessionStoreEntry,
4
+ } from 'openclaw/plugin-sdk/session-store-runtime';
5
+ import { emitBncrLogLine } from '../../core/logging.ts';
6
+
7
+ type RecordInboundSessionFn = (args: any) => Promise<unknown> | unknown;
8
+
9
+ export function buildBncrInboundSessionIdentityPatch(args: {
10
+ channelId: string;
11
+ accountId: string;
12
+ chatType: 'direct' | 'group';
13
+ displayTo: string;
14
+ senderId: string;
15
+ }) {
16
+ const { channelId, accountId, chatType, displayTo, senderId } = args;
17
+ return {
18
+ label: displayTo,
19
+ channel: channelId,
20
+ chatType,
21
+ origin: {
22
+ label: displayTo,
23
+ provider: channelId,
24
+ surface: channelId,
25
+ chatType,
26
+ from: senderId,
27
+ to: displayTo,
28
+ accountId,
29
+ },
30
+ deliveryContext: {
31
+ channel: channelId,
32
+ to: displayTo,
33
+ accountId,
34
+ },
35
+ route: {
36
+ channel: channelId,
37
+ accountId,
38
+ target: { to: displayTo },
39
+ },
40
+ lastTo: displayTo,
41
+ };
42
+ }
43
+
44
+ function normalizeNonEmptyString(value: unknown): string | null {
45
+ const normalized = String(value ?? '').trim();
46
+ return normalized || null;
47
+ }
48
+
49
+ export async function correctBncrInboundSessionLabel(args: {
50
+ storePath: string;
51
+ sessionKey: string;
52
+ expectedLabel: string;
53
+ }) {
54
+ const storePath = normalizeNonEmptyString(args.storePath);
55
+ const sessionKey = normalizeNonEmptyString(args.sessionKey);
56
+ const expectedLabel = normalizeNonEmptyString(args.expectedLabel);
57
+ if (!storePath || !sessionKey || !expectedLabel) return;
58
+
59
+ try {
60
+ await updateSessionStoreEntry({
61
+ storePath,
62
+ sessionKey,
63
+ update: (entry: any) => {
64
+ if (entry?.label === expectedLabel) return null;
65
+ return { label: expectedLabel };
66
+ },
67
+ });
68
+ } catch (err) {
69
+ emitBncrLogLine('warn', `[bncr] inbound session label correction failed: ${String(err)}`);
70
+ }
71
+ }
72
+
73
+ export async function recordAndPatchBncrInboundSessionEntry(args: {
74
+ storePath: string;
75
+ sessionKey: string;
76
+ ctx?: Record<string, unknown>;
77
+ patch: Record<string, unknown>;
78
+ }) {
79
+ const storePath = normalizeNonEmptyString(args.storePath);
80
+ const sessionKey = normalizeNonEmptyString(args.sessionKey);
81
+ if (!storePath || !sessionKey) return;
82
+
83
+ try {
84
+ if (args.ctx) {
85
+ await recordSessionMetaFromInbound({
86
+ storePath,
87
+ sessionKey,
88
+ ctx: args.ctx as any,
89
+ createIfMissing: true,
90
+ });
91
+ }
92
+ await updateSessionStoreEntry({
93
+ storePath,
94
+ sessionKey,
95
+ update: () => args.patch,
96
+ });
97
+ } catch (err) {
98
+ emitBncrLogLine('warn', `[bncr] inbound session patch failed: ${String(err)}`);
99
+ }
100
+ }
101
+
102
+ export function wrapBncrInboundRecordSessionLabelCorrection(args: {
103
+ recordInboundSession: RecordInboundSessionFn;
104
+ expectedLabel: string;
105
+ }): RecordInboundSessionFn {
106
+ return async (recordArgs: any) => {
107
+ const result = await args.recordInboundSession(recordArgs);
108
+ await correctBncrInboundSessionLabel({
109
+ storePath: recordArgs?.storePath,
110
+ sessionKey: recordArgs?.sessionKey,
111
+ expectedLabel: args.expectedLabel,
112
+ });
113
+ return result;
114
+ };
115
+ }
@@ -0,0 +1,262 @@
1
+ import {
2
+ type OutboundScheduleSource,
3
+ OUTBOUND_TERMINAL_REASON,
4
+ } from './reasons.ts';
5
+ import type { RetryRerouteDecision } from './retry-policy.ts';
6
+
7
+ export function buildOutboxScheduleDebugInfo(args: {
8
+ bridgeId: string;
9
+ accountId?: string | null;
10
+ // Account-local aggregated next delay after considering currently visible entries.
11
+ localNextDelay?: number | null;
12
+ // Bridge-global aggregated next delay after merging account-local scheduling decisions.
13
+ globalNextDelay?: number | null;
14
+ // Immediate wait observed for this specific scheduling event.
15
+ wait?: number | null;
16
+ source: OutboundScheduleSource;
17
+ messageId?: string;
18
+ }) {
19
+ return {
20
+ bridge: args.bridgeId,
21
+ ...(args.accountId ? { accountId: args.accountId } : {}),
22
+ ...(args.messageId ? { messageId: args.messageId } : {}),
23
+ source: args.source,
24
+ ...(typeof args.wait === 'number' ? { wait: args.wait } : {}),
25
+ ...(typeof args.localNextDelay === 'number' ? { localNextDelay: args.localNextDelay } : {}),
26
+ ...(typeof args.globalNextDelay === 'number' ? { globalNextDelay: args.globalNextDelay } : {}),
27
+ };
28
+ }
29
+
30
+ export function buildOutboxPushSkipDebugInfo(args: {
31
+ messageId: string;
32
+ accountId: string;
33
+ reason: string;
34
+ recentInboundReachable?: boolean;
35
+ kind?: string;
36
+ }) {
37
+ return {
38
+ messageId: args.messageId,
39
+ accountId: args.accountId,
40
+ ...(args.kind ? { kind: args.kind } : {}),
41
+ reason: args.reason,
42
+ ...(typeof args.recentInboundReachable === 'boolean'
43
+ ? { recentInboundReachable: args.recentInboundReachable }
44
+ : {}),
45
+ };
46
+ }
47
+
48
+ export function buildOutboxRouteSelectDebugInfo(args: {
49
+ messageId: string;
50
+ accountId: string;
51
+ routeReason: string;
52
+ connIds: Iterable<string>;
53
+ ownerConnId?: string;
54
+ ownerClientId?: string;
55
+ recentInboundReachable: boolean;
56
+ event: string;
57
+ kind?: string;
58
+ }) {
59
+ return {
60
+ messageId: args.messageId,
61
+ accountId: args.accountId,
62
+ ...(args.kind ? { kind: args.kind } : {}),
63
+ routeReason: args.routeReason,
64
+ connIds: Array.from(args.connIds),
65
+ ownerConnId: args.ownerConnId || '',
66
+ ownerClientId: args.ownerClientId || '',
67
+ recentInboundReachable: args.recentInboundReachable,
68
+ event: args.event,
69
+ };
70
+ }
71
+
72
+ export function buildOutboxPushOkDebugInfo(args: {
73
+ messageId: string;
74
+ accountId: string;
75
+ connIds: Iterable<string>;
76
+ ownerConnId?: string;
77
+ ownerClientId?: string;
78
+ recentInboundReachable: boolean;
79
+ event: string;
80
+ kind?: string;
81
+ }) {
82
+ return {
83
+ messageId: args.messageId,
84
+ accountId: args.accountId,
85
+ ...(args.kind ? { kind: args.kind } : {}),
86
+ connIds: Array.from(args.connIds),
87
+ ownerConnId: args.ownerConnId || '',
88
+ ownerClientId: args.ownerClientId || '',
89
+ recentInboundReachable: args.recentInboundReachable,
90
+ event: args.event,
91
+ };
92
+ }
93
+
94
+ export function buildFlushDebugInfo(args: {
95
+ bridgeId: string;
96
+ accountId: string | null;
97
+ targetAccounts: string[];
98
+ outboxSize: number;
99
+ trigger: string;
100
+ reason?: string;
101
+ }) {
102
+ return {
103
+ bridge: args.bridgeId,
104
+ accountId: args.accountId,
105
+ targetAccounts: [...args.targetAccounts],
106
+ outboxSize: args.outboxSize,
107
+ trigger: args.trigger,
108
+ reason: args.reason,
109
+ };
110
+ }
111
+
112
+ export function buildOutboxAckDebugInfo(args: {
113
+ messageId: string;
114
+ accountId: string;
115
+ requireAck: boolean;
116
+ ackResult: 'acked' | 'timeout';
117
+ onlineNow: boolean;
118
+ recentInboundReachable: boolean;
119
+ connIds?: Iterable<string>;
120
+ ownerConnId?: string;
121
+ ownerClientId?: string;
122
+ sessionKey?: string;
123
+ to?: string;
124
+ ackStage?: string;
125
+ ackOutcome?: string;
126
+ reason?: string;
127
+ kind?: string;
128
+ event?: string;
129
+ ackTimeoutMs?: number;
130
+ adaptiveAckTimeoutEnabled?: boolean;
131
+ }) {
132
+ return {
133
+ messageId: args.messageId,
134
+ accountId: args.accountId,
135
+ ...(args.sessionKey ? { sessionKey: args.sessionKey } : {}),
136
+ ...(args.to ? { to: args.to } : {}),
137
+ ...(args.kind ? { kind: args.kind } : {}),
138
+ requireAck: args.requireAck,
139
+ ackResult: args.ackResult,
140
+ ackStage: args.ackStage || 'message',
141
+ ackOutcome: args.ackOutcome || args.ackResult,
142
+ ...(args.reason ? { reason: args.reason } : {}),
143
+ ...(typeof args.ackTimeoutMs === 'number' ? { ackTimeoutMs: args.ackTimeoutMs } : {}),
144
+ ...(typeof args.adaptiveAckTimeoutEnabled === 'boolean'
145
+ ? { adaptiveAckTimeoutEnabled: args.adaptiveAckTimeoutEnabled }
146
+ : {}),
147
+ onlineNow: args.onlineNow,
148
+ recentInboundReachable: args.recentInboundReachable,
149
+ ...(args.connIds ? { connIds: Array.from(args.connIds) } : {}),
150
+ ...(args.ownerConnId ? { ownerConnId: args.ownerConnId } : {}),
151
+ ...(args.ownerClientId ? { ownerClientId: args.ownerClientId } : {}),
152
+ ...(args.event ? { event: args.event } : {}),
153
+ };
154
+ }
155
+
156
+ export function buildRetryRerouteDebugInfo(args: {
157
+ messageId: string;
158
+ accountId: string;
159
+ currentConnId: string;
160
+ decision: RetryRerouteDecision;
161
+ availableConnIds: string[];
162
+ }) {
163
+ if (args.decision.kind !== 'retry') {
164
+ return {
165
+ messageId: args.messageId,
166
+ accountId: args.accountId,
167
+ currentConnId: args.currentConnId,
168
+ availableConnIds: [...args.availableConnIds],
169
+ kind: args.decision.kind,
170
+ terminalReason: args.decision.terminalReason,
171
+ nextRetryCount: args.decision.nextRetryCount,
172
+ lastAttemptAt: args.decision.lastAttemptAt,
173
+ };
174
+ }
175
+
176
+ return {
177
+ messageId: args.messageId,
178
+ accountId: args.accountId,
179
+ currentConnId: args.currentConnId,
180
+ attemptedConnIds: [...args.decision.attemptedConnIds],
181
+ availableConnIds: [...args.availableConnIds],
182
+ revalidatedConnIds: [...args.decision.revalidatedConnIds],
183
+ hasUntriedAlternative: args.decision.hasUntriedAlternative,
184
+ shouldFastReroute: args.decision.shouldFastReroute,
185
+ routeAttemptRound: args.decision.routeAttemptRound,
186
+ nextAttemptAt: args.decision.nextAttemptAt,
187
+ fastReroutePending: args.decision.fastReroutePending,
188
+ nextRetryCount: args.decision.nextRetryCount,
189
+ lastAttemptAt: args.decision.lastAttemptAt,
190
+ lastError: args.decision.lastError,
191
+ kind: args.decision.kind,
192
+ };
193
+ }
194
+
195
+ export function buildPushFailureDebugInfo(args: {
196
+ messageId: string;
197
+ accountId: string;
198
+ retryCount: number;
199
+ lastError?: string;
200
+ retryable?: boolean;
201
+ kind?: string;
202
+ }) {
203
+ return {
204
+ messageId: args.messageId,
205
+ accountId: args.accountId,
206
+ ...(args.kind ? { kind: args.kind } : {}),
207
+ ...(typeof args.retryable === 'boolean' ? { retryable: args.retryable } : {}),
208
+ retryCount: args.retryCount,
209
+ error:
210
+ (typeof args.lastError === 'string' && args.lastError) ||
211
+ OUTBOUND_TERMINAL_REASON.PUSH_RETRY,
212
+ };
213
+ }
214
+
215
+ export function buildEnqueueFromReplyDebugInfo(args: {
216
+ accountId: string;
217
+ sessionKey: string;
218
+ route: { platform?: string; groupId?: string; userId?: string } | null | undefined;
219
+ payload: {
220
+ text: string;
221
+ mediaUrl: string;
222
+ mediaUrls?: string[];
223
+ asVoice: boolean;
224
+ audioAsVoice: boolean;
225
+ kind?: 'tool' | 'block' | 'final';
226
+ replyToId: string;
227
+ };
228
+ }) {
229
+ return {
230
+ accountId: args.accountId,
231
+ sessionKey: args.sessionKey,
232
+ route: {
233
+ platform: args.route?.platform,
234
+ groupId: args.route?.groupId,
235
+ userId: args.route?.userId,
236
+ },
237
+ payload: {
238
+ text: args.payload.text,
239
+ mediaUrl: args.payload.mediaUrl,
240
+ mediaUrls: args.payload.mediaUrls,
241
+ asVoice: args.payload.asVoice,
242
+ audioAsVoice: args.payload.audioAsVoice,
243
+ kind: args.payload.kind,
244
+ replyToId: args.payload.replyToId,
245
+ },
246
+ };
247
+ }
248
+
249
+ export function buildReplyMediaFallbackDebugInfo(args: {
250
+ sessionKey: string;
251
+ mediaUrl: string;
252
+ replyToId: string;
253
+ fallback: { text: string; reason: string };
254
+ }) {
255
+ return {
256
+ sessionKey: args.sessionKey,
257
+ mediaUrl: args.mediaUrl,
258
+ replyToId: args.replyToId || undefined,
259
+ fallbackText: args.fallback.text,
260
+ reason: args.fallback.reason,
261
+ };
262
+ }
@@ -0,0 +1,51 @@
1
+ export type MediaDedupeCacheEntry = {
2
+ mediaUrl: string;
3
+ text: string;
4
+ replyToId: string;
5
+ createdAt: number;
6
+ };
7
+
8
+ function asString(v: unknown, fallback = ''): string {
9
+ if (typeof v === 'string') return v;
10
+ if (v == null) return fallback;
11
+ return String(v);
12
+ }
13
+
14
+ export function normalizeReplyToId(value: unknown): string {
15
+ return asString(value || '').trim();
16
+ }
17
+
18
+ export function normalizeMessageText(value: unknown): string {
19
+ return asString(value || '').trim();
20
+ }
21
+
22
+ export function shouldTreatReplyToAsSame(
23
+ currentReplyToId: string,
24
+ previousReplyToId: string,
25
+ ): boolean {
26
+ if (!currentReplyToId || !previousReplyToId) return true;
27
+ return currentReplyToId === previousReplyToId;
28
+ }
29
+
30
+ export function buildMediaTextFallback(params: {
31
+ currentText: string;
32
+ previousText: string;
33
+ currentReplyToId: string;
34
+ previousReplyToId: string;
35
+ }): { text: string; reason: 'same-text-sent-checkmark' | 'text-changed-downgrade' } | null {
36
+ if (!shouldTreatReplyToAsSame(params.currentReplyToId, params.previousReplyToId)) {
37
+ return null;
38
+ }
39
+
40
+ if (params.currentText && params.currentText !== params.previousText) {
41
+ return {
42
+ text: params.currentText,
43
+ reason: 'text-changed-downgrade',
44
+ };
45
+ }
46
+
47
+ return {
48
+ text: '✅已发送',
49
+ reason: 'same-text-sent-checkmark',
50
+ };
51
+ }
@@ -1,3 +1,5 @@
1
+ import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
2
+
1
3
  function asString(v: unknown, fallback = ''): string {
2
4
  if (typeof v === 'string') return v;
3
5
  if (v == null) return fallback;
@@ -64,7 +66,7 @@ export function buildBncrMediaOutboundFrame(params: {
64
66
  messageId: params.messageId,
65
67
  idempotencyKey: params.messageId,
66
68
  sessionKey: params.sessionKey,
67
- replyToId: asString(params.replyToId || '').trim() || undefined,
69
+ replyToId: normalizeOutboundReplyToId({ kind: params.kind, replyToId: params.replyToId }) || undefined,
68
70
  message: {
69
71
  platform: params.route.platform,
70
72
  groupId: params.route.groupId,
@@ -0,0 +1,191 @@
1
+ import type { BncrConnection, OutboxEntry } from '../../core/types.ts';
2
+
3
+ export type OutboxRouteSelection = {
4
+ connIds: string[];
5
+ routeReason:
6
+ | 'owner'
7
+ | 'active-connections-unattempted-first'
8
+ | 'active-connections-revalidated'
9
+ | 'active-connections-all-visible'
10
+ | 'recent-inbound-fallback'
11
+ | 'none';
12
+ recentInboundReachable: boolean;
13
+ ownerConnId?: string;
14
+ };
15
+
16
+ export type OutboxFileTransferRouteSelection = {
17
+ connIds: string[];
18
+ routeReason:
19
+ | 'owner'
20
+ | 'active-connections'
21
+ | 'active-connections-reused'
22
+ | 'recent-inbound-fallback'
23
+ | 'none';
24
+ recentInboundReachable: boolean;
25
+ ownerConnId?: string;
26
+ };
27
+
28
+ function finiteNumberOr(value: unknown, fallback: number): number {
29
+ const n = Number(value);
30
+ return Number.isFinite(n) ? n : fallback;
31
+ }
32
+
33
+ export function computeOutboxRetryWait(nextAttemptAt: number, nowMs: number): number {
34
+ return Math.max(0, finiteNumberOr(nextAttemptAt, 0) - finiteNumberOr(nowMs, 0));
35
+ }
36
+
37
+ export function updateMinOutboxDelay(currentDelay: number | null, candidateDelay: number | null): number | null {
38
+ if (candidateDelay == null) return currentDelay;
39
+ return currentDelay == null ? candidateDelay : Math.min(currentDelay, candidateDelay);
40
+ }
41
+
42
+ export function clampOutboxDrainDelay(delayMs: number): number {
43
+ return Math.max(0, Math.min(finiteNumberOr(delayMs, 0), 30_000));
44
+ }
45
+
46
+ export function selectOutboxTargetAccounts(args: {
47
+ accountId?: string | null;
48
+ outboxEntries: Iterable<OutboxEntry>;
49
+ normalizeAccountId: (accountId: string) => string;
50
+ }): string[] {
51
+ const filterAcc = args.accountId ? args.normalizeAccountId(args.accountId) : null;
52
+ if (filterAcc) return [filterAcc];
53
+ return Array.from(
54
+ new Set(Array.from(args.outboxEntries).map((entry) => args.normalizeAccountId(entry.accountId))),
55
+ );
56
+ }
57
+
58
+ export function listAccountOutboxEntries(args: {
59
+ accountId: string;
60
+ outboxEntries: Iterable<OutboxEntry>;
61
+ normalizeAccountId: (accountId: string) => string;
62
+ }): OutboxEntry[] {
63
+ return Array.from(args.outboxEntries)
64
+ .filter((entry) => args.normalizeAccountId(entry.accountId) === args.accountId)
65
+ .sort((a, b) => a.createdAt - b.createdAt);
66
+ }
67
+
68
+ export function findDueOutboxEntry(entries: OutboxEntry[], nowMs: number): OutboxEntry | null {
69
+ return entries.find((item) => item.nextAttemptAt <= nowMs) || null;
70
+ }
71
+
72
+ export function computeNextOutboxDelay(entries: OutboxEntry[], nowMs: number): number | null {
73
+ if (!entries.length) return null;
74
+ const due = findDueOutboxEntry(entries, nowMs);
75
+ if (due) return 0;
76
+ return Math.max(0, entries[0].nextAttemptAt - nowMs);
77
+ }
78
+
79
+ export function buildOutboxOnlineDebugInfo(args: {
80
+ bridgeId: string;
81
+ accountId: string;
82
+ online: boolean;
83
+ recentInboundReachable: boolean;
84
+ connections: Iterable<BncrConnection>;
85
+ }) {
86
+ return {
87
+ bridge: args.bridgeId,
88
+ accountId: args.accountId,
89
+ online: args.online,
90
+ recentInboundReachable: args.recentInboundReachable,
91
+ connections: Array.from(args.connections).map((c) => ({
92
+ accountId: c.accountId,
93
+ connId: c.connId,
94
+ clientId: c.clientId,
95
+ lastSeenAt: c.lastSeenAt,
96
+ })),
97
+ };
98
+ }
99
+
100
+ export function selectOutboxFileTransferRouteCandidates(args: {
101
+ routeCandidates: Iterable<string>;
102
+ attemptedConnIds: Iterable<string>;
103
+ recentInboundConnIds: Iterable<string>;
104
+ ownerConnId?: string;
105
+ recentInboundReachable: boolean;
106
+ isRevalidatedAttemptedConn: (connId: string) => boolean;
107
+ }): OutboxFileTransferRouteSelection {
108
+ const routeCandidates = Array.from(args.routeCandidates);
109
+ const attemptedConnIds = new Set(Array.from(args.attemptedConnIds));
110
+ const filteredCandidates = routeCandidates.filter(
111
+ (connId) => !attemptedConnIds.has(connId) || args.isRevalidatedAttemptedConn(connId),
112
+ );
113
+ const ownerConnId =
114
+ args.ownerConnId && !attemptedConnIds.has(args.ownerConnId) ? args.ownerConnId : undefined;
115
+ let connIds = ownerConnId ? [ownerConnId] : filteredCandidates.length > 0 ? filteredCandidates : routeCandidates;
116
+ let routeReason: OutboxFileTransferRouteSelection['routeReason'] = ownerConnId
117
+ ? 'owner'
118
+ : connIds.length > 0
119
+ ? filteredCandidates.length > 0
120
+ ? 'active-connections'
121
+ : 'active-connections-reused'
122
+ : args.recentInboundReachable
123
+ ? 'recent-inbound-fallback'
124
+ : 'none';
125
+
126
+ if (!connIds.length && args.recentInboundReachable) {
127
+ const recentInboundConnIds = Array.from(args.recentInboundConnIds);
128
+ const filteredRecentInboundConnIds = recentInboundConnIds.filter(
129
+ (connId) => !attemptedConnIds.has(connId),
130
+ );
131
+ connIds = filteredRecentInboundConnIds.length > 0 ? filteredRecentInboundConnIds : recentInboundConnIds;
132
+ routeReason = connIds.length > 0 ? 'recent-inbound-fallback' : 'none';
133
+ }
134
+
135
+ return {
136
+ connIds,
137
+ routeReason,
138
+ recentInboundReachable: args.recentInboundReachable,
139
+ ...(ownerConnId ? { ownerConnId } : {}),
140
+ };
141
+ }
142
+
143
+ export function selectOutboxRouteCandidates(args: {
144
+ routeCandidates: Iterable<string>;
145
+ attemptedConnIds: Iterable<string>;
146
+ recentInboundConnIds: Iterable<string>;
147
+ ownerConnId?: string;
148
+ recentInboundReachable: boolean;
149
+ isRevalidatedAttemptedConn: (connId: string) => boolean;
150
+ }): OutboxRouteSelection {
151
+ const routeCandidates = Array.from(args.routeCandidates);
152
+ const attemptedConnIds = new Set(Array.from(args.attemptedConnIds));
153
+ const unattemptedCandidates = routeCandidates.filter((connId) => !attemptedConnIds.has(connId));
154
+ const revalidatedCandidates = routeCandidates.filter(
155
+ (connId) => attemptedConnIds.has(connId) && args.isRevalidatedAttemptedConn(connId),
156
+ );
157
+ const preferredCandidates = unattemptedCandidates.length > 0 ? unattemptedCandidates : routeCandidates;
158
+ const ownerConnId =
159
+ args.ownerConnId && preferredCandidates.includes(args.ownerConnId) ? args.ownerConnId : undefined;
160
+ let connIds = ownerConnId ? [ownerConnId] : preferredCandidates;
161
+ let routeReason: OutboxRouteSelection['routeReason'] = ownerConnId
162
+ ? 'owner'
163
+ : connIds.length > 0
164
+ ? unattemptedCandidates.length > 0
165
+ ? 'active-connections-unattempted-first'
166
+ : revalidatedCandidates.length > 0
167
+ ? 'active-connections-revalidated'
168
+ : 'active-connections-all-visible'
169
+ : args.recentInboundReachable
170
+ ? 'recent-inbound-fallback'
171
+ : 'none';
172
+
173
+ if (!connIds.length && args.recentInboundReachable) {
174
+ const recentInboundConnIds = Array.from(args.recentInboundConnIds);
175
+ const unattemptedRecentInboundConnIds = recentInboundConnIds.filter(
176
+ (connId) => !attemptedConnIds.has(connId),
177
+ );
178
+ connIds =
179
+ unattemptedRecentInboundConnIds.length > 0
180
+ ? unattemptedRecentInboundConnIds
181
+ : recentInboundConnIds;
182
+ routeReason = connIds.length > 0 ? 'recent-inbound-fallback' : 'none';
183
+ }
184
+
185
+ return {
186
+ connIds,
187
+ routeReason,
188
+ recentInboundReachable: args.recentInboundReachable,
189
+ ...(ownerConnId ? { ownerConnId } : {}),
190
+ };
191
+ }
@@ -0,0 +1,52 @@
1
+ export const OUTBOUND_DEGRADE_REASON = {
2
+ ACK_TIMEOUT: 'ack-timeout',
3
+ PUSH_UNCONFIRMED: 'push-unconfirmed',
4
+ } as const;
5
+
6
+ export const OUTBOUND_TERMINAL_REASON = {
7
+ PUSH_ACK_TIMEOUT: 'push-ack-timeout',
8
+ PUSH_DELIVERY_UNCONFIRMED: 'push-delivery-unconfirmed',
9
+ PUSH_RETRY_LIMIT: 'push-retry-limit',
10
+ PUSH_RETRY: 'push-retry',
11
+ FILE_ACK_TIMEOUT: 'file-ack-timeout',
12
+ } as const;
13
+
14
+ export const OUTBOUND_FLUSH_REASON = {
15
+ SCHEDULED_DRAIN: 'scheduled-drain',
16
+ WS_ONLINE: 'ws-online',
17
+ MESSAGE_ACKED: 'message-acked',
18
+ ACTIVITY_HEARTBEAT: 'activity-heartbeat',
19
+ INBOUND_ACCEPTED: 'inbound-accepted',
20
+ } as const;
21
+
22
+ export const OUTBOUND_FLUSH_TRIGGER = {
23
+ TIMER: 'timer',
24
+ CONNECT: 'connect',
25
+ ACK_OK: 'ack-ok',
26
+ ACTIVITY: 'activity',
27
+ INBOUND: 'inbound',
28
+ } as const;
29
+
30
+ // Scheduling debug sources describe where a next-drain wait came from.
31
+ // They are observability taxonomy only; do not derive runtime behavior from them.
32
+ export const OUTBOUND_SCHEDULE_SOURCE = {
33
+ // Single pending timer was armed by schedulePushDrain(...).
34
+ SCHEDULE_PUSH_DRAIN: 'schedule-push-drain',
35
+ // Current account has queued work, but nothing is due yet.
36
+ ACCOUNT_NO_DUE_ENTRY: 'account-no-due-entry',
37
+ // Ack-timeout / reroute path kept entry in outbox and scheduled a retry.
38
+ RETRY_REROUTE_WAIT: 'retry-reroute-wait',
39
+ // Direct push failure kept entry in outbox and scheduled backoff.
40
+ PUSH_FAIL_WAIT: 'push-fail-wait',
41
+ // Per-account flush processed its single-run item budget and yielded to the next drain.
42
+ ACCOUNT_BUDGET_YIELD: 'account-budget-yield',
43
+ // Per-account flush spent its single-run time budget and yielded to the next drain.
44
+ ACCOUNT_TIME_BUDGET_YIELD: 'account-time-budget-yield',
45
+ // Account-local next delay was merged into bridge-global next delay.
46
+ ACCOUNT_NEXT_DELAY_MERGE: 'account-next-delay-merge',
47
+ // flushPushQueue(...) finished and armed the next bridge-level drain.
48
+ FLUSH_NEXT_DRAIN: 'flush-next-drain',
49
+ } as const;
50
+
51
+ export type OutboundScheduleSource =
52
+ (typeof OUTBOUND_SCHEDULE_SOURCE)[keyof typeof OUTBOUND_SCHEDULE_SOURCE];