dsh-paoding 0.4.2 → 0.4.4

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,22 +8,28 @@
8
8
  */
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
- import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, 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 { normalizeConfig, normalizeTargetFields, saveConfig, serializeConfig } from '../../tools/lib/config.mjs'
15
+ import { loadConfig, normalizeConfig, normalizeTargetFields, saveConfig, serializeConfig, stripNonTargetKeys } from '../../tools/lib/config.mjs'
16
16
  import { parseYamlSubset, preprocessPatchText } from '../../tools/lib/yaml.mjs'
17
17
  import { captureWarnings, healthCheck } from '../../tools/lib/health.mjs'
18
+ import { warn } from '../../tools/lib/util.mjs'
18
19
  import {
19
20
  backfillHomePatchRows,
20
- detectHostVersionTriple,
21
+ detectAgentRolesFlavor,
22
+ detectHostVersionInfo,
21
23
  detectPresetSystem,
22
24
  formatGeneratorMarker,
23
25
  isPtcWorkerHost,
24
26
  listOrchestratorPresetIds,
25
27
  parseGeneratorMarker,
28
+ presetRolesFlavorOf,
29
+ presetRowId,
26
30
  readHomePatchRows,
31
+ stripHomePatchRows,
32
+ ROLES_AGENT_PRESET,
27
33
  WORKER_PTC,
28
34
  WORKER_WORKER_THREAD,
29
35
  } from '../../tools/lib/preset-system.mjs'
@@ -33,6 +39,12 @@ function workerFlavorOf(hostVersionTriple) {
33
39
  return isPtcWorkerHost(hostVersionTriple) ? WORKER_PTC : WORKER_WORKER_THREAD
34
40
  }
35
41
 
42
+ /** 宿主版本信息 → 角色委派行标记行(生成产物行的形态,与 compose 对齐;
43
+ * prerelease 透传——0.2.1 守卫 0.2.1-rc.2 落地,rc.0/rc.1 按内联形态对账)。 */
44
+ function rolesFlavorOf(hostVersionTriple, prerelease) {
45
+ return presetRolesFlavorOf(hostVersionTriple, prerelease)
46
+ }
47
+
36
48
  /**
37
49
  * 标记 worker 行与当前宿主形态是否兼容:只有「产物形态」与「宿主所需形态」在
38
50
  * ptc/worker-thread 上分叉(其中一边是 ptc)才算换代——null(v2/v1 旧标记)与
@@ -44,6 +56,16 @@ function workerFlavorCompatible(markerWorker, currentWorker) {
44
56
  return (markerWorker === WORKER_PTC) === (currentWorker === WORKER_PTC)
45
57
  }
46
58
 
59
+ /**
60
+ * 标记角色行与当前宿主形态是否兼容(workerFlavorCompatible 同款语义):分叉 =
61
+ * 其中一边是 agent-preset;null(v3/v2 旧标记)与 inline 同侧——0.2.1 宿主上
62
+ * 旧插件版本的 inline 产物重生成也产不出新形状,重生成无意义;插件升级后版本
63
+ * 不符走整盘重生成,新形状经那条路径落地。
64
+ */
65
+ function rolesFlavorCompatible(markerRoles, currentRoles) {
66
+ return (markerRoles === ROLES_AGENT_PRESET) === (currentRoles === ROLES_AGENT_PRESET)
67
+ }
68
+
47
69
  export function resolveDshHome() {
48
70
  return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
49
71
  }
@@ -270,15 +292,143 @@ export async function importConfigContent(content) {
270
292
  return { ok: true }
271
293
  }
272
294
 
