@yanglaofish/fishtools 0.2.0 → 0.4.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 (83) hide show
  1. package/README.md +43 -17
  2. package/manual/README.md +43 -17
  3. package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
  4. package/manual/docs/examples/pipeline-reader/index.js +14 -0
  5. package/manual/docs/examples/pipeline-reader/package.json +7 -0
  6. package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
  7. package/manual/docs/examples/pipeline-writer/index.js +17 -0
  8. package/manual/docs/examples/pipeline-writer/package.json +7 -0
  9. package/manual/docs/examples/script-plugin/LICENSE +21 -0
  10. package/manual/docs/examples/script-plugin/README.md +64 -0
  11. package/manual/docs/examples/script-plugin/index.js +39 -0
  12. package/manual/docs/examples/script-plugin/package.json +9 -0
  13. package/manual/docs/examples/script-plugin/scripts/benchmark.py +23 -0
  14. package/manual/docs/plugin-development.md +75 -14
  15. package/manual/docs/releases.md +30 -1
  16. package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
  17. package/manual/docs/roadmap.md +8 -1
  18. package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +1 -1
  19. package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
  20. package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
  21. package/manual/docs/superpowers/plans/2026-10-12-plugin-developer-experience.md +50 -0
  22. package/manual/docs/superpowers/reviews/2026-10-12-plugin-release-review.md +20 -0
  23. package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
  24. package/manual/docs/superpowers/specs/2026-10-12-plugin-developer-experience.md +22 -0
  25. package/manual/docs/verification.md +100 -0
  26. package/manual/packages/core/src/application.js +235 -0
  27. package/manual/packages/core/src/engine.js +93 -0
  28. package/manual/packages/core/src/host.js +22 -5
  29. package/manual/packages/core/src/runtime.js +135 -0
  30. package/manual/packages/core/src/terminal/run-feed.js +78 -0
  31. package/manual/packages/core/src/terminal/session.js +438 -0
  32. package/manual/packages/core/src/terminal/view.js +283 -0
  33. package/manual/tests/application.test.js +30 -0
  34. package/node_modules/@mytools/core/bin/fst.js +27 -5
  35. package/node_modules/@mytools/core/package.json +2 -0
  36. package/node_modules/@mytools/core/src/application.js +46 -21
  37. package/node_modules/@mytools/core/src/contracts.js +27 -9
  38. package/node_modules/@mytools/core/src/documentation.js +1 -1
  39. package/node_modules/@mytools/core/src/engine.js +1 -1
  40. package/node_modules/@mytools/core/src/environments.js +39 -1
  41. package/node_modules/@mytools/core/src/host.js +22 -5
  42. package/node_modules/@mytools/core/src/markdown-log.js +1 -1
  43. package/node_modules/@mytools/core/src/plugin-installations.js +84 -0
  44. package/node_modules/@mytools/core/src/plugin-tools.js +133 -0
  45. package/node_modules/@mytools/core/src/plugins.js +16 -6
  46. package/node_modules/@mytools/core/src/process.js +30 -8
  47. package/node_modules/@mytools/core/src/schema-path.js +31 -0
  48. package/node_modules/@mytools/core/src/terminal/model.js +1 -1
  49. package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
  50. package/node_modules/@mytools/core/src/terminal/presentation.js +3 -3
  51. package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
  52. package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
  53. package/node_modules/@mytools/core/src/terminal/run-viewer.js +30 -12
  54. package/node_modules/@mytools/core/src/terminal/session.js +118 -50
  55. package/node_modules/@mytools/core/src/terminal/theme.js +1 -0
  56. package/node_modules/@mytools/core/src/terminal/view.js +125 -28
  57. package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
  58. package/node_modules/@mytools/core/src/validation.js +24 -9
  59. package/node_modules/@mytools/core/src/version.js +1 -1
  60. package/node_modules/@mytools/core/src/workflows.js +13 -15
  61. package/node_modules/@mytools/pack/dependencies.js +8 -1
  62. package/node_modules/@mytools/pack/index.js +4 -2
  63. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +871 -870
  64. package/node_modules/{tar → minipass-flush/node_modules/minipass}/node_modules/yallist/README.md +7 -8
  65. package/node_modules/minipass-flush/node_modules/minipass/node_modules/yallist/package.json +29 -0
  66. package/node_modules/yallist/README.md +8 -7
  67. package/node_modules/yallist/package.json +51 -12
  68. package/package.json +15 -15
  69. package/node_modules/tar/node_modules/yallist/package.json +0 -68
  70. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/LICENSE +0 -0
  71. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/iterator.js +0 -0
  72. /package/node_modules/{yallist → minipass-flush/node_modules/minipass/node_modules/yallist}/yallist.js +0 -0
  73. /package/node_modules/{tar/node_modules/yallist → yallist}/LICENSE.md +0 -0
  74. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts +0 -0
  75. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.d.ts.map +0 -0
  76. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js +0 -0
  77. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/index.js.map +0 -0
  78. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/commonjs/package.json +0 -0
  79. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts +0 -0
  80. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.d.ts.map +0 -0
  81. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js +0 -0
  82. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/index.js.map +0 -0
  83. /package/node_modules/{tar/node_modules/yallist → yallist}/dist/esm/package.json +0 -0
