@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.
@@ -1,26 +1,34 @@
1
1
  /**
2
2
  * dsh-dream 知识存储:events.jsonl 为权威追加日志,evidence.jsonl 追加证据,
3
- * index.json 为可丢弃重建的派生索引,.lock 为短期文件锁。
3
+ * index.json 为可丢弃重建的派生索引(含 checkpoint),.lock 为短期文件锁。
4
4
  *
5
5
  * 设计要点:
6
- * - 幂等:同 idempotencyKey 重复调用返回首次结果且不写新事件。
7
- * - revision 守卫:expectedRevision 不符抛 KnowledgeError('revision'),不写盘。
8
- * - 崩溃安全:单行追加 + 单行 JSON.parse 容错;坏行跳过并计数。
9
- * - 有界回放:events.jsonl 超过 maxReplayEvents 行时只回放最近 N 行,stats().truncated=true。
10
- * - index.json 是派生物:自动更新失败按降级处理(事件仍是权威);rebuildIndex() 会明确重建。
11
- * - 只读方法(listLessons/listEvidence/getLesson/stats)绝不建目录、绝不写盘。
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, appendFileSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
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
- * - 未超过 staleMs:一律视为占用(哪怕 pid 已退出)。
53
- * - 超过 staleMs:JSON 里的 pid 不存活(或内容不可验证所有者)→ 可接管;pid 存活 → 仍是占用。
54
- * 绝不无条件删锁。
55
- */
56
- function lockIsStale(lockPath, staleMs) {
57
- let mtimeMs;
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
- mtimeMs = statSync(lockPath).mtimeMs;
72
+ raw = readFileSync(lockPath, 'utf8');
60
73
  }
61
74
  catch {
62
- return true;
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(readFileSync(lockPath, 'utf8'));
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
- if (Date.now() - at <= staleMs)
84
- return false;
85
- if (pid === undefined)
86
- return true;
87
- return !isProcessAlive(pid);
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
- /** 获取短期文件锁;失败抛 KnowledgeError('io')。 */
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
- return () => releaseLock(lockPath, token);
129
- if (lockIsStale(lockPath, options.staleMs)) {
130
- try {
131
- unlinkSync(lockPath);
132
- }
133
- catch (error) {
134
- const code = error.code;
135
- if (code !== 'ENOENT') {
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', '知识库锁已过期但无法接管:' + errorMessage(error));
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 (Date.now() >= deadline)
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 = typeof options.lockTimeoutMs === 'number' && options.lockTimeoutMs >= 0 ? options.lockTimeoutMs : 2000;
206
- this.lockStaleMs = typeof options.lockStaleMs === 'number' && options.lockStaleMs >= 0 ? options.lockStaleMs : 30000;
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.loadAll();
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
- return this.withLock(() => {
252
- const state = this.loadAll();
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
- return { lesson: clone(cached), created: false };
256
- const scope = {
257
- global: input.global === true,
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 input.evidence)
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
- let merged = existing;
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
- const mergedIds = existing.evidenceIds.concat(newIds);
299
- merged = this.appendLessonUpdate(state, existing, {
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: 'candidate',
474
+ state: initialState,
319
475
  evidenceIds,
320
476
  independentSupportCount: mergeEvidenceSupport({ evidenceIds }, Array.from(state.evidence.values())),
321
- review: { decision: 'unreviewed' },
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
- this.appendEvent({
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
- this.writeIndex(state);
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
- return this.withLock(() => {
349
- const state = this.loadAll();
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
- return clone(cached);
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
- const clean = {};
360
- for (const key of Object.keys(patch)) {
361
- if (key === 'id' || key === 'schemaVersion' || key === 'revision' || key === 'createdAt' || key === 'updatedAt')
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
- return this.withLock(() => {
386
- const state = this.loadEvents();
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
- return clone(cached);
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
- this.appendEvent({
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
- this.writeIndex(state);
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
- return this.withLock(() => {
436
- const state = this.loadEvents();
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
- return clone(cached);
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
- /** 从事件重建派生索引;不删除、不改写 events.jsonl / evidence.jsonl。 */
634
+ /** 从事件完整重建派生索引与 checkpoint;不删除、不改写 events.jsonl / evidence.jsonl。 */
453
635
  rebuildIndex() {
454
- this.withLock(() => {
455
- const state = this.loadAll();
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 表示回放被 MAX_REPLAY_EVENTS 截断(不静默)。 */
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.truncated,
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 release = acquireLock(this.lockPath, { timeoutMs: this.lockTimeoutMs, staleMs: this.lockStaleMs });
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
- /** 有界读取 JSONL:只保留最后 maxReplayEvents 条非空行,并报告总行数与截断。 */
490
- readBoundedLines(file) {
817
+ /** 顺序读取 JSONL 的非空行;每 TOUCH_EVERY_LINES 行调用一次 touch(长回放锁心跳)。 */
818
+ forEachLine(file, fromOffset, onLine, touch) {
491
819
  if (!existsSync(file))
492
- return { lines: [], total: 0, truncated: false };
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
- const push = (line) => {
503
- total++;
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, null);
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
- push(line);
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
- push(carry.endsWith('\r') ? carry.slice(0, -1) : carry);
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
- loadEvents() {
540
- const bounded = this.readBoundedLines(this.eventsPath);
541
- const state = {
542
- lessons: new Map(),
543
- evidence: new Map(),
544
- idempotency: new Map(),
545
- events: bounded.total,
546
- truncated: bounded.truncated,
547
- badLines: 0,
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
- const state = this.loadEvents();
556
- const bounded = this.readBoundedLines(this.evidencePath);
557
- state.truncated = state.truncated || bounded.truncated;
558
- for (const line of bounded.lines) {
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
- continue;
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
- state.idempotency.set(event.idempotencyKey, stored);
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.badLines++;
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.badLines++;
1034
+ state.orphanEvents++;
617
1035
  return;
618
1036
  }
619
1037
  state.lessons.set(merged.id, merged);
620
- if (!state.idempotency.has(event.idempotencyKey))
621
- state.idempotency.set(event.idempotencyKey, merged);
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.badLines++;
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.badLines++;
1064
+ state.orphanEvents++;
643
1065
  return;
644
1066
  }
645
1067
  state.lessons.set(merged.id, merged);
646
- if (!state.idempotency.has(event.idempotencyKey))
647
- state.idempotency.set(event.idempotencyKey, merged);
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
- this.appendLine(this.evidencePath, evidence);
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
- this.appendEvent({
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
- this.writeIndex(state);
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
- return {
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
- // index.json 只是派生加速层,丢失可 rebuildIndex() 重建。
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(this.buildIndexPayload(state), null, 2) + '\n', 'utf8');
1435
+ writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8');
808
1436
  renameSync(tmp, target);
809
1437
  }
810
1438
  catch (error) {