@kira905/dsh-memory-toolkit 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -1
- package/README.en.md +225 -2
- package/README.md +208 -2
- package/cordis.patch.yml +32 -0
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +11 -1
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// 12 · 分层软淘汰 + 号段哨兵(K5 新增件的夹具;每件都配阳性 / 阴性对照)
|
|
2
|
+
//
|
|
3
|
+
// 覆盖三件事:
|
|
4
|
+
// ① layer 字段迁移:干跑零写 / 加列回填 / 幂等 / 备份还原 / 保护闸;
|
|
5
|
+
// ② 软淘汰执行器:搬出守恒 / 搬回逐行指纹一致 / 空表保护 / 边表被改过整批拒动;
|
|
6
|
+
// ③ 号段哨兵:单机正常绿 / 越上界红 / 低于下界红 / 更高位段红 / 更低别台段只披露 / 本机库读不到 rc=4 /
|
|
7
|
+
// 远端读不到**不许** rc=4 / 报告只落文件。
|
|
8
|
+
//
|
|
9
|
+
// 铁律:所有夹具库都造在 os.tmpdir() 下;起子进程时 USERPROFILE / HOME / DSH_HOME 三件套一起隔离。
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
16
|
+
import { test, assert, assertEq } from './harness.mjs';
|
|
17
|
+
import { makeTempRoot, makeMemoryDb, sampleRows } from './fixtures.mjs';
|
|
18
|
+
import { loadLayerConfig, rowDigest, digestRows, compareDigests } from '../lib/layers.mjs';
|
|
19
|
+
import { buildPlan, applyMigration, restoreDatabase, inspectDatabase } from '../lib/layer-migrate.mjs';
|
|
20
|
+
import { planMove, moveOut, moveBack, dumpDigests, verifyAgainstDump, EVICTED_TABLE } from '../lib/layer-move.mjs';
|
|
21
|
+
import { loadBandConfig, evaluate, bandRanges, snapshotLocal, judgeSnapshot } from '../lib/band-sentinel.mjs';
|
|
22
|
+
|
|
23
|
+
export const __section = '12 · 分层软淘汰(layer 字段 / 搬出搬回)+ 号段哨兵(单机退化)';
|
|
24
|
+
|
|
25
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const ENGINE = path.resolve(HERE, '..');
|
|
27
|
+
const CLI = {
|
|
28
|
+
migrate: path.join(ENGINE, 'cli', 'layer-migrate.mjs'),
|
|
29
|
+
move: path.join(ENGINE, 'cli', 'layer-move.mjs'),
|
|
30
|
+
sentinel: path.join(ENGINE, 'cli', 'band-sentinel.mjs'),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 子进程环境:三件套全隔离(碰 home 的路径一个都不许真碰)。 */
|
|
34
|
+
function isoEnv(root) {
|
|
35
|
+
const home = path.join(root, 'home');
|
|
36
|
+
fs.mkdirSync(home, { recursive: true });
|
|
37
|
+
return {
|
|
38
|
+
...process.env,
|
|
39
|
+
USERPROFILE: home,
|
|
40
|
+
HOME: home,
|
|
41
|
+
DSH_HOME: path.join(home, '.dsh'),
|
|
42
|
+
MEMORY_ENGINE_MEMORY_DB: path.join(home, '.dsh', 'memory', 'memory.db'),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function run(script, args, env) {
|
|
47
|
+
const r = spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', timeout: 60000, windowsHide: true });
|
|
48
|
+
return { status: r.status, stdout: String(r.stdout ?? ''), stderr: String(r.stderr ?? '') };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function includes(text, needle, label) {
|
|
52
|
+
assert(String(text).includes(needle), `${label}:输出里没有「${needle}」\n---\n${String(text).slice(0, 1200)}\n---`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const shaOf = (p) => inspectDatabase(p).sha256;
|
|
56
|
+
|
|
57
|
+
function writeJson(file, obj) {
|
|
58
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
59
|
+
fs.writeFileSync(file, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 造一本已迁好「层」的夹具库(回填成 recall)。 */
|
|
64
|
+
function preparedDb(root, rows = sampleRows(6)) {
|
|
65
|
+
const dbPath = path.join(root, 'memory.db');
|
|
66
|
+
makeMemoryDb(dbPath, rows);
|
|
67
|
+
const cfg = loadLayerConfig({ file: null });
|
|
68
|
+
const res = applyMigration({ dbPath, cfg, backupPath: path.join(root, 'backup-before-migrate.db') });
|
|
69
|
+
return { dbPath, cfg, migration: res };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function countRows(dbPath, sql, params = []) {
|
|
73
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
74
|
+
try {
|
|
75
|
+
return Number(db.prepare(sql).get(...params).n);
|
|
76
|
+
} finally {
|
|
77
|
+
db.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function execSql(dbPath, sql, params = []) {
|
|
82
|
+
const db = new DatabaseSync(dbPath);
|
|
83
|
+
try {
|
|
84
|
+
db.prepare(sql).run(...params);
|
|
85
|
+
} finally {
|
|
86
|
+
db.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ───────────────────────────── ① 层配置与迁移 ─────────────────────────────
|
|
91
|
+
|
|
92
|
+
test('层名不写死:换一份层配置就换一套层名;缺省层不在集合里当场拒', () => {
|
|
93
|
+
const t = makeTempRoot('k5-layers-');
|
|
94
|
+
try {
|
|
95
|
+
const file = writeJson(path.join(t.root, 'layers.json'), { column: 'tier', layers: ['hot', 'warm', 'cold'], default: 'warm' });
|
|
96
|
+
const cfg = loadLayerConfig({ file, env: {} });
|
|
97
|
+
assertEq(cfg.layers.join('|'), 'hot|warm|cold', '层名来自配置文件');
|
|
98
|
+
assertEq(cfg.defaultLayer, 'warm', '缺省层来自配置文件');
|
|
99
|
+
assertEq(cfg.column, 'tier', '列名来自配置文件');
|
|
100
|
+
let threw = null;
|
|
101
|
+
try {
|
|
102
|
+
loadLayerConfig({ file: writeJson(path.join(t.root, 'bad.json'), { layers: ['a', 'b'], default: 'zzz' }), env: {} });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
threw = e;
|
|
105
|
+
}
|
|
106
|
+
assert(threw && /缺省层/.test(threw.message), '缺省层不在集合里 ⇒ 抛错(不是静默回退)');
|
|
107
|
+
} finally {
|
|
108
|
+
t.cleanup();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('layer-migrate 干跑零写:计划说清要加列,库文件 sha 一字不变', () => {
|
|
113
|
+
const t = makeTempRoot('k5-migrate-dry-');
|
|
114
|
+
try {
|
|
115
|
+
const dbPath = path.join(t.root, 'memory.db');
|
|
116
|
+
makeMemoryDb(dbPath, sampleRows(6));
|
|
117
|
+
const before = shaOf(dbPath);
|
|
118
|
+
const r = run(CLI.migrate, ['--db', dbPath], isoEnv(t.root));
|
|
119
|
+
assertEq(r.status, 0, `rc 应为 0\n${r.stderr}`);
|
|
120
|
+
includes(r.stdout, '将新增', '干跑计划');
|
|
121
|
+
includes(r.stdout, 'dry-run', '干跑标记');
|
|
122
|
+
assertEq(shaOf(dbPath), before, '干跑不许改库文件(sha 不变)');
|
|
123
|
+
assertEq(inspectDatabase(dbPath).columns.some((c) => c.name === 'layer'), false, '干跑后列仍然不在');
|
|
124
|
+
} finally {
|
|
125
|
+
t.cleanup();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('layer-migrate 保护闸:--apply 不带 --backup ⇒ rc=2 且库不动;未知参数也 rc=2', () => {
|
|
130
|
+
const t = makeTempRoot('k5-migrate-guard-');
|
|
131
|
+
try {
|
|
132
|
+
const dbPath = path.join(t.root, 'memory.db');
|
|
133
|
+
makeMemoryDb(dbPath, sampleRows(6));
|
|
134
|
+
const before = shaOf(dbPath);
|
|
135
|
+
const env = isoEnv(t.root);
|
|
136
|
+
|
|
137
|
+
const noBackup = run(CLI.migrate, ['--db', dbPath, '--apply'], env);
|
|
138
|
+
assertEq(noBackup.status, 2, '无备份的 --apply 必须 rc=2');
|
|
139
|
+
includes(noBackup.stderr, '--backup', '要说清缺什么');
|
|
140
|
+
assertEq(shaOf(dbPath), before, '拒跑时库不许动');
|
|
141
|
+
|
|
142
|
+
const unknown = run(CLI.migrate, ['--db', dbPath, '--wat'], env);
|
|
143
|
+
assertEq(unknown.status, 2, '未知参数必须 rc=2(fail-closed)');
|
|
144
|
+
includes(unknown.stderr, '未知参数', '错误要说人话');
|
|
145
|
+
|
|
146
|
+
const missing = run(CLI.migrate, ['--db', path.join(t.root, 'nope.db')], env);
|
|
147
|
+
assertEq(missing.status, 3, '库不存在 ⇒ rc=3');
|
|
148
|
+
} finally {
|
|
149
|
+
t.cleanup();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('layer-migrate --apply:加列 + 回填到缺省层,行数 / seq / 完整性三项复核通过', () => {
|
|
154
|
+
const t = makeTempRoot('k5-migrate-apply-');
|
|
155
|
+
try {
|
|
156
|
+
const dbPath = path.join(t.root, 'memory.db');
|
|
157
|
+
makeMemoryDb(dbPath, sampleRows(6));
|
|
158
|
+
const before = inspectDatabase(dbPath);
|
|
159
|
+
const backup = path.join(t.root, 'backup.db');
|
|
160
|
+
const env = isoEnv(t.root);
|
|
161
|
+
|
|
162
|
+
const r = run(CLI.migrate, ['--db', dbPath, '--apply', '--backup', backup], env);
|
|
163
|
+
assertEq(r.status, 0, `rc 应为 0\n${r.stderr}`);
|
|
164
|
+
includes(r.stdout, '已加列', '成功回执');
|
|
165
|
+
|
|
166
|
+
const after = inspectDatabase(dbPath);
|
|
167
|
+
assert(after.columns.some((c) => c.name === 'layer'), '列已加');
|
|
168
|
+
assertEq(after.rowCount, before.rowCount, '行数守恒(一行不删)');
|
|
169
|
+
assertEq(after.seq, before.seq, 'sqlite_sequence 不变');
|
|
170
|
+
assertEq(after.integrity, 'ok', 'integrity_check = ok');
|
|
171
|
+
assertEq(countRows(dbPath, "SELECT COUNT(*) AS n FROM memories WHERE layer = 'recall'"), 6, '全部回填成缺省层 recall');
|
|
172
|
+
assertEq(countRows(dbPath, "SELECT COUNT(*) AS n FROM memories WHERE layer IS NULL OR TRIM(layer) = ''"), 0, '没有空层行');
|
|
173
|
+
|
|
174
|
+
const bk = inspectDatabase(backup);
|
|
175
|
+
assert(bk.exists && bk.integrity === 'ok', '备份文件在位且完整');
|
|
176
|
+
assertEq(bk.rowCount, before.rowCount, '备份与源库行数一致');
|
|
177
|
+
assertEq(bk.columns.some((c) => c.name === 'layer'), false, '备份是改前状态(没有 layer 列)');
|
|
178
|
+
} finally {
|
|
179
|
+
t.cleanup();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('layer-migrate 幂等:第二次 --apply 一个字节都不写', () => {
|
|
184
|
+
const t = makeTempRoot('k5-migrate-idem-');
|
|
185
|
+
try {
|
|
186
|
+
const dbPath = path.join(t.root, 'memory.db');
|
|
187
|
+
makeMemoryDb(dbPath, sampleRows(6));
|
|
188
|
+
const env = isoEnv(t.root);
|
|
189
|
+
const first = run(CLI.migrate, ['--db', dbPath, '--apply', '--backup', path.join(t.root, 'b1.db')], env);
|
|
190
|
+
assertEq(first.status, 0, `第一次 apply 应成功\n${first.stderr}`);
|
|
191
|
+
const shaAfterFirst = shaOf(dbPath);
|
|
192
|
+
|
|
193
|
+
const second = run(CLI.migrate, ['--db', dbPath, '--apply', '--backup', path.join(t.root, 'b2.db')], env);
|
|
194
|
+
assertEq(second.status, 0, `第二次 apply 也应 rc=0\n${second.stderr}`);
|
|
195
|
+
includes(second.stdout, '幂等', '要说清无事可做');
|
|
196
|
+
assertEq(shaOf(dbPath), shaAfterFirst, '第二次 apply 库文件逐字节不变');
|
|
197
|
+
assertEq(fs.existsSync(path.join(t.root, 'b2.db')), false, '无事可做时不该再产出一份备份');
|
|
198
|
+
} finally {
|
|
199
|
+
t.cleanup();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('layer-migrate 可用自定义列名与自定义缺省层(配置驱动,不是写死三个层名)', () => {
|
|
204
|
+
const t = makeTempRoot('k5-migrate-custom-');
|
|
205
|
+
try {
|
|
206
|
+
const dbPath = path.join(t.root, 'memory.db');
|
|
207
|
+
makeMemoryDb(dbPath, sampleRows(4));
|
|
208
|
+
const r = run(
|
|
209
|
+
CLI.migrate,
|
|
210
|
+
['--db', dbPath, '--apply', '--backup', path.join(t.root, 'b.db'), '--layers', 'hot,warm,cold', '--default-layer', 'warm', '--column', 'tier'],
|
|
211
|
+
isoEnv(t.root),
|
|
212
|
+
);
|
|
213
|
+
assertEq(r.status, 0, `rc 应为 0\n${r.stderr}`);
|
|
214
|
+
const cols = inspectDatabase(dbPath).columns.map((c) => c.name);
|
|
215
|
+
assert(cols.includes('tier'), '列名按 --column 生效');
|
|
216
|
+
assert(!cols.includes('layer'), '没顺手加内置列');
|
|
217
|
+
assertEq(countRows(dbPath, "SELECT COUNT(*) AS n FROM memories WHERE tier = 'warm'"), 4, '回填到自定义缺省层');
|
|
218
|
+
} finally {
|
|
219
|
+
t.cleanup();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('layer-migrate --restore:干跑不动、--apply 复原到改前(含摘掉陈旧 WAL)', () => {
|
|
224
|
+
const t = makeTempRoot('k5-migrate-restore-');
|
|
225
|
+
try {
|
|
226
|
+
const dbPath = path.join(t.root, 'memory.db');
|
|
227
|
+
makeMemoryDb(dbPath, sampleRows(6));
|
|
228
|
+
const preMigration = shaOf(dbPath);
|
|
229
|
+
const env = isoEnv(t.root);
|
|
230
|
+
const backup = path.join(t.root, 'backup.db');
|
|
231
|
+
|
|
232
|
+
const applied = run(CLI.migrate, ['--db', dbPath, '--apply', '--backup', backup], env);
|
|
233
|
+
assertEq(applied.status, 0, `迁移应先成功\n${applied.stderr}`);
|
|
234
|
+
const postMigration = shaOf(dbPath);
|
|
235
|
+
assert(postMigration !== preMigration, '迁移确实改了库');
|
|
236
|
+
|
|
237
|
+
// 备份还原:干跑不许动
|
|
238
|
+
const dry = run(CLI.migrate, ['--db', dbPath, '--restore', backup], env);
|
|
239
|
+
assertEq(dry.status, 0, `还原干跑 rc 应为 0\n${dry.stderr}`);
|
|
240
|
+
assertEq(shaOf(dbPath), postMigration, '还原干跑不许动库');
|
|
241
|
+
|
|
242
|
+
const real = run(CLI.migrate, ['--db', dbPath, '--restore', backup, '--apply'], env);
|
|
243
|
+
assertEq(real.status, 0, `还原 rc 应为 0\n${real.stderr}`);
|
|
244
|
+
assertEq(shaOf(dbPath), preMigration, '还原后 sha 与迁移前逐字节一致');
|
|
245
|
+
assertEq(inspectDatabase(dbPath).columns.some((c) => c.name === 'layer'), false, '还原后列也没了(真回到改前)');
|
|
246
|
+
} finally {
|
|
247
|
+
t.cleanup();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ───────────────────────────── ② 软淘汰执行器 ─────────────────────────────
|
|
252
|
+
|
|
253
|
+
test('layer-move 干跑零写:说清会搬几行,库文件 sha 不变', () => {
|
|
254
|
+
const t = makeTempRoot('k5-move-dry-');
|
|
255
|
+
try {
|
|
256
|
+
const { dbPath } = preparedDb(t.root);
|
|
257
|
+
execSql(dbPath, "UPDATE memories SET layer = 'archival' WHERE id IN (1, 2)");
|
|
258
|
+
const before = shaOf(dbPath);
|
|
259
|
+
const r = run(CLI.move, ['--db', dbPath, '--layer', 'archival'], isoEnv(t.root));
|
|
260
|
+
assertEq(r.status, 0, `rc 应为 0\n${r.stderr}`);
|
|
261
|
+
includes(r.stdout, 'dry-run', '干跑标记');
|
|
262
|
+
includes(r.stdout, '2 行', '报出要搬的行数');
|
|
263
|
+
assertEq(shaOf(dbPath), before, '干跑不许改库');
|
|
264
|
+
assertEq(countRows(dbPath, 'SELECT COUNT(*) AS n FROM memories'), 6, '行数没变');
|
|
265
|
+
} finally {
|
|
266
|
+
t.cleanup();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('layer-move 搬出守恒:搬出层行数 + 剩下行数 == 搬前总行数,且边表拿到那几行', () => {
|
|
271
|
+
const t = makeTempRoot('k5-move-out-');
|
|
272
|
+
try {
|
|
273
|
+
const { dbPath, cfg } = preparedDb(t.root);
|
|
274
|
+
execSql(dbPath, "UPDATE memories SET layer = 'archival' WHERE id IN (1, 2)");
|
|
275
|
+
const res = moveOut({ dbPath, cfg, layer: 'archival', batch: 'test-batch' });
|
|
276
|
+
assertEq(res.moved, 2, '搬出行数');
|
|
277
|
+
assertEq(res.totalBefore, 6, '搬前总行数');
|
|
278
|
+
assertEq(res.remaining, 4, '剩下行数');
|
|
279
|
+
assertEq(res.evicted, 2, '边表里该层行数');
|
|
280
|
+
assertEq(res.evicted + res.remaining, res.totalBefore, '守恒式必须成立');
|
|
281
|
+
assertEq(countRows(dbPath, 'SELECT COUNT(*) AS n FROM memories'), 4, '主表真的少了 2 行');
|
|
282
|
+
assertEq(countRows(dbPath, `SELECT COUNT(*) AS n FROM ${EVICTED_TABLE} WHERE layer = 'archival'`), 2, '边表正好 2 行');
|
|
283
|
+
assertEq(countRows(dbPath, 'SELECT COUNT(*) AS n FROM memories WHERE layer = \'archival\''), 0, '主表里该层已空');
|
|
284
|
+
// 再跑一次:这层已经空了 ⇒ 0 行,不报错
|
|
285
|
+
const again = moveOut({ dbPath, cfg, layer: 'archival', batch: 'test-batch-2' });
|
|
286
|
+
assertEq(again.moved, 0, '第二次搬出 0 行(幂等)');
|
|
287
|
+
} finally {
|
|
288
|
+
t.cleanup();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('layer-move 搬回后逐行指纹一致(不是只数行数)', () => {
|
|
293
|
+
const t = makeTempRoot('k5-move-roundtrip-');
|
|
294
|
+
try {
|
|
295
|
+
const { dbPath, cfg } = preparedDb(t.root);
|
|
296
|
+
execSql(dbPath, "UPDATE memories SET layer = 'archival' WHERE id IN (1, 3, 5)");
|
|
297
|
+
const dumpFile = path.join(t.root, 'before.json');
|
|
298
|
+
const before = dumpDigests({ dbPath, cfg });
|
|
299
|
+
fs.writeFileSync(dumpFile, `${JSON.stringify(before, null, 2)}\n`, 'utf8');
|
|
300
|
+
|
|
301
|
+
moveOut({ dbPath, cfg, layer: 'archival', batch: 'roundtrip' });
|
|
302
|
+
const back = moveBack({ dbPath, cfg, layer: 'archival' });
|
|
303
|
+
assertEq(back.restored, 3, '搬回 3 行');
|
|
304
|
+
assertEq(back.totalAfter, 6, '搬回后主表 6 行');
|
|
305
|
+
|
|
306
|
+
const verify = verifyAgainstDump({ dbPath, cfg, dump: JSON.parse(fs.readFileSync(dumpFile, 'utf8')) });
|
|
307
|
+
assertEq(verify.ok, true, `逐行指纹必须一致:缺 ${verify.missing} 多 ${verify.extra} 变 ${verify.changed}`);
|
|
308
|
+
assertEq(verify.checked, 6, '核了 6 行');
|
|
309
|
+
assertEq(countRows(dbPath, `SELECT COUNT(*) AS n FROM ${EVICTED_TABLE}`), 0, '边表已清空');
|
|
310
|
+
|
|
311
|
+
// 阴性对照:故意改一行正文,对拍必须红
|
|
312
|
+
execSql(dbPath, "UPDATE memories SET text = text || '(改过)' WHERE id = 5");
|
|
313
|
+
const bad = verifyAgainstDump({ dbPath, cfg, dump: JSON.parse(fs.readFileSync(dumpFile, 'utf8')) });
|
|
314
|
+
assertEq(bad.ok, false, '内容变了 ⇒ 对拍必须红');
|
|
315
|
+
assertEq(bad.changed, [5], '点名到具体 id');
|
|
316
|
+
} finally {
|
|
317
|
+
t.cleanup();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('layer-move 空表保护:会搬空主表时拒跑(rc=1),加 --force 才动', () => {
|
|
322
|
+
const t = makeTempRoot('k5-move-force-');
|
|
323
|
+
try {
|
|
324
|
+
const { dbPath } = preparedDb(t.root);
|
|
325
|
+
const env = isoEnv(t.root);
|
|
326
|
+
const guarded = run(CLI.move, ['--db', dbPath, '--layer', 'recall', '--apply'], env);
|
|
327
|
+
assertEq(guarded.status, 1, '保护性拒跑 ⇒ rc=1');
|
|
328
|
+
includes(guarded.stderr, '--force', '要告诉人怎么过闸');
|
|
329
|
+
assertEq(countRows(dbPath, 'SELECT COUNT(*) AS n FROM memories'), 6, '拒跑时主表完好');
|
|
330
|
+
|
|
331
|
+
const forced = run(CLI.move, ['--db', dbPath, '--layer', 'recall', '--apply', '--force'], env);
|
|
332
|
+
assertEq(forced.status, 0, `--force 后应成功\n${forced.stderr}`);
|
|
333
|
+
includes(forced.stdout, '守恒', '要打守恒式');
|
|
334
|
+
assertEq(countRows(dbPath, 'SELECT COUNT(*) AS n FROM memories'), 0, '现在真搬空了');
|
|
335
|
+
} finally {
|
|
336
|
+
t.cleanup();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('layer-move 搬回前核指纹:边表被改过 ⇒ 整批拒动(rc=1),主表不动', () => {
|
|
341
|
+
const t = makeTempRoot('k5-move-tamper-');
|
|
342
|
+
try {
|
|
343
|
+
const { dbPath, cfg } = preparedDb(t.root);
|
|
344
|
+
execSql(dbPath, "UPDATE memories SET layer = 'archival' WHERE id IN (2, 4)");
|
|
345
|
+
moveOut({ dbPath, cfg, layer: 'archival', batch: 'tamper' });
|
|
346
|
+
execSql(dbPath, `UPDATE ${EVICTED_TABLE} SET text = '被改过' WHERE id = 4`, []);
|
|
347
|
+
|
|
348
|
+
const env = isoEnv(t.root);
|
|
349
|
+
const r = run(CLI.move, ['--db', dbPath, '--layer', 'archival', '--back', '--apply'], env);
|
|
350
|
+
assertEq(r.status, 1, '指纹不符 ⇒ rc=1');
|
|
351
|
+
includes(r.stderr, '指纹', '要说清是哪条判据拦的');
|
|
352
|
+
assertEq(countRows(dbPath, 'SELECT COUNT(*) AS n FROM memories'), 4, '主表不动(没搬回任何一行)');
|
|
353
|
+
assertEq(countRows(dbPath, `SELECT COUNT(*) AS n FROM ${EVICTED_TABLE}`), 2, '边表也原样');
|
|
354
|
+
} finally {
|
|
355
|
+
t.cleanup();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('layer-move 用法闸:层名不在集合里 / 未知参数 / 库不存在', () => {
|
|
360
|
+
const t = makeTempRoot('k5-move-usage-');
|
|
361
|
+
try {
|
|
362
|
+
const { dbPath } = preparedDb(t.root);
|
|
363
|
+
const env = isoEnv(t.root);
|
|
364
|
+
const badLayer = run(CLI.move, ['--db', dbPath, '--layer', 'nope'], env);
|
|
365
|
+
assertEq(badLayer.status, 2, '层名不在集合里 ⇒ rc=2');
|
|
366
|
+
includes(badLayer.stderr, '层名集合', '要指出集合从哪来');
|
|
367
|
+
assertEq(run(CLI.move, ['--db', dbPath, '--layer', 'recall', '--bogus'], env).status, 2, '未知参数 ⇒ rc=2');
|
|
368
|
+
assertEq(run(CLI.move, ['--db', path.join(t.root, 'nope.db'), '--layer', 'recall'], env).status, 3, '库不存在 ⇒ rc=3');
|
|
369
|
+
} finally {
|
|
370
|
+
t.cleanup();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ───────────────────────────── ③ 号段哨兵 ─────────────────────────────
|
|
375
|
+
|
|
376
|
+
/** 造一本 id 段受控的夹具库(显式 id)。 */
|
|
377
|
+
function bandDb(root, ids) {
|
|
378
|
+
const dbPath = path.join(root, 'memory.db');
|
|
379
|
+
makeMemoryDb(
|
|
380
|
+
dbPath,
|
|
381
|
+
ids.map((id, i) => ({ id, text: `号段夹具 ${id}`, tags: '号段', pinned: 0, created_at: 1_760_000_000 + i, updated_at: 1_760_000_000 + i })),
|
|
382
|
+
);
|
|
383
|
+
return dbPath;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
test('号段哨兵 单机正常 ⇒ rc=0(不 ssh、只读本机)', () => {
|
|
387
|
+
const t = makeTempRoot('k5-band-ok-');
|
|
388
|
+
try {
|
|
389
|
+
const dbPath = bandDb(t.root, [1, 2, 3, 4, 5, 6]);
|
|
390
|
+
const cfgFile = writeJson(path.join(t.root, 'bands.json'), { self: 'local', width: 1000, bands: [{ host: 'local', base: 1 }] });
|
|
391
|
+
const r = run(CLI.sentinel, ['--db', dbPath, '--config', cfgFile, '--line'], isoEnv(t.root));
|
|
392
|
+
assertEq(r.status, 0, `正常应 rc=0\n${r.stdout}\n${r.stderr}`);
|
|
393
|
+
includes(r.stdout, 'PASS', '结论');
|
|
394
|
+
includes(r.stdout, 'PASS · local seq=6→7', '一行里带 seq 与下一条');
|
|
395
|
+
} finally {
|
|
396
|
+
t.cleanup();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('号段哨兵 越过上界 ⇒ rc=3(阳性对照);低于下界也 rc=3', () => {
|
|
401
|
+
const t = makeTempRoot('k5-band-over-');
|
|
402
|
+
try {
|
|
403
|
+
const dbPath = bandDb(t.root, [1, 2, 3, 4, 5, 6]);
|
|
404
|
+
const env = isoEnv(t.root);
|
|
405
|
+
const narrow = writeJson(path.join(t.root, 'narrow.json'), { self: 'local', width: 3, bands: [{ host: 'local', base: 1 }] });
|
|
406
|
+
const over = run(CLI.sentinel, ['--db', dbPath, '--config', narrow], env);
|
|
407
|
+
assertEq(over.status, 3, '下一条 id 冲出上界 ⇒ rc=3');
|
|
408
|
+
includes(over.stdout, '冲出本段上界', '要点名是哪条判据');
|
|
409
|
+
|
|
410
|
+
const highBase = writeJson(path.join(t.root, 'high.json'), { self: 'local', width: 1000, bands: [{ host: 'local', base: 100 }] });
|
|
411
|
+
const under = run(CLI.sentinel, ['--db', dbPath, '--config', highBase], env);
|
|
412
|
+
assertEq(under.status, 3, '下一条 id 低于下界 ⇒ rc=3');
|
|
413
|
+
includes(under.stdout, '低于本段下界', '要点名是哪条判据');
|
|
414
|
+
} finally {
|
|
415
|
+
t.cleanup();
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('号段哨兵 更高位段有行 ⇒ 红;更低位的别台段行 ⇒ 只披露不判红', () => {
|
|
420
|
+
const t = makeTempRoot('k5-band-cross-');
|
|
421
|
+
try {
|
|
422
|
+
const env = isoEnv(t.root);
|
|
423
|
+
// ① 更高位段:self 在 1001 段,peer 在 2001 段 —— peer 段出现行 ⇒ 红
|
|
424
|
+
const higher = bandDb(path.join(t.root), [1001, 1002, 1003, 2005]);
|
|
425
|
+
const cfgHigher = writeJson(path.join(t.root, 'higher.json'), {
|
|
426
|
+
self: 'self', width: 1000, bands: [{ host: 'self', base: 1001 }, { host: 'peer', base: 2001 }],
|
|
427
|
+
});
|
|
428
|
+
const r1 = run(CLI.sentinel, ['--db', higher, '--config', cfgHigher], env);
|
|
429
|
+
assertEq(r1.status, 3, '更高位段有行 ⇒ rc=3');
|
|
430
|
+
includes(r1.stdout, '更高位段', '要说清是哪一段');
|
|
431
|
+
|
|
432
|
+
// ② 更低位的别台段:peer 在 1 段(有历史行),self 在 1001 段且 seq 正常 ⇒ 只披露
|
|
433
|
+
const lower = bandDb(path.join(t.root, 'lower'), [1, 2, 6, 1001, 1002, 1003]);
|
|
434
|
+
const cfgLower = writeJson(path.join(t.root, 'lower.json'), {
|
|
435
|
+
self: 'self', width: 1000, bands: [{ host: 'peer', base: 1 }, { host: 'self', base: 1001 }],
|
|
436
|
+
});
|
|
437
|
+
const r2 = run(CLI.sentinel, ['--db', lower, '--config', cfgLower], env);
|
|
438
|
+
assertEq(r2.status, 0, `更低位的别台段只披露,不许判红\n${r2.stdout}`);
|
|
439
|
+
includes(r2.stdout, '不判红', '要明说这条不判');
|
|
440
|
+
} finally {
|
|
441
|
+
t.cleanup();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('号段哨兵 本机库读不到 ⇒ rc=4(真失败),不是 3、也不是 0', () => {
|
|
446
|
+
const t = makeTempRoot('k5-band-missing-');
|
|
447
|
+
try {
|
|
448
|
+
const cfgFile = writeJson(path.join(t.root, 'bands.json'), { self: 'local', width: 1000, bands: [{ host: 'local', base: 1 }] });
|
|
449
|
+
const r = run(CLI.sentinel, ['--db', path.join(t.root, 'nope.db'), '--config', cfgFile, '--line'], isoEnv(t.root));
|
|
450
|
+
assertEq(r.status, 4, '本机库读不到 ⇒ rc=4(fail-closed)');
|
|
451
|
+
includes(r.stderr, 'fail-closed', '要说清这不是通过');
|
|
452
|
+
} finally {
|
|
453
|
+
t.cleanup();
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('号段哨兵 多机 + 远端读不到 ⇒ 结论写未探测,退出码**不是 4**', () => {
|
|
458
|
+
const t = makeTempRoot('k5-band-remote-');
|
|
459
|
+
try {
|
|
460
|
+
const dbPath = bandDb(t.root, [1001, 1002, 1003]);
|
|
461
|
+
const cfgFile = writeJson(path.join(t.root, 'bands.json'), {
|
|
462
|
+
self: 'local',
|
|
463
|
+
width: 1000,
|
|
464
|
+
bands: [
|
|
465
|
+
{ host: 'local', base: 1001 },
|
|
466
|
+
{ host: 'peer', base: 2001, remote: { ssh: 'k5-no-such-host-for-test', node: 'node' } },
|
|
467
|
+
],
|
|
468
|
+
});
|
|
469
|
+
const env = isoEnv(t.root);
|
|
470
|
+
const r = run(CLI.sentinel, ['--db', dbPath, '--config', cfgFile, '--timeout', '1500'], env);
|
|
471
|
+
assert(r.status !== 4, `远端读不到不许 rc=4(实际 ${r.status})\n${r.stderr}`);
|
|
472
|
+
assertEq(r.status, 0, '缺省不把"远端未探测"当红');
|
|
473
|
+
includes(`${r.stdout}${r.stderr}`, '未探测', '结论要写未探测 / 不可达');
|
|
474
|
+
|
|
475
|
+
const strict = run(CLI.sentinel, ['--db', dbPath, '--config', cfgFile, '--timeout', '1500', '--require-all'], env);
|
|
476
|
+
assertEq(strict.status, 5, '--require-all 时才算失败(rc=5,仍不是 4)');
|
|
477
|
+
} finally {
|
|
478
|
+
t.cleanup();
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('号段哨兵 默认关:不落任何文件;--apply --out 才写 JSON,且 stdout 不出 JSON', () => {
|
|
483
|
+
const t = makeTempRoot('k5-band-report-');
|
|
484
|
+
try {
|
|
485
|
+
const dbPath = bandDb(t.root, [1, 2, 3]);
|
|
486
|
+
const cfgFile = writeJson(path.join(t.root, 'bands.json'), { self: 'local', width: 1000, bands: [{ host: 'local', base: 1 }] });
|
|
487
|
+
const outFile = path.join(t.root, 'report.json');
|
|
488
|
+
const env = isoEnv(t.root);
|
|
489
|
+
|
|
490
|
+
const plain = run(CLI.sentinel, ['--db', dbPath, '--config', cfgFile], env);
|
|
491
|
+
assertEq(plain.status, 0, `rc 应为 0\n${plain.stderr}`);
|
|
492
|
+
assertEq(fs.existsSync(outFile), false, '缺省模式一个文件都不写');
|
|
493
|
+
assert(!plain.stdout.includes('{"'), 'stdout 不许吐 JSON');
|
|
494
|
+
|
|
495
|
+
const applied = run(CLI.sentinel, ['--db', dbPath, '--config', cfgFile, '--apply', '--out', outFile], env);
|
|
496
|
+
assertEq(applied.status, 0, `落盘模式 rc 应为 0\n${applied.stderr}`);
|
|
497
|
+
assert(fs.existsSync(outFile), '--apply --out 才落盘');
|
|
498
|
+
const payload = JSON.parse(fs.readFileSync(outFile, 'utf8'));
|
|
499
|
+
assertEq(payload.verdict, 'PASS', '报告里 verdict');
|
|
500
|
+
assertEq(payload.mode, 'single', '报告里模式 = 单机');
|
|
501
|
+
assert(!applied.stdout.includes('"tool":'), 'stdout 不出 JSON(只落文件)');
|
|
502
|
+
|
|
503
|
+
const jsonWithoutOut = run(CLI.sentinel, ['--db', dbPath, '--config', cfgFile, '--json'], env);
|
|
504
|
+
assertEq(jsonWithoutOut.status, 2, '--json 不配 --apply --out ⇒ rc=2');
|
|
505
|
+
assertEq(run(CLI.sentinel, ['--db', dbPath, '--config', cfgFile, '--bogus'], env).status, 2, '未知参数 ⇒ rc=2');
|
|
506
|
+
} finally {
|
|
507
|
+
t.cleanup();
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('号段哨兵:三个 CLi 的 --help 都通,且写清两种模式与退出码', () => {
|
|
512
|
+
const env = isoEnv(os.tmpdir());
|
|
513
|
+
for (const [name, script] of Object.entries(CLI)) {
|
|
514
|
+
const r = run(script, ['--help'], env);
|
|
515
|
+
assertEq(r.status, 0, `${name} --help 应 rc=0`);
|
|
516
|
+
includes(r.stdout, '退出码', `${name} --help 要写退出码`);
|
|
517
|
+
}
|
|
518
|
+
const sentinelHelp = run(CLI.sentinel, ['--help'], env).stdout;
|
|
519
|
+
includes(sentinelHelp, '单机', '要写单机模式');
|
|
520
|
+
includes(sentinelHelp, '多机', '要多机模式');
|
|
521
|
+
const moveHelp = run(CLI.move, ['--help'], env).stdout;
|
|
522
|
+
includes(moveHelp, '不删', '要写清搬走不等于删');
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// ───────────────────────────── ④ 夹具库自洽 ─────────────────────────────
|
|
526
|
+
|
|
527
|
+
test('行指纹算法:同内容同指纹、改一个字段就变(它是"逐行一致"的唯一判据)', () => {
|
|
528
|
+
const cfg = loadLayerConfig({ file: null, env: {} });
|
|
529
|
+
const row = { id: 7, text: '正文', tags: 'a b', pinned: 0, created_at: 1, updated_at: 2, layer: 'recall' };
|
|
530
|
+
const d1 = rowDigest(row, cfg);
|
|
531
|
+
const d2 = rowDigest({ ...row }, cfg);
|
|
532
|
+
assertEq(d1, d2, '同内容 ⇒ 同指纹');
|
|
533
|
+
assert(rowDigest({ ...row, text: '正文改' }, cfg) !== d1, '改正文 ⇒ 指纹变');
|
|
534
|
+
assert(rowDigest({ ...row, layer: 'archival' }, cfg) !== d1, '改层 ⇒ 指纹变');
|
|
535
|
+
const cmp = compareDigests(new Map([[1, 'a'], [2, 'b']]), new Map([[2, 'c'], [3, 'd']]));
|
|
536
|
+
assertEq(cmp.missing, [1], '缺行');
|
|
537
|
+
assertEq(cmp.changed, [2], '变行');
|
|
538
|
+
assertEq(cmp.extra, [3], '多行');
|
|
539
|
+
});
|