dsh-workspace-mover 2.4.1 → 2.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +12 -4
- package/README_EN.md +11 -3
- package/client/client.js +2678 -2599
- package/docs/compatibility/immediate-migration-2026-10-26.md +68 -0
- package/lib/index.js +54 -24
- package/lib/lifecycle.js +104 -0
- package/package.json +6 -5
- package/test/client-dom.test.mjs +96 -4
- package/test/e2e-sandbox.test.mjs +46 -1
- package/test/live-migration.test.mjs +183 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# 2.4.2 即时迁移与标题修复验收
|
|
2
|
+
|
|
3
|
+
正式版本:2.4.2。验收候选:2.4.2-rc.4;正式版仅调整版本和发布文档,运行逻辑相同。
|
|
4
|
+
|
|
5
|
+
## 人工验收结论(2026-10-26)
|
|
6
|
+
|
|
7
|
+
用户在 DSH Desktop 0.2.1-rc.2 / Windows 上确认:
|
|
8
|
+
|
|
9
|
+
- 空闲会话拖动后立即出现在目标工作区,标题正常。
|
|
10
|
+
- rc.4 修复后不切换会话即可继续发送,未再出现“会话不可用”。
|
|
11
|
+
- 运行中拖动选择取消:任务继续、会话不移动。
|
|
12
|
+
- 明确选择停止任务并迁移:任务停止,迁移后可在当前界面继续发送。
|
|
13
|
+
- 彻底退出重启后标题、历史、归属正常,继续发送成功。
|
|
14
|
+
|
|
15
|
+
证据来源:维护者在本次验收会话中先确认“一切正常”,随后按最后三项测试回复“都成功。进入发布流程和issue解决流程”。这是维护者本机验收,不代表原报告者已在旧版 DSH 复测。
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
用户已在 rc.2 确认迁移后立即显示在目标工作区,且未出现未命名会话;随后发现当前输入框显示“会话不可用”,手动切走再切回才恢复。rc.3 尝试同步重新打开,但用户复验仍不可用,并出现重新连接失败提示。rc.4 改为等待引用释放后打开,用户随后确认通过。
|
|
19
|
+
|
|
20
|
+
## 迁移后的当前对话
|
|
21
|
+
|
|
22
|
+
官方 SessionController 在 dispose 时发布 api-session/removed;客户端 Session.handleRemoved 将 removed 置为 true,后续 added 事件只更新列表,并不清除此标记。因此后端已恢复的 Agent 对应的旧界面仍不可用。rc.3 在迁移 RPC 结束后检查仍选中的会话,仅在 removed 时通过 uiWorkspace.clearMain/openSession 释放旧视图并重新打开,等待新会话打开。失败回滚也执行同样检查;用户切换到其他会话或插件卸载后不主动跳转。不改宿主文件或私有 removed 字段。
|
|
23
|
+
|
|
24
|
+
rc.3 漏掉了延迟释放:clearMain 仅立即释放主视图引用,React Provider 的其他引用要等界面更新才释放,立刻 openSession 会重新持有 removed 的旧对象。rc.4 用 sessions.retainInfo 观察旧 binding 的实际退出后再打开;最多等待 3 秒,插件卸载会取消等待。等待期间用户选了其他会话则不重新打开。
|
|
25
|
+
|
|
26
|
+
新增已安装客户端验证:执行 Desktop 内的真实 ClientSessions / SessionManager / Session 代码,使用真实 Cordis,替换界面 store 和未使用的传输边界。确认 added 不清除 removed、双引用下同步重新取得旧对象、延迟释放最后引用后才能取得 removed=false 的新对象。该验证不运行 React 界面,不能替代 Desktop 复验。
|
|
27
|
+
|
|
28
|
+
若其他界面仍持有旧会话引用,或重新打开失败,明确提示彻底退出并重启,不重复执行物理迁移。自动回归覆盖成功、回滚、连接报错、用户切换、正常界面和插件卸载;完整 Desktop 界面、草稿及预设保持情况仍需实际验收。
|
|
29
|
+
|
|
30
|
+
## 行为
|
|
31
|
+
|
|
32
|
+
- 插件观察官方 agents.create/resume,保留返回的生命周期 dispose;不修改持久化 writer、不可变 Session header 或安装文件。
|
|
33
|
+
- 空闲且已接入生命周期的会话:锁定迁移操作,阻止新请求及后台恢复,等待官方 dispose 排空并关闭 writer,真实搬移档案,通过官方 SessionController.resolveAgent 重新按宿主预设加载。
|
|
34
|
+
- 运行中会话:弹窗明确显示“停止任务并迁移”;取消不调用迁移。状态在确认后发生变化时,后端返回 stop-required,不擅自停止。
|
|
35
|
+
- 放行操作使用 AsyncLocalStorage 限定为当前迁移的恢复调用,不放行其他并发恢复。旧 Agent 引用在替换后拒绝发送。
|
|
36
|
+
- 迁移失败沿用文件和工作区归属回滚,并请求宿主从原位置重新加载。若重新加载失败,明确返回错误,不伪称全部成功。
|
|
37
|
+
- 热更新前已打开、没有保留生命周期句柄的会话不强行释放。完全重启一次后重新打开会话即可接入;未知后端仍保留原安全检查。
|
|
38
|
+
- 拖动迁移不自动创建 pending。历史 API 的排队能力和已有队列保持兼容;没有明确停止授权的后台任务不取消正在运行的 Agent。
|
|
39
|
+
- 活跃子代理迁移暂不支持;不能在其父任务仍持有生命周期时单独卸载。
|
|
40
|
+
|
|
41
|
+
## Issue #10:标题
|
|
42
|
+
|
|
43
|
+
原实现调用底层 table.update,但已安装宿主的缓存表使用 get/put。现在以完整 identity/rows 读取并写回,在 attachSession 发布新归属之前对齐 identity.cwd;保留 title 行,移除旧 workingDirectory 行。attach 失败恢复原 identity/rows。
|
|
44
|
+
|
|
45
|
+
本机“标题保留测试”归档后,标题仍保存在新版 per-record 投影缓存中;修复面板原来只读取旧 session_projcache.json 汇总文件,因此误显示“未命名会话”。现在扫描、归档、预览和备份列表按内存缓存、新版单记录文件、旧版汇总、档案标题的顺序读取。回归测试覆盖新旧缓存共存、内存更新和损坏缓存降级。
|
|
46
|
+
|
|
47
|
+
报告者 DSH 0.2.0-rc.2 环境尚未直接复现;这是发现并修复的具体代码缺陷,不声称已完成该环境验收。
|
|
48
|
+
|
|
49
|
+
## 自动验证
|
|
50
|
+
|
|
51
|
+
- 常规套件包含即时迁移的 DOM 确认/取消、无自动排队、生命周期并发拒绝和卸载恢复检查。
|
|
52
|
+
- test/live-migration.test.mjs 的可选集成项使用从本机 Desktop 提取的 DSH 0.2.1-rc.2、真实 Cordis、SessionStore、AgentRegistry、AgentLoop、JSONL/Zstd writer 和原生文件锁。
|
|
53
|
+
- 检查真实 Agent 创建、释放、物理迁移、目标路径恢复、继续追加、失败回滚后恢复原路径,以及运行中拒绝/明确停止后迁移。
|
|
54
|
+
- 集成测试的 SessionController 入口使用精简实现调用真实 agents.resume;没有启动完整 Desktop、加载真实用户预设或调用在线模型。实际预设、权限与模型恢复依赖下面的 Desktop 人工验收。
|
|
55
|
+
- DSH_TEST_NODE_MODULES 指向已有 runtime/node_modules,运行 npm run test:installed-host;未配置时明确 skip。
|
|
56
|
+
- rc.2 本机 Windows / Node 22.23.2:npm run check 通过,319 项通过、1 项真实宿主可选测试按预期 skip;单独配置真实宿主后 npm run test:installed-host 的 3 项全部通过。rc.3 增补当前对话重新打开的回归检查。跨平台 CI 尚未运行。
|
|
57
|
+
- rc.3:npm run check 通过,325 项通过、1 项可选测试跳过;语法、打包预检和 git diff --check 通过。新增的 6 项界面回归使用 DOM 与宿主服务替身,尚不能替代 Desktop 人工复验。
|
|
58
|
+
- rc.4:npm run check 通过,327 项通过、2 项宿主可选测试跳过;配置真实宿主后 npm run test:installed-host 的 4 项全部通过,包括上述客户端引用释放复现。语法、打包预检和 git diff --check 通过。Desktop 人工复验通过,见本文开头。
|
|
59
|
+
|
|
60
|
+
## Desktop 验收
|
|
61
|
+
|
|
62
|
+
1. 保存正在做的工作,从应用菜单或托盘彻底退出 DSH,再启动。自检版本应为 2.4.2-rc.4。
|
|
63
|
+
2. 新建测试会话并命名“标题保留测试”,发送一句消息,等回复结束。拖到 B,确认后应直接显示在 B,无 pending、无需重启。
|
|
64
|
+
3. 不切换到其他会话,在 B 当前界面直接连续发两条消息,确认输入框可用、正常回复,模型/预设/权限及未发送草稿保持预期,有效工作目录为 B。
|
|
65
|
+
4. 让另一个测试会话执行可随时停止的任务,生成回复期间拖动。先取消,原任务继续;再次拖动选择“停止任务并迁移”,确认任务停止后会话出现在 B,可继续发送。
|
|
66
|
+
5. 重启后标题、历史、归属和工作目录仍正确。分别检查手动标题和自动生成标题,并在归档后检查设置中的会话修复列表标题。若异常,保留测试会话,不重复强行搬移。
|
|
67
|
+
|
|
68
|
+
发布后在 issue #10 说明修复及升级步骤,并邀请原报告者在 DSH 0.2.0-rc.2 复测;待其确认后关闭。
|
package/lib/index.js
CHANGED
|
@@ -36,6 +36,7 @@ import { join, dirname, basename, relative, resolve, isAbsolute } from 'node:pat
|
|
|
36
36
|
import { homedir } from 'node:os';
|
|
37
37
|
import { spawn } from 'node:child_process';
|
|
38
38
|
import { Config, POLICY_FIELDS, buildConfig, attachConfig } from './config.js';
|
|
39
|
+
import { installLifecycle, lifecycleFor, withReleasedSession } from './lifecycle.js';
|
|
39
40
|
|
|
40
41
|
const name = 'workspace-mover';
|
|
41
42
|
// 只声明真正必需的两个服务。cordis 把 inject 里的**每一项**都当必需:任一缺失,
|
|
@@ -913,7 +914,9 @@ async function moveSessionEntry(ctx, payload = {}, hooks = {}) {
|
|
|
913
914
|
`workspace:${targetWorkspaceId}`
|
|
914
915
|
);
|
|
915
916
|
try {
|
|
916
|
-
|
|
917
|
+
if (!ctx.workspaceRegistry?.get(targetWorkspaceId)) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
|
|
918
|
+
return await withReleasedSession(ctx, sessionId, payload.stopRunning === true,
|
|
919
|
+
() => moveSession(ctx, { ...payload, sessionId, targetWorkspaceId }, hooks));
|
|
917
920
|
} finally {
|
|
918
921
|
release();
|
|
919
922
|
}
|
|
@@ -955,7 +958,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
955
958
|
const sourceCwd = meta.cwd;
|
|
956
959
|
const title = typeof sessionTitle === 'string' && sessionTitle.trim()
|
|
957
960
|
? sessionTitle.trim()
|
|
958
|
-
: (
|
|
961
|
+
: readSessionTitle(ctx, sessionId, readProjectionTitles().get(String(sessionId)) ?? meta.title ?? '未命名会话');
|
|
959
962
|
if (sourceCwd === undefined) throw new Error('session header carries no cwd');
|
|
960
963
|
if (samePath(sourceCwd, targetPath)) {
|
|
961
964
|
// 磁盘头已指向目标:若目标工作区还没记账它(历史遗留的未归组会话),
|
|
@@ -1129,7 +1132,11 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
1129
1132
|
// 才会读取 header 并经 mutate 发出 domain/changed。不能直接预置
|
|
1130
1133
|
// target.record.sessionIds,否则 attach 变成 no-op,WorkspaceFeed 不会推送目标
|
|
1131
1134
|
// 工作区的更新帧,浏览器就会暂时把会话显示在“未分组”。
|
|
1135
|
+
const originalProjection = captureProjection(ctx, sessionId);
|
|
1132
1136
|
try {
|
|
1137
|
+
// Listings can read the title during attach: align BEFORE publishing the
|
|
1138
|
+
// new header, otherwise the host rejects the old cwd-bound checkpoint.
|
|
1139
|
+
await realignProjectionIdentity(ctx, sessionId, targetPath);
|
|
1133
1140
|
await target.attachSession(sessionId);
|
|
1134
1141
|
} catch (err) {
|
|
1135
1142
|
// Roll back original bytes and reattach the source workspace.
|
|
@@ -1142,6 +1149,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
1142
1149
|
throw new Error(`physical rollback could not move the target directory: ${relocateErr?.message ?? relocateErr}`);
|
|
1143
1150
|
}
|
|
1144
1151
|
await storage.replace(sourceLocation, original);
|
|
1152
|
+
await writeProjection(ctx, String(sessionId), originalProjection, sourceCwd, true);
|
|
1145
1153
|
for (const e of allOwners) {
|
|
1146
1154
|
try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ }
|
|
1147
1155
|
}
|
|
@@ -1400,6 +1408,26 @@ function readProjectionTitles() {
|
|
|
1400
1408
|
return map;
|
|
1401
1409
|
}
|
|
1402
1410
|
|
|
1411
|
+
/** Current hosts use per-record checkpoints; legacy aggregate files can remain
|
|
1412
|
+
* on disk after an upgrade and must never override a current title. */
|
|
1413
|
+
function readSessionTitle(ctx, sessionId, fallback = null) {
|
|
1414
|
+
const titleOf = (record) => {
|
|
1415
|
+
const title = record?.rows?.title?.val;
|
|
1416
|
+
return typeof title === 'string' && title.trim() ? title.trim() : null;
|
|
1417
|
+
};
|
|
1418
|
+
const live = titleOf(captureProjection(ctx, sessionId));
|
|
1419
|
+
if (live) return live;
|
|
1420
|
+
try {
|
|
1421
|
+
const id = validId(String(sessionId), 'sessionId');
|
|
1422
|
+
if (/[\\/:]/.test(id) || id === '.' || id === '..') return fallback;
|
|
1423
|
+
const base = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
1424
|
+
const document = JSON.parse(readFileSync(join(base, 'storages', 'session_projcache', 'sessions', `${id}.json`), 'utf8'));
|
|
1425
|
+
const current = titleOf(document?.record);
|
|
1426
|
+
if (current) return current;
|
|
1427
|
+
} catch { /* Cache absence/corruption must not prevent session rescue. */ }
|
|
1428
|
+
return fallback;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1403
1431
|
/**
|
|
1404
1432
|
* 扫描存储根下的全部会话档案并分类:
|
|
1405
1433
|
* - orphaned 头部 cwd 指向的目录已不存在(项目文件夹被移动/改名/删除,官方讨论 #3012)
|
|
@@ -1504,8 +1532,8 @@ async function scanSessions(ctx) {
|
|
|
1504
1532
|
: 'misfiled'; // 记账方存在但不是 cwd 匹配的分组
|
|
1505
1533
|
items.push({
|
|
1506
1534
|
sessionId,
|
|
1507
|
-
title: projectionTitles.get(sessionId)
|
|
1508
|
-
?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话'),
|
|
1535
|
+
title: readSessionTitle(ctx, sessionId, projectionTitles.get(sessionId)
|
|
1536
|
+
?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话')),
|
|
1509
1537
|
cwd,
|
|
1510
1538
|
status,
|
|
1511
1539
|
targetWorkspaceId: matching?.id ?? null,
|
|
@@ -1615,12 +1643,8 @@ function invalidateFileReferenceSearches(ctx, stalePath) {
|
|
|
1615
1643
|
*/
|
|
1616
1644
|
async function realignProjectionIdentity(ctx, sessionId, nextCwd) {
|
|
1617
1645
|
try {
|
|
1618
|
-
const
|
|
1619
|
-
|
|
1620
|
-
await table.update(String(sessionId), (rec) =>
|
|
1621
|
-
rec?.identity ? { ...rec, identity: { ...rec.identity, cwd: nextCwd },
|
|
1622
|
-
rows: Object.fromEntries(Object.entries(rec.rows ?? {}).filter(([key]) => key !== 'workingDirectory')) } : rec
|
|
1623
|
-
);
|
|
1646
|
+
const record = captureProjection(ctx, sessionId);
|
|
1647
|
+
await writeProjection(ctx, String(sessionId), record, nextCwd);
|
|
1624
1648
|
} catch (err) {
|
|
1625
1649
|
ctx.logger?.warn?.(`workspace-mover: projection identity alignment failed for ${sessionId}: ${err?.message ?? err}`);
|
|
1626
1650
|
}
|
|
@@ -1999,13 +2023,16 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
|
|
|
1999
2023
|
const repairMembership = sameWorkspace && !rawRecordSessionIds(target).includes(id);
|
|
2000
2024
|
let backupPlan = { reused: false, estimatedBytes: 0 };
|
|
2001
2025
|
let status = 'safe';
|
|
2026
|
+
const liveMigration = Boolean(lifecycleFor(ctx, id));
|
|
2002
2027
|
if (running) {
|
|
2003
|
-
status = '
|
|
2004
|
-
warnings.push('session is
|
|
2028
|
+
status = 'warning';
|
|
2029
|
+
warnings.push('session is running; moving requires explicit stop confirmation');
|
|
2005
2030
|
}
|
|
2006
2031
|
try { assertNoResidentWriter(ctx, id); } catch (err) {
|
|
2007
|
-
|
|
2008
|
-
|
|
2032
|
+
if (!liveMigration) {
|
|
2033
|
+
status = 'queued';
|
|
2034
|
+
warnings.push(err.message);
|
|
2035
|
+
}
|
|
2009
2036
|
}
|
|
2010
2037
|
if (adapter.mode === 'filesystem-fallback') warnings.push('physical move uses the JSONL path fallback; official raw-byte path API is unavailable');
|
|
2011
2038
|
if (!adapter.capabilities.physicalPath) {
|
|
@@ -2054,10 +2081,11 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
|
|
|
2054
2081
|
status,
|
|
2055
2082
|
canMove: status === 'safe' || status === 'warning' || status === 'queued',
|
|
2056
2083
|
sessionId: id,
|
|
2057
|
-
title: readProjectionTitles().get(id) ?? meta.title ?? null,
|
|
2084
|
+
title: readSessionTitle(ctx, id, readProjectionTitles().get(id) ?? meta.title ?? null),
|
|
2058
2085
|
source,
|
|
2059
2086
|
target: targetInfo,
|
|
2060
2087
|
running,
|
|
2088
|
+
liveMigration,
|
|
2061
2089
|
branches: { available: true, count: branches.relations.length, mode, sessionIds: branches.ids, relations: branches.relations },
|
|
2062
2090
|
estimatedBackupBytes: sameWorkspace ? 0 : backupPlan.estimatedBytes,
|
|
2063
2091
|
backup: sameWorkspace ? { reused: false, estimatedBytes: 0 } : backupPlan,
|
|
@@ -2067,7 +2095,7 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
|
|
|
2067
2095
|
}
|
|
2068
2096
|
|
|
2069
2097
|
/** 批量迁移入口:逐会话复用 moveSession(各自独立备份/回滚,单条失败不牵连其余);历史聚合成一条。 */
|
|
2070
|
-
async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only' } = {}) {
|
|
2098
|
+
async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only', stopRunning = false, allowQueue = true } = {}) {
|
|
2071
2099
|
if (!Array.isArray(sessions) || sessions.length === 0) throw new Error('sessions must be a non-empty array');
|
|
2072
2100
|
if (sessions.length > batchLimit()) throw new Error(`too many sessions in one batch (max ${batchLimit()}, got ${sessions.length})`);
|
|
2073
2101
|
if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
|
|
@@ -2113,7 +2141,8 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'curr
|
|
|
2113
2141
|
sessionId,
|
|
2114
2142
|
targetWorkspaceId,
|
|
2115
2143
|
sessionTitle: typeof item.sessionTitle === 'string' ? item.sessionTitle : undefined,
|
|
2116
|
-
recordHistory: false
|
|
2144
|
+
recordHistory: false,
|
|
2145
|
+
stopRunning
|
|
2117
2146
|
});
|
|
2118
2147
|
if (result.attached) attachedCount += 1; else movedCount += 1;
|
|
2119
2148
|
let sourceWorkspaceId = null;
|
|
@@ -2123,7 +2152,7 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'curr
|
|
|
2123
2152
|
moved.push({ sessionId, title: result.title, fromCwd: result.from.cwd, sourceWorkspaceId, backup: result.backup ?? null });
|
|
2124
2153
|
results.push({ sessionId, ok: true, moved: result.moved, to: result.to.cwd, title: result.title });
|
|
2125
2154
|
} catch (err) {
|
|
2126
|
-
if (isBusyError(err)) {
|
|
2155
|
+
if (allowQueue && isBusyError(err)) {
|
|
2127
2156
|
const queued = createOrReuseMigration(ctx, {
|
|
2128
2157
|
sessionId,
|
|
2129
2158
|
targetWorkspaceId,
|
|
@@ -3196,14 +3225,14 @@ async function dropProjection(ctx, sessionId) {
|
|
|
3196
3225
|
}
|
|
3197
3226
|
}
|
|
3198
3227
|
|
|
3199
|
-
async function writeProjection(ctx, sessionId, record, cwd) {
|
|
3228
|
+
async function writeProjection(ctx, sessionId, record, cwd, preserveWorkingDirectory = false) {
|
|
3200
3229
|
try {
|
|
3201
3230
|
const table = projectionTable(ctx);
|
|
3202
3231
|
if (!table || typeof table.put !== 'function' || !record?.identity || !record?.rows) return;
|
|
3203
3232
|
// 注意:table 是底层 KvTable,put(key, value) 两参——value 必须是完整 {identity, rows};
|
|
3204
3233
|
// 服务层的 put(id, identity, rows) 三参签名在这里不可用(会整条写坏,域重开时崩)。
|
|
3205
3234
|
await table.put(String(sessionId), { identity: { ...record.identity, cwd },
|
|
3206
|
-
rows: Object.fromEntries(Object.entries(record.rows).filter(([key]) => key !== 'workingDirectory')) });
|
|
3235
|
+
rows: preserveWorkingDirectory ? record.rows : Object.fromEntries(Object.entries(record.rows).filter(([key]) => key !== 'workingDirectory')) });
|
|
3207
3236
|
} catch (err) {
|
|
3208
3237
|
ctx.logger?.warn?.(`workspace-mover: projection restore failed for ${sessionId} (title will rebuild lazily): ${err?.message ?? err}`);
|
|
3209
3238
|
}
|
|
@@ -3537,7 +3566,7 @@ async function listBackups(ctx) {
|
|
|
3537
3566
|
const { id, ts } = parsed;
|
|
3538
3567
|
let sizeBytes = 0;
|
|
3539
3568
|
try { sizeBytes = statSync(join(dir, name)).size; } catch { continue; }
|
|
3540
|
-
const g = groups.get(id) ?? { sessionId: id, title: titles.get(id) ?? null, count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
|
|
3569
|
+
const g = groups.get(id) ?? { sessionId: id, title: readSessionTitle(ctx, id, titles.get(id) ?? null), count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
|
|
3541
3570
|
g.count++;
|
|
3542
3571
|
g.totalBytes += sizeBytes;
|
|
3543
3572
|
g.oldest = Math.min(g.oldest, Number(ts));
|
|
@@ -4177,6 +4206,7 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
4177
4206
|
|
|
4178
4207
|
export function apply(ctx) {
|
|
4179
4208
|
const logger = ctx.logger ?? console;
|
|
4209
|
+
ctx.inject?.(['agents'], (owner) => installLifecycle(ctx, owner));
|
|
4180
4210
|
const dispatchRef = { fn: null }; // agent 工具在调用期才用到 dispatch,挂载时序无关
|
|
4181
4211
|
const agentToolsState = { registered: false };
|
|
4182
4212
|
const mount = (owner) => {
|
|
@@ -4271,15 +4301,15 @@ export function apply(ctx) {
|
|
|
4271
4301
|
return ok(await moveManySessions(ctx, {
|
|
4272
4302
|
sessions: [{ sessionId, sessionTitle: payload?.sessionTitle }],
|
|
4273
4303
|
targetWorkspaceId,
|
|
4274
|
-
mode: 'include-descendants'
|
|
4304
|
+
mode: 'include-descendants', stopRunning: payload?.stopRunning === true, allowQueue: payload?.allowQueue !== false
|
|
4275
4305
|
}));
|
|
4276
4306
|
}
|
|
4277
|
-
const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle });
|
|
4307
|
+
const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle, stopRunning: payload?.stopRunning === true });
|
|
4278
4308
|
logger.info?.(`workspace-mover: moved ${sessionId} -> ${result.to.cwd}`);
|
|
4279
4309
|
return ok(result);
|
|
4280
4310
|
} catch (err) {
|
|
4281
4311
|
logger.warn?.(`workspace-mover: MOVE FAILED sessionId=${payload?.sessionId} target=${payload?.targetWorkspaceId}: ${err?.message ?? err}`);
|
|
4282
|
-
if (isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
|
|
4312
|
+
if (payload?.allowQueue !== false && isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
|
|
4283
4313
|
const queued = createOrReuseMigration(ctx, {
|
|
4284
4314
|
sessionId: payload.sessionId,
|
|
4285
4315
|
targetWorkspaceId: payload.targetWorkspaceId,
|
package/lib/lifecycle.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Retain the public create/resume disposer. Never close a borrowed persistence
|
|
2
|
+
// writer or rewrite an immutable live Session header.
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
const migrationContext = new AsyncLocalStorage();
|
|
5
|
+
const original = Symbol.for('cordis.original');
|
|
6
|
+
const runtimes = new WeakMap();
|
|
7
|
+
const raw = (value) => value?.[original] ?? value;
|
|
8
|
+
|
|
9
|
+
function blocked() {
|
|
10
|
+
const error = new Error('This session is being migrated; retry after migration completes.');
|
|
11
|
+
error.code = 'migration-in-progress';
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function installLifecycle(ctx, owner) {
|
|
16
|
+
const agents = raw(owner.agents);
|
|
17
|
+
if (!agents || runtimes.has(agents)) return;
|
|
18
|
+
const runtime = { handles: new Map(), moving: new Set() };
|
|
19
|
+
runtimes.set(agents, runtime);
|
|
20
|
+
const restore = [];
|
|
21
|
+
for (const method of ['create', 'resume']) {
|
|
22
|
+
const fn = agents[method];
|
|
23
|
+
if (typeof fn !== 'function') continue;
|
|
24
|
+
const wrapped = async function (options, ...args) {
|
|
25
|
+
const id = options?.resumeSessionId ?? options?.sessionId;
|
|
26
|
+
if (runtime.moving.has(id) && migrationContext.getStore() !== runtime) throw blocked();
|
|
27
|
+
const handle = await Reflect.apply(fn, this, [options, ...args]);
|
|
28
|
+
const agent = raw(handle?.agent);
|
|
29
|
+
if (agent && typeof handle.dispose === 'function') {
|
|
30
|
+
const guards = [];
|
|
31
|
+
runtime.handles.set(agent.id, { handle, agent, guards });
|
|
32
|
+
// Requests that already resolved the old Agent must also be refused
|
|
33
|
+
// while its lifecycle is draining or after it has been replaced.
|
|
34
|
+
for (const entry of ['send', 'runMaintenance']) {
|
|
35
|
+
const action = agent[entry];
|
|
36
|
+
if (typeof action !== 'function') continue;
|
|
37
|
+
const guarded = function (...params) {
|
|
38
|
+
if (runtime.moving.has(agent.id) || raw(agents.get(agent.id)) !== agent) throw blocked();
|
|
39
|
+
return Reflect.apply(action, this, params);
|
|
40
|
+
};
|
|
41
|
+
agent[entry] = guarded;
|
|
42
|
+
guards.push(() => { if (agent[entry] === guarded) agent[entry] = action; });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return handle;
|
|
46
|
+
};
|
|
47
|
+
agents[method] = wrapped;
|
|
48
|
+
restore.push(() => { if (agents[method] === wrapped) agents[method] = fn; });
|
|
49
|
+
}
|
|
50
|
+
owner.on?.('agent/disposed', ({ agent }) => {
|
|
51
|
+
if (runtime.handles.get(agent.id)?.agent === raw(agent)) runtime.handles.delete(agent.id);
|
|
52
|
+
});
|
|
53
|
+
owner.effect(() => () => {
|
|
54
|
+
for (const entry of runtime.handles.values()) for (const undo of entry.guards) undo();
|
|
55
|
+
runtime.handles.clear();
|
|
56
|
+
for (const undo of restore.reverse()) undo();
|
|
57
|
+
runtimes.delete(agents);
|
|
58
|
+
}, 'workspace-mover: restore lifecycle observers');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function lifecycleFor(ctx, id) {
|
|
62
|
+
const agents = raw(typeof ctx.get === 'function' ? ctx.get('agents') : ctx.agents);
|
|
63
|
+
const runtime = agents && runtimes.get(agents);
|
|
64
|
+
const entry = runtime?.handles.get(id);
|
|
65
|
+
return entry && raw(agents.get(id)) === entry.agent ? { ...entry, runtime, agents } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function withReleasedSession(ctx, id, stopRunning, operation) {
|
|
69
|
+
const owned = lifecycleFor(ctx, id);
|
|
70
|
+
if (!owned) return operation();
|
|
71
|
+
const { handle, agent, runtime } = owned;
|
|
72
|
+
if (agent.status === 'running' && !stopRunning) {
|
|
73
|
+
const error = new Error('Session is running. Confirm stop and move, or cancel migration.');
|
|
74
|
+
error.code = 'stop-required';
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (agent.session.header?.parentSession || agent.session.header?.origin === 'subagent') {
|
|
78
|
+
throw new Error('Live subagent migration is unsupported; stop its owning task first.');
|
|
79
|
+
}
|
|
80
|
+
runtime.moving.add(id);
|
|
81
|
+
let result;
|
|
82
|
+
let failure;
|
|
83
|
+
try {
|
|
84
|
+
await handle.dispose(); // owns cancel → drain → close → unregister
|
|
85
|
+
result = await operation();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
failure = error;
|
|
88
|
+
} finally {
|
|
89
|
+
runtime.handles.delete(id);
|
|
90
|
+
try {
|
|
91
|
+
const controller = ctx.get?.('sessionController');
|
|
92
|
+
if (typeof controller?.resolveAgent === 'function') {
|
|
93
|
+
const restored = await migrationContext.run(runtime, () => controller.resolveAgent(id));
|
|
94
|
+
if (restored?.error) throw restored.error;
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
failure = new Error(`${failure ? failure.message : 'Files migrated successfully'}; session reload failed: ${error.message}`, { cause: error });
|
|
98
|
+
} finally {
|
|
99
|
+
runtime.moving.delete(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (failure) throw failure;
|
|
103
|
+
return result;
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-workspace-mover",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.2",
|
|
4
4
|
"description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
"docs/compatibility",
|
|
22
22
|
"docs/media",
|
|
23
23
|
"LICENSE"
|
|
24
|
-
],
|
|
25
|
-
|
|
26
|
-
"test
|
|
27
|
-
"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs test/live-migration.test.mjs",
|
|
27
|
+
"test:installed-host": "node --test test/installed-host.test.mjs test/live-migration.test.mjs",
|
|
28
|
+
"check:syntax": "node --check lib/index.js && node --check client/client.js && node --check lib/lifecycle.js",
|
|
28
29
|
"check:pack": "npm pack --dry-run",
|
|
29
30
|
"check": "npm run check:syntax && npm test && npm run check:pack"
|
|
30
31
|
},
|
package/test/client-dom.test.mjs
CHANGED
|
@@ -566,7 +566,7 @@ test('拖拽跨组移动:经权威 fiber 通道解析出正确 sessionId', asy
|
|
|
566
566
|
assert.equal(calls[0].payload.targetWorkspaceId, 'wid-b');
|
|
567
567
|
});
|
|
568
568
|
|
|
569
|
-
test('
|
|
569
|
+
test('即时迁移使用权威标题,不包含侧边栏时间,不启用排队', async () => {
|
|
570
570
|
resetDom();
|
|
571
571
|
mount();
|
|
572
572
|
const row = sessionRow('测试刚刚', 'session-aaa');
|
|
@@ -575,22 +575,114 @@ test('迁移标题使用权威预览,不包含侧边栏时间;驻留队列
|
|
|
575
575
|
const prior = rpcResponder;
|
|
576
576
|
setResponder((endpoint, payload) => endpoint === 'mover.preview' ? {
|
|
577
577
|
ok: true, value: {
|
|
578
|
-
title: '测试', status: '
|
|
578
|
+
title: '测试', status: 'safe', liveMigration: true, source: { cwd: 'C:/proj-alpha' },
|
|
579
579
|
target: { path: 'C:/proj-beta' }, estimatedBackupBytes: 0,
|
|
580
|
-
warnings: [
|
|
580
|
+
warnings: []
|
|
581
581
|
}
|
|
582
582
|
} : prior(endpoint, payload));
|
|
583
583
|
attach(beta);
|
|
584
584
|
await dragDrop(row, beta, 'session-aaa', { confirm: false });
|
|
585
585
|
const overlay = doc._body.querySelector('.wsm-overlay');
|
|
586
586
|
assert.equal(overlay.querySelector('.wsm-session').textContent, '测试');
|
|
587
|
-
assert.
|
|
587
|
+
assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /可能需重启/);
|
|
588
588
|
assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /进入空闲队列/);
|
|
589
589
|
dispatch({ type: 'click', target: overlay.querySelector('.wsm-ok') });
|
|
590
590
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
591
591
|
assert.equal(moveCalls()[0].payload.sessionTitle, '测试');
|
|
592
|
+
assert.equal(moveCalls()[0].payload.allowQueue, false);
|
|
592
593
|
});
|
|
593
594
|
|
|
595
|
+
for (const outcome of ['success', 'render-release', 'navigation-during-release', 'rollback', 'transport-error', 'navigated', 'healthy', 'unloaded']) {
|
|
596
|
+
test(`migration reconnects the removed current conversation via host navigation: ${outcome}`, async () => {
|
|
597
|
+
resetDom(); mount();
|
|
598
|
+
const row = sessionRow('标题保留测试', 'session-aaa');
|
|
599
|
+
const beta = header('proj-beta');
|
|
600
|
+
fixture({ rows: [row] });
|
|
601
|
+
attach(beta);
|
|
602
|
+
let selected = 'session-aaa';
|
|
603
|
+
let removed = false;
|
|
604
|
+
const releaseListeners = new Set();
|
|
605
|
+
let binding;
|
|
606
|
+
const navigation = [];
|
|
607
|
+
const ui = {
|
|
608
|
+
selection: { getSnapshot: () => ({ sessionId: selected }) },
|
|
609
|
+
clearMain() {
|
|
610
|
+
navigation.push('release'); selected = undefined;
|
|
611
|
+
const release = () => {
|
|
612
|
+
binding = undefined;
|
|
613
|
+
if (outcome === 'navigation-during-release') selected = 'session-other';
|
|
614
|
+
for (const listener of releaseListeners) listener();
|
|
615
|
+
};
|
|
616
|
+
if (outcome === 'render-release' || outcome === 'navigation-during-release') setTimeout(release, 0);
|
|
617
|
+
else release();
|
|
618
|
+
},
|
|
619
|
+
openSession(id) {
|
|
620
|
+
assert.equal(binding, undefined, 'must wait for Provider reference release before retaining again');
|
|
621
|
+
navigation.push(id); selected = id; removed = false; binding = { session };
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const session = {
|
|
625
|
+
getSnapshot: () => ({ removed }),
|
|
626
|
+
async open() { navigation.push('ready'); }
|
|
627
|
+
};
|
|
628
|
+
binding = { session };
|
|
629
|
+
const sessions = { binding: () => binding, retainInfo: () => ({
|
|
630
|
+
subscribe(listener) { releaseListeners.add(listener); return () => releaseListeners.delete(listener); }
|
|
631
|
+
}) };
|
|
632
|
+
ctx.get = (key) => ({ uiWorkspace: ui, sessions })[key];
|
|
633
|
+
const prior = rpcResponder;
|
|
634
|
+
setResponder((endpoint, payload) => {
|
|
635
|
+
if (endpoint !== 'mover.move') return prior(endpoint, payload);
|
|
636
|
+
removed = outcome !== 'healthy';
|
|
637
|
+
if (outcome === 'navigated') selected = 'session-other';
|
|
638
|
+
if (outcome === 'unloaded') unmount();
|
|
639
|
+
if (outcome === 'transport-error') throw new Error('Connection lost after release');
|
|
640
|
+
if (outcome === 'rollback') return { ok: false, error: { code: 'move-failed', message: 'rolled back cleanly' } };
|
|
641
|
+
return prior(endpoint, payload);
|
|
642
|
+
});
|
|
643
|
+
await dragDrop(row, beta, 'session-aaa');
|
|
644
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
645
|
+
assert.equal(moveCalls().length, 1, 'never repeats the physical migration');
|
|
646
|
+
if (['navigated', 'healthy', 'unloaded'].includes(outcome)) {
|
|
647
|
+
assert.deepEqual(navigation, []);
|
|
648
|
+
} else if (outcome === 'navigation-during-release') {
|
|
649
|
+
assert.deepEqual(navigation, ['release']);
|
|
650
|
+
assert.equal(selected, 'session-other');
|
|
651
|
+
} else {
|
|
652
|
+
assert.deepEqual(navigation, ['release', 'session-aaa', 'ready']);
|
|
653
|
+
assert.equal(removed, false, 'new view can accept messages without manually switching');
|
|
654
|
+
}
|
|
655
|
+
assert.equal(releaseListeners.size, 0, 'retention observer is released');
|
|
656
|
+
if (outcome === 'success') {
|
|
657
|
+
const text = doc._body.querySelector('.wsm-toast')?.textContent;
|
|
658
|
+
assert.match(text, /重启应用/);
|
|
659
|
+
assert.doesNotMatch(text, /刷新页面/);
|
|
660
|
+
}
|
|
661
|
+
if (outcome === 'rollback') assert.match(doc._body.querySelector('.wsm-toast')?.textContent, /rolled back cleanly/);
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
for (const approve of [false, true]) {
|
|
666
|
+
test(`运行中拖动会话:${approve ? '明确停止后迁移' : '取消不执行'}`, async () => {
|
|
667
|
+
resetDom(); mount();
|
|
668
|
+
const row = sessionRow('运行中', 'session-aaa');
|
|
669
|
+
const beta = header('proj-beta');
|
|
670
|
+
fixture({ rows: [row] });
|
|
671
|
+
const prior = rpcResponder;
|
|
672
|
+
setResponder((endpoint, payload) => endpoint === 'mover.preview' ? { ok: true, value: {
|
|
673
|
+
status: 'warning', running: true, liveMigration: true, warnings: ['session is running; moving requires explicit stop confirmation']
|
|
674
|
+
} } : prior(endpoint, payload));
|
|
675
|
+
attach(beta);
|
|
676
|
+
await dragDrop(row, beta, 'session-aaa', { confirm: false });
|
|
677
|
+
const overlay = doc._body.querySelector('.wsm-overlay');
|
|
678
|
+
assert.equal(overlay.querySelector('.wsm-ok').textContent, '停止任务并迁移');
|
|
679
|
+
dispatch({ type: 'click', target: overlay.querySelector(approve ? '.wsm-ok' : '.wsm-cancel') });
|
|
680
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
681
|
+
assert.equal(moveCalls().length, approve ? 1 : 0);
|
|
682
|
+
if (approve) assert.equal(moveCalls()[0].payload.stopRunning, true);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
594
686
|
test('兜底映射错配时不得把错配 id 当成真实会话移动(P2 回归)', async () => {
|
|
595
687
|
resetDom();
|
|
596
688
|
mount();
|
|
@@ -62,13 +62,18 @@ for (const compression of ['none', 'zstd']) {
|
|
|
62
62
|
writeFileSync(src, original);
|
|
63
63
|
const projections = { identity: { cwd: A }, rows: { workingDirectory: { val: A }, title: { val: 'Keep title' } } };
|
|
64
64
|
let updated;
|
|
65
|
-
|
|
65
|
+
let cached = projections;
|
|
66
|
+
ctx.get = (key) => key === 'sessionProjectionCache' ? { table: {
|
|
67
|
+
get() { return cached; }, async put(id, value) { cached = updated = value; }
|
|
68
|
+
} } : undefined;
|
|
66
69
|
entityA.record.sessionIds.push('session-aaa');
|
|
67
70
|
entityB.failAttach = true;
|
|
68
71
|
const failure = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
69
72
|
assert.equal(failure.ok, false);
|
|
70
73
|
assert.match(failure.error.message, /rolled back cleanly/);
|
|
71
74
|
assert.deepEqual(readFileSync(src), original);
|
|
75
|
+
assert.equal(cached.identity.cwd, A, 'failed attach restores the title identity');
|
|
76
|
+
assert.deepEqual(cached.rows, projections.rows, 'rollback restores every original projection row');
|
|
72
77
|
entityB.failAttach = false;
|
|
73
78
|
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
74
79
|
assert.equal(moved.ok, true, JSON.stringify(moved));
|
|
@@ -1437,6 +1442,46 @@ test('mover.archived 列出归档会话:归属取自原始记账,cwd 挂错
|
|
|
1437
1442
|
assert.equal(bbb.suggestedTitle, 'wid-b');
|
|
1438
1443
|
});
|
|
1439
1444
|
|
|
1445
|
+
test('current per-record and live titles override legacy titles across repair, archive, preview and backups', async () => {
|
|
1446
|
+
apply(ctx);
|
|
1447
|
+
const id = 'session-aaa';
|
|
1448
|
+
writeFileSync(artifactPath(root, A, id), makeArtifact({ type: 'session', id, cwd: A }));
|
|
1449
|
+
const storage = join(root, 'storages');
|
|
1450
|
+
const checkpoint = join(storage, 'session_projcache', 'sessions', `${id}.json`);
|
|
1451
|
+
mkdirSync(dirname(checkpoint), { recursive: true });
|
|
1452
|
+
writeFileSync(join(storage, 'session_projcache.json'), JSON.stringify({ tables: {
|
|
1453
|
+
sessions: { [id]: { rows: { title: { val: 'Outdated title' } } } }
|
|
1454
|
+
} }), 'utf8');
|
|
1455
|
+
writeFileSync(checkpoint, JSON.stringify({ version: 7, record: {
|
|
1456
|
+
identity: { cwd: A }, rows: { title: { val: '标题保留测试' } }
|
|
1457
|
+
} }), 'utf8');
|
|
1458
|
+
entityA.record.sessionIds.push(id);
|
|
1459
|
+
sharedIndex.sessionPaths.set(id, A);
|
|
1460
|
+
ctx.workspaceRegistry.archivedSessionIds = [id];
|
|
1461
|
+
stashBackup(id, readFileSync(artifactPath(root, A, id)));
|
|
1462
|
+
const titles = async () => {
|
|
1463
|
+
const scan = await call('mover.scan');
|
|
1464
|
+
const archived = await call('mover.archived');
|
|
1465
|
+
const preview = await call('mover.preview', { sessionId: id, targetWorkspaceId: 'wid-b' });
|
|
1466
|
+
const backups = await call('mover.backups.list');
|
|
1467
|
+
for (const result of [scan, archived, preview, backups]) assert.equal(result.ok, true, JSON.stringify(result));
|
|
1468
|
+
return [scan.value.items.find((item) => item.sessionId === id).title,
|
|
1469
|
+
archived.value.items.find((item) => item.sessionId === id).title,
|
|
1470
|
+
preview.value.title, backups.value.items.find((item) => item.sessionId === id).title];
|
|
1471
|
+
};
|
|
1472
|
+
assert.deepEqual(await titles(), Array(4).fill('标题保留测试'));
|
|
1473
|
+
ctx.get = (key) => key === 'sessionProjectionCache' ? { table: {
|
|
1474
|
+
get: () => ({ rows: { title: { val: 'Latest live title' } } })
|
|
1475
|
+
} } : undefined;
|
|
1476
|
+
assert.deepEqual(await titles(), Array(4).fill('Latest live title'));
|
|
1477
|
+
ctx.get = () => undefined;
|
|
1478
|
+
writeFileSync(checkpoint, '{broken', 'utf8');
|
|
1479
|
+
assert.deepEqual(await titles(), Array(4).fill('Outdated title'));
|
|
1480
|
+
rmSync(join(storage, 'session_projcache.json'));
|
|
1481
|
+
const scan = await call('mover.scan');
|
|
1482
|
+
assert.equal(scan.value.items.find((item) => item.sessionId === id).title, '未命名会话');
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1440
1485
|
test('mover.unarchive 取消归档:归档集移除、账本不动、无迁移', async () => {
|
|
1441
1486
|
apply(ctx);
|
|
1442
1487
|
entityA.record.sessionIds.push('session-aaa');
|