295
+ // ── 预设 CRUD(presets 路由族的服务端核心)──────────────────────────────────
296
+ //
297
+ // 面板 apply 只覆盖「全局 / 单个工作区视角的生成」,这里补齐预设对象本身的
298
+ // 生命周期:列举(GET)、新增 / 更新(POST/PUT,复用 installAssignments)、
299
+ // 删除(DELETE,配置条目 + preset 目录 + 声明行三位一体清理)。全局
300
+ // orchestrator 不在删除之列——插件启动自愈(ensurePresetInstalled)会在下次
301
+ // 启动把它装回来,删除语义不成立,路由层直接 403。
302
+
303
+ /**
304
+ * 预设清单组装(GET presets):输入 serializeState 后的只读视图,输出响应的
305
+ * presets 数组。全局 orchestrator 恒在首位(assignments = existing 顶层剥掉
306
+ * profile / workspaces 两个容器键后的目标字段;existing 缺失 = 尚无配置文件,
307
+ * assignments 也为 null),其后按配置 workspaces 段的键序逐个工作区补项。
308
+ */
309
+ export function listPresets(view) {
310
+ const existing = view?.existing ?? null
311
+ const installedPresets = Array.isArray(view?.installedPresets) ? view.installedPresets : []
312
+ const presets = [{
313
+ presetId: 'orchestrator',
314
+ scope: 'global',
315
+ workspacePath: null,
316
+ installed: installedPresets.includes('orchestrator'),
317
+ assignments: existing ? stripNonTargetKeys(existing) : null,
318
+ }]
319
+ const workspaces = existing?.workspaces && typeof existing.workspaces === 'object' && !Array.isArray(existing.workspaces)
320
+ ? existing.workspaces
321
+ : {}
322
+ const metaOf = view?.workspaceMeta ?? {}
323
+ // workspaceMeta 缺项兜底(理论不会走到——workspaceMeta 由同一份 existing 派生,
324
+ // 只防老版本状态/外部构造视图漏了键):整组键现算一次 assignSlugs,口径与
325
+ // serializeState / 生成层一致,避免逐项单算丢掉同 basename 的 hash 后缀。
326
+ let fallbackSlugs = null
327
+ for (const [wsPath, assignments] of Object.entries(workspaces)) {
328
+ let meta = metaOf[wsPath] ?? null
329
+ if (!meta) {
330
+ fallbackSlugs ??= assignSlugs(Object.keys(workspaces))
331
+ const slug = fallbackSlugs.get(wsPath)
332
+ const presetId = presetIdOf(slug)
333
+ meta = { slug, presetId, installed: installedPresets.includes(presetId) }
334
+ }
335
+ presets.push({
336
+ presetId: meta.presetId,
337
+ scope: 'workspace',
338
+ workspacePath: wsPath,
339
+ installed: !!meta.installed,
340
+ assignments,
341
+ })
342
+ }
343
+ return presets
344
+ }
345
+
346
+ /**
347
+ * presetId(orchestrator-<slug>)→ 工作区绝对路径反查(PUT/DELETE presets/<id>):
348
+ * 对 existing.workspaces 的键集整组 assignSlugs(与生成层 / serializeState 同源,
349
+ * 同 basename 冲突的 hash 后缀才能对得上),再按 presetIdOf 逐一比对。找不到
350
+ * (id 未注册、键集为空、全局 orchestrator 之外未知形态)返回 null,由路由层
351
+ * 回 404。
352
+ */
353
+ export function resolveWorkspacePathByPresetId(existing, presetId) {
354
+ const slugs = assignSlugs(Object.keys((existing && existing.workspaces) || {}))
355
+ for (const [wsPath, slug] of slugs) {
356
+ if (presetIdOf(slug) === presetId) return wsPath
357
+ }
358
+ return null
359
+ }
360
+
361
+ /**
362
+ * 删除工作区预设(DELETE presets/<id>):配置条目、preset 目录、声明行三位
363
+ * 一体清理。state 传原始 collectState state(拿 yamlMod / dshHome / configFile,
364
+ * 绝不能喂 serializeState 的 JSON 视图——loadConfig 需要宿主 yaml 口径)。
365
+ *
366
+ * 步骤与护栏:
367
+ * 1. 现配置读取(破坏性落盘护栏):读失败硬中止——「去条目 + 整文件覆写」
368
+ * 不允许对着不可信状态动刀,文案与 generateAndInstall 同款;
369
+ * 2. workspaces 段去掉该键后经 saveConfig 整体落盘(键已被外部移除也继续
370
+ * 后续清理,幂等);配置文件缺失(loadConfig 回 null)时没有条目可去,
371
+ * 跳过落盘——不为一次删除凭空物化配置文件;
372
+ * 3. slug 按「删除前键集 ∪ 本路径」分配:键在时恰为删除前键集,与
373
+ * generateAndInstall 落盘时的键集(磁盘 ∪ 本路径)同口径,保证算出的
374
+ * presetId 与当初产物目录名一致;键不在时同口径现算,纯兜底清理;
375
+ * 4. preset 目录存在才 rmSync;声明行经 stripHomePatchRows 撤除——该函数对
376
+ * 文件缺失 / 无托管块是安全 no-op(directory 轨宿主常态),无须预判轨道。
377
+ *
378
+ * 不动宿主 workspaceRegistry:注册表层与配置层解耦,DELETE 只清配置与预设产物
379
+ * (注册工作区是 POST /workspaces 的职责)。返回
380
+ * { presetId, removedConfigEntry, removedPresetDir };读配置失败仍按既有约定
381
+ * 抛错(无 statusCode,由路由层外层 catch 回 500)。
382
+ */
383
+ export function deleteWorkspacePreset(state, workspacePath) {
384
+ const { yamlMod, dshHome, configFile } = state
385
+ const wsPath = path.normalize(path.resolve(workspacePath))
386
+ let prev = null
387
+ try {
388
+ prev = loadConfig(configFile, yamlMod)
389
+ } catch (err) {
390
+ throw new Error(`现有配置 ${configFile} 读取失败,已中止(未做任何落盘): ${err.message}`)
391
+ }
392
+
393
+ const wsEntries = Object.keys(prev?.workspaces ?? {})
394
+ if (!wsEntries.includes(wsPath)) wsEntries.push(wsPath)
395
+ const presetId = presetIdOf(assignSlugs(wsEntries).get(wsPath))
396
+
397
+ let removedConfigEntry = false
398
+ if (prev !== null) {
399
+ const workspaces = { ...prev.workspaces }
400
+ removedConfigEntry = Object.prototype.hasOwnProperty.call(workspaces, wsPath)
401
+ delete workspaces[wsPath]
402
+ // 顶层字段随工作区保存的同一写盘口径重规范化(normalizeTargetFields),
403
+ // profile 维持磁盘现状(缺省 null 时由序列化器兜底,不引入运行时 profile)。
404
+ saveConfig(configFile, {
405
+ profile: prev.profile ?? null,
406
+ ...normalizeTargetFields(prev),
407
+ workspaces,
408
+ })
409
+ }
410
+
411
+ const presetDir = path.join(dshHome, '.agent-presets', presetId)
412
+ let removedPresetDir = false
413
+ if (existsSync(presetDir)) {
414
+ rmSync(presetDir, { recursive: true, force: true })
415
+ removedPresetDir = true
416
+ }
417
+ stripHomePatchRows(dshHome, [presetRowId(presetId)])
418
+
419
+ return { presetId, removedConfigEntry, removedPresetDir }
420
+ }
421
+
273
422
  // ── 首装 / 升级后 preset 自动重生成 ─────────────────────────────────────────
