agentworkshop 0.7.16 → 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 (81) hide show
  1. package/.AgentWorkShop/prompts/host-tools.json +105 -0
  2. package/.output/.AgentWorkShop/prompts/host-tools.json +105 -0
  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 +1087 -746
  22. package/.output/server/chunks/build/{TownView-CunmEMzS.mjs → TownView-ql2eHZOg.mjs} +4 -4
  23. package/.output/server/chunks/build/{TownView-CunmEMzS.mjs.map → TownView-ql2eHZOg.mjs.map} +1 -1
  24. package/.output/server/chunks/build/{_id_-CVnMdSeG.mjs → _id_-CwR5oLu9.mjs} +4 -4
  25. package/.output/server/chunks/build/{_id_-CVnMdSeG.mjs.map → _id_-CwR5oLu9.mjs.map} +1 -1
  26. package/.output/server/chunks/build/{_id_-D265Wh_k.mjs → _id_-DvJZ5Qis.mjs} +168 -120
  27. package/.output/server/chunks/build/_id_-DvJZ5Qis.mjs.map +1 -0
  28. package/.output/server/chunks/build/{_wsId_-eNJmBp05.mjs → _wsId_-B3aB9bIp.mjs} +5 -5
  29. package/.output/server/chunks/build/{_wsId_-eNJmBp05.mjs.map → _wsId_-B3aB9bIp.mjs.map} +1 -1
  30. package/.output/server/chunks/build/{daq-DxaXk_8N.mjs → daq-epWL_sC7.mjs} +5 -5
  31. package/.output/server/chunks/build/{daq-DxaXk_8N.mjs.map → daq-epWL_sC7.mjs.map} +1 -1
  32. package/.output/server/chunks/build/{dcw-B0dtiHFG.mjs → dcw-CBPaFj2f.mjs} +2 -2
  33. package/.output/server/chunks/build/{dcw-B0dtiHFG.mjs.map → dcw-CBPaFj2f.mjs.map} +1 -1
  34. package/.output/server/chunks/build/{logs-UWXeNKy_.mjs → logs-Bz1p6x_5.mjs} +3 -3
  35. package/.output/server/chunks/build/{logs-UWXeNKy_.mjs.map → logs-Bz1p6x_5.mjs.map} +1 -1
  36. package/.output/server/chunks/build/{pages-Aexs6LjD.mjs → pages-Dq7_H4FK.mjs} +3 -3
  37. package/.output/server/chunks/build/{pages-Aexs6LjD.mjs.map → pages-Dq7_H4FK.mjs.map} +1 -1
  38. package/.output/server/chunks/build/{town-BbaR-8io.mjs → town-CstiKxf_.mjs} +5 -5
  39. package/.output/server/chunks/build/{town-BbaR-8io.mjs.map → town-CstiKxf_.mjs.map} +1 -1
  40. package/.output/server/chunks/build/{useDaqStream-BkmyQT06.mjs → useDaqStream-BOOZ-p-l.mjs} +2 -2
  41. package/.output/server/chunks/build/{useDaqStream-BkmyQT06.mjs.map → useDaqStream-BOOZ-p-l.mjs.map} +1 -1
  42. package/.output/server/chunks/build/{useDcwStream-DWVoU96J.mjs → useDcwStream-DyB2ufQI.mjs} +15 -1
  43. package/.output/server/chunks/build/{useDcwStream-DWVoU96J.mjs.map → useDcwStream-DyB2ufQI.mjs.map} +1 -1
  44. package/.output/server/chunks/build/{useDeviceTwins-34WGk9aN.mjs → useDeviceTwins-ZbT86L9O.mjs} +2 -2
  45. package/.output/server/chunks/build/{useDeviceTwins-34WGk9aN.mjs.map → useDeviceTwins-ZbT86L9O.mjs.map} +1 -1
  46. package/.output/server/chunks/build/{useOpsLog-C9zTzYof.mjs → useOpsLog-8HCYFb6n.mjs} +2 -2
  47. package/.output/server/chunks/build/{useOpsLog-C9zTzYof.mjs.map → useOpsLog-8HCYFb6n.mjs.map} +1 -1
  48. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/revert.post.mjs +42 -0
  49. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/revert.post.mjs.map +1 -0
  50. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/versions.get.mjs +29 -0
  51. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id/versions.get.mjs.map +1 -0
  52. package/.output/server/chunks/routes/api/workshop/dcw/recipes/{_id_.patch.mjs → index.patch.mjs} +11 -6
  53. package/.output/server/chunks/routes/api/workshop/dcw/recipes/index.patch.mjs.map +1 -0
  54. package/.output/server/chunks/routes/renderer.mjs +1 -1
  55. package/.output/server/chunks/virtual/entry.mjs +34 -8
  56. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  57. package/.output/server/package.json +1 -1
  58. package/app/composables/workshop/useDcwStream.ts +12 -0
  59. package/app/pages/dcw/[id].vue +165 -1
  60. package/i18n/locales/en.ts +13 -0
  61. package/i18n/locales/zh-CN.ts +13 -0
  62. package/package.json +1 -1
  63. package/scripts/_dbg-recipe-e2e.mjs +176 -0
  64. package/server/api/workshop/dcw/recipes/{[id].patch.ts → [id]/index.patch.ts} +9 -3
  65. package/server/api/workshop/dcw/recipes/[id]/revert.post.ts +28 -0
  66. package/server/api/workshop/dcw/recipes/[id]/versions.get.ts +14 -0
  67. package/server/data/dcw-products.json +7 -0
  68. package/server/data/dcw-rollback.json +9 -9
  69. package/server/data/dcws.json +222 -222
  70. package/server/services/workshop/agents/host-tool-bridge.ts +21 -1
  71. package/server/services/workshop/agents/industrial-tools.ts +222 -4
  72. package/server/services/workshop/daq/drivers.ts +16 -3
  73. package/server/services/workshop/dcw/dcw-controller.ts +50 -2
  74. package/server/services/workshop/dcw/dcw-recipe.repo.ts +81 -3
  75. package/server/services/workshop/dcw/drivers.ts +2 -2
  76. package/shared/dcw-protocol.ts +14 -2
  77. package/.output/public/_nuxt/Bc3PiGGr.js +0 -1
  78. package/.output/public/_nuxt/_id_.DTvjd6VN.css +0 -1
  79. package/.output/public/_nuxt/builds/meta/cf0a8831-4514-493d-ad5a-4e371a089ef9.json +0 -1
  80. package/.output/server/chunks/build/_id_-D265Wh_k.mjs.map +0 -1
  81. package/.output/server/chunks/routes/api/workshop/dcw/recipes/_id_.patch.mjs.map +0 -1
