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/README.md +325 -304
- package/client/client.js +261 -68
- package/docs/ARCHITECTURE.md +68 -17
- package/lib/config.js +74 -0
- package/lib/index.js +331 -71
- package/package.json +40 -4
- package/test/client-dom.test.mjs +667 -0
- package/test/e2e-sandbox.test.mjs +288 -50
- package/test/policy.test.mjs +415 -0
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
|
-
|
|
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(
|
|
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 >
|
|
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(-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
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 =
|
|
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 >
|
|
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
|
|
1279
|
-
|
|
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
|
-
|
|
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 =
|
|
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 >
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
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
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
|
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
|
|
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 >
|
|
1583
|
-
throw new Error(`该工作区牵涉 ${affected.length} 个会话,超出单次上限 ${
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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 };
|