@modusensus/dsh-mneme 0.8.15 → 0.8.16
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/README.md +8 -3
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +14 -2
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +171 -3
- package/lib/client.js +1065 -156
- package/lib/config.js +68 -1
- package/lib/dream/decisions.js +20 -0
- package/lib/dream.js +2 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +24 -7
- package/lib/inject.js +27 -63
- package/lib/maintenance.js +25 -5
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/service.js +27 -2
- package/lib/settings.js +13 -2
- package/lib/store.js +196 -7
- package/lib/summarize.js +208 -60
- package/package.json +1 -1
- package/src/api-standalone.js +22 -39
- package/src/api.js +171 -3
- package/src/config.js +68 -1
- package/src/dream/decisions.js +20 -0
- package/src/dream.js +2 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +24 -7
- package/src/inject.js +27 -63
- package/src/maintenance.js +25 -5
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/service.js +27 -2
- package/src/settings.js +13 -2
- package/src/store.js +196 -7
- package/src/summarize.js +208 -60
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +25 -2
- package/test/client.test.js +354 -2
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +39 -0
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +98 -0
- package/test/maintenance.test.js +112 -2
- package/test/pre-inject-gate.test.js +10 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/service.test.js +7 -2
- package/test/settings.test.js +8 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +174 -0
package/test/inject.test.js
CHANGED
|
@@ -418,3 +418,26 @@ test("issue #334: snapshot query carries no lone surrogate (query path cut at ex
|
|
|
418
418
|
assert.ok(snap.query.length > 0, "query recorded");
|
|
419
419
|
assert.ok(!hasLoneSurrogate(snap.query), "snapshot query is surrogate-clean");
|
|
420
420
|
});
|
|
421
|
+
|
|
422
|
+
// surface.nodes 是宿主 getter:消息投影所属插件被移除时,dsh-session 的
|
|
423
|
+
// _assertProjections 直接 throw。读取不设防的话,异常会冒到调用方的 try,表现为
|
|
424
|
+
// 热记忆整块不渲染 + 语义查询为空——比「沿用可见面失效前的旧行为」更糟。
|
|
425
|
+
// 夹具用宿主真实的抛错文案,锁住「拿不到可见面就退回全量事件」这条退化路径。
|
|
426
|
+
test("throwing surface getter degrades to full-log hot memory instead of a blank block", () => {
|
|
427
|
+
const { contexts } = setup();
|
|
428
|
+
const events = [
|
|
429
|
+
{ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "把投影插件卸了会怎样" }] } },
|
|
430
|
+
{ type: "assistant/message", data: { message: { content: [{ type: "text", text: "热记忆不该因此整块消失" }] } } }
|
|
431
|
+
];
|
|
432
|
+
const session = {
|
|
433
|
+
snapshotEvents: () => events,
|
|
434
|
+
get surface() {
|
|
435
|
+
throw new Error('session message projection "dsh-rewind" was removed or replaced; restore the session with its owning plugin');
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
const text = contexts[0].text({ agent: { session } });
|
|
439
|
+
assert.ok(text.includes("[短期上下文]"), "热记忆块仍应渲染");
|
|
440
|
+
assert.ok(text.includes("把投影插件卸了会怎样"), "轮次提问应照旧进入");
|
|
441
|
+
assert.ok(text.includes("热记忆不该因此整块消失"), "轮次回复应照旧进入");
|
|
442
|
+
assert.equal(getInjectionSnapshot().query, "把投影插件卸了会怎样", "语义查询不受 getter 抛错影响");
|
|
443
|
+
});
|
package/test/llm-audit.test.js
CHANGED
|
@@ -468,6 +468,67 @@ test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates by source and statu
|
|
|
468
468
|
store.close();
|
|
469
469
|
});
|
|
470
470
|
|
|
471
|
+
// by_operation / by_day 是面板「token 去向」环形图与 7 天柱状的数据源。
|
|
472
|
+
// 锁三件事:① 环形图直接按返回顺序画段,所以 by_operation 必须按
|
|
473
|
+
// total_tokens 降序、by_day 必须按 day 升序(前端不再排);② 每行带完整的
|
|
474
|
+
// in/out/total 拆分(by_source 只有 total,画不出输入/输出对照);
|
|
475
|
+
// ③ write_admission 只计量不调 LLM,token 恒 0 是设计——它必须作为一行
|
|
476
|
+
// 出现且为 0,防的是将来有人把 0 行过滤掉或当成异常补齐。
|
|
477
|
+
test("GET /api/dsh-mneme/semantic/llm-audit/stats breaks spend down by operation and by day", async () => {
|
|
478
|
+
const { store, service, routes } = apiSetup();
|
|
479
|
+
const day1 = new Date(Date.now() - 2 * 86400000).toISOString();
|
|
480
|
+
const day2 = new Date(Date.now() - 1 * 86400000).toISOString();
|
|
481
|
+
service.saveLlmAudit({
|
|
482
|
+
timestamp: day1,
|
|
483
|
+
trigger_source: "autoDream", operation_type: "dream_consolidate",
|
|
484
|
+
model_id: "m", input_tokens: 100, output_tokens: 40, status: "success"
|
|
485
|
+
});
|
|
486
|
+
service.saveLlmAudit({
|
|
487
|
+
timestamp: day1,
|
|
488
|
+
trigger_source: "writeAdmission", operation_type: "write_admission",
|
|
489
|
+
model_id: "m", input_tokens: 0, output_tokens: 0, status: "success"
|
|
490
|
+
});
|
|
491
|
+
service.saveLlmAudit({
|
|
492
|
+
timestamp: day2,
|
|
493
|
+
trigger_source: "autoDream", operation_type: "dream_consolidate",
|
|
494
|
+
model_id: "m", input_tokens: 10, output_tokens: 5, status: "success"
|
|
495
|
+
});
|
|
496
|
+
service.saveLlmAudit({
|
|
497
|
+
timestamp: day2,
|
|
498
|
+
trigger_source: "autoSummarize", operation_type: "summarize_compress",
|
|
499
|
+
model_id: "m", input_tokens: 30, output_tokens: 10, status: "success"
|
|
500
|
+
});
|
|
501
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
|
|
502
|
+
const res = new FakeRes();
|
|
503
|
+
await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
|
|
504
|
+
const stats = JSON.parse(res.body);
|
|
505
|
+
assert.equal(res.statusCode, 200);
|
|
506
|
+
|
|
507
|
+
// by_operation:total_tokens 降序,含 in/out/total 拆分
|
|
508
|
+
assert.deepEqual(
|
|
509
|
+
stats.by_operation.map((o) => o.operation),
|
|
510
|
+
["dream_consolidate", "summarize_compress", "write_admission"]
|
|
511
|
+
);
|
|
512
|
+
const consolidate = stats.by_operation[0];
|
|
513
|
+
assert.equal(consolidate.c, 2);
|
|
514
|
+
assert.equal(consolidate.input_tokens, 110);
|
|
515
|
+
assert.equal(consolidate.output_tokens, 45);
|
|
516
|
+
assert.equal(consolidate.total_tokens, 155);
|
|
517
|
+
const admission = stats.by_operation.find((o) => o.operation === "write_admission");
|
|
518
|
+
assert.equal(admission.c, 1);
|
|
519
|
+
assert.equal(admission.total_tokens, 0);
|
|
520
|
+
|
|
521
|
+
// by_day:date(timestamp)(UTC)分组,day 升序
|
|
522
|
+
const d1 = day1.slice(0, 10);
|
|
523
|
+
const d2 = day2.slice(0, 10);
|
|
524
|
+
assert.deepEqual(stats.by_day.map((d) => d.day), [d1, d2]);
|
|
525
|
+
assert.equal(stats.by_day[0].c, 2);
|
|
526
|
+
assert.equal(stats.by_day[0].total_tokens, 140);
|
|
527
|
+
assert.equal(stats.by_day[1].c, 2);
|
|
528
|
+
assert.equal(stats.by_day[1].total_tokens, 55);
|
|
529
|
+
store.close();
|
|
530
|
+
});
|
|
531
|
+
|
|
471
532
|
// ------------------------------------------- entity extraction (issue #250)
|
|
472
533
|
// 实体抽取是「每次写入记忆都会跑」的后台 LLM 链路,但 createEntityStreamAdapter
|
|
473
534
|
// 只拿到 llm 句柄、没有 service,所以从来没有写审计的能力——面板因此回答不了
|
|
@@ -596,3 +657,40 @@ test("boot retention purge reads the assembled config, not rawCfg", async () =>
|
|
|
596
657
|
after.close();
|
|
597
658
|
assert.equal(left, 0, "面板开着的审计必须按 cfg 清理,否则审计行只增不减");
|
|
598
659
|
});
|
|
660
|
+
|
|
661
|
+
// #399 的启动期裁剪同理:留存开关与保留期都要取装配后的 cfg。这里额外锁一条
|
|
662
|
+
// 「默认关就不动手」——留存是 opt-in,默认关时一张空表不该被每次启动扫一遍。
|
|
663
|
+
test("boot purge trims expired distill records only when retention is on (issue #399)", async () => {
|
|
664
|
+
const dir = mkdtempSync(join(tmpdir(), "mneme-distill-retention-"));
|
|
665
|
+
const dbPath = join(dir, "memory.db");
|
|
666
|
+
|
|
667
|
+
const seed = createStore(dbPath);
|
|
668
|
+
createSettings(seed.db).setFeatureFlags({ "distillRecords.enabled": true, "distillRecords.retentionDays": 1 });
|
|
669
|
+
seed.saveDistillRecords([
|
|
670
|
+
{
|
|
671
|
+
sessionId: "s-boot",
|
|
672
|
+
seq: 1,
|
|
673
|
+
role: "user",
|
|
674
|
+
text: "过期原文",
|
|
675
|
+
windowId: "s-boot:1",
|
|
676
|
+
distilledAt: new Date(Date.now() - 2 * 86400000).toISOString()
|
|
677
|
+
}
|
|
678
|
+
]);
|
|
679
|
+
assert.equal(seed.db.prepare("SELECT count(*) AS c FROM distill_records").get().c, 1, "先放一行过期原文");
|
|
680
|
+
seed.close();
|
|
681
|
+
|
|
682
|
+
const ctx = new Context();
|
|
683
|
+
ctx.provide("tools", { register() { return () => {}; } });
|
|
684
|
+
ctx.provide("commands", { register() { return () => {}; } });
|
|
685
|
+
ctx.provide("systemPrompt", { context() { return () => {}; } });
|
|
686
|
+
ctx.provide("agentDefaultModel", { currentSelection() { return { provider: "mock", model: "mock" }; } });
|
|
687
|
+
ctx.provide("llm", { async *stream() { yield { type: "finish", reason: { kind: "stop" } }; } });
|
|
688
|
+
ctx.provide("webServer", { register() { return () => {}; } });
|
|
689
|
+
// bundle 说关、面板说开(保留期 1 天,那行已过期 2 天):只有读装配后的 cfg 才会清掉
|
|
690
|
+
await ctx.plugin(mneme, { memoryDir: dir, distillRecords: { enabled: false, retentionDays: 1 } });
|
|
691
|
+
|
|
692
|
+
const after = new DatabaseSync(dbPath);
|
|
693
|
+
const left = after.prepare("SELECT count(*) AS c FROM distill_records").get().c;
|
|
694
|
+
after.close();
|
|
695
|
+
assert.equal(left, 0, "面板开着的留存必须按 cfg 裁剪,否则原文只增不减");
|
|
696
|
+
});
|
package/test/maintenance.test.js
CHANGED
|
@@ -102,8 +102,8 @@ test("dry-run:报出将清理的行数与列文本大小,一个字节都不
|
|
|
102
102
|
|
|
103
103
|
assert.equal(report.dryRun, true);
|
|
104
104
|
assert.equal(report.olderThanDays, 7, "默认保留窗口 7 天");
|
|
105
|
-
assert.deepEqual(report.planned, { dream_run_inputs: 1, archived_embeddings: 1 });
|
|
106
|
-
assert.deepEqual(report.cleared, { dream_run_inputs: 0, archived_embeddings: 0 });
|
|
105
|
+
assert.deepEqual(report.planned, { dream_run_inputs: 1, archived_embeddings: 1, distill_records: 0 });
|
|
106
|
+
assert.deepEqual(report.cleared, { dream_run_inputs: 0, archived_embeddings: 0, distill_records: 0 });
|
|
107
107
|
assert.ok(report.column_bytes.dream_run_inputs > 600, "列文本大小要给真实数字(它是上界)");
|
|
108
108
|
assert.equal(typeof report.size.before_bytes, "number");
|
|
109
109
|
assert.equal(report.vacuum.ran, false);
|
|
@@ -116,6 +116,41 @@ test("dry-run:报出将清理的行数与列文本大小,一个字节都不
|
|
|
116
116
|
}
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
test("dry-run 与执行:蒸馏原文进报告,裁剪只删原文、溯源链接留住(#399)", () => {
|
|
120
|
+
const { store, close } = fileStore();
|
|
121
|
+
try {
|
|
122
|
+
seed(store);
|
|
123
|
+
const old = new Date(NOW - 30 * DAY).toISOString();
|
|
124
|
+
const fresh = new Date(NOW - 1 * DAY).toISOString();
|
|
125
|
+
store.saveDistillRecords([
|
|
126
|
+
{ sessionId: "s-old", seq: 1, role: "user", text: "z".repeat(600), windowId: "s-old:1", distilledAt: old },
|
|
127
|
+
{ sessionId: "s-fresh", seq: 1, role: "user", text: "新窗口的原文", windowId: "s-fresh:1", distilledAt: fresh }
|
|
128
|
+
]);
|
|
129
|
+
const memoryId = store.all()[0].id;
|
|
130
|
+
store.saveMemoryProvenance({ memoryId, sessionId: "s-old", seqStart: 1, seqEnd: 1, createdAt: old });
|
|
131
|
+
|
|
132
|
+
// dry-run:第三项要报出真实行数与列文本大小(上界),且一个字节都不改
|
|
133
|
+
const dry = maintenanceFor(store).reclaim({ dryRun: true });
|
|
134
|
+
assert.equal(dry.planned.distill_records, 1, "只有窗口外那行算数");
|
|
135
|
+
assert.ok(dry.column_bytes.distill_records >= 600, "蒸馏原文的列文本大小要给真实数字");
|
|
136
|
+
assert.equal(store.db.prepare("SELECT count(*) AS c FROM distill_records").get().c, 2, "dry-run 不删原文");
|
|
137
|
+
|
|
138
|
+
const applied = maintenanceFor(store).reclaim({ dryRun: false });
|
|
139
|
+
assert.equal(applied.cleared.distill_records, 1);
|
|
140
|
+
const rows = store.db.prepare("SELECT session_id FROM distill_records").all();
|
|
141
|
+
assert.deepEqual(rows.map((r) => r.session_id), ["s-fresh"], "窗口内原文留住");
|
|
142
|
+
// 裁剪只删原文:链接与记忆都留着,面板/CLI 显示「来源已过期」而不是查无此记忆。
|
|
143
|
+
assert.equal(
|
|
144
|
+
store.db.prepare("SELECT count(*) AS c FROM memory_provenance").get().c,
|
|
145
|
+
1,
|
|
146
|
+
"溯源链接刻意保留,指向已空区间"
|
|
147
|
+
);
|
|
148
|
+
assert.equal(rawMemoryCount(store), 2, "裁剪原文不删记忆");
|
|
149
|
+
} finally {
|
|
150
|
+
close();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
119
154
|
test("执行:只清窗口外的输入快照,run 的骨架与决策原文留住", () => {
|
|
120
155
|
const { store, close } = fileStore();
|
|
121
156
|
try {
|
|
@@ -191,6 +226,8 @@ test("VACUUM 失败不回吞:清理照做、报告与回执都带上失败原
|
|
|
191
226
|
clearDreamRunInputs: (b) => store.clearDreamRunInputs(b),
|
|
192
227
|
archivedEmbeddingStats: () => store.archivedEmbeddingStats(),
|
|
193
228
|
clearArchivedEmbeddings: () => store.clearArchivedEmbeddings(),
|
|
229
|
+
distillRecordStats: (b) => store.distillRecordStats(b),
|
|
230
|
+
clearDistillRecords: (b) => store.clearDistillRecords(b),
|
|
194
231
|
storageStats: () => store.storageStats(),
|
|
195
232
|
saveLlmAudit: (e) => store.saveLlmAudit(e),
|
|
196
233
|
vacuum: () => { throw new Error("SQLITE_BUSY: database is locked"); }
|
|
@@ -389,3 +426,76 @@ test("CLI:--vacuum 单独用会被拒(dry-run 没有可回收的页),也
|
|
|
389
426
|
assert.equal(result.status, 1);
|
|
390
427
|
assert.match(result.stderr, /--vacuum 需要配 --apply/);
|
|
391
428
|
});
|
|
429
|
+
|
|
430
|
+
test("HTTP 面(数据面):窗口上界与确认门里的抛错都跟面板侧同口径(#411)", async () => {
|
|
431
|
+
// 这张脸曾是面板路由的整段复制,于是 #410 修好面板侧之后它停在旧形态:#508 没有
|
|
432
|
+
// 上界(1e20 打进来 → maintenance 算日期抛 RangeError → 一个字节都不回),#523 的
|
|
433
|
+
// confirm-required 报告也没兜错。现在两处口径都来自 src/reclaim-http.js,这条锁从
|
|
434
|
+
// **数据面自己的端口**打真 HTTP,证明它真的走的是同一份。
|
|
435
|
+
const { store, close } = fileStore();
|
|
436
|
+
const maintenance = maintenanceFor(store);
|
|
437
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
438
|
+
const settings = createSettings(store.db);
|
|
439
|
+
const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0, maintenance });
|
|
440
|
+
await api.ready;
|
|
441
|
+
const url = `http://127.0.0.1:${api.port}/maintenance/reclaim`;
|
|
442
|
+
const post = (body) =>
|
|
443
|
+
fetch(url, {
|
|
444
|
+
method: "POST",
|
|
445
|
+
headers: { authorization: `Bearer ${api.token}`, "content-type": "application/json" },
|
|
446
|
+
body: JSON.stringify(body)
|
|
447
|
+
});
|
|
448
|
+
// 第二个实例:不传 maintenance(「没接线」的那一档)。在 try 之外建、在 finally 里
|
|
449
|
+
// 关——它要是漏关,node --test 会挂着一个开着的 server 句柄永不退出(变异检验里
|
|
450
|
+
// 一旦断言先炸就正好踩这条)。
|
|
451
|
+
const unplugged = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
|
|
452
|
+
await unplugged.ready;
|
|
453
|
+
try {
|
|
454
|
+
const { archived } = seed(store);
|
|
455
|
+
|
|
456
|
+
// 顺序也是契约:端点整个没接线时,报「窗口不合法」是误导——调用方会去改窗口,
|
|
457
|
+
// 改完照样 503。所以 503 必须赢过字段校验(面板侧同一条,见 api-routes-gaps.test.js)。
|
|
458
|
+
const unpluggedRes = await fetch(`http://127.0.0.1:${unplugged.port}/maintenance/reclaim`, {
|
|
459
|
+
method: "POST",
|
|
460
|
+
headers: { authorization: `Bearer ${unplugged.token}`, "content-type": "application/json" },
|
|
461
|
+
body: JSON.stringify({ olderThanDays: 1e20 })
|
|
462
|
+
});
|
|
463
|
+
assert.equal(unpluggedRes.status, 503, "没接线要赢过字段校验");
|
|
464
|
+
assert.equal((await unpluggedRes.json()).error, "maintenance-unavailable");
|
|
465
|
+
|
|
466
|
+
// 量级超界:旧形态下这一发既不改数据、也不回任何字节。
|
|
467
|
+
for (const huge of [3651, 1e20]) {
|
|
468
|
+
const out = await post({ dryRun: false, olderThanDays: huge });
|
|
469
|
+
assert.equal(out.status, 400, `olderThanDays=${huge} 必须被拒,而不是把连接挂住`);
|
|
470
|
+
assert.equal((await out.json()).error, "invalid-older-than-days");
|
|
471
|
+
}
|
|
472
|
+
const atLimit = await post({ olderThanDays: 3650 });
|
|
473
|
+
assert.equal(atLimit.status, 200, "上界本身合法");
|
|
474
|
+
assert.equal((await atLimit.json()).olderThanDays, 3650);
|
|
475
|
+
assert.ok(rawVec(store, archived.id), "被拒的那几发一个字节都不能改");
|
|
476
|
+
|
|
477
|
+
// 确认门的报告要描述「将要执行的那个动作」,且窗口是调用方问的那个。
|
|
478
|
+
const refused = await post({ dryRun: false, olderThanDays: 30, vacuum: true });
|
|
479
|
+
assert.equal(refused.status, 400);
|
|
480
|
+
const refusedBody = await refused.json();
|
|
481
|
+
assert.equal(refusedBody.error, "confirm-required");
|
|
482
|
+
assert.equal(refusedBody.report.olderThanDays, 30, "报告必须说的是被问的那个窗口");
|
|
483
|
+
assert.equal(refusedBody.report.vacuum.requested, true, "报告必须描述将要执行的那个动作(含 vacuum)");
|
|
484
|
+
assert.ok(rawVec(store, archived.id), "缺 confirm 时一个字节都不改");
|
|
485
|
+
|
|
486
|
+
const applied = await post({ dryRun: false, confirm: true, vacuum: true });
|
|
487
|
+
assert.equal(applied.status, 200);
|
|
488
|
+
const appliedBody = await applied.json();
|
|
489
|
+
assert.equal(appliedBody.olderThanDays, 7, "不传窗口就是默认 7 天");
|
|
490
|
+
assert.equal(appliedBody.cleared.dream_run_inputs, 1);
|
|
491
|
+
assert.equal(appliedBody.vacuum.requested, true, "勾了 VACUUM 就必须真带上");
|
|
492
|
+
assert.equal(appliedBody.vacuum.ran, true);
|
|
493
|
+
assert.equal(rawVec(store, archived.id), null);
|
|
494
|
+
} finally {
|
|
495
|
+
unplugged.server.closeIdleConnections?.();
|
|
496
|
+
unplugged.server.close();
|
|
497
|
+
api.server.closeIdleConnections?.();
|
|
498
|
+
api.server.close();
|
|
499
|
+
close();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
@@ -365,6 +365,16 @@ test("lightMode:预设翻转 preInjectGate.enabled 且不污染用户配置对
|
|
|
365
365
|
assert.equal(flat.heatEnabled, false, "平铺键照旧直赋(既有行为回归)");
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
+
test("lightMode:预设关掉 distillRecords.enabled,但保留用户显式设的 retentionDays(#399)", () => {
|
|
369
|
+
// 轻量模式关的是「留存原文」这个成本/隐私面;retentionDays 是保留窗口的时长,
|
|
370
|
+
// 不在 LIGHT_MODE_OFF 里——用户显式设过就照旧,别把配置顺手抹成默认值。
|
|
371
|
+
const user = { lightMode: true, distillRecords: { enabled: true, retentionDays: 30 } };
|
|
372
|
+
const preset = applyLightModePreset(user);
|
|
373
|
+
assert.equal(preset.distillRecords.enabled, false, "轻量模式必须关掉原始记录留存");
|
|
374
|
+
assert.equal(preset.distillRecords.retentionDays, 30, "保留窗口时长不受轻量模式影响");
|
|
375
|
+
assert.equal(user.distillRecords.enabled, true, "不改到用户原对象");
|
|
376
|
+
});
|
|
377
|
+
|
|
368
378
|
test("判定超时:abort 底层流(真宿主守约)→ degraded 审计 + 不缓存 + 下一帧重试(CodeRabbit on #382)", async () => {
|
|
369
379
|
const llm = fakeLlm([{ hang: true }, { json: { ids: [], reason: "recovered" } }]);
|
|
370
380
|
const s = setup({ preInjectGate: { enabled: true } }, llm, { judgeTimeoutMs: 50 });
|
|
@@ -124,21 +124,106 @@ test("windowDays/exemptDays 越界回默认;扫描超上限标 truncated", ()
|
|
|
124
124
|
|
|
125
125
|
// ============================ 第五指标(#275):归档净增速率 + 可压掉行数
|
|
126
126
|
|
|
127
|
-
test("归档净增速率:按
|
|
127
|
+
test("归档净增速率:按 archived_at 取归档时刻,窗外归档只进总数不进净增", () => {
|
|
128
128
|
const { store, service } = setup();
|
|
129
129
|
const fresh = service.saveWithDedupe({ type: "history", title: "刚归档", content: "x", importance: 3 }).memory;
|
|
130
130
|
const old = service.saveWithDedupe({ type: "history", title: "早归档", content: "y", importance: 3 }).memory;
|
|
131
131
|
service.saveWithDedupe({ type: "history", title: "活跃件", content: "z", importance: 3 });
|
|
132
132
|
store.setArchived(fresh.id, true);
|
|
133
133
|
store.setArchived(old.id, true);
|
|
134
|
-
//
|
|
135
|
-
store.db.prepare("UPDATE memories SET
|
|
134
|
+
// 精确口径:归档时刻是 archived_at 列(#275 第二批),把这行推到窗口外
|
|
135
|
+
store.db.prepare("UPDATE memories SET archived_at = ? WHERE id = ?")
|
|
136
136
|
.run(new Date(Date.now() - 100 * 86400000).toISOString(), old.id);
|
|
137
137
|
|
|
138
138
|
const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
|
|
139
139
|
assert.equal(stats.archive.total, 2, "归档区现有行数(活跃件不算)");
|
|
140
140
|
assert.equal(stats.archive.addedInWindow, 1, "窗外那次归档不算本窗口净增");
|
|
141
141
|
assert.equal(stats.archive.perDay, Math.round((1 / 30) * 100) / 100);
|
|
142
|
+
assert.equal(stats.archive.undated, 0, "两行都有 archived_at,都不算时刻不可追溯");
|
|
143
|
+
store.close();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("回归锁:归档后再 memory_update 刷 updated_at,不得算作本窗口新进归档", () => {
|
|
147
|
+
// 本批的核心回归锁。代理口径下归档时刻取 updated_at(setArchived 刷它),于是
|
|
148
|
+
// 「对已归档行做一次普通更新」就会把旧归档算成窗口内新进,perDay 偏高——这正是
|
|
149
|
+
// #275 第二批要消灭的假阳性。任何人把 recall-stats 改回 Date.parse(m.updated_at)
|
|
150
|
+
// 或改回 `m.archived_at ?? m.updated_at`,本用例立刻失败。
|
|
151
|
+
const { store, service } = setup();
|
|
152
|
+
const m = service.saveWithDedupe({ type: "history", title: "早归档", content: "x", importance: 3 }).memory;
|
|
153
|
+
store.setArchived(m.id, true);
|
|
154
|
+
store.db.prepare("UPDATE memories SET archived_at = ? WHERE id = ?")
|
|
155
|
+
.run(new Date(Date.now() - 100 * 86400000).toISOString(), m.id);
|
|
156
|
+
// 已归档行上的一次普通更新:updated_at 被刷到当下,archived_at 必须岿然不动
|
|
157
|
+
store.update(m.id, { content: "改过一遍" });
|
|
158
|
+
const row = store.getById(m.id);
|
|
159
|
+
assert.ok(
|
|
160
|
+
Date.parse(row.updated_at) > Date.now() - 60000,
|
|
161
|
+
"前提成立:update 确实把 updated_at 刷到了当下(否则本用例是恒真的)"
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
|
|
165
|
+
assert.equal(stats.archive.total, 1);
|
|
166
|
+
assert.equal(stats.archive.addedInWindow, 0, "旧归档不因一次 update 变成窗口新进");
|
|
167
|
+
assert.equal(stats.archive.perDay, 0);
|
|
168
|
+
store.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("取消归档清空 archived_at;再次归档重新落新时刻", () => {
|
|
172
|
+
const { store } = setup();
|
|
173
|
+
const m = store.save({ type: "history", title: "t", content: "c" });
|
|
174
|
+
const archived = store.setArchived(m.id, true);
|
|
175
|
+
assert.ok(archived.archived_at, "活跃 → 归档:落当前时刻");
|
|
176
|
+
|
|
177
|
+
const restored = store.setArchived(m.id, false);
|
|
178
|
+
assert.equal(restored.archived, false);
|
|
179
|
+
assert.equal(restored.archived_at, undefined, "取消归档清 NULL(toRow 读作 undefined,不是陈旧时刻)");
|
|
180
|
+
|
|
181
|
+
const again = store.setArchived(m.id, true);
|
|
182
|
+
assert.ok(again.archived_at, "重新归档重新落时刻");
|
|
183
|
+
assert.notEqual(again.archived_at, archived.archived_at, "是新一次归档,不是复用旧时刻");
|
|
184
|
+
store.close();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("幂等:重复归档不刷新 archived_at(否则重复调用又变成一次净增)", () => {
|
|
188
|
+
// 代理口径被推翻的两个假阳性之一就是「对同一行重复调 setArchived(true)」。精确
|
|
189
|
+
// 口径下重复归档不是新一次归档,时刻保持首次值;刷新它就等于把旧毛病换个入口复活。
|
|
190
|
+
const { store } = setup();
|
|
191
|
+
const m = store.save({ type: "history", title: "t", content: "c" });
|
|
192
|
+
const first = store.setArchived(m.id, true);
|
|
193
|
+
const second = store.setArchived(m.id, true);
|
|
194
|
+
const third = store.setArchived(m.id, true);
|
|
195
|
+
assert.equal(second.archived_at, first.archived_at);
|
|
196
|
+
assert.equal(third.archived_at, first.archived_at);
|
|
197
|
+
store.close();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("存量归档行(archived_at 为 NULL)计入 undated,不静默顶替成窗口新进", () => {
|
|
201
|
+
// NULL = 本列落地前就已归档的行,归档时刻不可追溯。用 updated_at 顶替就是退回被
|
|
202
|
+
// 推翻的代理口径;按 0 充数则是把「不知道」伪装成「没有」。这里锁的是第三种做法:
|
|
203
|
+
// 单独计数、不计入 addedInWindow。
|
|
204
|
+
const { store, service } = setup();
|
|
205
|
+
const m = service.saveWithDedupe({ type: "history", title: "存量归档", content: "x", importance: 3 }).memory;
|
|
206
|
+
store.setArchived(m.id, true);
|
|
207
|
+
store.db.prepare("UPDATE memories SET archived_at = NULL WHERE id = ?").run(m.id);
|
|
208
|
+
|
|
209
|
+
const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
|
|
210
|
+
assert.equal(stats.archive.total, 1);
|
|
211
|
+
assert.equal(stats.archive.addedInWindow, 0, "时刻不可追溯 → 不算窗口新进");
|
|
212
|
+
assert.equal(stats.archive.undated, 1, "单独暴露,不伪装成 0");
|
|
213
|
+
store.close();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("口径升级不波及其他四项指标:归档行照旧不进活跃 / 僵尸 / 豁免", () => {
|
|
217
|
+
const { store, service } = setup();
|
|
218
|
+
service.saveWithDedupe({ type: "project", title: "活跃", content: "x", importance: 3 });
|
|
219
|
+
const arch = service.saveWithDedupe({ type: "project", title: "归档", content: "y", importance: 3 }).memory;
|
|
220
|
+
store.setArchived(arch.id, true);
|
|
221
|
+
|
|
222
|
+
const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
|
|
223
|
+
assert.equal(stats.zombie.activeCount, 1, "归档行不算活跃");
|
|
224
|
+
assert.equal(stats.zombie.zombieCount, 1, "零曝光的活跃行照旧算僵尸");
|
|
225
|
+
assert.equal(stats.zombie.exemptCount, 0);
|
|
226
|
+
assert.equal(stats.archive.total, 1);
|
|
142
227
|
store.close();
|
|
143
228
|
});
|
|
144
229
|
|
|
@@ -183,7 +268,7 @@ test("没有归档行时给 0,不返回 null / NaN", () => {
|
|
|
183
268
|
service.saveWithDedupe({ type: "project", title: "只有活跃", content: "x", importance: 3 });
|
|
184
269
|
const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
|
|
185
270
|
assert.deepEqual(stats.archive, {
|
|
186
|
-
total: 0, addedInWindow: 0, perDay: 0, compressible: { rows: 0, groups: 0 }
|
|
271
|
+
total: 0, addedInWindow: 0, perDay: 0, undated: 0, compressible: { rows: 0, groups: 0 }
|
|
187
272
|
});
|
|
188
273
|
store.close();
|
|
189
274
|
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// 存储回收(maintenance/reclaim)的 HTTP 口径 —— 共用模块自己的锁。
|
|
2
|
+
//
|
|
3
|
+
// 这个文件盯两类回归:
|
|
4
|
+
// a) 口径本身(解析、校验、答复形状)。表驱动跑全量边界,因为这张脸有两个入口,
|
|
5
|
+
// 任何一处口径松掉都会同时污染面板与数据面;
|
|
6
|
+
// b) **不许再长出第二份实现**。历史上两个入口是整段复制的,于是 #410 修好面板侧
|
|
7
|
+
// 之后数据面还停在旧形态(#411)。下面的静态锁把「校验字面量只许出现在本模块」
|
|
8
|
+
// 写成断言:谁要是再复制一段出去,这里直接转红。
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import {
|
|
15
|
+
MAX_RECLAIM_WINDOW_DAYS,
|
|
16
|
+
RECLAIM_CONFIRM_HINT,
|
|
17
|
+
parseReclaimQuery,
|
|
18
|
+
parseReclaimJson,
|
|
19
|
+
readReclaimFields,
|
|
20
|
+
reclaimReply
|
|
21
|
+
} from "../src/reclaim-http.js";
|
|
22
|
+
|
|
23
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
|
|
25
|
+
/** 假 maintenance:记下每次调用的实参,回一个固定报告对象(用来锁「不加包装层」)。 */
|
|
26
|
+
function fakeMaintenance(report = { dryRun: true, planned: { dream_run_inputs: 1 } }) {
|
|
27
|
+
const calls = [];
|
|
28
|
+
return {
|
|
29
|
+
calls,
|
|
30
|
+
reclaim(options) {
|
|
31
|
+
calls.push(options);
|
|
32
|
+
return report;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// GET 侧:query 是字符串域
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
test("parseReclaimQuery: 只收十进制整数字面量,缺省才落默认窗口", () => {
|
|
41
|
+
assert.deepEqual(parseReclaimQuery(null), { ok: true, olderThanDays: undefined }, "不传 = 落默认窗口");
|
|
42
|
+
assert.deepEqual(parseReclaimQuery("0"), { ok: true, olderThanDays: 0 }, "0 是合法值(清全部快照)");
|
|
43
|
+
assert.deepEqual(parseReclaimQuery("3"), { ok: true, olderThanDays: 3 });
|
|
44
|
+
assert.deepEqual(parseReclaimQuery(String(MAX_RECLAIM_WINDOW_DAYS)), { ok: true, olderThanDays: MAX_RECLAIM_WINDOW_DAYS }, "上界本身合法");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("parseReclaimQuery: 畸形输入一律 400,绝不被静默读成 0", () => {
|
|
48
|
+
// Number() 会把 ""/[]/" " 都化成 0,而 0 在这里是「清掉全部快照」——最坏解释绝不能
|
|
49
|
+
// 从一个畸形输入静默拿到。所以这里的每一条都必须拒,而不是回落。
|
|
50
|
+
for (const bad of ["", " ", "3.5", "-1", "1e3", "0x10", "٣", "3 ", "+3", "abc"]) {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
parseReclaimQuery(bad),
|
|
53
|
+
{ ok: false, error: "invalid-older-than-days" },
|
|
54
|
+
`olderThanDays=${JSON.stringify(bad)} 必须被拒`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("parseReclaimQuery: 量级超界被拒(十进制字面量不保证是个能算日期的数)", () => {
|
|
60
|
+
// 400 位十进制字面量会化成 Infinity,maintenance 拿它算日期直接抛 RangeError。
|
|
61
|
+
for (const huge of [String(MAX_RECLAIM_WINDOW_DAYS + 1), "100000000000000000000", "9".repeat(400)]) {
|
|
62
|
+
assert.deepEqual(parseReclaimQuery(huge), { ok: false, error: "invalid-older-than-days" }, `olderThanDays=${huge.slice(0, 12)}… 必须被拒`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// POST 侧:JSON 数字域
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
test("parseReclaimJson: invalid-json 与 invalid-body 是两个不同的错", () => {
|
|
70
|
+
assert.deepEqual(parseReclaimJson(""), { ok: true, body: {} }, "空 body 等价于 {}");
|
|
71
|
+
assert.deepEqual(parseReclaimJson('{"a":1}'), { ok: true, body: { a: 1 } });
|
|
72
|
+
assert.deepEqual(parseReclaimJson("{bad"), { ok: false, error: "invalid-json" }, "发坏了");
|
|
73
|
+
assert.deepEqual(parseReclaimJson("null"), { ok: false, error: "invalid-body" }, "发成了 null");
|
|
74
|
+
assert.deepEqual(parseReclaimJson("[]"), { ok: false, error: "invalid-body" }, "发成了数组");
|
|
75
|
+
assert.deepEqual(parseReclaimJson('"x"'), { ok: false, error: "invalid-body" }, "发成了字符串");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("readReclaimFields: 默认值与透传", () => {
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
readReclaimFields({}),
|
|
81
|
+
{ ok: true, dryRun: true, olderThanDays: undefined, vacuum: false, confirm: false },
|
|
82
|
+
"缺省 = dry-run、默认窗口、不 VACUUM、未确认"
|
|
83
|
+
);
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
readReclaimFields({ dryRun: false, olderThanDays: 30, vacuum: true, confirm: true }),
|
|
86
|
+
{ ok: true, dryRun: false, olderThanDays: 30, vacuum: true, confirm: true }
|
|
87
|
+
);
|
|
88
|
+
assert.deepEqual(readReclaimFields({ olderThanDays: null }).olderThanDays, undefined, "null 与缺省同义:落默认窗口,不是 0");
|
|
89
|
+
assert.deepEqual(readReclaimFields({ olderThanDays: 0 }).olderThanDays, 0, "0 是合法值");
|
|
90
|
+
assert.deepEqual(readReclaimFields({ olderThanDays: MAX_RECLAIM_WINDOW_DAYS }).olderThanDays, MAX_RECLAIM_WINDOW_DAYS);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("readReclaimFields: 只收 JSON 数字,量级与形状都要过", () => {
|
|
94
|
+
for (const bogus of ["", [], false, "3", true, 3.5, -1]) {
|
|
95
|
+
assert.deepEqual(
|
|
96
|
+
readReclaimFields({ olderThanDays: bogus }),
|
|
97
|
+
{ ok: false, error: "invalid-older-than-days" },
|
|
98
|
+
`olderThanDays=${JSON.stringify(bogus)} 必须被拒`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
// 上界与 GET 侧同一条:1e20 也是「整数」,但 maintenance 拿它算日期会抛 RangeError。
|
|
102
|
+
for (const huge of [MAX_RECLAIM_WINDOW_DAYS + 1, 1e20, 1e21]) {
|
|
103
|
+
assert.deepEqual(readReclaimFields({ olderThanDays: huge }), { ok: false, error: "invalid-older-than-days" });
|
|
104
|
+
}
|
|
105
|
+
assert.deepEqual(readReclaimFields({ vacuum: "true" }), { ok: false, error: "invalid-vacuum" }, "vacuum 只收布尔");
|
|
106
|
+
assert.equal(readReclaimFields({ confirm: "true" }).confirm, false, "confirm 只认真布尔:字符串不算确认");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// 答复:两个入口共用的那一份
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
test("reclaimReply: 没接线时 503,且根本不碰 maintenance", () => {
|
|
113
|
+
const out = reclaimReply({ maintenance: null, dryRun: true });
|
|
114
|
+
// 字面量而不是常量:这是调用方要分支的错误码,改一个字母就是对外行为变更。
|
|
115
|
+
assert.deepEqual(out, { status: 503, body: { error: "maintenance-unavailable" } });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("reclaimReply: dry-run 报原样透传,不加包装层", () => {
|
|
119
|
+
const report = { dryRun: true, planned: { dream_run_inputs: 1 }, olderThanDays: 7 };
|
|
120
|
+
const maintenance = fakeMaintenance(report);
|
|
121
|
+
const out = reclaimReply({ maintenance, dryRun: true, olderThanDays: 7 });
|
|
122
|
+
assert.equal(out.status, 200);
|
|
123
|
+
assert.equal(out.body, report, "报告必须原样回,套一层 {report} 会打断既有消费方");
|
|
124
|
+
assert.deepEqual(maintenance.calls, [{ olderThanDays: 7, vacuum: false, dryRun: true }]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("reclaimReply: 缺 confirm 只回报告不执行,且报告描述的是将要执行的那个动作", () => {
|
|
128
|
+
const report = { dryRun: true, vacuum: { requested: true, ran: false } };
|
|
129
|
+
const maintenance = fakeMaintenance(report);
|
|
130
|
+
const out = reclaimReply({ maintenance, dryRun: false, olderThanDays: 30, vacuum: true });
|
|
131
|
+
assert.equal(out.status, 400);
|
|
132
|
+
assert.equal(out.body.error, "confirm-required");
|
|
133
|
+
assert.equal(out.body.hint, RECLAIM_CONFIRM_HINT, "提示是给调用方看的下一步动作,两个入口必须逐字一致");
|
|
134
|
+
assert.equal(out.body.report, report);
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
maintenance.calls,
|
|
137
|
+
[{ olderThanDays: 30, vacuum: true, dryRun: true }],
|
|
138
|
+
"报告必须带 vacuum:否则调用方看到的是「不 VACUUM」的数字,却带着 vacuum 去执行"
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("reclaimReply: 确认后按原样执行", () => {
|
|
143
|
+
const maintenance = fakeMaintenance({ dryRun: false, cleared: { dream_run_inputs: 1 } });
|
|
144
|
+
const out = reclaimReply({ maintenance, dryRun: false, olderThanDays: 30, vacuum: true, confirm: true });
|
|
145
|
+
assert.equal(out.status, 200);
|
|
146
|
+
assert.deepEqual(maintenance.calls, [{ olderThanDays: 30, vacuum: true, dryRun: false }]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("reclaimReply: 抛错回 500 并留日志,原始错误文本不能吞", () => {
|
|
150
|
+
const warnings = [];
|
|
151
|
+
const maintenance = {
|
|
152
|
+
reclaim() { throw new Error("database is locked"); }
|
|
153
|
+
};
|
|
154
|
+
for (const input of [
|
|
155
|
+
{ dryRun: true },
|
|
156
|
+
{ dryRun: false, confirm: true },
|
|
157
|
+
{ dryRun: false } // confirm-required 那份报告也是这里算的(#410)
|
|
158
|
+
]) {
|
|
159
|
+
const out = reclaimReply({ maintenance, ...input, warn: (m) => warnings.push(String(m)) });
|
|
160
|
+
assert.equal(out.status, 500, "三条会调 reclaim 的路径都必须回 500,不能无响应");
|
|
161
|
+
assert.deepEqual(out.body, { error: "internal" });
|
|
162
|
+
}
|
|
163
|
+
assert.equal(warnings.length, 3);
|
|
164
|
+
for (const w of warnings) {
|
|
165
|
+
assert.match(w, /storage reclaim failed/);
|
|
166
|
+
assert.match(w, /database is locked/, "原始错误文本要留住,否则现场无从下手");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("reclaimReply: 没有 logger 也不许把异常漏出去", () => {
|
|
171
|
+
// warn 是可选的(数据面可能没接 logger)。少一个回调不能让异常穿出去变成
|
|
172
|
+
// unhandled rejection——那正是 #410 的形态:客户端拿到的是「一个字节都没回」。
|
|
173
|
+
const maintenance = { reclaim() { throw new Error("boom"); } };
|
|
174
|
+
const out = reclaimReply({ maintenance, dryRun: false });
|
|
175
|
+
assert.equal(out.status, 500);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// 防分叉:口径只许有一份实现
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
test("两个入口都不许再自己写一份回收口径", () => {
|
|
182
|
+
// 每个字面量都是这张脸独有的:出现第二次就意味着又复制了一段校验/答复出去。
|
|
183
|
+
const FORKED = [
|
|
184
|
+
'"invalid-older-than-days"',
|
|
185
|
+
'"invalid-vacuum"',
|
|
186
|
+
'"confirm-required"',
|
|
187
|
+
'"maintenance-unavailable"',
|
|
188
|
+
"storage reclaim failed",
|
|
189
|
+
"MAX_RECLAIM_WINDOW_DAYS"
|
|
190
|
+
];
|
|
191
|
+
for (const name of ["api.js", "api-standalone.js"]) {
|
|
192
|
+
const src = readFileSync(join(ROOT, "src", name), "utf8");
|
|
193
|
+
for (const literal of FORKED) {
|
|
194
|
+
assert.equal(
|
|
195
|
+
src.includes(literal),
|
|
196
|
+
false,
|
|
197
|
+
`src/${name} 里不该再出现 ${literal}——回收口径只有 src/reclaim-http.js 一份`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
// 反向也要锁:光删掉字面量、不调共用模块,等于把功能删了。
|
|
201
|
+
assert.ok(src.includes("reclaim-http.js"), `src/${name} 必须从共用模块取口径`);
|
|
202
|
+
}
|
|
203
|
+
const panel = readFileSync(join(ROOT, "src", "api.js"), "utf8");
|
|
204
|
+
const data = readFileSync(join(ROOT, "src", "api-standalone.js"), "utf8");
|
|
205
|
+
assert.ok(panel.includes("reclaimReply("), "面板侧要真的走共用答复");
|
|
206
|
+
assert.ok(data.includes("reclaimReply("), "数据面要真的走共用答复");
|
|
207
|
+
assert.ok(panel.includes("readReclaimFields("), "面板侧 POST 要真的走共用校验");
|
|
208
|
+
assert.ok(data.includes("readReclaimFields("), "数据面要真的走共用校验");
|
|
209
|
+
});
|
package/test/service.test.js
CHANGED
|
@@ -53,7 +53,10 @@ test("injectCandidates returns preferences + high-importance items within limit"
|
|
|
53
53
|
const types = candidates.map((c) => c.type);
|
|
54
54
|
assert.ok(types.includes("preference"), "preferences included");
|
|
55
55
|
assert.ok(types.includes("project"), "high-importance project included");
|
|
56
|
-
|
|
56
|
+
// #369:history 曾被注入白名单挡在门外,现按「注入侧收编」进池(理由与实测数字
|
|
57
|
+
// 见 src/service.js 的 INJECT_TYPES 注释)。这里从「锁排除」改成「锁收编」——
|
|
58
|
+
// 白名单日后若被收窄,这条会红,而不是静默把两成条目关回不可注入。
|
|
59
|
+
assert.ok(types.includes("history"), "history included since #369");
|
|
57
60
|
assert.ok(candidates.length <= 5, "respects maxItems");
|
|
58
61
|
});
|
|
59
62
|
|
|
@@ -220,7 +223,9 @@ test("Bug4: injectCandidates with an empty query keeps the legacy rule-based sel
|
|
|
220
223
|
assert.deepEqual(noQuery.map((c) => c.id), legacy.map((c) => c.id), "empty query behaves identically to the old logic");
|
|
221
224
|
assert.deepEqual(blankQuery.map((c) => c.id), legacy.map((c) => c.id), "whitespace-only query degrades too");
|
|
222
225
|
assert.equal(legacy[0].type, "summary", "summary still leads");
|
|
223
|
-
|
|
226
|
+
// #369 收编后 history 可进池;本用例的重心是「空查询与旧规则路逐字节同序」,
|
|
227
|
+
// 这条只确认收编没有撼动优先级分层——summary 仍打头。
|
|
228
|
+
assert.ok(legacy.some((c) => c.type === "history"), "history included since #369");
|
|
224
229
|
});
|
|
225
230
|
|
|
226
231
|
test("Bug4: injectCandidates ignores the query when hybridInject is disabled", () => {
|