@@ -21,7 +21,7 @@ import type { WorkspaceTask } from '../types/task'
21
21
  import type { RpcHostToolDefinition } from './adapters/omp-rpc-client'
22
22
  import { loadHostToolDefs } from '../prompts/loader'
23
23
  import { daqRuntimeSettings } from '../settings'
24
- import { toolDaqFrames, toolDaqQuery, toolDcwControl, toolDcwJudge, toolDcwJournal, toolDcwRead, toolDcwRollback, toolMyIndustrialNodes, toolOpsLog, toolRecipeLog } from './industrial-tools'
24
+ import { toolDaqFrames, toolDaqQuery, toolDcwControl, toolDcwJudge, toolDcwJournal, toolDcwRead, toolDcwRollback, toolLineContext, toolMyIndustrialNodes, toolOpsLog, toolRecipeLog, toolRecipeRollback, toolRecipeUpdate, toolRecipeVersions } from './industrial-tools'
25
25
  import { listPluginTools } from './plugin-tools'
26
26
  import { extractTaskMode } from '../runtime/execution-mode'
27
27
 
@@ -162,6 +162,14 @@ export async function dispatchHostTool(ctx: HostToolBridgeContext, req: HostTool
162
162
  return toolOpsLog(identity.agentId, args as Parameters<typeof toolOpsLog>[1])
163
163
  case 'recipe_log':
164
164
  return toolRecipeLog(identity.agentId, args as Parameters<typeof toolRecipeLog>[1])
165
+ case 'line_context':
166
+ return toolLineContext(identity.agentId, args as { line_id?: string })
167
+ case 'recipe_versions':
168
+ return toolRecipeVersions(identity.agentId, args as { recipe_id?: string, limit?: number | string })
169
+ case 'recipe_update':
170
+ return toolRecipeUpdate(identity.agentId, args as Parameters<typeof toolRecipeUpdate>[1])
171
+ case 'recipe_rollback':
172
+ return toolRecipeRollback(identity.agentId, args as Parameters<typeof toolRecipeRollback>[1])
165
173
  }
