agentworkshop 0.7.38 → 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 (44) 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 +706 -706
  16. package/.output/server/chunks/build/{default-hseyOqBT.mjs → default-CZmZFpqH.mjs} +27 -42
  17. package/.output/server/chunks/build/{default-hseyOqBT.mjs.map → default-CZmZFpqH.mjs.map} +1 -1
  18. package/.output/server/chunks/routes/renderer.mjs +1 -1
  19. package/.output/server/chunks/virtual/entry.mjs +3 -1
  20. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  21. package/.output/server/package.json +1 -1
  22. package/README-zh.md +6 -6
  23. package/README.md +6 -6
  24. package/app/components/AppHeader.vue +0 -17
  25. package/app/components/AppSidebar.vue +1 -0
  26. package/docs/full-test-plan.md +4 -0
  27. package/docs/plugins.en.md +1 -0
  28. package/docs/plugins.md +1 -0
  29. package/i18n/locales/en.ts +1 -0
  30. package/i18n/locales/zh-CN.ts +1 -0
  31. package/package.json +1 -1
  32. package/scripts/_audit/detached-start.mjs +103 -0
  33. package/scripts/_audit/guard-upstream-liveness.mjs +104 -0
  34. package/scripts/_dbg-perms-e2e.mjs +10 -3
  35. package/scripts/_dbg-protocol-matrix.mjs +453 -441
  36. package/scripts/_dbg-render-regression.mjs +151 -130
  37. package/scripts/_dbg-town-agents-probe.mjs +60 -0
  38. package/scripts/_dbg-visual-acceptance.mjs +63 -0
  39. package/scripts/e2e-agent-team.mjs +5 -3
  40. package/scripts/three-system-e2e.mjs +6 -6
  41. package/server/plugins-builtin/rag-bridge/index.mjs +9 -2
  42. package/.output/public/_nuxt/DVsjtsvZ2.js +0 -1
  43. package/.output/public/_nuxt/builds/meta/44b66249-54d0-4c8a-9057-9755a32329e2.json +0 -1
  44. package/.output/public/_nuxt/default.DE_vT238.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentworkshop-prod",
