dsh-workspace-mover 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -38,6 +38,7 @@ import { zstdDecompressSync, zstdCompressSync, constants } from 'node:zlib';
38
38
  import { join, dirname, basename } from 'node:path';
39
39
  import { homedir } from 'node:os';
40
40
  import { spawn } from 'node:child_process';
41
+ import { Config, POLICY_FIELDS, buildConfig, attachConfig } from './config.js';
41
42
 
42
43
  const name = 'workspace-mover';
43
44
  // tools/approval 由 dsh-tools / dsh-user-approval 提供(dsh-base 默认挂载);
@@ -49,7 +50,58 @@ const ZSTD_MAGIC = 4247762216;
49
50
  /** Current write generation: DSH 0.1.5 treats the highest canonical generation as source of truth. */
50
51
  const CURRENT_GENERATION = 3;
51
52
  const HISTORY_FILE = 'history.json';
52
- const HISTORY_LIMIT = 100;
53
+
54
+ //#region 可调策略
55
+ /**
56
+ * 策略读取:**在使用点调用**(与官方 Config 文档"按需读 .get()"的建议一致,
57
+ * 不在模块顶层冻结),优先级为 插件 Config → 环境变量 → 代码默认值。
58
+ *
59
+ * 环境变量是本插件持有的兜底通道:官方 Config 需要 schemastery(宿主 loader 提供,
60
+ * 插件目录下不可解析),所以任何静态 import 都可能打断 boot。这里只做纯读取,
61
+ * Config 接入见 lib/config.js。
62
+ */
63
+ const POLICY_DEFAULTS = {
64
+ historyLimit: 100,
65
+ backupKeep: 20,
66
+ batchLimit: 50,
67
+ repointLimit: 200,
68
+ scanLimit: 400,
69
+ cleanupDays: 30
70
+ };
71
+ /** 环境变量名 → 策略键。 */
72
+ const POLICY_ENV = {
73
+ DSH_WORKSPACE_MOVER_HISTORY_LIMIT: 'historyLimit',
74
+ DSH_WORKSPACE_MOVER_BACKUP_KEEP: 'backupKeep',
75
+ DSH_WORKSPACE_MOVER_BATCH_LIMIT: 'batchLimit',
76
+ DSH_WORKSPACE_MOVER_REPOINT_LIMIT: 'repointLimit',
77
+ DSH_WORKSPACE_MOVER_SCAN_LIMIT: 'scanLimit',
78
+ DSH_WORKSPACE_MOVER_CLEANUP_DAYS: 'cleanupDays'
79
+ };
80
+
81
+ /** 解析一个正整数策略值;非法/越界一律回退默认(配置错误不应让功能不可用)。 */
82
+ function resolvePolicy(key, min, max) {
83
+ const raw = process.env[Object.keys(POLICY_ENV).find((env) => POLICY_ENV[env] === key)];
84
+ if (raw !== undefined && String(raw).trim() !== '') {
85
+ const n = Number(String(raw).trim());
86
+ if (Number.isFinite(n) && Number.isInteger(n) && n >= min && n <= max) return n;
87
+ }
88
+ return POLICY_DEFAULTS[key];
89
+ }
90
+
91
+ /** 历史保留条数。 */
92
+ const historyLimit = () => resolvePolicy('historyLimit', 1, 10000);
93
+ /** 每个会话保留的迁移备份份数。 */
94
+ const backupKeep = () => resolvePolicy('backupKeep', 1, 1000);
95
+ /** 单批移动/修复上限。 */
96
+ const batchLimit = () => resolvePolicy('batchLimit', 1, 500);
97
+ /** 单个工作区搬家上限。 */
98
+ const repointLimit = () => resolvePolicy('repointLimit', 1, 5000);
99
+ /** 救援扫描解析上限。 */
100
+ const scanLimit = () => resolvePolicy('scanLimit', 1, 100000);
101
+ /** 数据清理默认天数。 */
102
+ const cleanupDays = () => resolvePolicy('cleanupDays', 1, 3650);
103
+ //#endregion
104
+
53
105
 
54
106
  /** 路径等价判断:优先 realpath(大小写别名归一),目录缺失时退化为大小写不敏感字符串比较。 */
