kld-sdd 2.6.17 → 2.7.3
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/bin/kld-sdd-init.js +4 -0
- package/kld-sdd-guide.html +22 -0
- package/lib/init.js +160 -73
- package/package.json +3 -3
- package/skywalk-sdd/index.cjs +155 -25
- package/skywalk-sdd/lib/git-identity.cjs +270 -0
- package/skywalk-sdd/lib/shared.cjs +150 -2
- package/skywalk-sdd/lib/usage-contract.cjs +207 -0
- package/skywalk-sdd/lib/usage-reporter.cjs +460 -0
- package/skywalk-sdd/lib/user-config.cjs +132 -0
- package/skywalk-sdd/ontology/artifact-parser.cjs +1 -2
- package/templates/git-hooks/commit-msg +58 -15
- package/templates/git-hooks/hooks.config +19 -0
- package/templates/git-hooks/pre-commit +45 -11
- package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
- package/templates/git-hooks/pre-push +53 -11
- package/templates/git-hooks/pre-push-consistency-check.cjs +357 -118
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +1 -1
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +187 -257
- package/templates/skills/kld-sdd/opsx-consistency-check/reference.md +129 -0
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +27 -6
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +12 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// kld-T01 — CLI 使用统计上报契约
|
|
2
|
+
// 集中定义:七值 skill 枚举、ApiResponse 解包、内置默认上报地址、
|
|
3
|
+
// Node 14 兼容的 URL/HTTP 基础函数。与 kb-sdd 跨仓契约保持一致。
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const http = require('node:http');
|
|
7
|
+
const https = require('node:https');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 内置默认上报地址:四级 server 解析的最后一级兜底。
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_SERVER_URL = 'http://10.29.213.80:8080';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 七值使用统计 skill 枚举。
|
|
16
|
+
* test / explore 不属于统计范围,永不映射。
|
|
17
|
+
*/
|
|
18
|
+
const USAGE_SKILLS = Object.freeze([
|
|
19
|
+
'propose',
|
|
20
|
+
'spec',
|
|
21
|
+
'design',
|
|
22
|
+
'task',
|
|
23
|
+
'check',
|
|
24
|
+
'apply',
|
|
25
|
+
'archive',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const USAGE_SKILL_SET = new Set(USAGE_SKILLS);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 将 opsx 命令映射到使用统计 skill。
|
|
32
|
+
* @param {string} command
|
|
33
|
+
* @returns {string|null} 七值之一;非七值返回 null
|
|
34
|
+
*/
|
|
35
|
+
function mapCommandToUsageSkill(command) {
|
|
36
|
+
if (typeof command !== 'string' || !command) return null;
|
|
37
|
+
return USAGE_SKILL_SET.has(command) ? command : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 规范化 server URL:只接受 http/https,去尾斜杠,去首尾空白。
|
|
42
|
+
* @param {string} raw
|
|
43
|
+
* @returns {string|null} 规范化后的 base URL;非法返回 null
|
|
44
|
+
*/
|
|
45
|
+
function normalizeServerUrl(raw) {
|
|
46
|
+
if (typeof raw !== 'string') return null;
|
|
47
|
+
const trimmed = raw.trim();
|
|
48
|
+
if (!trimmed) return null;
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = new URL(trimmed);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
56
|
+
// 重组:protocol + // + host + pathname(去尾斜杠),忽略 search/hash
|
|
57
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
58
|
+
return `${parsed.protocol}//${parsed.host}${path}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 解开 ApiResponse 信封:{ code, message, data } → data;code 非 0 抛错。
|
|
63
|
+
* @param {object} body — 已 JSON.parse 的响应体
|
|
64
|
+
* @returns {*} data 字段内容
|
|
65
|
+
* @throws {Error} code 非 0、非对象、缺 code 字段
|
|
66
|
+
*/
|
|
67
|
+
function unwrapApiResponse(body) {
|
|
68
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
69
|
+
throw new Error('ApiResponse must be an object');
|
|
70
|
+
}
|
|
71
|
+
if (typeof body.code !== 'number') {
|
|
72
|
+
throw new Error('ApiResponse missing numeric code');
|
|
73
|
+
}
|
|
74
|
+
if (body.code !== 0) {
|
|
75
|
+
const msg = typeof body.message === 'string' && body.message
|
|
76
|
+
? body.message
|
|
77
|
+
: `server error code ${body.code}`;
|
|
78
|
+
throw new Error(msg);
|
|
79
|
+
}
|
|
80
|
+
return body.data === undefined ? null : body.data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 分类 usage 上报响应。
|
|
85
|
+
* - confirmed: 2xx/204,服务端已确认,可从 pending 队列移除
|
|
86
|
+
* - retry: 网络错误 / 429 / 5xx / timeout;保留在 pending 队列
|
|
87
|
+
* - drop: 其他 4xx(含 401);不再重试但不上抛,也不清除任何本地状态
|
|
88
|
+
*
|
|
89
|
+
* @param {number|null} statusCode
|
|
90
|
+
* @returns {'confirmed'|'retry'|'drop'}
|
|
91
|
+
*/
|
|
92
|
+
function classifyUsageSubmitResponse(statusCode) {
|
|
93
|
+
if (statusCode == null || statusCode === 0) return 'retry';
|
|
94
|
+
if (statusCode >= 200 && statusCode < 300) return 'confirmed';
|
|
95
|
+
if (statusCode === 429) return 'retry';
|
|
96
|
+
if (statusCode >= 500) return 'retry';
|
|
97
|
+
return 'drop';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Node 14 兼容的 POST JSON 实现。
|
|
102
|
+
* - 仅用核心 http/https,不依赖全局 fetch
|
|
103
|
+
* - 任何网络/解析错误均不抛,返回 statusCode=null 让调用方分类
|
|
104
|
+
* - 4xx/5xx 不抛错,返回 statusCode + body 供分类
|
|
105
|
+
*
|
|
106
|
+
* @param {string} url — 完整 URL(必须 http/https)
|
|
107
|
+
* @param {object} payload — 请求体(JSON 序列化)
|
|
108
|
+
* @param {{timeoutMs?:number,headers?:object}} [options]
|
|
109
|
+
* @returns {Promise<{statusCode:number|null,body:*,data:*,error:*}>}
|
|
110
|
+
* statusCode: HTTP 状态码,网络错误为 null
|
|
111
|
+
* body: 原始解析后的 JSON 对象(如可解析)
|
|
112
|
+
* data: 若为 ApiResponse 信封且 code=0,则为解包后的 data;否则为 undefined
|
|
113
|
+
* error: 仅在 statusCode=null 时携带 Error;否则为 null
|
|
114
|
+
*/
|
|
115
|
+
function postJson(url, payload, options = {}) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let target;
|
|
118
|
+
try {
|
|
119
|
+
target = new URL(url);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
reject(new Error(`invalid url: ${err.message}`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
125
|
+
reject(new Error(`only http/https supported, got ${target.protocol}`));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const transport = target.protocol === 'https:' ? https : http;
|
|
129
|
+
const timeoutMs = Number.isFinite(options && options.timeoutMs) && options.timeoutMs > 0
|
|
130
|
+
? Math.floor(options.timeoutMs)
|
|
131
|
+
: 30000;
|
|
132
|
+
|
|
133
|
+
let body;
|
|
134
|
+
try {
|
|
135
|
+
body = JSON.stringify(payload === undefined ? {} : payload);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
reject(new Error(`payload serialization error: ${err.message}`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const headers = Object.assign(
|
|
142
|
+
{
|
|
143
|
+
Accept: 'application/json',
|
|
144
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
145
|
+
'Content-Length': Buffer.byteLength(body),
|
|
146
|
+
},
|
|
147
|
+
options && options.headers && typeof options.headers === 'object' ? options.headers : {},
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const request = transport.request(
|
|
151
|
+
{
|
|
152
|
+
hostname: target.hostname,
|
|
153
|
+
port: target.port || (target.protocol === 'https:' ? 443 : 80),
|
|
154
|
+
path: target.pathname + target.search,
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers,
|
|
157
|
+
timeout: timeoutMs,
|
|
158
|
+
},
|
|
159
|
+
(response) => {
|
|
160
|
+
const chunks = [];
|
|
161
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
162
|
+
response.on('end', () => {
|
|
163
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
164
|
+
let parsedBody = null;
|
|
165
|
+
if (raw) {
|
|
166
|
+
try {
|
|
167
|
+
parsedBody = JSON.parse(raw);
|
|
168
|
+
} catch {
|
|
169
|
+
parsedBody = null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let data;
|
|
173
|
+
if (parsedBody && typeof parsedBody === 'object' && parsedBody.code === 0) {
|
|
174
|
+
try {
|
|
175
|
+
data = unwrapApiResponse(parsedBody);
|
|
176
|
+
} catch {
|
|
177
|
+
data = undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
resolve({
|
|
181
|
+
statusCode: response.statusCode == null ? null : response.statusCode,
|
|
182
|
+
body: parsedBody,
|
|
183
|
+
data,
|
|
184
|
+
error: null,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
request.on('timeout', () => {
|
|
190
|
+
request.destroy(new Error(`request timeout after ${timeoutMs}ms`));
|
|
191
|
+
});
|
|
192
|
+
request.on('error', (err) => {
|
|
193
|
+
resolve({ statusCode: null, body: null, data: undefined, error: err });
|
|
194
|
+
});
|
|
195
|
+
request.end(body);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
USAGE_SKILLS,
|
|
201
|
+
mapCommandToUsageSkill,
|
|
202
|
+
normalizeServerUrl,
|
|
203
|
+
unwrapApiResponse,
|
|
204
|
+
classifyUsageSubmitResponse,
|
|
205
|
+
postJson,
|
|
206
|
+
DEFAULT_SERVER_URL,
|
|
207
|
+
};
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// kld-T04 — 使用事件 reporter(git 身份缓存化韧性版)
|
|
2
|
+
// 职责:git 身份取自 ~/.kld-sdd/git-identity.json 持久缓存(git 仅由 git-identity.cjs
|
|
3
|
+
// 后台刷新读取,慢 git 环境前台零调用);无身份当前事件入 pending 队列不跳过,
|
|
4
|
+
// 身份就绪后 flush 时注入身份补报;
|
|
5
|
+
// server 四级解析(config > kb-state.api > KLD_SDD_SERVER > 内置默认);
|
|
6
|
+
// 单一 3s deadline;pending JSONL 原子追加;100 条上限;1MB 淘汰;
|
|
7
|
+
// HTTP 状态分类(confirmed/retry/drop,401 归入 drop 不清任何本地状态);
|
|
8
|
+
// 并发安全(appendFileSync O_APPEND + 事件 id 差集提交);不改原业务退出码。
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
normalizeServerUrl,
|
|
17
|
+
classifyUsageSubmitResponse,
|
|
18
|
+
postJson,
|
|
19
|
+
mapCommandToUsageSkill,
|
|
20
|
+
DEFAULT_SERVER_URL,
|
|
21
|
+
} = require('./usage-contract.cjs');
|
|
22
|
+
const {
|
|
23
|
+
readUserConfig,
|
|
24
|
+
} = require('./user-config.cjs');
|
|
25
|
+
const gitIdentity = require('./git-identity.cjs');
|
|
26
|
+
|
|
27
|
+
const PENDING_FILE_NAME = 'pending-usage.jsonl';
|
|
28
|
+
const PENDING_MAX_BYTES = 1024 * 1024; // 1 MB
|
|
29
|
+
const FLUSH_MAX_COUNT = 100;
|
|
30
|
+
const SHARED_DEADLINE_MS = 3000;
|
|
31
|
+
// 单行事件 JSON 的安全上限(小于 4KB 以保证 appendFileSync 在 POSIX/Windows 都原子)
|
|
32
|
+
const SINGLE_EVENT_MAX_BYTES = 4096;
|
|
33
|
+
|
|
34
|
+
function pendingPathFor(homeDir) {
|
|
35
|
+
const envHome = typeof process !== 'undefined' && process && process.env
|
|
36
|
+
? process.env.KLD_SDD_HOME
|
|
37
|
+
: undefined;
|
|
38
|
+
const home = homeDir || envHome || require('node:os').homedir();
|
|
39
|
+
return path.join(home, '.kld-sdd', PENDING_FILE_NAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// git 身份进程内缓存:仅缓存成功读取;失败不缓存,下次重试
|
|
43
|
+
let gitIdentityCache = null;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 读取本机 git 身份。
|
|
47
|
+
* - 来源:~/.kld-sdd/git-identity.json 持久缓存(git-identity.cjs 维护,
|
|
48
|
+
* git 仅由 detached 后台刷新进程读取——慢 git 环境(7-34s 启动)前台零调用)
|
|
49
|
+
* - 进程内缓存:首次成功后复用;失败不缓存
|
|
50
|
+
* - 无缓存 / 缓存缺字段 / tombstone → 返回 null(调用方将当前事件入队待补报,
|
|
51
|
+
* 并已由 ensureIdentity 触发一次后台刷新)
|
|
52
|
+
*
|
|
53
|
+
* @param {string} [homeDir] — 测试注入 home;缺省走 KLD_SDD_HOME/os.homedir
|
|
54
|
+
* @returns {{userName:string, email:string}|null}
|
|
55
|
+
*/
|
|
56
|
+
function readGitIdentity(homeDir) {
|
|
57
|
+
if (gitIdentityCache) return gitIdentityCache;
|
|
58
|
+
const identity = gitIdentity.ensureIdentity({ homeDir });
|
|
59
|
+
if (!identity) return null;
|
|
60
|
+
gitIdentityCache = identity;
|
|
61
|
+
return gitIdentityCache;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 测试专用:重置进程内 git 身份缓存。
|
|
66
|
+
*/
|
|
67
|
+
function _resetGitIdentityCache() {
|
|
68
|
+
gitIdentityCache = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 四级解析上报地址:config.server → 项目 kb-state.json api → env KLD_SDD_SERVER → 内置默认。
|
|
73
|
+
* 每级均经 normalizeServerUrl;结果永远非空(默认地址兜底)。
|
|
74
|
+
*
|
|
75
|
+
* @param {{homeDir?:string, projectRoot?:string, env?:object}} [options]
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function resolveReportServer(options = {}) {
|
|
79
|
+
const env = options.env || process.env;
|
|
80
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
81
|
+
// 1. 用户级 config.server
|
|
82
|
+
const config = readUserConfig(options.homeDir);
|
|
83
|
+
if (config && typeof config.server === 'string') {
|
|
84
|
+
const normalized = normalizeServerUrl(config.server);
|
|
85
|
+
if (normalized) return normalized;
|
|
86
|
+
}
|
|
87
|
+
// 2. 项目级 kb-state.json 的 api
|
|
88
|
+
try {
|
|
89
|
+
const kbStatePath = path.join(projectRoot, 'kb-state.json');
|
|
90
|
+
if (fs.existsSync(kbStatePath)) {
|
|
91
|
+
const kbState = JSON.parse(fs.readFileSync(kbStatePath, 'utf8'));
|
|
92
|
+
if (kbState && typeof kbState.api === 'string') {
|
|
93
|
+
const normalized = normalizeServerUrl(kbState.api);
|
|
94
|
+
if (normalized) return normalized;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// kb-state 读取失败时静默,继续下一级
|
|
99
|
+
}
|
|
100
|
+
// 3. env KLD_SDD_SERVER
|
|
101
|
+
if (env && typeof env.KLD_SDD_SERVER === 'string') {
|
|
102
|
+
const normalized = normalizeServerUrl(env.KLD_SDD_SERVER);
|
|
103
|
+
if (normalized) return normalized;
|
|
104
|
+
}
|
|
105
|
+
// 4. 内置默认地址兜底
|
|
106
|
+
return normalizeServerUrl(DEFAULT_SERVER_URL);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 生成事件唯一 id(基于时间戳 + pid + 随机字节)。
|
|
111
|
+
* 用于 flush 提交时按 id 差集删除已确认行,避免 read-modify-write 覆盖并发 append。
|
|
112
|
+
*/
|
|
113
|
+
function generateEventId() {
|
|
114
|
+
return `ue_${Date.now().toString(36)}_${process.pid.toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 为没有 id 字段的旧事件规范化一个 id(基于内容 hash)。
|
|
119
|
+
* 同一行多次读出会得到同一个 id,使 flush 差集提交对旧 fixture 兼容。
|
|
120
|
+
*/
|
|
121
|
+
function ensureEventId(event) {
|
|
122
|
+
if (event && typeof event === 'object') {
|
|
123
|
+
if (typeof event.id === 'string' && event.id) return event;
|
|
124
|
+
const hash = crypto
|
|
125
|
+
.createHash('sha1')
|
|
126
|
+
.update(JSON.stringify(event))
|
|
127
|
+
.digest('hex')
|
|
128
|
+
.slice(0, 12);
|
|
129
|
+
return Object.assign({}, event, { id: `legacy_${hash}` });
|
|
130
|
+
}
|
|
131
|
+
return event;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 读取 pending 队列。
|
|
136
|
+
* - 尾部半行 / 损坏行被隔离:跳过但不污染合法记录
|
|
137
|
+
* - 没有 id 的旧事件会被规范化一个内容 hash id
|
|
138
|
+
* - 返回 { events, corrupted }
|
|
139
|
+
*/
|
|
140
|
+
function readPending(filePath) {
|
|
141
|
+
if (!fs.existsSync(filePath)) return { events: [], corrupted: 0 };
|
|
142
|
+
let raw;
|
|
143
|
+
try {
|
|
144
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
145
|
+
} catch {
|
|
146
|
+
return { events: [], corrupted: 0 };
|
|
147
|
+
}
|
|
148
|
+
if (!raw) return { events: [], corrupted: 0 };
|
|
149
|
+
const lines = raw.split('\n');
|
|
150
|
+
const events = [];
|
|
151
|
+
let corrupted = 0;
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (!trimmed) continue;
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(trimmed);
|
|
157
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.skill === 'string') {
|
|
158
|
+
events.push(ensureEventId(parsed));
|
|
159
|
+
} else {
|
|
160
|
+
corrupted += 1;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
corrupted += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { events, corrupted };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 原子重写 pending 文件:先 temp 后 rename。
|
|
171
|
+
* 仅在 flush 提交时调用;并发 append 的新行必须在重写前重新读入并按 id 保留。
|
|
172
|
+
*/
|
|
173
|
+
function writePendingAtomic(filePath, events) {
|
|
174
|
+
const dir = path.dirname(filePath);
|
|
175
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
176
|
+
if (!events || events.length === 0) {
|
|
177
|
+
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
181
|
+
const payload = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
182
|
+
fs.writeFileSync(tmp, payload, 'utf8');
|
|
183
|
+
try {
|
|
184
|
+
fs.renameSync(tmp, filePath);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 追加事件到 pending 队列。
|
|
193
|
+
* - 用 fs.appendFileSync 单行追加:POSIX O_APPEND 对小行原子;Windows 对 <4KB append 也原子
|
|
194
|
+
* - 超 1MB 时先重读并按需淘汰最旧完整行(O(n):先算总大小,每次 shift 只减对应字节)
|
|
195
|
+
* - 事件自动补 id(如未含)
|
|
196
|
+
*/
|
|
197
|
+
function appendPending(event, filePath) {
|
|
198
|
+
if (!event || typeof event !== 'object') return;
|
|
199
|
+
const target = filePath || pendingPathFor();
|
|
200
|
+
const dir = path.dirname(target);
|
|
201
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
202
|
+
|
|
203
|
+
// 自动补 id(用于 flush 差集删除)
|
|
204
|
+
const toAppend = Object.assign({}, event);
|
|
205
|
+
if (typeof toAppend.id !== 'string' || !toAppend.id) {
|
|
206
|
+
toAppend.id = generateEventId();
|
|
207
|
+
}
|
|
208
|
+
const line = JSON.stringify(toAppend) + '\n';
|
|
209
|
+
const lineBytes = Buffer.byteLength(line, 'utf8');
|
|
210
|
+
if (lineBytes > SINGLE_EVENT_MAX_BYTES) {
|
|
211
|
+
// 防御:单行超 4KB 直接丢弃(不应发生;正常事件 <200 字节)
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 先检查追加后是否超 1MB;超则先淘汰
|
|
216
|
+
let existingSize = 0;
|
|
217
|
+
let existingEvents = [];
|
|
218
|
+
if (fs.existsSync(target)) {
|
|
219
|
+
const { events } = readPending(target);
|
|
220
|
+
existingEvents = events;
|
|
221
|
+
for (const e of events) {
|
|
222
|
+
existingSize += Buffer.byteLength(JSON.stringify(e), 'utf8') + 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (existingSize + lineBytes > PENDING_MAX_BYTES) {
|
|
226
|
+
// O(n) 淘汰:先算总大小,每次 shift 只减对应字节
|
|
227
|
+
let currentSize = existingSize;
|
|
228
|
+
let dropCount = 0;
|
|
229
|
+
while (dropCount < existingEvents.length && currentSize + lineBytes > PENDING_MAX_BYTES) {
|
|
230
|
+
const evicted = existingEvents[dropCount];
|
|
231
|
+
currentSize -= (Buffer.byteLength(JSON.stringify(evicted), 'utf8') + 1);
|
|
232
|
+
dropCount += 1;
|
|
233
|
+
}
|
|
234
|
+
if (dropCount > 0) {
|
|
235
|
+
const retained = existingEvents.slice(dropCount);
|
|
236
|
+
// 原子重写(淘汰后才需要 rewrite;此后 append 当前行)
|
|
237
|
+
writePendingAtomic(target, retained);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// 原子 append(POSIX O_APPEND;Windows 单行 <4KB 也原子)
|
|
241
|
+
try {
|
|
242
|
+
fs.appendFileSync(target, line, 'utf8');
|
|
243
|
+
} catch {
|
|
244
|
+
// append 失败时静默(不改原业务退出码)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 提交单条事件;发送前注入 git 身份;返回分类。
|
|
250
|
+
* @returns {Promise<'confirmed'|'retry'|'drop'>}
|
|
251
|
+
*/
|
|
252
|
+
async function submitOne(server, identity, event, timeoutMs) {
|
|
253
|
+
// 提交时剔除内部 id(不上传给服务端),统一注入当前 git 身份
|
|
254
|
+
// (兼容 pending 队列里无身份字段的遗留事件)
|
|
255
|
+
const outgoing = Object.assign({}, event, {
|
|
256
|
+
userName: identity.userName,
|
|
257
|
+
email: identity.email,
|
|
258
|
+
});
|
|
259
|
+
delete outgoing.id;
|
|
260
|
+
const result = await postJson(
|
|
261
|
+
`${server}/api/v1/usage-events`,
|
|
262
|
+
outgoing,
|
|
263
|
+
{ timeoutMs },
|
|
264
|
+
);
|
|
265
|
+
return classifyUsageSubmitResponse(result.statusCode);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 共享冲刷执行体:读 pending → 逐条提交 → 按 id 差集原子提交队列变化。
|
|
270
|
+
* reportUsageOnce(冲刷后发当前事件)与 flushPendingUsage(只冲刷)共用。
|
|
271
|
+
*
|
|
272
|
+
* @returns {Promise<{submitted:number, confirmedIds:Set, droppedIds:Set}>}
|
|
273
|
+
*/
|
|
274
|
+
async function flushPendingQueue(server, identity, pendingFile, deadlineStart) {
|
|
275
|
+
const deadline = deadlineStart || (Date.now() + SHARED_DEADLINE_MS);
|
|
276
|
+
const confirmedIds = new Set(); // 已成功的事件 id
|
|
277
|
+
const droppedIds = new Set(); // 4xx 永久失败的事件 id
|
|
278
|
+
let submitted = 0;
|
|
279
|
+
|
|
280
|
+
const { events: pendingEvents } = readPending(pendingFile);
|
|
281
|
+
let flushed = 0;
|
|
282
|
+
for (let i = 0; i < pendingEvents.length; i++) {
|
|
283
|
+
const ev = pendingEvents[i];
|
|
284
|
+
const remaining = deadline - Date.now();
|
|
285
|
+
if (flushed >= FLUSH_MAX_COUNT || remaining <= 0) break;
|
|
286
|
+
|
|
287
|
+
const timeoutMs = Math.max(50, remaining);
|
|
288
|
+
let classification;
|
|
289
|
+
try {
|
|
290
|
+
classification = await submitOne(server, identity, ev, timeoutMs);
|
|
291
|
+
} catch {
|
|
292
|
+
classification = 'retry';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (classification === 'confirmed') {
|
|
296
|
+
flushed += 1;
|
|
297
|
+
submitted += 1;
|
|
298
|
+
if (ev.id) confirmedIds.add(ev.id);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (classification === 'drop') {
|
|
302
|
+
// drop 丢弃该条(含 401),不 retained、不 break,继续下一条
|
|
303
|
+
if (ev.id) droppedIds.add(ev.id);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
// retry:网络/5xx/429/timeout,本次及后续保留
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 原子提交队列变化(按 id 差集删除已确认 + 已丢弃)
|
|
311
|
+
// 关键:rewrite 前必须重新读文件,把并发 append 的新行合并进来
|
|
312
|
+
if (confirmedIds.size > 0 || droppedIds.size > 0) {
|
|
313
|
+
const { events: latestEvents } = readPending(pendingFile);
|
|
314
|
+
const retained = latestEvents.filter((e) => {
|
|
315
|
+
if (e.id && confirmedIds.has(e.id)) return false;
|
|
316
|
+
if (e.id && droppedIds.has(e.id)) return false;
|
|
317
|
+
return true;
|
|
318
|
+
});
|
|
319
|
+
writePendingAtomic(pendingFile, retained);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { submitted, confirmedIds, droppedIds };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 只冲刷 pending 队列,不产生新事件(cmdEnd / git 身份就绪后的补报入口)。
|
|
327
|
+
* 无身份返回 skipped 零请求;任何失败均不抛。
|
|
328
|
+
*
|
|
329
|
+
* @param {object} [options]
|
|
330
|
+
* @param {string} [options.homeDir] — 测试注入
|
|
331
|
+
* @param {string} [options.projectRoot] — kb-state.json 查找目录,缺省 process.cwd()
|
|
332
|
+
* @returns {Promise<{submitted:number,enqueued:boolean,skipped:boolean}>}
|
|
333
|
+
*/
|
|
334
|
+
async function flushPendingUsage(options = {}) {
|
|
335
|
+
const result = { submitted: 0, enqueued: false, skipped: false };
|
|
336
|
+
try {
|
|
337
|
+
const identity = readGitIdentity(options.homeDir);
|
|
338
|
+
if (!identity) {
|
|
339
|
+
result.skipped = true;
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
const pendingFile = pendingPathFor(options.homeDir);
|
|
343
|
+
const { events: pendingEvents } = readPending(pendingFile);
|
|
344
|
+
if (pendingEvents.length === 0) return result;
|
|
345
|
+
const server = resolveReportServer({ homeDir: options.homeDir, projectRoot: options.projectRoot });
|
|
346
|
+
const flushed = await flushPendingQueue(server, identity, pendingFile);
|
|
347
|
+
result.submitted = flushed.submitted;
|
|
348
|
+
return result;
|
|
349
|
+
} catch {
|
|
350
|
+
// 兜底:永不抛到调用方
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 上报一次使用事件(主入口)。任何失败均不抛。
|
|
357
|
+
*
|
|
358
|
+
* @param {object} options
|
|
359
|
+
* @param {string} options.skill — 七值之一;非七值立即返回
|
|
360
|
+
* @param {string} [options.startedAt] — ISO8601;缺省取当前
|
|
361
|
+
* @param {string} [options.homeDir] — 测试注入
|
|
362
|
+
* @param {string} [options.projectRoot] — kb-state.json 查找目录,缺省 process.cwd()
|
|
363
|
+
* @returns {Promise<{submitted:number,enqueued:boolean,skipped:boolean}>}
|
|
364
|
+
*/
|
|
365
|
+
async function reportUsageOnce(options = {}) {
|
|
366
|
+
const result = { submitted: 0, enqueued: false, skipped: false };
|
|
367
|
+
const skill = mapCommandToUsageSkill(options.skill);
|
|
368
|
+
if (!skill) {
|
|
369
|
+
result.skipped = true;
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const startedAt = typeof options.startedAt === 'string' && options.startedAt
|
|
374
|
+
? options.startedAt
|
|
375
|
+
: new Date().toISOString();
|
|
376
|
+
const currentEvent = { skill, startedAt, id: generateEventId() };
|
|
377
|
+
const pendingFile = pendingPathFor(options.homeDir);
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
// 无 git 身份:当前事件入队(无身份字段,与遗留事件同构),本次冲刷整体跳过、
|
|
381
|
+
// 队列原有内容保留;身份就绪后由后续 flush 注入身份补报(慢 git 环境不静默丢失)。
|
|
382
|
+
const identity = readGitIdentity(options.homeDir);
|
|
383
|
+
if (!identity) {
|
|
384
|
+
try {
|
|
385
|
+
appendPending(currentEvent, pendingFile);
|
|
386
|
+
result.enqueued = true;
|
|
387
|
+
} catch { /* append 失败静默,不改退出码 */ }
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
const server = resolveReportServer({ homeDir: options.homeDir, projectRoot: options.projectRoot });
|
|
391
|
+
|
|
392
|
+
const MAX_TIMEOUT_SKEW_MS = 500; // 调度容差
|
|
393
|
+
// 共享 3s deadline:冲刷与当前事件共用同一预算
|
|
394
|
+
const deadline = Date.now() + SHARED_DEADLINE_MS;
|
|
395
|
+
|
|
396
|
+
// 1. 先冲刷 pending(最多 100 条;retry 提前终止时剩余保留)
|
|
397
|
+
const flushOutcome = await flushPendingQueue(server, identity, pendingFile, deadline);
|
|
398
|
+
result.submitted += flushOutcome.submitted;
|
|
399
|
+
// 冲刷提前失败判定:零提交、零 drop,且队列仍有残留(retry 语义)
|
|
400
|
+
const failedEarly = flushOutcome.submitted === 0
|
|
401
|
+
&& flushOutcome.confirmedIds.size === 0
|
|
402
|
+
&& flushOutcome.droppedIds.size === 0
|
|
403
|
+
&& readPending(pendingFile).events.length > 0;
|
|
404
|
+
|
|
405
|
+
// 2. 发当前事件(若仍有预算且未提前失败)
|
|
406
|
+
// flush 提前失败时当前事件必须入队,不得静默丢弃。
|
|
407
|
+
let currentOutcome = 'pending'; // 'confirmed' | 'enqueued' | 'dropped' | 'pending'
|
|
408
|
+
if (failedEarly) {
|
|
409
|
+
currentOutcome = 'enqueued';
|
|
410
|
+
} else {
|
|
411
|
+
const remaining = deadline - Date.now();
|
|
412
|
+
if (remaining > MAX_TIMEOUT_SKEW_MS) {
|
|
413
|
+
let classification;
|
|
414
|
+
try {
|
|
415
|
+
classification = await submitOne(server, identity, currentEvent, remaining);
|
|
416
|
+
} catch {
|
|
417
|
+
classification = 'retry';
|
|
418
|
+
}
|
|
419
|
+
if (classification === 'confirmed') {
|
|
420
|
+
result.submitted += 1;
|
|
421
|
+
currentOutcome = 'confirmed';
|
|
422
|
+
} else if (classification === 'drop') {
|
|
423
|
+
// 当前事件 drop(含 401)直接丢弃不入队,不清任何本地状态
|
|
424
|
+
currentOutcome = 'dropped';
|
|
425
|
+
} else {
|
|
426
|
+
currentOutcome = 'enqueued';
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
currentOutcome = 'enqueued';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 3. append 当前事件(若需要)
|
|
434
|
+
try {
|
|
435
|
+
if (currentOutcome === 'enqueued') {
|
|
436
|
+
appendPending(currentEvent, pendingFile);
|
|
437
|
+
result.enqueued = true;
|
|
438
|
+
}
|
|
439
|
+
} catch { /* ignore */ }
|
|
440
|
+
|
|
441
|
+
return result;
|
|
442
|
+
} catch {
|
|
443
|
+
// 兜底:永不抛到调用方
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
module.exports = {
|
|
449
|
+
reportUsageOnce,
|
|
450
|
+
flushPendingUsage,
|
|
451
|
+
appendPending,
|
|
452
|
+
readPending,
|
|
453
|
+
readGitIdentity,
|
|
454
|
+
resolveReportServer,
|
|
455
|
+
_resetGitIdentityCache,
|
|
456
|
+
PENDING_FILE_NAME,
|
|
457
|
+
PENDING_MAX_BYTES,
|
|
458
|
+
FLUSH_MAX_COUNT,
|
|
459
|
+
SHARED_DEADLINE_MS,
|
|
460
|
+
};
|