@@ -0,0 +1,50 @@
1
+ # 第三方插件开发体验实施计划
2
+
3
+ > 执行方式:使用测试先行;独立模块按 superpowers:dispatching-parallel-agents 并行实施,宿主集成在当前会话完成。用户已经授权实施。
4
+
5
+ **目标:** 修复两个 issue 的明确缺陷,补齐第三方插件创建、校验、开发引用及宿主公共能力。
6
+
7
+ **架构:** schema 路径与校验共用一个契约层;开发工具复用加载契约且不启动服务;应用层管理插件批次与工作区状态。保持现有动作、工作流及 Cordis 生命周期结构。
8
+
9
+ **技术栈:** Node.js >=24.15.0、ESM、Ajv、Execa、Cordis、node:test。
10
+
11
+ **规范:** ../specs/2026-10-12-plugin-developer-experience.md
12
+
13
+ ## 全局约束与审查重点
14
+
15
+ 中文文档及注释。保持 utf8 默认、维护模式保护和输出上限;不实现运行时热重载、长度伪字段、输出截断及关键词平台推断。只在受控临时工作区测试数据操作。重点检查动态键嵌套类型、显式解释器不回退、Scoped npm ID 状态目录、失效源码链接、清理时恢复批次和多进程安全。
16
+
17
+ ## 任务 1:契约与诊断
18
+
19
+ 文件:validation.js、contracts.js、workflows.js、新 schema-path.js 及契约测试。
20
+ 接口:validate(schema,value,label,{useDefaults=true});共用 schema 路径解析。
21
+ - [x] 增加动态字段、非法嵌套、完整字段路径、输出不补默认值的失败测试。
22
+ - [x] 实现共用解析与校验策略,定向测试通过。
23
+
24
+ ## 任务 2:进程编码
25
+
26
+ 文件:process.js、tests/process.test.js。
27
+ 接口:runProcess(file,args,{encoding='utf8',...})。
28
+ - [x] 增加 UTF16LE 分块及编码一致性的失败测试。
29
+ - [x] 实现统一解码,验证取消/超时/字节限制回归。
30
+
31
+ ## 任务 3:插件工具
32
+
33
+ 文件:新 plugin-tools.js、fst.js、开发工具测试。
34
+ 接口:validatePluginDirectory(directory)、createPlugin(name,directory)。
35
+ - [x] 添加身份/schema/平台诊断、无 runtime 副作用和脚手架可安装的失败测试。
36
+ - [x] 实现 validate/create 与 CLI 输出及退出码,验证生成插件。
37
+
38
+ ## 任务 4:宿主和开发循环
39
+
40
+ 文件:plugins.js、application.js、host.js、environments.js、fst.js、pack 及测试。
41
+ - [x] 添加源码引用重启、状态隔离、候选解释器、CLI 管理模式和安全批次清理的失败测试。
42
+ - [x] 实现 link、受控清理预览/执行、CLI 管理模式、stateDir、平台告警与环境解析。
43
+ - [x] 验证依赖加载、失败状态保持和分发快照。
44
+
45
+ ## 任务 5:文档与整体验收
46
+
47
+ 文件:plugin-development.md、roadmap.md、verification.md、docs/examples 新真实脚本插件及离线文档收集。
48
+ - [x] 补齐公开契约、迁移说明、开发循环和脚本示例。
49
+ - [x] 完整 npm test、npm run build、npm run dist:npm,审阅所有差异。
50
+ - [x] 在功能分支提交并报告结果、后置能力与实际验证范围。
@@ -0,0 +1,20 @@
1
+ # 0.4.0 发布前双轴审查
2
+
3
+ 用户于 2026-10-12 要求使用 `code-review`,验证处理后推送并发包。固定基线为 `v0.3.0`(提交 `681ebd1`),被审查提交为 `addaece`,命令为 `git diff v0.3.0...addaece`。需求源是本轮插件开发体验规范、GitHub #1/#2 和用户附件;后续修复另做定向复核。
4
+
5
+ ## Standards
6
+
7
+ 没有发现明确的文档规范违反;中文说明、分支管理、安装状态备份和清理前备份均符合要求。未发现需要为本次发布安排重构的代码异味。
8
+
9
+ 发现 1 项 P2 正确性问题:Windows 的 `which` 对裸候选自动优先搜索宿主启动目录,可能误选同名解释器。已改为按工作区和 PATH 构造绝对路径后逐项查找;回归测试用子进程隔离启动目录与工作区,覆盖同名冲突、相对 PATH、显式路径及缺失不回退。新增两项测试先失败,修复后环境、宿主和进程定向 18/18 通过。
10
+
11
+ ## Spec
12
+
13
+ 发现 2 项 P2:
14
+
15
+ 1. 规范要求带类型的动态键,但 `type: ['object','null']` 的合法动态字段被路径解析器拒绝。现在只要联合类型允许 object,就继续按动态字段 schema 检查;纯标量及关闭对象中的未知字段仍拒绝。新增回归先失败,修复后覆盖 Ajv 输出、字段解析、条件预设和工作流绑定,相关 27/27 通过。
16
+ 2. Windows 解释器候选未按工作区查找,与开发手册冲突;与 Standards 轴同一问题,按上述修复和验证处理。
17
+
18
+ 其余采纳需求有实现,未发现额外范围扩张。热重载、长度伪字段、CLIXML 转换、自动猜编码、截断及强制平台门禁继续后置。
19
+
20
+ 汇总:Standards 1 项 P2,Spec 2 项 P2,其中 1 项重复,共 2 个独立问题,均已修复。发布仍需通过最终完整测试和既有双平台发布工作流。
@@ -0,0 +1,23 @@
1
+ # 多插件执行摘要与用户输出
2
+
3
+ ## 目标
4
+
5
+ 同一工作流可以顺序调用不同插件,前一步的结构化输出绑定到后一步输入。默认主面板明确展示每一步的能力、所属插件、调用路径和执行状态,避免用户只能看到最终结果或被技术日志刷屏。
6
+
7
+ ## 展示规则
8
+
9
+ - 默认折叠:显示步骤执行中、成功、失败、取消、跳过或停止状态。普通输入输出、作者 log、受管子进程 stdout/stderr 收入详情。
10
+ - 主面板 Ctrl+O 切换本次会话所有运行的摘要/详情,不提交或修改输入框草稿。详情可以翻页;管理菜单不清除运行内容。
11
+ - 执行失败直接展示错误摘要。嵌套失败传播不重复打印父节点与根运行的同一错误;完整错误链仍在详情和持久日志中。
12
+ - 插件 `await print(message,data?)` 表示面向用户的内容,折叠时仍实时展示;`await log(...)` 表示诊断内容。两者均落盘,print 在 log 事件上标记 `audience: user`,沿用现有订阅和日志保留机制。
13
+ - 不读取或生成所谓模型内部思考。未来 LLM 插件可用相同接口提供进度说明与诊断摘要。
14
+
15
+ ## 插件契约
16
+
17
+ 动作和交互命令获得相同的 print 能力。Python 等子进程默认写诊断日志;作者希望特定输出直接可见时,在 runProcess 的 onOutput 回调中调用 print。print 不直接写终端控制台,也不允许覆盖执行器固定的步骤归属。旧插件无需改动,仍完整记录 log 和返回值。
18
+
19
+ ## 本地验收
20
+
21
+ `preview-pipeline` 调用三个不同插件:文本准备示例 → 基础示例转大写 → 报告保存示例。两个新增示例提供可安装目录和 MIT 许可证。文本与演示等待时间可修改,实际写出 report.txt,并通过 print 显示位置。既有试用工作流只在不存在时创建,不覆盖用户修改。
22
+
23
+ 验证跨插件结果传递、运行中的状态变化、Ctrl+O 与输入草稿、折叠时错误和 print 的可见性、完整日志落盘,并验证 Windows/WSL 真实终端。仅交付本地试用,不发布。
@@ -0,0 +1,22 @@
1
+ # 第三方插件开发体验与宿主契约
2
+
3
+ 依据:GitHub #1、#2,用户提供的 fst-issue-draft.md,以及 2026-10-12 评审。用户已授权实施。
4
+
5
+ ## 本轮范围
6
+
7
+ - 统一工作流绑定和条件预设的 schema 路径解析,支持带类型的动态键,拒绝关闭对象的未知字段;长度计算留待后续。
8
+ - 输入/配置继续填默认值,动作/命令输出只验证。诊断提供完整 JSON 指针、缺失字段及 schema 位置。
9
+ - runProcess 的 encoding 同时作用于实时日志和返回文本,默认 utf8,支持 utf16le 与常用 Windows 编码;维持取消、字节上限、超时。自动猜编码、CLIXML 转换和截断模式不在本轮。
10
+ - plugin validate <dir> 提供人类可读和 JSON 报告、退出码,检查包身份、SDK、schema、动作、条件、环境与平台声明;不启动动作或 runtime。导入可信插件会执行模块顶层代码。
11
+ - plugin create <name> 生成可独立安装的 ESM 包、真实可运行的动作、测试、README、MIT LICENSE;平台声明明确,不依赖未公开的 SDK。
12
+ - plugin link <dir> 使用直接源码引用,重启宿主后加载修改,支持移除及分发时复制快照。运行中热重载后置。
13
+ - 独立 CLI 的 add/remove/link/configure 使用管理模式;保留常驻宿主维护模式保护,不增加 gate:false。
14
+ - 安装批次清理显式预览并保留恢复批次,不删除当前引用及不能确认安全的目录;备份插件状态文件,清理只针对本项目 installations 内目录。
15
+ - 动作和命令获得稳定的 projectDir、按插件隔离的 stateDir,状态随可写工作区,卸载不自动删除,不自动进入分发包。
16
+ - host 环境支持显式解释器覆盖和有序候选,显式路径失败不回退;tool/bundled 的语义保持。
17
+ - 平台/架构不匹配给明确告警,旧插件缺省保持兼容;不根据 NPU 等关键词推断平台。
18
+ - 增加 Python 脚本、嵌套输入、业务失败和条件预设的完整插件示例及离线文档。
19
+
20
+ ## 验收
21
+
22
+ 先复现缺陷再修复;定向测试覆盖错误路径、候选选择、UTF16 分块、动态键类型、无 runtime 副作用的校验、源码修改后重启、状态隔离、清理恢复保护。最后执行完整 npm test、build 与分发检查。未取得真实业务插件源码,不冒充已经完成其硬件验收。本轮完成源码演进,不自动发布新 npm 版本。
@@ -1,5 +1,73 @@
1
1
  # 首版验收记录
