@yanglaofish/fishtools 0.1.4 → 0.3.0

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 (97) hide show
  1. package/README.md +79 -25
  2. package/manual/LICENSE +21 -0
  3. package/manual/README.md +199 -0
  4. package/manual/THIRD_PARTY_NOTICES.md +32 -0
  5. package/manual/docs/examples/action-plugin/LICENSE +21 -0
  6. package/manual/docs/examples/action-plugin/README.md +38 -0
  7. package/manual/docs/examples/action-plugin/index.js +18 -0
  8. package/manual/docs/examples/action-plugin/input.json +1 -0
  9. package/manual/docs/examples/action-plugin/package.json +9 -0
  10. package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
  11. package/manual/docs/examples/pipeline-reader/index.js +14 -0
  12. package/manual/docs/examples/pipeline-reader/package.json +7 -0
  13. package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
  14. package/manual/docs/examples/pipeline-writer/index.js +17 -0
  15. package/manual/docs/examples/pipeline-writer/package.json +7 -0
  16. package/manual/docs/plans/2026-10-10-workbench-ui.md +27 -0
  17. package/{docs → manual/docs}/plugin-development.md +172 -20
  18. package/{docs → manual/docs}/releases.md +32 -2
  19. package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
  20. package/manual/docs/roadmap.md +90 -0
  21. package/manual/docs/superpowers/plans/2026-10-09-workflow-mvp.md +115 -0
  22. package/manual/docs/superpowers/plans/2026-10-10-cordis-services.md +34 -0
  23. package/manual/docs/superpowers/plans/2026-10-10-fst-terminal.md +33 -0
  24. package/manual/docs/superpowers/plans/2026-10-10-portable-plugin-contract.md +35 -0
  25. package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +77 -0
  26. package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
  27. package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
  28. package/manual/docs/superpowers/specs/2026-10-09-workflow-cli-design.md +377 -0
  29. package/manual/docs/superpowers/specs/2026-10-10-cordis-services-design.md +88 -0
  30. package/manual/docs/superpowers/specs/2026-10-10-portable-plugin-contract.md +31 -0
  31. package/manual/docs/superpowers/specs/2026-10-10-run-logs.md +28 -0
  32. package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
  33. package/manual/docs/superpowers/specs/2026-10-11-control-observation.md +37 -0
  34. package/{docs → manual/docs}/verification.md +97 -1
  35. package/manual/examples/service-plugins/README.md +20 -0
  36. package/manual/examples/service-plugins/consumer/LICENSE +21 -0
  37. package/manual/examples/service-plugins/consumer/index.js +14 -0
  38. package/manual/examples/service-plugins/consumer/package.json +1 -0
  39. package/manual/examples/service-plugins/provider/LICENSE +21 -0
  40. package/manual/examples/service-plugins/provider/index.js +19 -0
  41. package/manual/examples/service-plugins/provider/package.json +1 -0
  42. package/manual/packages/core/LICENSE +21 -0
  43. package/manual/packages/core/src/application.js +211 -0
  44. package/manual/packages/core/src/engine.js +93 -0
  45. package/manual/packages/core/src/host.js +124 -0
  46. package/manual/packages/core/src/runtime.js +135 -0
  47. package/manual/packages/core/src/terminal/run-feed.js +78 -0
  48. package/manual/packages/core/src/terminal/session.js +438 -0
  49. package/manual/packages/core/src/terminal/view.js +283 -0
  50. package/manual/packages/sdk/LICENSE +21 -0
  51. package/manual/packages/sdk/index.js +14 -0
  52. package/manual/packages/sdk/package.json +1 -0
  53. package/manual/plugins/environment/LICENSE +21 -0
  54. package/manual/plugins/environment/index.js +92 -0
  55. package/manual/plugins/environment/package.json +1 -0
  56. package/manual/plugins/examples/LICENSE +21 -0
  57. package/manual/plugins/examples/index.js +23 -0
  58. package/manual/plugins/examples/package.json +1 -0
  59. package/manual/plugins/examples/scripts/echo.py +5 -0
  60. package/manual/tests/application.test.js +75 -0
  61. package/node_modules/@mytools/core/bin/fst.js +12 -4
  62. package/node_modules/@mytools/core/src/application.js +11 -5
  63. package/node_modules/@mytools/core/src/cli-run.js +6 -0
  64. package/node_modules/@mytools/core/src/contracts.js +15 -2
  65. package/node_modules/@mytools/core/src/control.js +63 -0
  66. package/node_modules/@mytools/core/src/documentation.js +65 -0
  67. package/node_modules/@mytools/core/src/engine.js +80 -26
  68. package/node_modules/@mytools/core/src/host.js +15 -2
  69. package/node_modules/@mytools/core/src/markdown-log.js +9 -6
  70. package/node_modules/@mytools/core/src/process.js +49 -15
  71. package/node_modules/@mytools/core/src/project.js +1 -1
  72. package/node_modules/@mytools/core/src/run-logs.js +1 -1
  73. package/node_modules/@mytools/core/src/terminal/conditions.js +32 -0
  74. package/node_modules/@mytools/core/src/terminal/log-viewer.js +7 -8
  75. package/node_modules/@mytools/core/src/terminal/model.js +33 -4
  76. package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
  77. package/node_modules/@mytools/core/src/terminal/presentation.js +8 -10
  78. package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
  79. package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
  80. package/node_modules/@mytools/core/src/terminal/run-viewer.js +104 -0
  81. package/node_modules/@mytools/core/src/terminal/session.js +212 -57
  82. package/node_modules/@mytools/core/src/terminal/theme.js +29 -0
  83. package/node_modules/@mytools/core/src/terminal/view.js +185 -33
  84. package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
  85. package/node_modules/@mytools/core/src/validation.js +17 -4
  86. package/node_modules/@mytools/core/src/version.js +1 -1
  87. package/node_modules/@mytools/core/src/workflows.js +41 -19
  88. package/node_modules/@mytools/core/templates/control-demo.yaml +47 -0
  89. package/node_modules/@mytools/environment/index.js +14 -2
  90. package/node_modules/@mytools/pack/index.js +6 -2
  91. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +780 -780
  92. package/node_modules/@mytools/web/public/assets/index-DT2B4tMV.js +70 -0
  93. package/node_modules/@mytools/web/public/assets/{index-BeV6IqTW.css → index-vuZvrUTT.css} +1 -1
  94. package/node_modules/@mytools/web/public/index.html +2 -2
  95. package/package.json +2 -2
  96. package/node_modules/@mytools/web/public/assets/index-TgsBnZgG.js +0 -70
  97. /package/{docs → manual/docs}/deployment.md +0 -0
