@yeaft/webchat-agent 1.0.680 → 1.0.682
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +108 -108
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +140 -134
- package/yeaft/conversation/session-history.js +85 -1
- package/yeaft/web-bridge.js +7 -0
|
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, rmSync } from 'fs';
|
|
20
|
+
import { accessSync, constants, existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync, rmSync } from 'fs';
|
|
21
21
|
import { createHash, randomUUID } from 'node:crypto';
|
|
22
22
|
import { hostname } from 'node:os';
|
|
23
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
23
24
|
import { join, basename, resolve } from 'path';
|
|
24
25
|
import { isPermissionError } from '../init.js';
|
|
25
26
|
import { writeAtomic } from '../storage/atomic.js';
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
} from './visible-entry.js';
|
|
34
35
|
import { copyPersistedSessionAttachments, readPersistedAttachmentFile, persistYeaftAttachments, MAX_TOTAL_BYTES } from '../attachments.js';
|
|
35
36
|
import { markConversationDirty } from './history-index-state.js';
|
|
36
|
-
import { historyError, isHumanRequest, messageOrigin,
|
|
37
|
+
import { historyError, isHumanRequest, messageOrigin, planSessionMerge, materializeSessionMerge, materializeSessionCopy, sessionCopyOrigins } from './session-history.js';
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Default cold-start "recent window" size, expressed in TURNS (not raw
|
|
@@ -84,6 +85,7 @@ const SEGMENT_TARGET_BYTES = 1024 * 1024;
|
|
|
84
85
|
const SEGMENT_FIRST_NAME = '000001.jsonl';
|
|
85
86
|
const MESSAGE_SEQUENCE_FILE = 'message-sequence.json';
|
|
86
87
|
const MESSAGE_SEQUENCE_LOCK_DIR = 'message-sequence.lock';
|
|
88
|
+
const MESSAGE_SEQUENCE_MUTEX_FILE = 'message-sequence-lock.db';
|
|
87
89
|
const MESSAGE_SEQUENCE_LOCK_STALE_MS = 30_000;
|
|
88
90
|
const MESSAGE_SEQUENCE_LOCK_ATTEMPTS = 200;
|
|
89
91
|
const MESSAGE_SEQUENCE_LOCK_RETRY_MS = 5;
|
|
@@ -142,38 +144,42 @@ function sleepSync(ms) {
|
|
|
142
144
|
Atomics.wait(buffer, 0, 0, ms);
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
// The SQLite transaction serializes BOTH reservation and abandoned-directory
|
|
148
|
+
// recovery. Kernel locks disappear on process exit; unlike rename-after-check,
|
|
149
|
+
// no contender can replace the observed directory while another reclaims it.
|
|
150
|
+
function withMessageSequenceMutex(root, callback) {
|
|
151
|
+
const path = join(root, MESSAGE_SEQUENCE_MUTEX_FILE);
|
|
152
|
+
let db;
|
|
147
153
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
db = new DatabaseSync(path);
|
|
155
|
+
db.exec('PRAGMA busy_timeout=1000; BEGIN IMMEDIATE;');
|
|
156
|
+
return callback();
|
|
150
157
|
} catch (error) {
|
|
151
|
-
|
|
158
|
+
// SQLite wraps OS permission failures. Only normalize a confirmed read-only
|
|
159
|
+
// path; busy/corrupt/missing-parent errors remain real failures.
|
|
160
|
+
if (error?.code === 'ERR_SQLITE_ERROR' && [8, 14].includes(error.errcode & 255)) {
|
|
161
|
+
try {
|
|
162
|
+
accessSync(root, constants.W_OK);
|
|
163
|
+
if (existsSync(path)) accessSync(path, constants.W_OK);
|
|
164
|
+
} catch (permissionError) {
|
|
165
|
+
if (isPermissionError(permissionError)) throw permissionError;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
} finally {
|
|
170
|
+
try { db?.exec('ROLLBACK;'); } catch {}
|
|
171
|
+
db?.close();
|
|
152
172
|
}
|
|
153
173
|
}
|
|
154
174
|
|
|
155
|
-
function
|
|
175
|
+
function abandonedMessageSequenceLock(lockDir) {
|
|
156
176
|
const details = statSync(lockDir);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
177
|
+
let owner;
|
|
178
|
+
try { owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8')); } catch {}
|
|
179
|
+
if (owner?.host === hostname() && Number.isInteger(owner.pid) && owner.pid > 0) {
|
|
180
|
+
try { process.kill(owner.pid, 0); return false; }
|
|
181
|
+
catch (error) { return error?.code === 'ESRCH'; }
|
|
161
182
|
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function messageSequenceLockIdentity(owner) {
|
|
165
|
-
if (!owner) return null;
|
|
166
|
-
if (typeof owner.token === 'string' && owner.token) return `token:${owner.token}`;
|
|
167
|
-
return `legacy:${owner.host || ''}:${Number(owner.pid) || 0}:${Number(owner.startedAt) || 0}`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function messageSequenceLockCanBeTaken(lockDir) {
|
|
171
|
-
const { owner, details } = readMessageSequenceLock(lockDir);
|
|
172
|
-
if (owner?.host === hostname()) return !processIsAlive(Number(owner.pid));
|
|
173
|
-
// Yeaft data roots are local, but a hostname can change after restoring a
|
|
174
|
-
// machine image. Never steal a live local owner's lock based on age alone;
|
|
175
|
-
// an unverifiable remote/legacy owner is reclaimable only after the stale
|
|
176
|
-
// window and only if takeMessageSequenceLock observes the same identity.
|
|
177
183
|
return Date.now() - details.mtimeMs > MESSAGE_SEQUENCE_LOCK_STALE_MS;
|
|
178
184
|
}
|
|
179
185
|
|
|
@@ -203,29 +209,6 @@ function releaseMessageSequenceLock(lockDir, token) {
|
|
|
203
209
|
return true;
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
function takeMessageSequenceLock(lockDir) {
|
|
207
|
-
const observed = readMessageSequenceLock(lockDir).owner;
|
|
208
|
-
if (!messageSequenceLockCanBeTaken(lockDir)) return false;
|
|
209
|
-
const observedIdentity = messageSequenceLockIdentity(observed);
|
|
210
|
-
const claimed = `${lockDir}.stale-${randomUUID()}`;
|
|
211
|
-
try {
|
|
212
|
-
renameSync(lockDir, claimed);
|
|
213
|
-
} catch (error) {
|
|
214
|
-
if (error?.code === 'ENOENT') return true;
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
const claimedOwner = readMessageSequenceLock(claimed).owner;
|
|
218
|
-
const ownerChanged = messageSequenceLockIdentity(claimedOwner) !== observedIdentity;
|
|
219
|
-
const ownerRevived = claimedOwner?.host === hostname()
|
|
220
|
-
&& processIsAlive(Number(claimedOwner.pid));
|
|
221
|
-
if (ownerChanged || ownerRevived) {
|
|
222
|
-
try { renameSync(claimed, lockDir); } catch {}
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
rmSync(claimed, { recursive: true, force: true });
|
|
226
|
-
return true;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
212
|
function acquireMessageSequenceLock(lockDir) {
|
|
230
213
|
for (let attempt = 0; attempt < MESSAGE_SEQUENCE_LOCK_ATTEMPTS; attempt += 1) {
|
|
231
214
|
const token = randomUUID();
|
|
@@ -233,8 +216,13 @@ function acquireMessageSequenceLock(lockDir) {
|
|
|
233
216
|
mkdirSync(lockDir, { mode: 0o700 });
|
|
234
217
|
} catch (error) {
|
|
235
218
|
if (error?.code !== 'EEXIST') throw error;
|
|
219
|
+
// Caller holds the kernel-backed mutex, including throughout recovery.
|
|
220
|
+
// All current writers take it before touching the directory/sidecar.
|
|
236
221
|
try {
|
|
237
|
-
if (
|
|
222
|
+
if (abandonedMessageSequenceLock(lockDir)) {
|
|
223
|
+
rmSync(lockDir, { recursive: true });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
238
226
|
} catch (inspectionError) {
|
|
239
227
|
if (inspectionError?.code === 'ENOENT') continue;
|
|
240
228
|
throw inspectionError;
|
|
@@ -255,7 +243,7 @@ function acquireMessageSequenceLock(lockDir) {
|
|
|
255
243
|
throw error;
|
|
256
244
|
}
|
|
257
245
|
}
|
|
258
|
-
throw
|
|
246
|
+
throw historyError('message_sequence_locked', `Timed out reserving conversation message ids: ${lockDir}`);
|
|
259
247
|
}
|
|
260
248
|
|
|
261
249
|
function latestTodoWriteSnapshot(toolCalls) {
|
|
@@ -1119,14 +1107,14 @@ class SegmentStore {
|
|
|
1119
1107
|
* Read physical rows without applying reflection tombstones. Session cloning
|
|
1120
1108
|
* needs the complete durable transcript, including rows hidden by folding.
|
|
1121
1109
|
*/
|
|
1122
|
-
readAllRaw({ includeCold = false } = {}) {
|
|
1110
|
+
readAllRaw({ includeCold = false, appendOrder = false } = {}) {
|
|
1123
1111
|
if (!this.hasData()) return [];
|
|
1124
1112
|
const idx = this.loadIndex();
|
|
1125
|
-
|
|
1113
|
+
const rows = (idx.segments || [])
|
|
1126
1114
|
.slice()
|
|
1127
|
-
.sort((a, b) => (a.firstSeq || 0) - (b.firstSeq || 0))
|
|
1128
|
-
.flatMap(segment => this.#readSegment(segment.file, { includeCold }))
|
|
1129
|
-
|
|
1115
|
+
.sort((a, b) => appendOrder ? a.file.localeCompare(b.file) : (a.firstSeq || 0) - (b.firstSeq || 0))
|
|
1116
|
+
.flatMap(segment => this.#readSegment(segment.file, { includeCold, appendOrder }));
|
|
1117
|
+
return appendOrder ? rows : rows.sort(compareMessagesBySeq);
|
|
1130
1118
|
}
|
|
1131
1119
|
|
|
1132
1120
|
*scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null, projection = 'context' } = {}) {
|
|
@@ -1353,10 +1341,10 @@ class SegmentStore {
|
|
|
1353
1341
|
out.streamId = typeof out.streamId === 'string' && out.streamId ? out.streamId : null;
|
|
1354
1342
|
out.revision = Number.isFinite(Number(out.revision)) ? Math.max(0, Number(out.revision)) : 0;
|
|
1355
1343
|
out.segments = Array.isArray(out.segments) ? out.segments.filter(s => s && s.file) : [];
|
|
1356
|
-
out.segments.sort((a, b) =>
|
|
1344
|
+
out.segments.sort((a, b) => a.file.localeCompare(b.file));
|
|
1357
1345
|
const maxSeq = out.segments.reduce((max, seg) => Math.max(max, Number(seg.lastSeq) || 0), 0);
|
|
1358
1346
|
out.nextSeq = Math.max(Number(out.nextSeq) || 1, maxSeq + 1);
|
|
1359
|
-
out.activeSegment = out.
|
|
1347
|
+
out.activeSegment = out.segments[out.segments.length - 1]?.file || out.activeSegment || SEGMENT_FIRST_NAME;
|
|
1360
1348
|
out.totalMessages = Number(out.totalMessages) || out.segments.reduce((sum, seg) => sum + (Number(seg.count) || 0), 0);
|
|
1361
1349
|
out.foldedMessageIds = Array.isArray(out.foldedMessageIds)
|
|
1362
1350
|
? Array.from(new Set(out.foldedMessageIds.filter(id => typeof id === 'string' && id)))
|
|
@@ -1369,7 +1357,7 @@ class SegmentStore {
|
|
|
1369
1357
|
if (!existsSync(this.segmentDir)) return idx;
|
|
1370
1358
|
const files = readdirSync(this.segmentDir).filter(f => f.endsWith('.jsonl')).sort();
|
|
1371
1359
|
for (const file of files) {
|
|
1372
|
-
const rows = this.#readSegment(file, { includeCold: true });
|
|
1360
|
+
const rows = this.#readSegment(file, { includeCold: true, appendOrder: true });
|
|
1373
1361
|
if (rows.length === 0) continue;
|
|
1374
1362
|
const seqs = rows.map(r => parseSeqFromId(r.id)).filter(Number.isFinite);
|
|
1375
1363
|
const path = join(this.segmentDir, file);
|
|
@@ -1399,7 +1387,7 @@ class SegmentStore {
|
|
|
1399
1387
|
return true;
|
|
1400
1388
|
}
|
|
1401
1389
|
|
|
1402
|
-
#readSegment(file, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
|
|
1390
|
+
#readSegment(file, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, appendOrder = false } = {}) {
|
|
1403
1391
|
const path = join(this.segmentDir, file);
|
|
1404
1392
|
if (!existsSync(path)) return [];
|
|
1405
1393
|
const raw = readFileSync(path, 'utf8');
|
|
@@ -1413,7 +1401,7 @@ class SegmentStore {
|
|
|
1413
1401
|
if (!includeCold && msg.cold === true) continue;
|
|
1414
1402
|
rows.push(msg);
|
|
1415
1403
|
}
|
|
1416
|
-
rows.sort(compareMessagesBySeq);
|
|
1404
|
+
if (!appendOrder) rows.sort(compareMessagesBySeq);
|
|
1417
1405
|
return desc ? rows.reverse() : rows;
|
|
1418
1406
|
}
|
|
1419
1407
|
}
|
|
@@ -1575,6 +1563,7 @@ export class ConversationStore {
|
|
|
1575
1563
|
return {
|
|
1576
1564
|
...msg,
|
|
1577
1565
|
id: `m${String(seq).padStart(4, '0')}`,
|
|
1566
|
+
seq,
|
|
1578
1567
|
time: msg.time || new Date().toISOString(),
|
|
1579
1568
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1580
1569
|
};
|
|
@@ -1583,6 +1572,7 @@ export class ConversationStore {
|
|
|
1583
1572
|
const fullMsg = {
|
|
1584
1573
|
...msg,
|
|
1585
1574
|
id,
|
|
1575
|
+
seq,
|
|
1586
1576
|
time: msg.time || new Date().toISOString(),
|
|
1587
1577
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1588
1578
|
};
|
|
@@ -1637,6 +1627,7 @@ export class ConversationStore {
|
|
|
1637
1627
|
return rows.map((msg, index) => ({
|
|
1638
1628
|
...msg,
|
|
1639
1629
|
id: `m${String(firstSeq + index).padStart(4, '0')}`,
|
|
1630
|
+
seq: firstSeq + index,
|
|
1640
1631
|
time: msg.time || new Date().toISOString(),
|
|
1641
1632
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1642
1633
|
}));
|
|
@@ -1644,6 +1635,7 @@ export class ConversationStore {
|
|
|
1644
1635
|
const persisted = rows.map((msg, index) => ({
|
|
1645
1636
|
...msg,
|
|
1646
1637
|
id: `m${String(firstSeq + index).padStart(4, '0')}`,
|
|
1638
|
+
seq: firstSeq + index,
|
|
1647
1639
|
time: msg.time || new Date().toISOString(),
|
|
1648
1640
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1649
1641
|
}));
|
|
@@ -2042,7 +2034,7 @@ export class ConversationStore {
|
|
|
2042
2034
|
if (typeof sessionId !== 'string' || !sessionId) {
|
|
2043
2035
|
throw historyError('invalid_fork_boundary', 'Session identity is required');
|
|
2044
2036
|
}
|
|
2045
|
-
const { rows: durableRows, streamId } = this.#loadDurableSession(sessionId);
|
|
2037
|
+
const { rows: durableRows, streamId } = this.#loadDurableSession(sessionId, { allowDuplicateIds: true });
|
|
2046
2038
|
const rows = Object.hasOwn(options, 'beforeMessageId')
|
|
2047
2039
|
? durableRows.slice(0, this.#sessionCopyBeforeMessageCutoff(durableRows, streamId, options.beforeMessageId))
|
|
2048
2040
|
: durableRows;
|
|
@@ -2060,6 +2052,9 @@ export class ConversationStore {
|
|
|
2060
2052
|
const fence = typeof beforeMessageId === 'string' ? beforeMessageId.trim() : '';
|
|
2061
2053
|
if (!fence) throw historyError('invalid_fork_boundary', 'beforeMessageId must be a nonempty string');
|
|
2062
2054
|
const index = rows.findIndex(row => row.id === fence);
|
|
2055
|
+
if (rows.filter(row => row.id === fence).length > 1) {
|
|
2056
|
+
throw historyError('ambiguous_fork_boundary', 'Message boundary identifies multiple durable rows');
|
|
2057
|
+
}
|
|
2063
2058
|
if (index < 0) throw historyError('fork_boundary_not_found', `Human message not found: ${fence}`);
|
|
2064
2059
|
if (!isHumanRequest(rows[index])) throw historyError('invalid_fork_boundary', 'beforeMessageId must identify a durable human request');
|
|
2065
2060
|
const user = rows[index];
|
|
@@ -2142,13 +2137,15 @@ export class ConversationStore {
|
|
|
2142
2137
|
* 不会把活动 turn 截短伪装成完整 turn。前缀删改拒绝为 invalid_fork_boundary。
|
|
2143
2138
|
* 兼容旧 inclusive throughMessageId(null 为空)和 exclusive beforeMessageId;
|
|
2144
2139
|
* 所有已提供边界均验证,取较早 cutoff。runtime 负责在最早活动输入前捕获。
|
|
2140
|
+
* 重复的旧 ID 按物理记录逐条复制;idMap 仅包含可唯一映射的旧 ID。
|
|
2141
|
+
* 歧义引用保存在目标行 unresolvedMessageReferences 中,不连错、不丢消息。
|
|
2145
2142
|
* @returns {{ copiedCount: number, idMap: Map<string, string> }}
|
|
2146
2143
|
*/
|
|
2147
2144
|
copySession(sourceSessionId, targetSessionId, options = {}) {
|
|
2148
2145
|
if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) {
|
|
2149
2146
|
return { copiedCount: 0, idMap: new Map() };
|
|
2150
2147
|
}
|
|
2151
|
-
const { rows: durableRows, streamId } = this.#loadDurableSession(sourceSessionId);
|
|
2148
|
+
const { rows: durableRows, streamId } = this.#loadDurableSession(sourceSessionId, { allowDuplicateIds: true });
|
|
2152
2149
|
let rows = durableRows;
|
|
2153
2150
|
let cutoff = rows.length;
|
|
2154
2151
|
let capturedCutoff = null;
|
|
@@ -2172,6 +2169,9 @@ export class ConversationStore {
|
|
|
2172
2169
|
const fence = typeof options.throughMessageId === 'string' ? options.throughMessageId.trim() : '';
|
|
2173
2170
|
if (!fence) throw historyError('invalid_fork_boundary', 'throughMessageId must be a nonempty string or null');
|
|
2174
2171
|
const index = rows.findIndex(row => row.id === fence);
|
|
2172
|
+
if (rows.filter(row => row.id === fence).length > 1) {
|
|
2173
|
+
throw historyError('ambiguous_fork_boundary', 'Message boundary identifies multiple durable rows');
|
|
2174
|
+
}
|
|
2175
2175
|
if (index < 0) throw historyError('fork_boundary_not_found', `Durable message not found: ${fence}`);
|
|
2176
2176
|
cutoff = Math.min(cutoff, index + 1);
|
|
2177
2177
|
}
|
|
@@ -2223,29 +2223,11 @@ export class ConversationStore {
|
|
|
2223
2223
|
if (rows.length === 0) return { copiedCount: 0, idMap: new Map() };
|
|
2224
2224
|
|
|
2225
2225
|
const firstSeq = this.#reserveSeqRange(rows.length);
|
|
2226
|
-
const idMap = new Map(rows.map((row, index) => [
|
|
2227
|
-
row.id,
|
|
2228
|
-
`m${String(firstSeq + index).padStart(4, '0')}`,
|
|
2229
|
-
]));
|
|
2230
|
-
const remapId = id => idMap.get(id) || id;
|
|
2231
2226
|
const assetRows = copyPersistedSessionAttachments(rows, { yeaftDir: this.#dir, sourceSessionId, targetSessionId });
|
|
2232
|
-
const copies = assetRows
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
sessionId: targetSessionId,
|
|
2237
|
-
origin: messageOrigin(row, sourceSessionId, streamId),
|
|
2238
|
-
};
|
|
2239
|
-
delete copy.seq;
|
|
2240
|
-
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remapId(row.quote.messageId) };
|
|
2241
|
-
if (idMap.has(row.causalRootId)) copy.causalRootId = remapId(row.causalRootId);
|
|
2242
|
-
if (Array.isArray(row.foldedMessageIds)) {
|
|
2243
|
-
copy.foldedMessageIds = row.foldedMessageIds.map(remapId);
|
|
2244
|
-
}
|
|
2245
|
-
if (Array.isArray(row.sourceMessageIds)) {
|
|
2246
|
-
copy.sourceMessageIds = row.sourceMessageIds.map(remapId);
|
|
2247
|
-
}
|
|
2248
|
-
return copy;
|
|
2227
|
+
const { rows: copies, idMap } = materializeSessionCopy(assetRows, {
|
|
2228
|
+
sourceSessionId, streamId, targetSessionId, firstSeq,
|
|
2229
|
+
sourceRows: durableRows,
|
|
2230
|
+
origins: sessionCopyOrigins(durableRows, sourceSessionId, streamId),
|
|
2249
2231
|
});
|
|
2250
2232
|
const target = this.#segmentStoreForConversationDir(this.#sessionConversationDir(targetSessionId));
|
|
2251
2233
|
const written = target.appendBatch(copies);
|
|
@@ -2256,7 +2238,7 @@ export class ConversationStore {
|
|
|
2256
2238
|
// Session irrecoverably incomplete. Verify the physical target rows before
|
|
2257
2239
|
// the higher-level CRUD operation commits the clone.
|
|
2258
2240
|
const persistedIds = new Set(target.readAllRaw({ includeCold: true }).map(row => row.id));
|
|
2259
|
-
const expectedIds =
|
|
2241
|
+
const expectedIds = copies.map(row => row.id);
|
|
2260
2242
|
if (written.length !== rows.length
|
|
2261
2243
|
|| persistedIds.size !== expectedIds.length
|
|
2262
2244
|
|| expectedIds.some(id => !persistedIds.has(id))) {
|
|
@@ -2265,10 +2247,12 @@ export class ConversationStore {
|
|
|
2265
2247
|
return { copiedCount: written.length, idMap };
|
|
2266
2248
|
}
|
|
2267
2249
|
|
|
2268
|
-
#loadDurableSession(sourceSessionId) {
|
|
2250
|
+
#loadDurableSession(sourceSessionId, { allowDuplicateIds = false } = {}) {
|
|
2269
2251
|
const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sourceSessionId));
|
|
2270
|
-
const segmentedRows = primary.readAllRaw({ includeCold: true });
|
|
2271
|
-
|
|
2252
|
+
const segmentedRows = primary.readAllRaw({ includeCold: true, appendOrder: true });
|
|
2253
|
+
const segmentIds = new Set(segmentedRows.map(row => row.id));
|
|
2254
|
+
const hasDuplicates = segmentIds.size !== segmentedRows.length;
|
|
2255
|
+
if (hasDuplicates && !allowDuplicateIds) {
|
|
2272
2256
|
throw historyError('ambiguous_merge_history', 'Duplicate persisted identity within one Session');
|
|
2273
2257
|
}
|
|
2274
2258
|
const legacyRows = this.#sessionFileEntries('all', sourceSessionId)
|
|
@@ -2279,15 +2263,25 @@ export class ConversationStore {
|
|
|
2279
2263
|
}
|
|
2280
2264
|
})
|
|
2281
2265
|
.filter(Boolean);
|
|
2282
|
-
//
|
|
2283
|
-
//
|
|
2284
|
-
|
|
2285
|
-
const
|
|
2286
|
-
for (const row of
|
|
2266
|
+
// A shadow is collapsible only when its legacy-representable payload is
|
|
2267
|
+
// identical, not merely its ID. Distinct hot/cold/segment rows all survive.
|
|
2268
|
+
const shadowKey = row => serializeMessage({ ...row, time: row.time || '<missing>' });
|
|
2269
|
+
const shadows = new Map();
|
|
2270
|
+
for (const row of segmentedRows) {
|
|
2271
|
+
const key = shadowKey(row);
|
|
2272
|
+
shadows.set(key, (shadows.get(key) || 0) + 1);
|
|
2273
|
+
}
|
|
2274
|
+
const legacy = [];
|
|
2275
|
+
for (const row of legacyRows) {
|
|
2287
2276
|
if (row?.sessionId !== sourceSessionId || typeof row.id !== 'string' || !row.id) continue;
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2277
|
+
const key = shadowKey(row);
|
|
2278
|
+
if (shadows.get(key)) { shadows.set(key, shadows.get(key) - 1); continue; }
|
|
2279
|
+
legacy.push(row);
|
|
2280
|
+
}
|
|
2281
|
+
// Legacy-only rows predate the segmented transcript; each block has its
|
|
2282
|
+
// own stable order. Never switch order after an append introduces a clash.
|
|
2283
|
+
const rows = [...legacy.sort(compareMessagesBySeq),
|
|
2284
|
+
...segmentedRows.filter(row => row?.sessionId === sourceSessionId)];
|
|
2291
2285
|
return { sessionId: sourceSessionId, streamId: rows.length || primary.hasData() ? primary.metadata().streamId : null, rows };
|
|
2292
2286
|
}
|
|
2293
2287
|
|
|
@@ -3785,16 +3779,21 @@ export class ConversationStore {
|
|
|
3785
3779
|
* that did not maintain an allocator high-water mark.
|
|
3786
3780
|
* @returns {number}
|
|
3787
3781
|
*/
|
|
3788
|
-
#scanNextSeq() {
|
|
3782
|
+
#scanNextSeq({ verifyRows = false } = {}) {
|
|
3789
3783
|
let maxSeq = 0;
|
|
3790
|
-
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
3784
|
+
for (const dir of [this.#chatDir, ...this.#chatConversationDirs(), ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
3791
3785
|
const store = this.#segmentStoreForConversationDir(dir);
|
|
3792
3786
|
if (store.hasData()) {
|
|
3793
3787
|
const idx = store.loadIndex();
|
|
3794
3788
|
maxSeq = Math.max(maxSeq, (Number(idx.nextSeq) || 1) - 1);
|
|
3789
|
+
if (verifyRows) {
|
|
3790
|
+
for (const row of store.readAllRaw({ includeCold: true, appendOrder: true })) {
|
|
3791
|
+
maxSeq = Math.max(maxSeq, parseSeqFromId(row.id) || 0, Number(row.seq) || 0);
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3795
3794
|
}
|
|
3796
3795
|
}
|
|
3797
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
3796
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#chatMessageDirs('messages'), ...this.#chatMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
3798
3797
|
if (!existsSync(dir)) continue;
|
|
3799
3798
|
for (const file of readdirSync(dir)) {
|
|
3800
3799
|
const match = file.match(/^m(\d+)\.md$/);
|
|
@@ -3807,17 +3806,19 @@ export class ConversationStore {
|
|
|
3807
3806
|
/**
|
|
3808
3807
|
* Reserve one non-overlapping range of global message ids.
|
|
3809
3808
|
*
|
|
3810
|
-
*
|
|
3811
|
-
*
|
|
3812
|
-
*
|
|
3813
|
-
*
|
|
3814
|
-
* and
|
|
3809
|
+
* SQLite BEGIN IMMEDIATE owns the cross-process mutex and crash recovery;
|
|
3810
|
+
* the directory lock also fences earlier writers during rolling upgrades. The atomic high-water sidecar makes a whole transcript
|
|
3811
|
+
* reservation cheap after the first verified bootstrap. A legacy/missing
|
|
3812
|
+
* sidecar requires actual transcript ID verification, not only derived seq
|
|
3813
|
+
* indexes. Restore/import is offline-only and must invalidate the sidecar;
|
|
3814
|
+
* neither a cache epoch nor a scan can fence already in-flight writers.
|
|
3815
3815
|
*
|
|
3816
3816
|
* @param {number} count
|
|
3817
3817
|
* @returns {number} first reserved sequence
|
|
3818
3818
|
*/
|
|
3819
3819
|
#reserveSeqRange(count) {
|
|
3820
3820
|
const size = Math.max(1, Math.floor(Number(count) || 1));
|
|
3821
|
+
if (!Number.isSafeInteger(size)) throw new Error('Invalid conversation message id reservation');
|
|
3821
3822
|
const rootKey = resolve(this.#dir);
|
|
3822
3823
|
const sequencePath = join(this.#dir, MESSAGE_SEQUENCE_FILE);
|
|
3823
3824
|
const cached = messageSequenceReservations.get(rootKey);
|
|
@@ -3827,6 +3828,7 @@ export class ConversationStore {
|
|
|
3827
3828
|
const durableNext = Number(recorded?.nextSeq);
|
|
3828
3829
|
if (Number.isSafeInteger(durableNext)
|
|
3829
3830
|
&& durableNext >= cached.end
|
|
3831
|
+
&& recorded.transcriptIdsVerified === true
|
|
3830
3832
|
&& typeof recorded?.epoch === 'string'
|
|
3831
3833
|
&& recorded.epoch === cached.epoch) {
|
|
3832
3834
|
const firstSeq = cached.next;
|
|
@@ -3843,35 +3845,39 @@ export class ConversationStore {
|
|
|
3843
3845
|
// monotonic identities, not a promise of contiguous durable rows.
|
|
3844
3846
|
const durableSize = Math.max(size, MESSAGE_SEQUENCE_RESERVATION_SIZE);
|
|
3845
3847
|
const lockDir = join(this.#dir, MESSAGE_SEQUENCE_LOCK_DIR);
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
try {
|
|
3849
|
-
let recordedNext = null;
|
|
3850
|
-
let epoch = null;
|
|
3848
|
+
return withMessageSequenceMutex(this.#dir, () => {
|
|
3849
|
+
const lockToken = acquireMessageSequenceLock(lockDir);
|
|
3851
3850
|
try {
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3851
|
+
let recordedNext = null;
|
|
3852
|
+
let epoch = null;
|
|
3853
|
+
let transcriptIdsVerified = false;
|
|
3854
|
+
try {
|
|
3855
|
+
const recorded = JSON.parse(readFileSync(sequencePath, 'utf8'));
|
|
3856
|
+
const nextSeq = Number(recorded?.nextSeq);
|
|
3857
|
+
if (Number.isSafeInteger(nextSeq) && nextSeq > 0) {
|
|
3858
|
+
recordedNext = nextSeq;
|
|
3859
|
+
transcriptIdsVerified = recorded.transcriptIdsVerified === true;
|
|
3860
|
+
if (typeof recorded.epoch === 'string' && recorded.epoch) epoch = recorded.epoch;
|
|
3861
|
+
}
|
|
3862
|
+
} catch (error) {
|
|
3863
|
+
if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
3857
3864
|
}
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
}
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
}
|
|
3865
|
+
// A syntactically valid sidecar can still be stale after a partial restore
|
|
3866
|
+
// or crash. Never trust it below the durable transcript high-water mark.
|
|
3867
|
+
const scannedNext = this.#scanNextSeq({ verifyRows: !transcriptIdsVerified });
|
|
3868
|
+
const firstSeq = Math.max(recordedNext || 1, scannedNext);
|
|
3869
|
+
if (!Number.isSafeInteger(firstSeq + durableSize)) throw new Error('Conversation message id sequence exhausted');
|
|
3870
|
+
epoch ||= randomUUID();
|
|
3871
|
+
writeAtomic(sequencePath, `${JSON.stringify({ version: 1, epoch, transcriptIdsVerified: true, nextSeq: firstSeq + durableSize })}\n`, { mode: 0o600 });
|
|
3872
|
+
messageSequenceReservations.set(rootKey, {
|
|
3873
|
+
epoch,
|
|
3874
|
+
next: firstSeq + size,
|
|
3875
|
+
end: firstSeq + durableSize,
|
|
3876
|
+
});
|
|
3877
|
+
return firstSeq;
|
|
3878
|
+
} finally {
|
|
3879
|
+
releaseMessageSequenceLock(lockDir, lockToken);
|
|
3880
|
+
}
|
|
3881
|
+
});
|
|
3876
3882
|
}
|
|
3877
3883
|
}
|
|
@@ -35,7 +35,81 @@ export function historicalReplayRow(row) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function originKey(origin) {
|
|
38
|
-
return JSON.stringify([origin.streamId, origin.messageId]);
|
|
38
|
+
return JSON.stringify([origin.streamId, origin.messageId, origin.rowOccurrence ?? null, origin.copyOccurrence ?? null]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 在完整源上分配稳定 lineage,再应用复制边界;保留旧 occurrence 命名空间。 */
|
|
42
|
+
export function sessionCopyOrigins(rows, sourceSessionId, streamId) {
|
|
43
|
+
const origins = rows.map(row => messageOrigin(row, sourceSessionId, streamId));
|
|
44
|
+
const reserved = new Set(origins.map(originKey));
|
|
45
|
+
const used = new Set();
|
|
46
|
+
return origins.map(origin => {
|
|
47
|
+
if (!used.has(originKey(origin))) { used.add(originKey(origin)); return origin; }
|
|
48
|
+
let occurrence = 2;
|
|
49
|
+
let candidate;
|
|
50
|
+
do {
|
|
51
|
+
candidate = { ...origin, copyOccurrence: [...(origin.copyOccurrence || []), occurrence++] };
|
|
52
|
+
} while (reserved.has(originKey(candidate)) || used.has(originKey(candidate)));
|
|
53
|
+
used.add(originKey(candidate));
|
|
54
|
+
return candidate;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 按源记录位置分配复制身份,不能用 Map<旧 ID, 新 ID> 覆盖冲突记录。
|
|
60
|
+
* 无法唯一识别目标的引用从可执行引用字段移到诊断元数据;保留
|
|
61
|
+
* 原引用及所有目标候选,不阻断复制,也不猜测折叠/因果关系。源行不变。
|
|
62
|
+
* idMap 只提供唯一旧 ID 的兼容查询,绝不返回任意一个冲突候选。
|
|
63
|
+
*/
|
|
64
|
+
export function materializeSessionCopy(rows, { sourceSessionId, streamId, targetSessionId, firstSeq, sourceRows = rows, origins = sessionCopyOrigins(sourceRows, sourceSessionId, streamId) }) {
|
|
65
|
+
const ids = rows.map((_, index) => `m${String(firstSeq + index).padStart(4, '0')}`);
|
|
66
|
+
const occurrences = new Map();
|
|
67
|
+
sourceRows.forEach((row, index) => {
|
|
68
|
+
const positions = occurrences.get(row.id) || [];
|
|
69
|
+
positions.push(index);
|
|
70
|
+
occurrences.set(row.id, positions);
|
|
71
|
+
});
|
|
72
|
+
const idMap = new Map([...occurrences].filter(([, positions]) => positions.length === 1 && positions[0] < rows.length)
|
|
73
|
+
.map(([id, positions]) => [id, ids[positions[0]]]));
|
|
74
|
+
const copies = rows.map((row, index) => {
|
|
75
|
+
const copy = { ...historicalReplayRow(row), id: ids[index], seq: firstSeq + index,
|
|
76
|
+
sessionId: targetSessionId, origin: origins[index] };
|
|
77
|
+
// Existing diagnostics also belong to the copied namespace on nested forks.
|
|
78
|
+
const unresolved = (row.unresolvedMessageReferences || []).map(reference => ({ ...reference,
|
|
79
|
+
candidateMessageIds: (reference.candidateMessageIds || []).flatMap(id =>
|
|
80
|
+
occurrences.has(id) ? occurrences.get(id).map(candidate => ids[candidate]).filter(Boolean) : [id]),
|
|
81
|
+
}));
|
|
82
|
+
const remap = (id, field, referenceIndex) => {
|
|
83
|
+
const positions = occurrences.get(id);
|
|
84
|
+
if (!positions) return id; // External/client identities are not persisted row IDs.
|
|
85
|
+
// Duplicate bare IDs contain no record-level target identity. Role,
|
|
86
|
+
// proximity and a matching VP turn cannot prove the intended record
|
|
87
|
+
// (and may select this very row). Never infer a live reference from them.
|
|
88
|
+
if (positions.length === 1) return ids[positions[0]] || id;
|
|
89
|
+
unresolved.push({ field, ...(referenceIndex === undefined ? {} : { index: referenceIndex }),
|
|
90
|
+
messageId: id, candidateMessageIds: positions.map(position => ids[position]).filter(Boolean),
|
|
91
|
+
candidateOrigins: positions.map(position => origins[position]) });
|
|
92
|
+
return undefined;
|
|
93
|
+
};
|
|
94
|
+
if (row.causalRootId) {
|
|
95
|
+
const value = remap(row.causalRootId, 'causalRootId');
|
|
96
|
+
if (value === undefined) delete copy.causalRootId;
|
|
97
|
+
else copy.causalRootId = value;
|
|
98
|
+
}
|
|
99
|
+
if (row.quote?.messageId) {
|
|
100
|
+
copy.quote = { ...row.quote };
|
|
101
|
+
const value = remap(row.quote.messageId, 'quote.messageId');
|
|
102
|
+
if (value === undefined) delete copy.quote.messageId;
|
|
103
|
+
else copy.quote.messageId = value;
|
|
104
|
+
}
|
|
105
|
+
for (const field of ['foldedMessageIds', 'sourceMessageIds']) {
|
|
106
|
+
if (Array.isArray(row[field])) copy[field] = row[field]
|
|
107
|
+
.map((id, referenceIndex) => remap(id, field, referenceIndex)).filter(id => id !== undefined);
|
|
108
|
+
}
|
|
109
|
+
if (unresolved.length) copy.unresolvedMessageReferences = unresolved;
|
|
110
|
+
return copy;
|
|
111
|
+
});
|
|
112
|
+
return { rows: copies, idMap };
|
|
39
113
|
}
|
|
40
114
|
|
|
41
115
|
function stableJson(value) {
|
|
@@ -218,6 +292,14 @@ function equivalentRows(left, right, aliases) {
|
|
|
218
292
|
if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
|
|
219
293
|
}
|
|
220
294
|
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
|
|
295
|
+
if (row.unresolvedMessageReferences) copy.unresolvedMessageReferences = row.unresolvedMessageReferences
|
|
296
|
+
.map(reference => {
|
|
297
|
+
if (reference.candidateOrigins) {
|
|
298
|
+
const { candidateMessageIds, ...identity } = reference;
|
|
299
|
+
return identity; // Scope-independent evidence includes excluded suffix candidates.
|
|
300
|
+
}
|
|
301
|
+
return { ...reference, candidateMessageIds: reference.candidateMessageIds.map(remap) };
|
|
302
|
+
});
|
|
221
303
|
if (Array.isArray(row.attachments)) copy.attachments = row.attachments.map(att => {
|
|
222
304
|
const { path, ...metadata } = att;
|
|
223
305
|
return metadata;
|
|
@@ -326,6 +408,8 @@ export function materializeSessionMerge(plan, targetSessionId, firstSeq) {
|
|
|
326
408
|
if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
|
|
327
409
|
}
|
|
328
410
|
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
|
|
411
|
+
if (row.unresolvedMessageReferences) copy.unresolvedMessageReferences = row.unresolvedMessageReferences
|
|
412
|
+
.map(reference => ({ ...reference, candidateMessageIds: reference.candidateMessageIds.map(remap) }));
|
|
329
413
|
if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: calls.get(toolScope(row, call.id)) }));
|
|
330
414
|
if (turn.links.results.has(index)) copy.toolCallId = callId(turn.links.results.get(index));
|
|
331
415
|
if (calls.has(toolScope(row, row.presentationCallId))) copy.presentationCallId = calls.get(toolScope(row, row.presentationCallId));
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -374,6 +374,13 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
|
|
|
374
374
|
} catch { /* best-effort project metadata */ }
|
|
375
375
|
}
|
|
376
376
|
if (session !== replaySession) return;
|
|
377
|
+
// A warm cached project runtime does not activate/broadcast on load.
|
|
378
|
+
// Every browser bootstrap must receive its Skill catalogue again, even
|
|
379
|
+
// when no Skill file changed since the previous connection.
|
|
380
|
+
broadcastSkillSlashCommands({
|
|
381
|
+
config: replaySession.config,
|
|
382
|
+
skillManager: projectRuntime?.skillManager || baseRuntime?.skillManager || replaySession.skillManager,
|
|
383
|
+
});
|
|
377
384
|
const status = mergedStatusForProjectRuntime(projectRuntime, replaySession);
|
|
378
385
|
hydrateYeaftStatusFromSession({ ...replaySession, status }, { reason: 'history_load', emitEvent: true });
|
|
379
386
|
sendSessionEvent({
|