@yeaft/webchat-agent 1.0.544 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.544",
3
+ "version": "1.0.545",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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 { join, basename } from 'path';
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
- #nextSeq; // next message sequence number across chat/session/legacy
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.#nextSeq = null;
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
- const seq = this.#getNextSeq();
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
- return messages.map(m => this.append(m));
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
- this.#nextSeq = 1;
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.#getNextSeq();
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 = { ...row, sessionId: targetSessionId };
1683
- delete copy.id;
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
- * Determine the next sequence number by scanning existing files.
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
- #getNextSeq() {
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
- this.#nextSeq = maxSeq + 1;
3151
- return this.#nextSeq;
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
  }