166
174
 
167
175
  const ws = ctx.getWorkspace()
@@ -610,6 +618,18 @@ export async function dispatchHostTool(ctx: HostToolBridgeContext, req: HostTool
610
618
  case 'recipe_log': {
611
619
  return toolRecipeLog(identity.agentId, args as Parameters<typeof toolRecipeLog>[1])
612
620
  }
621
+ case 'line_context': {
622
+ return toolLineContext(identity.agentId, args as { line_id?: string })
623
+ }
624
+ case 'recipe_versions': {
625
+ return toolRecipeVersions(identity.agentId, args as { recipe_id?: string, limit?: number | string })
626
+ }
627
+ case 'recipe_update': {
628
+ return toolRecipeUpdate(identity.agentId, args as Parameters<typeof toolRecipeUpdate>[1])
629
+ }
630
+ case 'recipe_rollback': {
631
+ return toolRecipeRollback(identity.agentId, args as Parameters<typeof toolRecipeRollback>[1])
632
+ }
613
633
  }
614
634
  return { text: `未知工具: ${req.toolName}`, isError: true }
615
635
  }
@@ -20,6 +20,9 @@ import { getDaqNodeRepo } from '../daq/daq-node.repo'
20
20
  import { findDaqTemplate } from '../daq/daq-templates'
21
21
  import { getRecipeRollBackManager } from '../dcw/recipe-rollback-manager'
22
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'
23
26
 
24
27
  export async function toolMyIndustrialNodes(agentId: string): Promise<{ text: string }> {
25
28
  const repo = getAgentNodeBindingRepo()
@@ -68,10 +71,13 @@ export async function toolMyIndustrialNodes(agentId: string): Promise<{ text: st
68
71
  5. 调控闭环:每次下发自动开一条优化记录(open);观察数采后用 dcw_judge 落判定(keep/rollback/uncertain);
69
72
  判 rollback 后用 dcw_rollback 执行回退;dcw_journal 可查节点参数变更史。未判定前再下发,旧记录会被标记 superseded。
70
73
  若节点被他人的 open 记录阻塞(对方已消失),超时(30 分钟)后你可接管:dcw_judge 会带接管标记入册。
71
- 6. 自查面:ops_log 查你负责产线的运维日志(谁/何时做了什么,来源区分 Agent/用户/系统;
72
- 参数 line_id/node_id/kind/actor_kind/minutes/limit/mine);recipe_log 查配方下发与回退历史
73
- (recipe.apply/优化开窗/判定/回退,参数 line_id/recipe_id/minutes/limit)。
74
- 作业前后各查一次,即可完整知道自己与团队对当前节点做过哪些操作、Recipe 有过哪些变更。`,
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 下发,配方定义供后续批次生效。`,
75
81
  }
76
82
  }
77
83
 
