modelstage 0.0.0-stage → 0.5.24

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 (286) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +101 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +58 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +647 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +334 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/web/README.md +193 -0
  126. package/src/web/app/anim-list.js +222 -0
  127. package/src/web/app/background-solids.js +150 -0
  128. package/src/web/app/backgrounds.js +152 -0
  129. package/src/web/app/camera-log.js +28 -0
  130. package/src/web/app/context.js +72 -0
  131. package/src/web/app/dir-picker.js +242 -0
  132. package/src/web/app/entry-report.js +71 -0
  133. package/src/web/app/memory-bars.js +91 -0
  134. package/src/web/app/memory-budget.js +228 -0
  135. package/src/web/app/memory-guard.js +204 -0
  136. package/src/web/app/model-actions.js +682 -0
  137. package/src/web/app/renderer-setting.js +70 -0
  138. package/src/web/app/root-manager.js +254 -0
  139. package/src/web/app/status.js +138 -0
  140. package/src/web/app/topbar-actions.js +357 -0
  141. package/src/web/app/transport-actions.js +297 -0
  142. package/src/web/app/ui-state.js +559 -0
  143. package/src/web/app/url-params.js +171 -0
  144. package/src/web/app/view-settings.js +186 -0
  145. package/src/web/components.css +465 -0
  146. package/src/web/debug/diag-hud.js +95 -0
  147. package/src/web/debug/drag.js +369 -0
  148. package/src/web/debug/log.js +320 -0
  149. package/src/web/debug/model-cmp.js +292 -0
  150. package/src/web/debug/panel.css +474 -0
  151. package/src/web/debug/panel.js +712 -0
  152. package/src/web/debug/race-probe.js +82 -0
  153. package/src/web/debug/upload.js +130 -0
  154. package/src/web/engine/adapters/atlas.js +47 -0
  155. package/src/web/engine/adapters/index.js +22 -0
  156. package/src/web/engine/adapters/renderer.js +223 -0
  157. package/src/web/engine/adapters/skeleton.js +105 -0
  158. package/src/web/engine/adapters/texture.js +64 -0
  159. package/src/web/engine/animation.js +181 -0
  160. package/src/web/engine/appearance.js +136 -0
  161. package/src/web/engine/camera-adapter.js +212 -0
  162. package/src/web/engine/effect-chain.js +138 -0
  163. package/src/web/engine/engine-state.js +120 -0
  164. package/src/web/engine/filters.js +224 -0
  165. package/src/web/engine/fit.js +197 -0
  166. package/src/web/engine/frame-gate.js +33 -0
  167. package/src/web/engine/gl-driver.js +56 -0
  168. package/src/web/engine/gl.js +77 -0
  169. package/src/web/engine/index.js +20 -0
  170. package/src/web/engine/loader.js +55 -0
  171. package/src/web/engine/model-info.js +26 -0
  172. package/src/web/engine/pipeline.js +200 -0
  173. package/src/web/engine/player.js +427 -0
  174. package/src/web/engine/pma/blend.js +76 -0
  175. package/src/web/engine/pma/detect.js +118 -0
  176. package/src/web/engine/pma/index.js +8 -0
  177. package/src/web/engine/pma/resolve.js +112 -0
  178. package/src/web/engine/pma/upload.js +127 -0
  179. package/src/web/engine/pma.js +5 -0
  180. package/src/web/engine/render-frame.js +93 -0
  181. package/src/web/engine/runtime-capabilities.js +209 -0
  182. package/src/web/engine/runtime.js +37 -0
  183. package/src/web/engine/skins.js +165 -0
  184. package/src/web/engine/textures.js +174 -0
  185. package/src/web/index.html +115 -0
  186. package/src/web/infra/api.js +531 -0
  187. package/src/web/infra/diag.js +509 -0
  188. package/src/web/infra/document.js +269 -0
  189. package/src/web/infra/dom.js +31 -0
  190. package/src/web/infra/events.mjs +27 -0
  191. package/src/web/infra/log.js +153 -0
  192. package/src/web/infra/mem-stats.js +112 -0
  193. package/src/web/infra/plugin-host-registration.mjs +285 -0
  194. package/src/web/infra/plugin-host-report.mjs +32 -0
  195. package/src/web/infra/plugin-host-unload.mjs +79 -0
  196. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  197. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  198. package/src/web/infra/plugin-host.js +628 -0
  199. package/src/web/infra/ready.js +461 -0
  200. package/src/web/infra/schedule.js +51 -0
  201. package/src/web/infra/shortcuts.js +129 -0
  202. package/src/web/infra/slot-registry.js +402 -0
  203. package/src/web/infra/vfs.js +247 -0
  204. package/src/web/live2d/bridge.js +400 -0
  205. package/src/web/live2d/model.js +321 -0
  206. package/src/web/live2d/overlay.js +142 -0
  207. package/src/web/live2d/pixi-app.js +111 -0
  208. package/src/web/live2d/playback-probe.js +92 -0
  209. package/src/web/live2d/probe-geometry.js +329 -0
  210. package/src/web/live2d/probe.js +119 -0
  211. package/src/web/live2d/release.js +66 -0
  212. package/src/web/live2d/runtime-adapter.js +289 -0
  213. package/src/web/live2d/stage-ctx.js +152 -0
  214. package/src/web/live2d/stage.js +204 -0
  215. package/src/web/live2d/vfs-settings.js +174 -0
  216. package/src/web/live2d/view.js +89 -0
  217. package/src/web/main.js +629 -0
  218. package/src/web/panel/background-list.js +373 -0
  219. package/src/web/panel/bg-source.js +291 -0
  220. package/src/web/panel/color-picker.css +199 -0
  221. package/src/web/panel/color-picker.js +278 -0
  222. package/src/web/panel/dialog.css +116 -0
  223. package/src/web/panel/dialog.js +173 -0
  224. package/src/web/panel/dom.js +35 -0
  225. package/src/web/panel/icons.js +58 -0
  226. package/src/web/panel/memory-bar.js +195 -0
  227. package/src/web/panel/panel-settings.js +1263 -0
  228. package/src/web/panel/panel.css +1304 -0
  229. package/src/web/panel/render-choice.js +104 -0
  230. package/src/web/panel/schema.js +784 -0
  231. package/src/web/panel/settings-io.js +291 -0
  232. package/src/web/panel/shell-controls.js +274 -0
  233. package/src/web/panel/slider.js +210 -0
  234. package/src/web/panel/toolbar-schema.js +111 -0
  235. package/src/web/panel/toolbar.js +195 -0
  236. package/src/web/panel/transport-picker.js +375 -0
  237. package/src/web/panel/transport.js +321 -0
  238. package/src/web/panel/view.js +553 -0
  239. package/src/web/plugin/unpack-worker.js +185 -0
  240. package/src/web/render/camera/constants.js +60 -0
  241. package/src/web/render/camera/index.js +123 -0
  242. package/src/web/render/camera/spine.js +124 -0
  243. package/src/web/render/capture.js +30 -0
  244. package/src/web/render/interaction.js +527 -0
  245. package/src/web/render/live2d.js +627 -0
  246. package/src/web/render/playback.js +47 -0
  247. package/src/web/render/reset-view.js +29 -0
  248. package/src/web/render/spine.js +386 -0
  249. package/src/web/render/surface-switch.js +176 -0
  250. package/src/web/render/surface.js +571 -0
  251. package/src/web/render/surfaces.js +70 -0
  252. package/src/web/roster/filter.js +31 -0
  253. package/src/web/roster/list.js +276 -0
  254. package/src/web/roster/persist.js +52 -0
  255. package/src/web/roster/reveal.js +77 -0
  256. package/src/web/roster/rows.js +248 -0
  257. package/src/web/roster/snapshot.js +37 -0
  258. package/src/web/roster/view.js +670 -0
  259. package/src/web/spine/canvas-api-groups.js +267 -0
  260. package/src/web/spine/canvas-api.js +97 -0
  261. package/src/web/spine/canvas-document.js +80 -0
  262. package/src/web/spine/canvas-engine.js +193 -0
  263. package/src/web/spine/canvas-entry.js +28 -0
  264. package/src/web/spine/canvas-pixels.js +36 -0
  265. package/src/web/spine/canvas-runtime.js +127 -0
  266. package/src/web/spine/canvas-view.js +108 -0
  267. package/src/web/spine/canvas.js +7 -0
  268. package/src/web/spine/runtime-hint.js +73 -0
  269. package/src/web/spine/runtimes.js +212 -0
  270. package/src/web/style.css +493 -0
  271. package/tool/lib/bundle-probe-node.mjs +121 -0
  272. package/tool/lib/check-perms.mjs +114 -0
  273. package/tool/lib/contract-checks.mjs +582 -0
  274. package/tool/lib/cubism-core.mjs +122 -0
  275. package/tool/lib/export-bundle.mjs +328 -0
  276. package/tool/lib/paths.mjs +20 -0
  277. package/tool/lib/runtime-guides.mjs +65 -0
  278. package/tool/lib/runtime-package.mjs +403 -0
  279. package/tool/lib/spine-meta.mjs +227 -0
  280. package/tool/lib/spine-motions.mjs +104 -0
  281. package/tool/lib/spine-runtime.mjs +131 -0
  282. package/tool/lib/test-lock.mjs +178 -0
  283. package/tool/lib/test-report.mjs +133 -0
  284. package/tool/parse-jsonc.mjs +86 -0
  285. package/tool/resolve-live2d-runtimes.mjs +276 -0
  286. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,558 @@
