@modusensus/dsh-mneme 0.8.3 → 0.8.5

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.
Files changed (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +727 -636
  3. package/bin/cli.mjs +0 -0
  4. package/bin/dsh-mneme-mcp.mjs +510 -0
  5. package/cordis.patch.yml +21 -21
  6. package/lib/api-standalone.js +506 -316
  7. package/lib/api.js +1403 -1316
  8. package/lib/bootstrap.js +211 -0
  9. package/lib/client.js +4657 -4366
  10. package/lib/commands.js +95 -95
  11. package/lib/config.js +615 -510
  12. package/lib/document.js +221 -0
  13. package/lib/dream/clustering.js +118 -118
  14. package/lib/dream/decisions.js +658 -658
  15. package/lib/dream/narratives.js +44 -0
  16. package/lib/dream/sleep.js +702 -700
  17. package/lib/dream.js +147 -16
  18. package/lib/embedding.js +177 -177
  19. package/lib/entities/extractor.js +249 -249
  20. package/lib/guide.js +44 -0
  21. package/lib/heat.js +144 -136
  22. package/lib/hot-memory.js +46 -46
  23. package/lib/index.js +515 -515
  24. package/lib/inject.js +375 -295
  25. package/lib/lang.js +208 -179
  26. package/lib/local-embedder.js +320 -320
  27. package/lib/mirror.js +198 -198
  28. package/lib/organize.js +450 -0
  29. package/lib/quality-filter.js +126 -126
  30. package/lib/recall-stats.js +125 -0
  31. package/lib/reranker.js +269 -269
  32. package/lib/runtime/adopt.js +176 -176
  33. package/lib/runtime/closure.js +172 -172
  34. package/lib/runtime/download.js +247 -247
  35. package/lib/runtime/layout.js +265 -235
  36. package/lib/runtime/loader.js +252 -249
  37. package/lib/runtime/model-fetch.js +361 -361
  38. package/lib/runtime/provision.js +237 -237
  39. package/lib/runtime/tarball.js +130 -130
  40. package/lib/runtime/verify.js +204 -191
  41. package/lib/scope.js +118 -118
  42. package/lib/search/adaptive.js +60 -22
  43. package/lib/search/bm25.js +96 -96
  44. package/lib/service.js +2324 -2087
  45. package/lib/settings.js +459 -420
  46. package/lib/store.js +2549 -2367
  47. package/lib/summarize.js +820 -449
  48. package/lib/tools.js +702 -574
  49. package/lib/vector-index.js +110 -110
  50. package/lib/version-check.js +122 -122
  51. package/package.json +109 -108
  52. package/runtime-manifest.json +526 -596
  53. package/scripts/benchmark-embed.js +201 -201
  54. package/scripts/benchmark-recall.js +199 -199
  55. package/scripts/benchmark-rerank.js +166 -166
  56. package/scripts/build-runtime-manifest.mjs +224 -217
  57. package/scripts/check-sync.js +42 -42
  58. package/scripts/e2e-dsh.js +220 -220
  59. package/scripts/mneme-runtime.mjs +277 -277
  60. package/scripts/stress-dsh.js +255 -255
  61. package/scripts/sync-lib.js +47 -47
  62. package/scripts/sync-test-badge.mjs +67 -61
  63. package/src/api-standalone.js +506 -316
  64. package/src/api.js +1403 -1316
  65. package/src/bootstrap.js +211 -0
  66. package/src/commands.js +95 -95
  67. package/src/config.js +615 -510
  68. package/src/document.js +221 -0
  69. package/src/dream/clustering.js +118 -118
  70. package/src/dream/decisions.js +658 -658
  71. package/src/dream/narratives.js +44 -0
  72. package/src/dream/sleep.js +702 -700
  73. package/src/dream.js +147 -16
  74. package/src/embedding.js +177 -177
  75. package/src/entities/extractor.js +249 -249
  76. package/src/guide.js +44 -0
  77. package/src/heat.js +144 -136
  78. package/src/hot-memory.js +46 -46
  79. package/src/index.js +515 -515
  80. package/src/inject.js +375 -295
  81. package/src/lang.js +208 -179
  82. package/src/local-embedder.js +320 -320
  83. package/src/mirror.js +198 -198
  84. package/src/organize.js +450 -0
  85. package/src/quality-filter.js +126 -126
  86. package/src/recall-stats.js +125 -0
  87. package/src/reranker.js +269 -269
  88. package/src/runtime/adopt.js +176 -176
  89. package/src/runtime/closure.js +172 -172
  90. package/src/runtime/download.js +247 -247
  91. package/src/runtime/layout.js +265 -235
  92. package/src/runtime/loader.js +252 -249
  93. package/src/runtime/model-fetch.js +361 -361
  94. package/src/runtime/provision.js +237 -237
  95. package/src/runtime/tarball.js +130 -130
  96. package/src/runtime/verify.js +204 -191
  97. package/src/scope.js +118 -118
  98. package/src/search/adaptive.js +60 -22
  99. package/src/search/bm25.js +96 -96
  100. package/src/service.js +2324 -2087
  101. package/src/settings.js +459 -420
  102. package/src/store.js +2549 -2367
  103. package/src/summarize.js +820 -449
  104. package/src/tools.js +702 -574
  105. package/src/vector-index.js +110 -110
  106. package/src/version-check.js +122 -122
  107. package/test/adaptive-inject-budget.test.js +85 -0
  108. package/test/api-routes-gaps.test.js +218 -218
  109. package/test/api.test.js +1640 -1482
  110. package/test/audit.test.js +448 -448
  111. package/test/benchmark.test.js +94 -94
  112. package/test/bootstrap.test.js +220 -0
  113. package/test/client.test.js +860 -736
  114. package/test/clustering.test.js +100 -100
  115. package/test/commands.test.js +106 -106
  116. package/test/config.test.js +66 -66
  117. package/test/conflict-freeze.test.js +300 -300
  118. package/test/document.test.js +523 -0
  119. package/test/dream-archive-guardrail.test.js +116 -116
  120. package/test/dream-candidate-hybrid.test.js +281 -281
  121. package/test/dream-coverage-degrade.test.js +86 -86
  122. package/test/dream.test.js +1308 -1254
  123. package/test/embedding-ready.test.js +196 -196
  124. package/test/entities.test.js +547 -547
  125. package/test/entity-recall.test.js +179 -0
  126. package/test/epistemic.test.js +298 -298
  127. package/test/fnew-0112.test.js +311 -311
  128. package/test/fnew-03.test.js +422 -422
  129. package/test/graph-api.test.js +175 -175
  130. package/test/heat.test.js +175 -148
  131. package/test/helpers/dream-mock.js +82 -82
  132. package/test/helpers/peer-worker.mjs +25 -25
  133. package/test/helpers/runtime-source.js +60 -60
  134. package/test/helpers/tar-builder.js +80 -80
  135. package/test/hot-memory.test.js +145 -145
  136. package/test/inject-content-cap.test.js +91 -0
  137. package/test/inject-first-round-bm25.test.js +89 -89
  138. package/test/inject-heat.test.js +61 -0
  139. package/test/inject-pools.test.js +170 -0
  140. package/test/inject-recall.test.js +105 -0
  141. package/test/inject-rotation.test.js +161 -161
  142. package/test/inject.test.js +283 -249
  143. package/test/lang.test.js +87 -87
  144. package/test/lib-smoke.test.js +96 -96
  145. package/test/llm-audit.test.js +462 -309
  146. package/test/llm-message-source.test.js +177 -177
  147. package/test/local-embedder.test.js +249 -249
  148. package/test/mcp-stdio.test.js +385 -0
  149. package/test/mirror-dirty.test.js +424 -424
  150. package/test/mirror-edit-digest.test.js +187 -187
  151. package/test/mirror-generation.test.js +499 -499
  152. package/test/mirror.test.js +249 -249
  153. package/test/narrative-bars.test.js +162 -0
  154. package/test/organize.test.js +418 -0
  155. package/test/peer-blockers.test.js +193 -193
  156. package/test/policy-epoch.test.js +259 -259
  157. package/test/quality-filter.test.js +186 -186
  158. package/test/reasoning-effort.test.js +746 -746
  159. package/test/recall-evals.test.js +235 -235
  160. package/test/recall-layer.test.js +326 -326
  161. package/test/recall-stats.test.js +123 -0
  162. package/test/receipt-chain.test.js +451 -451
  163. package/test/reflection.test.js +226 -226
  164. package/test/reindex-backfill.test.js +62 -62
  165. package/test/reranker-single-logit.test.js +73 -73
  166. package/test/reranker.test.js +197 -197
  167. package/test/resident-status.test.js +83 -0
  168. package/test/runtime-adopt.test.js +206 -206
  169. package/test/runtime-cli.test.js +89 -89
  170. package/test/runtime-closure.test.js +193 -193
  171. package/test/runtime-download.test.js +302 -302
  172. package/test/runtime-layout.test.js +163 -163
  173. package/test/runtime-loader.test.js +255 -237
  174. package/test/runtime-manifest.test.js +159 -158
  175. package/test/runtime-model-fetch.test.js +348 -348
  176. package/test/runtime-provision.test.js +101 -101
  177. package/test/runtime-tarball.test.js +81 -81
  178. package/test/runtime-verify.test.js +279 -214
  179. package/test/scope-candidates.test.js +239 -239
  180. package/test/scope-explicit.test.js +452 -452
  181. package/test/scope-retrieval.test.js +197 -197
  182. package/test/scope-storage.test.js +255 -255
  183. package/test/scope-strict.test.js +231 -231
  184. package/test/search-fusion.test.js +90 -90
  185. package/test/semantic.test.js +124 -124
  186. package/test/service-search.test.js +174 -174
  187. package/test/service.test.js +405 -405
  188. package/test/settings.test.js +261 -261
  189. package/test/skip-invalid-restore.test.js +262 -262
  190. package/test/sleep-action-set.test.js +422 -422
  191. package/test/sleep-heat.test.js +126 -125
  192. package/test/sleep-scheduler-rearm.test.js +89 -89
  193. package/test/sleep.test.js +410 -373
  194. package/test/standalone-api.test.js +544 -359
  195. package/test/store-vector-cache.test.js +98 -98
  196. package/test/store.test.js +396 -383
  197. package/test/stress.test.js +209 -209
  198. package/test/summarize.test.js +1288 -575
  199. package/test/tools-dto-schema-matrix.test.js +325 -0
  200. package/test/tools-memory-get-schema.test.js +65 -65
  201. package/test/tools.test.js +374 -341
  202. package/test/updated-at-semantics.test.js +113 -113
  203. package/test/vector-index.test.js +221 -221
  204. package/test/version-check.test.js +152 -152
  205. package/test/webServer-optional.test.js +82 -82
@@ -1,424 +1,424 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { EventEmitter } from "node:events";
7
- import { createStore } from "../src/store.js";
8
- import { createMirror, TYPE_FILE } from "../src/mirror.js";
9
- import { createService } from "../src/service.js";
10
- import { createApi } from "../src/api.js";
11
- import { createSettings } from "../src/settings.js";
12
-
13
- /**
14
- * F-NEW-03 回归测试(audit peer 发现的:syncMirror 失败无持久 dirty 状态)。
15
- *
16
- * 修复内容:
17
- * - store.js 新增 mirror_state 表 + set/getMirrorState + markMirrorDirty/Clean
18
- * - service.syncMirror 失败写 dirty(dirty=1+last_error+last_attempt)、成功写
19
- * clean(dirty=0+last_error=null+success_at);状态写入各自 try/catch 不向外抛
20
- * - service.recoverMirror() 启动时 dirty 则做有界重试(最多 3 次)收敛镜像,
21
- * 仍失败保持 dirty 下次再试;返回 { recovered, error },绝不向外抛
22
- * - service.getMirrorHealth() 供 api.js /api/dsh-mneme/health 使用
23
- * - index.js 启动时调 recoverMirror()
24
- *
25
- * 测试点由 Kimi K2.7 设计(TC-F-03-001~011)。
26
- * 注意:getMirrorState().dirty 是布尔(row.dirty===1 转换),不是 1。
27
- */
28
-
29
- function setup({ dbPath } = {}) {
30
- const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-dirty-"));
31
- const mirrorDir = join(dir, "mirror");
32
- const store = createStore(dbPath ?? ":memory:");
33
- const mirror = createMirror(mirrorDir);
34
- const warns = [];
35
- const logger = { warn: (...a) => warns.push(a.join(" ")) };
36
- const service = createService({ store, mirror, config: {}, logger });
37
- return { dir, mirrorDir, store, mirror, service, warns };
38
- }
39
-
40
- function mirrorPath(dir, type) {
41
- return join(dir, TYPE_FILE[type]);
42
- }
43
-
44
- /** mock mirror.sync 抛错(沿用 fnew-0112 的 throwingMirrorSync 模式) */
45
- function throwingMirrorSync(mirror, message = "No space left on device") {
46
- const original = mirror.sync.bind(mirror);
47
- const err = new Error(message);
48
- err.code = "ENOSPC";
49
- mirror.sync = () => { throw err; };
50
- return { original, err };
51
- }
52
-
53
- /** 包一层计数 + 透传的 mirror.sync */
54
- function countingSync(mirror) {
55
- let calls = 0;
56
- const original = mirror.sync.bind(mirror);
57
- mirror.sync = (...args) => { calls++; return original(...args); };
58
- return { calls: () => calls, restore: () => { mirror.sync = original; } };
59
- }
60
-
61
- // ── TC-F-03-001:getMirrorState 的 dirty 是布尔契约 ─────────────────────────
62
-
63
- test("F-NEW-03: markMirrorDirty → getMirrorState().dirty 是布尔 true,字段齐全", () => {
64
- const { dir, store, mirror, service } = setup();
65
- try {
66
- const now = new Date().toISOString();
67
- store.markMirrorDirty("probe error", now);
68
- const state = service.getMirrorState();
69
- assert.equal(typeof state.dirty, "boolean", "dirty 必须是布尔(不是 1)");
70
- assert.equal(state.dirty, true);
71
- assert.equal(state.last_error, "probe error");
72
- assert.equal(state.last_attempt, now);
73
- assert.equal(state.success_at, null, "失败后不得写 success_at");
74
- // 空表默认形状
75
- store.clearMirrorDirty();
76
- const clean = store.getMirrorState();
77
- assert.equal(clean.dirty, false);
78
- assert.equal(clean.last_error, null);
79
- } finally {
80
- store.close();
81
- rmSync(dir, { recursive: true, force: true });
82
- }
83
- });
84
-
85
- // ── TC-F-03-003:syncMirror 失败持久化 dirty,且不向外抛 ────────────────────
86
-
87
- test("F-NEW-03: syncMirror 失败 → dirty=1+last_error+last_attempt,不抛", () => {
88
- const { dir, store, mirror, service, warns } = setup();
89
- try {
90
- const { original, err } = throwingMirrorSync(mirror);
91
- try {
92
- assert.doesNotThrow(() => {
93
- service.saveWithDedupe({ type: "project", title: "写脏", content: "x", importance: 3 });
94
- }, "syncMirror 失败不得向外抛");
95
- const state = service.getMirrorState();
96
- assert.equal(state.dirty, true, "失败必须持久 dirty");
97
- assert.equal(state.last_error, err.message, "last_error 必须记录错误消息");
98
- assert.ok(state.last_attempt, "last_attempt 必须有值");
99
- assert.ok(Number.isFinite(new Date(state.last_attempt).getTime()), "last_attempt 须为合法时间");
100
- assert.equal(state.success_at, null, "失败不得写 success_at");
101
- assert.ok(warns.length >= 1, "失败应走 logger.warn");
102
- } finally {
103
- mirror.sync = original;
104
- }
105
- } finally {
106
- store.close();
107
- rmSync(dir, { recursive: true, force: true });
108
- }
109
- });
110
-
111
- // ── TC-F-03-002:syncMirror 成功清 dirty 并记录 success_at ──────────────────
112
-
113
- test("F-NEW-03: syncMirror 成功 → dirty=0+last_error=null+success_at,镜像收敛", () => {
114
- const { dir, mirrorDir, store, mirror, service } = setup();
115
- try {
116
- // 先制造 dirty
117
- const { original } = throwingMirrorSync(mirror);
118
- const { memory } = service.saveWithDedupe({ type: "project", title: "收敛目标", content: "第一版", importance: 3 });
119
- assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
120
- // 恢复 sync,下一次写应清 dirty 且镜像收敛
121
- mirror.sync = original;
122
- service.saveWithDedupe({ type: "project", title: "后续", content: "第二版", importance: 2 });
123
- const state = service.getMirrorState();
124
- assert.equal(state.dirty, false, "成功后必须清 dirty");
125
- assert.equal(state.last_error, null, "last_error 必须清空");
126
- assert.ok(state.success_at, "success_at 必须有值");
127
- assert.ok(Number.isFinite(new Date(state.success_at).getTime()), "success_at 须为合法时间");
128
- const file = readFileSync(mirrorPath(mirrorDir, "project"), "utf8");
129
- assert.match(file, /收敛目标/, "失败后的下一次同步必须收敛镜像");
130
- assert.match(file, /第二版/, "后续写入也必须上镜像");
131
- assert.ok(store.getById(memory.id), "store 数据不受影响");
132
- } finally {
133
- store.close();
134
- rmSync(dir, { recursive: true, force: true });
135
- }
136
- });
137
-
138
- // ── TC-F-03-006:recoverMirror dirty+成功 → 恢复 clean,首试即中恰好一次 ────
139
-
140
- test("F-NEW-03: recoverMirror dirty→成功恢复 clean,首试即中、镜像收敛、返回 recovered", () => {
141
- const { dir, mirrorDir, store, mirror, service } = setup();
142
- try {
143
- // 制造 dirty(失败一次)
144
- const { original } = throwingMirrorSync(mirror);
145
- service.saveWithDedupe({ type: "project", title: "启动恢复", content: "Dirty 数据", importance: 3 });
146
- assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
147
- // 恢复后包计数,recoverMirror 首次尝试即成功 → sync 恰好一次
148
- mirror.sync = original;
149
- const counter = countingSync(mirror);
150
- const result = service.recoverMirror();
151
- assert.equal(result.recovered, true, "恢复成功必须返回 recovered=true");
152
- assert.equal(result.error, null);
153
- assert.equal(counter.calls(), 1, "首试即中:sync 必须只调一次");
154
- const state = service.getMirrorState();
155
- assert.equal(state.dirty, false, "恢复成功必须清 dirty");
156
- assert.equal(state.last_error, null);
157
- assert.ok(state.success_at, "恢复成功应写 success_at");
158
- const file = readFileSync(mirrorPath(mirrorDir, "project"), "utf8");
159
- assert.match(file, /启动恢复/, "recover 后镜像必须与 store 收敛");
160
- } finally {
161
- store.close();
162
- rmSync(dir, { recursive: true, force: true });
163
- }
164
- });
165
-
166
- // ── TC-F-03-007:recoverMirror 失败保持 dirty,有界重试后放弃,不抛 ─────────
167
-
168
- test("F-NEW-03: recoverMirror 仍失败 → dirty 保持、返回 recovered=false+error,不抛", () => {
169
- const { dir, store, mirror, service } = setup();
170
- try {
171
- const { original } = throwingMirrorSync(mirror);
172
- try {
173
- service.saveWithDedupe({ type: "project", title: "再失败", content: "x", importance: 3 });
174
- const dirtyState = service.getMirrorState();
175
- assert.equal(dirtyState.dirty, true, "前置:必须 dirty");
176
- const counter = countingSync(mirror); // 包装的仍是抛错 mock → 每次尝试都失败
177
- assert.doesNotThrow(() => service.recoverMirror(), "recoverMirror 失败也不得抛");
178
- const after = service.getMirrorState();
179
- assert.equal(after.dirty, true, "失败后 dirty 必须保持,供下次启动再试");
180
- assert.ok(after.last_error, "last_error 须有值");
181
- assert.ok(after.last_attempt >= dirtyState.last_attempt, "last_attempt 须更新");
182
- assert.equal(after.success_at, null);
183
- // 有界性:失败路径最多重试 MAX_ATTEMPTS(3) 次后放弃,不得无限循环
184
- assert.ok(counter.calls() <= 3, `recover 失败必须是有界重试,实际 ${counter.calls()} 次`);
185
- } finally {
186
- mirror.sync = original;
187
- }
188
- } finally {
189
- store.close();
190
- rmSync(dir, { recursive: true, force: true });
191
- }
192
- });
193
-
194
- // ── 有界性(Kimi 风险点 3):recoverMirror 重试次数精确 ≤3 ──────────────────
195
-
196
- test("F-NEW-03: recoverMirror 失败路径恰好重试 3 次后放弃(有界不无限)", () => {
197
- const { dir, store, mirror, service } = setup();
198
- try {
199
- const { original } = throwingMirrorSync(mirror);
200
- try {
201
- service.saveWithDedupe({ type: "project", title: "有界", content: "x", importance: 3 });
202
- assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
203
- const counter = countingSync(mirror);
204
- const result = service.recoverMirror();
205
- assert.equal(counter.calls(), 3, "失败路径必须恰好尝试 3 次(MAX_ATTEMPTS)");
206
- assert.equal(result.recovered, false, "3 次耗尽仍失败 → recovered=false");
207
- assert.ok(result.error, "必须携带失败原因 error");
208
- assert.equal(service.getMirrorState().dirty, true, "放弃后 dirty 保持,下次启动再试");
209
- } finally {
210
- mirror.sync = original;
211
- }
212
- } finally {
213
- store.close();
214
- rmSync(dir, { recursive: true, force: true });
215
- }
216
- });
217
-
218
- // ── TC-F-03-008:recoverMirror 不 dirty 时 no-op ────────────────────────────
219
-
220
- test("F-NEW-03: recoverMirror 不 dirty → 不重渲染、状态不变、返回 recovered=true", () => {
221
- const { dir, store, mirror, service } = setup();
222
- try {
223
- const counter = countingSync(mirror);
224
- const result = service.recoverMirror();
225
- assert.equal(counter.calls(), 0, "不 dirty 时必须 no-op,不调 sync");
226
- assert.equal(result.recovered, true, "本来干净 → recovered=true 且不调 sync");
227
- const state = service.getMirrorState();
228
- assert.equal(state.dirty, false, "状态不得改变");
229
- // 已有数据但 dirty=0 同样 no-op(saveWithDedupe 自身会同步一次,记录基线)
230
- service.saveWithDedupe({ type: "project", title: "干净数据", content: "x", importance: 3 });
231
- assert.equal(service.getMirrorState().dirty, false);
232
- const before = counter.calls();
233
- service.recoverMirror();
234
- assert.equal(counter.calls(), before, "干净状态下 recoverMirror 不得触发同步");
235
- } finally {
236
- store.close();
237
- rmSync(dir, { recursive: true, force: true });
238
- }
239
- });
240
-
241
- // ── TC-F-03-009:持久化跨重开(真实 DB 文件)───────────────────────────────
242
-
243
- test("F-NEW-03: markMirrorDirty 后关闭/重开 store → dirty 仍持久", () => {
244
- const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-dirty-"));
245
- const dbPath = join(dir, "memory.db");
246
- const mirrorDir = join(dir, "mirror");
247
- try {
248
- const store = createStore(dbPath);
249
- store.markMirrorDirty("persist error", "2026-08-16T00:00:00.000Z");
250
- store.close();
251
-
252
- const reopened = createStore(dbPath);
253
- const state = reopened.getMirrorState();
254
- assert.equal(state.dirty, true, "重开后 dirty 必须仍为 true");
255
- assert.equal(state.last_error, "persist error");
256
- assert.equal(state.last_attempt, "2026-08-16T00:00:00.000Z");
257
- reopened.close();
258
- } finally {
259
- rmSync(dir, { recursive: true, force: true });
260
- }
261
- });
262
-
263
- test("F-NEW-03: 重开后 recoverMirror 从持久 dirty 恢复 → clean 且再次重开仍 clean", () => {
264
- const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-dirty-"));
265
- const dbPath = join(dir, "memory.db");
266
- const mirrorDir = join(dir, "mirror");
267
- try {
268
- // 第一次"启动":写失败 → dirty 持久化
269
- let store = createStore(dbPath);
270
- let mirror = createMirror(mirrorDir);
271
- let service = createService({ store, mirror, config: {} });
272
- const { original } = throwingMirrorSync(mirror);
273
- service.saveWithDedupe({ type: "summary", title: "总览", content: "Dirty 内容", importance: 3 });
274
- assert.equal(service.getMirrorState().dirty, true);
275
- store.close();
276
-
277
- // 第二次"启动":index.js 同款 recoverMirror() → 恢复成功
278
- store = createStore(dbPath);
279
- mirror = createMirror(mirrorDir);
280
- service = createService({ store, mirror, config: {} });
281
- assert.equal(service.getMirrorState().dirty, true, "重开必须先看到 dirty");
282
- service.recoverMirror();
283
- assert.equal(service.getMirrorState().dirty, false, "recover 后必须清 dirty");
284
- assert.ok(service.getMirrorState().success_at);
285
- const file = readFileSync(mirrorPath(mirrorDir, "summary"), "utf8");
286
- assert.match(file, /Dirty 内容/, "镜像必须收敛");
287
- store.close();
288
-
289
- // 第三次打开:clean 状态持久
290
- store = createStore(dbPath);
291
- assert.equal(store.getMirrorState().dirty, false, "clean 也必须持久");
292
- store.close();
293
- } finally {
294
- rmSync(dir, { recursive: true, force: true });
295
- }
296
- });
297
-
298
- // ── TC-F-03-010 / 011:health 端点反映真实 mirror 状态 ──────────────────────
299
-
300
- class FakeRes extends EventEmitter {
301
- constructor() { super(); this.statusCode = 200; this.body = ""; }
302
- writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
303
- end(text) { this.body = text ?? ""; this.emit("end"); return this; }
304
- }
305
-
306
- function apiSetup(store, service) {
307
- const settings = createSettings(store.db);
308
- const commands = {
309
- add: (def) => settings.addCommand(def),
310
- remove: (id) => settings.removeCommand(id),
311
- list: () => settings.listCommands()
312
- };
313
- const routes = [];
314
- const ctx = {
315
- webServer: { register(route) { routes.push(route); return () => {}; } }
316
- };
317
- createApi(ctx, service, settings, commands, undefined, undefined, "");
318
- const health = routes.find((r) => r.path === "/api/dsh-mneme/health");
319
- return { health };
320
- }
321
-
322
- test("F-NEW-03: health 端点 dirty 时返回 dirty=true 及 last_error/last_attempt", () => {
323
- const { dir, store, mirror, service } = setup();
324
- try {
325
- const { health } = apiSetup(store, service);
326
- assert.ok(health, "必须注册 /api/dsh-mneme/health");
327
- const { original } = throwingMirrorSync(mirror);
328
- try {
329
- service.saveWithDedupe({ type: "project", title: "脏", content: "x", importance: 3 });
330
- } finally {
331
- mirror.sync = original;
332
- }
333
- assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
334
- const res = new FakeRes();
335
- health.handler({ url: "/api/dsh-mneme/health", method: "GET", headers: {} }, res);
336
- assert.equal(res.statusCode, 200);
337
- const body = JSON.parse(res.body);
338
- assert.equal(body.mirror.dirty, true, "health 必须反映真实 dirty(布尔)");
339
- assert.equal(body.mirror.status, "degraded", "dirty 时 health 必须为 degraded");
340
- // v0.3.6 脱敏:只回脱敏错误码,不回显原始 last_error
341
- assert.equal(body.mirror.last_error, "no-space", "last_error 必须脱敏为 no-space");
342
- assert.ok(!body.mirror.last_error.includes("No space left on device"),
343
- "原始错误串不得出现在 health 响应中");
344
- assert.ok(body.mirror.last_attempt, "last_attempt 须返回");
345
- assert.equal(body.mirror.success_at, null);
346
- } finally {
347
- store.close();
348
- rmSync(dir, { recursive: true, force: true });
349
- }
350
- });
351
-
352
- test("F-NEW-03: health 端点 clean 后返回 dirty=false 且 success_at 有值", () => {
353
- const { dir, store, mirror, service } = setup();
354
- try {
355
- const { health } = apiSetup(store, service);
356
- // 制造 dirty 后恢复成功 → clean
357
- const { original } = throwingMirrorSync(mirror);
358
- service.saveWithDedupe({ type: "project", title: "清", content: "x", importance: 3 });
359
- mirror.sync = original;
360
- service.saveWithDedupe({ type: "project", title: "清2", content: "y", importance: 2 });
361
- assert.equal(service.getMirrorState().dirty, false, "前置:必须 clean");
362
- const res = new FakeRes();
363
- health.handler({ url: "/api/dsh-mneme/health", method: "GET", headers: {} }, res);
364
- assert.equal(res.statusCode, 200);
365
- const body = JSON.parse(res.body);
366
- assert.equal(body.mirror.dirty, false, "health 必须反映 clean");
367
- assert.equal(body.mirror.last_error, null);
368
- assert.ok(body.mirror.success_at, "success_at 须返回");
369
- } finally {
370
- store.close();
371
- rmSync(dir, { recursive: true, force: true });
372
- }
373
- });
374
-
375
- // ── TC-F-03-004/005:状态写入自身失败 → syncMirror 仍不抛 ───────────────────
376
-
377
- test("F-NEW-03: markMirrorDirty 自身抛错 → syncMirror 不抛、只 warn", () => {
378
- const { dir, store, mirror, service, warns } = setup();
379
- try {
380
- const { original } = throwingMirrorSync(mirror);
381
- const origDirty = store.markMirrorDirty.bind(store);
382
- try {
383
- store.markMirrorDirty = () => { throw new Error("db locked"); };
384
- assert.doesNotThrow(() => {
385
- service.saveWithDedupe({ type: "project", title: "fail-safe", content: "x", importance: 3 });
386
- }, "markMirrorDirty 失败不得使 syncMirror 外抛");
387
- assert.ok(warns.some((w) => /markMirrorDirty failed|db locked/.test(w)), "应走 logger.warn");
388
- assert.equal(store.count(), 1, "store 写入不受影响");
389
- } finally {
390
- store.markMirrorDirty = origDirty;
391
- mirror.sync = original;
392
- }
393
- } finally {
394
- store.close();
395
- rmSync(dir, { recursive: true, force: true });
396
- }
397
- });
398
-
399
- test("F-NEW-03: markMirrorCleanForGeneration 自身抛错 → syncMirror 不抛、只 warn", () => {
400
- const { dir, store, mirror, service, warns } = setup();
401
- try {
402
- const { original } = throwingMirrorSync(mirror);
403
- const origClean = store.markMirrorCleanForGeneration.bind(store);
404
- try {
405
- // 制造 dirty
406
- service.saveWithDedupe({ type: "project", title: "前置脏", content: "x", importance: 3 });
407
- assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
408
- // 恢复 sync + 让 markMirrorCleanForGeneration 抛错(v0.3.6 成功路径的 CAS 写)
409
- mirror.sync = original;
410
- store.markMirrorCleanForGeneration = () => { throw new Error("clean write fail"); };
411
- assert.doesNotThrow(() => {
412
- service.saveWithDedupe({ type: "project", title: "成功路径", content: "y", importance: 2 });
413
- }, "markMirrorCleanForGeneration 失败不得使 syncMirror 外抛");
414
- assert.ok(warns.some((w) => /markMirrorCleanForGeneration failed|clean write fail/.test(w)), "应走 logger.warn");
415
- assert.equal(store.count(), 2, "store 写入不受影响");
416
- } finally {
417
- store.markMirrorCleanForGeneration = origClean;
418
- mirror.sync = original;
419
- }
420
- } finally {
421
- store.close();
422
- rmSync(dir, { recursive: true, force: true });
423
- }
424
- });
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { EventEmitter } from "node:events";
7
+ import { createStore } from "../src/store.js";
8
+ import { createMirror, TYPE_FILE } from "../src/mirror.js";
9
+ import { createService } from "../src/service.js";
10
+ import { createApi } from "../src/api.js";
11
+ import { createSettings } from "../src/settings.js";
12
+
13
+ /**
14
+ * F-NEW-03 回归测试(audit peer 发现的:syncMirror 失败无持久 dirty 状态)。
15
+ *
16
+ * 修复内容:
17
+ * - store.js 新增 mirror_state 表 + set/getMirrorState + markMirrorDirty/Clean
18
+ * - service.syncMirror 失败写 dirty(dirty=1+last_error+last_attempt)、成功写
19
+ * clean(dirty=0+last_error=null+success_at);状态写入各自 try/catch 不向外抛
20
+ * - service.recoverMirror() 启动时 dirty 则做有界重试(最多 3 次)收敛镜像,
21
+ * 仍失败保持 dirty 下次再试;返回 { recovered, error },绝不向外抛
22
+ * - service.getMirrorHealth() 供 api.js /api/dsh-mneme/health 使用
23
+ * - index.js 启动时调 recoverMirror()
24
+ *
25
+ * 测试点由 Kimi K2.7 设计(TC-F-03-001~011)。
26
+ * 注意:getMirrorState().dirty 是布尔(row.dirty===1 转换),不是 1。
27
+ */
28
+
29
+ function setup({ dbPath } = {}) {
30
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-dirty-"));
31
+ const mirrorDir = join(dir, "mirror");
32
+ const store = createStore(dbPath ?? ":memory:");
33
+ const mirror = createMirror(mirrorDir);
34
+ const warns = [];
35
+ const logger = { warn: (...a) => warns.push(a.join(" ")) };
36
+ const service = createService({ store, mirror, config: {}, logger });
37
+ return { dir, mirrorDir, store, mirror, service, warns };
38
+ }
39
+
40
+ function mirrorPath(dir, type) {
41
+ return join(dir, TYPE_FILE[type]);
42
+ }
43
+
44
+ /** mock mirror.sync 抛错(沿用 fnew-0112 的 throwingMirrorSync 模式) */
45
+ function throwingMirrorSync(mirror, message = "No space left on device") {
46
+ const original = mirror.sync.bind(mirror);
47
+ const err = new Error(message);
48
+ err.code = "ENOSPC";
49
+ mirror.sync = () => { throw err; };
50
+ return { original, err };
51
+ }
52
+
53
+ /** 包一层计数 + 透传的 mirror.sync */
54
+ function countingSync(mirror) {
55
+ let calls = 0;
56
+ const original = mirror.sync.bind(mirror);
57
+ mirror.sync = (...args) => { calls++; return original(...args); };
58
+ return { calls: () => calls, restore: () => { mirror.sync = original; } };
59
+ }
60
+
61
+ // ── TC-F-03-001:getMirrorState 的 dirty 是布尔契约 ─────────────────────────
62
+
63
+ test("F-NEW-03: markMirrorDirty → getMirrorState().dirty 是布尔 true,字段齐全", () => {
64
+ const { dir, store, mirror, service } = setup();
65
+ try {
66
+ const now = new Date().toISOString();
67
+ store.markMirrorDirty("probe error", now);
68
+ const state = service.getMirrorState();
69
+ assert.equal(typeof state.dirty, "boolean", "dirty 必须是布尔(不是 1)");
70
+ assert.equal(state.dirty, true);
71
+ assert.equal(state.last_error, "probe error");
72
+ assert.equal(state.last_attempt, now);
73
+ assert.equal(state.success_at, null, "失败后不得写 success_at");
74
+ // 空表默认形状
75
+ store.clearMirrorDirty();
76
+ const clean = store.getMirrorState();
77
+ assert.equal(clean.dirty, false);
78
+ assert.equal(clean.last_error, null);
79
+ } finally {
80
+ store.close();
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ });
84
+
85
+ // ── TC-F-03-003:syncMirror 失败持久化 dirty,且不向外抛 ────────────────────
86
+
87
+ test("F-NEW-03: syncMirror 失败 → dirty=1+last_error+last_attempt,不抛", () => {
88
+ const { dir, store, mirror, service, warns } = setup();
89
+ try {
90
+ const { original, err } = throwingMirrorSync(mirror);
91
+ try {
92
+ assert.doesNotThrow(() => {
93
+ service.saveWithDedupe({ type: "project", title: "写脏", content: "x", importance: 3 });
94
+ }, "syncMirror 失败不得向外抛");
95
+ const state = service.getMirrorState();
96
+ assert.equal(state.dirty, true, "失败必须持久 dirty");
97
+ assert.equal(state.last_error, err.message, "last_error 必须记录错误消息");
98
+ assert.ok(state.last_attempt, "last_attempt 必须有值");
99
+ assert.ok(Number.isFinite(new Date(state.last_attempt).getTime()), "last_attempt 须为合法时间");
100
+ assert.equal(state.success_at, null, "失败不得写 success_at");
101
+ assert.ok(warns.length >= 1, "失败应走 logger.warn");
102
+ } finally {
103
+ mirror.sync = original;
104
+ }
105
+ } finally {
106
+ store.close();
107
+ rmSync(dir, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ // ── TC-F-03-002:syncMirror 成功清 dirty 并记录 success_at ──────────────────
112
+
113
+ test("F-NEW-03: syncMirror 成功 → dirty=0+last_error=null+success_at,镜像收敛", () => {
114
+ const { dir, mirrorDir, store, mirror, service } = setup();
115
+ try {
116
+ // 先制造 dirty
117
+ const { original } = throwingMirrorSync(mirror);
118
+ const { memory } = service.saveWithDedupe({ type: "project", title: "收敛目标", content: "第一版", importance: 3 });
119
+ assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
120
+ // 恢复 sync,下一次写应清 dirty 且镜像收敛
121
+ mirror.sync = original;
122
+ service.saveWithDedupe({ type: "project", title: "后续", content: "第二版", importance: 2 });
123
+ const state = service.getMirrorState();
124
+ assert.equal(state.dirty, false, "成功后必须清 dirty");
125
+ assert.equal(state.last_error, null, "last_error 必须清空");
126
+ assert.ok(state.success_at, "success_at 必须有值");
127
+ assert.ok(Number.isFinite(new Date(state.success_at).getTime()), "success_at 须为合法时间");
128
+ const file = readFileSync(mirrorPath(mirrorDir, "project"), "utf8");
129
+ assert.match(file, /收敛目标/, "失败后的下一次同步必须收敛镜像");
130
+ assert.match(file, /第二版/, "后续写入也必须上镜像");
131
+ assert.ok(store.getById(memory.id), "store 数据不受影响");
132
+ } finally {
133
+ store.close();
134
+ rmSync(dir, { recursive: true, force: true });
135
+ }
136
+ });
137
+
138
+ // ── TC-F-03-006:recoverMirror dirty+成功 → 恢复 clean,首试即中恰好一次 ────
139
+
140
+ test("F-NEW-03: recoverMirror dirty→成功恢复 clean,首试即中、镜像收敛、返回 recovered", () => {
141
+ const { dir, mirrorDir, store, mirror, service } = setup();
142
+ try {
143
+ // 制造 dirty(失败一次)
144
+ const { original } = throwingMirrorSync(mirror);
145
+ service.saveWithDedupe({ type: "project", title: "启动恢复", content: "Dirty 数据", importance: 3 });
146
+ assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
147
+ // 恢复后包计数,recoverMirror 首次尝试即成功 → sync 恰好一次
148
+ mirror.sync = original;
149
+ const counter = countingSync(mirror);
150
+ const result = service.recoverMirror();
151
+ assert.equal(result.recovered, true, "恢复成功必须返回 recovered=true");
152
+ assert.equal(result.error, null);
153
+ assert.equal(counter.calls(), 1, "首试即中:sync 必须只调一次");
154
+ const state = service.getMirrorState();
155
+ assert.equal(state.dirty, false, "恢复成功必须清 dirty");
156
+ assert.equal(state.last_error, null);
157
+ assert.ok(state.success_at, "恢复成功应写 success_at");
158
+ const file = readFileSync(mirrorPath(mirrorDir, "project"), "utf8");
159
+ assert.match(file, /启动恢复/, "recover 后镜像必须与 store 收敛");
160
+ } finally {
161
+ store.close();
162
+ rmSync(dir, { recursive: true, force: true });
163
+ }
164
+ });
165
+
166
+ // ── TC-F-03-007:recoverMirror 失败保持 dirty,有界重试后放弃,不抛 ─────────
167
+
168
+ test("F-NEW-03: recoverMirror 仍失败 → dirty 保持、返回 recovered=false+error,不抛", () => {
169
+ const { dir, store, mirror, service } = setup();
170
+ try {
171
+ const { original } = throwingMirrorSync(mirror);
172
+ try {
173
+ service.saveWithDedupe({ type: "project", title: "再失败", content: "x", importance: 3 });
174
+ const dirtyState = service.getMirrorState();
175
+ assert.equal(dirtyState.dirty, true, "前置:必须 dirty");
176
+ const counter = countingSync(mirror); // 包装的仍是抛错 mock → 每次尝试都失败
177
+ assert.doesNotThrow(() => service.recoverMirror(), "recoverMirror 失败也不得抛");
178
+ const after = service.getMirrorState();
179
+ assert.equal(after.dirty, true, "失败后 dirty 必须保持,供下次启动再试");
180
+ assert.ok(after.last_error, "last_error 须有值");
181
+ assert.ok(after.last_attempt >= dirtyState.last_attempt, "last_attempt 须更新");
182
+ assert.equal(after.success_at, null);
183
+ // 有界性:失败路径最多重试 MAX_ATTEMPTS(3) 次后放弃,不得无限循环
184
+ assert.ok(counter.calls() <= 3, `recover 失败必须是有界重试,实际 ${counter.calls()} 次`);
185
+ } finally {
186
+ mirror.sync = original;
187
+ }
188
+ } finally {
189
+ store.close();
190
+ rmSync(dir, { recursive: true, force: true });
191
+ }
192
+ });
193
+
194
+ // ── 有界性(Kimi 风险点 3):recoverMirror 重试次数精确 ≤3 ──────────────────
195
+
196
+ test("F-NEW-03: recoverMirror 失败路径恰好重试 3 次后放弃(有界不无限)", () => {
197
+ const { dir, store, mirror, service } = setup();
198
+ try {
199
+ const { original } = throwingMirrorSync(mirror);
200
+ try {
201
+ service.saveWithDedupe({ type: "project", title: "有界", content: "x", importance: 3 });
202
+ assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
203
+ const counter = countingSync(mirror);
204
+ const result = service.recoverMirror();
205
+ assert.equal(counter.calls(), 3, "失败路径必须恰好尝试 3 次(MAX_ATTEMPTS)");
206
+ assert.equal(result.recovered, false, "3 次耗尽仍失败 → recovered=false");
207
+ assert.ok(result.error, "必须携带失败原因 error");
208
+ assert.equal(service.getMirrorState().dirty, true, "放弃后 dirty 保持,下次启动再试");
209
+ } finally {
210
+ mirror.sync = original;
211
+ }
212
+ } finally {
213
+ store.close();
214
+ rmSync(dir, { recursive: true, force: true });
215
+ }
216
+ });
217
+
218
+ // ── TC-F-03-008:recoverMirror 不 dirty 时 no-op ────────────────────────────
219
+
220
+ test("F-NEW-03: recoverMirror 不 dirty → 不重渲染、状态不变、返回 recovered=true", () => {
221
+ const { dir, store, mirror, service } = setup();
222
+ try {
223
+ const counter = countingSync(mirror);
224
+ const result = service.recoverMirror();
225
+ assert.equal(counter.calls(), 0, "不 dirty 时必须 no-op,不调 sync");
226
+ assert.equal(result.recovered, true, "本来干净 → recovered=true 且不调 sync");
227
+ const state = service.getMirrorState();
228
+ assert.equal(state.dirty, false, "状态不得改变");
229
+ // 已有数据但 dirty=0 同样 no-op(saveWithDedupe 自身会同步一次,记录基线)
230
+ service.saveWithDedupe({ type: "project", title: "干净数据", content: "x", importance: 3 });
231
+ assert.equal(service.getMirrorState().dirty, false);
232
+ const before = counter.calls();
233
+ service.recoverMirror();
234
+ assert.equal(counter.calls(), before, "干净状态下 recoverMirror 不得触发同步");
235
+ } finally {
236
+ store.close();
237
+ rmSync(dir, { recursive: true, force: true });
238
+ }
239
+ });
240
+
241
+ // ── TC-F-03-009:持久化跨重开(真实 DB 文件)───────────────────────────────
242
+
243
+ test("F-NEW-03: markMirrorDirty 后关闭/重开 store → dirty 仍持久", () => {
244
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-dirty-"));
245
+ const dbPath = join(dir, "memory.db");
246
+ const mirrorDir = join(dir, "mirror");
247
+ try {
248
+ const store = createStore(dbPath);
249
+ store.markMirrorDirty("persist error", "2026-08-16T00:00:00.000Z");
250
+ store.close();
251
+
252
+ const reopened = createStore(dbPath);
253
+ const state = reopened.getMirrorState();
254
+ assert.equal(state.dirty, true, "重开后 dirty 必须仍为 true");
255
+ assert.equal(state.last_error, "persist error");
256
+ assert.equal(state.last_attempt, "2026-08-16T00:00:00.000Z");
257
+ reopened.close();
258
+ } finally {
259
+ rmSync(dir, { recursive: true, force: true });
260
+ }
261
+ });
262
+
263
+ test("F-NEW-03: 重开后 recoverMirror 从持久 dirty 恢复 → clean 且再次重开仍 clean", () => {
264
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-dirty-"));
265
+ const dbPath = join(dir, "memory.db");
266
+ const mirrorDir = join(dir, "mirror");
267
+ try {
268
+ // 第一次"启动":写失败 → dirty 持久化
269
+ let store = createStore(dbPath);
270
+ let mirror = createMirror(mirrorDir);
271
+ let service = createService({ store, mirror, config: {} });
272
+ const { original } = throwingMirrorSync(mirror);
273
+ service.saveWithDedupe({ type: "summary", title: "总览", content: "Dirty 内容", importance: 3 });
274
+ assert.equal(service.getMirrorState().dirty, true);
275
+ store.close();
276
+
277
+ // 第二次"启动":index.js 同款 recoverMirror() → 恢复成功
278
+ store = createStore(dbPath);
279
+ mirror = createMirror(mirrorDir);
280
+ service = createService({ store, mirror, config: {} });
281
+ assert.equal(service.getMirrorState().dirty, true, "重开必须先看到 dirty");
282
+ service.recoverMirror();
283
+ assert.equal(service.getMirrorState().dirty, false, "recover 后必须清 dirty");
284
+ assert.ok(service.getMirrorState().success_at);
285
+ const file = readFileSync(mirrorPath(mirrorDir, "summary"), "utf8");
286
+ assert.match(file, /Dirty 内容/, "镜像必须收敛");
287
+ store.close();
288
+
289
+ // 第三次打开:clean 状态持久
290
+ store = createStore(dbPath);
291
+ assert.equal(store.getMirrorState().dirty, false, "clean 也必须持久");
292
+ store.close();
293
+ } finally {
294
+ rmSync(dir, { recursive: true, force: true });
295
+ }
296
+ });
297
+
298
+ // ── TC-F-03-010 / 011:health 端点反映真实 mirror 状态 ──────────────────────
299
+
300
+ class FakeRes extends EventEmitter {
301
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
302
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
303
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
304
+ }
305
+
306
+ function apiSetup(store, service) {
307
+ const settings = createSettings(store.db);
308
+ const commands = {
309
+ add: (def) => settings.addCommand(def),
310
+ remove: (id) => settings.removeCommand(id),
311
+ list: () => settings.listCommands()
312
+ };
313
+ const routes = [];
314
+ const ctx = {
315
+ webServer: { register(route) { routes.push(route); return () => {}; } }
316
+ };
317
+ createApi(ctx, service, settings, commands, undefined, undefined, "");
318
+ const health = routes.find((r) => r.path === "/api/dsh-mneme/health");
319
+ return { health };
320
+ }
321
+
322
+ test("F-NEW-03: health 端点 dirty 时返回 dirty=true 及 last_error/last_attempt", () => {
323
+ const { dir, store, mirror, service } = setup();
324
+ try {
325
+ const { health } = apiSetup(store, service);
326
+ assert.ok(health, "必须注册 /api/dsh-mneme/health");
327
+ const { original } = throwingMirrorSync(mirror);
328
+ try {
329
+ service.saveWithDedupe({ type: "project", title: "脏", content: "x", importance: 3 });
330
+ } finally {
331
+ mirror.sync = original;
332
+ }
333
+ assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
334
+ const res = new FakeRes();
335
+ health.handler({ url: "/api/dsh-mneme/health", method: "GET", headers: {} }, res);
336
+ assert.equal(res.statusCode, 200);
337
+ const body = JSON.parse(res.body);
338
+ assert.equal(body.mirror.dirty, true, "health 必须反映真实 dirty(布尔)");
339
+ assert.equal(body.mirror.status, "degraded", "dirty 时 health 必须为 degraded");
340
+ // v0.3.6 脱敏:只回脱敏错误码,不回显原始 last_error
341
+ assert.equal(body.mirror.last_error, "no-space", "last_error 必须脱敏为 no-space");
342
+ assert.ok(!body.mirror.last_error.includes("No space left on device"),
343
+ "原始错误串不得出现在 health 响应中");
344
+ assert.ok(body.mirror.last_attempt, "last_attempt 须返回");
345
+ assert.equal(body.mirror.success_at, null);
346
+ } finally {
347
+ store.close();
348
+ rmSync(dir, { recursive: true, force: true });
349
+ }
350
+ });
351
+
352
+ test("F-NEW-03: health 端点 clean 后返回 dirty=false 且 success_at 有值", () => {
353
+ const { dir, store, mirror, service } = setup();
354
+ try {
355
+ const { health } = apiSetup(store, service);
356
+ // 制造 dirty 后恢复成功 → clean
357
+ const { original } = throwingMirrorSync(mirror);
358
+ service.saveWithDedupe({ type: "project", title: "清", content: "x", importance: 3 });
359
+ mirror.sync = original;
360
+ service.saveWithDedupe({ type: "project", title: "清2", content: "y", importance: 2 });
361
+ assert.equal(service.getMirrorState().dirty, false, "前置:必须 clean");
362
+ const res = new FakeRes();
363
+ health.handler({ url: "/api/dsh-mneme/health", method: "GET", headers: {} }, res);
364
+ assert.equal(res.statusCode, 200);
365
+ const body = JSON.parse(res.body);
366
+ assert.equal(body.mirror.dirty, false, "health 必须反映 clean");
367
+ assert.equal(body.mirror.last_error, null);
368
+ assert.ok(body.mirror.success_at, "success_at 须返回");
369
+ } finally {
370
+ store.close();
371
+ rmSync(dir, { recursive: true, force: true });
372
+ }
373
+ });
374
+
375
+ // ── TC-F-03-004/005:状态写入自身失败 → syncMirror 仍不抛 ───────────────────
376
+
377
+ test("F-NEW-03: markMirrorDirty 自身抛错 → syncMirror 不抛、只 warn", () => {
378
+ const { dir, store, mirror, service, warns } = setup();
379
+ try {
380
+ const { original } = throwingMirrorSync(mirror);
381
+ const origDirty = store.markMirrorDirty.bind(store);
382
+ try {
383
+ store.markMirrorDirty = () => { throw new Error("db locked"); };
384
+ assert.doesNotThrow(() => {
385
+ service.saveWithDedupe({ type: "project", title: "fail-safe", content: "x", importance: 3 });
386
+ }, "markMirrorDirty 失败不得使 syncMirror 外抛");
387
+ assert.ok(warns.some((w) => /markMirrorDirty failed|db locked/.test(w)), "应走 logger.warn");
388
+ assert.equal(store.count(), 1, "store 写入不受影响");
389
+ } finally {
390
+ store.markMirrorDirty = origDirty;
391
+ mirror.sync = original;
392
+ }
393
+ } finally {
394
+ store.close();
395
+ rmSync(dir, { recursive: true, force: true });
396
+ }
397
+ });
398
+
399
+ test("F-NEW-03: markMirrorCleanForGeneration 自身抛错 → syncMirror 不抛、只 warn", () => {
400
+ const { dir, store, mirror, service, warns } = setup();
401
+ try {
402
+ const { original } = throwingMirrorSync(mirror);
403
+ const origClean = store.markMirrorCleanForGeneration.bind(store);
404
+ try {
405
+ // 制造 dirty
406
+ service.saveWithDedupe({ type: "project", title: "前置脏", content: "x", importance: 3 });
407
+ assert.equal(service.getMirrorState().dirty, true, "前置:必须 dirty");
408
+ // 恢复 sync + 让 markMirrorCleanForGeneration 抛错(v0.3.6 成功路径的 CAS 写)
409
+ mirror.sync = original;
410
+ store.markMirrorCleanForGeneration = () => { throw new Error("clean write fail"); };
411
+ assert.doesNotThrow(() => {
412
+ service.saveWithDedupe({ type: "project", title: "成功路径", content: "y", importance: 2 });
413
+ }, "markMirrorCleanForGeneration 失败不得使 syncMirror 外抛");
414
+ assert.ok(warns.some((w) => /markMirrorCleanForGeneration failed|clean write fail/.test(w)), "应走 logger.warn");
415
+ assert.equal(store.count(), 2, "store 写入不受影响");
416
+ } finally {
417
+ store.markMirrorCleanForGeneration = origClean;
418
+ mirror.sync = original;
419
+ }
420
+ } finally {
421
+ store.close();
422
+ rmSync(dir, { recursive: true, force: true });
423
+ }
424
+ });