274
423
  //
275
424
  // 唯一官方安装通道是插件通道(dsh plugin add dsh-paoding),包不再携带 CLI:
276
425
  // preset 的落盘改由插件启动时自愈。对账基准是 preset 目录里的 .generator-version
277
- // 标记文件(v3 起内容 = 生成器版本 + 预设安装轨道 + workflow worker 形态三行):
278
- // 插件包版本变、或宿主轨道翻转 / worker 形态换代(只升 dsh 不动插件——升级最
279
- // 常见路径,旧标记缺行时同样解析为不符)→ 下次启动自动自愈,一举解决「升级后
280
- // preset 还是旧版产物」「升 dsh 后 preset 静默消失」与「升 dsh 后 worker 行
281
- // 失配 never started」三类漂移。
426
+ // 标记文件(v4 起内容 = 生成器版本 + 预设安装轨道 + workflow worker 形态 +
427
+ // 角色委派行形态四行):插件包版本变、或宿主轨道翻转 / worker 形态换代 / 角色
428
+ // 行换代(只升 dsh 不动插件——升级最常见路径,旧标记缺行时同样解析为不符)→
429
+ // 下次启动自动自愈,一举解决「升级后 preset 还是旧版产物」「升 dsh 后 preset
430
+ // 静默消失」「升 dsh 后 worker 行失配 never started」与「升 dsh 0.2.1 后内联
431
+ // persona/toolFilter 被 apply 拒绝、新建会话即抛错」四类漂移。
282
432
 
283
433
  // 生成器版本(标记对账基准):插件包根 package.json 读一次即缓存。读失败兜底
284
434
  // '0.0.0'——最坏后果只是每次启动多重生成一次 preset,不会装坏任何东西。
@@ -294,6 +444,45 @@ export function pluginVersion() {
294
444
  return cachedGeneratorVersion ?? '0.0.0'
295
445
  }
296
446
 
