dsh-workspace-mover 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +285 -0
- package/README.md +357 -304
- package/README_EN.md +346 -0
- package/client/client.js +632 -100
- package/docs/ARCHITECTURE.md +81 -22
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-04.md +39 -0
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-13.md +45 -0
- package/lib/config.js +74 -0
- package/lib/index.js +1756 -172
- package/package.json +102 -63
- package/test/client-dom.test.mjs +796 -0
- package/test/e2e-sandbox.test.mjs +705 -60
- package/test/policy.test.mjs +415 -0
package/lib/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// ① 跨工作区「真迁移」原始会话:物理搬移 session.jsonl.zstd、改写头部 cwd、
|
|
4
4
|
// 更新工作区注册表(侧边栏拖拽触发)。会话 id 与历史文件保持原样,
|
|
5
|
-
//
|
|
5
|
+
// 不复制第二条注册会话,不消耗 token;恢复快照独立存放在插件数据目录。
|
|
6
6
|
// ② 孤儿会话救援(v0.3):扫描存储根分类失联/未挂账/幽灵档案,
|
|
7
7
|
// relink 复用同一条迁移管线换路径重挂,attach 原地补记账。
|
|
8
8
|
// 针对官方讨论 #3012(项目文件夹移动后历史"消失")的社区修复。
|
|
@@ -32,12 +32,14 @@
|
|
|
32
32
|
// 依赖官方「已记账 id 跳过校验」语义,先刷新 registry 三张索引并预置
|
|
33
33
|
// target.record.sessionIds 再 attach(版本敏感,失败自动回滚)。
|
|
34
34
|
|
|
35
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, rmSync, statSync, cpSync, statfsSync } from 'node:fs';
|
|
35
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, readdirSync, rmSync, statSync, cpSync, copyFileSync, statfsSync } from 'node:fs';
|
|
36
36
|
import { realpathSync } from 'node:fs';
|
|
37
37
|
import { zstdDecompressSync, zstdCompressSync, constants } from 'node:zlib';
|
|
38
|
-
import {
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
39
|
+
import { join, dirname, basename, relative, resolve } from 'node:path';
|
|
39
40
|
import { homedir } from 'node:os';
|
|
40
41
|
import { spawn } from 'node:child_process';
|
|
42
|
+
import { Config, POLICY_FIELDS, buildConfig, attachConfig } from './config.js';
|
|
41
43
|
|
|
42
44
|
const name = 'workspace-mover';
|
|
43
45
|
// tools/approval 由 dsh-tools / dsh-user-approval 提供(dsh-base 默认挂载);
|
|
@@ -49,7 +51,59 @@ const ZSTD_MAGIC = 4247762216;
|
|
|
49
51
|
/** Current write generation: DSH 0.1.5 treats the highest canonical generation as source of truth. */
|
|
50
52
|
const CURRENT_GENERATION = 3;
|
|
51
53
|
const HISTORY_FILE = 'history.json';
|
|
52
|
-
const
|
|
54
|
+
const migrationRuntimes = new WeakMap();
|
|
55
|
+
|
|
56
|
+
//#region 可调策略
|
|
57
|
+
/**
|
|
58
|
+
* 策略读取:**在使用点调用**(与官方 Config 文档"按需读 .get()"的建议一致,
|
|
59
|
+
* 不在模块顶层冻结),优先级为 插件 Config → 环境变量 → 代码默认值。
|
|
60
|
+
*
|
|
61
|
+
* 环境变量是本插件持有的兜底通道:官方 Config 需要 schemastery(宿主 loader 提供,
|
|
62
|
+
* 插件目录下不可解析),所以任何静态 import 都可能打断 boot。这里只做纯读取,
|
|
63
|
+
* Config 接入见 lib/config.js。
|
|
64
|
+
*/
|
|
65
|
+
const POLICY_DEFAULTS = {
|
|
66
|
+
historyLimit: 100,
|
|
67
|
+
backupKeep: 20,
|
|
68
|
+
batchLimit: 50,
|
|
69
|
+
repointLimit: 200,
|
|
70
|
+
scanLimit: 400,
|
|
71
|
+
cleanupDays: 30
|
|
72
|
+
};
|
|
73
|
+
/** 环境变量名 → 策略键。 */
|
|
74
|
+
const POLICY_ENV = {
|
|
75
|
+
DSH_WORKSPACE_MOVER_HISTORY_LIMIT: 'historyLimit',
|
|
76
|
+
DSH_WORKSPACE_MOVER_BACKUP_KEEP: 'backupKeep',
|
|
77
|
+
DSH_WORKSPACE_MOVER_BATCH_LIMIT: 'batchLimit',
|
|
78
|
+
DSH_WORKSPACE_MOVER_REPOINT_LIMIT: 'repointLimit',
|
|
79
|
+
DSH_WORKSPACE_MOVER_SCAN_LIMIT: 'scanLimit',
|
|
80
|
+
DSH_WORKSPACE_MOVER_CLEANUP_DAYS: 'cleanupDays'
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** 解析一个正整数策略值;非法/越界一律回退默认(配置错误不应让功能不可用)。 */
|
|
84
|
+
function resolvePolicy(key, min, max) {
|
|
85
|
+
const raw = process.env[Object.keys(POLICY_ENV).find((env) => POLICY_ENV[env] === key)];
|
|
86
|
+
if (raw !== undefined && String(raw).trim() !== '') {
|
|
87
|
+
const n = Number(String(raw).trim());
|
|
88
|
+
if (Number.isFinite(n) && Number.isInteger(n) && n >= min && n <= max) return n;
|
|
89
|
+
}
|
|
90
|
+
return POLICY_DEFAULTS[key];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 历史保留条数。 */
|
|
94
|
+
const historyLimit = () => resolvePolicy('historyLimit', 1, 10000);
|
|
95
|
+
/** 每个会话保留的迁移备份份数。 */
|
|
96
|
+
const backupKeep = () => resolvePolicy('backupKeep', 1, 1000);
|
|
97
|
+
/** 单批移动/修复上限。 */
|
|
98
|
+
const batchLimit = () => resolvePolicy('batchLimit', 1, 500);
|
|
99
|
+
/** 单个工作区搬家上限。 */
|
|
100
|
+
const repointLimit = () => resolvePolicy('repointLimit', 1, 5000);
|
|
101
|
+
/** 救援扫描解析上限。 */
|
|
102
|
+
const scanLimit = () => resolvePolicy('scanLimit', 1, 100000);
|
|
103
|
+
/** 数据清理默认天数。 */
|
|
104
|
+
const cleanupDays = () => resolvePolicy('cleanupDays', 1, 3650);
|
|
105
|
+
//#endregion
|
|
106
|
+
|
|
53
107
|
|
|
54
108
|
/** 路径等价判断:优先 realpath(大小写别名归一),目录缺失时退化为大小写不敏感字符串比较。 */
|
|
55
109
|
function samePath(a, b) {
|
|
@@ -192,6 +246,143 @@ async function listPersistenceHeaders(persistence) {
|
|
|
192
246
|
if (!Array.isArray(snapshots)) return [];
|
|
193
247
|
return snapshots.map(persistenceHeader).filter((header) => header && typeof header === typeof {});
|
|
194
248
|
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Describe the public persistence surface without assuming that the provider
|
|
252
|
+
* exposes its private filesystem layout. Physical moves still need the legacy
|
|
253
|
+
* JSONL path adapter; handle-only providers are kept read/diagnostic capable.
|
|
254
|
+
*/
|
|
255
|
+
function persistenceCapabilities(persistence) {
|
|
256
|
+
return {
|
|
257
|
+
list: typeof persistence?.list === 'function',
|
|
258
|
+
stat: typeof persistence?.stat === 'function',
|
|
259
|
+
open: typeof persistence?.open === 'function',
|
|
260
|
+
create: typeof persistence?.create === 'function',
|
|
261
|
+
flush: typeof persistence?.flush === 'function',
|
|
262
|
+
physicalPath: typeof persistence?.root === 'string' && persistence.root.length > 0,
|
|
263
|
+
formatStatus: typeof persistence?.formatStatus === 'function'
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function createPersistenceAdapter(ctx) {
|
|
268
|
+
const persistence = ctx?.sessionPersistence;
|
|
269
|
+
const capabilities = persistenceCapabilities(persistence);
|
|
270
|
+
const mode = capabilities.physicalPath
|
|
271
|
+
? 'filesystem-fallback'
|
|
272
|
+
: (capabilities.list && capabilities.stat && capabilities.open ? 'official-handle' : 'unavailable');
|
|
273
|
+
const reason = mode === 'filesystem-fallback'
|
|
274
|
+
? 'public raw-byte path API is unavailable; physical JSONL path adapter is active'
|
|
275
|
+
: mode === 'official-handle'
|
|
276
|
+
? 'provider exposes public list/stat/open APIs but no public raw-byte path; physical moves are unavailable'
|
|
277
|
+
: 'provider does not expose enough public persistence APIs';
|
|
278
|
+
const unsupportedPhysical = () => {
|
|
279
|
+
const err = new Error('official persistence provider does not expose a raw-byte physical path operation');
|
|
280
|
+
err.code = 'unsupported';
|
|
281
|
+
throw err;
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
mode,
|
|
285
|
+
reason,
|
|
286
|
+
capabilities,
|
|
287
|
+
root: capabilities.physicalPath ? persistence.root : null,
|
|
288
|
+
compression: persistenceCompression(persistence),
|
|
289
|
+
async list() {
|
|
290
|
+
if (!capabilities.list) return [];
|
|
291
|
+
return listPersistenceHeaders(persistence);
|
|
292
|
+
},
|
|
293
|
+
async locate(session) {
|
|
294
|
+
const sessionId = String(session?.sessionId ?? session?.id ?? '');
|
|
295
|
+
if (!sessionId) throw new Error('session id required');
|
|
296
|
+
if (mode === 'filesystem-fallback') {
|
|
297
|
+
const cwd = typeof session?.cwd === 'string' ? session.cwd : null;
|
|
298
|
+
if (!cwd) throw new Error(`session '${sessionId}' carries no cwd`);
|
|
299
|
+
const artifact = artifactPath(persistence.root, cwd, sessionId, this.compression);
|
|
300
|
+
return { kind: 'filesystem', sessionId, cwd, artifact };
|
|
301
|
+
}
|
|
302
|
+
if (capabilities.stat) {
|
|
303
|
+
const stat = await persistence.stat(sessionId);
|
|
304
|
+
return { kind: 'handle', sessionId, cwd: session?.cwd ?? null, stat };
|
|
305
|
+
}
|
|
306
|
+
throw new Error('persistence stat API unavailable');
|
|
307
|
+
},
|
|
308
|
+
async inspect(location) {
|
|
309
|
+
if (location?.kind === 'filesystem') {
|
|
310
|
+
if (!existsSync(location.artifact)) return { exists: false, sizeBytes: 0 };
|
|
311
|
+
const st = statSync(location.artifact);
|
|
312
|
+
const generations = listGenerationArtifacts(dirname(location.artifact)).map((entry) => ({
|
|
313
|
+
version: entry.version,
|
|
314
|
+
compression: entry.compression,
|
|
315
|
+
name: entry.name
|
|
316
|
+
}));
|
|
317
|
+
return { exists: st.isFile(), sizeBytes: st.isFile() ? st.size : 0, path: location.artifact, generations };
|
|
318
|
+
}
|
|
319
|
+
if (location?.kind === 'handle' && capabilities.stat) {
|
|
320
|
+
return { exists: true, stat: location.stat ?? await persistence.stat(location.sessionId) };
|
|
321
|
+
}
|
|
322
|
+
return { exists: false, sizeBytes: 0 };
|
|
323
|
+
},
|
|
324
|
+
async read(location) {
|
|
325
|
+
if (location?.kind === 'filesystem') return readFileSync(location.artifact);
|
|
326
|
+
unsupportedPhysical();
|
|
327
|
+
},
|
|
328
|
+
async write(location, bytes) {
|
|
329
|
+
if (location?.kind === 'filesystem') {
|
|
330
|
+
mkdirSync(dirname(location.artifact), { recursive: true });
|
|
331
|
+
atomicWrite(location.artifact, Buffer.from(bytes));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
unsupportedPhysical();
|
|
335
|
+
},
|
|
336
|
+
async replace(location, bytes) {
|
|
337
|
+
if (location?.kind === 'filesystem') {
|
|
338
|
+
atomicWrite(location.artifact, Buffer.from(bytes));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
unsupportedPhysical();
|
|
342
|
+
},
|
|
343
|
+
async relocate(location, destinationDir, { moveImpl = moveDir } = {}) {
|
|
344
|
+
if (location?.kind === 'filesystem') return moveImpl(dirname(location.artifact), destinationDir);
|
|
345
|
+
unsupportedPhysical();
|
|
346
|
+
},
|
|
347
|
+
async formatStatus(sessionId) {
|
|
348
|
+
if (typeof persistence.formatStatus === 'function') return persistence.formatStatus(sessionId);
|
|
349
|
+
if (capabilities.stat) {
|
|
350
|
+
try { return (await persistence.stat(sessionId))?.formatStatus ?? null; } catch { return null; }
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function declaredHeaderGeneration(header) {
|
|
358
|
+
for (const key of ['generation', 'formatGeneration', 'formatVersion', 'sessionGeneration']) {
|
|
359
|
+
const value = header?.[key];
|
|
360
|
+
if (Number.isInteger(value) && value >= 0) return value;
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function validateArtifactGeneration(filename, header) {
|
|
366
|
+
const file = parseGenerationLogFilename(basename(filename));
|
|
367
|
+
const declared = declaredHeaderGeneration(header);
|
|
368
|
+
if (file && declared !== null && file.version !== declared) {
|
|
369
|
+
const err = new Error(`session filename generation v${file.version} does not match header generation v${declared}: ${filename}`);
|
|
370
|
+
err.code = 'unsupported';
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
return { filename: file, declaredGeneration: declared };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function storageDiagnostics(ctx) {
|
|
377
|
+
const adapter = createPersistenceAdapter(ctx);
|
|
378
|
+
return {
|
|
379
|
+
mode: adapter.mode,
|
|
380
|
+
reason: adapter.reason,
|
|
381
|
+
capabilities: adapter.capabilities,
|
|
382
|
+
physicalPath: adapter.capabilities.physicalPath,
|
|
383
|
+
format: adapter.compression
|
|
384
|
+
};
|
|
385
|
+
}
|
|
195
386
|
function findDestinationArtifact(dir, compression) {
|
|
196
387
|
try {
|
|
197
388
|
if (existsSync(dir) && !statSync(dir).isDirectory()) {
|
|
@@ -362,8 +553,8 @@ function moveDir(srcDir, dstDir, hooks = {}) {
|
|
|
362
553
|
* 迁移后一致性校验:目标档案可回读、id 与 cwd 双确认。
|
|
363
554
|
* 复制路径的静默截断、头改写遗漏都会在这里现形。
|
|
364
555
|
*/
|
|
365
|
-
function
|
|
366
|
-
const header = readHeader(
|
|
556
|
+
function verifyRelocatedBytes(bytes, sessionId, expectedCwd, compression = 'zstd') {
|
|
557
|
+
const header = readHeader(bytes, compression);
|
|
367
558
|
if (String(header?.id) !== String(sessionId)) {
|
|
368
559
|
throw new Error(`header id mismatch: expected '${sessionId}', got '${header?.id}'`);
|
|
369
560
|
}
|
|
@@ -372,6 +563,10 @@ function verifyRelocatedArtifact(dstArtifact, sessionId, expectedCwd, compressio
|
|
|
372
563
|
}
|
|
373
564
|
return true;
|
|
374
565
|
}
|
|
566
|
+
|
|
567
|
+
function verifyRelocatedArtifact(dstArtifact, sessionId, expectedCwd, compression = 'zstd') {
|
|
568
|
+
return verifyRelocatedBytes(readFileSync(dstArtifact), sessionId, expectedCwd, compression);
|
|
569
|
+
}
|
|
375
570
|
//#endregion
|
|
376
571
|
|
|
377
572
|
//#region 备份管理($DSH_HOME/workspace-mover/backups,保留最近 20 份)
|
|
@@ -380,19 +575,142 @@ function backupDir() {
|
|
|
380
575
|
return join(home, 'workspace-mover', 'backups');
|
|
381
576
|
}
|
|
382
577
|
|
|
383
|
-
|
|
578
|
+
/**
|
|
579
|
+
* New snapshots include a content fingerprint. Legacy timestamp-only names
|
|
580
|
+
* remain readable so existing user backups are not orphaned by the upgrade.
|
|
581
|
+
*/
|
|
582
|
+
/** Parse both legacy `${id}.${ts}.zstd` and fingerprinted snapshot names. */
|
|
583
|
+
function parseBackupFilename(name) {
|
|
584
|
+
const match = /^(.*)\.(\d+)(?:-([a-z0-9]+))?(?:\.([a-f0-9]{64}))?\.zstd$/i.exec(String(name));
|
|
585
|
+
if (!match) return null;
|
|
586
|
+
return { id: match[1], ts: Number(match[2]), sequence: match[3] ?? null, fingerprint: match[4]?.toLowerCase() ?? null };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Return a stable content digest, optionally ignoring the migration-only cwd header field. */
|
|
590
|
+
function backupFingerprint(buf, { compression = 'zstd', ignoreCwd = false } = {}) {
|
|
591
|
+
let normalized = Buffer.from(buf);
|
|
592
|
+
if (ignoreCwd) {
|
|
593
|
+
try { normalized = rewriteHeaderCwd(normalized, '__workspace_mover_cwd__', compression); } catch { /* use raw bytes for malformed/unknown artifacts */ }
|
|
594
|
+
}
|
|
595
|
+
return createHash('sha256')
|
|
596
|
+
.update(`${compression}:${ignoreCwd ? 'content' : 'bytes'}\0`, 'utf8')
|
|
597
|
+
.update(normalized)
|
|
598
|
+
.digest('hex');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function backupFilesFor(id) {
|
|
602
|
+
const dir = backupDir();
|
|
603
|
+
if (!dirExists(dir)) return [];
|
|
604
|
+
const files = [];
|
|
605
|
+
for (const name of readdirSync(dir)) {
|
|
606
|
+
const parsed = parseBackupFilename(name);
|
|
607
|
+
if (!parsed || parsed.id !== String(id)) continue;
|
|
608
|
+
try {
|
|
609
|
+
const stat = statSync(join(dir, name));
|
|
610
|
+
if (!stat.isFile()) continue;
|
|
611
|
+
files.push({ ...parsed, name, sizeBytes: stat.size });
|
|
612
|
+
} catch { /* a concurrently removed snapshot is ignored */ }
|
|
613
|
+
}
|
|
614
|
+
return files;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function findExistingBackup(id, fingerprint, options = {}) {
|
|
618
|
+
const { compression = 'zstd', ignoreCwd = false } = options;
|
|
619
|
+
for (const file of backupFilesFor(id)) {
|
|
620
|
+
try {
|
|
621
|
+
const bytes = readFileSync(join(backupDir(), file.name));
|
|
622
|
+
if (backupFingerprint(bytes, { compression, ignoreCwd }) === fingerprint) return file;
|
|
623
|
+
} catch { /* unreadable legacy backups remain visible for manual cleanup */ }
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function backupPath(id, fingerprint) {
|
|
629
|
+
const dir = backupDir();
|
|
630
|
+
const ts = Date.now();
|
|
631
|
+
const suffix = `${fingerprint}.zstd`;
|
|
632
|
+
const base = join(dir, `${id}.${ts}.${suffix}`);
|
|
633
|
+
if (!existsSync(base)) return base;
|
|
634
|
+
for (let seq = 2; seq < 1000; seq++) {
|
|
635
|
+
const candidate = join(dir, `${id}.${ts}-${seq}.${suffix}`);
|
|
636
|
+
if (!existsSync(candidate)) return candidate;
|
|
637
|
+
}
|
|
638
|
+
return join(dir, `${id}.${ts}-${Math.random().toString(36).slice(2, 8)}.${suffix}`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function pruneBackups(id, protectedName = null) {
|
|
642
|
+
const dir = backupDir();
|
|
643
|
+
const keep = backupFilesFor(id).sort((a, b) => a.ts - b.ts || a.name.localeCompare(b.name));
|
|
644
|
+
while (keep.length > backupKeep()) {
|
|
645
|
+
let index = 0;
|
|
646
|
+
if (keep[index]?.name === protectedName) index = keep.findIndex((item) => item.name !== protectedName);
|
|
647
|
+
if (index < 0) break;
|
|
648
|
+
const [removed] = keep.splice(index, 1);
|
|
649
|
+
rmSync(join(dir, removed.name), { force: true });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Save one recoverable snapshot. Migration calls this with ignoreCwd=true so
|
|
655
|
+
* moving the same session back and forth does not create another physical copy.
|
|
656
|
+
*/
|
|
657
|
+
function stashBackup(id, buf, options = {}) {
|
|
658
|
+
const bytes = Buffer.from(buf);
|
|
659
|
+
const compression = options.compression ?? 'zstd';
|
|
660
|
+
const ignoreCwd = Boolean(options.ignoreCwd);
|
|
384
661
|
try {
|
|
385
662
|
const dir = backupDir();
|
|
386
663
|
mkdirSync(dir, { recursive: true });
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
664
|
+
const fingerprint = backupFingerprint(bytes, { compression, ignoreCwd });
|
|
665
|
+
const existing = findExistingBackup(id, fingerprint, { compression, ignoreCwd });
|
|
666
|
+
if (existing) {
|
|
667
|
+
pruneBackups(id, existing.name);
|
|
668
|
+
return {
|
|
669
|
+
created: false,
|
|
670
|
+
reused: true,
|
|
671
|
+
fileName: existing.name,
|
|
672
|
+
fingerprint,
|
|
673
|
+
sizeBytes: existing.sizeBytes,
|
|
674
|
+
addedBytes: 0,
|
|
675
|
+
sourceBytes: bytes.length
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
const fileName = basename(backupPath(id, fingerprint));
|
|
679
|
+
atomicWrite(join(dir, fileName), bytes);
|
|
680
|
+
// Prefix filtering keeps neighbouring ids such as session-x / session-x-1 isolated.
|
|
681
|
+
pruneBackups(id, fileName);
|
|
682
|
+
return {
|
|
683
|
+
created: true,
|
|
684
|
+
reused: false,
|
|
685
|
+
fileName,
|
|
686
|
+
fingerprint,
|
|
687
|
+
sizeBytes: bytes.length,
|
|
688
|
+
addedBytes: bytes.length,
|
|
689
|
+
sourceBytes: bytes.length
|
|
690
|
+
};
|
|
391
691
|
} catch (err) {
|
|
392
692
|
throw new Error(`backup failed (refusing to move without a stash): ${err?.message ?? err}`);
|
|
393
693
|
}
|
|
394
694
|
}
|
|
395
695
|
|
|
696
|
+
async function inspectBackupPlan(id, location, sourceInfo, compression = 'zstd') {
|
|
697
|
+
if (!sourceInfo?.exists) return { reused: false, estimatedBytes: sourceInfo?.sizeBytes ?? 0, unavailable: true };
|
|
698
|
+
try {
|
|
699
|
+
const bytes = readFileSync(location.artifact);
|
|
700
|
+
const fingerprint = backupFingerprint(bytes, { compression, ignoreCwd: true });
|
|
701
|
+
const existing = findExistingBackup(id, fingerprint, { compression, ignoreCwd: true });
|
|
702
|
+
return {
|
|
703
|
+
reused: Boolean(existing),
|
|
704
|
+
estimatedBytes: existing ? 0 : bytes.length,
|
|
705
|
+
fileName: existing?.name ?? null,
|
|
706
|
+
fingerprint,
|
|
707
|
+
sourceBytes: bytes.length
|
|
708
|
+
};
|
|
709
|
+
} catch {
|
|
710
|
+
return { reused: false, estimatedBytes: sourceInfo.sizeBytes ?? 0, unavailable: true };
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
396
714
|
function historyPath() {
|
|
397
715
|
|
|
398
716
|
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
@@ -411,7 +729,7 @@ function readHistory() {
|
|
|
411
729
|
function writeHistory(entries) {
|
|
412
730
|
const path = historyPath();
|
|
413
731
|
mkdirSync(dirname(path), { recursive: true });
|
|
414
|
-
atomicWrite(path, Buffer.from(JSON.stringify(entries.slice(-
|
|
732
|
+
atomicWrite(path, Buffer.from(JSON.stringify(entries.slice(-historyLimit()), null, 2) + '\n', 'utf8'));
|
|
415
733
|
}
|
|
416
734
|
|
|
417
735
|
function rememberMove(result, sourceWorkspaceId, targetWorkspaceId) {
|
|
@@ -423,7 +741,8 @@ function rememberMove(result, sourceWorkspaceId, targetWorkspaceId) {
|
|
|
423
741
|
targetWorkspaceId,
|
|
424
742
|
from: result.from.cwd,
|
|
425
743
|
to: result.to.cwd,
|
|
426
|
-
movedAt: new Date().toISOString()
|
|
744
|
+
movedAt: new Date().toISOString(),
|
|
745
|
+
backup: result.backup ?? null
|
|
427
746
|
};
|
|
428
747
|
const history = readHistory();
|
|
429
748
|
history.push(entry);
|
|
@@ -443,7 +762,8 @@ function rememberBatchMove(movedItems, targetWorkspaceId, targetPath) {
|
|
|
443
762
|
sessionId: String(item.sessionId),
|
|
444
763
|
title: item.title,
|
|
445
764
|
from: item.fromCwd,
|
|
446
|
-
sourceWorkspaceId: item.sourceWorkspaceId ?? null
|
|
765
|
+
sourceWorkspaceId: item.sourceWorkspaceId ?? null,
|
|
766
|
+
backup: item.backup ?? null
|
|
447
767
|
}))
|
|
448
768
|
};
|
|
449
769
|
const history = readHistory();
|
|
@@ -589,6 +909,12 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
589
909
|
const registry = ctx.workspaceRegistry;
|
|
590
910
|
const persistence = ctx.sessionPersistence;
|
|
591
911
|
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
912
|
+
const storage = createPersistenceAdapter(ctx);
|
|
913
|
+
if (!storage.capabilities.physicalPath) {
|
|
914
|
+
const err = new Error(`${storage.reason}; physical session migration is unavailable`);
|
|
915
|
+
err.code = 'unsupported';
|
|
916
|
+
throw err;
|
|
917
|
+
}
|
|
592
918
|
|
|
593
919
|
// ---- 目标实体 ----
|
|
594
920
|
const target = registry.get(targetWorkspaceId);
|
|
@@ -615,8 +941,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
615
941
|
if (samePath(sourceCwd, targetPath)) {
|
|
616
942
|
// 磁盘头已指向目标:若目标工作区还没记账它(历史遗留的未归组会话),
|
|
617
943
|
// 自动补上归属而不是报错——用户意图就是「让它归到这个组」。
|
|
618
|
-
|
|
619
|
-
try { accounted = [...target.record.sessionIds].includes(String(sessionId)); } catch { /* ignore */ }
|
|
944
|
+
const accounted = rawRecordSessionIds(target).includes(String(sessionId));
|
|
620
945
|
if (!accounted) {
|
|
621
946
|
const r = await attachUnregistered(ctx, sessionId);
|
|
622
947
|
ctx.logger?.info?.(`workspace-mover: self-healed accounting for ${sessionId} -> ${r.workspaceId}`);
|
|
@@ -642,21 +967,27 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
642
967
|
}
|
|
643
968
|
|
|
644
969
|
// ---- 文件路径 ----
|
|
645
|
-
const root =
|
|
970
|
+
const root = storage.root;
|
|
646
971
|
if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
|
|
647
|
-
const compression =
|
|
648
|
-
const
|
|
972
|
+
const compression = storage.compression;
|
|
973
|
+
const sourceLocation = await storage.locate({ sessionId, cwd: sourceCwd });
|
|
974
|
+
const sourceInfo = await storage.inspect(sourceLocation);
|
|
975
|
+
if (!sourceInfo.exists) throw new Error(`session artifact missing under: ${dirname(sourceLocation.artifact)}`);
|
|
976
|
+
const srcDir = dirname(sourceLocation.artifact);
|
|
649
977
|
const dstDir = sessionDir(root, targetPath, sessionId);
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const dstArtifact = join(dstDir,
|
|
978
|
+
const destinationCheck = await storage.locate({ sessionId, cwd: targetPath });
|
|
979
|
+
const destinationInfo = await storage.inspect(destinationCheck);
|
|
980
|
+
if (destinationInfo.exists) throw new Error(`destination artifact already exists under: ${dstDir}`);
|
|
981
|
+
const srcArtifact = sourceLocation.artifact;
|
|
982
|
+
const dstArtifact = join(dstDir, basename(srcArtifact));
|
|
983
|
+
const destinationLocation = { ...destinationCheck, cwd: targetPath, artifact: dstArtifact };
|
|
655
984
|
|
|
656
985
|
// ---- 备份先行:备份失败 = 零副作用(尚未触碰记账/索引/目标路径)----
|
|
657
|
-
const original =
|
|
986
|
+
const original = await storage.read(sourceLocation);
|
|
987
|
+
validateArtifactGeneration(srcArtifact, readHeader(original, compression));
|
|
988
|
+
let backup = null;
|
|
658
989
|
try {
|
|
659
|
-
stashImpl(String(sessionId), original);
|
|
990
|
+
backup = stashImpl(String(sessionId), original, { compression, ignoreCwd: true }) ?? null;
|
|
660
991
|
} catch (err) {
|
|
661
992
|
const e = new Error(`backup failed, nothing was changed: ${err?.message ?? err}`);
|
|
662
993
|
e.code = 'backup-failed';
|
|
@@ -687,7 +1018,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
687
1018
|
// 提示性检查(Windows 下与实际写入存在竞态):不通过则尽早失败(此时零改动),
|
|
688
1019
|
// 通过也不构成保证——真正的兜底是复制失败清理 + 迁移后回读校验。
|
|
689
1020
|
const st = statfsSync(dirname(dstArtifact));
|
|
690
|
-
const needed = Math.max(Number(st.bsize) || 4096,
|
|
1021
|
+
const needed = Math.max(Number(st.bsize) || 4096, Number(sourceInfo.sizeBytes) * 2);
|
|
691
1022
|
if (typeof st.bfree === 'number' && st.bfree * Number(st.bsize || 4096) < needed) {
|
|
692
1023
|
const e = new Error(`insufficient disk space at target (need ~${needed} bytes, free ~${st.bfree * Number(st.bsize || 4096)})`);
|
|
693
1024
|
e.code = 'insufficient-space';
|
|
@@ -705,19 +1036,19 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
705
1036
|
const rewritten = rewriteHeaderCwd(original, targetPath, compression);
|
|
706
1037
|
|
|
707
1038
|
try {
|
|
708
|
-
|
|
1039
|
+
await storage.replace(sourceLocation, rewritten);
|
|
709
1040
|
} catch (err) {
|
|
710
|
-
for (const e of allOwners) { try { if (!e.
|
|
1041
|
+
for (const e of allOwners) { try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ } }
|
|
711
1042
|
throw new Error(`move failed while rewriting header: ${err?.message ?? err}`);
|
|
712
1043
|
}
|
|
713
1044
|
|
|
714
1045
|
try {
|
|
715
|
-
const how =
|
|
1046
|
+
const how = await storage.relocate(sourceLocation, dirname(dstArtifact));
|
|
1047
|
+
const targetLocation = { ...sourceLocation, cwd: targetPath, artifact: dstArtifact };
|
|
716
1048
|
// 迁移后一致性校验:目标档案可回读、id/cwd 双确认。失败视同搬运失败,整体回退。
|
|
717
1049
|
try {
|
|
718
|
-
|
|
1050
|
+
verifyRelocatedBytes(await storage.read(targetLocation), sessionId, targetPath, compression);
|
|
719
1051
|
} catch (verifyErr) {
|
|
720
|
-
try { moveDir(dirname(dstArtifact), dirname(srcArtifact)); } catch { /* 尽力移回 */ }
|
|
721
1052
|
throw new Error(`post-move verification failed: ${verifyErr?.message ?? verifyErr}`);
|
|
722
1053
|
}
|
|
723
1054
|
if (how !== 'rename') ctx.logger?.info?.('workspace-mover: directory rename fell back to copy+delete');
|
|
@@ -727,9 +1058,28 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
727
1058
|
if (existsSync(srcProject) && readdirSync(srcProject).length === 0) rmSync(srcProject, { recursive: true });
|
|
728
1059
|
} catch { /* ignore */ }
|
|
729
1060
|
} catch (err) {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1061
|
+
let rollbackError = null;
|
|
1062
|
+
try {
|
|
1063
|
+
if (!(await storage.inspect(sourceLocation)).exists) {
|
|
1064
|
+
await storage.relocate({ ...sourceLocation, cwd: targetPath, artifact: dstArtifact }, dirname(srcArtifact));
|
|
1065
|
+
}
|
|
1066
|
+
await storage.replace(sourceLocation, original);
|
|
1067
|
+
} catch (cause) {
|
|
1068
|
+
rollbackError = cause;
|
|
1069
|
+
try { await storage.write(sourceLocation, original); } catch (writeErr) { rollbackError = writeErr; }
|
|
1070
|
+
recordRecovery(ctx, {
|
|
1071
|
+
kind: 'move', phase: 'physical-rollback', sessionId: String(sessionId),
|
|
1072
|
+
source: sourceCwd, target: targetPath, backup: backup?.fileName ? join(backupDir(), backup.fileName) : backupDir(),
|
|
1073
|
+
lastError: `relocate failed (${err?.message ?? err}) AND rollback failed (${cause?.message ?? cause})`
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
for (const owner of allOwners) {
|
|
1077
|
+
try { if (!rawRecordSessionIds(owner).includes(String(sessionId))) await owner.attachSession?.(sessionId); } catch { /* recovery record/backup remains */ }
|
|
1078
|
+
}
|
|
1079
|
+
if (rollbackError) {
|
|
1080
|
+
throw new Error(`move failed at relocate step (${err?.message ?? err}) AND rollback failed (${rollbackError?.message ?? rollbackError}); original bytes are preserved in the recovery backup`);
|
|
1081
|
+
}
|
|
1082
|
+
throw new Error(`move failed at relocate step, rolled back cleanly: ${err?.message ?? err}`);
|
|
733
1083
|
}
|
|
734
1084
|
|
|
735
1085
|
// ---- 内存一致性收尾(版本敏感,降级安全)----
|
|
@@ -782,18 +1132,23 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
782
1132
|
if (priorHeader === undefined) registry.headers?.delete?.(sessionId);
|
|
783
1133
|
else registry.headers?.set?.(sessionId, priorHeader);
|
|
784
1134
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1135
|
+
const targetLocation = { ...sourceLocation, cwd: targetPath, artifact: dstArtifact };
|
|
1136
|
+
try {
|
|
1137
|
+
await storage.relocate(targetLocation, dirname(srcArtifact));
|
|
1138
|
+
} catch (relocateErr) {
|
|
1139
|
+
await storage.write(sourceLocation, original);
|
|
1140
|
+
throw new Error(`physical rollback could not move the target directory: ${relocateErr?.message ?? relocateErr}`);
|
|
1141
|
+
}
|
|
1142
|
+
await storage.replace(sourceLocation, original);
|
|
788
1143
|
// 索引无需再清:非常驻路径在收尾时已清空、由源 attach 重新记账;
|
|
789
1144
|
// 常驻路径上一段已按移动前快照还原。
|
|
790
1145
|
for (const e of allOwners) {
|
|
791
|
-
try { if (!e.
|
|
1146
|
+
try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ }
|
|
792
1147
|
}
|
|
793
1148
|
} catch (rollbackErr) {
|
|
794
1149
|
recordRecovery(ctx, {
|
|
795
1150
|
kind: 'move', phase: 'attach-rollback', sessionId: String(sessionId),
|
|
796
|
-
source: sourceCwd, target: targetPath, backup: backupDir(),
|
|
1151
|
+
source: sourceCwd, target: targetPath, backup: backup?.fileName ? join(backupDir(), backup.fileName) : backupDir(),
|
|
797
1152
|
lastError: `attach failed (${err?.message ?? err}) AND rollback failed (${rollbackErr?.message ?? rollbackErr})`
|
|
798
1153
|
});
|
|
799
1154
|
throw new Error(`move failed during attach (${err?.message ?? err}) AND rollback failed (${rollbackErr?.message ?? rollbackErr}). Original bytes stashed under ${backupDir()}`);
|
|
@@ -823,7 +1178,8 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
823
1178
|
to: { cwd: targetPath, workspaceId: targetWorkspaceId },
|
|
824
1179
|
cacheInvalidated,
|
|
825
1180
|
restartHint,
|
|
826
|
-
warnings
|
|
1181
|
+
warnings,
|
|
1182
|
+
backup
|
|
827
1183
|
};
|
|
828
1184
|
if (recordHistory) {
|
|
829
1185
|
try {
|
|
@@ -847,7 +1203,7 @@ async function listWorkspaces(ctx) {
|
|
|
847
1203
|
status: undefined,
|
|
848
1204
|
sessionIds: (() => { try { return [...entity.sessionIds]; } catch { return []; } })(),
|
|
849
1205
|
// 原始记账数(含归档与幽灵成员):空工作区判定必须看原始账本,藏了成员的组不能算空
|
|
850
|
-
rawSessionCount: (() => { try { return entity.
|
|
1206
|
+
rawSessionCount: (() => { try { return rawRecordSessionIds(entity).length; } catch { return undefined; } })()
|
|
851
1207
|
}));
|
|
852
1208
|
let archivedSessionIds = [];
|
|
853
1209
|
try { archivedSessionIds = [...registry.archivedSessionIds]; } catch { /* ignore */ }
|
|
@@ -855,7 +1211,7 @@ async function listWorkspaces(ctx) {
|
|
|
855
1211
|
}
|
|
856
1212
|
|
|
857
1213
|
//#region 孤儿会话救援(v0.3):扫描分类 + 批量重挂
|
|
858
|
-
const SCAN_MAX_ITEMS = 400;
|
|
1214
|
+
const SCAN_MAX_ITEMS = 400; // 默认值(导出用于测试/文档);实际生效扫描上限见 scanLimit()
|
|
859
1215
|
|
|
860
1216
|
function dirExists(p) {
|
|
861
1217
|
if (typeof p !== 'string' || p.length === 0) return false;
|
|
@@ -866,6 +1222,163 @@ function dirExists(p) {
|
|
|
866
1222
|
}
|
|
867
1223
|
}
|
|
868
1224
|
|
|
1225
|
+
function findUnlistedFilesystemArtifact(root, compression) {
|
|
1226
|
+
if (!dirExists(root)) return null;
|
|
1227
|
+
for (const project of readdirSync(root)) {
|
|
1228
|
+
if (!(project.startsWith('--') && project.endsWith('--'))) continue;
|
|
1229
|
+
const projectPath = join(root, project);
|
|
1230
|
+
if (!dirExists(projectPath)) continue;
|
|
1231
|
+
for (const idDir of readdirSync(projectPath)) {
|
|
1232
|
+
const sessionPath = join(projectPath, idDir);
|
|
1233
|
+
if (!dirExists(sessionPath)) continue;
|
|
1234
|
+
const names = readdirSync(sessionPath);
|
|
1235
|
+
const artifacts = listGenerationArtifacts(sessionPath);
|
|
1236
|
+
if (artifacts.length === 0) {
|
|
1237
|
+
if (names.length > 0) return { unknownPath: sessionPath };
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
try {
|
|
1241
|
+
return { found: findHighestArtifact(sessionPath, compression), sessionId: idDir };
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
return { error };
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* 存储布局自证(只读,永不抛错)。
|
|
1252
|
+
*
|
|
1253
|
+
* 本插件的整条管线都建立在两个**未文档化**的假设上:
|
|
1254
|
+
* ① `persistence.root` 存在(`SessionPersistence` 的文档化接口只有 create/open/stat/list);
|
|
1255
|
+
* ② 目录布局等于本地复刻的 `projectKey` / `encodeSegment`(见上面的"最小移植"注释)。
|
|
1256
|
+
* 宿主一旦改变其中任何一项,`sessionDir()` 会静默指向不存在的路径,表现为
|
|
1257
|
+
* "所有会话都找不到"而没有任何线索。
|
|
1258
|
+
*
|
|
1259
|
+
* 这里用真实会话反推一次目录:从 persistence.list() 拿 header,算出它**应该**在的位置,
|
|
1260
|
+
* 再用 findHighestArtifact 验证该位置确实有档案。对不上就是 degraded。
|
|
1261
|
+
*
|
|
1262
|
+
* @returns {{status:'ok'|'degraded'|'empty'|'unavailable', checked:number, path?:string, reason?:string}}
|
|
1263
|
+
*/
|
|
1264
|
+
async function verifyStorageLayout(ctx) {
|
|
1265
|
+
const persistence = ctx?.sessionPersistence;
|
|
1266
|
+
if (!persistence) return { status: 'unavailable', checked: 0, reason: 'session persistence service unavailable' };
|
|
1267
|
+
const adapter = createPersistenceAdapter(ctx);
|
|
1268
|
+
const root = adapter.root;
|
|
1269
|
+
if (!adapter.capabilities.physicalPath) {
|
|
1270
|
+
if (adapter.mode === 'official-handle') {
|
|
1271
|
+
let checked = 0;
|
|
1272
|
+
try { checked = (await adapter.list()).length; } catch { /* diagnostics below */ }
|
|
1273
|
+
return {
|
|
1274
|
+
status: 'ok',
|
|
1275
|
+
adapter: adapter.mode,
|
|
1276
|
+
physicalPath: false,
|
|
1277
|
+
checked,
|
|
1278
|
+
reason: adapter.reason
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
return { status: 'unavailable', checked: 0, adapter: adapter.mode, physicalPath: false, reason: adapter.reason };
|
|
1282
|
+
}
|
|
1283
|
+
let headers;
|
|
1284
|
+
try {
|
|
1285
|
+
headers = await adapter.list();
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
return { status: 'unavailable', checked: 0, reason: `persistence.list() failed: ${err?.message ?? err}` };
|
|
1288
|
+
}
|
|
1289
|
+
const compression = adapter.compression;
|
|
1290
|
+
if (headers.length === 0) {
|
|
1291
|
+
let unlisted = null;
|
|
1292
|
+
try { unlisted = findUnlistedFilesystemArtifact(root, compression); }
|
|
1293
|
+
catch (err) {
|
|
1294
|
+
return { status: 'degraded', diagnostic: 'layout-unknown', checked: 0, path: root, adapter: adapter.mode, physicalPath: true, reason: `cannot inspect session root: ${err?.message ?? err}` };
|
|
1295
|
+
}
|
|
1296
|
+
if (unlisted?.error) {
|
|
1297
|
+
const diagnostic = unlisted.error?.code === 'unsupported' ? 'unsupported' : 'layout-unknown';
|
|
1298
|
+
return { status: 'degraded', diagnostic, checked: 0, path: root, adapter: adapter.mode, physicalPath: true, reason: `${diagnostic}: ${unlisted.error?.message ?? unlisted.error}` };
|
|
1299
|
+
}
|
|
1300
|
+
if (unlisted?.unknownPath) {
|
|
1301
|
+
return { status: 'degraded', diagnostic: 'layout-unknown', checked: 0, path: root, adapter: adapter.mode, physicalPath: true, reason: `no supported archive filename under: ${unlisted.unknownPath}` };
|
|
1302
|
+
}
|
|
1303
|
+
if (unlisted?.found) {
|
|
1304
|
+
try {
|
|
1305
|
+
const bytes = await adapter.read({ kind: 'filesystem', sessionId: unlisted.sessionId, cwd: null, artifact: unlisted.found.path });
|
|
1306
|
+
const header = readHeader(bytes, compression);
|
|
1307
|
+
validateArtifactGeneration(unlisted.found.path, header);
|
|
1308
|
+
return {
|
|
1309
|
+
status: 'degraded', diagnostic: 'layout-unknown', checked: 0, path: root, adapter: adapter.mode,
|
|
1310
|
+
physicalPath: true,
|
|
1311
|
+
reason: `archive exists under ${unlisted.found.path} but persistence.list() did not return it`
|
|
1312
|
+
};
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
const diagnostic = err?.code === 'unsupported' ? 'unsupported' : 'corrupt-data';
|
|
1315
|
+
return { status: 'degraded', diagnostic, checked: 0, path: root, adapter: adapter.mode, physicalPath: true, reason: `${diagnostic}: ${err?.message ?? err}` };
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return { status: 'empty', checked: 0, path: root, adapter: adapter.mode, physicalPath: true };
|
|
1319
|
+
}
|
|
1320
|
+
// 取最新几条做样本:编码边界(多字节/分隔符/驱动器号)更容易在其中一条上暴露
|
|
1321
|
+
const sample = headers.slice(0, 5);
|
|
1322
|
+
for (const header of sample) {
|
|
1323
|
+
const { id, cwd } = header;
|
|
1324
|
+
if (id === undefined || typeof cwd !== 'string' || cwd.length === 0) continue;
|
|
1325
|
+
const expectedDir = sessionDir(root, cwd, id);
|
|
1326
|
+
let artifacts;
|
|
1327
|
+
try {
|
|
1328
|
+
artifacts = listGenerationArtifacts(expectedDir);
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
return { status: 'degraded', diagnostic: 'layout-unknown', checked: sample.length, path: root, adapter: adapter.mode, physicalPath: true, reason: `cannot inspect derived directory: ${err?.message ?? err}` };
|
|
1331
|
+
}
|
|
1332
|
+
if (artifacts.length === 0) {
|
|
1333
|
+
if (dirExists(expectedDir) && readdirSync(expectedDir).length > 0) {
|
|
1334
|
+
return {
|
|
1335
|
+
status: 'degraded', diagnostic: 'layout-unknown', checked: sample.length, path: root,
|
|
1336
|
+
adapter: adapter.mode, physicalPath: true,
|
|
1337
|
+
reason: `session directory contains no supported archive filename: ${expectedDir}`
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
const found = findHighestArtifact(expectedDir, compression);
|
|
1344
|
+
const location = { kind: 'filesystem', sessionId: String(id), cwd, artifact: found.path };
|
|
1345
|
+
const archiveHeader = readHeader(await adapter.read(location), compression);
|
|
1346
|
+
validateArtifactGeneration(found.path, archiveHeader);
|
|
1347
|
+
if (String(archiveHeader?.id) !== String(id)) throw new Error(`archive id ${archiveHeader?.id} does not match persistence.list() id ${id}`);
|
|
1348
|
+
if (typeof archiveHeader?.cwd !== 'string' || !samePath(archiveHeader.cwd, cwd)) {
|
|
1349
|
+
return {
|
|
1350
|
+
status: 'degraded', diagnostic: 'layout-unknown', checked: sample.length, path: root,
|
|
1351
|
+
adapter: adapter.mode, physicalPath: true,
|
|
1352
|
+
reason: `archive header cwd does not match persistence.list() for session ${id}`
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
const generations = artifacts.map((entry) => ({ version: entry.version, compression: entry.compression, name: entry.name }));
|
|
1356
|
+
return {
|
|
1357
|
+
status: 'ok', checked: sample.length, path: root, adapter: adapter.mode, physicalPath: true,
|
|
1358
|
+
generations,
|
|
1359
|
+
generationWarning: generations.length > 1
|
|
1360
|
+
? `multiple generations present; v${generations[0].version} is selected and older files are preserved`
|
|
1361
|
+
: null
|
|
1362
|
+
};
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
const diagnostic = err?.code === 'unsupported' ? 'unsupported' : 'corrupt-data';
|
|
1365
|
+
return {
|
|
1366
|
+
status: 'degraded', diagnostic, checked: sample.length, path: root, adapter: adapter.mode,
|
|
1367
|
+
physicalPath: true, reason: `${diagnostic}: ${err?.message ?? err}`
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return {
|
|
1372
|
+
status: 'degraded',
|
|
1373
|
+
diagnostic: 'layout-unknown',
|
|
1374
|
+
checked: sample.length,
|
|
1375
|
+
path: root,
|
|
1376
|
+
adapter: adapter.mode,
|
|
1377
|
+
physicalPath: true,
|
|
1378
|
+
reason: 'layout-unknown: no session archive found at the derived path — the host storage layout may have changed'
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
869
1382
|
/**
|
|
870
1383
|
* 从投影缓存读会话标题(只读、防御性解析)。侧边栏显示的标题(官方自动命名/重命名)
|
|
871
1384
|
* 存在投影缓存里,磁盘档案头对这类会话是空的——标题必须以这里为准才能与侧边栏一致;
|
|
@@ -900,9 +1413,15 @@ async function scanSessions(ctx) {
|
|
|
900
1413
|
const registry = ctx.workspaceRegistry;
|
|
901
1414
|
const persistence = ctx.sessionPersistence;
|
|
902
1415
|
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
903
|
-
const
|
|
1416
|
+
const storage = createPersistenceAdapter(ctx);
|
|
1417
|
+
if (!storage.capabilities.physicalPath) {
|
|
1418
|
+
const err = new Error(`${storage.reason}; physical session scanning is unavailable`);
|
|
1419
|
+
err.code = 'unsupported';
|
|
1420
|
+
throw err;
|
|
1421
|
+
}
|
|
1422
|
+
const root = storage.root;
|
|
904
1423
|
if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
|
|
905
|
-
const compression =
|
|
1424
|
+
const compression = storage.compression;
|
|
906
1425
|
|
|
907
1426
|
const entities = registry.list();
|
|
908
1427
|
let archived = new Set();
|
|
@@ -920,22 +1439,46 @@ async function scanSessions(ctx) {
|
|
|
920
1439
|
const projPath = join(root, proj);
|
|
921
1440
|
if (!dirExists(projPath)) continue;
|
|
922
1441
|
for (const idDir of readdirSync(projPath)) {
|
|
923
|
-
const
|
|
1442
|
+
const sessionPath = join(projPath, idDir);
|
|
1443
|
+
let found;
|
|
1444
|
+
let layoutError = null;
|
|
1445
|
+
try {
|
|
1446
|
+
found = findHighestArtifact(sessionPath, compression);
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
const artifacts = listGenerationArtifacts(sessionPath);
|
|
1449
|
+
if (artifacts.length === 0) continue;
|
|
1450
|
+
found = artifacts[0];
|
|
1451
|
+
layoutError = err;
|
|
1452
|
+
}
|
|
924
1453
|
if (!found) continue;
|
|
925
1454
|
const artifact = found.path;
|
|
926
1455
|
let st;
|
|
927
1456
|
try { st = statSync(artifact); } catch { continue; }
|
|
928
1457
|
if (!st.isFile()) continue;
|
|
929
1458
|
total++;
|
|
930
|
-
metas.push({ artifact, idDir, st });
|
|
1459
|
+
metas.push({ artifact, idDir, st, layoutError });
|
|
931
1460
|
}
|
|
932
1461
|
}
|
|
933
1462
|
metas.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
|
|
934
|
-
const selected = metas.slice(0,
|
|
935
|
-
for (const { artifact, idDir, st } of selected) {
|
|
1463
|
+
const selected = metas.slice(0, scanLimit());
|
|
1464
|
+
for (const { artifact, idDir, st, layoutError } of selected) {
|
|
936
1465
|
seenArtifacts.add(artifact);
|
|
937
1466
|
let header = null;
|
|
938
|
-
|
|
1467
|
+
let diagnostic = null;
|
|
1468
|
+
let diagnosticDetail = null;
|
|
1469
|
+
if (layoutError) {
|
|
1470
|
+
diagnostic = layoutError.code === 'unsupported' ? 'unsupported' : 'layout-unknown';
|
|
1471
|
+
diagnosticDetail = String(layoutError?.message ?? layoutError);
|
|
1472
|
+
} else {
|
|
1473
|
+
try {
|
|
1474
|
+
header = readHeader(await storage.read({ kind: 'filesystem', sessionId: idDir, cwd: null, artifact }), compression);
|
|
1475
|
+
validateArtifactGeneration(artifact, header);
|
|
1476
|
+
} catch (err) {
|
|
1477
|
+
header = null;
|
|
1478
|
+
diagnostic = err?.code === 'unsupported' ? 'unsupported' : 'corrupt-data';
|
|
1479
|
+
diagnosticDetail = String(err?.message ?? err);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
939
1482
|
const sessionId = String(header?.id ?? idDir);
|
|
940
1483
|
const cwd = typeof header?.cwd === 'string' ? header.cwd : null;
|
|
941
1484
|
const alive = dirExists(cwd);
|
|
@@ -943,7 +1486,7 @@ async function scanSessions(ctx) {
|
|
|
943
1486
|
// 记账方按原始 record 计算(公开 getter 会过滤掉幽灵 id);
|
|
944
1487
|
// 「误放」= 有记账方但不是 cwd 匹配的那个工作区(克隆工具、路径漂移、改名后重建分组)。
|
|
945
1488
|
const owners = entities.filter((e) => {
|
|
946
|
-
try { return
|
|
1489
|
+
try { return rawRecordSessionIds(e).includes(String(sessionId)); } catch { return false; }
|
|
947
1490
|
});
|
|
948
1491
|
const correctlyOwned = owners.length === 1 && matching !== undefined && owners[0].id === matching.id;
|
|
949
1492
|
const status = header === null ? 'unreadable'
|
|
@@ -965,6 +1508,8 @@ async function scanSessions(ctx) {
|
|
|
965
1508
|
ownerWorkspaceIds: owners.map((e) => e.id),
|
|
966
1509
|
sizeBytes: st.size,
|
|
967
1510
|
mtimeMs: st.mtimeMs,
|
|
1511
|
+
diagnostic,
|
|
1512
|
+
diagnosticDetail,
|
|
968
1513
|
archived: archived.has(sessionId)
|
|
969
1514
|
});
|
|
970
1515
|
}
|
|
@@ -974,7 +1519,7 @@ async function scanSessions(ctx) {
|
|
|
974
1519
|
let ids = [];
|
|
975
1520
|
// 幽灵正是「有账无档」的 id,而官方公开的 sessionIds getter 会把它们
|
|
976
1521
|
// 按索引过滤掉——必须遍历原始记账 record.sessionIds 才能看见它们。
|
|
977
|
-
try { ids =
|
|
1522
|
+
try { ids = rawRecordSessionIds(entity); } catch { continue; }
|
|
978
1523
|
for (const id of ids) {
|
|
979
1524
|
try {
|
|
980
1525
|
if (!existsSync(artifactPath(root, entity.path, id, compression))) ghosts.push({ workspaceId: entity.id, sessionId: String(id) });
|
|
@@ -986,6 +1531,7 @@ async function scanSessions(ctx) {
|
|
|
986
1531
|
return {
|
|
987
1532
|
root,
|
|
988
1533
|
scanned: total,
|
|
1534
|
+
scannedParsed: items.length,
|
|
989
1535
|
truncated: total > items.length,
|
|
990
1536
|
recoveryCount: recoveryCount(),
|
|
991
1537
|
counts: items.reduce((acc, it) => { acc[it.status] = (acc[it.status] ?? 0) + 1; return acc; }, {}),
|
|
@@ -997,7 +1543,10 @@ async function scanSessions(ctx) {
|
|
|
997
1543
|
/** 仅拒绝回合进行中的会话(与宿主 UI「进行中」徽标同款判据)。 */
|
|
998
1544
|
function assertNotRunning(ctx, sessionId) {
|
|
999
1545
|
if (peekService(ctx, 'agents')?.get?.(sessionId)?.status === 'running') {
|
|
1000
|
-
|
|
1546
|
+
const err = new Error('session is currently running; queued migration will retry after it becomes idle');
|
|
1547
|
+
err.code = 'busy';
|
|
1548
|
+
err.reason = 'session-running';
|
|
1549
|
+
throw err;
|
|
1001
1550
|
}
|
|
1002
1551
|
}
|
|
1003
1552
|
|
|
@@ -1074,11 +1623,448 @@ async function attachUnregistered(ctx, sessionId) { const registry = ctx.workspa
|
|
|
1074
1623
|
return { workspaceId: entity.id, path: entity.path };
|
|
1075
1624
|
}
|
|
1076
1625
|
|
|
1626
|
+
function parentSessionId(header) {
|
|
1627
|
+
for (const key of ['parentSessionId', 'parentId', 'parentSession', 'forkOf']) {
|
|
1628
|
+
const value = header?.[key];
|
|
1629
|
+
if (typeof value === 'string' && value.length > 0) return value;
|
|
1630
|
+
}
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
async function collectDescendantSessions(ctx, rootSessionId, adapter = createPersistenceAdapter(ctx)) {
|
|
1635
|
+
const headers = await adapter.list();
|
|
1636
|
+
const children = new Map();
|
|
1637
|
+
for (const header of headers) {
|
|
1638
|
+
const childId = String(header?.id ?? '');
|
|
1639
|
+
const parentId = parentSessionId(header);
|
|
1640
|
+
if (!childId || !parentId) continue;
|
|
1641
|
+
const list = children.get(parentId) ?? [];
|
|
1642
|
+
list.push({ sessionId: childId, title: header?.title ?? null, parentSessionId: parentId });
|
|
1643
|
+
children.set(parentId, list);
|
|
1644
|
+
}
|
|
1645
|
+
const ids = [String(rootSessionId)];
|
|
1646
|
+
const relations = [];
|
|
1647
|
+
const seen = new Set(ids);
|
|
1648
|
+
for (let cursor = 0; cursor < ids.length; cursor++) {
|
|
1649
|
+
for (const child of children.get(ids[cursor]) ?? []) {
|
|
1650
|
+
if (seen.has(child.sessionId)) continue;
|
|
1651
|
+
seen.add(child.sessionId);
|
|
1652
|
+
ids.push(child.sessionId);
|
|
1653
|
+
relations.push(child);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
return { ids, relations };
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const PORTABLE_BUNDLE_VERSION = 1;
|
|
1660
|
+
const PLUGIN_VERSION = '2.2.0';
|
|
1661
|
+
|
|
1662
|
+
function bundleSafeJoin(root, ...parts) {
|
|
1663
|
+
const base = resolve(root);
|
|
1664
|
+
const target = resolve(base, ...parts);
|
|
1665
|
+
const rel = relative(base, target);
|
|
1666
|
+
if (rel === '..' || rel.startsWith('..\\') || rel.startsWith('../') || resolve(base, rel) !== target) {
|
|
1667
|
+
const err = new Error(`bundle path escapes its root: ${parts.join('/')}`);
|
|
1668
|
+
err.code = 'invalid-input';
|
|
1669
|
+
throw err;
|
|
1670
|
+
}
|
|
1671
|
+
return target;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function bundleRelative(path) {
|
|
1675
|
+
return String(path).replaceAll('\\', '/');
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function sha256Bytes(bytes) {
|
|
1679
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function walkFiles(root, prefix = '') {
|
|
1683
|
+
const out = [];
|
|
1684
|
+
for (const name of readdirSync(root)) {
|
|
1685
|
+
const path = join(root, name);
|
|
1686
|
+
const rel = prefix ? join(prefix, name) : name;
|
|
1687
|
+
let st;
|
|
1688
|
+
try { st = statSync(path); } catch { continue; }
|
|
1689
|
+
if (st.isDirectory()) out.push(...walkFiles(path, rel));
|
|
1690
|
+
else if (st.isFile()) out.push({ path, relativePath: bundleRelative(rel), sizeBytes: st.size });
|
|
1691
|
+
}
|
|
1692
|
+
return out;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function bundleManifestPath(bundlePath) {
|
|
1696
|
+
if (typeof bundlePath !== 'string' || bundlePath.trim().length === 0) throw new Error('bundlePath required');
|
|
1697
|
+
return resolve(bundlePath);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function loadPortableBundle(bundlePath) {
|
|
1701
|
+
const root = bundleManifestPath(bundlePath);
|
|
1702
|
+
const manifestPath = bundleSafeJoin(root, 'manifest.json');
|
|
1703
|
+
if (!existsSync(manifestPath)) throw new Error(`bundle manifest missing: ${manifestPath}`);
|
|
1704
|
+
let manifest;
|
|
1705
|
+
try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch (err) { throw new Error(`bundle manifest is invalid: ${err?.message ?? err}`); }
|
|
1706
|
+
if (manifest?.version !== PORTABLE_BUNDLE_VERSION || !Array.isArray(manifest.sessions)) {
|
|
1707
|
+
const err = new Error(`unsupported portable bundle manifest version: ${manifest?.version ?? 'unknown'}`);
|
|
1708
|
+
err.code = 'unsupported';
|
|
1709
|
+
throw err;
|
|
1710
|
+
}
|
|
1711
|
+
let checksums = {};
|
|
1712
|
+
try {
|
|
1713
|
+
const checksumPath = bundleSafeJoin(root, 'checksums.json');
|
|
1714
|
+
if (existsSync(checksumPath)) checksums = JSON.parse(readFileSync(checksumPath, 'utf8'));
|
|
1715
|
+
} catch (err) {
|
|
1716
|
+
throw new Error(`bundle checksums are invalid: ${err?.message ?? err}`);
|
|
1717
|
+
}
|
|
1718
|
+
const files = [];
|
|
1719
|
+
const missing = [];
|
|
1720
|
+
const checksumMismatches = [];
|
|
1721
|
+
for (const session of manifest.sessions) {
|
|
1722
|
+
if (typeof session?.sessionId !== 'string' || !Array.isArray(session.files)) continue;
|
|
1723
|
+
for (const item of session.files) {
|
|
1724
|
+
if (typeof item?.bundlePath !== 'string') continue;
|
|
1725
|
+
const path = bundleSafeJoin(root, item.bundlePath);
|
|
1726
|
+
if (!existsSync(path)) {
|
|
1727
|
+
missing.push(item.bundlePath);
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
const bytes = readFileSync(path);
|
|
1731
|
+
const expected = checksums[item.bundlePath] ?? item.sha256 ?? null;
|
|
1732
|
+
if (expected && expected !== sha256Bytes(bytes)) checksumMismatches.push(item.bundlePath);
|
|
1733
|
+
files.push({ ...item, sessionId: session.sessionId, path, bytes });
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
return { root, manifest, checksums, files, missing, checksumMismatches };
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function workspaceForSession(registry, sessionId, cwd = null) {
|
|
1740
|
+
try {
|
|
1741
|
+
return registry?.list?.().find((entity) => rawRecordSessionIds(entity).includes(String(sessionId)))
|
|
1742
|
+
?? registry?.list?.().find((entity) => cwd && samePath(entity.path, cwd))
|
|
1743
|
+
?? null;
|
|
1744
|
+
} catch { return null; }
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
async function exportPortableBundle(ctx, { sessionId, bundlePath, mode = 'current-only' } = {}) {
|
|
1748
|
+
const rootSessionId = validId(sessionId, 'sessionId');
|
|
1749
|
+
if (!['current-only', 'include-descendants'].includes(mode)) throw new Error(`unknown fork migration mode '${mode}'`);
|
|
1750
|
+
const registry = ctx.workspaceRegistry;
|
|
1751
|
+
const storage = createPersistenceAdapter(ctx);
|
|
1752
|
+
if (!storage.capabilities.physicalPath) {
|
|
1753
|
+
const err = new Error(`${storage.reason}; portable export needs a physical session path`);
|
|
1754
|
+
err.code = 'unsupported';
|
|
1755
|
+
throw err;
|
|
1756
|
+
}
|
|
1757
|
+
const destination = bundleManifestPath(bundlePath);
|
|
1758
|
+
if (existsSync(destination) && readdirSync(destination).length > 0) throw new Error(`bundle destination is not empty: ${destination}`);
|
|
1759
|
+
const headers = await storage.list();
|
|
1760
|
+
const tree = mode === 'include-descendants' ? await collectDescendantSessions(ctx, rootSessionId, storage) : { ids: [rootSessionId], relations: [] };
|
|
1761
|
+
const sessions = [];
|
|
1762
|
+
const checksums = {};
|
|
1763
|
+
const attachmentRelations = [];
|
|
1764
|
+
const manifest = {
|
|
1765
|
+
version: PORTABLE_BUNDLE_VERSION,
|
|
1766
|
+
pluginVersion: PLUGIN_VERSION,
|
|
1767
|
+
dshVersion: ctx.dshVersion ?? ctx.version ?? null,
|
|
1768
|
+
exportedAt: new Date().toISOString(),
|
|
1769
|
+
rootSessionId,
|
|
1770
|
+
mode,
|
|
1771
|
+
sessions: [],
|
|
1772
|
+
attachments: attachmentRelations
|
|
1773
|
+
};
|
|
1774
|
+
try {
|
|
1775
|
+
mkdirSync(destination, { recursive: true });
|
|
1776
|
+
for (const id of tree.ids) {
|
|
1777
|
+
const meta = headers.find((header) => String(header?.id) === id);
|
|
1778
|
+
if (!meta) throw new Error(`session '${id}' not found in session persistence`);
|
|
1779
|
+
const location = await storage.locate({ sessionId: id, cwd: meta.cwd });
|
|
1780
|
+
const info = await storage.inspect(location);
|
|
1781
|
+
if (!info.exists) throw new Error(`session archive missing for '${id}'`);
|
|
1782
|
+
const bytes = await storage.read(location);
|
|
1783
|
+
const parsed = validateArtifactGeneration(location.artifact, readHeader(bytes, storage.compression));
|
|
1784
|
+
const sessionRoot = bundleSafeJoin(destination, 'sessions', encodeSegment(id));
|
|
1785
|
+
mkdirSync(sessionRoot, { recursive: true });
|
|
1786
|
+
const artifactName = basename(location.artifact);
|
|
1787
|
+
const artifactRel = bundleRelative(join('sessions', encodeSegment(id), artifactName));
|
|
1788
|
+
const artifactPathInBundle = bundleSafeJoin(destination, artifactRel);
|
|
1789
|
+
writeFileSync(artifactPathInBundle, bytes);
|
|
1790
|
+
checksums[artifactRel] = sha256Bytes(bytes);
|
|
1791
|
+
const sourceWorkspace = workspaceForSession(registry, id, meta.cwd);
|
|
1792
|
+
const sessionRecord = {
|
|
1793
|
+
sessionId: id,
|
|
1794
|
+
title: meta.title ?? null,
|
|
1795
|
+
parentSessionId: parentSessionId(meta),
|
|
1796
|
+
originalWorkspace: sourceWorkspace ? { id: sourceWorkspace.id, title: sourceWorkspace.title, path: sourceWorkspace.path } : null,
|
|
1797
|
+
format: { generation: parsed.filename?.version ?? 0, compression: parsed.filename?.compression ?? storage.compression, artifactName },
|
|
1798
|
+
files: [{ bundlePath: artifactRel, kind: 'session', originalPath: location.artifact, bytes: bytes.length, sha256: checksums[artifactRel] }],
|
|
1799
|
+
attachments: []
|
|
1800
|
+
};
|
|
1801
|
+
for (const extra of walkFiles(dirname(location.artifact))) {
|
|
1802
|
+
if (extra.path === location.artifact || parseGenerationLogFilename(basename(extra.path))) continue;
|
|
1803
|
+
const rel = bundleRelative(join('attachments', encodeSegment(id), extra.relativePath));
|
|
1804
|
+
const target = bundleSafeJoin(destination, rel);
|
|
1805
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1806
|
+
copyFileSync(extra.path, target);
|
|
1807
|
+
checksums[rel] = sha256Bytes(readFileSync(target));
|
|
1808
|
+
const attachment = { sessionId: id, bundlePath: rel, relativePath: extra.relativePath, bytes: extra.sizeBytes, sha256: checksums[rel] };
|
|
1809
|
+
sessionRecord.files.push({ ...attachment, kind: 'attachment', originalPath: extra.path });
|
|
1810
|
+
sessionRecord.attachments.push(attachment);
|
|
1811
|
+
attachmentRelations.push(attachment);
|
|
1812
|
+
}
|
|
1813
|
+
manifest.sessions.push(sessionRecord);
|
|
1814
|
+
}
|
|
1815
|
+
writeFileSync(bundleSafeJoin(destination, 'workspace-metadata.json'), JSON.stringify({ sessions: manifest.sessions.map((item) => item.originalWorkspace), exportedAt: manifest.exportedAt }, null, 2) + '\n');
|
|
1816
|
+
writeFileSync(bundleSafeJoin(destination, 'checksums.json'), JSON.stringify(checksums, null, 2) + '\n');
|
|
1817
|
+
writeFileSync(bundleSafeJoin(destination, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
try { rmSync(destination, { recursive: true, force: true }); } catch { /* preserve the original error */ }
|
|
1820
|
+
throw err;
|
|
1821
|
+
}
|
|
1822
|
+
return { exported: true, bundlePath: destination, sessionCount: manifest.sessions.length, attachmentCount: attachmentRelations.length, manifest };
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
async function inspectPortableBundle(ctx, { bundlePath, targetWorkspaceId } = {}) {
|
|
1826
|
+
const targetId = validId(targetWorkspaceId, 'targetWorkspaceId');
|
|
1827
|
+
const registry = ctx.workspaceRegistry;
|
|
1828
|
+
const target = registry?.get?.(targetId);
|
|
1829
|
+
if (!target) throw new Error(`unknown workspace '${targetId}'`);
|
|
1830
|
+
const storage = createPersistenceAdapter(ctx);
|
|
1831
|
+
const bundle = loadPortableBundle(bundlePath);
|
|
1832
|
+
if (!storage.capabilities.physicalPath) {
|
|
1833
|
+
return {
|
|
1834
|
+
status: 'blocked',
|
|
1835
|
+
bundlePath: bundle.root,
|
|
1836
|
+
targetWorkspace: { id: target.id, title: target.title, path: target.path },
|
|
1837
|
+
sessions: [],
|
|
1838
|
+
conflicts: [],
|
|
1839
|
+
missingFiles: bundle.missing,
|
|
1840
|
+
checksumMismatches: bundle.checksumMismatches,
|
|
1841
|
+
missingAttachments: [],
|
|
1842
|
+
formatIncompatible: [{ reason: `${storage.reason}; physical bundle import is unavailable` }],
|
|
1843
|
+
requiredBytes: 0,
|
|
1844
|
+
freeBytes: null,
|
|
1845
|
+
recoverableFiles: bundle.files.length - bundle.checksumMismatches.length,
|
|
1846
|
+
adapter: storageDiagnostics(ctx)
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
const conflicts = [];
|
|
1850
|
+
const incompatible = [];
|
|
1851
|
+
const sessions = [];
|
|
1852
|
+
let requiredBytes = 0;
|
|
1853
|
+
for (const session of bundle.manifest.sessions) {
|
|
1854
|
+
const artifact = bundle.files.find((item) => item.sessionId === session.sessionId && item.kind === 'session');
|
|
1855
|
+
if (!artifact) continue;
|
|
1856
|
+
requiredBytes += Number(artifact.bytes?.length ?? artifact.sizeBytes ?? 0);
|
|
1857
|
+
for (const attachment of session.attachments ?? []) {
|
|
1858
|
+
requiredBytes += Number(attachment.bytes ?? attachment.sizeBytes ?? 0);
|
|
1859
|
+
}
|
|
1860
|
+
if (session.format?.compression && session.format.compression !== storage.compression) incompatible.push({ sessionId: session.sessionId, reason: `compression ${session.format.compression} != target ${storage.compression}` });
|
|
1861
|
+
let destinationArtifact = null;
|
|
1862
|
+
try { destinationArtifact = findDestinationArtifact(sessionDir(storage.root, target.path, session.sessionId), storage.compression); } catch (err) { conflicts.push({ sessionId: session.sessionId, reason: String(err?.message ?? err) }); }
|
|
1863
|
+
if (destinationArtifact) conflicts.push({ sessionId: session.sessionId, reason: 'target archive already exists' });
|
|
1864
|
+
if (workspaceForSession(registry, session.sessionId)) conflicts.push({ sessionId: session.sessionId, reason: 'session id is already registered' });
|
|
1865
|
+
sessions.push({ sessionId: session.sessionId, title: session.title ?? null, bytes: artifact.bytes?.length ?? 0, attachments: session.attachments?.length ?? 0, conflict: Boolean(destinationArtifact) });
|
|
1866
|
+
}
|
|
1867
|
+
let freeBytes = null;
|
|
1868
|
+
try { const st = statfsSync(target.path); freeBytes = Number(st.bfree) * Number(st.bsize || 4096); } catch { /* report unknown free space */ }
|
|
1869
|
+
const missingAttachments = bundle.manifest.attachments.filter((item) => bundle.missing.includes(item.bundlePath));
|
|
1870
|
+
const status = bundle.missing.length || bundle.checksumMismatches.length || conflicts.length || incompatible.length
|
|
1871
|
+
? 'blocked'
|
|
1872
|
+
: (freeBytes !== null && freeBytes < requiredBytes * 2)
|
|
1873
|
+
? 'blocked'
|
|
1874
|
+
: missingAttachments.length
|
|
1875
|
+
? 'warning'
|
|
1876
|
+
: 'safe';
|
|
1877
|
+
return {
|
|
1878
|
+
status,
|
|
1879
|
+
bundlePath: bundle.root,
|
|
1880
|
+
targetWorkspace: { id: target.id, title: target.title, path: target.path },
|
|
1881
|
+
sessions,
|
|
1882
|
+
conflicts,
|
|
1883
|
+
missingFiles: bundle.missing,
|
|
1884
|
+
checksumMismatches: bundle.checksumMismatches,
|
|
1885
|
+
missingAttachments,
|
|
1886
|
+
formatIncompatible: incompatible,
|
|
1887
|
+
requiredBytes,
|
|
1888
|
+
freeBytes,
|
|
1889
|
+
recoverableFiles: bundle.files.length - bundle.checksumMismatches.length,
|
|
1890
|
+
adapter: storageDiagnostics(ctx)
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
async function importPortableBundle(ctx, { bundlePath, targetWorkspaceId, dryRun = true } = {}) {
|
|
1895
|
+
const plan = await inspectPortableBundle(ctx, { bundlePath, targetWorkspaceId });
|
|
1896
|
+
if (dryRun !== false) return { ...plan, dryRun: true, imported: false };
|
|
1897
|
+
if (plan.status === 'blocked') {
|
|
1898
|
+
const err = new Error('portable bundle dry-run is blocked; resolve conflicts, missing files, and format incompatibilities first');
|
|
1899
|
+
err.code = 'conflict';
|
|
1900
|
+
throw err;
|
|
1901
|
+
}
|
|
1902
|
+
const bundle = loadPortableBundle(bundlePath);
|
|
1903
|
+
const storage = createPersistenceAdapter(ctx);
|
|
1904
|
+
const target = ctx.workspaceRegistry.get(targetWorkspaceId);
|
|
1905
|
+
const results = [];
|
|
1906
|
+
for (const session of bundle.manifest.sessions) {
|
|
1907
|
+
const artifact = bundle.files.find((item) => item.sessionId === session.sessionId && item.kind === 'session');
|
|
1908
|
+
if (!artifact) continue;
|
|
1909
|
+
const dstDir = sessionDir(storage.root, target.path, session.sessionId);
|
|
1910
|
+
const dstArtifact = join(dstDir, basename(session.format?.artifactName || basename(artifact.bundlePath)));
|
|
1911
|
+
let created = false;
|
|
1912
|
+
const missingAttachments = [];
|
|
1913
|
+
try {
|
|
1914
|
+
const header = readHeader(artifact.bytes, storage.compression);
|
|
1915
|
+
validateArtifactGeneration(dstArtifact, header);
|
|
1916
|
+
const finalBytes = samePath(header.cwd, target.path) ? artifact.bytes : rewriteHeaderCwd(artifact.bytes, target.path, storage.compression);
|
|
1917
|
+
stashBackup(session.sessionId, artifact.bytes, { compression: storage.compression, ignoreCwd: true });
|
|
1918
|
+
mkdirSync(dstDir, { recursive: true });
|
|
1919
|
+
await storage.write({ kind: 'filesystem', sessionId: session.sessionId, cwd: target.path, artifact: dstArtifact }, finalBytes);
|
|
1920
|
+
created = true;
|
|
1921
|
+
for (const attachment of session.attachments ?? []) {
|
|
1922
|
+
const source = bundleSafeJoin(bundle.root, attachment.bundlePath);
|
|
1923
|
+
if (!existsSync(source)) {
|
|
1924
|
+
missingAttachments.push(attachment.bundlePath);
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
const destination = bundleSafeJoin(dstDir, attachment.relativePath);
|
|
1928
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
1929
|
+
copyFileSync(source, destination);
|
|
1930
|
+
}
|
|
1931
|
+
verifyRelocatedBytes(await storage.read({ kind: 'filesystem', sessionId: session.sessionId, cwd: target.path, artifact: dstArtifact }), session.sessionId, target.path, storage.compression);
|
|
1932
|
+
await target.attachSession(session.sessionId);
|
|
1933
|
+
results.push({ sessionId: session.sessionId, ok: true, targetWorkspaceId, missingAttachments });
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
if (created) { try { rmSync(dstDir, { recursive: true, force: true }); } catch { /* preserve recovery bytes */ } }
|
|
1936
|
+
results.push({ sessionId: session.sessionId, ok: false, error: String(err?.message ?? err) });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
return { ...plan, dryRun: false, imported: true, results, importedCount: results.filter((item) => item.ok).length, failedCount: results.filter((item) => !item.ok).length };
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/** Read-only move plan used by the client confirmation dialog and agent callers. */
|
|
1943
|
+
async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'current-only' } = {}) {
|
|
1944
|
+
const id = validId(sessionId, 'sessionId');
|
|
1945
|
+
const targetId = validId(targetWorkspaceId, 'targetWorkspaceId');
|
|
1946
|
+
const registry = ctx.workspaceRegistry;
|
|
1947
|
+
const persistence = ctx.sessionPersistence;
|
|
1948
|
+
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
1949
|
+
const target = registry.get(targetId);
|
|
1950
|
+
if (!target) throw new Error(`unknown workspace '${targetId}'`);
|
|
1951
|
+
const adapter = createPersistenceAdapter(ctx);
|
|
1952
|
+
if (!['current-only', 'include-descendants'].includes(mode)) throw new Error(`unknown fork migration mode '${mode}'`);
|
|
1953
|
+
const headers = await adapter.list();
|
|
1954
|
+
const meta = headers.find((header) => String(header?.id) === id);
|
|
1955
|
+
if (!meta) throw new Error(`session '${id}' not found in session persistence`);
|
|
1956
|
+
if (typeof meta.cwd !== 'string' || meta.cwd.length === 0) throw new Error('session header carries no cwd');
|
|
1957
|
+
const sourceWorkspace = registry.list().find((entity) => {
|
|
1958
|
+
try { return samePath(entity.path, meta.cwd); } catch { return false; }
|
|
1959
|
+
});
|
|
1960
|
+
const running = peekService(ctx, 'agents')?.get?.(id)?.status === 'running';
|
|
1961
|
+
const branches = mode === 'include-descendants'
|
|
1962
|
+
? await collectDescendantSessions(ctx, id, adapter)
|
|
1963
|
+
: { ids: [id], relations: [] };
|
|
1964
|
+
const warnings = [];
|
|
1965
|
+
const source = {
|
|
1966
|
+
workspaceId: sourceWorkspace?.id ?? null,
|
|
1967
|
+
cwd: meta.cwd,
|
|
1968
|
+
sizeBytes: 0,
|
|
1969
|
+
generation: null,
|
|
1970
|
+
compression: adapter.compression,
|
|
1971
|
+
path: null
|
|
1972
|
+
};
|
|
1973
|
+
const targetInfo = {
|
|
1974
|
+
workspaceId: targetId,
|
|
1975
|
+
path: target.path,
|
|
1976
|
+
collision: false,
|
|
1977
|
+
writable: false
|
|
1978
|
+
};
|
|
1979
|
+
const sameWorkspace = samePath(meta.cwd, target.path);
|
|
1980
|
+
const repairMembership = sameWorkspace && !rawRecordSessionIds(target).includes(id);
|
|
1981
|
+
let backupPlan = { reused: false, estimatedBytes: 0 };
|
|
1982
|
+
let status = 'safe';
|
|
1983
|
+
if (running) {
|
|
1984
|
+
status = 'queued';
|
|
1985
|
+
warnings.push('session is currently running and will wait for idle');
|
|
1986
|
+
}
|
|
1987
|
+
if (adapter.mode === 'filesystem-fallback') warnings.push('physical move uses the JSONL path fallback; official raw-byte path API is unavailable');
|
|
1988
|
+
if (!adapter.capabilities.physicalPath) {
|
|
1989
|
+
status = 'blocked';
|
|
1990
|
+
warnings.push('provider exposes public handles but no physical raw-byte path; this version cannot relocate the archive directory');
|
|
1991
|
+
} else {
|
|
1992
|
+
const location = await adapter.locate({ sessionId: id, cwd: meta.cwd });
|
|
1993
|
+
const info = await adapter.inspect(location);
|
|
1994
|
+
source.path = location.artifact;
|
|
1995
|
+
source.sizeBytes = info.sizeBytes ?? 0;
|
|
1996
|
+
try { source.generation = parseGenerationLogFilename(basename(location.artifact))?.version ?? null; } catch { /* diagnostic only */ }
|
|
1997
|
+
source.generations = info.generations ?? [];
|
|
1998
|
+
if (source.generations.length > 1) {
|
|
1999
|
+
warnings.push(`multiple generations found; v${source.generation} is selected and older files are preserved`);
|
|
2000
|
+
}
|
|
2001
|
+
if (!info.exists) {
|
|
2002
|
+
status = 'blocked';
|
|
2003
|
+
warnings.push('source session archive is missing or unreadable');
|
|
2004
|
+
}
|
|
2005
|
+
if (!sameWorkspace && info.exists) backupPlan = await inspectBackupPlan(id, location, info, adapter.compression);
|
|
2006
|
+
const targetDir = sessionDir(adapter.root, target.path, id);
|
|
2007
|
+
try {
|
|
2008
|
+
const targetArtifact = findDestinationArtifact(targetDir, adapter.compression);
|
|
2009
|
+
targetInfo.collision = Boolean(targetArtifact) && !sameWorkspace;
|
|
2010
|
+
targetInfo.writable = !targetArtifact;
|
|
2011
|
+
if (targetInfo.collision) {
|
|
2012
|
+
status = 'blocked';
|
|
2013
|
+
warnings.push('target workspace already contains an archive with this session id');
|
|
2014
|
+
}
|
|
2015
|
+
} catch (err) {
|
|
2016
|
+
targetInfo.writable = false;
|
|
2017
|
+
status = 'blocked';
|
|
2018
|
+
warnings.push(String(err?.message ?? err));
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (sameWorkspace) {
|
|
2022
|
+
if (repairMembership && status !== 'blocked') {
|
|
2023
|
+
warnings.push('session archive is already at target; workspace membership will be repaired');
|
|
2024
|
+
} else {
|
|
2025
|
+
status = 'blocked';
|
|
2026
|
+
warnings.push('session already belongs to the target workspace');
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
if (branches.relations.length > 0) warnings.push(`${branches.relations.length} descendant session(s) selected`);
|
|
2030
|
+
return {
|
|
2031
|
+
status,
|
|
2032
|
+
canMove: status === 'safe' || status === 'warning' || status === 'queued',
|
|
2033
|
+
sessionId: id,
|
|
2034
|
+
title: meta.title ?? null,
|
|
2035
|
+
source,
|
|
2036
|
+
target: targetInfo,
|
|
2037
|
+
running,
|
|
2038
|
+
branches: { available: true, count: branches.relations.length, mode, sessionIds: branches.ids, relations: branches.relations },
|
|
2039
|
+
estimatedBackupBytes: sameWorkspace ? 0 : backupPlan.estimatedBytes,
|
|
2040
|
+
backup: sameWorkspace ? { reused: false, estimatedBytes: 0 } : backupPlan,
|
|
2041
|
+
adapter: storageDiagnostics(ctx),
|
|
2042
|
+
warnings
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
|
|
1077
2046
|
/** 批量迁移入口:逐会话复用 moveSession(各自独立备份/回滚,单条失败不牵连其余);历史聚合成一条。 */
|
|
1078
|
-
async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
|
|
2047
|
+
async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only' } = {}) {
|
|
1079
2048
|
if (!Array.isArray(sessions) || sessions.length === 0) throw new Error('sessions must be a non-empty array');
|
|
1080
|
-
if (sessions.length >
|
|
2049
|
+
if (sessions.length > batchLimit()) throw new Error(`too many sessions in one batch (max ${batchLimit()}, got ${sessions.length})`);
|
|
1081
2050
|
if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
|
|
2051
|
+
if (!['current-only', 'include-descendants'].includes(mode)) throw new Error(`unknown fork migration mode '${mode}'`);
|
|
2052
|
+
const forkRelations = [];
|
|
2053
|
+
if (mode === 'include-descendants') {
|
|
2054
|
+
const expanded = [];
|
|
2055
|
+
const adapter = createPersistenceAdapter(ctx);
|
|
2056
|
+
for (const item of sessions) {
|
|
2057
|
+
const sessionId = validId(item?.sessionId, 'sessionId');
|
|
2058
|
+
const tree = await collectDescendantSessions(ctx, sessionId, adapter);
|
|
2059
|
+
expanded.push({ ...item, sessionId });
|
|
2060
|
+
for (const descendant of tree.relations) {
|
|
2061
|
+
expanded.push({ sessionId: descendant.sessionId, sessionTitle: descendant.title });
|
|
2062
|
+
forkRelations.push(descendant);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
sessions = expanded;
|
|
2066
|
+
if (sessions.length > batchLimit()) throw new Error(`fork tree expands beyond batch limit ${batchLimit()}`);
|
|
2067
|
+
}
|
|
1082
2068
|
// 批内去重:同一会话在一次批量里只处理一次
|
|
1083
2069
|
const seenIds = new Set();
|
|
1084
2070
|
sessions = sessions.filter((item) => {
|
|
@@ -1090,6 +2076,7 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
|
|
|
1090
2076
|
const registry = ctx.workspaceRegistry;
|
|
1091
2077
|
const results = [];
|
|
1092
2078
|
const moved = [];
|
|
2079
|
+
let queuedCount = 0;
|
|
1093
2080
|
let movedCount = 0;
|
|
1094
2081
|
let attachedCount = 0;
|
|
1095
2082
|
for (const item of sessions) {
|
|
@@ -1110,10 +2097,21 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
|
|
|
1110
2097
|
try {
|
|
1111
2098
|
sourceWorkspaceId = registry?.list?.().find((e) => { try { return samePath(e.path, result.from.cwd); } catch { return false; } })?.id ?? null;
|
|
1112
2099
|
} catch { sourceWorkspaceId = null; }
|
|
1113
|
-
moved.push({ sessionId, title: result.title, fromCwd: result.from.cwd, sourceWorkspaceId });
|
|
2100
|
+
moved.push({ sessionId, title: result.title, fromCwd: result.from.cwd, sourceWorkspaceId, backup: result.backup ?? null });
|
|
1114
2101
|
results.push({ sessionId, ok: true, moved: result.moved, to: result.to.cwd, title: result.title });
|
|
1115
2102
|
} catch (err) {
|
|
1116
|
-
|
|
2103
|
+
if (isBusyError(err)) {
|
|
2104
|
+
const queued = createOrReuseMigration(ctx, {
|
|
2105
|
+
sessionId,
|
|
2106
|
+
targetWorkspaceId,
|
|
2107
|
+
title: typeof item.sessionTitle === 'string' ? item.sessionTitle : null,
|
|
2108
|
+
reason: String(err?.message ?? err)
|
|
2109
|
+
});
|
|
2110
|
+
queuedCount++;
|
|
2111
|
+
results.push({ sessionId, ok: false, queued: true, state: 'queued', queueTaskId: queued.id, error: queued.lastError });
|
|
2112
|
+
} else {
|
|
2113
|
+
results.push({ sessionId, ok: false, error: err?.message ?? String(err) });
|
|
2114
|
+
}
|
|
1117
2115
|
}
|
|
1118
2116
|
}
|
|
1119
2117
|
let historyId = null;
|
|
@@ -1131,15 +2129,16 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
|
|
|
1131
2129
|
} catch (err) {
|
|
1132
2130
|
ctx.logger?.warn?.(`workspace-mover: task record write failed: ${err?.message ?? err}`);
|
|
1133
2131
|
}
|
|
1134
|
-
return { results, movedCount, attachedCount, failedCount: results.length - movedCount - attachedCount, historyId, taskId };
|
|
2132
|
+
return { results, movedCount, attachedCount, queuedCount, failedCount: results.length - movedCount - attachedCount, historyId, taskId, mode, forkRelations };
|
|
1135
2133
|
}
|
|
1136
2134
|
|
|
1137
|
-
//#region
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
// (记录里的 sourceCwd 仅作展示),因此用户在批后手动搬动过会话也不会用错路径。
|
|
2135
|
+
//#region 迁移任务中心与 busy 队列
|
|
2136
|
+
// tasks.json 记录已经执行的批量;migrations.json 记录尚未完成的 busy 迁移。
|
|
2137
|
+
// 两者都使用真实 session id,在重试时重新解析当前档案位置,不信任旧路径。
|
|
1141
2138
|
const TASKS_FILE = 'tasks.json';
|
|
1142
2139
|
const TASKS_LIMIT = 50;
|
|
2140
|
+
const MIGRATIONS_FILE = 'migrations.json';
|
|
2141
|
+
const MIGRATIONS_LIMIT = 200;
|
|
1143
2142
|
|
|
1144
2143
|
function tasksPath() {
|
|
1145
2144
|
return join(pluginDataDir(), TASKS_FILE);
|
|
@@ -1159,6 +2158,305 @@ function writeTasks(tasks) {
|
|
|
1159
2158
|
atomicWrite(tasksPath(), Buffer.from(JSON.stringify({ version: 1, tasks: tasks.slice(0, TASKS_LIMIT) }, null, 2) + '\n', 'utf8'));
|
|
1160
2159
|
}
|
|
1161
2160
|
|
|
2161
|
+
function migrationsPath() {
|
|
2162
|
+
return join(pluginDataDir(), MIGRATIONS_FILE);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
function readMigrations() {
|
|
2166
|
+
if (!existsSync(migrationsPath())) return [];
|
|
2167
|
+
let value;
|
|
2168
|
+
try {
|
|
2169
|
+
value = JSON.parse(readFileSync(migrationsPath(), 'utf8'));
|
|
2170
|
+
} catch (cause) {
|
|
2171
|
+
const err = new Error(`migration queue data is corrupt: ${cause?.message ?? cause}`);
|
|
2172
|
+
err.code = 'data-corrupt';
|
|
2173
|
+
throw err;
|
|
2174
|
+
}
|
|
2175
|
+
const validStates = new Set(['queued', 'waiting-for-idle', 'retrying', 'done', 'failed', 'cancelled']);
|
|
2176
|
+
if (value?.version !== 1 || !Array.isArray(value?.tasks) || value.tasks.some((task) =>
|
|
2177
|
+
!task || typeof task.id !== 'string' || typeof task.sessionId !== 'string'
|
|
2178
|
+
|| typeof task.targetWorkspaceId !== 'string' || !validStates.has(task.state))) {
|
|
2179
|
+
const err = new Error('migration queue data is corrupt: invalid schema');
|
|
2180
|
+
err.code = 'data-corrupt';
|
|
2181
|
+
throw err;
|
|
2182
|
+
}
|
|
2183
|
+
return value.tasks;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function migrationQueueSummary() {
|
|
2187
|
+
const counts = { total: 0, queued: 0, waitingForIdle: 0, retrying: 0, failed: 0, done: 0, cancelled: 0 };
|
|
2188
|
+
let tasks;
|
|
2189
|
+
try {
|
|
2190
|
+
tasks = readMigrations();
|
|
2191
|
+
} catch (err) {
|
|
2192
|
+
return { ...counts, pending: 0, error: String(err?.message ?? err) };
|
|
2193
|
+
}
|
|
2194
|
+
for (const task of tasks) {
|
|
2195
|
+
counts.total++;
|
|
2196
|
+
if (task.state === 'waiting-for-idle') counts.waitingForIdle++;
|
|
2197
|
+
else if (Object.hasOwn(counts, task.state)) counts[task.state]++;
|
|
2198
|
+
}
|
|
2199
|
+
counts.pending = counts.queued + counts.waitingForIdle + counts.retrying;
|
|
2200
|
+
return counts;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
function writeMigrations(tasks) {
|
|
2204
|
+
const pendingStates = new Set(['queued', 'waiting-for-idle', 'retrying']);
|
|
2205
|
+
const pending = tasks.filter((task) => pendingStates.has(task.state));
|
|
2206
|
+
if (pending.length > MIGRATIONS_LIMIT) {
|
|
2207
|
+
const err = new Error(`migration queue is full (max ${MIGRATIONS_LIMIT} pending tasks)`);
|
|
2208
|
+
err.code = 'queue-full';
|
|
2209
|
+
throw err;
|
|
2210
|
+
}
|
|
2211
|
+
const terminalLimit = MIGRATIONS_LIMIT - pending.length;
|
|
2212
|
+
const terminal = tasks.filter((task) => !pendingStates.has(task.state)).slice(0, terminalLimit);
|
|
2213
|
+
const retainedSet = new Set([...pending, ...terminal]);
|
|
2214
|
+
const retained = tasks.filter((task) => retainedSet.has(task));
|
|
2215
|
+
mkdirSync(dirname(migrationsPath()), { recursive: true });
|
|
2216
|
+
atomicWrite(migrationsPath(), Buffer.from(JSON.stringify({ version: 1, tasks: retained }, null, 2) + '\n', 'utf8'));
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function isBusyError(err) {
|
|
2220
|
+
return err?.code === 'busy' || /currently running|is busy|another operation is in flight|bulk operation is in flight/.test(String(err?.message ?? err));
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function migrationId() {
|
|
2224
|
+
return `migration-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
function migrationSummary(task) {
|
|
2228
|
+
return {
|
|
2229
|
+
id: task.id,
|
|
2230
|
+
sessionId: task.sessionId,
|
|
2231
|
+
title: task.title ?? null,
|
|
2232
|
+
targetWorkspaceId: task.targetWorkspaceId,
|
|
2233
|
+
sourceWorkspaceId: task.sourceWorkspaceId ?? null,
|
|
2234
|
+
state: task.state,
|
|
2235
|
+
attempts: task.attempts ?? 0,
|
|
2236
|
+
createdAt: task.createdAt,
|
|
2237
|
+
updatedAt: task.updatedAt,
|
|
2238
|
+
lastError: task.lastError ?? null,
|
|
2239
|
+
lastAttemptAt: task.lastAttemptAt ?? null,
|
|
2240
|
+
completedAt: task.completedAt ?? null
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function createOrReuseMigration(ctx, { sessionId, targetWorkspaceId, title, sourceWorkspaceId = null, reason = 'session is busy' }) {
|
|
2245
|
+
const tasks = readMigrations();
|
|
2246
|
+
const existing = tasks.find((task) => String(task.sessionId) === String(sessionId)
|
|
2247
|
+
&& String(task.targetWorkspaceId) === String(targetWorkspaceId)
|
|
2248
|
+
&& !['done', 'cancelled'].includes(task.state));
|
|
2249
|
+
if (existing) {
|
|
2250
|
+
existing.lastError = reason;
|
|
2251
|
+
existing.state = 'waiting-for-idle';
|
|
2252
|
+
existing.updatedAt = new Date().toISOString();
|
|
2253
|
+
writeMigrations(tasks);
|
|
2254
|
+
return existing;
|
|
2255
|
+
}
|
|
2256
|
+
const now = new Date().toISOString();
|
|
2257
|
+
const task = {
|
|
2258
|
+
id: migrationId(),
|
|
2259
|
+
sessionId: String(sessionId),
|
|
2260
|
+
title: title ?? null,
|
|
2261
|
+
targetWorkspaceId: String(targetWorkspaceId),
|
|
2262
|
+
sourceWorkspaceId: sourceWorkspaceId ?? null,
|
|
2263
|
+
state: 'waiting-for-idle',
|
|
2264
|
+
attempts: 0,
|
|
2265
|
+
createdAt: now,
|
|
2266
|
+
updatedAt: now,
|
|
2267
|
+
lastError: reason,
|
|
2268
|
+
lastAttemptAt: null,
|
|
2269
|
+
completedAt: null
|
|
2270
|
+
};
|
|
2271
|
+
tasks.unshift(task);
|
|
2272
|
+
writeMigrations(tasks);
|
|
2273
|
+
return task;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
function migrationTaskState(task, state, extra = {}) {
|
|
2277
|
+
return {
|
|
2278
|
+
...task,
|
|
2279
|
+
...extra,
|
|
2280
|
+
state,
|
|
2281
|
+
updatedAt: new Date().toISOString()
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
async function processMigrationTask(ctx, task) {
|
|
2286
|
+
const current = readMigrations().find((item) => item.id === task.id);
|
|
2287
|
+
if (!current || ['done', 'cancelled'].includes(current.state)) return current ?? task;
|
|
2288
|
+
if (peekService(ctx, 'agents')?.get?.(task.sessionId)?.status === 'running') {
|
|
2289
|
+
return migrationTaskState(current, 'waiting-for-idle', { lastError: 'session is currently running; waiting for idle' });
|
|
2290
|
+
}
|
|
2291
|
+
const attemptAt = new Date().toISOString();
|
|
2292
|
+
const retrying = migrationTaskState(current, 'retrying', {
|
|
2293
|
+
attempts: (current.attempts ?? 0) + 1,
|
|
2294
|
+
lastAttemptAt: attemptAt,
|
|
2295
|
+
lastError: null
|
|
2296
|
+
});
|
|
2297
|
+
const tasks = readMigrations();
|
|
2298
|
+
const index = tasks.findIndex((item) => item.id === task.id);
|
|
2299
|
+
if (index < 0 || ['done', 'cancelled'].includes(tasks[index].state)) return tasks[index] ?? current;
|
|
2300
|
+
tasks[index] = retrying;
|
|
2301
|
+
writeMigrations(tasks);
|
|
2302
|
+
try {
|
|
2303
|
+
const result = await moveSessionEntry(ctx, {
|
|
2304
|
+
sessionId: retrying.sessionId,
|
|
2305
|
+
targetWorkspaceId: retrying.targetWorkspaceId,
|
|
2306
|
+
sessionTitle: retrying.title ?? undefined,
|
|
2307
|
+
recordHistory: true
|
|
2308
|
+
});
|
|
2309
|
+
return migrationTaskState(retrying, 'done', {
|
|
2310
|
+
lastError: null,
|
|
2311
|
+
completedAt: new Date().toISOString(),
|
|
2312
|
+
result: { to: result.to, verified: result.verified }
|
|
2313
|
+
});
|
|
2314
|
+
} catch (err) {
|
|
2315
|
+
if (isBusyError(err)) return migrationTaskState(retrying, 'waiting-for-idle', { lastError: String(err?.message ?? err) });
|
|
2316
|
+
return migrationTaskState(retrying, 'failed', { lastError: String(err?.message ?? err) });
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
async function drainMigrationQueueRaw(ctx, { taskId = null } = {}) {
|
|
2321
|
+
const tasks = readMigrations();
|
|
2322
|
+
const eligible = new Set(['queued', 'waiting-for-idle', 'retrying']);
|
|
2323
|
+
const selected = tasks.filter((task) => (taskId ? task.id === taskId : eligible.has(task.state)));
|
|
2324
|
+
if (selected.length === 0) return { items: [], processed: 0 };
|
|
2325
|
+
let processed = 0;
|
|
2326
|
+
for (const task of selected) {
|
|
2327
|
+
const current = readMigrations().find((item) => item.id === task.id);
|
|
2328
|
+
if (!current || !eligible.has(current.state)) continue;
|
|
2329
|
+
const next = await processMigrationTask(ctx, current);
|
|
2330
|
+
const latest = readMigrations();
|
|
2331
|
+
const index = latest.findIndex((item) => item.id === task.id);
|
|
2332
|
+
if (index >= 0 && latest[index].state !== 'cancelled') {
|
|
2333
|
+
latest[index] = next;
|
|
2334
|
+
writeMigrations(latest);
|
|
2335
|
+
}
|
|
2336
|
+
processed++;
|
|
2337
|
+
}
|
|
2338
|
+
return { items: readMigrations().map(migrationSummary), processed };
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
async function drainMigrationQueue(ctx, options = {}) {
|
|
2342
|
+
const runtime = migrationRuntimes.get(ctx);
|
|
2343
|
+
if (!runtime) return drainMigrationQueueRaw(ctx, options);
|
|
2344
|
+
const previous = runtime.draining ?? Promise.resolve();
|
|
2345
|
+
const promise = previous.catch(() => {}).then(() => drainMigrationQueueRaw(ctx, options));
|
|
2346
|
+
runtime.draining = promise;
|
|
2347
|
+
try {
|
|
2348
|
+
return await promise;
|
|
2349
|
+
} finally {
|
|
2350
|
+
if (runtime.draining === promise) runtime.draining = null;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function startMigrationRuntime(ctx, owner, logger) {
|
|
2355
|
+
if (!ctx || typeof ctx !== 'object' || migrationRuntimes.has(ctx)) return;
|
|
2356
|
+
const runtime = { timer: null, draining: null };
|
|
2357
|
+
runtime.timer = setInterval(() => {
|
|
2358
|
+
if (runtime.draining) return;
|
|
2359
|
+
void drainMigrationQueue(ctx).catch((err) => {
|
|
2360
|
+
logger?.warn?.(`workspace-mover: migration queue drain failed: ${err?.message ?? err}`);
|
|
2361
|
+
});
|
|
2362
|
+
}, 5000);
|
|
2363
|
+
runtime.timer.unref?.();
|
|
2364
|
+
migrationRuntimes.set(ctx, runtime);
|
|
2365
|
+
owner?.effect?.(() => () => {
|
|
2366
|
+
clearInterval(runtime.timer);
|
|
2367
|
+
if (migrationRuntimes.get(ctx) === runtime) migrationRuntimes.delete(ctx);
|
|
2368
|
+
}, 'workspace-mover: migration queue dispose');
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
async function enqueueMigrations(ctx, payload = {}) {
|
|
2372
|
+
const targetWorkspaceId = validId(payload?.targetWorkspaceId, 'targetWorkspaceId');
|
|
2373
|
+
const sourceItems = Array.isArray(payload?.sessions) ? payload.sessions : [{ sessionId: payload?.sessionId, title: payload?.sessionTitle }];
|
|
2374
|
+
if (sourceItems.length === 0) throw new Error('sessions must be a non-empty array');
|
|
2375
|
+
if (sourceItems.length > batchLimit()) throw new Error(`too many sessions in one batch (max ${batchLimit()}, got ${sourceItems.length})`);
|
|
2376
|
+
const registry = ctx.workspaceRegistry;
|
|
2377
|
+
const created = [];
|
|
2378
|
+
for (const item of sourceItems) {
|
|
2379
|
+
const sessionId = validId(item?.sessionId, 'sessionId');
|
|
2380
|
+
let sourceWorkspaceId = null;
|
|
2381
|
+
try {
|
|
2382
|
+
sourceWorkspaceId = registry?.list?.().find((e) => rawRecordSessionIds(e).includes(sessionId))?.id ?? null;
|
|
2383
|
+
} catch { /* source group is optional metadata */ }
|
|
2384
|
+
created.push(createOrReuseMigration(ctx, {
|
|
2385
|
+
sessionId,
|
|
2386
|
+
targetWorkspaceId,
|
|
2387
|
+
title: typeof item?.title === 'string' ? item.title : item?.sessionTitle,
|
|
2388
|
+
sourceWorkspaceId,
|
|
2389
|
+
reason: 'queued by user'
|
|
2390
|
+
}));
|
|
2391
|
+
}
|
|
2392
|
+
await drainMigrationQueue(ctx);
|
|
2393
|
+
const all = readMigrations();
|
|
2394
|
+
return { queued: created.map(migrationSummary), items: all.map(migrationSummary) };
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
async function listMigrations(ctx) {
|
|
2398
|
+
await drainMigrationQueue(ctx);
|
|
2399
|
+
return { items: readMigrations().map(migrationSummary) };
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
async function retryMigration(ctx, payload = {}) {
|
|
2403
|
+
const batch = Array.isArray(payload.taskIds);
|
|
2404
|
+
const taskIds = batch ? [...new Set(payload.taskIds)] : [payload.taskId];
|
|
2405
|
+
if (taskIds.length === 0 || taskIds.length > MIGRATIONS_LIMIT
|
|
2406
|
+
|| taskIds.some((id) => typeof id !== 'string' || id.length === 0)) {
|
|
2407
|
+
throw new Error(`taskId or taskIds with 1-${MIGRATIONS_LIMIT} ids required`);
|
|
2408
|
+
}
|
|
2409
|
+
const tasks = readMigrations();
|
|
2410
|
+
const results = [];
|
|
2411
|
+
let changed = false;
|
|
2412
|
+
for (const id of taskIds) {
|
|
2413
|
+
const task = tasks.find((item) => item.id === id);
|
|
2414
|
+
if (!task) {
|
|
2415
|
+
if (!batch) throw new Error(`migration task '${id}' not found`);
|
|
2416
|
+
results.push({ id, state: 'missing', lastError: 'migration task not found' });
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
if (task.state === 'cancelled' || task.state === 'retrying' || task.state === 'done') {
|
|
2420
|
+
results.push(migrationSummary(task));
|
|
2421
|
+
continue;
|
|
2422
|
+
}
|
|
2423
|
+
task.state = 'queued';
|
|
2424
|
+
task.lastError = null;
|
|
2425
|
+
task.updatedAt = new Date().toISOString();
|
|
2426
|
+
changed = true;
|
|
2427
|
+
results.push(task);
|
|
2428
|
+
}
|
|
2429
|
+
if (changed) {
|
|
2430
|
+
writeMigrations(tasks);
|
|
2431
|
+
await drainMigrationQueue(ctx, batch ? {} : { taskId: taskIds[0] });
|
|
2432
|
+
}
|
|
2433
|
+
const latest = readMigrations();
|
|
2434
|
+
const items = taskIds.map((id) => {
|
|
2435
|
+
const task = latest.find((item) => item.id === id);
|
|
2436
|
+
return task ? migrationSummary(task) : results.find((item) => item.id === id);
|
|
2437
|
+
});
|
|
2438
|
+
return batch ? { items, retried: results.filter((item) => item && !['missing', 'done', 'cancelled', 'retrying'].includes(item.state)).length }
|
|
2439
|
+
: { item: items[0] };
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
async function cancelMigration({ taskId } = {}) {
|
|
2443
|
+
if (typeof taskId !== 'string' || taskId.length === 0) throw new Error('taskId required');
|
|
2444
|
+
const tasks = readMigrations();
|
|
2445
|
+
const task = tasks.find((item) => item.id === taskId);
|
|
2446
|
+
if (!task) throw new Error(`migration task '${taskId}' not found`);
|
|
2447
|
+
if (task.state === 'retrying') {
|
|
2448
|
+
const err = new Error('migration task is already in progress and cannot be cancelled');
|
|
2449
|
+
err.code = 'busy';
|
|
2450
|
+
throw err;
|
|
2451
|
+
}
|
|
2452
|
+
if (!['done', 'cancelled'].includes(task.state)) {
|
|
2453
|
+
task.state = 'cancelled';
|
|
2454
|
+
task.updatedAt = new Date().toISOString();
|
|
2455
|
+
}
|
|
2456
|
+
writeMigrations(tasks);
|
|
2457
|
+
return { cancelled: task.state === 'cancelled', item: migrationSummary(task) };
|
|
2458
|
+
}
|
|
2459
|
+
|
|
1162
2460
|
/** 批量迁移完成后落任务记录:done/failed 逐项带来源与目标路径、最后错误与最后尝试时间。 */
|
|
1163
2461
|
function recordMoveManyTask(ctx, { targetWorkspaceId, results, moved }) {
|
|
1164
2462
|
const target = ctx.workspaceRegistry?.list?.().find((e) => e.id === targetWorkspaceId);
|
|
@@ -1171,7 +2469,9 @@ function recordMoveManyTask(ctx, { targetWorkspaceId, results, moved }) {
|
|
|
1171
2469
|
sourceCwd: moved.find((mv) => mv.sessionId === r.sessionId)?.fromCwd ?? null,
|
|
1172
2470
|
targetCwd: target?.path ?? null,
|
|
1173
2471
|
state: r.ok ? 'done' : 'failed',
|
|
2472
|
+
queued: Boolean(r.queued),
|
|
1174
2473
|
error: r.ok ? null : (r.error ?? 'unknown'),
|
|
2474
|
+
queueTaskId: r.queueTaskId ?? null,
|
|
1175
2475
|
lastAttemptAt: new Date().toISOString()
|
|
1176
2476
|
});
|
|
1177
2477
|
}
|
|
@@ -1202,6 +2502,7 @@ async function listTasks() {
|
|
|
1202
2502
|
done: t.items.filter((i) => i.state === 'done').length,
|
|
1203
2503
|
failed: t.items.filter((i) => i.state === 'failed').length,
|
|
1204
2504
|
skipped: t.items.filter((i) => i.state === 'skipped').length,
|
|
2505
|
+
queued: t.items.filter((i) => i.state === 'queued').length,
|
|
1205
2506
|
total: t.items.length,
|
|
1206
2507
|
items: t.items
|
|
1207
2508
|
}))
|
|
@@ -1232,7 +2533,7 @@ async function retryTask(ctx, { taskId } = {}) {
|
|
|
1232
2533
|
item.lastError = null;
|
|
1233
2534
|
item.lastAttemptAt = attemptAt;
|
|
1234
2535
|
item.targetCwd = result.to?.cwd ?? item.targetCwd;
|
|
1235
|
-
movedNow.push({ sessionId: item.sessionId, title: item.title, fromCwd: result.from?.cwd ?? null, sourceWorkspaceId: null });
|
|
2536
|
+
movedNow.push({ sessionId: item.sessionId, title: item.title, fromCwd: result.from?.cwd ?? null, sourceWorkspaceId: null, backup: result.backup ?? null });
|
|
1236
2537
|
results.push({ sessionId: item.sessionId, ok: true });
|
|
1237
2538
|
} catch (err) {
|
|
1238
2539
|
const msg = String(err?.message ?? err);
|
|
@@ -1275,8 +2576,10 @@ async function forgetTask(ctx, { taskId, all } = {}) {
|
|
|
1275
2576
|
* 数据保护:按时间清理回收站条目与备份(两项独立判定,互不影响)。
|
|
1276
2577
|
* dryRun=true 只统计不删除,供确认框展示将释放的数量与空间。
|
|
1277
2578
|
*/
|
|
1278
|
-
async function cleanupOldData(ctx, { days
|
|
1279
|
-
|
|
2579
|
+
async function cleanupOldData(ctx, { days, dryRun = false } = {}) {
|
|
2580
|
+
// 注意:不能写 `{ days = 30 }`——解构默认值会把 undefined 变成 30,
|
|
2581
|
+
// 于是下面的策略回退永远看不到"未传值",策略配置被默认值静默遮蔽。
|
|
2582
|
+
const keepDays = Math.min(3650, Math.max(1, days === undefined ? cleanupDays() : (Number(days) || cleanupDays()))); // 输入钳制:1–3650 天
|
|
1280
2583
|
const cutoff = Date.now() - keepDays * 86400000;
|
|
1281
2584
|
let freedBytes = 0;
|
|
1282
2585
|
let recyclePurged = 0;
|
|
@@ -1298,9 +2601,9 @@ async function cleanupOldData(ctx, { days = 30, dryRun = false } = {}) {
|
|
|
1298
2601
|
const bdir = backupDir();
|
|
1299
2602
|
if (dirExists(bdir)) {
|
|
1300
2603
|
for (const name of readdirSync(bdir)) {
|
|
1301
|
-
const
|
|
1302
|
-
if (!
|
|
1303
|
-
const ts =
|
|
2604
|
+
const parsed = parseBackupFilename(name);
|
|
2605
|
+
if (!parsed) continue;
|
|
2606
|
+
const ts = parsed.ts;
|
|
1304
2607
|
if (!(ts < cutoff)) continue;
|
|
1305
2608
|
let size = 0;
|
|
1306
2609
|
try { size = statSync(join(bdir, name)).size; } catch { continue; }
|
|
@@ -1330,7 +2633,7 @@ async function homeMisfiledSession(ctx, sessionId) {
|
|
|
1330
2633
|
for (const entity of registry.list()) {
|
|
1331
2634
|
if (entity.id === home.id) continue;
|
|
1332
2635
|
let listed = false;
|
|
1333
|
-
try { listed =
|
|
2636
|
+
try { listed = rawRecordSessionIds(entity).includes(String(sessionId)); } catch { /* ignore */ }
|
|
1334
2637
|
if (!listed) continue;
|
|
1335
2638
|
try {
|
|
1336
2639
|
await entity.detachSession(sessionId);
|
|
@@ -1346,7 +2649,7 @@ async function homeMisfiledSession(ctx, sessionId) {
|
|
|
1346
2649
|
/** 批量修复入口:relink(换路径真迁移)+ attach(原地补记账)+ home(误放归位)。 */
|
|
1347
2650
|
async function repairSessions(ctx, actions = []) {
|
|
1348
2651
|
if (!Array.isArray(actions)) throw new Error('actions must be an array');
|
|
1349
|
-
if (actions.length >
|
|
2652
|
+
if (actions.length > batchLimit()) throw new Error(`too many actions in one batch (max ${batchLimit()})`);
|
|
1350
2653
|
const results = [];
|
|
1351
2654
|
for (const action of actions) {
|
|
1352
2655
|
const sessionId = action?.sessionId;
|
|
@@ -1372,7 +2675,7 @@ async function repairSessions(ctx, actions = []) {
|
|
|
1372
2675
|
//#endregion
|
|
1373
2676
|
|
|
1374
2677
|
//#region 工作区搬家向导(v0.5):体检 + 注册表原地重定向 + 成员批量迁移
|
|
1375
|
-
const REPOINT_MAX_SESSIONS = 200;
|
|
2678
|
+
const REPOINT_MAX_SESSIONS = 200; // 默认值;实际生效上限见 repointLimit()
|
|
1376
2679
|
|
|
1377
2680
|
/** 规范化一个必须真实存在的目录;失败抛用户可读错误。与官方 realpath 唯一性约定一致。 */
|
|
1378
2681
|
function canonicalDir(p, label) {
|
|
@@ -1398,7 +2701,7 @@ async function auditWorkspaces(ctx) {
|
|
|
1398
2701
|
if (typeof entity.status === 'function') status = await entity.status();
|
|
1399
2702
|
} catch { /* 保持 stat 兜底结果 */ }
|
|
1400
2703
|
let memberCount = 0;
|
|
1401
|
-
try { memberCount = entity.
|
|
2704
|
+
try { memberCount = rawRecordSessionIds(entity).length; } catch { /* ignore */ }
|
|
1402
2705
|
items.push({ workspaceId: entity.id, title: entity.title, path: entity.path, status, memberCount });
|
|
1403
2706
|
}
|
|
1404
2707
|
return { items };
|
|
@@ -1444,14 +2747,24 @@ async function listArchivedSessions(ctx) {
|
|
|
1444
2747
|
});
|
|
1445
2748
|
}
|
|
1446
2749
|
items.sort((a, b) => String(a.title).localeCompare(String(b.title), 'zh'));
|
|
1447
|
-
|
|
2750
|
+
// 归档会话通常很旧,而扫描只解析按 mtime 排序的前 SCAN_MAX_ITEMS 条——
|
|
2751
|
+
// 大库上它们会整体从本列表消失。把"扫过多少 / 共多少"如实带出去,
|
|
2752
|
+
// 让 UI 能说明"这里不是全部",而不是静默展示一个残缺清单。
|
|
2753
|
+
return {
|
|
2754
|
+
items,
|
|
2755
|
+
truncated: scan.truncated,
|
|
2756
|
+
scannedParsed: scan.scannedParsed ?? scan.items.length,
|
|
2757
|
+
scannedTotal: scan.scanned,
|
|
2758
|
+
archivedTotal: archivedIds.size
|
|
2759
|
+
};
|
|
1448
2760
|
}
|
|
1449
2761
|
|
|
1450
2762
|
/**
|
|
1451
|
-
*
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
2763
|
+
* 取消归档。优先使用官方文档化的 `registry.unarchiveSession(sessionId)`——
|
|
2764
|
+
* 它的语义与本插件一致(归档从不摘记账槽,因此取消后自动回到原工作区原位置,
|
|
2765
|
+
* 且不需要会话存在性检查)。官方接口不可用时回退到等价的持久状态写通道
|
|
2766
|
+
* (enqueueOperation + setState,与官方 archiveSession 同款)。
|
|
2767
|
+
* 带 targetWorkspaceId 且 ≠ 归属时,接着走常规迁移(备份/历史/撤销/防运行中全套保护)。
|
|
1455
2768
|
*/
|
|
1456
2769
|
async function unarchiveSession(ctx, { sessionId, targetWorkspaceId } = {}) {
|
|
1457
2770
|
if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('sessionId required');
|
|
@@ -1459,20 +2772,27 @@ async function unarchiveSession(ctx, { sessionId, targetWorkspaceId } = {}) {
|
|
|
1459
2772
|
if (!registry) throw new Error('workspace registry unavailable');
|
|
1460
2773
|
const archivedIds = readArchivedIds(registry);
|
|
1461
2774
|
if (!archivedIds.includes(sessionId)) throw new Error(`session '${sessionId}' is not archived`);
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
2775
|
+
|
|
2776
|
+
if (typeof registry.unarchiveSession === 'function') {
|
|
2777
|
+
// 官方通道:去掉归档集里的 id 即完成任务(记账槽原样保留)。
|
|
2778
|
+
await registry.unarchiveSession(sessionId);
|
|
2779
|
+
} else {
|
|
2780
|
+
if (typeof registry.enqueueOperation !== 'function' || typeof registry.setState !== 'function' || typeof registry.requireState !== 'function') {
|
|
2781
|
+
throw new Error('host workspace registry exposes no state mutation API; unarchive is unsupported on this DSH version');
|
|
2782
|
+
}
|
|
2783
|
+
await registry.enqueueOperation(async () => {
|
|
2784
|
+
const state = registry.requireState();
|
|
2785
|
+
await registry.setState({
|
|
2786
|
+
...state,
|
|
2787
|
+
archivedSessionIds: state.archivedSessionIds.filter((id) => String(id) !== sessionId)
|
|
2788
|
+
});
|
|
1470
2789
|
});
|
|
1471
|
-
}
|
|
2790
|
+
}
|
|
2791
|
+
|
|
1472
2792
|
let moved = null;
|
|
1473
2793
|
if (typeof targetWorkspaceId === 'string' && targetWorkspaceId.length > 0) {
|
|
1474
2794
|
const owner = registry.list().find((e) => {
|
|
1475
|
-
try { return
|
|
2795
|
+
try { return rawRecordSessionIds(e).includes(String(sessionId)); } catch { return false; }
|
|
1476
2796
|
});
|
|
1477
2797
|
if (!owner || owner.id !== targetWorkspaceId) {
|
|
1478
2798
|
moved = await moveSession(ctx, { sessionId, targetWorkspaceId });
|
|
@@ -1576,15 +2896,21 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
|
|
|
1576
2896
|
if (typeof header?.cwd !== 'string' || !samePath(header.cwd, stalePath)) continue;
|
|
1577
2897
|
addAffected(header.id, typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null);
|
|
1578
2898
|
}
|
|
1579
|
-
try { for (const id of entity
|
|
2899
|
+
try { for (const id of rawRecordSessionIds(entity)) addAffected(id, registry.headers?.get?.(id)?.title ?? null); } catch { /* 记账不可读时以磁盘扫描为准 */ }
|
|
1580
2900
|
|
|
1581
2901
|
const affected = [...byId.entries()].map(([sessionId, title]) => ({ sessionId, title }));
|
|
1582
|
-
if (affected.length >
|
|
1583
|
-
throw new Error(`该工作区牵涉 ${affected.length} 个会话,超出单次上限 ${
|
|
2902
|
+
if (affected.length > repointLimit()) {
|
|
2903
|
+
throw new Error(`该工作区牵涉 ${affected.length} 个会话,超出单次上限 ${repointLimit()};请分批处理`);
|
|
1584
2904
|
}
|
|
1585
2905
|
|
|
1586
|
-
const
|
|
1587
|
-
|
|
2906
|
+
const storage = createPersistenceAdapter(ctx);
|
|
2907
|
+
if (!storage.capabilities.physicalPath) {
|
|
2908
|
+
const err = new Error(`${storage.reason}; physical workspace repoint is unavailable`);
|
|
2909
|
+
err.code = 'unsupported';
|
|
2910
|
+
throw err;
|
|
2911
|
+
}
|
|
2912
|
+
const root = storage.root;
|
|
2913
|
+
const compression = storage.compression;
|
|
1588
2914
|
if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
|
|
1589
2915
|
|
|
1590
2916
|
if (dryRun) return { dryRun: true, from: stalePath, to: targetCanon, resumeMode, count: affected.length, items: affected };
|
|
@@ -1617,23 +2943,27 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
|
|
|
1617
2943
|
}
|
|
1618
2944
|
}
|
|
1619
2945
|
try {
|
|
1620
|
-
await entity.mutate((record) => ({
|
|
1621
|
-
...record,
|
|
1622
|
-
path: targetCanon,
|
|
1623
|
-
// 标题跟随文件夹改名:仅当标题仍是旧文件夹名(官方 create 的默认值)时同步;
|
|
1624
|
-
// 用户自定义过的标题原样保留
|
|
1625
|
-
...(typeof record.title === 'string' && record.title.length > 0
|
|
1626
|
-
&& record.title.toLowerCase() === basename(stalePath).toLowerCase()
|
|
1627
|
-
? { title: basename(targetCanon) }
|
|
1628
|
-
: {})
|
|
1629
|
-
}));
|
|
2946
|
+
await entity.mutate((record) => ({ ...record, path: targetCanon }));
|
|
1630
2947
|
pathUpdated = true;
|
|
2948
|
+
// 标题跟随文件夹改名:仅当标题仍是旧文件夹名(官方 create 的默认值)时同步,
|
|
2949
|
+
// 用户自定义过的标题原样保留。走官方 entity.setTitle,与 mutate 解耦——
|
|
2950
|
+
// 标题同步失败不应影响已经完成的 path 重定向。
|
|
2951
|
+
try {
|
|
2952
|
+
const currentTitle = entity.title;
|
|
2953
|
+
if (typeof currentTitle === 'string' && currentTitle.length > 0
|
|
2954
|
+
&& currentTitle.toLowerCase() === basename(stalePath).toLowerCase()
|
|
2955
|
+
&& typeof entity.setTitle === 'function') {
|
|
2956
|
+
await entity.setTitle(basename(targetCanon));
|
|
2957
|
+
}
|
|
2958
|
+
} catch (err) {
|
|
2959
|
+
ctx.logger?.warn?.(`workspace-mover: title sync after repoint failed (path is already updated): ${err?.message ?? err}`);
|
|
2960
|
+
}
|
|
1631
2961
|
// 剪枝保护网:mutate 若因索引预置失败清掉了某些成员,立即按官方
|
|
1632
2962
|
// attach 语义补回(头部已被预置成新路径,校验必过),失败则记入 skipped。
|
|
1633
2963
|
try {
|
|
1634
2964
|
for (const { sessionId } of affected) {
|
|
1635
2965
|
let accounted = false;
|
|
1636
|
-
try { accounted = entity.
|
|
2966
|
+
try { accounted = rawRecordSessionIds(entity).includes(String(sessionId)); } catch { /* ignore */ }
|
|
1637
2967
|
if (!accounted) await entity.attachSession(sessionId);
|
|
1638
2968
|
}
|
|
1639
2969
|
} catch (err) {
|
|
@@ -1663,24 +2993,39 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
|
|
|
1663
2993
|
try {
|
|
1664
2994
|
assertNotRunning(ctx, sessionId);
|
|
1665
2995
|
const meta = headers.find((h) => String(h?.id) === String(sessionId));
|
|
1666
|
-
const
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
2996
|
+
const sourceLocation = await storage.locate({ sessionId, cwd: typeof meta?.cwd === 'string' ? meta.cwd : stalePath });
|
|
2997
|
+
const sourceInfo = await storage.inspect(sourceLocation);
|
|
2998
|
+
if (!sourceInfo.exists) throw new Error(`档案缺失:${sourceLocation.artifact}`);
|
|
2999
|
+
const srcArtifact = sourceLocation.artifact;
|
|
3000
|
+
const destinationProbe = await storage.locate({ sessionId, cwd: targetCanon });
|
|
3001
|
+
if ((await storage.inspect(destinationProbe)).exists) throw new Error(`目标位置已有同名档案:${destinationProbe.artifact}`);
|
|
3002
|
+
const dstArtifact = join(dirname(destinationProbe.artifact), basename(srcArtifact));
|
|
3003
|
+
const destinationLocation = { ...destinationProbe, cwd: targetCanon, artifact: dstArtifact };
|
|
3004
|
+
|
|
3005
|
+
const original = await storage.read(sourceLocation);
|
|
3006
|
+
stashBackup(String(sessionId), original, { compression, ignoreCwd: true });
|
|
3007
|
+
await storage.replace(sourceLocation, rewriteHeaderCwd(original, targetCanon, compression));
|
|
1674
3008
|
try {
|
|
1675
|
-
|
|
3009
|
+
await storage.relocate(sourceLocation, dirname(dstArtifact));
|
|
1676
3010
|
// 迁移后一致性校验(同 moveSession),失败视同搬移失败回退源字节
|
|
1677
3011
|
try {
|
|
1678
|
-
|
|
3012
|
+
verifyRelocatedBytes(await storage.read(destinationLocation), sessionId, targetCanon, compression);
|
|
1679
3013
|
} catch (verifyErr) {
|
|
1680
3014
|
throw new Error(`物理搬移校验失败:${verifyErr?.message ?? verifyErr}`);
|
|
1681
3015
|
}
|
|
1682
3016
|
} catch (err) {
|
|
1683
|
-
|
|
3017
|
+
try {
|
|
3018
|
+
if (!(await storage.inspect(sourceLocation)).exists) await storage.relocate(destinationLocation, dirname(srcArtifact));
|
|
3019
|
+
await storage.replace(sourceLocation, original);
|
|
3020
|
+
} catch (rollbackErr) {
|
|
3021
|
+
try { await storage.write(sourceLocation, original); } catch { /* backup remains available */ }
|
|
3022
|
+
recordRecovery(ctx, {
|
|
3023
|
+
kind: 'repoint', phase: 'physical-rollback', sessionId: String(sessionId),
|
|
3024
|
+
source: dirname(srcArtifact), target: dirname(dstArtifact), backup: null,
|
|
3025
|
+
lastError: `move failed (${err?.message ?? err}) AND rollback failed (${rollbackErr?.message ?? rollbackErr})`
|
|
3026
|
+
});
|
|
3027
|
+
throw new Error(`物理搬移失败且回滚不完整;原始档案已尝试写回,备份和恢复记录已保留:${rollbackErr?.message ?? rollbackErr}`);
|
|
3028
|
+
}
|
|
1684
3029
|
throw new Error(`物理搬移失败:${err?.message ?? err}`);
|
|
1685
3030
|
}
|
|
1686
3031
|
try {
|
|
@@ -1699,7 +3044,7 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
|
|
|
1699
3044
|
}
|
|
1700
3045
|
// 旧路径散件(从未记账)落位后补挂账;已记账成员为幂等跳过
|
|
1701
3046
|
try {
|
|
1702
|
-
if (!entity.
|
|
3047
|
+
if (!rawRecordSessionIds(entity).includes(String(sessionId))) await entity.attachSession(sessionId);
|
|
1703
3048
|
} catch (err) {
|
|
1704
3049
|
throw new Error(`迁移完成但补挂账失败(可在会话修复面板重试):${err?.message ?? err}`);
|
|
1705
3050
|
}
|
|
@@ -1778,12 +3123,16 @@ async function locateArtifact(ctx, sessionId) {
|
|
|
1778
3123
|
const registry = ctx.workspaceRegistry;
|
|
1779
3124
|
const persistence = ctx.sessionPersistence;
|
|
1780
3125
|
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
1781
|
-
const
|
|
1782
|
-
|
|
3126
|
+
const storage = createPersistenceAdapter(ctx);
|
|
3127
|
+
if (!storage.capabilities.physicalPath) {
|
|
3128
|
+
const err = new Error(`${storage.reason}; physical artifact lookup is unavailable`);
|
|
3129
|
+
err.code = 'unsupported';
|
|
3130
|
+
throw err;
|
|
3131
|
+
}
|
|
1783
3132
|
const candidates = [];
|
|
1784
3133
|
try { const p = registry.sessionPaths?.get?.(sessionId); if (typeof p === 'string' && p) candidates.push(p); } catch { /* ignore */ }
|
|
1785
3134
|
for (const e of registry.list()) {
|
|
1786
|
-
try { if (
|
|
3135
|
+
try { if (rawRecordSessionIds(e).includes(String(sessionId))) candidates.push(e.path); } catch { /* ignore */ }
|
|
1787
3136
|
}
|
|
1788
3137
|
try { const h = registry.headers?.get?.(sessionId); if (typeof h?.cwd === 'string') candidates.push(h.cwd); } catch { /* ignore */ }
|
|
1789
3138
|
try {
|
|
@@ -1792,8 +3141,9 @@ async function locateArtifact(ctx, sessionId) {
|
|
|
1792
3141
|
}
|
|
1793
3142
|
} catch { /* ignore */ }
|
|
1794
3143
|
for (const cwd of candidates) {
|
|
1795
|
-
const
|
|
1796
|
-
|
|
3144
|
+
const location = await storage.locate({ sessionId, cwd });
|
|
3145
|
+
const info = await storage.inspect(location);
|
|
3146
|
+
if (info.exists) return { ...location, cwd };
|
|
1797
3147
|
}
|
|
1798
3148
|
throw new Error(`session archive not found for '${sessionId}'`);
|
|
1799
3149
|
}
|
|
@@ -1856,19 +3206,48 @@ async function modifyArchiveSet(ctx, sessionId, add) {
|
|
|
1856
3206
|
}
|
|
1857
3207
|
}
|
|
1858
3208
|
|
|
3209
|
+
/**
|
|
3210
|
+
* 注册表实体上是否存在某个官方文档化的方法(只读探测,永不抛错)。
|
|
3211
|
+
* 用于 mover.status 的能力位:官方接口缺失时插件会静默回退到等价实现,
|
|
3212
|
+
* 从外部看不出差异,因此必须显式可观测。
|
|
3213
|
+
*/
|
|
3214
|
+
function hasEntityApi(registry, method) {
|
|
3215
|
+
try {
|
|
3216
|
+
const first = registry?.list?.()?.[0];
|
|
3217
|
+
return typeof first?.[method] === 'function';
|
|
3218
|
+
} catch {
|
|
3219
|
+
return false;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
|
|
1859
3223
|
/** 非空字符串 id 校验(≤300 字符)。 */
|
|
1860
|
-
function validId(value, name = 'id') {
|
|
1861
|
-
if (typeof value !== 'string' || value.length === 0 || value.length > 300) {
|
|
3224
|
+
function validId(value, name = 'id') { if (typeof value !== 'string' || value.length === 0 || value.length > 300) {
|
|
1862
3225
|
throw new Error(`${name} must be a non-empty string of at most 300 characters`);
|
|
1863
3226
|
}
|
|
1864
3227
|
return value;
|
|
1865
3228
|
}
|
|
1866
3229
|
|
|
3230
|
+
/**
|
|
3231
|
+
* 读取工作区实体的**原始**记账名单(含幽灵 id)。
|
|
3232
|
+
*
|
|
3233
|
+
* 这是本插件唯一允许触碰的私有成员:官方公开的 `sessionIds` getter 会按
|
|
3234
|
+
* canonical-cwd 索引过滤掉"有账无档"的幽灵 id,而救援扫描正是要看见它们。
|
|
3235
|
+
* 官方 `Workspace` 接口没有等价的未过滤视图,因此这里集中收敛为一个访问器——
|
|
3236
|
+
* 未来宿主改变内部结构时只需改这一处,而不是散落全文件的 15 个 `try/catch`。
|
|
3237
|
+
* 读取失败返回空数组(与"无记账"同义,调用方按只读处理)。
|
|
3238
|
+
*/
|
|
3239
|
+
function rawRecordSessionIds(entity) {
|
|
3240
|
+
try {
|
|
3241
|
+
const ids = entity?.record?.sessionIds;
|
|
3242
|
+
return Array.isArray(ids) ? ids.map(String) : [...ids].map(String);
|
|
3243
|
+
} catch {
|
|
3244
|
+
return [];
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
|
|
1867
3248
|
/** 扫描所有记账该会话的实体(只读,不改动)。 */
|
|
1868
3249
|
function listOwners(registry, sessionId) {
|
|
1869
|
-
return registry.list().filter((e) =>
|
|
1870
|
-
try { return [...e.record.sessionIds].includes(sessionId); } catch { return false; }
|
|
1871
|
-
});
|
|
3250
|
+
return registry.list().filter((e) => rawRecordSessionIds(e).includes(String(sessionId)));
|
|
1872
3251
|
}
|
|
1873
3252
|
|
|
1874
3253
|
/** 从所有记账方摘除会话(moveSession 的 owners 扫描同款),返回被摘实体。 */
|
|
@@ -1903,14 +3282,14 @@ async function deleteSessionToTrash(ctx, payload = {}, hooks = {}) {
|
|
|
1903
3282
|
const sessionId = validId(payload?.sessionId, 'sessionId');
|
|
1904
3283
|
const release = acquireLocks(`session:${sessionId}`);
|
|
1905
3284
|
try {
|
|
1906
|
-
return await deleteSessionToTrashLocked(ctx, sessionId, hooks);
|
|
3285
|
+
return await deleteSessionToTrashLocked(ctx, sessionId, { ...hooks, purgeBackups: payload?.purgeBackups === true });
|
|
1907
3286
|
} finally {
|
|
1908
3287
|
release();
|
|
1909
3288
|
}
|
|
1910
3289
|
}
|
|
1911
3290
|
|
|
1912
3291
|
async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
1913
|
-
const { moveImpl = moveDir, writeManifestImpl = (path, manifest) => writeFileSync(path, JSON.stringify(manifest, null, 2)) } = hooks;
|
|
3292
|
+
const { moveImpl = moveDir, writeManifestImpl = (path, manifest) => writeFileSync(path, JSON.stringify(manifest, null, 2)), purgeBackups = false } = hooks;
|
|
1914
3293
|
const registry = ctx.workspaceRegistry;
|
|
1915
3294
|
const persistence = ctx.sessionPersistence;
|
|
1916
3295
|
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
@@ -1918,9 +3297,10 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
1918
3297
|
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');
|
|
1919
3298
|
}
|
|
1920
3299
|
assertNotRunning(ctx, sessionId);
|
|
1921
|
-
const
|
|
1922
|
-
const
|
|
1923
|
-
const
|
|
3300
|
+
const storage = createPersistenceAdapter(ctx);
|
|
3301
|
+
const location = await locateArtifact(ctx, sessionId);
|
|
3302
|
+
const { artifact, cwd: artifactCwd } = location;
|
|
3303
|
+
const header = readHeader(await storage.read(location), storage.compression);
|
|
1924
3304
|
const archived = readArchivedIds(registry).includes(sessionId);
|
|
1925
3305
|
const projection = captureProjection(ctx, sessionId);
|
|
1926
3306
|
const owners = listOwners(registry, sessionId);
|
|
@@ -1929,7 +3309,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
1929
3309
|
// ① 物理移动先行
|
|
1930
3310
|
let how = 'rename';
|
|
1931
3311
|
try {
|
|
1932
|
-
how =
|
|
3312
|
+
how = await storage.relocate(location, join(entryDir, 'session'), { moveImpl });
|
|
1933
3313
|
} catch (err) {
|
|
1934
3314
|
try { rmSync(entryDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1935
3315
|
throw new Error(`move to recycle failed (nothing was changed): ${err?.message ?? err}`);
|
|
@@ -1949,7 +3329,9 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
1949
3329
|
try {
|
|
1950
3330
|
writeManifestImpl(join(entryDir, TRASH_MANIFEST), manifest);
|
|
1951
3331
|
} catch (err) {
|
|
1952
|
-
try {
|
|
3332
|
+
try {
|
|
3333
|
+
await storage.relocate({ ...location, artifact: join(entryDir, 'session', basename(artifact)) }, dirname(artifact));
|
|
3334
|
+
} catch (backErr) {
|
|
1953
3335
|
recordRecovery(ctx, {
|
|
1954
3336
|
kind: 'delete', phase: 'manifest-rollback', sessionId: String(sessionId),
|
|
1955
3337
|
source: dirname(artifact), target: entryDir, backup: null,
|
|
@@ -1965,7 +3347,10 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
1965
3347
|
forgetSessionIndexes(registry, sessionId);
|
|
1966
3348
|
if (archived) await modifyArchiveSet(ctx, sessionId, false);
|
|
1967
3349
|
await dropProjection(ctx, String(sessionId));
|
|
1968
|
-
|
|
3350
|
+
// 可选:连同该会话的迁移备份一起清掉(默认保留——回收站还原后仍可能要用)。
|
|
3351
|
+
let backupsDeleted = 0;
|
|
3352
|
+
if (purgeBackups) backupsDeleted = (await deleteBackup(ctx, { sessionId: String(sessionId) })).deleted;
|
|
3353
|
+
return { deleted: true, sessionId: String(sessionId), title: manifest.title, how, backupsDeleted };
|
|
1969
3354
|
}
|
|
1970
3355
|
|
|
1971
3356
|
function readTrashEntry(entryDir) {
|
|
@@ -2015,6 +3400,12 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
|
|
|
2015
3400
|
const registry = ctx.workspaceRegistry;
|
|
2016
3401
|
const persistence = ctx.sessionPersistence;
|
|
2017
3402
|
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
3403
|
+
const storage = createPersistenceAdapter(ctx);
|
|
3404
|
+
if (!storage.capabilities.physicalPath) {
|
|
3405
|
+
const err = new Error(`${storage.reason}; physical trash restore is unavailable`);
|
|
3406
|
+
err.code = 'unsupported';
|
|
3407
|
+
throw err;
|
|
3408
|
+
}
|
|
2018
3409
|
const dir = recycleDir();
|
|
2019
3410
|
if (!dirExists(dir)) throw new Error(`no trashed session '${sessionId}'`);
|
|
2020
3411
|
let entryDir = null;
|
|
@@ -2025,22 +3416,25 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
|
|
|
2025
3416
|
if (m && String(m.sessionId) === String(sessionId)) { entryDir = candidate; manifest = m; break; }
|
|
2026
3417
|
}
|
|
2027
3418
|
if (!entryDir) throw new Error(`no trashed session '${sessionId}'`);
|
|
2028
|
-
const compression =
|
|
3419
|
+
const compression = storage.compression;
|
|
2029
3420
|
const sessionDir = join(entryDir, 'session');
|
|
2030
3421
|
const foundInTrash = findHighestArtifact(sessionDir, compression);
|
|
2031
3422
|
if (!foundInTrash) throw new Error(`trash entry has no session artifact: ${entryDir}`);
|
|
2032
3423
|
const artifactInTrash = foundInTrash.path;
|
|
2033
3424
|
if (!existsSync(artifactInTrash)) throw new Error(`trashed archive missing for '${sessionId}'`);
|
|
3425
|
+
const trashLocation = { kind: 'filesystem', sessionId: String(sessionId), cwd: manifest.cwd ?? null, artifact: artifactInTrash };
|
|
2034
3426
|
if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined) {
|
|
2035
3427
|
throw new Error('a live session with this id already exists; restart the harness before restoring');
|
|
2036
3428
|
}
|
|
2037
3429
|
let existingPath = null;
|
|
2038
3430
|
try { existingPath = registry.sessionPaths?.get?.(sessionId); } catch { /* ignore */ }
|
|
2039
3431
|
if (existingPath) throw new Error('session id already exists in the workspace registry');
|
|
2040
|
-
const header = readHeader(
|
|
3432
|
+
const header = readHeader(await storage.read(trashLocation), compression);
|
|
2041
3433
|
const originalCwd = typeof header?.cwd === 'string' ? header.cwd : manifest.cwd;
|
|
2042
3434
|
if (typeof originalCwd !== 'string' || originalCwd.length === 0) throw new Error('trashed manifest carries no original path');
|
|
2043
|
-
const
|
|
3435
|
+
const originalProbe = await storage.locate({ sessionId, cwd: originalCwd });
|
|
3436
|
+
const originalLocation = { ...originalProbe, artifact: join(dirname(originalProbe.artifact), basename(artifactInTrash)) };
|
|
3437
|
+
const originalInfo = await storage.inspect(originalProbe);
|
|
2044
3438
|
let destPath = originalCwd;
|
|
2045
3439
|
let targetEntity = registry.list().find((e) => { try { return samePath(e.path, originalCwd); } catch { return false; } }) ?? null;
|
|
2046
3440
|
if (typeof targetWorkspaceId === 'string' && targetWorkspaceId.length > 0) {
|
|
@@ -2048,16 +3442,15 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
|
|
|
2048
3442
|
if (!targetEntity) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
|
|
2049
3443
|
destPath = targetEntity.path;
|
|
2050
3444
|
}
|
|
2051
|
-
if (
|
|
3445
|
+
if (originalInfo.exists) {
|
|
2052
3446
|
if (!targetEntity || samePath(targetEntity.path, originalCwd)) {
|
|
2053
|
-
throw new Error(`original location is occupied by an archive with the same id: ${
|
|
3447
|
+
throw new Error(`original location is occupied by an archive with the same id: ${originalProbe.artifact}; pass targetWorkspaceId to restore elsewhere`);
|
|
2054
3448
|
}
|
|
2055
3449
|
} else if (!targetEntity) {
|
|
2056
3450
|
throw new Error(`original location '${originalCwd}' has no registered workspace; pass targetWorkspaceId to restore into one`);
|
|
2057
3451
|
}
|
|
2058
3452
|
// 先移回原位(头 cwd 与原位一致,attach 校验天然通过)
|
|
2059
|
-
|
|
2060
|
-
moveDir(sessionDir, dirname(originalArtifact));
|
|
3453
|
+
await storage.relocate(trashLocation, dirname(originalLocation.artifact));
|
|
2061
3454
|
let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, moved: null };
|
|
2062
3455
|
try {
|
|
2063
3456
|
if (targetEntity && !samePath(originalCwd, destPath)) {
|
|
@@ -2078,10 +3471,11 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
|
|
|
2078
3471
|
}
|
|
2079
3472
|
|
|
2080
3473
|
/** 彻底删除:rm 回收站条目。all=true 清空。 */
|
|
2081
|
-
async function purgeTrash(ctx, { sessionId, all } = {}) {
|
|
3474
|
+
async function purgeTrash(ctx, { sessionId, all, purgeBackups = false } = {}) {
|
|
2082
3475
|
const dir = recycleDir();
|
|
2083
|
-
if (!dirExists(dir)) return { purged: 0 };
|
|
3476
|
+
if (!dirExists(dir)) return { purged: 0, backupsDeleted: 0 };
|
|
2084
3477
|
let purged = 0;
|
|
3478
|
+
let backupsDeleted = 0;
|
|
2085
3479
|
for (const entry of readdirSync(dir)) {
|
|
2086
3480
|
const entryDir = join(dir, entry);
|
|
2087
3481
|
if (!dirExists(entryDir)) continue;
|
|
@@ -2089,21 +3483,32 @@ async function purgeTrash(ctx, { sessionId, all } = {}) {
|
|
|
2089
3483
|
if (all || (typeof sessionId === 'string' && manifest?.sessionId === String(sessionId))) {
|
|
2090
3484
|
rmSync(entryDir, { recursive: true, force: true });
|
|
2091
3485
|
purged++;
|
|
3486
|
+
// 彻底删除(不可还原)时才顺带清备份:留在回收站里的会话仍可能靠备份兜底。
|
|
3487
|
+
if (purgeBackups && manifest?.sessionId) {
|
|
3488
|
+
try { backupsDeleted += (await deleteBackup(ctx, { sessionId: String(manifest.sessionId) })).deleted; }
|
|
3489
|
+
catch { /* 备份清理失败不影响回收站条目已删除的事实 */ }
|
|
3490
|
+
}
|
|
2092
3491
|
}
|
|
2093
3492
|
}
|
|
2094
|
-
return { purged };
|
|
3493
|
+
return { purged, backupsDeleted };
|
|
2095
3494
|
}
|
|
2096
3495
|
|
|
2097
|
-
/**
|
|
3496
|
+
/**
|
|
3497
|
+
* 备份聚合清单:按会话分组,兼容旧文件名和带内容指纹的新快照文件名。
|
|
3498
|
+
*
|
|
3499
|
+
* 额外标注孤儿组:会话已被彻底删除(或档案已不存在)时,它历次迁移留下的备份
|
|
3500
|
+
* 不会再被任何操作引用——`stashBackup` 的 20 份裁剪只在"再次备份同一会话"时触发,
|
|
3501
|
+
* 所以这些字节会永久留在磁盘上。标出来用户才可能清掉。
|
|
3502
|
+
*/
|
|
2098
3503
|
async function listBackups(ctx) {
|
|
2099
3504
|
const dir = backupDir();
|
|
2100
|
-
if (!dirExists(dir)) return { items: [], totalBytes: 0, dir };
|
|
3505
|
+
if (!dirExists(dir)) return { items: [], totalBytes: 0, dir, orphanBytes: 0, orphanGroups: 0 };
|
|
2101
3506
|
const titles = readProjectionTitles();
|
|
2102
3507
|
const groups = new Map();
|
|
2103
3508
|
for (const name of readdirSync(dir)) {
|
|
2104
|
-
const
|
|
2105
|
-
if (!
|
|
2106
|
-
const
|
|
3509
|
+
const parsed = parseBackupFilename(name);
|
|
3510
|
+
if (!parsed) continue;
|
|
3511
|
+
const { id, ts } = parsed;
|
|
2107
3512
|
let sizeBytes = 0;
|
|
2108
3513
|
try { sizeBytes = statSync(join(dir, name)).size; } catch { continue; }
|
|
2109
3514
|
const g = groups.get(id) ?? { sessionId: id, title: titles.get(id) ?? null, count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
|
|
@@ -2111,15 +3516,37 @@ async function listBackups(ctx) {
|
|
|
2111
3516
|
g.totalBytes += sizeBytes;
|
|
2112
3517
|
g.oldest = Math.min(g.oldest, Number(ts));
|
|
2113
3518
|
g.newest = Math.max(g.newest, Number(ts));
|
|
2114
|
-
g.files.push({ name, ts: Number(ts), sizeBytes });
|
|
3519
|
+
g.files.push({ name, ts: Number(ts), sizeBytes, fingerprint: parsed.fingerprint });
|
|
2115
3520
|
groups.set(id, g);
|
|
2116
3521
|
}
|
|
3522
|
+
|
|
3523
|
+
// 存活集合:注册表原始记账 ∪ 持久化档案头。任一命中都视为"会话仍在"。
|
|
3524
|
+
const alive = new Set();
|
|
3525
|
+
try {
|
|
3526
|
+
for (const entity of ctx.workspaceRegistry?.list?.() ?? []) {
|
|
3527
|
+
for (const id of rawRecordSessionIds(entity)) alive.add(String(id));
|
|
3528
|
+
}
|
|
3529
|
+
} catch { /* 注册表不可用:只靠档案头判断 */ }
|
|
3530
|
+
try {
|
|
3531
|
+
for (const header of await listPersistenceHeaders(ctx.sessionPersistence)) {
|
|
3532
|
+
if (header?.id !== undefined) alive.add(String(header.id));
|
|
3533
|
+
}
|
|
3534
|
+
} catch { /* 持久化不可用:只靠记账判断 */ }
|
|
3535
|
+
|
|
2117
3536
|
const items = [...groups.values()].map((g) => ({
|
|
2118
3537
|
...g,
|
|
2119
|
-
|
|
3538
|
+
orphan: !alive.has(String(g.sessionId)),
|
|
3539
|
+
files: g.files.sort((x, y) => y.ts - x.ts || y.name.localeCompare(x.name)).map(({ name, ts, sizeBytes, fingerprint }) => ({ name, ts, sizeBytes, fingerprint }))
|
|
2120
3540
|
}));
|
|
2121
3541
|
items.sort((a, b) => b.newest - a.newest);
|
|
2122
|
-
|
|
3542
|
+
const orphanItems = items.filter((it) => it.orphan);
|
|
3543
|
+
return {
|
|
3544
|
+
items,
|
|
3545
|
+
totalBytes: items.reduce((sum, it) => sum + it.totalBytes, 0),
|
|
3546
|
+
orphanGroups: orphanItems.length,
|
|
3547
|
+
orphanBytes: orphanItems.reduce((sum, it) => sum + it.totalBytes, 0),
|
|
3548
|
+
dir
|
|
3549
|
+
};
|
|
2123
3550
|
}
|
|
2124
3551
|
|
|
2125
3552
|
/** 从备份恢复会话:默认回备份里的原 cwd;占用/缺分组时给 targetWorkspaceId。回读校验。 */
|
|
@@ -2138,6 +3565,12 @@ async function restoreBackupLocked(ctx, sessionId, fileName, targetWorkspaceId)
|
|
|
2138
3565
|
const registry = ctx.workspaceRegistry;
|
|
2139
3566
|
const persistence = ctx.sessionPersistence;
|
|
2140
3567
|
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
3568
|
+
const storage = createPersistenceAdapter(ctx);
|
|
3569
|
+
if (!storage.capabilities.physicalPath) {
|
|
3570
|
+
const err = new Error(`${storage.reason}; physical backup restore is unavailable`);
|
|
3571
|
+
err.code = 'unsupported';
|
|
3572
|
+
throw err;
|
|
3573
|
+
}
|
|
2141
3574
|
const dir = backupDir();
|
|
2142
3575
|
if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined) {
|
|
2143
3576
|
throw new Error('a live session with this id exists; restart the harness before restoring');
|
|
@@ -2147,17 +3580,19 @@ async function restoreBackupLocked(ctx, sessionId, fileName, targetWorkspaceId)
|
|
|
2147
3580
|
if (existingPath) throw new Error('session id already exists in the workspace registry');
|
|
2148
3581
|
let path2 = null;
|
|
2149
3582
|
if (typeof fileName === 'string' && fileName.length > 0) {
|
|
2150
|
-
|
|
2151
|
-
|
|
3583
|
+
const safeName = basename(fileName);
|
|
3584
|
+
const parsed = parseBackupFilename(safeName);
|
|
3585
|
+
if (!parsed || parsed.id !== String(sessionId)) throw new Error('backup file does not belong to the given session');
|
|
3586
|
+
path2 = join(dir, safeName);
|
|
2152
3587
|
} else {
|
|
2153
|
-
const candidates =
|
|
2154
|
-
|
|
2155
|
-
|
|
3588
|
+
const candidates = backupFilesFor(sessionId)
|
|
3589
|
+
.sort((a, b) => b.ts - a.ts || String(b.sequence ?? '').localeCompare(String(a.sequence ?? '')))
|
|
3590
|
+
.map((item) => item.name);
|
|
2156
3591
|
if (candidates.length === 0) throw new Error(`no backups found for '${sessionId}'`);
|
|
2157
|
-
path2 = join(dir, candidates[
|
|
3592
|
+
path2 = join(dir, candidates[0]);
|
|
2158
3593
|
}
|
|
2159
3594
|
if (!existsSync(path2)) throw new Error(`backup file not found: ${path2}`);
|
|
2160
|
-
const compression =
|
|
3595
|
+
const compression = storage.compression;
|
|
2161
3596
|
const bytes = readFileSync(path2);
|
|
2162
3597
|
const header = readHeader(bytes, compression);
|
|
2163
3598
|
if (String(header?.id) !== String(sessionId)) throw new Error(`backup header id mismatch: ${header?.id}`);
|
|
@@ -2171,17 +3606,19 @@ async function restoreBackupLocked(ctx, sessionId, fileName, targetWorkspaceId)
|
|
|
2171
3606
|
destCwd = targetEntity.path;
|
|
2172
3607
|
}
|
|
2173
3608
|
if (!targetEntity) throw new Error(`backup cwd '${backupCwd}' has no registered workspace; pass targetWorkspaceId to restore into one`);
|
|
2174
|
-
const
|
|
2175
|
-
if (
|
|
3609
|
+
const destinationProbe = await storage.locate({ sessionId, cwd: destCwd });
|
|
3610
|
+
if ((await storage.inspect(destinationProbe)).exists) throw new Error(`destination artifact already exists under: ${dirname(destinationProbe.artifact)}`);
|
|
3611
|
+
const dstDir = dirname(destinationProbe.artifact);
|
|
2176
3612
|
const dstArtifact = join(dstDir, preferredArtifactName(compression));
|
|
3613
|
+
const destinationLocation = { ...destinationProbe, cwd: destCwd, artifact: dstArtifact };
|
|
2177
3614
|
const final = samePath(backupCwd, destCwd) ? bytes : rewriteHeaderCwd(bytes, destCwd, compression);
|
|
2178
3615
|
let created = false;
|
|
2179
3616
|
try {
|
|
2180
3617
|
mkdirSync(dstDir, { recursive: true });
|
|
2181
|
-
|
|
3618
|
+
await storage.write(destinationLocation, final);
|
|
2182
3619
|
created = existsSync(dstArtifact);
|
|
2183
3620
|
// 回读校验:id 与 cwd 双确认后才挂账
|
|
2184
|
-
const verify = readHeader(
|
|
3621
|
+
const verify = readHeader(await storage.read(destinationLocation), compression);
|
|
2185
3622
|
if (String(verify?.id) !== String(sessionId) || !verify?.cwd || !samePath(verify.cwd, destCwd)) {
|
|
2186
3623
|
throw new Error('restore verification failed (header round-trip mismatch)');
|
|
2187
3624
|
}
|
|
@@ -2200,10 +3637,12 @@ async function deleteBackup(ctx, { sessionId, fileName } = {}) {
|
|
|
2200
3637
|
if (!dirExists(dir)) return { deleted: 0 };
|
|
2201
3638
|
let targets = [];
|
|
2202
3639
|
if (typeof fileName === 'string' && fileName.length > 0) {
|
|
2203
|
-
|
|
2204
|
-
|
|
3640
|
+
const safeName = basename(fileName);
|
|
3641
|
+
const parsed = parseBackupFilename(safeName);
|
|
3642
|
+
if (!parsed || parsed.id !== String(sessionId)) throw new Error('backup file does not belong to the given session');
|
|
3643
|
+
targets = [safeName];
|
|
2205
3644
|
} else {
|
|
2206
|
-
targets =
|
|
3645
|
+
targets = backupFilesFor(sessionId).map((item) => item.name);
|
|
2207
3646
|
}
|
|
2208
3647
|
let deleted = 0;
|
|
2209
3648
|
for (const name of targets) {
|
|
@@ -2286,10 +3725,11 @@ const JSON_TEXT = (args, value) => [{ type: 'text', text: JSON.stringify(value)
|
|
|
2286
3725
|
const LIST_MAX_SESSIONS = 40;
|
|
2287
3726
|
|
|
2288
3727
|
/**
|
|
2289
|
-
*
|
|
3728
|
+
* 构造四个 agent 工具(复用 RPC dispatch:锁、错误码、事务语义与面板完全一致):
|
|
2290
3729
|
* - mover_list_sessions 只读:会话/分组清单,供模型把用户口中的名字解析成精确 id
|
|
2291
3730
|
* - mover_move_session 迁移单个会话(审批门)
|
|
2292
3731
|
* - mover_repair_sessions 一键修复(审批门,只动记账不动文件)
|
|
3732
|
+
* - mover_doctor 只读自检(无审批门)
|
|
2293
3733
|
*/
|
|
2294
3734
|
function createAgentTools(ctx, dispatchRef) {
|
|
2295
3735
|
const SIGNAL_NOTE = 'Cancellation is cooperative: the in-flight step finishes rolling back to a consistent state before stopping.';
|
|
@@ -2329,7 +3769,7 @@ function createAgentTools(ctx, dispatchRef) {
|
|
|
2329
3769
|
},
|
|
2330
3770
|
{
|
|
2331
3771
|
name: 'mover_move_session',
|
|
2332
|
-
description: `Move one DSH session into another workspace group with full protection: byte-level backup is taken before anything changes, then the session header is rewritten, the archive directory is moved atomically, the result is verified by re-reading, and any failure rolls everything back automatically. The session keeps its id and full history; a post-move record enables one-click undo. ${SIGNAL_NOTE} Ask the user to confirm the target group before calling, then pass the exact ids from mover_list_sessions.`,
|
|
3772
|
+
description: `Move one DSH session into another workspace group with full protection: byte-level backup is taken before anything changes, then the session header is rewritten, the archive directory is moved atomically, the result is verified by re-reading, and any failure rolls everything back automatically. The session keeps its id and full history; a post-move record enables one-click undo. If the session is running, no file is touched: the move is persisted in the busy queue and the call returns busy with a queueTaskId. ${SIGNAL_NOTE} Ask the user to confirm the target group before calling, then pass the exact ids from mover_list_sessions.`,
|
|
2333
3773
|
parameters: {
|
|
2334
3774
|
type: 'object',
|
|
2335
3775
|
properties: {
|
|
@@ -2409,8 +3849,21 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
2409
3849
|
}
|
|
2410
3850
|
|
|
2411
3851
|
if (!persistence) fail('host-persistence', 'session persistence service unavailable');
|
|
2412
|
-
else
|
|
2413
|
-
|
|
3852
|
+
else {
|
|
3853
|
+
const storage = storageDiagnostics(ctx);
|
|
3854
|
+
if (storage.mode === 'filesystem-fallback' && dirExists(persistence.root)) {
|
|
3855
|
+
pass('host-persistence', `${storage.mode}: ${persistence.root}`);
|
|
3856
|
+
} else if (storage.mode === 'official-handle') {
|
|
3857
|
+
pass('host-persistence', `${storage.mode}: public list/stat/open available; physical path unavailable`);
|
|
3858
|
+
} else {
|
|
3859
|
+
fail('host-persistence', `${storage.mode}: ${storage.reason}`);
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
const storage = storageDiagnostics(ctx);
|
|
3864
|
+
if (storage.mode === 'filesystem-fallback') warn('storage-adapter', storage.reason);
|
|
3865
|
+
else if (storage.mode === 'official-handle') pass('storage-adapter', storage.reason);
|
|
3866
|
+
else fail('storage-adapter', storage.reason);
|
|
2414
3867
|
|
|
2415
3868
|
const projection = projectionTable(ctx);
|
|
2416
3869
|
const projectionRead = typeof projection?.get === 'function';
|
|
@@ -2434,7 +3887,12 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
2434
3887
|
try { const trash = await listTrash(ctx); pass('data-recycle', `${trash.items.length} entries`); }
|
|
2435
3888
|
catch (err) { fail('data-recycle', String(err?.message ?? err).slice(0, 140)); }
|
|
2436
3889
|
|
|
2437
|
-
try {
|
|
3890
|
+
try {
|
|
3891
|
+
const backups = await listBackups(ctx);
|
|
3892
|
+
const orphanNote = backups.orphanGroups > 0 ? `, ${backups.orphanGroups} orphan group(s) (${backups.orphanBytes} bytes)` : '';
|
|
3893
|
+
const snapshotCount = backups.items.reduce((sum, item) => sum + Number(item.count || 0), 0);
|
|
3894
|
+
pass('data-backups', `${backups.items.length} session groups, ${snapshotCount} physical snapshot(s)${orphanNote}`);
|
|
3895
|
+
}
|
|
2438
3896
|
catch (err) { fail('data-backups', String(err?.message ?? err).slice(0, 140)); }
|
|
2439
3897
|
|
|
2440
3898
|
try { const history = await listHistory(); pass('data-history', `${history.items?.length ?? 0} records`); }
|
|
@@ -2443,10 +3901,30 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
2443
3901
|
try { const tasks = await listTasks(); pass('data-tasks', `${tasks.items?.length ?? 0} records`); }
|
|
2444
3902
|
catch (err) { fail('data-tasks', String(err?.message ?? err).slice(0, 140)); }
|
|
2445
3903
|
|
|
3904
|
+
try {
|
|
3905
|
+
const queue = migrationQueueSummary();
|
|
3906
|
+
if (queue.error) fail('data-migrations', queue.error.slice(0, 140));
|
|
3907
|
+
else if (queue.pending > 0) warn('data-migrations', `${queue.pending} pending, ${queue.failed} failed, ${queue.done} done`);
|
|
3908
|
+
else pass('data-migrations', `${queue.total} records`);
|
|
3909
|
+
} catch (err) { fail('data-migrations', String(err?.message ?? err).slice(0, 140)); }
|
|
3910
|
+
|
|
2446
3911
|
const recovered = recoveryCount();
|
|
2447
3912
|
if (recovered === 0) pass('recovery', 'no manual-recovery records');
|
|
2448
3913
|
else warn('recovery', `${recovered} record(s) need manual review — see the rescue panel`);
|
|
2449
3914
|
|
|
3915
|
+
// 存储布局自证:把"所有会话突然都找不到"提前变成一条可见的降级提示。
|
|
3916
|
+
try {
|
|
3917
|
+
const layout = await verifyStorageLayout(ctx);
|
|
3918
|
+
if (layout.generationWarning) warn('data-layout', layout.generationWarning);
|
|
3919
|
+
else if (layout.status === 'ok' && layout.physicalPath === false) pass('data-layout', `official provider metadata available (${layout.checked} session(s); physical path adapter unavailable)`);
|
|
3920
|
+
else if (layout.status === 'ok') pass('data-layout', `derived directory verified (${layout.checked} sample(s) via ${layout.adapter ?? 'filesystem'})`);
|
|
3921
|
+
else if (layout.status === 'empty') warn('data-layout', 'no stored sessions to verify against (fresh install?)');
|
|
3922
|
+
else if (layout.status === 'unavailable') warn('data-layout', layout.reason ?? 'storage layout cannot be verified');
|
|
3923
|
+
else warn('data-layout', `storage layout may have changed — ${layout.reason ?? 'no archive found at the derived path'}`);
|
|
3924
|
+
} catch (err) {
|
|
3925
|
+
warn('data-layout', String(err?.message ?? err).slice(0, 140));
|
|
3926
|
+
}
|
|
3927
|
+
|
|
2450
3928
|
if (!registry) warn('workspaces', 'skipped — registry unavailable');
|
|
2451
3929
|
else {
|
|
2452
3930
|
let total = 0;
|
|
@@ -2486,26 +3964,48 @@ export function apply(ctx) {
|
|
|
2486
3964
|
switch (endpoint) {
|
|
2487
3965
|
case 'mover.status': {
|
|
2488
3966
|
const projection = projectionTable(ctx);
|
|
3967
|
+
const layout = await verifyStorageLayout(ctx);
|
|
3968
|
+
const storage = storageDiagnostics(ctx);
|
|
2489
3969
|
const capabilities = {
|
|
2490
3970
|
registry: Boolean(ctx.workspaceRegistry),
|
|
2491
3971
|
persistence: Boolean(ctx.sessionPersistence),
|
|
2492
3972
|
projectionRead: typeof projection?.get === 'function',
|
|
2493
3973
|
projectionWrite: typeof projection?.put === 'function' && typeof projection?.delete === 'function',
|
|
2494
3974
|
archiveChannel: typeof ctx.workspaceRegistry?.enqueueOperation === 'function',
|
|
3975
|
+
// v2.2 起优先使用的官方文档化接口。是否走官方通道必须**可观测**:
|
|
3976
|
+
// 缺失时会静默回退到等价实现(功能不变),但从外部看不出差异。
|
|
3977
|
+
unarchiveApi: typeof ctx.workspaceRegistry?.unarchiveSession === 'function',
|
|
3978
|
+
setTitleApi: hasEntityApi(ctx.workspaceRegistry, 'setTitle'),
|
|
2495
3979
|
coordinatorStates: Boolean(peekService(ctx, 'sessionPersistence')?.coordinator?.states),
|
|
2496
3980
|
fileReferences: Boolean(peekService(ctx, 'fileReferences')),
|
|
2497
|
-
agents: Boolean(peekService(ctx, 'agents'))
|
|
3981
|
+
agents: Boolean(peekService(ctx, 'agents')),
|
|
3982
|
+
storageLayout: layout.status,
|
|
3983
|
+
storageAdapter: storage.mode,
|
|
3984
|
+
storageApi: storage.capabilities,
|
|
3985
|
+
physicalPath: storage.physicalPath,
|
|
3986
|
+
migrationQueue: true,
|
|
3987
|
+
forkTree: true,
|
|
3988
|
+
portableBundle: true,
|
|
3989
|
+
backupSnapshots: true,
|
|
3990
|
+
migrationQueueState: migrationQueueSummary()
|
|
2498
3991
|
};
|
|
2499
3992
|
const degradedFeatures = [];
|
|
2500
3993
|
if (!capabilities.projectionWrite) degradedFeatures.push('projection-title-restore');
|
|
2501
3994
|
if (!capabilities.archiveChannel) degradedFeatures.push('archive-set-changes');
|
|
3995
|
+
if (!capabilities.unarchiveApi) degradedFeatures.push('unarchive-official-api');
|
|
3996
|
+
if (!capabilities.setTitleApi) degradedFeatures.push('set-title-official-api');
|
|
2502
3997
|
if (!capabilities.coordinatorStates) degradedFeatures.push('resident-hotfix');
|
|
2503
3998
|
if (!capabilities.fileReferences) degradedFeatures.push('file-reference-cleanup');
|
|
3999
|
+
if (layout.status === 'degraded') degradedFeatures.push('storage-layout');
|
|
4000
|
+
if (storage.mode === 'filesystem-fallback') degradedFeatures.push('storage-official-handle');
|
|
4001
|
+
if (!storage.physicalPath) degradedFeatures.push('physical-path-migration');
|
|
4002
|
+
if (capabilities.migrationQueueState.error) degradedFeatures.push('migration-queue-data');
|
|
2504
4003
|
return ok({
|
|
2505
4004
|
ready: Boolean(capabilities.registry && capabilities.persistence),
|
|
2506
4005
|
channel: CHANNEL,
|
|
2507
4006
|
capabilities,
|
|
2508
4007
|
degradedFeatures,
|
|
4008
|
+
storageLayout: layout,
|
|
2509
4009
|
agentTools: agentToolsState.registered,
|
|
2510
4010
|
recoveryCount: recoveryCount()
|
|
2511
4011
|
});
|
|
@@ -2537,11 +4037,60 @@ export function apply(ctx) {
|
|
|
2537
4037
|
const targetWorkspaceId = payload?.targetWorkspaceId;
|
|
2538
4038
|
if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('sessionId required');
|
|
2539
4039
|
if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
|
|
4040
|
+
if (payload?.mode === 'include-descendants') {
|
|
4041
|
+
return ok(await moveManySessions(ctx, {
|
|
4042
|
+
sessions: [{ sessionId, sessionTitle: payload?.sessionTitle }],
|
|
4043
|
+
targetWorkspaceId,
|
|
4044
|
+
mode: 'include-descendants'
|
|
4045
|
+
}));
|
|
4046
|
+
}
|
|
2540
4047
|
const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle });
|
|
2541
4048
|
logger.info?.(`workspace-mover: moved ${sessionId} -> ${result.to.cwd}`);
|
|
2542
4049
|
return ok(result);
|
|
2543
4050
|
} catch (err) {
|
|
2544
4051
|
logger.warn?.(`workspace-mover: MOVE FAILED sessionId=${payload?.sessionId} target=${payload?.targetWorkspaceId}: ${err?.message ?? err}`);
|
|
4052
|
+
if (isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
|
|
4053
|
+
const queued = createOrReuseMigration(ctx, {
|
|
4054
|
+
sessionId: payload.sessionId,
|
|
4055
|
+
targetWorkspaceId: payload.targetWorkspaceId,
|
|
4056
|
+
title: payload.sessionTitle,
|
|
4057
|
+
reason: String(err?.message ?? err)
|
|
4058
|
+
});
|
|
4059
|
+
return fail('busy', String(err?.message ?? err), {
|
|
4060
|
+
queueTaskId: queued.id,
|
|
4061
|
+
queueState: queued.state,
|
|
4062
|
+
sessionId: payload.sessionId,
|
|
4063
|
+
targetWorkspaceId: payload.targetWorkspaceId
|
|
4064
|
+
});
|
|
4065
|
+
}
|
|
4066
|
+
return mapError(err);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
case 'mover.preview': {
|
|
4070
|
+
try {
|
|
4071
|
+
return ok(await inspectMovePlan(ctx, payload ?? {}));
|
|
4072
|
+
} catch (err) {
|
|
4073
|
+
return mapError(err);
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
case 'mover.bundle.export': {
|
|
4077
|
+
try {
|
|
4078
|
+
return ok(await exportPortableBundle(ctx, payload ?? {}));
|
|
4079
|
+
} catch (err) {
|
|
4080
|
+
return mapError(err);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
case 'mover.bundle.inspect': {
|
|
4084
|
+
try {
|
|
4085
|
+
return ok(await inspectPortableBundle(ctx, payload ?? {}));
|
|
4086
|
+
} catch (err) {
|
|
4087
|
+
return mapError(err);
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
case 'mover.bundle.import': {
|
|
4091
|
+
try {
|
|
4092
|
+
return ok(await importPortableBundle(ctx, payload ?? {}));
|
|
4093
|
+
} catch (err) {
|
|
2545
4094
|
return mapError(err);
|
|
2546
4095
|
}
|
|
2547
4096
|
}
|
|
@@ -2640,6 +4189,29 @@ export function apply(ctx) {
|
|
|
2640
4189
|
return mapError(err);
|
|
2641
4190
|
}
|
|
2642
4191
|
}
|
|
4192
|
+
case 'mover.migrations.enqueue': {
|
|
4193
|
+
try {
|
|
4194
|
+
return ok(await enqueueMigrations(ctx, payload ?? {}));
|
|
4195
|
+
} catch (err) {
|
|
4196
|
+
return mapError(err);
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
case 'mover.migrations.list':
|
|
4200
|
+
return ok(await listMigrations(ctx));
|
|
4201
|
+
case 'mover.migrations.retry': {
|
|
4202
|
+
try {
|
|
4203
|
+
return ok(await retryMigration(ctx, payload ?? {}));
|
|
4204
|
+
} catch (err) {
|
|
4205
|
+
return mapError(err);
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
case 'mover.migrations.cancel': {
|
|
4209
|
+
try {
|
|
4210
|
+
return ok(await cancelMigration(payload ?? {}));
|
|
4211
|
+
} catch (err) {
|
|
4212
|
+
return mapError(err);
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
2643
4215
|
case 'mover.data.cleanup': {
|
|
2644
4216
|
try {
|
|
2645
4217
|
return ok(await cleanupOldData(ctx, payload ?? {}));
|
|
@@ -2674,6 +4246,18 @@ export function apply(ctx) {
|
|
|
2674
4246
|
}
|
|
2675
4247
|
|
|
2676
4248
|
registerAgentTools(ctx, dispatchRef, logger, agentToolsState);
|
|
4249
|
+
startMigrationRuntime(ctx, ctx, logger);
|
|
4250
|
+
|
|
4251
|
+
// 官方 Config:**暂不注册**,只导出构造能力。
|
|
4252
|
+
//
|
|
4253
|
+
// 官方 schema 来自 @deepseek-ai/schemastery,由宿主 loader 提供;本插件目录下
|
|
4254
|
+
// 实测无法解析该模块(npm 上的 schemastery 也没有 .volatile)。而静态 import
|
|
4255
|
+
// 一个不可解析的模块会打断 **boot**(官方记录的 bundle 层事故类别)。
|
|
4256
|
+
//
|
|
4257
|
+
// 因此这里刻意不向任何未文档化的服务名做 inject——那只会让 fiber 处于半装载
|
|
4258
|
+
// 状态。setup 已经就绪:宿主一旦提供文档化的 schema 接缝,把构造器交给
|
|
4259
|
+
// lib/config.js 的 buildConfig() 即可(已单测覆盖)。在此之前,全部策略都由
|
|
4260
|
+
// 环境变量(见 resolvePolicy)与代码默认值驱动,功能完整。
|
|
2677
4261
|
}
|
|
2678
4262
|
|
|
2679
|
-
export { name, inject, CHANNEL, projectKey, encodeSegment, sessionDir, artifactPath, generationLogFilename, parseGenerationLogFilename, listGenerationArtifacts, findHighestArtifact, preferredArtifactName, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, moveSessionEntry, validId, SCAN_MAX_ITEMS };
|
|
4263
|
+
export { name, inject, CHANNEL, projectKey, encodeSegment, sessionDir, artifactPath, generationLogFilename, parseGenerationLogFilename, listGenerationArtifacts, findHighestArtifact, preferredArtifactName, persistenceCapabilities, createPersistenceAdapter, storageDiagnostics, inspectMovePlan, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, moveSessionEntry, validId, SCAN_MAX_ITEMS, POLICY_DEFAULTS, resolvePolicy, verifyStorageLayout, rawRecordSessionIds, historyLimit, backupKeep, batchLimit, repointLimit, scanLimit, cleanupDays, Config, POLICY_FIELDS, buildConfig, attachConfig };
|