@@ -616,3 +622,215 @@ export async function toolRecipeLog(agentId: string, args: {
616
622
  text: `Recipe 变更史(${lineId ? `产线 ${lineId}` : `负责产线 ${scope.lineIds.length} 条`},近 ${minutes} 分钟,${rows.length} 条,新→旧):\n${body.join('\n')}\n\n说明:配方下发=整批参数写命令;优化开窗=单次设定变更(含 Agent 假设);回退执行=已恢复基线值。节点级参数值逐笔历史用 dcw_journal(node_id)。`,
617
623
  }
618
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,6 +228,19 @@ 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 快速失败);数采采样忙时仍跳帧让路防堆积。
@@ -272,7 +285,7 @@ export async function getModbusConn(cfg: Record<string, unknown>, transport: Mod
272
285
  if (existing && existing.client.isOpen) return existing
273
286
  if (existing) {
274
287
  try {
275
- await existing.client.close()
288
+ await closeModbusSafely(existing.client)
276
289
  }
277
290
  catch { /* 已断 */ }
278
291
  modbusPool.delete(key)
@@ -425,7 +438,7 @@ export const modbusTcpDriver: DaqDriver = {
425
438
  const key = modbusKey(driverConfig)
426
439
  const conn = modbusPool.get(key)
427
440
  if (conn) {
428
- await conn.client.close()
441
+ await closeModbusSafely(conn.client)
429
442
  modbusPool.delete(key)
430
443
  }
431
444
  }
@@ -663,7 +676,7 @@ export const modbusRtuDriver: DaqDriver = {
663
676
  try {
664
677
  const conn = modbusPool.get(modbusKey(driverConfig, 'rtu-tcp'))
665
678
  if (conn) {
666
- await conn.client.close()
679
+ await closeModbusSafely(conn.client)
667
680
  modbusPool.delete(modbusKey(driverConfig, 'rtu-tcp'))
668
681
  }
669
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)
@@ -10,7 +10,7 @@
10
10
  import { createRequire } from 'node:module'
11
11
  import type { DcwDriverKind } from '../../../../shared/dcw-protocol'
12
12
  import { AppError } from '../../../utils/errors'
13
- import { classifyCommError, decodeRegisters, evictModbusConn, evictOpcUaConn, getModbusConn, getOpcUaConn, modbusKey, registerOffset, withModbusConn } from '../daq/drivers'
13
+ import { classifyCommError, closeModbusSafely, decodeRegisters, evictModbusConn, evictOpcUaConn, getModbusConn, getOpcUaConn, modbusKey, registerOffset, withModbusConn } from '../daq/drivers'
14
14
 
15
15
  const reqNative = createRequire(import.meta.url)
16
16
 
@@ -268,7 +268,7 @@ export const modbusTcpDcwDriver: DcwWriteDriver = {
268
268
  try {
269
269
  const key = modbusKey(driverConfig)
270
270
  const conn = await getModbusConn(driverConfig).catch(() => null)
271
- if (conn) await conn.client.close()
271
+ if (conn) await closeModbusSafely(conn.client)
272
272
  void key
273
273
  }
274
274
  catch { /* ignore */ }
@@ -377,8 +377,20 @@ export interface RecipeView {
377
377
  daqWindows: RecipeDaqWindow[]
378
378
  /** 参数版本(活动批次外的 params 修改自增;回退/审计定位用) */
379
379
  version?: number
380
- /** 参数版本历史(cap 20:每次活动批次外的 params 修改存旧版) */
381
- paramsHistory?: Array<{ version: number, params: RecipeParam[], at: string }>
380
+ /** 参数版本历史(cap 20:每次活动批次外的 params 修改存旧版;by/actorName/actor/description 为变更归因) */
381
+ paramsHistory?: Array<{
382
+ version: number
383
+ params: RecipeParam[]
384
+ at: string
385
+ /** 变更来源:user/agent/system */
386
+ by?: string
387
+ /** 人话操作者(用户名或「Channel名/成员名」) */
388
+ actorName?: string
389
+ /** 原始 id(userId/agentId/'system') */
390
+ actor?: string
391
+ /** 变更描述/原因 */
392
+ description?: string
393
+ }>
382
394
  /** 已知良好批次(判定 keep / 手动标记;基准恢复的目标) */
383
395
  lastGoodRunId?: string | null
384
396
  createdAt: string