agentworkshop 0.7.37 → 0.7.39

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 (91) hide show
  1. package/.AgentWorkShop/prompts/aml-mission-default.md +20 -20
  2. package/.AgentWorkShop/prompts/aml-playbook.md +49 -49
  3. package/.AgentWorkShop/prompts/host-tools.json +1238 -1238
  4. package/.AgentWorkShop/prompts/peer-reply-cross-channel.md +10 -10
  5. package/.output/.AgentWorkShop/prompts/aml-mission-default.md +20 -20
  6. package/.output/.AgentWorkShop/prompts/aml-playbook.md +49 -49
  7. package/.output/.AgentWorkShop/prompts/host-tools.json +1238 -1238
  8. package/.output/.AgentWorkShop/prompts/peer-reply-cross-channel.md +10 -10
  9. package/.output/nitro.json +1 -1
  10. package/.output/public/_nuxt/{BG8sCcwx.js → CUDwzgLV.js} +4 -4
  11. package/.output/public/_nuxt/CmsOtOAA2.js +1 -0
  12. package/.output/public/_nuxt/builds/latest.json +1 -1
  13. package/.output/public/_nuxt/builds/meta/4457d63f-0866-4cea-b320-04a321f24795.json +1 -0
  14. package/.output/public/_nuxt/default.BXI_fZUi.css +1 -0
  15. package/.output/server/chunks/_/nitro.mjs +703 -704
  16. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  17. package/.output/server/chunks/build/{default-hseyOqBT.mjs → default-CZmZFpqH.mjs} +27 -42
  18. package/.output/server/chunks/build/{default-hseyOqBT.mjs.map → default-CZmZFpqH.mjs.map} +1 -1
  19. package/.output/server/chunks/routes/renderer.mjs +1 -1
  20. package/.output/server/chunks/virtual/entry.mjs +3 -1
  21. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  22. package/.output/server/package.json +1 -1
  23. package/README-zh.md +6 -6
  24. package/README.md +6 -6
  25. package/app/components/AppHeader.vue +0 -17
  26. package/app/components/AppSidebar.vue +1 -0
  27. package/docs/cli.en.md +2 -2
  28. package/docs/cli.md +2 -2
  29. package/docs/full-test-plan.md +5 -1
  30. package/docs/plugins.en.md +2 -1
  31. package/docs/plugins.md +2 -1
  32. package/docs/sdk.en.md +1 -1
  33. package/docs/sdk.md +1 -1
  34. package/i18n/locales/en.ts +1 -0
  35. package/i18n/locales/zh-CN.ts +1 -0
  36. package/package.json +1 -1
  37. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.continue.json +1 -0
  38. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.json +1 -0
  39. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/054bb4bc4e4217a2ab0b0c6b663747214a9af464f5568ea60e784704e340bb0f.source +153 -0
  40. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/0554197c3d2bac203a1f5c48a5d52495d1336db1bd7aa4f4c19875810363e684.source +153 -0
  41. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/05c203d12a954fffba942e1d626e3f5fec05b18a5aef2bac003b7884a4b77406.source +431 -0
  42. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/145a6e6ba811af35aed31e0cc3211f53706a14980fa069ee9e3c1591139f96f0.source +180 -0
  43. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/1ae0fb281f97899c025e368ca73f41349b01fb5f85b19e08d7ebccff49095ed5.source +112 -0
  44. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/20ee9967aa0541c516db7575bdd4a1a745939bc7016bd10d7635903113edb8ec.source +227 -0
  45. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/233751843aeba9d4c5263f275b8102de00443c06f286918cbd23e0b6729e01aa.source +131 -0
  46. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/263c2e05b425e5c322e60c6475279f963bab3a873eb521b21df7fec935a46597.source +160 -0
  47. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/2ac5eaac28eaaee2178c4b32014df8e732f9dd432a285a872f5b7f31c1395fa4.source +157 -0
  48. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/33a0db0d2d95d2c11d0720090458e5fba1425376f5ceb5b422d1d3471be3226b.source +348 -0
  49. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/359e474ae83db35e4615d3728de6bf694b64d4dce9b536730a3b6518f9c0494a.source +296 -0
  50. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/37a4c3c31c4b70f6b719c53d1e9c4fcba603f31adfdd11bb6ef9f0d094923c56.source +285 -0
  51. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/461feca1c14a695eb8b0f17a6f95fe97eeceabc0510faaea4ba6d25bde147d8d.source +217 -0
  52. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/4cfadbda8591f29d5e1e230dc000e701b756e850441b3900fb596a4d2f6e98c2.source +301 -0
  53. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/50ca2810cf0b248c96948994c0c404531bbad6e553166b6816b3b03bfa0f75d2.source +151 -0
  54. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/52d9e8cb591a9ae86e238c3620a610cd386717bee222c0f37b25b2ac7e523dc4.source +135 -0
  55. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/6c87daafa4c006560a954636a97333f6123153c9d34d96203f7ee3a14e556357.source +156 -0
  56. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/77bc47854b577900621cacf1697f81fa2bc005af129d37614a97a3441c08f99f.source +72 -0
  57. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/7bbb789d3a527817118cf8399ed1861d6a962ade10878fd07223435d333a7089.source +469 -0
  58. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/86b7c1daafc8b60ba9982dde5bf1bcc74265709483c176e71f23ec6fc267451f.source +259 -0
  59. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/8df66d62bd41984d0970e8236882080c5085ac937d07a29779463d6a03b37c76.source +97 -0
  60. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/9962f2d1b3a85137bd40cc835725600b39f89ee02a27c06b2e0402e1eafde115.source +277 -0
  61. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a1090ea066b792cb201739f4994e37bf7c12f52e805239a1eab87f18a537f266.source +171 -0
  62. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/a5241091650134d0919897e086a606a3846c8b2b54f8b4c84a8696c145ac3e9e.source +425 -0
  63. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/af19fa4c6e34d5540028be4b17600161283af3a2a2dfc61a3fdc0834436a0c14.source +99 -0
  64. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3713d78e2b2f93905ff6be8d58a8d0a2f42dea322eb67c987fda22ff6a084d5.source +325 -0
  65. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/b3849943c3aff102d1af815631fc843a2a52c17646a4b04696ece323e483bbec.source +332 -0
  66. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/bac735a3803a6650b7f1eb8f587e6dc5c68ea055000c3c99a11789438b78e837.source +43 -0
  67. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d32c24d61eb8b33c73e5a8fc7e4dc7ef437aed238ee4371c2b00617e6104090a.source +154 -0
  68. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/d5170dc9a9879fda04fdf91d8a110d4efb6787ee38dda596d4da899883d36a72.source +531 -0
  69. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/dc59bcd7bb397be63ba2f2c3fec23f3782dcb17d2d75a9981eeced761f33eb32.source +439 -0
  70. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/eb5ff8cf66b92aec7bf6726781d8df74faa2b030c3aaac17d6b00c1fc04c9b32.source +323 -0
  71. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f10615a6fae18436e70d1201b2d44fdae3174a3aa7bb4db97dea1b3ed291d721.source +433 -0
  72. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f36d3e271668bc1260c2823542d22dbf1d0f7041ff16b1ac175bce2256e582ca.source +280 -0
  73. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/f94449380af09f31ac352152007146a75cc80bae8d6411ffe57b2c56f933b0ff.source +117 -0
  74. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/fe4dfd81178fd6b648335f6405d9ede71319aebde796008e50db21f8adf44c99.source +160 -0
  75. package/scripts/.mimosa/hook-state/sess_aeaecb58-8833-4b83-b93c-050467d21e03.mtxwtxme-61028-1f4519fbc4.baseline/ffb4e3aa548cfdadabaa44ab599f9b6a3ca6288176a2cc7a4b2fc2a12b5fb848.source +101 -0
  76. package/scripts/_audit/detached-start.mjs +103 -0
  77. package/scripts/_audit/guard-upstream-liveness.mjs +104 -0
  78. package/scripts/_dbg-perms-e2e.mjs +10 -3
  79. package/scripts/_dbg-protocol-matrix.mjs +453 -441
  80. package/scripts/_dbg-render-regression.mjs +151 -130
  81. package/scripts/_dbg-town-agents-probe.mjs +60 -0
  82. package/scripts/_dbg-visual-acceptance.mjs +63 -0
  83. package/scripts/e2e-agent-team.mjs +5 -3
  84. package/scripts/test-plugin-hardening.mjs +32 -0
  85. package/scripts/three-system-e2e.mjs +6 -6
  86. package/server/plugins/dev-stability-guard.ts +40 -63
  87. package/server/plugins-builtin/rag-bridge/index.mjs +9 -2
  88. package/server/utils/stability-severity.ts +66 -0
  89. package/.output/public/_nuxt/DVsjtsvZ2.js +0 -1
  90. package/.output/public/_nuxt/builds/meta/a66fdef7-dcfd-453b-b1f6-543c7dfd98d5.json +0 -1
  91. package/.output/public/_nuxt/default.DE_vT238.css +0 -1
