dsh-agent-board 1.7.8 → 1.7.9
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 +12 -3
- package/lib/cleanup.mjs +256 -0
- package/lib/client/dashboard.js +128 -3
- package/lib/client/kernel.js +32 -7
- package/lib/client/task-detail.js +13 -7
- package/lib/client.js +173 -17
- package/lib/core.mjs +9 -2
- package/lib/dispatch.mjs +65 -0
- package/lib/rpc.mjs +103 -18
- package/package.json +1 -1
- package/test/cleanup.test.mjs +341 -0
- package/test/conditional-get-tasks.test.mjs +96 -0
- package/test/core.test.mjs +152 -6
- package/test/helpers/mock-ctx.mjs +7 -2
- package/test/ui-zoom-client.test.mjs +76 -0
package/README.md
CHANGED
|
@@ -113,13 +113,13 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
113
113
|
|
|
114
114
|
## 功能总览
|
|
115
115
|
|
|
116
|
-
**核心能力一页速览**(`★` 为 v1.7.5–1.7.
|
|
116
|
+
**核心能力一页速览**(`★` 为 v1.7.5–1.7.9 新栈):
|
|
117
117
|
|
|
118
118
|
- **看板**:六列状态流、拖拽 / 多选批量 / 文本·优先级·标签筛选、归档留档、会话隔离
|
|
119
119
|
- **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管
|
|
120
120
|
- **验收治理** `★`:硬性验收脚本独立复跑、驳回全量带回重派、Verifier 自测指南(userTest)、风险队列「待你过目」、验收时序防护(Verifier 在场时主窗口抢批需显式 `force:true`;有活跃 run 的卡禁止归档;Verifier 迟到结论落 `lateVerdict` 留痕+迟到驳回直投告警,不再静默吞掉)
|
|
121
121
|
- **度量** `★`:Token 消耗(总量 / 有效 / 缓存读,按模型·按任务·按日)+ 记分卡(模型表现 / 质量趋势 / 驳回聚类)
|
|
122
|
-
- **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage
|
|
122
|
+
- **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage)、**僵尸 run 留档清扫**(host 重启后「落定卡上的 running 无 endedAt 留档」在派发周期自动关账 `stale-closed` + 清认领位,归档门禁不再被幻影 run 永久拦截)
|
|
123
123
|
- **学习反馈**:候选教训信号 → 主窗口沉淀(零耦合,不碰任何笔记 / 记忆工具 API)
|
|
124
124
|
|
|
125
125
|
以下各节是完整规格。
|
|
@@ -135,7 +135,10 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
135
135
|
- 拖拽流转、多选批量操作(带一步撤销)、文本/优先级/标签筛选;卡片键盘可达(Tab 聚焦 + Enter/Space 开详情),详情页附「流转到」按钮组(状态迁移的键盘替代通道,失败内联回显)
|
|
136
136
|
- 归档区:时间倒序 + 排序选择器 + 纵向滚动
|
|
137
137
|
- Esc 逐级关闭(详情 → 看板 → 面板)
|
|
138
|
+
- **详情面板操作消歧**:破坏性操作(取消任务 / 删除)独立成「危险操作」区(红色虚线分隔 + a11y 分组 + confirm 预警),与表单按钮(保存/发布)视觉分层;头部右上 ✕ 显性关闭钮——「取消」不再被误读为「关面板」
|
|
139
|
+
- **fork/迁移会话同样有看板**:根判定用会话头 `delegationDepth`(fork/seed 会话 depth=0 认根;真子代理 depth≥1 才隐藏看板入口),不再误把带 parentSession 的 fork 当子代理
|
|
138
140
|
- 全部结构性图标为 Lucide 线性 SVG(`currentColor` 跟随主题,浅深色自适应)
|
|
141
|
+
- **界面缩放档位**(⚙️ 设置弹层「界面」小节,90% / 100%(默认)/ 110% / 125% / 150% 五档):面板根容器 CSS `zoom` 等比缩放(字体/图标/间距一并放大,只影响本面板、不碰宿主页面),选中即时生效并 `localStorage` 本地记忆(键 `dsh-agent-board-ui-zoom`)
|
|
139
142
|
|
|
140
143
|
### 仪表盘(Token 消耗)
|
|
141
144
|
|
|
@@ -157,6 +160,12 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
157
160
|
- 统计区耗时同口径拆分:「平均排队 / 平均执行」双行展示(平均验收单列不变)
|
|
158
161
|
- 统计区新增「调研 ROI」对比行:resolved/archived 卡按有无调研注入分组现算卡数 / 平均执行时长 / 平均 token(双组总样本 ≥4 才渲染,无调研组明显更慢时数字 warn 色提示)
|
|
159
162
|
|
|
163
|
+
### 存储清理(已结算会话)
|
|
164
|
+
|
|
165
|
+
- **看板内建「已结算会话清理」**(`task-mv2131cz`,用户指令「这个清理可以做到看板上」):DSH 宿主无会话删除 API(归档=执行门禁不删数据),看板是唯一知道「哪些会话账已结清」的组件——清理只能长在看板上。仪表盘新增「**存储清理**」区:「预览可清理会话」按钮 → 展示可删会话数 + 预估释放空间 + 按卡分组明细 + 跳过项与理由(**只读不删**;体积估算优先读宿主 `SessionPersistenceSnapshot.sizeBytes`,快照缺省时回退目录遍历);「清理」按钮**二次确认**后真删(删除不可恢复,token 账早已记入卡片、删除不影响任何统计),完成后报告「已释放 N / 跳过 M 及理由」
|
|
166
|
+
- **可删判定:卡龄主闸 + 三底闸**(host `lib/cleanup.mjs` 纯函数):只删**本板 runs[] 关联的会话 id**,且 **主闸 = 卡龄**——所属卡 `status=archived` 且 `archivedAt` 距今 ≥ `cleanupRetentionDays`(板级设置项,默认 **7 天**、可配 1-90,回收与归档生命周期对齐);**三底闸永留**——① `outcome` 已落定(非 `running`)② 会话当前不活跃(不在 `ctx.agents` 活跃树 / 非本会话血统)③ 路径安全(id 白名单 + `rm` 前 `isWithin` 再验)——任一解析不确定即跳过并计入理由(宁漏勿错删)。`continuable`/`usageRecorded` 两道旧闸已撤:归档满保留天数即视为账本封存、续跑意愿过期(恢复待办时旧会话已被删走,既有「冷复活失败→回落新 spawn」路径承接,零新逻辑)
|
|
167
|
+
- **删除范围**:会话日志目录 `~/.dsh/sessions/<bucket>/<id>/`(`fs.rm` recursive)+ projcache 缓存 `~/.dsh/storages/session_projcache/sessions/<id>.json`;**路径逃逸双闸防护**:① 会话 id 白名单 `[A-Za-z0-9_-]`(含 `../`、`/`、`\` 一律拒)② `rm` 前再验目标绝对路径仍在 sessions/projcache 桶内(`path.relative` 判定)。**注册表索引残留**:`~/.dsh/storages/session_projcache.json`(宿主托管的 `version/seq` 结构)**不清理**——只是元数据索引,残留条目不占会话日志体积,宿主自会 GC;每次清理写 `board.cleanupLog` 留痕(时间/操作者/删除数/释放字节/会话 id 清单,上限 50 条)
|
|
168
|
+
|
|
160
169
|
### 学习反馈(候选教训信号 → 主窗口沉淀)
|
|
161
170
|
|
|
162
171
|
- **信号源架构(零耦合)**:看板只产「候选教训**信号**」,不做**存储**——不调用任何笔记/记忆工具的 API、不写任何外部文件、也不知道教训最终被存到哪;用不用、存进哪个工具(如 `note_search` / `note_manage`)完全由主窗口 agent 自己决定
|
|
@@ -184,7 +193,7 @@ draft → pending → in-progress → verifying → resolved → archived
|
|
|
184
193
|
- **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
|
|
185
194
|
- **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档(僵尸态出清:`archive-task` 对「无活跃 run 的 in-progress」——典型如被 parentKick 推进后子任务已全部归档的史诗——直接放行,有活跃 run 的仍拒);**归档子任务仍计入史诗进度并在详情留档可见**——进度分子口径 `settled = resolved | cancelled | archived`、分母也含归档,归档一张子卡不会再让史诗进度从 `0/10` 退化成 `0/9`(进度只增不减),全归档的父卡也照常显示徽章;详情子任务清单不排归档行(灰化 + 行尾「已归档」徽章 + 沉底排序,点击仍可进子卡看留档)
|
|
186
195
|
- **死会话零打扰**:15s 派发心跳对无活 root 的会话板直接跳过(不读盘/不写盘/零日志,会话重开后自动恢复派发);`create-task` 拒绝不完整 sessionId(防裸短 id 建出幻影板)
|
|
187
|
-
-
|
|
196
|
+
- **面板轮询条件请求 + 渲染短路**:`get-tasks` 响应附 `tasksHash`;客户端轮询带上轮 hash 作 `ifHash`,host 比对一致时返回 `{unchanged:true}` 轻量响应(**不含 tasks 全量**——轮询载荷从 1.34MB 级降到 KB 级,usage/healthHints/池状态等轻量聚合照常返回),前端保留 `state.tasks` 不动并跳过重渲染;hash 不一致 / 不带 `ifHash` / `includeArchived` 一律全量返回(老客户端零回归)
|
|
188
197
|
- **看板抽屉稳定性(isRoot 持久语义 + 蝶变防抖)**:`isRoot` 判定改读**持久 parentSession**(根会话=无父)——休眠根会话(有板有卡但无活 agent)也算 root,入口不再分钟级消失;子代理会话(parentSession 指向父)恒不显示入口。客户端防抖仍保留兜底:已确认 root 的会话需**连续 3 次(≈9s)false** 才收起抽屉(会话生命周期内「曾确认 true」粘滞),看板不再在生成状态切换时突然消失;从未确认 root 的子代理会话仍即时隐藏入口,保护语义不变
|
|
189
198
|
- 卡片识别层:父卡显示「📦 史诗 · settled/total」徽章 + 3px 迷你进度条(`settled = resolved | cancelled | archived`,total 同口径含归档;有在跑子任务时附「▸ 在跑:标题」行);子卡标题下显示「↳ 父任务标题」;依赖未满足的待办卡底部灰字「⛓ 等待「依赖标题」」(childStats 由 host 现算,缺字段一律不渲染)
|
|
190
199
|
- 详情父子区块:父卡详情列子任务清单(状态色点 + 标题,**含已归档留档**——归档行灰化 + 行尾「已归档」徽章并沉底,点击直达子卡详情,标题行汇总 settled/total);子卡详情顶行「↳ 史诗:父标题」点击回跳父卡
|
package/lib/cleanup.mjs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// dsh-agent-board — 已结算会话清理(lib/cleanup.mjs)
|
|
2
|
+
// 纯函数 + node IO,不碰 ctx 与共享状态。看板内建「已结算会话清理」:
|
|
3
|
+
// 扫本板全部任务 runs[],卡龄主闸筛「可删」= 所属卡 status=archived 且 archivedAt 距今 ≥ cleanupRetentionDays
|
|
4
|
+
// (板级设置项,默认 7 天、可配 1-90);三底闸永留 = outcome 已落定(非 running)∧ 会话不活跃
|
|
5
|
+
// (不在活跃树/本会话血统)∧ 路径安全(id 白名单 + rm 前 isWithin 再验)。
|
|
6
|
+
// 路径逃逸防护两道闸:① 会话 id 白名单 [A-Za-z0-9_-];② rm 前再验目标绝对路径仍在 sessions 桶内。
|
|
7
|
+
// 体积估算主源(task-mv39ag1e):官方 SessionPersistenceSnapshot.sizeBytes——sessionPersistence.stat(id)
|
|
8
|
+
// 只读元数据(不读事件日志、不取写所有权,preview 零副作用红线不破),替代逐会话目录递归遍历
|
|
9
|
+
// (Verifier 实测全量预览 ~340ms 同步 IO 阻塞事件循环);stat 缺失/异常/sizeBytes 缺省 → 回退目录遍历(老宿主降级不炸)。
|
|
10
|
+
// 数据源(与 usage.mjs findRunLog 同源布局,2026-10 本机核对):
|
|
11
|
+
// 会话日志目录:~/.dsh/sessions/<bucket>/<id>/(bucket=工作区转义目录名,id=会话 id 目录)
|
|
12
|
+
// projcache 逐会话:~/.dsh/storages/session_projcache/sessions/<id>.json
|
|
13
|
+
// 注册表索引残留:~/.dsh/storages/session_projcache.json(宿主托管、version/seq 结构,**不清理**,
|
|
14
|
+
// 只文档注明残留形态——见 README「存储清理」小节)
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import fsNode from 'node:fs'
|
|
17
|
+
|
|
18
|
+
// ===== 会话 id 安全性(路径逃逸第一道闸)=====
|
|
19
|
+
// 合法会话 id 只含 [A-Za-z0-9_-]:裸 UUID(5c912460-...)、session-<uuid>、import-sess_...、child-... 均符合。
|
|
20
|
+
// 任何出现 . / \ 等路径字符(含 ../ 逃逸、Windows \ 分隔符)的 id 一律拒。
|
|
21
|
+
export function isSafeSessionId(id) {
|
|
22
|
+
if (typeof id !== 'string') return false
|
|
23
|
+
if (!id || id.length > 200) return false
|
|
24
|
+
if (id !== id.trim()) return false
|
|
25
|
+
return /^[A-Za-z0-9_-]+$/.test(id)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 可删判定的跳过原因(稳定机器码,测试与仪表盘按它对齐)
|
|
29
|
+
export var SKIP_REASON_TEXT = {
|
|
30
|
+
'id-invalid': '会话 id 非法(路径逃逸防护)',
|
|
31
|
+
'not-settled': 'outcome 未落定',
|
|
32
|
+
'not-archived': '所属卡未归档',
|
|
33
|
+
'too-young': '归档未满保留天数',
|
|
34
|
+
'active': '会话仍活跃/属本会话血统',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 卡龄主闸保留天数(板级设置项 cleanupRetentionDays 与此对齐:默认 7 天、可配 1-90)
|
|
38
|
+
export var CLEANUP_RETENTION_DEFAULT = 7
|
|
39
|
+
export var CLEANUP_RETENTION_MIN = 1
|
|
40
|
+
export var CLEANUP_RETENTION_MAX = 90
|
|
41
|
+
var MS_PER_DAY = 86400000
|
|
42
|
+
|
|
43
|
+
function retentionOf(opts) {
|
|
44
|
+
var d = (opts && typeof opts.retentionDays === 'number' && opts.retentionDays >= CLEANUP_RETENTION_MIN) ? Math.floor(opts.retentionDays) : CLEANUP_RETENTION_DEFAULT
|
|
45
|
+
if (d > CLEANUP_RETENTION_MAX) d = CLEANUP_RETENTION_MAX
|
|
46
|
+
return d
|
|
47
|
+
}
|
|
48
|
+
function nowOf(opts) {
|
|
49
|
+
return (opts && typeof opts.now === 'number' && opts.now > 0) ? opts.now : Date.now()
|
|
50
|
+
}
|
|
51
|
+
// 归档是否已满保留天数:archivedAt 缺失/非法一律不满足(宁漏勿错删,绝不因时间戳读不出而放行删除)
|
|
52
|
+
export function archivedAged(archivedAt, retentionDays, now) {
|
|
53
|
+
if (!archivedAt) return false
|
|
54
|
+
var at = Date.parse(archivedAt)
|
|
55
|
+
if (!(at > 0)) return false
|
|
56
|
+
return (now - at) >= retentionDays * MS_PER_DAY
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 单个 run 条目可删判定:返回 { deletable:true } 或 { deletable:false, reason }。
|
|
60
|
+
// 入参从「run 级」升到「run + 所属卡」级——卡龄主闸需要卡的 status/archivedAt。
|
|
61
|
+
// 闸序(先硬后软):① id 合法 ② outcome 落定(非 running)③ 卡龄主闸(archived + 满保留天数)④ 不活跃。
|
|
62
|
+
export function classifyRun(run, card, blockedIds, opts) {
|
|
63
|
+
var id = (run && typeof run.id === 'string') ? run.id : ''
|
|
64
|
+
if (!isSafeSessionId(id)) return { deletable: false, reason: 'id-invalid' }
|
|
65
|
+
if (!run.outcome || run.outcome === 'running') return { deletable: false, reason: 'not-settled' }
|
|
66
|
+
// 卡龄主闸(回收与归档生命周期对齐):run 可删 ⟺ 所属卡已归档且归档满 retentionDays。
|
|
67
|
+
if (!card || card.status !== 'archived') return { deletable: false, reason: 'not-archived' }
|
|
68
|
+
if (!archivedAged(card.archivedAt, retentionOf(opts), nowOf(opts))) return { deletable: false, reason: 'too-young' }
|
|
69
|
+
if (blockedIds && blockedIds.has(id)) return { deletable: false, reason: 'active' }
|
|
70
|
+
return { deletable: true }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 扫全部任务 runs[]:按会话 id 聚合分类。同一 id 只要任一条不可删 → 整组不可删(宁漏勿错删)。
|
|
74
|
+
// 返回 { items: [{ id, taskId, role }], skipped: [{ id, taskId, reason, text }] }
|
|
75
|
+
export function scanDeletable(tasks, blockedIds, opts) {
|
|
76
|
+
var list = Array.isArray(tasks) ? tasks : []
|
|
77
|
+
var groups = {} // id -> { id, taskId, role, runs: [{ run, card }] }
|
|
78
|
+
var order = []
|
|
79
|
+
for (var ti = 0; ti < list.length; ti++) {
|
|
80
|
+
var t = list[ti]
|
|
81
|
+
if (!t || !Array.isArray(t.runs)) continue
|
|
82
|
+
for (var ri = 0; ri < t.runs.length; ri++) {
|
|
83
|
+
var r = t.runs[ri]
|
|
84
|
+
if (!r || typeof r.id !== 'string' || !r.id) continue
|
|
85
|
+
var g = groups[r.id]
|
|
86
|
+
if (!g) { g = groups[r.id] = { id: r.id, taskId: t.id, role: r.role || '', runs: [] }; order.push(r.id) }
|
|
87
|
+
g.runs.push({ run: r, card: t })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
var items = []
|
|
91
|
+
var skipped = []
|
|
92
|
+
for (var oi = 0; oi < order.length; oi++) {
|
|
93
|
+
var id = order[oi]
|
|
94
|
+
var grp = groups[id]
|
|
95
|
+
var reason = null
|
|
96
|
+
for (var k = 0; k < grp.runs.length; k++) {
|
|
97
|
+
var e = grp.runs[k]
|
|
98
|
+
var c = classifyRun(e.run, e.card, blockedIds, opts)
|
|
99
|
+
if (!c.deletable) { reason = c.reason; break }
|
|
100
|
+
}
|
|
101
|
+
if (reason === null) items.push({ id: id, taskId: grp.taskId, role: grp.role })
|
|
102
|
+
else skipped.push({ id: id, taskId: grp.taskId, reason: reason, text: SKIP_REASON_TEXT[reason] || reason })
|
|
103
|
+
}
|
|
104
|
+
return { items: items, skipped: skipped }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ===== 体积估算(只读,绝不写盘)=====
|
|
108
|
+
// 递归目录体积(字节):不存在/读不到返回 0,绝不抛错
|
|
109
|
+
export function dirSizeOf(p) {
|
|
110
|
+
try {
|
|
111
|
+
var st = fsNode.statSync(p)
|
|
112
|
+
if (!st.isDirectory()) return 0
|
|
113
|
+
} catch (_) { return 0 }
|
|
114
|
+
var total = 0
|
|
115
|
+
var stack = [p]
|
|
116
|
+
while (stack.length) {
|
|
117
|
+
var cur = stack.pop()
|
|
118
|
+
var entries
|
|
119
|
+
try { entries = fsNode.readdirSync(cur, { withFileTypes: true }) } catch (_) { continue }
|
|
120
|
+
for (var i = 0; i < entries.length; i++) {
|
|
121
|
+
var e = entries[i]
|
|
122
|
+
var fp = path.join(cur, e.name)
|
|
123
|
+
if (e.isDirectory()) stack.push(fp)
|
|
124
|
+
else { try { total += fsNode.statSync(fp).size } catch (_) {} }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return total
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function fileSizeOf(p) {
|
|
131
|
+
try { return fsNode.statSync(p).size } catch (_) { return 0 }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 定位会话目录:扫 sessionsRoot 下所有桶,返回 <bucket>/<id> 目录路径(存在才返回),否则 null
|
|
135
|
+
export function findSessionDir(id, sessionsRoot) {
|
|
136
|
+
if (!id || !sessionsRoot) return null
|
|
137
|
+
try {
|
|
138
|
+
var buckets = fsNode.readdirSync(sessionsRoot)
|
|
139
|
+
for (var i = 0; i < buckets.length; i++) {
|
|
140
|
+
var cand = path.join(sessionsRoot, buckets[i], id)
|
|
141
|
+
try { if (fsNode.statSync(cand).isDirectory()) return cand } catch (_) {}
|
|
142
|
+
}
|
|
143
|
+
} catch (_) {}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 一次性扫 sessionsRoot 建 id → 目录路径 索引:3423 会话量级下 preview 只扫一遍桶,
|
|
148
|
+
// 避免「每个可删项都 readdirSync 整棵 sessions 树」的 O(N×M) 重复读盘。
|
|
149
|
+
export function buildSessionDirIndex(sessionsRoot) {
|
|
150
|
+
var idx = {}
|
|
151
|
+
if (!sessionsRoot) return idx
|
|
152
|
+
try {
|
|
153
|
+
var buckets = fsNode.readdirSync(sessionsRoot)
|
|
154
|
+
for (var i = 0; i < buckets.length; i++) {
|
|
155
|
+
var bdir = path.join(sessionsRoot, buckets[i])
|
|
156
|
+
var entries
|
|
157
|
+
try { entries = fsNode.readdirSync(bdir) } catch (_) { continue }
|
|
158
|
+
for (var j = 0; j < entries.length; j++) {
|
|
159
|
+
var fp = path.join(bdir, entries[j])
|
|
160
|
+
try { if (fsNode.statSync(fp).isDirectory()) idx[entries[j]] = fp } catch (_) {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (_) {}
|
|
164
|
+
return idx
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 路径仍在根内判定(路径逃逸第二道闸):relative 不以 .. 开头、不是绝对路径、且不等于根本身
|
|
168
|
+
export function isWithin(root, p) {
|
|
169
|
+
if (!root || !p) return false
|
|
170
|
+
var rel = path.relative(root, p)
|
|
171
|
+
if (rel === '') return false // 目标 == 根本身 → 拒绝(绝不删整个桶)
|
|
172
|
+
if (path.isAbsolute(rel)) return false
|
|
173
|
+
return rel !== '..' && rel.indexOf('..' + path.sep) !== 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ===== preview:dry-run,只读盘估体积、绝不删任何文件 =====
|
|
177
|
+
// opts.roots = { sessions: '<home>/.dsh/sessions', projcacheDir: '<home>/.dsh/storages/session_projcache/sessions' }
|
|
178
|
+
// opts.statSession(可选,task-mv39ag1e):官方快照面 `sessionPersistence.stat(id)` 的注入形态——
|
|
179
|
+
// 返回 { sizeBytes? } 或 undefined。体积主源走快照,以下四种情况一律回退目录遍历(双轨,老宿主不炸):
|
|
180
|
+
// ① 未注入(老宿主无 sessionPersistence 服务/测试桩)② 返回 undefined(会话不在持久层)
|
|
181
|
+
// ③ snapshot.sizeBytes 缺省(未物化会话/非 JSONL 后端)④ stat 抛异常。
|
|
182
|
+
// 口径注记:sizeBytes=会话日志物理体积(官方快照口径),可能略小于目录全量(目录或含附件等)——预览是估算,可接受。
|
|
183
|
+
async function sessionBytesOf(id, dir, statSession) {
|
|
184
|
+
if (typeof statSession === 'function') {
|
|
185
|
+
try {
|
|
186
|
+
var snap = await statSession(id)
|
|
187
|
+
if (snap && typeof snap.sizeBytes === 'number' && isFinite(snap.sizeBytes) && snap.sizeBytes >= 0) return snap.sizeBytes
|
|
188
|
+
} catch (_) { /* stat 异常 → 回退目录遍历 */ }
|
|
189
|
+
}
|
|
190
|
+
return dir ? dirSizeOf(dir) : 0
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 返回 { total, totalBytes, items: [{id,taskId,role,bytes,sessionDir,projcachePath}], skipped, byTask }
|
|
194
|
+
export async function previewCleanup(tasks, opts) {
|
|
195
|
+
opts = opts || {}
|
|
196
|
+
var roots = opts.roots || {}
|
|
197
|
+
var sessionsRoot = roots.sessions
|
|
198
|
+
var projcacheDir = roots.projcacheDir
|
|
199
|
+
var statSession = opts.statSession
|
|
200
|
+
var list = Array.isArray(tasks) ? tasks : []
|
|
201
|
+
var titleById = {}
|
|
202
|
+
for (var ti = 0; ti < list.length; ti++) { if (list[ti] && list[ti].id) titleById[list[ti].id] = list[ti].title || list[ti].id }
|
|
203
|
+
var scan = scanDeletable(list, opts.blockedIds, opts)
|
|
204
|
+
var dirIndex = buildSessionDirIndex(sessionsRoot)
|
|
205
|
+
var items = []
|
|
206
|
+
var totalBytes = 0
|
|
207
|
+
var byTask = {}
|
|
208
|
+
for (var i = 0; i < scan.items.length; i++) {
|
|
209
|
+
var it = scan.items[i]
|
|
210
|
+
var dir = dirIndex[it.id] || null
|
|
211
|
+
var dirBytes = await sessionBytesOf(it.id, dir, statSession)
|
|
212
|
+
var pcPath = projcacheDir ? path.join(projcacheDir, it.id + '.json') : null
|
|
213
|
+
var pcBytes = pcPath ? fileSizeOf(pcPath) : 0
|
|
214
|
+
var bytes = dirBytes + pcBytes
|
|
215
|
+
totalBytes += bytes
|
|
216
|
+
items.push({ id: it.id, taskId: it.taskId, role: it.role, bytes: bytes, sessionDir: dir, projcachePath: (pcPath && pcBytes > 0) ? pcPath : null })
|
|
217
|
+
var bk = byTask[it.taskId] || (byTask[it.taskId] = { taskId: it.taskId, title: titleById[it.taskId] || it.taskId, count: 0, bytes: 0 })
|
|
218
|
+
bk.count++; bk.bytes += bytes
|
|
219
|
+
}
|
|
220
|
+
return { total: items.length, totalBytes: totalBytes, items: items, skipped: scan.skipped, byTask: Object.keys(byTask).map(function (k) { return byTask[k] }) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ===== run:真删(confirm 执行)=====
|
|
224
|
+
// 每个目标删前再验 isSafeSessionId + isWithin(路径逃逸第二道闸)。会话目录 rm recursive;
|
|
225
|
+
// projcache json rm force。返回 { deleted, deletedIds, freedBytes, skipped }
|
|
226
|
+
export async function runCleanup(items, opts) {
|
|
227
|
+
opts = opts || {}
|
|
228
|
+
var roots = opts.roots || {}
|
|
229
|
+
var sessionsRoot = roots.sessions
|
|
230
|
+
var projcacheDir = roots.projcacheDir
|
|
231
|
+
var list = Array.isArray(items) ? items : []
|
|
232
|
+
var deletedIds = []
|
|
233
|
+
var freedBytes = 0
|
|
234
|
+
var skipped = []
|
|
235
|
+
for (var i = 0; i < list.length; i++) {
|
|
236
|
+
var it = list[i]
|
|
237
|
+
if (!it || !isSafeSessionId(it.id)) { skipped.push({ id: it && it.id, reason: 'id-invalid' }); continue }
|
|
238
|
+
var dir = it.sessionDir
|
|
239
|
+
var pcPath = it.projcachePath
|
|
240
|
+
// 先验两道路径逃逸(删前整体校验,避免「删了一半才发现另一条逃逸」)
|
|
241
|
+
if (dir && !isWithin(sessionsRoot, dir)) { skipped.push({ id: it.id, reason: 'path-escape' }); continue }
|
|
242
|
+
if (pcPath && projcacheDir && !isWithin(projcacheDir, pcPath)) { skipped.push({ id: it.id, reason: 'path-escape' }); continue }
|
|
243
|
+
var freed = 0
|
|
244
|
+
// 删会话目录(核心目标;失败则整项算跳过,绝不误报成功)。释放字节同走快照主源(sessionBytesOf 内部回退目录遍历)。
|
|
245
|
+
if (dir) {
|
|
246
|
+
try { var db = await sessionBytesOf(it.id, dir, opts.statSession); await fsNode.promises.rm(dir, { recursive: true, force: true }); freed += db } catch (e) { skipped.push({ id: it.id, reason: 'rm-failed' }); continue }
|
|
247
|
+
}
|
|
248
|
+
// 删 projcache json(best-effort:删不掉不阻断主目标)
|
|
249
|
+
if (pcPath && projcacheDir) {
|
|
250
|
+
try { var pb = fileSizeOf(pcPath); await fsNode.promises.rm(pcPath, { force: true }); freed += pb } catch (_) { /* projcache 删不掉不阻断主目标 */ }
|
|
251
|
+
}
|
|
252
|
+
deletedIds.push(it.id)
|
|
253
|
+
freedBytes += freed
|
|
254
|
+
}
|
|
255
|
+
return { deleted: deletedIds.length, deletedIds: deletedIds, freedBytes: freedBytes, skipped: skipped }
|
|
256
|
+
}
|
package/lib/client/dashboard.js
CHANGED
|
@@ -755,6 +755,73 @@
|
|
|
755
755
|
}))
|
|
756
756
|
}
|
|
757
757
|
|
|
758
|
+
// ===== 存储清理区(已结算会话清理,task-mv2131cz):数据源 host 端 cleanup-preview / cleanup-run =====
|
|
759
|
+
// preview 纯读盘零副作用;run 需 confirm:true 二次确认(删除不可恢复,token 账已入卡不受影响)。
|
|
760
|
+
// 可删判定口径在 host lib/cleanup.mjs:卡龄主闸(卡归档满 cleanupRetentionDays 天)+ 三底闸(落定/不活跃/路径安全)。
|
|
761
|
+
function fmtBytes(n) {
|
|
762
|
+
var v = Number(n) || 0
|
|
763
|
+
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB'
|
|
764
|
+
if (v >= 1048576) return (v / 1048576).toFixed(v >= 10485760 ? 0 : 1) + ' MB'
|
|
765
|
+
if (v >= 1024) return (v / 1024).toFixed(v >= 10240 ? 0 : 1) + ' KB'
|
|
766
|
+
return String(v) + ' B'
|
|
767
|
+
}
|
|
768
|
+
function StorageCleanup() {
|
|
769
|
+
var _R = React; var useState = _R.useState
|
|
770
|
+
var _p = useState(null), prev = _p[0], setPrev = _p[1]
|
|
771
|
+
var _o = useState(false), open = _o[0], setOpen = _o[1]
|
|
772
|
+
var _r = useState(null), report = _r[0], setReport = _r[1]
|
|
773
|
+
var _m = useState(''), msg = _m[0], setMsg = _m[1]
|
|
774
|
+
var _b = useState(false), busy = _b[0], setBusy = _b[1]
|
|
775
|
+
var _c = useState(false), confirmOn = _c[0], setConfirmOn = _c[1]
|
|
776
|
+
function preview() {
|
|
777
|
+
setBusy(true); setMsg('')
|
|
778
|
+
rpc('cleanup-preview').then(function (r) {
|
|
779
|
+
if (r && r.ok) { setPrev(r); setReport(null); setConfirmOn(false) } else { setMsg((r && r.error) || '预览失败') }
|
|
780
|
+
setBusy(false)
|
|
781
|
+
}).catch(function (e) { setMsg('预览失败: ' + (e && e.message || e)); setBusy(false) })
|
|
782
|
+
}
|
|
783
|
+
function runClean() {
|
|
784
|
+
setBusy(true); setMsg('')
|
|
785
|
+
rpc('cleanup-run', { confirm: true }).then(function (r) {
|
|
786
|
+
setReport(r); setPrev(null); setConfirmOn(false); setBusy(false)
|
|
787
|
+
if (r && r.ok) { if (typeof fetchTasks === 'function') fetchTasks() } else { setMsg((r && r.error) || '清理失败') }
|
|
788
|
+
}).catch(function (e) { setMsg('清理失败: ' + (e && e.message || e)); setBusy(false) })
|
|
789
|
+
}
|
|
790
|
+
var box = { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 }
|
|
791
|
+
var head = React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('trash-2', 11), '存储清理')
|
|
792
|
+
var btn = { fontSize: 10, padding: '2px 10px', border: '1px solid ' + C.border, borderRadius: 5, background: C.card, color: C.text, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4 }
|
|
793
|
+
var dangerBtn = { fontSize: 10, padding: '2px 10px', border: '1px solid ' + C.err, borderRadius: 5, background: C.card, color: C.err, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4 }
|
|
794
|
+
function skippedLine(s, i) {
|
|
795
|
+
return React.createElement('div', { key: 'sk' + i, style: { fontSize: 9, color: C.text2, marginBottom: 2 } },
|
|
796
|
+
React.createElement('span', { style: { color: C.warn } }, '⏭ '), String(s.id || '').slice(0, 14) + '…:' + (s.text || s.reason || ''))
|
|
797
|
+
}
|
|
798
|
+
return React.createElement('div', { style: box },
|
|
799
|
+
head,
|
|
800
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' } },
|
|
801
|
+
React.createElement('button', { onClick: preview, disabled: busy, style: btn, title: '扫描本看板归档超过保留天数的卡片的会话,估算可释放空间(只读不删)' }, ic('refresh-cw', 11), busy ? '扫描中…' : '预览可清理会话'),
|
|
802
|
+
prev ? React.createElement('span', { style: { fontSize: 10, color: C.text } }, prev.total > 0 ? ('可删 ' + prev.total + ' 个会话 · 约 ' + fmtBytes(prev.totalBytes)) : '无可删会话') : null,
|
|
803
|
+
prev && prev.total > 0 ? React.createElement('button', { onClick: function () { setOpen(!open); setConfirmOn(false) }, style: btn }, ic(open ? 'chevron-up' : 'chevron-down', 10), open ? '收起明细' : '展开明细') : null,
|
|
804
|
+
prev && prev.total > 0 ? (confirmOn ? React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
|
|
805
|
+
React.createElement('button', { onClick: runClean, disabled: busy, style: dangerBtn, title: '确认删除不可恢复;token 账已入卡不受影响' }, ic('check', 11), '确认清理(不可恢复)'),
|
|
806
|
+
React.createElement('button', { onClick: function () { setConfirmOn(false) }, style: btn }, '取消'))
|
|
807
|
+
: React.createElement('button', { onClick: function () { setConfirmOn(true) }, disabled: busy, style: btn, title: '删除不可恢复;token 账已入卡不受影响' }, ic('trash-2', 11), '清理')) : null),
|
|
808
|
+
msg ? React.createElement('div', { style: { fontSize: 9, color: C.err, marginTop: 4 } }, msg) : null,
|
|
809
|
+
report && report.ok ? React.createElement('div', { style: { fontSize: 10, color: C.text, marginTop: 5 } },
|
|
810
|
+
'✅ 已释放约 ' + fmtBytes(report.freedBytes) + '(删除 ' + report.deleted + ' 个会话' + ((report.skipped && report.skipped.length) ? ' · 跳过 ' + report.skipped.length + ' 个' : '') + ')') : null,
|
|
811
|
+
report && report.ok && report.skipped && report.skipped.length ? React.createElement('div', { style: { marginTop: 4 } }, report.skipped.map(skippedLine)) : null,
|
|
812
|
+
open && prev && prev.total > 0 ? React.createElement('div', { style: { marginTop: 6, borderTop: '1px solid ' + C.nested, paddingTop: 5 } },
|
|
813
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginBottom: 4 } }, '可删会话(会话目录 + projcache 缓存一并删除)'),
|
|
814
|
+
(prev.byTask || []).map(function (b) {
|
|
815
|
+
return React.createElement('div', { key: b.taskId, style: { marginBottom: 3, fontSize: 9, color: C.text2 } },
|
|
816
|
+
React.createElement('span', { style: { color: C.brand } }, shortId(b.title)), ':' + b.count + ' 个 · 约 ' + fmtBytes(b.bytes))
|
|
817
|
+
}),
|
|
818
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2 } }, '数据来源:本看板 runs[] 里所属卡已归档超过 ' + (state.cleanupRetentionDays || 7) + ' 天、且 outcome 已落定并已不活跃的 Worker/Verifier 会话;token 账早已记入卡片,删除不影响任何统计。')
|
|
819
|
+
) : null,
|
|
820
|
+
prev && prev.total === 0 && prev.skipped && prev.skipped.length ? React.createElement('div', { style: { marginTop: 5 } },
|
|
821
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginBottom: 3 } }, '无符合条件可删的会话(跳过项)'),
|
|
822
|
+
prev.skipped.slice(0, 8).map(skippedLine)) : null)
|
|
823
|
+
}
|
|
824
|
+
|
|
758
825
|
function Dashboard() {
|
|
759
826
|
var stats = computeStats(state.tasks); var statusOrder = ['pending', 'in-progress', 'verifying', 'resolved', 'blocked', 'archived']; var prioOrder = ['critical', 'high', 'medium', 'low']
|
|
760
827
|
return React.createElement('div', null,
|
|
@@ -762,6 +829,7 @@
|
|
|
762
829
|
React.createElement(GlobalBoards),
|
|
763
830
|
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' } }, React.createElement(StatCard, { label: '总任务', value: stats.total, color: C.text }), React.createElement(StatCard, { label: '待办', value: stats.byStatus['pending'] || 0, color: C.text2 }), React.createElement(StatCard, { label: '进行中', value: stats.byStatus['in-progress'] || 0, color: C.brand }), React.createElement(StatCard, { label: '验证中', value: stats.byStatus['verifying'] || 0, color: C.warn }), React.createElement(StatCard, { label: '已完成', value: stats.byStatus['resolved'] || 0, color: C.ok }), React.createElement(StatCard, { label: '已归档', value: stats.byStatus['archived'] || 0, color: C.text2 })),
|
|
764
831
|
React.createElement(HealthHints),
|
|
832
|
+
React.createElement(StorageCleanup),
|
|
765
833
|
React.createElement(TokenUsage, { usage: state.usageSummary }),
|
|
766
834
|
// 模型表现区(卡3):Token 区正下方;scoreboard 随 usageSummary 同通道透传(老 host 缺字段 → null → 空态)
|
|
767
835
|
React.createElement(ModelPerf, { sb: (state.usageSummary && state.usageSummary.scoreboard) || null }),
|
|
@@ -825,6 +893,30 @@
|
|
|
825
893
|
React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '分'),
|
|
826
894
|
dirty ? React.createElement('button', { onClick: function () { commit(val) }, title: '应用', style: { fontSize: 9, padding: '0px 5px', border: 'none', borderRadius: 2, background: C.brand, color: C_INV, cursor: 'pointer', lineHeight: '14px', fontWeight: 600, display: 'inline-flex', alignItems: 'center' } }, ic('check', 9)) : null)
|
|
827
895
|
}
|
|
896
|
+
// 清理保留天数输入(卡龄主闸设置项 cleanupRetentionDays,1~90 天):与 MinCfg 同款受控输入 + 失焦/回车提交,
|
|
897
|
+
// 越界值直接回弹到服务端权威值(不留脏态),成功走 set-board-config → fetchTasks 由服务端权威值纠偏。
|
|
898
|
+
function RetentionCfg(props) {
|
|
899
|
+
var _R = React; var useState = _R.useState, useEffect = _R.useEffect
|
|
900
|
+
var _a = useState(String(props.value)), val = _a[0], setVal = _a[1]
|
|
901
|
+
var _b = useState(false), dirty = _b[0], setDirty = _b[1]
|
|
902
|
+
useEffect(function () { setVal(String(props.value)); setDirty(false) }, [props.value])
|
|
903
|
+
function commit(v) {
|
|
904
|
+
var n = parseInt(v, 10)
|
|
905
|
+
if (isNaN(n) || n < 1 || n > 90) { setVal(String(props.value)); setDirty(false); return }
|
|
906
|
+
setVal(String(n)); setDirty(false); rpc('set-board-config', { key: 'cleanupRetentionDays', value: n }).then(fetchTasks).catch(function () {})
|
|
907
|
+
}
|
|
908
|
+
return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, color: C.text2 } },
|
|
909
|
+
props.label,
|
|
910
|
+
React.createElement('input', {
|
|
911
|
+
value: val, title: props.tip || ('1 ~ 90 天,默认 7'),
|
|
912
|
+
onChange: function (e) { setVal(e.target.value); setDirty(e.target.value !== String(props.value)) },
|
|
913
|
+
onBlur: function () { if (dirty) commit(val) },
|
|
914
|
+
onKeyDown: function (e) { if (e.key === 'Enter') commit(val) },
|
|
915
|
+
style: { width: 38, padding: '0px 3px', fontSize: 9, textAlign: 'center', border: '1px solid ' + (dirty ? C.brand : C.border2), borderRadius: 2, background: C.card, color: C.text }
|
|
916
|
+
}),
|
|
917
|
+
React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '天'),
|
|
918
|
+
dirty ? React.createElement('button', { onClick: function () { commit(val) }, title: '应用', style: { fontSize: 9, padding: '0px 5px', border: 'none', borderRadius: 2, background: C.brand, color: C_INV, cursor: 'pointer', lineHeight: '14px', fontWeight: 600, display: 'inline-flex', alignItems: 'center' } }, ic('check', 9)) : null)
|
|
919
|
+
}
|
|
828
920
|
|
|
829
921
|
function PoolStatus() {
|
|
830
922
|
var ps = state.poolStatus
|
|
@@ -868,6 +960,23 @@
|
|
|
868
960
|
dirty ? React.createElement('button', { onClick: save, title: '应用', style: { fontSize: 9, padding: '0px 5px', border: 'none', borderRadius: 2, background: C.brand, color: C_INV, cursor: 'pointer', lineHeight: '14px', fontWeight: 600, display: 'inline-flex', alignItems: 'center' } }, ic('check', 9)) : null)
|
|
869
961
|
}
|
|
870
962
|
|
|
963
|
+
// 界面缩放档位选择(task-mv58e01j,用户反馈「字体整体偏小」):90/100/110/125/150% 五档下拉。
|
|
964
|
+
// 纯客户端偏好——不走 set-board-config(host 不管 UI 偏好):选中即 localStorage.setItem 持久化
|
|
965
|
+
// + state.uiZoom + notify,TopPanel 根容器 zoom 订阅即时生效;非法档位(脏值)不采纳。
|
|
966
|
+
function ZoomCfg() {
|
|
967
|
+
var cur = state.uiZoom || 1
|
|
968
|
+
function pick(v) {
|
|
969
|
+
var n = parseFloat(v)
|
|
970
|
+
if (UI_ZOOM_LEVELS.indexOf(n) < 0) return
|
|
971
|
+
state.uiZoom = n
|
|
972
|
+
try { localStorage.setItem(UI_ZOOM_KEY, String(n)) } catch (_) {}
|
|
973
|
+
notify()
|
|
974
|
+
}
|
|
975
|
+
return React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, color: C.text2 } }, '界面缩放',
|
|
976
|
+
React.createElement('select', { value: String(cur), onChange: function (e) { pick(e.target.value) }, title: '界面缩放:等比放大/缩小面板内字体、图标与间距(选中即时生效,本地记忆)', style: { padding: '0px 4px', fontSize: 9, border: '1px solid ' + C.border, borderRadius: 2, background: C.card, color: C.text } },
|
|
977
|
+
UI_ZOOM_LEVELS.map(function (z) { return React.createElement('option', { key: String(z), value: String(z) }, Math.round(z * 100) + '%' + (z === 1 ? '(默认)' : '')) })))
|
|
978
|
+
}
|
|
979
|
+
|
|
871
980
|
function PoolCfgPopover(props) {
|
|
872
981
|
var _R = React; var useState = _R.useState, useEffect = _R.useEffect, useRef = _R.useRef
|
|
873
982
|
var _a = useState(false), open = _a[0], setOpen = _a[1]
|
|
@@ -891,12 +1000,16 @@
|
|
|
891
1000
|
// position:fixed + 视口坐标:面板容器是 overflow:hidden + maxHeight:60vh,
|
|
892
1001
|
// absolute 弹窗在面板内容短时会被裁掉下半截(超时配置行曾被整个裁掉)。
|
|
893
1002
|
// fixed 脱离任何祖先裁剪(面板无 transform/filter,不会形成包含块)。
|
|
894
|
-
|
|
1003
|
+
// 界面缩放补偿(task-mv58e01j):根容器挂了 CSS zoom 后,getBoundingClientRect 返回缩放后的
|
|
1004
|
+
// 可视坐标,而 fixed 弹层在 zoom 子树内 top/right/maxHeight 会再被缩一次 → 各项除以档位还原成
|
|
1005
|
+
// CSS px,弹层仍贴着按钮且不溢出视口底(zoom=1 时与旧行为逐字一致)。
|
|
1006
|
+
var z = state.uiZoom || 1
|
|
1007
|
+
if (!open && btnRef.current) { var r = btnRef.current.getBoundingClientRect(); setPos({ top: (r.bottom + 4) / z, right: Math.max(8, window.innerWidth - r.right) / z, maxH: Math.max(140, (window.innerHeight - r.bottom - 16) / z) }) }
|
|
895
1008
|
setOpen(!open)
|
|
896
1009
|
}
|
|
897
1010
|
return React.createElement('span', { ref: ref, style: { position: 'relative', display: 'inline-flex' } },
|
|
898
1011
|
React.createElement('button', { ref: btnRef, onClick: toggle, title: '池配置', style: { fontSize: 12, padding: '3px 8px', border: '1px solid ' + (open ? C.brand : C.border), borderRadius: 6, cursor: 'pointer', background: open ? C.nested : 'transparent', color: C.text2, display: 'inline-flex', alignItems: 'center' } }, ic('settings', 13)),
|
|
899
|
-
open && pos ? React.createElement('div', { style: { position: 'fixed', top: pos.top + 'px', right: pos.right + 'px', padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 1000, whiteSpace: 'nowrap', maxHeight:
|
|
1012
|
+
open && pos ? React.createElement('div', { style: { position: 'fixed', top: pos.top + 'px', right: pos.right + 'px', padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 1000, whiteSpace: 'nowrap', maxHeight: (pos.maxH || 300) + 'px', overflowY: 'auto' } },
|
|
900
1013
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 5 } }, '并发上限(一次性派发,用完即销毁)'),
|
|
901
1014
|
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 5 } },
|
|
902
1015
|
React.createElement(PoolCfg, { label: 'Worker 并发', cfgKey: 'maxWorkers', value: props.maxW }),
|
|
@@ -940,7 +1053,19 @@
|
|
|
940
1053
|
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
|
|
941
1054
|
React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) { setCfg('epicSplit', e.target.checked) } }),
|
|
942
1055
|
React.createElement('span', null, '🧩 史诗拆分:大任务引导拆为 epic + 子任务')),
|
|
943
|
-
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作')
|
|
1056
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作'),
|
|
1057
|
+
// 清理保留天数(卡龄主闸 cleanupRetentionDays,默认 7、可配 1-90):回收与归档生命周期对齐——
|
|
1058
|
+
// 卡片归档满该天数后,其会话才进入可清理池。仪表盘「存储清理」区文案读同一服务端权威值。
|
|
1059
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '存储清理'),
|
|
1060
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
1061
|
+
React.createElement(RetentionCfg, { label: '归档保留', value: props.retentionDays, tip: '卡片归档后多少天可被完全回收(1~90 天,默认 7)' }),
|
|
1062
|
+
React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '(归档满该天数后其会话可被清理回收)')),
|
|
1063
|
+
// 界面缩放档位(task-mv58e01j):设置弹层「界面」小节——五档下拉选中即 localStorage
|
|
1064
|
+
// 持久化 + state.uiZoom + notify 即时生效(zoom 挂在 TopPanel 根容器,只影响本面板)。
|
|
1065
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '界面'),
|
|
1066
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
1067
|
+
React.createElement(ZoomCfg, null),
|
|
1068
|
+
React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '(等比缩放面板字体/图标/间距,仅本面板生效并本地记忆)'))) : null)
|
|
944
1069
|
}
|
|
945
1070
|
|
|
946
1071
|
function TeamView() {
|
package/lib/client/kernel.js
CHANGED
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
['circle', { cx: 12, cy: 12, r: 10 }],
|
|
195
195
|
['rect', { width: 6, height: 6, x: 9, y: 9 }]
|
|
196
196
|
],
|
|
197
|
-
//
|
|
197
|
+
// 取消入口图标(详情页「取消任务」按钮;语义=作废不派发,与删除 trash-2 区分)
|
|
198
198
|
'ban': [
|
|
199
199
|
['circle', { cx: 12, cy: 12, r: 10 }],
|
|
200
200
|
['path', { d: 'm4.9 4.9 14.2 14.2' }]
|
|
@@ -234,7 +234,15 @@
|
|
|
234
234
|
blocked: { title: '阻塞:卡住待处理(等依赖/等主窗口裁决)', empty: '暂无阻塞——任务卡住待裁决时会出现在这里' }
|
|
235
235
|
}
|
|
236
236
|
var reqEpoch = 0 // 会话切换纪元:切会话时自增,旧会话在途响应按纪元丢弃
|
|
237
|
-
|
|
237
|
+
// ===== 界面缩放档位(task-mv58e01j,用户 2026-10-12「字体整体偏小」)=====
|
|
238
|
+
// 面板内 ~317 处内联 fontSize px 写死(9/10/11 为主),逐处改不值 → 面板根容器 CSS zoom
|
|
239
|
+
// 等比缩放统一接管(Chromium 支持,字体/图标/间距一并放大;zoom 只作用本面板子树,不碰宿主页面)。
|
|
240
|
+
// 纯客户端偏好(不走 host set-board-config):localStorage 键 dsh-agent-board-ui-zoom 持久化,缺省 1;
|
|
241
|
+
// 脏值/档位外值/读取异常一律回退 1(白名单校验,不写回——下次选中档位时覆盖)。
|
|
242
|
+
var UI_ZOOM_KEY = 'dsh-agent-board-ui-zoom'
|
|
243
|
+
var UI_ZOOM_LEVELS = [0.9, 1, 1.1, 1.25, 1.5] // 90% / 100%(默认)/ 110% / 125% / 150%
|
|
244
|
+
function readUiZoom() { try { var v = parseFloat(localStorage.getItem(UI_ZOOM_KEY)); return UI_ZOOM_LEVELS.indexOf(v) >= 0 ? v : 1 } catch (_) { return 1 } }
|
|
245
|
+
var state = { sessionId: null, tasks: [], boardMode: 'auto', teamMode: false, workMode: 'auto', minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, cleanupRetentionDays: 7, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, verifyUserGuide: true, uiZoom: readUiZoom(), lessonPushed: {}, isRoot: true, isRootEverTrue: false, isRootFalseN: 0, isRootStable: true, open: false, detailId: null, children: [], dragOver: null, dragTask: null, dispatchInfo: '', view: 'board', layoutLeft: 280, layoutRight: 0, poolStatus: null, escalatedIds: [], filterQ: '', filterPrio: [], filterTag: '', filterReview: false, selectMode: false, selected: {}, undoSnapshot: null, cardHover: '', archSort: 'time-desc', dateRange: { from: '', to: '' }, rfOpen: false, gboOpen: false, activity: {}, archived: [], archQ: '', globalBoards: [], createOpen: false, createFlash: '', mpRole: '', mpSize: '', usageSummary: null, childStats: {}, tasksErr: '', tasksHash: '' }
|
|
238
246
|
|
|
239
247
|
// #16 快捷键:Esc 逐级关闭(详情→看板→面板);输入框聚焦时不劫持
|
|
240
248
|
try {
|
|
@@ -315,7 +323,12 @@
|
|
|
315
323
|
// 空范围(两端皆空)不传 field:host 收到 undefined 即全量聚合,与老 host 的调用形态完全一致;
|
|
316
324
|
// 绝不用空字符串占位(host 侧 `from`/`to` 空串虽也判为无范围,但少传一个字段就少一处口径分叉)。
|
|
317
325
|
var rgSend = activeRange()
|
|
318
|
-
var rpcArgs = (rgSend.from || rgSend.to) ? { range: rgSend } :
|
|
326
|
+
var rpcArgs = (rgSend.from || rgSend.to) ? { range: rgSend } : {}
|
|
327
|
+
// 条件请求瘦身(task-mv492for):带上轮 tasksHash 作 ifHash,host 比对一致则返回 unchanged:true
|
|
328
|
+
// 轻量响应(不含 tasks 全量,轮询载荷 1.34MB→KB 级);首轮/会话切换后 state.tasksHash 为空不带
|
|
329
|
+
// ifHash → 全量返回(与不带 ifHash 的老客户端同路径)。空范围时 rpcArgs 由 undefined 改 {} 是同义
|
|
330
|
+
// 形态(rpc 助手 args||{} 归一;host 只读 range/sessionId/ifHash,多传空对象无害)。
|
|
331
|
+
if (state.tasksHash) rpcArgs.ifHash = state.tasksHash
|
|
319
332
|
// 范围守卫:范围已在本轮响应回来前被改掉 → 这份响应属于旧范围,丢弃 usageSummary 赋值
|
|
320
333
|
// (否则旧范围的旧聚合会覆盖新范围的新聚合,用户看到「切了范围数字没变」直到下一次轮询)。
|
|
321
334
|
// 注意 tasksHash 短路只管 tasks:usageSummary 照常赋值(现状已对,保持)。
|
|
@@ -330,7 +343,11 @@
|
|
|
330
343
|
// (hash 只管任务列表渲染;这些字段随下次任意 notify 生效,不为它们单独渲染)。
|
|
331
344
|
// 老 host 不返回 tasksHash → 恒视为变化,行为与旧版完全一致。
|
|
332
345
|
var newHash = (d && d.tasksHash) || ''
|
|
333
|
-
|
|
346
|
+
// 条件请求瘦身(task-mv492for):host 判 ifHash 一致时返回 unchanged:true(响应不含 tasks 全量)。
|
|
347
|
+
// state.tasks 必须原样保留——unchanged 优先于 hash 比对:缺 tasks 的响应若误入变化分支,
|
|
348
|
+
// `(d && d.tasks) || []` 会把列表清空。轻量字段不受影响,照常走下方既有赋值链。
|
|
349
|
+
var unchanged = !!(d && d.unchanged)
|
|
350
|
+
var tasksChanged = !unchanged && (!newHash || newHash !== state.tasksHash)
|
|
334
351
|
// 设置开关的权威纠偏(task-muw5uudk):开关不走 tasksHash,hash 不变分支里照常赋值也没有 notify
|
|
335
352
|
// → 勾选框必须等下一次任意 notify 才翻面(安静板卡数秒)。这里在赋值前快照、赋值后比对,
|
|
336
353
|
// 有变化就补一次 notify:乐观更新(PoolCfgPopover)已让点击瞬时翻面,本兜底是服务端权威值纠偏
|
|
@@ -385,6 +402,9 @@
|
|
|
385
402
|
state.epicSplit = cfgKnobOf(d, 'epicSplit')
|
|
386
403
|
// 自测指南开关(设置区「验收」):同上——缺字段=开(详情块/报告清单段照常),只有显式 false 才关
|
|
387
404
|
state.verifyUserGuide = cfgKnobOf(d, 'verifyUserGuide')
|
|
405
|
+
// 清理保留天数(设置区「存储清理」,卡龄主闸):数值键,host 已 cfg 归一(缺字段/脏值=7、钳 1-90),
|
|
406
|
+
// 客户端读服务端权威值(与 softTimeoutMin/hardTimeoutMin 同款直接赋值,不做布尔口径纠偏)。
|
|
407
|
+
state.cleanupRetentionDays = (d && d.cleanupRetentionDays) || 7
|
|
388
408
|
// 开关有变 / Token 区聚合有变 → 补一次 notify(tasksChanged 分支已在上面 notify 过,
|
|
389
409
|
// 这里只管 hash 不变时被跳过的那两次:开关乐观更新纠偏 + 统计范围切换后的新聚合)
|
|
390
410
|
var cfgDelta = !tasksChanged && cfgKnobsChanged(cfgKnobs, state)
|
|
@@ -602,8 +622,10 @@
|
|
|
602
622
|
var _ndo = useState(state.notifyDispatch), ndOn = _ndo[0], setNdOn = _ndo[1]; var _nno = useState(state.notifyDone), nnOn = _nno[0], setNnOn = _nno[1]
|
|
603
623
|
var _eso = useState(state.epicSplit), esOn = _eso[0], setEsOn = _eso[1] // 史诗拆分总开关勾选态(设置区「功能」)
|
|
604
624
|
var _vug = useState(state.verifyUserGuide), vugOn = _vug[0], setVugOn = _vug[1] // 自测指南开关勾选态(设置区「验收」)
|
|
625
|
+
var _crd = useState(state.cleanupRetentionDays), crd = _crd[0], setCrd = _crd[1] // 清理保留天数(设置区「存储清理」)
|
|
626
|
+
var _uzm = useState(state.uiZoom), uiZoom = _uzm[0], setUiZoom = _uzm[1] // 界面缩放档位(设置区「界面」,zoom 挂面板根容器)
|
|
605
627
|
var _usg = useState(state.usageSummary), setUsage = _usg[1] // Token 区聚合订阅(task-mv10lvud 根修):usageSummary 换新对象即触发渲染
|
|
606
|
-
useEffect(function () { function update() { setOpen(state.open); setTasksState(state.tasks); setModeState(state.boardMode); setDetailId(state.detailId); setDragOver(state.dragOver); setDispatchInfo(state.dispatchInfo); setViewState(state.view); setLayL(state.layoutLeft); setLayR(state.layoutRight); setMinW(state.minWorkers); setMaxW(state.maxWorkers); setMinV(state.minVerifiers); setMaxV(state.maxVerifiers); setWorkerModel(state.workerModel); setVerifierModel(state.verifierModel); setTeamMode(state.teamMode); setWorkModeState(state.workMode); setDR(state.dateRange); setSoftT(state.softTimeoutMin); setHardT(state.hardTimeoutMin); setFbEnabled(state.feedbackEnabled); setNdOn(state.notifyDispatch); setNnOn(state.notifyDone); setEsOn(state.epicSplit); setVugOn(state.verifyUserGuide); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash); setTasksErr(state.tasksErr); setUsage(state.usageSummary) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
|
|
628
|
+
useEffect(function () { function update() { setOpen(state.open); setTasksState(state.tasks); setModeState(state.boardMode); setDetailId(state.detailId); setDragOver(state.dragOver); setDispatchInfo(state.dispatchInfo); setViewState(state.view); setLayL(state.layoutLeft); setLayR(state.layoutRight); setMinW(state.minWorkers); setMaxW(state.maxWorkers); setMinV(state.minVerifiers); setMaxV(state.maxVerifiers); setWorkerModel(state.workerModel); setVerifierModel(state.verifierModel); setTeamMode(state.teamMode); setWorkModeState(state.workMode); setDR(state.dateRange); setSoftT(state.softTimeoutMin); setHardT(state.hardTimeoutMin); setFbEnabled(state.feedbackEnabled); setNdOn(state.notifyDispatch); setNnOn(state.notifyDone); setEsOn(state.epicSplit); setVugOn(state.verifyUserGuide); setCrd(state.cleanupRetentionDays); setUiZoom(state.uiZoom); setCreateOpen(state.createOpen); setCreateFlash(state.createFlash); setTasksErr(state.tasksErr); setUsage(state.usageSummary) }; listeners.push(update); update(); return function () { var i = listeners.indexOf(update); if (i >= 0) listeners.splice(i, 1) } }, [])
|
|
607
629
|
if (!open) return null
|
|
608
630
|
if (!state.isRootStable) return null // 子代理会话不渲染看板面板(读 isRootStable:瞬态 false 不闪,见 isRoot 蝶变防抖)
|
|
609
631
|
var active = tasks.filter(function (t) { return t.status !== 'archived' }); var archived = tasks.filter(function (t) { return t.status === 'archived' })
|
|
@@ -625,11 +647,14 @@
|
|
|
625
647
|
})
|
|
626
648
|
content = React.createElement('div', null, React.createElement(FilterBar, null), hasFilter && active.length === 0 && archived.length === 0 ? React.createElement('div', { style: { textAlign: 'center', padding: 16, color: C.text2, fontSize: 11 } }, '无匹配任务') : null, React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 6 } }, state.selectMode ? '多选模式:点击卡片勾选,底部批量操作' : '提示:拖拽卡片到目标列即可流转状态'), tasks.length === 0 ? React.createElement('div', { style: { textAlign: 'center', padding: '16px 8px 12px', color: C.text2, fontSize: 12 } }, '点击右上角', React.createElement('span', { style: { color: C.brand, fontWeight: 600 } }, '「+ 新建任务」'), '创建第一张卡,或直接在对话里说:帮我建一张任务卡') : null, React.createElement('div', { style: { display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 } }, cols), React.createElement(BatchBar, null))
|
|
627
649
|
}
|
|
628
|
-
|
|
650
|
+
// 面板根容器(顶层 div):zoom 挂这里——等比缩放整个面板子树(字体/图标/间距),只影响本面板
|
|
651
|
+
// 不影响宿主页面;uiZoom 为订阅值(设置弹层选档 → state.uiZoom + notify → 本组件重渲染即时生效)。
|
|
652
|
+
// React 的 zoom 属无单位数值型 style(isUnitlessNumber 含 zoom),直接传数值不会拼 px。
|
|
653
|
+
return React.createElement('div', { style: { position: 'fixed', top: 44, left: (layL + 8) + 'px', right: (layR + 8) + 'px', maxHeight: '60vh', zIndex: 900, zoom: uiZoom, background: C.bg, border: '1px solid ' + C.border, borderRadius: '0 0 10px 10px', boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
|
|
629
654
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 12px', borderBottom: '1px solid ' + C.border, flexShrink: 0, flexWrap: 'wrap', gap: 4 } },
|
|
630
655
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } }, React.createElement('span', { style: { fontWeight: 600, fontSize: 13, color: C.text, display: 'inline-flex', alignItems: 'center', gap: 5 } }, ic('clipboard-list', 15), '智能看板'), React.createElement(ViewTab, null), React.createElement(PoolStatus, null)),
|
|
631
656
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' } },
|
|
632
|
-
React.createElement(PoolCfgPopover, { minW: minW, maxW: maxW, minV: minV, maxV: maxV, workerModel: workerModel, verifierModel: verifierModel, softT: softT, hardT: hardT, feedbackEnabled: fbEnabled, notifyDispatch: ndOn, notifyDone: nnOn, epicSplit: esOn, verifyUserGuide: vugOn }),
|
|
657
|
+
React.createElement(PoolCfgPopover, { minW: minW, maxW: maxW, minV: minV, maxV: maxV, workerModel: workerModel, verifierModel: verifierModel, softT: softT, hardT: hardT, feedbackEnabled: fbEnabled, notifyDispatch: ndOn, notifyDone: nnOn, epicSplit: esOn, verifyUserGuide: vugOn, retentionDays: crd }),
|
|
633
658
|
dispatchInfo ? React.createElement('span', { style: { fontSize: 9, color: C.brand, maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: dispatchInfo }, dispatchInfo) : null,
|
|
634
659
|
React.createElement('button', { onClick: function () { state.createOpen = true; notify() }, title: '新建任务(可存为草稿)', style: { fontSize: 11, padding: '3px 8px', border: '1px solid ' + C.border, borderRadius: 6, cursor: 'pointer', background: 'transparent', color: C.text2, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('plus', 11), '新建任务'),
|
|
635
660
|
createFlash ? React.createElement('span', { style: { fontSize: 10, color: C.ok } }, createFlash) : null,
|