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
package/skywalk-sdd/index.cjs
CHANGED
|
@@ -1375,30 +1375,35 @@ function appendEventUnlocked(dataDir, changeName, event) {
|
|
|
1375
1375
|
appendMarkdownLog(dataDir, changeName, event);
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
|
-
/** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
}
|
|
1378
|
+
/** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。
|
|
1379
|
+
* kld-T06:Progress Runtime 已删除,appendEvent 仅负责本地持久化,不再做事件富化、
|
|
1380
|
+
* agent 拉起、metadata refresh 或 artifact snapshot。
|
|
1381
|
+
*/
|
|
1382
|
+
function appendEvent(dataDir, changeName, event, options = {}) {
|
|
1383
|
+
if (options.lockHeld) {
|
|
1384
|
+
appendEventUnlocked(dataDir, changeName, event);
|
|
1385
|
+
return { idempotent: false, event };
|
|
1386
|
+
}
|
|
1387
|
+
const result = withEventWriteLock(dataDir, changeName, () => {
|
|
1388
|
+
if (event?.type === 'stage_end') {
|
|
1389
|
+
const existing = readAllEventEndsById(dataDir, event.event_id, changeName);
|
|
1390
|
+
if (existing.length > 0) {
|
|
1391
|
+
const latest = existing.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
|
|
1392
|
+
if (latest.result !== event.result) {
|
|
1393
|
+
const error = new Error(
|
|
1394
|
+
`E_STAGE_END_CONFLICT: event_id "${event.event_id}" 已记录 result=${latest.result},不能改为 ${event.result}`,
|
|
1395
|
+
);
|
|
1396
|
+
error.code = 'E_STAGE_END_CONFLICT';
|
|
1397
|
+
throw error;
|
|
1398
|
+
}
|
|
1399
|
+
return { idempotent: true, event: latest };
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
appendEventUnlocked(dataDir, changeName, event);
|
|
1403
|
+
return { idempotent: false, event };
|
|
1404
|
+
});
|
|
1405
|
+
return result;
|
|
1406
|
+
}
|
|
1402
1407
|
|
|
1403
1408
|
/** 把 ISO 时间戳格式化为本地时区 `YYYY-MM-DD HH:mm:ss (UTC±HH:MM)`。
|
|
1404
1409
|
* execution-log 面向人读,统一用本地时区 + 显式偏移标注,避免 UTC 裸值与用户体感差 8 小时(N9)。 */
|
|
@@ -5385,6 +5390,20 @@ function cmdStart(args) {
|
|
|
5385
5390
|
appendEvent(dataDir, event.change, event);
|
|
5386
5391
|
writeActiveStage(projectRoot, event);
|
|
5387
5392
|
|
|
5393
|
+
// kld-T05: 七值命令成功写入 stage_start 后,异步上报使用事件。
|
|
5394
|
+
// test/explore 与失败路径不上报;上报失败不影响本地事件与退出码。
|
|
5395
|
+
const usagePromise = (() => {
|
|
5396
|
+
try {
|
|
5397
|
+
const { mapCommandToUsageSkill } = require('./lib/usage-contract.cjs');
|
|
5398
|
+
const skill = mapCommandToUsageSkill(command);
|
|
5399
|
+
if (!skill) return null;
|
|
5400
|
+
const { reportUsageOnce } = require('./lib/usage-reporter.cjs');
|
|
5401
|
+
return reportUsageOnce({ skill, startedAt: timestamp }).catch(() => null);
|
|
5402
|
+
} catch {
|
|
5403
|
+
return null;
|
|
5404
|
+
}
|
|
5405
|
+
})();
|
|
5406
|
+
|
|
5388
5407
|
// 输出 JSON,方便 AI Agent 解析 event_id
|
|
5389
5408
|
const output = {
|
|
5390
5409
|
event_id: eventId,
|
|
@@ -5392,6 +5411,9 @@ function cmdStart(args) {
|
|
|
5392
5411
|
message: `SDD ${command} 阶段开始记录(change: ${event.change})`,
|
|
5393
5412
|
};
|
|
5394
5413
|
console.log(JSON.stringify(output, null, 2));
|
|
5414
|
+
// 返回 usage 上报 promise(可能为 null),便于测试或调用方 await;
|
|
5415
|
+
// 现有 main() 不读返回值,行为兼容。
|
|
5416
|
+
return usagePromise;
|
|
5395
5417
|
}
|
|
5396
5418
|
|
|
5397
5419
|
const ONTOLOGY_AUTHORING_STAGES = new Set(['propose', 'spec', 'design', 'task', 'check']);
|
|
@@ -5738,6 +5760,9 @@ function cmdEnd(args, options = {}) {
|
|
|
5738
5760
|
...event.details.archive_result,
|
|
5739
5761
|
...actualPaths,
|
|
5740
5762
|
};
|
|
5763
|
+
if (event.details.archive_result.archive_id && !event.archive_id) {
|
|
5764
|
+
event.archive_id = event.details.archive_result.archive_id;
|
|
5765
|
+
}
|
|
5741
5766
|
}
|
|
5742
5767
|
|
|
5743
5768
|
appendEvent(dataDir, event.change, event);
|
|
@@ -5745,6 +5770,37 @@ function cmdEnd(args, options = {}) {
|
|
|
5745
5770
|
clearActiveStage(projectRoot, startEvent);
|
|
5746
5771
|
}
|
|
5747
5772
|
|
|
5773
|
+
// Archive stage_end is remapped to archive_linked on the wire. Also emit an explicit
|
|
5774
|
+
// run_ended so monitors that only listen for terminal kinds still close the run.
|
|
5775
|
+
if (command === 'archive' && (result === 'success' || result === 'partial' || result === 'failed' || result === 'failure')) {
|
|
5776
|
+
try {
|
|
5777
|
+
const terminalResult = result === 'failure' ? 'failed' : result;
|
|
5778
|
+
appendEvent(dataDir, event.change, cleanOptionalFields({
|
|
5779
|
+
schema_version: SCHEMA_VERSION,
|
|
5780
|
+
event_id: generateEventId(),
|
|
5781
|
+
type: 'stage_end',
|
|
5782
|
+
event_kind: 'run_ended',
|
|
5783
|
+
source: event.source,
|
|
5784
|
+
command: 'archive',
|
|
5785
|
+
stage: 'archive',
|
|
5786
|
+
change: event.change,
|
|
5787
|
+
agent_type: event.agent_type,
|
|
5788
|
+
project_root: projectRoot,
|
|
5789
|
+
session_id: event.session_id,
|
|
5790
|
+
git_sha: event.git_sha,
|
|
5791
|
+
timestamp: nowISO(),
|
|
5792
|
+
result: terminalResult,
|
|
5793
|
+
summary: event.summary || '变更归档完成,运行已结束',
|
|
5794
|
+
details: {
|
|
5795
|
+
terminal_of: event.event_id,
|
|
5796
|
+
archive_id: event.details?.archive_result?.archive_id,
|
|
5797
|
+
},
|
|
5798
|
+
}));
|
|
5799
|
+
} catch (err) {
|
|
5800
|
+
try { console.error(`[telemetry] run_ended 补发失败(不阻塞): ${err.message}`); } catch {}
|
|
5801
|
+
}
|
|
5802
|
+
}
|
|
5803
|
+
|
|
5748
5804
|
// 终态摘要必须基于已经落库的 archive stage_end。
|
|
5749
5805
|
if (command === 'archive') {
|
|
5750
5806
|
try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
|
|
@@ -5802,6 +5858,19 @@ function cmdEnd(args, options = {}) {
|
|
|
5802
5858
|
if (!options.silent) {
|
|
5803
5859
|
console.log(JSON.stringify(output, null, 2));
|
|
5804
5860
|
}
|
|
5861
|
+
// kld-usage-flush: stage_end 落库后冲刷 pending 队列(fire-and-forget,不产生新事件)。
|
|
5862
|
+
// 覆盖"start 时网络故障/无身份,end 时已恢复"的同会话补报场景;失败不影响 output 与退出码。
|
|
5863
|
+
// promise 以不可枚举属性挂返回对象供测试 await,JSON.stringify 输出不变。
|
|
5864
|
+
try {
|
|
5865
|
+
const { flushPendingUsage } = require('./lib/usage-reporter.cjs');
|
|
5866
|
+
const usageFlush = flushPendingUsage({ projectRoot }).catch(() => null);
|
|
5867
|
+
Object.defineProperty(output, 'usageFlush', {
|
|
5868
|
+
value: usageFlush,
|
|
5869
|
+
enumerable: false,
|
|
5870
|
+
writable: false,
|
|
5871
|
+
configurable: true,
|
|
5872
|
+
});
|
|
5873
|
+
} catch { /* flush 不可用不阻塞 */ }
|
|
5805
5874
|
return output;
|
|
5806
5875
|
}
|
|
5807
5876
|
|
|
@@ -6246,6 +6315,64 @@ function normalizeStrictFinalOutputSnapshot(details) {
|
|
|
6246
6315
|
};
|
|
6247
6316
|
}
|
|
6248
6317
|
|
|
6318
|
+
/**
|
|
6319
|
+
* Best-effort per-file byte sizes for apply delivery snapshots.
|
|
6320
|
+
* Looks under projectRoot first, then its parent (split specs/code workspace).
|
|
6321
|
+
* Never fails the record path — missing files simply omit size entries.
|
|
6322
|
+
*/
|
|
6323
|
+
function attachFinalOutputFileSizes(details, projectRoot) {
|
|
6324
|
+
const snapshot = details?.final_output_snapshot;
|
|
6325
|
+
if (!snapshot || !Array.isArray(snapshot.files) || snapshot.files.length === 0) {
|
|
6326
|
+
return details;
|
|
6327
|
+
}
|
|
6328
|
+
const existing = (snapshot.file_sizes && typeof snapshot.file_sizes === 'object'
|
|
6329
|
+
&& !Array.isArray(snapshot.file_sizes))
|
|
6330
|
+
? { ...snapshot.file_sizes }
|
|
6331
|
+
: {};
|
|
6332
|
+
const roots = [];
|
|
6333
|
+
const pushRoot = (candidate) => {
|
|
6334
|
+
if (!candidate || typeof candidate !== 'string') return;
|
|
6335
|
+
const resolved = path.resolve(candidate);
|
|
6336
|
+
if (!roots.some((item) => item.toLowerCase?.() === resolved.toLowerCase() || item === resolved)) {
|
|
6337
|
+
roots.push(resolved);
|
|
6338
|
+
}
|
|
6339
|
+
};
|
|
6340
|
+
pushRoot(projectRoot);
|
|
6341
|
+
if (projectRoot) pushRoot(path.dirname(projectRoot));
|
|
6342
|
+
|
|
6343
|
+
let changed = false;
|
|
6344
|
+
for (const relative of snapshot.files) {
|
|
6345
|
+
const rel = String(relative || '').trim().replace(/\\/g, '/');
|
|
6346
|
+
if (!rel || Number.isInteger(existing[rel])) continue;
|
|
6347
|
+
for (const root of roots) {
|
|
6348
|
+
try {
|
|
6349
|
+
const absolute = path.resolve(root, ...rel.split('/'));
|
|
6350
|
+
const rootKey = process.platform === 'win32' ? root.toLowerCase() : root;
|
|
6351
|
+
const absKey = process.platform === 'win32' ? absolute.toLowerCase() : absolute;
|
|
6352
|
+
const prefix = rootKey.endsWith(path.sep) ? rootKey : `${rootKey}${path.sep}`;
|
|
6353
|
+
if (absKey !== rootKey && !absKey.startsWith(prefix)) continue;
|
|
6354
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
|
|
6355
|
+
const bytes = fs.statSync(absolute).size;
|
|
6356
|
+
if (Number.isInteger(bytes) && bytes >= 0) {
|
|
6357
|
+
existing[rel] = bytes;
|
|
6358
|
+
changed = true;
|
|
6359
|
+
break;
|
|
6360
|
+
}
|
|
6361
|
+
} catch {
|
|
6362
|
+
// try next root
|
|
6363
|
+
}
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
if (!changed && Object.keys(existing).length === 0) return details;
|
|
6367
|
+
return {
|
|
6368
|
+
...details,
|
|
6369
|
+
final_output_snapshot: {
|
|
6370
|
+
...snapshot,
|
|
6371
|
+
file_sizes: existing,
|
|
6372
|
+
},
|
|
6373
|
+
};
|
|
6374
|
+
}
|
|
6375
|
+
|
|
6249
6376
|
function normalizeStrictTestResult(args, details) {
|
|
6250
6377
|
const input = details?.test_results;
|
|
6251
6378
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
@@ -7362,7 +7489,10 @@ function cmdRecord(args) {
|
|
|
7362
7489
|
details = normalizeStrictCheckResult(details, args.result);
|
|
7363
7490
|
} else if (strict && type === 'final_output_snapshot') {
|
|
7364
7491
|
strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
|
|
7365
|
-
details =
|
|
7492
|
+
details = attachFinalOutputFileSizes(
|
|
7493
|
+
normalizeStrictFinalOutputSnapshot(details),
|
|
7494
|
+
projectRoot,
|
|
7495
|
+
);
|
|
7366
7496
|
} else if (strict) {
|
|
7367
7497
|
strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
|
|
7368
7498
|
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// kld-T07 — git 身份持久缓存与后台刷新(慢 git 环境韧性)
|
|
2
|
+
// 职责:~/.kld-sdd/git-identity.json 持久缓存读写(tmp+rename 原子);
|
|
3
|
+
// TTL 判定(身份 7 天 / tombstone 1 天);
|
|
4
|
+
// detached+unref 后台刷新(git config --get-regexp 单次调用,宽限 60s);
|
|
5
|
+
// in-progress 锁(mtime TTL 120s)防并发多 spawn;
|
|
6
|
+
// 仅 git 成功但字段缺失才写 tombstone;失败/超时不写,允许下次重试。
|
|
7
|
+
// 设计背景:企业终端 git 进程启动 7~34s,前台 execFileSync 必然超时;
|
|
8
|
+
// 稳态(缓存命中)零 git 调用、零额外延迟。
|
|
9
|
+
// 依赖注入(spawn/execFileSync/now/env/homeDir)便于单测;Node 14 兼容。
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const childProcess = require('node:child_process');
|
|
16
|
+
|
|
17
|
+
const IDENTITY_FILE_NAME = 'git-identity.json';
|
|
18
|
+
const REFRESH_LOCK_NAME = 'git-identity.refresh.lock';
|
|
19
|
+
const IDENTITY_TTL_MS = 7 * 24 * 3600 * 1000; // 身份缓存 7 天
|
|
20
|
+
const TOMBSTONE_TTL_MS = 24 * 3600 * 1000; // tombstone 1 天
|
|
21
|
+
const REFRESH_LOCK_TTL_MS = 120 * 1000; // in-progress 锁 120s
|
|
22
|
+
const REFRESH_GIT_TIMEOUT_MS = 60 * 1000; // 后台 git 宽限 60s(适配 7-34s 慢机器)
|
|
23
|
+
|
|
24
|
+
/** 解析 home:显式 homeDir > env KLD_SDD_HOME > os.homedir(与 pendingPathFor 同注入模式) */
|
|
25
|
+
function resolveHome(homeDir, env) {
|
|
26
|
+
const e = env || process.env;
|
|
27
|
+
return homeDir || (e && e.KLD_SDD_HOME) || os.homedir();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function identityPathFor(homeDir, env) {
|
|
31
|
+
return path.join(resolveHome(homeDir, env), '.kld-sdd', IDENTITY_FILE_NAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function refreshLockPathFor(homeDir, env) {
|
|
35
|
+
return path.join(resolveHome(homeDir, env), '.kld-sdd', REFRESH_LOCK_NAME);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nowMs(options) {
|
|
39
|
+
return options && typeof options.now === 'function' ? options.now() : Date.now();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 原子写 JSON(tmp+rename),与 writePendingAtomic 同款 */
|
|
43
|
+
function writeJsonAtomic(filePath, obj) {
|
|
44
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
45
|
+
const tmp = path.join(
|
|
46
|
+
path.dirname(filePath),
|
|
47
|
+
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
|
|
48
|
+
);
|
|
49
|
+
fs.writeFileSync(tmp, `${JSON.stringify(obj)}\n`, 'utf8');
|
|
50
|
+
try {
|
|
51
|
+
fs.renameSync(tmp, filePath);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 写入身份缓存(readAt 可经 options.now 注入,测试 TTL 用)。
|
|
60
|
+
* @param {{userName:string,email:string}} identity
|
|
61
|
+
*/
|
|
62
|
+
function writeIdentityCache(identity, options = {}) {
|
|
63
|
+
writeJsonAtomic(identityPathFor(options.homeDir, options.env), {
|
|
64
|
+
userName: identity.userName,
|
|
65
|
+
email: identity.email,
|
|
66
|
+
readAt: new Date(nowMs(options)).toISOString(),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 写入 tombstone(确认无身份;TTL 1 天,防止每命令反复 spawn git) */
|
|
71
|
+
function writeTombstone(options = {}) {
|
|
72
|
+
writeJsonAtomic(identityPathFor(options.homeDir, options.env), {
|
|
73
|
+
unavailable: true,
|
|
74
|
+
readAt: new Date(nowMs(options)).toISOString(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 读身份缓存。
|
|
80
|
+
* @returns {{kind:'identity',userName:string,email:string,stale:boolean}
|
|
81
|
+
* |{kind:'tombstone',stale:boolean}
|
|
82
|
+
* |{kind:'miss'}}
|
|
83
|
+
* 损坏 JSON / 缺字段 / 文件不存在一律 miss
|
|
84
|
+
*/
|
|
85
|
+
function readIdentityCache(options = {}) {
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = fs.readFileSync(identityPathFor(options.homeDir, options.env), 'utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
return { kind: 'miss' };
|
|
91
|
+
}
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
return { kind: 'miss' };
|
|
97
|
+
}
|
|
98
|
+
if (!parsed || typeof parsed !== 'object') return { kind: 'miss' };
|
|
99
|
+
const readAt = Date.parse(typeof parsed.readAt === 'string' ? parsed.readAt : '');
|
|
100
|
+
const age = Number.isFinite(readAt) ? nowMs(options) - readAt : Infinity;
|
|
101
|
+
if (parsed.unavailable === true) {
|
|
102
|
+
return { kind: 'tombstone', stale: !(age >= 0 && age < TOMBSTONE_TTL_MS) };
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
typeof parsed.userName === 'string' && parsed.userName
|
|
106
|
+
&& typeof parsed.email === 'string' && parsed.email
|
|
107
|
+
) {
|
|
108
|
+
return {
|
|
109
|
+
kind: 'identity',
|
|
110
|
+
userName: parsed.userName,
|
|
111
|
+
email: parsed.email,
|
|
112
|
+
stale: !(age >= 0 && age < IDENTITY_TTL_MS),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { kind: 'miss' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 获取 in-progress 锁:新鲜锁(<120s)存在或并发创建失败返回 false。
|
|
120
|
+
* 锁内容无语义,mtime 即心跳。
|
|
121
|
+
*/
|
|
122
|
+
function tryAcquireRefreshLock(options = {}) {
|
|
123
|
+
const lockPath = refreshLockPathFor(options.homeDir, options.env);
|
|
124
|
+
let stale = false;
|
|
125
|
+
try {
|
|
126
|
+
const st = fs.statSync(lockPath);
|
|
127
|
+
if (nowMs(options) - st.mtimeMs < REFRESH_LOCK_TTL_MS) return false;
|
|
128
|
+
stale = true; // 过期锁:可接管
|
|
129
|
+
} catch { /* 无锁 */ }
|
|
130
|
+
try {
|
|
131
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
132
|
+
// 新建用 'wx' 排他竞争;过期锁允许覆盖接管(并发接管最多多刷一次,幂等无害)
|
|
133
|
+
fs.writeFileSync(lockPath, String(nowMs(options)), { encoding: 'utf8', flag: stale ? 'w' : 'wx' });
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false; // 并发创建失败视为他人持锁
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function releaseRefreshLock(options = {}) {
|
|
141
|
+
try {
|
|
142
|
+
fs.unlinkSync(refreshLockPathFor(options.homeDir, options.env));
|
|
143
|
+
} catch { /* ignore */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 触发后台刷新:detached+unref 子进程运行本文件 refresh 模式。
|
|
148
|
+
* spawn 抛错时释放锁。返回是否真正触发(false=锁被持有或 spawn 失败)。
|
|
149
|
+
*/
|
|
150
|
+
function triggerBackgroundRefresh(options = {}) {
|
|
151
|
+
if (!tryAcquireRefreshLock(options)) return false;
|
|
152
|
+
const spawnFn = (options && options.spawn) || childProcess.spawn;
|
|
153
|
+
try {
|
|
154
|
+
const child = spawnFn(
|
|
155
|
+
process.execPath,
|
|
156
|
+
[__filename, 'refresh', '--home', resolveHome(options.homeDir, options.env)],
|
|
157
|
+
{ detached: true, stdio: 'ignore' },
|
|
158
|
+
);
|
|
159
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
160
|
+
return true;
|
|
161
|
+
} catch {
|
|
162
|
+
releaseRefreshLock(options);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 主入口(同步,零 git):读持久缓存,必要时触发后台刷新。
|
|
169
|
+
* - 身份新鲜:直接返回,零 spawn
|
|
170
|
+
* - 身份过期:沿用旧值 + 触发刷新
|
|
171
|
+
* - tombstone 新鲜:返回 null,零 spawn
|
|
172
|
+
* - tombstone 过期 / miss:返回 null + 触发刷新
|
|
173
|
+
*
|
|
174
|
+
* @returns {{userName:string,email:string}|null}
|
|
175
|
+
*/
|
|
176
|
+
function ensureIdentity(options = {}) {
|
|
177
|
+
const cache = readIdentityCache(options);
|
|
178
|
+
if (cache.kind === 'identity') {
|
|
179
|
+
if (cache.stale) triggerBackgroundRefresh(options);
|
|
180
|
+
return { userName: cache.userName, email: cache.email };
|
|
181
|
+
}
|
|
182
|
+
if (cache.kind === 'tombstone' && !cache.stale) return null;
|
|
183
|
+
triggerBackgroundRefresh(options);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 后台刷新执行体(也可注入 execFileSync 在测试内 in-process 运行)。
|
|
189
|
+
* git 成功但字段缺失才写 tombstone;失败/超时什么都不写(允许下次重试)。
|
|
190
|
+
* 总是释放 in-progress 锁。
|
|
191
|
+
* @returns {boolean} git 调用是否成功(不代表一定写入了身份)
|
|
192
|
+
*/
|
|
193
|
+
function runRefresh(options = {}) {
|
|
194
|
+
const execFileSyncFn = (options && options.execFileSync) || childProcess.execFileSync;
|
|
195
|
+
try {
|
|
196
|
+
let out;
|
|
197
|
+
try {
|
|
198
|
+
out = String(execFileSyncFn('git', ['config', '--get-regexp', '^user\\.'], {
|
|
199
|
+
timeout: REFRESH_GIT_TIMEOUT_MS,
|
|
200
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
201
|
+
}));
|
|
202
|
+
} catch {
|
|
203
|
+
return false; // 失败/超时:不写 tombstone,允许下次重试
|
|
204
|
+
}
|
|
205
|
+
let userName = '';
|
|
206
|
+
let email = '';
|
|
207
|
+
for (const line of out.split('\n')) {
|
|
208
|
+
const m = line.match(/^(\S+)\s+(.*)$/);
|
|
209
|
+
if (!m) continue;
|
|
210
|
+
if (m[1] === 'user.name') userName = m[2].trim();
|
|
211
|
+
else if (m[1] === 'user.email') email = m[2].trim();
|
|
212
|
+
}
|
|
213
|
+
if (userName && email) {
|
|
214
|
+
writeIdentityCache({ userName, email }, options);
|
|
215
|
+
} else {
|
|
216
|
+
writeTombstone(options);
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
} finally {
|
|
220
|
+
releaseRefreshLock(options);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* refresh 子进程 CLI 入口:node git-identity.cjs refresh --home <dir>
|
|
226
|
+
* runRefresh 成功且回读缓存为 identity 时,lazy require usage-reporter 自触发一次
|
|
227
|
+
* pending 冲刷(身份就绪后数秒内补报,不再等下一次七值 start)。
|
|
228
|
+
* lazy require 规避循环依赖(usage-reporter 顶层已 require 本模块)。
|
|
229
|
+
* 返回 flush promise(无触发时为 resolved promise),便于测试 await。
|
|
230
|
+
*/
|
|
231
|
+
function runRefreshCli(argv, options = {}) {
|
|
232
|
+
const homeIdx = argv.indexOf('--home');
|
|
233
|
+
const homeDir = homeIdx >= 0 ? argv[homeIdx + 1] : undefined;
|
|
234
|
+
try {
|
|
235
|
+
const ok = runRefresh(Object.assign({}, options, { homeDir }));
|
|
236
|
+
if (!ok) return Promise.resolve(false);
|
|
237
|
+
// 回读缓存:仅真实写入 identity 才触发 flush(tombstone 不触发)
|
|
238
|
+
const cache = readIdentityCache({ homeDir });
|
|
239
|
+
if (cache.kind !== 'identity') return Promise.resolve(false);
|
|
240
|
+
try {
|
|
241
|
+
const { flushPendingUsage } = require('./usage-reporter.cjs');
|
|
242
|
+
return flushPendingUsage({ homeDir })
|
|
243
|
+
.catch(() => false)
|
|
244
|
+
.then(() => true);
|
|
245
|
+
} catch {
|
|
246
|
+
return Promise.resolve(true);
|
|
247
|
+
}
|
|
248
|
+
} catch { /* 永不抛到进程外 */ }
|
|
249
|
+
return Promise.resolve(false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (require.main === module) {
|
|
253
|
+
if (process.argv[2] === 'refresh') runRefreshCli(process.argv.slice(2));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = {
|
|
257
|
+
ensureIdentity,
|
|
258
|
+
readIdentityCache,
|
|
259
|
+
writeIdentityCache,
|
|
260
|
+
writeTombstone,
|
|
261
|
+
triggerBackgroundRefresh,
|
|
262
|
+
runRefresh,
|
|
263
|
+
runRefreshCli,
|
|
264
|
+
identityPathFor,
|
|
265
|
+
refreshLockPathFor,
|
|
266
|
+
IDENTITY_TTL_MS,
|
|
267
|
+
TOMBSTONE_TTL_MS,
|
|
268
|
+
REFRESH_LOCK_TTL_MS,
|
|
269
|
+
REFRESH_GIT_TIMEOUT_MS,
|
|
270
|
+
};
|
|
@@ -347,12 +347,27 @@ function loadKbState(anchorDir = __dirname, env = process.env) {
|
|
|
347
347
|
return { config: null, path: null, warnings };
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/** 监控项目绑定名长度上限:与 batch schema project_name maxLength 对齐。 */
|
|
351
|
+
const PROJECT_NAME_MAX_LENGTH = 128;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 归一化监控项目绑定名:非字符串或 trim 后为空 → null。
|
|
355
|
+
*
|
|
356
|
+
* @param {*} value
|
|
357
|
+
* @returns {string|null}
|
|
358
|
+
*/
|
|
359
|
+
function normalizeProjectName(value) {
|
|
360
|
+
if (typeof value !== 'string') return null;
|
|
361
|
+
const trimmed = value.trim();
|
|
362
|
+
return trimmed === '' ? null : trimmed;
|
|
363
|
+
}
|
|
364
|
+
|
|
350
365
|
/**
|
|
351
366
|
* 从 KB 配置 + 环境变量解析 KB 连接参数。
|
|
352
367
|
*
|
|
353
368
|
* @param {object} stateConfig — loadKbState().config
|
|
354
369
|
* @param {object} [env=process.env]
|
|
355
|
-
* @returns {{ apiBase: string, token: string, spaceId: string, kbId: string, targets: array }}
|
|
370
|
+
* @returns {{ apiBase: string, token: string, spaceId: string, kbId: string, targets: array, projectName: string|null }}
|
|
356
371
|
*/
|
|
357
372
|
function resolveKbParams(stateConfig = {}, env = process.env) {
|
|
358
373
|
const apiBase = stateConfig.api || env.ENGINEERING_KB_API || 'http://localhost:8090/api';
|
|
@@ -361,7 +376,136 @@ function resolveKbParams(stateConfig = {}, env = process.env) {
|
|
|
361
376
|
? stateConfig.targets[0].spaceId : '';
|
|
362
377
|
const kbId = stateConfig.targets && stateConfig.targets.length > 0
|
|
363
378
|
? stateConfig.targets[0].kbId : '';
|
|
364
|
-
|
|
379
|
+
const projectName = normalizeProjectName(stateConfig.projectName)
|
|
380
|
+
|| normalizeProjectName(env.ENGINEERING_KB_PROJECT_NAME);
|
|
381
|
+
return { apiBase, token, spaceId, kbId, targets: stateConfig.targets || [], projectName };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Normalize API client scope list from kb-state or env.
|
|
386
|
+
* @param {object} stateConfig
|
|
387
|
+
* @param {object} [env=process.env]
|
|
388
|
+
* @returns {string[]}
|
|
389
|
+
*/
|
|
390
|
+
function resolveKbScopes(stateConfig = {}, env = process.env) {
|
|
391
|
+
const fromState = stateConfig.scopes
|
|
392
|
+
|| stateConfig.apiKeyScopes
|
|
393
|
+
|| stateConfig.api_client_scopes;
|
|
394
|
+
if (Array.isArray(fromState)) {
|
|
395
|
+
return fromState.map((scope) => String(scope).trim()).filter(Boolean);
|
|
396
|
+
}
|
|
397
|
+
if (typeof fromState === 'string' && fromState.trim()) {
|
|
398
|
+
return fromState.split(/[\s,]+/).map((scope) => scope.trim()).filter(Boolean);
|
|
399
|
+
}
|
|
400
|
+
const fromEnv = env.ENGINEERING_KB_SCOPES || env.ENGINEERING_KB_API_SCOPES;
|
|
401
|
+
if (typeof fromEnv === 'string' && fromEnv.trim()) {
|
|
402
|
+
return fromEnv.split(/[\s,]+/).map((scope) => scope.trim()).filter(Boolean);
|
|
403
|
+
}
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// kld-T06: progress:write 校验、progress binding 读取已随 Progress Runtime 一并删除。
|
|
408
|
+
// Archive/Knowledge 的 api_client 能力由 resolveKbParams / httpGetJson / uploadFile 等保留。
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 通过服务端 dimensions 端点校验当前 API Key 用户是否为指定项目成员。
|
|
412
|
+
*
|
|
413
|
+
* 用于 kb-config 配置期的项目绑定预校验;服务端 A3 仍在上传时做权威裁决
|
|
414
|
+
* (非成员置空、不阻断),因此本函数对端点缺失/网络失败软返回而不抛错。
|
|
415
|
+
*
|
|
416
|
+
* @param {string} specRoot — spec 包根目录(含 kb-state.json)
|
|
417
|
+
* @param {string} projectName — 待校验的项目名称
|
|
418
|
+
* @param {object} [options]
|
|
419
|
+
* @param {object} [options.env=process.env]
|
|
420
|
+
* @param {number} [options.timeoutMs=15000]
|
|
421
|
+
* @returns {Promise<{ ok: boolean, reason?: string, member: boolean|null, matchedProject?: object }>}
|
|
422
|
+
* @throws {Error} E_PROGRESS_BINDING_INCOMPLETE — kb-state 缺失或缺 api/apiKey
|
|
423
|
+
*/
|
|
424
|
+
async function verifyProjectMembershipViaServer(specRoot, projectName, options = {}) {
|
|
425
|
+
const env = options.env || process.env;
|
|
426
|
+
const normalized = normalizeProjectName(projectName);
|
|
427
|
+
if (!normalized || normalized.length > PROJECT_NAME_MAX_LENGTH) {
|
|
428
|
+
return { ok: false, reason: 'invalid_input', member: null };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const root = path.resolve(specRoot);
|
|
432
|
+
const { config: kbState, path: statePath } = loadKbState(root, env);
|
|
433
|
+
if (!kbState || !statePath) {
|
|
434
|
+
const error = new Error('kb-state.json not found');
|
|
435
|
+
error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
|
|
436
|
+
error.missingFields = ['kb-state.json'];
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const kbParams = resolveKbParams(kbState, env);
|
|
441
|
+
const missing = [];
|
|
442
|
+
if (!kbParams.apiBase) missing.push('api');
|
|
443
|
+
if (!kbParams.token) missing.push('apiKey');
|
|
444
|
+
if (missing.length > 0) {
|
|
445
|
+
const error = new Error(`Progress binding incomplete: missing ${missing.join(', ')}`);
|
|
446
|
+
error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
|
|
447
|
+
error.missingFields = missing;
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const apiBase = String(kbParams.apiBase).replace(/\/+$/, '');
|
|
452
|
+
const url = `${apiBase}/v1/sdd-runs/dimensions`;
|
|
453
|
+
let response;
|
|
454
|
+
try {
|
|
455
|
+
response = await httpGetJson(url, kbParams.token, options.timeoutMs || 15000);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
// 鉴权失败(密钥失效/吊销)与端点缺失/网络异常分开归因,避免配置流程误导。
|
|
458
|
+
const statusMatch = error && /^HTTP (\d{3})\b/.exec(String(error.message || ''));
|
|
459
|
+
if (statusMatch && (statusMatch[1] === '401' || statusMatch[1] === '403')) {
|
|
460
|
+
return { ok: false, reason: 'auth_failed', member: null };
|
|
461
|
+
}
|
|
462
|
+
return { ok: false, reason: 'endpoint_unavailable', member: null };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const data = response && typeof response === 'object' && response.data != null
|
|
466
|
+
? response.data
|
|
467
|
+
: response;
|
|
468
|
+
const projects = Array.isArray(data?.projects) ? data.projects : [];
|
|
469
|
+
const matched = projects.find((p) => {
|
|
470
|
+
const name = normalizeProjectName(p && (p.name || p.projectName));
|
|
471
|
+
return name === normalized;
|
|
472
|
+
});
|
|
473
|
+
return {
|
|
474
|
+
ok: true,
|
|
475
|
+
member: Boolean(matched),
|
|
476
|
+
matchedProject: matched
|
|
477
|
+
? { id: matched.projectId || matched.id || null, name: normalized }
|
|
478
|
+
: undefined,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 原子写入/清除 kb-state.json 的 projectName(监控项目绑定),保留其余键。
|
|
484
|
+
*
|
|
485
|
+
* @param {string} specRoot — spec 包根目录
|
|
486
|
+
* @param {string|null} projectName — 项目名;null/空白 = 清除绑定
|
|
487
|
+
* @returns {{ ok: true, path: string, projectName: string|null }}
|
|
488
|
+
* @throws {Error} E_PROGRESS_BINDING_INCOMPLETE — kb-state.json 不存在
|
|
489
|
+
*/
|
|
490
|
+
function setKbProjectName(specRoot, projectName, options = {}) {
|
|
491
|
+
const env = options.env || process.env;
|
|
492
|
+
const root = path.resolve(specRoot);
|
|
493
|
+
const { config: kbState, path: statePath } = loadKbState(root, env);
|
|
494
|
+
if (!kbState || !statePath) {
|
|
495
|
+
const error = new Error('kb-state.json not found');
|
|
496
|
+
error.code = 'E_PROGRESS_BINDING_INCOMPLETE';
|
|
497
|
+
error.missingFields = ['kb-state.json'];
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
const normalized = normalizeProjectName(projectName);
|
|
501
|
+
if (normalized && normalized.length > PROJECT_NAME_MAX_LENGTH) {
|
|
502
|
+
const error = new Error(`projectName 超长(>${PROJECT_NAME_MAX_LENGTH} 字符)`);
|
|
503
|
+
error.code = 'E_PROJECT_NAME_INVALID';
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
const next = { ...kbState, projectName: normalized };
|
|
507
|
+
writeJsonAtomic(statePath, next);
|
|
508
|
+
return { ok: true, path: statePath, projectName: next.projectName };
|
|
365
509
|
}
|
|
366
510
|
|
|
367
511
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -656,6 +800,10 @@ module.exports = {
|
|
|
656
800
|
IDE_DIRS,
|
|
657
801
|
loadKbState,
|
|
658
802
|
resolveKbParams,
|
|
803
|
+
resolveKbScopes,
|
|
804
|
+
normalizeProjectName,
|
|
805
|
+
verifyProjectMembershipViaServer,
|
|
806
|
+
setKbProjectName,
|
|
659
807
|
// HTTP
|
|
660
808
|
requestJson,
|
|
661
809
|
httpGetJson,
|