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/lib/index.js CHANGED
@@ -965,7 +965,7 @@ function decodeRecordLocal(value) {
965
965
  }
966
966
  /**
967
967
  * 读取一个会话日志文件(zip 帧扫描 + 解包 + seq 连续性)。
968
- * @param file - `session.jsonl.zstd` 绝对路径。
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
- * 四个体检 gate(每个可独立测试)与报告聚合。
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 ? "# 四门全绿:无需处置" : `# 无可处置项(${unjudged.length} 门无从判定,非异常):${unjudged.map((entry) => entry.id).join(" / ")}`);
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
- * 批量体检:枚举会话日志、跑四门、给出汇总(供 `session-health-scan` 使用)。
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>/session.jsonl.zstd`)。 */
1885
+ /** 会话根目录(`<dshHome>/sessions/<project>/<sessionId>/`)。 */
1620
1886
  function sessionsRoot(dshHome) {
1621
1887
  return join(dshHome ?? join(homedir(), ".dsh"), "sessions");
1622
1888
  }
1623
- /** 枚举全部会话日志(按 mtime 倒序),带可选上限。 */
1624
- function discoverSessions(dshHome, limit = 200) {
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
- if (entry.isDirectory()) {
1641
- const candidate = join(path, "session.jsonl.zstd");
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 discoverSessions(dshHome, 1e5).find((session) => session.sessionId === sessionId)?.logPath;
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 logPath = findSessionLog(sessionId, runtime.dshHome);
1939
- if (logPath === void 0) return {
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.2.3",
3
+ "version": "0.3.3",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",
7
- "tag": "dsh-0.1.2"
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.2-rc.1 <0.1.3-0"
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.2-rc.1 <0.1.3-0",
85
- "@deepseek-ai/dsh-client-ui-settings": ">=0.1.2-rc.1 <0.1.3-0",
86
- "@deepseek-ai/dsh-client-ui-settings-general": ">=0.1.2-rc.1 <0.1.3-0",
87
- "@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.1.3-0",
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,3 +1,7 @@
1
1
  {
2
- "screenshots": []
2
+ "screenshots": [
3
+ "assets/left-sidebar.png",
4
+ "assets/archive.png",
5
+ "assets/doctor.png"
6
+ ]
3
7
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 体检面板:体检(四门)→ 处方(命令清单)→ 出院(可逆处置 + before/after 对照)。
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', {
@@ -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
  }