dsh-workspace-mover 2.4.2 → 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.
package/lib/index.js CHANGED
@@ -1550,6 +1550,13 @@ async function scanSessions(ctx) {
1550
1550
  }
1551
1551
 
1552
1552
  const ghosts = [];
1553
+ // Include all discovered archives, even those outside the parsed scan limit.
1554
+ const archiveLocations = new Map();
1555
+ for (const { idDir, artifact } of metas) {
1556
+ const paths = archiveLocations.get(String(idDir)) ?? [];
1557
+ paths.push(artifact);
1558
+ archiveLocations.set(String(idDir), paths);
1559
+ }
1553
1560
  for (const entity of entities) {
1554
1561
  let ids = [];
1555
1562
  // 幽灵正是「有账无档」的 id,而官方公开的 sessionIds getter 会把它们
@@ -1557,7 +1564,11 @@ async function scanSessions(ctx) {
1557
1564
  try { ids = rawRecordSessionIds(entity); } catch { continue; }
1558
1565
  for (const id of ids) {
1559
1566
  try {
1560
- if (!existsSync(artifactPath(root, entity.path, id, compression))) ghosts.push({ workspaceId: entity.id, sessionId: String(id) });
1567
+ if (!existsSync(artifactPath(root, entity.path, id, compression))) {
1568
+ const locations = archiveLocations.get(String(id)) ?? [];
1569
+ ghosts.push({ workspaceId: entity.id, sessionId: String(id), locations,
1570
+ status: locations.length ? 'found-elsewhere' : 'location-unknown', archived: archived.has(String(id)) });
1571
+ }
1561
1572
  } catch { /* skip */ }
1562
1573
  }
1563
1574
  }
@@ -3337,7 +3348,26 @@ async function deleteSessionToTrash(ctx, payload = {}, hooks = {}) {
3337
3348
  const sessionId = validId(payload?.sessionId, 'sessionId');
3338
3349
  const release = acquireLocks(`session:${sessionId}`);
3339
3350
  try {
3340
- return await deleteSessionToTrashLocked(ctx, sessionId, { ...hooks, purgeBackups: payload?.purgeBackups === true });
3351
+ assertNotRunning(ctx, sessionId);
3352
+ if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined && !lifecycleFor(ctx, sessionId)) {
3353
+ throw new Error('session is resident in memory without a tracked lifecycle handle; restart the harness before deleting');
3354
+ }
3355
+ // Disposing the Agent may remove its projection; preserve display metadata first.
3356
+ const projection = captureProjection(ctx, sessionId);
3357
+ const title = readSessionTitle(ctx, sessionId, null);
3358
+ return await withReleasedSession(ctx, sessionId, false,
3359
+ () => deleteSessionToTrashLocked(ctx, sessionId, { ...hooks, projection, title, purgeBackups: payload?.purgeBackups === true }), {
3360
+ reloadOnSuccess: false,
3361
+ // A failed rollback must never resume a missing archive as a blank session.
3362
+ canReload: async () => {
3363
+ try {
3364
+ const location = await locateArtifact(ctx, sessionId);
3365
+ const storage = createPersistenceAdapter(ctx);
3366
+ const header = readHeader(await storage.read(location), storage.compression);
3367
+ return String(header?.id) === String(sessionId);
3368
+ } catch { return false; }
3369
+ }
3370
+ });
3341
3371
  } finally {
3342
3372
  release();
3343
3373
  }
@@ -3357,7 +3387,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
3357
3387
  const { artifact, cwd: artifactCwd } = location;
3358
3388
  const header = readHeader(await storage.read(location), storage.compression);
3359
3389
  const archived = readArchivedIds(registry).includes(sessionId);
3360
- const projection = captureProjection(ctx, sessionId);
3390
+ const projection = hooks.projection ?? captureProjection(ctx, sessionId);
3361
3391
  const owners = listOwners(registry, sessionId);
3362
3392
  const entryDir = join(recycleDir(), `${Date.now()}-${String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_')}`);
3363
3393
  mkdirSync(entryDir, { recursive: true });
@@ -3372,7 +3402,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
3372
3402
  const manifest = {
3373
3403
  version: 1,
3374
3404
  sessionId: String(sessionId),
3375
- title: typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : null,
3405
+ title: hooks.title ?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : null),
3376
3406
  cwd: artifactCwd ?? header?.cwd ?? null,
3377
3407
  ownerWorkspaceId: owners[0]?.id ?? null,
3378
3408
  ownerTitle: owners[0]?.title ?? null,
@@ -3393,6 +3423,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
3393
3423
  lastError: `manifest write failed (${err?.message ?? err}) AND move-back failed (${backErr?.message ?? backErr}) — files remain under ${entryDir}`
3394
3424
  });
