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