kld-sdd 2.8.0 → 2.8.2
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/README.md +15 -0
- package/lib/init.js +3 -1
- package/package.json +4 -3
- package/skywalk-sdd/context-client.cjs +5 -2
- package/skywalk-sdd/kb-ingest.cjs +191 -0
- package/skywalk-sdd/kb-sync-identity.cjs +5 -43
- package/skywalk-sdd/kb-upload.cjs +111 -146
- package/skywalk-sdd/lib/kb-config.cjs +107 -0
- package/skywalk-sdd/lib/shared.cjs +7 -5
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +24 -173
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +13 -3
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +37 -277
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +38 -10
- package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +18 -0
- package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +1 -1
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +11 -7
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-1-prechange.md +1 -1
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-2-during.md +1 -1
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-4-explore.md +1 -1
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-5-governance.md +1 -1
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +8 -3
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* kb-upload.cjs — 知识库上传脚本
|
|
6
6
|
*
|
|
7
7
|
* 支持两种上传模式:
|
|
8
|
-
* 1.
|
|
8
|
+
* 1. 文件夹上传:读取本地 Spec → 生成本体 JSON → 直接上传(无 ZIP、无 AI)
|
|
9
9
|
* 2. Zip 上传:直接上传已有 zip 包
|
|
10
10
|
*
|
|
11
11
|
* 用法:
|
|
@@ -16,11 +16,13 @@
|
|
|
16
16
|
*
|
|
17
17
|
* 历史产物(缺 entity-id / version-id):
|
|
18
18
|
* 自动旁路物化 spec-ontology.json,无 semantic_key 冲突时自动确认并发布为 current;
|
|
19
|
-
*
|
|
19
|
+
* --require-review 仅保存候选;明确审阅后用 --confirm-review 发布同一原文。
|
|
20
|
+
* 并发基线冲突整体拒绝,不覆盖团队 current。
|
|
20
21
|
*
|
|
21
22
|
* project_id 解析:
|
|
22
23
|
* 1. $SPEC_ROOT/skywalk-sdd/project-identity.json 的 project_id
|
|
23
|
-
* 2.
|
|
24
|
+
* 2. 未配置的首次身份 → 使用已选上传库的 spaceKey 建立归属
|
|
25
|
+
* 3. 缺少空间元数据或已有绑定不符 → 停止并提示修正配置
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
28
|
const fs = require('fs');
|
|
@@ -37,15 +39,41 @@ const {
|
|
|
37
39
|
isKbStateHostDir,
|
|
38
40
|
uploadFile,
|
|
39
41
|
httpGetJson,
|
|
42
|
+
resolveKbParams,
|
|
40
43
|
} = require('./lib/shared.cjs');
|
|
41
44
|
|
|
42
45
|
function resolveUploadTarget(state, options = {}) {
|
|
43
46
|
if (Boolean(options.spaceId) !== Boolean(options.kbId)) throw new Error('必须同时指定 --space-id 和 --kb-id');
|
|
44
|
-
const targets = state.targets
|
|
47
|
+
const targets = Array.isArray(state?.targets) ? state.targets : [];
|
|
48
|
+
const configured = state?.uploadTarget;
|
|
45
49
|
if (options.spaceId && options.kbId) {
|
|
46
|
-
|
|
50
|
+
const differs = Object.prototype.hasOwnProperty.call(state || {}, 'uploadTarget')
|
|
51
|
+
&& (!configured || configured.spaceId !== options.spaceId || configured.kbId !== options.kbId);
|
|
52
|
+
if (differs && !options.overrideUploadTarget) {
|
|
53
|
+
throw new Error('UPLOAD_TARGET_CONFLICT: 命令中的上传库与助手保存的上传位置不同;请更新旧 Skill,默认不传目标参数。确需临时改投时,显式增加 --override-upload-target');
|
|
54
|
+
}
|
|
55
|
+
const selected = targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId);
|
|
56
|
+
if (options.overrideUploadTarget && !selected) throw new Error('临时上传目标必须在 kb-state.json 的 targets 中');
|
|
57
|
+
return selected || { spaceId: options.spaceId, kbId: options.kbId };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Object.prototype.hasOwnProperty.call(state || {}, 'uploadTarget') && configured == null) {
|
|
61
|
+
throw new Error('尚未选择 Spec 上传知识库(uploadTarget 已清空);请在助手中选择上传位置,不能用读取库代替');
|
|
62
|
+
}
|
|
63
|
+
if (configured != null) {
|
|
64
|
+
if (!configured.spaceId || !configured.kbId) {
|
|
65
|
+
throw new Error('kb-state.json 的 uploadTarget 不完整,请在 SDD 助手中重新选择 Spec 上传知识库');
|
|
66
|
+
}
|
|
67
|
+
const selected = targets.find(t => t.spaceId === configured.spaceId && t.kbId === configured.kbId);
|
|
68
|
+
if (!selected) {
|
|
69
|
+
throw new Error('kb-state.json 的 uploadTarget 已不在 targets 读取范围中,请在 SDD 助手中重新选择 Spec 上传知识库');
|
|
70
|
+
}
|
|
71
|
+
return selected;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (targets.length !== 1) {
|
|
75
|
+
throw new Error('请明确上传目标:在 SDD 助手中选择“Spec 上传知识库”,或同时指定 --space-id 和 --kb-id;不会自动分发到多个 KB');
|
|
47
76
|
}
|
|
48
|
-
if (targets.length !== 1) throw new Error('请明确上传目标:配置一个知识库,或同时指定 --space-id 和 --kb-id;不会自动分发到多个 KB');
|
|
49
77
|
if (!targets[0].spaceId || !targets[0].kbId) throw new Error('知识库目标缺少 spaceId/kbId');
|
|
50
78
|
return targets[0];
|
|
51
79
|
}
|
|
@@ -91,9 +119,9 @@ function resolveKbStatePath(specRoot) {
|
|
|
91
119
|
function readArchiveCache(specRoot, changeName, context) {
|
|
92
120
|
const statePath = resolveKbStatePath(specRoot);
|
|
93
121
|
const state = readJsonSafe(statePath);
|
|
94
|
-
|
|
122
|
+
const local = require('./lib/kb-config.cjs').readReceipts(specRoot);
|
|
95
123
|
const key = cacheKey(changeName, context);
|
|
96
|
-
return key ? state
|
|
124
|
+
return key ? local.receipts?.[key] || local.legacyArchiveCache?.[key] || state?.archiveCache?.[key] || null : null;
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
/**
|
|
@@ -104,18 +132,14 @@ function readArchiveCache(specRoot, changeName, context) {
|
|
|
104
132
|
* @param {string} contentHash
|
|
105
133
|
*/
|
|
106
134
|
function updateKbStateCache(specRoot, changeName, archiveId, contentHash, context) {
|
|
107
|
-
const statePath = resolveKbStatePath(specRoot);
|
|
108
|
-
const state = readJsonSafe(statePath) || {};
|
|
109
|
-
if (!state.archiveCache) state.archiveCache = {};
|
|
110
135
|
const key = cacheKey(changeName, context);
|
|
111
136
|
if (!key) throw new Error('缓存必须包含 API、Space 和 KB');
|
|
112
|
-
|
|
137
|
+
require('./lib/kb-config.cjs').writeReceipt(specRoot, key, {
|
|
113
138
|
archive_id: archiveId,
|
|
114
139
|
content_hash: contentHash,
|
|
115
140
|
uploaded_at: new Date().toISOString(),
|
|
116
141
|
target: context,
|
|
117
|
-
};
|
|
118
|
-
writeJsonAtomic(statePath, state);
|
|
142
|
+
});
|
|
119
143
|
console.log(`✓ 已更新本地缓存: ${changeName} → ${archiveId.slice(0, 24)}...`);
|
|
120
144
|
}
|
|
121
145
|
|
|
@@ -149,13 +173,35 @@ function resolveSpecRoot(explicit) {
|
|
|
149
173
|
|
|
150
174
|
/**
|
|
151
175
|
* 解析 project_id
|
|
152
|
-
*
|
|
176
|
+
* 保留团队绑定;仅缺失或明确 UNCONFIGURED 的身份可从已选上传目标首次建立。
|
|
153
177
|
*/
|
|
154
|
-
function resolveProjectId(specRoot) {
|
|
178
|
+
function resolveProjectId(specRoot, target) {
|
|
155
179
|
const identityPath = path.join(specRoot, 'skywalk-sdd', 'project-identity.json');
|
|
180
|
+
if (!fs.existsSync(identityPath) && typeof target?.spaceKey === 'string' && target.spaceKey.trim()) {
|
|
181
|
+
ensureDir(path.dirname(identityPath));
|
|
182
|
+
try {
|
|
183
|
+
fs.writeFileSync(identityPath, JSON.stringify({
|
|
184
|
+
schema_version: 'kld-sdd-project-identity/v1',
|
|
185
|
+
project_id: target.spaceKey.trim(),
|
|
186
|
+
kb_space_id: target.spaceId,
|
|
187
|
+
kb_space_name: target.spaceName || '',
|
|
188
|
+
created_at: new Date().toISOString(),
|
|
189
|
+
}, null, 2) + '\n', { flag: 'wx' });
|
|
190
|
+
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
191
|
+
}
|
|
156
192
|
if (fs.existsSync(identityPath)) {
|
|
157
193
|
const identity = readJson(identityPath);
|
|
158
|
-
|
|
194
|
+
const provisional = !identity?.kb_space_id && typeof identity?._warning === 'string'
|
|
195
|
+
&& identity._warning.startsWith('UNCONFIGURED') && /^urn:kld:sdd:project:/.test(identity.project_id || '');
|
|
196
|
+
if (provisional && typeof target?.spaceKey === 'string' && target.spaceKey.trim()) {
|
|
197
|
+
const next = { ...identity, previous_unconfigured_project_id: identity.project_id,
|
|
198
|
+
project_id: target.spaceKey.trim(), kb_space_id: target.spaceId, kb_space_name: target.spaceName || '',
|
|
199
|
+
configured_at: new Date().toISOString() };
|
|
200
|
+
delete next._warning;
|
|
201
|
+
writeJsonAtomic(identityPath, next);
|
|
202
|
+
return next.project_id;
|
|
203
|
+
}
|
|
204
|
+
if (typeof identity?.project_id === 'string' && identity.project_id.trim()) {
|
|
159
205
|
return identity.project_id;
|
|
160
206
|
}
|
|
161
207
|
}
|
|
@@ -171,9 +217,9 @@ function resolveProjectId(specRoot) {
|
|
|
171
217
|
* 位置校验:只读 `<specRoot>/kb-state.json`(specRoot 的 spec 根身份已由
|
|
172
218
|
* resolveSpecRoot 在入口校验)。
|
|
173
219
|
*/
|
|
174
|
-
function readKbState(specRoot) {
|
|
220
|
+
function readKbState(specRoot, env = process.env) {
|
|
175
221
|
const statePath = resolveKbStatePath(specRoot);
|
|
176
|
-
if (!fs.existsSync(statePath)) {
|
|
222
|
+
if (!fs.existsSync(statePath) && !fs.existsSync(path.join(specRoot, 'kb.config.json'))) {
|
|
177
223
|
throw new Error(
|
|
178
224
|
'KB 配置不存在(kb-state.json 缺失)。\n' +
|
|
179
225
|
`期望位置: ${statePath}\n` +
|
|
@@ -181,7 +227,14 @@ function readKbState(specRoot) {
|
|
|
181
227
|
);
|
|
182
228
|
}
|
|
183
229
|
try {
|
|
184
|
-
|
|
230
|
+
const loaded = require('./lib/kb-config.cjs').readProjectConfig(specRoot);
|
|
231
|
+
const state = loaded.config;
|
|
232
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('配置内容必须是 JSON 对象');
|
|
233
|
+
if (env.SDD_KB_STATE_FILE && path.resolve(env.SDD_KB_STATE_FILE) !== statePath) {
|
|
234
|
+
console.warn('⚠️ SDD_KB_STATE_FILE 已忽略;上传统一读取所选 Spec 根目录的 kb-state.json');
|
|
235
|
+
}
|
|
236
|
+
const params = resolveKbParams(state, env);
|
|
237
|
+
return { ...state, api: params.apiBase, apiKey: params.token };
|
|
185
238
|
} catch (e) {
|
|
186
239
|
throw new Error(
|
|
187
240
|
`KB 配置解析失败 (${statePath}): ${e.message}\n` +
|
|
@@ -190,6 +243,16 @@ function readKbState(specRoot) {
|
|
|
190
243
|
}
|
|
191
244
|
}
|
|
192
245
|
|
|
246
|
+
// Upload and identity synchronization share this entry; neither may reinterpret read targets.
|
|
247
|
+
function prepareUploadConfig(specRoot, options = {}) {
|
|
248
|
+
const state = readKbState(specRoot);
|
|
249
|
+
if (!state.apiKey) throw new Error('KB 配置中无 apiKey,请在助手中登录知识库或运行 /opsx-kb-config');
|
|
250
|
+
const target = resolveUploadTarget(state, options);
|
|
251
|
+
const projectId = resolveProjectId(specRoot, options.readOnlyIdentity ? undefined : target);
|
|
252
|
+
preCheckProjectId(projectId, state, target);
|
|
253
|
+
return { state, target, projectId };
|
|
254
|
+
}
|
|
255
|
+
|
|
193
256
|
/**
|
|
194
257
|
* 校验文件夹必需文件
|
|
195
258
|
*/
|
|
@@ -223,7 +286,7 @@ function preCheckProjectId(projectId, state, selectedTarget) {
|
|
|
223
286
|
}
|
|
224
287
|
const spaceKey = (selectedTarget || resolveUploadTarget(state)).spaceKey;
|
|
225
288
|
if (!spaceKey) {
|
|
226
|
-
console.warn('⚠️ kb-state.json
|
|
289
|
+
console.warn('⚠️ kb-state.json 的上传目标缺少 spaceKey,跳过本地预检');
|
|
227
290
|
return;
|
|
228
291
|
}
|
|
229
292
|
if (projectId !== spaceKey) {
|
|
@@ -431,7 +494,26 @@ async function uploadZip(zipPath, state, options) {
|
|
|
431
494
|
async function main() {
|
|
432
495
|
const args = parseArgs(process.argv.slice(2));
|
|
433
496
|
|
|
434
|
-
if (
|
|
497
|
+
if (args['check-config']) {
|
|
498
|
+
try {
|
|
499
|
+
const specRoot = resolveSpecRoot(args.project);
|
|
500
|
+
const state = readKbState(specRoot);
|
|
501
|
+
if (!state.apiKey) throw new Error('尚未配置知识库 API Key');
|
|
502
|
+
const target = resolveUploadTarget(state);
|
|
503
|
+
const identityPath = path.join(specRoot, 'skywalk-sdd/project-identity.json');
|
|
504
|
+
const identity = fs.existsSync(identityPath) ? readJson(identityPath) : null;
|
|
505
|
+
if (fs.existsSync(identityPath) && (!identity || typeof identity.project_id !== 'string' || !identity.project_id.trim())) throw new Error('项目身份文件缺少 project_id,已保留原文件,请先修复');
|
|
506
|
+
const provisional = identity && !identity.kb_space_id && typeof identity._warning === 'string'
|
|
507
|
+
&& identity._warning.startsWith('UNCONFIGURED') && /^urn:kld:sdd:project:/.test(identity.project_id || '');
|
|
508
|
+
if (identity?.project_id && !provisional) preCheckProjectId(identity.project_id, state, target);
|
|
509
|
+
else if (!target.spaceKey) throw new Error('上传目标缺少项目标识 spaceKey;请在助手重新登录获取知识库列表,或运行 /opsx-kb-config 补齐');
|
|
510
|
+
console.log(JSON.stringify({ configFile: resolveKbStatePath(specRoot), api: state.api,
|
|
511
|
+
uploadTarget: { spaceId: target.spaceId, kbId: target.kbId, spaceKey: target.spaceKey, spaceName: target.spaceName, kbName: target.kbName }, projectId: identity?.project_id || null,
|
|
512
|
+
identityWillBeCreated: !identity && Boolean(target.spaceKey), identityWillBeBound: Boolean(provisional) }, null, 2));
|
|
513
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!args.folder && !args.package && !args.change) {
|
|
435
517
|
console.error('用法:');
|
|
436
518
|
console.error(' node skywalk-sdd/kb-upload.cjs --folder=<path> [--minimum-level=propose-spec] [--validate-only]');
|
|
437
519
|
console.error(' node skywalk-sdd/kb-upload.cjs --package=<zip-path>');
|
|
@@ -468,6 +550,7 @@ async function main() {
|
|
|
468
550
|
const result = await uploadZip(zipPath, state, {
|
|
469
551
|
spaceId: args['space-id'],
|
|
470
552
|
kbId: args['kb-id'],
|
|
553
|
+
overrideUploadTarget: Boolean(args['override-upload-target']),
|
|
471
554
|
});
|
|
472
555
|
|
|
473
556
|
console.log('\n── 上传结果 ──');
|
|
@@ -535,130 +618,11 @@ async function main() {
|
|
|
535
618
|
return;
|
|
536
619
|
}
|
|
537
620
|
|
|
538
|
-
//
|
|
539
|
-
const projectId = resolveProjectId(specRoot);
|
|
540
|
-
console.log(`✓ project_id: ${projectId}`);
|
|
541
|
-
|
|
542
|
-
// 3. 读取 KB 配置 + 预检
|
|
543
|
-
const state = readKbState(specRoot);
|
|
544
|
-
if (!state.apiKey) {
|
|
545
|
-
throw new Error('KB 配置中无 apiKey,请运行 /opsx-kb-config 配置知识库。');
|
|
546
|
-
}
|
|
547
|
-
const target = resolveUploadTarget(state, { spaceId: args['space-id'], kbId: args['kb-id'] });
|
|
548
|
-
preCheckProjectId(projectId, state, target);
|
|
549
|
-
const cacheContext = { api: state.api || 'http://localhost:8090/api', spaceId: target.spaceId, kbId: target.kbId };
|
|
550
|
-
|
|
551
|
-
// 5. 构建 Archive Package
|
|
552
|
-
console.log('');
|
|
553
|
-
const { zipPath, manifest, stagingDir, requiresReview, legacyStatus } = buildPackageFromFolder(specRoot, changeName, {
|
|
554
|
-
reason: args.reason,
|
|
555
|
-
requireReview: Boolean(args['require-review']),
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
try {
|
|
559
|
-
// Local receipts are informational only. Always verify the selected destination remotely.
|
|
560
|
-
const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
|
|
561
|
-
|
|
562
|
-
// === Step 5.5: KB 预检(网络级幂等确认)===
|
|
563
|
-
const archiveId = manifest.archive_id;
|
|
564
|
-
const contentHash = manifest.content_hash;
|
|
565
|
-
console.log(`→ Pre-check: archive_id=${archiveId}`);
|
|
566
|
-
console.log(` content_hash=${contentHash}`);
|
|
567
|
-
|
|
568
|
-
const checkUrl = `${apiUrl}/v1/spaces/${target.spaceId}/knowledge-bases/${target.kbId}/ingestions/check`
|
|
569
|
-
+ `?archiveId=${encodeURIComponent(archiveId)}`
|
|
570
|
-
+ `&contentHash=${encodeURIComponent(contentHash)}`;
|
|
571
|
-
|
|
572
|
-
try {
|
|
573
|
-
const checkResult = await httpGetJson(checkUrl, state.apiKey);
|
|
574
|
-
|
|
575
|
-
if (checkResult?.data?.idempotent && checkResult.data.publicationStatus === 'published') {
|
|
576
|
-
console.log('\n✓ KB 中已存在相同 archive(幂等命中),跳过上传');
|
|
577
|
-
console.log(` snapshot_id: ${checkResult.data.snapshotId}`);
|
|
578
|
-
updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
|
|
579
|
-
console.log(` publication: ${checkResult.data.publicationStatus}; projection: ${checkResult.data.projectionStatus}`);
|
|
580
|
-
if (['failed', 'degraded'].includes(checkResult.data.projectionStatus)) console.log('⚠️ 检索准备尚未完成,请在知识库概览重试该任务的投影');
|
|
581
|
-
console.log('\n✓ 归档已存在(检索状态以上述回执为准)');
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if (checkResult && checkResult.data && checkResult.data.exists && !checkResult.data.idempotent) {
|
|
586
|
-
throw new Error(
|
|
587
|
-
`ARCHIVE_IMMUTABILITY_VIOLATION: archive_id 已存在但 content_hash 不同\n` +
|
|
588
|
-
` archive_id: ${archiveId}\n` +
|
|
589
|
-
` 如果内容确实变更,请确认实体身份块已正确更新`
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
console.log('✓ Pre-check 通过(KB 中无此 archive,继续上传)');
|
|
594
|
-
} catch (checkErr) {
|
|
595
|
-
// 降级:KB 后端未部署 /check 接口(404)→ 继续上传
|
|
596
|
-
if (checkErr.message && checkErr.message.includes('HTTP 404')) {
|
|
597
|
-
console.log('⚠️ KB 后端未部署 /check 接口,降级为直接上传');
|
|
598
|
-
} else {
|
|
599
|
-
throw checkErr;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// 6. 上传
|
|
604
|
-
console.log('');
|
|
605
|
-
const result = await uploadZip(zipPath, state, {
|
|
606
|
-
spaceId: target.spaceId,
|
|
607
|
-
kbId: target.kbId,
|
|
608
|
-
allowNeedsReview: requiresReview,
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
console.log('\n── 上传结果 ──');
|
|
612
|
-
console.log(JSON.stringify(result, null, 2));
|
|
613
|
-
|
|
614
|
-
// 7. 输出 manifest 摘要
|
|
615
|
-
console.log('\n── Archive Package 摘要 ──');
|
|
616
|
-
console.log(` archive_id: ${manifest.archive_id}`);
|
|
617
|
-
console.log(` project_id: ${manifest.project_id}`);
|
|
618
|
-
console.log(` change_id: ${manifest.change_id}`);
|
|
619
|
-
console.log(` files: ${manifest.files.length}`);
|
|
620
|
-
|
|
621
|
-
const publicationStatus = result?.data?.report?.publication_status
|
|
622
|
-
|| result?.report?.publication_status
|
|
623
|
-
|| '';
|
|
624
|
-
if (publicationStatus === 'needs_review') {
|
|
625
|
-
console.log('\n⚠️ 历史 Spec 已安全保存为候选,尚未进入 current 召回');
|
|
626
|
-
console.log(` → 审核 ${path.join(folderPath, 'ontology', 'spec-ontology.json')}`);
|
|
627
|
-
if (legacyStatus === 'needs_review') {
|
|
628
|
-
console.log(' → 正文没有足够的结构化事实,只能作为证据保留');
|
|
629
|
-
} else {
|
|
630
|
-
console.log(' → 运行 approve-spec-ontology 后再次上传即可发布;不带 --require-review 上传则自动确认');
|
|
631
|
-
}
|
|
632
|
-
} else {
|
|
633
|
-
// Published commit has been verified; a receipt does not imply projection completion.
|
|
634
|
-
updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// === 投影状态提示 ===
|
|
638
|
-
// 上传成功后,KB 后端异步执行 vector embedding + AGE graph 投影。
|
|
639
|
-
// 正常 SDD 流程中,Spec→Design 等阶段间有用户手动触发的时间间隔,投影通常在此期间完成。
|
|
640
|
-
const projStatus = result?.data?.projectionStatus || result?.projectionStatus || '';
|
|
641
|
-
const jobStatus = result?.data?.status || result?.status || '';
|
|
642
|
-
|
|
643
|
-
if (projStatus === 'complete') {
|
|
644
|
-
console.log('\n✓ 投影已完成,向量/图检索可用');
|
|
645
|
-
} else if (projStatus === 'degraded') {
|
|
646
|
-
console.log('\n⚠️ 投影完成但有降级(部分通道不可用,召回结果会标记降级)');
|
|
647
|
-
} else if (projStatus === 'disabled') {
|
|
648
|
-
console.log('\nℹ️ 投影已禁用(仅可使用当前已就绪的检索通道)');
|
|
649
|
-
} else if (projStatus && projStatus !== 'complete') {
|
|
650
|
-
console.log(`\nℹ️ 投影进行中 (${projStatus}),向量/图检索暂不可用`);
|
|
651
|
-
console.log(' → 正常 SDD 流程中阶段间间隔通常足够投影完成');
|
|
652
|
-
console.log(' → 如后续查询 degraded,可用 `context-client.cjs --check-only` 确认 KB 可用性');
|
|
653
|
-
} else if (jobStatus === 'succeeded') {
|
|
654
|
-
console.log('\n✓ 入库成功');
|
|
655
|
-
}
|
|
621
|
+
// Direct JSON transport reads the existing documents; ZIP remains for --package compatibility.
|
|
656
622
|
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
console.log('\n✓ 临时文件已清理');
|
|
661
|
-
}
|
|
623
|
+
const result = await require('./kb-ingest.cjs').uploadChanges(specRoot, [{ key: changeName }], { spaceId: args['space-id'], kbId: args['kb-id'], overrideUploadTarget: Boolean(args['override-upload-target']), requireReview: Boolean(args['require-review']), confirmReview: Boolean(args['confirm-review']) });
|
|
624
|
+
console.log(JSON.stringify(result, null, 2));
|
|
625
|
+
if (result.items.some(item => item.status === 'failed')) { for (const item of result.items.filter(i => i.status === 'failed')) console.error(item.message); process.exitCode = 1; }
|
|
662
626
|
|
|
663
627
|
} catch (err) {
|
|
664
628
|
console.error(`\n❌ ${err.message}`);
|
|
@@ -666,5 +630,6 @@ async function main() {
|
|
|
666
630
|
}
|
|
667
631
|
}
|
|
668
632
|
|
|
633
|
+
module.exports = { resolveUploadTarget, prepareUploadConfig, readKbState, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
|
|
634
|
+
|
|
669
635
|
if (require.main === module) main();
|
|
670
|
-
module.exports = { resolveUploadTarget, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// One resolver for tray, Skills, retrieval, uploads and reporting. Never export a
|
|
3
|
+
// personal file wholesale as team configuration.
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const PUBLIC_KEYS = ['api', 'targets', 'uploadTarget'];
|
|
8
|
+
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
9
|
+
function endpoint(value) {
|
|
10
|
+
const url = new URL(value);
|
|
11
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('知识库服务地址无效');
|
|
12
|
+
return url.toString().replace(/\/$/, '').replace(/\/api$/, '') + '/api';
|
|
13
|
+
}
|
|
14
|
+
function objectFile(file) {
|
|
15
|
+
if (!fs.existsSync(file)) return null;
|
|
16
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('配置文件不能是符号链接');
|
|
17
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('配置必须是 JSON 对象');
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function paths(root) {
|
|
22
|
+
return { team: path.join(root, 'kb.config.json'), personal: path.join(root, 'kb-state.json'), state: path.join(root, 'skywalk-sdd/state/ingestions.json') };
|
|
23
|
+
}
|
|
24
|
+
function unionTargets(a, b) {
|
|
25
|
+
const result = [];
|
|
26
|
+
for (const t of [...(a || []), ...(b || [])]) {
|
|
27
|
+
if (!t || typeof t.spaceId !== 'string' || typeof t.kbId !== 'string' || !t.spaceId || !t.kbId) throw new Error('知识库目标必须包含空间和知识库');
|
|
28
|
+
if (!result.some(x => x.spaceId === t.spaceId && x.kbId === t.kbId)) result.push(t);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function readProjectConfig(root) {
|
|
33
|
+
const files = paths(root), personal = objectFile(files.personal) || {}, team = objectFile(files.team);
|
|
34
|
+
const revision = hash([files.team, files.personal].map(f => fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '').join('\0'));
|
|
35
|
+
let config = { ...personal }, warnings = [];
|
|
36
|
+
if (team) {
|
|
37
|
+
if (team.schemaVersion !== 1) throw new Error('团队知识库配置版本不兼容,请升级 kld-sdd');
|
|
38
|
+
if (Object.keys(team).some(k => !['schemaVersion', ...PUBLIC_KEYS].includes(k))) throw new Error('团队知识库配置包含非公共字段,请移到个人配置');
|
|
39
|
+
if (!Array.isArray(team.targets) || !Object.hasOwn(team, 'uploadTarget')) throw new Error('团队配置缺少读取范围或上传位置');
|
|
40
|
+
config = { ...personal, api: endpoint(team.api), targets: unionTargets(team.targets, personal.personalReadTargets), uploadTarget: team.uploadTarget };
|
|
41
|
+
}
|
|
42
|
+
const credentialApi = personal.credentialForApi || personal.api;
|
|
43
|
+
if (personal.apiKey && config.api && ((!credentialApi && team) || (credentialApi && credentialApi !== config.api && endpoint(credentialApi) !== endpoint(config.api)))) {
|
|
44
|
+
config.apiKey = ''; config.credentialMismatch = true;
|
|
45
|
+
warnings.push('知识库服务地址已变化,请重新登录;原 Key 已保留在本机');
|
|
46
|
+
}
|
|
47
|
+
// A changed shared server must not inherit a process-wide credential either.
|
|
48
|
+
if (team && !config.apiKey) config.credentialMismatch = true;
|
|
49
|
+
return { config, personal, team, files, revision, warnings, path: fs.existsSync(files.personal) || team ? files.personal : null };
|
|
50
|
+
}
|
|
51
|
+
function atomic(file, value) {
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
53
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error('配置文件不能是符号链接');
|
|
54
|
+
const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
|
|
55
|
+
try { fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); fs.renameSync(temp, file); }
|
|
56
|
+
finally { fs.rmSync(temp, { force: true }); }
|
|
57
|
+
}
|
|
58
|
+
function saveProjectConfig(root, config, options = {}) {
|
|
59
|
+
return require('../ontology/change-lock.cjs').withChangeLock(root, 'kb-config', {}, () => {
|
|
60
|
+
const before = readProjectConfig(root);
|
|
61
|
+
if (options.revision != null && options.revision !== before.revision) throw new Error('配置已被其他程序修改,请重新读取');
|
|
62
|
+
const api = endpoint(config.api);
|
|
63
|
+
protectPersonal(root);
|
|
64
|
+
const apiKey = config.credentialMismatch && !config.apiKey ? before.personal.apiKey : config.apiKey;
|
|
65
|
+
const sameKey = apiKey && apiKey === before.personal.apiKey;
|
|
66
|
+
const credentialApi = sameKey && !options.credentialVerified ? (before.personal.credentialForApi || before.personal.api || api) : api;
|
|
67
|
+
let personal = { ...before.personal, ...config, ...(apiKey !== undefined ? { apiKey } : {}), api, credentialForApi: credentialApi };
|
|
68
|
+
if (options.team === true || before.team) {
|
|
69
|
+
const targets = unionTargets(config.targets, []);
|
|
70
|
+
const selected = config.uploadTarget || null;
|
|
71
|
+
if (selected && !targets.some(t => t.spaceId === selected.spaceId && t.kbId === selected.kbId)) throw new Error('上传位置必须在所选知识库中');
|
|
72
|
+
if (options.team === true) {
|
|
73
|
+
const team = { schemaVersion: 1, api, targets: targets.map(t => Object.fromEntries(['spaceId', 'kbId', 'spaceKey', 'spaceName', 'kbName'].filter(k => typeof t[k] === 'string').map(k => [k, t[k]]))), uploadTarget: selected && { spaceId: selected.spaceId, kbId: selected.kbId } };
|
|
74
|
+
atomic(before.files.team, team);
|
|
75
|
+
} else {
|
|
76
|
+
if (api !== endpoint(before.team.api) || JSON.stringify(selected) !== JSON.stringify(before.team.uploadTarget)) throw new Error('修改团队地址或上传位置,请选择「团队共享」保存');
|
|
77
|
+
}
|
|
78
|
+
const defaults = options.team === true ? targets : before.team.targets;
|
|
79
|
+
personal.personalReadTargets = targets.filter(t => !defaults.some(x => x.spaceId === t.spaceId && x.kbId === t.kbId));
|
|
80
|
+
delete personal.targets; delete personal.uploadTarget;
|
|
81
|
+
}
|
|
82
|
+
delete personal.credentialMismatch;
|
|
83
|
+
if (personal.archiveCache) {
|
|
84
|
+
const state = objectFile(before.files.state) || { schemaVersion: 1, receipts: {} };
|
|
85
|
+
state.legacyArchiveCache = { ...personal.archiveCache, ...state.legacyArchiveCache };
|
|
86
|
+
atomic(before.files.state, state); delete personal.archiveCache;
|
|
87
|
+
}
|
|
88
|
+
protectPersonal(root);
|
|
89
|
+
atomic(before.files.personal, personal);
|
|
90
|
+
return readProjectConfig(root);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function readReceipts(root) { return objectFile(paths(root).state) || { schemaVersion: 1, receipts: {} }; }
|
|
94
|
+
function writeReceipt(root, key, value) {
|
|
95
|
+
return require('../ontology/change-lock.cjs').withChangeLock(root, 'kb-config', {}, () => {
|
|
96
|
+
const state = readReceipts(root); state.receipts ||= {}; state.receipts[key] = value;
|
|
97
|
+
protectPersonal(root); atomic(paths(root).state, state); return value;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function protectPersonal(root) {
|
|
101
|
+
const file = path.join(root, '.gitignore');
|
|
102
|
+
if (fs.existsSync(file) && fs.lstatSync(file).isSymbolicLink()) throw new Error('.gitignore 不能是符号链接');
|
|
103
|
+
const old = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
104
|
+
const missing = ['/kb-state.json', '/skywalk-sdd/state/'].filter(rule => !old.split(/\r?\n/).includes(rule));
|
|
105
|
+
if (missing.length) fs.writeFileSync(file, old + (old.endsWith('\n') || !old ? '' : '\n') + missing.join('\n') + '\n');
|
|
106
|
+
}
|
|
107
|
+
module.exports = { endpoint, paths, readProjectConfig, saveProjectConfig, readReceipts, writeReceipt, atomic, unionTargets };
|
|
@@ -352,12 +352,13 @@ function loadKbState(anchorDir = __dirname, env = process.env) {
|
|
|
352
352
|
let current = start;
|
|
353
353
|
for (let level = 0; level < MAX_KB_STATE_LEVELS; level += 1) {
|
|
354
354
|
const candidate = path.join(current, KB_STATE_FILENAME);
|
|
355
|
-
if (fs.existsSync(candidate) && isKbStateHostDir(current)) {
|
|
355
|
+
if ((fs.existsSync(candidate) || fs.existsSync(path.join(current, 'kb.config.json'))) && isKbStateHostDir(current)) {
|
|
356
356
|
try {
|
|
357
|
-
|
|
358
|
-
return { config, path: candidate, warnings };
|
|
357
|
+
return require('./kb-config.cjs').readProjectConfig(current);
|
|
359
358
|
} catch (e) {
|
|
360
359
|
warnings.push(`kb-state.json 解析失败 (${candidate}): ${e.message}`);
|
|
360
|
+
// A broken configuration belongs to this Spec. Never borrow another project's credentials.
|
|
361
|
+
return { config: null, path: candidate, warnings };
|
|
361
362
|
}
|
|
362
363
|
}
|
|
363
364
|
if (current === fileRoot) break;
|
|
@@ -391,7 +392,7 @@ function normalizeProjectName(value) {
|
|
|
391
392
|
*/
|
|
392
393
|
function resolveKbParams(stateConfig = {}, env = process.env) {
|
|
393
394
|
const apiBase = stateConfig.api || env.ENGINEERING_KB_API || 'http://localhost:8090/api';
|
|
394
|
-
const token = stateConfig.apiKey || env.ENGINEERING_KB_TOKEN || '';
|
|
395
|
+
const token = stateConfig.credentialMismatch ? '' : stateConfig.apiKey || env.ENGINEERING_KB_TOKEN || '';
|
|
395
396
|
const spaceId = stateConfig.targets && stateConfig.targets.length > 0
|
|
396
397
|
? stateConfig.targets[0].spaceId : '';
|
|
397
398
|
const kbId = stateConfig.targets && stateConfig.targets.length > 0
|
|
@@ -523,7 +524,8 @@ function setKbProjectName(specRoot, projectName, options = {}) {
|
|
|
523
524
|
error.code = 'E_PROJECT_NAME_INVALID';
|
|
524
525
|
throw error;
|
|
525
526
|
}
|
|
526
|
-
const
|
|
527
|
+
const personal = require('./kb-config.cjs').readProjectConfig(root).personal;
|
|
528
|
+
const next = { ...personal, projectName: normalized };
|
|
527
529
|
writeJsonAtomic(statePath, next);
|
|
528
530
|
return { ok: true, path: statePath, projectName: next.projectName };
|
|
529
531
|
}
|