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.
@@ -8,19 +8,21 @@
8
8
  */
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
12
12
  import { readFile } from 'node:fs/promises'
13
13
  import { assignSlugs, collectState, generateAndInstall, presetIdOf } from '../../tools/install.mjs'
14
14
  import { autoAssignments, baseAssignments } from '../../tools/lib/alloc.mjs'
15
+ import { computeRestrictAllow } from '../../tools/lib/compose.mjs'
15
16
  import { loadConfig, normalizeConfig, normalizeTargetFields, saveConfig, serializeConfig, stripNonTargetKeys } from '../../tools/lib/config.mjs'
16
- import { parseYamlSubset, preprocessPatchText } from '../../tools/lib/yaml.mjs'
17
+ import { loadYaml, parseYamlSubset, preprocessPatchText } from '../../tools/lib/yaml.mjs'
17
18
  import { captureWarnings, healthCheck } from '../../tools/lib/health.mjs'
18
- import { warn } from '../../tools/lib/util.mjs'
19
+ import { ROLES, warn } from '../../tools/lib/util.mjs'
19
20
  import {
20
21
  backfillHomePatchRows,
21
22
  detectAgentRolesFlavor,
22
23
  detectHostVersionInfo,
23
24
  detectPresetSystem,
25
+ findSuspectPresetRows,
24
26
  formatGeneratorMarker,
25
27
  isPtcWorkerHost,
26
28
  listOrchestratorPresetIds,
@@ -28,6 +30,7 @@ import {
28
30
  presetRolesFlavorOf,
29
31
  presetRowId,
30
32
  readHomePatchRows,
33
+ resolvePatchTargetLayer,
31
34
  stripHomePatchRows,
32
35
  ROLES_AGENT_PRESET,
33
36
  WORKER_PTC,
@@ -74,6 +77,37 @@ export function resolveConfigFile() {
74
77
  return process.env.DSH_PAODING_CONFIG || path.join(resolveDshHome(), 'dsh-paoding.config.yml')
75
78
  }
76
79
 
80
+ /**
81
+ * patch 层解析(自愈 / 删除工作区预设等动 patch 的路径入口各解析一次):
82
+ * 读一次配置(loadConfig 失败按 null 对待——层判定不被配置错误牵连,配置问题
83
+ * 由各写盘路径自己的护栏处理),经 resolvePatchTargetLayer 走完整判定链
84
+ * (patch_layer 键 > DSH_PAODING_PATCH_LAYER > compat 壳探测 > home)。
85
+ */
86
+ function resolveLayerTarget(dshHome, config = null) {
87
+ return resolvePatchTargetLayer(dshHome, { config })
88
+ }
89
+
90
+ /**
91
+ * 两层 patch 文件的可疑行扫描(maoying 坏行形状探测,WARN 级,只记不删——
92
+ * 自动删行会踩用户手写内容)。目标层与 legacy 层都扫:compat 壳把坏行留在
93
+ * home 层的场景里,启动自愈只重写 profile 层,home 层的坏行只有这里能被看见。
94
+ * 读失败静默跳过(该层没这文件很正常)。
95
+ */
96
+ function warnSuspectPatchRows(dshHome, layerTarget) {
97
+ for (const patchPath of [layerTarget.patchPath, layerTarget.legacyPatchPath]) {
98
+ let text = null
99
+ try {
100
+ text = readFileSync(patchPath, 'utf8')
101
+ } catch {
102
+ continue
103
+ }
104
+ for (const suspect of findSuspectPresetRows(text)) {
105
+ warn(`${patchPath} 第 ${suspect.lineNo} 行检出可疑声明行 "- id: ${suspect.id}"(id 含空白或非 ASCII,` +
106
+ '形如 compat 壳压平的坏行,会让宿主 YAML 解析失败)——运行 `dsh-paoding doctor` 查看与清理')
107
+ }
108
+ }
109
+ }
110
+
77
111
  // ── state cache ─────────────────────────────────────────────────────────────
78
112
 
79
113
  let stateCache = null
@@ -288,10 +322,178 @@ export async function importConfigContent(content) {
288
322
  error.statusCode = 422
289
323
  throw error
290
324
  }
325
+ // 写盘前自动轮换备份(当前配置推入 bak-1,旧 bak 依次后移;首次保存无
326
+ // 「当前」则不备份)——apply / 工作区预设写盘 / 回滚同一口径,见下方备份节。
327
+ rotateConfigBackups()
291
328
  saveConfig(resolveConfigFile(), normalizeConfig(data))
292
329
  return { ok: true }
293
330
  }
294
331
 
332
+ // ── 配置自动备份与回滚(配置文件写路径的护栏,页头「更多」菜单服务端核心)──────
333
+ //
334
+ // 语义:凡面板写配置(apply / import / 工作区预设写盘 / 回滚本身)在落盘前先把
335
+ // 当前配置推入备份链:当前 → bak-1(最新),旧 bak 依次后移(bak-1→bak-2、
336
+ // bak-2→bak-3),bak-3(最老)淘汰。首次保存(配置文件尚不存在)没有「当前」
337
+ // 可备份,不产生任何 bak 文件。备份名 = 配置文件名 + .bak-N(默认配置即
338
+ // dsh-paoding.config.yml.bak-1/2/3),自定义配置名(DSH_PAODING_CONFIG)随之
339
+ // 派生,清单与还原按同一派生口径校验。
340
+
341
+ /** 备份名校验用正则:配置文件名转义后拼 .bak-N 后缀(N = 1..3 层)。 */
342
+ function configBackupRe() {
343
+ const stem = path.basename(resolveConfigFile()).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
344
+ return new RegExp(`^${stem}\\.bak-([123])$`)
345
+ }
346
+
347
+ /** 第 n 层备份的绝对路径(与配置文件同目录,文件名恒为单层 basename)。 */
348
+ function configBackupPath(n) {
349
+ return `${resolveConfigFile()}.bak-${n}`
350
+ }
351
+
352
+ /**
353
+ * 备份轮换:把当前配置推入 bak-1,旧 bak 依次后移,bak-3 淘汰。当前配置不
354
+ * 存在(首次保存)时不写任何文件、返回 false;轮换完成返回 true。当前文件
355
+ * 用拷贝而非改名进 bak-1——原地保留,读侧(生成管线 / 回滚校验)随时可读;
356
+ * 后移用改名,同一目录内 rename 是原子操作,中断也不产生半个备份。
357
+ */
358
+ export function rotateConfigBackups() {
359
+ const config = resolveConfigFile()
360
+ if (!existsSync(config)) return false
361
+ rmSync(configBackupPath(3), { force: true }) // 最老一层直接淘汰(不存在也无妨)
362
+ for (const [from, to] of [[2, 3], [1, 2]]) {
363
+ try {
364
+ renameSync(configBackupPath(from), configBackupPath(to))
365
+ } catch (err) {
366
+ if (err.code !== 'ENOENT') throw err // 该层尚无备份:正常,跳过
367
+ }
368
+ }
369
+ copyFileSync(config, configBackupPath(1))
370
+ return true
371
+ }
372
+
373
+ /**
374
+ * 备份清单(GET config/backups):{ file, mtimeMs, size }[],按新→旧排序
375
+ *(mtime 降序,同刻并列时层号小者在前)。只读不写;配置目录读不了 / 无备份
376
+ * 一律返回空数组,绝不让清单页 5xx。file 恒为 basename(还原路由只认配置
377
+ * 目录内的备份名,回传它即可防路径穿越)。
378
+ */
379
+ export function listConfigBackups() {
380
+ let names = []
381
+ try {
382
+ names = readdirSync(path.dirname(resolveConfigFile()))
383
+ } catch {
384
+ return [] // 目录不存在 / 不可读 = 无备份可言
385
+ }
386
+ const backupRe = configBackupRe()
387
+ return names
388
+ .map((name) => {
389
+ const m = backupRe.exec(name)
390
+ if (!m) return null
391
+ try {
392
+ const st = statSync(path.join(path.dirname(resolveConfigFile()), name))
393
+ return { file: name, mtimeMs: st.mtimeMs, size: st.size, layer: Number(m[1]) }
394
+ } catch {
395
+ return null // 列举途中被删 / stat 失败:跳过该条
396
+ }
397
+ })
398
+ .filter(Boolean)
399
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || a.layer - b.layer)
400
+ .map(({ file, mtimeMs, size }) => ({ file, mtimeMs, size }))
401
+ }
402
+
403
+ /**
404
+ * 回滚配置(POST config/restore):file 须精确匹配「配置文件名.bak-1/2/3」,
405
+ * 显式拒任何含分隔符 / 上跳段的名字(防路径穿越);通过后先按 loadConfig 同一
406
+ * 口径验证备份可解析(坏备份拒绝覆盖,422;备份不存在 404——statusCode 由
407
+ * 路由层外层 catch 按其回状态码 + { error } 信封),再把「当前配置」按同款
408
+ * 轮换推入备份链(滚错了还能再滚回来),最后拷贝备份为当前。落盘即改 mtime,
409
+ * state 缓存靠既有对账自动失效,无需手动清理。
410
+ */
411
+ export async function restoreConfigBackup(file) {
412
+ const name = typeof file === 'string' ? file : ''
413
+ if (name === '' || name.includes('/') || name.includes('..') || !configBackupRe().test(name)) {
414
+ const error = new Error(`备份文件名不合法(须为 ${path.basename(resolveConfigFile())}.bak-1/2/3):${name === '' ? '(空)' : name}`)
415
+ error.statusCode = 422
416
+ throw error
417
+ }
418
+ const backupPath = path.join(path.dirname(resolveConfigFile()), name)
419
+ // 宿主 yaml 模块从 state 顺路取(collectState 已探测好);state 通道异常时
420
+ // 退化为仅内置解析器校验,与 importConfigContent 同款降级——宁严勿漏。
421
+ let yamlMod = null
422
+ try {
423
+ yamlMod = (await getState())?.yamlMod ?? null
424
+ } catch { /* 校验口径降级,见上 */ }
425
+ let parsed = null
426
+ try {
427
+ parsed = loadConfig(backupPath, yamlMod)
428
+ } catch (err) {
429
+ const error = new Error(`备份无法解析为合法配置,已拒绝回滚:${err.message}`)
430
+ error.statusCode = 422
431
+ throw error
432
+ }
433
+ if (parsed === null) {
434
+ const error = new Error(`备份不存在:${name}`)
435
+ error.statusCode = 404
436
+ throw error
437
+ }
438
+ // 先把选定备份的内容读进内存再轮换:轮换会把整条备份链后移一层,固定层号
439
+ // (bak-N)路径下的内容随之改变——后读的话落到「当前」的就不是用户选的
440
+ // 那份快照了。配置文件很小,整读零成本,顺序必须遵守。
441
+ const content = readFileSync(backupPath)
442
+ // 回滚本身也是一次写配置:先把「当前」按同款轮换推入备份链(滚错了还能
443
+ // 再滚回来),再落选定快照。mtime 变化令 state 缓存按既有对账自动失效,
444
+ // 无需手动清理。
445
+ rotateConfigBackups()
446
+ writeFileSync(resolveConfigFile(), content, { mode: 0o600 }) // 权限口径与 saveConfig 一致
447
+ return { ok: true }
448
+ }
449
+
450
+ // ── 工具面瘦身统计(slim:state / preview / apply 响应的派生字段)─────────────
451
+
452
+ /**
453
+ * 主 agent 工具面瘦身统计,口径与生成管线严格同源(composeGenerated):
454
+ * - total = 可用工具全集 = presetUniverse ∪ inventory 的名字数。presetUniverse
455
+ * = restrict.mjs 主 agent 白名单(restrictBase)∪ 源 preset 各角色 allow
456
+ * 自带面;inventory = 检测到的可用 MCP / 插件工具名。这正是单 agent 模式下
457
+ * 主 agent 名下可能挂着的一切工具(computeRestrictAllow / usableWith 判定
458
+ * 用的同一候选全集),即「不拆角色时的完整工具面」;
459
+ * - main = 当前 assignments 下主 agent 的有效 allow 计数:computeRestrictAllow
460
+ * (base ∪ extra ∪ 自定义角色名 − main_agent_remove − roles_remove,过可用
461
+ * 性过滤)的结果长度;结果与 base 全同时该函数返回 null,按 base 计数。
462
+ * state 收原始通道(blocks 为 Map、inventory 为 Set)与序列化视图(blocks 为
463
+ * 普通对象、inventory 为数组)两态,一律只读。assignments 先剥 profile /
464
+ * workspaces、过 normalizeTargetFields 再算——手写未规范化的提交也得到与
465
+ * 落盘一致的口径。
466
+ */
467
+ export function slimStatsOf(state, assignments) {
468
+ const restrictBase = Array.isArray(state?.restrictBase) ? state.restrictBase : []
469
+ // blocks 两种形态:原始 state 的 Map 值带 names 数组;序列化视图的值即名字数组
470
+ const blockValues = state?.blocks instanceof Map
471
+ ? [...state.blocks.values()]
472
+ : (state?.blocks && typeof state.blocks === 'object' ? Object.values(state.blocks) : [])
473
+ const presetUniverse = new Set(restrictBase)
474
+ for (const block of blockValues) {
475
+ for (const name of Array.isArray(block?.names) ? block.names : Array.isArray(block) ? block : []) {
476
+ presetUniverse.add(name)
477
+ }
478
+ }
479
+ const inventory = state?.inventory instanceof Set
480
+ ? state.inventory
481
+ : new Set(Array.isArray(state?.inventory) ? state.inventory : [])
482
+ const total = new Set([...presetUniverse, ...inventory]).size
483
+ const target = normalizeTargetFields(stripNonTargetKeys(assignments ?? {}))
484
+ const customToolNames = Object.keys(target.roles).filter((n) => !ROLES.includes(n))
485
+ const allow = computeRestrictAllow(
486
+ restrictBase,
487
+ presetUniverse,
488
+ inventory,
489
+ target.main_agent_extra,
490
+ target.main_agent_remove,
491
+ customToolNames,
492
+ target.roles_remove,
493
+ ) ?? restrictBase
494
+ return { total, main: allow.length }
495
+ }
496
+
295
497
  // ── 预设 CRUD(presets 路由族的服务端核心)──────────────────────────────────
