agentworkshop 0.7.17 → 0.7.19

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 (38) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/{BJem8_v5.js → BOJaHO5V.js} +4 -4
  3. package/.output/public/_nuxt/B_C3GLsD.js +1 -0
  4. package/.output/public/_nuxt/_id_.IbmpkhH4.css +1 -0
  5. package/.output/public/_nuxt/builds/latest.json +1 -1
  6. package/.output/public/_nuxt/builds/meta/7894c281-50fb-4f00-849f-32749ec2431c.json +1 -0
  7. package/.output/public/_nuxt/{entry.BNApuuEW.css → entry.DRUzDwmX.css} +1 -1
  8. package/.output/server/chunks/_/nitro.mjs +626 -571
  9. package/.output/server/chunks/build/{_id_-DvJZ5Qis.mjs → _id_-Bs-euJPu.mjs} +178 -131
  10. package/.output/server/chunks/build/_id_-Bs-euJPu.mjs.map +1 -0
  11. package/.output/server/chunks/routes/renderer.mjs +1 -1
  12. package/.output/server/chunks/virtual/entry.mjs +9 -1
  13. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  14. package/.output/server/package.json +1 -1
  15. package/app/pages/dcw/[id].vue +56 -6
  16. package/i18n/locales/en.ts +4 -0
  17. package/i18n/locales/zh-CN.ts +4 -0
  18. package/package.json +148 -148
  19. package/scripts/_dbg-live-line-e2e.mjs +279 -0
  20. package/scripts/_dbg-mqtt-publisher.mjs +17 -0
  21. package/scripts/_dbg-pg-tcp-probe.cjs +27 -0
  22. package/scripts/_dbg-recipe-stale-e2e.mjs +135 -0
  23. package/server/data/daqs.json +611 -86
  24. package/server/data/dcw-lines.json +28 -28
  25. package/server/data/dcw-products.json +28 -7
  26. package/server/data/dcw-recipes.json +109 -14
  27. package/server/data/dcw-rollback.json +1389 -9
  28. package/server/data/dcw-runs.json +625 -365
  29. package/server/data/dcw-writes.json +576 -0
  30. package/server/data/dcws.json +819 -316
  31. package/server/data/line-runs.json +54 -14
  32. package/server/services/workshop/agents/industrial-tools.ts +52 -18
  33. package/server/services/workshop/dcw/dcw-controller.ts +11 -1
  34. package/server/services/workshop/dcw/dcw-recipe.repo.ts +21 -6
  35. package/.output/public/_nuxt/DtM4RX5T.js +0 -1
  36. package/.output/public/_nuxt/_id_.pF-B9O6u.css +0 -1
  37. package/.output/public/_nuxt/builds/meta/6a0a88de-639e-4e7b-b7c1-7f4034e04787.json +0 -1
  38. package/.output/server/chunks/build/_id_-DvJZ5Qis.mjs.map +0 -1
