dsh-workspace-mover 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +285 -0
- package/README.md +357 -304
- package/README_EN.md +346 -0
- package/client/client.js +632 -100
- package/docs/ARCHITECTURE.md +81 -22
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-04.md +39 -0
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-13.md +45 -0
- package/lib/config.js +74 -0
- package/lib/index.js +1756 -172
- package/package.json +102 -63
- package/test/client-dom.test.mjs +796 -0
- package/test/e2e-sandbox.test.mjs +705 -60
- package/test/policy.test.mjs +415 -0
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
// 备份目录通过 DSH_HOME 重定向到临时区,不污染真实环境。
|
|
4
4
|
import { test, beforeEach, afterEach } from 'node:test';
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
6
8
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync, renameSync, realpathSync, utimesSync } from 'node:fs';
|
|
7
9
|
import { tmpdir } from 'node:os';
|
|
8
10
|
import { join, dirname } from 'node:path';
|
|
9
11
|
import { zstdCompressSync, constants } from 'node:zlib';
|
|
10
12
|
|
|
11
|
-
import { apply, scanFrames, readHeader, artifactPath, openInFileManager, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, SCAN_MAX_ITEMS } from '../lib/index.js';
|
|
13
|
+
import { apply, scanFrames, readHeader, artifactPath, sessionDir, createPersistenceAdapter, openInFileManager, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, SCAN_MAX_ITEMS } from '../lib/index.js';
|
|
12
14
|
|
|
13
15
|
const OPTS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
|
|
14
16
|
const DEAD = 'E:\\wsm-dead-path'; // 刻意不存在的路径(孤儿分类用)
|
|
15
17
|
let A; // 每次用例指向真实临时目录(健康会话要求 cwd 在磁盘上存在)
|
|
16
18
|
let B;
|
|
17
19
|
let root;
|
|
18
|
-
let ctx, entityA, entityB, sharedIndex, workspaceEvents;
|
|
20
|
+
let ctx, entityA, entityB, sharedIndex, workspaceEvents, cleanups;
|
|
19
21
|
|
|
20
22
|
/** 测试根目录:优先 WSM_TEST_ROOT(受限环境下 Temp 可能禁止目录重命名),否则退回系统 Temp。 */
|
|
21
23
|
function makeRoot() {
|
|
@@ -55,6 +57,11 @@ function makeEntity(id, path, hostRef) {
|
|
|
55
57
|
detached: [],
|
|
56
58
|
failAttach: false,
|
|
57
59
|
async status() { return existsSync(record.path) ? 'ok' : 'missing-dir'; },
|
|
60
|
+
// 官方文档化的改名接口:只换标题,不碰 path / 成员名单。
|
|
61
|
+
async setTitle(next) {
|
|
62
|
+
record.title = String(next);
|
|
63
|
+
record.updatedAt = new Date().toISOString();
|
|
64
|
+
},
|
|
58
65
|
async mutate(fn) {
|
|
59
66
|
// 官方语义:整体换新快照(本插件借此同步改写 path/title),随后按索引剪枝成员
|
|
60
67
|
const changed = fn(record);
|
|
@@ -70,7 +77,8 @@ function makeEntity(id, path, hostRef) {
|
|
|
70
77
|
if (record.sessionIds.includes(sid)) return;
|
|
71
78
|
// 官方语义:未记账 id 需通过「头部 cwd 规范化后 === path」校验,
|
|
72
79
|
// 并经 rememberSessionPath 回填三张索引后才挂账
|
|
73
|
-
const
|
|
80
|
+
const artifact = findArtifact(sid);
|
|
81
|
+
const header = readHeader(readFileSync(artifact), artifact.endsWith('.zstd') ? 'zstd' : 'none');
|
|
74
82
|
let canonical;
|
|
75
83
|
try { canonical = realpathSync(header.cwd); } catch {
|
|
76
84
|
throw new Error(`cwd does not resolve: '${header.cwd}'`);
|
|
@@ -99,10 +107,10 @@ function findArtifact(sessionId) {
|
|
|
99
107
|
const dir = join(root, proj, sessionId);
|
|
100
108
|
if (!existsSync(dir)) continue;
|
|
101
109
|
const names = readdirSync(dir)
|
|
102
|
-
.filter((name) =>
|
|
110
|
+
.filter((name) => /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/.test(name))
|
|
103
111
|
.sort((a, b) => {
|
|
104
|
-
const va =
|
|
105
|
-
const vb =
|
|
112
|
+
const va = Number(/^session\.v([1-9][0-9]*)\.jsonl/.exec(a)?.[1] ?? 0);
|
|
113
|
+
const vb = Number(/^session\.v([1-9][0-9]*)\.jsonl/.exec(b)?.[1] ?? 0);
|
|
106
114
|
return vb - va;
|
|
107
115
|
});
|
|
108
116
|
if (names.length) return join(dir, names[0]);
|
|
@@ -115,6 +123,7 @@ beforeEach(() => {
|
|
|
115
123
|
// 的唯一性约定);macOS 的 Temp 位于 /var → /private/var 符号链接下,
|
|
116
124
|
// 夹具若用未规范化路径会与 canonical 比对失配,因此创建后立即归一。
|
|
117
125
|
root = realpathSync(makeRoot());
|
|
126
|
+
cleanups = [];
|
|
118
127
|
A = join(root, 'proj-alpha');
|
|
119
128
|
B = join(root, 'proj-beta');
|
|
120
129
|
mkdirSync(A, { recursive: true });
|
|
@@ -150,6 +159,7 @@ beforeEach(() => {
|
|
|
150
159
|
};
|
|
151
160
|
const persistence = {
|
|
152
161
|
root,
|
|
162
|
+
compression: 'zstd',
|
|
153
163
|
async list() {
|
|
154
164
|
// 动态扫描 root(与真实后端一致):遍历 projectKey 目录读头部
|
|
155
165
|
const out = [];
|
|
@@ -159,15 +169,15 @@ beforeEach(() => {
|
|
|
159
169
|
const projPath = join(root, proj);
|
|
160
170
|
for (const idDir of readdirSync(projPath)) {
|
|
161
171
|
const dir = join(projPath, idDir);
|
|
162
|
-
const names = existsSync(dir) ? readdirSync(dir).filter((name) =>
|
|
172
|
+
const names = existsSync(dir) ? readdirSync(dir).filter((name) => /^session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/.test(name)) : [];
|
|
163
173
|
if (!names.length) continue;
|
|
164
174
|
names.sort((a, b) => {
|
|
165
|
-
const va =
|
|
166
|
-
const vb =
|
|
175
|
+
const va = Number(/^session\.v([1-9][0-9]*)\.jsonl/.exec(a)?.[1] ?? 0);
|
|
176
|
+
const vb = Number(/^session\.v([1-9][0-9]*)\.jsonl/.exec(b)?.[1] ?? 0);
|
|
167
177
|
return vb - va;
|
|
168
178
|
});
|
|
169
179
|
const f = join(dir, names[0]);
|
|
170
|
-
try { out.push(readHeader(readFileSync(f))); } catch { /* skip */ }
|
|
180
|
+
try { out.push(readHeader(readFileSync(f), f.endsWith('.zstd') ? 'zstd' : 'none')); } catch { /* skip */ }
|
|
171
181
|
}
|
|
172
182
|
}
|
|
173
183
|
return out;
|
|
@@ -179,12 +189,17 @@ beforeEach(() => {
|
|
|
179
189
|
sessionPersistence: persistence,
|
|
180
190
|
logger: { info() {}, warn() {} },
|
|
181
191
|
get: () => undefined,
|
|
192
|
+
effect(register) {
|
|
193
|
+
const cleanup = register();
|
|
194
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup);
|
|
195
|
+
},
|
|
182
196
|
connection: { rpc: { handle(_ch, h) { rpcHandler = h; return () => {}; } } }
|
|
183
197
|
};
|
|
184
198
|
ctx.__rpc = () => rpcHandler;
|
|
185
199
|
});
|
|
186
200
|
|
|
187
201
|
afterEach(() => {
|
|
202
|
+
for (const cleanup of cleanups) cleanup();
|
|
188
203
|
delete process.env.DSH_HOME;
|
|
189
204
|
rmSync(root, { recursive: true, force: true });
|
|
190
205
|
});
|
|
@@ -198,6 +213,349 @@ test('mover.status 就绪', async () => {
|
|
|
198
213
|
const res = await call('mover.status');
|
|
199
214
|
assert.equal(res.ok, true);
|
|
200
215
|
assert.equal(res.value.ready, true);
|
|
216
|
+
assert.equal(res.value.capabilities.backupSnapshots, true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('mover.preview:安全项报告目标与备份大小,目标冲突明确阻断', async () => {
|
|
220
|
+
apply(ctx);
|
|
221
|
+
const safe = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
222
|
+
assert.equal(safe.ok, true, JSON.stringify(safe));
|
|
223
|
+
assert.equal(safe.value.status, 'safe');
|
|
224
|
+
assert.equal(safe.value.target.path, B);
|
|
225
|
+
assert.ok(safe.value.estimatedBackupBytes > 0);
|
|
226
|
+
assert.equal(safe.value.adapter.mode, 'filesystem-fallback');
|
|
227
|
+
|
|
228
|
+
const collision = artifactPath(root, B, 'session-aaa');
|
|
229
|
+
mkdirSync(dirname(collision), { recursive: true });
|
|
230
|
+
writeFileSync(collision, makeArtifact({ type: 'session', id: 'session-aaa', cwd: B, title: 'Alpha discussion' }));
|
|
231
|
+
const blocked = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
232
|
+
assert.equal(blocked.ok, true, JSON.stringify(blocked));
|
|
233
|
+
assert.equal(blocked.value.status, 'blocked');
|
|
234
|
+
assert.match(blocked.value.warnings.join(' '), /already contains/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('往返迁移预览从实际位置读取,只有当前工作区被标为无需移动', async () => {
|
|
238
|
+
apply(ctx);
|
|
239
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
240
|
+
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
241
|
+
assert.equal(moved.ok, true, JSON.stringify(moved));
|
|
242
|
+
const same = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
243
|
+
assert.equal(same.value.status, 'blocked');
|
|
244
|
+
assert.equal(same.value.target.collision, false);
|
|
245
|
+
assert.equal(same.value.estimatedBackupBytes, 0);
|
|
246
|
+
assert.ok(same.value.warnings.includes('session already belongs to the target workspace'));
|
|
247
|
+
const back = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
|
|
248
|
+
assert.equal(back.value.source.cwd, B);
|
|
249
|
+
assert.equal(back.value.target.path, A);
|
|
250
|
+
assert.equal(back.value.status, 'safe');
|
|
251
|
+
const returned = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
|
|
252
|
+
assert.equal(returned.ok, true, JSON.stringify(returned));
|
|
253
|
+
assert.equal(readHeader(readFileSync(findArtifact('session-aaa'))).cwd, A);
|
|
254
|
+
assert.equal(existsSync(sessionDir(root, B, 'session-aaa')), false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('预览允许修复同工作区未登记会话,且不报告副本冲突', async () => {
|
|
258
|
+
apply(ctx);
|
|
259
|
+
const plan = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
|
|
260
|
+
assert.equal(plan.value.status, 'safe');
|
|
261
|
+
assert.equal(plan.value.target.collision, false);
|
|
262
|
+
assert.equal(plan.value.estimatedBackupBytes, 0);
|
|
263
|
+
const repaired = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
|
|
264
|
+
assert.equal(repaired.ok, true, JSON.stringify(repaired));
|
|
265
|
+
assert.equal(repaired.value.attached, true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('救援扫描将混合压缩编码标为 unsupported,继续返回其余诊断', async () => {
|
|
269
|
+
apply(ctx);
|
|
270
|
+
const plain = join(sessionDir(root, A, 'session-aaa'), 'session.v3.jsonl');
|
|
271
|
+
writeFileSync(plain, Buffer.from([
|
|
272
|
+
JSON.stringify({ type: 'session', id: 'session-aaa', cwd: A }),
|
|
273
|
+
JSON.stringify({ type: 'message', id: 'event-mixed' })
|
|
274
|
+
].join('\n') + '\n', 'utf8'));
|
|
275
|
+
|
|
276
|
+
const scan = await call('mover.scan');
|
|
277
|
+
assert.equal(scan.ok, true, JSON.stringify(scan));
|
|
278
|
+
const item = scan.value.items.find((entry) => entry.sessionId === 'session-aaa');
|
|
279
|
+
assert.equal(item.status, 'unreadable');
|
|
280
|
+
assert.equal(item.diagnostic, 'unsupported');
|
|
281
|
+
assert.match(item.diagnosticDetail, /mixed session compression encodings/);
|
|
282
|
+
const status = await call('mover.status');
|
|
283
|
+
assert.equal(status.value.storageLayout.diagnostic, 'unsupported');
|
|
284
|
+
const doctor = await call('mover.doctor');
|
|
285
|
+
assert.match(doctor.value.checks.find((entry) => entry.id === 'data-layout').detail, /unsupported/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('救援扫描明确区分损坏档案', async () => {
|
|
289
|
+
apply(ctx);
|
|
290
|
+
writeFileSync(artifactPath(root, A, 'session-aaa'), Buffer.from('damaged archive'));
|
|
291
|
+
|
|
292
|
+
const scan = await call('mover.scan');
|
|
293
|
+
assert.equal(scan.ok, true, JSON.stringify(scan));
|
|
294
|
+
const item = scan.value.items.find((entry) => entry.sessionId === 'session-aaa');
|
|
295
|
+
assert.equal(item.status, 'unreadable');
|
|
296
|
+
assert.equal(item.diagnostic, 'corrupt-data');
|
|
297
|
+
assert.ok(item.diagnosticDetail);
|
|
298
|
+
const status = await call('mover.status');
|
|
299
|
+
assert.equal(status.value.storageLayout.diagnostic, 'corrupt-data');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('v4 Zstd 档案迁移与撤回时保留代际和事件字节', async () => {
|
|
303
|
+
apply(ctx);
|
|
304
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
305
|
+
const sourceV3 = artifactPath(root, A, 'session-aaa');
|
|
306
|
+
const sourceV4 = join(dirname(sourceV3), 'session.v4.jsonl.zstd');
|
|
307
|
+
renameSync(sourceV3, sourceV4);
|
|
308
|
+
|
|
309
|
+
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
310
|
+
assert.equal(moved.ok, true, JSON.stringify(moved));
|
|
311
|
+
const targetV4 = join(sessionDir(root, B, 'session-aaa'), 'session.v4.jsonl.zstd');
|
|
312
|
+
assert.ok(existsSync(targetV4));
|
|
313
|
+
assert.equal(readHeader(readFileSync(targetV4)).cwd, B);
|
|
314
|
+
|
|
315
|
+
const undone = await call('mover.undo', { historyId: moved.value.historyId });
|
|
316
|
+
assert.equal(undone.ok, true, JSON.stringify(undone));
|
|
317
|
+
assert.ok(existsSync(sourceV4));
|
|
318
|
+
assert.equal(readHeader(readFileSync(sourceV4)).cwd, A);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('v3/v4 同目录明确诊断最高代际,并保留旧代际文件', async () => {
|
|
322
|
+
apply(ctx);
|
|
323
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
324
|
+
const sourceV3 = artifactPath(root, A, 'session-aaa');
|
|
325
|
+
const sourceV4 = join(dirname(sourceV3), 'session.v4.jsonl.zstd');
|
|
326
|
+
writeFileSync(sourceV4, readFileSync(sourceV3));
|
|
327
|
+
|
|
328
|
+
const status = await call('mover.status');
|
|
329
|
+
assert.match(status.value.storageLayout.generationWarning, /v4 is selected/);
|
|
330
|
+
const doctor = await call('mover.doctor');
|
|
331
|
+
assert.equal(doctor.value.checks.find((item) => item.id === 'data-layout')?.state, 'warn');
|
|
332
|
+
|
|
333
|
+
const preview = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
334
|
+
assert.deepEqual(preview.value.source.generations.map((item) => item.version), [4, 3]);
|
|
335
|
+
assert.match(preview.value.warnings.join(' '), /v4 is selected and older files are preserved/);
|
|
336
|
+
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
337
|
+
assert.equal(moved.ok, true, JSON.stringify(moved));
|
|
338
|
+
assert.ok(existsSync(join(sessionDir(root, B, 'session-aaa'), 'session.v3.jsonl.zstd')));
|
|
339
|
+
assert.equal(readHeader(readFileSync(join(sessionDir(root, B, 'session-aaa'), 'session.v4.jsonl.zstd'))).cwd, B);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('legacy v0 与当前 v3 plain JSONL 均可迁移并撤回,不强制升级代际', async () => {
|
|
343
|
+
apply(ctx);
|
|
344
|
+
ctx.sessionPersistence.compression = 'none';
|
|
345
|
+
const cases = [
|
|
346
|
+
{ sessionId: 'session-aaa', filename: 'session.jsonl' },
|
|
347
|
+
{ sessionId: 'session-plain-v3', filename: 'session.v3.jsonl' }
|
|
348
|
+
];
|
|
349
|
+
for (const item of cases) {
|
|
350
|
+
entityA.record.sessionIds.push(item.sessionId);
|
|
351
|
+
const sourceDir = sessionDir(root, A, item.sessionId);
|
|
352
|
+
mkdirSync(sourceDir, { recursive: true });
|
|
353
|
+
if (item.sessionId === 'session-aaa') rmSync(artifactPath(root, A, item.sessionId), { force: true });
|
|
354
|
+
const source = join(sourceDir, item.filename);
|
|
355
|
+
writeFileSync(source, Buffer.from([
|
|
356
|
+
JSON.stringify({ type: 'session', id: item.sessionId, cwd: A }),
|
|
357
|
+
JSON.stringify({ type: 'message', id: `event-${item.sessionId}` })
|
|
358
|
+
].join('\n') + '\n', 'utf8'));
|
|
359
|
+
|
|
360
|
+
const moved = await call('mover.move', { sessionId: item.sessionId, targetWorkspaceId: 'wid-b' });
|
|
361
|
+
assert.equal(moved.ok, true, JSON.stringify(moved));
|
|
362
|
+
const target = join(sessionDir(root, B, item.sessionId), item.filename);
|
|
363
|
+
assert.ok(existsSync(target));
|
|
364
|
+
assert.equal(readHeader(readFileSync(target), 'none').cwd, B);
|
|
365
|
+
assert.equal(readFileSync(target, 'utf8').split('\n')[1], JSON.stringify({ type: 'message', id: `event-${item.sessionId}` }));
|
|
366
|
+
|
|
367
|
+
const undone = await call('mover.undo', { historyId: moved.value.historyId });
|
|
368
|
+
assert.equal(undone.ok, true, JSON.stringify(undone));
|
|
369
|
+
assert.equal(readHeader(readFileSync(source), 'none').cwd, A);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('文件名代际与显式 header 代际不符时,在迁移前拒绝且字节不变', async () => {
|
|
374
|
+
apply(ctx);
|
|
375
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
376
|
+
const source = artifactPath(root, A, 'session-aaa');
|
|
377
|
+
const bytes = makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, formatVersion: 4 });
|
|
378
|
+
writeFileSync(source, bytes);
|
|
379
|
+
|
|
380
|
+
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
381
|
+
assert.equal(moved.ok, false);
|
|
382
|
+
assert.equal(moved.error.code, 'unsupported');
|
|
383
|
+
assert.deepEqual(readFileSync(source), bytes);
|
|
384
|
+
assert.ok(!existsSync(artifactPath(root, B, 'session-aaa')));
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('busy 迁移进入持久化队列,空闲后可重试完成', async () => {
|
|
388
|
+
apply(ctx);
|
|
389
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
390
|
+
ctx.get = (key) => (key === 'agents' ? { get: () => ({ status: 'running' }) } : undefined);
|
|
391
|
+
const queued = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
392
|
+
assert.equal(queued.ok, false);
|
|
393
|
+
assert.equal(queued.error.code, 'busy');
|
|
394
|
+
assert.ok(queued.error.details.queueTaskId);
|
|
395
|
+
|
|
396
|
+
const waiting = await call('mover.migrations.list');
|
|
397
|
+
const waitingItem = waiting.value.items.find((item) => item.id === queued.error.details.queueTaskId);
|
|
398
|
+
assert.equal(waitingItem.state, 'waiting-for-idle');
|
|
399
|
+
|
|
400
|
+
ctx.get = () => undefined;
|
|
401
|
+
const retried = await call('mover.migrations.retry', { taskId: waitingItem.id });
|
|
402
|
+
assert.equal(retried.ok, true, JSON.stringify(retried));
|
|
403
|
+
assert.equal(retried.value.item.state, 'done');
|
|
404
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
|
|
405
|
+
const repeated = await call('mover.migrations.retry', { taskId: waitingItem.id });
|
|
406
|
+
assert.equal(repeated.value.item.state, 'done');
|
|
407
|
+
assert.equal(repeated.value.item.attempts, retried.value.item.attempts, 'completed retries are idempotent');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('busy 队列在插件重新挂载后从持久化记录继续执行', async () => {
|
|
411
|
+
apply(ctx);
|
|
412
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
413
|
+
ctx.get = (key) => (key === 'agents' ? { get: () => ({ status: 'running' }) } : undefined);
|
|
414
|
+
const queued = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
415
|
+
const queueFile = join(root, 'workspace-mover', 'migrations.json');
|
|
416
|
+
assert.ok(existsSync(queueFile));
|
|
417
|
+
|
|
418
|
+
for (const cleanup of cleanups) cleanup();
|
|
419
|
+
cleanups = [];
|
|
420
|
+
ctx.get = () => undefined;
|
|
421
|
+
let restartedRpc;
|
|
422
|
+
const restartedCtx = {
|
|
423
|
+
...ctx,
|
|
424
|
+
effect(register) {
|
|
425
|
+
const cleanup = register();
|
|
426
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup);
|
|
427
|
+
},
|
|
428
|
+
connection: { rpc: { handle(_ch, handler) { restartedRpc = handler; return () => {}; } } }
|
|
429
|
+
};
|
|
430
|
+
apply(restartedCtx);
|
|
431
|
+
const recovered = await restartedRpc('mover.migrations.list', {});
|
|
432
|
+
assert.equal(recovered.ok, true, JSON.stringify(recovered));
|
|
433
|
+
const item = recovered.value.items.find((entry) => entry.id === queued.error.details.queueTaskId);
|
|
434
|
+
assert.equal(item.state, 'done');
|
|
435
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('迁移队列支持批量重试并逐项隔离失败', async () => {
|
|
439
|
+
apply(ctx);
|
|
440
|
+
const queued = await call('mover.migrations.enqueue', {
|
|
441
|
+
targetWorkspaceId: 'wid-b',
|
|
442
|
+
sessions: [{ sessionId: 'session-missing-a' }, { sessionId: 'session-missing-b' }]
|
|
443
|
+
});
|
|
444
|
+
assert.equal(queued.ok, true, JSON.stringify(queued));
|
|
445
|
+
const taskIds = queued.value.queued.map((item) => item.id);
|
|
446
|
+
const retry = await call('mover.migrations.retry', { taskIds });
|
|
447
|
+
assert.equal(retry.ok, true, JSON.stringify(retry));
|
|
448
|
+
assert.equal(retry.value.retried, 2);
|
|
449
|
+
assert.deepEqual(retry.value.items.map((item) => item.state), ['failed', 'failed']);
|
|
450
|
+
assert.deepEqual(retry.value.items.map((item) => item.attempts), [2, 2]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('迁移队列执行中拒绝取消,损坏的队列数据由 status 和 doctor 显示', async () => {
|
|
454
|
+
apply(ctx);
|
|
455
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
456
|
+
ctx.get = (key) => (key === 'agents' ? { get: () => ({ status: 'running' }) } : undefined);
|
|
457
|
+
const queued = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
458
|
+
ctx.get = () => undefined;
|
|
459
|
+
|
|
460
|
+
const originalList = ctx.sessionPersistence.list.bind(ctx.sessionPersistence);
|
|
461
|
+
let entered;
|
|
462
|
+
const inMove = new Promise((resolve) => { entered = resolve; });
|
|
463
|
+
let release;
|
|
464
|
+
const hold = new Promise((resolve) => { release = resolve; });
|
|
465
|
+
ctx.sessionPersistence.list = async (...args) => {
|
|
466
|
+
entered();
|
|
467
|
+
await hold;
|
|
468
|
+
return originalList(...args);
|
|
469
|
+
};
|
|
470
|
+
const retrying = call('mover.migrations.retry', { taskId: queued.error.details.queueTaskId });
|
|
471
|
+
await inMove;
|
|
472
|
+
const cancel = await call('mover.migrations.cancel', { taskId: queued.error.details.queueTaskId });
|
|
473
|
+
assert.equal(cancel.ok, false);
|
|
474
|
+
assert.equal(cancel.error.code, 'busy');
|
|
475
|
+
release();
|
|
476
|
+
const done = await retrying;
|
|
477
|
+
assert.equal(done.value.item.state, 'done');
|
|
478
|
+
|
|
479
|
+
writeFileSync(join(root, 'workspace-mover', 'migrations.json'), '{broken', 'utf8');
|
|
480
|
+
const status = await call('mover.status');
|
|
481
|
+
assert.equal(status.ok, true);
|
|
482
|
+
assert.match(status.value.capabilities.migrationQueueState.error, /corrupt/);
|
|
483
|
+
assert.ok(status.value.degradedFeatures.includes('migration-queue-data'));
|
|
484
|
+
const doctor = await call('mover.doctor');
|
|
485
|
+
assert.equal(doctor.ok, true);
|
|
486
|
+
assert.equal(doctor.value.checks.find((item) => item.id === 'data-migrations')?.state, 'fail');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('official handle-only persistence 被明确标记为降级而不是猜物理路径', async () => {
|
|
490
|
+
const official = createPersistenceAdapter({ sessionPersistence: {
|
|
491
|
+
async list() { return []; },
|
|
492
|
+
async stat() { return { formatStatus: 'jsonl' }; },
|
|
493
|
+
async open() { return {}; }
|
|
494
|
+
} });
|
|
495
|
+
assert.equal(official.mode, 'official-handle');
|
|
496
|
+
assert.equal(official.capabilities.physicalPath, false);
|
|
497
|
+
await assert.rejects(() => official.read({ kind: 'handle', sessionId: 'x' }), /raw-byte physical path/);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('fork-tree 预览会列出后代,Portable Bundle 导入先 dry-run 再执行', async () => {
|
|
501
|
+
apply(ctx);
|
|
502
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
503
|
+
const child = artifactPath(root, A, 'session-child');
|
|
504
|
+
mkdirSync(dirname(child), { recursive: true });
|
|
505
|
+
writeFileSync(child, makeArtifact({ type: 'session', id: 'session-child', parentSessionId: 'session-aaa', cwd: A, title: 'Child branch' }));
|
|
506
|
+
|
|
507
|
+
const preview = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b', mode: 'include-descendants' });
|
|
508
|
+
assert.equal(preview.ok, true, JSON.stringify(preview));
|
|
509
|
+
assert.equal(preview.value.branches.count, 1);
|
|
510
|
+
assert.deepEqual(preview.value.branches.sessionIds, ['session-aaa', 'session-child']);
|
|
511
|
+
|
|
512
|
+
const bundlePath = join(root, 'portable-bundle');
|
|
513
|
+
const exported = await call('mover.bundle.export', { sessionId: 'session-aaa', bundlePath, mode: 'include-descendants' });
|
|
514
|
+
assert.equal(exported.ok, true, JSON.stringify(exported));
|
|
515
|
+
assert.equal(exported.value.sessionCount, 2);
|
|
516
|
+
assert.ok(existsSync(join(bundlePath, 'manifest.json')));
|
|
517
|
+
assert.ok(existsSync(join(bundlePath, 'checksums.json')));
|
|
518
|
+
|
|
519
|
+
entityA.record.sessionIds = [];
|
|
520
|
+
rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
|
|
521
|
+
rmSync(dirname(artifactPath(root, A, 'session-child')), { recursive: true, force: true });
|
|
522
|
+
const dryRun = await call('mover.bundle.import', { bundlePath, targetWorkspaceId: 'wid-b' });
|
|
523
|
+
assert.equal(dryRun.ok, true, JSON.stringify(dryRun));
|
|
524
|
+
assert.equal(dryRun.value.dryRun, true);
|
|
525
|
+
assert.equal(dryRun.value.status, 'safe');
|
|
526
|
+
const imported = await call('mover.bundle.import', { bundlePath, targetWorkspaceId: 'wid-b', dryRun: false });
|
|
527
|
+
assert.equal(imported.ok, true, JSON.stringify(imported));
|
|
528
|
+
assert.equal(imported.value.importedCount, 2);
|
|
529
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
|
|
530
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-child')));
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('v2.2 mover.status 能力位:官方接口缺失时如实标记为降级(可观测,不静默)', async () => {
|
|
534
|
+
apply(ctx);
|
|
535
|
+
// 夹具的实体带官方 setTitle(与真实 Workspace 接口一致),但不带 unarchiveSession。
|
|
536
|
+
const res = await call('mover.status');
|
|
537
|
+
assert.equal(res.ok, true);
|
|
538
|
+
assert.equal(res.value.capabilities.setTitleApi, true, '实体带 setTitle 应被识别');
|
|
539
|
+
assert.equal(res.value.capabilities.unarchiveApi, false, '夹具无官方取消归档接口');
|
|
540
|
+
assert.ok(res.value.degradedFeatures.includes('unarchive-official-api'),
|
|
541
|
+
`缺失官方取消归档接口应列入降级: ${JSON.stringify(res.value.degradedFeatures)}`);
|
|
542
|
+
assert.ok(!res.value.degradedFeatures.includes('set-title-official-api'), '有 setTitle 不应报降级');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('v2.2 mover.status 能力位:补上官方接口后不再报降级', async () => {
|
|
546
|
+
apply(ctx);
|
|
547
|
+
ctx.workspaceRegistry.unarchiveSession = async () => {};
|
|
548
|
+
const res = await call('mover.status');
|
|
549
|
+
assert.equal(res.value.capabilities.unarchiveApi, true);
|
|
550
|
+
assert.ok(!res.value.degradedFeatures.includes('unarchive-official-api'));
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('v2.2 mover.status 能力位:setTitle 缺失时也如实标记', async () => {
|
|
554
|
+
apply(ctx);
|
|
555
|
+
delete entityA.setTitle;
|
|
556
|
+
const res = await call('mover.status');
|
|
557
|
+
assert.equal(res.value.capabilities.setTitleApi, false);
|
|
558
|
+
assert.ok(res.value.degradedFeatures.includes('set-title-official-api'));
|
|
201
559
|
});
|
|
202
560
|
|
|
203
561
|
test('正常迁移:文件物理搬移 + 头部改写 + 双向记账 + 有备份', async () => {
|
|
@@ -841,6 +1199,22 @@ test('标题同步:默认名(=旧文件夹名)跟随改名,自定义标
|
|
|
841
1199
|
assert.equal(entityA.record.title, '我的项目', '自定义标题保留');
|
|
842
1200
|
});
|
|
843
1201
|
|
|
1202
|
+
test('v2.2 标题同步失败不得影响已完成的 path 重定向(setTitle 与 mutate 解耦)', async () => {
|
|
1203
|
+
apply(ctx);
|
|
1204
|
+
setupMovedFolder2();
|
|
1205
|
+
const ent = ctx.workspaceRegistry.entities.get('wid-t2');
|
|
1206
|
+
const oldPath = ent.path;
|
|
1207
|
+
const moved = oldPath + '-moved';
|
|
1208
|
+
ent.record.title = oldPath.split(/[\\/]/).pop(); // 仍是旧文件夹名 → 会尝试同步
|
|
1209
|
+
ent.setTitle = async () => { throw new Error('simulated setTitle failure'); };
|
|
1210
|
+
|
|
1211
|
+
const res = await call('mover.repoint', { workspaceId: 'wid-t2', newPath: moved });
|
|
1212
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1213
|
+
assert.equal(res.value.pathUpdated, true, 'path 重定向必须完成');
|
|
1214
|
+
assert.equal(realpathSync(ent.path), realpathSync(moved), '工作区已指向新路径');
|
|
1215
|
+
assert.notEqual(ent.record.title, moved.split(/[\\/]/).pop(), '标题同步失败 → 保留原标题');
|
|
1216
|
+
});
|
|
1217
|
+
|
|
844
1218
|
/** 独立夹具:避免用例间复用同一目录名导致的前置污染。 */
|
|
845
1219
|
function setupMovedFolder2() {
|
|
846
1220
|
const dir = join(root, 'ws-t2');
|
|
@@ -1005,13 +1379,51 @@ test('mover.unarchive 拒绝:未归档的会话;registry 缺状态写通道'
|
|
|
1005
1379
|
assert.equal(miss.ok, false);
|
|
1006
1380
|
assert.match(miss.error.message, /not archived/);
|
|
1007
1381
|
|
|
1008
|
-
// 归档集里有它,但 registry
|
|
1382
|
+
// 归档集里有它,但 registry 既无官方接口也无状态写通道(老版本宿主容错)
|
|
1009
1383
|
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1010
1384
|
const res = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1011
1385
|
assert.equal(res.ok, false);
|
|
1012
1386
|
assert.match(res.error.message, /no state mutation API/);
|
|
1013
1387
|
});
|
|
1014
1388
|
|
|
1389
|
+
test('v2.2 mover.unarchive 优先走官方 registry.unarchiveSession', async () => {
|
|
1390
|
+
apply(ctx);
|
|
1391
|
+
stubRegistryState();
|
|
1392
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1393
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1394
|
+
let officialCalls = 0;
|
|
1395
|
+
let fallbackCalls = 0;
|
|
1396
|
+
const enqueue = ctx.workspaceRegistry.enqueueOperation;
|
|
1397
|
+
ctx.workspaceRegistry.unarchiveSession = async (id) => {
|
|
1398
|
+
officialCalls++;
|
|
1399
|
+
ctx.workspaceRegistry.archivedSessionIds = ctx.workspaceRegistry.archivedSessionIds.filter((x) => String(x) !== String(id));
|
|
1400
|
+
};
|
|
1401
|
+
ctx.workspaceRegistry.enqueueOperation = (...args) => { fallbackCalls++; return enqueue(...args); };
|
|
1402
|
+
|
|
1403
|
+
const res = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1404
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1405
|
+
assert.equal(officialCalls, 1, '必须调用官方接口');
|
|
1406
|
+
assert.equal(fallbackCalls, 0, '有官方接口时不得走回退通道');
|
|
1407
|
+
assert.equal(ctx.workspaceRegistry.archivedSessionIds.includes('session-aaa'), false);
|
|
1408
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa'], '记账槽不得被取消归档改动');
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
test('v2.2 mover.unarchive 官方接口缺失时回退到状态写通道', async () => {
|
|
1412
|
+
apply(ctx);
|
|
1413
|
+
stubRegistryState();
|
|
1414
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1415
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1416
|
+
let fallbackCalls = 0;
|
|
1417
|
+
const enqueue = ctx.workspaceRegistry.enqueueOperation;
|
|
1418
|
+
ctx.workspaceRegistry.enqueueOperation = (...args) => { fallbackCalls++; return enqueue(...args); };
|
|
1419
|
+
assert.equal(typeof ctx.workspaceRegistry.unarchiveSession, 'undefined', '夹具默认无官方接口');
|
|
1420
|
+
|
|
1421
|
+
const res = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1422
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1423
|
+
assert.equal(fallbackCalls, 1, '必须走回退通道');
|
|
1424
|
+
assert.equal(ctx.workspaceRegistry.archivedSessionIds.includes('session-aaa'), false);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1015
1427
|
test('mover.openFolder 只允许已注册工作区路径;openInFileManager 按平台拼装命令', async () => {
|
|
1016
1428
|
apply(ctx);
|
|
1017
1429
|
// 未注册路径拒绝(不触发任何 spawn)
|
|
@@ -1136,7 +1548,7 @@ test('迁移流程级:残留目标目录给出明确错误,清理后同一
|
|
|
1136
1548
|
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')));
|
|
1137
1549
|
});
|
|
1138
1550
|
|
|
1139
|
-
test('stashBackup:相邻前缀 id
|
|
1551
|
+
test('stashBackup:相邻前缀 id 互不误删;同一毫秒的连续备份互不覆盖', () => {
|
|
1140
1552
|
const a = 'session-aaa';
|
|
1141
1553
|
const b = 'session-aaa-1'; // a 的前缀超集;'-' < '.' 使其排序在 a 的所有备份之前(最旧)
|
|
1142
1554
|
const dir = join(root, 'workspace-mover', 'backups');
|
|
@@ -1147,13 +1559,59 @@ test('stashBackup:相邻前缀 id 的备份互不误删(前缀收紧)', ()
|
|
|
1147
1559
|
const files = readdirSync(dir);
|
|
1148
1560
|
assert.equal(files.filter((f) => f.startsWith(`${b}.`)).length, 1, '相邻前缀 id 的备份不被误删');
|
|
1149
1561
|
assert.equal(files.filter((f) => f.startsWith(`${a}.`)).length, 20, 'a 恰好保留最近 20 份');
|
|
1150
|
-
//
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
assert.equal(
|
|
1562
|
+
// 同一毫秒的连续 stash:必须各自留下独立文件(曾因文件名相撞而静默覆盖,
|
|
1563
|
+
// 使 backupKeep 失效——Linux CI 就是这样挂掉的)。
|
|
1564
|
+
const msA = 'session-ms';
|
|
1565
|
+
const countMs = () => readdirSync(dir).filter((f) => f.startsWith(`${msA}.`)).length;
|
|
1566
|
+
stashBackup(msA, Buffer.from('ms-1'));
|
|
1567
|
+
stashBackup(msA, Buffer.from('ms-2'));
|
|
1568
|
+
assert.equal(countMs(), 2,
|
|
1569
|
+
`同一毫秒的两次备份必须留下 2 份而不是互相覆盖,实际 ${countMs()} 份:${JSON.stringify(readdirSync(dir).filter((f) => f.startsWith(`${msA}.`)))}`);
|
|
1570
|
+
const msContents = readdirSync(dir).filter((f) => f.startsWith(`${msA}.`)).map((f) => readFileSync(join(dir, f)).toString()).sort();
|
|
1571
|
+
assert.deepEqual(msContents, ['ms-1', 'ms-2'], `两份备份的内容都必须保留,实际 ${JSON.stringify(msContents)}`);
|
|
1572
|
+
|
|
1573
|
+
// 裁剪计数仍受 backupKeep 约束:把 a 撑回 21 份后应回落到 20。
|
|
1574
|
+
for (let i = 2000; i < 2021; i++) writeFileSync(join(dir, `${a}.${i}.zstd`), Buffer.from('v' + i));
|
|
1575
|
+
stashBackup(a, Buffer.from('after-prune'));
|
|
1576
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith(`${a}.`)).length, 20, 'a 仍恰好保留 20 份');
|
|
1577
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith(`${b}.`)).length, 1, '相邻前缀 id 仍不受影响');
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
test('stashBackup:忽略迁移 cwd 后复用同一快照,内容变化才新增物理文件', () => {
|
|
1581
|
+
const dir = join(root, 'workspace-mover', 'backups');
|
|
1582
|
+
const firstBytes = makeArtifact({ type: 'session', id: 'session-dedupe', cwd: A, title: 'Stable content' });
|
|
1583
|
+
const sameContentAtOtherWorkspace = makeArtifact({ type: 'session', id: 'session-dedupe', cwd: B, title: 'Stable content' });
|
|
1584
|
+
const first = stashBackup('session-dedupe', firstBytes, { compression: 'zstd', ignoreCwd: true });
|
|
1585
|
+
const reused = stashBackup('session-dedupe', sameContentAtOtherWorkspace, { compression: 'zstd', ignoreCwd: true });
|
|
1586
|
+
assert.equal(first.created, true);
|
|
1587
|
+
assert.equal(reused.reused, true);
|
|
1588
|
+
assert.equal(reused.addedBytes, 0);
|
|
1589
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith('session-dedupe.')).length, 1);
|
|
1590
|
+
|
|
1591
|
+
const changed = makeArtifact({ type: 'session', id: 'session-dedupe', cwd: B, title: 'Changed content' });
|
|
1592
|
+
const second = stashBackup('session-dedupe', changed, { compression: 'zstd', ignoreCwd: true });
|
|
1593
|
+
assert.equal(second.created, true);
|
|
1594
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith('session-dedupe.')).length, 2);
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
test('往返迁移:备份物理文件只保留一份,但每次移动历史都记录复用状态', async () => {
|
|
1598
|
+
apply(ctx);
|
|
1599
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1600
|
+
const first = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1601
|
+
assert.equal(first.ok, true, JSON.stringify(first));
|
|
1602
|
+
const backPreview = await call('mover.preview', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
|
|
1603
|
+
assert.equal(backPreview.ok, true, JSON.stringify(backPreview));
|
|
1604
|
+
assert.equal(backPreview.value.backup.reused, true);
|
|
1605
|
+
assert.equal(backPreview.value.estimatedBackupBytes, 0);
|
|
1606
|
+
const back = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
|
|
1607
|
+
assert.equal(back.ok, true, JSON.stringify(back));
|
|
1608
|
+
const files = readdirSync(join(root, 'workspace-mover', 'backups')).filter((f) => f.startsWith('session-aaa.'));
|
|
1609
|
+
assert.equal(files.length, 1, JSON.stringify(files));
|
|
1610
|
+
const history = await call('mover.history');
|
|
1611
|
+
assert.equal(history.ok, true);
|
|
1612
|
+
assert.equal(history.value.items.length, 2);
|
|
1613
|
+
assert.equal(history.value.items[0].backup.reused, true);
|
|
1614
|
+
assert.equal(history.value.items[1].backup.created, true);
|
|
1157
1615
|
});
|
|
1158
1616
|
|
|
1159
1617
|
//#region v0.9:回收站 + 备份管理
|
|
@@ -1223,6 +1681,65 @@ test('mover.session.delete:移入回收站并完成四件套清理', async ()
|
|
|
1223
1681
|
assert.equal(list.value.items[0].ownerWorkspaceId, 'wid-a');
|
|
1224
1682
|
});
|
|
1225
1683
|
|
|
1684
|
+
test('v2.2 备份 GC:删除会话默认保留备份,purgeBackups=true 才清理,且不误删相邻 id', async () => {
|
|
1685
|
+
apply(ctx);
|
|
1686
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1687
|
+
sharedIndex.sessionPaths.set('session-aaa-1', A);
|
|
1688
|
+
entityA.record.sessionIds.push('session-aaa', 'session-aaa-1');
|
|
1689
|
+
// 三个会话各留一份备份;session-aaa-1 是 session-aaa 的前缀超集,必须不被误删
|
|
1690
|
+
stashBackup('session-aaa', Buffer.from('aaa-1'));
|
|
1691
|
+
stashBackup('session-aaa-1', Buffer.from('neighbour'));
|
|
1692
|
+
const dir = join(root, 'workspace-mover', 'backups');
|
|
1693
|
+
const filesFor = (id) => readdirSync(dir).filter((f) => f.startsWith(`${id}.`));
|
|
1694
|
+
|
|
1695
|
+
// ① 默认:删除会话但保留备份
|
|
1696
|
+
const del = await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1697
|
+
assert.equal(del.ok, true, JSON.stringify(del));
|
|
1698
|
+
assert.equal(del.value.backupsDeleted, 0, '默认不得删除备份');
|
|
1699
|
+
assert.equal(filesFor('session-aaa').length, 1, '备份仍在(回收站还原后可能还要用)');
|
|
1700
|
+
assert.equal(filesFor('session-aaa-1').length, 1);
|
|
1701
|
+
|
|
1702
|
+
// ② 现在 session-aaa 档案已不存在 → 它的备份组成为孤儿
|
|
1703
|
+
const listed = await call('mover.backups.list');
|
|
1704
|
+
assert.equal(listed.ok, true);
|
|
1705
|
+
const aaaGroup = listed.value.items.find((it) => it.sessionId === 'session-aaa');
|
|
1706
|
+
const neighbourGroup = listed.value.items.find((it) => it.sessionId === 'session-aaa-1');
|
|
1707
|
+
assert.equal(aaaGroup.orphan, true, '会话已删除 → 备份组标记为孤儿');
|
|
1708
|
+
assert.equal(neighbourGroup.orphan, false, '仍存在的会话不得被误标为孤儿');
|
|
1709
|
+
assert.equal(listed.value.orphanGroups, 1);
|
|
1710
|
+
assert.ok(listed.value.orphanBytes > 0);
|
|
1711
|
+
|
|
1712
|
+
// ③ 彻底删除 + purgeBackups:连带清掉该会话备份,且相邻前缀 id 毫发无损
|
|
1713
|
+
const purged = await call('mover.trash.purge', { sessionId: 'session-aaa', purgeBackups: true });
|
|
1714
|
+
assert.equal(purged.ok, true, JSON.stringify(purged));
|
|
1715
|
+
assert.equal(purged.value.purged, 1);
|
|
1716
|
+
assert.equal(purged.value.backupsDeleted, 1);
|
|
1717
|
+
assert.equal(filesFor('session-aaa').length, 0, '孤儿备份必须被清掉');
|
|
1718
|
+
assert.equal(filesFor('session-aaa-1').length, 1, '相邻前缀 id 的备份绝不能被误删');
|
|
1719
|
+
assert.equal(readFileSync(join(dir, filesFor('session-aaa-1')[0])).toString(), 'neighbour');
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
test('v2.2 备份 GC:doctor 汇总孤儿备份组数与占用', async () => {
|
|
1723
|
+
apply(ctx);
|
|
1724
|
+
// 只留一份"会话已不存在"的备份
|
|
1725
|
+
stashBackup('session-ghost', Buffer.alloc(64, 7));
|
|
1726
|
+
const doc = await call('mover.doctor');
|
|
1727
|
+
assert.equal(doc.ok, true);
|
|
1728
|
+
const backupsCheck = doc.value.checks.find((c) => c.id === 'data-backups');
|
|
1729
|
+
assert.equal(backupsCheck.state, 'pass');
|
|
1730
|
+
assert.match(backupsCheck.detail, /orphan group/, `应汇报孤儿组: ${backupsCheck.detail}`);
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
test('v2.2 备份 GC:moveMany 历史聚合的会话备份仍被视为存活', async () => {
|
|
1734
|
+
apply(ctx);
|
|
1735
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1736
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1737
|
+
stashBackup('session-aaa', Buffer.from('alive'));
|
|
1738
|
+
const listed = await call('mover.backups.list');
|
|
1739
|
+
const group = listed.value.items.find((it) => it.sessionId === 'session-aaa');
|
|
1740
|
+
assert.equal(group.orphan, false, '注册表仍记账的会话不算孤儿');
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1226
1743
|
test('mover.session.delete:常驻内存的会话拒绝删除', async () => {
|
|
1227
1744
|
apply(ctx);
|
|
1228
1745
|
entityA.record.sessionIds.push('session-aaa');
|
|
@@ -1760,6 +2277,43 @@ test('v1.4 scan 超限截断:先按 mtime 排序,保留最新 400 条', asyn
|
|
|
1760
2277
|
assert.ok(ids.has('session-gen-409'), '最新的生成会话必须保留');
|
|
1761
2278
|
assert.equal(ids.has('session-gen-000'), false, '最旧的生成会话必须被截掉');
|
|
1762
2279
|
assert.ok(ids.has('session-aaa'), '整体最新的夹具会话保留');
|
|
2280
|
+
assert.equal(res.value.scannedParsed, CAP, 'scannedParsed 报告实际解析条数');
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
test('v2.2 归档列表透传截断信息:大库上"不是全部"必须可说明', async () => {
|
|
2284
|
+
apply(ctx);
|
|
2285
|
+
const CAP = SCAN_MAX_ITEMS;
|
|
2286
|
+
const EXTRA = 5;
|
|
2287
|
+
const base = Date.now() - 1_000_000;
|
|
2288
|
+
// 造一个很旧、且被归档的会话:它会被 mtime 截断挡在扫描窗口之外。
|
|
2289
|
+
const oldId = 'session-archived-old';
|
|
2290
|
+
const oldPath = artifactPath(root, A, oldId);
|
|
2291
|
+
mkdirSync(dirname(oldPath), { recursive: true });
|
|
2292
|
+
writeFileSync(oldPath, makeArtifact({ type: 'session', id: oldId, cwd: A, title: 'Ancient archived' }));
|
|
2293
|
+
const oldTime = new Date(base - 500_000);
|
|
2294
|
+
utimesSync(oldPath, oldTime, oldTime);
|
|
2295
|
+
|
|
2296
|
+
for (let i = 0; i < CAP + EXTRA; i++) {
|
|
2297
|
+
const id = `session-arch-${String(i).padStart(3, '0')}`;
|
|
2298
|
+
const p = artifactPath(root, A, id);
|
|
2299
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
2300
|
+
writeFileSync(p, makeArtifact({ type: 'session', id, cwd: A, title: `Arch ${i}` }));
|
|
2301
|
+
const t = new Date(base + i * 1000);
|
|
2302
|
+
utimesSync(p, t, t);
|
|
2303
|
+
}
|
|
2304
|
+
// 官方归档集:把最旧的那个也标为归档
|
|
2305
|
+
ctx.workspaceRegistry.archivedSessionIds = [oldId];
|
|
2306
|
+
|
|
2307
|
+
const res = await call('mover.archived');
|
|
2308
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
2309
|
+
assert.equal(res.value.truncated, true, '本次扫描必然被截断');
|
|
2310
|
+
assert.equal(res.value.archivedTotal, 1);
|
|
2311
|
+
assert.ok(res.value.scannedTotal > res.value.scannedParsed,
|
|
2312
|
+
`必须同时给出"共多少"与"解析了多少": ${JSON.stringify({ total: res.value.scannedTotal, parsed: res.value.scannedParsed })}`);
|
|
2313
|
+
// 关键:被截断挡住的归档会话确实不在列表里——所以 UI 必须能说明这件事
|
|
2314
|
+
assert.equal(res.value.items.some((it) => it.sessionId === oldId), false,
|
|
2315
|
+
'该归档会话落在扫描窗口之外(正是需要提示用户的场景)');
|
|
2316
|
+
assert.equal(res.value.items.length, 0);
|
|
1763
2317
|
});
|
|
1764
2318
|
|
|
1765
2319
|
test('v1.4 回滚也失败 → 写 recovery 记录,scan 报告 recoveryCount', async () => {
|
|
@@ -1919,44 +2473,135 @@ test('v2.1 mover_repair_sessions 走审批并修复未记账夹具会话', async
|
|
|
1919
2473
|
assert.equal(approvalCalls.length, 1);
|
|
1920
2474
|
assert.equal(approvalCalls[0].toolName, 'mover_repair_sessions');
|
|
1921
2475
|
});
|
|
1922
|
-
|
|
1923
|
-
// ===== v2.2.0 mover.doctor 自检 =====
|
|
1924
|
-
|
|
1925
|
-
test('v2.2 mover.doctor 沙箱全绿:服务/数据/工作区全部 pass', async () => {
|
|
1926
|
-
apply(ctx);
|
|
1927
|
-
const res = await call('mover.doctor');
|
|
1928
|
-
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1929
|
-
const { checks, summary } = res.value;
|
|
1930
|
-
assert.ok(checks.length >= 12, `至少 12 项检查,实际 ${checks.length}`);
|
|
1931
|
-
for (const c of checks) assert.ok(['pass', 'warn', 'fail'].includes(c.state), `state 合法: ${c.id}`);
|
|
1932
|
-
assert.equal(summary.fail, 0, `不应有 fail: ${JSON.stringify(checks.filter((c) => c.state === 'fail'))}`);
|
|
1933
|
-
const ids = new Set(checks.map((c) => c.id));
|
|
1934
|
-
for (const expected of ['host-registry', 'host-persistence', 'host-projection', 'data-recycle', 'data-backups', 'recovery', 'workspaces']) {
|
|
1935
|
-
assert.ok(ids.has(expected), `缺少检查项 ${expected}`);
|
|
1936
|
-
}
|
|
1937
|
-
assert.equal(typeof res.value.generatedAt, 'string');
|
|
1938
|
-
});
|
|
1939
|
-
|
|
1940
|
-
test('v2.2 mover.doctor 降级:摘除注册表后 fail + warn,但不抛错', async () => {
|
|
1941
|
-
apply(ctx);
|
|
1942
|
-
ctx.workspaceRegistry = undefined;
|
|
1943
|
-
const res = await call('mover.doctor');
|
|
1944
|
-
assert.equal(res.ok, true, 'doctor 本身永远 ok');
|
|
1945
|
-
const registryCheck = res.value.checks.find((c) => c.id === 'host-registry');
|
|
1946
|
-
assert.equal(registryCheck.state, 'fail');
|
|
1947
|
-
const workspacesCheck = res.value.checks.find((c) => c.id === 'workspaces');
|
|
1948
|
-
assert.equal(workspacesCheck.state, 'warn');
|
|
1949
|
-
assert.equal(res.value.summary.fail >= 1, true);
|
|
1950
|
-
});
|
|
1951
|
-
|
|
1952
|
-
test('v2.2 mover.doctor 恢复记录告警:recovery.json 存在时 warn', async () => {
|
|
1953
|
-
apply(ctx);
|
|
1954
|
-
const dir = join(root, 'workspace-mover');
|
|
1955
|
-
mkdirSync(dir, { recursive: true });
|
|
1956
|
-
writeFileSync(join(dir, 'recovery.json'), JSON.stringify({ version: 1, records: [{ id: 'rec-x', kind: 'delete', phase: 'test', createdAt: new Date().toISOString() }] }));
|
|
1957
|
-
const res = await call('mover.doctor');
|
|
1958
|
-
assert.equal(res.ok, true);
|
|
1959
|
-
const recovery = res.value.checks.find((c) => c.id === 'recovery');
|
|
1960
|
-
assert.equal(recovery.state, 'warn');
|
|
1961
|
-
assert.match(recovery.detail, /1 record/);
|
|
1962
|
-
});
|
|
2476
|
+
|
|
2477
|
+
// ===== v2.2.0 mover.doctor 自检 =====
|
|
2478
|
+
|
|
2479
|
+
test('v2.2 mover.doctor 沙箱全绿:服务/数据/工作区全部 pass', async () => {
|
|
2480
|
+
apply(ctx);
|
|
2481
|
+
const res = await call('mover.doctor');
|
|
2482
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
2483
|
+
const { checks, summary } = res.value;
|
|
2484
|
+
assert.ok(checks.length >= 12, `至少 12 项检查,实际 ${checks.length}`);
|
|
2485
|
+
for (const c of checks) assert.ok(['pass', 'warn', 'fail'].includes(c.state), `state 合法: ${c.id}`);
|
|
2486
|
+
assert.equal(summary.fail, 0, `不应有 fail: ${JSON.stringify(checks.filter((c) => c.state === 'fail'))}`);
|
|
2487
|
+
const ids = new Set(checks.map((c) => c.id));
|
|
2488
|
+
for (const expected of ['host-registry', 'host-persistence', 'host-projection', 'data-recycle', 'data-backups', 'recovery', 'workspaces']) {
|
|
2489
|
+
assert.ok(ids.has(expected), `缺少检查项 ${expected}`);
|
|
2490
|
+
}
|
|
2491
|
+
assert.equal(typeof res.value.generatedAt, 'string');
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2494
|
+
test('v2.2 mover.doctor 降级:摘除注册表后 fail + warn,但不抛错', async () => {
|
|
2495
|
+
apply(ctx);
|
|
2496
|
+
ctx.workspaceRegistry = undefined;
|
|
2497
|
+
const res = await call('mover.doctor');
|
|
2498
|
+
assert.equal(res.ok, true, 'doctor 本身永远 ok');
|
|
2499
|
+
const registryCheck = res.value.checks.find((c) => c.id === 'host-registry');
|
|
2500
|
+
assert.equal(registryCheck.state, 'fail');
|
|
2501
|
+
const workspacesCheck = res.value.checks.find((c) => c.id === 'workspaces');
|
|
2502
|
+
assert.equal(workspacesCheck.state, 'warn');
|
|
2503
|
+
assert.equal(res.value.summary.fail >= 1, true);
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
test('v2.2 mover.doctor 恢复记录告警:recovery.json 存在时 warn', async () => {
|
|
2507
|
+
apply(ctx);
|
|
2508
|
+
const dir = join(root, 'workspace-mover');
|
|
2509
|
+
mkdirSync(dir, { recursive: true });
|
|
2510
|
+
writeFileSync(join(dir, 'recovery.json'), JSON.stringify({ version: 1, records: [{ id: 'rec-x', kind: 'delete', phase: 'test', createdAt: new Date().toISOString() }] }));
|
|
2511
|
+
const res = await call('mover.doctor');
|
|
2512
|
+
assert.equal(res.ok, true);
|
|
2513
|
+
const recovery = res.value.checks.find((c) => c.id === 'recovery');
|
|
2514
|
+
assert.equal(recovery.state, 'warn');
|
|
2515
|
+
assert.match(recovery.detail, /1 record/);
|
|
2516
|
+
});
|
|
2517
|
+
|
|
2518
|
+
//#region v2.2 存储布局自证(插件复刻了 session-persistence-jsonl 的目录编码,
|
|
2519
|
+
// 且 persistence.root 并非文档化接口——宿主改布局时必须可检测而非静默失效)
|
|
2520
|
+
|
|
2521
|
+
test('mover.status 报告 storageLayout=ok,且 doctor 有 data-layout 通过项', async () => {
|
|
2522
|
+
apply(ctx);
|
|
2523
|
+
const status = await call('mover.status');
|
|
2524
|
+
assert.equal(status.ok, true);
|
|
2525
|
+
assert.equal(status.value.capabilities.storageLayout, 'ok',
|
|
2526
|
+
`沙箱夹具的布局必须自证通过: ${JSON.stringify(status.value.storageLayout)}`);
|
|
2527
|
+
assert.ok(!status.value.degradedFeatures.includes('storage-layout'));
|
|
2528
|
+
|
|
2529
|
+
const doc = await call('mover.doctor');
|
|
2530
|
+
const layoutCheck = doc.value.checks.find((c) => c.id === 'data-layout');
|
|
2531
|
+
assert.ok(layoutCheck, 'doctor 必须包含 data-layout 检查项');
|
|
2532
|
+
assert.equal(layoutCheck.state, 'pass');
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
test('宿主布局漂移时自证失败:storageLayout=degraded 且列为降级特性', async () => {
|
|
2536
|
+
apply(ctx);
|
|
2537
|
+
// 把持久化根指向别处:插件按自己的编码规则反推出的目录在那边不存在。
|
|
2538
|
+
ctx.sessionPersistence.root = join(root, 'somewhere-else');
|
|
2539
|
+
const status = await call('mover.status');
|
|
2540
|
+
assert.equal(status.ok, true, 'status 本身永远 ok');
|
|
2541
|
+
assert.equal(status.value.capabilities.storageLayout, 'degraded');
|
|
2542
|
+
assert.equal(status.value.storageLayout.diagnostic, 'layout-unknown');
|
|
2543
|
+
assert.ok(status.value.degradedFeatures.includes('storage-layout'),
|
|
2544
|
+
`降级特性应包含 storage-layout: ${JSON.stringify(status.value.degradedFeatures)}`);
|
|
2545
|
+
assert.equal(typeof status.value.storageLayout.reason, 'string');
|
|
2546
|
+
|
|
2547
|
+
const doc = await call('mover.doctor');
|
|
2548
|
+
assert.equal(doc.ok, true, 'doctor 不因布局异常而抛错');
|
|
2549
|
+
const layoutCheck = doc.value.checks.find((c) => c.id === 'data-layout');
|
|
2550
|
+
assert.equal(layoutCheck.state, 'warn');
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
test('没有会话可校验时 storageLayout=empty(全新安装不误报降级)', async () => {
|
|
2554
|
+
apply(ctx);
|
|
2555
|
+
// 清空持久化根:list() 动态扫描,返回空即无样本可校验。
|
|
2556
|
+
rmSync(root, { recursive: true, force: true });
|
|
2557
|
+
const status = await call('mover.status');
|
|
2558
|
+
assert.equal(status.ok, true);
|
|
2559
|
+
assert.equal(status.value.capabilities.storageLayout, 'empty');
|
|
2560
|
+
assert.ok(!status.value.degradedFeatures.includes('storage-layout'), '无样本不应报降级');
|
|
2561
|
+
});
|
|
2562
|
+
//#endregion
|
|
2563
|
+
|
|
2564
|
+
|
|
2565
|
+
test('默认恢复选择最新备份,显式指定时仍可恢复旧备份', async () => {
|
|
2566
|
+
apply(ctx);
|
|
2567
|
+
const dir = join(root, 'workspace-mover', 'backups');
|
|
2568
|
+
mkdirSync(dir, { recursive: true });
|
|
2569
|
+
writeFileSync(join(dir, 'session-aaa.1000.zstd'), makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, title: 'Old content' }));
|
|
2570
|
+
writeFileSync(join(dir, 'session-aaa.2000.zstd'), makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, title: 'Latest content' }));
|
|
2571
|
+
rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
|
|
2572
|
+
const restored = await call('mover.backups.restore', { sessionId: 'session-aaa' });
|
|
2573
|
+
assert.equal(restored.ok, true, JSON.stringify(restored));
|
|
2574
|
+
assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).title, 'Latest content');
|
|
2575
|
+
entityA.record.sessionIds = [];
|
|
2576
|
+
sharedIndex.sessionPaths.delete('session-aaa');
|
|
2577
|
+
rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
|
|
2578
|
+
const old = await call('mover.backups.restore', { sessionId: 'session-aaa', fileName: 'session-aaa.1000.zstd' });
|
|
2579
|
+
assert.equal(old.ok, true, JSON.stringify(old));
|
|
2580
|
+
assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).title, 'Old content');
|
|
2581
|
+
});
|
|
2582
|
+
|
|
2583
|
+
test('Bundle 空间估算包含附件,空间不足时 dry-run 阻断且执行不写入', async () => {
|
|
2584
|
+
apply(ctx);
|
|
2585
|
+
const attachment = Buffer.alloc(4096, 7);
|
|
2586
|
+
writeFileSync(join(dirname(artifactPath(root, A, 'session-aaa')), 'attachment.bin'), attachment);
|
|
2587
|
+
const bundlePath = join(root, 'space-bundle');
|
|
2588
|
+
const exported = await call('mover.bundle.export', { sessionId: 'session-aaa', bundlePath });
|
|
2589
|
+
assert.equal(exported.ok, true, JSON.stringify(exported));
|
|
2590
|
+
const archiveBytes = readFileSync(artifactPath(root, A, 'session-aaa')).length;
|
|
2591
|
+
rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
|
|
2592
|
+
const original = fs.statfsSync;
|
|
2593
|
+
fs.statfsSync = () => ({ bfree: 1, bsize: 1 });
|
|
2594
|
+
syncBuiltinESMExports();
|
|
2595
|
+
try {
|
|
2596
|
+
const plan = await call('mover.bundle.inspect', { bundlePath, targetWorkspaceId: 'wid-b' });
|
|
2597
|
+
assert.equal(plan.ok, true, JSON.stringify(plan));
|
|
2598
|
+
assert.equal(plan.value.requiredBytes, archiveBytes + attachment.length);
|
|
2599
|
+
assert.equal(plan.value.status, 'blocked');
|
|
2600
|
+
const imported = await call('mover.bundle.import', { bundlePath, targetWorkspaceId: 'wid-b', dryRun: false });
|
|
2601
|
+
assert.equal(imported.ok, false);
|
|
2602
|
+
assert.ok(!existsSync(artifactPath(root, B, 'session-aaa')));
|
|
2603
|
+
} finally {
|
|
2604
|
+
fs.statfsSync = original;
|
|
2605
|
+
syncBuiltinESMExports();
|
|
2606
|
+
}
|
|
2607
|
+
});
|