dsh-lite-memory 0.17.0 → 0.18.19
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 +15 -15
- package/TRIGGERS.md +1 -1
- package/client.js +343 -44
- package/docs/BENCH.md +10 -8
- package/docs/RELEASE-0.18.0.md +35 -0
- package/docs/RELEASE-0.18.1.md +18 -0
- package/docs/RELEASE-0.18.10.md +25 -0
- package/docs/RELEASE-0.18.11.md +48 -0
- package/docs/RELEASE-0.18.12.md +27 -0
- package/docs/RELEASE-0.18.13.md +13 -0
- package/docs/RELEASE-0.18.14.md +13 -0
- package/docs/RELEASE-0.18.15.md +5 -0
- package/docs/RELEASE-0.18.2.md +21 -0
- package/docs/RELEASE-0.18.3.md +16 -0
- package/docs/RELEASE-0.18.4.md +15 -0
- package/docs/RELEASE-0.18.5.md +21 -0
- package/docs/RELEASE-0.18.6.md +18 -0
- package/docs/RELEASE-0.18.7.md +23 -0
- package/docs/RELEASE-0.18.8.md +18 -0
- package/docs/RELEASE-0.18.9.md +24 -0
- package/index.js +403 -131
- package/learning.js +65 -6
- package/package.json +7 -4
- package/state.js +56 -8
- package/store.js +361 -33
- package/test/client.test.mjs +79 -17
- package/test/contract.test.mjs +231 -93
- package/test/control.test.mjs +2 -2
- package/test/crossing.test.mjs +22 -1
- package/test/eval-seed.test.mjs +113 -0
- package/test/evolution-read.test.mjs +33 -10
- package/test/ha.test.mjs +186 -0
- package/test/layer-read.test.mjs +293 -0
- package/test/learning.test.mjs +80 -0
- package/test/palace.test.mjs +2 -0
- package/test/recall.test.mjs +80 -9
- package/test/record.test.mjs +130 -0
- package/test/safety.test.mjs +119 -0
- package/test/state.test.mjs +57 -1
- package/test/store.test.mjs +49 -5
- package/test/view.test.mjs +3 -0
- package/view.js +97 -13
package/test/control.test.mjs
CHANGED
|
@@ -57,7 +57,7 @@ const seedRule = (scope, title) => {
|
|
|
57
57
|
const row = draft.entries.find((entry) => entry.id === id);
|
|
58
58
|
row.origin = 'project_evolution';
|
|
59
59
|
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
60
|
-
row.maintenance.learning = { state: 'active' };
|
|
60
|
+
row.maintenance.learning = { state: 'active', specDigest: 'seeded' };
|
|
61
61
|
return { changed: true };
|
|
62
62
|
});
|
|
63
63
|
return id;
|
|
@@ -95,7 +95,7 @@ const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { oper
|
|
|
95
95
|
const saved = await runtime.exec('memory_save', { title: '关闭后写入', summary: '应当拒绝', body: '不该落盘' }, agent);
|
|
96
96
|
assert.equal(saved.changed, false);
|
|
97
97
|
assert.equal(saved.reason, 'memory_disabled');
|
|
98
|
-
assert.equal(saved.id,
|
|
98
|
+
assert.equal(saved.id, undefined);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/* C02:关闭期间所有写入入口都拒绝,自进化不能绕过总开关打开。 */
|
package/test/crossing.test.mjs
CHANGED
|
@@ -10,7 +10,7 @@ const view = await import('../view.js');
|
|
|
10
10
|
|
|
11
11
|
const {
|
|
12
12
|
saveEntry, routeScope, visibleRules, move, resumeMove, movePending,
|
|
13
|
-
upsert, forget, readLinks, loadIndex, scopeOf, globalScope, readEntry,
|
|
13
|
+
upsert, forget, readLinks, loadIndex, scopeOf, globalScope, readEntry, pendingConflictsOf,
|
|
14
14
|
} = store;
|
|
15
15
|
|
|
16
16
|
let passed = 0;
|
|
@@ -369,4 +369,25 @@ await test('L06 展示视图不把关联结构塞给模型', () => {
|
|
|
369
369
|
});
|
|
370
370
|
|
|
371
371
|
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
372
|
+
|
|
373
|
+
// ── S12 冲突队列:相近只记待办,确认后才进库 ───────────────────────────────
|
|
374
|
+
await test('S12 相近保存进待办队列,确认 distinct 后出队', () => {
|
|
375
|
+
const scope = fresh('conflict-queue');
|
|
376
|
+
saveEntry(globalScope(), { title: '发布前检查清单', summary: '发版前逐项确认', body: '清单', scope: 'global', resolution: 'distinct' });
|
|
377
|
+
assert.equal(pendingConflictsOf(globalScope()), 0);
|
|
378
|
+
const near = saveEntry(scope, { title: '发布前检查清单', summary: '发版前逐项确认(项目补充)', body: '项目清单' });
|
|
379
|
+
assert.equal(near.needsResolution, true);
|
|
380
|
+
assert.equal(near.pendingConflicts, 1);
|
|
381
|
+
assert.equal(pendingConflictsOf(scope), 1);
|
|
382
|
+
const queued = loadIndex(scope).conflicts[0];
|
|
383
|
+
assert.equal(queued.title, '发布前检查清单');
|
|
384
|
+
assert.equal(queued.scope, 'project');
|
|
385
|
+
assert.equal(queued.candidates.length >= 1, true);
|
|
386
|
+
const again = saveEntry(scope, { title: '发布前检查清单', summary: '发版前逐项确认(项目补充)', body: '项目清单' });
|
|
387
|
+
assert.equal(pendingConflictsOf(scope), 1);
|
|
388
|
+
const distinct = saveEntry(scope, { title: '发布前检查清单', summary: '发版前逐项确认(项目补充)', body: '项目清单', resolution: 'distinct' });
|
|
389
|
+
assert.equal(distinct.needsResolution, false);
|
|
390
|
+
assert.equal(pendingConflictsOf(scope), 0);
|
|
391
|
+
});
|
|
392
|
+
|
|
372
393
|
console.log(`crossing.test.mjs: ${passed} passed`);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 30 条固定样本的召回基线:10 该召回、10 该拒绝、10 该等条件确认。
|
|
3
|
+
* 只断言行为(召回率 / 误触发 / 边界外泄),措辞取用户原话视角。
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-eval-'));
|
|
11
|
+
const view = await import('../view.js');
|
|
12
|
+
|
|
13
|
+
const base = (extra) => ({
|
|
14
|
+
status: 'active', applicability: 'all_in_scope', conditions: [], selection: null,
|
|
15
|
+
aliases: [], tags: [], triggers: [], when: '', lastUsed: '', ...extra,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const MEM = [
|
|
19
|
+
base({ id: 'mem_100000000001', title: '插件改完先试热刷新', summary: '热刷新不行再重启宿主', triggers: ['插件改完'] }),
|
|
20
|
+
base({ id: 'mem_100000000002', title: '发版前跑四条 lint', summary: '文案、中文排印、风格、检测器都要清零', triggers: ['发版'] }),
|
|
21
|
+
base({ id: 'mem_100000000003', title: '外链一律用绝对路径', summary: '聊天里的文件链接必须绝对路径,否则点开报 ENOENT' }),
|
|
22
|
+
base({ id: 'mem_100000000004', title: '截图只在看外观时用', summary: '判断页面对不对读文本树' }),
|
|
23
|
+
base({ id: 'mem_100000000005', title: '写盘先写临时文件再改名', summary: 'rename 之后 fsync 目录,避免半截文件' }),
|
|
24
|
+
base({ id: 'mem_100000000006', title: '客户端包上限四十 KiB', summary: 'gzip 之后超过就不许合入' }),
|
|
25
|
+
base({ id: 'mem_100000000007', title: '测试日志写到临时目录', summary: '长跑放后台,用 EXIT= 判断结果' }),
|
|
26
|
+
base({ id: 'mem_100000000008', title: '推送前先跑全量测试', summary: '绿了再 commit' }),
|
|
27
|
+
base({ id: 'mem_100000000009', title: '一次性脚本用完就删', summary: '这个脚本只跑一次,别留仓库里' }),
|
|
28
|
+
base({ id: 'mem_100000000010', title: '中文文案不用省略号收尾', summary: '能写完就写完' }),
|
|
29
|
+
base({
|
|
30
|
+
id: 'mem_100000000011', title: '部署前确认目标机器', summary: '只在目标机器上执行',
|
|
31
|
+
applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'deploy' }],
|
|
32
|
+
}),
|
|
33
|
+
base({ id: 'mem_100000000012', title: '配色只沿用现有 token', summary: '不新造颜色', applicability: 'unresolved' }),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** 10 条该召回:查询是真人会说的话,答案就在标题/关键词里。 */
|
|
37
|
+
const RECALL = [
|
|
38
|
+
['插件改完怎么让它生效?', '插件改完先试热刷新'],
|
|
39
|
+
['发版之前要跑哪些检查?', '发版前跑四条 lint'],
|
|
40
|
+
['聊天里给的文件链接怎么写?', '外链一律用绝对路径'],
|
|
41
|
+
['判断页面报错要截图吗?', '截图只在看外观时用'],
|
|
42
|
+
['写文件怎么保证不会写出半截?', '写盘先写临时文件再改名'],
|
|
43
|
+
['客户端包大小有上限吗?', '客户端包上限四十 KiB'],
|
|
44
|
+
['测试日志放哪里?', '测试日志写到临时目录'],
|
|
45
|
+
['推送代码前要做什么?', '推送前先跑全量测试'],
|
|
46
|
+
['临时脚本要留在仓库里吗?', '一次性脚本用完就删'],
|
|
47
|
+
['中文文案末尾要加省略号吗?', '中文文案不用省略号收尾'],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** 10 条该拒绝:字面上有常见词,但跟任何一条记忆都不相关。 */
|
|
51
|
+
const REJECT = [
|
|
52
|
+
'把这段视频的字幕导出来',
|
|
53
|
+
'明早路过菜市场买点香菜',
|
|
54
|
+
'这个季节樱桃多少钱一斤',
|
|
55
|
+
'帮我把上周的照片按日期归类',
|
|
56
|
+
'厨房水槽下面有点漏水',
|
|
57
|
+
'地铁六号线末班车几点',
|
|
58
|
+
'明天杭州会下雨吗',
|
|
59
|
+
'给猫换一个更安静的猫砂盆',
|
|
60
|
+
'这本书的第二章讲什么',
|
|
61
|
+
'楼下咖啡店的营业时间',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** 10 条该等条件:找到了,但适用条件还没确认,不许当已适用塞进上下文。 */
|
|
65
|
+
const AWAIT = [
|
|
66
|
+
['这台机器部署前要确认什么?', '部署前确认目标机器'],
|
|
67
|
+
['部署脚本怎么跑?', '部署前确认目标机器'],
|
|
68
|
+
['机器对不上能直接部署吗?', '部署前确认目标机器'],
|
|
69
|
+
['新页面的配色怎么定?', '配色只沿用现有 token'],
|
|
70
|
+
['这个组件能新造颜色吗?', '配色只沿用现有 token'],
|
|
71
|
+
['界面上可以加渐变吗?', '配色只沿用现有 token'],
|
|
72
|
+
['主题色改成什么了?', '配色只沿用现有 token'],
|
|
73
|
+
['图标颜色跟着谁走?', '配色只沿用现有 token'],
|
|
74
|
+
['按钮圆角用新的吗?', '配色只沿用现有 token'],
|
|
75
|
+
['换肤要动哪些变量?', '配色只沿用现有 token'],
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const titleOf = (row) => row.record.title;
|
|
79
|
+
const rank = (query, context = {}) => view.rank(MEM, query, { limit: 3, requireApplicable: true, context });
|
|
80
|
+
|
|
81
|
+
let passed = 0;
|
|
82
|
+
const test = (name, fn) => {
|
|
83
|
+
fn();
|
|
84
|
+
passed += 1;
|
|
85
|
+
console.log(`eval: ${name}`);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
test('该召回的 10 条都进前三', () => {
|
|
89
|
+
const missed = RECALL.filter(([query, want]) => !rank(query).slice(0, 3).some((row) => titleOf(row) === want));
|
|
90
|
+
const recallAt3 = (RECALL.length - missed.length) / RECALL.length;
|
|
91
|
+
assert.ok(recallAt3 >= 0.9, `Recall@3 ${recallAt3};漏了:${missed.map((row) => row[0]).join('、')}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('该拒绝的 10 条一条都不出现', () => {
|
|
95
|
+
const leaked = REJECT.filter((query) => rank(query).length > 0);
|
|
96
|
+
assert.equal(leaked.length, 0, `误触发:${leaked.join('、')}`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('条件未确认的 10 条不会当作已适用出现', () => {
|
|
100
|
+
const exposed = AWAIT.filter(([query, want]) => rank(query).some((row) => titleOf(row) === want));
|
|
101
|
+
assert.equal(exposed.length, 0, `条件未知却进了上下文:${exposed.map((row) => row[0]).join('、')}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('条件确认之后同一条就能用', () => {
|
|
105
|
+
const rows = rank('这台机器部署前要确认什么?', { taskKind: 'deploy' });
|
|
106
|
+
assert.ok(rows.some((row) => titleOf(row) === '部署前确认目标机器'));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('措辞不同也能召回(同义说法不依赖逐字重合)', () => {
|
|
110
|
+
assert.ok(rank('热刷新不生效是不是要重启').some((row) => titleOf(row) === '插件改完先试热刷新'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
console.log(`eval: ${passed} passed`);
|
|
@@ -63,7 +63,7 @@ const save = (scope, fields) => {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
/** 直接种一条实验来源条目,绕开 PR4 的整理入口。 */
|
|
66
|
-
function seedRule(scope, { title, body, summary, conditions = [], learning = { state: 'active' }, topicKey, tags = [] }) {
|
|
66
|
+
function seedRule(scope, { title, body, summary, conditions = [], learning = { state: 'active', specDigest: 'seeded' }, topicKey, tags = [] }) {
|
|
67
67
|
const saved = save(scope, { title, body, summary, tags });
|
|
68
68
|
state.transact(scope, {}, (draft) => {
|
|
69
69
|
const row = draft.entries.find((entry) => entry.id === saved.id);
|
|
@@ -169,7 +169,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
169
169
|
const text = runtime.text(agent, sig);
|
|
170
170
|
assert.match(text, /项目实验规则/);
|
|
171
171
|
assert.match(text, /先给结论再解释/);
|
|
172
|
-
assert.ok(text.includes(id));
|
|
172
|
+
assert.ok(!text.includes(id), '实验规则也只给标题,不给内部编号');
|
|
173
173
|
assert.doesNotMatch(text, /第 .* 版/);
|
|
174
174
|
}
|
|
175
175
|
|
|
@@ -185,7 +185,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
185
185
|
const sig = signal();
|
|
186
186
|
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
|
|
187
187
|
const text = runtime.text(agent, sig);
|
|
188
|
-
assert.ok(text.includes(id));
|
|
188
|
+
assert.ok(!text.includes(id), '实验规则也只给标题,不给内部编号');
|
|
189
189
|
assert.match(text, /memory_read/);
|
|
190
190
|
assert.equal(text.includes('先做这一步再检查'), false);
|
|
191
191
|
assert.equal(Buffer.byteLength(text) <= 1200 + 200, true);
|
|
@@ -388,7 +388,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
388
388
|
title: '暂停口播规则',
|
|
389
389
|
body: '这条已经暂停,不该给正文。',
|
|
390
390
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
391
|
-
learning: { state: 'paused', blockedReason: 'needs_revalidation' },
|
|
391
|
+
learning: { state: 'paused', blockedReason: 'needs_revalidation', specDigest: 'seeded' },
|
|
392
392
|
});
|
|
393
393
|
enable(scope, { seriesId: '口播' });
|
|
394
394
|
const runtime = boot();
|
|
@@ -424,7 +424,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
424
424
|
await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
|
|
425
425
|
const value = await runtime.exec('memory_search', { query: '口播开头', bindForRequest: true }, agent);
|
|
426
426
|
assert.equal(value.status, 'ok');
|
|
427
|
-
assert.equal((value.refs || []).some((ref) => ref.
|
|
427
|
+
assert.equal((value.refs || []).some((ref) => ref.title === '口播开头'), true);
|
|
428
428
|
const episodes = Object.values(store.taskState(scope).episodes);
|
|
429
429
|
assert.equal(episodes.length, 1);
|
|
430
430
|
assert.equal(episodes[0].status, 'proposed');
|
|
@@ -435,7 +435,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
435
435
|
const dir = mkdir('fixed');
|
|
436
436
|
const scope = scopeOf(dir);
|
|
437
437
|
save(scope, { title: '固定发布检查', body: '发版前跑一遍检查。', tags: ['发布'] });
|
|
438
|
-
seedRule(scope, { title: '实验发布检查', body: '实验规则:发版前先备份。', tags: ['发布'], learning: { state: 'active' } });
|
|
438
|
+
seedRule(scope, { title: '实验发布检查', body: '实验规则:发版前先备份。', tags: ['发布'], learning: { state: 'active', specDigest: 'seeded' } });
|
|
439
439
|
enable(scope);
|
|
440
440
|
const runtime = boot();
|
|
441
441
|
const agent = makeAgent('a-fixed', dir);
|
|
@@ -473,7 +473,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
473
473
|
title: '过期口播规则',
|
|
474
474
|
body: '这条试用已经到期,不该自动出现。',
|
|
475
475
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
476
|
-
learning: { state: 'trial', trialUntil: new Date(Date.now() - 60_000).toISOString() },
|
|
476
|
+
learning: { state: 'trial', specDigest: 'seeded', trialUntil: new Date(Date.now() - 60_000).toISOString() },
|
|
477
477
|
});
|
|
478
478
|
enable(scope, { seriesId: '口播' });
|
|
479
479
|
const runtime = boot();
|
|
@@ -494,7 +494,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
494
494
|
title: '快到期口播规则',
|
|
495
495
|
body: '这条试用马上到期,过期后同一请求也不能再出现。',
|
|
496
496
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
497
|
-
learning: { state: 'trial', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
497
|
+
learning: { state: 'trial', specDigest: 'seeded', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
498
498
|
});
|
|
499
499
|
enable(scope, { seriesId: '口播' });
|
|
500
500
|
const runtime = boot();
|
|
@@ -514,14 +514,14 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
514
514
|
title: '快到期的主题规则',
|
|
515
515
|
body: '这条试用马上到期。',
|
|
516
516
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
517
|
-
learning: { state: 'trial', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
517
|
+
learning: { state: 'trial', specDigest: 'seeded', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
518
518
|
topicKey: 'theme',
|
|
519
519
|
});
|
|
520
520
|
seedRule(scope, {
|
|
521
521
|
title: '长期口播开头',
|
|
522
522
|
body: '开场三句内给结论。',
|
|
523
523
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
524
|
-
learning: { state: 'active' },
|
|
524
|
+
learning: { state: 'active', specDigest: 'seeded' },
|
|
525
525
|
topicKey: 'opening',
|
|
526
526
|
});
|
|
527
527
|
enable(scope, { seriesId: '口播' });
|
|
@@ -575,4 +575,27 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
575
575
|
assert.match(value.text, /适用条件还没确认/);
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
+
/* L22:自动续写轮没有新的真人消息,仍沿用本会话上一次的请求上下文读正文。 */
|
|
579
|
+
{
|
|
580
|
+
const dir = mkdir('continuation');
|
|
581
|
+
const scope = scopeOf(dir);
|
|
582
|
+
const saved = save(scope, { title: '口播发布清单', body: '按清单逐项确认。' });
|
|
583
|
+
state.transact(scope, {}, (draft) => {
|
|
584
|
+
const row = draft.entries.find((entry) => entry.id === saved.id);
|
|
585
|
+
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
586
|
+
return { changed: true };
|
|
587
|
+
});
|
|
588
|
+
enable(scope, { seriesId: '口播' });
|
|
589
|
+
const runtime = boot();
|
|
590
|
+
const agent = makeAgent('a-continuation', dir);
|
|
591
|
+
await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播怎么做')));
|
|
592
|
+
const first = await runtime.exec('memory_read', { id: saved.id }, agent);
|
|
593
|
+
await runtime.preStep({ agent, signal: signal(), turn: 't2' }, enter());
|
|
594
|
+
assert.equal(first.status, 'ok');
|
|
595
|
+
assert.match(first.body, /按清单逐项确认/);
|
|
596
|
+
const second = await runtime.exec('memory_read', { id: saved.id }, agent);
|
|
597
|
+
assert.equal(second.status, 'ok');
|
|
598
|
+
assert.equal(second.repeat, true);
|
|
599
|
+
}
|
|
600
|
+
|
|
578
601
|
console.log('evolution-read.test.mjs: all passed');
|
package/test/ha.test.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-ha-'));
|
|
7
|
+
const store = await import('../store.js');
|
|
8
|
+
const state = await import('../state.js');
|
|
9
|
+
|
|
10
|
+
let passed = 0;
|
|
11
|
+
const test = (name, fn) => {
|
|
12
|
+
try {
|
|
13
|
+
fn();
|
|
14
|
+
passed += 1;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error(`FAIL ${name}`);
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const dirs = [];
|
|
22
|
+
const fresh = (label) => {
|
|
23
|
+
const dir = path.join(os.tmpdir(), `lite-memory-ha-${label}-${Math.random().toString(16).slice(2, 8)}`);
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
dirs.push(dir);
|
|
26
|
+
return store.scopeOf(dir);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const lockOf = (scope) => path.join(scope.dir, '.lock');
|
|
30
|
+
const currentOf = (scope) => path.join(scope.dir, 'state.json');
|
|
31
|
+
const prevOf = (scope) => path.join(scope.dir, 'state.prev.json');
|
|
32
|
+
|
|
33
|
+
test('P1-C: a lock left by a dead pid is reclaimed instead of reported busy', () => {
|
|
34
|
+
const scope = fresh('stale-lock');
|
|
35
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
36
|
+
fs.writeFileSync(lockOf(scope), '999999:deadbeef');
|
|
37
|
+
const saved = store.upsert(scope, { title: '锁回收', summary: '崩溃进程留下的锁不该卡住写入', body: '正文' });
|
|
38
|
+
assert.ok(saved.record.id.startsWith('mem_'));
|
|
39
|
+
assert.equal(fs.existsSync(lockOf(scope)), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('P1-C: a lock held by a live pid still reports busy and is never deleted', () => {
|
|
43
|
+
const scope = fresh('live-lock');
|
|
44
|
+
fs.mkdirSync(scope.dir, { recursive: true });
|
|
45
|
+
const file = lockOf(scope);
|
|
46
|
+
fs.writeFileSync(file, `${process.pid}:live`);
|
|
47
|
+
try {
|
|
48
|
+
assert.throws(
|
|
49
|
+
() => store.upsert(scope, { title: '忙', summary: '另一个进程正在写', body: '正文' }),
|
|
50
|
+
(error) => error.code === 'busy' && /另一个进程/.test(error.message),
|
|
51
|
+
);
|
|
52
|
+
assert.equal(fs.readFileSync(file, 'utf8'), `${process.pid}:live`);
|
|
53
|
+
} finally {
|
|
54
|
+
fs.rmSync(file, { force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('P0-B: 别的项目占着全局锁,本项目保存自己的条目照样能写', () => {
|
|
59
|
+
const project = fresh('other-project-save');
|
|
60
|
+
const global = store.globalScope();
|
|
61
|
+
fs.mkdirSync(global.dir, { recursive: true });
|
|
62
|
+
const file = lockOf(global);
|
|
63
|
+
fs.writeFileSync(file, `${process.pid}:busy-global`);
|
|
64
|
+
try {
|
|
65
|
+
const saved = store.saveEntry(project, {
|
|
66
|
+
title: '本项目照常保存',
|
|
67
|
+
summary: '别的项目或全局在写,不影响本项目的记忆',
|
|
68
|
+
body: '本项目照常保存正文。',
|
|
69
|
+
memoryClass: 'project_fact',
|
|
70
|
+
});
|
|
71
|
+
assert.equal(saved.chosenScope, 'project');
|
|
72
|
+
assert.ok(saved.record && saved.record.id.startsWith('mem_'));
|
|
73
|
+
} finally {
|
|
74
|
+
fs.rmSync(file, { force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('P0-B: a corrupt state.json falls back to state.prev.json and says so', () => {
|
|
79
|
+
const scope = fresh('recover');
|
|
80
|
+
store.upsert(scope, { title: '第一条', summary: '先写一条', body: '正文一' });
|
|
81
|
+
store.upsert(scope, { title: '第二条', summary: '再写一条,prev 才有货', body: '正文二' });
|
|
82
|
+
assert.ok(fs.existsSync(prevOf(scope)), 'second publish should have produced state.prev.json');
|
|
83
|
+
fs.writeFileSync(currentOf(scope), '{"format":"broken"');
|
|
84
|
+
const loaded = state.load(scope);
|
|
85
|
+
assert.equal(loaded.recovered, true);
|
|
86
|
+
assert.ok(loaded.state.entries.length >= 1);
|
|
87
|
+
assert.match(loaded.recovery.message, /已从上一版恢复/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('P0-B: recovery does not bring a closed memory switch back on its own', () => {
|
|
91
|
+
const scope = fresh('recover-auth');
|
|
92
|
+
store.upsert(scope, { title: '关闭前的一条', summary: '先有数据', body: '正文' });
|
|
93
|
+
store.setMemoryEnabled(scope, false, { operationId: 'op-close' });
|
|
94
|
+
// 关闭这一版是 current;prev 还是开启状态。current 坏掉后恢复的是 prev。
|
|
95
|
+
assert.equal(store.controlStatus(scope).enabled, false);
|
|
96
|
+
fs.writeFileSync(currentOf(scope), '{"format":"broken"');
|
|
97
|
+
const loaded = state.load(scope);
|
|
98
|
+
assert.equal(loaded.recovered, true);
|
|
99
|
+
assert.equal(loaded.state.control.recoveryPending, true);
|
|
100
|
+
assert.equal(store.controlStatus(scope).enabled, false, '恢复出来仍是关闭,不能自己恢复授权');
|
|
101
|
+
const reopened = store.setMemoryEnabled(scope, true, { operationId: 'op-reopen' });
|
|
102
|
+
assert.equal(reopened.ok, true);
|
|
103
|
+
assert.equal(store.controlStatus(scope).enabled, true);
|
|
104
|
+
assert.equal(store.controlStatus(scope).recoveryPending, false, '用户明确开启后不再待确认');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('P0-B: both files unreadable gives a recovery step, not just a path', () => {
|
|
108
|
+
const scope = fresh('both-bad');
|
|
109
|
+
store.upsert(scope, { title: '第一条', summary: '先写一条', body: '正文一' });
|
|
110
|
+
store.upsert(scope, { title: '第二条', summary: '再写一条', body: '正文二' });
|
|
111
|
+
fs.writeFileSync(currentOf(scope), 'broken');
|
|
112
|
+
fs.writeFileSync(prevOf(scope), 'broken too');
|
|
113
|
+
try {
|
|
114
|
+
state.load(scope);
|
|
115
|
+
assert.fail('expected a corrupt error');
|
|
116
|
+
} catch (error) {
|
|
117
|
+
assert.equal(error.code, 'corrupt');
|
|
118
|
+
assert.match(error.message, /恢复/);
|
|
119
|
+
assert.match(error.message, /state\.prev\.json/);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('P0-A: writeDurable publishes atomically and leaves no temp file behind', () => {
|
|
124
|
+
const dir = path.join(os.tmpdir(), `lite-memory-durable-${Math.random().toString(16).slice(2, 8)}`);
|
|
125
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
126
|
+
dirs.push(dir);
|
|
127
|
+
const file = path.join(dir, 'x.json');
|
|
128
|
+
state.writeDurable(file, '{"ok":true}');
|
|
129
|
+
assert.equal(fs.readFileSync(file, 'utf8'), '{"ok":true}');
|
|
130
|
+
assert.deepEqual(fs.readdirSync(dir), ['x.json']);
|
|
131
|
+
state.writeDurable(file, 'second');
|
|
132
|
+
assert.equal(fs.readFileSync(file, 'utf8'), 'second');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('P0-A: a failing write surfaces as unavailable and does not clobber the good file', () => {
|
|
136
|
+
const dir = path.join(os.tmpdir(), `lite-memory-durable-bad-${Math.random().toString(16).slice(2, 8)}`);
|
|
137
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
138
|
+
dirs.push(dir);
|
|
139
|
+
const file = path.join(dir, 'x.json');
|
|
140
|
+
state.writeDurable(file, 'good');
|
|
141
|
+
const frozen = path.join(dir, 'frozen');
|
|
142
|
+
fs.mkdirSync(frozen);
|
|
143
|
+
if (process.platform === 'win32') {
|
|
144
|
+
// Windows has no directory write bit: chmod on a folder changes nothing, so
|
|
145
|
+
// the injection has to be a failure Windows actually raises. A directory in
|
|
146
|
+
// the way of the final rename is EPERM here and EISDIR on POSIX — both must
|
|
147
|
+
// surface as `unavailable`, and neither may touch the good file beside it.
|
|
148
|
+
const blocked = path.join(dir, 'blocked.json');
|
|
149
|
+
fs.mkdirSync(blocked);
|
|
150
|
+
assert.throws(
|
|
151
|
+
() => state.writeDurable(blocked, 'nope'),
|
|
152
|
+
(error) => error.code === 'unavailable' && /写不进去/.test(error.message),
|
|
153
|
+
);
|
|
154
|
+
} else {
|
|
155
|
+
fs.chmodSync(frozen, 0o500);
|
|
156
|
+
try {
|
|
157
|
+
assert.throws(
|
|
158
|
+
() => state.writeDurable(path.join(frozen, 'y.json'), 'nope'),
|
|
159
|
+
(error) => error.code === 'unavailable' && /写不进去/.test(error.message),
|
|
160
|
+
);
|
|
161
|
+
} finally {
|
|
162
|
+
fs.chmodSync(frozen, 0o700);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
assert.equal(fs.readFileSync(file, 'utf8'), 'good');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('P1-D: archived body round-trips through cold storage, and corrupt cold fails loudly', () => {
|
|
169
|
+
const scope = fresh('cold');
|
|
170
|
+
const saved = store.upsert(scope, { title: '冷存', summary: '归档正文放冷存', body: '归档正文内容' });
|
|
171
|
+
const archived = store.forget(scope, saved.record.id, { mode: 'archive' });
|
|
172
|
+
assert.ok(['archive', 'archived'].includes(archived.mode));
|
|
173
|
+
const cold = path.join(scope.dir, 'cold', `${saved.record.id}.json`);
|
|
174
|
+
assert.ok(fs.existsSync(cold), 'cold file should exist for an archived record');
|
|
175
|
+
assert.equal(JSON.parse(fs.readFileSync(cold, 'utf8')).body, '归档正文内容');
|
|
176
|
+
assert.equal(store.readEntry(scope, saved.record.id).body, '归档正文内容');
|
|
177
|
+
fs.writeFileSync(cold, '{broken');
|
|
178
|
+
assert.throws(
|
|
179
|
+
() => store.readEntry(scope, saved.record.id),
|
|
180
|
+
(error) => error.code === 'cold_corrupt' && /冷存/.test(error.message),
|
|
181
|
+
'a broken cold file must never read back as an empty body',
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
console.log(`test/ha.test.mjs: ${passed} passed`);
|