447
+ /**
448
+ * 工作区预设(orchestrator-<slug>)角色行形态迁移:基础 preset 整盘重生成后,
449
+ * 磁盘上各工作区产物的角色行形态若与宿主所需不符(典型:宿主刚升 0.2.1,工作区
450
+ * 产物还是旧内联形状——声明行又在场,挂该预设的新建会话照样被 tool-subagent
451
+ * 守卫拒绝),按该工作区在配置 workspaces 段里最后一次应用的条目就地重生成
452
+ *(顶层配置兜底合入,落盘不回写配置)。产物缺失(从未生成过)与形态判不出的
453
+ *(角色被清空)一律跳过——不自作主张替用户代生成;单个工作区失败只 warn,
454
+ * 绝不拖垮整体自愈。返回完成换代的工作区 presetId 列表。
455
+ */
456
+ function migrateWorkspacePresetRoles({ dshHome, state }) {
457
+ const wanted = presetRolesFlavorOf(state.hostVersionTriple, state.hostPrerelease)
458
+ const wsEntries = state.existing?.workspaces ?? {}
459
+ const wsPaths = Object.keys(wsEntries)
460
+ if (wsPaths.length === 0) return []
461
+ const slugs = assignSlugs(wsPaths)
462
+ const migrated = []
463
+ for (const wsPath of wsPaths) {
464
+ const presetId = presetIdOf(slugs.get(wsPath))
465
+ let flavor = null
466
+ try {
467
+ flavor = detectAgentRolesFlavor(readFileSync(path.join(dshHome, '.agent-presets', presetId, 'agent.cordis.yml'), 'utf8'))
468
+ } catch {
469
+ continue // 目录 / 产物缺失:该工作区从未生成过,保持沉默(生成是显式动作)
470
+ }
471
+ if (flavor === null || flavor === wanted) continue
472
+ try {
473
+ // 该工作区最后一次应用的条目优先,顶层字段兜底(条目缺键时回落全局值);
474
+ // saveConfigOnWrite=false——配置 workspaces 段原样保留,只换代产物。
475
+ const merged = { ...normalizeTargetFields(state.existing), ...normalizeTargetFields(wsEntries[wsPath]) }
476
+ const assignments = autoAssignments({ existing: merged, suggested: state.suggested, staticBase: state.staticBase })
477
+ generateAndInstall(state, assignments, { dryRun: false, saveConfigOnWrite: false, workspacePath: wsPath })
478
+ migrated.push(presetId)
479
+ } catch (err) {
480
+ warn(`工作区预设角色行换代失败(${presetId},可对该工作区重新「保存并应用」修复): ${err?.message ?? err}`)
481
+ }
482
+ }
483
+ return migrated
484
+ }
485
+
297
486
  // dispose 中止时的返回理由(记 reason 而非 error:不算失败,调用方不打日志)。
298
487
  const PRESET_ENSURE_ABORTED = '插件已卸载(dispose),本次 preset 生成中止'
299
488
 
@@ -302,9 +491,10 @@ const PRESET_ENSURE_ABORTED = '插件已卸载(dispose),本次 preset 生
302
491
  *
303
492
  * 判定(满足其一即动盘):
304
493
  * - preset 目录 / 标记缺失,或标记版本与 generatorVersion 不符 → 整盘重生成;
