dsh-workspace-mover 2.0.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 ADDED
@@ -0,0 +1,2446 @@
1
+ // dsh-workspace-mover — host half
2
+ //
3
+ // ① 跨工作区「真迁移」原始会话:物理搬移 session.jsonl.zstd、改写头部 cwd、
4
+ // 更新工作区注册表(侧边栏拖拽触发)。会话 id 与历史文件保持原样,
5
+ // 不产生副本,不消耗 token。
6
+ // ② 孤儿会话救援(v0.3):扫描存储根分类失联/未挂账/幽灵档案,
7
+ // relink 复用同一条迁移管线换路径重挂,attach 原地补记账。
8
+ // 针对官方讨论 #3012(项目文件夹移动后历史"消失")的社区修复。
9
+ // ③ 工作区搬家向导(v0.5):治 #3012 的病根——项目文件夹移动/改名后,
10
+ // 工作区注册记录里的 path 失效、成员会话头部 cwd 集体失效。向导把工作区
11
+ // 原地重定向到新路径(经实体统一写入通道 mutate 换 path,工作区 id、标题、
12
+ // 展示排序、归档位全部保持),再逐会话物理搬移+改写+清理常驻状态;
13
+ // 单文件各自备份回滚,中断可携原路径续跑。
14
+ //
15
+ // 热插拔约定:cordis.patch.yml 挂载,不改任何 dsh 源码;零 npm 依赖。
16
+ // 兼容:Node >= 22(zlib zstd*),dsh 0.1.5-rc.1。
17
+ //
18
+ // 内部接口使用声明:
19
+ // - ctx.workspaceRegistry 实体上的 attachSession/detachSession 为进程内公开方法,
20
+ // 但官方 RPC 未暴露跨工作区移动;本插件在进程内直接调用它们。
21
+ // - 工作区重定向使用 entity.mutate(官方实体的统一写入通道,负责 updatedAt 与
22
+ // 失效成员剪枝)。版本敏感点:mutate 按 sessionPaths 内存索引剪枝成员,
23
+ // 因此必须在换 path 之前,把所有受影响会话的三张索引预置成新路径——否则
24
+ // 全体成员会被当成失效记录而清空。mutate 不可用(宿主结构变化)时整个向导
25
+ // 在动第一个文件之前中止,并还原索引快照。
26
+ // - registry.headers / registry.sessionPaths 两张内存缓存索引的失效属于版本敏感操作,
27
+ // 全部包在 try/catch 中;失败时降级为「移动成功但需重启后归属刷新」。
28
+ // - 迁移常驻内存的空闲会话后,需删除 persistence.coordinator.states 里按旧 cwd
29
+ // 缓存的写入状态,否则该会话再追加事件会写回旧路径造成历史分叉;
30
+ // 删除后宿主会在下次 append 时从磁盘新位置重新 adopt(版本敏感,失败降级为重启建议)。
31
+ // - 常驻会话的进程内冻结头永远携带旧 cwd,attach 的 cwd 校验必然失败;
32
+ // 依赖官方「已记账 id 跳过校验」语义,先刷新 registry 三张索引并预置
33
+ // target.record.sessionIds 再 attach(版本敏感,失败自动回滚)。
34
+
35
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, rmSync, statSync, cpSync, statfsSync } from 'node:fs';
36
+ import { realpathSync } from 'node:fs';
37
+ import { zstdDecompressSync, zstdCompressSync, constants } from 'node:zlib';
38
+ import { join, dirname, basename } from 'node:path';
39
+ import { homedir } from 'node:os';
40
+ import { spawn } from 'node:child_process';
41
+
42
+ const name = 'workspace-mover';
43
+ const inject = ['workspaceRegistry', 'sessionPersistence'];
44
+
45
+ const CHANNEL = '/workspace-mover';
46
+ const ZSTD_MAGIC = 4247762216;
47
+ /** Current write generation: DSH 0.1.5 treats the highest canonical generation as source of truth. */
48
+ const CURRENT_GENERATION = 3;
49
+ const HISTORY_FILE = 'history.json';
50
+ const HISTORY_LIMIT = 100;
51
+
52
+ /** 路径等价判断:优先 realpath(大小写别名归一),目录缺失时退化为大小写不敏感字符串比较。 */
53
+ function samePath(a, b) {
54
+ try {
55
+ return realpathSync(a) === realpathSync(b);
56
+ } catch {
57
+ return String(a).toLowerCase() === String(b).toLowerCase();
58
+ }
59
+ }
60
+
61
+ /** 防御性读取宿主服务:未注入或不可用时返回 undefined,不抛错。 */
62
+ function peekService(ctx, key) {
63
+ try {
64
+ return ctx.get?.(key);
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ //#region 存储布局编码 —— 与 @deepseek-ai/dsh-session-persistence-jsonl 语义一致的最小移植
71
+ function encodeSegment(raw) {
72
+ if (raw.length === 0) throw new Error('cannot encode an empty path segment');
73
+ if (raw === '.') return '~002E';
74
+ if (raw === '..') return '~002E~002E';
75
+ let out = '';
76
+ for (let i = 0; i < raw.length; i++) {
77
+ const code = raw.charCodeAt(i);
78
+ const ch = String.fromCharCode(code);
79
+ if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
80
+ else out += '~' + code.toString(16).toUpperCase().padStart(4, '0');
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function projectKey(cwd) {
86
+ if (cwd.length === 0) throw new Error('cannot encode an empty project path');
87
+ let readable = '';
88
+ let separatorRun = false;
89
+ for (let i = 0; i < cwd.length; i++) {
90
+ const code = cwd.charCodeAt(i);
91
+ const ch = String.fromCharCode(code);
92
+ if (ch === '/' || ch === '\\' || ch === ':') {
93
+ if (!separatorRun) readable += '-';
94
+ separatorRun = true;
95
+ } else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
96
+ readable += ch;
97
+ separatorRun = false;
98
+ } else {
99
+ readable += '~' + code.toString(16).toUpperCase().padStart(4, '0');
100
+ separatorRun = false;
101
+ }
102
+ }
103
+ return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`;
104
+ }
105
+
106
+ function sessionDir(root, cwd, id) {
107
+ return join(root, projectKey(cwd), encodeSegment(id));
108
+ }
109
+
110
+ /** Official naming: v0 = session.jsonl[.zstd]; vN = session.vN.jsonl[.zstd]. */
111
+ function generationLogFilename(version, compression = 'zstd') {
112
+ const base = Number(version) === 0 ? 'session.jsonl' : `session.v${Number(version)}.jsonl`;
113
+ return compression === 'zstd' ? `${base}.zstd` : base;
114
+ }
115
+
116
+ function parseGenerationLogFilename(filename) {
117
+ const name = String(filename ?? '');
118
+ let compression = 'none';
119
+ let stem = name;
120
+ if (stem.endsWith('.zstd')) {
121
+ compression = 'zstd';
122
+ stem = stem.slice(0, -5);
123
+ }
124
+ if (stem === 'session.jsonl') return { version: 0, compression, name };
125
+ const m = /^session\.v([1-9][0-9]*)\.jsonl$/.exec(stem);
126
+ if (!m) return null;
127
+ return { version: Number(m[1]), compression, name };
128
+ }
129
+
130
+ function listGenerationArtifacts(dir) {
131
+ if (!existsSync(dir)) return [];
132
+ try { if (!statSync(dir).isDirectory()) return []; } catch { return []; }
133
+ const out = [];
134
+ for (const entry of readdirSync(dir)) {
135
+ const parsed = parseGenerationLogFilename(entry);
136
+ if (!parsed) continue;
137
+ const path = join(dir, entry);
138
+ try {
139
+ if (!statSync(path).isFile()) continue;
140
+ } catch {
141
+ continue;
142
+ }
143
+ out.push({ ...parsed, path });
144
+ }
145
+ return out.sort((a, b) => b.version - a.version || a.name.localeCompare(b.name));
146
+ }
147
+
148
+ function findHighestArtifact(dir, compression = 'zstd') {
149
+ const entries = listGenerationArtifacts(dir);
150
+ const encodings = new Set(entries.map((entry) => entry.compression));
151
+ if (encodings.size > 1) {
152
+ const err = new Error(`mixed session compression encodings under: ${dir}`);
153
+ err.code = 'unsupported';
154
+ throw err;
155
+ }
156
+ if (entries.length > 0 && !encodings.has(compression)) {
157
+ const actual = [...encodings][0];
158
+ const err = new Error(`session compression ${actual} does not match configured ${compression} under: ${dir}`);
159
+ err.code = 'unsupported';
160
+ throw err;
161
+ }
162
+ return entries[0] ?? null;
163
+ }
164
+
165
+ function preferredArtifactName(compression = 'zstd') {
166
+ return generationLogFilename(CURRENT_GENERATION, compression);
167
+ }
168
+
169
+ /** Resolve the highest generation in a session directory; fall back to the default write name. */
170
+ function artifactPath(root, cwd, id, compression = 'zstd') {
171
+ const dir = sessionDir(root, cwd, id);
172
+ const found = findHighestArtifact(dir, compression);
173
+ return found?.path ?? join(dir, preferredArtifactName(compression));
174
+ }
175
+
176
+ function persistenceCompression(persistence) {
177
+ return persistence?.compression === 'none' ? 'none' : 'zstd';
178
+ }
179
+
180
+
181
+ // DSH 0.1.5 returns persistence.list() snapshots as { header, revision, sizeBytes }.
182
+ // Keep accepting the older flat { id, cwd, ... } shape used by earlier hosts/tests.
183
+ function persistenceHeader(snapshot) {
184
+ const header = snapshot?.header;
185
+ return header && typeof header === typeof {} ? header : snapshot;
186
+ }
187
+
188
+ async function listPersistenceHeaders(persistence) {
189
+ const snapshots = await persistence.list();
190
+ if (!Array.isArray(snapshots)) return [];
191
+ return snapshots.map(persistenceHeader).filter((header) => header && typeof header === typeof {});
192
+ }
193
+ function findDestinationArtifact(dir, compression) {
194
+ try {
195
+ if (existsSync(dir) && !statSync(dir).isDirectory()) {
196
+ const err = new Error(`target is not a directory: ${dir}`);
197
+ err.code = 'ENOTDIR';
198
+ throw err;
199
+ }
200
+ return findHighestArtifact(dir, compression);
201
+ } catch (err) {
202
+ if (['ENOTDIR', 'EACCES', 'EPERM'].includes(err?.code)) {
203
+ const e = new Error(`target not writable: ${dir}`);
204
+ e.code = 'not-writable';
205
+ throw e;
206
+ }
207
+ throw err;
208
+ }
209
+ }
210
+ //#endregion
211
+
212
+ //#region zstd 拼接帧容器 —— 定位/改写首帧(会话头)
213
+ /** 扫描完整帧边界;返回 [start,end] 数组。损坏结构抛错。 */
214
+ export function scanFrames(buffer) {
215
+ const frames = [];
216
+ let offset = 0;
217
+ while (offset < buffer.length) {
218
+ const start = offset;
219
+ if (buffer.length - offset < 4) throw new Error(`corrupt zstd log: truncated magic at ${offset}`);
220
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error(`corrupt zstd log: invalid frame magic at ${offset}`);
221
+ offset += 4;
222
+ if (offset === buffer.length) throw new Error('corrupt zstd log: torn frame descriptor');
223
+ const descriptor = buffer.readUInt8(offset++);
224
+ if ((descriptor & 24) !== 0) throw new Error(`corrupt zstd log: reserved header bit at ${offset - 1}`);
225
+ const contentSizeFlag = descriptor >>> 6;
226
+ const singleSegment = (descriptor & 32) !== 0;
227
+ const checksum = (descriptor & 4) !== 0;
228
+ const dictionaryFlag = descriptor & 3;
229
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
230
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : (1 << contentSizeFlag);
231
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
232
+ if (buffer.length - offset < remainingHeaderBytes) throw new Error('corrupt zstd log: torn frame header');
233
+ offset += remainingHeaderBytes;
234
+ for (;;) {
235
+ if (buffer.length - offset < 3) throw new Error('corrupt zstd log: torn block header');
236
+ const blockHeader = buffer.readUIntLE(offset, 3);
237
+ offset += 3;
238
+ const lastBlock = (blockHeader & 1) !== 0;
239
+ const blockType = (blockHeader >>> 1) & 3;
240
+ const blockSize = blockHeader >>> 3;
241
+ if (blockType === 3) throw new Error(`corrupt zstd log: reserved block type at ${offset - 3}`);
242
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
243
+ if (buffer.length - offset < payloadBytes) throw new Error('corrupt zstd log: torn block payload');
244
+ offset += payloadBytes;
245
+ if (lastBlock) break;
246
+ }
247
+ if (checksum) {
248
+ if (buffer.length - offset < 4) throw new Error('corrupt zstd log: torn checksum');
249
+ offset += 4;
250
+ }
251
+ frames.push([start, offset]);
252
+ }
253
+ return frames;
254
+ }
255
+
256
+ /** 解出首帧(必须恰好一行 JSON 会话头)。 */
257
+ export function readHeader(buf, compression = 'zstd') {
258
+ if (compression === 'none') {
259
+ const end = buf.indexOf(10);
260
+ if (end < 0) throw new Error('first line is not newline-terminated');
261
+ if (end === 0) throw new Error('empty session artifact');
262
+ return JSON.parse(buf.subarray(0, end).toString('utf8'));
263
+ }
264
+ const frames = scanFrames(buf);
265
+ if (frames.length < 1) throw new Error('empty session artifact');
266
+ const [s, e] = frames[0];
267
+ const text = zstdDecompressSync(buf.subarray(s, e)).toString('utf8');
268
+ if (text.length === 0 || text.charCodeAt(text.length - 1) !== 10) throw new Error('first frame is not one newline-terminated header line');
269
+ if (text.indexOf('\n') !== text.length - 1) throw new Error('first frame carries more than the header line');
270
+ return JSON.parse(text);
271
+ }
272
+
273
+ /** 用新 cwd 重写首帧并拼回其余帧(字节级保留)。返回新 Buffer。 */
274
+ export function rewriteHeaderCwd(buf, nextCwd, compression = 'zstd') {
275
+ if (compression === 'none') {
276
+ const end = buf.indexOf(10);
277
+ if (end < 0) throw new Error('first line is not newline-terminated');
278
+ const header = readHeader(buf, 'none');
279
+ header.cwd = nextCwd;
280
+ return Buffer.concat([Buffer.from(JSON.stringify(header) + '\n', 'utf8'), buf.subarray(end + 1)]);
281
+ }
282
+ const header = readHeader(buf, 'zstd');
283
+ const frames = scanFrames(buf);
284
+ header.cwd = nextCwd;
285
+ const newFrame0 = zstdCompressSync(Buffer.from(JSON.stringify(header) + '\n', 'utf8'), {
286
+ params: { [constants.ZSTD_c_checksumFlag]: 1 }
287
+ });
288
+ return Buffer.concat([newFrame0, buf.subarray(frames[0][1])]);
289
+ }
290
+
291
+ function sleepSync(ms) {
292
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
293
+ }
294
+
295
+ /**
296
+ * Windows 怪癖防御:目录内刚发生文件改名后立刻改名目录会瞬时 EPERM。
297
+ * 指数退避重试;仍失败返回 false(调用方走复制+删除兜底)。
298
+ */
299
+ function renameWithRetry(src, dst, attempts = 6) {
300
+ for (let i = 0; i < attempts; i++) {
301
+ try {
302
+ renameSync(src, dst);
303
+ return true;
304
+ } catch (err) {
305
+ const code = err?.code;
306
+ if (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY') throw err;
307
+ if (i === attempts - 1) return false;
308
+ sleepSync(20 * 2 ** i);
309
+ }
310
+ }
311
+ return false;
312
+ }
313
+
314
+ function atomicWrite(path, buffer) {
315
+ const tmp = `${path}.wsm-tmp`;
316
+ writeFileSync(tmp, buffer);
317
+ if (!renameWithRetry(tmp, path)) {
318
+ try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
319
+ throw new Error(`atomic publish failed for ${path}`);
320
+ }
321
+ }
322
+
323
+ /**
324
+ * 目录搬运:优先改名(同卷瞬时),退化为递归复制+删除。
325
+ * 保守覆盖策略:目标目录只允许"不存在"或"空"——非空内容一律拒绝,绝不误删外来目录;
326
+ * 复制失败只清理本次创建的目标目录,源目录不动,重试幂等。
327
+ * renameImpl / copyImpl 可注入(测试模拟改名失败与复制中途失败)。
328
+ */
329
+ function moveDir(srcDir, dstDir, hooks = {}) {
330
+ const { copyImpl = cpSync, renameImpl = renameWithRetry } = hooks;
331
+ mkdirSync(dirname(dstDir), { recursive: true });
332
+ let renamed = false;
333
+ try {
334
+ renamed = renameImpl(srcDir, dstDir);
335
+ } catch (err) {
336
+ if (err?.code !== 'EEXIST' && err?.code !== 'ENOTEMPTY') throw err;
337
+ // rename 撞上已存在的目标:落入下方兜底(复查非空后拒绝)
338
+ }
339
+ if (renamed) return 'rename';
340
+ if (existsSync(dstDir) && readdirSync(dstDir).length > 0) {
341
+ // 非空目标拒绝覆盖:外来目录零损伤。若为进程中断留下的残骸,
342
+ // 其中只会是本会话自己的文件(id 全局唯一),可手动删除后重试。
343
+ throw new Error(`destination directory not empty (refusing to overwrite unknown content): ${dstDir}`);
344
+ }
345
+ let owned = false; // 目标目录由本次调用创建时,失败才负责清理
346
+ try {
347
+ if (existsSync(dstDir)) rmSync(dstDir, { recursive: true, force: true }); // 空目录
348
+ mkdirSync(dstDir, { recursive: true });
349
+ owned = true;
350
+ copyImpl(srcDir, dstDir, { recursive: true });
351
+ } catch (err) {
352
+ if (owned) { try { rmSync(dstDir, { recursive: true, force: true }); } catch { /* 尽力清理 */ } }
353
+ throw err;
354
+ }
355
+ rmSync(srcDir, { recursive: true, force: true });
356
+ return 'copy';
357
+ }
358
+
359
+ /**
360
+ * 迁移后一致性校验:目标档案可回读、id 与 cwd 双确认。
361
+ * 复制路径的静默截断、头改写遗漏都会在这里现形。
362
+ */
363
+ function verifyRelocatedArtifact(dstArtifact, sessionId, expectedCwd, compression = 'zstd') {
364
+ const header = readHeader(readFileSync(dstArtifact), compression);
365
+ if (String(header?.id) !== String(sessionId)) {
366
+ throw new Error(`header id mismatch: expected '${sessionId}', got '${header?.id}'`);
367
+ }
368
+ if (typeof header?.cwd !== 'string' || !samePath(header.cwd, expectedCwd)) {
369
+ throw new Error(`header cwd mismatch: expected '${expectedCwd}', got '${header?.cwd}'`);
370
+ }
371
+ return true;
372
+ }
373
+ //#endregion
374
+
375
+ //#region 备份管理($DSH_HOME/workspace-mover/backups,保留最近 20 份)
376
+ function backupDir() {
377
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
378
+ return join(home, 'workspace-mover', 'backups');
379
+ }
380
+
381
+ function stashBackup(id, buf) {
382
+ try {
383
+ const dir = backupDir();
384
+ mkdirSync(dir, { recursive: true });
385
+ atomicWrite(join(dir, `${id}.${Date.now()}.zstd`), buf);
386
+ // 前缀收紧到 `${id}.`:相邻 id(如 session-x 与 session-x-1)的备份互不误删
387
+ const keep = readdirSync(dir).filter((f) => f.startsWith(`${id}.`)).sort();
388
+ while (keep.length > 20) rmSync(join(dir, keep.shift()), { force: true });
389
+ } catch (err) {
390
+ throw new Error(`backup failed (refusing to move without a stash): ${err?.message ?? err}`);
391
+ }
392
+ }
393
+
394
+ function historyPath() {
395
+
396
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
397
+ return join(home, 'workspace-mover', HISTORY_FILE);
398
+ }
399
+
400
+ function readHistory() {
401
+ try {
402
+ const value = JSON.parse(readFileSync(historyPath(), 'utf8'));
403
+ return Array.isArray(value) ? value : [];
404
+ } catch {
405
+ return [];
406
+ }
407
+ }
408
+
409
+ function writeHistory(entries) {
410
+ const path = historyPath();
411
+ mkdirSync(dirname(path), { recursive: true });
412
+ atomicWrite(path, Buffer.from(JSON.stringify(entries.slice(-HISTORY_LIMIT), null, 2) + '\n', 'utf8'));
413
+ }
414
+
415
+ function rememberMove(result, sourceWorkspaceId, targetWorkspaceId) {
416
+ const entry = {
417
+ id: `${result.sessionId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
418
+ sessionId: String(result.sessionId),
419
+ title: result.title,
420
+ sourceWorkspaceId: sourceWorkspaceId ?? null,
421
+ targetWorkspaceId,
422
+ from: result.from.cwd,
423
+ to: result.to.cwd,
424
+ movedAt: new Date().toISOString()
425
+ };
426
+ const history = readHistory();
427
+ history.push(entry);
428
+ writeHistory(history);
429
+ return entry;
430
+ }
431
+
432
+ /** 批量移动聚合成一条历史:多选可跨组,来源工作区按会话各自记录。 */
433
+ function rememberBatchMove(movedItems, targetWorkspaceId, targetPath) {
434
+ const entry = {
435
+ id: `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
436
+ batch: true,
437
+ targetWorkspaceId,
438
+ to: targetPath ?? null,
439
+ movedAt: new Date().toISOString(),
440
+ sessions: movedItems.map((item) => ({
441
+ sessionId: String(item.sessionId),
442
+ title: item.title,
443
+ from: item.fromCwd,
444
+ sourceWorkspaceId: item.sourceWorkspaceId ?? null
445
+ }))
446
+ };
447
+ const history = readHistory();
448
+ history.push(entry);
449
+ writeHistory(history);
450
+ return entry;
451
+ }
452
+
453
+ async function listHistory() {
454
+ return { items: readHistory().reverse() };
455
+ }
456
+
457
+ async function undoMove(ctx, historyId) {
458
+ if (typeof historyId !== 'string' || historyId.length === 0) throw new Error('historyId required');
459
+ const history = readHistory();
460
+ const index = history.findIndex((entry) => entry.id === historyId);
461
+ if (index < 0) throw new Error(`move history '${historyId}' not found`);
462
+ const entry = history[index];
463
+ if (entry.batch) return undoBatchMove(ctx, history, index, entry, historyId);
464
+ if (!entry.sourceWorkspaceId) throw new Error('original workspace is no longer available; choose a target workspace in Session Repair');
465
+ const result = await moveSession(ctx, {
466
+ sessionId: entry.sessionId,
467
+ targetWorkspaceId: entry.sourceWorkspaceId,
468
+ recordHistory: false
469
+ });
470
+ history.splice(index, 1);
471
+ writeHistory(history);
472
+ return { ...result, undone: true, historyId };
473
+ }
474
+
475
+ /** 批量撤回:逐个送回各自来源;成功的从记录中移除,失败保留以便再次撤回。 */
476
+ async function undoBatchMove(ctx, history, index, entry, historyId) {
477
+ const results = [];
478
+ const remaining = [];
479
+ for (const item of entry.sessions ?? []) {
480
+ if (!item.sourceWorkspaceId) {
481
+ remaining.push(item);
482
+ results.push({ sessionId: item.sessionId, ok: false, error: 'original workspace is no longer available; choose a target workspace in Session Repair' });
483
+ continue;
484
+ }
485
+ try {
486
+ await moveSession(ctx, {
487
+ sessionId: item.sessionId,
488
+ targetWorkspaceId: item.sourceWorkspaceId,
489
+ recordHistory: false
490
+ });
491
+ results.push({ sessionId: item.sessionId, ok: true });
492
+ } catch (err) {
493
+ remaining.push(item);
494
+ results.push({ sessionId: item.sessionId, ok: false, error: err?.message ?? String(err) });
495
+ }
496
+ }
497
+ if (remaining.length > 0) {
498
+ entry.sessions = remaining;
499
+ writeHistory(history);
500
+ } else {
501
+ history.splice(index, 1);
502
+ writeHistory(history);
503
+ }
504
+ const undoneCount = results.filter((r) => r.ok).length;
505
+ return { undone: true, batch: true, historyId, results, undoneCount, failedCount: results.length - undoneCount };
506
+ }
507
+ //#endregion
508
+
509
+ //#region RPC 信封(与 dsh rpcErrorSchema 兼容)
510
+ const ok = (value) => ({ ok: true, value });
511
+ const fail = (code, message, details = {}) => ({
512
+ ok: false,
513
+ error: { code, message, details: { issues: [{ message }], ...details } }
514
+ });
515
+ const failBadRequest = (message, details = {}) => fail('bad-request', message, details);
516
+
517
+ //#region 并发防护(v1.4):try-acquire 锁,绝不等待——占用即 busy
518
+ const busyKeys = new Map(); // 'session:<id>' | 'workspace:<id>'
519
+
520
+ function busyError(key) {
521
+ const [kind, id] = key.split(':');
522
+ const err = new Error(kind === 'session'
523
+ ? `session '${id}' is busy: another operation is in flight`
524
+ : `workspace '${id}' is busy: a bulk operation is in flight`);
525
+ err.code = 'busy';
526
+ return err;
527
+ }
528
+
529
+ /** 一次性获取一组锁;任一被占即整体失败(已获取的立即释放),返回释放函数。 */
530
+ function acquireLocks(...keys) {
531
+ const held = [];
532
+ try {
533
+ for (const key of keys) {
534
+ if (busyKeys.has(key)) throw busyError(key);
535
+ busyKeys.set(key, true);
536
+ held.push(key);
537
+ }
538
+ } catch (err) {
539
+ for (const k of held) busyKeys.delete(k);
540
+ throw err;
541
+ }
542
+ return () => { for (const k of held) busyKeys.delete(k); };
543
+ }
544
+
545
+ /** 把抛出的异常映射为稳定错误码(客户端按 code 决定交互,不再匹配英文文案)。 */
546
+ function mapError(err) {
547
+ const msg = String(err?.message ?? err);
548
+ if (err?.code === 'ENOTDIR') return fail('not-writable', `target not writable: ${msg}`);
549
+ if (err?.code && err.code !== 'bad-request') return fail(err.code, msg);
550
+ let code = 'internal-error';
551
+ if (/running|resident in memory|is busy/.test(msg)) code = 'busy';
552
+ else if (/already belongs|already exists|not empty \(refusing/.test(msg)) code = 'conflict';
553
+ else if (/not writable/.test(msg)) code = 'not-writable';
554
+ else if (/insufficient disk space/.test(msg)) code = 'insufficient-space';
555
+ else if (/backup failed, nothing was changed/.test(msg)) code = 'backup-failed';
556
+ else if (/rolled back|rollback|verification failed|move failed|manifest write failed|restore verification|relocate step/.test(msg)) code = 'rollback-failed';
557
+ else if (/unsupported|no state mutation API/.test(msg)) code = 'unsupported';
558
+ else if (/corrupt|mismatch|unreadable|does not match its schema|trashed archive missing|archive missing/.test(msg)) code = 'corrupt-artifact';
559
+ else if (/not found|not archived|not found in session persistence|no trashed session|no backups found|unknown endpoint|unknown workspace|no workspace accounts|no registered workspace/.test(msg)) code = 'not-found';
560
+ else if (/required|must be|too many|invalid|cannot be empty|no matching/.test(msg)) code = 'invalid-input';
561
+ return fail(code, msg);
562
+ }
563
+ //#endregion
564
+
565
+ /** 入口封装:会话锁 + 源/目标工作区锁(try-acquire,占用即 busy)。 */
566
+ async function moveSessionEntry(ctx, payload = {}, hooks = {}) {
567
+ const sessionId = validId(payload?.sessionId, 'sessionId');
568
+ const targetWorkspaceId = validId(payload?.targetWorkspaceId, 'targetWorkspaceId');
569
+ const release = acquireLocks(
570
+ `session:${sessionId}`,
571
+ `workspace:${targetWorkspaceId}`
572
+ );
573
+ try {
574
+ return await moveSession(ctx, { ...payload, sessionId, targetWorkspaceId }, hooks);
575
+ } finally {
576
+ release();
577
+ }
578
+ }
579
+
580
+ /**
581
+ * 迁移一个空闲会话到目标工作区(核心实现,不含锁;入口请走 moveSessionEntry)。
582
+ * 顺序:备份 → 预检 → detach → 改写+搬运(失败自动还原)→ 缓存失效 → attach(失败自动回滚)。
583
+ * stashImpl 可注入(测试模拟备份失败)。
584
+ */
585
+ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, recordHistory = true }, hooks = {}) {
586
+ const { stashImpl = stashBackup } = hooks;
587
+ const registry = ctx.workspaceRegistry;
588
+ const persistence = ctx.sessionPersistence;
589
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
590
+
591
+ // ---- 目标实体 ----
592
+ const target = registry.get(targetWorkspaceId);
593
+ if (target === undefined) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
594
+ const targetPath = target.path;
595
+
596
+ // ---- 运行状态检查:仅拒绝回合进行中的会话 ----
597
+ // sessions.get(id) 对所有常驻内存会话都返回对象(含空闲),不能作为判据;
598
+ // 与宿主一致的真实判据是 agents.get(id)?.status === 'running'。
599
+ assertNotRunning(ctx, sessionId);
600
+ const liveSession = peekService(ctx, 'sessions')?.get?.(sessionId);
601
+ const materialized = liveSession !== undefined;
602
+ const priorLiveHeader = liveSession?.header;
603
+
604
+ // ---- 从磁盘取权威头(不依赖注册表缓存)----
605
+ const headers = await listPersistenceHeaders(persistence);
606
+ const meta = headers.find((h) => String(h.id) === String(sessionId));
607
+ if (meta === undefined) throw new Error(`session '${sessionId}' not found in session persistence`);
608
+ const sourceCwd = meta.cwd;
609
+ const title = typeof sessionTitle === 'string' && sessionTitle.trim()
610
+ ? sessionTitle.trim()
611
+ : (typeof meta.title === 'string' && meta.title.trim() ? meta.title.trim() : '未命名会话');
612
+ if (sourceCwd === undefined) throw new Error('session header carries no cwd');
613
+ if (samePath(sourceCwd, targetPath)) {
614
+ // 磁盘头已指向目标:若目标工作区还没记账它(历史遗留的未归组会话),
615
+ // 自动补上归属而不是报错——用户意图就是「让它归到这个组」。
616
+ let accounted = false;
617
+ try { accounted = [...target.record.sessionIds].includes(String(sessionId)); } catch { /* ignore */ }
618
+ if (!accounted) {
619
+ const r = await attachUnregistered(ctx, sessionId);
620
+ ctx.logger?.info?.(`workspace-mover: self-healed accounting for ${sessionId} -> ${r.workspaceId}`);
621
+ return {
622
+ moved: false,
623
+ attached: true,
624
+ sessionId,
625
+ from: { cwd: sourceCwd },
626
+ to: { cwd: targetPath, workspaceId: targetWorkspaceId },
627
+ cacheInvalidated: true,
628
+ restartHint: null
629
+ };
630
+ }
631
+ throw new Error('session already belongs to the target workspace');
632
+ }
633
+
634
+ // ---- 源工作区实体(可能无主: Ungrouped)----
635
+ let sourceEntity;
636
+ for (const entity of registry.list()) {
637
+ try {
638
+ if (samePath(entity.path, sourceCwd)) { sourceEntity = entity; break; }
639
+ } catch { /* skip missing dirs */ }
640
+ }
641
+
642
+ // ---- 文件路径 ----
643
+ const root = persistence.root;
644
+ if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
645
+ const compression = persistenceCompression(persistence);
646
+ const srcDir = sessionDir(root, sourceCwd, sessionId);
647
+ const dstDir = sessionDir(root, targetPath, sessionId);
648
+ const srcFound = findHighestArtifact(srcDir, compression);
649
+ if (!srcFound) throw new Error(`session artifact missing under: ${srcDir}`);
650
+ const srcArtifact = srcFound.path;
651
+ if (findDestinationArtifact(dstDir, compression)) throw new Error(`destination artifact already exists under: ${dstDir}`);
652
+ const dstArtifact = join(dstDir, srcFound.name);
653
+
654
+ // ---- 备份先行:备份失败 = 零副作用(尚未触碰记账/索引/目标路径)----
655
+ const original = readFileSync(srcArtifact);
656
+ try {
657
+ stashImpl(String(sessionId), original);
658
+ } catch (err) {
659
+ const e = new Error(`backup failed, nothing was changed: ${err?.message ?? err}`);
660
+ e.code = 'backup-failed';
661
+ throw e;
662
+ }
663
+
664
+ // ---- 预检(在改动任何记账之前;任何失败都不落地)----
665
+ const warnings = [];
666
+ const allOwners = listOwners(registry, sessionId);
667
+ if (allOwners.length > 1) {
668
+ // 双记账:移动时把多余归属一并摘除,否则会残留幽灵记账
669
+ warnings.push('double-accounted');
670
+ ctx.logger?.warn?.(`workspace-mover: session ${sessionId} is accounted by ${allOwners.length} workspaces; extra owners will be detached`);
671
+ }
672
+ try {
673
+ const probeDir = dirname(dstArtifact);
674
+ mkdirSync(probeDir, { recursive: true });
675
+ const probe = join(probeDir, `.wsm-probe-${Date.now()}`);
676
+ writeFileSync(probe, Buffer.alloc(0));
677
+ rmSync(probe, { force: true });
678
+ } catch (err) {
679
+ // 源头挂码:底层 EEXIST 文案含 "already exists",交给 mapError 正则会误判为 conflict
680
+ const e = new Error(`target not writable: ${err?.message ?? err}`);
681
+ e.code = 'not-writable';
682
+ throw e;
683
+ }
684
+ try {
685
+ // 提示性检查(Windows 下与实际写入存在竞态):不通过则尽早失败(此时零改动),
686
+ // 通过也不构成保证——真正的兜底是复制失败清理 + 迁移后回读校验。
687
+ const st = statfsSync(dirname(dstArtifact));
688
+ const needed = Math.max(Number(st.bsize) || 4096, statSync(srcArtifact).size * 2);
689
+ if (typeof st.bfree === 'number' && st.bfree * Number(st.bsize || 4096) < needed) {
690
+ const e = new Error(`insufficient disk space at target (need ~${needed} bytes, free ~${st.bfree * Number(st.bsize || 4096)})`);
691
+ e.code = 'insufficient-space';
692
+ throw e;
693
+ }
694
+ } catch (err) {
695
+ if (err?.code === 'insufficient-space' || /insufficient disk space/.test(String(err?.message ?? err))) throw err;
696
+ ctx.logger?.warn?.(`workspace-mover: disk space check skipped: ${err?.message ?? err}`);
697
+ }
698
+
699
+ // ---- detach(纯记账;失败不阻断)——双记账会话把多余归属一并摘除,避免移动后残留幽灵 ----
700
+ await detachEverywhere(ctx, sessionId);
701
+
702
+ // ---- 改写 → 搬运 ----
703
+ const rewritten = rewriteHeaderCwd(original, targetPath, compression);
704
+
705
+ try {
706
+ atomicWrite(srcArtifact, rewritten);
707
+ } catch (err) {
708
+ for (const e of allOwners) { try { if (!e.record.sessionIds.includes(sessionId)) await e.attachSession?.(sessionId); } catch { /* ignore */ } }
709
+ throw new Error(`move failed while rewriting header: ${err?.message ?? err}`);
710
+ }
711
+
712
+ try {
713
+ const how = moveDir(dirname(srcArtifact), dirname(dstArtifact));
714
+ // 迁移后一致性校验:目标档案可回读、id/cwd 双确认。失败视同搬运失败,整体回退。
715
+ try {
716
+ verifyRelocatedArtifact(dstArtifact, sessionId, targetPath, compression);
717
+ } catch (verifyErr) {
718
+ try { moveDir(dirname(dstArtifact), dirname(srcArtifact)); } catch { /* 尽力移回 */ }
719
+ throw new Error(`post-move verification failed: ${verifyErr?.message ?? verifyErr}`);
720
+ }
721
+ if (how !== 'rename') ctx.logger?.info?.('workspace-mover: directory rename fell back to copy+delete');
722
+ // 源项目目录空了就顺手清掉(listProjectDirs 容忍缺失)
723
+ try {
724
+ const srcProject = dirname(dirname(srcArtifact));
725
+ if (existsSync(srcProject) && readdirSync(srcProject).length === 0) rmSync(srcProject, { recursive: true });
726
+ } catch { /* ignore */ }
727
+ } catch (err) {
728
+ // 搬运失败:把改写后的文件退回原文,再抛出
729
+ atomicWrite(srcArtifact, original);
730
+ throw new Error(`move failed at relocate step: ${err?.message ?? err}`);
731
+ }
732
+
733
+ // ---- 内存一致性收尾(版本敏感,降级安全)----
734
+ // ① 注册表缓存:让侧边栏立即按新归属分组。
735
+ // ② 常驻会话的持久化写入状态:coordinator 缓存了旧 cwd 的 meta,
736
+ // 必须删除以迫使宿主从磁盘新位置重新 adopt,避免后续追加写回旧路径。
737
+ let restartHint = null;
738
+ let cacheInvalidated = true;
739
+ // 先留底原索引项,attach 失败时才能完整还原移动前的内存状态
740
+ const priorHeader = registry.headers?.get?.(sessionId);
741
+ const priorSessionPath = registry.sessionPaths?.get?.(sessionId);
742
+ try {
743
+ registry.headers?.delete?.(sessionId);
744
+ registry.sessionPaths?.delete?.(sessionId);
745
+ registry.invalidSessionPaths?.delete?.(sessionId);
746
+ } catch (err) {
747
+ cacheInvalidated = false;
748
+ ctx.logger?.warn?.(`workspace-mover: cache invalidation failed, restart required: ${err?.message ?? err}`);
749
+ }
750
+ if (materialized) {
751
+ try {
752
+ persistence.coordinator?.states?.delete?.(String(sessionId));
753
+ } catch (err) {
754
+ ctx.logger?.warn?.(`workspace-mover: persistence state cleanup failed, restart recommended: ${err?.message ?? err}`);
755
+ restartHint = 'a harness restart is recommended to fully re-bind this session';
756
+ }
757
+ }
758
+ if (!cacheInvalidated && restartHint === null) {
759
+ restartHint = 'sidebar grouping may need a harness restart to refresh';
760
+ }
761
+
762
+ // ---- 常驻会话:先修正进程内 live header,再走官方 attach 写路径 ----
763
+ // DSH 0.1.5 的 WorkspaceEntity.attachSession() 只有在目标快照尚未记账时
764
+ // 才会读取 header 并经 mutate 发出 domain/changed。不能直接预置
765
+ // target.record.sessionIds,否则 attach 变成 no-op,WorkspaceFeed 不会推送目标
766
+ // 工作区的更新帧,浏览器就会暂时把会话显示在“未分组”。
767
+ try {
768
+ if (materialized) {
769
+ const retargeted = retargetLiveHeader(ctx, sessionId, targetPath);
770
+ if (retargeted === null) throw new Error('live session header retarget failed');
771
+ }
772
+ await target.attachSession(sessionId);
773
+ } catch (err) {
774
+ // 自动回滚:还原 live header 与索引 → 原件放回源目录 → 重新挂回源工作区
775
+ try {
776
+ if (materialized && liveSession) liveSession.header = priorLiveHeader;
777
+ if (materialized) {
778
+ if (priorSessionPath === undefined) registry.sessionPaths?.delete?.(sessionId);
779
+ else registry.sessionPaths?.set?.(sessionId, priorSessionPath);
780
+ if (priorHeader === undefined) registry.headers?.delete?.(sessionId);
781
+ else registry.headers?.set?.(sessionId, priorHeader);
782
+ }
783
+ rmSync(dirname(dstArtifact), { recursive: true, force: true });
784
+ mkdirSync(dirname(srcArtifact), { recursive: true });
785
+ writeFileSync(srcArtifact, original);
786
+ // 索引无需再清:非常驻路径在收尾时已清空、由源 attach 重新记账;
787
+ // 常驻路径上一段已按移动前快照还原。
788
+ for (const e of allOwners) {
789
+ try { if (!e.record.sessionIds.includes(sessionId)) await e.attachSession?.(sessionId); } catch { /* ignore */ }
790
+ }
791
+ } catch (rollbackErr) {
792
+ recordRecovery(ctx, {
793
+ kind: 'move', phase: 'attach-rollback', sessionId: String(sessionId),
794
+ source: sourceCwd, target: targetPath, backup: backupDir(),
795
+ lastError: `attach failed (${err?.message ?? err}) AND rollback failed (${rollbackErr?.message ?? rollbackErr})`
796
+ });
797
+ throw new Error(`move failed during attach (${err?.message ?? err}) AND rollback failed (${rollbackErr?.message ?? rollbackErr}). Original bytes stashed under ${backupDir()}`);
798
+ }
799
+ throw new Error(`move failed during target attach, rolled back cleanly: ${err?.message ?? err}`);
800
+ }
801
+
802
+ // ---- 进程内热修:@ 搜索缓存失效 / 投影缓存身份对齐 ----
803
+ invalidateFileReferenceSearches(ctx, sourceCwd);
804
+ await realignProjectionIdentity(ctx, sessionId, targetPath);
805
+
806
+ // ---- registry 单一归属收尾:任何双记账残留在此清零 ----
807
+ try {
808
+ const extras = listOwners(registry, sessionId).filter((e) => e.id !== targetWorkspaceId);
809
+ for (const e of extras) {
810
+ await e.detachSession?.(sessionId);
811
+ ctx.logger?.warn?.(`workspace-mover: post-move cleanup detached stale owner ${e.id} for ${sessionId}`);
812
+ }
813
+ } catch { /* ignore */ }
814
+
815
+ const result = {
816
+ moved: true,
817
+ verified: true,
818
+ sessionId,
819
+ title,
820
+ from: { cwd: sourceCwd },
821
+ to: { cwd: targetPath, workspaceId: targetWorkspaceId },
822
+ cacheInvalidated,
823
+ restartHint,
824
+ warnings
825
+ };
826
+ if (recordHistory) {
827
+ try {
828
+ const historyEntry = rememberMove(result, sourceEntity?.id, targetWorkspaceId);
829
+ result.historyId = historyEntry.id;
830
+ } catch (err) {
831
+ ctx.logger?.warn?.(`workspace-mover: move history write failed: ${err?.message ?? err}`);
832
+ }
833
+ }
834
+ return result;
835
+ }
836
+
837
+ /** 列出工作区与各自会话(供拖拽目标解析)。 */
838
+ async function listWorkspaces(ctx) {
839
+ const registry = ctx.workspaceRegistry;
840
+ if (!registry) throw new Error('workspace registry unavailable');
841
+ const items = registry.list().map((entity) => ({
842
+ workspaceId: entity.id,
843
+ path: entity.path,
844
+ title: entity.title,
845
+ status: undefined,
846
+ sessionIds: (() => { try { return [...entity.sessionIds]; } catch { return []; } })(),
847
+ // 原始记账数(含归档与幽灵成员):空工作区判定必须看原始账本,藏了成员的组不能算空
848
+ rawSessionCount: (() => { try { return entity.record.sessionIds.length; } catch { return undefined; } })()
849
+ }));
850
+ let archivedSessionIds = [];
851
+ try { archivedSessionIds = [...registry.archivedSessionIds]; } catch { /* ignore */ }
852
+ return { items, archivedSessionIds };
853
+ }
854
+
855
+ //#region 孤儿会话救援(v0.3):扫描分类 + 批量重挂
856
+ const SCAN_MAX_ITEMS = 400;
857
+
858
+ function dirExists(p) {
859
+ if (typeof p !== 'string' || p.length === 0) return false;
860
+ try {
861
+ return statSync(p).isDirectory();
862
+ } catch {
863
+ return false;
864
+ }
865
+ }
866
+
867
+ /**
868
+ * 从投影缓存读会话标题(只读、防御性解析)。侧边栏显示的标题(官方自动命名/重命名)
869
+ * 存在投影缓存里,磁盘档案头对这类会话是空的——标题必须以这里为准才能与侧边栏一致;
870
+ * 文件缺失或形状不符时返回空映射,调用方退化为档案头标题。
871
+ */
872
+ function readProjectionTitles() {
873
+ const map = new Map();
874
+ try {
875
+ const base = process.env.DSH_HOME ?? join(homedir(), '.dsh');
876
+ const parsed = JSON.parse(readFileSync(join(base, 'storages', 'session_projcache.json'), 'utf8'));
877
+ const sessions = parsed?.tables?.sessions;
878
+ if (sessions && typeof sessions === 'object') {
879
+ for (const [id, entry] of Object.entries(sessions)) {
880
+ const val = entry?.rows?.title?.val;
881
+ if (typeof val === 'string' && val.trim()) map.set(String(id), val.trim());
882
+ }
883
+ }
884
+ } catch { /* 投影缓存不可读:标题退化为档案头 */ }
885
+ return map;
886
+ }
887
+
888
+ /**
889
+ * 扫描存储根下的全部会话档案并分类:
890
+ * - orphaned 头部 cwd 指向的目录已不存在(项目文件夹被移动/改名/删除,官方讨论 #3012)
891
+ * - unregistered cwd 仍有效但没有任何工作区记账它(bootstrap 只跑一次、agent 内部 fork 不注册等)
892
+ * - misfiled cwd 匹配某工作区,但记账在别的工作区(克隆工具、路径漂移、改名后重建分组)
893
+ * - ok 归属健康(记账方恰好是 cwd 匹配的工作区)
894
+ * - unreadable 档案损坏,解不出会话头
895
+ * 另返回 ghosts:注册表记了账但磁盘档案缺失的幽灵 id。
896
+ */
897
+ async function scanSessions(ctx) {
898
+ const registry = ctx.workspaceRegistry;
899
+ const persistence = ctx.sessionPersistence;
900
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
901
+ const root = persistence.root;
902
+ if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
903
+ const compression = persistenceCompression(persistence);
904
+
905
+ const entities = registry.list();
906
+ let archived = new Set();
907
+ try { archived = new Set([...registry.archivedSessionIds]); } catch { /* ignore */ }
908
+ const projectionTitles = readProjectionTitles();
909
+
910
+ const items = [];
911
+ const seenArtifacts = new Set();
912
+ const metas = [];
913
+ let total = 0;
914
+ // 第一遍:只收集元数据(含超限),按 mtime 降序后仅解析最新 SCAN_MAX_ITEMS 条——
915
+ // 旧实现"目录序先到先得"会在大库下把最新会话截掉。
916
+ for (const proj of readdirSync(root)) {
917
+ if (!(proj.startsWith('--') && proj.endsWith('--'))) continue; // 只认 projectKey 目录
918
+ const projPath = join(root, proj);
919
+ if (!dirExists(projPath)) continue;
920
+ for (const idDir of readdirSync(projPath)) {
921
+ const found = findHighestArtifact(join(projPath, idDir), compression);
922
+ if (!found) continue;
923
+ const artifact = found.path;
924
+ let st;
925
+ try { st = statSync(artifact); } catch { continue; }
926
+ if (!st.isFile()) continue;
927
+ total++;
928
+ metas.push({ artifact, idDir, st });
929
+ }
930
+ }
931
+ metas.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
932
+ const selected = metas.slice(0, SCAN_MAX_ITEMS);
933
+ for (const { artifact, idDir, st } of selected) {
934
+ seenArtifacts.add(artifact);
935
+ let header = null;
936
+ try { header = readHeader(readFileSync(artifact), compression); } catch { header = null; }
937
+ const sessionId = String(header?.id ?? idDir);
938
+ const cwd = typeof header?.cwd === 'string' ? header.cwd : null;
939
+ const alive = dirExists(cwd);
940
+ const matching = cwd ? entities.find((e) => { try { return samePath(e.path, cwd); } catch { return false; } }) : undefined;
941
+ // 记账方按原始 record 计算(公开 getter 会过滤掉幽灵 id);
942
+ // 「误放」= 有记账方但不是 cwd 匹配的那个工作区(克隆工具、路径漂移、改名后重建分组)。
943
+ const owners = entities.filter((e) => {
944
+ try { return [...e.record.sessionIds].includes(sessionId); } catch { return false; }
945
+ });
946
+ const correctlyOwned = owners.length === 1 && matching !== undefined && owners[0].id === matching.id;
947
+ const status = header === null ? 'unreadable'
948
+ : !alive ? 'orphaned'
949
+ : matching === undefined ? 'unregistered' // cwd 有效但没有任何分组认领这个路径
950
+ : owners.length === 0 ? 'unregistered' // 有匹配分组但无人记账
951
+ : correctlyOwned ? 'ok'
952
+ : 'misfiled'; // 记账方存在但不是 cwd 匹配的分组
953
+ items.push({
954
+ sessionId,
955
+ title: projectionTitles.get(sessionId)
956
+ ?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话'),
957
+ cwd,
958
+ status,
959
+ targetWorkspaceId: matching?.id ?? null,
960
+ homeWorkspaceId: matching?.id ?? null,
961
+ homeTitle: matching?.title ?? null,
962
+ homePath: matching?.path ?? null,
963
+ ownerWorkspaceIds: owners.map((e) => e.id),
964
+ sizeBytes: st.size,
965
+ mtimeMs: st.mtimeMs,
966
+ archived: archived.has(sessionId)
967
+ });
968
+ }
969
+
970
+ const ghosts = [];
971
+ for (const entity of entities) {
972
+ let ids = [];
973
+ // 幽灵正是「有账无档」的 id,而官方公开的 sessionIds getter 会把它们
974
+ // 按索引过滤掉——必须遍历原始记账 record.sessionIds 才能看见它们。
975
+ try { ids = [...entity.record.sessionIds]; } catch { continue; }
976
+ for (const id of ids) {
977
+ try {
978
+ if (!existsSync(artifactPath(root, entity.path, id, compression))) ghosts.push({ workspaceId: entity.id, sessionId: String(id) });
979
+ } catch { /* skip */ }
980
+ }
981
+ }
982
+
983
+ items.sort((a, b) => b.mtimeMs - a.mtimeMs);
984
+ return {
985
+ root,
986
+ scanned: total,
987
+ truncated: total > items.length,
988
+ recoveryCount: recoveryCount(),
989
+ counts: items.reduce((acc, it) => { acc[it.status] = (acc[it.status] ?? 0) + 1; return acc; }, {}),
990
+ items,
991
+ ghosts
992
+ };
993
+ }
994
+
995
+ /** 仅拒绝回合进行中的会话(与宿主 UI「进行中」徽标同款判据)。 */
996
+ function assertNotRunning(ctx, sessionId) {
997
+ if (peekService(ctx, 'agents')?.get?.(sessionId)?.status === 'running') {
998
+ throw new Error('session is currently running; wait for its turn to finish before moving');
999
+ }
1000
+ }
1001
+
1002
+ //#region 迁移后的进程内热修(版本敏感,全部 fail-soft)
1003
+
1004
+ /**
1005
+ * 换掉常驻会话对象上的冻结旧头。官方 header 是 deepFreeze 的纯对象,
1006
+ * 但 live.session.header 是普通实例属性——重启后宿主本来就会从磁盘新头
1007
+ * 重建,这里等价地原地换成新值的冻结克隆。@ 文件引用、未来的 attach
1008
+ * cwd 校验等一切读取 live 头的消费方随即拿到新 cwd。
1009
+ */
1010
+ function retargetLiveHeader(ctx, sessionId, nextCwd) {
1011
+ try {
1012
+ const live = peekService(ctx, 'sessions')?.get?.(sessionId);
1013
+ if (!live || typeof live.header !== 'object' || live.header === null) return false;
1014
+ if (typeof live.header.cwd === 'string' && samePath(live.header.cwd, nextCwd)) return false;
1015
+ const nextHeader = { ...live.header, cwd: nextCwd };
1016
+ try { Object.freeze(nextHeader); } catch { /* 冻结失败无害 */ }
1017
+ live.header = nextHeader;
1018
+ return true;
1019
+ } catch (err) {
1020
+ ctx.logger?.warn?.(`workspace-mover: live header retarget failed for ${sessionId}: ${err?.message ?? err}`);
1021
+ return null;
1022
+ }
1023
+ }
1024
+
1025
+ /** 清空以旧路径为根的 @ 文件引用搜索缓存(下一次 @ 会按新头重建根目录)。 */
1026
+ function invalidateFileReferenceSearches(ctx, stalePath) {
1027
+ try {
1028
+ const searches = peekService(ctx, 'fileReferences')?.searches;
1029
+ if (!(searches instanceof Map)) return;
1030
+ for (const [agent, search] of [...searches]) {
1031
+ const root = search?.root ?? agent?.session?.header?.cwd;
1032
+ if (typeof root === 'string' && samePath(root, stalePath)) {
1033
+ try { search.dispose?.(); } catch { /* ignore */ }
1034
+ searches.delete(agent);
1035
+ }
1036
+ }
1037
+ } catch (err) {
1038
+ ctx.logger?.warn?.(`workspace-mover: file-reference invalidation failed: ${err?.message ?? err}`);
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * 对齐投影缓存检查点的日志身份(identity.cwd)。缓存以 {createdAt, cwd}
1044
+ * 绑定日志生命周期,cwd 不对齐会在下次冷启动时整条废弃——标题投影随之
1045
+ * 懒重建,侧栏在会话被打开前回退显示分组名。
1046
+ */
1047
+ async function realignProjectionIdentity(ctx, sessionId, nextCwd) {
1048
+ try {
1049
+ const table = peekService(ctx, 'sessionProjectionCache')?.table;
1050
+ if (typeof table?.update !== 'function') return;
1051
+ await table.update(String(sessionId), (rec) =>
1052
+ rec?.identity ? { ...rec, identity: { ...rec.identity, cwd: nextCwd } } : rec
1053
+ );
1054
+ } catch (err) {
1055
+ ctx.logger?.warn?.(`workspace-mover: projection identity alignment failed for ${sessionId}: ${err?.message ?? err}`);
1056
+ }
1057
+ }
1058
+ //#endregion
1059
+
1060
+ /** 把一个「cwd 有效但无人记账」的会话挂接到路径匹配的现有工作区。 */
1061
+ async function attachUnregistered(ctx, sessionId) { const registry = ctx.workspaceRegistry;
1062
+ const persistence = ctx.sessionPersistence;
1063
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
1064
+ assertNotRunning(ctx, sessionId);
1065
+ const headers = await listPersistenceHeaders(persistence);
1066
+ const meta = headers.find((h) => String(h.id) === String(sessionId));
1067
+ if (meta === undefined) throw new Error(`session '${sessionId}' not found in session persistence`);
1068
+ if (meta.cwd === undefined) throw new Error('session header carries no cwd');
1069
+ const entity = registry.list().find((e) => { try { return samePath(e.path, meta.cwd); } catch { return false; } });
1070
+ if (!entity) throw new Error(`no workspace accounts path '${meta.cwd}' — create that workspace first, then retry`);
1071
+ await entity.attachSession(sessionId);
1072
+ return { workspaceId: entity.id, path: entity.path };
1073
+ }
1074
+
1075
+ /** 批量迁移入口:逐会话复用 moveSession(各自独立备份/回滚,单条失败不牵连其余);历史聚合成一条。 */
1076
+ async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
1077
+ if (!Array.isArray(sessions) || sessions.length === 0) throw new Error('sessions must be a non-empty array');
1078
+ if (sessions.length > 50) throw new Error(`too many sessions in one batch (max 50, got ${sessions.length})`);
1079
+ if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
1080
+ // 批内去重:同一会话在一次批量里只处理一次
1081
+ const seenIds = new Set();
1082
+ sessions = sessions.filter((item) => {
1083
+ const sid = String(item?.sessionId ?? '');
1084
+ if (!sid || seenIds.has(sid)) return false;
1085
+ seenIds.add(sid);
1086
+ return true;
1087
+ });
1088
+ const registry = ctx.workspaceRegistry;
1089
+ const results = [];
1090
+ const moved = [];
1091
+ let movedCount = 0;
1092
+ let attachedCount = 0;
1093
+ for (const item of sessions) {
1094
+ const sessionId = item?.sessionId;
1095
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
1096
+ results.push({ sessionId: null, ok: false, error: 'sessionId required' });
1097
+ continue;
1098
+ }
1099
+ try {
1100
+ const result = await moveSessionEntry(ctx, {
1101
+ sessionId,
1102
+ targetWorkspaceId,
1103
+ sessionTitle: typeof item.sessionTitle === 'string' ? item.sessionTitle : undefined,
1104
+ recordHistory: false
1105
+ });
1106
+ if (result.attached) attachedCount += 1; else movedCount += 1;
1107
+ let sourceWorkspaceId = null;
1108
+ try {
1109
+ sourceWorkspaceId = registry?.list?.().find((e) => { try { return samePath(e.path, result.from.cwd); } catch { return false; } })?.id ?? null;
1110
+ } catch { sourceWorkspaceId = null; }
1111
+ moved.push({ sessionId, title: result.title, fromCwd: result.from.cwd, sourceWorkspaceId });
1112
+ results.push({ sessionId, ok: true, moved: result.moved, to: result.to.cwd, title: result.title });
1113
+ } catch (err) {
1114
+ results.push({ sessionId, ok: false, error: err?.message ?? String(err) });
1115
+ }
1116
+ }
1117
+ let historyId = null;
1118
+ if (moved.length > 0) {
1119
+ try {
1120
+ const target = registry?.list?.().find((e) => e.id === targetWorkspaceId);
1121
+ historyId = rememberBatchMove(moved, targetWorkspaceId, target?.path ?? null).id;
1122
+ } catch (err) {
1123
+ ctx.logger?.warn?.(`workspace-mover: batch move history write failed: ${err?.message ?? err}`);
1124
+ }
1125
+ }
1126
+ let taskId = null;
1127
+ try {
1128
+ taskId = recordMoveManyTask(ctx, { targetWorkspaceId, results, moved })?.id ?? null;
1129
+ } catch (err) {
1130
+ ctx.logger?.warn?.(`workspace-mover: task record write failed: ${err?.message ?? err}`);
1131
+ }
1132
+ return { results, movedCount, attachedCount, failedCount: results.length - movedCount - attachedCount, historyId, taskId };
1133
+ }
1134
+
1135
+ //#region 迁移任务中心(v1.2,记录式)
1136
+ // 任务 = 已执行批量的持久化记录 + 逐项可重试状态。不是后台队列:
1137
+ // 重试在 RPC 内同步执行,逐项隔离失败;来源位置在重试时实时解析
1138
+ // (记录里的 sourceCwd 仅作展示),因此用户在批后手动搬动过会话也不会用错路径。
1139
+ const TASKS_FILE = 'tasks.json';
1140
+ const TASKS_LIMIT = 50;
1141
+
1142
+ function tasksPath() {
1143
+ return join(pluginDataDir(), TASKS_FILE);
1144
+ }
1145
+
1146
+ function readTasks() {
1147
+ try {
1148
+ const value = JSON.parse(readFileSync(tasksPath(), 'utf8'));
1149
+ return Array.isArray(value?.tasks) ? value.tasks : [];
1150
+ } catch {
1151
+ return [];
1152
+ }
1153
+ }
1154
+
1155
+ function writeTasks(tasks) {
1156
+ mkdirSync(dirname(tasksPath()), { recursive: true });
1157
+ atomicWrite(tasksPath(), Buffer.from(JSON.stringify({ version: 1, tasks: tasks.slice(0, TASKS_LIMIT) }, null, 2) + '\n', 'utf8'));
1158
+ }
1159
+
1160
+ /** 批量迁移完成后落任务记录:done/failed 逐项带来源与目标路径、最后错误与最后尝试时间。 */
1161
+ function recordMoveManyTask(ctx, { targetWorkspaceId, results, moved }) {
1162
+ const target = ctx.workspaceRegistry?.list?.().find((e) => e.id === targetWorkspaceId);
1163
+ const items = [];
1164
+ for (const r of results) {
1165
+ if (r.sessionId === null) continue;
1166
+ items.push({
1167
+ sessionId: String(r.sessionId),
1168
+ title: r.title ?? null,
1169
+ sourceCwd: moved.find((mv) => mv.sessionId === r.sessionId)?.fromCwd ?? null,
1170
+ targetCwd: target?.path ?? null,
1171
+ state: r.ok ? 'done' : 'failed',
1172
+ error: r.ok ? null : (r.error ?? 'unknown'),
1173
+ lastAttemptAt: new Date().toISOString()
1174
+ });
1175
+ }
1176
+ if (items.length === 0) return null;
1177
+ const tasks = readTasks();
1178
+ const task = {
1179
+ id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1180
+ kind: 'moveMany',
1181
+ targetWorkspaceId,
1182
+ targetPath: target?.path ?? null,
1183
+ createdAt: new Date().toISOString(),
1184
+ items
1185
+ };
1186
+ tasks.unshift(task);
1187
+ writeTasks(tasks);
1188
+ return task;
1189
+ }
1190
+
1191
+ async function listTasks() {
1192
+ const tasks = readTasks();
1193
+ return {
1194
+ items: tasks.map((t) => ({
1195
+ id: t.id,
1196
+ kind: t.kind,
1197
+ targetWorkspaceId: t.targetWorkspaceId,
1198
+ targetPath: t.targetPath,
1199
+ createdAt: t.createdAt,
1200
+ done: t.items.filter((i) => i.state === 'done').length,
1201
+ failed: t.items.filter((i) => i.state === 'failed').length,
1202
+ skipped: t.items.filter((i) => i.state === 'skipped').length,
1203
+ total: t.items.length,
1204
+ items: t.items
1205
+ }))
1206
+ };
1207
+ }
1208
+
1209
+ /**
1210
+ * 仅重试未成功项:失败/跳过的逐项再迁移(同步、逐项隔离)。
1211
+ * 来源位置实时解析(moveSession 按 persistence 头定位),不用记录时的旧路径;
1212
+ * 报"already belongs to the target workspace"的按完成处理(幂等);
1213
+ * 成功项补记批量历史,撤回能力与常规批量一致。
1214
+ */
1215
+ async function retryTask(ctx, { taskId } = {}) {
1216
+ if (typeof taskId !== 'string' || taskId.length === 0) throw new Error('taskId required');
1217
+ const tasks = readTasks();
1218
+ const task = tasks.find((t) => t.id === taskId);
1219
+ if (!task) throw new Error(`task '${taskId}' not found`);
1220
+ if (task.kind !== 'moveMany') throw new Error(`task kind '${task.kind}' is not retryable`);
1221
+ const retryable = task.items.filter((it) => it.state !== 'done').slice(0, 50);
1222
+ const results = [];
1223
+ const movedNow = [];
1224
+ for (const item of retryable) {
1225
+ const attemptAt = new Date().toISOString();
1226
+ try {
1227
+ const result = await moveSessionEntry(ctx, { sessionId: item.sessionId, targetWorkspaceId: task.targetWorkspaceId, sessionTitle: item.title ?? undefined, recordHistory: false });
1228
+ item.state = 'done';
1229
+ item.error = null;
1230
+ item.lastError = null;
1231
+ item.lastAttemptAt = attemptAt;
1232
+ item.targetCwd = result.to?.cwd ?? item.targetCwd;
1233
+ movedNow.push({ sessionId: item.sessionId, title: item.title, fromCwd: result.from?.cwd ?? null, sourceWorkspaceId: null });
1234
+ results.push({ sessionId: item.sessionId, ok: true });
1235
+ } catch (err) {
1236
+ const msg = String(err?.message ?? err);
1237
+ item.lastAttemptAt = attemptAt;
1238
+ item.lastError = msg;
1239
+ if (/already belongs to the target workspace/.test(msg)) {
1240
+ item.state = 'done';
1241
+ item.error = null;
1242
+ results.push({ sessionId: item.sessionId, ok: true });
1243
+ } else {
1244
+ item.state = 'failed';
1245
+ item.error = msg;
1246
+ results.push({ sessionId: item.sessionId, ok: false, error: msg });
1247
+ }
1248
+ }
1249
+ }
1250
+ if (movedNow.length > 0) {
1251
+ try { rememberBatchMove(movedNow, task.targetWorkspaceId, task.targetPath); } catch (err) { ctx.logger?.warn?.(`workspace-mover: retry history write failed: ${err?.message ?? err}`); }
1252
+ }
1253
+ writeTasks(tasks);
1254
+ return {
1255
+ retried: retryable.length,
1256
+ fixedCount: results.filter((r) => r.ok).length,
1257
+ failedCount: results.filter((r) => !r.ok).length,
1258
+ results,
1259
+ items: task.items
1260
+ };
1261
+ }
1262
+
1263
+ /** 清除任务记录(仅记录本身,不影响任何已迁移会话)。 */
1264
+ async function forgetTask(ctx, { taskId, all } = {}) {
1265
+ const tasks = readTasks();
1266
+ const before = tasks.length;
1267
+ const kept = all ? [] : tasks.filter((t) => t.id !== taskId);
1268
+ writeTasks(kept);
1269
+ return { forgotten: before - kept.length };
1270
+ }
1271
+
1272
+ /**
1273
+ * 数据保护:按时间清理回收站条目与备份(两项独立判定,互不影响)。
1274
+ * dryRun=true 只统计不删除,供确认框展示将释放的数量与空间。
1275
+ */
1276
+ async function cleanupOldData(ctx, { days = 30, dryRun = false } = {}) {
1277
+ const keepDays = Math.min(3650, Math.max(1, Number(days) || 30)); // 输入钳制:1–3650 天
1278
+ const cutoff = Date.now() - keepDays * 86400000;
1279
+ let freedBytes = 0;
1280
+ let recyclePurged = 0;
1281
+ let backupDeleted = 0;
1282
+ const rdir = recycleDir();
1283
+ if (dirExists(rdir)) {
1284
+ for (const entry of readdirSync(rdir)) {
1285
+ const entryDir = join(rdir, entry);
1286
+ if (!dirExists(entryDir)) continue;
1287
+ const manifest = readTrashEntry(entryDir);
1288
+ if (!manifest?.sessionId) continue;
1289
+ const ts = Number.isFinite(Date.parse(manifest.deletedAt ?? '')) ? Date.parse(manifest.deletedAt) : 0;
1290
+ if (ts >= cutoff) continue;
1291
+ const size = dirSizeSync(join(entryDir, 'session'));
1292
+ if (dryRun) { recyclePurged++; freedBytes += size; continue; }
1293
+ try { rmSync(entryDir, { recursive: true, force: true }); recyclePurged++; freedBytes += size; } catch { /* 单项失败不影响其余 */ }
1294
+ }
1295
+ }
1296
+ const bdir = backupDir();
1297
+ if (dirExists(bdir)) {
1298
+ for (const name of readdirSync(bdir)) {
1299
+ const m = /^(.+)\.(\d+)\.zstd$/.exec(name);
1300
+ if (!m) continue;
1301
+ const ts = Number(m[2]);
1302
+ if (!(ts < cutoff)) continue;
1303
+ let size = 0;
1304
+ try { size = statSync(join(bdir, name)).size; } catch { continue; }
1305
+ if (dryRun) { backupDeleted++; freedBytes += size; continue; }
1306
+ try { rmSync(join(bdir, name), { force: true }); backupDeleted++; freedBytes += size; } catch { /* 单项失败不影响其余 */ }
1307
+ }
1308
+ }
1309
+ return { recyclePurged, backupDeleted, freedBytes, dryRun: Boolean(dryRun) };
1310
+ }
1311
+
1312
+ /**
1313
+ * 误放归位:会话的真实目录(header cwd)匹配某工作区,但记账在别处。
1314
+ * 摘掉所有错误记账方,再由 attachUnregistered 补上正确归属。
1315
+ * 磁盘文件本就在正确位置(cwd 未漂移),无需备份/搬运/改写。
1316
+ */
1317
+ async function homeMisfiledSession(ctx, sessionId) {
1318
+ const registry = ctx.workspaceRegistry;
1319
+ const persistence = ctx.sessionPersistence;
1320
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
1321
+ assertNotRunning(ctx, sessionId);
1322
+ const headers = await listPersistenceHeaders(persistence);
1323
+ const meta = headers.find((h) => String(h.id) === String(sessionId));
1324
+ if (meta === undefined) throw new Error(`session '${sessionId}' not found in session persistence`);
1325
+ if (meta.cwd === undefined) throw new Error('session header carries no cwd');
1326
+ const home = registry.list().find((e) => { try { return samePath(e.path, meta.cwd); } catch { return false; } });
1327
+ if (!home) throw new Error(`no workspace accounts path '${meta.cwd}'`);
1328
+ for (const entity of registry.list()) {
1329
+ if (entity.id === home.id) continue;
1330
+ let listed = false;
1331
+ try { listed = [...entity.record.sessionIds].includes(String(sessionId)); } catch { /* ignore */ }
1332
+ if (!listed) continue;
1333
+ try {
1334
+ await entity.detachSession(sessionId);
1335
+ ctx.logger?.info?.(`workspace-mover: detached misfiled ${sessionId} from ${entity.id}`);
1336
+ } catch (err) {
1337
+ ctx.logger?.warn?.(`workspace-mover: detach misfiled failed (continuing): ${err?.message ?? err}`);
1338
+ }
1339
+ }
1340
+ const r = await attachUnregistered(ctx, sessionId);
1341
+ return { sessionId, ok: true, homedTo: r.workspaceId };
1342
+ }
1343
+
1344
+ /** 批量修复入口:relink(换路径真迁移)+ attach(原地补记账)+ home(误放归位)。 */
1345
+ async function repairSessions(ctx, actions = []) {
1346
+ if (!Array.isArray(actions)) throw new Error('actions must be an array');
1347
+ if (actions.length > 50) throw new Error('too many actions in one batch (max 50)');
1348
+ const results = [];
1349
+ for (const action of actions) {
1350
+ const sessionId = action?.sessionId;
1351
+ const label = String(sessionId ?? '?');
1352
+ try {
1353
+ if (action.kind === 'relink') {
1354
+ const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId: action.targetWorkspaceId });
1355
+ results.push({ sessionId, ok: true, moved: true, to: result.to.cwd });
1356
+ } else if (action.kind === 'attach') {
1357
+ const r = await attachUnregistered(ctx, sessionId);
1358
+ results.push({ sessionId, ok: true, attachedTo: r.workspaceId });
1359
+ } else if (action.kind === 'home') {
1360
+ results.push(await homeMisfiledSession(ctx, sessionId));
1361
+ } else {
1362
+ results.push({ sessionId, ok: false, error: `unknown kind '${action.kind}'` });
1363
+ }
1364
+ } catch (err) {
1365
+ results.push({ sessionId, ok: false, error: err?.message ?? String(err) });
1366
+ }
1367
+ }
1368
+ return { results };
1369
+ }
1370
+ //#endregion
1371
+
1372
+ //#region 工作区搬家向导(v0.5):体检 + 注册表原地重定向 + 成员批量迁移
1373
+ const REPOINT_MAX_SESSIONS = 200;
1374
+
1375
+ /** 规范化一个必须真实存在的目录;失败抛用户可读错误。与官方 realpath 唯一性约定一致。 */
1376
+ function canonicalDir(p, label) {
1377
+ if (typeof p !== 'string' || p.trim().length === 0) throw new Error(`${label}不能为空`);
1378
+ const trimmed = p.trim();
1379
+ let canonical;
1380
+ try { canonical = realpathSync(trimmed); } catch (err) {
1381
+ throw new Error(`${label} '${trimmed}' 不存在或无法访问(${err?.code ?? err?.message ?? err})`);
1382
+ }
1383
+ if (!statSync(canonical).isDirectory()) throw new Error(`${label} '${canonical}' 不是目录`);
1384
+ return canonical;
1385
+ }
1386
+
1387
+ /** 工作区体检:逐个实体给出官方 status 判定(ok / missing-dir)与原始记账数。 */
1388
+ async function auditWorkspaces(ctx) {
1389
+ const registry = ctx.workspaceRegistry;
1390
+ const persistence = ctx.sessionPersistence;
1391
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
1392
+ const items = [];
1393
+ for (const entity of registry.list()) {
1394
+ let status = dirExists(entity.path) ? 'ok' : 'missing-dir';
1395
+ try {
1396
+ if (typeof entity.status === 'function') status = await entity.status();
1397
+ } catch { /* 保持 stat 兜底结果 */ }
1398
+ let memberCount = 0;
1399
+ try { memberCount = entity.record.sessionIds.length; } catch { /* ignore */ }
1400
+ items.push({ workspaceId: entity.id, title: entity.title, path: entity.path, status, memberCount });
1401
+ }
1402
+ return { items };
1403
+ }
1404
+
1405
+ //#region 归档会话管理 + 打开文件夹(v0.8)
1406
+
1407
+ /** 归档集合读取:registry 缺成员(老版本宿主)时返回空数组,不抛错。 */
1408
+ function readArchivedIds(registry) {
1409
+ try {
1410
+ const ids = registry.archivedSessionIds;
1411
+ return Array.isArray(ids) ? ids.map(String) : [...ids].map(String);
1412
+ } catch {
1413
+ return [];
1414
+ }
1415
+ }
1416
+
1417
+ /**
1418
+ * 列出已归档会话。标题/cwd 复用 scanSessions(投影缓存标题优先、档案头兜底,
1419
+ * 与侧边栏显示一致);归属取自原始记账(官方归档从不摘记账槽,所以归属工作区仍在);
1420
+ * cwd 匹配到别的工作区的行给出归位建议。
1421
+ */
1422
+ async function listArchivedSessions(ctx) {
1423
+ const registry = ctx.workspaceRegistry;
1424
+ if (!registry) throw new Error('workspace registry unavailable');
1425
+ const archivedIds = new Set(readArchivedIds(registry));
1426
+ if (archivedIds.size === 0) return { items: [], truncated: false };
1427
+ const scan = await scanSessions(ctx);
1428
+ const items = [];
1429
+ for (const it of scan.items) {
1430
+ if (!archivedIds.has(it.sessionId)) continue;
1431
+ const ownerId = (it.ownerWorkspaceIds ?? [])[0] ?? null;
1432
+ const ownerEntity = ownerId ? registry.get(ownerId) : null;
1433
+ const suggestId = it.homeWorkspaceId && (!ownerId || it.homeWorkspaceId !== ownerId) ? it.homeWorkspaceId : null;
1434
+ items.push({
1435
+ sessionId: it.sessionId,
1436
+ title: it.title,
1437
+ cwd: it.cwd,
1438
+ ownerWorkspaceId: ownerId,
1439
+ ownerTitle: ownerEntity?.title ?? null,
1440
+ suggestedWorkspaceId: suggestId,
1441
+ suggestedTitle: suggestId ? it.homeTitle ?? null : null
1442
+ });
1443
+ }
1444
+ items.sort((a, b) => String(a.title).localeCompare(String(b.title), 'zh'));
1445
+ return { items, truncated: scan.truncated };
1446
+ }
1447
+
1448
+ /**
1449
+ * 取消归档:走官方 registry 的持久状态写通道(与官方 archiveSession 同款
1450
+ * enqueueOperation + setState,域 schema 校验兜底),把 id 从 registry 全局归档集移除;
1451
+ * 归档从不摘记账槽,取消后自动回到原工作区原位置。带 targetWorkspaceId 且 ≠ 归属时,
1452
+ * 接着走常规迁移(备份/历史/撤销/防运行中全套保护)。
1453
+ */
1454
+ async function unarchiveSession(ctx, { sessionId, targetWorkspaceId } = {}) {
1455
+ if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('sessionId required');
1456
+ const registry = ctx.workspaceRegistry;
1457
+ if (!registry) throw new Error('workspace registry unavailable');
1458
+ const archivedIds = readArchivedIds(registry);
1459
+ if (!archivedIds.includes(sessionId)) throw new Error(`session '${sessionId}' is not archived`);
1460
+ if (typeof registry.enqueueOperation !== 'function' || typeof registry.setState !== 'function' || typeof registry.requireState !== 'function') {
1461
+ throw new Error('host workspace registry exposes no state mutation API; unarchive is unsupported on this DSH version');
1462
+ }
1463
+ await registry.enqueueOperation(async () => {
1464
+ const state = registry.requireState();
1465
+ await registry.setState({
1466
+ ...state,
1467
+ archivedSessionIds: state.archivedSessionIds.filter((id) => String(id) !== sessionId)
1468
+ });
1469
+ });
1470
+ let moved = null;
1471
+ if (typeof targetWorkspaceId === 'string' && targetWorkspaceId.length > 0) {
1472
+ const owner = registry.list().find((e) => {
1473
+ try { return [...e.record.sessionIds].includes(sessionId); } catch { return false; }
1474
+ });
1475
+ if (!owner || owner.id !== targetWorkspaceId) {
1476
+ moved = await moveSession(ctx, { sessionId, targetWorkspaceId });
1477
+ }
1478
+ }
1479
+ return { unarchived: true, sessionId, moved };
1480
+ }
1481
+
1482
+ /**
1483
+ * 用系统文件管理器打开目录;spawn 可注入(测试用)。不等待、不校验退出码。
1484
+ * Windows 上后台进程直接开窗不会置前(无前台激活权),且 ShellExecute 类方案
1485
+ * (PowerShell Shell.Application.Open / Start-Process)从宿主进程里会静默失效——
1486
+ * 实测唯一可靠链路:explorer.exe 开窗后约 0.8s,经 WScript AppActivate 按标题拉前台。
1487
+ */
1488
+ export function openInFileManager(path, spawnFn = spawn) {
1489
+ if (process.platform === 'win32') {
1490
+ const title = basename(path.replace(/[\\/]+$/, ''));
1491
+ const child = spawnFn(String.raw`C:\Windows\explorer.exe`, [path], { detached: true, stdio: 'ignore' });
1492
+ child?.unref?.();
1493
+ const timer = setTimeout(() => {
1494
+ try {
1495
+ const act = spawnFn('powershell.exe', ['-NoProfile', '-Command', `$ws = New-Object -ComObject WScript.Shell; $null = $ws.AppActivate('${title.replace(/'/g, "''")}')`], { detached: true, stdio: 'ignore' });
1496
+ act?.unref?.();
1497
+ } catch { /* 置前失败只影响前台,不影响开窗本身 */ }
1498
+ }, 800);
1499
+ timer?.unref?.();
1500
+ return 'explorer.exe';
1501
+ }
1502
+ const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
1503
+ const child = spawnFn(command, [path], { detached: true, stdio: 'ignore' });
1504
+ child?.unref?.();
1505
+ return command;
1506
+ }
1507
+
1508
+ /** 用系统文件管理器打开某个已注册工作区的目录(workspaceId 或 path 必须命中注册表,防任意路径打开)。 */
1509
+ async function openWorkspaceFolder(ctx, { workspaceId, path } = {}) {
1510
+ const registry = ctx.workspaceRegistry;
1511
+ if (!registry) throw new Error('workspace registry unavailable');
1512
+ const entities = registry.list();
1513
+ let entity = null;
1514
+ if (typeof workspaceId === 'string' && workspaceId.length > 0) {
1515
+ entity = entities.find((e) => e.id === workspaceId) ?? null;
1516
+ if (!entity && typeof path === 'string' && path.length > 0) {
1517
+ entity = entities.find((e) => { try { return samePath(e.path, path); } catch { return false; } }) ?? null;
1518
+ }
1519
+ } else if (typeof path === 'string' && path.length > 0) {
1520
+ entity = entities.find((e) => { try { return samePath(e.path, path); } catch { return false; } }) ?? null;
1521
+ }
1522
+ if (!entity) throw new Error('target does not belong to any registered workspace');
1523
+ if (!dirExists(entity.path)) throw new Error(`workspace directory does not exist: ${entity.path}`);
1524
+ const command = openInFileManager(entity.path);
1525
+ return { opened: true, workspaceId: entity.id, path: entity.path, command };
1526
+ }
1527
+
1528
+ /**
1529
+ * 工作区原地搬家。fromPath 是失效的旧登记路径,newPath 是磁盘上的新家:
1530
+ * ① 盘点「头部 cwd 指向旧路径的磁盘会话 ∪ 实体原始记账」;
1531
+ * ② 预置注册表三张索引到新路径(防 mutate 失效剪枝清空名单)→ 经统一写入通道换 path;
1532
+ * ③ 逐会话备份+改写+物理搬移+常驻状态清理,单文件失败只影响自己,中断可用
1533
+ * 同参数续跑(实体 path 已是新路径时自动跳过改写,仅清扫残留散件)。
1534
+ */
1535
+ /** 入口封装:工作区锁(try-acquire,占用即 busy)——与 moveSessionEntry 的目标工作区锁同键。 */
1536
+ async function repointWorkspace(ctx, payload = {}) {
1537
+ const workspaceId = validId(payload?.workspaceId, 'workspaceId');
1538
+ const release = acquireLocks(`workspace:${workspaceId}`);
1539
+ try {
1540
+ return await repointWorkspaceLocked(ctx, payload);
1541
+ } finally {
1542
+ release();
1543
+ }
1544
+ }
1545
+
1546
+ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dryRun = false }) {
1547
+ const registry = ctx.workspaceRegistry;
1548
+ const persistence = ctx.sessionPersistence;
1549
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
1550
+
1551
+ const entity = registry.get(workspaceId);
1552
+ if (!entity) throw new Error(`unknown workspace '${workspaceId}'`);
1553
+
1554
+ const targetCanon = canonicalDir(newPath, '新路径');
1555
+ const stalePath = typeof fromPath === 'string' && fromPath.trim().length > 0 ? fromPath.trim() : entity.path;
1556
+
1557
+ // 已经搬过的实体允许携带旧路径进来续跑(resume);否则校验传参一致
1558
+ const resumeMode = samePath(entity.path, targetCanon);
1559
+ if (!resumeMode && !samePath(stalePath, entity.path)) {
1560
+ throw new Error(`workspace '${entity.title}' 目前登记在 '${entity.path}',与传入的失效路径 '${stalePath}' 不一致`);
1561
+ }
1562
+ for (const other of registry.list()) {
1563
+ if (other.id === workspaceId) continue;
1564
+ if (samePath(other.path, targetCanon)) {
1565
+ throw new Error(`新路径已被工作区「${other.title}」占用(${other.path}),不能搬过去`);
1566
+ }
1567
+ }
1568
+
1569
+ // ---- 盘点:磁盘上头部仍指旧路径的会话 ∪ 实体原始记账(含档案损坏者)----
1570
+ const headers = await listPersistenceHeaders(persistence);
1571
+ const byId = new Map();
1572
+ const addAffected = (id, title) => { if (id !== undefined && !byId.has(String(id))) byId.set(String(id), title); };
1573
+ for (const header of headers) {
1574
+ if (typeof header?.cwd !== 'string' || !samePath(header.cwd, stalePath)) continue;
1575
+ addAffected(header.id, typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null);
1576
+ }
1577
+ try { for (const id of entity.record.sessionIds) addAffected(id, registry.headers?.get?.(id)?.title ?? null); } catch { /* 记账不可读时以磁盘扫描为准 */ }
1578
+
1579
+ const affected = [...byId.entries()].map(([sessionId, title]) => ({ sessionId, title }));
1580
+ if (affected.length > REPOINT_MAX_SESSIONS) {
1581
+ throw new Error(`该工作区牵涉 ${affected.length} 个会话,超出单次上限 ${REPOINT_MAX_SESSIONS};请分批处理`);
1582
+ }
1583
+
1584
+ const root = persistence.root;
1585
+ const compression = persistenceCompression(persistence);
1586
+ if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
1587
+
1588
+ if (dryRun) return { dryRun: true, from: stalePath, to: targetCanon, resumeMode, count: affected.length, items: affected };
1589
+
1590
+ // ---- 进行中的会话只跳过「物理搬移」,不跳过索引预置(否则 mutate 剪枝会清掉它们)----
1591
+ const runningIds = new Set(
1592
+ affected.map((it) => it.sessionId)
1593
+ .filter((id) => peekService(ctx, 'agents')?.get?.(id)?.status === 'running')
1594
+ );
1595
+
1596
+ // ---- 预置索引(全部受影响会话)→ mutate 换 path(失败整体中止:文件一个都还没动)----
1597
+ let pathUpdated = false;
1598
+ if (!resumeMode) {
1599
+ if (typeof entity.mutate !== 'function') {
1600
+ throw new Error('宿主的工作区实体缺少统一写入通道 mutate,无法安全重定向(可能是版本变化);尚未改动任何文件');
1601
+ }
1602
+ const priorHeaders = new Map();
1603
+ const priorPaths = new Map();
1604
+ const priorInvalid = new Map();
1605
+ for (const { sessionId, title } of affected) {
1606
+ try {
1607
+ priorHeaders.set(sessionId, registry.headers?.get?.(sessionId));
1608
+ priorPaths.set(sessionId, registry.sessionPaths?.get?.(sessionId));
1609
+ priorInvalid.set(sessionId, registry.invalidSessionPaths?.get?.(sessionId));
1610
+ registry.headers?.set?.(sessionId, { ...(registry.headers?.get?.(sessionId) ?? {}), id: sessionId, cwd: targetCanon, ...(title ? { title } : {}) });
1611
+ registry.invalidSessionPaths?.delete?.(sessionId);
1612
+ registry.sessionPaths?.set?.(sessionId, targetCanon);
1613
+ } catch (err) {
1614
+ ctx.logger?.warn?.(`workspace-mover: index pre-seed failed for ${sessionId}: ${err?.message ?? err}`);
1615
+ }
1616
+ }
1617
+ try {
1618
+ await entity.mutate((record) => ({
1619
+ ...record,
1620
+ path: targetCanon,
1621
+ // 标题跟随文件夹改名:仅当标题仍是旧文件夹名(官方 create 的默认值)时同步;
1622
+ // 用户自定义过的标题原样保留
1623
+ ...(typeof record.title === 'string' && record.title.length > 0
1624
+ && record.title.toLowerCase() === basename(stalePath).toLowerCase()
1625
+ ? { title: basename(targetCanon) }
1626
+ : {})
1627
+ }));
1628
+ pathUpdated = true;
1629
+ // 剪枝保护网:mutate 若因索引预置失败清掉了某些成员,立即按官方
1630
+ // attach 语义补回(头部已被预置成新路径,校验必过),失败则记入 skipped。
1631
+ try {
1632
+ for (const { sessionId } of affected) {
1633
+ let accounted = false;
1634
+ try { accounted = entity.record.sessionIds.includes(sessionId); } catch { /* ignore */ }
1635
+ if (!accounted) await entity.attachSession(sessionId);
1636
+ }
1637
+ } catch (err) {
1638
+ ctx.logger?.warn?.(`workspace-mover: post-mutate reattach failed: ${err?.message ?? err}`);
1639
+ }
1640
+ } catch (err) {
1641
+ try {
1642
+ for (const [id, v] of priorHeaders) { if (v === undefined) registry.headers?.delete?.(id); else registry.headers?.set?.(id, v); }
1643
+ for (const [id, v] of priorPaths) { if (v === undefined) registry.sessionPaths?.delete?.(id); else registry.sessionPaths?.set?.(id, v); }
1644
+ for (const [id, v] of priorInvalid) { if (v === undefined) registry.invalidSessionPaths?.delete?.(id); else registry.invalidSessionPaths?.set?.(id, v); }
1645
+ } catch (restoreErr) {
1646
+ ctx.logger?.warn?.(`workspace-mover: index restore after abort failed (restart recommended): ${restoreErr?.message ?? restoreErr}`);
1647
+ }
1648
+ throw new Error(`重定向失败,已中止(未改动任何文件):${err?.message ?? err}`);
1649
+ }
1650
+ }
1651
+
1652
+ // ---- 逐会话:备份 → 改写头帧 → 物理搬移 → 清理常驻写入状态 ----
1653
+ const moved = [];
1654
+ const skipped = [...runningIds].map((sessionId) => ({
1655
+ sessionId,
1656
+ title: affected.find((it) => it.sessionId === sessionId)?.title ?? null,
1657
+ error: '会话正在进行中,已跳过'
1658
+ }));
1659
+ for (const { sessionId, title } of affected) {
1660
+ if (runningIds.has(sessionId)) continue; // 已在 skipped 中
1661
+ try {
1662
+ assertNotRunning(ctx, sessionId);
1663
+ const meta = headers.find((h) => String(h?.id) === String(sessionId));
1664
+ const srcArtifact = artifactPath(root, typeof meta?.cwd === 'string' ? meta.cwd : stalePath, sessionId, compression);
1665
+ if (!existsSync(srcArtifact)) throw new Error(`档案缺失:${srcArtifact}`);
1666
+ const dstArtifact = artifactPath(root, targetCanon, sessionId, compression);
1667
+ if (existsSync(dstArtifact)) throw new Error(`目标位置已有同名档案:${dstArtifact}`);
1668
+
1669
+ const original = readFileSync(srcArtifact);
1670
+ stashBackup(String(sessionId), original);
1671
+ atomicWrite(srcArtifact, rewriteHeaderCwd(original, targetCanon, compression));
1672
+ try {
1673
+ moveDir(dirname(srcArtifact), dirname(dstArtifact));
1674
+ // 迁移后一致性校验(同 moveSession),失败视同搬移失败回退源字节
1675
+ try {
1676
+ verifyRelocatedArtifact(dstArtifact, sessionId, targetCanon, compression);
1677
+ } catch (verifyErr) {
1678
+ throw new Error(`物理搬移校验失败:${verifyErr?.message ?? verifyErr}`);
1679
+ }
1680
+ } catch (err) {
1681
+ atomicWrite(srcArtifact, original);
1682
+ throw new Error(`物理搬移失败:${err?.message ?? err}`);
1683
+ }
1684
+ try {
1685
+ const srcProject = dirname(dirname(srcArtifact));
1686
+ if (existsSync(srcProject) && readdirSync(srcProject).length === 0) rmSync(srcProject, { recursive: true });
1687
+ } catch { /* ignore */ }
1688
+
1689
+ let restartHint = null;
1690
+ try {
1691
+ if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined) {
1692
+ persistence.coordinator?.states?.delete?.(String(sessionId));
1693
+ }
1694
+ } catch (err) {
1695
+ restartHint = '本次会话需重启 Harness 后才会完全写入新位置';
1696
+ ctx.logger?.warn?.(`workspace-mover: persistence state cleanup failed for ${sessionId}: ${err?.message ?? err}`);
1697
+ }
1698
+ // 旧路径散件(从未记账)落位后补挂账;已记账成员为幂等跳过
1699
+ try {
1700
+ if (!entity.record.sessionIds.includes(sessionId)) await entity.attachSession(sessionId);
1701
+ } catch (err) {
1702
+ throw new Error(`迁移完成但补挂账失败(可在会话修复面板重试):${err?.message ?? err}`);
1703
+ }
1704
+ // 进程内热修:常驻头换新 / 投影缓存身份对齐(@ 缓存在批末统一失效)
1705
+ retargetLiveHeader(ctx, sessionId, targetCanon);
1706
+ await realignProjectionIdentity(ctx, sessionId, targetCanon);
1707
+ moved.push({ sessionId, title, restartHint });
1708
+ } catch (err) {
1709
+ skipped.push({ sessionId, title, error: err?.message ?? String(err) });
1710
+ }
1711
+ }
1712
+
1713
+ // 批末统一失效以旧路径为根的 @ 文件引用搜索缓存
1714
+ invalidateFileReferenceSearches(ctx, stalePath);
1715
+
1716
+ return { dryRun: false, from: stalePath, to: targetCanon, resumeMode, pathUpdated, movedCount: moved.length, moved, skipped };
1717
+ }
1718
+ //#endregion
1719
+
1720
+ //#region 会话回收站 + 备份管理(v0.9)
1721
+
1722
+ function pluginDataDir() {
1723
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'workspace-mover');
1724
+ }
1725
+
1726
+ const RECOVERY_FILE = 'recovery.json';
1727
+
1728
+ /** 回滚也失败时的兜底:写持久化恢复记录,供体检面板提示"需要人工恢复"。fail-soft。 */
1729
+ function recordRecovery(ctx, entry) {
1730
+ try {
1731
+ const p = join(pluginDataDir(), RECOVERY_FILE);
1732
+ let list = [];
1733
+ try {
1734
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
1735
+ if (Array.isArray(parsed?.records)) list = parsed.records;
1736
+ } catch { /* 首次或损坏:重建 */ }
1737
+ list.unshift({ ...entry, id: `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, createdAt: new Date().toISOString() });
1738
+ atomicWrite(p, Buffer.from(JSON.stringify({ version: 1, records: list.slice(0, 50) }, null, 2) + '\n', 'utf8'));
1739
+ ctx.logger?.warn?.(`workspace-mover: RECOVERY RECORD written (${entry.kind}/${entry.phase}) for ${entry.sessionId ?? entry.workspaceId ?? '?'}`);
1740
+ } catch (err) {
1741
+ ctx.logger?.warn?.(`workspace-mover: recovery record write failed: ${err?.message ?? err}`);
1742
+ }
1743
+ }
1744
+
1745
+ function recoveryCount() {
1746
+ try {
1747
+ const parsed = JSON.parse(readFileSync(join(pluginDataDir(), RECOVERY_FILE), 'utf8'));
1748
+ return Array.isArray(parsed?.records) ? parsed.records.length : 0;
1749
+ } catch {
1750
+ return 0;
1751
+ }
1752
+ }
1753
+
1754
+ function recycleDir() {
1755
+ return join(pluginDataDir(), 'recycle');
1756
+ }
1757
+
1758
+ const TRASH_MANIFEST = 'wsm-manifest.json';
1759
+
1760
+ function dirSizeSync(dir) {
1761
+ let total = 0;
1762
+ let names = [];
1763
+ try { names = readdirSync(dir); } catch { return 0; } // 目录缺失(如已还原的陈旧回收条目)按 0 计
1764
+ for (const f of names) {
1765
+ const p = join(dir, f);
1766
+ let st;
1767
+ try { st = statSync(p); } catch { continue; }
1768
+ if (st.isDirectory()) total += dirSizeSync(p);
1769
+ else if (st.isFile()) total += st.size;
1770
+ }
1771
+ return total;
1772
+ }
1773
+
1774
+ /** 定位会话档案:注册表索引 → 记账方路径 → persistence 头部,全部落空则抛错。 */
1775
+ async function locateArtifact(ctx, sessionId) {
1776
+ const registry = ctx.workspaceRegistry;
1777
+ const persistence = ctx.sessionPersistence;
1778
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
1779
+ const root = persistence.root;
1780
+ const compression = persistenceCompression(persistence);
1781
+ const candidates = [];
1782
+ try { const p = registry.sessionPaths?.get?.(sessionId); if (typeof p === 'string' && p) candidates.push(p); } catch { /* ignore */ }
1783
+ for (const e of registry.list()) {
1784
+ try { if ([...e.record.sessionIds].includes(sessionId)) candidates.push(e.path); } catch { /* ignore */ }
1785
+ }
1786
+ try { const h = registry.headers?.get?.(sessionId); if (typeof h?.cwd === 'string') candidates.push(h.cwd); } catch { /* ignore */ }
1787
+ try {
1788
+ for (const meta of await listPersistenceHeaders(persistence)) {
1789
+ if (String(meta?.id) === String(sessionId) && typeof meta?.cwd === 'string') candidates.push(meta.cwd);
1790
+ }
1791
+ } catch { /* ignore */ }
1792
+ for (const cwd of candidates) {
1793
+ const artifact = artifactPath(root, cwd, sessionId, compression);
1794
+ if (existsSync(artifact)) return { artifact, cwd };
1795
+ }
1796
+ throw new Error(`session archive not found for '${sessionId}'`);
1797
+ }
1798
+
1799
+ /** 投影缓存读取/删除/回写:老宿主缺 API 时全部降级为 no-op(官方语义:陈旧条目无害)。 */
1800
+ function projectionTable(ctx) {
1801
+ try { return peekService(ctx, 'sessionProjectionCache')?.table ?? null; } catch { return null; }
1802
+ }
1803
+
1804
+ function captureProjection(ctx, sessionId) {
1805
+ try {
1806
+ const table = projectionTable(ctx);
1807
+ if (typeof table?.get !== 'function') return null;
1808
+ return table.get(String(sessionId)) ?? null;
1809
+ } catch { return null; }
1810
+ }
1811
+
1812
+ async function dropProjection(ctx, sessionId) {
1813
+ try {
1814
+ const table = projectionTable(ctx);
1815
+ if (typeof table?.delete !== 'function') return;
1816
+ await table.delete(String(sessionId));
1817
+ } catch (err) {
1818
+ ctx.logger?.warn?.(`workspace-mover: projection entry removal failed for ${sessionId} (stale entry is harmless): ${err?.message ?? err}`);
1819
+ }
1820
+ }
1821
+
1822
+ async function writeProjection(ctx, sessionId, record, cwd) {
1823
+ try {
1824
+ const table = projectionTable(ctx);
1825
+ if (!table || typeof table.put !== 'function' || !record?.identity || !record?.rows) return;
1826
+ // 注意:table 是底层 KvTable,put(key, value) 两参——value 必须是完整 {identity, rows};
1827
+ // 服务层的 put(id, identity, rows) 三参签名在这里不可用(会整条写坏,域重开时崩)。
1828
+ await table.put(String(sessionId), { identity: { ...record.identity, cwd }, rows: record.rows });
1829
+ } catch (err) {
1830
+ ctx.logger?.warn?.(`workspace-mover: projection restore failed for ${sessionId} (title will rebuild lazily): ${err?.message ?? err}`);
1831
+ }
1832
+ }
1833
+
1834
+ /** 归档集变更(fail-soft):宿主缺持久写通道时跳过并返回 false,文件操作照常。 */
1835
+ async function modifyArchiveSet(ctx, sessionId, add) {
1836
+ const registry = ctx.workspaceRegistry;
1837
+ if (!registry || typeof registry.enqueueOperation !== 'function' || typeof registry.setState !== 'function' || typeof registry.requireState !== 'function') {
1838
+ ctx.logger?.warn?.(`workspace-mover: host registry exposes no state mutation API; archive set change skipped for ${sessionId}`);
1839
+ return false;
1840
+ }
1841
+ try {
1842
+ await registry.enqueueOperation(async () => {
1843
+ const state = registry.requireState();
1844
+ const ids = [...(state.archivedSessionIds ?? [])].map(String);
1845
+ const next = add
1846
+ ? (ids.includes(sessionId) ? ids : [...ids, sessionId])
1847
+ : ids.filter((id) => id !== sessionId);
1848
+ await registry.setState({ ...state, archivedSessionIds: next });
1849
+ });
1850
+ return true;
1851
+ } catch (err) {
1852
+ ctx.logger?.warn?.(`workspace-mover: archive set change failed for ${sessionId}: ${err?.message ?? err}`);
1853
+ return false;
1854
+ }
1855
+ }
1856
+
1857
+ /** 非空字符串 id 校验(≤300 字符)。 */
1858
+ function validId(value, name = 'id') {
1859
+ if (typeof value !== 'string' || value.length === 0 || value.length > 300) {
1860
+ throw new Error(`${name} must be a non-empty string of at most 300 characters`);
1861
+ }
1862
+ return value;
1863
+ }
1864
+
1865
+ /** 扫描所有记账该会话的实体(只读,不改动)。 */
1866
+ function listOwners(registry, sessionId) {
1867
+ return registry.list().filter((e) => {
1868
+ try { return [...e.record.sessionIds].includes(sessionId); } catch { return false; }
1869
+ });
1870
+ }
1871
+
1872
+ /** 从所有记账方摘除会话(moveSession 的 owners 扫描同款),返回被摘实体。 */
1873
+ async function detachEverywhere(ctx, sessionId) {
1874
+ const owners = listOwners(ctx.workspaceRegistry, sessionId);
1875
+ for (const entity of owners) {
1876
+ try { await entity.detachSession(sessionId); } catch (err) {
1877
+ ctx.logger?.warn?.(`workspace-mover: detach failed for ${sessionId} on ${entity.id}: ${err?.message ?? err}`);
1878
+ }
1879
+ }
1880
+ return owners;
1881
+ }
1882
+
1883
+ /** 清空注册表三张内存索引(moveSession 收尾同款)。 */
1884
+ function forgetSessionIndexes(registry, sessionId) {
1885
+ try { registry.headers?.delete?.(sessionId); } catch { /* ignore */ }
1886
+ try { registry.sessionPaths?.delete?.(sessionId); } catch { /* ignore */ }
1887
+ try { registry.invalidSessionPaths?.delete?.(sessionId); } catch { /* ignore */ }
1888
+ }
1889
+
1890
+ /** 删除会话 → 回收站:拒绝常驻内存会话,四件套清理(文件/记账/投影/索引),manifest 记录全部还原信息。 */
1891
+ /**
1892
+ * 删除会话 → 回收站。顺序刻意"物理移动先行":
1893
+ * ① 移动目录到回收站(失败 = 零副作用,记账/索引/投影/归档原样);
1894
+ * ② manifest 落盘(失败 = 原样搬回,同样零副作用);
1895
+ * ③ 记账/索引/归档/投影清理(此后任何单项失败只降级为 warn——文件已安全入站,
1896
+ * 最坏情况是可见的幽灵记账,可从回收站还原或用救援面板修复)。
1897
+ * moveImpl / writeManifestImpl 可注入(测试模拟移动与落盘失败)。
1898
+ */
1899
+ /** 入口封装:会话锁(try-acquire,占用即 busy)。 */
1900
+ async function deleteSessionToTrash(ctx, payload = {}, hooks = {}) {
1901
+ const sessionId = validId(payload?.sessionId, 'sessionId');
1902
+ const release = acquireLocks(`session:${sessionId}`);
1903
+ try {
1904
+ return await deleteSessionToTrashLocked(ctx, sessionId, hooks);
1905
+ } finally {
1906
+ release();
1907
+ }
1908
+ }
1909
+
1910
+ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
1911
+ const { moveImpl = moveDir, writeManifestImpl = (path, manifest) => writeFileSync(path, JSON.stringify(manifest, null, 2)) } = hooks;
1912
+ const registry = ctx.workspaceRegistry;
1913
+ const persistence = ctx.sessionPersistence;
1914
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
1915
+ if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined) {
1916
+ throw new Error('session is resident in memory (it may remain resident even when not focused; archiving does not unload it); close it or restart the harness before deleting');
1917
+ }
1918
+ assertNotRunning(ctx, sessionId);
1919
+ const compression = persistenceCompression(persistence);
1920
+ const { artifact, cwd: artifactCwd } = await locateArtifact(ctx, sessionId);
1921
+ const header = readHeader(readFileSync(artifact), compression);
1922
+ const archived = readArchivedIds(registry).includes(sessionId);
1923
+ const projection = captureProjection(ctx, sessionId);
1924
+ const owners = listOwners(registry, sessionId);
1925
+ const entryDir = join(recycleDir(), `${Date.now()}-${String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_')}`);
1926
+ mkdirSync(entryDir, { recursive: true });
1927
+ // ① 物理移动先行
1928
+ let how = 'rename';
1929
+ try {
1930
+ how = moveImpl(dirname(artifact), join(entryDir, 'session'));
1931
+ } catch (err) {
1932
+ try { rmSync(entryDir, { recursive: true, force: true }); } catch { /* ignore */ }
1933
+ throw new Error(`move to recycle failed (nothing was changed): ${err?.message ?? err}`);
1934
+ }
1935
+ const manifest = {
1936
+ version: 1,
1937
+ sessionId: String(sessionId),
1938
+ title: typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : null,
1939
+ cwd: artifactCwd ?? header?.cwd ?? null,
1940
+ ownerWorkspaceId: owners[0]?.id ?? null,
1941
+ ownerTitle: owners[0]?.title ?? null,
1942
+ archived,
1943
+ deletedAt: new Date().toISOString(),
1944
+ projection
1945
+ };
1946
+ // ② manifest 落盘;失败原样搬回,回收站不留不可见条目
1947
+ try {
1948
+ writeManifestImpl(join(entryDir, TRASH_MANIFEST), manifest);
1949
+ } catch (err) {
1950
+ try { moveDir(join(entryDir, 'session'), dirname(artifact)); } catch (backErr) {
1951
+ recordRecovery(ctx, {
1952
+ kind: 'delete', phase: 'manifest-rollback', sessionId: String(sessionId),
1953
+ source: dirname(artifact), target: entryDir, backup: null,
1954
+ lastError: `manifest write failed (${err?.message ?? err}) AND move-back failed (${backErr?.message ?? backErr}) — files remain under ${entryDir}`
1955
+ });
1956
+ ctx.logger?.warn?.(`workspace-mover: manifest write failed AND move-back failed for ${sessionId}: ${backErr?.message ?? backErr} — files remain under ${entryDir}`);
1957
+ }
1958
+ try { rmSync(entryDir, { recursive: true, force: true }); } catch { /* ignore */ }
1959
+ throw new Error(`manifest write failed, session moved back: ${err?.message ?? err}`);
1960
+ }
1961
+ // ③ 四件套清理(文件已安全入站;单项失败降级为 warn)
1962
+ await detachEverywhere(ctx, sessionId);
1963
+ forgetSessionIndexes(registry, sessionId);
1964
+ if (archived) await modifyArchiveSet(ctx, sessionId, false);
1965
+ await dropProjection(ctx, String(sessionId));
1966
+ return { deleted: true, sessionId: String(sessionId), title: manifest.title, how };
1967
+ }
1968
+
1969
+ function readTrashEntry(entryDir) {
1970
+ const manifestPath = join(entryDir, TRASH_MANIFEST);
1971
+ if (!existsSync(manifestPath)) return null;
1972
+ try { return JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { return null; }
1973
+ }
1974
+
1975
+ async function listTrash(ctx) {
1976
+ const dir = recycleDir();
1977
+ if (!dirExists(dir)) return { items: [], dir };
1978
+ const items = [];
1979
+ for (const entry of readdirSync(dir)) {
1980
+ const entryDir = join(dir, entry);
1981
+ if (!dirExists(entryDir)) continue;
1982
+ const manifest = readTrashEntry(entryDir);
1983
+ if (!manifest?.sessionId) continue;
1984
+ items.push({
1985
+ entry,
1986
+ sessionId: String(manifest.sessionId),
1987
+ title: manifest.title ?? null,
1988
+ cwd: manifest.cwd ?? null,
1989
+ ownerWorkspaceId: manifest.ownerWorkspaceId ?? null,
1990
+ ownerTitle: manifest.ownerTitle ?? null,
1991
+ archived: Boolean(manifest.archived),
1992
+ deletedAt: manifest.deletedAt ?? null,
1993
+ sizeBytes: dirSizeSync(join(entryDir, 'session'))
1994
+ });
1995
+ }
1996
+ items.sort((a, b) => (a.deletedAt < b.deletedAt ? 1 : -1));
1997
+ return { items, dir };
1998
+ }
1999
+
2000
+ /** 从回收站还原:默认回原路径;给 targetWorkspaceId 时先移回原位再走完整 moveSession 管线。 */
2001
+ /** 入口封装:会话锁(try-acquire,占用即 busy)。 */
2002
+ async function restoreFromTrash(ctx, payload = {}) {
2003
+ const sessionId = validId(payload?.sessionId, 'sessionId');
2004
+ const release = acquireLocks(`session:${sessionId}`);
2005
+ try {
2006
+ return await restoreFromTrashLocked(ctx, sessionId, payload?.targetWorkspaceId);
2007
+ } finally {
2008
+ release();
2009
+ }
2010
+ }
2011
+
2012
+ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
2013
+ const registry = ctx.workspaceRegistry;
2014
+ const persistence = ctx.sessionPersistence;
2015
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
2016
+ const dir = recycleDir();
2017
+ if (!dirExists(dir)) throw new Error(`no trashed session '${sessionId}'`);
2018
+ let entryDir = null;
2019
+ let manifest = null;
2020
+ for (const entry of readdirSync(dir)) {
2021
+ const candidate = join(dir, entry);
2022
+ const m = readTrashEntry(candidate);
2023
+ if (m && String(m.sessionId) === String(sessionId)) { entryDir = candidate; manifest = m; break; }
2024
+ }
2025
+ if (!entryDir) throw new Error(`no trashed session '${sessionId}'`);
2026
+ const compression = persistenceCompression(persistence);
2027
+ const sessionDir = join(entryDir, 'session');
2028
+ const foundInTrash = findHighestArtifact(sessionDir, compression);
2029
+ if (!foundInTrash) throw new Error(`trash entry has no session artifact: ${entryDir}`);
2030
+ const artifactInTrash = foundInTrash.path;
2031
+ if (!existsSync(artifactInTrash)) throw new Error(`trashed archive missing for '${sessionId}'`);
2032
+ if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined) {
2033
+ throw new Error('a live session with this id already exists; restart the harness before restoring');
2034
+ }
2035
+ let existingPath = null;
2036
+ try { existingPath = registry.sessionPaths?.get?.(sessionId); } catch { /* ignore */ }
2037
+ if (existingPath) throw new Error('session id already exists in the workspace registry');
2038
+ const header = readHeader(readFileSync(artifactInTrash), compression);
2039
+ const originalCwd = typeof header?.cwd === 'string' ? header.cwd : manifest.cwd;
2040
+ if (typeof originalCwd !== 'string' || originalCwd.length === 0) throw new Error('trashed manifest carries no original path');
2041
+ const originalArtifact = artifactPath(persistence.root, originalCwd, sessionId, compression);
2042
+ let destPath = originalCwd;
2043
+ let targetEntity = registry.list().find((e) => { try { return samePath(e.path, originalCwd); } catch { return false; } }) ?? null;
2044
+ if (typeof targetWorkspaceId === 'string' && targetWorkspaceId.length > 0) {
2045
+ targetEntity = registry.get(targetWorkspaceId);
2046
+ if (!targetEntity) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
2047
+ destPath = targetEntity.path;
2048
+ }
2049
+ if (existsSync(originalArtifact)) {
2050
+ if (!targetEntity || samePath(targetEntity.path, originalCwd)) {
2051
+ throw new Error(`original location is occupied by an archive with the same id: ${originalArtifact}; pass targetWorkspaceId to restore elsewhere`);
2052
+ }
2053
+ } else if (!targetEntity) {
2054
+ throw new Error(`original location '${originalCwd}' has no registered workspace; pass targetWorkspaceId to restore into one`);
2055
+ }
2056
+ // 先移回原位(头 cwd 与原位一致,attach 校验天然通过)
2057
+ const bytes = readFileSync(artifactInTrash);
2058
+ moveDir(sessionDir, dirname(originalArtifact));
2059
+ let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, moved: null };
2060
+ try {
2061
+ if (targetEntity && !samePath(originalCwd, destPath)) {
2062
+ // 原位只是中转:直接走现有 moveSession 全管线(备份/改写/热修/记账全套)
2063
+ restored.moved = await moveSession(ctx, { sessionId, targetWorkspaceId: targetEntity.id, sessionTitle: manifest.title ?? undefined });
2064
+ restored.cwd = restored.moved?.to?.cwd ?? destPath;
2065
+ } else if (targetEntity) {
2066
+ await targetEntity.attachSession(sessionId);
2067
+ }
2068
+ await writeProjection(ctx, String(sessionId), manifest.projection, restored.cwd);
2069
+ if (manifest.archived) await modifyArchiveSet(ctx, String(sessionId), true);
2070
+ } catch (err) {
2071
+ ctx.logger?.warn?.(`workspace-mover: restore of ${sessionId} landed unaccounted at ${restored.cwd}: ${err?.message ?? err}`);
2072
+ restored.warning = 'restored but not accounted; use the rescue panel to file it into a group';
2073
+ }
2074
+ try { rmSync(entryDir, { recursive: true, force: true }); } catch { /* ignore */ }
2075
+ return restored;
2076
+ }
2077
+
2078
+ /** 彻底删除:rm 回收站条目。all=true 清空。 */
2079
+ async function purgeTrash(ctx, { sessionId, all } = {}) {
2080
+ const dir = recycleDir();
2081
+ if (!dirExists(dir)) return { purged: 0 };
2082
+ let purged = 0;
2083
+ for (const entry of readdirSync(dir)) {
2084
+ const entryDir = join(dir, entry);
2085
+ if (!dirExists(entryDir)) continue;
2086
+ const manifest = readTrashEntry(entryDir);
2087
+ if (all || (typeof sessionId === 'string' && manifest?.sessionId === String(sessionId))) {
2088
+ rmSync(entryDir, { recursive: true, force: true });
2089
+ purged++;
2090
+ }
2091
+ }
2092
+ return { purged };
2093
+ }
2094
+
2095
+ /** 备份聚合清单:按会话分组(文件名 `${id}.${ts}.zstd`),附投影标题与总占用。 */
2096
+ async function listBackups(ctx) {
2097
+ const dir = backupDir();
2098
+ if (!dirExists(dir)) return { items: [], totalBytes: 0, dir };
2099
+ const titles = readProjectionTitles();
2100
+ const groups = new Map();
2101
+ for (const name of readdirSync(dir)) {
2102
+ const m = /^(.+)\.(\d+)\.zstd$/.exec(name);
2103
+ if (!m) continue;
2104
+ const [, id, ts] = m;
2105
+ let sizeBytes = 0;
2106
+ try { sizeBytes = statSync(join(dir, name)).size; } catch { continue; }
2107
+ const g = groups.get(id) ?? { sessionId: id, title: titles.get(id) ?? null, count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
2108
+ g.count++;
2109
+ g.totalBytes += sizeBytes;
2110
+ g.oldest = Math.min(g.oldest, Number(ts));
2111
+ g.newest = Math.max(g.newest, Number(ts));
2112
+ g.files.push({ name, ts: Number(ts), sizeBytes });
2113
+ groups.set(id, g);
2114
+ }
2115
+ const items = [...groups.values()].map((g) => ({
2116
+ ...g,
2117
+ files: g.files.sort((x, y) => y.ts - x.ts).map(({ name, ts, sizeBytes }) => ({ name, ts, sizeBytes }))
2118
+ }));
2119
+ items.sort((a, b) => b.newest - a.newest);
2120
+ return { items, totalBytes: items.reduce((sum, it) => sum + it.totalBytes, 0), dir };
2121
+ }
2122
+
2123
+ /** 从备份恢复会话:默认回备份里的原 cwd;占用/缺分组时给 targetWorkspaceId。回读校验。 */
2124
+ /** 入口封装:会话锁(try-acquire,占用即 busy)。 */
2125
+ async function restoreBackup(ctx, payload = {}) {
2126
+ const sessionId = validId(payload?.sessionId, 'sessionId');
2127
+ const release = acquireLocks(`session:${sessionId}`);
2128
+ try {
2129
+ return await restoreBackupLocked(ctx, sessionId, payload?.fileName, payload?.targetWorkspaceId);
2130
+ } finally {
2131
+ release();
2132
+ }
2133
+ }
2134
+
2135
+ async function restoreBackupLocked(ctx, sessionId, fileName, targetWorkspaceId) {
2136
+ const registry = ctx.workspaceRegistry;
2137
+ const persistence = ctx.sessionPersistence;
2138
+ if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
2139
+ const dir = backupDir();
2140
+ if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined) {
2141
+ throw new Error('a live session with this id exists; restart the harness before restoring');
2142
+ }
2143
+ let existingPath = null;
2144
+ try { existingPath = registry.sessionPaths?.get?.(sessionId); } catch { /* ignore */ }
2145
+ if (existingPath) throw new Error('session id already exists in the workspace registry');
2146
+ let path2 = null;
2147
+ if (typeof fileName === 'string' && fileName.length > 0) {
2148
+ if (!fileName.startsWith(`${sessionId}.`)) throw new Error('backup file does not belong to the given session');
2149
+ path2 = join(dir, basename(fileName));
2150
+ } else {
2151
+ const candidates = dirExists(dir)
2152
+ ? readdirSync(dir).filter((f) => f.startsWith(`${sessionId}.`)).sort()
2153
+ : [];
2154
+ if (candidates.length === 0) throw new Error(`no backups found for '${sessionId}'`);
2155
+ path2 = join(dir, candidates[candidates.length - 1]);
2156
+ }
2157
+ if (!existsSync(path2)) throw new Error(`backup file not found: ${path2}`);
2158
+ const compression = persistenceCompression(persistence);
2159
+ const bytes = readFileSync(path2);
2160
+ const header = readHeader(bytes, compression);
2161
+ if (String(header?.id) !== String(sessionId)) throw new Error(`backup header id mismatch: ${header?.id}`);
2162
+ const backupCwd = typeof header?.cwd === 'string' ? header.cwd : null;
2163
+ if (!backupCwd) throw new Error('backup header carries no cwd');
2164
+ let destCwd = backupCwd;
2165
+ let targetEntity = registry.list().find((e) => { try { return samePath(e.path, backupCwd); } catch { return false; } }) ?? null;
2166
+ if (typeof targetWorkspaceId === 'string' && targetWorkspaceId.length > 0) {
2167
+ targetEntity = registry.get(targetWorkspaceId);
2168
+ if (!targetEntity) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
2169
+ destCwd = targetEntity.path;
2170
+ }
2171
+ if (!targetEntity) throw new Error(`backup cwd '${backupCwd}' has no registered workspace; pass targetWorkspaceId to restore into one`);
2172
+ const dstDir = sessionDir(persistence.root, destCwd, sessionId);
2173
+ if (findDestinationArtifact(dstDir, compression)) throw new Error(`destination artifact already exists under: ${dstDir}`);
2174
+ const dstArtifact = join(dstDir, preferredArtifactName(compression));
2175
+ const final = samePath(backupCwd, destCwd) ? bytes : rewriteHeaderCwd(bytes, destCwd, compression);
2176
+ let created = false;
2177
+ try {
2178
+ mkdirSync(dstDir, { recursive: true });
2179
+ atomicWrite(dstArtifact, final);
2180
+ created = existsSync(dstArtifact);
2181
+ // 回读校验:id 与 cwd 双确认后才挂账
2182
+ const verify = readHeader(readFileSync(dstArtifact), compression);
2183
+ if (String(verify?.id) !== String(sessionId) || !verify?.cwd || !samePath(verify.cwd, destCwd)) {
2184
+ throw new Error('restore verification failed (header round-trip mismatch)');
2185
+ }
2186
+ await targetEntity.attachSession(sessionId);
2187
+ } catch (err) {
2188
+ if (created) { try { rmSync(dirname(dstArtifact), { recursive: true, force: true }); } catch { /* ignore */ } }
2189
+ throw err;
2190
+ }
2191
+ return { restored: true, sessionId: String(sessionId), cwd: destCwd, workspaceId: targetEntity.id, fromBackup: basename(path2) };
2192
+ }
2193
+
2194
+ /** 删除备份:fileName 指定单份;省略则清空该会话全部备份。 */
2195
+ async function deleteBackup(ctx, { sessionId, fileName } = {}) {
2196
+ if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('sessionId required');
2197
+ const dir = backupDir();
2198
+ if (!dirExists(dir)) return { deleted: 0 };
2199
+ let targets = [];
2200
+ if (typeof fileName === 'string' && fileName.length > 0) {
2201
+ if (!fileName.startsWith(`${sessionId}.`)) throw new Error('backup file does not belong to the given session');
2202
+ targets = [basename(fileName)];
2203
+ } else {
2204
+ targets = readdirSync(dir).filter((f) => f.startsWith(`${sessionId}.`));
2205
+ }
2206
+ let deleted = 0;
2207
+ for (const name of targets) {
2208
+ try { rmSync(join(dir, name), { force: true }); deleted++; } catch { /* ignore */ }
2209
+ }
2210
+ return { deleted };
2211
+ }
2212
+
2213
+ /**
2214
+ * 一键修复:扫描 → 分类 → 逐项修复可自动修复项。
2215
+ * 挂错 → 归位;未记账且有路径匹配分组 → 补账;孤儿/损坏/无匹配分组的未记账 → 跳过并说明原因。
2216
+ * 全部只动记账(home/attach 不搬文件),复用 repairSessions 的逐项隔离。
2217
+ */
2218
+ async function repairAllSessions(ctx) {
2219
+ const scan = await scanSessions(ctx);
2220
+ const actions = [];
2221
+ const skipped = [];
2222
+ for (const it of scan.items) {
2223
+ if (it.status === 'misfiled') {
2224
+ actions.push({ sessionId: it.sessionId, kind: 'home' });
2225
+ } else if (it.status === 'unregistered' && it.targetWorkspaceId) {
2226
+ actions.push({ sessionId: it.sessionId, kind: 'attach' });
2227
+ } else if (it.status === 'unregistered' || it.status === 'orphaned') {
2228
+ skipped.push({ sessionId: it.sessionId, title: it.title, status: it.status, reason: 'needs-target-workspace' });
2229
+ } else if (it.status === 'unreadable') {
2230
+ skipped.push({ sessionId: it.sessionId, title: it.title, status: it.status, reason: 'unreadable' });
2231
+ }
2232
+ // ok:无需处理
2233
+ }
2234
+ const { results } = actions.length > 0 ? await repairSessions(ctx, actions) : { results: [] };
2235
+ const fixed = results.filter((r) => r.ok);
2236
+ const failed = results.filter((r) => !r.ok).map((r) => ({ sessionId: r.sessionId ?? null, error: r.error ?? 'unknown' }));
2237
+ return {
2238
+ fixedCount: fixed.length,
2239
+ failedCount: failed.length,
2240
+ skippedCount: skipped.length,
2241
+ fixed,
2242
+ failed,
2243
+ skipped,
2244
+ counts: scan.counts
2245
+ };
2246
+ }
2247
+
2248
+ export function apply(ctx) {
2249
+ const logger = ctx.logger ?? console;
2250
+ const mount = (owner) => {
2251
+ const connection = owner?.connection;
2252
+ if (!connection?.rpc?.handle) return;
2253
+ // DSH 0.1.5's rpc getter keeps the Connection service context; rebind it
2254
+ // to this injected web context so rpc.handle() can register on webServer.
2255
+ const rawConnection = connection[Symbol.for('cordis.original')] ?? connection;
2256
+ const scopedConnection = rawConnection?.[Symbol.for('cordis.extend')]?.({ ctx: owner }) ?? connection;
2257
+ const dispose = scopedConnection.rpc.handle(CHANNEL, async (endpoint, payload = {}, signal) => {
2258
+ if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } };
2259
+ try {
2260
+ switch (endpoint) {
2261
+ case 'mover.status': {
2262
+ const projection = projectionTable(ctx);
2263
+ const capabilities = {
2264
+ registry: Boolean(ctx.workspaceRegistry),
2265
+ persistence: Boolean(ctx.sessionPersistence),
2266
+ projectionRead: typeof projection?.get === 'function',
2267
+ projectionWrite: typeof projection?.put === 'function' && typeof projection?.delete === 'function',
2268
+ archiveChannel: typeof ctx.workspaceRegistry?.enqueueOperation === 'function',
2269
+ coordinatorStates: Boolean(peekService(ctx, 'sessionPersistence')?.coordinator?.states),
2270
+ fileReferences: Boolean(peekService(ctx, 'fileReferences')),
2271
+ agents: Boolean(peekService(ctx, 'agents'))
2272
+ };
2273
+ const degradedFeatures = [];
2274
+ if (!capabilities.projectionWrite) degradedFeatures.push('projection-title-restore');
2275
+ if (!capabilities.archiveChannel) degradedFeatures.push('archive-set-changes');
2276
+ if (!capabilities.coordinatorStates) degradedFeatures.push('resident-hotfix');
2277
+ if (!capabilities.fileReferences) degradedFeatures.push('file-reference-cleanup');
2278
+ return ok({
2279
+ ready: Boolean(capabilities.registry && capabilities.persistence),
2280
+ channel: CHANNEL,
2281
+ capabilities,
2282
+ degradedFeatures,
2283
+ recoveryCount: recoveryCount()
2284
+ });
2285
+ }
2286
+ case 'mover.workspaces':
2287
+ return ok(await listWorkspaces(ctx));
2288
+ case 'mover.history':
2289
+ return ok(await listHistory());
2290
+ case 'mover.undo':
2291
+ try {
2292
+ return ok(await undoMove(ctx, payload?.historyId));
2293
+ } catch (err) {
2294
+ return mapError(err);
2295
+ }
2296
+ case 'mover.scan':
2297
+ return ok(await scanSessions(ctx));
2298
+ case 'mover.repair': {
2299
+ try {
2300
+ return ok(await repairSessions(ctx, payload?.actions));
2301
+ } catch (err) {
2302
+ return mapError(err);
2303
+ }
2304
+ }
2305
+ case 'mover.move': {
2306
+ try {
2307
+ const sessionId = payload?.sessionId;
2308
+ const targetWorkspaceId = payload?.targetWorkspaceId;
2309
+ if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('sessionId required');
2310
+ if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
2311
+ const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle });
2312
+ logger.info?.(`workspace-mover: moved ${sessionId} -> ${result.to.cwd}`);
2313
+ return ok(result);
2314
+ } catch (err) {
2315
+ logger.warn?.(`workspace-mover: MOVE FAILED sessionId=${payload?.sessionId} target=${payload?.targetWorkspaceId}: ${err?.message ?? err}`);
2316
+ return mapError(err);
2317
+ }
2318
+ }
2319
+ case 'mover.moveMany': {
2320
+ try {
2321
+ return ok(await moveManySessions(ctx, payload ?? {}));
2322
+ } catch (err) {
2323
+ logger.warn?.(`workspace-mover: MOVE MANY FAILED target=${payload?.targetWorkspaceId}: ${err?.message ?? err}`);
2324
+ return mapError(err);
2325
+ }
2326
+ }
2327
+ case 'mover.ws.audit':
2328
+ return ok(await auditWorkspaces(ctx));
2329
+ case 'mover.archived':
2330
+ return ok(await listArchivedSessions(ctx));
2331
+ case 'mover.unarchive': {
2332
+ try {
2333
+ return ok(await unarchiveSession(ctx, payload ?? {}));
2334
+ } catch (err) {
2335
+ logger.warn?.(`workspace-mover: UNARCHIVE FAILED session=${payload?.sessionId}: ${err?.message ?? err}`);
2336
+ return mapError(err);
2337
+ }
2338
+ }
2339
+ case 'mover.openFolder': {
2340
+ try {
2341
+ return ok(await openWorkspaceFolder(ctx, payload ?? {}));
2342
+ } catch (err) {
2343
+ logger.warn?.(`workspace-mover: OPEN FOLDER FAILED ${payload?.workspaceId ?? payload?.path}: ${err?.message ?? err}`);
2344
+ return mapError(err);
2345
+ }
2346
+ }
2347
+ case 'mover.session.delete': {
2348
+ try {
2349
+ return ok(await deleteSessionToTrash(ctx, payload ?? {}));
2350
+ } catch (err) {
2351
+ logger.warn?.(`workspace-mover: DELETE FAILED session=${payload?.sessionId}: ${err?.message ?? err}`);
2352
+ return mapError(err);
2353
+ }
2354
+ }
2355
+ case 'mover.trash.list':
2356
+ return ok(await listTrash(ctx));
2357
+ case 'mover.trash.restore': {
2358
+ try {
2359
+ return ok(await restoreFromTrash(ctx, payload ?? {}));
2360
+ } catch (err) {
2361
+ logger.warn?.(`workspace-mover: TRASH RESTORE FAILED session=${payload?.sessionId}: ${err?.message ?? err}`);
2362
+ return mapError(err);
2363
+ }
2364
+ }
2365
+ case 'mover.trash.purge': {
2366
+ try {
2367
+ return ok(await purgeTrash(ctx, payload ?? {}));
2368
+ } catch (err) {
2369
+ return mapError(err);
2370
+ }
2371
+ }
2372
+ case 'mover.backups.list':
2373
+ return ok(await listBackups(ctx));
2374
+ case 'mover.backups.restore': {
2375
+ try {
2376
+ return ok(await restoreBackup(ctx, payload ?? {}));
2377
+ } catch (err) {
2378
+ logger.warn?.(`workspace-mover: BACKUP RESTORE FAILED session=${payload?.sessionId}: ${err?.message ?? err}`);
2379
+ return mapError(err);
2380
+ }
2381
+ }
2382
+ case 'mover.backups.deleteOne': {
2383
+ try {
2384
+ return ok(await deleteBackup(ctx, payload ?? {}));
2385
+ } catch (err) {
2386
+ return mapError(err);
2387
+ }
2388
+ }
2389
+ case 'mover.repairAll': {
2390
+ try {
2391
+ return ok(await repairAllSessions(ctx));
2392
+ } catch (err) {
2393
+ logger.warn?.(`workspace-mover: REPAIR ALL FAILED: ${err?.message ?? err}`);
2394
+ return mapError(err);
2395
+ }
2396
+ }
2397
+ case 'mover.tasks.list':
2398
+ return ok(await listTasks());
2399
+ case 'mover.tasks.retry': {
2400
+ try {
2401
+ return ok(await retryTask(ctx, payload ?? {}));
2402
+ } catch (err) {
2403
+ logger.warn?.(`workspace-mover: TASK RETRY FAILED ${payload?.taskId}: ${err?.message ?? err}`);
2404
+ return mapError(err);
2405
+ }
2406
+ }
2407
+ case 'mover.tasks.forget': {
2408
+ try {
2409
+ return ok(await forgetTask(ctx, payload ?? {}));
2410
+ } catch (err) {
2411
+ return mapError(err);
2412
+ }
2413
+ }
2414
+ case 'mover.data.cleanup': {
2415
+ try {
2416
+ return ok(await cleanupOldData(ctx, payload ?? {}));
2417
+ } catch (err) {
2418
+ return mapError(err);
2419
+ }
2420
+ }
2421
+ case 'mover.repoint': {
2422
+ try {
2423
+ return ok(await repointWorkspace(ctx, payload ?? {}));
2424
+ } catch (err) {
2425
+ logger.warn?.(`workspace-mover: REPOINT FAILED ws=${payload?.workspaceId}: ${err?.message ?? err}`);
2426
+ return mapError(err);
2427
+ }
2428
+ }
2429
+ default:
2430
+ return failBadRequest(`unknown endpoint '${endpoint}'`);
2431
+ }
2432
+ } catch (err) {
2433
+ return mapError(err);
2434
+ }
2435
+ }, { authority: 'loopback' });
2436
+ owner.effect?.(() => () => { try { dispose?.(); } catch { /* ignore */ } }, 'workspace-mover: rpc dispose');
2437
+ };
2438
+
2439
+ if (typeof ctx.inject === 'function') {
2440
+ ctx.inject(['connection', 'webServer'], (webCtx) => mount(webCtx));
2441
+ } else {
2442
+ mount(ctx);
2443
+ }
2444
+ }
2445
+
2446
+ export { name, inject, CHANNEL, projectKey, encodeSegment, sessionDir, artifactPath, generationLogFilename, parseGenerationLogFilename, listGenerationArtifacts, findHighestArtifact, preferredArtifactName, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, moveSessionEntry, validId, SCAN_MAX_ITEMS };