dsh-paoding 0.2.2 → 0.3.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.
@@ -1,35 +1,59 @@
1
1
  /**
2
2
  * paoding-config-ui — DSH Web 插件 (cordis)
3
3
  *
4
- * 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(设置页分区,无独立服务端):
5
- * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/preview/apply/rescan +
6
- * client.css 静态样式),经 ./api-core.mjs 复用 tools/install.mjs 的
7
- * 检测与生成管线。
8
- * - Client 侧:lib/client.js 将配置面板挂到设置页的 settings.section 槽位。
4
+ * 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(左侧栏全页配置,无独立服务端):
5
+ * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/version/
6
+ * upgrade/preview/apply/rescan + client.css 静态样式),经
7
+ * ./api-core.mjs 复用 tools/install.mjs 的检测与生成管线;workspaces 直连
8
+ * 宿主 workspaceRegistry 服务(list / create,幂等);version
9
+ * 复用 tools/lib/version.mjs 的版本检测(npm registry 优先、GitHub release
10
+ * 兜底);upgrade 经 tools/lib/upgrade.mjs spawn `dsh plugin update` 完成
11
+ * 一键升级。首装 / 升级后 preset 自动重生成(ensurePresetInstalled,等价
12
+ * install --auto 语义)在路由注册完成后异步触发,不阻塞启动。
13
+ * - Client 侧:lib/client.js 在左侧栏「新会话」下方注入「庖丁配置」入口,
14
+ * 点击后在会话中栏打开全页配置 overlay(data-dsh-paoding-active)。
9
15
  *
10
16
  * 挂载(~/.dsh/cordis.patch.yml):
11
17
  * - id: paoding-config-ui
12
- * name: paoding-config-ui # 解析自 $DSH_HOME/node_modules(或插件市场安装)
18
+ * name: dsh-paoding # 指向 pnpm 装进 profile 的本包(与 cordis.patch.yml 一致)
13
19
  *
14
20
  * 借鉴 dsh-better-sidebar 的机制(详见其 docs/plans 与 AGENTS.md):
15
21
  * - 客户端 bundle 以 window.__ModuleLoader__.load({id, factory}) 注册,由
16
22
  * dsh-client-modules 节点半扫描 dsh.client 声明并以 /plugins/<id>/client.js
17
23
  * 下发;bundle 的注册 id 必须等于包名(图行 id)。
18
- * - 设置分区经 ctx.slots.inject('settings.section') + slots.register() 挂载,
19
- * id/order/label 驱动设置壳导航(与 ui-settings-general 的 general 分区同款)。
24
+ * - 全页配置页不走宿主槽位:入口经 DOM 注入 + MutationObserver 自愈挂到
25
+ * 侧栏(与 dsh-mnemon 同款 sidebar 模式),overlay 容器挂到会话中栏,
26
+ * 打开态由 html[data-dsh-paoding-active] 驱动,与其它全页面板互斥。
20
27
  * - 宿主路由自带信任围栏:/api/paoding 长于 /api 前缀,webserver 最长前缀
21
28
  * 优先会命中本插件而绕过 /api 网关的围栏,故在此复刻同一围栏语义
22
29
  * (Host 回环或 webRuntime.trustedHosts,拒绝 cross-site 浏览器请求)。
23
30
  */
31
+ import { existsSync, statSync } from 'node:fs'
24
32
  import { readFile } from 'node:fs/promises'
25
- import { getState, refreshState, hasStateCache, serializeState, installAssignments } from './api-core.mjs'
33
+ import os from 'node:os'
34
+ import path from 'node:path'
35
+ import { fileURLToPath } from 'node:url'
36
+ import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion } from './api-core.mjs'
37
+ import { detectSkills } from '../../tools/lib/skills.mjs'
26
38
  import { collectRuntimeFacts } from './runtime-inventory.mjs'
39
+ import { getVersionInfo } from '../../tools/lib/version.mjs'
40
+ import { runUpgrade } from '../../tools/lib/upgrade.mjs'
27
41
 
28
42
  export const name = 'paoding-config-ui'
29
- // apiProxy:宿主 API 代理服务——models 路由经 ctx.apiProxy.llm.models({})
30
- // 取 DSH 已配置/已注册的模型目录(设置页模型选择器同款目录,JSON-safe 纯
31
- // 对象;失败组在 failures 里,不抛错)。
32
- export const inject = ['webServer', 'apiProxy']
43
+
44
+ // 挂载形态判定(version 路由的 layout 字段,供客户端确认框分流文案;不放进
45
+ // 共享的 version.mjs——CLI 不需要这个概念,路由层合并即可):
46
+ // dev → 插件包根(本文件向上两级)含 .git,即开发检出、`dsh plugin
47
+ // add link:<仓库绝对路径>` 直连本仓库;无法就地升级(upgrade
48
+ // 会拒绝并提示手动 dsh plugin add dsh-paoding@latest 切到
49
+ // registry 版本)。
50
+ // installed → registry 版本(插件通道 pnpm 安装进 profile),升级原地换版本。
51
+ const MOUNT_LAYOUT = existsSync(new URL('../../.git', import.meta.url)) ? 'dev' : 'installed'
52
+ // llm:宿主模型运行时服务(DSH ≥ 0.1.6 取代旧 apiProxy.llm)——models 路由
53
+ // 经 ctx.llm.listProviders() + listModels(provider) 取已注册的模型目录
54
+ //(设置页模型选择器同款目录,JSON-safe 纯对象;失败组在 failures 里,不抛错)。
55
+ // workspaceRegistry:宿主工作区注册表(list / resolveByPath / create)。
56
+ export const inject = ['webServer', 'llm', 'workspaceRegistry']
33
57
 