2
2
 
3
+ ## 0.4.0 发布前验证(2026-10-12)
4
+
5
+ - 用户授权使用 `code-review` 验证处理后推送和发包。以 `v0.3.0` 为固定基线并行审查 Standards / Spec;记录见[双轴审查](superpowers/reviews/2026-10-12-plugin-release-review.md)。两个独立 P2 均已先失败复现再修复:可空对象的动态字段解析,以及 Windows 候选解释器错误优先搜索宿主启动目录。
6
+ - `npm run dist:npm` 构建成功,版本和 CLI 同步为 0.4.0;归档含 276 个包。设置 `FISHTOOLS_TEST_NPM_ARCHIVE` 指向该归档后,完整 `npm test` **230/230 通过**,零失败、零跳过,159.3 秒,包括离线全局安装、脱离源码调用和再次导出。
7
+ - 输出默认值迁移、源码引用需重启、旧版宿主清理前退出等说明已更新。远端双平台、便携包、Docker 及 npm 发布结果待正式发布工作流完成后追加。
8
+
9
+ ## 2026-10-12 第三方插件开发体验(本地未发布)
10
+
11
+ - 分支 `codex/plugin-developer-experience`,基线 `681ebd1`。依据 GitHub #1、#2 及用户提供的 `fst-issue-draft.md`,实现插件创建、离线校验、源码引用、安装批次清理,并修复 schema 路径、输出默认值、编码与宿主环境契约。
12
+ - 开发循环测试实际执行 CLI 创建、校验、生成插件测试、独立安装及工作流调用;源码修改后新进程加载新代码,源码丢失仍可移除引用。打包复制开发插件快照,排除 `.flow` 私有状态,后续源码变化不影响快照。
13
+ - 清理测试覆盖预览不修改、当前/恢复/活动批次保留、目录链接拒绝,并实际解压清理备份确认文件可恢复。多进程配置更新测试确认不会用旧快照覆盖其他插件配置或日志设置。
14
+ - Python 示例使用真实 Python 解释器执行,另以可控子进程覆盖业务未通过、异常与取消协议;声明支持候选解释器和显式覆盖,显式路径不存在时不回退。
15
+ - 审查发现并修复随包环境缺失 `artifacts` 却通过静态校验的问题,新增失败复现后通过;合法的非当前平台资源仍可通过。合法长 npm ID 的状态目录也已补失败测试,改用固定长度哈希隔离。最终独立复核未发现剩余 P1/P2 阻断项。
16
+ - 修复后完整 `npm test` **227/227 通过**,零失败、零跳过,耗时 194.2 秒;包括 npm 离线全局安装、脱离源码运行并再次导出的集成验收。
17
+ - `npm run dist:npm` 成功,包含 Web 构建与许可收集;最终本地归档大小 9,493,874 字节、276 个包。核对新增开发工具模块、直接依赖及 Python 示例均已进入归档。版本号尚未调整,`dist/yanglaofish-fishtools-0.3.0.tgz` 仅供本次开发验收,不代表重新发布或替换公开 0.3.0。
18
+ - 本轮不包含真实 vLLM/昇腾 NPU 硬件验收,也没有运行中热重载、自动猜编码、CLIXML 转换、输出截断或平台拒绝门禁。新增能力尚未发布到 npm,当前公开 0.3.0 不包含这些接口。
19
+
20
+ ## 0.3.0 发布前验证(2026-10-12)
21
+
22
+ - 以 `v0.2.0` 为基线审查本轮代码和需求;修复离线手册缺失源码链接、并行取消测试时序、主面板密集日志重复全量快照与重绘,以及展开详情缺少完整错误链。
23
+ - 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` 版本、运行面板代码、离线研究文档及其源码引用。
24
+ - npm 官方注册表在发布前只有 `latest: 0.2.0`,`0.3.0` 尚不存在。跨平台测试、自包含包与 Docker 检查由版本 tag 的发布工作流再次执行;本地通过不代替远端发布结果。
25
+
26
+ ## 合并执行回看入口(2026-10-11,本地试用,未发布)
27
+
28
+ - 用户再次试用后认为 F4 没有独立价值:主面板查看当前执行,统一通过 `/logs` 回看步骤、输入输出和日志。删除独立运行列表实现,旧 `/runs` 与 F4 仅兼容转入同一入口;界面不再推荐 F4。
29
+ - `/logs` 显示工作流标题,正在运行的任务排在前面;历史持久记录、从详情返回列表、切换其他执行和原有日志保留策略保持可用。
30
+ - 先复现旧实现三个场景失败,修订后 **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 与日志双入口设计;未发布、未推送。
31
+
32
+ ## 精简返回反馈与 F4 说明(2026-10-11,本地试用,未发布)
33
+
34
+ - 用户反馈通用“已返回主面板”消息重复:现在返回只更新顶部位置,保留命令与实际结果,不新增返回记录。下方早期验收中“保留返回提示”的描述由本节取代。
35
+ - F4 标为“运行列表”,首页和列表说明用于选择一次执行,查看步骤、输入输出与日志;`/logs` 使用同一套记录但默认打开日志页。移除试用标记和滚动状态行里的重复 F4 提示。
36
+ - 导航、窄屏、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`。两项返回场景先复现失败再通过。
37
+ - 未发布、未推送,重新启动 `fst-preview.cmd` 使用本地修订;全局 fst 不变。
38
+
39
+ ## 运行草稿与导航反馈(2026-10-11,本地试用,未发布)
40
+
41
+ - 分支 `fix/run-navigation-feedback`。目标:`/run` 不被编辑草稿阻挡;菜单返回有清楚的去向,命令记录和运行过程始终可回看。
42
+ - 复现:参数页只查看后完成、拒绝删除步骤都曾被无条件标记为未保存;运行拒绝保存后,编辑菜单仍误判为已启动。先补失败测试,再修复真实修改判断及运行返回值。
43
+ - `/run` 选择已保存的工作流,`/run ID` 执行磁盘版本并保留任何草稿。编辑菜单单独提供保存并运行、运行已保存版本、返回编辑;取消留在编辑页,未创建运行。
44
+ - 顶部采用青绿字符标识与产品信息并排,持续显示工作区和当前位置;操作菜单增加分隔和标题。命令、菜单选择、返回主面板提示按顺序保留,本次会话内可翻页;不替代持久化运行日志。
45
+ - 相关测试 **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 个真实组件导航场景。
46
+ - 首次完整 `npm test`:164/167 通过,三个旧 TUI 测试仍按“裸 /run 直接执行”编写,其中等待输入的测试被终止。已调整为指定工作流 ID,上述 82 项复验包含这三个测试且全部通过;未将首次全量结果标记为全绿。npm 归档测试仅使用离线 dry-run,没有发布。
47
+ - Windows 真实 PTY:临时工作区预置脏草稿,裸 `/run` 选择并执行已保存版本,输出 SAVED;编辑菜单运行选择返回后仍留在编辑菜单,草稿保留。WSL 真实 PTY:实时输出、执行中打开配置、返回、并发执行、翻页、插件入口和工作流编辑、退出恢复通过。
48
+ - 已检查真实组件渲染的首页、插件菜单、返回主面板预览;此轮只修改本地源码和本地试用入口,未更新全局 fst、未推送、未打 tag、未发布。
49
+
50
+ ## 对象菜单与主面板运行(2026-10-11,本地试用,未发布)
51
+
52
+ - 分支 `feat/terminal-command-hierarchy`,试用入口仍为根目录 `fst-preview.cmd` / `npm run preview`。
53
+ - 按用户指定职责分层:`/workflows` 管理与编辑,`/run` 工作流 ID/标题补全,`/plugin` 管理插件,插件名入口选择能力,`/config` 工具设置,`/tools` Web/资料。旧直达命令兼容执行,减少顶层补全数量,来源明确标注。
54
+ - 工作流删除移入 `.flow/deleted-workflows`,保留可复制恢复的 YAML;有嵌套引用或只读项目时拒绝删除。测试只使用临时项目,不删除用户已有数据。
55
+ - 主面板按事件到达顺序保留多次执行、输入输出与管理提示。配置操作不丢执行内容,滚轮/PgUp/PgDn 回看。普通文本误输入改为“输入提示”,移除重复的输入标题并修复小高度溢出。
56
+ - Windows 真 PTY 验证工作流补全、主面板持续 stdout、执行中打开/返回配置、执行完成与正常退出。WSL 100×24 真 PTY 验证持续输出、执行中配置、并发另一个工作流、回看翻页、插件二级菜单、工作流编辑菜单及退出恢复;记录 `.cache/hierarchy-wsl.ansi`。初次脚本在主输入就绪前发键,调整为等待主输入提示后复验通过。
57
+ - 独立审查复现完成任务监听器积累与并发/管理提示遮挡最新输出,均补失败测试后修复:每个应用只保留一个活跃订阅,所有主面板事件统一追加顺序,结束后解除订阅。
58
+ - 本地相关测试 **91/91 通过**,包括导航、主面板、详细日志、编辑、插件契约、控制流、删除引用保护与原有日志保留测试;独立复核中的 9 项导航专项也通过。
59
+ - 此轮未发布、未推送,等待用户本地试用确认;未重复运行完整发行构建或 npm 发布验收。
60
+
61
+ ## 日志阅读与运行记录入口(2026-10-11,本地试用,未发布)
62
+
63
+ - 分支 `fix/readable-run-logs`。使用 `npm run preview` 或根目录 `fst-preview.cmd`;隔离的 `.cache/log-preview-平台` 工作区提供成功、故意缺少解释器、持续输出三个示例,不升级全局安装或修改用户默认工作区。
64
+ - 首页 F4 打开运行记录;支持多工作流、多次执行,运行中任务优先,详情返回列表,每次运行分别保存本次启动期间的阅读位置。重启可读取持久化记录,不承诺恢复上次进程的滚动位置。
65
+ - 实时和历史共用结构化阅读面板。默认展示结果与错误摘要,按 j 查看原始 JSON 和完整错误链;Enter 查看详情而非关闭。独立终端屏幕避免旧面板与首页混排。
66
+ - 本地专项测试 **51/51 通过**:`log-readability`、`terminal-runs`、`terminal`、`terminal-theme`、`log-viewer`、`run-logs`。覆盖 F4 草稿保留、多运行选择、历史读取、阅读位置、流输出合并、错误摘要、终端清理与已有编辑/日志保留行为。此轮未重新执行完整发行包构建与发布验收。
67
+ - Windows 真实 PTY 已验证首页 F4、历史失败记录、Enter 查看输入输出、q 返回运行列表、Esc 返回首页及正常退出。
68
+ - WSL 真实 100×24 PTY 已验证 F4、历史失败摘要、Enter 详情、实时 stdout、鼠标滚轮暂停跟随、后台继续执行、返回同次阅读位置、切换成功记录、退出取消确认及独立屏幕恢复。证据 `.cache/log-preview-wsl.ansi`;脚本 `.cache/log-preview-pty.py`。首次验证因跨文件系统启动耗时与过早发送按键未完成,调整等待到首页输入就绪后复验通过。
69
+ - 另外检查了真实组件输出生成的 100×24 彩色预览。自动化及终端验证不代替用户体验确认;此轮未创建 tag、npm 版本或 Release,等待用户本地试用反馈。
70
+
3
71
  ## fst 交互终端(2026-10-10,当前入口)
