dsh-paoding 0.4.6 → 0.4.8

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.
@@ -29,6 +29,16 @@
29
29
  * 志带上一轮化身的历史,那段 turn 天然平衡;继承切点打在轮次边界(实测 73 处
30
30
  * mid 位 end-seed 前一事件以 turn/end 为主),owned 段计数不受污染。时长与
31
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')不回落。
32
42
  */
33
43
 
34
44
  import { readdirSync, statSync } from 'node:fs'
@@ -41,6 +51,9 @@ import { ROLE_PRESET_ID_PREFIX } from '../../tools/lib/compose.mjs'
41
51
  export const STATS_DEFAULT_DAYS = 30
42
52
  export const STATS_MAX_DAYS = 365
43
53
 
54
+ /** mtime 预过滤松弛量(1h):挡时钟偏斜与落盘延迟——宁可多开几个句柄,不误跳。 */
55
+ const MTIME_SLACK_MS = 60 * 60 * 1000
56
+
44
57
  // ── 参数清洗 ─────────────────────────────────────────────────────────────────
45
58
 
46
59
  /** days 清洗:parseInt 夹取 [1, 365];NaN / 负数 / 0 回落缺省或夹到下限。 */
@@ -140,6 +153,15 @@ function listSessionIds(sessionsRoot, slug) {
140
153
  }
141
154
  }
142
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
+
143
165
  // ── 冷读摘要 ─────────────────────────────────────────────────────────────────
144
166
 
145
167
  /** 一个会话的抽摘要结果(JSON-safe 纯数据,可进缓存)。 */
