agentworkshop 0.7.15 → 0.7.17

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.
Files changed (99) hide show
  1. package/.AgentWorkShop/prompts/host-tools.json +924 -755
  2. package/.output/.AgentWorkShop/prompts/host-tools.json +924 -755
  3. package/.output/nitro.json +1 -1
  4. package/.output/public/_nuxt/{DMdVWIGz.js → B-E8fXAO.js} +1 -1
  5. package/.output/public/_nuxt/{DyisHHzD.js → BA3XzwiD.js} +1 -1
  6. package/.output/public/_nuxt/{CD1SlCoI.js → BJem8_v5.js} +4 -4
  7. package/.output/public/_nuxt/{6vYobj4F.js → BWC4HQHQ.js} +1 -1
  8. package/.output/public/_nuxt/{kkEthL84.js → Ba7YofCb.js} +1 -1
  9. package/.output/public/_nuxt/{DGVAuScD.js → C_2bTK0C.js} +1 -1
  10. package/.output/public/_nuxt/{B53Mp7lD.js → CgT5wMfz.js} +1 -1
  11. package/.output/public/_nuxt/{DT-Wg8r1.js → D4CLp1hc.js} +1 -1
  12. package/.output/public/_nuxt/{C6m-kenn.js → DAR70Lf5.js} +1 -1
  13. package/.output/public/_nuxt/{B3Psw_sL.js → DBXhDLgl.js} +1 -1
  14. package/.output/public/_nuxt/{Cj6139IM.js → DsOkkMX3.js} +1 -1
  15. package/.output/public/_nuxt/DtM4RX5T.js +1 -0
  16. package/.output/public/_nuxt/{DaE5saZg.js → QT54MQV9.js} +1 -1
  17. package/.output/public/_nuxt/{Bmpvki7M.js → UtviM1bg.js} +1 -1
  18. package/.output/public/_nuxt/_id_.pF-B9O6u.css +1 -0
  19. package/.output/public/_nuxt/builds/latest.json +1 -1
  20. package/.output/public/_nuxt/builds/meta/6a0a88de-639e-4e7b-b7c1-7f4034e04787.json +1 -0
  21. package/.output/server/chunks/_/nitro.mjs +1411 -910
  22. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  23. package/.output/server/chunks/build/{TownView-CunmEMzS.mjs → TownView-ql2eHZOg.mjs} +4 -4
  24. package/.output/server/chunks/build/{TownView-CunmEMzS.mjs.map → TownView-ql2eHZOg.mjs.map} +1 -1
  25. package/.output/server/chunks/build/{_id_-CVnMdSeG.mjs → _id_-CwR5oLu9.mjs} +4 -4
  26. package/.output/server/chunks/build/{_id_-CVnMdSeG.mjs.map → _id_-CwR5oLu9.mjs.map} +1 -1
  27. package/.output/server/chunks/build/{_id_-D265Wh_k.mjs → _id_-DvJZ5Qis.mjs} +168 -120
  28. package/.output/server/chunks/build/_id_-DvJZ5Qis.mjs.map +1 -0
  29. package/.output/server/chunks/build/{_wsId_-eNJmBp05.mjs → _wsId_-B3aB9bIp.mjs} +5 -5
  30. package/.output/server/chunks/build/{_wsId_-eNJmBp05.mjs.map → _wsId_-B3aB9bIp.mjs.map} +1 -1
  31. package/.output/server/chunks/build/{daq-DxaXk_8N.mjs → daq-epWL_sC7.mjs} +5 -5
  32. package/.output/server/chunks/build/{daq-DxaXk_8N.mjs.map → daq-epWL_sC7.mjs.map} +1 -1
  33. package/.output/server/chunks/build/{dcw-B0dtiHFG.mjs → dcw-CBPaFj2f.mjs} +2 -2
  34. package/.output/server/chunks/build/{dcw-B0dtiHFG.mjs.map → dcw-CBPaFj2f.mjs.map} +1 -1
  35. package/.output/server/chunks/build/{logs-UWXeNKy_.mjs → logs-Bz1p6x_5.mjs} +3 -3
  36. package/.output/server/chunks/build/{logs-UWXeNKy_.mjs.map → logs-Bz1p6x_5.mjs.map} +1 -1
  37. package/.output/server/chunks/build/{pages-Aexs6LjD.mjs → pages-Dq7_H4FK.mjs} +3 -3
  38. package/.output/server/chunks/build/{pages-Aexs6LjD.mjs.map → pages-Dq7_H4FK.mjs.map} +1 -1
  39. package/.output/server/chunks/build/{town-BbaR-8io.mjs → town-CstiKxf_.mjs} +5 -5
  40. package/.output/server/chunks/build/{town-BbaR-8io.mjs.map → town-CstiKxf_.mjs.map} +1 -1
  41. package/.output/server/chunks/build/{useDaqStream-BkmyQT06.mjs → useDaqStream-BOOZ-p-l.mjs} +2 -2
  42. package/.output/server/chunks/build/{useDaqStream-BkmyQT06.mjs.map → useDaqStream-BOOZ-p-l.mjs.map} +1 -1
  43. package/.output/server/chunks/build/{useDcwStream-DWVoU96J.mjs → useDcwStream-DyB2ufQI.mjs} +15 -1
  44. package/.output/server/chunks/build/{useDcwStream-DWVoU96J.mjs.map → useDcwStream-DyB2ufQI.mjs.map} +1 -1
  45. package/.output/server/chunks/build/{useDeviceTwins-34WGk9aN.mjs → useDeviceTwins-ZbT86L9O.mjs} +2 -2
  46. package/.output/server/chunks/build/{useDeviceTwins-34WGk9aN.mjs.map → useDeviceTwins-ZbT86L9O.mjs.map} +1 -1
  47. package/.output/server/chunks/build/{useOpsLog-C9zTzYof.mjs → useOpsLog-8HCYFb6n.mjs} +2 -2
  48. package/.output/server/chunks/build/{useOpsLog-C9zTzYof.mjs.map → useOpsLog-8HCYFb6n.mjs.map} +1 -1
  49. package/.output/server/chunks/routes/api/workshop/dcw/index.post.mjs +1 -1
  50. package/.output/server/chunks/routes/api/workshop/dcw/index.post.mjs.map +1 -1
  51. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/revert.post.mjs +42 -0
  52. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/revert.post.mjs.map +1 -0
  53. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/versions.get.mjs +29 -0
  54. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/versions.get.mjs.map +1 -0
  55. package/.output/server/chunks/routes/api/workshop/dcw/recipes/{_id_.patch.mjs → index.patch.mjs} +11 -6
  56. package/.output/server/chunks/routes/api/workshop/dcw/recipes/index.patch.mjs.map +1 -0
  57. package/.output/server/chunks/routes/api/workshop/index3.post.mjs +1 -1
  58. package/.output/server/chunks/routes/api/workshop/index3.post.mjs.map +1 -1
  59. package/.output/server/chunks/routes/renderer.mjs +1 -1
  60. package/.output/server/chunks/virtual/entry.mjs +34 -8
  61. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  62. package/.output/server/package.json +1 -1
  63. package/app/composables/workshop/useDcwStream.ts +12 -0
  64. package/app/pages/dcw/[id].vue +165 -1
  65. package/i18n/locales/en.ts +13 -0
  66. package/i18n/locales/zh-CN.ts +13 -0
  67. package/package.json +1 -1
  68. package/scripts/_dbg-opslog-agent-e2e.mjs +191 -0
  69. package/scripts/_dbg-recipe-e2e.mjs +176 -0
  70. package/server/api/workshop/daq/index.post.ts +1 -1
  71. package/server/api/workshop/dcw/lines/index.post.ts +1 -1
  72. package/server/api/workshop/dcw/recipes/{[id].patch.ts → [id]/index.patch.ts} +9 -3
  73. package/server/api/workshop/dcw/recipes/[id]/revert.post.ts +28 -0
  74. package/server/api/workshop/dcw/recipes/[id]/versions.get.ts +14 -0
  75. package/server/data/daqs.json +471 -61
  76. package/server/data/dcw-lines.json +49 -0
  77. package/server/data/dcw-products.json +56 -0
  78. package/server/data/dcw-recipes.json +64 -0
  79. package/server/data/dcw-rollback.json +239 -6
  80. package/server/data/dcw-runs.json +162 -142
  81. package/server/data/dcw-writes.json +132 -0
  82. package/server/data/dcws.json +344 -198
  83. package/server/data/line-runs.json +41 -11
  84. package/server/data/scene-layouts.json +11 -0
  85. package/server/plugins/workshop.ts +9 -0
  86. package/server/services/workshop/agents/agent-badge.ts +38 -0
  87. package/server/services/workshop/agents/host-tool-bridge.ts +642 -612
  88. package/server/services/workshop/agents/industrial-tools.ts +357 -4
  89. package/server/services/workshop/daq/drivers.ts +41 -8
  90. package/server/services/workshop/dcw/dcw-controller.ts +50 -2
  91. package/server/services/workshop/dcw/dcw-recipe.repo.ts +81 -3
  92. package/server/services/workshop/dcw/drivers.ts +2 -2
  93. package/server/services/workshop/dcw/recipe-rollback-manager.ts +8 -6
  94. package/shared/dcw-protocol.ts +14 -2
  95. package/.output/public/_nuxt/Bc3PiGGr.js +0 -1
  96. package/.output/public/_nuxt/_id_.DTvjd6VN.css +0 -1
  97. package/.output/public/_nuxt/builds/meta/65f6bed9-b794-44a4-805a-e22700c10817.json +0 -1
  98. package/.output/server/chunks/build/_id_-D265Wh_k.mjs.map +0 -1
  99. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id_.patch.mjs.map +0 -1
