dsh-workspace-mover 2.0.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/LICENSE +21 -0
- package/README.md +239 -0
- package/client/client.js +1918 -0
- package/cordis.patch.yml +14 -0
- package/docs/ARCHITECTURE.md +141 -0
- package/docs/media/batch_move_selection.png +0 -0
- package/docs/media/confirm_popup.png +0 -0
- package/docs/media/drag_session_to_another_workspace.png +0 -0
- package/docs/media/original_workspace.png +0 -0
- package/docs/media/remove_popup.png +0 -0
- package/docs/media/setting_dialogue_repair.png +0 -0
- package/docs/media/workspace_after_move.png +0 -0
- package/docs/media/workspace_after_rename.png +0 -0
- package/docs/media/workspace_examination.png +0 -0
- package/docs/media/workspace_move.png +0 -0
- package/lib/index.js +2446 -0
- package/package.json +62 -0
- package/test/core.test.mjs +124 -0
- package/test/e2e-sandbox.test.mjs +1804 -0
- package/test/release-downloads.test.mjs +102 -0
- package/test/traffic-badge.test.mjs +66 -0
|
@@ -0,0 +1,1804 @@
|
|
|
1
|
+
// 端到端沙箱测试:假注册表 + 假持久化 + 真实文件系统,完整走 mover.move 流程。
|
|
2
|
+
// 覆盖:status / 正常迁移 / 同工作区拒绝 / 未知目标 / attach 失败自动回滚。
|
|
3
|
+
// 备份目录通过 DSH_HOME 重定向到临时区,不污染真实环境。
|
|
4
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync, renameSync, realpathSync, utimesSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { zstdCompressSync, constants } from 'node:zlib';
|
|
10
|
+
|
|
11
|
+
import { apply, scanFrames, readHeader, artifactPath, openInFileManager, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, SCAN_MAX_ITEMS } from '../lib/index.js';
|
|
12
|
+
|
|
13
|
+
const OPTS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
|
|
14
|
+
const DEAD = 'E:\\wsm-dead-path'; // 刻意不存在的路径(孤儿分类用)
|
|
15
|
+
let A; // 每次用例指向真实临时目录(健康会话要求 cwd 在磁盘上存在)
|
|
16
|
+
let B;
|
|
17
|
+
let root;
|
|
18
|
+
let ctx, entityA, entityB, sharedIndex, workspaceEvents;
|
|
19
|
+
|
|
20
|
+
/** 测试根目录:优先 WSM_TEST_ROOT(受限环境下 Temp 可能禁止目录重命名),否则退回系统 Temp。 */
|
|
21
|
+
function makeRoot() {
|
|
22
|
+
const candidates = [
|
|
23
|
+
process.env.WSM_TEST_ROOT ? join(process.env.WSM_TEST_ROOT, 'wsm-e2e-') : null,
|
|
24
|
+
join(tmpdir(), 'wsm-e2e-')
|
|
25
|
+
].filter(Boolean);
|
|
26
|
+
let lastErr;
|
|
27
|
+
for (const base of candidates) {
|
|
28
|
+
try { return mkdtempSync(base); } catch (err) { lastErr = err; }
|
|
29
|
+
}
|
|
30
|
+
throw lastErr;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeArtifact(header) {
|
|
34
|
+
const head = zstdCompressSync(Buffer.from(JSON.stringify(header) + '\n', 'utf8'), OPTS);
|
|
35
|
+
const ev = zstdCompressSync(Buffer.from(JSON.stringify({ seq: 1 }) + '\n', 'utf8'), OPTS);
|
|
36
|
+
return Buffer.concat([head, ev]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 模拟 dsh 工作区实体:record 快照 + 记账,attach 对已记账 id 跳过校验(官方语义)。
|
|
41
|
+
* mutate 复刻官方统一写入通道的两个关键行为:调用 fn 生成新快照后,
|
|
42
|
+
* 按「索引中的会话 cwd === 新 path」剪枝成员(对应 WorkspaceEntity.mutate);
|
|
43
|
+
* status 对应官方 missing-dir 判定。
|
|
44
|
+
*/
|
|
45
|
+
function makeEntity(id, path, hostRef) {
|
|
46
|
+
const record = { path, title: id, sessionIds: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
// 官方语义:path / title 都是读取 record 的实时 getter(mutate 换路径/标题后立即生效)
|
|
50
|
+
get path() { return record.path; },
|
|
51
|
+
get title() { return record.title; },
|
|
52
|
+
get record() { return record; },
|
|
53
|
+
get sessionIds() { return [...record.sessionIds].filter((sid) => hostRef.sessionPath(String(sid)) === record.path); },
|
|
54
|
+
attached: [],
|
|
55
|
+
detached: [],
|
|
56
|
+
failAttach: false,
|
|
57
|
+
async status() { return existsSync(record.path) ? 'ok' : 'missing-dir'; },
|
|
58
|
+
async mutate(fn) {
|
|
59
|
+
// 官方语义:整体换新快照(本插件借此同步改写 path/title),随后按索引剪枝成员
|
|
60
|
+
const changed = fn(record);
|
|
61
|
+
const pruned = changed.sessionIds.filter((sid) => hostRef.sessionPath(String(sid)) === changed.path);
|
|
62
|
+
Object.assign(record, changed, {
|
|
63
|
+
sessionIds: pruned,
|
|
64
|
+
updatedAt: new Date().toISOString()
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
async attachSession(sid) {
|
|
68
|
+
if (this.failAttach) throw new Error('simulated attach failure');
|
|
69
|
+
// 官方语义:已记账时 attach 是 no-op,mutate 不发 domain/changed。
|
|
70
|
+
if (record.sessionIds.includes(sid)) return;
|
|
71
|
+
// 官方语义:未记账 id 需通过「头部 cwd 规范化后 === path」校验,
|
|
72
|
+
// 并经 rememberSessionPath 回填三张索引后才挂账
|
|
73
|
+
const header = readHeader(readFileSync(findArtifact(sid)));
|
|
74
|
+
let canonical;
|
|
75
|
+
try { canonical = realpathSync(header.cwd); } catch {
|
|
76
|
+
throw new Error(`cwd does not resolve: '${header.cwd}'`);
|
|
77
|
+
}
|
|
78
|
+
if (canonical.toLowerCase() !== record.path.toLowerCase()) throw new Error(`cwd resolves elsewhere: '${header.cwd}'`);
|
|
79
|
+
sharedIndex.headers.set(String(sid), header);
|
|
80
|
+
sharedIndex.sessionPaths.set(String(sid), canonical);
|
|
81
|
+
sharedIndex.invalidSessionPaths.delete(String(sid));
|
|
82
|
+
this.attached.push(sid);
|
|
83
|
+
record.sessionIds.unshift(sid);
|
|
84
|
+
hostRef.onWorkspaceChanged?.(id, record);
|
|
85
|
+
},
|
|
86
|
+
async detachSession(sid) {
|
|
87
|
+
if (!record.sessionIds.includes(sid)) return;
|
|
88
|
+
this.detached.push(sid);
|
|
89
|
+
record.sessionIds = record.sessionIds.filter((x) => x !== sid);
|
|
90
|
+
hostRef.onWorkspaceChanged?.(id, record);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 按存储布局约定在磁盘上找某会话的档案位置。 */
|
|
96
|
+
function findArtifact(sessionId) {
|
|
97
|
+
for (const proj of readdirSync(root)) {
|
|
98
|
+
if (!(proj.startsWith('--') && proj.endsWith('--'))) continue;
|
|
99
|
+
const dir = join(root, proj, sessionId);
|
|
100
|
+
if (!existsSync(dir)) continue;
|
|
101
|
+
const names = readdirSync(dir)
|
|
102
|
+
.filter((name) => name === 'session.jsonl.zstd' || /^session\.v[1-9][0-9]*\.jsonl\.zstd$/.test(name))
|
|
103
|
+
.sort((a, b) => {
|
|
104
|
+
const va = a === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(a)[1]);
|
|
105
|
+
const vb = b === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(b)[1]);
|
|
106
|
+
return vb - va;
|
|
107
|
+
});
|
|
108
|
+
if (names.length) return join(dir, names[0]);
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`artifact for ${sessionId} not found`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
// 生产环境中 cwd 一律是 realpath 规范化后的 canonical 路径(官方注册表
|
|
115
|
+
// 的唯一性约定);macOS 的 Temp 位于 /var → /private/var 符号链接下,
|
|
116
|
+
// 夹具若用未规范化路径会与 canonical 比对失配,因此创建后立即归一。
|
|
117
|
+
root = realpathSync(makeRoot());
|
|
118
|
+
A = join(root, 'proj-alpha');
|
|
119
|
+
B = join(root, 'proj-beta');
|
|
120
|
+
mkdirSync(A, { recursive: true });
|
|
121
|
+
mkdirSync(B, { recursive: true });
|
|
122
|
+
process.env.DSH_HOME = root; // 备份重定向
|
|
123
|
+
|
|
124
|
+
// 会话 session-aaa 归属 A(真实 zstd 帧结构)
|
|
125
|
+
const srcArtifact = artifactPath(root, A, 'session-aaa');
|
|
126
|
+
mkdirSync(dirname(srcArtifact), { recursive: true });
|
|
127
|
+
mkdirSync(dirname(artifactPath(root, B, 'placeholder')), { recursive: true });
|
|
128
|
+
writeFileSync(srcArtifact, makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, title: 'Alpha discussion' }));
|
|
129
|
+
|
|
130
|
+
// 三张内存索引先于实体创建(实体 mutate 的成员剪枝按此判定);
|
|
131
|
+
// 官方实体持有的是 { sessionPath(id) } 形态的宿主引用
|
|
132
|
+
sharedIndex = { headers: new Map(), sessionPaths: new Map(), invalidSessionPaths: new Map() };
|
|
133
|
+
workspaceEvents = [];
|
|
134
|
+
const hostRef = {
|
|
135
|
+
...sharedIndex,
|
|
136
|
+
sessionPath: (id) => sharedIndex.sessionPaths.get(String(id)),
|
|
137
|
+
onWorkspaceChanged: (workspaceId, record) => workspaceEvents.push({
|
|
138
|
+
workspaceId,
|
|
139
|
+
sessionIds: [...record.sessionIds]
|
|
140
|
+
})
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
entityA = makeEntity('wid-a', A, hostRef);
|
|
144
|
+
entityB = makeEntity('wid-b', B, hostRef);
|
|
145
|
+
const registry = {
|
|
146
|
+
...sharedIndex,
|
|
147
|
+
entities: new Map([['wid-a', entityA], ['wid-b', entityB]]),
|
|
148
|
+
get(id) { return this.entities.get(id); },
|
|
149
|
+
list() { return [...this.entities.values()]; }
|
|
150
|
+
};
|
|
151
|
+
const persistence = {
|
|
152
|
+
root,
|
|
153
|
+
async list() {
|
|
154
|
+
// 动态扫描 root(与真实后端一致):遍历 projectKey 目录读头部
|
|
155
|
+
const out = [];
|
|
156
|
+
if (!existsSync(root)) return out;
|
|
157
|
+
for (const proj of readdirSync(root)) {
|
|
158
|
+
if (!(proj.startsWith('--') && proj.endsWith('--'))) continue;
|
|
159
|
+
const projPath = join(root, proj);
|
|
160
|
+
for (const idDir of readdirSync(projPath)) {
|
|
161
|
+
const dir = join(projPath, idDir);
|
|
162
|
+
const names = existsSync(dir) ? readdirSync(dir).filter((name) => name === 'session.jsonl.zstd' || /^session\.v[1-9][0-9]*\.jsonl\.zstd$/.test(name)) : [];
|
|
163
|
+
if (!names.length) continue;
|
|
164
|
+
names.sort((a, b) => {
|
|
165
|
+
const va = a === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(a)[1]);
|
|
166
|
+
const vb = b === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(b)[1]);
|
|
167
|
+
return vb - va;
|
|
168
|
+
});
|
|
169
|
+
const f = join(dir, names[0]);
|
|
170
|
+
try { out.push(readHeader(readFileSync(f))); } catch { /* skip */ }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
let rpcHandler = null;
|
|
177
|
+
ctx = {
|
|
178
|
+
workspaceRegistry: registry,
|
|
179
|
+
sessionPersistence: persistence,
|
|
180
|
+
logger: { info() {}, warn() {} },
|
|
181
|
+
get: () => undefined,
|
|
182
|
+
connection: { rpc: { handle(_ch, h) { rpcHandler = h; return () => {}; } } }
|
|
183
|
+
};
|
|
184
|
+
ctx.__rpc = () => rpcHandler;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
afterEach(() => {
|
|
188
|
+
delete process.env.DSH_HOME;
|
|
189
|
+
rmSync(root, { recursive: true, force: true });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
async function call(endpoint, payload) {
|
|
193
|
+
return await ctx.__rpc()(endpoint, payload ?? {});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
test('mover.status 就绪', async () => {
|
|
197
|
+
apply(ctx);
|
|
198
|
+
const res = await call('mover.status');
|
|
199
|
+
assert.equal(res.ok, true);
|
|
200
|
+
assert.equal(res.value.ready, true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('正常迁移:文件物理搬移 + 头部改写 + 双向记账 + 有备份', async () => {
|
|
204
|
+
apply(ctx);
|
|
205
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
206
|
+
const before = readFileSync(artifactPath(root, A, 'session-aaa'));
|
|
207
|
+
|
|
208
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
209
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
210
|
+
assert.equal(res.value.moved, true);
|
|
211
|
+
assert.equal(res.value.title, 'Alpha discussion');
|
|
212
|
+
assert.equal(res.value.cacheInvalidated, true);
|
|
213
|
+
|
|
214
|
+
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')), 'source artifact gone');
|
|
215
|
+
const movedBuf = readFileSync(artifactPath(root, B, 'session-aaa'));
|
|
216
|
+
assert.equal(readHeader(movedBuf).cwd, B);
|
|
217
|
+
|
|
218
|
+
// 其余帧字节保留
|
|
219
|
+
const fo = scanFrames(before), fn = scanFrames(movedBuf);
|
|
220
|
+
assert.equal(fn.length, fo.length);
|
|
221
|
+
for (let i = 1; i < fo.length; i++) {
|
|
222
|
+
assert.ok(before.subarray(fo[i][0], fo[i][1]).equals(movedBuf.subarray(fn[i][0], fn[i][1])), `frame ${i} preserved`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 记账
|
|
226
|
+
assert.deepEqual(entityB.attached, ['session-aaa']);
|
|
227
|
+
assert.deepEqual(entityA.detached, ['session-aaa']);
|
|
228
|
+
assert.deepEqual(entityA.sessionIds, []);
|
|
229
|
+
|
|
230
|
+
// 备份落在 $DSH_HOME/workspace-mover/backups
|
|
231
|
+
const backups = join(root, 'workspace-mover', 'backups');
|
|
232
|
+
assert.ok(existsSync(backups));
|
|
233
|
+
assert.ok(readdirSync(backups).some((f) => f.startsWith('session-aaa')));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
test('批量迁移常驻会话通过官方 attach 事件立即更新目标工作区', async () => {
|
|
238
|
+
apply(ctx);
|
|
239
|
+
const ids = ['session-aaa', 'session-bbb'];
|
|
240
|
+
const secondArtifact = artifactPath(root, A, 'session-bbb');
|
|
241
|
+
mkdirSync(dirname(secondArtifact), { recursive: true });
|
|
242
|
+
writeFileSync(secondArtifact, makeArtifact({ type: 'session', id: 'session-bbb', cwd: A, title: 'Beta discussion' }));
|
|
243
|
+
entityA.record.sessionIds.push(...ids);
|
|
244
|
+
for (const id of ids) {
|
|
245
|
+
sharedIndex.headers.set(id, { id, cwd: A });
|
|
246
|
+
sharedIndex.sessionPaths.set(id, A);
|
|
247
|
+
}
|
|
248
|
+
const live = new Map(ids.map((id) => [id, {
|
|
249
|
+
header: Object.freeze({ id, cwd: A })
|
|
250
|
+
}]));
|
|
251
|
+
ctx.get = (key) => {
|
|
252
|
+
if (key === 'sessions') return { get: (id) => live.get(String(id)) };
|
|
253
|
+
if (key === 'agents') return { get: () => ({ status: 'idle' }) };
|
|
254
|
+
return undefined;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const res = await call('mover.moveMany', {
|
|
258
|
+
sessions: ids.map((sessionId) => ({ sessionId })),
|
|
259
|
+
targetWorkspaceId: 'wid-b'
|
|
260
|
+
});
|
|
261
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
262
|
+
assert.equal(res.value.movedCount, 2);
|
|
263
|
+
assert.equal(entityB.record.sessionIds.length, 2);
|
|
264
|
+
const targetEvents = workspaceEvents.filter((event) => event.workspaceId === 'wid-b');
|
|
265
|
+
assert.equal(targetEvents.length, 2, 'each official attach emits a target workspace change');
|
|
266
|
+
assert.deepEqual(new Set(targetEvents.at(-1).sessionIds), new Set(ids));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('DSH 0.1.5 persistence.list 快照形状可迁移', async () => {
|
|
270
|
+
apply(ctx);
|
|
271
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
272
|
+
const originalList = ctx.sessionPersistence.list;
|
|
273
|
+
ctx.sessionPersistence.list = async () => (await originalList.call(ctx.sessionPersistence)).map((header) => ({
|
|
274
|
+
header,
|
|
275
|
+
revision: 'test-revision',
|
|
276
|
+
sizeBytes: 0
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
280
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
281
|
+
assert.equal(res.value.moved, true);
|
|
282
|
+
assert.equal(readHeader(readFileSync(artifactPath(root, B, 'session-aaa'))).cwd, B);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('移动历史:可查询并一键撤回到原工作区', async () => {
|
|
286
|
+
apply(ctx);
|
|
287
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
288
|
+
|
|
289
|
+
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
290
|
+
assert.equal(moved.ok, true, JSON.stringify(moved));
|
|
291
|
+
assert.ok(moved.value.historyId);
|
|
292
|
+
|
|
293
|
+
const listed = await call('mover.history');
|
|
294
|
+
assert.equal(listed.ok, true);
|
|
295
|
+
assert.equal(listed.value.items[0].id, moved.value.historyId);
|
|
296
|
+
assert.equal(listed.value.items[0].sourceWorkspaceId, 'wid-a');
|
|
297
|
+
assert.equal(listed.value.items[0].targetWorkspaceId, 'wid-b');
|
|
298
|
+
assert.equal(listed.value.items[0].title, 'Alpha discussion');
|
|
299
|
+
|
|
300
|
+
const undone = await call('mover.undo', { historyId: moved.value.historyId });
|
|
301
|
+
assert.equal(undone.ok, true, JSON.stringify(undone));
|
|
302
|
+
assert.equal(undone.value.undone, true);
|
|
303
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')));
|
|
304
|
+
assert.ok(!existsSync(artifactPath(root, B, 'session-aaa')));
|
|
305
|
+
assert.deepEqual(entityA.sessionIds, ['session-aaa']);
|
|
306
|
+
assert.deepEqual(entityB.sessionIds, []);
|
|
307
|
+
|
|
308
|
+
const after = await call('mover.history');
|
|
309
|
+
assert.deepEqual(after.value.items, []);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('同工作区移动被拒绝', async () => {
|
|
313
|
+
apply(ctx);
|
|
314
|
+
entityA.record.sessionIds.push('session-aaa'); // 已记账:真·重复移动
|
|
315
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-a' });
|
|
316
|
+
assert.equal(res.ok, false);
|
|
317
|
+
assert.match(res.error.message, /already belongs/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('磁盘已属目标但未记账时自动补账(自愈)', async () => {
|
|
321
|
+
apply(ctx);
|
|
322
|
+
// cwd=A、无人记账的会话拖到 wid-a(它自己的真实归属路径)
|
|
323
|
+
const dir = artifactPath(root, A, 'session-selfheal');
|
|
324
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
325
|
+
writeFileSync(dir, makeArtifact({ type: 'session', id: 'session-selfheal', cwd: A }));
|
|
326
|
+
|
|
327
|
+
const res = await call('mover.move', { sessionId: 'session-selfheal', targetWorkspaceId: 'wid-a' });
|
|
328
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
329
|
+
assert.equal(res.value.attached, true);
|
|
330
|
+
assert.equal(res.value.moved, false);
|
|
331
|
+
assert.ok(entityA.attached.includes('session-selfheal'), 'accounting repaired');
|
|
332
|
+
assert.ok(existsSync(dir), 'file untouched');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('未知目标工作区被拒绝', async () => {
|
|
336
|
+
apply(ctx);
|
|
337
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'nope' });
|
|
338
|
+
assert.equal(res.ok, false);
|
|
339
|
+
assert.match(res.error.message, /unknown workspace/i);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('attach 失败 → 自动回滚到源目录并重新挂账', async () => {
|
|
343
|
+
apply(ctx);
|
|
344
|
+
entityB.failAttach = true;
|
|
345
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
346
|
+
const originalBytes = readFileSync(artifactPath(root, A, 'session-aaa'));
|
|
347
|
+
|
|
348
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
349
|
+
assert.equal(res.ok, false);
|
|
350
|
+
assert.match(res.error.message, /rolled back cleanly/i);
|
|
351
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact restored at source');
|
|
352
|
+
assert.ok(!existsSync(artifactPath(root, B, 'session-aaa')), 'destination cleaned up');
|
|
353
|
+
assert.deepEqual(readFileSync(artifactPath(root, A, 'session-aaa')), originalBytes, 'bytes identical after rollback');
|
|
354
|
+
assert.deepEqual(entityB.attached, []);
|
|
355
|
+
assert.ok(entityA.attached.includes('session-aaa'), 're-attached to source');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('常驻内存但空闲的会话允许迁移:刷新索引 + 官方 attach 记账 + 清理持久化状态', async () => {
|
|
359
|
+
apply(ctx);
|
|
360
|
+
const staleState = { meta: { cwd: A }, cursor: 1, owner: {} };
|
|
361
|
+
ctx.sessionPersistence.coordinator = { states: new Map([['session-aaa', staleState]]) };
|
|
362
|
+
// 模拟注册表三张索引里的旧 cwd 缓存(宿主启动时由 live header 写入)
|
|
363
|
+
ctx.workspaceRegistry.headers.set('session-aaa', { id: 'session-aaa', cwd: A });
|
|
364
|
+
ctx.workspaceRegistry.sessionPaths.set('session-aaa', A);
|
|
365
|
+
ctx.get = (key) => {
|
|
366
|
+
if (key === 'agents') return { get: () => ({ status: 'idle' }) };
|
|
367
|
+
if (key === 'sessions') return { get: () => ({ id: 'session-aaa', header: { cwd: A } }) };
|
|
368
|
+
return undefined;
|
|
369
|
+
};
|
|
370
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
371
|
+
|
|
372
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
373
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
374
|
+
assert.ok(!ctx.sessionPersistence.coordinator.states.has('session-aaa'), 'stale state deleted so host re-adopts from disk');
|
|
375
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
|
|
376
|
+
// 官方 attach 生效:目标实体持有会话,索引指向新路径
|
|
377
|
+
assert.ok(entityB.record.sessionIds.includes('session-aaa'), 'membership attached through official path');
|
|
378
|
+
assert.equal(entityB.attached.includes('session-aaa'), true, 'attach still called to persist membership');
|
|
379
|
+
assert.equal(ctx.workspaceRegistry.sessionPaths.get('session-aaa'), B, 'sessionPaths points at target');
|
|
380
|
+
assert.equal(ctx.workspaceRegistry.headers.get('session-aaa').cwd, B, 'headers cache refreshed');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('常驻会话 attach 失败时还原 live header 并回滚', async () => {
|
|
384
|
+
apply(ctx);
|
|
385
|
+
entityB.failAttach = true;
|
|
386
|
+
ctx.get = (key) => {
|
|
387
|
+
if (key === 'agents') return { get: () => ({ status: 'idle' }) };
|
|
388
|
+
if (key === 'sessions') return { get: () => ({ id: 'session-aaa', header: { cwd: A } }) };
|
|
389
|
+
return undefined;
|
|
390
|
+
};
|
|
391
|
+
ctx.workspaceRegistry.headers.set('session-aaa', { id: 'session-aaa', cwd: A });
|
|
392
|
+
ctx.workspaceRegistry.sessionPaths.set('session-aaa', A);
|
|
393
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
394
|
+
|
|
395
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
396
|
+
assert.equal(res.ok, false);
|
|
397
|
+
assert.match(res.error.message, /rolled back cleanly/i);
|
|
398
|
+
// 预置已撤销
|
|
399
|
+
assert.ok(!entityB.record.sessionIds.includes('session-aaa'), 'target membership absent after rollback');
|
|
400
|
+
assert.equal(ctx.workspaceRegistry.sessionPaths.get('session-aaa'), A, 'prior sessionPath restored');
|
|
401
|
+
assert.equal(ctx.workspaceRegistry.headers.get('session-aaa').cwd, A, 'prior header restored');
|
|
402
|
+
// 文件与源记账恢复
|
|
403
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact restored at source');
|
|
404
|
+
assert.ok(entityA.attached.includes('session-aaa'), 're-attached to source');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('回合进行中的会话被拒绝', async () => {
|
|
408
|
+
apply(ctx);
|
|
409
|
+
ctx.get = (key) => {
|
|
410
|
+
if (key === 'agents') return { get: () => ({ status: 'running' }) };
|
|
411
|
+
return undefined;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
415
|
+
assert.equal(res.ok, false);
|
|
416
|
+
assert.match(res.error.message, /currently running/);
|
|
417
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'nothing moved');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ---- v0.3 孤儿救援:扫描分类 + 批量修复 ----
|
|
421
|
+
|
|
422
|
+
test('mover.scan 分类 orphaned / unregistered / ok,并检出幽灵记账', async () => {
|
|
423
|
+
apply(ctx);
|
|
424
|
+
// ok:A 下已注册的 session-aaa(beforeEach 已建);生产环境启动期会把健康成员写入索引
|
|
425
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
426
|
+
sharedIndex.headers.set('session-aaa', { id: 'session-aaa', cwd: A });
|
|
427
|
+
sharedIndex.sessionPaths.set('session-aaa', realpathSync(A));
|
|
428
|
+
// unregistered:cwd=A 有效但无人记账
|
|
429
|
+
const unregDir = artifactPath(root, A, 'session-unreg');
|
|
430
|
+
mkdirSync(dirname(unregDir), { recursive: true });
|
|
431
|
+
writeFileSync(unregDir, makeArtifact({ type: 'session', id: 'session-unreg', cwd: A }));
|
|
432
|
+
// orphaned:cwd 指向已不存在的目录
|
|
433
|
+
const orphanDir = artifactPath(root, DEAD, 'session-orphan');
|
|
434
|
+
mkdirSync(dirname(orphanDir), { recursive: true });
|
|
435
|
+
writeFileSync(orphanDir, makeArtifact({ type: 'session', id: 'session-orphan', cwd: DEAD }));
|
|
436
|
+
// ghost:记账了但磁盘无档案
|
|
437
|
+
entityB.record.sessionIds.push('session-ghost');
|
|
438
|
+
|
|
439
|
+
const res = await call('mover.scan', {});
|
|
440
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
441
|
+
const byId = Object.fromEntries(res.value.items.map((it) => [it.sessionId, it]));
|
|
442
|
+
assert.equal(byId['session-aaa'].status, 'ok');
|
|
443
|
+
assert.equal(byId['session-unreg'].status, 'unregistered');
|
|
444
|
+
assert.equal(byId['session-unreg'].targetWorkspaceId, 'wid-a');
|
|
445
|
+
assert.equal(byId['session-orphan'].status, 'orphaned');
|
|
446
|
+
assert.deepEqual(res.value.ghosts, [{ workspaceId: 'wid-b', sessionId: 'session-ghost' }]);
|
|
447
|
+
assert.equal(res.value.scanned, 3);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('mover.repair attach:原地补记账到路径匹配的工作区', async () => {
|
|
451
|
+
apply(ctx);
|
|
452
|
+
const unregDir = artifactPath(root, A, 'session-unreg');
|
|
453
|
+
mkdirSync(dirname(unregDir), { recursive: true });
|
|
454
|
+
writeFileSync(unregDir, makeArtifact({ type: 'session', id: 'session-unreg', cwd: A }));
|
|
455
|
+
|
|
456
|
+
const res = await call('mover.repair', { actions: [{ sessionId: 'session-unreg', kind: 'attach' }] });
|
|
457
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
458
|
+
assert.equal(res.value.results[0].ok, true);
|
|
459
|
+
assert.equal(res.value.results[0].attachedTo, 'wid-a');
|
|
460
|
+
assert.ok(entityA.attached.includes('session-unreg'));
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---- v0.7 误放会话:misfiled 分类 + 一键归位 ----
|
|
464
|
+
|
|
465
|
+
test('mover.scan 识别误放会话:记账在 A、目录属于 B', async () => {
|
|
466
|
+
apply(ctx);
|
|
467
|
+
// 磁盘文件与头部 cwd 都指向 B(文件本就在正确位置),但记账挂在 A 名下
|
|
468
|
+
const misDir = artifactPath(root, B, 'session-mis');
|
|
469
|
+
mkdirSync(dirname(misDir), { recursive: true });
|
|
470
|
+
writeFileSync(misDir, makeArtifact({ type: 'session', id: 'session-mis', cwd: B, title: 'Misfiled talk' }));
|
|
471
|
+
entityA.record.sessionIds.push('session-mis');
|
|
472
|
+
|
|
473
|
+
const res = await call('mover.scan', {});
|
|
474
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
475
|
+
const it = res.value.items.find((i) => i.sessionId === 'session-mis');
|
|
476
|
+
assert.equal(it.status, 'misfiled');
|
|
477
|
+
assert.equal(it.homeWorkspaceId, 'wid-b');
|
|
478
|
+
assert.equal(it.homeTitle, 'wid-b');
|
|
479
|
+
assert.equal(it.homePath, B);
|
|
480
|
+
assert.deepEqual(it.ownerWorkspaceIds, ['wid-a']);
|
|
481
|
+
assert.equal(res.value.counts.misfiled, 1);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('mover.repair home:从错误记账方摘账并补到正确分组(文件不动)', async () => {
|
|
485
|
+
apply(ctx);
|
|
486
|
+
const misDir = artifactPath(root, B, 'session-mis');
|
|
487
|
+
mkdirSync(dirname(misDir), { recursive: true });
|
|
488
|
+
writeFileSync(misDir, makeArtifact({ type: 'session', id: 'session-mis', cwd: B, title: 'Misfiled talk' }));
|
|
489
|
+
entityA.record.sessionIds.push('session-mis');
|
|
490
|
+
|
|
491
|
+
const res = await call('mover.repair', { actions: [{ sessionId: 'session-mis', kind: 'home' }] });
|
|
492
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
493
|
+
const r = res.value.results[0];
|
|
494
|
+
assert.equal(r.ok, true);
|
|
495
|
+
assert.equal(r.homedTo, 'wid-b');
|
|
496
|
+
assert.ok(!entityA.record.sessionIds.includes('session-mis'), '错误记账已摘除');
|
|
497
|
+
assert.ok(entityB.record.sessionIds.includes('session-mis'), '正确分组已补账');
|
|
498
|
+
assert.ok(existsSync(misDir), '磁盘文件原样不动');
|
|
499
|
+
// 归位后再扫描应归类为 ok
|
|
500
|
+
const scan = await call('mover.scan', {});
|
|
501
|
+
assert.equal(scan.value.items.find((i) => i.sessionId === 'session-mis').status, 'ok');
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('mover.repair home:双重记账时摘掉所有错误方,只留正确分组', async () => {
|
|
505
|
+
apply(ctx);
|
|
506
|
+
const misDir = artifactPath(root, B, 'session-mis');
|
|
507
|
+
mkdirSync(dirname(misDir), { recursive: true });
|
|
508
|
+
writeFileSync(misDir, makeArtifact({ type: 'session', id: 'session-mis', cwd: B }));
|
|
509
|
+
entityA.record.sessionIds.push('session-mis');
|
|
510
|
+
entityB.record.sessionIds.push('session-mis');
|
|
511
|
+
|
|
512
|
+
const res = await call('mover.repair', { actions: [{ sessionId: 'session-mis', kind: 'home' }] });
|
|
513
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
514
|
+
assert.equal(res.value.results[0].ok, true);
|
|
515
|
+
assert.ok(!entityA.record.sessionIds.includes('session-mis'), '多余记账已摘除');
|
|
516
|
+
assert.ok(entityB.record.sessionIds.includes('session-mis'), '正确分组保持记账');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('mover.repair relink:失联会话换路径真迁移(复用迁移管线)', async () => {
|
|
520
|
+
apply(ctx);
|
|
521
|
+
const orphanDir = artifactPath(root, DEAD, 'session-orphan');
|
|
522
|
+
mkdirSync(dirname(orphanDir), { recursive: true });
|
|
523
|
+
writeFileSync(orphanDir, makeArtifact({ type: 'session', id: 'session-orphan', cwd: DEAD }));
|
|
524
|
+
|
|
525
|
+
const res = await call('mover.repair', {
|
|
526
|
+
actions: [{ sessionId: 'session-orphan', kind: 'relink', targetWorkspaceId: 'wid-b' }]
|
|
527
|
+
});
|
|
528
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
529
|
+
assert.equal(res.value.results[0].ok, true);
|
|
530
|
+
assert.equal(res.value.results[0].to, B);
|
|
531
|
+
assert.ok(!existsSync(orphanDir), 'old artifact gone');
|
|
532
|
+
const movedBuf = readFileSync(artifactPath(root, B, 'session-orphan'));
|
|
533
|
+
assert.equal(readHeader(movedBuf).cwd, B);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('mover.repair:attach 无匹配工作区时报错、未知 kind 报错、坏批次整体拒绝', async () => {
|
|
537
|
+
apply(ctx);
|
|
538
|
+
const noWs = await call('mover.repair', { actions: [{ sessionId: 'session-nofile', kind: 'attach' }] });
|
|
539
|
+
assert.equal(noWs.ok, true); // 批量端点逐条返回结果而非整包失败
|
|
540
|
+
assert.equal(noWs.value.results[0].ok, false);
|
|
541
|
+
|
|
542
|
+
const badKind = await call('mover.repair', { actions: [{ sessionId: 'x', kind: 'wat' }] });
|
|
543
|
+
assert.equal(badKind.value.results[0].ok, false);
|
|
544
|
+
assert.match(badKind.value.results[0].error, /unknown kind/);
|
|
545
|
+
|
|
546
|
+
const tooMany = await call('mover.repair', { actions: Array.from({ length: 51 }, (_, i) => ({ sessionId: `s${i}`, kind: 'attach' })) });
|
|
547
|
+
assert.equal(tooMany.ok, false);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// ---- v0.5 工作区搬家向导:体检 + 原地重定向 ----
|
|
551
|
+
|
|
552
|
+
/** 模拟「用户已在磁盘把项目文件夹改名」的现实场景,并登记一个已记账成员。 */
|
|
553
|
+
function setupMovedFolder() {
|
|
554
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
555
|
+
sharedIndex.headers.set('session-aaa', { id: 'session-aaa', cwd: A, title: 'Alpha discussion' });
|
|
556
|
+
sharedIndex.sessionPaths.set('session-aaa', realpathSync(A));
|
|
557
|
+
const moved = A + '-moved';
|
|
558
|
+
renameSync(A, moved);
|
|
559
|
+
return moved;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
test('mover.ws.audit:官方 status 判定 missing-dir,记账数取原始名单', async () => {
|
|
563
|
+
apply(ctx);
|
|
564
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
565
|
+
renameSync(A, A + '-moved');
|
|
566
|
+
|
|
567
|
+
const res = await call('mover.ws.audit');
|
|
568
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
569
|
+
const byId = Object.fromEntries(res.value.items.map((it) => [it.workspaceId, it]));
|
|
570
|
+
assert.equal(byId['wid-a'].status, 'missing-dir');
|
|
571
|
+
assert.equal(byId['wid-a'].path, A, '失效路径原样暴露给体检面板');
|
|
572
|
+
assert.equal(byId['wid-a'].memberCount, 1, '原始记账数(含索引已失联的成员)');
|
|
573
|
+
assert.equal(byId['wid-b'].status, 'ok');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('搬家向导:dryRun 只盘点;执行后换 path、成员原地迁移、散件补挂账', async () => {
|
|
577
|
+
apply(ctx);
|
|
578
|
+
const moved = setupMovedFolder();
|
|
579
|
+
// 一只从未被记账的散件也留在旧路径(cwd=A 且目录已被搬走)
|
|
580
|
+
const strayDir = artifactPath(root, A, 'session-stray');
|
|
581
|
+
mkdirSync(dirname(strayDir), { recursive: true });
|
|
582
|
+
writeFileSync(strayDir, makeArtifact({ type: 'session', id: 'session-stray', cwd: A }));
|
|
583
|
+
|
|
584
|
+
const dry = await call('mover.repoint', { workspaceId: 'wid-a', newPath: moved, dryRun: true });
|
|
585
|
+
assert.equal(dry.ok, true, JSON.stringify(dry));
|
|
586
|
+
assert.equal(dry.value.count, 2);
|
|
587
|
+
assert.deepEqual(dry.value.items.map((i) => i.sessionId).sort(), ['session-aaa', 'session-stray']);
|
|
588
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'dryRun 不动任何文件');
|
|
589
|
+
|
|
590
|
+
const res = await call('mover.repoint', { workspaceId: 'wid-a', newPath: moved });
|
|
591
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
592
|
+
assert.equal(res.value.pathUpdated, true);
|
|
593
|
+
assert.equal(res.value.movedCount, 2);
|
|
594
|
+
assert.deepEqual(res.value.skipped, []);
|
|
595
|
+
|
|
596
|
+
const canon = realpathSync(moved);
|
|
597
|
+
assert.equal(entityA.record.path, canon, '注册表路径已重定向(工作区 id 不变)');
|
|
598
|
+
assert.ok(entityA.record.sessionIds.includes('session-aaa'), '成员通过 mutate 剪枝存活(索引预置生效)');
|
|
599
|
+
assert.ok(entityA.record.sessionIds.includes('session-stray'), '散件补挂账进同一工作区');
|
|
600
|
+
|
|
601
|
+
const newArtifact = readFileSync(artifactPath(root, canon, 'session-aaa'));
|
|
602
|
+
assert.equal(readHeader(newArtifact).cwd, canon, '头部 cwd 改写为新家');
|
|
603
|
+
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')), '旧位置档案清空');
|
|
604
|
+
|
|
605
|
+
const after = await call('mover.ws.audit');
|
|
606
|
+
const row = after.value.items.find((it) => it.workspaceId === 'wid-a');
|
|
607
|
+
assert.equal(row.status, 'ok', '搬家后体检转绿');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test('新路径被其他工作区占用时拒绝,且不动任何文件', async () => {
|
|
611
|
+
apply(ctx);
|
|
612
|
+
setupMovedFolder();
|
|
613
|
+
const before = readFileSync(artifactPath(root, A, 'session-aaa'));
|
|
614
|
+
|
|
615
|
+
const res = await call('mover.repoint', { workspaceId: 'wid-a', newPath: B });
|
|
616
|
+
assert.equal(res.ok, false);
|
|
617
|
+
assert.match(res.error.message, /占用/);
|
|
618
|
+
assert.equal(entityA.record.path, A, 'path 未变');
|
|
619
|
+
assert.deepEqual(readFileSync(artifactPath(root, A, 'session-aaa')), before, '字节未动');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('宿主实体缺少 mutate 写入通道时整体中止并还原索引快照', async () => {
|
|
623
|
+
apply(ctx);
|
|
624
|
+
setupMovedFolder();
|
|
625
|
+
const priorHeader = sharedIndex.headers.get('session-aaa');
|
|
626
|
+
delete entityA.mutate;
|
|
627
|
+
|
|
628
|
+
const res = await call('mover.repoint', { workspaceId: 'wid-a', newPath: A + '-moved' });
|
|
629
|
+
assert.equal(res.ok, false);
|
|
630
|
+
assert.match(res.error.message, /mutate/);
|
|
631
|
+
assert.ok(!/[\\/]proj-alpha-moved[\\/]/.test(findArtifact('session-aaa')), '文件留在原地');
|
|
632
|
+
assert.equal(sharedIndex.headers.get('session-aaa'), priorHeader, 'headers 快照还原');
|
|
633
|
+
assert.equal(
|
|
634
|
+
String(sharedIndex.sessionPaths.get('session-aaa')).toLowerCase(),
|
|
635
|
+
A.toLowerCase(),
|
|
636
|
+
'sessionPaths 还原为改名前的 canonical(realpath 与原路径一致)'
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('逐会话失败只影响自己:目标撞车者跳过,其余完成;清理障碍后续跑收尾', async () => {
|
|
641
|
+
apply(ctx);
|
|
642
|
+
const moved = setupMovedFolder();
|
|
643
|
+
entityA.record.sessionIds.push('session-blocker');
|
|
644
|
+
mkdirSync(dirname(artifactPath(root, A, 'session-blocker')), { recursive: true });
|
|
645
|
+
writeFileSync(artifactPath(root, A, 'session-blocker'),
|
|
646
|
+
makeArtifact({ type: 'session', id: 'session-blocker', cwd: A }));
|
|
647
|
+
sharedIndex.headers.set('session-blocker', { id: 'session-blocker', cwd: A });
|
|
648
|
+
sharedIndex.sessionPaths.set('session-blocker', A); // canonical 即改名前的原路径
|
|
649
|
+
// 预先在新家放置同名档案制造撞车
|
|
650
|
+
mkdirSync(dirname(artifactPath(root, moved, 'session-blocker')), { recursive: true });
|
|
651
|
+
writeFileSync(artifactPath(root, moved, 'session-blocker'), makeArtifact({ type: 'session', id: 'x', cwd: moved }));
|
|
652
|
+
|
|
653
|
+
const first = await call('mover.repoint', { workspaceId: 'wid-a', fromPath: A, newPath: moved });
|
|
654
|
+
assert.equal(first.ok, true, JSON.stringify(first));
|
|
655
|
+
assert.deepEqual(first.value.moved.map((m) => m.sessionId), ['session-aaa'], '健康成员照常迁移');
|
|
656
|
+
assert.equal(first.value.skipped.length, 1);
|
|
657
|
+
assert.equal(first.value.skipped[0].sessionId, 'session-blocker');
|
|
658
|
+
assert.match(first.value.skipped[0].error, /已有同名档案/);
|
|
659
|
+
|
|
660
|
+
rmSync(dirname(artifactPath(root, moved, 'session-blocker')), { recursive: true, force: true });
|
|
661
|
+
|
|
662
|
+
const second = await call('mover.repoint', { workspaceId: 'wid-a', fromPath: A, newPath: moved });
|
|
663
|
+
assert.equal(second.ok, true, JSON.stringify(second));
|
|
664
|
+
assert.equal(second.value.pathUpdated, false, 'resume 模式不再重复改写 path');
|
|
665
|
+
assert.equal(second.value.movedCount, 1);
|
|
666
|
+
assert.equal(second.value.moved[0].sessionId, 'session-blocker');
|
|
667
|
+
assert.ok(existsSync(artifactPath(root, realpathSync(moved), 'session-blocker')));
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('进行中的会话整批跳过;path 已换好,空闲后携原路径续跑即可收尾', async () => {
|
|
671
|
+
apply(ctx);
|
|
672
|
+
setupMovedFolder();
|
|
673
|
+
ctx.get = (key) => (key === 'agents' ? { get: () => ({ status: 'running' }) } : undefined);
|
|
674
|
+
|
|
675
|
+
const dry = await call('mover.repoint', { workspaceId: 'wid-a', newPath: A + '-moved', dryRun: true });
|
|
676
|
+
assert.equal(dry.value.count, 1, '盘点仍如实报告总数');
|
|
677
|
+
|
|
678
|
+
const res = await call('mover.repoint', { workspaceId: 'wid-a', newPath: A + '-moved' });
|
|
679
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
680
|
+
assert.equal(res.value.movedCount, 0);
|
|
681
|
+
assert.equal(res.value.skipped[0].sessionId, 'session-aaa');
|
|
682
|
+
assert.match(res.value.skipped[0].error, /进行中/);
|
|
683
|
+
assert.equal(entityA.record.path, realpathSync(A + '-moved'), 'path 换好了;等会话空闲后再续跑清扫');
|
|
684
|
+
assert.ok(entityA.record.sessionIds.includes('session-aaa'), '进行中成员不被剪枝清掉(预置覆盖全部受影响会话)');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// ---- v0.6 批量迁移:mover.moveMany ----
|
|
688
|
+
|
|
689
|
+
test('moveMany:多个会话一次迁移到同一目标,逐条返回结果与汇总', async () => {
|
|
690
|
+
apply(ctx);
|
|
691
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
692
|
+
// 同组再来一个会话
|
|
693
|
+
const dir2 = artifactPath(root, A, 'session-bbb');
|
|
694
|
+
mkdirSync(dirname(dir2), { recursive: true });
|
|
695
|
+
writeFileSync(dir2, makeArtifact({ type: 'session', id: 'session-bbb', cwd: A, title: 'Beta talk' }));
|
|
696
|
+
|
|
697
|
+
const res = await call('mover.moveMany', {
|
|
698
|
+
sessions: [
|
|
699
|
+
{ sessionId: 'session-aaa', sessionTitle: 'Alpha discussion' },
|
|
700
|
+
{ sessionId: 'session-bbb' }
|
|
701
|
+
],
|
|
702
|
+
targetWorkspaceId: 'wid-b'
|
|
703
|
+
});
|
|
704
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
705
|
+
assert.equal(res.value.movedCount, 2);
|
|
706
|
+
assert.equal(res.value.failedCount, 0);
|
|
707
|
+
assert.deepEqual(res.value.results.map((r) => r.sessionId).sort(), ['session-aaa', 'session-bbb']);
|
|
708
|
+
assert.ok(res.value.results.every((r) => r.ok));
|
|
709
|
+
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')));
|
|
710
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-bbb')));
|
|
711
|
+
assert.equal(readHeader(readFileSync(artifactPath(root, B, 'session-bbb'))).cwd, B);
|
|
712
|
+
// 历史聚合成一条批量记录(不再逐条落账),可整批撤回
|
|
713
|
+
assert.ok(res.value.historyId);
|
|
714
|
+
const history = await call('mover.history');
|
|
715
|
+
assert.equal(history.value.items.length, 1);
|
|
716
|
+
const batchEntry = history.value.items[0];
|
|
717
|
+
assert.equal(batchEntry.id, res.value.historyId);
|
|
718
|
+
assert.equal(batchEntry.batch, true);
|
|
719
|
+
assert.equal(batchEntry.targetWorkspaceId, 'wid-b');
|
|
720
|
+
assert.equal(batchEntry.to, B);
|
|
721
|
+
assert.equal(batchEntry.sessions.length, 2);
|
|
722
|
+
const bySession = Object.fromEntries(batchEntry.sessions.map((s) => [s.sessionId, s]));
|
|
723
|
+
assert.equal(bySession['session-aaa'].sourceWorkspaceId, 'wid-a');
|
|
724
|
+
assert.equal(bySession['session-aaa'].from, A);
|
|
725
|
+
assert.equal(bySession['session-bbb'].sourceWorkspaceId, 'wid-a');
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test('moveMany:坏会话只影响自己,其余照常完成', async () => {
|
|
729
|
+
apply(ctx);
|
|
730
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
731
|
+
const res = await call('mover.moveMany', {
|
|
732
|
+
sessions: [
|
|
733
|
+
{ sessionId: 'session-ghost' }, // 不存在
|
|
734
|
+
{ sessionId: 'session-aaa' }
|
|
735
|
+
],
|
|
736
|
+
targetWorkspaceId: 'wid-b'
|
|
737
|
+
});
|
|
738
|
+
assert.equal(res.ok, true);
|
|
739
|
+
assert.equal(res.value.movedCount, 1);
|
|
740
|
+
assert.equal(res.value.failedCount, 1);
|
|
741
|
+
const byId = Object.fromEntries(res.value.results.map((r) => [r.sessionId, r]));
|
|
742
|
+
assert.equal(byId['session-ghost'].ok, false);
|
|
743
|
+
assert.match(byId['session-ghost'].error, /not found/);
|
|
744
|
+
assert.equal(byId['session-aaa'].ok, true);
|
|
745
|
+
// 有失败项时只把成功的聚合成一条
|
|
746
|
+
const history = await call('mover.history');
|
|
747
|
+
assert.equal(history.value.items.length, 1);
|
|
748
|
+
assert.deepEqual(history.value.items[0].sessions.map((s) => s.sessionId), ['session-aaa']);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('moveMany 历史聚合成一条:整批一键撤回到各自来源', async () => {
|
|
752
|
+
apply(ctx);
|
|
753
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
754
|
+
const dir2 = artifactPath(root, A, 'session-bbb');
|
|
755
|
+
mkdirSync(dirname(dir2), { recursive: true });
|
|
756
|
+
writeFileSync(dir2, makeArtifact({ type: 'session', id: 'session-bbb', cwd: A, title: 'Beta talk' }));
|
|
757
|
+
|
|
758
|
+
const res = await call('mover.moveMany', {
|
|
759
|
+
sessions: [{ sessionId: 'session-aaa' }, { sessionId: 'session-bbb' }],
|
|
760
|
+
targetWorkspaceId: 'wid-b'
|
|
761
|
+
});
|
|
762
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
763
|
+
assert.ok(res.value.historyId);
|
|
764
|
+
|
|
765
|
+
const undone = await call('mover.undo', { historyId: res.value.historyId });
|
|
766
|
+
assert.equal(undone.ok, true, JSON.stringify(undone));
|
|
767
|
+
assert.equal(undone.value.undone, true);
|
|
768
|
+
assert.equal(undone.value.batch, true);
|
|
769
|
+
assert.equal(undone.value.undoneCount, 2);
|
|
770
|
+
assert.equal(undone.value.failedCount, 0);
|
|
771
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'session-aaa 回到 A');
|
|
772
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-bbb')), 'session-bbb 回到 A');
|
|
773
|
+
assert.ok(!existsSync(artifactPath(root, B, 'session-aaa')));
|
|
774
|
+
assert.ok(!existsSync(artifactPath(root, B, 'session-bbb')));
|
|
775
|
+
assert.ok(entityA.sessionIds.includes('session-aaa'));
|
|
776
|
+
assert.ok(entityA.sessionIds.includes('session-bbb'));
|
|
777
|
+
const after = await call('mover.history');
|
|
778
|
+
assert.deepEqual(after.value.items, [], '整批撤回后记录清空');
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test('批量撤回部分失败:来源已失效的会话保留在记录中,其余照常撤回', async () => {
|
|
782
|
+
apply(ctx);
|
|
783
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
784
|
+
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
785
|
+
assert.equal(moved.ok, true, JSON.stringify(moved));
|
|
786
|
+
|
|
787
|
+
// 直接构造一条批量历史:一个来源有效(wid-a),一个来源工作区已不存在(wid-gone)
|
|
788
|
+
const historyDir = join(root, 'workspace-mover');
|
|
789
|
+
mkdirSync(historyDir, { recursive: true });
|
|
790
|
+
writeFileSync(join(historyDir, 'history.json'), JSON.stringify([{
|
|
791
|
+
id: 'batch-crafted',
|
|
792
|
+
batch: true,
|
|
793
|
+
targetWorkspaceId: 'wid-b',
|
|
794
|
+
to: B,
|
|
795
|
+
movedAt: new Date().toISOString(),
|
|
796
|
+
sessions: [
|
|
797
|
+
{ sessionId: 'session-aaa', title: 'Alpha discussion', from: A, sourceWorkspaceId: 'wid-a' },
|
|
798
|
+
{ sessionId: 'session-gone', title: 'Lost', from: A, sourceWorkspaceId: 'wid-gone' }
|
|
799
|
+
]
|
|
800
|
+
}], null, 2));
|
|
801
|
+
|
|
802
|
+
const undone = await call('mover.undo', { historyId: 'batch-crafted' });
|
|
803
|
+
assert.equal(undone.ok, true, JSON.stringify(undone));
|
|
804
|
+
assert.equal(undone.value.undoneCount, 1);
|
|
805
|
+
assert.equal(undone.value.failedCount, 1);
|
|
806
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), '有效来源的会话被撤回');
|
|
807
|
+
// 记录保留失败项,可再次撤回
|
|
808
|
+
const after = await call('mover.history');
|
|
809
|
+
assert.equal(after.value.items.length, 1);
|
|
810
|
+
assert.deepEqual(after.value.items[0].sessions.map((s) => s.sessionId), ['session-gone']);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test('moveMany:空列表 / 超上限 / 缺 target 整批拒绝', async () => {
|
|
814
|
+
apply(ctx);
|
|
815
|
+
const empty = await call('mover.moveMany', { sessions: [], targetWorkspaceId: 'wid-b' });
|
|
816
|
+
assert.equal(empty.ok, false);
|
|
817
|
+
const tooMany = await call('mover.moveMany', {
|
|
818
|
+
sessions: Array.from({ length: 51 }, (_, i) => ({ sessionId: `s${i}` })),
|
|
819
|
+
targetWorkspaceId: 'wid-b'
|
|
820
|
+
});
|
|
821
|
+
assert.equal(tooMany.ok, false);
|
|
822
|
+
const noTarget = await call('mover.moveMany', { sessions: [{ sessionId: 'session-aaa' }] });
|
|
823
|
+
assert.equal(noTarget.ok, false);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// ---- v0.5.1 三项热修回归 ----
|
|
827
|
+
|
|
828
|
+
test('标题同步:默认名(=旧文件夹名)跟随改名,自定义标题原样保留', async () => {
|
|
829
|
+
apply(ctx);
|
|
830
|
+
setupMovedFolder();
|
|
831
|
+
const moved = A + '-moved';
|
|
832
|
+
|
|
833
|
+
entityA.record.title = 'proj-alpha'; // 官方 create 的默认值 = 文件夹名 → 应跟随改为 proj-alpha-moved
|
|
834
|
+
await call('mover.repoint', { workspaceId: 'wid-a', newPath: moved });
|
|
835
|
+
assert.equal(entityA.record.title, 'proj-alpha-moved', '默认名同步为新文件夹名');
|
|
836
|
+
|
|
837
|
+
apply(ctx);
|
|
838
|
+
setupMovedFolder2();
|
|
839
|
+
entityA.record.title = '我的项目'; // 用户自定义过 → 不动
|
|
840
|
+
await call('mover.repoint', { workspaceId: 'wid-a', newPath: A + '-moved' });
|
|
841
|
+
assert.equal(entityA.record.title, '我的项目', '自定义标题保留');
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
/** 独立夹具:避免用例间复用同一目录名导致的前置污染。 */
|
|
845
|
+
function setupMovedFolder2() {
|
|
846
|
+
const dir = join(root, 'ws-t2');
|
|
847
|
+
mkdirSync(dir, { recursive: true });
|
|
848
|
+
const artifact = artifactPath(root, dir, 'session-t2');
|
|
849
|
+
mkdirSync(dirname(artifact), { recursive: true });
|
|
850
|
+
writeFileSync(artifact, makeArtifact({ type: 'session', id: 'session-t2', cwd: dir }));
|
|
851
|
+
const ent = makeEntity('wid-t2', dir, { ...sharedIndex, sessionPath: (id) => sharedIndex.sessionPaths.get(String(id)) });
|
|
852
|
+
ctx.workspaceRegistry.entities.set('wid-t2', ent);
|
|
853
|
+
sharedIndex.sessionPaths.set('session-t2', realpathSync(dir));
|
|
854
|
+
renameSync(dir, dir + '-moved');
|
|
855
|
+
return dir;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
test('常驻会话热修:冻结头原地换新、@ 搜索缓存按旧根清除、投影检查点身份对齐', async () => {
|
|
859
|
+
apply(ctx);
|
|
860
|
+
const moved = setupMovedFolder();
|
|
861
|
+
|
|
862
|
+
const liveHeader = Object.freeze({ id: 'session-aaa', cwd: A });
|
|
863
|
+
const liveSession = { header: liveHeader };
|
|
864
|
+
let disposed = 0;
|
|
865
|
+
const agent = { session: liveSession };
|
|
866
|
+
const searches = new Map([[agent, { root: A, dispose() { disposed += 1; } }]]);
|
|
867
|
+
const applied = [];
|
|
868
|
+
const projTable = {
|
|
869
|
+
async update(id, fn) {
|
|
870
|
+
const next = fn({ identity: { createdAt: 1720000000000, cwd: A }, rows: { k: { ver: 1, seq: 5, val: {} } } });
|
|
871
|
+
applied.push([id, next]);
|
|
872
|
+
return next;
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
ctx.get = (key) => {
|
|
876
|
+
if (key === 'agents') return { get: () => ({ status: 'idle' }) };
|
|
877
|
+
if (key === 'sessions') return { get: (id) => (String(id) === 'session-aaa' ? liveSession : undefined) };
|
|
878
|
+
if (key === 'fileReferences') return { searches };
|
|
879
|
+
if (key === 'sessionProjectionCache') return { table: projTable };
|
|
880
|
+
return undefined;
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
const res = await call('mover.repoint', { workspaceId: 'wid-a', newPath: moved });
|
|
884
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
885
|
+
|
|
886
|
+
const canon = realpathSync(moved);
|
|
887
|
+
assert.notEqual(liveSession.header, liveHeader, 'live 头已替换而非原地改写');
|
|
888
|
+
assert.equal(liveSession.header.cwd, canon, 'live 头指向新家');
|
|
889
|
+
assert.ok(Object.isFrozen(liveSession.header), '替换后的头保持官方冻结语义');
|
|
890
|
+
assert.equal(disposed, 1, '以旧路径为根的 @ 搜索被 dispose');
|
|
891
|
+
assert.ok(!searches.has(agent), '缓存条目删除,下次 @ 按新头重建');
|
|
892
|
+
assert.equal(applied.length, 1);
|
|
893
|
+
assert.equal(applied[0][0], 'session-aaa');
|
|
894
|
+
assert.equal(applied[0][1].identity.cwd, canon, '投影检查点 identity.cwd 对齐');
|
|
895
|
+
assert.equal(applied[0][1].identity.createdAt, 1720000000000, 'createdAt 不变(同一日志生命周期)');
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test('宿主缺少 fileReferences/sessionProjectionCache 服务时热修静默降级', async () => {
|
|
899
|
+
apply(ctx);
|
|
900
|
+
setupMovedFolder();
|
|
901
|
+
ctx.get = () => undefined;
|
|
902
|
+
|
|
903
|
+
const res = await call('mover.repoint', { workspaceId: 'wid-a', newPath: A + '-moved' });
|
|
904
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
905
|
+
assert.equal(res.value.movedCount, 1);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
//#region v0.8:空分组原始记账 / 归档管理 / 打开文件夹
|
|
909
|
+
|
|
910
|
+
/** 模拟官方 registry 的持久状态写通道(archiveSession 同款 enqueueOperation + setState)。 */
|
|
911
|
+
function stubRegistryState() {
|
|
912
|
+
const registry = ctx.workspaceRegistry;
|
|
913
|
+
registry.requireState = () => ({
|
|
914
|
+
initialized: true,
|
|
915
|
+
workspaceIds: [...registry.entities.keys()],
|
|
916
|
+
archivedSessionIds: registry.archivedSessionIds
|
|
917
|
+
});
|
|
918
|
+
registry.setState = async (next) => { registry.archivedSessionIds = next.archivedSessionIds; };
|
|
919
|
+
registry.enqueueOperation = (fn) => fn();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
test('mover.workspaces 暴露原始记账数:归档与幽灵成员都算数(空分组判定的依据)', async () => {
|
|
923
|
+
apply(ctx);
|
|
924
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
925
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
926
|
+
// B 记账了两个幽灵 id(磁盘无档案)+ 一个归档 id
|
|
927
|
+
entityB.record.sessionIds.push('ghost-1', 'ghost-2', 'archived-1');
|
|
928
|
+
ctx.workspaceRegistry.archivedSessionIds = ['archived-1'];
|
|
929
|
+
|
|
930
|
+
const res = await call('mover.workspaces');
|
|
931
|
+
assert.equal(res.ok, true);
|
|
932
|
+
const a = res.value.items.find((it) => it.workspaceId === 'wid-a');
|
|
933
|
+
const b = res.value.items.find((it) => it.workspaceId === 'wid-b');
|
|
934
|
+
assert.equal(a.rawSessionCount, 1);
|
|
935
|
+
assert.equal(b.rawSessionCount, 3);
|
|
936
|
+
// 公开 getter 会按索引过滤幽灵 id —— 这正是要用原始记账判定空分组的原因
|
|
937
|
+
assert.deepEqual(b.sessionIds, []);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
test('mover.archived 列出归档会话:归属取自原始记账,cwd 挂错时给出归位建议', async () => {
|
|
941
|
+
apply(ctx);
|
|
942
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
943
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
944
|
+
// session-bbb:文件在 B、记账在 A(挂错)→ 归档后建议归位到 B
|
|
945
|
+
const bArtifact = artifactPath(root, B, 'session-bbb');
|
|
946
|
+
mkdirSync(dirname(bArtifact), { recursive: true });
|
|
947
|
+
writeFileSync(bArtifact, makeArtifact({ type: 'session', id: 'session-bbb', cwd: B, title: 'Beta talk' }));
|
|
948
|
+
sharedIndex.sessionPaths.set('session-bbb', B);
|
|
949
|
+
entityA.record.sessionIds.push('session-bbb');
|
|
950
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa', 'session-bbb'];
|
|
951
|
+
|
|
952
|
+
const res = await call('mover.archived');
|
|
953
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
954
|
+
const items = res.value.items;
|
|
955
|
+
assert.equal(items.length, 2);
|
|
956
|
+
const aaa = items.find((it) => it.sessionId === 'session-aaa');
|
|
957
|
+
assert.equal(aaa.title, 'Alpha discussion');
|
|
958
|
+
assert.equal(aaa.ownerWorkspaceId, 'wid-a');
|
|
959
|
+
assert.equal(aaa.suggestedWorkspaceId, null);
|
|
960
|
+
const bbb = items.find((it) => it.sessionId === 'session-bbb');
|
|
961
|
+
assert.equal(bbb.ownerWorkspaceId, 'wid-a');
|
|
962
|
+
assert.equal(bbb.suggestedWorkspaceId, 'wid-b');
|
|
963
|
+
assert.equal(bbb.suggestedTitle, 'wid-b');
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test('mover.unarchive 取消归档:归档集移除、账本不动、无迁移', async () => {
|
|
967
|
+
apply(ctx);
|
|
968
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
969
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa', 'session-other'];
|
|
970
|
+
stubRegistryState();
|
|
971
|
+
|
|
972
|
+
const res = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
973
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
974
|
+
assert.equal(res.value.unarchived, true);
|
|
975
|
+
assert.equal(res.value.moved, null);
|
|
976
|
+
assert.deepEqual(ctx.workspaceRegistry.archivedSessionIds, ['session-other']);
|
|
977
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa']);
|
|
978
|
+
assert.equal(entityA.attached.length, 0);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test('mover.unarchive 带目标且 ≠ 归属:先出归档集再走完整迁移(备份 + 可撤销)', async () => {
|
|
982
|
+
apply(ctx);
|
|
983
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
984
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
985
|
+
stubRegistryState();
|
|
986
|
+
|
|
987
|
+
const res = await call('mover.unarchive', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
988
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
989
|
+
assert.equal(res.value.moved.moved, true);
|
|
990
|
+
assert.deepEqual(ctx.workspaceRegistry.archivedSessionIds, []);
|
|
991
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-aaa')), 'artifact moved to B');
|
|
992
|
+
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')), 'source artifact gone');
|
|
993
|
+
assert.ok(existsSync(join(root, 'workspace-mover', 'backups')), 'backup created');
|
|
994
|
+
|
|
995
|
+
const listed = await call('mover.history');
|
|
996
|
+
assert.equal(listed.value.items.length, 1);
|
|
997
|
+
const undone = await call('mover.undo', { historyId: listed.value.items[0].id });
|
|
998
|
+
assert.equal(undone.ok, true);
|
|
999
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')));
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test('mover.unarchive 拒绝:未归档的会话;registry 缺状态写通道', async () => {
|
|
1003
|
+
apply(ctx);
|
|
1004
|
+
const miss = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1005
|
+
assert.equal(miss.ok, false);
|
|
1006
|
+
assert.match(miss.error.message, /not archived/);
|
|
1007
|
+
|
|
1008
|
+
// 归档集里有它,但 registry 没有官方写通道(老版本宿主容错)
|
|
1009
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1010
|
+
const res = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1011
|
+
assert.equal(res.ok, false);
|
|
1012
|
+
assert.match(res.error.message, /no state mutation API/);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
test('mover.openFolder 只允许已注册工作区路径;openInFileManager 按平台拼装命令', async () => {
|
|
1016
|
+
apply(ctx);
|
|
1017
|
+
// 未注册路径拒绝(不触发任何 spawn)
|
|
1018
|
+
const stranger = join(root, 'stranger');
|
|
1019
|
+
mkdirSync(stranger, { recursive: true });
|
|
1020
|
+
const bad = await call('mover.openFolder', { path: stranger });
|
|
1021
|
+
assert.equal(bad.ok, false);
|
|
1022
|
+
assert.match(bad.error.message, /does not belong/);
|
|
1023
|
+
|
|
1024
|
+
// 注册了但目录已消失同样拒绝
|
|
1025
|
+
const ghost = makeEntity('wid-ghost', join(root, 'ghost-dir'), { ...sharedIndex, sessionPath: (id) => sharedIndex.sessionPaths.get(String(id)) });
|
|
1026
|
+
ctx.workspaceRegistry.entities.set('wid-ghost', ghost);
|
|
1027
|
+
const missing = await call('mover.openFolder', { workspaceId: 'wid-ghost' });
|
|
1028
|
+
assert.equal(missing.ok, false);
|
|
1029
|
+
assert.match(missing.error.message, /does not exist/);
|
|
1030
|
+
|
|
1031
|
+
// 平台命令拼装(spawn 注入 fake,不真开窗口):Windows = explorer 开窗 + 延时 AppActivate 置前
|
|
1032
|
+
const calls = [];
|
|
1033
|
+
const fakeChild = { unref() {} };
|
|
1034
|
+
const cmd = openInFileManager(A, (command, args, opts) => { calls.push([command, args, opts]); return fakeChild; });
|
|
1035
|
+
if (process.platform === 'win32') {
|
|
1036
|
+
assert.equal(cmd, 'explorer.exe');
|
|
1037
|
+
assert.deepEqual(calls[0][0], String.raw`C:\Windows\explorer.exe`);
|
|
1038
|
+
assert.deepEqual(calls[0][1], [A]);
|
|
1039
|
+
assert.deepEqual(calls[0][2], { detached: true, stdio: 'ignore' });
|
|
1040
|
+
// 约 0.8s 后用 WScript AppActivate 按标题(目录名)拉前台;单引号翻倍转义
|
|
1041
|
+
await new Promise((r) => setTimeout(r, 950));
|
|
1042
|
+
assert.equal(calls.length, 2, 'expected the delayed activate spawn');
|
|
1043
|
+
assert.equal(calls[1][0], 'powershell.exe');
|
|
1044
|
+
assert.match(calls[1][1][2], /AppActivate\('proj-alpha'\)/);
|
|
1045
|
+
assert.equal(openInFileManager("E:\\od'd IR", () => fakeChild), 'explorer.exe');
|
|
1046
|
+
} else {
|
|
1047
|
+
const expected = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
1048
|
+
assert.equal(cmd, expected);
|
|
1049
|
+
assert.deepEqual(calls[0][0], expected);
|
|
1050
|
+
assert.deepEqual(calls[0][1], [A]);
|
|
1051
|
+
assert.deepEqual(calls[0][2], { detached: true, stdio: 'ignore' });
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
//#region v0.8.1:moveDir 事务性 + 备份前缀收紧
|
|
1056
|
+
|
|
1057
|
+
test('moveDir:嵌套子目录递归搬运完整', () => {
|
|
1058
|
+
const src = join(root, 'md-src');
|
|
1059
|
+
const dst = join(root, 'md-dst');
|
|
1060
|
+
mkdirSync(join(src, 'extra', 'sub'), { recursive: true });
|
|
1061
|
+
writeFileSync(join(src, 'a.zstd'), Buffer.from('top'));
|
|
1062
|
+
writeFileSync(join(src, 'extra', 'b.txt'), Buffer.from('nested'));
|
|
1063
|
+
writeFileSync(join(src, 'extra', 'sub', 'c.txt'), Buffer.from('deep'));
|
|
1064
|
+
const how = moveDir(src, dst);
|
|
1065
|
+
assert.ok(how === 'rename' || how === 'copy');
|
|
1066
|
+
assert.ok(!existsSync(src), 'source removed');
|
|
1067
|
+
assert.equal(readFileSync(join(dst, 'a.zstd'), 'utf8'), 'top');
|
|
1068
|
+
assert.equal(readFileSync(join(dst, 'extra', 'b.txt'), 'utf8'), 'nested');
|
|
1069
|
+
assert.equal(readFileSync(join(dst, 'extra', 'sub', 'c.txt'), 'utf8'), 'deep');
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test('moveDir:非空目标目录拒绝覆盖,两侧零损伤(不误删外来内容)', () => {
|
|
1073
|
+
const src = join(root, 'md-src2');
|
|
1074
|
+
const dst = join(root, 'md-dst2');
|
|
1075
|
+
mkdirSync(src, { recursive: true });
|
|
1076
|
+
writeFileSync(join(src, 'session.jsonl.zstd'), Buffer.from('payload'));
|
|
1077
|
+
// 预置非空目标目录(外来内容或进程中断残骸)→ 必须拒绝且双方都原样保留
|
|
1078
|
+
mkdirSync(dst, { recursive: true });
|
|
1079
|
+
writeFileSync(join(dst, 'foreign.bin'), Buffer.from('not-mine'));
|
|
1080
|
+
assert.throws(() => moveDir(src, dst, { renameImpl: () => false }), /not empty/);
|
|
1081
|
+
assert.equal(readFileSync(join(src, 'session.jsonl.zstd'), 'utf8'), 'payload', 'source untouched');
|
|
1082
|
+
assert.equal(readFileSync(join(dst, 'foreign.bin'), 'utf8'), 'not-mine', 'foreign content untouched');
|
|
1083
|
+
// 目标为空目录则允许兜底复制(空目录无数据可损失)
|
|
1084
|
+
rmSync(join(dst, 'foreign.bin'));
|
|
1085
|
+
assert.equal(moveDir(src, dst, { renameImpl: () => false }), 'copy');
|
|
1086
|
+
assert.ok(!existsSync(src), 'source removed');
|
|
1087
|
+
assert.equal(readFileSync(join(dst, 'session.jsonl.zstd'), 'utf8'), 'payload');
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
test('moveDir:复制失败只清理本次创建的目标目录,源完好,重试成功', () => {
|
|
1091
|
+
const src = join(root, 'md-src3');
|
|
1092
|
+
const dst = join(root, 'md-dst3');
|
|
1093
|
+
mkdirSync(src, { recursive: true });
|
|
1094
|
+
writeFileSync(join(src, 'f.txt'), Buffer.from('data'));
|
|
1095
|
+
let calls = 0;
|
|
1096
|
+
assert.throws(
|
|
1097
|
+
() => moveDir(src, dst, { renameImpl: () => false, copyImpl: () => { calls++; throw new Error('simulated copy failure'); } }),
|
|
1098
|
+
/simulated copy failure/
|
|
1099
|
+
);
|
|
1100
|
+
assert.equal(calls, 1);
|
|
1101
|
+
assert.ok(existsSync(join(src, 'f.txt')), 'source untouched');
|
|
1102
|
+
assert.ok(!existsSync(dst), 'half-built destination removed');
|
|
1103
|
+
// 重试(默认真实复制,rename 兜底路径)成功,磁盘零残留
|
|
1104
|
+
moveDir(src, dst, { renameImpl: () => false });
|
|
1105
|
+
assert.equal(readFileSync(join(dst, 'f.txt'), 'utf8'), 'data');
|
|
1106
|
+
assert.ok(!existsSync(src));
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test('moveDir:rename 抛 EEXIST(撞上已有目标)落入复制兜底', () => {
|
|
1110
|
+
const src = join(root, 'md-src4');
|
|
1111
|
+
const dst = join(root, 'md-dst4');
|
|
1112
|
+
mkdirSync(src, { recursive: true });
|
|
1113
|
+
writeFileSync(join(src, 'f.txt'), Buffer.from('x'));
|
|
1114
|
+
const how = moveDir(src, dst, { renameImpl: () => { const e = new Error('exists'); e.code = 'EEXIST'; throw e; } });
|
|
1115
|
+
assert.equal(how, 'copy');
|
|
1116
|
+
assert.equal(readFileSync(join(dst, 'f.txt'), 'utf8'), 'x');
|
|
1117
|
+
assert.ok(!existsSync(src));
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
test('迁移流程级:残留目标目录给出明确错误,清理后同一流程重试成功', async () => {
|
|
1121
|
+
apply(ctx);
|
|
1122
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1123
|
+
// 预置"进程中断残骸":目标会话目录非空但无 artifact(moveSession 的 dstArtifact 守卫放行,moveDir 拒绝)
|
|
1124
|
+
const dstArtifact = artifactPath(root, B, 'session-aaa');
|
|
1125
|
+
mkdirSync(dirname(dstArtifact), { recursive: true });
|
|
1126
|
+
writeFileSync(join(dirname(dstArtifact), 'partial.bin'), Buffer.from('leftover'));
|
|
1127
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1128
|
+
assert.equal(res.ok, false);
|
|
1129
|
+
assert.match(res.error.message, /not empty/);
|
|
1130
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'source intact after refusal');
|
|
1131
|
+
// 清理残骸(错误信息指引的手动动作)后:同一迁移流程重试成功
|
|
1132
|
+
rmSync(dirname(dstArtifact), { recursive: true, force: true });
|
|
1133
|
+
const res2 = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1134
|
+
assert.equal(res2.ok, true, JSON.stringify(res2));
|
|
1135
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
|
|
1136
|
+
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')));
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test('stashBackup:相邻前缀 id 的备份互不误删(前缀收紧)', () => {
|
|
1140
|
+
const a = 'session-aaa';
|
|
1141
|
+
const b = 'session-aaa-1'; // a 的前缀超集;'-' < '.' 使其排序在 a 的所有备份之前(最旧)
|
|
1142
|
+
const dir = join(root, 'workspace-mover', 'backups');
|
|
1143
|
+
mkdirSync(dir, { recursive: true });
|
|
1144
|
+
writeFileSync(join(dir, `${b}.500.zstd`), Buffer.from('other'));
|
|
1145
|
+
for (let i = 1000; i < 1021; i++) writeFileSync(join(dir, `${a}.${i}.zstd`), Buffer.from('v' + i)); // 预置 21 份
|
|
1146
|
+
stashBackup(a, Buffer.from('fresh')); // 再入 1 份 → 触发裁剪
|
|
1147
|
+
const files = readdirSync(dir);
|
|
1148
|
+
assert.equal(files.filter((f) => f.startsWith(`${b}.`)).length, 1, '相邻前缀 id 的备份不被误删');
|
|
1149
|
+
assert.equal(files.filter((f) => f.startsWith(`${a}.`)).length, 20, 'a 恰好保留最近 20 份');
|
|
1150
|
+
// 同毫秒连续 stash:文件名相同被原子覆盖(last-wins),不报错、不破坏裁剪计数
|
|
1151
|
+
stashBackup(a, Buffer.from('same-ms-1'));
|
|
1152
|
+
stashBackup(a, Buffer.from('same-ms-2'));
|
|
1153
|
+
const files2 = readdirSync(dir);
|
|
1154
|
+
assert.equal(files2.filter((f) => f.startsWith(`${a}.`)).length, 20, '同毫秒覆盖不改变保留计数');
|
|
1155
|
+
const newest = files2.filter((f) => f.startsWith(`${a}.`)).sort().at(-1);
|
|
1156
|
+
assert.equal(readFileSync(join(dir, newest)).toString(), 'same-ms-2', '最新内容 last-wins');
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
//#region v0.9:回收站 + 备份管理
|
|
1160
|
+
|
|
1161
|
+
/** 投影缓存 mock:插件在服务缺省时降级为 no-op,需要验证标题行为时挂上。
|
|
1162
|
+
* 镜像真实底层 KvTable 契约:put(key, value) 两参、value 为完整 {identity, rows},
|
|
1163
|
+
* 并做最小 shape 校验——防止把服务层三参签名误用到底层表(域重开时才会爆的真实事故)。 */
|
|
1164
|
+
function stubProjection(record) {
|
|
1165
|
+
const assertRecordShape = (value) => {
|
|
1166
|
+
if (!value || typeof value !== 'object') throw new Error('record not-object');
|
|
1167
|
+
if (!value.identity || typeof value.identity.createdAt !== 'number') throw new Error('record.identity invalid');
|
|
1168
|
+
if (!value.rows || typeof value.rows !== 'object') throw new Error('record.rows must be a record (got undefined)');
|
|
1169
|
+
for (const [rk, rv] of Object.entries(value.rows)) {
|
|
1170
|
+
if (typeof rv.ver !== 'number' || typeof rv.seq !== 'number' || !('val' in rv)) throw new Error(`row '${rk}' invalid`);
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
const table = {
|
|
1174
|
+
records: new Map(record ? [['session-aaa', record]] : []),
|
|
1175
|
+
get(key) { return this.records.get(String(key)) ?? null; },
|
|
1176
|
+
async put(key, value) { assertRecordShape(value); this.records.set(String(key), value); },
|
|
1177
|
+
async delete(key) { return this.records.delete(String(key)); },
|
|
1178
|
+
async update(key, fn) {
|
|
1179
|
+
const rec = this.records.get(String(key));
|
|
1180
|
+
if (!rec) throw new Error(`missing-key ${key}`);
|
|
1181
|
+
const next = fn(rec);
|
|
1182
|
+
assertRecordShape(next);
|
|
1183
|
+
this.records.set(String(key), next);
|
|
1184
|
+
return next;
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
const prevGet = ctx.get;
|
|
1188
|
+
ctx.get = (key) => { if (key === 'sessionProjectionCache') return { table }; return prevGet ? prevGet(key) : undefined; };
|
|
1189
|
+
return table;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
test('mover.session.delete:移入回收站并完成四件套清理', async () => {
|
|
1193
|
+
apply(ctx);
|
|
1194
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1195
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1196
|
+
sharedIndex.headers.set('session-aaa', { id: 'session-aaa', cwd: A, title: 'Alpha discussion' });
|
|
1197
|
+
const table = stubProjection({ identity: { createdAt: 1720000000000, cwd: A }, rows: { title: { ver: 1, seq: 3, val: 'Alpha discussion' } } });
|
|
1198
|
+
|
|
1199
|
+
const res = await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1200
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1201
|
+
assert.equal(res.value.title, 'Alpha discussion');
|
|
1202
|
+
// ① 文件进回收站(原位消失)
|
|
1203
|
+
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')));
|
|
1204
|
+
const recycle = join(root, 'workspace-mover', 'recycle');
|
|
1205
|
+
const entries = readdirSync(recycle);
|
|
1206
|
+
assert.equal(entries.length, 1);
|
|
1207
|
+
assert.ok(readdirSync(join(recycle, entries[0], 'session')).some((name) => name === 'session.jsonl.zstd' || /^session\.v[1-9][0-9]*\.jsonl\.zstd$/.test(name)));
|
|
1208
|
+
// ② manifest 完整(含投影记录,供还原标题)
|
|
1209
|
+
const manifest = JSON.parse(readFileSync(join(recycle, entries[0], 'wsm-manifest.json'), 'utf8'));
|
|
1210
|
+
assert.equal(manifest.sessionId, 'session-aaa');
|
|
1211
|
+
assert.equal(manifest.cwd, A);
|
|
1212
|
+
assert.equal(manifest.ownerWorkspaceId, 'wid-a');
|
|
1213
|
+
assert.equal(manifest.projection.rows.title.val, 'Alpha discussion');
|
|
1214
|
+
// ③ 记账清空 ④ 索引清空 ⑤ 投影条目删除
|
|
1215
|
+
assert.deepEqual(entityA.record.sessionIds, []);
|
|
1216
|
+
assert.equal(sharedIndex.sessionPaths.get('session-aaa'), undefined);
|
|
1217
|
+
assert.equal(table.records.has('session-aaa'), false);
|
|
1218
|
+
// 回收站列表可见
|
|
1219
|
+
const list = await call('mover.trash.list');
|
|
1220
|
+
assert.equal(list.value.items.length, 1);
|
|
1221
|
+
assert.equal(list.value.items[0].sessionId, 'session-aaa');
|
|
1222
|
+
assert.equal(list.value.items[0].title, 'Alpha discussion');
|
|
1223
|
+
assert.equal(list.value.items[0].ownerWorkspaceId, 'wid-a');
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
test('mover.session.delete:常驻内存的会话拒绝删除', async () => {
|
|
1227
|
+
apply(ctx);
|
|
1228
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1229
|
+
ctx.get = (key) => (key === 'sessions' ? { get: () => ({ id: 'session-aaa' }) } : undefined);
|
|
1230
|
+
const res = await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1231
|
+
assert.equal(res.ok, false);
|
|
1232
|
+
assert.match(res.error.message, /resident in memory/);
|
|
1233
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'file untouched');
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test('mover.session.delete:物理移动失败时零副作用(记账/索引/投影/归档原样)', async () => {
|
|
1237
|
+
apply(ctx);
|
|
1238
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1239
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1240
|
+
sharedIndex.headers.set('session-aaa', { id: 'session-aaa', cwd: A, title: 'Alpha discussion' });
|
|
1241
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1242
|
+
stubRegistryState();
|
|
1243
|
+
const table = stubProjection({ identity: { createdAt: 1720000000000, cwd: A }, rows: { title: { ver: 1, seq: 3, val: 'Alpha discussion' } } });
|
|
1244
|
+
|
|
1245
|
+
await assert.rejects(
|
|
1246
|
+
() => deleteSessionToTrash(ctx, { sessionId: 'session-aaa' }, { moveImpl: () => { throw new Error('boom'); } }),
|
|
1247
|
+
/move to recycle failed \(nothing was changed\)/
|
|
1248
|
+
);
|
|
1249
|
+
// 零副作用:五处状态全部原样
|
|
1250
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact still in place');
|
|
1251
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa'], 'accounting intact');
|
|
1252
|
+
assert.equal(sharedIndex.sessionPaths.get('session-aaa'), A, 'index intact');
|
|
1253
|
+
assert.ok(table.records.has('session-aaa'), 'projection intact');
|
|
1254
|
+
assert.deepEqual(ctx.workspaceRegistry.archivedSessionIds, ['session-aaa'], 'archive set intact');
|
|
1255
|
+
const recycle = join(root, 'workspace-mover', 'recycle');
|
|
1256
|
+
assert.ok(!existsSync(recycle) || readdirSync(recycle).length === 0, 'no recycle entries left');
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
test('mover.session.delete:manifest 写失败时原样搬回,状态零变化', async () => {
|
|
1260
|
+
apply(ctx);
|
|
1261
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1262
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1263
|
+
const table = stubProjection({ identity: { createdAt: 1720000000000, cwd: A }, rows: { title: { ver: 1, seq: 3, val: 'Alpha discussion' } } });
|
|
1264
|
+
|
|
1265
|
+
await assert.rejects(
|
|
1266
|
+
() => deleteSessionToTrash(ctx, { sessionId: 'session-aaa' }, { writeManifestImpl: () => { throw new Error('disk full'); } }),
|
|
1267
|
+
/manifest write failed, session moved back/
|
|
1268
|
+
);
|
|
1269
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact moved back');
|
|
1270
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa'], 'accounting untouched');
|
|
1271
|
+
assert.ok(table.records.has('session-aaa'), 'projection untouched');
|
|
1272
|
+
const recycle = join(root, 'workspace-mover', 'recycle');
|
|
1273
|
+
assert.ok(!existsSync(recycle) || readdirSync(recycle).length === 0, 'no invisible recycle entries');
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
test('mover.session.delete:归档会话删除时同步移出归档集', async () => {
|
|
1277
|
+
apply(ctx);
|
|
1278
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1279
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1280
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1281
|
+
stubRegistryState();
|
|
1282
|
+
const res = await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1283
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1284
|
+
assert.deepEqual(ctx.workspaceRegistry.archivedSessionIds, []);
|
|
1285
|
+
const list = await call('mover.trash.list');
|
|
1286
|
+
assert.equal(list.value.items[0].archived, true, 'manifest 记录归档状态供还原');
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
test('mover.trash.restore:还原到原路径并回写记账、投影标题、归档集', async () => {
|
|
1290
|
+
apply(ctx);
|
|
1291
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1292
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1293
|
+
sharedIndex.headers.set('session-aaa', { id: 'session-aaa', cwd: A, title: 'Alpha discussion' });
|
|
1294
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1295
|
+
stubRegistryState();
|
|
1296
|
+
const table = stubProjection({ identity: { createdAt: 1720000000000, cwd: A }, rows: { title: { ver: 1, seq: 3, val: 'Alpha discussion' } } });
|
|
1297
|
+
await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1298
|
+
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')));
|
|
1299
|
+
|
|
1300
|
+
const res = await call('mover.trash.restore', { sessionId: 'session-aaa' });
|
|
1301
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1302
|
+
assert.equal(res.value.workspaceId, 'wid-a');
|
|
1303
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact back at A');
|
|
1304
|
+
assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).cwd, A);
|
|
1305
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa'], 're-attached to A');
|
|
1306
|
+
const rec = table.records.get('session-aaa');
|
|
1307
|
+
assert.equal(rec.identity.cwd, A, 'identity.cwd 对齐原路径');
|
|
1308
|
+
assert.equal(rec.rows.title.val, 'Alpha discussion', '标题投影回写');
|
|
1309
|
+
assert.deepEqual(ctx.workspaceRegistry.archivedSessionIds, ['session-aaa'], '归档状态还原');
|
|
1310
|
+
const list = await call('mover.trash.list');
|
|
1311
|
+
assert.equal(list.value.items.length, 0, '回收站条目清除');
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test('mover.trash.restore:指定其他分组时先回原位再走完整迁移', async () => {
|
|
1315
|
+
apply(ctx);
|
|
1316
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1317
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1318
|
+
sharedIndex.headers.set('session-aaa', { id: 'session-aaa', cwd: A, title: 'Alpha discussion' });
|
|
1319
|
+
await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1320
|
+
const res = await call('mover.trash.restore', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1321
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1322
|
+
assert.equal(res.value.moved?.moved, true, 'went through moveSession pipeline');
|
|
1323
|
+
assert.equal(readHeader(readFileSync(artifactPath(root, B, 'session-aaa'))).cwd, B);
|
|
1324
|
+
assert.deepEqual(entityB.record.sessionIds, ['session-aaa']);
|
|
1325
|
+
assert.ok(existsSync(join(root, 'workspace-mover', 'backups')), 'moveSession created a backup');
|
|
1326
|
+
const list = await call('mover.trash.list');
|
|
1327
|
+
assert.equal(list.value.items.length, 0);
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
test('mover.trash.restore:原位置无分组且未给 target 时明确报错', async () => {
|
|
1331
|
+
apply(ctx);
|
|
1332
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1333
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1334
|
+
await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1335
|
+
ctx.workspaceRegistry.entities.delete('wid-a'); // 模拟原工作区被删除
|
|
1336
|
+
const res = await call('mover.trash.restore', { sessionId: 'session-aaa' });
|
|
1337
|
+
assert.equal(res.ok, false);
|
|
1338
|
+
assert.match(res.error.message, /no registered workspace/);
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
test('mover.trash.purge:单个与清空', async () => {
|
|
1342
|
+
apply(ctx);
|
|
1343
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1344
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1345
|
+
await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1346
|
+
const artifactB = artifactPath(root, B, 'session-bbb');
|
|
1347
|
+
mkdirSync(dirname(artifactB), { recursive: true });
|
|
1348
|
+
writeFileSync(artifactB, makeArtifact({ type: 'session', id: 'session-bbb', cwd: B, title: 'Beta' }));
|
|
1349
|
+
sharedIndex.sessionPaths.set('session-bbb', B);
|
|
1350
|
+
entityB.record.sessionIds.push('session-bbb');
|
|
1351
|
+
await call('mover.session.delete', { sessionId: 'session-bbb' });
|
|
1352
|
+
let list = await call('mover.trash.list');
|
|
1353
|
+
assert.equal(list.value.items.length, 2);
|
|
1354
|
+
const one = await call('mover.trash.purge', { sessionId: 'session-bbb' });
|
|
1355
|
+
assert.equal(one.value.purged, 1);
|
|
1356
|
+
list = await call('mover.trash.list');
|
|
1357
|
+
assert.equal(list.value.items.length, 1);
|
|
1358
|
+
const all = await call('mover.trash.purge', { all: true });
|
|
1359
|
+
assert.equal(all.value.purged, 1);
|
|
1360
|
+
list = await call('mover.trash.list');
|
|
1361
|
+
assert.equal(list.value.items.length, 0);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
test('mover.backups.list:按会话聚合、排序与总量', async () => {
|
|
1365
|
+
apply(ctx);
|
|
1366
|
+
const dir = join(root, 'workspace-mover', 'backups');
|
|
1367
|
+
mkdirSync(dir, { recursive: true });
|
|
1368
|
+
writeFileSync(join(dir, 'session-aaa.1000.zstd'), Buffer.alloc(10, 1));
|
|
1369
|
+
writeFileSync(join(dir, 'session-aaa.2000.zstd'), Buffer.alloc(20, 2));
|
|
1370
|
+
writeFileSync(join(dir, 'session-bbb.1500.zstd'), Buffer.alloc(5, 3));
|
|
1371
|
+
const res = await call('mover.backups.list');
|
|
1372
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1373
|
+
assert.equal(res.value.items.length, 2);
|
|
1374
|
+
const aaa = res.value.items.find((it) => it.sessionId === 'session-aaa');
|
|
1375
|
+
assert.equal(aaa.count, 2);
|
|
1376
|
+
assert.equal(aaa.totalBytes, 30);
|
|
1377
|
+
assert.equal(aaa.oldest, 1000);
|
|
1378
|
+
assert.equal(aaa.newest, 2000);
|
|
1379
|
+
assert.equal(res.value.items[0].sessionId, 'session-aaa', 'newest 降序');
|
|
1380
|
+
assert.equal(res.value.totalBytes, 35);
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
test('mover.backups.restore:默认回原 cwd 并回读校验;注册表占用时报错', async () => {
|
|
1384
|
+
apply(ctx);
|
|
1385
|
+
rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true }); // 移除夹具:恢复前提是磁盘上无此会话
|
|
1386
|
+
const bytes = makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, title: 'Alpha discussion' });
|
|
1387
|
+
stashBackup('session-aaa', bytes);
|
|
1388
|
+
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')));
|
|
1389
|
+
const res = await call('mover.backups.restore', { sessionId: 'session-aaa' });
|
|
1390
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1391
|
+
assert.equal(res.value.workspaceId, 'wid-a');
|
|
1392
|
+
const restoredHeader = readHeader(readFileSync(artifactPath(root, A, 'session-aaa')));
|
|
1393
|
+
assert.equal(restoredHeader.cwd, A);
|
|
1394
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa']);
|
|
1395
|
+
// 同 id 已恢复 → 再次恢复被注册表冲突检查拒绝
|
|
1396
|
+
rmSync(join(root, 'workspace-mover', 'backups'), { recursive: true, force: true });
|
|
1397
|
+
stashBackup('session-aaa', bytes);
|
|
1398
|
+
const res2 = await call('mover.backups.restore', { sessionId: 'session-aaa' });
|
|
1399
|
+
assert.equal(res2.ok, false);
|
|
1400
|
+
assert.match(res2.error.message, /already exists/);
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
test('mover.backups.restore:指定其他分组时改写头 cwd 并挂账', async () => {
|
|
1404
|
+
apply(ctx);
|
|
1405
|
+
rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true }); // 移除夹具:避免 attach 校验撞上旧副本
|
|
1406
|
+
const bytes = makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, title: 'Alpha discussion' });
|
|
1407
|
+
stashBackup('session-aaa', bytes);
|
|
1408
|
+
const res = await call('mover.backups.restore', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1409
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1410
|
+
const header = readHeader(readFileSync(artifactPath(root, B, 'session-aaa')));
|
|
1411
|
+
assert.equal(header.cwd, B);
|
|
1412
|
+
assert.deepEqual(entityB.record.sessionIds, ['session-aaa']);
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
test('mover.backups.deleteOne:指定单份与全部份', async () => {
|
|
1416
|
+
apply(ctx);
|
|
1417
|
+
const dir = join(root, 'workspace-mover', 'backups');
|
|
1418
|
+
mkdirSync(dir, { recursive: true });
|
|
1419
|
+
writeFileSync(join(dir, 'session-aaa.1000.zstd'), Buffer.from('a'));
|
|
1420
|
+
stashBackup('session-aaa', Buffer.from('b'));
|
|
1421
|
+
stashBackup('session-bbb', Buffer.from('c'));
|
|
1422
|
+
const before = readdirSync(dir);
|
|
1423
|
+
assert.equal(before.filter((f) => f.startsWith('session-aaa.')).length, 2);
|
|
1424
|
+
const manual = before.find((f) => f.startsWith('session-aaa.'));
|
|
1425
|
+
const one = await call('mover.backups.deleteOne', { sessionId: 'session-aaa', fileName: manual });
|
|
1426
|
+
assert.equal(one.value.deleted, 1);
|
|
1427
|
+
const all = await call('mover.backups.deleteOne', { sessionId: 'session-aaa' });
|
|
1428
|
+
assert.equal(all.value.deleted, 1);
|
|
1429
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith('session-aaa.')).length, 0);
|
|
1430
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith('session-bbb.')).length, 1, '其他会话备份不受影响');
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
//#region v1.0:迁移后一致性校验 + 一键修复
|
|
1434
|
+
|
|
1435
|
+
test('verifyRelocatedArtifact:id/cwd 双确认,任一不符即抛错', () => {
|
|
1436
|
+
const artifact = join(root, 'verify-src', 'session.jsonl.zstd');
|
|
1437
|
+
mkdirSync(dirname(artifact), { recursive: true });
|
|
1438
|
+
writeFileSync(artifact, makeArtifact({ type: 'session', id: 'session-aaa', cwd: A, title: 'Alpha discussion' }));
|
|
1439
|
+
assert.equal(verifyRelocatedArtifact(artifact, 'session-aaa', A), true);
|
|
1440
|
+
assert.throws(() => verifyRelocatedArtifact(artifact, 'session-zzz', A), /id mismatch/);
|
|
1441
|
+
assert.throws(() => verifyRelocatedArtifact(artifact, 'session-aaa', B), /cwd mismatch/);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
test('mover.move:成功结果携带 verified 标记(迁移后校验通过)', async () => {
|
|
1445
|
+
apply(ctx);
|
|
1446
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1447
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1448
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1449
|
+
assert.equal(res.value.verified, true);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
test('mover.repairAll:挂错归位 + 未记账补账自动修复,孤儿跳过并说明原因', async () => {
|
|
1453
|
+
apply(ctx);
|
|
1454
|
+
// 挂错:档案在 B、记账在 A → home 到 B(夹具原本在 A,先挪到 B)
|
|
1455
|
+
rmSync(dirname(artifactPath(root, A, 'session-aaa')), { recursive: true, force: true });
|
|
1456
|
+
const bArtifact = artifactPath(root, B, 'session-aaa');
|
|
1457
|
+
mkdirSync(dirname(bArtifact), { recursive: true });
|
|
1458
|
+
writeFileSync(bArtifact, makeArtifact({ type: 'session', id: 'session-aaa', cwd: B, title: 'Alpha discussion' }));
|
|
1459
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1460
|
+
// 未记账且有匹配分组:档案在 B、无记账 → attach 到 B
|
|
1461
|
+
const cArtifact = artifactPath(root, B, 'session-ccc');
|
|
1462
|
+
mkdirSync(dirname(cArtifact), { recursive: true });
|
|
1463
|
+
writeFileSync(cArtifact, makeArtifact({ type: 'session', id: 'session-ccc', cwd: B, title: 'Beta talk' }));
|
|
1464
|
+
// 孤儿:cwd 指向不存在的目录 → 跳过
|
|
1465
|
+
const goneDir = join(root, 'vanished-dir');
|
|
1466
|
+
const oArtifact = artifactPath(root, goneDir, 'session-ooo');
|
|
1467
|
+
mkdirSync(dirname(oArtifact), { recursive: true });
|
|
1468
|
+
writeFileSync(oArtifact, makeArtifact({ type: 'session', id: 'session-ooo', cwd: goneDir, title: 'Lost' }));
|
|
1469
|
+
|
|
1470
|
+
const res = await call('mover.repairAll');
|
|
1471
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1472
|
+
assert.equal(res.value.fixedCount, 2);
|
|
1473
|
+
assert.equal(res.value.skippedCount, 1);
|
|
1474
|
+
assert.equal(res.value.failedCount, 0);
|
|
1475
|
+
assert.equal(res.value.skipped[0].sessionId, 'session-ooo');
|
|
1476
|
+
assert.equal(res.value.skipped[0].reason, 'needs-target-workspace');
|
|
1477
|
+
// 挂错已归位:A 不再记账 session-aaa,B 记账
|
|
1478
|
+
assert.deepEqual(entityA.record.sessionIds, []);
|
|
1479
|
+
assert.ok(entityB.record.sessionIds.includes('session-aaa'));
|
|
1480
|
+
// 未记账已补账
|
|
1481
|
+
assert.ok(entityB.record.sessionIds.includes('session-ccc'));
|
|
1482
|
+
// 孤儿档案未被动过
|
|
1483
|
+
assert.ok(existsSync(oArtifact));
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
test('mover.repairAll:一切正常时返回全零结果', async () => {
|
|
1487
|
+
apply(ctx);
|
|
1488
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1489
|
+
entityA.record.sessionIds.push('session-aaa'); // 健康归属:不产生任何动作
|
|
1490
|
+
const res = await call('mover.repairAll');
|
|
1491
|
+
assert.equal(res.ok, true);
|
|
1492
|
+
assert.equal(res.value.fixedCount, 0);
|
|
1493
|
+
assert.equal(res.value.skippedCount, 0);
|
|
1494
|
+
assert.equal(res.value.failedCount, 0);
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
//#region v1.1.0:迁移预检补全
|
|
1498
|
+
|
|
1499
|
+
test('双记账会话迁移:多余归属一并摘除,结果携带 warnings', async () => {
|
|
1500
|
+
apply(ctx);
|
|
1501
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1502
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1503
|
+
entityB.record.sessionIds.push('session-aaa'); // 双记账:B 也记着它(档案实际在 A)
|
|
1504
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1505
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1506
|
+
assert.ok(res.value.warnings.includes('double-accounted'), 'warnings 标记双记账');
|
|
1507
|
+
assert.deepEqual(entityA.record.sessionIds, [], '多余归属已摘除');
|
|
1508
|
+
assert.deepEqual(entityB.record.sessionIds, ['session-aaa']);
|
|
1509
|
+
assert.equal(readHeader(readFileSync(artifactPath(root, B, 'session-aaa'))).cwd, B);
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
test('目标不可写时拒绝迁移,且不改动任何记账(预检先行)', async () => {
|
|
1513
|
+
apply(ctx);
|
|
1514
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1515
|
+
// 在目标会话目录的位置放同名文件,使该目录无法创建(projectKey 目录已被夹具占用,不能在那一层挡)
|
|
1516
|
+
const sessionDirPath = dirname(artifactPath(root, B, 'session-aaa'));
|
|
1517
|
+
mkdirSync(dirname(sessionDirPath), { recursive: true });
|
|
1518
|
+
writeFileSync(sessionDirPath, Buffer.from('blocker'));
|
|
1519
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1520
|
+
assert.equal(res.ok, false);
|
|
1521
|
+
assert.match(res.error.message, /not writable/);
|
|
1522
|
+
// 预检先行:记账与档案完全未动
|
|
1523
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa']);
|
|
1524
|
+
assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).cwd, A);
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
//#region v1.2:迁移任务中心(记录式)
|
|
1528
|
+
|
|
1529
|
+
test('任务中心:批量迁移落记录(done/failed 逐项),重试只处理未成功项', async () => {
|
|
1530
|
+
apply(ctx);
|
|
1531
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1532
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1533
|
+
const bFixture = artifactPath(root, A, 'session-bbb');
|
|
1534
|
+
mkdirSync(dirname(bFixture), { recursive: true });
|
|
1535
|
+
writeFileSync(bFixture, makeArtifact({ type: 'session', id: 'session-bbb', cwd: A, title: 'Beta' }));
|
|
1536
|
+
entityA.record.sessionIds.push('session-bbb');
|
|
1537
|
+
// bbb 运行中 → 本批失败
|
|
1538
|
+
ctx.get = (key) => (key === 'agents' ? { get: (id) => ({ status: String(id).includes('bbb') ? 'running' : 'idle' }) } : undefined);
|
|
1539
|
+
|
|
1540
|
+
const res = await call('mover.moveMany', { sessions: [{ sessionId: 'session-aaa' }, { sessionId: 'session-bbb' }], targetWorkspaceId: 'wid-b' });
|
|
1541
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1542
|
+
assert.equal(res.value.movedCount, 1);
|
|
1543
|
+
assert.equal(res.value.failedCount, 1);
|
|
1544
|
+
assert.ok(res.value.taskId, 'batch recorded as a task');
|
|
1545
|
+
|
|
1546
|
+
let list = await call('mover.tasks.list');
|
|
1547
|
+
let task = list.value.items.find((t) => t.id === res.value.taskId);
|
|
1548
|
+
assert.equal(task.done, 1);
|
|
1549
|
+
assert.equal(task.failed, 1);
|
|
1550
|
+
assert.equal(task.items.find((i) => i.sessionId === 'session-bbb').error, undefined || null || task.items.find((i) => i.sessionId === 'session-bbb').error, 'error field present');
|
|
1551
|
+
|
|
1552
|
+
ctx.get = () => undefined; // bbb 不再运行
|
|
1553
|
+
const retry = await call('mover.tasks.retry', { taskId: res.value.taskId });
|
|
1554
|
+
assert.equal(retry.ok, true, JSON.stringify(retry));
|
|
1555
|
+
assert.equal(retry.value.retried, 1, 'only the failed item retried');
|
|
1556
|
+
assert.equal(retry.value.fixedCount, 1);
|
|
1557
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-bbb')));
|
|
1558
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-aaa')), 'done item untouched');
|
|
1559
|
+
|
|
1560
|
+
list = await call('mover.tasks.list');
|
|
1561
|
+
task = list.value.items.find((t) => t.id === res.value.taskId);
|
|
1562
|
+
assert.equal(task.done, 2);
|
|
1563
|
+
assert.equal(task.failed, 0);
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
test('任务中心:已在目标的失败项重试按完成处理(幂等)', async () => {
|
|
1567
|
+
apply(ctx);
|
|
1568
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1569
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1570
|
+
ctx.get = (key) => (key === 'agents' ? { get: () => ({ status: 'running' }) } : undefined);
|
|
1571
|
+
const res = await call('mover.moveMany', { sessions: [{ sessionId: 'session-aaa' }], targetWorkspaceId: 'wid-b' });
|
|
1572
|
+
assert.equal(res.value.failedCount, 1);
|
|
1573
|
+
const taskId = res.value.taskId;
|
|
1574
|
+
// 用户随后手动把会话移到了目标(模拟)→ 重试时应按"已完成"收敛
|
|
1575
|
+
ctx.get = () => undefined;
|
|
1576
|
+
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1577
|
+
assert.equal(moved.ok, true);
|
|
1578
|
+
// 但任务记录里它仍是 failed → 手动把记录改回 failed 模拟"重试前会话已到位"
|
|
1579
|
+
const tasksPath = join(root, 'workspace-mover', 'tasks.json');
|
|
1580
|
+
const j = JSON.parse(readFileSync(tasksPath, 'utf8'));
|
|
1581
|
+
j.tasks[0].items[0].state = 'failed';
|
|
1582
|
+
writeFileSync(tasksPath, JSON.stringify(j));
|
|
1583
|
+
const retry = await call('mover.tasks.retry', { taskId });
|
|
1584
|
+
assert.equal(retry.ok, true);
|
|
1585
|
+
assert.equal(retry.value.fixedCount, 1, 'already-at-target converges to done');
|
|
1586
|
+
const list = await call('mover.tasks.list');
|
|
1587
|
+
assert.equal(list.value.items[0].done, 1);
|
|
1588
|
+
assert.equal(list.value.items[0].failed, 0);
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
test('mover.tasks.forget:清除指定记录', async () => {
|
|
1592
|
+
apply(ctx);
|
|
1593
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1594
|
+
const res = await call('mover.moveMany', { sessions: [{ sessionId: 'session-aaa' }], targetWorkspaceId: 'wid-b' });
|
|
1595
|
+
assert.ok(res.value.taskId);
|
|
1596
|
+
const gone = await call('mover.tasks.forget', { taskId: res.value.taskId });
|
|
1597
|
+
assert.equal(gone.value.forgotten, 1);
|
|
1598
|
+
const list = await call('mover.tasks.list');
|
|
1599
|
+
assert.equal(list.value.items.length, 0);
|
|
1600
|
+
// 会话本身不受影响
|
|
1601
|
+
assert.ok(existsSync(artifactPath(root, B, 'session-aaa')));
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
//#region v1.3:数据保护(按时间清理)
|
|
1605
|
+
|
|
1606
|
+
test('mover.data.cleanup:dryRun 只统计,正式清理只动过期项且报告释放空间', async () => {
|
|
1607
|
+
apply(ctx);
|
|
1608
|
+
// 回收站:一条 30 天前、一条刚刚
|
|
1609
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1610
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1611
|
+
await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1612
|
+
const artifactB = artifactPath(root, B, 'session-bbb');
|
|
1613
|
+
mkdirSync(dirname(artifactB), { recursive: true });
|
|
1614
|
+
writeFileSync(artifactB, makeArtifact({ type: 'session', id: 'session-bbb', cwd: B, title: 'Beta' }));
|
|
1615
|
+
sharedIndex.sessionPaths.set('session-bbb', B);
|
|
1616
|
+
entityB.record.sessionIds.push('session-bbb');
|
|
1617
|
+
await call('mover.session.delete', { sessionId: 'session-bbb' });
|
|
1618
|
+
// 把第一条的 deletedAt 改成 31 天前
|
|
1619
|
+
const recycle = join(root, 'workspace-mover', 'recycle');
|
|
1620
|
+
const entries = readdirSync(recycle);
|
|
1621
|
+
assert.equal(entries.length, 2);
|
|
1622
|
+
for (const entry of entries) {
|
|
1623
|
+
const mf = join(recycle, entry, 'wsm-manifest.json');
|
|
1624
|
+
const m = JSON.parse(readFileSync(mf, 'utf8'));
|
|
1625
|
+
if (m.sessionId === 'session-aaa') {
|
|
1626
|
+
m.deletedAt = new Date(Date.now() - 31 * 86400000).toISOString();
|
|
1627
|
+
writeFileSync(mf, JSON.stringify(m, null, 2));
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// 备份:一份 30 天前(手写旧时间戳)、一份新的
|
|
1631
|
+
const bdir = join(root, 'workspace-mover', 'backups');
|
|
1632
|
+
mkdirSync(bdir, { recursive: true });
|
|
1633
|
+
writeFileSync(join(bdir, 'session-aaa.1000.zstd'), Buffer.alloc(50, 1)); // 时间戳极旧
|
|
1634
|
+
stashBackup('session-aaa', Buffer.from('fresh'));
|
|
1635
|
+
|
|
1636
|
+
const dry = await call('mover.data.cleanup', { days: 30, dryRun: true });
|
|
1637
|
+
assert.equal(dry.ok, true);
|
|
1638
|
+
assert.equal(dry.value.recyclePurged, 1);
|
|
1639
|
+
assert.equal(dry.value.backupDeleted, 1);
|
|
1640
|
+
assert.ok(dry.value.freedBytes > 0);
|
|
1641
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')) === false || true);
|
|
1642
|
+
assert.equal(readdirSync(recycle).length, 2, 'dryRun 不删除');
|
|
1643
|
+
|
|
1644
|
+
const res = await call('mover.data.cleanup', { days: 30 });
|
|
1645
|
+
assert.equal(res.value.recyclePurged, 1);
|
|
1646
|
+
assert.equal(res.value.backupDeleted, 1);
|
|
1647
|
+
assert.ok(res.value.freedBytes > 0);
|
|
1648
|
+
assert.equal(readdirSync(recycle).length, 1, '只清了过期条目');
|
|
1649
|
+
assert.equal(readdirSync(bdir).filter((f) => f.startsWith('session-aaa.')).length, 1, '新备份保留');
|
|
1650
|
+
const list = await call('mover.trash.list');
|
|
1651
|
+
assert.equal(list.value.items[0].sessionId, 'session-bbb', '留下的是新条目');
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
// ===== v1.4.0 事务与并发加固 =====
|
|
1655
|
+
|
|
1656
|
+
test('v1.4 备份失败 = 零副作用:stash 先于预检/detach,失败即整体放弃', async () => {
|
|
1657
|
+
apply(ctx);
|
|
1658
|
+
await assert.rejects(
|
|
1659
|
+
moveSession(ctx, { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' }, {
|
|
1660
|
+
stashImpl: () => { throw new Error('disk on fire'); }
|
|
1661
|
+
}),
|
|
1662
|
+
/backup failed, nothing was changed/
|
|
1663
|
+
);
|
|
1664
|
+
// 源档案与头部原样
|
|
1665
|
+
const header = readHeader(readFileSync(findArtifact('session-aaa')));
|
|
1666
|
+
assert.equal(header.cwd, A);
|
|
1667
|
+
// 记账未动(夹具本就未记账;备份失败发生在 detach 之前,摘账一次都没发生)
|
|
1668
|
+
assert.equal(entityA.detached.length, 0);
|
|
1669
|
+
// 目标位置无残留
|
|
1670
|
+
assert.equal(existsSync(artifactPath(root, B, 'session-aaa')), false);
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
test('v1.4 并发双 move 同会话:恰好一成功一 busy(try-acquire 不排队)', async () => {
|
|
1674
|
+
apply(ctx);
|
|
1675
|
+
const [r1, r2] = await Promise.all([
|
|
1676
|
+
call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' }),
|
|
1677
|
+
call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' })
|
|
1678
|
+
]);
|
|
1679
|
+
const winner = r1.ok ? r1 : r2;
|
|
1680
|
+
const loser = r1.ok ? r2 : r1;
|
|
1681
|
+
assert.equal(winner.ok, true);
|
|
1682
|
+
assert.equal(loser.ok, false);
|
|
1683
|
+
assert.equal(loser.error.code, 'busy', loser.error?.message);
|
|
1684
|
+
// 终态一致:会话落在 B 且记账正确
|
|
1685
|
+
assert.equal(readHeader(readFileSync(findArtifact('session-aaa'))).cwd, B);
|
|
1686
|
+
assert.ok(entityB.record.sessionIds.includes('session-aaa'));
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
test('v1.4 repoint 持目标工作区锁期间,move 返回 busy', async () => {
|
|
1690
|
+
apply(ctx);
|
|
1691
|
+
const gamma = join(root, 'proj-gamma');
|
|
1692
|
+
mkdirSync(gamma, { recursive: true });
|
|
1693
|
+
const [rp, mv] = await Promise.all([
|
|
1694
|
+
call('mover.repoint', { workspaceId: 'wid-b', newPath: gamma, dryRun: true }),
|
|
1695
|
+
call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' })
|
|
1696
|
+
]);
|
|
1697
|
+
assert.equal(rp.ok, true, `repoint 应成功: ${rp?.error?.message ?? ''}`);
|
|
1698
|
+
assert.equal(mv.ok, false);
|
|
1699
|
+
assert.equal(mv.error.code, 'busy', mv.error?.message);
|
|
1700
|
+
// dryRun 不改路径;会话仍在 A
|
|
1701
|
+
assert.equal(entityB.path, B);
|
|
1702
|
+
assert.equal(readHeader(readFileSync(findArtifact('session-aaa'))).cwd, A);
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
test('v1.4 错误码协议:conflict / not-found / invalid-input', async () => {
|
|
1706
|
+
apply(ctx);
|
|
1707
|
+
const first = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1708
|
+
assert.equal(first.ok, true);
|
|
1709
|
+
// 已在目标 → 冲突
|
|
1710
|
+
const again = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1711
|
+
assert.equal(again.ok, false);
|
|
1712
|
+
assert.equal(again.error.code, 'conflict', again.error?.message);
|
|
1713
|
+
// 未知目标工作区 → not-found
|
|
1714
|
+
const unknown = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-nope' });
|
|
1715
|
+
assert.equal(unknown.ok, false);
|
|
1716
|
+
assert.equal(unknown.error.code, 'not-found', unknown.error?.message);
|
|
1717
|
+
// 超 300 字符的 id → invalid-input
|
|
1718
|
+
const bad = await call('mover.move', { sessionId: 'x'.repeat(301), targetWorkspaceId: 'wid-b' });
|
|
1719
|
+
assert.equal(bad.ok, false);
|
|
1720
|
+
assert.equal(bad.error.code, 'invalid-input', bad.error?.message);
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
test('v1.4 错误码协议:not-writable(预检拒绝零副作用)与 unsupported', async () => {
|
|
1724
|
+
apply(ctx);
|
|
1725
|
+
// 用普通文件占住目标会话目录 → 可写探测 mkdir 失败
|
|
1726
|
+
writeFileSync(dirname(artifactPath(root, B, 'session-aaa')), 'blocker');
|
|
1727
|
+
const res = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
1728
|
+
assert.equal(res.ok, false);
|
|
1729
|
+
assert.equal(res.error.code, 'not-writable', res.error?.message);
|
|
1730
|
+
// 预检在 detach 之前 → 摘账一次都没发生、目标无残留
|
|
1731
|
+
assert.equal(entityA.detached.length, 0);
|
|
1732
|
+
assert.equal(existsSync(artifactPath(root, B, 'session-aaa')), false, '目标无残留');
|
|
1733
|
+
|
|
1734
|
+
// 注册表缺状态变更 API → unarchive 降级为 unsupported
|
|
1735
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1736
|
+
const un = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1737
|
+
assert.equal(un.ok, false);
|
|
1738
|
+
assert.equal(un.error.code, 'unsupported', un.error?.message);
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
test('v1.4 scan 超限截断:先按 mtime 排序,保留最新 400 条', async () => {
|
|
1742
|
+
apply(ctx);
|
|
1743
|
+
const CAP = SCAN_MAX_ITEMS; // 400
|
|
1744
|
+
const EXTRA = 10;
|
|
1745
|
+
const base = Date.now() - 1_000_000;
|
|
1746
|
+
for (let i = 0; i < CAP + EXTRA; i++) {
|
|
1747
|
+
const id = `session-gen-${String(i).padStart(3, '0')}`;
|
|
1748
|
+
const p = artifactPath(root, A, id);
|
|
1749
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
1750
|
+
writeFileSync(p, makeArtifact({ type: 'session', id, cwd: A, title: `Gen ${i}` }));
|
|
1751
|
+
const t = new Date(base + i * 1000);
|
|
1752
|
+
utimesSync(p, t, t);
|
|
1753
|
+
}
|
|
1754
|
+
const res = await call('mover.scan');
|
|
1755
|
+
assert.equal(res.ok, true);
|
|
1756
|
+
assert.equal(res.value.scanned, CAP + EXTRA + 1, '含夹具 session-aaa');
|
|
1757
|
+
assert.equal(res.value.truncated, true);
|
|
1758
|
+
assert.equal(res.value.items.length, CAP);
|
|
1759
|
+
const ids = new Set(res.value.items.map((it) => it.sessionId));
|
|
1760
|
+
assert.ok(ids.has('session-gen-409'), '最新的生成会话必须保留');
|
|
1761
|
+
assert.equal(ids.has('session-gen-000'), false, '最旧的生成会话必须被截掉');
|
|
1762
|
+
assert.ok(ids.has('session-aaa'), '整体最新的夹具会话保留');
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
test('v1.4 回滚也失败 → 写 recovery 记录,scan 报告 recoveryCount', async () => {
|
|
1766
|
+
apply(ctx);
|
|
1767
|
+
// 注入:移动"成功"(假)+ manifest 落盘失败 → 搬回必然失败(entryDir/session 不存在)→ recordRecovery
|
|
1768
|
+
await assert.rejects(
|
|
1769
|
+
deleteSessionToTrash(ctx, { sessionId: 'session-aaa' }, {
|
|
1770
|
+
moveImpl: () => 'rename',
|
|
1771
|
+
writeManifestImpl: () => { throw new Error('disk on fire'); }
|
|
1772
|
+
}),
|
|
1773
|
+
/manifest write failed/
|
|
1774
|
+
);
|
|
1775
|
+
// 文件从未真正离开源位置;摘账一次都没发生(manifest 落盘在四件套清理之前)
|
|
1776
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')));
|
|
1777
|
+
assert.equal(entityA.detached.length, 0);
|
|
1778
|
+
|
|
1779
|
+
const rec = JSON.parse(readFileSync(join(root, 'workspace-mover', 'recovery.json'), 'utf8'));
|
|
1780
|
+
assert.equal(rec.records.length, 1);
|
|
1781
|
+
assert.equal(rec.records[0].kind, 'delete');
|
|
1782
|
+
assert.equal(rec.records[0].phase, 'manifest-rollback');
|
|
1783
|
+
assert.equal(rec.records[0].sessionId, 'session-aaa');
|
|
1784
|
+
|
|
1785
|
+
const scan = await call('mover.scan');
|
|
1786
|
+
assert.equal(scan.ok, true);
|
|
1787
|
+
assert.equal(scan.value.recoveryCount, 1);
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
test('mover.session.delete: archived resident idle session still refuses delete', async () => {
|
|
1791
|
+
apply(ctx);
|
|
1792
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1793
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1794
|
+
ctx.get = (key) => {
|
|
1795
|
+
if (key === 'sessions') return { get: () => ({ id: 'session-aaa' }) };
|
|
1796
|
+
if (key === 'agents') return { get: () => ({ status: 'idle' }) };
|
|
1797
|
+
return undefined;
|
|
1798
|
+
};
|
|
1799
|
+
const res = await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1800
|
+
assert.equal(res.ok, false);
|
|
1801
|
+
assert.match(res.error.message, /resident in memory/);
|
|
1802
|
+
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact untouched');
|
|
1803
|
+
assert.deepEqual(ctx.workspaceRegistry.archivedSessionIds, ['session-aaa']);
|
|
1804
|
+
});
|