305
- * - 版本一致但标记 worker 形态与当前宿主所需分叉(宿主跨过 0.1.5/0.1.6
306
- * worker 边界;v2/v1 旧标记在真实 ptc 宿主上同样分叉)→ 整盘重生成(该行
307
- * 长在产物正文里,轻量路径覆盖不到);
494
+ * - 版本一致但标记 worker 形态或角色行形态与当前宿主所需分叉(宿主跨过
495
+ * 0.1.5/0.1.6 worker 边界或 0.2.0/0.2.1 角色行边界;旧格式标记在真实
496
+ * ptc/agent-preset 宿主上同样分叉)→ 整盘重生成(这些行长在产物正文里,
497
+ * 轻量路径覆盖不到);
308
498
  * - 版本 worker 都一致但标记轨道与当前宿主轨道不符(只升 dsh 不动插件 /
309
499
  * 旧格式标记)→ 轻量迁移:声明轨上按磁盘产物补写缺失声明行(内容字节
310
500
  * 不动,不重生成),directory 轨上落回整盘重生成(残留声明行会让 0.1.6
@@ -338,12 +528,14 @@ export async function ensurePresetInstalled({ dshHome = resolveDshHome(), genera
338
528
 
339
529
  if (marker !== null && marker.version === generatorVersion) {
340
530
  // 版本一致:内容原则上无需重生成,但宿主可能已随 dsh 升降级翻转轨道或
341
- // 跨过 workflow worker 形态边界(只升 dsh 不动插件是升级常态),声明行也
342
- // 可能被外部动过——这里做轻量对账。worker 形态不符(v2/v1 旧标记没有第
343
- // 三行,一律视为不符)不能走轻量路径:该行长在产物正文里,补行/撤行覆盖
344
- // 不到,必须整盘重生成——落到下方重生成分支。
345
- const currentWorker = workerFlavorOf(detectHostVersionTriple())
346
- if (workerFlavorCompatible(marker.worker, currentWorker)) {
531
+ // 跨过 workflow worker 形态 / 角色委派行形态边界(只升 dsh 不动插件是升级
532
+ // 常态),声明行也可能被外部动过——这里做轻量对账。worker 形态或角色行
533
+ // 形态不符(v3/v2/v1 旧标记缺行,一律视为不符)不能走轻量路径:这些行
534
+ // 长在产物正文里,补行/撤行覆盖不到,必须整盘重生成——落到下方重生成分支。
535
+ const hostInfo = detectHostVersionInfo()
536
+ const currentWorker = workerFlavorOf(hostInfo?.triple ?? null)
537
+ const currentRoles = rolesFlavorOf(hostInfo?.triple ?? null, hostInfo?.prerelease ?? null)
538
+ if (workerFlavorCompatible(marker.worker, currentWorker) && rolesFlavorCompatible(marker.roles, currentRoles)) {
347
539
  const system = await detectPresetSystem({ dshHome })
348
540
  if (shouldAbort?.()) return { installed: false, reason: PRESET_ENSURE_ABORTED }
349
541
  const presetIds = listOrchestratorPresetIds(dshHome)
@@ -352,7 +544,7 @@ export async function ensurePresetInstalled({ dshHome = resolveDshHome(), genera
352
544
  if (marker.track === 'declarative' && backfilled.length === 0) {
353
545
  return { installed: false, reason: 'preset 已由当前版本生成器产出,无需重生成' }
354
546
  }
355
- writeFileSync(markerFile, formatGeneratorMarker(generatorVersion, 'declarative', currentWorker), 'utf8')
547
+ writeFileSync(markerFile, formatGeneratorMarker(generatorVersion, 'declarative', currentWorker, currentRoles), 'utf8')
356
548
  return backfilled.length > 0
357
549
  ? { installed: false, backfilled, reason: `宿主已切换声明行轨,补写 ${backfilled.length} 条预设声明行(preset 内容未重生成)` }
358
550
  : { installed: false, reason: '预设安装轨道标记已更新为声明行轨(内容无需重生成)' }
@@ -363,12 +555,12 @@ export async function ensurePresetInstalled({ dshHome = resolveDshHome(), genera
363
555
  if (marker.track === 'directory') {
364
556
  return { installed: false, reason: 'preset 已由当前版本生成器产出,无需重生成' }
365
557
  }
366
- writeFileSync(markerFile, formatGeneratorMarker(generatorVersion, 'directory', currentWorker), 'utf8')
558
+ writeFileSync(markerFile, formatGeneratorMarker(generatorVersion, 'directory', currentWorker, currentRoles), 'utf8')
367
559
  return { installed: false, reason: '预设安装轨道标记已升级为带轨道格式(内容无需重生成)' }
368
560
  }
369
561
  // 声明行残留 + directory 宿主:落到下方整盘重生成
370
562
  }
371
- // worker 形态不符(含旧格式标记升级到 v3):落到下方整盘重生成
563
+ // worker 形态或角色行形态不符(含旧格式标记升级到 v4):落到下方整盘重生成
372
564
  }
373
565
 
374
566
  if (shouldAbort?.()) return { installed: false, reason: PRESET_ENSURE_ABORTED }
@@ -396,16 +588,20 @@ export async function ensurePresetInstalled({ dshHome = resolveDshHome(), genera
396
588
  if (shouldAbort?.()) return { installed: false, reason: PRESET_ENSURE_ABORTED }
397
589
  mkdirSync(presetDir, { recursive: true })
398
590
  const track = result.presetSystem === 'declarative' ? 'declarative' : 'directory'
399
- writeFileSync(markerFile, formatGeneratorMarker(generatorVersion, track, workerFlavorOf(state.hostVersionTriple)), 'utf8')
591
+ writeFileSync(markerFile, formatGeneratorMarker(generatorVersion, track, workerFlavorOf(state.hostVersionTriple), rolesFlavorOf(state.hostVersionTriple, state.hostPrerelease)), 'utf8')
400
592
  // 声明轨上整盘重生成只重建本 preset 的行:其余工作区 preset 目录若在而
401
593
  // 声明行缺(轨道翻转后首次触发本路径),一并补齐。
402
594
  const backfilled = track === 'declarative' ? backfillHomePatchRows(dshHome, listOrchestratorPresetIds(dshHome)) : []
595
+ // 工作区预设角色行形态换代(宿主跨 0.2.1 边界后的存量 orchestrator-<slug>
596
+ // 产物):基础重生成只换了全局预设,工作区产物不跟手就会继续被守卫拒绝。
597
+ const migratedPresets = migrateWorkspacePresetRoles({ dshHome, state })
403
598
  return {
404
599
  installed: true,
405
600
  ...(backfilled.length > 0 ? { backfilled } : {}),
406
- reason: state.existing
407
- ? 'preset 缺失或由旧版生成器产出,已按现有配置重新生成'
408
- : 'preset 缺失或由旧版生成器产出,已写入基础模板',
601
+ ...(migratedPresets.length > 0 ? { migratedPresets } : {}),
602
+ reason:
603
+ (state.existing ? 'preset 缺失或由旧版生成器产出,已按现有配置重新生成' : 'preset 缺失或由旧版生成器产出,已写入基础模板') +
604
+ (migratedPresets.length > 0 ? `;工作区预设角色行已换代:${migratedPresets.join('、')}` : ''),
409
605
  }
410
606
  } catch (err) {
411
607
  // 绝不抛:首装自愈是锦上添花,失败只带回 error 交调用方记日志,不拖垮插件启动。
@@ -2,11 +2,13 @@
2
2
  * paoding-config-ui — DSH Web 插件 (cordis)
3
3
  *
4
4
  * 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(左侧栏全页配置,无独立服务端):
5
- * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/version/
6
- * upgrade/preview/apply/rescan/config/export/config/import + client.css
7
- * 静态样式),经
5
+ * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/presets/
6
+ * version/upgrade/preview/apply/rescan/config/export/config/import +
7
+ * client.css 静态样式),经
8
8
  * ./api-core.mjs 复用 tools/install.mjs 的检测与生成管线;workspaces 直连
9
- * 宿主 workspaceRegistry 服务(list / create,幂等);version
9
+ * 宿主 workspaceRegistry 服务(list / create,幂等);presets 提供预设
10
+ * 对象的 CRUD(列举 / 新增更新 / 删除工作区预设,全局 orchestrator 由启动
11
+ * 自愈保障、拒绝删除);version
10
12
  * 复用 tools/lib/version.mjs 的版本检测(npm registry 优先,npmmirror
11
13
  * 镜像、GitHub release 依次兜底);upgrade 经 tools/lib/upgrade.mjs 完成
12
14
  * 一键升级(常规 profile spawn dsh 插件转发器;desktop profile 被 dsh CLI
@@ -37,7 +39,7 @@ import { readFile } from 'node:fs/promises'
37
39
  import os from 'node:os'
38
40
  import path from 'node:path'
39
41
  import { fileURLToPath } from 'node:url'
40
- import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent } from './api-core.mjs'
42
+ import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent, listPresets, resolveWorkspacePathByPresetId, deleteWorkspacePreset } from './api-core.mjs'
41
43
  import { collectModelRefs } from '../../tools/lib/health.mjs'
42
44
  import { detectSkills } from '../../tools/lib/skills.mjs'
43
45
  import { collectRuntimeFacts } from './runtime-inventory.mjs'
@@ -289,6 +291,73 @@ async function modelDirectoryOf(ctx) {
289
291
  return { groups: results.filter(Boolean), failures }
290
292
  }
291
293
 
294
+ /**
295
+ * 工作区预设写侧公共流程(POST presets / PUT presets/<id> 共用,模块级以便
296
+ * 复用同一把闸):与 apply 路由完全同款 —— in-flight 闸防并发写盘、原始 state
297
+ * 喂生成管线(serializeState 视图会炸 TypeError,契约见 tests/api-apply.test.mjs)、
298
+ * 按需取模型目录做体检、落盘后强制重刷缓存(best-effort,失败只 warn)。
299
+ * withCreated:POST 需要回 created(以进 installAssignments 前的
300
+ * state.existing?.workspaces 判断该工作区键是否已存在);PUT 一律视作更新,
301
+ * 不回该键。生成失败按 apply 同款 422(err.warnings 透传体检结果)。
302
+ */
303
+ async function writeWorkspacePreset(ctx, res, { workspacePath, assignments, withCreated }) {
304
+ if (applyInFlight) {
305
+ json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
306
+ return
307
+ }
308
+ applyInFlight = true
309
+ try {
310
+ const s = await rawStateWithRuntimeFacts(ctx)
311
+ const created = withCreated
312
+ ? !(s.existing?.workspaces && Object.prototype.hasOwnProperty.call(s.existing.workspaces, workspacePath))
313
+ : undefined
314
+ // 模型目录按需取(unknown_model 体检的比对名单),口径与 apply 一致:
315
+ // assignments 里没有任何角色专用模型时不做目录发现,拿不到传 null。
316
+ let models = null
317
+ if (collectModelRefs(assignments).length > 0) {
318
+ try {
319
+ models = (await modelDirectoryOf(ctx)).groups
320
+ } catch {
321
+ models = null
322
+ }
323
+ }
324
+ try {
325
+ const result = installAssignments(s, assignments, {
326
+ dryRun: false,
327
+ saveConfigOnWrite: true,
328
+ workspacePath,
329
+ models,
330
+ })
331
+ // 落盘后强制重刷状态缓存(同 apply:否则浏览器刷新读到应用前的快照);
332
+ // best-effort,刷新失败不影响已成功的落盘结果。
333
+ try {
334
+ await rawStateWithRuntimeFacts(ctx, { force: true })
335
+ } catch (err) {
336
+ ctx.logger?.warn?.('paoding-config-ui: post-apply state refresh failed: ' + String(err?.message ?? err))
337
+ }
338
+ json(res, 200, {
339
+ wrote: result.wrote,
340
+ staleNote: result.staleNote,
341
+ roleResults: result.roleResults,
342
+ text: result.text,
343
+ // 本次落点随 workspacePath:orchestrator(全局更新)或 orchestrator-<slug>。
344
+ presetId: result.presetId,
345
+ configFile: s.configFile,
346
+ warnings: result.warnings,
347
+ ...(withCreated ? { created } : {}),
348
+ workspace: workspacePath,
349
+ })
350
+ } catch (err) {
351
+ json(res, 422, {
352
+ error: err.message,
353
+ ...(Array.isArray(err?.warnings) ? { warnings: err.warnings } : {}),
354
+ })
355
+ }
356
+ } finally {
357
+ applyInFlight = false
358
+ }
359
+ }
360
+
292
361
  // —— 宿主热切换竞态兜底 ————————————————————————————————————————————
293
362
  // dsh 0.1.7-rc.2 / 0.2.0-rc.x 插件列表「禁用→启用」会重建条目纤维:新 apply()
294
363
  // 先执行,旧 incarnation 的路由注销走异步结算,注册瞬间旧路由常仍在表里,
@@ -658,6 +727,129 @@ export async function apply(ctx) {
658
727
  return
659
728
  }
660
729
 
730
+ // ── 预设 CRUD(presets 路由族)──────────────────────────────────────
731
+ // 把「预设对象」的生命周期开放给 API(服务端核心在 api-core.mjs):
732
+ // GET presets 列举:全局 orchestrator 恒在首位 + 配置
733
+ // workspaces 段的各工作区预设(只读)。
734
+ // POST presets 新增(或幂等更新)工作区预设 = apply 带
735
+ // workspace 的幂等包装:同一生成管线落
736
+ // orchestrator-<slug> 并把条目并进配置
737
+ // workspaces 段,响应多带 created / workspace。
738
+ // PUT presets/<id> 更新既有预设(orchestrator = 全局,等价
739
+ // apply 不带 workspace;orchestrator-<slug>
740
+ // = 反查到的工作区)。
741
+ // DELETE presets/<id> 删工作区预设 = 配置条目 + preset 目录产物 +
742
+ // 声明行三位一体清理;全局 orchestrator 由
743
+ // 插件启动自愈保障,删除一律 403 拒绝。
744
+ // 写侧三条(POST/PUT/DELETE)与 apply 共用同一把 in-flight 闸:任一
745
+ // 落盘路径进行中,其余写请求一律 409,防并发交错写配置。方法不符的
746
+ // 路径不命中以下分支,落到底部 404 兜底(与既有路由一致)。
747
+ if (route === 'presets' && req.method === 'GET') {
748
+ // 列举只读:serializeState 后的浏览器视图即可(existing /
749
+ // installedPresets / workspaceMeta 齐全,见 listPresets)。
750
+ json(res, 200, { ok: true, presets: listPresets(await stateWithRuntimeFacts(ctx)) })
751
+ return
752
+ }
753
+
754
+ if (route === 'presets' && req.method === 'POST') {
755
+ // 新增 / 幂等更新工作区预设。校验先行(不占闸):assignments 非空
756
+ // 对象、workspace 归一为绝对路径且 statSync 预检是目录(文案与
757
+ // POST /workspaces / apply 的工作区预检同款)。
758
+ const body = await readBody(req)
759
+ const assignments = body.assignments
760
+ if (!assignments || typeof assignments !== 'object' || Array.isArray(assignments) || Object.keys(assignments).length === 0) {
761
+ json(res, 422, { error: '缺少预设配置(body.assignments)' })
762
+ return
763
+ }
764
+ const rawWorkspace = typeof body.workspace === 'string' ? body.workspace.trim() : ''
765
+ if (rawWorkspace === '') {
766
+ json(res, 422, { error: '缺少工作区目录绝对路径(body.workspace)' })
767
+ return
768
+ }
769
+ let wsStat = null
770
+ try { wsStat = statSync(rawWorkspace) } catch { /* 不存在:stat 抛错,落到下方 422 */ }
771
+ if (!wsStat || !wsStat.isDirectory()) {
772
+ json(res, 422, { error: `目录不存在或不是目录:${rawWorkspace}` })
773
+ return
774
+ }
775
+ await writeWorkspacePreset(ctx, res, {
776
+ workspacePath: path.normalize(path.resolve(rawWorkspace)),
777
+ assignments,
778
+ withCreated: true,
779
+ })
780
+ return
781
+ }
782
+
783
+ if (route.startsWith('presets/') && (req.method === 'PUT' || req.method === 'DELETE')) {
784
+ const presetId = route.slice('presets/'.length)
785
+ // 只认单层 id(presets/<id>);空 id / 多层路径落底部 404 兜底。
786
+ if (presetId !== '' && !presetId.includes('/')) {
787
+ // 全局预设两态先行分流:更新 = 等价 apply 不带 workspace;删除
788
+ // 语义不成立(启动自愈会装回来),403 直接拒绝。
789
+ if (req.method === 'DELETE' && presetId === 'orchestrator') {
790
+ json(res, 403, { error: '全局 orchestrator 预设不支持删除:插件启动自愈会自动重装;卸载请移除 dsh-paoding 插件' })
791
+ return
792
+ }
793
+ // PUT 的 body 与校验先行(不占闸,与 POST 同款文案),校验过了再
794
+ // 分流全局 / 工作区;DELETE 无 body。
795
+ let assignments = null
796
+ if (req.method === 'PUT') {
797
+ const body = await readBody(req)
798
+ assignments = body.assignments
799
+ if (!assignments || typeof assignments !== 'object' || Array.isArray(assignments) || Object.keys(assignments).length === 0) {
800
+ json(res, 422, { error: '缺少预设配置(body.assignments)' })
801
+ return
802
+ }
803
+ }
804
+ if (presetId === 'orchestrator') {
805
+ // 全局更新:workspacePath = null、无 created,其余与 POST 一致。
806
+ await writeWorkspacePreset(ctx, res, { workspacePath: null, assignments, withCreated: false })
807
+ return
808
+ }
809
+ // 工作区预设按 id 反查路径(原始 state 的 existing.workspaces 键集
810
+ // 整组 assignSlugs,同 basename 的 hash 后缀才对得上);此处读取
811
+ // 在闸外(缓存命中近乎零成本),未知的 id 趁早 404,不必等闸。
812
+ const s0 = await rawStateWithRuntimeFacts(ctx)
813
+ const workspacePath = resolveWorkspacePathByPresetId(s0.existing, presetId)
814
+ if (workspacePath === null) {
815
+ json(res, 404, { error: `未找到预设: ${presetId}` })
816
+ return
817
+ }
818
+ // 目录预检(配置里的路径可能已被外部删除),文案与 POST 同款。
819
+ let wsStat = null
820
+ try { wsStat = statSync(workspacePath) } catch { /* 不存在:落到下方 422 */ }
821
+ if (!wsStat || !wsStat.isDirectory()) {
822
+ json(res, 422, { error: `目录不存在或不是目录:${workspacePath}` })
823
+ return
824
+ }
825
+ if (req.method === 'PUT') {
826
+ await writeWorkspacePreset(ctx, res, { workspacePath, assignments, withCreated: false })
827
+ return
828
+ }
829
+ // DELETE:核心清理在 deleteWorkspacePreset(配置重读护栏在函数
830
+ // 内部),传原始 state 拿 yamlMod / dshHome / configFile。与写侧
831
+ // 共用同一把闸;成功后显式重刷缓存(saveConfig 已改 mtime,理论
832
+ // 上缓存自失效,重刷与 POST/PUT 对齐;best-effort)。
833
+ if (applyInFlight) {
834
+ json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
835
+ return
836
+ }
837
+ applyInFlight = true
838
+ try {
839
+ const removed = deleteWorkspacePreset(s0, workspacePath)
840
+ try {
841
+ await rawStateWithRuntimeFacts(ctx, { force: true })
842
+ } catch (err) {
843
+ ctx.logger?.warn?.('paoding-config-ui: post-apply state refresh failed: ' + String(err?.message ?? err))
844
+ }
845
+ json(res, 200, { ok: true, ...removed })
846
+ } finally {
847
+ applyInFlight = false
848
+ }
849
+ return
850
+ }
851
+ }
852
+
661
853
  json(res, 404, { error: `no such route: ${req.method} /api/paoding/${route}` })
662
854
  } catch (err) {
663
855
  // 错误原文(可能带本机绝对路径)留日志;对外 message 先脱敏(home /
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paoding-config-ui",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "main": "index.mjs",
@@ -18,12 +18,15 @@
18
18
 
19
19
  # ── identity ────────────────────────────────────────────────────────────────
20
20
 
21
- # 编排人设:{{model}}/{{cwd}} 由 agent 的 route/workspace 解析(同 standard)。
21
+ # 编排人设:{{model}} 由 agent 的 route 解析;{{cwd}} 禁用(见下)。
22
+ # 注意:作为 agent-preset-registry 选中 preset 挂部署主代理时,0.2.1 的
23
+ # deployment:persona-prefix 段只注册 provider/model——禁用 {{cwd}}(纯装饰,
24
+ # 工作目录已由宿主 env 上下文提供)。
22
25
  - id: persona
23
26
  name: '@deepseek-ai/dsh-persona'
24
27
  config:
25
28
  prefix: |-
26
- You are the orchestrator agent powered by the {{model}} model. Your working directory is {{cwd}}.
29
+ You are the orchestrator agent powered by the {{model}} model.
27
30
 
28
31
  Decompose the task, delegate to role agents, integrate results.
29
32
  - Internal file questions (finding code, references, reading files): solve yourself with glob/grep/read (and codegraph if enabled) — never delegate them.