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/CHANGELOG.md +345 -327
- package/README.md +389 -381
- package/README_EN.md +376 -368
- package/client/client.js +2792 -2678
- package/docs/ARCHITECTURE.md +224 -224
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-13.md +45 -45
- package/docs/compatibility/immediate-migration-2026-10-26.md +68 -68
- package/docs/compatibility/issue-11-pr-12-review-2026-11-02.md +103 -0
- package/docs/compatibility/issue-5-desktop-acceptance-2026-10-22.md +106 -106
- package/docs/compatibility/pr-review-2026-10-22.md +56 -56
- package/lib/index.js +36 -5
- package/lib/lifecycle.js +2 -2
- package/package.json +104 -104
- package/test/client-dom.test.mjs +98 -0
- package/test/core.test.mjs +73 -73
- package/test/e2e-sandbox.test.mjs +36 -1
- package/test/live-migration.test.mjs +72 -2
- package/test/release-downloads.test.mjs +102 -102
- package/test/traffic-badge.test.mjs +80 -80
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)))
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/test/client-dom.test.mjs
CHANGED
|
@@ -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();
|
package/test/core.test.mjs
CHANGED
|
@@ -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');
|