@@ -11,6 +11,7 @@ import type { AgentNodeBinding } from './node-bindings.repo'
11
11
  import { getAgentNodeBindingRepo } from './node-bindings.repo'
12
12
  import { getToolApprovals } from './tool-approvals'
13
13
  import { nodeSemanticCards } from './industrial-context'
14
+ import { agentBadgeLabel } from './agent-badge'
14
15
  import { getDcwController } from '../dcw/dcw-controller'
15
16
  import { getActiveLineRun } from '../dcw/line-run'
16
17
  import { findDcwTemplate } from '../dcw/dcw-templates'
@@ -18,6 +19,10 @@ import { daqRuntimeSettings } from '../settings'
18
19
  import { getDaqNodeRepo } from '../daq/daq-node.repo'
19
20
  import { findDaqTemplate } from '../daq/daq-templates'
20
21
  import { getRecipeRollBackManager } from '../dcw/recipe-rollback-manager'
22
+ import { getOps } from '../ops/ops'
23
+ import { getDcwLineRepo } from '../dcw/dcw-line.repo'
24
+ import { getDcwProductRepo } from '../dcw/dcw-product.repo'
25
+ import { getDcwRecipeRepo } from '../dcw/dcw-recipe.repo'
21
26
 
22
27
  export async function toolMyIndustrialNodes(agentId: string): Promise<{ text: string }> {
23
28
  const repo = getAgentNodeBindingRepo()
@@ -65,7 +70,14 @@ export async function toolMyIndustrialNodes(agentId: string): Promise<{ text: st
65
70
  4. 改动设定后等待工艺响应(热惯性/传动惯量)再评估,避免连续大幅调整。
66
71
  5. 调控闭环:每次下发自动开一条优化记录(open);观察数采后用 dcw_judge 落判定(keep/rollback/uncertain);
67
72
  判 rollback 后用 dcw_rollback 执行回退;dcw_journal 可查节点参数变更史。未判定前再下发,旧记录会被标记 superseded。
68
- 若节点被他人的 open 记录阻塞(对方已消失),超时(30 分钟)后你可接管:dcw_judge 会带接管标记入册。`,
73
+ 若节点被他人的 open 记录阻塞(对方已消失),超时(30 分钟)后你可接管:dcw_judge 会带接管标记入册。
74
+ 6. 自查面:line_context 看你控制的产线/产品/配方全景(动手前必读,确认归属);
75
+ ops_log 查你负责产线的运维日志(谁/何时做了什么,来源区分 Agent/用户/系统;
76
+ 参数 line_id/node_id/kind/actor_kind/minutes/limit/mine);recipe_log 查配方下发与回退事件;
77
+ recipe_versions 查配方参数版本史(谁改的/为什么/参数 diff)。
78
+ 7. 配方操控闭环(在线优化框架):验证过参数更优 → recipe_update 保存进配方(记录你的名字与原因,
79
+ 生成新版本);发现优化有问题 → recipe_rollback 回退到稳定版本(version)或已知良好批次
80
+ (to_last_good=true);运行中 PLC 当前值用 dcw_control 下发,配方定义供后续批次生效。`,
69
81
  }
70
82
  }
71
83
 
