@xmoxmo/bncr 0.6.1 → 0.6.3
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 +65 -1
- package/package.json +1 -1
- package/scripts/check-pack.mjs +4 -1
- package/scripts/selfcheck.mjs +4 -1
- package/src/channel.ts +144 -5
- package/src/messaging/inbound/commands.ts +62 -26
- package/src/messaging/inbound/context-facts.ts +88 -58
- package/src/messaging/inbound/conversation-history-serial.ts +258 -0
- package/src/messaging/inbound/conversation-history.ts +450 -0
- package/src/messaging/inbound/dispatch.ts +568 -123
- package/src/messaging/inbound/history-shard-worker.ts +180 -0
- package/src/messaging/inbound/native-command.ts +89 -33
- package/src/messaging/inbound/outbound-replay-cache.ts +202 -17
- package/src/messaging/inbound/reply-dispatch.ts +7 -6
- package/src/messaging/inbound/scene-admin.ts +40 -20
- package/src/messaging/inbound/turn-context.ts +101 -37
- package/src/plugin/bridge-lifecycle.ts +8 -0
- package/src/plugin/bridge-outbox-facade.ts +15 -1
- package/src/plugin/channel-components.ts +5 -5
- package/src/plugin/channel-runtime-builders-delivery.ts +2 -2
- package/src/plugin/channel-runtime-constants.ts +2 -0
- package/src/plugin/channel-runtime-types.ts +9 -7
- package/src/plugin/inbound-acceptance.ts +9 -24
- package/src/plugin/inbound-handlers.ts +4 -4
- package/src/plugin/inbound-surface-handlers-group.ts +4 -2
- package/src/plugin/outbox-drain-post-push.ts +3 -0
- package/src/plugin/scene-registry.ts +4 -4
- package/src/plugin/sqlite-state.ts +2109 -0
- package/src/plugin/state-store.ts +731 -69
- package/src/plugin/state-transient-runtime-group.ts +17 -3
- package/src/messaging/inbound/group-history.ts +0 -264
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
|
+
type BncrConversationContextMedia = {
|
|
4
|
+
type?: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
contentType?: string;
|
|
7
|
+
kind?: string;
|
|
8
|
+
messageId?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type BncrConversationContextEntry = {
|
|
12
|
+
messageId?: string;
|
|
13
|
+
timestamp?: number;
|
|
14
|
+
role?: 'user' | 'assistant' | 'system';
|
|
15
|
+
sender?: string;
|
|
16
|
+
senderId?: string;
|
|
17
|
+
content?: string;
|
|
18
|
+
media?: BncrConversationContextMedia[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type BncrParticipantRecord = Record<
|
|
22
|
+
string,
|
|
23
|
+
{
|
|
24
|
+
name: string;
|
|
25
|
+
username?: string;
|
|
26
|
+
isBot?: boolean;
|
|
27
|
+
isAdmin?: boolean;
|
|
28
|
+
isOwner?: boolean;
|
|
29
|
+
isAuthorizedSender?: boolean;
|
|
30
|
+
role?: 'owner' | 'admin' | 'user';
|
|
31
|
+
displayName?: string;
|
|
32
|
+
}
|
|
33
|
+
>;
|
|
34
|
+
|
|
3
35
|
export type BncrStructuredContextFactsInput = {
|
|
4
36
|
channelId: string;
|
|
5
37
|
accountId: string;
|
|
@@ -57,18 +89,10 @@ export type BncrStructuredContextFactsInput = {
|
|
|
57
89
|
kind?: string;
|
|
58
90
|
messageId?: string;
|
|
59
91
|
}>;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
body?: string;
|
|
65
|
-
media?: Array<{
|
|
66
|
-
path?: string;
|
|
67
|
-
contentType?: string;
|
|
68
|
-
kind?: string;
|
|
69
|
-
messageId?: string;
|
|
70
|
-
}>;
|
|
71
|
-
}>;
|
|
92
|
+
conversationContext?: BncrConversationContextEntry[];
|
|
93
|
+
participants?: BncrParticipantRecord | null;
|
|
94
|
+
isGroupChat?: boolean;
|
|
95
|
+
groupSubject?: string;
|
|
72
96
|
};
|
|
73
97
|
|
|
74
98
|
function toBncrPromptMediaPath(id: string): string | undefined {
|
|
@@ -166,18 +190,24 @@ export function buildBncrStructuredContextFacts(input: BncrStructuredContextFact
|
|
|
166
190
|
kind: item.kind,
|
|
167
191
|
messageId: item.messageId,
|
|
168
192
|
})),
|
|
169
|
-
|
|
193
|
+
conversationContext: (input.conversationContext || []).map((entry) => ({
|
|
170
194
|
messageId: entry.messageId,
|
|
195
|
+
timestamp: entry.timestamp,
|
|
196
|
+
role: entry.role,
|
|
171
197
|
sender: entry.sender,
|
|
172
198
|
senderId: entry.senderId,
|
|
173
|
-
|
|
199
|
+
content: entry.content,
|
|
174
200
|
media: (entry.media || []).map((item) => ({
|
|
201
|
+
type: item.type || item.kind || 'document',
|
|
175
202
|
path: item.path,
|
|
176
203
|
contentType: item.contentType,
|
|
177
|
-
kind: item.kind,
|
|
178
204
|
messageId: item.messageId,
|
|
179
205
|
})),
|
|
180
206
|
})),
|
|
207
|
+
participants: input.participants ?? null,
|
|
208
|
+
isGroupChat: input.isGroupChat,
|
|
209
|
+
groupSubject: input.groupSubject,
|
|
210
|
+
accountId: input.accountId,
|
|
181
211
|
};
|
|
182
212
|
}
|
|
183
213
|
|
|
@@ -210,18 +240,11 @@ export function buildBncrPromptVisibleContextFacts(
|
|
|
210
240
|
kind?: string;
|
|
211
241
|
messageId?: string;
|
|
212
242
|
}>;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
media?: Array<{
|
|
219
|
-
path?: string;
|
|
220
|
-
contentType?: string;
|
|
221
|
-
kind?: string;
|
|
222
|
-
messageId?: string;
|
|
223
|
-
}>;
|
|
224
|
-
}>;
|
|
243
|
+
conversation_context?: Array<Record<string, unknown>>;
|
|
244
|
+
participants?: BncrParticipantRecord;
|
|
245
|
+
is_group_chat?: boolean;
|
|
246
|
+
group_subject?: string;
|
|
247
|
+
account_id?: string;
|
|
225
248
|
} = {};
|
|
226
249
|
|
|
227
250
|
if (facts.platform) {
|
|
@@ -272,27 +295,39 @@ export function buildBncrPromptVisibleContextFacts(
|
|
|
272
295
|
return Object.values(payload).some((field) => field !== undefined) ? [payload] : [];
|
|
273
296
|
});
|
|
274
297
|
}
|
|
275
|
-
if (facts.
|
|
276
|
-
result.
|
|
277
|
-
const
|
|
298
|
+
if (facts.conversationContext?.length) {
|
|
299
|
+
result.conversation_context = facts.conversationContext.flatMap((entry) => {
|
|
300
|
+
const items: Array<Record<string, unknown>> = [];
|
|
301
|
+
if (entry.content) {
|
|
302
|
+
items.push({
|
|
303
|
+
messageId: entry.messageId,
|
|
304
|
+
timestamp: entry.timestamp,
|
|
305
|
+
role: entry.role,
|
|
306
|
+
sender: entry.sender,
|
|
307
|
+
senderId: entry.senderId,
|
|
308
|
+
content: entry.content,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
for (const item of entry.media || []) {
|
|
278
312
|
const promptMediaPath = resolveBncrPromptMediaPath(item.path);
|
|
279
313
|
const payload = {
|
|
314
|
+
messageId: item.messageId ?? entry.messageId,
|
|
315
|
+
timestamp: entry.timestamp,
|
|
316
|
+
role: entry.role,
|
|
317
|
+
sender: entry.sender,
|
|
318
|
+
senderId: entry.senderId,
|
|
319
|
+
media_type: item.type || 'document',
|
|
280
320
|
...(promptMediaPath ? { path: promptMediaPath } : {}),
|
|
281
|
-
contentType: item.contentType,
|
|
282
|
-
kind: item.kind,
|
|
283
|
-
messageId: item.messageId,
|
|
321
|
+
...(item.contentType ? { contentType: item.contentType } : {}),
|
|
284
322
|
};
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
messageId: entry.messageId,
|
|
289
|
-
sender: entry.sender,
|
|
290
|
-
senderId: entry.senderId,
|
|
291
|
-
body: entry.body,
|
|
292
|
-
...(media.length > 0 ? { media } : {}),
|
|
293
|
-
};
|
|
294
|
-
return Object.values(payload).some((field) => field !== undefined) ? [payload] : [];
|
|
323
|
+
if (Object.values(payload).some((field) => field !== undefined)) items.push(payload);
|
|
324
|
+
}
|
|
325
|
+
return items;
|
|
295
326
|
});
|
|
327
|
+
if (facts.participants) result.participants = facts.participants;
|
|
328
|
+
if (facts.isGroupChat !== undefined) result.is_group_chat = facts.isGroupChat;
|
|
329
|
+
if (facts.groupSubject) result.group_subject = facts.groupSubject;
|
|
330
|
+
if (facts.accountId) result.account_id = facts.accountId;
|
|
296
331
|
}
|
|
297
332
|
|
|
298
333
|
return result;
|
|
@@ -358,25 +393,17 @@ export type BncrStructuredContextFactsFromInboundPartsInput = {
|
|
|
358
393
|
bridgeSenderName?: string;
|
|
359
394
|
senderIsOwner?: boolean;
|
|
360
395
|
senderIsAuthorized?: boolean;
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
body?: string;
|
|
366
|
-
media?: Array<{
|
|
367
|
-
path?: string;
|
|
368
|
-
contentType?: string;
|
|
369
|
-
kind?: string;
|
|
370
|
-
messageId?: string;
|
|
371
|
-
}>;
|
|
372
|
-
}>;
|
|
396
|
+
conversationHistoryContext?: BncrConversationContextEntry[];
|
|
397
|
+
participants?: BncrParticipantRecord | null;
|
|
398
|
+
isGroupChat?: boolean;
|
|
399
|
+
groupSubject?: string;
|
|
373
400
|
};
|
|
374
401
|
export function buildBncrStructuredContextFactsFromInboundParts(
|
|
375
402
|
input: BncrStructuredContextFactsFromInboundPartsInput,
|
|
376
403
|
) {
|
|
377
404
|
const mediaItems = Array.isArray(input.prepared.mediaItems) ? input.prepared.mediaItems : [];
|
|
378
|
-
const
|
|
379
|
-
? input.
|
|
405
|
+
const conversationHistoryContext = Array.isArray(input.conversationHistoryContext)
|
|
406
|
+
? input.conversationHistoryContext
|
|
380
407
|
: [];
|
|
381
408
|
return buildBncrStructuredContextFacts({
|
|
382
409
|
channelId: input.channelId,
|
|
@@ -435,6 +462,9 @@ export function buildBncrStructuredContextFactsFromInboundParts(
|
|
|
435
462
|
kind: item.kind || inferBncrStructuredMediaKind(item.contentType),
|
|
436
463
|
messageId: input.parsed.msgId,
|
|
437
464
|
})),
|
|
438
|
-
|
|
465
|
+
conversationContext: conversationHistoryContext,
|
|
466
|
+
participants: input.participants ?? null,
|
|
467
|
+
isGroupChat: input.isGroupChat,
|
|
468
|
+
groupSubject: input.groupSubject,
|
|
439
469
|
});
|
|
440
470
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { emitBncrLogLine } from '../../core/logging.ts';
|
|
2
|
+
|
|
3
|
+
export type ConversationHistorySerialPhase =
|
|
4
|
+
| 'queued'
|
|
5
|
+
| 'running'
|
|
6
|
+
| 'snapshot'
|
|
7
|
+
| 'shard_created'
|
|
8
|
+
| 'upload_start'
|
|
9
|
+
| 'upload_end'
|
|
10
|
+
| 'cache_delete_start'
|
|
11
|
+
| 'cache_delete_done'
|
|
12
|
+
| 'lock_clear_start'
|
|
13
|
+
| 'lock_clear_done'
|
|
14
|
+
| 'abandoned';
|
|
15
|
+
|
|
16
|
+
export type ConversationHistorySerialState = {
|
|
17
|
+
historyKey: string;
|
|
18
|
+
owner?: string;
|
|
19
|
+
phase: ConversationHistorySerialPhase;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
snapshotMessageIds: string[];
|
|
22
|
+
cacheKey?: string;
|
|
23
|
+
uploadCompleted: boolean;
|
|
24
|
+
abandoned: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ConversationHistorySerialHandle = {
|
|
28
|
+
phase: (
|
|
29
|
+
phase: ConversationHistorySerialPhase,
|
|
30
|
+
data?: { snapshotMessageIds?: string[]; cacheKey?: string },
|
|
31
|
+
) => void;
|
|
32
|
+
setCleanup: (cleanup: () => void) => void;
|
|
33
|
+
isAbandoned: () => boolean;
|
|
34
|
+
owner: () => string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type BncrConversationHistorySerialEntry = {
|
|
38
|
+
chain: Promise<void>;
|
|
39
|
+
abort: (reason?: unknown) => void;
|
|
40
|
+
state: ConversationHistorySerialState;
|
|
41
|
+
cleanup?: () => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const bncrConversationHistoryChains = new Map<string, BncrConversationHistorySerialEntry[]>();
|
|
45
|
+
let bncrConversationHistorySerialOwner = 'default';
|
|
46
|
+
|
|
47
|
+
export function setConversationHistorySerialOwner(owner: string): void {
|
|
48
|
+
bncrConversationHistorySerialOwner = String(owner || '').trim() || 'default';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getConversationHistorySerialOwner(): string {
|
|
52
|
+
return bncrConversationHistorySerialOwner;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createConversationHistorySerialHandle(entry?: BncrConversationHistorySerialEntry) {
|
|
56
|
+
return {
|
|
57
|
+
phase: (
|
|
58
|
+
phase: ConversationHistorySerialPhase,
|
|
59
|
+
data?: { snapshotMessageIds?: string[]; cacheKey?: string },
|
|
60
|
+
) => {
|
|
61
|
+
if (!entry) return;
|
|
62
|
+
entry.state.phase = phase;
|
|
63
|
+
entry.state.updatedAt = Date.now();
|
|
64
|
+
if (data?.snapshotMessageIds) {
|
|
65
|
+
entry.state.snapshotMessageIds = data.snapshotMessageIds
|
|
66
|
+
.map((messageId) => String(messageId || '').trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
if (data?.cacheKey) entry.state.cacheKey = data.cacheKey;
|
|
70
|
+
if (phase === 'upload_end') entry.state.uploadCompleted = true;
|
|
71
|
+
},
|
|
72
|
+
setCleanup: (cleanup: () => void) => {
|
|
73
|
+
if (entry) entry.cleanup = cleanup;
|
|
74
|
+
},
|
|
75
|
+
isAbandoned: () => {
|
|
76
|
+
if (!entry) return false;
|
|
77
|
+
return (
|
|
78
|
+
entry.state.abandoned === true ||
|
|
79
|
+
(Boolean(entry.state.owner) && entry.state.owner !== bncrConversationHistorySerialOwner)
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
owner: () => entry?.state.owner || bncrConversationHistorySerialOwner,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isUploadCompletedPhase(state: ConversationHistorySerialState): boolean {
|
|
87
|
+
return state.uploadCompleted === true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runConversationHistorySerial<T>(
|
|
91
|
+
historyKey: string,
|
|
92
|
+
task: (handle: ConversationHistorySerialHandle) => Promise<T> | T,
|
|
93
|
+
meta?: { to?: string | null; debugEnabled?: boolean; owner?: string },
|
|
94
|
+
) {
|
|
95
|
+
const key = String(historyKey || '').trim();
|
|
96
|
+
if (!key) return await task(createConversationHistorySerialHandle());
|
|
97
|
+
|
|
98
|
+
const serialEntries = bncrConversationHistoryChains.get(key) || [];
|
|
99
|
+
const previous = serialEntries.at(-1)?.chain || Promise.resolve();
|
|
100
|
+
// A task with no predecessor has already acquired the serial slot even before
|
|
101
|
+
// its first microtask runs. Stale-lock cleanup must be able to distinguish
|
|
102
|
+
// that active slot from genuinely queued work behind an earlier lock.
|
|
103
|
+
const hasPredecessor = serialEntries.length > 0;
|
|
104
|
+
let release!: () => void;
|
|
105
|
+
let abort!: (reason?: unknown) => void;
|
|
106
|
+
const current = new Promise<void>((resolve, reject) => {
|
|
107
|
+
release = resolve;
|
|
108
|
+
abort = reject;
|
|
109
|
+
});
|
|
110
|
+
const chain = previous.then(
|
|
111
|
+
() => current,
|
|
112
|
+
() => current,
|
|
113
|
+
);
|
|
114
|
+
const serialEntry: BncrConversationHistorySerialEntry = {
|
|
115
|
+
chain,
|
|
116
|
+
abort,
|
|
117
|
+
state: {
|
|
118
|
+
historyKey: key,
|
|
119
|
+
owner: meta?.owner || bncrConversationHistorySerialOwner,
|
|
120
|
+
phase: hasPredecessor ? 'queued' : 'running',
|
|
121
|
+
updatedAt: Date.now(),
|
|
122
|
+
snapshotMessageIds: [],
|
|
123
|
+
uploadCompleted: false,
|
|
124
|
+
abandoned: false,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
const handle = createConversationHistorySerialHandle(serialEntry);
|
|
128
|
+
serialEntries.push(serialEntry);
|
|
129
|
+
bncrConversationHistoryChains.set(key, serialEntries);
|
|
130
|
+
|
|
131
|
+
const debugGate = () => meta?.debugEnabled === true;
|
|
132
|
+
emitBncrLogLine(
|
|
133
|
+
'info',
|
|
134
|
+
`[bncr] conversation-history-serial queued|key=${key}|to=${String(meta?.to || '-')}`,
|
|
135
|
+
{ debugOnly: true },
|
|
136
|
+
debugGate,
|
|
137
|
+
);
|
|
138
|
+
try {
|
|
139
|
+
await previous.catch(() => {});
|
|
140
|
+
emitBncrLogLine(
|
|
141
|
+
'info',
|
|
142
|
+
`[bncr] conversation-history-serial acquired|key=${key}|to=${String(meta?.to || '-')}`,
|
|
143
|
+
{ debugOnly: true },
|
|
144
|
+
debugGate,
|
|
145
|
+
);
|
|
146
|
+
if (serialEntry.state.abandoned) {
|
|
147
|
+
return undefined as T;
|
|
148
|
+
}
|
|
149
|
+
serialEntry.state.phase = 'running';
|
|
150
|
+
serialEntry.state.updatedAt = Date.now();
|
|
151
|
+
return await task(handle);
|
|
152
|
+
} finally {
|
|
153
|
+
emitBncrLogLine(
|
|
154
|
+
'info',
|
|
155
|
+
`[bncr] conversation-history-serial lock-clear-start|key=${key}|phase=${serialEntry.state.phase}`,
|
|
156
|
+
{ debugOnly: true },
|
|
157
|
+
debugGate,
|
|
158
|
+
);
|
|
159
|
+
serialEntry.state.phase = 'lock_clear_start';
|
|
160
|
+
serialEntry.state.updatedAt = Date.now();
|
|
161
|
+
release();
|
|
162
|
+
serialEntry.state.phase = 'lock_clear_done';
|
|
163
|
+
serialEntry.state.updatedAt = Date.now();
|
|
164
|
+
const currentEntries = bncrConversationHistoryChains.get(key);
|
|
165
|
+
if (currentEntries) {
|
|
166
|
+
const entryIndex = currentEntries.indexOf(serialEntry);
|
|
167
|
+
if (entryIndex >= 0) currentEntries.splice(entryIndex, 1);
|
|
168
|
+
if (currentEntries.length === 0) bncrConversationHistoryChains.delete(key);
|
|
169
|
+
}
|
|
170
|
+
emitBncrLogLine(
|
|
171
|
+
'info',
|
|
172
|
+
`[bncr] conversation-history-serial lock-clear-done|key=${key}|phase=${serialEntry.state.phase}`,
|
|
173
|
+
{ debugOnly: true },
|
|
174
|
+
debugGate,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function clearConversationHistorySerialLocks(reason = 'runtime-restart', owner?: string) {
|
|
180
|
+
bncrConversationHistorySerialOwner =
|
|
181
|
+
owner || `${reason}:${Date.now()}:${Math.random().toString(16).slice(2, 8)}`;
|
|
182
|
+
const staleEntries = Array.from(bncrConversationHistoryChains.values()).flat();
|
|
183
|
+
const staleCount = staleEntries.length;
|
|
184
|
+
for (const entry of staleEntries) {
|
|
185
|
+
if (entry.state.phase === 'queued') {
|
|
186
|
+
// Queued work is intentionally preserved after a hot rebuild; give it
|
|
187
|
+
// the new instance owner so it is not treated as stale when it runs.
|
|
188
|
+
entry.state.owner = bncrConversationHistorySerialOwner;
|
|
189
|
+
}
|
|
190
|
+
// Upload-completed locks can safely finish snapshot cleanup before being abandoned.
|
|
191
|
+
// Locks still uploading cannot prove delivery, so their local cache is preserved.
|
|
192
|
+
if (isUploadCompletedPhase(entry.state) && entry.cleanup) {
|
|
193
|
+
try {
|
|
194
|
+
entry.cleanup();
|
|
195
|
+
} catch (err) {
|
|
196
|
+
emitBncrLogLine(
|
|
197
|
+
'warn',
|
|
198
|
+
`[bncr] conversation-history-serial stale cleanup failed|key=${entry.state.historyKey}|reason=${String(err)}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Started locks are abandoned but remain in the chain as a barrier. A hot
|
|
203
|
+
// rebuild must not let new tasks run while the old upload still owns state;
|
|
204
|
+
// once the old task releases its lock it leaves the map and new work runs.
|
|
205
|
+
if (entry.state.phase !== 'queued') {
|
|
206
|
+
entry.state.abandoned = true;
|
|
207
|
+
entry.state.phase = 'abandoned';
|
|
208
|
+
entry.state.updatedAt = Date.now();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Do not clear the chain map here. Abandoned started entries stay visible to
|
|
212
|
+
// readConversationHistorySerialHistoryKeys so shard recovery and the worker
|
|
213
|
+
// will not race them.
|
|
214
|
+
if (staleCount > 0) {
|
|
215
|
+
emitBncrLogLine(
|
|
216
|
+
'warn',
|
|
217
|
+
`[bncr] conversation-history-serial settled stale locks|count=${staleCount}|reason=${reason}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return staleCount;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function readConversationHistorySerialStates(): readonly ConversationHistorySerialState[] {
|
|
224
|
+
return Array.from(bncrConversationHistoryChains.values()).flatMap((entries) =>
|
|
225
|
+
entries.map((entry) => ({
|
|
226
|
+
historyKey: entry.state.historyKey,
|
|
227
|
+
...(entry.state.owner ? { owner: entry.state.owner } : {}),
|
|
228
|
+
phase: entry.state.phase,
|
|
229
|
+
updatedAt: entry.state.updatedAt,
|
|
230
|
+
snapshotMessageIds: [...entry.state.snapshotMessageIds],
|
|
231
|
+
...(entry.state.cacheKey ? { cacheKey: entry.state.cacheKey } : {}),
|
|
232
|
+
uploadCompleted: entry.state.uploadCompleted,
|
|
233
|
+
abandoned: entry.state.abandoned,
|
|
234
|
+
})),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function readConversationHistorySerialHistoryKeys(): string[] {
|
|
239
|
+
return Array.from(bncrConversationHistoryChains.keys()).filter(
|
|
240
|
+
(historyKey) => (bncrConversationHistoryChains.get(historyKey)?.length ?? 0) > 0,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function resetConversationHistorySerialForTest() {
|
|
245
|
+
setConversationHistorySerialOwner('default');
|
|
246
|
+
clearConversationHistorySerialLocks('test');
|
|
247
|
+
// Tests must never inherit queued chains from an earlier test. Runtime
|
|
248
|
+
// restarts deliberately keep queued work in the serial chain, so only the
|
|
249
|
+
// test helper fully abandons those entries as well.
|
|
250
|
+
for (const entry of Array.from(bncrConversationHistoryChains.values()).flat()) {
|
|
251
|
+
entry.state.abandoned = true;
|
|
252
|
+
entry.state.phase = 'abandoned';
|
|
253
|
+
entry.state.updatedAt = Date.now();
|
|
254
|
+
entry.chain.catch(() => {});
|
|
255
|
+
entry.abort(new Error('conversation-history-serial test reset'));
|
|
256
|
+
}
|
|
257
|
+
bncrConversationHistoryChains.clear();
|
|
258
|
+
}
|