dsh-paoding 0.3.10 → 0.4.0

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,298 @@
1
+ /**
2
+ * 结构化配置体检(save-time config health check):把散落在生成期 console.warn
3
+ * 里的告警(fork+model 互斥、roles_remove 命中 start_mode 等)与「配置了但宿主
4
+ * 未挂载」「零工具角色」「未知模型」「预设漂移」四类静默问题收口成
5
+ * { items: [{ level: 'error'|'warn'|'info', code, message }] },随面板 preview /
6
+ * apply 响应的 warnings 字段与 CLI --dry-run 报告透传(面板渲染由客户端批次做,
7
+ * 本模块只负责产出结构化字段)。
8
+ *
9
+ * 检查项一览(每项一个独立函数,可单测):
10
+ * - unmounted_tool (warn):assignments 配置的具体工具名(roles.*.tools 与
11
+ * main_agent_extra)不在 presetUniverse ∪ inventory。白名单语义与生成层
12
+ * filterUsableTools 同源(不另立判断,防两处漂移);含 `*`/`?` 的条目跳过
13
+ * (glob 支持由另一批次实现,这里不抢)。
14
+ * - zero_tool_role (error):内置角色过滤后 allow 为空(提前给可读信息;
15
+ * composeGenerated 的既有 throw 行为不变,体检在前、throw 兜底);自定义
16
+ * 角色 tools 为空同口径(renderCustomRoleBlocks 的 throw 同样不动)。
17
+ * - unknown_model (warn):roles.*.model 配了但模型目录(models,可选)查无
18
+ * 此名;models 为 null(CLI / 面板模型目录拿不到)时整项跳过。
19
+ * - preset_drift (info):已安装预设与当前配置的再生成结果不一致(复用
20
+ * generateAndInstall 的 dryRun 产物文本对比;dryRunResult 已由调用方传入时
21
+ * 直接消费,不再重跑生成)。
22
+ * - generation (warn):生成期 warn 原样透传(captureWarnings 接管 console.warn
23
+ * 抓取 warning: 前缀行),不重复实现判定逻辑。
24
+ *
25
+ * 依赖 util(ROLES)、compose(filterUsableTools)、state(generateAndInstall,
26
+ * 仅在未拿到 dryRunResult 时做一次 dry-run 预演);被 api-core / cli / doctor
27
+ * 引用。healthCheck 自身绝不 throw——体检坏了不该连带请求 500。
28
+ */
29
+ import { readFileSync } from 'node:fs'
30
+ import path from 'node:path'
31
+ import { ROLES } from './util.mjs'
32
+ import { filterUsableTools } from './compose.mjs'
33
+ import { generateAndInstall } from './state.mjs'
34
+
35
+ const WARN_PREFIX = 'warning: '
36
+
37
+ /**
38
+ * 临时接管 console.warn,收集窗口期内所有告警行。返回 [fn() 结果, 消息数组];
39
+ * 消息剥掉 util.warn 的 `warning: ` 前缀(透传给结构化 items 的 message 用)。
40
+ * 非字符串参数取 message 字段或 String() 兜底;finally 里必还原,fn 抛错也
41
+ * 不吞——需要「抓告警 + 容错」的调用方自己包 try/catch(见 installAssignments)。
42
+ */
43
+ export function captureWarnings(fn) {
44
+ const messages = []
45
+ const original = console.warn
46
+ console.warn = (...args) => {
47
+ const text = args.map((a) => (typeof a === 'string' ? a : String(a?.message ?? a))).join(' ')
48
+ messages.push(text.startsWith(WARN_PREFIX) ? text.slice(WARN_PREFIX.length) : text)
49
+ }
50
+ let result
51
+ try {
52
+ result = fn()
53
+ } finally {
54
+ console.warn = original
55
+ }
56
+ return [result, messages]
57
+ }
58
+
59
+ /** 体检结果条目工厂:统一形状,防各检查项字段漂移。 */
60
+ function item(level, code, message) {
61
+ return { level, code, message }
62
+ }
63
+
64
+ /**
65
+ * presetUniverse(restrict.mjs 主 agent 白名单 ∪ 源 preset 各角色 allow 自带面),
66
+ * 与 composeGenerated 同一构造。state 必须是原始 collectState 产物(blocks 是
67
+ * Map)——序列化视图喂进来 blocks 不可枚举时按空面兜底(结果只会多报
68
+ * unmounted_tool warn,不会误报 error)。
69
+ */
70
+ function presetUniverseOf(state) {
71
+ const universe = new Set(Array.isArray(state.restrictBase) ? state.restrictBase : [])
72
+ if (state.blocks instanceof Map) {
73
+ for (const block of state.blocks.values()) {
74
+ for (const name of block.names) universe.add(name)
75
+ }
76
+ }
77
+ return universe
78
+ }
79
+
80
+ /** inventory 兜底:原始 state 是 Set;容一个数组形态(序列化视图误入时)。 */
81
+ function inventoryOf(state) {
82
+ return state.inventory instanceof Set
83
+ ? state.inventory
84
+ : new Set(Array.isArray(state.inventory) ? state.inventory : [])
85
+ }
86
+
87
+ /** assignments 里配置的具体工具名 → [{ name, where }](roles.*.tools + main_agent_extra)。 */
88
+ function configuredToolRefs(assignments) {
89
+ const refs = []
90
+ const roles = assignments && typeof assignments.roles === 'object' && assignments.roles !== null ? assignments.roles : {}
91
+ for (const [role, conf] of Object.entries(roles)) {
92
+ if (!conf || typeof conf !== 'object') continue
93
+ const tools = Array.isArray(conf.tools) ? conf.tools : []
94
+ for (const tool of tools) {
95
+ if (typeof tool === 'string' && tool !== '') refs.push({ name: tool, where: `roles.${role}.tools` })
96
+ }
97
+ }
98
+ const extra = Array.isArray(assignments?.main_agent_extra) ? assignments.main_agent_extra : []
99
+ for (const tool of extra) {
100
+ if (typeof tool === 'string' && tool !== '') refs.push({ name: tool, where: 'main_agent_extra' })
101
+ }
102
+ return refs
103
+ }
104
+
105
+ /** 收集 roles.*.model 引用([{ toolName, model }],trim 后非空才算配置了)。 */
106
+ export function collectModelRefs(assignments) {
107
+ const refs = []
108
+ const roles = assignments && typeof assignments.roles === 'object' && assignments.roles !== null ? assignments.roles : {}
109
+ for (const [toolName, conf] of Object.entries(roles)) {
110
+ if (!conf || typeof conf !== 'object') continue
111
+ if (typeof conf.model === 'string' && conf.model.trim() !== '') {
112
+ refs.push({ toolName, model: conf.model.trim() })
113
+ }
114
+ }
115
+ return refs
116
+ }
117
+
118
+ // ── 检查项:unmounted_tool ──────────────────────────────────────────────────
119
+
120
+ /**
121
+ * 配置了但当前未挂载(presetUniverse ∪ inventory 都查无)的工具名。可用性判定
122
+ * 直接复用生成层的 filterUsableTools(同一白名单语义,零漂移);`*`/`?` 通配
123
+ * 条目跳过(glob 支持另一批次实现)。
124
+ */
125
+ function checkUnmountedTools(refs, presetUniverse, inventory) {
126
+ const names = [...new Set(refs.map((r) => r.name))]
127
+ const kept = new Set(filterUsableTools(names, presetUniverse, inventory))
128
+ const items = []
129
+ const seen = new Set()
130
+ for (const { name, where } of refs) {
131
+ if (name.includes('*') || name.includes('?')) continue
132
+ if (kept.has(name)) continue
133
+ const key = `${name}\u0000${where}`
134
+ if (seen.has(key)) continue
135
+ seen.add(key)
136
+ items.push(item('warn', 'unmounted_tool', `已配置但当前未挂载:${name}(${where})`))
137
+ }
138
+ return items
139
+ }
140
+
141
+ // ── 检查项:zero_tool_role ──────────────────────────────────────────────────
142
+
143
+ /**
144
+ * 过滤后工具为空的角色。意图(intent)取法与 composeGenerated 逐字对齐:
145
+ * 显式 `tools: []` 就按空算(不回落源模板 allow),只有键缺省才回落 blocks;
146
+ * 自定义角色不经过滤、tools 为空即零工具(与 renderCustomRoleBlocks 同口径)。
147
+ * 只产 error 条目,不在本层拦下生成——composeGenerated 的既有 throw 照旧兜底。
148
+ */
149
+ function checkZeroToolRoles(assignments, state, presetUniverse, inventory) {
150
+ const items = []
151
+ const roles = assignments && typeof assignments.roles === 'object' && assignments.roles !== null ? assignments.roles : {}
152
+ const removedRoles = new Set(Array.isArray(assignments?.roles_remove) ? assignments.roles_remove : [])
153
+ const blocks = state.blocks instanceof Map ? state.blocks : null
154
+ for (const role of ROLES) {
155
+ if (removedRoles.has(role)) continue
156
+ const conf = roles[role] && typeof roles[role] === 'object' ? roles[role] : null
157
+ const intent = Array.isArray(conf?.tools) ? conf.tools : (blocks?.get(role)?.names ?? [])
158
+ if (filterUsableTools(intent, presetUniverse, inventory).length === 0) {
159
+ items.push(
160
+ item(
161
+ 'error',
162
+ 'zero_tool_role',
163
+ `角色 ${role} 过滤后没有任何可用工具(零工具角色会被拒绝安装):` +
164
+ `请在配置里为它勾选至少一个已挂载工具,或加入 roles_remove 删除该角色`,
165
+ ),
166
+ )
167
+ }
168
+ }
169
+ for (const [toolName, conf] of Object.entries(roles)) {
170
+ if (ROLES.includes(toolName)) continue
171
+ const tools = Array.isArray(conf?.tools) ? conf.tools : []
172
+ if (tools.length === 0) {
173
+ items.push(
174
+ item(
175
+ 'error',
176
+ 'zero_tool_role',
177
+ `自定义角色 ${toolName} 没有配置任何工具(零工具角色会被拒绝安装):请在配置里为它写 tools 列表`,
178
+ ),
179
+ )
180
+ }
181
+ }
182
+ return items
183
+ }
184
+
185
+ // ── 检查项:unknown_model ───────────────────────────────────────────────────
186
+
187
+ /** 模型目录(面板 models 路由的 groups 形状)→ 已知模型 id 集合;容忍扁平 id 数组。 */
188
+ function knownModelIds(models) {
189
+ const ids = new Set()
190
+ if (!Array.isArray(models)) return ids
191
+ for (const entry of models) {
192
+ if (typeof entry === 'string' && entry !== '') {
193
+ ids.add(entry)
194
+ continue
195
+ }
196
+ if (!entry || typeof entry !== 'object') continue
197
+ if (typeof entry.id === 'string' && entry.id !== '') ids.add(entry.id)
198
+ if (Array.isArray(entry.models)) {
199
+ for (const model of entry.models) {
200
+ if (typeof model === 'string' && model !== '') ids.add(model)
201
+ else if (model && typeof model.id === 'string' && model.id !== '') ids.add(model.id)
202
+ }
203
+ }
204
+ }
205
+ return ids
206
+ }
207
+
208
+ /** roles.*.model 配了但目录查无 → warn。models 为 null(拿不到目录)整项跳过。 */
209
+ function checkUnknownModels(assignments, models) {
210
+ if (models === null || models === undefined) return []
211
+ const known = knownModelIds(models)
212
+ if (known.size === 0) return []
213
+ return collectModelRefs(assignments)
214
+ .filter(({ model }) => !known.has(model))
215
+ .map(({ toolName, model }) =>
216
+ item('warn', 'unknown_model', `roles.${toolName}.model: '${model}' 不在当前模型目录中(名单以设置页模型选择器为准,配错会被静默回落)`),
217
+ )
218
+ }
219
+
220
+ // ── 检查项:preset_drift ────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * 已安装预设 vs 再生成结果:对比 dryRunResult.text 与落盘的 agent.cordis.yml
224
+ * (按 dryRunResult.presetId 定位,工作区预设同样适用)。文本一致 → 无条目;
225
+ * 落盘文件缺失(未安装)→ info 条目提示应用后生成;内容有差 → info「落后于
226
+ * 配置」。拿不到 dryRunResult(调用方没生成过)时返回空,由调用方决定是否
227
+ * 先做 dry-run 预演再传入。
228
+ */
229
+ function checkPresetDrift(state, dryRunResult) {
230
+ if (!dryRunResult || typeof dryRunResult.text !== 'string') return []
231
+ const presetId = typeof dryRunResult.presetId === 'string' && dryRunResult.presetId !== '' ? dryRunResult.presetId : 'orchestrator'
232
+ const dshHome = typeof state.dshHome === 'string' ? state.dshHome : null
233
+ if (!dshHome) return []
234
+ let installed = null
235
+ try {
236
+ installed = readFileSync(path.join(dshHome, '.agent-presets', presetId, 'agent.cordis.yml'), 'utf8')
237
+ } catch {
238
+ installed = null
239
+ }
240
+ if (installed === dryRunResult.text) return []
241
+ return [
242
+ item(
243
+ 'info',
244
+ 'preset_drift',
245
+ installed === null
246
+ ? `预设 ${presetId} 尚未安装:应用一次(面板「保存并应用」或 CLI --auto)即生成`
247
+ : `预设 ${presetId} 落后于配置,重新应用即同步`,
248
+ ),
249
+ ]
250
+ }
251
+
252
+ // ── 入口 ────────────────────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * 跑全套体检,返回 { items }。参数:
256
+ * - state:原始 collectState 产物(restrictBase / blocks / inventory / dshHome)。
257
+ * - assignments:待检的目标字段集合(配置面板提交形状或 loadConfig 产物)。
258
+ * - models:模型目录(面板 models 路由的 groups 数组);null = 跳过 unknown_model。
259
+ * - dryRunResult:调用方已有的 generateAndInstall 产物(preview/apply 顺路传入,
260
+ * 不重跑生成);null 时本函数自己跑一次 dry-run 预演(doctor / 直接调用)。
261
+ * - generationWarnings:调用方在其生成窗口内用 captureWarnings 抓到的告警
262
+ * (installAssignments 传入,避免同一次生成跑两遍);null 且无 dryRunResult
263
+ * 时由本函数的预演自行抓取。
264
+ * 任何内部失败都折成条目(generation_error),本函数自身不 throw。
265
+ */
266
+ export function healthCheck({ state, assignments, models = null, dryRunResult = null, generationWarnings = null } = {}) {
267
+ const items = []
268
+ if (!state || typeof state !== 'object') return { items }
269
+
270
+ const presetUniverse = presetUniverseOf(state)
271
+ const inventory = inventoryOf(state)
272
+
273
+ // 生成(或复用调用方产物)与生成期告警:三条互斥路径——
274
+ // 1) dryRunResult 已给 → 全复用,零重跑;
275
+ // 2) 只有 generationWarnings(原生成已失败,installAssignments 容错路径)
276
+ // → 消费现成告警,不再重跑(失败场景重跑只会复现同一个错);
277
+ // 3) 都没有 → 自己做一次 dry-run 预演并抓告警;预演失败折成 generation_error。
278
+ let result = dryRunResult
279
+ let warns = Array.isArray(generationWarnings) ? generationWarnings : null
280
+ if (result === null && warns === null) {
281
+ try {
282
+ const [ran, captured] = captureWarnings(() => generateAndInstall(state, assignments ?? {}, { dryRun: true }))
283
+ result = ran
284
+ warns = captured
285
+ } catch (err) {
286
+ items.push(item('error', 'generation_error', `生成预演失败:${err?.message ?? err}`))
287
+ }
288
+ }
289
+
290
+ items.push(...checkUnmountedTools(configuredToolRefs(assignments), presetUniverse, inventory))
291
+ items.push(...checkZeroToolRoles(assignments, state, presetUniverse, inventory))
292
+ items.push(...checkUnknownModels(assignments, models))
293
+ if (Array.isArray(warns)) {
294
+ for (const message of warns) items.push(item('warn', 'generation', message))
295
+ }
296
+ items.push(...checkPresetDrift(state, result))
297
+ return { items }
298
+ }