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.
- package/README.md +26 -22
- package/README_en.md +26 -22
- package/bin/dsh-paoding.mjs +73 -11
- package/docs/architecture.md +36 -31
- package/docs/architecture_en.md +54 -38
- package/docs/configuration.md +151 -57
- package/docs/configuration_en.md +155 -60
- package/docs/installation.md +96 -234
- package/docs/installation_en.md +111 -252
- package/docs/orchestration.md +24 -24
- package/docs/orchestration_en.md +24 -24
- package/package.json +8 -5
- package/plugins/paoding-config-ui/api-core.mjs +215 -18
- package/plugins/paoding-config-ui/index.mjs +413 -154
- package/plugins/paoding-config-ui/lib/base.css +1083 -42
- package/plugins/paoding-config-ui/lib/client.js +2266 -252
- package/plugins/paoding-config-ui/package.json +2 -2
- package/plugins/paoding-config-ui/runtime-inventory.mjs +27 -26
- package/presets/orchestrator/agent.cordis.yml +19 -9
- package/presets/orchestrator/preset.yml +3 -2
- package/presets/orchestrator/restrict.mjs +2 -1
- package/tools/install.mjs +9 -4
- package/tools/lib/alloc.mjs +60 -29
- package/tools/lib/cli.mjs +127 -279
- package/tools/lib/compose.mjs +261 -95
- package/tools/lib/config.mjs +266 -86
- package/tools/lib/host.mjs +55 -24
- package/tools/lib/skills.mjs +72 -45
- package/tools/lib/state.mjs +172 -40
- package/tools/lib/upgrade.mjs +170 -0
- package/tools/lib/util.mjs +7 -0
- package/tools/lib/version.mjs +233 -0
- package/tools/lib/workspaces.mjs +74 -0
- package/tools/lib/yaml.mjs +92 -15
- package/install.sh +0 -31
- package/tools/lib/wizard.mjs +0 -516
|
@@ -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/
|
|
6
|
-
* client.css 静态样式),经
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
* -
|
|
19
|
-
*
|
|
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
|
|
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
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 === '
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
241
|
-
services[key] = row
|
|
387
|
+
return
|
|
242
388
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
577
|
+
// 路由就绪后异步触发首装 / 升级后 preset 自愈(不 await,不阻塞启动;
|
|
578
|
+
// 结果只走日志,成败都不影响路由服务)。
|
|
579
|
+
triggerPresetEnsure(ctx, () => disposed)
|
|
580
|
+
// 包装宿主 disposer:先立 disposed 标志(在途 preset 自愈就此止步、不再
|
|
581
|
+
// 写盘),再交给宿主注销路由。
|
|
582
|
+
return () => {
|
|
583
|
+
disposed = true
|
|
584
|
+
disposer?.()
|
|
585
|
+
}
|
|
327
586
|
}
|