kld-sdd 2.6.16 → 2.6.21
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 +10 -0
- package/kld-sdd-guide.html +22 -0
- package/lib/device-auth-cli.js +130 -0
- package/lib/device-auth.js +345 -0
- package/lib/init-account-binding.js +108 -0
- package/lib/init.js +238 -55
- package/lib/skills-bundle.js +20 -1
- package/lib/tool-profiles.js +8 -0
- package/package.json +7 -3
- package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
- package/skywalk-sdd/context-client.cjs +38 -87
- package/skywalk-sdd/index.cjs +860 -132
- package/skywalk-sdd/kb-sync-identity.cjs +780 -0
- package/skywalk-sdd/kb-upload.cjs +505 -0
- package/skywalk-sdd/lib/shared.cjs +811 -0
- package/skywalk-sdd/lib/usage-contract.cjs +276 -0
- package/skywalk-sdd/lib/usage-reporter.cjs +354 -0
- package/skywalk-sdd/lib/user-config.cjs +157 -0
- package/skywalk-sdd/metrics-v3.cjs +138 -8
- package/skywalk-sdd/ontology/archive-package.cjs +19 -34
- package/skywalk-sdd/ontology/change-lock.cjs +3 -7
- package/skywalk-sdd/ontology/external-key.cjs +18 -4
- package/skywalk-sdd/ontology/id.cjs +26 -5
- package/skywalk-sdd/ontology/identity-index.cjs +3 -7
- package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
- package/skywalk-sdd/ontology/runtime.cjs +16 -12
- package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
- package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
- package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
- package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
- package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
- package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
- package/skywalk-sdd/spec-root.cjs +31 -0
- package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
- package/templates/git-hooks/pre-push-consistency-check.cjs +252 -123
- package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +327 -0
- package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +54 -9
- package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +63 -6
- package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +113 -65
- package/templates/openspec/proposal.md +7 -3
- package/templates/openspec/spec.md +3 -3
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +8 -6
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-apply/reference.md +29 -7
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +49 -17
- package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
- 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-design/SKILL.md +2 -2
- package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +185 -0
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +127 -0
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
- package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
- package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
- package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
- package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
- package/templates/skills/kld-sdd/opsx-ontology-query/state.example.json +0 -7
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// kld-T01 — CLI 使用统计上报契约
|
|
2
|
+
// 集中定义:七值 skill 枚举、Device Flow 信封、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
|
+
* 七值使用统计 skill 枚举。
|
|
11
|
+
* test / explore 不属于统计范围,永不映射。
|
|
12
|
+
*/
|
|
13
|
+
const USAGE_SKILLS = Object.freeze([
|
|
14
|
+
'propose',
|
|
15
|
+
'spec',
|
|
16
|
+
'design',
|
|
17
|
+
'task',
|
|
18
|
+
'check',
|
|
19
|
+
'apply',
|
|
20
|
+
'archive',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const USAGE_SKILL_SET = new Set(USAGE_SKILLS);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 将 opsx 命令映射到使用统计 skill。
|
|
27
|
+
* @param {string} command
|
|
28
|
+
* @returns {string|null} 七值之一;非七值返回 null
|
|
29
|
+
*/
|
|
30
|
+
function mapCommandToUsageSkill(command) {
|
|
31
|
+
if (typeof command !== 'string' || !command) return null;
|
|
32
|
+
return USAGE_SKILL_SET.has(command) ? command : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 规范化 server URL:只接受 http/https,去尾斜杠,去首尾空白。
|
|
37
|
+
* @param {string} raw
|
|
38
|
+
* @returns {string|null} 规范化后的 base URL;非法返回 null
|
|
39
|
+
*/
|
|
40
|
+
function normalizeServerUrl(raw) {
|
|
41
|
+
if (typeof raw !== 'string') return null;
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (!trimmed) return null;
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = new URL(trimmed);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
51
|
+
// 重组:protocol + // + host + pathname(去尾斜杠),忽略 search/hash
|
|
52
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
53
|
+
return `${parsed.protocol}//${parsed.host}${path}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 解开 ApiResponse 信封:{ code, message, data } → data;code 非 0 抛错。
|
|
58
|
+
* @param {object} body — 已 JSON.parse 的响应体
|
|
59
|
+
* @returns {*} data 字段内容
|
|
60
|
+
* @throws {Error} code 非 0、非对象、缺 code 字段
|
|
61
|
+
*/
|
|
62
|
+
function unwrapApiResponse(body) {
|
|
63
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
64
|
+
throw new Error('ApiResponse must be an object');
|
|
65
|
+
}
|
|
66
|
+
if (typeof body.code !== 'number') {
|
|
67
|
+
throw new Error('ApiResponse missing numeric code');
|
|
68
|
+
}
|
|
69
|
+
if (body.code !== 0) {
|
|
70
|
+
const msg = typeof body.message === 'string' && body.message
|
|
71
|
+
? body.message
|
|
72
|
+
: `server error code ${body.code}`;
|
|
73
|
+
throw new Error(msg);
|
|
74
|
+
}
|
|
75
|
+
return body.data === undefined ? null : body.data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 解析 Device Flow code 响应。
|
|
80
|
+
* kb-sdd 契约:{ deviceCode, userCode, verificationUri, verificationUriComplete?, expiresIn?, interval? }
|
|
81
|
+
* @param {object} body
|
|
82
|
+
* @returns {{deviceCode:string,userCode:string,verificationUri:string,verificationUriComplete:string|null,expiresIn:number,interval:number}|null}
|
|
83
|
+
*/
|
|
84
|
+
function parseDeviceCodeResponse(body) {
|
|
85
|
+
if (!body || typeof body !== 'object') return null;
|
|
86
|
+
if (typeof body.deviceCode !== 'string' || !body.deviceCode) return null;
|
|
87
|
+
if (typeof body.userCode !== 'string' || !body.userCode) return null;
|
|
88
|
+
if (typeof body.verificationUri !== 'string' || !body.verificationUri) return null;
|
|
89
|
+
const expiresIn = Number.isFinite(body.expiresIn) && body.expiresIn > 0
|
|
90
|
+
? Math.floor(body.expiresIn)
|
|
91
|
+
: 600;
|
|
92
|
+
const interval = Number.isFinite(body.interval) && body.interval > 0
|
|
93
|
+
? Math.floor(body.interval)
|
|
94
|
+
: 2;
|
|
95
|
+
return {
|
|
96
|
+
deviceCode: body.deviceCode,
|
|
97
|
+
userCode: body.userCode,
|
|
98
|
+
verificationUri: body.verificationUri,
|
|
99
|
+
verificationUriComplete: typeof body.verificationUriComplete === 'string' && body.verificationUriComplete
|
|
100
|
+
? body.verificationUriComplete
|
|
101
|
+
: null,
|
|
102
|
+
expiresIn,
|
|
103
|
+
interval,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 分类 Device Flow poll 响应。
|
|
109
|
+
* kb-sdd 主契约:{ status: 'pending'|'authorized'|'expired'|'denied', token?, user? }
|
|
110
|
+
* 兼容 RFC 8628:{ error: 'authorization_pending'|'slow_down'|'expired_token'|'access_denied' }
|
|
111
|
+
*
|
|
112
|
+
* @param {number|null} statusCode — HTTP 状态码;网络错误为 null
|
|
113
|
+
* @param {object|null} body — 已解析 JSON;解析失败为 null
|
|
114
|
+
* @returns {{kind:'authorized',token:string,user:*}|{kind:'pending'}|{kind:'slow_down'}|{kind:'expired'}|{kind:'denied'}|{kind:'error',message?:string}}
|
|
115
|
+
*/
|
|
116
|
+
function classifyDevicePollResponse(statusCode, body) {
|
|
117
|
+
const payload = body && typeof body === 'object' ? body : {};
|
|
118
|
+
// kb-sdd 主契约:status 字段
|
|
119
|
+
if (typeof payload.status === 'string') {
|
|
120
|
+
const s = payload.status;
|
|
121
|
+
if (s === 'authorized') {
|
|
122
|
+
if (typeof payload.token === 'string' && payload.token) {
|
|
123
|
+
return { kind: 'authorized', token: payload.token, user: payload.user === undefined ? null : payload.user };
|
|
124
|
+
}
|
|
125
|
+
return { kind: 'error', message: 'authorized but missing token' };
|
|
126
|
+
}
|
|
127
|
+
if (s === 'pending') return { kind: 'pending' };
|
|
128
|
+
if (s === 'expired') return { kind: 'expired' };
|
|
129
|
+
if (s === 'denied') return { kind: 'denied' };
|
|
130
|
+
return { kind: 'error', message: `unknown status: ${s}` };
|
|
131
|
+
}
|
|
132
|
+
// RFC 8628 兼容:error 字段
|
|
133
|
+
if (typeof payload.error === 'string') {
|
|
134
|
+
const e = payload.error;
|
|
135
|
+
if (e === 'authorization_pending') return { kind: 'pending' };
|
|
136
|
+
if (e === 'slow_down') return { kind: 'slow_down' };
|
|
137
|
+
if (e === 'expired_token') return { kind: 'expired' };
|
|
138
|
+
if (e === 'access_denied') return { kind: 'denied' };
|
|
139
|
+
return { kind: 'error', message: e };
|
|
140
|
+
}
|
|
141
|
+
// 无识别字段:网络错误或 HTTP 异常
|
|
142
|
+
if (statusCode == null) return { kind: 'error', message: 'network error' };
|
|
143
|
+
if (statusCode >= 400) return { kind: 'error', message: `HTTP ${statusCode}` };
|
|
144
|
+
return { kind: 'error', message: 'unrecognized response' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 分类 usage 上报响应。
|
|
149
|
+
* - confirmed: 2xx/204,服务端已确认,可从 pending 队列移除
|
|
150
|
+
* - unauthorized: 401,token 失效;调用方应原子删除本地 token 并提示一次
|
|
151
|
+
* - retry: 网络错误 / 429 / 5xx / timeout;保留在 pending 队列
|
|
152
|
+
* - drop: 其他 4xx;不再重试但不上抛
|
|
153
|
+
*
|
|
154
|
+
* @param {number|null} statusCode
|
|
155
|
+
* @returns {'confirmed'|'unauthorized'|'retry'|'drop'}
|
|
156
|
+
*/
|
|
157
|
+
function classifyUsageSubmitResponse(statusCode) {
|
|
158
|
+
if (statusCode == null || statusCode === 0) return 'retry';
|
|
159
|
+
if (statusCode >= 200 && statusCode < 300) return 'confirmed';
|
|
160
|
+
if (statusCode === 401) return 'unauthorized';
|
|
161
|
+
if (statusCode === 429) return 'retry';
|
|
162
|
+
if (statusCode >= 500) return 'retry';
|
|
163
|
+
return 'drop';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Node 14 兼容的 POST JSON 实现。
|
|
168
|
+
* - 仅用核心 http/https,不依赖全局 fetch
|
|
169
|
+
* - 任何网络/解析错误均不抛,返回 statusCode=null 让调用方分类
|
|
170
|
+
* - 4xx/5xx 不抛错,返回 statusCode + body 供分类
|
|
171
|
+
*
|
|
172
|
+
* @param {string} url — 完整 URL(必须 http/https)
|
|
173
|
+
* @param {object} payload — 请求体(JSON 序列化)
|
|
174
|
+
* @param {{token?:string,timeoutMs?:number,headers?:object}} [options]
|
|
175
|
+
* @returns {Promise<{statusCode:number|null,body:*,data:*,error:*}>}
|
|
176
|
+
* statusCode: HTTP 状态码,网络错误为 null
|
|
177
|
+
* body: 原始解析后的 JSON 对象(如可解析)
|
|
178
|
+
* data: 若为 ApiResponse 信封且 code=0,则为解包后的 data;否则为 undefined
|
|
179
|
+
* error: 仅在 statusCode=null 时携带 Error;否则为 null
|
|
180
|
+
*/
|
|
181
|
+
function postJson(url, payload, options = {}) {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
let target;
|
|
184
|
+
try {
|
|
185
|
+
target = new URL(url);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
reject(new Error(`invalid url: ${err.message}`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
191
|
+
reject(new Error(`only http/https supported, got ${target.protocol}`));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const transport = target.protocol === 'https:' ? https : http;
|
|
195
|
+
const token = options && typeof options.token === 'string' && options.token ? options.token : null;
|
|
196
|
+
const timeoutMs = Number.isFinite(options && options.timeoutMs) && options.timeoutMs > 0
|
|
197
|
+
? Math.floor(options.timeoutMs)
|
|
198
|
+
: 30000;
|
|
199
|
+
|
|
200
|
+
let body;
|
|
201
|
+
try {
|
|
202
|
+
body = JSON.stringify(payload === undefined ? {} : payload);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
reject(new Error(`payload serialization error: ${err.message}`));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const headers = Object.assign(
|
|
209
|
+
{
|
|
210
|
+
Accept: 'application/json',
|
|
211
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
212
|
+
'Content-Length': Buffer.byteLength(body),
|
|
213
|
+
},
|
|
214
|
+
options && options.headers && typeof options.headers === 'object' ? options.headers : {},
|
|
215
|
+
);
|
|
216
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
217
|
+
|
|
218
|
+
const request = transport.request(
|
|
219
|
+
{
|
|
220
|
+
hostname: target.hostname,
|
|
221
|
+
port: target.port || (target.protocol === 'https:' ? 443 : 80),
|
|
222
|
+
path: target.pathname + target.search,
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers,
|
|
225
|
+
timeout: timeoutMs,
|
|
226
|
+
},
|
|
227
|
+
(response) => {
|
|
228
|
+
const chunks = [];
|
|
229
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
230
|
+
response.on('end', () => {
|
|
231
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
232
|
+
let parsedBody = null;
|
|
233
|
+
if (raw) {
|
|
234
|
+
try {
|
|
235
|
+
parsedBody = JSON.parse(raw);
|
|
236
|
+
} catch {
|
|
237
|
+
parsedBody = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
let data;
|
|
241
|
+
if (parsedBody && typeof parsedBody === 'object' && parsedBody.code === 0) {
|
|
242
|
+
try {
|
|
243
|
+
data = unwrapApiResponse(parsedBody);
|
|
244
|
+
} catch {
|
|
245
|
+
data = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
resolve({
|
|
249
|
+
statusCode: response.statusCode == null ? null : response.statusCode,
|
|
250
|
+
body: parsedBody,
|
|
251
|
+
data,
|
|
252
|
+
error: null,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
request.on('timeout', () => {
|
|
258
|
+
request.destroy(new Error(`request timeout after ${timeoutMs}ms`));
|
|
259
|
+
});
|
|
260
|
+
request.on('error', (err) => {
|
|
261
|
+
resolve({ statusCode: null, body: null, data: undefined, error: err });
|
|
262
|
+
});
|
|
263
|
+
request.end(body);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
USAGE_SKILLS,
|
|
269
|
+
mapCommandToUsageSkill,
|
|
270
|
+
normalizeServerUrl,
|
|
271
|
+
unwrapApiResponse,
|
|
272
|
+
parseDeviceCodeResponse,
|
|
273
|
+
classifyDevicePollResponse,
|
|
274
|
+
classifyUsageSubmitResponse,
|
|
275
|
+
postJson,
|
|
276
|
+
};
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// kld-T04 — 使用事件 reporter(kld-fixback RED#2/#4/#9 重构版)
|
|
2
|
+
// 职责:单一 3s deadline;pending JSONL 原子追加;100 条上限;1MB 淘汰;
|
|
3
|
+
// HTTP 状态分类(confirmed/retry/unauthorized/drop);401 原子清除 token;
|
|
4
|
+
// 并发安全(appendFileSync O_APPEND + 事件 id 差集提交);不改原业务退出码。
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
normalizeServerUrl,
|
|
13
|
+
classifyUsageSubmitResponse,
|
|
14
|
+
postJson,
|
|
15
|
+
mapCommandToUsageSkill,
|
|
16
|
+
} = require('./usage-contract.cjs');
|
|
17
|
+
const {
|
|
18
|
+
readUserConfig,
|
|
19
|
+
clearUserToken,
|
|
20
|
+
} = require('./user-config.cjs');
|
|
21
|
+
|
|
22
|
+
const PENDING_FILE_NAME = 'pending-usage.jsonl';
|
|
23
|
+
const PENDING_MAX_BYTES = 1024 * 1024; // 1 MB
|
|
24
|
+
const FLUSH_MAX_COUNT = 100;
|
|
25
|
+
const SHARED_DEADLINE_MS = 3000;
|
|
26
|
+
// 单行事件 JSON 的安全上限(小于 4KB 以保证 appendFileSync 在 POSIX/Windows 都原子)
|
|
27
|
+
const SINGLE_EVENT_MAX_BYTES = 4096;
|
|
28
|
+
|
|
29
|
+
function pendingPathFor(homeDir) {
|
|
30
|
+
const envHome = typeof process !== 'undefined' && process && process.env
|
|
31
|
+
? process.env.KLD_SDD_HOME
|
|
32
|
+
: undefined;
|
|
33
|
+
const home = homeDir || envHome || require('node:os').homedir();
|
|
34
|
+
return path.join(home, '.kld-sdd', PENDING_FILE_NAME);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 生成事件唯一 id(基于时间戳 + pid + 随机字节)。
|
|
39
|
+
* 用于 flush 提交时按 id 差集删除已确认行,避免 read-modify-write 覆盖并发 append。
|
|
40
|
+
*/
|
|
41
|
+
function generateEventId() {
|
|
42
|
+
return `ue_${Date.now().toString(36)}_${process.pid.toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 为没有 id 字段的旧事件规范化一个 id(基于内容 hash)。
|
|
47
|
+
* 同一行多次读出会得到同一个 id,使 flush 差集提交对旧 fixture 兼容。
|
|
48
|
+
*/
|
|
49
|
+
function ensureEventId(event) {
|
|
50
|
+
if (event && typeof event === 'object') {
|
|
51
|
+
if (typeof event.id === 'string' && event.id) return event;
|
|
52
|
+
const hash = crypto
|
|
53
|
+
.createHash('sha1')
|
|
54
|
+
.update(JSON.stringify(event))
|
|
55
|
+
.digest('hex')
|
|
56
|
+
.slice(0, 12);
|
|
57
|
+
return Object.assign({}, event, { id: `legacy_${hash}` });
|
|
58
|
+
}
|
|
59
|
+
return event;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读取 pending 队列。
|
|
64
|
+
* - 尾部半行 / 损坏行被隔离:跳过但不污染合法记录
|
|
65
|
+
* - 没有 id 的旧事件会被规范化一个内容 hash id
|
|
66
|
+
* - 返回 { events, corrupted }
|
|
67
|
+
*/
|
|
68
|
+
function readPending(filePath) {
|
|
69
|
+
if (!fs.existsSync(filePath)) return { events: [], corrupted: 0 };
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
73
|
+
} catch {
|
|
74
|
+
return { events: [], corrupted: 0 };
|
|
75
|
+
}
|
|
76
|
+
if (!raw) return { events: [], corrupted: 0 };
|
|
77
|
+
const lines = raw.split('\n');
|
|
78
|
+
const events = [];
|
|
79
|
+
let corrupted = 0;
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (!trimmed) continue;
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(trimmed);
|
|
85
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.skill === 'string') {
|
|
86
|
+
events.push(ensureEventId(parsed));
|
|
87
|
+
} else {
|
|
88
|
+
corrupted += 1;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
corrupted += 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { events, corrupted };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 原子重写 pending 文件:先 temp 后 rename。
|
|
99
|
+
* 仅在 flush 提交时调用;并发 append 的新行必须在重写前重新读入并按 id 保留。
|
|
100
|
+
*/
|
|
101
|
+
function writePendingAtomic(filePath, events) {
|
|
102
|
+
const dir = path.dirname(filePath);
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
if (!events || events.length === 0) {
|
|
105
|
+
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
109
|
+
const payload = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
110
|
+
fs.writeFileSync(tmp, payload, 'utf8');
|
|
111
|
+
try {
|
|
112
|
+
fs.renameSync(tmp, filePath);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 追加事件到 pending 队列。
|
|
121
|
+
* - 用 fs.appendFileSync 单行追加:POSIX O_APPEND 对小行原子;Windows 对 <4KB append 也原子
|
|
122
|
+
* - 超 1MB 时先重读并按需淘汰最旧完整行(O(n):先算总大小,每次 shift 只减对应字节)
|
|
123
|
+
* - 事件自动补 id(如未含)
|
|
124
|
+
*/
|
|
125
|
+
function appendPending(event, filePath) {
|
|
126
|
+
if (!event || typeof event !== 'object') return;
|
|
127
|
+
const target = filePath || pendingPathFor();
|
|
128
|
+
const dir = path.dirname(target);
|
|
129
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
130
|
+
|
|
131
|
+
// 自动补 id(用于 flush 差集删除)
|
|
132
|
+
const toAppend = Object.assign({}, event);
|
|
133
|
+
if (typeof toAppend.id !== 'string' || !toAppend.id) {
|
|
134
|
+
toAppend.id = generateEventId();
|
|
135
|
+
}
|
|
136
|
+
const line = JSON.stringify(toAppend) + '\n';
|
|
137
|
+
const lineBytes = Buffer.byteLength(line, 'utf8');
|
|
138
|
+
if (lineBytes > SINGLE_EVENT_MAX_BYTES) {
|
|
139
|
+
// 防御:单行超 4KB 直接丢弃(不应发生;正常事件 <200 字节)
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 先检查追加后是否超 1MB;超则先淘汰
|
|
144
|
+
let existingSize = 0;
|
|
145
|
+
let existingEvents = [];
|
|
146
|
+
if (fs.existsSync(target)) {
|
|
147
|
+
const { events } = readPending(target);
|
|
148
|
+
existingEvents = events;
|
|
149
|
+
for (const e of events) {
|
|
150
|
+
existingSize += Buffer.byteLength(JSON.stringify(e), 'utf8') + 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (existingSize + lineBytes > PENDING_MAX_BYTES) {
|
|
154
|
+
// O(n) 淘汰:先算总大小,每次 shift 只减对应字节
|
|
155
|
+
let currentSize = existingSize;
|
|
156
|
+
let dropCount = 0;
|
|
157
|
+
while (dropCount < existingEvents.length && currentSize + lineBytes > PENDING_MAX_BYTES) {
|
|
158
|
+
const evicted = existingEvents[dropCount];
|
|
159
|
+
currentSize -= (Buffer.byteLength(JSON.stringify(evicted), 'utf8') + 1);
|
|
160
|
+
dropCount += 1;
|
|
161
|
+
}
|
|
162
|
+
if (dropCount > 0) {
|
|
163
|
+
const retained = existingEvents.slice(dropCount);
|
|
164
|
+
// 原子重写(淘汰后才需要 rewrite;此后 append 当前行)
|
|
165
|
+
writePendingAtomic(target, retained);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// 原子 append(POSIX O_APPEND;Windows 单行 <4KB 也原子)
|
|
169
|
+
try {
|
|
170
|
+
fs.appendFileSync(target, line, 'utf8');
|
|
171
|
+
} catch {
|
|
172
|
+
// append 失败时静默(不改原业务退出码)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 提交单条事件;返回分类。
|
|
178
|
+
* @returns {Promise<'confirmed'|'unauthorized'|'retry'|'drop'>}
|
|
179
|
+
*/
|
|
180
|
+
async function submitOne(server, token, event, timeoutMs) {
|
|
181
|
+
// 提交时剔除内部 id(不上传给服务端)
|
|
182
|
+
const outgoing = Object.assign({}, event);
|
|
183
|
+
delete outgoing.id;
|
|
184
|
+
const result = await postJson(
|
|
185
|
+
`${server}/api/v1/usage-events`,
|
|
186
|
+
outgoing,
|
|
187
|
+
{ token, timeoutMs },
|
|
188
|
+
);
|
|
189
|
+
return classifyUsageSubmitResponse(result.statusCode);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 上报一次使用事件(主入口)。任何失败均不抛。
|
|
194
|
+
*
|
|
195
|
+
* @param {object} options
|
|
196
|
+
* @param {string} options.skill — 七值之一;非七值立即返回
|
|
197
|
+
* @param {string} [options.startedAt] — ISO8601;缺省取当前
|
|
198
|
+
* @param {string} [options.homeDir] — 测试注入
|
|
199
|
+
* @param {Function} [options.onNotify] — 401 等用户提示
|
|
200
|
+
* @returns {Promise<{submitted:number,enqueued:boolean,tokenCleared:boolean,skipped:boolean}>}
|
|
201
|
+
*/
|
|
202
|
+
async function reportUsageOnce(options = {}) {
|
|
203
|
+
const result = { submitted: 0, enqueued: false, tokenCleared: false, skipped: false };
|
|
204
|
+
const skill = mapCommandToUsageSkill(options.skill);
|
|
205
|
+
if (!skill) {
|
|
206
|
+
result.skipped = true;
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
const onNotify = typeof options.onNotify === 'function' ? options.onNotify : () => {};
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const config = readUserConfig(options.homeDir);
|
|
213
|
+
if (!config || typeof config.token !== 'string' || !config.token) {
|
|
214
|
+
result.skipped = true;
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
const server = normalizeServerUrl(config.server || '');
|
|
218
|
+
if (!server) {
|
|
219
|
+
result.skipped = true;
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
const token = config.token;
|
|
223
|
+
|
|
224
|
+
const startedAt = typeof options.startedAt === 'string' && options.startedAt
|
|
225
|
+
? options.startedAt
|
|
226
|
+
: new Date().toISOString();
|
|
227
|
+
const currentEvent = { skill, startedAt, id: generateEventId() };
|
|
228
|
+
|
|
229
|
+
const pendingFile = pendingPathFor(options.homeDir);
|
|
230
|
+
const deadline = Date.now() + SHARED_DEADLINE_MS;
|
|
231
|
+
const MAX_TIMEOUT_SKEW_MS = 500; // 调度容差
|
|
232
|
+
|
|
233
|
+
// 1. 先冲刷 pending(最多 100 条)
|
|
234
|
+
const { events: pendingEvents } = readPending(pendingFile);
|
|
235
|
+
const confirmedIds = new Set(); // 已成功的事件 id
|
|
236
|
+
const droppedIds = new Set(); // 4xx 永久失败的事件 id
|
|
237
|
+
const failedEarly = { hit: false, kind: null }; // 提前终止标记(unauthorized/retry)
|
|
238
|
+
|
|
239
|
+
let flushed = 0;
|
|
240
|
+
for (let i = 0; i < pendingEvents.length; i++) {
|
|
241
|
+
const ev = pendingEvents[i];
|
|
242
|
+
const remaining = deadline - Date.now();
|
|
243
|
+
if (flushed >= FLUSH_MAX_COUNT || remaining <= 0) break;
|
|
244
|
+
|
|
245
|
+
const timeoutMs = Math.max(50, remaining);
|
|
246
|
+
let classification;
|
|
247
|
+
try {
|
|
248
|
+
classification = await submitOne(server, token, ev, timeoutMs);
|
|
249
|
+
} catch {
|
|
250
|
+
classification = 'retry';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (classification === 'confirmed') {
|
|
254
|
+
flushed += 1;
|
|
255
|
+
result.submitted += 1;
|
|
256
|
+
if (ev.id) confirmedIds.add(ev.id);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (classification === 'unauthorized') {
|
|
260
|
+
// 401:原子清除 token;本次及后续不再请求
|
|
261
|
+
try {
|
|
262
|
+
if (clearUserToken(options.homeDir)) {
|
|
263
|
+
result.tokenCleared = true;
|
|
264
|
+
onNotify('检测到平台凭证已失效,请运行 `kld-sdd auth login` 重新绑定。');
|
|
265
|
+
}
|
|
266
|
+
} catch { /* ignore */ }
|
|
267
|
+
failedEarly.hit = true;
|
|
268
|
+
failedEarly.kind = 'unauthorized';
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
if (classification === 'drop') {
|
|
272
|
+
// kld-fixback YELLOW#4: drop 丢弃该条,不 retained、不 break,继续下一条
|
|
273
|
+
if (ev.id) droppedIds.add(ev.id);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
// retry:网络/5xx/429/timeout,本次及后续保留
|
|
277
|
+
failedEarly.hit = true;
|
|
278
|
+
failedEarly.kind = 'retry';
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 2. 发当前事件(若仍有预算且未提前失败)
|
|
283
|
+
// kld-fixback-review#2 RED: flush 提前失败时当前事件必须入队(与 design
|
|
284
|
+
// 『网络/超时/429/5xx 将事件追加或保留在 pending 队列』一致),不得静默丢弃。
|
|
285
|
+
let currentOutcome = 'pending'; // 'confirmed' | 'enqueued' | 'dropped' | 'pending'
|
|
286
|
+
if (failedEarly.hit) {
|
|
287
|
+
currentOutcome = 'enqueued';
|
|
288
|
+
} else {
|
|
289
|
+
const remaining = deadline - Date.now();
|
|
290
|
+
if (remaining > MAX_TIMEOUT_SKEW_MS) {
|
|
291
|
+
let classification;
|
|
292
|
+
try {
|
|
293
|
+
classification = await submitOne(server, token, currentEvent, remaining);
|
|
294
|
+
} catch {
|
|
295
|
+
classification = 'retry';
|
|
296
|
+
}
|
|
297
|
+
if (classification === 'confirmed') {
|
|
298
|
+
result.submitted += 1;
|
|
299
|
+
currentOutcome = 'confirmed';
|
|
300
|
+
} else if (classification === 'unauthorized') {
|
|
301
|
+
try {
|
|
302
|
+
if (clearUserToken(options.homeDir)) {
|
|
303
|
+
result.tokenCleared = true;
|
|
304
|
+
onNotify('检测到平台凭证已失效,请运行 `kld-sdd auth login` 重新绑定。');
|
|
305
|
+
}
|
|
306
|
+
} catch { /* ignore */ }
|
|
307
|
+
currentOutcome = 'enqueued';
|
|
308
|
+
failedEarly.hit = true;
|
|
309
|
+
failedEarly.kind = 'unauthorized';
|
|
310
|
+
} else if (classification === 'drop') {
|
|
311
|
+
// kld-fixback YELLOW#4: 当前事件 drop 直接丢弃不入队
|
|
312
|
+
currentOutcome = 'dropped';
|
|
313
|
+
} else {
|
|
314
|
+
currentOutcome = 'enqueued';
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
currentOutcome = 'enqueued';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 3. 原子提交队列变化(按 id 差集删除已确认 + 已丢弃;append 当前事件若需要)
|
|
322
|
+
// 关键:rewrite 前必须重新读文件,把并发 append 的新行合并进来
|
|
323
|
+
try {
|
|
324
|
+
if (confirmedIds.size > 0 || droppedIds.size > 0) {
|
|
325
|
+
const { events: latestEvents } = readPending(pendingFile);
|
|
326
|
+
const retained = latestEvents.filter((e) => {
|
|
327
|
+
if (e.id && confirmedIds.has(e.id)) return false;
|
|
328
|
+
if (e.id && droppedIds.has(e.id)) return false;
|
|
329
|
+
return true;
|
|
330
|
+
});
|
|
331
|
+
writePendingAtomic(pendingFile, retained);
|
|
332
|
+
}
|
|
333
|
+
if (currentOutcome === 'enqueued') {
|
|
334
|
+
appendPending(currentEvent, pendingFile);
|
|
335
|
+
result.enqueued = true;
|
|
336
|
+
}
|
|
337
|
+
} catch { /* ignore */ }
|
|
338
|
+
|
|
339
|
+
return result;
|
|
340
|
+
} catch {
|
|
341
|
+
// 兜底:永不抛到调用方
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
reportUsageOnce,
|
|
348
|
+
appendPending,
|
|
349
|
+
readPending,
|
|
350
|
+
PENDING_FILE_NAME,
|
|
351
|
+
PENDING_MAX_BYTES,
|
|
352
|
+
FLUSH_MAX_COUNT,
|
|
353
|
+
SHARED_DEADLINE_MS,
|
|
354
|
+
};
|