@stardustlc/dsh-dream 0.5.0 → 0.5.1
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/CHANGELOG.md +9 -0
- package/README.en.md +12 -7
- package/README.md +12 -7
- package/lib/client.js +54 -6
- package/lib/journal.d.ts +15 -0
- package/lib/journal.js +32 -11
- package/lib/knowledge-store.d.ts +92 -7
- package/lib/knowledge-store.js +815 -187
- package/lib/knowledge.d.ts +26 -3
- package/lib/knowledge.js +36 -5
- package/lib/retrieval.d.ts +35 -7
- package/lib/retrieval.js +130 -18
- package/lib/tools.js +49 -7
- package/lib/web.d.ts +4 -1
- package/lib/web.js +2 -1
- package/package.json +1 -1
- package/skills/dream-protocol/SKILL.md +8 -4
package/lib/knowledge-store.js
CHANGED
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-dream 知识存储:events.jsonl 为权威追加日志,evidence.jsonl 追加证据,
|
|
3
|
-
* index.json
|
|
3
|
+
* index.json 为可丢弃重建的派生索引(含 checkpoint),.lock 为短期文件锁。
|
|
4
4
|
*
|
|
5
5
|
* 设计要点:
|
|
6
|
-
* - 幂等:同 idempotencyKey
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
6
|
+
* - 幂等:同 idempotencyKey + 同 requestHash 重复调用返回首次结果且不写新事件;
|
|
7
|
+
* 同 key 异 payload → KnowledgeError('duplicate')。
|
|
8
|
+
* - revision 守卫:expectedRevision 不符抛 KnowledgeError('revision')(details 带 currentRevision),不写盘。
|
|
9
|
+
* - 崩溃安全:单行追加 + 单行 JSON.parse 容错;坏行/孤儿事件/未知 schemaVersion 分开计数。
|
|
10
|
+
* - 有界回放:events.jsonl 超过 maxReplayEvents 行时 truncated=true;
|
|
11
|
+
* index.json 的 checkpoint(lastEventOffset/lastEventBytes/lastEventId/前缀哈希)让读路径
|
|
12
|
+
* "快照 + 尾部增量",截断不再丢实体;checkpoint 失效回退全量回放;rebuildIndex() 权威重建。
|
|
13
|
+
* - 写路径在状态不完整/截断时自动做一次完整回放,避免"看不到旧实体就无法更新"。
|
|
14
|
+
* - 只读方法(listLessons/listEvidence/getLesson/stats/diagnose)绝不建目录、绝不写盘。
|
|
12
15
|
* - 新写盘路径在 maskSecrets 开启(默认)时统一过 mask.ts。
|
|
13
16
|
*
|
|
14
17
|
* @module dsh-dream/knowledge-store
|
|
15
18
|
*/
|
|
16
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync,
|
|
17
|
-
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
|
|
20
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
18
21
|
import { join } from 'node:path';
|
|
19
22
|
import { StringDecoder } from 'node:string_decoder';
|
|
20
23
|
import { KnowledgeError, canTransition, evidenceNaturalKey, evidenceSessionKey, lessonFingerprint, mergeEvidenceSupport, validateEvidence, validateLesson, } from './knowledge.js';
|
|
21
24
|
import { maskSecrets } from './mask.js';
|
|
22
25
|
/** events.jsonl 默认最多回放的行数(有界读取)。 */
|
|
23
26
|
export const MAX_REPLAY_EVENTS = 200000;
|
|
27
|
+
const PREFIX_HASH_BYTES = 64 * 1024;
|
|
28
|
+
const LAST_LINE_WINDOW_BYTES = 64 * 1024;
|
|
29
|
+
const TOUCH_EVERY_LINES = 4096;
|
|
30
|
+
/** 本进程 boot 标识,用于锁的 PID 复用判别。 */
|
|
31
|
+
const BOOT_ID = randomUUID();
|
|
24
32
|
const LESSON_STATES = ['candidate', 'usable', 'disputed', 'stale', 'rejected'];
|
|
25
33
|
const REVIEW_DECISIONS = ['unreviewed', 'accepted', 'rejected'];
|
|
26
34
|
function isRecord(value) {
|
|
@@ -47,28 +55,45 @@ function isProcessAlive(pid) {
|
|
|
47
55
|
return error.code === 'EPERM';
|
|
48
56
|
}
|
|
49
57
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
function stableStringify(value) {
|
|
59
|
+
if (value === null || typeof value !== 'object') {
|
|
60
|
+
const text = JSON.stringify(value);
|
|
61
|
+
return text === undefined ? 'null' : text;
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
|
|
65
|
+
const record = value;
|
|
66
|
+
return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
|
|
67
|
+
}
|
|
68
|
+
/** 读取锁信息;绝不修改文件。 */
|
|
69
|
+
function readLockInfo(lockPath, staleMs) {
|
|
70
|
+
let raw;
|
|
58
71
|
try {
|
|
59
|
-
|
|
72
|
+
raw = readFileSync(lockPath, 'utf8');
|
|
60
73
|
}
|
|
61
74
|
catch {
|
|
62
|
-
return
|
|
75
|
+
return { present: false, stale: false };
|
|
76
|
+
}
|
|
77
|
+
let at = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
at = statSync(lockPath).mtimeMs;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// 用文件 mtime 判断年龄失败时退回 now
|
|
63
83
|
}
|
|
64
|
-
let at = mtimeMs;
|
|
65
84
|
let pid;
|
|
85
|
+
let bootId;
|
|
86
|
+
let token;
|
|
66
87
|
try {
|
|
67
|
-
const parsed = JSON.parse(
|
|
88
|
+
const parsed = JSON.parse(raw);
|
|
68
89
|
if (isRecord(parsed)) {
|
|
69
90
|
const rawPid = parsed.pid;
|
|
70
91
|
if (typeof rawPid === 'number' && Number.isInteger(rawPid) && rawPid > 0)
|
|
71
92
|
pid = rawPid;
|
|
93
|
+
if (typeof parsed.bootId === 'string')
|
|
94
|
+
bootId = parsed.bootId;
|
|
95
|
+
if (typeof parsed.token === 'string')
|
|
96
|
+
token = parsed.token;
|
|
72
97
|
const rawAt = parsed.at;
|
|
73
98
|
if (typeof rawAt === 'string') {
|
|
74
99
|
const parsedAt = Date.parse(rawAt);
|
|
@@ -80,11 +105,21 @@ function lockIsStale(lockPath, staleMs) {
|
|
|
80
105
|
catch {
|
|
81
106
|
// 内容损坏:无法验证所有者,只能用文件 mtime 判断年龄
|
|
82
107
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
const ageMs = Date.now() - at;
|
|
109
|
+
const alive = pid === undefined ? undefined : isProcessAlive(pid);
|
|
110
|
+
const stale = ageMs > staleMs && (pid === undefined || alive === false);
|
|
111
|
+
return { present: true, ageMs, pid, alive, bootId, token, stale, raw };
|
|
112
|
+
}
|
|
113
|
+
function refreshLock(lockPath, token) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
116
|
+
if (!isRecord(parsed) || parsed.token !== token)
|
|
117
|
+
return;
|
|
118
|
+
writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token, bootId: BOOT_ID }));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// 锁已被接管/删除:心跳失败不覆盖他人锁
|
|
122
|
+
}
|
|
88
123
|
}
|
|
89
124
|
/** 释放自己持有的锁:token 不匹配或内容不可读时不删,避免误删他人锁。 */
|
|
90
125
|
function releaseLock(lockPath, token) {
|
|
@@ -103,7 +138,17 @@ function releaseLock(lockPath, token) {
|
|
|
103
138
|
// 已被他人清理或无权删除:保持现状
|
|
104
139
|
}
|
|
105
140
|
}
|
|
106
|
-
|
|
141
|
+
function holderDetails(info) {
|
|
142
|
+
const details = {};
|
|
143
|
+
if (info.pid !== undefined)
|
|
144
|
+
details.holderPid = info.pid;
|
|
145
|
+
if (info.ageMs !== undefined)
|
|
146
|
+
details.ageMs = Math.round(info.ageMs);
|
|
147
|
+
if (info.bootId !== undefined)
|
|
148
|
+
details.holderBootId = info.bootId;
|
|
149
|
+
return details;
|
|
150
|
+
}
|
|
151
|
+
/** 获取短期文件锁;失败抛 KnowledgeError('io')。超硬上限且心跳停滞时不静默抢占。 */
|
|
107
152
|
function acquireLock(lockPath, options) {
|
|
108
153
|
const token = randomUUID();
|
|
109
154
|
const deadline = Date.now() + options.timeoutMs;
|
|
@@ -112,7 +157,7 @@ function acquireLock(lockPath, options) {
|
|
|
112
157
|
try {
|
|
113
158
|
const fd = openSync(lockPath, 'wx');
|
|
114
159
|
try {
|
|
115
|
-
writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }));
|
|
160
|
+
writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token, bootId: BOOT_ID }));
|
|
116
161
|
}
|
|
117
162
|
finally {
|
|
118
163
|
closeSync(fd);
|
|
@@ -124,25 +169,48 @@ function acquireLock(lockPath, options) {
|
|
|
124
169
|
if (code !== 'EEXIST')
|
|
125
170
|
throw new KnowledgeError('io', '获取知识库锁失败:' + errorMessage(error));
|
|
126
171
|
}
|
|
127
|
-
if (acquired)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
172
|
+
if (acquired) {
|
|
173
|
+
const heartbeat = setInterval(() => refreshLock(lockPath, token), options.heartbeatMs);
|
|
174
|
+
if (typeof heartbeat.unref === 'function')
|
|
175
|
+
heartbeat.unref();
|
|
176
|
+
return {
|
|
177
|
+
release: () => {
|
|
178
|
+
clearInterval(heartbeat);
|
|
179
|
+
releaseLock(lockPath, token);
|
|
180
|
+
},
|
|
181
|
+
touch: () => refreshLock(lockPath, token),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const info = readLockInfo(lockPath, options.staleMs);
|
|
185
|
+
if (info.stale) {
|
|
186
|
+
// TOCTOU:删除前再确认内容没有变化(他人刚接管/刷新则重新判断)。
|
|
187
|
+
const recheck = readLockInfo(lockPath, options.staleMs);
|
|
188
|
+
if (recheck.raw === info.raw) {
|
|
189
|
+
try {
|
|
190
|
+
unlinkSync(lockPath);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
const code = error.code;
|
|
195
|
+
if (code === 'ENOENT')
|
|
196
|
+
continue;
|
|
136
197
|
if (Date.now() >= deadline) {
|
|
137
|
-
throw new KnowledgeError('io', '
|
|
198
|
+
throw new KnowledgeError('io', '检测到过期知识库锁但无法接管:' + errorMessage(error), holderDetails(info));
|
|
138
199
|
}
|
|
139
|
-
sleepSync(25);
|
|
140
200
|
}
|
|
141
201
|
}
|
|
202
|
+
sleepSync(25);
|
|
142
203
|
continue;
|
|
143
204
|
}
|
|
144
|
-
if (
|
|
145
|
-
throw new KnowledgeError('io', '
|
|
205
|
+
if (info.present && info.pid !== undefined && info.alive === true && (info.ageMs ?? 0) > options.hardLimitMs) {
|
|
206
|
+
throw new KnowledgeError('io', '知识库锁被进程 ' + info.pid + ' 持有 ' + Math.round(info.ageMs ?? 0) + 'ms(超过硬上限 ' + options.hardLimitMs + 'ms 且心跳停滞);请结束该进程后重试,或确认无人写入后手动删除 ' + lockPath, holderDetails(info));
|
|
207
|
+
}
|
|
208
|
+
if (Date.now() >= deadline) {
|
|
209
|
+
if (info.present) {
|
|
210
|
+
throw new KnowledgeError('io', '知识库被其他进程占用(pid=' + (info.pid === undefined ? '未知' : String(info.pid)) + ',age=' + Math.round(info.ageMs ?? 0) + 'ms),请稍后重试', holderDetails(info));
|
|
211
|
+
}
|
|
212
|
+
throw new KnowledgeError('io', '获取知识库锁超时,请稍后重试');
|
|
213
|
+
}
|
|
146
214
|
sleepSync(25);
|
|
147
215
|
}
|
|
148
216
|
}
|
|
@@ -172,9 +240,40 @@ function isEventShape(value) {
|
|
|
172
240
|
return false;
|
|
173
241
|
}
|
|
174
242
|
}
|
|
243
|
+
/** 事件 / 证据行是否携带非当前 schemaVersion(R7:独立计 unsupportedVersions)。 */
|
|
244
|
+
function hasUnsupportedSchema(value) {
|
|
245
|
+
if (!isRecord(value))
|
|
246
|
+
return false;
|
|
247
|
+
const version = value.schemaVersion;
|
|
248
|
+
if (typeof version === 'number' && version !== 1)
|
|
249
|
+
return true;
|
|
250
|
+
const payload = value.payload;
|
|
251
|
+
if (isRecord(payload)) {
|
|
252
|
+
const payloadVersion = payload.schemaVersion;
|
|
253
|
+
if (typeof payloadVersion === 'number' && payloadVersion !== 1)
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
175
258
|
function emptyStateCounts() {
|
|
176
259
|
return { candidate: 0, usable: 0, disputed: 0, stale: 0, rejected: 0 };
|
|
177
260
|
}
|
|
261
|
+
function createEmptyReplayState() {
|
|
262
|
+
return {
|
|
263
|
+
lessons: new Map(),
|
|
264
|
+
evidence: new Map(),
|
|
265
|
+
idempotency: new Map(),
|
|
266
|
+
events: 0,
|
|
267
|
+
replayedEvents: 0,
|
|
268
|
+
badLines: 0,
|
|
269
|
+
orphanEvents: 0,
|
|
270
|
+
unsupportedVersions: 0,
|
|
271
|
+
replayMode: 'full',
|
|
272
|
+
idempotencyComplete: true,
|
|
273
|
+
entitiesFromSnapshot: false,
|
|
274
|
+
snapshotEvidenceCount: 0,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
178
277
|
/**
|
|
179
278
|
* 经验知识存储。无文件时所有读取方法安全返回空,写入方法在首次写入时自建目录。
|
|
180
279
|
*/
|
|
@@ -188,6 +287,9 @@ export class KnowledgeStore {
|
|
|
188
287
|
maxReplayEvents;
|
|
189
288
|
lockTimeoutMs;
|
|
190
289
|
lockStaleMs;
|
|
290
|
+
lockHardLimitMs;
|
|
291
|
+
lockHeartbeatMs;
|
|
292
|
+
lastIndexError;
|
|
191
293
|
constructor(knowledgeDir, options = {}) {
|
|
192
294
|
if (typeof knowledgeDir !== 'string' || knowledgeDir.trim() === '') {
|
|
193
295
|
throw new KnowledgeError('invalid', 'knowledgeDir 必须是非空字符串');
|
|
@@ -195,6 +297,7 @@ export class KnowledgeStore {
|
|
|
195
297
|
const maxReplay = typeof options.maxReplayEvents === 'number' && Number.isFinite(options.maxReplayEvents)
|
|
196
298
|
? Math.max(1, Math.floor(options.maxReplayEvents))
|
|
197
299
|
: MAX_REPLAY_EVENTS;
|
|
300
|
+
const numberOption = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
198
301
|
this.dir = knowledgeDir;
|
|
199
302
|
this.eventsPath = join(knowledgeDir, 'events.jsonl');
|
|
200
303
|
this.evidencePath = join(knowledgeDir, 'evidence.jsonl');
|
|
@@ -202,8 +305,10 @@ export class KnowledgeStore {
|
|
|
202
305
|
this.lockPath = join(knowledgeDir, '.lock');
|
|
203
306
|
this.mask = options.maskSecrets !== false;
|
|
204
307
|
this.maxReplayEvents = maxReplay;
|
|
205
|
-
this.lockTimeoutMs =
|
|
206
|
-
this.lockStaleMs =
|
|
308
|
+
this.lockTimeoutMs = numberOption(options.lockTimeoutMs, 2000);
|
|
309
|
+
this.lockStaleMs = numberOption(options.lockStaleMs, 30000);
|
|
310
|
+
this.lockHardLimitMs = numberOption(options.lockHardLimitMs, 600000);
|
|
311
|
+
this.lockHeartbeatMs = Math.max(10, numberOption(options.lockHeartbeatMs, 5000));
|
|
207
312
|
}
|
|
208
313
|
/** 全部经验,按 updatedAt 降序(同刻按 id 稳定排序)。 */
|
|
209
314
|
listLessons() {
|
|
@@ -229,8 +334,8 @@ export class KnowledgeStore {
|
|
|
229
334
|
appendEvidence(input) {
|
|
230
335
|
if (!isRecord(input))
|
|
231
336
|
throw new KnowledgeError('invalid', '证据输入必须是对象');
|
|
232
|
-
return this.withLock(() => {
|
|
233
|
-
const state = this.
|
|
337
|
+
return this.withLock((touch) => {
|
|
338
|
+
const state = this.loadCompleteState(touch);
|
|
234
339
|
const existing = this.findEvidenceByNaturalKey(state, input);
|
|
235
340
|
if (existing !== undefined)
|
|
236
341
|
return { evidence: clone(existing), created: false };
|
|
@@ -239,23 +344,44 @@ export class KnowledgeStore {
|
|
|
239
344
|
});
|
|
240
345
|
}
|
|
241
346
|
/**
|
|
242
|
-
* 创建候选经验。同 idempotencyKey 幂等;同指纹(文本 + 范围相同)确定性合并,
|
|
347
|
+
* 创建候选经验。同 idempotencyKey + 同 requestHash 幂等;同指纹(文本 + 范围相同)确定性合并,
|
|
243
348
|
* 只把新证据并入已有经验,不新增经验。
|
|
349
|
+
* R2′:有 read 证据 → 初始 usable(含 user-correction read 时 review=accepted/user);只有 claimed 或无证据 → candidate。
|
|
244
350
|
*/
|
|
245
351
|
createLesson(input, idempotencyKey) {
|
|
246
352
|
assertIdempotencyKey(idempotencyKey);
|
|
247
353
|
if (!isRecord(input))
|
|
248
354
|
throw new KnowledgeError('invalid', '经验输入必须是对象');
|
|
249
|
-
if (!Array.isArray(input.evidence))
|
|
355
|
+
if (input.evidence !== undefined && !Array.isArray(input.evidence)) {
|
|
250
356
|
throw new KnowledgeError('invalid', 'evidence 必须是数组');
|
|
251
|
-
|
|
252
|
-
|
|
357
|
+
}
|
|
358
|
+
const evidenceInputs = input.evidence === undefined ? [] : input.evidence;
|
|
359
|
+
const requestHash = this.hashRequest({
|
|
360
|
+
operation: 'lesson.create',
|
|
361
|
+
kind: input.kind === undefined ? null : input.kind,
|
|
362
|
+
title: input.title === undefined ? null : input.title,
|
|
363
|
+
action: input.action === undefined ? null : input.action,
|
|
364
|
+
when: input.when === undefined ? null : input.when,
|
|
365
|
+
exceptions: input.exceptions === undefined ? null : input.exceptions,
|
|
366
|
+
projectId: input.projectId === undefined ? null : input.projectId,
|
|
367
|
+
workspaceRoot: input.workspaceRoot === undefined ? null : input.workspaceRoot,
|
|
368
|
+
global: input.global === true,
|
|
369
|
+
applicability: input.applicability === undefined ? null : input.applicability,
|
|
370
|
+
evidence: evidenceInputs,
|
|
371
|
+
conflictIds: input.conflictIds === undefined ? null : input.conflictIds,
|
|
372
|
+
});
|
|
373
|
+
return this.withLock((touch) => {
|
|
374
|
+
const state = this.loadCompleteState(touch);
|
|
253
375
|
const cached = state.idempotency.get(idempotencyKey);
|
|
254
|
-
if (cached !== undefined)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
376
|
+
if (cached !== undefined) {
|
|
377
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
378
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
379
|
+
if (lesson === undefined) {
|
|
380
|
+
throw new KnowledgeError('io', '无法恢复幂等键的历史结果(events.jsonl 可能被删改):' + idempotencyKey, { idempotencyKey });
|
|
381
|
+
}
|
|
382
|
+
return { lesson, created: false };
|
|
383
|
+
}
|
|
384
|
+
const scope = { global: input.global === true };
|
|
259
385
|
if (typeof input.projectId === 'string' && input.projectId.trim() !== '')
|
|
260
386
|
scope.projectId = input.projectId;
|
|
261
387
|
if (typeof input.workspaceRoot === 'string' && input.workspaceRoot.trim() !== '')
|
|
@@ -289,21 +415,51 @@ export class KnowledgeStore {
|
|
|
289
415
|
});
|
|
290
416
|
const existing = Array.from(state.lessons.values()).find((lesson) => lessonFingerprint(lesson) === fingerprint);
|
|
291
417
|
const appended = [];
|
|
292
|
-
for (const raw of
|
|
418
|
+
for (const raw of evidenceInputs)
|
|
293
419
|
appended.push(this.persistEvidenceLocked(raw, state));
|
|
294
420
|
if (existing !== undefined) {
|
|
295
421
|
const newIds = appended.map((item) => item.id).filter((id) => !existing.evidenceIds.includes(id));
|
|
296
|
-
|
|
422
|
+
const mergedIds = existing.evidenceIds.concat(newIds);
|
|
423
|
+
const mergedEvidence = mergedIds
|
|
424
|
+
.map((id) => state.evidence.get(id))
|
|
425
|
+
.filter((item) => item !== undefined);
|
|
426
|
+
const hasRead = mergedEvidence.some((item) => item.verification === 'read');
|
|
427
|
+
const hasUserCorrectionRead = mergedEvidence.some((item) => item.kind === 'user-correction' && item.verification === 'read');
|
|
428
|
+
const patch = {};
|
|
297
429
|
if (newIds.length > 0) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
evidenceIds: mergedIds,
|
|
301
|
-
independentSupportCount: mergeEvidenceSupport({ evidenceIds: mergedIds }, Array.from(state.evidence.values())),
|
|
302
|
-
}, idempotencyKey);
|
|
430
|
+
patch.evidenceIds = mergedIds;
|
|
431
|
+
patch.independentSupportCount = mergeEvidenceSupport({ evidenceIds: mergedIds }, Array.from(state.evidence.values()));
|
|
303
432
|
}
|
|
433
|
+
// R2′:候选经验遇到 read 证据可升为 usable;disputed/stale/rejected 状态保持不变。
|
|
434
|
+
if (existing.state === 'candidate' && hasRead) {
|
|
435
|
+
patch.state = 'usable';
|
|
436
|
+
if (hasUserCorrectionRead) {
|
|
437
|
+
patch.review = { decision: 'accepted', actor: 'user', at: now };
|
|
438
|
+
}
|
|
439
|
+
else if (existing.review.decision !== 'unreviewed') {
|
|
440
|
+
patch.review = { decision: 'unreviewed' };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
else if (existing.state === 'usable' &&
|
|
444
|
+
hasUserCorrectionRead &&
|
|
445
|
+
existing.review.decision !== 'accepted') {
|
|
446
|
+
patch.review = { decision: 'accepted', actor: 'user', at: now };
|
|
447
|
+
}
|
|
448
|
+
if (Object.keys(patch).length === 0)
|
|
449
|
+
return { lesson: clone(existing), created: false };
|
|
450
|
+
const merged = this.appendLessonUpdate(state, existing, patch, idempotencyKey, requestHash, touch);
|
|
304
451
|
return { lesson: clone(merged), created: false };
|
|
305
452
|
}
|
|
306
453
|
const evidenceIds = appended.map((item) => item.id);
|
|
454
|
+
const allEvidence = evidenceIds
|
|
455
|
+
.map((id) => state.evidence.get(id))
|
|
456
|
+
.filter((item) => item !== undefined);
|
|
457
|
+
const hasRead = allEvidence.some((item) => item.verification === 'read');
|
|
458
|
+
const hasUserCorrectionRead = allEvidence.some((item) => item.kind === 'user-correction' && item.verification === 'read');
|
|
459
|
+
const initialState = hasRead ? 'usable' : 'candidate';
|
|
460
|
+
const initialReview = hasUserCorrectionRead
|
|
461
|
+
? { decision: 'accepted', actor: 'user', at: now }
|
|
462
|
+
: { decision: 'unreviewed' };
|
|
307
463
|
const lesson = validateLesson({
|
|
308
464
|
schemaVersion: 1,
|
|
309
465
|
id: 'lsn_' + randomUUID(),
|
|
@@ -315,65 +471,68 @@ export class KnowledgeStore {
|
|
|
315
471
|
exceptions: input.exceptions === undefined ? [] : input.exceptions,
|
|
316
472
|
scope,
|
|
317
473
|
applicability: input.applicability === undefined ? [] : input.applicability,
|
|
318
|
-
state:
|
|
474
|
+
state: initialState,
|
|
319
475
|
evidenceIds,
|
|
320
476
|
independentSupportCount: mergeEvidenceSupport({ evidenceIds }, Array.from(state.evidence.values())),
|
|
321
|
-
review:
|
|
477
|
+
review: initialReview,
|
|
322
478
|
createdAt: now,
|
|
323
479
|
updatedAt: now,
|
|
324
480
|
conflictIds: input.conflictIds === undefined ? [] : input.conflictIds,
|
|
325
481
|
});
|
|
326
482
|
const stored = this.maskLesson(lesson);
|
|
327
|
-
|
|
483
|
+
const event = {
|
|
328
484
|
schemaVersion: 1,
|
|
329
485
|
id: 'evt_' + randomUUID(),
|
|
330
486
|
at: now,
|
|
331
487
|
kind: 'lesson.create',
|
|
332
488
|
lessonId: stored.id,
|
|
333
489
|
idempotencyKey,
|
|
490
|
+
requestHash,
|
|
334
491
|
payload: stored,
|
|
335
|
-
}
|
|
492
|
+
};
|
|
493
|
+
this.appendEvent(state, event);
|
|
336
494
|
state.lessons.set(stored.id, stored);
|
|
337
|
-
if (!state.idempotency.has(idempotencyKey))
|
|
338
|
-
state.idempotency.set(idempotencyKey, stored);
|
|
339
|
-
|
|
495
|
+
if (!state.idempotency.has(idempotencyKey)) {
|
|
496
|
+
state.idempotency.set(idempotencyKey, { lessonId: stored.id, requestHash, lesson: stored });
|
|
497
|
+
}
|
|
498
|
+
this.writeIndex(state, touch);
|
|
340
499
|
return { lesson: clone(stored), created: true };
|
|
341
500
|
});
|
|
342
501
|
}
|
|
343
|
-
/** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision')
|
|
502
|
+
/** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision')(details 带 currentRevision)且不写。 */
|
|
344
503
|
updateLesson(id, patch, expectedRevision, idempotencyKey) {
|
|
345
504
|
assertIdempotencyKey(idempotencyKey);
|
|
346
505
|
if (!isRecord(patch))
|
|
347
506
|
throw new KnowledgeError('invalid', 'patch 必须是对象');
|
|
348
|
-
|
|
349
|
-
|
|
507
|
+
const requestHash = this.hashRequest({ operation: 'lesson.update', lessonId: id, patch: this.hashablePatch(patch) });
|
|
508
|
+
return this.withLock((touch) => {
|
|
509
|
+
const state = this.loadCompleteState(touch);
|
|
350
510
|
const cached = state.idempotency.get(idempotencyKey);
|
|
351
|
-
if (cached !== undefined)
|
|
352
|
-
|
|
511
|
+
if (cached !== undefined) {
|
|
512
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
513
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
514
|
+
if (lesson === undefined)
|
|
515
|
+
throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
|
|
516
|
+
return lesson;
|
|
517
|
+
}
|
|
353
518
|
const lesson = state.lessons.get(id);
|
|
354
519
|
if (lesson === undefined)
|
|
355
|
-
throw new KnowledgeError('invalid', '经验不存在:' + id);
|
|
520
|
+
throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
|
|
356
521
|
if (lesson.revision !== expectedRevision) {
|
|
357
|
-
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
continue;
|
|
363
|
-
const value = patch[key];
|
|
364
|
-
if (value === undefined)
|
|
365
|
-
continue;
|
|
366
|
-
clean[key] = value;
|
|
522
|
+
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
|
|
523
|
+
lessonId: id,
|
|
524
|
+
expectedRevision,
|
|
525
|
+
currentRevision: lesson.revision,
|
|
526
|
+
});
|
|
367
527
|
}
|
|
528
|
+
const clean = this.cleanPatch(patch);
|
|
368
529
|
if (clean.state !== undefined && clean.state !== lesson.state && !canTransition(lesson.state, clean.state)) {
|
|
369
|
-
throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + clean.state);
|
|
530
|
+
throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + clean.state, { lessonId: id });
|
|
370
531
|
}
|
|
371
532
|
if (clean.evidenceIds !== undefined) {
|
|
372
|
-
if (!Array.isArray(clean.evidenceIds))
|
|
373
|
-
throw new KnowledgeError('invalid', 'evidenceIds 必须是数组');
|
|
374
533
|
clean.independentSupportCount = mergeEvidenceSupport({ evidenceIds: clean.evidenceIds }, Array.from(state.evidence.values()));
|
|
375
534
|
}
|
|
376
|
-
return clone(this.appendLessonUpdate(state, lesson, clean, idempotencyKey));
|
|
535
|
+
return clone(this.appendLessonUpdate(state, lesson, clean, idempotencyKey, requestHash, touch));
|
|
377
536
|
});
|
|
378
537
|
}
|
|
379
538
|
/** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
|
|
@@ -382,20 +541,30 @@ export class KnowledgeStore {
|
|
|
382
541
|
if (!isEnum(decision, REVIEW_DECISIONS))
|
|
383
542
|
throw new KnowledgeError('invalid', 'review decision 取值不合法');
|
|
384
543
|
const maskedActor = actor === undefined ? undefined : this.mask ? maskSecrets(actor) : actor;
|
|
385
|
-
|
|
386
|
-
|
|
544
|
+
const requestHash = this.hashRequest({ operation: 'lesson.review', lessonId: id, decision, actor: maskedActor === undefined ? null : maskedActor });
|
|
545
|
+
return this.withLock((touch) => {
|
|
546
|
+
const state = this.loadCompleteState(touch);
|
|
387
547
|
const cached = state.idempotency.get(idempotencyKey);
|
|
388
|
-
if (cached !== undefined)
|
|
389
|
-
|
|
548
|
+
if (cached !== undefined) {
|
|
549
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
550
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
551
|
+
if (lesson === undefined)
|
|
552
|
+
throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
|
|
553
|
+
return lesson;
|
|
554
|
+
}
|
|
390
555
|
const lesson = state.lessons.get(id);
|
|
391
556
|
if (lesson === undefined)
|
|
392
|
-
throw new KnowledgeError('invalid', '经验不存在:' + id);
|
|
557
|
+
throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
|
|
393
558
|
if (lesson.revision !== expectedRevision) {
|
|
394
|
-
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision
|
|
559
|
+
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
|
|
560
|
+
lessonId: id,
|
|
561
|
+
expectedRevision,
|
|
562
|
+
currentRevision: lesson.revision,
|
|
563
|
+
});
|
|
395
564
|
}
|
|
396
565
|
const nextState = decision === 'accepted' ? 'usable' : decision === 'rejected' ? 'rejected' : 'candidate';
|
|
397
566
|
if (nextState !== lesson.state && !canTransition(lesson.state, nextState)) {
|
|
398
|
-
throw new KnowledgeError('invalid', '不允许的审阅迁移:' + lesson.state + ' → ' + nextState);
|
|
567
|
+
throw new KnowledgeError('invalid', '不允许的审阅迁移:' + lesson.state + ' → ' + nextState, { lessonId: id });
|
|
399
568
|
}
|
|
400
569
|
const now = new Date().toISOString();
|
|
401
570
|
const merged = validateLesson({
|
|
@@ -410,7 +579,7 @@ export class KnowledgeStore {
|
|
|
410
579
|
const payload = { decision, state: nextState };
|
|
411
580
|
if (maskedActor !== undefined)
|
|
412
581
|
payload.actor = maskedActor;
|
|
413
|
-
|
|
582
|
+
const event = {
|
|
414
583
|
schemaVersion: 1,
|
|
415
584
|
id: 'evt_' + randomUUID(),
|
|
416
585
|
at: now,
|
|
@@ -418,12 +587,15 @@ export class KnowledgeStore {
|
|
|
418
587
|
lessonId: lesson.id,
|
|
419
588
|
revision: merged.revision,
|
|
420
589
|
idempotencyKey,
|
|
590
|
+
requestHash,
|
|
421
591
|
payload,
|
|
422
|
-
}
|
|
592
|
+
};
|
|
593
|
+
this.appendEvent(state, event);
|
|
423
594
|
state.lessons.set(merged.id, merged);
|
|
424
|
-
if (!state.idempotency.has(idempotencyKey))
|
|
425
|
-
state.idempotency.set(idempotencyKey, merged);
|
|
426
|
-
|
|
595
|
+
if (!state.idempotency.has(idempotencyKey)) {
|
|
596
|
+
state.idempotency.set(idempotencyKey, { lessonId: merged.id, requestHash, lesson: merged });
|
|
597
|
+
}
|
|
598
|
+
this.writeIndex(state, touch);
|
|
427
599
|
return clone(merged);
|
|
428
600
|
});
|
|
429
601
|
}
|
|
@@ -432,31 +604,41 @@ export class KnowledgeStore {
|
|
|
432
604
|
assertIdempotencyKey(idempotencyKey);
|
|
433
605
|
if (!isEnum(to, LESSON_STATES))
|
|
434
606
|
throw new KnowledgeError('invalid', '目标状态取值不合法');
|
|
435
|
-
|
|
436
|
-
|
|
607
|
+
const requestHash = this.hashRequest({ operation: 'lesson.transition', lessonId: id, to });
|
|
608
|
+
return this.withLock((touch) => {
|
|
609
|
+
const state = this.loadCompleteState(touch);
|
|
437
610
|
const cached = state.idempotency.get(idempotencyKey);
|
|
438
|
-
if (cached !== undefined)
|
|
439
|
-
|
|
611
|
+
if (cached !== undefined) {
|
|
612
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
613
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
614
|
+
if (lesson === undefined)
|
|
615
|
+
throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
|
|
616
|
+
return lesson;
|
|
617
|
+
}
|
|
440
618
|
const lesson = state.lessons.get(id);
|
|
441
619
|
if (lesson === undefined)
|
|
442
|
-
throw new KnowledgeError('invalid', '经验不存在:' + id);
|
|
620
|
+
throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
|
|
443
621
|
if (lesson.revision !== expectedRevision) {
|
|
444
|
-
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision
|
|
622
|
+
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
|
|
623
|
+
lessonId: id,
|
|
624
|
+
expectedRevision,
|
|
625
|
+
currentRevision: lesson.revision,
|
|
626
|
+
});
|
|
445
627
|
}
|
|
446
628
|
if (!canTransition(lesson.state, to)) {
|
|
447
|
-
throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + to);
|
|
629
|
+
throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + to, { lessonId: id });
|
|
448
630
|
}
|
|
449
|
-
return clone(this.appendLessonUpdate(state, lesson, { state: to }, idempotencyKey));
|
|
631
|
+
return clone(this.appendLessonUpdate(state, lesson, { state: to }, idempotencyKey, requestHash, touch));
|
|
450
632
|
});
|
|
451
633
|
}
|
|
452
|
-
/**
|
|
634
|
+
/** 从事件完整重建派生索引与 checkpoint;不删除、不改写 events.jsonl / evidence.jsonl。 */
|
|
453
635
|
rebuildIndex() {
|
|
454
|
-
this.withLock(() => {
|
|
455
|
-
const state = this.
|
|
456
|
-
this.writeIndexStrict(state);
|
|
636
|
+
this.withLock((touch) => {
|
|
637
|
+
const state = this.loadAllUnbounded(touch);
|
|
638
|
+
this.writeIndexStrict(state, touch);
|
|
457
639
|
});
|
|
458
640
|
}
|
|
459
|
-
/** 统计。truncated=true
|
|
641
|
+
/** 统计。truncated=true 表示事件总量超过有界回放上限(不静默);快照模式下实体已折叠进 checkpoint。 */
|
|
460
642
|
stats() {
|
|
461
643
|
const state = this.loadAll();
|
|
462
644
|
const byState = emptyStateCounts();
|
|
@@ -467,9 +649,67 @@ export class KnowledgeStore {
|
|
|
467
649
|
evidence: state.evidence.size,
|
|
468
650
|
events: state.events,
|
|
469
651
|
byState,
|
|
470
|
-
truncated: state.
|
|
652
|
+
truncated: state.events > this.maxReplayEvents,
|
|
471
653
|
badLines: state.badLines,
|
|
654
|
+
orphanEvents: state.orphanEvents,
|
|
655
|
+
unsupportedVersions: state.unsupportedVersions,
|
|
656
|
+
firstReplayedEventId: state.firstReplayedEventId,
|
|
657
|
+
replayMode: state.replayMode,
|
|
658
|
+
replayedEvents: state.replayedEvents,
|
|
659
|
+
skippedEvents: Math.max(0, state.events - state.replayedEvents),
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/** 只读诊断:锁、文件、计数与最近一次派生索引写失败;绝不获取锁、绝不写盘。 */
|
|
663
|
+
diagnose() {
|
|
664
|
+
const info = readLockInfo(this.lockPath, this.lockStaleMs);
|
|
665
|
+
const state = this.loadAll();
|
|
666
|
+
const byState = emptyStateCounts();
|
|
667
|
+
for (const lesson of state.lessons.values())
|
|
668
|
+
byState[lesson.state]++;
|
|
669
|
+
const lock = {
|
|
670
|
+
present: info.present,
|
|
671
|
+
stale: info.stale,
|
|
672
|
+
};
|
|
673
|
+
if (info.ageMs !== undefined)
|
|
674
|
+
lock.ageMs = Math.round(info.ageMs);
|
|
675
|
+
if (info.pid !== undefined)
|
|
676
|
+
lock.pid = info.pid;
|
|
677
|
+
if (info.alive !== undefined)
|
|
678
|
+
lock.alive = info.alive;
|
|
679
|
+
if (info.bootId !== undefined)
|
|
680
|
+
lock.bootId = info.bootId;
|
|
681
|
+
let generatedAt;
|
|
682
|
+
try {
|
|
683
|
+
const raw = existsSync(this.indexPath) ? JSON.parse(readFileSync(this.indexPath, 'utf8')) : undefined;
|
|
684
|
+
if (isRecord(raw) && typeof raw.generatedAt === 'string')
|
|
685
|
+
generatedAt = raw.generatedAt;
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
// 派生索引损坏不影响 diagnose
|
|
689
|
+
}
|
|
690
|
+
const result = {
|
|
691
|
+
writable: !info.present || info.stale,
|
|
692
|
+
lock,
|
|
693
|
+
files: {
|
|
694
|
+
dir: this.dir,
|
|
695
|
+
events: this.fileInfo(this.eventsPath),
|
|
696
|
+
evidence: this.fileInfo(this.evidencePath),
|
|
697
|
+
index: generatedAt === undefined ? this.fileInfo(this.indexPath) : { ...this.fileInfo(this.indexPath), generatedAt },
|
|
698
|
+
},
|
|
699
|
+
counts: {
|
|
700
|
+
lessons: state.lessons.size,
|
|
701
|
+
evidence: state.evidence.size,
|
|
702
|
+
events: state.events,
|
|
703
|
+
badLines: state.badLines,
|
|
704
|
+
orphanEvents: state.orphanEvents,
|
|
705
|
+
unsupportedVersions: state.unsupportedVersions,
|
|
706
|
+
truncated: state.events > this.maxReplayEvents,
|
|
707
|
+
byState,
|
|
708
|
+
},
|
|
472
709
|
};
|
|
710
|
+
if (this.lastIndexError !== undefined)
|
|
711
|
+
result.lastIndexError = this.lastIndexError;
|
|
712
|
+
return result;
|
|
473
713
|
}
|
|
474
714
|
// ---------- 内部实现 ----------
|
|
475
715
|
ensureDir() {
|
|
@@ -478,41 +718,118 @@ export class KnowledgeStore {
|
|
|
478
718
|
}
|
|
479
719
|
withLock(operation) {
|
|
480
720
|
this.ensureDir();
|
|
481
|
-
const
|
|
721
|
+
const handle = acquireLock(this.lockPath, {
|
|
722
|
+
timeoutMs: this.lockTimeoutMs,
|
|
723
|
+
staleMs: this.lockStaleMs,
|
|
724
|
+
hardLimitMs: this.lockHardLimitMs,
|
|
725
|
+
heartbeatMs: this.lockHeartbeatMs,
|
|
726
|
+
});
|
|
482
727
|
try {
|
|
483
|
-
return operation();
|
|
728
|
+
return operation(handle.touch);
|
|
484
729
|
}
|
|
485
730
|
finally {
|
|
486
|
-
release();
|
|
731
|
+
handle.release();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
fileInfo(path) {
|
|
735
|
+
let bytes = 0;
|
|
736
|
+
let exists = false;
|
|
737
|
+
try {
|
|
738
|
+
const stat = statSync(path);
|
|
739
|
+
exists = true;
|
|
740
|
+
bytes = stat.size;
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
exists = false;
|
|
744
|
+
}
|
|
745
|
+
return { path, exists, bytes };
|
|
746
|
+
}
|
|
747
|
+
hashRequest(value) {
|
|
748
|
+
return createHash('sha256').update(stableStringify(value), 'utf8').digest('hex');
|
|
749
|
+
}
|
|
750
|
+
hashablePatch(patch) {
|
|
751
|
+
const out = {};
|
|
752
|
+
for (const key of Object.keys(patch)) {
|
|
753
|
+
if (key === 'id' || key === 'schemaVersion' || key === 'revision' || key === 'createdAt' || key === 'updatedAt')
|
|
754
|
+
continue;
|
|
755
|
+
const value = patch[key];
|
|
756
|
+
if (value !== undefined)
|
|
757
|
+
out[key] = value;
|
|
758
|
+
}
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
cleanPatch(patch) {
|
|
762
|
+
const clean = {};
|
|
763
|
+
const hashable = this.hashablePatch(patch);
|
|
764
|
+
for (const key of Object.keys(hashable)) {
|
|
765
|
+
;
|
|
766
|
+
clean[key] = hashable[key];
|
|
767
|
+
}
|
|
768
|
+
return clean;
|
|
769
|
+
}
|
|
770
|
+
assertSameRequest(entry, requestHash, idempotencyKey) {
|
|
771
|
+
if (entry.requestHash !== undefined && entry.requestHash !== requestHash) {
|
|
772
|
+
throw new KnowledgeError('duplicate', '幂等键已用于不同的请求 payload:' + idempotencyKey, {
|
|
773
|
+
idempotencyKey,
|
|
774
|
+
existingRequestHash: entry.requestHash,
|
|
775
|
+
requestHash,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* 恢复"首次结果":
|
|
781
|
+
* - 尾部回放产生的 entry 自带快照,直接返回;
|
|
782
|
+
* - checkpoint 里的 entry 只有 lessonId/requestHash,则做一次有针对性的完整扫描,
|
|
783
|
+
* 在命中该幂等键的那一刻取快照;失败再退回当前实体(best effort)。
|
|
784
|
+
*/
|
|
785
|
+
resolveIdempotentLesson(state, idempotencyKey) {
|
|
786
|
+
const entry = state.idempotency.get(idempotencyKey);
|
|
787
|
+
if (entry === undefined)
|
|
788
|
+
return undefined;
|
|
789
|
+
if (entry.lesson !== undefined)
|
|
790
|
+
return clone(entry.lesson);
|
|
791
|
+
const exact = this.replayUntilIdempotencyKey(idempotencyKey);
|
|
792
|
+
if (exact !== undefined)
|
|
793
|
+
return clone(exact);
|
|
794
|
+
const current = state.lessons.get(entry.lessonId);
|
|
795
|
+
return current === undefined ? undefined : clone(current);
|
|
796
|
+
}
|
|
797
|
+
replayUntilIdempotencyKey(idempotencyKey) {
|
|
798
|
+
const state = createEmptyReplayState();
|
|
799
|
+
let found;
|
|
800
|
+
try {
|
|
801
|
+
this.forEachLine(this.eventsPath, 0, (line) => {
|
|
802
|
+
state.events++;
|
|
803
|
+
this.applyEventLine(state, line);
|
|
804
|
+
const hit = state.idempotency.get(idempotencyKey);
|
|
805
|
+
if (hit !== undefined && hit.lesson !== undefined) {
|
|
806
|
+
found = hit.lesson;
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
return true;
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
return undefined;
|
|
487
814
|
}
|
|
815
|
+
return found;
|
|
488
816
|
}
|
|
489
|
-
/**
|
|
490
|
-
|
|
817
|
+
/** 顺序读取 JSONL 的非空行;每 TOUCH_EVERY_LINES 行调用一次 touch(长回放锁心跳)。 */
|
|
818
|
+
forEachLine(file, fromOffset, onLine, touch) {
|
|
491
819
|
if (!existsSync(file))
|
|
492
|
-
return
|
|
493
|
-
const max = this.maxReplayEvents;
|
|
820
|
+
return;
|
|
494
821
|
const fd = openSync(file, 'r');
|
|
495
822
|
try {
|
|
496
823
|
const decoder = new StringDecoder('utf8');
|
|
497
824
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
498
|
-
const ring = [];
|
|
499
|
-
let ringStart = 0;
|
|
500
|
-
let total = 0;
|
|
501
825
|
let carry = '';
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (ring.length < max) {
|
|
505
|
-
ring.push(line);
|
|
506
|
-
}
|
|
507
|
-
else {
|
|
508
|
-
ring[ringStart] = line;
|
|
509
|
-
ringStart = (ringStart + 1) % max;
|
|
510
|
-
}
|
|
511
|
-
};
|
|
826
|
+
let position = fromOffset;
|
|
827
|
+
let count = 0;
|
|
512
828
|
for (;;) {
|
|
513
|
-
const read = readSync(fd, buffer, 0, buffer.length,
|
|
829
|
+
const read = readSync(fd, buffer, 0, buffer.length, position);
|
|
514
830
|
if (read <= 0)
|
|
515
831
|
break;
|
|
832
|
+
position += read;
|
|
516
833
|
carry += decoder.write(buffer.subarray(0, read));
|
|
517
834
|
let index = carry.indexOf('\n');
|
|
518
835
|
while (index >= 0) {
|
|
@@ -520,49 +837,135 @@ export class KnowledgeStore {
|
|
|
520
837
|
carry = carry.slice(index + 1);
|
|
521
838
|
if (line.endsWith('\r'))
|
|
522
839
|
line = line.slice(0, -1);
|
|
523
|
-
if (line.trim() !== '')
|
|
524
|
-
|
|
840
|
+
if (line.trim() !== '') {
|
|
841
|
+
count++;
|
|
842
|
+
if (count % TOUCH_EVERY_LINES === 0)
|
|
843
|
+
touch?.();
|
|
844
|
+
if (onLine(line) === false)
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
525
847
|
index = carry.indexOf('\n');
|
|
526
848
|
}
|
|
527
849
|
}
|
|
528
850
|
carry += decoder.end();
|
|
529
851
|
if (carry.trim() !== '')
|
|
530
|
-
|
|
531
|
-
const lines = ringStart === 0 ? ring : ring.slice(ringStart).concat(ring.slice(0, ringStart));
|
|
532
|
-
return { lines, total, truncated: total > max };
|
|
852
|
+
onLine(carry.endsWith('\r') ? carry.slice(0, -1) : carry);
|
|
533
853
|
}
|
|
534
854
|
finally {
|
|
535
855
|
closeSync(fd);
|
|
536
856
|
}
|
|
537
857
|
}
|
|
538
|
-
/**
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
858
|
+
/** 有界读取:只保留最后 maxReplayEvents 条非空行,并报告总行数与是否截断。 */
|
|
859
|
+
readBoundedLines(file, fromOffset, touch) {
|
|
860
|
+
const max = this.maxReplayEvents;
|
|
861
|
+
const ring = [];
|
|
862
|
+
let ringStart = 0;
|
|
863
|
+
let total = 0;
|
|
864
|
+
this.forEachLine(file, fromOffset, (line) => {
|
|
865
|
+
total++;
|
|
866
|
+
if (ring.length < max) {
|
|
867
|
+
ring.push(line);
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
ring[ringStart] = line;
|
|
871
|
+
ringStart = (ringStart + 1) % max;
|
|
872
|
+
}
|
|
873
|
+
}, touch);
|
|
874
|
+
const lines = ringStart === 0 ? ring : ring.slice(ringStart).concat(ring.slice(0, ringStart));
|
|
875
|
+
return { lines, total, truncated: total > max };
|
|
876
|
+
}
|
|
877
|
+
/** 快照 + 尾部增量(checkpoint 有效时);否则有界全量回放。 */
|
|
878
|
+
loadEvents(touch) {
|
|
879
|
+
const snapshot = this.tryLoadCheckpoint();
|
|
880
|
+
if (snapshot !== undefined) {
|
|
881
|
+
const state = createEmptyReplayState();
|
|
882
|
+
state.replayMode = 'snapshot+tail';
|
|
883
|
+
state.entitiesFromSnapshot = true;
|
|
884
|
+
state.lessons = snapshot.lessons;
|
|
885
|
+
state.idempotency = snapshot.idempotency;
|
|
886
|
+
state.events = snapshot.checkpoint.eventsTotal;
|
|
887
|
+
state.badLines = snapshot.checkpoint.counters.badLines;
|
|
888
|
+
state.orphanEvents = snapshot.checkpoint.counters.orphanEvents;
|
|
889
|
+
state.unsupportedVersions = snapshot.checkpoint.counters.unsupportedVersions;
|
|
890
|
+
state.snapshotEvidenceCount = snapshot.checkpoint.counters.evidenceCount;
|
|
891
|
+
state.idempotencyComplete = snapshot.checkpoint.idempotencyComplete === true;
|
|
892
|
+
state.lastEventId = snapshot.checkpoint.lastEventId;
|
|
893
|
+
const tail = this.readBoundedLines(this.eventsPath, snapshot.checkpoint.offset, touch);
|
|
894
|
+
if (tail.truncated)
|
|
895
|
+
return this.loadEventsBounded(0, touch);
|
|
896
|
+
state.events += tail.total;
|
|
897
|
+
state.replayedEvents = tail.total;
|
|
898
|
+
for (const line of tail.lines)
|
|
899
|
+
this.applyEventLine(state, line);
|
|
900
|
+
return state;
|
|
901
|
+
}
|
|
902
|
+
return this.loadEventsBounded(0, touch);
|
|
903
|
+
}
|
|
904
|
+
loadEventsBounded(fromOffset, touch) {
|
|
905
|
+
const state = createEmptyReplayState();
|
|
906
|
+
state.replayMode = 'full';
|
|
907
|
+
const bounded = this.readBoundedLines(this.eventsPath, fromOffset, touch);
|
|
908
|
+
state.events = bounded.total;
|
|
909
|
+
state.replayedEvents = bounded.lines.length;
|
|
910
|
+
state.idempotencyComplete = !bounded.truncated;
|
|
549
911
|
for (const line of bounded.lines)
|
|
550
912
|
this.applyEventLine(state, line);
|
|
551
913
|
return state;
|
|
552
914
|
}
|
|
915
|
+
/** 无界完整回放(显式压缩 / 写路径发现截断或状态不完整时使用)。 */
|
|
916
|
+
loadAllUnbounded(touch) {
|
|
917
|
+
const state = createEmptyReplayState();
|
|
918
|
+
state.replayMode = 'full';
|
|
919
|
+
state.idempotencyComplete = true;
|
|
920
|
+
this.forEachLine(this.eventsPath, 0, (line) => {
|
|
921
|
+
state.events++;
|
|
922
|
+
this.applyEventLine(state, line);
|
|
923
|
+
}, touch);
|
|
924
|
+
state.replayedEvents = state.events;
|
|
925
|
+
this.mergeEvidenceFile(state, touch);
|
|
926
|
+
return state;
|
|
927
|
+
}
|
|
553
928
|
/** 事件流 + evidence.jsonl(两条路径都留痕,按 id 合并去重)。 */
|
|
554
|
-
loadAll() {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
929
|
+
loadAll(touch) {
|
|
930
|
+
let state = this.mergeEvidenceFile(this.loadEvents(touch), touch);
|
|
931
|
+
if (state.entitiesFromSnapshot && this.hasMissingEvidenceRefs(state)) {
|
|
932
|
+
state = this.mergeEvidenceFile(this.loadEventsBounded(0, touch), touch);
|
|
933
|
+
}
|
|
934
|
+
return state;
|
|
935
|
+
}
|
|
936
|
+
/** 写路径:状态不完整、证据疑似缺失或截断未折叠时,自动做一次完整回放。 */
|
|
937
|
+
loadCompleteState(touch) {
|
|
938
|
+
let state = this.loadAll(touch);
|
|
939
|
+
const evidenceDeficit = state.entitiesFromSnapshot && state.evidence.size < state.snapshotEvidenceCount;
|
|
940
|
+
const incompleteIdempotency = state.replayMode === 'snapshot+tail' && !state.idempotencyComplete;
|
|
941
|
+
const skippedWithoutSnapshot = !state.entitiesFromSnapshot && state.events - state.replayedEvents > 0;
|
|
942
|
+
if (evidenceDeficit || incompleteIdempotency || skippedWithoutSnapshot) {
|
|
943
|
+
state = this.loadAllUnbounded(touch);
|
|
944
|
+
}
|
|
945
|
+
return state;
|
|
946
|
+
}
|
|
947
|
+
hasMissingEvidenceRefs(state) {
|
|
948
|
+
for (const lesson of state.lessons.values()) {
|
|
949
|
+
for (const id of lesson.evidenceIds) {
|
|
950
|
+
if (!state.evidence.has(id))
|
|
951
|
+
return true;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
mergeEvidenceFile(state, touch) {
|
|
957
|
+
this.forEachLine(this.evidencePath, 0, (line) => {
|
|
559
958
|
let raw;
|
|
560
959
|
try {
|
|
561
960
|
raw = JSON.parse(line);
|
|
562
961
|
}
|
|
563
962
|
catch {
|
|
564
963
|
state.badLines++;
|
|
565
|
-
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (hasUnsupportedSchema(raw)) {
|
|
967
|
+
state.unsupportedVersions++;
|
|
968
|
+
return;
|
|
566
969
|
}
|
|
567
970
|
try {
|
|
568
971
|
const evidence = validateEvidence(raw);
|
|
@@ -572,7 +975,7 @@ export class KnowledgeStore {
|
|
|
572
975
|
catch {
|
|
573
976
|
state.badLines++;
|
|
574
977
|
}
|
|
575
|
-
}
|
|
978
|
+
}, touch);
|
|
576
979
|
return state;
|
|
577
980
|
}
|
|
578
981
|
applyEventLine(state, line) {
|
|
@@ -584,25 +987,40 @@ export class KnowledgeStore {
|
|
|
584
987
|
state.badLines++;
|
|
585
988
|
return;
|
|
586
989
|
}
|
|
990
|
+
if (!isRecord(raw)) {
|
|
991
|
+
state.badLines++;
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (hasUnsupportedSchema(raw)) {
|
|
995
|
+
state.unsupportedVersions++;
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
587
998
|
if (!isEventShape(raw)) {
|
|
588
999
|
state.badLines++;
|
|
589
1000
|
return;
|
|
590
1001
|
}
|
|
591
1002
|
const event = raw;
|
|
1003
|
+
if (state.firstReplayedEventId === undefined)
|
|
1004
|
+
state.firstReplayedEventId = event.id;
|
|
1005
|
+
state.lastEventId = event.id;
|
|
592
1006
|
try {
|
|
593
1007
|
switch (event.kind) {
|
|
594
1008
|
case 'lesson.create': {
|
|
595
1009
|
const lesson = validateLesson(event.payload);
|
|
596
1010
|
const stored = { ...lesson, id: event.lessonId };
|
|
597
1011
|
state.lessons.set(stored.id, stored);
|
|
598
|
-
if (!state.idempotency.has(event.idempotencyKey))
|
|
599
|
-
|
|
1012
|
+
if (!state.idempotency.has(event.idempotencyKey)) {
|
|
1013
|
+
const record = { lessonId: stored.id, lesson: stored };
|
|
1014
|
+
if (event.requestHash !== undefined)
|
|
1015
|
+
record.requestHash = event.requestHash;
|
|
1016
|
+
state.idempotency.set(event.idempotencyKey, record);
|
|
1017
|
+
}
|
|
600
1018
|
return;
|
|
601
1019
|
}
|
|
602
1020
|
case 'lesson.update': {
|
|
603
1021
|
const previous = state.lessons.get(event.lessonId);
|
|
604
1022
|
if (previous === undefined || event.revision <= previous.revision) {
|
|
605
|
-
state.
|
|
1023
|
+
state.orphanEvents++;
|
|
606
1024
|
return;
|
|
607
1025
|
}
|
|
608
1026
|
const merged = validateLesson({
|
|
@@ -613,18 +1031,22 @@ export class KnowledgeStore {
|
|
|
613
1031
|
updatedAt: event.at,
|
|
614
1032
|
});
|
|
615
1033
|
if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
|
|
616
|
-
state.
|
|
1034
|
+
state.orphanEvents++;
|
|
617
1035
|
return;
|
|
618
1036
|
}
|
|
619
1037
|
state.lessons.set(merged.id, merged);
|
|
620
|
-
if (!state.idempotency.has(event.idempotencyKey))
|
|
621
|
-
|
|
1038
|
+
if (!state.idempotency.has(event.idempotencyKey)) {
|
|
1039
|
+
const record = { lessonId: merged.id, lesson: merged };
|
|
1040
|
+
if (event.requestHash !== undefined)
|
|
1041
|
+
record.requestHash = event.requestHash;
|
|
1042
|
+
state.idempotency.set(event.idempotencyKey, record);
|
|
1043
|
+
}
|
|
622
1044
|
return;
|
|
623
1045
|
}
|
|
624
1046
|
case 'lesson.review': {
|
|
625
1047
|
const previous = state.lessons.get(event.lessonId);
|
|
626
1048
|
if (previous === undefined || event.revision <= previous.revision) {
|
|
627
|
-
state.
|
|
1049
|
+
state.orphanEvents++;
|
|
628
1050
|
return;
|
|
629
1051
|
}
|
|
630
1052
|
const review = event.payload.actor === undefined
|
|
@@ -639,12 +1061,16 @@ export class KnowledgeStore {
|
|
|
639
1061
|
updatedAt: event.at,
|
|
640
1062
|
});
|
|
641
1063
|
if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
|
|
642
|
-
state.
|
|
1064
|
+
state.orphanEvents++;
|
|
643
1065
|
return;
|
|
644
1066
|
}
|
|
645
1067
|
state.lessons.set(merged.id, merged);
|
|
646
|
-
if (!state.idempotency.has(event.idempotencyKey))
|
|
647
|
-
|
|
1068
|
+
if (!state.idempotency.has(event.idempotencyKey)) {
|
|
1069
|
+
const record = { lessonId: merged.id, lesson: merged };
|
|
1070
|
+
if (event.requestHash !== undefined)
|
|
1071
|
+
record.requestHash = event.requestHash;
|
|
1072
|
+
state.idempotency.set(event.idempotencyKey, record);
|
|
1073
|
+
}
|
|
648
1074
|
return;
|
|
649
1075
|
}
|
|
650
1076
|
case 'evidence.add': {
|
|
@@ -660,8 +1086,10 @@ export class KnowledgeStore {
|
|
|
660
1086
|
state.badLines++;
|
|
661
1087
|
}
|
|
662
1088
|
}
|
|
663
|
-
appendEvent(event) {
|
|
1089
|
+
appendEvent(state, event) {
|
|
664
1090
|
this.appendLine(this.eventsPath, event);
|
|
1091
|
+
state.events++;
|
|
1092
|
+
state.lastEventId = event.id;
|
|
665
1093
|
}
|
|
666
1094
|
appendLine(file, value) {
|
|
667
1095
|
this.ensureDir();
|
|
@@ -711,22 +1139,23 @@ export class KnowledgeStore {
|
|
|
711
1139
|
raw.sourceHash = input.sourceHash;
|
|
712
1140
|
const evidence = this.maskEvidence(validateEvidence(raw));
|
|
713
1141
|
const natural = evidenceNaturalKey(evidence);
|
|
714
|
-
|
|
715
|
-
this.appendEvent({
|
|
1142
|
+
const event = {
|
|
716
1143
|
schemaVersion: 1,
|
|
717
1144
|
id: 'evt_' + randomUUID(),
|
|
718
1145
|
at: now,
|
|
719
1146
|
kind: 'evidence.add',
|
|
720
1147
|
idempotencyKey: 'evidence:' + (natural === undefined ? evidence.id : natural),
|
|
721
1148
|
payload: evidence,
|
|
722
|
-
}
|
|
1149
|
+
};
|
|
1150
|
+
this.appendLine(this.evidencePath, evidence);
|
|
1151
|
+
this.appendEvent(state, event);
|
|
723
1152
|
state.evidence.set(evidence.id, evidence);
|
|
724
1153
|
return evidence;
|
|
725
1154
|
}
|
|
726
1155
|
/** 在锁内追加 lesson.update 并同步内存与索引。 */
|
|
727
|
-
appendLessonUpdate(state, previous, patch, idempotencyKey) {
|
|
1156
|
+
appendLessonUpdate(state, previous, patch, idempotencyKey, requestHash, touch) {
|
|
728
1157
|
if (patch.state !== undefined && patch.state !== previous.state && !canTransition(previous.state, patch.state)) {
|
|
729
|
-
throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + patch.state);
|
|
1158
|
+
throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + patch.state, { lessonId: previous.id });
|
|
730
1159
|
}
|
|
731
1160
|
const now = new Date().toISOString();
|
|
732
1161
|
const merged = this.maskLesson(validateLesson({
|
|
@@ -743,7 +1172,7 @@ export class KnowledgeStore {
|
|
|
743
1172
|
continue;
|
|
744
1173
|
payload[key] = merged[key];
|
|
745
1174
|
}
|
|
746
|
-
|
|
1175
|
+
const event = {
|
|
747
1176
|
schemaVersion: 1,
|
|
748
1177
|
id: 'evt_' + randomUUID(),
|
|
749
1178
|
at: now,
|
|
@@ -751,12 +1180,15 @@ export class KnowledgeStore {
|
|
|
751
1180
|
lessonId: merged.id,
|
|
752
1181
|
revision: merged.revision,
|
|
753
1182
|
idempotencyKey,
|
|
1183
|
+
requestHash,
|
|
754
1184
|
payload,
|
|
755
|
-
}
|
|
1185
|
+
};
|
|
1186
|
+
this.appendEvent(state, event);
|
|
756
1187
|
state.lessons.set(merged.id, merged);
|
|
757
|
-
if (!state.idempotency.has(idempotencyKey))
|
|
758
|
-
state.idempotency.set(idempotencyKey, merged);
|
|
759
|
-
|
|
1188
|
+
if (!state.idempotency.has(idempotencyKey)) {
|
|
1189
|
+
state.idempotency.set(idempotencyKey, { lessonId: merged.id, requestHash, lesson: merged });
|
|
1190
|
+
}
|
|
1191
|
+
this.writeIndex(state, touch);
|
|
760
1192
|
return merged;
|
|
761
1193
|
}
|
|
762
1194
|
maskLesson(lesson) {
|
|
@@ -775,6 +1207,194 @@ export class KnowledgeStore {
|
|
|
775
1207
|
return evidence;
|
|
776
1208
|
return { ...evidence, summary: maskSecrets(evidence.summary) };
|
|
777
1209
|
}
|
|
1210
|
+
// ---------- checkpoint / index ----------
|
|
1211
|
+
hashPrefix(file, bytes) {
|
|
1212
|
+
if (bytes <= 0)
|
|
1213
|
+
return createHash('sha256').digest('hex');
|
|
1214
|
+
const fd = openSync(file, 'r');
|
|
1215
|
+
try {
|
|
1216
|
+
const buffer = Buffer.allocUnsafe(bytes);
|
|
1217
|
+
let offset = 0;
|
|
1218
|
+
while (offset < bytes) {
|
|
1219
|
+
const read = readSync(fd, buffer, offset, bytes - offset, offset);
|
|
1220
|
+
if (read <= 0)
|
|
1221
|
+
break;
|
|
1222
|
+
offset += read;
|
|
1223
|
+
}
|
|
1224
|
+
return createHash('sha256').update(buffer.subarray(0, offset)).digest('hex');
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
closeSync(fd);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
readLastLineId(file, offset) {
|
|
1231
|
+
if (offset <= 0)
|
|
1232
|
+
return undefined;
|
|
1233
|
+
const start = Math.max(0, offset - LAST_LINE_WINDOW_BYTES);
|
|
1234
|
+
const fd = openSync(file, 'r');
|
|
1235
|
+
try {
|
|
1236
|
+
const buffer = Buffer.allocUnsafe(offset - start);
|
|
1237
|
+
let read = 0;
|
|
1238
|
+
while (read < buffer.length) {
|
|
1239
|
+
const chunk = readSync(fd, buffer, read, buffer.length - read, start + read);
|
|
1240
|
+
if (chunk <= 0)
|
|
1241
|
+
break;
|
|
1242
|
+
read += chunk;
|
|
1243
|
+
}
|
|
1244
|
+
const text = buffer.subarray(0, read).toString('utf8').replace(/\r?\n+$/, '');
|
|
1245
|
+
const index = Math.max(text.lastIndexOf('\n'), text.lastIndexOf('\r'));
|
|
1246
|
+
const line = index >= 0 ? text.slice(index + 1) : text;
|
|
1247
|
+
try {
|
|
1248
|
+
const parsed = JSON.parse(line);
|
|
1249
|
+
return isRecord(parsed) && typeof parsed.id === 'string' ? parsed.id : undefined;
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
return undefined;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
finally {
|
|
1256
|
+
closeSync(fd);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
tryLoadCheckpoint() {
|
|
1260
|
+
if (!existsSync(this.indexPath))
|
|
1261
|
+
return undefined;
|
|
1262
|
+
let raw;
|
|
1263
|
+
try {
|
|
1264
|
+
raw = JSON.parse(readFileSync(this.indexPath, 'utf8'));
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
return undefined;
|
|
1268
|
+
}
|
|
1269
|
+
if (!isRecord(raw) || raw.schemaVersion !== 1)
|
|
1270
|
+
return undefined;
|
|
1271
|
+
const cpRaw = raw.checkpoint;
|
|
1272
|
+
if (!isRecord(cpRaw))
|
|
1273
|
+
return undefined;
|
|
1274
|
+
const offset = cpRaw.offset;
|
|
1275
|
+
const bytes = cpRaw.bytes;
|
|
1276
|
+
const lastEventId = cpRaw.lastEventId;
|
|
1277
|
+
const prefixHash = cpRaw.prefixHash;
|
|
1278
|
+
const prefixBytes = cpRaw.prefixBytes;
|
|
1279
|
+
const eventsTotal = cpRaw.eventsTotal;
|
|
1280
|
+
const countersRaw = cpRaw.counters;
|
|
1281
|
+
if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0 ||
|
|
1282
|
+
typeof bytes !== 'number' || bytes !== offset ||
|
|
1283
|
+
typeof lastEventId !== 'string' ||
|
|
1284
|
+
typeof prefixHash !== 'string' ||
|
|
1285
|
+
typeof prefixBytes !== 'number' || !Number.isInteger(prefixBytes) || prefixBytes < 0 ||
|
|
1286
|
+
typeof eventsTotal !== 'number' || !Number.isInteger(eventsTotal) || eventsTotal < 0 ||
|
|
1287
|
+
!isRecord(countersRaw)) {
|
|
1288
|
+
return undefined;
|
|
1289
|
+
}
|
|
1290
|
+
const counterNumber = (key) => {
|
|
1291
|
+
const value = countersRaw[key];
|
|
1292
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0;
|
|
1293
|
+
};
|
|
1294
|
+
const counters = {
|
|
1295
|
+
badLines: counterNumber('badLines'),
|
|
1296
|
+
orphanEvents: counterNumber('orphanEvents'),
|
|
1297
|
+
unsupportedVersions: counterNumber('unsupportedVersions'),
|
|
1298
|
+
evidenceCount: counterNumber('evidenceCount'),
|
|
1299
|
+
};
|
|
1300
|
+
const lessonsRaw = raw.lessons;
|
|
1301
|
+
if (!isRecord(lessonsRaw))
|
|
1302
|
+
return undefined;
|
|
1303
|
+
const lessons = new Map();
|
|
1304
|
+
for (const [id, value] of Object.entries(lessonsRaw)) {
|
|
1305
|
+
let lesson;
|
|
1306
|
+
try {
|
|
1307
|
+
lesson = validateLesson(value);
|
|
1308
|
+
}
|
|
1309
|
+
catch {
|
|
1310
|
+
return undefined;
|
|
1311
|
+
}
|
|
1312
|
+
if (lesson.id !== id)
|
|
1313
|
+
return undefined;
|
|
1314
|
+
lessons.set(id, lesson);
|
|
1315
|
+
}
|
|
1316
|
+
if (offset === 0 && lessons.size > 0)
|
|
1317
|
+
return undefined;
|
|
1318
|
+
if (existsSync(this.eventsPath)) {
|
|
1319
|
+
const size = statSync(this.eventsPath).size;
|
|
1320
|
+
if (size < offset)
|
|
1321
|
+
return undefined;
|
|
1322
|
+
if (offset > 0) {
|
|
1323
|
+
if (prefixBytes !== Math.min(offset, PREFIX_HASH_BYTES))
|
|
1324
|
+
return undefined;
|
|
1325
|
+
if (this.hashPrefix(this.eventsPath, prefixBytes) !== prefixHash)
|
|
1326
|
+
return undefined;
|
|
1327
|
+
if (this.readLastLineId(this.eventsPath, offset) !== lastEventId)
|
|
1328
|
+
return undefined;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
else if (offset !== 0) {
|
|
1332
|
+
return undefined;
|
|
1333
|
+
}
|
|
1334
|
+
const idempotency = new Map();
|
|
1335
|
+
const idemRaw = raw.checkpoint !== undefined && isRecord(cpRaw.idempotency) ? cpRaw.idempotency : undefined;
|
|
1336
|
+
if (idemRaw !== undefined) {
|
|
1337
|
+
for (const [key, value] of Object.entries(idemRaw)) {
|
|
1338
|
+
if (!isRecord(value) || typeof value.lessonId !== 'string' || value.lessonId === '')
|
|
1339
|
+
return undefined;
|
|
1340
|
+
const record = { lessonId: value.lessonId };
|
|
1341
|
+
if (typeof value.requestHash === 'string')
|
|
1342
|
+
record.requestHash = value.requestHash;
|
|
1343
|
+
idempotency.set(key, record);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
const serialized = {};
|
|
1347
|
+
for (const [key, entry] of idempotency) {
|
|
1348
|
+
serialized[key] = entry.requestHash === undefined
|
|
1349
|
+
? { lessonId: entry.lessonId }
|
|
1350
|
+
: { lessonId: entry.lessonId, requestHash: entry.requestHash };
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
lessons,
|
|
1354
|
+
idempotency,
|
|
1355
|
+
checkpoint: {
|
|
1356
|
+
offset,
|
|
1357
|
+
bytes,
|
|
1358
|
+
lastEventId,
|
|
1359
|
+
prefixHash,
|
|
1360
|
+
prefixBytes,
|
|
1361
|
+
eventsTotal,
|
|
1362
|
+
counters,
|
|
1363
|
+
idempotency: serialized,
|
|
1364
|
+
idempotencyComplete: cpRaw.idempotencyComplete === true,
|
|
1365
|
+
},
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
buildCheckpoint(state) {
|
|
1369
|
+
if (!existsSync(this.eventsPath))
|
|
1370
|
+
return undefined;
|
|
1371
|
+
const size = statSync(this.eventsPath).size;
|
|
1372
|
+
if (size > 0 && (state.lastEventId === undefined || state.lastEventId === ''))
|
|
1373
|
+
return undefined;
|
|
1374
|
+
const prefixBytes = Math.min(size, PREFIX_HASH_BYTES);
|
|
1375
|
+
const idempotency = {};
|
|
1376
|
+
for (const [key, entry] of state.idempotency) {
|
|
1377
|
+
idempotency[key] = entry.requestHash === undefined
|
|
1378
|
+
? { lessonId: entry.lessonId }
|
|
1379
|
+
: { lessonId: entry.lessonId, requestHash: entry.requestHash };
|
|
1380
|
+
}
|
|
1381
|
+
return {
|
|
1382
|
+
offset: size,
|
|
1383
|
+
bytes: size,
|
|
1384
|
+
lastEventId: state.lastEventId === undefined ? '' : state.lastEventId,
|
|
1385
|
+
prefixHash: this.hashPrefix(this.eventsPath, prefixBytes),
|
|
1386
|
+
prefixBytes,
|
|
1387
|
+
eventsTotal: state.events,
|
|
1388
|
+
counters: {
|
|
1389
|
+
badLines: state.badLines,
|
|
1390
|
+
orphanEvents: state.orphanEvents,
|
|
1391
|
+
unsupportedVersions: state.unsupportedVersions,
|
|
1392
|
+
evidenceCount: state.evidence.size,
|
|
1393
|
+
},
|
|
1394
|
+
idempotency,
|
|
1395
|
+
idempotencyComplete: state.idempotencyComplete,
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
778
1398
|
buildIndexPayload(state) {
|
|
779
1399
|
const lessons = {};
|
|
780
1400
|
const stateCounts = emptyStateCounts();
|
|
@@ -782,29 +1402,37 @@ export class KnowledgeStore {
|
|
|
782
1402
|
lessons[lesson.id] = lesson;
|
|
783
1403
|
stateCounts[lesson.state]++;
|
|
784
1404
|
}
|
|
785
|
-
|
|
1405
|
+
const payload = {
|
|
786
1406
|
schemaVersion: 1,
|
|
787
1407
|
generatedAt: new Date().toISOString(),
|
|
788
1408
|
lessons,
|
|
789
1409
|
stateCounts,
|
|
790
1410
|
byState: stateCounts,
|
|
791
1411
|
};
|
|
1412
|
+
const checkpoint = this.buildCheckpoint(state);
|
|
1413
|
+
if (checkpoint !== undefined)
|
|
1414
|
+
payload.checkpoint = checkpoint;
|
|
1415
|
+
return payload;
|
|
792
1416
|
}
|
|
793
|
-
/**
|
|
794
|
-
writeIndex(state) {
|
|
1417
|
+
/** 自动更新派生索引:失败降级(权威事件已落盘),记录 lastIndexError 供 diagnose()。 */
|
|
1418
|
+
writeIndex(state, touch) {
|
|
795
1419
|
try {
|
|
796
|
-
this.writeIndexStrict(state);
|
|
1420
|
+
this.writeIndexStrict(state, touch);
|
|
1421
|
+
this.lastIndexError = undefined;
|
|
797
1422
|
}
|
|
798
|
-
catch {
|
|
799
|
-
|
|
1423
|
+
catch (error) {
|
|
1424
|
+
this.lastIndexError = { message: maskSecrets(errorMessage(error)), at: new Date().toISOString() };
|
|
800
1425
|
}
|
|
801
1426
|
}
|
|
802
|
-
writeIndexStrict(state) {
|
|
1427
|
+
writeIndexStrict(state, touch) {
|
|
803
1428
|
this.ensureDir();
|
|
1429
|
+
touch?.();
|
|
1430
|
+
const payload = this.buildIndexPayload(state);
|
|
1431
|
+
touch?.();
|
|
804
1432
|
const target = this.indexPath;
|
|
805
1433
|
const tmp = target + '.tmp-' + process.pid + '-' + randomUUID();
|
|
806
1434
|
try {
|
|
807
|
-
writeFileSync(tmp, JSON.stringify(
|
|
1435
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
808
1436
|
renameSync(tmp, target);
|
|
809
1437
|
}
|
|
810
1438
|
catch (error) {
|