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/engine.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * 搬迁引擎:把一条会话日志从「旧 cwd 的 project 目录」搬到「新 cwd 的 project 目录」,
3
+ * 并改写 header 里的 cwd。不依赖 cordis,便于单测(见 test/core.test.mjs)。
4
+ *
5
+ * 命名与格式事实(都来自 dsh-session-persistence-jsonl 的实测与源码):
6
+ * - 日志路径 `<root>/<projectKey(cwd)>/<encodeSegment(id)>/session[.vN].jsonl[.zstd]`;
7
+ * - `findLog` 会扫**所有** project 目录找同一个 id,多于一处就抛
8
+ * `duplicate JSONL session id ... appears in multiple project directories`;
9
+ * - generation 文件名必须规范(v0 不带 `vN`),临时名/带后缀的备份名不会被当成 generation。
10
+ * 因此搬迁必须做到「任何时刻都不存在两份规范名的日志」,见 {@link applyPlan} 的三步改名。
11
+ *
12
+ * @module dsh-session-ops/engine
13
+ */
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { ZSTD_MAGIC, readHeaderFromBuffer, rewriteHeaderCwd, scanZstdFrames, } from './store/frames.js';
17
+ import { findGeneration, parseGenerationName, projectKey, sessionDir, } from './store/paths.js';
18
+ import { messageOf } from './store/util.js';
19
+ /** 迁移中使用的临时文件名后缀(都**不**匹配 generation 规范名)。 */
20
+ export const TMP_SUFFIX = '.session-move-tmp';
21
+ export const PARKED_SUFFIX = '.session-move-old';
22
+ /**
23
+ * `findGeneration` / `parseGenerationName`(「目录里哪份才算规范 generation」)已经搬到
24
+ * `lib/store/paths.js`——删除路径也要认得同一套命名,规则只该有一份。
25
+ * 这里保留再导出,是为了不打断本仓库既有的导入者(`test/core.test.mjs` 就按
26
+ * `../lib/engine.js` 导入它们)。
27
+ */
28
+ export { findGeneration, parseGenerationName };
29
+ /**
30
+ * 计算一次搬迁的计划:读源日志、改写 cwd、给出目标路径。**只读**,不写任何东西。
31
+ *
32
+ * @param options - `{root, sessionId, fromCwd, toCwd, compression}`。
33
+ * @returns 计划对象(含 `buffer`,供 {@link applyPlan} 直接落盘)。
34
+ * @throws 源日志缺失、header 与请求不符、目标已存在、源与目标同目录时。
35
+ */
36
+ export function planMove(options) {
37
+ const { root, sessionId, fromCwd, toCwd } = options;
38
+ const compression = options.compression ?? 'zstd';
39
+ if (typeof root !== 'string' || root.length === 0)
40
+ throw new Error('session root is unknown');
41
+ if (typeof sessionId !== 'string' || sessionId.length === 0)
42
+ throw new Error('session id is required');
43
+ if (typeof fromCwd !== 'string' || fromCwd.length === 0)
44
+ throw new Error('source cwd is required');
45
+ if (typeof toCwd !== 'string' || toCwd.length === 0)
46
+ throw new Error('target cwd is required');
47
+ if (fromCwd === toCwd)
48
+ throw new Error('source and target cwd are identical');
49
+ const fromKey = projectKey(fromCwd);
50
+ const toKey = projectKey(toCwd);
51
+ if (fromKey === toKey)
52
+ throw new Error('source and target resolve to the same project directory');
53
+ const sourceDir = sessionDir(root, fromCwd, sessionId);
54
+ const targetDir = sessionDir(root, toCwd, sessionId);
55
+ const found = findGeneration(sourceDir);
56
+ if ('error' in found) {
57
+ throw new Error(`${found.error}${found.others !== undefined && found.others.length > 0 ? ` (found: ${found.others.join(', ')})` : ''}`);
58
+ }
59
+ const sourcePath = join(sourceDir, found.selected.name);
60
+ const targetPath = join(targetDir, found.selected.name);
61
+ const buffer = readFileSync(sourcePath);
62
+ const compressed = found.selected.compressed;
63
+ const header = readHeaderFromBuffer(buffer, compressed);
64
+ if (header === undefined)
65
+ throw new Error(`cannot read a session header from ${sourcePath}`);
66
+ if (String(header.id) !== sessionId)
67
+ throw new Error(`header id ${String(header.id)} does not match requested session ${sessionId}`);
68
+ if (typeof header.cwd === 'string' && header.cwd !== fromCwd) {
69
+ throw new Error(`header cwd ${header.cwd} does not match the stored cwd ${fromCwd}`);
70
+ }
71
+ const rewritten = rewriteHeaderCwd(buffer, toCwd, { compressed });
72
+ if (rewritten.rewritten !== true)
73
+ throw new Error(`cannot rewrite the header cwd: ${rewritten.reason ?? 'unknown reason'}`);
74
+ let targetExists = false;
75
+ try {
76
+ statSync(targetPath);
77
+ targetExists = true;
78
+ }
79
+ catch {
80
+ targetExists = false;
81
+ }
82
+ if (targetExists)
83
+ throw new Error(`target log already exists (refusing to overwrite): ${targetPath}`);
84
+ // 目标目录里已有租约文件 = 有人正持有该目录的写租约(或上一次搬迁没搬完)。
85
+ // 此时再搬会把「已持有的锁」和「日志」拆到两个目录,不如直接拒绝。
86
+ if (existsSync(join(targetDir, 'session.lock'))) {
87
+ throw new Error(`target session directory already holds a write lease (refusing to merge): ${join(targetDir, 'session.lock')}`);
88
+ }
89
+ const frames = compressed ? scanZstdFrames(buffer) : { frames: [], incomplete: false };
90
+ if (compressed && frames.incomplete)
91
+ throw new Error(`source log has an incomplete trailing frame: ${frames.reason}`);
92
+ return {
93
+ root,
94
+ compression,
95
+ sessionId,
96
+ fromCwd,
97
+ toCwd,
98
+ fromKey,
99
+ toKey,
100
+ generation: found.selected,
101
+ /** 源目录里除 generation 与 `session.lock` 之外的文件(真正会留在原地的)。 */
102
+ otherFiles: found.others.filter((name) => name !== 'session.lock'),
103
+ /** 源目录里是否有写租约文件;有则由 {@link applyPlan} 随目录一起搬走。 */
104
+ leaseFile: found.others.includes('session.lock') ? 'session.lock' : null,
105
+ otherGenerations: found.all.slice(1).map((entry) => entry.name),
106
+ sourceDir,
107
+ targetDir,
108
+ sourcePath,
109
+ targetPath,
110
+ bytesBefore: buffer.length,
111
+ bytesAfter: rewritten.buffer.length,
112
+ frameCount: compressed ? frames.frames.length : null,
113
+ header,
114
+ rewrittenHeader: rewritten.header,
115
+ buffer: rewritten.buffer,
116
+ };
117
+ }
118
+ /** 原子落盘:写 + fsync(崩在中间也不会留下半个文件)。 */
119
+ function writeDurable(path, buffer) {
120
+ const fd = openSync(path, 'w');
121
+ try {
122
+ writeSync(fd, buffer, 0, buffer.length, 0);
123
+ fsyncSync(fd);
124
+ }
125
+ finally {
126
+ closeSync(fd);
127
+ }
128
+ }
129
+ /**
130
+ * 执行计划。三步改名保证「任何时刻都不存在两份规范名日志」:
131
+ * 1. 新日志先以临时名写进目标目录(此时还不是 generation);
132
+ * 2. 源文件改名成 `*.session-move-old`(源目录里不再有规范名);
133
+ * 3. 临时名改成规范名(此刻新日志才生效),随后删掉 `.old` 与空的源目录。
134
+ * 崩在 2 与 3 之间时,源数据仍在 `.old` 里、新数据仍在 `.tmp` 里,人工可救。
135
+ *
136
+ * @param plan - {@link planMove} 的返回值。
137
+ * @returns 搬迁报告(含 `dshReadbackSafe` 之外的事实字段,供 HTTP/工具直接回话)。
138
+ */
139
+ export function applyPlan(plan) {
140
+ const report = {
141
+ sessionId: plan.sessionId,
142
+ from: plan.fromCwd,
143
+ to: plan.toCwd,
144
+ generation: plan.generation.name,
145
+ bytesBefore: plan.bytesBefore,
146
+ bytesAfter: plan.bytesAfter,
147
+ frames: plan.frameCount,
148
+ sourcePath: plan.sourcePath,
149
+ targetPath: plan.targetPath,
150
+ moved: true,
151
+ };
152
+ mkdirSync(plan.targetDir, { recursive: true });
153
+ const tmpPath = plan.targetPath + TMP_SUFFIX;
154
+ const parkedPath = plan.sourcePath + PARKED_SUFFIX;
155
+ writeDurable(tmpPath, plan.buffer);
156
+ renameSync(plan.sourcePath, parkedPath);
157
+ renameSync(tmpPath, plan.targetPath);
158
+ rmSync(parkedPath, { force: true });
159
+ // 0.2.2 起每个会话目录里有 `session.lock`(POSIX flock,写入句柄整个生命周期持有;
160
+ // 内核约定这个文件永不删除、靠稳定 inode 判断归属)。搬迁必须把它一起搬过去:
161
+ // rename 保留 inode,本进程已持有的 flock 会跟着到新路径,仍能挡住第二个写入者;
162
+ // 留在源目录会得到「源目录有锁、新目录无锁」的半套租约。
163
+ const leaseName = 'session.lock';
164
+ const sourceLease = join(plan.sourceDir, leaseName);
165
+ const targetLease = join(plan.targetDir, leaseName);
166
+ let leaseMoved = false;
167
+ if (existsSync(sourceLease)) {
168
+ if (existsSync(targetLease)) {
169
+ report.leaseConflict = targetLease;
170
+ }
171
+ else {
172
+ try {
173
+ renameSync(sourceLease, targetLease);
174
+ leaseMoved = true;
175
+ }
176
+ catch (error) {
177
+ report.leaseMoveError = messageOf(error);
178
+ }
179
+ }
180
+ }
181
+ report.leaseMoved = leaseMoved;
182
+ let removedSourceDir = false;
183
+ let leftover = [];
184
+ try {
185
+ leftover = readdirSync(plan.sourceDir);
186
+ }
187
+ catch {
188
+ leftover = [];
189
+ }
190
+ if (leftover.length === 0) {
191
+ try {
192
+ rmdirSync(plan.sourceDir);
193
+ removedSourceDir = true;
194
+ }
195
+ catch (error) {
196
+ report.sourceDirCleanupError = messageOf(error);
197
+ }
198
+ }
199
+ report.removedSourceDir = removedSourceDir;
200
+ report.leftInSourceDir = leftover;
201
+ // 落盘之后再读一遍目标文件:确认帧结构可解、header 已是新 cwd。
202
+ const after = readFileSync(plan.targetPath);
203
+ const header = readHeaderFromBuffer(after, plan.generation.compressed);
204
+ report.verifiedHeader = header === undefined
205
+ ? null
206
+ : { id: String(header.id), cwd: typeof header.cwd === 'string' ? header.cwd : null, version: header.version };
207
+ report.verifiedBytes = after.length;
208
+ report.ok = report.verifiedHeader !== null && report.verifiedHeader.cwd === plan.toCwd;
209
+ return report;
210
+ }
211
+ /**
212
+ * 只读校验:目标位置上是否有一份可解析、id/cwd 都对得上的日志。
213
+ * @param options - `{root, sessionId, cwd, compression}`。
214
+ * @returns `{ok, header, bytes}` 或 `{ok:false, error}`。
215
+ */
216
+ export function verifyStoredLog(options) {
217
+ try {
218
+ const dir = sessionDir(options.root, options.cwd, options.sessionId);
219
+ const found = findGeneration(dir);
220
+ if ('error' in found)
221
+ return { ok: false, error: found.error };
222
+ const buffer = readFileSync(join(dir, found.selected.name));
223
+ const header = readHeaderFromBuffer(buffer, found.selected.compressed);
224
+ if (header === undefined)
225
+ return { ok: false, error: 'header is unreadable' };
226
+ return {
227
+ ok: String(header.id) === options.sessionId && header.cwd === options.cwd,
228
+ header: { id: String(header.id), cwd: typeof header.cwd === 'string' ? header.cwd : null },
229
+ bytes: buffer.length,
230
+ magic: buffer.length >= 4 && buffer.readUInt32LE(0) === ZSTD_MAGIC ? 'zstd' : 'plain',
231
+ };
232
+ }
233
+ catch (error) {
234
+ return { ok: false, error: messageOf(error) };
235
+ }
236
+ }
package/lib/index.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * `dsh-session-ops` Host 半边(插件入口)。
3
+ *
4
+ * 这个包把原先两个独立插件合并成**一个** DSH 插件:
5
+ * - `dsh-session-admin`(删除)→ {@link module:dsh-session-ops/admin};
6
+ * - `dsh-session-move`(搬迁)→ {@link module:dsh-session-ops/move}。
7
+ *
8
+ * 两个半边的代码是**逐字搬过来的**(见两个模块头部列出的差异清单),这里只做合并后的
9
+ * 那一层:一份配置 schema、一个活配置对象、一次 `loader/volatile-update` 订阅、
10
+ * 一个 `apply`,把两条路由与两个工具注册出去。
11
+ *
12
+ * ## 对外契约(合并后一条都不许变)
13
+ *
14
+ * - HTTP:`/session-admin/api/<method>` 与 `/session-move/api/<method>` 的方法名、
15
+ * 入参、`{ok:true,value}` / `{ok:false,error:{code,message}}` 响应形状;
16
+ * - 模型工具:`session_admin` 与 `session_move` 的名字、`parameters`、`output.schema`;
17
+ * - 文档与排障产物:`$DSH_HOME/logs/dsh-session-admin.log`(`[时间] 级别 正文`)与
18
+ * `$DSH_HOME/logs/dsh-session-move.log`(`时间 [级别] 正文`)两个文件与两种行格式;
19
+ * - 安全默认:`allowWorkspaceSessions` / `allowLiveSessions` / `moveAllowHosts` /
20
+ * `adminAllowHosts` / `memoryFollow` 一律落在安全的一侧(见 {@link Config})。
21
+ *
22
+ * ## 配置键的合并方式
23
+ *
24
+ * 两半原先各有一个 `allowHosts`,但语义不同(删除半边按 `Host` 头解析 authority,
25
+ * 搬迁半边按 socket 的 `remoteAddress` 精确匹配),所以**拆成两个键**而不是合并:
26
+ *
27
+ * | 旧键(dsh-session-admin) | 旧键(dsh-session-move) | 新键 |
28
+ * | --- | --- | --- |
29
+ * | `enabled` | `enabled` | `enabled`(两半共用一个总开关) |
30
+ * | `allowHosts` | — | `adminAllowHosts` |
31
+ * | — | `allowHosts` | `moveAllowHosts` |
32
+ * | — | `root` | `root` |
33
+ * | `headerDeleteButton` | — | `headerDeleteButton` |
34
+ * | `allowWorkspaceSessions` | — | `allowWorkspaceSessions` |
35
+ * | `allowLiveSessions` | — | `allowLiveSessions` |
36
+ * | `maxBatch` | — | `maxBatch` |
37
+ * | — | `refuseRunning` | `refuseRunning` |
38
+ * | — | `maxSessions` | `maxSessions` |
39
+ * | — | `memoryFollow` | `memoryFollow` |
40
+ *
41
+ * 只有 `enabled` 是**语义合并**(两半原先各一个,现在一个):任一旧值为 `false` 的
42
+ * 组合在新键下都会变成「全开或全关」,profile patch 要按新语义重写。
43
+ *
44
+ * @module dsh-session-ops
45
+ */
46
+ import z from '@deepseek-ai/schemastery';
47
+ import { LOG_LINE_LEVEL_FIRST, LOG_LINE_TIME_FIRST, createFileLogger, resolveVolatileConfig, } from './store/host.js';
48
+ import { ADMIN_API_PREFIX, createSessionAdmin, isInside, isTrustedRequest as isTrustedAdminRequest, mountAdmin, } from './admin.js';
49
+ import { MOVE_API_PREFIX, isTrustedRequest as isTrustedMoveRequest, mountForkMembershipRepair, mountMove, sweepForkChildrenOnBoot, } from './move.js';
50
+ import { messageOf } from './store/util.js';
51
+ export { ADMIN_API_PREFIX, MOVE_API_PREFIX, createSessionAdmin, isInside, isTrustedAdminRequest, isTrustedMoveRequest, mountAdmin, mountForkMembershipRepair, mountMove, sweepForkChildrenOnBoot, };
52
+ /**
53
+ * 搬迁半边原来的公开名(`dsh-session-move` 的 `API_PREFIX = '/session-move/api'`)。
54
+ *
55
+ * 合并后两半各有一个前缀,明确的名字是 {@link ADMIN_API_PREFIX} / {@link MOVE_API_PREFIX};
56
+ * 这个别名留着,是为了让按 `dsh-session-move/lib/index.js` 写过 import 的脚本/测试不改也能跑。
57
+ */
58
+ export const API_PREFIX = MOVE_API_PREFIX;
59
+ // ---------------------------------------------------------------------------
60
+ // 插件元信息
61
+ // ---------------------------------------------------------------------------
62
+ /** Cordis 插件名(profile 行 id 是 `session-toolbox`,两者刻意不同名)。 */
63
+ export const name = 'dsh-session-ops';
64
+ /**
65
+ * 依赖的服务。**这不是可选的**:cordis 里没有声明 inject 就拿不到服务,
66
+ * `ctx.webServer` / `ctx.tools` / `ctx.sessions` / `ctx.sessionPersistence` 会静默是
67
+ * undefined——表现是日志照常打「mounted」但路由 404、工具根本没注册、守卫形同虚设。
68
+ *
69
+ * 两份清单的并集:删除半边要 `sessions`(Host 进程里挂着的会话),搬迁半边要
70
+ * `sessionPersistence`(会话根与写入句柄)。`workspaceRegistry` / `webServer` / `tools`
71
+ * 两边都要。
72
+ */
73
+ export const inject = ['webServer', 'tools', 'sessions', 'sessionPersistence', 'workspaceRegistry'];
74
+ /** 设置命名空间;与 client 半边 `settings.section` 的 id 必须一致。 */
75
+ export const SETTINGS_NS = 'session-toolbox';
76
+ // ---------------------------------------------------------------------------
77
+ // 配置(两半合并成一份 schema)
78
+ // ---------------------------------------------------------------------------
79
+ /**
80
+ * 插件配置(两份原配置的**并集**,键名与默认值见 {@link module:dsh-session-ops} 的表)。
81
+ *
82
+ * 键分两类,区别是**能不能在线改**:
83
+ *
84
+ * - **旋钮**:标了 `.volatile()` 的字段由 Loader 以活引用(`{ get() }`)交给 `apply`,
85
+ * 写盘后原地更新引用、再补发一次 `loader/volatile-update`。插件页上本行的「配置」
86
+ * 卡片写的就是这 7 个字段,落盘在 profile 的 `cordis.patch.yml` 里,**不重启、不重挂**。
87
+ * - **组合事实**:`enabled`(挂载期决定注册什么)、`root`(挂载期解析会话根)、
88
+ * `adminAllowHosts` / `moveAllowHosts`(挂载期决定 HTTP 信任栅栏)。刻意不标 volatile:
89
+ * 不进表单,要改就改 profile 的 patch(改完本行会重挂一次)。
90
+ *
91
+ * `description` 会随 schema 进入表单描述,是界面文案的权威来源。
92
+ */
93
+ export const Config = z.object({
94
+ /** 总开关(两半共用)。关掉后两条路由与两个工具都不注册。改它需要重挂本行(非 volatile)。 */
95
+ enabled: z.boolean().default(true),
96
+ /** 会话根目录;留空则从 `sessionPersistence.root` 推断(jsonl 后端会暴露它)。搬迁半边用。 */
97
+ root: z.string().default(''),
98
+ /**
99
+ * 删除半边的额外信任 authority(`host` 或 `host:port`,按 `Host` 头解析)。
100
+ * 原 `dsh-session-admin` 的 `allowHosts`。默认空——只信 loopback。
101
+ */
102
+ adminAllowHosts: z.array(String).default([]),
103
+ /**
104
+ * 搬迁半边的额外信任 authority(`host` 或 `host:port`,按 socket 的 `remoteAddress`
105
+ * 精确匹配)。原 `dsh-session-move` 的 `allowHosts`。默认空——只信 loopback。
106
+ */
107
+ moveAllowHosts: z.array(String).default([]),
108
+ /** 是否允许删「属于某个工作区」的会话。默认否——工作区会话通常还想留。 */
109
+ allowWorkspaceSessions: z.boolean().default(false)
110
+ .description('允许删除「属于某个工作区」的会话。默认否——工作区会话通常还想留。')
111
+ .volatile(),
112
+ /** 是否允许删仍然挂在 Host 进程里的会话(正在浏览/正在跑)。默认否。 */
113
+ allowLiveSessions: z.boolean().default(false)
114
+ .description('允许删除仍然挂在 Host 进程里的会话(正在浏览或正在跑)。默认否。')
115
+ .volatile(),
116
+ /** 一次调用最多删多少个,防手滑。 */
117
+ maxBatch: z.number().default(200)
118
+ .description('一次调用最多删多少个会话,防手滑。默认 200。')
119
+ .volatile(),
120
+ /**
121
+ * 是否在会话标题栏显示「删除当前会话」的垃圾桶图标。**默认开启。**
122
+ *
123
+ * 形态刻意是「图标 + 二次确认」而不是带字面标签的按钮:标签会招来点击,而这里
124
+ * 删的正是你正在用的会话。图标不占字面位置,误点最多弹出一个确认框,离真正删除
125
+ * 还隔着一步。清理由右侧「会话管理」面板或设置页承担,那是显式挑选动作。
126
+ *
127
+ * 这是个 volatile 旋钮:浏览器半边在挂载时问一次 Host(`config` 接口),所以
128
+ * 改完**刷新页面**就生效——不需要重启 dsh web,也不需要重挂本插件。
129
+ */
130
+ headerDeleteButton: z.boolean().default(true)
131
+ .description('在会话标题栏显示「删除当前会话」的垃圾桶图标。默认开启;关掉后清理由「会话管理」面板/设置页承担。改完刷新页面生效。')
132
+ .volatile(),
133
+ /** 是否拒绝搬"正在跑一轮"的会话。默认拒绝——搬运期间它的写入句柄可能落盘。 */
134
+ refuseRunning: z.boolean().default(true)
135
+ .description('拒绝搬迁「正在跑一轮」的会话。默认开启——搬运期间它的写入句柄可能落盘。')
136
+ .volatile(),
137
+ /** `list` 一次最多返回多少条会话(浏览器半边还要筛选)。 */
138
+ maxSessions: z.number().default(2000)
139
+ .description('`list` 一次最多返回多少条会话(浏览器半边还要筛选)。默认 2000。')
140
+ .volatile(),
141
+ /**
142
+ * 项目作用域记忆的条目要不要跟着会话走。**默认 off**。
143
+ *
144
+ * 项目记忆是**工作区共享**的(一次写入、那个工作区里所有会话都读),所以把一条
145
+ * 会话的条目拖到另一个工作区会改变那边的记忆视图——只有"这条会话本来就该在目标
146
+ * 工作区"(纠正错放)时才该这么做:
147
+ * - `off` :什么都不做。报告里仍给出**只读预览**(若开启会复制哪几条)。
148
+ * - `copy` :把该会话产出的条目(frontmatter `session:` 精确匹配)复制到目标
149
+ * 工作区,按文件名去重,源工作区不动。
150
+ * - `move` :同上,但复制后从源工作区移除(不可逆,谨慎)。
151
+ */
152
+ memoryFollow: z.string().default('off')
153
+ .description('项目记忆跟随:off 只给只读预览(默认 / 推荐);copy 把本会话产出的条目复制到目标工作区;move 复制后从源移除(不可逆)。')
154
+ .volatile(),
155
+ /**
156
+ * 启动时是否做一轮「fork 子会话漏挂工作区」的定向扫描。
157
+ *
158
+ * 创建期的 `session/created` 补挂只挡住「Host fork 时就没挂」。记账写进去之后
159
+ * 又被抹掉的那一类(同机两个 Host 共用 `$DSH_HOME` 时,另一个进程用旧内存快照
160
+ * 重写 `storages/workspace.json`)只有启动期扫描能补回来,所以默认开启。
161
+ *
162
+ * 扫描范围收得很窄:只认带 `parentSession` 的普通 fork 子会话(排除 `origin:
163
+ * subagent` 与 `delegationDepth > 0`),且父会话已被某个工作区记账、子会话
164
+ * `cwd` 规范化后等于该工作区路径。刻意 ungrouped 的普通会话/迁移会话不碰。
165
+ */
166
+ repairForkChildrenOnBoot: z.boolean().default(true)
167
+ .description('启动时补挂「fork 子会话漏挂工作区」:只处理父会话已入册、cwd 与工作区一致的普通 fork 子会话。默认开启;挂载期读取,改它需要重挂本行。'),
168
+ });
169
+ /**
170
+ * 解包 entry 配置里的 `.volatile()` 活引用,并交给 schema 校验补默认值。
171
+ *
172
+ * 实现在 `lib/store/host.js`:先解包输入、再校验、最后**再解包一次**——
173
+ * schemastery 对 `.volatile()` 字段输出的也是活引用,不收敛的话读到的就是 `{ get() }`
174
+ * 对象(HTTP 面上会变成一个空对象)。
175
+ * @param raw - entry 配置或普通配置对象。
176
+ * @returns 普通值配置。
177
+ */
178
+ export function resolveConfig(raw = {}) {
179
+ // 解包后的普通对象在这里收敛成 {@link SessionOpsConfig}:schema 的默认值保证了
180
+ // 每个键都在,但 schemastery 的输出类型不携带这些静态事实,只能就地断言一次。
181
+ return resolveVolatileConfig(Config, raw);
182
+ }
183
+ /**
184
+ * 挂载插件:两条路由 + 两个工具。
185
+ *
186
+ * 两个半边共用**同一个活配置对象**(见下),各自的信任栅栏读各自的那个键
187
+ * (`adminAllowHosts` / `moveAllowHosts`),所以 volatile 更新只需要在这里重读一次。
188
+ *
189
+ * 日志刻意保留两份、两种行格式:`$DSH_HOME/logs/dsh-session-admin.log`
190
+ * (`[时间] 级别 正文`)与 `$DSH_HOME/logs/dsh-session-move.log`(`时间 [级别] 正文`)
191
+ * 是合并前就存在的排障产物,合并后依然按各自的格式写各自那份。
192
+ *
193
+ * @param ctx - Host cordis 上下文(已 inject 五个服务)。
194
+ * @param rawConfig - 部署层配置。
195
+ * @returns 无。
196
+ */
197
+ export function apply(ctx, rawConfig = {}) {
198
+ /**
199
+ * 当前生效的配置。
200
+ *
201
+ * 对象**身份保持不变**:volatile 字段更新时只把新值 `Object.assign` 进来,于是两个
202
+ * 半边里所有已经捕获了 `config` 的闭包(HTTP 方法表、工具、信任栅栏、日志)自动
203
+ * 看到最新值,不需要重挂,也不需要额外的 getter 管道。
204
+ */
205
+ const config = { ...resolveConfig(rawConfig) };
206
+ // 两个半边各自的文件日志(行格式是各自原有的排障产物,见 lib/store/host.js)。
207
+ const adminLog = createFileLogger(ctx, { name: 'dsh-session-admin', formatLine: LOG_LINE_LEVEL_FIRST });
208
+ const moveLog = createFileLogger(ctx, { name: 'dsh-session-move', formatLine: LOG_LINE_TIME_FIRST });
209
+ const logs = [adminLog, moveLog];
210
+ // volatile 字段写盘后 Loader 原地换引用并补发这个事件:重读一次就能拿到新值。
211
+ ctx.on('loader/volatile-update', () => {
212
+ try {
213
+ Object.assign(config, resolveConfig(rawConfig));
214
+ }
215
+ catch (error) {
216
+ // 表单写盘前会先校验候选值,正常路径不会走到这里;真出错也不能把插件带崩。
217
+ for (const log of logs)
218
+ log('warn', `config refresh failed: ${String(error)}`);
219
+ }
220
+ });
221
+ if (config.enabled === false) {
222
+ for (const log of logs)
223
+ log('info', 'disabled by config; nothing registered');
224
+ return;
225
+ }
226
+ mountAdmin(ctx, config, adminLog);
227
+ mountMove(ctx, config, moveLog);
228
+ // fork 子会话漏挂工作区(实测):创建期监听 + 启动期定向扫描;只补记账,不改 Host 源码。
229
+ // 扫描是 fire-and-forget,但绝不留下未处理的 rejection——自愈路径不能影响插件其余部分。
230
+ const forkSweep = mountForkMembershipRepair(ctx, config, moveLog);
231
+ if (forkSweep !== undefined) {
232
+ forkSweep.catch((error) => moveLog('warn', `membership sweep failed: ${messageOf(error)}`));
233
+ }
234
+ }
235
+ // ---------------------------------------------------------------------------
236
+ // 兼容再导出
237
+ // ---------------------------------------------------------------------------
238
+ /**
239
+ * 会话存储原语全量再导出(`lib/store/` 的五个模块):
240
+ * `dshHome`、`encodeSegment`、`readSessionHeader`、`hasZstd`、`decodeProjectKey` 等。
241
+ *
242
+ * 原来的会话存储共享库根入口就是这个形状;合并成单包后保留同一份公开面,
243
+ * 外部按包名导入的脚本(如 dsh-open-in-dsh)只改包名即可,不必换符号名。
244
+ * 需要显式分层时也可以用子路径 `dsh-session-ops/store`(见 package.json exports)。
245
+ */
246
+ export * from './store/index.js';
247
+ // 2026-09-25:本文件是 dsh-session-admin + dsh-session-move 的合并入口
248
+ // (本包就是这两个插件的合并产物;会话存储知识收在 `lib/store/`)。