@@ -1,441 +1,453 @@
1
- /**
2
- * 全协议连通矩阵实测(真实模拟工况;DAQ 只读 + DCW 读/写闭环)。
3
- * NO_PROXY='*' node scripts/_dbg-protocol-matrix.mjs [--base http://127.0.0.1:3000]
4
- *
5
- * 前置(模拟工况,全部在跑):
6
- * MQTT :1883(aw/sim/temp 每 2s) · HTTP :1889(/api/value;/api/setpoint)
7
- * ModbusTCP :1502(40001 压力 f32 0.6~1.1 / 40003 温度 f32 165~175)
8
- * ModbusRTU :15030(40001 f32 40.25↔44.25) · OPC UA :4840(ns=2;s=AW.Temp/AW.SetTemp)
9
- * PLC 涂布产线 :15040(PV 温度 40001 / SP 温度 40021 可写,一阶热惯性)
10
- *
11
- * 矩阵:
12
- * DAQ 读取 ×5(mqtt/http/modbus-tcp/modbus-rtu/opcua):test-connection → 采样落库(Timescale)→ WS 帧
13
- * DCW 读/写 ×4:modbus-tcp(PLC 真闭环 SP→PV 收敛)/ opcua(回读)/ mqtt(发布+订阅验证)/ http(POST 落达验证)
14
- * 联锁:配方窗口负例(越窗 400)
15
- */
16
- const BASE = (() => {
17
- const i = process.argv.indexOf('--base')
18
- return i > 0 ? process.argv[i + 1] : 'http://127.0.0.1:3000'
19
- })()
20
- const WS_BASE = BASE.replace(/^http/, 'ws')
21
- const TAG = Date.now().toString(36)
22
- import net from 'node:net'
23
- import { spawnSync } from 'node:child_process'
24
- import fs from 'node:fs'
25
- import { spawn } from 'node:child_process'
26
- import { resolve } from 'node:path'
27
-
28
- let failures = 0
29
- let passed = 0
30
- const results = []
31
- const check = (id, name, ok, detail = '') => {
32
- console.log(` ${ok ? 'PASS' : 'FAIL'} ${id} ${name}${detail ? ` — ${detail}` : ''}`)
33
- results.push({ id, name, ok, detail })
34
- ok ? passed++ : failures++
35
- }
36
- const sleep = ms => new Promise(r => setTimeout(r, ms))
37
-
38
- const api = async (method, path, { body, token } = {}, attempt = 0) => {
39
- const headers = { 'content-type': 'application/json' }
40
- if (token) headers.authorization = `Bearer ${token}`
41
- try {
42
- const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined })
43
- return { status: res.status, ...(await res.json().catch(() => ({}))) }
44
- }
45
- catch (err) {
46
- // dev server 可能正被外部击杀/自愈重启:短重试跨过死亡窗口
47
- if (attempt < 3) { await sleep(2500); return api(method, path, { body, token }, attempt + 1) }
48
- throw err
49
- }
50
- }
51
-
52
- async function waitUntil(name, cond, timeoutMs = 30_000, intervalMs = 800) {
53
- const deadline = Date.now() + timeoutMs
54
- let last = null
55
- while (Date.now() < deadline) {
56
- try { last = await cond(); if (last) return last } catch (e) { last = e }
57
- await sleep(intervalMs)
58
- }
59
- throw new Error(`timeout:${name} last=${String(last).slice(0, 160)}`)
60
- }
61
-
62
- /** AEP WS(全局 peer:daq.reading/daq.frame 等场景帧) */
63
- function openAep(token) {
64
- const frames = []
65
- const ws = new WebSocket(`${WS_BASE}/api/workshop/ws?token=${encodeURIComponent(token)}`)
66
- ws.addEventListener('message', (ev) => {
67
- try {
68
- const e = JSON.parse(ev.data)
69
- if (e.type && e.type !== 'pong') frames.push(e)
70
- }
71
- catch { /* ignore */ }
72
- })
73
- return { ws, frames, ready: new Promise(r => ws.addEventListener('open', () => r())) }
74
- }
75
-
76
- async function main() {
77
- console.log(`\n━━━ 全协议矩阵实测 @ ${BASE} (tag=${TAG}) ━━━`)
78
-
79
- // ── P0. 模拟工况 preflight(自愈):并行会话的端口清理可能杀掉模拟器,开跑前逐一拉活 ──
80
- const SIMS = [
81
- { name: 'mqtt-http', ports: [18830, 1889], cmd: 'scripts/dev-protocol-simulators.mjs', env: { MQTT_SIM_PORT: '18830', HTTP_SIM_PORT: '1889' }, log: 'sim-mqtt-http.log' },
82
- { name: 'modbus-tcp', ports: [1502], cmd: 'scripts/dev-modbus-simulator.mjs', env: {}, log: 'sim-modbus-tcp.log' },
83
- { name: 'opcua', ports: [4840], cmd: 'scripts/dev-opcua-simulator.mjs', env: { OPCUA_SIM_PORT: '4840' }, log: 'sim-opcua.log' },
84
- { name: 'plc', ports: [15040], cmd: 'scripts/dev-plc-simulator.mjs', env: {}, args: ['--port', '15040'], log: 'sim-plc.log' },
85
- { name: 'rtu', ports: [15030], cmd: 'scripts/_rtu-mini-slave.mjs', env: {}, log: 'sim-rtu.log' },
86
- ]
87
- const portUp = (port) => new Promise((res) => {
88
- const s = net.connect(port, '127.0.0.1')
89
- const t = setTimeout(() => { s.destroy(); res(false) }, 1000)
90
- s.on('connect', () => { clearTimeout(t); s.destroy(); res(true) })
91
- s.on('error', () => { clearTimeout(t); res(false) })
92
- })
93
- const portOwners = (port) => {
94
- // netstat 找出该端口所有 LISTENING 持有者(Windows 双绑竞态:僵尸实例会导致读写分家)
95
- const out = spawnSync('cmd', ['/c', `netstat -ano | findstr :${port} | findstr LISTENING`], { encoding: 'utf-8' })
96
- const pids = new Set()
97
- for (const line of (out.stdout ?? '').split('\n')) {
98
- const m = line.trim().match(/\s(\d+)\s*$/)
99
- if (m) pids.add(m[1])
100
- }
101
- return [...pids]
102
- }
103
- for (const sim of SIMS) {
104
- // 清场:杀掉端口上所有旧持有者(僵尸/双绑),保证唯一新实例
105
- for (const pid of portOwners(sim.ports[0])) {
106
- spawnSync('cmd', ['/c', `taskkill /F /PID ${pid}`])
107
- console.log(` [preflight] ${sim.name} 清掉旧持有者 pid=${pid}`)
108
- await sleep(300)
109
- }
110
- let up = false
111
- {
112
- const fd = fs.openSync(resolve('.AgentWorkShop/data', sim.log), 'a')
113
- const child = spawn(process.execPath, [sim.cmd, ...(sim.args ?? [])], {
114
- cwd: process.cwd(), env: { ...process.env, NO_PROXY: '127.0.0.1,localhost', ...sim.env },
115
- detached: true, stdio: ['ignore', fd, fd],
116
- })
117
- child.unref()
118
- console.log(` [preflight] ${sim.name} → 拉起唯一实例 pid=${child.pid}`)
119
- for (let i = 0; i < 15 && !up; i++) { await sleep(1000); up = await portUp(sim.ports[0]) }
120
- if (!up) throw new Error(`preflight:${sim.name} 拉起失败`)
121
- }
122
- }
123
- check('0.0', '模拟工况 preflight(六端口唯一实例)', true)
124
-
125
- // dev server 健康(被外部击杀 → 清僵尸 guard 自愈;最多等 90s)
126
- {
127
- let ok = false
128
- for (let i = 0; i < 30 && !ok; i++) {
129
- try { ok = (await fetch(`${BASE}/api/health`)).status === 200 } catch { ok = false }
130
- if (!ok) {
131
- if (i === 0) {
132
- const lockPath = resolve('.AgentWorkShop/.runtime/aw.lock')
133
- let lockPid = 0
134
- try { lockPid = Number(JSON.parse(fs.readFileSync(lockPath, 'utf-8')).pid ?? 0) } catch { /* 无锁 */ }
135
- if (lockPid) {
136
- spawnSync('cmd', ['/c', `taskkill /F /PID ${lockPid}`])
137
- try { fs.unlinkSync(lockPath) } catch { /* 无锁 */ }
138
- console.log(` [preflight] dev server 僵尸 guard(pid=${lockPid})已清,重启…`)
139
- const fd = fs.openSync(resolve('.AgentWorkShop/data/dev-server.log'), 'a')
140
- const child = spawn(process.execPath, ['bin/aw.mjs', 'dev'], {
141
- cwd: process.cwd(), env: { ...process.env, NO_PROXY: '127.0.0.1,localhost' },
142
- detached: true, stdio: ['ignore', fd, fd],
143
- })
144
- child.unref()
145
- }
146
- }
147
- await sleep(3000)
148
- }
149
- }
150
- if (!ok) throw new Error('preflight:dev server 90s 未恢复')
151
- }
152
- console.log(' [preflight] dev server 健康')
153
-
154
- // ── 0. 注册 + 产线 ──
155
- const reg = await api('POST', '/api/users/register', {
156
- body: { email: `proto-${TAG}@test.local`, password: 'Passw0rd!123', name: `proto-${TAG}` },
157
- })
158
- const token = reg.data?.token
159
- if (!token) throw new Error(`注册失败: ${JSON.stringify(reg).slice(0, 200)}`)
160
-
161
- const line = (await api('POST', '/api/workshop/dcw/lines', { body: { name: `全协议产线-${TAG}` }, token })).data?.line
162
- check('0.1', '建产线', Boolean(line?.id))
163
-
164
- // ── 0.2 产线级权限(新版权限模型:普通用户默认无权,admin 逐线授 operate) ──
165
- const ADMIN_PASS = process.env.AW_ADMIN_PASS ?? 'admin123'
166
- const adminLogin = await api('POST', '/api/users/login', {
167
- body: { email: 'admin@awshop.local', password: ADMIN_PASS },
168
- }).catch(() => null)
169
- const adminTok = adminLogin?.data?.token
170
- if (adminTok) {
171
- const granted = await api('PUT', '/api/workshop/permissions', {
172
- body: { userId: reg.data?.user?.id, grants: [{ lineId: line.id, mode: 'operate' }] }, token: adminTok,
173
- }).catch(() => null)
174
- check('0.2', 'admin 授予测试用户产线 operate 权限', Boolean(granted?.data?.userId), granted?.message ?? '')
175
- }
176
- else {
177
- console.log(' [skip] admin 登录失败(可设 AW_ADMIN_PASS),以普通用户无权态继续(写入段将按权限模型拒绝)')
178
- }
179
-
180
- // ── 1. DAQ 五协议节点(配置连接 + 连接测试) ──
181
- const daqDefs = [
182
- { id: 'mqtt', driver: 'mqtt', templateRef: 'daq-temp-tc', name: `MQTT温度-${TAG}`, expect: [30, 75],
183
- cfg: { host: '127.0.0.1', port: 18830, topic: 'aw/sim/temp', jsonPath: 'data.temp' } },
184
- { id: 'http', driver: 'http', templateRef: 'daq-temp-tc', name: `HTTP流量-${TAG}`, expect: [38, 48],
185
- cfg: { url: 'http://127.0.0.1:1889/api/value', jsonPath: 'data.value' } },
186
- { id: 'mbtcp', driver: 'modbus-tcp', templateRef: 'daq-pressure-tx', name: `ModbusTCP压力-${TAG}`, expect: [0.5, 1.25],
187
- cfg: { host: '127.0.0.1', port: 1502, unitId: 1, register: 40001, registerType: 'holding', dataType: 'float32', byteOrder: 'big' } },
188
- { id: 'rtu', driver: 'modbus-rtu', templateRef: 'daq-temp-tc', name: `RTU温度-${TAG}`, expect: [60, 72],
189
- cfg: { host: '127.0.0.1', port: 15030, unitId: 1, register: 40001, registerType: 'holding', dataType: 'uint16' } },
190
- { id: 'opcua', driver: 'opcua', templateRef: 'daq-temp-tc', name: `OPCUA温度-${TAG}`, expect: [140, 200],
191
- cfg: { endpoint: 'opc.tcp://127.0.0.1:4840', nodeId: 'ns=2;s=AW.Temp' } },
192
- { id: 'plcpv', driver: 'modbus-tcp', templateRef: 'daq-temp-tc', name: `PLC-PV温度-${TAG}`, expect: [0, 260],
193
- cfg: { host: '127.0.0.1', port: 15040, unitId: 1, register: 40001, registerType: 'holding', dataType: 'float32', byteOrder: 'big' } },
194
- ]
195
- const daqNodes = {}
196
- for (const d of daqDefs) {
197
- const t = await api('POST', '/api/workshop/daq/test-driver', { body: { driver: d.driver, driverConfig: d.cfg }, token })
198
- check(`1.${d.id}-test`, `${d.driver} 连接测试`, t.data?.test?.ok === true, JSON.stringify(t.data?.test ?? t.message ?? {}).slice(0, 120))
199
- const created = await api('POST', '/api/workshop/daq', {
200
- body: { templateRef: d.templateRef, name: d.name, driver: d.driver, driverConfig: d.cfg, lineId: line.id, intervalMs: 800, publishIntervalMs: 0 }, token,
201
- })
202
- daqNodes[d.id] = created.data?.node
203
- check(`1.${d.id}-create`, `${d.driver} 节点创建`, Boolean(daqNodes[d.id]?.id), created.message ?? '')
204
- }
205
-
206
- // ── 2. DCW 节点(四路下发通道) ──
207
- const dcwDefs = [
208
- { id: 'plcsp', driver: 'modbus-tcp', name: `PLC-SP温度-${TAG}`, recipe: true,
209
- cfg: { host: '127.0.0.1', port: 15040, unitId: 1, register: 40021, dataType: 'float32', byteOrder: 'big' } },
210
- { id: 'ocusp', driver: 'opcua', name: `OPCUA-SP-${TAG}`,
211
- cfg: { endpoint: 'opc.tcp://127.0.0.1:4840', nodeId: 'ns=2;s=AW.SetTemp' } },
212
- { id: 'mqsp', driver: 'mqtt', name: `MQTT-SP-${TAG}`,
213
- cfg: { host: '127.0.0.1', port: 18830, topic: `aw/sim/setpoint-${TAG}`, jsonKey: 'setpoint', qos: 1 } },
214
- { id: 'htsp', driver: 'http', name: `HTTP-SP-${TAG}`,
215
- cfg: { url: 'http://127.0.0.1:1889/api/setpoint', bodyKey: 'setpoint' } },
216
- ]
217
- const dcwNodes = {}
218
- for (const d of dcwDefs) {
219
- const t = await api('POST', '/api/workshop/dcw/test-driver', { body: { driver: d.driver, driverConfig: d.cfg }, token })
220
- check(`2.${d.id}-test`, `DCW ${d.driver} 连接测试`, t.data?.test?.ok === true, JSON.stringify(t.data?.test ?? t.message ?? {}).slice(0, 120))
221
- const created = await api('POST', '/api/workshop/dcw', {
222
- body: { templateRef: 'dcw-temp-sp', name: d.name, driver: d.driver, driverConfig: d.cfg, lineId: line.id }, token,
223
- })
224
- dcwNodes[d.id] = created.data?.node
225
- check(`2.${d.id}-create`, `DCW ${d.driver} 节点创建`, Boolean(dcwNodes[d.id]?.id), created.message ?? '')
226
- }
227
-
228
- // ── 3. 产品/配方/开跑(激活数采门控 + 配方窗口) ──
229
- // 预置:先把 PLC SP 裸写到 25℃,让 PV 从上轮残留收敛到低温 —— 6.4c 的"爬升"才有确定性基线
230
- {
231
- const net = await import('node:net')
232
- await new Promise((resolve) => {
233
- const sock = net.connect(15040, '127.0.0.1')
234
- const b = Buffer.alloc(4)
235
- b.writeFloatBE(25)
236
- const words = [b.readUInt16BE(0), b.readUInt16BE(2)]
237
- const frame = Buffer.alloc(17)
238
- frame.writeUInt16BE(1, 0) // txn
239
- frame.writeUInt16BE(0, 2) // proto
240
- frame.writeUInt16BE(11, 4) // len(unit+PDU)
241
- frame.writeUInt8(1, 6) // unit
242
- frame.writeUInt8(16, 7) // FC16
243
- frame.writeUInt16BE(20, 8) // start = 40021-40001
244
- frame.writeUInt16BE(2, 10) // qty words
245
- frame.writeUInt8(4, 12) // byte count
246
- frame.writeUInt16BE(words[0], 13)
247
- frame.writeUInt16BE(words[1], 15)
248
- sock.on('connect', () => sock.write(frame))
249
- sock.on('data', () => { sock.destroy(); resolve() })
250
- sock.on('error', () => resolve())
251
- setTimeout(() => { sock.destroy(); resolve() }, 3000)
252
- })
253
- await sleep(22_000) // τ=8s ×3,PV 充分回落(<60)
254
- }
255
- const prod = (await api('POST', '/api/workshop/dcw/products', { body: { name: `全协议产品-${TAG}`, lineId: line.id }, token })).data?.product
256
- const plcSp = dcwNodes.plcsp
257
- const recipe = (await api('POST', '/api/workshop/dcw/recipes', {
258
- body: {
259
- productId: prod.id, name: `全协议配方-${TAG}`,
260
- params: [{ templateRef: 'dcw-temp-sp', nodeId: plcSp.id, value: 180, min: 176, max: 188 }],
261
- daqWindows: [{ nodeId: daqNodes.plcpv.id, min: 100, max: 260 }],
262
- }, token,
263
- })).data?.recipe
264
- check('3.1', '产品+配方(PLC-SP 绑工艺窗 176~188)', Boolean(prod?.id && recipe?.id))
265
- const start = await api('POST', `/api/workshop/dcw/lines/${line.id}/start`, { body: { recipeId: recipe.id }, token })
266
- check('3.2', '产线开跑(数采门控激活)', start.code === 0, start.message ?? '')
267
-
268
- // ── 4. DAQ 采样落库 + 数值域 + WS 帧 ──
269
- const aep = openAep(token)
270
- await aep.ready
271
- await sleep(9000)
272
- for (const d of daqDefs) {
273
- const node = daqNodes[d.id]
274
- const s = await api('GET', `/api/workshop/daq/${node.id}/samples?bucketMs=1000`, { token })
275
- const points = s.data?.points ?? s.data ?? []
276
- const n = Array.isArray(points) ? points.length : 0
277
- const lastVal = n > 0 ? Number(Object.values(points[n - 1] ?? {})[1] ?? NaN) : NaN
278
- const inRange = Number.isFinite(lastVal) && lastVal >= d.expect[0] && lastVal <= d.expect[1]
279
- check(`4.${d.id}`, `${d.driver} 持续采样落库(Timescale)`, n >= 4 && inRange,
280
- `points=${n} last=${lastVal} 期望域[${d.expect.join(',')}] ${s.message ?? ''}`)
281
- }
282
- const daqFrames = aep.frames.filter(f => f.type === 'daq.reading' || f.type === 'daq.frame')
283
- check('4.ws', 'WS 实时推送(daq.reading/frame 场景帧)', daqFrames.length >= 10, `frames=${daqFrames.length} total=${aep.frames.length}`)
284
-
285
- // ── 5. DCW 读(读回设备参数);响应形状 { read: {...} } ──
286
- const rPlc = await api('POST', `/api/workshop/dcw/${dcwNodes.plcsp.id}/read`, { token })
287
- const plcRead = Number(rPlc.data?.read?.value ?? rPlc.data?.value ?? NaN)
288
- check('5.1', 'DCW Modbus 读 SP 寄存器', rPlc.code === 0 && Number.isFinite(plcRead), `value=${plcRead} ${JSON.stringify(rPlc.data ?? {}).slice(0, 120)} ${rPlc.message ?? ''}`)
289
- const rOpc = await api('POST', `/api/workshop/dcw/${dcwNodes.ocusp.id}/read`, { token })
290
- const opcRead = Number(rOpc.data?.read?.value ?? rOpc.data?.value ?? NaN)
291
- check('5.2', 'DCW OPC UA 读节点', rOpc.code === 0 && Number.isFinite(opcRead), `value=${opcRead} ${rOpc.message ?? ''}`)
292
- const rMq = await api('POST', `/api/workshop/dcw/${dcwNodes.mqsp.id}/read`, { token })
293
- const mqReadVal = rMq.data?.read?.value ?? rMq.data?.value
294
- check('5.3', 'DCW MQTT 读=能力外优雅报错(仅下行)', !Number.isFinite(Number(mqReadVal)) && (rMq.code !== 0 || rMq.data?.read?.ok === false || rMq.data?.ok === false), `code=${rMq.code} read=${JSON.stringify(rMq.data?.read ?? {}).slice(0, 100)}`)
295
-
296
- // ── 6. DCW 写 ×4 ──
297
- // 6.1 MQTT 下行:先建订阅(不等待)→ 驱动写 → 收到的报文即真实链路证据
298
- let mqttRx = null
299
- try {
300
- const { default: mqtt } = await import('mqtt')
301
- const sub = await new Promise((resolve) => {
302
- const c = mqtt.connect('mqtt://127.0.0.1:18830', { connectTimeout: 4000 })
303
- const timer = setTimeout(() => { c.end(true); resolve(null) }, 8000)
304
- c.on('connect', () => c.subscribe(`aw/sim/setpoint-${TAG}`, { qos: 1 }, () => { clearTimeout(timer); resolve(c) }))
305
- c.on('error', () => { clearTimeout(timer); resolve(null) })
306
- })
307
- if (sub) {
308
- const wMq = await api('POST', `/api/workshop/dcw/${dcwNodes.mqsp.id}/write`, { body: { value: 165.5 }, token })
309
- check("6.1a", "DCW MQTT 下发执行", wMq.code === 0 && wMq.data?.outcome?.ok === true, JSON.stringify(wMq.data ?? wMq.message ?? {}).slice(0, 140))
310
- mqttRx = await new Promise((resolve) => {
311
- const timer = setTimeout(() => { sub.end(true); resolve(null) }, 10_000)
312
- sub.on('message', (_t, payload) => { clearTimeout(timer); sub.end(true); resolve(payload.toString()) })
313
- })
314
- }
315
- }
316
- catch { /* mqtt 包不可用则跳过订阅验证 */ }
317
- // broker 侧确定性验收:模拟器把真实路由落盘(broker 收到即证据,不依赖订阅端时序)
318
- let auditHit = null
319
- {
320
- const auditPath = resolve('.AgentWorkShop/data/sim-setpoint-routes.log')
321
- for (let i = 0; i < 10 && !auditHit; i++) {
322
- await sleep(1000)
323
- try {
324
- const content = fs.readFileSync(auditPath, 'utf-8')
325
- auditHit = content.split('\n').reverse().find(l => l.includes(`aw/sim/setpoint-${TAG}`)) ?? null
326
- }
327
- catch { /* 文件未生成 */ }
328
- }
329
- }
330
- check('6.1b', 'DCW MQTT 真实报文抵达 Broker(broker 路由审计)', auditHit !== null && auditHit.includes('165.5'), `audit=${auditHit?.trim() ?? 'null'}`)
331
- // 环境噪声重试:broker 实例可能在订阅/发布之间被杀换新,换全新订阅再写一次
332
- if (auditHit === null) {
333
- try {
334
- const { default: mqtt2 } = await import('mqtt')
335
- const sub2 = await new Promise((resolve) => {
336
- const c = mqtt2.connect('mqtt://127.0.0.1:18830', { connectTimeout: 3000 })
337
- const timer = setTimeout(() => { c.end(true); resolve(null) }, 6000)
338
- c.on('connect', () => c.subscribe(`aw/sim/setpoint-${TAG}`, { qos: 1 }, () => { clearTimeout(timer); resolve(c) }))
339
- c.on('error', () => { clearTimeout(timer); resolve(null) })
340
- })
341
- if (sub2) {
342
- await api('POST', `/api/workshop/dcw/${dcwNodes.mqsp.id}/write`, { body: { value: 165.5 }, token })
343
- mqttRx = await new Promise((resolve) => {
344
- const timer = setTimeout(() => { sub2.end(true); resolve(null) }, 8000)
345
- sub2.on('message', (_t, payload) => { clearTimeout(timer); sub2.end(true); resolve(payload.toString()) })
346
- })
347
- check('6.1b-retry', 'DCW MQTT 报文抵达(重订重写)', mqttRx !== null && mqttRx.includes('165.5'), `payload=${mqttRx}`)
348
- }
349
- }
350
- catch { /* ignore */ }
351
- }
352
-
353
- // 6.2 HTTP 下发:经驱动 POST,模拟器日志留痕
354
- const wHt = await api('POST', `/api/workshop/dcw/${dcwNodes.htsp.id}/write`, { body: { value: 168.25 }, token })
355
- check("6.2a", "DCW HTTP 下发执行", wHt.code === 0 && wHt.data?.outcome?.ok === true, JSON.stringify(wHt.data ?? wHt.message ?? {}).slice(0, 140))
356
-
357
- // 6.3 OPC UA 写 + 回读校验
358
- const wOpc = await api('POST', `/api/workshop/dcw/${dcwNodes.ocusp.id}/write`, { body: { value: 172.5 }, token })
359
- check("6.3a", "DCW OPC UA 写入", wOpc.code === 0 && wOpc.data?.outcome?.ok === true, JSON.stringify(wOpc.data ?? wOpc.message ?? {}).slice(0, 140))
360
- await sleep(1200)
361
- const rOpc2 = await api('POST', `/api/workshop/dcw/${dcwNodes.ocusp.id}/read`, { token })
362
- const opc2 = Number(rOpc2.data?.read?.value ?? rOpc2.data?.value ?? NaN)
363
- check('6.3b', 'DCW OPC UA 回读=172.5±1', Number.isFinite(opc2) && Math.abs(opc2 - 172.5) <= 1, `read=${opc2}`)
364
-
365
- // 6.4 PLC Modbus 写(配方窗内)+ 真闭环 SP→PV 收敛
366
- const wPlc = await api('POST', `/api/workshop/dcw/${plcSp.id}/write`, { body: { value: 182 }, token })
367
- check("6.4a", "DCW Modbus 写 SP=182(配方窗内)", wPlc.code === 0 && wPlc.data?.outcome?.ok === true, JSON.stringify(wPlc.data ?? wPlc.message ?? {}).slice(0, 140))
368
- const wPlcBad = await api('POST', `/api/workshop/dcw/${plcSp.id}/write`, { body: { value: 195 }, token })
369
- check('6.4b', '配方窗联锁负例(195 越窗 → 400)', wPlcBad.status === 400, `status=${wPlcBad.status} msg=${String(wPlcBad.message ?? '').slice(0, 80)}`)
370
-
371
- // PLC 一阶热惯性:PV 从 ~26(预置衰减后)随配方 SP=180 + 写 182 爬升收敛
372
- const pvNode = daqNodes.plcpv
373
- const pvStart = await api('GET', `/api/workshop/daq/${pvNode.id}/samples?bucketMs=1000`, { token })
374
- const pv0 = (pvStart.data?.points ?? []).slice(-3).map(p => Number(Object.values(p)[1])).filter(Number.isFinite).at(-1)
375
- let pvLastSeen = null
376
- let pvEnd = null
377
- let pvDiag = ''
378
- try {
379
- pvEnd = await waitUntil('PV 收敛', async () => {
380
- const s = await api('GET', `/api/workshop/daq/${pvNode.id}/samples?bucketMs=1000`, { token })
381
- const pts = s.data?.points ?? []
382
- const v = pts.slice(-2).map(p => Number(Object.values(p)[1])).find(Number.isFinite)
383
- if (Number.isFinite(v)) pvLastSeen = v
384
- const view = await api('GET', '/api/workshop/daq', { token })
385
- const me = (Array.isArray(view.data) ? view.data : (view.data?.nodes ?? [])).find(x => x.id === pvNode.id)
386
- const lines = await api('GET', '/api/workshop/dcw/lines', { token })
387
- const ln = (Array.isArray(lines.data) ? lines.data : (lines.data?.lines ?? [])).find(l => l.id === line.id)
388
- pvDiag = `pts=${pts.length} node=${me?.state} err=${String(me?.lastError ?? '-').slice(0, 40)} lineRun=${ln && (ln.activeRun || ln.run) ? 'on' : 'off'}`
389
- return Number.isFinite(v) && v >= 170 ? v : null
390
- }, 90_000, 2500)
391
- } catch { /* keep null */ }
392
- // 物理真值回退:DAQ 观测链若被环境中断(模拟器运行中被杀/ revived),直接裸读寄存器验证闭环
393
- let rawSp = null
394
- let rawPv = null
395
- if (!pvEnd) {
396
- await new Promise((resolveRaw) => {
397
- const sock = net.connect(15040, '127.0.0.1')
398
- let buf = Buffer.alloc(0)
399
- sock.on('connect', () => sock.write(Buffer.from([0, 1, 0, 0, 0, 6, 1, 3, 0, 0, 0, 0x16])))
400
- sock.on('data', (d) => {
401
- buf = Buffer.concat([buf, d])
402
- if (buf.length >= 9 + 44) {
403
- rawPv = Number(buf.subarray(9, 13).readFloatBE(0).toFixed(1))
404
- rawSp = Number(buf.subarray(49, 53).readFloatBE(0).toFixed(1))
405
- sock.destroy(); resolveRaw()
406
- }
407
- })
408
- sock.on('error', () => resolveRaw())
409
- setTimeout(() => { sock.destroy(); resolveRaw() }, 4000)
410
- })
411
- }
412
- const loopProven = (Number.isFinite(pv0) && pv0 < 60 && Number.isFinite(pvEnd) && pvEnd >= 170)
413
- || (rawSp !== null && Math.abs(rawSp - 182) <= 2 && rawPv !== null && Math.abs(rawPv - rawSp) <= 6)
414
- check('6.4c', '真实闭环:PLC PV 随 SP 收敛(≈26→≥170)', loopProven, `pv0=${pv0} pvEnd=${pvEnd} lastSeen=${pvLastSeen} | rawSP=${rawSp} rawPV=${rawPv} ${pvDiag}`)
415
-
416
- // ── 7. 写后节点视图一致性 + journal ──
417
- const list = await api('GET', '/api/workshop/dcw', { token })
418
- const nodes = Array.isArray(list.data) ? list.data : (list.data?.nodes ?? [])
419
- const plcNow = nodes.find(n => n.id === plcSp.id)
420
- check('7.1', 'DCW 节点 readValue 收敛 182±2', plcNow && Math.abs(Number(plcNow.readValue) - 182) <= 2, `readValue=${plcNow?.readValue}`)
421
- const journal = await api('GET', `/api/workshop/dcw/journal?nodeId=${plcSp.id}`, { token })
422
- check('7.2', '写账本留痕(journal anchors)', (journal.data?.anchors ?? []).length >= 1, `anchors=${(journal.data?.anchors ?? []).length}`)
423
-
424
- // ── 清理 ──
425
- await api('POST', `/api/workshop/dcw/lines/${line.id}/stop`, { token }).catch(() => {})
426
- for (const n of Object.values(daqNodes)) await api('DELETE', `/api/workshop/daq/${n.id}`, { token }).catch(() => {})
427
- for (const n of Object.values(dcwNodes)) await api('DELETE', `/api/workshop/dcw/${n.id}`, { token }).catch(() => {})
428
- await api('DELETE', `/api/workshop/dcw/lines/${line.id}`, { token }).catch(() => {})
429
-
430
- console.log(`\n━━━ 矩阵结果: ${passed} passed / ${failures} failed ━━━`)
431
- if (failures) {
432
- console.log('失败项:')
433
- for (const r of results.filter(x => !x.ok)) console.log(` ${r.id} ${r.name} — ${r.detail}`)
434
- }
435
- process.exit(failures === 0 ? 0 : 1)
436
- }
437
-
438
- main().catch((err) => {
439
- console.error('FATAL', err)
440
- process.exit(1)
441
- })
1
+ /**
2
+ * 全协议连通矩阵实测(真实模拟工况;DAQ 只读 + DCW 读/写闭环)。
3
+ * NO_PROXY='*' node scripts/_dbg-protocol-matrix.mjs [--base http://127.0.0.1:3000]
4
+ *
5
+ * 前置(模拟工况,全部在跑):
6
+ * MQTT :1883(aw/sim/temp 每 2s) · HTTP :1889(/api/value;/api/setpoint)
7
+ * ModbusTCP :1502(40001 压力 f32 0.6~1.1 / 40003 温度 f32 165~175)
8
+ * ModbusRTU :15030(40001 f32 40.25↔44.25) · OPC UA :4840(ns=2;s=AW.Temp/AW.SetTemp)
9
+ * PLC 涂布产线 :15040(PV 温度 40001 / SP 温度 40021 可写,一阶热惯性)
10
+ *
11
+ * 矩阵:
12
+ * DAQ 读取 ×5(mqtt/http/modbus-tcp/modbus-rtu/opcua):test-connection → 采样落库(Timescale)→ WS 帧
13
+ * DCW 读/写 ×4:modbus-tcp(PLC 真闭环 SP→PV 收敛)/ opcua(回读)/ mqtt(发布+订阅验证)/ http(POST 落达验证)
14
+ * 联锁:配方窗口负例(越窗 400)
15
+ */
16
+ const BASE = (() => {
17
+ const i = process.argv.indexOf('--base')
18
+ return i > 0 ? process.argv[i + 1] : 'http://127.0.0.1:3000'
19
+ })()
20
+ const WS_BASE = BASE.replace(/^http/, 'ws')
21
+ const TAG = Date.now().toString(36)
22
+ import net from 'node:net'
23
+ import { spawnSync } from 'node:child_process'
24
+ import fs from 'node:fs'
25
+ import { spawn } from 'node:child_process'
26
+ import { resolve } from 'node:path'
27
+
28
+ let failures = 0
29
+ let passed = 0
30
+ const results = []
31
+ const check = (id, name, ok, detail = '') => {
32
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${id} ${name}${detail ? ` — ${detail}` : ''}`)
33
+ results.push({ id, name, ok, detail })
34
+ ok ? passed++ : failures++
35
+ }
36
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
37
+
38
+ const api = async (method, path, { body, token } = {}, attempt = 0) => {
39
+ const headers = { 'content-type': 'application/json' }
40
+ if (token) headers.authorization = `Bearer ${token}`
41
+ try {
42
+ const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined })
43
+ return { status: res.status, ...(await res.json().catch(() => ({}))) }
44
+ }
45
+ catch (err) {
46
+ // dev server 可能正被外部击杀/自愈重启:短重试跨过死亡窗口
47
+ if (attempt < 3) { await sleep(2500); return api(method, path, { body, token }, attempt + 1) }
48
+ throw err
49
+ }
50
+ }
51
+
52
+ async function waitUntil(name, cond, timeoutMs = 30_000, intervalMs = 800) {
53
+ const deadline = Date.now() + timeoutMs
54
+ let last = null
55
+ while (Date.now() < deadline) {
56
+ try { last = await cond(); if (last) return last } catch (e) { last = e }
57
+ await sleep(intervalMs)
58
+ }
59
+ throw new Error(`timeout:${name} last=${String(last).slice(0, 160)}`)
60
+ }
61
+
62
+ /** AEP WS(全局 peer:daq.reading/daq.frame 等场景帧) */
63
+ function openAep(token) {
64
+ const frames = []
65
+ const ws = new WebSocket(`${WS_BASE}/api/workshop/ws?token=${encodeURIComponent(token)}`)
66
+ ws.addEventListener('message', (ev) => {
67
+ try {
68
+ const e = JSON.parse(ev.data)
69
+ if (e.type && e.type !== 'pong') frames.push(e)
70
+ }
71
+ catch { /* ignore */ }
72
+ })
73
+ return { ws, frames, ready: new Promise(r => ws.addEventListener('open', () => r())) }
74
+ }
75
+
76
+ async function main() {
77
+ console.log(`\n━━━ 全协议矩阵实测 @ ${BASE} (tag=${TAG}) ━━━`)
78
+
79
+ // ── P0. 模拟工况 preflight(自愈):并行会话的端口清理可能杀掉模拟器,开跑前逐一拉活 ──
80
+ const SIMS = [
81
+ { name: 'mqtt-http', ports: [18830, 1889], cmd: 'scripts/dev-protocol-simulators.mjs', env: { MQTT_SIM_PORT: '18830', HTTP_SIM_PORT: '1889' }, log: 'sim-mqtt-http.log' },
82
+ { name: 'modbus-tcp', ports: [1502], cmd: 'scripts/dev-modbus-simulator.mjs', env: {}, log: 'sim-modbus-tcp.log' },
83
+ { name: 'opcua', ports: [4840], cmd: 'scripts/dev-opcua-simulator.mjs', env: { OPCUA_SIM_PORT: '4840' }, log: 'sim-opcua.log' },
84
+ { name: 'plc', ports: [15040], cmd: 'scripts/dev-plc-simulator.mjs', env: {}, args: ['--port', '15040'], log: 'sim-plc.log' },
85
+ { name: 'rtu', ports: [15030], cmd: 'scripts/_rtu-mini-slave.mjs', env: {}, log: 'sim-rtu.log' },
86
+ ]
87
+ const portUp = (port) => new Promise((res) => {
88
+ const s = net.connect(port, '127.0.0.1')
89
+ const t = setTimeout(() => { s.destroy(); res(false) }, 1000)
90
+ s.on('connect', () => { clearTimeout(t); s.destroy(); res(true) })
91
+ s.on('error', () => { clearTimeout(t); res(false) })
92
+ })
93
+ const portOwners = (port) => {
94
+ // netstat 找出该端口所有 LISTENING 持有者(Windows 双绑竞态:僵尸实例会导致读写分家)
95
+ const out = spawnSync('cmd', ['/c', `netstat -ano | findstr :${port} | findstr LISTENING`], { encoding: 'utf-8' })
96
+ const pids = new Set()
97
+ for (const line of (out.stdout ?? '').split('\n')) {
98
+ const m = line.trim().match(/\s(\d+)\s*$/)
99
+ if (m) pids.add(m[1])
100
+ }
101
+ return [...pids]
102
+ }
103
+ for (const sim of SIMS) {
104
+ // 清场:杀掉端口上所有旧持有者(僵尸/双绑),保证唯一新实例
105
+ for (const pid of portOwners(sim.ports[0])) {
106
+ spawnSync('cmd', ['/c', `taskkill /F /PID ${pid}`])
107
+ console.log(` [preflight] ${sim.name} 清掉旧持有者 pid=${pid}`)
108
+ await sleep(300)
109
+ }
110
+ let up = false
111
+ {
112
+ const fd = fs.openSync(resolve('.AgentWorkShop/data', sim.log), 'a')
113
+ const child = spawn(process.execPath, [sim.cmd, ...(sim.args ?? [])], {
114
+ cwd: process.cwd(), env: { ...process.env, NO_PROXY: '127.0.0.1,localhost', ...sim.env },
115
+ detached: true, stdio: ['ignore', fd, fd],
116
+ })
117
+ child.unref()
118
+ console.log(` [preflight] ${sim.name} → 拉起唯一实例 pid=${child.pid}`)
119
+ for (let i = 0; i < 15 && !up; i++) { await sleep(1000); up = await portUp(sim.ports[0]) }
120
+ if (!up) throw new Error(`preflight:${sim.name} 拉起失败`)
121
+ }
122
+ }
123
+ check('0.0', '模拟工况 preflight(六端口唯一实例)', true)
124
+
125
+ // dev server 健康(被外部击杀 → 清僵尸 guard 自愈;最多等 90s)
126
+ {
127
+ let ok = false
128
+ for (let i = 0; i < 30 && !ok; i++) {
129
+ try { ok = (await fetch(`${BASE}/api/health`)).status === 200 } catch { ok = false }
130
+ if (!ok) {
131
+ if (i === 0) {
132
+ const lockPath = resolve('.AgentWorkShop/.runtime/aw.lock')
133
+ let lockPid = 0
134
+ try { lockPid = Number(JSON.parse(fs.readFileSync(lockPath, 'utf-8')).pid ?? 0) } catch { /* 无锁 */ }
135
+ if (lockPid) {
136
+ spawnSync('cmd', ['/c', `taskkill /F /PID ${lockPid}`])
137
+ try { fs.unlinkSync(lockPath) } catch { /* 无锁 */ }
138
+ console.log(` [preflight] dev server 僵尸 guard(pid=${lockPid})已清,重启…`)
139
+ const fd = fs.openSync(resolve('.AgentWorkShop/data/dev-server.log'), 'a')
140
+ const child = spawn(process.execPath, ['bin/aw.mjs', 'dev'], {
141
+ cwd: process.cwd(), env: { ...process.env, NO_PROXY: '127.0.0.1,localhost' },
142
+ detached: true, stdio: ['ignore', fd, fd],
143
+ })
144
+ child.unref()
145
+ }
146
+ }
147
+ await sleep(3000)
148
+ }
149
+ }
150
+ if (!ok) throw new Error('preflight:dev server 90s 未恢复')
151
+ }
152
+ console.log(' [preflight] dev server 健康')
153
+
154
+ // ── 0. 注册 + 产线 ──
155
+ const reg = await api('POST', '/api/users/register', {
156
+ body: { email: `proto-${TAG}@test.local`, password: 'Passw0rd!123', name: `proto-${TAG}` },
157
+ })
158
+ const token = reg.data?.token
159
+ if (!token) throw new Error(`注册失败: ${JSON.stringify(reg).slice(0, 200)}`)
160
+
161
+ // 角色前置:建线/建节点需 admin/editor(0.7.x 权限硬化);admin 把测试用户提为 editor
162
+ const admLogin = await api('POST', '/api/users/login', {
163
+ body: { email: 'admin@awshop.local', password: process.env.AW_ADMIN_PASS ?? 'admin123' },
164
+ }).catch(() => null)
165
+ const admTok = admLogin?.data?.token
166
+ const regId = reg.data?.user?.id
167
+ if (admTok && regId) {
168
+ const promoted = await api('PUT', `/api/users/${regId}`, { body: { role: 'editor' }, token: admTok }).catch(() => null)
169
+ check('0.0b', 'admin 提升测试用户为 editor', promoted?.data?.role === 'editor', promoted?.message ?? '')
170
+ }
171
+ else { console.log(' [skip] admin 不可用,建线可能被 403(设 AW_ADMIN_PASS)') }
172
+
173
+ const line = (await api('POST', '/api/workshop/dcw/lines', { body: { name: `全协议产线-${TAG}` }, token })).data?.line
174
+ check('0.1', '建产线', Boolean(line?.id))
175
+
176
+ // ── 0.2 产线级权限(新版权限模型:普通用户默认无权,admin 逐线授 operate) ──
177
+ const ADMIN_PASS = process.env.AW_ADMIN_PASS ?? 'admin123'
178
+ const adminLogin = await api('POST', '/api/users/login', {
179
+ body: { email: 'admin@awshop.local', password: ADMIN_PASS },
180
+ }).catch(() => null)
181
+ const adminTok = adminLogin?.data?.token
182
+ if (adminTok) {
183
+ const granted = await api('PUT', '/api/workshop/permissions', {
184
+ body: { userId: reg.data?.user?.id, grants: [{ lineId: line.id, mode: 'operate' }] }, token: adminTok,
185
+ }).catch(() => null)
186
+ check('0.2', 'admin 授予测试用户产线 operate 权限', Boolean(granted?.data?.userId), granted?.message ?? '')
187
+ }
188
+ else {
189
+ console.log(' [skip] admin 登录失败(可设 AW_ADMIN_PASS),以普通用户无权态继续(写入段将按权限模型拒绝)')
190
+ }
191
+
192
+ // ── 1. DAQ 五协议节点(配置连接 + 连接测试) ──
193
+ const daqDefs = [
194
+ { id: 'mqtt', driver: 'mqtt', templateRef: 'daq-temp-tc', name: `MQTT温度-${TAG}`, expect: [30, 75],
195
+ cfg: { host: '127.0.0.1', port: 18830, topic: 'aw/sim/temp', jsonPath: 'data.temp' } },
196
+ { id: 'http', driver: 'http', templateRef: 'daq-temp-tc', name: `HTTP流量-${TAG}`, expect: [38, 48],
197
+ cfg: { url: 'http://127.0.0.1:1889/api/value', jsonPath: 'data.value' } },
198
+ { id: 'mbtcp', driver: 'modbus-tcp', templateRef: 'daq-pressure-tx', name: `ModbusTCP压力-${TAG}`, expect: [0.5, 1.25],
199
+ cfg: { host: '127.0.0.1', port: 1502, unitId: 1, register: 40001, registerType: 'holding', dataType: 'float32', byteOrder: 'big' } },
200
+ { id: 'rtu', driver: 'modbus-rtu', templateRef: 'daq-temp-tc', name: `RTU温度-${TAG}`, expect: [60, 72],
201
+ cfg: { host: '127.0.0.1', port: 15030, unitId: 1, register: 40001, registerType: 'holding', dataType: 'uint16' } },
202
+ { id: 'opcua', driver: 'opcua', templateRef: 'daq-temp-tc', name: `OPCUA温度-${TAG}`, expect: [140, 200],
203
+ cfg: { endpoint: 'opc.tcp://127.0.0.1:4840', nodeId: 'ns=2;s=AW.Temp' } },
204
+ { id: 'plcpv', driver: 'modbus-tcp', templateRef: 'daq-temp-tc', name: `PLC-PV温度-${TAG}`, expect: [0, 260],
205
+ cfg: { host: '127.0.0.1', port: 15040, unitId: 1, register: 40001, registerType: 'holding', dataType: 'float32', byteOrder: 'big' } },
206
+ ]
207
+ const daqNodes = {}
208
+ for (const d of daqDefs) {
209
+ const t = await api('POST', '/api/workshop/daq/test-driver', { body: { driver: d.driver, driverConfig: d.cfg }, token })
210
+ check(`1.${d.id}-test`, `${d.driver} 连接测试`, t.data?.test?.ok === true, JSON.stringify(t.data?.test ?? t.message ?? {}).slice(0, 120))
211
+ const created = await api('POST', '/api/workshop/daq', {
212
+ body: { templateRef: d.templateRef, name: d.name, driver: d.driver, driverConfig: d.cfg, lineId: line.id, intervalMs: 800, publishIntervalMs: 0 }, token,
213
+ })
214
+ daqNodes[d.id] = created.data?.node
215
+ check(`1.${d.id}-create`, `${d.driver} 节点创建`, Boolean(daqNodes[d.id]?.id), created.message ?? '')
216
+ }
217
+
218
+ // ── 2. DCW 节点(四路下发通道) ──
219
+ const dcwDefs = [
220
+ { id: 'plcsp', driver: 'modbus-tcp', name: `PLC-SP温度-${TAG}`, recipe: true,
221
+ cfg: { host: '127.0.0.1', port: 15040, unitId: 1, register: 40021, dataType: 'float32', byteOrder: 'big' } },
222
+ { id: 'ocusp', driver: 'opcua', name: `OPCUA-SP-${TAG}`,
223
+ cfg: { endpoint: 'opc.tcp://127.0.0.1:4840', nodeId: 'ns=2;s=AW.SetTemp' } },
224
+ { id: 'mqsp', driver: 'mqtt', name: `MQTT-SP-${TAG}`,
225
+ cfg: { host: '127.0.0.1', port: 18830, topic: `aw/sim/setpoint-${TAG}`, jsonKey: 'setpoint', qos: 1 } },
226
+ { id: 'htsp', driver: 'http', name: `HTTP-SP-${TAG}`,
227
+ cfg: { url: 'http://127.0.0.1:1889/api/setpoint', bodyKey: 'setpoint' } },
228
+ ]
229
+ const dcwNodes = {}
230
+ for (const d of dcwDefs) {
231
+ const t = await api('POST', '/api/workshop/dcw/test-driver', { body: { driver: d.driver, driverConfig: d.cfg }, token })
232
+ check(`2.${d.id}-test`, `DCW ${d.driver} 连接测试`, t.data?.test?.ok === true, JSON.stringify(t.data?.test ?? t.message ?? {}).slice(0, 120))
233
+ const created = await api('POST', '/api/workshop/dcw', {
234
+ body: { templateRef: 'dcw-temp-sp', name: d.name, driver: d.driver, driverConfig: d.cfg, lineId: line.id }, token,
235
+ })
236
+ dcwNodes[d.id] = created.data?.node
237
+ check(`2.${d.id}-create`, `DCW ${d.driver} 节点创建`, Boolean(dcwNodes[d.id]?.id), created.message ?? '')
238
+ }
239
+
240
+ // ── 3. 产品/配方/开跑(激活数采门控 + 配方窗口) ──
241
+ // 预置:先把 PLC SP 裸写到 25℃,让 PV 从上轮残留收敛到低温 —— 6.4c 的"爬升"才有确定性基线
242
+ {
243
+ const net = await import('node:net')
244
+ await new Promise((resolve) => {
245
+ const sock = net.connect(15040, '127.0.0.1')
246
+ const b = Buffer.alloc(4)
247
+ b.writeFloatBE(25)
248
+ const words = [b.readUInt16BE(0), b.readUInt16BE(2)]
249
+ const frame = Buffer.alloc(17)
250
+ frame.writeUInt16BE(1, 0) // txn
251
+ frame.writeUInt16BE(0, 2) // proto
252
+ frame.writeUInt16BE(11, 4) // len(unit+PDU)
253
+ frame.writeUInt8(1, 6) // unit
254
+ frame.writeUInt8(16, 7) // FC16
255
+ frame.writeUInt16BE(20, 8) // start = 40021-40001
256
+ frame.writeUInt16BE(2, 10) // qty words
257
+ frame.writeUInt8(4, 12) // byte count
258
+ frame.writeUInt16BE(words[0], 13)
259
+ frame.writeUInt16BE(words[1], 15)
260
+ sock.on('connect', () => sock.write(frame))
261
+ sock.on('data', () => { sock.destroy(); resolve() })
262
+ sock.on('error', () => resolve())
263
+ setTimeout(() => { sock.destroy(); resolve() }, 3000)
264
+ })
265
+ await sleep(22_000) // τ=8s ×3,PV 充分回落(<60)
266
+ }
267
+ const prod = (await api('POST', '/api/workshop/dcw/products', { body: { name: `全协议产品-${TAG}`, lineId: line.id }, token })).data?.product
268
+ const plcSp = dcwNodes.plcsp
269
+ const recipe = (await api('POST', '/api/workshop/dcw/recipes', {
270
+ body: {
271
+ productId: prod.id, name: `全协议配方-${TAG}`,
272
+ params: [{ templateRef: 'dcw-temp-sp', nodeId: plcSp.id, value: 180, min: 176, max: 188 }],
273
+ daqWindows: [{ nodeId: daqNodes.plcpv.id, min: 100, max: 260 }],
274
+ }, token,
275
+ })).data?.recipe
276
+ check('3.1', '产品+配方(PLC-SP 绑工艺窗 176~188)', Boolean(prod?.id && recipe?.id))
277
+ const start = await api('POST', `/api/workshop/dcw/lines/${line.id}/start`, { body: { recipeId: recipe.id }, token })
278
+ check('3.2', '产线开跑(数采门控激活)', start.code === 0, start.message ?? '')
279
+
280
+ // ── 4. DAQ 采样落库 + 数值域 + WS 帧 ──
281
+ const aep = openAep(token)
282
+ await aep.ready
283
+ await sleep(21000) // 采样缺省 5000ms:9s 只攒 ~3 点,21s 保证 n>=4
284
+ for (const d of daqDefs) {
285
+ const node = daqNodes[d.id]
286
+ const s = await api('GET', `/api/workshop/daq/${node.id}/samples?bucketMs=1000`, { token })
287
+ const points = s.data?.points ?? s.data ?? []
288
+ const n = Array.isArray(points) ? points.length : 0
289
+ const lastVal = n > 0 ? Number(Object.values(points[n - 1] ?? {})[1] ?? NaN) : NaN
290
+ const inRange = Number.isFinite(lastVal) && lastVal >= d.expect[0] && lastVal <= d.expect[1]
291
+ check(`4.${d.id}`, `${d.driver} 持续采样落库(Timescale)`, n >= 4 && inRange,
292
+ `points=${n} last=${lastVal} 期望域[${d.expect.join(',')}] ${s.message ?? ''}`)
293
+ }
294
+ const daqFrames = aep.frames.filter(f => f.type === 'daq.reading' || f.type === 'daq.frame')
295
+ check('4.ws', 'WS 实时推送(daq.reading/frame 场景帧)', daqFrames.length >= 10, `frames=${daqFrames.length} total=${aep.frames.length}`)
296
+
297
+ // ── 5. DCW 读(读回设备参数);响应形状 { read: {...} } ──
298
+ const rPlc = await api('POST', `/api/workshop/dcw/${dcwNodes.plcsp.id}/read`, { token })
299
+ const plcRead = Number(rPlc.data?.read?.value ?? rPlc.data?.value ?? NaN)
300
+ check('5.1', 'DCW Modbus 读 SP 寄存器', rPlc.code === 0 && Number.isFinite(plcRead), `value=${plcRead} ${JSON.stringify(rPlc.data ?? {}).slice(0, 120)} ${rPlc.message ?? ''}`)
301
+ const rOpc = await api('POST', `/api/workshop/dcw/${dcwNodes.ocusp.id}/read`, { token })
302
+ const opcRead = Number(rOpc.data?.read?.value ?? rOpc.data?.value ?? NaN)
303
+ check('5.2', 'DCW OPC UA 读节点', rOpc.code === 0 && Number.isFinite(opcRead), `value=${opcRead} data=${JSON.stringify(rOpc.data ?? {}).slice(0, 160)} ${rOpc.message ?? ''}`)
304
+ const rMq = await api('POST', `/api/workshop/dcw/${dcwNodes.mqsp.id}/read`, { token })
305
+ const mqReadVal = rMq.data?.read?.value ?? rMq.data?.value
306
+ check('5.3', 'DCW MQTT 读=能力外优雅报错(仅下行)', !Number.isFinite(Number(mqReadVal)) && (rMq.code !== 0 || rMq.data?.read?.ok === false || rMq.data?.ok === false), `code=${rMq.code} read=${JSON.stringify(rMq.data?.read ?? {}).slice(0, 100)}`)
307
+
308
+ // ── 6. DCW 写 ×4 ──
309
+ // 6.1 MQTT 下行:先建订阅(不等待)→ 驱动写 → 收到的报文即真实链路证据
310
+ let mqttRx = null
311
+ try {
312
+ const { default: mqtt } = await import('mqtt')
313
+ const sub = await new Promise((resolve) => {
314
+ const c = mqtt.connect('mqtt://127.0.0.1:18830', { connectTimeout: 4000 })
315
+ const timer = setTimeout(() => { c.end(true); resolve(null) }, 8000)
316
+ c.on('connect', () => c.subscribe(`aw/sim/setpoint-${TAG}`, { qos: 1 }, () => { clearTimeout(timer); resolve(c) }))
317
+ c.on('error', () => { clearTimeout(timer); resolve(null) })
318
+ })
319
+ if (sub) {
320
+ const wMq = await api('POST', `/api/workshop/dcw/${dcwNodes.mqsp.id}/write`, { body: { value: 165.5 }, token })
321
+ check("6.1a", "DCW MQTT 下发执行", wMq.code === 0 && wMq.data?.outcome?.ok === true, JSON.stringify(wMq.data ?? wMq.message ?? {}).slice(0, 140))
322
+ mqttRx = await new Promise((resolve) => {
323
+ const timer = setTimeout(() => { sub.end(true); resolve(null) }, 10_000)
324
+ sub.on('message', (_t, payload) => { clearTimeout(timer); sub.end(true); resolve(payload.toString()) })
325
+ })
326
+ }
327
+ }
328
+ catch { /* mqtt 包不可用则跳过订阅验证 */ }
329
+ // broker 侧确定性验收:模拟器把真实路由落盘(broker 收到即证据,不依赖订阅端时序)
330
+ let auditHit = null
331
+ {
332
+ const auditPath = resolve('.AgentWorkShop/data/sim-setpoint-routes.log')
333
+ for (let i = 0; i < 10 && !auditHit; i++) {
334
+ await sleep(1000)
335
+ try {
336
+ const content = fs.readFileSync(auditPath, 'utf-8')
337
+ auditHit = content.split('\n').reverse().find(l => l.includes(`aw/sim/setpoint-${TAG}`)) ?? null
338
+ }
339
+ catch { /* 文件未生成 */ }
340
+ }
341
+ }
342
+ check('6.1b', 'DCW MQTT 真实报文抵达 Broker(broker 路由审计)', auditHit !== null && auditHit.includes('165.5'), `audit=${auditHit?.trim() ?? 'null'}`)
343
+ // 环境噪声重试:broker 实例可能在订阅/发布之间被杀换新,换全新订阅再写一次
344
+ if (auditHit === null) {
345
+ try {
346
+ const { default: mqtt2 } = await import('mqtt')
347
+ const sub2 = await new Promise((resolve) => {
348
+ const c = mqtt2.connect('mqtt://127.0.0.1:18830', { connectTimeout: 3000 })
349
+ const timer = setTimeout(() => { c.end(true); resolve(null) }, 6000)
350
+ c.on('connect', () => c.subscribe(`aw/sim/setpoint-${TAG}`, { qos: 1 }, () => { clearTimeout(timer); resolve(c) }))
351
+ c.on('error', () => { clearTimeout(timer); resolve(null) })
352
+ })
353
+ if (sub2) {
354
+ await api('POST', `/api/workshop/dcw/${dcwNodes.mqsp.id}/write`, { body: { value: 165.5 }, token })
355
+ mqttRx = await new Promise((resolve) => {
356
+ const timer = setTimeout(() => { sub2.end(true); resolve(null) }, 8000)
357
+ sub2.on('message', (_t, payload) => { clearTimeout(timer); sub2.end(true); resolve(payload.toString()) })
358
+ })
359
+ check('6.1b-retry', 'DCW MQTT 报文抵达(重订重写)', mqttRx !== null && mqttRx.includes('165.5'), `payload=${mqttRx}`)
360
+ }
361
+ }
362
+ catch { /* ignore */ }
363
+ }
364
+
365
+ // 6.2 HTTP 下发:经驱动 POST,模拟器日志留痕
366
+ const wHt = await api('POST', `/api/workshop/dcw/${dcwNodes.htsp.id}/write`, { body: { value: 168.25 }, token })
367
+ check("6.2a", "DCW HTTP 下发执行", wHt.code === 0 && wHt.data?.outcome?.ok === true, JSON.stringify(wHt.data ?? wHt.message ?? {}).slice(0, 140))
368
+
369
+ // 6.3 OPC UA 写 + 回读校验
370
+ const wOpc = await api('POST', `/api/workshop/dcw/${dcwNodes.ocusp.id}/write`, { body: { value: 172.5 }, token })
371
+ check("6.3a", "DCW OPC UA 写入", wOpc.code === 0 && wOpc.data?.outcome?.ok === true, JSON.stringify(wOpc.data ?? wOpc.message ?? {}).slice(0, 140))
372
+ await sleep(1200)
373
+ const rOpc2 = await api('POST', `/api/workshop/dcw/${dcwNodes.ocusp.id}/read`, { token })
374
+ const opc2 = Number(rOpc2.data?.read?.value ?? rOpc2.data?.value ?? NaN)
375
+ check('6.3b', 'DCW OPC UA 回读=172.5±1', Number.isFinite(opc2) && Math.abs(opc2 - 172.5) <= 1, `read=${opc2}`)
376
+
377
+ // 6.4 PLC Modbus 写(配方窗内)+ 真闭环 SP→PV 收敛
378
+ const wPlc = await api('POST', `/api/workshop/dcw/${plcSp.id}/write`, { body: { value: 182 }, token })
379
+ check("6.4a", "DCW Modbus 写 SP=182(配方窗内)", wPlc.code === 0 && wPlc.data?.outcome?.ok === true, JSON.stringify(wPlc.data ?? wPlc.message ?? {}).slice(0, 140))
380
+ const wPlcBad = await api('POST', `/api/workshop/dcw/${plcSp.id}/write`, { body: { value: 195 }, token })
381
+ check('6.4b', '配方窗联锁负例(195 越窗 → 400)', wPlcBad.status === 400, `status=${wPlcBad.status} msg=${String(wPlcBad.message ?? '').slice(0, 80)}`)
382
+
383
+ // PLC 一阶热惯性:PV 从 ~26(预置衰减后)随配方 SP=180 + 写 182 爬升收敛
384
+ const pvNode = daqNodes.plcpv
385
+ const pvStart = await api('GET', `/api/workshop/daq/${pvNode.id}/samples?bucketMs=1000`, { token })
386
+ const pv0 = (pvStart.data?.points ?? []).slice(-3).map(p => Number(Object.values(p)[1])).filter(Number.isFinite).at(-1)
387
+ let pvLastSeen = null
388
+ let pvEnd = null
389
+ let pvDiag = ''
390
+ try {
391
+ pvEnd = await waitUntil('PV 收敛', async () => {
392
+ const s = await api('GET', `/api/workshop/daq/${pvNode.id}/samples?bucketMs=1000`, { token })
393
+ const pts = s.data?.points ?? []
394
+ const v = pts.slice(-2).map(p => Number(Object.values(p)[1])).find(Number.isFinite)
395
+ if (Number.isFinite(v)) pvLastSeen = v
396
+ const view = await api('GET', '/api/workshop/daq', { token })
397
+ const me = (Array.isArray(view.data) ? view.data : (view.data?.nodes ?? [])).find(x => x.id === pvNode.id)
398
+ const lines = await api('GET', '/api/workshop/dcw/lines', { token })
399
+ const ln = (Array.isArray(lines.data) ? lines.data : (lines.data?.lines ?? [])).find(l => l.id === line.id)
400
+ pvDiag = `pts=${pts.length} node=${me?.state} err=${String(me?.lastError ?? '-').slice(0, 40)} lineRun=${ln && (ln.activeRun || ln.run) ? 'on' : 'off'}`
401
+ return Number.isFinite(v) && v >= 170 ? v : null
402
+ }, 90_000, 2500)
403
+ } catch { /* keep null */ }
404
+ // 物理真值回退:DAQ 观测链若被环境中断(模拟器运行中被杀/ revived),直接裸读寄存器验证闭环
405
+ let rawSp = null
406
+ let rawPv = null
407
+ if (!pvEnd) {
408
+ await new Promise((resolveRaw) => {
409
+ const sock = net.connect(15040, '127.0.0.1')
410
+ let buf = Buffer.alloc(0)
411
+ sock.on('connect', () => sock.write(Buffer.from([0, 1, 0, 0, 0, 6, 1, 3, 0, 0, 0, 0x16])))
412
+ sock.on('data', (d) => {
413
+ buf = Buffer.concat([buf, d])
414
+ if (buf.length >= 9 + 44) {
415
+ rawPv = Number(buf.subarray(9, 13).readFloatBE(0).toFixed(1))
416
+ rawSp = Number(buf.subarray(49, 53).readFloatBE(0).toFixed(1))
417
+ sock.destroy(); resolveRaw()
418
+ }
419
+ })
420
+ sock.on('error', () => resolveRaw())
421
+ setTimeout(() => { sock.destroy(); resolveRaw() }, 4000)
422
+ })
423
+ }
424
+ const loopProven = (Number.isFinite(pv0) && pv0 < 60 && Number.isFinite(pvEnd) && pvEnd >= 170)
425
+ || (rawSp !== null && Math.abs(rawSp - 182) <= 2 && rawPv !== null && Math.abs(rawPv - rawSp) <= 6)
426
+ check('6.4c', '真实闭环:PLC PV 随 SP 收敛(≈26→≥170)', loopProven, `pv0=${pv0} pvEnd=${pvEnd} lastSeen=${pvLastSeen} | rawSP=${rawSp} rawPV=${rawPv} ${pvDiag}`)
427
+
428
+ // ── 7. 写后节点视图一致性 + journal ──
429
+ const list = await api('GET', '/api/workshop/dcw', { token })
430
+ const nodes = Array.isArray(list.data) ? list.data : (list.data?.nodes ?? [])
431
+ const plcNow = nodes.find(n => n.id === plcSp.id)
432
+ check('7.1', 'DCW 节点 readValue 收敛 182±2', plcNow && Math.abs(Number(plcNow.readValue) - 182) <= 2, `readValue=${plcNow?.readValue}`)
433
+ const journal = await api('GET', `/api/workshop/dcw/journal?nodeId=${plcSp.id}`, { token })
434
+ check('7.2', '写账本留痕(journal anchors)', (journal.data?.anchors ?? []).length >= 1, `anchors=${(journal.data?.anchors ?? []).length}`)
435
+
436
+ // ── 清理 ──
437
+ await api('POST', `/api/workshop/dcw/lines/${line.id}/stop`, { token }).catch(() => {})
438
+ for (const n of Object.values(daqNodes)) await api('DELETE', `/api/workshop/daq/${n.id}`, { token }).catch(() => {})
439
+ for (const n of Object.values(dcwNodes)) await api('DELETE', `/api/workshop/dcw/${n.id}`, { token }).catch(() => {})
440
+ await api('DELETE', `/api/workshop/dcw/lines/${line.id}`, { token }).catch(() => {})
441
+
442
+ console.log(`\n━━━ 矩阵结果: ${passed} passed / ${failures} failed ━━━`)
443
+ if (failures) {
444
+ console.log('失败项:')
445
+ for (const r of results.filter(x => !x.ok)) console.log(` ${r.id} ${r.name} — ${r.detail}`)
446
+ }
447
+ process.exit(failures === 0 ? 0 : 1)
448
+ }
449
+
450
+ main().catch((err) => {
451
+ console.error('FATAL', err)
452
+ process.exit(1)
453
+ })