@spzhongwin/skill-logger-plugin 1.0.11 → 1.0.13

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.
Files changed (54) hide show
  1. package/dist/active-skills.js +67 -0
  2. package/dist/active-skills.test.js +29 -0
  3. package/dist/config-sync.js +439 -0
  4. package/dist/config-sync.test.js +145 -0
  5. package/dist/hooks.js +337 -0
  6. package/dist/hooks.test.js +123 -0
  7. package/dist/http.js +54 -0
  8. package/dist/identity.js +56 -0
  9. package/dist/index.js +240 -78
  10. package/dist/index.test.js +39 -0
  11. package/dist/integration.test.js +102 -0
  12. package/dist/matcher.js +362 -0
  13. package/dist/matcher.test.js +139 -0
  14. package/dist/paths.js +62 -0
  15. package/dist/paths.test.js +49 -0
  16. package/dist/reporter.js +267 -0
  17. package/dist/reporter.test.js +128 -0
  18. package/dist/semver.js +64 -0
  19. package/dist/semver.test.js +21 -0
  20. package/dist/skill-version.js +23 -0
  21. package/dist/types.js +9 -0
  22. package/dist/updater.js +352 -0
  23. package/dist/updater.test.js +212 -0
  24. package/dist/ws-client.js +484 -0
  25. package/openclaw.plugin.json +50 -50
  26. package/package.json +37 -37
  27. package/src/active-skills.test.ts +32 -32
  28. package/src/active-skills.ts +77 -77
  29. package/src/config-sync.test.ts +165 -165
  30. package/src/config-sync.ts +544 -544
  31. package/src/hooks.test.ts +251 -251
  32. package/src/hooks.ts +517 -517
  33. package/src/http.ts +61 -61
  34. package/src/identity.ts +64 -64
  35. package/src/index.test.ts +53 -53
  36. package/src/index.ts +226 -226
  37. package/src/integration.test.ts +119 -119
  38. package/src/matcher.test.ts +170 -170
  39. package/src/matcher.ts +393 -393
  40. package/src/paths.test.ts +57 -57
  41. package/src/paths.ts +84 -84
  42. package/src/reporter.test.ts +139 -139
  43. package/src/reporter.ts +298 -298
  44. package/src/sample-config.json +72 -72
  45. package/src/semver.test.ts +23 -23
  46. package/src/semver.ts +60 -60
  47. package/src/skill-version.ts +53 -53
  48. package/src/types.ts +198 -198
  49. package/src/updater.test.ts +325 -237
  50. package/src/updater.ts +549 -433
  51. package/src/ws-client.test.ts +48 -37
  52. package/src/ws-client.ts +717 -642
  53. package/test-ws.ts +17 -17
  54. package/tsconfig.json +14 -14
