dsh-paoding 0.4.5 → 0.4.6
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 +5 -4
- package/README_en.md +5 -4
- package/bin/dsh-paoding.mjs +22 -10
- package/docs/configuration.md +4 -0
- package/docs/configuration_en.md +4 -0
- package/docs/installation.md +17 -7
- package/docs/installation_en.md +14 -6
- package/package.json +1 -1
- package/plugins/paoding-config-ui/api-core.mjs +171 -2
- package/plugins/paoding-config-ui/index.mjs +78 -4
- package/plugins/paoding-config-ui/lib/base.css +244 -0
- package/plugins/paoding-config-ui/lib/client.js +505 -43
- package/plugins/paoding-config-ui/package.json +1 -1
- package/plugins/paoding-config-ui/stats-core.mjs +362 -0
- package/tools/lib/cli.mjs +298 -8
- package/tools/lib/config.mjs +117 -0
- package/tools/lib/doctor.mjs +3 -13
- package/tools/lib/lifecycle.mjs +264 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 委派效果统计核心 —— DSH Web 插件 (plugins/paoding-config-ui) 的只读冷读层。
|
|
3
|
+
*
|
|
4
|
+
* A 路 = 宿主 sessionPersistence 服务:逐会话 open(id,'read') 冷读日志抽摘要
|
|
5
|
+
* (另一进程持有会话也能读),文件系统只列会话目录名做枚举与缓存对账,绝不
|
|
6
|
+
* 直接解日志文件——格式代际(session.jsonl / v1…v4、zstd / 明文)与撕尾恢复
|
|
7
|
+
* 都是宿主后端的内部细节。
|
|
8
|
+
*
|
|
9
|
+
* ── 目录与 slug 口径(对照 @deepseek-ai/dsh-session-persistence-jsonl 实测)──
|
|
10
|
+
* $DSH_HOME/sessions/<项目slug>/<会话id>/session[.vN].jsonl(.zstd)
|
|
11
|
+
* 项目 slug 即后端 projectKey(cwd):
|
|
12
|
+
* 分隔符 / \ : 折叠成单个 -;安全字符 [A-Za-z0-9._-] 原样;~ 与其余字符按
|
|
13
|
+
* UTF-16 码元 ~XXXX(大写十六进制)转义;剥前导 - 后以 -- 包头包尾,截 251。
|
|
14
|
+
* 本文件 projectKeyOf 为同款纯函数(真实目录名逐字验证过,见
|
|
15
|
+
* tests/api-stats.test.mjs 与真实 ~/.dsh/sessions 抽查),宿主侧实现有朝一日
|
|
16
|
+
* 变了以真实目录为准(stats 路由的 slug 来自查询参数,本函数只负责「缺省 =
|
|
17
|
+
* 当前面板项目」这一步换算)。
|
|
18
|
+
*
|
|
19
|
+
* ── 失败判据(判据与证据,2026-10 真实库 1278 会话抽查)────────────────────
|
|
20
|
+
* 父会话侧 job/settled 全库 903 条 kind 均为 "bash"——子代理委派不走后台 job
|
|
21
|
+
* 通道,父日志里没有可用的终止记录,判据只能取子会话自身的事件形态。DSH
|
|
22
|
+
* agent 循环每轮助手轮次以 turn/start 开、turn/end 收,据此:
|
|
23
|
+
* 正常完成 = owned 段 turn/start ≥ 1 且 turn/start === turn/end;
|
|
24
|
+
* 失败形态 = (a) turn/start > turn/end —— 轮次中断(进程崩溃 / 中途终止,
|
|
25
|
+
* 真实库存在 2 例);(b) turn/start === 0 —— 子会话被创建但从未
|
|
26
|
+
* 跑过任何轮次(spawn 后即死,真实库 9 例,含唯一一条真实
|
|
27
|
+
* paoding-role 样本:存活 88 秒、仅 4 条预置事件 + end-seed)。
|
|
28
|
+
* 计数只看 owned 段(inheritedEventCount 切掉继承前缀):可续子会话续跑时日
|
|
29
|
+
* 志带上一轮化身的历史,那段 turn 天然平衡;继承切点打在轮次边界(实测 73 处
|
|
30
|
+
* mid 位 end-seed 前一事件以 turn/end 为主),owned 段计数不受污染。时长与
|
|
31
|
+
* 窗口过滤同样只用 owned 段,防跨化身膨胀。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
35
|
+
import os from 'node:os'
|
|
36
|
+
import path from 'node:path'
|
|
37
|
+
import { resolveDshHome } from './api-core.mjs'
|
|
38
|
+
import { ROLE_PRESET_ID_PREFIX } from '../../tools/lib/compose.mjs'
|
|
39
|
+
|
|
40
|
+
/** 窗口缺省与上限(天):days 参数整数夹取到 [1, 365],无法解析回落缺省。 */
|
|
41
|
+
export const STATS_DEFAULT_DAYS = 30
|
|
42
|
+
export const STATS_MAX_DAYS = 365
|
|
43
|
+
|
|
44
|
+
// ── 参数清洗 ─────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** days 清洗:parseInt 夹取 [1, 365];NaN / 负数 / 0 回落缺省或夹到下限。 */
|
|
47
|
+
export function sanitizeStatsDays(raw) {
|
|
48
|
+
const n = Number.parseInt(String(raw ?? ''), 10)
|
|
49
|
+
if (!Number.isFinite(n)) return STATS_DEFAULT_DAYS
|
|
50
|
+
return Math.min(STATS_MAX_DAYS, Math.max(1, n))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* project slug 清洗:仅安全段字符(与会话 id 的 encodeSegment 白名单一致),
|
|
55
|
+
* 显式拒 '.' / '..',且 resolve 后不得逃出会话根(防符号级穿越)。'all' 放行
|
|
56
|
+
* ——跨项目聚合仅内部 / 测试用,面板 v1 不出此选项。非法返回 null(路由层
|
|
57
|
+
* 回 422)。
|
|
58
|
+
*/
|
|
59
|
+
export function sanitizeProjectSlug(raw, sessionsRoot) {
|
|
60
|
+
const slug = String(raw ?? '')
|
|
61
|
+
if (slug === 'all') return 'all'
|
|
62
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(slug)) return null
|
|
63
|
+
if (slug === '.' || slug === '..') return null
|
|
64
|
+
// join 后再 resolve 必须原样落回同一深层路径:'..' 之类会指向父目录而被拒
|
|
65
|
+
if (path.resolve(sessionsRoot, slug) !== path.join(sessionsRoot, slug)) return null
|
|
66
|
+
return slug
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── slug 换算(缺省 = 当前面板项目)──────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 宿主 projectKey(cwd) 的同款实现(口径见文件头注释):逐码元扫描,分隔符
|
|
73
|
+
* 连续段折叠成单个 '-',剥前导 '-',-- 包头包尾,251 截断。
|
|
74
|
+
*/
|
|
75
|
+
export function projectKeyOf(cwd) {
|
|
76
|
+
const text = String(cwd ?? '')
|
|
77
|
+
if (text.length === 0) throw new Error('cannot encode an empty project path')
|
|
78
|
+
let readable = ''
|
|
79
|
+
let separatorRun = false
|
|
80
|
+
for (let i = 0; i < text.length; i++) {
|
|
81
|
+
const ch = text[i]
|
|
82
|
+
if (ch === '/' || ch === '\\' || ch === ':') {
|
|
83
|
+
if (!separatorRun) readable += '-'
|
|
84
|
+
separatorRun = true
|
|
85
|
+
} else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
86
|
+
readable += ch
|
|
87
|
+
separatorRun = false
|
|
88
|
+
} else {
|
|
89
|
+
readable += '~' + text.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0')
|
|
90
|
+
separatorRun = false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return `--${(readable.replace(/^-+/, '') || 'root').slice(0, 251)}--`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 当前面板项目的 slug(cwd 口径与 api-core 的 state 采集一致)。 */
|
|
97
|
+
export function currentProjectSlug() {
|
|
98
|
+
const cwd = process.env.DSH_PAODING_CWD || process.cwd()
|
|
99
|
+
try {
|
|
100
|
+
return projectKeyOf(cwd)
|
|
101
|
+
} catch {
|
|
102
|
+
return '_no-cwd'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 会话枚举与缓存对账 ───────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** 会话日志文件名(最高代代际胜出:无版本号 = 0 代,vN 按数值比较)。 */
|
|
109
|
+
const SESSION_FILE_RE = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/
|
|
110
|
+
|
|
111
|
+
function currentSessionFile(sessionDir) {
|
|
112
|
+
let best = null
|
|
113
|
+
let bestVersion = -1
|
|
114
|
+
let names
|
|
115
|
+
try {
|
|
116
|
+
names = readdirSync(sessionDir)
|
|
117
|
+
} catch {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
for (const name of names) {
|
|
121
|
+
const m = SESSION_FILE_RE.exec(name)
|
|
122
|
+
if (!m) continue
|
|
123
|
+
const version = m[1] ? Number(m[1]) : 0
|
|
124
|
+
if (version > bestVersion) {
|
|
125
|
+
bestVersion = version
|
|
126
|
+
best = name
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return best
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 列一个项目 slug 下的会话目录名(即会话 id)。目录不存在 / 不可读 → 空数组。 */
|
|
133
|
+
function listSessionIds(sessionsRoot, slug) {
|
|
134
|
+
try {
|
|
135
|
+
return readdirSync(path.join(sessionsRoot, slug), { withFileTypes: true })
|
|
136
|
+
.filter((e) => e.isDirectory())
|
|
137
|
+
.map((e) => e.name)
|
|
138
|
+
} catch {
|
|
139
|
+
return []
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── 冷读摘要 ─────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** 一个会话的抽摘要结果(JSON-safe 纯数据,可进缓存)。 */
|
|
146
|
+
function summarizeEvents(header, allEvents, inheritedCount) {
|
|
147
|
+
const owned = inheritedCount > 0 ? allEvents.slice(inheritedCount) : allEvents
|
|
148
|
+
let firstTime = null
|
|
149
|
+
let lastTime = null
|
|
150
|
+
let turnStarts = 0
|
|
151
|
+
let turnEnds = 0
|
|
152
|
+
for (const event of owned) {
|
|
153
|
+
const time = event && typeof event.time === 'number' ? event.time : null
|
|
154
|
+
if (time !== null) {
|
|
155
|
+
if (firstTime === null) firstTime = time
|
|
156
|
+
lastTime = time
|
|
157
|
+
}
|
|
158
|
+
if (event && event.type === 'turn/start') turnStarts++
|
|
159
|
+
else if (event && event.type === 'turn/end') turnEnds++
|
|
160
|
+
}
|
|
161
|
+
const headerTime = header && typeof header.createdAt === 'number' ? header.createdAt : null
|
|
162
|
+
if (firstTime === null) firstTime = headerTime
|
|
163
|
+
if (lastTime === null) lastTime = firstTime
|
|
164
|
+
return {
|
|
165
|
+
agentPreset: header && typeof header.agentPreset === 'string' ? header.agentPreset : null,
|
|
166
|
+
firstTime,
|
|
167
|
+
lastTime,
|
|
168
|
+
durationMs: firstTime !== null && lastTime !== null ? Math.max(0, lastTime - firstTime) : 0,
|
|
169
|
+
turnStarts,
|
|
170
|
+
turnEnds,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 冷读一个会话抽摘要。句柄 open / read 任一失败(锁住 / 损坏 / 格式不识)原样
|
|
176
|
+
* 上抛由调用方跳过计数;close 失败不掩盖读结果。read 返回形状按宿主契约
|
|
177
|
+
* { events },兼容裸数组,其余视为空日志。
|
|
178
|
+
*/
|
|
179
|
+
export async function readSessionSummary(persistence, sessionId) {
|
|
180
|
+
const handle = await persistence.open(sessionId, 'read')
|
|
181
|
+
try {
|
|
182
|
+
const raw = await handle.read()
|
|
183
|
+
const events = Array.isArray(raw)
|
|
184
|
+
? raw
|
|
185
|
+
: raw && Array.isArray(raw.events)
|
|
186
|
+
? raw.events
|
|
187
|
+
: []
|
|
188
|
+
const inherited = handle && typeof handle.inheritedEventCount === 'number' && handle.inheritedEventCount > 0
|
|
189
|
+
? handle.inheritedEventCount
|
|
190
|
+
: 0
|
|
191
|
+
return summarizeEvents(handle && handle.header, events, inherited)
|
|
192
|
+
} finally {
|
|
193
|
+
try {
|
|
194
|
+
await handle.close()
|
|
195
|
+
} catch {
|
|
196
|
+
// 关不掉就关不掉:读结果已到手,不因 close 异常作废
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── 聚合 ─────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 失败判据(见文件头「失败判据」节):owned 段跑完过至少一轮且首尾平衡才算
|
|
205
|
+
* 正常完成;没跑过轮次或轮次中断都计入 failures。
|
|
206
|
+
*/
|
|
207
|
+
function isFailedSummary(summary) {
|
|
208
|
+
if (summary.turnStarts < 1) return true
|
|
209
|
+
return summary.turnStarts !== summary.turnEnds
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 单会话是否落在 days 窗口内(按会话首事件时间;拿不到首时间的丢弃)。 */
|
|
213
|
+
function withinWindow(summary, days, nowMs) {
|
|
214
|
+
if (summary.firstTime === null || typeof summary.firstTime !== 'number') return false
|
|
215
|
+
return nowMs - summary.firstTime <= days * 24 * 60 * 60 * 1000
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 聚合:只认 agentPreset 以 paoding-role- 开头的会话(其余忽略)。每角色
|
|
220
|
+
* calls / totalMs / failures / lastUsedAt;roles 按调用数降序,同量按最近使用
|
|
221
|
+
* 新者在前(展示顺序稳定)。返回 roles 与 delegatedSessions 计数。
|
|
222
|
+
*/
|
|
223
|
+
function aggregateRoles(summaries, days, nowMs) {
|
|
224
|
+
const byRole = new Map()
|
|
225
|
+
let delegatedSessions = 0
|
|
226
|
+
for (const summary of summaries) {
|
|
227
|
+
const preset = summary.agentPreset
|
|
228
|
+
if (typeof preset !== 'string' || !preset.startsWith(ROLE_PRESET_ID_PREFIX)) continue
|
|
229
|
+
if (!withinWindow(summary, days, nowMs)) continue
|
|
230
|
+
delegatedSessions++
|
|
231
|
+
const role = preset.slice(ROLE_PRESET_ID_PREFIX.length) || '(匿名角色)'
|
|
232
|
+
let row = byRole.get(role)
|
|
233
|
+
if (!row) {
|
|
234
|
+
row = { role, calls: 0, totalMs: 0, failures: 0, lastUsedAt: 0 }
|
|
235
|
+
byRole.set(role, row)
|
|
236
|
+
}
|
|
237
|
+
row.calls++
|
|
238
|
+
row.totalMs += summary.durationMs
|
|
239
|
+
if (isFailedSummary(summary)) row.failures++
|
|
240
|
+
if (typeof summary.lastTime === 'number' && summary.lastTime > row.lastUsedAt) {
|
|
241
|
+
row.lastUsedAt = summary.lastTime
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const roles = [...byRole.values()].map((row) => ({
|
|
245
|
+
role: row.role,
|
|
246
|
+
calls: row.calls,
|
|
247
|
+
avgMs: row.calls > 0 ? Math.round(row.totalMs / row.calls) : 0,
|
|
248
|
+
failures: row.failures,
|
|
249
|
+
lastUsedAt: row.lastUsedAt,
|
|
250
|
+
}))
|
|
251
|
+
roles.sort((a, b) => (b.calls - a.calls) || (b.lastUsedAt - a.lastUsedAt))
|
|
252
|
+
return { roles, delegatedSessions }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── 并发闸(同时最多 4 个读句柄)─────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
async function mapLimited(items, limit, worker) {
|
|
258
|
+
const results = new Array(items.length)
|
|
259
|
+
let next = 0
|
|
260
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
261
|
+
while (true) {
|
|
262
|
+
const index = next++
|
|
263
|
+
if (index >= items.length) return
|
|
264
|
+
try {
|
|
265
|
+
results[index] = { ok: true, value: await worker(items[index], index) }
|
|
266
|
+
} catch (err) {
|
|
267
|
+
results[index] = { ok: false, error: err }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
await Promise.all(runners)
|
|
272
|
+
return results
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── 摘要缓存(内存即可,进程重启重建)────────────────────────────────────────
|
|
276
|
+
// sessionId(含 slug 前缀的复合键)→ { file, mtimeMs, size, summary }:目录里
|
|
277
|
+
// 选中日志文件的 mtime / size 都没变就直用缓存摘要,不再开读句柄。活跃会话的
|
|
278
|
+
// 日志持续增长,mtime 变化即自然失效。条目随会话删除残留几 KB 无妨,不做上限。
|
|
279
|
+
|
|
280
|
+
const summaryCache = new Map()
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 委派效果统计主入口。ctx 缺 sessionPersistence(reflect 软探测,绝不进
|
|
284
|
+
* inject——硬注入会让缺服务的老宿主挂载即炸)→ { ok:false,
|
|
285
|
+
* reason:'persistence-unavailable' },面板据此整卡不渲染。project 非法抛
|
|
286
|
+
* 422(外层 catch 统一回 { error } 信封)。
|
|
287
|
+
*/
|
|
288
|
+
export async function collectDelegationStats(ctx, { project, days } = {}) {
|
|
289
|
+
const persistence = reflectService(ctx, 'sessionPersistence')
|
|
290
|
+
if (!persistence) return { ok: false, reason: 'persistence-unavailable' }
|
|
291
|
+
|
|
292
|
+
const windowDays = sanitizeStatsDays(days)
|
|
293
|
+
const sessionsRoot = path.join(resolveDshHome(), 'sessions')
|
|
294
|
+
const slug = sanitizeProjectSlug(project !== undefined && project !== null && project !== '' ? project : currentProjectSlug(), sessionsRoot)
|
|
295
|
+
if (slug === null) {
|
|
296
|
+
const err = new Error(`project 参数不合法(仅允许 [A-Za-z0-9._-],或 all): ${String(project).slice(0, 64)}`)
|
|
297
|
+
err.statusCode = 422
|
|
298
|
+
throw err
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 枚举:all = 列会话根下全部项目目录;单 slug 只列该目录。
|
|
302
|
+
const slugs = slug === 'all'
|
|
303
|
+
? (() => {
|
|
304
|
+
try {
|
|
305
|
+
return readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
|
306
|
+
} catch {
|
|
307
|
+
return []
|
|
308
|
+
}
|
|
309
|
+
})()
|
|
310
|
+
: [slug]
|
|
311
|
+
const targets = []
|
|
312
|
+
for (const dir of slugs) {
|
|
313
|
+
for (const id of listSessionIds(sessionsRoot, dir)) {
|
|
314
|
+
targets.push({ slug: dir, id })
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const nowMs = Date.now()
|
|
319
|
+
const reads = await mapLimited(targets, 4, async ({ slug: dir, id }) => {
|
|
320
|
+
const sessionDir = path.join(sessionsRoot, dir, id)
|
|
321
|
+
const file = currentSessionFile(sessionDir)
|
|
322
|
+
if (file === null) throw new Error('no session log file')
|
|
323
|
+
const st = statSync(path.join(sessionDir, file)) // stat 失败 → 跳过计数
|
|
324
|
+
const cacheKey = dir + '\u0000' + id
|
|
325
|
+
const hit = summaryCache.get(cacheKey)
|
|
326
|
+
if (hit && hit.file === file && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
|
|
327
|
+
return { id, summary: hit.summary }
|
|
328
|
+
}
|
|
329
|
+
const summary = await readSessionSummary(persistence, id)
|
|
330
|
+
summaryCache.set(cacheKey, { file, mtimeMs: st.mtimeMs, size: st.size, summary })
|
|
331
|
+
return { id, summary }
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
let skipped = 0
|
|
335
|
+
const summaries = []
|
|
336
|
+
for (const r of reads) {
|
|
337
|
+
if (r.ok) summaries.push(r.value.summary)
|
|
338
|
+
else skipped++
|
|
339
|
+
}
|
|
340
|
+
const { roles, delegatedSessions } = aggregateRoles(summaries, windowDays, nowMs)
|
|
341
|
+
return {
|
|
342
|
+
ok: true,
|
|
343
|
+
project: slug,
|
|
344
|
+
windowDays,
|
|
345
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
346
|
+
roles,
|
|
347
|
+
sessionsScanned: targets.length,
|
|
348
|
+
delegatedSessions,
|
|
349
|
+
skipped,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** reflect 软探测(与 persistenceAvailableOf 同款口径,任何异常按缺失处理)。 */
|
|
354
|
+
function reflectService(ctx, name) {
|
|
355
|
+
try {
|
|
356
|
+
const reflect = ctx.reflect
|
|
357
|
+
if (!reflect || typeof reflect.get !== 'function') return undefined
|
|
358
|
+
return reflect.get(name, false)
|
|
359
|
+
} catch {
|
|
360
|
+
return undefined
|
|
361
|
+
}
|
|
362
|
+
}
|