agentworkshop 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/B9Yie5HN.js +2 -0
  3. package/.output/public/_nuxt/CN7Zh8GQ.js +1 -0
  4. package/.output/public/_nuxt/CdSgfcYR.js +1 -0
  5. package/.output/public/_nuxt/CdfAP_IH2.js +1 -0
  6. package/.output/public/_nuxt/ChnfyQHz.js +1 -0
  7. package/.output/public/_nuxt/{BkKYf8xY.js → CyygA0q4.js} +4 -4
  8. package/.output/public/_nuxt/{Bz40vyZS.js → DFnGFEav.js} +7 -7
  9. package/.output/public/_nuxt/Xaf_HIR3.js +1 -0
  10. package/.output/public/_nuxt/ZugRk0CM2.js +4 -0
  11. package/.output/public/_nuxt/builds/latest.json +1 -1
  12. package/.output/public/_nuxt/builds/meta/55c10854-ee0e-4be4-924b-a1b0e100fe11.json +1 -0
  13. package/.output/public/_nuxt/{default.iGJy9dP-.css → default.D17kXlD6.css} +1 -1
  14. package/.output/public/_nuxt/monitor.CmwpEUCI.css +1 -0
  15. package/.output/public/_nuxt/yIKY_V1P.js +1 -0
  16. package/.output/server/chunks/_/nitro.mjs +1144 -933
  17. package/.output/server/chunks/_/nitro.mjs.map +1 -1
  18. package/.output/server/chunks/build/{TownView-BkHxMiwZ.mjs → TownView-Bl8Gkcxi.mjs} +3 -3
  19. package/.output/server/chunks/build/{TownView-BkHxMiwZ.mjs.map → TownView-Bl8Gkcxi.mjs.map} +1 -1
  20. package/.output/server/chunks/build/{_id_-Bna1_JuB.mjs → _id_-CWLFGZjV.mjs} +2 -2
  21. package/.output/server/chunks/build/{_id_-Bna1_JuB.mjs.map → _id_-CWLFGZjV.mjs.map} +1 -1
  22. package/.output/server/chunks/build/_id_-GnRJPykX.mjs +1 -1
  23. package/.output/server/chunks/build/{_wsId_-CzLDGT_V.mjs → _wsId_-BhQMjyHG.mjs} +3 -4
  24. package/.output/server/chunks/build/{_wsId_-CzLDGT_V.mjs.map → _wsId_-BhQMjyHG.mjs.map} +1 -1
  25. package/.output/server/chunks/build/{daq-CmeM4u5u.mjs → daq-BOgpn9sl.mjs} +4 -5
  26. package/.output/server/chunks/build/daq-BOgpn9sl.mjs.map +1 -0
  27. package/.output/server/chunks/build/dcw-BTM5YU-B.mjs +1 -1
  28. package/.output/server/chunks/build/{default-Dt6xLdpd.mjs → default-CKJWhkot.mjs} +60 -17
  29. package/.output/server/chunks/build/{default-Dt6xLdpd.mjs.map → default-CKJWhkot.mjs.map} +1 -1
  30. package/.output/server/chunks/build/{logs-CUQt1z7D.mjs → logs-Dyk0I2I9.mjs} +3 -4
  31. package/.output/server/chunks/build/logs-Dyk0I2I9.mjs.map +1 -0
  32. package/.output/server/chunks/build/{monitor-yNoV98rf.mjs → monitor-KW8WLOJd.mjs} +34 -33
  33. package/.output/server/chunks/build/{monitor-yNoV98rf.mjs.map → monitor-KW8WLOJd.mjs.map} +1 -1
  34. package/.output/server/chunks/build/{town-BN4vQPb8.mjs → town-_4tEEEVW.mjs} +3 -4
  35. package/.output/server/chunks/build/town-_4tEEEVW.mjs.map +1 -0
  36. package/.output/server/chunks/build/useDaqStream-hwkhJHFU.mjs +1 -1
  37. package/.output/server/chunks/build/useDcwStream-Bdew_yrr.mjs +1 -1
  38. package/.output/server/chunks/build/{useWorkshopWs-xcdeiSmJ.mjs → useWorkshopWs-B3j5wIAK.mjs} +177 -3
  39. package/.output/server/chunks/build/useWorkshopWs-B3j5wIAK.mjs.map +1 -0
  40. package/.output/server/chunks/build/{workshop-CLhwe5eo.mjs → workshop-k0XZTFKg.mjs} +2 -3
  41. package/.output/server/chunks/build/workshop-k0XZTFKg.mjs.map +1 -0
  42. package/.output/server/chunks/routes/api/plugins/_name/_...path_.mjs +8 -2
  43. package/.output/server/chunks/routes/api/plugins/_name/_...path_.mjs.map +1 -1
  44. package/.output/server/chunks/routes/api/workshop/channel-templates/_id/instantiate.post.mjs +1 -1
  45. package/.output/server/chunks/routes/api/workshop/channels.post.mjs +1 -1
  46. package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs +32 -0
  47. package/.output/server/chunks/routes/api/workshop/hitl/pending.get.mjs.map +1 -0
  48. package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs +56 -0
  49. package/.output/server/chunks/routes/api/workshop/hitl/respond.post.mjs.map +1 -0
  50. package/.output/server/chunks/routes/api/workshop/plugins/_name/disable.post.mjs +1 -1
  51. package/.output/server/chunks/routes/api/workshop/plugins/_name/enable.post.mjs +1 -1
  52. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.delete.mjs +1 -1
  53. package/.output/server/chunks/routes/api/workshop/scene/layouts/_channelId_.put.mjs +1 -1
  54. package/.output/server/chunks/routes/api/workshop/workspaces/_id/channel-templates/_tplId_.post.mjs +1 -1
  55. package/.output/server/chunks/routes/renderer.mjs +2 -2
  56. package/.output/server/chunks/virtual/entry.mjs +17 -11
  57. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  58. package/.output/server/package.json +1 -1
  59. package/README.md +435 -434
  60. package/app/components/AppHeader.vue +138 -0
  61. package/app/composables/workshop/useWorkshopWs.ts +4 -0
  62. package/app/pages/monitor.vue +17 -1
  63. package/app/stores/workshop/hitl.ts +80 -0
  64. package/bin/aw.mjs +0 -0
  65. package/cli/commands/tui.mjs +19 -0
  66. package/config.yml +134 -134
  67. package/docs/tui.md +43 -0
  68. package/i18n/locales/en.ts +3 -0
  69. package/i18n/locales/zh-CN.ts +3 -0
  70. package/package.json +140 -144
  71. package/scripts/_spike-pitui.mjs +116 -0
  72. package/scripts/e2e-hitl-live.mjs +323 -0
  73. package/scripts/e2e-line-live.mjs +144 -0
  74. package/scripts/test-hitl-registry.ts +112 -0
  75. package/scripts/test-tui-commands.mjs +131 -0
  76. package/scripts/test-tui-reducers.mjs +72 -0
  77. package/scripts/tsconfig.json +14 -0
  78. package/scripts/tui-smoke.mjs +91 -0
  79. package/server/api/plugins/[name]/[...path].ts +10 -1
  80. package/server/api/workshop/hitl/pending.get.ts +28 -0
  81. package/server/api/workshop/hitl/respond.post.ts +67 -0
  82. package/server/api/workshop/ws.ts +30 -1
  83. package/server/data/daqs.json +69 -17
  84. package/server/data/dcw-lines.json +21 -0
  85. package/server/data/dcw-products.json +42 -0
  86. package/server/data/dcw-recipes.json +168 -0
  87. package/server/data/dcw-rollback.json +133 -1
  88. package/server/data/dcw-runs.json +162 -108
  89. package/server/data/dcw-writes.json +180 -0
  90. package/server/data/dcws.json +427 -275
  91. package/server/data/line-runs.json +21 -1
  92. package/server/plugins/workshop.ts +7 -0
  93. package/server/services/workshop/agents/harness-terminal.ts +92 -25
  94. package/server/services/workshop/agents/hitl-registry.ts +181 -0
  95. package/server/services/workshop/agents/tool-approvals.ts +15 -0
  96. package/server/services/workshop/scene-events.ts +19 -9
  97. package/shared/workshop-protocol.ts +37 -0
  98. package/tui/README.md +65 -0
  99. package/tui/aw-tui.mjs +356 -0
  100. package/tui/commands/index.mjs +250 -0
  101. package/tui/components/chat-log.mjs +42 -0
  102. package/tui/components/hitl-card.mjs +36 -0
  103. package/tui/components/monitor-pane.mjs +39 -0
  104. package/tui/components/root.mjs +53 -0
  105. package/tui/components/status-bar.mjs +29 -0
  106. package/tui/lib/api.mjs +82 -0
  107. package/tui/lib/config.mjs +58 -0
  108. package/tui/lib/reducers.mjs +218 -0
  109. package/tui/lib/state.mjs +80 -0
  110. package/tui/lib/virtual-terminal.mjs +48 -0
  111. package/tui/lib/ws.mjs +124 -0
  112. package/tui/theme.mjs +38 -0
  113. package/.output/public/_nuxt/B7V9IfTL.js +0 -1
  114. package/.output/public/_nuxt/BE5QyG5J2.js +0 -1
  115. package/.output/public/_nuxt/BuKI8z-5.js +0 -1
  116. package/.output/public/_nuxt/D0C5Q0di.js +0 -1
  117. package/.output/public/_nuxt/DC-QMEk1.js +0 -2
  118. package/.output/public/_nuxt/DEkQ0qQ02.js +0 -1
  119. package/.output/public/_nuxt/builds/meta/4d990d25-ebbb-4fe4-a23b-3d28c7f42fee.json +0 -1
  120. package/.output/public/_nuxt/dt2tKE95.js +0 -4
  121. package/.output/public/_nuxt/i_MKObHo.js +0 -1
  122. package/.output/public/_nuxt/monitor.41fCD6TB.css +0 -1
  123. package/.output/public/_nuxt/tpDMVY5w.js +0 -1
  124. package/.output/server/chunks/build/connection-Dc0fn6yd.mjs +0 -120
  125. package/.output/server/chunks/build/connection-Dc0fn6yd.mjs.map +0 -1
  126. package/.output/server/chunks/build/daq-CmeM4u5u.mjs.map +0 -1
  127. package/.output/server/chunks/build/logs-CUQt1z7D.mjs.map +0 -1
  128. package/.output/server/chunks/build/town-BN4vQPb8.mjs.map +0 -1
  129. package/.output/server/chunks/build/useWorkshopWs-xcdeiSmJ.mjs.map +0 -1
  130. package/.output/server/chunks/build/workshop-CLhwe5eo.mjs.map +0 -1
