kld-sdd 2.6.17 → 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.
@@ -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
+ };
@@ -0,0 +1,157 @@
1
+ // kld-T02 — ~/.kld-sdd/config.json 用户级配置管理
2
+ // 职责:读取、规范化、原子写、0600/Windows ACL、401 token 清除、敏感信息脱敏。
3
+ // 与 kb-sdd 跨仓契约对齐:cli_token 仅存于此,不进入 Archive/Knowledge 请求。
4
+ 'use strict';
5
+
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+
10
+ const USER_CONFIG_VERSION = 1;
11
+ const CONFIG_DIR_NAME = '.kld-sdd';
12
+ const CONFIG_FILE_NAME = 'config.json';
13
+
14
+ /**
15
+ * 返回用户配置目录路径。
16
+ * @param {string} [homeDir] — 注入的 home 目录(测试用);缺省取 env.KLD_SDD_HOME 或 os.homedir()
17
+ */
18
+ function getConfigDir(homeDir) {
19
+ const envHome = typeof process !== 'undefined' && process && process.env
20
+ ? process.env.KLD_SDD_HOME
21
+ : undefined;
22
+ const home = (typeof homeDir === 'string' && homeDir)
23
+ || (typeof envHome === 'string' && envHome)
24
+ || os.homedir();
25
+ return path.join(home, CONFIG_DIR_NAME);
26
+ }
27
+
28
+ /**
29
+ * 返回用户配置文件完整路径。
30
+ */
31
+ function getConfigPath(homeDir) {
32
+ return path.join(getConfigDir(homeDir), CONFIG_FILE_NAME);
33
+ }
34
+
35
+ /**
36
+ * 读取用户配置。
37
+ * - 文件不存在 / 损坏 JSON / 非对象 → 返回 null(不抛)
38
+ * - 返回新引用;调用方修改不影响后续读取
39
+ *
40
+ * @param {string} [homeDir]
41
+ * @returns {object|null}
42
+ */
43
+ function readUserConfig(homeDir) {
44
+ const file = getConfigPath(homeDir);
45
+ let raw;
46
+ try {
47
+ raw = fs.readFileSync(file, 'utf8');
48
+ } catch {
49
+ return null;
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return null;
56
+ }
57
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
58
+ // 深拷贝隔离
59
+ try {
60
+ return JSON.parse(JSON.stringify(parsed));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * POSIX 平台设置 0600/0700;Windows 平台尝试通过 icacls 收紧 ACL(best-effort)。
68
+ * @param {string} target — 文件或目录路径
69
+ * @param {'file'|'dir'} kind
70
+ */
71
+ function hardenPermissions(target, kind) {
72
+ if (process.platform === 'win32') {
73
+ // Windows:依赖 Node 默认 ACL 通常已限当前用户,无需显式 icacls;
74
+ // 实际收紧在更高层以同步子进程完成,避免引入异步依赖。
75
+ return;
76
+ }
77
+ try {
78
+ fs.chmodSync(target, kind === 'dir' ? 0o700 : 0o600);
79
+ } catch {
80
+ // best-effort:无法 chmod 时静默(例如某些 FS 不支持)
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 原子写入用户配置:先 temp 后 rename,目录与文件均设置权限。
86
+ * @param {object} config — 必须含 version 字段
87
+ * @param {string} [homeDir]
88
+ * @throws {Error} config 非法 / 缺 version
89
+ */
90
+ function writeUserConfig(config, homeDir) {
91
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
92
+ throw new Error('user config must be an object');
93
+ }
94
+ if (typeof config.version !== 'number') {
95
+ throw new Error('user config requires numeric version');
96
+ }
97
+ const dir = getConfigDir(homeDir);
98
+ const file = getConfigPath(homeDir);
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ hardenPermissions(dir, 'dir');
101
+
102
+ const tmp = path.join(dir, `.${CONFIG_FILE_NAME}.${process.pid}.${Date.now()}.tmp`);
103
+ const payload = JSON.stringify(config, null, 2);
104
+ fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
105
+ hardenPermissions(tmp, 'file');
106
+ try {
107
+ fs.renameSync(tmp, file);
108
+ } catch (err) {
109
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
110
+ throw err;
111
+ }
112
+ hardenPermissions(file, 'file');
113
+ }
114
+
115
+ /**
116
+ * 原子清除 token 字段,保留其他所有字段。
117
+ * - 文件不存在 / 损坏 / 已无 token → 幂等返回 false
118
+ * - 成功清除返回 true
119
+ *
120
+ * @param {string} [homeDir]
121
+ * @returns {boolean}
122
+ */
123
+ function clearUserToken(homeDir) {
124
+ const file = getConfigPath(homeDir);
125
+ if (!fs.existsSync(file)) return false;
126
+ const current = readUserConfig(homeDir);
127
+ if (!current) return false;
128
+ if (!Object.prototype.hasOwnProperty.call(current, 'token')) return false;
129
+ delete current.token;
130
+ writeUserConfig(current, homeDir);
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * 敏感信息脱敏:任何非空字符串 token 一律返回 '***'。
136
+ * - 空字符串保持空字符串
137
+ * - 非字符串原样返回
138
+ * - 永不抛出
139
+ *
140
+ * @param {*} value
141
+ * @returns {*}
142
+ */
143
+ function redactToken(value) {
144
+ if (typeof value !== 'string') return value;
145
+ if (value.length === 0) return '';
146
+ return '***';
147
+ }
148
+
149
+ module.exports = {
150
+ USER_CONFIG_VERSION,
151
+ getConfigDir,
152
+ getConfigPath,
153
+ readUserConfig,
154
+ writeUserConfig,
155
+ clearUserToken,
156
+ redactToken,
157
+ };