dsh-session-ops 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/move.js ADDED
@@ -0,0 +1,830 @@
1
+ /**
2
+ * `dsh-session-ops` 的**搬迁半边**(原 `dsh-session-move` 插件的 Host 半边)。
3
+ *
4
+ * 这一半的代码搬自原 `dsh-session-move` 的 `lib/index.js`,差异只有四处:
5
+ * - 插件 entry 的 `name` / `inject` / `Config` / `resolveConfig` / `apply` 上移到
6
+ * `lib/index.js`(合并后只有一个 entry、一份配置 schema、一个 `apply`);
7
+ * - `API_PREFIX` 改名 `MOVE_API_PREFIX`(删除半边占着 `ADMIN_API_PREFIX`);
8
+ * - 配置键 `allowHosts` 改名 `moveAllowHosts`(与 `adminAllowHosts` 语义不同);
9
+ * - 原 `apply` 里"建配置/建日志/订阅 volatile/看 enabled"那段上移到入口,剩下的接线
10
+ * 成为 `mountMove(ctx, config, log)`。
11
+ * 其余一字未动(`createMover`、`/session-move/api` 的方法表、`session_move` 工具定义、
12
+ * socket 版信任栅栏、注释)。`./engine.js` / `./memory.js` / `./sessionlog.js` 也原样搬来。
13
+ *
14
+ * 能力:把一条已持久化的会话搬到另一个工作区——**真搬迁**:
15
+ * 1. 会话日志搬到目标工作区的 project 目录,header 的 `cwd` 改写成目标路径;
16
+ * 2. 目标工作区 `attachSession`、原工作区 `detachSession`(工作区注册表成员随动);
17
+ * 3. 若该会话此刻正被 Host 持有(`ctx.sessions` 里挂着),同时改写内存里的
18
+ * `session.header.cwd` 与持久化写入句柄的 header,让它之后继续往新位置写。
19
+ *
20
+ * 一处硬边界:**正在跑一轮的会话一律拒绝**(`refuseRunning` 默认 true)。它的写入句柄
21
+ * 随时可能落盘,搬家窗口里挪目录会让这一轮的事件写到一个不存在的位置(ENOENT)。
22
+ *
23
+ * 会话存储的公共知识(路径命名、帧读写、注册表成员/归档集合、「正在跑」判定、
24
+ * 宿主与 HTTP 面的底座)在 `lib/store/` 里;本文件只保留搬迁策略本身。
25
+ *
26
+ * @module dsh-session-ops/move
27
+ */
28
+ import { statSync } from 'node:fs';
29
+ import { realpath } from 'node:fs/promises';
30
+ import { createApiHandler, readJsonBody } from './store/http.js';
31
+ import { dshHome } from './store/host.js';
32
+ import { SESSION_RUNNING_STATUS, accountedSessionIds, agentSessionStatus, archivedSessionIds, findWorkspace, redirectSessionWriters, residentSessionIds, sessionWriteHandles, workspaceOwningSession, } from './store/registry.js';
33
+ import { applyPlan, planMove } from './engine.js';
34
+ import { applyMemoryFollow, planMemoryFollow } from './memory.js';
35
+ import { hasZstd } from './sessionlog.js';
36
+ import { messageOf } from './store/util.js';
37
+ /** HTTP 路由前缀(两半各一条,合并后仍然分开)。 */
38
+ export const MOVE_API_PREFIX = '/session-move/api';
39
+ // ---------------------------------------------------------------------------
40
+ // 日志 / $DSH_HOME(实现在共享的 `lib/store/host.js`)
41
+ // ---------------------------------------------------------------------------
42
+ /**
43
+ * `$DSH_HOME`(环境变量优先,默认 `~/.dsh`)。
44
+ *
45
+ * 实现在 `lib/store/host.js`——它与删除半边用的是同一份;
46
+ * 这里再导出,保持本仓库的公开面不变。
47
+ */
48
+ export { dshHome };
49
+ // ---------------------------------------------------------------------------
50
+ // HTTP 小工具
51
+ // ---------------------------------------------------------------------------
52
+ /**
53
+ * 从 `Host` 头里取 hostname:去掉端口,IPv6 的方括号保留(与 `URL.hostname` 对得上)。
54
+ * @param authority - `host` 或 `host:port`。
55
+ * @returns hostname;取不到时 undefined。
56
+ */
57
+ function hostnameOf(authority) {
58
+ const text = String(authority).trim();
59
+ if (text.length === 0)
60
+ return undefined;
61
+ if (text.startsWith('[')) {
62
+ const end = text.indexOf(']');
63
+ return end === -1 ? undefined : text.slice(0, end + 1);
64
+ }
65
+ const colon = text.lastIndexOf(':');
66
+ return colon === -1 ? text : text.slice(0, colon);
67
+ }
68
+ /**
69
+ * loopback 或显式白名单 authority 才放行,并额外拒绝浏览器侧的跨站 / 跨源请求。
70
+ *
71
+ * 原先这份栅栏只按 socket 的 `remoteAddress` 判断回环、`allowHosts` 只做精确字符串
72
+ * 匹配,**不看 `sec-fetch-site` / `Origin`**——恶意页面从任意站点 POST 到本机
73
+ * `127.0.0.1:3080` 时,socket 地址同样是回环,只看 `remoteAddress` 拦不住。
74
+ * 2026-09-25 与 `dsh-session-admin` 那份栅栏对齐(那条缺口已补),差异只剩
75
+ * 「本份以 `remoteAddress` 为回环判据、admin 以 `Host` 头为判据」。
76
+ * 共享包只提供路由骨架(`createApiHandler`),栅栏以回调形式传进去。
77
+ */
78
+ export function isTrustedRequest(req, allowHosts) {
79
+ // 1) 浏览器侧的跨站向量先拦掉。
80
+ if (req?.headers?.['sec-fetch-site'] === 'cross-site')
81
+ return false;
82
+ // 2) 传输层:loopback 直达;非 loopback 必须在白名单里(精确字符串匹配,原行为)。
83
+ const address = req.socket?.remoteAddress ?? '';
84
+ const fromLoopback = address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
85
+ const host = String(req.headers?.host ?? '');
86
+ if (!fromLoopback && !(Array.isArray(allowHosts) && allowHosts.includes(host)))
87
+ return false;
88
+ // 3) 带了 Origin 就必须与请求的 authority 同源。
89
+ const origin = req?.headers?.origin;
90
+ if (origin === undefined)
91
+ return true;
92
+ const hostname = hostnameOf(host);
93
+ if (hostname === undefined)
94
+ return false;
95
+ try {
96
+ // 头可能是数组(重复头);`URL` 的入参本来就是字符串,这里按原语义做一次 ToString。
97
+ return new URL(String(origin)).hostname === hostname;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /** 把"旧 cwd 指向目标工作区目录"这件事落实到注册表:attach 新的、detach 旧的。 */
104
+ function createMover(ctx, config, log) {
105
+ const persistence = ctx.sessionPersistence;
106
+ const registry = ctx.workspaceRegistry;
107
+ const sessionStore = ctx.get('sessions');
108
+ const agents = ctx.get('agents');
109
+ const rootOf = () => {
110
+ if (typeof config.root === 'string' && config.root.length > 0)
111
+ return config.root;
112
+ const fromBackend = persistence?.root;
113
+ if (typeof fromBackend === 'string' && fromBackend.length > 0)
114
+ return fromBackend;
115
+ throw new Error('cannot locate the session root: sessionPersistence exposes no `root`; set the plugin config `root`');
116
+ };
117
+ const compressionOf = () => (typeof persistence?.compression === 'string' ? persistence.compression : 'zstd');
118
+ // 下面这几个查询都来自 `lib/store/registry.js`(与删除半边共用一份):
119
+ // 服务挂在哪、id 怎么比、状态叫什么,规则只写在一个地方。
120
+ const residentIds = () => residentSessionIds(sessionStore);
121
+ const runningOf = (id) => agentSessionStatus(agents, id);
122
+ const writersOf = () => sessionWriteHandles(persistence);
123
+ const ownerOf = (sessionId) => workspaceOwningSession(registry.list(), sessionId);
124
+ const resolveWorkspace = (key) => findWorkspace(registry.list(), key);
125
+ /** 全量快照:工作区身份 + 每条会话的 cwd/归属/驻留状态。 */
126
+ async function list() {
127
+ const root = rootOf();
128
+ const snapshots = await persistence.list();
129
+ const resident = residentIds();
130
+ const writers = writersOf();
131
+ const archived = archivedSessionIds(registry);
132
+ const rows = [];
133
+ for (const snapshot of snapshots) {
134
+ const header = snapshot.header;
135
+ const id = String(header.id);
136
+ const running = runningOf(id);
137
+ const owner = ownerOf(id);
138
+ const handle = writers === undefined ? undefined : writers.get(id);
139
+ rows.push({
140
+ id,
141
+ cwd: typeof header.cwd === 'string' ? header.cwd : null,
142
+ createdAt: typeof header.createdAt === 'number' ? header.createdAt : null,
143
+ resident: resident.has(id),
144
+ running: running ?? null,
145
+ hasWriteHandle: handle !== undefined && handle !== null,
146
+ archived: archived.has(id),
147
+ workspaceId: owner === undefined ? null : String(owner.id),
148
+ workspaceTitle: owner === undefined ? null : String(owner.title),
149
+ movable: typeof header.cwd === 'string' && (running !== SESSION_RUNNING_STATUS || config.refuseRunning !== true),
150
+ });
151
+ }
152
+ rows.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0));
153
+ const workspaces = registry.list().map((workspace) => ({
154
+ id: String(workspace.id),
155
+ title: String(workspace.title),
156
+ path: String(workspace.path),
157
+ visible: workspace.sessionIds.length,
158
+ accounted: accountedSessionIds(workspace)?.length ?? 0,
159
+ }));
160
+ return {
161
+ root,
162
+ compression: compressionOf(),
163
+ zstd: hasZstd,
164
+ workspaces,
165
+ sessions: rows.slice(0, Math.max(1, Number(config.maxSessions) || 2000)),
166
+ counts: { stored: rows.length, resident: resident.size, workspaces: workspaces.length },
167
+ };
168
+ }
169
+ /**
170
+ * 记忆条目"随会话走"的模式;非法值退化成 off(绝不因为一个错拼去改写记忆库)。
171
+ *
172
+ * 是个函数而不是常量:`memoryFollow` 是 volatile 旋钮,在线改完就该在**下一次调用**
173
+ * 生效,所以在用到的地方现读。
174
+ */
175
+ function memoryMode() {
176
+ return ['off', 'copy', 'move'].includes(String(config.memoryFollow)) ? String(config.memoryFollow) : 'off';
177
+ }
178
+ /**
179
+ * 计算一次"记忆跟随"的**只读预览**:源/目标项目 slug、该会话产出的条目数。
180
+ * 真正写入由 {@link applyMemoryFollow} 在搬迁成功之后执行。
181
+ */
182
+ const memoryFollowOf = (sessionId, fromPath, targetPath) => {
183
+ try {
184
+ const plan = planMemoryFollow({ dshHome: dshHome(), sessionId, fromCwd: fromPath, toCwd: targetPath });
185
+ return {
186
+ plan,
187
+ summary: {
188
+ mode: memoryMode(),
189
+ sourceSlug: plan.source === null ? null : plan.source.slug,
190
+ targetSlug: plan.target.slug,
191
+ targetHadProject: plan.target.existed,
192
+ sourceEntries: plan.totalInSource,
193
+ matched: plan.matched.length,
194
+ titles: plan.matched.slice(0, 10).map((entry) => entry.title),
195
+ alreadyInTarget: plan.matched.filter((entry) => entry.existsInTarget).length,
196
+ note: memoryMode() === 'off'
197
+ ? 'memoryFollow=off:未改动任何记忆条目;上面是"若开启会复制哪些"的预览(条目按 frontmatter 的 session: 归属匹配)'
198
+ : undefined,
199
+ },
200
+ };
201
+ }
202
+ catch (error) {
203
+ return { plan: null, summary: { mode: memoryMode(), error: messageOf(error) } };
204
+ }
205
+ };
206
+ /** 只读校验:用 DSH 自己的持久化读取器把这条会话读一遍。 */
207
+ async function verify(sessionId) {
208
+ const handle = await persistence.open(sessionId, 'read');
209
+ try {
210
+ const result = await handle.read();
211
+ return {
212
+ headerCwd: typeof handle.header.cwd === 'string' ? handle.header.cwd : null,
213
+ inheritedEventCount: Number(handle.inheritedEventCount),
214
+ events: result?.events ? result.events.length : null,
215
+ };
216
+ }
217
+ finally {
218
+ try {
219
+ await handle.close();
220
+ }
221
+ catch {
222
+ /* 已经关掉了 */
223
+ }
224
+ }
225
+ }
226
+ /**
227
+ * 搬一条会话。
228
+ * @param request - `{sessionId, targetWorkspace, dryRun}`。
229
+ * @returns 报告:存储搬迁事实 + 内存状态改写事实 + 注册表成员变更事实 + 读回校验。
230
+ */
231
+ async function move(request) {
232
+ const sessionId = String(request.sessionId ?? '');
233
+ if (sessionId.length === 0)
234
+ throw new Error('sessionId is required');
235
+ const dryRun = request.dryRun === true;
236
+ const root = rootOf();
237
+ const compression = compressionOf();
238
+ const snapshots = await persistence.list();
239
+ const snapshot = snapshots.find((candidate) => String(candidate.header.id) === sessionId);
240
+ if (snapshot === undefined)
241
+ throw new Error(`stored session not found: ${sessionId}`);
242
+ const header = snapshot.header;
243
+ if (typeof header.cwd !== 'string' || header.cwd.length === 0) {
244
+ throw new Error(`session ${sessionId} has no cwd in its stored header`);
245
+ }
246
+ const target = resolveWorkspace(request.targetWorkspace);
247
+ if (target === undefined)
248
+ throw new Error(`unknown target workspace: ${String(request.targetWorkspace)}`);
249
+ const targetPath = String(target.path);
250
+ const fromPath = String(header.cwd);
251
+ const running = runningOf(sessionId);
252
+ if (running === SESSION_RUNNING_STATUS && config.refuseRunning !== false) {
253
+ throw new Error(`session ${sessionId} is running a turn; wait for it to finish (or set refuseRunning: false) before moving it`);
254
+ }
255
+ const report = {
256
+ sessionId,
257
+ from: fromPath,
258
+ to: targetPath,
259
+ workspace: { id: String(target.id), title: String(target.title) },
260
+ dryRun,
261
+ running: running ?? null,
262
+ };
263
+ if (fromPath === targetPath) {
264
+ // cwd 已经指向目标工作区:**不需要搬日志**,但可能压根没被这个工作区记账
265
+ // (导入 / 同步回来 / 工作区当时还不存在的会话就是这样,界面上显示为 Ungrouped)。
266
+ // 这种"归位"只差一次注册表成员修复。
267
+ const owner = ownerOf(sessionId);
268
+ if (owner !== undefined && String(owner.id) === String(target.id)) {
269
+ report.noop = true;
270
+ report.note = 'the session already lives in this workspace and is accounted by it';
271
+ return report;
272
+ }
273
+ report.attachOnly = true;
274
+ report.note = 'the session cwd already points at this workspace; only registry membership is repaired (no log move)';
275
+ if (dryRun) {
276
+ report.dryRunOnly = true;
277
+ return report;
278
+ }
279
+ try {
280
+ await target.attachSession(sessionId);
281
+ report.attachedTo = String(target.id);
282
+ }
283
+ catch (error) {
284
+ throw new Error(`cannot attach the session to workspace "${String(target.title)}": ${messageOf(error)}`);
285
+ }
286
+ if (owner !== undefined) {
287
+ try {
288
+ await owner.detachSession(sessionId);
289
+ report.detachedFrom = String(owner.id);
290
+ }
291
+ catch (error) {
292
+ report.detachError = messageOf(error);
293
+ }
294
+ }
295
+ try {
296
+ report.dshReadback = await verify(sessionId);
297
+ }
298
+ catch (error) {
299
+ report.dshReadbackError = messageOf(error);
300
+ }
301
+ report.ok = report.dshReadback !== undefined && report.dshReadback.headerCwd === targetPath;
302
+ log('info', `re-filed ${sessionId} under workspace ${targetPath} (attach only)`);
303
+ return report;
304
+ }
305
+ let targetStat;
306
+ try {
307
+ targetStat = statSync(targetPath);
308
+ }
309
+ catch (error) {
310
+ throw new Error(`target workspace directory is not readable: ${targetPath} (${messageOf(error)})`);
311
+ }
312
+ if (!targetStat.isDirectory())
313
+ throw new Error(`target workspace path is not a directory: ${targetPath}`);
314
+ const plan = planMove({ root, compression, sessionId, fromCwd: fromPath, toCwd: targetPath });
315
+ report.plan = {
316
+ generation: plan.generation.name,
317
+ version: plan.generation.version,
318
+ compressed: plan.generation.compressed,
319
+ bytesBefore: plan.bytesBefore,
320
+ bytesAfter: plan.bytesAfter,
321
+ frames: plan.frameCount,
322
+ sourcePath: plan.sourcePath,
323
+ targetPath: plan.targetPath,
324
+ leaseFile: plan.leaseFile ?? null,
325
+ leftInSourceDir: plan.otherFiles,
326
+ otherGenerations: plan.otherGenerations,
327
+ };
328
+ // 记忆条目预览(只读,dry-run 也会给):项目作用域是按工作区根目录定的,
329
+ // 所以"要不要把这条会话在源工作区写下的条目一并带过去"是另一个显式选择。
330
+ const memory = memoryFollowOf(sessionId, fromPath, targetPath);
331
+ report.memoryFollow = memory.summary;
332
+ if (dryRun) {
333
+ report.dryRunOnly = true;
334
+ return report;
335
+ }
336
+ // 1) 落盘:日志搬家 + header cwd 改写。
337
+ const storage = applyPlan(plan);
338
+ report.storage = storage;
339
+ if (storage.ok !== true) {
340
+ throw new Error(`the log was moved to ${plan.targetPath} but the rewritten header did not verify: ${JSON.stringify(storage.verifiedHeader)}`);
341
+ }
342
+ // 2) 内存态:让"以后继续写"落到新位置。
343
+ // 写入句柄每次 append 都从自己的 header 重新推 logPath(appendLines → logPath(meta.cwd)),
344
+ // 所以改写 handle.header 就够了;会话对象自身的 header 也同步,供工具解析 cwd;
345
+ // 注册表里的头部缓存也要一起改。三处都在 `lib/store/registry.js`。
346
+ const redirect = await redirectSessionWriters({
347
+ writers: writersOf(),
348
+ sessions: sessionStore,
349
+ registry,
350
+ sessionId,
351
+ cwd: targetPath,
352
+ });
353
+ const live = redirect.liveSession;
354
+ if (redirect.flushError !== undefined)
355
+ report.handleFlushError = redirect.flushError;
356
+ if (redirect.writeHandleRedirected)
357
+ report.writeHandleRedirected = true;
358
+ if (redirect.liveHeaderUpdated)
359
+ report.liveHeaderUpdated = true;
360
+ if (redirect.registryHeaderCacheUpdated)
361
+ report.registryHeaderCacheUpdated = true;
362
+ // 3) 注册表成员:attach 目标、detach 原属。attachSession 会校验 header.cwd
363
+ // (刚改写过的),并在成功后把进程内的 canonical cwd 索引指向新路径。
364
+ const source = ownerOf(sessionId);
365
+ try {
366
+ await target.attachSession(sessionId);
367
+ report.attachedTo = String(target.id);
368
+ }
369
+ catch (error) {
370
+ throw new Error(`the session log was moved, but attaching it to workspace "${String(target.title)}" failed: ${messageOf(error)}`);
371
+ }
372
+ if (source !== undefined && String(source.id) !== String(target.id)) {
373
+ try {
374
+ await source.detachSession(sessionId);
375
+ report.detachedFrom = String(source.id);
376
+ }
377
+ catch (error) {
378
+ report.detachError = messageOf(error);
379
+ }
380
+ }
381
+ // 记忆条目跟随(可选,默认关):日志已搬成、注册表已换边之后才动记忆库,
382
+ // 且失败只记录、不让一次成功的搬迁变成失败。
383
+ if (memory.plan !== null && memoryMode() !== 'off' && memory.plan.source !== null && memory.plan.matched.length > 0) {
384
+ try {
385
+ memory.summary.applied = applyMemoryFollow(memory.plan, memoryMode());
386
+ }
387
+ catch (error) {
388
+ memory.summary.applyError = messageOf(error);
389
+ }
390
+ }
391
+ // 4) 读回:DSH 自己的读取器能读到才算真的搬成了。
392
+ try {
393
+ report.dshReadback = await verify(sessionId);
394
+ }
395
+ catch (error) {
396
+ report.dshReadbackError = messageOf(error);
397
+ }
398
+ // 记忆作用域提示(只提示,不改动任何记忆数据):
399
+ // dsh-cross-memory 把「项目作用域」定在工作区根目录上($DSH_HOME/memories/projects/<slug>,
400
+ // 由 session.header.cwd 经 findProjectRoot 解析),而且解析结果按 **Session 对象**
401
+ // 缓存在 rootCache(WeakMap)里。所以搬完 cwd 之后:冷会话下次加载就用新工作区;
402
+ // 驻留会话要等它重新加载才会切换。记忆条目本身不随会话搬迁。
403
+ if (ctx.get('memories') !== undefined) {
404
+ report.memoryScope = live !== undefined
405
+ ? 'dsh-cross-memory 在场:该会话仍驻留,工作区根被按 Session 对象缓存,项目记忆作用域要等它重新加载(重新 resume / 重启 dsh web)才切到新工作区;记忆条目不随会话搬迁。'
406
+ : 'dsh-cross-memory 在场:该会话是冷会话,下次加载即按新 cwd 解析项目作用域;记忆条目不随会话搬迁。';
407
+ }
408
+ report.ok = report.dshReadback !== undefined && report.dshReadback.headerCwd === targetPath;
409
+ log('info', `moved ${sessionId}: ${fromPath} -> ${targetPath} (${storage.bytesBefore} -> ${storage.bytesAfter} bytes)`);
410
+ return report;
411
+ }
412
+ return { list, move, verify, rootOf, compressionOf, resolveWorkspace };
413
+ }
414
+ // ---------------------------------------------------------------------------
415
+ // 模型侧工具
416
+ // ---------------------------------------------------------------------------
417
+ /**
418
+ * 工具输入 schema。**必须是真正的 JSON Schema**:`ctx.tools.register()` 收的是
419
+ * 已编译的 ToolDefinition,`required` 得是对象级的字符串数组,且根必须有
420
+ * `type: 'object'`,否则请求会被上游 400 掉。
421
+ */
422
+ const TOOL_PARAMETERS = {
423
+ type: 'object',
424
+ additionalProperties: true,
425
+ properties: {
426
+ action: {
427
+ type: 'string',
428
+ enum: ['list', 'move'],
429
+ description: 'list = 列出工作区与会话(含归属/驻留状态);move = 把一条会话搬到目标工作区。',
430
+ },
431
+ sessionId: { type: 'string', description: 'action="move":要搬的会话 id(如 session-<uuid>)。' },
432
+ targetWorkspace: { type: 'string', description: 'action="move":目标工作区 id、绝对路径或标题(唯一匹配)。' },
433
+ dryRun: { type: 'boolean', description: 'action="move":只报告计划,不动任何数据。' },
434
+ filter: { type: 'string', description: 'action="list":按 id / cwd / 工作区标题过滤。' },
435
+ limit: { type: 'integer', description: 'action="list":最多返回多少条(默认 40)。' },
436
+ },
437
+ required: ['action'],
438
+ };
439
+ /** 工具输出 schema(同样是真 JSON Schema)。 */
440
+ const TOOL_OUTPUT = {
441
+ type: 'object',
442
+ additionalProperties: true,
443
+ required: ['ok', 'summary'],
444
+ properties: {
445
+ ok: { type: 'boolean', description: '调用本身是否成功。' },
446
+ summary: { type: 'string', description: '一句话结论。' },
447
+ report: { type: 'object', additionalProperties: true, description: 'action="move" 的完整报告。' },
448
+ workspaces: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'action="list" 的工作区行。' },
449
+ sessions: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'action="list" 的会话行。' },
450
+ },
451
+ };
452
+ /** 工具的注册体。 */
453
+ function buildTool(mover, config, log) {
454
+ return {
455
+ name: 'session_move',
456
+ description: 'Move one persisted DSH session into another workspace, durably: the session log is relocated to the target workspace\'s project directory, its header cwd is rewritten, and the workspace registry membership follows. ' +
457
+ 'DSH ties a session to a workspace ONLY through the session header cwd, so this is the real move (not a display grouping). ' +
458
+ 'Always call action="list" first to see workspace ids/titles and which sessions are movable, then action="move" with the ids. ' +
459
+ 'Sessions running a turn are refused. Sessions currently loaded but idle are supported: their in-memory header and persistence write handle are redirected too. ' +
460
+ 'Use dryRun=true to preview without touching anything.',
461
+ parameters: TOOL_PARAMETERS,
462
+ output: {
463
+ schema: TOOL_OUTPUT,
464
+ render: (_args, value) => [{ type: 'text', text: value.summary }],
465
+ },
466
+ presentCall: (args) => ({
467
+ card: 'generic',
468
+ title: args.action === 'move' ? 'Move session to workspace' : 'List sessions and workspaces',
469
+ kind: 'other',
470
+ rawInput: { action: args.action, sessionId: args.sessionId, targetWorkspace: args.targetWorkspace },
471
+ }),
472
+ async execute(args) {
473
+ if (args.action === 'list') {
474
+ const snapshot = await mover.list();
475
+ const needle = typeof args.filter === 'string' ? args.filter.trim().toLowerCase() : '';
476
+ const filtered = needle.length === 0
477
+ ? snapshot.sessions
478
+ : snapshot.sessions.filter((row) => row.id.toLowerCase().includes(needle) ||
479
+ (row.cwd ?? '').toLowerCase().includes(needle) ||
480
+ (row.workspaceTitle ?? '').toLowerCase().includes(needle));
481
+ const limit = Math.max(1, Math.min(Number(args.limit ?? 40), 500));
482
+ const rows = filtered.slice(0, limit);
483
+ return {
484
+ ok: true,
485
+ workspaces: snapshot.workspaces,
486
+ sessions: rows,
487
+ summary: `${snapshot.workspaces.length} workspaces, ${snapshot.counts.stored} stored sessions (${snapshot.counts.resident} loaded). ` +
488
+ `Showing ${rows.length} of ${filtered.length} matching rows. ` +
489
+ rows.slice(0, 10).map((row) => `${row.id} — ${row.cwd ?? 'no cwd'} [${row.workspaceTitle ?? 'ungrouped'}${row.running === SESSION_RUNNING_STATUS ? ', running' : row.resident ? ', loaded' : ''}]`).join(' | '),
490
+ };
491
+ }
492
+ if (args.action === 'move') {
493
+ const report = await mover.move({
494
+ sessionId: args.sessionId,
495
+ targetWorkspace: args.targetWorkspace,
496
+ dryRun: args.dryRun === true,
497
+ });
498
+ if (report.noop === true) {
499
+ return { ok: true, report, summary: `No move needed: ${report.sessionId} already lives in "${report.workspace.title}".` };
500
+ }
501
+ if (report.attachOnly === true) {
502
+ const head = report.dryRunOnly === true
503
+ ? `DRY RUN: would re-file ${report.sessionId} under workspace "${report.workspace.title}"`
504
+ : `Re-filed ${report.sessionId} under workspace "${report.workspace.title}"`;
505
+ return {
506
+ ok: true,
507
+ report,
508
+ summary: `${head}. Its cwd (${report.to}) already pointed at that workspace, so only registry membership changed (no log move).`,
509
+ };
510
+ }
511
+ // 走到这里说明 report.plan / report.storage 一定存在(move() 在 dry-run 与
512
+ // 落盘两条路径上都先填好它们才返回);类型上是可选字段,就地断言一次。
513
+ const plan = report.plan;
514
+ const storage = report.storage;
515
+ const head = report.dryRunOnly === true
516
+ ? `DRY RUN: would move ${report.sessionId} from ${report.from} to ${report.to}`
517
+ : `Moved ${report.sessionId}: ${report.from} -> ${report.to} (workspace "${report.workspace.title}")`;
518
+ const detail = report.dryRunOnly === true
519
+ ? `${plan.bytesBefore} bytes / ${plan.frames ?? 'plain'} frames -> ${plan.bytesAfter} bytes`
520
+ : `${storage.bytesBefore} -> ${storage.bytesAfter} bytes; readback cwd = ${report.dshReadback?.headerCwd ?? 'unverified'}` +
521
+ (report.writeHandleRedirected === true ? '; live write handle redirected' : '') +
522
+ (report.liveHeaderUpdated === true ? '; live header updated' : '');
523
+ return { ok: true, report, summary: `${head}. ${detail}` };
524
+ }
525
+ return { ok: false, summary: `unknown action: ${String(args.action)}` };
526
+ },
527
+ };
528
+ }
529
+ /**
530
+ * 挂载搬迁半边:路由 + 工具。
531
+ *
532
+ * 与删除半边刻意不同的两点(原样保留的理由):
533
+ * - 信任栅栏看 socket 的 `remoteAddress`(`isTrustedRequest`),`allowHosts` 只做精确
534
+ * 字符串匹配;删除半边看 `Host` 头;
535
+ * - 请求体按**严格模式**读(`requireObject: true`,非普通对象一律 400)。
536
+ *
537
+ * @param ctx - Host cordis 上下文(`webServer` / `tools` / `sessionPersistence` /
538
+ * `workspaceRegistry`;`sessions` / `agents` 是可选事实源,用 `ctx.get` 取)。
539
+ * @param config - 已解包的**活配置对象**(身份由入口保持,volatile 更新时原地覆盖)。
540
+ * @param log - `(level, message) => void`(入口传的是 move 那套行格式的文件日志)。
541
+ * @returns 无。
542
+ */
543
+ export function mountMove(ctx, config, log) {
544
+ if (!hasZstd) {
545
+ log('warn', 'this node build lacks zlib zstd support; moves are refused');
546
+ }
547
+ const mover = createMover(ctx, config, log);
548
+ let rootNote = 'unknown';
549
+ try {
550
+ rootNote = mover.rootOf();
551
+ }
552
+ catch (error) {
553
+ rootNote = `unresolved (${messageOf(error)})`;
554
+ }
555
+ log('info', `mounted: prefix=${MOVE_API_PREFIX} root=${rootNote} compression=${mover.compressionOf()} allowHosts=[${config.moveAllowHosts.join(', ')}] memoryFollow=${['off', 'copy', 'move'].includes(String(config.memoryFollow)) ? config.memoryFollow : 'off (invalid value ignored)'}`);
556
+ const api = {
557
+ /** 工作区 + 会话快照。 */
558
+ list: async () => mover.list(),
559
+ /** 搬一条会话。 */
560
+ move: async (payload) => mover.move(payload),
561
+ /** 用 DSH 自己的读取器读回一条会话。 */
562
+ verify: async (payload) => mover.verify(String(payload.sessionId ?? '')),
563
+ /** 能力/诊断。 */
564
+ config: async () => ({
565
+ prefix: MOVE_API_PREFIX,
566
+ root: (() => {
567
+ try {
568
+ return mover.rootOf();
569
+ }
570
+ catch {
571
+ return null;
572
+ }
573
+ })(),
574
+ compression: mover.compressionOf(),
575
+ zstd: hasZstd,
576
+ refuseRunning: config.refuseRunning,
577
+ maxSessions: config.maxSessions,
578
+ }),
579
+ };
580
+ ctx.effect(() => ctx.webServer.register({
581
+ kind: 'prefix',
582
+ path: MOVE_API_PREFIX,
583
+ // 路由骨架(403/404/200/400 的响应体、方法名解析、GET 走 query)在共享包里;
584
+ // 两件"只有本插件才有的策略"以参数传进去:
585
+ // - 信任栅栏(socket remoteAddress 版,见 isTrustedRequest);
586
+ // - 请求体按**严格模式**读(非普通对象一律 400),这是本插件原有的行为。
587
+ handler: createApiHandler({
588
+ prefix: MOVE_API_PREFIX,
589
+ methods: api,
590
+ trustRequest: (req) => isTrustedRequest(req, config.moveAllowHosts),
591
+ readBody: (req) => readJsonBody(req, { requireObject: true }),
592
+ log,
593
+ }),
594
+ }), 'dsh-session-move: api route');
595
+ ctx.effect(() => ctx.tools.register(buildTool(mover, config, log)), 'dsh-session-move: session_move tool');
596
+ }
597
+ // ---------------------------------------------------------------------------
598
+ // fork 子会话成员自愈
599
+ // ---------------------------------------------------------------------------
600
+ /** 一次 `session/created` 里能拿到的会话事实。 */
601
+ const FORK_CHILD_ORIGIN = 'subagent';
602
+ /**
603
+ * 判断一条会话头是不是「普通 fork 子会话」——自愈只对这类会话动手。
604
+ *
605
+ * 依据是 Host 的 fork 实现(`session/fork` 里 `meta: { parentSession, isSeeded: true }`)
606
+ * 与子代理实现(`origin: 'subagent'`、`delegationDepth: 1`)这两个实测事实:
607
+ * 子代理会话的 cwd 同样指着工作区,但它们本来就不该入册,必须排除。
608
+ *
609
+ * @param header - 会话头(`session.header`)。
610
+ * @returns 普通 fork 子会话为 true。
611
+ */
612
+ function isForkChild(header) {
613
+ if (header === null || typeof header !== 'object')
614
+ return false;
615
+ const record = header;
616
+ if (typeof record.parentSession !== 'string' || record.parentSession.length === 0)
617
+ return false;
618
+ if (record.origin === FORK_CHILD_ORIGIN)
619
+ return false;
620
+ const depth = Number(record.delegationDepth);
621
+ return !(Number.isFinite(depth) && depth > 0);
622
+ }
623
+ /**
624
+ * 构建一份「fork 子会话漏挂工作区」的补记账器(创建期监听与启动期扫描共用)。
625
+ *
626
+ * ## 背景(实测,不是推测)
627
+ *
628
+ * Host 的 `session/fork` 本来会给自己找归属:`forkWorkspace(source.header)` 按
629
+ * 「哪个工作区的成员表里有源会话」挑一个,再 `workspace.attachSession(childId)`,
630
+ * 失败还会回 `session/workspace-attach-failed`。但实测中「编辑重发」产生的子会话
631
+ * 最终仍是 `ungrouped`(`session_admin` 里 `workspaceTitle: null`),而第一次 fork
632
+ * 的父会话明明是某个工作区的成员——结果是客户端只把它们记进「占位」列表,
633
+ * 刷新/重启后侧栏里再也看不到,就是「刚才那个会话怎么找不到了」。
634
+ *
635
+ * ## 规则刻意收窄(创建期与启动期同一套)
636
+ *
637
+ * - 只处理普通 fork 子会话(见 {@link isForkChild});
638
+ * - 父会话必须已经挂在某个工作区,子会话就挂到**同一个**工作区;父会话自己都没
639
+ * 归属就不猜,只记一行日志;
640
+ * - 子会话 `header.cwd` 必须 realpath 后真的等于那个工作区的路径,否则不挂
641
+ * (宁可留一条 ungrouped,也不把别处的会话塞进工作区)。
642
+ *
643
+ * 幂等:已记账直接返回;同一 id 的并发事件用 `inFlight` 去重;attach 失败只记日志,
644
+ * 绝不上抛——这条自愈路径失败不能影响 Host 创建会话本身。
645
+ *
646
+ * 不做全量扫描:343 条存量会话里 155 条是子代理、还有一批刻意 ungrouped 的迁移
647
+ * 会话,全量 attach 会改写用户既有的侧栏结构。启动期扫描只认 fork 子会话
648
+ * (见 {@link sweepForkChildrenOnBoot}),存量漂移仍可用 `session_move` 显式修。
649
+ *
650
+ * @param ctx - Host cordis 上下文(需要 `workspaceRegistry`)。
651
+ * @param log - `(level, message) => void`(入口传的是 move 那套行格式的文件日志)。
652
+ * @returns repairer(`{ repair, ownerOf }`),注册表不可用时 `undefined`。
653
+ */
654
+ function createForkMembershipRepairer(ctx, log) {
655
+ const registry = ctx.workspaceRegistry;
656
+ if (registry === undefined || typeof registry.list !== 'function') {
657
+ log('warn', 'membership repair skipped: workspaceRegistry is unavailable');
658
+ return undefined;
659
+ }
660
+ /** 正在补记账的会话 id(并发重复事件去重)。 */
661
+ const inFlight = new Set();
662
+ /** realpath 不抛异常的版本:解析不出来就没法比较。 */
663
+ const canonical = async (path) => {
664
+ try {
665
+ return await realpath(path);
666
+ }
667
+ catch {
668
+ return undefined;
669
+ }
670
+ };
671
+ /** 一条会话当前登记在哪个工作区(按注册表的完整记账,不是过滤后的视图)。 */
672
+ const ownerOf = (sessionId) => {
673
+ try {
674
+ return workspaceOwningSession(registry.list(), sessionId);
675
+ }
676
+ catch {
677
+ return undefined;
678
+ }
679
+ };
680
+ /**
681
+ * 给一条 fork 子会话补记账。
682
+ * @param sessionId - 子会话 id。
683
+ * @param header - 子会话头。
684
+ * @param reason - 触发来源,只进日志。
685
+ * @returns 完成(含跳过)后 resolve。
686
+ */
687
+ async function repair(sessionId, header, reason) {
688
+ if (typeof sessionId !== 'string' || sessionId.length === 0)
689
+ return;
690
+ if (inFlight.has(sessionId))
691
+ return;
692
+ if (!isForkChild(header))
693
+ return;
694
+ if (ownerOf(sessionId) !== undefined)
695
+ return;
696
+ inFlight.add(sessionId);
697
+ try {
698
+ const parentSession = String(header.parentSession);
699
+ const owner = ownerOf(parentSession);
700
+ if (owner === undefined) {
701
+ log('info', `membership: fork child ${sessionId} stays ungrouped — parent ${parentSession} belongs to no workspace`);
702
+ return;
703
+ }
704
+ if (typeof owner.attachSession !== 'function') {
705
+ log('warn', `membership: workspace ${owner.id} exposes no attachSession; cannot re-file ${sessionId}`);
706
+ return;
707
+ }
708
+ const cwd = typeof header.cwd === 'string' ? header.cwd : '';
709
+ if (cwd.length === 0) {
710
+ log('warn', `membership: fork child ${sessionId} carries no header cwd; not attaching`);
711
+ return;
712
+ }
713
+ const [sessionPath, workspacePath] = await Promise.all([canonical(cwd), canonical(String(owner.path))]);
714
+ if (sessionPath === undefined || workspacePath === undefined || sessionPath !== workspacePath) {
715
+ log('warn', `membership: fork child ${sessionId} cwd '${cwd}' does not resolve to workspace '${String(owner.path)}'; not attaching`);
716
+ return;
717
+ }
718
+ await owner.attachSession(sessionId);
719
+ log('info', `membership: attached fork child ${sessionId} to workspace ${owner.id} (${reason})`);
720
+ }
721
+ catch (error) {
722
+ log('warn', `membership: attach failed for ${sessionId}: ${messageOf(error)}`);
723
+ }
724
+ finally {
725
+ inFlight.delete(sessionId);
726
+ }
727
+ }
728
+ return { repair, ownerOf };
729
+ }
730
+ /**
731
+ * 启动期的成员自愈扫描:把**已经丢掉记账**的 fork 子会话补回去。
732
+ *
733
+ * ## 为什么创建期补挂不够(实测的第二条证据链)
734
+ *
735
+ * `session/created` 那一刻补挂只挡住「Host fork 时就没挂」这一种。实测中有些 fork
736
+ * 在 Host 侧确实走了 `workspace.attachSession(childId)`(RPC 返回成功、插件侧没有
737
+ * `fork 失败` 日志),但子会话最终仍不在任何工作区的 `sessionIds` 里——也就是说
738
+ * 记账**写进去之后又没了**。同一个时间窗里,父会话的归档也没落住。
739
+ *
740
+ * 能同时抹掉「新 attach」与「归档集合」的只有一件事:**另一个 Host 进程用自己内存里
741
+ * 的旧快照重写了 `$DSH_HOME/storages/workspace.json`**。存储层是整文件原子替换、
742
+ * 无跨进程锁(`dsh-storage-json` 的注释写的就是「一个 unit 文件只有一个写者,后写
743
+ * 覆盖先写是对的」),而同一台机器上同时跑两个 Host、共用同一个 `~/.dsh` 就会出现
744
+ * 这类丢失。这类丢失发生在 attach 之后,创建期的补挂拦不住。
745
+ *
746
+ * 所以这里补一道**启动期**的定向扫描:只处理 fork 子会话(`isForkChild`),且仍然
747
+ * 要求「父会话已被工作区记账 + 子会话 cwd 规范化后等于该工作区路径」;不碰子代理、
748
+ * 不碰没有父会话的普通会话,也不做全量 attach,避免改写用户刻意留着的 ungrouped。
749
+ *
750
+ * 幂等:已记账直接跳过;失败只记日志,绝不让插件挂载失败。
751
+ *
752
+ * @param ctx - Host cordis 上下文(需要 `workspaceRegistry` 与 `sessionPersistence`)。
753
+ * @param config - 活配置对象;`repairForkChildrenOnBoot === false` 时跳过整轮扫描。
754
+ * @param log - `(level, message) => void`。
755
+ * @param shared - 与创建期监听器共享的 repairer(`mountForkMembershipRepair` 内部用)。
756
+ * @returns 扫描结束(含跳过)后 resolve 的报告;永不 reject。
757
+ */
758
+ export async function sweepForkChildrenOnBoot(ctx, config = {}, log = () => { }, shared) {
759
+ const report = { checked: 0, attached: 0, skipped: undefined };
760
+ if (config !== null && typeof config === 'object' && config.repairForkChildrenOnBoot === false) {
761
+ report.skipped = 'disabled';
762
+ return report;
763
+ }
764
+ const repairer = shared ?? createForkMembershipRepairer(ctx, log);
765
+ if (repairer === undefined) {
766
+ report.skipped = 'registry-unavailable';
767
+ return report;
768
+ }
769
+ const persistence = ctx?.sessionPersistence;
770
+ if (persistence === undefined || typeof persistence.list !== 'function') {
771
+ report.skipped = 'persistence-unavailable';
772
+ return report;
773
+ }
774
+ let snapshots;
775
+ try {
776
+ snapshots = await persistence.list();
777
+ }
778
+ catch (error) {
779
+ log('warn', `membership sweep: session listing failed: ${messageOf(error)}`);
780
+ report.skipped = 'listing-failed';
781
+ return report;
782
+ }
783
+ for (const snapshot of Array.isArray(snapshots) ? snapshots : []) {
784
+ const header = snapshot?.header;
785
+ const sessionId = String(header?.id ?? snapshot?.id ?? '');
786
+ if (sessionId.length === 0)
787
+ continue;
788
+ if (!isForkChild(header))
789
+ continue;
790
+ report.checked += 1;
791
+ try {
792
+ const wasAccounted = repairer.ownerOf(sessionId) !== undefined;
793
+ await repairer.repair(sessionId, header, 'boot-sweep');
794
+ if (!wasAccounted && repairer.ownerOf(sessionId) !== undefined)
795
+ report.attached += 1;
796
+ }
797
+ catch (error) {
798
+ log('warn', `membership sweep: ${sessionId} failed: ${messageOf(error)}`);
799
+ }
800
+ }
801
+ if (report.attached > 0)
802
+ log('info', `membership sweep: re-attached ${report.attached} of ${report.checked} fork child(ren) that had lost workspace accounting`);
803
+ return report;
804
+ }
805
+ /**
806
+ * 挂载 fork 子会话的成员自愈:创建期监听 + 启动期定向扫描。
807
+ *
808
+ * @param ctx - Host cordis 上下文。
809
+ * @param config - 活配置对象。
810
+ * @param log - `(level, message) => void`。
811
+ * @returns 启动期扫描的 Promise(Host/cordis 忽略;测试可 await 以确定性观察结果)。
812
+ */
813
+ export function mountForkMembershipRepair(ctx, config, log) {
814
+ const repairer = createForkMembershipRepairer(ctx, log);
815
+ if (repairer === undefined)
816
+ return undefined;
817
+ // 返回值给测试留一个可 await 的口子:Host 与 cordis 都忽略监听器返回值。
818
+ ctx.on('session/created', (session) => {
819
+ try {
820
+ return repairer.repair(String(session?.id ?? ''), session?.header, 'session/created');
821
+ }
822
+ catch (error) {
823
+ log('warn', `membership: session/created handler failed: ${messageOf(error)}`);
824
+ return undefined;
825
+ }
826
+ });
827
+ log('info', 'membership repair mounted: fork children are re-attached on session/created and by the boot sweep');
828
+ return sweepForkChildrenOnBoot(ctx, config, log, repairer);
829
+ }
830
+ // 2026-09-25:本文件由 dsh-session-move/lib/index.js 逐字搬入 dsh-session-ops(合并包)。