@kira905/dsh-diagnostics-extra 0.0.0-stage → 0.1.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 +54 -0
- package/LICENSE +661 -0
- package/README.en.md +269 -0
- package/README.md +248 -2
- package/attachments-audit.mjs +333 -0
- package/attachments-fetch.mjs +507 -0
- package/dsh-why.mjs +499 -0
- package/lib/attach-refs.mjs +224 -0
- package/lib/dsh-home.mjs +95 -0
- package/lib/generated-docs.mjs +397 -0
- package/lib/powershell-exe.mjs +188 -0
- package/lib/redact.mjs +157 -0
- package/lib/session-file-name.mjs +160 -0
- package/owner-todo-scan.mjs +425 -0
- package/package.json +45 -4
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================
|
|
3
|
+
// attachments-audit.mjs — 会话引用的图片附件 vs 本机实体 · 只读对账(卡 AT1 阶段 1 / 方案 C)
|
|
4
|
+
// ------------------------------------------------------------
|
|
5
|
+
// 为什么:从另一台机同步来的「含图会话」在本机续聊必挂,报
|
|
6
|
+
// `DeepSeek API stream from https://api.deepseek.com failed` / code=TRANSPORT
|
|
7
|
+
// —— 看着像网络故障,其实是历史图片实体(`attachments/v1/objects/<2>/<sha256>`)
|
|
8
|
+
// 不在本机:每轮请求前 prepareRequestImages 会扫**全部历史消息**里的图,
|
|
9
|
+
// 读不到实体抛普通 Error(非 LlmError),被兜底 catch 归成 TRANSPORT。
|
|
10
|
+
// 本脚本只做一件事:把「缺哪些对象 / 哪些会话受影响」变成**看得见的事实**。
|
|
11
|
+
//
|
|
12
|
+
// 用法(全部只读,除自身缓存外不写任何文件):
|
|
13
|
+
// node attachments-audit.mjs # 全量对账(默认带缓存)
|
|
14
|
+
// node attachments-audit.mjs --json # 机器可读
|
|
15
|
+
// node attachments-audit.mjs --session <id|前缀> # 只看一个会话
|
|
16
|
+
// node attachments-audit.mjs --quiet # 只出退出码(给开机/交接挂点)
|
|
17
|
+
// node attachments-audit.mjs --no-cache # 忽略缓存全量重扫
|
|
18
|
+
// node attachments-audit.mjs --top 30 # 摘要里最多列几个会话
|
|
19
|
+
// 退出码:0 = 无缺失 · 3 = 有缺失 · 2 = 用法错 · 4 = 环境不可用(读不到会话/附件根)
|
|
20
|
+
// ⚠️ 4 是 fail-closed:读不到 ≠ 没缺失(AT10 的教训)
|
|
21
|
+
//
|
|
22
|
+
// 缓存:`<home>/butler-attach-scan.json`(单机状态,键 = 相对路径 + size + mtimeMs)。
|
|
23
|
+
// 实测:首次全量 138 s → 缓存命中 1.2 s(573 个会话文件 / 735 MB zstd)。
|
|
24
|
+
// ⚠️ mtime 会被复制/搬动骗过(归档回流的教训)⇒ 键里**同时含 size**,
|
|
25
|
+
// 且 `--no-cache` 可强制全量重扫。
|
|
26
|
+
// 本文件的 `collectAudit()` 是 attachments-fetch.mjs 的**同一口径来源**,别在那边另写一套。
|
|
27
|
+
// ============================================================
|
|
28
|
+
import fs from 'node:fs'
|
|
29
|
+
import os from 'node:os'
|
|
30
|
+
import path from 'node:path'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import { resolveDshHome } from './lib/dsh-home.mjs'
|
|
33
|
+
import {
|
|
34
|
+
collectSessionRefs,
|
|
35
|
+
decodeSessionBuffer,
|
|
36
|
+
listSessionFiles,
|
|
37
|
+
normalizeSessionId,
|
|
38
|
+
objectPathFor,
|
|
39
|
+
objectsRootFor,
|
|
40
|
+
sessionTrees,
|
|
41
|
+
sha256File,
|
|
42
|
+
} from './lib/attach-refs.mjs'
|
|
43
|
+
|
|
44
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
45
|
+
const SELF = path.resolve(fileURLToPath(import.meta.url))
|
|
46
|
+
const CACHE_VERSION = 2
|
|
47
|
+
const CACHE_NAME = 'butler-attach-scan.json'
|
|
48
|
+
|
|
49
|
+
export function usage() {
|
|
50
|
+
return [
|
|
51
|
+
'usage: node attachments-audit.mjs [--home <dir>] [--session <id|prefix>] [--json] [--quiet]',
|
|
52
|
+
' [--no-cache] [--cache <file>] [--top <n>] [--sessions-only]',
|
|
53
|
+
' [--verify-objects]',
|
|
54
|
+
'',
|
|
55
|
+
' 只读对账:会话引用的 sha256 vs 本机 <home>/attachments/v1/objects 实体。',
|
|
56
|
+
' 退出码 0=无缺失 / 3=有缺失 / 2=用法错 / 4=环境不可用(读不到 ≠ 无缺失)',
|
|
57
|
+
].join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseAuditArgs(argv) {
|
|
61
|
+
const o = {
|
|
62
|
+
home: null, session: null, json: false, quiet: false, noCache: false,
|
|
63
|
+
cache: null, top: 20, sessionsOnly: false, verifyObjects: false, help: false, errors: [],
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const a = argv[i]
|
|
67
|
+
const next = () => {
|
|
68
|
+
const v = argv[++i]
|
|
69
|
+
if (v === undefined || v.startsWith('--')) { o.errors.push(`缺少参数值: ${a}`); return null }
|
|
70
|
+
return v
|
|
71
|
+
}
|
|
72
|
+
if (a === '--home') o.home = next()
|
|
73
|
+
else if (a === '--session') o.session = next()
|
|
74
|
+
else if (a === '--cache') o.cache = next()
|
|
75
|
+
else if (a === '--top') o.top = Number(next() ?? NaN)
|
|
76
|
+
else if (a === '--json') o.json = true
|
|
77
|
+
else if (a === '--quiet') o.quiet = true
|
|
78
|
+
else if (a === '--no-cache') o.noCache = true
|
|
79
|
+
else if (a === '--sessions-only') o.sessionsOnly = true
|
|
80
|
+
else if (a === '--verify-objects') o.verifyObjects = true
|
|
81
|
+
else if (a === '--help' || a === '-h') o.help = true
|
|
82
|
+
else o.errors.push(`未知参数: ${a}`)
|
|
83
|
+
}
|
|
84
|
+
if (!Number.isFinite(o.top) || o.top < 1) o.top = 20
|
|
85
|
+
return o
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readCache(cacheFile) {
|
|
89
|
+
try {
|
|
90
|
+
const raw = JSON.parse(fs.readFileSync(cacheFile, 'utf8'))
|
|
91
|
+
if (raw?.version !== CACHE_VERSION || typeof raw.entries !== 'object') return null
|
|
92
|
+
return raw
|
|
93
|
+
} catch { return null }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeCacheAtomic(cacheFile, data) {
|
|
97
|
+
const tmp = `${cacheFile}.${process.pid}.tmp`
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true })
|
|
100
|
+
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8')
|
|
101
|
+
fs.renameSync(tmp, cacheFile)
|
|
102
|
+
} catch {
|
|
103
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* 缓存写失败不影响对账结论 */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scanSession(file, home) {
|
|
108
|
+
const buf = fs.readFileSync(file)
|
|
109
|
+
const dec = decodeSessionBuffer(buf)
|
|
110
|
+
const { refs } = collectSessionRefs(dec.text)
|
|
111
|
+
return {
|
|
112
|
+
rel: path.relative(home, file),
|
|
113
|
+
bytes: buf.length,
|
|
114
|
+
frames: dec.frames,
|
|
115
|
+
badFrames: dec.badFrames,
|
|
116
|
+
shas: [...refs.keys()],
|
|
117
|
+
meta: refs,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function countFiles(dir) {
|
|
122
|
+
let n = 0
|
|
123
|
+
const stack = [dir]
|
|
124
|
+
while (stack.length) {
|
|
125
|
+
const cur = stack.pop()
|
|
126
|
+
let ents = []
|
|
127
|
+
try { ents = fs.readdirSync(cur, { withFileTypes: true }) } catch { continue }
|
|
128
|
+
for (const e of ents) {
|
|
129
|
+
const p = path.join(cur, e.name)
|
|
130
|
+
if (e.isDirectory()) stack.push(p)
|
|
131
|
+
else if (e.isFile()) n++
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return n
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 执行一次对账(只读;除缓存外不写文件)。
|
|
139
|
+
* @param {object} o 解析后的参数(见 parseAuditArgs)
|
|
140
|
+
* @param {(msg:string)=>void} [logErr] 局部错误输出(默认 stderr)
|
|
141
|
+
* @returns {{ok:boolean, reason?:string, summary?:object, cacheFile:string, code:number}}
|
|
142
|
+
*/
|
|
143
|
+
export function collectAudit(o, logErr = (m) => process.stderr.write(m)) {
|
|
144
|
+
const home = resolveDshHome(o.home)
|
|
145
|
+
const objectsRoot = objectsRootFor(home)
|
|
146
|
+
const cacheFile = o.cache ? path.resolve(o.cache) : path.join(home, CACHE_NAME)
|
|
147
|
+
const trees = sessionTrees(home).filter((t) => !(o.sessionsOnly && t.name !== 'sessions'))
|
|
148
|
+
if (trees.length === 0) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false, code: 4, cacheFile,
|
|
151
|
+
reason: `找不到会话目录(tried: ${['sessions', '_archived-sessions'].map((n) => path.join(home, n)).join(' | ')})`,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const target = o.session ? normalizeSessionId(o.session) : null
|
|
155
|
+
if (o.session && target === null) {
|
|
156
|
+
return { ok: false, code: 2, cacheFile, reason: `--session 需要会话 id(guid)或含 guid 的路径: ${o.session}` }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const cache = o.noCache ? null : readCache(cacheFile)
|
|
160
|
+
const cacheEntries = {}
|
|
161
|
+
const results = []
|
|
162
|
+
let scanned = 0, reused = 0, decodeFailures = 0, scanMs = 0
|
|
163
|
+
for (const tree of trees) {
|
|
164
|
+
for (const file of listSessionFiles(tree.dir)) {
|
|
165
|
+
const rel = path.relative(home, file)
|
|
166
|
+
if (target !== null && !rel.toLowerCase().includes(target)) continue
|
|
167
|
+
let st
|
|
168
|
+
try { st = fs.statSync(file) } catch { continue }
|
|
169
|
+
const prev = cache?.entries?.[rel]
|
|
170
|
+
if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs && Array.isArray(prev.shas)) {
|
|
171
|
+
reused++
|
|
172
|
+
cacheEntries[rel] = prev
|
|
173
|
+
// 缓存里带上 ref 元数据(bytes/mediaType/name):缺失对象的体积估算只能从这里来
|
|
174
|
+
// —— 对象本身不在盘上,无法 stat(v2 起;v1 缓存无此字段 ⇒ 版本门整库失效)
|
|
175
|
+
results.push({
|
|
176
|
+
rel, bytes: st.size, frames: prev.frames ?? 0, badFrames: 0, shas: prev.shas,
|
|
177
|
+
meta: new Map(Object.entries(prev.meta ?? {}).map(([sha, m]) => [sha, { sha, count: 1, sample: m }])),
|
|
178
|
+
})
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
const t0 = Date.now()
|
|
182
|
+
let r
|
|
183
|
+
try { r = scanSession(file, home) } catch (e) {
|
|
184
|
+
decodeFailures++
|
|
185
|
+
logErr(` !! 读取失败 ${rel}: ${e?.message ?? e}\n`)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
scanMs += Date.now() - t0
|
|
189
|
+
scanned++
|
|
190
|
+
cacheEntries[rel] = {
|
|
191
|
+
size: st.size, mtimeMs: st.mtimeMs, frames: r.frames, shas: r.shas,
|
|
192
|
+
meta: Object.fromEntries([...r.meta].map(([sha, rec]) => [sha, {
|
|
193
|
+
bytes: rec.sample?.bytes ?? 0,
|
|
194
|
+
mediaType: rec.sample?.mediaType ?? null,
|
|
195
|
+
name: rec.sample?.name ?? null,
|
|
196
|
+
width: rec.sample?.width ?? null,
|
|
197
|
+
height: rec.sample?.height ?? null,
|
|
198
|
+
}])),
|
|
199
|
+
}
|
|
200
|
+
results.push(r)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (target === null && !o.noCache) {
|
|
204
|
+
writeCacheAtomic(cacheFile, {
|
|
205
|
+
version: CACHE_VERSION, host: os.hostname(), updatedAt: new Date().toISOString(),
|
|
206
|
+
home, entries: cacheEntries,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const exists = new Map()
|
|
211
|
+
const checkExists = (sha) => {
|
|
212
|
+
let v = exists.get(sha)
|
|
213
|
+
if (v === undefined) { v = fs.existsSync(objectPathFor(objectsRoot, sha)); exists.set(sha, v) }
|
|
214
|
+
return v
|
|
215
|
+
}
|
|
216
|
+
const corrupt = []
|
|
217
|
+
const bySession = []
|
|
218
|
+
const missingMap = new Map()
|
|
219
|
+
let refsTotal = 0, withImages = 0
|
|
220
|
+
for (const r of results) {
|
|
221
|
+
if (r.shas.length === 0) continue
|
|
222
|
+
withImages++
|
|
223
|
+
const miss = []
|
|
224
|
+
for (const sha of r.shas) {
|
|
225
|
+
refsTotal++
|
|
226
|
+
if (checkExists(sha)) continue
|
|
227
|
+
miss.push(sha)
|
|
228
|
+
let rec = missingMap.get(sha)
|
|
229
|
+
if (!rec) { rec = { sha, refs: 0, sessions: [], sample: r.meta.get(sha)?.sample ?? null }; missingMap.set(sha, rec) }
|
|
230
|
+
rec.refs += r.meta.get(sha)?.count ?? 1
|
|
231
|
+
rec.sessions.push(r.rel)
|
|
232
|
+
}
|
|
233
|
+
if (miss.length > 0) {
|
|
234
|
+
bySession.push({
|
|
235
|
+
rel: r.rel,
|
|
236
|
+
tree: r.rel.startsWith('_archived-sessions') ? 'archived' : 'sessions',
|
|
237
|
+
missing: miss,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
let presentObjects = 0
|
|
242
|
+
for (const v of exists.values()) if (v) presentObjects++
|
|
243
|
+
if (o.verifyObjects) {
|
|
244
|
+
for (const [sha, ok] of exists) {
|
|
245
|
+
if (!ok) continue
|
|
246
|
+
try { if (sha256File(objectPathFor(objectsRoot, sha)) !== sha) corrupt.push(sha) } catch { corrupt.push(sha) }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const missingBytes = [...missingMap.values()].reduce((n, m) => n + (m.sample?.bytes ?? 0), 0)
|
|
251
|
+
const summary = {
|
|
252
|
+
dshHome: home,
|
|
253
|
+
host: os.hostname(),
|
|
254
|
+
objectsRoot,
|
|
255
|
+
objectFiles: fs.existsSync(objectsRoot) ? countFiles(objectsRoot) : 0,
|
|
256
|
+
sessionFiles: results.length,
|
|
257
|
+
sessionsWithImages: withImages,
|
|
258
|
+
refsTotal,
|
|
259
|
+
distinctSha: exists.size,
|
|
260
|
+
presentObjects,
|
|
261
|
+
missingObjects: missingMap.size,
|
|
262
|
+
missingBytesEstimate: missingBytes,
|
|
263
|
+
affectedSessions: bySession.length,
|
|
264
|
+
corruptObjects: corrupt.length,
|
|
265
|
+
decodeFailures,
|
|
266
|
+
referencedShas: [...exists.keys()].sort(),
|
|
267
|
+
cache: { file: o.noCache ? null : cacheFile, reused, scanned, scanMs, cacheHit: reused > 0 && scanned === 0 },
|
|
268
|
+
generatedAt: new Date().toISOString(),
|
|
269
|
+
missing: [...missingMap.values()].map((m) => ({
|
|
270
|
+
sha: m.sha, refs: m.refs, sessionCount: m.sessions.length,
|
|
271
|
+
sessions: m.sessions.slice(0, 20), sample: m.sample,
|
|
272
|
+
})).sort((a, b) => b.sessionCount - a.sessionCount || b.refs - a.refs),
|
|
273
|
+
affected: bySession.sort((a, b) => b.missing.length - a.missing.length),
|
|
274
|
+
}
|
|
275
|
+
return { ok: true, code: summary.missingObjects > 0 ? 3 : 0, summary, cacheFile, objectsRoot, home }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 人读摘要(`--quiet` 时不调用)。 */
|
|
279
|
+
export function formatAudit(summary, { top = 20 } = {}) {
|
|
280
|
+
const lines = []
|
|
281
|
+
lines.push('attachments 对账(只读)')
|
|
282
|
+
lines.push(` DSH_HOME ${summary.dshHome}`)
|
|
283
|
+
lines.push(` 会话文件 ${summary.sessionFiles}(含图 ${summary.sessionsWithImages})· 解压重扫 ${summary.cache.scanned} / 缓存复用 ${summary.cache.reused}${summary.cache.scanMs ? ` · 扫描 ${(summary.cache.scanMs / 1000).toFixed(1)}s` : ''}`)
|
|
284
|
+
lines.push(` 引用 ${summary.refsTotal} 次(会话×对象)/ ${summary.distinctSha} 个不同对象`)
|
|
285
|
+
lines.push(` 本机 objects ${summary.objectFiles} 个文件`)
|
|
286
|
+
const flag = summary.missingObjects > 0 ? '⚠ ' : 'OK '
|
|
287
|
+
lines.push(` ${flag}缺失对象 ${summary.missingObjects}(约 ${(summary.missingBytesEstimate / 1048576).toFixed(2)} MB)· 受影响会话 ${summary.affectedSessions}`)
|
|
288
|
+
if (summary.corruptObjects) lines.push(` ⚠ 内容与文件名不符的对象 ${summary.corruptObjects}(--verify-objects)`)
|
|
289
|
+
if (summary.decodeFailures) lines.push(` ⚠ 读取失败的会话文件 ${summary.decodeFailures}(见 stderr)`)
|
|
290
|
+
if (summary.affectedSessions > 0) {
|
|
291
|
+
lines.push(' 受影响会话(缺失数降序):')
|
|
292
|
+
for (const s of summary.affected.slice(0, top)) lines.push(` ${String(s.missing.length).padStart(3)} 个 ${s.rel}`)
|
|
293
|
+
if (summary.affected.length > top) lines.push(` … 另 ${summary.affected.length - top} 个(--top 调大)`)
|
|
294
|
+
lines.push('')
|
|
295
|
+
lines.push(` 补齐命令:node ${path.join(HERE, 'attachments-fetch.mjs')} --all-missing`)
|
|
296
|
+
lines.push(` node ${path.join(HERE, 'attachments-fetch.mjs')} --session <会话id> # 只补一个会话`)
|
|
297
|
+
lines.push(` node ${path.join(HERE, 'attachments-fetch.mjs')} --probe # 先看有哪些可用的取回源`)
|
|
298
|
+
} else {
|
|
299
|
+
lines.push(' 未发现缺失:本机所有被引用的附件实体都在位 ✅')
|
|
300
|
+
}
|
|
301
|
+
return lines.join('\n')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function main(argv = process.argv.slice(2)) {
|
|
305
|
+
const o = parseAuditArgs(argv)
|
|
306
|
+
if (o.help) { process.stdout.write(usage() + '\n'); return 0 }
|
|
307
|
+
if (o.errors.length > 0) { process.stderr.write(o.errors.join('\n') + '\n' + usage() + '\n'); return 2 }
|
|
308
|
+
const r = collectAudit(o)
|
|
309
|
+
if (!r.ok) {
|
|
310
|
+
process.stderr.write(`attachments-audit: ${r.reason}\n`)
|
|
311
|
+
if (r.code === 4) process.stderr.write(' ⇒ 退出码 4(读不到 ≠ 无缺失)\n')
|
|
312
|
+
return r.code
|
|
313
|
+
}
|
|
314
|
+
if (o.json) process.stdout.write(JSON.stringify(r.summary, null, 2) + '\n')
|
|
315
|
+
else if (!o.quiet) process.stdout.write(formatAudit(r.summary, { top: o.top }) + '\n')
|
|
316
|
+
return r.code
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 入口守卫:junction 下 import.meta.url 解析为物理路径、argv[1] 是逻辑路径 ⇒ 必须 realpath 后比
|
|
320
|
+
function isMain() {
|
|
321
|
+
const arg = process.argv[1]
|
|
322
|
+
if (!arg) return false
|
|
323
|
+
try { return fs.realpathSync(arg) === fs.realpathSync(SELF) } catch { return false }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (isMain()) {
|
|
327
|
+
try {
|
|
328
|
+
process.exitCode = main()
|
|
329
|
+
} catch (e) {
|
|
330
|
+
process.stderr.write(`attachments-audit: 内部错误 ${e?.stack ?? e}\n`)
|
|
331
|
+
process.exitCode = 4
|
|
332
|
+
}
|
|
333
|
+
}
|