@yeaft/webchat-agent 1.0.543 → 1.0.545
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/connection/message-router.js +14 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +183 -118
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +400 -33
|
Binary file
|
package/package.json
CHANGED
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* Reference: yeaft-yeaft-core-systems.md §4.1, yeaft-yeaft-brainstorm-v5.1.md
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync } from 'fs';
|
|
20
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync, rmSync } from 'fs';
|
|
21
21
|
import { createHash, randomUUID } from 'node:crypto';
|
|
22
|
-
import {
|
|
22
|
+
import { hostname } from 'node:os';
|
|
23
|
+
import { join, basename, resolve } from 'path';
|
|
23
24
|
import { isPermissionError } from '../init.js';
|
|
24
25
|
import { writeAtomic } from '../storage/atomic.js';
|
|
25
26
|
import { pairSanitize } from '../pair-sanitize.js';
|
|
@@ -76,6 +77,138 @@ const SEGMENT_LINEAGE_FILE = 'lineage.json';
|
|
|
76
77
|
const SEGMENT_DIR = 'segments';
|
|
77
78
|
const SEGMENT_TARGET_BYTES = 1024 * 1024;
|
|
78
79
|
const SEGMENT_FIRST_NAME = '000001.jsonl';
|
|
80
|
+
const MESSAGE_SEQUENCE_FILE = 'message-sequence.json';
|
|
81
|
+
const MESSAGE_SEQUENCE_LOCK_DIR = 'message-sequence.lock';
|
|
82
|
+
const MESSAGE_SEQUENCE_LOCK_STALE_MS = 30_000;
|
|
83
|
+
const MESSAGE_SEQUENCE_LOCK_ATTEMPTS = 200;
|
|
84
|
+
const MESSAGE_SEQUENCE_LOCK_RETRY_MS = 5;
|
|
85
|
+
const MESSAGE_SEQUENCE_RESERVATION_SIZE = 256;
|
|
86
|
+
// Shared by every ConversationStore in this process. The durable sidecar owns
|
|
87
|
+
// cross-process uniqueness; this cache only consumes ranges already reserved
|
|
88
|
+
// by this process, so long-lived stores cannot diverge or reuse one another's ids.
|
|
89
|
+
const messageSequenceReservations = new Map();
|
|
90
|
+
|
|
91
|
+
function sleepSync(ms) {
|
|
92
|
+
if (ms <= 0) return;
|
|
93
|
+
const buffer = new Int32Array(new SharedArrayBuffer(4));
|
|
94
|
+
Atomics.wait(buffer, 0, 0, ms);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function processIsAlive(pid) {
|
|
98
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
99
|
+
try {
|
|
100
|
+
process.kill(pid, 0);
|
|
101
|
+
return true;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return error?.code === 'EPERM';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readMessageSequenceLock(lockDir) {
|
|
108
|
+
const details = statSync(lockDir);
|
|
109
|
+
try {
|
|
110
|
+
return { owner: JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8')), details };
|
|
111
|
+
} catch {
|
|
112
|
+
return { owner: null, details };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function messageSequenceLockIdentity(owner) {
|
|
117
|
+
if (!owner) return null;
|
|
118
|
+
if (typeof owner.token === 'string' && owner.token) return `token:${owner.token}`;
|
|
119
|
+
return `legacy:${owner.host || ''}:${Number(owner.pid) || 0}:${Number(owner.startedAt) || 0}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function messageSequenceLockCanBeTaken(lockDir) {
|
|
123
|
+
const { owner, details } = readMessageSequenceLock(lockDir);
|
|
124
|
+
if (owner?.host === hostname()) return !processIsAlive(Number(owner.pid));
|
|
125
|
+
// Yeaft data roots are local, but a hostname can change after restoring a
|
|
126
|
+
// machine image. Never steal a live local owner's lock based on age alone;
|
|
127
|
+
// an unverifiable remote/legacy owner is reclaimable only after the stale
|
|
128
|
+
// window and only if takeMessageSequenceLock observes the same identity.
|
|
129
|
+
return Date.now() - details.mtimeMs > MESSAGE_SEQUENCE_LOCK_STALE_MS;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function messageSequenceLockIsOwned(lockDir, token) {
|
|
133
|
+
try {
|
|
134
|
+
const owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8'));
|
|
135
|
+
return owner?.token === token;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function releaseMessageSequenceLock(lockDir, token) {
|
|
142
|
+
if (!messageSequenceLockIsOwned(lockDir, token)) return false;
|
|
143
|
+
const claimed = `${lockDir}.release-${token}`;
|
|
144
|
+
try {
|
|
145
|
+
renameSync(lockDir, claimed);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error?.code === 'ENOENT') return false;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
if (!messageSequenceLockIsOwned(claimed, token)) {
|
|
151
|
+
try { renameSync(claimed, lockDir); } catch {}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
rmSync(claimed, { recursive: true, force: true });
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function takeMessageSequenceLock(lockDir) {
|
|
159
|
+
const observed = readMessageSequenceLock(lockDir).owner;
|
|
160
|
+
if (!messageSequenceLockCanBeTaken(lockDir)) return false;
|
|
161
|
+
const observedIdentity = messageSequenceLockIdentity(observed);
|
|
162
|
+
const claimed = `${lockDir}.stale-${randomUUID()}`;
|
|
163
|
+
try {
|
|
164
|
+
renameSync(lockDir, claimed);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error?.code === 'ENOENT') return true;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const claimedOwner = readMessageSequenceLock(claimed).owner;
|
|
170
|
+
const ownerChanged = messageSequenceLockIdentity(claimedOwner) !== observedIdentity;
|
|
171
|
+
const ownerRevived = claimedOwner?.host === hostname()
|
|
172
|
+
&& processIsAlive(Number(claimedOwner.pid));
|
|
173
|
+
if (ownerChanged || ownerRevived) {
|
|
174
|
+
try { renameSync(claimed, lockDir); } catch {}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
rmSync(claimed, { recursive: true, force: true });
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function acquireMessageSequenceLock(lockDir) {
|
|
182
|
+
for (let attempt = 0; attempt < MESSAGE_SEQUENCE_LOCK_ATTEMPTS; attempt += 1) {
|
|
183
|
+
const token = randomUUID();
|
|
184
|
+
try {
|
|
185
|
+
mkdirSync(lockDir, { mode: 0o700 });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
188
|
+
try {
|
|
189
|
+
if (takeMessageSequenceLock(lockDir)) continue;
|
|
190
|
+
} catch (inspectionError) {
|
|
191
|
+
if (inspectionError?.code === 'ENOENT') continue;
|
|
192
|
+
throw inspectionError;
|
|
193
|
+
}
|
|
194
|
+
sleepSync(MESSAGE_SEQUENCE_LOCK_RETRY_MS);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({
|
|
199
|
+
pid: process.pid,
|
|
200
|
+
host: hostname(),
|
|
201
|
+
token,
|
|
202
|
+
startedAt: Date.now(),
|
|
203
|
+
}), { flag: 'wx', mode: 0o600 });
|
|
204
|
+
return token;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
throw new Error('Timed out reserving conversation message ids');
|
|
211
|
+
}
|
|
79
212
|
|
|
80
213
|
function latestTodoWriteSnapshot(toolCalls) {
|
|
81
214
|
if (!Array.isArray(toolCalls)) return null;
|
|
@@ -817,6 +950,87 @@ class SegmentStore {
|
|
|
817
950
|
this.saveIndex();
|
|
818
951
|
}
|
|
819
952
|
|
|
953
|
+
/**
|
|
954
|
+
* Append messages whose persisted ids have already been allocated.
|
|
955
|
+
* Session copies own a complete ordered snapshot, so publishing segment data
|
|
956
|
+
* and metadata in batches avoids rewriting the index once per message.
|
|
957
|
+
*
|
|
958
|
+
* @param {object[]} messages
|
|
959
|
+
* @returns {object[]}
|
|
960
|
+
*/
|
|
961
|
+
appendBatch(messages) {
|
|
962
|
+
const rows = (messages || []).filter(Boolean);
|
|
963
|
+
if (rows.length === 0) return [];
|
|
964
|
+
this.ensure();
|
|
965
|
+
const idx = this.loadIndex();
|
|
966
|
+
const establishesAnchor = (Number(idx.totalMessages) || 0) === 0
|
|
967
|
+
&& (!Array.isArray(idx.segments) || idx.segments.length === 0);
|
|
968
|
+
let firstLine = null;
|
|
969
|
+
let revisionDelta = 0;
|
|
970
|
+
const foldedIds = [];
|
|
971
|
+
let segment = idx.segments[idx.segments.length - 1] || null;
|
|
972
|
+
let active = segment?.file || idx.activeSegment || SEGMENT_FIRST_NAME;
|
|
973
|
+
let activePath = join(this.segmentDir, active);
|
|
974
|
+
let currentSize = existsSync(activePath) ? statSync(activePath).size : 0;
|
|
975
|
+
let pendingLines = [];
|
|
976
|
+
let pendingBytes = 0;
|
|
977
|
+
|
|
978
|
+
const flush = () => {
|
|
979
|
+
if (pendingLines.length === 0) return;
|
|
980
|
+
appendFileSync(activePath, pendingLines.join(''), { encoding: 'utf8', mode: 0o644 });
|
|
981
|
+
pendingLines = [];
|
|
982
|
+
pendingBytes = 0;
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
for (const msg of rows) {
|
|
986
|
+
const line = `${JSON.stringify(msg)}\n`;
|
|
987
|
+
const lineBytes = Buffer.byteLength(line);
|
|
988
|
+
if (currentSize + pendingBytes > 0
|
|
989
|
+
&& currentSize + pendingBytes + lineBytes >= SEGMENT_TARGET_BYTES) {
|
|
990
|
+
flush();
|
|
991
|
+
active = nextSegmentName(active);
|
|
992
|
+
activePath = join(this.segmentDir, active);
|
|
993
|
+
currentSize = existsSync(activePath) ? statSync(activePath).size : 0;
|
|
994
|
+
segment = null;
|
|
995
|
+
}
|
|
996
|
+
if (!segment || segment.file !== active) {
|
|
997
|
+
const seq = parseSeqFromId(msg.id);
|
|
998
|
+
segment = { file: active, firstSeq: seq, lastSeq: seq, count: 0, bytes: currentSize };
|
|
999
|
+
idx.segments.push(segment);
|
|
1000
|
+
}
|
|
1001
|
+
const seq = parseSeqFromId(msg.id);
|
|
1002
|
+
segment.firstSeq = Number.isFinite(segment.firstSeq) ? Math.min(segment.firstSeq, seq) : seq;
|
|
1003
|
+
segment.lastSeq = Number.isFinite(segment.lastSeq) ? Math.max(segment.lastSeq, seq) : seq;
|
|
1004
|
+
segment.count = (segment.count || 0) + 1;
|
|
1005
|
+
segment.bytes = (segment.bytes || 0) + lineBytes;
|
|
1006
|
+
idx.totalMessages = (idx.totalMessages || 0) + 1;
|
|
1007
|
+
idx.lastMessageId = msg.id || null;
|
|
1008
|
+
idx.nextSeq = Math.max(Number(idx.nextSeq) || 1, seq + 1);
|
|
1009
|
+
idx.activeSegment = active;
|
|
1010
|
+
if (!firstLine) firstLine = line;
|
|
1011
|
+
if (msg._reflection || (Array.isArray(msg.foldedMessageIds) && msg.foldedMessageIds.length > 0)) {
|
|
1012
|
+
revisionDelta += 1;
|
|
1013
|
+
}
|
|
1014
|
+
if (msg._reflection && Array.isArray(msg.foldedMessageIds)) foldedIds.push(...msg.foldedMessageIds);
|
|
1015
|
+
pendingLines.push(line);
|
|
1016
|
+
pendingBytes += lineBytes;
|
|
1017
|
+
}
|
|
1018
|
+
flush();
|
|
1019
|
+
idx.revision = (Number(idx.revision) || 0) + revisionDelta;
|
|
1020
|
+
if (foldedIds.length > 0) {
|
|
1021
|
+
idx.foldedMessageIds = Array.from(new Set([
|
|
1022
|
+
...(Array.isArray(idx.foldedMessageIds) ? idx.foldedMessageIds : []),
|
|
1023
|
+
...foldedIds.filter(id => typeof id === 'string' && id),
|
|
1024
|
+
]));
|
|
1025
|
+
}
|
|
1026
|
+
this.#persistCurrentLineage({
|
|
1027
|
+
revision: idx.revision,
|
|
1028
|
+
anchor: establishesAnchor && firstLine ? this.#lineageAnchorForLine(firstLine) : undefined,
|
|
1029
|
+
});
|
|
1030
|
+
this.saveIndex();
|
|
1031
|
+
return rows;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
820
1034
|
readAll({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
|
|
821
1035
|
if (!this.hasData()) return [];
|
|
822
1036
|
const idx = this.loadIndex();
|
|
@@ -1169,7 +1383,7 @@ export class ConversationStore {
|
|
|
1169
1383
|
#chatColdDir;
|
|
1170
1384
|
#legacyMsgDir;
|
|
1171
1385
|
#legacyColdDir;
|
|
1172
|
-
#
|
|
1386
|
+
#fallbackNextSeq; // process-local ids when the durable allocator is read-only
|
|
1173
1387
|
#nextSeqByThread; // Map<threadId, number> — per-thread counters (task-314)
|
|
1174
1388
|
|
|
1175
1389
|
/**
|
|
@@ -1192,7 +1406,7 @@ export class ConversationStore {
|
|
|
1192
1406
|
this.#legacyMsgDir = join(this.#legacyConvDir, 'messages');
|
|
1193
1407
|
this.#legacyColdDir = join(this.#legacyConvDir, 'cold');
|
|
1194
1408
|
|
|
1195
|
-
this.#
|
|
1409
|
+
this.#fallbackNextSeq = null;
|
|
1196
1410
|
this.#nextSeqByThread = new Map();
|
|
1197
1411
|
|
|
1198
1412
|
// Ensure new chat and session-root directories exist (graceful on permission
|
|
@@ -1219,6 +1433,17 @@ export class ConversationStore {
|
|
|
1219
1433
|
|
|
1220
1434
|
// ─── Write API ──────────────────────────────────────────
|
|
1221
1435
|
|
|
1436
|
+
#reserveFallbackSeqRange(count) {
|
|
1437
|
+
const size = Math.max(1, Math.floor(Number(count) || 1));
|
|
1438
|
+
if (!Number.isSafeInteger(this.#fallbackNextSeq) || this.#fallbackNextSeq <= 0) {
|
|
1439
|
+
try { this.#fallbackNextSeq = this.#scanNextSeq(); }
|
|
1440
|
+
catch { this.#fallbackNextSeq = Date.now(); }
|
|
1441
|
+
}
|
|
1442
|
+
const first = this.#fallbackNextSeq;
|
|
1443
|
+
this.#fallbackNextSeq += size;
|
|
1444
|
+
return first;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1222
1447
|
#markDirty(message, reason, sourceIds = null) {
|
|
1223
1448
|
const sessionId = message?.sessionId || null;
|
|
1224
1449
|
try {
|
|
@@ -1260,7 +1485,26 @@ export class ConversationStore {
|
|
|
1260
1485
|
* @returns {object} — the persisted message with id assigned
|
|
1261
1486
|
*/
|
|
1262
1487
|
append(msg) {
|
|
1263
|
-
|
|
1488
|
+
let seq;
|
|
1489
|
+
try {
|
|
1490
|
+
seq = this.#reserveSeqRange(1);
|
|
1491
|
+
} catch (err) {
|
|
1492
|
+
if (!isPermissionError(err)) throw err;
|
|
1493
|
+
// Live chat remains best-effort when its data root is read-only. The
|
|
1494
|
+
// returned row is usable by the current turn but is intentionally not
|
|
1495
|
+
// persisted; strict operations such as Session copy bypass this path.
|
|
1496
|
+
seq = this.#reserveFallbackSeqRange(1);
|
|
1497
|
+
if (!_permissionWarned) {
|
|
1498
|
+
console.warn(`[Yeaft] Cannot reserve message id: ${err.code} — message not persisted`);
|
|
1499
|
+
_permissionWarned = true;
|
|
1500
|
+
}
|
|
1501
|
+
return {
|
|
1502
|
+
...msg,
|
|
1503
|
+
id: `m${String(seq).padStart(4, '0')}`,
|
|
1504
|
+
time: msg.time || new Date().toISOString(),
|
|
1505
|
+
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1264
1508
|
const id = `m${String(seq).padStart(4, '0')}`;
|
|
1265
1509
|
const fullMsg = {
|
|
1266
1510
|
...msg,
|
|
@@ -1282,7 +1526,6 @@ export class ConversationStore {
|
|
|
1282
1526
|
throw err;
|
|
1283
1527
|
}
|
|
1284
1528
|
|
|
1285
|
-
this.#nextSeq = seq + 1;
|
|
1286
1529
|
if (fullMsg._reflection || (
|
|
1287
1530
|
(fullMsg.role === 'user' || fullMsg.role === 'assistant')
|
|
1288
1531
|
&& isVisibleConversationRow(fullMsg)
|
|
@@ -1303,7 +1546,65 @@ export class ConversationStore {
|
|
|
1303
1546
|
* @returns {object[]} — persisted messages with ids
|
|
1304
1547
|
*/
|
|
1305
1548
|
appendBatch(messages) {
|
|
1306
|
-
|
|
1549
|
+
const rows = (messages || []).filter(Boolean);
|
|
1550
|
+
if (rows.length === 0) return [];
|
|
1551
|
+
let firstSeq;
|
|
1552
|
+
try {
|
|
1553
|
+
firstSeq = this.#reserveSeqRange(rows.length);
|
|
1554
|
+
} catch (err) {
|
|
1555
|
+
if (!isPermissionError(err)) throw err;
|
|
1556
|
+
firstSeq = this.#reserveFallbackSeqRange(rows.length);
|
|
1557
|
+
if (!_permissionWarned) {
|
|
1558
|
+
console.warn(`[Yeaft] Cannot reserve message ids: ${err.code} — message batch not persisted`);
|
|
1559
|
+
_permissionWarned = true;
|
|
1560
|
+
}
|
|
1561
|
+
return rows.map((msg, index) => ({
|
|
1562
|
+
...msg,
|
|
1563
|
+
id: `m${String(firstSeq + index).padStart(4, '0')}`,
|
|
1564
|
+
time: msg.time || new Date().toISOString(),
|
|
1565
|
+
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1566
|
+
}));
|
|
1567
|
+
}
|
|
1568
|
+
const persisted = rows.map((msg, index) => ({
|
|
1569
|
+
...msg,
|
|
1570
|
+
id: `m${String(firstSeq + index).padStart(4, '0')}`,
|
|
1571
|
+
time: msg.time || new Date().toISOString(),
|
|
1572
|
+
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1573
|
+
}));
|
|
1574
|
+
const batches = new Map();
|
|
1575
|
+
for (const row of persisted) {
|
|
1576
|
+
const key = row.chatId ? `chat:${row.chatId}` : (row.sessionId ? `session:${row.sessionId}` : 'chat:');
|
|
1577
|
+
const batch = batches.get(key) || [];
|
|
1578
|
+
batch.push(row);
|
|
1579
|
+
batches.set(key, batch);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
for (const batch of batches.values()) {
|
|
1583
|
+
try {
|
|
1584
|
+
this.#segmentStoreFor(batch[0], { create: true }).appendBatch(batch);
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
if (isPermissionError(err)) {
|
|
1587
|
+
if (!_permissionWarned) {
|
|
1588
|
+
console.warn(`[Yeaft] Cannot write message batch: ${err.code} — messages not persisted`);
|
|
1589
|
+
_permissionWarned = true;
|
|
1590
|
+
}
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
throw err;
|
|
1594
|
+
}
|
|
1595
|
+
for (const row of batch) {
|
|
1596
|
+
if (row._reflection || (
|
|
1597
|
+
(row.role === 'user' || row.role === 'assistant')
|
|
1598
|
+
&& isVisibleConversationRow(row)
|
|
1599
|
+
)) {
|
|
1600
|
+
this.#markDirty(row, row._reflection ? 'fold' : 'append', [
|
|
1601
|
+
row.id,
|
|
1602
|
+
...(Array.isArray(row.foldedMessageIds) ? row.foldedMessageIds : []),
|
|
1603
|
+
]);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return persisted;
|
|
1307
1608
|
}
|
|
1308
1609
|
|
|
1309
1610
|
/**
|
|
@@ -1480,7 +1781,9 @@ export class ConversationStore {
|
|
|
1480
1781
|
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
1481
1782
|
this.#segmentStoreForConversationDir(dir).clear();
|
|
1482
1783
|
}
|
|
1483
|
-
|
|
1784
|
+
// Message ids are root-global durable identities. Clearing transcript rows
|
|
1785
|
+
// must not rewind the allocator: another Agent process may still hold a
|
|
1786
|
+
// previously reserved range, and reusing ids would corrupt later history.
|
|
1484
1787
|
this.updateIndex({ totalMessages: 0, lastMessageId: null });
|
|
1485
1788
|
this.#markAllDirty('clear');
|
|
1486
1789
|
}
|
|
@@ -1672,15 +1975,18 @@ export class ConversationStore {
|
|
|
1672
1975
|
const rows = [...rowsById.values()].sort(compareMessagesBySeq);
|
|
1673
1976
|
if (rows.length === 0) return { copiedCount: 0, idMap: new Map() };
|
|
1674
1977
|
|
|
1675
|
-
const firstSeq = this.#
|
|
1978
|
+
const firstSeq = this.#reserveSeqRange(rows.length);
|
|
1676
1979
|
const idMap = new Map(rows.map((row, index) => [
|
|
1677
1980
|
row.id,
|
|
1678
1981
|
`m${String(firstSeq + index).padStart(4, '0')}`,
|
|
1679
1982
|
]));
|
|
1680
1983
|
const remapId = id => idMap.get(id) || id;
|
|
1681
1984
|
const copies = rows.map(row => {
|
|
1682
|
-
const copy = {
|
|
1683
|
-
|
|
1985
|
+
const copy = {
|
|
1986
|
+
...row,
|
|
1987
|
+
id: idMap.get(row.id),
|
|
1988
|
+
sessionId: targetSessionId,
|
|
1989
|
+
};
|
|
1684
1990
|
if (idMap.has(row.causalRootId)) copy.causalRootId = remapId(row.causalRootId);
|
|
1685
1991
|
if (Array.isArray(row.foldedMessageIds)) {
|
|
1686
1992
|
copy.foldedMessageIds = row.foldedMessageIds.map(remapId);
|
|
@@ -1688,19 +1994,16 @@ export class ConversationStore {
|
|
|
1688
1994
|
if (Array.isArray(row.sourceMessageIds)) {
|
|
1689
1995
|
copy.sourceMessageIds = row.sourceMessageIds.map(remapId);
|
|
1690
1996
|
}
|
|
1691
|
-
if (row.cold === true) delete copy.cold;
|
|
1692
1997
|
return copy;
|
|
1693
1998
|
});
|
|
1694
|
-
const written = this.appendBatch(copies);
|
|
1695
|
-
for (let index = 0; index < written.length; index += 1) {
|
|
1696
|
-
if (rows[index].cold === true) this.moveToCold(written[index].id);
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
// append() intentionally treats permission failures as best-effort for live
|
|
1700
|
-
// chat. A Session copy cannot: reporting success with a partial transcript
|
|
1701
|
-
// would make the new Session irrecoverably incomplete. Verify the target's
|
|
1702
|
-
// physical rows before the higher-level CRUD operation commits the clone.
|
|
1703
1999
|
const target = this.#segmentStoreForConversationDir(this.#sessionConversationDir(targetSessionId));
|
|
2000
|
+
const written = target.appendBatch(copies);
|
|
2001
|
+
this.#markDirty({ sessionId: targetSessionId }, 'copy-session');
|
|
2002
|
+
|
|
2003
|
+
// Live append treats permission failures as best-effort. A Session copy
|
|
2004
|
+
// cannot: reporting success with a partial transcript would make the new
|
|
2005
|
+
// Session irrecoverably incomplete. Verify the physical target rows before
|
|
2006
|
+
// the higher-level CRUD operation commits the clone.
|
|
1704
2007
|
const persistedIds = new Set(target.readAllRaw({ includeCold: true }).map(row => row.id));
|
|
1705
2008
|
const expectedIds = [...idMap.values()];
|
|
1706
2009
|
if (written.length !== rows.length
|
|
@@ -2330,7 +2633,6 @@ export class ConversationStore {
|
|
|
2330
2633
|
}
|
|
2331
2634
|
}
|
|
2332
2635
|
}
|
|
2333
|
-
this.#nextSeq = null;
|
|
2334
2636
|
this.#markDirty({ sessionId }, 'delete-session');
|
|
2335
2637
|
return removed;
|
|
2336
2638
|
}
|
|
@@ -2417,7 +2719,6 @@ export class ConversationStore {
|
|
|
2417
2719
|
}
|
|
2418
2720
|
}
|
|
2419
2721
|
if (removed > 0) {
|
|
2420
|
-
this.#nextSeq = null;
|
|
2421
2722
|
this.#markAllDirty('compact-orphans');
|
|
2422
2723
|
}
|
|
2423
2724
|
return { scanned, removed, orphans, skipped: false };
|
|
@@ -3122,12 +3423,12 @@ export class ConversationStore {
|
|
|
3122
3423
|
}
|
|
3123
3424
|
|
|
3124
3425
|
/**
|
|
3125
|
-
*
|
|
3426
|
+
* Scan the durable stores for the first unused global sequence.
|
|
3427
|
+
* This bootstraps the root sequence sidecar when upgrading from a version
|
|
3428
|
+
* that did not maintain an allocator high-water mark.
|
|
3126
3429
|
* @returns {number}
|
|
3127
3430
|
*/
|
|
3128
|
-
#
|
|
3129
|
-
if (this.#nextSeq != null) return this.#nextSeq;
|
|
3130
|
-
|
|
3431
|
+
#scanNextSeq() {
|
|
3131
3432
|
let maxSeq = 0;
|
|
3132
3433
|
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
3133
3434
|
const store = this.#segmentStoreForConversationDir(dir);
|
|
@@ -3140,14 +3441,80 @@ export class ConversationStore {
|
|
|
3140
3441
|
if (!existsSync(dir)) continue;
|
|
3141
3442
|
for (const file of readdirSync(dir)) {
|
|
3142
3443
|
const match = file.match(/^m(\d+)\.md$/);
|
|
3143
|
-
if (match)
|
|
3144
|
-
const seq = parseInt(match[1], 10);
|
|
3145
|
-
if (seq > maxSeq) maxSeq = seq;
|
|
3146
|
-
}
|
|
3444
|
+
if (match) maxSeq = Math.max(maxSeq, parseInt(match[1], 10));
|
|
3147
3445
|
}
|
|
3148
3446
|
}
|
|
3447
|
+
return maxSeq + 1;
|
|
3448
|
+
}
|
|
3149
3449
|
|
|
3150
|
-
|
|
3151
|
-
|
|
3450
|
+
/**
|
|
3451
|
+
* Reserve one non-overlapping range of global message ids.
|
|
3452
|
+
*
|
|
3453
|
+
* `mkdir` is the cross-process mutex: it is atomic on supported local file
|
|
3454
|
+
* systems and therefore coordinates long-lived stores as well as short-lived
|
|
3455
|
+
* Session-copy stores. The atomic high-water sidecar makes a whole transcript
|
|
3456
|
+
* reservation O(1) once the lock is held, while the disk scan keeps upgrades
|
|
3457
|
+
* and manually restored transcripts safe.
|
|
3458
|
+
*
|
|
3459
|
+
* @param {number} count
|
|
3460
|
+
* @returns {number} first reserved sequence
|
|
3461
|
+
*/
|
|
3462
|
+
#reserveSeqRange(count) {
|
|
3463
|
+
const size = Math.max(1, Math.floor(Number(count) || 1));
|
|
3464
|
+
const rootKey = resolve(this.#dir);
|
|
3465
|
+
const sequencePath = join(this.#dir, MESSAGE_SEQUENCE_FILE);
|
|
3466
|
+
const cached = messageSequenceReservations.get(rootKey);
|
|
3467
|
+
if (cached && cached.next + size <= cached.end) {
|
|
3468
|
+
try {
|
|
3469
|
+
const recorded = JSON.parse(readFileSync(sequencePath, 'utf8'));
|
|
3470
|
+
const durableNext = Number(recorded?.nextSeq);
|
|
3471
|
+
if (Number.isSafeInteger(durableNext)
|
|
3472
|
+
&& durableNext >= cached.end
|
|
3473
|
+
&& typeof recorded?.epoch === 'string'
|
|
3474
|
+
&& recorded.epoch === cached.epoch) {
|
|
3475
|
+
const firstSeq = cached.next;
|
|
3476
|
+
cached.next += size;
|
|
3477
|
+
return firstSeq;
|
|
3478
|
+
}
|
|
3479
|
+
} catch {}
|
|
3480
|
+
messageSequenceReservations.delete(rootKey);
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
// Small realtime appends amortize the cross-process lock and sidecar fsync.
|
|
3484
|
+
// Large transcript copies reserve exactly what they need to avoid stranding
|
|
3485
|
+
// an unnecessarily large gap. Gaps after a crash are valid: ids are unique
|
|
3486
|
+
// monotonic identities, not a promise of contiguous durable rows.
|
|
3487
|
+
const durableSize = Math.max(size, MESSAGE_SEQUENCE_RESERVATION_SIZE);
|
|
3488
|
+
const lockDir = join(this.#dir, MESSAGE_SEQUENCE_LOCK_DIR);
|
|
3489
|
+
const lockToken = acquireMessageSequenceLock(lockDir);
|
|
3490
|
+
|
|
3491
|
+
try {
|
|
3492
|
+
let recordedNext = null;
|
|
3493
|
+
let epoch = null;
|
|
3494
|
+
try {
|
|
3495
|
+
const recorded = JSON.parse(readFileSync(sequencePath, 'utf8'));
|
|
3496
|
+
const nextSeq = Number(recorded?.nextSeq);
|
|
3497
|
+
if (Number.isSafeInteger(nextSeq) && nextSeq > 0) {
|
|
3498
|
+
recordedNext = nextSeq;
|
|
3499
|
+
if (typeof recorded.epoch === 'string' && recorded.epoch) epoch = recorded.epoch;
|
|
3500
|
+
}
|
|
3501
|
+
} catch (error) {
|
|
3502
|
+
if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
3503
|
+
}
|
|
3504
|
+
// A syntactically valid sidecar can still be stale after a partial restore
|
|
3505
|
+
// or crash. Never trust it below the durable transcript high-water mark.
|
|
3506
|
+
const scannedNext = this.#scanNextSeq();
|
|
3507
|
+
const firstSeq = Math.max(recordedNext || 1, scannedNext);
|
|
3508
|
+
epoch ||= randomUUID();
|
|
3509
|
+
writeAtomic(sequencePath, `${JSON.stringify({ version: 1, epoch, nextSeq: firstSeq + durableSize })}\n`, { mode: 0o600 });
|
|
3510
|
+
messageSequenceReservations.set(rootKey, {
|
|
3511
|
+
epoch,
|
|
3512
|
+
next: firstSeq + size,
|
|
3513
|
+
end: firstSeq + durableSize,
|
|
3514
|
+
});
|
|
3515
|
+
return firstSeq;
|
|
3516
|
+
} finally {
|
|
3517
|
+
releaseMessageSequenceLock(lockDir, lockToken);
|
|
3518
|
+
}
|
|
3152
3519
|
}
|
|
3153
3520
|
}
|