@@ -279,35 +301,19 @@ async function mapLimited(items, limit, worker) {
279
301
 
280
302
  const summaryCache = new Map()
281
303
 
304
+ // ── 扫描 + 聚合(一轮冷读,缺省 / 回落两路共用)─────────────────────────────
305
+
282
306
  /**
283
- * 委派效果统计主入口。ctx 缺 sessionPersistence(reflect 软探测,绝不进
284
- * inject——硬注入会让缺服务的老宿主挂载即炸)→ { ok:false,
285
- * reason:'persistence-unavailable' },面板据此整卡不渲染。project 非法抛
286
- * 422(外层 catch 统一回 { error } 信封)。
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(目录确实扫过)。
287
315
  */
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]
316
+ async function scanAndAggregate(sessionsRoot, slugs, windowDays, nowMs, persistence) {
311
317
  const targets = []
312
318
  for (const dir of slugs) {
313
319
  for (const id of listSessionIds(sessionsRoot, dir)) {
@@ -315,12 +321,14 @@ export async function collectDelegationStats(ctx, { project, days } = {}) {
315
321
  }
316
322
  }
317
323
 
318
- const nowMs = Date.now()
319
324
  const reads = await mapLimited(targets, 4, async ({ slug: dir, id }) => {
320
325
  const sessionDir = path.join(sessionsRoot, dir, id)
321
326
  const file = currentSessionFile(sessionDir)
322
327
  if (file === null) throw new Error('no session log file')
323
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
+ }
324
332
  const cacheKey = dir + '\u0000' + id
325
333
  const hit = summaryCache.get(cacheKey)
326
334
  if (hit && hit.file === file && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
@@ -334,20 +342,71 @@ export async function collectDelegationStats(ctx, { project, days } = {}) {
334
342
  let skipped = 0
335
343
  const summaries = []
336
344
  for (const r of reads) {
337
- if (r.ok) summaries.push(r.value.summary)
338
- else skipped++
345
+ if (!r.ok) {
346
+ skipped++ // 只有 open/read 真失败才计;预过滤出局不是错
347
+ continue
348
+ }
349
+ if (r.value.summary !== null) summaries.push(r.value.summary)
339
350
  }
340
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) {
341
357
  return {
342
358
  ok: true,
343
- project: slug,
359
+ project,
344
360
  windowDays,
345
361
  generatedAt: new Date(nowMs).toISOString(),
346
- roles,
347
- sessionsScanned: targets.length,
348
- delegatedSessions,
349
- skipped,
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)
350
408
  }
409
+ return statsEnvelope(slug, result, windowDays, nowMs)
351
410
  }
352
411
 
353
412
  /** reflect 软探测(与 persistenceAvailableOf 同款口径,任何异常按缺失处理)。 */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * dsh-paoding.config.yml 键规范化与读写:main_agent_display_name /
3
- * roles.<toolName>.name /
3
+ * patch_layer(声明行落盘层 'home' | 'profile',缺省自动判定,非法值 warn 回
4
+ * 未设置)/ roles.<toolName>.name /
4
5
  * roles.<toolName>.model / roles.<toolName>.provider(角色专用模型,生成层映射到
5
6
  * dsh-tool-subagent 的 agentOptions)/ roles.<toolName>.start_mode(角色启动方式,
6
7
  * 生成层把内置委派块的块级 provider 行改写为 fork,'fresh' | 'fork')/
@@ -212,11 +213,26 @@ export function normalizeConfig(data) {
212
213
  }
213
214
  return {
214
215
  profile: typeof data.profile === 'string' && data.profile !== '' ? data.profile : null,
216
+ patch_layer: normalizePatchLayer(data.patch_layer),
215
217
  ...normalizeTargetFields(data),
216
218
  workspaces: normalizeWorkspaces(data.workspaces),
217
219
  }
218
220
  }
219
221
 
222
+ /**
223
+ * 声明行落盘层(顶层键 patch_layer,仅 'home' | 'profile' 两个合法值):缺省 /
224
+ * null = 按环境自动判定(Android compat 壳 / 容器路径走 profile 层,其余 home,
225
+ * 判定在 preset-system.resolvePatchTargetLayer);非法值(类型或取值不对)warn
226
+ * 后当未设置——配置错了降级提醒而非中断加载,与 background_mode / start_mode
227
+ * 的非法值口径一致。normalize 后恒为 'home' | 'profile' | null。
228
+ */
229
+ function normalizePatchLayer(value) {
230
+ if (value === null || value === undefined || value === '') return null
231
+ if (value === 'home' || value === 'profile') return value
232
+ warn(`patch_layer: 非法值已忽略(${JSON.stringify(value)}),按自动判定(可选: 'home' / 'profile')`)
233
+ return null
234
+ }
235
+
220
236
  /**
221
237
  * 工作区条目段(顶层键 workspaces)规范化:只收 trim 后非空的字符串键(键 =
222
238
  * 工作区目录绝对路径),值过 normalizeTargetFields;值不是映射、键为空串则
@@ -606,6 +622,12 @@ function writeWorkspaces(lines, workspaces) {
606
622
  export function serializeConfig(config) {
607
623
  const lines = ['# dsh-paoding(庖丁)安装配置 —— 可手编;改后在侧栏底部「庖丁配置」入口应用,或重跑 node tools/install.mjs --auto']
608
624
  lines.push(`profile: ${config.profile ?? 'web'}`)
625
+ // patch_layer 显式钉层才落盘(null/缺省 = 自动判定,输出与旧版逐字节一致);
626
+ // 落盘它才能在 loadConfig → saveConfig 往返中保住用户手编的层钉(否则面板
627
+ // 一保存该键就被抹掉,层判定退回自动)。
628
+ if (config.patch_layer === 'home' || config.patch_layer === 'profile') {
629
+ lines.push(`patch_layer: ${config.patch_layer}`)
630
+ }
609
631
  writeTargetFields(lines, '', config)
610
632
  // workspaces 段可选:键缺失 / 空对象整段省略(不含 workspaces 的配置输出与
611
633
  // 旧版逐字节一致)。
@@ -2,7 +2,8 @@
2
2
  * `dsh-paoding doctor` 的检查本体:只读体检,不动任何盘。与 health.mjs 的分工
3
3
  * ——healthCheck 管「这次保存/应用会怎样」(面向 preview / apply 响应与
4
4
  * --dry-run 报告的单次配置体检);本模块管「这台机器装得正不正」(配置文件、
5
- * 预设安装轨道、托管声明行、孤儿工作区预设目录、版本标记、预设漂移六项环境
5
+ * 预设安装轨道、托管声明行(home + profile 层双层:托管块损坏 / 整文件解析 /
6
+ * 可疑坏行 / 行对账 / 层漂移)、孤儿工作区预设目录、版本标记、预设漂移六项环境
6
7
  * 对账),结果形状是人读导向的 { items: [{ status, label, detail }] }:
7
8
  * status: 'ok' | 'warn' | 'error' | 'skip'(skip = 前置事实缺失,降级不炸——
8
9
  * 无 DSH_HOME / 无配置的干净机器上各项依此跳过)。
@@ -30,11 +31,14 @@ import {
30
31
  detectAgentRolesFlavor,
31
32
  detectHostVersionInfo,
32
33
  detectPresetSystem,
34
+ findSuspectPresetRows,
33
35
  isDeclarativeTriple,
34
36
  isPtcWorkerHost,
35
37
  parseGeneratorMarker,
36
38
  presetRolesFlavorOf,
37
39
  readHomePatchRows,
40
+ resolvePatchTargetLayer,
41
+ validatePatchYaml,
38
42
  } from './preset-system.mjs'
39
43
  import { healthCheck } from './health.mjs'
40
44
 
@@ -117,43 +121,99 @@ export async function runDoctor({ dshHome, configFile, profile = null, state = n
117
121
  }
118
122
  }
119
123
 
120
- // ── 3. 声明行托管块(~/.dsh/cordis.patch.yml)────────────────────────────
121
- // readHomePatchRows 对损坏块自捕返 [],读不出「损坏」——这里直接扫标记行补一道:
122
- // 有 BEGIN 没 END = 托管块损坏,所有托管写盘都会被拒绝。
123
- let patchText = null
124
- try {
125
- patchText = readFileSync(path.join(dshHome, 'cordis.patch.yml'), 'utf8')
126
- } catch (err) {
127
- if (err?.code === 'ENOENT') add('skip', '声明行托管块', 'cordis.patch.yml 不存在(尚无声明行;目录轨宿主也不需要)')
128
- else add('skip', '声明行托管块', `cordis.patch.yml 读取失败(${err?.message ?? err}),跳过该项`)
129
- }
130
- if (patchText !== null) {
124
+ // ── 3. 声明行托管块(home + 目标 profile 层 patch 双层对账)───────────────
125
+ // 层判定与写盘侧同一条链(resolvePatchTargetLayer:config 可用时按
126
+ // patch_layer / profile 键,否则按环境自动——compat 壳 → profile 层)。
127
+ // 每层四查:托管块损坏(error,既有口径)→ 整文件 YAML 解析(error:compat
128
+ // 壳压平的坏行会让宿主在任何插件代码运行前死掉,dsh-maoying#1)→ 可疑坏行
129
+ // (warn,只点名行号不删)→ 行对账(目录轨残留 / 孤儿行,既有口径)。最后
130
+ // 层漂移:托管块只落在非目标层 → warn(下次自愈 / 保存会写目标层并撤遗留)。
131
+ const layerTarget = resolvePatchTargetLayer(dshHome, { config })
132
+ const layerEntries = [
133
+ { layer: 'home', profileName: null, patchFile: path.join(dshHome, 'cordis.patch.yml') },
134
+ { layer: 'profile', profileName: layerTarget.profileName, patchFile: layerTarget.patchPath },
135
+ ]
136
+ const seenPatchFiles = new Set()
137
+ let blockLayerSeen = null
138
+ for (const entry of layerEntries) {
139
+ if (seenPatchFiles.has(entry.patchFile)) continue // 目标 profile 恰为 'web' 时与 home 同文件,不双报
140
+ seenPatchFiles.add(entry.patchFile)
141
+ let patchText = null
142
+ try {
143
+ patchText = readFileSync(entry.patchFile, 'utf8')
144
+ } catch (err) {
145
+ // home 层缺失维持既有 skip 口径(干净机器各项依此跳过);profile 层缺失
146
+ // 是常态(非 compat 壳 / 从未迁移过),静默。
147
+ if (entry.layer === 'home' && err?.code === 'ENOENT') {
148
+ add('skip', '声明行托管块', 'cordis.patch.yml 不存在(尚无声明行;目录轨宿主也不需要)')
149
+ } else if (err?.code !== 'ENOENT') {
150
+ add('skip', '声明行托管块', `${entry.patchFile} 读取失败(${err?.message ?? err}),跳过该层`)
151
+ }
152
+ continue
153
+ }
154
+ const layerName = entry.layer === 'home' ? '' : `(profile 层 ${entry.patchFile})`
131
155
  const markers = managedMarkerLines(patchText)
132
156
  const hasBegin = markers.has(MANAGED_BEGIN)
133
157
  const hasEnd = markers.has(MANAGED_END)
158
+ if (hasBegin) blockLayerSeen = blockLayerSeen ?? entry.layer
134
159
  if (hasBegin && !hasEnd) {
135
- add('error', '声明行托管块', '托管块损坏(只有起始标记没有结束标记):补回结束标记或删除整块,否则面板保存 / 自愈都会拒绝写盘')
136
- } else {
160
+ add('error', '声明行托管块', `${layerName}托管块损坏(只有起始标记没有结束标记):补回结束标记或删除整块,否则面板保存 / 自愈都会拒绝写盘`)
161
+ }
162
+ // 整文件 YAML 解析(宿主 yaml 包口径,与写盘校验 validatePatchYaml 同源;
163
+ // yaml 包缺失退内置子集解析器——lenient,只兜 gross 损坏挡不住坏缩进)。
164
+ const parseVerdict = validatePatchYaml(dshHome, patchText)
165
+ if (!parseVerdict.ok) {
166
+ add(
167
+ 'error',
168
+ 'patch 文件解析',
169
+ `${entry.patchFile} 无法解析:${parseVerdict.message}——宿主启动会直接失败。请删除坏行或从 .bak-1 备份恢复` +
170
+ '(Android compat 壳重写 patch 压平坏行的特征参见 dsh-maoying#1)',
171
+ )
172
+ }
173
+ // 可疑坏行(maoying 坏行形状):warn 点名行号,只诊断不删——删行可能踩
174
+ // 用户手写内容,修复交给人或清理侧的行级手术。
175
+ const suspects = findSuspectPresetRows(patchText)
176
+ if (suspects.length > 0) {
177
+ const lineNos = suspects.map((s) => s.lineNo).join('、')
178
+ add(
179
+ 'warn',
180
+ '声明行可疑行',
181
+ `${entry.patchFile} 第 ${lineNos} 行检出可疑声明行(如 "- id: ${suspects[0].id}"——id 含空白或非 ASCII,` +
182
+ '形如 compat 壳把显示名压平进 id 位产生的坏行,宿主 YAML 解析会失败,参见 dsh-maoying#1)' +
183
+ '——请删除坏行或从 .bak-1 备份恢复后重试',
184
+ )
185
+ }
186
+ // 行对账(损坏块时 readHomePatchRows 自捕返空数组——损坏已单独报,这里
187
+ // 只在块结构完好时做逐行对账,避免损坏块上报出误导性的「无声明行」)。
188
+ if (!hasBegin || hasEnd) {
137
189
  let rows = []
138
190
  try {
139
- rows = readHomePatchRows(dshHome)
191
+ rows = readHomePatchRows(dshHome, { layer: entry.layer, profileName: entry.profileName ?? 'web' })
140
192
  } catch {
141
193
  rows = []
142
194
  }
143
195
  if (rows.length === 0) {
144
- add('ok', '声明行托管块', hasBegin ? '托管块存在但无声明行' : 'cordis.patch.yml 无托管块(目录轨宿主无需声明行)')
196
+ add('ok', '声明行托管块', hasBegin ? `${layerName}托管块存在但无声明行` : `${entry.layer === 'home' ? '' : `${entry.patchFile} `}无托管块(目录轨宿主无需声明行)`)
145
197
  } else {
146
198
  const orphans = rows.filter((row) => row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId)))
147
199
  if (track === 'directory') {
148
- add('warn', '声明行托管块', `检出 ${rows.length} 条声明行,但宿主是目录轨(≤ 0.1.6)——残留声明行会让 profile 启动失败,重启 DSH 触发自愈撤下`)
200
+ add('warn', '声明行托管块', `${layerName}检出 ${rows.length} 条声明行,但宿主是目录轨(≤ 0.1.6)——残留声明行会让 profile 启动失败,重启 DSH 触发自愈撤下`)
149
201
  } else if (orphans.length > 0) {
150
- add('warn', '声明行托管块', `孤儿声明行(指向已删目录):${orphans.map((o) => o.rowId).join('、')}——重启 DSH 自愈会撤下,或重新应用一次同步`)
202
+ add('warn', '声明行托管块', `${layerName}孤儿声明行(指向已删目录):${orphans.map((o) => o.rowId).join('、')}——重启 DSH 自愈会撤下,或重新应用一次同步`)
151
203
  } else {
152
- add('ok', '声明行托管块', `${rows.length} 条声明行均指向存在的预设目录`)
204
+ add('ok', '声明行托管块', `${layerName}${rows.length} 条声明行均指向存在的预设目录`)
153
205
  }
154
206
  }
155
207
  }
156
208
  }
209
+ if (blockLayerSeen !== null && blockLayerSeen !== layerTarget.layer) {
210
+ add(
211
+ 'warn',
212
+ 'patch 层漂移',
213
+ `声明行托管块在 ${blockLayerSeen} 层,当前判定目标层是 ${layerTarget.layer}(${layerTarget.patchPath})` +
214
+ '——下次自愈 / 保存会写目标层并撤下这层的遗留块(compat 壳迁移行为,参见 dsh-maoying#1)',
215
+ )
216
+ }
157
217
 
158
218
  // ── 4. 孤儿工作区预设目录(.agent-presets/orchestrator-*)─────────────────
159
219
  if (!configUsable && existsSync(configFile)) {
@@ -5,9 +5,11 @@
5
5
  * 体检同源(孤儿目录 = workspacePresetOrphans,孤儿声明行 = presetId 指向的
6
6
  * 目录已不存在),本模块把「怎么删」收敛在一处,两个命令只决定「删哪些」。
7
7
  *
8
- * 备份纪律:一次清理调用对 cordis.patch.yml 至多备份一份 .bak-1(首次写盘前
9
- * 拷贝,已有同名备份覆盖——只保「本次改动前」的原貌),同一次调用里的后续写
10
- * 不再覆盖这份首备。配置文件 dsh-paoding.config.yml 是用户数据,只有 uninstall
8
+ * 备份纪律:一次清理调用对每个 patch 文件(home 层 cordis.patch.yml 与各
9
+ * profile 层 profiles/<name>/cordis.patch.yml——compat 壳把托管块搬进 profile
10
+ * 层,见 managedBlockStateAll)至多备份一份 .bak-1(首次写盘前拷贝,已有同名
11
+ * 备份覆盖——只保「本次改动前」的原貌),同一次调用里对同一文件的后续写不再
12
+ * 覆盖这份首备。配置文件 dsh-paoding.config.yml 是用户数据,只有 uninstall
11
13
  * --purge 才动,删除前列在计划里。
12
14
  *
13
15
  * 依赖 preset-system(托管块手术与轨道原语)/ workspaces(slug 名册)/
@@ -69,6 +71,38 @@ export function managedBlockState(dshHome) {
69
71
  return { present, corrupt: present && !marks.has(MANAGED_END) }
70
72
  }
71
73
 
74
+ /**
75
+ * 全层托管块在场状态(managedBlockState 的双层版;managedBlockState 本身保持
76
+ * 只报 home 层的旧口径不动):home 层恒在列表首位(文件缺失 present=false),
77
+ * 其后是 profiles/<name>/cordis.patch.yml 里真实存在的每一层。每项
78
+ * { layer: 'home'|'profile', profileName: null|<name>, patchFile, present,
79
+ * corrupt }。compat 壳把托管块搬进 profile 层后,uninstall / doctor --fix
80
+ * 必须两层都过一遍,否则卸载会在 home 层空转、profile 层残留整块声明行。
81
+ */
82
+ export function managedBlockStateAll(dshHome) {
83
+ const entries = [{ layer: 'home', profileName: null, patchFile: path.join(dshHome, 'cordis.patch.yml') }]
84
+ try {
85
+ for (const name of readdirSync(path.join(dshHome, 'profiles')).sort()) {
86
+ const patchFile = path.join(dshHome, 'profiles', name, 'cordis.patch.yml')
87
+ if (existsSync(patchFile)) entries.push({ layer: 'profile', profileName: name, patchFile })
88
+ }
89
+ } catch { /* 无 profiles 目录:只有 home 层 */ }
90
+ return entries.map((entry) => {
91
+ let text = null
92
+ try {
93
+ text = readFileSync(entry.patchFile, 'utf8')
94
+ } catch {
95
+ return { ...entry, present: false, corrupt: false }
96
+ }
97
+ const marks = new Set(String(text).split(/\r?\n/).map((l) => l.trim()))
98
+ const present = marks.has(MANAGED_BEGIN)
99
+ return { ...entry, present, corrupt: present && !marks.has(MANAGED_END) }
100
+ })
101
+ }
102
+
103
+ /** managedBlockStateAll 条目 → preset-system 手术 opts(home 层恒 home 口径)。 */
104
+ const layerOptsOf = (entry) => ({ layer: entry.layer, profileName: entry.profileName ?? 'web' })
105
+
72
106
  /**
73
107
  * 配置文件对账读法(doctor 配置文件项同一判据):缺失返回 { config: null,
74
108
  * exists: false, usable: false };存在但解析失败 → { exists: true, usable:
@@ -106,10 +140,22 @@ export function workspacePresetOrphans(dshHome, config) {
106
140
  * 不存在的 paoding-config-ui 的声明行)——判据只认「目录没了」,不认行名。
107
141
  * presetId 解析不出的行不在此列(doctor 不点名它,清理侧也不动它,交给用户)。
108
142
  * 托管块损坏时返回空数组(损坏块只能整块手工处理,行级手术做不了)。
143
+ * 双层扫描:home 与各 profile 层的孤儿行并集(rowId 去重——同一 preset 的行
144
+ * 理论上只在一层,两层都在场时清理侧会逐层各撤一遍)。
109
145
  */
110
146
  export function orphanManagedRows(dshHome) {
111
- const rows = readHomePatchRows(dshHome)
112
- return rows.filter((row) => row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId)))
147
+ const seen = new Set()
148
+ const out = []
149
+ for (const entry of managedBlockStateAll(dshHome)) {
150
+ if (!entry.present || entry.corrupt) continue
151
+ for (const row of readHomePatchRows(dshHome, layerOptsOf(entry))) {
152
+ const key = `${entry.layer}:${row.rowId}`
153
+ if (seen.has(key)) continue
154
+ seen.add(key)
155
+ if (row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId))) out.push(row)
156
+ }
157
+ }
158
+ return out
113
159
  }
