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/lib/index.js
CHANGED
|
@@ -965,7 +965,7 @@ function decodeRecordLocal(value) {
|
|
|
965
965
|
}
|
|
966
966
|
/**
|
|
967
967
|
* 读取一个会话日志文件(zip 帧扫描 + 解包 + seq 连续性)。
|
|
968
|
-
* @param file -
|
|
968
|
+
* @param file - 当前代日志的绝对路径(由代次解析得出,不假定具体文件名)。
|
|
969
969
|
* @param bytes - 文件内容(调用方读取,便于测试注入)。
|
|
970
970
|
* @param decoders - 可选官方解码器(缺省用本地等价实现)。
|
|
971
971
|
* @returns 事件、统计与问题清单。
|
|
@@ -1025,6 +1025,12 @@ function decodeSessionLogBytes(file, bytes, decoders) {
|
|
|
1025
1025
|
if (gapped) break;
|
|
1026
1026
|
}
|
|
1027
1027
|
const openStep = findOpenStep(events);
|
|
1028
|
+
let header;
|
|
1029
|
+
const headerLine = lines[0];
|
|
1030
|
+
if (headerLine !== void 0) try {
|
|
1031
|
+
const parsed = JSON.parse(headerLine);
|
|
1032
|
+
if (typeof parsed === "object" && parsed !== null) header = parsed;
|
|
1033
|
+
} catch {}
|
|
1028
1034
|
return {
|
|
1029
1035
|
events,
|
|
1030
1036
|
frames: frames.length,
|
|
@@ -1032,7 +1038,8 @@ function decodeSessionLogBytes(file, bytes, decoders) {
|
|
|
1032
1038
|
recoveredFromTorn,
|
|
1033
1039
|
issues,
|
|
1034
1040
|
decoder: decoders === void 0 ? "local" : "official",
|
|
1035
|
-
...openStep === void 0 ? {} : { openStep }
|
|
1041
|
+
...openStep === void 0 ? {} : { openStep },
|
|
1042
|
+
...header === void 0 ? {} : { header }
|
|
1036
1043
|
};
|
|
1037
1044
|
}
|
|
1038
1045
|
/** 从文件读取并解码。 */
|
|
@@ -1056,6 +1063,186 @@ function findOpenStep(events) {
|
|
|
1056
1063
|
return open;
|
|
1057
1064
|
}
|
|
1058
1065
|
//#endregion
|
|
1066
|
+
//#region src/host/health/generation.ts
|
|
1067
|
+
/**
|
|
1068
|
+
* 会话日志的「代次」文件名契约(对齐宿主 session 操作核心)。
|
|
1069
|
+
*
|
|
1070
|
+
* 一个会话目录里可以同时存在多份日志产物:
|
|
1071
|
+
*
|
|
1072
|
+
* session.jsonl.zstd 历史代 0(无版本号)
|
|
1073
|
+
* session.v<N>.jsonl.zstd 当前代 N(N ≥ 1)
|
|
1074
|
+
* session.migration.<token>.jsonl.zstd.tmp 迁移暂存(当前代的发布源)
|
|
1075
|
+
*
|
|
1076
|
+
* **当前代由目录实测的最高代次决定,不硬编码 `v3`;日志名由代次推导,不硬编码
|
|
1077
|
+
* `session.jsonl.zstd`。** 硬编码 v0 正是本插件此前「看不见任何已发布当前代的
|
|
1078
|
+
* 会话」的病根:实测 449 个会话目录中有 61 个只留当前代,旧发现逻辑对它们一条
|
|
1079
|
+
* 都发现不了,因而既扫不到也修不了。
|
|
1080
|
+
*
|
|
1081
|
+
* 宿主侧的代次发布顺序是「写暂存 → fsync → 校验 → 原子发布成当前代」,所以
|
|
1082
|
+
* **当前代次非 0 等价于「这份日志是从 TMP 里移出来的」**;目录内仍有暂存残留
|
|
1083
|
+
* 则是发布源仍在原地的物证。本模块据此给出处置优先级。
|
|
1084
|
+
*
|
|
1085
|
+
* 语法逐条对齐宿主包,改动它们必须同步这里(`tests/generation.spec.ts` 复刻了
|
|
1086
|
+
* 宿主的全部拒绝用例,宿主放宽/收紧时该组测试会失败):
|
|
1087
|
+
* - `@deepseek-ai/dsh-session-format/src/filename.ts`
|
|
1088
|
+
* `CANONICAL_LOG_FILENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl$/u`
|
|
1089
|
+
* - `@deepseek-ai/dsh-session-persistence-jsonl/src/format.ts` 的压缩后缀
|
|
1090
|
+
* - `@deepseek-ai/dsh-session-persistence-jsonl/src/generation.ts:725`
|
|
1091
|
+
* 暂存名 `session.migration.${randomToken()}${suffix}.tmp`(token 为 16 位小写十六进制)
|
|
1092
|
+
*/
|
|
1093
|
+
/**
|
|
1094
|
+
* 规范日志名的语法(不含压缩后缀)。
|
|
1095
|
+
*
|
|
1096
|
+
* 与宿主 `session-format/src/filename.ts` 逐字一致:版本 0 不带版本号,
|
|
1097
|
+
* 后续代次必须是小写数字且无前导零;`.v0`、`.v01`、`.V1`、`session.v1.backup.jsonl`
|
|
1098
|
+
* 都不识别为规范代次。
|
|
1099
|
+
*/
|
|
1100
|
+
const CANONICAL_LOG_BASENAME = /^session(?:\.v([1-9][0-9]*))?\.jsonl$/u;
|
|
1101
|
+
/**
|
|
1102
|
+
* 迁移暂存名的语法(对齐 `generation.ts:725` 的写入名)。
|
|
1103
|
+
*
|
|
1104
|
+
* token 的位数与字符集是宿主内部细节,这里只固定**形状**
|
|
1105
|
+
* `session.migration.<hex>.jsonl[.zstd].tmp`,宿主换 token 长度时本插件不会失明。
|
|
1106
|
+
*/
|
|
1107
|
+
const MIGRATION_STAGING_NAME = /^session\.migration\.[0-9a-f]+\.jsonl(?:\.zstd)?\.tmp$/u;
|
|
1108
|
+
function compressionSuffix(compression) {
|
|
1109
|
+
return compression === "zstd" ? ".zstd" : "";
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* 某代次的规范日志文件名。
|
|
1113
|
+
* @param version - 非负安全整数的格式代次。
|
|
1114
|
+
* @param compression - 物理编码(缺省 zstd)。
|
|
1115
|
+
* @returns 该代次在会话目录内的文件名。
|
|
1116
|
+
*/
|
|
1117
|
+
function generationLogFilename(version, compression = "zstd") {
|
|
1118
|
+
if (!Number.isSafeInteger(version) || version < 0) throw new Error(`session log generation must be a non-negative safe integer, got ${String(version)}`);
|
|
1119
|
+
return `${version === 0 ? "session.jsonl" : `session.v${version}.jsonl`}${compressionSuffix(compression)}`;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* 把一个规范日志名解析回代次。
|
|
1123
|
+
*
|
|
1124
|
+
* 与宿主 `parseGenerationLogFilename` 同语义:非规范名(临时、大写、前导零、
|
|
1125
|
+
* `.v0`、别的压缩后缀)一律返回 undefined,**不猜**。
|
|
1126
|
+
* @param filename - 会话目录里的一个文件名。
|
|
1127
|
+
* @param compression - 该目录使用的物理编码(缺省 zstd)。
|
|
1128
|
+
* @returns 其格式代次,或名字不构成规范代次时的 undefined。
|
|
1129
|
+
*/
|
|
1130
|
+
function parseGenerationLogFilename(filename, compression = "zstd") {
|
|
1131
|
+
const suffix = compressionSuffix(compression);
|
|
1132
|
+
if (!filename.endsWith(suffix)) return void 0;
|
|
1133
|
+
const match = CANONICAL_LOG_BASENAME.exec(filename.slice(0, filename.length - suffix.length));
|
|
1134
|
+
if (match === null) return void 0;
|
|
1135
|
+
if (match[1] === void 0) return 0;
|
|
1136
|
+
const version = Number(match[1]);
|
|
1137
|
+
return Number.isSafeInteger(version) ? version : void 0;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* 两种压缩编码都试一遍,判定该名字是不是某代的规范产物。
|
|
1141
|
+
*
|
|
1142
|
+
* 发现路径不能假设部署里的 `compression` 配置:配置在 profile 侧,插件读不到,
|
|
1143
|
+
* 而日志就在磁盘上。因此按名字本身分类,编码作为结果一并带回。
|
|
1144
|
+
* @param filename - 会话目录里的一个文件名。
|
|
1145
|
+
* @returns 代次与编码,或该名字不是规范代次时的 undefined。
|
|
1146
|
+
*/
|
|
1147
|
+
function classifyGenerationFilename(filename) {
|
|
1148
|
+
const zstd = parseGenerationLogFilename(filename, "zstd");
|
|
1149
|
+
if (zstd !== void 0) return {
|
|
1150
|
+
version: zstd,
|
|
1151
|
+
compression: "zstd"
|
|
1152
|
+
};
|
|
1153
|
+
const none = parseGenerationLogFilename(filename, "none");
|
|
1154
|
+
if (none !== void 0) return {
|
|
1155
|
+
version: none,
|
|
1156
|
+
compression: "none"
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* 该文件名是否是迁移暂存(当前代的发布源)。
|
|
1161
|
+
* @param filename - 会话目录里的一个文件名。
|
|
1162
|
+
* @returns 是否形如 `session.migration.<token>.jsonl[.zstd].tmp`。
|
|
1163
|
+
*/
|
|
1164
|
+
function isMigrationStagingFilename(filename) {
|
|
1165
|
+
return MIGRATION_STAGING_NAME.test(filename);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* 读取一个会话目录的代次事实(只读,不碰任何文件内容)。
|
|
1169
|
+
* @param dir - 会话目录的绝对路径。
|
|
1170
|
+
* @returns 规范产物(按代次升序)、当前代、历史代与暂存残留。
|
|
1171
|
+
*/
|
|
1172
|
+
function readSessionGenerations(dir) {
|
|
1173
|
+
const canonical = [];
|
|
1174
|
+
const staging = [];
|
|
1175
|
+
let entries;
|
|
1176
|
+
try {
|
|
1177
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1178
|
+
} catch {
|
|
1179
|
+
return {
|
|
1180
|
+
canonical: [],
|
|
1181
|
+
staging: [],
|
|
1182
|
+
legacyOnly: false
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
for (const entry of entries) {
|
|
1186
|
+
if (!entry.isFile()) continue;
|
|
1187
|
+
const path = join(dir, entry.name);
|
|
1188
|
+
let bytes;
|
|
1189
|
+
let mtimeMs;
|
|
1190
|
+
try {
|
|
1191
|
+
const stat = statSync(path);
|
|
1192
|
+
bytes = stat.size;
|
|
1193
|
+
mtimeMs = stat.mtimeMs;
|
|
1194
|
+
} catch {
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
const artifact = {
|
|
1198
|
+
name: entry.name,
|
|
1199
|
+
path,
|
|
1200
|
+
bytes,
|
|
1201
|
+
mtimeMs
|
|
1202
|
+
};
|
|
1203
|
+
const generation = classifyGenerationFilename(entry.name);
|
|
1204
|
+
if (generation !== void 0) {
|
|
1205
|
+
canonical.push({
|
|
1206
|
+
...artifact,
|
|
1207
|
+
version: generation.version,
|
|
1208
|
+
compression: generation.compression
|
|
1209
|
+
});
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
if (isMigrationStagingFilename(entry.name)) staging.push(artifact);
|
|
1213
|
+
}
|
|
1214
|
+
canonical.sort((left, right) => left.version - right.version || left.mtimeMs - right.mtimeMs);
|
|
1215
|
+
staging.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
1216
|
+
const current = canonical.at(-1);
|
|
1217
|
+
const legacy = canonical.find((artifact) => artifact.version === 0);
|
|
1218
|
+
return {
|
|
1219
|
+
canonical,
|
|
1220
|
+
staging,
|
|
1221
|
+
...current === void 0 ? {} : { current },
|
|
1222
|
+
...legacy === void 0 ? {} : { legacy },
|
|
1223
|
+
legacyOnly: canonical.length > 0 && current?.version === 0
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
/** 目录内全部已识别产物的最新 mtime。 */
|
|
1227
|
+
function latestArtifactMtime(facts) {
|
|
1228
|
+
const times = [...facts.canonical, ...facts.staging].map((artifact) => artifact.mtimeMs);
|
|
1229
|
+
return times.length === 0 ? void 0 : Math.max(...times);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* 该会话的处置优先级。
|
|
1233
|
+
*
|
|
1234
|
+
* `high` 的判据就是「从 TMP 移出来」本身:当前代次非 0(当前代是经
|
|
1235
|
+
* `session.migration.*.tmp` 暂存发布出来的),或目录内仍有暂存残留。
|
|
1236
|
+
* 代次 0 的历史代是宿主按契约保留的未发布会话,旧发现逻辑读的就是它 —— 这类
|
|
1237
|
+
* 会话既没有发布痕迹、也没有被读错,故为 `normal`。
|
|
1238
|
+
* @param facts - 会话目录的代次事实。
|
|
1239
|
+
* @returns 处置优先级。
|
|
1240
|
+
*/
|
|
1241
|
+
function sessionPriority(facts) {
|
|
1242
|
+
if (facts.staging.length > 0) return "high";
|
|
1243
|
+
return (facts.current?.version ?? 0) > 0 ? "high" : "normal";
|
|
1244
|
+
}
|
|
1245
|
+
//#endregion
|
|
1059
1246
|
//#region src/host/health/lossless.ts
|
|
1060
1247
|
/** 单个值是否可无损 JSON 序列化。 */
|
|
1061
1248
|
function isLossless(value) {
|
|
@@ -1207,7 +1394,7 @@ function isPlainArray(value) {
|
|
|
1207
1394
|
//#endregion
|
|
1208
1395
|
//#region src/host/health/gates.ts
|
|
1209
1396
|
/**
|
|
1210
|
-
*
|
|
1397
|
+
* 五个体检 gate(每个可独立测试)与报告聚合。
|
|
1211
1398
|
*
|
|
1212
1399
|
* 统一输出形状:`{ id, level, evidence, attribution, detail }`;
|
|
1213
1400
|
* 聚合结果 `SessionHealthReport` 供「体检 → 处方 → 出院」三步流程使用。
|
|
@@ -1246,6 +1433,65 @@ function readTailFacts(log) {
|
|
|
1246
1433
|
assistantAfterLastUser
|
|
1247
1434
|
};
|
|
1248
1435
|
}
|
|
1436
|
+
/**
|
|
1437
|
+
* gate 0:代次事实(当前代是历史代还是已发布代、有没有暂存残留)。
|
|
1438
|
+
*
|
|
1439
|
+
* 存在的理由:一个会话目录里可以有多份日志产物,而**读哪一份**决定了后面所有
|
|
1440
|
+
* 门的结论。早期版本固定读 `session.jsonl.zstd`,于是对已发布当前代的会话要么
|
|
1441
|
+
* 读错(读历史代那份,12MB 全量回放)、要么完全发现不了。本门把这个前提显式化成
|
|
1442
|
+
* 可判定的证据,档位判据只取「有据可依」的三条:
|
|
1443
|
+
* - 没有任何规范产物(只剩暂存)→ `fail`:当前代尚未发布;
|
|
1444
|
+
* - 有暂存残留 → `warn`:发布源仍在原地(发布完成未清,或尚未发布);
|
|
1445
|
+
* - 文件名代次与 header 代次不一致 → `warn`:发布不变量被破坏。
|
|
1446
|
+
* 历史代与当前代并存是宿主**契约要求**(已提交代次不删不改),故记 `ok` 并写进
|
|
1447
|
+
* 证据,不抬档 —— 抬档会把 34 个完全正常的会话变成噪声。
|
|
1448
|
+
* @param facts - 会话目录的代次事实;缺省表示无从判定。
|
|
1449
|
+
* @param log - 已解码的日志(用于取 header.version 交叉核对)。
|
|
1450
|
+
*/
|
|
1451
|
+
function gateGeneration(facts, log) {
|
|
1452
|
+
if (facts === void 0) return {
|
|
1453
|
+
id: "generation",
|
|
1454
|
+
level: "skipped",
|
|
1455
|
+
evidence: "未提供代次事实,本门无从判定"
|
|
1456
|
+
};
|
|
1457
|
+
const current = facts.current;
|
|
1458
|
+
const headerVersion = typeof log?.header?.["version"] === "number" ? log.header["version"] : void 0;
|
|
1459
|
+
const detail = {
|
|
1460
|
+
currentVersion: current?.version,
|
|
1461
|
+
currentName: current?.name,
|
|
1462
|
+
currentCompression: current?.compression,
|
|
1463
|
+
canonicalVersions: facts.canonical.map((artifact) => artifact.version),
|
|
1464
|
+
staging: facts.staging.map((artifact) => artifact.name),
|
|
1465
|
+
legacyOnly: facts.legacyOnly,
|
|
1466
|
+
priority: sessionPriority(facts),
|
|
1467
|
+
...headerVersion === void 0 ? {} : { headerVersion }
|
|
1468
|
+
};
|
|
1469
|
+
if (current === void 0) return {
|
|
1470
|
+
id: "generation",
|
|
1471
|
+
level: "fail",
|
|
1472
|
+
evidence: `目录内没有任何规范代次产物,仅剩 ${facts.staging.length} 份迁移暂存:当前代尚未从暂存发布`,
|
|
1473
|
+
detail
|
|
1474
|
+
};
|
|
1475
|
+
if (facts.staging.length > 0) return {
|
|
1476
|
+
id: "generation",
|
|
1477
|
+
level: "warn",
|
|
1478
|
+
evidence: `当前代 v${current.version}(${current.name})旁留有 ${facts.staging.length} 份迁移暂存残留:` + facts.staging.map((artifact) => artifact.name).join("、"),
|
|
1479
|
+
detail
|
|
1480
|
+
};
|
|
1481
|
+
if (headerVersion !== void 0 && headerVersion !== current.version) return {
|
|
1482
|
+
id: "generation",
|
|
1483
|
+
level: "warn",
|
|
1484
|
+
evidence: `文件名代次与日志头代次不一致:${current.name} 名为 v${current.version},header.version=${headerVersion}`,
|
|
1485
|
+
detail
|
|
1486
|
+
};
|
|
1487
|
+
const note = facts.legacyOnly ? ";目录内只有历史代,宿主首次写访问时才发布当前代" : facts.legacy === void 0 ? "" : ";历史代 v0 按契约保留未删";
|
|
1488
|
+
return {
|
|
1489
|
+
id: "generation",
|
|
1490
|
+
level: "ok",
|
|
1491
|
+
evidence: `当前代 v${current.version}(${current.name})${note}`,
|
|
1492
|
+
detail
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1249
1495
|
/** gate 1:日志完整性(可解码、seq 连续、无撕裂尾帧)。 */
|
|
1250
1496
|
function gateLogIntegrity(log) {
|
|
1251
1497
|
if (log.issues.length > 0) {
|
|
@@ -1438,10 +1684,11 @@ function gateColdRead(facts) {
|
|
|
1438
1684
|
detail
|
|
1439
1685
|
};
|
|
1440
1686
|
}
|
|
1441
|
-
/**
|
|
1687
|
+
/** 聚合全部 gate 结果为一份报告。 */
|
|
1442
1688
|
function buildSessionReport(context) {
|
|
1443
1689
|
const log = context.log ?? (context.logPath === void 0 ? void 0 : decodeSessionLogFile(context.logPath));
|
|
1444
1690
|
const gates = [];
|
|
1691
|
+
gates.push(gateGeneration(context.generations, log));
|
|
1445
1692
|
if (log === void 0) gates.push({
|
|
1446
1693
|
id: "log-integrity",
|
|
1447
1694
|
level: "fail",
|
|
@@ -1458,6 +1705,7 @@ function buildSessionReport(context) {
|
|
|
1458
1705
|
return {
|
|
1459
1706
|
sessionId: context.sessionId,
|
|
1460
1707
|
level,
|
|
1708
|
+
priority: context.generations === void 0 ? "normal" : sessionPriority(context.generations),
|
|
1461
1709
|
gates,
|
|
1462
1710
|
generatedAt: (context.now ?? Date.now)()
|
|
1463
1711
|
};
|
|
@@ -1537,6 +1785,13 @@ function quarantineProjectionCache(sessionId, dshHome, now = Date.now) {
|
|
|
1537
1785
|
function prescribe(report, dshHome) {
|
|
1538
1786
|
const lines = [];
|
|
1539
1787
|
const gate = (id) => report.gates.find((entry) => entry.id === id);
|
|
1788
|
+
const generation = gate("generation");
|
|
1789
|
+
if (generation?.level === "fail" || generation?.level === "warn") {
|
|
1790
|
+
lines.push(`# 代次异常:${generation.evidence}`);
|
|
1791
|
+
const currentVersion = generation.detail?.["currentVersion"];
|
|
1792
|
+
lines.push(typeof currentVersion === "number" ? `# 当前代 v${currentVersion}:本插件不改会话日志,暂存残留的发布/隔离需用会话代次工具处理` : "# 目录内只有迁移暂存、还没有规范产物:需先用会话代次工具发布当前代,或重启宿主触发发布");
|
|
1793
|
+
if (report.priority === "high") lines.push("# 该会话优先级为 high(当前代由暂存发布而来),建议先处置它");
|
|
1794
|
+
}
|
|
1540
1795
|
const lossless = gate("lossless-json");
|
|
1541
1796
|
if (lossless?.level === "fail") {
|
|
1542
1797
|
const pkg = lossless.attribution?.package ?? "unknown";
|
|
@@ -1562,10 +1817,15 @@ function prescribe(report, dshHome) {
|
|
|
1562
1817
|
if (gate("cold-read")?.level === "fail") lines.push("# 会话仍处于 open step:请等待宿主结算(不要硬改日志),必要时重启宿主后重新体检");
|
|
1563
1818
|
if (lines.length === 0) {
|
|
1564
1819
|
const unjudged = report.gates.filter((entry) => entry.level === "skipped");
|
|
1565
|
-
lines.push(unjudged.length === 0 ? "#
|
|
1820
|
+
lines.push(unjudged.length === 0 ? "# 全部检查通过:无需处置" : `# 无可处置项(${unjudged.length} 门无从判定,非异常):${unjudged.map((entry) => entry.id).join(" / ")}`);
|
|
1566
1821
|
}
|
|
1567
1822
|
return lines;
|
|
1568
1823
|
}
|
|
1824
|
+
/** 残留门的可读归因:区分「本插件有能力处置」与「按红线只能等 / 交给别的工具」。 */
|
|
1825
|
+
function residualNote(residual) {
|
|
1826
|
+
if (residual.some((entry) => entry.id === "generation")) return "其中 generation 来自会话目录的代次产物(暂存残留或当前代尚未发布),本插件不改会话日志,需用会话代次工具处置。";
|
|
1827
|
+
return "这类异常来自会话日志或宿主运行态,本插件不改会话日志,请等待宿主结算后重新体检。";
|
|
1828
|
+
}
|
|
1569
1829
|
/**
|
|
1570
1830
|
* 判定一次处置的结果,并给出人读说明。
|
|
1571
1831
|
*
|
|
@@ -1585,7 +1845,7 @@ function assessRepair(input) {
|
|
|
1585
1845
|
const residualIds = residual.map((entry) => entry.id).join(" / ");
|
|
1586
1846
|
if (before.level === "ok") return {
|
|
1587
1847
|
verdict: "nothing-to-do",
|
|
1588
|
-
explanation: "
|
|
1848
|
+
explanation: "全部检查通过,无需处置",
|
|
1589
1849
|
residual: []
|
|
1590
1850
|
};
|
|
1591
1851
|
if (repair.ok) return residual.length === 0 ? {
|
|
@@ -1594,12 +1854,12 @@ function assessRepair(input) {
|
|
|
1594
1854
|
residual: []
|
|
1595
1855
|
} : {
|
|
1596
1856
|
verdict: "repaired-with-residual",
|
|
1597
|
-
explanation: `已隔离投影缓存记录;但仍有与该缓存无关的异常:${residualIds}
|
|
1857
|
+
explanation: `已隔离投影缓存记录;但仍有与该缓存无关的异常:${residualIds}。${residualNote(residual)}`,
|
|
1598
1858
|
residual
|
|
1599
1859
|
};
|
|
1600
1860
|
if (!isActionable(before.gates.find((entry) => entry.id === "projection-cache")?.level)) return {
|
|
1601
1861
|
verdict: "not-applicable",
|
|
1602
|
-
explanation: residualIds === "" ? "当前异常无可逆处置项" : `当前异常无可逆处置项(投影缓存无可隔离记录);异常来自 ${residualIds}
|
|
1862
|
+
explanation: residualIds === "" ? "当前异常无可逆处置项" : `当前异常无可逆处置项(投影缓存无可隔离记录);异常来自 ${residualIds},${residualNote(residual)}`,
|
|
1603
1863
|
residual
|
|
1604
1864
|
};
|
|
1605
1865
|
return {
|
|
@@ -1611,17 +1871,36 @@ function assessRepair(input) {
|
|
|
1611
1871
|
//#endregion
|
|
1612
1872
|
//#region src/host/health/scan.ts
|
|
1613
1873
|
/**
|
|
1614
|
-
*
|
|
1874
|
+
* 批量体检:枚举会话日志、跑门、给出汇总(供 `session-health-scan` 使用)。
|
|
1615
1875
|
*
|
|
1616
1876
|
* 只读;不激活任何 Agent,也不写任何会话数据。为控制成本,默认限制单次扫描的
|
|
1617
1877
|
* 会话数与并发度,并支持 `limit` / `since` 过滤。
|
|
1878
|
+
*
|
|
1879
|
+
* **发现按代次而不是按固定文件名。** 一个会话目录的当前日志名由目录实测的最高
|
|
1880
|
+
* 代次决定(见 `generation.ts`):历史代是 `session.jsonl.zstd`,已发布的当前代
|
|
1881
|
+
* 是 `session.v<N>.jsonl.zstd`。早期版本在这里写死 `session.jsonl.zstd`,于是
|
|
1882
|
+
* 61 个只留当前代的会话**一条都发现不了** —— 既扫不到,`findSessionLog` 也返回
|
|
1883
|
+
* undefined,连 `session-health-repair` 都会先报「未找到会话日志」。
|
|
1618
1884
|
*/
|
|
1619
|
-
/** 会话根目录(`<dshHome>/sessions/<project>/<sessionId
|
|
1885
|
+
/** 会话根目录(`<dshHome>/sessions/<project>/<sessionId>/`)。 */
|
|
1620
1886
|
function sessionsRoot(dshHome) {
|
|
1621
1887
|
return join(dshHome ?? join(homedir(), ".dsh"), "sessions");
|
|
1622
1888
|
}
|
|
1623
|
-
/**
|
|
1624
|
-
|
|
1889
|
+
/**
|
|
1890
|
+
* 语料枚举上限。
|
|
1891
|
+
*
|
|
1892
|
+
* 这是防御无界遍历的**边界**,不是策略:早期版本写 200,实测语料 449 个会话时
|
|
1893
|
+
* 它静默截掉了一半(体检进度分母恒为 200,末 249 个会话永远扫不到)。
|
|
1894
|
+
*/
|
|
1895
|
+
const DISCOVERY_LIMIT = 1e5;
|
|
1896
|
+
/**
|
|
1897
|
+
* 枚举全部会话(按 mtime 倒序),带可选上限。
|
|
1898
|
+
*
|
|
1899
|
+
* 「是会话目录」的判据是**目录里有规范代次产物或迁移暂存**,不要求任何具体文件名。
|
|
1900
|
+
* @param dshHome - DSH home(缺省 ~/.dsh)。
|
|
1901
|
+
* @param limit - 返回上限(缺省 `DISCOVERY_LIMIT`)。
|
|
1902
|
+
*/
|
|
1903
|
+
function discoverSessions(dshHome, limit = DISCOVERY_LIMIT) {
|
|
1625
1904
|
const root = sessionsRoot(dshHome);
|
|
1626
1905
|
const out = [];
|
|
1627
1906
|
if (!existsSync(root)) return out;
|
|
@@ -1636,30 +1915,23 @@ function discoverSessions(dshHome, limit = 200) {
|
|
|
1636
1915
|
continue;
|
|
1637
1916
|
}
|
|
1638
1917
|
for (const entry of entries) {
|
|
1918
|
+
if (!entry.isDirectory()) continue;
|
|
1639
1919
|
const path = join(dir, entry.name);
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
if (existsSync(candidate)) {
|
|
1643
|
-
let stats;
|
|
1644
|
-
try {
|
|
1645
|
-
const stat = statSync(candidate);
|
|
1646
|
-
stats = {
|
|
1647
|
-
mtimeMs: stat.mtimeMs,
|
|
1648
|
-
size: stat.size
|
|
1649
|
-
};
|
|
1650
|
-
} catch {
|
|
1651
|
-
continue;
|
|
1652
|
-
}
|
|
1653
|
-
out.push({
|
|
1654
|
-
sessionId: entry.name,
|
|
1655
|
-
logPath: candidate,
|
|
1656
|
-
updatedAt: stats.mtimeMs,
|
|
1657
|
-
bytes: stats.size
|
|
1658
|
-
});
|
|
1659
|
-
continue;
|
|
1660
|
-
}
|
|
1920
|
+
const generations = readSessionGenerations(path);
|
|
1921
|
+
if (generations.canonical.length === 0 && generations.staging.length === 0) {
|
|
1661
1922
|
stack.push(path);
|
|
1923
|
+
continue;
|
|
1662
1924
|
}
|
|
1925
|
+
const current = generations.current;
|
|
1926
|
+
out.push({
|
|
1927
|
+
sessionId: entry.name,
|
|
1928
|
+
dir: path,
|
|
1929
|
+
...current === void 0 ? {} : { logPath: current.path },
|
|
1930
|
+
generations,
|
|
1931
|
+
priority: sessionPriority(generations),
|
|
1932
|
+
updatedAt: latestArtifactMtime(generations) ?? 0,
|
|
1933
|
+
bytes: current?.bytes ?? 0
|
|
1934
|
+
});
|
|
1663
1935
|
}
|
|
1664
1936
|
}
|
|
1665
1937
|
out.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
@@ -1668,19 +1940,28 @@ function discoverSessions(dshHome, limit = 200) {
|
|
|
1668
1940
|
/**
|
|
1669
1941
|
* 语料总数:只做目录枚举,**不跑体检**。
|
|
1670
1942
|
*
|
|
1671
|
-
* 用于判断缓存是否已过期——枚举很便宜,而重扫要解 zstd
|
|
1943
|
+
* 用于判断缓存是否已过期——枚举很便宜,而重扫要解 zstd、跑门。
|
|
1672
1944
|
* 正因为两者代价差着量级,「对账」才不构成缓存失效策略本身。
|
|
1673
1945
|
* @param dshHome - DSH home(缺省 ~/.dsh)。
|
|
1674
1946
|
*/
|
|
1675
1947
|
function countCorpus(dshHome) {
|
|
1676
1948
|
return discoverSessions(dshHome, DISCOVERY_LIMIT).length;
|
|
1677
1949
|
}
|
|
1678
|
-
/**
|
|
1950
|
+
/** 按 id 定位一个会话(跨工程目录查找)。 */
|
|
1951
|
+
function findSession(sessionId, dshHome) {
|
|
1952
|
+
return discoverSessions(dshHome, DISCOVERY_LIMIT).find((session) => session.sessionId === sessionId);
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* 定位一个会话的**当前代**日志路径(跨工程目录查找)。
|
|
1956
|
+
*
|
|
1957
|
+
* 返回 undefined 有两种含义,调用方必须区分:会话不存在,或会话存在但尚未发布
|
|
1958
|
+
* 当前代(只有迁移暂存)。需要区分时用 `findSession`。
|
|
1959
|
+
* @param sessionId - 会话 id。
|
|
1960
|
+
* @param dshHome - DSH home(缺省 ~/.dsh)。
|
|
1961
|
+
*/
|
|
1679
1962
|
function findSessionLog(sessionId, dshHome) {
|
|
1680
|
-
return
|
|
1963
|
+
return findSession(sessionId, dshHome)?.logPath;
|
|
1681
1964
|
}
|
|
1682
|
-
/** 语料枚举上限(进度分母的来源;与 route 侧 MAX_SCAN_LIMIT 同量级)。 */
|
|
1683
|
-
const DISCOVERY_LIMIT = 200;
|
|
1684
1965
|
/** 未显式传 limit 时的单批会话数。 */
|
|
1685
1966
|
const DEFAULT_BATCH_LIMIT = 50;
|
|
1686
1967
|
/**
|
|
@@ -1689,6 +1970,9 @@ const DEFAULT_BATCH_LIMIT = 50;
|
|
|
1689
1970
|
* 语料先整体列出(按 mtime 倒序,≤ DISCOVERY_LIMIT),再按 `[offset, offset+limit)`
|
|
1690
1971
|
* 切片扫描。这样客户端可以用小批次连续调用、自己累计真实进度,而宿主保持无状态
|
|
1691
1972
|
* ——不必把同步循环改成异步,也不必新增进度轮询端点。
|
|
1973
|
+
*
|
|
1974
|
+
* 批内**命中顺序**按处置优先级排(`high` 在前,稳定排序)。分批切片仍按 mtime,
|
|
1975
|
+
* 所以 offset/total 的算术不受影响:只有同一批里的呈现顺序变了。
|
|
1692
1976
|
* @param options - dshHome / 批大小 / 批起点 / 是否只返回非 ok / 归属查询 / 热态状态提供者。
|
|
1693
1977
|
*/
|
|
1694
1978
|
function scanSessions(options) {
|
|
@@ -1710,7 +1994,8 @@ function scanSessions(options) {
|
|
|
1710
1994
|
const report = buildSessionReport({
|
|
1711
1995
|
sessionId: session.sessionId,
|
|
1712
1996
|
...options.dshHome === void 0 ? {} : { dshHome: options.dshHome },
|
|
1713
|
-
logPath: session.logPath,
|
|
1997
|
+
...session.logPath === void 0 ? {} : { logPath: session.logPath },
|
|
1998
|
+
generations: session.generations,
|
|
1714
1999
|
...projectionState === void 0 ? {} : { projectionState },
|
|
1715
2000
|
...options.attribute === void 0 ? {} : { attribute: options.attribute },
|
|
1716
2001
|
...options.now === void 0 ? {} : { now: options.now }
|
|
@@ -1718,6 +2003,7 @@ function scanSessions(options) {
|
|
|
1718
2003
|
if (options.onlyProblems === true && report.level === "ok") continue;
|
|
1719
2004
|
findings.push(report);
|
|
1720
2005
|
}
|
|
2006
|
+
findings.sort((left, right) => left.priority === right.priority ? 0 : left.priority === "high" ? -1 : 1);
|
|
1721
2007
|
return {
|
|
1722
2008
|
ok: true,
|
|
1723
2009
|
scanned: batch.length,
|
|
@@ -1935,8 +2221,8 @@ async function handleMethod(method, payload, runtime) {
|
|
|
1935
2221
|
ok: false,
|
|
1936
2222
|
error: "缺少合法的 sessionId"
|
|
1937
2223
|
};
|
|
1938
|
-
const
|
|
1939
|
-
if (
|
|
2224
|
+
const session = findSession(sessionId, runtime.dshHome);
|
|
2225
|
+
if (session === void 0) return {
|
|
1940
2226
|
ok: false,
|
|
1941
2227
|
error: `未找到会话日志:${sessionId}`
|
|
1942
2228
|
};
|
|
@@ -1944,7 +2230,8 @@ async function handleMethod(method, payload, runtime) {
|
|
|
1944
2230
|
const reportFor = () => buildSessionReport({
|
|
1945
2231
|
sessionId,
|
|
1946
2232
|
dshHome: runtime.dshHome,
|
|
1947
|
-
logPath,
|
|
2233
|
+
...session.logPath === void 0 ? {} : { logPath: session.logPath },
|
|
2234
|
+
generations: session.generations,
|
|
1948
2235
|
...projectionState === void 0 ? {} : { projectionState },
|
|
1949
2236
|
attribute: runtime.attribute
|
|
1950
2237
|
});
|
|
@@ -1979,7 +2266,7 @@ async function handleMethod(method, payload, runtime) {
|
|
|
1979
2266
|
verdict: clean.verdict,
|
|
1980
2267
|
explanation: clean.explanation,
|
|
1981
2268
|
residual: clean.residual,
|
|
1982
|
-
prescriptions: ["#
|
|
2269
|
+
prescriptions: ["# 全部检查通过:无需处置"]
|
|
1983
2270
|
};
|
|
1984
2271
|
}
|
|
1985
2272
|
const repair = quarantineProjectionCache(sessionId, runtime.dshHome);
|
|
@@ -2108,4 +2395,4 @@ function apply(ctx) {
|
|
|
2108
2395
|
}), "dsh-manage-session: /session-steward/api route");
|
|
2109
2396
|
}
|
|
2110
2397
|
//#endregion
|
|
2111
|
-
export { DEFAULT_CONFIG, HEALTH_METHODS, HISTORY_METHODS, HealthCache, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, apply, assessRepair, buildProjectionOwnerIndex, buildSessionReport, countCorpus, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, dirSize, discoverSessions, editWorkspaceDocument, findSessionLog, firstLosslessViolation, gateColdRead, gateLogIntegrity, gateLosslessJson, gateProjectionCache, handleMethod, indexSessionDirs, inject, isLossless, isSafeChild, listHistory, locateSessionUsage, methodEnabled, prescribe, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readTailFacts, scanSessions, scanZstdFrames, sessionsRootFor };
|
|
2398
|
+
export { DEFAULT_CONFIG, HEALTH_METHODS, HISTORY_METHODS, HealthCache, STEWARD_API_PREFIX, STEWARD_SETTINGS_NAMESPACE, apply, assessRepair, buildProjectionOwnerIndex, buildSessionReport, classifyGenerationFilename, countCorpus, createAttributor, decodeSessionLogBytes, decodeSessionLogFile, dirSize, discoverSessions, editWorkspaceDocument, findSession, findSessionLog, firstLosslessViolation, gateColdRead, gateGeneration, gateLogIntegrity, gateLosslessJson, gateProjectionCache, generationLogFilename, handleMethod, indexSessionDirs, inject, isLossless, isMigrationStagingFilename, isSafeChild, latestArtifactMtime, listHistory, locateSessionUsage, methodEnabled, parseGenerationLogFilename, prescribe, projCacheRootFor, pruneArchiveFile, pruneHistory, purgeHistory, quarantineProjectionCache, readArchiveSet, readProjectionCache, readSessionGenerations, readTailFacts, scanSessions, scanZstdFrames, sessionPriority, sessionsRootFor };
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-manage-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"registry": "https://registry.npmjs.org",
|
|
6
6
|
"access": "public",
|
|
7
|
-
"tag": "dsh-0.1.
|
|
7
|
+
"tag": "dsh-0.1.5"
|
|
8
8
|
},
|
|
9
|
-
"description": "DSH Web
|
|
9
|
+
"description": "DSH Web 插件(DSH 0.1.5 专线):会话管家 —— 会话历史文件(归档浏览与清理)与会话健康检查(体检 → 处方 → 出院),不碰会话日志、只做可逆处置",
|
|
10
10
|
"keywords": [
|
|
11
11
|
"deepseek-harness",
|
|
12
12
|
"dsh",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"build": "node -e \"require('fs').rmSync('lib',{recursive:true,force:true})\" && tsc -b && tsdown",
|
|
52
52
|
"prepublishOnly": "npm run build",
|
|
53
53
|
"typecheck": "tsc -b --pretty false",
|
|
54
|
-
"test": "vitest run"
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"release:015": "npm publish --tag dsh-0.1.5"
|
|
55
56
|
},
|
|
56
57
|
"files": [
|
|
57
58
|
"lib/index.js",
|
|
@@ -76,15 +77,15 @@
|
|
|
76
77
|
],
|
|
77
78
|
"engines": {
|
|
78
79
|
"node": ">=20",
|
|
79
|
-
"dsh": ">=0.1.
|
|
80
|
+
"dsh": ">=0.1.5-rc.1 <0.1.6-0"
|
|
80
81
|
},
|
|
81
82
|
"license": "MIT",
|
|
82
83
|
"peerDependencies": {
|
|
83
84
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
84
|
-
"@deepseek-ai/dsh-client-locale": ">=0.1.
|
|
85
|
-
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.
|
|
86
|
-
"@deepseek-ai/dsh-client-ui-settings-general": ">=0.1.
|
|
87
|
-
"@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.1.
|
|
85
|
+
"@deepseek-ai/dsh-client-locale": ">=0.1.5-rc.1 <0.1.6-0",
|
|
86
|
+
"@deepseek-ai/dsh-client-ui-settings": ">=0.1.5-rc.1 <0.1.6-0",
|
|
87
|
+
"@deepseek-ai/dsh-client-ui-settings-general": ">=0.1.5-rc.1 <0.1.6-0",
|
|
88
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.1.6-0",
|
|
88
89
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
89
90
|
"react": "^18.2.0"
|
|
90
91
|
},
|
package/screenshots.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 体检面板:体检(五门)→ 处方(命令清单)→ 出院(可逆处置 + before/after 对照)。
|
|
3
3
|
*
|
|
4
4
|
* 三态流转完全由 host 侧报告驱动,客户端只做展示与触发;重型动作都在 host,
|
|
5
5
|
* 渲染周期内不发同步重活。
|
|
@@ -19,6 +19,7 @@ import { translate, type LocaleKey } from './locales.ts'
|
|
|
19
19
|
export type PanelTranslate = (key: LocaleKey, params?: Record<string, unknown>) => string
|
|
20
20
|
|
|
21
21
|
const GATE_LABEL: Record<HealthGate['id'], LocaleKey> = {
|
|
22
|
+
'generation': 'health.gate.generation',
|
|
22
23
|
'log-integrity': 'health.gate.log-integrity',
|
|
23
24
|
'projection-cache': 'health.gate.projection-cache',
|
|
24
25
|
'lossless-json': 'health.gate.lossless-json',
|
|
@@ -315,6 +316,9 @@ export function HealthPanel({ t, onClose }: { t?: PanelTranslate; onClose: () =>
|
|
|
315
316
|
.join(' · ')
|
|
316
317
|
return createElement('li', { key: item.sessionId, className: 'dss_row' }, [
|
|
317
318
|
createElement('span', { key: 'lv', className: `dss_levelPill dss_level_${item.level}` }, translate(t, LEVEL_LABEL[item.level])),
|
|
319
|
+
// 优先级徽标:这些会话的当前代是从迁移暂存(TMP)发布出来的,即本插件
|
|
320
|
+
// 此前读错产物或完全发现不了的那批。列表按优先级排,再标一次让排序可解释。
|
|
321
|
+
item.priority === 'high' && createElement('span', { key: 'p', className: 'dss_meta' }, translate(t, 'health.priority.high')),
|
|
318
322
|
createElement('span', { key: 'id', className: 'dss_meta dss_uuid' }, item.sessionId),
|
|
319
323
|
reasons !== '' && createElement('span', { key: 'why', className: 'dss_why' }, reasons),
|
|
320
324
|
createElement('button', {
|
package/src/client/host-api.ts
CHANGED
|
@@ -98,7 +98,7 @@ export interface HistoryPurgeResponse {
|
|
|
98
98
|
|
|
99
99
|
/** 一个 gate 的结果(与 host 侧 GateResult 对齐)。 */
|
|
100
100
|
export interface HealthGate {
|
|
101
|
-
id: 'log-integrity' | 'projection-cache' | 'lossless-json' | 'cold-read'
|
|
101
|
+
id: 'generation' | 'log-integrity' | 'projection-cache' | 'lossless-json' | 'cold-read'
|
|
102
102
|
level: 'ok' | 'warn' | 'fail' | 'skipped'
|
|
103
103
|
evidence: string
|
|
104
104
|
attribution?: { projection?: string; package?: string; field?: string }
|
|
@@ -110,6 +110,11 @@ export interface HealthReport {
|
|
|
110
110
|
sessionId: string
|
|
111
111
|
/** 总判:聚合时 `skipped` 不抬升(见 host 侧 buildSessionReport)。 */
|
|
112
112
|
level: 'ok' | 'warn' | 'fail'
|
|
113
|
+
/**
|
|
114
|
+
* 处置优先级(见 host 侧 generation.ts):`high` = 当前代是从迁移暂存(TMP)发布出来的。
|
|
115
|
+
* 与 `level` 正交:优先级答「先看谁」,档位答「要不要处置」。
|
|
116
|
+
*/
|
|
117
|
+
priority?: 'high' | 'normal'
|
|
113
118
|
gates: HealthGate[]
|
|
114
119
|
generatedAt: number
|
|
115
120
|
}
|