3
- "version": "0.7.38",
3
+ "version": "0.7.39",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "dependencies": {
package/README-zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  **[English →](./README.md)** · **[在线文档 →](https://kingdol666.github.io/AgentWorkShop/)** · **[版本发布 →](https://github.com/kingdol666/AgentWorkShop/releases)** · **[更新日志 →](./changelog.md)**
17
17
 
18
- *当前版本:**v0.7.38** · 14 个执行引擎 · 5 种现场协议 · 99 个运行时设置项 · 双语文档(简体中文 / English)*
18
+ *当前版本:**v0.7.39** · 14 个执行引擎 · 5 种现场协议 · 99 个运行时设置项 · 双语文档(简体中文 / English)*
19
19
 
20
20
  *一个配置驱动的平台:**AI Agent 团队**与**工业数字孪生**共享同一运行时——Agent 查询真实遥测、经人工审批的写控回路下发监督设定值,每个事件实时推送到 3D 孪生。*
21
21
 
@@ -253,7 +253,7 @@ aw update --check # 只报告,不安装
253
253
  npm install -g agentworkshop@latest # 手动等效
254
254
  ```
255
255
 
256
- 版本遵循 semver。每次 `aw start` 都会校验配置根,并把 `home` 之前的旧版 `data/` 布局迁移进来(最新文件胜出),因此数据可以跨版本存活。SQLite schema 迁移在服务端启动时执行。当前版本:**v0.7.38**——见[版本发布](https://github.com/kingdol666/AgentWorkShop/releases)。
256
+ 版本遵循 semver。每次 `aw start` 都会校验配置根,并把 `home` 之前的旧版 `data/` 布局迁移进来(最新文件胜出),因此数据可以跨版本存活。SQLite schema 迁移在服务端启动时执行。当前版本:**v0.7.39**——见[版本发布](https://github.com/kingdol666/AgentWorkShop/releases)。
257
257
 
258
258
  ### 第一次「Agent × 产线」会话(约 2 分钟)
259
259
 
@@ -436,10 +436,10 @@ SUBMITTED ─▶ ASSIGNED ─▶ WORKING ─▶ WAITING ─▶ COMPLETED
436
436
  |---|---|---|---|
437
437
  | `e2e-full-closedloop.mjs` | **124 PASS / 0 FAIL**(2026-09-12,v0.7.36) | 注册 → 登录 → 产线/产品/配方 → 数采采样 → Agent 绑定节点 → `daq_query` → `dcw_control` → HITL 审批 → PLC 写入 → 回读 → 配方回退 → 级联删除 → 数据根隔离 | `node scripts/e2e-full-closedloop.mjs http://127.0.0.1:3111` |
438
438
  | `e2e-aml.ts --real` | 0 失败(2026-09-11) | AML 数据集 → 作业提交 → 状态/日志 → 排行榜 → 晋级门禁,跑在真实 Python 运行时上 | `node node_modules/tsx/dist/cli.mjs --tsconfig .nuxt/tsconfig.server.json scripts/e2e-aml.ts --real` |
439
- | 五协议真实产线 | 37/37 | 逐协议连通、数采入 Timescale、逐协议数控下发 + 回读、Agent 闭环、HITL 经真实 OPC UA 写入、配方 + 参数账本回退 | `node scripts/_dbg-live-line-e2e.mjs` |
440
- | 生产 API 全链路 | 64/64 | 跨重启持久化、模板 CRUD、任务 assign/complete/cancel/loop/pipeline、A2A + mailbox、WS 广播、MCP 端点、级联删除 | `AW_E2E_TOKEN=<token> node scripts/api-live-e2e.mjs` |
441
- | 产线权限 / 审计负向 | 20/20 + 9/9 | 三态产线授权 + 人话 403、授权撤销收敛、无 token WS 零遥测 | `scripts/e2e-auth-matrix.mjs` |
442
- | 渲染回归 | 29/29 | 227 行数采表完整性、WS 驱动行更新、筛选、详情页、3D 小镇 + 模型库、7 页 smoke、零 pageerror | `node scripts/_dbg-render-regression.mjs <base> <email> <pass>` |
439
+ | 五协议真实产线 | 37/37(2026-09-12 干净环境复验) | 逐协议连通、数采入 Timescale、逐协议数控下发 + 回读、Agent 闭环、HITL 经真实 OPC UA 写入、配方 + 参数账本回退 | `node scripts/_dbg-live-line-e2e.mjs` |
440
+ | 生产 API 全链路 | 64/64(2026-09-12 干净环境复验) | 跨重启持久化、模板 CRUD、任务 assign/complete/cancel/loop/pipeline、A2A + mailbox、WS 广播、MCP 端点、级联删除 | `AW_E2E_TOKEN=<token> node scripts/api-live-e2e.mjs` |
441
+ | 产线权限 / 审计负向 | 21/21 + 9/9 | 三态产线授权 + 人话 403、绑定主体校验、授权撤销收敛、无 token WS 零遥测 | `node scripts/_dbg-perms-e2e.mjs <base> <adminPass>` · `node scripts/_dbg-audit-neg-e2e.mjs <base> <adminPass>` |
442
+ | 渲染回归 | 30/30 | 数采表完整性(行数与 API 动态对齐)、WS 驱动行更新、筛选、详情页、3D 小镇 + 模型库、7 页 smoke、零 pageerror | `node scripts/_dbg-render-regression.mjs <base> <email> <pass>` |
443
443
  | 多 Harness 并行 | 21 | omp 闭环 · codex 真实寄存器写入 · dsh 真实数采 · opencode 配方写入+回退——四引擎在一条开跑产线上 | `node scripts/e2e-multiharness-team.mjs` |
444
444
  | 离线单元/属性套件 | 全绿 | AEP 事件索引(`test-events-index`)、LRU、数据根拆分(`test-data-root`)、日志洪泛、回退索引、插件加固、记忆按月查询、CLI 退出码(`test-cli-exit`)、SDK 能力面(`test-sdk-surface`) | `node scripts/test-<name>.mjs` |
445
445
 
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  **[中文文档 →](./README-zh.md)** · **[Online Docs →](https://kingdol666.github.io/AgentWorkShop/)** · **[Releases →](https://github.com/kingdol666/AgentWorkShop/releases)** · **[Changelog →](./changelog.md)**
17
17
 
18
- *Current version: **v0.7.38** · 14 agent engines · 5 field protocols · 99 runtime settings · bilingual docs (简体中文 / English)*
18
+ *Current version: **v0.7.39** · 14 agent engines · 5 field protocols · 99 runtime settings · bilingual docs (简体中文 / English)*
19
19
 
20
20
  *A configuration-driven platform where **AI agent teams** and an **industrial digital twin** share one runtime — agents query real telemetry, issue supervisory setpoints through human-approved write control, and every event streams live to a 3D twin.*
21
21
 
@@ -253,7 +253,7 @@ aw update --check # only report; nothing is installed
253
253
  npm install -g agentworkshop@latest # manual equivalent
254
254
  ```
255
255
 
256
- Releases follow semver. `aw start` verifies the config root on every launch and migrates the legacy pre-`home` `data/` layout into it (newest file wins), so data survives upgrades. SQLite schema migrations run server-side at boot. Current version: **v0.7.38** — see [Releases](https://github.com/kingdol666/AgentWorkShop/releases).
256
+ Releases follow semver. `aw start` verifies the config root on every launch and migrates the legacy pre-`home` `data/` layout into it (newest file wins), so data survives upgrades. SQLite schema migrations run server-side at boot. Current version: **v0.7.39** — see [Releases](https://github.com/kingdol666/AgentWorkShop/releases).
257
257
 
258
258
  ### Your first agent × line session (~2 minutes)
259
259
 
@@ -444,10 +444,10 @@ the event stream and the HTTP API — not on mocks.
444
444
  |---|---|---|---|
445
445
  | `e2e-full-closedloop.mjs` | **124 PASS / 0 FAIL** (2026-09-12, v0.7.36) | registration → login → line/product/recipe → DAQ sampling → agent bound to nodes → `daq_query` → `dcw_control` → HITL approval → PLC write → readback → recipe rollback → cascade delete → data-root isolation | `node scripts/e2e-full-closedloop.mjs http://127.0.0.1:3111` |
446
446
  | `e2e-aml.ts --real` | 0 failures (2026-09-11) | AML datasets → job submit → status/logs → leaderboard → promotion gates, against the real Python runtime | `node node_modules/tsx/dist/cli.mjs --tsconfig .nuxt/tsconfig.server.json scripts/e2e-aml.ts --real` |
447
- | Five-protocol live line | 37/37 | driver connectivity, sampling into Timescale, DCW dispatch + readback per protocol, agent closed loop, HITL over a real OPC UA write, recipe + param-ledger rollback | `node scripts/_dbg-live-line-e2e.mjs` |
448
- | Production API live | 64/64 | persistence across restart, template CRUD, task assign/complete/cancel/loop/pipeline, A2A + mailbox, WS broadcast, MCP endpoint, cascade delete | `AW_E2E_TOKEN=<token> node scripts/api-live-e2e.mjs` |
449
- | Line permissions / audit-negative | 20/20 + 9/9 | three-state line grants with human-readable 403s, revocation convergence, unauthenticated WS receives zero telemetry | `scripts/e2e-auth-matrix.mjs` |
450
- | Render regression | 29/29 | 227-row DAQ table integrity, WS-driven row updates, filters, detail page, 3D town + model library, 7-page smoke, zero page errors | `node scripts/_dbg-render-regression.mjs <base> <email> <pass>` |
447
+ | Five-protocol live line | 37/37 (re-verified 2026-09-12 on a clean instance) | driver connectivity, sampling into Timescale, DCW dispatch + readback per protocol, agent closed loop, HITL over a real OPC UA write, recipe + param-ledger rollback | `node scripts/_dbg-live-line-e2e.mjs` |
448
+ | Production API live | 64/64 (re-verified 2026-09-12 on a clean instance) | persistence across restart, template CRUD, task assign/complete/cancel/loop/pipeline, A2A + mailbox, WS broadcast, MCP endpoint, cascade delete | `AW_E2E_TOKEN=<token> node scripts/api-live-e2e.mjs` |
449
+ | Line permissions / audit-negative | 21/21 + 9/9 | three-state line grants with human-readable 403s, binding-subject validation, revocation convergence, unauthenticated WS receives zero telemetry | `node scripts/_dbg-perms-e2e.mjs <base> <adminPass>` · `node scripts/_dbg-audit-neg-e2e.mjs <base> <adminPass>` |
450
+ | Render regression | 30/30 | DAQ table integrity (row count aligned with the API), WS-driven row updates, filters, detail page, 3D town + model library, 7-page smoke, zero page errors | `node scripts/_dbg-render-regression.mjs <base> <email> <pass>` |
451
451
  | Multi-harness parallel | 21 | omp closed loop · codex real register write · dsh real acquisition · opencode recipe write+rollback — four engines on one running line | `node scripts/e2e-multiharness-team.mjs` |
452
452
  | Offline unit/property suites | all green | AEP event index (`test-events-index`), LRU, data-root split (`test-data-root`), log flooding, rollback index, plugin hardening, memory month query, CLI exit codes (`test-cli-exit`), SDK surface (`test-sdk-surface`) | `node scripts/test-<name>.mjs` |
453
453
 
@@ -204,17 +204,6 @@ const onAvatarMenu: MenuProps['onClick'] = async ({ key }) => {
204
204
 
205
205
  <span class="rail-mark i-tabler-route" />
206
206
 
207
- <!-- AML 建模入口:固定导航点(航迹由访问历史生成,/aml 需常驻入口) -->
208
- <NuxtLink
209
- class="trail-node aml-entry"
210
- :class="{ active: route.path.startsWith('/aml') }"
211
- to="/aml"
212
- :title="t('aml.k1amlx159')"
213
- >
214
- <span class="node-icon i-tabler-flask" />
215
- <span class="node-title">{{ t('aml.k1amlx158') }}</span>
216
- </NuxtLink>
217
-
218
207
  <nav
219
208
  class="trail"
220
209
  :aria-label="t('header.trail')"
@@ -462,12 +451,6 @@ const onAvatarMenu: MenuProps['onClick'] = async ({ key }) => {
462
451
  color: var(--ink-faint);
463
452
  }
464
453
 
465
- /* AML 固定入口:复用航点 chip 语言,紧贴 rail-mark 常驻 */
466
- .aml-entry {
467
- flex: 0 0 auto;
468
- margin-right: 2px;
469
- }
470
-
471
454
  .trail {
472
455
  flex: 0 1 auto;
473
456
  min-width: 0;
@@ -38,6 +38,7 @@ const menuGroups = computed<MenuGroup[]>(() => [
38
38
  { key: '/town', icon: 'i-tabler-map-2', label: t('menu.town'), motion: 'im-pop' },
39
39
  { key: '/daq', icon: 'i-tabler-activity', label: t('menu.daq'), motion: 'im-pop' },
40
40
  { key: '/dcw', icon: 'i-tabler-settings-automation', label: t('menu.dcw'), motion: 'im-pop' },
41
+ { key: '/aml', icon: 'i-tabler-flask', label: t('menu.aml'), motion: 'im-pop' },
41
42
  ],
42
43
  },
43
44
  {
@@ -20,6 +20,8 @@
20
20
  - Bash 写源码/配置会被 Mimosa hook 拒绝 → 用 Write/Edit 工具;git add 源码路径同样被拒。
21
21
  - commitlint:subject 首字符不得大写(中文开头最稳),body 每行 ≤100 字符。本仓库有并行会话共享 git index:commit 必须 pathspec 限定,提交后 `git log` 核对没被卷走。
22
22
  - 共享实例有累积脏数据(数百产线/未结批次):判断「数采没采」前先确认产线批次门控(见 §8 排障表)。
23
+ - **长跑服务别挂在 agent 工具的后台作业里**:这类作业在回合结束/被回收时会连子进程一起终止,而 `aw start` 的 stdout 缓冲区随之丢失 —— 表现就是「日志停在半路、无 fatal、端口释放、退出码 1」,极易误判成服务自己崩了。要做长跑验收请**真分离**:`node scripts/_audit/detached-start.mjs --port 3001`(spawn + detached + stdio 落文件;`--status` 看健康与日志尾,`--stop` 停),之后只靠 `/api/health` 与日志文件判断存活。实测踩过三次「静默退出」,`[stability-guard]` 一行 fatal 都没有。
24
+ - **被强杀会留下陈旧单实例锁**:`aw start` 被外部终止后来不及释放 `.AgentWorkShop/.runtime/aw.lock`,下一次启动会以退出码 2 直接退出(日志只有一行 `__AW_START_EXIT=2`,没有报错正文)。`aw stop` 会清理它(即使锁里的 pid 属于**另一个**模式);`detached-start.mjs` 也内置了这步。
23
25
 
24
26
  **报告产物**:`docs/test-report-<版本>.md`,逐阶段列脚本 → 断言数 → 结果 → 失败详情与处置。
25
27
 
@@ -163,6 +165,8 @@
163
165
  | npm publish/install 被 7890 拦 | `--no-proxy --https-proxy=null --proxy=null`;git push 用 `-c http.proxy=` |
164
166
  | dev 起服务 500 找不到 `D:\shared\*.mjs` | nitro 相对导入打包坑:须 `@/` 别名(勿用裸相对路径) |
165
167
  | tsx 报类型/import 错 | 必须带 `--tsconfig .nuxt/tsconfig.server.json` |
168
+ | 长跑中途服务**静默消失**、端口释放、日志里**没有** fatal 行 | 两种可能,靠"日志有无 `[stability-guard] fatal`"区分:① 被外部强杀(`taskkill`/句柄回收)—— 退出码 1 且 stdout 缓冲区丢失,属进程外因素;② 走到 fatal 分支 —— 会有 `fatal <kind>, exiting:` 加完整错误文本。护栏只有四类异常**不退进程**:`socket`(ECONNRESET/EPIPE)/`db-busy`(SQLITE_BUSY)/`upstream`(ECONNREFUSED/ENOTFOUND/EHOSTUNREACH/ENETUNREACH/连接超时)/`engine`(omp·codex·dsh·opencode API),其余一律 fatal。判据集中在 `server/utils/stability-severity.ts`,可被纯 node 直接断言(`test-plugin-hardening.mjs` 第 3 节,16 条)|
169
+ | 一台设备没开就带走了整个平台(数采/数控/孪生全挂) | 曾实测:Modbus TCP 设备未启动(`connect ECONNREFUSED 127.0.0.1:1502`)触发 `unhandledRejection` 走到 fatal → 生产实例 `exit 1`。`ECONNREFUSED` 等**外部设备/上游不可达**已归入 `upstream`(只记录 + 计数,不退进程)—— 设备离线是运维常态,DAQ 驱动本来就把失败记为节点 `lastError`/`state=offline` 并继续轮询。回归验证:`node scripts/_audit/guard-upstream-liveness.mjs --port 1502`(建一条指向死端口的节点 → 断言服务仍健康)|
166
170
 
167
171
  ---
168
172
 
@@ -500,6 +500,7 @@ export default {
500
500
  | Symptom | Cause / fix |
501
501
  |---|---|
502
502
  | Plugin code edits have no effect | the host has no plugin-directory watcher: touch `<home>/plugins-state.json`, or toggle `aw plugin enable/disable` once, to trigger a reload (the reload imports with `?t=` to defeat the cache); core `server/` and `shared/` changes still need a process restart |
503
+ | Recreated a same-named plugin but routes 404 | a name previously disabled keeps its flag in `<home>/plugins-state.json`; recreating the directory does not reload it — run `aw plugin enable <name>` explicitly after recreating (verified in practice) |
503
504
  | Load failures are invisible | `aw plugin list` does **not** show them. Use `GET /api/workshop/plugins` (returns `{ plugins, failures, initedAt }`, **no `{code,data}` envelope**) or read `[aw-plugins] 装载失败 …` in the startup log |
504
505
  | Plugin route 404 | matching is exact; check the method and the leading `/` of the path, and whether the plugin is enabled (a disabled plugin's routes are dropped from the table) |
505
506
  | Plugin route 401 | the entry declared `auth: 'user' \| 'admin' \| 'agent-or-user'` and the request carried no valid credential; use `auth: 'none'` only for open endpoints |
package/docs/plugins.md CHANGED
@@ -497,6 +497,7 @@ export default {
497
497
  | 现象 | 原因 / 处理 |
498
498
  |---|---|
499
499
  | 改了插件代码没生效 | 宿主没有插件目录 watcher:触碰 `<home>/plugins-state.json` 或 `aw plugin enable/disable` 往返一次触发重载(重载会带 `?t=` 破缓存);核心 `server/`、`shared/` 改动仍需重启进程 |
500
+ | 重建同名插件后路由 404 | 此前 disable 过的插件名在 `<home>/plugins-state.json` 里留有禁用标记,重建目录不会自动恢复装载 —— 重建后必须显式 `aw plugin enable <name>`(实测踩过) |
500
501
  | 装载失败看不到 | `aw plugin list` **不显示**装载失败。用 `GET /api/workshop/plugins`(返回 `{ plugins, failures, initedAt }`,**没有 `{code,data}` 信封**),或看启动日志里的 `[aw-plugins] 装载失败 …` |
501
502
  | 插件路由 404 | exact-match;检查 method 与 path 前导 `/`,以及插件是否处于启用态(停用插件的路由已从路由表移除) |
502
503
  | 插件路由 401 | 入口声明了 `auth: 'user' \| 'admin' \| 'agent-or-user'`,请求未带有效凭据;开放接口才用 `auth: 'none'` |
@@ -25,6 +25,7 @@ export default {
25
25
  tokens: 'API Tokens',
26
26
  daq: 'DAQ Console',
27
27
  dcw: 'Line Ops',
28
+ aml: 'Modeling',
28
29
  logs: 'Logs',
29
30
  users: 'Users',
30
31
  monitor: 'Runtime Monitor',
@@ -25,6 +25,7 @@ export default {
25
25
  tokens: 'API Token',
26
26
  daq: '数采中心',
27
27
  dcw: '产线运营',
28
+ aml: '建模',
28
29
  logs: '日志管理',
29
30
  users: '用户管理',
30
31
  monitor: '运行时监控',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentworkshop",
3
- "version": "0.7.38",
3
+ "version": "0.7.39",
4
4
  "type": "module",
5
5
  "description": "AgentWorkShop 软件开发系统 - 基于 Nuxt 4 的配置驱动运行范式",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 把 `aw start` 以**真分离**方式拉起(脱离 agent 作业的进程组),日志落文件。
3
+ *
4
+ * 为什么需要它:agent 工具的后台作业在回合结束/被回收时会连子进程一起终止,
5
+ * 而 `aw start` 的 stdout 缓冲区随之丢失 —— 表现为「日志停在半路、无 fatal 行、
6
+ * 端口释放、退出码 1」,极易误判成服务自己崩了(实测踩过两次)。
7
+ *
8
+ * 做法:node 的 spawn + detached + stdio 重定向到文件。
9
+ * Windows 上还要 unref 并用 windowsHide,否则父进程退出时子进程一起走。
10
+ *
11
+ * 用法:
12
+ * node scripts/_audit/detached-start.mjs [--log <path>] [--cwd <dir>] [--port 3001] [--wait 45]
13
+ * node scripts/_audit/detached-start.mjs --status # 只看健康门与日志尾
14
+ * node scripts/_audit/detached-start.mjs --stop # 停掉分离实例
15
+ */
16
+ import { spawn, spawnSync } from 'node:child_process'
17
+ import { closeSync, existsSync, openSync, readFileSync, statSync } from 'node:fs'
18
+ import { dirname, join, resolve } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const arg = (k, d) => {
22
+ const i = process.argv.indexOf(k)
23
+ return i >= 0 ? process.argv[i + 1] : d
24
+ }
25
+ const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
26
+ const CWD = resolve(arg('--cwd', repo))
27
+ const PORT = Number(arg('--port', '3001'))
28
+ const LOG = resolve(arg('--log', join(CWD, '..', 'aw-detached.log')))
29
+ const PIDFILE = LOG + '.pid'
30
+ const BASE = `http://127.0.0.1:${PORT}`
31
+
32
+ const health = async () => {
33
+ try {
34
+ const r = await fetch(`${BASE}/api/health`, { signal: AbortSignal.timeout(6000) })
35
+ const j = await r.json()
36
+ return j?.data?.status === 'ok' ? { ok: true, version: j.data.version, uptimeMs: j.data.uptimeMs } : { ok: false }
37
+ }
38
+ catch { return { ok: false } }
39
+ }
40
+
41
+ if (process.argv.includes('--status')) {
42
+ const h = await health()
43
+ console.log(h.ok ? `✔ 健康 version=${h.version} uptime=${Math.round(h.uptimeMs / 1000)}s` : '✖ 不可达')
44
+ const pid = existsSync(PIDFILE) ? readFileSync(PIDFILE, 'utf8').trim() : '(无 pid 文件)'
45
+ console.log(` 分离 pid 文件: ${pid}`)
46
+ if (existsSync(LOG)) {
47
+ const size = statSync(LOG).size
48
+ const tail = readFileSync(LOG, 'utf8').split(/\r?\n/).slice(-6)
49
+ console.log(` 日志 ${LOG} (${(size / 1024).toFixed(1)} KB) 尾部:`)
50
+ for (const l of tail) if (l.trim()) console.log(' ' + l.slice(0, 150))
51
+ }
52
+ process.exitCode = h.ok ? 0 : 1
53
+ }
54
+
55
+ else if (process.argv.includes('--stop')) {
56
+ const pid = existsSync(PIDFILE) ? Number(readFileSync(PIDFILE, 'utf8').trim()) : NaN
57
+ if (Number.isInteger(pid)) {
58
+ const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
59
+ console.log(r.status === 0 ? `✔ 已终止分离实例 pid=${pid}` : `· taskkill 退出码 ${r.status}(进程可能已退出)`)
60
+ }
61
+ else console.log('· 无 pid 文件,改用 aw stop')
62
+ if (!Number.isInteger(pid)) spawnSync('aw', ['stop'], { cwd: CWD, stdio: 'inherit', shell: true })
63
+ }
64
+
65
+ else {
66
+ const pre = await health()
67
+ if (pre.ok) {
68
+ console.log(`· ${BASE} 已有健康实例(version=${pre.version}),不重复启动`)
69
+ process.exitCode = 0
70
+ }
71
+ else {
72
+ const out = openSync(LOG, 'a')
73
+ // 只清大小写重复的代理变量:7890 会拦 localhost
74
+ const env = { ...process.env, NO_PROXY: '127.0.0.1,localhost', HTTP_PROXY: '', HTTPS_PROXY: '' }
75
+ const child = spawn(process.execPath, [join(repo, 'bin', 'aw.mjs'), 'start', '--port', String(PORT)], {
76
+ cwd: CWD,
77
+ env,
78
+ detached: true,
79
+ windowsHide: true,
80
+ stdio: ['ignore', out, out],
81
+ })
82
+ closeSync(out)
83
+ child.unref()
84
+ const { writeFileSync } = await import('node:fs')
85
+ writeFileSync(PIDFILE, String(child.pid), 'utf8')
86
+ console.log(`✔ 已分离启动 pid=${child.pid} → ${BASE},日志 ${LOG}`)
87
+
88
+ const waitS = Number(arg('--wait', '45'))
89
+ for (let i = 0; i < waitS; i += 3) {
90
+ await new Promise(r => setTimeout(r, 3000))
91
+ const h = await health()
92
+ if (h.ok) {
93
+ console.log(`✔ 健康门通过 version=${h.version}(${(i + 3)}s)`)
94
+ process.exitCode = 0
95
+ break
96
+ }
97
+ }
98
+ if (process.exitCode === undefined) {
99
+ console.error(`✖ ${waitS}s 内未通过健康门,看日志:${LOG}`)
100
+ process.exitCode = 1
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 稳定性护栏端到端验证:外部设备不可达时,服务端**必须存活**。
3
+ *
4
+ * 复现的真实事故:一台 Modbus TCP 设备未启动(connect ECONNREFUSED 127.0.0.1:1502)
5
+ * 触发 unhandledRejection 走到 fatal 分支 → 整个生产实例 exit 1 →
6
+ * 数采/数控/数字孪生全平台被一台设备带走。
7
+ *
8
+ * 做法:
9
+ * 1. 建立指向**必然拒绝连接**的端口的 Modbus TCP 数采节点(默认 1502,可 --port 覆盖)
10
+ * 2. 等采集轮询真正发起连接(默认 25s)
11
+ * 3. 断言:健康门仍 200、进程未死、日志出现 upstream 分级记录
12
+ *
13
+ * 用法:node scripts/_audit/guard-upstream-liveness.mjs [--base http://127.0.0.1:3001] [--port 1502] [--wait 25] [--keep]
14
+ */
15
+ const arg = (k, d) => {
16
+ const i = process.argv.indexOf(k)
17
+ return i >= 0 ? process.argv[i + 1] : d
18
+ }
19
+ const BASE = arg('--base', 'http://127.0.0.1:3001')
20
+ const DEAD_PORT = Number(arg('--port', '1502'))
21
+ const WAIT_S = Number(arg('--wait', '25'))
22
+ const KEEP = process.argv.includes('--keep')
23
+ const TOKEN = process.env.AW_E2E_TOKEN
24
+ ?? (await (await fetch(`${BASE}/api/users/login`, {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json' },
27
+ body: JSON.stringify({ email: 'admin@awshop.local', password: 'admin123' }),
28
+ })).json()).data.token
29
+
30
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
31
+ const api = async (method, path, body) => {
32
+ const r = await fetch(BASE + path, {
33
+ method,
34
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${TOKEN}` },
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ })
37
+ return r.json().catch(() => ({}))
38
+ }
39
+
40
+ let pass = 0
41
+ let fail = 0
42
+ const check = (name, ok, detail = '') => {
43
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
44
+ ok ? pass++ : fail++
45
+ }
46
+
47
+ const health = async () => {
48
+ try {
49
+ const r = await fetch(`${BASE}/api/health`, { signal: AbortSignal.timeout(5000) })
50
+ const j = await r.json()
51
+ return { ok: r.ok && j?.data?.status === 'ok', version: j?.data?.version }
52
+ }
53
+ catch (e) {
54
+ return { ok: false, error: String(e?.message ?? e) }
55
+ }
56
+ }
57
+
58
+ console.log(`\n━━━ 稳定性护栏:外部设备不可达不应带走服务(设备端口 :${DEAD_PORT}) ━━━`)
59
+ const before = await health()
60
+ check('前置:服务健康', before.ok, `version=${before.version}`)
61
+
62
+ // 1) 建一条指向死端口的 Modbus TCP 节点。字段形状对齐既有节点:
63
+ // templateRef 必填(信号模板)、地址走 driverConfig.register。
64
+ const ts = Date.now()
65
+ const line = await api('POST', '/api/workshop/dcw/lines', { name: `护栏验证线-${ts}`, productId: null })
66
+ const lineId = line?.data?.line?.id ?? line?.data?.id
67
+ check('创建验证产线', !!lineId, lineId ?? JSON.stringify(line).slice(0, 120))
68
+
69
+ const node = await api('POST', '/api/workshop/daq', {
70
+ lineId,
71
+ name: `死端口节点-${ts}`,
72
+ templateRef: arg('--template', 'daq-temp-tc'),
73
+ driver: 'modbus-tcp',
74
+ unit: '℃',
75
+ intervalMs: 500,
76
+ enabled: true,
77
+ driverConfig: { host: '127.0.0.1', port: DEAD_PORT, unitId: 1, register: 40001, dataType: 'float32', byteOrder: 'big' },
78
+ })
79
+ const nodeId = node?.data?.node?.id ?? node?.data?.id
80
+ check('创建指向必然拒绝连接端口的数采节点', !!nodeId, nodeId ?? JSON.stringify(node).slice(0, 200))
81
+
82
+ // 2) 开跑,让轮询真正去连那个端口
83
+ if (lineId) await api('POST', `/api/workshop/dcw/lines/${lineId}/start`, {})
84
+ console.log(` · 等待 ${WAIT_S}s 让采集轮询发起连接 ...`)
85
+ await sleep(WAIT_S * 1000)
86
+
87
+ // 3) 核心断言:服务还活着
88
+ const after = await health()
89
+ check('设备不可达后服务仍健康(修复前此处 exit 1)', after.ok, after.ok ? `version=${after.version}` : `unreachable: ${after.error}`)
90
+
91
+ const opened = await api('GET', `/api/workshop/daq/${nodeId}/samples?from=${Date.now() - WAIT_S * 1000}&bucketMs=1000&limit=5`).catch(() => null)
92
+ check('节点面仍可查询(平台未被带走)', opened !== null)
93
+
94
+ // 4) 清理
95
+ if (!KEEP && lineId) {
96
+ await api('POST', `/api/workshop/dcw/lines/${lineId}/stop`, {}).catch(() => null)
97
+ await api('DELETE', `/api/workshop/daq/${nodeId}`).catch(() => null)
98
+ await api('DELETE', `/api/workshop/dcw/lines/${lineId}`).catch(() => null)
99
+ console.log(' · 验证用产线/节点已清理')
100
+ }
101
+ else if (KEEP) console.log(` · KEEP=1,保留现场:line=${lineId} node=${nodeId}`)
102
+
103
+ console.log(`\n━━━ 结果:${pass} PASS / ${fail} FAIL ━━━\n`)
104
+ process.exitCode = fail === 0 ? 0 : 1
@@ -70,13 +70,20 @@ const wr2 = await api(`/api/workshop/dcw/${w2.id}/write`, { method: 'POST', body
70
70
  ok('operate 产线写控放行', wr2.status === 200 && wr2.body?.data?.outcome != null, `ack=${JSON.stringify(wr2.body?.data?.outcome)?.slice(0, 60)}`)
71
71
 
72
72
  // ---- 绑定校验(daq 需 readonly+,dcw 需 operate) ----
73
+ // 绑定主体是频道成员实例 id(0.7.37 绑定主体校验):plain 用户自建频道+成员,取实例 id
74
+ const bindCh = await api('/api/workshop/channels', { method: 'POST', body: JSON.stringify({ name: `e2e-perms-ch-${Date.now()}`, leadAgent: { name: 'e2e-perms-lead', harness: 'mock' } }) }, plainTok)
75
+ const bindTpl = await api('/api/workshop/agents', { method: 'POST', body: JSON.stringify({ name: 'e2e-perms-worker', harness: 'mock' }) }, plainTok)
76
+ const bindJoin = await api(`/api/workshop/channels/${bindCh.body?.data?.channelId}/agents`, { method: 'POST', body: JSON.stringify({ agentId: bindTpl.body?.data?.id, role: 'worker' }) }, plainTok)
77
+ const bindAgentId = bindJoin.body?.data?.id
78
+ ok('plain 自建频道成员(绑定主体)', Boolean(bindAgentId), bindJoin.body?.message ?? '')
79
+
73
80
  const daqNodes = q1.body.data.nodes
74
81
  const dq1 = daqNodes.find(n => n.lineId === LINE1)
75
- const bindD = await api('/api/workshop/agent-tools/bindings', { method: 'POST', body: JSON.stringify({ agentId: 'e2e-perms-agent', nodeId: dq1.id, kind: 'daq' }) }, plainTok)
82
+ const bindD = await api('/api/workshop/agent-tools/bindings', { method: 'POST', body: JSON.stringify({ agentId: bindAgentId, nodeId: dq1.id, kind: 'daq' }) }, plainTok)
76
83
  ok('绑定 daq 节点(readonly 线)放行', bindD.status === 200, JSON.stringify(bindD.body?.message ?? ''))
77
- const bindW1 = await api('/api/workshop/agent-tools/bindings', { method: 'POST', body: JSON.stringify({ agentId: 'e2e-perms-agent', nodeId: w1.id, kind: 'dcw' }) }, plainTok)
84
+ const bindW1 = await api('/api/workshop/agent-tools/bindings', { method: 'POST', body: JSON.stringify({ agentId: bindAgentId, nodeId: w1.id, kind: 'dcw' }) }, plainTok)
78
85
  ok('绑定 dcw 节点(readonly 线)被拒', bindW1.status === 403, bindW1.body?.message)
79
- const bindW2 = await api('/api/workshop/agent-tools/bindings', { method: 'POST', body: JSON.stringify({ agentId: 'e2e-perms-agent', nodeId: w2.id, kind: 'dcw' }) }, plainTok)
86
+ const bindW2 = await api('/api/workshop/agent-tools/bindings', { method: 'POST', body: JSON.stringify({ agentId: bindAgentId, nodeId: w2.id, kind: 'dcw' }) }, plainTok)
80
87
  ok('绑定 dcw 节点(operate 线)放行', bindW2.status === 200)
81
88
 
82
89
  // ---- 越权访问无权产线节点(直接按 id 打,数据面不因列表隐藏而放行) ----