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/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // ① 跨工作区「真迁移」原始会话:物理搬移 session.jsonl.zstd、改写头部 cwd、
4
4
  // 更新工作区注册表(侧边栏拖拽触发)。会话 id 与历史文件保持原样,
5
- // 不产生副本,不消耗 token。
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 { join, dirname, basename } from 'node:path';
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 verifyRelocatedArtifact(dstArtifact, sessionId, expectedCwd, compression = 'zstd') {
418
- const header = readHeader(readFileSync(dstArtifact), compression);
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
- * 备份文件名:`${id}.${epochMs}[-seq].zstd`。
437
- *
438
- * 为什么需要 `-seq`:毫秒时间戳不足以区分同一毫秒内的两次备份,而 Linux 的时钟/文件系统
439
- * 时间戳粒度比 Windows 粗得多,连续两次 stash 极可能落在同一毫秒。此时文件名相撞,
440
- * 第二次会**覆盖**第一次——用户配置的保留份数(backupKeep)于是静默失效,实际只留下
441
- * 一份。这不是假设:CI 的 ubuntu / macos 作业就是这样挂掉的。
442
- *
443
- * 命名规则:
444
- * - 无 `-seq`:`atomicWrite` 的 temp+rename 是覆盖语义,等于"同毫秒最后一次胜出",
445
- * 同时把该毫秒的文件数限制为 1(保留原有测试断言的"同毫秒覆盖"行为,也避免浪费空间)。
446
- * - 带 `-seq`:该毫秒已被占用时递增,保证每次调用都留下独立的备份文件。
447
- * 两种形态的排序都保持近似的"旧→新"顺序(`-` = 0x2D < `.` = 0x2E,所以 `ts` 排在
448
- * `ts-seq` 之前,`ts-seq2` 又排在 `ts-seq10` 之前——同一毫秒内顺序仅影响裁剪,不影响正确性)。
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
- function backupPath(id) {
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 base = join(dir, `${id}.${ts}.zstd`);
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}.zstd`);
635
+ const candidate = join(dir, `${id}.${ts}-${seq}.${suffix}`);
457
636
  if (!existsSync(candidate)) return candidate;
458
637
  }
459
- // 极端情况:同一毫秒 1000 次备份。退化为加序号强制唯一,绝不覆盖既有备份。
460
- return join(dir, `${id}.${ts}-${Math.random().toString(36).slice(2, 8)}.zstd`);
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
- function stashBackup(id, buf) {
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
- atomicWrite(backupPath(id), buf);
468
- // 前缀收紧到 `${id}.`:相邻 id(如 session-x 与 session-x-1)的备份互不误删
469
- const keep = readdirSync(dir).filter((f) => f.startsWith(`${id}.`)).sort();
470
- while (keep.length > backupKeep()) rmSync(join(dir, keep.shift()), { force: true });
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 = persistence.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 = persistenceCompression(persistence);
727
- const srcDir = sessionDir(root, sourceCwd, sessionId);
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 srcFound = findHighestArtifact(srcDir, compression);
730
- if (!srcFound) throw new Error(`session artifact missing under: ${srcDir}`);
731
- const srcArtifact = srcFound.path;
732
- if (findDestinationArtifact(dstDir, compression)) throw new Error(`destination artifact already exists under: ${dstDir}`);
733
- const dstArtifact = join(dstDir, srcFound.name);
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 = readFileSync(srcArtifact);
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, statSync(srcArtifact).size * 2);
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
- atomicWrite(srcArtifact, rewritten);
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 = moveDir(dirname(srcArtifact), dirname(dstArtifact));
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
- verifyRelocatedArtifact(dstArtifact, sessionId, targetPath, compression);
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
- atomicWrite(srcArtifact, original);
811
- throw new Error(`move failed at relocate step: ${err?.message ?? err}`);
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
- rmSync(dirname(dstArtifact), { recursive: true, force: true });
865
- mkdirSync(dirname(srcArtifact), { recursive: true });
866
- writeFileSync(srcArtifact, original);
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 root = persistence.root;
966
- if (typeof root !== 'string' || root.length === 0) {
967
- return { status: 'degraded', checked: 0, reason: 'persistence backend exposes no root' };
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 listPersistenceHeaders(persistence);
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
- if (headers.length === 0) return { status: 'empty', checked: 0, path: root };
976
- const compression = persistenceCompression(persistence);
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
- if (findHighestArtifact(expectedDir, compression)) {
985
- return { status: 'ok', checked: sample.length, path: root };
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 { status: 'degraded', checked: sample.length, path: root, reason: `cannot read derived directory: ${err?.message ?? err}` };
1364
+ const diagnostic = err?.code === 'unsupported' ? 'unsupported' : 'corrupt-data';
1365
+ return {
1366
+ status: 'degraded', diagnostic, checked: sample.length, path: root, adapter: adapter.mode,
1367
+ physicalPath: true, reason: `${diagnostic}: ${err?.message ?? err}`
1368
+ };
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
- reason: 'no session archive found at the layout this plugin derives — the host storage layout may have changed'
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 root = persistence.root;
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 = persistenceCompression(persistence);
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 found = findHighestArtifact(join(projPath, idDir), compression);
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
- try { header = readHeader(readFileSync(artifact), compression); } catch { header = null; }
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
- throw new Error('session is currently running; wait for its turn to finish before moving');
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
- results.push({ sessionId, ok: false, error: err?.message ?? String(err) });
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 迁移任务中心(v1.2,记录式)
1270
- // 任务 = 已执行批量的持久化记录 + 逐项可重试状态。不是后台队列:
1271
- // 重试在 RPC 内同步执行,逐项隔离失败;来源位置在重试时实时解析
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
- // 兼容同毫秒去重后缀:`${id}.${ts}.zstd` 与 `${id}.${ts}-${seq}.zstd`
1436
- const m = /^(.+)\.(\d+)(?:-\d+)?\.zstd$/.exec(name);
1437
- if (!m) continue;
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 root = persistence.root;
1739
- const compression = persistenceCompression(persistence);
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 srcArtifact = artifactPath(root, typeof meta?.cwd === 'string' ? meta.cwd : stalePath, sessionId, compression);
1823
- if (!existsSync(srcArtifact)) throw new Error(`档案缺失:${srcArtifact}`);
1824
- const dstArtifact = artifactPath(root, targetCanon, sessionId, compression);
1825
- if (existsSync(dstArtifact)) throw new Error(`目标位置已有同名档案:${dstArtifact}`);
1826
-
1827
- const original = readFileSync(srcArtifact);
1828
- stashBackup(String(sessionId), original);
1829
- atomicWrite(srcArtifact, rewriteHeaderCwd(original, targetCanon, compression));
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
- moveDir(dirname(srcArtifact), dirname(dstArtifact));
3009
+ await storage.relocate(sourceLocation, dirname(dstArtifact));
1832
3010
  // 迁移后一致性校验(同 moveSession),失败视同搬移失败回退源字节
1833
3011
  try {
1834
- verifyRelocatedArtifact(dstArtifact, sessionId, targetCanon, compression);
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
- atomicWrite(srcArtifact, original);
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 root = persistence.root;
1938
- const compression = persistenceCompression(persistence);
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 artifact = artifactPath(root, cwd, sessionId, compression);
1952
- if (existsSync(artifact)) return { artifact, cwd };
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 compression = persistenceCompression(persistence);
2107
- const { artifact, cwd: artifactCwd } = await locateArtifact(ctx, sessionId);
2108
- const header = readHeader(readFileSync(artifact), compression);
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 = moveImpl(dirname(artifact), join(entryDir, 'session'));
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 { moveDir(join(entryDir, 'session'), dirname(artifact)); } catch (backErr) {
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 = persistenceCompression(persistence);
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(readFileSync(artifactInTrash), compression);
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 originalArtifact = artifactPath(persistence.root, originalCwd, sessionId, compression);
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 (existsSync(originalArtifact)) {
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: ${originalArtifact}; pass targetWorkspaceId to restore elsewhere`);
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
- const bytes = readFileSync(artifactInTrash);
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
- * 备份聚合清单:按会话分组(文件名 `${id}.${ts}.zstd`),附投影标题与总占用。
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
- // 兼容同毫秒去重后缀:`${id}.${ts}.zstd` 与 `${id}.${ts}-${seq}.zstd`
2305
- const m = /^(.+)\.(\d+)(?:-\d+)?\.zstd$/.exec(name);
2306
- if (!m) continue;
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
- if (!fileName.startsWith(`${sessionId}.`)) throw new Error('backup file does not belong to the given session');
2374
- path2 = join(dir, basename(fileName));
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 = dirExists(dir)
2377
- ? readdirSync(dir).filter((f) => f.startsWith(`${sessionId}.`)).sort()
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[candidates.length - 1]);
3592
+ path2 = join(dir, candidates[0]);
2381
3593
  }