@@ -123,6 +135,8 @@ export async function toolDcwControl(agentId: string, args: { node_id?: string,
123
135
  const meta = {
124
136
  source: 'agent' as const,
125
137
  actor: agentId,
138
+ // 人话操作者:「Channel名/成员名」——/logs 来源=Agent 与用户/系统操作可区分追溯
139
+ actorName: agentBadgeLabel(agentId),
126
140
  taskId: args.task_id ? String(args.task_id) : undefined,
127
141
  hypothesis: args.hypothesis ? String(args.hypothesis) : '',
128
142
  }
@@ -355,7 +369,7 @@ export async function toolDcwJudge(agentId: string, args: { record_id?: string,
355
369
  return { text: `记录 ${recordId} 不是你发起的优化(发起者:${record.agentId ?? '用户'}),Agent 仅可判定自己的记录;他人记录请请用户在界面判定。`, isError: true }
356
370
  try {
357
371
  const finalReason = takeover ? `[接管孤儿记录,原属主 ${record.agentId ?? '?'} 超时未判定] ${reason}` : reason
358
- const updated = rb.judge(recordId, verdict, finalReason, 'agent', agentId, { takeover })
372
+ const updated = rb.judge(recordId, verdict, finalReason, 'agent', agentId, { takeover, actorName: agentBadgeLabel(agentId) })
359
373
  const next = verdict === 'rollback'
360
374
  ? '判定已入册;请立即用 dcw_rollback(record_id) 执行回退(判定不自动改 PLC)。'
361
375
  : verdict === 'keep'
@@ -385,10 +399,10 @@ export async function toolDcwRollback(agentId: string, args: { record_id?: strin
385
399
  const takeover = record.agentId !== agentId && rb.isStale(record)
386
400
  if (record.agentId !== agentId && !takeover)
387
401
  return { text: `记录 ${recordId} 不是你发起的优化(发起者:${record.agentId ?? '用户'}),回退他人记录请请用户在数采中心/产线详情执行;若原属主已消失(超时未判定),可先 dcw_judge 接管后再回退。`, isError: true }
388
- const fresh = await rb.rollbackRecord(recordId, agentId, 'agent')
402
+ const fresh = await rb.rollbackRecord(recordId, agentId, 'agent', undefined, { actorName: agentBadgeLabel(agentId) })
389
403
  return { text: `回退已执行:记录 ${recordId} 标记 rolled-back;下发恢复值 ${fresh?.params[0]?.to}${'(以回读为准)'};新回退记录 ${fresh?.id} 已入册。请 daq_query 复测确认恢复。` }
390
404
  }
391
- const fresh = await rb.rollbackNode(nodeId, agentId, 'agent', to)
405
+ const fresh = await rb.rollbackNode(nodeId, agentId, 'agent', to, { actorName: agentBadgeLabel(agentId) })
392
406
  return { text: `节点单步回退已执行:恢复到最近稳定锚值;新回退记录 ${fresh?.id} 已入册。请 daq_query 复测确认恢复。` }
393
407
  }
394
408
  catch (err) {
@@ -481,3 +495,342 @@ export async function toolDaqFrames(agentId: string, args: {
481
495
  }
482
496
  return { text: `数采帧查询结果(${targets.length} 个节点):\n\n${sections.join('\n\n')}\n\n向量=多点工程量轮廓(完整点列前端可查);图像=像素在对象存储,指标供判读(brightness 过低=曝光不足/遮挡)。` }
483
497
  }
498
+
499
+ // ================================================================
500
+ // 运维日志 / Recipe 变更史查询(Agent 自查面:负责产线 scoped)
501
+ // ================================================================
502
+
503
+ /** Agent 负责范围 = 绑定节点的全集(节点 id 集 + 这些节点所属产线集);无绑定 = 无范围 */
504
+ function agentOpsScope(agentId: string): { lineIds: string[], nodeIds: Set<string> } | null {
505
+ const bindings = getAgentNodeBindingRepo().byAgent(agentId)
506
+ if (bindings.length === 0) return null
507
+ const lineIds = new Set<string>()
508
+ const nodeIds = new Set<string>()
509
+ for (const b of bindings) {
510
+ nodeIds.add(b.nodeId)
511
+ try {
512
+ const n = b.kind === 'dcw' ? getDcwController().byId(b.nodeId) : getDaqNodeRepo().byId(b.nodeId)
513
+ if (n?.lineId) lineIds.add(n.lineId)
514
+ }
515
+ catch { /* 节点刚被删等情况忽略 */ }
516
+ }
517
+ return { lineIds: [...lineIds], nodeIds }
518
+ }
519
+
520
+ const OPS_ACTOR_LABEL: Record<string, string> = { agent: 'Agent', user: '用户', system: '系统' }
521
+
522
+ function fmtAuditAt(iso: string): string {
523
+ return iso.length >= 19 ? iso.slice(5, 19).replace('T', ' ') : iso
524
+ }
525
+
526
+ /** 工具:ops_log —— 负责产线的运维/审计日志查询(全部下发/判定/回退/配方/产线事件)。
527
+ * 权限:仅自己绑定节点所覆盖的产线;node_id 须为绑定节点。来源(actor_kind)区分 Agent/用户/系统。 */
528
+ export async function toolOpsLog(agentId: string, args: {
529
+ line_id?: string
530
+ node_id?: string
531
+ kind?: string
532
+ actor_kind?: string
533
+ minutes?: number | string
534
+ limit?: number | string
535
+ mine?: boolean | string
536
+ }): Promise<{ text: string, isError?: boolean }> {
537
+ const audit = getOps()?.audit
538
+ if (!audit) return { text: '审计仓储未装配(服务未就绪),请稍后重试。', isError: true }
539
+ const scope = agentOpsScope(agentId)
540
+ if (!scope) return { text: '你尚未绑定任何工业节点,无日志可查(日志权限跟随节点绑定)。', isError: true }
541
+ const nodeId = String(args.node_id ?? '').trim()
542
+ const lineId = String(args.line_id ?? '').trim()
543
+ if (nodeId && !scope.nodeIds.has(nodeId))
544
+ return { text: `无权查询节点 ${nodeId} 的日志(仅可查自己绑定的节点;用 my_industrial_nodes 查看绑定)。`, isError: true }
545
+ if (lineId && !scope.lineIds.includes(lineId))
546
+ return { text: `无权查询产线 ${lineId} 的日志(你的负责产线:${scope.lineIds.join(', ') || '(绑定节点均未分配产线)'})。`, isError: true }
547
+ const kind = String(args.kind ?? '').trim() || undefined
548
+ const actorKind = String(args.actor_kind ?? '').trim() || undefined
549
+ const mine = args.mine === true || args.mine === 'true'
550
+ const minutes = Number(args.minutes) || 1440
551
+ const limit = Math.min(Number(args.limit) || 20, 100)
552
+ const from = new Date(Date.now() - minutes * 60_000).toISOString()
553
+
554
+ const lines = lineId ? [lineId] : scope.lineIds
555
+ const byId = new Map<string, Record<string, unknown>>()
556
+ for (const lid of lines) {
557
+ for (const r of audit.query({ lineId: lid, kind, actorKind, actor: mine ? agentId : undefined, from, limit }))
558
+ byId.set(String(r.id), r)
559
+ }
560
+ let rows = [...byId.values()]
561
+ if (nodeId) rows = rows.filter(r => r.targetId === nodeId)
562
+ rows.sort((a, b) => String(b.at).localeCompare(String(a.at)))
563
+ rows = rows.slice(0, limit)
564
+ if (rows.length === 0) return { text: `窗口(近 ${minutes} 分钟)内无匹配日志。可调大 minutes,或放宽 kind/actor_kind/node_id 过滤。` }
565
+
566
+ const body = rows.map((r) => {
567
+ const src = OPS_ACTOR_LABEL[String(r.actorKind)] ?? String(r.actorKind)
568
+ return `- [${fmtAuditAt(String(r.at))}] 来源=${src} 操作者=${String(r.actorName) || String(r.actor)} | ${String(r.action)} | ${String(r.summary)}`
569
+ })
570
+ const scopeNote = lineId ? `产线 ${lineId}` : `负责产线 ${scope.lineIds.length} 条`
571
+ return {
572
+ text: `运维日志(${scopeNote},近 ${minutes} 分钟,${rows.length} 条,新→旧):\n${body.join('\n')}\n\n说明:来源=Agent 的操作者格式为「Channel名/成员名」;需要节点级参数值变更史用 dcw_journal,配方下发/回退专门视图用 recipe_log。`,
573
+ }
574
+ }
575
+
576
+ /** 工具:recipe_log —— 负责产线的配方下发与回退历史(recipe.apply + 优化开窗/判定/回退)。
577
+ * Agent 据此知道 Recipe 层面发生过哪些下发变更、谁做的、是否已回退。 */
578
+ export async function toolRecipeLog(agentId: string, args: {
579
+ line_id?: string
580
+ recipe_id?: string
581
+ minutes?: number | string
582
+ limit?: number | string
583
+ }): Promise<{ text: string, isError?: boolean }> {
584
+ const audit = getOps()?.audit
585
+ if (!audit) return { text: '审计仓储未装配(服务未就绪),请稍后重试。', isError: true }
586
+ const scope = agentOpsScope(agentId)
587
+ if (!scope) return { text: '你尚未绑定任何工业节点,无 Recipe 历史可查(权限跟随节点绑定)。', isError: true }
588
+ const lineId = String(args.line_id ?? '').trim()
589
+ if (lineId && !scope.lineIds.includes(lineId))
590
+ return { text: `无权查询产线 ${lineId} 的 Recipe 历史(你的负责产线:${scope.lineIds.join(', ') || '(无)'})。`, isError: true }
591
+ const recipeId = String(args.recipe_id ?? '').trim() || undefined
592
+ const minutes = Number(args.minutes) || 1440
593
+ const limit = Math.min(Number(args.limit) || 20, 100)
594
+ const from = new Date(Date.now() - minutes * 60_000).toISOString()
595
+
596
+ const lines = lineId ? [lineId] : scope.lineIds
597
+ const byId = new Map<string, Record<string, unknown>>()
598
+ for (const lid of lines) {
599
+ for (const kind of ['recipe', 'rollback']) {
600
+ for (const r of audit.query({ lineId: lid, kind, recipeId, from, limit }))
601
+ byId.set(String(r.id), r)
602
+ }
603
+ }
604
+ const rows = [...byId.values()].sort((a, b) => String(b.at).localeCompare(String(a.at))).slice(0, limit)
605
+ if (rows.length === 0) return { text: `窗口(近 ${minutes} 分钟)内无配方下发/回退记录。可调大 minutes 或换 line_id。` }
606
+
607
+ const body = rows.map((r) => {
608
+ const src = OPS_ACTOR_LABEL[String(r.actorKind)] ?? String(r.actorKind)
609
+ const action = String(r.action)
610
+ const tag = action === 'recipe.apply'
611
+ ? '配方下发'
612
+ : action === 'optimization.open'
613
+ ? '优化开窗'
614
+ : action === 'optimization.judge'
615
+ ? '优化判定'
616
+ : action === 'optimization.rollback'
617
+ ? '回退执行'
618
+ : action
619
+ return `- [${fmtAuditAt(String(r.at))}] ${tag} 来源=${src} 操作者=${String(r.actorName) || String(r.actor)} | ${String(r.summary)}${r.recipeId ? `(配方 ${String(r.recipeId).slice(0, 8)})` : ''}`
620
+ })
621
+ return {
622
+ text: `Recipe 变更史(${lineId ? `产线 ${lineId}` : `负责产线 ${scope.lineIds.length} 条`},近 ${minutes} 分钟,${rows.length} 条,新→旧):\n${body.join('\n')}\n\n说明:配方下发=整批参数写命令;优化开窗=单次设定变更(含 Agent 假设);回退执行=已恢复基线值。节点级参数值逐笔历史用 dcw_journal(node_id)。`,
623
+ }
624
+ }
625
+
626
+ // ================================================================
627
+ // 产线上下文 + Recipe 版本管理(Agent 操控闭环:知道改什么 → 保存 → 可回退)
628
+ // ================================================================
629
+
630
+ /** 工具:line_context —— 我控制的产线/产品/Recipe 全景(归属认知 + 完整参数面)。
631
+ * 逐产线输出:产线名/运行态、活动批次(产品/配方/版本/参数)、我的节点绑定、
632
+ * 配方目标 vs PLC 当前值对照、lastGood 批次。 */
633
+ export async function toolLineContext(agentId: string, args: { line_id?: string } = {}): Promise<{ text: string, isError?: boolean }> {
634
+ const scope = agentOpsScope(agentId)
635
+ if (!scope) return { text: '你尚未绑定任何工业节点,暂无产线上下文(请先在数字孪生界面绑定节点)。' }
636
+ const wanted = String(args.line_id ?? '').trim()
637
+ const lineIds = wanted ? [wanted] : scope.lineIds
638
+ if (wanted && !scope.lineIds.includes(wanted)) {
639
+ return { text: `产线 ${wanted} 不在你的负责范围(你绑定节点覆盖的产线:${scope.lineIds.join(', ') || '无'})。`, isError: true }
640
+ }
641
+ if (lineIds.length === 0) return { text: '你绑定的节点均未分配产线(未挂线的节点没有产线/产品/配方上下文)。' }
642
+
643
+ const sections: string[] = []
644
+ for (const lid of lineIds) {
645
+ const line = getDcwLineRepo().byId(lid)
646
+ const run = getActiveLineRun(lid)
647
+ const recipe = run?.recipeId ? getDcwController().listRecipes().find(r => r.id === run.recipeId) : undefined
648
+ const parts: string[] = []
649
+ parts.push(`■ 产线 ${line?.name ?? lid}(${lid})· 状态:${run ? '运行中' : '待机/停线'}`)
650
+ if (run) {
651
+ parts.push(` 活动批次 ${run.runId.slice(0, 8)} · 产品「${run.productName}」(${run.productId}) · 开跑 ${run.startedAt.slice(11, 19)} · 已打标 ${run.taggedSamples} 样本`)
652
+ }
653
+ const myDcw = getAgentNodeBindingRepo().byAgent(agentId)
654
+ .filter(b => b.kind === 'dcw')
655
+ .map(b => getDcwController().byId(b.nodeId))
656
+ .filter(n => n?.lineId === lid)
657
+ const myDaq = getAgentNodeBindingRepo().byAgent(agentId)
658
+ .filter(b => b.kind === 'daq')
659
+ .map(b => getDaqNodeRepo().byId(b.nodeId))
660
+ .filter(n => n?.lineId === lid)
661
+ parts.push(` 我绑定的节点:数控 [${myDcw.map(n => n!.name).join(', ') || '无'}];数采 [${myDaq.map(n => n!.name).join(', ') || '无'}]`)
662
+ if (recipe) {
663
+ parts.push(` 配方「${recipe.name}」(${recipe.id}) v${recipe.version ?? 1}${recipe.description ? ` — ${recipe.description}` : ''}`)
664
+ for (const p of recipe.params) {
665
+ const node = getDcwController().byId(p.nodeId)
666
+ const mine = myDcw.some(n => n!.id === p.nodeId)
667
+ const cur = node?.value != null ? node.value : '?'
668
+ const win = p.min != null || p.max != null ? `(窗口 ${p.min ?? '-∞'}~${p.max ?? '+∞'})` : ''
669
+ parts.push(` · ${node?.name ?? p.nodeId} = ${p.value}${node?.unit ?? ''}${win} | PLC 当前 ${cur}${node?.unit ?? ''}${mine ? ' [我负责]' : ''}`)
670
+ }
671
+ const good = recipe.lastGoodRunId ? getDcwRecipeRepo().runById(recipe.lastGoodRunId) : undefined
672
+ parts.push(` 已知良好批次:${good ? `${good.id.slice(0, 8)}(${good.startedAt.slice(0, 19).replace('T', ' ')})` : '未标记'};当前参数版本 v${recipe.version ?? 1}`)
673
+ }
674
+ else {
675
+ // 无活动批次:列出该产线的配方定义(产线未开跑也有配方上下文)
676
+ const lineRecipes = getDcwController().listRecipes().filter(r => r.lineId === lid).slice(0, 3)
677
+ if (lineRecipes.length === 0) {
678
+ parts.push(' 当前无活动配方(产线未开跑;该产线也暂无配方定义)')
679
+ }
680
+ else {
681
+ parts.push(` 当前无活动批次;产线已有 ${lineRecipes.length} 个配方定义(开跑时绑定):`)
682
+ for (const r of lineRecipes) {
683
+ const product = r.productId ? getDcwProductRepo().byId(r.productId) : undefined
684
+ const pp = r.params.map((p) => {
685
+ const node = getDcwController().byId(p.nodeId)
686
+ const cur = node?.value != null ? node.value : '?'
687
+ return `${node?.name ?? p.nodeId}=${p.value}${node?.unit ?? ''}(PLC 当前 ${cur})`
688
+ }).join(', ')
689
+ parts.push(` · 配方「${r.name}」(${r.id}) v${r.version ?? 1} · 产品「${product?.name ?? r.productId}」: ${pp}${r.lastGoodRunId ? ` [良好批次 ${r.lastGoodRunId.slice(0, 8)}]` : ''}`)
690
+ }
691
+ }
692
+ }
693
+ sections.push(parts.join('\n'))
694
+ }
695
+ return {
696
+ text: `你控制的产线全景(${sections.length} 条):\n\n${sections.join('\n\n')}\n\n说明:配方目标=开跑批次冻结的工艺窗口;PLC 当前值=实时读数。把验证过的最佳参数固化到配方用 recipe_update;回退配方到稳定版本用 recipe_rollback;逐节点参数变更史用 dcw_journal;配方版本史用 recipe_versions。`,
697
+ }
698
+ }
699
+
700
+ /** 工具:recipe_versions —— 配方参数版本史(谁/何时/为什么改了什么;旧→新)。
701
+ * 每条含 by(user/agent/system)、操作者人话名、变更描述、参数 diff。 */
702
+ export async function toolRecipeVersions(agentId: string, args: { recipe_id?: string, limit?: number | string }): Promise<{ text: string, isError?: boolean }> {
703
+ const recipeId = String(args.recipe_id ?? '').trim()
704
+ if (!recipeId) return { text: 'recipe_id 必填(line_context 可看到当前配方的 id)。', isError: true }
705
+ const recipe = getDcwController().listRecipes().find(r => r.id === recipeId)
706
+ if (!recipe) return { text: `配方 ${recipeId} 不存在。`, isError: true }
707
+ const scope = agentOpsScope(agentId)
708
+ if (!scope || !recipe.lineId || !scope.lineIds.includes(recipe.lineId)) {
709
+ return { text: `无权查看配方 ${recipeId} 的版本史(该配方不在你负责的产线上)。`, isError: true }
710
+ }
711
+ const rows = getDcwController().recipeVersions(recipeId)
712
+ const limit = Math.min(Number(args.limit) || 20, 50)
713
+ const shown = rows.slice(-limit)
714
+ const srcLabel: Record<string, string> = { user: '用户', agent: 'Agent', system: '系统' }
715
+ const lines = shown.map((v, i) => {
716
+ const prev = shown[i - 1]
717
+ let diff = '初始版本'
718
+ if (prev) {
719
+ const changed = v.params
720
+ .map(p => ({ p, old: prev.params.find(x => x.nodeId === p.nodeId) }))
721
+ .filter(({ p, old }) => !old || old.value !== p.value)
722
+ .map(({ p, old }) => `${getDcwController().byId(p.nodeId)?.name ?? p.nodeId} ${old?.value ?? '新增'}→${p.value}`)
723
+ const removed = prev.params.filter(o => !v.params.some(x => x.nodeId === o.nodeId)).map(o => `${o.nodeId} 移除`)
724
+ const all = [...changed, ...removed]
725
+ diff = all.length > 0 ? `变更:${all.join(';')}` : '参数未变'
726
+ }
727
+ const src = v.by ? (srcLabel[v.by] ?? v.by) : '—'
728
+ return `- v${v.version} [${v.at.slice(0, 19).replace('T', ' ')}] 来源=${src} 操作者=${v.actorName ?? '—'}${v.description ? ` | ${v.description}` : ''}\n ${diff}`
729
+ })
730
+ return {
731
+ text: `配方「${recipe.name}」版本史(当前 v${recipe.version ?? 1},共 ${rows.length} 条,旧→新):\n${lines.join('\n')}\n\n回退用 recipe_rollback(recipe_id + version 或 to_last_good=true);保存新参数用 recipe_update。`,
732
+ }
733
+ }
734
+
735
+ /** 工具:recipe_update —— 把验证过的最佳参数保存进配方(生成新版本,带归因与原因)。
736
+ * 只改配方定义(下一批次生效);不改运行中 PLC 当前值(那用 dcw_control 逐节点下发)。 */
737
+ export async function toolRecipeUpdate(agentId: string, args: {
738
+ recipe_id?: string
739
+ params?: Array<{ node_id?: string, value?: number | string, min?: number | string, max?: number | string }>
740
+ reason?: string
741
+ task_id?: string
742
+ }): Promise<{ text: string, isError?: boolean }> {
743
+ const recipeId = String(args.recipe_id ?? '').trim()
744
+ const reason = String(args.reason ?? '').trim()
745
+ if (!recipeId) return { text: 'recipe_id 必填(line_context 可看到当前配方的 id)。', isError: true }
746
+ if (!reason) return { text: 'reason 必填:保存参数必须说明依据(如数采证据/判定结论),便于版本史追溯。', isError: true }
747
+ const list = (args.params ?? []).filter(p => p && String(p.node_id ?? '').trim() && Number.isFinite(Number(p.value)))
748
+ if (list.length === 0) return { text: 'params 至少提供一条 {node_id, value}(value 必须为数字)。', isError: true }
749
+ const recipe = getDcwController().listRecipes().find(r => r.id === recipeId)
750
+ if (!recipe) return { text: `配方 ${recipeId} 不存在。`, isError: true }
751
+ const repo = getAgentNodeBindingRepo()
752
+ for (const p of list) {
753
+ const nodeId = String(p.node_id).trim()
754
+ const node = getDcwController().byId(nodeId)
755
+ if (!node) return { text: `节点 ${nodeId} 不存在。`, isError: true }
756
+ if (node.lineId !== recipe.lineId) return { text: `节点「${node.name}」不属于配方「${recipe.name}」所在产线,不可混入。`, isError: true }
757
+ if (!repo.find(agentId, nodeId, 'dcw')) {
758
+ return { text: `无权修改节点「${node.name}」的配方参数(仅可改自己绑定 dcw 的节点)。`, isError: true }
759
+ }
760
+ }
761
+ // 部分合并:只更新提供的节点,其余参数保持不变
762
+ const merged = recipe.params.map((p) => {
763
+ const patch = list.find(x => String(x.node_id).trim() === p.nodeId)
764
+ if (!patch) return { nodeId: p.nodeId, templateRef: p.templateRef, value: p.value, min: p.min, max: p.max }
765
+ const out: { nodeId: string, templateRef?: string, value: number, min?: number, max?: number } = { nodeId: p.nodeId, templateRef: p.templateRef, value: Number(patch.value) }
766
+ if (p.min != null) out.min = p.min
767
+ if (patch.min != null && Number.isFinite(Number(patch.min))) out.min = Number(patch.min)
768
+ if (p.max != null) out.max = p.max
769
+ if (patch.max != null && Number.isFinite(Number(patch.max))) out.max = Number(patch.max)
770
+ return out
771
+ })
772
+ const extra = list.filter(p => !recipe.params.some(x => x.nodeId === String(p.node_id).trim()))
773
+ for (const p of extra) merged.push({ nodeId: String(p.node_id).trim(), value: Number(p.value) })
774
+ try {
775
+ const updated = getDcwController().updateRecipe(recipeId, { params: merged }, {
776
+ by: 'agent',
777
+ actorName: agentBadgeLabel(agentId),
778
+ actor: agentId,
779
+ description: reason,
780
+ })
781
+ const changedNodes = list.map((p) => {
782
+ const node = getDcwController().byId(String(p.node_id).trim())
783
+ const before = recipe.params.find(x => x.nodeId === String(p.node_id).trim())?.value
784
+ return `${node?.name ?? p.node_id} ${before ?? '?'}→${Number(p.value)}`
785
+ }).join(';')
786
+ return {
787
+ text: `已保存为 v${updated.version ?? 1}:「${updated.name}」参数 ${changedNodes};原因:${reason}。\n注意:配方定义已更新,运行中批次仍按开跑时冻结的参数生产,新参数从下次开跑/一键下发生效;要把新值写入运行中的 PLC,用 dcw_control 逐节点下发(走安全联锁)。回退用 recipe_rollback。`,
788
+ }
789
+ }
790
+ catch (err) {
791
+ return { text: `保存失败:${err instanceof Error ? err.message : String(err)}`, isError: true }
792
+ }
793
+ }
794
+
795
+ /** 工具:recipe_rollback —— 回退配方参数到稳定版本(指定历史版本或已知良好批次快照)。
796
+ * 生成新版本(非破坏,历史保留);不改运行中 PLC 当前值。 */
797
+ export async function toolRecipeRollback(agentId: string, args: {
798
+ recipe_id?: string
799
+ version?: number | string
800
+ to_last_good?: boolean | string
801
+ reason?: string
802
+ }): Promise<{ text: string, isError?: boolean }> {
803
+ const recipeId = String(args.recipe_id ?? '').trim()
804
+ const reason = String(args.reason ?? '').trim()
805
+ const toLastGood = args.to_last_good === true || args.to_last_good === 'true'
806
+ const version = Number(args.version)
807
+ if (!recipeId) return { text: 'recipe_id 必填。', isError: true }
808
+ if (!reason) return { text: 'reason 必填:回退必须说明原因(如优化翻车/越限),便于版本史追溯。', isError: true }
809
+ if (!toLastGood && !Number.isFinite(version)) return { text: '需提供 version(历史版本号)或 to_last_good=true。', isError: true }
810
+ const recipe = getDcwController().listRecipes().find(r => r.id === recipeId)
811
+ if (!recipe) return { text: `配方 ${recipeId} 不存在。`, isError: true }
812
+ const scope = agentOpsScope(agentId)
813
+ if (!scope || !recipe.lineId || !scope.lineIds.includes(recipe.lineId)) {
814
+ return { text: `无权回退配方 ${recipeId}(该配方不在你负责的产线上)。`, isError: true }
815
+ }
816
+ if (!toLastGood && Number.isFinite(version) && version >= (recipe.version ?? 1)) {
817
+ return { text: `不能回退到 v${version}(当前已是 v${recipe.version ?? 1};回退目标是更早的版本)。`, isError: true }
818
+ }
819
+ try {
820
+ const updated = getDcwController().revertRecipe(recipeId, {
821
+ version: toLastGood ? undefined : version,
822
+ toLastGood,
823
+ }, {
824
+ by: 'agent',
825
+ actorName: agentBadgeLabel(agentId),
826
+ actor: agentId,
827
+ description: reason,
828
+ })
829
+ return {
830
+ text: `回退完成:配方「${updated.name}」已生成 v${updated.version ?? 1},参数恢复为目标版本(${toLastGood ? '已知良好批次冻结' : `v${version}`});原因:${reason}。\n运行中批次不受影响(仍按开跑冻结参数);新参数下次开跑生效。版本史用 recipe_versions 复核。`,
831
+ }
832
+ }
833
+ catch (err) {
834
+ return { text: `回退失败:${err instanceof Error ? err.message : String(err)}`, isError: true }
835
+ }
836
+ }
@@ -228,18 +228,51 @@ export function evictModbusConn(cfg: Record<string, unknown>, transport: ModbusT
228
228
  modbusPool.delete(key)
229
229
  }
230
230
 
231
+ /**
232
+ * 安全关闭:半开 socket(对端断电)时 modbus-serial close() 等待 FIN 永不返回,
233
+ * 一律 500ms 超时保护(fire-and-forget 底层 close,调用方不阻塞)。
234
+ */
235
+ export async function closeModbusSafely(client: import('modbus-serial').ModbusRTU): Promise<void> {
236
+ await Promise.race([
237
+ Promise.resolve()
238
+ .then(() => client.close())
239
+ .catch(() => { /* 已断 */ }),
240
+ new Promise((r) => setTimeout(r, 500)),
241
+ ])
242
+ }
243
+
231
244
  /**
232
245
  * 连接级排队执行:操作串行入队,完成后链路归还。
233
246
  * 数控写入等当前采样读完成再执行(不再 409 快速失败);数采采样忙时仍跳帧让路防堆积。
247
+ * per-request 3s 硬超时:半开连接(对端断电/SYN 黑洞)时底层请求永不 settle,
248
+ * 超时后作废连接并重置 tail 链,防止单条死连接堵死该 host:port 的全部后续请求。
234
249
  */
235
250
  export function withModbusConn<R>(conn: ModbusConn, fn: () => Promise<R>): Promise<R> {
236
251
  conn.pending++
237
252
  const run = conn.tail.then(() => fn(), () => fn())
238
- conn.tail = run.then(
239
- () => { conn.pending-- },
240
- () => { conn.pending-- },
241
- )
242
- return run
253
+ let timer: NodeJS.Timeout | undefined
254
+ let dead = false
255
+ const guarded = Promise.race([
256
+ run,
257
+ new Promise<never>((_, rej) => {
258
+ timer = setTimeout(() => {
259
+ dead = true
260
+ try {
261
+ void conn.client.close()
262
+ }
263
+ catch { /* 已断 */ }
264
+ rej(new Error('Modbus 响应超时(3s)——连接可能半开,已作废重连'))
265
+ }, 3000)
266
+ }),
267
+ ] as Promise<R>)
268
+ void guarded.catch(() => { /* 超时分支先 reject,防 unhandled */ })
269
+ conn.tail = guarded.then(
270
+ () => { clearTimeout(timer); conn.pending-- },
271
+ () => { clearTimeout(timer); conn.pending-- },
272
+ ).then(() => {
273
+ if (dead) conn.tail = Promise.resolve()
274
+ })
275
+ return guarded
243
276
  }
244
277
 
245
278
  export function modbusKey(cfg: Record<string, unknown>, transport: ModbusTransport = 'tcp'): string {
@@ -252,7 +285,7 @@ export async function getModbusConn(cfg: Record<string, unknown>, transport: Mod
252
285
  if (existing && existing.client.isOpen) return existing
253
286
  if (existing) {
254
287
  try {
255
- await existing.client.close()
288
+ await closeModbusSafely(existing.client)
256
289
  }
257
290
  catch { /* 已断 */ }
258
291
  modbusPool.delete(key)
@@ -405,7 +438,7 @@ export const modbusTcpDriver: DaqDriver = {
405
438
  const key = modbusKey(driverConfig)
406
439
  const conn = modbusPool.get(key)
407
440
  if (conn) {
408
- await conn.client.close()
441
+ await closeModbusSafely(conn.client)
409
442
  modbusPool.delete(key)
410
443
  }
411
444
  }
@@ -643,7 +676,7 @@ export const modbusRtuDriver: DaqDriver = {
643
676
  try {
644
677
  const conn = modbusPool.get(modbusKey(driverConfig, 'rtu-tcp'))
645
678
  if (conn) {
646
- await conn.client.close()
679
+ await closeModbusSafely(conn.client)
647
680
  modbusPool.delete(modbusKey(driverConfig, 'rtu-tcp'))
648
681
  }
649
682
  }
@@ -556,8 +556,56 @@ class DcwController {
556
556
  return getDcwRecipeRepo().create(input)
557
557
  }
558
558
 
559
- updateRecipe(id: string, patch: Partial<RecipeInput>) {
560
- return getDcwRecipeRepo().update(id, patch)
559
+ updateRecipe(id: string, patch: Partial<RecipeInput>, meta?: { by: 'user' | 'agent' | 'system', actorName: string, actor: string, description: string }) {
560
+ const recipe = getDcwRecipeRepo().update(id, patch, meta)
561
+ // 配方参数版本变更 → 运维日志(版本号 + 归因;与版本历史同点入册)
562
+ if (patch.params !== undefined) {
563
+ try {
564
+ recordOps({
565
+ actor: meta?.actor ?? 'user',
566
+ actorName: meta?.actorName ?? meta?.actor ?? 'user',
567
+ actorKind: meta?.by ?? 'user',
568
+ action: meta?.by === 'agent' ? 'recipe.update.agent' : 'recipe.update',
569
+ kind: 'recipe',
570
+ targetKind: 'recipe',
571
+ targetId: id,
572
+ summary: `更新配方「${recipe.name}」→ v${recipe.version ?? 1}${meta?.description ? `:${meta.description.slice(0, 80)}` : ''}`,
573
+ lineId: recipe.lineId ?? '',
574
+ productId: recipe.productId ?? '',
575
+ recipeId: id,
576
+ detail: { version: recipe.version ?? 1, description: meta?.description ?? '' },
577
+ })
578
+ }
579
+ catch { /* 日志失败不影响更新 */ }
580
+ }
581
+ return recipe
582
+ }
583
+
584
+ /** 回退配方参数到历史版本/lastGood(生成新版本;非破坏) */
585
+ revertRecipe(id: string, target: { version?: number, toLastGood?: boolean }, meta: { by: 'user' | 'agent' | 'system', actorName: string, actor: string, description: string }) {
586
+ const recipe = getDcwRecipeRepo().revertToVersion(id, target, meta)
587
+ try {
588
+ recordOps({
589
+ actor: meta.actor,
590
+ actorName: meta.actorName,
591
+ actorKind: meta.by,
592
+ action: 'recipe.revert',
593
+ kind: 'recipe',
594
+ targetKind: 'recipe',
595
+ targetId: id,
596
+ summary: `回退配方「${recipe.name}」→ v${recipe.version ?? 1}${meta.description ? `:${meta.description.slice(0, 80)}` : ''}`,
597
+ lineId: recipe.lineId ?? '',
598
+ productId: recipe.productId ?? '',
599
+ recipeId: id,
600
+ detail: { version: recipe.version ?? 1, description: meta.description },
601
+ })
602
+ }
603
+ catch { /* 日志失败不影响回退 */ }
604
+ return recipe
605
+ }
606
+
607
+ recipeVersions(id: string) {
608
+ return getDcwRecipeRepo().versions(id)
561
609
  }
562
610
 
563
611
  removeRecipe(id: string): void {
@@ -29,6 +29,14 @@ function loadJson<T>(file: string, fallback: T): T {
29
29
  return loadJsonFile(file, fallback) as T
30
30
  }
31
31
 
32
+ /** 配方变更归因(版本历史/运维日志共用):by=来源,actorName=人话操作者 */
33
+ export interface RecipeUpdateMeta {
34
+ by: 'user' | 'agent' | 'system'
35
+ actorName: string
36
+ actor: string
37
+ description: string
38
+ }
39
+
32
40
  function saveJson(file: string, data: unknown): void {
33
41
  saveJsonFileAtomic(file, data)
34
42
  }
@@ -175,7 +183,7 @@ class DcwRecipeRepo {
175
183
  return recipe
176
184
  }
177
185
 
178
- update(id: string, patch: Partial<RecipeInput>): RecipeView {
186
+ update(id: string, patch: Partial<RecipeInput>, meta?: RecipeUpdateMeta): RecipeView {
179
187
  const r = this.byId(id)
180
188
  if (!r) throw new AppError(404, ErrorCodes.NOT_FOUND, `Recipe 不存在: ${id}`)
181
189
  if (patch.productId !== undefined && patch.productId !== r.productId) {
@@ -191,11 +199,20 @@ class DcwRecipeRepo {
191
199
  if (patch.description !== undefined) r.description = String(patch.description).trim()
192
200
  if (patch.params !== undefined) {
193
201
  const fresh = normParams(patch.params, r.lineId)
194
- // 参数版本化(调控闭环):活动批次外的参数修改 → 版本自增 + 旧版入史(cap 20)
202
+ // 参数版本化(调控闭环):活动批次外的参数修改 → 版本自增 + 旧版入史(cap 20);
203
+ // 归因元数据(by/actorName/actor/description)随版本入史,前端/Agent 可查谁改的为什么改
195
204
  const changed = JSON.stringify(fresh) !== JSON.stringify(r.params)
196
205
  if (changed) {
197
206
  r.paramsHistory ??= []
198
- r.paramsHistory.push({ version: r.version ?? 1, params: r.params, at: new Date().toISOString() })
207
+ r.paramsHistory.push({
208
+ version: r.version ?? 1,
209
+ params: r.params,
210
+ at: new Date().toISOString(),
211
+ by: meta?.by ?? 'user',
212
+ actorName: meta?.actorName ?? meta?.actor ?? 'user',
213
+ actor: meta?.actor ?? '',
214
+ description: meta?.description ?? '',
215
+ })
199
216
  if (r.paramsHistory.length > 20)
200
217
  r.paramsHistory.splice(0, r.paramsHistory.length - 20)
201
218
  r.version = (r.version ?? 1) + 1
@@ -208,6 +225,67 @@ class DcwRecipeRepo {
208
225
  return r
209
226
  }
210
227
 
228
+ /**
229
+ * 回退配方参数到指定历史版本(或已知良好批次快照),生成新版本(非破坏,历史完整保留)。
230
+ * target: { version: N } = 回到 vN 的参数;{ toLastGood: true } = 回到 lastGood 批次的参数冻结。
231
+ */
232
+ revertToVersion(id: string, target: { version?: number, toLastGood?: boolean }, meta: RecipeUpdateMeta): RecipeView {
233
+ const r = this.byId(id)
234
+ if (!r) throw new AppError(404, ErrorCodes.NOT_FOUND, `Recipe 不存在: ${id}`)
235
+ let snapshot: RecipeParam[] | undefined
236
+ let desc: string
237
+ if (target.toLastGood) {
238
+ const run = r.lastGoodRunId ? this.runById(r.lastGoodRunId) : undefined
239
+ if (!run?.paramsSnapshot?.length) {
240
+ throw new AppError(409, ErrorCodes.CONFLICT, `配方「${r.name}」无可回退的良好批次(先标记 lastGood 或指定 version)`)
241
+ }
242
+ snapshot = normParams(run.paramsSnapshot, r.lineId)
243
+ desc = `回退到已知良好批次 ${run.id.slice(0, 8)} 的参数冻结`
244
+ }
245
+ else {
246
+ const v = Number(target.version)
247
+ if (!Number.isFinite(v)) throw new AppError(400, ErrorCodes.VALIDATION_ERROR, 'version 必须为数字版本号')
248
+ const hit = (r.paramsHistory ?? []).find(h => h.version === v)
249
+ if (!hit) {
250
+ const avail = (r.paramsHistory ?? []).map(h => h.version).sort((a, b) => b - a)
251
+ throw new AppError(404, ErrorCodes.NOT_FOUND, `版本 v${v} 不存在(可用历史版本:${avail.length ? avail.map(n => `v${n}`).join(', ') : '无'};当前 v${r.version ?? 1})`)
252
+ }
253
+ snapshot = hit.params
254
+ desc = `回退到 v${v}(该版变更于 ${hit.at.slice(0, 19).replace('T', ' ')}${hit.actorName ? `,原操作者 ${hit.actorName}` : ''})`
255
+ }
256
+ if (JSON.stringify(snapshot) === JSON.stringify(r.params)) {
257
+ throw new AppError(409, ErrorCodes.CONFLICT, `目标版本参数与当前 v${r.version ?? 1} 完全一致,无需回退`)
258
+ }
259
+ const out = this.update(id, {
260
+ params: snapshot.map(p => ({ nodeId: p.nodeId, templateRef: p.templateRef, value: p.value, min: p.min, max: p.max })),
261
+ }, { ...meta, description: `${meta.description ? `${meta.description};` : ''}${desc}` })
262
+ return out
263
+ }
264
+
265
+ /** 参数版本历史(旧→新;含当前版尾部) */
266
+ versions(id: string): Array<{ version: number, at: string, by?: string, actorName?: string, description?: string, params: RecipeParam[], current?: boolean }> {
267
+ const r = this.byId(id)
268
+ if (!r) throw new AppError(404, ErrorCodes.NOT_FOUND, `Recipe 不存在: ${id}`)
269
+ const rows: Array<{ version: number, at: string, by?: string, actorName?: string, description?: string, params: RecipeParam[], current?: boolean }> = (r.paramsHistory ?? []).map(h => ({
270
+ version: h.version,
271
+ at: h.at,
272
+ by: h.by,
273
+ actorName: h.actorName,
274
+ description: h.description,
275
+ params: h.params,
276
+ }))
277
+ rows.push({
278
+ version: r.version ?? 1,
279
+ at: r.updatedAt,
280
+ by: undefined,
281
+ actorName: undefined,
282
+ description: '当前版本',
283
+ params: r.params,
284
+ current: true,
285
+ })
286
+ return rows
287
+ }
288
+
211
289
  /** 标记已知良好批次(基准恢复的目标;Trial keep / 手动均可调) */
212
290
  markGood(recipeId: string, runId: string): RecipeView {
213
291
  const r = this.byId(recipeId)