@@ -1,5 +1,56 @@
1
1
  # 首版验收记录
2
2
 
3
+ ## 0.3.0 发布前验证(2026-10-12)
4
+
5
+ - 以 `v0.2.0` 为基线审查本轮代码和需求;修复离线手册缺失源码链接、并行取消测试时序、主面板密集日志重复全量快照与重绘,以及展开详情缺少完整错误链。
6
+ - Windows 本机使用 Node.js 24.15.0、npm 11.15.0:`npm run build` 成功;`npm test` 为 **184/184 通过**;`npm run dist:npm` 生成 `yanglaofish-fishtools-0.3.0.tgz`。归档内核对了 `package.json` 版本、运行面板代码、离线研究文档及其源码引用。
7
+ - npm 官方注册表在发布前只有 `latest: 0.2.0`,`0.3.0` 尚不存在。跨平台测试、自包含包与 Docker 检查由版本 tag 的发布工作流再次执行;本地通过不代替远端发布结果。
8
+
9
+ ## 合并执行回看入口(2026-10-11,本地试用,未发布)
10
+
11
+ - 用户再次试用后认为 F4 没有独立价值:主面板查看当前执行,统一通过 `/logs` 回看步骤、输入输出和日志。删除独立运行列表实现,旧 `/runs` 与 F4 仅兼容转入同一入口;界面不再推荐 F4。
12
+ - `/logs` 显示工作流标题,正在运行的任务排在前面;历史持久记录、从详情返回列表、切换其他执行和原有日志保留策略保持可用。
13
+ - 先复现旧实现三个场景失败,修订后 **31/31 通过**:`node --test tests/log-readability.test.js tests/run-logs.test.js tests/terminal-location.test.js tests/terminal-theme.test.js tests/terminal-navigation.test.js`。本节取代下方 F4 与日志双入口设计;未发布、未推送。
14
+
15
+ ## 精简返回反馈与 F4 说明(2026-10-11,本地试用,未发布)
16
+
17
+ - 用户反馈通用“已返回主面板”消息重复:现在返回只更新顶部位置,保留命令与实际结果,不新增返回记录。下方早期验收中“保留返回提示”的描述由本节取代。
18
+ - F4 标为“运行列表”,首页和列表说明用于选择一次执行,查看步骤、输入输出与日志;`/logs` 使用同一套记录但默认打开日志页。移除试用标记和滚动状态行里的重复 F4 提示。
19
+ - 导航、窄屏、F4 输入草稿保留、历史详情和主面板专项 **25/25 通过**:`node --test tests/terminal-location.test.js tests/terminal-theme.test.js tests/log-readability.test.js tests/terminal-navigation.test.js`。两项返回场景先复现失败再通过。
20
+ - 未发布、未推送,重新启动 `fst-preview.cmd` 使用本地修订;全局 fst 不变。
21
+
22
+ ## 运行草稿与导航反馈(2026-10-11,本地试用,未发布)
23
+
24
+ - 分支 `fix/run-navigation-feedback`。目标:`/run` 不被编辑草稿阻挡;菜单返回有清楚的去向,命令记录和运行过程始终可回看。
25
+ - 复现:参数页只查看后完成、拒绝删除步骤都曾被无条件标记为未保存;运行拒绝保存后,编辑菜单仍误判为已启动。先补失败测试,再修复真实修改判断及运行返回值。
26
+ - `/run` 选择已保存的工作流,`/run ID` 执行磁盘版本并保留任何草稿。编辑菜单单独提供保存并运行、运行已保存版本、返回编辑;取消留在编辑页,未创建运行。
27
+ - 顶部采用青绿字符标识与产品信息并排,持续显示工作区和当前位置;操作菜单增加分隔和标题。命令、菜单选择、返回主面板提示按顺序保留,本次会话内可翻页;不替代持久化运行日志。
28
+ - 相关测试 **82/82 通过**:`node --test tests/terminal*.test.js tests/log-readability.test.js tests/log-viewer.test.js tests/run-logs.test.js tests/workflows.test.js`。包含新增 6 个草稿/执行场景与 2 个真实组件导航场景。
29
+ - 首次完整 `npm test`:164/167 通过,三个旧 TUI 测试仍按“裸 /run 直接执行”编写,其中等待输入的测试被终止。已调整为指定工作流 ID,上述 82 项复验包含这三个测试且全部通过;未将首次全量结果标记为全绿。npm 归档测试仅使用离线 dry-run,没有发布。
30
+ - Windows 真实 PTY:临时工作区预置脏草稿,裸 `/run` 选择并执行已保存版本,输出 SAVED;编辑菜单运行选择返回后仍留在编辑菜单,草稿保留。WSL 真实 PTY:实时输出、执行中打开配置、返回、并发执行、翻页、插件入口和工作流编辑、退出恢复通过。
31
+ - 已检查真实组件渲染的首页、插件菜单、返回主面板预览;此轮只修改本地源码和本地试用入口,未更新全局 fst、未推送、未打 tag、未发布。
32
+
33
+ ## 对象菜单与主面板运行(2026-10-11,本地试用,未发布)
34
+
35
+ - 分支 `feat/terminal-command-hierarchy`,试用入口仍为根目录 `fst-preview.cmd` / `npm run preview`。
36
+ - 按用户指定职责分层:`/workflows` 管理与编辑,`/run` 工作流 ID/标题补全,`/plugin` 管理插件,插件名入口选择能力,`/config` 工具设置,`/tools` Web/资料。旧直达命令兼容执行,减少顶层补全数量,来源明确标注。
37
+ - 工作流删除移入 `.flow/deleted-workflows`,保留可复制恢复的 YAML;有嵌套引用或只读项目时拒绝删除。测试只使用临时项目,不删除用户已有数据。
38
+ - 主面板按事件到达顺序保留多次执行、输入输出与管理提示。配置操作不丢执行内容,滚轮/PgUp/PgDn 回看。普通文本误输入改为“输入提示”,移除重复的输入标题并修复小高度溢出。
39
+ - Windows 真 PTY 验证工作流补全、主面板持续 stdout、执行中打开/返回配置、执行完成与正常退出。WSL 100×24 真 PTY 验证持续输出、执行中配置、并发另一个工作流、回看翻页、插件二级菜单、工作流编辑菜单及退出恢复;记录 `.cache/hierarchy-wsl.ansi`。初次脚本在主输入就绪前发键,调整为等待主输入提示后复验通过。
40
+ - 独立审查复现完成任务监听器积累与并发/管理提示遮挡最新输出,均补失败测试后修复:每个应用只保留一个活跃订阅,所有主面板事件统一追加顺序,结束后解除订阅。
41
+ - 本地相关测试 **91/91 通过**,包括导航、主面板、详细日志、编辑、插件契约、控制流、删除引用保护与原有日志保留测试;独立复核中的 9 项导航专项也通过。
42
+ - 此轮未发布、未推送,等待用户本地试用确认;未重复运行完整发行构建或 npm 发布验收。
43
+
44
+ ## 日志阅读与运行记录入口(2026-10-11,本地试用,未发布)
45
+
46
+ - 分支 `fix/readable-run-logs`。使用 `npm run preview` 或根目录 `fst-preview.cmd`;隔离的 `.cache/log-preview-平台` 工作区提供成功、故意缺少解释器、持续输出三个示例,不升级全局安装或修改用户默认工作区。
47
+ - 首页 F4 打开运行记录;支持多工作流、多次执行,运行中任务优先,详情返回列表,每次运行分别保存本次启动期间的阅读位置。重启可读取持久化记录,不承诺恢复上次进程的滚动位置。
48
+ - 实时和历史共用结构化阅读面板。默认展示结果与错误摘要,按 j 查看原始 JSON 和完整错误链;Enter 查看详情而非关闭。独立终端屏幕避免旧面板与首页混排。
49
+ - 本地专项测试 **51/51 通过**:`log-readability`、`terminal-runs`、`terminal`、`terminal-theme`、`log-viewer`、`run-logs`。覆盖 F4 草稿保留、多运行选择、历史读取、阅读位置、流输出合并、错误摘要、终端清理与已有编辑/日志保留行为。此轮未重新执行完整发行包构建与发布验收。
50
+ - Windows 真实 PTY 已验证首页 F4、历史失败记录、Enter 查看输入输出、q 返回运行列表、Esc 返回首页及正常退出。
51
+ - WSL 真实 100×24 PTY 已验证 F4、历史失败摘要、Enter 详情、实时 stdout、鼠标滚轮暂停跟随、后台继续执行、返回同次阅读位置、切换成功记录、退出取消确认及独立屏幕恢复。证据 `.cache/log-preview-wsl.ansi`;脚本 `.cache/log-preview-pty.py`。首次验证因跨文件系统启动耗时与过早发送按键未完成,调整等待到首页输入就绪后复验通过。
52
+ - 另外检查了真实组件输出生成的 100×24 彩色预览。自动化及终端验证不代替用户体验确认;此轮未创建 tag、npm 版本或 Release,等待用户本地试用反馈。
53
+
3
54
  ## fst 交互终端(2026-10-10,当前入口)