4
72
 
5
73
  本节取代下方早期 `fish` 默认启动 Web 的记录。产品仍为 Fishtools,命令改为 `fst`:交互终端默认进入 TUI,`fst web` 显式启动 Web,原子子命令继续支持自动化。
@@ -167,6 +235,32 @@ MiB = 1,048,576 字节。以下为本机单次测量,不是性能保证;运
167
235
  代码与测试提交 e55075e 的 [Windows / Ubuntu CI](https://github.com/yanglaofish/Fishtools/actions/runs/38053011826) 均通过;Windows 5 分 13 秒、Ubuntu 1 分 27 秒。验收文档归档提交不改变已验证代码。
168
236
  # 2026-10-11 TUI 上手体验修正
169
237
 
238
+ ## 2026-10-11 本地后续验收:跨插件执行摘要与用户输出(未发布)
239
+
240
+ - 分支 `feat/collapsed-plugin-execution`,基线 `21eea07`。主面板默认展示每一步的插件、动作、状态和耗时;Ctrl+O 展开输入输出与诊断日志。错误及插件 `await print(...)` 输出默认可见,完整事件仍落盘。
241
+ - 新增 `preview-pipeline`:文本准备、转大写、保存报告分别由三个不同插件执行,验证结果逐步传递,输入文本与演示等待时间可调整。通过 `fst-preview.cmd` 启动后执行 `/run preview-pipeline`,仅使用隔离试用工作区。
242
+ - 本地终端、流程、插件契约、日志、进程、诊断和文档相关测试 **119/119** 通过;这不是新一轮完整发行包验收。Windows 与 WSL 真实 PTY 验证参数表、运行中状态、三插件成功、用户输出、Ctrl+O 展开/折叠与正常退出;WSL 另外验证返回配置菜单后继续运行,以及折叠状态直接显示缺失程序错误。
243
+ - 真实 TerminalView 渲染截图检查摘要状态、用户输出和快捷键提示。独立审查未发现 P1/P2 阻断问题,补跑终端相关 **13/13** 通过,并实测嵌套并行失败、交错运行、兄弟取消与子工作流输出绑定失败的显示归属和错误去重。
244
+ - 本轮未发布 npm、tag 或 Release,未升级全局 fst;新增 `print` 契约已在插件手册注明尚未发布。
245
+
246
+ ## 本地后续:核心公开接入审查与命令历史(未发布)
247
+
248
+ - 以 `bf4b983` 为审查快照,扩大检查插件加载、安装、生命周期、编排、日志及打包公开契约。核心相关测试 55/55 通过;从仓库外临时目录实际执行 CLI 导出手册、安装三个独立插件、重新加载、跨插件传值、print/log 展示与插件命令,链路通过。
249
+ - 审查时确认三项 P2:`runProcess` 相对程序路径不遵循 `cwd`;Web 打包选项限定官方插件 ID;共享 Ajv 重复注册配置 schema 的 `$id`,导致外部插件升级或后续安装失败。后续修复与验收见下节。
250
+ - 分支 `fix/terminal-command-history` 修复主输入缺少命令历史:会话内最多 100 条,连续重复合并,↑/↓ 浏览,回到底部恢复草稿。补全选择优先,多行保留光标移动,参数表单及取消输入不记录,退出后不持久化。
251
+ - 三项新增回归测试先失败后通过,终端相关 61/61 通过;Windows 真实 PTY 验证 `/config` 返回后 ↑ 取回、Enter 再执行。未发布或更新全局安装。
252
+
253
+ ## 公共插件契约三项缺陷修复(本地未发布)
254
+
255
+ - 分支 `fix/public-plugin-contracts`,基线 `eecba28`。相对程序路径及相对 PATH 以子进程 cwd 查找;不改变进程全局目录。Windows 和 WSL 均验证了含中文、空格目录中的程序调用。
256
+ - Web 打包和 TUI 环境选择按 `services.web` 的实际提供者查找,包含其传递插件依赖。关闭可选 Web 时,工作流或其他插件真正依赖的提供者仍保留。外部安装的第三方 Web 插件已导出,并使用包内 Node 与包内核心在仓库外成功调用服务。
257
+ - 每份 schema 独立编译,保留 WeakMap 缓存,隔离 `$id` 注册。真实 npm 安装回归覆盖带 `$id` 配置的插件首次安装、安装其他插件、移除其他插件、升级、拒绝旧版非法配置、重启与卸载;内部 `$ref` 保留,新旧约束不串用。
258
+ - 新增五项回归断言先失败后通过,专项 14/14;后续发现 TUI 同类官方包名残留,追加失败测试后修复。全量 `npm test` 181/181 通过(194.6 秒);TUI 修复后终端与文档 66/66 通过。最终增强打包子进程及依赖保留断言后,进程与打包 9/9 通过。
259
+ - WSL 进程与插件安装升级 12/12 通过;随后补强含空格目录用例,WSL 进程专项 7/7 通过。首次 WSL 启动命令因 PATH 含空格发生 shell 解析错误,改用脚本启动后完成验证;不将启动失败计为产品回归。
260
+ - 独立审查未发现 P1/P2 阻断;其建议的含空格 cwd 和普通插件依赖 Web 两项边界已补验。开发手册同步契约说明。本轮未改变 history/logs 语义,未发布、推送或升级全局安装。
261
+
262
+ ## 首次上手体验验收详情(历史)
263
+
170
264
  - 修正首页缺少产品介绍、示例入口以及插件清单不显眼的问题:显示 FISHtools 字符标识、欢迎语、当前工作区、数量和真实工作流示例。
171
265
  - `/plugins` 直接列出已有插件,详情展示声明的动作、输入输出、环境、配置及命令;维护模式仍保留说明,仅隐藏不可执行的命令入口。
172
266
  - 完整页面按终端高度分配空间;80×24、52×24、40×20 自动化渲染检查覆盖首页和可滚动插件列表。
@@ -185,3 +279,9 @@ MiB = 1,048,576 字节。以下为本机单次测量,不是性能保证;运
185
279
  - npm 与便携包包含 `manual/`;`fst docs`、`fst docs plugins`、`fst docs --export <新目录>` 与 TUI `/docs` 均可离线访问。文档测试 **4/4**,验证相对 Markdown 链接、拒绝覆盖、脱离源码定位和再次导出。SDK 尚未独立发布;示例使用标准对象,不依赖内部 SDK 的在线安装。
186
280
  - 验收脚本复用旧临时工作区曾被项目归属校验拒绝;脚本现为每次解压生成独立工作区,重新验收通过。没有放宽归属或完整性检查。
187
281
  - 本记录为本地验收结果;远端 CI 与 npm/tag/Release 结果在发布完成后追加。
282
+
283
+ - 提交 `4956cc0` 的 [Windows / Ubuntu 全量 CI](https://github.com/yanglaofish/Fishtools/actions/runs/38075609079) 均通过,各 **143/143**;Windows 6 分 17 秒、Ubuntu 2 分 8 秒。
284
+
285
+ - `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`;不是鉴权失败,当时不能将此次上传宣称为已可在线安装。随后注册表可见,核对同一归档后重跑原任务的失败步骤完成同步。
286
+
287
+ - 最终 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,235 @@
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
+ import {openPluginState,withPluginMutation,assertPluginState,savePluginState,leaseInstallation} from './plugin-installations.js';
18
+
19
+ export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false,omitInstalledPlugin}={}) {
20
+ projectDir=await prepareBundleWorkspace(path.resolve(projectDir),workspace);
21
+ const project=await readJson(path.join(projectDir,'flow.project.json'),{plugins:[]});
22
+ const output=path.resolve(outputDir??process.env.FLOW_OUTPUT_DIR??(project.readOnly?path.join(process.cwd(),'flow-results'):path.join(projectDir,'.flow','runs')));
23
+ const store=new WorkflowStore(path.join(projectDir,'workflows'));
24
+ const emitter=new EventEmitter(), runs=new Map();
25
+ const resources=createResources(),operations=new Set();let closing,launching=0;
26
+ const runLogs=createRunLogs(output,{projectDir,allowLegacy:output===path.join(projectDir,'.flow','runs')}),writingRuns=new Set();
27
+ validateKeepRuns(project.logs?.keepRuns??null);
28
+ const opened=await openPluginState(projectDir,{readOnly:project.readOnly});
29
+ let host,state=opened.state,releaseInstallation=opened.release,busy=false;
30
+ const app={
31
+ projectDir,project,outputDir:output,store,
32
+ get host(){return host;},
33
+ get maintenance(){return maintenance;},
34
+ catalog:()=>host.catalog(),
35
+ 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]||state.links?.[p.id]),development:!!state.links?.[p.id],warnings:host.warnings.filter(item=>item.plugin===p.id).map(item=>item.message)})),
36
+ listCommands:()=>host.commands(),
37
+ listWorkflows:()=>store.list(),
38
+ async saveWorkflow(w){if(project.readOnly)throw new FlowError('此分发包仅用于运行;请在开发项目中编辑');return store.save(w);},
39
+ async removeWorkflow(id){if(project.readOnly)throw new FlowError('只读项目不能删除工作流');return store.remove(id);},
40
+ checkWorkflow:id=>store.resolve([id],host),
41
+ subscribe(fn){emitter.on('event',fn);return ()=>emitter.off('event',fn);},
42
+ async startRun(id,input={}) {
43
+ if(busy) throw new FlowError('插件更新中,请稍后运行');
44
+ const closure=await store.resolve([id],host);
45
+ const snapshotStore={resolve:async()=>structuredClone(closure),childId:ref=>store.childId(ref)};
46
+ return app.executeTracked(id,(activeHost,context,event)=>runWorkflow({store:snapshotStore,host:activeHost,id,input,signal:context.signal,onEvent:event,runId:context.runId,context}));
47
+ },
48
+ async startCommand(name,input={},context={}){
49
+ if(busy)throw new FlowError('插件更新中,请稍后调用');
50
+ return app.executeTracked(`/${name}`,(activeHost,runContext)=>activeHost.invokeCommand(name,input,{...context,...runContext}));
51
+ },
52
+ async executeTracked(id,execute){
53
+ const runId=randomUUID(),dir=path.join(output,runId),controller=new AbortController();
54
+ await mkdir(dir,{recursive:true});
55
+ const run={id:runId,workflowId:id,logOwner:runLogs.owner,status:'running',startedAt:new Date().toISOString(),events:[],controller};
56
+ runs.set(runId,run);
57
+ writingRuns.add(runId);
58
+ const markdownFile=path.join(dir,'run.md');
59
+ let writes=writeFile(markdownFile,markdownHeader(run));
60
+ const event=e=>{
61
+ const full={time:new Date().toISOString(),runId,...e};run.events.push(full);
62
+ emitter.emit('event',full);
63
+ const {output:ignored,...record}=full;
64
+ writes=writes.then(async()=>{
65
+ await appendFile(path.join(dir,'events.jsonl'),JSON.stringify(record)+'\n');
66
+ await appendFile(markdownFile,markdownEvent(full));
67
+ });
68
+ return writes;
69
+ };
70
+ const activeHost=host;
71
+ run.done=(async()=>{
72
+ try {
73
+ await event({type:'run.started'});
74
+ run.output=await execute(activeHost,{outputDir:dir,signal:controller.signal,runId,log:(message,data,meta={})=>event({...meta,type:'log',message,data})},event);
75
+ run.status='succeeded';await event({type:'run.succeeded'});
76
+ } catch(error) {
77
+ if(error?.code==='WORKFLOW_STOPPED'){
78
+ run.status='stopped';run.reason=error.details?.reason??error.message;
79
+ await event({type:'run.stopped',reason:run.reason});
80
+ }else{
81
+ run.status=error?.code==='EXECUTION_FAILED'?'failed':controller.signal.aborted||['PROMPT_CANCELLED','CANCELLED'].includes(error?.code)?'cancelled':'failed';
82
+ run.error=errorRecord(error);
83
+ 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});
84
+ }
85
+ } finally {
86
+ run.finishedAt=new Date().toISOString();
87
+ await writes;
88
+ await appendFile(markdownFile,markdownResult(app.getRun(runId)));
89
+ await writeFile(path.join(dir,'result.json'),JSON.stringify(snapshotRun(run),null,2));
90
+ writingRuns.delete(runId);
91
+ try{await pruneLogs();}catch(error){
92
+ await event({type:'log',message:`日志自动清理失败:${error.message}`});
93
+ await writeFile(path.join(dir,'result.json'),JSON.stringify(snapshotRun(run),null,2));
94
+ }
95
+ }
96
+ return snapshotRun(run);
97
+ })();
98
+ return run;
99
+ },
100
+ getRun(id){const r=runs.get(id);return r?snapshotRun(r):null;},
101
+ 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'));},
102
+ listRuns:()=>[...runs.keys()].map(id=>{const {events,...r}=app.getRun(id);return r;}),
103
+ logSettings:()=>({keepRuns:project.logs?.keepRuns??null}),
104
+ async listRunHistory(){
105
+ const records=new Map((await runLogs.list()).map(r=>[r.id,r]));
106
+ for(const id of runs.keys())records.set(id,app.getRun(id));
107
+ 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);
108
+ },
109
+ async readLog(id){
110
+ const run=runs.get(id);
111
+ if(run&&writingRuns.has(id))return markdownHeader(run)+run.events.map(markdownEvent).join('');
112
+ return runLogs.markdown(id);
113
+ },
114
+ async configureLogs(keepRuns){
115
+ validateKeepRuns(keepRuns);if(project.readOnly)throw new FlowError('只读运行包不能保存日志设置');
116
+ if(busy)throw new FlowError('项目正在更新,请稍后修改日志设置');
117
+ busy=true;
118
+ try{
119
+ await withPluginMutation(projectDir,async()=>{
120
+ const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
121
+ if(latest.readOnly)throw new FlowError('只读运行包不能保存日志设置');
122
+ const nextProject={...latest,logs:{...latest.logs,keepRuns}},temp=`${file}.${randomUUID()}.tmp`;
123
+ await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
124
+ for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
125
+ await pruneLogs();
126
+ });
127
+ }finally{busy=false;}
128
+ },
129
+ cancelRun(id){const r=runs.get(id);if(r?.status==='running')r.controller.abort();return !!r;},
130
+ async installPlugin(source){return change({add:source});},
131
+ async linkPlugin(source){return change({link:path.resolve(projectDir,source)});},
132
+ async removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name]&&!state.links?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
133
+ async pack(options){const {packApplication}=await import('@mytools/pack');return packApplication(app,options);},
134
+ async packPlan(options){const {planApplication}=await import('@mytools/pack');return planApplication(app,options);},
135
+ async configurePlugin(id,value){
136
+ if(project.readOnly)throw new FlowError('只读项目不能保存配置');
137
+ assertMutable();
138
+ if(resources.active(id))throw new FlowError('请先停止此插件提供的 Web,再修改配置');
139
+ if(busy||launching||app.listRuns().some(r=>r.status==='running'))throw new FlowError('请等待运行或安装结束');
140
+ if(!host.definitions.has(id))throw new FlowError(`插件不存在:${id}`);
141
+ value=validate(host.definitions.get(id).config,value,`${id} 配置`);
142
+ busy=true;let nextHost;
143
+ try{
144
+ await withPluginMutation(projectDir,async()=>{
145
+ await assertPluginState(projectDir,state);
146
+ const file=path.join(projectDir,'flow.project.json'),latest=await readJson(file,project);
147
+ if(latest.readOnly)throw new FlowError('只读项目不能保存配置');
148
+ const nextConfig={...latest.config,[id]:value};
149
+ nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:latest.environments,runtimeEnabled:!maintenance});
150
+ const nextProject={...latest,config:nextConfig},temp=`${file}.${randomUUID()}.tmp`;
151
+ await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
152
+ const old=host;host=nextHost;nextHost=null;
153
+ for(const key of Object.keys(project))delete project[key];Object.assign(project,nextProject);
154
+ await old.close();
155
+ });
156
+ }finally{try{if(nextHost)await nextHost.close();}finally{busy=false;}}
157
+ },
158
+ async setMaintenance(enabled){
159
+ if(typeof enabled!=='boolean')throw new FlowError('维护模式必须为布尔值');
160
+ if(enabled===maintenance)return;
161
+ if(busy||launching||app.listRuns().some(r=>r.status==='running'))throw new FlowError('请等待运行或安装结束');
162
+ if([...host.definitions.values()].some(p=>p.runtime&&resources.active(p.id)))throw new FlowError('请先停止运行时插件提供的 Web');
163
+ busy=true;
164
+ try{
165
+ const entries=[...host.definitions.values()].map(p=>({plugin:p,root:p.root}));
166
+ if(enabled){maintenance=true;await host.close();}
167
+ const next=await createHost({plugins:entries,config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!enabled});
168
+ const old=host;host=next;maintenance=enabled;if(!enabled)await old.close();
169
+ }finally{busy=false;}
170
+ },
171
+ startWeb(options){
172
+ if(busy)throw new FlowError('插件更新中,请稍后启动 Web');
173
+ const owner=[...host.definitions.values()].find(p=>p.services?.web)?.id;
174
+ return resources.own(()=>host.service('web',options),owner);
175
+ },
176
+ close(){
177
+ closing??=Promise.resolve().then(async()=>{
178
+ for(const r of runs.values())if(r.status==='running')r.controller.abort();
179
+ await Promise.allSettled([...operations]);
180
+ for(const r of runs.values())if(r.status==='running')r.controller.abort();
181
+ await Promise.allSettled([...runs.values()].map(r=>r.done));
182
+ const errors=[];
183
+ try{await resources.close();}catch(error){errors.push(error);}
184
+ try{await host.close();}catch(error){errors.push(error);}
185
+ try{await releaseInstallation();}catch(error){errors.push(error);}
186
+ if(errors.length)throw new AggregateError(errors,`应用关闭失败:${errors.map(e=>e.message).join(';')}`);
187
+ });return closing;
188
+ },
189
+ };
190
+ let initial;
191
+ const services={application:app,runProcess};
192
+ const build=extra=>createHost({plugins:[...initial,...extra],config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
193
+ try{
194
+ initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
195
+ const external=[];
196
+ for(const name of Object.keys(state.dependencies??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(name,state.root));
197
+ for(const [name,source] of Object.entries(state.links??{}))if(!maintenance||name!==omitInstalledPlugin)external.push(await loadPlugin(source,projectDir));
198
+ host=await build(external);
199
+ }catch(error){await releaseInstallation();throw error;}
200
+ for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','linkPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
201
+ const original=app[name],isRun=['startRun','startCommand'].includes(name);
202
+ app[name]=async(...args)=>{
203
+ if(closing)throw new FlowError('应用已关闭');
204
+ if(isRun)launching++;
205
+ const task=Promise.resolve().then(()=>original.apply(app,args));operations.add(task);
206
+ try{return await task;}finally{operations.delete(task);if(isRun)launching--;}
207
+ };
208
+ }
209
+ function assertMutable(){if(!maintenance&&host.hasRuntime)throw new FlowError('运行时插件已启动,请先进入维护模式再修改插件或配置');}
210
+ function snapshotRun(run){const {done,controller,...result}=run;return structuredClone(result);}
211
+ async function pruneLogs(){for(const id of await runLogs.prune(project.logs?.keepRuns??null,writingRuns))runs.delete(id);}
212
+ async function change(operation){
213
+ if(project.readOnly)throw new FlowError('运行包不支持安装或移除插件');
214
+ assertMutable();
215
+ if(busy || launching || [...runs.values()].some(r=>r.status==='running'))throw new FlowError('请等待当前运行或安装结束');
216
+ busy=true;let nextHost,nextRelease;
217
+ try {
218
+ return await withPluginMutation(projectDir,async()=>{
219
+ await assertPluginState(projectDir,state);
220
+ const next=await installGeneration(projectDir,{...operation,state});
221
+ for(const p of host.definitions.values())if(resources.active(p.id)){
222
+ const candidate=[...initial,...next.loaded].find(e=>(e.plugin??e).id===p.id);
223
+ if(!candidate||candidate.root!==p.root||(candidate.plugin??candidate).version!==p.version)throw new FlowError('请先停止活动 Web,再替换其提供者');
224
+ }
225
+ nextHost=await build(next.loaded);
226
+ nextRelease=await leaseInstallation(projectDir,next.state.root);
227
+ await savePluginState(projectDir,next.state,state);
228
+ const old=host,oldRelease=releaseInstallation;host=nextHost;nextHost=null;state=next.state;releaseInstallation=nextRelease;nextRelease=null;
229
+ try{await old.close();}finally{await oldRelease();}
230
+ return app.listPlugins();
231
+ });
232
+ } finally {try{if(nextHost)await nextHost.close();}finally{try{if(nextRelease)await nextRelease();}finally{busy=false;}}}
233
+ }
234
+ return app;
235
+ }
@@ -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
+ }