@@ -0,0 +1,323 @@
1
+ /**
2
+ * HITL 真实端到端(三阶段;需要:服务运行 + omp CLI + 可用模型):
3
+ *
4
+ * Phase W API/WS 层:任务 → lead 空闲 → 注入 ask → AEP hitl.request
5
+ * → REST pending → POST respond → hitl.resolved → ask 收到答案
6
+ * Phase U 真实浏览器(Chrome):AppHeader 铃标渲染 → 下拉条目 → 跳转 /monitor
7
+ * → 应答后徽标消隐(截图 4 张)
8
+ * Phase T TUI(无头):状态条 HITL 计数 → /hitl 列表 → 作答卡 → 提交 → 落定
9
+ *
10
+ * 时序对齐 scripts/test-terminal-e2e.mjs 的已验证路径:首个任务仅用于 spawn,
11
+ * ask 指令经终端 WS follow_up 注入(lead 空闲后);目标对话框按 options 匹配
12
+ * (调度循环的空闲询问也是合法 HITL 待办,不作为断言对象)。
13
+ *
14
+ * 运行:node scripts/e2e-hitl-live.mjs [--base http://127.0.0.1:3000]
15
+ * 产物:gui-test-screenshots/hitl-e2e-*.png
16
+ */
17
+ import puppeteer from 'puppeteer-core'
18
+
19
+ const BASE = (() => {
20
+ const i = process.argv.indexOf('--base')
21
+ return i > 0 ? process.argv[i + 1] : 'http://127.0.0.1:3000'
22
+ })()
23
+ const WS_BASE = BASE.replace(/^http/, 'ws')
24
+ const SHOT_DIR = 'gui-test-screenshots'
25
+ const TAG = Date.now().toString(36)
26
+ const ASK_1 = 'Use the ask tool NOW to ask me: Proceed? with options yes and no. After I answer, call complete_task with the deliverable set to my answer.'
27
+ const ASK_2 = 'Use the ask tool NOW to ask me: Proceed2? with options confirm and cancel. After I answer, call complete_task with the deliverable set to my answer.'
28
+
29
+ let failures = 0
30
+ let passed = 0
31
+ const check = (name, ok, detail = '') => {
32
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
33
+ if (ok) passed += 1
34
+ else failures += 1
35
+ }
36
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
37
+
38
+ async function waitUntil(name, cond, timeoutMs = 300_000, intervalMs = 1000) {
39
+ const deadline = Date.now() + timeoutMs
40
+ let last = null
41
+ while (Date.now() < deadline) {
42
+ try {
43
+ last = await cond()
44
+ if (last) return last
45
+ }
46
+ catch (e) { last = e }
47
+ await sleep(intervalMs)
48
+ }
49
+ throw new Error(`waitUntil timeout: ${name} (last=${String(last).slice(0, 200)})`)
50
+ }
51
+
52
+ const api = async (method, path, { body, token } = {}) => {
53
+ const headers = { 'content-type': 'application/json' }
54
+ if (token) headers.authorization = `Bearer ${token}`
55
+ const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined })
56
+ return { status: res.status, ...(await res.json().catch(() => ({}))) }
57
+ }
58
+
59
+ /** AEP WS:sub 一条频道,聚合信封 */
60
+ function openAep(channelId, token) {
61
+ const envelopes = []
62
+ const ws = new WebSocket(`${WS_BASE}/api/workshop/ws?token=${encodeURIComponent(token)}`)
63
+ ws.addEventListener('open', () => ws.send(JSON.stringify({ type: 'sub', channelId, token })))
64
+ ws.addEventListener('message', (ev) => {
65
+ try {
66
+ const e = JSON.parse(ev.data)
67
+ if (e.type && e.type !== 'pong') envelopes.push(e)
68
+ }
69
+ catch { /* ignore */ }
70
+ })
71
+ return { ws, envelopes }
72
+ }
73
+
74
+ /** 终端 WS:聚合 term 消息与帧;send 上行(input=空闲注入) */
75
+ function openTerm(agentId, channelId, token) {
76
+ const messages = []
77
+ const ws = new WebSocket(`${WS_BASE}/api/system/monitor/terminal/ws?agentId=${agentId}&channelId=${channelId}&token=${encodeURIComponent(token)}`)
78
+ ws.addEventListener('message', (ev) => {
79
+ try {
80
+ messages.push(JSON.parse(ev.data))
81
+ }
82
+ catch { /* ignore */ }
83
+ })
84
+ return {
85
+ ws,
86
+ messages,
87
+ send: obj => ws.send(JSON.stringify(obj)),
88
+ frames: () => messages.filter(m => m.type === 'term.frames').flatMap(m => m.frames),
89
+ }
90
+ }
91
+
92
+ /** 等待终端 WS 接入(spawn 前 NO_SESSION,4s 后重试;成功同时暂停 park TTL) */
93
+ async function openTermWithRetry(agentId, channelId, token, attempts = 60) {
94
+ for (let i = 0; i < attempts; i++) {
95
+ const t = openTerm(agentId, channelId, token)
96
+ const ok = await new Promise((resolve) => {
97
+ const to = setTimeout(() => resolve(false), 6000)
98
+ const probe = setInterval(() => {
99
+ const gotInit = t.messages.some(m => m.type === 'term.init')
100
+ const gotErr = t.messages.some(m => m.type === 'term.error')
101
+ if (!gotInit && !gotErr) return
102
+ clearInterval(probe)
103
+ clearTimeout(to)
104
+ resolve(gotInit)
105
+ }, 300)
106
+ })
107
+ if (ok) return t
108
+ await sleep(4000)
109
+ }
110
+ return null
111
+ }
112
+
113
+ /** 待办列表中匹配目标对话框(options 同时含两个词) */
114
+ const isTargetDialog = (item, a, b) => {
115
+ const opts = JSON.stringify(item.options ?? []).toLowerCase()
116
+ return opts.includes(a) && opts.includes(b)
117
+ }
118
+
119
+ async function main() {
120
+ console.log(`\n━━━ HITL 真实端到端 @ ${BASE} ━━━`)
121
+
122
+ const reg = await api('POST', '/api/users/register', {
123
+ body: { email: `hitl-e2e-${TAG}@test.local`, password: 'Passw0rd!123', name: `hitl-e2e-${TAG}` },
124
+ })
125
+ const token = reg.data?.token
126
+ if (!token) throw new Error(`注册失败: ${JSON.stringify(reg).slice(0, 200)}`)
127
+ const me = await api('GET', '/api/users/me', { token })
128
+ const channelName = `hitl-e2e-${TAG}`
129
+ const ch = await api('POST', '/api/workshop/channels', {
130
+ body: { name: channelName, description: 'hitl live e2e', leadAgent: { name: 'hitl-e2e-lead', harness: 'omp' } },
131
+ token,
132
+ })
133
+ const channelId = ch.data?.channelId
134
+ const leadAgentId = ch.data?.leadAgentId
135
+ check('W1 omp lead channel 创建', Boolean(channelId && leadAgentId), `channel=${channelId?.slice(0, 8)}`)
136
+
137
+ const aep = openAep(channelId, token)
138
+ await sleep(500)
139
+
140
+ // W2:提交首个任务(仅用于 spawn;调度循环的空闲询问属正常 HITL 待办)
141
+ const task = await api('POST', `/api/workshop/channels/${channelId}/tasks`, {
142
+ body: { title: `hitl-e2e-wait-${TAG}`, description: 'Wait for further instructions from the human operator via the terminal.' },
143
+ token,
144
+ })
145
+ check('W2 提交驻留任务(触发 omp spawn)', task.code === 0, `task=${task.data?.id?.slice(0, 8)}`)
146
+
147
+ console.log(' … 等待 omp spawn(冷启动可达 2-3 分钟)…')
148
+ const term = await openTermWithRetry(leadAgentId, channelId, token)
149
+ check('W2b 终端 WS 接入(agentId 寻址)', Boolean(term))
150
+
151
+ // 等 lead 空闲(调度循环的空闲询问 = 首个 hitl.request;不作为断言对象)
152
+ await waitUntil('lead 首回合结束(空闲询问到达)', () =>
153
+ aep.envelopes.some(e => e.type === 'hitl.request') || term.frames().some(f => f.frame.type === 'agent_end'), 300_000)
154
+ await sleep(2000)
155
+
156
+ // 注入 ask 指令(follow_up;omp 会撤销空闲询问并弹出新对话框)
157
+ term.send({ type: 'input', text: ASK_1 })
158
+ console.log(' … 等待 agent 弹出 ask 对话框(目标:options 含 yes/no)…')
159
+ const hitlReq = await waitUntil('AEP hitl.request 帧(yes/no)', () =>
160
+ aep.envelopes.find(e => e.type === 'hitl.request' && isTargetDialog(e.payload, 'yes', 'no')) ?? null, 300_000)
161
+ const item = hitlReq.payload
162
+ check('W3 hitl.request 帧到达(AEP)', true, `id=${item.id.slice(0, 8)}`)
163
+ check('W3 payload 语义(kind/channel/agent)', item.kind === 'omp-dialog' && item.channelId === channelId && item.agentId === leadAgentId && Boolean(item.agentName), `kind=${item.kind} agent=${item.agentName}`)
164
+ check('W3 对话框形态(select + yes/no)', item.method === 'select' && (item.options ?? []).some(o => String(o).toLowerCase().startsWith('yes')) && (item.options ?? []).some(o => String(o).toLowerCase().startsWith('no')), `method=${item.method} options=${JSON.stringify(item.options)}`)
165
+
166
+ const pending = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
167
+ check('W4 GET /hitl/pending 含目标待办', (pending.data?.items ?? []).some(i => i.id === item.id), `count=${pending.data?.items?.length}`)
168
+
169
+ // ── Phase U:真实浏览器渲染 ──
170
+ console.log(' ── Phase U:浏览器渲染 ──')
171
+ const chromePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
172
+ const browser = await puppeteer.launch({
173
+ executablePath: chromePath,
174
+ headless: true,
175
+ args: ['--no-sandbox', '--disable-gpu', '--window-size=1440,900'],
176
+ defaultViewport: { width: 1440, height: 900 },
177
+ })
178
+ const page = await browser.newPage()
179
+ await page.evaluateOnNewDocument((inject) => {
180
+ localStorage.setItem('workshop.user', JSON.stringify({ user: { ...inject.me, token: inject.token } }))
181
+ document.cookie = `token=${inject.token}; path=/; max-age=31536000`
182
+ }, { me: me.data ?? {}, token })
183
+ await page.goto(`${BASE}/monitor`, { waitUntil: 'networkidle2', timeout: 60_000 })
184
+ await sleep(2500)
185
+ const shot = async name => page.screenshot({ path: `${SHOT_DIR}/hitl-e2e-${name}.png` }).catch(() => {})
186
+
187
+ // 铃标出现(lead 自主组队会带来额外待办;只断言存在与计数≥1)
188
+ await page.waitForSelector('.hitl-bell', { timeout: 60_000 })
189
+ const badgeBefore = Number.parseInt(await page.$eval('.hitl-count', el => el.textContent.trim()).catch(() => '0'), 10) || 0
190
+ check('U1 AppHeader 铃标出现且计数≥1', badgeBefore >= 1, `badge=${badgeBefore}`)
191
+ await shot('1-badge')
192
+
193
+ await page.click('.hitl-bell')
194
+ await page.waitForSelector('.hitl-menu .hitl-item', { timeout: 10_000 })
195
+ // 按目标标题定位条目(列表可能同时含 lead 空闲询问/worker 对话框)
196
+ const hitlItems = await page.$$('.hitl-item')
197
+ let targetItem = null
198
+ let targetText = ''
199
+ for (const el of hitlItems) {
200
+ const txt = await el.evaluate(node => node.textContent)
201
+ if (txt.includes('Proceed?')) {
202
+ targetItem = el
203
+ targetText = txt
204
+ break
205
+ }
206
+ }
207
+ check('U2 下拉渲染目标条目(Proceed? · lead)', Boolean(targetItem) && targetText.includes('hitl-e2e-lead'), `text=${targetText.slice(0, 60)}`)
208
+ await shot('2-dropdown')
209
+
210
+ if (targetItem) {
211
+ await targetItem.click()
212
+ await sleep(2500)
213
+ }
214
+ const url = page.url()
215
+ check('U3 点击条目跳转 /monitor 定位', url.includes('/monitor') && url.includes(`agentId=${leadAgentId}`) && url.includes(`channelId=${channelId}`), `url=${url.slice(0, 110)}`)
216
+ await shot('3-jump-monitor')
217
+
218
+ // ── Phase W(续):统一应答 → 落定 → agent 收到答案 ──
219
+ const respond = await api('POST', '/api/workshop/hitl/respond', {
220
+ body: { kind: 'omp-dialog', id: item.id, value: 'yes' },
221
+ token,
222
+ })
223
+ check('W5 POST /hitl/respond 成功', respond.code === 0, respond.message ?? '')
224
+
225
+ const resolved = await waitUntil('AEP hitl.resolved 帧', () =>
226
+ aep.envelopes.find(e => e.type === 'hitl.resolved' && e.payload?.id === item.id) ?? null, 60_000)
227
+ check('W6 hitl.resolved 帧(answered)', resolved.payload?.outcome === 'answered', `outcome=${resolved.payload?.outcome}`)
228
+
229
+ const pendingAfter = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
230
+ check('W7 应答后目标待办清空', !(pendingAfter.data?.items ?? []).some(i => i.id === item.id), `count=${pendingAfter.data?.items?.length}`)
231
+
232
+ // 前端徽标随 resolved 下降/消隐(其他来源的待办可能仍在)
233
+ await page.waitForFunction((before) => {
234
+ const el = document.querySelector('.hitl-count')
235
+ return !el || (Number.parseInt(el.textContent.trim(), 10) || 0) < before
236
+ }, { timeout: 30_000 }, badgeBefore).catch(() => {})
237
+ const badgeNowEl = await page.$('.hitl-count')
238
+ const badgeNow = badgeNowEl ? Number.parseInt(await badgeNowEl.evaluate(el => el.textContent.trim()), 10) || 0 : 0
239
+ check('U4 前端徽标计数下降/消隐', !badgeNowEl || badgeNow < badgeBefore, `before=${badgeBefore} after=${badgeNow}`)
240
+ await shot('4-badge-cleared')
241
+
242
+ // 关键闭环证据:agent 的 ask 工具收到 'yes'
243
+ console.log(' … 等待 agent 消化答案(ask 工具返回)…')
244
+ const askFed = await waitUntil('ask 工具收到 yes(终端帧)', () => {
245
+ const list = term.frames().filter(f => f.frame.type === 'tool_execution_end' && String(f.frame.toolName) === 'ask')
246
+ const lastAsk = list.at(-1)
247
+ return lastAsk ? String(lastAsk.frame.result ?? '').toLowerCase().includes('yes') : null
248
+ }, 120_000).catch(() => null)
249
+ check('W8 闭环:答案经 ui_response 回传 agent(ask 结果含 yes)', Boolean(askFed))
250
+
251
+ // ═══════════════ Phase T:TUI 端到端(第二个 ask) ═══════════════
252
+ console.log(' ── Phase T:TUI(无头)──')
253
+ const { main: tuiMain } = await import('../tui/aw-tui.mjs')
254
+ await tuiMain(['--headless', '--url', BASE, '--token', token, '--channel', channelName])
255
+ const vt = globalThis.__tuiSmokeTerminal
256
+ const type = async (text) => {
257
+ for (const c of text) vt.emitInput(c)
258
+ await sleep(50)
259
+ vt.emitInput('\r')
260
+ await sleep(400)
261
+ }
262
+ const waitTui = async (needle, timeoutMs = 300_000) => {
263
+ const deadline = Date.now() + timeoutMs
264
+ while (Date.now() < deadline) {
265
+ if (vt.text().includes(needle)) return true
266
+ await sleep(500)
267
+ }
268
+ return false
269
+ }
270
+ check('T1 TUI 启动并接入频道', Boolean(vt) && await waitTui(`已切换到频道「${channelName}」`, 30_000))
271
+
272
+ // 等 ask 回合真正结束(在其 ask 工具帧之后出现 agent_end,且 term.state 已 idle)
273
+ // —— 不能用旧 term.state 判断,否则会在回合仍在运行时过早注入(被当 steer 吞掉)
274
+ const framesAtAsk = term.frames().length
275
+ await waitUntil('ask 回合结束(其后 agent_end + idle)', () => {
276
+ const fr = term.frames()
277
+ const endedAfterAsk = fr.slice(framesAtAsk).some(f => f.frame.type === 'agent_end')
278
+ const st = term.messages.filter(m => m.type === 'term.state').at(-1)
279
+ return endedAfterAsk && st && st.running === false ? true : null
280
+ }, 240_000).catch(() => {})
281
+ await sleep(2500)
282
+ term.send({ type: 'input', text: ASK_2 })
283
+
284
+ check('T2 TUI 状态条出现 HITL 待办', await waitTui('HITL 待处理'))
285
+ await type('/hitl')
286
+ check('T3 /hitl 列表渲染', await waitTui('待人工处理'))
287
+ // 按标题定位序号(列表里可能同时存在调度空闲询问)
288
+ const row = await waitUntil('T4 列表出现 Proceed2 行', () => {
289
+ const m = vt.text().match(/(\d+)\. \[omp-dialog\] Proceed2\?/)
290
+ return m ? m[1] : null
291
+ }, 60_000).catch(() => null)
292
+ check('T4 目标待办行渲染(Proceed2?)', Boolean(row), `序号=${row}`)
293
+ await type(`/hitl ${row}`)
294
+ check('T5 进入作答卡', await waitTui('HITL 作答'))
295
+ await type('1')
296
+ check('T6 作答提交回执', await waitTui('应答已提交'), '(select 选项 1 = confirm)')
297
+
298
+ const pend2 = await waitUntil('T7 REST 待办不再含 Proceed2', async () => {
299
+ const p = await api('GET', `/api/workshop/hitl/pending?channelId=${channelId}`, { token })
300
+ const items = p.data?.items ?? []
301
+ return items.every(i => !String(i.title).includes('Proceed2')) ? true : null
302
+ }, 60_000).catch(() => null)
303
+ check('T7 TUI 应答写入服务端', pend2 === true)
304
+
305
+ check('T8 TUI 渲染落定行(hitl.resolved 帧)', await waitTui('待办已落定', 60_000))
306
+ const askFed2 = await waitUntil('T9 ask 工具收到 confirm(终端帧)', () => {
307
+ const list = term.frames().filter(f => f.frame.type === 'tool_execution_end' && String(f.frame.toolName) === 'ask')
308
+ const lastAsk = list.at(-1)
309
+ return lastAsk ? String(lastAsk.frame.result ?? '').toLowerCase().includes('confirm') : null
310
+ }, 120_000).catch(() => null)
311
+ check('T9 闭环:答案回传 agent(ask 结果含 confirm)', Boolean(askFed2))
312
+
313
+ // ── 清理 + 汇总 ──
314
+ await browser.close().catch(() => {})
315
+ await api('DELETE', `/api/workshop/channels/${channelId}?purge=1`, { token }).catch(() => {})
316
+ console.log(`\n━━━ 结果: ${passed} passed / ${failures} failed ━━━`)
317
+ process.exit(failures === 0 ? 0 : 1)
318
+ }
319
+
320
+ main().catch((err) => {
321
+ console.error('FATAL', err.message)
322
+ process.exit(1)
323
+ })
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 产线管理 + 数控(DCW) + 数采(DAQ) 真实端到端(PLC 工艺模拟器):
3
+ * node scripts/e2e-line-live.mjs [--base http://127.0.0.1:3000]
4
+ *
5
+ * 链路:建产线 → 挂数采/数控节点 → 产品+配方 → 开跑 → 数采出数 →
6
+ * 数控下发(write,带回读校验)→ 越界拒绝 → 产线查询打标 → 清理。
7
+ */
8
+ const BASE = (() => {
9
+ const i = process.argv.indexOf('--base')
10
+ return i > 0 ? process.argv[i + 1] : 'http://127.0.0.1:3000'
11
+ })()
12
+ const TAG = Date.now().toString(36)
13
+
14
+ let failures = 0
15
+ let passed = 0
16
+ const check = (name, ok, detail = '') => {
17
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
18
+ if (ok) passed += 1
19
+ else failures += 1
20
+ }
21
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
22
+
23
+ async function waitUntil(name, cond, timeoutMs = 30_000, intervalMs = 1000) {
24
+ const deadline = Date.now() + timeoutMs
25
+ let last = null
26
+ while (Date.now() < deadline) {
27
+ try {
28
+ last = await cond()
29
+ if (last) return last
30
+ }
31
+ catch (e) { last = e }
32
+ await sleep(intervalMs)
33
+ }
34
+ throw new Error(`waitUntil timeout: ${name} (last=${String(last).slice(0, 160)})`)
35
+ }
36
+
37
+ const api = async (method, path, { body, token } = {}) => {
38
+ const headers = { 'content-type': 'application/json' }
39
+ if (token) headers.authorization = `Bearer ${token}`
40
+ const res = await fetch(`${BASE}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined })
41
+ return { status: res.status, ...(await res.json().catch(() => ({}))) }
42
+ }
43
+
44
+ async function main() {
45
+ console.log(`\n━━━ 产线/数控/数采 真实端到端 @ ${BASE} ━━━`)
46
+
47
+ const reg = await api('POST', '/api/users/register', {
48
+ body: { email: `line-e2e-${TAG}@test.local`, password: 'Passw0rd!123', name: `line-e2e-${TAG}` },
49
+ })
50
+ const token = reg.data?.token
51
+ if (!token) throw new Error(`注册失败: ${JSON.stringify(reg).slice(0, 160)}`)
52
+
53
+ // ── 1. 产线 + 节点 + 产品 + 配方 ──
54
+ const line = (await api('POST', '/api/workshop/dcw/lines', { body: { name: `线-e2e-${TAG}` }, token })).data?.line
55
+ check('1.1 建产线', Boolean(line?.id), `id=${line?.id?.slice(0, 8)}`)
56
+
57
+ const dq = (await api('POST', '/api/workshop/daq', {
58
+ body: { templateRef: 'daq-temp-tc', name: `温度采集-${TAG}`, lineId: line.id, intervalMs: 500 },
59
+ token,
60
+ })).data?.node
61
+ check('1.2 挂数采节点(daq-temp-tc)', Boolean(dq?.id), `id=${dq?.id?.slice(0, 8)}`)
62
+
63
+ const dw = (await api('POST', '/api/workshop/dcw', {
64
+ body: { templateRef: 'dcw-temp-sp', name: `温度设定-${TAG}`, lineId: line.id },
65
+ token,
66
+ })).data?.node
67
+ check('1.3 挂数控节点(dcw-temp-sp)', Boolean(dw?.id), `id=${dw?.id?.slice(0, 8)}`)
68
+
69
+ const prod = (await api('POST', '/api/workshop/dcw/products', {
70
+ body: { name: `产品-${TAG}`, lineId: line.id },
71
+ token,
72
+ })).data?.product
73
+ check('1.4 建产品', Boolean(prod?.id))
74
+
75
+ const recipe = (await api('POST', '/api/workshop/dcw/recipes', {
76
+ body: {
77
+ productId: prod.id,
78
+ name: `配方-${TAG}`,
79
+ params: [{ templateRef: 'dcw-temp-sp', nodeId: dw.id, value: 180, min: 176, max: 188 }],
80
+ daqWindows: [{ nodeId: dq.id, min: 100, max: 260 }],
81
+ },
82
+ token,
83
+ })).data?.recipe
84
+ check('1.5 建配方(SP=180 窗口 176-188 + 数采窗口)', Boolean(recipe?.id))
85
+
86
+ // ── 2. 开跑 → 数采出数 ──
87
+ const start = await api('POST', `/api/workshop/dcw/lines/${line.id}/start`, { body: { recipeId: recipe.id }, token })
88
+ check('2.1 产线开跑(配方激活)', start.code === 0 && (start.data?.line?.active ?? start.data?.run?.active ?? true), start.message ?? '')
89
+
90
+ console.log(' … 等待 PLC 工艺模拟器产出样本(10s)…')
91
+ await sleep(10_000)
92
+ const samples = await api('GET', `/api/workshop/daq/${dq.id}/samples`, { token, query: undefined })
93
+ const pts = samples.data?.points ?? samples.data ?? []
94
+ check('2.2 数采时序出数(模拟器)', Array.isArray(pts) && pts.length >= 3, `points=${Array.isArray(pts) ? pts.length : 'n/a'}`)
95
+ const latest = Array.isArray(pts) && pts.length > 0 ? pts.at(-1) : null
96
+ const latestVal = latest?.value ?? latest?.v
97
+ check('2.3 样本含工程量数值', Number.isFinite(Number(latestVal)), `latest=${JSON.stringify(latest)?.slice(0, 80)}`)
98
+
99
+ // ── 3. 数控下发(带回读校验) + 越界拒绝 ──
100
+ const write = await api('POST', `/api/workshop/dcw/${dw.id}/write`, { body: { value: 182 }, token })
101
+ check('3.1 手动下发 SP=182(写+回读校验 ACK)', write.code === 0 && write.data?.outcome?.ok !== false, JSON.stringify(write.data?.outcome ?? write.message)?.slice(0, 100))
102
+
103
+ const inRange = await waitUntil('3.2 设定值收敛到 182±2(工艺响应)', async () => {
104
+ const list = await api('GET', '/api/workshop/dcw', { token })
105
+ const nodes = Array.isArray(list.data) ? list.data : (list.data?.nodes ?? [])
106
+ const node = nodes.find(n => n.id === dw.id)
107
+ const v = Number(node?.readValue ?? node?.value ?? NaN)
108
+ return Number.isFinite(v) && Math.abs(v - 182) <= 2 ? v : null
109
+ }, 30_000, 1500).catch(() => null)
110
+ check('3.2 设定值收敛(182±2)', inRange !== null, `value=${inRange}`)
111
+
112
+ const bad = await api('POST', `/api/workshop/dcw/${dw.id}/write`, { body: { value: 999 }, token })
113
+ check('3.3 越界下发被拒(400 量程校验)', bad.status === 400 || bad.code === 400 || bad.code === 'VALIDATION_ERROR', `status=${bad.status} code=${bad.code}`)
114
+
115
+ // ── 4. 产线查询(工艺打标)──
116
+ // lineQuery 的 productId/recipeId 是过滤器:带上产品/配方过滤仍能查到样本,
117
+ // 即证明数采样本确实按「产品+配方」打标(开跑激活窗口后逐样本打标)
118
+ const lineData = await waitUntil('4.1 产线查询返回打标样本', async () => {
119
+ const q = await api('GET', `/api/workshop/dcw/line/query?lineId=${line.id}&nodeId=${dq.id}&from=${Date.now() - 600_000}`, { token })
120
+ const d = q.data ?? {}
121
+ const pts = (d.channels ?? []).reduce((acc, c) => acc + (c.points?.length ?? 0), 0)
122
+ return pts >= 1 ? d : null
123
+ }, 30_000, 2000).catch(() => null)
124
+ check('4.1 产线查询返回工艺样本(channels.points)', Boolean(lineData), lineData ? `channels=${lineData.channels?.length}` : 'empty')
125
+
126
+ const tagged = await api('GET', `/api/workshop/dcw/line/query?lineId=${line.id}&nodeId=${dq.id}&from=${Date.now() - 600_000}&productId=${prod.id}&recipeId=${recipe.id}`, { token })
127
+ const taggedPts = ((tagged.data?.channels ?? [])[0]?.points ?? []).length
128
+ check('4.2 按产品+配方过滤仍命中(样本打标验证)', taggedPts >= 1, `taggedPoints=${taggedPts}`)
129
+
130
+ // ── 清理 ──
131
+ await api('POST', `/api/workshop/dcw/lines/${line.id}/stop`, { token }).catch(() => {})
132
+ for (const [p, id] of [['/api/workshop/daq', dq.id], ['/api/workshop/dcw', dw.id]]) {
133
+ await api('DELETE', `${p}/${id}`, { token }).catch(() => {})
134
+ }
135
+ await api('DELETE', `/api/workshop/dcw/lines/${line.id}`, { token }).catch(() => {})
136
+
137
+ console.log(`\n━━━ 结果: ${passed} passed / ${failures} failed ━━━`)
138
+ process.exit(failures === 0 ? 0 : 1)
139
+ }
140
+
141
+ main().catch((err) => {
142
+ console.error('FATAL', err.message)
143
+ process.exit(1)
144
+ })
@@ -0,0 +1,112 @@
1
+ /**
2
+ * HITL 全局待办层单元测试(tsx 直跑):
3
+ * npx tsx scripts/test-hitl-registry.ts
4
+ *
5
+ * 覆盖:
6
+ * - hitl-registry:登记/快照/幂等/落定/per-channel 订阅/parkDeadline/resolver 补全
7
+ * - harness-terminal park 语义:零订阅 park(不再秒取消)/订阅暂停/退订重新计时/
8
+ * 应答落定 answered/TTL 到期落定 expired 并写 cancelled 到 omp stdin
9
+ *
10
+ * TTL 经 AW_SECURITY_HITL_TIMEOUT_MS=250 注入(须在首次 settings 读取前设置)。
11
+ */
12
+ import type { OmpRpcClient } from '../server/services/workshop/agents/adapters/omp-rpc-client'
13
+ import { securityHitlTimeoutMs } from '../server/services/workshop/settings'
14
+ import { getHitlRegistry } from '../server/services/workshop/agents/hitl-registry'
15
+ import {
16
+ attachTerminalTap,
17
+ respondTerminalUi,
18
+ subscribeTerminal,
19
+ } from '../server/services/workshop/agents/harness-terminal'
20
+
21
+ process.env.AW_SECURITY_HITL_TIMEOUT_MS = '250'
22
+
23
+ let failures = 0
24
+ const check = (name: string, cond: boolean, extra = '') => {
25
+ console.log(`${cond ? ' ✓' : ' ✗ FAIL'} ${name}${extra ? ` (${extra})` : ''}`)
26
+ if (!cond) failures++
27
+ }
28
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
29
+
30
+ console.log('[hitl] park TTL =', securityHitlTimeoutMs(), 'ms')
31
+ const reg = getHitlRegistry()
32
+
33
+ // ===== 1. registry 基础 =====
34
+ console.log('[1] hitl-registry 基础语义')
35
+ const events: Array<{ type: string, channelId?: string }> = []
36
+ const unsub = reg.subscribe('ch-1', (e) => {
37
+ events.push({ type: e.type, channelId: (e.payload as { channelId?: string }).channelId })
38
+ })
39
+ reg.register({ kind: 'omp-dialog', id: 'd1', agentId: 'a1', channelId: 'ch-1', agentName: 'lead', method: 'confirm', title: '确认下发?' })
40
+ check('登记后 snapshot 可见', reg.snapshot().length === 1 && reg.snapshot()[0]!.id === 'd1')
41
+ check('channel 订阅者收到 hitl.request', events.length === 1 && events[0]!.type === 'hitl.request')
42
+ reg.register({ kind: 'omp-dialog', id: 'd1', agentId: 'a1', channelId: 'ch-1', title: '重复登记' })
43
+ check('同 kind+id 幂等(不重复)', reg.snapshot().length === 1)
44
+ reg.register({ kind: 'dcw-approval', id: 'ap-x', agentId: 'a2', channelId: 'ch-2', title: 'DCW 下发审批' })
45
+ check('channelId 过滤生效', reg.snapshot('ch-2').length === 1 && reg.snapshot('ch-1').length === 1)
46
+ check('find 取回单条', reg.find('dcw-approval', 'ap-x')?.title === 'DCW 下发审批')
47
+ reg.setParkDeadline('omp-dialog', 'd1', '2026-01-01T00:00:00.000Z')
48
+ check('setParkDeadline 更新 expiresAt', reg.find('omp-dialog', 'd1')?.expiresAt === '2026-01-01T00:00:00.000Z')
49
+ check('resolve 落定并广播 hitl.resolved', reg.resolve('omp-dialog', 'd1', 'answered', 'user-1') && events[1]!.type === 'hitl.resolved')
50
+ check('落定后 snapshot 清空该条', reg.snapshot('ch-1').length === 0 && reg.find('omp-dialog', 'd1') === null)
51
+ check('重复 resolve 幂等返回 false', reg.resolve('omp-dialog', 'd1', 'answered') === false)
52
+ unsub()
53
+
54
+ // ===== 2. resolver 补全 =====
55
+ console.log('[2] resolver 补全 channelId/agentName')
56
+ reg.configureResolver(agentId => agentId === 'inst-9' ? { channelId: 'ch-9', agentName: '温控工' } : null)
57
+ reg.register({ kind: 'dcw-approval', id: 'ap-r', agentId: 'inst-9', title: 'DCW 下发审批' })
58
+ check('resolver 补全 channelId', reg.find('dcw-approval', 'ap-r')?.channelId === 'ch-9')
59
+ check('resolver 补全 agentName', reg.find('dcw-approval', 'ap-r')?.agentName === '温控工')
60
+ reg.resolve('dcw-approval', 'ap-r', 'cancelled')
61
+
62
+ // ===== 3. harness-terminal park 语义(假 omp 客户端) =====
63
+ console.log('[3] harness-terminal park 语义')
64
+ const written: Array<Record<string, unknown>> = []
65
+ const fakeClient = {
66
+ onRawFrame(fn: (f: Record<string, unknown>) => void) {
67
+ ;(fakeClient as unknown as { __emit: (f: Record<string, unknown>) => void }).__emit = fn
68
+ return () => {}
69
+ },
70
+ async send() {},
71
+ writeRaw(frame: Record<string, unknown>) { written.push(frame) },
72
+ } as unknown as OmpRpcClient
73
+
74
+ attachTerminalTap(fakeClient, { pid: 4242, harness: 'omp', agentId: 'agt-1', channelId: 'ch-1', name: '调度长', role: 'lead' })
75
+ const emit = (f: Record<string, unknown>) => (fakeClient as unknown as { __emit: (f: Record<string, unknown>) => void }).__emit(f)
76
+
77
+ emit({ type: 'extension_ui_request', id: 'dlg-1', method: 'confirm', title: '允许写入设定值?' })
78
+ await sleep(20)
79
+ check('零订阅到达 → park 而非秒取消(未写 cancelled 到 stdin)', !written.some(w => w.type === 'extension_ui_response'))
80
+ const parked = reg.find('omp-dialog', 'dlg-1')
81
+ check('登记进全局待办且带 park 截止', parked !== null && typeof parked?.expiresAt === 'string')
82
+
83
+ const unsubTerm = subscribeTerminal(4242, () => {})
84
+ await sleep(10)
85
+ check('订阅者接入 → 计时暂停(expiresAt=null)', reg.find('omp-dialog', 'dlg-1')?.expiresAt === null)
86
+
87
+ unsubTerm()
88
+ await sleep(10)
89
+ check('订阅者全部离开 → 重新计时(expiresAt 恢复未来时刻)', typeof reg.find('omp-dialog', 'dlg-1')?.expiresAt === 'string')
90
+
91
+ respondTerminalUi(4242, { id: 'dlg-1', confirmed: true })
92
+ await sleep(10)
93
+ check('应答写入 omp stdin(extension_ui_response confirmed)', written.some(w => w.type === 'extension_ui_response' && w.id === 'dlg-1' && w.confirmed === true))
94
+ check('应答后全局待办落定', reg.find('omp-dialog', 'dlg-1') === null)
95
+
96
+ // TTL 到期 → expired
97
+ emit({ type: 'extension_ui_request', id: 'dlg-2', method: 'select', title: '选择方案', options: ['A', 'B'] })
98
+ await sleep(20)
99
+ check('第二个对话框已 park', reg.find('omp-dialog', 'dlg-2') !== null)
100
+ await sleep(400)
101
+ check('park 超时 → 落定 expired 且全局待办清空', reg.find('omp-dialog', 'dlg-2') === null)
102
+ check('超时向 omp stdin 写 cancelled', written.some(w => w.type === 'extension_ui_response' && w.id === 'dlg-2' && w.cancelled === true))
103
+
104
+ // omp 主动撤销(method=cancel)→ 落定 cancelled
105
+ emit({ type: 'extension_ui_request', id: 'dlg-3', method: 'input', title: '补充说明' })
106
+ await sleep(20)
107
+ emit({ type: 'extension_ui_request', method: 'cancel', targetId: 'dlg-3' })
108
+ await sleep(20)
109
+ check('omp 撤销对话框 → 待办落定清空', reg.find('omp-dialog', 'dlg-3') === null)
110
+
111
+ console.log(failures === 0 ? '\n[hitl] 全部通过' : `\n[hitl] ${failures} 项失败`)
112
+ process.exit(failures === 0 ? 0 : 1)