package/src/reporter.ts CHANGED
@@ -1,298 +1,298 @@
1
- /**
2
- * 本地落盘 + 日志轮转 (Log Rotation) + 每 3 分钟批量上报。
3
- *
4
- * 设计原则:
5
- * - events.jsonl 是活跃的写入日志,通过 appendFile 追加。
6
- * - 上报时,将其 rename 轮转为带有时间戳的文件,隔离写和读,根绝并发读写丢失数据的竞态条件。
7
- * - 后台处理所有的轮转文件并上报,上报成功后通过 fs.unlink 清理文件。
8
- * - 失败则保留待下次重试(因为底层 DB 依赖 INSERT IGNORE 处理重复 event_id,所以即使重试时存在部分重复上报也是安全的)。
9
- * - 所有异常吞掉,绝不阻塞 agent / gateway。
10
- */
11
- import fs from "node:fs/promises";
12
- import path from "node:path";
13
- import type { PluginConfig, SkillEvent, UserInfoSnapshot } from "./types.ts";
14
- import type { PluginPaths } from "./paths.ts";
15
- import { GitIdentityProvider, type IdentityProvider } from "./identity.ts";
16
- import { defaultFetch } from "./http.ts";
17
-
18
- /** 注入 fetch 便于测试;默认用全局 fetch。 */
19
- type FetchLike = (url: string, init: RequestInit) => Promise<{ ok: boolean; status: number; json?: () => Promise<unknown> }>;
20
-
21
- const FLUSH_INTERVAL_MS = 3 * 60 * 1000;
22
- const BATCH_SIZE = 500;
23
-
24
- export type ReporterOptions = {
25
- paths: Pick<PluginPaths, "eventsLogPath">;
26
- /** 读取最新插件配置(上报地址/鉴权可能在 gateway_start 后才注入)。 */
27
- getConfig: () => PluginConfig;
28
- identityProvider?: IdentityProvider;
29
- fetchImpl?: FetchLike;
30
- now?: () => number;
31
- };
32
-
33
- export class Reporter {
34
- private readonly paths: Pick<PluginPaths, "eventsLogPath">;
35
- private readonly getConfig: () => PluginConfig;
36
- private readonly identityProvider: IdentityProvider;
37
- private readonly fetchImpl: FetchLike;
38
- private timer: ReturnType<typeof setInterval> | undefined;
39
- /** 防止多次 flush 重入。 */
40
- private flushing = false;
41
-
42
- constructor(opts: ReporterOptions) {
43
- this.paths = opts.paths;
44
- this.getConfig = opts.getConfig;
45
- this.identityProvider = opts.identityProvider ?? new GitIdentityProvider();
46
- this.fetchImpl = opts.fetchImpl ?? defaultFetch();
47
- }
48
-
49
- private get isDebug(): boolean {
50
- return this.getConfig().debugLogging !== false;
51
- }
52
-
53
- private async writeFallbackLog(level: "INFO" | "WARN" | "ERROR", ...args: any[]): Promise<void> {
54
- try {
55
- const msg = args.map(a => (a instanceof Error) ? (a.stack || a.toString()) : (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ");
56
- const ts = new Date().toISOString();
57
- const logLine = `[${ts}] [${level}] [skill-logger-plugin] ${msg}\n`;
58
-
59
- const logDir = path.dirname(this.paths.eventsLogPath);
60
- await fs.mkdir(logDir, { recursive: true });
61
- await fs.appendFile(path.join(logDir, "skill-logger.err.log"), logLine);
62
-
63
- if (level === "INFO" && this.isDebug) {
64
- console.log("[skill-logger-plugin/reporter]", ...args);
65
- } else if (level === "WARN" || level === "ERROR") {
66
- console.warn("[skill-logger-plugin]", ...args);
67
- }
68
- } catch {
69
- // ignore
70
- }
71
- }
72
-
73
- private debug(...args: any[]): void {
74
- if (this.isDebug) {
75
- void this.writeFallbackLog("INFO", ...args);
76
- }
77
- }
78
-
79
- /** 追加一行事件到 events.jsonl(目录不存在自动建)。失败不抛。 */
80
- async appendEvent(event: SkillEvent): Promise<void> {
81
- try {
82
- // 防御:截断会导致 MySQL Data Truncation 宕机的超长字段
83
- if (typeof event.error_message === "string" && event.error_message.length > 15000) {
84
- event.error_message = event.error_message.substring(0, 15000) + "...(truncated)";
85
- }
86
- if (typeof event.command === "string" && event.command.length > 15000) {
87
- event.command = event.command.substring(0, 15000) + "...(truncated)";
88
- }
89
-
90
- let line = "";
91
- try {
92
- line = JSON.stringify(event);
93
- // 防御:防止整个 JSON 过大(如带有 base64 图片的 args)导致 Express 413 Payload Too Large
94
- if (line.length > 100000) {
95
- const safeEvent = { ...event, args: { _warning: "args omitted due to excessive size" } };
96
- line = JSON.stringify(safeEvent);
97
- }
98
- } catch {
99
- return; // JSON 序列化失败直接丢弃
100
- }
101
-
102
- await fs.mkdir(path.dirname(this.paths.eventsLogPath), { recursive: true });
103
- await fs.appendFile(this.paths.eventsLogPath, line + "\n");
104
- } catch (err) {
105
- void this.writeFallbackLog("ERROR", "写事件日志失败", this.paths.eventsLogPath, err);
106
- }
107
- }
108
-
109
- /** 启动 3 分钟定时上报。 */
110
- startTimer(): void {
111
- if (this.timer) return;
112
- this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
113
- // 不阻止进程退出
114
- if (typeof this.timer.unref === "function") this.timer.unref();
115
- }
116
-
117
- /** 停止定时器,并尽力做最后一次 flush。 */
118
- async stopTimer(): Promise<void> {
119
- if (this.timer) {
120
- clearInterval(this.timer);
121
- this.timer = undefined;
122
- }
123
- await this.flush();
124
- }
125
-
126
- private linesOf(content: string): string[] {
127
- return content.split("\n").filter((l) => l.trim().length > 0);
128
- }
129
-
130
- private async resolveUserInfo(appKey: string, config: PluginConfig): Promise<UserInfoSnapshot> {
131
- if (!config.reportBaseUrl) return {};
132
-
133
- try {
134
- const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_user/resolve";
135
- const headers: Record<string, string> = { "Content-Type": "application/json" };
136
- if (config.authToken) headers.Authorization = config.authToken;
137
-
138
- const res = await this.fetchImpl(url, {
139
- method: "POST",
140
- headers,
141
- body: JSON.stringify({ appKey }),
142
- });
143
-
144
- if (!res.ok) {
145
- return { error_message: `resolve user info failed: HTTP ${res.status}` };
146
- }
147
-
148
- const data = typeof (res as any).json === "function" ? await (res as any).json() : {};
149
- return (data.user_info || data.userInfo || data.data || {}) as UserInfoSnapshot;
150
- } catch (err) {
151
- return { error_message: `resolve user info failed: ${err instanceof Error ? err.message : String(err)}` };
152
- }
153
- }
154
-
155
- private async attachUserInfo(events: SkillEvent[], config: PluginConfig): Promise<SkillEvent[]> {
156
- const appKeys = [...new Set(events.map((event) => event.app_key || "").filter(Boolean))];
157
- if (appKeys.length === 0) return events;
158
-
159
- const byAppKey = new Map<string, UserInfoSnapshot>();
160
- await Promise.all(appKeys.map(async (appKey) => {
161
- byAppKey.set(appKey, await this.resolveUserInfo(appKey, config));
162
- }));
163
-
164
- return events.map((event) => {
165
- if (!event.app_key) return event;
166
- const userInfo = byAppKey.get(event.app_key);
167
- if (!userInfo || Object.keys(userInfo).length === 0) return event;
168
- return { ...event, user_info: userInfo };
169
- });
170
- }
171
-
172
- /**
173
- * 读本地队列并分批 POST;每批成功后清理对应本地文件。
174
- * 采用日志轮转(Rename)规避读写竞态条件。
175
- * 未配置 reportBaseUrl → 直接返回(只落本地,不上报)。
176
- */
177
- async flush(): Promise<void> {
178
- if (this.flushing) return;
179
- this.flushing = true;
180
- try {
181
- const config = this.getConfig();
182
- if (!config.reportBaseUrl) {
183
- void this.writeFallbackLog("WARN", "flush skipped: reportBaseUrl 没有配置!请检查 openclaw.json 插件配置是否正确加载。");
184
- return;
185
- }
186
-
187
- const logDir = path.dirname(this.paths.eventsLogPath);
188
-
189
- // 1. 重命名当前的活跃日志文件为时间戳格式(原子操作,解决读写冲突)
190
- try {
191
- await fs.access(this.paths.eventsLogPath);
192
- const timestamp = Date.now();
193
- const rotatedPath = path.join(logDir, `events.${timestamp}.jsonl`);
194
- await fs.rename(this.paths.eventsLogPath, rotatedPath);
195
- this.debug(`Rotated active log to ${path.basename(rotatedPath)}`);
196
- } catch {
197
- // 文件不存在,跳过重命名
198
- }
199
-
200
- // 2. 扫描所有轮转的日志文件
201
- let files: string[] = [];
202
- try {
203
- const dirEntries = await fs.readdir(logDir);
204
- files = dirEntries
205
- .filter(f => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl")
206
- .map(f => path.join(logDir, f));
207
- } catch {
208
- return; // 目录不存在直接返回
209
- }
210
-
211
- if (files.length === 0) {
212
- this.debug("No rotated log files found. flush finished.");
213
- return;
214
- }
215
-
216
- this.debug(`Found ${files.length} rotated log files to process.`);
217
- const identity = await this.identityProvider.getIdentity();
218
- const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_report/batch";
219
-
220
- // 3. 逐个处理文件上报
221
- for (const filePath of files) {
222
- try {
223
- const content = await fs.readFile(filePath, "utf-8");
224
- const lines = this.linesOf(content);
225
- if (lines.length === 0) {
226
- await fs.unlink(filePath); // 空文件直接删除
227
- continue;
228
- }
229
-
230
- let cursor = 0;
231
- let allSuccess = true;
232
-
233
- while (cursor < lines.length) {
234
- const slice = lines.slice(cursor, cursor + BATCH_SIZE);
235
- const events: SkillEvent[] = [];
236
- for (const line of slice) {
237
- try {
238
- events.push(JSON.parse(line));
239
- } catch {
240
- // 跳过坏行
241
- }
242
- }
243
-
244
- if (events.length === 0) {
245
- cursor += slice.length;
246
- continue;
247
- }
248
-
249
- const eventsWithUserInfo = await this.attachUserInfo(events, config);
250
- const body = JSON.stringify({
251
- identity,
252
- ide: "openclaw",
253
- marketplace: "openclaw",
254
- events: eventsWithUserInfo,
255
- });
256
- const headers: Record<string, string> = { "Content-Type": "application/json" };
257
- if (config.authToken) headers.Authorization = config.authToken;
258
-
259
- const res = await this.fetchImpl(url, { method: "POST", headers, body });
260
- if (!res.ok) {
261
- const errBody = await res.text().catch(() => "无法读取响应体");
262
- await this.writeFallbackLog("ERROR", `批量上报失败 (文件 ${path.basename(filePath)}), HTTP`, res.status, "服务端返回信息:", errBody);
263
- // 防御死循环:如果是由于报文过大(413)或格式错误(400)等业务级拒绝,放弃该批次,不要死锁整个本地队列
264
- if (res.status === 400 || res.status === 413 || res.status === 422) {
265
- await this.writeFallbackLog("WARN", `报文被服务器永久拒绝,丢弃该批次以释放队列`);
266
- cursor += slice.length;
267
- continue;
268
- }
269
- allSuccess = false;
270
- break; // 其他网络或 500 错误:本文件终止处理,留待下轮重试
271
- }
272
-
273
- this.debug(`Successfully reported batch of ${events.length} events from ${path.basename(filePath)}`);
274
- cursor += slice.length;
275
- }
276
-
277
- // 如果该文件所有的 batch 都上报成功了,将其物理删除
278
- if (allSuccess) {
279
- await fs.unlink(filePath);
280
- this.debug(`Deleted fully processed file: ${path.basename(filePath)}`);
281
- } else {
282
- // 如果某一批次失败,文件保留。下轮 flush 会把前面成功批次的事件再报一次。
283
- // 但因为 DB 的 skill_logger_events 表 event_id 是唯一键且使用了 INSERT IGNORE,所以数据去重是绝对安全的。
284
- this.debug(`File ${path.basename(filePath)} partially failed. Keeping it for next flush.`);
285
- }
286
-
287
- } catch (err) {
288
- await this.writeFallbackLog("ERROR", `处理文件 ${path.basename(filePath)} 异常:`, err);
289
- }
290
- }
291
-
292
- } catch (err) {
293
- await this.writeFallbackLog("ERROR", "flush 整体异常", err);
294
- } finally {
295
- this.flushing = false;
296
- }
297
- }
298
- }
1
+ /**
2
+ * 本地落盘 + 日志轮转 (Log Rotation) + 每 3 分钟批量上报。
3
+ *
4
+ * 设计原则:
5
+ * - events.jsonl 是活跃的写入日志,通过 appendFile 追加。
6
+ * - 上报时,将其 rename 轮转为带有时间戳的文件,隔离写和读,根绝并发读写丢失数据的竞态条件。
7
+ * - 后台处理所有的轮转文件并上报,上报成功后通过 fs.unlink 清理文件。
8
+ * - 失败则保留待下次重试(因为底层 DB 依赖 INSERT IGNORE 处理重复 event_id,所以即使重试时存在部分重复上报也是安全的)。
9
+ * - 所有异常吞掉,绝不阻塞 agent / gateway。
10
+ */
11
+ import fs from "node:fs/promises";
12
+ import path from "node:path";
13
+ import type { PluginConfig, SkillEvent, UserInfoSnapshot } from "./types.ts";
14
+ import type { PluginPaths } from "./paths.ts";
15
+ import { GitIdentityProvider, type IdentityProvider } from "./identity.ts";
16
+ import { defaultFetch } from "./http.ts";
17
+
18
+ /** 注入 fetch 便于测试;默认用全局 fetch。 */
19
+ type FetchLike = (url: string, init: RequestInit) => Promise<{ ok: boolean; status: number; json?: () => Promise<unknown> }>;
20
+
21
+ const FLUSH_INTERVAL_MS = 3 * 60 * 1000;
22
+ const BATCH_SIZE = 500;
23
+
24
+ export type ReporterOptions = {
25
+ paths: Pick<PluginPaths, "eventsLogPath">;
26
+ /** 读取最新插件配置(上报地址/鉴权可能在 gateway_start 后才注入)。 */
27
+ getConfig: () => PluginConfig;
28
+ identityProvider?: IdentityProvider;
29
+ fetchImpl?: FetchLike;
30
+ now?: () => number;
31
+ };
32
+
33
+ export class Reporter {
34
+ private readonly paths: Pick<PluginPaths, "eventsLogPath">;
35
+ private readonly getConfig: () => PluginConfig;
36
+ private readonly identityProvider: IdentityProvider;
37
+ private readonly fetchImpl: FetchLike;
38
+ private timer: ReturnType<typeof setInterval> | undefined;
39
+ /** 防止多次 flush 重入。 */
40
+ private flushing = false;
41
+
42
+ constructor(opts: ReporterOptions) {
43
+ this.paths = opts.paths;
44
+ this.getConfig = opts.getConfig;
45
+ this.identityProvider = opts.identityProvider ?? new GitIdentityProvider();
46
+ this.fetchImpl = opts.fetchImpl ?? defaultFetch();
47
+ }
48
+
49
+ private get isDebug(): boolean {
50
+ return this.getConfig().debugLogging !== false;
51
+ }
52
+
53
+ private async writeFallbackLog(level: "INFO" | "WARN" | "ERROR", ...args: any[]): Promise<void> {
54
+ try {
55
+ const msg = args.map(a => (a instanceof Error) ? (a.stack || a.toString()) : (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ");
56
+ const ts = new Date().toISOString();
57
+ const logLine = `[${ts}] [${level}] [skill-logger-plugin] ${msg}\n`;
58
+
59
+ const logDir = path.dirname(this.paths.eventsLogPath);
60
+ await fs.mkdir(logDir, { recursive: true });
61
+ await fs.appendFile(path.join(logDir, "skill-logger.err.log"), logLine);
62
+
63
+ if (level === "INFO" && this.isDebug) {
64
+ console.log("[skill-logger-plugin/reporter]", ...args);
65
+ } else if (level === "WARN" || level === "ERROR") {
66
+ console.warn("[skill-logger-plugin]", ...args);
67
+ }
68
+ } catch {
69
+ // ignore
70
+ }
71
+ }
72
+
73
+ private debug(...args: any[]): void {
74
+ if (this.isDebug) {
75
+ void this.writeFallbackLog("INFO", ...args);
76
+ }
77
+ }
78
+
79
+ /** 追加一行事件到 events.jsonl(目录不存在自动建)。失败不抛。 */
80
+ async appendEvent(event: SkillEvent): Promise<void> {
81
+ try {
82
+ // 防御:截断会导致 MySQL Data Truncation 宕机的超长字段
83
+ if (typeof event.error_message === "string" && event.error_message.length > 15000) {
84
+ event.error_message = event.error_message.substring(0, 15000) + "...(truncated)";
85
+ }
86
+ if (typeof event.command === "string" && event.command.length > 15000) {
87
+ event.command = event.command.substring(0, 15000) + "...(truncated)";
88
+ }
89
+
90
+ let line = "";
91
+ try {
92
+ line = JSON.stringify(event);
93
+ // 防御:防止整个 JSON 过大(如带有 base64 图片的 args)导致 Express 413 Payload Too Large
94
+ if (line.length > 100000) {
95
+ const safeEvent = { ...event, args: { _warning: "args omitted due to excessive size" } };
96
+ line = JSON.stringify(safeEvent);
97
+ }
98
+ } catch {
99
+ return; // JSON 序列化失败直接丢弃
100
+ }
101
+
102
+ await fs.mkdir(path.dirname(this.paths.eventsLogPath), { recursive: true });
103
+ await fs.appendFile(this.paths.eventsLogPath, line + "\n");
104
+ } catch (err) {
105
+ void this.writeFallbackLog("ERROR", "写事件日志失败", this.paths.eventsLogPath, err);
106
+ }
107
+ }
108
+
109
+ /** 启动 3 分钟定时上报。 */
110
+ startTimer(): void {
111
+ if (this.timer) return;
112
+ this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
113
+ // 不阻止进程退出
114
+ if (typeof this.timer.unref === "function") this.timer.unref();
115
+ }
116
+
117
+ /** 停止定时器,并尽力做最后一次 flush。 */
118
+ async stopTimer(): Promise<void> {
119
+ if (this.timer) {
120
+ clearInterval(this.timer);
121
+ this.timer = undefined;
122
+ }
123
+ await this.flush();
124
+ }
125
+
126
+ private linesOf(content: string): string[] {
127
+ return content.split("\n").filter((l) => l.trim().length > 0);
128
+ }
129
+
130
+ private async resolveUserInfo(appKey: string, config: PluginConfig): Promise<UserInfoSnapshot> {
131
+ if (!config.reportBaseUrl) return {};
132
+
133
+ try {
134
+ const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_user/resolve";
135
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
136
+ if (config.authToken) headers.Authorization = config.authToken;
137
+
138
+ const res = await this.fetchImpl(url, {
139
+ method: "POST",
140
+ headers,
141
+ body: JSON.stringify({ appKey }),
142
+ });
143
+
144
+ if (!res.ok) {
145
+ return { error_message: `resolve user info failed: HTTP ${res.status}` };
146
+ }
147
+
148
+ const data = typeof (res as any).json === "function" ? await (res as any).json() : {};
149
+ return (data.user_info || data.userInfo || data.data || {}) as UserInfoSnapshot;
150
+ } catch (err) {
151
+ return { error_message: `resolve user info failed: ${err instanceof Error ? err.message : String(err)}` };
152
+ }
153
+ }
154
+
155
+ private async attachUserInfo(events: SkillEvent[], config: PluginConfig): Promise<SkillEvent[]> {
156
+ const appKeys = [...new Set(events.map((event) => event.app_key || "").filter(Boolean))];
157
+ if (appKeys.length === 0) return events;
158
+
159
+ const byAppKey = new Map<string, UserInfoSnapshot>();
160
+ await Promise.all(appKeys.map(async (appKey) => {
161
+ byAppKey.set(appKey, await this.resolveUserInfo(appKey, config));
162
+ }));
163
+
164
+ return events.map((event) => {
165
+ if (!event.app_key) return event;
166
+ const userInfo = byAppKey.get(event.app_key);
167
+ if (!userInfo || Object.keys(userInfo).length === 0) return event;
168
+ return { ...event, user_info: userInfo };
169
+ });
170
+ }
171
+
172
+ /**
173
+ * 读本地队列并分批 POST;每批成功后清理对应本地文件。
174
+ * 采用日志轮转(Rename)规避读写竞态条件。
175
+ * 未配置 reportBaseUrl → 直接返回(只落本地,不上报)。
176
+ */
177
+ async flush(): Promise<void> {
178
+ if (this.flushing) return;
179
+ this.flushing = true;
180
+ try {
181
+ const config = this.getConfig();
182
+ if (!config.reportBaseUrl) {
183
+ void this.writeFallbackLog("WARN", "flush skipped: reportBaseUrl 没有配置!请检查 openclaw.json 插件配置是否正确加载。");
184
+ return;
185
+ }
186
+
187
+ const logDir = path.dirname(this.paths.eventsLogPath);
188
+
189
+ // 1. 重命名当前的活跃日志文件为时间戳格式(原子操作,解决读写冲突)
190
+ try {
191
+ await fs.access(this.paths.eventsLogPath);
192
+ const timestamp = Date.now();
193
+ const rotatedPath = path.join(logDir, `events.${timestamp}.jsonl`);
194
+ await fs.rename(this.paths.eventsLogPath, rotatedPath);
195
+ this.debug(`Rotated active log to ${path.basename(rotatedPath)}`);
196
+ } catch {
197
+ // 文件不存在,跳过重命名
198
+ }
199
+
200
+ // 2. 扫描所有轮转的日志文件
201
+ let files: string[] = [];
202
+ try {
203
+ const dirEntries = await fs.readdir(logDir);
204
+ files = dirEntries
205
+ .filter(f => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl")
206
+ .map(f => path.join(logDir, f));
207
+ } catch {
208
+ return; // 目录不存在直接返回
209
+ }
210
+
211
+ if (files.length === 0) {
212
+ this.debug("No rotated log files found. flush finished.");
213
+ return;
214
+ }
215
+
216
+ this.debug(`Found ${files.length} rotated log files to process.`);
217
+ const identity = await this.identityProvider.getIdentity();
218
+ const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_report/batch";
219
+
220
+ // 3. 逐个处理文件上报
221
+ for (const filePath of files) {
222
+ try {
223
+ const content = await fs.readFile(filePath, "utf-8");
224
+ const lines = this.linesOf(content);
225
+ if (lines.length === 0) {
226
+ await fs.unlink(filePath); // 空文件直接删除
227
+ continue;
228
+ }
229
+
230
+ let cursor = 0;
231
+ let allSuccess = true;
232
+
233
+ while (cursor < lines.length) {
234
+ const slice = lines.slice(cursor, cursor + BATCH_SIZE);
235
+ const events: SkillEvent[] = [];
236
+ for (const line of slice) {
237
+ try {
238
+ events.push(JSON.parse(line));
239
+ } catch {
240
+ // 跳过坏行
241
+ }
242
+ }
243
+
244
+ if (events.length === 0) {
245
+ cursor += slice.length;
246
+ continue;
247
+ }
248
+
249
+ const eventsWithUserInfo = await this.attachUserInfo(events, config);
250
+ const body = JSON.stringify({
251
+ identity,
252
+ ide: "openclaw",
253
+ marketplace: "openclaw",
254
+ events: eventsWithUserInfo,
255
+ });
256
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
257
+ if (config.authToken) headers.Authorization = config.authToken;
258
+
259
+ const res = await this.fetchImpl(url, { method: "POST", headers, body });
260
+ if (!res.ok) {
261
+ const errBody = await res.text().catch(() => "无法读取响应体");
262
+ await this.writeFallbackLog("ERROR", `批量上报失败 (文件 ${path.basename(filePath)}), HTTP`, res.status, "服务端返回信息:", errBody);
263
+ // 防御死循环:如果是由于报文过大(413)或格式错误(400)等业务级拒绝,放弃该批次,不要死锁整个本地队列
264
+ if (res.status === 400 || res.status === 413 || res.status === 422) {
265
+ await this.writeFallbackLog("WARN", `报文被服务器永久拒绝,丢弃该批次以释放队列`);
266
+ cursor += slice.length;
267
+ continue;
268
+ }
269
+ allSuccess = false;
270
+ break; // 其他网络或 500 错误:本文件终止处理,留待下轮重试
271
+ }
272
+
273
+ this.debug(`Successfully reported batch of ${events.length} events from ${path.basename(filePath)}`);
274
+ cursor += slice.length;
275
+ }
276
+
277
+ // 如果该文件所有的 batch 都上报成功了,将其物理删除
278
+ if (allSuccess) {
279
+ await fs.unlink(filePath);
280
+ this.debug(`Deleted fully processed file: ${path.basename(filePath)}`);
281
+ } else {
282
+ // 如果某一批次失败,文件保留。下轮 flush 会把前面成功批次的事件再报一次。
283
+ // 但因为 DB 的 skill_logger_events 表 event_id 是唯一键且使用了 INSERT IGNORE,所以数据去重是绝对安全的。
284
+ this.debug(`File ${path.basename(filePath)} partially failed. Keeping it for next flush.`);
285
+ }
286
+
287
+ } catch (err) {
288
+ await this.writeFallbackLog("ERROR", `处理文件 ${path.basename(filePath)} 异常:`, err);
289
+ }
290
+ }
291
+
292
+ } catch (err) {
293
+ await this.writeFallbackLog("ERROR", "flush 整体异常", err);
294
+ } finally {
295
+ this.flushing = false;
296
+ }
297
+ }
298
+ }