@leonhardthust/dsh-decision-engine 0.0.0-stage → 1.9.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 +55 -2
- package/README.zh.md +57 -0
- package/config.example.yml +436 -0
- package/cordis.patch.yml +14 -0
- package/docs/assets/config-page.png +0 -0
- package/docs/assets/decisions-tab.png +0 -0
- package/docs/assets/hero.jpeg +0 -0
- package/docs/assets/stats-charts-preview.png +0 -0
- package/docs/assets/stats-donut-toggle.png +0 -0
- package/docs/assets/stats-interactive-charts.png +0 -0
- package/docs/assets/stats-point-donut.png +0 -0
- package/docs/assets/stats-ranking-bars.png +0 -0
- package/docs/lessons/cloudflare-builds-root-directory.md +51 -0
- package/docs/lessons/cold-start-timeout.md +134 -0
- package/docs/lessons/endpoint-path-vs-model.md +26 -0
- package/docs/lessons/model-field-required-by-provider.md +31 -0
- package/docs/local-dev-link.md +54 -0
- package/docs/releasing.md +67 -0
- package/docs/security-revision.md +130 -0
- package/icon.svg +6 -0
- package/lib/adapters/agent.js +314 -0
- package/lib/adapters/route.js +144 -0
- package/lib/adapters/tools.js +186 -0
- package/lib/adapters/translate.js +143 -0
- package/lib/approval-detail.js +325 -0
- package/lib/client.charts.js +36 -0
- package/lib/client.js +8589 -0
- package/lib/config-file.js +136 -0
- package/lib/credentials.js +187 -0
- package/lib/decision-cache.js +188 -0
- package/lib/decision-points.js +205 -0
- package/lib/decision-store.js +774 -0
- package/lib/endpoint-fallback.js +143 -0
- package/lib/engine.js +312 -0
- package/lib/guard/advisory.js +100 -0
- package/lib/guard/audit.js +79 -0
- package/lib/guard/context.js +128 -0
- package/lib/guard/effects.js +96 -0
- package/lib/guard/fallback.js +139 -0
- package/lib/guard/listener.js +191 -0
- package/lib/guard/policy.js +28 -0
- package/lib/home-paths.js +86 -0
- package/lib/index.js +3823 -0
- package/lib/outcomes.js +169 -0
- package/lib/points/spec.js +645 -0
- package/lib/providers.js +958 -0
- package/lib/resilience.js +308 -0
- package/lib/scope.js +68 -0
- package/lib/server-config.js +351 -0
- package/lib/session-events.js +75 -0
- package/lib/stats-store.js +916 -0
- package/lib/stats.js +1293 -0
- package/lib/telemetry.js +465 -0
- package/lib/tokenizer.js +201 -0
- package/lib/tolerance.js +498 -0
- package/lib/vendor/microcharts-charts.css +47 -0
- package/lib/vendor-src/entry.js +13 -0
- package/lib/vendor-src/jsx-shim.js +34 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +101 -3
- package/scripts/migrate-profile.mjs +102 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 决策明细存储(`decisions.jsonl`)——「决策历史」标签页的数据源。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么不写进 telemetry.jsonl
|
|
5
|
+
*
|
|
6
|
+
* `telemetry.jsonl` 是**人读的审计流水账**(DEC-013 定义的「服务端到底说了
|
|
7
|
+
* 什么」),而且它是 `StatsStore.backfillFromTelemetry()` 的唯一回填来源。
|
|
8
|
+
* 它的平均行长实测只有 377 字节;一旦把发给决策模型的 `state`、`questions`
|
|
9
|
+
* 与回来的 `answers` 塞进去,行长会膨胀十到十五倍,于是:
|
|
10
|
+
*
|
|
11
|
+
* 1. `tail -f` 不再好读——而这正是它存在的理由;
|
|
12
|
+
* 2. 回填链路读的每一行都变大,历史迁移的内存与耗时跟着涨;
|
|
13
|
+
* 3. 老版本插件读到带大字段的行时,字段口径会漂移。
|
|
14
|
+
*
|
|
15
|
+
* 所以另立一个文件。两个文件各管一件事:
|
|
16
|
+
*
|
|
17
|
+
* - `telemetry.jsonl` 对外口径的决策**结论**(谁、哪家、什么裁决、多少 token)
|
|
18
|
+
* - `decisions.jsonl` 本地的决策**明细**(问了什么、答了什么、给谁做的)
|
|
19
|
+
*
|
|
20
|
+
* ## 记录体为什么带 sessionId
|
|
21
|
+
*
|
|
22
|
+
* 这个字段是新加的:老遥测里**根本没有**会话维度,所以「某个会话里的全部
|
|
23
|
+
* 决策」在旧数据上答不出来。这里不回溯、不臆造——没有 sessionId 的记录
|
|
24
|
+
* (例如思维链翻译的匿名通道)就是「不可归属」,界面上不进任何会话页。
|
|
25
|
+
* 臆造一个 id 比留空更坏:它会让用户看到一条属于别人的记录。
|
|
26
|
+
*
|
|
27
|
+
* ## 隐私边界:唯一的开关是「决策历史」
|
|
28
|
+
*
|
|
29
|
+
* 这里只有一个开关——构造项 `getEnabled()`(来自 `decisionsTabEnabled`):
|
|
30
|
+
*
|
|
31
|
+
* - **关 = 完全不写**。不建文件、不占磁盘、不采集内容;不是「写了再藏」。
|
|
32
|
+
* - **开 = 记录明细,且一定带上内容**。内容只在超过单条长度上限时才由
|
|
33
|
+
* `truncateContent()` 退回 `capture: 'meta'`;没有任何配置键能把它静默
|
|
34
|
+
* 剥掉——`capture: 'meta'` 因此只可能来自截断超限(`too-large` /
|
|
35
|
+
* `serialize-error` / `no-content`),不存在「配置成只记元数据」的档位。
|
|
36
|
+
*
|
|
37
|
+
* 内容落盘的前提是三条硬约束,写在这里是为了以后不被悄悄改掉:
|
|
38
|
+
*
|
|
39
|
+
* 1. **只落本地**。这条链路不经过任何网络请求,也不进匿名统计(
|
|
40
|
+
* `lib/telemetry.js` 的七个字段与这里无关)。
|
|
41
|
+
* 2. **写盘前脱敏**。`maskSecrets()` 对整行做模式替换:一次 `bash` 里
|
|
42
|
+
* 贴过的 API key 不该躺在磁盘上。
|
|
43
|
+
* 3. **有上限**。单条内容截断、文件按体积轮转——明细不是无界日志。
|
|
44
|
+
*
|
|
45
|
+
* ## 写入为什么不阻塞决策
|
|
46
|
+
*
|
|
47
|
+
* 与 `StatsStore` 同一套做法:`record()` **不 await**,把写盘排进串行队列
|
|
48
|
+
* 就返回;队列有上限,磁盘挂掉时丢最旧的。决策路径上的额外开销恒为
|
|
49
|
+
* 「一次 push + 一次 JSON.stringify」,绝不等磁盘。
|
|
50
|
+
*
|
|
51
|
+
* @module dsh-decision-engine/decision-store
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { randomUUID } from "node:crypto";
|
|
55
|
+
import { chmod, mkdir, open, rename, stat, unlink } from "node:fs/promises";
|
|
56
|
+
import { dirname, join } from "node:path";
|
|
57
|
+
|
|
58
|
+
/** 明细文件名(与 `telemetry.jsonl` / `stats.jsonl` 同目录)。 */
|
|
59
|
+
export const DECISIONS_FILE_NAME = "decisions.jsonl";
|
|
60
|
+
|
|
61
|
+
/** 轮转后的旧文件(覆盖式,只留一代)。 */
|
|
62
|
+
export const DECISIONS_ROTATED_NAME = "decisions.jsonl.1";
|
|
63
|
+
|
|
64
|
+
/** 记录体版本。加字段不升版本(读端按缺省值兜),改语义才升。 */
|
|
65
|
+
export const DECISION_RECORD_VERSION = 1;
|
|
66
|
+
|
|
67
|
+
/** 默认体积上限:8 MB。到顶就轮转,明细文件永无界增长。 */
|
|
68
|
+
export const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
69
|
+
|
|
70
|
+
/** 默认单条内容上限:4000 字符。够回答「这次问了什么、答了什么」。 */
|
|
71
|
+
export const DEFAULT_MAX_CONTENT_CHARS = 4000;
|
|
72
|
+
|
|
73
|
+
/** 尾部读默认扫描行数:再多也不会整文件载入。 */
|
|
74
|
+
export const DEFAULT_SCAN_LINES = 4000;
|
|
75
|
+
|
|
76
|
+
/** 单次查询的返回条数上限。 */
|
|
77
|
+
export const MAX_ENTRIES_PER_QUERY = 500;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 密钥形态的**兜底**替换表。
|
|
81
|
+
*
|
|
82
|
+
* 为什么是「兜底」而不是「保证」:这里只认**有固定形态**的密钥(OpenAI 风格
|
|
83
|
+
* 的 `sk-`、GitHub token、JSON 里的 apiKey / token 字段)。没有固定形态的
|
|
84
|
+
* 密钥(例如某家自建的任意字符串)在原理上没法靠模式识别——那种情况只能
|
|
85
|
+
* 靠用户不把 key 贴进命令。因此这里的定位是「拦住最常见的泄漏形态」,
|
|
86
|
+
* 不是「保证磁盘上没有密钥」。这句话必须留在代码里,否则后来的人会以为
|
|
87
|
+
* 脱敏是全覆盖的。
|
|
88
|
+
*
|
|
89
|
+
* 替换值不含引号与反斜杠,因此对一行 JSON 做替换后仍然是合法 JSON。
|
|
90
|
+
*/
|
|
91
|
+
const SECRET_PATTERNS = [
|
|
92
|
+
// OpenAI / 兼容网关的 key
|
|
93
|
+
[/(sk-[A-Za-z0-9_-]{12,})/g, "sk-***"],
|
|
94
|
+
// GitHub token
|
|
95
|
+
[/(ghp_[A-Za-z0-9]{20,})/g, "ghp_***"],
|
|
96
|
+
[/(github_pat_[A-Za-z0-9_]{20,})/g, "github_pat_***"],
|
|
97
|
+
// `Authorization: Bearer xxx`(JSON 串或 header 行都认)
|
|
98
|
+
[/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi, "$1***"],
|
|
99
|
+
// JSON 里的敏感字段(序列化后形如 `"apiKey": "..."`)
|
|
100
|
+
[/("(?:x-)?api[-_]?key"\s*:\s*")[^"]{4,}/gi, "$1***"],
|
|
101
|
+
[/("(?:authorization|proxy-authorization)"\s*:\s*")[^"]{4,}/gi, "$1***"],
|
|
102
|
+
[/("(?:password|passwd|secret|token|apiKeyValue)"\s*:\s*")[^"]{4,}/gi, "$1***"],
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 对一段文本做密钥兜底替换。
|
|
107
|
+
*
|
|
108
|
+
* 输入通常是**整行 JSON**(见 `DecisionStore.record`)。对整行而不是只对
|
|
109
|
+
* `content` 做,是因为 `detail` 里也可能带路径或命令片段。
|
|
110
|
+
*
|
|
111
|
+
* @param text - 待脱敏的文本。
|
|
112
|
+
* @returns 替换后的文本;输入非字符串时原样返回。
|
|
113
|
+
*/
|
|
114
|
+
export function maskSecrets(text) {
|
|
115
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
116
|
+
let out = text;
|
|
117
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
|
118
|
+
out = out.replace(pattern, replacement);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 截断到指定长度,保留头尾(中段省略)。
|
|
125
|
+
*
|
|
126
|
+
* 与 `lib/index.js` 的 `headTail` 同语义,但刻意不复用:那是服务端
|
|
127
|
+
* 「发给决策模型的 state」的裁剪口径,改它会改变决策行为;这里是写盘的
|
|
128
|
+
* 展示口径,两者必须能各自演进。
|
|
129
|
+
*
|
|
130
|
+
* @param text - 原文。
|
|
131
|
+
* @param headChars - 头部保留字符数。
|
|
132
|
+
* @param tailChars - 尾部保留字符数。
|
|
133
|
+
* @returns 截断后的文本。
|
|
134
|
+
*/
|
|
135
|
+
function headTail(text, headChars, tailChars) {
|
|
136
|
+
const value = typeof text === "string" ? text : String(text ?? "");
|
|
137
|
+
const head = Math.max(0, Number(headChars) || 0);
|
|
138
|
+
const tail = Math.max(0, Number(tailChars) || 0);
|
|
139
|
+
if (value.length <= head + tail) return value;
|
|
140
|
+
const tailText = tail > 0 ? value.slice(-tail) : "";
|
|
141
|
+
return `${value.slice(0, head)}…[省略 ${value.length - head - tailText.length} 字符]…${tailText}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 深拷贝一份可 JSON 化的值。
|
|
146
|
+
*
|
|
147
|
+
* 不用 `structuredClone`:明细内容来自服务端的 JSON,走一次
|
|
148
|
+
* `JSON.parse(JSON.stringify())` 顺带把 `undefined`、函数、类实例全部归一成
|
|
149
|
+
* 纯数据,写盘时不会出现「序列化到一半抛错」这种情况。
|
|
150
|
+
*
|
|
151
|
+
* @param value - 待拷贝的值。
|
|
152
|
+
* @returns 拷贝结果;不可序列化时返回 undefined。
|
|
153
|
+
*/
|
|
154
|
+
function jsonClone(value) {
|
|
155
|
+
try {
|
|
156
|
+
return JSON.parse(JSON.stringify(value));
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 把一次决策的内容裁到上限之内。
|
|
164
|
+
*
|
|
165
|
+
* 裁剪顺序**按信息价值从低到高**排,不是按字段顺序:
|
|
166
|
+
*
|
|
167
|
+
* 1. `state.args_preview` —— 工具参数预览,最长、也最可能是噪声(一段
|
|
168
|
+
* 命令的完整正文);
|
|
169
|
+
* 2. `state.recent` —— 最近几条对话的摘要;
|
|
170
|
+
* 3. `state` 整体 —— 换成一个「已截断」的占位对象;
|
|
171
|
+
* 4. 连 `questions` / `answers` 都超限时,退回 `null` + `capture: 'meta'`。
|
|
172
|
+
*
|
|
173
|
+
* 为什么 `questions` / `answers` 排在最后:它们才是「这次到底问了什么、
|
|
174
|
+
* 答了什么」,也就是这个页面存在的理由;`state` 是背景材料。为了保住
|
|
175
|
+
* 结论可以丢掉背景,反过来不行。
|
|
176
|
+
*
|
|
177
|
+
* @param content - `{ state, questions, answers }`,可缺项。
|
|
178
|
+
* @param maxChars - 序列化后的长度上限。
|
|
179
|
+
* @returns `{ content, capture, note }`:`capture` 为 `"full"` / `"meta"`。
|
|
180
|
+
*/
|
|
181
|
+
export function truncateContent(content, maxChars) {
|
|
182
|
+
const limit = Number.isFinite(Number(maxChars))
|
|
183
|
+
? Math.max(200, Number(maxChars))
|
|
184
|
+
: DEFAULT_MAX_CONTENT_CHARS;
|
|
185
|
+
if (content === null || typeof content !== "object") {
|
|
186
|
+
return { content: null, capture: "meta", note: "no-content" };
|
|
187
|
+
}
|
|
188
|
+
const cloned = jsonClone(content);
|
|
189
|
+
if (cloned === null || typeof cloned !== "object") {
|
|
190
|
+
return { content: null, capture: "meta", note: "serialize-error" };
|
|
191
|
+
}
|
|
192
|
+
const sizeOf = (value) => {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.stringify(value).length;
|
|
195
|
+
} catch {
|
|
196
|
+
return Number.POSITIVE_INFINITY;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
if (sizeOf(cloned) <= limit) return { content: cloned, capture: "full", note: "" };
|
|
200
|
+
|
|
201
|
+
// 第一刀:工具参数预览(最长、价值最低)。
|
|
202
|
+
if (typeof cloned.state?.args_preview === "string") {
|
|
203
|
+
cloned.state.args_preview = headTail(cloned.state.args_preview, Math.floor(limit / 8), 0);
|
|
204
|
+
if (sizeOf(cloned) <= limit) return { content: cloned, capture: "full", note: "trim-args" };
|
|
205
|
+
}
|
|
206
|
+
// 第二刀:最近对话摘要。
|
|
207
|
+
if (typeof cloned.state?.recent === "string") {
|
|
208
|
+
cloned.state.recent = headTail(cloned.state.recent, Math.floor(limit / 8), 0);
|
|
209
|
+
if (sizeOf(cloned) <= limit) return { content: cloned, capture: "full", note: "trim-recent" };
|
|
210
|
+
}
|
|
211
|
+
// 第三刀:整个 state 换成占位(保住 questions / answers)。
|
|
212
|
+
if (cloned.state !== undefined && cloned.state !== null) {
|
|
213
|
+
cloned.state = { truncated: true, note: "state dropped to fit the content limit" };
|
|
214
|
+
if (sizeOf(cloned) <= limit) return { content: cloned, capture: "full", note: "drop-state" };
|
|
215
|
+
}
|
|
216
|
+
// 最后的兜底:内容整体不存,只留元数据。
|
|
217
|
+
return { content: null, capture: "meta", note: "too-large" };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 造一条决策明细记录。
|
|
222
|
+
*
|
|
223
|
+
* 与 `stats.js` 的 `buildRecord` 同一个角色:**唯一的构造入口**。差异在于
|
|
224
|
+
* 这里多两样东西——会话归属与决策内容——而它们正是这个页面的核心。
|
|
225
|
+
*
|
|
226
|
+
* 字段取舍说明:
|
|
227
|
+
*
|
|
228
|
+
* - `mode` 是**决策点自己的三态**(observe / enforce / off);`topMode`
|
|
229
|
+
* 是旧的顶层 `shadow` / `enforce`。两者并存,因为界面要同时回答
|
|
230
|
+
* 「这个点在当时是观察还是生效」与「插件整体是什么模式」。
|
|
231
|
+
* - `tool` / `turn` / `step` **有则带、无则省**。不同决策点的现场不同
|
|
232
|
+
* (授权点有工具名与轮次,翻译点什么都没有),硬造一个 `null` 只会
|
|
233
|
+
* 让界面多出一列「—」。
|
|
234
|
+
* - `action` 是裁决词(`delegate` / `deny` / `keep` / `skip` / `switch` …),
|
|
235
|
+
* `detail` 是人读的原因。两者都留,因为界面表里只放裁决词、展开才看原因。
|
|
236
|
+
*
|
|
237
|
+
* @param options - 字段见函数体;缺省值一律给安全值。
|
|
238
|
+
* @returns 记录对象(字段齐整,可直接 JSON 化)。
|
|
239
|
+
*/
|
|
240
|
+
export function buildDecisionRecord(options = {}) {
|
|
241
|
+
const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
242
|
+
const str = (value, fallback = "") =>
|
|
243
|
+
typeof value === "string" && value.length > 0 ? value : fallback;
|
|
244
|
+
const optional = (value) =>
|
|
245
|
+
typeof value === "string" && value.length > 0 ? { value } : undefined;
|
|
246
|
+
|
|
247
|
+
const tool = optional(options.tool);
|
|
248
|
+
const turn = typeof options.turn === "number" && Number.isFinite(options.turn) ? options.turn : undefined;
|
|
249
|
+
const step = typeof options.step === "number" && Number.isFinite(options.step) ? options.step : undefined;
|
|
250
|
+
const scores = options.scores === undefined ? undefined : jsonClone(options.scores);
|
|
251
|
+
const trimmed =
|
|
252
|
+
options.content === undefined || options.content === null
|
|
253
|
+
? { content: null, capture: "meta", note: "no-content" }
|
|
254
|
+
: truncateContent(options.content, options.maxContentChars);
|
|
255
|
+
|
|
256
|
+
const record = {
|
|
257
|
+
v: DECISION_RECORD_VERSION,
|
|
258
|
+
seq: num(options.seq),
|
|
259
|
+
ts: str(options.ts, new Date().toISOString()),
|
|
260
|
+
// 空串 = 不可归属。读端据此把它排除在「某个会话的决策历史」之外。
|
|
261
|
+
sessionId: str(options.sessionId),
|
|
262
|
+
point: str(options.point, "unknown"),
|
|
263
|
+
tag: str(options.tag),
|
|
264
|
+
mode: str(options.mode, "unknown"),
|
|
265
|
+
topMode: str(options.topMode),
|
|
266
|
+
provider: str(options.provider, "unknown"),
|
|
267
|
+
model: str(options.model),
|
|
268
|
+
action: str(options.action),
|
|
269
|
+
detail: str(options.detail),
|
|
270
|
+
// 降级失败时的 HTTP 状态码(401/403/404/500 ...):来自降级原因的 extra.status。
|
|
271
|
+
// 单独成字段,便于「标记」列直接展示(如 HTTP 401),而不必去猜 detail 是否带码。
|
|
272
|
+
status: typeof options.status === "number" && Number.isFinite(options.status) ? options.status : undefined,
|
|
273
|
+
cached: options.cached === true,
|
|
274
|
+
degraded: options.degraded === true,
|
|
275
|
+
latencyMs: num(options.latencyMs),
|
|
276
|
+
inputTokens: num(options.inputTokens),
|
|
277
|
+
outputTokens: num(options.outputTokens),
|
|
278
|
+
capture: trimmed.capture,
|
|
279
|
+
};
|
|
280
|
+
if (tool !== undefined) record.tool = tool.value;
|
|
281
|
+
if (turn !== undefined) record.turn = turn;
|
|
282
|
+
if (step !== undefined) record.step = step;
|
|
283
|
+
if (scores !== undefined && scores !== null) record.scores = scores;
|
|
284
|
+
if (trimmed.content !== null) record.content = trimmed.content;
|
|
285
|
+
// note 只在真的裁剪过时才写:未裁剪的记录不该带一个空字段。
|
|
286
|
+
if (trimmed.note.length > 0 && trimmed.note !== "no-content") record.contentNote = trimmed.note;
|
|
287
|
+
return record;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 从文件尾部倒着读,最多取 `maxLines` 行。
|
|
292
|
+
*
|
|
293
|
+
* 为什么必须从尾部读:明细带内容后文件会长到上百 MB,整文件载入会
|
|
294
|
+
* 把宿主进程的堆打满——而这个页面绝大多数时候只想看**最近几十条**。
|
|
295
|
+
*
|
|
296
|
+
* 坏行(写到一半被杀的半行)在这里原样返回、由调用方跳过,与
|
|
297
|
+
* `StatsStore.read()` 的容错口径一致。
|
|
298
|
+
*
|
|
299
|
+
* @param path - 文件路径。
|
|
300
|
+
* @param maxLines - 最多返回多少行。
|
|
301
|
+
* @param chunkBytes - 每次反向读取的字节数。
|
|
302
|
+
* @returns 行数组,**最早的在前**;文件不存在或读失败时返回空数组。
|
|
303
|
+
*/
|
|
304
|
+
export async function readTailLines(path, maxLines, chunkBytes = 65536) {
|
|
305
|
+
const want = Math.max(0, Number(maxLines) || 0);
|
|
306
|
+
if (want === 0) return [];
|
|
307
|
+
let handle;
|
|
308
|
+
try {
|
|
309
|
+
handle = await open(path, "r");
|
|
310
|
+
} catch {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
const reversed = [];
|
|
314
|
+
try {
|
|
315
|
+
const info = await handle.stat();
|
|
316
|
+
let position = info.size;
|
|
317
|
+
let buffer = "";
|
|
318
|
+
while (position > 0 && reversed.length < want) {
|
|
319
|
+
const start = Math.max(0, position - Math.max(1024, chunkBytes));
|
|
320
|
+
const length = position - start;
|
|
321
|
+
const data = await handle.read(Buffer.alloc(length), 0, length, start);
|
|
322
|
+
buffer = data.buffer.subarray(0, data.bytesRead).toString("utf8") + buffer;
|
|
323
|
+
const parts = buffer.split("\n");
|
|
324
|
+
// 第一段一定是被截断的半行,留给下一轮(或循环结束后处理)。
|
|
325
|
+
buffer = parts.shift() ?? "";
|
|
326
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
327
|
+
const trimmed = parts[index].trim();
|
|
328
|
+
if (trimmed.length > 0) reversed.push(trimmed);
|
|
329
|
+
if (reversed.length >= want) break;
|
|
330
|
+
}
|
|
331
|
+
position = start;
|
|
332
|
+
}
|
|
333
|
+
// 走到文件头时,buffer 里剩下的是最早那一行(文件没有尾换行时会走到这里)。
|
|
334
|
+
if (position === 0 && reversed.length < want && buffer.trim().length > 0) {
|
|
335
|
+
reversed.push(buffer.trim());
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
} finally {
|
|
340
|
+
await handle.close().catch(() => {});
|
|
341
|
+
}
|
|
342
|
+
return reversed.reverse();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 把一批行按字节上限切成若干片。
|
|
347
|
+
*
|
|
348
|
+
* 切分点必须落在**行边界**上:JSONL 一行被劈成两半,解析时会变成两条坏行
|
|
349
|
+
* (与 `StatsStore.schedule` 那个「并发 appendFile 会把行拆成两半」的坑
|
|
350
|
+
* 同一类)。因此这里按「累积字节数」找切点,然后把切点回退到下一个换行。
|
|
351
|
+
*
|
|
352
|
+
* @param lines - 待写的行(每行自带结尾的 `\n`)。
|
|
353
|
+
* @param limit - 每片的最大字节数。
|
|
354
|
+
* @returns 片的数组;入参为空时返回空数组。
|
|
355
|
+
*/
|
|
356
|
+
function splitByBytes(lines, limit) {
|
|
357
|
+
if (!Array.isArray(lines) || lines.length === 0) return [];
|
|
358
|
+
const max = Math.max(1024, Number(limit) || DEFAULT_MAX_BYTES);
|
|
359
|
+
const chunks = [];
|
|
360
|
+
let current = [];
|
|
361
|
+
let size = 0;
|
|
362
|
+
for (const line of lines) {
|
|
363
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
364
|
+
// 单行就超过上限:它自己成一片(没法再切——切了就是坏行)。
|
|
365
|
+
if (size > 0 && size + bytes > max) {
|
|
366
|
+
chunks.push(current.join(""));
|
|
367
|
+
current = [];
|
|
368
|
+
size = 0;
|
|
369
|
+
}
|
|
370
|
+
current.push(line);
|
|
371
|
+
size += bytes;
|
|
372
|
+
}
|
|
373
|
+
if (current.length > 0) chunks.push(current.join(""));
|
|
374
|
+
return chunks;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 取一条记录的稳定唯一标识。
|
|
379
|
+
*
|
|
380
|
+
* 新记录自带 `id`(`<进程前缀>-<seq>`,见 `DecisionStore.record`)。但**已经
|
|
381
|
+
* 落盘的老记录没有这个字段**——它们只有 `seq`,而 `seq` 跨重启会重复。
|
|
382
|
+
* 因此这里用 `ts#seq` 兜底:`ts` 精确到毫秒,两个不同进程要写出完全相同的
|
|
383
|
+
* 时间戳**且**相同的 seq 才会撞,实测用户的 308 条历史数据里 308/308 唯一。
|
|
384
|
+
*
|
|
385
|
+
* 这个函数是「哪些字段能当身份用」的**唯一**定义处:存储层读取时会用它补齐
|
|
386
|
+
* `id`,界面拿 `id` 当 key 与展开标识——两边不能各算一套,否则又会错位。
|
|
387
|
+
*
|
|
388
|
+
* @param entry - 一条记录。
|
|
389
|
+
* @returns 稳定标识;拿不到任何字段时返回空串。
|
|
390
|
+
*/
|
|
391
|
+
export function recordIdOf(entry) {
|
|
392
|
+
if (entry === null || typeof entry !== "object") return "";
|
|
393
|
+
if (typeof entry.id === "string" && entry.id.length > 0) return entry.id;
|
|
394
|
+
const ts = typeof entry.ts === "string" ? entry.ts : "";
|
|
395
|
+
const seq = Number(entry.seq);
|
|
396
|
+
if (ts.length === 0 && !Number.isFinite(seq)) return "";
|
|
397
|
+
return `${ts}#${Number.isFinite(seq) ? seq : 0}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 决策明细存储。
|
|
402
|
+
*
|
|
403
|
+
* 用法:
|
|
404
|
+
*
|
|
405
|
+
* const store = new DecisionStore({ dir, getEnabled, getMaxBytes });
|
|
406
|
+
* store.record(buildDecisionRecord({ … })); // 不 await,异步落盘
|
|
407
|
+
* const page = await store.readForSession(sessionId, { limit: 200 });
|
|
408
|
+
*/
|
|
409
|
+
export class DecisionStore {
|
|
410
|
+
/**
|
|
411
|
+
* @param options.dir - 插件数据目录(`~/.dsh/dsh-decision-engine/`)。
|
|
412
|
+
* @param options.fileName - 文件名,默认 `decisions.jsonl`。
|
|
413
|
+
* @param options.logger - 宿主 logger;写盘失败时用它警告。
|
|
414
|
+
* @param options.getEnabled - 返回「决策历史」开关。**关闭时不写任何东西**:
|
|
415
|
+
* 不建文件、不占磁盘、也不采集内容。用取值函数而不是定值,配置改了
|
|
416
|
+
* 立刻生效(与插件其余配置的处理方式一致)。**这是唯一的开关**:开启时
|
|
417
|
+
* 记录一定带内容,内容只在超过长度上限时才由 `truncateContent()` 退回
|
|
418
|
+
* `capture: 'meta'`。
|
|
419
|
+
* @param options.getMaxBytes - 返回文件体积上限。
|
|
420
|
+
* @param options.getMaxContentChars - 返回单条内容长度上限。
|
|
421
|
+
* @param options.maxQueue - 待写队列上限;超过丢最旧的,防止磁盘挂起时吃内存。
|
|
422
|
+
*/
|
|
423
|
+
constructor(options = {}) {
|
|
424
|
+
this.dir = options.dir;
|
|
425
|
+
this.fileName = options.fileName ?? DECISIONS_FILE_NAME;
|
|
426
|
+
this.logger = options.logger;
|
|
427
|
+
this.getEnabled = typeof options.getEnabled === "function" ? options.getEnabled : () => true;
|
|
428
|
+
this.getMaxBytes =
|
|
429
|
+
typeof options.getMaxBytes === "function" ? options.getMaxBytes : () => DEFAULT_MAX_BYTES;
|
|
430
|
+
this.getMaxContentChars =
|
|
431
|
+
typeof options.getMaxContentChars === "function"
|
|
432
|
+
? options.getMaxContentChars
|
|
433
|
+
: () => DEFAULT_MAX_CONTENT_CHARS;
|
|
434
|
+
this.maxQueue = Number.isFinite(options.maxQueue) ? Math.max(16, options.maxQueue) : 4096;
|
|
435
|
+
/** 待写队列(已脱敏的整行文本)。 */
|
|
436
|
+
this.queue = [];
|
|
437
|
+
/** 正在进行的写盘任务。 */
|
|
438
|
+
this.pending = null;
|
|
439
|
+
/** 进程内单调序号:同一毫秒内多条记录之间也能稳定排序。 */
|
|
440
|
+
this.seq = 0;
|
|
441
|
+
/**
|
|
442
|
+
* 记录 id 的进程前缀,每次构造(= 每次插件加载)随机生成。
|
|
443
|
+
*
|
|
444
|
+
* 它与 `seq` 拼成 `id`,解决「`seq` 跨重启重复」:只靠进程内计数器,
|
|
445
|
+
* DSH 重启后新记录的 seq 会与旧记录撞车(实测 307 条里 89 个重复),
|
|
446
|
+
* 界面上的 React key 也就跟着重复——点第二行会展开第三行。
|
|
447
|
+
*/
|
|
448
|
+
this.idPrefix = randomUUID().slice(0, 8);
|
|
449
|
+
/** 累计写入条数(诊断用)。 */
|
|
450
|
+
this.written = 0;
|
|
451
|
+
/** 累计失败条数(诊断用)。 */
|
|
452
|
+
this.failed = 0;
|
|
453
|
+
/** 队列溢出丢弃的条数(诊断用)。 */
|
|
454
|
+
this.dropped = 0;
|
|
455
|
+
/** 轮转次数(诊断用)。 */
|
|
456
|
+
this.rotated = 0;
|
|
457
|
+
/** 自上次轮转以来已追加的字节数。 */
|
|
458
|
+
this.appendedBytes = 0;
|
|
459
|
+
/** 是否已经确认过文件权限(避免每条记录一次 chmod)。 */
|
|
460
|
+
this.permissionSettled = false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 明细文件的绝对路径。 */
|
|
464
|
+
get path() {
|
|
465
|
+
return join(this.dir, this.fileName);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** 轮转后旧文件的绝对路径。 */
|
|
469
|
+
get rotatePath() {
|
|
470
|
+
return join(this.dir, DECISIONS_ROTATED_NAME);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 开关是否开启。 */
|
|
474
|
+
enabled() {
|
|
475
|
+
try {
|
|
476
|
+
return this.getEnabled() !== false;
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 记一条决策明细。
|
|
484
|
+
*
|
|
485
|
+
* **不返回 Promise**:调用方不该 await 它。写盘在后台串行进行,决策路径
|
|
486
|
+
* 上的额外开销只有一次 `JSON.stringify` 与一次 `push`。
|
|
487
|
+
*
|
|
488
|
+
* @param record - `buildDecisionRecord()` 造出的记录。
|
|
489
|
+
* @returns 是否入队(开关关闭、内容为空、序列化失败都返回 false)。
|
|
490
|
+
*/
|
|
491
|
+
record(record) {
|
|
492
|
+
if (!this.enabled()) return false;
|
|
493
|
+
if (record === null || typeof record !== "object") return false;
|
|
494
|
+
const seq = this.seq + 1;
|
|
495
|
+
/**
|
|
496
|
+
* `id` 是记录的**全局唯一标识**;`seq` 不是。
|
|
497
|
+
*
|
|
498
|
+
* 这是真机上被用户抓到的缺陷(2026-11-03:「点第二个,展开的是第三个」):
|
|
499
|
+
* `seq` 只是**进程内**计数器,DSH 每重启一次就从 1 重新数。实测用户的
|
|
500
|
+
* `decisions.jsonl` 里 307 条记录只有 119 个不同的 `seq`(89 个重复)。
|
|
501
|
+
*
|
|
502
|
+
* 界面拿 `seq` 当 React `key` 与「哪一行展开了」的标识,于是:
|
|
503
|
+
* 1. 重复 key 让 React 把行/事件处理器对到错的 DOM 节点上——
|
|
504
|
+
* 点第二行展开的是第三行;
|
|
505
|
+
* 2. `expanded === seq` 会同时命中多条记录,展开一行冒出好几段详情。
|
|
506
|
+
*
|
|
507
|
+
* 因此在进程内计数之外,再拼一段**每次启动随机生成**的前缀:两者合起来
|
|
508
|
+
* 跨重启唯一。`seq` 原样保留——它是**排序**用的,语义不变。
|
|
509
|
+
*/
|
|
510
|
+
//
|
|
511
|
+
// 内容与 `capture` 一律**原样落盘**:开关开着就是「记录明细且一定带内容」,
|
|
512
|
+
// 这里没有第二个档位可以把它降级成只记元数据(`capture: 'meta'` 只可能由
|
|
513
|
+
// `buildDecisionRecord()` → `truncateContent()` 的超限裁剪产生)。
|
|
514
|
+
const finalRecord = { ...record, seq, id: `${this.idPrefix}-${seq}` };
|
|
515
|
+
let line;
|
|
516
|
+
try {
|
|
517
|
+
// 脱敏放在**序列化之后**:模式表针对的是 JSON 文本形态(`"apiKey": "…"`),
|
|
518
|
+
// 对结构化对象做替换既难写又容易漏。
|
|
519
|
+
line = maskSecrets(JSON.stringify(finalRecord));
|
|
520
|
+
} catch {
|
|
521
|
+
// 循环引用之类的问题不该把调用方带崩——明细是旁路。
|
|
522
|
+
this.failed += 1;
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
this.seq += 1;
|
|
526
|
+
if (this.queue.length >= this.maxQueue) {
|
|
527
|
+
this.queue.shift();
|
|
528
|
+
this.dropped += 1;
|
|
529
|
+
}
|
|
530
|
+
this.queue.push(`${line}\n`);
|
|
531
|
+
this.schedule();
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* 安排一次落盘。
|
|
537
|
+
*
|
|
538
|
+
* 串行保证顺序:写入中的那一次没结束就不再发起新的写,否则两个
|
|
539
|
+
* `appendFile` 并发会让同一行被拆成两半——JSONL 一行拆开后,解析时
|
|
540
|
+
* 会被当成两条坏行丢掉(与 `StatsStore.schedule` 同一个理由)。
|
|
541
|
+
*/
|
|
542
|
+
schedule() {
|
|
543
|
+
if (this.pending !== null) return;
|
|
544
|
+
this.pending = this.drain().finally(() => {
|
|
545
|
+
this.pending = null;
|
|
546
|
+
if (this.queue.length > 0) this.schedule();
|
|
547
|
+
});
|
|
548
|
+
// drain 里可能先轮转再写;错误已在内部吞掉,这里只防未处理的 rejection。
|
|
549
|
+
this.pending.catch(() => {});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** 把队列里的条目写进文件;到体积上限时先轮转。 */
|
|
553
|
+
async drain() {
|
|
554
|
+
await Promise.resolve();
|
|
555
|
+
while (this.queue.length > 0) {
|
|
556
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
557
|
+
const payload = batch.join("");
|
|
558
|
+
try {
|
|
559
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
560
|
+
await this.ensureSizeBaseline();
|
|
561
|
+
const limit = this.maxBytes();
|
|
562
|
+
/**
|
|
563
|
+
* 一批可能很大(调用方连续记了 200 条 → 一批 60 KB),**整批写会
|
|
564
|
+
* 直接冲过上限**:轮转判断发生在写之前,而写之前文件还是空的,
|
|
565
|
+
* 于是「追加字节数 + 这一批 > 上限」永远不成立,上限形同虚设。
|
|
566
|
+
*
|
|
567
|
+
* 因此把这一批按上限切成若干片,逐片写。片与片之间才可能触发
|
|
568
|
+
* 轮转,于是「文件有多大」真正受上限约束。
|
|
569
|
+
*/
|
|
570
|
+
for (const chunk of splitByBytes(batch, limit)) {
|
|
571
|
+
// 写之前:文件已接近上限就先轮转,让新内容从一个空文件开始。
|
|
572
|
+
// `appendedBytes > 0` 是「文件还空着就不轮转」——对着一个不存在的
|
|
573
|
+
// 文件 rename 会失败,那只是噪音。
|
|
574
|
+
if (this.appendedBytes > 0 && this.appendedBytes + chunk.length > limit) {
|
|
575
|
+
await this.rotate();
|
|
576
|
+
}
|
|
577
|
+
const handle = await open(this.path, "a");
|
|
578
|
+
try {
|
|
579
|
+
await handle.write(chunk);
|
|
580
|
+
} finally {
|
|
581
|
+
await handle.close().catch(() => {});
|
|
582
|
+
}
|
|
583
|
+
this.appendedBytes += chunk.length;
|
|
584
|
+
}
|
|
585
|
+
this.written += batch.length;
|
|
586
|
+
if (!this.permissionSettled) {
|
|
587
|
+
this.permissionSettled = true;
|
|
588
|
+
// 明细里可能有会话正文与命令片段,权限与 config.yml 一致保持 600。
|
|
589
|
+
await chmod(this.path, 0o600).catch(() => {});
|
|
590
|
+
}
|
|
591
|
+
} catch (error) {
|
|
592
|
+
// 写盘失败:丢这批,记一次警告。**不重试**——重试会堆积,
|
|
593
|
+
// 而明细失败绝不该拖住决策。
|
|
594
|
+
this.failed += batch.length;
|
|
595
|
+
this.logger?.warn?.(
|
|
596
|
+
`dsh-decision-engine: 写入决策明细失败(${error instanceof Error ? error.message : String(error)})`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** 体积上限(现读配置)。 */
|
|
603
|
+
maxBytes() {
|
|
604
|
+
try {
|
|
605
|
+
const value = Number(this.getMaxBytes());
|
|
606
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_MAX_BYTES;
|
|
607
|
+
} catch {
|
|
608
|
+
return DEFAULT_MAX_BYTES;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 首次写盘前用真实文件大小初始化「已追加字节」。
|
|
614
|
+
*
|
|
615
|
+
* 为什么需要:进程刚启动时 `appendedBytes` 是 0,而磁盘上的文件可能已经
|
|
616
|
+
* 有几十 MB(升级前累积的)。不校准的话要再写满一整个上限才轮转。
|
|
617
|
+
*/
|
|
618
|
+
async ensureSizeBaseline() {
|
|
619
|
+
if (this.permissionSettled) return;
|
|
620
|
+
try {
|
|
621
|
+
const info = await stat(this.path);
|
|
622
|
+
this.appendedBytes = info.size;
|
|
623
|
+
} catch {
|
|
624
|
+
this.appendedBytes = 0;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* 把当前文件挪成 `.1`,覆盖上一代。
|
|
630
|
+
*
|
|
631
|
+
* 只留一代而不是多代:这个页面的用途是「看会话里刚才发生了什么」,
|
|
632
|
+
* 不是归档。多代备份会让磁盘占用变成上限的整数倍,而收益近似为零。
|
|
633
|
+
*/
|
|
634
|
+
async rotate() {
|
|
635
|
+
try {
|
|
636
|
+
await unlink(this.rotatePath).catch(() => {});
|
|
637
|
+
await rename(this.path, this.rotatePath);
|
|
638
|
+
this.rotated += 1;
|
|
639
|
+
} catch (error) {
|
|
640
|
+
this.logger?.warn?.(
|
|
641
|
+
`dsh-decision-engine: 轮转决策明细失败(${error instanceof Error ? error.message : String(error)})`,
|
|
642
|
+
);
|
|
643
|
+
} finally {
|
|
644
|
+
// 无论成功与否都把计数清零:失败时继续往老文件写也不会无限跑偏。
|
|
645
|
+
this.appendedBytes = 0;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 读某个会话的决策明细(时间升序)。
|
|
651
|
+
*
|
|
652
|
+
* 先读当前文件尾部;不够再读轮转过的 `.1`(更早的那些)。**永远整读**:
|
|
653
|
+
* 扫描行数有上限 `DEFAULT_SCAN_LINES`,返回条数有上限 `MAX_ENTRIES_PER_QUERY`。
|
|
654
|
+
*
|
|
655
|
+
* @param sessionId - 会话 id;空串直接返回空页(不可归属的记录不属于任何人)。
|
|
656
|
+
* @param options.limit - 返回多少条(默认 200,上限 500)。
|
|
657
|
+
* @param options.scanLines - 最多倒着扫多少行(默认 4000)。
|
|
658
|
+
* @returns `{ entries, total, scanned, truncated, file }`。
|
|
659
|
+
* `total` 是扫描窗口内**该会话**的记录数,可能大于 `entries.length`。
|
|
660
|
+
*/
|
|
661
|
+
async readForSession(sessionId, options = {}) {
|
|
662
|
+
const id = typeof sessionId === "string" ? sessionId.trim() : "";
|
|
663
|
+
const limitRequested = Number(options.limit);
|
|
664
|
+
const limit = Number.isFinite(limitRequested) && limitRequested > 0
|
|
665
|
+
? Math.min(MAX_ENTRIES_PER_QUERY, Math.floor(limitRequested))
|
|
666
|
+
: 200;
|
|
667
|
+
const scanLines = Number.isFinite(Number(options.scanLines))
|
|
668
|
+
? Math.max(limit, Math.floor(Number(options.scanLines)))
|
|
669
|
+
: DEFAULT_SCAN_LINES;
|
|
670
|
+
const empty = {
|
|
671
|
+
entries: [],
|
|
672
|
+
total: 0,
|
|
673
|
+
scanned: 0,
|
|
674
|
+
truncated: false,
|
|
675
|
+
file: this.path,
|
|
676
|
+
enabled: this.enabled(),
|
|
677
|
+
};
|
|
678
|
+
if (id.length === 0) return empty;
|
|
679
|
+
|
|
680
|
+
const current = await readTailLines(this.path, scanLines);
|
|
681
|
+
let lines = current;
|
|
682
|
+
let scannedHitCap = current.length >= scanLines;
|
|
683
|
+
if (current.length < scanLines) {
|
|
684
|
+
const older = await readTailLines(this.rotatePath, scanLines - current.length);
|
|
685
|
+
lines = [...older, ...current];
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 预筛:先用子串判断这行**有没有可能**属于目标会话,命中了才 `JSON.parse`。
|
|
690
|
+
*
|
|
691
|
+
* 决策历史按会话读时,通常只有百分之几的行属于该会话;过去每行都做一次
|
|
692
|
+
* 完整 JSON.parse 再丢掉,白花 90% 以上的解析开销。
|
|
693
|
+
*
|
|
694
|
+
* 为什么这是安全的(不是启发式):`JSON.stringify` 只会把会话 id 里的
|
|
695
|
+
* 引号、反斜杠与控制字符转义,其余字符原样保留。因此只要 id 本身不含
|
|
696
|
+
* 这些需要转义的字符,磁盘上那一行的 `"sessionId":"<id>"` 片段必然
|
|
697
|
+
* **逐字**包含 id;反过来,包含 id 子串的行仍要经过下面的严格比较,
|
|
698
|
+
* 预筛只可能漏掉不可能匹配的行,不会放过不匹配的行。
|
|
699
|
+
*
|
|
700
|
+
* id 含转义字符时(极罕见)直接退化成「全部解析」,与旧行为一致。
|
|
701
|
+
*/
|
|
702
|
+
const canPrefilter = !/[\"\\\u0000-\u001f]/.test(id);
|
|
703
|
+
const needle = canPrefilter ? `"sessionId":"${id}"` : "";
|
|
704
|
+
const matched = [];
|
|
705
|
+
for (const line of lines) {
|
|
706
|
+
if (canPrefilter && !line.includes(needle)) continue;
|
|
707
|
+
let entry;
|
|
708
|
+
try {
|
|
709
|
+
entry = JSON.parse(line);
|
|
710
|
+
} catch {
|
|
711
|
+
// 坏行跳过(与 StatsStore.read 同口径),不影响其余记录。
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
715
|
+
if (typeof entry.sessionId !== "string" || entry.sessionId !== id) continue;
|
|
716
|
+
matched.push(entry);
|
|
717
|
+
}
|
|
718
|
+
// 按**时间**排序,不按 `seq`。
|
|
719
|
+
//
|
|
720
|
+
// 这里曾经按 seq 排,理由是「崩溃重启后文件顺序可能错位」——那个理由
|
|
721
|
+
// 站不住:文件是**追加**写的,append 顺序本身就是发生顺序(轮转也只是把
|
|
722
|
+
// 旧文件拼在前面)。而 `seq` 每重启一次就从 1 重数(实测 307 条里只有
|
|
723
|
+
// 119 个不同的 seq),按它排序会把不同批次的记录交叉排出来,**时间顺序
|
|
724
|
+
// 整个乱掉**——那正是「决策历史」这个页面唯一的排序依据。
|
|
725
|
+
//
|
|
726
|
+
// 现在按 ts 升序;ts 相同时由 `Array.prototype.sort` 的稳定性保持文件里
|
|
727
|
+
// 的先后,那也正是真实发生顺序。
|
|
728
|
+
matched.sort((left, right) => String(left.ts ?? "").localeCompare(String(right.ts ?? "")));
|
|
729
|
+
|
|
730
|
+
const entries = matched.slice(-limit);
|
|
731
|
+
/**
|
|
732
|
+
* 补齐 `id` 并保证**本次响应内唯一**。
|
|
733
|
+
*
|
|
734
|
+
* 老记录没有 `id` 字段(本次修复之前写的),用 `ts#seq` 兜底;万一连这个
|
|
735
|
+
* 也撞(同一毫秒 + 同一 seq),再加一个位置后缀。界面拿 `id` 当 React
|
|
736
|
+
* `key` 与展开标识,**重复 key 会让点击落到错的行上**,所以这一层不能
|
|
737
|
+
* 留给界面去赌。
|
|
738
|
+
*/
|
|
739
|
+
const seen = new Set();
|
|
740
|
+
for (const entry of entries) {
|
|
741
|
+
const base = recordIdOf(entry);
|
|
742
|
+
let unique = base;
|
|
743
|
+
let attempt = 1;
|
|
744
|
+
while (seen.has(unique)) {
|
|
745
|
+
unique = `${base}~${attempt}`;
|
|
746
|
+
attempt += 1;
|
|
747
|
+
}
|
|
748
|
+
seen.add(unique);
|
|
749
|
+
entry.id = unique;
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
entries,
|
|
753
|
+
total: matched.length,
|
|
754
|
+
scanned: lines.length,
|
|
755
|
+
truncated: matched.length > limit || scannedHitCap,
|
|
756
|
+
file: this.path,
|
|
757
|
+
enabled: this.enabled(),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* 等待队列里的内容全部落盘。
|
|
763
|
+
*
|
|
764
|
+
* 只在**关停**与测试里用;决策路径上绝不能 await 它。
|
|
765
|
+
*
|
|
766
|
+
* @returns 落盘后的条数。
|
|
767
|
+
*/
|
|
768
|
+
async flush() {
|
|
769
|
+
while (this.pending !== null) {
|
|
770
|
+
await this.pending.catch(() => {});
|
|
771
|
+
}
|
|
772
|
+
return this.written;
|
|
773
|
+
}
|
|
774
|
+
}
|