@kira905/dsh-memory-toolkit 0.1.3 → 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 +92 -0
- package/README.en.md +225 -2
- package/README.md +180 -2
- 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 +1 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// 号段哨兵(单机退化版):看「下一条新 id 会不会落到不属于这台机的号段里」。
|
|
2
|
+
//
|
|
3
|
+
// 单机 / 多机怎么退化:
|
|
4
|
+
// · 缺省(配置里只有一条 band)= **单写者检查**:只读本机库的 sqlite_sequence,
|
|
5
|
+
// 判 nextId 是否落在 [base, base+width);不 ssh、不猜别的机器、不假装知道对端。
|
|
6
|
+
// · 配置里有多条 band 且给了 remote(ssh 别名 + 远端 node 路径)时,才逐台去读。
|
|
7
|
+
// **任一远端读不到 ⇒ 结论写「未探测 / 不可达」,退出码不得为 4**
|
|
8
|
+
// (4 只留给「本机库读不到」这种真失败 —— 这是生产件踩过的坑,别改回去)。
|
|
9
|
+
//
|
|
10
|
+
// 判据(沿用生产件的好口径,实现自己写):
|
|
11
|
+
// ① 主判据:seq 决定的下一条新 id 必须 ∈ [base, base+width);低了下界 / 冲了上界都判红;
|
|
12
|
+
// ② 出现**更高位段**的行 ⇒ 红(更高位 id 不可能由这一台合法产生);
|
|
13
|
+
// ③ 出现**更低位的别台段**行 ⇒ 只披露不判红(历史搬入 / 合并搬入共用段,删行=删记忆);
|
|
14
|
+
// ④ 本机库读不到 / 打不开 ⇒ rc=4(fail-closed,不算通过)。
|
|
15
|
+
//
|
|
16
|
+
// 本文件纯只读:零 INSERT/UPDATE/DELETE,不碰 sqlite_sequence,不 VACUUM、不 checkpoint。
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
23
|
+
import { readJsonFile, defaultBandsConfigPath } from './layers.mjs';
|
|
24
|
+
|
|
25
|
+
export const VERSION = '1.0.0';
|
|
26
|
+
export const MARK = 'K5BAND:';
|
|
27
|
+
export const EXIT = Object.freeze({ OK: 0, FAIL: 1, USAGE: 2, BAND: 3, LOCAL_UNREADABLE: 4, UNPROBED: 5 });
|
|
28
|
+
|
|
29
|
+
const DEFAULTS = Object.freeze({ width: 1_000_000, base: 2_000_000 });
|
|
30
|
+
const HOST_KEY = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$/;
|
|
31
|
+
|
|
32
|
+
function positiveInt(value, where) {
|
|
33
|
+
const n = Number(value);
|
|
34
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`${where} 必须是正整数,实际是 ${JSON.stringify(value)}`);
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 读号段配置。号段表**只在配置里**:代码里不出现任何具体机器名,
|
|
40
|
+
* 也没有"必须三台机"的假设(单机装一条 band 就够)。
|
|
41
|
+
*/
|
|
42
|
+
export function loadBandConfig({ file = null, env = process.env } = {}) {
|
|
43
|
+
const cfgPath = file ? path.resolve(file) : path.resolve(env.MEMORY_ENGINE_BANDS_FILE || defaultBandsConfigPath());
|
|
44
|
+
const disk = readJsonFile(cfgPath);
|
|
45
|
+
const warnings = [];
|
|
46
|
+
if (!disk.ok && disk.reason !== 'missing') throw new Error(`号段配置读不了(${cfgPath}):${disk.reason}`);
|
|
47
|
+
if (!disk.ok) warnings.push(`号段配置不在(${cfgPath})⇒ 用单机兜底:base=${DEFAULTS.base} width=${DEFAULTS.width}`);
|
|
48
|
+
const raw = disk.ok && disk.value && typeof disk.value === 'object' ? disk.value : {};
|
|
49
|
+
|
|
50
|
+
const width = positiveInt(raw.width ?? DEFAULTS.width, '配置 width');
|
|
51
|
+
const list = Array.isArray(raw.bands) && raw.bands.length ? raw.bands : [{ host: 'local', base: DEFAULTS.base }];
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const bands = list.map((item, index) => {
|
|
54
|
+
const host = String(item?.host ?? '').trim() || `band${index}`;
|
|
55
|
+
if (!HOST_KEY.test(host)) throw new Error(`第 ${index + 1} 条 band 的 host 不合法:${JSON.stringify(host)}`);
|
|
56
|
+
if (seen.has(host.toLowerCase())) throw new Error(`band 的 host 重复:${host}`);
|
|
57
|
+
seen.add(host.toLowerCase());
|
|
58
|
+
const base = positiveInt(item?.base ?? DEFAULTS.base + index * width, `band「${host}」的 base`);
|
|
59
|
+
const remote = item?.remote && typeof item.remote === 'object' ? { ...item.remote } : null;
|
|
60
|
+
if (remote) {
|
|
61
|
+
if (!remote.ssh) throw new Error(`band「${host}」的 remote 缺 ssh 别名`);
|
|
62
|
+
remote.node = String(remote.node || 'node');
|
|
63
|
+
remote.db = remote.db ? String(remote.db) : '';
|
|
64
|
+
}
|
|
65
|
+
return { host, base, remote };
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const sorted = [...bands].sort((a, b) => a.base - b.base);
|
|
69
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
70
|
+
if (sorted[i].base < sorted[i - 1].base + width) {
|
|
71
|
+
throw new Error(`号段重叠:${sorted[i - 1].host} 的 [${sorted[i - 1].base}, ${sorted[i - 1].base + width}) 与 ${sorted[i].host} 的起点 ${sorted[i].base} 相交`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const self = String(raw.self ?? bands[0].host).trim();
|
|
76
|
+
const selfBand = bands.find((b) => b.host.toLowerCase() === self.toLowerCase());
|
|
77
|
+
if (!selfBand) throw new Error(`配置 self=「${self}」不在 bands 里(有一台机没写进号段表)`);
|
|
78
|
+
|
|
79
|
+
return { file: cfgPath, fileUsed: disk.ok, width, bands, self: selfBand.host, warnings };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 每一段的闭开区间 [lo, hi) + 首段下界之上/末段上界之外的兜底两段。 */
|
|
83
|
+
export function bandRanges(cfg) {
|
|
84
|
+
const sorted = [...cfg.bands].sort((a, b) => a.base - b.base);
|
|
85
|
+
return sorted.map((b) => [b.base, b.base + cfg.width]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function emptyCounts(n) {
|
|
89
|
+
const counts = new Array(n).fill(0);
|
|
90
|
+
return { counts, below: 0, above: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 本机(或任意本地文件)只读快照。 */
|
|
94
|
+
export function snapshotLocal(dbPath, ranges) {
|
|
95
|
+
const abs = path.resolve(dbPath);
|
|
96
|
+
const out = {
|
|
97
|
+
ok: false,
|
|
98
|
+
via: 'local',
|
|
99
|
+
dbPath: abs,
|
|
100
|
+
host: os.hostname(),
|
|
101
|
+
node: process.version,
|
|
102
|
+
seq: null,
|
|
103
|
+
rows: null,
|
|
104
|
+
minId: null,
|
|
105
|
+
maxId: null,
|
|
106
|
+
counts: emptyCounts(ranges.length).counts,
|
|
107
|
+
below: 0,
|
|
108
|
+
above: 0,
|
|
109
|
+
error: null,
|
|
110
|
+
};
|
|
111
|
+
if (!fs.existsSync(abs)) {
|
|
112
|
+
out.error = `库文件不存在:${abs}`;
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
let db = null;
|
|
116
|
+
try {
|
|
117
|
+
db = new DatabaseSync(abs, { readOnly: true });
|
|
118
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
119
|
+
const seqRow = db.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'memories'").get();
|
|
120
|
+
out.seq = seqRow && seqRow.seq !== undefined ? Number(seqRow.seq) : null;
|
|
121
|
+
const agg = db.prepare('SELECT COUNT(*) AS n, MIN(id) AS lo, MAX(id) AS hi FROM memories').get();
|
|
122
|
+
out.rows = Number(agg.n);
|
|
123
|
+
out.minId = agg.lo === null ? null : Number(agg.lo);
|
|
124
|
+
out.maxId = agg.hi === null ? null : Number(agg.hi);
|
|
125
|
+
const between = db.prepare('SELECT COUNT(*) AS n FROM memories WHERE id >= ? AND id < ?');
|
|
126
|
+
out.counts = ranges.map(([lo, hi]) => Number(between.get(Number(lo), Number(hi)).n));
|
|
127
|
+
const first = ranges[0];
|
|
128
|
+
const last = ranges[ranges.length - 1];
|
|
129
|
+
out.below = Number(db.prepare('SELECT COUNT(*) AS n FROM memories WHERE id < ?').get(Number(first[0])).n);
|
|
130
|
+
out.above = Number(db.prepare('SELECT COUNT(*) AS n FROM memories WHERE id >= ?').get(Number(last[1])).n);
|
|
131
|
+
out.ok = true;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
out.error = `读不动:${e && e.message}`;
|
|
134
|
+
} finally {
|
|
135
|
+
try {
|
|
136
|
+
if (db) db.close();
|
|
137
|
+
} catch {
|
|
138
|
+
/* ignore */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 远端一段的取数脚本(经 stdin 喂给对端 node,对端不落任何文件)。 */
|
|
145
|
+
export function remoteScript({ dbPath = '', ranges }) {
|
|
146
|
+
const db = JSON.stringify(String(dbPath || ''));
|
|
147
|
+
const rg = JSON.stringify(ranges);
|
|
148
|
+
return [
|
|
149
|
+
"const {DatabaseSync}=require('node:sqlite');",
|
|
150
|
+
"const os=require('node:os'),path=require('node:path');",
|
|
151
|
+
`const DB=${db};const R=${rg};`,
|
|
152
|
+
"const p=DB||path.join(process.env.USERPROFILE||os.homedir(),'.dsh','memory','memory.db');",
|
|
153
|
+
"const o={host:os.hostname(),node:process.version,db:p};",
|
|
154
|
+
'try{',
|
|
155
|
+
'const d=new DatabaseSync(p,{readOnly:true});',
|
|
156
|
+
'const s=d.prepare("SELECT seq FROM sqlite_sequence WHERE name=\'memories\'").get();',
|
|
157
|
+
'o.seq=s&&s.seq!==undefined?Number(s.seq):null;',
|
|
158
|
+
"const a=d.prepare('SELECT COUNT(*) n,MIN(id) lo,MAX(id) hi FROM memories').get();",
|
|
159
|
+
'o.rows=Number(a.n);o.minId=a.lo===null?null:Number(a.lo);o.maxId=a.hi===null?null:Number(a.hi);',
|
|
160
|
+
"const q=d.prepare('SELECT COUNT(*) n FROM memories WHERE id>=? AND id<?');",
|
|
161
|
+
'o.counts=R.map(function(r){return Number(q.get(r[0],r[1]).n)});',
|
|
162
|
+
"o.below=Number(d.prepare('SELECT COUNT(*) n FROM memories WHERE id<?').get(R[0][0]).n);",
|
|
163
|
+
"o.above=Number(d.prepare('SELECT COUNT(*) n FROM memories WHERE id>=?').get(R[R.length-1][1]).n);",
|
|
164
|
+
'd.close();',
|
|
165
|
+
'}catch(e){o.error=String((e&&e.message)||e)}',
|
|
166
|
+
`process.stdout.write('${MARK}'+JSON.stringify(o)+'\\\\n');`,
|
|
167
|
+
].join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function firstLine(text) {
|
|
171
|
+
return String(text || '').trim().split(/\r?\n/).filter(Boolean)[0] || '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 远端一段:连不上 / 没吐约定 JSON ⇒ unreachable(**不是** 本机失败)。 */
|
|
175
|
+
export function probeRemote(band, { ranges, timeoutMs = 20000 } = {}) {
|
|
176
|
+
const remote = band.remote;
|
|
177
|
+
if (!remote || !remote.ssh) {
|
|
178
|
+
return { ok: false, via: 'unconfigured', unreachable: true, host: band.host, error: '配置里没给 remote.ssh(这一段未探测)' };
|
|
179
|
+
}
|
|
180
|
+
const script = remoteScript({ dbPath: remote.db, ranges });
|
|
181
|
+
const argv = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', remote.ssh, `${remote.node} -`];
|
|
182
|
+
const r = spawnSync('ssh', argv, {
|
|
183
|
+
input: script,
|
|
184
|
+
timeout: Math.max(1000, Number(timeoutMs) || 20000),
|
|
185
|
+
windowsHide: true,
|
|
186
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
187
|
+
});
|
|
188
|
+
const stdout = Buffer.isBuffer(r.stdout) ? r.stdout.toString('utf8') : String(r.stdout ?? '');
|
|
189
|
+
const stderr = Buffer.isBuffer(r.stderr) ? r.stderr.toString('utf8') : String(r.stderr ?? '');
|
|
190
|
+
const hit = stdout.split(/\r?\n/).find((line) => line.startsWith(MARK));
|
|
191
|
+
if (!hit) {
|
|
192
|
+
const why = r.error
|
|
193
|
+
? `ssh 调起失败:${r.error.message}`
|
|
194
|
+
: r.status === 255
|
|
195
|
+
? `连不上「${remote.ssh}」${stderr ? `:${firstLine(stderr)}` : ''}`
|
|
196
|
+
: `对端没吐出约定 JSON(rc=${r.status})${stderr ? `:${firstLine(stderr)}` : ''}`;
|
|
197
|
+
return { ok: false, via: `ssh:${remote.ssh}`, unreachable: true, host: band.host, error: why };
|
|
198
|
+
}
|
|
199
|
+
let data;
|
|
200
|
+
try {
|
|
201
|
+
data = JSON.parse(hit.slice(MARK.length));
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return { ok: false, via: `ssh:${remote.ssh}`, unreachable: true, host: band.host, error: `对端 JSON 解不开:${e && e.message}` };
|
|
204
|
+
}
|
|
205
|
+
if (data.error) {
|
|
206
|
+
return { ok: false, via: `ssh:${remote.ssh}`, unreachable: true, host: band.host, dbPath: data.db, error: `对端读库失败:${data.error}` };
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
via: `ssh:${remote.ssh}`,
|
|
211
|
+
host: data.host || band.host,
|
|
212
|
+
node: data.node,
|
|
213
|
+
dbPath: data.db,
|
|
214
|
+
seq: data.seq === undefined ? null : Number(data.seq),
|
|
215
|
+
rows: Number(data.rows),
|
|
216
|
+
minId: data.minId === null ? null : Number(data.minId),
|
|
217
|
+
maxId: data.maxId === null ? null : Number(data.maxId),
|
|
218
|
+
counts: Array.isArray(data.counts) ? data.counts.map(Number) : [],
|
|
219
|
+
below: Number(data.below ?? 0),
|
|
220
|
+
above: Number(data.above ?? 0),
|
|
221
|
+
error: null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 一台的判据:problems 判红、disclosures 只披露、warnings 提醒。 */
|
|
226
|
+
export function judgeSnapshot(rec, { bands, width, selfHost }) {
|
|
227
|
+
const problems = [];
|
|
228
|
+
const disclosures = [];
|
|
229
|
+
const warnings = [];
|
|
230
|
+
const label = rec.host || rec.label || '?';
|
|
231
|
+
const ownIndex = bands.findIndex((b) => b.host.toLowerCase() === String(label).toLowerCase());
|
|
232
|
+
const band = bands[ownIndex];
|
|
233
|
+
if (!band) {
|
|
234
|
+
problems.push(`「${label}」不在号段表里 —— 无法判它的段`);
|
|
235
|
+
return { problems, disclosures, warnings };
|
|
236
|
+
}
|
|
237
|
+
const lo = band.base;
|
|
238
|
+
const hi = band.base + width;
|
|
239
|
+
|
|
240
|
+
if (rec.seq === null || rec.seq === undefined) {
|
|
241
|
+
problems.push('读不到 sqlite_sequence(memories)⇒ 判不了下一条新 id');
|
|
242
|
+
} else {
|
|
243
|
+
const nextId = rec.seq + 1;
|
|
244
|
+
rec.nextId = nextId;
|
|
245
|
+
if (nextId < lo) {
|
|
246
|
+
problems.push(`seq=${rec.seq} ⇒ 下一条新 id=${nextId} 低于本段下界 ${lo}(号段未落位 / 被回退,应落在 [${lo}, ${hi}))`);
|
|
247
|
+
} else if (nextId >= hi) {
|
|
248
|
+
problems.push(`seq=${rec.seq} ⇒ 下一条新 id=${nextId} 冲出本段上界 [${lo}, ${hi})(号段被顶高)`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const counts = rec.counts || [];
|
|
253
|
+
for (let i = 0; i < bands.length; i += 1) {
|
|
254
|
+
const n = Number(counts[i] ?? 0);
|
|
255
|
+
if (n <= 0 || i === ownIndex) continue;
|
|
256
|
+
const other = bands[i];
|
|
257
|
+
if (other.base > band.base) {
|
|
258
|
+
problems.push(`出现更高位段 ${n} 行([${other.base}, ${other.base + width}) 属于「${other.host}」)—— 更高位 id 不可能由「${label}」合法产生`);
|
|
259
|
+
} else {
|
|
260
|
+
disclosures.push(`历史搬入行 ${n} 行([${other.base}, ${other.base + width}) 属于「${other.host}」,比本段低)—— 不判红:删行=删记忆,且不影响后续新 id`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const above = Number(rec.above ?? 0);
|
|
264
|
+
if (above > 0) problems.push(`出现 ${above} 行超出全部号段(id ≥ ${bands[bands.length - 1].base + width})—— 号段表里没这一段`);
|
|
265
|
+
const below = Number(rec.below ?? 0);
|
|
266
|
+
if (below > 0) disclosures.push(`更低位段 ${below} 行(id < ${bands[0].base},历史遗留/合并搬入)—— 不判红`);
|
|
267
|
+
|
|
268
|
+
const own = Number(counts[ownIndex] ?? 0);
|
|
269
|
+
rec.ownBandIndex = ownIndex;
|
|
270
|
+
rec.ownBandRows = own;
|
|
271
|
+
if (own === 0) warnings.push(`本段 [${lo}, ${hi}) 暂时没有行(只说明还没写过新记忆,不判异常)`);
|
|
272
|
+
return { problems, disclosures, warnings };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 跑一遍哨兵。
|
|
277
|
+
* @returns 报告对象(含 verdict / exitCode / hosts / problems / disclosures / warnings)
|
|
278
|
+
*/
|
|
279
|
+
export function evaluate({ config, dbPath, only = null, overrides = {}, timeoutMs = 20000 } = {}) {
|
|
280
|
+
const bands = config.bands.map((b) => {
|
|
281
|
+
const key = b.host.toLowerCase();
|
|
282
|
+
const override = overrides[key];
|
|
283
|
+
return override ? { ...b, base: Number(override) } : b;
|
|
284
|
+
});
|
|
285
|
+
const ranges = bandRanges({ ...config, bands });
|
|
286
|
+
const wanted = only && only.length ? new Set(only.map((s) => s.toLowerCase())) : null;
|
|
287
|
+
|
|
288
|
+
const report = {
|
|
289
|
+
tool: 'band-sentinel',
|
|
290
|
+
version: VERSION,
|
|
291
|
+
at: new Date().toISOString(),
|
|
292
|
+
onHost: os.hostname(),
|
|
293
|
+
node: process.version,
|
|
294
|
+
config: { file: config.file, fileUsed: config.fileUsed, self: config.self, width: config.width, bands },
|
|
295
|
+
mode: bands.length > 1 ? 'multi' : 'single',
|
|
296
|
+
criterion:
|
|
297
|
+
'seq 决定的下一条新 id 必须落在本段内;出现更高位段的行判红;更低位的别台段行只披露不判红;本机库读不到才 rc=4',
|
|
298
|
+
hosts: [],
|
|
299
|
+
problems: [],
|
|
300
|
+
disclosures: [...config.warnings],
|
|
301
|
+
warnings: [],
|
|
302
|
+
unprobed: [],
|
|
303
|
+
localUnreadable: false,
|
|
304
|
+
verdict: 'PASS',
|
|
305
|
+
exitCode: EXIT.OK,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
for (const band of bands) {
|
|
309
|
+
if (wanted && !wanted.has(band.host.toLowerCase())) continue;
|
|
310
|
+
const isSelf = band.host.toLowerCase() === String(config.self).toLowerCase();
|
|
311
|
+
let rec;
|
|
312
|
+
let localFailure = false;
|
|
313
|
+
if (isSelf) {
|
|
314
|
+
rec = snapshotLocal(dbPath, ranges);
|
|
315
|
+
rec.osHost = rec.host;
|
|
316
|
+
rec.host = band.host;
|
|
317
|
+
rec.label = band.host;
|
|
318
|
+
localFailure = !rec.ok;
|
|
319
|
+
} else if (!band.remote) {
|
|
320
|
+
report.unprobed.push(`${band.host}:未探测(配置里没给 remote.ssh)`);
|
|
321
|
+
report.hosts.push({ host: band.host, label: band.host, via: 'unconfigured', ok: false, unprobed: true, base: band.base, error: '未探测(配置里没给 remote.ssh)' });
|
|
322
|
+
continue;
|
|
323
|
+
} else {
|
|
324
|
+
rec = probeRemote(band, { ranges, timeoutMs });
|
|
325
|
+
rec.osHost = rec.host;
|
|
326
|
+
rec.host = band.host;
|
|
327
|
+
rec.label = band.host;
|
|
328
|
+
}
|
|
329
|
+
rec.base = band.base;
|
|
330
|
+
rec.isSelf = isSelf;
|
|
331
|
+
rec.seq = rec.seq === undefined ? null : rec.seq;
|
|
332
|
+
|
|
333
|
+
if (!rec.ok) {
|
|
334
|
+
rec.problems = [];
|
|
335
|
+
rec.disclosures = [];
|
|
336
|
+
rec.warnings = [];
|
|
337
|
+
report.hosts.push(rec);
|
|
338
|
+
if (localFailure) {
|
|
339
|
+
report.localUnreadable = true;
|
|
340
|
+
report.problems.push(`${band.host}:本机库读不到(fail-closed,不算通过)—— ${rec.error || '未知原因'}`);
|
|
341
|
+
} else {
|
|
342
|
+
report.unprobed.push(`${band.host}:未探测 / 不可达 —— ${rec.error || '未知原因'}`);
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const verdict = judgeSnapshot(rec, { bands, width: config.width, selfHost: config.self });
|
|
347
|
+
rec.problems = verdict.problems;
|
|
348
|
+
rec.disclosures = verdict.disclosures;
|
|
349
|
+
rec.warnings = verdict.warnings;
|
|
350
|
+
for (const p of verdict.problems) report.problems.push(`${band.host}:${p}`);
|
|
351
|
+
for (const d of verdict.disclosures) report.disclosures.push(`${band.host}:${d}`);
|
|
352
|
+
for (const w of verdict.warnings) report.warnings.push(`${band.host}:${w}`);
|
|
353
|
+
report.hosts.push(rec);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (report.localUnreadable) {
|
|
357
|
+
report.verdict = 'LOCAL-UNREADABLE';
|
|
358
|
+
report.exitCode = EXIT.LOCAL_UNREADABLE;
|
|
359
|
+
} else if (report.problems.length) {
|
|
360
|
+
report.verdict = 'FAIL';
|
|
361
|
+
report.exitCode = EXIT.BAND;
|
|
362
|
+
} else if (report.unprobed.length) {
|
|
363
|
+
report.verdict = 'PARTIAL';
|
|
364
|
+
report.exitCode = EXIT.OK;
|
|
365
|
+
} else {
|
|
366
|
+
report.verdict = 'PASS';
|
|
367
|
+
report.exitCode = EXIT.OK;
|
|
368
|
+
}
|
|
369
|
+
return report;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** 报告里的时间一律本地时(ISO 里存的是 UTC,人读的还是本地钟点)。 */
|
|
373
|
+
function localStamp(iso) {
|
|
374
|
+
const d = iso ? new Date(iso) : new Date();
|
|
375
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
376
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function formatLine(report) {
|
|
380
|
+
const bits = report.hosts.map((h) => {
|
|
381
|
+
if (h.unprobed) return `${h.host}=未探测`;
|
|
382
|
+
if (!h.ok) return `${h.host}=读不到`;
|
|
383
|
+
const mark = h.problems.length ? '✗' : '✓';
|
|
384
|
+
return `${h.host} seq=${h.seq}→${h.nextId ?? '?'}${mark}`;
|
|
385
|
+
});
|
|
386
|
+
return `[号段哨兵] ${localStamp(report.at)} ${report.verdict} · ${bits.join(' · ')}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function formatHuman(report) {
|
|
390
|
+
const lines = [];
|
|
391
|
+
lines.push(`[band-sentinel] v${VERSION} · ${localStamp(report.at)} · 本机=${report.onHost} · node ${report.node}`);
|
|
392
|
+
lines.push(` 模式:${report.mode === 'single' ? '单机(单写者号段检查,不 ssh)' : `多机(${report.config.bands.length} 段)`} · 本机段=${report.config.self} · 段宽=${report.config.width}`);
|
|
393
|
+
lines.push(` 配置:${report.config.file}${report.config.fileUsed ? '' : '(文件不在 ⇒ 用兜底)'}`);
|
|
394
|
+
for (const h of report.hosts) {
|
|
395
|
+
if (h.unprobed) {
|
|
396
|
+
lines.push(` ${String(h.host).padEnd(8)} ⚠️ 未探测 —— ${h.error}`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (!h.ok) {
|
|
400
|
+
lines.push(` ${String(h.host).padEnd(8)} ✗ 读不到 [${h.via}] ${h.error}`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const mark = h.problems.length ? '✗' : '✓';
|
|
404
|
+
const ownRows = h.ownBandRows === undefined ? '?' : h.ownBandRows;
|
|
405
|
+
lines.push(
|
|
406
|
+
` ${String(h.host).padEnd(8)} ${mark} seq=${h.seq} → 下一条 ${h.nextId ?? '?'}|期望 [${h.base}, ${h.base + report.config.width})|` +
|
|
407
|
+
`本段 ${ownRows} 行|取数 ${h.via}${h.dbPath ? `|库 ${h.dbPath}` : ''}`,
|
|
408
|
+
);
|
|
409
|
+
for (const p of h.problems) lines.push(` ✗ ${p}`);
|
|
410
|
+
for (const d of h.disclosures) lines.push(` ℹ️ ${d}`);
|
|
411
|
+
for (const w of h.warnings) lines.push(` ⚠️ ${w}`);
|
|
412
|
+
}
|
|
413
|
+
if (report.problems.length) lines.push(` 结论 FAIL(${report.problems.length} 条异常,rc=${report.exitCode})`);
|
|
414
|
+
else if (report.unprobed.length) lines.push(` 结论 PARTIAL(${report.unprobed.length} 段未探测 / 不可达,rc=${report.exitCode} —— 不算红,但也别当全部正常)`);
|
|
415
|
+
else lines.push(` 结论 PASS(${report.hosts.length} 段全部正常)`);
|
|
416
|
+
return lines.join('\n');
|
|
417
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// 严格命令行层:复用 `parseArgs`,但把「没声明过的开关」当成错误。
|
|
2
|
+
//
|
|
3
|
+
// 为什么另外包一层:仓库里的 `parseArgs` 有意不做白名单(它给各命令留自由),
|
|
4
|
+
// 而这一批命令都要「未知参数 fail-closed」——拼错一个字母不许静默跑成默认行为。
|
|
5
|
+
// 这里只做三件事:白名单校验、类型收口、以及 JSON 只落文件的统一出口。
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { parseArgs } from './cli-util.mjs';
|
|
10
|
+
|
|
11
|
+
/** 通用退出码(与既有 CLI 同口径,个别命令另有扩展)。 */
|
|
12
|
+
export const EXIT = Object.freeze({
|
|
13
|
+
OK: 0,
|
|
14
|
+
FAIL: 1,
|
|
15
|
+
USAGE: 2,
|
|
16
|
+
UNREADABLE: 3,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** 声明式开关表:值取 'string' | 'bool' | 'number'。`help` / `h` 自动带上。 */
|
|
20
|
+
export function parseCli(argv, spec) {
|
|
21
|
+
const table = { help: 'bool', h: 'bool', ...spec };
|
|
22
|
+
const { flags: raw, rest } = parseArgs(argv);
|
|
23
|
+
|
|
24
|
+
const unknown = [];
|
|
25
|
+
for (const key of Object.keys(raw)) {
|
|
26
|
+
if (!Object.prototype.hasOwnProperty.call(table, key)) unknown.push(key);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const flags = Object.create(null);
|
|
30
|
+
const invalid = [];
|
|
31
|
+
for (const [key, kind] of Object.entries(table)) {
|
|
32
|
+
const v = raw[key];
|
|
33
|
+
if (kind === 'bool') {
|
|
34
|
+
// 显式 `--flag=false` 也认(parseArgs 会把它当字符串)
|
|
35
|
+
flags[key] = v !== undefined && v !== 'false' && v !== false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (v === undefined || v === true) {
|
|
39
|
+
flags[key] = undefined;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (kind === 'number') {
|
|
43
|
+
const n = Number(v);
|
|
44
|
+
flags[key] = Number.isFinite(n) ? n : NaN;
|
|
45
|
+
if (!Number.isFinite(n)) invalid.push(key);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
flags[key] = String(v);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
flags,
|
|
53
|
+
rest,
|
|
54
|
+
unknown,
|
|
55
|
+
invalid,
|
|
56
|
+
help: Boolean(flags.help || flags.h),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 用法错:写到 stderr,退出码 2(不抛栈,卡会话里看见的是人话)。 */
|
|
61
|
+
export function usageError(lines) {
|
|
62
|
+
const text = Array.isArray(lines) ? lines.join('\n') : String(lines);
|
|
63
|
+
process.stderr.write(`${text}\n`);
|
|
64
|
+
process.exitCode = EXIT.USAGE;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 一次性把参数问题全报出来(未知开关 / 数值不合法 / 多余位置参数)。 */
|
|
68
|
+
export function rejectBadArgs(parsed, { allowRest = false } = {}) {
|
|
69
|
+
const bad = [];
|
|
70
|
+
if (parsed.unknown.length) bad.push(`未知参数:${parsed.unknown.map((k) => `--${k}`).join('、')}(--help 看可用开关)`);
|
|
71
|
+
if (parsed.invalid.length) bad.push(`数值不合法:${parsed.invalid.map((k) => `--${k}`).join('、')}`);
|
|
72
|
+
if (!allowRest && parsed.rest.length) bad.push(`多余的位置参数:${parsed.rest.join(' ')}`);
|
|
73
|
+
return bad;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 目录先建好再写(原子性靠同目录 rename,跨盘一律 copy + 校验)。 */
|
|
77
|
+
export function writeTextFile(file, text) {
|
|
78
|
+
const abs = path.resolve(file);
|
|
79
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
80
|
+
fs.writeFileSync(abs, text, 'utf8');
|
|
81
|
+
return abs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `--json` 的统一出口:**机器可读内容只落文件,不上 stdout**。
|
|
86
|
+
* 返回写到的绝对路径;没给 `--json` 就返回 null(调用方只打人话)。
|
|
87
|
+
*/
|
|
88
|
+
export function emitJsonReport({ json, out, payload }) {
|
|
89
|
+
if (!json) return null;
|
|
90
|
+
if (!out) {
|
|
91
|
+
throw new UsageError('`--json` 要配 `--out <file>`:JSON 只落文件,不上 stdout');
|
|
92
|
+
}
|
|
93
|
+
return writeTextFile(out, `${JSON.stringify(payload, null, 2)}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 用法错的自定义异常 —— 由各命令的 main 统一转成 rc=2。 */
|
|
97
|
+
export class UsageError extends Error {
|
|
98
|
+
constructor(message) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = 'UsageError';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 保护性拒跑(比如「这一搬会把主表搬空」):rc=1,只打人话,不打栈。 */
|
|
105
|
+
export class GuardError extends Error {
|
|
106
|
+
constructor(message) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = 'GuardError';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 把未知异常与用法错分别落到 rc(别的异常就是 rc=1)。 */
|
|
113
|
+
export function settle(err, { prefix = '' } = {}) {
|
|
114
|
+
const head = prefix ? `${prefix} ` : '';
|
|
115
|
+
if (err instanceof UsageError) {
|
|
116
|
+
process.stderr.write(`${head}❌ ${err.message}\n`);
|
|
117
|
+
process.exitCode = EXIT.USAGE;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (err instanceof GuardError || (err && err.name === 'GuardError')) {
|
|
121
|
+
process.stderr.write(`${head}❌ ${err.message}\n`);
|
|
122
|
+
process.exitCode = EXIT.FAIL;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const detail = err && err.stack ? err.stack : String(err);
|
|
126
|
+
process.stderr.write(`${head}❌ ${detail}\n`);
|
|
127
|
+
process.exitCode = EXIT.FAIL;
|
|
128
|
+
}
|