dsh-workspace-mover 2.2.0 → 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 +40 -8
- package/README_EN.md +346 -0
- package/client/client.js +376 -37
- package/docs/ARCHITECTURE.md +17 -9
- 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/index.js +1465 -141
- package/package.json +102 -99
- package/test/client-dom.test.mjs +129 -0
- package/test/e2e-sandbox.test.mjs +417 -10
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,10 +32,11 @@
|
|
|
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';
|
|
41
42
|
import { Config, POLICY_FIELDS, buildConfig, attachConfig } from './config.js';
|
|
@@ -50,6 +51,7 @@ const ZSTD_MAGIC = 4247762216;
|
|
|
50
51
|
/** Current write generation: DSH 0.1.5 treats the highest canonical generation as source of truth. */
|
|
51
52
|
const CURRENT_GENERATION = 3;
|
|
52
53
|
const HISTORY_FILE = 'history.json';
|
|
54
|
+
const migrationRuntimes = new WeakMap();
|
|
53
55
|
|
|
54
56
|
//#region 可调策略
|
|
55
57
|
/**
|
|
@@ -244,6 +246,143 @@ async function listPersistenceHeaders(persistence) {
|
|
|
244
246
|
if (!Array.isArray(snapshots)) return [];
|
|
245
247
|
return snapshots.map(persistenceHeader).filter((header) => header && typeof header === typeof {});
|
|
246
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
|
+
}
|
|
247
386
|
function findDestinationArtifact(dir, compression) {
|
|
248
387
|
try {
|
|
249
388
|
if (existsSync(dir) && !statSync(dir).isDirectory()) {
|
|
@@ -414,8 +553,8 @@ function moveDir(srcDir, dstDir, hooks = {}) {
|
|
|
414
553
|
* 迁移后一致性校验:目标档案可回读、id 与 cwd 双确认。
|
|
415
554
|
* 复制路径的静默截断、头改写遗漏都会在这里现形。
|
|
416
555
|
*/
|
|
417
|
-
function
|
|
418
|
-
const header = readHeader(
|
|
556
|
+
function verifyRelocatedBytes(bytes, sessionId, expectedCwd, compression = 'zstd') {
|
|
557
|
+
const header = readHeader(bytes, compression);
|
|
419
558
|
if (String(header?.id) !== String(sessionId)) {
|
|
420
559
|
throw new Error(`header id mismatch: expected '${sessionId}', got '${header?.id}'`);
|
|
421
560
|
}
|
|
@@ -424,6 +563,10 @@ function verifyRelocatedArtifact(dstArtifact, sessionId, expectedCwd, compressio
|
|
|
424
563
|
}
|
|
425
564
|
return true;
|
|
426
565
|
}
|
|
566
|
+
|
|
567
|
+
function verifyRelocatedArtifact(dstArtifact, sessionId, expectedCwd, compression = 'zstd') {
|
|
568
|
+
return verifyRelocatedBytes(readFileSync(dstArtifact), sessionId, expectedCwd, compression);
|
|
569
|
+
}
|
|
427
570
|
//#endregion
|
|
428
571
|
|
|
429
572
|
//#region 备份管理($DSH_HOME/workspace-mover/backups,保留最近 20 份)
|
|
@@ -433,46 +576,141 @@ function backupDir() {
|
|
|
433
576
|
}
|
|
434
577
|
|
|
435
578
|
/**
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* 为什么需要 `-seq`:毫秒时间戳不足以区分同一毫秒内的两次备份,而 Linux 的时钟/文件系统
|
|
439
|
-
* 时间戳粒度比 Windows 粗得多,连续两次 stash 极可能落在同一毫秒。此时文件名相撞,
|
|
440
|
-
* 第二次会**覆盖**第一次——用户配置的保留份数(backupKeep)于是静默失效,实际只留下
|
|
441
|
-
* 一份。这不是假设:CI 的 ubuntu / macos 作业就是这样挂掉的。
|
|
442
|
-
*
|
|
443
|
-
* 命名规则:
|
|
444
|
-
* - 无 `-seq`:`atomicWrite` 的 temp+rename 是覆盖语义,等于"同毫秒最后一次胜出",
|
|
445
|
-
* 同时把该毫秒的文件数限制为 1(保留原有测试断言的"同毫秒覆盖"行为,也避免浪费空间)。
|
|
446
|
-
* - 带 `-seq`:该毫秒已被占用时递增,保证每次调用都留下独立的备份文件。
|
|
447
|
-
* 两种形态的排序都保持近似的"旧→新"顺序(`-` = 0x2D < `.` = 0x2E,所以 `ts` 排在
|
|
448
|
-
* `ts-seq` 之前,`ts-seq2` 又排在 `ts-seq10` 之前——同一毫秒内顺序仅影响裁剪,不影响正确性)。
|
|
579
|
+
* New snapshots include a content fingerprint. Legacy timestamp-only names
|
|
580
|
+
* remain readable so existing user backups are not orphaned by the upgrade.
|
|
449
581
|
*/
|
|
450
|
-
|
|
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) {
|
|
451
629
|
const dir = backupDir();
|
|
452
630
|
const ts = Date.now();
|
|
453
|
-
const
|
|
631
|
+
const suffix = `${fingerprint}.zstd`;
|
|
632
|
+
const base = join(dir, `${id}.${ts}.${suffix}`);
|
|
454
633
|
if (!existsSync(base)) return base;
|
|
455
634
|
for (let seq = 2; seq < 1000; seq++) {
|
|
456
|
-
const candidate = join(dir, `${id}.${ts}-${seq}
|
|
635
|
+
const candidate = join(dir, `${id}.${ts}-${seq}.${suffix}`);
|
|
457
636
|
if (!existsSync(candidate)) return candidate;
|
|
458
637
|
}
|
|
459
|
-
|
|
460
|
-
|
|
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
|
+
}
|
|
461
651
|
}
|
|
462
652
|
|
|
463
|
-
|
|
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);
|
|
464
661
|
try {
|
|
465
662
|
const dir = backupDir();
|
|
466
663
|
mkdirSync(dir, { recursive: true });
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
+
};
|
|
471
691
|
} catch (err) {
|
|
472
692
|
throw new Error(`backup failed (refusing to move without a stash): ${err?.message ?? err}`);
|
|
473
693
|
}
|
|
474
694
|
}
|
|
475
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
|
+
|
|
476
714
|
function historyPath() {
|
|
477
715
|
|
|
478
716
|
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
@@ -503,7 +741,8 @@ function rememberMove(result, sourceWorkspaceId, targetWorkspaceId) {
|
|
|
503
741
|
targetWorkspaceId,
|
|
504
742
|
from: result.from.cwd,
|
|
505
743
|
to: result.to.cwd,
|
|
506
|
-
movedAt: new Date().toISOString()
|
|
744
|
+
movedAt: new Date().toISOString(),
|
|
745
|
+
backup: result.backup ?? null
|
|
507
746
|
};
|
|
508
747
|
const history = readHistory();
|
|
509
748
|
history.push(entry);
|
|
@@ -523,7 +762,8 @@ function rememberBatchMove(movedItems, targetWorkspaceId, targetPath) {
|
|
|
523
762
|
sessionId: String(item.sessionId),
|
|
524
763
|
title: item.title,
|
|
525
764
|
from: item.fromCwd,
|
|
526
|
-
sourceWorkspaceId: item.sourceWorkspaceId ?? null
|
|
765
|
+
sourceWorkspaceId: item.sourceWorkspaceId ?? null,
|
|
766
|
+
backup: item.backup ?? null
|
|
527
767
|
}))
|
|
528
768
|
};
|
|
529
769
|
const history = readHistory();
|
|
@@ -669,6 +909,12 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
669
909
|
const registry = ctx.workspaceRegistry;
|
|
670
910
|
const persistence = ctx.sessionPersistence;
|
|
671
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
|
+
}
|
|
672
918
|
|
|
673
919
|
// ---- 目标实体 ----
|
|
674
920
|
const target = registry.get(targetWorkspaceId);
|
|
@@ -721,21 +967,27 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
721
967
|
}
|
|
722
968
|
|
|
723
969
|
// ---- 文件路径 ----
|
|
724
|
-
const root =
|
|
970
|
+
const root = storage.root;
|
|
725
971
|
if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
|
|
726
|
-
const compression =
|
|
727
|
-
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);
|
|
728
977
|
const dstDir = sessionDir(root, targetPath, sessionId);
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
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 };
|
|
734
984
|
|
|
735
985
|
// ---- 备份先行:备份失败 = 零副作用(尚未触碰记账/索引/目标路径)----
|
|
736
|
-
const original =
|
|
986
|
+
const original = await storage.read(sourceLocation);
|
|
987
|
+
validateArtifactGeneration(srcArtifact, readHeader(original, compression));
|
|
988
|
+
let backup = null;
|
|
737
989
|
try {
|
|
738
|
-
stashImpl(String(sessionId), original);
|
|
990
|
+
backup = stashImpl(String(sessionId), original, { compression, ignoreCwd: true }) ?? null;
|
|
739
991
|
} catch (err) {
|
|
740
992
|
const e = new Error(`backup failed, nothing was changed: ${err?.message ?? err}`);
|
|
741
993
|
e.code = 'backup-failed';
|
|
@@ -766,7 +1018,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
766
1018
|
// 提示性检查(Windows 下与实际写入存在竞态):不通过则尽早失败(此时零改动),
|
|
767
1019
|
// 通过也不构成保证——真正的兜底是复制失败清理 + 迁移后回读校验。
|
|
768
1020
|
const st = statfsSync(dirname(dstArtifact));
|
|
769
|
-
const needed = Math.max(Number(st.bsize) || 4096,
|
|
1021
|
+
const needed = Math.max(Number(st.bsize) || 4096, Number(sourceInfo.sizeBytes) * 2);
|
|
770
1022
|
if (typeof st.bfree === 'number' && st.bfree * Number(st.bsize || 4096) < needed) {
|
|
771
1023
|
const e = new Error(`insufficient disk space at target (need ~${needed} bytes, free ~${st.bfree * Number(st.bsize || 4096)})`);
|
|
772
1024
|
e.code = 'insufficient-space';
|
|
@@ -784,19 +1036,19 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
784
1036
|
const rewritten = rewriteHeaderCwd(original, targetPath, compression);
|
|
785
1037
|
|
|
786
1038
|
try {
|
|
787
|
-
|
|
1039
|
+
await storage.replace(sourceLocation, rewritten);
|
|
788
1040
|
} catch (err) {
|
|
789
1041
|
for (const e of allOwners) { try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ } }
|
|
790
1042
|
throw new Error(`move failed while rewriting header: ${err?.message ?? err}`);
|
|
791
1043
|
}
|
|
792
1044
|
|
|
793
1045
|
try {
|
|
794
|
-
const how =
|
|
1046
|
+
const how = await storage.relocate(sourceLocation, dirname(dstArtifact));
|
|
1047
|
+
const targetLocation = { ...sourceLocation, cwd: targetPath, artifact: dstArtifact };
|
|
795
1048
|
// 迁移后一致性校验:目标档案可回读、id/cwd 双确认。失败视同搬运失败,整体回退。
|
|
796
1049
|
try {
|
|
797
|
-
|
|
1050
|
+
verifyRelocatedBytes(await storage.read(targetLocation), sessionId, targetPath, compression);
|
|
798
1051
|
} catch (verifyErr) {
|
|
799
|
-
try { moveDir(dirname(dstArtifact), dirname(srcArtifact)); } catch { /* 尽力移回 */ }
|
|
800
1052
|
throw new Error(`post-move verification failed: ${verifyErr?.message ?? verifyErr}`);
|
|
801
1053
|
}
|
|
802
1054
|
if (how !== 'rename') ctx.logger?.info?.('workspace-mover: directory rename fell back to copy+delete');
|
|
@@ -806,9 +1058,28 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
806
1058
|
if (existsSync(srcProject) && readdirSync(srcProject).length === 0) rmSync(srcProject, { recursive: true });
|
|
807
1059
|
} catch { /* ignore */ }
|
|
808
1060
|
} catch (err) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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}`);
|
|
812
1083
|
}
|
|
813
1084
|
|
|
814
1085
|
// ---- 内存一致性收尾(版本敏感,降级安全)----
|
|
@@ -861,9 +1132,14 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
861
1132
|
if (priorHeader === undefined) registry.headers?.delete?.(sessionId);
|
|
862
1133
|
else registry.headers?.set?.(sessionId, priorHeader);
|
|
863
1134
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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);
|
|
867
1143
|
// 索引无需再清:非常驻路径在收尾时已清空、由源 attach 重新记账;
|
|
868
1144
|
// 常驻路径上一段已按移动前快照还原。
|
|
869
1145
|
for (const e of allOwners) {
|
|
@@ -872,7 +1148,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
872
1148
|
} catch (rollbackErr) {
|
|
873
1149
|
recordRecovery(ctx, {
|
|
874
1150
|
kind: 'move', phase: 'attach-rollback', sessionId: String(sessionId),
|
|
875
|
-
source: sourceCwd, target: targetPath, backup: backupDir(),
|
|
1151
|
+
source: sourceCwd, target: targetPath, backup: backup?.fileName ? join(backupDir(), backup.fileName) : backupDir(),
|
|
876
1152
|
lastError: `attach failed (${err?.message ?? err}) AND rollback failed (${rollbackErr?.message ?? rollbackErr})`
|
|
877
1153
|
});
|
|
878
1154
|
throw new Error(`move failed during attach (${err?.message ?? err}) AND rollback failed (${rollbackErr?.message ?? rollbackErr}). Original bytes stashed under ${backupDir()}`);
|
|
@@ -902,7 +1178,8 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
902
1178
|
to: { cwd: targetPath, workspaceId: targetWorkspaceId },
|
|
903
1179
|
cacheInvalidated,
|
|
904
1180
|
restartHint,
|
|
905
|
-
warnings
|
|
1181
|
+
warnings,
|
|
1182
|
+
backup
|
|
906
1183
|
};
|
|
907
1184
|
if (recordHistory) {
|
|
908
1185
|
try {
|
|
@@ -945,6 +1222,31 @@ function dirExists(p) {
|
|
|
945
1222
|
}
|
|
946
1223
|
}
|
|
947
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
|
+
|
|
948
1250
|
/**
|
|
949
1251
|
* 存储布局自证(只读,永不抛错)。
|
|
950
1252
|
*
|
|
@@ -962,38 +1264,118 @@ function dirExists(p) {
|
|
|
962
1264
|
async function verifyStorageLayout(ctx) {
|
|
963
1265
|
const persistence = ctx?.sessionPersistence;
|
|
964
1266
|
if (!persistence) return { status: 'unavailable', checked: 0, reason: 'session persistence service unavailable' };
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
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 };
|
|
968
1282
|
}
|
|
969
1283
|
let headers;
|
|
970
1284
|
try {
|
|
971
|
-
headers = await
|
|
1285
|
+
headers = await adapter.list();
|
|
972
1286
|
} catch (err) {
|
|
973
1287
|
return { status: 'unavailable', checked: 0, reason: `persistence.list() failed: ${err?.message ?? err}` };
|
|
974
1288
|
}
|
|
975
|
-
|
|
976
|
-
|
|
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
|
+
}
|
|
977
1320
|
// 取最新几条做样本:编码边界(多字节/分隔符/驱动器号)更容易在其中一条上暴露
|
|
978
1321
|
const sample = headers.slice(0, 5);
|
|
979
1322
|
for (const header of sample) {
|
|
980
1323
|
const { id, cwd } = header;
|
|
981
1324
|
if (id === undefined || typeof cwd !== 'string' || cwd.length === 0) continue;
|
|
982
1325
|
const expectedDir = sessionDir(root, cwd, id);
|
|
1326
|
+
let artifacts;
|
|
983
1327
|
try {
|
|
984
|
-
|
|
985
|
-
|
|
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
|
+
};
|
|
986
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
|
+
};
|
|
987
1363
|
} catch (err) {
|
|
988
|
-
|
|
989
|
-
return {
|
|
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
|
+
};
|
|
990
1369
|
}
|
|
991
1370
|
}
|
|
992
1371
|
return {
|
|
993
1372
|
status: 'degraded',
|
|
1373
|
+
diagnostic: 'layout-unknown',
|
|
994
1374
|
checked: sample.length,
|
|
995
1375
|
path: root,
|
|
996
|
-
|
|
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'
|
|
997
1379
|
};
|
|
998
1380
|
}
|
|
999
1381
|
|
|
@@ -1031,9 +1413,15 @@ async function scanSessions(ctx) {
|
|
|
1031
1413
|
const registry = ctx.workspaceRegistry;
|
|
1032
1414
|
const persistence = ctx.sessionPersistence;
|
|
1033
1415
|
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
1034
|
-
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;
|
|
1035
1423
|
if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
|
|
1036
|
-
const compression =
|
|
1424
|
+
const compression = storage.compression;
|
|
1037
1425
|
|
|
1038
1426
|
const entities = registry.list();
|
|
1039
1427
|
let archived = new Set();
|
|
@@ -1051,22 +1439,46 @@ async function scanSessions(ctx) {
|
|
|
1051
1439
|
const projPath = join(root, proj);
|
|
1052
1440
|
if (!dirExists(projPath)) continue;
|
|
1053
1441
|
for (const idDir of readdirSync(projPath)) {
|
|
1054
|
-
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
|
+
}
|
|
1055
1453
|
if (!found) continue;
|
|
1056
1454
|
const artifact = found.path;
|
|
1057
1455
|
let st;
|
|
1058
1456
|
try { st = statSync(artifact); } catch { continue; }
|
|
1059
1457
|
if (!st.isFile()) continue;
|
|
1060
1458
|
total++;
|
|
1061
|
-
metas.push({ artifact, idDir, st });
|
|
1459
|
+
metas.push({ artifact, idDir, st, layoutError });
|
|
1062
1460
|
}
|
|
1063
1461
|
}
|
|
1064
1462
|
metas.sort((a, b) => b.st.mtimeMs - a.st.mtimeMs);
|
|
1065
1463
|
const selected = metas.slice(0, scanLimit());
|
|
1066
|
-
for (const { artifact, idDir, st } of selected) {
|
|
1464
|
+
for (const { artifact, idDir, st, layoutError } of selected) {
|
|
1067
1465
|
seenArtifacts.add(artifact);
|
|
1068
1466
|
let header = null;
|
|
1069
|
-
|
|
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
|
+
}
|
|
1070
1482
|
const sessionId = String(header?.id ?? idDir);
|
|
1071
1483
|
const cwd = typeof header?.cwd === 'string' ? header.cwd : null;
|
|
1072
1484
|
const alive = dirExists(cwd);
|
|
@@ -1096,6 +1508,8 @@ async function scanSessions(ctx) {
|
|
|
1096
1508
|
ownerWorkspaceIds: owners.map((e) => e.id),
|
|
1097
1509
|
sizeBytes: st.size,
|
|
1098
1510
|
mtimeMs: st.mtimeMs,
|
|
1511
|
+
diagnostic,
|
|
1512
|
+
diagnosticDetail,
|
|
1099
1513
|
archived: archived.has(sessionId)
|
|
1100
1514
|
});
|
|
1101
1515
|
}
|
|
@@ -1129,7 +1543,10 @@ async function scanSessions(ctx) {
|
|
|
1129
1543
|
/** 仅拒绝回合进行中的会话(与宿主 UI「进行中」徽标同款判据)。 */
|
|
1130
1544
|
function assertNotRunning(ctx, sessionId) {
|
|
1131
1545
|
if (peekService(ctx, 'agents')?.get?.(sessionId)?.status === 'running') {
|
|
1132
|
-
|
|
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;
|
|
1133
1550
|
}
|
|
1134
1551
|
}
|
|
1135
1552
|
|
|
@@ -1206,11 +1623,448 @@ async function attachUnregistered(ctx, sessionId) { const registry = ctx.workspa
|
|
|
1206
1623
|
return { workspaceId: entity.id, path: entity.path };
|
|
1207
1624
|
}
|
|
1208
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
|
+
|
|
1209
2046
|
/** 批量迁移入口:逐会话复用 moveSession(各自独立备份/回滚,单条失败不牵连其余);历史聚合成一条。 */
|
|
1210
|
-
async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
|
|
2047
|
+
async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only' } = {}) {
|
|
1211
2048
|
if (!Array.isArray(sessions) || sessions.length === 0) throw new Error('sessions must be a non-empty array');
|
|
1212
2049
|
if (sessions.length > batchLimit()) throw new Error(`too many sessions in one batch (max ${batchLimit()}, got ${sessions.length})`);
|
|
1213
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
|
+
}
|
|
1214
2068
|
// 批内去重:同一会话在一次批量里只处理一次
|
|
1215
2069
|
const seenIds = new Set();
|
|
1216
2070
|
sessions = sessions.filter((item) => {
|
|
@@ -1222,6 +2076,7 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
|
|
|
1222
2076
|
const registry = ctx.workspaceRegistry;
|
|
1223
2077
|
const results = [];
|
|
1224
2078
|
const moved = [];
|
|
2079
|
+
let queuedCount = 0;
|
|
1225
2080
|
let movedCount = 0;
|
|
1226
2081
|
let attachedCount = 0;
|
|
1227
2082
|
for (const item of sessions) {
|
|
@@ -1242,10 +2097,21 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
|
|
|
1242
2097
|
try {
|
|
1243
2098
|
sourceWorkspaceId = registry?.list?.().find((e) => { try { return samePath(e.path, result.from.cwd); } catch { return false; } })?.id ?? null;
|
|
1244
2099
|
} catch { sourceWorkspaceId = null; }
|
|
1245
|
-
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 });
|
|
1246
2101
|
results.push({ sessionId, ok: true, moved: result.moved, to: result.to.cwd, title: result.title });
|
|
1247
2102
|
} catch (err) {
|
|
1248
|
-
|
|
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
|
+
}
|
|
1249
2115
|
}
|
|
1250
2116
|
}
|
|
1251
2117
|
let historyId = null;
|
|
@@ -1263,15 +2129,16 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId } = {}) {
|
|
|
1263
2129
|
} catch (err) {
|
|
1264
2130
|
ctx.logger?.warn?.(`workspace-mover: task record write failed: ${err?.message ?? err}`);
|
|
1265
2131
|
}
|
|
1266
|
-
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 };
|
|
1267
2133
|
}
|
|
1268
2134
|
|
|
1269
|
-
//#region
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
// (记录里的 sourceCwd 仅作展示),因此用户在批后手动搬动过会话也不会用错路径。
|
|
2135
|
+
//#region 迁移任务中心与 busy 队列
|
|
2136
|
+
// tasks.json 记录已经执行的批量;migrations.json 记录尚未完成的 busy 迁移。
|
|
2137
|
+
// 两者都使用真实 session id,在重试时重新解析当前档案位置,不信任旧路径。
|
|
1273
2138
|
const TASKS_FILE = 'tasks.json';
|
|
1274
2139
|
const TASKS_LIMIT = 50;
|
|
2140
|
+
const MIGRATIONS_FILE = 'migrations.json';
|
|
2141
|
+
const MIGRATIONS_LIMIT = 200;
|
|
1275
2142
|
|
|
1276
2143
|
function tasksPath() {
|
|
1277
2144
|
return join(pluginDataDir(), TASKS_FILE);
|
|
@@ -1291,6 +2158,305 @@ function writeTasks(tasks) {
|
|
|
1291
2158
|
atomicWrite(tasksPath(), Buffer.from(JSON.stringify({ version: 1, tasks: tasks.slice(0, TASKS_LIMIT) }, null, 2) + '\n', 'utf8'));
|
|
1292
2159
|
}
|
|
1293
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
|
+
|
|
1294
2460
|
/** 批量迁移完成后落任务记录:done/failed 逐项带来源与目标路径、最后错误与最后尝试时间。 */
|
|
1295
2461
|
function recordMoveManyTask(ctx, { targetWorkspaceId, results, moved }) {
|
|
1296
2462
|
const target = ctx.workspaceRegistry?.list?.().find((e) => e.id === targetWorkspaceId);
|
|
@@ -1303,7 +2469,9 @@ function recordMoveManyTask(ctx, { targetWorkspaceId, results, moved }) {
|
|
|
1303
2469
|
sourceCwd: moved.find((mv) => mv.sessionId === r.sessionId)?.fromCwd ?? null,
|
|
1304
2470
|
targetCwd: target?.path ?? null,
|
|
1305
2471
|
state: r.ok ? 'done' : 'failed',
|
|
2472
|
+
queued: Boolean(r.queued),
|
|
1306
2473
|
error: r.ok ? null : (r.error ?? 'unknown'),
|
|
2474
|
+
queueTaskId: r.queueTaskId ?? null,
|
|
1307
2475
|
lastAttemptAt: new Date().toISOString()
|
|
1308
2476
|
});
|
|
1309
2477
|
}
|
|
@@ -1334,6 +2502,7 @@ async function listTasks() {
|
|
|
1334
2502
|
done: t.items.filter((i) => i.state === 'done').length,
|
|
1335
2503
|
failed: t.items.filter((i) => i.state === 'failed').length,
|
|
1336
2504
|
skipped: t.items.filter((i) => i.state === 'skipped').length,
|
|
2505
|
+
queued: t.items.filter((i) => i.state === 'queued').length,
|
|
1337
2506
|
total: t.items.length,
|
|
1338
2507
|
items: t.items
|
|
1339
2508
|
}))
|
|
@@ -1364,7 +2533,7 @@ async function retryTask(ctx, { taskId } = {}) {
|
|
|
1364
2533
|
item.lastError = null;
|
|
1365
2534
|
item.lastAttemptAt = attemptAt;
|
|
1366
2535
|
item.targetCwd = result.to?.cwd ?? item.targetCwd;
|
|
1367
|
-
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 });
|
|
1368
2537
|
results.push({ sessionId: item.sessionId, ok: true });
|
|
1369
2538
|
} catch (err) {
|
|
1370
2539
|
const msg = String(err?.message ?? err);
|
|
@@ -1432,10 +2601,9 @@ async function cleanupOldData(ctx, { days, dryRun = false } = {}) {
|
|
|
1432
2601
|
const bdir = backupDir();
|
|
1433
2602
|
if (dirExists(bdir)) {
|
|
1434
2603
|
for (const name of readdirSync(bdir)) {
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
const ts = Number(m[2]);
|
|
2604
|
+
const parsed = parseBackupFilename(name);
|
|
2605
|
+
if (!parsed) continue;
|
|
2606
|
+
const ts = parsed.ts;
|
|
1439
2607
|
if (!(ts < cutoff)) continue;
|
|
1440
2608
|
let size = 0;
|
|
1441
2609
|
try { size = statSync(join(bdir, name)).size; } catch { continue; }
|
|
@@ -1735,8 +2903,14 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
|
|
|
1735
2903
|
throw new Error(`该工作区牵涉 ${affected.length} 个会话,超出单次上限 ${repointLimit()};请分批处理`);
|
|
1736
2904
|
}
|
|
1737
2905
|
|
|
1738
|
-
const
|
|
1739
|
-
|
|
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;
|
|
1740
2914
|
if (typeof root !== 'string' || root.length === 0) throw new Error('session persistence backend exposes no root');
|
|
1741
2915
|
|
|
1742
2916
|
if (dryRun) return { dryRun: true, from: stalePath, to: targetCanon, resumeMode, count: affected.length, items: affected };
|
|
@@ -1819,24 +2993,39 @@ async function repointWorkspaceLocked(ctx, { workspaceId, fromPath, newPath, dry
|
|
|
1819
2993
|
try {
|
|
1820
2994
|
assertNotRunning(ctx, sessionId);
|
|
1821
2995
|
const meta = headers.find((h) => String(h?.id) === String(sessionId));
|
|
1822
|
-
const
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
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));
|
|
1830
3008
|
try {
|
|
1831
|
-
|
|
3009
|
+
await storage.relocate(sourceLocation, dirname(dstArtifact));
|
|
1832
3010
|
// 迁移后一致性校验(同 moveSession),失败视同搬移失败回退源字节
|
|
1833
3011
|
try {
|
|
1834
|
-
|
|
3012
|
+
verifyRelocatedBytes(await storage.read(destinationLocation), sessionId, targetCanon, compression);
|
|
1835
3013
|
} catch (verifyErr) {
|
|
1836
3014
|
throw new Error(`物理搬移校验失败:${verifyErr?.message ?? verifyErr}`);
|
|
1837
3015
|
}
|
|
1838
3016
|
} catch (err) {
|
|
1839
|
-
|
|
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
|
+
}
|
|
1840
3029
|
throw new Error(`物理搬移失败:${err?.message ?? err}`);
|
|
1841
3030
|
}
|
|
1842
3031
|
try {
|
|
@@ -1934,8 +3123,12 @@ async function locateArtifact(ctx, sessionId) {
|
|
|
1934
3123
|
const registry = ctx.workspaceRegistry;
|
|
1935
3124
|
const persistence = ctx.sessionPersistence;
|
|
1936
3125
|
if (!registry || !persistence) throw new Error('workspace registry or session persistence service unavailable');
|
|
1937
|
-
const
|
|
1938
|
-
|
|
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
|
+
}
|
|
1939
3132
|
const candidates = [];
|
|
1940
3133
|
try { const p = registry.sessionPaths?.get?.(sessionId); if (typeof p === 'string' && p) candidates.push(p); } catch { /* ignore */ }
|
|
1941
3134
|
for (const e of registry.list()) {
|
|
@@ -1948,8 +3141,9 @@ async function locateArtifact(ctx, sessionId) {
|
|
|
1948
3141
|
}
|
|
1949
3142
|
} catch { /* ignore */ }
|
|
1950
3143
|
for (const cwd of candidates) {
|
|
1951
|
-
const
|
|
1952
|
-
|
|
3144
|
+
const location = await storage.locate({ sessionId, cwd });
|
|
3145
|
+
const info = await storage.inspect(location);
|
|
3146
|
+
if (info.exists) return { ...location, cwd };
|
|
1953
3147
|
}
|
|
1954
3148
|
throw new Error(`session archive not found for '${sessionId}'`);
|
|
1955
3149
|
}
|
|
@@ -2103,9 +3297,10 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
2103
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');
|
|
2104
3298
|
}
|
|
2105
3299
|
assertNotRunning(ctx, sessionId);
|
|
2106
|
-
const
|
|
2107
|
-
const
|
|
2108
|
-
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);
|
|
2109
3304
|
const archived = readArchivedIds(registry).includes(sessionId);
|
|
2110
3305
|
const projection = captureProjection(ctx, sessionId);
|
|
2111
3306
|
const owners = listOwners(registry, sessionId);
|
|
@@ -2114,7 +3309,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
2114
3309
|
// ① 物理移动先行
|
|
2115
3310
|
let how = 'rename';
|
|
2116
3311
|
try {
|
|
2117
|
-
how =
|
|
3312
|
+
how = await storage.relocate(location, join(entryDir, 'session'), { moveImpl });
|
|
2118
3313
|
} catch (err) {
|
|
2119
3314
|
try { rmSync(entryDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2120
3315
|
throw new Error(`move to recycle failed (nothing was changed): ${err?.message ?? err}`);
|
|
@@ -2134,7 +3329,9 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
2134
3329
|
try {
|
|
2135
3330
|
writeManifestImpl(join(entryDir, TRASH_MANIFEST), manifest);
|
|
2136
3331
|
} catch (err) {
|
|
2137
|
-
try {
|
|
3332
|
+
try {
|
|
3333
|
+
await storage.relocate({ ...location, artifact: join(entryDir, 'session', basename(artifact)) }, dirname(artifact));
|
|
3334
|
+
} catch (backErr) {
|
|
2138
3335
|
recordRecovery(ctx, {
|
|
2139
3336
|
kind: 'delete', phase: 'manifest-rollback', sessionId: String(sessionId),
|
|
2140
3337
|
source: dirname(artifact), target: entryDir, backup: null,
|
|
@@ -2203,6 +3400,12 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
|
|
|
2203
3400
|
const registry = ctx.workspaceRegistry;
|
|
2204
3401
|
const persistence = ctx.sessionPersistence;
|
|
2205
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
|
+
}
|
|
2206
3409
|
const dir = recycleDir();
|
|
2207
3410
|
if (!dirExists(dir)) throw new Error(`no trashed session '${sessionId}'`);
|
|
2208
3411
|
let entryDir = null;
|
|
@@ -2213,22 +3416,25 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
|
|
|
2213
3416
|
if (m && String(m.sessionId) === String(sessionId)) { entryDir = candidate; manifest = m; break; }
|
|
2214
3417
|
}
|
|
2215
3418
|
if (!entryDir) throw new Error(`no trashed session '${sessionId}'`);
|
|
2216
|
-
const compression =
|
|
3419
|
+
const compression = storage.compression;
|
|
2217
3420
|
const sessionDir = join(entryDir, 'session');
|
|
2218
3421
|
const foundInTrash = findHighestArtifact(sessionDir, compression);
|
|
2219
3422
|
if (!foundInTrash) throw new Error(`trash entry has no session artifact: ${entryDir}`);
|
|
2220
3423
|
const artifactInTrash = foundInTrash.path;
|
|
2221
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 };
|
|
2222
3426
|
if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined) {
|
|
2223
3427
|
throw new Error('a live session with this id already exists; restart the harness before restoring');
|
|
2224
3428
|
}
|
|
2225
3429
|
let existingPath = null;
|
|
2226
3430
|
try { existingPath = registry.sessionPaths?.get?.(sessionId); } catch { /* ignore */ }
|
|
2227
3431
|
if (existingPath) throw new Error('session id already exists in the workspace registry');
|
|
2228
|
-
const header = readHeader(
|
|
3432
|
+
const header = readHeader(await storage.read(trashLocation), compression);
|
|
2229
3433
|
const originalCwd = typeof header?.cwd === 'string' ? header.cwd : manifest.cwd;
|
|
2230
3434
|
if (typeof originalCwd !== 'string' || originalCwd.length === 0) throw new Error('trashed manifest carries no original path');
|
|
2231
|
-
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);
|
|
2232
3438
|
let destPath = originalCwd;
|
|
2233
3439
|
let targetEntity = registry.list().find((e) => { try { return samePath(e.path, originalCwd); } catch { return false; } }) ?? null;
|
|
2234
3440
|
if (typeof targetWorkspaceId === 'string' && targetWorkspaceId.length > 0) {
|
|
@@ -2236,16 +3442,15 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
|
|
|
2236
3442
|
if (!targetEntity) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
|
|
2237
3443
|
destPath = targetEntity.path;
|
|
2238
3444
|
}
|
|
2239
|
-
if (
|
|
3445
|
+
if (originalInfo.exists) {
|
|
2240
3446
|
if (!targetEntity || samePath(targetEntity.path, originalCwd)) {
|
|
2241
|
-
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`);
|
|
2242
3448
|
}
|
|
2243
3449
|
} else if (!targetEntity) {
|
|
2244
3450
|
throw new Error(`original location '${originalCwd}' has no registered workspace; pass targetWorkspaceId to restore into one`);
|
|
2245
3451
|
}
|
|
2246
3452
|
// 先移回原位(头 cwd 与原位一致,attach 校验天然通过)
|
|
2247
|
-
|
|
2248
|
-
moveDir(sessionDir, dirname(originalArtifact));
|
|
3453
|
+
await storage.relocate(trashLocation, dirname(originalLocation.artifact));
|
|
2249
3454
|
let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, moved: null };
|
|
2250
3455
|
try {
|
|
2251
3456
|
if (targetEntity && !samePath(originalCwd, destPath)) {
|
|
@@ -2289,7 +3494,7 @@ async function purgeTrash(ctx, { sessionId, all, purgeBackups = false } = {}) {
|
|
|
2289
3494
|
}
|
|
2290
3495
|
|
|
2291
3496
|
/**
|
|
2292
|
-
*
|
|
3497
|
+
* 备份聚合清单:按会话分组,兼容旧文件名和带内容指纹的新快照文件名。
|
|
2293
3498
|
*
|
|
2294
3499
|
* 额外标注孤儿组:会话已被彻底删除(或档案已不存在)时,它历次迁移留下的备份
|
|
2295
3500
|
* 不会再被任何操作引用——`stashBackup` 的 20 份裁剪只在"再次备份同一会话"时触发,
|
|
@@ -2301,10 +3506,9 @@ async function listBackups(ctx) {
|
|
|
2301
3506
|
const titles = readProjectionTitles();
|
|
2302
3507
|
const groups = new Map();
|
|
2303
3508
|
for (const name of readdirSync(dir)) {
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
const [, id, ts] = m;
|
|
3509
|
+
const parsed = parseBackupFilename(name);
|
|
3510
|
+
if (!parsed) continue;
|
|
3511
|
+
const { id, ts } = parsed;
|
|
2308
3512
|
let sizeBytes = 0;
|
|
2309
3513
|
try { sizeBytes = statSync(join(dir, name)).size; } catch { continue; }
|
|
2310
3514
|
const g = groups.get(id) ?? { sessionId: id, title: titles.get(id) ?? null, count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
|
|
@@ -2312,7 +3516,7 @@ async function listBackups(ctx) {
|
|
|
2312
3516
|
g.totalBytes += sizeBytes;
|
|
2313
3517
|
g.oldest = Math.min(g.oldest, Number(ts));
|
|
2314
3518
|
g.newest = Math.max(g.newest, Number(ts));
|
|
2315
|
-
g.files.push({ name, ts: Number(ts), sizeBytes });
|
|
3519
|
+
g.files.push({ name, ts: Number(ts), sizeBytes, fingerprint: parsed.fingerprint });
|
|
2316
3520
|
groups.set(id, g);
|
|
2317
3521
|
}
|
|
2318
3522
|
|
|
@@ -2332,7 +3536,7 @@ async function listBackups(ctx) {
|
|
|
2332
3536
|
const items = [...groups.values()].map((g) => ({
|
|
2333
3537
|
...g,
|
|
2334
3538
|
orphan: !alive.has(String(g.sessionId)),
|
|
2335
|
-
files: g.files.sort((x, y) => y.ts - x.ts).map(({ name, ts, sizeBytes }) => ({ name, ts, sizeBytes }))
|
|
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 }))
|
|
2336
3540
|
}));
|
|
2337
3541
|
items.sort((a, b) => b.newest - a.newest);
|
|
2338
3542
|
const orphanItems = items.filter((it) => it.orphan);
|
|
@@ -2361,6 +3565,12 @@ async function restoreBackupLocked(ctx, sessionId, fileName, targetWorkspaceId)
|
|
|
2361
3565
|
const registry = ctx.workspaceRegistry;
|
|
2362
3566
|
const persistence = ctx.sessionPersistence;
|
|
2363
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
|
+
}
|
|
2364
3574
|
const dir = backupDir();
|
|
2365
3575
|
if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined) {
|
|
2366
3576
|
throw new Error('a live session with this id exists; restart the harness before restoring');
|
|
@@ -2370,17 +3580,19 @@ async function restoreBackupLocked(ctx, sessionId, fileName, targetWorkspaceId)
|
|
|
2370
3580
|
if (existingPath) throw new Error('session id already exists in the workspace registry');
|
|
2371
3581
|
let path2 = null;
|
|
2372
3582
|
if (typeof fileName === 'string' && fileName.length > 0) {
|
|
2373
|
-
|
|
2374
|
-
|
|
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);
|
|
2375
3587
|
} else {
|
|
2376
|
-
const candidates =
|
|
2377
|
-
|
|
2378
|
-
|
|
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);
|
|
2379
3591
|
if (candidates.length === 0) throw new Error(`no backups found for '${sessionId}'`);
|
|
2380
|
-
path2 = join(dir, candidates[
|
|
3592
|
+
path2 = join(dir, candidates[0]);
|
|
2381
3593
|
}
|
|
2382
3594
|
if (!existsSync(path2)) throw new Error(`backup file not found: ${path2}`);
|
|
2383
|
-
const compression =
|
|
3595
|
+
const compression = storage.compression;
|
|
2384
3596
|
const bytes = readFileSync(path2);
|
|
2385
3597
|
const header = readHeader(bytes, compression);
|
|
2386
3598
|
if (String(header?.id) !== String(sessionId)) throw new Error(`backup header id mismatch: ${header?.id}`);
|
|
@@ -2394,17 +3606,19 @@ async function restoreBackupLocked(ctx, sessionId, fileName, targetWorkspaceId)
|
|
|
2394
3606
|
destCwd = targetEntity.path;
|
|
2395
3607
|
}
|
|
2396
3608
|
if (!targetEntity) throw new Error(`backup cwd '${backupCwd}' has no registered workspace; pass targetWorkspaceId to restore into one`);
|
|
2397
|
-
const
|
|
2398
|
-
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);
|
|
2399
3612
|
const dstArtifact = join(dstDir, preferredArtifactName(compression));
|
|
3613
|
+
const destinationLocation = { ...destinationProbe, cwd: destCwd, artifact: dstArtifact };
|
|
2400
3614
|
const final = samePath(backupCwd, destCwd) ? bytes : rewriteHeaderCwd(bytes, destCwd, compression);
|
|
2401
3615
|
let created = false;
|
|
2402
3616
|
try {
|
|
2403
3617
|
mkdirSync(dstDir, { recursive: true });
|
|
2404
|
-
|
|
3618
|
+
await storage.write(destinationLocation, final);
|
|
2405
3619
|
created = existsSync(dstArtifact);
|
|
2406
3620
|
// 回读校验:id 与 cwd 双确认后才挂账
|
|
2407
|
-
const verify = readHeader(
|
|
3621
|
+
const verify = readHeader(await storage.read(destinationLocation), compression);
|
|
2408
3622
|
if (String(verify?.id) !== String(sessionId) || !verify?.cwd || !samePath(verify.cwd, destCwd)) {
|
|
2409
3623
|
throw new Error('restore verification failed (header round-trip mismatch)');
|
|
2410
3624
|
}
|
|
@@ -2423,10 +3637,12 @@ async function deleteBackup(ctx, { sessionId, fileName } = {}) {
|
|
|
2423
3637
|
if (!dirExists(dir)) return { deleted: 0 };
|
|
2424
3638
|
let targets = [];
|
|
2425
3639
|
if (typeof fileName === 'string' && fileName.length > 0) {
|
|
2426
|
-
|
|
2427
|
-
|
|
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];
|
|
2428
3644
|
} else {
|
|
2429
|
-
targets =
|
|
3645
|
+
targets = backupFilesFor(sessionId).map((item) => item.name);
|
|
2430
3646
|
}
|
|
2431
3647
|
let deleted = 0;
|
|
2432
3648
|
for (const name of targets) {
|
|
@@ -2553,7 +3769,7 @@ function createAgentTools(ctx, dispatchRef) {
|
|
|
2553
3769
|
},
|
|
2554
3770
|
{
|
|
2555
3771
|
name: 'mover_move_session',
|
|
2556
|
-
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.`,
|
|
2557
3773
|
parameters: {
|
|
2558
3774
|
type: 'object',
|
|
2559
3775
|
properties: {
|
|
@@ -2633,8 +3849,21 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
2633
3849
|
}
|
|
2634
3850
|
|
|
2635
3851
|
if (!persistence) fail('host-persistence', 'session persistence service unavailable');
|
|
2636
|
-
else
|
|
2637
|
-
|
|
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);
|
|
2638
3867
|
|
|
2639
3868
|
const projection = projectionTable(ctx);
|
|
2640
3869
|
const projectionRead = typeof projection?.get === 'function';
|
|
@@ -2661,7 +3890,8 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
2661
3890
|
try {
|
|
2662
3891
|
const backups = await listBackups(ctx);
|
|
2663
3892
|
const orphanNote = backups.orphanGroups > 0 ? `, ${backups.orphanGroups} orphan group(s) (${backups.orphanBytes} bytes)` : '';
|
|
2664
|
-
|
|
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}`);
|
|
2665
3895
|
}
|
|
2666
3896
|
catch (err) { fail('data-backups', String(err?.message ?? err).slice(0, 140)); }
|
|
2667
3897
|
|
|
@@ -2671,6 +3901,13 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
2671
3901
|
try { const tasks = await listTasks(); pass('data-tasks', `${tasks.items?.length ?? 0} records`); }
|
|
2672
3902
|
catch (err) { fail('data-tasks', String(err?.message ?? err).slice(0, 140)); }
|
|
2673
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
|
+
|
|
2674
3911
|
const recovered = recoveryCount();
|
|
2675
3912
|
if (recovered === 0) pass('recovery', 'no manual-recovery records');
|
|
2676
3913
|
else warn('recovery', `${recovered} record(s) need manual review — see the rescue panel`);
|
|
@@ -2678,7 +3915,9 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
2678
3915
|
// 存储布局自证:把"所有会话突然都找不到"提前变成一条可见的降级提示。
|
|
2679
3916
|
try {
|
|
2680
3917
|
const layout = await verifyStorageLayout(ctx);
|
|
2681
|
-
if (layout.
|
|
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'})`);
|
|
2682
3921
|
else if (layout.status === 'empty') warn('data-layout', 'no stored sessions to verify against (fresh install?)');
|
|
2683
3922
|
else if (layout.status === 'unavailable') warn('data-layout', layout.reason ?? 'storage layout cannot be verified');
|
|
2684
3923
|
else warn('data-layout', `storage layout may have changed — ${layout.reason ?? 'no archive found at the derived path'}`);
|
|
@@ -2726,6 +3965,7 @@ export function apply(ctx) {
|
|
|
2726
3965
|
case 'mover.status': {
|
|
2727
3966
|
const projection = projectionTable(ctx);
|
|
2728
3967
|
const layout = await verifyStorageLayout(ctx);
|
|
3968
|
+
const storage = storageDiagnostics(ctx);
|
|
2729
3969
|
const capabilities = {
|
|
2730
3970
|
registry: Boolean(ctx.workspaceRegistry),
|
|
2731
3971
|
persistence: Boolean(ctx.sessionPersistence),
|
|
@@ -2739,7 +3979,15 @@ export function apply(ctx) {
|
|
|
2739
3979
|
coordinatorStates: Boolean(peekService(ctx, 'sessionPersistence')?.coordinator?.states),
|
|
2740
3980
|
fileReferences: Boolean(peekService(ctx, 'fileReferences')),
|
|
2741
3981
|
agents: Boolean(peekService(ctx, 'agents')),
|
|
2742
|
-
storageLayout: layout.status
|
|
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()
|
|
2743
3991
|
};
|
|
2744
3992
|
const degradedFeatures = [];
|
|
2745
3993
|
if (!capabilities.projectionWrite) degradedFeatures.push('projection-title-restore');
|
|
@@ -2749,6 +3997,9 @@ export function apply(ctx) {
|
|
|
2749
3997
|
if (!capabilities.coordinatorStates) degradedFeatures.push('resident-hotfix');
|
|
2750
3998
|
if (!capabilities.fileReferences) degradedFeatures.push('file-reference-cleanup');
|
|
2751
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');
|
|
2752
4003
|
return ok({
|
|
2753
4004
|
ready: Boolean(capabilities.registry && capabilities.persistence),
|
|
2754
4005
|
channel: CHANNEL,
|
|
@@ -2786,11 +4037,60 @@ export function apply(ctx) {
|
|
|
2786
4037
|
const targetWorkspaceId = payload?.targetWorkspaceId;
|
|
2787
4038
|
if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('sessionId required');
|
|
2788
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
|
+
}
|
|
2789
4047
|
const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle });
|
|
2790
4048
|
logger.info?.(`workspace-mover: moved ${sessionId} -> ${result.to.cwd}`);
|
|
2791
4049
|
return ok(result);
|
|
2792
4050
|
} catch (err) {
|
|
2793
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) {
|
|
2794
4094
|
return mapError(err);
|
|
2795
4095
|
}
|
|
2796
4096
|
}
|
|
@@ -2889,6 +4189,29 @@ export function apply(ctx) {
|
|
|
2889
4189
|
return mapError(err);
|
|
2890
4190
|
}
|
|
2891
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
|
+
}
|
|
2892
4215
|
case 'mover.data.cleanup': {
|
|
2893
4216
|
try {
|
|
2894
4217
|
return ok(await cleanupOldData(ctx, payload ?? {}));
|
|
@@ -2923,6 +4246,7 @@ export function apply(ctx) {
|
|
|
2923
4246
|
}
|
|
2924
4247
|
|
|
2925
4248
|
registerAgentTools(ctx, dispatchRef, logger, agentToolsState);
|
|
4249
|
+
startMigrationRuntime(ctx, ctx, logger);
|
|
2926
4250
|
|
|
2927
4251
|
// 官方 Config:**暂不注册**,只导出构造能力。
|
|
2928
4252
|
//
|
|
@@ -2936,4 +4260,4 @@ export function apply(ctx) {
|
|
|
2936
4260
|
// 环境变量(见 resolvePolicy)与代码默认值驱动,功能完整。
|
|
2937
4261
|
}
|
|
2938
4262
|
|
|
2939
|
-
export { name, inject, CHANNEL, projectKey, encodeSegment, sessionDir, artifactPath, generationLogFilename, parseGenerationLogFilename, listGenerationArtifacts, findHighestArtifact, preferredArtifactName, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, moveSessionEntry, validId, SCAN_MAX_ITEMS, POLICY_DEFAULTS, resolvePolicy, verifyStorageLayout, rawRecordSessionIds, historyLimit, backupKeep, batchLimit, repointLimit, scanLimit, cleanupDays, Config, POLICY_FIELDS, buildConfig, attachConfig };
|
|
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 };
|