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