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.
@@ -0,0 +1,272 @@
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 type { HttpRequest } from './store/http.js';
29
+ import { dshHome } from './store/host.js';
30
+ import type { LogFn } from './store/host.js';
31
+ import type { PersistenceLike, WorkspaceEntity, WorkspaceRegistryLike } from './store/registry.js';
32
+ import type { MoveReport } from './engine.js';
33
+ import type { MemoryFollowOutcome } from './memory.js';
34
+ import type { SessionOpsConfig } from './index.js';
35
+ /** HTTP 路由前缀(两半各一条,合并后仍然分开)。 */
36
+ export declare const MOVE_API_PREFIX = "/session-move/api";
37
+ /**
38
+ * `$DSH_HOME`(环境变量优先,默认 `~/.dsh`)。
39
+ *
40
+ * 实现在 `lib/store/host.js`——它与删除半边用的是同一份;
41
+ * 这里再导出,保持本仓库的公开面不变。
42
+ */
43
+ export { dshHome };
44
+ /**
45
+ * loopback 或显式白名单 authority 才放行,并额外拒绝浏览器侧的跨站 / 跨源请求。
46
+ *
47
+ * 原先这份栅栏只按 socket 的 `remoteAddress` 判断回环、`allowHosts` 只做精确字符串
48
+ * 匹配,**不看 `sec-fetch-site` / `Origin`**——恶意页面从任意站点 POST 到本机
49
+ * `127.0.0.1:3080` 时,socket 地址同样是回环,只看 `remoteAddress` 拦不住。
50
+ * 2026-09-25 与 `dsh-session-admin` 那份栅栏对齐(那条缺口已补),差异只剩
51
+ * 「本份以 `remoteAddress` 为回环判据、admin 以 `Host` 头为判据」。
52
+ * 共享包只提供路由骨架(`createApiHandler`),栅栏以回调形式传进去。
53
+ */
54
+ export declare function isTrustedRequest(req: HttpRequest, allowHosts: string[]): boolean;
55
+ /** 持久化后端 `list()` 返回的一条会话快照。 */
56
+ export interface SessionSnapshotLike {
57
+ id?: unknown;
58
+ header: Record<string, unknown>;
59
+ }
60
+ /** 持久化后端 `open()` 返回的读句柄。 */
61
+ export interface PersistenceHandleLike {
62
+ header: Record<string, unknown>;
63
+ inheritedEventCount?: unknown;
64
+ read(): Promise<{
65
+ events?: unknown[];
66
+ } | undefined>;
67
+ close(): Promise<unknown>;
68
+ }
69
+ /** 搬迁半边用到的持久化后端能力(`ctx.sessionPersistence` 的子集)。 */
70
+ export interface SessionPersistenceLike extends PersistenceLike {
71
+ list: () => Promise<SessionSnapshotLike[]>;
72
+ open: (sessionId: string, mode: string) => Promise<PersistenceHandleLike>;
73
+ }
74
+ /** `list` 快照里的一行工作区。 */
75
+ export interface WorkspaceSummary {
76
+ id: string;
77
+ title: string;
78
+ path: string;
79
+ visible: number;
80
+ accounted: number;
81
+ }
82
+ /** `list` 快照里的一条会话。 */
83
+ export interface SessionRow {
84
+ id: string;
85
+ cwd: string | null;
86
+ createdAt: number | null;
87
+ resident: boolean;
88
+ running: string | null;
89
+ hasWriteHandle: boolean;
90
+ archived: boolean;
91
+ workspaceId: string | null;
92
+ workspaceTitle: string | null;
93
+ movable: boolean;
94
+ }
95
+ /** {@link Mover.list} 的返回。 */
96
+ export interface ListSnapshot {
97
+ root: string;
98
+ compression: string;
99
+ zstd: boolean;
100
+ workspaces: WorkspaceSummary[];
101
+ sessions: SessionRow[];
102
+ counts: {
103
+ stored: number;
104
+ resident: number;
105
+ workspaces: number;
106
+ };
107
+ }
108
+ /** `mover.move` 的入参(HTTP / 工具都把浏览器或模型给的对象原样递进来)。 */
109
+ export interface MoveRequest {
110
+ sessionId?: unknown;
111
+ targetWorkspace?: unknown;
112
+ dryRun?: unknown;
113
+ }
114
+ /** 计划摘要在报告里的形状。 */
115
+ export interface MovePlanSummary {
116
+ generation: string;
117
+ version: number;
118
+ compressed: boolean;
119
+ bytesBefore: number;
120
+ bytesAfter: number;
121
+ frames: number | null;
122
+ sourcePath: string;
123
+ targetPath: string;
124
+ leaseFile: string | null;
125
+ leftInSourceDir: string[];
126
+ otherGenerations: string[];
127
+ }
128
+ /** 记忆跟随预览/结果摘要在报告里的形状。 */
129
+ export interface MemoryFollowSummary {
130
+ mode: string;
131
+ sourceSlug?: string | null;
132
+ targetSlug?: string;
133
+ targetHadProject?: boolean;
134
+ sourceEntries?: number;
135
+ matched?: number;
136
+ titles?: string[];
137
+ alreadyInTarget?: number;
138
+ note?: string;
139
+ error?: string;
140
+ applied?: MemoryFollowOutcome;
141
+ applyError?: string;
142
+ }
143
+ /** `mover.verify` 的读回结果。 */
144
+ export interface VerifyReadback {
145
+ headerCwd: string | null;
146
+ inheritedEventCount: number;
147
+ events: number | null;
148
+ }
149
+ /** {@link Mover.move} 的完整报告。字段按路径可选(noop / attach-only / dry-run)。 */
150
+ export interface MoveActionReport {
151
+ sessionId: string;
152
+ from: string;
153
+ to: string;
154
+ workspace: {
155
+ id: string;
156
+ title: string;
157
+ };
158
+ dryRun: boolean;
159
+ running: string | null;
160
+ noop?: boolean;
161
+ note?: string;
162
+ attachOnly?: boolean;
163
+ dryRunOnly?: boolean;
164
+ plan?: MovePlanSummary;
165
+ memoryFollow?: MemoryFollowSummary;
166
+ storage?: MoveReport;
167
+ handleFlushError?: string;
168
+ writeHandleRedirected?: boolean;
169
+ liveHeaderUpdated?: boolean;
170
+ registryHeaderCacheUpdated?: boolean;
171
+ attachedTo?: string;
172
+ detachedFrom?: string;
173
+ detachError?: string;
174
+ dshReadback?: VerifyReadback;
175
+ dshReadbackError?: string;
176
+ memoryScope?: string;
177
+ ok?: boolean;
178
+ }
179
+ /** `list` / `move` / `verify` 三个入口(`createMover` 的返回)。 */
180
+ export interface Mover {
181
+ list: () => Promise<ListSnapshot>;
182
+ move: (request: MoveRequest) => Promise<MoveActionReport>;
183
+ verify: (sessionId: string) => Promise<VerifyReadback>;
184
+ rootOf: () => string;
185
+ compressionOf: () => string;
186
+ resolveWorkspace: (key: unknown) => WorkspaceEntity | undefined;
187
+ }
188
+ /** {@link createMover} 用到的 Host 上下文(最小本地接口,见 `src/index.ts` 的说明)。 */
189
+ export interface MoveContext {
190
+ sessionPersistence?: SessionPersistenceLike | undefined;
191
+ workspaceRegistry: WorkspaceRegistryLike;
192
+ get: (name: string) => any;
193
+ webServer: {
194
+ register(options: unknown): unknown;
195
+ };
196
+ tools: {
197
+ register(definition: unknown): unknown;
198
+ };
199
+ effect: (fn: () => unknown, label?: string) => unknown;
200
+ }
201
+ /**
202
+ * 挂载搬迁半边:路由 + 工具。
203
+ *
204
+ * 与删除半边刻意不同的两点(原样保留的理由):
205
+ * - 信任栅栏看 socket 的 `remoteAddress`(`isTrustedRequest`),`allowHosts` 只做精确
206
+ * 字符串匹配;删除半边看 `Host` 头;
207
+ * - 请求体按**严格模式**读(`requireObject: true`,非普通对象一律 400)。
208
+ *
209
+ * @param ctx - Host cordis 上下文(`webServer` / `tools` / `sessionPersistence` /
210
+ * `workspaceRegistry`;`sessions` / `agents` 是可选事实源,用 `ctx.get` 取)。
211
+ * @param config - 已解包的**活配置对象**(身份由入口保持,volatile 更新时原地覆盖)。
212
+ * @param log - `(level, message) => void`(入口传的是 move 那套行格式的文件日志)。
213
+ * @returns 无。
214
+ */
215
+ export declare function mountMove(ctx: MoveContext, config: SessionOpsConfig, log: LogFn): void;
216
+ /** 成员自愈扫描的汇总。 */
217
+ export interface ForkSweepReport {
218
+ checked: number;
219
+ attached: number;
220
+ skipped?: string;
221
+ }
222
+ /** 成员自愈器(创建期监听与启动期扫描共用)。 */
223
+ export interface ForkMembershipRepairer {
224
+ repair: (sessionId: string, header: Record<string, unknown> | undefined, reason: string) => Promise<void>;
225
+ ownerOf: (sessionId: string) => WorkspaceEntity | undefined;
226
+ }
227
+ /**
228
+ * 成员自愈用到的 Host 上下文(`workspaceRegistry` 可缺:缺了就跳过自愈)。
229
+ */
230
+ export interface RepairContext {
231
+ workspaceRegistry?: WorkspaceRegistryLike | undefined;
232
+ sessionPersistence?: SessionPersistenceLike | undefined;
233
+ on: (event: string, handler: (...args: any[]) => unknown) => unknown;
234
+ }
235
+ /**
236
+ * 启动期的成员自愈扫描:把**已经丢掉记账**的 fork 子会话补回去。
237
+ *
238
+ * ## 为什么创建期补挂不够(实测的第二条证据链)
239
+ *
240
+ * `session/created` 那一刻补挂只挡住「Host fork 时就没挂」这一种。实测中有些 fork
241
+ * 在 Host 侧确实走了 `workspace.attachSession(childId)`(RPC 返回成功、插件侧没有
242
+ * `fork 失败` 日志),但子会话最终仍不在任何工作区的 `sessionIds` 里——也就是说
243
+ * 记账**写进去之后又没了**。同一个时间窗里,父会话的归档也没落住。
244
+ *
245
+ * 能同时抹掉「新 attach」与「归档集合」的只有一件事:**另一个 Host 进程用自己内存里
246
+ * 的旧快照重写了 `$DSH_HOME/storages/workspace.json`**。存储层是整文件原子替换、
247
+ * 无跨进程锁(`dsh-storage-json` 的注释写的就是「一个 unit 文件只有一个写者,后写
248
+ * 覆盖先写是对的」),而同一台机器上同时跑两个 Host、共用同一个 `~/.dsh` 就会出现
249
+ * 这类丢失。这类丢失发生在 attach 之后,创建期的补挂拦不住。
250
+ *
251
+ * 所以这里补一道**启动期**的定向扫描:只处理 fork 子会话(`isForkChild`),且仍然
252
+ * 要求「父会话已被工作区记账 + 子会话 cwd 规范化后等于该工作区路径」;不碰子代理、
253
+ * 不碰没有父会话的普通会话,也不做全量 attach,避免改写用户刻意留着的 ungrouped。
254
+ *
255
+ * 幂等:已记账直接跳过;失败只记日志,绝不让插件挂载失败。
256
+ *
257
+ * @param ctx - Host cordis 上下文(需要 `workspaceRegistry` 与 `sessionPersistence`)。
258
+ * @param config - 活配置对象;`repairForkChildrenOnBoot === false` 时跳过整轮扫描。
259
+ * @param log - `(level, message) => void`。
260
+ * @param shared - 与创建期监听器共享的 repairer(`mountForkMembershipRepair` 内部用)。
261
+ * @returns 扫描结束(含跳过)后 resolve 的报告;永不 reject。
262
+ */
263
+ export declare function sweepForkChildrenOnBoot(ctx: RepairContext, config?: Partial<SessionOpsConfig>, log?: LogFn, shared?: ForkMembershipRepairer): Promise<ForkSweepReport>;
264
+ /**
265
+ * 挂载 fork 子会话的成员自愈:创建期监听 + 启动期定向扫描。
266
+ *
267
+ * @param ctx - Host cordis 上下文。
268
+ * @param config - 活配置对象。
269
+ * @param log - `(level, message) => void`。
270
+ * @returns 启动期扫描的 Promise(Host/cordis 忽略;测试可 await 以确定性观察结果)。
271
+ */
272
+ export declare function mountForkMembershipRepair(ctx: RepairContext, config: Partial<SessionOpsConfig>, log: LogFn): Promise<ForkSweepReport> | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 会话日志的帧级读写原语 —— **实现在本包的 `lib/store/` 里**(`dsh-session-ops`)。
3
+ *
4
+ * 这个文件现在只是一层**历史路径的门面**:`./sessionlog.js` 曾经是本插件私有的
5
+ * 实现(从 dsh-sync 移植),而删除半边那边重复着同一套知识的另一半
6
+ * (`decodeProjectKey`)。两个插件合并成 `dsh-session-ops` 之后:
7
+ * - 实现只有一份,规则与边界都写在 `lib/store/paths.js` / `lib/store/frames.js`
8
+ * 的模块注释里;
9
+ * - 这里的模块路径保留下来,是为了让本包原有的 `test/*.mjs` 不必改 import 就继续可用。
10
+ *
11
+ * @module dsh-session-ops/sessionlog
12
+ */
13
+ export { CHECKSUM_OPTIONS, ZSTD_MAGIC, hasZstd, readHeaderFromBuffer, rewriteHeaderCwd, scanZstdFrames, } from './store/frames.js';
14
+ export { compressionSuffix, encodeSegment, generationLogFilename, logPath, projectKey, sessionDir, } from './store/paths.js';
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 会话日志(`session[.vN].jsonl[.zstd]`)的**帧级读写原语**。
3
+ *
4
+ * ## 帧格式
5
+ *
6
+ * 日志是**独立 Zstandard 帧的标准拼接**:帧 0 只含 header 行(DSH 的
7
+ * `assertIndependentHeaderFrame` 强制),之后每帧是一个事件批次。于是改写只需重压
8
+ * 帧 0、再把剩余字节原样接上——后续帧一个字节都不动。
9
+ *
10
+ * 两个必须对齐的细节:
11
+ * - DSH 压缩时带内容校验和(`params: {[ZSTD_c_checksumFlag]: 1}`),重压帧 0
12
+ * 也必须带(见 {@link CHECKSUM_OPTIONS});
13
+ * - 帧边界要自己走一遍(Node 不暴露「已消费输入字节数」),就是下面的
14
+ * {@link scanZstdFrames}。
15
+ *
16
+ * ## 为什么头部这么要紧
17
+ *
18
+ * header 承载 `cwd` / `origin` / `parentSession` / `isSeeded`:
19
+ * - 「会话属于哪个工作区」在 DSH 里**只**由 `header.cwd` 决定
20
+ * (`WorkspaceEntity.sessionIds` 每次读取都用 `realpath(header.cwd) === workspace.path`
21
+ * 过滤),加上 `assertStoredIdentity` 要求物理路径等于
22
+ * `logPath(root, header.cwd, id)`,所以搬迁 == 改 cwd + 挪目录;
23
+ * - 「是不是子代理会话」只能从 `origin` 拿,projcache 里没有。
24
+ *
25
+ * 于是本模块同时提供两种读法,**语义不同、不要互相替换**:
26
+ * - {@link readHeaderFromBuffer}:只解**帧 0**(帧结构严格,给搬迁用);
27
+ * - {@link readSessionHeader}:按目录找日志文件、整段解压取第一行(宽松,给扫描/删除用)。
28
+ *
29
+ * @module dsh-session-ops/store/frames
30
+ */
31
+ /** zstd 帧魔数(小端)。 */
32
+ export declare const ZSTD_MAGIC = 4247762216;
33
+ /** zstd 压缩选项(带内容校验和时才有 `params`)。 */
34
+ export interface ZstdCompressOptions {
35
+ params?: Record<number, number>;
36
+ }
37
+ /** 与 DSH 一致的压缩选项:带内容校验和。 */
38
+ export declare const CHECKSUM_OPTIONS: ZstdCompressOptions;
39
+ /** `zstdCompressSync` 需要 Node ≥22.15 / ≥23.8;缺失时插件拒绝搬迁而不是写坏日志。 */
40
+ export declare const hasZstd: boolean;
41
+ /** 会话头(日志第一行)。字段按 DSH 实际写入的形状声明,其余键原样保留。 */
42
+ export interface SessionHeader {
43
+ type?: string;
44
+ version?: number;
45
+ id?: string;
46
+ cwd?: string;
47
+ origin?: string;
48
+ parentSession?: string;
49
+ isSeeded?: boolean;
50
+ delegationDepth?: number;
51
+ createdAt?: number;
52
+ [key: string]: unknown;
53
+ }
54
+ /** 拼接帧里的一个帧范围。 */
55
+ export interface ZstdFrameRange {
56
+ start: number;
57
+ end: number;
58
+ }
59
+ /** {@link scanZstdFrames} 的扫描结果。 */
60
+ export interface ZstdFrameScan {
61
+ frames: ZstdFrameRange[];
62
+ incomplete: boolean;
63
+ reason?: string;
64
+ }
65
+ /**
66
+ * 走一遍拼接帧,给出每帧的字节范围。
67
+ *
68
+ * 按 zstd 规范解析帧头(魔数 → Frame_Header_Descriptor → 可选窗口描述符 /
69
+ * 字典 id / 内容长度)与块序列(3 字节块头 → 块体),直到 Last_Block,
70
+ * 再按标志位跳过 4 字节内容校验和。
71
+ *
72
+ * @param buffer - 整个日志文件的内容。
73
+ * @returns `{frames, incomplete, reason}`;`frames` 每项含 `{start, end}`。
74
+ */
75
+ export declare function scanZstdFrames(buffer: Buffer): ZstdFrameScan;
76
+ /**
77
+ * 读出日志第一帧里的 header 对象(只解析,不改写)。
78
+ * @param buffer - 日志内容。
79
+ * @param compressed - `true`(默认)表示 zstd 帧日志,`false` 表示未压缩的行式日志。
80
+ * @returns header 对象;不是合法会话日志时 `undefined`。
81
+ */
82
+ export declare function readHeaderFromBuffer(buffer: Buffer, compressed?: boolean): SessionHeader | undefined;
83
+ /**
84
+ * 把一个会话目录里的会话头读出来(**宽松**读法:扫目录找日志文件,取解压后的第一行)。
85
+ *
86
+ * 与 {@link readHeaderFromBuffer} 的区别是它不校验帧结构、也不要求 `type === 'session'`:
87
+ * 目录里可能有半截写入或手工改名的日志,扫描/删除路径宁可拿到一个「像头部」的对象,
88
+ * 也不要因为格式不严格就把整条会话当成不可读。
89
+ *
90
+ * 解压失败(截断、缺 zstd 支持)返回 `undefined`,调用方按「未知」处理。
91
+ * @param dir - 会话目录。
92
+ * @param cache - 以 `路径:mtime:size` 为键的头部缓存(可选;不传就现建一个)。
93
+ * @returns 头部对象,或 undefined。
94
+ */
95
+ export declare function readSessionHeader(dir: string, cache?: Map<string, SessionHeader | undefined>): SessionHeader | undefined;
96
+ /** {@link rewriteHeaderCwd} 成功时的结果。 */
97
+ export interface HeaderRewriteSuccess {
98
+ buffer: Buffer;
99
+ rewritten: true;
100
+ header: SessionHeader;
101
+ frames?: number;
102
+ reason?: undefined;
103
+ }
104
+ /** {@link rewriteHeaderCwd} 无法改写时的结果(原样返回 buffer)。 */
105
+ export interface HeaderRewriteFailure {
106
+ buffer: Buffer;
107
+ rewritten: false;
108
+ header?: SessionHeader;
109
+ frames?: number;
110
+ reason?: string;
111
+ }
112
+ /** {@link rewriteHeaderCwd} 的结果。 */
113
+ export type HeaderRewriteResult = HeaderRewriteSuccess | HeaderRewriteFailure;
114
+ /**
115
+ * 把会话头的 `cwd` 改写成另一条绝对路径。
116
+ *
117
+ * 只动 `cwd` 一个键,其余键(含该 generation 自己的字段)按原顺序逐字保留;
118
+ * 帧 0 之后的字节**原样接上**,所以事件批次一个字节都没变。
119
+ *
120
+ * @param buffer - 日志内容。
121
+ * @param newCwd - 改写后的绝对路径。
122
+ * @param options - `{compressed}`:帧日志(默认 `true`)或未压缩行式日志。
123
+ * @returns `{buffer, rewritten, header, frames?, reason?}`;无需改写或无法改写时
124
+ * `rewritten: false` 并原样返回。
125
+ */
126
+ export declare function rewriteHeaderCwd(buffer: Buffer, newCwd: string, options?: {
127
+ compressed?: boolean;
128
+ }): HeaderRewriteResult;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 宿主侧的公共底座:`$DSH_HOME`、尽力而为的文件日志、以及 volatile 配置的解包。
3
+ *
4
+ * 这几段代码在两个插件里逐字重复——它们都是「DSH 宿主约定」而不是插件策略:
5
+ * - `dshHome()` 认 `DSH_HOME` 环境变量(测试靠它把整个沙箱指到临时目录);
6
+ * - 宿主 stdout 不落盘,排障只能靠 `$DSH_HOME/logs/<name>.log`;
7
+ * - 标了 `.volatile()` 的配置字段,Loader 交给 `apply` 的是活引用(`{get()}`)
8
+ * 而不是值,值在写盘后原地更新,所以**解包必须做两次**(进 schema 之前一次、
9
+ * schema 出来之后再一次,后者是因为 schemastery 对 volatile 字段输出的也是活引用)。
10
+ *
11
+ * 文件日志的**行格式两边不同**(admin 是 `[时间] 级别 正文`,move 是 `时间 [级别] 正文`),
12
+ * 这是对外可见的排障产物,所以这里不做统一,而是把两种格式都作为预设导出,
13
+ * 由调用方显式选择(见 {@link LOG_LINE_LEVEL_FIRST} / {@link LOG_LINE_TIME_FIRST})。
14
+ *
15
+ * @module dsh-session-ops/store/host
16
+ */
17
+ /** 一行文件日志的 `{iso, level, message}`。 */
18
+ export interface LogEntry {
19
+ iso: string;
20
+ level: string;
21
+ message: string;
22
+ }
23
+ /** 宿主 cordis 上下文里本模块用到的那一小块(只在 `createFileLogger` 里取 `logger`)。 */
24
+ export interface LoggerContext {
25
+ logger?: Record<string, ((message: string) => unknown) | undefined> | undefined;
26
+ }
27
+ /** 日志行格式:`(entry) => 一整行(含结尾换行)`。 */
28
+ export type LogLineFormatter = (entry: LogEntry) => string;
29
+ /** 插件里到处用的日志函数。 */
30
+ export type LogFn = (level: string, message: string) => void;
31
+ /** `$DSH_HOME`:环境变量优先,默认 `~/.dsh`。 */
32
+ export declare function dshHome(): string;
33
+ /**
34
+ * 文件日志行格式之一:`[ISO 时间] 级别 正文`(`dsh-session-admin` 在用)。
35
+ * @param entry - `{iso, level, message}`。
36
+ * @returns 一整行(含结尾换行)。
37
+ */
38
+ export declare const LOG_LINE_LEVEL_FIRST: LogLineFormatter;
39
+ /**
40
+ * 文件日志行格式之一:`ISO 时间 [级别] 正文`(`dsh-session-move` 在用)。
41
+ * @param entry - `{iso, level, message}`。
42
+ * @returns 一整行(含结尾换行)。
43
+ */
44
+ export declare const LOG_LINE_TIME_FIRST: LogLineFormatter;
45
+ /** {@link createFileLogger} 的选项。 */
46
+ export interface CreateFileLoggerOptions {
47
+ /** 日志文件名(不含 `.log`;默认 `dsh-session-ops`)。 */
48
+ name?: string;
49
+ /** 行格式(默认 {@link LOG_LINE_LEVEL_FIRST})。 */
50
+ formatLine?: LogLineFormatter;
51
+ }
52
+ /**
53
+ * 尽力而为的文件日志:写 `$DSH_HOME/logs/<name>.log`,并顺带喂宿主 logger。
54
+ *
55
+ * 任何一步失败都静默吞掉——日志本身永远不该成为故障源(建目录失败、磁盘满了、
56
+ * 宿主 logger 不可用,都不影响插件机制)。
57
+ * @param ctx - Host cordis 上下文(只用 `ctx?.logger`,可为 undefined)。
58
+ * @param options - `{name, formatLine}`:日志文件名(不含 `.log`;默认
59
+ * `dsh-session-ops`)与行格式(默认 {@link LOG_LINE_LEVEL_FIRST})。
60
+ * @returns `(level, message) => void`,即插件里的 `log`。
61
+ */
62
+ export declare function createFileLogger(ctx: LoggerContext | undefined, options?: CreateFileLoggerOptions): LogFn;
63
+ /** 解包后的普通配置对象。 */
64
+ export type PlainConfig = Record<string, unknown>;
65
+ /**
66
+ * 解包 entry 配置里的 `.volatile()` 活引用。
67
+ * @param raw - entry 配置或普通配置对象。
68
+ * @returns 值已解包的浅拷贝。
69
+ */
70
+ export declare function plainConfig(raw?: unknown): PlainConfig;
71
+ /**
72
+ * 「可调用、收普通对象、返回普通对象」的 schema 形状(schemastery 的 `Config` 就是这种)。
73
+ *
74
+ * 本包不依赖 `@deepseek-ai/schemastery` 的运行时类型,只要求这个最小形状。
75
+ */
76
+ export type ConfigSchemaLike = (raw?: any) => unknown;
77
+ /**
78
+ * 解析一份 entry 配置:先解包输入的活引用,再交给 schema 校验并补默认值,最后**再解包
79
+ * 一次**——schemastery 对 `.volatile()` 字段输出的也是活引用,不收敛的话读到的就是
80
+ * `{ get() }` 对象(HTTP 面上会变成一个空对象)。
81
+ *
82
+ * schema 由调用方传入(本包只把 `@deepseek-ai/schemastery` 当类型来源),传进来的只要是
83
+ * 「可调用、收普通对象、返回带默认值的普通对象」即可。
84
+ * @param schema - 插件自己的 `Config`(schemastery 对象,可调用)。
85
+ * @param raw - entry 配置或普通配置对象。
86
+ * @returns 普通值配置。
87
+ */
88
+ export declare function resolveVolatileConfig(schema: ConfigSchemaLike, raw?: unknown): PlainConfig;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 两个会话插件共用的 **HTTP 面**:JSON 响应封装、请求体读取、以及前缀路由的骨架。
3
+ *
4
+ * 路由协议(响应码与响应体)是两个插件对浏览器半边的**公开契约**,必须逐字一致,
5
+ * 所以它只在写一份:
6
+ * - 403 `{ok:false,error:{code:'forbidden',message:'forbidden'}}` —— 信任栅栏拒绝;
7
+ * - 404 `{ok:false,error:{code:'not-found',message:'unknown method'}}` —— 路径没落在
8
+ * 前缀下,或方法名里还有 `/`;
9
+ * - 404 `{ok:false,error:{code:'not-found',message:'unknown method <m>'}}` —— 方法不在表里;
10
+ * - 200 `{ok:true,value}`;
11
+ * - 400 `{ok:false,error:{code:'bad-request',message:<err.message>}}` —— 处理器抛错。
12
+ * `GET` 的载荷是 query string,其余方法读 `JSON` 请求体(1 MiB 上限)。
13
+ *
14
+ * **信任栅栏本身刻意不在这里**:两个插件的判定依据不同(`dsh-session-admin` 看
15
+ * `Host` 头 + `sec-fetch-site`/`Origin` + `allowHosts` 的 host/hostname 匹配;
16
+ * `dsh-session-move` 看 socket 的 `remoteAddress` + `allowHosts` 精确字符串匹配),
17
+ * 语义不同就不能合并——合并必然改动其中一边的对外行为。这里只收一个
18
+ * `trustRequest(req) => boolean` 回调。
19
+ *
20
+ * 同理,`writeJson` 的 `content-length` 与 `readJsonBody` 的严格程度两边也不同,
21
+ * 都用显式开关保留原状(见各参数)。
22
+ *
23
+ * @module dsh-session-ops/store/http
24
+ */
25
+ /**
26
+ * 本模块用到的请求形状(Node 的 `IncomingMessage` 与测试里的假请求都满足它)。
27
+ *
28
+ * 刻意写成最小本地接口而不是直接引用 `node:http`:两个插件只用到这几个字段,
29
+ * 测试里的假请求也不实现完整的 `IncomingMessage`。
30
+ */
31
+ export interface HttpRequest extends AsyncIterable<Buffer> {
32
+ url?: string | undefined;
33
+ method?: string | undefined;
34
+ headers?: Record<string, string | string[] | undefined> | undefined;
35
+ socket?: {
36
+ remoteAddress?: string | undefined;
37
+ } | undefined;
38
+ }
39
+ /** 本模块用到的响应形状(Node 的 `ServerResponse` 与测试里的假响应都满足它)。 */
40
+ export interface HttpResponse {
41
+ writeHead(status: number, headers: Record<string, string | number>): unknown;
42
+ end(body?: string): unknown;
43
+ }
44
+ /** 路由方法表里的一个处理器:收载荷,返回值包成 `{ok:true,value}`。 */
45
+ export type ApiMethod = (payload: any) => unknown | Promise<unknown>;
46
+ /** {@link createApiHandler} 的选项。 */
47
+ export interface CreateApiHandlerOptions {
48
+ /** 路由前缀,如 `/session-admin/api`。 */
49
+ prefix: string;
50
+ /** 方法名 → `async (payload) => value`。 */
51
+ methods: Record<string, ApiMethod | undefined>;
52
+ /** `(req) => boolean`:请求是否来自受信的浏览器(各插件自己的栅栏)。 */
53
+ trustRequest?: ((req: HttpRequest) => boolean) | undefined;
54
+ /** `(level, message) => void`(可为 undefined)。 */
55
+ log?: ((level: string, message: string) => void) | undefined;
56
+ /** 读请求体,默认 {@link readJsonBody}(宽松)。 */
57
+ readBody?: (req: HttpRequest) => Promise<unknown>;
58
+ /** 透传给 {@link writeJson}。 */
59
+ contentLength?: boolean;
60
+ }
61
+ /**
62
+ * 写一个 JSON 响应。
63
+ * @param res - Node HTTP 响应。
64
+ * @param status - 状态码。
65
+ * @param body - 任意可 JSON 序列化的值。
66
+ * @param options - `{contentLength}`:是否带上 `content-length` 头。默认 `false`
67
+ * (`dsh-session-move` 的原样行为,响应走 chunked);`dsh-session-admin` 传 `true`
68
+ * 以保留它原有的 `content-length`。
69
+ * @returns 无。
70
+ */
71
+ export declare function writeJson(res: HttpResponse, status: number, body: unknown, options?: {
72
+ contentLength?: boolean;
73
+ }): void;
74
+ /**
75
+ * 读一个 JSON 请求体(上限 1 MiB)。
76
+ *
77
+ * 严格程度由调用方选,因为两个插件原本就不一样:
78
+ * - `requireObject: false`(默认,`dsh-session-admin` 的行为):不 trim、不校验类型,
79
+ * 空 body 返回 `{}`,其余原样交给 `JSON.parse`(一个空白 body 会抛出
80
+ * `Unexpected end of JSON input`,进而变成 400);
81
+ * - `requireObject: true`(`dsh-session-move` 的行为):trim 后空串返回 `{}`,
82
+ * 解析结果必须是**普通对象**,否则抛 `request body must be a JSON object`。
83
+ * @param req - Node HTTP 请求。
84
+ * @param options - `{requireObject}`。
85
+ * @returns 解析出来的载荷对象。
86
+ */
87
+ export declare function readJsonBody(req: HttpRequest, options?: {
88
+ requireObject?: boolean;
89
+ }): Promise<unknown>;
90
+ /**
91
+ * 造一个 `kind: 'prefix'` 路由处理器(`ctx.webServer.register({kind:'prefix', path, handler})` 的 `handler`)。
92
+ *
93
+ * 方法表里的每个处理器收到 `payload`,返回值包成 `{ok:true,value}`;抛错变成 400 并写一行
94
+ * `<method> failed: <message>` 日志。`GET` 的载荷是 query string(全部是字符串)。
95
+ * @param options - 见下。
96
+ * @param options.prefix - 路由前缀,如 `/session-admin/api`。
97
+ * @param options.methods - 方法名 → `async (payload) => value`。
98
+ * @param options.trustRequest - `(req) => boolean`:请求是否来自受信的浏览器(各插件自己的栅栏)。
99
+ * @param options.log - `(level, message) => void`(可为 undefined)。
100
+ * @param options.readBody - 读请求体,默认 {@link readJsonBody}(宽松);`dsh-session-move`
101
+ * 传 `(req) => readJsonBody(req, {requireObject: true})`。
102
+ * @param options.contentLength - 透传给 {@link writeJson}。
103
+ * @returns `async (req, res) => void`。
104
+ */
105
+ export declare function createApiHandler(options: CreateApiHandlerOptions): (req: HttpRequest, res: HttpResponse) => Promise<void>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `dsh-session-ops/lib/store` —— 本包内部的**会话存储原语**。
3
+ *
4
+ * 这一层只做库:没有模型工具、没有槽位注册、没有 DSH 插件生命周期代码,
5
+ * 也**零 `@deepseek-ai/*` 运行时依赖**(只用 node 内置模块)。删除半边与搬迁半边
6
+ * 共用它,避免两边逐字重复同一套会话存储知识。
7
+ *
8
+ * 五个子模块:
9
+ * - {@link module:dsh-session-ops/store/paths}:项目目录键 `--C-proj--` 的编解码、
10
+ * 会话目录与 generation 文件名的命名规则;
11
+ * - {@link module:dsh-session-ops/store/frames}:`session[.vN].jsonl[.zstd]` 的帧扫描、
12
+ * 头部读取(两种严格度)与 cwd 改写;
13
+ * - {@link module:dsh-session-ops/store/registry}:工作区注册表成员表 / 归档集合、
14
+ * 驻留会话、「正在跑一轮」判定、内存里三处 cwd 的重定向;
15
+ * - {@link module:dsh-session-ops/store/host}:`$DSH_HOME`、尽力而为的文件日志、
16
+ * volatile 配置解包;
17
+ * - {@link module:dsh-session-ops/store/http}:两个半边共用的 HTTP 面(JSON 响应封装、
18
+ * 请求体读取、前缀路由骨架;**信任栅栏按插件语义不同,不在这里**)。
19
+ *
20
+ * 有意**没有**放进来的东西(语义不同或只有一边有):两套信任栅栏、迁移的三步改名策略
21
+ * (`planMove`/`applyPlan`)、磁盘扫描与范围筛选(删除半边专有)、项目记忆跟随
22
+ * (搬迁半边专有)、模型工具与槽位定义。
23
+ *
24
+ * @module dsh-session-ops/store
25
+ */
26
+ export * from './paths.js';
27
+ export * from './frames.js';
28
+ export * from './registry.js';
29
+ export * from './host.js';
30
+ export * from './http.js';