dsh-workspace-mover 2.4.1 → 2.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -493,6 +493,104 @@ test('模块导出 apply / inject,且 apply 不抛错', () => {
493
493
  assert.deepEqual(mod.inject, ['connection', 'slots']);
494
494
  });
495
495
 
496
+ test('locale service wins over browser language, switches live and releases registrations', async () => {
497
+ resetDom();
498
+ let language = 'en';
499
+ let dicts;
500
+ let unregisters = 0;
501
+ const listeners = new Set();
502
+ const locale = {
503
+ register(ns, values) {
504
+ assert.equal(ns, 'workspace-mover'); dicts = values;
505
+ return () => { unregisters++; };
506
+ },
507
+ bind() { return (key) => dicts[language][key] ?? key; },
508
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
509
+ };
510
+ ctx = makeCtx();
511
+ ctx.get = (key) => key === 'locale' ? locale : undefined;
512
+ mod.apply(ctx);
513
+ const row = sessionRow('Locale test', 'session-aaa');
514
+ const beta = header('proj-beta');
515
+ fixture({ rows: [row] }); attach(beta);
516
+ await dragDrop(row, beta, 'session-aaa', { confirm: false });
517
+ assert.equal(doc._body.querySelector('.wsm-title').textContent, 'Move session across workspaces');
518
+ dispatch({ type: 'click', target: doc._body.querySelector('.wsm-cancel') });
519
+ language = 'zh'; for (const fn of listeners) fn();
520
+ await dragDrop(row, beta, 'session-aaa', { confirm: false });
521
+ assert.equal(doc._body.querySelector('.wsm-title').textContent, '跨工作区移动会话');
522
+ dispatch({ type: 'click', target: doc._body.querySelector('.wsm-cancel') });
523
+ unmount();
524
+ assert.equal(unregisters, 1);
525
+ assert.equal(listeners.size, 0);
526
+ });
527
+
528
+ test('late locale service is disposed even when unavailable at initial mount', () => {
529
+ resetDom();
530
+ ctx = makeCtx();
531
+ let arrive;
532
+ ctx.inject = (keys, callback) => { assert.deepEqual(keys, ['locale']); arrive = callback; };
533
+ mod.apply(ctx);
534
+ let disposed = 0;
535
+ const locale = { register: () => () => disposed++, bind: () => () => 'translated' };
536
+ arrive({ get: () => locale, effect(fn) { effects.push(fn()); } });
537
+ unmount();
538
+ assert.equal(disposed, 1);
539
+ });
540
+
541
+ test('deleting selected conversation clears its view without reopening; later selection is preserved', async () => {
542
+ for (const navigate of [false, true]) {
543
+ resetDom();
544
+ ctx = makeCtx();
545
+ let selected = 'session-delete';
546
+ let cleared = 0;
547
+ let rpc;
548
+ const ui = {
549
+ selection: { getSnapshot: () => ({ sessionId: selected }) },
550
+ clearMain() { selected = null; cleared++; },
551
+ openSession() { assert.fail('must not reload deleted conversation'); }
552
+ };
553
+ ctx.get = (key) => key === 'uiWorkspace' ? ui : undefined;
554
+ ctx.slots = {
555
+ inject(_name, fn) { fn(); },
556
+ register(options) { rpc = options.inject().rpcCall; }
557
+ };
558
+ ctx.connection.rpc.call = async () => {
559
+ if (navigate) selected = 'session-other';
560
+ return { ok: true, value: { deleted: true } };
561
+ };
562
+ mod.apply(ctx);
563
+ await rpc('mover.session.delete', { sessionId: 'session-delete' });
564
+ assert.equal(cleared, navigate ? 0 : 1);
565
+ assert.equal(selected, navigate ? 'session-other' : null);
566
+ }
567
+ });
568
+
569
+ test('restore refreshes the real session baseline once; refresh failure never repeats file restoration', async () => {
570
+ for (const endpoint of ['mover.trash.restore', 'mover.backups.restore', 'mover.unarchive']) {
571
+ for (const brokenRefresh of [false, true]) {
572
+ resetDom();
573
+ ctx = makeCtx();
574
+ let rpc;
575
+ const actions = [];
576
+ ctx.slots = { inject(_name, fn) { fn(); }, register(options) { rpc = options.inject().rpcCall; } };
577
+ ctx.get = (key) => key === 'sessions' ? {
578
+ async refresh() { actions.push('refresh'); if (brokenRefresh) throw new Error('list unavailable'); }
579
+ } : undefined;
580
+ ctx.connection.rpc.call = async (_channel, method) => {
581
+ actions.push(method);
582
+ return { ok: true, value: { restored: true } };
583
+ };
584
+ mod.apply(ctx);
585
+ actions.length = 0; // discard the initial status probe
586
+ const result = await rpc(endpoint, { sessionId: 'session-restored' });
587
+ assert.equal(result.ok, true, 'do not label completed restore as failed');
588
+ assert.deepEqual(actions, [endpoint, 'refresh']);
589
+ if (brokenRefresh) assert.match(doc._body.querySelector('.wsm-toast')?.textContent, /无需重复还原/);
590
+ }
591
+ }
592
+ });
593
+
496
594
  test('热重载:卸载再装载不得让 document 监听器净增长(teardown 回归)', () => {
497
595
  resetDom();
498
596
  mount();
@@ -566,7 +664,7 @@ test('拖拽跨组移动:经权威 fiber 通道解析出正确 sessionId', asy
566
664
  assert.equal(calls[0].payload.targetWorkspaceId, 'wid-b');
567
665
  });
568
666
 
569
- test('迁移标题使用权威预览,不包含侧边栏时间;驻留队列提示可能需重启', async () => {
667
+ test('即时迁移使用权威标题,不包含侧边栏时间,不启用排队', async () => {
570
668
  resetDom();
571
669
  mount();
572
670
  const row = sessionRow('测试刚刚', 'session-aaa');
@@ -575,22 +673,114 @@ test('迁移标题使用权威预览,不包含侧边栏时间;驻留队列
575
673
  const prior = rpcResponder;
576
674
  setResponder((endpoint, payload) => endpoint === 'mover.preview' ? {
577
675
  ok: true, value: {
578
- title: '测试', status: 'queued', source: { cwd: 'C:/proj-alpha' },
676
+ title: '测试', status: 'safe', liveMigration: true, source: { cwd: 'C:/proj-alpha' },
579
677
  target: { path: 'C:/proj-beta' }, estimatedBackupBytes: 0,
580
- warnings: ['resident writer; restart required']
678
+ warnings: []
581
679
  }
582
680
  } : prior(endpoint, payload));
583
681
  attach(beta);
584
682
  await dragDrop(row, beta, 'session-aaa', { confirm: false });
585
683
  const overlay = doc._body.querySelector('.wsm-overlay');
586
684
  assert.equal(overlay.querySelector('.wsm-session').textContent, '测试');
587
- assert.match(overlay.querySelector('.wsm-preview').textContent, /可能需重启/);
685
+ assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /可能需重启/);
588
686
  assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /进入空闲队列/);
589
687
  dispatch({ type: 'click', target: overlay.querySelector('.wsm-ok') });
590
688
  await new Promise((resolve) => setTimeout(resolve, 0));
591
689
  assert.equal(moveCalls()[0].payload.sessionTitle, '测试');
690
+ assert.equal(moveCalls()[0].payload.allowQueue, false);
592
691
  });
593
692
 
693
+ for (const outcome of ['success', 'render-release', 'navigation-during-release', 'rollback', 'transport-error', 'navigated', 'healthy', 'unloaded']) {
694
+ test(`migration reconnects the removed current conversation via host navigation: ${outcome}`, async () => {
695
+ resetDom(); mount();
696
+ const row = sessionRow('标题保留测试', 'session-aaa');
697
+ const beta = header('proj-beta');
698
+ fixture({ rows: [row] });
699
+ attach(beta);
700
+ let selected = 'session-aaa';
701
+ let removed = false;
702
+ const releaseListeners = new Set();
703
+ let binding;
704
+ const navigation = [];
705
+ const ui = {
706
+ selection: { getSnapshot: () => ({ sessionId: selected }) },
707
+ clearMain() {
708
+ navigation.push('release'); selected = undefined;
709
+ const release = () => {
710
+ binding = undefined;
711
+ if (outcome === 'navigation-during-release') selected = 'session-other';
712
+ for (const listener of releaseListeners) listener();
713
+ };
714
+ if (outcome === 'render-release' || outcome === 'navigation-during-release') setTimeout(release, 0);
715
+ else release();
716
+ },
717
+ openSession(id) {
718
+ assert.equal(binding, undefined, 'must wait for Provider reference release before retaining again');
719
+ navigation.push(id); selected = id; removed = false; binding = { session };
720
+ }
721
+ };
722
+ const session = {
723
+ getSnapshot: () => ({ removed }),
724
+ async open() { navigation.push('ready'); }
725
+ };
726
+ binding = { session };
727
+ const sessions = { binding: () => binding, retainInfo: () => ({
728
+ subscribe(listener) { releaseListeners.add(listener); return () => releaseListeners.delete(listener); }
729
+ }) };
730
+ ctx.get = (key) => ({ uiWorkspace: ui, sessions })[key];
731
+ const prior = rpcResponder;
732
+ setResponder((endpoint, payload) => {
733
+ if (endpoint !== 'mover.move') return prior(endpoint, payload);
734
+ removed = outcome !== 'healthy';
735
+ if (outcome === 'navigated') selected = 'session-other';
736
+ if (outcome === 'unloaded') unmount();
737
+ if (outcome === 'transport-error') throw new Error('Connection lost after release');
738
+ if (outcome === 'rollback') return { ok: false, error: { code: 'move-failed', message: 'rolled back cleanly' } };
739
+ return prior(endpoint, payload);
740
+ });
741
+ await dragDrop(row, beta, 'session-aaa');
742
+ await new Promise((resolve) => setTimeout(resolve, 10));
743
+ assert.equal(moveCalls().length, 1, 'never repeats the physical migration');
744
+ if (['navigated', 'healthy', 'unloaded'].includes(outcome)) {
745
+ assert.deepEqual(navigation, []);
746
+ } else if (outcome === 'navigation-during-release') {
747
+ assert.deepEqual(navigation, ['release']);
748
+ assert.equal(selected, 'session-other');
749
+ } else {
750
+ assert.deepEqual(navigation, ['release', 'session-aaa', 'ready']);
751
+ assert.equal(removed, false, 'new view can accept messages without manually switching');
752
+ }
753
+ assert.equal(releaseListeners.size, 0, 'retention observer is released');
754
+ if (outcome === 'success') {
755
+ const text = doc._body.querySelector('.wsm-toast')?.textContent;
756
+ assert.match(text, /重启应用/);
757
+ assert.doesNotMatch(text, /刷新页面/);
758
+ }
759
+ if (outcome === 'rollback') assert.match(doc._body.querySelector('.wsm-toast')?.textContent, /rolled back cleanly/);
760
+ });
761
+ }
762
+
763
+ for (const approve of [false, true]) {
764
+ test(`运行中拖动会话:${approve ? '明确停止后迁移' : '取消不执行'}`, async () => {
765
+ resetDom(); mount();
766
+ const row = sessionRow('运行中', 'session-aaa');
767
+ const beta = header('proj-beta');
768
+ fixture({ rows: [row] });
769
+ const prior = rpcResponder;
770
+ setResponder((endpoint, payload) => endpoint === 'mover.preview' ? { ok: true, value: {
771
+ status: 'warning', running: true, liveMigration: true, warnings: ['session is running; moving requires explicit stop confirmation']
772
+ } } : prior(endpoint, payload));
773
+ attach(beta);
774
+ await dragDrop(row, beta, 'session-aaa', { confirm: false });
775
+ const overlay = doc._body.querySelector('.wsm-overlay');
776
+ assert.equal(overlay.querySelector('.wsm-ok').textContent, '停止任务并迁移');
777
+ dispatch({ type: 'click', target: overlay.querySelector(approve ? '.wsm-ok' : '.wsm-cancel') });
778
+ await new Promise((resolve) => setTimeout(resolve, 0));
779
+ assert.equal(moveCalls().length, approve ? 1 : 0);
780
+ if (approve) assert.equal(moveCalls()[0].payload.stopRunning, true);
781
+ });
782
+ }
783
+
594
784
  test('兜底映射错配时不得把错配 id 当成真实会话移动(P2 回归)', async () => {
595
785
  resetDom();
596
786
  mount();
@@ -1,55 +1,55 @@
1
- // 核心纯函数单元测试:帧扫描 / 头部读写 / 存储路径编码。
2
- // 运行:npm test(或 node --test test/)
3
- import { test } from 'node:test';
4
- import assert from 'node:assert/strict';
5
- import { zstdCompressSync, constants } from 'node:zlib';
6
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
8
- import { join } from 'node:path';
9
- import { scanFrames, readHeader, rewriteHeaderCwd, generationLogFilename, parseGenerationLogFilename, findHighestArtifact, artifactPath, sessionDir } from '../lib/index.js';
10
-
11
- const OPTS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
12
-
13
- /** 构造一个「头 + N 个事件帧」的合成档案。 */
14
- function makeArtifact(header, events) {
15
- const head = zstdCompressSync(Buffer.from(JSON.stringify(header) + '\n', 'utf8'), OPTS);
16
- const rest = events.map((e) => zstdCompressSync(Buffer.from(JSON.stringify(e) + '\n', 'utf8'), OPTS));
17
- return Buffer.concat([head, ...rest]);
18
- }
19
-
20
- test('scanFrames 定位全部帧边界', () => {
21
- const buf = makeArtifact({ type: 'session', id: 's1', cwd: 'E:\\a' }, [{ seq: 1 }, { seq: 2 }]);
22
- const frames = scanFrames(buf);
23
- assert.equal(frames.length, 3);
24
- for (const [s, e] of frames) assert.ok(e > s && e <= buf.length);
25
- });
26
-
27
- test('readHeader 解出首行会话头', () => {
28
- const buf = makeArtifact({ type: 'session', id: 'session-x', cwd: 'C:\\work' }, [{ seq: 1 }]);
29
- const header = readHeader(buf);
30
- assert.equal(header.type, 'session');
31
- assert.equal(header.id, 'session-x');
32
- assert.equal(header.cwd, 'C:\\work');
33
- });
34
-
35
- test('rewriteHeaderCwd 只改头部,其余帧字节不变', () => {
36
- const ev = [{ seq: 1, text: 'hello 世界' }, { seq: 2 }];
37
- const original = makeArtifact({ type: 'session', id: 'session-y', cwd: 'E:\\old\\path' }, ev);
38
- const rewritten = rewriteHeaderCwd(original, 'E:\\new\\home');
39
- // 头部已更新
40
- const header = readHeader(rewritten);
41
- assert.equal(header.cwd, 'E:\\new\\home');
42
- assert.equal(header.id, 'session-y');
43
- // 其余事件帧逐字节保留
44
- const of = scanFrames(original), nf = scanFrames(rewritten);
45
- assert.equal(nf.length, of.length);
46
- assert.ok(of.slice(1).every(([, e], i) => original.subarray(of[i + 1][0], e).equals(rewritten.subarray(nf[i + 1][0], nf[i + 1][1]))));
47
- });
48
-
1
+ // 核心纯函数单元测试:帧扫描 / 头部读写 / 存储路径编码。
2
+ // 运行:npm test(或 node --test test/)
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { zstdCompressSync, constants } from 'node:zlib';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { scanFrames, readHeader, rewriteHeaderCwd, generationLogFilename, parseGenerationLogFilename, findHighestArtifact, artifactPath, sessionDir } from '../lib/index.js';
10
+
11
+ const OPTS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
12
+
13
+ /** 构造一个「头 + N 个事件帧」的合成档案。 */
14
+ function makeArtifact(header, events) {
15
+ const head = zstdCompressSync(Buffer.from(JSON.stringify(header) + '\n', 'utf8'), OPTS);
16
+ const rest = events.map((e) => zstdCompressSync(Buffer.from(JSON.stringify(e) + '\n', 'utf8'), OPTS));
17
+ return Buffer.concat([head, ...rest]);
18
+ }
19
+
20
+ test('scanFrames 定位全部帧边界', () => {
21
+ const buf = makeArtifact({ type: 'session', id: 's1', cwd: 'E:\\a' }, [{ seq: 1 }, { seq: 2 }]);
22
+ const frames = scanFrames(buf);
23
+ assert.equal(frames.length, 3);
24
+ for (const [s, e] of frames) assert.ok(e > s && e <= buf.length);
25
+ });
26
+
27
+ test('readHeader 解出首行会话头', () => {
28
+ const buf = makeArtifact({ type: 'session', id: 'session-x', cwd: 'C:\\work' }, [{ seq: 1 }]);
29
+ const header = readHeader(buf);
30
+ assert.equal(header.type, 'session');
31
+ assert.equal(header.id, 'session-x');
32
+ assert.equal(header.cwd, 'C:\\work');
33
+ });
34
+
35
+ test('rewriteHeaderCwd 只改头部,其余帧字节不变', () => {
36
+ const ev = [{ seq: 1, text: 'hello 世界' }, { seq: 2 }];
37
+ const original = makeArtifact({ type: 'session', id: 'session-y', cwd: 'E:\\old\\path' }, ev);
38
+ const rewritten = rewriteHeaderCwd(original, 'E:\\new\\home');
39
+ // 头部已更新
40
+ const header = readHeader(rewritten);
41
+ assert.equal(header.cwd, 'E:\\new\\home');
42
+ assert.equal(header.id, 'session-y');
43
+ // 其余事件帧逐字节保留
44
+ const of = scanFrames(original), nf = scanFrames(rewritten);
45
+ assert.equal(nf.length, of.length);
46
+ assert.ok(of.slice(1).every(([, e], i) => original.subarray(of[i + 1][0], e).equals(rewritten.subarray(nf[i + 1][0], nf[i + 1][1]))));
47
+ });
48
+
49
49
  test('损坏档案被拒绝而不是静默通过', () => {
50
- const buf = makeArtifact({ type: 'session', id: 's', cwd: 'x' }, []);
51
- buf.writeUInt32LE(0x12345678, 8); // 破坏第一帧内部
52
- assert.throws(() => readHeader(buf));
50
+ const buf = makeArtifact({ type: 'session', id: 's', cwd: 'x' }, []);
51
+ buf.writeUInt32LE(0x12345678, 8); // 破坏第一帧内部
52
+ assert.throws(() => readHeader(buf));
53
53
  });
54
54
 
55
55
  test('readHeader and rewriteHeaderCwd support uncompressed JSONL', () => {
@@ -67,29 +67,29 @@ test('readHeader and rewriteHeaderCwd support uncompressed JSONL', () => {
67
67
 
68
68
 
69
69
  test('generationLogFilename prefers v3 zstd names', () => {
70
- assert.equal(generationLogFilename(0, 'zstd'), 'session.jsonl.zstd');
71
- assert.equal(generationLogFilename(3, 'zstd'), 'session.v3.jsonl.zstd');
72
- assert.equal(parseGenerationLogFilename('session.jsonl.zstd')?.version, 0);
73
- assert.equal(parseGenerationLogFilename('session.v3.jsonl.zstd')?.version, 3);
74
- assert.equal(parseGenerationLogFilename('session.migration.tmp.jsonl.zstd'), null);
75
- });
76
-
70
+ assert.equal(generationLogFilename(0, 'zstd'), 'session.jsonl.zstd');
71
+ assert.equal(generationLogFilename(3, 'zstd'), 'session.v3.jsonl.zstd');
72
+ assert.equal(parseGenerationLogFilename('session.jsonl.zstd')?.version, 0);
73
+ assert.equal(parseGenerationLogFilename('session.v3.jsonl.zstd')?.version, 3);
74
+ assert.equal(parseGenerationLogFilename('session.migration.tmp.jsonl.zstd'), null);
75
+ });
76
+
77
77
  test('findHighestArtifact picks the newest canonical generation', () => {
78
- const root = mkdtempSync(join(tmpdir(), 'wsm-gen-'));
79
- try {
80
- const dir = join(root, 'sess');
81
- mkdirSync(dir, { recursive: true });
82
- writeFileSync(join(dir, 'session.jsonl.zstd'), Buffer.from('old'));
83
- writeFileSync(join(dir, 'session.v2.jsonl.zstd'), Buffer.from('mid'));
84
- writeFileSync(join(dir, 'session.v3.jsonl.zstd'), Buffer.from('new'));
85
- writeFileSync(join(dir, 'session.migration.abc.jsonl.zstd'), Buffer.from('tmp'));
86
- const found = findHighestArtifact(dir);
87
- assert.equal(found?.version, 3);
88
- assert.equal(found?.name, 'session.v3.jsonl.zstd');
89
- assert.equal(artifactPath(root, 'E:\\proj', 'session-x'), join(sessionDir(root, 'E:\\proj', 'session-x'), 'session.v3.jsonl.zstd'));
90
- } finally {
91
- rmSync(root, { recursive: true, force: true });
92
- }
78
+ const root = mkdtempSync(join(tmpdir(), 'wsm-gen-'));
79
+ try {
80
+ const dir = join(root, 'sess');
81
+ mkdirSync(dir, { recursive: true });
82
+ writeFileSync(join(dir, 'session.jsonl.zstd'), Buffer.from('old'));
83
+ writeFileSync(join(dir, 'session.v2.jsonl.zstd'), Buffer.from('mid'));
84
+ writeFileSync(join(dir, 'session.v3.jsonl.zstd'), Buffer.from('new'));
85
+ writeFileSync(join(dir, 'session.migration.abc.jsonl.zstd'), Buffer.from('tmp'));
86
+ const found = findHighestArtifact(dir);
87
+ assert.equal(found?.version, 3);
88
+ assert.equal(found?.name, 'session.v3.jsonl.zstd');
89
+ assert.equal(artifactPath(root, 'E:\\proj', 'session-x'), join(sessionDir(root, 'E:\\proj', 'session-x'), 'session.v3.jsonl.zstd'));
90
+ } finally {
91
+ rmSync(root, { recursive: true, force: true });
92
+ }
93
93
  });
94
94
 
95
95
  test('findHighestArtifact supports the official uncompressed encoding', () => {
@@ -62,13 +62,18 @@ for (const compression of ['none', 'zstd']) {
62
62
  writeFileSync(src, original);
63
63
  const projections = { identity: { cwd: A }, rows: { workingDirectory: { val: A }, title: { val: 'Keep title' } } };
64
64
  let updated;
65
- ctx.get = (key) => key === 'sessionProjectionCache' ? { table: { async update(id, fn) { updated = fn(projections); } } } : undefined;
65
+ let cached = projections;
66
+ ctx.get = (key) => key === 'sessionProjectionCache' ? { table: {
67
+ get() { return cached; }, async put(id, value) { cached = updated = value; }
68
+ } } : undefined;
66
69
  entityA.record.sessionIds.push('session-aaa');
67
70
  entityB.failAttach = true;
68
71
  const failure = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
69
72
  assert.equal(failure.ok, false);
70
73
  assert.match(failure.error.message, /rolled back cleanly/);
71
74
  assert.deepEqual(readFileSync(src), original);
75
+ assert.equal(cached.identity.cwd, A, 'failed attach restores the title identity');
76
+ assert.deepEqual(cached.rows, projections.rows, 'rollback restores every original projection row');
72
77
  entityB.failAttach = false;
73
78
  const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
74
79
  assert.equal(moved.ok, true, JSON.stringify(moved));
@@ -926,7 +931,7 @@ test('mover.scan 分类 orphaned / unregistered / ok,并检出幽灵记账', a
926
931
  assert.equal(byId['session-unreg'].status, 'unregistered');
927
932
  assert.equal(byId['session-unreg'].targetWorkspaceId, 'wid-a');
928
933
  assert.equal(byId['session-orphan'].status, 'orphaned');
929
- assert.deepEqual(res.value.ghosts, [{ workspaceId: 'wid-b', sessionId: 'session-ghost' }]);
934
+ assert.deepEqual(res.value.ghosts, [{ workspaceId: 'wid-b', sessionId: 'session-ghost', locations: [], status: 'location-unknown', archived: false }]);
930
935
  assert.equal(res.value.scanned, 3);
931
936
  });
932
937
 
@@ -1437,6 +1442,46 @@ test('mover.archived 列出归档会话:归属取自原始记账,cwd 挂错
1437
1442
  assert.equal(bbb.suggestedTitle, 'wid-b');
1438
1443
  });
1439
1444
 
1445
+ test('current per-record and live titles override legacy titles across repair, archive, preview and backups', async () => {
1446
+ apply(ctx);
1447
+ const id = 'session-aaa';
1448
+ writeFileSync(artifactPath(root, A, id), makeArtifact({ type: 'session', id, cwd: A }));
1449
+ const storage = join(root, 'storages');
1450
+ const checkpoint = join(storage, 'session_projcache', 'sessions', `${id}.json`);
1451
+ mkdirSync(dirname(checkpoint), { recursive: true });
1452
+ writeFileSync(join(storage, 'session_projcache.json'), JSON.stringify({ tables: {
1453
+ sessions: { [id]: { rows: { title: { val: 'Outdated title' } } } }
1454
+ } }), 'utf8');
1455
+ writeFileSync(checkpoint, JSON.stringify({ version: 7, record: {
1456
+ identity: { cwd: A }, rows: { title: { val: '标题保留测试' } }
1457
+ } }), 'utf8');
1458
+ entityA.record.sessionIds.push(id);
1459
+ sharedIndex.sessionPaths.set(id, A);
1460
+ ctx.workspaceRegistry.archivedSessionIds = [id];
1461
+ stashBackup(id, readFileSync(artifactPath(root, A, id)));
1462
+ const titles = async () => {
1463
+ const scan = await call('mover.scan');
1464
+ const archived = await call('mover.archived');
1465
+ const preview = await call('mover.preview', { sessionId: id, targetWorkspaceId: 'wid-b' });
1466
+ const backups = await call('mover.backups.list');
1467
+ for (const result of [scan, archived, preview, backups]) assert.equal(result.ok, true, JSON.stringify(result));
1468
+ return [scan.value.items.find((item) => item.sessionId === id).title,
1469
+ archived.value.items.find((item) => item.sessionId === id).title,
1470
+ preview.value.title, backups.value.items.find((item) => item.sessionId === id).title];
1471
+ };
1472
+ assert.deepEqual(await titles(), Array(4).fill('标题保留测试'));
1473
+ ctx.get = (key) => key === 'sessionProjectionCache' ? { table: {
1474
+ get: () => ({ rows: { title: { val: 'Latest live title' } } })
1475
+ } } : undefined;
1476
+ assert.deepEqual(await titles(), Array(4).fill('Latest live title'));
1477
+ ctx.get = () => undefined;
1478
+ writeFileSync(checkpoint, '{broken', 'utf8');
1479
+ assert.deepEqual(await titles(), Array(4).fill('Outdated title'));
1480
+ rmSync(join(storage, 'session_projcache.json'));
1481
+ const scan = await call('mover.scan');
1482
+ assert.equal(scan.value.items.find((item) => item.sessionId === id).title, '未命名会话');
1483
+ });
1484
+
1440
1485
  test('mover.unarchive 取消归档:归档集移除、账本不动、无迁移', async () => {
1441
1486
  apply(ctx);
1442
1487
  entityA.record.sessionIds.push('session-aaa');
@@ -1890,6 +1935,40 @@ test('mover.session.delete:manifest 写失败时原样搬回,状态零变化
1890
1935
  assert.ok(!existsSync(recycle) || readdirSync(recycle).length === 0, 'no invisible recycle entries');
1891
1936
  });
1892
1937
 
1938
+ test('delete failed rollback preserves relocated archive for recovery instead of removing it', async () => {
1939
+ apply(ctx);
1940
+ entityA.record.sessionIds.push('session-aaa');
1941
+ const source = artifactPath(root, A, 'session-aaa');
1942
+ const bytes = readFileSync(source);
1943
+ // A successful relocation followed by a new destination file blocks move-back.
1944
+ await assert.rejects(deleteSessionToTrash(ctx, { sessionId: 'session-aaa' }, {
1945
+ writeManifestImpl() {
1946
+ mkdirSync(dirname(source), { recursive: true });
1947
+ writeFileSync(source, 'concurrent destination');
1948
+ throw new Error('manifest disk failure');
1949
+ }
1950
+ }), /rollback failed; session files preserved/);
1951
+ const recycle = join(root, 'workspace-mover', 'recycle');
1952
+ const entry = readdirSync(recycle)[0];
1953
+ assert.deepEqual(readFileSync(join(recycle, entry, 'session', source.split(/[\\/]/).at(-1))), bytes);
1954
+ assert.deepEqual(entityA.record.sessionIds, ['session-aaa']);
1955
+ });
1956
+
1957
+ test('scan distinguishes misplaced membership from unknown archive location without detaching', async () => {
1958
+ apply(ctx);
1959
+ entityB.record.sessionIds.push('session-aaa', 'session-missing');
1960
+ ctx.workspaceRegistry.archivedSessionIds = ['session-missing'];
1961
+ const res = await call('mover.scan');
1962
+ assert.equal(res.ok, true);
1963
+ const found = res.value.ghosts.find((g) => g.sessionId === 'session-aaa');
1964
+ assert.equal(found.status, 'found-elsewhere');
1965
+ assert.deepEqual(found.locations, [artifactPath(root, A, 'session-aaa')]);
1966
+ const unknown = res.value.ghosts.find((g) => g.sessionId === 'session-missing');
1967
+ assert.equal(unknown.status, 'location-unknown');
1968
+ assert.equal(unknown.archived, true);
1969
+ assert.deepEqual(entityB.record.sessionIds, ['session-aaa', 'session-missing']);
1970
+ });
1971
+
1893
1972
  test('mover.session.delete:归档会话删除时同步移出归档集', async () => {
1894
1973
  apply(ctx);
1895
1974
  sharedIndex.sessionPaths.set('session-aaa', A);
@@ -1917,6 +1996,7 @@ test('mover.trash.restore:还原到原路径并回写记账、投影标题、
1917
1996
  const res = await call('mover.trash.restore', { sessionId: 'session-aaa' });
1918
1997
  assert.equal(res.ok, true, JSON.stringify(res));
1919
1998
  assert.equal(res.value.workspaceId, 'wid-a');
1999
+ assert.equal(res.value.archived, true, 'recycle restore explicitly reports preserved archived visibility');
1920
2000
  assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact back at A');
1921
2001
  assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).cwd, A);
1922
2002
  assert.deepEqual(entityA.record.sessionIds, ['session-aaa'], 're-attached to A');