修复完整历史与运行态内存放大并重建发布包
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
const nativeFs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { createJsonReader, readJsonSync, writeJsonSync, cloneJsonValue, jsonValueEqual } = require('./json-persistence');
|
||||
|
||||
// 跨存储实例保护仍在使用的旧修订;弱引用不延长会话或消息的寿命。
|
||||
const liveBaselines = new Set();
|
||||
|
||||
const SESSION_RUNTIME_KEYS = new Set([
|
||||
'ws', 'tailer', 'codexAppStateTimer', 'codexAppStateDirty', 'codexAppStateCleaned',
|
||||
@@ -16,10 +20,13 @@ function createSessionHistoryStore(options = {}) {
|
||||
// 身份只留在当前存储实例,不能通过 JSON 快照伪造可信完整加载或修订。
|
||||
const loadedStates = new WeakMap();
|
||||
const removedIds = new Set();
|
||||
const chunkMaxBytes = options.chunkMaxBytes || 2 * 1024 * 1024;
|
||||
const chunkMaxMessages = options.chunkMaxMessages || 64;
|
||||
let commits = 0;
|
||||
|
||||
function validId(value) {
|
||||
const id = String(value || '');
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw Object.assign(new Error('会话归档 ID 无效'), { code: 'history_invalid_id' });
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(id) || id === '_turns') throw Object.assign(new Error('会话归档 ID 无效'), { code: 'history_invalid_id' });
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -27,18 +34,116 @@ function createSessionHistoryStore(options = {}) {
|
||||
return path.join(historyDir, `${validId(id)}.json`);
|
||||
}
|
||||
|
||||
function readRecord(id) {
|
||||
let json;
|
||||
function archiveDir(id) {
|
||||
return path.join(historyDir, validId(id));
|
||||
}
|
||||
|
||||
function manifestPath(id) {
|
||||
return path.join(archiveDir(id), 'manifest.json');
|
||||
}
|
||||
|
||||
function chunkPath(id, file) {
|
||||
if (!/^[a-f0-9]{64}\.json$/.test(file)) throw new Error('完整会话归档块引用无效');
|
||||
return path.join(archiveDir(id), 'chunks', file);
|
||||
}
|
||||
|
||||
function stringify(value) {
|
||||
return JSON.stringify(value, (key, item) => {
|
||||
if (typeof item === 'bigint') return String(item);
|
||||
if (item instanceof Map) return Object.fromEntries(item);
|
||||
if (item instanceof Set) return [...item];
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function sessionMetadata(session) {
|
||||
const metadata = Object.create(null);
|
||||
for (const key of Object.keys(session)) {
|
||||
if (key === 'messages' || key === 'historySnapshotBaseIndex' || key === 'historySnapshotCount'
|
||||
|| key === 'historyArchiveRevision' || SESSION_RUNTIME_KEYS.has(key)) continue;
|
||||
Object.defineProperty(metadata, key, { value: session[key], enumerable: true });
|
||||
}
|
||||
return cloneJsonValue(metadata);
|
||||
}
|
||||
|
||||
function chunkWriter(id) {
|
||||
const entries = [];
|
||||
let pending = [];
|
||||
let bytes = 2;
|
||||
function flush() {
|
||||
if (!pending.length) return;
|
||||
const json = `[${pending.map(item => item.json).join(',')}]`;
|
||||
const file = `${crypto.createHash('sha256').update(json).digest('hex')}.json`;
|
||||
const target = chunkPath(id, file);
|
||||
if (!fs.existsSync(target)) writeAtomicJson(target, null, json);
|
||||
pending.forEach((item, index) => {
|
||||
Object.assign(item.entry, { id: item.id || null, role: item.role, digest: item.digest, file, index, bytes: item.bytes });
|
||||
entries.push(item.entry);
|
||||
});
|
||||
pending = [];
|
||||
bytes = 2;
|
||||
}
|
||||
function add(message) {
|
||||
const json = stringify(message);
|
||||
if (json === undefined) throw new Error('完整会话消息不可序列化');
|
||||
const size = Buffer.byteLength(json);
|
||||
if (pending.length && (bytes + size + 1 > chunkMaxBytes || pending.length >= chunkMaxMessages)) flush();
|
||||
const entry = {};
|
||||
pending.push({ json, id: message?.id, role: message?.role || null,
|
||||
digest: crypto.createHash('sha256').update(json).digest('hex'), bytes: size, entry });
|
||||
bytes += size + 1;
|
||||
// 单条超大消息仍完整保存,不能按预览限额截断。
|
||||
if (bytes >= chunkMaxBytes || pending.length >= chunkMaxMessages) flush();
|
||||
return entry;
|
||||
}
|
||||
return { add, finish() { flush(); return entries; } };
|
||||
}
|
||||
|
||||
function migrateLegacy(id) {
|
||||
if (!fs.existsSync(filePath(id))) return null;
|
||||
const reader = createJsonReader(fs, filePath(id));
|
||||
const record = Object.create(null);
|
||||
const metadata = Object.create(null);
|
||||
const writer = chunkWriter(id);
|
||||
let hasMessages = false;
|
||||
try {
|
||||
json = fs.readFileSync(filePath(id), 'utf8');
|
||||
reader.object(key => {
|
||||
if (key !== 'session') { record[key] = reader.value(); return; }
|
||||
reader.object(field => {
|
||||
if (field === 'messages') {
|
||||
hasMessages = true;
|
||||
reader.array(message => { if (message?.ccwebPersistenceNotice !== true) writer.add(message); });
|
||||
} else Object.defineProperty(metadata, field, { value: reader.value(), enumerable: true, configurable: true });
|
||||
});
|
||||
});
|
||||
reader.end();
|
||||
} finally {
|
||||
reader.close();
|
||||
}
|
||||
if (record.version !== 1 || typeof record.revision !== 'string' || !record.revision
|
||||
|| metadata.id !== id || !hasMessages || typeof record.complete !== 'boolean') {
|
||||
throw Object.assign(new Error('完整会话归档格式无效'), { code: 'history_invalid_archive' });
|
||||
}
|
||||
const entries = writer.finish();
|
||||
const manifest = { version: 2, revision: record.revision, complete: record.complete,
|
||||
resetRevision: record.resetRevision || null, session: sessionMetadata(metadata), entries };
|
||||
// 旧单文件保留为迁移来源;所有块成功落盘后,清单才成为唯一提交点。
|
||||
writeAtomicJson(manifestPath(id), manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function readRecord(id) {
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(fs.readFileSync(manifestPath(id), 'utf8'));
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null;
|
||||
if (error?.code === 'ENOENT') return migrateLegacy(id);
|
||||
throw error;
|
||||
}
|
||||
const record = JSON.parse(json);
|
||||
if (record?.version !== 1 || typeof record.revision !== 'string' || !record.revision
|
||||
|| record.session?.id !== id || !Array.isArray(record.session.messages)
|
||||
|| typeof record.complete !== 'boolean') {
|
||||
if (record?.version !== 2 || typeof record.revision !== 'string' || !record.revision
|
||||
|| record.session?.id !== id || !Array.isArray(record.entries) || typeof record.complete !== 'boolean'
|
||||
|| record.entries.some(entry => !/^[a-f0-9]{64}\.json$/.test(entry.file)
|
||||
|| !/^[a-f0-9]{64}$/.test(entry.digest) || !Number.isSafeInteger(entry.index) || entry.index < 0)) {
|
||||
throw Object.assign(new Error('完整会话归档格式无效'), { code: 'history_invalid_archive' });
|
||||
}
|
||||
return record;
|
||||
@@ -47,18 +152,125 @@ function createSessionHistoryStore(options = {}) {
|
||||
function state(session) {
|
||||
const value = session && loadedStates.get(session);
|
||||
if (!value) return null;
|
||||
const { baseline, ...metadata } = value;
|
||||
return metadata;
|
||||
return { id: value.id, revision: value.revision, complete: value.complete, source: value.source,
|
||||
resetRevision: value.resetRevision || null, start: value.start || 0,
|
||||
end: value.end, total: value.manifest?.entries.length, partial: value.partial === true };
|
||||
}
|
||||
|
||||
function register(session, manifest, start, end, partial) {
|
||||
const baseline = { id: session.id, revision: manifest.revision, complete: manifest.complete,
|
||||
source: 'archive', resetRevision: manifest.resetRevision || null, manifest, start, end, partial, directory: archiveDir(session.id) };
|
||||
loadedStates.set(session, baseline);
|
||||
liveBaselines.add(new WeakRef(baseline));
|
||||
}
|
||||
|
||||
function entryReader(id) {
|
||||
const cache = new Map();
|
||||
return entry => {
|
||||
let messages = cache.get(entry.file);
|
||||
if (!messages) {
|
||||
const json = fs.readFileSync(chunkPath(id, entry.file), 'utf8');
|
||||
if (crypto.createHash('sha256').update(json).digest('hex') !== entry.file.slice(0, -5)) {
|
||||
throw new Error('完整会话归档块校验失败');
|
||||
}
|
||||
messages = JSON.parse(json);
|
||||
if (!Array.isArray(messages)) throw new Error('完整会话归档块格式无效');
|
||||
cache.set(entry.file, messages);
|
||||
if (cache.size > 2) cache.delete(cache.keys().next().value);
|
||||
}
|
||||
const message = messages[entry.index];
|
||||
if (message === undefined) throw new Error('完整会话归档消息引用无效');
|
||||
return message;
|
||||
};
|
||||
}
|
||||
|
||||
function loadRange(id, record, start, end, partial) {
|
||||
const session = cloneJsonValue(record.session);
|
||||
const read = entryReader(id);
|
||||
session.messages = record.entries.slice(start, end).map(read);
|
||||
session.historySnapshotBaseIndex = start;
|
||||
session.historySnapshotCount = session.messages.length;
|
||||
register(session, record, start, end, partial);
|
||||
return session;
|
||||
}
|
||||
|
||||
function load(id) {
|
||||
const record = readRecord(validId(id));
|
||||
id = validId(id);
|
||||
const record = readRecord(id);
|
||||
return record ? loadRange(id, record, 0, record.entries.length, false) : null;
|
||||
}
|
||||
|
||||
function loadTail(id, count = 180, limits = {}) {
|
||||
id = validId(id);
|
||||
const record = readRecord(id);
|
||||
if (!record) return null;
|
||||
loadedStates.set(record.session, {
|
||||
id, revision: record.revision, complete: record.complete, source: 'archive',
|
||||
resetRevision: record.resetRevision || null, baseline: JSON.stringify(record.session),
|
||||
});
|
||||
return record.session;
|
||||
const end = record.entries.length;
|
||||
let start = Math.max(0, end - Math.max(0, Math.trunc(count)));
|
||||
if (Number.isFinite(limits.maxBytes) && limits.maxBytes > 0) {
|
||||
let bytes = 0;
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
const size = record.entries[index].bytes || 0;
|
||||
if (bytes && bytes + size > limits.maxBytes) { start = index + 1; break; }
|
||||
bytes += size;
|
||||
}
|
||||
}
|
||||
return loadRange(id, record, start, end, start > 0);
|
||||
}
|
||||
|
||||
function loadMetadata(id) {
|
||||
return loadTail(id, 0);
|
||||
}
|
||||
|
||||
function loadWindow(id, start, end = start + 1) {
|
||||
id = validId(id);
|
||||
const record = readRecord(id);
|
||||
if (!record) return null;
|
||||
const total = record.entries.length;
|
||||
start = Math.max(0, Math.min(total, Math.trunc(start)));
|
||||
end = Math.max(start, Math.min(total, Math.trunc(end)));
|
||||
return loadRange(id, record, start, end, start > 0 || end < total);
|
||||
}
|
||||
|
||||
function forEachMessage(id, callback) {
|
||||
id = validId(id);
|
||||
const record = readRecord(id);
|
||||
if (!record) return;
|
||||
const read = entryReader(id);
|
||||
for (let index = 0; index < record.entries.length; index += 1) callback(read(record.entries[index]), index);
|
||||
}
|
||||
|
||||
function findMessage(id, predicate, before = Infinity) {
|
||||
id = validId(id);
|
||||
const record = readRecord(id);
|
||||
if (!record) return null;
|
||||
const read = entryReader(id);
|
||||
for (let index = Math.min(record.entries.length, before) - 1; index >= 0; index -= 1) {
|
||||
const message = read(record.entries[index]);
|
||||
if (predicate(message, index)) return { message, index };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadHistoryPage(id, before, count = 24) {
|
||||
id = validId(id);
|
||||
const record = readRecord(id);
|
||||
if (!record) return null;
|
||||
const total = record.entries.length;
|
||||
const end = Number.isFinite(before) ? Math.max(0, Math.min(total, Math.trunc(before))) : total;
|
||||
const start = Math.max(0, end - Math.max(0, Math.trunc(count)));
|
||||
const read = entryReader(id);
|
||||
return { messages: record.entries.slice(start, end).map(read), total, start, end,
|
||||
complete: record.complete, revision: record.revision };
|
||||
}
|
||||
|
||||
function messageIndex(id, messageId) {
|
||||
const record = readRecord(validId(id));
|
||||
return record ? record.entries.findIndex(entry => entry.id === messageId) : -1;
|
||||
}
|
||||
|
||||
function countMessages(id, role) {
|
||||
const record = readRecord(validId(id));
|
||||
return record ? record.entries.filter(entry => !role || entry.role === role).length : null;
|
||||
}
|
||||
|
||||
function markLegacy(session) {
|
||||
@@ -76,28 +288,16 @@ function createSessionHistoryStore(options = {}) {
|
||||
if (!previous || previous.id !== session.id) {
|
||||
throw Object.assign(new Error('只能确认可信加载会话的历史状态'), { code: 'history_untrusted_source' });
|
||||
}
|
||||
loadedStates.set(session, { ...previous, complete: true, reset: options.reset === true });
|
||||
const confirmed = { ...previous, complete: true, reset: options.reset === true, partial: false };
|
||||
loadedStates.set(session, confirmed);
|
||||
if (confirmed.manifest) liveBaselines.add(new WeakRef(confirmed));
|
||||
session.historySnapshotBaseIndex = 0;
|
||||
session.historySnapshotCount = session.messages.length;
|
||||
return () => loadedStates.set(session, previous);
|
||||
}
|
||||
|
||||
function serializeSession(session) {
|
||||
// 完整档案没有文本、消息数、数组长度或深度裁剪;不可序列化时整次提交失败。
|
||||
const json = JSON.stringify(session, function replacer(key, value) {
|
||||
if (this === session && SESSION_RUNTIME_KEYS.has(key)) return undefined;
|
||||
if (typeof value === 'bigint') return String(value);
|
||||
if (value instanceof Map) return Object.fromEntries(value);
|
||||
if (value instanceof Set) return [...value];
|
||||
return value;
|
||||
});
|
||||
const persisted = JSON.parse(json);
|
||||
if (!Array.isArray(persisted.messages)) throw new Error('完整会话消息必须为数组');
|
||||
return persisted;
|
||||
}
|
||||
|
||||
function equal(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
return jsonValueEqual(left, right);
|
||||
}
|
||||
|
||||
function plainObject(value) {
|
||||
@@ -187,13 +387,14 @@ function createSessionHistoryStore(options = {}) {
|
||||
return persisted;
|
||||
}
|
||||
|
||||
function writeAtomicJson(target, record) {
|
||||
function writeAtomicJson(target, record, json) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(temporary, 'wx', 0o600);
|
||||
fs.writeFileSync(fd, JSON.stringify(record), 'utf8');
|
||||
if (json === undefined) writeJsonSync(fs, fd, record);
|
||||
else fs.writeFileSync(fd, json, 'utf8');
|
||||
fs.fsyncSync(fd);
|
||||
fs.closeSync(fd);
|
||||
fd = undefined;
|
||||
@@ -206,10 +407,6 @@ function createSessionHistoryStore(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecord(id, record) {
|
||||
writeAtomicJson(filePath(id), record);
|
||||
}
|
||||
|
||||
function turnPath(id) {
|
||||
return path.join(turnsDir, `${validId(id)}.json`);
|
||||
}
|
||||
@@ -220,7 +417,7 @@ function createSessionHistoryStore(options = {}) {
|
||||
|
||||
function loadTurn(id) {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(turnPath(id), 'utf8'));
|
||||
const state = readJsonSync(fs, turnPath(id));
|
||||
if (state?.sessionId !== id || state.agent !== 'codexapp') throw new Error('完整运行态归档无效');
|
||||
return state;
|
||||
} catch (error) {
|
||||
@@ -244,11 +441,16 @@ function createSessionHistoryStore(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function save(session) {
|
||||
function save(session, options = {}) {
|
||||
const id = validId(session?.id);
|
||||
const previous = loadedStates.get(session);
|
||||
if (removedIds.has(id)) throw Object.assign(new Error('已删除会话不能由旧对象重新保存'), { code: 'history_removed' });
|
||||
const current = readRecord(id);
|
||||
if (previous?.source === 'archive' && !fs.existsSync(manifestPath(id))) {
|
||||
throw Object.assign(new Error('完整会话归档已移除,拒绝旧对象重新创建'), { code: 'history_removed' });
|
||||
}
|
||||
// 同修订只核对文件头,可信清单已经是不可变基线;旧修订才重新读取当前清单。
|
||||
const current = previous?.source === 'archive' && revision(id) === previous.revision
|
||||
? previous.manifest : readRecord(id);
|
||||
if (current) {
|
||||
if (previous?.source !== 'archive' || previous.id !== id) {
|
||||
throw Object.assign(new Error('受限预览不能覆盖完整会话归档'), { code: 'history_untrusted_source' });
|
||||
@@ -259,47 +461,177 @@ function createSessionHistoryStore(options = {}) {
|
||||
const complete = current?.complete || (previous ? previous.complete
|
||||
: !session.messages?.some(message => message?.ccwebPersistenceNotice === true)
|
||||
&& !(Number(session.historySnapshotBaseIndex) > 0));
|
||||
let persisted = serializeSession(session);
|
||||
if (!Array.isArray(session.messages)) throw new Error('完整会话消息必须为数组');
|
||||
let metadata = sessionMetadata(session);
|
||||
let taskTrackingPreserved = null;
|
||||
const currentTracking = current?.session.taskTracking;
|
||||
if (!options.taskBoardMetadata && currentTracking && typeof currentTracking === 'object') {
|
||||
const persistedVersion = Number.isSafeInteger(currentTracking.version) ? currentTracking.version : 0;
|
||||
const incomingVersion = Number.isSafeInteger(metadata.taskTracking?.version) ? metadata.taskTracking.version : -1;
|
||||
if (incomingVersion < persistedVersion) {
|
||||
metadata.taskTracking = cloneJsonValue(currentTracking);
|
||||
taskTrackingPreserved = { incomingVersion, persistedVersion };
|
||||
}
|
||||
}
|
||||
const rebased = !!current && previous.revision !== current.revision;
|
||||
if (rebased) {
|
||||
const baseline = JSON.parse(previous.baseline);
|
||||
if (!identifiedArray(baseline.messages) || !identifiedArray(persisted.messages) || !identifiedArray(current.session.messages)) {
|
||||
const baseEntries = previous?.manifest?.entries || [];
|
||||
const viewedEntries = previous?.partial ? baseEntries.slice(previous.start, previous.end) : baseEntries;
|
||||
const descriptors = [];
|
||||
const positions = new Map();
|
||||
for (const message of session.messages) {
|
||||
if (message?.ccwebPersistenceNotice === true) continue;
|
||||
const json = stringify(message);
|
||||
if (json === undefined) throw new Error('完整会话消息不可序列化');
|
||||
const descriptor = { id: message?.id || null, digest: crypto.createHash('sha256').update(json).digest('hex'), message };
|
||||
// 同 ID 的最新片段保留最初位置,不同气泡即使同 turn 也分别保存。
|
||||
if (descriptor.id && positions.has(descriptor.id)) descriptors[positions.get(descriptor.id)] = descriptor;
|
||||
else {
|
||||
if (descriptor.id) positions.set(descriptor.id, descriptors.length);
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
}
|
||||
let ordered = descriptors;
|
||||
let start = previous?.partial ? previous.start : 0;
|
||||
let end;
|
||||
const read = entryReader(id);
|
||||
const currentEntries = current?.entries || [];
|
||||
const resetChanged = rebased && (current.resetRevision || null) !== (previous.resetRevision || null);
|
||||
if (rebased && !resetChanged) {
|
||||
if ([viewedEntries, descriptors, currentEntries].some(entries => !entries.every(entry => typeof entry.id === 'string' && entry.id))) {
|
||||
throw Object.assign(new Error('旧修订缺少可靠消息 ID,拒绝覆盖新历史'), { code: 'history_stale_unidentified' });
|
||||
}
|
||||
if (baseline.messages.length > 0 && persisted.messages.length === 0 && !equal(current.session.messages, baseline.messages)) {
|
||||
if (!previous.partial && baseEntries.length > 0 && descriptors.length === 0
|
||||
&& !equal(currentEntries.map(entry => entry.digest), baseEntries.map(entry => entry.digest))) {
|
||||
throw Object.assign(new Error('清空前历史已经更新,请重新加载'), { code: 'history_stale_clear' });
|
||||
}
|
||||
if ((current.resetRevision || null) !== (previous.resetRevision || null)) {
|
||||
// 清空提交后旧运行对象只能同步元数据,旧线程的历史和输出不能复活。
|
||||
persisted.messages = baseline.messages;
|
||||
for (const key of ['claudeSessionId', 'codexThreadId', 'codexAppThreadId', 'historySnapshotBaseIndex', 'historySnapshotCount']) {
|
||||
if (Object.prototype.hasOwnProperty.call(baseline, key)) persisted[key] = baseline[key];
|
||||
else delete persisted[key];
|
||||
const baseById = new Map(viewedEntries.map(entry => [entry.id, entry]));
|
||||
const incomingById = new Map(descriptors.map(entry => [entry.id, entry]));
|
||||
const currentIds = new Set(currentEntries.map(entry => entry.id));
|
||||
ordered = [];
|
||||
for (const entry of currentEntries) {
|
||||
const base = baseById.get(entry.id);
|
||||
const next = incomingById.get(entry.id);
|
||||
if (!base) { ordered.push(entry); continue; }
|
||||
if (!next) {
|
||||
if (entry.digest !== base.digest) ordered.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (next.digest === base.digest || next.digest === entry.digest) ordered.push(entry);
|
||||
else if (entry.digest === base.digest) ordered.push(next);
|
||||
else ordered.push({ id: next.id, incoming: next.message, base, current: entry });
|
||||
}
|
||||
persisted = mergeValue(baseline, persisted, current.session);
|
||||
const orderedIds = new Set(ordered.map(entry => entry.id));
|
||||
for (let index = 0; index < descriptors.length; index += 1) {
|
||||
const item = descriptors[index];
|
||||
if (baseById.has(item.id) || currentIds.has(item.id) || orderedIds.has(item.id)) continue;
|
||||
let position = -1;
|
||||
for (let next = index + 1; next < descriptors.length; next += 1) {
|
||||
position = ordered.findIndex(entry => entry.id === descriptors[next].id);
|
||||
if (position >= 0) break;
|
||||
}
|
||||
if (position < 0) {
|
||||
for (let anchor = index - 1; anchor >= 0; anchor -= 1) {
|
||||
const found = ordered.findIndex(entry => entry.id === descriptors[anchor].id);
|
||||
if (found >= 0) { position = found + 1; break; }
|
||||
}
|
||||
}
|
||||
ordered.splice(position < 0 ? ordered.length : position, 0, item);
|
||||
orderedIds.add(item.id);
|
||||
}
|
||||
if (previous.partial) {
|
||||
// 只协调原加载窗口及窗口内的并发新增,不能把未加载的后缀变成内存基线。
|
||||
const prefixIds = new Set(baseEntries.slice(0, previous.start).map(entry => entry.id));
|
||||
const suffixIds = new Set(baseEntries.slice(previous.end).map(entry => entry.id));
|
||||
const lastPrefix = ordered.reduce((found, entry, index) => prefixIds.has(entry.id) ? index : found, -1);
|
||||
start = lastPrefix >= 0 ? lastPrefix + 1 : Math.min(previous.start, ordered.length);
|
||||
const firstSuffix = ordered.findIndex(entry => suffixIds.has(entry.id));
|
||||
end = firstSuffix >= 0 ? firstSuffix : ordered.length;
|
||||
if (previous.start === previous.end && descriptors.length === 0) start = end;
|
||||
}
|
||||
} else if (resetChanged) {
|
||||
// 清空后的旧对象只同步元数据,旧线程及输出不能复活。
|
||||
ordered = currentEntries;
|
||||
start = previous.partial ? currentEntries.length : 0;
|
||||
end = currentEntries.length;
|
||||
for (const key of ['claudeSessionId', 'codexThreadId', 'codexAppThreadId']) {
|
||||
if (Object.prototype.hasOwnProperty.call(previous.manifest.session, key)) metadata[key] = previous.manifest.session[key];
|
||||
else delete metadata[key];
|
||||
}
|
||||
} else if (previous?.partial) {
|
||||
const prefix = currentEntries.slice(0, previous.start);
|
||||
const suffix = currentEntries.slice(previous.end);
|
||||
const unseenIds = new Set([...prefix, ...suffix].filter(entry => entry.id).map(entry => entry.id));
|
||||
// 重试的稳定 ID 若已存在于未加载区间,保留已提交原文和位置。
|
||||
ordered = [...prefix, ...descriptors.filter(entry => !entry.id || !unseenIds.has(entry.id)), ...suffix];
|
||||
end = ordered.length - suffix.length;
|
||||
}
|
||||
if (complete) {
|
||||
persisted.historySnapshotBaseIndex = 0;
|
||||
persisted.historySnapshotCount = persisted.messages.length;
|
||||
if (rebased) metadata = mergeValue(previous.manifest.session, metadata, current.session);
|
||||
const writer = chunkWriter(id);
|
||||
const currentById = new Map(currentEntries.filter(entry => entry.id).map(entry => [entry.id, entry]));
|
||||
const entries = ordered.map((item, index) => {
|
||||
if (item.file) return item;
|
||||
const same = item.id ? currentById.get(item.id) : currentEntries[index];
|
||||
if (same && same.digest === item.digest) return same;
|
||||
const message = item.current
|
||||
? mergeValue(read(item.base), cloneJsonValue(item.incoming), read(item.current))
|
||||
: item.message;
|
||||
return writer.add(message);
|
||||
});
|
||||
writer.finish();
|
||||
const nextRevision = crypto.randomUUID();
|
||||
const resetRevision = previous?.reset ? nextRevision : current?.resetRevision || null;
|
||||
const record = { version: 2, revision: nextRevision, complete, resetRevision, session: metadata, entries };
|
||||
end ??= entries.length;
|
||||
let updated;
|
||||
let synchronizedMetadata;
|
||||
if (rebased || end - start !== session.messages.length) {
|
||||
const byId = new Map(session.messages.filter(message => message?.id).map(message => [message.id, message]));
|
||||
// 提交前完成所有可能失败的块读取;提交失败不修改活跃消息和可信基线。
|
||||
updated = entries.slice(start, end).map(entry => ({ target: byId.get(entry.id), persisted: read(entry) }));
|
||||
// 活跃元数据可以原位更新,不能与不可变清单基线共用新增的对象引用。
|
||||
synchronizedMetadata = cloneJsonValue(metadata);
|
||||
}
|
||||
const revision = crypto.randomUUID();
|
||||
const resetRevision = previous?.reset ? revision : current?.resetRevision || null;
|
||||
const record = { version: 1, revision, complete, resetRevision, session: persisted };
|
||||
writeRecord(id, record);
|
||||
// 只有原子提交后才推进修订,失败重试仍以原修订为基线。
|
||||
loadedStates.set(session, { id, revision, complete, resetRevision, source: 'archive', baseline: JSON.stringify(persisted) });
|
||||
if (rebased) {
|
||||
// 流式处理闭包可能持有气泡和工具对象,合并时按 ID 原位协调,保持后续 delta 写入目标。
|
||||
synchronizeValue(session, persisted, true);
|
||||
const synchronizedTracking = taskTrackingPreserved && !updated ? cloneJsonValue(metadata.taskTracking) : null;
|
||||
writeAtomicJson(manifestPath(id), record);
|
||||
if (updated) {
|
||||
session.messages.length = updated.length;
|
||||
updated.forEach((item, index) => { session.messages[index] = synchronizeValue(item.target, item.persisted); });
|
||||
synchronizeValue(session, { ...synchronizedMetadata, messages: session.messages }, true);
|
||||
}
|
||||
if (synchronizedTracking) session.taskTracking = synchronizeValue(session.taskTracking, synchronizedTracking);
|
||||
session.historySnapshotBaseIndex = start;
|
||||
session.historySnapshotCount = session.messages.length;
|
||||
register(session, record, start, end, start > 0 || end < entries.length);
|
||||
if (++commits % 32 === 0) {
|
||||
// 只回收当前清单和活跃旧修订都不再引用的块,清理失败不改变已提交结果。
|
||||
try { collectChunks(id, record); } catch {}
|
||||
}
|
||||
return { revision: nextRevision, complete, session, rebased, taskTrackingPreserved };
|
||||
}
|
||||
|
||||
function collectChunks(id, current) {
|
||||
const files = new Set(current.entries.map(entry => entry.file));
|
||||
for (const reference of liveBaselines) {
|
||||
const baseline = reference.deref();
|
||||
if (!baseline) { liveBaselines.delete(reference); continue; }
|
||||
if (baseline.id === id && baseline.directory === archiveDir(id)) baseline.manifest.entries.forEach(entry => files.add(entry.file));
|
||||
}
|
||||
const directory = path.join(archiveDir(id), 'chunks');
|
||||
for (const file of fs.readdirSync(directory)) {
|
||||
if (/^[a-f0-9]{64}\.json$/.test(file) && !files.has(file)) fs.unlinkSync(path.join(directory, file));
|
||||
}
|
||||
return { revision, complete, session: persisted, rebased };
|
||||
}
|
||||
|
||||
function listIds() {
|
||||
try {
|
||||
return fs.readdirSync(historyDir).filter(name => /^[a-zA-Z0-9_-]+\.json$/.test(name))
|
||||
.map(name => name.slice(0, -5));
|
||||
const ids = new Set();
|
||||
for (const name of fs.readdirSync(historyDir)) {
|
||||
// 运行态归档属于保留目录,不参与会话归档枚举。
|
||||
if (name === '_turns' || name === '_turns.json') continue;
|
||||
if (/^[a-zA-Z0-9_-]+\.json$/.test(name)) ids.add(name.slice(0, -5));
|
||||
else if (/^[a-zA-Z0-9_-]+$/.test(name) && fs.existsSync(manifestPath(name))) ids.add(name);
|
||||
}
|
||||
return [...ids];
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
@@ -309,10 +641,11 @@ function createSessionHistoryStore(options = {}) {
|
||||
function revision(id) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(filePath(id), 'r');
|
||||
const recordPath = fs.existsSync(manifestPath(id)) ? manifestPath(id) : filePath(id);
|
||||
fd = fs.openSync(recordPath, 'r');
|
||||
const head = Buffer.alloc(256);
|
||||
const count = fs.readSync(fd, head, 0, head.length, 0);
|
||||
const matched = /^\{"version":1,"revision":"([0-9a-f-]{36})",/.exec(head.toString('utf8', 0, count));
|
||||
const matched = /^\{"version":[12],"revision":"([0-9a-f-]{36})",/.exec(head.toString('utf8', 0, count));
|
||||
if (!matched) throw new Error('完整会话归档修订无效');
|
||||
return matched[1];
|
||||
} catch (error) {
|
||||
@@ -328,11 +661,13 @@ function createSessionHistoryStore(options = {}) {
|
||||
try { fs.unlinkSync(filePath(id)); } catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
fs.rmSync(archiveDir(id), { recursive: true, force: true });
|
||||
removedIds.add(id);
|
||||
removeTurn(id);
|
||||
}
|
||||
|
||||
return { load, save, state, markLegacy, markComplete, listIds, revision, remove, saveTurn, loadTurn, removeTurn, listTurnIds, historyDir };
|
||||
return { load, loadTail, loadMetadata, loadWindow, forEachMessage, findMessage, loadHistoryPage, messageIndex, countMessages, save, state, markLegacy, markComplete,
|
||||
listIds, revision, remove, saveTurn, loadTurn, removeTurn, listTurnIds, historyDir };
|
||||
}
|
||||
|
||||
module.exports = { createSessionHistoryStore };
|
||||
|
||||
Reference in New Issue
Block a user