34
58
  // ── 浏览器信任围栏(复刻 dsh-client-connection 的 /api 网关围栏语义,
35
59
  // 参考 dsh-better-sidebar/src/trust-fence.ts,BSD-3-Clause 同源实现)──────
@@ -74,7 +98,9 @@ function isTrustedApiRequest(req, trustedHosts) {
74
98
  try { return new URL(origin).hostname === hostUrl.hostname } catch { return false }
75
99
  }
76
100
 
77
- /** Read a JSON request body (bounded). */
101
+ /** Read a JSON request body (bounded). 失败错误带 statusCode(413/400),
102
+ * 外层 catch 据此回对应状态码;客户端提前断开时 reject(Promise 已 settled
103
+ * 则 reject 为 no-op,正常完成后的 close 事件同样无害)。 */
78
104
  function readBody(req) {
79
105
  return new Promise((resolve, reject) => {
80
106
  const chunks = []
@@ -82,7 +108,9 @@ function readBody(req) {
82
108
  req.on('data', (c) => {
83
109
  size += c.length
84
110
  if (size > 5 * 1024 * 1024) {
85
- reject(new Error('body too large'))
111
+ const err = new Error('request body too large')
112
+ err.statusCode = 413
113
+ reject(err)
86
114
  req.destroy()
87
115
  return
88
116
  }
@@ -92,10 +120,14 @@ function readBody(req) {
92
120
  try {
93
121
  resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
94
122
  } catch {
95
- reject(new Error('invalid JSON body'))
123
+ const err = new Error('invalid JSON body')
124
+ err.statusCode = 400
125
+ reject(err)
96
126
  }
97
127
  })
98
128
  req.on('error', reject)
129
+ req.on('close', () => reject(new Error('request closed before body completed')))
130
+ req.on('aborted', () => reject(new Error('request aborted')))
99
131
  })
100
132
  }
101
133
 
@@ -108,11 +140,124 @@ function json(res, status, payload) {
108
140
  res.end(text)
109
141
  }
110
142
 
143
+ // ── 对外错误信息脱敏(home 目录 / 插件安装根 → ~)───────────────────────────
144
+ // 错误原文(可能带本机绝对路径)只进日志;回给浏览器的 message 先过这里。
145
+
146
+ const SANITIZE_HOME_DIR = os.homedir()
147
+ const SANITIZE_PLUGIN_ROOT = fileURLToPath(new URL('../..', import.meta.url)).replace(/[\\/]+$/, '')
148
+
149
+ function sanitizeMessage(message) {
150
+ let text = String(message ?? '')
151
+ // 先长后短:安装根通常是 home 的子路径,先替换更具体的
152
+ text = text.split(SANITIZE_PLUGIN_ROOT).join('~')
153
+ text = text.split(SANITIZE_HOME_DIR).join('~')
154
+ return text
155
+ }
156
+
157
+ // ── 首装 / 升级后 preset 自动重生成(启动钩子)──────────────────────────────
158
+
159
+ // 进程级防抖:每次插件 apply 至多触发一次自愈(防未来的热重载 / 多次 apply
160
+ // 重复跑全量检测 + 落盘)。
161
+ let presetEnsureStarted = false
162
+
163
+ /** 路由注册完成后异步触发 preset 自愈(不 await、不阻塞启动;结果只进日志)。
164
+ * isDisposed:dispose 探针——ensurePresetInstalled 在各 await 点后检查,
165
+ * 插件卸载后在途的生成不再继续写盘。 */
166
+ function triggerPresetEnsure(ctx, isDisposed) {
167
+ if (presetEnsureStarted) return
168
+ presetEnsureStarted = true
169
+ const generatorVersion = pluginVersion()
170
+ // ensurePresetInstalled 自身绝不抛;.catch 只是防御性兜底。
171
+ ensurePresetInstalled({ dshHome: resolveDshHome(), generatorVersion, shouldAbort: isDisposed })
172
+ .then((r) => {
173
+ if (r.installed) {
174
+ ctx.logger?.info?.(
175
+ `paoding-config-ui: orchestrator preset 已重生成(generator v${generatorVersion},${r.reason})`,
176
+ )
177
+ } else if (r.error) {
178
+ // 失败复位防抖闩:下次 activate(热重载 / 重注册)可重试
179
+ presetEnsureStarted = false
180
+ ctx.logger?.warn?.(`paoding-config-ui: preset 自动生成失败(可在 设置 → 庖丁配置 手动应用): ${r.error}`)
181
+ }
182
+ // 版本一致的常规跳过不打日志:每次启动都刷一行纯噪音。
183
+ })
184
+ .catch((err) => {
185
+ presetEnsureStarted = false
186
+ ctx.logger?.warn?.('paoding-config-ui: preset 自动生成异常: ' + String(err?.message ?? err))
187
+ })
188
+ }
189
+
190
+ // ── persistence 探测(可续子代理的前置条件,只喂面板警告,不拦 preview/apply)──
191
+ //
192
+ // DSH 的可续(continuable)子代理要求 host 同时提供 sessionPersistence 与
193
+ // sessionQuery 两个服务,缺一个委派就直接报 PERSISTENCE_UNAVAILABLE /
194
+ // CONTINUATION_UNAVAILABLE。此处做免 inject 的安全探测:cordis 的
195
+ // ctx.reflect.get(name, false) 读不到返回 undefined,也不触发 inject 护栏
196
+ //(ctx[name] 直取才会抛);整个探测 try/catch 兜底,任何异常一律按「不可用」
197
+ // 处理——false 只影响面板警告文案的显隐。
198
+ function persistenceAvailableOf(ctx) {
199
+ try {
200
+ const reflect = ctx.reflect
201
+ if (!reflect || typeof reflect.get !== 'function') return false
202
+ return !!(reflect.get('sessionPersistence', false) && reflect.get('sessionQuery', false))
203
+ } catch {
204
+ return false
205
+ }
206
+ }
207
+
208
+ /** 面板 state 响应统一挂 persistenceAvailable(state / rescan 同一形状)。 */
209
+ function stateResponse(ctx, state) {
210
+ return { ...state, persistenceAvailable: persistenceAvailableOf(ctx) }
211
+ }
212
+
213
+ // ── state 读取共享 helper(「采集运行时事实 → 重刷 → 读缓存 →〔按需〕序列化」)──
214
+
215
+ // in-flight 去重:全量检测进行中时并发请求共用同一次采集(并发 GET state 只
216
+ // 跑一次检测,不重复重握手 MCP)。检测本身不抛(collectRuntimeFacts /
217
+ // refreshState 均自捕),promise 不会 reject。
218
+ let stateDetectInFlight = null
219
+
220
+ /**
221
+ * 取原始 state(给生成管线 installAssignments / generateAndInstall 用的原始
222
+ * state 通道:Set/Map/dstDir/yamlMod 原样,绝不能过 serializeState——JSON
223
+ * 视图会丢 dstDir/yamlMod、Set→排序数组、Map→普通对象,喂进生成管线必炸
224
+ * TypeError,apply/preview 一律 422;契约由 tests/api-apply.test.mjs 钉死)。
225
+ * 缓存命中且配置文件 mtime 未变(外部 CLI / 手编改动检测)时直接返回缓存;
226
+ * 否则(缓存缺失 / 已失效 / force)带实时运行时事实做一次全量检测再返回。
227
+ * state 首刷 / rescan / apply 前后共三处走这一条路径(前两处再过序列化层)。
228
+ */
229
+ async function rawStateWithRuntimeFacts(ctx, { force = false } = {}) {
230
+ if (!force && !isStateCacheStale()) return getState()
231
+ stateDetectInFlight ??= (async () => {
232
+ const facts = await collectRuntimeFacts(ctx)
233
+ await refreshState(facts)
234
+ })()
235
+ try {
236
+ await stateDetectInFlight
237
+ } finally {
238
+ stateDetectInFlight = null
239
+ }
240
+ return getState()
241
+ }
242
+
243
+ /** 取面板 state(浏览器 JSON 视图):原始 state 过 serializeState 的序列化层。 */
244
+ async function stateWithRuntimeFacts(ctx, { force = false } = {}) {
245
+ return serializeState(await rawStateWithRuntimeFacts(ctx, { force }))
246
+ }
247
+
248
+ // apply in-flight 闸(模块级):apply 落盘期间再来的 apply 直接 409,防并发写配置。
249
+ let applyInFlight = false
250
+
111
251
  export function apply(ctx) {
112
252
  const { webServer } = ctx
253
+ // dispose 探针:插件卸载后,在途异步任务(preset 自愈)不得再写盘。
254
+ let disposed = false
113
255
  // webRuntime 由 dsh-web-app 的 web-runtime 行提供;非 web 部署下缺失时退化为
114
- // 仅回环信任(每次请求读实时值,trustedHosts 变化无需重启插件)。
115
- const trustedHostsOf = () => ctx.get('webRuntime')?.trustedHosts ?? []
256
+ // 仅回环信任(每次请求读实时值,trustedHosts 变化无需重启插件)。读取自捕:
257
+ // 围栏检查不能以 unhandled rejection 收场(读不到按空信任表继续)。
258
+ const trustedHostsOf = () => {
259
+ try { return ctx.get('webRuntime')?.trustedHosts ?? [] } catch { return [] }
260
+ }
116
261
 
117
262
  const disposer = webServer.register({
118
263
  kind: 'prefix',
@@ -140,21 +285,21 @@ export function apply(ctx) {
140
285
  })
141
286
  res.end(css)
142
287
  } catch (err) {
143
- json(res, 500, { error: `client.css unavailable: ${err.message}` })
288
+ ctx.logger?.warn?.('paoding-config-ui: client.css read failed: ' + String(err?.message ?? err))
289
+ json(res, 500, { error: sanitizeMessage(`client.css unavailable: ${err.message}`) })
144
290
  }
145
291
  return
146
292
  }
147
293
 
148
294
  if (route === 'state' && req.method === 'GET') {
149
- // 仅缓存缺失时带实时运行时事实做一次全量检测(成本同旧版首次 GET),
150
- // 命中缓存直接返回——运行时注册表只在 DSH 重启/插件启停时变化,
151
- // 没必要让每次打开面板都重握手 MCP。
152
- if (!hasStateCache()) {
153
- const facts = await collectRuntimeFacts(ctx)
154
- await refreshState(facts)
155
- }
156
- const s = await getState()
157
- json(res, 200, serializeState(s))
295
+ // 缓存命中且配置文件 mtime 未变直接返回;缓存缺失 / 外部(CLI/手编)
296
+ // 改过配置时带实时运行时事实做一次全量检测(成本同旧版首次 GET)。
297
+ // 运行时注册表只在 DSH 重启/插件启停时变化,没必要每次打开面板都重
298
+ // 握手 MCP;mtime 对账保证外部改动不视而不见。并发请求共用同一次
299
+ // 检测(stateWithRuntimeFacts 去重)。响应附带 persistenceAvailable
300
+ //(sessionPersistence + sessionQuery 双服务在场的免 inject 探测,
301
+ // 面板据此显隐可续模式的警告;缺失只警告,preview/apply 不拦截)。
302
+ json(res, 200, stateResponse(ctx, await stateWithRuntimeFacts(ctx)))
158
303
  return
159
304
  }
160
305
 
@@ -164,164 +309,278 @@ export function apply(ctx) {
164
309
  // 目录发现可能稍慢,属正常。失败组在 failures 里,不抛错;这里自捕
165
310
  // 一层,保证错误也走 { ok: false, error } 形状(外层 catch 是 { error })。
166
311
  try {
167
- // apiProxy 的 llm.* 是裸 RPC handler:入参 { rpcId, payload } 信封、
168
- // 返回 { rpcId, result: { ok, value | error } } 信封(ok()/err() 同源
169
- // 形状),不是直接吐 { groups, failures }——必须拆一层信封再取 value。
170
- const envelope = await ctx.apiProxy.llm.models({ rpcId: 'paoding-models', payload: {} })
171
- const result = envelope && typeof envelope.result === 'object' && envelope.result !== null
172
- ? envelope.result
173
- : null
174
- if (!result || !result.ok) {
175
- const message = result && result.error && result.error.message ? result.error.message : '模型目录调用失败'
176
- throw new Error(message)
177
- }
178
- const catalog = result.value
179
- if (!catalog || !Array.isArray(catalog.groups)) throw new Error('模型目录响应缺少 groups')
180
- json(res, 200, { ok: true, groups: catalog.groups, failures: Array.isArray(catalog.failures) ? catalog.failures : [] })
312
+ // ctx.llm(DSH ≥ 0.1.6 的宿主模型运行时):listProviders() 给已注册
313
+ // 适配器的 provider 路由({ id, ... }),listModels(provider) 给该
314
+ // 路由的目录({ provider, id, name, ... })。逐 provider 并发实调
315
+ // 映射成客户端的 groups 形状(group.id = provider 路由 id);单路由
316
+ // 失败隔离记入 failures 不抛错,与旧 apiProxy 时代的容错语义一致。
317
+ const providers = ctx.llm.listProviders()
318
+ const failures = []
319
+ const results = await Promise.all((Array.isArray(providers) ? providers : []).map(async (provider) => {
320
+ const id = provider && typeof provider.id === 'string' ? provider.id : null
321
+ if (!id) return null
322
+ try {
323
+ const models = await ctx.llm.listModels(id)
324
+ return {
325
+ id,
326
+ name: provider.name || provider.displayName || id,
327
+ models: (Array.isArray(models) ? models : [])
328
+ .filter((m) => m && typeof m.id === 'string' && m.id !== '')
329
+ .map((m) => ({ id: m.id, name: m.name || m.id })),
330
+ }
331
+ } catch (err) {
332
+ failures.push(`${id}: ${err.message}`)
333
+ return null
334
+ }
335
+ }))
336
+ json(res, 200, { ok: true, groups: results.filter(Boolean), failures })
181
337
  } catch (err) {
182
338
  json(res, 500, { ok: false, error: err.message })
183
339
  }
184
340
  return
185
341
  }
186
342
 
187
- if (route === 'rescan' && req.method === 'POST') {
188
- // 重新检测 = 采集实时事实 → 带事实重刷状态(getState 不再自己重刷)
189
- const facts = await collectRuntimeFacts(ctx)
190
- await refreshState(facts)
191
- const s = await getState()
192
- json(res, 200, serializeState(s))
193
- return
194
- }
195
-
196
- // ── TEMP PROBE (generic runtime detection groundwork; remove later) ──
197
- // 崩溃安全:任何一步失败都记入 errors 而非 500;访问路径分别报告
198
- // reflect.get / ctx.get / ctx[key] 三种取法的可用性(cordis 的 ctx[key]
199
- // 受 inject 护栏约束,ctx.reflect.get 是官方无 inject 读取口)。
200
- if (route === 'probe' && req.method === 'GET') {
201
- const cap = (arr, n) => (Array.isArray(arr) ? arr.slice(0, n) : arr)
202
- const methodNamesOf = (svc) => {
203
- const methods = []
204
- for (let p = Object.getPrototypeOf(svc); p && p !== Object.prototype && methods.length < 60; p = Object.getPrototypeOf(p)) {
205
- for (const m of Object.getOwnPropertyNames(p)) {
206
- if (m !== 'constructor' && typeof svc[m] === 'function' && !methods.includes(m)) methods.push(m)
207
- }
343
+ if (route === 'workspaces' && req.method === 'GET') {
344
+ // 按工作区拉技能(?skills=1&path=<urlencoded 绝对路径>):技能扫描
345
+ // 不跟随 GUI 进程启动目录后,客户端工作区视角的技能网格由此取数——
346
+ // 用户级两根恒扫(detectSkills(dshHome, null)),项目级两根按 path
347
+ // 显式追加;projectSkills = 全集里不在用户级的部分(该项目根独有)。
348
+ // path 缺失 / 空值 422;目录不存在不报错——detectSkills 自己跳过
349
+ // 缺失根,项目集自然为空。下方无参 / discover 分支行为不动。
350
+ if (url.searchParams.get('skills') === '1') {
351
+ const wsPath = url.searchParams.get('path')
352
+ if (typeof wsPath !== 'string' || wsPath.trim() === '') {
353
+ json(res, 422, { error: '缺少工作区目录绝对路径(query.path)' })
354
+ return
208
355
  }
209
- return methods
210
- }
211
- const probe = { errors: [] }
212
- const tryReport = (label, fn) => {
213
- try { probe[label] = fn() } catch (err) { probe[label] = 'error: ' + String(err?.message ?? err) }
356
+ const dshHome = resolveDshHome()
357
+ const userSkills = detectSkills(dshHome, null)
358
+ const all = detectSkills(dshHome, wsPath)
359
+ const userSet = new Set(userSkills)
360
+ const projectSkills = all.filter((s) => !userSet.has(s))
361
+ json(res, 200, { ok: true, skills: all, projectSkills })
362
+ return
214
363
  }
215
-
216
- // 1) 每类服务三种取法的可用性 + 方法表面
217
- // ⚠️ 切勿把服务实例本身放进结果:ctx 服务是 cordis 代理,JSON
218
- // stringify 访问其 toJSON 会触发 inject 护栏抛错。只留纯数据。
219
- const svcNames = ['tools', 'pluginInventory', 'loader', 'commands', 'mnemon', 'codegraph',
220
- 'agentLoop', 'systemPrompt', 'settings', 'webRuntime', 'webServer', 'llm', 'mcp', 'attachments', 'reflect']
221
- const services = {}
222
- for (const key of svcNames) {
223
- const row = {}
224
- let found = undefined
225
- for (const [path, access] of [
226
- ['reflect', () => ctx.reflect?.get?.(key, false)],
227
- ['get', () => ctx.get?.(key)],
228
- ['prop', () => ctx[key]],
229
- ]) {
230
- try {
231
- const v = access()
232
- if (v !== undefined && v !== null) {
233
- row[path] = 'ok'
234
- if (found === undefined) found = v
235
- } else {
236
- row[path] = 'missing'
237
- }
238
- } catch (err) { row[path] = 'throw: ' + String(err?.message ?? err) }
364
+ // 候选发现(?discover=1):给「添加工作区」组合框供候选——扫已知工作区
365
+ // 路径的父目录与 home 一层里带项目标记(.git / .codegraph / package.json)
366
+ // 的目录,已在注册表 / 配置里的不再建议。两路数据源都允许失败:宿主注册
367
+ // 表拉不到就当空注册表继续扫,配置键取 getState() 失败也按空数组继续,
368
+ // discovery 不因此 5xx;只有扫描本身出错才 500(客户端届时静默降级纯手输)。
369
+ if (url.searchParams.get('discover') === '1') {
370
+ let registryPaths = []
371
+ try {
372
+ // workspaceRegistry.list() 同步返回注册表实体({ id, path, ... })
373
+ registryPaths = ctx.workspaceRegistry.list()
374
+ .map((w) => (w && typeof w.path === 'string' && w.path !== '' ? w.path : null))
375
+ .filter(Boolean)
376
+ } catch { /* 注册表不可用:按空名单继续发现 */ }
377
+ let configPaths = []
378
+ try {
379
+ const s = await getState()
380
+ configPaths = Object.keys((s.existing && s.existing.workspaces) || {})
381
+ } catch { /* 状态缓存不可用:按无配置工作区继续 */ }
382
+ try {
383
+ json(res, 200, { ok: true, items: discoverWorkspaceCandidates({ registryPaths, configPaths }) })
384
+ } catch (err) {
385
+ json(res, 500, { ok: false, error: err.message })
239
386
  }
240
- if (found !== undefined) row.methods = methodNamesOf(found)
241
- services[key] = row
387
+ return
242
388
  }
243
- probe.services = services
244
-
245
- // 2) pluginInventory.list() 直接采样
246
- tryReport('pluginInventory', () => {
247
- const pi = ctx.reflect?.get?.('pluginInventory', false) ?? ctx.get?.('pluginInventory')
248
- if (!pi || typeof pi.list !== 'function') return 'no service / no list()'
249
- const entries = pi.list()?.entries
250
- if (!Array.isArray(entries)) return 'list() shape: ' + typeof entries
251
- return entries.slice(0, 60).map((e) => ({ entryId: e.entryId, moduleName: e.moduleName, enabled: e.enabled, fiberPhase: e.fiberPhase }))
252
- })
389
+ // 工作区列表:宿主 workspaceRegistry(同步 list),精简成 UI 需要的
390
+ // { workspaceId, path, title, sessionCount }。失败 500 + ok:false
391
+ //(客户端 api 助手对非 2xx 一律 throw,面板据此降级为只用配置文件里
392
+ // 的工作区键,不阻塞使用)。
393
+ try {
394
+ const items = ctx.workspaceRegistry.list()
395
+ json(res, 200, {
396
+ ok: true,
397
+ items: items.map((w) => ({
398
+ workspaceId: w?.id,
399
+ path: w?.path,
400
+ title: w?.title ?? null,
401
+ sessionCount: Array.isArray(w?.sessionIds) ? w.sessionIds.length : 0,
402
+ })),
403
+ })
404
+ } catch (err) {
405
+ json(res, 500, { ok: false, error: err.message })
406
+ }
407
+ return
408
+ }
253
409
 
254
- // 3) tools 服务的枚举口 + 工具名采样
255
- tryReport('toolsMethods', () => {
256
- const t = ctx.reflect?.get?.('tools', false) ?? ctx.get?.('tools')
257
- return t ? methodNamesOf(t) : 'no service'
258
- })
259
- for (const [label, method] of [
260
- ['toolsSchemas', 'schemas'],
261
- ['toolsEntries', 'entries'],
262
- ]) {
263
- tryReport(label, () => {
264
- const t = ctx.reflect?.get?.('tools', false) ?? ctx.get?.('tools')
265
- if (!t || typeof t[method] !== 'function') return 'no ' + method + '()'
266
- const result = t[method]()
267
- if (result == null || typeof result[Symbol.iterator] !== 'function') return 'not iterable: ' + typeof result
268
- const names = []
269
- for (const item of result) {
270
- const name = typeof item === 'string' ? item : (item && typeof item === 'object' ? (typeof item.name === 'string' ? item.name : (Array.isArray(item) ? item[0] : undefined)) : undefined)
271
- if (typeof name === 'string') names.push(name)
272
- if (names.length >= 120) break
273
- }
274
- return { count: names.length, sample: cap(names, 120) }
410
+ if (route === 'workspaces' && req.method === 'POST') {
411
+ // 注册工作区(宿主 workspace.create 幂等:已注册原样返回 created:false)。
412
+ // 只收非空字符串路径;创建成功后把归一后的 path/title 回给客户端,
413
+ // UI 直接切到该工作区。
414
+ const body = await readBody(req)
415
+ const wsPath = typeof body.path === 'string' ? body.path.trim() : ''
416
+ if (wsPath === '') {
417
+ json(res, 422, { error: '缺少工作区目录绝对路径(body.path)' })
418
+ return
419
+ }
420
+ // 存在预检:调宿主 create 之前先确认路径真实存在且是目录——坏路径
421
+ // 不必劳宿主一趟(create 幂等语义也可能把不存在的路径照单全收),
422
+ // 这里一句中文报错直给客户端,落 wsAddErr 展示。
423
+ let wsStat = null
424
+ try { wsStat = statSync(wsPath) } catch { /* 不存在:stat 抛错,落到下方 422 */ }
425
+ if (!wsStat || !wsStat.isDirectory()) {
426
+ json(res, 422, { error: `目录不存在或不是目录:${wsPath}` })
427
+ return
428
+ }
429
+ try {
430
+ // 与宿主 WorkspaceCommands.create 同款幂等语义:先 resolveByPath
431
+ // 查重,已注册原样返回 created:false,否则 create 新建。
432
+ const existing = await ctx.workspaceRegistry.resolveByPath(wsPath)
433
+ const workspace = existing !== undefined ? existing : await ctx.workspaceRegistry.create(wsPath)
434
+ json(res, 200, {
435
+ ok: true,
436
+ // path 以宿主返回的归一路径为准;title 宿主会兜底成 basename。
437
+ workspace: {
438
+ path: typeof workspace.path === 'string' && workspace.path !== '' ? workspace.path : path.normalize(path.resolve(wsPath)),
439
+ title: typeof workspace.title === 'string' && workspace.title !== '' ? workspace.title : null,
440
+ },
441
+ created: existing === undefined,
275
442
  })
443
+ } catch (err) {
444
+ json(res, 500, { ok: false, error: err.message })
276
445
  }
446
+ return
447
+ }
277
448
 
278
- json(res, 200, probe)
449
+ if (route === 'rescan' && req.method === 'POST') {
450
+ // 重新检测 = 无视缓存强制带实时运行时事实重刷一次再返回
451
+ //(getState 不再自己重刷;与其他请求并发时共用同一次检测)。
452
+ // 与 state 同款挂 persistenceAvailable,客户端 setData 两处同形状。
453
+ json(res, 200, stateResponse(ctx, await stateWithRuntimeFacts(ctx, { force: true })))
279
454
  return
280
455
  }
281
456
 
282
457
  if ((route === 'preview' || route === 'apply') && req.method === 'POST') {
283
458
  const body = await readBody(req)
284
- const s = await getState()
285
459
  const dryRun = route === 'preview'
460
+ // apply in-flight 闸(预览无写副作用不受限):落盘期间重复 apply 直接
461
+ // 409,防并发写配置文件。
462
+ if (!dryRun && applyInFlight) {
463
+ json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
464
+ return
465
+ }
466
+ if (!dryRun) applyInFlight = true
286
467
  try {
287
- const result = installAssignments(s, body.assignments, {
288
- dryRun,
289
- saveConfigOnWrite: !dryRun,
290
- })
291
- // apply 落盘后必须重刷状态缓存:stateCache 仍是应用前的快照(existing
292
- // 还是旧 config 的 roles),浏览器刷新读到的就是它——角色专用模型 /
293
- // 显示名等会“看起来保存丢失、回落默认”。这里与 rescan 同款带运行时
294
- // 事实重刷;best-effort:刷新失败不影响 apply 已成功的落盘结果
295
- // (refreshState 自捕错误置空缓存,下次 GET state 会带事实重建)。
296
- if (!dryRun && result.wrote) {
297
- try {
298
- const facts = await collectRuntimeFacts(ctx)
299
- await refreshState(facts)
300
- } catch (err) {
301
- ctx.logger?.warn?.('paoding-config-ui: post-apply state refresh failed: ' + String(err?.message ?? err))
468
+ // 可选工作区定位:body.workspace 非空字符串时归一成绝对路径,为该
469
+ // 工作区生成独立 preset(orchestrator-<slug>)并把条目并进配置
470
+ // workspaces 段;缺省 / null = 全局(现状行为)。
471
+ const rawWorkspace = typeof body.workspace === 'string' ? body.workspace.trim() : ''
472
+ const workspacePath = rawWorkspace === '' ? null : path.normalize(path.resolve(rawWorkspace))
473
+ // 工作区预检:与 POST /workspaces 同款 statSync+isDirectory——坏路径
474
+ // 不劳生成管线一趟,直接 422。
475
+ if (workspacePath !== null) {
476
+ let wsStat = null
477
+ try { wsStat = statSync(workspacePath) } catch { /* 不存在:stat 抛错,落到下方 422 */ }
478
+ if (!wsStat || !wsStat.isDirectory()) {
479
+ json(res, 422, { error: `目录不存在或不是目录:${rawWorkspace}` })
480
+ return
302
481
  }
303
482
  }
304
- json(res, 200, {
305
- dryRun,
306
- wrote: result.wrote,
307
- staleNote: result.staleNote,
308
- roleResults: result.roleResults,
309
- text: result.text,
310
- configFile: s.configFile,
311
- })
312
- } catch (err) {
313
- json(res, 422, { error: err.message })
483
+ // 落盘前确保基于新鲜 state:外部(CLI/手编)改过配置时按 mtime 作废
484
+ // 缓存重读(带运行时事实),防止陈旧 existing 覆盖丢失外部修改
485
+ //(generateAndInstall 只护 workspaces 段,其余以传入 state 为准)。
486
+ // 必须走原始 state 通道(Set/Map/dstDir/yamlMod 齐全)——序列化视图
487
+ // 喂生成管线会炸 TypeError(契约由 tests/api-apply.test.mjs 钉死)。
488
+ const s = await rawStateWithRuntimeFacts(ctx)
489
+ try {
490
+ const result = installAssignments(s, body.assignments, {
491
+ dryRun,
492
+ saveConfigOnWrite: !dryRun,
493
+ workspacePath,
494
+ })
495
+ // apply 落盘后必须重刷状态缓存:stateCache 仍是应用前的快照(existing
496
+ // 还是旧 config 的 roles),浏览器刷新读到的就是它——角色专用模型 /
497
+ // 显示名等会“看起来保存丢失、回落默认”。这里强制带运行时事实重刷;
498
+ // best-effort:刷新失败不影响 apply 已成功的落盘结果(缓存仍标失效,
499
+ // 下次 GET state 会带事实重建)。
500
+ if (!dryRun && result.wrote) {
501
+ try {
502
+ // 返回值本就丢弃:原始通道即可,白做一次序列化毫无意义。
503
+ await rawStateWithRuntimeFacts(ctx, { force: true })
504
+ } catch (err) {
505
+ ctx.logger?.warn?.('paoding-config-ui: post-apply state refresh failed: ' + String(err?.message ?? err))
506
+ }
507
+ }
508
+ json(res, 200, {
509
+ dryRun,
510
+ wrote: result.wrote,
511
+ staleNote: result.staleNote,
512
+ roleResults: result.roleResults,
513
+ text: result.text,
514
+ // 本次落点:全局 orchestrator 或工作区专属 orchestrator-<slug>。
515
+ presetId: result.presetId,
516
+ configFile: s.configFile,
517
+ })
518
+ } catch (err) {
519
+ json(res, 422, { error: err.message })
520
+ }
521
+ } finally {
522
+ if (!dryRun) applyInFlight = false
314
523
  }
315
524
  return
316
525
  }
317
526
 
527
+ if (route === 'version' && req.method === 'GET') {
528
+ // 版本提示:npm registry 优先、GitHub release 兜底(tools/lib/version.mjs,
529
+ // 带 1 小时内存缓存,命中不发外网请求)。绝不 5xx——检测失败也回
530
+ // 200 + error 字段,面板据此静默降级(不打扰使用)。timeoutMs 3000:
531
+ // 检测只是锦上添花,不值得让面板等更久。layout 告知客户端挂载形态
532
+ //(升级确认框按此分流警示文案),见 MOUNT_LAYOUT 注释。
533
+ json(res, 200, { ...(await getVersionInfo({ timeoutMs: 3000 })), layout: MOUNT_LAYOUT })
534
+ return
535
+ }
536
+
537
+ if (route === 'upgrade' && req.method === 'POST') {
538
+ // 一键升级:spawn `dsh plugin --profile <name> update dsh-paoding`
539
+ // 原地换版本(编排、profile 探测与互斥在 tools/lib/upgrade.mjs)。
540
+ // ① 双保险:先取缓存内的版本信息,已无新版(按钮不该出现——竞态或
541
+ // 误触 POST)就直接报最新,绝不无谓拉起子进程。
542
+ const info = await getVersionInfo({ timeoutMs: 3000 })
543
+ if (!info.updateAvailable) {
544
+ json(res, 200, { ok: true, upToDate: true, current: info.current })
545
+ return
546
+ }
547
+ // ② 真正执行:升级只换 profile 里的插件包本体,本进程代码不变、不越
548
+ // 俎代庖;DSH_HOME 经环境继承给 dsh 子进程自行解析。
549
+ const result = await runUpgrade()
550
+ // 失败用 500 而非 apply 的 422:升级失败不是「提交的配置不合法」,
551
+ // 而是环境/网络问题(pnpm 权限、断网、超时);且客户端 api 助手
552
+ // 对非 2xx 一律 throw 走 catch 醒目展示(这正是失败该有的观感),
553
+ // 200 + ok:false 反而容易被当成成功静默掉。restartRequired 只在真
554
+ // 的换了版本时为 true(upToDate / 失败都谈不上「需重启生效」)。
555
+ // message:成功换版后的生效说明,客户端直接展示在更新卡。
556
+ json(res, result.ok ? 200 : 500, {
557
+ ...result,
558
+ restartRequired: result.ok === true && result.upToDate !== true,
559
+ ...(result.ok === true && result.upToDate !== true
560
+ ? { message: `已升级到 v${result.version},重启 DSH 后新版本生效(preset 会自动按新版重生成)` }
561
+ : {}),
562
+ })
563
+ return
564
+ }
565
+
318
566
  json(res, 404, { error: `no such route: ${req.method} /api/paoding/${route}` })
319
567
  } catch (err) {
320
- json(res, 500, { error: err.message })
568
+ // 错误原文(可能带本机绝对路径)留日志;对外 message 先脱敏(home /
569
+ // 插件安装根 → ~)。readBody 的 413/400 带 statusCode,按其状态码回。
570
+ ctx.logger?.warn?.('paoding-config-ui: request failed: ' + String(err?.message ?? err))
571
+ json(res, Number.isInteger(err?.statusCode) ? err.statusCode : 500, { error: sanitizeMessage(err?.message ?? err) })
321
572
  }
322
573
  },
323
574
  })
324
575
 
325
576
  ctx.logger?.info?.('paoding-config-ui: /api/paoding/* routes registered')
326
- return disposer
577
+ // 路由就绪后异步触发首装 / 升级后 preset 自愈(不 await,不阻塞启动;
578
+ // 结果只走日志,成败都不影响路由服务)。
579
+ triggerPresetEnsure(ctx, () => disposed)
580
+ // 包装宿主 disposer:先立 disposed 标志(在途 preset 自愈就此止步、不再
581
+ // 写盘),再交给宿主注销路由。
582
+ return () => {
583
+ disposed = true
584
+ disposer?.()
585
+ }
327
586
  }