3395
3425
  ctx.logger?.warn?.(`workspace-mover: manifest write failed AND move-back failed for ${sessionId}: ${backErr?.message ?? backErr} — files remain under ${entryDir}`);
3426
+ throw new Error(`manifest write failed AND rollback failed; session files preserved under ${entryDir}: ${backErr?.message ?? backErr}`);
3396
3427
  }
3397
3428
  try { rmSync(entryDir, { recursive: true, force: true }); } catch { /* ignore */ }
3398
3429
  throw new Error(`manifest write failed, session moved back: ${err?.message ?? err}`);
@@ -3506,7 +3537,7 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
3506
3537
  }
3507
3538
  // 先移回原位(头 cwd 与原位一致,attach 校验天然通过)
3508
3539
  await storage.relocate(trashLocation, dirname(originalLocation.artifact));
3509
- let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, moved: null };
3540
+ let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, archived: Boolean(manifest.archived), moved: null };
3510
3541
  try {
3511
3542
  if (targetEntity && !samePath(originalCwd, destPath)) {
3512
3543
  // 原位只是中转:直接走现有 moveSession 全管线(备份/改写/热修/记账全套)
package/lib/lifecycle.js CHANGED
@@ -65,7 +65,7 @@ export function lifecycleFor(ctx, id) {
65
65
  return entry && raw(agents.get(id)) === entry.agent ? { ...entry, runtime, agents } : null;
66
66
  }
67
67
 
68
- export async function withReleasedSession(ctx, id, stopRunning, operation) {
68
+ export async function withReleasedSession(ctx, id, stopRunning, operation, { reloadOnSuccess = true, canReload = () => true } = {}) {
69
69
  const owned = lifecycleFor(ctx, id);
70
70
  if (!owned) return operation();
71
71
  const { handle, agent, runtime } = owned;
@@ -89,7 +89,7 @@ export async function withReleasedSession(ctx, id, stopRunning, operation) {
89
89
  runtime.handles.delete(id);
90
90
  try {
91
91
  const controller = ctx.get?.('sessionController');
92
- if (typeof controller?.resolveAgent === 'function') {
92
+ if ((failure || reloadOnSuccess) && await canReload() && typeof controller?.resolveAgent === 'function') {
93
93
  const restored = await migrationContext.run(runtime, () => controller.resolveAgent(id));
94
94
  if (restored?.error) throw restored.error;
95
95
  }
package/package.json CHANGED
@@ -1,104 +1,104 @@
1
- {
2
- "name": "dsh-workspace-mover",
3
- "version": "2.4.2",
4
- "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./client": "./client/client.js",
10
- "./package.json": "./package.json"
11
- },
12
- "files": [
13
- "lib",
14
- "client",
15
- "test",
16
- "cordis.patch.yml",
17
- "README.md",
18
- "README_EN.md",
19
- "CHANGELOG.md",
20
- "docs/ARCHITECTURE.md",
21
- "docs/compatibility",
22
- "docs/media",
23
- "LICENSE"
24
- ],
25
- "scripts": {
26
- "test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs test/live-migration.test.mjs",
27
- "test:installed-host": "node --test test/installed-host.test.mjs test/live-migration.test.mjs",
28
- "check:syntax": "node --check lib/index.js && node --check client/client.js && node --check lib/lifecycle.js",
29
- "check:pack": "npm pack --dry-run",
30
- "check": "npm run check:syntax && npm test && npm run check:pack"
31
- },
32
- "keywords": [
33
- "deepseek-harness",
34
- "dsh",
35
- "dsh-plugin",
36
- "workspace",
37
- "session",
38
- "session-migration",
39
- "drag-and-drop",
40
- "agent-tools"
41
- ],
42
- "engines": {
43
- "node": ">=22",
44
- "dsh": ">=0.1.5-rc.1"
45
- },
46
- "//peerDependencies": "Deliberately `*`. These are OPTIONAL peers: the plugin declares them so storefronts can see the host packages it integrates with, but it fail-softs at runtime (mover.status capabilities + mover_doctor). npm's prerelease rule evaluates each `||` alternative as a set and only admits a prerelease when a comparator in that set shares the version's exact major.minor.patch tuple AND carries a prerelease tag -- so an upper bound like `<0.2.0-0` excludes EVERY 0.2.0 prerelease, which made the host report 2.2.0 as incompatible with 0.2.0-rc.2. Enumerating release lines would keep producing false incompatibilities on each new line; the real floor is engines.dsh, and capability flags report any actual runtime mismatch. Do NOT narrow these to version ranges.",
47
- "peerDependencies": {
48
- "@deepseek-ai/cordis": "*",
49
- "@deepseek-ai/dsh-session": "*",
50
- "@deepseek-ai/dsh-tools": "*",
51
- "@deepseek-ai/dsh-workspace": "*",
52
- "@deepseek-ai/schemastery": "*",
53
- "@deepseek-ai/dsh-client-connection": "*",
54
- "@deepseek-ai/dsh-client-locale": "*",
55
- "@deepseek-ai/dsh-client-ui-slots": "*"
56
- },
57
- "peerDependenciesMeta": {
58
- "@deepseek-ai/cordis": {
59
- "optional": true
60
- },
61
- "@deepseek-ai/dsh-session": {
62
- "optional": true
63
- },
64
- "@deepseek-ai/dsh-tools": {
65
- "optional": true
66
- },
67
- "@deepseek-ai/dsh-workspace": {
68
- "optional": true
69
- },
70
- "@deepseek-ai/schemastery": {
71
- "optional": true
72
- },
73
- "@deepseek-ai/dsh-client-connection": {
74
- "optional": true
75
- },
76
- "@deepseek-ai/dsh-client-locale": {
77
- "optional": true
78
- },
79
- "@deepseek-ai/dsh-client-ui-slots": {
80
- "optional": true
81
- }
82
- },
83
- "license": "MIT",
84
- "repository": {
85
- "type": "git",
86
- "url": "git+https://github.com/PianoPrince/dsh-workspace-mover.git"
87
- },
88
- "bugs": {
89
- "url": "https://github.com/PianoPrince/dsh-workspace-mover/issues"
90
- },
91
- "homepage": "https://github.com/PianoPrince/dsh-workspace-mover#readme",
92
- "dsh": {
93
- "bundle": {
94
- "patch": "./cordis.patch.yml"
95
- },
96
- "client": {
97
- "inject": [
98
- "@deepseek-ai/dsh-client-connection",
99
- "@deepseek-ai/dsh-client-ui-slots"
100
- ],
101
- "platform": "web"
102
- }
103
- }
104
- }
1
+ {
2
+ "name": "dsh-workspace-mover",
3
+ "version": "2.4.3",
4
+ "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./client/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "client",
15
+ "test",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "README_EN.md",
19
+ "CHANGELOG.md",
20
+ "docs/ARCHITECTURE.md",
21
+ "docs/compatibility",
22
+ "docs/media",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs test/live-migration.test.mjs",
27
+ "test:installed-host": "node --test test/installed-host.test.mjs test/live-migration.test.mjs",
28
+ "check:syntax": "node --check lib/index.js && node --check client/client.js && node --check lib/lifecycle.js",
29
+ "check:pack": "npm pack --dry-run",
30
+ "check": "npm run check:syntax && npm test && npm run check:pack"
31
+ },
32
+ "keywords": [
33
+ "deepseek-harness",
34
+ "dsh",
35
+ "dsh-plugin",
36
+ "workspace",
37
+ "session",
38
+ "session-migration",
39
+ "drag-and-drop",
40
+ "agent-tools"
41
+ ],
42
+ "engines": {
43
+ "node": ">=22",
44
+ "dsh": ">=0.1.5-rc.1"
45
+ },
46
+ "//peerDependencies": "Deliberately `*`. These are OPTIONAL peers: the plugin declares them so storefronts can see the host packages it integrates with, but it fail-softs at runtime (mover.status capabilities + mover_doctor). npm's prerelease rule evaluates each `||` alternative as a set and only admits a prerelease when a comparator in that set shares the version's exact major.minor.patch tuple AND carries a prerelease tag -- so an upper bound like `<0.2.0-0` excludes EVERY 0.2.0 prerelease, which made the host report 2.2.0 as incompatible with 0.2.0-rc.2. Enumerating release lines would keep producing false incompatibilities on each new line; the real floor is engines.dsh, and capability flags report any actual runtime mismatch. Do NOT narrow these to version ranges.",
47
+ "peerDependencies": {
48
+ "@deepseek-ai/cordis": "*",
49
+ "@deepseek-ai/dsh-session": "*",
50
+ "@deepseek-ai/dsh-tools": "*",
51
+ "@deepseek-ai/dsh-workspace": "*",
52
+ "@deepseek-ai/schemastery": "*",
53
+ "@deepseek-ai/dsh-client-connection": "*",
54
+ "@deepseek-ai/dsh-client-locale": "*",
55
+ "@deepseek-ai/dsh-client-ui-slots": "*"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "@deepseek-ai/cordis": {
59
+ "optional": true
60
+ },
61
+ "@deepseek-ai/dsh-session": {
62
+ "optional": true
63
+ },
64
+ "@deepseek-ai/dsh-tools": {
65
+ "optional": true
66
+ },
67
+ "@deepseek-ai/dsh-workspace": {
68
+ "optional": true
69
+ },
70
+ "@deepseek-ai/schemastery": {
71
+ "optional": true
72
+ },
73
+ "@deepseek-ai/dsh-client-connection": {
74
+ "optional": true
75
+ },
76
+ "@deepseek-ai/dsh-client-locale": {
77
+ "optional": true
78
+ },
79
+ "@deepseek-ai/dsh-client-ui-slots": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "license": "MIT",
84
+ "repository": {
85
+ "type": "git",
86
+ "url": "git+https://github.com/PianoPrince/dsh-workspace-mover.git"
87
+ },
88
+ "bugs": {
89
+ "url": "https://github.com/PianoPrince/dsh-workspace-mover/issues"
90
+ },
91
+ "homepage": "https://github.com/PianoPrince/dsh-workspace-mover#readme",
92
+ "dsh": {
93
+ "bundle": {
94
+ "patch": "./cordis.patch.yml"
95
+ },
96
+ "client": {
97
+ "inject": [
98
+ "@deepseek-ai/dsh-client-connection",
99
+ "@deepseek-ai/dsh-client-ui-slots"
100
+ ],
101
+ "platform": "web"
102
+ }
103
+ }
104
+ }
@@ -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();
@@ -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', () => {
@@ -931,7 +931,7 @@ test('mover.scan 分类 orphaned / unregistered / ok,并检出幽灵记账', a
931
931
  assert.equal(byId['session-unreg'].status, 'unregistered');
932
932
  assert.equal(byId['session-unreg'].targetWorkspaceId, 'wid-a');
933
933
  assert.equal(byId['session-orphan'].status, 'orphaned');
934
- 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 }]);
935
935
  assert.equal(res.value.scanned, 3);
936
936
  });
937
937
 
@@ -1935,6 +1935,40 @@ test('mover.session.delete:manifest 写失败时原样搬回,状态零变化
1935
1935
  assert.ok(!existsSync(recycle) || readdirSync(recycle).length === 0, 'no invisible recycle entries');
1936
1936
  });
1937
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
+
1938
1972
  test('mover.session.delete:归档会话删除时同步移出归档集', async () => {
1939
1973
  apply(ctx);
1940
1974
  sharedIndex.sessionPaths.set('session-aaa', A);
@@ -1962,6 +1996,7 @@ test('mover.trash.restore:还原到原路径并回写记账、投影标题、
1962
1996
  const res = await call('mover.trash.restore', { sessionId: 'session-aaa' });
1963
1997
  assert.equal(res.ok, true, JSON.stringify(res));
1964
1998
  assert.equal(res.value.workspaceId, 'wid-a');
1999
+ assert.equal(res.value.archived, true, 'recycle restore explicitly reports preserved archived visibility');
1965
2000
  assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact back at A');
1966
2001
  assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).cwd, A);
1967
2002
  assert.deepEqual(entityA.record.sessionIds, ['session-aaa'], 're-attached to A');