114
160
 
115
161
  /**
@@ -121,13 +167,17 @@ export function orphanManagedRows(dshHome) {
121
167
  * generateAndInstall 的孤儿回收口径一致)
122
168
  * - 删完目录后 .generator-version 若仍在(理论上随基础目录已走,防御性兜底)
123
169
  * 一并删除并在结果里注明
124
- * 备份纪律见模块头:对 cordis.patch.yml 的首次写前备份 .bak-1,之后不再覆盖。
125
- * 返回结构化结果(字段见实现),全程不 throw——单步失败折进对应字段,由调用
126
- * 方决定退出码与文案。
170
+ * 备份纪律见模块头:对每个 patch 文件(home / 各 profile 层)的首次写前备份
171
+ * .bak-1,之后不再覆盖;backupPath 兼容旧字段(首个备份),backupPaths 列全。
172
+ * 托管块手术双层循环(managedBlockStateAll:compat 壳把块搬进 profile 层后,
173
+ * 只清 home 层的旧卸载会空转),损坏块不碰:preset-system 会拒绝写盘,降级
174
+ * 给调用方报需手工。返回结构化结果(字段见实现),全程不 throw——单步失败
175
+ * 折进对应字段,由调用方决定退出码与文案。
127
176
  */
128
177
  export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowIds = [], removeDirIds = [] } = {}) {
129
178
  const result = {
130
- backupPath: null,
179
+ backupPath: null, // 旧字段:首个写前备份(向后兼容 cli 打印)
180
+ backupPaths: [], // 本次实际落下的全部备份(每层 patch 至多一份)
131
181
  blockPresent: false,
132
182
  blockCorrupt: false,
133
183
  blockRemoved: false,
@@ -137,35 +187,43 @@ export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowI
137
187
  dirFailures: [],
138
188
  }
139
189
 
140
- const block = managedBlockState(dshHome)
141
- result.blockPresent = block.present
142
- result.blockCorrupt = block.corrupt
190
+ const layers = managedBlockStateAll(dshHome)
191
+ result.blockPresent = layers.some((l) => l.present)
192
+ result.blockCorrupt = layers.filter((l) => l.present).some((l) => l.corrupt)
193
+ const prunedSeen = new Set()
143
194
 
144
- let backupDone = false
145
- const backupOnce = () => {
146
- if (backupDone) return
147
- backupDone = true
148
- result.backupPath = backupBeforeWrite(path.join(dshHome, 'cordis.patch.yml'))
195
+ const backupDone = new Set()
196
+ const backupOnce = (patchFile) => {
197
+ if (backupDone.has(patchFile)) return
198
+ backupDone.add(patchFile)
199
+ const backupPath = backupBeforeWrite(patchFile)
200
+ if (backupPath !== null) {
201
+ result.backupPaths.push(backupPath)
202
+ if (result.backupPath === null) result.backupPath = backupPath
203
+ }
149
204
  }
150
205
 
151
- // 1) 托管块手术(损坏块不碰:preset-system 会拒绝写盘,降级给调用方报需手工)
152
- if (block.present && !block.corrupt) {
153
- const rowsOnDisk = readHomePatchRows(dshHome).map((r) => r.rowId)
206
+ // 1) 托管块手术(逐层;损坏块不碰:preset-system 会拒绝写盘,降级给调用方报需手工)
207
+ for (const entry of layers.filter((l) => l.present && !l.corrupt)) {
208
+ const opts = layerOptsOf(entry)
209
+ const rowsOnDisk = readHomePatchRows(dshHome, opts).map((r) => r.rowId)
154
210
  if (stripBlock) {
155
- backupOnce()
156
- result.blockRemoved = stripHomePatchRows(dshHome, null)
211
+ backupOnce(entry.patchFile)
212
+ if (stripHomePatchRows(dshHome, null, opts)) result.blockRemoved = true
157
213
  } else if (pruneRowIds.length > 0) {
158
214
  const present = pruneRowIds.filter((id) => rowsOnDisk.includes(id))
159
215
  if (present.length > 0) {
160
- backupOnce()
216
+ backupOnce(entry.patchFile)
161
217
  // stripHomePatchRows 返回 true = 指定段确已撤下;块里本来就没有的 id
162
- // 不算撤(present 已过滤),不虚报。
163
- if (stripHomePatchRows(dshHome, present)) result.prunedRowIds = present
218
+ // 不算撤(present 已过滤),不虚报。同一 rowId 两层都在场时各撤一遍,
219
+ // 结果里只记一次(Set 去重)。
220
+ if (stripHomePatchRows(dshHome, present, opts)) for (const id of present) prunedSeen.add(id)
164
221
  }
165
222
  }
166
223
  }
224
+ result.prunedRowIds = [...prunedSeen]
167
225
 
168
- // 2) 预设目录删除 + 声明行同步(目录先删,行后撤——与孤儿回收同序)
226
+ // 2) 预设目录删除 + 声明行同步(目录先删,行后撤——与孤儿回收同序;行逐层清)
169
227
  if (removeDirIds.length > 0) {
170
228
  for (const id of removeDirIds) {
171
229
  const dir = path.join(dshHome, '.agent-presets', id)
@@ -177,12 +235,15 @@ export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowI
177
235
  }
178
236
  }
179
237
  const removedIds = new Set(result.removedDirs)
180
- const danglingRowIds = readHomePatchRows(dshHome)
181
- .filter((r) => r.presetId && removedIds.has(r.presetId))
182
- .map((r) => r.rowId)
183
- if (danglingRowIds.length > 0 && !result.blockCorrupt) {
184
- backupOnce()
185
- stripHomePatchRows(dshHome, danglingRowIds)
238
+ for (const entry of layers.filter((l) => l.present && !l.corrupt)) {
239
+ const opts = layerOptsOf(entry)
240
+ const danglingRowIds = readHomePatchRows(dshHome, opts)
241
+ .filter((r) => r.presetId && removedIds.has(r.presetId))
242
+ .map((r) => r.rowId)
243
+ if (danglingRowIds.length > 0) {
244
+ backupOnce(entry.patchFile)
245
+ stripHomePatchRows(dshHome, danglingRowIds, opts)
246
+ }
186
247
  }
187
248
  }
188
249
 
@@ -202,17 +263,25 @@ export function cleanupPaodingArtifacts(dshHome, { stripBlock = false, pruneRowI
202
263
  /**
203
264
  * 卸载计划(只读侦察,不落盘):给确认提示、非 TTY 防呆打印与「无事可做」
204
265
  * 判定共用。不加载配置文件内容(卸载不依赖配置语义),只看存在性。
266
+ * blockPresent / blockCorrupt / blockRowCount 聚合双层(compat 壳的 profile 层
267
+ * 托管块也算在场);patchFile 保留 home 层路径的旧字段口径,patchFiles 列出
268
+ * 各层实际在场托管块的文件(供调用方点名 profile 层残留)。
205
269
  */
206
270
  export function planUninstall({ dshHome, configFile } = {}) {
207
- const block = managedBlockState(dshHome)
271
+ const layers = managedBlockStateAll(dshHome)
208
272
  const markerPath = generatorMarkerPath(dshHome)
273
+ let blockRowCount = 0
274
+ for (const entry of layers.filter((l) => l.present && !l.corrupt)) {
275
+ blockRowCount += readHomePatchRows(dshHome, layerOptsOf(entry)).length
276
+ }
209
277
  return {
210
278
  dshHome,
211
279
  configFile,
212
280
  patchFile: path.join(dshHome, 'cordis.patch.yml'),
213
- blockPresent: block.present,
214
- blockCorrupt: block.corrupt,
215
- blockRowCount: readHomePatchRows(dshHome).length,
281
+ patchFiles: layers.filter((l) => l.present).map((l) => l.patchFile),
282
+ blockPresent: layers.some((l) => l.present),
283
+ blockCorrupt: layers.filter((l) => l.present).some((l) => l.corrupt),
284
+ blockRowCount,
216
285
  presetDirs: listOrchestratorPresetIds(dshHome),
217
286
  markerPath,
218
287
  markerExists: existsSync(markerPath),