55
107
  function samePath(a, b) {
@@ -380,14 +432,42 @@ function backupDir() {
380
432
  return join(home, 'workspace-mover', 'backups');
381
433
  }
382
434
 
435
+ /**
436
+ * 备份文件名:`${id}.${epochMs}[-seq].zstd`。
437
+ *
438
+ * 为什么需要 `-seq`:毫秒时间戳不足以区分同一毫秒内的两次备份,而 Linux 的时钟/文件系统
439
+ * 时间戳粒度比 Windows 粗得多,连续两次 stash 极可能落在同一毫秒。此时文件名相撞,
440
+ * 第二次会**覆盖**第一次——用户配置的保留份数(backupKeep)于是静默失效,实际只留下
441
+ * 一份。这不是假设:CI 的 ubuntu / macos 作业就是这样挂掉的。
442
+ *
443
+ * 命名规则:
444
+ * - 无 `-seq`:`atomicWrite` 的 temp+rename 是覆盖语义,等于"同毫秒最后一次胜出",
445
+ * 同时把该毫秒的文件数限制为 1(保留原有测试断言的"同毫秒覆盖"行为,也避免浪费空间)。
446
+ * - 带 `-seq`:该毫秒已被占用时递增,保证每次调用都留下独立的备份文件。
447
+ * 两种形态的排序都保持近似的"旧→新"顺序(`-` = 0x2D < `.` = 0x2E,所以 `ts` 排在
448
+ * `ts-seq` 之前,`ts-seq2` 又排在 `ts-seq10` 之前——同一毫秒内顺序仅影响裁剪,不影响正确性)。
449
+ */
450
+ function backupPath(id) {
451
+ const dir = backupDir();
452
+ const ts = Date.now();
453
+ const base = join(dir, `${id}.${ts}.zstd`);
454
+ if (!existsSync(base)) return base;
455
+ for (let seq = 2; seq < 1000; seq++) {
456
+ const candidate = join(dir, `${id}.${ts}-${seq}.zstd`);
457
+ if (!existsSync(candidate)) return candidate;
458
+ }
459
+ // 极端情况:同一毫秒 1000 次备份。退化为加序号强制唯一,绝不覆盖既有备份。
460
+ return join(dir, `${id}.${ts}-${Math.random().toString(36).slice(2, 8)}.zstd`);
461
+ }
462
+
383
463
  function stashBackup(id, buf) {
384
464
  try {
385
465
  const dir = backupDir();
386
466
  mkdirSync(dir, { recursive: true });
387
- atomicWrite(join(dir, `${id}.${Date.now()}.zstd`), buf);
467
+ atomicWrite(backupPath(id), buf);
388
468
  // 前缀收紧到 `${id}.`:相邻 id(如 session-x 与 session-x-1)的备份互不误删
389
469
  const keep = readdirSync(dir).filter((f) => f.startsWith(`${id}.`)).sort();
390
- while (keep.length > 20) rmSync(join(dir, keep.shift()), { force: true });
470
+ while (keep.length > backupKeep()) rmSync(join(dir, keep.shift()), { force: true });
391
471
  } catch (err) {
392
472
  throw new Error(`backup failed (refusing to move without a stash): ${err?.message ?? err}`);
393
473
  }
@@ -411,7 +491,7 @@ function readHistory() {
411
491
  function writeHistory(entries) {
412
492
  const path = historyPath();
413
493
  mkdirSync(dirname(path), { recursive: true });
414
- atomicWrite(path, Buffer.from(JSON.stringify(entries.slice(-HISTORY_LIMIT), null, 2) + '\n', 'utf8'));
494
+ atomicWrite(path, Buffer.from(JSON.stringify(entries.slice(-historyLimit()), null, 2) + '\n', 'utf8'));
415
495
  }
416
496
 
417
497
  function rememberMove(result, sourceWorkspaceId, targetWorkspaceId) {
@@ -615,8 +695,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
615
695
  if (samePath(sourceCwd, targetPath)) {
616
696
  // 磁盘头已指向目标:若目标工作区还没记账它(历史遗留的未归组会话),
617
697
  // 自动补上归属而不是报错——用户意图就是「让它归到这个组」。
618
- let accounted = false;
619
- try { accounted = [...target.record.sessionIds].includes(String(sessionId)); } catch { /* ignore */ }
698
+ const accounted = rawRecordSessionIds(target).includes(String(sessionId));
620
699
  if (!accounted) {
621
700
  const r = await attachUnregistered(ctx, sessionId);
622
701
  ctx.logger?.info?.(`workspace-mover: self-healed accounting for ${sessionId} -> ${r.workspaceId}`);
@@ -707,7 +786,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
707
786
  try {
708
787
  atomicWrite(srcArtifact, rewritten);
709
788
  } catch (err) {
710
- for (const e of allOwners) { try { if (!e.record.sessionIds.includes(sessionId)) await e.attachSession?.(sessionId); } catch { /* ignore */ } }
789
+ for (const e of allOwners) { try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ } }
711
790
  throw new Error(`move failed while rewriting header: ${err?.message ?? err}`);
712
791
  }
713
792
 
@@ -788,7 +867,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
788
867
  // 索引无需再清:非常驻路径在收尾时已清空、由源 attach 重新记账;
789
868
  // 常驻路径上一段已按移动前快照还原。
790
869
  for (const e of allOwners) {
791
- try { if (!e.record.sessionIds.includes(sessionId)) await e.attachSession?.(sessionId); } catch { /* ignore */ }
870
+ try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ }
792
871
  }
793
872
  } catch (rollbackErr) {
794
873
  recordRecovery(ctx, {
@@ -847,7 +926,7 @@ async function listWorkspaces(ctx) {
847
926
  status: undefined,
848
927
  sessionIds: (() => { try { return [...entity.sessionIds]; } catch { return []; } })(),
849
928
  // 原始记账数(含归档与幽灵成员):空工作区判定必须看原始账本,藏了成员的组不能算空
850
- rawSessionCount: (() => { try { return entity.record.sessionIds.length; } catch { return undefined; } })()
929
+ rawSessionCount: (() => { try { return rawRecordSessionIds(entity).length; } catch { return undefined; } })()
851
930
  }));
852
931
  let archivedSessionIds = [];
853
932
  try { archivedSessionIds = [...registry.archivedSessionIds]; } catch { /* ignore */ }
@@ -855,7 +934,7 @@ async function listWorkspaces(ctx) {
855
934
  }
856
935
 
857
936
  //#region 孤儿会话救援(v0.3):扫描分类 + 批量重挂
858
- const SCAN_MAX_ITEMS = 400;
937
+ const SCAN_MAX_ITEMS = 400; // 默认值(导出用于测试/文档);实际生效扫描上限见 scanLimit()
859
938
 
860
939
  function dirExists(p) {
861
940
  if (typeof p !== 'string' || p.length === 0) return false;
@@ -866,6 +945,58 @@ function dirExists(p) {
866
945
  }
867
946
  }
868
947
 
948
+ /**
949
+ * 存储布局自证(只读,永不抛错)。
950
+ *
951
+ * 本插件的整条管线都建立在两个**未文档化**的假设上:
952
+ * ① `persistence.root` 存在(`SessionPersistence` 的文档化接口只有 create/open/stat/list);
953
+ * ② 目录布局等于本地复刻的 `projectKey` / `encodeSegment`(见上面的"最小移植"注释)。
954
+ * 宿主一旦改变其中任何一项,`sessionDir()` 会静默指向不存在的路径,表现为
955
+ * "所有会话都找不到"而没有任何线索。
956
+ *
957
+ * 这里用真实会话反推一次目录:从 persistence.list() 拿 header,算出它**应该**在的位置,
958
+ * 再用 findHighestArtifact 验证该位置确实有档案。对不上就是 degraded。
959
+ *
960
+ * @returns {{status:'ok'|'degraded'|'empty'|'unavailable', checked:number, path?:string, reason?:string}}
961
+ */
962
+ async function verifyStorageLayout(ctx) {
963
+ const persistence = ctx?.sessionPersistence;
964
+ if (!persistence) return { status: 'unavailable', checked: 0, reason: 'session persistence service unavailable' };
965
+ const root = persistence.root;
966
+ if (typeof root !== 'string' || root.length === 0) {
967
+ return { status: 'degraded', checked: 0, reason: 'persistence backend exposes no root' };
968
+ }
969
+ let headers;
970
+ try {
971
+ headers = await listPersistenceHeaders(persistence);
972
+ } catch (err) {
973
+ return { status: 'unavailable', checked: 0, reason: `persistence.list() failed: ${err?.message ?? err}` };
974
+ }
975
+ if (headers.length === 0) return { status: 'empty', checked: 0, path: root };
976
+ const compression = persistenceCompression(persistence);
977
+ // 取最新几条做样本:编码边界(多字节/分隔符/驱动器号)更容易在其中一条上暴露
978
+ const sample = headers.slice(0, 5);
979
+ for (const header of sample) {
980
+ const { id, cwd } = header;
981
+ if (id === undefined || typeof cwd !== 'string' || cwd.length === 0) continue;
982
+ const expectedDir = sessionDir(root, cwd, id);
983
+ try {
984
+ if (findHighestArtifact(expectedDir, compression)) {
985
+ return { status: 'ok', checked: sample.length, path: root };
986
+ }
987
+ } catch (err) {
988
+ // 混合压缩编码等显式拒绝:布局本身可能是对的,但当前配置读不了它
989
+ return { status: 'degraded', checked: sample.length, path: root, reason: `cannot read derived directory: ${err?.message ?? err}` };
990
+ }
991
+ }
992
+ return {
993
+ status: 'degraded',
994
+ checked: sample.length,
995
+ path: root,
996
+ reason: 'no session archive found at the layout this plugin derives — the host storage layout may have changed'
997
+ };
998
+ }
999
+
869
1000
  /**
870
1001
  * 从投影缓存读会话标题(只读、防御性解析)。侧边栏显示的标题(官方自动命名/重命名)
871
1002
  * 存在投影缓存里,磁盘档案头对这类会话是空的——标题必须以这里为准才能与侧边栏一致;
@@ -931,7 +1062,7 @@ async function scanSessions(ctx) {
931
1062
  }
932
1063
  }
933
1064
  metas.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
934
- const selected = metas.slice(0, SCAN_MAX_ITEMS);
1065
+ const selected = metas.slice(0, scanLimit());
935
1066
  for (const { artifact, idDir, st } of selected) {
936
1067
  seenArtifacts.add(artifact);
937
1068
  let header = null;
@@ -943,7 +1074,7 @@ async function scanSessions(ctx) {
943
1074
  // 记账方按原始 record 计算(公开 getter 会过滤掉幽灵 id);
944
1075
  // 「误放」= 有记账方但不是 cwd 匹配的那个工作区(克隆工具、路径漂移、改名后重建分组)。
945
1076
  const owners = entities.filter((e) => {
946
- try { return [...e.record.sessionIds].includes(sessionId); } catch { return false; }
1077
+ try { return rawRecordSessionIds(e).includes(String(sessionId)); } catch { return false; }
947
1078
  });
948
1079
  const correctlyOwned = owners.length === 1 && matching !== undefined && owners[0].id === matching.id;
949
1080
  const status = header === null ? 'unreadable'
@@ -974,7 +1105,7 @@ async function scanSessions(ctx) {
974
1105
  let ids = [];
975
1106
  // 幽灵正是「有账无档」的 id,而官方公开的 sessionIds getter 会把它们
976
1107
  // 按索引过滤掉——必须遍历原始记账 record.sessionIds 才能看见它们。
977
- try { ids = [...entity.record.sessionIds]; } catch { continue; }
1108
+ try { ids = rawRecordSessionIds(entity); } catch { continue; }
978
1109
  for (const id of ids) {
979
1110
  try {
980
1111
  if (!existsSync(artifactPath(root, entity.path, id, compression))) ghosts.push({ workspaceId: entity.id, sessionId: String(id) });
@@ -986,6 +1117,7 @@ async function scanSessions(ctx) {
986
1117
  return {
987
1118
  root,
988
1119
  scanned: total,
1120
+ scannedParsed: items.length,
989
1121
  truncated: total > items.length,
990
1122
  recoveryCount: recoveryCount(),
991
1123
  counts: items.reduce((acc, it) => { acc[it.status] = (acc[it.status] ?? 0) + 1; return acc; }, {}),
@@ -1077,7 +1209,7 @@ async function attachUnregistered(ctx, sessionId) { const registry = ctx.workspa
1077
1209
  /** 批量迁移入口:逐会话复用 moveSession(各自独立备份/回滚,单条失败不牵连其余);历史聚合成一条。 */
1078
1210
  async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
1079
1211
  if (!Array.isArray(sessions) || sessions.length === 0) throw new Error('sessions must be a non-empty array');
1080
- if (sessions.length > 50) throw new Error(`too many sessions in one batch (max 50, got ${sessions.length})`);
1212
+ if (sessions.length > batchLimit()) throw new Error(`too many sessions in one batch (max ${batchLimit()}, got ${sessions.length})`);
1081
1213
  if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
1082
1214
  // 批内去重:同一会话在一次批量里只处理一次
1083
1215
  const seenIds = new Set();
@@ -1275,8 +1407,10 @@ async function forgetTask(ctx, { taskId, all } = {}) {
1275
1407
  * 数据保护:按时间清理回收站条目与备份(两项独立判定,互不影响)。
1276
1408
  * dryRun=true 只统计不删除,供确认框展示将释放的数量与空间。
1277
1409
  */
1278
- async function cleanupOldData(ctx, { days = 30, dryRun = false } = {}) {
1279
- const keepDays = Math.min(3650, Math.max(1, Number(days) || 30)); // 输入钳制:1–3650 天
1410
+ async function cleanupOldData(ctx, { days, dryRun = false } = {}) {
1411
+ // 注意:不能写 `{ days = 30 }`——解构默认值会把 undefined 变成 30,
1412
+ // 于是下面的策略回退永远看不到"未传值",策略配置被默认值静默遮蔽。
1413
+ const keepDays = Math.min(3650, Math.max(1, days === undefined ? cleanupDays() : (Number(days) || cleanupDays()))); // 输入钳制:1–3650 天
1280
1414
  const cutoff = Date.now() - keepDays * 86400000;
1281
1415
  let freedBytes = 0;
1282
1416
  let recyclePurged = 0;
@@ -1298,7 +1432,8 @@ async function cleanupOldData(ctx, { days = 30, dryRun = false } = {}) {
1298
1432
  const bdir = backupDir();
1299
1433
  if (dirExists(bdir)) {
1300
1434
  for (const name of readdirSync(bdir)) {
1301
- const m = /^(.+)\.(\d+)\.zstd$/.exec(name);
1435
+ // 兼容同毫秒去重后缀:`${id}.${ts}.zstd` 与 `${id}.${ts}-${seq}.zstd`
1436
+ const m = /^(.+)\.(\d+)(?:-\d+)?\.zstd$/.exec(name);
1302
1437
  if (!m) continue;
1303
1438
  const ts = Number(m[2]);
1304
1439
  if (!(ts < cutoff)) continue;
@@ -1330,7 +1465,7 @@ async function homeMisfiledSession(ctx, sessionId) {
1330
1465
  for (const entity of registry.list()) {
1331
1466
  if (entity.id === home.id) continue;
1332
1467
  let listed = false;
1333
- try { listed = [...entity.record.sessionIds].includes(String(sessionId)); } catch { /* ignore */ }
1468
+ try { listed = rawRecordSessionIds(entity).includes(String(sessionId)); } catch { /* ignore */ }
1334
1469
  if (!listed) continue;
1335
1470
  try {
1336
1471
  await entity.detachSession(sessionId);
@@ -1346,7 +1481,7 @@ async function homeMisfiledSession(ctx, sessionId) {
1346
1481
  /** 批量修复入口:relink(换路径真迁移)+ attach(原地补记账)+ home(误放归位)。 */
1347
1482
  async function repairSessions(ctx, actions = []) {
1348
1483
  if (!Array.isArray(actions)) throw new Error('actions must be an array');
1349
- if (actions.length > 50) throw new Error('too many actions in one batch (max 50)');
1484
+ if (actions.length > batchLimit()) throw new Error(`too many actions in one batch (max ${batchLimit()})`);
1350
1485
  const results = [];
1351
1486
  for (const action of actions) {
1352
1487
  const sessionId = action?.sessionId;
@@ -1372,7 +1507,7 @@ async function repairSessions(ctx, actions = []) {
1372
1507
  //#endregion
1373
1508
 
1374
1509
  //#region 工作区搬家向导(v0.5):体检 + 注册表原地重定向 + 成员批量迁移
1375
- const REPOINT_MAX_SESSIONS = 200;
1510
+ const REPOINT_MAX_SESSIONS = 200; // 默认值;实际生效上限见 repointLimit()
1376
1511
 
1377
1512
  /** 规范化一个必须真实存在的目录;失败抛用户可读错误。与官方 realpath 唯一性约定一致。 */
1378
1513
  function canonicalDir(p, label) {
@@ -1398,7 +1533,7 @@ async function auditWorkspaces(ctx) {
1398
1533
  if (typeof entity.status === 'function') status = await entity.status();
1399
1534
  } catch { /* 保持 stat 兜底结果 */ }
1400
1535
  let memberCount = 0;
1401
- try { memberCount = entity.record.sessionIds.length; } catch { /* ignore */ }
1536
+ try { memberCount = rawRecordSessionIds(entity).length; } catch { /* ignore */ }
1402
1537
  items.push({ workspaceId: entity.id, title: entity.title, path: entity.path, status, memberCount });
1403
1538
  }
1404
1539
  return { items };
@@ -1444,14 +1579,24 @@ async function listArchivedSessions(ctx) {
1444
1579
  });
1445
1580
  }
1446
1581
  items.sort((a, b) => String(a.title).localeCompare(String(b.title), 'zh'));
1447
- return { items, truncated: scan.truncated };
1582
+ // 归档会话通常很旧,而扫描只解析按 mtime 排序的前 SCAN_MAX_ITEMS 条——
1583
+ // 大库上它们会整体从本列表消失。把"扫过多少 / 共多少"如实带出去,
1584
+ // 让 UI 能说明"这里不是全部",而不是静默展示一个残缺清单。
1585
+ return {
1586
+ items,
1587
+ truncated: scan.truncated,
1588
+ scannedParsed: scan.scannedParsed ?? scan.items.length,
1589
+ scannedTotal: scan.scanned,
1590
+ archivedTotal: archivedIds.size
1591
+ };
1448
1592
  }
1449
1593
 
1450
1594
  /**
1451
- * 取消归档:走官方 registry 的持久状态写通道(与官方 archiveSession 同款
1452
- * enqueueOperation + setState,域 schema 校验兜底),把 id 从 registry 全局归档集移除;
1453
- * 归档从不摘记账槽,取消后自动回到原工作区原位置。带 targetWorkspaceId 且 ≠ 归属时,
1454
- * 接着走常规迁移(备份/历史/撤销/防运行中全套保护)。
1595
+ * 取消归档。优先使用官方文档化的 `registry.unarchiveSession(sessionId)`——
1596
+ * 它的语义与本插件一致(归档从不摘记账槽,因此取消后自动回到原工作区原位置,
1597
+ * 且不需要会话存在性检查)。官方接口不可用时回退到等价的持久状态写通道
1598
+ * (enqueueOperation + setState,与官方 archiveSession 同款)。
1599
+ * 带 targetWorkspaceId 且 ≠ 归属时,接着走常规迁移(备份/历史/撤销/防运行中全套保护)。
1455
1600
  */
1456
1601
  async function unarchiveSession(ctx, { sessionId, targetWorkspaceId } = {}) {
1457
1602
  if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('sessionId required');
@@ -1459,20 +1604,27 @@ async function unarchiveSession(ctx, { sessionId, targetWorkspaceId } = {}) {
1459
1604
  if (!registry) throw new Error('workspace registry unavailable');
1460
1605
  const archivedIds = readArchivedIds(registry);
1461
1606
  if (!archivedIds.includes(sessionId)) throw new Error(`session '${sessionId}' is not archived`);
1462
- if (typeof registry.enqueueOperation !== 'function' || typeof registry.setState !== 'function' || typeof registry.requireState !== 'function') {
1463
- throw new Error('host workspace registry exposes no state mutation API; unarchive is unsupported on this DSH version');
1464
- }
1465
- await registry.enqueueOperation(async () => {
1466
- const state = registry.requireState();
1467
- await registry.setState({
1468
- ...state,
1469
- archivedSessionIds: state.archivedSessionIds.filter((id) => String(id) !== sessionId)
1607
+
1608
+ if (typeof registry.unarchiveSession === 'function') {
1609
+ // 官方通道:去掉归档集里的 id 即完成任务(记账槽原样保留)。
1610
+ await registry.unarchiveSession(sessionId);
1611
+ } else {
1612
+ if (typeof registry.enqueueOperation !== 'function' || typeof registry.setState !== 'function' || typeof registry.requireState !== 'function') {
1613
+ throw new Error('host workspace registry exposes no state mutation API; unarchive is unsupported on this DSH version');
1614
+ }
1615
+ await registry.enqueueOperation(async () => {
1616
+ const state = registry.requireState();
1617
+ await registry.setState({
1618
+ ...state,
1619
+ archivedSessionIds: state.archivedSessionIds.filter((id) => String(id) !== sessionId)
1620
+ });
1470
1621
  });
1471
- });
1622
+ }
1623
+
1472
1624
  let moved = null;
1473
1625
  if (typeof targetWorkspaceId === 'string' && targetWorkspaceId.length > 0) {
1474
1626
  const owner = registry.list().find((e) => {
1475
- try { return [...e.record.sessionIds].includes(sessionId); } catch { return false; }
1627
+ try { return rawRecordSessionIds(e).includes(String(sessionId)); } catch { return false; }
1476
1628
  });
1477
1629
  if (!owner || owner.id !== targetWorkspaceId) {
1478
1630
  moved = await moveSession(ctx, { sessionId, targetWorkspaceId });
@@ -1576,11 +1728,11 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
1576
1728
  if (typeof header?.cwd !== 'string' || !samePath(header.cwd, stalePath)) continue;
1577
1729
  addAffected(header.id, typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null);
1578
1730
  }
1579
- try { for (const id of entity.record.sessionIds) addAffected(id, registry.headers?.get?.(id)?.title ?? null); } catch { /* 记账不可读时以磁盘扫描为准 */ }
1731
+ try { for (const id of rawRecordSessionIds(entity)) addAffected(id, registry.headers?.get?.(id)?.title ?? null); } catch { /* 记账不可读时以磁盘扫描为准 */ }
1580
1732
 
1581
1733
  const affected = [...byId.entries()].map(([sessionId, title]) => ({ sessionId, title }));
1582
- if (affected.length > REPOINT_MAX_SESSIONS) {
1583
- throw new Error(`该工作区牵涉 ${affected.length} 个会话,超出单次上限 ${REPOINT_MAX_SESSIONS};请分批处理`);
1734
+ if (affected.length > repointLimit()) {
1735
+ throw new Error(`该工作区牵涉 ${affected.length} 个会话,超出单次上限 ${repointLimit()};请分批处理`);
1584
1736
  }
1585
1737
 
1586
1738
  const root = persistence.root;
@@ -1617,23 +1769,27 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
1617
1769
  }
1618
1770
  }
1619
1771
  try {
1620
- await entity.mutate((record) => ({
1621
- ...record,
1622
- path: targetCanon,
1623
- // 标题跟随文件夹改名:仅当标题仍是旧文件夹名(官方 create 的默认值)时同步;
1624
- // 用户自定义过的标题原样保留
1625
- ...(typeof record.title === 'string' && record.title.length > 0
1626
- && record.title.toLowerCase() === basename(stalePath).toLowerCase()
1627
- ? { title: basename(targetCanon) }
1628
- : {})
1629
- }));
1772
+ await entity.mutate((record) => ({ ...record, path: targetCanon }));
1630
1773
  pathUpdated = true;
1774
+ // 标题跟随文件夹改名:仅当标题仍是旧文件夹名(官方 create 的默认值)时同步,
1775
+ // 用户自定义过的标题原样保留。走官方 entity.setTitle,与 mutate 解耦——
1776
+ // 标题同步失败不应影响已经完成的 path 重定向。
1777
+ try {
1778
+ const currentTitle = entity.title;
1779
+ if (typeof currentTitle === 'string' && currentTitle.length > 0
1780
+ && currentTitle.toLowerCase() === basename(stalePath).toLowerCase()
1781
+ && typeof entity.setTitle === 'function') {
1782
+ await entity.setTitle(basename(targetCanon));
1783
+ }
1784
+ } catch (err) {
1785
+ ctx.logger?.warn?.(`workspace-mover: title sync after repoint failed (path is already updated): ${err?.message ?? err}`);
1786
+ }
1631
1787
  // 剪枝保护网:mutate 若因索引预置失败清掉了某些成员,立即按官方
1632
1788
  // attach 语义补回(头部已被预置成新路径,校验必过),失败则记入 skipped。
1633
1789
  try {
1634
1790
  for (const { sessionId } of affected) {
1635
1791
  let accounted = false;
1636
- try { accounted = entity.record.sessionIds.includes(sessionId); } catch { /* ignore */ }
1792
+ try { accounted = rawRecordSessionIds(entity).includes(String(sessionId)); } catch { /* ignore */ }
1637
1793
  if (!accounted) await entity.attachSession(sessionId);
1638
1794
  }
1639
1795
  } catch (err) {
@@ -1699,7 +1855,7 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
1699
1855
  }
1700
1856
  // 旧路径散件(从未记账)落位后补挂账;已记账成员为幂等跳过
1701
1857
  try {
1702
- if (!entity.record.sessionIds.includes(sessionId)) await entity.attachSession(sessionId);
1858
+ if (!rawRecordSessionIds(entity).includes(String(sessionId))) await entity.attachSession(sessionId);
1703
1859
  } catch (err) {
1704
1860
  throw new Error(`迁移完成但补挂账失败(可在会话修复面板重试):${err?.message ?? err}`);
1705
1861
  }
@@ -1783,7 +1939,7 @@ async function locateArtifact(ctx, sessionId) {
1783
1939
  const candidates = [];
1784
1940
  try { const p = registry.sessionPaths?.get?.(sessionId); if (typeof p === 'string' && p) candidates.push(p); } catch { /* ignore */ }
1785
1941
  for (const e of registry.list()) {
1786
- try { if ([...e.record.sessionIds].includes(sessionId)) candidates.push(e.path); } catch { /* ignore */ }
1942
+ try { if (rawRecordSessionIds(e).includes(String(sessionId))) candidates.push(e.path); } catch { /* ignore */ }
1787
1943
  }
1788
1944
  try { const h = registry.headers?.get?.(sessionId); if (typeof h?.cwd === 'string') candidates.push(h.cwd); } catch { /* ignore */ }
1789
1945
  try {
@@ -1856,19 +2012,48 @@ async function modifyArchiveSet(ctx, sessionId, add) {
1856
2012
  }
1857
2013
  }
1858
2014
 
2015
+ /**
2016
+ * 注册表实体上是否存在某个官方文档化的方法(只读探测,永不抛错)。
2017
+ * 用于 mover.status 的能力位:官方接口缺失时插件会静默回退到等价实现,
2018
+ * 从外部看不出差异,因此必须显式可观测。
2019
+ */
2020
+ function hasEntityApi(registry, method) {
2021
+ try {
2022
+ const first = registry?.list?.()?.[0];
2023
+ return typeof first?.[method] === 'function';
2024
+ } catch {
2025
+ return false;
2026
+ }
2027
+ }
2028
+
1859
2029
  /** 非空字符串 id 校验(≤300 字符)。 */
1860
- function validId(value, name = 'id') {
1861
- if (typeof value !== 'string' || value.length === 0 || value.length > 300) {
2030
+ function validId(value, name = 'id') { if (typeof value !== 'string' || value.length === 0 || value.length > 300) {
1862
2031
  throw new Error(`${name} must be a non-empty string of at most 300 characters`);
1863
2032
  }
1864
2033
  return value;
1865
2034
  }
1866
2035
 
2036
+ /**
2037
+ * 读取工作区实体的**原始**记账名单(含幽灵 id)。
2038
+ *
2039
+ * 这是本插件唯一允许触碰的私有成员:官方公开的 `sessionIds` getter 会按
2040
+ * canonical-cwd 索引过滤掉"有账无档"的幽灵 id,而救援扫描正是要看见它们。
2041
+ * 官方 `Workspace` 接口没有等价的未过滤视图,因此这里集中收敛为一个访问器——
2042
+ * 未来宿主改变内部结构时只需改这一处,而不是散落全文件的 15 个 `try/catch`。
2043
+ * 读取失败返回空数组(与"无记账"同义,调用方按只读处理)。
2044
+ */
2045
+ function rawRecordSessionIds(entity) {
2046
+ try {
2047
+ const ids = entity?.record?.sessionIds;
2048
+ return Array.isArray(ids) ? ids.map(String) : [...ids].map(String);
2049
+ } catch {
2050
+ return [];
2051
+ }
2052
+ }
2053
+
1867
2054
  /** 扫描所有记账该会话的实体(只读,不改动)。 */
1868
2055
  function listOwners(registry, sessionId) {
1869
- return registry.list().filter((e) => {
1870
- try { return [...e.record.sessionIds].includes(sessionId); } catch { return false; }
1871
- });
2056
+ return registry.list().filter((e) => rawRecordSessionIds(e).includes(String(sessionId)));
1872
2057
  }
1873
2058
 
1874
2059
  /** 从所有记账方摘除会话(moveSession 的 owners 扫描同款),返回被摘实体。 */
@@ -1903,14 +2088,14 @@ async function deleteSessionToTrash(ctx, payload = {}, hooks = {}) {
1903
2088
  const sessionId = validId(payload?.sessionId, 'sessionId');
1904
2089
  const release = acquireLocks(`session:${sessionId}`);
1905
2090
  try {
1906
- return await deleteSessionToTrashLocked(ctx, sessionId, hooks);
2091
+ return await deleteSessionToTrashLocked(ctx, sessionId, { ...hooks, purgeBackups: payload?.purgeBackups === true });
1907
2092
  } finally {
1908
2093
  release();
1909
2094
  }
1910
2095
  }
1911
2096
 
1912
2097
  async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
1913
- const { moveImpl = moveDir, writeManifestImpl = (path, manifest) => writeFileSync(path, JSON.stringify(manifest, null, 2)) } = hooks;
2098
+ const { moveImpl = moveDir, writeManifestImpl = (path, manifest) => writeFileSync(path, JSON.stringify(manifest, null, 2)), purgeBackups = false } = hooks;
1914
2099
  const registry = ctx.workspaceRegistry;
1915
2100
  const persistence = ctx.sessionPersistence;
1916
2101
  if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
@@ -1965,7 +2150,10 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
1965
2150
  forgetSessionIndexes(registry, sessionId);
1966
2151
  if (archived) await modifyArchiveSet(ctx, sessionId, false);
1967
2152
  await dropProjection(ctx, String(sessionId));
1968
- return { deleted: true, sessionId: String(sessionId), title: manifest.title, how };
2153
+ // 可选:连同该会话的迁移备份一起清掉(默认保留——回收站还原后仍可能要用)。
2154
+ let backupsDeleted = 0;
2155
+ if (purgeBackups) backupsDeleted = (await deleteBackup(ctx, { sessionId: String(sessionId) })).deleted;
2156
+ return { deleted: true, sessionId: String(sessionId), title: manifest.title, how, backupsDeleted };
1969
2157
  }
1970
2158
 
1971
2159
  function readTrashEntry(entryDir) {
@@ -2078,10 +2266,11 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
2078
2266
  }
2079
2267
 
2080
2268
  /** 彻底删除:rm 回收站条目。all=true 清空。 */
2081
- async function purgeTrash(ctx, { sessionId, all } = {}) {
2269
+ async function purgeTrash(ctx, { sessionId, all, purgeBackups = false } = {}) {
2082
2270
  const dir = recycleDir();
2083
- if (!dirExists(dir)) return { purged: 0 };
2271
+ if (!dirExists(dir)) return { purged: 0, backupsDeleted: 0 };
2084
2272
  let purged = 0;
2273
+ let backupsDeleted = 0;
2085
2274
  for (const entry of readdirSync(dir)) {
2086
2275
  const entryDir = join(dir, entry);
2087
2276
  if (!dirExists(entryDir)) continue;
@@ -2089,19 +2278,31 @@ async function purgeTrash(ctx, { sessionId, all } = {}) {
2089
2278
  if (all || (typeof sessionId === 'string' && manifest?.sessionId === String(sessionId))) {
2090
2279
  rmSync(entryDir, { recursive: true, force: true });
2091
2280
  purged++;
2281
+ // 彻底删除(不可还原)时才顺带清备份:留在回收站里的会话仍可能靠备份兜底。
2282
+ if (purgeBackups && manifest?.sessionId) {
2283
+ try { backupsDeleted += (await deleteBackup(ctx, { sessionId: String(manifest.sessionId) })).deleted; }
2284
+ catch { /* 备份清理失败不影响回收站条目已删除的事实 */ }
2285
+ }
2092
2286
  }
2093
2287
  }
2094
- return { purged };
2288
+ return { purged, backupsDeleted };
2095
2289
  }
2096
2290
 
2097
- /** 备份聚合清单:按会话分组(文件名 `${id}.${ts}.zstd`),附投影标题与总占用。 */
2291
+ /**
2292
+ * 备份聚合清单:按会话分组(文件名 `${id}.${ts}.zstd`),附投影标题与总占用。
2293
+ *
2294
+ * 额外标注孤儿组:会话已被彻底删除(或档案已不存在)时,它历次迁移留下的备份
2295
+ * 不会再被任何操作引用——`stashBackup` 的 20 份裁剪只在"再次备份同一会话"时触发,
2296
+ * 所以这些字节会永久留在磁盘上。标出来用户才可能清掉。
2297
+ */
2098
2298
  async function listBackups(ctx) {
2099
2299
  const dir = backupDir();
2100
- if (!dirExists(dir)) return { items: [], totalBytes: 0, dir };
2300
+ if (!dirExists(dir)) return { items: [], totalBytes: 0, dir, orphanBytes: 0, orphanGroups: 0 };
2101
2301
  const titles = readProjectionTitles();
2102
2302
  const groups = new Map();
2103
2303
  for (const name of readdirSync(dir)) {
2104
- const m = /^(.+)\.(\d+)\.zstd$/.exec(name);
2304
+ // 兼容同毫秒去重后缀:`${id}.${ts}.zstd` 与 `${id}.${ts}-${seq}.zstd`
2305
+ const m = /^(.+)\.(\d+)(?:-\d+)?\.zstd$/.exec(name);
2105
2306
  if (!m) continue;
2106
2307
  const [, id, ts] = m;
2107
2308
  let sizeBytes = 0;
@@ -2114,12 +2315,34 @@ async function listBackups(ctx) {
2114
2315
  g.files.push({ name, ts: Number(ts), sizeBytes });
2115
2316
  groups.set(id, g);
2116
2317
  }
2318
+
2319
+ // 存活集合:注册表原始记账 ∪ 持久化档案头。任一命中都视为"会话仍在"。
2320
+ const alive = new Set();
2321
+ try {
2322
+ for (const entity of ctx.workspaceRegistry?.list?.() ?? []) {
2323
+ for (const id of rawRecordSessionIds(entity)) alive.add(String(id));
2324
+ }
2325
+ } catch { /* 注册表不可用:只靠档案头判断 */ }
2326
+ try {
2327
+ for (const header of await listPersistenceHeaders(ctx.sessionPersistence)) {
2328
+ if (header?.id !== undefined) alive.add(String(header.id));
2329
+ }
2330
+ } catch { /* 持久化不可用:只靠记账判断 */ }
2331
+
2117
2332
  const items = [...groups.values()].map((g) => ({
2118
2333
  ...g,
2334
+ orphan: !alive.has(String(g.sessionId)),
2119
2335
  files: g.files.sort((x, y) => y.ts - x.ts).map(({ name, ts, sizeBytes }) => ({ name, ts, sizeBytes }))
2120
2336
  }));
2121
2337
  items.sort((a, b) => b.newest - a.newest);
2122
- return { items, totalBytes: items.reduce((sum, it) => sum + it.totalBytes, 0), dir };
2338
+ const orphanItems = items.filter((it) => it.orphan);
2339
+ return {
2340
+ items,
2341
+ totalBytes: items.reduce((sum, it) => sum + it.totalBytes, 0),
2342
+ orphanGroups: orphanItems.length,
2343
+ orphanBytes: orphanItems.reduce((sum, it) => sum + it.totalBytes, 0),
2344
+ dir
2345
+ };
2123
2346
  }
2124
2347
 
2125
2348
  /** 从备份恢复会话:默认回备份里的原 cwd;占用/缺分组时给 targetWorkspaceId。回读校验。 */
@@ -2286,10 +2509,11 @@ const JSON_TEXT = (args, value) => [{ type: 'text', text: JSON.stringify(value)
2286
2509
  const LIST_MAX_SESSIONS = 40;
2287
2510
 
2288
2511
  /**
2289
- * 构造三个 agent 工具(复用 RPC dispatch:锁、错误码、事务语义与面板完全一致):
2512
+ * 构造四个 agent 工具(复用 RPC dispatch:锁、错误码、事务语义与面板完全一致):
2290
2513
  * - mover_list_sessions 只读:会话/分组清单,供模型把用户口中的名字解析成精确 id
2291
2514
  * - mover_move_session 迁移单个会话(审批门)
2292
2515
  * - mover_repair_sessions 一键修复(审批门,只动记账不动文件)
2516
+ * - mover_doctor 只读自检(无审批门)
2293
2517
  */
2294
2518
  function createAgentTools(ctx, dispatchRef) {
2295
2519
  const SIGNAL_NOTE = 'Cancellation is cooperative: the in-flight step finishes rolling back to a consistent state before stopping.';
@@ -2434,7 +2658,11 @@ async function runDoctor(ctx, agentToolsState) {
2434
2658
  try { const trash = await listTrash(ctx); pass('data-recycle', `${trash.items.length} entries`); }
2435
2659
  catch (err) { fail('data-recycle', String(err?.message ?? err).slice(0, 140)); }
2436
2660
 
2437
- try { const backups = await listBackups(ctx); pass('data-backups', `${backups.items.length} session groups`); }
2661
+ try {
2662
+ const backups = await listBackups(ctx);
2663
+ const orphanNote = backups.orphanGroups > 0 ? `, ${backups.orphanGroups} orphan group(s) (${backups.orphanBytes} bytes)` : '';
2664
+ pass('data-backups', `${backups.items.length} session groups${orphanNote}`);
2665
+ }
2438
2666
  catch (err) { fail('data-backups', String(err?.message ?? err).slice(0, 140)); }
2439
2667
 
2440
2668
  try { const history = await listHistory(); pass('data-history', `${history.items?.length ?? 0} records`); }
@@ -2447,6 +2675,17 @@ async function runDoctor(ctx, agentToolsState) {
2447
2675
  if (recovered === 0) pass('recovery', 'no manual-recovery records');
2448
2676
  else warn('recovery', `${recovered} record(s) need manual review — see the rescue panel`);
2449
2677
 
2678
+ // 存储布局自证:把"所有会话突然都找不到"提前变成一条可见的降级提示。
2679
+ try {
2680
+ const layout = await verifyStorageLayout(ctx);
2681
+ if (layout.status === 'ok') pass('data-layout', `derived directory verified (${layout.checked} sample(s))`);
2682
+ else if (layout.status === 'empty') warn('data-layout', 'no stored sessions to verify against (fresh install?)');
2683
+ else if (layout.status === 'unavailable') warn('data-layout', layout.reason ?? 'storage layout cannot be verified');
2684
+ else warn('data-layout', `storage layout may have changed — ${layout.reason ?? 'no archive found at the derived path'}`);
2685
+ } catch (err) {
2686
+ warn('data-layout', String(err?.message ?? err).slice(0, 140));
2687
+ }
2688
+
2450
2689
  if (!registry) warn('workspaces', 'skipped — registry unavailable');
2451
2690
  else {
2452
2691
  let total = 0;
@@ -2486,26 +2725,36 @@ export function apply(ctx) {
2486
2725
  switch (endpoint) {
2487
2726
  case 'mover.status': {
2488
2727
  const projection = projectionTable(ctx);
2728
+ const layout = await verifyStorageLayout(ctx);
2489
2729
  const capabilities = {
2490
2730
  registry: Boolean(ctx.workspaceRegistry),
2491
2731
  persistence: Boolean(ctx.sessionPersistence),
2492
2732
  projectionRead: typeof projection?.get === 'function',
2493
2733
  projectionWrite: typeof projection?.put === 'function' && typeof projection?.delete === 'function',
2494
2734
  archiveChannel: typeof ctx.workspaceRegistry?.enqueueOperation === 'function',
2735
+ // v2.2 起优先使用的官方文档化接口。是否走官方通道必须**可观测**:
2736
+ // 缺失时会静默回退到等价实现(功能不变),但从外部看不出差异。
2737
+ unarchiveApi: typeof ctx.workspaceRegistry?.unarchiveSession === 'function',
2738
+ setTitleApi: hasEntityApi(ctx.workspaceRegistry, 'setTitle'),
2495
2739
  coordinatorStates: Boolean(peekService(ctx, 'sessionPersistence')?.coordinator?.states),
2496
2740
  fileReferences: Boolean(peekService(ctx, 'fileReferences')),
2497
- agents: Boolean(peekService(ctx, 'agents'))
2741
+ agents: Boolean(peekService(ctx, 'agents')),
2742
+ storageLayout: layout.status
2498
2743
  };
2499
2744
  const degradedFeatures = [];
2500
2745
  if (!capabilities.projectionWrite) degradedFeatures.push('projection-title-restore');
2501
2746
  if (!capabilities.archiveChannel) degradedFeatures.push('archive-set-changes');
2747
+ if (!capabilities.unarchiveApi) degradedFeatures.push('unarchive-official-api');
2748
+ if (!capabilities.setTitleApi) degradedFeatures.push('set-title-official-api');
2502
2749
  if (!capabilities.coordinatorStates) degradedFeatures.push('resident-hotfix');
2503
2750
  if (!capabilities.fileReferences) degradedFeatures.push('file-reference-cleanup');
2751
+ if (layout.status === 'degraded') degradedFeatures.push('storage-layout');
2504
2752
  return ok({
2505
2753
  ready: Boolean(capabilities.registry && capabilities.persistence),
2506
2754
  channel: CHANNEL,
2507
2755
  capabilities,
2508
2756
  degradedFeatures,
2757
+ storageLayout: layout,
2509
2758
  agentTools: agentToolsState.registered,
2510
2759
  recoveryCount: recoveryCount()
2511
2760
  });
@@ -2674,6 +2923,17 @@ export function apply(ctx) {
2674
2923
  }
2675
2924
 
2676
2925
  registerAgentTools(ctx, dispatchRef, logger, agentToolsState);
2926
+
2927
+ // 官方 Config:**暂不注册**,只导出构造能力。
2928
+ //
2929
+ // 官方 schema 来自 @deepseek-ai/schemastery,由宿主 loader 提供;本插件目录下
2930
+ // 实测无法解析该模块(npm 上的 schemastery 也没有 .volatile)。而静态 import
2931
+ // 一个不可解析的模块会打断 **boot**(官方记录的 bundle 层事故类别)。
2932
+ //
2933
+ // 因此这里刻意不向任何未文档化的服务名做 inject——那只会让 fiber 处于半装载
2934
+ // 状态。setup 已经就绪:宿主一旦提供文档化的 schema 接缝,把构造器交给
2935
+ // lib/config.js 的 buildConfig() 即可(已单测覆盖)。在此之前,全部策略都由
2936
+ // 环境变量(见 resolvePolicy)与代码默认值驱动,功能完整。
2677
2937
  }
2678
2938
 
2679
- export { name, inject, CHANNEL, projectKey, encodeSegment, sessionDir, artifactPath, generationLogFilename, parseGenerationLogFilename, listGenerationArtifacts, findHighestArtifact, preferredArtifactName, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, moveSessionEntry, validId, SCAN_MAX_ITEMS };
2939
+ export { name, inject, CHANNEL, projectKey, encodeSegment, sessionDir, artifactPath, generationLogFilename, parseGenerationLogFilename, listGenerationArtifacts, findHighestArtifact, preferredArtifactName, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, moveSessionEntry, validId, SCAN_MAX_ITEMS, POLICY_DEFAULTS, resolvePolicy, verifyStorageLayout, rawRecordSessionIds, historyLimit, backupKeep, batchLimit, repointLimit, scanLimit, cleanupDays, Config, POLICY_FIELDS, buildConfig, attachConfig };