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.
@@ -3,19 +3,21 @@
3
3
  // 备份目录通过 DSH_HOME 重定向到临时区,不污染真实环境。
4
4
  import { test, beforeEach, afterEach } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import { syncBuiltinESMExports } from 'node:module';
6
8
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync, renameSync, realpathSync, utimesSync } from 'node:fs';
7
9
  import { tmpdir } from 'node:os';
8
10
  import { join, dirname } from 'node:path';
9
11
  import { zstdCompressSync, constants } from 'node:zlib';
10
12
 
11
- import { apply, scanFrames, readHeader, artifactPath, openInFileManager, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, SCAN_MAX_ITEMS } from '../lib/index.js';
13
+ import { apply, scanFrames, readHeader, artifactPath, sessionDir, createPersistenceAdapter, openInFileManager, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, SCAN_MAX_ITEMS } from '../lib/index.js';
12
14
 
13
15
  const OPTS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
14
16
  const DEAD = 'E:\\wsm-dead-path'; // 刻意不存在的路径(孤儿分类用)
15
17
  let A; // 每次用例指向真实临时目录(健康会话要求 cwd 在磁盘上存在)
16
18
  let B;
17
19
  let root;
18
- let ctx, entityA, entityB, sharedIndex, workspaceEvents;
20
+ let ctx, entityA, entityB, sharedIndex, workspaceEvents, cleanups;
19
21
 
20
22
  /** 测试根目录:优先 WSM_TEST_ROOT(受限环境下 Temp 可能禁止目录重命名),否则退回系统 Temp。 */