@@ -0,0 +1,279 @@
1
+ /**
2
+ * 模拟真实产线全栈 E2E(生产实例 :3001,真实协议:Modbus TCP/RTU、MQTT、OPC UA、HTTP)
3
+ *
4
+ * S1 夹具:产线+产品+配方+5 数采节点(modbus-tcp/rtu/mqtt/opcua/http)+5 数控节点
5
+ * S2 协议连通:逐节点驱动 test(真实连接)
6
+ * S3 数采:开跑 → 采样流入(mock 之外的 5 协议节点值变化)+ Timescale 落库
7
+ * S4 数控:REST 下发 modbus-tcp/rtu/opcua/http/mqtt 五路(真实协议写+回读/路由捕获)
8
+ * S5 Agent 闭环:绑定 → 任务(dcw_control→daq_query→dcw_judge)→ CLOSEDLOOP-OK
9
+ * S6 HITL:manual 绑定的 OPC UA 节点 → 待审批 → 批准 → 真实写入
10
+ * S7 Recipe:下发(PLC=目标)/recipe_update/recipe_rollback/版本史/参数账本回退
11
+ *
12
+ * 用法:NO_PROXY='127.0.0.1,localhost' node scripts/_dbg-live-line-e2e.mjs [base] [tag]
13
+ */
14
+ const BASE = process.argv[2] ?? 'http://127.0.0.1:3001'
15
+ const TAG = process.argv[3] ?? `lv${Math.random().toString(36).slice(2, 6)}`
16
+ const { createRequire } = await import('node:module')
17
+ const reqMqtt = createRequire(import.meta.url)
18
+ let pass = 0
19
+ let fail = 0
20
+ const ok = (cond, name, extra = '') => {
21
+ if (cond) {
22
+ pass++
23
+ console.log(` ✓ ${name}`)
24
+ }
25
+ else {
26
+ fail++
27
+ console.log(` ✗ ${name} ${extra}`)
28
+ }
29
+ }
30
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
31
+ const api = async (method, path, { body, token } = {}) => {
32
+ const res = await fetch(`${BASE}${path}`, {
33
+ method,
34
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ signal: AbortSignal.timeout(60_000),
37
+ })
38
+ return { status: res.status, ...(await res.json().catch(() => ({}))) }
39
+ }
40
+ const invoke = (token, agentId, tool, args) => api('POST', '/api/workshop/agent-tools/invoke', { body: { agentId, tool, args }, token })
41
+
42
+ async function main() {
43
+ console.log(`━━━ 模拟真实产线全栈 E2E @ ${BASE} (tag=${TAG}) ━━━`)
44
+
45
+ // ════ S0 登录 ════
46
+ const login = await api('POST', '/api/users/login', { body: { email: 'admin@awshop.local', password: 'admin123' } })
47
+ if (login.data?.token === undefined) {
48
+ // 隔离库回退:admin 用户名
49
+ const alt = await api('POST', '/api/users/login', { body: { email: 'admin', password: 'admin123' } })
50
+ login.data = alt.data
51
+ }
52
+ const token = login.data?.token
53
+ ok(Boolean(token), 'admin 登录')
54
+ if (!token) process.exit(1)
55
+
56
+ // ════ S1 夹具 ════
57
+ const line = (await api('POST', '/api/workshop/dcw/lines', { body: { name: `模拟产线-${TAG}` }, token })).data?.line
58
+ const product = (await api('POST', '/api/workshop/dcw/products', { body: { name: `模拟产品-${TAG}`, lineId: line.id }, token })).data?.product
59
+ ok(Boolean(line?.id && product?.id), `产线/产品创建(${line.id.slice(0, 8)})`)
60
+
61
+ // 5 数采节点(5 协议)
62
+ const daqDefs = [
63
+ { key: 'modbus-tcp', name: `进料压力-MBTCP`, driverConfig: { host: '127.0.0.1', port: 1502, unitId: 1, register: 40001, registerType: 'holding', dataType: 'float32', scale: 1, byteOrder: 'big' }, template: 'pressure-tx' },
64
+ { key: 'modbus-rtu', name: `RTU 温度`, driverConfig: { host: '127.0.0.1', port: 15030, unitId: 1, register: 40001, registerType: 'holding', dataType: 'float32', scale: 1, byteOrder: 'big' }, template: 'temp-tc' },
65
+ { key: 'opcua', name: `OPC 炉温`, driverConfig: { endpoint: 'opc.tcp://127.0.0.1:4840', nodeId: 'ns=2;s=AW.Temp' }, template: 'temp-tc' },
66
+ { key: 'mqtt', name: `MQTT 均温`, driverConfig: { host: '127.0.0.1', port: 1883, topic: 'aw/sim/temp', jsonPath: 'data.temp' }, template: 'temp-tc' },
67
+ { key: 'http', name: `HTTP 品质值`, driverConfig: { url: 'http://127.0.0.1:1889/api/value', jsonPath: 'data.value' }, template: 'temp-tc' },
68
+ ]
69
+ const daqNodes = {}
70
+ for (const d of daqDefs) {
71
+ const test = await api('POST', '/api/workshop/daq/test-driver', { body: { driver: d.key, driverConfig: d.driverConfig }, token })
72
+ const node = (await api('POST', '/api/workshop/daq', {
73
+ body: { name: `${d.name}-${TAG}`, templateRef: d.template, driver: d.key, driverConfig: d.driverConfig, unit: d.key === 'modbus-tcp' ? 'MPa' : '℃', min: 0, max: 200, intervalMs: 2000, lineId: line.id },
74
+ token,
75
+ })).data?.node
76
+ daqNodes[d.key] = node
77
+ const testOk = test.data?.test?.ok !== false
78
+ ok(Boolean(node?.id), `[daq] ${d.key} 创建(test=${testOk ? '连通' : (test.data?.test?.message ?? '失败')})`, test.data?.test?.message)
79
+ }
80
+
81
+ // 5 数控节点(5 协议)
82
+ const dcwDefs = [
83
+ { key: 'modbus-tcp', name: `压力设定-MBTCP`, driverConfig: { host: '127.0.0.1', port: 1502, unitId: 1, register: 40021, dataType: 'float32', byteOrder: 'big' }, unit: 'MPa', min: 0, max: 5 },
84
+ { key: 'modbus-rtu', name: `RTU 开度`, driverConfig: { host: '127.0.0.1', port: 15030, unitId: 1, register: 40021, dataType: 'int16', byteOrder: 'big' }, unit: '%', min: 0, max: 10000 },
85
+ { key: 'opcua', name: `OPC 炉温设定`, driverConfig: { endpoint: 'opc.tcp://127.0.0.1:4840', nodeId: 'ns=2;s=AW.SetTemp' }, unit: '℃', min: 0, max: 200 },
86
+ { key: 'mqtt', name: `MQTT 下位机设定`, driverConfig: { host: '127.0.0.1', port: 1883, topic: `aw/sim/setpoint/${TAG}`, jsonKey: 'value' }, unit: '℃', min: 0, max: 200 },
87
+ { key: 'http', name: `HTTP 加热设定`, driverConfig: { url: 'http://127.0.0.1:1889/api/setpoint', bodyKey: 'value' }, unit: '℃', min: 0, max: 200 },
88
+ ]
89
+ const dcwNodes = {}
90
+ for (const d of dcwDefs) {
91
+ const node = (await api('POST', '/api/workshop/dcw', {
92
+ body: { name: `${d.name}-${TAG}`, templateRef: 'temp-sp', driver: d.key, driverConfig: d.driverConfig, unit: d.unit, min: d.min, max: d.max, lineId: line.id },
93
+ token,
94
+ })).data?.node
95
+ dcwNodes[d.key] = node
96
+ ok(Boolean(node?.id), `[dcw] ${d.key} 创建`)
97
+ }
98
+
99
+ // 配方:2 个真实协议参数
100
+ const recipe = (await api('POST', '/api/workshop/dcw/recipes', {
101
+ body: { name: `工艺A-${TAG}`, productId: product.id, params: [
102
+ { nodeId: dcwNodes['modbus-tcp'].id, value: 0.9, min: 0.6, max: 1.1 },
103
+ { nodeId: dcwNodes['modbus-rtu'].id, value: 500 },
104
+ ] },
105
+ token,
106
+ })).data?.recipe
107
+ ok(Boolean(recipe?.id), `配方创建(${recipe?.id})`)
108
+
109
+ // ════ S2 数采协议连通(节点级 test)════
110
+ for (const [k, n] of Object.entries(daqNodes)) {
111
+ const t = await api('POST', `/api/workshop/daq/${n.id}/test`, { body: {}, token })
112
+ ok(t.data?.ok === true || t.data?.ok === undefined || t.status === 200, `[daq] ${k} test 连通`, JSON.stringify(t.data ?? t).slice(0, 90))
113
+ }
114
+
115
+ // ════ S3 数采流入(开跑 → 采样 + Timescale)════
116
+ const producedBefore = (await api('GET', '/api/workshop/daq', { token })).data?.meta?.produced ?? 0
117
+ const start = await api('POST', `/api/workshop/dcw/lines/${line.id}/start`, { body: { recipeId: recipe.id }, token })
118
+ ok(start.status === 200, '产线开跑(绑定配方批次)', start.message)
119
+ await sleep(9000)
120
+ const daqSnap = (await api('GET', '/api/workshop/daq', { token })).data
121
+ const liveNodes = daqSnap.nodes.filter(n => Object.values(daqNodes).some(x => x?.id === n.id))
122
+ const changing = liveNodes.filter(n => n.value != null)
123
+ ok(changing.length >= 4, `数采流入:5 协议节点中 ${changing.length} 个有实时值(${changing.map(n => `${n.name}=${n.value}`).join('; ').slice(0, 140)})`)
124
+ const prodAfter = daqSnap.meta?.produced ?? 0
125
+ ok(prodAfter > producedBefore, `管线 produced 增长(${producedBefore} → ${prodAfter})`)
126
+
127
+ // ════ S4 数控五协议下发 ════
128
+ const w1 = await api('POST', `/api/workshop/dcw/${dcwNodes['modbus-tcp'].id}/write`, { body: { value: 0.95 }, token })
129
+ ok(w1.data?.outcome?.ok === true || w1.data?.ok !== false, `modbus-tcp 写 0.95(回读 ${w1.data?.outcome?.readback ?? w1.data?.readback ?? '?'})`, JSON.stringify(w1.data ?? w1).slice(0, 120))
130
+ const w2 = await api('POST', `/api/workshop/dcw/${dcwNodes['modbus-rtu'].id}/write`, { body: { value: 500 }, token })
131
+ ok(w2.data?.outcome?.ok === true || w2.data?.ok !== false, `modbus-rtu 写 500(int16,回读 ${w2.data?.outcome?.readback ?? w2.data?.readback ?? '?'})`, JSON.stringify(w2.data ?? w2).slice(0, 120))
132
+ const w3 = await api('POST', `/api/workshop/dcw/${dcwNodes['opcua'].id}/write`, { body: { value: 88 }, token })
133
+ ok(w3.data?.outcome?.ok === true, `opcua 写 88(回读 ${w3.data?.outcome?.readback ?? '?'})`, JSON.stringify(w3.data ?? w3).slice(0, 120))
134
+ const w4 = await api('POST', `/api/workshop/dcw/${dcwNodes['http'].id}/write`, { body: { value: 66.6 }, token })
135
+ ok(w4.data?.outcome?.ok === true, `http 写 66.6(POST /api/setpoint,回读 ${w4.data?.outcome?.readback ?? '?'})`, JSON.stringify(w4.data ?? w4).slice(0, 120))
136
+
137
+ // mqtt 下发:订阅容器内 mosquitto?此处 1883 是 proto-sim broker —— 用 node 订阅捕获
138
+ const captured = await captureMqtt(`aw/sim/setpoint/${TAG}`, async () => {
139
+ await api('POST', `/api/workshop/dcw/${dcwNodes['mqtt'].id}/write`, { body: { value: 55.5 }, token })
140
+ })
141
+ ok(captured.includes('55.5'), `mqtt 下发路由捕获(topic=aw/sim/setpoint/${TAG} payload 含 55.5)`, captured.slice(0, 80))
142
+
143
+ // ════ S5 Agent 闭环(真实协议节点)════
144
+ const ch = (await api('POST', '/api/workshop/channels', {
145
+ body: { name: `live-${TAG}`, leadAgent: { name: `lead-${TAG}`, harness: 'mock', config: { delayMs: 60 } } },
146
+ token,
147
+ })).data
148
+ const tpl = await api('POST', '/api/workshop/agents', {
149
+ body: { name: `op-${TAG}`, harness: 'omp', config: { provider: 'zhipu-coding-plan', model: 'glm-5.3-flash', systemPromptPrefix: '你是产线操作员:严格按步骤执行,完成后立即调用 complete_task。' } },
150
+ token,
151
+ })
152
+ const join = await api('POST', `/api/workshop/channels/${ch.channelId}/agents`, { body: { agentId: tpl.data.id, role: 'worker' }, token })
153
+ const instId = join.data?.id
154
+ await api('POST', '/api/workshop/agent-tools/bindings', { body: { agentId: instId, nodeId: dcwNodes['modbus-tcp'].id, kind: 'dcw', mode: 'auto' }, token })
155
+ await api('POST', '/api/workshop/agent-tools/bindings', { body: { agentId: instId, nodeId: daqNodes['modbus-tcp'].id, kind: 'daq', mode: 'auto' }, token })
156
+ // HITL:opcua 设定节点 manual 模式
157
+ await api('POST', '/api/workshop/agent-tools/bindings', { body: { agentId: instId, nodeId: dcwNodes['opcua'].id, kind: 'dcw', mode: 'manual' }, token })
158
+ ok(Boolean(instId), 'Agent 入队 + 三节点绑定(auto×2 + manual×1)')
159
+
160
+ // 闭环任务
161
+ const t1 = await api('POST', `/api/workshop/channels/${ch.channelId}/tasks`, {
162
+ body: {
163
+ title: `closedloop-${TAG}`,
164
+ parts: [{ text: `闭环调优(严格按步骤):
165
+ 1. dcw_control(node_id="${dcwNodes['modbus-tcp'].id}", value=0.95, hypothesis="提压验证") 下发压力设定;
166
+ 2. 等待 3 秒后 daq_query(node_id="${daqNodes['modbus-tcp'].id}") 查看进料压力响应;
167
+ 3. 基于数采证据 dcw_judge:对刚才下发的优化记录落判定 keep(附一句证据);
168
+ 4. 最终交付原样包含一行标记 CLOSEDLOOP-OK,并附压力设定值与采样值各一个。
169
+ 完成后调用 complete_task。` }],
170
+ assigneeId: instId,
171
+ },
172
+ token,
173
+ })
174
+ const task1 = t1.data?.task?.id ?? t1.data?.id
175
+ const st1 = await pollTask(token, task1, 12 * 60_000)
176
+ ok(st1 === 'COMPLETED', `[闭环] 任务 COMPLETED(state=${st1})`)
177
+ const blob1 = await gatherTaskBlob(token, ch.channelId, task1)
178
+ ok(blob1.includes('CLOSEDLOOP-OK'), '[闭环] 交付含 CLOSEDLOOP-OK(真实协议下发+数采证据+判定)')
179
+
180
+ // ════ S6 HITL(manual 绑定 → invoke 挂起 → 审批 → 真实写入)════
181
+ // HITL 语义:invoke 挂起等待裁决(超时 300s),因此后台发起、轮询待办、批准后收结果
182
+ const hitlPromise = invoke(token, instId, 'dcw_control', { node_id: dcwNodes['opcua'].id, value: 92, task_id: 'hitl-e2e' }).catch(err => ({ data: { result: { text: `invoke err: ${err.message}`, isError: true } } }))
183
+ let pending = null
184
+ for (let i = 0; i < 10 && !pending; i++) {
185
+ await sleep(1500)
186
+ const pend = (await api('GET', '/api/workshop/agent-tools/approvals', { token })).data?.approvals ?? []
187
+ pending = pend.find(p => p.agentId === instId) ?? null
188
+ }
189
+ ok(Boolean(pending?.id), `[HITL] 产生待审批(${pending?.id ?? '无'})`)
190
+ if (pending?.id) {
191
+ const dec = await api('POST', `/api/workshop/agent-tools/approvals/${pending.id}/decide`, { body: { approved: true, comment: 'E2E 批准' }, token })
192
+ ok(dec.status === 200, '[HITL] 管理员批准')
193
+ const hitl = await hitlPromise
194
+ ok(hitl.data?.result?.isError !== true, '[HITL] Agent 收到批准结果并执行', (hitl.data?.result?.text ?? '').slice(0, 90))
195
+ await sleep(1500)
196
+ const rd = await api('POST', `/api/workshop/dcw/${dcwNodes['opcua'].id}/read`, { body: {}, token })
197
+ ok(String(rd.data?.read?.value ?? '').startsWith('92'), `[HITL] 批准后真实写入生效(OPC UA SetTemp 回读 ${rd.data?.read?.value})`, JSON.stringify(rd.data ?? rd).slice(0, 90))
198
+ }
199
+ else {
200
+ await hitlPromise.catch(() => {})
201
+ }
202
+
203
+ // ════ S7 Recipe 全生命周期(真实协议下发)════
204
+ const upd = await invoke(token, instId, 'recipe_update', { recipe_id: recipe.id, params: [{ node_id: dcwNodes['modbus-tcp'].id, value: 0.98 }], reason: '闭环验证:压力窗口内稳定' })
205
+ ok(upd.data?.result?.text?.includes('v2'), '[recipe] Agent recipe_update → v2', upd.data?.result?.text?.slice(0, 90))
206
+ const rv = await api('POST', `/api/workshop/dcw/recipes/${recipe.id}/revert`, { body: { version: 1, reason: 'E2E 回退到初版' }, token })
207
+ ok(rv.data?.recipe?.version === 3, '[recipe] 界面回退 → v3')
208
+ const jl = await invoke(token, instId, 'dcw_journal', { node_id: dcwNodes['modbus-tcp'].id })
209
+ ok(jl.data?.result?.text?.includes('优化记录') || jl.data?.result?.text?.includes('锚'), '[journal] dcw_journal 参数账本可查')
210
+ // 节点级参数回退:回退压力设定到上一稳定锚
211
+ const rb = await invoke(token, instId, 'dcw_rollback', { node_id: dcwNodes['modbus-tcp'].id })
212
+ const rbOk = rb.data?.result?.text?.includes('回退') && !rb.data?.result?.isError
213
+ const rbSkip = (rb.data?.result?.text ?? '').includes('冷却') || (rb.data?.result?.text ?? '').includes('无可回退')
214
+ ok(rbOk || rbSkip, `[journal] dcw_rollback 执行(${rb.data?.result?.text?.slice(0, 70) ?? '∅'})`)
215
+
216
+ // ════ 清理 ════
217
+ await api('POST', `/api/workshop/dcw/lines/${line.id}/stop`, { body: {}, token }).catch(() => {})
218
+ await api('DELETE', `/api/workshop/channels/${ch.channelId}?purge=1`, { token }).catch(() => {})
219
+ await api('DELETE', `/api/workshop/agents/${tpl.data.id}`, { token }).catch(() => {})
220
+ await api('DELETE', `/api/workshop/dcw/recipes/${recipe.id}`, { token }).catch(() => {})
221
+ await api('DELETE', `/api/workshop/dcw/products/${product.id}`, { token }).catch(() => {})
222
+ for (const n of Object.values(dcwNodes)) await api('DELETE', `/api/workshop/dcw/${n.id}`, { token }).catch(() => {})
223
+ for (const n of Object.values(daqNodes)) await api('DELETE', `/api/workshop/daq/${n.id}`, { token }).catch(() => {})
224
+ await api('DELETE', `/api/workshop/dcw/lines/${line.id}`, { token }).catch(() => {})
225
+
226
+ console.log(`\n━━━ LiveLine E2E: ${pass} passed / ${fail} failed ━━━`)
227
+ process.exit(fail === 0 ? 0 : 1)
228
+ }
229
+
230
+ async function pollTask(token, id, timeoutMs) {
231
+ const deadline = Date.now() + timeoutMs
232
+ let state = ''
233
+ while (Date.now() < deadline) {
234
+ for (let i = 0; i < 3; i++) {
235
+ try {
236
+ const me = await api('GET', `/api/workshop/tasks/${id}`, { token })
237
+ state = me.data?.state ?? ''
238
+ break
239
+ }
240
+ catch { await sleep(3000) }
241
+ }
242
+ if (['COMPLETED', 'FAILED', 'CANCELED'].includes(state)) return state
243
+ await sleep(6000)
244
+ }
245
+ return state || 'RUNNING'
246
+ }
247
+
248
+ async function gatherTaskBlob(token, channelId, taskId) {
249
+ const taskBlob = JSON.stringify((await api('GET', `/api/workshop/tasks/${taskId}`, { token })).data ?? {})
250
+ const msgBlob = JSON.stringify((await api('GET', `/api/workshop/channels/${channelId}/messages?limit=200`, { token })).data ?? {})
251
+ const evBlob = JSON.stringify((await api('GET', `/api/workshop/channels/${channelId}/events?limit=400`, { token })).data ?? {})
252
+ return taskBlob + msgBlob + evBlob
253
+ }
254
+
255
+ /** 订阅 MQTT 主题并执行动作,断言捕获 payload(真实 broker = 127.0.0.1:1883) */
256
+ async function captureMqtt(topic, action) {
257
+ const mqtt = createMqttLib()
258
+ return await new Promise((resolve) => {
259
+ const client = mqtt.connect('mqtt://127.0.0.1:1883')
260
+ let got = ''
261
+ const done = () => { try { client.end(true) } catch {} ; resolve(got) }
262
+ client.on('connect', async () => {
263
+ client.subscribe(topic, () => {
264
+ setTimeout(action, 300)
265
+ })
266
+ setTimeout(() => done(), 9000)
267
+ })
268
+ client.on('message', (t, payload) => { got = `${t} ${payload.toString()}` })
269
+ setTimeout(() => done(), 12_000)
270
+ })
271
+ }
272
+ function createMqttLib() {
273
+ return reqMqtt('mqtt')
274
+ }
275
+
276
+ main().catch((err) => {
277
+ console.error('E2E 异常:', err)
278
+ process.exit(1)
279
+ })
@@ -0,0 +1,17 @@
1
+ /**
2
+ * MQTT 模拟数据发布器:向真实 mosquitto(1883)每 2s 发布 aw/sim/temp = {"data":{"temp":…}},
3
+ * 供 daq mqtt 驱动节点订阅采样(与 dev-protocol-simulators 的载荷形状一致)。
4
+ * 用法:node scripts/_dbg-mqtt-publisher.mjs (前台常驻;SIGINT 退出)
5
+ */
6
+ import { createRequire } from 'node:module'
7
+ const req = createRequire(import.meta.url)
8
+ const mqtt = req('mqtt')
9
+
10
+ const client = mqtt.connect('mqtt://127.0.0.1:1883', { reconnectPeriod: 3000 })
11
+ let temp = 51.2
12
+ client.on('connect', () => console.log('[mqtt-pub] 已连接 mosquitto 1883,开始发布 aw/sim/temp'))
13
+ client.on('error', (e) => console.error('[mqtt-pub] error:', e.message))
14
+ setInterval(() => {
15
+ temp = Number(Math.max(40, Math.min(60, temp + (Math.random() - 0.48) * 0.6)).toFixed(2))
16
+ client.publish('aw/sim/temp', JSON.stringify({ data: { temp } }), { qos: 0 })
17
+ }, 2000)
@@ -0,0 +1,27 @@
1
+ const net = require('net')
2
+
3
+ const s = net.connect({ host: '127.0.0.1', port: 5432, timeout: 4000 })
4
+ s.on('connect', () => {
5
+ console.log('TCP connected')
6
+ // 发送 PG StartupMessage(protocol 3.0, user postgres, db awshop)
7
+ const params = ['user', 'postgres', 'database', 'awshop']
8
+ const body = Buffer.from([0, 3, ...Buffer.from(params.join('\0') + '\0', 'utf8'), 0])
9
+ const head = Buffer.alloc(8)
10
+ head.writeInt32BE(8 + body.length, 0)
11
+ s.write(head)
12
+ })
13
+ s.on('data', (d) => {
14
+ console.log('resp type:', String.fromCharCode(d[0]), 'len:', d.readInt32BE(1))
15
+ s.destroy()
16
+ process.exit(0)
17
+ })
18
+ s.on('timeout', () => {
19
+ console.log('TIMEOUT')
20
+ s.destroy()
21
+ process.exit(1)
22
+ })
23
+ s.on('error', (e) => {
24
+ console.log('ERR:', e.message)
25
+ process.exit(1)
26
+ })
27
+ s.on('close', had => console.log('closed, hadError=', had))
@@ -0,0 +1,135 @@
1
+ /**
2
+ * 配方失效节点(停用/解绑/删除)一致性 E2E(隔离实例 :3021)
3
+ *
4
+ * 验证:
5
+ * 1) 配方一键下发:A 正常下发;B(停用)/C(解绑)/D(删除)三类失效参数逐一跳过并记明原因
6
+ * 2) 手动 REST 写停用节点 → 409(单点门控)
7
+ * 3) Agent dcw_control 停用节点 → 明确「已停用」报错
8
+ * 4) Agent recipe_update:触到停用/解绑节点被拒;正常参数保存时自动剪除基线中的失效参数并告知
9
+ * 5) recipe_rollback 回退到含失效节点的历史版本 → 剪枝成功,版本描述记录剔除
10
+ * 6) line_context 参数行带 [已停用/已取消绑定/已删除] 标记
11
+ * 用法:NO_PROXY='127.0.0.1,localhost' node scripts/_dbg-recipe-stale-e2e.mjs [base]
12
+ */
13
+ const BASE = process.argv[2] ?? 'http://127.0.0.1:3021'
14
+ const TAG = `st${Math.random().toString(36).slice(2, 6)}`
15
+ let pass = 0
16
+ let fail = 0
17
+ const ok = (cond, name, extra = '') => {
18
+ if (cond) { pass++; console.log(` ✓ ${name}`) }
19
+ else { fail++; console.log(` ✗ ${name} ${extra}`) }
20
+ }
21
+ const api = async (method, path, { body, token } = {}) => {
22
+ const res = await fetch(`${BASE}${path}`, {
23
+ method,
24
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
25
+ body: body === undefined ? undefined : JSON.stringify(body),
26
+ signal: AbortSignal.timeout(30_000),
27
+ })
28
+ return { status: res.status, ...(await res.json().catch(() => ({}))) }
29
+ }
30
+ const invoke = (token, agentId, tool, args) => api('POST', '/api/workshop/agent-tools/invoke', { body: { agentId, tool, args }, token })
31
+
32
+ async function main() {
33
+ console.log(`━━━ 配方失效节点一致性 E2E @ ${BASE} (tag=${TAG}) ━━━`)
34
+ const login = await api('POST', '/api/users/login', { body: { email: 'admin', password: 'admin123' } })
35
+ const token = login.data?.token
36
+ ok(Boolean(token), 'admin 登录')
37
+
38
+ // ── 夹具:产线 + 4 节点 + 产品 + 配方(4 参数)──
39
+ const line = (await api('POST', '/api/workshop/dcw/lines', { body: { name: `st-line-${TAG}` }, token })).data?.line
40
+ const mkNode = async (name) => (await api('POST', '/api/workshop/dcw', {
41
+ body: { name: `${name}-${TAG}`, templateRef: 'temp-sp', driver: 'mock', driverConfig: { key: `${name}-${TAG}` }, min: 0, max: 100, unit: '℃', lineId: line.id },
42
+ token,
43
+ })).data?.node
44
+ const na = await mkNode('st-a')
45
+ const nb = await mkNode('st-b')
46
+ const nc = await mkNode('st-c')
47
+ const nd = await mkNode('st-d')
48
+ const product = (await api('POST', '/api/workshop/dcw/products', { body: { name: `st-prod-${TAG}`, lineId: line.id }, token })).data?.product
49
+ const recipe = (await api('POST', '/api/workshop/dcw/recipes', {
50
+ body: { name: `st-recipe-${TAG}`, productId: product.id, params: [
51
+ { nodeId: na.id, value: 40 },
52
+ { nodeId: nb.id, value: 41 },
53
+ { nodeId: nc.id, value: 42 },
54
+ { nodeId: nd.id, value: 43 },
55
+ ] },
56
+ token,
57
+ })).data?.recipe
58
+ ok(Boolean(na?.id && nb?.id && nc?.id && nd?.id && recipe?.id), `夹具:4 节点 + 配方 ${recipe?.id}`)
59
+
60
+ // ── 三态变异:B 停用 / C 解绑 / D 删除 ──
61
+ await api('PATCH', `/api/workshop/dcw/${nb.id}`, { body: { enabled: false }, token })
62
+ await api('PATCH', `/api/workshop/dcw/${nc.id}`, { body: { lineId: '' }, token })
63
+ await api('DELETE', `/api/workshop/dcw/${nd.id}`, { token })
64
+
65
+ // ── 1. 一键下发:失效参数逐一跳过 ──
66
+ const apply = await api('POST', `/api/workshop/dcw/recipes/${recipe.id}/apply`, { body: {}, token })
67
+ ok(apply.status === 200, '一键下发执行')
68
+ const results = apply.data?.run?.results ?? []
69
+ const byNode = Object.fromEntries(results.map(r => [r.nodeId ?? 'deleted', r]))
70
+ ok(byNode[na.id]?.ok === true, 'A 正常节点照常下发')
71
+ ok((byNode[nb.id]?.message ?? '').includes('已停用') && byNode[nb.id]?.ok === false, `B 停用跳过(${byNode[nb.id]?.message ?? '∅'})`)
72
+ ok((byNode[nc.id]?.message ?? '').includes('已取消绑定') && byNode[nc.id]?.ok === false, `C 解绑跳过(${byNode[nc.id]?.message ?? '∅'})`)
73
+ ok(Object.values(byNode).some(r => (r.message ?? '').includes('已删除') && r.ok === false), `D 删除跳过(${(results.find(r => !r.nodeId)?.message) ?? '∅'})`)
74
+ ok((apply.data?.run?.results ?? []).every(r => results.some(x => x === r)), 'run.results 完整保留跳过原因')
75
+
76
+ // ── 2. 手动 REST 写停用节点 → 409 ──
77
+ const manual = await api('POST', `/api/workshop/dcw/${nb.id}/write`, { body: { value: 10 }, token })
78
+ ok(manual.status === 409, `手动写停用节点 409(实际 ${manual.status} ${manual.message ?? ''})`)
79
+
80
+ // ── 3. Agent 组建 + 直调 ──
81
+ const ch = (await api('POST', '/api/workshop/channels', {
82
+ body: { name: `st-${TAG}`, leadAgent: { name: `lead-${TAG}`, harness: 'mock', config: { delayMs: 60 } } },
83
+ token,
84
+ })).data
85
+ const tpl = await api('POST', '/api/workshop/agents', { body: { name: `stw-${TAG}`, harness: 'omp', config: { provider: 'zhipu-coding-plan', model: 'glm-5.3-flash' } }, token })
86
+ const join = await api('POST', `/api/workshop/channels/${ch.channelId}/agents`, { body: { agentId: tpl.data.id, role: 'worker' }, token })
87
+ const instId = join.data?.id
88
+ for (const n of [na, nb, nc]) await api('POST', '/api/workshop/agent-tools/bindings', { body: { agentId: instId, nodeId: n.id, kind: 'dcw', mode: 'auto' }, token })
89
+ ok(Boolean(instId), 'omp worker 建号+绑定')
90
+
91
+ const wB = await invoke(token, instId, 'dcw_control', { node_id: nb.id, value: 10 })
92
+ ok(wB.data?.result?.isError === true && wB.data?.result?.text.includes('已停用'), '[agent] dcw_control 停用节点明确报错', wB.data?.result?.text?.slice(0, 80))
93
+
94
+ // ── 4. recipe_update:触雷拒绝 + 自动剪枝 ──
95
+ const uB = await invoke(token, instId, 'recipe_update', { recipe_id: recipe.id, params: [{ node_id: nb.id, value: 70 }], reason: '触雷测试' })
96
+ ok(uB.data?.result?.isError === true && uB.data?.result?.text.includes('已停用'), '[agent] recipe_update 停用节点被拒', uB.data?.result?.text?.slice(0, 80))
97
+ const uC = await invoke(token, instId, 'recipe_update', { recipe_id: recipe.id, params: [{ node_id: nc.id, value: 70 }], reason: '触雷测试' })
98
+ ok(uC.data?.result?.isError === true && uC.data?.result?.text.includes('已取消绑定'), '[agent] recipe_update 解绑节点被拒', uC.data?.result?.text?.slice(0, 80))
99
+
100
+ const uA = await invoke(token, instId, 'recipe_update', { recipe_id: recipe.id, params: [{ node_id: na.id, value: 77 }], reason: '正常参数保存(应自动剔除 C/D 失效参数)' })
101
+ ok(uA.data?.result?.text?.includes('v2') && uA.data?.result?.text?.includes('剔除'), '[agent] 正常保存 → v2 + 剔除失效参数告知', uA.data?.result?.text?.slice(0, 120))
102
+ const cur = (await api('GET', '/api/workshop/dcw', { token })).data?.recipes?.find(r => r.id === recipe.id)
103
+ ok(cur?.params?.length === 2 && cur?.params?.some(p => p.nodeId === na.id) && cur?.params?.some(p => p.nodeId === nb.id), `基线剪枝后参数=A+B(实际 ${cur?.params?.length} 条)`)
104
+
105
+ // ── 5. 回退到含失效节点的 v1 → 剪枝成功 ──
106
+ const rb = await api('POST', `/api/workshop/dcw/recipes/${recipe.id}/revert`, { body: { version: 1, reason: '回退到含失效节点的初版' }, token })
107
+ ok(rb.status === 200 && rb.data?.recipe?.version === 3, `回退 v1 → v3 成功(实际 v${rb.data?.recipe?.version})`)
108
+ ok((rb.data?.recipe?.description ?? '').includes('剔除') || (rb.data?.recipe?.paramsHistory ?? []).some(h => (h.description ?? '').includes('剔除')), '回退版本描述记录剔除')
109
+ ok((rb.data?.recipe?.params ?? []).every(p => p.nodeId !== nd.id && p.nodeId !== nc.id), '回退后不含已删除/已解绑节点参数')
110
+
111
+ // ── 6. line_context 状态标记 ──
112
+ const lc = await invoke(token, instId, 'line_context', {})
113
+ if (lc.data?.result?.text?.includes('已停用') && lc.data?.result?.text?.includes('参数不下发')) ok(true, '[agent] line_context 标记停用参数')
114
+ else {
115
+ ok(false, '[agent] line_context 标记停用参数')
116
+ console.log(' ── line_context 实际输出 ──')
117
+ console.log(lc.data?.result?.text ?? JSON.stringify(lc))
118
+ }
119
+
120
+ // ── 清理 ──
121
+ await api('DELETE', `/api/workshop/channels/${ch.channelId}?purge=1`, { token }).catch(() => {})
122
+ await api('DELETE', `/api/workshop/agents/${tpl.data.id}`, { token }).catch(() => {})
123
+ await api('DELETE', `/api/workshop/dcw/recipes/${recipe.id}`, { token }).catch(() => {})
124
+ await api('DELETE', `/api/workshop/dcw/products/${product.id}`, { token }).catch(() => {})
125
+ for (const n of [na, nb, nc]) await api('DELETE', `/api/workshop/dcw/${n.id}`, { token }).catch(() => {})
126
+ await api('DELETE', `/api/workshop/dcw/lines/${line.id}`, { token }).catch(() => {})
127
+
128
+ console.log(`\n━━━ StaleRef E2E: ${pass} passed / ${fail} failed ━━━`)
129
+ process.exit(fail === 0 ? 0 : 1)
130
+ }
131
+
132
+ main().catch((err) => {
133
+ console.error('E2E 异常:', err)
134
+ process.exit(1)
135
+ })