dsh-paoding 0.3.10 → 0.4.1

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
+ }
@@ -13,7 +13,10 @@
13
13
  * profile 目录里直跑 `pnpm add dsh-paoding@<目标版本>`:本包已在 profile
14
14
  * package.json 的 dsh.profile.bundles 清单里,无需 bundle reconcile;用
15
15
  * 精确版本而非 update,避开 pnpm 新鲜度防护 / 镜像 packument 滞后造成的
16
- * 静默空转。
16
+ * 静默空转。pnpm 本身按候选链解析(裸 pnpm → corepack pnpm → PNPM_HOME
17
+ * 常见落位,仅 ENOENT 换下一路)——corepack 管的机器没跑过 corepack
18
+ * enable 时 PATH 里没有裸 pnpm,0.3.8~0.4.0 直 spawn 裸名即报
19
+ * `spawn pnpm ENOENT`(0.4.1 修)。
17
20
  *
18
21
  * 本模块只负责「拿目标版本 → 探测自身 profile → 跑子进程」这一段编排;
19
22
  * profile 名从本文件运行时真实路径反推(registry 安装形态必然位于
@@ -77,7 +80,7 @@ function defaultSpawn(command, args, options, timeoutMs, timeoutLabel) {
77
80
  }, timeoutMs)
78
81
  child.on('error', (err) => {
79
82
  clearTimeout(timer)
80
- resolve({ code: null, output: tailOutput(output), error: String(err?.message ?? err) })
83
+ resolve({ code: null, output: tailOutput(output), error: String(err?.message ?? err), errorCode: err?.code ?? null })
81
84
  })
82
85
  child.on('close', (code) => {
83
86
  clearTimeout(timer)
@@ -128,8 +131,10 @@ function desktopProfileDir() {
128
131
  * node:child_process 的 spawn、Date.now()。
129
132
  * - timeoutMs:升级子进程超时(含 pnpm 下载安装),默认 120s。
130
133
  * - spawnImpl 契约:(command, args, options, timeoutMs, timeoutLabel) →
131
- * Promise resolving { code, output, error? }(同 defaultSpawn 的收敛形状;
132
- * timeoutLabel 仅缺省实现用于超时文案,注入桩可忽略)。
134
+ * Promise resolving { code, output, error?, errorCode? }(同 defaultSpawn
135
+ * 的收敛形状;errorCode 为子进程 spawn 失败的 err.code,如 'ENOENT',
136
+ * desktop 分支据此走 pnpm 候选链回落;timeoutLabel 仅缺省实现用于超时
137
+ * 文案,注入桩可忽略)。
133
138
  * @returns {Promise<object>} 恒不抛错,收敛为:
134
139
  * - { ok: true, upToDate: true, current } 本地已不落后 registry
135
140
  * - { ok: true, version, profile, output, elapsedMs } 升级成功(退出码 0)
@@ -193,19 +198,62 @@ async function doRunUpgrade({ profile, fetchImpl, spawnImpl, timeoutMs, now }) {
193
198
  return { ok: false, error: `desktop profile 目录不存在或未初始化: ${desktopDir}` }
194
199
  }
195
200
  const useShell = process.platform === 'win32'
196
- const command = isDesktop ? (useShell ? 'pnpm.cmd' : 'pnpm') : useShell ? 'dsh.cmd' : 'dsh'
197
201
  const args = isDesktop
198
202
  ? ['add', `dsh-paoding@${targetVersion}`]
199
203
  : ['plugin', '--profile', resolved, 'update', 'dsh-paoding']
200
204
  const run = typeof spawnImpl === 'function' ? spawnImpl : defaultSpawn
201
- const proc = await run(command, args, {
205
+ const spawnOptions = {
202
206
  cwd: isDesktop ? desktopDir : os.tmpdir(),
203
207
  env: process.env,
204
208
  stdio: ['ignore', 'pipe', 'pipe'],
205
209
  shell: useShell,
206
- }, timeoutMs, isDesktop ? 'pnpm add' : 'dsh plugin update')
210
+ }
211
+
212
+ // pnpm/dsh 候选链:spawn ENOENT(命令在 PATH 里不存在)才换下一路,
213
+ // EACCES / 超时 / 退出码非 0 都是真实运行结果,如实返回不换通道。
214
+ // - 裸 pnpm:npm i -g pnpm / homebrew 等常规安装直接命中;
215
+ // - corepack pnpm:corepack 随 node 发行,node 在 PATH 就兜得住,
216
+ // 未 enable 也能即装即用(corepack pnpm add ...);
217
+ // - PNPM_HOME 常见落位(darwin ~/Library/pnpm、linux ~/.local/share/pnpm)
218
+ // 的绝对路径:pnpm 独立安装脚本形态,spawn 绝对路径不依赖 PATH;
219
+ // existsSync 预筛,不存在不浪费一次 spawn。
220
+ // 常规 profile 的 dsh 单候选同走这套循环,ENOENT 也收敛成可读文案。
221
+ const suffix = useShell ? '.cmd' : ''
222
+ const candidates = isDesktop
223
+ ? [
224
+ { command: `pnpm${suffix}`, argsPrefix: [], label: 'pnpm' },
225
+ { command: `corepack${suffix}`, argsPrefix: ['pnpm'], label: 'corepack pnpm' },
226
+ ]
227
+ : [{ command: `dsh${suffix}`, argsPrefix: [], label: 'dsh' }]
228
+ if (isDesktop) {
229
+ const homes = [path.join(os.homedir(), 'Library', 'pnpm'), path.join(os.homedir(), '.local', 'share', 'pnpm')]
230
+ for (const home of homes) {
231
+ const bin = path.join(home, `pnpm${suffix}`)
232
+ if (existsSync(bin)) candidates.push({ command: bin, argsPrefix: [], label: bin })
233
+ }
234
+ }
235
+
236
+ let proc = null
237
+ for (const cand of candidates) {
238
+ proc = await run(cand.command, [...cand.argsPrefix, ...args], spawnOptions, timeoutMs,
239
+ isDesktop ? 'pnpm add' : 'dsh plugin update')
240
+ if (proc?.errorCode !== 'ENOENT') break
241
+ }
207
242
  const output = tailOutput(proc?.output)
208
243
 
244
+ // 全候选 ENOENT:给可操作的出路,不裸抛 spawn xxx ENOENT。
245
+ if (proc?.errorCode === 'ENOENT') {
246
+ const tried = candidates.map((c) => c.label).join(' / ')
247
+ return {
248
+ ok: false,
249
+ error: isDesktop
250
+ ? `未找到可用的 pnpm(已试 ${tried})。可先在终端执行 corepack enable 并重启 DSH 后重试;或手动升级:cd ${desktopDir} && corepack pnpm add dsh-paoding@${targetVersion}`
251
+ : `未找到 dsh 命令(PATH 异常?已试 ${tried}),请检查 dsh 安装后重试`,
252
+ output,
253
+ elapsedMs: elapsedMs(),
254
+ }
255
+ }
256
+
209
257
  // ④ 无磁盘后验(旧 npx 时代读 $DSH_HOME/dsh-paoding 对账的方案已随该目录
210
258
  // 一并废弃):本进程代码不随升级变化,无从对账;成功与否以退出码为准,
211
259
  // 新版本重启 DSH 后生效(preset 由新版启动钩子自动重生成)。