2382
3594
  if (!existsSync(path2)) throw new Error(`backup file not found: ${path2}`);
2383
- const compression = persistenceCompression(persistence);
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 dstDir = sessionDir(persistence.root, destCwd, sessionId);
2398
- if (findDestinationArtifact(dstDir, compression)) throw new Error(`destination artifact already exists under: ${dstDir}`);
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
- atomicWrite(dstArtifact, final);
3618
+ await storage.write(destinationLocation, final);
2405
3619
  created = existsSync(dstArtifact);
2406
3620
  // 回读校验:id 与 cwd 双确认后才挂账
2407
- const verify = readHeader(readFileSync(dstArtifact), compression);
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
- if (!fileName.startsWith(`${sessionId}.`)) throw new Error('backup file does not belong to the given session');
2427
- targets = [basename(fileName)];
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 = readdirSync(dir).filter((f) => f.startsWith(`${sessionId}.`));
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 if (typeof persistence.root === 'string' && dirExists(persistence.root)) pass('host-persistence', String(persistence.root));
2637
- else fail('host-persistence', `sessions root missing: ${persistence.root ?? '(none)'}`);
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
- pass('data-backups', `${backups.items.length} session groups${orphanNote}`);
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.status === 'ok') pass('data-layout', `derived directory verified (${layout.checked} sample(s))`);
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 };