dsh-manage-session 0.2.3 → 0.3.3
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.en.md +14 -0
- package/README.ja.md +14 -0
- package/README.ko.md +14 -0
- package/README.md +14 -0
- package/dsh.plugin.json +4 -4
- package/lib/client.js +12 -3
- package/lib/index.d.ts +167 -10
- package/lib/index.js +331 -44
- package/package.json +10 -9
- package/screenshots.json +5 -1
- package/src/client/health-panel.tsx +5 -1
- package/src/client/host-api.ts +6 -1
- package/src/client/locales.ts +7 -2
- package/src/host/health/cache.ts +1 -1
- package/src/host/health/decode.ts +19 -1
- package/src/host/health/gates.ts +91 -3
- package/src/host/health/generation.ts +216 -0
- package/src/host/health/repair.ts +28 -7
- package/src/host/health/scan.ts +84 -27
- package/src/index.ts +27 -7
package/src/client/locales.ts
CHANGED
|
@@ -59,12 +59,15 @@ export const zh = {
|
|
|
59
59
|
// 分批扫描的进度读数:done/total + 已用秒数(total 由宿主报告的总数给出)
|
|
60
60
|
'health.progress': '{done}/{total} · 已用 {sec}s',
|
|
61
61
|
'health.empty': '未发现异常会话。',
|
|
62
|
-
'health.clean': '
|
|
62
|
+
'health.clean': '全部检查通过。',
|
|
63
63
|
'health.level.ok': '正常',
|
|
64
64
|
'health.level.warn': '注意',
|
|
65
65
|
// 中性档:本门无从判定(如冷态会话无热态投影),不参与 warn 聚合
|
|
66
66
|
'health.level.skipped': '未检查',
|
|
67
67
|
'health.level.fail': '异常',
|
|
68
|
+
// 处置优先级:当前代是从迁移暂存(TMP)发布出来的会话,排在最前
|
|
69
|
+
'health.priority.high': '优先',
|
|
70
|
+
'health.gate.generation': '代次产物',
|
|
68
71
|
'health.gate.log-integrity': '日志完整性',
|
|
69
72
|
'health.gate.projection-cache': '投影缓存',
|
|
70
73
|
'health.gate.lossless-json': '无损 JSON',
|
|
@@ -147,11 +150,13 @@ export const en: Record<LocaleKey, string> = {
|
|
|
147
150
|
'health.scanning': 'Running…',
|
|
148
151
|
'health.progress': '{done}/{total} · {sec}s elapsed',
|
|
149
152
|
'health.empty': 'No problem session found.',
|
|
150
|
-
'health.clean': 'All
|
|
153
|
+
'health.clean': 'All checks passed.',
|
|
151
154
|
'health.level.ok': 'ok',
|
|
152
155
|
'health.level.warn': 'warn',
|
|
153
156
|
'health.level.skipped': 'Not checked',
|
|
154
157
|
'health.level.fail': 'fail',
|
|
158
|
+
'health.priority.high': 'Priority',
|
|
159
|
+
'health.gate.generation': 'Generation artifact',
|
|
155
160
|
'health.gate.log-integrity': 'Log integrity',
|
|
156
161
|
'health.gate.projection-cache': 'Projection cache',
|
|
157
162
|
'health.gate.lossless-json': 'Lossless JSON',
|
package/src/host/health/cache.ts
CHANGED
|
@@ -36,6 +36,14 @@ export interface SessionLogRead {
|
|
|
36
36
|
decoder: 'official' | 'local'
|
|
37
37
|
/** 尾部是否仍有未收尾的 open step。 */
|
|
38
38
|
openStep?: { turn: number; step: number }
|
|
39
|
+
/**
|
|
40
|
+
* 日志首行的 header(`type: 'session'` 记录)。
|
|
41
|
+
*
|
|
42
|
+
* 取它是为了拿到 `version`(格式代次):文件名代次与 header 代次都由宿主的
|
|
43
|
+
* 代次发布路径写入,二者一致是发布不变量。首行不是 JSON 时为 undefined,
|
|
44
|
+
* 由 `log-integrity` 门按解码问题如实报出,不在这里抛。
|
|
45
|
+
*/
|
|
46
|
+
header?: Record<string, unknown>
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
/** 解码后的事件(结构子集)。 */
|
|
@@ -209,7 +217,7 @@ export function decodeRecordLocal(value: unknown): DecodedEvent[] {
|
|
|
209
217
|
|
|
210
218
|
/**
|
|
211
219
|
* 读取一个会话日志文件(zip 帧扫描 + 解包 + seq 连续性)。
|
|
212
|
-
* @param file -
|
|
220
|
+
* @param file - 当前代日志的绝对路径(由代次解析得出,不假定具体文件名)。
|
|
213
221
|
* @param bytes - 文件内容(调用方读取,便于测试注入)。
|
|
214
222
|
* @param decoders - 可选官方解码器(缺省用本地等价实现)。
|
|
215
223
|
* @returns 事件、统计与问题清单。
|
|
@@ -276,6 +284,15 @@ export function decodeSessionLogBytes(
|
|
|
276
284
|
if (gapped) break
|
|
277
285
|
}
|
|
278
286
|
const openStep = findOpenStep(events)
|
|
287
|
+
// 首行是 header,不参与事件序列(下面的事件循环从 index 1 起)。
|
|
288
|
+
let header: Record<string, unknown> | undefined
|
|
289
|
+
const headerLine = lines[0]
|
|
290
|
+
if (headerLine !== undefined) {
|
|
291
|
+
try {
|
|
292
|
+
const parsed: unknown = JSON.parse(headerLine)
|
|
293
|
+
if (typeof parsed === 'object' && parsed !== null) header = parsed as Record<string, unknown>
|
|
294
|
+
} catch { /* 首行不是 JSON:header 不可读,由 log-integrity 门报出 */ }
|
|
295
|
+
}
|
|
279
296
|
return {
|
|
280
297
|
events,
|
|
281
298
|
frames: frames.length,
|
|
@@ -284,6 +301,7 @@ export function decodeSessionLogBytes(
|
|
|
284
301
|
issues,
|
|
285
302
|
decoder: decoders === undefined ? 'local' : 'official',
|
|
286
303
|
...(openStep === undefined ? {} : { openStep }),
|
|
304
|
+
...(header === undefined ? {} : { header }),
|
|
287
305
|
}
|
|
288
306
|
}
|
|
289
307
|
|
package/src/host/health/gates.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 五个体检 gate(每个可独立测试)与报告聚合。
|
|
3
3
|
*
|
|
4
4
|
* 统一输出形状:`{ id, level, evidence, attribution, detail }`;
|
|
5
5
|
* 聚合结果 `SessionHealthReport` 供「体检 → 处方 → 出院」三步流程使用。
|
|
@@ -10,6 +10,11 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import { decodeSessionLogFile, type SessionLogRead } from './decode.ts'
|
|
13
|
+
import {
|
|
14
|
+
sessionPriority,
|
|
15
|
+
type SessionGenerations,
|
|
16
|
+
type SessionPriority,
|
|
17
|
+
} from './generation.ts'
|
|
13
18
|
import { firstLosslessViolation, type LosslessViolation } from './lossless.ts'
|
|
14
19
|
|
|
15
20
|
/**
|
|
@@ -40,7 +45,7 @@ export interface GateAttribution {
|
|
|
40
45
|
|
|
41
46
|
/** 一个 gate 的结果。 */
|
|
42
47
|
export interface GateResult {
|
|
43
|
-
id: 'log-integrity' | 'projection-cache' | 'lossless-json' | 'cold-read'
|
|
48
|
+
id: 'generation' | 'log-integrity' | 'projection-cache' | 'lossless-json' | 'cold-read'
|
|
44
49
|
level: GateLevel
|
|
45
50
|
evidence: string
|
|
46
51
|
attribution?: GateAttribution
|
|
@@ -51,6 +56,12 @@ export interface GateResult {
|
|
|
51
56
|
export interface SessionHealthReport {
|
|
52
57
|
sessionId: string
|
|
53
58
|
level: GateLevel
|
|
59
|
+
/**
|
|
60
|
+
* 处置优先级:`high` = 该会话的当前代是从迁移暂存(TMP)发布出来的。
|
|
61
|
+
*
|
|
62
|
+
* 与 `level` 正交:优先级答「先看谁」,档位答「要不要处置」。
|
|
63
|
+
*/
|
|
64
|
+
priority: SessionPriority
|
|
54
65
|
gates: GateResult[]
|
|
55
66
|
generatedAt: number
|
|
56
67
|
}
|
|
@@ -64,6 +75,12 @@ export interface GateContext {
|
|
|
64
75
|
logPath?: string
|
|
65
76
|
/** 已解码的日志(复用,避免重复解码)。 */
|
|
66
77
|
log?: SessionLogRead
|
|
78
|
+
/**
|
|
79
|
+
* 会话目录的代次事实(见 `generation.ts`)。
|
|
80
|
+
*
|
|
81
|
+
* 不提供时 `generation` 门记 `skipped`(无从判定),不猜、也不当作正常。
|
|
82
|
+
*/
|
|
83
|
+
generations?: SessionGenerations
|
|
67
84
|
/** 热态:宿主 `sessionProjections.checkpoint(session)` 的逐行状态。 */
|
|
68
85
|
projectionState?: Record<string, unknown>
|
|
69
86
|
/** 归属查询函数(投影 key → 包名)。 */
|
|
@@ -114,6 +131,73 @@ export function readTailFacts(log: SessionLogRead): TailFacts {
|
|
|
114
131
|
}
|
|
115
132
|
}
|
|
116
133
|
|
|
134
|
+
/**
|
|
135
|
+
* gate 0:代次事实(当前代是历史代还是已发布代、有没有暂存残留)。
|
|
136
|
+
*
|
|
137
|
+
* 存在的理由:一个会话目录里可以有多份日志产物,而**读哪一份**决定了后面所有
|
|
138
|
+
* 门的结论。早期版本固定读 `session.jsonl.zstd`,于是对已发布当前代的会话要么
|
|
139
|
+
* 读错(读历史代那份,12MB 全量回放)、要么完全发现不了。本门把这个前提显式化成
|
|
140
|
+
* 可判定的证据,档位判据只取「有据可依」的三条:
|
|
141
|
+
* - 没有任何规范产物(只剩暂存)→ `fail`:当前代尚未发布;
|
|
142
|
+
* - 有暂存残留 → `warn`:发布源仍在原地(发布完成未清,或尚未发布);
|
|
143
|
+
* - 文件名代次与 header 代次不一致 → `warn`:发布不变量被破坏。
|
|
144
|
+
* 历史代与当前代并存是宿主**契约要求**(已提交代次不删不改),故记 `ok` 并写进
|
|
145
|
+
* 证据,不抬档 —— 抬档会把 34 个完全正常的会话变成噪声。
|
|
146
|
+
* @param facts - 会话目录的代次事实;缺省表示无从判定。
|
|
147
|
+
* @param log - 已解码的日志(用于取 header.version 交叉核对)。
|
|
148
|
+
*/
|
|
149
|
+
export function gateGeneration(facts: SessionGenerations | undefined, log?: SessionLogRead): GateResult {
|
|
150
|
+
if (facts === undefined) {
|
|
151
|
+
return { id: 'generation', level: 'skipped', evidence: '未提供代次事实,本门无从判定' }
|
|
152
|
+
}
|
|
153
|
+
const current = facts.current
|
|
154
|
+
const headerVersion = typeof log?.header?.['version'] === 'number' ? log.header['version'] as number : undefined
|
|
155
|
+
const detail = {
|
|
156
|
+
currentVersion: current?.version,
|
|
157
|
+
currentName: current?.name,
|
|
158
|
+
currentCompression: current?.compression,
|
|
159
|
+
canonicalVersions: facts.canonical.map(artifact => artifact.version),
|
|
160
|
+
staging: facts.staging.map(artifact => artifact.name),
|
|
161
|
+
legacyOnly: facts.legacyOnly,
|
|
162
|
+
priority: sessionPriority(facts),
|
|
163
|
+
...(headerVersion === undefined ? {} : { headerVersion }),
|
|
164
|
+
}
|
|
165
|
+
if (current === undefined) {
|
|
166
|
+
return {
|
|
167
|
+
id: 'generation',
|
|
168
|
+
level: 'fail',
|
|
169
|
+
evidence: `目录内没有任何规范代次产物,仅剩 ${facts.staging.length} 份迁移暂存:当前代尚未从暂存发布`,
|
|
170
|
+
detail,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (facts.staging.length > 0) {
|
|
174
|
+
return {
|
|
175
|
+
id: 'generation',
|
|
176
|
+
level: 'warn',
|
|
177
|
+
evidence: `当前代 v${current.version}(${current.name})旁留有 ${facts.staging.length} 份迁移暂存残留:` +
|
|
178
|
+
facts.staging.map(artifact => artifact.name).join('、'),
|
|
179
|
+
detail,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (headerVersion !== undefined && headerVersion !== current.version) {
|
|
183
|
+
return {
|
|
184
|
+
id: 'generation',
|
|
185
|
+
level: 'warn',
|
|
186
|
+
evidence: `文件名代次与日志头代次不一致:${current.name} 名为 v${current.version},header.version=${headerVersion}`,
|
|
187
|
+
detail,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const note = facts.legacyOnly
|
|
191
|
+
? ';目录内只有历史代,宿主首次写访问时才发布当前代'
|
|
192
|
+
: facts.legacy === undefined ? '' : ';历史代 v0 按契约保留未删'
|
|
193
|
+
return {
|
|
194
|
+
id: 'generation',
|
|
195
|
+
level: 'ok',
|
|
196
|
+
evidence: `当前代 v${current.version}(${current.name})${note}`,
|
|
197
|
+
detail,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
117
201
|
/** gate 1:日志完整性(可解码、seq 连续、无撕裂尾帧)。 */
|
|
118
202
|
export function gateLogIntegrity(log: SessionLogRead): GateResult {
|
|
119
203
|
if (log.issues.length > 0) {
|
|
@@ -302,12 +386,14 @@ export function gateColdRead(facts: TailFacts): GateResult {
|
|
|
302
386
|
return { id: 'cold-read', level: 'ok', evidence: `最后一轮 completed 结束,会话可原地接续(末 seq ${facts.lastSeq})`, detail }
|
|
303
387
|
}
|
|
304
388
|
|
|
305
|
-
/**
|
|
389
|
+
/** 聚合全部 gate 结果为一份报告。 */
|
|
306
390
|
export function buildSessionReport(context: GateContext): SessionHealthReport {
|
|
307
391
|
const log = context.log ?? (context.logPath === undefined
|
|
308
392
|
? undefined
|
|
309
393
|
: decodeSessionLogFile(context.logPath))
|
|
310
394
|
const gates: GateResult[] = []
|
|
395
|
+
// 代次门恒在首位:它断言的是「读的是哪一份产物」,是后面所有门的前提。
|
|
396
|
+
gates.push(gateGeneration(context.generations, log))
|
|
311
397
|
if (log === undefined) {
|
|
312
398
|
gates.push({ id: 'log-integrity', level: 'fail', evidence: '未提供日志读取结果或日志路径' })
|
|
313
399
|
} else {
|
|
@@ -325,6 +411,8 @@ export function buildSessionReport(context: GateContext): SessionHealthReport {
|
|
|
325
411
|
return {
|
|
326
412
|
sessionId: context.sessionId,
|
|
327
413
|
level,
|
|
414
|
+
// 没有代次事实就不主张优先级:优先级是「先看谁」的排序主张,无据时按普通处理。
|
|
415
|
+
priority: context.generations === undefined ? 'normal' : sessionPriority(context.generations),
|
|
328
416
|
gates,
|
|
329
417
|
generatedAt: (context.now ?? Date.now)(),
|
|
330
418
|
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话日志的「代次」文件名契约(对齐宿主 session 操作核心)。
|
|
3
|
+
*
|
|
4
|
+
* 一个会话目录里可以同时存在多份日志产物:
|
|
5
|
+
*
|
|
6
|
+
* session.jsonl.zstd 历史代 0(无版本号)
|
|
7
|
+
* session.v<N>.jsonl.zstd 当前代 N(N ≥ 1)
|
|
8
|
+
* session.migration.<token>.jsonl.zstd.tmp 迁移暂存(当前代的发布源)
|
|
9
|
+
*
|
|
10
|
+
* **当前代由目录实测的最高代次决定,不硬编码 `v3`;日志名由代次推导,不硬编码
|
|
11
|
+
* `session.jsonl.zstd`。** 硬编码 v0 正是本插件此前「看不见任何已发布当前代的
|
|
12
|
+
* 会话」的病根:实测 449 个会话目录中有 61 个只留当前代,旧发现逻辑对它们一条
|
|
13
|
+
* 都发现不了,因而既扫不到也修不了。
|
|
14
|
+
*
|
|
15
|
+
* 宿主侧的代次发布顺序是「写暂存 → fsync → 校验 → 原子发布成当前代」,所以
|
|
16
|
+
* **当前代次非 0 等价于「这份日志是从 TMP 里移出来的」**;目录内仍有暂存残留
|
|
17
|
+
* 则是发布源仍在原地的物证。本模块据此给出处置优先级。
|
|
18
|
+
*
|
|
19
|
+
* 语法逐条对齐宿主包,改动它们必须同步这里(`tests/generation.spec.ts` 复刻了
|
|
20
|
+
* 宿主的全部拒绝用例,宿主放宽/收紧时该组测试会失败):
|
|
21
|
+
* - `@deepseek-ai/dsh-session-format/src/filename.ts`
|
|
22
|
+
* `CANONICAL_LOG_FILENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl$/u`
|
|
23
|
+
* - `@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts` 的压缩后缀
|
|
24
|
+
* - `@deepseek-ai/dsh-session-persistence-jsonl/src/generation.ts:725`
|
|
25
|
+
* 暂存名 `session.migration.${randomToken()}${suffix}.tmp`(token 为 16 位小写十六进制)
|
|
26
|
+
*/
|
|
27
|
+
import { readdirSync, statSync, type Dirent } from 'node:fs'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
/** 日志的物理编码(对齐宿主的 `JsonlCompression`)。 */
|
|
31
|
+
export type LogCompression = 'zstd' | 'none'
|
|
32
|
+
|
|
33
|
+
/** 处置优先级:`high` = 该会话的当前代是从暂存(TMP)发布出来的。 */
|
|
34
|
+
export type SessionPriority = 'high' | 'normal'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 规范日志名的语法(不含压缩后缀)。
|
|
38
|
+
*
|
|
39
|
+
* 与宿主 `session-format/src/filename.ts` 逐字一致:版本 0 不带版本号,
|
|
40
|
+
* 后续代次必须是小写数字且无前导零;`.v0`、`.v01`、`.V1`、`session.v1.backup.jsonl`
|
|
41
|
+
* 都不识别为规范代次。
|
|
42
|
+
*/
|
|
43
|
+
const CANONICAL_LOG_BASENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl$/u
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 迁移暂存名的语法(对齐 `generation.ts:725` 的写入名)。
|
|
47
|
+
*
|
|
48
|
+
* token 的位数与字符集是宿主内部细节,这里只固定**形状**
|
|
49
|
+
* `session.migration.<hex>.jsonl[.zstd].tmp`,宿主换 token 长度时本插件不会失明。
|
|
50
|
+
*/
|
|
51
|
+
const MIGRATION_STAGING_NAME = /^session\.migration\.[0-9a-f]+\.jsonl(?:\.zstd)?\.tmp$/u
|
|
52
|
+
|
|
53
|
+
/** 一份日志产物在磁盘上的事实。 */
|
|
54
|
+
export interface LogArtifact {
|
|
55
|
+
/** 文件名(不含目录)。 */
|
|
56
|
+
name: string
|
|
57
|
+
/** 绝对路径。 */
|
|
58
|
+
path: string
|
|
59
|
+
bytes: number
|
|
60
|
+
/** mtime(epoch ms)。 */
|
|
61
|
+
mtimeMs: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 一份规范代次产物。 */
|
|
65
|
+
export interface GenerationArtifact extends LogArtifact {
|
|
66
|
+
/** 该文件承载的格式代次;0 为无版本号的历史代。 */
|
|
67
|
+
version: number
|
|
68
|
+
/** 该文件的物理编码。 */
|
|
69
|
+
compression: LogCompression
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 一个会话目录的代次事实。 */
|
|
73
|
+
export interface SessionGenerations {
|
|
74
|
+
/** 目录内全部规范产物,按代次升序(同代次再按 mtime 升序)。 */
|
|
75
|
+
canonical: GenerationArtifact[]
|
|
76
|
+
/** 当前代 = 规范产物中代次最高的一份;目录内没有规范产物时为 undefined。 */
|
|
77
|
+
current?: GenerationArtifact
|
|
78
|
+
/** 历史代(代次 0);宿主按契约保留已提交的旧代次,不删不改。 */
|
|
79
|
+
legacy?: GenerationArtifact
|
|
80
|
+
/** 迁移暂存残留(发布源)。 */
|
|
81
|
+
staging: LogArtifact[]
|
|
82
|
+
/** 只有历史代、尚无更高代次:宿主首次写访问才会发布,冷读要全量内存迁移。 */
|
|
83
|
+
legacyOnly: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function compressionSuffix(compression: LogCompression): '.zstd' | '' {
|
|
87
|
+
return compression === 'zstd' ? '.zstd' : ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 某代次的规范日志文件名。
|
|
92
|
+
* @param version - 非负安全整数的格式代次。
|
|
93
|
+
* @param compression - 物理编码(缺省 zstd)。
|
|
94
|
+
* @returns 该代次在会话目录内的文件名。
|
|
95
|
+
*/
|
|
96
|
+
export function generationLogFilename(version: number, compression: LogCompression = 'zstd'): string {
|
|
97
|
+
if (!Number.isSafeInteger(version) || version < 0) {
|
|
98
|
+
throw new Error(`session log generation must be a non-negative safe integer, got ${String(version)}`)
|
|
99
|
+
}
|
|
100
|
+
const basename = version === 0 ? 'session.jsonl' : `session.v${version}.jsonl`
|
|
101
|
+
return `${basename}${compressionSuffix(compression)}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 把一个规范日志名解析回代次。
|
|
106
|
+
*
|
|
107
|
+
* 与宿主 `parseGenerationLogFilename` 同语义:非规范名(临时、大写、前导零、
|
|
108
|
+
* `.v0`、别的压缩后缀)一律返回 undefined,**不猜**。
|
|
109
|
+
* @param filename - 会话目录里的一个文件名。
|
|
110
|
+
* @param compression - 该目录使用的物理编码(缺省 zstd)。
|
|
111
|
+
* @returns 其格式代次,或名字不构成规范代次时的 undefined。
|
|
112
|
+
*/
|
|
113
|
+
export function parseGenerationLogFilename(filename: string, compression: LogCompression = 'zstd'): number | undefined {
|
|
114
|
+
const suffix = compressionSuffix(compression)
|
|
115
|
+
if (!filename.endsWith(suffix)) return undefined
|
|
116
|
+
const match = CANONICAL_LOG_BASENAME.exec(filename.slice(0, filename.length - suffix.length))
|
|
117
|
+
if (match === null) return undefined
|
|
118
|
+
if (match[1] === undefined) return 0
|
|
119
|
+
const version = Number(match[1])
|
|
120
|
+
return Number.isSafeInteger(version) ? version : undefined
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 两种压缩编码都试一遍,判定该名字是不是某代的规范产物。
|
|
125
|
+
*
|
|
126
|
+
* 发现路径不能假设部署里的 `compression` 配置:配置在 profile 侧,插件读不到,
|
|
127
|
+
* 而日志就在磁盘上。因此按名字本身分类,编码作为结果一并带回。
|
|
128
|
+
* @param filename - 会话目录里的一个文件名。
|
|
129
|
+
* @returns 代次与编码,或该名字不是规范代次时的 undefined。
|
|
130
|
+
*/
|
|
131
|
+
export function classifyGenerationFilename(filename: string): { version: number; compression: LogCompression } | undefined {
|
|
132
|
+
const zstd = parseGenerationLogFilename(filename, 'zstd')
|
|
133
|
+
if (zstd !== undefined) return { version: zstd, compression: 'zstd' }
|
|
134
|
+
const none = parseGenerationLogFilename(filename, 'none')
|
|
135
|
+
if (none !== undefined) return { version: none, compression: 'none' }
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 该文件名是否是迁移暂存(当前代的发布源)。
|
|
141
|
+
* @param filename - 会话目录里的一个文件名。
|
|
142
|
+
* @returns 是否形如 `session.migration.<token>.jsonl[.zstd].tmp`。
|
|
143
|
+
*/
|
|
144
|
+
export function isMigrationStagingFilename(filename: string): boolean {
|
|
145
|
+
return MIGRATION_STAGING_NAME.test(filename)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 读取一个会话目录的代次事实(只读,不碰任何文件内容)。
|
|
150
|
+
* @param dir - 会话目录的绝对路径。
|
|
151
|
+
* @returns 规范产物(按代次升序)、当前代、历史代与暂存残留。
|
|
152
|
+
*/
|
|
153
|
+
export function readSessionGenerations(dir: string): SessionGenerations {
|
|
154
|
+
const canonical: GenerationArtifact[] = []
|
|
155
|
+
const staging: LogArtifact[] = []
|
|
156
|
+
let entries: Dirent[]
|
|
157
|
+
try {
|
|
158
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
159
|
+
} catch {
|
|
160
|
+
// 目录在枚举与读取之间消失(宿主正在删会话):如实返回「没有产物」,
|
|
161
|
+
// 由调用方决定是否跳过这一步,不在这里抛。
|
|
162
|
+
return { canonical: [], staging: [], legacyOnly: false }
|
|
163
|
+
}
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
if (!entry.isFile()) continue
|
|
166
|
+
const path = join(dir, entry.name)
|
|
167
|
+
let bytes: number
|
|
168
|
+
let mtimeMs: number
|
|
169
|
+
try {
|
|
170
|
+
const stat = statSync(path)
|
|
171
|
+
bytes = stat.size
|
|
172
|
+
mtimeMs = stat.mtimeMs
|
|
173
|
+
} catch {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
const artifact: LogArtifact = { name: entry.name, path, bytes, mtimeMs }
|
|
177
|
+
const generation = classifyGenerationFilename(entry.name)
|
|
178
|
+
if (generation !== undefined) {
|
|
179
|
+
canonical.push({ ...artifact, version: generation.version, compression: generation.compression })
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (isMigrationStagingFilename(entry.name)) staging.push(artifact)
|
|
183
|
+
}
|
|
184
|
+
canonical.sort((left, right) => left.version - right.version || left.mtimeMs - right.mtimeMs)
|
|
185
|
+
staging.sort((left, right) => left.mtimeMs - right.mtimeMs)
|
|
186
|
+
const current = canonical.at(-1)
|
|
187
|
+
const legacy = canonical.find(artifact => artifact.version === 0)
|
|
188
|
+
return {
|
|
189
|
+
canonical,
|
|
190
|
+
staging,
|
|
191
|
+
...(current === undefined ? {} : { current }),
|
|
192
|
+
...(legacy === undefined ? {} : { legacy }),
|
|
193
|
+
legacyOnly: canonical.length > 0 && current?.version === 0,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 目录内全部已识别产物的最新 mtime。 */
|
|
198
|
+
export function latestArtifactMtime(facts: SessionGenerations): number | undefined {
|
|
199
|
+
const times = [...facts.canonical, ...facts.staging].map(artifact => artifact.mtimeMs)
|
|
200
|
+
return times.length === 0 ? undefined : Math.max(...times)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 该会话的处置优先级。
|
|
205
|
+
*
|
|
206
|
+
* `high` 的判据就是「从 TMP 移出来」本身:当前代次非 0(当前代是经
|
|
207
|
+
* `session.migration.*.tmp` 暂存发布出来的),或目录内仍有暂存残留。
|
|
208
|
+
* 代次 0 的历史代是宿主按契约保留的未发布会话,旧发现逻辑读的就是它 —— 这类
|
|
209
|
+
* 会话既没有发布痕迹、也没有被读错,故为 `normal`。
|
|
210
|
+
* @param facts - 会话目录的代次事实。
|
|
211
|
+
* @returns 处置优先级。
|
|
212
|
+
*/
|
|
213
|
+
export function sessionPriority(facts: SessionGenerations): SessionPriority {
|
|
214
|
+
if (facts.staging.length > 0) return 'high'
|
|
215
|
+
return (facts.current?.version ?? 0) > 0 ? 'high' : 'normal'
|
|
216
|
+
}
|
|
@@ -96,6 +96,20 @@ export function prescribe(report: SessionHealthReport, dshHome?: string): string
|
|
|
96
96
|
const gate = (id: SessionHealthReport['gates'][number]['id']): SessionHealthReport['gates'][number] | undefined =>
|
|
97
97
|
report.gates.find(entry => entry.id === id)
|
|
98
98
|
|
|
99
|
+
const generation = gate('generation')
|
|
100
|
+
if (generation?.level === 'fail' || generation?.level === 'warn') {
|
|
101
|
+
lines.push(`# 代次异常:${generation.evidence}`)
|
|
102
|
+
// 处方文本只讲判据,不写死具体代次:当前代由目录实测得出(detail.currentVersion)。
|
|
103
|
+
// 写死 `v3` 正是本插件此前「版本适配落后」的形态。
|
|
104
|
+
const currentVersion = generation.detail?.['currentVersion']
|
|
105
|
+
lines.push(typeof currentVersion === 'number'
|
|
106
|
+
? `# 当前代 v${currentVersion}:本插件不改会话日志,暂存残留的发布/隔离需用会话代次工具处理`
|
|
107
|
+
: '# 目录内只有迁移暂存、还没有规范产物:需先用会话代次工具发布当前代,或重启宿主触发发布')
|
|
108
|
+
if (report.priority === 'high') {
|
|
109
|
+
lines.push('# 该会话优先级为 high(当前代由暂存发布而来),建议先处置它')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
const lossless = gate('lossless-json')
|
|
100
114
|
if (lossless?.level === 'fail') {
|
|
101
115
|
const pkg = lossless.attribution?.package ?? 'unknown'
|
|
@@ -132,7 +146,7 @@ export function prescribe(report: SessionHealthReport, dshHome?: string): string
|
|
|
132
146
|
// 「无从判定」不是「全绿」:把 skipped 的门如实列出,避免用户以为都检查过了。
|
|
133
147
|
const unjudged = report.gates.filter(entry => entry.level === 'skipped')
|
|
134
148
|
lines.push(unjudged.length === 0
|
|
135
|
-
? '#
|
|
149
|
+
? '# 全部检查通过:无需处置'
|
|
136
150
|
: `# 无可处置项(${unjudged.length} 门无从判定,非异常):${unjudged.map(entry => entry.id).join(' / ')}`)
|
|
137
151
|
}
|
|
138
152
|
return lines
|
|
@@ -145,7 +159,7 @@ export function describeHome(dshHome?: string): string {
|
|
|
145
159
|
|
|
146
160
|
/** 处置结果的定性分类。 */
|
|
147
161
|
export type RepairVerdict =
|
|
148
|
-
/**
|
|
162
|
+
/** 全部检查通过,没有要做的事。 */
|
|
149
163
|
| 'nothing-to-do'
|
|
150
164
|
/** 处置生效且异常已消除。 */
|
|
151
165
|
| 'repaired'
|
|
@@ -165,6 +179,15 @@ export interface RepairAssessment {
|
|
|
165
179
|
residual: { id: string; level: GateLevel }[]
|
|
166
180
|
}
|
|
167
181
|
|
|
182
|
+
/** 残留门的可读归因:区分「本插件有能力处置」与「按红线只能等 / 交给别的工具」。 */
|
|
183
|
+
function residualNote(residual: { id: string }[]): string {
|
|
184
|
+
if (residual.some(entry => entry.id === 'generation')) {
|
|
185
|
+
return '其中 generation 来自会话目录的代次产物(暂存残留或当前代尚未发布),' +
|
|
186
|
+
'本插件不改会话日志,需用会话代次工具处置。'
|
|
187
|
+
}
|
|
188
|
+
return '这类异常来自会话日志或宿主运行态,本插件不改会话日志,请等待宿主结算后重新体检。'
|
|
189
|
+
}
|
|
190
|
+
|
|
168
191
|
/**
|
|
169
192
|
* 判定一次处置的结果,并给出人读说明。
|
|
170
193
|
*
|
|
@@ -187,7 +210,7 @@ export function assessRepair(input: {
|
|
|
187
210
|
const residualIds = residual.map(entry => entry.id).join(' / ')
|
|
188
211
|
|
|
189
212
|
if (before.level === 'ok') {
|
|
190
|
-
return { verdict: 'nothing-to-do', explanation: '
|
|
213
|
+
return { verdict: 'nothing-to-do', explanation: '全部检查通过,无需处置', residual: [] }
|
|
191
214
|
}
|
|
192
215
|
|
|
193
216
|
if (repair.ok) {
|
|
@@ -195,8 +218,7 @@ export function assessRepair(input: {
|
|
|
195
218
|
? { verdict: 'repaired', explanation: '处置生效:已隔离投影缓存记录,体检结果已恢复', residual: [] }
|
|
196
219
|
: {
|
|
197
220
|
verdict: 'repaired-with-residual',
|
|
198
|
-
explanation: `已隔离投影缓存记录;但仍有与该缓存无关的异常:${residualIds}
|
|
199
|
-
'这类异常来自会话日志或宿主运行态,本插件不改会话日志,请等待宿主结算后重新体检。',
|
|
221
|
+
explanation: `已隔离投影缓存记录;但仍有与该缓存无关的异常:${residualIds}。${residualNote(residual)}`,
|
|
200
222
|
residual,
|
|
201
223
|
}
|
|
202
224
|
}
|
|
@@ -208,8 +230,7 @@ export function assessRepair(input: {
|
|
|
208
230
|
verdict: 'not-applicable',
|
|
209
231
|
explanation: residualIds === ''
|
|
210
232
|
? '当前异常无可逆处置项'
|
|
211
|
-
: `当前异常无可逆处置项(投影缓存无可隔离记录);异常来自 ${residualIds}
|
|
212
|
-
'本插件不改会话日志,请等待宿主结算后重新体检。',
|
|
233
|
+
: `当前异常无可逆处置项(投影缓存无可隔离记录);异常来自 ${residualIds},${residualNote(residual)}`,
|
|
213
234
|
residual,
|
|
214
235
|
}
|
|
215
236
|
}
|