4
55
 
5
56
  本节取代下方早期 `fish` 默认启动 Web 的记录。产品仍为 Fishtools,命令改为 `fst`:交互终端默认进入 TUI,`fst web` 显式启动 Web,原子子命令继续支持自动化。
@@ -167,8 +218,53 @@ MiB = 1,048,576 字节。以下为本机单次测量,不是性能保证;运
167
218
  代码与测试提交 e55075e 的 [Windows / Ubuntu CI](https://github.com/yanglaofish/Fishtools/actions/runs/38053011826) 均通过;Windows 5 分 13 秒、Ubuntu 1 分 27 秒。验收文档归档提交不改变已验证代码。
168
219
  # 2026-10-11 TUI 上手体验修正
169
220
 
221
+ ## 2026-10-11 本地后续验收:跨插件执行摘要与用户输出(未发布)
222
+
223
+ - 分支 `feat/collapsed-plugin-execution`,基线 `21eea07`。主面板默认展示每一步的插件、动作、状态和耗时;Ctrl+O 展开输入输出与诊断日志。错误及插件 `await print(...)` 输出默认可见,完整事件仍落盘。
224
+ - 新增 `preview-pipeline`:文本准备、转大写、保存报告分别由三个不同插件执行,验证结果逐步传递,输入文本与演示等待时间可调整。通过 `fst-preview.cmd` 启动后执行 `/run preview-pipeline`,仅使用隔离试用工作区。
225
+ - 本地终端、流程、插件契约、日志、进程、诊断和文档相关测试 **119/119** 通过;这不是新一轮完整发行包验收。Windows 与 WSL 真实 PTY 验证参数表、运行中状态、三插件成功、用户输出、Ctrl+O 展开/折叠与正常退出;WSL 另外验证返回配置菜单后继续运行,以及折叠状态直接显示缺失程序错误。
226
+ - 真实 TerminalView 渲染截图检查摘要状态、用户输出和快捷键提示。独立审查未发现 P1/P2 阻断问题,补跑终端相关 **13/13** 通过,并实测嵌套并行失败、交错运行、兄弟取消与子工作流输出绑定失败的显示归属和错误去重。
227
+ - 本轮未发布 npm、tag 或 Release,未升级全局 fst;新增 `print` 契约已在插件手册注明尚未发布。
228
+
229
+ ## 本地后续:核心公开接入审查与命令历史(未发布)
230
+
231
+ - 以 `bf4b983` 为审查快照,扩大检查插件加载、安装、生命周期、编排、日志及打包公开契约。核心相关测试 55/55 通过;从仓库外临时目录实际执行 CLI 导出手册、安装三个独立插件、重新加载、跨插件传值、print/log 展示与插件命令,链路通过。
232
+ - 审查时确认三项 P2:`runProcess` 相对程序路径不遵循 `cwd`;Web 打包选项限定官方插件 ID;共享 Ajv 重复注册配置 schema 的 `$id`,导致外部插件升级或后续安装失败。后续修复与验收见下节。
233
+ - 分支 `fix/terminal-command-history` 修复主输入缺少命令历史:会话内最多 100 条,连续重复合并,↑/↓ 浏览,回到底部恢复草稿。补全选择优先,多行保留光标移动,参数表单及取消输入不记录,退出后不持久化。
234
+ - 三项新增回归测试先失败后通过,终端相关 61/61 通过;Windows 真实 PTY 验证 `/config` 返回后 ↑ 取回、Enter 再执行。未发布或更新全局安装。
235
+
236
+ ## 公共插件契约三项缺陷修复(本地未发布)
237
+
238
+ - 分支 `fix/public-plugin-contracts`,基线 `eecba28`。相对程序路径及相对 PATH 以子进程 cwd 查找;不改变进程全局目录。Windows 和 WSL 均验证了含中文、空格目录中的程序调用。
239
+ - Web 打包和 TUI 环境选择按 `services.web` 的实际提供者查找,包含其传递插件依赖。关闭可选 Web 时,工作流或其他插件真正依赖的提供者仍保留。外部安装的第三方 Web 插件已导出,并使用包内 Node 与包内核心在仓库外成功调用服务。
240
+ - 每份 schema 独立编译,保留 WeakMap 缓存,隔离 `$id` 注册。真实 npm 安装回归覆盖带 `$id` 配置的插件首次安装、安装其他插件、移除其他插件、升级、拒绝旧版非法配置、重启与卸载;内部 `$ref` 保留,新旧约束不串用。
241
+ - 新增五项回归断言先失败后通过,专项 14/14;后续发现 TUI 同类官方包名残留,追加失败测试后修复。全量 `npm test` 181/181 通过(194.6 秒);TUI 修复后终端与文档 66/66 通过。最终增强打包子进程及依赖保留断言后,进程与打包 9/9 通过。
242
+ - WSL 进程与插件安装升级 12/12 通过;随后补强含空格目录用例,WSL 进程专项 7/7 通过。首次 WSL 启动命令因 PATH 含空格发生 shell 解析错误,改用脚本启动后完成验证;不将启动失败计为产品回归。
243
+ - 独立审查未发现 P1/P2 阻断;其建议的含空格 cwd 和普通插件依赖 Web 两项边界已补验。开发手册同步契约说明。本轮未改变 history/logs 语义,未发布、推送或升级全局安装。
244
+
245
+ ## 首次上手体验验收详情(历史)
246
+
170
247
  - 修正首页缺少产品介绍、示例入口以及插件清单不显眼的问题:显示 FISHtools 字符标识、欢迎语、当前工作区、数量和真实工作流示例。
171
248
  - `/plugins` 直接列出已有插件,详情展示声明的动作、输入输出、环境、配置及命令;维护模式仍保留说明,仅隐藏不可执行的命令入口。
172
249
  - 完整页面按终端高度分配空间;80×24、52×24、40×20 自动化渲染检查覆盖首页和可滚动插件列表。
173
- - 本地终端与 Markdown 阅读器共 26 项测试通过;Windows 与 WSL 真实 PTY 检查首页、插件列表和详情返回。安装及远端全量验证以对应 CI 最终结果为准。
250
+ - 本地终端与 Markdown 阅读器共 26 项测试通过;Windows 与 WSL 真实 PTY 检查首页、插件列表和详情返回。本地 npm 安装、再次导出及包内插件调用验证通过。
251
+ - 提交 `4c6bdc5` 的 [Windows/Linux 全量 CI](https://github.com/yanglaofish/Fishtools/actions/runs/38071024230) 各 91 项通过;[v0.1.4 发布验收](https://github.com/yanglaofish/Fishtools/actions/runs/38071614758) 的双平台测试、自包含归档解压运行、无 Node Docker 检查及发布全部成功。npm 官方注册表已提供 `@yanglaofish/fishtools@0.1.4`,[Release](https://github.com/yanglaofish/Fishtools/releases/tag/v0.1.4) 已附三个归档及 SHA256SUMS。
174
252
  - 修复 npm 发布预演依赖远端版本状态的问题:仅预演采用离线独立空缓存,实际发布继续检查注册表版本和归档校验值。
253
+
254
+ ## 2026-10-11 0.2.0 控制流、运行观察与离线开发资料
255
+
256
+ - 本地最终 `npm test` **143/143** 通过,180.2 秒;涵盖真实 npm 离线全局安装、再次导出、资料定位和归档完整性。Web 构建通过,无新增运行时依赖。
257
+ - 独立审查发现并修复:嵌套并行首因可能被取消覆盖、空分组与最后跳过节点漏检取消、null schema 绕过声明、失败后用户取消覆盖最终状态;分别有失败复现和回归测试。服务示例补齐严格输出声明。
258
+ - WSL Linux 运行控制流、实时输出和旧工作流专项 **29/29** 通过。Windows 完整便携 ZIP 解压后清空 PATH,使用包内 Node 执行 hello、control-demo、正常停止、离线手册阅读及导出成功。
259
+ - Windows 导出的同一 Linux tar.gz 在 WSL Ubuntu 24.04 中解压,通过 80×24 真实 PTY 验证彩色首页、/docs 阅读/翻页/鼠标滚轮、control-demo 运行、输入输出与日志视图、关闭面板、/runs 重新观察及正常退出。Windows 真实 PTY 已验证文档阅读、插件详情和退出;终端专项 **44/44** 通过,包含 40×20 窄屏、NO_COLOR 与安全文本渲染。
260
+ - Linux 包只读挂载到 Debian 12 slim 容器;容器断网、根文件系统只读、移除 capabilities、基础环境无 Node/npm。手册导出后实际安装零依赖示例,offline.greet 返回“你好,离线用户”;条件并行与提前结束、配置保存、参数修改、完整性检查及再次导出全部通过。复现入口 `scripts/portable-smoke.sh`。
261
+ - Chrome 真实浏览器 1440 与 390 宽度验证嵌套控制节点编辑保存、输入引用、执行结果、日志暂停/恢复跟随、关闭面板后台继续、Esc 焦点及只读管理;无横向溢出或 console error。终态 cancelled / stopped 与未完成状态分别显示;Web 模型 **6/6** 通过。
262
+ - npm 与便携包包含 `manual/`;`fst docs`、`fst docs plugins`、`fst docs --export <新目录>` 与 TUI `/docs` 均可离线访问。文档测试 **4/4**,验证相对 Markdown 链接、拒绝覆盖、脱离源码定位和再次导出。SDK 尚未独立发布;示例使用标准对象,不依赖内部 SDK 的在线安装。
263
+ - 验收脚本复用旧临时工作区曾被项目归属校验拒绝;脚本现为每次解压生成独立工作区,重新验收通过。没有放宽归属或完整性检查。
264
+ - 本记录为本地验收结果;远端 CI 与 npm/tag/Release 结果在发布完成后追加。
265
+
266
+ - 提交 `4956cc0` 的 [Windows / Ubuntu 全量 CI](https://github.com/yanglaofish/Fishtools/actions/runs/38075609079) 均通过,各 **143/143**;Windows 6 分 17 秒、Ubuntu 2 分 8 秒。
267
+
268
+ - `v0.2.0` 的[正式发布验收](https://github.com/yanglaofish/Fishtools/actions/runs/38076094907)中,两平台各 **143/143**、Windows/Linux 自包含包及 Docker 检查全部通过。npm 于北京时间 2026-10-11 02:37 接受上传,随后注册表可见性等待超过五分钟,首次发布任务因此失败,当时尚未完成公开 Release。维护者[只读状态查询](https://github.com/yanglaofish/Fishtools/actions/runs/38077080952)返回 HTTP 200、`validating`;不是鉴权失败,当时不能将此次上传宣称为已可在线安装。随后注册表可见,核对同一归档后重跑原任务的失败步骤完成同步。
269
+
270
+ - 最终 npm 官方注册表的 `latest` 为 **0.2.0**,版本元数据与已验收归档 SHA-512 完全一致,tarball 可下载;[发布流程](https://github.com/yanglaofish/Fishtools/actions/runs/38076094907)重试后成功。[v0.2.0 Release](https://github.com/yanglaofish/Fishtools/releases/tag/v0.2.0) 已公开于本仓库访问范围,包含 npm `.tgz`、Windows `.zip`、Linux `.tar.gz` 与 `SHA256SUMS`。没有改动 tag 或重复上传不同归档。
@@ -0,0 +1,20 @@
1
+ # 可独立安装的服务插件示例
2
+
3
+ `provider` 提供 `demo.connection`,`consumer` 提供 `/demo.read` 和可编排动作。两者是标准 npm 插件目录,无需修改核心代码;MIT 许可证随目录保留。
4
+
5
+ 已安装 fst 后,使用 `fst docs --export ./fishtools-manual` 导出资料,再进入导出目录执行以下命令。源码开发也可直接从仓库根目录执行:
6
+
7
+ ```sh
8
+ fst --maintenance plugin add ./examples/service-plugins/provider
9
+ fst --maintenance plugin add ./examples/service-plugins/consumer
10
+ fst command demo.read
11
+ fst
12
+ ```
13
+
14
+ 在 TUI 输入 `/demo.read`,或将“使用共享连接”添加到工作流。两个动作共享同一个连接实例;每次启动应用创建新实例,调用计数不会跨进程保留。
15
+
16
+ `dependencies` 声明提供者的插件 ID 和版本,使导出消费者工作流时自动包含提供者;`runtime.inject` 声明运行时服务就绪要求。它们与 package.json 的 npm 库依赖承担不同职责。
17
+
18
+ 示例不连接真实网络。实际 SSH/数据库插件应在 ready 中完成连接后再 `ctx.set()`,在 `ctx.on('dispose', async () => ...)` 中关闭;不要提前发布未就绪的连接。
19
+
20
+ 修改有状态插件前,在 TUI `/plugins` 进入维护模式,修改完成后选择恢复服务;原子 CLI 使用 `--maintenance`。维护模式是本进程选项,不写入项目文件。按默认方式再次启动即恢复服务。
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mytools contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ export default {
2
+ id:'fishtools-demo-consumer',version:'1.0.0',title:'共享服务调用示例',
3
+ dependencies:{'fishtools-demo-connection':'^1.0.0'},
4
+ runtime:{inject:['demo.connection'],apply(){}},
5
+ commands:{'demo.read':{action:'read',description:'使用共享连接读取文本'}},
6
+ actions:{read:{
7
+ title:'使用共享连接',
8
+ input:{type:'object',properties:{text:{type:'string',default:'hello'}},additionalProperties:false},
9
+ output:{type:'object',properties:{text:{type:'string'},uses:{type:'integer',minimum:1}},required:['text','uses'],additionalProperties:false},
10
+ async run(input,{getService,signal}){
11
+ signal?.throwIfAborted();return getService('demo.connection').read(input.text);
12
+ },
13
+ }},
14
+ };
@@ -0,0 +1 @@
1
+ {"name":"fishtools-demo-consumer","version":"1.0.0","type":"module","main":"index.js","files":["index.js","LICENSE"],"license":"MIT"}
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mytools contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ export default {
2
+ id:'fishtools-demo-connection',version:'1.0.0',title:'共享连接示例',
3
+ runtime:{
4
+ provides:['demo.connection'],
5
+ apply(ctx){
6
+ let connection;
7
+ ctx.on('dispose',async()=>{await connection?.close();});
8
+ ctx.on('ready',async()=>{
9
+ // 实际插件可在此连接 SSH 或数据库;就绪后才发布服务。
10
+ let uses=0,closed=false;
11
+ connection={
12
+ read(text){if(closed)throw Error('连接已关闭');return {text:`connection:${text}`,uses:++uses};},
13
+ async close(){closed=true;},
14
+ };
15
+ ctx.set('demo.connection',connection);
16
+ });
17
+ },
18
+ },
19
+ };
@@ -0,0 +1 @@
1
+ {"name":"fishtools-demo-connection","version":"1.0.0","type":"module","main":"index.js","files":["index.js","LICENSE"],"license":"MIT"}
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mytools contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,211 @@
1
+ import { mkdir, appendFile, writeFile, rename, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { EventEmitter } from 'node:events';
5
+ import { createHost } from './host.js';
6
+ import { WorkflowStore } from './workflows.js';
7
+ import { runWorkflow } from './engine.js';
8
+ import { runProcess } from './process.js';
9
+ import { readJson, loadPlugin, installGeneration } from './plugins.js';
10
+ import { FlowError } from '@mytools/sdk';
11
+ import {prepareBundleWorkspace} from './bundle-workspace.js';
12
+ import {validate} from './validation.js';
13
+ import {createResources} from './resources.js';
14
+ import {errorRecord} from './diagnostics.js';
15
+ import {markdownHeader,markdownEvent,markdownResult} from './markdown-log.js';
16
+ import {createRunLogs,validateKeepRuns} from './run-logs.js';
17
+
18
+ export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false}={}) {
19
+ projectDir=await prepareBundleWorkspace(path.resolve(projectDir),workspace);
20
+ const project=await readJson(path.join(projectDir,'flow.project.json'),{plugins:[]});
21
+ const output=path.resolve(outputDir??process.env.FLOW_OUTPUT_DIR??(project.readOnly?path.join(process.cwd(),'flow-results'):path.join(projectDir,'.flow','runs')));
22
+ const store=new WorkflowStore(path.join(projectDir,'workflows'));
23
+ const emitter=new EventEmitter(), runs=new Map();
24
+ const resources=createResources(),operations=new Set();let closing,launching=0;
25
+ const runLogs=createRunLogs(output,{projectDir,allowLegacy:output===path.join(projectDir,'.flow','runs')}),writingRuns=new Set();
26
+ validateKeepRuns(project.logs?.keepRuns??null);
27
+ let host,state=await readJson(path.join(projectDir,'.flow','plugins.json'),{}),busy=false;
28
+ const app={
29
+ projectDir,project,outputDir:output,store,
30
+ get host(){return host;},
31
+ get maintenance(){return maintenance;},
32
+ catalog:()=>host.catalog(),
33
+ listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,runtime:host.runtimeStatus().find(r=>r.id===p.id),removable:!!state.dependencies?.[p.id]})),
34
+ listCommands:()=>host.commands(),
35
+ listWorkflows:()=>store.list(),
36
+ async saveWorkflow(w){if(project.readOnly)throw new FlowError('此分发包仅用于运行;请在开发项目中编辑');return store.save(w);},
37
+ async removeWorkflow(id){if(project.readOnly)throw new FlowError('只读项目不能删除工作流');return store.remove(id);},
38
+ checkWorkflow:id=>store.resolve([id],host),
39
+ subscribe(fn){emitter.on('event',fn);return ()=>emitter.off('event',fn);},
40
+ async startRun(id,input={}) {
41
+ if(busy) throw new FlowError('插件更新中,请稍后运行');
42
+ const closure=await store.resolve([id],host);
43
+ const snapshotStore={resolve:async()=>structuredClone(closure),childId:ref=>store.childId(ref)};
44
+ return app.executeTracked(id,(activeHost,context,event)=>runWorkflow({store:snapshotStore,host:activeHost,id,input,signal:context.signal,onEvent:event,runId:context.runId,context}));
45
+ },
46
+ async startCommand(name,input={},context={}){
47
+ if(busy)throw new FlowError('插件更新中,请稍后调用');
48
+ return app.executeTracked(`/${name}`,(activeHost,runContext)=>activeHost.invokeCommand(name,input,{...context,...runContext}));
49
+ },
50
+ async executeTracked(id,execute){
51
+ const runId=randomUUID(),dir=path.join(output,runId),controller=new AbortController();
52
+ await mkdir(dir,{recursive:true});
53
+ const run={id:runId,workflowId:id,logOwner:runLogs.owner,status:'running',startedAt:new Date().toISOString(),events:[],controller};
54
+ runs.set(runId,run);
55
+ writingRuns.add(runId);
56
+ const markdownFile=path.join(dir,'run.md');
57
+ let writes=writeFile(markdownFile,markdownHeader(run));
58
+ const event=e=>{
59
+ const full={time:new Date().toISOString(),runId,...e};run.events.push(full);
60
+ emitter.emit('event',full);
61
+ const {output:ignored,...record}=full;
62
+ writes=writes.then(async()=>{
63
+ await appendFile(path.join(dir,'events.jsonl'),JSON.stringify(record)+'\n');
64
+ await appendFile(markdownFile,markdownEvent(full));
65
+ });
66
+ return writes;
67
+ };
68
+ const activeHost=host;
69
+ run.done=(async()=>{
70
+ try {
71
+ await event({type:'run.started'});
72
+ run.output=await execute(activeHost,{outputDir:dir,signal:controller.signal,runId,log:(message,data,meta={})=>event({...meta,type:'log',message,data})},event);
73
+ run.status='succeeded';await event({type:'run.succeeded'});
74
+ } catch(error) {
75
+ if(error?.code==='WORKFLOW_STOPPED'){
76
+ run.status='stopped';run.reason=error.details?.reason??error.message;
77
+ await event({type:'run.stopped',reason:run.reason});
78
+ }else{
79
+ run.status=error?.code==='EXECUTION_FAILED'?'failed':controller.signal.aborted||['PROMPT_CANCELLED','CANCELLED'].includes(error?.code)?'cancelled':'failed';
80
+ run.error=errorRecord(error);
81
+ await event({type:`run.${run.status}`,message:run.error.message,code:run.error.code,plugin:run.error.plugin,action:run.error.action,command:run.error.command,details:run.error.details,error:run.error});
82
+ }
83
+ } finally {
84
+ run.finishedAt=new Date().toISOString();
85
+ await writes;
86
+ await appendFile(markdownFile,markdownResult(app.getRun(runId)));
87
+ await writeFile(path.join(dir,'result.json'),JSON.stringify(snapshotRun(run),null,2));
88
+ writingRuns.delete(runId);
89
+ try{await pruneLogs();}catch(error){
90
+ await event({type:'log',message:`日志自动清理失败:${error.message}`});
91
+ await writeFile(path.join(dir,'result.json'),JSON.stringify(snapshotRun(run),null,2));
92
+ }
93
+ }
94
+ return snapshotRun(run);
95
+ })();
96
+ return run;
97
+ },
98
+ getRun(id){const r=runs.get(id);return r?snapshotRun(r):null;},
99
+ async readRun(id){if(runs.has(id))return app.getRun(id);if(!/^[a-f0-9-]{36}$/.test(id))throw new FlowError('运行 ID 无效');return JSON.parse(await readFile(path.join(output,id,'result.json'),'utf8'));},
100
+ listRuns:()=>[...runs.keys()].map(id=>{const {events,...r}=app.getRun(id);return r;}),
101
+ logSettings:()=>({keepRuns:project.logs?.keepRuns??null}),
102
+ async listRunHistory(){
103
+ const records=new Map((await runLogs.list()).map(r=>[r.id,r]));
104
+ for(const id of runs.keys())records.set(id,app.getRun(id));
105
+ return [...records.values()].sort((a,b)=>Date.parse(b.finishedAt??b.startedAt)-Date.parse(a.finishedAt??a.startedAt)||b.id.localeCompare(a.id)).map(({events,output,...r})=>r);
106
+ },
107
+ async readLog(id){
108
+ const run=runs.get(id);
109
+ if(run&&writingRuns.has(id))return markdownHeader(run)+run.events.map(markdownEvent).join('');
110
+ return runLogs.markdown(id);
111
+ },
112
+ async configureLogs(keepRuns){
113
+ validateKeepRuns(keepRuns);if(project.readOnly)throw new FlowError('只读运行包不能保存日志设置');
114
+ if(busy)throw new FlowError('项目正在更新,请稍后修改日志设置');
115
+ busy=true;
116
+ try{
117
+ const logs={...project.logs,keepRuns},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
118
+ await mkdir(projectDir,{recursive:true});await writeFile(temp,JSON.stringify({...project,logs},null,2));await rename(temp,file);project.logs=logs;
119
+ await pruneLogs();
120
+ }finally{busy=false;}
121
+ },
122
+ cancelRun(id){const r=runs.get(id);if(r?.status==='running')r.controller.abort();return !!r;},
123
+ async installPlugin(source){return change({add:source});},
124
+ async removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
125
+ async pack(options){const {packApplication}=await import('@mytools/pack');return packApplication(app,options);},
126
+ async packPlan(options){const {planApplication}=await import('@mytools/pack');return planApplication(app,options);},
127
+ async configurePlugin(id,value){
128
+ if(project.readOnly)throw new FlowError('只读项目不能保存配置');
129
+ assertMutable();
130
+ if(resources.active(id))throw new FlowError('请先停止此插件提供的 Web,再修改配置');
131
+ if(busy||launching||app.listRuns().some(r=>r.status==='running'))throw new FlowError('请等待运行或安装结束');
132
+ if(!host.definitions.has(id))throw new FlowError(`插件不存在:${id}`);
133
+ value=validate(host.definitions.get(id).config,value,`${id} 配置`);
134
+ busy=true;let nextHost;
135
+ try{
136
+ const nextConfig={...project.config,[id]:value};
137
+ nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
138
+ const nextProject={...project,config:nextConfig},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
139
+ await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
140
+ const old=host;host=nextHost;nextHost=null;project.config=nextConfig;await old.close();
141
+ }finally{try{if(nextHost)await nextHost.close();}finally{busy=false;}}
142
+ },
143
+ async setMaintenance(enabled){
144
+ if(typeof enabled!=='boolean')throw new FlowError('维护模式必须为布尔值');
145
+ if(enabled===maintenance)return;
146
+ if(busy||launching||app.listRuns().some(r=>r.status==='running'))throw new FlowError('请等待运行或安装结束');
147
+ if([...host.definitions.values()].some(p=>p.runtime&&resources.active(p.id)))throw new FlowError('请先停止运行时插件提供的 Web');
148
+ busy=true;
149
+ try{
150
+ const entries=[...host.definitions.values()].map(p=>({plugin:p,root:p.root}));
151
+ if(enabled){maintenance=true;await host.close();}
152
+ const next=await createHost({plugins:entries,config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!enabled});
153
+ const old=host;host=next;maintenance=enabled;if(!enabled)await old.close();
154
+ }finally{busy=false;}
155
+ },
156
+ startWeb(options){
157
+ if(busy)throw new FlowError('插件更新中,请稍后启动 Web');
158
+ const owner=[...host.definitions.values()].find(p=>p.services?.web)?.id;
159
+ return resources.own(()=>host.service('web',options),owner);
160
+ },
161
+ close(){
162
+ closing??=Promise.resolve().then(async()=>{
163
+ for(const r of runs.values())if(r.status==='running')r.controller.abort();
164
+ await Promise.allSettled([...operations]);
165
+ for(const r of runs.values())if(r.status==='running')r.controller.abort();
166
+ await Promise.allSettled([...runs.values()].map(r=>r.done));
167
+ const errors=[];
168
+ try{await resources.close();}catch(error){errors.push(error);}
169
+ try{await host.close();}catch(error){errors.push(error);}
170
+ if(errors.length)throw new AggregateError(errors,`应用关闭失败:${errors.map(e=>e.message).join(';')}`);
171
+ });return closing;
172
+ },
173
+ };
174
+ const initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
175
+ const services={application:app,runProcess};
176
+ const build=extra=>createHost({plugins:[...initial,...extra],config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
177
+ const external=[];
178
+ for(const name of Object.keys(state.dependencies??{}))external.push(await loadPlugin(name,state.root));
179
+ host=await build(external);
180
+ for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
181
+ const original=app[name],isRun=['startRun','startCommand'].includes(name);
182
+ app[name]=async(...args)=>{
183
+ if(closing)throw new FlowError('应用已关闭');
184
+ if(isRun)launching++;
185
+ const task=Promise.resolve().then(()=>original.apply(app,args));operations.add(task);
186
+ try{return await task;}finally{operations.delete(task);if(isRun)launching--;}
187
+ };
188
+ }
189
+ function assertMutable(){if(!maintenance&&host.hasRuntime)throw new FlowError('运行时插件已启动,请先进入维护模式再修改插件或配置');}
190
+ function snapshotRun(run){const {done,controller,...result}=run;return structuredClone(result);}
191
+ async function pruneLogs(){for(const id of await runLogs.prune(project.logs?.keepRuns??null,writingRuns))runs.delete(id);}
192
+ async function change(operation){
193
+ if(project.readOnly)throw new FlowError('运行包不支持安装或移除插件');
194
+ assertMutable();
195
+ if(busy || launching || [...runs.values()].some(r=>r.status==='running'))throw new FlowError('请等待当前运行或安装结束');
196
+ busy=true;let nextHost;
197
+ try {
198
+ const next=await installGeneration(projectDir,{...operation,state});
199
+ for(const p of host.definitions.values())if(resources.active(p.id)){
200
+ const candidate=[...initial,...next.loaded].find(e=>(e.plugin??e).id===p.id);
201
+ if(!candidate||candidate.root!==p.root||(candidate.plugin??candidate).version!==p.version)throw new FlowError('请先停止活动 Web,再替换其提供者');
202
+ }
203
+ nextHost=await build(next.loaded);
204
+ const file=path.join(projectDir,'.flow','plugins.json'),temp=`${file}.tmp`;
205
+ await writeFile(temp,JSON.stringify(next.state,null,2));await rename(temp,file);
206
+ const old=host;host=nextHost;nextHost=null;state=next.state;await old.close();
207
+ return app.listPlugins();
208
+ } finally {try{if(nextHost)await nextHost.close();}finally{busy=false;}}
209
+ }
210
+ return app;
211
+ }
@@ -0,0 +1,93 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { FlowError } from '@mytools/sdk';
3
+ import { bind, validate, emptyObject } from './validation.js';
4
+ import {errorRecord} from './diagnostics.js';
5
+ import {evaluateCondition,childLists} from './control.js';
6
+
7
+ export async function runWorkflow({store,host,id,input={},signal,onEvent=()=>{},runId=randomUUID(),context={}}) {
8
+ const closure=await store.resolve([id],host);
9
+ const snapshot=new Map(closure.workflows.map(w=>[w.id,w]));
10
+ const emit=event=>onEvent({time:new Date().toISOString(),runId,...event});
11
+ const failures=new WeakMap();
12
+ const checkCancelled=currentSignal=>{if(currentSignal?.aborted)throw new FlowError('运行已取消',{code:'CANCELLED'});};
13
+ const skip=async(step,w,path,message)=>{
14
+ const callPath=`${path}/${step.id}`;
15
+ await emit({type:'step.skipped',workflowId:w.id,stepId:step.id,callPath,action:step.action,message});
16
+ for(const list of childLists(step))for(const child of list)await skip(child,w,callPath,message);
17
+ if(step.workflow){const child=snapshot.get(store.childId(step.workflow));for(const node of child.steps)await skip(node,child,callPath,message);}
18
+ };
19
+ const sequence=async(steps,w,base,path,currentSignal,reportFailure)=>{
20
+ const state={inputs:base.inputs,steps:{...base.steps}},outputs={};
21
+ for(let i=0;i<steps.length;i++){
22
+ try{
23
+ const result=await executeStep(steps[i],w,state,path,currentSignal,reportFailure);
24
+ state.steps[steps[i].id]=result;
25
+ if(Object.hasOwn(result,'output'))outputs[steps[i].id]=result.output;
26
+ }catch(error){
27
+ for(const pending of steps.slice(i+1))await skip(pending,w,path,error.code==='WORKFLOW_STOPPED'?'工作流已提前结束':'前序步骤失败或运行已取消');
28
+ throw error;
29
+ }
30
+ }
31
+ checkCancelled(currentSignal);return {state,outputs};
32
+ };
33
+ const parallel=async(group,w,state,path,currentSignal,reportFailure)=>{
34
+ const controller=new AbortController(),combined=currentSignal?AbortSignal.any([currentSignal,controller.signal]):controller.signal;
35
+ const results=new Array(group.steps.length);let next=0,firstError;
36
+ // 叶子先锁定首因,再写事件;父控制节点的异步收尾不能改变失败顺序。
37
+ const fail=error=>{if(!firstError){firstError=error;reportFailure?.(error);controller.abort(error);}};
38
+ const worker=async()=>{
39
+ while(!firstError&&next<group.steps.length){
40
+ const index=next++,step=group.steps[index];
41
+ try{results[index]=await executeStep(step,w,{inputs:state.inputs,steps:{...state.steps}},path,combined,fail);}
42
+ catch(error){fail(error);}
43
+ }
44
+ };
45
+ // 等待所有已启动任务清理,避免 Promise.all 首次拒绝后仍有后台动作执行。
46
+ await Promise.all(Array.from({length:Math.min(group.concurrency??4,group.steps.length)},worker));
47
+ if(firstError){for(const step of group.steps.slice(next))await skip(step,w,path,'并行组已停止调度');throw firstError;}
48
+ return Object.fromEntries(group.steps.flatMap((step,index)=>Object.hasOwn(results[index]??{},'output')?[[step.id,results[index].output]]:[]));
49
+ };
50
+ const executeStep=async(step,w,state,parentPath,currentSignal,reportFailure)=>{
51
+ const callPath=`${parentPath}/${step.id}`,start=Date.now(),base={workflowId:w.id,stepId:step.id,callPath,action:step.action};
52
+ try{
53
+ checkCancelled(currentSignal);
54
+ if(step.when&&!evaluateCondition(step.when,state)){await skip(step,w,parentPath,'执行条件不满足');return {status:'skipped'};}
55
+ const args=step.action||step.workflow?bind(step.with??{},state):{};
56
+ await emit({type:'step.started',...base,input:args});
57
+ let output;
58
+ if(step.action)output=await host.invoke(step.action,args,{
59
+ ...context,signal:currentSignal,runId,stepId:step.id,callPath,workflow:w,
60
+ log:(message,data,meta={})=>emit({type:'log',stream:meta.stream,...(meta.audience==='user'?{audience:'user'}:{}),...base,message,data}),
61
+ });
62
+ else if(step.workflow)output=await execute(store.childId(step.workflow),args,callPath,currentSignal,reportFailure);
63
+ else if(step.branch){
64
+ const matched=evaluateCondition(step.branch.condition,state);
65
+ for(const unused of matched?(step.branch.else??[]):step.branch.then)await skip(unused,w,callPath,'未选择此分支');
66
+ output=(await sequence(matched?step.branch.then:(step.branch.else??[]),w,state,callPath,currentSignal,reportFailure)).outputs;
67
+ }else if(step.parallel)output=await parallel(step.parallel,w,state,callPath,currentSignal,reportFailure);
68
+ else throw new FlowError(step.stop.reason,{code:'WORKFLOW_STOPPED',details:{reason:step.stop.reason}});
69
+ checkCancelled(currentSignal);
70
+ await emit({type:'step.succeeded',...base,durationMs:Date.now()-start,output});
71
+ return {status:'succeeded',output};
72
+ }catch(cause){
73
+ const origin=failures.get(cause),propagated=origin&&(origin===callPath||origin.startsWith(callPath+'/'));
74
+ const cancelled=propagated?cause.code==='CANCELLED':!!currentSignal?.aborted,stopped=!cancelled&&cause.code==='WORKFLOW_STOPPED',error=errorRecord(cause);
75
+ const failure=propagated||stopped?cause:Object.assign(new FlowError(`${callPath}: ${error.message}`,{code:cancelled?'CANCELLED':'EXECUTION_FAILED',cause,details:error.details}),{plugin:error.plugin,action:error.action});
76
+ if(!failures.has(failure))failures.set(failure,callPath);
77
+ reportFailure?.(failure);
78
+ await emit({type:cancelled?'step.cancelled':stopped?'step.stopped':'step.failed',...base,plugin:error.plugin,message:error.message,code:error.code,details:error.details,error:stopped?undefined:error,durationMs:Date.now()-start});
79
+ throw failure;
80
+ }
81
+ };
82
+ const execute=async(workflowId,values,callPath,currentSignal,reportFailure)=>{
83
+ checkCancelled(currentSignal);
84
+ const w=snapshot.get(workflowId),state={inputs:validate(w.inputs??emptyObject,values,`${workflowId} 输入`),steps:{}};
85
+ await emit({type:'workflow.started',workflowId,callPath});
86
+ try{
87
+ const result=await sequence(w.steps,w,state,callPath,currentSignal,reportFailure),output=bind(w.outputs??{},result.state);
88
+ checkCancelled(currentSignal);
89
+ await emit({type:'workflow.succeeded',workflowId,callPath});return output;
90
+ }catch(error){if(error.code==='WORKFLOW_STOPPED')await emit({type:'workflow.stopped',workflowId,callPath,message:error.details.reason});throw error;}
91
+ };
92
+ return execute(id,input,id,signal);
93
+ }