1
+ // src/server/unpack/runtime/exec.mjs —— 「exec」运行时适配器:spawn 插件入口,走「一行一个 JSON」帧协议
2
+ //
3
+ // 协议(docs/设计/插件运行时与多语言.md §四):
4
+ // 请求 父 → 子 {"id":1,"method":"canHandle","absPath":"…","size":123}
5
+ // 响应 子 → 父 {"id":1,"ok":true,"handled":true}
6
+ // {"id":1,"ok":true,"prefix":"vfs://…","entry":"entry.json",
7
+ // "files":[{"name":"a.png","tmpPath":"<插件临时目录>/a.png","size":123}]}
8
+ // 错误 子 → 父 {"id":1,"ok":false,"code":"ULB_ERR_…","message":"…"}
9
+ //
10
+ // 四条硬约束(设计稿 §四,逐条落在代码里):
11
+ // ① 帧 = 一行 JSON(UTF-8)+ \n;解析必须支持跨 chunk 拼行(readline 天然满足)
12
+ // ② 二进制**不进帧**:子进程把产物写到临时目录,帧里只给 {name, tmpPath, size}
13
+ // —— 20MB 包 base64 会又慢又吃内存。产物由父进程按需读取(见 lazyBytes)
14
+ // ③ 超时必须双向:父进程每次请求设超时并 kill;子进程侧也有自己的内部上限(插件责任)
15
+ // ④ 崩溃只重试一次:异常退出后下一次请求重启,仍失败则该插件后续请求直接报错
16
+ //
17
+ // **安全边界**:exec 是"让一个陌生可执行文件在你机器上跑"——它能读你所有文件、连你的网络。
18
+ // 所以本运行时默认禁用,必须 config/plugin-runtime.json 显式开启(门禁在 runtime/index.mjs)。
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ // .env 解析复用 core/env.mjs(与 /api/brand 同一份文件、同一套规则,不另写一套)
22
+ import { createEnv } from '../../core/env.mjs';
23
+ import { spawn } from 'node:child_process';
24
+ import readline from 'node:readline';
25
+ import { createLogger } from '../../core/log.mjs';
26
+
27
+ /** 本适配器能处理的 runtime 名 */
28
+ export const RUNTIME = 'exec';
29
+
30
+ /** 协议版本:exec 插件必须在 plugin.json 声明 frameProtocol,且必须是本版本 */
31
+ export const SUPPORTED_FRAME_PROTOCOL = 1;
32
+
33
+ /** 清单不合法时的错误码(与"宿主还没实现"区分开,便于排障) */
34
+ export const MANIFEST_INVALID = 'EXEC_MANIFEST_INVALID';
35
+
36
+ /** 单次请求缺省超时:首帧含 python 启动 + import UnityPy(冷启实测 1~3s),给足余量 */
37
+ const DEFAULT_TIMEOUT_MS = 120_000;
38
+ /** 空闲多久后退出子进程(避免常驻 python 吃内存) */
39
+ const DEFAULT_IDLE_MS = 60_000;
40
+ /** 单行上限:防止子进程吐超大 JSON 把父进程内存拉爆(产物走 tmpPath,帧里不该有大块) */
41
+ const MAX_LINE_BYTES = 4 * 1024 * 1024;
42
+ /** SIGTERM 后等多久改 SIGKILL */
43
+ const KILL_GRACE_MS = 3000;
44
+ /** 本适配器自己的日志(exec 通道排障:未知进度档、空闲退出等) */
45
+ const execLog = createLogger('exec');
46
+
47
+ /**
48
+ * 进度档白名单 —— **唯一来源**:档名与每档语义都只在这张表里写一次。
49
+ *
50
+ * 契约 §四·⑨:不认识的档名**忽略并记 debug**,不报错、不中断请求 ——
51
+ * 插件可以自由加更细的档(monotonic 名字也行),老主进程只打一条 debug。
52
+ * 为什么不散在别处:这份名单曾同时抄在设计稿里,新增档位若只改一处,
53
+ * 插件发的帧就会被当"未知档"静默忽略(不报错,但也**没有进度**)——
54
+ * "同一事实两处表达"是本项目反复踩的坑;设计稿现在只解释语义并指向本表。
55
+ * 与 plugin-store 的三段阶段名对齐(read / resolve / write):档名要转发给前端 / SSE 订阅方,
56
+ * 两处必须是同一套词,否则对不上。
57
+ * `msField`:该档在 plugin-store 的 `phases` 对象里对应的字段名(`read→readMs` 这种**名字不同**的
58
+ * 也必须写明)。SSE 的补发循环靠它派生,**不再手写档名**;新增档位漏配 msField 会被单测拦下
59
+ * (而不是运行时少发一条、静默少一段进度)。
60
+ */
61
+ export const PROGRESS_PHASES = Object.freeze([
62
+ { name: 'read', msField: 'readMs', note: '从盘读整包(容器字节进内存)' },
63
+ { name: 'resolve', msField: 'pluginResolveMs', note: '插件解压/解析(真正吃 CPU 的那一段)' },
64
+ { name: 'write', msField: 'writeMs', note: '产物落盘(写 files/ 与 index.json)' },
65
+ ]);
66
+
67
+ /** 快速判定用的集合(由上表派生 —— **不要**在这里另写一份名单) */
68
+ const KNOWN_PROGRESS_PHASES = new Set(PROGRESS_PHASES.map((p) => p.name));
69
+
70
+ /**
71
+ * 校验一个 exec 插件的清单是否自洽(**纯校验,不起进程**)。
72
+ * 这一层在装载期就把"清单写错了"报出来,而不是等第一次解包才发现。
73
+ * @param {object} meta 插件清单
74
+ * @returns {{ok: true} | {ok: false, error: string, code: string}}
75
+ */
76
+ export function validateExecManifest(meta) {
77
+ const entry = meta && meta.entry ? String(meta.entry) : '';
78
+ if (!entry) {
79
+ return { ok: false, code: MANIFEST_INVALID, error: 'runtime:"exec" 的插件必须在 plugin.json 里声明 entry(相对插件目录的入口文件,如 worker.py)' };
80
+ }
81
+ // entry 必须是相对路径:绝对路径会让插件把"整机任意可执行文件"变成自己的入口(明显越界)
82
+ if (path.isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) {
83
+ return { ok: false, code: MANIFEST_INVALID, error: `runtime:"exec" 的 entry 必须是插件目录内的相对路径(不允许绝对路径或 ..):${entry}` };
84
+ }
85
+ const fp = meta && meta.frameProtocol;
86
+ if (fp === undefined || fp === null) {
87
+ return { ok: false, code: MANIFEST_INVALID, error: 'runtime:"exec" 的插件必须声明 frameProtocol(当前支持版本 1)' };
88
+ }
89
+ if (Number(fp) !== SUPPORTED_FRAME_PROTOCOL) {
90
+ return { ok: false, code: MANIFEST_INVALID, error: `不支持的 frameProtocol:${fp}(本宿主支持 ${SUPPORTED_FRAME_PROTOCOL})` };
91
+ }
92
+ return { ok: true };
93
+ }
94
+
95
+ /**
96
+ * 惰性字节视图:`filesView` 只要求"有 length 和 slice",真正取用(take)时才读盘。
97
+ *
98
+ * 为什么不做成一次性全读:一个包的产物可能是几十 MB(纹理),而列表/校验路径只看 size;
99
+ * 全读进内存等于把解包的峰值再抬高一份。
100
+ *
101
+ * ⚠️ **必须同时提供 `arrayBuffer()`**:`filesView` 的 toBytes 优先走 Blob 形状
102
+ * (`typeof v.arrayBuffer === 'function'`),否则会落到 `new Uint8Array(v)` 那条 ——
103
+ * 那是 array-like 分支,对一个普通对象只会产出**长度对、内容全 0** 的假字节。
104
+ * 实测踩到过:entry.json 175 字节全 `0x00`,JSON 解析直接失败。
105
+ * 返回的 ArrayBuffer 必须是**独立拷贝**(`buf.buffer.slice(...)`):Buffer 常来自共享池,
106
+ * 直接把 buf.buffer 交出去会把池里相邻的其它内容也带上。
107
+ * @param {string} tmpPath 子进程写出的临时文件
108
+ * @param {number} size 字节数
109
+ */
110
+ function lazyBytes(tmpPath, size) {
111
+ return {
112
+ length: Number(size) || 0,
113
+ // Blob 形状(优先分支):filesView → toBytes 会读它
114
+ arrayBuffer: async () => {
115
+ const buf = fs.readFileSync(tmpPath);
116
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
117
+ },
118
+ // Uint8Array 形状(备用分支)
119
+ slice: () => fs.readFileSync(tmpPath),
120
+ tmpPath,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * 展开清单里的 `${VAR}` 占位符,返回部署端提供的值。
126
+ *
127
+ * 为什么需要占位符:插件清单是**可分发**的(不能写死本机路径),但依赖目录又是部署差异
128
+ * (每台机器装的位置不同)—— 用 `${VAR}` 声明"这里要一个由部署端提供的值",
129
+ * 既不带本机信息、又不用把配置拆到另一个文件。
130
+ * 取值顺序:`process.env` → `<root>/.env`(与 /api/brand 读同一份,改完刷新即生效)。
131
+ * 未设置 ⇒ **返回失败而不是空串**:静默降级成"插件不可用但不说为什么"是最难查的那类故障。
132
+ * @param {string} value 清单里的原始值(可能是 `${VAR}`,也可能就是普通路径)
133
+ * @param {{root?: string}} opts root = 项目根(.env 所在目录)
134
+ */
135
+ function expandPlaceholder(value, opts = {}) {
136
+ const raw = String(value == null ? '' : value).trim();
137
+ const hit = /^\$\{([A-Z_][A-Z0-9_]*)\}$/.exec(raw);
138
+ if (!hit) return { ok: true, value: raw }; // 不是占位符:原样返回(绝对/相对路径都走这条)
139
+ const name = hit[1];
140
+ const fromProcess = String(process.env[name] || '').trim();
141
+ if (fromProcess) return { ok: true, value: fromProcess, source: `env:${name}` };
142
+ // 再查 .env:部署端更愿意改这个文件(.env 已 gitignore,不随仓库分发)
143
+ try {
144
+ const root = (opts && opts.root) || process.cwd();
145
+ const env = createEnv({ dotenvFile: path.join(root, '.env') });
146
+ const fromDotenv = String((env.readDotEnv() || {})[name] || '').trim();
147
+ if (fromDotenv) return { ok: true, value: fromDotenv, source: `.env:${name}` };
148
+ } catch { /* 读 .env 失败按未设置处理,下面统一给可读错误 */ }
149
+ return {
150
+ ok: false,
151
+ code: 'EXEC_CONFIG_MISSING',
152
+ error: `插件清单里的 \${${name}} 没有值:请设置环境变量 ${name},`
153
+ + `或在项目根的 .env 里加一行 ${name}=<路径>(清单不再写死本机路径,改配置即可换机)`,
154
+ };
155
+ }
156
+
157
+ /** 找可用的解释器:清单声明优先,否则按常见路径探**一次并记住**。
158
+ *
159
+ * 为什么缓存:本函数会被每个 worker 的 spawn 调用一次,而探测要对候选路径逐个 statSync;
160
+ * 同一进程里解释器不会换,探一次就够(`repeat-identical-call` 报的正是这一处)。
161
+ * 清单显式声明时直接返回,根本不进探测循环。
162
+ * @param {object} meta 插件清单
163
+ * @returns {string} 解释器路径或命令名
164
+ */
165
+ let interpreterProbeCache = null;
166
+ function resolveInterpreter(meta) {
167
+ // ① 环境变量最优先(部署期覆盖,便于换机/换版本而不改清单)
168
+ const fromEnv = String(process.env.MODELSTAGE_PYTHON || '').trim();
169
+ if (fromEnv) return fromEnv;
170
+ // ② 清单声明(插件作者指定;本项目已置空,交给③④探测)
171
+ const declared = meta && meta.interpreter ? String(meta.interpreter) : '';
172
+ if (declared) return declared;
173
+ if (interpreterProbeCache) return interpreterProbeCache;
174
+ // 候选按"新到旧"排:新版本装的依赖不保证能在旧版本里 import
175
+ for (const candidate of ['/usr/local/bin/python3.14', '/usr/local/bin/python3.10', '/usr/bin/python3']) {
176
+ try {
177
+ if (fs.existsSync(candidate)) {
178
+ interpreterProbeCache = candidate;
179
+ return candidate;
180
+ }
181
+ } catch { /* 继续下一个 */ }
182
+ }
183
+ // 都没探到 ⇒ 交给 PATH(spawn 会报 ENOENT,错误信息里带命令名,够定位)
184
+ interpreterProbeCache = 'python3';
185
+ return interpreterProbeCache;
186
+ }
187
+
188
+ /**
189
+ * 一个 exec 插件的常驻 worker。
190
+ *
191
+ * 生命周期:装载时**不**启动(避免"装了插件就起进程");首个请求才 spawn,空闲后自动退出。
192
+ */
193
+ class ExecWorker {
194
+ /**
195
+ * @param {object} meta 插件清单
196
+ * @param {{pluginDir: string, depsDir?: string|null, log?: Function}} opts
197
+ */
198
+ constructor(meta, opts) {
199
+ this.meta = meta;
200
+ this.pluginDir = opts.pluginDir;
201
+ this.depsDir = opts.depsDir || null;
202
+ this.log = typeof opts.log === 'function' ? opts.log : () => {};
203
+ this.proc = null;
204
+ this.pending = new Map(); // id → {resolve, reject, timer, method}
205
+ this.nextId = 1;
206
+ this.dead = false; // 进程结束后置真;下次请求会尝试重启
207
+ this.restartUsed = false; // 自动重启只允许一次(设计稿 ④)
208
+ this.idleTimer = null;
209
+ this.outputTail = []; // 最后若干行 stderr/非 JSON 输出,报错时说清"插件为什么挂"
210
+ this.ready = null; // spawn 的 Promise(并发首请求共用一次启动)
211
+ this.disposed = false;
212
+ }
213
+
214
+ /** 启动子进程(幂等:并发调用共用同一次 spawn) */
215
+ ensureStarted() {
216
+ if (this.disposed) return Promise.reject(new Error('exec worker 已释放'));
217
+ if (this.proc && !this.dead) return Promise.resolve(this.proc);
218
+ if (this.ready) return this.ready;
219
+ this.ready = new Promise((resolve, reject) => {
220
+ const interpreter = resolveInterpreter(this.meta);
221
+ const entryAbs = path.join(this.pluginDir, String(this.meta.entry || 'worker.py'));
222
+ if (!fs.existsSync(entryAbs)) {
223
+ this.ready = null;
224
+ return reject(new Error(`exec 插件入口不存在:${entryAbs}`));
225
+ }
226
+ const env = { ...process.env };
227
+ // 依赖隔离:插件自带依赖目录进 PYTHONPATH,不要求宿主全局装包
228
+ const deps = this.depsDir || path.join(this.pluginDir, '.deps');
229
+ env.PYTHONPATH = [deps, this.pluginDir, env.PYTHONPATH || ''].filter(Boolean).join(path.delimiter);
230
+ // 中文路径:强制 UTF-8 + 不缓冲,否则帧会卡在缓冲区里(本机路径大量中文,踩过)
231
+ env.PYTHONIOENCODING = 'utf-8';
232
+ env.PYTHONUNBUFFERED = '1';
233
+ // 不要继承宿主的 venv:会把插件的依赖解析带到别处去
234
+ delete env.VIRTUAL_ENV;
235
+
236
+ let child;
237
+ try {
238
+ child = spawn(interpreter, [entryAbs], {
239
+ cwd: this.pluginDir,
240
+ env,
241
+ stdio: ['pipe', 'pipe', 'pipe'],
242
+ });
243
+ } catch (e) {
244
+ this.ready = null;
245
+ return reject(new Error(`启动 exec 插件失败(${interpreter} ${entryAbs}):${e.message}`));
246
+ }
247
+ this.proc = child;
248
+ this.dead = false;
249
+
250
+ // 按行读 stdout:readline 自带跨 chunk 拼行(约束 ①)
251
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
252
+ rl.on('line', (line) => this.onLine(line));
253
+ child.stderr.on('data', (buf) => this.pushTail(String(buf)));
254
+ child.on('error', (e) => this.onExit(`spawn error: ${e.message}`));
255
+ child.on('exit', (code, signal) => this.onExit(`exit code=${code} signal=${signal}`));
256
+
257
+ resolve(child);
258
+ });
259
+ return this.ready;
260
+ }
261
+
262
+ /** 记录最后若干行插件输出(诊断用) */
263
+ pushTail(text) {
264
+ for (const one of String(text).split('\n')) {
265
+ if (!one.trim()) continue;
266
+ this.outputTail.push(one.slice(0, 500));
267
+ if (this.outputTail.length > 20) this.outputTail.shift();
268
+ }
269
+ }
270
+
271
+ /** 收一行:解析 JSON → 按 id 配对;坏行只记日志(插件往 stdout 打调试信息很常见) */
272
+ onLine(line) {
273
+ const text = String(line).trim();
274
+ if (!text) return;
275
+ if (Buffer.byteLength(text, 'utf8') > MAX_LINE_BYTES) {
276
+ this.log('warn', `exec 插件输出超长行(>${MAX_LINE_BYTES}B),已丢弃`, { plugin: this.meta.id });
277
+ return;
278
+ }
279
+ let msg;
280
+ try {
281
+ msg = JSON.parse(text);
282
+ } catch {
283
+ this.pushTail(`stdout(非 JSON): ${text.slice(0, 200)}`);
284
+ return;
285
+ }
286
+ const id = Number(msg && msg.id);
287
+ const waiter = this.pending.get(id);
288
+ if (!waiter) return; // 迟到的响应(已超时/已判失败)直接丢
289
+ // 进度帧:**不终结请求** —— 同一 id 后面还会有真正的响应/错误帧(契约 §四·⑦)。
290
+ // 所以必须在这里 early return,绝不能落到下面的 settle 分支:
291
+ // 否则一条进度帧会被当成响应,请求提前以"返回值缺 prefix/entry"失败。
292
+ if (msg && msg.progress && typeof msg.progress === 'object') {
293
+ this.deliverProgress(waiter, msg.progress);
294
+ return;
295
+ }
296
+ this.pending.delete(id);
297
+ clearTimeout(waiter.timer);
298
+ waiter.resolve(msg);
299
+ }
300
+
301
+ /**
302
+ * 转发一条进度帧(契约 §四·⑦⑧⑨⑩ 的唯一落点)。
303
+ *
304
+ * 三件事都在这里收口,避免散落到调用方:
305
+ * ⑨ 未知档名 → 忽略 + debug(插件可以自由加分档,主进程不认识不算错)
306
+ * ⑩ 同档同毫秒的重复 → 丢掉(没有新信息)
307
+ * ⑧ 没注册回调 → 静默丢弃(进度只是旁路增强,不影响正确性)
308
+ * @param {object} waiter 该请求的挂起记录(含 onProgress / progressSeen)
309
+ * @param {{phase?: string, ms?: number}} progress 子进程上报的内容
310
+ */
311
+ deliverProgress(waiter, progress) {
312
+ const phase = String((progress && progress.phase) || '');
313
+ if (!phase) return;
314
+ if (!KNOWN_PROGRESS_PHASES.has(phase)) {
315
+ // 已知档名从表里现算(不另写一份):排查"为什么没进度"时,一行日志就能看出
316
+ // "插件发了个不在名单里的名字"还是"根本没发"。
317
+ this.log('debug', `exec 插件上报了未知进度档「${phase}」,已忽略(已知:${KNOWN_PROGRESS_PHASES.size ? [...KNOWN_PROGRESS_PHASES].join('/') : '无'})`, { plugin: this.meta.id });
318
+ return;
319
+ }
320
+ const rawMs = Number(progress && progress.ms);
321
+ const ms = Number.isFinite(rawMs) ? Math.max(0, Math.round(rawMs)) : null;
322
+ const key = `${phase}@${ms}`;
323
+ if (waiter.progressSeen.has(key)) return;
324
+ waiter.progressSeen.add(key);
325
+ if (typeof waiter.onProgress !== 'function') return;
326
+ try {
327
+ waiter.onProgress({ phase, ms });
328
+ } catch (e) {
329
+ // 回调抛错不能污染请求本身(它只是旁路信息)
330
+ this.log('warn', `进度回调抛错(已忽略):${(e && e.message) || e}`, { plugin: this.meta.id });
331
+ }
332
+ }
333
+
334
+ /** 子进程退出:把在途请求全部判失败,标记需要重启 */
335
+ onExit(reason) {
336
+ this.dead = true;
337
+ this.proc = null;
338
+ this.ready = null;
339
+ const tail = this.outputTail.slice(-5).join(' | ');
340
+ for (const [, waiter] of this.pending) {
341
+ clearTimeout(waiter.timer);
342
+ waiter.reject(new Error(`exec 插件进程结束(${reason})${tail ? `;插件输出:${tail}` : ''}`));
343
+ }
344
+ this.pending.clear();
345
+ }
346
+
347
+ /**
348
+ * 发一个请求并等响应。
349
+ * @param {string} method canHandle | resolve
350
+ * @param {object} params
351
+ * @param {number} timeoutMs
352
+ * @returns {Promise<object>} 子进程的原始响应帧
353
+ */
354
+ async request(method, params, timeoutMs = DEFAULT_TIMEOUT_MS, opts = {}) {
355
+ await this.ensureStarted();
356
+ const child = this.proc;
357
+ if (!child || this.dead) throw new Error('exec 插件进程不可用');
358
+ const id = this.nextId++;
359
+ const frame = `${JSON.stringify({ id, method, ...params })}\n`;
360
+
361
+ return new Promise((resolve, reject) => {
362
+ const timer = setTimeout(() => {
363
+ this.pending.delete(id);
364
+ // 超时必须真杀:只 reject 不 kill 的话,子进程会继续占着 CPU/内存
365
+ this.kill(`请求超时(${method} > ${timeoutMs}ms)`);
366
+ reject(new Error(`exec 插件请求超时(${method},${timeoutMs}ms)`));
367
+ }, timeoutMs);
368
+ if (typeof timer.unref === 'function') timer.unref();
369
+ this.pending.set(id, {
370
+ resolve, reject, timer, method,
371
+ // 进度回调按**请求**绑定,不绑在 worker 实例上:
372
+ // 同一插件可能被两个解包任务同时用,绑实例会让 A 的进度报给 B(契约 §四·⑦ 的并发含义)
373
+ onProgress: typeof opts.onProgress === 'function' ? opts.onProgress : null,
374
+ progressSeen: new Set(), // 同档同毫秒去重(契约 §四·⑩)
375
+ });
376
+ try {
377
+ child.stdin.write(frame, 'utf8');
378
+ } catch (e) {
379
+ this.pending.delete(id);
380
+ clearTimeout(timer);
381
+ reject(new Error(`写入 exec 插件 stdin 失败:${e.message}`));
382
+ }
383
+ this.touchIdle();
384
+ });
385
+ }
386
+
387
+ /** 重置空闲计时(有活动就不退) */
388
+ touchIdle() {
389
+ if (this.idleTimer) clearTimeout(this.idleTimer);
390
+ this.idleTimer = setTimeout(() => {
391
+ this.log('debug', 'exec 插件空闲退出', { plugin: this.meta.id });
392
+ this.kill('空闲超时');
393
+ }, DEFAULT_IDLE_MS);
394
+ if (typeof this.idleTimer.unref === 'function') this.idleTimer.unref();
395
+ }
396
+
397
+ /**
398
+ * 终止子进程:SIGTERM → 宽限期 → SIGKILL(**必须确认真死**,否则留僵尸)。
399
+ * @param {string} why
400
+ */
401
+ kill(why) {
402
+ const child = this.proc;
403
+ if (!child) return;
404
+ const pid = child.pid;
405
+ this.log('warn', `终止 exec 插件进程(${why})`, { plugin: this.meta.id, pid });
406
+ try { child.kill('SIGTERM'); } catch { /* 已退出 */ }
407
+ const hardTimer = setTimeout(() => {
408
+ try {
409
+ // SIGKILL 兜底:插件忽略 SIGTERM 时不能就这么留着
410
+ child.kill('SIGKILL');
411
+ } catch { /* 已退出 */ }
412
+ }, KILL_GRACE_MS);
413
+ if (typeof hardTimer.unref === 'function') hardTimer.unref();
414
+ // exit 事件会走 onExit 清 pending;这里显式再清一次,避免"kill 了但 exit 迟迟不来"时挂住
415
+ this.onExit(`killed: ${why}`);
416
+ }
417
+
418
+ /** 关掉(适配器 dispose 调) */
419
+ dispose() {
420
+ this.disposed = true;
421
+ if (this.idleTimer) clearTimeout(this.idleTimer);
422
+ this.idleTimer = null;
423
+ this.kill('适配器释放');
424
+ }
425
+ }
426
+
427
+ /**
428
+ * 装载一个 exec 插件:**不启动进程**,只校验清单并交出一个按需启动的 api。
429
+ * 这样"装了插件但从不使用"不会在机器上留一个 python。
430
+ * @param {object} meta
431
+ * @param {{pluginsDir: string, log?: Function}} ctx
432
+ */
433
+ export function loadExecPlugin(meta, ctx) {
434
+ const v = validateExecManifest(meta);
435
+ if (!v.ok) return v;
436
+ const pluginsDir = (ctx && ctx.pluginsDir) || '';
437
+ // files[] 里形如 "unitypy-bundle/worker.py":第一段是插件目录名
438
+ const first = Array.isArray(meta.files) && meta.files.length ? String(meta.files[0]) : '';
439
+ const dirName = first.includes('/') ? first.split('/')[0] : String(meta.id || '');
440
+ const pluginDir = path.join(pluginsDir, dirName);
441
+ if (!fs.existsSync(pluginDir)) {
442
+ return { ok: false, code: 'EXEC_PLUGIN_DIR_MISSING', error: `exec 插件目录不存在:${pluginDir}` };
443
+ }
444
+ // 入口文件在**装载期**就校验,不等 spawn 才炸:
445
+ // spawn 失败只会在第一次请求时暴露,那时错误会混在"插件判定失败"里,很难看出是清单写错。
446
+ // ——同时这也是"清单声明了什么就必须存在什么"的一致性要求。
447
+ const entryPath = path.join(pluginDir, String(meta.entry));
448
+ if (!fs.existsSync(entryPath)) {
449
+ return { ok: false, code: 'EXEC_ENTRY_MISSING', error: `exec 入口文件不存在:${entryPath}` };
450
+ }
451
+ // depsDir:支持三种写法 —— `${VAR}` 占位符(推荐,清单不带本机信息)、绝对路径、插件内相对路径。
452
+ // 占位符取不到值 ⇒ **装载期就失败并给可读错误**,不要等到 spawn 后才以
453
+ // "ModuleNotFoundError: No module named 'UnityPy'" 的形式冒出来(那时看不出是配置问题)。
454
+ let resolvedDepsDir = { ok: true, value: null };
455
+ if (meta.depsDir) {
456
+ const expanded = expandPlaceholder(meta.depsDir, { root: ctx && ctx.root });
457
+ if (!expanded.ok) return expanded;
458
+ resolvedDepsDir = {
459
+ ok: true,
460
+ value: path.isAbsolute(expanded.value) ? expanded.value : path.join(pluginDir, expanded.value),
461
+ };
462
+ }
463
+ const worker = new ExecWorker(meta, {
464
+ pluginDir,
465
+ // depsDir 支持两种写法:插件内相对路径('.deps')或**绝对路径**。
466
+ // 为什么需要绝对路径:本项目仓库在 CIFS 上(noexec + 不能 utime .so),
467
+ // 带 C 扩展的依赖装进去会 PermissionError;这类依赖只能放到本地盘,
468
+ // 由插件在自己的 README 里写明位置(见 unitypy-bundle 的说明)。
469
+ depsDir: resolvedDepsDir.ok ? resolvedDepsDir.value : null,
470
+ // log 缺省不再退化成空函数:exec 通道的排障信息(尤其"上报了未知进度档「x」")必须能在
471
+ // LOG_LEVEL=debug 下真的打出来。此前调用方只传 {pluginsDir, root},ctx.log 为空
472
+ // ⇒ 这些 debug 全被静默丢弃 —— "为什么没有进度"根本无从查起。
473
+ log: (ctx && ctx.log) || ((level, msg, detail) => {
474
+ const fn = typeof execLog[level] === 'function' ? execLog[level] : execLog.info;
475
+ fn(msg, detail);
476
+ }),
477
+ });
478
+ const timeoutMs = Number.isFinite(Number(meta.requestTimeoutMs)) ? Number(meta.requestTimeoutMs) : DEFAULT_TIMEOUT_MS;
479
+ const providerId = String(meta.id || dirName);
480
+
481
+ const api = {
482
+ /**
483
+ * canHandle 走帧协议(异步)。plugin-runner 已改成 `await` 调用,
484
+ * 所以返回 Promise 与 node 运行时的同步 boolean 在使用上等价。
485
+ */
486
+ async canHandle(file) {
487
+ const resp = await worker.request('canHandle', {
488
+ rootId: file.rootId,
489
+ rel: file.rel,
490
+ name: file.name,
491
+ absPath: file.absPath || null,
492
+ size: file.size,
493
+ }, timeoutMs, { onProgress: file && file.onProgress });
494
+ if (!resp || resp.ok === false) {
495
+ // 插件自报"我不认"与"我出错了"必须分清:后者要抛出去让 runner 记 canhandle-threw
496
+ if (resp && resp.code) throw new Error(`${resp.code}: ${resp.message || ''}`);
497
+ return false;
498
+ }
499
+ return resp.handled === true;
500
+ },
501
+
502
+ /**
503
+ * resolve:子进程把产物写到临时目录,这里把 {name,tmpPath,size} 转成
504
+ * `filesView` 能吃的普通对象(值 = 惰性字节视图,take 时才读盘)。
505
+ */
506
+ async resolve(file) {
507
+ const resp = await worker.request('resolve', {
508
+ rootId: file.rootId,
509
+ rel: file.rel,
510
+ name: file.name,
511
+ absPath: file.absPath || null,
512
+ size: file.size,
513
+ }, timeoutMs, { onProgress: file && file.onProgress });
514
+ if (!resp || resp.ok === false) {
515
+ const err = new Error(`${(resp && resp.code) || 'EXEC_RESOLVE_FAILED'}: ${(resp && resp.message) || '插件未返回结果'}`);
516
+ err.code = (resp && resp.code) || 'EXEC_RESOLVE_FAILED';
517
+ throw err;
518
+ }
519
+ const list = Array.isArray(resp.files) ? resp.files : [];
520
+ const out = {};
521
+ for (const f of list) {
522
+ const name = String((f && (f.name || f.path)) || '');
523
+ if (!name) continue;
524
+ const tmp = f && f.tmpPath ? String(f.tmpPath) : '';
525
+ if (!tmp) continue; // 没有 tmpPath 的条目拿不到字节,宁可少一个也不报假成功
526
+ out[name] = lazyBytes(tmp, f.size);
527
+ }
528
+ return {
529
+ prefix: resp.prefix,
530
+ entry: resp.entry,
531
+ files: out,
532
+ // 诊断用(runner 不读,但日志要能看出"是哪个插件解的")
533
+ provider: providerId,
534
+ unpackMs: resp.unpackMs,
535
+ };
536
+ },
537
+
538
+ /** 释放:拆掉子进程(插件被禁用/热重载时由 runner 调) */
539
+ dispose() { worker.dispose(); },
540
+ };
541
+ return { ok: true, api };
542
+ }
543
+
544
+ export default {
545
+ runtime: RUNTIME,
546
+ /**
547
+ * 统一接口:load(meta, ctx) → {ok, api} | {ok:false, error, code}
548
+ * @param {object} meta
549
+ * @param {object} ctx
550
+ */
551
+ load(meta, ctx) {
552
+ const r = loadExecPlugin(meta, ctx);
553
+ if (!r.ok) return { ok: false, error: r.error, code: r.code };
554
+ return { ok: true, api: r.api };
555
+ },
556
+ /** 适配器级释放:各插件的子进程随其 api.dispose() 关闭(runner 在卸载时调) */
557
+ dispose() { /* 无全局资源 */ },
558
+ };