@modusensus/dsh-mneme 0.3.1 → 0.3.2
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/lib/api.js +18 -0
- package/lib/index.js +6 -0
- package/lib/service.js +106 -0
- package/lib/store.js +93 -0
- package/package.json +1 -1
- package/src/api.js +18 -0
- package/src/index.js +6 -0
- package/src/service.js +106 -0
- package/src/store.js +93 -0
- package/test/fnew-03.test.js +415 -0
- package/test/mirror-dirty.test.js +391 -0
package/lib/api.js
CHANGED
|
@@ -268,6 +268,24 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
268
268
|
}
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
+
// --- health: mirror sync state (F-NEW-03) ---
|
|
272
|
+
register({
|
|
273
|
+
kind: "exact",
|
|
274
|
+
path: "/api/dsh-mneme/health",
|
|
275
|
+
handler(req, res) {
|
|
276
|
+
try {
|
|
277
|
+
const state = service.getMirrorHealth?.() ?? null;
|
|
278
|
+
sendJson(res, 200, {
|
|
279
|
+
mirror: state
|
|
280
|
+
? { dirty: state.dirty === true, last_error: state.last_error ?? null, last_attempt: state.last_attempt ?? null, success_at: state.success_at ?? null }
|
|
281
|
+
: null
|
|
282
|
+
});
|
|
283
|
+
} catch {
|
|
284
|
+
sendJson(res, 500, { error: "internal" });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
271
289
|
// --- custom commands ---
|
|
272
290
|
register({
|
|
273
291
|
kind: "exact",
|
package/lib/index.js
CHANGED
|
@@ -45,6 +45,12 @@ export const apply = (ctx, config) => {
|
|
|
45
45
|
const mirror = createMirror(memoryDir);
|
|
46
46
|
const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
|
|
47
47
|
|
|
48
|
+
// F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
|
|
49
|
+
// a safe re-render at boot so a stale mirror converges without needing a
|
|
50
|
+
// business write. Bounded: single attempt; on failure dirty stays for the
|
|
51
|
+
// next boot. Never throws.
|
|
52
|
+
service.recoverMirror();
|
|
53
|
+
|
|
48
54
|
// Recall-layer receipt: when searchMemories runs with recordRecall=true, the
|
|
49
55
|
// retrieval scene (query/mode/topK/threshold + candidates) is persisted to
|
|
50
56
|
// recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
|
package/lib/service.js
CHANGED
|
@@ -483,17 +483,123 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
483
483
|
* non-forgotten memories are mirrored: forgotten entries must not reach the
|
|
484
484
|
* human-editable file (a human "edit" could otherwise resurrect them).
|
|
485
485
|
*/
|
|
486
|
+
// syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
|
|
487
|
+
// 失败写 store.markMirrorDirty(dirty=1 + last_error + last_attempt),成功写
|
|
488
|
+
// store.markMirrorClean(dirty=0 + last_error=null + success_at)。状态写入用
|
|
489
|
+
// try/catch 包住,避免 db 关闭等场景下 syncMirror 自身再抛出(F-NEW-03)。
|
|
486
490
|
function syncMirror() {
|
|
487
491
|
if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
|
|
492
|
+
const now = new Date().toISOString();
|
|
488
493
|
try {
|
|
489
494
|
mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
|
|
495
|
+
// 成功:写 clean 状态
|
|
496
|
+
try {
|
|
497
|
+
store.markMirrorClean(now);
|
|
498
|
+
} catch (stateError) {
|
|
499
|
+
// markMirrorClean 失败不能影响主要同步逻辑,仅记录日志
|
|
500
|
+
logger?.warn?.("syncMirror: markMirrorClean failed:", stateError);
|
|
501
|
+
}
|
|
490
502
|
} catch (error) {
|
|
503
|
+
// 同步失败:写 dirty 状态
|
|
504
|
+
const errMsg = error?.message ?? String(error);
|
|
491
505
|
logger?.warn?.("syncMirror failed:", error);
|
|
506
|
+
try {
|
|
507
|
+
store.markMirrorDirty(errMsg, now);
|
|
508
|
+
} catch (stateError) {
|
|
509
|
+
// markMirrorDirty 失败同样不能向外抛出
|
|
510
|
+
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
|
|
516
|
+
// (F-NEW-03)。dirty=true → 有界重试(最多 3 次,立即重试)重跑 syncMirror
|
|
517
|
+
// 收敛镜像;某次成功(dirty 变 0)立即停止。返回 { recovered, error } 供
|
|
518
|
+
// index.js 启动 / api.js health 判断。一切 fail-safe,绝不向外抛。
|
|
519
|
+
function recoverMirror() {
|
|
520
|
+
const MAX_ATTEMPTS = 3;
|
|
521
|
+
let lastError = null;
|
|
522
|
+
let recovered = false;
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
const state = store.getMirrorState();
|
|
526
|
+
if (!state?.dirty) {
|
|
527
|
+
// 本来就干净:无需恢复,视为成功
|
|
528
|
+
return { recovered: true, error: null };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// dirty 状态:最多尝试 3 次 sync
|
|
532
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
533
|
+
try {
|
|
534
|
+
syncMirror(); // syncMirror 内部已 catch,不会向外抛
|
|
535
|
+
const currentState = store.getMirrorState();
|
|
536
|
+
if (!currentState?.dirty) {
|
|
537
|
+
// 成功:镜像已收敛为 clean
|
|
538
|
+
recovered = true;
|
|
539
|
+
lastError = null;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
// 仍 dirty:记录最后一次错误供重试耗尽后上报
|
|
543
|
+
lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty`;
|
|
544
|
+
} catch (syncError) {
|
|
545
|
+
// syncMirror 理论不抛,fail-safe 兜底
|
|
546
|
+
const errMsg = syncError?.message ?? String(syncError);
|
|
547
|
+
logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
|
|
548
|
+
lastError = errMsg;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!recovered) {
|
|
553
|
+
logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
|
|
554
|
+
} else {
|
|
555
|
+
logger?.warn?.("dsh-mneme mirror: recovered from dirty state");
|
|
556
|
+
}
|
|
557
|
+
} catch (error) {
|
|
558
|
+
// fail-safe:任何意外异常不向外抛
|
|
559
|
+
lastError = error?.message ?? String(error);
|
|
560
|
+
logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { recovered, error: recovered ? null : lastError };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
|
|
567
|
+
// (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
|
|
568
|
+
function getMirrorHealth() {
|
|
569
|
+
try {
|
|
570
|
+
const state = store.getMirrorState();
|
|
571
|
+
if (!state) {
|
|
572
|
+
// 无状态行:返回安全默认值
|
|
573
|
+
return {
|
|
574
|
+
dirty: false,
|
|
575
|
+
last_error: null,
|
|
576
|
+
last_attempt: null,
|
|
577
|
+
success_at: null
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
dirty: Boolean(state.dirty),
|
|
582
|
+
last_error: state.last_error ?? null,
|
|
583
|
+
last_attempt: state.last_attempt ?? null,
|
|
584
|
+
success_at: state.success_at ?? null
|
|
585
|
+
};
|
|
586
|
+
} catch (error) {
|
|
587
|
+
// fail-safe:状态读取失败也不向外抛
|
|
588
|
+
logger?.warn?.("getMirrorHealth failed:", error);
|
|
589
|
+
return {
|
|
590
|
+
dirty: false,
|
|
591
|
+
last_error: error?.message ?? String(error),
|
|
592
|
+
last_attempt: null,
|
|
593
|
+
success_at: null
|
|
594
|
+
};
|
|
492
595
|
}
|
|
493
596
|
}
|
|
494
597
|
|
|
495
598
|
return {
|
|
496
599
|
saveWithDedupe,
|
|
600
|
+
recoverMirror,
|
|
601
|
+
getMirrorHealth,
|
|
602
|
+
getMirrorState: () => store.getMirrorState(),
|
|
497
603
|
injectCandidates,
|
|
498
604
|
mergeHumanEdits,
|
|
499
605
|
toApiList,
|
package/lib/store.js
CHANGED
|
@@ -164,6 +164,18 @@ CREATE TABLE IF NOT EXISTS entity_relations (
|
|
|
164
164
|
CREATE INDEX IF NOT EXISTS idx_relations_from ON entity_relations(from_entity);
|
|
165
165
|
CREATE INDEX IF NOT EXISTS idx_relations_to ON entity_relations(to_entity);
|
|
166
166
|
CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type);
|
|
167
|
+
|
|
168
|
+
-- mirror 渲染状态 (F-NEW-03): 单行持久记录 mirror 同步失败/成功状态,使
|
|
169
|
+
-- syncMirror 失败不再只靠瞬时 console.warn —— dirty=1 提示镜像脏了需重渲染,
|
|
170
|
+
-- last_error/last_attempt 记录失败原因与最近尝试,success_at 记录最近成功。
|
|
171
|
+
-- 上层可据此在启动时重试、提供人工 reconcile 入口与健康状态查询。
|
|
172
|
+
CREATE TABLE IF NOT EXISTS mirror_state (
|
|
173
|
+
id TEXT PRIMARY KEY, -- 单一状态行(用 'main')
|
|
174
|
+
dirty INTEGER NOT NULL DEFAULT 0, -- 1=镜像脏了需重渲染
|
|
175
|
+
last_error TEXT, -- 最近失败原因
|
|
176
|
+
last_attempt TEXT, -- 最近尝试时间(ISO)
|
|
177
|
+
success_at TEXT -- 最近成功时间(ISO)
|
|
178
|
+
);
|
|
167
179
|
`;
|
|
168
180
|
|
|
169
181
|
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
@@ -323,6 +335,19 @@ function toRelation(row) {
|
|
|
323
335
|
};
|
|
324
336
|
}
|
|
325
337
|
|
|
338
|
+
function toMirrorState(row) {
|
|
339
|
+
if (!row) {
|
|
340
|
+
return { dirty: false, last_error: null, last_attempt: null, success_at: null };
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
id: row.id,
|
|
344
|
+
dirty: row.dirty === 1,
|
|
345
|
+
last_error: row.last_error,
|
|
346
|
+
last_attempt: row.last_attempt,
|
|
347
|
+
success_at: row.success_at
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
326
351
|
function parseJsonArray(raw) {
|
|
327
352
|
try {
|
|
328
353
|
const arr = JSON.parse(raw);
|
|
@@ -1084,6 +1109,69 @@ export function createStore(path) {
|
|
|
1084
1109
|
).all(entityId, entityId).map(toRelation);
|
|
1085
1110
|
}
|
|
1086
1111
|
|
|
1112
|
+
// --- mirror sync state (F-NEW-03) -----------------------------------------
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Upsert the single mirror_state row (id='main'). patch accepts
|
|
1116
|
+
* {dirty?, last_error?, last_attempt?, success_at?} — only the keys present
|
|
1117
|
+
* on the object are written, everything else is left untouched. Returns the
|
|
1118
|
+
* freshly read state row (default shape when absent).
|
|
1119
|
+
*/
|
|
1120
|
+
function setMirrorState(patch) {
|
|
1121
|
+
const ALLOWED = new Set(["dirty", "last_error", "last_attempt", "success_at"]);
|
|
1122
|
+
const keys = Object.keys(patch).filter((key) =>
|
|
1123
|
+
ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
|
|
1124
|
+
);
|
|
1125
|
+
if (keys.length === 0) {
|
|
1126
|
+
db.prepare(
|
|
1127
|
+
"INSERT INTO mirror_state (id) VALUES ('main') ON CONFLICT(id) DO NOTHING"
|
|
1128
|
+
).run();
|
|
1129
|
+
return getMirrorState();
|
|
1130
|
+
}
|
|
1131
|
+
// 列同时出现在 INSERT 与 ON CONFLICT 里(excluded.*),保证首次插入也写入
|
|
1132
|
+
// patch 值,而不只是默认值;未传入的列保持不变(partial upsert)。
|
|
1133
|
+
const cols = keys.join(", ");
|
|
1134
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
1135
|
+
const updates = keys.map((k) => `${k} = excluded.${k}`).join(", ");
|
|
1136
|
+
const values = keys.map((key) =>
|
|
1137
|
+
key === "dirty" ? (patch[key] ? 1 : 0) : patch[key]
|
|
1138
|
+
);
|
|
1139
|
+
db.prepare(
|
|
1140
|
+
`INSERT INTO mirror_state (id, ${cols}) VALUES ('main', ${placeholders})
|
|
1141
|
+
ON CONFLICT(id) DO UPDATE SET ${updates}`
|
|
1142
|
+
).run(...values);
|
|
1143
|
+
return getMirrorState();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null} when absent. */
|
|
1147
|
+
function getMirrorState() {
|
|
1148
|
+
const row = db.prepare("SELECT * FROM mirror_state WHERE id = 'main'").get();
|
|
1149
|
+
return toMirrorState(row);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Convenience: mark the mirror dirty after a failed sync (dirty=1 + last_error + last_attempt). */
|
|
1153
|
+
function markMirrorDirty(error, now) {
|
|
1154
|
+
return setMirrorState({
|
|
1155
|
+
dirty: 1,
|
|
1156
|
+
last_error: error,
|
|
1157
|
+
last_attempt: now ?? nowIso()
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Convenience: mark the mirror clean after a successful sync (dirty=0 + last_error=null + success_at). */
|
|
1162
|
+
function markMirrorClean(now) {
|
|
1163
|
+
return setMirrorState({
|
|
1164
|
+
dirty: 0,
|
|
1165
|
+
last_error: null,
|
|
1166
|
+
success_at: now ?? nowIso()
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** Convenience: clear only the dirty flag + last_error, leaving success_at untouched (manual reconcile / retry path). */
|
|
1171
|
+
function clearMirrorDirty() {
|
|
1172
|
+
return setMirrorState({ dirty: 0, last_error: null });
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1087
1175
|
return {
|
|
1088
1176
|
db,
|
|
1089
1177
|
count,
|
|
@@ -1132,6 +1220,11 @@ export function createStore(path) {
|
|
|
1132
1220
|
saveRelation,
|
|
1133
1221
|
migrateAttrsToMemory,
|
|
1134
1222
|
getRelations,
|
|
1223
|
+
setMirrorState,
|
|
1224
|
+
getMirrorState,
|
|
1225
|
+
markMirrorDirty,
|
|
1226
|
+
markMirrorClean,
|
|
1227
|
+
clearMirrorDirty,
|
|
1135
1228
|
close() {
|
|
1136
1229
|
db.close();
|
|
1137
1230
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 6 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/src/api.js
CHANGED
|
@@ -268,6 +268,24 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
268
268
|
}
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
+
// --- health: mirror sync state (F-NEW-03) ---
|
|
272
|
+
register({
|
|
273
|
+
kind: "exact",
|
|
274
|
+
path: "/api/dsh-mneme/health",
|
|
275
|
+
handler(req, res) {
|
|
276
|
+
try {
|
|
277
|
+
const state = service.getMirrorHealth?.() ?? null;
|
|
278
|
+
sendJson(res, 200, {
|
|
279
|
+
mirror: state
|
|
280
|
+
? { dirty: state.dirty === true, last_error: state.last_error ?? null, last_attempt: state.last_attempt ?? null, success_at: state.success_at ?? null }
|
|
281
|
+
: null
|
|
282
|
+
});
|
|
283
|
+
} catch {
|
|
284
|
+
sendJson(res, 500, { error: "internal" });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
271
289
|
// --- custom commands ---
|
|
272
290
|
register({
|
|
273
291
|
kind: "exact",
|
package/src/index.js
CHANGED
|
@@ -45,6 +45,12 @@ export const apply = (ctx, config) => {
|
|
|
45
45
|
const mirror = createMirror(memoryDir);
|
|
46
46
|
const service = createService({ store, mirror, config: cfg, logger: ctx.logger });
|
|
47
47
|
|
|
48
|
+
// F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
|
|
49
|
+
// a safe re-render at boot so a stale mirror converges without needing a
|
|
50
|
+
// business write. Bounded: single attempt; on failure dirty stays for the
|
|
51
|
+
// next boot. Never throws.
|
|
52
|
+
service.recoverMirror();
|
|
53
|
+
|
|
48
54
|
// Recall-layer receipt: when searchMemories runs with recordRecall=true, the
|
|
49
55
|
// retrieval scene (query/mode/topK/threshold + candidates) is persisted to
|
|
50
56
|
// recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
|
package/src/service.js
CHANGED
|
@@ -483,17 +483,123 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
483
483
|
* non-forgotten memories are mirrored: forgotten entries must not reach the
|
|
484
484
|
* human-editable file (a human "edit" could otherwise resurrect them).
|
|
485
485
|
*/
|
|
486
|
+
// syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
|
|
487
|
+
// 失败写 store.markMirrorDirty(dirty=1 + last_error + last_attempt),成功写
|
|
488
|
+
// store.markMirrorClean(dirty=0 + last_error=null + success_at)。状态写入用
|
|
489
|
+
// try/catch 包住,避免 db 关闭等场景下 syncMirror 自身再抛出(F-NEW-03)。
|
|
486
490
|
function syncMirror() {
|
|
487
491
|
if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
|
|
492
|
+
const now = new Date().toISOString();
|
|
488
493
|
try {
|
|
489
494
|
mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
|
|
495
|
+
// 成功:写 clean 状态
|
|
496
|
+
try {
|
|
497
|
+
store.markMirrorClean(now);
|
|
498
|
+
} catch (stateError) {
|
|
499
|
+
// markMirrorClean 失败不能影响主要同步逻辑,仅记录日志
|
|
500
|
+
logger?.warn?.("syncMirror: markMirrorClean failed:", stateError);
|
|
501
|
+
}
|
|
490
502
|
} catch (error) {
|
|
503
|
+
// 同步失败:写 dirty 状态
|
|
504
|
+
const errMsg = error?.message ?? String(error);
|
|
491
505
|
logger?.warn?.("syncMirror failed:", error);
|
|
506
|
+
try {
|
|
507
|
+
store.markMirrorDirty(errMsg, now);
|
|
508
|
+
} catch (stateError) {
|
|
509
|
+
// markMirrorDirty 失败同样不能向外抛出
|
|
510
|
+
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
|
|
516
|
+
// (F-NEW-03)。dirty=true → 有界重试(最多 3 次,立即重试)重跑 syncMirror
|
|
517
|
+
// 收敛镜像;某次成功(dirty 变 0)立即停止。返回 { recovered, error } 供
|
|
518
|
+
// index.js 启动 / api.js health 判断。一切 fail-safe,绝不向外抛。
|
|
519
|
+
function recoverMirror() {
|
|
520
|
+
const MAX_ATTEMPTS = 3;
|
|
521
|
+
let lastError = null;
|
|
522
|
+
let recovered = false;
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
const state = store.getMirrorState();
|
|
526
|
+
if (!state?.dirty) {
|
|
527
|
+
// 本来就干净:无需恢复,视为成功
|
|
528
|
+
return { recovered: true, error: null };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// dirty 状态:最多尝试 3 次 sync
|
|
532
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
533
|
+
try {
|
|
534
|
+
syncMirror(); // syncMirror 内部已 catch,不会向外抛
|
|
535
|
+
const currentState = store.getMirrorState();
|
|
536
|
+
if (!currentState?.dirty) {
|
|
537
|
+
// 成功:镜像已收敛为 clean
|
|
538
|
+
recovered = true;
|
|
539
|
+
lastError = null;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
// 仍 dirty:记录最后一次错误供重试耗尽后上报
|
|
543
|
+
lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty`;
|
|
544
|
+
} catch (syncError) {
|
|
545
|
+
// syncMirror 理论不抛,fail-safe 兜底
|
|
546
|
+
const errMsg = syncError?.message ?? String(syncError);
|
|
547
|
+
logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
|
|
548
|
+
lastError = errMsg;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!recovered) {
|
|
553
|
+
logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
|
|
554
|
+
} else {
|
|
555
|
+
logger?.warn?.("dsh-mneme mirror: recovered from dirty state");
|
|
556
|
+
}
|
|
557
|
+
} catch (error) {
|
|
558
|
+
// fail-safe:任何意外异常不向外抛
|
|
559
|
+
lastError = error?.message ?? String(error);
|
|
560
|
+
logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return { recovered, error: recovered ? null : lastError };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
|
|
567
|
+
// (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
|
|
568
|
+
function getMirrorHealth() {
|
|
569
|
+
try {
|
|
570
|
+
const state = store.getMirrorState();
|
|
571
|
+
if (!state) {
|
|
572
|
+
// 无状态行:返回安全默认值
|
|
573
|
+
return {
|
|
574
|
+
dirty: false,
|
|
575
|
+
last_error: null,
|
|
576
|
+
last_attempt: null,
|
|
577
|
+
success_at: null
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
dirty: Boolean(state.dirty),
|
|
582
|
+
last_error: state.last_error ?? null,
|
|
583
|
+
last_attempt: state.last_attempt ?? null,
|
|
584
|
+
success_at: state.success_at ?? null
|
|
585
|
+
};
|
|
586
|
+
} catch (error) {
|
|
587
|
+
// fail-safe:状态读取失败也不向外抛
|
|
588
|
+
logger?.warn?.("getMirrorHealth failed:", error);
|
|
589
|
+
return {
|
|
590
|
+
dirty: false,
|
|
591
|
+
last_error: error?.message ?? String(error),
|
|
592
|
+
last_attempt: null,
|
|
593
|
+
success_at: null
|
|
594
|
+
};
|
|
492
595
|
}
|
|
493
596
|
}
|
|
494
597
|
|
|
495
598
|
return {
|
|
496
599
|
saveWithDedupe,
|
|
600
|
+
recoverMirror,
|
|
601
|
+
getMirrorHealth,
|
|
602
|
+
getMirrorState: () => store.getMirrorState(),
|
|
497
603
|
injectCandidates,
|
|
498
604
|
mergeHumanEdits,
|
|
499
605
|
toApiList,
|
package/src/store.js
CHANGED
|
@@ -164,6 +164,18 @@ CREATE TABLE IF NOT EXISTS entity_relations (
|
|
|
164
164
|
CREATE INDEX IF NOT EXISTS idx_relations_from ON entity_relations(from_entity);
|
|
165
165
|
CREATE INDEX IF NOT EXISTS idx_relations_to ON entity_relations(to_entity);
|
|
166
166
|
CREATE INDEX IF NOT EXISTS idx_relations_type ON entity_relations(relation_type);
|
|
167
|
+
|
|
168
|
+
-- mirror 渲染状态 (F-NEW-03): 单行持久记录 mirror 同步失败/成功状态,使
|
|
169
|
+
-- syncMirror 失败不再只靠瞬时 console.warn —— dirty=1 提示镜像脏了需重渲染,
|
|
170
|
+
-- last_error/last_attempt 记录失败原因与最近尝试,success_at 记录最近成功。
|
|
171
|
+
-- 上层可据此在启动时重试、提供人工 reconcile 入口与健康状态查询。
|
|
172
|
+
CREATE TABLE IF NOT EXISTS mirror_state (
|
|
173
|
+
id TEXT PRIMARY KEY, -- 单一状态行(用 'main')
|
|
174
|
+
dirty INTEGER NOT NULL DEFAULT 0, -- 1=镜像脏了需重渲染
|
|
175
|
+
last_error TEXT, -- 最近失败原因
|
|
176
|
+
last_attempt TEXT, -- 最近尝试时间(ISO)
|
|
177
|
+
success_at TEXT -- 最近成功时间(ISO)
|
|
178
|
+
);
|
|
167
179
|
`;
|
|
168
180
|
|
|
169
181
|
const TYPES = new Set(["preference", "project", "decision", "history", "summary"]);
|
|
@@ -323,6 +335,19 @@ function toRelation(row) {
|
|
|
323
335
|
};
|
|
324
336
|
}
|
|
325
337
|
|
|
338
|
+
function toMirrorState(row) {
|
|
339
|
+
if (!row) {
|
|
340
|
+
return { dirty: false, last_error: null, last_attempt: null, success_at: null };
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
id: row.id,
|
|
344
|
+
dirty: row.dirty === 1,
|
|
345
|
+
last_error: row.last_error,
|
|
346
|
+
last_attempt: row.last_attempt,
|
|
347
|
+
success_at: row.success_at
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
326
351
|
function parseJsonArray(raw) {
|
|
327
352
|
try {
|
|
328
353
|
const arr = JSON.parse(raw);
|
|
@@ -1084,6 +1109,69 @@ export function createStore(path) {
|
|
|
1084
1109
|
).all(entityId, entityId).map(toRelation);
|
|
1085
1110
|
}
|
|
1086
1111
|
|
|
1112
|
+
// --- mirror sync state (F-NEW-03) -----------------------------------------
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Upsert the single mirror_state row (id='main'). patch accepts
|
|
1116
|
+
* {dirty?, last_error?, last_attempt?, success_at?} — only the keys present
|
|
1117
|
+
* on the object are written, everything else is left untouched. Returns the
|
|
1118
|
+
* freshly read state row (default shape when absent).
|
|
1119
|
+
*/
|
|
1120
|
+
function setMirrorState(patch) {
|
|
1121
|
+
const ALLOWED = new Set(["dirty", "last_error", "last_attempt", "success_at"]);
|
|
1122
|
+
const keys = Object.keys(patch).filter((key) =>
|
|
1123
|
+
ALLOWED.has(key) && Object.prototype.hasOwnProperty.call(patch, key)
|
|
1124
|
+
);
|
|
1125
|
+
if (keys.length === 0) {
|
|
1126
|
+
db.prepare(
|
|
1127
|
+
"INSERT INTO mirror_state (id) VALUES ('main') ON CONFLICT(id) DO NOTHING"
|
|
1128
|
+
).run();
|
|
1129
|
+
return getMirrorState();
|
|
1130
|
+
}
|
|
1131
|
+
// 列同时出现在 INSERT 与 ON CONFLICT 里(excluded.*),保证首次插入也写入
|
|
1132
|
+
// patch 值,而不只是默认值;未传入的列保持不变(partial upsert)。
|
|
1133
|
+
const cols = keys.join(", ");
|
|
1134
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
1135
|
+
const updates = keys.map((k) => `${k} = excluded.${k}`).join(", ");
|
|
1136
|
+
const values = keys.map((key) =>
|
|
1137
|
+
key === "dirty" ? (patch[key] ? 1 : 0) : patch[key]
|
|
1138
|
+
);
|
|
1139
|
+
db.prepare(
|
|
1140
|
+
`INSERT INTO mirror_state (id, ${cols}) VALUES ('main', ${placeholders})
|
|
1141
|
+
ON CONFLICT(id) DO UPDATE SET ${updates}`
|
|
1142
|
+
).run(...values);
|
|
1143
|
+
return getMirrorState();
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Current mirror state; default {dirty:0, last_error:null, last_attempt:null, success_at:null} when absent. */
|
|
1147
|
+
function getMirrorState() {
|
|
1148
|
+
const row = db.prepare("SELECT * FROM mirror_state WHERE id = 'main'").get();
|
|
1149
|
+
return toMirrorState(row);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Convenience: mark the mirror dirty after a failed sync (dirty=1 + last_error + last_attempt). */
|
|
1153
|
+
function markMirrorDirty(error, now) {
|
|
1154
|
+
return setMirrorState({
|
|
1155
|
+
dirty: 1,
|
|
1156
|
+
last_error: error,
|
|
1157
|
+
last_attempt: now ?? nowIso()
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Convenience: mark the mirror clean after a successful sync (dirty=0 + last_error=null + success_at). */
|
|
1162
|
+
function markMirrorClean(now) {
|
|
1163
|
+
return setMirrorState({
|
|
1164
|
+
dirty: 0,
|
|
1165
|
+
last_error: null,
|
|
1166
|
+
success_at: now ?? nowIso()
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** Convenience: clear only the dirty flag + last_error, leaving success_at untouched (manual reconcile / retry path). */
|
|
1171
|
+
function clearMirrorDirty() {
|
|
1172
|
+
return setMirrorState({ dirty: 0, last_error: null });
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1087
1175
|
return {
|
|
1088
1176
|
db,
|
|
1089
1177
|
count,
|
|
@@ -1132,6 +1220,11 @@ export function createStore(path) {
|
|
|
1132
1220
|
saveRelation,
|
|
1133
1221
|
migrateAttrsToMemory,
|
|
1134
1222
|
getRelations,
|
|
1223
|
+
setMirrorState,
|
|
1224
|
+
getMirrorState,
|
|
1225
|
+
markMirrorDirty,
|
|
1226
|
+
markMirrorClean,
|
|
1227
|
+
clearMirrorDirty,
|
|
1135
1228
|
close() {
|
|
1136
1229
|
db.close();
|
|
1137
1230
|
}
|