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/LICENSE +21 -0
- package/README.md +121 -2
- package/README.zh.md +115 -0
- package/cordis.patch.yml +38 -0
- package/docs/DETAIL.md +24 -0
- package/docs/DETAIL.zh.md +23 -0
- package/icon.svg +1 -0
- package/lib/admin.js +904 -0
- package/lib/client.js +1869 -0
- package/lib/engine.js +236 -0
- package/lib/index.js +248 -0
- package/lib/memory.js +210 -0
- package/lib/move.js +830 -0
- package/lib/sessionlog.js +14 -0
- package/lib/store/frames.js +274 -0
- package/lib/store/host.js +104 -0
- package/lib/store/http.js +133 -0
- package/lib/store/index.js +30 -0
- package/lib/store/paths.js +166 -0
- package/lib/store/registry.js +234 -0
- package/lib/store/util.js +19 -0
- package/lib/types/admin.d.ts +187 -0
- package/lib/types/engine.d.ts +136 -0
- package/lib/types/index.d.ts +281 -0
- package/lib/types/memory.d.ts +106 -0
- package/lib/types/move.d.ts +272 -0
- package/lib/types/sessionlog.d.ts +14 -0
- package/lib/types/store/frames.d.ts +128 -0
- package/lib/types/store/host.d.ts +88 -0
- package/lib/types/store/http.d.ts +105 -0
- package/lib/types/store/index.d.ts +30 -0
- package/lib/types/store/paths.d.ts +100 -0
- package/lib/types/store/registry.d.ts +206 -0
- package/lib/types/store/util.d.ts +16 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +89 -4
|
@@ -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,274 @@
|
|
|
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
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import zlib from 'node:zlib';
|
|
34
|
+
import { isSessionLogFile } from './paths.js';
|
|
35
|
+
import { messageOf } from './util.js';
|
|
36
|
+
/** zstd 帧魔数(小端)。 */
|
|
37
|
+
export const ZSTD_MAGIC = 0xfd2fb528;
|
|
38
|
+
/** 与 DSH 一致的压缩选项:带内容校验和。 */
|
|
39
|
+
export const CHECKSUM_OPTIONS = zlib.constants?.ZSTD_c_checksumFlag === undefined
|
|
40
|
+
? {}
|
|
41
|
+
: { params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 } };
|
|
42
|
+
/** `zstdCompressSync` 需要 Node ≥22.15 / ≥23.8;缺失时插件拒绝搬迁而不是写坏日志。 */
|
|
43
|
+
export const hasZstd = typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function';
|
|
44
|
+
/** `zstdDecompressSync` 同样需要 Node ≥22.15;缺失时降级为「读不到会话头」。 */
|
|
45
|
+
const zstdDecompress = typeof zlib.zstdDecompressSync === 'function' ? zlib.zstdDecompressSync : undefined;
|
|
46
|
+
/**
|
|
47
|
+
* 走一遍拼接帧,给出每帧的字节范围。
|
|
48
|
+
*
|
|
49
|
+
* 按 zstd 规范解析帧头(魔数 → Frame_Header_Descriptor → 可选窗口描述符 /
|
|
50
|
+
* 字典 id / 内容长度)与块序列(3 字节块头 → 块体),直到 Last_Block,
|
|
51
|
+
* 再按标志位跳过 4 字节内容校验和。
|
|
52
|
+
*
|
|
53
|
+
* @param buffer - 整个日志文件的内容。
|
|
54
|
+
* @returns `{frames, incomplete, reason}`;`frames` 每项含 `{start, end}`。
|
|
55
|
+
*/
|
|
56
|
+
export function scanZstdFrames(buffer) {
|
|
57
|
+
const frames = [];
|
|
58
|
+
let offset = 0;
|
|
59
|
+
while (offset < buffer.length) {
|
|
60
|
+
const start = offset;
|
|
61
|
+
if (buffer.length - offset < 4)
|
|
62
|
+
return { frames, incomplete: true, reason: `尾部剩 ${buffer.length - offset} 字节,不足以构成帧魔数` };
|
|
63
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
|
|
64
|
+
return { frames, incomplete: true, reason: `偏移 ${offset} 处不是 zstd 魔数` };
|
|
65
|
+
}
|
|
66
|
+
offset += 4;
|
|
67
|
+
if (offset >= buffer.length)
|
|
68
|
+
return { frames, incomplete: true, reason: '帧头被截断(缺少描述符)' };
|
|
69
|
+
const descriptor = buffer.readUInt8(offset);
|
|
70
|
+
offset += 1;
|
|
71
|
+
const fcsFlag = descriptor >> 6;
|
|
72
|
+
const singleSegment = (descriptor >> 5) & 1;
|
|
73
|
+
const checksum = (descriptor >> 2) & 1;
|
|
74
|
+
const dictionaryFlag = descriptor & 3;
|
|
75
|
+
if (singleSegment === 0)
|
|
76
|
+
offset += 1; // Window_Descriptor
|
|
77
|
+
offset += dictionaryFlag === 0 ? 0 : dictionaryFlag === 1 ? 1 : dictionaryFlag === 2 ? 2 : 4;
|
|
78
|
+
const fcsSize = fcsFlag === 0 ? (singleSegment === 1 ? 1 : 0) : fcsFlag === 1 ? 2 : fcsFlag === 2 ? 4 : 8;
|
|
79
|
+
offset += fcsSize;
|
|
80
|
+
if (offset > buffer.length)
|
|
81
|
+
return { frames, incomplete: true, reason: '帧头字段越过文件尾' };
|
|
82
|
+
for (;;) {
|
|
83
|
+
if (offset + 3 > buffer.length)
|
|
84
|
+
return { frames, incomplete: true, reason: '块头被截断' };
|
|
85
|
+
const header = buffer.readUInt8(offset) | (buffer.readUInt8(offset + 1) << 8) | (buffer.readUInt8(offset + 2) << 16);
|
|
86
|
+
offset += 3;
|
|
87
|
+
const lastBlock = header & 1;
|
|
88
|
+
const blockType = (header >> 1) & 3;
|
|
89
|
+
const blockSize = header >> 3;
|
|
90
|
+
if (blockType === 0)
|
|
91
|
+
offset += blockSize;
|
|
92
|
+
else if (blockType === 1)
|
|
93
|
+
offset += 1;
|
|
94
|
+
else if (blockType === 2)
|
|
95
|
+
offset += blockSize;
|
|
96
|
+
else
|
|
97
|
+
return { frames, incomplete: true, reason: `偏移 ${offset - 3} 处出现保留块类型` };
|
|
98
|
+
if (lastBlock === 1)
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (checksum === 1)
|
|
102
|
+
offset += 4;
|
|
103
|
+
if (offset > buffer.length)
|
|
104
|
+
return { frames, incomplete: true, reason: '帧体越过文件尾' };
|
|
105
|
+
frames.push({ start, end: offset });
|
|
106
|
+
}
|
|
107
|
+
return { frames, incomplete: false };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 读出日志第一帧里的 header 对象(只解析,不改写)。
|
|
111
|
+
* @param buffer - 日志内容。
|
|
112
|
+
* @param compressed - `true`(默认)表示 zstd 帧日志,`false` 表示未压缩的行式日志。
|
|
113
|
+
* @returns header 对象;不是合法会话日志时 `undefined`。
|
|
114
|
+
*/
|
|
115
|
+
export function readHeaderFromBuffer(buffer, compressed = true) {
|
|
116
|
+
if (!compressed) {
|
|
117
|
+
try {
|
|
118
|
+
const text = buffer.toString('utf8');
|
|
119
|
+
const newlineIndex = text.indexOf('\n');
|
|
120
|
+
const parsed = JSON.parse(newlineIndex === -1 ? text : text.slice(0, newlineIndex));
|
|
121
|
+
return parsed !== null && typeof parsed === 'object' && parsed.type === 'session' ? parsed : undefined;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const scan = scanZstdFrames(buffer);
|
|
128
|
+
if (scan.frames.length === 0)
|
|
129
|
+
return undefined;
|
|
130
|
+
try {
|
|
131
|
+
const first = scan.frames[0];
|
|
132
|
+
const inflated = zlib.zstdDecompressSync(buffer.subarray(first.start, first.end));
|
|
133
|
+
const text = Buffer.from(inflated).toString('utf8');
|
|
134
|
+
const newlineIndex = text.indexOf('\n');
|
|
135
|
+
const parsed = JSON.parse(newlineIndex === -1 ? text : text.slice(0, newlineIndex));
|
|
136
|
+
return parsed !== null && typeof parsed === 'object' && parsed.type === 'session' ? parsed : undefined;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 把一个会话目录里的会话头读出来(**宽松**读法:扫目录找日志文件,取解压后的第一行)。
|
|
144
|
+
*
|
|
145
|
+
* 与 {@link readHeaderFromBuffer} 的区别是它不校验帧结构、也不要求 `type === 'session'`:
|
|
146
|
+
* 目录里可能有半截写入或手工改名的日志,扫描/删除路径宁可拿到一个「像头部」的对象,
|
|
147
|
+
* 也不要因为格式不严格就把整条会话当成不可读。
|
|
148
|
+
*
|
|
149
|
+
* 解压失败(截断、缺 zstd 支持)返回 `undefined`,调用方按「未知」处理。
|
|
150
|
+
* @param dir - 会话目录。
|
|
151
|
+
* @param cache - 以 `路径:mtime:size` 为键的头部缓存(可选;不传就现建一个)。
|
|
152
|
+
* @returns 头部对象,或 undefined。
|
|
153
|
+
*/
|
|
154
|
+
export function readSessionHeader(dir, cache = new Map()) {
|
|
155
|
+
let entries;
|
|
156
|
+
try {
|
|
157
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
const log = entries.find((e) => e.isFile() && isSessionLogFile(e.name));
|
|
163
|
+
if (log === undefined)
|
|
164
|
+
return undefined;
|
|
165
|
+
const path = join(dir, log.name);
|
|
166
|
+
let stat;
|
|
167
|
+
try {
|
|
168
|
+
stat = statSync(path);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const key = `${path}:${stat.mtimeMs}:${stat.size}`;
|
|
174
|
+
if (cache.has(key))
|
|
175
|
+
return cache.get(key);
|
|
176
|
+
let header;
|
|
177
|
+
try {
|
|
178
|
+
const raw = readFileSync(path);
|
|
179
|
+
const text = log.name.endsWith('.zstd')
|
|
180
|
+
? zstdDecompress === undefined
|
|
181
|
+
? undefined
|
|
182
|
+
: zstdDecompress(raw).toString('utf8')
|
|
183
|
+
: raw.toString('utf8');
|
|
184
|
+
if (text !== undefined) {
|
|
185
|
+
const end = text.indexOf('\n');
|
|
186
|
+
header = JSON.parse(end === -1 ? text : text.slice(0, end));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
header = undefined;
|
|
191
|
+
}
|
|
192
|
+
cache.set(key, header);
|
|
193
|
+
return header;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 把会话头的 `cwd` 改写成另一条绝对路径。
|
|
197
|
+
*
|
|
198
|
+
* 只动 `cwd` 一个键,其余键(含该 generation 自己的字段)按原顺序逐字保留;
|
|
199
|
+
* 帧 0 之后的字节**原样接上**,所以事件批次一个字节都没变。
|
|
200
|
+
*
|
|
201
|
+
* @param buffer - 日志内容。
|
|
202
|
+
* @param newCwd - 改写后的绝对路径。
|
|
203
|
+
* @param options - `{compressed}`:帧日志(默认 `true`)或未压缩行式日志。
|
|
204
|
+
* @returns `{buffer, rewritten, header, frames?, reason?}`;无需改写或无法改写时
|
|
205
|
+
* `rewritten: false` 并原样返回。
|
|
206
|
+
*/
|
|
207
|
+
export function rewriteHeaderCwd(buffer, newCwd, options = {}) {
|
|
208
|
+
const compressed = options.compressed !== false;
|
|
209
|
+
if (typeof newCwd !== 'string' || newCwd.length === 0) {
|
|
210
|
+
return { buffer, rewritten: false, reason: 'empty target cwd' };
|
|
211
|
+
}
|
|
212
|
+
if (!compressed) {
|
|
213
|
+
const text = buffer.toString('utf8');
|
|
214
|
+
const newlineIndex = text.indexOf('\n');
|
|
215
|
+
const firstLine = newlineIndex === -1 ? text : text.slice(0, newlineIndex);
|
|
216
|
+
const rest = newlineIndex === -1 ? '' : text.slice(newlineIndex);
|
|
217
|
+
let header;
|
|
218
|
+
try {
|
|
219
|
+
header = JSON.parse(firstLine);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return { buffer, rewritten: false, reason: 'header line is not JSON' };
|
|
223
|
+
}
|
|
224
|
+
if (header?.type !== 'session' || typeof header.cwd !== 'string') {
|
|
225
|
+
return { buffer, rewritten: false, reason: 'header has no cwd' };
|
|
226
|
+
}
|
|
227
|
+
if (header.cwd === newCwd)
|
|
228
|
+
return { buffer, rewritten: false, header };
|
|
229
|
+
const rebuilt = {};
|
|
230
|
+
for (const [key, value] of Object.entries(header))
|
|
231
|
+
rebuilt[key] = key === 'cwd' ? newCwd : value;
|
|
232
|
+
return { buffer: Buffer.from(JSON.stringify(rebuilt) + rest, 'utf8'), rewritten: true, header: rebuilt };
|
|
233
|
+
}
|
|
234
|
+
if (!hasZstd)
|
|
235
|
+
return { buffer, rewritten: false, reason: 'this node build lacks zlib zstd support' };
|
|
236
|
+
const scan = scanZstdFrames(buffer);
|
|
237
|
+
if (scan.incomplete || scan.frames.length === 0) {
|
|
238
|
+
return { buffer, rewritten: false, reason: scan.reason ?? 'no complete frame' };
|
|
239
|
+
}
|
|
240
|
+
const firstFrame = scan.frames[0];
|
|
241
|
+
let header;
|
|
242
|
+
let rest;
|
|
243
|
+
try {
|
|
244
|
+
const inflated = zlib.zstdDecompressSync(buffer.subarray(firstFrame.start, firstFrame.end));
|
|
245
|
+
const text = Buffer.from(inflated).toString('utf8');
|
|
246
|
+
const newlineIndex = text.indexOf('\n');
|
|
247
|
+
header = JSON.parse(newlineIndex === -1 ? text : text.slice(0, newlineIndex));
|
|
248
|
+
rest = newlineIndex === -1 ? '' : text.slice(newlineIndex);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
return { buffer, rewritten: false, reason: `cannot decode header frame: ${messageOf(error)}` };
|
|
252
|
+
}
|
|
253
|
+
if (header?.type !== 'session' || typeof header.cwd !== 'string') {
|
|
254
|
+
return { buffer, rewritten: false, reason: 'header has no cwd' };
|
|
255
|
+
}
|
|
256
|
+
if (header.cwd === newCwd)
|
|
257
|
+
return { buffer, rewritten: false, header, frames: scan.frames.length };
|
|
258
|
+
const rebuilt = {};
|
|
259
|
+
for (const [key, value] of Object.entries(header))
|
|
260
|
+
rebuilt[key] = key === 'cwd' ? newCwd : value;
|
|
261
|
+
let newFrame;
|
|
262
|
+
try {
|
|
263
|
+
newFrame = zlib.zstdCompressSync(Buffer.from(JSON.stringify(rebuilt) + rest, 'utf8'), CHECKSUM_OPTIONS);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
return { buffer, rewritten: false, reason: `cannot recompress header frame: ${messageOf(error)}` };
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
buffer: Buffer.concat([newFrame, buffer.subarray(firstFrame.end)]),
|
|
270
|
+
rewritten: true,
|
|
271
|
+
header: rebuilt,
|
|
272
|
+
frames: scan.frames.length,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
/** `$DSH_HOME`:环境变量优先,默认 `~/.dsh`。 */
|
|
21
|
+
export function dshHome() {
|
|
22
|
+
const fromEnv = process.env.DSH_HOME;
|
|
23
|
+
return fromEnv !== undefined && fromEnv !== '' ? fromEnv : join(homedir(), '.dsh');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 文件日志行格式之一:`[ISO 时间] 级别 正文`(`dsh-session-admin` 在用)。
|
|
27
|
+
* @param entry - `{iso, level, message}`。
|
|
28
|
+
* @returns 一整行(含结尾换行)。
|
|
29
|
+
*/
|
|
30
|
+
export const LOG_LINE_LEVEL_FIRST = ({ iso, level, message }) => `[${iso}] ${level} ${message}\n`;
|
|
31
|
+
/**
|
|
32
|
+
* 文件日志行格式之一:`ISO 时间 [级别] 正文`(`dsh-session-move` 在用)。
|
|
33
|
+
* @param entry - `{iso, level, message}`。
|
|
34
|
+
* @returns 一整行(含结尾换行)。
|
|
35
|
+
*/
|
|
36
|
+
export const LOG_LINE_TIME_FIRST = ({ iso, level, message }) => `${iso} [${level}] ${message}\n`;
|
|
37
|
+
/**
|
|
38
|
+
* 尽力而为的文件日志:写 `$DSH_HOME/logs/<name>.log`,并顺带喂宿主 logger。
|
|
39
|
+
*
|
|
40
|
+
* 任何一步失败都静默吞掉——日志本身永远不该成为故障源(建目录失败、磁盘满了、
|
|
41
|
+
* 宿主 logger 不可用,都不影响插件机制)。
|
|
42
|
+
* @param ctx - Host cordis 上下文(只用 `ctx?.logger`,可为 undefined)。
|
|
43
|
+
* @param options - `{name, formatLine}`:日志文件名(不含 `.log`;默认
|
|
44
|
+
* `dsh-session-ops`)与行格式(默认 {@link LOG_LINE_LEVEL_FIRST})。
|
|
45
|
+
* @returns `(level, message) => void`,即插件里的 `log`。
|
|
46
|
+
*/
|
|
47
|
+
export function createFileLogger(ctx, options = {}) {
|
|
48
|
+
const name = typeof options.name === 'string' && options.name.length > 0 ? options.name : 'dsh-session-ops';
|
|
49
|
+
const formatLine = typeof options.formatLine === 'function' ? options.formatLine : LOG_LINE_LEVEL_FIRST;
|
|
50
|
+
let path;
|
|
51
|
+
try {
|
|
52
|
+
const dir = join(dshHome(), 'logs');
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
path = join(dir, `${name}.log`);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
path = undefined;
|
|
58
|
+
}
|
|
59
|
+
return (level, message) => {
|
|
60
|
+
try {
|
|
61
|
+
ctx?.logger?.[level]?.(message);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* 宿主 logger 不可用不影响文件日志 */
|
|
65
|
+
}
|
|
66
|
+
if (path === undefined)
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
appendFileSync(path, formatLine({ iso: new Date().toISOString(), level, message }), 'utf8');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* 日志写不进去就算了 */
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 解包 entry 配置里的 `.volatile()` 活引用。
|
|
78
|
+
* @param raw - entry 配置或普通配置对象。
|
|
79
|
+
* @returns 值已解包的浅拷贝。
|
|
80
|
+
*/
|
|
81
|
+
export function plainConfig(raw = {}) {
|
|
82
|
+
const source = raw !== null && typeof raw === 'object' ? raw : {};
|
|
83
|
+
const plain = {};
|
|
84
|
+
for (const [key, value] of Object.entries(source)) {
|
|
85
|
+
plain[key] = value !== null && typeof value === 'object' && typeof value.get === 'function'
|
|
86
|
+
? value.get()
|
|
87
|
+
: value;
|
|
88
|
+
}
|
|
89
|
+
return plain;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 解析一份 entry 配置:先解包输入的活引用,再交给 schema 校验并补默认值,最后**再解包
|
|
93
|
+
* 一次**——schemastery 对 `.volatile()` 字段输出的也是活引用,不收敛的话读到的就是
|
|
94
|
+
* `{ get() }` 对象(HTTP 面上会变成一个空对象)。
|
|
95
|
+
*
|
|
96
|
+
* schema 由调用方传入(本包只把 `@deepseek-ai/schemastery` 当类型来源),传进来的只要是
|
|
97
|
+
* 「可调用、收普通对象、返回带默认值的普通对象」即可。
|
|
98
|
+
* @param schema - 插件自己的 `Config`(schemastery 对象,可调用)。
|
|
99
|
+
* @param raw - entry 配置或普通配置对象。
|
|
100
|
+
* @returns 普通值配置。
|
|
101
|
+
*/
|
|
102
|
+
export function resolveVolatileConfig(schema, raw = {}) {
|
|
103
|
+
return plainConfig(schema(plainConfig(raw)));
|
|
104
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
import { messageOf } from './util.js';
|
|
26
|
+
/** 请求体上限:1 MiB。 */
|
|
27
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
28
|
+
/**
|
|
29
|
+
* 写一个 JSON 响应。
|
|
30
|
+
* @param res - Node HTTP 响应。
|
|
31
|
+
* @param status - 状态码。
|
|
32
|
+
* @param body - 任意可 JSON 序列化的值。
|
|
33
|
+
* @param options - `{contentLength}`:是否带上 `content-length` 头。默认 `false`
|
|
34
|
+
* (`dsh-session-move` 的原样行为,响应走 chunked);`dsh-session-admin` 传 `true`
|
|
35
|
+
* 以保留它原有的 `content-length`。
|
|
36
|
+
* @returns 无。
|
|
37
|
+
*/
|
|
38
|
+
export function writeJson(res, status, body, options = {}) {
|
|
39
|
+
const text = JSON.stringify(body);
|
|
40
|
+
if (options.contentLength === true) {
|
|
41
|
+
res.writeHead(status, {
|
|
42
|
+
'content-type': 'application/json; charset=utf-8',
|
|
43
|
+
'cache-control': 'no-store',
|
|
44
|
+
'content-length': Buffer.byteLength(text),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
49
|
+
}
|
|
50
|
+
res.end(text);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 读一个 JSON 请求体(上限 1 MiB)。
|
|
54
|
+
*
|
|
55
|
+
* 严格程度由调用方选,因为两个插件原本就不一样:
|
|
56
|
+
* - `requireObject: false`(默认,`dsh-session-admin` 的行为):不 trim、不校验类型,
|
|
57
|
+
* 空 body 返回 `{}`,其余原样交给 `JSON.parse`(一个空白 body 会抛出
|
|
58
|
+
* `Unexpected end of JSON input`,进而变成 400);
|
|
59
|
+
* - `requireObject: true`(`dsh-session-move` 的行为):trim 后空串返回 `{}`,
|
|
60
|
+
* 解析结果必须是**普通对象**,否则抛 `request body must be a JSON object`。
|
|
61
|
+
* @param req - Node HTTP 请求。
|
|
62
|
+
* @param options - `{requireObject}`。
|
|
63
|
+
* @returns 解析出来的载荷对象。
|
|
64
|
+
*/
|
|
65
|
+
export async function readJsonBody(req, options = {}) {
|
|
66
|
+
const requireObject = options.requireObject === true;
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
for await (const chunk of req) {
|
|
70
|
+
size += chunk.length;
|
|
71
|
+
if (size > MAX_BODY_BYTES)
|
|
72
|
+
throw new Error('request body too large');
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
}
|
|
75
|
+
if (chunks.length === 0)
|
|
76
|
+
return {};
|
|
77
|
+
const buffer = Buffer.concat(chunks).toString('utf8');
|
|
78
|
+
if (!requireObject)
|
|
79
|
+
return JSON.parse(buffer);
|
|
80
|
+
const text = buffer.trim();
|
|
81
|
+
if (text === '')
|
|
82
|
+
return {};
|
|
83
|
+
const parsed = JSON.parse(text);
|
|
84
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
85
|
+
throw new Error('request body must be a JSON object');
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 造一个 `kind: 'prefix'` 路由处理器(`ctx.webServer.register({kind:'prefix', path, handler})` 的 `handler`)。
|
|
90
|
+
*
|
|
91
|
+
* 方法表里的每个处理器收到 `payload`,返回值包成 `{ok:true,value}`;抛错变成 400 并写一行
|
|
92
|
+
* `<method> failed: <message>` 日志。`GET` 的载荷是 query string(全部是字符串)。
|
|
93
|
+
* @param options - 见下。
|
|
94
|
+
* @param options.prefix - 路由前缀,如 `/session-admin/api`。
|
|
95
|
+
* @param options.methods - 方法名 → `async (payload) => value`。
|
|
96
|
+
* @param options.trustRequest - `(req) => boolean`:请求是否来自受信的浏览器(各插件自己的栅栏)。
|
|
97
|
+
* @param options.log - `(level, message) => void`(可为 undefined)。
|
|
98
|
+
* @param options.readBody - 读请求体,默认 {@link readJsonBody}(宽松);`dsh-session-move`
|
|
99
|
+
* 传 `(req) => readJsonBody(req, {requireObject: true})`。
|
|
100
|
+
* @param options.contentLength - 透传给 {@link writeJson}。
|
|
101
|
+
* @returns `async (req, res) => void`。
|
|
102
|
+
*/
|
|
103
|
+
export function createApiHandler(options) {
|
|
104
|
+
const { prefix, methods, trustRequest, log, readBody = readJsonBody, contentLength = false } = options;
|
|
105
|
+
const reply = (res, status, body) => writeJson(res, status, body, { contentLength });
|
|
106
|
+
return async (req, res) => {
|
|
107
|
+
if (typeof trustRequest !== 'function' || !trustRequest(req)) {
|
|
108
|
+
reply(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal');
|
|
112
|
+
const method = url.pathname.startsWith(`${prefix}/`) ? url.pathname.slice(prefix.length + 1) : undefined;
|
|
113
|
+
if (method === undefined || method.includes('/')) {
|
|
114
|
+
reply(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const handler = methods[method];
|
|
118
|
+
if (typeof handler !== 'function') {
|
|
119
|
+
reply(res, 404, { ok: false, error: { code: 'not-found', message: `unknown method ${method}` } });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const payload = req.method === 'GET' ? Object.fromEntries(url.searchParams) : await readBody(req);
|
|
124
|
+
const value = await handler(payload);
|
|
125
|
+
reply(res, 200, { ok: true, value });
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (typeof log === 'function')
|
|
129
|
+
log('warn', `${method} failed: ${messageOf(error)}`);
|
|
130
|
+
reply(res, 400, { ok: false, error: { code: 'bad-request', message: messageOf(error) } });
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -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';
|