21
23
  function makeRoot() {
@@ -75,7 +77,8 @@ function makeEntity(id, path, hostRef) {
75
77
  if (record.sessionIds.includes(sid)) return;
76
78
  // 官方语义:未记账 id 需通过「头部 cwd 规范化后 === path」校验,
77
79
  // 并经 rememberSessionPath 回填三张索引后才挂账
78
- const header = readHeader(readFileSync(findArtifact(sid)));
80
+ const artifact = findArtifact(sid);
81
+ const header = readHeader(readFileSync(artifact), artifact.endsWith('.zstd') ? 'zstd' : 'none');
79
82
  let canonical;
80
83
  try { canonical = realpathSync(header.cwd); } catch {
81
84
  throw new Error(`cwd does not resolve: '${header.cwd}'`);
@@ -104,10 +107,10 @@ function findArtifact(sessionId) {
104
107
  const dir = join(root, proj, sessionId);
105
108
  if (!existsSync(dir)) continue;
106
109
  const names = readdirSync(dir)
107
- .filter((name) => name === 'session.jsonl.zstd' || /^session\.v[1-9][0-9]*\.jsonl\.zstd$/.test(name))
110
+ .filter((name) => /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/.test(name))
108
111
  .sort((a, b) => {
109
- const va = a === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(a)[1]);
110
- const vb = b === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(b)[1]);
112
+ const va = Number(/^session\.v([1-9][0-9]*)\.jsonl/.exec(a)?.[1] ?? 0);
113
+ const vb = Number(/^session\.v([1-9][0-9]*)\.jsonl/.exec(b)?.[1] ?? 0);
111
114
  return vb - va;
112
115
  });
113
116
  if (names.length) return join(dir, names[0]);
@@ -120,6 +123,7 @@ beforeEach(() => {
120
123
  // 的唯一性约定);macOS 的 Temp 位于 /var → /private/var 符号链接下,
121
124
  // 夹具若用未规范化路径会与 canonical 比对失配,因此创建后立即归一。
122
125
  root = realpathSync(makeRoot());
126
+ cleanups = [];
123
127
  A = join(root, 'proj-alpha');
124
128
  B = join(root, 'proj-beta');
125
129
  mkdirSync(A, { recursive: true });
@@ -155,6 +159,7 @@ beforeEach(() => {
155
159
  };
156
160
  const persistence = {
157
161
  root,
162
+ compression: 'zstd',
158
163
  async list() {
159
164
  // 动态扫描 root(与真实后端一致):遍历 projectKey 目录读头部
160
165
  const out = [];
@@ -164,15 +169,15 @@ beforeEach(() => {
164
169
  const projPath = join(root, proj);
165
170
  for (const idDir of readdirSync(projPath)) {
166
171
  const dir = join(projPath, idDir);
167
- const names = existsSync(dir) ? readdirSync(dir).filter((name) => name === 'session.jsonl.zstd' || /^session\.v[1-9][0-9]*\.jsonl\.zstd$/.test(name)) : [];
172
+ const names = existsSync(dir) ? readdirSync(dir).filter((name) => /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/.test(name)) : [];
168
173
  if (!names.length) continue;
169
174
  names.sort((a, b) => {
170
- const va = a === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(a)[1]);
171
- const vb = b === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(b)[1]);
175
+ const va = Number(/^session\.v([1-9][0-9]*)\.jsonl/.exec(a)?.[1] ?? 0);
176
+ const vb = Number(/^session\.v([1-9][0-9]*)\.jsonl/.exec(b)?.[1] ?? 0);
172
177
  return vb - va;
173
178
  });
174
179
  const f = join(dir, names[0]);
175
- try { out.push(readHeader(readFileSync(f))); } catch { /* skip */ }
180
+ try { out.push(readHeader(readFileSync(f), f.endsWith('.zstd') ? 'zstd' : 'none')); } catch { /* skip */ }
176
181
  }
177
182
  }
178
183
  return out;
@@ -184,12 +189,17 @@ beforeEach(() => {
184
189
  sessionPersistence: persistence,
185
190
  logger: { info() {}, warn() {} },
186
191
  get: () => undefined,
192
+ effect(register) {
193
+ const cleanup = register();
194
+ if (typeof cleanup === 'function') cleanups.push(cleanup);
195
+ },
187
196
  connection: { rpc: { handle(_ch, h) { rpcHandler = h; return () => {}; } } }
188
197
  };
189
198
  ctx.__rpc = () => rpcHandler;
190
199
  });
191
200
 
192
201
  afterEach(() => {
202
+ for (const cleanup of cleanups) cleanup();
193
203
  delete process.env.DSH_HOME;
194
204
  rmSync(root, { recursive: true, force: true });
195
205
  });
@@ -203,6 +213,321 @@ test('mover.status 就绪', async () => {
203
213
  const res = await call('mover.status');
204
214
  assert.equal(res.ok, true);
205
215
  assert.equal(res.value.ready, true);
216
+ assert.equal(res.value.capabilities.backupSnapshots, true);
217
+ });
218
+
219
+ test('mover.preview:安全项报告目标与备份大小,目标冲突明确阻断', async () => {
220
+ apply(ctx);
221
+ const safe = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
222
+ assert.equal(safe.ok, true, JSON.stringify(safe));
223
+ assert.equal(safe.value.status, 'safe');
224
+ assert.equal(safe.value.target.path, B);
225
+ assert.ok(safe.value.estimatedBackupBytes > 0);
226
+ assert.equal(safe.value.adapter.mode, 'filesystem-fallback');
227
+
228
+ const collision = artifactPath(root, B, 'session-aaa');
229
+ mkdirSync(dirname(collision), { recursive: true });
230
+ writeFileSync(collision, makeArtifact({ type: 'session', id: 'session-aaa', cwd: B, title: 'Alpha discussion' }));
231
+ const blocked = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
232
+ assert.equal(blocked.ok, true, JSON.stringify(blocked));
233
+ assert.equal(blocked.value.status, 'blocked');
234
+ assert.match(blocked.value.warnings.join(' '), /already contains/);
235
+ });
236
+
237
+ test('往返迁移预览从实际位置读取,只有当前工作区被标为无需移动', async () => {
238
+ apply(ctx);
239
+ entityA.record.sessionIds.push('session-aaa');
240
+ const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
241
+ assert.equal(moved.ok, true, JSON.stringify(moved));
242
+ const same = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
243
+ assert.equal(same.value.status, 'blocked');
244
+ assert.equal(same.value.target.collision, false);
245
+ assert.equal(same.value.estimatedBackupBytes, 0);
246
+ assert.ok(same.value.warnings.includes('session already belongs to the target workspace'));
247
+ const back = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
248
+ assert.equal(back.value.source.cwd, B);
249
+ assert.equal(back.value.target.path, A);
250
+ assert.equal(back.value.status, 'safe');
251
+ const returned = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
252
+ assert.equal(returned.ok, true, JSON.stringify(returned));
253
+ assert.equal(readHeader(readFileSync(findArtifact('session-aaa'))).cwd, A);
254
+ assert.equal(existsSync(sessionDir(root, B, 'session-aaa')), false);
255
+ });
256
+
257
+ test('预览允许修复同工作区未登记会话,且不报告副本冲突', async () => {
258
+ apply(ctx);
259
+ const plan = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
260
+ assert.equal(plan.value.status, 'safe');
261
+ assert.equal(plan.value.target.collision, false);
262
+ assert.equal(plan.value.estimatedBackupBytes, 0);
263
+ const repaired = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
264
+ assert.equal(repaired.ok, true, JSON.stringify(repaired));
265
+ assert.equal(repaired.value.attached, true);
266
+ });
267
+
268
+ test('救援扫描将混合压缩编码标为 unsupported,继续返回其余诊断', async () => {
269
+ apply(ctx);
270
+ const plain = join(sessionDir(root, A, 'session-aaa'), 'session.v3.jsonl');
271
+ writeFileSync(plain, Buffer.from([
272
+ JSON.stringify({ type: 'session', id: 'session-aaa', cwd: A }),
273
+ JSON.stringify({ type: 'message', id: 'event-mixed' })
274
+ ].join('\n') + '\n', 'utf8'));
275
+
276
+ const scan = await call('mover.scan');
277
+ assert.equal(scan.ok, true, JSON.stringify(scan));
278
+ const item = scan.value.items.find((entry) => entry.sessionId === 'session-aaa');
279
+ assert.equal(item.status, 'unreadable');
280
+ assert.equal(item.diagnostic, 'unsupported');
281
+ assert.match(item.diagnosticDetail, /mixed session compression encodings/);
282
+ const status = await call('mover.status');
283
+ assert.equal(status.value.storageLayout.diagnostic, 'unsupported');
284
+ const doctor = await call('mover.doctor');
285
+ assert.match(doctor.value.checks.find((entry) => entry.id === 'data-layout').detail, /unsupported/);
286
+ });
287
+
288
+ test('救援扫描明确区分损坏档案', async () => {
289
+ apply(ctx);
290
+ writeFileSync(artifactPath(root, A, 'session-aaa'), Buffer.from('damaged archive'));
291
+
292
+ const scan = await call('mover.scan');
293
+ assert.equal(scan.ok, true, JSON.stringify(scan));
294
+ const item = scan.value.items.find((entry) => entry.sessionId === 'session-aaa');
295
+ assert.equal(item.status, 'unreadable');
296
+ assert.equal(item.diagnostic, 'corrupt-data');
297
+ assert.ok(item.diagnosticDetail);
298
+ const status = await call('mover.status');
299
+ assert.equal(status.value.storageLayout.diagnostic, 'corrupt-data');
300
+ });
301
+
302
+ test('v4 Zstd 档案迁移与撤回时保留代际和事件字节', async () => {
303
+ apply(ctx);
304
+ entityA.record.sessionIds.push('session-aaa');
305
+ const sourceV3 = artifactPath(root, A, 'session-aaa');
306
+ const sourceV4 = join(dirname(sourceV3), 'session.v4.jsonl.zstd');
307
+ renameSync(sourceV3, sourceV4);
308
+
309
+ const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
310
+ assert.equal(moved.ok, true, JSON.stringify(moved));
311
+ const targetV4 = join(sessionDir(root, B, 'session-aaa'), 'session.v4.jsonl.zstd');
312
+ assert.ok(existsSync(targetV4));
313
+ assert.equal(readHeader(readFileSync(targetV4)).cwd, B);
314
+
315
+ const undone = await call('mover.undo', { historyId: moved.value.historyId });
316
+ assert.equal(undone.ok, true, JSON.stringify(undone));
317
+ assert.ok(existsSync(sourceV4));
318
+ assert.equal(readHeader(readFileSync(sourceV4)).cwd, A);
319
+ });
320
+
321
+ test('v3/v4 同目录明确诊断最高代际,并保留旧代际文件', async () => {
322
+ apply(ctx);
323
+ entityA.record.sessionIds.push('session-aaa');
324
+ const sourceV3 = artifactPath(root, A, 'session-aaa');
325
+ const sourceV4 = join(dirname(sourceV3), 'session.v4.jsonl.zstd');
326
+ writeFileSync(sourceV4, readFileSync(sourceV3));
327
+
328
+ const status = await call('mover.status');
329
+ assert.match(status.value.storageLayout.generationWarning, /v4 is selected/);
330
+ const doctor = await call('mover.doctor');
331
+ assert.equal(doctor.value.checks.find((item) => item.id === 'data-layout')?.state, 'warn');
332
+
333
+ const preview = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
334
+ assert.deepEqual(preview.value.source.generations.map((item) => item.version), [4, 3]);
335
+ assert.match(preview.value.warnings.join(' '), /v4 is selected and older files are preserved/);
336
+ const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
337
+ assert.equal(moved.ok, true, JSON.stringify(moved));
338
+ assert.ok(existsSync(join(sessionDir(root, B, 'session-aaa'), 'session.v3.jsonl.zstd')));
339
+ assert.equal(readHeader(readFileSync(join(sessionDir(root, B, 'session-aaa'), 'session.v4.jsonl.zstd'))).cwd, B);
340
+ });
341
+
342
+ test('legacy v0 与当前 v3 plain JSONL 均可迁移并撤回,不强制升级代际', async () => {
343
+ apply(ctx);
344
+ ctx.sessionPersistence.compression = 'none';
345
+ const cases = [
346
+ { sessionId: 'session-aaa', filename: 'session.jsonl' },
347
+ { sessionId: 'session-plain-v3', filename: 'session.v3.jsonl' }
348
+ ];
349
+ for (const item of cases) {
350
+ entityA.record.sessionIds.push(item.sessionId);
351
+ const sourceDir = sessionDir(root, A, item.sessionId);
352
+ mkdirSync(sourceDir, { recursive: true });
353
+ if (item.sessionId === 'session-aaa') rmSync(artifactPath(root, A, item.sessionId), { force: true });
354
+ const source = join(sourceDir, item.filename);
355
+ writeFileSync(source, Buffer.from([
356
+ JSON.stringify({ type: 'session', id: item.sessionId, cwd: A }),
357
+ JSON.stringify({ type: 'message', id: `event-${item.sessionId}` })
358
+ ].join('\n') + '\n', 'utf8'));
359
+
360
+ const moved = await call('mover.move', { sessionId: item.sessionId, targetWorkspaceId: 'wid-b' });
361
+ assert.equal(moved.ok, true, JSON.stringify(moved));
362
+ const target = join(sessionDir(root, B, item.sessionId), item.filename);
363
+ assert.ok(existsSync(target));
364
+ assert.equal(readHeader(readFileSync(target), 'none').cwd, B);
365
+ assert.equal(readFileSync(target, 'utf8').split('\n')[1], JSON.stringify({ type: 'message', id: `event-${item.sessionId}` }));
366
+
367
+ const undone = await call('mover.undo', { historyId: moved.value.historyId });
368
+ assert.equal(undone.ok, true, JSON.stringify(undone));
369
+ assert.equal(readHeader(readFileSync(source), 'none').cwd, A);
370
+ }
371
+ });
372
+
373
+ test('文件名代际与显式 header 代际不符时,在迁移前拒绝且字节不变', async () => {
374
+ apply(ctx);
375
+ entityA.record.sessionIds.push('session-aaa');
376
+ const source = artifactPath(root, A, 'session-aaa');
377
+ const bytes = makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, formatVersion: 4 });
378
+ writeFileSync(source, bytes);
379
+
380
+ const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
381
+ assert.equal(moved.ok, false);
382
+ assert.equal(moved.error.code, 'unsupported');
383
+ assert.deepEqual(readFileSync(source), bytes);
384
+ assert.ok(!existsSync(artifactPath(root, B, 'session-aaa')));
385
+ });
386
+
387
+ test('busy 迁移进入持久化队列,空闲后可重试完成', async () => {
388
+ apply(ctx);
389
+ entityA.record.sessionIds.push('session-aaa');
390
+ ctx.get = (key) => (key === 'agents' ? { get: () => ({ status: 'running' }) } : undefined);
391
+ const queued = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
392
+ assert.equal(queued.ok, false);
393
+ assert.equal(queued.error.code, 'busy');
394
+ assert.ok(queued.error.details.queueTaskId);
395
+
396
+ const waiting = await call('mover.migrations.list');
397
+ const waitingItem = waiting.value.items.find((item) => item.id === queued.error.details.queueTaskId);
398
+ assert.equal(waitingItem.state, 'waiting-for-idle');
399
+
400
+ ctx.get = () => undefined;
401
+ const retried = await call('mover.migrations.retry', { taskId: waitingItem.id });
402
+ assert.equal(retried.ok, true, JSON.stringify(retried));
403
+ assert.equal(retried.value.item.state, 'done');
404
+ assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
405
+ const repeated = await call('mover.migrations.retry', { taskId: waitingItem.id });
406
+ assert.equal(repeated.value.item.state, 'done');
407
+ assert.equal(repeated.value.item.attempts, retried.value.item.attempts, 'completed retries are idempotent');
408
+ });
409
+
410
+ test('busy 队列在插件重新挂载后从持久化记录继续执行', async () => {
411
+ apply(ctx);
412
+ entityA.record.sessionIds.push('session-aaa');
413
+ ctx.get = (key) => (key === 'agents' ? { get: () => ({ status: 'running' }) } : undefined);
414
+ const queued = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
415
+ const queueFile = join(root, 'workspace-mover', 'migrations.json');
416
+ assert.ok(existsSync(queueFile));
417
+
418
+ for (const cleanup of cleanups) cleanup();
419
+ cleanups = [];
420
+ ctx.get = () => undefined;
421
+ let restartedRpc;
422
+ const restartedCtx = {
423
+ ...ctx,
424
+ effect(register) {
425
+ const cleanup = register();
426
+ if (typeof cleanup === 'function') cleanups.push(cleanup);
427
+ },
428
+ connection: { rpc: { handle(_ch, handler) { restartedRpc = handler; return () => {}; } } }
429
+ };
430
+ apply(restartedCtx);
431
+ const recovered = await restartedRpc('mover.migrations.list', {});
432
+ assert.equal(recovered.ok, true, JSON.stringify(recovered));
433
+ const item = recovered.value.items.find((entry) => entry.id === queued.error.details.queueTaskId);
434
+ assert.equal(item.state, 'done');
435
+ assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
436
+ });
437
+
438
+ test('迁移队列支持批量重试并逐项隔离失败', async () => {
439
+ apply(ctx);
440
+ const queued = await call('mover.migrations.enqueue', {
441
+ targetWorkspaceId: 'wid-b',
442
+ sessions: [{ sessionId: 'session-missing-a' }, { sessionId: 'session-missing-b' }]
443
+ });
444
+ assert.equal(queued.ok, true, JSON.stringify(queued));
445
+ const taskIds = queued.value.queued.map((item) => item.id);
446
+ const retry = await call('mover.migrations.retry', { taskIds });
447
+ assert.equal(retry.ok, true, JSON.stringify(retry));
448
+ assert.equal(retry.value.retried, 2);
449
+ assert.deepEqual(retry.value.items.map((item) => item.state), ['failed', 'failed']);
450
+ assert.deepEqual(retry.value.items.map((item) => item.attempts), [2, 2]);
451
+ });
452
+
453
+ test('迁移队列执行中拒绝取消,损坏的队列数据由 status 和 doctor 显示', async () => {
454
+ apply(ctx);
455
+ entityA.record.sessionIds.push('session-aaa');
456
+ ctx.get = (key) => (key === 'agents' ? { get: () => ({ status: 'running' }) } : undefined);
457
+ const queued = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
458
+ ctx.get = () => undefined;
459
+
460
+ const originalList = ctx.sessionPersistence.list.bind(ctx.sessionPersistence);
461
+ let entered;
462
+ const inMove = new Promise((resolve) => { entered = resolve; });
463
+ let release;
464
+ const hold = new Promise((resolve) => { release = resolve; });
465
+ ctx.sessionPersistence.list = async (...args) => {
466
+ entered();
467
+ await hold;
468
+ return originalList(...args);
469
+ };
470
+ const retrying = call('mover.migrations.retry', { taskId: queued.error.details.queueTaskId });
471
+ await inMove;
472
+ const cancel = await call('mover.migrations.cancel', { taskId: queued.error.details.queueTaskId });
473
+ assert.equal(cancel.ok, false);
474
+ assert.equal(cancel.error.code, 'busy');
475
+ release();
476
+ const done = await retrying;
477
+ assert.equal(done.value.item.state, 'done');
478
+
479
+ writeFileSync(join(root, 'workspace-mover', 'migrations.json'), '{broken', 'utf8');
480
+ const status = await call('mover.status');
481
+ assert.equal(status.ok, true);
482
+ assert.match(status.value.capabilities.migrationQueueState.error, /corrupt/);
483
+ assert.ok(status.value.degradedFeatures.includes('migration-queue-data'));
484
+ const doctor = await call('mover.doctor');
485
+ assert.equal(doctor.ok, true);
486
+ assert.equal(doctor.value.checks.find((item) => item.id === 'data-migrations')?.state, 'fail');
487
+ });
488
+
489
+ test('official handle-only persistence 被明确标记为降级而不是猜物理路径', async () => {
490
+ const official = createPersistenceAdapter({ sessionPersistence: {
491
+ async list() { return []; },
492
+ async stat() { return { formatStatus: 'jsonl' }; },
493
+ async open() { return {}; }
494
+ } });
495
+ assert.equal(official.mode, 'official-handle');
496
+ assert.equal(official.capabilities.physicalPath, false);
497
+ await assert.rejects(() => official.read({ kind: 'handle', sessionId: 'x' }), /raw-byte physical path/);
498
+ });
499
+
500
+ test('fork-tree 预览会列出后代,Portable Bundle 导入先 dry-run 再执行', async () => {
501
+ apply(ctx);
502
+ entityA.record.sessionIds.push('session-aaa');
503
+ const child = artifactPath(root, A, 'session-child');
504
+ mkdirSync(dirname(child), { recursive: true });
505
+ writeFileSync(child, makeArtifact({ type: 'session', id: 'session-child', parentSessionId: 'session-aaa', cwd: A, title: 'Child branch' }));
506
+
507
+ const preview = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b', mode: 'include-descendants' });
508
+ assert.equal(preview.ok, true, JSON.stringify(preview));
509
+ assert.equal(preview.value.branches.count, 1);
510
+ assert.deepEqual(preview.value.branches.sessionIds, ['session-aaa', 'session-child']);
511
+
512
+ const bundlePath = join(root, 'portable-bundle');
513
+ const exported = await call('mover.bundle.export', { sessionId: 'session-aaa', bundlePath, mode: 'include-descendants' });
514
+ assert.equal(exported.ok, true, JSON.stringify(exported));
515
+ assert.equal(exported.value.sessionCount, 2);
516
+ assert.ok(existsSync(join(bundlePath, 'manifest.json')));
517
+ assert.ok(existsSync(join(bundlePath, 'checksums.json')));
518
+
519
+ entityA.record.sessionIds = [];
520
+ rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
521
+ rmSync(dirname(artifactPath(root, A, 'session-child')), { recursive: true, force: true });
522
+ const dryRun = await call('mover.bundle.import', { bundlePath, targetWorkspaceId: 'wid-b' });
523
+ assert.equal(dryRun.ok, true, JSON.stringify(dryRun));
524
+ assert.equal(dryRun.value.dryRun, true);
525
+ assert.equal(dryRun.value.status, 'safe');
526
+ const imported = await call('mover.bundle.import', { bundlePath, targetWorkspaceId: 'wid-b', dryRun: false });
527
+ assert.equal(imported.ok, true, JSON.stringify(imported));
528
+ assert.equal(imported.value.importedCount, 2);
529
+ assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
530
+ assert.ok(existsSync(artifactPath(root, B, 'session-child')));
206
531
  });
207
532
 
208
533
  test('v2.2 mover.status 能力位:官方接口缺失时如实标记为降级(可观测,不静默)', async () => {
@@ -1252,6 +1577,43 @@ test('stashBackup:相邻前缀 id 互不误删;同一毫秒的连续备份
1252
1577
  assert.equal(readdirSync(dir).filter((f) => f.startsWith(`${b}.`)).length, 1, '相邻前缀 id 仍不受影响');
1253
1578
  });
1254
1579
 
1580
+ test('stashBackup:忽略迁移 cwd 后复用同一快照,内容变化才新增物理文件', () => {
1581
+ const dir = join(root, 'workspace-mover', 'backups');
1582
+ const firstBytes = makeArtifact({ type: 'session', id: 'session-dedupe', cwd: A, title: 'Stable content' });
1583
+ const sameContentAtOtherWorkspace = makeArtifact({ type: 'session', id: 'session-dedupe', cwd: B, title: 'Stable content' });
1584
+ const first = stashBackup('session-dedupe', firstBytes, { compression: 'zstd', ignoreCwd: true });
1585
+ const reused = stashBackup('session-dedupe', sameContentAtOtherWorkspace, { compression: 'zstd', ignoreCwd: true });
1586
+ assert.equal(first.created, true);
1587
+ assert.equal(reused.reused, true);
1588
+ assert.equal(reused.addedBytes, 0);
1589
+ assert.equal(readdirSync(dir).filter((f) => f.startsWith('session-dedupe.')).length, 1);
1590
+
1591
+ const changed = makeArtifact({ type: 'session', id: 'session-dedupe', cwd: B, title: 'Changed content' });
1592
+ const second = stashBackup('session-dedupe', changed, { compression: 'zstd', ignoreCwd: true });
1593
+ assert.equal(second.created, true);
1594
+ assert.equal(readdirSync(dir).filter((f) => f.startsWith('session-dedupe.')).length, 2);
1595
+ });
1596
+
1597
+ test('往返迁移:备份物理文件只保留一份,但每次移动历史都记录复用状态', async () => {
1598
+ apply(ctx);
1599
+ entityA.record.sessionIds.push('session-aaa');
1600
+ const first = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
1601
+ assert.equal(first.ok, true, JSON.stringify(first));
1602
+ const backPreview = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
1603
+ assert.equal(backPreview.ok, true, JSON.stringify(backPreview));
1604
+ assert.equal(backPreview.value.backup.reused, true);
1605
+ assert.equal(backPreview.value.estimatedBackupBytes, 0);
1606
+ const back = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
1607
+ assert.equal(back.ok, true, JSON.stringify(back));
1608
+ const files = readdirSync(join(root, 'workspace-mover', 'backups')).filter((f) => f.startsWith('session-aaa.'));
1609
+ assert.equal(files.length, 1, JSON.stringify(files));
1610
+ const history = await call('mover.history');
1611
+ assert.equal(history.ok, true);
1612
+ assert.equal(history.value.items.length, 2);
1613
+ assert.equal(history.value.items[0].backup.reused, true);
1614
+ assert.equal(history.value.items[1].backup.created, true);
1615
+ });
1616
+
1255
1617
  //#region v0.9:回收站 + 备份管理
1256
1618
 
1257
1619
  /** 投影缓存 mock:插件在服务缺省时降级为 no-op,需要验证标题行为时挂上。
@@ -2177,6 +2539,7 @@ test('宿主布局漂移时自证失败:storageLayout=degraded 且列为降级
2177
2539
  const status = await call('mover.status');
2178
2540
  assert.equal(status.ok, true, 'status 本身永远 ok');
2179
2541
  assert.equal(status.value.capabilities.storageLayout, 'degraded');
2542
+ assert.equal(status.value.storageLayout.diagnostic, 'layout-unknown');
2180
2543
  assert.ok(status.value.degradedFeatures.includes('storage-layout'),
2181
2544
  `降级特性应包含 storage-layout: ${JSON.stringify(status.value.degradedFeatures)}`);
2182
2545
  assert.equal(typeof status.value.storageLayout.reason, 'string');
@@ -2198,3 +2561,47 @@ test('没有会话可校验时 storageLayout=empty(全新安装不误报降级
2198
2561
  });
2199
2562
  //#endregion
2200
2563
 
2564
+
2565
+ test('默认恢复选择最新备份,显式指定时仍可恢复旧备份', async () => {
2566
+ apply(ctx);
2567
+ const dir = join(root, 'workspace-mover', 'backups');
2568
+ mkdirSync(dir, { recursive: true });
2569
+ writeFileSync(join(dir, 'session-aaa.1000.zstd'), makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, title: 'Old content' }));
2570
+ writeFileSync(join(dir, 'session-aaa.2000.zstd'), makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, title: 'Latest content' }));
2571
+ rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
2572
+ const restored = await call('mover.backups.restore', { sessionId: 'session-aaa' });
2573
+ assert.equal(restored.ok, true, JSON.stringify(restored));
2574
+ assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).title, 'Latest content');
2575
+ entityA.record.sessionIds = [];
2576
+ sharedIndex.sessionPaths.delete('session-aaa');
2577
+ rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
2578
+ const old = await call('mover.backups.restore', { sessionId: 'session-aaa', fileName: 'session-aaa.1000.zstd' });
2579
+ assert.equal(old.ok, true, JSON.stringify(old));
2580
+ assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).title, 'Old content');
2581
+ });
2582
+
2583
+ test('Bundle 空间估算包含附件,空间不足时 dry-run 阻断且执行不写入', async () => {
2584
+ apply(ctx);
2585
+ const attachment = Buffer.alloc(4096, 7);
2586
+ writeFileSync(join(dirname(artifactPath(root, A, 'session-aaa')), 'attachment.bin'), attachment);
2587
+ const bundlePath = join(root, 'space-bundle');
2588
+ const exported = await call('mover.bundle.export', { sessionId: 'session-aaa', bundlePath });
2589
+ assert.equal(exported.ok, true, JSON.stringify(exported));
2590
+ const archiveBytes = readFileSync(artifactPath(root, A, 'session-aaa')).length;
2591
+ rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
2592
+ const original = fs.statfsSync;
2593
+ fs.statfsSync = () => ({ bfree: 1, bsize: 1 });
2594
+ syncBuiltinESMExports();
2595
+ try {
2596
+ const plan = await call('mover.bundle.inspect', { bundlePath, targetWorkspaceId: 'wid-b' });
2597
+ assert.equal(plan.ok, true, JSON.stringify(plan));
2598
+ assert.equal(plan.value.requiredBytes, archiveBytes + attachment.length);
2599
+ assert.equal(plan.value.status, 'blocked');
2600
+ const imported = await call('mover.bundle.import', { bundlePath, targetWorkspaceId: 'wid-b', dryRun: false });
2601
+ assert.equal(imported.ok, false);
2602
+ assert.ok(!existsSync(artifactPath(root, B, 'session-aaa')));
2603
+ } finally {
2604
+ fs.statfsSync = original;
2605
+ syncBuiltinESMExports();
2606
+ }
2607
+ });