@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.
- package/dist/active-skills.js +67 -0
- package/dist/active-skills.test.js +29 -0
- package/dist/config-sync.js +439 -0
- package/dist/config-sync.test.js +145 -0
- package/dist/hooks.js +337 -0
- package/dist/hooks.test.js +123 -0
- package/dist/http.js +54 -0
- package/dist/identity.js +56 -0
- package/dist/index.js +240 -78
- package/dist/index.test.js +39 -0
- package/dist/integration.test.js +102 -0
- package/dist/matcher.js +362 -0
- package/dist/matcher.test.js +139 -0
- package/dist/paths.js +62 -0
- package/dist/paths.test.js +49 -0
- package/dist/reporter.js +267 -0
- package/dist/reporter.test.js +128 -0
- package/dist/semver.js +64 -0
- package/dist/semver.test.js +21 -0
- package/dist/skill-version.js +23 -0
- package/dist/types.js +9 -0
- package/dist/updater.js +352 -0
- package/dist/updater.test.js +212 -0
- package/dist/ws-client.js +484 -0
- package/openclaw.plugin.json +50 -50
- package/package.json +37 -37
- package/src/active-skills.test.ts +32 -32
- package/src/active-skills.ts +77 -77
- package/src/config-sync.test.ts +165 -165
- package/src/config-sync.ts +544 -544
- package/src/hooks.test.ts +251 -251
- package/src/hooks.ts +517 -517
- package/src/http.ts +61 -61
- package/src/identity.ts +64 -64
- package/src/index.test.ts +53 -53
- package/src/index.ts +226 -226
- package/src/integration.test.ts +119 -119
- package/src/matcher.test.ts +170 -170
- package/src/matcher.ts +393 -393
- package/src/paths.test.ts +57 -57
- package/src/paths.ts +84 -84
- package/src/reporter.test.ts +139 -139
- package/src/reporter.ts +298 -298
- package/src/sample-config.json +72 -72
- package/src/semver.test.ts +23 -23
- package/src/semver.ts +60 -60
- package/src/skill-version.ts +53 -53
- package/src/types.ts +198 -198
- package/src/updater.test.ts +325 -237
- package/src/updater.ts +549 -433
- package/src/ws-client.test.ts +48 -37
- package/src/ws-client.ts +717 -642
- package/test-ws.ts +17 -17
- package/tsconfig.json +14 -14
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
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 { GitIdentityProvider } from "./identity.ts";
|
|
14
|
+
import { defaultFetch } from "./http.ts";
|
|
15
|
+
const FLUSH_INTERVAL_MS = 3 * 60 * 1000;
|
|
16
|
+
const BATCH_SIZE = 500;
|
|
17
|
+
export class Reporter {
|
|
18
|
+
paths;
|
|
19
|
+
getConfig;
|
|
20
|
+
identityProvider;
|
|
21
|
+
fetchImpl;
|
|
22
|
+
timer;
|
|
23
|
+
/** 防止多次 flush 重入。 */
|
|
24
|
+
flushing = false;
|
|
25
|
+
constructor(opts) {
|
|
26
|
+
this.paths = opts.paths;
|
|
27
|
+
this.getConfig = opts.getConfig;
|
|
28
|
+
this.identityProvider = opts.identityProvider ?? new GitIdentityProvider();
|
|
29
|
+
this.fetchImpl = opts.fetchImpl ?? defaultFetch();
|
|
30
|
+
}
|
|
31
|
+
get isDebug() {
|
|
32
|
+
return this.getConfig().debugLogging !== false;
|
|
33
|
+
}
|
|
34
|
+
async writeFallbackLog(level, ...args) {
|
|
35
|
+
try {
|
|
36
|
+
const msg = args.map(a => (a instanceof Error) ? (a.stack || a.toString()) : (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ");
|
|
37
|
+
const ts = new Date().toISOString();
|
|
38
|
+
const logLine = `[${ts}] [${level}] [skill-logger-plugin] ${msg}\n`;
|
|
39
|
+
const logDir = path.dirname(this.paths.eventsLogPath);
|
|
40
|
+
await fs.mkdir(logDir, { recursive: true });
|
|
41
|
+
await fs.appendFile(path.join(logDir, "skill-logger.err.log"), logLine);
|
|
42
|
+
if (level === "INFO" && this.isDebug) {
|
|
43
|
+
console.log("[skill-logger-plugin/reporter]", ...args);
|
|
44
|
+
}
|
|
45
|
+
else if (level === "WARN" || level === "ERROR") {
|
|
46
|
+
console.warn("[skill-logger-plugin]", ...args);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// ignore
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
debug(...args) {
|
|
54
|
+
if (this.isDebug) {
|
|
55
|
+
void this.writeFallbackLog("INFO", ...args);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** 追加一行事件到 events.jsonl(目录不存在自动建)。失败不抛。 */
|
|
59
|
+
async appendEvent(event) {
|
|
60
|
+
try {
|
|
61
|
+
// 防御:截断会导致 MySQL Data Truncation 宕机的超长字段
|
|
62
|
+
if (typeof event.error_message === "string" && event.error_message.length > 15000) {
|
|
63
|
+
event.error_message = event.error_message.substring(0, 15000) + "...(truncated)";
|
|
64
|
+
}
|
|
65
|
+
if (typeof event.command === "string" && event.command.length > 15000) {
|
|
66
|
+
event.command = event.command.substring(0, 15000) + "...(truncated)";
|
|
67
|
+
}
|
|
68
|
+
let line = "";
|
|
69
|
+
try {
|
|
70
|
+
line = JSON.stringify(event);
|
|
71
|
+
// 防御:防止整个 JSON 过大(如带有 base64 图片的 args)导致 Express 413 Payload Too Large
|
|
72
|
+
if (line.length > 100000) {
|
|
73
|
+
const safeEvent = { ...event, args: { _warning: "args omitted due to excessive size" } };
|
|
74
|
+
line = JSON.stringify(safeEvent);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return; // JSON 序列化失败直接丢弃
|
|
79
|
+
}
|
|
80
|
+
await fs.mkdir(path.dirname(this.paths.eventsLogPath), { recursive: true });
|
|
81
|
+
await fs.appendFile(this.paths.eventsLogPath, line + "\n");
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
void this.writeFallbackLog("ERROR", "写事件日志失败", this.paths.eventsLogPath, err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** 启动 3 分钟定时上报。 */
|
|
88
|
+
startTimer() {
|
|
89
|
+
if (this.timer)
|
|
90
|
+
return;
|
|
91
|
+
this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
|
|
92
|
+
// 不阻止进程退出
|
|
93
|
+
if (typeof this.timer.unref === "function")
|
|
94
|
+
this.timer.unref();
|
|
95
|
+
}
|
|
96
|
+
/** 停止定时器,并尽力做最后一次 flush。 */
|
|
97
|
+
async stopTimer() {
|
|
98
|
+
if (this.timer) {
|
|
99
|
+
clearInterval(this.timer);
|
|
100
|
+
this.timer = undefined;
|
|
101
|
+
}
|
|
102
|
+
await this.flush();
|
|
103
|
+
}
|
|
104
|
+
linesOf(content) {
|
|
105
|
+
return content.split("\n").filter((l) => l.trim().length > 0);
|
|
106
|
+
}
|
|
107
|
+
async resolveUserInfo(appKey, config) {
|
|
108
|
+
if (!config.reportBaseUrl)
|
|
109
|
+
return {};
|
|
110
|
+
try {
|
|
111
|
+
const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_user/resolve";
|
|
112
|
+
const headers = { "Content-Type": "application/json" };
|
|
113
|
+
if (config.authToken)
|
|
114
|
+
headers.Authorization = config.authToken;
|
|
115
|
+
const res = await this.fetchImpl(url, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers,
|
|
118
|
+
body: JSON.stringify({ appKey }),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
return { error_message: `resolve user info failed: HTTP ${res.status}` };
|
|
122
|
+
}
|
|
123
|
+
const data = typeof res.json === "function" ? await res.json() : {};
|
|
124
|
+
return (data.user_info || data.userInfo || data.data || {});
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
return { error_message: `resolve user info failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async attachUserInfo(events, config) {
|
|
131
|
+
const appKeys = [...new Set(events.map((event) => event.app_key || "").filter(Boolean))];
|
|
132
|
+
if (appKeys.length === 0)
|
|
133
|
+
return events;
|
|
134
|
+
const byAppKey = new Map();
|
|
135
|
+
await Promise.all(appKeys.map(async (appKey) => {
|
|
136
|
+
byAppKey.set(appKey, await this.resolveUserInfo(appKey, config));
|
|
137
|
+
}));
|
|
138
|
+
return events.map((event) => {
|
|
139
|
+
if (!event.app_key)
|
|
140
|
+
return event;
|
|
141
|
+
const userInfo = byAppKey.get(event.app_key);
|
|
142
|
+
if (!userInfo || Object.keys(userInfo).length === 0)
|
|
143
|
+
return event;
|
|
144
|
+
return { ...event, user_info: userInfo };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* 读本地队列并分批 POST;每批成功后清理对应本地文件。
|
|
149
|
+
* 采用日志轮转(Rename)规避读写竞态条件。
|
|
150
|
+
* 未配置 reportBaseUrl → 直接返回(只落本地,不上报)。
|
|
151
|
+
*/
|
|
152
|
+
async flush() {
|
|
153
|
+
if (this.flushing)
|
|
154
|
+
return;
|
|
155
|
+
this.flushing = true;
|
|
156
|
+
try {
|
|
157
|
+
const config = this.getConfig();
|
|
158
|
+
if (!config.reportBaseUrl) {
|
|
159
|
+
void this.writeFallbackLog("WARN", "flush skipped: reportBaseUrl 没有配置!请检查 openclaw.json 插件配置是否正确加载。");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const logDir = path.dirname(this.paths.eventsLogPath);
|
|
163
|
+
// 1. 重命名当前的活跃日志文件为时间戳格式(原子操作,解决读写冲突)
|
|
164
|
+
try {
|
|
165
|
+
await fs.access(this.paths.eventsLogPath);
|
|
166
|
+
const timestamp = Date.now();
|
|
167
|
+
const rotatedPath = path.join(logDir, `events.${timestamp}.jsonl`);
|
|
168
|
+
await fs.rename(this.paths.eventsLogPath, rotatedPath);
|
|
169
|
+
this.debug(`Rotated active log to ${path.basename(rotatedPath)}`);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// 文件不存在,跳过重命名
|
|
173
|
+
}
|
|
174
|
+
// 2. 扫描所有轮转的日志文件
|
|
175
|
+
let files = [];
|
|
176
|
+
try {
|
|
177
|
+
const dirEntries = await fs.readdir(logDir);
|
|
178
|
+
files = dirEntries
|
|
179
|
+
.filter(f => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl")
|
|
180
|
+
.map(f => path.join(logDir, f));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return; // 目录不存在直接返回
|
|
184
|
+
}
|
|
185
|
+
if (files.length === 0) {
|
|
186
|
+
this.debug("No rotated log files found. flush finished.");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.debug(`Found ${files.length} rotated log files to process.`);
|
|
190
|
+
const identity = await this.identityProvider.getIdentity();
|
|
191
|
+
const url = config.reportBaseUrl.replace(/\/$/, "") + "/skill_report/batch";
|
|
192
|
+
// 3. 逐个处理文件上报
|
|
193
|
+
for (const filePath of files) {
|
|
194
|
+
try {
|
|
195
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
196
|
+
const lines = this.linesOf(content);
|
|
197
|
+
if (lines.length === 0) {
|
|
198
|
+
await fs.unlink(filePath); // 空文件直接删除
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
let cursor = 0;
|
|
202
|
+
let allSuccess = true;
|
|
203
|
+
while (cursor < lines.length) {
|
|
204
|
+
const slice = lines.slice(cursor, cursor + BATCH_SIZE);
|
|
205
|
+
const events = [];
|
|
206
|
+
for (const line of slice) {
|
|
207
|
+
try {
|
|
208
|
+
events.push(JSON.parse(line));
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// 跳过坏行
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (events.length === 0) {
|
|
215
|
+
cursor += slice.length;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const eventsWithUserInfo = await this.attachUserInfo(events, config);
|
|
219
|
+
const body = JSON.stringify({
|
|
220
|
+
identity,
|
|
221
|
+
ide: "openclaw",
|
|
222
|
+
marketplace: "openclaw",
|
|
223
|
+
events: eventsWithUserInfo,
|
|
224
|
+
});
|
|
225
|
+
const headers = { "Content-Type": "application/json" };
|
|
226
|
+
if (config.authToken)
|
|
227
|
+
headers.Authorization = config.authToken;
|
|
228
|
+
const res = await this.fetchImpl(url, { method: "POST", headers, body });
|
|
229
|
+
if (!res.ok) {
|
|
230
|
+
const errBody = await res.text().catch(() => "无法读取响应体");
|
|
231
|
+
await this.writeFallbackLog("ERROR", `批量上报失败 (文件 ${path.basename(filePath)}), HTTP`, res.status, "服务端返回信息:", errBody);
|
|
232
|
+
// 防御死循环:如果是由于报文过大(413)或格式错误(400)等业务级拒绝,放弃该批次,不要死锁整个本地队列
|
|
233
|
+
if (res.status === 400 || res.status === 413 || res.status === 422) {
|
|
234
|
+
await this.writeFallbackLog("WARN", `报文被服务器永久拒绝,丢弃该批次以释放队列`);
|
|
235
|
+
cursor += slice.length;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
allSuccess = false;
|
|
239
|
+
break; // 其他网络或 500 错误:本文件终止处理,留待下轮重试
|
|
240
|
+
}
|
|
241
|
+
this.debug(`Successfully reported batch of ${events.length} events from ${path.basename(filePath)}`);
|
|
242
|
+
cursor += slice.length;
|
|
243
|
+
}
|
|
244
|
+
// 如果该文件所有的 batch 都上报成功了,将其物理删除
|
|
245
|
+
if (allSuccess) {
|
|
246
|
+
await fs.unlink(filePath);
|
|
247
|
+
this.debug(`Deleted fully processed file: ${path.basename(filePath)}`);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
// 如果某一批次失败,文件保留。下轮 flush 会把前面成功批次的事件再报一次。
|
|
251
|
+
// 但因为 DB 的 skill_logger_events 表 event_id 是唯一键且使用了 INSERT IGNORE,所以数据去重是绝对安全的。
|
|
252
|
+
this.debug(`File ${path.basename(filePath)} partially failed. Keeping it for next flush.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
await this.writeFallbackLog("ERROR", `处理文件 ${path.basename(filePath)} 异常:`, err);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
await this.writeFallbackLog("ERROR", "flush 整体异常", err);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
this.flushing = false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { Reporter } from "./reporter.ts";
|
|
7
|
+
const identityProvider = {
|
|
8
|
+
getIdentity: async () => ({ user_id: "", git_name: "n", git_email: "e", machine_id: "m" }),
|
|
9
|
+
};
|
|
10
|
+
function makeEvent(i) {
|
|
11
|
+
return { event_id: `id${i}`, event_type: "function_call", skill_name: "s", called_at: new Date().toISOString() };
|
|
12
|
+
}
|
|
13
|
+
let dir;
|
|
14
|
+
let paths;
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
dir = path.join(os.tmpdir(), `slp-reporter-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
17
|
+
await fs.mkdir(dir, { recursive: true });
|
|
18
|
+
paths = {
|
|
19
|
+
eventsLogPath: path.join(dir, "events.jsonl"),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
/** 缺文件视为空,匹配轮转模型下「上报成功即删除」的形态。 */
|
|
26
|
+
async function readEventsOrEmpty(p) {
|
|
27
|
+
try {
|
|
28
|
+
return await fs.readFile(p, "utf-8");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** 列出已轮转、待上报的日志文件(排除活跃的 events.jsonl)。 */
|
|
35
|
+
async function listRotated(d) {
|
|
36
|
+
try {
|
|
37
|
+
const entries = await fs.readdir(d);
|
|
38
|
+
return entries.filter((f) => f.startsWith("events.") && f.endsWith(".jsonl") && f !== "events.jsonl");
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
describe("Reporter.appendEvent", () => {
|
|
45
|
+
it("目录自动创建并追加写", async () => {
|
|
46
|
+
const r = new Reporter({ paths, getConfig: () => ({}), identityProvider });
|
|
47
|
+
await r.appendEvent(makeEvent(1));
|
|
48
|
+
await r.appendEvent(makeEvent(2));
|
|
49
|
+
const lines = (await fs.readFile(paths.eventsLogPath, "utf-8")).trim().split("\n");
|
|
50
|
+
assert.equal(lines.length, 2);
|
|
51
|
+
assert.equal(JSON.parse(lines[1]).event_id, "id2");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("Reporter.flush", () => {
|
|
55
|
+
it("未配置 reportBaseUrl 时不上报", async () => {
|
|
56
|
+
let called = 0;
|
|
57
|
+
const r = new Reporter({
|
|
58
|
+
paths,
|
|
59
|
+
getConfig: () => ({}),
|
|
60
|
+
identityProvider,
|
|
61
|
+
fetchImpl: async () => {
|
|
62
|
+
called++;
|
|
63
|
+
return { ok: true, status: 200 };
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
await r.appendEvent(makeEvent(1));
|
|
67
|
+
await r.flush();
|
|
68
|
+
assert.equal(called, 0);
|
|
69
|
+
});
|
|
70
|
+
it("成功上报后清理本地日志,再次 flush 不重复发", async () => {
|
|
71
|
+
const sent = [];
|
|
72
|
+
const config = { reportBaseUrl: "https://x" };
|
|
73
|
+
const r = new Reporter({
|
|
74
|
+
paths,
|
|
75
|
+
getConfig: () => config,
|
|
76
|
+
identityProvider,
|
|
77
|
+
fetchImpl: async (_url, init) => {
|
|
78
|
+
const body = JSON.parse(String(init.body));
|
|
79
|
+
sent.push(body.events.length);
|
|
80
|
+
return { ok: true, status: 200 };
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
await r.appendEvent(makeEvent(1));
|
|
84
|
+
await r.appendEvent(makeEvent(2));
|
|
85
|
+
await r.flush();
|
|
86
|
+
await r.flush(); // 无新事件
|
|
87
|
+
assert.deepEqual(sent, [2]);
|
|
88
|
+
// 轮转文件已全部上报并删除,活跃日志清空
|
|
89
|
+
assert.equal(await readEventsOrEmpty(paths.eventsLogPath), "");
|
|
90
|
+
assert.equal((await listRotated(dir)).length, 0);
|
|
91
|
+
});
|
|
92
|
+
it("保留 flush 期间新追加的事件(轮转隔离读写)", async () => {
|
|
93
|
+
let appendedDuringFlush = false;
|
|
94
|
+
const r = new Reporter({
|
|
95
|
+
paths,
|
|
96
|
+
getConfig: () => ({ reportBaseUrl: "https://x" }),
|
|
97
|
+
identityProvider,
|
|
98
|
+
fetchImpl: async () => {
|
|
99
|
+
if (!appendedDuringFlush) {
|
|
100
|
+
appendedDuringFlush = true;
|
|
101
|
+
await fs.appendFile(paths.eventsLogPath, JSON.stringify(makeEvent(2)) + "\n");
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, status: 200 };
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
await r.appendEvent(makeEvent(1));
|
|
107
|
+
await r.flush();
|
|
108
|
+
const lines = (await readEventsOrEmpty(paths.eventsLogPath)).trim().split("\n").filter(Boolean);
|
|
109
|
+
assert.equal(lines.length, 1);
|
|
110
|
+
assert.equal(JSON.parse(lines[0]).event_id, "id2");
|
|
111
|
+
});
|
|
112
|
+
it("上报失败保留轮转文件,下轮重试成功后清空", async () => {
|
|
113
|
+
let ok = false;
|
|
114
|
+
const r = new Reporter({
|
|
115
|
+
paths,
|
|
116
|
+
getConfig: () => ({ reportBaseUrl: "https://x" }),
|
|
117
|
+
identityProvider,
|
|
118
|
+
fetchImpl: async () => ({ ok, status: ok ? 200 : 500 }),
|
|
119
|
+
});
|
|
120
|
+
await r.appendEvent(makeEvent(1));
|
|
121
|
+
await r.flush(); // 失败:轮转文件保留待重试
|
|
122
|
+
assert.equal((await listRotated(dir)).length, 1);
|
|
123
|
+
ok = true;
|
|
124
|
+
await r.flush(); // 成功:清空
|
|
125
|
+
assert.equal((await listRotated(dir)).length, 0);
|
|
126
|
+
assert.equal(await readEventsOrEmpty(paths.eventsLogPath), "");
|
|
127
|
+
});
|
|
128
|
+
});
|
package/dist/semver.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量语义化版本比较(纯函数,无依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 仅用于「本地 skill 版本 vs 平台最新版本」的过期判断,不追求完整 semver 规范:
|
|
5
|
+
* - 容忍前缀 `v`/`V`(如 `v1.2.3`);
|
|
6
|
+
* - 比较 `主.次.修订…` 的数值段,缺省段按 0 补齐(`1.2` == `1.2.0`);
|
|
7
|
+
* - 预发布/构建元数据(`-beta`、`+build`)只取核心版本比较,忽略其后缀;
|
|
8
|
+
* - 任一侧无法解析为「点分数字」时,退化为字符串相等判断。
|
|
9
|
+
*/
|
|
10
|
+
/** 解析核心数字段;非「点分数字」返回 null(触发字符串回退)。 */
|
|
11
|
+
function parseCore(v) {
|
|
12
|
+
const core = v.trim().replace(/^[vV]/, "").split(/[-+]/, 1)[0];
|
|
13
|
+
if (!core)
|
|
14
|
+
return null;
|
|
15
|
+
const parts = core.split(".");
|
|
16
|
+
const nums = [];
|
|
17
|
+
for (const p of parts) {
|
|
18
|
+
if (!/^\d+$/.test(p))
|
|
19
|
+
return null;
|
|
20
|
+
nums.push(Number(p));
|
|
21
|
+
}
|
|
22
|
+
return nums.length > 0 ? nums : null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 比较 a 与 b:a<b 返回 -1,a>b 返回 1,相等返回 0。
|
|
26
|
+
* 两侧都能解析为点分数字时按数值逐段比较;否则退化为字符串比较。
|
|
27
|
+
*/
|
|
28
|
+
export function compareVersions(a, b) {
|
|
29
|
+
const na = parseCore(a);
|
|
30
|
+
const nb = parseCore(b);
|
|
31
|
+
if (na && nb) {
|
|
32
|
+
const len = Math.max(na.length, nb.length);
|
|
33
|
+
for (let i = 0; i < len; i++) {
|
|
34
|
+
const x = na[i] ?? 0;
|
|
35
|
+
const y = nb[i] ?? 0;
|
|
36
|
+
if (x < y)
|
|
37
|
+
return -1;
|
|
38
|
+
if (x > y)
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
// 字符串回退:仅区分相等/不等(不臆测大小关系)。
|
|
44
|
+
const sa = a.trim();
|
|
45
|
+
const sb = b.trim();
|
|
46
|
+
if (sa === sb)
|
|
47
|
+
return 0;
|
|
48
|
+
return sa < sb ? -1 : 1;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 本地版本是否「落后于」最新版本(即需要更新)。
|
|
52
|
+
* - 两侧都是合法 semver:latest 数值更大才算过期;
|
|
53
|
+
* - 无法数值比较时:字符串不相等即视为过期(保守,宁可多更新一次)。
|
|
54
|
+
* 任一侧为空 → 不判过期(缺版本号则不参与更新)。
|
|
55
|
+
*/
|
|
56
|
+
export function isOutdated(local, latest) {
|
|
57
|
+
if (!local || !latest)
|
|
58
|
+
return false;
|
|
59
|
+
const na = parseCore(local);
|
|
60
|
+
const nb = parseCore(latest);
|
|
61
|
+
if (na && nb)
|
|
62
|
+
return compareVersions(local, latest) < 0;
|
|
63
|
+
return local.trim() !== latest.trim();
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { compareVersions, isOutdated } from "./semver.ts";
|
|
4
|
+
describe("compareVersions", () => {
|
|
5
|
+
it("数值逐段比较:1.10 > 1.9", () => assert.equal(compareVersions("1.10.0", "1.9.0"), 1));
|
|
6
|
+
it("缺省段按 0 补齐:1.2 == 1.2.0", () => assert.equal(compareVersions("1.2", "1.2.0"), 0));
|
|
7
|
+
it("容忍 v 前缀", () => assert.equal(compareVersions("v2.0.0", "2.0.0"), 0));
|
|
8
|
+
it("a < b", () => assert.equal(compareVersions("1.0.0", "1.0.1"), -1));
|
|
9
|
+
it("忽略预发布后缀(核心相等)", () => assert.equal(compareVersions("1.0.0-beta", "1.0.0"), 0));
|
|
10
|
+
it("非数字版本退化为字符串比较", () => assert.equal(compareVersions("abc", "abc"), 0));
|
|
11
|
+
});
|
|
12
|
+
describe("isOutdated", () => {
|
|
13
|
+
it("本地落后 → true", () => assert.equal(isOutdated("1.0.0", "1.1.0"), true));
|
|
14
|
+
it("本地更新 → false", () => assert.equal(isOutdated("2.0.0", "1.9.0"), false));
|
|
15
|
+
it("相等 → false", () => assert.equal(isOutdated("1.2.3", "1.2.3"), false));
|
|
16
|
+
it("1.9 vs 1.10:应判过期(数值语义)", () => assert.equal(isOutdated("1.9.0", "1.10.0"), true));
|
|
17
|
+
it("缺本地版本 → 不判过期", () => assert.equal(isOutdated(undefined, "1.0.0"), false));
|
|
18
|
+
it("缺最新版本 → 不判过期", () => assert.equal(isOutdated("1.0.0", undefined), false));
|
|
19
|
+
it("非 semver 且不同 → 过期(保守)", () => assert.equal(isOutdated("alpha", "beta"), true));
|
|
20
|
+
it("带预发布后缀核心相同 → 不过期", () => assert.equal(isOutdated("2026-01", "2026-02"), false));
|
|
21
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从 SKILL.md 文本中提取版本号(纯正则、不借助任何模型)。
|
|
3
|
+
*
|
|
4
|
+
* 兼容点:
|
|
5
|
+
* - 键名:英文 `version` / `Version`(大小写不敏感)、中文 `版本号` / `版本`;
|
|
6
|
+
* - 冒号:半角 `:` 或全角 `:`;
|
|
7
|
+
* - 取值:去除包裹引号、行尾 ` # 注释`、首尾空白。
|
|
8
|
+
*
|
|
9
|
+
* 范围:在整篇 SKILL.md 内按「单独一行」匹配(frontmatter 在最前,故天然优先命中)。
|
|
10
|
+
* 因要求行首即键名(前面只允许空白),Markdown 标题 `## Version: x`(带 `#` 前缀)不会被误命中。
|
|
11
|
+
* 取不到返回 undefined(该 skill 不参与版本更新)。
|
|
12
|
+
*/
|
|
13
|
+
export function parseSkillVersion(content) {
|
|
14
|
+
const m = /(?:^|\r?\n)[ \t]*(?:version|版本号|版本)[ \t]*[::][ \t]*(.+)/i.exec(content);
|
|
15
|
+
if (!m)
|
|
16
|
+
return undefined;
|
|
17
|
+
const v = m[1]
|
|
18
|
+
.replace(/\s+#.*$/, "") // 行尾注释
|
|
19
|
+
.trim()
|
|
20
|
+
.replace(/^["']|["']$/g, "") // 包裹引号
|
|
21
|
+
.trim();
|
|
22
|
+
return v || undefined;
|
|
23
|
+
}
|
package/dist/types.js
ADDED