296
498
  //
297
499
  // 面板 apply 只覆盖「全局 / 单个工作区视角的生成」,这里补齐预设对象本身的
@@ -414,7 +616,11 @@ export function deleteWorkspacePreset(state, workspacePath) {
414
616
  rmSync(presetDir, { recursive: true, force: true })
415
617
  removedPresetDir = true
416
618
  }
417
- stripHomePatchRows(dshHome, [presetRowId(presetId)])
619
+ // 声明行撤在目标层(prev 配置在场:patch_layer / profile 键生效;compat 壳
620
+ // 环境自动落 profile 层)。撤哪层必须与当初写哪层对齐,否则删工作区会在
621
+ // home 层空撤一轮、profile 层残留孤儿声明行。
622
+ const layerTarget = resolveLayerTarget(dshHome, prev)
623
+ stripHomePatchRows(dshHome, [presetRowId(presetId)], { layer: layerTarget.layer, profileName: layerTarget.profileName })
418
624
 
419
625
  return { presetId, removedConfigEntry, removedPresetDir }
420
626
  }
@@ -526,6 +732,17 @@ export async function ensurePresetInstalled({ dshHome = resolveDshHome(), genera
526
732
  marker = parseGeneratorMarker(await readFile(markerFile, 'utf8'))
527
733
  } catch { /* 目录或标记缺失:按未生成处理 */ }
528
734
 
735
+ // 目标 patch 层解析一次(配置读取失败按 null 对待——层判定不被配置错误
736
+ // 牵连),后续补行 / 读行都落目标层(compat 壳环境即 profile 层);顺手扫
737
+ // 两层可疑坏行(WARN 级只留轨迹,绝不阻塞自愈——诊断归 doctor)。
738
+ let ensureConfig = null
739
+ try {
740
+ ensureConfig = loadConfig(resolveConfigFile(), loadYaml(dshHome))
741
+ } catch { /* 配置解析失败:层按自动判定走 */ }
742
+ const layerTarget = resolveLayerTarget(dshHome, ensureConfig)
743
+ const patchOpts = { layer: layerTarget.layer, profileName: layerTarget.profileName }
744
+ warnSuspectPatchRows(dshHome, layerTarget)
745
+
529
746
  if (marker !== null && marker.version === generatorVersion) {
530
747
  // 版本一致:内容原则上无需重生成,但宿主可能已随 dsh 升降级翻转轨道或
531
748
  // 跨过 workflow worker 形态 / 角色委派行形态边界(只升 dsh 不动插件是升级
@@ -540,7 +757,7 @@ export async function ensurePresetInstalled({ dshHome = resolveDshHome(), genera
540
757
  if (shouldAbort?.()) return { installed: false, reason: PRESET_ENSURE_ABORTED }
541
758
  const presetIds = listOrchestratorPresetIds(dshHome)
542
759
  if (system === 'declarative') {
543
- const backfilled = backfillHomePatchRows(dshHome, presetIds)
760
+ const backfilled = backfillHomePatchRows(dshHome, presetIds, patchOpts)
544
761
  if (marker.track === 'declarative' && backfilled.length === 0) {
545
762
  return { installed: false, reason: 'preset 已由当前版本生成器产出,无需重生成' }
546
763
  }
@@ -551,7 +768,8 @@ export async function ensurePresetInstalled({ dshHome = resolveDshHome(), genera
551
768
  }
552
769
  // directory 轨:声明行残留会让 0.1.6 profile 启动失败——有残留走整盘
553
770
  // 重生成(directory 分支撤块自愈);行清白时只升级标记格式,不动内容。
554
- if (readHomePatchRows(dshHome).length === 0) {
771
+ // 读的是目标层(compat 壳环境下 home 层残留由 reconcile 另行处理)。
772
+ if (readHomePatchRows(dshHome, patchOpts).length === 0) {
555
773
  if (marker.track === 'directory') {
556
774
  return { installed: false, reason: 'preset 已由当前版本生成器产出,无需重生成' }
557
775
  }
@@ -590,8 +808,11 @@ export async function ensurePresetInstalled({ dshHome = resolveDshHome(), genera
590
808
  const track = result.presetSystem === 'declarative' ? 'declarative' : 'directory'
591
809
  writeFileSync(markerFile, formatGeneratorMarker(generatorVersion, track, workerFlavorOf(state.hostVersionTriple), rolesFlavorOf(state.hostVersionTriple, state.hostPrerelease)), 'utf8')
592
810
  // 声明轨上整盘重生成只重建本 preset 的行:其余工作区 preset 目录若在而
593
- // 声明行缺(轨道翻转后首次触发本路径),一并补齐。
594
- const backfilled = track === 'declarative' ? backfillHomePatchRows(dshHome, listOrchestratorPresetIds(dshHome)) : []
811
+ // 声明行缺(轨道翻转后首次触发本路径),一并补齐(落目标层;
812
+ // generateAndInstall 内部同口径解析层并在 profile 层写成功后 reconcile 撤
813
+ // home 遗留块)。校验失败(如文件已含坏行)在此被外层 catch 收成 error,
814
+ // 自愈绝不带崩插件启动。
815
+ const backfilled = track === 'declarative' ? backfillHomePatchRows(dshHome, listOrchestratorPresetIds(dshHome), patchOpts) : []
595
816
  // 工作区预设角色行形态换代(宿主跨 0.2.1 边界后的存量 orchestrator-<slug>
596
817
  // 产物):基础重生成只换了全局预设,工作区产物不跟手就会继续被守卫拒绝。
597
818
  const migratedPresets = migrateWorkspacePresetRoles({ dshHome, state })
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(左侧栏全页配置,无独立服务端):
5
5
  * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/presets/
6
- * version/upgrade/preview/apply/rescan/config/export/config/import +
6
+ * version/upgrade/preview/apply/rescan/config/export/config/import/
7
+ * config/backups/config/restore +
7
8
  * client.css 静态样式),经
8
9
  * ./api-core.mjs 复用 tools/install.mjs 的检测与生成管线;workspaces 直连
9
10
  * 宿主 workspaceRegistry 服务(list / create,幂等);presets 提供预设
@@ -39,10 +40,12 @@ import { readFile } from 'node:fs/promises'
39
40
  import os from 'node:os'
40
41
  import path from 'node:path'
41
42
  import { fileURLToPath } from 'node:url'
42
- import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent, listPresets, resolveWorkspacePathByPresetId, deleteWorkspacePreset } from './api-core.mjs'
43
+ import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent, listConfigBackups, restoreConfigBackup, rotateConfigBackups, slimStatsOf, listPresets, resolveWorkspacePathByPresetId, deleteWorkspacePreset } from './api-core.mjs'
43
44
  import { collectModelRefs } from '../../tools/lib/health.mjs'
45
+ import { diffAssignments, loadConfig } from '../../tools/lib/config.mjs'
44
46
  import { detectSkills } from '../../tools/lib/skills.mjs'
45
47
  import { collectRuntimeFacts, detectHmrOf } from './runtime-inventory.mjs'
48
+ import { collectDelegationStats } from './stats-core.mjs'
46
49
  import { getVersionInfo } from '../../tools/lib/version.mjs'
47
50
  import { runUpgrade } from '../../tools/lib/upgrade.mjs'
48
51
 
@@ -216,9 +219,17 @@ function persistenceAvailableOf(ctx) {
216
219
  }
217
220
  }
218
221
 
219
- /** 面板 state 响应统一挂 persistenceAvailable(state / rescan 同一形状)。 */
222
+ /** 面板 state 响应统一挂 persistenceAvailable(state / rescan 同一形状)。
223
+ * slim:工具面瘦身统计(total = 可用工具全集名数、main = 已保存配置下主 agent
224
+ * 有效 allow 计数),按磁盘已保存配置算——state 是序列化视图(blocks 普通对象 /
225
+ * inventory 数组),slimStatsOf 两态通吃;无配置文件时 existing 为 null,按空
226
+ * 目标字段算(main = restrict.mjs 基准白名单长度)。 */
220
227
  function stateResponse(ctx, state) {
221
- return { ...state, persistenceAvailable: persistenceAvailableOf(ctx) }
228
+ return {
229
+ ...state,
230
+ persistenceAvailable: persistenceAvailableOf(ctx),
231
+ slim: slimStatsOf(state, state.existing ?? {}),
232
+ }
222
233
  }
223
234
 
224
235
  // ── state 读取共享 helper(「采集运行时事实 → 重刷 → 读缓存 →〔按需〕序列化」)──
@@ -322,6 +333,9 @@ async function writeWorkspacePreset(ctx, res, { workspacePath, assignments, with
322
333
  }
323
334
  }
324
335
  try {
336
+ // 写盘前自动轮换备份(当前配置推入 bak-1;首次保存无「当前」则不备份),
337
+ // 与 apply / import / 回滚同一口径——工作区预设写盘同样是配置写路径。
338
+ rotateConfigBackups()
325
339
  const result = installAssignments(s, assignments, {
326
340
  dryRun: false,
327
341
  saveConfigOnWrite: true,
@@ -436,6 +450,26 @@ export async function apply(ctx) {
436
450
  return
437
451
  }
438
452
 
453
+ if (route === 'stats' && req.method === 'GET') {
454
+ // 委派效果统计(GET stats?days=30&project=<slug>):宿主
455
+ // sessionPersistence 服务逐会话冷读抽摘要聚合(A 路,见
456
+ // stats-core.mjs 文件头——服务面、slug 口径、mtime 预过滤与失败判据
457
+ // 的实测证据都在那里)。服务经 ctx.reflect 软探测(绝不进 inject 数
458
+ // 组:硬注入会让缺服务的老宿主在挂载时就炸),拿不到回 { ok:false,
459
+ // reason:'persistence-unavailable' },客户端整卡不渲染。project
460
+ // 缺省 = 当前面板项目(cwd → projectKey 换算),该 slug 零委派记录
461
+ // 时核心层自动回落全项目聚合(响应 project='all',面板标题标注
462
+ // 「全部项目」;desktop 宿主进程 cwd 停在 profile 目录、换算 slug
463
+ // 恒无会话,靠这一步救回卡片),显式传 project 不回落;days 缺省 30
464
+ // 上限 365;project 非法在核心层抛 422(外层 catch 统一回 { error })。
465
+ // 路由注册在既有 /api/paoding 前缀下,上方信任围栏自动覆盖本路由。
466
+ json(res, 200, await collectDelegationStats(ctx, {
467
+ project: url.searchParams.get('project') ?? undefined,
468
+ days: url.searchParams.get('days') ?? undefined,
469
+ }))
470
+ return
471
+ }
472
+
439
473
  if (route === 'models' && req.method === 'GET') {
440
474
  // 模型目录:DSH 设置页模型选择器同款目录(deepseek-official 三模型、
441
475
  // pi-ai 各路由等),JSON-safe 纯对象。与 state 缓存无关,每次实调——
@@ -608,6 +642,14 @@ export async function apply(ctx) {
608
642
  }
609
643
  }
610
644
  try {
645
+ // diff 旧值先读:apply 的落盘会覆盖磁盘配置,改前这一读才拿得到
646
+ // 磁盘真实内容(preview 不落盘,先后等价,统一走改前口径)。生成
647
+ // 管线随后对同一文件做同样的 loadConfig 护栏,解析失败同样 422。
648
+ const diskAssignments = loadConfig(s.configFile, s.yamlMod)
649
+ // 写盘前自动轮换备份(当前配置推入 bak-1;首次保存无「当前」则不
650
+ // 备份),与 import / 工作区预设写盘 / 回滚同一口径。预览不落盘,
651
+ // 不轮换。
652
+ if (!dryRun) rotateConfigBackups()
611
653
  const result = installAssignments(s, body.assignments, {
612
654
  dryRun,
613
655
  saveConfigOnWrite: !dryRun,
@@ -639,6 +681,12 @@ export async function apply(ctx) {
639
681
  // 结构化体检结果(tools/lib/health.mjs):未挂载工具 / 零工具角色 /
640
682
  // 未知模型 / 预设漂移 / 生成期告警。纯增量字段,客户端批次负责渲染。
641
683
  warnings: result.warnings,
684
+ // 本次提交相对磁盘现配置的差分(tools/lib/config.mjs 的纯结构化
685
+ // 结果,零文案,客户端 i18n 渲染)与工具面瘦身统计(total = 可用
686
+ // 工具全集,main = 本次提交下主 agent 有效 allow 计数)。均为纯
687
+ // 增量字段。
688
+ diff: diffAssignments(diskAssignments, body.assignments),
689
+ slim: slimStatsOf(s, body.assignments),
642
690
  })
643
691
  } catch (err) {
644
692
  // 422 保持既有 { error } 形状;体检结果(如零工具角色的可读说明)
@@ -688,6 +736,35 @@ export async function apply(ctx) {
688
736
  return
689
737
  }
690
738
 
739
+ if (route === 'config/backups' && req.method === 'GET') {
740
+ // 备份清单(新→旧):{ file, mtimeMs, size }[],无备份返回空数组。
741
+ // 纯读不写;file 恒为 basename,客户端回传它即够(还原路由只认配置
742
+ // 目录内的备份名)。
743
+ json(res, 200, { ok: true, backups: listConfigBackups() })
744
+ return
745
+ }
746
+
747
+ if (route === 'config/restore' && req.method === 'POST') {
748
+ // 回滚配置:{ file } 须精确匹配「配置文件名.bak-1/2/3」。与 apply /
749
+ // import 共用同一把 in-flight 闸(写盘路径防并发交错)。核心函数先
750
+ // loadConfig 验证备份可解析(坏备份 422 / 备份不存在 404,statusCode
751
+ // 由外层 catch 回 { error } 信封,不写盘),再把「当前配置」按同款
752
+ // 轮换推入备份链后拷贝备份为当前;mtime 变化令 state 缓存按既有对账
753
+ // 自动失效,无需手动清理。
754
+ const body = await readBody(req)
755
+ if (applyInFlight) {
756
+ json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
757
+ return
758
+ }
759
+ applyInFlight = true
760
+ try {
761
+ json(res, 200, await restoreConfigBackup(typeof body.file === 'string' ? body.file : ''))
762
+ } finally {
763
+ applyInFlight = false
764
+ }
765
+ return
766
+ }
767
+
691
768
  if (route === 'version' && req.method === 'GET') {
692
769
  // 版本提示:npm registry 优先,npmmirror / GitHub 依次兜底(version.mjs,
693
770
  // 带 1 小时内存缓存,命中不发外网请求)。绝不 5xx——检测失败也回