@stardustlc/dsh-dream 0.5.0 → 0.6.0
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 +22 -0
- package/README.en.md +35 -13
- package/README.md +39 -17
- package/lib/bridge.d.ts +205 -2
- package/lib/bridge.js +1053 -6
- package/lib/client.js +564 -27
- package/lib/evidence.d.ts +15 -0
- package/lib/evidence.js +123 -0
- package/lib/journal.d.ts +15 -0
- package/lib/journal.js +32 -11
- package/lib/knowledge-store.d.ts +111 -10
- package/lib/knowledge-store.js +1056 -198
- package/lib/knowledge.d.ts +55 -3
- package/lib/knowledge.js +65 -5
- package/lib/mask.d.ts +3 -1
- package/lib/mask.js +8 -2
- package/lib/retrieval.d.ts +47 -7
- package/lib/retrieval.js +196 -19
- package/lib/tools.js +406 -60
- package/lib/web.d.ts +52 -2
- package/lib/web.js +155 -14
- package/package.json +2 -2
- package/skills/dream-protocol/SKILL.md +15 -6
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,28 +169,52 @@ 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
|
}
|
|
217
|
+
const KNOWN_EVENT_KINDS = new Set(['lesson.create', 'lesson.update', 'lesson.review', 'lesson.batch', 'evidence.add']);
|
|
149
218
|
/** 事件行形状检查(字段值再做 schema 校验)。 */
|
|
150
219
|
function isEventShape(value) {
|
|
151
220
|
if (!isRecord(value) || value.schemaVersion !== 1)
|
|
@@ -166,15 +235,57 @@ function isEventShape(value) {
|
|
|
166
235
|
typeof value.revision === 'number' &&
|
|
167
236
|
Number.isInteger(value.revision) &&
|
|
168
237
|
value.payload !== undefined);
|
|
238
|
+
case 'lesson.batch': {
|
|
239
|
+
if (!isRecord(value.payload) || !Array.isArray(value.payload.updates))
|
|
240
|
+
return false;
|
|
241
|
+
return value.payload.updates.every((item) => isRecord(item) &&
|
|
242
|
+
typeof item.lessonId === 'string' &&
|
|
243
|
+
item.lessonId !== '' &&
|
|
244
|
+
typeof item.revision === 'number' &&
|
|
245
|
+
Number.isInteger(item.revision) &&
|
|
246
|
+
isRecord(item.patch));
|
|
247
|
+
}
|
|
169
248
|
case 'evidence.add':
|
|
170
249
|
return value.payload !== undefined;
|
|
171
250
|
default:
|
|
172
251
|
return false;
|
|
173
252
|
}
|
|
174
253
|
}
|
|
254
|
+
/** 事件 / 证据行是否携带非当前 schemaVersion(R7:独立计 unsupportedVersions)。 */
|
|
255
|
+
function hasUnsupportedSchema(value) {
|
|
256
|
+
if (!isRecord(value))
|
|
257
|
+
return false;
|
|
258
|
+
const version = value.schemaVersion;
|
|
259
|
+
if (typeof version === 'number' && version !== 1)
|
|
260
|
+
return true;
|
|
261
|
+
const payload = value.payload;
|
|
262
|
+
if (isRecord(payload)) {
|
|
263
|
+
const payloadVersion = payload.schemaVersion;
|
|
264
|
+
if (typeof payloadVersion === 'number' && payloadVersion !== 1)
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
175
269
|
function emptyStateCounts() {
|
|
176
270
|
return { candidate: 0, usable: 0, disputed: 0, stale: 0, rejected: 0 };
|
|
177
271
|
}
|
|
272
|
+
function createEmptyReplayState() {
|
|
273
|
+
return {
|
|
274
|
+
lessons: new Map(),
|
|
275
|
+
evidence: new Map(),
|
|
276
|
+
idempotency: new Map(),
|
|
277
|
+
events: 0,
|
|
278
|
+
replayedEvents: 0,
|
|
279
|
+
badLines: 0,
|
|
280
|
+
orphanEvents: 0,
|
|
281
|
+
unsupportedVersions: 0,
|
|
282
|
+
skippedEvents: 0,
|
|
283
|
+
replayMode: 'full',
|
|
284
|
+
idempotencyComplete: true,
|
|
285
|
+
entitiesFromSnapshot: false,
|
|
286
|
+
snapshotEvidenceCount: 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
178
289
|
/**
|
|
179
290
|
* 经验知识存储。无文件时所有读取方法安全返回空,写入方法在首次写入时自建目录。
|
|
180
291
|
*/
|
|
@@ -188,6 +299,9 @@ export class KnowledgeStore {
|
|
|
188
299
|
maxReplayEvents;
|
|
189
300
|
lockTimeoutMs;
|
|
190
301
|
lockStaleMs;
|
|
302
|
+
lockHardLimitMs;
|
|
303
|
+
lockHeartbeatMs;
|
|
304
|
+
lastIndexError;
|
|
191
305
|
constructor(knowledgeDir, options = {}) {
|
|
192
306
|
if (typeof knowledgeDir !== 'string' || knowledgeDir.trim() === '') {
|
|
193
307
|
throw new KnowledgeError('invalid', 'knowledgeDir 必须是非空字符串');
|
|
@@ -195,6 +309,7 @@ export class KnowledgeStore {
|
|
|
195
309
|
const maxReplay = typeof options.maxReplayEvents === 'number' && Number.isFinite(options.maxReplayEvents)
|
|
196
310
|
? Math.max(1, Math.floor(options.maxReplayEvents))
|
|
197
311
|
: MAX_REPLAY_EVENTS;
|
|
312
|
+
const numberOption = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
198
313
|
this.dir = knowledgeDir;
|
|
199
314
|
this.eventsPath = join(knowledgeDir, 'events.jsonl');
|
|
200
315
|
this.evidencePath = join(knowledgeDir, 'evidence.jsonl');
|
|
@@ -202,8 +317,10 @@ export class KnowledgeStore {
|
|
|
202
317
|
this.lockPath = join(knowledgeDir, '.lock');
|
|
203
318
|
this.mask = options.maskSecrets !== false;
|
|
204
319
|
this.maxReplayEvents = maxReplay;
|
|
205
|
-
this.lockTimeoutMs =
|
|
206
|
-
this.lockStaleMs =
|
|
320
|
+
this.lockTimeoutMs = numberOption(options.lockTimeoutMs, 2000);
|
|
321
|
+
this.lockStaleMs = numberOption(options.lockStaleMs, 30000);
|
|
322
|
+
this.lockHardLimitMs = numberOption(options.lockHardLimitMs, 600000);
|
|
323
|
+
this.lockHeartbeatMs = Math.max(10, numberOption(options.lockHeartbeatMs, 5000));
|
|
207
324
|
}
|
|
208
325
|
/** 全部经验,按 updatedAt 降序(同刻按 id 稳定排序)。 */
|
|
209
326
|
listLessons() {
|
|
@@ -229,8 +346,8 @@ export class KnowledgeStore {
|
|
|
229
346
|
appendEvidence(input) {
|
|
230
347
|
if (!isRecord(input))
|
|
231
348
|
throw new KnowledgeError('invalid', '证据输入必须是对象');
|
|
232
|
-
return this.withLock(() => {
|
|
233
|
-
const state = this.
|
|
349
|
+
return this.withLock((touch) => {
|
|
350
|
+
const state = this.loadCompleteState(touch);
|
|
234
351
|
const existing = this.findEvidenceByNaturalKey(state, input);
|
|
235
352
|
if (existing !== undefined)
|
|
236
353
|
return { evidence: clone(existing), created: false };
|
|
@@ -239,23 +356,45 @@ export class KnowledgeStore {
|
|
|
239
356
|
});
|
|
240
357
|
}
|
|
241
358
|
/**
|
|
242
|
-
* 创建候选经验。同 idempotencyKey 幂等;同指纹(文本 + 范围相同)确定性合并,
|
|
359
|
+
* 创建候选经验。同 idempotencyKey + 同 requestHash 幂等;同指纹(文本 + 范围相同)确定性合并,
|
|
243
360
|
* 只把新证据并入已有经验,不新增经验。
|
|
361
|
+
* 经调用方核验的 read 来源可用于项目经验;来源读取不代表用户采纳,review 始终 unreviewed。
|
|
244
362
|
*/
|
|
245
|
-
createLesson(input, idempotencyKey) {
|
|
363
|
+
createLesson(input, idempotencyKey, options = {}) {
|
|
246
364
|
assertIdempotencyKey(idempotencyKey);
|
|
247
365
|
if (!isRecord(input))
|
|
248
366
|
throw new KnowledgeError('invalid', '经验输入必须是对象');
|
|
249
|
-
if (!Array.isArray(input.evidence))
|
|
367
|
+
if (input.evidence !== undefined && !Array.isArray(input.evidence)) {
|
|
250
368
|
throw new KnowledgeError('invalid', 'evidence 必须是数组');
|
|
251
|
-
|
|
252
|
-
|
|
369
|
+
}
|
|
370
|
+
const evidenceInputs = input.evidence === undefined ? [] : input.evidence;
|
|
371
|
+
const requestHash = this.hashRequest({
|
|
372
|
+
operation: 'lesson.create',
|
|
373
|
+
...(options.requireReview === true ? { requireReview: true } : {}),
|
|
374
|
+
kind: input.kind === undefined ? null : input.kind,
|
|
375
|
+
title: input.title === undefined ? null : input.title,
|
|
376
|
+
action: input.action === undefined ? null : input.action,
|
|
377
|
+
when: input.when === undefined ? null : input.when,
|
|
378
|
+
exceptions: input.exceptions === undefined ? null : input.exceptions,
|
|
379
|
+
projectId: input.projectId === undefined ? null : input.projectId,
|
|
380
|
+
workspaceRoot: input.workspaceRoot === undefined ? null : input.workspaceRoot,
|
|
381
|
+
global: input.global === true,
|
|
382
|
+
applicability: input.applicability === undefined ? null : input.applicability,
|
|
383
|
+
evidence: evidenceInputs,
|
|
384
|
+
conflictIds: input.conflictIds === undefined ? null : input.conflictIds,
|
|
385
|
+
});
|
|
386
|
+
return this.withLock((touch) => {
|
|
387
|
+
const state = this.loadCompleteState(touch);
|
|
253
388
|
const cached = state.idempotency.get(idempotencyKey);
|
|
254
|
-
if (cached !== undefined)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
389
|
+
if (cached !== undefined) {
|
|
390
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
391
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
392
|
+
if (lesson === undefined) {
|
|
393
|
+
throw new KnowledgeError('io', '无法恢复幂等键的历史结果(events.jsonl 可能被删改):' + idempotencyKey, { idempotencyKey });
|
|
394
|
+
}
|
|
395
|
+
return { lesson, created: false };
|
|
396
|
+
}
|
|
397
|
+
const scope = { global: input.global === true };
|
|
259
398
|
if (typeof input.projectId === 'string' && input.projectId.trim() !== '')
|
|
260
399
|
scope.projectId = input.projectId;
|
|
261
400
|
if (typeof input.workspaceRoot === 'string' && input.workspaceRoot.trim() !== '')
|
|
@@ -289,21 +428,41 @@ export class KnowledgeStore {
|
|
|
289
428
|
});
|
|
290
429
|
const existing = Array.from(state.lessons.values()).find((lesson) => lessonFingerprint(lesson) === fingerprint);
|
|
291
430
|
const appended = [];
|
|
292
|
-
for (const raw of
|
|
431
|
+
for (const raw of evidenceInputs)
|
|
293
432
|
appended.push(this.persistEvidenceLocked(raw, state));
|
|
294
433
|
if (existing !== undefined) {
|
|
295
434
|
const newIds = appended.map((item) => item.id).filter((id) => !existing.evidenceIds.includes(id));
|
|
296
|
-
|
|
435
|
+
const mergedIds = existing.evidenceIds.concat(newIds);
|
|
436
|
+
const mergedEvidence = mergedIds
|
|
437
|
+
.map((id) => state.evidence.get(id))
|
|
438
|
+
.filter((item) => item !== undefined);
|
|
439
|
+
const hasRead = mergedEvidence.some((item) => item.verification === 'read');
|
|
440
|
+
const patch = {};
|
|
297
441
|
if (newIds.length > 0) {
|
|
298
|
-
|
|
299
|
-
merged = this.appendLessonUpdate(state, existing, {
|
|
300
|
-
evidenceIds: mergedIds,
|
|
301
|
-
independentSupportCount: mergeEvidenceSupport({ evidenceIds: mergedIds }, Array.from(state.evidence.values())),
|
|
302
|
-
}, idempotencyKey);
|
|
442
|
+
patch.evidenceIds = mergedIds;
|
|
303
443
|
}
|
|
444
|
+
const supportCount = mergeEvidenceSupport({ evidenceIds: mergedIds }, Array.from(state.evidence.values()));
|
|
445
|
+
if (supportCount !== existing.independentSupportCount)
|
|
446
|
+
patch.independentSupportCount = supportCount;
|
|
447
|
+
// R2′:候选经验遇到 read 证据可升为 usable;disputed/stale/rejected 状态保持不变。
|
|
448
|
+
if (existing.state === 'candidate' && hasRead && !options.requireReview) {
|
|
449
|
+
patch.state = 'usable';
|
|
450
|
+
if (existing.review.decision !== 'unreviewed') {
|
|
451
|
+
patch.review = { decision: 'unreviewed' };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (Object.keys(patch).length === 0)
|
|
455
|
+
return { lesson: clone(existing), created: false };
|
|
456
|
+
const merged = this.appendLessonUpdate(state, existing, patch, idempotencyKey, requestHash, touch);
|
|
304
457
|
return { lesson: clone(merged), created: false };
|
|
305
458
|
}
|
|
306
459
|
const evidenceIds = appended.map((item) => item.id);
|
|
460
|
+
const allEvidence = evidenceIds
|
|
461
|
+
.map((id) => state.evidence.get(id))
|
|
462
|
+
.filter((item) => item !== undefined);
|
|
463
|
+
const hasRead = allEvidence.some((item) => item.verification === 'read');
|
|
464
|
+
const initialState = hasRead && !options.requireReview ? 'usable' : 'candidate';
|
|
465
|
+
const initialReview = { decision: 'unreviewed' };
|
|
307
466
|
const lesson = validateLesson({
|
|
308
467
|
schemaVersion: 1,
|
|
309
468
|
id: 'lsn_' + randomUUID(),
|
|
@@ -315,102 +474,167 @@ export class KnowledgeStore {
|
|
|
315
474
|
exceptions: input.exceptions === undefined ? [] : input.exceptions,
|
|
316
475
|
scope,
|
|
317
476
|
applicability: input.applicability === undefined ? [] : input.applicability,
|
|
318
|
-
state:
|
|
477
|
+
state: initialState,
|
|
319
478
|
evidenceIds,
|
|
320
479
|
independentSupportCount: mergeEvidenceSupport({ evidenceIds }, Array.from(state.evidence.values())),
|
|
321
|
-
review:
|
|
480
|
+
review: initialReview,
|
|
322
481
|
createdAt: now,
|
|
323
482
|
updatedAt: now,
|
|
324
483
|
conflictIds: input.conflictIds === undefined ? [] : input.conflictIds,
|
|
325
484
|
});
|
|
326
485
|
const stored = this.maskLesson(lesson);
|
|
327
|
-
|
|
486
|
+
const event = {
|
|
328
487
|
schemaVersion: 1,
|
|
329
488
|
id: 'evt_' + randomUUID(),
|
|
330
489
|
at: now,
|
|
331
490
|
kind: 'lesson.create',
|
|
332
491
|
lessonId: stored.id,
|
|
333
492
|
idempotencyKey,
|
|
493
|
+
requestHash,
|
|
334
494
|
payload: stored,
|
|
335
|
-
}
|
|
495
|
+
};
|
|
496
|
+
this.appendEvent(state, event);
|
|
336
497
|
state.lessons.set(stored.id, stored);
|
|
337
|
-
if (!state.idempotency.has(idempotencyKey))
|
|
338
|
-
state.idempotency.set(idempotencyKey, stored);
|
|
339
|
-
|
|
498
|
+
if (!state.idempotency.has(idempotencyKey)) {
|
|
499
|
+
state.idempotency.set(idempotencyKey, { lessonId: stored.id, requestHash, lesson: stored });
|
|
500
|
+
}
|
|
501
|
+
this.writeIndex(state, touch);
|
|
340
502
|
return { lesson: clone(stored), created: true };
|
|
341
503
|
});
|
|
342
504
|
}
|
|
343
|
-
/**
|
|
505
|
+
/** 追加证据与更新引用共用一把锁;先检查 revision 与全部输入,不改变审阅状态。 */
|
|
506
|
+
attachEvidence(id, inputs, expectedRevision, idempotencyKey) {
|
|
507
|
+
assertIdempotencyKey(idempotencyKey);
|
|
508
|
+
if (!Array.isArray(inputs) || inputs.length === 0)
|
|
509
|
+
throw new KnowledgeError('invalid', 'attach-evidence 需要非空 evidence 数组');
|
|
510
|
+
const requestHash = this.hashRequest({ operation: 'lesson.attach-evidence', id, inputs });
|
|
511
|
+
return this.withLock((touch) => {
|
|
512
|
+
const state = this.loadCompleteState(touch);
|
|
513
|
+
const cached = state.idempotency.get(idempotencyKey);
|
|
514
|
+
if (cached) {
|
|
515
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
516
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
517
|
+
if (!lesson)
|
|
518
|
+
throw new KnowledgeError('io', '无法恢复追加证据结果');
|
|
519
|
+
return lesson;
|
|
520
|
+
}
|
|
521
|
+
const previous = state.lessons.get(id);
|
|
522
|
+
if (!previous)
|
|
523
|
+
throw new KnowledgeError('invalid', '经验不存在:' + id);
|
|
524
|
+
if (previous.revision !== expectedRevision)
|
|
525
|
+
throw new KnowledgeError('revision', '经验 revision 已变化', { currentRevision: previous.revision });
|
|
526
|
+
for (const item of inputs)
|
|
527
|
+
validateEvidence({ ...item, id: 'evd_probe', schemaVersion: 1, observedAt: item.observedAt ?? new Date().toISOString() });
|
|
528
|
+
const ids = [...previous.evidenceIds];
|
|
529
|
+
for (const item of inputs) {
|
|
530
|
+
const evidence = this.persistEvidenceLocked(item, state);
|
|
531
|
+
if (!ids.includes(evidence.id))
|
|
532
|
+
ids.push(evidence.id);
|
|
533
|
+
}
|
|
534
|
+
const count = mergeEvidenceSupport({ evidenceIds: ids }, [...state.evidence.values()]);
|
|
535
|
+
if (ids.length === previous.evidenceIds.length && count === previous.independentSupportCount)
|
|
536
|
+
return clone(previous);
|
|
537
|
+
return clone(this.appendLessonUpdate(state, previous, { evidenceIds: ids, independentSupportCount: count }, idempotencyKey, requestHash, touch));
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/** 按 patch 更新经验;revision 不符抛 KnowledgeError('revision')(details 带 currentRevision)且不写。 */
|
|
344
541
|
updateLesson(id, patch, expectedRevision, idempotencyKey) {
|
|
345
542
|
assertIdempotencyKey(idempotencyKey);
|
|
346
543
|
if (!isRecord(patch))
|
|
347
544
|
throw new KnowledgeError('invalid', 'patch 必须是对象');
|
|
348
|
-
|
|
349
|
-
|
|
545
|
+
const requestHash = this.hashRequest({ operation: 'lesson.update', lessonId: id, patch: this.hashablePatch(patch) });
|
|
546
|
+
return this.withLock((touch) => {
|
|
547
|
+
const state = this.loadCompleteState(touch);
|
|
350
548
|
const cached = state.idempotency.get(idempotencyKey);
|
|
351
|
-
if (cached !== undefined)
|
|
352
|
-
|
|
549
|
+
if (cached !== undefined) {
|
|
550
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
551
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
552
|
+
if (lesson === undefined)
|
|
553
|
+
throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
|
|
554
|
+
return lesson;
|
|
555
|
+
}
|
|
353
556
|
const lesson = state.lessons.get(id);
|
|
354
557
|
if (lesson === undefined)
|
|
355
|
-
throw new KnowledgeError('invalid', '经验不存在:' + id);
|
|
558
|
+
throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
|
|
356
559
|
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;
|
|
560
|
+
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
|
|
561
|
+
lessonId: id,
|
|
562
|
+
expectedRevision,
|
|
563
|
+
currentRevision: lesson.revision,
|
|
564
|
+
});
|
|
367
565
|
}
|
|
566
|
+
const clean = this.cleanPatch(patch);
|
|
368
567
|
if (clean.state !== undefined && clean.state !== lesson.state && !canTransition(lesson.state, clean.state)) {
|
|
369
|
-
throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + clean.state);
|
|
568
|
+
throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + clean.state, { lessonId: id });
|
|
370
569
|
}
|
|
371
570
|
if (clean.evidenceIds !== undefined) {
|
|
372
|
-
if (!Array.isArray(clean.evidenceIds))
|
|
373
|
-
throw new KnowledgeError('invalid', 'evidenceIds 必须是数组');
|
|
374
571
|
clean.independentSupportCount = mergeEvidenceSupport({ evidenceIds: clean.evidenceIds }, Array.from(state.evidence.values()));
|
|
375
572
|
}
|
|
376
|
-
return clone(this.appendLessonUpdate(state, lesson, clean, idempotencyKey));
|
|
573
|
+
return clone(this.appendLessonUpdate(state, lesson, clean, idempotencyKey, requestHash, touch));
|
|
377
574
|
});
|
|
378
575
|
}
|
|
379
576
|
/** 审阅经验:accepted → usable,rejected → rejected,unreviewed → candidate。 */
|
|
380
|
-
reviewLesson(id, decision, expectedRevision, idempotencyKey, actor) {
|
|
577
|
+
reviewLesson(id, decision, expectedRevision, idempotencyKey, actor, note, resolution) {
|
|
381
578
|
assertIdempotencyKey(idempotencyKey);
|
|
382
579
|
if (!isEnum(decision, REVIEW_DECISIONS))
|
|
383
580
|
throw new KnowledgeError('invalid', 'review decision 取值不合法');
|
|
384
581
|
const maskedActor = actor === undefined ? undefined : this.mask ? maskSecrets(actor) : actor;
|
|
385
|
-
|
|
386
|
-
|
|
582
|
+
const maskedNote = note === undefined ? undefined : this.mask ? maskSecrets(note) : note;
|
|
583
|
+
const requestHash = this.hashRequest({
|
|
584
|
+
operation: 'lesson.review',
|
|
585
|
+
lessonId: id,
|
|
586
|
+
decision,
|
|
587
|
+
actor: maskedActor === undefined ? null : maskedActor,
|
|
588
|
+
note: maskedNote === undefined ? null : maskedNote,
|
|
589
|
+
resolution: resolution === undefined ? null : resolution,
|
|
590
|
+
});
|
|
591
|
+
return this.withLock((touch) => {
|
|
592
|
+
const state = this.loadCompleteState(touch);
|
|
387
593
|
const cached = state.idempotency.get(idempotencyKey);
|
|
388
|
-
if (cached !== undefined)
|
|
389
|
-
|
|
594
|
+
if (cached !== undefined) {
|
|
595
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
596
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
597
|
+
if (lesson === undefined)
|
|
598
|
+
throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
|
|
599
|
+
return lesson;
|
|
600
|
+
}
|
|
390
601
|
const lesson = state.lessons.get(id);
|
|
391
602
|
if (lesson === undefined)
|
|
392
|
-
throw new KnowledgeError('invalid', '经验不存在:' + id);
|
|
603
|
+
throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
|
|
393
604
|
if (lesson.revision !== expectedRevision) {
|
|
394
|
-
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision
|
|
605
|
+
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
|
|
606
|
+
lessonId: id,
|
|
607
|
+
expectedRevision,
|
|
608
|
+
currentRevision: lesson.revision,
|
|
609
|
+
});
|
|
395
610
|
}
|
|
396
611
|
const nextState = decision === 'accepted' ? 'usable' : decision === 'rejected' ? 'rejected' : 'candidate';
|
|
397
612
|
if (nextState !== lesson.state && !canTransition(lesson.state, nextState)) {
|
|
398
|
-
throw new KnowledgeError('invalid', '不允许的审阅迁移:' + lesson.state + ' → ' + nextState);
|
|
613
|
+
throw new KnowledgeError('invalid', '不允许的审阅迁移:' + lesson.state + ' → ' + nextState, { lessonId: id });
|
|
399
614
|
}
|
|
400
615
|
const now = new Date().toISOString();
|
|
616
|
+
const nextReview = { decision, at: now };
|
|
617
|
+
if (maskedActor !== undefined)
|
|
618
|
+
nextReview.actor = maskedActor;
|
|
619
|
+
if (maskedNote !== undefined)
|
|
620
|
+
nextReview.note = maskedNote;
|
|
621
|
+
if (resolution !== undefined)
|
|
622
|
+
nextReview.resolution = resolution;
|
|
401
623
|
const merged = validateLesson({
|
|
402
624
|
...lesson,
|
|
403
625
|
state: nextState,
|
|
404
|
-
review:
|
|
405
|
-
? { decision, at: now }
|
|
406
|
-
: { decision, actor: maskedActor, at: now },
|
|
626
|
+
review: nextReview,
|
|
407
627
|
revision: lesson.revision + 1,
|
|
408
628
|
updatedAt: now,
|
|
409
629
|
});
|
|
410
630
|
const payload = { decision, state: nextState };
|
|
411
631
|
if (maskedActor !== undefined)
|
|
412
632
|
payload.actor = maskedActor;
|
|
413
|
-
|
|
633
|
+
if (maskedNote !== undefined)
|
|
634
|
+
payload.note = maskedNote;
|
|
635
|
+
if (resolution !== undefined)
|
|
636
|
+
payload.resolution = resolution;
|
|
637
|
+
const event = {
|
|
414
638
|
schemaVersion: 1,
|
|
415
639
|
id: 'evt_' + randomUUID(),
|
|
416
640
|
at: now,
|
|
@@ -418,45 +642,149 @@ export class KnowledgeStore {
|
|
|
418
642
|
lessonId: lesson.id,
|
|
419
643
|
revision: merged.revision,
|
|
420
644
|
idempotencyKey,
|
|
645
|
+
requestHash,
|
|
421
646
|
payload,
|
|
422
|
-
}
|
|
647
|
+
};
|
|
648
|
+
this.appendEvent(state, event);
|
|
423
649
|
state.lessons.set(merged.id, merged);
|
|
424
|
-
if (!state.idempotency.has(idempotencyKey))
|
|
425
|
-
state.idempotency.set(idempotencyKey, merged);
|
|
426
|
-
|
|
650
|
+
if (!state.idempotency.has(idempotencyKey)) {
|
|
651
|
+
state.idempotency.set(idempotencyKey, { lessonId: merged.id, requestHash, lesson: merged });
|
|
652
|
+
}
|
|
653
|
+
this.writeIndex(state, touch);
|
|
427
654
|
return clone(merged);
|
|
428
655
|
});
|
|
429
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* 批量原子更新(M2-②):一个事件承载全部变更;先统一校验,
|
|
659
|
+
* 任一条不通过 → 零写入并抛 KnowledgeError(details.failedId)。
|
|
660
|
+
*/
|
|
661
|
+
updateLessonsBatch(updates, idempotencyKey) {
|
|
662
|
+
assertIdempotencyKey(idempotencyKey);
|
|
663
|
+
if (!Array.isArray(updates) || updates.length === 0)
|
|
664
|
+
throw new KnowledgeError('invalid', 'updates 必须是非空数组');
|
|
665
|
+
for (const update of updates) {
|
|
666
|
+
if (!isRecord(update) || typeof update.id !== 'string' || update.id.trim() === '' || !isRecord(update.patch)) {
|
|
667
|
+
throw new KnowledgeError('invalid', 'updates 每项必须包含 id/patch/expectedRevision');
|
|
668
|
+
}
|
|
669
|
+
if (typeof update.expectedRevision !== 'number' || !Number.isInteger(update.expectedRevision)) {
|
|
670
|
+
throw new KnowledgeError('invalid', 'expectedRevision 必须是整数', { failedId: update.id });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const requestHash = this.hashRequest({
|
|
674
|
+
operation: 'lesson.batch',
|
|
675
|
+
updates: updates.map((update) => ({ id: update.id, expectedRevision: update.expectedRevision, patch: this.hashablePatch(update.patch) })),
|
|
676
|
+
});
|
|
677
|
+
return this.withLock((touch) => {
|
|
678
|
+
const state = this.loadCompleteState(touch);
|
|
679
|
+
const cached = state.idempotency.get(idempotencyKey);
|
|
680
|
+
if (cached !== undefined) {
|
|
681
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
682
|
+
const batch = this.resolveIdempotentBatch(state, idempotencyKey);
|
|
683
|
+
if (batch === undefined)
|
|
684
|
+
throw new KnowledgeError('io', '无法恢复批量幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
|
|
685
|
+
return batch;
|
|
686
|
+
}
|
|
687
|
+
const now = new Date().toISOString();
|
|
688
|
+
const planned = [];
|
|
689
|
+
for (const update of updates) {
|
|
690
|
+
const previous = state.lessons.get(update.id);
|
|
691
|
+
if (previous === undefined)
|
|
692
|
+
throw new KnowledgeError('invalid', '经验不存在:' + update.id, { failedId: update.id });
|
|
693
|
+
if (previous.revision !== update.expectedRevision) {
|
|
694
|
+
throw new KnowledgeError('revision', '经验 ' + update.id + ' 期望 revision ' + update.expectedRevision + ',实际 ' + previous.revision, {
|
|
695
|
+
failedId: update.id,
|
|
696
|
+
expectedRevision: update.expectedRevision,
|
|
697
|
+
currentRevision: previous.revision,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
const clean = this.cleanPatch(update.patch);
|
|
701
|
+
if (clean.state !== undefined && clean.state !== previous.state && !canTransition(previous.state, clean.state)) {
|
|
702
|
+
throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + clean.state, { failedId: update.id });
|
|
703
|
+
}
|
|
704
|
+
if (clean.evidenceIds !== undefined) {
|
|
705
|
+
clean.independentSupportCount = mergeEvidenceSupport({ evidenceIds: clean.evidenceIds }, Array.from(state.evidence.values()));
|
|
706
|
+
}
|
|
707
|
+
const merged = this.maskLesson(validateLesson({
|
|
708
|
+
...previous,
|
|
709
|
+
...clean,
|
|
710
|
+
id: previous.id,
|
|
711
|
+
schemaVersion: 1,
|
|
712
|
+
revision: previous.revision + 1,
|
|
713
|
+
updatedAt: now,
|
|
714
|
+
}));
|
|
715
|
+
planned.push({ previous, merged, patch: clean });
|
|
716
|
+
}
|
|
717
|
+
const event = {
|
|
718
|
+
schemaVersion: 1,
|
|
719
|
+
id: 'evt_' + randomUUID(),
|
|
720
|
+
at: now,
|
|
721
|
+
kind: 'lesson.batch',
|
|
722
|
+
idempotencyKey,
|
|
723
|
+
requestHash,
|
|
724
|
+
payload: {
|
|
725
|
+
updates: planned.map(({ previous, merged, patch: clean }) => {
|
|
726
|
+
const patch = {};
|
|
727
|
+
for (const key of Object.keys(clean)) {
|
|
728
|
+
;
|
|
729
|
+
patch[key] = merged[key];
|
|
730
|
+
}
|
|
731
|
+
return { lessonId: previous.id, revision: merged.revision, patch };
|
|
732
|
+
}),
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
this.appendEvent(state, event);
|
|
736
|
+
const applied = [];
|
|
737
|
+
for (const { merged } of planned) {
|
|
738
|
+
state.lessons.set(merged.id, merged);
|
|
739
|
+
applied.push(merged);
|
|
740
|
+
}
|
|
741
|
+
if (!state.idempotency.has(idempotencyKey)) {
|
|
742
|
+
state.idempotency.set(idempotencyKey, { lessonId: applied[0].id, requestHash, batch: applied, lessonIds: applied.map((lesson) => lesson.id) });
|
|
743
|
+
}
|
|
744
|
+
this.writeIndex(state, touch);
|
|
745
|
+
return clone(applied);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
430
748
|
/** 状态迁移;不允许的迁移抛 KnowledgeError('invalid') 且不写。 */
|
|
431
749
|
applyTransition(id, to, expectedRevision, idempotencyKey) {
|
|
432
750
|
assertIdempotencyKey(idempotencyKey);
|
|
433
751
|
if (!isEnum(to, LESSON_STATES))
|
|
434
752
|
throw new KnowledgeError('invalid', '目标状态取值不合法');
|
|
435
|
-
|
|
436
|
-
|
|
753
|
+
const requestHash = this.hashRequest({ operation: 'lesson.transition', lessonId: id, to });
|
|
754
|
+
return this.withLock((touch) => {
|
|
755
|
+
const state = this.loadCompleteState(touch);
|
|
437
756
|
const cached = state.idempotency.get(idempotencyKey);
|
|
438
|
-
if (cached !== undefined)
|
|
439
|
-
|
|
757
|
+
if (cached !== undefined) {
|
|
758
|
+
this.assertSameRequest(cached, requestHash, idempotencyKey);
|
|
759
|
+
const lesson = this.resolveIdempotentLesson(state, idempotencyKey);
|
|
760
|
+
if (lesson === undefined)
|
|
761
|
+
throw new KnowledgeError('io', '无法恢复幂等键的历史结果:' + idempotencyKey, { idempotencyKey });
|
|
762
|
+
return lesson;
|
|
763
|
+
}
|
|
440
764
|
const lesson = state.lessons.get(id);
|
|
441
765
|
if (lesson === undefined)
|
|
442
|
-
throw new KnowledgeError('invalid', '经验不存在:' + id);
|
|
766
|
+
throw new KnowledgeError('invalid', '经验不存在:' + id, { lessonId: id });
|
|
443
767
|
if (lesson.revision !== expectedRevision) {
|
|
444
|
-
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision
|
|
768
|
+
throw new KnowledgeError('revision', '经验 ' + id + ' 期望 revision ' + expectedRevision + ',实际 ' + lesson.revision, {
|
|
769
|
+
lessonId: id,
|
|
770
|
+
expectedRevision,
|
|
771
|
+
currentRevision: lesson.revision,
|
|
772
|
+
});
|
|
445
773
|
}
|
|
446
774
|
if (!canTransition(lesson.state, to)) {
|
|
447
|
-
throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + to);
|
|
775
|
+
throw new KnowledgeError('invalid', '不允许的状态迁移:' + lesson.state + ' → ' + to, { lessonId: id });
|
|
448
776
|
}
|
|
449
|
-
return clone(this.appendLessonUpdate(state, lesson, { state: to }, idempotencyKey));
|
|
777
|
+
return clone(this.appendLessonUpdate(state, lesson, { state: to }, idempotencyKey, requestHash, touch));
|
|
450
778
|
});
|
|
451
779
|
}
|
|
452
|
-
/**
|
|
780
|
+
/** 从事件完整重建派生索引与 checkpoint;不删除、不改写 events.jsonl / evidence.jsonl。 */
|
|
453
781
|
rebuildIndex() {
|
|
454
|
-
this.withLock(() => {
|
|
455
|
-
const state = this.
|
|
456
|
-
this.writeIndexStrict(state);
|
|
782
|
+
this.withLock((touch) => {
|
|
783
|
+
const state = this.loadAllUnbounded(touch);
|
|
784
|
+
this.writeIndexStrict(state, touch);
|
|
457
785
|
});
|
|
458
786
|
}
|
|
459
|
-
/** 统计。truncated=true
|
|
787
|
+
/** 统计。truncated=true 表示事件总量超过有界回放上限(不静默);快照模式下实体已折叠进 checkpoint。 */
|
|
460
788
|
stats() {
|
|
461
789
|
const state = this.loadAll();
|
|
462
790
|
const byState = emptyStateCounts();
|
|
@@ -467,9 +795,69 @@ export class KnowledgeStore {
|
|
|
467
795
|
evidence: state.evidence.size,
|
|
468
796
|
events: state.events,
|
|
469
797
|
byState,
|
|
470
|
-
truncated: state.
|
|
798
|
+
truncated: state.events > this.maxReplayEvents,
|
|
471
799
|
badLines: state.badLines,
|
|
800
|
+
orphanEvents: state.orphanEvents,
|
|
801
|
+
unsupportedVersions: state.unsupportedVersions,
|
|
802
|
+
skippedEvents: state.skippedEvents,
|
|
803
|
+
firstReplayedEventId: state.firstReplayedEventId,
|
|
804
|
+
replayMode: state.replayMode,
|
|
805
|
+
replayedEvents: state.replayedEvents,
|
|
806
|
+
unreplayedEvents: Math.max(0, state.events - state.replayedEvents),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/** 只读诊断:锁、文件、计数与最近一次派生索引写失败;绝不获取锁、绝不写盘。 */
|
|
810
|
+
diagnose() {
|
|
811
|
+
const info = readLockInfo(this.lockPath, this.lockStaleMs);
|
|
812
|
+
const state = this.loadAll();
|
|
813
|
+
const byState = emptyStateCounts();
|
|
814
|
+
for (const lesson of state.lessons.values())
|
|
815
|
+
byState[lesson.state]++;
|
|
816
|
+
const lock = {
|
|
817
|
+
present: info.present,
|
|
818
|
+
stale: info.stale,
|
|
819
|
+
};
|
|
820
|
+
if (info.ageMs !== undefined)
|
|
821
|
+
lock.ageMs = Math.round(info.ageMs);
|
|
822
|
+
if (info.pid !== undefined)
|
|
823
|
+
lock.pid = info.pid;
|
|
824
|
+
if (info.alive !== undefined)
|
|
825
|
+
lock.alive = info.alive;
|
|
826
|
+
if (info.bootId !== undefined)
|
|
827
|
+
lock.bootId = info.bootId;
|
|
828
|
+
let generatedAt;
|
|
829
|
+
try {
|
|
830
|
+
const raw = existsSync(this.indexPath) ? JSON.parse(readFileSync(this.indexPath, 'utf8')) : undefined;
|
|
831
|
+
if (isRecord(raw) && typeof raw.generatedAt === 'string')
|
|
832
|
+
generatedAt = raw.generatedAt;
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
// 派生索引损坏不影响 diagnose
|
|
836
|
+
}
|
|
837
|
+
const result = {
|
|
838
|
+
writable: !info.present || info.stale,
|
|
839
|
+
lock,
|
|
840
|
+
files: {
|
|
841
|
+
dir: this.dir,
|
|
842
|
+
events: this.fileInfo(this.eventsPath),
|
|
843
|
+
evidence: this.fileInfo(this.evidencePath),
|
|
844
|
+
index: generatedAt === undefined ? this.fileInfo(this.indexPath) : { ...this.fileInfo(this.indexPath), generatedAt },
|
|
845
|
+
},
|
|
846
|
+
counts: {
|
|
847
|
+
lessons: state.lessons.size,
|
|
848
|
+
evidence: state.evidence.size,
|
|
849
|
+
events: state.events,
|
|
850
|
+
badLines: state.badLines,
|
|
851
|
+
orphanEvents: state.orphanEvents,
|
|
852
|
+
unsupportedVersions: state.unsupportedVersions,
|
|
853
|
+
skippedEvents: state.skippedEvents,
|
|
854
|
+
truncated: state.events > this.maxReplayEvents,
|
|
855
|
+
byState,
|
|
856
|
+
},
|
|
472
857
|
};
|
|
858
|
+
if (this.lastIndexError !== undefined)
|
|
859
|
+
result.lastIndexError = this.lastIndexError;
|
|
860
|
+
return result;
|
|
473
861
|
}
|
|
474
862
|
// ---------- 内部实现 ----------
|
|
475
863
|
ensureDir() {
|
|
@@ -478,41 +866,136 @@ export class KnowledgeStore {
|
|
|
478
866
|
}
|
|
479
867
|
withLock(operation) {
|
|
480
868
|
this.ensureDir();
|
|
481
|
-
const
|
|
869
|
+
const handle = acquireLock(this.lockPath, {
|
|
870
|
+
timeoutMs: this.lockTimeoutMs,
|
|
871
|
+
staleMs: this.lockStaleMs,
|
|
872
|
+
hardLimitMs: this.lockHardLimitMs,
|
|
873
|
+
heartbeatMs: this.lockHeartbeatMs,
|
|
874
|
+
});
|
|
482
875
|
try {
|
|
483
|
-
return operation();
|
|
876
|
+
return operation(handle.touch);
|
|
484
877
|
}
|
|
485
878
|
finally {
|
|
486
|
-
release();
|
|
879
|
+
handle.release();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
fileInfo(path) {
|
|
883
|
+
let bytes = 0;
|
|
884
|
+
let exists = false;
|
|
885
|
+
try {
|
|
886
|
+
const stat = statSync(path);
|
|
887
|
+
exists = true;
|
|
888
|
+
bytes = stat.size;
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
exists = false;
|
|
892
|
+
}
|
|
893
|
+
return { path, exists, bytes };
|
|
894
|
+
}
|
|
895
|
+
hashRequest(value) {
|
|
896
|
+
return createHash('sha256').update(stableStringify(value), 'utf8').digest('hex');
|
|
897
|
+
}
|
|
898
|
+
hashablePatch(patch) {
|
|
899
|
+
const out = {};
|
|
900
|
+
for (const key of Object.keys(patch)) {
|
|
901
|
+
if (key === 'id' || key === 'schemaVersion' || key === 'revision' || key === 'createdAt' || key === 'updatedAt')
|
|
902
|
+
continue;
|
|
903
|
+
const value = patch[key];
|
|
904
|
+
if (value !== undefined)
|
|
905
|
+
out[key] = value;
|
|
906
|
+
}
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
cleanPatch(patch) {
|
|
910
|
+
const clean = {};
|
|
911
|
+
const hashable = this.hashablePatch(patch);
|
|
912
|
+
for (const key of Object.keys(hashable)) {
|
|
913
|
+
;
|
|
914
|
+
clean[key] = hashable[key];
|
|
915
|
+
}
|
|
916
|
+
return clean;
|
|
917
|
+
}
|
|
918
|
+
assertSameRequest(entry, requestHash, idempotencyKey) {
|
|
919
|
+
if (entry.requestHash !== undefined && entry.requestHash !== requestHash) {
|
|
920
|
+
throw new KnowledgeError('duplicate', '幂等键已用于不同的请求 payload:' + idempotencyKey, {
|
|
921
|
+
idempotencyKey,
|
|
922
|
+
existingRequestHash: entry.requestHash,
|
|
923
|
+
requestHash,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* 恢复"首次结果":
|
|
929
|
+
* - 尾部回放产生的 entry 自带快照,直接返回;
|
|
930
|
+
* - checkpoint 里的 entry 只有 lessonId/requestHash,则做一次有针对性的完整扫描,
|
|
931
|
+
* 在命中该幂等键的那一刻取快照;失败再退回当前实体(best effort)。
|
|
932
|
+
*/
|
|
933
|
+
resolveIdempotentLesson(state, idempotencyKey) {
|
|
934
|
+
const entry = state.idempotency.get(idempotencyKey);
|
|
935
|
+
if (entry === undefined)
|
|
936
|
+
return undefined;
|
|
937
|
+
if (entry.lesson !== undefined)
|
|
938
|
+
return clone(entry.lesson);
|
|
939
|
+
const exact = this.replayUntilIdempotencyKey(idempotencyKey);
|
|
940
|
+
if (exact !== undefined && exact.lesson !== undefined)
|
|
941
|
+
return clone(exact.lesson);
|
|
942
|
+
const current = state.lessons.get(entry.lessonId);
|
|
943
|
+
return current === undefined ? undefined : clone(current);
|
|
944
|
+
}
|
|
945
|
+
resolveIdempotentBatch(state, idempotencyKey) {
|
|
946
|
+
const entry = state.idempotency.get(idempotencyKey);
|
|
947
|
+
if (entry === undefined)
|
|
948
|
+
return undefined;
|
|
949
|
+
if (entry.batch !== undefined)
|
|
950
|
+
return clone(entry.batch);
|
|
951
|
+
const exact = this.replayUntilIdempotencyKey(idempotencyKey);
|
|
952
|
+
if (exact !== undefined && exact.batch !== undefined)
|
|
953
|
+
return clone(exact.batch);
|
|
954
|
+
const ids = entry.lessonIds !== undefined ? entry.lessonIds : [entry.lessonId];
|
|
955
|
+
const out = [];
|
|
956
|
+
for (const id of ids) {
|
|
957
|
+
const lesson = state.lessons.get(id);
|
|
958
|
+
if (lesson !== undefined)
|
|
959
|
+
out.push(clone(lesson));
|
|
960
|
+
}
|
|
961
|
+
return out.length === 0 ? undefined : out;
|
|
962
|
+
}
|
|
963
|
+
replayUntilIdempotencyKey(idempotencyKey) {
|
|
964
|
+
const state = createEmptyReplayState();
|
|
965
|
+
let found;
|
|
966
|
+
try {
|
|
967
|
+
this.forEachLine(this.eventsPath, 0, (line) => {
|
|
968
|
+
state.events++;
|
|
969
|
+
this.applyEventLine(state, line);
|
|
970
|
+
const hit = state.idempotency.get(idempotencyKey);
|
|
971
|
+
if (hit !== undefined && (hit.lesson !== undefined || hit.batch !== undefined)) {
|
|
972
|
+
found = hit;
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
return true;
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
return undefined;
|
|
487
980
|
}
|
|
981
|
+
return found;
|
|
488
982
|
}
|
|
489
|
-
/**
|
|
490
|
-
|
|
983
|
+
/** 顺序读取 JSONL 的非空行;每 TOUCH_EVERY_LINES 行调用一次 touch(长回放锁心跳)。 */
|
|
984
|
+
forEachLine(file, fromOffset, onLine, touch) {
|
|
491
985
|
if (!existsSync(file))
|
|
492
|
-
return
|
|
493
|
-
const max = this.maxReplayEvents;
|
|
986
|
+
return;
|
|
494
987
|
const fd = openSync(file, 'r');
|
|
495
988
|
try {
|
|
496
989
|
const decoder = new StringDecoder('utf8');
|
|
497
990
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
498
|
-
const ring = [];
|
|
499
|
-
let ringStart = 0;
|
|
500
|
-
let total = 0;
|
|
501
991
|
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
|
-
};
|
|
992
|
+
let position = fromOffset;
|
|
993
|
+
let count = 0;
|
|
512
994
|
for (;;) {
|
|
513
|
-
const read = readSync(fd, buffer, 0, buffer.length,
|
|
995
|
+
const read = readSync(fd, buffer, 0, buffer.length, position);
|
|
514
996
|
if (read <= 0)
|
|
515
997
|
break;
|
|
998
|
+
position += read;
|
|
516
999
|
carry += decoder.write(buffer.subarray(0, read));
|
|
517
1000
|
let index = carry.indexOf('\n');
|
|
518
1001
|
while (index >= 0) {
|
|
@@ -520,49 +1003,136 @@ export class KnowledgeStore {
|
|
|
520
1003
|
carry = carry.slice(index + 1);
|
|
521
1004
|
if (line.endsWith('\r'))
|
|
522
1005
|
line = line.slice(0, -1);
|
|
523
|
-
if (line.trim() !== '')
|
|
524
|
-
|
|
1006
|
+
if (line.trim() !== '') {
|
|
1007
|
+
count++;
|
|
1008
|
+
if (count % TOUCH_EVERY_LINES === 0)
|
|
1009
|
+
touch?.();
|
|
1010
|
+
if (onLine(line) === false)
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
525
1013
|
index = carry.indexOf('\n');
|
|
526
1014
|
}
|
|
527
1015
|
}
|
|
528
1016
|
carry += decoder.end();
|
|
529
1017
|
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 };
|
|
1018
|
+
onLine(carry.endsWith('\r') ? carry.slice(0, -1) : carry);
|
|
533
1019
|
}
|
|
534
1020
|
finally {
|
|
535
1021
|
closeSync(fd);
|
|
536
1022
|
}
|
|
537
1023
|
}
|
|
538
|
-
/**
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1024
|
+
/** 有界读取:只保留最后 maxReplayEvents 条非空行,并报告总行数与是否截断。 */
|
|
1025
|
+
readBoundedLines(file, fromOffset, touch) {
|
|
1026
|
+
const max = this.maxReplayEvents;
|
|
1027
|
+
const ring = [];
|
|
1028
|
+
let ringStart = 0;
|
|
1029
|
+
let total = 0;
|
|
1030
|
+
this.forEachLine(file, fromOffset, (line) => {
|
|
1031
|
+
total++;
|
|
1032
|
+
if (ring.length < max) {
|
|
1033
|
+
ring.push(line);
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
ring[ringStart] = line;
|
|
1037
|
+
ringStart = (ringStart + 1) % max;
|
|
1038
|
+
}
|
|
1039
|
+
}, touch);
|
|
1040
|
+
const lines = ringStart === 0 ? ring : ring.slice(ringStart).concat(ring.slice(0, ringStart));
|
|
1041
|
+
return { lines, total, truncated: total > max };
|
|
1042
|
+
}
|
|
1043
|
+
/** 快照 + 尾部增量(checkpoint 有效时);否则有界全量回放。 */
|
|
1044
|
+
loadEvents(touch) {
|
|
1045
|
+
const snapshot = this.tryLoadCheckpoint();
|
|
1046
|
+
if (snapshot !== undefined) {
|
|
1047
|
+
const state = createEmptyReplayState();
|
|
1048
|
+
state.replayMode = 'snapshot+tail';
|
|
1049
|
+
state.entitiesFromSnapshot = true;
|
|
1050
|
+
state.lessons = snapshot.lessons;
|
|
1051
|
+
state.idempotency = snapshot.idempotency;
|
|
1052
|
+
state.events = snapshot.checkpoint.eventsTotal;
|
|
1053
|
+
state.badLines = snapshot.checkpoint.counters.badLines;
|
|
1054
|
+
state.orphanEvents = snapshot.checkpoint.counters.orphanEvents;
|
|
1055
|
+
state.unsupportedVersions = snapshot.checkpoint.counters.unsupportedVersions;
|
|
1056
|
+
state.skippedEvents = snapshot.checkpoint.counters.skippedEvents;
|
|
1057
|
+
state.snapshotEvidenceCount = snapshot.checkpoint.counters.evidenceCount;
|
|
1058
|
+
state.idempotencyComplete = snapshot.checkpoint.idempotencyComplete === true;
|
|
1059
|
+
state.lastEventId = snapshot.checkpoint.lastEventId;
|
|
1060
|
+
const tail = this.readBoundedLines(this.eventsPath, snapshot.checkpoint.offset, touch);
|
|
1061
|
+
if (tail.truncated)
|
|
1062
|
+
return this.loadEventsBounded(0, touch);
|
|
1063
|
+
state.events += tail.total;
|
|
1064
|
+
state.replayedEvents = tail.total;
|
|
1065
|
+
for (const line of tail.lines)
|
|
1066
|
+
this.applyEventLine(state, line);
|
|
1067
|
+
return state;
|
|
1068
|
+
}
|
|
1069
|
+
return this.loadEventsBounded(0, touch);
|
|
1070
|
+
}
|
|
1071
|
+
loadEventsBounded(fromOffset, touch) {
|
|
1072
|
+
const state = createEmptyReplayState();
|
|
1073
|
+
state.replayMode = 'full';
|
|
1074
|
+
const bounded = this.readBoundedLines(this.eventsPath, fromOffset, touch);
|
|
1075
|
+
state.events = bounded.total;
|
|
1076
|
+
state.replayedEvents = bounded.lines.length;
|
|
1077
|
+
state.idempotencyComplete = !bounded.truncated;
|
|
549
1078
|
for (const line of bounded.lines)
|
|
550
1079
|
this.applyEventLine(state, line);
|
|
551
1080
|
return state;
|
|
552
1081
|
}
|
|
1082
|
+
/** 无界完整回放(显式压缩 / 写路径发现截断或状态不完整时使用)。 */
|
|
1083
|
+
loadAllUnbounded(touch) {
|
|
1084
|
+
const state = createEmptyReplayState();
|
|
1085
|
+
state.replayMode = 'full';
|
|
1086
|
+
state.idempotencyComplete = true;
|
|
1087
|
+
this.forEachLine(this.eventsPath, 0, (line) => {
|
|
1088
|
+
state.events++;
|
|
1089
|
+
this.applyEventLine(state, line);
|
|
1090
|
+
}, touch);
|
|
1091
|
+
state.replayedEvents = state.events;
|
|
1092
|
+
this.mergeEvidenceFile(state, touch);
|
|
1093
|
+
return state;
|
|
1094
|
+
}
|
|
553
1095
|
/** 事件流 + evidence.jsonl(两条路径都留痕,按 id 合并去重)。 */
|
|
554
|
-
loadAll() {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
1096
|
+
loadAll(touch) {
|
|
1097
|
+
let state = this.mergeEvidenceFile(this.loadEvents(touch), touch);
|
|
1098
|
+
if (state.entitiesFromSnapshot && this.hasMissingEvidenceRefs(state)) {
|
|
1099
|
+
state = this.mergeEvidenceFile(this.loadEventsBounded(0, touch), touch);
|
|
1100
|
+
}
|
|
1101
|
+
return state;
|
|
1102
|
+
}
|
|
1103
|
+
/** 写路径:状态不完整、证据疑似缺失或截断未折叠时,自动做一次完整回放。 */
|
|
1104
|
+
loadCompleteState(touch) {
|
|
1105
|
+
let state = this.loadAll(touch);
|
|
1106
|
+
const evidenceDeficit = state.entitiesFromSnapshot && state.evidence.size < state.snapshotEvidenceCount;
|
|
1107
|
+
const incompleteIdempotency = state.replayMode === 'snapshot+tail' && !state.idempotencyComplete;
|
|
1108
|
+
const skippedWithoutSnapshot = !state.entitiesFromSnapshot && state.events - state.replayedEvents > 0;
|
|
1109
|
+
if (evidenceDeficit || incompleteIdempotency || skippedWithoutSnapshot) {
|
|
1110
|
+
state = this.loadAllUnbounded(touch);
|
|
1111
|
+
}
|
|
1112
|
+
return state;
|
|
1113
|
+
}
|
|
1114
|
+
hasMissingEvidenceRefs(state) {
|
|
1115
|
+
for (const lesson of state.lessons.values()) {
|
|
1116
|
+
for (const id of lesson.evidenceIds) {
|
|
1117
|
+
if (!state.evidence.has(id))
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
mergeEvidenceFile(state, touch) {
|
|
1124
|
+
this.forEachLine(this.evidencePath, 0, (line) => {
|
|
559
1125
|
let raw;
|
|
560
1126
|
try {
|
|
561
1127
|
raw = JSON.parse(line);
|
|
562
1128
|
}
|
|
563
1129
|
catch {
|
|
564
1130
|
state.badLines++;
|
|
565
|
-
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (hasUnsupportedSchema(raw)) {
|
|
1134
|
+
state.unsupportedVersions++;
|
|
1135
|
+
return;
|
|
566
1136
|
}
|
|
567
1137
|
try {
|
|
568
1138
|
const evidence = validateEvidence(raw);
|
|
@@ -572,7 +1142,7 @@ export class KnowledgeStore {
|
|
|
572
1142
|
catch {
|
|
573
1143
|
state.badLines++;
|
|
574
1144
|
}
|
|
575
|
-
}
|
|
1145
|
+
}, touch);
|
|
576
1146
|
return state;
|
|
577
1147
|
}
|
|
578
1148
|
applyEventLine(state, line) {
|
|
@@ -584,25 +1154,45 @@ export class KnowledgeStore {
|
|
|
584
1154
|
state.badLines++;
|
|
585
1155
|
return;
|
|
586
1156
|
}
|
|
1157
|
+
if (!isRecord(raw)) {
|
|
1158
|
+
state.badLines++;
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (hasUnsupportedSchema(raw)) {
|
|
1162
|
+
state.unsupportedVersions++;
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const kind = raw.kind;
|
|
1166
|
+
if (typeof kind !== 'string' || !KNOWN_EVENT_KINDS.has(kind)) {
|
|
1167
|
+
state.skippedEvents++;
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
587
1170
|
if (!isEventShape(raw)) {
|
|
588
1171
|
state.badLines++;
|
|
589
1172
|
return;
|
|
590
1173
|
}
|
|
591
1174
|
const event = raw;
|
|
1175
|
+
if (state.firstReplayedEventId === undefined)
|
|
1176
|
+
state.firstReplayedEventId = event.id;
|
|
1177
|
+
state.lastEventId = event.id;
|
|
592
1178
|
try {
|
|
593
1179
|
switch (event.kind) {
|
|
594
1180
|
case 'lesson.create': {
|
|
595
1181
|
const lesson = validateLesson(event.payload);
|
|
596
1182
|
const stored = { ...lesson, id: event.lessonId };
|
|
597
1183
|
state.lessons.set(stored.id, stored);
|
|
598
|
-
if (!state.idempotency.has(event.idempotencyKey))
|
|
599
|
-
|
|
1184
|
+
if (!state.idempotency.has(event.idempotencyKey)) {
|
|
1185
|
+
const record = { lessonId: stored.id, lesson: stored };
|
|
1186
|
+
if (event.requestHash !== undefined)
|
|
1187
|
+
record.requestHash = event.requestHash;
|
|
1188
|
+
state.idempotency.set(event.idempotencyKey, record);
|
|
1189
|
+
}
|
|
600
1190
|
return;
|
|
601
1191
|
}
|
|
602
1192
|
case 'lesson.update': {
|
|
603
1193
|
const previous = state.lessons.get(event.lessonId);
|
|
604
1194
|
if (previous === undefined || event.revision <= previous.revision) {
|
|
605
|
-
state.
|
|
1195
|
+
state.orphanEvents++;
|
|
606
1196
|
return;
|
|
607
1197
|
}
|
|
608
1198
|
const merged = validateLesson({
|
|
@@ -613,23 +1203,31 @@ export class KnowledgeStore {
|
|
|
613
1203
|
updatedAt: event.at,
|
|
614
1204
|
});
|
|
615
1205
|
if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
|
|
616
|
-
state.
|
|
1206
|
+
state.orphanEvents++;
|
|
617
1207
|
return;
|
|
618
1208
|
}
|
|
619
1209
|
state.lessons.set(merged.id, merged);
|
|
620
|
-
if (!state.idempotency.has(event.idempotencyKey))
|
|
621
|
-
|
|
1210
|
+
if (!state.idempotency.has(event.idempotencyKey)) {
|
|
1211
|
+
const record = { lessonId: merged.id, lesson: merged };
|
|
1212
|
+
if (event.requestHash !== undefined)
|
|
1213
|
+
record.requestHash = event.requestHash;
|
|
1214
|
+
state.idempotency.set(event.idempotencyKey, record);
|
|
1215
|
+
}
|
|
622
1216
|
return;
|
|
623
1217
|
}
|
|
624
1218
|
case 'lesson.review': {
|
|
625
1219
|
const previous = state.lessons.get(event.lessonId);
|
|
626
1220
|
if (previous === undefined || event.revision <= previous.revision) {
|
|
627
|
-
state.
|
|
1221
|
+
state.orphanEvents++;
|
|
628
1222
|
return;
|
|
629
1223
|
}
|
|
630
|
-
const review = event.payload.
|
|
631
|
-
|
|
632
|
-
|
|
1224
|
+
const review = { decision: event.payload.decision, at: event.at };
|
|
1225
|
+
if (event.payload.actor !== undefined)
|
|
1226
|
+
review.actor = event.payload.actor;
|
|
1227
|
+
if (event.payload.note !== undefined)
|
|
1228
|
+
review.note = event.payload.note;
|
|
1229
|
+
if (event.payload.resolution !== undefined)
|
|
1230
|
+
review.resolution = event.payload.resolution;
|
|
633
1231
|
const merged = validateLesson({
|
|
634
1232
|
...previous,
|
|
635
1233
|
review,
|
|
@@ -639,12 +1237,50 @@ export class KnowledgeStore {
|
|
|
639
1237
|
updatedAt: event.at,
|
|
640
1238
|
});
|
|
641
1239
|
if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
|
|
642
|
-
state.
|
|
1240
|
+
state.orphanEvents++;
|
|
643
1241
|
return;
|
|
644
1242
|
}
|
|
645
1243
|
state.lessons.set(merged.id, merged);
|
|
646
|
-
if (!state.idempotency.has(event.idempotencyKey))
|
|
647
|
-
|
|
1244
|
+
if (!state.idempotency.has(event.idempotencyKey)) {
|
|
1245
|
+
const record = { lessonId: merged.id, lesson: merged };
|
|
1246
|
+
if (event.requestHash !== undefined)
|
|
1247
|
+
record.requestHash = event.requestHash;
|
|
1248
|
+
state.idempotency.set(event.idempotencyKey, record);
|
|
1249
|
+
}
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
case 'lesson.batch': {
|
|
1253
|
+
const applied = [];
|
|
1254
|
+
const payload = event.payload;
|
|
1255
|
+
for (const item of payload.updates) {
|
|
1256
|
+
const previous = state.lessons.get(item.lessonId);
|
|
1257
|
+
if (previous === undefined || item.revision <= previous.revision) {
|
|
1258
|
+
state.orphanEvents++;
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const merged = validateLesson({
|
|
1262
|
+
...previous,
|
|
1263
|
+
...item.patch,
|
|
1264
|
+
id: previous.id,
|
|
1265
|
+
revision: item.revision,
|
|
1266
|
+
updatedAt: event.at,
|
|
1267
|
+
});
|
|
1268
|
+
if (merged.state !== previous.state && !canTransition(previous.state, merged.state)) {
|
|
1269
|
+
state.orphanEvents++;
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
state.lessons.set(merged.id, merged);
|
|
1273
|
+
applied.push(merged);
|
|
1274
|
+
}
|
|
1275
|
+
if (!state.idempotency.has(event.idempotencyKey)) {
|
|
1276
|
+
const first = applied[0];
|
|
1277
|
+
const record = first === undefined
|
|
1278
|
+
? { lessonId: payload.updates[0] === undefined ? '' : payload.updates[0].lessonId }
|
|
1279
|
+
: { lessonId: first.id, batch: applied, lessonIds: applied.map((lesson) => lesson.id) };
|
|
1280
|
+
if (event.requestHash !== undefined)
|
|
1281
|
+
record.requestHash = event.requestHash;
|
|
1282
|
+
state.idempotency.set(event.idempotencyKey, record);
|
|
1283
|
+
}
|
|
648
1284
|
return;
|
|
649
1285
|
}
|
|
650
1286
|
case 'evidence.add': {
|
|
@@ -660,8 +1296,10 @@ export class KnowledgeStore {
|
|
|
660
1296
|
state.badLines++;
|
|
661
1297
|
}
|
|
662
1298
|
}
|
|
663
|
-
appendEvent(event) {
|
|
1299
|
+
appendEvent(state, event) {
|
|
664
1300
|
this.appendLine(this.eventsPath, event);
|
|
1301
|
+
state.events++;
|
|
1302
|
+
state.lastEventId = event.id;
|
|
665
1303
|
}
|
|
666
1304
|
appendLine(file, value) {
|
|
667
1305
|
this.ensureDir();
|
|
@@ -678,6 +1316,8 @@ export class KnowledgeStore {
|
|
|
678
1316
|
if (sessionKey === undefined && hash === undefined)
|
|
679
1317
|
return undefined;
|
|
680
1318
|
for (const item of state.evidence.values()) {
|
|
1319
|
+
if (input.verification === 'claimed' && item.verification === 'read')
|
|
1320
|
+
continue;
|
|
681
1321
|
if (sessionKey !== undefined && evidenceSessionKey(item) === sessionKey)
|
|
682
1322
|
return item;
|
|
683
1323
|
if (hash !== undefined && item.sourceHash === hash)
|
|
@@ -690,12 +1330,12 @@ export class KnowledgeStore {
|
|
|
690
1330
|
if (!isRecord(input))
|
|
691
1331
|
throw new KnowledgeError('invalid', '证据输入必须是对象');
|
|
692
1332
|
const existing = this.findEvidenceByNaturalKey(state, input);
|
|
693
|
-
if (existing !== undefined)
|
|
1333
|
+
if (existing !== undefined && !(existing.verification === 'claimed' && input.verification === 'read'))
|
|
694
1334
|
return existing;
|
|
695
1335
|
const now = new Date().toISOString();
|
|
696
1336
|
const raw = {
|
|
697
1337
|
schemaVersion: 1,
|
|
698
|
-
id: 'evd_' + randomUUID(),
|
|
1338
|
+
id: existing?.id ?? 'evd_' + randomUUID(),
|
|
699
1339
|
kind: input.kind,
|
|
700
1340
|
observedAt: typeof input.observedAt === 'string' ? input.observedAt : now,
|
|
701
1341
|
summary: input.summary,
|
|
@@ -709,24 +1349,27 @@ export class KnowledgeStore {
|
|
|
709
1349
|
raw.projectId = input.projectId;
|
|
710
1350
|
if (input.sourceHash !== undefined)
|
|
711
1351
|
raw.sourceHash = input.sourceHash;
|
|
1352
|
+
if (input.verificationReason !== undefined)
|
|
1353
|
+
raw.verificationReason = input.verificationReason;
|
|
712
1354
|
const evidence = this.maskEvidence(validateEvidence(raw));
|
|
713
1355
|
const natural = evidenceNaturalKey(evidence);
|
|
714
|
-
|
|
715
|
-
this.appendEvent({
|
|
1356
|
+
const event = {
|
|
716
1357
|
schemaVersion: 1,
|
|
717
1358
|
id: 'evt_' + randomUUID(),
|
|
718
1359
|
at: now,
|
|
719
1360
|
kind: 'evidence.add',
|
|
720
1361
|
idempotencyKey: 'evidence:' + (natural === undefined ? evidence.id : natural),
|
|
721
1362
|
payload: evidence,
|
|
722
|
-
}
|
|
1363
|
+
};
|
|
1364
|
+
this.appendLine(this.evidencePath, evidence);
|
|
1365
|
+
this.appendEvent(state, event);
|
|
723
1366
|
state.evidence.set(evidence.id, evidence);
|
|
724
1367
|
return evidence;
|
|
725
1368
|
}
|
|
726
1369
|
/** 在锁内追加 lesson.update 并同步内存与索引。 */
|
|
727
|
-
appendLessonUpdate(state, previous, patch, idempotencyKey) {
|
|
1370
|
+
appendLessonUpdate(state, previous, patch, idempotencyKey, requestHash, touch) {
|
|
728
1371
|
if (patch.state !== undefined && patch.state !== previous.state && !canTransition(previous.state, patch.state)) {
|
|
729
|
-
throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + patch.state);
|
|
1372
|
+
throw new KnowledgeError('invalid', '不允许的状态迁移:' + previous.state + ' → ' + patch.state, { lessonId: previous.id });
|
|
730
1373
|
}
|
|
731
1374
|
const now = new Date().toISOString();
|
|
732
1375
|
const merged = this.maskLesson(validateLesson({
|
|
@@ -743,7 +1386,7 @@ export class KnowledgeStore {
|
|
|
743
1386
|
continue;
|
|
744
1387
|
payload[key] = merged[key];
|
|
745
1388
|
}
|
|
746
|
-
|
|
1389
|
+
const event = {
|
|
747
1390
|
schemaVersion: 1,
|
|
748
1391
|
id: 'evt_' + randomUUID(),
|
|
749
1392
|
at: now,
|
|
@@ -751,30 +1394,237 @@ export class KnowledgeStore {
|
|
|
751
1394
|
lessonId: merged.id,
|
|
752
1395
|
revision: merged.revision,
|
|
753
1396
|
idempotencyKey,
|
|
1397
|
+
requestHash,
|
|
754
1398
|
payload,
|
|
755
|
-
}
|
|
1399
|
+
};
|
|
1400
|
+
this.appendEvent(state, event);
|
|
756
1401
|
state.lessons.set(merged.id, merged);
|
|
757
|
-
if (!state.idempotency.has(idempotencyKey))
|
|
758
|
-
state.idempotency.set(idempotencyKey, merged);
|
|
759
|
-
|
|
1402
|
+
if (!state.idempotency.has(idempotencyKey)) {
|
|
1403
|
+
state.idempotency.set(idempotencyKey, { lessonId: merged.id, requestHash, lesson: merged });
|
|
1404
|
+
}
|
|
1405
|
+
this.writeIndex(state, touch);
|
|
760
1406
|
return merged;
|
|
761
1407
|
}
|
|
762
1408
|
maskLesson(lesson) {
|
|
763
1409
|
if (!this.mask)
|
|
764
1410
|
return lesson;
|
|
765
|
-
|
|
1411
|
+
const masked = {
|
|
766
1412
|
...lesson,
|
|
767
1413
|
title: maskSecrets(lesson.title),
|
|
768
1414
|
action: maskSecrets(lesson.action),
|
|
769
1415
|
when: maskSecrets(lesson.when),
|
|
770
1416
|
exceptions: lesson.exceptions.map((item) => maskSecrets(item)),
|
|
771
1417
|
};
|
|
1418
|
+
if (lesson.review.note !== undefined) {
|
|
1419
|
+
const refs = [lesson.id, ...(lesson.review.resolution?.affectedIds ?? []), ...(lesson.review.resolution?.targetId ? [lesson.review.resolution.targetId] : [])];
|
|
1420
|
+
masked.review = { ...lesson.review, note: maskSecrets(lesson.review.note, refs) };
|
|
1421
|
+
}
|
|
1422
|
+
return masked;
|
|
772
1423
|
}
|
|
773
1424
|
maskEvidence(evidence) {
|
|
774
1425
|
if (!this.mask)
|
|
775
1426
|
return evidence;
|
|
776
1427
|
return { ...evidence, summary: maskSecrets(evidence.summary) };
|
|
777
1428
|
}
|
|
1429
|
+
// ---------- checkpoint / index ----------
|
|
1430
|
+
hashPrefix(file, bytes) {
|
|
1431
|
+
if (bytes <= 0)
|
|
1432
|
+
return createHash('sha256').digest('hex');
|
|
1433
|
+
const fd = openSync(file, 'r');
|
|
1434
|
+
try {
|
|
1435
|
+
const buffer = Buffer.allocUnsafe(bytes);
|
|
1436
|
+
let offset = 0;
|
|
1437
|
+
while (offset < bytes) {
|
|
1438
|
+
const read = readSync(fd, buffer, offset, bytes - offset, offset);
|
|
1439
|
+
if (read <= 0)
|
|
1440
|
+
break;
|
|
1441
|
+
offset += read;
|
|
1442
|
+
}
|
|
1443
|
+
return createHash('sha256').update(buffer.subarray(0, offset)).digest('hex');
|
|
1444
|
+
}
|
|
1445
|
+
finally {
|
|
1446
|
+
closeSync(fd);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
readLastLineId(file, offset) {
|
|
1450
|
+
if (offset <= 0)
|
|
1451
|
+
return undefined;
|
|
1452
|
+
const start = Math.max(0, offset - LAST_LINE_WINDOW_BYTES);
|
|
1453
|
+
const fd = openSync(file, 'r');
|
|
1454
|
+
try {
|
|
1455
|
+
const buffer = Buffer.allocUnsafe(offset - start);
|
|
1456
|
+
let read = 0;
|
|
1457
|
+
while (read < buffer.length) {
|
|
1458
|
+
const chunk = readSync(fd, buffer, read, buffer.length - read, start + read);
|
|
1459
|
+
if (chunk <= 0)
|
|
1460
|
+
break;
|
|
1461
|
+
read += chunk;
|
|
1462
|
+
}
|
|
1463
|
+
const text = buffer.subarray(0, read).toString('utf8').replace(/\r?\n+$/, '');
|
|
1464
|
+
const index = Math.max(text.lastIndexOf('\n'), text.lastIndexOf('\r'));
|
|
1465
|
+
const line = index >= 0 ? text.slice(index + 1) : text;
|
|
1466
|
+
try {
|
|
1467
|
+
const parsed = JSON.parse(line);
|
|
1468
|
+
return isRecord(parsed) && typeof parsed.id === 'string' ? parsed.id : undefined;
|
|
1469
|
+
}
|
|
1470
|
+
catch {
|
|
1471
|
+
return undefined;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
finally {
|
|
1475
|
+
closeSync(fd);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
tryLoadCheckpoint() {
|
|
1479
|
+
if (!existsSync(this.indexPath))
|
|
1480
|
+
return undefined;
|
|
1481
|
+
let raw;
|
|
1482
|
+
try {
|
|
1483
|
+
raw = JSON.parse(readFileSync(this.indexPath, 'utf8'));
|
|
1484
|
+
}
|
|
1485
|
+
catch {
|
|
1486
|
+
return undefined;
|
|
1487
|
+
}
|
|
1488
|
+
if (!isRecord(raw) || raw.schemaVersion !== 1)
|
|
1489
|
+
return undefined;
|
|
1490
|
+
const cpRaw = raw.checkpoint;
|
|
1491
|
+
if (!isRecord(cpRaw))
|
|
1492
|
+
return undefined;
|
|
1493
|
+
const offset = cpRaw.offset;
|
|
1494
|
+
const bytes = cpRaw.bytes;
|
|
1495
|
+
const lastEventId = cpRaw.lastEventId;
|
|
1496
|
+
const prefixHash = cpRaw.prefixHash;
|
|
1497
|
+
const prefixBytes = cpRaw.prefixBytes;
|
|
1498
|
+
const eventsTotal = cpRaw.eventsTotal;
|
|
1499
|
+
const countersRaw = cpRaw.counters;
|
|
1500
|
+
if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0 ||
|
|
1501
|
+
typeof bytes !== 'number' || bytes !== offset ||
|
|
1502
|
+
typeof lastEventId !== 'string' ||
|
|
1503
|
+
typeof prefixHash !== 'string' ||
|
|
1504
|
+
typeof prefixBytes !== 'number' || !Number.isInteger(prefixBytes) || prefixBytes < 0 ||
|
|
1505
|
+
typeof eventsTotal !== 'number' || !Number.isInteger(eventsTotal) || eventsTotal < 0 ||
|
|
1506
|
+
!isRecord(countersRaw)) {
|
|
1507
|
+
return undefined;
|
|
1508
|
+
}
|
|
1509
|
+
const counterNumber = (key) => {
|
|
1510
|
+
const value = countersRaw[key];
|
|
1511
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0;
|
|
1512
|
+
};
|
|
1513
|
+
const counters = {
|
|
1514
|
+
badLines: counterNumber('badLines'),
|
|
1515
|
+
orphanEvents: counterNumber('orphanEvents'),
|
|
1516
|
+
unsupportedVersions: counterNumber('unsupportedVersions'),
|
|
1517
|
+
skippedEvents: counterNumber('skippedEvents'),
|
|
1518
|
+
evidenceCount: counterNumber('evidenceCount'),
|
|
1519
|
+
};
|
|
1520
|
+
const lessonsRaw = raw.lessons;
|
|
1521
|
+
if (!isRecord(lessonsRaw))
|
|
1522
|
+
return undefined;
|
|
1523
|
+
const lessons = new Map();
|
|
1524
|
+
for (const [id, value] of Object.entries(lessonsRaw)) {
|
|
1525
|
+
let lesson;
|
|
1526
|
+
try {
|
|
1527
|
+
lesson = validateLesson(value);
|
|
1528
|
+
}
|
|
1529
|
+
catch {
|
|
1530
|
+
return undefined;
|
|
1531
|
+
}
|
|
1532
|
+
if (lesson.id !== id)
|
|
1533
|
+
return undefined;
|
|
1534
|
+
lessons.set(id, lesson);
|
|
1535
|
+
}
|
|
1536
|
+
if (offset === 0 && lessons.size > 0)
|
|
1537
|
+
return undefined;
|
|
1538
|
+
if (existsSync(this.eventsPath)) {
|
|
1539
|
+
const size = statSync(this.eventsPath).size;
|
|
1540
|
+
if (size < offset)
|
|
1541
|
+
return undefined;
|
|
1542
|
+
if (offset > 0) {
|
|
1543
|
+
if (prefixBytes !== Math.min(offset, PREFIX_HASH_BYTES))
|
|
1544
|
+
return undefined;
|
|
1545
|
+
if (this.hashPrefix(this.eventsPath, prefixBytes) !== prefixHash)
|
|
1546
|
+
return undefined;
|
|
1547
|
+
if (this.readLastLineId(this.eventsPath, offset) !== lastEventId)
|
|
1548
|
+
return undefined;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
else if (offset !== 0) {
|
|
1552
|
+
return undefined;
|
|
1553
|
+
}
|
|
1554
|
+
const idempotency = new Map();
|
|
1555
|
+
const idemRaw = raw.checkpoint !== undefined && isRecord(cpRaw.idempotency) ? cpRaw.idempotency : undefined;
|
|
1556
|
+
if (idemRaw !== undefined) {
|
|
1557
|
+
for (const [key, value] of Object.entries(idemRaw)) {
|
|
1558
|
+
if (!isRecord(value) || typeof value.lessonId !== 'string' || value.lessonId === '')
|
|
1559
|
+
return undefined;
|
|
1560
|
+
const record = { lessonId: value.lessonId };
|
|
1561
|
+
if (typeof value.requestHash === 'string')
|
|
1562
|
+
record.requestHash = value.requestHash;
|
|
1563
|
+
if (Array.isArray(value.lessonIds)) {
|
|
1564
|
+
record.lessonIds = value.lessonIds.filter((id) => typeof id === 'string');
|
|
1565
|
+
}
|
|
1566
|
+
idempotency.set(key, record);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
const serialized = {};
|
|
1570
|
+
for (const [key, entry] of idempotency) {
|
|
1571
|
+
const record = { lessonId: entry.lessonId };
|
|
1572
|
+
if (entry.requestHash !== undefined)
|
|
1573
|
+
record.requestHash = entry.requestHash;
|
|
1574
|
+
if (entry.lessonIds !== undefined)
|
|
1575
|
+
record.lessonIds = entry.lessonIds;
|
|
1576
|
+
serialized[key] = record;
|
|
1577
|
+
}
|
|
1578
|
+
return {
|
|
1579
|
+
lessons,
|
|
1580
|
+
idempotency,
|
|
1581
|
+
checkpoint: {
|
|
1582
|
+
offset,
|
|
1583
|
+
bytes,
|
|
1584
|
+
lastEventId,
|
|
1585
|
+
prefixHash,
|
|
1586
|
+
prefixBytes,
|
|
1587
|
+
eventsTotal,
|
|
1588
|
+
counters,
|
|
1589
|
+
idempotency: serialized,
|
|
1590
|
+
idempotencyComplete: cpRaw.idempotencyComplete === true,
|
|
1591
|
+
},
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
buildCheckpoint(state) {
|
|
1595
|
+
if (!existsSync(this.eventsPath))
|
|
1596
|
+
return undefined;
|
|
1597
|
+
const size = statSync(this.eventsPath).size;
|
|
1598
|
+
if (size > 0 && (state.lastEventId === undefined || state.lastEventId === ''))
|
|
1599
|
+
return undefined;
|
|
1600
|
+
const prefixBytes = Math.min(size, PREFIX_HASH_BYTES);
|
|
1601
|
+
const idempotency = {};
|
|
1602
|
+
for (const [key, entry] of state.idempotency) {
|
|
1603
|
+
const record = { lessonId: entry.lessonId };
|
|
1604
|
+
if (entry.requestHash !== undefined)
|
|
1605
|
+
record.requestHash = entry.requestHash;
|
|
1606
|
+
if (entry.lessonIds !== undefined)
|
|
1607
|
+
record.lessonIds = entry.lessonIds;
|
|
1608
|
+
idempotency[key] = record;
|
|
1609
|
+
}
|
|
1610
|
+
return {
|
|
1611
|
+
offset: size,
|
|
1612
|
+
bytes: size,
|
|
1613
|
+
lastEventId: state.lastEventId === undefined ? '' : state.lastEventId,
|
|
1614
|
+
prefixHash: this.hashPrefix(this.eventsPath, prefixBytes),
|
|
1615
|
+
prefixBytes,
|
|
1616
|
+
eventsTotal: state.events,
|
|
1617
|
+
counters: {
|
|
1618
|
+
badLines: state.badLines,
|
|
1619
|
+
orphanEvents: state.orphanEvents,
|
|
1620
|
+
unsupportedVersions: state.unsupportedVersions,
|
|
1621
|
+
skippedEvents: state.skippedEvents,
|
|
1622
|
+
evidenceCount: state.evidence.size,
|
|
1623
|
+
},
|
|
1624
|
+
idempotency,
|
|
1625
|
+
idempotencyComplete: state.idempotencyComplete,
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
778
1628
|
buildIndexPayload(state) {
|
|
779
1629
|
const lessons = {};
|
|
780
1630
|
const stateCounts = emptyStateCounts();
|
|
@@ -782,29 +1632,37 @@ export class KnowledgeStore {
|
|
|
782
1632
|
lessons[lesson.id] = lesson;
|
|
783
1633
|
stateCounts[lesson.state]++;
|
|
784
1634
|
}
|
|
785
|
-
|
|
1635
|
+
const payload = {
|
|
786
1636
|
schemaVersion: 1,
|
|
787
1637
|
generatedAt: new Date().toISOString(),
|
|
788
1638
|
lessons,
|
|
789
1639
|
stateCounts,
|
|
790
1640
|
byState: stateCounts,
|
|
791
1641
|
};
|
|
1642
|
+
const checkpoint = this.buildCheckpoint(state);
|
|
1643
|
+
if (checkpoint !== undefined)
|
|
1644
|
+
payload.checkpoint = checkpoint;
|
|
1645
|
+
return payload;
|
|
792
1646
|
}
|
|
793
|
-
/**
|
|
794
|
-
writeIndex(state) {
|
|
1647
|
+
/** 自动更新派生索引:失败降级(权威事件已落盘),记录 lastIndexError 供 diagnose()。 */
|
|
1648
|
+
writeIndex(state, touch) {
|
|
795
1649
|
try {
|
|
796
|
-
this.writeIndexStrict(state);
|
|
1650
|
+
this.writeIndexStrict(state, touch);
|
|
1651
|
+
this.lastIndexError = undefined;
|
|
797
1652
|
}
|
|
798
|
-
catch {
|
|
799
|
-
|
|
1653
|
+
catch (error) {
|
|
1654
|
+
this.lastIndexError = { message: maskSecrets(errorMessage(error)), at: new Date().toISOString() };
|
|
800
1655
|
}
|
|
801
1656
|
}
|
|
802
|
-
writeIndexStrict(state) {
|
|
1657
|
+
writeIndexStrict(state, touch) {
|
|
803
1658
|
this.ensureDir();
|
|
1659
|
+
touch?.();
|
|
1660
|
+
const payload = this.buildIndexPayload(state);
|
|
1661
|
+
touch?.();
|
|
804
1662
|
const target = this.indexPath;
|
|
805
1663
|
const tmp = target + '.tmp-' + process.pid + '-' + randomUUID();
|
|
806
1664
|
try {
|
|
807
|
-
writeFileSync(tmp, JSON.stringify(
|
|
1665
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
808
1666
|
renameSync(tmp, target);
|
|
809
1667
|
}
|
|
810
1668
|
catch (error) {
|