@xmoxmo/bncr 0.6.2 → 0.6.4
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 +3 -0
- package/scripts/selfcheck.mjs +3 -0
- package/src/channel.ts +134 -7
- package/src/messaging/inbound/commands.ts +62 -26
- package/src/messaging/inbound/conversation-history-serial.ts +258 -0
- package/src/messaging/inbound/conversation-history.ts +174 -60
- package/src/messaging/inbound/dispatch.ts +557 -137
- 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 +128 -19
- package/src/messaging/inbound/reply-dispatch.ts +7 -6
- package/src/messaging/inbound/scene-admin.ts +17 -12
- package/src/messaging/inbound/turn-context.ts +2 -9
- package/src/plugin/bridge-lifecycle.ts +8 -0
- package/src/plugin/bridge-outbox-facade.ts +6 -1
- package/src/plugin/channel-runtime-constants.ts +2 -0
- package/src/plugin/outbox-drain-post-push.ts +3 -0
- package/src/plugin/sqlite-state.ts +2109 -0
- package/src/plugin/state-store.ts +704 -46
- package/src/plugin/state-transient-runtime-group.ts +14 -0
|
@@ -1,8 +1,22 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, rename } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
1
4
|
import {
|
|
2
5
|
dumpRegisterDriftSnapshot,
|
|
3
6
|
normalizeRegisterDriftSnapshot,
|
|
4
7
|
} from '../core/register-trace.ts';
|
|
5
8
|
import type { BncrRoute, OutboxEntry } from '../core/types.ts';
|
|
9
|
+
import {
|
|
10
|
+
type BncrConversationHistoryMap,
|
|
11
|
+
buildBncrBotReplyMessageId,
|
|
12
|
+
buildBncrConversationHistoryKeyFromRoute,
|
|
13
|
+
resetConversationHistoryVersions,
|
|
14
|
+
resolveBncrHistoryLimit,
|
|
15
|
+
} from '../messaging/inbound/conversation-history.ts';
|
|
16
|
+
import {
|
|
17
|
+
getConversationHistorySerialOwner,
|
|
18
|
+
readConversationHistorySerialHistoryKeys,
|
|
19
|
+
} from '../messaging/inbound/conversation-history-serial.ts';
|
|
6
20
|
import type {
|
|
7
21
|
BncrOutboundReplayEntry,
|
|
8
22
|
BncrOutboundReplayStatus,
|
|
@@ -24,6 +38,15 @@ import type {
|
|
|
24
38
|
PersistedState as BncrPersistedState,
|
|
25
39
|
BncrSceneRecord,
|
|
26
40
|
} from './channel-runtime-types.ts';
|
|
41
|
+
import type {
|
|
42
|
+
BncrHistoryShardCreateInput,
|
|
43
|
+
BncrHistoryShardCreateResult,
|
|
44
|
+
BncrHistoryShardQueue,
|
|
45
|
+
BncrSqliteControlState,
|
|
46
|
+
BncrSqliteHistoryState,
|
|
47
|
+
BncrSqliteOutboundState,
|
|
48
|
+
BncrSqliteStateDatabase,
|
|
49
|
+
} from './sqlite-state.ts';
|
|
27
50
|
|
|
28
51
|
const GROUP_REPLY_MODES = new Set<BncrGroupReplyMode>(['admin', 'mention', 'hybrid', 'all']);
|
|
29
52
|
const DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT = 50;
|
|
@@ -54,6 +77,39 @@ type PersistedConversationHistoryEntryInput = Partial<BncrPersistedConversationH
|
|
|
54
77
|
type PersistedOutboundReplayBucketInput = Partial<BncrPersistedOutboundReplayBucket>;
|
|
55
78
|
type PersistedOutboundReplayEntryInput = Partial<BncrOutboundReplayEntry>;
|
|
56
79
|
|
|
80
|
+
const bncrHistoryShardQueues = new WeakMap<BncrConversationHistoryMap, BncrHistoryShardQueue>();
|
|
81
|
+
|
|
82
|
+
export function registerBncrHistoryShardQueue(
|
|
83
|
+
historyMap: BncrConversationHistoryMap,
|
|
84
|
+
queue: BncrHistoryShardQueue,
|
|
85
|
+
): void {
|
|
86
|
+
bncrHistoryShardQueues.set(historyMap, queue);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getBncrHistoryShardQueue(
|
|
90
|
+
historyMap: BncrConversationHistoryMap,
|
|
91
|
+
): BncrHistoryShardQueue | undefined {
|
|
92
|
+
return bncrHistoryShardQueues.get(historyMap);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildMigratedHistoryMessageId(seed: string): string {
|
|
96
|
+
const digest = createHash('sha1').update(seed).digest('hex').slice(0, 24);
|
|
97
|
+
return `bncr-synthetic:migrated:${digest}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatSqliteCutoverTimestamp(timestamp: number): string {
|
|
101
|
+
const date = new Date(timestamp);
|
|
102
|
+
const pad = (value: number) => String(value).padStart(2, '0');
|
|
103
|
+
return [
|
|
104
|
+
date.getFullYear(),
|
|
105
|
+
pad(date.getMonth() + 1),
|
|
106
|
+
pad(date.getDate()),
|
|
107
|
+
pad(date.getHours()),
|
|
108
|
+
pad(date.getMinutes()),
|
|
109
|
+
pad(date.getSeconds()),
|
|
110
|
+
].join('');
|
|
111
|
+
}
|
|
112
|
+
|
|
57
113
|
export function createBncrStateStore(runtime: {
|
|
58
114
|
getStatePath: () => string | null;
|
|
59
115
|
now: () => number;
|
|
@@ -89,20 +145,65 @@ export function createBncrStateStore(runtime: {
|
|
|
89
145
|
lastOutboundByAccount: Map<string, number>;
|
|
90
146
|
getLastDriftSnapshot: () => BncrPersistedState['lastDriftSnapshot'];
|
|
91
147
|
setLastDriftSnapshot: (value: BncrPersistedState['lastDriftSnapshot']) => void;
|
|
148
|
+
sqliteState?: BncrSqliteStateDatabase;
|
|
149
|
+
createSqliteState?: (
|
|
150
|
+
statePath: string | null,
|
|
151
|
+
) => BncrSqliteStateDatabase | PromiseLike<BncrSqliteStateDatabase | null> | null;
|
|
92
152
|
}) {
|
|
93
153
|
const outboundReplayCache =
|
|
94
154
|
runtime.outboundReplayCache ?? new Map<string, BncrPersistedOutboundReplayEntry[]>();
|
|
155
|
+
let sqliteState: BncrSqliteStateDatabase | undefined;
|
|
156
|
+
|
|
157
|
+
async function resolveSqliteState(
|
|
158
|
+
statePath: string | null,
|
|
159
|
+
): Promise<BncrSqliteStateDatabase | undefined> {
|
|
160
|
+
if (!sqliteState) {
|
|
161
|
+
sqliteState =
|
|
162
|
+
runtime.sqliteState ?? (await runtime.createSqliteState?.(statePath)) ?? undefined;
|
|
163
|
+
}
|
|
164
|
+
return sqliteState;
|
|
165
|
+
}
|
|
95
166
|
|
|
96
167
|
function resolvePersistedConversationHistoryLimit(key: string): number {
|
|
97
168
|
const scene = runtime.sceneRegistry.get(key);
|
|
98
|
-
const sceneLimit = scene?.historyLimit;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
169
|
+
const sceneLimit = resolveBncrHistoryLimit(scene?.historyLimit);
|
|
170
|
+
return Math.max(DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT, Math.ceil(sceneLimit * 1.2));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveOutboundReplayBucketHistoryKey(
|
|
174
|
+
bucketKey: string,
|
|
175
|
+
entries: readonly BncrOutboundReplayEntry[],
|
|
176
|
+
): string | null {
|
|
177
|
+
const route = entries.find((entry) => runtime.parseRouteLike(entry?.route))?.route;
|
|
178
|
+
if (route) {
|
|
179
|
+
return buildBncrConversationHistoryKeyFromRoute({
|
|
180
|
+
platform: route.platform,
|
|
181
|
+
groupId: route.groupId,
|
|
182
|
+
userId: route.userId,
|
|
183
|
+
});
|
|
104
184
|
}
|
|
105
|
-
|
|
185
|
+
const separator = bucketKey.indexOf(':');
|
|
186
|
+
if (separator < 0 || separator >= bucketKey.length - 1) return null;
|
|
187
|
+
return bucketKey.slice(separator + 1).trim() || null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resolveOutboundReplayBucketLimit(
|
|
191
|
+
bucketKey: string,
|
|
192
|
+
entries: readonly BncrOutboundReplayEntry[],
|
|
193
|
+
): number {
|
|
194
|
+
const historyKey = resolveOutboundReplayBucketHistoryKey(bucketKey, entries);
|
|
195
|
+
if (!historyKey) return DEFAULT_PERSISTED_CONVERSATION_HISTORY_LIMIT;
|
|
196
|
+
return resolveBncrHistoryLimit(runtime.sceneRegistry.get(historyKey)?.historyLimit);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function normalizeOutboundReplayBucket(
|
|
200
|
+
bucketKey: string,
|
|
201
|
+
entries: readonly BncrOutboundReplayEntry[],
|
|
202
|
+
): BncrOutboundReplayEntry[] {
|
|
203
|
+
return entries
|
|
204
|
+
.slice()
|
|
205
|
+
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
|
|
206
|
+
.slice(-resolveOutboundReplayBucketLimit(bucketKey, entries));
|
|
106
207
|
}
|
|
107
208
|
|
|
108
209
|
function loadPersistedSceneRegistry(persisted: unknown): void {
|
|
@@ -151,10 +252,11 @@ export function createBncrStateStore(runtime: {
|
|
|
151
252
|
: {}),
|
|
152
253
|
...(typeof item.historyLimit === 'number' &&
|
|
153
254
|
Number.isFinite(item.historyLimit) &&
|
|
154
|
-
item.historyLimit >=
|
|
255
|
+
Math.floor(item.historyLimit) >= 2
|
|
155
256
|
? { historyLimit: Math.floor(item.historyLimit) }
|
|
156
257
|
: {}),
|
|
157
258
|
...(typeof item.historyForce === 'boolean' ? { historyForce: item.historyForce } : {}),
|
|
259
|
+
...(typeof item.downloadMedia === 'boolean' ? { downloadMedia: item.downloadMedia } : {}),
|
|
158
260
|
lastSeenAt,
|
|
159
261
|
});
|
|
160
262
|
}
|
|
@@ -168,8 +270,12 @@ export function createBncrStateStore(runtime: {
|
|
|
168
270
|
persisted: unknown,
|
|
169
271
|
target: Map<string, BncrPersistedConversationHistoryEntry[]>,
|
|
170
272
|
resolveLimit: (key: string) => number,
|
|
273
|
+
options?: {
|
|
274
|
+
assistantMessageIdsByHistoryKey?: ReadonlyMap<string, ReadonlySet<string>>;
|
|
275
|
+
merge?: boolean;
|
|
276
|
+
},
|
|
171
277
|
): void {
|
|
172
|
-
target.clear();
|
|
278
|
+
if (!options?.merge) target.clear();
|
|
173
279
|
const buckets = Array.isArray(persisted)
|
|
174
280
|
? (persisted as PersistedConversationHistoryBucketInput[])
|
|
175
281
|
: [];
|
|
@@ -180,12 +286,12 @@ export function createBncrStateStore(runtime: {
|
|
|
180
286
|
const rawEntries = Array.isArray(bucket?.entries)
|
|
181
287
|
? (bucket.entries as PersistedConversationHistoryEntryInput[])
|
|
182
288
|
: [];
|
|
183
|
-
for (const entry of rawEntries) {
|
|
289
|
+
for (const [entryIndex, entry] of rawEntries.entries()) {
|
|
184
290
|
const sender = runtime.asString(entry?.sender || '').trim();
|
|
185
291
|
const body = runtime.asString(entry?.body || '').trim();
|
|
186
292
|
if (!sender || !body) continue;
|
|
187
293
|
const timestamp = runtime.finiteNumberOr(entry?.timestamp, 0);
|
|
188
|
-
|
|
294
|
+
let messageId = runtime.asString(entry?.messageId || '').trim();
|
|
189
295
|
|
|
190
296
|
const media: BncrPersistedConversationHistoryMediaEntry[] = [];
|
|
191
297
|
const rawMedia = Array.isArray(entry?.media)
|
|
@@ -201,41 +307,184 @@ export function createBncrStateStore(runtime: {
|
|
|
201
307
|
path,
|
|
202
308
|
...(contentType ? { contentType } : {}),
|
|
203
309
|
...(kind ? { kind: kind as BncrPersistedConversationHistoryMediaEntry['kind'] } : {}),
|
|
204
|
-
|
|
310
|
+
// Legacy entries without platform ids are back-filled with a stable
|
|
311
|
+
// synthetic id so snapshot cleanup can still remove them exactly.
|
|
312
|
+
...(mediaMessageId || messageId ? { messageId: mediaMessageId || messageId } : {}),
|
|
205
313
|
};
|
|
206
314
|
media.push(normalizedMedia);
|
|
207
315
|
}
|
|
208
316
|
|
|
317
|
+
const rawRole = entry?.role;
|
|
318
|
+
const assistantMessageIds = options?.assistantMessageIdsByHistoryKey?.get(key);
|
|
319
|
+
const role =
|
|
320
|
+
rawRole === 'user' || rawRole === 'assistant' || rawRole === 'system'
|
|
321
|
+
? rawRole
|
|
322
|
+
: assistantMessageIds?.has(messageId)
|
|
323
|
+
? 'assistant'
|
|
324
|
+
: 'user';
|
|
325
|
+
if (!messageId) {
|
|
326
|
+
messageId = buildMigratedHistoryMessageId(
|
|
327
|
+
JSON.stringify({
|
|
328
|
+
key,
|
|
329
|
+
entryIndex,
|
|
330
|
+
sender,
|
|
331
|
+
senderId: runtime.asString(entry?.senderId || '').trim(),
|
|
332
|
+
role,
|
|
333
|
+
body,
|
|
334
|
+
timestamp,
|
|
335
|
+
media: media.map((item) => [item.path, item.contentType, item.kind, item.messageId]),
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
209
340
|
const normalizedEntry: BncrPersistedConversationHistoryEntry = {
|
|
210
341
|
sender,
|
|
211
342
|
...(runtime.asString(entry?.senderId || '').trim()
|
|
212
343
|
? { senderId: runtime.asString(entry?.senderId || '').trim() }
|
|
213
344
|
: {}),
|
|
214
|
-
|
|
215
|
-
? { role: entry.role }
|
|
216
|
-
: {}),
|
|
345
|
+
role,
|
|
217
346
|
body,
|
|
218
347
|
...(timestamp > 0 ? { timestamp } : {}),
|
|
219
348
|
...(messageId ? { messageId } : {}),
|
|
220
349
|
...(media.length > 0 ? { media } : {}),
|
|
221
350
|
};
|
|
351
|
+
if (
|
|
352
|
+
messageId &&
|
|
353
|
+
Array.isArray(normalizedEntry.media) &&
|
|
354
|
+
normalizedEntry.media.some((item) => !item.messageId)
|
|
355
|
+
) {
|
|
356
|
+
normalizedEntry.media = normalizedEntry.media.map((item) =>
|
|
357
|
+
item.messageId
|
|
358
|
+
? item
|
|
359
|
+
: {
|
|
360
|
+
...item,
|
|
361
|
+
messageId,
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
}
|
|
222
365
|
entries.push(normalizedEntry);
|
|
223
366
|
}
|
|
224
367
|
if (entries.length > 0) {
|
|
225
368
|
const limit = resolveLimit(key);
|
|
226
|
-
target.
|
|
369
|
+
const merged = options?.merge ? [...(target.get(key) || []), ...entries] : entries;
|
|
370
|
+
const seenMessageIds = new Set<string>();
|
|
371
|
+
const deduped: BncrPersistedConversationHistoryEntry[] = [];
|
|
372
|
+
for (const entry of merged) {
|
|
373
|
+
const messageId = runtime.asString(entry.messageId || '').trim();
|
|
374
|
+
if (messageId && seenMessageIds.has(messageId)) continue;
|
|
375
|
+
if (messageId) seenMessageIds.add(messageId);
|
|
376
|
+
deduped.push(entry);
|
|
377
|
+
}
|
|
378
|
+
target.set(key, Number.isFinite(limit) ? deduped.slice(-limit) : deduped);
|
|
227
379
|
}
|
|
228
380
|
}
|
|
229
381
|
}
|
|
230
382
|
|
|
231
|
-
function loadPersistedConversationHistories(
|
|
232
|
-
|
|
233
|
-
|
|
383
|
+
function loadPersistedConversationHistories(
|
|
384
|
+
persisted: unknown,
|
|
385
|
+
options?: { assistantMessageIdsByHistoryKey?: ReadonlyMap<string, ReadonlySet<string>> },
|
|
386
|
+
): void {
|
|
387
|
+
loadPersistedHistoryBuckets(
|
|
388
|
+
persisted,
|
|
389
|
+
runtime.conversationHistories,
|
|
390
|
+
(key) => resolvePersistedConversationHistoryLimit(key),
|
|
391
|
+
options,
|
|
234
392
|
);
|
|
393
|
+
// Persisted replacement invalidates freshness markers used by queued flushes.
|
|
394
|
+
resetConversationHistoryVersions(runtime.conversationHistories);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function collectPersistedOutboundAssistantMessageIds(
|
|
398
|
+
persisted: unknown,
|
|
399
|
+
): Map<string, Set<string>> {
|
|
400
|
+
const messageIdsByHistoryKey = new Map<string, Set<string>>();
|
|
401
|
+
const buckets = Array.isArray(persisted)
|
|
402
|
+
? (persisted as Array<{ key?: unknown; entries?: unknown }>)
|
|
403
|
+
: [];
|
|
404
|
+
for (const bucket of buckets) {
|
|
405
|
+
if (!bucket || typeof bucket !== 'object') continue;
|
|
406
|
+
const bucketKey = runtime.asString(bucket?.key || '').trim();
|
|
407
|
+
const rawEntries = Array.isArray(bucket.entries) ? bucket.entries : [];
|
|
408
|
+
for (const entry of rawEntries as Array<{
|
|
409
|
+
sender?: unknown;
|
|
410
|
+
body?: unknown;
|
|
411
|
+
messageId?: unknown;
|
|
412
|
+
route?: { platform?: unknown; groupId?: unknown; userId?: unknown };
|
|
413
|
+
}>) {
|
|
414
|
+
const sender = runtime.asString(entry?.sender || '').trim();
|
|
415
|
+
const body = runtime.asString(entry?.body || '').trim();
|
|
416
|
+
if (!sender || !body) continue;
|
|
417
|
+
const messageId = runtime.asString(entry?.messageId || '').trim();
|
|
418
|
+
if (!messageId) continue;
|
|
419
|
+
const route = entry?.route;
|
|
420
|
+
const historyKey =
|
|
421
|
+
buildBncrConversationHistoryKeyFromRoute({
|
|
422
|
+
platform: typeof route?.platform === 'string' ? route.platform : undefined,
|
|
423
|
+
groupId: typeof route?.groupId === 'string' ? route.groupId : undefined,
|
|
424
|
+
userId: typeof route?.userId === 'string' ? route.userId : undefined,
|
|
425
|
+
}) ?? (bucketKey.includes(':') ? bucketKey.slice(bucketKey.indexOf(':') + 1) : '');
|
|
426
|
+
if (!historyKey) continue;
|
|
427
|
+
const scopedMessageIds = messageIdsByHistoryKey.get(historyKey) ?? new Set<string>();
|
|
428
|
+
scopedMessageIds.add(messageId);
|
|
429
|
+
messageIdsByHistoryKey.set(historyKey, scopedMessageIds);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return messageIdsByHistoryKey;
|
|
235
433
|
}
|
|
236
434
|
|
|
237
|
-
function
|
|
238
|
-
|
|
435
|
+
function resolvePersistedOutboundReplayMessageId(args: {
|
|
436
|
+
bucketKey: string;
|
|
437
|
+
sender: string;
|
|
438
|
+
senderId?: string;
|
|
439
|
+
body: string;
|
|
440
|
+
timestamp: number;
|
|
441
|
+
media: BncrPersistedConversationHistoryMediaEntry[];
|
|
442
|
+
route?: BncrRoute | null;
|
|
443
|
+
historyMap?: Map<string, BncrPersistedConversationHistoryEntry[]>;
|
|
444
|
+
}): string | undefined {
|
|
445
|
+
const route = args.route;
|
|
446
|
+
const historyKey = route
|
|
447
|
+
? buildBncrConversationHistoryKeyFromRoute({
|
|
448
|
+
platform: route.platform,
|
|
449
|
+
groupId: route.groupId,
|
|
450
|
+
userId: route.userId,
|
|
451
|
+
})
|
|
452
|
+
: args.bucketKey.includes(':')
|
|
453
|
+
? args.bucketKey.slice(args.bucketKey.indexOf(':') + 1).trim()
|
|
454
|
+
: '';
|
|
455
|
+
if (!historyKey) return undefined;
|
|
456
|
+
|
|
457
|
+
const matchingHistory = args.historyMap
|
|
458
|
+
?.get(historyKey)
|
|
459
|
+
?.find(
|
|
460
|
+
(candidate) =>
|
|
461
|
+
candidate.role === 'assistant' &&
|
|
462
|
+
candidate.sender === args.sender &&
|
|
463
|
+
candidate.body === args.body &&
|
|
464
|
+
(args.timestamp <= 0 || candidate.timestamp === args.timestamp),
|
|
465
|
+
);
|
|
466
|
+
const matchingMessageId = runtime.asString(matchingHistory?.messageId || '').trim();
|
|
467
|
+
if (matchingMessageId) return matchingMessageId;
|
|
468
|
+
|
|
469
|
+
return buildBncrBotReplyMessageId({
|
|
470
|
+
historyKey,
|
|
471
|
+
sender: args.sender,
|
|
472
|
+
...(args.senderId ? { senderId: args.senderId } : {}),
|
|
473
|
+
body: args.body,
|
|
474
|
+
...(args.timestamp > 0 ? { timestamp: args.timestamp } : {}),
|
|
475
|
+
media: args.media.length > 0 ? args.media : undefined,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function loadPersistedOutboundReplayCache(
|
|
480
|
+
persisted: unknown,
|
|
481
|
+
options?: {
|
|
482
|
+
merge?: boolean;
|
|
483
|
+
historyMap?: Map<string, BncrPersistedConversationHistoryEntry[]>;
|
|
484
|
+
},
|
|
485
|
+
target: Map<string, BncrPersistedOutboundReplayEntry[]> = outboundReplayCache,
|
|
486
|
+
): void {
|
|
487
|
+
if (!options?.merge) target.clear();
|
|
239
488
|
const buckets = Array.isArray(persisted)
|
|
240
489
|
? (persisted as PersistedOutboundReplayBucketInput[])
|
|
241
490
|
: [];
|
|
@@ -282,6 +531,24 @@ export function createBncrStateStore(runtime: {
|
|
|
282
531
|
...(mediaMessageId ? { messageId: mediaMessageId } : {}),
|
|
283
532
|
});
|
|
284
533
|
}
|
|
534
|
+
const resolvedMessageId =
|
|
535
|
+
messageId ||
|
|
536
|
+
resolvePersistedOutboundReplayMessageId({
|
|
537
|
+
bucketKey: key,
|
|
538
|
+
sender,
|
|
539
|
+
senderId: runtime.asString(entry?.senderId || '').trim(),
|
|
540
|
+
body,
|
|
541
|
+
timestamp,
|
|
542
|
+
media,
|
|
543
|
+
route,
|
|
544
|
+
historyMap: options?.historyMap,
|
|
545
|
+
});
|
|
546
|
+
const normalizedMedia = media.map((item) => ({
|
|
547
|
+
...item,
|
|
548
|
+
...(item.messageId || resolvedMessageId
|
|
549
|
+
? { messageId: item.messageId || resolvedMessageId }
|
|
550
|
+
: {}),
|
|
551
|
+
}));
|
|
285
552
|
entries.push({
|
|
286
553
|
sender,
|
|
287
554
|
...(runtime.asString(entry?.senderId || '').trim()
|
|
@@ -289,8 +556,8 @@ export function createBncrStateStore(runtime: {
|
|
|
289
556
|
: {}),
|
|
290
557
|
body,
|
|
291
558
|
...(timestamp > 0 ? { timestamp } : {}),
|
|
292
|
-
...(
|
|
293
|
-
...(
|
|
559
|
+
...(resolvedMessageId ? { messageId: resolvedMessageId } : {}),
|
|
560
|
+
...(normalizedMedia.length > 0 ? { media: normalizedMedia } : {}),
|
|
294
561
|
...(accountId ? { accountId } : {}),
|
|
295
562
|
...(sessionKey ? { sessionKey } : {}),
|
|
296
563
|
...(route ? { route } : {}),
|
|
@@ -303,7 +570,18 @@ export function createBncrStateStore(runtime: {
|
|
|
303
570
|
});
|
|
304
571
|
}
|
|
305
572
|
if (entries.length > 0) {
|
|
306
|
-
|
|
573
|
+
const combined = options?.merge ? [...(target.get(key) || []), ...entries] : entries;
|
|
574
|
+
const seenMessageIds = new Set<string>();
|
|
575
|
+
const deduped: BncrOutboundReplayEntry[] = [];
|
|
576
|
+
for (const entry of combined) {
|
|
577
|
+
const messageId = runtime.asString(entry.messageId || '').trim();
|
|
578
|
+
if (messageId && seenMessageIds.has(messageId)) continue;
|
|
579
|
+
if (messageId) seenMessageIds.add(messageId);
|
|
580
|
+
deduped.push(entry);
|
|
581
|
+
}
|
|
582
|
+
if (deduped.length > 0) {
|
|
583
|
+
target.set(key, normalizeOutboundReplayBucket(key, deduped));
|
|
584
|
+
}
|
|
307
585
|
}
|
|
308
586
|
}
|
|
309
587
|
}
|
|
@@ -327,10 +605,100 @@ export function createBncrStateStore(runtime: {
|
|
|
327
605
|
function dumpPersistedOutboundReplayCache() {
|
|
328
606
|
return Array.from(outboundReplayCache.entries()).map(([key, entries]) => ({
|
|
329
607
|
key,
|
|
330
|
-
entries,
|
|
608
|
+
entries: normalizeOutboundReplayBucket(key, entries),
|
|
609
|
+
}));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function dumpPersistedReplayBucketsFromMap(
|
|
613
|
+
source: Map<string, BncrPersistedOutboundReplayEntry[]>,
|
|
614
|
+
): BncrPersistedOutboundReplayBucket[] {
|
|
615
|
+
return Array.from(source.entries()).map(([key, entries]) => ({
|
|
616
|
+
key,
|
|
617
|
+
entries: normalizeOutboundReplayBucket(key, entries),
|
|
331
618
|
}));
|
|
332
619
|
}
|
|
333
620
|
|
|
621
|
+
function buildMergedSqliteHistoryState(
|
|
622
|
+
state: BncrSqliteStateDatabase,
|
|
623
|
+
skipHistoryKeys: readonly string[],
|
|
624
|
+
current?: BncrSqliteHistoryState,
|
|
625
|
+
): BncrSqliteHistoryState {
|
|
626
|
+
const sqliteHistory =
|
|
627
|
+
current ?? state.loadHistoryState(skipHistoryKeys, getConversationHistorySerialOwner());
|
|
628
|
+
const historyTarget = new Map<string, BncrPersistedConversationHistoryEntry[]>();
|
|
629
|
+
loadPersistedHistoryBuckets(sqliteHistory.historyBuckets, historyTarget, (key) =>
|
|
630
|
+
resolvePersistedConversationHistoryLimit(key),
|
|
631
|
+
);
|
|
632
|
+
loadPersistedHistoryBuckets(
|
|
633
|
+
dumpPersistedConversationHistories(),
|
|
634
|
+
historyTarget,
|
|
635
|
+
(key) => resolvePersistedConversationHistoryLimit(key),
|
|
636
|
+
{ merge: true },
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
const replayTarget = new Map<string, BncrPersistedOutboundReplayEntry[]>();
|
|
640
|
+
loadPersistedOutboundReplayCache(
|
|
641
|
+
sqliteHistory.replayBuckets,
|
|
642
|
+
{ historyMap: runtime.conversationHistories },
|
|
643
|
+
replayTarget,
|
|
644
|
+
);
|
|
645
|
+
loadPersistedOutboundReplayCache(
|
|
646
|
+
dumpPersistedOutboundReplayCache(),
|
|
647
|
+
{ merge: true, historyMap: runtime.conversationHistories },
|
|
648
|
+
replayTarget,
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
historyBuckets: dumpPersistedHistoryBuckets(historyTarget, (key) =>
|
|
653
|
+
resolvePersistedConversationHistoryLimit(key),
|
|
654
|
+
),
|
|
655
|
+
replayBuckets: dumpPersistedReplayBucketsFromMap(replayTarget),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function saveMergedSqliteHistoryState(
|
|
660
|
+
state: BncrSqliteStateDatabase,
|
|
661
|
+
skipHistoryKeys: readonly string[],
|
|
662
|
+
): void {
|
|
663
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
664
|
+
// Optimistic revision protects rows written by another instance between
|
|
665
|
+
// our read and write; a conflict reloads and merges before retrying.
|
|
666
|
+
const current = state.loadHistoryState(skipHistoryKeys, getConversationHistorySerialOwner());
|
|
667
|
+
const merged = buildMergedSqliteHistoryState(state, skipHistoryKeys, current);
|
|
668
|
+
const revision = state.getHistoryStateRevision();
|
|
669
|
+
try {
|
|
670
|
+
state.saveHistoryState(merged.historyBuckets, merged.replayBuckets, revision);
|
|
671
|
+
return;
|
|
672
|
+
} catch (error) {
|
|
673
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
674
|
+
if (message.includes('history revision conflict') && attempt < 7) continue;
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function reconcileHistoryMemoryWithSqlite(historyKey?: string): Promise<void> {
|
|
681
|
+
const state = await resolveSqliteState(runtime.getStatePath());
|
|
682
|
+
if (!state) return;
|
|
683
|
+
const mode = state.getStoreMode();
|
|
684
|
+
if (mode !== 'dual' && mode !== 'sqlite') return;
|
|
685
|
+
const skipHistoryKeys = historyKey ? [historyKey] : readConversationHistorySerialHistoryKeys();
|
|
686
|
+
saveMergedSqliteHistoryState(state, skipHistoryKeys);
|
|
687
|
+
|
|
688
|
+
const sqliteHistory = state.loadHistoryState(
|
|
689
|
+
skipHistoryKeys,
|
|
690
|
+
getConversationHistorySerialOwner(),
|
|
691
|
+
);
|
|
692
|
+
loadPersistedHistoryBuckets(
|
|
693
|
+
sqliteHistory.historyBuckets,
|
|
694
|
+
runtime.conversationHistories,
|
|
695
|
+
(key) => resolvePersistedConversationHistoryLimit(key),
|
|
696
|
+
);
|
|
697
|
+
loadPersistedOutboundReplayCache(sqliteHistory.replayBuckets, {
|
|
698
|
+
historyMap: runtime.conversationHistories,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
334
702
|
function loadPersistedAccountTimestampMap(target: Map<string, number>, persisted: unknown): void {
|
|
335
703
|
target.clear();
|
|
336
704
|
const items = Array.isArray(persisted)
|
|
@@ -448,14 +816,104 @@ export function createBncrStateStore(runtime: {
|
|
|
448
816
|
}
|
|
449
817
|
}
|
|
450
818
|
|
|
451
|
-
|
|
819
|
+
function dumpSqliteControlState(): BncrSqliteControlState {
|
|
820
|
+
return {
|
|
821
|
+
sessionRoutes: dumpPersistedSessionRoutes(),
|
|
822
|
+
sceneRegistry: dumpPersistedSceneRegistry(),
|
|
823
|
+
lastSessionByAccount: dumpPersistedLastSessionMap(),
|
|
824
|
+
lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
|
|
825
|
+
lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
|
|
826
|
+
lastOutboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastOutboundByAccount),
|
|
827
|
+
lastDriftSnapshot: dumpRegisterDriftSnapshot(runtime.getLastDriftSnapshot() ?? null),
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function applySqliteOutboundState(sqliteOutbound: BncrSqliteOutboundState): void {
|
|
832
|
+
const jsonOutbox = new Map(runtime.outbox);
|
|
833
|
+
const jsonDead = new Map(
|
|
834
|
+
runtime.getDeadLetter().map((entry) => [entry.messageId, entry] as const),
|
|
835
|
+
);
|
|
836
|
+
const sqliteOutbox = new Map<string, OutboxEntry>();
|
|
837
|
+
const sqliteDead = new Map<string, OutboxEntry>();
|
|
838
|
+
for (const entry of sqliteOutbound.outbox) {
|
|
839
|
+
const normalized = runtime.normalizePersistedOutboxEntry(entry);
|
|
840
|
+
if (normalized) sqliteOutbox.set(normalized.messageId, normalized);
|
|
841
|
+
}
|
|
842
|
+
for (const entry of sqliteOutbound.deadLetter) {
|
|
843
|
+
const normalized = runtime.normalizePersistedOutboxEntry(entry);
|
|
844
|
+
if (normalized) sqliteDead.set(normalized.messageId, normalized);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const knownMessageIds = new Set<string>([...sqliteOutbox.keys(), ...sqliteDead.keys()]);
|
|
848
|
+
runtime.outbox.clear();
|
|
849
|
+
for (const entry of sqliteOutbox.values()) runtime.outbox.set(entry.messageId, entry);
|
|
850
|
+
for (const [messageId, entry] of jsonOutbox) {
|
|
851
|
+
if (!knownMessageIds.has(messageId)) runtime.outbox.set(messageId, entry);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const deadLetter = [...sqliteDead.values()];
|
|
855
|
+
for (const [messageId, entry] of jsonDead) {
|
|
856
|
+
if (!knownMessageIds.has(messageId)) deadLetter.push(entry);
|
|
857
|
+
}
|
|
858
|
+
runtime.setDeadLetter(deadLetter.slice(-runtime.maxDeadLetterEntries));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function buildPersistedStateSnapshot(): BncrPersistedState {
|
|
862
|
+
return {
|
|
863
|
+
outbox: Array.from(runtime.outbox.values()),
|
|
864
|
+
deadLetter: runtime.getDeadLetter().slice(-runtime.maxDeadLetterEntries),
|
|
865
|
+
sessionRoutes: dumpPersistedSessionRoutes(),
|
|
866
|
+
sceneRegistry: dumpPersistedSceneRegistry(),
|
|
867
|
+
conversationHistories: dumpPersistedConversationHistories(),
|
|
868
|
+
outboundReplayCache: dumpPersistedOutboundReplayCache(),
|
|
869
|
+
lastSessionByAccount: dumpPersistedLastSessionMap(),
|
|
870
|
+
lastActivityByAccount: dumpPersistedAccountTimestampMap(runtime.lastActivityByAccount),
|
|
871
|
+
lastInboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastInboundByAccount),
|
|
872
|
+
lastOutboundByAccount: dumpPersistedAccountTimestampMap(runtime.lastOutboundByAccount),
|
|
873
|
+
lastDriftSnapshot: dumpRegisterDriftSnapshot(runtime.getLastDriftSnapshot() ?? null),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function createHistoryShard(
|
|
878
|
+
input: BncrHistoryShardCreateInput,
|
|
879
|
+
): BncrHistoryShardCreateResult | null {
|
|
880
|
+
if (!sqliteState) return null;
|
|
881
|
+
const mode = sqliteState.getStoreMode();
|
|
882
|
+
if (mode !== 'dual' && mode !== 'sqlite') return null;
|
|
883
|
+
const skipHistoryKeys = input.historyKey
|
|
884
|
+
? [input.historyKey]
|
|
885
|
+
: readConversationHistorySerialHistoryKeys();
|
|
886
|
+
saveMergedSqliteHistoryState(sqliteState, skipHistoryKeys);
|
|
887
|
+
return sqliteState.createHistoryShard(input);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function completeHistoryShard(shardId: number): void {
|
|
891
|
+
sqliteState?.completeHistoryShard(shardId, getConversationHistorySerialOwner());
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function loadState(skipHistoryKeys?: readonly string[]) {
|
|
452
895
|
const statePath = runtime.getStatePath();
|
|
453
|
-
|
|
454
|
-
|
|
896
|
+
const activeSqliteState = await resolveSqliteState(statePath);
|
|
897
|
+
if (!statePath && !activeSqliteState) return;
|
|
898
|
+
let legacyJsonSha256: string | undefined;
|
|
899
|
+
if (statePath && activeSqliteState && !activeSqliteState.isControlStateImported()) {
|
|
900
|
+
try {
|
|
901
|
+
legacyJsonSha256 = createHash('sha256')
|
|
902
|
+
.update(await readFile(statePath))
|
|
903
|
+
.digest('hex');
|
|
904
|
+
} catch {
|
|
905
|
+
// A missing JSON file is handled by the fallback below; it is not a migration source.
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const defaults = {
|
|
455
909
|
outbox: [],
|
|
456
910
|
deadLetter: [],
|
|
457
911
|
sessionRoutes: [],
|
|
458
|
-
}
|
|
912
|
+
};
|
|
913
|
+
const loaded =
|
|
914
|
+
statePath && activeSqliteState?.getStoreMode() !== 'sqlite'
|
|
915
|
+
? await readOpenClawJsonFileWithFallback(statePath, defaults)
|
|
916
|
+
: { value: defaults };
|
|
459
917
|
const data = loaded.value as BncrPersistedStateStoreInput;
|
|
460
918
|
|
|
461
919
|
runtime.outbox.clear();
|
|
@@ -478,38 +936,223 @@ export function createBncrStateStore(runtime: {
|
|
|
478
936
|
|
|
479
937
|
loadPersistedSessionRoutes(data.sessionRoutes);
|
|
480
938
|
loadPersistedSceneRegistry(data.sceneRegistry);
|
|
481
|
-
|
|
482
|
-
|
|
939
|
+
const persistedOutboundReplay = data.outboundReplayCache ?? data.messageCache;
|
|
940
|
+
loadPersistedConversationHistories(data.conversationHistories ?? data.groupHistories, {
|
|
941
|
+
assistantMessageIdsByHistoryKey:
|
|
942
|
+
collectPersistedOutboundAssistantMessageIds(persistedOutboundReplay),
|
|
943
|
+
});
|
|
944
|
+
loadPersistedOutboundReplayCache(persistedOutboundReplay, {
|
|
945
|
+
historyMap: runtime.conversationHistories,
|
|
946
|
+
});
|
|
483
947
|
loadPersistedLastSessionMap(data.lastSessionByAccount);
|
|
484
948
|
loadPersistedAccountTimestampMap(runtime.lastActivityByAccount, data.lastActivityByAccount);
|
|
485
949
|
loadPersistedAccountTimestampMap(runtime.lastInboundByAccount, data.lastInboundByAccount);
|
|
486
950
|
loadPersistedAccountTimestampMap(runtime.lastOutboundByAccount, data.lastOutboundByAccount);
|
|
487
951
|
|
|
488
952
|
runtime.setLastDriftSnapshot(normalizeRegisterDriftSnapshot(data.lastDriftSnapshot));
|
|
953
|
+
if (activeSqliteState) {
|
|
954
|
+
const mode = activeSqliteState.getStoreMode();
|
|
955
|
+
if ((mode === 'dual' || mode === 'sqlite') && activeSqliteState.isControlStateImported()) {
|
|
956
|
+
const control = activeSqliteState.loadControlState();
|
|
957
|
+
loadPersistedSessionRoutes(control.sessionRoutes);
|
|
958
|
+
loadPersistedSceneRegistry(control.sceneRegistry);
|
|
959
|
+
loadPersistedLastSessionMap(control.lastSessionByAccount);
|
|
960
|
+
loadPersistedAccountTimestampMap(
|
|
961
|
+
runtime.lastActivityByAccount,
|
|
962
|
+
control.lastActivityByAccount,
|
|
963
|
+
);
|
|
964
|
+
loadPersistedAccountTimestampMap(
|
|
965
|
+
runtime.lastInboundByAccount,
|
|
966
|
+
control.lastInboundByAccount,
|
|
967
|
+
);
|
|
968
|
+
loadPersistedAccountTimestampMap(
|
|
969
|
+
runtime.lastOutboundByAccount,
|
|
970
|
+
control.lastOutboundByAccount,
|
|
971
|
+
);
|
|
972
|
+
runtime.setLastDriftSnapshot(control.lastDriftSnapshot);
|
|
973
|
+
} else if (!activeSqliteState.isControlStateImported()) {
|
|
974
|
+
activeSqliteState.importControlState(dumpSqliteControlState(), {
|
|
975
|
+
...(statePath ? { legacyJsonPath: statePath } : {}),
|
|
976
|
+
...(legacyJsonSha256 ? { legacyJsonSha256 } : {}),
|
|
977
|
+
storeMode: 'dual',
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
const activeMode = activeSqliteState.getStoreMode();
|
|
981
|
+
if (activeMode === 'dual' || activeMode === 'sqlite') {
|
|
982
|
+
if (activeSqliteState.isHistoryImported()) {
|
|
983
|
+
const sqliteHistory = activeSqliteState.loadHistoryState(
|
|
984
|
+
skipHistoryKeys,
|
|
985
|
+
getConversationHistorySerialOwner(),
|
|
986
|
+
);
|
|
987
|
+
loadPersistedConversationHistories(sqliteHistory.historyBuckets);
|
|
988
|
+
loadPersistedOutboundReplayCache(sqliteHistory.replayBuckets, {
|
|
989
|
+
historyMap: runtime.conversationHistories,
|
|
990
|
+
});
|
|
991
|
+
} else {
|
|
992
|
+
saveMergedSqliteHistoryState(activeSqliteState, skipHistoryKeys ?? []);
|
|
993
|
+
}
|
|
994
|
+
if (activeSqliteState.isOutboundImported()) {
|
|
995
|
+
applySqliteOutboundState(activeSqliteState.loadOutboundState());
|
|
996
|
+
activeSqliteState.saveOutboundState(
|
|
997
|
+
Array.from(runtime.outbox.values()),
|
|
998
|
+
runtime.getDeadLetter(),
|
|
999
|
+
);
|
|
1000
|
+
} else {
|
|
1001
|
+
activeSqliteState.importOutboundState(
|
|
1002
|
+
Array.from(runtime.outbox.values()),
|
|
1003
|
+
runtime.getDeadLetter(),
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
489
1008
|
backfillAccountActivityFromSessionRoutes();
|
|
490
1009
|
}
|
|
491
1010
|
|
|
492
1011
|
async function flushState() {
|
|
493
1012
|
const statePath = runtime.getStatePath();
|
|
494
|
-
|
|
1013
|
+
const sqliteState = await resolveSqliteState(statePath);
|
|
1014
|
+
const sqliteMode = sqliteState?.getStoreMode();
|
|
1015
|
+
if (!statePath && !(sqliteMode === 'dual' || sqliteMode === 'sqlite')) return;
|
|
495
1016
|
|
|
496
|
-
const data
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1017
|
+
const data = buildPersistedStateSnapshot();
|
|
1018
|
+
|
|
1019
|
+
if (sqliteState && (sqliteMode === 'dual' || sqliteMode === 'sqlite')) {
|
|
1020
|
+
sqliteState.saveControlState(dumpSqliteControlState());
|
|
1021
|
+
const skipHistoryKeys = readConversationHistorySerialHistoryKeys();
|
|
1022
|
+
saveMergedSqliteHistoryState(sqliteState, skipHistoryKeys);
|
|
1023
|
+
if (sqliteState.isOutboundImported()) {
|
|
1024
|
+
sqliteState.saveOutboundState(Array.from(runtime.outbox.values()), runtime.getDeadLetter());
|
|
1025
|
+
} else {
|
|
1026
|
+
sqliteState.importOutboundState(
|
|
1027
|
+
Array.from(runtime.outbox.values()),
|
|
1028
|
+
runtime.getDeadLetter(),
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (statePath && sqliteMode !== 'sqlite') {
|
|
1033
|
+
await writeOpenClawJsonFileAtomically(statePath, data);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function exportSqliteStateToJson(): Promise<BncrPersistedState> {
|
|
1038
|
+
const statePath = runtime.getStatePath();
|
|
1039
|
+
const sqlite = await resolveSqliteState(statePath);
|
|
1040
|
+
if (!sqlite) throw new Error('bncr sqlite export requires an active sqlite backend');
|
|
1041
|
+
const mode = sqlite.getStoreMode();
|
|
1042
|
+
if (mode !== 'dual' && mode !== 'sqlite') {
|
|
1043
|
+
throw new Error(`bncr sqlite export requires dual or sqlite mode, got ${String(mode)}`);
|
|
1044
|
+
}
|
|
1045
|
+
await flushState();
|
|
1046
|
+
await loadState();
|
|
1047
|
+
return buildPersistedStateSnapshot();
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function cutoverToSqlite(): Promise<{ backupPath: string | null; storeMode: string }> {
|
|
1051
|
+
const statePath = runtime.getStatePath();
|
|
1052
|
+
const sqlite = await resolveSqliteState(statePath);
|
|
1053
|
+
if (!sqlite) throw new Error('bncr sqlite cutover requires an active sqlite backend');
|
|
1054
|
+
const mode = sqlite.getStoreMode();
|
|
1055
|
+
if (mode !== 'dual' && mode !== 'sqlite') {
|
|
1056
|
+
throw new Error(`bncr sqlite cutover requires dual or sqlite mode, got ${String(mode)}`);
|
|
1057
|
+
}
|
|
1058
|
+
if (
|
|
1059
|
+
!sqlite.isControlStateImported() ||
|
|
1060
|
+
!sqlite.isHistoryImported() ||
|
|
1061
|
+
!sqlite.isOutboundImported()
|
|
1062
|
+
) {
|
|
1063
|
+
throw new Error('bncr sqlite cutover requires control, history, and outbound state imported');
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
await flushState();
|
|
1067
|
+
const outbound = sqlite.loadOutboundState();
|
|
1068
|
+
if (outbound.outbox.length !== runtime.outbox.size) {
|
|
1069
|
+
throw new Error('bncr sqlite cutover outbox count mismatch');
|
|
1070
|
+
}
|
|
1071
|
+
if (outbound.deadLetter.length !== runtime.getDeadLetter().length) {
|
|
1072
|
+
throw new Error('bncr sqlite cutover dead-letter count mismatch');
|
|
1073
|
+
}
|
|
1074
|
+
const outstandingHistoryShards = sqlite.listHistoryShards();
|
|
1075
|
+
if (outstandingHistoryShards.length > 0) {
|
|
1076
|
+
throw new Error('bncr sqlite cutover requires no outstanding history shards');
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const timestamp = formatSqliteCutoverTimestamp(runtime.now());
|
|
1080
|
+
const computedBackupPath = statePath
|
|
1081
|
+
? join(dirname(statePath), `${basename(statePath, '.json')}.pre-sqlite-${timestamp}.json`)
|
|
1082
|
+
: null;
|
|
1083
|
+
const backupPath =
|
|
1084
|
+
mode === 'sqlite'
|
|
1085
|
+
? (sqlite.getMeta('sqlite_cutover_backup_path') ?? computedBackupPath)
|
|
1086
|
+
: computedBackupPath;
|
|
1087
|
+
if (statePath && mode !== 'sqlite') {
|
|
1088
|
+
try {
|
|
1089
|
+
await rename(statePath, backupPath!);
|
|
1090
|
+
sqlite.setMeta('sqlite_cutover_backup_path', backupPath!);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
509
1095
|
|
|
510
|
-
|
|
1096
|
+
sqlite.setStoreMode('sqlite');
|
|
1097
|
+
sqlite.setMeta('sqlite_cutover_at', String(runtime.now()));
|
|
1098
|
+
return { backupPath, storeMode: sqlite.getStoreMode() ?? 'sqlite' };
|
|
511
1099
|
}
|
|
512
1100
|
|
|
1101
|
+
const historyShardQueue: BncrHistoryShardQueue = {
|
|
1102
|
+
createHistoryShard,
|
|
1103
|
+
reconcileHistoryMemory: (historyKey) => reconcileHistoryMemoryWithSqlite(historyKey),
|
|
1104
|
+
claimNextHistoryShard: () => {
|
|
1105
|
+
if (!sqliteState) return null;
|
|
1106
|
+
sqliteState.cleanupCompletedHistoryShards();
|
|
1107
|
+
return (
|
|
1108
|
+
sqliteState.claimNextHistoryShard(
|
|
1109
|
+
readConversationHistorySerialHistoryKeys(),
|
|
1110
|
+
getConversationHistorySerialOwner(),
|
|
1111
|
+
) ?? null
|
|
1112
|
+
);
|
|
1113
|
+
},
|
|
1114
|
+
completeHistoryShard: (shardId, owner) =>
|
|
1115
|
+
sqliteState?.completeHistoryShard(shardId, owner || getConversationHistorySerialOwner()),
|
|
1116
|
+
markHistoryShardProcessing: (shardId, owner) =>
|
|
1117
|
+
sqliteState?.markHistoryShardProcessing(
|
|
1118
|
+
shardId,
|
|
1119
|
+
owner || getConversationHistorySerialOwner(),
|
|
1120
|
+
) ?? false,
|
|
1121
|
+
markHistoryShardFailed: (shardId, error, owner) => {
|
|
1122
|
+
const result = sqliteState?.markHistoryShardFailed(
|
|
1123
|
+
shardId,
|
|
1124
|
+
error,
|
|
1125
|
+
owner || getConversationHistorySerialOwner(),
|
|
1126
|
+
) ?? {
|
|
1127
|
+
attempts: 0,
|
|
1128
|
+
terminal: false,
|
|
1129
|
+
};
|
|
1130
|
+
if (result.terminal && sqliteState) {
|
|
1131
|
+
const restored = sqliteState.restoreTerminalHistoryShardsToActive();
|
|
1132
|
+
loadPersistedHistoryBuckets(
|
|
1133
|
+
restored.historyBuckets,
|
|
1134
|
+
runtime.conversationHistories,
|
|
1135
|
+
resolvePersistedConversationHistoryLimit,
|
|
1136
|
+
{ merge: true },
|
|
1137
|
+
);
|
|
1138
|
+
loadPersistedOutboundReplayCache(restored.replayBuckets, {
|
|
1139
|
+
merge: true,
|
|
1140
|
+
historyMap: runtime.conversationHistories,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
return result;
|
|
1144
|
+
},
|
|
1145
|
+
markHistoryShardCompleted: (shardId, owner) =>
|
|
1146
|
+
sqliteState?.markHistoryShardCompleted(
|
|
1147
|
+
shardId,
|
|
1148
|
+
owner || getConversationHistorySerialOwner(),
|
|
1149
|
+
) ?? false,
|
|
1150
|
+
renewHistoryShardLease: (shardId, owner) =>
|
|
1151
|
+
sqliteState?.renewHistoryShardLease(shardId, owner || getConversationHistorySerialOwner()) ??
|
|
1152
|
+
false,
|
|
1153
|
+
};
|
|
1154
|
+
registerBncrHistoryShardQueue(runtime.conversationHistories, historyShardQueue);
|
|
1155
|
+
|
|
513
1156
|
return {
|
|
514
1157
|
loadPersistedAccountTimestampMap,
|
|
515
1158
|
dumpPersistedAccountTimestampMap,
|
|
@@ -526,5 +1169,20 @@ export function createBncrStateStore(runtime: {
|
|
|
526
1169
|
backfillAccountActivityFromSessionRoutes,
|
|
527
1170
|
loadState,
|
|
528
1171
|
flushState,
|
|
1172
|
+
exportSqliteStateToJson,
|
|
1173
|
+
cutoverToSqlite,
|
|
1174
|
+
createHistoryShard,
|
|
1175
|
+
completeHistoryShard,
|
|
1176
|
+
recoverInFlightHistoryShards: async (skipHistoryKeys?: readonly string[]) => {
|
|
1177
|
+
const statePath = runtime.getStatePath();
|
|
1178
|
+
const sqlite = await resolveSqliteState(statePath);
|
|
1179
|
+
return (
|
|
1180
|
+
sqlite?.recoverInFlightHistoryShards(
|
|
1181
|
+
skipHistoryKeys,
|
|
1182
|
+
getConversationHistorySerialOwner(),
|
|
1183
|
+
) ?? 0
|
|
1184
|
+
);
|
|
1185
|
+
},
|
|
1186
|
+
historyShardQueue,
|
|
529
1187
|
};
|
|
530
1188
|
}
|