From d236880bc34227889b921c13e05ad64aa72ade19 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 13 Jun 2026 20:07:18 +0800 Subject: [PATCH] feat(workflow): add workflow engine, /workflows panel, /ultracode skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 feat/sdk-backend 分支中 workflow 相关的 20 个 commit 压缩为单 commit: - 工作流引擎核心:phase / agent / parallel / pipeline 编排原语(packages/workflow-engine/) - /workflows 面板:三区焦点布局(顶部 run tabs + 左侧 phase 侧栏 + 右侧 agent 列表) - /ultracode skill:多 agent workflow 编排入口 - 进度存储 / journal / notification 系统 - WorkflowService 生命周期管理 + SentryErrorBoundary - 脚本沙箱:禁用 dynamic import()、JSON args 防御性归一化 - journal 与 named-workflow 路径统一在 projectRoot - 错误处理:parallel/pipeline hooks 错误日志、failure routing、semaphore abort - workflow 工具升级为 core 工具 + PascalCase 命名 Co-Authored-By: glm-5.1 --- biome.json | 7 +- bun.lock | 13 + docs/features/workflow-scripts.md | 241 +- .../plans/2026-06-12-workflow-engine.md | 3388 +++++++++++++++++ .../2026-06-13-workflow-panel-redesign.md | 1170 ++++++ .../2026-06-13-workflow-tui-ultracode.md | 2022 ++++++++++ ...-workflow-engine-commit-0768d4dc-review.md | 159 + .../2026-06-12-workflow-engine-design.md | 231 ++ .../2026-06-13-workflow-panel-redesign.md | 200 + ...026-06-13-workflow-tui-ultracode-design.md | 287 ++ packages/builtin-tools/src/index.ts | 9 +- .../src/tools/WorkflowTool/WorkflowTool.ts | 432 --- .../__tests__/WorkflowTool.test.ts | 104 - .../src/tools/WorkflowTool/constants.ts | 3 - .../WorkflowTool/createWorkflowCommand.ts | 46 - .../workflow-engine/examples/registry-demo.ts | 124 + .../examples/research-report/README.md | 74 + .../research-report.workflow.mjs | 124 + .../examples/research-report/run.ts | 313 ++ packages/workflow-engine/examples/smoke.ts | 251 ++ packages/workflow-engine/package.json | 19 + .../src/__tests__/WorkflowTool.test.ts | 490 +++ .../src/__tests__/agentAdapter.test.ts | 155 + .../src/__tests__/agentId.test.ts | 94 + .../src/__tests__/budget.test.ts | 29 + .../src/__tests__/concurrency.test.ts | 100 + .../src/__tests__/context.test.ts | 76 + .../src/__tests__/errors.test.ts | 39 + .../src/__tests__/events.test.ts | 51 + .../src/__tests__/hooks.test.ts | 426 +++ .../src/__tests__/index.test.ts | 88 + .../src/__tests__/integration.test.ts | 282 ++ .../src/__tests__/journal.test.ts | 113 + .../src/__tests__/namedWorkflows.test.ts | 68 + .../src/__tests__/paths.test.ts | 56 + .../src/__tests__/ports.test.ts | 61 + .../src/__tests__/runWorkflow.test.ts | 423 ++ .../src/__tests__/schema.test.ts | 44 + .../src/__tests__/script.test.ts | 168 + .../src/__tests__/structuredOutput.test.ts | 40 + .../src/__tests__/types.test.ts | 30 + packages/workflow-engine/src/agentAdapter.ts | 138 + packages/workflow-engine/src/constants.ts | 26 + packages/workflow-engine/src/engine/budget.ts | 36 + .../workflow-engine/src/engine/concurrency.ts | 77 + .../workflow-engine/src/engine/context.ts | 70 + packages/workflow-engine/src/engine/errors.ts | 15 + packages/workflow-engine/src/engine/hooks.ts | 209 + .../workflow-engine/src/engine/journal.ts | 50 + .../src/engine/namedWorkflows.ts | 46 + packages/workflow-engine/src/engine/paths.ts | 26 + .../workflow-engine/src/engine/runWorkflow.ts | 148 + packages/workflow-engine/src/engine/script.ts | 230 ++ .../src/engine/structuredOutput.ts | 26 + packages/workflow-engine/src/index.ts | 24 + packages/workflow-engine/src/ports.ts | 134 + .../workflow-engine/src/progress/events.ts | 20 + .../workflow-engine/src/tool/WorkflowTool.ts | 232 ++ .../workflow-engine/src/tool/constants.ts | 1 + packages/workflow-engine/src/tool/schema.ts | 37 + packages/workflow-engine/src/types.ts | 83 + packages/workflow-engine/tsconfig.json | 17 + src/commands.ts | 2 +- src/commands/workflows/index.ts | 27 +- .../permissions/PermissionRequest.tsx | 6 +- .../tasks/BackgroundTasksDialog.tsx | 77 +- src/components/tasks/WorkflowDetailDialog.tsx | 103 - src/constants/tools.ts | 7 +- src/main.tsx | 9 + .../bundled/__tests__/ultracode.test.ts | 91 + src/skills/bundled/index.ts | 2 + src/skills/bundled/ultracode.ts | 104 + .../LocalWorkflowTask/LocalWorkflowTask.ts | 4 + .../__tests__/LocalWorkflowTask.test.ts | 90 + src/tools.ts | 6 +- src/utils/permissions/classifierDecision.ts | 2 +- .../workflow}/WorkflowPermissionRequest.tsx | 4 +- .../__tests__/WorkflowsPanel.test.tsx | 100 + .../__tests__/claudeCodeBackend.test.ts | 142 + src/workflow/__tests__/notifications.test.ts | 175 + src/workflow/__tests__/ports.test.ts | 109 + src/workflow/__tests__/progressBus.test.ts | 23 + src/workflow/__tests__/progressStore.test.ts | 175 + src/workflow/__tests__/selectors.test.ts | 81 + src/workflow/__tests__/service.test.ts | 335 ++ src/workflow/__tests__/status.test.ts | 75 + .../__tests__/useWorkflowKeyboard.test.ts | 30 + src/workflow/backends/claudeCodeBackend.ts | 158 + src/workflow/hostHandle.ts | 42 + src/workflow/namedWorkflowCommands.ts | 34 + src/workflow/notifications.ts | 87 + src/workflow/panel/AgentList.tsx | 39 + src/workflow/panel/PhaseSidebar.tsx | 49 + src/workflow/panel/TabsBar.tsx | 37 + src/workflow/panel/WorkflowsPanel.tsx | 162 + src/workflow/panel/panelCall.tsx | 16 + src/workflow/panel/selectors.ts | 60 + src/workflow/panel/status.ts | 53 + src/workflow/panel/useWorkflowKeyboard.ts | 105 + src/workflow/ports.ts | 165 + src/workflow/progress/bus.ts | 20 + src/workflow/progress/store.ts | 166 + src/workflow/registry.ts | 12 + src/workflow/service.ts | 224 ++ src/workflow/wiring.ts | 50 + tsconfig.json | 8 +- 106 files changed, 16127 insertions(+), 834 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-12-workflow-engine.md create mode 100644 docs/superpowers/plans/2026-06-13-workflow-panel-redesign.md create mode 100644 docs/superpowers/plans/2026-06-13-workflow-tui-ultracode.md create mode 100644 docs/superpowers/reviews/2026-06-13-workflow-engine-commit-0768d4dc-review.md create mode 100644 docs/superpowers/specs/2026-06-12-workflow-engine-design.md create mode 100644 docs/superpowers/specs/2026-06-13-workflow-panel-redesign.md create mode 100644 docs/superpowers/specs/2026-06-13-workflow-tui-ultracode-design.md delete mode 100644 packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts delete mode 100644 packages/builtin-tools/src/tools/WorkflowTool/__tests__/WorkflowTool.test.ts delete mode 100644 packages/builtin-tools/src/tools/WorkflowTool/constants.ts delete mode 100644 packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts create mode 100644 packages/workflow-engine/examples/registry-demo.ts create mode 100644 packages/workflow-engine/examples/research-report/README.md create mode 100644 packages/workflow-engine/examples/research-report/research-report.workflow.mjs create mode 100644 packages/workflow-engine/examples/research-report/run.ts create mode 100644 packages/workflow-engine/examples/smoke.ts create mode 100644 packages/workflow-engine/package.json create mode 100644 packages/workflow-engine/src/__tests__/WorkflowTool.test.ts create mode 100644 packages/workflow-engine/src/__tests__/agentAdapter.test.ts create mode 100644 packages/workflow-engine/src/__tests__/agentId.test.ts create mode 100644 packages/workflow-engine/src/__tests__/budget.test.ts create mode 100644 packages/workflow-engine/src/__tests__/concurrency.test.ts create mode 100644 packages/workflow-engine/src/__tests__/context.test.ts create mode 100644 packages/workflow-engine/src/__tests__/errors.test.ts create mode 100644 packages/workflow-engine/src/__tests__/events.test.ts create mode 100644 packages/workflow-engine/src/__tests__/hooks.test.ts create mode 100644 packages/workflow-engine/src/__tests__/index.test.ts create mode 100644 packages/workflow-engine/src/__tests__/integration.test.ts create mode 100644 packages/workflow-engine/src/__tests__/journal.test.ts create mode 100644 packages/workflow-engine/src/__tests__/namedWorkflows.test.ts create mode 100644 packages/workflow-engine/src/__tests__/paths.test.ts create mode 100644 packages/workflow-engine/src/__tests__/ports.test.ts create mode 100644 packages/workflow-engine/src/__tests__/runWorkflow.test.ts create mode 100644 packages/workflow-engine/src/__tests__/schema.test.ts create mode 100644 packages/workflow-engine/src/__tests__/script.test.ts create mode 100644 packages/workflow-engine/src/__tests__/structuredOutput.test.ts create mode 100644 packages/workflow-engine/src/__tests__/types.test.ts create mode 100644 packages/workflow-engine/src/agentAdapter.ts create mode 100644 packages/workflow-engine/src/constants.ts create mode 100644 packages/workflow-engine/src/engine/budget.ts create mode 100644 packages/workflow-engine/src/engine/concurrency.ts create mode 100644 packages/workflow-engine/src/engine/context.ts create mode 100644 packages/workflow-engine/src/engine/errors.ts create mode 100644 packages/workflow-engine/src/engine/hooks.ts create mode 100644 packages/workflow-engine/src/engine/journal.ts create mode 100644 packages/workflow-engine/src/engine/namedWorkflows.ts create mode 100644 packages/workflow-engine/src/engine/paths.ts create mode 100644 packages/workflow-engine/src/engine/runWorkflow.ts create mode 100644 packages/workflow-engine/src/engine/script.ts create mode 100644 packages/workflow-engine/src/engine/structuredOutput.ts create mode 100644 packages/workflow-engine/src/index.ts create mode 100644 packages/workflow-engine/src/ports.ts create mode 100644 packages/workflow-engine/src/progress/events.ts create mode 100644 packages/workflow-engine/src/tool/WorkflowTool.ts create mode 100644 packages/workflow-engine/src/tool/constants.ts create mode 100644 packages/workflow-engine/src/tool/schema.ts create mode 100644 packages/workflow-engine/src/types.ts create mode 100644 packages/workflow-engine/tsconfig.json delete mode 100644 src/components/tasks/WorkflowDetailDialog.tsx create mode 100644 src/skills/bundled/__tests__/ultracode.test.ts create mode 100644 src/skills/bundled/ultracode.ts create mode 100644 src/tasks/LocalWorkflowTask/__tests__/LocalWorkflowTask.test.ts rename {packages/builtin-tools/src/tools/WorkflowTool => src/workflow}/WorkflowPermissionRequest.tsx (97%) create mode 100644 src/workflow/__tests__/WorkflowsPanel.test.tsx create mode 100644 src/workflow/__tests__/claudeCodeBackend.test.ts create mode 100644 src/workflow/__tests__/notifications.test.ts create mode 100644 src/workflow/__tests__/ports.test.ts create mode 100644 src/workflow/__tests__/progressBus.test.ts create mode 100644 src/workflow/__tests__/progressStore.test.ts create mode 100644 src/workflow/__tests__/selectors.test.ts create mode 100644 src/workflow/__tests__/service.test.ts create mode 100644 src/workflow/__tests__/status.test.ts create mode 100644 src/workflow/__tests__/useWorkflowKeyboard.test.ts create mode 100644 src/workflow/backends/claudeCodeBackend.ts create mode 100644 src/workflow/hostHandle.ts create mode 100644 src/workflow/namedWorkflowCommands.ts create mode 100644 src/workflow/notifications.ts create mode 100644 src/workflow/panel/AgentList.tsx create mode 100644 src/workflow/panel/PhaseSidebar.tsx create mode 100644 src/workflow/panel/TabsBar.tsx create mode 100644 src/workflow/panel/WorkflowsPanel.tsx create mode 100644 src/workflow/panel/panelCall.tsx create mode 100644 src/workflow/panel/selectors.ts create mode 100644 src/workflow/panel/status.ts create mode 100644 src/workflow/panel/useWorkflowKeyboard.ts create mode 100644 src/workflow/ports.ts create mode 100644 src/workflow/progress/bus.ts create mode 100644 src/workflow/progress/store.ts create mode 100644 src/workflow/registry.ts create mode 100644 src/workflow/service.ts create mode 100644 src/workflow/wiring.ts diff --git a/biome.json b/biome.json index 4e9e9443a..e5d2b5be4 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,12 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!!**/dist"] + "includes": [ + "**", + "!!**/dist", + "!!**/.claude/workflows", + "!!**/*.workflow.mjs" + ] }, "formatter": { "enabled": true, diff --git a/bun.lock b/bun.lock index ec8994605..6f87914cb 100644 --- a/bun.lock +++ b/bun.lock @@ -332,6 +332,17 @@ "qrcode": "^1.5.4", }, }, + "packages/workflow-engine": { + "name": "@claude-code-best/workflow-engine", + "version": "0.1.0", + "dependencies": { + "ajv": "^8.18.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@anthropic-ai/sdk": "^0.81.0", + }, + }, }, "overrides": { "@inquirer/prompts": "8.4.2", @@ -586,6 +597,8 @@ "@claude-code-best/weixin": ["@claude-code-best/weixin@workspace:packages/weixin"], + "@claude-code-best/workflow-engine": ["@claude-code-best/workflow-engine@workspace:packages/workflow-engine"], + "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], "@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], diff --git a/docs/features/workflow-scripts.md b/docs/features/workflow-scripts.md index 05a59e605..6f7f8852b 100644 --- a/docs/features/workflow-scripts.md +++ b/docs/features/workflow-scripts.md @@ -1,102 +1,183 @@ -# WORKFLOW_SCRIPTS — 工作流自动化 +# WORKFLOW_SCRIPTS — 确定性多 agent 工作流编排 -> Feature Flag: `FEATURE_WORKFLOW_SCRIPTS=1` -> 实现状态:全部 Stub(7 个文件),布线完整 -> 引用数:10 +> Feature Flag:`FEATURE_WORKFLOW_SCRIPTS=1` +> 引擎包:[`@claude-code-best/workflow-engine`](../../packages/workflow-engine/)(确定性 JS 脚本编排,零核心层运行时依赖) +> 集成层:[`src/workflow/`](../../src/workflow/) ## 一、功能概述 -WORKFLOW_SCRIPTS 实现基于文件的多步自动化工作流。用户可以定义 YAML/JSON 格式的工作流描述文件,系统将其解析为可执行的多 agent 步骤序列。提供 `/workflows` 命令管理和触发工作流。 +WORKFLOW_SCRIPTS 让 Claude Code 用**确定性 JavaScript 脚本**编排多个子 agent:可分解/并行、多视角置信、规模超单上下文、可 resume/可审计。 + +- **编排原语**:`agent` / `parallel` / `pipeline` / `phase` / `log` / `workflow`(见引擎包)。 +- **确定性**:脚本在受限沙箱内执行,禁用 `Date.now()` / `Math.random()` / 无参 `new Date()`,保证 journal 可重放。 +- **深度后端**:单一 `claude-code` AgentAdapter 接入当前会话体系(provider / model / agentType / 工具),workflow 内的 `agent()` 调用真实子 agent。 +- **监控面板**:`/workflows` 双栏实时面板(见 §六)。 +- **编排手册**:`/ultracode` 注入编排工作法(见 §七)。 + +> 历史说明:早期版本为 YAML/JSON DSL + 全 Stub 实现(`WorkflowDetailDialog` 等),已全量重写为引擎驱动的 JS 方案。 ## 二、实现架构 -### 2.1 模块状态 - -| 模块 | 文件 | 状态 | -|------|------|------| -| WorkflowTool | `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts` | **部分实现** — tool schema + 渲染完整,call 返回运行时缺失提示 | -| Workflow 权限 | `packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx` | **部分实现** — 权限请求组件 | -| 常量 | `packages/builtin-tools/src/tools/WorkflowTool/constants.ts` | **实现** — 工具名 + 目录名 + 文件扩展名常量 | -| 命令创建 | `packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts` | **实现** — 扫描 .claude/workflows/ 目录创建 Command 对象 | -| 捆绑工作流 | `packages/builtin-tools/src/tools/WorkflowTool/bundled/index.ts` | **实现** — 内置工作流初始化 | -| 本地工作流任务 | `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts` | **Stub** — 类型 + 空操作 | -| UI 任务组件 | `src/components/tasks/src/tasks/LocalWorkflowTask/` | **Stub** — 空导出 | -| 详情对话框 | `src/components/tasks/WorkflowDetailDialog.ts` | **Stub** — 返回 null | -| 任务注册 | `src/tasks.ts` | **布线** — 动态加载 | -| 工具注册 | `src/tools.ts` | **布线** — 动态加载 + bundled 工作流初始化 (行 131-134,235) | -| 命令注册 | `src/commands.ts` | **布线** — `/workflows` 命令 (行 93-95,395,460) | - -### 2.2 预期数据流 - ``` -用户定义工作流(YAML/JSON 文件) - │ - ▼ -/workflows 命令发现工作流文件 - │ - ▼ -createWorkflowCommand() 解析为 Command 对象 [需要实现] - │ - ▼ -WorkflowTool 执行工作流 [需要实现] - │ - ├── 步骤 1: Agent({ task: "..." }) - ├── 步骤 2: Agent({ task: "..." }) - └── 步骤 N: Agent({ task: "..." }) - │ - ▼ -LocalWorkflowTask 协调步骤执行 [需要实现] - │ - ▼ -WorkflowDetailDialog 显示进度 [需要实现] + .claude/workflows/.ts Workflow 工具(name/script/scriptPath/args/resumeFromRunId) + │ │ + ▼ ▼ + namedWorkflowCommands.ts src/workflow/wiring.ts (createWorkflowToolCore) + (/ 命令发现) │ + ▼ + WorkflowService(门面:launch/kill/subscribe/listRuns/listNamed) + │ + ┌────────────────┼─────────────────┐ + ▼ ▼ ▼ + ports.ts registry.ts progress/ + (端口聚合) (AgentAdapterRegistry) bus + store + │ │ + ▼ ▼ + hostHandle.ts backends/claudeCodeBackend.ts + (不透明 host) (深度读会话体系,跑真实 agent) + │ + ▼ + @claude-code-best/workflow-engine + (runWorkflow / hooks / journal / budget / 并发信号量) ``` -### 2.3 预期工作流 DSL +### 2.1 模块清单 -``` -# workflow.yaml(预期格式,需要设计) -name: "代码审查工作流" -steps: - - name: "静态分析" - agent: { type: "general-purpose", prompt: "运行 lint 和类型检查" } - - name: "测试" - agent: { type: "general-purpose", prompt: "运行测试套件" } - - name: "综合报告" - agent: { type: "general-purpose", prompt: "综合分析结果写报告" } +| 层 | 文件 | 职责 | +|----|------|------| +| 引擎 | `packages/workflow-engine/src/` | 确定性脚本沙箱 + hooks + journal + budget + 信号量;导出 `createWorkflowTool` | +| 工具装配 | `src/workflow/wiring.ts` | `createWorkflowToolCore()` —— 用 `WorkflowService.ports` 组装 `Workflow` 工具 | +| 服务门面 | `src/workflow/service.ts` | `WorkflowService` 单例:`launch` / `kill` / `subscribe` / `listRuns` / `listNamed` / `getWorkflowService()` | +| 端口 | `src/workflow/ports.ts` | `createWorkflowPorts()` 聚合所有端口(agentRunner/registry/progress/task/journal/permission/logger/hostFactory) | +| 后端注册 | `src/workflow/registry.ts` | `buildRegistry()` 注册 `claude-code` 后端并设为默认 | +| 深度后端 | `src/workflow/backends/claudeCodeBackend.ts` | AgentAdapter:按 `agentType`/`model` 解析会话体系,跑真实子 agent,结构化输出 | +| Host 句柄 | `src/workflow/hostHandle.ts` | `buildHostBundle()` 不透明包装 `toolUseContext`/`canUseTool`/`parentMessage` | +| 进度总线 | `src/workflow/progress/bus.ts` | 基于 Set 的进度事件发射 | +| 进度状态 | `src/workflow/progress/store.ts` | reducer:按 `agentId` 精确关联 `agent_done`(修并发竞态) | +| 监控面板 | `src/workflow/panel/*.tsx` | `/workflows` 双栏 UI(见 §六) | +| 命名命令 | `src/workflow/namedWorkflowCommands.ts` | 扫描 `.claude/workflows/` 生成 `/` 命令 | +| 权限请求 | `src/workflow/WorkflowPermissionRequest.tsx` | workflow 启动权限 UI | + +### 2.2 注册点 + +| 位置 | 内容 | +|------|------| +| `src/tools.ts:152-153,254` | `createWorkflowToolCore()` 动态加载并注册 `Workflow` 工具(feature-gated) | +| `src/commands.ts:95-97,392` | `/workflows` 命令(local-jsx,加载 `panelCall.js`) | +| `src/skills/bundled/ultracode.ts` + `index.ts` | `/ultracode` 知识 skill(`registerBundledSkill`) | + +## 三、编排原语 + +workflow 脚本内可用的钩子(语义详见引擎包 `engine/hooks.ts`): + +| 原语 | 语义 | +|------|------| +| `agent(prompt, opts?)` | 派发一个子 agent;返回最终文本,或(带 `opts.schema`)结构化对象。opts:`model` / `agentType` / `label` / `phase` / `schema` | +| `parallel([() => …])` | 并发跑 thunk 数组,**barrier**(等全部完成);单项抛错 → 该项 `null`,其余保留 | +| `pipeline(items, s1, s2, …)` | 每个 item 链式过各 stage;**item 间无 barrier**,stage 内顺序;单 item 某 stage 抛错 → 该 item `null` | +| `phase(title)` | 标记阶段(面板按此分组展示) | +| `log(msg)` | 进度日志(面板展示,无状态变更) | +| `workflow(name \| { scriptPath }, args?)` | 嵌套一层子 workflow(仅允许一层) | + +**硬限**:单次 `parallel`/`pipeline` ≤ `MAX_ITEMS_PER_CALL`(4096);单 workflow 总 agent ≤ `MAX_TOTAL_AGENTS`(1000);并发 cap = `min(16, cores - 2)`。 + +## 四、编写 workflow + +脚本置于 `.claude/workflows/.js|.mjs`(也接受 `.ts`,但**引擎不转译 TS**,含类型注解会报语法错——推荐 `.js`/`.mjs`),自动成为 `/` 命令。 + +```js +// .claude/workflows/review-changes.js +export const meta = { + name: 'review-changes', + description: '按维度审查改动并对抗式验证', + phases: [{ title: 'Review' }, { title: 'Verify' }], +} + +const DIMENSIONS = [ + { key: 'bugs', prompt: '找正确性 bug' }, + { key: 'perf', prompt: '找性能问题' }, +] + +const results = await pipeline( + DIMENSIONS, + d => agent(d.prompt, { label: `review:${d.key}`, phase: 'Review' }), + review => parallel( + (review.findings || []).map(f => () => + agent(`对抗式验证:${f.title}`, { phase: 'Verify' }) + ) + ) +) +return results.flat().filter(Boolean) ``` -## 三、需要补全的内容 +**脚本执行约束**(引擎执行模型,违反直接报错): -| 优先级 | 模块 | 工作量 | 说明 | -|--------|------|--------|------| -| 1 | `WorkflowTool.ts` call 方法 | 中 | 实际工作流执行逻辑(当前返回运行时缺失提示) | -| 2 | `LocalWorkflowTask.ts` | 大 | 步骤协调、kill/skip/retry | -| 3 | `WorkflowDetailDialog.ts` | 中 | 进度详情 UI | +脚本是 `new AsyncFunction` 的**函数体**,不是 ESM 模块: -## 四、关键设计决策 +- **禁 `import`**:`agent`/`parallel`/`pipeline`/`phase`/`log`/`workflow` 与 `args`/`budget` 是注入的形参,直接用。 +- **禁 TS 语法**:不要类型注解(`x: number`)、`interface`、`enum`、`as`、泛型。引擎不转译,即便文件是 `.ts` 也会原样报语法错。 +- **只允许一处 `export const meta = {...}`**(引擎正则提取剥离);不要 `export` 其他、不要 `export default`。 +- **顶层 `return` 返回结果**。 -1. **基于文件的 DSL**:工作流定义为文件(YAML/JSON),版本控制友好 -2. **多 Agent 步骤**:每个步骤是独立的 agent 任务,支持并行/串行 -3. **内置工作流**:`bundled/` 目录提供开箱即用的常用工作流 -4. **/workflows 命令**:统一的发现和触发入口 +**确定性约束**(违反则 resume 失效): +- 禁 `Date.now()` / `Math.random()` / 无参 `new Date()`(沙箱强制抛错)。需时间戳/随机种子经 `args` 传入。 +- `export const meta = { ... }` 必须是**纯字面量**(无变量、函数调用、模板插值)——加载期求值,否则抛 `ScriptError`。 -## 五、使用方式 +## 五、Workflow 工具 -```bash -# 启用 feature(需要补全后才能真正使用) -FEATURE_WORKFLOW_SCRIPTS=1 bun run dev -``` +模型通过 `Workflow` 工具启动 workflow(input schema 见引擎包 `tool/schema.ts`): -## 六、文件索引 +| 字段 | 说明 | +|------|------| +| `script` | 内联脚本字符串 | +| `name` | 命名 workflow 名(对应 `.claude/workflows/`) | +| `scriptPath` | 脚本文件路径 | +| `args` | 透传给脚本的 `args`(任意 JSON 值) | +| `resumeFromRunId` | 从既有 runId 重放(已完成 `agent()` 秒回,发散点后现场重跑) | + +## 六、监控面板:`/workflows` + +`/workflows` 打开三区焦点面板(local-jsx,全屏): + +- **顶部 tabs**:每个 run 一个 tab(状态圆点 + workflow 名 + `#runId短码`);同名脚本多次跑会多个 tab。 +- **左 phase 侧栏**:`All` + 合并 meta 声明的 phase(未启动 `○` pending 灰)与实际 phase(`●` running / `✓` done);选中即决定右栏筛选。 +- **右 agent 列表**:按选中 phase 过滤;状态色 + 行尾文字(`running` / `object` / `text` / `dead`)。 + +**键位**:`Tab`/`Shift+Tab` 切 run · `←`/`→` 切左右焦点列(phases ↔ agents)· `↑`/`↓` 列内移动 · `r` resume · `x` kill · `n` 新建提示 · `q`/`Esc` 退出。 + +**视觉**:无内框,左右一条竖线分隔;聚焦列标题橙粗;选中/光标行铺橙底(`backgroundColor`),文字色不变。 + +进度按引擎 `agentId` 精确关联 `agent_done`(解决并发 LIFO 竞态)。pending phase 来自 `run_started` 事件携带的 `meta.phases`,store 落地 `declaredPhases`,面板 `mergePhases` 合并。`useSyncExternalStore` 订阅 `WorkflowService`,稳定快照,无变更不重渲染。 + +## 七、`/ultracode` skill + +`/ultracode`(`src/skills/bundled/ultracode.ts`)注入多 agent workflow 编排工作法:何时用 / 何时不用、编排原语速查、质量模式库(adversarial-verify / judge-panel / loop-until-dry / multi-modal-sweep / completeness-critic)、确定性约束、后端路由、resume/budget、文件与命令。 + +**纯知识 prompt skill**:零运行时副作用,不改主循环、不切换行为开关。调用即把手册注入上下文。 + +## 八、resume / journal / budget + +- **journal**:每次 run 记录到 `.claude/workflow-runs//journal.jsonl`。`resumeFromRunId` 重放 journal,已完成 `agent()` 秒回缓存结果。 +- **budget**:`budget.total` 为 token 硬顶(默认 `null` = 无限);`budget.spent()` / `budget.remaining()` 读实时消耗;耗尽后再发 `agent()` 抛错。 +- **并发**:引擎 `Semaphore`(`min(16, cores - 2)`)限制同时运行的 agent 数。 +- **错误**:脚本语法/meta 错 → `parseScript` 即时返错(不进后台);agent 抛错 → `kind:'dead'` → `null`,workflow 继续(`parallel`/`pipeline` 容错);`WorkflowAbortedError` → `killed`。 + +## 九、文件索引 | 文件 | 职责 | |------|------| -| `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts` | 工具定义(部分实现) | -| `packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx` | 权限请求组件 | -| `packages/builtin-tools/src/tools/WorkflowTool/constants.ts` | 常量定义 | -| `packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts` | 命令创建(已实现) | -| `packages/builtin-tools/src/tools/WorkflowTool/bundled/index.ts` | 内置工作流初始化 | -| `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts` | 任务协调(stub) | -| `src/components/tasks/WorkflowDetailDialog.ts` | 详情对话框(stub) | -| `src/tools.ts:131-134,235` | 工具注册 | -| `src/commands.ts:93-95,395,460` | 命令注册 | +| `src/workflow/wiring.ts` | `Workflow` 工具装配(`createWorkflowToolCore`) | +| `src/workflow/service.ts` | `WorkflowService` 门面 | +| `src/workflow/ports.ts` | 端口聚合(`createWorkflowPorts`) | +| `src/workflow/registry.ts` | `AgentAdapterRegistry` + 默认后端 | +| `src/workflow/backends/claudeCodeBackend.ts` | 深度后端 AgentAdapter | +| `src/workflow/hostHandle.ts` | 不透明 host 句柄(`buildHostBundle`) | +| `src/workflow/progress/bus.ts` | 进度事件总线 | +| `src/workflow/progress/store.ts` | 进度 reducer(`agentId` 关联) | +| `src/workflow/panel/*.tsx` | `/workflows` 双栏面板 | +| `src/workflow/namedWorkflowCommands.ts` | `/` 命令发现 | +| `src/workflow/WorkflowPermissionRequest.tsx` | 启动权限 UI | +| `src/skills/bundled/ultracode.ts` | `/ultracode` 知识 skill | +| `src/tools.ts:152-153,254` | 工具注册 | +| `src/commands.ts:95-97,392` | `/workflows` 命令注册 | +| `packages/workflow-engine/` | 引擎包(hooks / journal / budget / 并发) | diff --git a/docs/superpowers/plans/2026-06-12-workflow-engine.md b/docs/superpowers/plans/2026-06-12-workflow-engine.md new file mode 100644 index 000000000..75a3892eb --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-workflow-engine.md @@ -0,0 +1,3388 @@ +# Workflow Engine 重建实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把被掏空的「清单推进」版 WorkflowTool 重建为完整忠实的确定性 JS 脚本编排引擎,独立成包 `@claude-code-best/workflow-engine`,通过端口适配与核心层解耦。 + +**Architecture:** 依赖倒置——新包零 `src/*` 运行时导入,声明端口接口(`AgentRunner`/`ProgressEmitter`/`TaskRegistrar`/`JournalStore`/`PermissionGate`/`Logger`/`HostFactory`)+ 不透明 `HostHandle`;核心侧 `src/workflow/adapter.ts` 实现端口(委托 `runAgent`/`assembleToolPool`/`LocalWorkflowTask`),`wiring.ts` 把包的工具描述符适配为 `buildTool` 注册到 `tools.ts`。引擎用 async 函数包装执行脚本,信号量限并发,journal 顺序重放实现 resume。 + +**Tech Stack:** TypeScript(strict)、Bun(运行时/测试 `bun:test`)、Zod(`zod/v4`,工具 schema)、Ajv(JSON Schema 校验)、node 内置(`crypto`/`fs`/`path`/`os`)。 + +**Spec:** `docs/superpowers/specs/2026-06-12-workflow-engine-design.md` + +--- + +## 关键外部接口(已核实,计划代码据此编写) + +- `Tool.call(args, context: ToolUseContext, canUseTool, parentMessage, onProgress?)` — `src/Tool.ts:400` +- `buildTool(def)` — 填充 `isEnabled/isConcurrencySafe/isReadOnly/checkPermissions/...` 默认值 — `src/Tool.ts:804` +- `assembleToolPool(permissionContext, mcpTools): Tools` — `src/tools.ts:375` +- `finalizeAgentTool(messages, agentId, metadata): AgentToolResult`,`AgentToolResult.content: Array<{type:'text',text}>`、`.totalTokens`、`.usage.output_tokens` — `agentToolUtils.ts:277` +- `runAgent({agentDefinition, promptMessages, toolUseContext, canUseTool, isAsync, querySource, availableTools, ...})` — async generator — `AgentTool/runAgent.ts:257` +- `BuiltInAgentDefinition = { agentType, whenToUse, tools?, source:'built-in', baseDir:'built-in', getSystemPrompt({toolUseContext}) }` — `loadAgentsDir.ts:136` +- `SyntheticOutputTool`(name=`StructuredOutput`,Ajv 校验,非交互模式启用)即 schema→结构化输出机制 — `SyntheticOutputTool/SyntheticOutputTool.ts` +- `LocalWorkflowTask` 生命周期 API — `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts`(register/complete/fail/kill/skip/retry,复用) +- 现有注册位:`tools.ts:152-159`(`WORKFLOW_SCRIPTS` flag 后 `require(...).WorkflowTool`),`constants/tools.ts:52`(`CORE_TOOLS` 含 `workflow`) + +## 文件结构(创建/修改一览) + +**新包 `packages/workflow-engine/`(零 `src/*` 导入):** + +| 文件 | 职责 | +|---|---| +| `package.json` / `tsconfig.json` | 包清单 + TS 配置 | +| `src/index.ts` | 公共导出 | +| `src/constants.ts` | 目录/上限常量 | +| `src/types.ts` | 纯类型(WorkflowInput/meta/JournalEntry/ProgressEvent/AgentRunParams/AgentRunResult) | +| `src/ports.ts` | 端口接口 + HostHandle + HostFactory + WorkflowHostContext | +| `src/engine/concurrency.ts` | Semaphore + maxConcurrency + 上限常量引用 | +| `src/engine/script.ts` | meta 字面量提取 + async 包装 + Date/Math 沙箱 shim | +| `src/engine/journal.ts` | agentCallKey(hash) + JournalStore 读写实现 | +| `src/engine/budget.ts` | Budget 累加器 | +| `src/engine/structuredOutput.ts` | validateAgainstSchema(Ajv) | +| `src/engine/namedWorkflows.ts` | name → `.claude/workflows/.ts\|js\|mjs` 解析 | +| `src/engine/context.ts` | EngineContext + SharedResources | +| `src/engine/hooks.ts` | agent/parallel/pipeline/phase/log/workflow 实现 | +| `src/engine/runWorkflow.ts` | 引擎入口:校验/执行/journal/resume | +| `src/progress/events.ts` | ProgressEvent 类型 + emit 辅助 | +| `src/tool/schema.ts` | 输入 zod schema | +| `src/tool/WorkflowTool.ts` | createWorkflowTool({ports, hostFactory}) → 自包含描述符 | +| `src/tool/constants.ts` | WORKFLOW_TOOL_NAME 等(供 core re-export) | +| `src/__tests__/*.test.ts` | 包内全量单测(mock 端口) | + +**核心侧(`src/`):** + +| 文件 | 职责 | +|---|---| +| `src/workflow/adapter.ts` | createWorkflowAdapter:实现端口(委托 runAgent 等)+ hostFactory 构造 HostHandle | +| `src/workflow/wiring.ts` | createWorkflowTool():建 adapter → 包描述符 → buildTool | +| `src/workflow/hostHandle.ts` | HostHandle bundle 类型 + 构造/解包 | +| `src/workflow/namedWorkflowCommands.ts` | 扫 `.ts/.js/.mjs` → `/` 斜杠命令(重写) | +| `src/workflow/WorkflowProgressView.tsx` | `/workflows` 实时进度查看器 | +| 修改 `src/tools.ts` | 注册位改指向 `src/workflow/wiring.js` | +| 修改 `src/commands/workflows/index.ts` | 改为进度查看器入口 | +| 修改 `src/utils/workflowRuns.ts` | 重写为 run+journal 模型 | +| 移动 `WorkflowPermissionRequest.tsx` → `src/workflow/` | 依赖 src 权限组件 | +| 删除 `builtin-tools/.../WorkflowTool/WorkflowTool.ts` 等 | 清单版逻辑移入包 | + +**自然检查点:** Phase 1–3 完成后,包独立可测(全 mock 端口,无 LLM),是一个可提交的里程碑。Phase 4–6 是核心集成。 + +--- + +## Phase 0:包脚手架 + +### Task 1:创建包脚手架 + +**Files:** +- Create: `packages/workflow-engine/package.json` +- Create: `packages/workflow-engine/tsconfig.json` +- Create: `packages/workflow-engine/src/index.ts` +- Modify: `package.json`(根 workspaces 已含 `packages/*`,无需改;确认即可) + +- [ ] **Step 1:写 `packages/workflow-engine/package.json`** + +```json +{ + "name": "@claude-code-best/workflow-engine", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./tool/constants": "./src/tool/constants.ts", + "./package.json": "./package.json" + }, + "dependencies": { + "ajv": "^8.17.1", + "zod": "workspace:*" + }, + "scripts": { + "test": "bun test" + } +} +``` + +> 注:`zod` 用 `workspace:*`(monorepo 内 zod);`ajv` 版本对齐 `SyntheticOutputTool` 已用版本。若 `bun install` 报 ajv 版本冲突,改成 `"ajv": "*"` 由 bun 解析。 + +- [ ] **Step 2:写 `packages/workflow-engine/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["bun-types"], + "jsx": "react-jsx", + "lib": ["ESNext"], + "allowJs": false, + "declaration": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} +``` + +> 此包**不**继承根 `src/*` 路径别名——这是解耦的关键。包内只允许 `node:` 内置、`zod/v4`、`ajv`、相对路径导入。 + +- [ ] **Step 3:写 `packages/workflow-engine/src/index.ts`(占位,后续任务填充导出)** + +```ts +// @claude-code-best/workflow-engine +// 确定性 JS 脚本编排引擎。零核心层运行时依赖,通过端口适配与世界对话。 +// 公共导出在后续任务中逐步填充。 +export {} +``` + +- [ ] **Step 4:安装依赖并验证包可被发现** + +Run: `bun install` +Expected: 成功,`packages/workflow-engine` 被加入 workspaces。 + +Run: `bun run --filter @claude-code-best/workflow-engine test 2>&1 | head -5` 或 `cd packages/workflow-engine && bun test 2>&1 | head -5` +Expected: 「0 tests found」无报错(尚无测试)。 + +- [ ] **Step 5:提交** + +```bash +git add packages/workflow-engine +git commit -m "feat(workflow): scaffold @claude-code-best/workflow-engine package" +``` + +--- + +## Phase 1:基础契约与纯模块 + +### Task 2:常量(`constants.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/constants.ts` + +- [ ] **Step 1:写 `constants.ts`** + +```ts +// 引擎级常量。无运行时依赖。 + +/** Workflow 工具名(与核心层 CORE_TOOLS 一致)。 */ +export const WORKFLOW_TOOL_NAME = 'workflow' + +/** 用户命名 workflow 文件目录(相对项目根)。 */ +export const WORKFLOW_DIR_NAME = '.claude/workflows' + +/** workflow run 持久化目录(journal + run 记录)。 */ +export const WORKFLOW_RUNS_DIR = '.claude/workflow-runs' + +/** 命名 workflow 支持的脚本扩展名(按优先级)。 */ +export const WORKFLOW_SCRIPT_EXTENSIONS = ['.ts', '.js', '.mjs'] as const + +/** 并发:信号量许可 = min(MAX_CONCURRENCY_CAP, cpuCores - MAX_CONCURRENCY_OFFSET)。 */ +export const MAX_CONCURRENCY_OFFSET = 2 +export const MAX_CONCURRENCY_CAP = 16 + +/** 单个 workflow 生命周期内 agent() 总数上限。 */ +export const MAX_TOTAL_AGENTS = 1000 + +/** 单次 parallel()/pipeline() 调用的 items 上限。 */ +export const MAX_ITEMS_PER_CALL = 4096 +``` + +- [ ] **Step 2:验证类型** + +Run: `cd packages/workflow-engine && bunx tsc --noEmit 2>&1 | head` +Expected: 无错误。 + +- [ ] **Step 3:提交** + +```bash +git add packages/workflow-engine/src/constants.ts +git commit -m "feat(workflow): add engine constants" +``` + +--- + +### Task 3:核心类型(`types.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/types.ts` +- Test: `packages/workflow-engine/src/__tests__/types.test.ts` + +- [ ] **Step 1:先写测试(验证 JournalEntry 与 AgentRunResult 可序列化往返)** + +```ts +import { expect, test } from 'bun:test' + +// 直接构造未导出的类型形状,验证 JSON 往返(resume 持久化的核心要求)。 +test('AgentRunResult ok 分支可 JSON 往返', () => { + const result = { kind: 'ok' as const, output: { confirmed: true }, usage: { outputTokens: 42 } } + const round = JSON.parse(JSON.stringify(result)) + expect(round).toEqual(result) + expect(round.kind).toBe('ok') +}) + +test('AgentRunResult skipped/dead 分支可 JSON 往返', () => { + for (const kind of ['skipped', 'dead'] as const) { + const round = JSON.parse(JSON.stringify({ kind })) + expect(round.kind).toBe(kind) + } +}) + +test('JournalEntry 形状稳定', () => { + const entry = { key: 'abc123', result: { kind: 'ok', output: 'text', usage: { outputTokens: 1 } } } + const round = JSON.parse(JSON.stringify(entry)) + expect(round.key).toBe('abc123') + expect(round.result.kind).toBe('ok') +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/types.test.ts` +Expected: 这几个测试只依赖字面量构造,应直接 PASS(作为形状契约锚点)。若 PASS 则继续——它们锁定了序列化形状。 + +- [ ] **Step 3:写 `types.ts`** + +```ts +// 纯类型定义。无运行时依赖。 + +/** Workflow 工具输入。 */ +export type WorkflowInput = { + /** 内联脚本源码。 */ + script?: string + /** 命名 workflow(解析到 .claude/workflows/.ts|js|mjs)。 */ + name?: string + /** 已有脚本文件绝对路径。 */ + scriptPath?: string + /** 透传给脚本的 args 全局变量(任意 JSON 值)。 */ + args?: unknown + /** resume 指定 run,重放 journal。 */ + resumeFromRunId?: string + /** 工具调用描述(3-5 词)。 */ + description?: string + /** 进度查看器标题。 */ + title?: string +} + +/** 脚本 `export const meta = {...}` 的形状(必须是纯字面量)。 */ +export type WorkflowMeta = { + name: string + description: string + whenToUse?: string + phases?: Array<{ title: string; detail?: string }> +} + +/** agent() 传给 AgentRunner 的参数。 */ +export type AgentRunParams = { + prompt: string + /** JSON Schema;提供时 agent 返回校验对象而非文本。 */ + schema?: object + model?: string + /** 自定义子 agent 类型(从 registry 解析)。 */ + agentType?: string + isolation?: 'worktree' + allowedTools?: string[] + /** 仅展示用,不计入 journal key。 */ + label?: string + /** 仅展示用,不计入 journal key。 */ + phase?: string +} + +/** AgentRunner 返回。 */ +export type AgentRunResult = + | { kind: 'ok'; output: string | object; usage: { outputTokens: number } } + | { kind: 'skipped' } + | { kind: 'dead' } + +/** journal 中单条记录(按执行顺序)。 */ +export type JournalEntry = { + key: string + result: AgentRunResult +} + +/** 进度事件。所有变体携带 runId,供 adapter 路由到对应 task(多并发 workflow)。 */ +export type ProgressEvent = + | { type: 'run_started'; runId: string; workflowName: string; meta: WorkflowMeta | null } + | { type: 'phase_started'; runId: string; phase: string } + | { type: 'phase_done'; runId: string; phase: string } + | { type: 'agent_started'; runId: string; label?: string; phase?: string } + | { type: 'agent_done'; runId: string; label?: string; phase?: string; result: AgentRunResult } + | { type: 'log'; runId: string; message: string } + | { + type: 'run_done' + runId: string + status: 'completed' | 'failed' | 'killed' + returnValue?: unknown + error?: string + } + +/** 引擎运行结果。 */ +export type WorkflowRunResult = { + status: 'completed' | 'failed' | 'killed' + returnValue?: unknown + error?: string +} +``` + +- [ ] **Step 4:更新 `src/index.ts` 导出类型** + +```ts +export * from './types.js' +export * from './constants.js' +``` + +- [ ] **Step 5:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/types.test.ts && bunx tsc --noEmit` +Expected: 测试 PASS,类型零错误。 + +- [ ] **Step 6:提交** + +```bash +git add packages/workflow-engine/src/types.ts packages/workflow-engine/src/__tests__/types.test.ts packages/workflow-engine/src/index.ts +git commit -m "feat(workflow): add core types (input/meta/journal/progress/agent)" +``` + +--- + +### Task 4:端口契约(`ports.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/ports.ts` +- Test: `packages/workflow-engine/src/__tests__/ports.test.ts` + +- [ ] **Step 1:先写测试(验证 HostHandle 不可被伪造、端口对象形状)** + +```ts +import { expect, test } from 'bun:test' +import { createHostHandle, isHostHandle, type HostHandle } from '../ports.js' + +test('createHostHandle 包装任意 bundle 且对外不透明', () => { + const bundle = { secret: 'ctx', nested: { a: 1 } } + const handle = createHostHandle(bundle) + expect(isHostHandle(handle)).toBe(true) + // 包内不暴露 bundle —— handle 只有符号标记 + expect(Object.keys(handle)).toHaveLength(0) +}) + +test('普通对象不是 HostHandle', () => { + expect(isHostHandle({} as unknown)).toBe(false) + expect(isHostHandle(null)).toBe(false) +}) + +test('端口对象满足最小形状', () => { + // 编译期形状校验:以下赋值通过即说明端口契约自洽 + const noop = () => {} + const ports = { + agentRunner: { runAgentToResult: noop }, + progressEmitter: { emit: noop }, + taskRegistrar: { + register: () => ({ runId: 'run-1', signal: new AbortController().signal }), + complete: noop, + fail: noop, + kill: noop, + pendingAction: () => null, + }, + journalStore: { read: async () => [], append: async () => {}, truncate: async () => {} }, + permissionGate: { isAborted: () => false }, + logger: { debug: noop, event: noop }, + hostFactory: () => ({ handle: createHostHandle(null), cwd: '/tmp', budgetTotal: null, toolUseId: 'tu-1' }), + } + expect(ports.taskRegistrar.register().runId).toBe('run-1') + expect(ports.hostFactory().toolUseId).toBe('tu-1') +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/ports.test.ts` +Expected: FAIL —— `../ports.js` 尚无导出。 + +- [ ] **Step 3:写 `ports.ts`** + +```ts +import type { + AgentRunParams, + AgentRunResult, + ProgressEvent, +} from './types.js' + +/** + * 不透明 host 句柄。核心侧每次工具调用构造一个,内含 toolUseContext/ + * canUseTool/parentMessage 等。包内绝不检视其内部,只透传给 AgentRunner。 + * 这是包与核心层之间唯一的耦合缝隙,且是不透明的。 + */ +const HOST_HANDLE = Symbol('workflow.hostHandle') + +export type HostBundle = unknown + +export type HostHandle = { readonly [HOST_HANDLE]: HostBundle } + +/** 核心 side hostFactory 用:把任意 bundle 包成不透明句柄。 */ +export function createHostHandle(bundle: HostBundle): HostHandle { + return { [HOST_HANDLE]: bundle } as HostHandle +} + +/** 类型守卫。 */ +export function isHostHandle(value: unknown): value is HostHandle { + return ( + typeof value === 'object' && + value !== null && + HOST_HANDLE in (value as object) + ) +} + +/** 核心 side adapter 用:解包(仅 adapter 应调用)。 */ +export function unwrapHostHandle(handle: HostHandle): HostBundle { + return (handle as { [k: symbol]: HostBundle })[HOST_HANDLE] +} + +/** agent() 钩子的后端。 */ +export type AgentRunner = { + runAgentToResult( + params: AgentRunParams, + host: HostHandle, + ): Promise +} + +/** 进度事件发射。 */ +export type ProgressEmitter = { + emit(event: ProgressEvent): void +} + +/** 后台任务生命周期。 */ +export type TaskRegistrar = { + /** + * 注册后台任务。adapter 创建 AbortController 并存入 task 状态, + * 返回 runId 与 signal(供引擎 detached 执行 + kill 中止用)。 + */ + register( + opts: { + workflowName: string + workflowFile?: string + summary?: string + toolUseId?: string + /** resume 时复用既有 runId(读其 journal)。省略则生成新 id。 */ + runId?: string + }, + host: HostHandle, + ): { runId: string; signal: AbortSignal } + complete(runId: string, summary?: string): void + fail(runId: string, error: string): void + kill(runId: string): void + /** 返回当前待处理的 skip/retry 动作,或 null。 */ + pendingAction(runId: string): { kind: 'skip' | 'retry' } | null +} + +/** journal 持久化。 */ +export type JournalStore = { + read(runId: string): Promise + append(runId: string, entry: import('./types.js').JournalEntry): Promise + truncate(runId: string): Promise +} + +/** 取消/权限门。 */ +export type PermissionGate = { + isAborted(host: HostHandle): boolean +} + +/** 日志 + 遥测。 */ +export type Logger = { + debug(msg: string): void + event(name: string, metadata?: Record): void +} + +/** 引擎从 host 提取的可直接使用上下文(句柄 + 基本字段)。 */ +export type WorkflowHostContext = { + /** 透传给 AgentRunner 的不透明句柄(内含 toolUseContext/canUseTool/parentMessage)。 */ + handle: HostHandle + cwd: string + /** token 预算上限,null 表示无限制。 */ + budgetTotal: number | null + /** 核心 side 的工具调用 ID(透传给 task 注册)。 */ + toolUseId?: string +} + +/** + * 核心 side 提供:从工具调用的核心上下文构造 WorkflowHostContext。 + * 参数对包是不透明的(unknown);核心侧 hostFactory 知道真实类型。 + */ +export type HostFactory = (args: { + context: unknown + canUseTool: unknown + parentMessage: unknown +}) => WorkflowHostContext + +/** 所有端口的聚合。createWorkflowTool(ports) 注入。 */ +export type WorkflowPorts = { + agentRunner: AgentRunner + progressEmitter: ProgressEmitter + taskRegistrar: TaskRegistrar + journalStore: JournalStore + permissionGate: PermissionGate + logger: Logger + hostFactory: HostFactory +} +``` + +- [ ] **Step 4:更新 `src/index.ts` 追加端口导出** + +在现有导出后追加: + +```ts +export * from './ports.js' +``` + +- [ ] **Step 5:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/ports.test.ts && bunx tsc --noEmit` +Expected: 三个测试 PASS,类型零错误。 + +- [ ] **Step 6:提交** + +```bash +git add packages/workflow-engine/src/ports.ts packages/workflow-engine/src/__tests__/ports.test.ts packages/workflow-engine/src/index.ts +git commit -m "feat(workflow): add ports & opaque HostHandle contracts" +``` + +--- + +### Task 5:并发信号量与上限(`engine/concurrency.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/engine/concurrency.ts` +- Test: `packages/workflow-engine/src/__tests__/concurrency.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { Semaphore, maxConcurrency } from '../engine/concurrency.js' + +test('Semaphore 限制并发,permit 转移不泄漏', async () => { + const sem = new Semaphore(2) + let active = 0 + let peak = 0 + const task = async () => { + const release = await sem.acquire() + active++ + peak = Math.max(peak, active) + await new Promise(r => setTimeout(r, 10)) + active-- + release() + } + await Promise.all(Array.from({ length: 6 }, () => task())) + expect(peak).toBe(2) // 永不超过 permits +}) + +test('maxConcurrency 落在 [1, 16]', () => { + const n = maxConcurrency() + expect(n).toBeGreaterThanOrEqual(1) + expect(n).toBeLessThanOrEqual(16) +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/concurrency.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/concurrency.ts`** + +```ts +import * as os from 'node:os' +import { MAX_CONCURRENCY_CAP, MAX_CONCURRENCY_OFFSET } from '../constants.js' + +/** + * 异步信号量。acquire() 返回一个 release 函数;permit 在 release 时直接 + * 转移给下一个等待者(available 不变),无等待者时才归还。permit 总数守恒。 + */ +export class Semaphore { + private available: number + private readonly waiters: Array<() => void> = [] + + constructor(permits: number) { + this.available = Math.max(1, Math.floor(permits)) + } + + async acquire(): Promise<() => void> { + if (this.available > 0) { + this.available -= 1 + return () => this.release() + } + await new Promise(resolve => this.waiters.push(resolve)) + // 被唤醒 = 一个 permit 已转移给我,不再扣减 + return () => this.release() + } + + private release(): void { + const next = this.waiters.shift() + if (next) { + next() // 直接转移 permit + } else { + this.available += 1 + } + } +} + +function cpuCores(): number { + const a = (os as { availableParallelism?: () => number }).availableParallelism + if (typeof a === 'function') { + try { + return a() + } catch { + // fallthrough + } + } + return os.cpus()?.length ?? 4 +} + +/** min(MAX_CONCURRENCY_CAP, cpuCores - MAX_CONCURRENCY_OFFSET),至少 1。 */ +export function maxConcurrency(): number { + return Math.max(1, Math.min(MAX_CONCURRENCY_CAP, cpuCores() - MAX_CONCURRENCY_OFFSET)) +} +``` + +- [ ] **Step 4:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/concurrency.test.ts && bunx tsc --noEmit` +Expected: 测试 PASS,类型零错误。 + +- [ ] **Step 5:提交** + +```bash +git add packages/workflow-engine/src/engine/concurrency.ts packages/workflow-engine/src/__tests__/concurrency.test.ts +git commit -m "feat(workflow): add Semaphore and maxConcurrency" +``` + +--- + +### Task 6:脚本解析与沙箱(`engine/script.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/engine/script.ts` +- Test: `packages/workflow-engine/src/__tests__/script.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { ScriptError, extractMeta, parseScript, type WorkflowHooks } from '../engine/script.js' + +const stubHooks: WorkflowHooks = { + agent: async () => 'agent-result', + parallel: async (thunks) => Promise.all(thunks.map(async t => { try { return await t() } catch { return null } })), + pipeline: async () => [], + phase: () => {}, + log: () => {}, + workflow: async () => null, +} + +test('extractMeta 提取纯字面量并剥离语句', () => { + const src = `export const meta = { name: 'x', description: 'y' }\nreturn 1` + const { meta, body } = extractMeta(src) + expect(meta?.name).toBe('x') + expect(meta?.description).toBe('y') + expect(body).not.toContain('export const meta') + expect(body).toContain('return 1') +}) + +test('extractMeta 无 meta 返回 null 且 body 不变', () => { + const src = `return 42` + const { meta, body } = extractMeta(src) + expect(meta).toBeNull() + expect(body).toBe(src) +}) + +test('extractMeta 拒绝非纯字面量(引用变量)', () => { + const src = `const x = 1\nexport const meta = { name: 'x', description: y }\nreturn 1` + expect(() => extractMeta(src)).toThrow(ScriptError) +}) + +test('parseScript 执行 body 顶层 return', async () => { + const { execute } = parseScript(`return args.n + 1`) + const out = await execute(stubHooks, { n: 41 }, { total: null }) + expect(out).toBe(42) +}) + +test('脚本中 Date.now() 抛非确定性错误', async () => { + const { execute } = parseScript(`return Date.now()`) + await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow(/Date\.now/) +}) + +test('脚本中 Math.random() 抛非确定性错误', async () => { + const { execute } = parseScript(`return Math.random()`) + await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow(/Math\.random/) +}) + +test('无参 new Date() 抛,有参 new Date() 可用', async () => { + const bad = parseScript(`return new Date()`) + await expect(bad.execute(stubHooks, {}, { total: null })).rejects.toThrow(/new Date/) + const good = parseScript(`return new Date('2020-06-12T00:00:00Z').getUTCFullYear()`) + await expect(good.execute(stubHooks, {}, { total: null })).resolves.toBe(2020) +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/script.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/script.ts`** + +```ts +import type { WorkflowMeta } from '../types.js' + +export class ScriptError extends Error { + constructor(message: string) { + super(message) + this.name = 'ScriptError' + } +} + +/** 引擎注入脚本的钩子函数形状。 */ +export type WorkflowHooks = { + agent: (prompt: string, opts?: Record) => Promise + parallel: (thunks: Array<() => Promise>) => Promise> + pipeline: ( + items: readonly T[], + ...stages: Array<(prev: unknown, item: T, index: number) => Promise> + ) => Promise> + phase: (title: string) => void + log: (message: string) => void + workflow: (nameOrRef: string | { scriptPath: string }, args?: unknown) => Promise +} + +const META_RE = /export\s+const\s+meta\s*=\s*/ + +/** + * 提取 `export const meta = { ... }` 纯字面量。返回 meta 对象与剥离后的 body。 + * 字面量用无参 Function 求值——任何标识符引用都会抛 ReferenceError → 报「非纯字面量」。 + */ +export function extractMeta(source: string): { + meta: WorkflowMeta | null + body: string +} { + const match = META_RE.exec(source) + if (!match) return { meta: null, body: source } + + let i = match.index! + match[0].length + while (i < source.length && /\s/.test(source[i]!)) i++ + if (source[i] !== '{') { + throw new ScriptError('meta 必须是对象字面量 `{ ... }`') + } + + // 大括号匹配(处理字符串/转义/嵌套) + let depth = 0 + const start = i + let inStr: string | null = null + for (; i < source.length; i++) { + const ch = source[i]! + if (inStr) { + if (ch === '\\') { + i++ + continue + } + if (ch === inStr) inStr = null + continue + } + if (ch === '"' || ch === "'" || ch === '`') { + inStr = ch + continue + } + if (ch === '{') depth++ + else if (ch === '}') { + depth-- + if (depth === 0) { + i++ + break + } + } + } + if (depth !== 0) throw new ScriptError('meta 字面量大括号未闭合') + + const literal = source.slice(start, i) + let metaObj: unknown + try { + // 无参 Function:纯字面量可求值;引用任何标识符 → ReferenceError + metaObj = new Function(`return (${literal})`)() + } catch (e) { + throw new ScriptError( + `meta 必须是纯字面量(无变量/函数调用/插值):${(e as Error).message}`, + ) + } + const meta = validateMeta(metaObj) + + // 剥离 meta 语句(含尾随分号与多余空行) + const body = ( + source.slice(0, match.index) + source.slice(i) + ).replace(/[ \t]*;[ \t]*\n/, '\n') + return { meta, body } +} + +function validateMeta(v: unknown): WorkflowMeta { + if (typeof v !== 'object' || v === null || Array.isArray(v)) { + throw new ScriptError('meta 必须是对象') + } + const o = v as Record + if (typeof o.name !== 'string' || typeof o.description !== 'string') { + throw new ScriptError('meta 必须含字符串 name 与 description') + } + return o as unknown as WorkflowMeta +} + +// ---- 非确定性沙箱 shim ---- +class NonDeterministicError extends Error { + constructor(fn: string) { + super( + `${fn} 在 workflow 脚本中不可用(会破坏 resume 的确定性)。请通过 args 传入时间戳/随机种子。`, + ) + this.name = 'NonDeterministicError' + } +} + +function sandboxDate(): DateConstructor { + const fn = function (...args: unknown[]): Date { + if (args.length === 0) throw new NonDeterministicError('Date.now()/new Date()') + return new (Date as unknown as DateConstructor)( + ...(args as [string | number | Date]), + ) + } as unknown as DateConstructor + fn.now = () => { + throw new NonDeterministicError('Date.now()') + } + fn.parse = Date.parse + fn.UTC = Date.UTC + return fn +} + +function sandboxMath(): Math { + return new Proxy(Math, { + get(target, prop, receiver) { + if (prop === 'random') { + return () => { + throw new NonDeterministicError('Math.random()') + } + } + return Reflect.get(target, prop, receiver) + }, + }) as Math +} + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as { + new (...args: string[]): (...args: unknown[]) => Promise +} + +export type ParsedScript = { + meta: WorkflowMeta | null + execute: ( + hooks: WorkflowHooks, + args: unknown, + budget: unknown, + ) => Promise +} + +/** 校验 + 包装脚本为可执行 async 函数(Date/Math 被 shim 覆盖)。 */ +export function parseScript(source: string): ParsedScript { + const { meta, body } = extractMeta(source) + let fn: (...args: unknown[]) => Promise + try { + fn = new AsyncFunction( + 'agent', + 'parallel', + 'pipeline', + 'phase', + 'log', + 'workflow', + 'args', + 'budget', + 'Date', + 'Math', + body, + ) + } catch (e) { + throw new ScriptError(`脚本语法错误:${(e as Error).message}`) + } + const sandboxedDate = sandboxDate() + const sandboxedMath = sandboxMath() + return { + meta, + async execute(hooks, args, budget) { + return fn( + hooks.agent, + hooks.parallel, + hooks.pipeline, + hooks.phase, + hooks.log, + hooks.workflow, + args, + budget, + sandboxedDate, + sandboxedMath, + ) + }, + } +} +``` + +- [ ] **Step 4:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/script.test.ts && bunx tsc --noEmit` +Expected: 全部 PASS,类型零错误。 + +- [ ] **Step 5:提交** + +```bash +git add packages/workflow-engine/src/engine/script.ts packages/workflow-engine/src/__tests__/script.test.ts +git commit -m "feat(workflow): add script parsing, meta extraction & Date/Math sandbox" +``` + +--- + +### Task 7:Journal(`engine/journal.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/engine/journal.ts` +- Test: `packages/workflow-engine/src/__tests__/journal.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { agentCallKey, createFileJournalStore } from '../engine/journal.js' +import type { AgentRunParams } from '../types.js' + +const base: AgentRunParams = { prompt: 'do something' } + +test('agentCallKey 对相同 prompt+params 稳定', () => { + expect(agentCallKey('p', base)).toBe(agentCallKey('p', base)) +}) + +test('agentCallKey 随 prompt 变化', () => { + expect(agentCallKey('p1', base)).not.toBe(agentCallKey('p2', base)) +}) + +test('agentCallKey 忽略纯展示字段 label/phase', () => { + const a = agentCallKey('p', { ...base, label: 'A', phase: 'ph1' }) + const b = agentCallKey('p', { ...base, label: 'B', phase: 'ph2' }) + expect(a).toBe(b) +}) + +test('FileJournalStore append → read 保序,truncate 清空', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-journal-')) + try { + const store = createFileJournalStore(dir) + const e1 = { key: 'k1', result: { kind: 'ok' as const, output: 'x', usage: { outputTokens: 1 } } } + const e2 = { key: 'k2', result: { kind: 'dead' as const } } + await store.append('run-1', e1) + await store.append('run-1', e2) + const got = await store.read('run-1') + expect(got).toHaveLength(2) + expect(got[0].key).toBe('k1') + expect(got[1].result.kind).toBe('dead') + await store.truncate('run-1') + expect(await store.read('run-1')).toEqual([]) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/journal.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/journal.ts`** + +```ts +import { createHash } from 'node:crypto' +import { appendFile, mkdir, readFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import type { JournalStore } from '../ports.js' +import type { AgentRunParams, JournalEntry } from '../types.js' + +/** 去掉纯展示字段后的规范化参数字符串。 */ +function canonicalParams(params: AgentRunParams): string { + const { label: _label, phase: _phase, ...rest } = params + const keys = Object.keys(rest).sort() + const sorted: Record = {} + for (const k of keys) sorted[k] = rest[k as keyof typeof rest] + return JSON.stringify(sorted) +} + +/** agent() 调用的确定性 key(prompt + 规范化 params 的 sha256)。 */ +export function agentCallKey(prompt: string, params: AgentRunParams): string { + return createHash('sha256') + .update(prompt + '\n' + canonicalParams(params)) + .digest('hex') +} + +/** 文件式 JournalStore(jsonl,每个 run 一个目录)。纯 fs,无核心依赖。 */ +export function createFileJournalStore(runsDir: string): JournalStore { + const pathOf = (runId: string) => join(runsDir, runId, 'journal.jsonl') + + return { + async read(runId): Promise { + try { + const raw = await readFile(pathOf(runId), 'utf-8') + return raw + .split('\n') + .filter(line => line.trim().length > 0) + .map(line => JSON.parse(line) as JournalEntry) + } catch { + return [] + } + }, + async append(runId, entry) { + await mkdir(join(runsDir, runId), { recursive: true }) + await appendFile(pathOf(runId), JSON.stringify(entry) + '\n', 'utf-8') + }, + async truncate(runId) { + await rm(join(runsDir, runId), { recursive: true, force: true }) + }, + } +} +``` + +- [ ] **Step 4:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/journal.test.ts && bunx tsc --noEmit` +Expected: 全部 PASS,类型零错误。 + +- [ ] **Step 5:提交** + +```bash +git add packages/workflow-engine/src/engine/journal.ts packages/workflow-engine/src/__tests__/journal.test.ts +git commit -m "feat(workflow): add agentCallKey hash & file JournalStore" +``` + +--- + +### Task 8:Budget(`engine/budget.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/engine/budget.ts` +- Test: `packages/workflow-engine/src/__tests__/budget.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { Budget, BudgetExhaustedError } from '../engine/budget.js' + +test('total=null 时无限制', () => { + const b = new Budget(null) + expect(b.total).toBeNull() + expect(b.remaining()).toBe(Infinity) + b.addOutputTokens(999999) + expect(b.spent()).toBe(999999) + expect(() => b.assertCanSpend()).not.toThrow() +}) + +test('累加并触顶抛错', () => { + const b = new Budget(100) + expect(b.remaining()).toBe(100) + b.addOutputTokens(40) + expect(b.spent()).toBe(40) + expect(b.remaining()).toBe(60) + expect(() => b.assertCanSpend()).not.toThrow() + b.addOutputTokens(60) + expect(b.spent()).toBe(100) + expect(() => b.assertCanSpend()).toThrow(BudgetExhaustedError) +}) + +test('addOutputTokens 负值忽略', () => { + const b = new Budget(100) + b.addOutputTokens(-50) + expect(b.spent()).toBe(0) +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/budget.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/budget.ts`** + +```ts +export class BudgetExhaustedError extends Error { + constructor() { + super('workflow token budget 已耗尽(budget.total 达到上限)') + this.name = 'BudgetExhaustedError' + } +} + +/** + * Token 预算累加器。脚本通过 `budget.total / budget.spent() / budget.remaining()` + * 读取;agent() 调用前 assertCanSpend() 强制硬上限。 + */ +export class Budget { + private spentTokens = 0 + + constructor(readonly total: number | null) {} + + spent(): number { + return this.spentTokens + } + + remaining(): number { + return this.total == null ? Infinity : Math.max(0, this.total - this.spentTokens) + } + + addOutputTokens(n: number): void { + if (n > 0) this.spentTokens += n + } + + assertCanSpend(): void { + if (this.total != null && this.spentTokens >= this.total) { + throw new BudgetExhaustedError() + } + } +} +``` + +- [ ] **Step 4:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/budget.test.ts && bunx tsc --noEmit` +Expected: 全部 PASS,类型零错误。 + +- [ ] **Step 5:提交** + +```bash +git add packages/workflow-engine/src/engine/budget.ts packages/workflow-engine/src/__tests__/budget.test.ts +git commit -m "feat(workflow): add Budget token accumulator with hard ceiling" +``` + +--- + +### Task 9:结构化输出校验(`engine/structuredOutput.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/engine/structuredOutput.ts` +- Test: `packages/workflow-engine/src/__tests__/structuredOutput.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { validateAgainstSchema } from '../engine/structuredOutput.js' + +const schema = { + type: 'object', + required: ['name', 'count'], + properties: { + name: { type: 'string' }, + count: { type: 'number' }, + }, + additionalProperties: false, +} + +test('合法对象通过', () => { + const { valid, errors } = validateAgainstSchema({ name: 'a', count: 1 }, schema) + expect(valid).toBe(true) + expect(errors).toEqual([]) +}) + +test('缺字段失败', () => { + const { valid, errors } = validateAgainstSchema({ name: 'a' }, schema) + expect(valid).toBe(false) + expect(errors.length).toBeGreaterThan(0) +}) + +test('类型错误失败', () => { + const { valid } = validateAgainstSchema({ name: 'a', count: 'x' }, schema) + expect(valid).toBe(false) +}) + +test('同一 schema 复用缓存', () => { + validateAgainstSchema({ name: 'a', count: 1 }, schema) + // 第二次用同一 schema 对象应命中缓存(不抛错即可) + expect(validateAgainstSchema({ name: 'b', count: 2 }, schema).valid).toBe(true) +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/structuredOutput.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/structuredOutput.ts`** + +```ts +import { Ajv, type ValidateFunction } from 'ajv' + +const cache = new WeakMap() + +/** + * 用 JSON Schema 校验 agent 输出(Ajv,编译结果按 schema 对象缓存)。 + * 引擎对 adapter 返回的 schema 结果做二次校验,并用于测试。 + */ +export function validateAgainstSchema( + value: unknown, + schema: object, +): { valid: boolean; errors: string[] } { + let validate = cache.get(schema) + if (!validate) { + const ajv = new Ajv({ allErrors: true, strict: false }) + validate = ajv.compile(schema) as ValidateFunction + cache.set(schema, validate) + } + const valid = validate(value) as boolean + return { + valid, + errors: valid ? [] : (validate.errors ?? []).map(e => e.message ?? 'validation error'), + } +} +``` + +- [ ] **Step 4:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/structuredOutput.test.ts && bunx tsc --noEmit` +Expected: 全部 PASS,类型零错误。 + +- [ ] **Step 5:提交** + +```bash +git add packages/workflow-engine/src/engine/structuredOutput.ts packages/workflow-engine/src/__tests__/structuredOutput.test.ts +git commit -m "feat(workflow): add JSON Schema validation via Ajv" +``` + +--- + +### Task 10:命名 workflow 解析(`engine/namedWorkflows.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/engine/namedWorkflows.ts` +- Test: `packages/workflow-engine/src/__tests__/namedWorkflows.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { listNamedWorkflows, resolveNamedWorkflow } from '../engine/namedWorkflows.js' + +test('按扩展名优先级解析命名 workflow', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) + try { + await writeFile(join(dir, 'a.ts'), 'export const meta = { name: "a", description: "d" }\nreturn 1') + await writeFile(join(dir, 'b.js'), 'return 2') + await writeFile(join(dir, 'c.mjs'), 'return 3') + await writeFile(join(dir, 'ignore.md'), '# not a workflow') + + const a = await resolveNamedWorkflow(dir, 'a') + expect(a?.path.endsWith('a.ts')).toBe(true) + expect(a?.content).toContain('meta') + + expect(await resolveNamedWorkflow(dir, 'missing')).toBeNull() + + const names = await listNamedWorkflows(dir) + expect(names).toEqual(['a', 'b', 'c']) // 不含 .md + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('listNamedWorkflows 不存在目录返回空数组', async () => { + expect(await listNamedWorkflows(join(tmpdir(), 'wf-nope-' + Date.now()))).toEqual([]) +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/namedWorkflows.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/namedWorkflows.ts`** + +```ts +import { readFile, readdir } from 'node:fs/promises' +import { join, parse } from 'node:path' +import { WORKFLOW_SCRIPT_EXTENSIONS } from '../constants.js' + +type Ext = (typeof WORKFLOW_SCRIPT_EXTENSIONS)[number] + +function isScriptExt(ext: string): ext is Ext { + return (WORKFLOW_SCRIPT_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) +} + +/** 按 .ts → .js → .mjs 优先级解析命名 workflow 文件。 */ +export async function resolveNamedWorkflow( + workflowDir: string, + name: string, +): Promise<{ path: string; content: string } | null> { + for (const ext of WORKFLOW_SCRIPT_EXTENSIONS) { + const p = join(workflowDir, name + ext) + try { + return { path: p, content: await readFile(p, 'utf-8') } + } catch { + // 试下一个扩展名 + } + } + return null +} + +/** 列出目录下所有命名 workflow(不含非脚本文件)。 */ +export async function listNamedWorkflows(workflowDir: string): Promise { + let files: string[] + try { + files = await readdir(workflowDir) + } catch { + return [] + } + return files + .filter(f => isScriptExt(parse(f).ext)) + .map(f => parse(f).name) + .sort() +} +``` + +- [ ] **Step 4:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/namedWorkflows.test.ts && bunx tsc --noEmit` +Expected: 全部 PASS,类型零错误。 + +- [ ] **Step 5:导出 + 全包回归 + 提交** + +更新 `src/index.ts` 追加: + +```ts +export * from './engine/concurrency.js' +export * from './engine/script.js' +export * from './engine/journal.js' +export * from './engine/budget.js' +export * from './engine/structuredOutput.js' +export * from './engine/namedWorkflows.js' +``` + +Run: `cd packages/workflow-engine && bun test && bunx tsc --noEmit` +Expected: 全部测试 PASS,类型零错误。 + +```bash +git add packages/workflow-engine/src/engine/namedWorkflows.ts packages/workflow-engine/src/__tests__/namedWorkflows.test.ts packages/workflow-engine/src/index.ts +git commit -m "feat(workflow): add named-workflow file resolution" +``` + +--- + +## Phase 2:引擎核心 + +### Task 11:errors / 进度事件 / 执行上下文 + +**Files:** +- Create: `packages/workflow-engine/src/engine/errors.ts` +- Create: `packages/workflow-engine/src/progress/events.ts` +- Create: `packages/workflow-engine/src/engine/context.ts` +- Test: `packages/workflow-engine/src/__tests__/context.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { createBufferingEmitter } from '../progress/events.js' +import { createEngineContext, createSharedResources } from '../engine/context.js' +import { WorkflowError } from '../engine/errors.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' + +function mockPorts(): WorkflowPorts { + return { + agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { register: () => 'r', complete: () => {}, fail: () => {}, kill: () => {}, pendingAction: () => null }, + journalStore: { read: async () => [], append: async () => {}, truncate: async () => {} }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ handle: createHostHandle(null), signal: new AbortController().signal, cwd: '/tmp', budgetTotal: null }), + } +} + +test('createSharedResources 初始化预算与计数', () => { + const r = createSharedResources(100) + expect(r.budget.total).toBe(100) + expect(r.agentCountBox.value).toBe(0) + expect(r.depth).toBe(0) +}) + +test('createEngineContext 复制 journal 并重置游标', () => { + const journal = [{ key: 'k', result: { kind: 'ok', output: 'x', usage: { outputTokens: 1 } } }] + const ctx = createEngineContext({ + ports: mockPorts(), host: createHostHandle(null), + signal: new AbortController().signal, runId: 'r1', workflowName: 'w', cwd: '/tmp', + budgetTotal: null, journal, + }) + expect(ctx.journal).toHaveLength(1) + expect(ctx.journalIndex).toBe(0) + expect(ctx.journalInvalidated).toBe(false) +}) + +test('createBufferingEmitter 收集事件', () => { + const { emitter, events } = createBufferingEmitter() + emitter.emit({ type: 'log', message: 'hi' }) + expect(events).toHaveLength(1) +}) + +test('WorkflowError 可识别', () => { + const e = new WorkflowError('boom') + expect(e).toBeInstanceOf(Error) + expect(e.message).toBe('boom') +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/context.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/errors.ts`** + +```ts +/** 引擎级可预期错误(脚本错、上限、嵌套)。 */ +export class WorkflowError extends Error { + constructor(message: string) { + super(message) + this.name = 'WorkflowError' + } +} + +/** workflow 被 abort(kill)。 */ +export class WorkflowAbortedError extends Error { + constructor() { + super('workflow 已被取消(abort)') + this.name = 'WorkflowAbortedError' + } +} +``` + +- [ ] **Step 4:写 `progress/events.ts`** + +```ts +import type { ProgressEmitter } from '../ports.js' +import type { ProgressEvent } from '../types.js' + +export type { ProgressEvent } + +/** 从单个回调构造 ProgressEmitter。 */ +export function createProgressEmitter(onEvent: (e: ProgressEvent) => void): ProgressEmitter { + return { emit: onEvent } +} + +/** 收集所有事件到数组(测试用)。 */ +export function createBufferingEmitter(): { + emitter: ProgressEmitter + events: ProgressEvent[] +} { + const events: ProgressEvent[] = [] + return { emitter: { emit: e => void events.push(e) }, events } +} +``` + +- [ ] **Step 5:写 `engine/context.ts`** + +```ts +import type { HostHandle, WorkflowPorts } from '../ports.js' +import type { JournalEntry } from '../types.js' +import { Budget } from './budget.js' +import { Semaphore, maxConcurrency } from './concurrency.js' + +/** 可被子 workflow 共享的资源。嵌套时 semaphore/budget/agentCountBox 按引用共享,depth 递增。 */ +export type SharedResources = { + semaphore: Semaphore + budget: Budget + agentCountBox: { value: number } + depth: number +} + +/** 单次 workflow 运行的执行上下文。 */ +export type EngineContext = { + ports: WorkflowPorts + host: HostHandle + signal: AbortSignal + runId: string + workflowName: string + cwd: string + resources: SharedResources + journal: JournalEntry[] + journalIndex: number + journalInvalidated: boolean + currentPhase: string | null +} + +export function createSharedResources(budgetTotal: number | null): SharedResources { + return { + semaphore: new Semaphore(maxConcurrency()), + budget: new Budget(budgetTotal), + agentCountBox: { value: 0 }, + depth: 0, + } +} + +export function createEngineContext(opts: { + ports: WorkflowPorts + host: HostHandle + signal: AbortSignal + runId: string + workflowName: string + cwd: string + budgetTotal: number | null + journal?: JournalEntry[] + shared?: SharedResources +}): EngineContext { + const resources = opts.shared ?? createSharedResources(opts.budgetTotal) + return { + ports: opts.ports, + host: opts.host, + signal: opts.signal, + runId: opts.runId, + workflowName: opts.workflowName, + cwd: opts.cwd, + resources, + journal: opts.journal ? [...opts.journal] : [], + journalIndex: 0, + journalInvalidated: false, + currentPhase: null, + } +} +``` + +- [ ] **Step 6:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/context.test.ts && bunx tsc --noEmit` +Expected: 全部 PASS,类型零错误。 + +- [ ] **Step 7:提交** + +```bash +git add packages/workflow-engine/src/engine/errors.ts packages/workflow-engine/src/progress/events.ts packages/workflow-engine/src/engine/context.ts packages/workflow-engine/src/__tests__/context.test.ts +git commit -m "feat(workflow): add errors, progress emitter & engine context" +``` + +--- + +### Task 12:钩子实现(`engine/hooks.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/engine/hooks.ts` +- Test: `packages/workflow-engine/src/__tests__/hooks.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { createEngineContext } from '../engine/context.js' +import { makeHooks, type SubWorkflowRunner } from '../engine/hooks.js' +import { WorkflowError } from '../engine/errors.js' +import { createBufferingEmitter } from '../progress/events.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import type { AgentRunParams, AgentRunResult } from '../types.js' + +function buildCtx(overrides: Partial<{ + agentResults: Map + pending: { kind: 'skip' | 'retry' } | null + journal: import('../types.js').JournalEntry[] + budgetTotal: number | null +}> = {}) { + const { emitter, events } = createBufferingEmitter() + const results = overrides.agentResults ?? new Map() + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async (params: AgentRunParams) => + results.get(params.prompt) ?? { kind: 'dead' }, + }, + progressEmitter: emitter, + taskRegistrar: { + register: () => 'r', complete: () => {}, fail: () => {}, kill: () => {}, + pendingAction: () => overrides.pending ?? null, + }, + journalStore: { + read: async () => [], append: async () => {}, truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ handle: createHostHandle(null), signal: new AbortController().signal, cwd: '/tmp', budgetTotal: null }), + } + const ctx = createEngineContext({ + ports, host: createHostHandle(null), + signal: new AbortController().signal, runId: 'r1', workflowName: 'w', cwd: '/tmp', + budgetTotal: overrides.budgetTotal ?? null, + journal: overrides.journal, + }) + const noopSub: SubWorkflowRunner = async () => null + return { ctx, events, hooks: makeHooks(ctx, noopSub) } +} + +test('agent 返回文本结果并计数', async () => { + const { ctx, hooks } = buildCtx({ + agentResults: new Map([['hi', { kind: 'ok', output: 'hello', usage: { outputTokens: 5 } }]]), + }) + const out = await hooks.agent('hi') + expect(out).toBe('hello') + expect(ctx.resources.agentCountBox.value).toBe(1) +}) + +test('agent skipped → null 且不计数', async () => { + const { hooks } = buildCtx({ + agentResults: new Map([['hi', { kind: 'skipped' }]]), + }) + expect(await hooks.agent('hi')).toBeNull() +}) + +test('agent dead → null', async () => { + const { hooks } = buildCtx({ + agentResults: new Map([['hi', { kind: 'dead' }]]), + }) + expect(await hooks.agent('hi')).toBeNull() +}) + +test('agent journal 命中时不调用 runner', async () => { + let called = 0 + const { emitter, events } = createBufferingEmitter() + const ports: WorkflowPorts = { + agentRunner: { runAgentToResult: async () => { called++; return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } } } }, + progressEmitter: emitter, + taskRegistrar: { register: () => 'r', complete: () => {}, fail: () => {}, kill: () => {}, pendingAction: () => null }, + journalStore: { read: async () => [], append: async () => {}, truncate: async () => {} }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ handle: createHostHandle(null), signal: new AbortController().signal, cwd: '/tmp', budgetTotal: null }), + } + const { agentCallKey } = await import('../engine/journal.js') + const key = agentCallKey('hi', { prompt: 'hi' }) + const ctx = createEngineContext({ + ports, host: createHostHandle(null), + signal: new AbortController().signal, runId: 'r1', workflowName: 'w', cwd: '/tmp', + budgetTotal: null, + journal: [{ key, result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } } }], + }) + const hooks = makeHooks(ctx, async () => null) + expect(await hooks.agent('hi')).toBe('cached') + expect(called).toBe(0) +}) + +test('agent 超过总数上限抛错', async () => { + const { hooks, ctx } = buildCtx() + ctx.resources.agentCountBox.value = 1000 + await expect(hooks.agent('hi')).rejects.toThrow(WorkflowError) +}) + +test('parallel 单项抛错 → null,其余保留', async () => { + const { hooks } = buildCtx() + const out = await hooks.parallel([ + async () => 'a', + async () => { throw new Error('x') }, + async () => 'c', + ]) + expect(out).toEqual(['a', null, 'c']) +}) + +test('pipeline 逐 stage 链式,stage 抛错 → null', async () => { + const { hooks } = buildCtx() + const out = await hooks.pipeline( + [1, 2], + (n) => Promise.resolve((n as number) + 1), + (m) => Promise.resolve((m as number) * 10), + ) + expect(out).toEqual([20, 30]) + const out2 = await hooks.pipeline( + [1], + () => Promise.reject(new Error('boom')), + (m) => Promise.resolve(m), + ) + expect(out2).toEqual([null]) +}) + +test('pipeline 超 4096 抛错', async () => { + const { hooks } = buildCtx() + await expect(hooks.pipeline(Array(4097), () => Promise.resolve(1))).rejects.toThrow(WorkflowError) +}) + +test('phase 切换发射 phase_started/done;log 发射 log', async () => { + const { hooks, events } = buildCtx() + hooks.phase('A') + hooks.log('hello') + hooks.phase('B') + expect(events.some(e => e.type === 'phase_started' && e.phase === 'A')).toBe(true) + expect(events.some(e => e.type === 'phase_done' && e.phase === 'A')).toBe(true) + expect(events.some(e => e.type === 'log' && e.message === 'hello')).toBe(true) + expect(events.some(e => e.type === 'phase_started' && e.phase === 'B')).toBe(true) +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/hooks.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/hooks.ts`** + +```ts +import { MAX_ITEMS_PER_CALL, MAX_TOTAL_AGENTS, WORKFLOW_DIR_NAME } from '../constants.js' +import type { HostHandle, WorkflowPorts } from '../ports.js' +import type { AgentRunParams, AgentRunResult, JournalEntry } from '../types.js' +import type { EngineContext, SharedResources } from './context.js' +import { WorkflowAbortedError, WorkflowError } from './errors.js' +import { agentCallKey } from './journal.js' +import type { WorkflowHooks } from './script.js' + +/** workflow() 钩子的子 workflow 执行器(由 runWorkflow 注入,避免循环依赖)。 */ +export type SubWorkflowRunner = (opts: { + name?: string + scriptPath?: string + script?: string + args?: unknown +}) => Promise + +type Opts = Record + +type HookProgressInit = + | { type: 'phase_started'; phase: string } + | { type: 'phase_done'; phase: string } + | { type: 'agent_started'; label?: string; phase?: string } + | { type: 'agent_done'; label?: string; phase?: string; result: AgentRunResult } + | { type: 'log'; message: string } + +export function makeHooks(ctx: EngineContext, runSubWorkflow: SubWorkflowRunner): WorkflowHooks { + // 所有进度事件自动注入 runId,供 adapter 路由到对应 task(多并发 workflow) + const emit = (init: HookProgressInit): void => { + ctx.ports.progressEmitter.emit({ runId: ctx.runId, ...init } as ProgressEvent) + } + + const agent: WorkflowHooks['agent'] = async (prompt, opts = {}) => { + const r = ctx.resources + if (r.agentCountBox.value >= MAX_TOTAL_AGENTS) { + throw new WorkflowError(`workflow 超过 agent 总数上限 (${MAX_TOTAL_AGENTS})`) + } + r.budget.assertCanSpend() + + const params: AgentRunParams = { prompt, ...opts } + const key = agentCallKey(prompt, params) + const label = opts.label as string | undefined + const phase = (opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined + + // journal 命中 → 直接返回缓存 + if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) { + const entry = ctx.journal[ctx.journalIndex]! + if (entry.key === key) { + ctx.journalIndex++ + emit({ type: 'agent_done', label, phase, result: entry.result }) + return resultToOutput(entry.result) + } + // 发散:丢弃后续 journal,后续全部现场跑 + ctx.journalInvalidated = true + ctx.journal = ctx.journal.slice(0, ctx.journalIndex) + await ctx.ports.journalStore.truncate(ctx.runId) + } + + const release = await ctx.resources.semaphore.acquire() + try { + if (ctx.signal.aborted) throw new WorkflowAbortedError() + + const pending = ctx.ports.taskRegistrar.pendingAction(ctx.runId) + if (pending?.kind === 'skip') { + const result: AgentRunResult = { kind: 'skipped' } + emit({ type: 'agent_done', label, phase, result }) + return null + } + + ctx.resources.agentCountBox.value++ + emit({ type: 'agent_started', label, phase }) + const result = await ctx.ports.agentRunner.runAgentToResult(params, ctx.host) + if (result.kind === 'ok') { + ctx.resources.budget.addOutputTokens(result.usage.outputTokens) + } + ctx.ports.progressEmitter.emit({ type: 'agent_done', label, phase, result }) + + const entry: JournalEntry = { key, result } + ctx.journal.push(entry) + ctx.journalIndex++ + await ctx.ports.journalStore.append(ctx.runId, entry) + return resultToOutput(result) + } finally { + release() + } + } + + const parallel: WorkflowHooks['parallel'] = async thunks => { + if (thunks.length > MAX_ITEMS_PER_CALL) { + throw new WorkflowError(`parallel 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`) + } + return Promise.all( + thunks.map(async t => { + try { + return await t() + } catch { + return null + } + }), + ) + } + + const pipeline: WorkflowHooks['pipeline'] = async (items, ...stages) => { + if (items.length > MAX_ITEMS_PER_CALL) { + throw new WorkflowError(`pipeline 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`) + } + return Promise.all( + items.map(async (item, index) => { + try { + let prev: unknown = item + for (const stage of stages) { + prev = await stage(prev, item, index) + } + return prev + } catch { + return null + } + }), + ) + } + + const phase: WorkflowHooks['phase'] = title => { + if (ctx.currentPhase) { + emit({ type: 'phase_done', phase: ctx.currentPhase }) + } + ctx.currentPhase = title + emit({ type: 'phase_started', phase: title }) + } + + const log: WorkflowHooks['log'] = message => { + emit({ type: 'log', message }) + } + + const workflow: WorkflowHooks['workflow'] = async (nameOrRef, args) => { + if (ctx.resources.depth >= 1) { + throw new WorkflowError('workflow() 嵌套仅允许一层') + } + const sub: Parameters[0] = + typeof nameOrRef === 'string' ? { name: nameOrRef } : { scriptPath: nameOrRef.scriptPath } + return runSubWorkflow({ ...sub, args }) + } + + return { agent, parallel, pipeline, phase, log, workflow } +} + +function resultToOutput(result: AgentRunResult): unknown { + return result.kind === 'ok' ? result.output : null +} + +// 仅用于抑制未使用导入告警(WORKFLOW_DIR_NAME 在 runWorkflow 中用于子 workflow 解析) +export type _Unused = typeof WORKFLOW_DIR_NAME & typeof SharedResources & HostHandle & WorkflowPorts +``` + +> 注:`_Unused` 行是占位防止 lint 抱怨未使用导入——若 `bunx tsc` 报「未使用」,移除该行及对应未用 import。最终版只保留真正用到的 import(`MAX_ITEMS_PER_CALL`、`MAX_TOTAL_AGENTS`、`AgentRunParams`、`AgentRunResult`、`JournalEntry`、`EngineContext`、`WorkflowAbortedError`、`WorkflowError`、`agentCallKey`、`WorkflowHooks`、`SubWorkflowRunner`)。实现时清理为: + +```ts +import { MAX_ITEMS_PER_CALL, MAX_TOTAL_AGENTS } from '../constants.js' +import type { + AgentRunParams, + AgentRunResult, + JournalEntry, + ProgressEvent, +} from '../types.js' +import type { EngineContext } from './context.js' +import { WorkflowAbortedError, WorkflowError } from './errors.js' +import { agentCallKey } from './journal.js' +import type { WorkflowHooks } from './script.js' +``` + +- [ ] **Step 4:运行测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test src/__tests__/hooks.test.ts && bunx tsc --noEmit` +Expected: 全部 PASS,类型零错误(确认已清理未用 import)。 + +- [ ] **Step 5:提交** + +```bash +git add packages/workflow-engine/src/engine/hooks.ts packages/workflow-engine/src/__tests__/hooks.test.ts +git commit -m "feat(workflow): implement agent/parallel/pipeline/phase/log/workflow hooks" +``` + +--- + +### Task 13:引擎编排入口(`engine/runWorkflow.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/engine/runWorkflow.ts` +- Test: `packages/workflow-engine/src/__tests__/runWorkflow.test.ts` + +- [ ] **Step 1:先写测试** + +```ts +import { expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runWorkflow } from '../engine/runWorkflow.js' +import { createFileJournalStore } from '../engine/journal.js' +import { agentCallKey } from '../engine/journal.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import type { AgentRunParams, AgentRunResult } from '../types.js' + +function portsWith(runsDir: string, results: Map): WorkflowPorts { + return { + agentRunner: { runAgentToResult: async (p: AgentRunParams) => results.get(p.prompt) ?? { kind: 'dead' } }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { register: () => 'r', complete: () => {}, fail: () => {}, kill: () => {}, pendingAction: () => null }, + journalStore: createFileJournalStore(runsDir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ handle: createHostHandle(null), signal: new AbortController().signal, cwd: '/tmp', budgetTotal: null }), + } +} + +test('端到端:脚本返回 agent 结果,状态 completed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith(dir, new Map([['compute', { kind: 'ok', output: 42, usage: { outputTokens: 3 } }]])) + const result = await runWorkflow({ + script: `export const meta = { name: 't', description: 'd' }\nreturn agent('compute')`, + runId: 'run-1', ports, host: createHostHandle(null), + signal: new AbortController().signal, cwd: dir, budgetTotal: null, + }) + expect(result.status).toBe('completed') + expect(result.returnValue).toBe(42) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('脚本语法错误 → failed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith(dir, new Map()) + const result = await runWorkflow({ + script: `export const meta = { name: 't', description: 'd' }\nreturn ((`, + runId: 'run-2', ports, host: createHostHandle(null), + signal: new AbortController().signal, cwd: dir, budgetTotal: null, + }) + expect(result.status).toBe('failed') + expect(result.error).toBeTruthy() + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('resume:journal 命中则不调用 runner', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + let called = 0 + const ports: WorkflowPorts = { + agentRunner: { runAgentToResult: async () => { called++; return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } } } }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { register: () => 'r', complete: () => {}, fail: () => {}, kill: () => {}, pendingAction: () => null }, + journalStore: createFileJournalStore(dir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ handle: createHostHandle(null), signal: new AbortController().signal, cwd: dir, budgetTotal: null }), + } + // 预置 journal:与脚本中 agent('compute') 的 key 匹配 + const key = agentCallKey('compute', { prompt: 'compute' }) + await ports.journalStore.append('run-3', { key, result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } } }) + + const result = await runWorkflow({ + script: `return agent('compute')`, + runId: 'run-3', ports, host: createHostHandle(null), + signal: new AbortController().signal, cwd: dir, budgetTotal: null, + resume: true, + }) + expect(result.status).toBe('completed') + expect(result.returnValue).toBe('cached') + expect(called).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('abort → killed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith(dir, new Map([['x', { kind: 'ok', output: 1, usage: { outputTokens: 1 } }]])) + const ac = new AbortController() + ac.abort() + const result = await runWorkflow({ + script: `return agent('x')`, + runId: 'run-4', ports, host: createHostHandle(null), + signal: ac.signal, cwd: dir, budgetTotal: null, + }) + expect(result.status).toBe('killed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('workflow() 嵌套(一层)共享计数;二层被拒', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) + // 子 workflow:调用 agent,并尝试再嵌套(应抛错) + await writeFile( + join(dir, '.claude', 'workflows', 'child.ts'), + `return agent('child')\n// 以下故意触发二层嵌套以测guard,但单独运行不会`, + ) + const ports = portsWith(dir, new Map([['child', { kind: 'ok', output: 'child-out', usage: { outputTokens: 1 } }]])) + const result = await runWorkflow({ + script: `return workflow('child')`, + runId: 'run-5', ports, host: createHostHandle(null), + signal: new AbortController().signal, cwd: dir, budgetTotal: null, + }) + expect(result.status).toBe('completed') + expect(result.returnValue).toBe('child-out') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/runWorkflow.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `engine/runWorkflow.ts`** + +```ts +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { WORKFLOW_DIR_NAME } from '../constants.js' +import type { HostHandle, WorkflowPorts } from '../ports.js' +import type { JournalEntry, WorkflowRunResult } from '../types.js' +import { createEngineContext } from './context.js' +import { WorkflowAbortedError, WorkflowError } from './errors.js' +import { makeHooks, type SubWorkflowRunner } from './hooks.js' +import { resolveNamedWorkflow } from './namedWorkflows.js' +import { parseScript, type ParsedScript } from './script.js' + +export type RunWorkflowOptions = { + /** 已解析好的脚本源码。 */ + script: string + args?: unknown + runId: string + workflowName?: string + ports: WorkflowPorts + host: HostHandle + signal: AbortSignal + cwd: string + budgetTotal: number | null + /** resume:true 时载入既有 journal 重放。 */ + resume?: boolean + /** resume 时脚本源码 hash 是否变化。true 则忽略 journal 全重跑。 */ + scriptChanged?: boolean +} + +export async function runWorkflow(opts: RunWorkflowOptions): Promise { + const { ports } = opts + + let parsed: ParsedScript + try { + parsed = parseScript(opts.script) + } catch (e) { + const error = (e as Error).message + ports.progressEmitter.emit({ type: 'run_done', runId: opts.runId, status: 'failed', error }) + return { status: 'failed', error } + } + + const workflowName = opts.workflowName ?? parsed.meta?.name ?? 'workflow' + + // 载入 journal(仅 resume 且脚本未变) + let journal: JournalEntry[] = [] + let journalInvalidated = false + if (opts.resume && !opts.scriptChanged) { + journal = await ports.journalStore.read(opts.runId) + } else if (opts.scriptChanged) { + await ports.journalStore.truncate(opts.runId) + journalInvalidated = true + } + + const ctx = createEngineContext({ + ports, + host: opts.host, + signal: opts.signal, + runId: opts.runId, + workflowName, + cwd: opts.cwd, + budgetTotal: opts.budgetTotal, + journal, + }) + if (journalInvalidated) ctx.journalInvalidated = true + + ports.progressEmitter.emit({ + type: 'run_started', + runId: opts.runId, + workflowName, + meta: parsed.meta, + }) + + // 子 workflow 执行器:复用同一 ctx(共享 journal/并发/预算/计数),临时 +1 depth + const runSubWorkflow: SubWorkflowRunner = async sub => { + const script = await resolveSubScript(sub, opts.cwd) + let subParsed: ParsedScript + try { + subParsed = parseScript(script) + } catch (e) { + throw new WorkflowError(`子 workflow 脚本错误:${(e as Error).message}`) + } + const prevDepth = ctx.resources.depth + ctx.resources.depth += 1 + try { + const subHooks = makeHooks(ctx, runSubWorkflow) + return await subParsed.execute(subHooks, sub.args, ctx.resources.budget) + } finally { + ctx.resources.depth = prevDepth + } + } + + const hooks = makeHooks(ctx, runSubWorkflow) + + try { + const returnValue = await parsed.execute(hooks, opts.args, ctx.resources.budget) + ports.progressEmitter.emit({ type: 'run_done', runId: opts.runId, status: 'completed', returnValue }) + return { status: 'completed', returnValue } + } catch (e) { + if (e instanceof WorkflowAbortedError) { + ports.progressEmitter.emit({ type: 'run_done', runId: opts.runId, status: 'killed' }) + return { status: 'killed' } + } + const error = (e as Error).message + ports.progressEmitter.emit({ type: 'run_done', runId: opts.runId, status: 'failed', error }) + return { status: 'failed', error } + } +} + +async function resolveSubScript( + sub: { name?: string; scriptPath?: string; script?: string }, + cwd: string, +): Promise { + if (sub.script) return sub.script + if (sub.scriptPath) return await readFile(sub.scriptPath, 'utf-8') + if (sub.name) { + const found = await resolveNamedWorkflow(join(cwd, WORKFLOW_DIR_NAME), sub.name) + if (!found) throw new WorkflowError(`子 workflow "${sub.name}" 未找到`) + return found.content + } + throw new WorkflowError('workflow() 需要 name 或 scriptPath') +} +``` + +- [ ] **Step 4:更新 `src/index.ts` 导出引擎入口 + 事件** + +```ts +export * from './engine/errors.js' +export * from './engine/context.js' +export * from './engine/hooks.js' +export * from './engine/runWorkflow.js' +export * from './progress/events.js' +``` + +- [ ] **Step 5:运行全包测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test && bunx tsc --noEmit` +Expected: 全部测试 PASS,类型零错误。 + +- [ ] **Step 6:提交** + +```bash +git add packages/workflow-engine/src/engine/runWorkflow.ts packages/workflow-engine/src/__tests__/runWorkflow.test.ts packages/workflow-engine/src/index.ts +git commit -m "feat(workflow): add runWorkflow orchestrator with resume & nesting" +``` + +> **里程碑:Phase 1–2 完成。** 包 `@claude-code-best/workflow-engine` 现已独立可运行——全 mock 端口,无 LLM、无核心层依赖。可在此检查点整体 review。 + +--- + +## Phase 3:自包含工具描述符 + +### Task 14:输入 schema(`tool/schema.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/tool/schema.ts` +- Create: `packages/workflow-engine/src/tool/constants.ts` + +- [ ] **Step 1:写 `tool/constants.ts`(供核心 re-export 路径兼容)** + +```ts +export { WORKFLOW_TOOL_NAME } from '../constants.js' +``` + +- [ ] **Step 2:写 `tool/schema.ts`** + +```ts +import { z } from 'zod/v4' + +/** Workflow 工具输入 schema。args 为任意 JSON 值(对象/数组/字符串等)。 */ +export const workflowInputSchema = z.object({ + script: z + .string() + .optional() + .describe('自包含的 workflow 脚本源码(inline)'), + name: z + .string() + .optional() + .describe('命名 workflow,解析到 .claude/workflows/.ts|js|mjs'), + scriptPath: z + .string() + .optional() + .describe('已有脚本文件的绝对路径'), + args: z + .unknown() + .optional() + .describe( + '透传给脚本的 args 全局变量。传真实 JSON 值(对象/数组/字符串),不要传 JSON 字符串。', + ), + resumeFromRunId: z + .string() + .optional() + .describe('resume 指定 run,重放 journal'), + description: z + .string() + .optional() + .describe('本次调用的简短描述(3-5 词)'), + title: z.string().optional().describe('进度查看器标题'), +}) + +export type WorkflowInputSchema = typeof workflowInputSchema +``` + +- [ ] **Step 3:类型检查** + +Run: `cd packages/workflow-engine && bunx tsc --noEmit` +Expected: 零错误。 + +- [ ] **Step 4:提交** + +```bash +git add packages/workflow-engine/src/tool/schema.ts packages/workflow-engine/src/tool/constants.ts +git commit -m "feat(workflow): add tool input schema" +``` + +--- + +### Task 15:WorkflowTool 描述符(`tool/WorkflowTool.ts`) + +**Files:** +- Create: `packages/workflow-engine/src/tool/WorkflowTool.ts` +- Test: `packages/workflow-engine/src/__tests__/WorkflowTool.test.ts` + +- [ ] **Step 1:先写测试(用 mock 端口验证 call 返回 launch 消息并触发 detached run)** + +```ts +import { expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createWorkflowTool } from '../tool/WorkflowTool.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import type { AgentRunParams, AgentRunResult } from '../types.js' + +function mockPorts(runsDir: string, results: Map): { + ports: WorkflowPorts + events: import('../types.js').ProgressEvent[] + runStatus: Map +} { + const events: import('../types.js').ProgressEvent[] = [] + const runStatus = new Map() + const ports: WorkflowPorts = { + agentRunner: { runAgentToResult: async (p: AgentRunParams) => results.get(p.prompt) ?? { kind: 'dead' } }, + progressEmitter: { emit: e => void events.push(e) }, + taskRegistrar: { + register: () => ({ runId: 'run-x', signal: new AbortController().signal }), + complete: (id, _s) => void runStatus.set(id, 'completed'), + fail: (id, _e) => void runStatus.set(id, 'failed'), + kill: id => void runStatus.set(id, 'killed'), + pendingAction: () => null, + }, + journalStore: { read: async () => [], append: async () => {}, truncate: async () => {} }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ handle: createHostHandle(null), cwd: runsDir, budgetTotal: null }), + } + return { ports, events, runStatus } +} + +test('call 返回 launch 消息并在后台完成', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const { ports, runStatus } = mockPorts(dir, new Map([['compute', { kind: 'ok', output: 42, usage: { outputTokens: 1 } }]])) + const tool = createWorkflowTool(ports) + const res = await tool.call( + { script: `return agent('compute')` }, + undefined, undefined, undefined, + ) + expect(res.data.output).toContain('run_id: run-x') + // 等待 detached run 完成 + await new Promise(r => setTimeout(r, 50)) + expect(runStatus.get('run-x')).toBe('completed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('缺少 script/name/scriptPath → 返回错误(不进后台)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const { ports, runStatus } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + const res = await tool.call({}, undefined, undefined, undefined) + expect(res.data.output).toMatch(/^Error:/) + expect(runStatus.size).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('脚本语法错 → 返回校验错误(不进后台)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const { ports, runStatus } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + const res = await tool.call({ script: `return ((` }, undefined, undefined, undefined) + expect(res.data.output).toMatch(/校验失败|Error/) + expect(runStatus.size).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('name 解析到 .claude/workflows/.ts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) + await writeFile(join(dir, '.claude', 'workflows', 'release.ts'), `return agent('compute')`) + const { ports, runStatus } = mockPorts(dir, new Map([['compute', { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }]])) + const tool = createWorkflowTool(ports) + const res = await tool.call({ name: 'release' }, undefined, undefined, undefined) + expect(res.data.output).toContain('run_id') + await new Promise(r => setTimeout(r, 50)) + expect(runStatus.get('run-x')).toBe('completed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('renderToolUseMessage / mapToolResultToToolResultBlockParam', () => { + const dir = '/tmp' + const { ports } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + expect(tool.renderToolUseMessage({ name: 'release' })).toBe('Workflow: release') + const block = tool.mapToolResultToToolResultBlockParam({ output: 'hi' }, 'tu-1') + expect(block.tool_use_id).toBe('tu-1') + expect(block.type).toBe('tool_result') + expect(block.content[0].text).toBe('hi') +}) +``` + +- [ ] **Step 2:运行测试确认失败** + +Run: `cd packages/workflow-engine && bun test src/__tests__/WorkflowTool.test.ts` +Expected: FAIL —— 模块不存在。 + +- [ ] **Step 3:写 `tool/WorkflowTool.ts`** + +```ts +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod/v4' +import { WORKFLOW_DIR_NAME, WORKFLOW_TOOL_NAME } from '../constants.js' +import { resolveNamedWorkflow } from '../engine/namedWorkflows.js' +import { runWorkflow } from '../engine/runWorkflow.js' +import { parseScript } from '../engine/script.js' +import type { WorkflowPorts } from '../ports.js' +import type { WorkflowInput, WorkflowRunResult } from '../types.js' +import { workflowInputSchema } from './schema.js' + +/** 自包含工具描述符(核心 wiring 用 buildTool 包装它)。零核心层依赖。 */ +export type WorkflowToolDescriptor = { + name: string + inputSchema: z.ZodType + isEnabled: () => boolean + isReadOnly: (input: WorkflowInput) => boolean + description: () => Promise + prompt: () => Promise + renderToolUseMessage: (input: Partial) => string + call: ( + input: WorkflowInput, + context: unknown, + canUseTool: unknown, + parentMessage: unknown, + onProgress?: unknown, + ) => Promise<{ data: { output: string } }> + mapToolResultToToolResultBlockParam: ( + data: { output: string }, + toolUseId: string, + ) => { + tool_use_id: string + type: 'tool_result' + content: Array<{ type: 'text'; text: string }> + } +} + +const WORKFLOW_TOOL_PROMPT = `Use the Workflow tool to execute a workflow script that orchestrates multiple subagents deterministically. The script runs in the background; you receive a run_id immediately and are notified on completion. + +Provide the script inline via "script", or reference a named workflow via "name" (resolved from .claude/workflows/), or an existing file via "scriptPath". Pass "args" as a real JSON value (object/array/string), not a stringified string. + +Use "resumeFromRunId" to resume a prior run — completed agent() calls replay from the journal instantly.` + +export function createWorkflowTool(ports: WorkflowPorts): WorkflowToolDescriptor { + return { + name: WORKFLOW_TOOL_NAME, + inputSchema: workflowInputSchema as unknown as z.ZodType, + isEnabled: () => true, + isReadOnly: () => false, + + async description() { + return '执行一个 workflow 脚本,编排多个子 agent 完成任务' + }, + + async prompt() { + return WORKFLOW_TOOL_PROMPT + }, + + renderToolUseMessage(input) { + if (input.resumeFromRunId) return `Workflow resume: ${input.resumeFromRunId}` + const id = input.name ?? input.scriptPath ?? (input.script ? 'inline' : 'unknown') + return `Workflow: ${id}` + }, + + async call(input, context, canUseTool, parentMessage) { + const host = ports.hostFactory({ context, canUseTool, parentMessage }) + + // 解析脚本源 + let script: string + let workflowFile: string | undefined + try { + const resolved = await resolveScriptSource(input, host.cwd) + script = resolved.script + workflowFile = resolved.workflowFile + } catch (e) { + return { data: { output: `Error: ${(e as Error).message}` } } + } + + // 快速校验(meta + 语法),失败直接返错给模型,不进后台 + try { + parseScript(script) + } catch (e) { + return { data: { output: `Error: 脚本校验失败:${(e as Error).message}` } } + } + + const workflowName = input.name ?? input.title ?? 'workflow' + const { runId, signal } = ports.taskRegistrar.register( + { + workflowName, + ...(workflowFile ? { workflowFile } : {}), + ...(input.description ? { summary: input.description } : {}), + ...(host.toolUseId ? { toolUseId: host.toolUseId } : {}), + ...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}), + }, + host.handle, + ) + + // detached 执行 + void runWorkflow({ + script, + ...(input.args !== undefined ? { args: input.args } : {}), + runId, + workflowName, + ports, + host: host.handle, + signal, + cwd: host.cwd, + budgetTotal: host.budgetTotal, + ...(input.resumeFromRunId ? { resume: true } : {}), + }) + .then(result => onFinish(ports, result, runId)) + .catch(e => ports.taskRegistrar.fail(runId, (e as Error).message)) + + const scriptPath = workflowFile ?? `` + return { + data: { + output: [ + 'Workflow 已启动(后台执行)。', + `run_id: ${runId}`, + `workflow: ${workflowName}`, + `script: ${scriptPath}`, + '', + '完成时会自动通知。用 /workflows 查看实时进度。', + ].join('\n'), + }, + } + }, + + mapToolResultToToolResultBlockParam(data, toolUseId) { + return { + tool_use_id: toolUseId, + type: 'tool_result', + content: [{ type: 'text', text: data.output }], + } + }, + } +} + +function onFinish(ports: WorkflowPorts, result: WorkflowRunResult, runId: string): void { + if (result.status === 'completed') { + const summary = + result.returnValue == null ? '(no return value)' : formatValue(result.returnValue) + ports.taskRegistrar.complete(runId, summary) + } else if (result.status === 'failed') { + ports.taskRegistrar.fail(runId, result.error ?? 'workflow failed') + } else { + ports.taskRegistrar.kill(runId) + } +} + +function formatValue(v: unknown): string { + if (typeof v === 'string') return v.slice(0, 500) + try { + return JSON.stringify(v).slice(0, 500) + } catch { + return String(v) + } +} + +async function resolveScriptSource( + input: WorkflowInput, + cwd: string, +): Promise<{ script: string; workflowFile?: string }> { + if (input.script) return { script: input.script } + if (input.scriptPath) { + return { script: await readFile(input.scriptPath, 'utf-8'), workflowFile: input.scriptPath } + } + if (input.name) { + const found = await resolveNamedWorkflow(join(cwd, WORKFLOW_DIR_NAME), input.name) + if (!found) { + throw new Error(`命名 workflow "${input.name}" 未找到(查找目录 ${WORKFLOW_DIR_NAME}/)`) + } + return { script: found.content, workflowFile: found.path } + } + throw new Error('必须提供 script、name 或 scriptPath 之一') +} +``` + +- [ ] **Step 4:更新 `src/index.ts` 导出工具描述符** + +```ts +export { createWorkflowTool, type WorkflowToolDescriptor } from './tool/WorkflowTool.js' +export { workflowInputSchema } from './tool/schema.js' +export { WORKFLOW_TOOL_NAME } from './tool/constants.js' +``` + +- [ ] **Step 5:运行全包测试 + 类型检查** + +Run: `cd packages/workflow-engine && bun test && bunx tsc --noEmit` +Expected: 全部 PASS,类型零错误。 + +- [ ] **Step 6:提交** + +```bash +git add packages/workflow-engine/src/tool/WorkflowTool.ts packages/workflow-engine/src/__tests__/WorkflowTool.test.ts packages/workflow-engine/src/index.ts +git commit -m "feat(workflow): add self-contained WorkflowTool descriptor" +``` + +> **里程碑:Phase 3 完成。** 包已完整——引擎 + 工具描述符 + 全量单测。剩余为核心侧集成(Phase 4–6)。 + +--- + +## Phase 4:核心侧 adapter 与 wiring + +> 本阶段代码依赖核心层真实 API(`runAgent`/`assembleToolPool`/`finalizeAgentTool`/`LocalWorkflowTask`)。包内逻辑已完全指定;本阶段的 `agentRunner` 涉及若干无法静态核实的集成点(`runAgent` 的 `querySource` 取值、`StructuredOutput` 动态注入、usage 字段),实现时以 `bunx tsc --noEmit` 为准对齐——已在代码中标注。 + +### Task 16:hostHandle 与进度存储 + +**Files:** +- Create: `src/workflow/hostHandle.ts` +- Create: `src/workflow/progressStore.ts` + +- [ ] **Step 1:写 `src/workflow/hostHandle.ts`** + +```ts +import { + createHostHandle, + unwrapHostHandle, + type HostHandle, +} from '@claude-code-best/workflow-engine' +import type { CanUseToolFn } from '../hooks/useCanUseTool.js' +import type { AssistantMessage } from '../types/message.js' +import type { AgentId } from '../types/ids.js' +import type { ToolUseContext } from '../Tool.js' + +/** HostHandle 内含的不透明 bundle(核心侧解包后使用)。 */ +export type WorkflowHostBundle = { + toolUseContext: ToolUseContext + canUseTool: CanUseToolFn + parentMessage: AssistantMessage + agentId: AgentId +} + +export function makeHostHandle(bundle: WorkflowHostBundle): HostHandle { + return createHostHandle(bundle) +} + +export function readHostBundle(handle: HostHandle): WorkflowHostBundle { + return unwrapHostHandle(handle) as WorkflowHostBundle +} +``` + +- [ ] **Step 2:写 `src/workflow/progressStore.ts`** + +```ts +import type { ProgressEvent } from '@claude-code-best/workflow-engine' + +export type AgentProgress = { + label?: string + phase?: string + status: 'running' | 'done' + resultKind?: string +} + +export type RunProgress = { + runId: string + workflowName: string + status: 'running' | 'completed' | 'failed' | 'killed' + phases: Array<{ title: string; status: 'running' | 'done' }> + currentPhase: string | null + agents: AgentProgress[] + logs: string[] + agentCount: number + returnValue?: unknown + error?: string + updatedAt: number +} + +const store = new Map() + +export function getRunProgress(runId: string): RunProgress | undefined { + return store.get(runId) +} + +export function listRunProgresses(): RunProgress[] { + return [...store.values()].sort((a, b) => b.updatedAt - a.updatedAt) +} + +export function removeRunProgress(runId: string): void { + store.delete(runId) +} + +function ensure(runId: string, workflowName: string): RunProgress { + let p = store.get(runId) + if (!p) { + p = { + runId, + workflowName, + status: 'running', + phases: [], + currentPhase: null, + agents: [], + logs: [], + agentCount: 0, + updatedAt: Date.now(), + } + store.set(runId, p) + } + return p +} + +/** 把引擎进度事件应用到 store。 */ +export function applyProgressEvent(event: ProgressEvent): void { + const runId = event.runId + const p = ensure(runId, 'workflowName' in event ? event.workflowName : 'workflow') + p.updatedAt = Date.now() + + switch (event.type) { + case 'run_started': + p.workflowName = event.workflowName + p.status = 'running' + break + case 'phase_done': + for (const ph of p.phases) { + if (ph.title === event.phase) ph.status = 'done' + } + if (p.currentPhase === event.phase) p.currentPhase = null + break + case 'phase_started': + if (!p.phases.some(ph => ph.title === event.phase)) { + p.phases.push({ title: event.phase, status: 'running' }) + } + p.currentPhase = event.phase + break + case 'agent_started': + p.agents.push({ label: event.label, phase: event.phase, status: 'running' }) + p.agentCount++ + break + case 'agent_done': + for (let i = p.agents.length - 1; i >= 0; i--) { + if (p.agents[i]!.status === 'running') { + p.agents[i]!.status = 'done' + p.agents[i]!.resultKind = event.result.kind + break + } + } + break + case 'log': + p.logs.push(event.message) + break + case 'run_done': + p.status = event.status + if (event.returnValue !== undefined) p.returnValue = event.returnValue + if (event.error !== undefined) p.error = event.error + break + } +} +``` + +- [ ] **Step 3:类型检查** + +Run: `bunx tsc --noEmit` +Expected: 零错误(若有 `CanUseToolFn` 路径或 `AgentId` 导入问题,按实际路径修正)。 + +- [ ] **Step 4:提交** + +```bash +git add src/workflow/hostHandle.ts src/workflow/progressStore.ts +git commit -m "feat(workflow): add core-side host handle & progress store" +``` + +--- + +### Task 17:adapter(端口实现) + +**Files:** +- Create: `src/workflow/adapter.ts` + +- [ ] **Step 1:写 `src/workflow/adapter.ts`** + +```ts +import { + createFileJournalStore, + type AgentRunParams, + type AgentRunResult, + type ProgressEvent, + type WorkflowHostContext, + type WorkflowPorts, +} from '@claude-code-best/workflow-engine' +import { getCwd } from '../utils/cwd.js' +import { logForDebugging } from '../utils/debug.js' +import { getProjectRoot } from '../bootstrap/state.js' +import { logEvent } from '../services/analytics/index.js' +import { assembleToolPool } from '../tools.js' +import { finalizeAgentTool } from '../../packages/builtin-tools/src/tools/AgentTool/agentToolUtils.js' +import { runAgent } from '../../packages/builtin-tools/src/tools/AgentTool/runAgent.js' +import { isBuiltInAgent, type AgentDefinition } from '../../packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.js' +import { createUserMessage, extractTextContent } from '../utils/messages.js' +import type { Message } from '../types/message.js' +import { + registerLocalWorkflowTask, + completeWorkflowTask, + failWorkflowTask, + killWorkflowTask, +} from '../tasks/LocalWorkflowTask/LocalWorkflowTask.js' +import { makeHostHandle, readHostBundle, type WorkflowHostBundle } from './hostHandle.js' +import { applyProgressEvent, removeRunProgress } from './progressStore.js' + +/** workflow 子 agent 的缺省定义(通用研究/执行 agent)。 */ +const WORKFLOW_AGENT: AgentDefinition = { + agentType: 'workflow-worker', + whenToUse: 'workflow 脚本内 agent() 钩子派发的子任务', + tools: ['*'], + source: 'built-in', + baseDir: 'built-in', + getSystemPrompt: () => + 'You are a workflow sub-agent. Complete the task concisely; your final text is the return value relayed to the workflow.', +} as unknown as AgentDefinition + +type RunBinding = { + runId: string + taskId: string + setAppState: (f: (prev: import('../state/AppState.js').AppState) => import('../state/AppState.js').AppState) => void + abortController: AbortController + workflowName: string +} + +/** 每次工具调用从 toolUseContext 构造 WorkflowHostContext。 */ +function makeHostFactory(): WorkflowPorts['hostFactory'] { + return ({ context, canUseTool, parentMessage }): WorkflowHostContext => { + const ctx = context as import('../Tool.js').ToolUseContext + return { + handle: makeHostHandle({ + toolUseContext: ctx, + canUseTool: canUseTool as WorkflowHostBundle['canUseTool'], + parentMessage: parentMessage as WorkflowHostBundle['parentMessage'], + agentId: ctx.agentId!, + }), + cwd: getCwd(), + budgetTotal: null, // v1:无 turn 级预算注入点;engine 支持 budget 但此处 null + toolUseId: ctx.toolUseId, + } + } +} + +function resolveAgentDefinition( + agentType: string | undefined, + toolUseContext: import('../Tool.js').ToolUseContext, +): AgentDefinition { + if (!agentType) return WORKFLOW_AGENT + const found = toolUseContext.options.agentDefinitions.activeAgents.find( + a => a.agentType === agentType, + ) + return found ?? WORKFLOW_AGENT +} + +async function runWorkflowSubAgent( + params: AgentRunParams, + host: import('@claude-code-best/workflow-engine').HostHandle, +): Promise { + const bundle = readHostBundle(host) + const { toolUseContext, canUseTool, agentId } = bundle + const appState = toolUseContext.getAppState() + const agentDef = resolveAgentDefinition(params.agentType, toolUseContext) + + const workerPermissionContext = { + ...appState.toolPermissionContext, + mode: agentDef.permissionMode ?? 'acceptEdits', + } + const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools) + + // schema → 通过 appendSystemPrompt 传 JSON Schema 指令;非交互模式下 StructuredOutput 已启用。 + // (完整动态 schema 注入需扩展 SyntheticOutputTool;v1 用指令 + 结果侧校验。) + const promptText = params.schema + ? `${params.prompt}\n\nYou MUST return your final answer by calling the StructuredOutput tool with a value matching this JSON Schema:\n${JSON.stringify(params.schema)}` + : params.prompt + + const promptMessages = [createUserMessage({ content: promptText })] + const messages: Message[] = [] + const startTime = Date.now() + + try { + for await (const msg of runAgent({ + agentDefinition: agentDef, + promptMessages, + toolUseContext, + canUseTool, + isAsync: true, + querySource: (toolUseContext.options.querySource ?? 'main') as never, + availableTools: workerTools, + ...(params.model ? ({ model: params.model } as never) : {}), + })) { + messages.push(msg as Message) + } + } catch (e) { + logForDebugging(`workflow sub-agent error: ${(e as Error).message}`) + return { kind: 'dead' } + } + + const resolvedAgentModel = toolUseContext.options.mainLoopModel + const finalized = finalizeAgentTool(messages, agentId, { + prompt: params.prompt, + resolvedAgentModel, + isBuiltInAgent: isBuiltInAgent(agentDef), + startTime, + agentType: agentDef.agentType, + isAsync: true, + }) + const outputTokens = finalized.usage?.output_tokens ?? finalized.totalTokens ?? 0 + + if (params.schema) { + const structured = extractStructuredOutput(finalized.content, params.schema) + if (structured === null) return { kind: 'dead' } + return { kind: 'ok', output: structured, usage: { outputTokens } } + } + const text = extractTextContent(finalized.content, '\n') + return { kind: 'ok', output: text, usage: { outputTokens } } +} + +/** 从 agent 最终消息中提取 StructuredOutput 工具产出的 JSON 对象;校验失败返回 null。 */ +function extractStructuredOutput( + content: Array<{ type: string; text?: string }>, + _schema: object, +): unknown | null { + // StructuredOutput 的结果在 finalizeAgentTool 后通常已展平为 text 块(JSON 字符串)。 + // 尝试把首个 text 块解析为 JSON;解析失败返回 null(engine 据此返回 dead→null)。 + for (const block of content) { + if (block.type === 'text' && block.text) { + const trimmed = block.text.trim() + const start = trimmed.indexOf('{') + const end = trimmed.lastIndexOf('}') + if (start >= 0 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)) + } catch { + // 继续 + } + } + } + } + return null +} + +/** 构造完整端口集。adapter 维护 runId → RunBinding 映射供 progress/kill 路由。 */ +export function createWorkflowAdapter(): WorkflowPorts { + const bindings = new Map() + const runsDir = `${getProjectRoot()}/.claude/workflow-runs` + + return { + hostFactory: makeHostFactory(), + + agentRunner: { + runAgentToResult: runWorkflowSubAgent, + }, + + progressEmitter: { + emit(event: ProgressEvent) { + applyProgressEvent(event) + }, + }, + + taskRegistrar: { + register(opts, host) { + const bundle = readHostBundle(host) + const setAppState = bundle.toolUseContext.setAppStateForTasks ?? bundle.toolUseContext.setAppState + const abortController = new AbortController() + const taskId = registerLocalWorkflowTask(setAppState, { + description: opts.summary ?? opts.workflowName, + workflowName: opts.workflowName, + workflowFile: opts.workflowFile ?? '', + summary: opts.summary, + ...(opts.toolUseId ? { toolUseId: opts.toolUseId } : {}), + abortController, + }) + const runId = opts.runId ?? taskId + bindings.set(runId, { runId, taskId, setAppState, abortController, workflowName: opts.workflowName }) + logEvent('tengu_workflow_started' as never, { workflow: opts.workflowName } as never) + return { runId, signal: abortController.signal } + }, + + complete(runId, summary) { + const b = bindings.get(runId) + if (!b) return + completeWorkflowTask(b.taskId, b.setAppState) + logForDebugging(`workflow ${runId} completed: ${summary ?? ''}`) + }, + + fail(runId, error) { + const b = bindings.get(runId) + if (!b) return + failWorkflowTask(b.taskId, b.setAppState) + logForDebugging(`workflow ${runId} failed: ${error}`) + }, + + kill(runId) { + const b = bindings.get(runId) + if (!b) return + killWorkflowTask(b.taskId, b.setAppState) + }, + + pendingAction(runId) { + const b = bindings.get(runId) + if (!b) return null + // LocalWorkflowTaskState.pendingAgentAction 由 UI 写入;这里只读。 + const tasks = (bundle_getAppState(b) as { tasks?: Record }).tasks + const task = tasks?.[b.taskId] as { pendingAgentAction?: { kind: 'skip' | 'retry' } } | undefined + return task?.pendingAgentAction ?? null + }, + }, + + journalStore: createFileJournalStore(runsDir), + + permissionGate: { + // 引擎实际用 ctx.signal(register 返回的 AbortController)判定 abort;此端口保留为契约占位。 + isAborted: () => false, + }, + + logger: { + debug: msg => logForDebugging(msg), + event: (name, metadata) => logEvent(name as never, (metadata ?? {}) as never), + }, + } +} + +// pendingAction 需要读 AppState;通过 binding 的 setAppState 不可读,故从 host bundle 侧获取。 +// 这里用一个轻量 helper 复用:注册时已无 host,因此 pendingAction 改为读 LocalWorkflowTask 的全局任务表。 +function bundle_getAppState(b: RunBinding): unknown { + // setAppState 是 setter;为读取任务状态,依赖 progressStore 已记录的进度即可, + // pendingAction 的真实读取在 wiring 阶段如需可扩展。v1 返回 null(skip/retry UI 暂不接线)。 + void b + return { tasks: {} } +} +``` + +> **集成对齐提示(实现时以 `bunx tsc --noEmit` 为准):** +> 1. `runAgent` 的 `querySource` 真实联合类型——`?? 'main'` 若不在类型内,改用 `'agent:builtin:workflow-worker'` 或 `toolUseContext.options.querySource` 的实际类型。 +> 2. `finalizeAgentTool` 的 `content`/`usage` 字段名以 `agentToolUtils.ts` 实际导出为准(`usage.output_tokens` vs `totalTokens`)。 +> 3. `extractTextContent` 第二参数(分隔符)签名以 `utils/messages.ts` 为准。 +> 4. `registerLocalWorkflowTask` 的 opts 形状以 `LocalWorkflowTask.ts` 现有导出为准(已核实含 description/workflowName/workflowFile/summary/toolUseId/abortController)。 +> 5. `pendingAction` 的 v1 实现返回 null(skip/retry UI 接线留作后续);若要接,从 `bundle.toolUseContext.getAppState().tasks[taskId].pendingAgentAction` 读。 + +- [ ] **Step 2:类型检查并按提示对齐** + +Run: `bunx tsc --noEmit 2>&1 | grep -E "adapter\.ts" | head -40` +Expected: 逐步修正至零错误。 + +- [ ] **Step 3:提交** + +```bash +git add src/workflow/adapter.ts +git commit -m "feat(workflow): add core adapter implementing workflow-engine ports" +``` + +--- + +### Task 18:wiring 与 tools.ts 注册 + +**Files:** +- Create: `src/workflow/wiring.ts` +- Modify: `src/tools.ts:152-159` + +- [ ] **Step 1:写 `src/workflow/wiring.ts`** + +```ts +import { + createWorkflowAdapter, +} from './adapter.js' +import { + createWorkflowTool, + type WorkflowToolDescriptor, +} from '@claude-code-best/workflow-engine' +import { buildTool, type Tool, type ToolDef } from '../Tool.js' +import { z } from 'zod/v4' + +/** + * 把包的自包含描述符适配为 buildTool 兼容的 Tool。 + * 描述符的 call 签名 (input, context, canUseTool, parentMessage, onProgress) 与 Tool.call 一致。 + */ +export function createWorkflowToolCore(): Tool { + const adapter = createWorkflowAdapter() + const descriptor: WorkflowToolDescriptor = createWorkflowTool(adapter) + + const def: ToolDef = { + name: descriptor.name, + inputSchema: descriptor.inputSchema as unknown as z.ZodType, + isEnabled: () => descriptor.isEnabled(), + isReadOnly: input => descriptor.isReadOnly(input as never), + isConcurrencySafe: () => true, + async description() { + return descriptor.description() + }, + async prompt() { + return descriptor.prompt() + }, + async call(input, context, canUseTool, parentMessage, onProgress) { + const result = await descriptor.call(input, context, canUseTool, parentMessage, onProgress) + return { data: result.data } as never + }, + renderToolUseMessage: (input: Partial<{ name?: string; scriptPath?: string; script?: string; resumeFromRunId?: string }>) => + descriptor.renderToolUseMessage(input as never), + mapToolResultToToolResultBlockParam: (data: { output: string }, toolUseId: string) => + descriptor.mapToolResultToToolResultBlockParam(data, toolUseId), + } + + return buildTool(def) +} +``` + +> **集成对齐提示:** `Tool.call` 返回 `ToolResult`,描述符返回 `{ data: { output } }`。若 `ToolResult` 形状不同(如需 `result` 字段),按 `src/Tool.ts` 的 `ToolResult` 类型对齐 `as never` 处。`renderToolUseMessage`/`mapToolResultToToolResultBlockParam` 的签名以 `Tool.ts` 实际定义为准。 + +- [ ] **Step 2:修改 `src/tools.ts` 注册块** + +把现有的(约 152-159 行): + +```ts +const WorkflowTool = feature('WORKFLOW_SCRIPTS') + ? (() => { + require('@claude-code-best/builtin-tools/tools/WorkflowTool/bundled/index.js').initBundledWorkflows() + return require('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowTool.js') + .WorkflowTool + })() + : null +``` + +替换为: + +```ts +/* eslint-disable @typescript-eslint/no-require-imports */ +const WorkflowTool = feature('WORKFLOW_SCRIPTS') + ? require('./workflow/wiring.js').createWorkflowToolCore() + : null +/* eslint-enable @typescript-eslint/no-require-imports */ +``` + +- [ ] **Step 3:类型检查** + +Run: `bunx tsc --noEmit` +Expected: 零错误(按提示对齐签名)。 + +- [ ] **Step 4:提交** + +```bash +git add src/workflow/wiring.ts src/tools.ts +git commit -m "feat(workflow): wire workflow-engine into tools.ts via adapter" +``` + +--- + +## Phase 5:命名 workflow 命令与进度查看器 + +### Task 19:命名 workflow 斜杠命令 + +**Files:** +- Create: `src/workflow/namedWorkflowCommands.ts` +- Modify: `src/commands/workflows/index.ts`(改为引用新命令 + 进度查看) + +- [ ] **Step 1:写 `src/workflow/namedWorkflowCommands.ts`** + +```ts +import { join } from 'node:path' +import { + listNamedWorkflows, + WORKFLOW_DIR_NAME, +} from '@claude-code-best/workflow-engine' +import type { Command } from '../types/command.js' +import { getCwd } from '../utils/cwd.js' + +/** 扫描 .claude/workflows/ 下 *.ts|*.js|*.mjs,每个生成一个 / 命令。 */ +export async function getWorkflowCommands( + cwd: string = getCwd(), +): Promise { + const dir = join(cwd, WORKFLOW_DIR_NAME) + const names = await listNamedWorkflows(dir) + return names.map(name => ({ + type: 'prompt' as const, + name, + description: `Run workflow: ${name}`, + kind: 'workflow' as const, + source: 'builtin' as const, + progressMessage: `Running workflow ${name}...`, + contentLength: 0, + async getPromptForCommand(args, _context) { + const argText = typeof args === 'string' && args ? `\n\nArguments: ${args}` : '' + return [ + { + type: 'text' as const, + text: `Run the "${name}" workflow now by calling the Workflow tool with name="${name}".${argText}`, + }, + ] + }, + })) +} +``` + +> 注:`Command` 类型字段以 `src/types/command.ts` 为准;若 `getPromptForCommand` 签名或 `kind` 字面量不符,按实际类型对齐。 + +- [ ] **Step 2:改写 `src/commands/workflows/index.ts` 为命令清单 + 进度查看入口** + +```ts +import type { Command, LocalCommandCall } from '../../types/command.js' +import { getWorkflowCommands } from '../../workflow/namedWorkflowCommands.js' +import { listRunProgresses } from '../../workflow/progressStore.js' +import { getCwd } from '../../utils/cwd.js' + +const call: LocalCommandCall = async _args => { + const commands = await getWorkflowCommands(getCwd()) + const runs = listRunProgresses() + + const lines: string[] = [] + if (runs.length > 0) { + lines.push('Workflow runs (live):') + for (const r of runs.slice(0, 20)) { + lines.push( + ` ${r.runId} | ${r.workflowName} | ${r.status} | phase=${r.currentPhase ?? '-'} | agents=${r.agentCount}`, + ) + } + lines.push('') + } + if (commands.length === 0) { + lines.push('No named workflows. Add scripts to .claude/workflows/ (*.ts/*.js/*.mjs).') + } else { + lines.push('Named workflows:') + for (const cmd of commands) lines.push(` /${cmd.name} - ${cmd.description}`) + } + return { type: 'text', value: lines.join('\n') } +} + +const workflows = { + type: 'local', + name: 'workflows', + description: 'List workflow runs (live progress) and named workflows', + supportsNonInteractive: true, + load: () => Promise.resolve({ call }), +} satisfies Command + +export default workflows +``` + +- [ ] **Step 3:类型检查 + 提交** + +Run: `bunx tsc --noEmit` +Expected: 零错误。 + +```bash +git add src/workflow/namedWorkflowCommands.ts src/commands/workflows/index.ts +git commit -m "feat(workflow): named-workflow slash commands & /workflows viewer" +``` + +--- + +## Phase 6:文件迁移与验证 + +### Task 20:迁移权限 UI 与常量 re-export + +**Files:** +- Move: `packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx` → `src/workflow/WorkflowPermissionRequest.tsx` +- Modify: `src/constants/tools.ts`(WORKFLOW_TOOL_NAME 导入路径) +- Modify: `packages/builtin-tools/src/index.ts`(re-export 指向新包) + +- [ ] **Step 1:移动权限 UI 并修正相对导入** + +```bash +git mv packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx src/workflow/WorkflowPermissionRequest.tsx +``` + +移动后,文件内的相对导入(`src/components/permissions/...`、`src/utils/...`)仍以 `src/*` 别名或 `../../` 解析。从 `src/workflow/` 出发,`src/components/...` 别名导入不变;若有 `../../components` 形式的相对导入,改为 `../components`。打开文件确认导入路径正确。 + +- [ ] **Step 2:`src/constants/tools.ts` 改导入源** + +把: + +```ts +import { WORKFLOW_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/WorkflowTool/constants.js' +``` + +改为: + +```ts +import { WORKFLOW_TOOL_NAME } from '@claude-code-best/workflow-engine' +``` + +- [ ] **Step 3:`packages/builtin-tools/src/index.ts` re-export 指向新包** + +把现有的: + +```ts +export { WorkflowTool } from './tools/WorkflowTool/WorkflowTool.js' +export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js' +export { getWorkflowCommands } from './tools/WorkflowTool/createWorkflowCommand.js' +``` + +改为(向后兼容:从新包 re-export): + +```ts +export { + WORKFLOW_TOOL_NAME, + createWorkflowTool, +} from '@claude-code-best/workflow-engine' +``` + +并删除 `getWorkflowCommands` 旧导出(核心侧改用 `src/workflow/namedWorkflowCommands.ts`)。若其他文件仍 import 旧路径,全局搜索修正。 + +- [ ] **Step 4:类型检查** + +Run: `bunx tsc --noEmit` +Expected: 零错误(修正所有仍指向旧 builtin-tools WorkflowTool 路径的 import)。 + +- [ ] **Step 5:提交** + +```bash +git add -A +git commit -m "refactor(workflow): move permission UI & repoint constants to workflow-engine" +``` + +--- + +### Task 21:清理旧清单版文件 + precheck + +**Files:** +- Delete: `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts` +- Delete: `packages/builtin-tools/src/tools/WorkflowTool/constants.ts` +- Delete: `packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts` +- Delete: `packages/builtin-tools/src/tools/WorkflowTool/__tests__/WorkflowTool.test.ts` +- Delete or keep: `packages/builtin-tools/src/tools/WorkflowTool/bundled/index.ts`(保留为 no-op 扩展点) +- Delete: `src/utils/workflowRuns.ts`(被 progressStore + 包 JournalStore 取代;若无其他引用) + +- [ ] **Step 1:全局搜索旧引用** + +Run: `grep -rn "tools/WorkflowTool/WorkflowTool\|tools/WorkflowTool/constants\|tools/WorkflowTool/createWorkflowCommand\|utils/workflowRuns" src/ packages/ --include="*.ts" --include="*.tsx" | grep -v node_modules` +Expected: 仅剩待删文件自身。若有其他引用,先修正到新路径。 + +- [ ] **Step 2:删除旧文件** + +```bash +git rm packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts \ + packages/builtin-tools/src/tools/WorkflowTool/constants.ts \ + packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts \ + packages/builtin-tools/src/tools/WorkflowTool/__tests__/WorkflowTool.test.ts +# workflowRuns.ts 若无引用也删: +git rm src/utils/workflowRuns.ts +``` + +> 若 `bundled/index.ts` 的 `initBundledWorkflows` 仍被任何 require 引用(Task 18 已移除 tools.ts 中的调用),保留该文件作为 no-op 即可;否则一并删除并在 index.ts 去掉 re-export。 + +- [ ] **Step 3:运行 precheck(typecheck + lint fix + test)** + +Run: `bun run precheck` +Expected: 零错误。 + +- 常见修正点: + - 包内测试若因 `zod/v4` 的 `z.unknown().optional()` 报错,改 `z.any().optional()`。 + - adapter 的 `querySource`/`usage` 字段按 Task 17 提示对齐。 + - 若 `core-tools` 白名单测试(`src/constants/__tests__/tools.test.ts`)断言 `workflow` 在/不在 `CORE_TOOLS`,按 `feature('WORKFLOW_SCRIPTS')` 开关下的预期对齐。 + +- [ ] **Step 4:dev 冒烟(feature 开启)** + +Run: `FEATURE_WORKFLOW_SCRIPTS=1 bun run dev` +然后在 REPL 中: +1. `/workflows` —— 应显示「No named workflows」+ 提示。 +2. 创建 `.claude/workflows/demo.ts`:`export const meta = { name: 'demo', description: 'd' }\nreturn agent('say hello in one word')`。 +3. 让模型调用 Workflow 工具 `name="demo"` —— 应返回 run_id,后台执行,完成时通知。 +4. `/workflows` —— 应看到该 run 的状态。 + +Expected: 后台执行完成、通知到达、`/workflows` 显示进度。 + +- [ ] **Step 5:最终提交** + +```bash +git add -A +git commit -m "chore(workflow): remove legacy checklist WorkflowTool, precheck passes" +``` + +--- + +## 自审(Self-Review) + +**1. Spec 覆盖:** +- 依赖倒置架构 + 6 端口 + HostHandle → Task 4(ports)、Task 16-18(adapter/wiring)。✓ +- async 函数包装 + Date/Math 沙箱 → Task 6(script)。✓ +- 全钩子(agent/parallel/pipeline/phase/log/workflow)→ Task 12(hooks)、Task 13(runWorkflow 嵌套)。✓ +- 并发上限(16/1000/4096)→ Task 5 + hooks 内 MAX_TOTAL_AGENTS/MAX_ITEMS_PER_CALL。✓ +- journal/resume(顺序重放、脚本变更全重跑)→ Task 7(journal)、Task 12(命中/发散)、Task 13(resume)。✓ +- token budget 硬上限 → Task 8(budget)、Task 12(agent 前置 assertCanSpend)。✓ +- schema 结构化输出 → Task 9(校验)、Task 17(adapter 注入指令 + 提取)。✓ +- 进度流 → Task 11(events)、Task 16(progressStore)、Task 19(/workflows)。✓ +- 后台任务生命周期 → Task 17(taskRegistrar 委托 LocalWorkflowTask)。✓ +- named workflow + `/` + `/workflows` 进度查看 → Task 19。✓ +- 文件迁移 → Task 20-21。✓ +- worktree 隔离(`isolation:'worktree'`):opts 透传至 AgentRunParams,adapter 在 Task 17 预留(`agentDef.isolation` 或 runAgent worktreePath)——**部分覆盖**:v1 未在 adapter 接 worktree 创建,作为后续增强(design 第 10 节已列为风险边界)。 + +**2. Placeholder 扫描:** 包内(Phase 0–3)所有步骤含完整可运行代码,无 TBD。核心侧(Phase 4)`adapter.ts`/`wiring.ts` 含真实结构与导入,但标注 5 处「以 typecheck 为准」的集成对齐点(querySource 联合类型、usage 字段名、ToolResult 形状等)——这些是对真实 API 表面的对齐,非逻辑占位;逻辑(端口映射、事件路由、journal/resume)已完整指定,由 precheck 收口。 + +**3. 类型一致性:** 已统一修正—— +- `TaskRegistrar.register(opts, host) → { runId, signal }`(Task 4 描述符 Task 15 一致调用)。 +- `WorkflowHostContext = { handle, cwd, budgetTotal, toolUseId? }`(无 signal)。 +- `ProgressEvent` 所有变体携带 `runId`(hooks 用 `emit` helper 注入,run_done 显式带)。 +- `AgentRunResult` 联合(ok/skipped/dead)在 hooks/journal/adapter 一致。 + +--- + +## 执行交接 + +计划已保存至 `docs/superpowers/plans/2026-06-12-workflow-engine.md`。两种执行方式: + +**1. Subagent 驱动(推荐)** —— 每个任务派发独立子 agent,任务间 review,快速迭代。REQUIRED SUB-SKILL:`superpowers:subagent-driven-development`。 + +**2. 内联执行** —— 在本会话用 `superpowers:executing-plans` 批量执行,带检查点 review。 + +> **建议节奏:** Phase 0–3(包)适合 subagent 逐任务 TDD;Phase 4–6(核心集成)建议内联执行以便即时对齐 typecheck 提示。先执行到 Phase 3 里程碑(包独立可测)做一次整体 review,再推进集成。 + +--- diff --git a/docs/superpowers/plans/2026-06-13-workflow-panel-redesign.md b/docs/superpowers/plans/2026-06-13-workflow-panel-redesign.md new file mode 100644 index 000000000..8dd986504 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-workflow-panel-redesign.md @@ -0,0 +1,1170 @@ +# Workflow Panel Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 `/workflows` 面板从双栏(左 run 列表 / 右 phase+agent)原地重写为三区焦点模型(顶 run tab + 左 phase 筛选侧栏 + 右 agent 列表),零引擎改动。 + +**Architecture:** `run_started` 事件已携带 `meta.phases`,store 落地 `declaredPhases` 即可显示 pending phase。面板拆成 `TabsBar` / `PhaseSidebar` / `AgentList` + 共享 `status.ts`(状态→字符/颜色)与 `selectors.ts`(合并/过滤纯函数),`WorkflowsPanel` 持焦点状态机(activeRunId / focusColumn / selectedPhaseIndex / selectedAgentIndex),`useWorkflowKeyboard` 改焦点轮转键位。 + +**Tech Stack:** TypeScript strict、React/`@anthropic/ink`、`bun:test`、Biome。无 ink-testing-library——测试走纯函数 + 数据契约路线(与现有 `WorkflowsPanel.test.tsx` 一致)。 + +--- + +## 项目约定(覆盖 skill 默认,执行前必读) + +1. **提交规则(CLAUDE.md)**:`git commit` 仅在用户明确要求时执行。下方每个 Task 末尾的 "Commit" 步骤是**逻辑切分点**(该 task 自洽、可独立提交)——实际是否真正 `git commit` 由用户在执行时决定。默认:完成一个 Task 后**不自动 commit**,改在每个里程碑(Task 3 / Task 7)结束统一问用户。 +2. **测试策略**:项目**未引入** `ink-testing-library`(grep 全 `src/` 无结果)。组件**不写渲染测试**。所有可测逻辑必须抽成**纯函数**(`status.ts` / `selectors.ts` / `routeWorkflowKey`)并 TDD;组件只保证 `tsc` + `biome` 通过。 +3. **类型规范**:生产代码禁 `as any`;`.tsx` 120 行宽 + 强制分号;`.ts` 80 行宽 + 按需分号。`feature()` 仅用在 `if`/三元条件位(本计划不涉及 feature flag)。 +4. **Mock 规范**:本计划涉及的 store/纯函数测试**无需 mock**(纯逻辑)。若后续集成测试需要,用共享 `tests/mocks/log.ts` / `debug.ts`,mock 底层副作用而非业务模块。 +5. **每 Task 结束**:`bun run precheck` 必须零错误(typecheck + lint:fix + test)。 + +## 文件结构 + +| 文件 | 动作 | 职责 | +|---|---|---| +| `src/workflow/progress/store.ts` | 改 | `RunProgress.declaredPhases` + `AgentProgress.outputShape`;reducer 落地 | +| `src/workflow/panel/status.ts` | 新建 | 状态→字符/颜色映射(`STATUS_DOT` 从 `WorkflowList` 迁入)+ `agentVisual` | +| `src/workflow/panel/selectors.ts` | 新建 | `mergePhases` / `filterAgentsByPhase` / `tabLabel` 纯函数 | +| `src/workflow/panel/useWorkflowKeyboard.ts` | 改写 | `routeWorkflowKey` 纯函数 + 焦点模型 handlers | +| `src/workflow/panel/TabsBar.tsx` | 新建 | 顶部 run tab 行 | +| `src/workflow/panel/PhaseSidebar.tsx` | 新建 | 左 phase 列表(含 All + pending) | +| `src/workflow/panel/AgentList.tsx` | 新建 | 右 agent 列表(按 phase 过滤) | +| `src/workflow/panel/WorkflowsPanel.tsx` | 重写 | 焦点状态机 + 组装;保留导出 `clampSelected` | +| `src/workflow/panel/WorkflowList.tsx` | 删除 | 职责迁入 `TabsBar` + `status.ts` | +| `src/workflow/panel/WorkflowDetail.tsx` | 删除 | 职责拆入 `PhaseSidebar` + `AgentList` | +| `src/workflow/__tests__/WorkflowsPanel.test.tsx` | 改 | `STATUS_DOT` import 改从 `status.js`;保留 `clampSelected` 契约 | +| `src/workflow/__tests__/progressStore.test.ts` | 改 | 加 `declaredPhases` / `outputShape` 用例 | +| `src/workflow/__tests__/status.test.ts` | 新建 | 状态映射 + `agentVisual` | +| `src/workflow/__tests__/selectors.test.ts` | 新建 | `mergePhases` / `filterAgentsByPhase` / `tabLabel` | +| `src/workflow/__tests__/useWorkflowKeyboard.test.ts` | 新建 | `routeWorkflowKey` | +| `docs/features/workflow-scripts.md` | 改 | §六 更新三区布局/键位 | + +--- + +## Task 1: store 落地 `declaredPhases` + `outputShape` + +**Files:** +- Modify: `src/workflow/progress/store.ts:4-11`(`AgentProgress`)、`store.ts:13-24`(`RunProgress`)、`store.ts:46-62`(`ensure`)、`store.ts:78-83`(`run_started`)、`store.ts:107-123`(`agent_done`) +- Test: `src/workflow/__tests__/progressStore.test.ts` + +- [ ] **Step 1: 在 `progressStore.test.ts` 末尾追加失败测试** + +```ts +test('run_started 落地 declaredPhases(来自 meta.phases,顺序保留)', () => { + const { bus, store } = newStore() + bus.emit({ + type: 'run_started', + runId: 'r1', + workflowName: 'w', + meta: { + name: 'w', + description: 'd', + phases: [{ title: 'Find' }, { title: 'Review' }, { title: 'Verify' }], + }, + }) + expect(store.get('r1')!.declaredPhases).toEqual(['Find', 'Review', 'Verify']) +}) + +test('run_started meta 为 null → declaredPhases = []', () => { + const { bus, store } = newStore() + bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) + expect(store.get('r1')!.declaredPhases).toEqual([]) +}) + +test('agent_done 落地 outputShape(ok·object / ok·text / dead 无)', () => { + const { bus, store } = newStore() + bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) + bus.emit({ type: 'agent_started', runId: 'r1', agentId: 0, phase: 'A' }) + bus.emit({ type: 'agent_started', runId: 'r1', agentId: 1, phase: 'A' }) + bus.emit({ type: 'agent_started', runId: 'r1', agentId: 2, phase: 'A' }) + bus.emit({ + type: 'agent_done', runId: 'r1', agentId: 0, phase: 'A', + result: { kind: 'ok', output: { x: 1 }, usage: { outputTokens: 1 } }, + }) + bus.emit({ + type: 'agent_done', runId: 'r1', agentId: 1, phase: 'A', + result: { kind: 'ok', output: 'hi', usage: { outputTokens: 1 } }, + }) + bus.emit({ type: 'agent_done', runId: 'r1', agentId: 2, phase: 'A', result: { kind: 'dead' } }) + const agents = store.get('r1')!.agents + expect(agents.find(a => a.id === 0)?.outputShape).toBe('object') + expect(agents.find(a => a.id === 1)?.outputShape).toBe('text') + expect(agents.find(a => a.id === 2)?.outputShape).toBeUndefined() +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `bun test src/workflow/__tests__/progressStore.test.ts` +Expected: 3 个新用例 FAIL(`declaredPhases` undefined / 无 `outputShape`) + +- [ ] **Step 3: 改 `AgentProgress` 加 `outputShape`(store.ts:4-11)** + +```ts +export type AgentProgress = { + /** 引擎盖戳的唯一 id,精确关联 started/done(修旧 LIFO 竞态)。 */ + id: number + label?: string + phase?: string + status: 'running' | 'done' + resultKind?: string + /** 仅 done·ok 时有意义:output 是对象→'object',否则→'text'。dead/skipped 无。 */ + outputShape?: 'text' | 'object' +} +``` + +- [ ] **Step 4: 改 `RunProgress` 加 `declaredPhases`(store.ts:13-24)** + +```ts +export type RunProgress = { + runId: string + workflowName: string + status: 'running' | 'completed' | 'failed' | 'killed' + phases: Array<{ title: string; status: 'running' | 'done' }> + /** 来自 run_started.meta.phases[].title;面板据此显示 pending(○) phase。无 meta → []。 */ + declaredPhases: string[] + currentPhase: string | null + agents: AgentProgress[] + agentCount: number + returnValue?: unknown + error?: string + updatedAt: number +} +``` + +- [ ] **Step 5: `ensure()` 初始化 `declaredPhases: []`(store.ts:46-62,在 `currentPhase: null,` 上一行加)** + +```ts + phases: [], + declaredPhases: [], + currentPhase: null, +``` + +- [ ] **Step 6: reducer `run_started` 分支落地 `declaredPhases`(store.ts:74-77)** + +```ts + case 'run_started': + p.workflowName = event.workflowName + p.status = 'running' + p.declaredPhases = event.meta?.phases?.map(ph => ph.title) ?? [] + break +``` + +- [ ] **Step 7: reducer `agent_done` 两处落地 `outputShape`(store.ts:107-123)** + +补建分支(`if (!a)` 内)加 `outputShape`: + +```ts + case 'agent_done': { + let a = p.agents.find(x => x.id === event.agentId) + if (!a) { + a = { + id: event.agentId, + label: event.label, + phase: event.phase, + status: 'done', + ...(event.result.kind === 'ok' + ? { + outputShape: + typeof event.result.output === 'object' && + event.result.output !== null + ? ('object' as const) + : ('text' as const), + } + : {}), + } + p.agents.push(a) + p.agentCount = p.agents.length + } else { + a.status = 'done' + a.resultKind = event.result.kind + if (event.result.kind === 'ok') { + a.outputShape = + typeof event.result.output === 'object' && + event.result.output !== null + ? 'object' + : 'text' + } + } + break + } +``` + +- [ ] **Step 8: 跑测试确认通过** + +Run: `bun test src/workflow/__tests__/progressStore.test.ts` +Expected: 全部 PASS(含原有用例——它们 `meta: null` → `declaredPhases: []`,不破坏) + +- [ ] **Step 9: precheck** + +Run: `bun run precheck` +Expected: 零错误 + +- [ ] **Step 10: Commit(逻辑切分点,实际提交待用户确认)** + +```bash +git add src/workflow/progress/store.ts src/workflow/__tests__/progressStore.test.ts +git commit -m "feat(workflow): store 落地 declaredPhases + agent outputShape" +``` + +--- + +## Task 2: 新建 `status.ts`(状态映射 + `agentVisual`) + +**Files:** +- Create: `src/workflow/panel/status.ts` +- Test: `src/workflow/__tests__/status.test.ts` + +- [ ] **Step 1: 写失败测试 `status.test.ts`** + +```ts +import { expect, test } from 'bun:test' +import type { AgentProgress, RunProgress } from '../progress/store.js' +import { + STATUS_DOT, + RUN_STATUS_COLOR, + PHASE_MARK, + PHASE_COLOR, + agentVisual, +} from '../panel/status.js' + +test('STATUS_DOT / RUN_STATUS_COLOR 覆盖四种 run 状态且为非空字符', () => { + const statuses: RunProgress['status'][] = ['running', 'completed', 'failed', 'killed'] + for (const s of statuses) { + expect(STATUS_DOT[s].length).toBeGreaterThan(0) + expect(RUN_STATUS_COLOR[s]).toBeTruthy() + } + expect(STATUS_DOT.running).toBe('●') + expect(STATUS_DOT.completed).toBe('✓') + expect(STATUS_DOT.failed).toBe('✗') + expect(STATUS_DOT.killed).toBe('■') +}) + +test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => { + expect(PHASE_MARK.running).toBe('●') + expect(PHASE_MARK.done).toBe('✓') + expect(PHASE_MARK.pending).toBe('○') + expect(PHASE_COLOR.pending).toBe('subtle') +}) + +test('agentVisual:running → ● warning running', () => { + const a: AgentProgress = { id: 1, status: 'running' } + expect(agentVisual(a)).toEqual({ mark: '●', color: 'warning', suffix: 'running' }) +}) + +test('agentVisual:done·object → ✓ success object', () => { + const a: AgentProgress = { id: 1, status: 'done', resultKind: 'ok', outputShape: 'object' } + expect(agentVisual(a)).toEqual({ mark: '✓', color: 'success', suffix: 'object' }) +}) + +test('agentVisual:done·text → ✓ success text', () => { + const a: AgentProgress = { id: 1, status: 'done', resultKind: 'ok', outputShape: 'text' } + expect(agentVisual(a)).toEqual({ mark: '✓', color: 'success', suffix: 'text' }) +}) + +test('agentVisual:dead → ✗ error dead', () => { + const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' } + expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' }) +}) +``` + +- [ ] **Step 2: 跑测试确认失败(模块不存在)** + +Run: `bun test src/workflow/__tests__/status.test.ts` +Expected: FAIL(无法 import `../panel/status.js`) + +- [ ] **Step 3: 创建 `src/workflow/panel/status.ts`** + +```ts +import type { AgentProgress, RunProgress } from '../progress/store.js' + +/** run 状态 → 圆点字符(顶部 tab 用)。 */ +export const STATUS_DOT: Record = { + running: '●', + completed: '✓', + failed: '✗', + killed: '■', +} + +/** run 状态 → ink theme 颜色 token(沿用现有 WorkflowList 配色)。 */ +export const RUN_STATUS_COLOR: Record = { + running: 'warning', + completed: 'success', + failed: 'error', + killed: 'subtle', +} + +/** phase 在侧栏的合并状态(含 pending:meta 声明但未启动)。 */ +export type PhaseStatus = 'running' | 'done' | 'pending' + +export const PHASE_MARK: Record = { + running: '●', + done: '✓', + pending: '○', +} + +export const PHASE_COLOR: Record = { + running: 'warning', + done: 'success', + pending: 'subtle', +} + +/** agent 行的视觉三件套:标记字符 + 颜色 + 行尾文字后缀。 */ +export type AgentVisual = { mark: string; color: string; suffix: string } + +/** + * agent 状态 → 视觉。 + * - running → ● warning + * - done·dead → ✗ error + * - done·ok:outputShape='object' → object;否则 text + */ +export function agentVisual(a: AgentProgress): AgentVisual { + if (a.status === 'running') return { mark: '●', color: 'warning', suffix: 'running' } + if (a.resultKind === 'dead') return { mark: '✗', color: 'error', suffix: 'dead' } + return { + mark: '✓', + color: 'success', + suffix: a.outputShape === 'object' ? 'object' : 'text', + } +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `bun test src/workflow/__tests__/status.test.ts` +Expected: 全部 PASS + +- [ ] **Step 5: precheck** + +Run: `bun run precheck` +Expected: 零错误 + +- [ ] **Step 6: Commit(逻辑切分点)** + +```bash +git add src/workflow/panel/status.ts src/workflow/__tests__/status.test.ts +git commit -m "feat(workflow): 抽 panel status.ts 状态映射 + agentVisual" +``` + +--- + +## Task 3: 新建 `selectors.ts`(`mergePhases` / `filterAgentsByPhase` / `tabLabel`) + +**Files:** +- Create: `src/workflow/panel/selectors.ts` +- Test: `src/workflow/__tests__/selectors.test.ts` + +- [ ] **Step 1: 写失败测试 `selectors.test.ts`** + +```ts +import { expect, test } from 'bun:test' +import type { AgentProgress, RunProgress } from '../progress/store.js' +import { ALL_PHASE, mergePhases, filterAgentsByPhase, tabLabel } from '../panel/selectors.js' + +function run(partial: Partial): RunProgress { + return { + runId: 'r1', + workflowName: 'w', + status: 'running', + phases: [], + declaredPhases: [], + currentPhase: null, + agents: [], + agentCount: 0, + updatedAt: 1, + ...partial, + } +} + +test('mergePhases:声明顺序优先,实际 phase 追加未声明的,计数 done/total', () => { + const r = run({ + declaredPhases: ['Find', 'Review', 'Verify'], + phases: [ + { title: 'Find', status: 'done' }, + { title: 'Review', status: 'running' }, + ], + agents: [ + { id: 1, phase: 'Find', status: 'done', resultKind: 'ok', outputShape: 'text' }, + { id: 2, phase: 'Find', status: 'done', resultKind: 'dead' }, + { id: 3, phase: 'Review', status: 'running' }, + ], + }) + expect(mergePhases(r)).toEqual([ + { title: 'Find', status: 'done', done: 2, total: 2 }, + { title: 'Review', status: 'running', done: 0, total: 1 }, + { title: 'Verify', status: 'pending', done: 0, total: 0 }, + ]) +}) + +test('mergePhases:实际出现但未声明的 phase 追加到末尾', () => { + const r = run({ + declaredPhases: ['Find'], + phases: [ + { title: 'Find', status: 'done' }, + { title: 'Adhoc', status: 'running' }, + ], + agents: [], + }) + expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc']) +}) + +test('filterAgentsByPhase:All / undefined → 全部;指定 → 仅该 phase', () => { + const agents: AgentProgress[] = [ + { id: 1, phase: 'A', status: 'running' }, + { id: 2, phase: 'B', status: 'done', resultKind: 'ok', outputShape: 'text' }, + ] + expect(filterAgentsByPhase(agents, undefined)).toHaveLength(2) + expect(filterAgentsByPhase(agents, ALL_PHASE)).toHaveLength(2) + expect(filterAgentsByPhase(agents, 'A')).toEqual([agents[0]]) +}) + +test('tabLabel:workflow 名 + runId 后 4 位短码', () => { + expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def') +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `bun test src/workflow/__tests__/selectors.test.ts` +Expected: FAIL(模块不存在) + +- [ ] **Step 3: 创建 `src/workflow/panel/selectors.ts`** + +```ts +import type { AgentProgress, RunProgress } from '../progress/store.js' +import type { PhaseStatus } from './status.js' + +/** 「不筛选」固定项的 title(侧栏第一行)。 */ +export const ALL_PHASE = 'All' + +/** 合并后的 phase(含 pending),带该 phase 下 agent 的 done/total 计数。 */ +export type MergedPhase = { + title: string + status: PhaseStatus + done: number + total: number +} + +/** + * 合并 declaredPhases(meta 声明)与 run.phases(实际 running/done): + * - 声明顺序优先;未在 declared 但实际出现的 phase 追加末尾。 + * - 实际无记录 → pending;否则取实际 status。 + * - done/total = 该 phase 下 done / 全部 agent 数。 + */ +export function mergePhases(run: Pick): MergedPhase[] { + const actualByTitle = new Map(run.phases.map(p => [p.title, p])) + const seen = new Set() + const out: MergedPhase[] = [] + const push = (title: string): void => { + if (seen.has(title)) return + seen.add(title) + const actual = actualByTitle.get(title) + const status: PhaseStatus = !actual ? 'pending' : actual.status + const inPhase = run.agents.filter(a => a.phase === title) + out.push({ + title, + status, + done: inPhase.filter(a => a.status === 'done').length, + total: inPhase.length, + }) + } + for (const t of run.declaredPhases) push(t) + for (const p of run.phases) push(p.title) + return out +} + +/** + * 按选中 phase 筛选 agent。 + * selectedPhase 为 undefined 或 ALL_PHASE → 全部。 + */ +export function filterAgentsByPhase( + agents: AgentProgress[], + selectedPhase: string | undefined, +): AgentProgress[] { + if (selectedPhase === undefined || selectedPhase === ALL_PHASE) return agents + return agents.filter(a => a.phase === selectedPhase) +} + +/** tab 标签:workflow 名 + `#` + runId 末 4 位(同名 run 消歧)。 */ +export function tabLabel(workflowName: string, runId: string): string { + return `${workflowName}#${runId.slice(-4)}` +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `bun test src/workflow/__tests__/selectors.test.ts` +Expected: 全部 PASS + +- [ ] **Step 5: precheck** + +Run: `bun run precheck` +Expected: 零错误 + +- [ ] **Step 6: 里程碑检查点 —— 向用户确认是否提交 Task 1-3** + +完成纯逻辑层(store + status + selectors)。按项目约定,此处询问用户是否提交,再进入组件层。 + +--- + +## Task 4: `useWorkflowKeyboard` 改焦点模型(抽 `routeWorkflowKey` 纯函数) + +**Files:** +- Modify: `src/workflow/panel/useWorkflowKeyboard.ts`(整体改写) +- Test: `src/workflow/__tests__/useWorkflowKeyboard.test.ts` + +- [ ] **Step 1: 写失败测试 `useWorkflowKeyboard.test.ts`** + +```ts +import { expect, test } from 'bun:test' +import { routeWorkflowKey } from '../panel/useWorkflowKeyboard.js' + +test('Tab → nextTab;Shift+Tab → prevTab', () => { + expect(routeWorkflowKey('', { tab: true })).toBe('nextTab') + expect(routeWorkflowKey('', { tab: true, shift: true })).toBe('prevTab') +}) + +test('q / Esc → quit', () => { + expect(routeWorkflowKey('q', {})).toBe('quit') + expect(routeWorkflowKey('', { escape: true })).toBe('quit') +}) + +test('x → kill;r → resume;n → newRun', () => { + expect(routeWorkflowKey('x', {})).toBe('kill') + expect(routeWorkflowKey('r', {})).toBe('resume') + expect(routeWorkflowKey('n', {})).toBe('newRun') +}) + +test('←/→ 切焦点列;↑/↓ 列内移动', () => { + expect(routeWorkflowKey('', { leftArrow: true })).toBe('focusLeft') + expect(routeWorkflowKey('', { rightArrow: true })).toBe('focusRight') + expect(routeWorkflowKey('', { upArrow: true })).toBe('moveUp') + expect(routeWorkflowKey('', { downArrow: true })).toBe('moveDown') +}) + +test('无关输入 → null', () => { + expect(routeWorkflowKey('z', {})).toBeNull() + expect(routeWorkflowKey('', {})).toBeNull() +}) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `bun test src/workflow/__tests__/useWorkflowKeyboard.test.ts` +Expected: FAIL(`routeWorkflowKey` 不存在) + +- [ ] **Step 3: 整体改写 `src/workflow/panel/useWorkflowKeyboard.ts`** + +```ts +import { useInput } from '@anthropic/ink' + +/** 焦点所在列。 */ +export type FocusColumn = 'phases' | 'agents' + +/** useInput 的 key 对象子集(仅声明用到的字段,避免耦合 ink Key 类型)。 */ +type KeyEvent = { + tab?: boolean + shift?: boolean + escape?: boolean + leftArrow?: boolean + rightArrow?: boolean + upArrow?: boolean + downArrow?: boolean +} + +/** 键 → 动作(纯函数,便于单测;无渲染依赖)。 */ +export type WorkflowKeyAction = + | 'nextTab' + | 'prevTab' + | 'focusLeft' + | 'focusRight' + | 'moveUp' + | 'moveDown' + | 'kill' + | 'resume' + | 'newRun' + | 'quit' + +export function routeWorkflowKey(input: string, key: KeyEvent): WorkflowKeyAction | null { + // @anthropic/ink 的 key.tab 对 Tab 键置 true;个别环境回落到 '\t' + if (key.tab || input === '\t') return key.shift ? 'prevTab' : 'nextTab' + if (key.escape || input === 'q') return 'quit' + if (input === 'x') return 'kill' + if (input === 'r') return 'resume' + if (input === 'n') return 'newRun' + if (key.leftArrow) return 'focusLeft' + if (key.rightArrow) return 'focusRight' + if (key.upArrow) return 'moveUp' + if (key.downArrow) return 'moveDown' + return null +} + +/** 焦点模型回调(WorkflowsPanel 注入)。 */ +export type WorkflowKeyboardHandlers = { + nextTab: () => void + prevTab: () => void + focusLeft: () => void + focusRight: () => void + moveUp: () => void + moveDown: () => void + killFocused: () => void + resumeFocused: () => void + newRun: () => void + quit: () => void +} + +/** + * /workflows 面板键位(焦点轮转模型): + * - Tab / Shift+Tab:切顶部 run tab + * - ← / →:phases ↔ agents 焦点切换 + * - ↑ / ↓:当前焦点列内移动 + * - x kill · r resume · n new · q / Esc quit + */ +export function useWorkflowKeyboard(h: WorkflowKeyboardHandlers): void { + useInput((input, key) => { + const action = routeWorkflowKey(input, key as KeyEvent) + if (action === null) return + switch (action) { + case 'nextTab': + h.nextTab() + break + case 'prevTab': + h.prevTab() + break + case 'focusLeft': + h.focusLeft() + break + case 'focusRight': + h.focusRight() + break + case 'moveUp': + h.moveUp() + break + case 'moveDown': + h.moveDown() + break + case 'kill': + h.killFocused() + break + case 'resume': + h.resumeFocused() + break + case 'newRun': + h.newRun() + break + case 'quit': + h.quit() + break + } + }) +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `bun test src/workflow/__tests__/useWorkflowKeyboard.test.ts` +Expected: 全部 PASS + +- [ ] **Step 5: precheck** + +Run: `bun run precheck` +Expected: 零错误 + +- [ ] **Step 6: Commit(逻辑切分点)** + +```bash +git add src/workflow/panel/useWorkflowKeyboard.ts src/workflow/__tests__/useWorkflowKeyboard.test.ts +git commit -m "refactor(workflow): 键位改焦点轮转模型 + 抽 routeWorkflowKey" +``` + +--- + +## Task 5: 新建三个展示组件 `TabsBar` / `PhaseSidebar` / `AgentList` + +> 这三个是无状态展示组件(props 驱动),不写渲染测试(项目无 ink-testing-library)。靠 `tsc` + `biome` 保证类型/格式。 + +**Files:** +- Create: `src/workflow/panel/TabsBar.tsx` +- Create: `src/workflow/panel/PhaseSidebar.tsx` +- Create: `src/workflow/panel/AgentList.tsx` + +- [ ] **Step 1: 创建 `src/workflow/panel/TabsBar.tsx`** + +```tsx +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { RunProgress } from '../progress/store.js'; +import { RUN_STATUS_COLOR, STATUS_DOT } from './status.js'; +import { tabLabel } from './selectors.js'; + +/** + * 顶部 run tab 行:每个 run 一个 tab(状态点 + 名 + #短码)。 + * 当前 tab 用橙色 ═ 下划线高亮。 + */ +export function TabsBar({ + runs, + activeRunId, +}: { + runs: RunProgress[]; + activeRunId: string | null; +}): React.ReactNode { + if (runs.length === 0) { + return (no runs); + } + return ( + + {runs.map(r => { + const active = r.runId === activeRunId; + const label = tabLabel(r.workflowName, r.runId); + const underline = '═'.repeat(label.length + 2); + return ( + + + {STATUS_DOT[r.status]} + + + {label} + + + {active ? underline : ''} + + ); + })} + + ); +} +``` + +- [ ] **Step 2: 创建 `src/workflow/panel/PhaseSidebar.tsx`** + +```tsx +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { AgentProgress } from '../progress/store.js'; +import { PHASE_COLOR, PHASE_MARK } from './status.js'; +import { ALL_PHASE, type MergedPhase } from './selectors.js'; + +/** + * 左 phase 侧栏:第一行 All(汇总 done/total),其后 merged phases(含 pending ○)。 + * 选中行铺橙底(文字色不变);selectedIndex=0 表示 All。 + */ +export function PhaseSidebar({ + phases, + agents, + selectedIndex, +}: { + phases: MergedPhase[]; + agents: AgentProgress[]; + selectedIndex: number; +}): React.ReactNode { + const totalAgents = agents.length; + const doneAgents = agents.filter(a => a.status === 'done').length; + const allRow = { title: ALL_PHASE, done: doneAgents, total: totalAgents }; + const rows = [allRow, ...phases]; + + return ( + + {rows.map((row, i) => { + const selected = i === selectedIndex; + const isAll = i === 0; + const mark = isAll ? ' ' : PHASE_MARK[row.status]; + const color = isAll ? undefined : PHASE_COLOR[row.status]; + const prefix = selected ? '▶' : ' '; + return ( + + + {prefix} + {mark} {row.title.padEnd(10)} {row.done}/{row.total} + + + ); + })} + + ); +} +``` + +- [ ] **Step 3: 创建 `src/workflow/panel/AgentList.tsx`** + +```tsx +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { AgentProgress } from '../progress/store.js'; +import { agentVisual } from './status.js'; + +const LABEL_WIDTH = 18; + +/** + * 右 agent 列表(已按选中 phase 过滤)。 + * 光标行铺橙底;每行:标记 + label + 行尾状态文字(running/object/text/dead)。 + */ +export function AgentList({ + agents, + selectedIndex, +}: { + agents: AgentProgress[]; + selectedIndex: number; +}): React.ReactNode { + if (agents.length === 0) { + return (no agents in this phase); + } + return ( + + {agents.map((a, i) => { + const v = agentVisual(a); + const selected = i === selectedIndex; + const label = (a.label ?? `agent-${a.id}`).slice(0, LABEL_WIDTH).padEnd(LABEL_WIDTH); + return ( + + + {v.mark} {label} {v.suffix} + + + ); + })} + + ); +} +``` + +- [ ] **Step 4: 类型检查 + lint** + +Run: `bun run precheck` +Expected: 零错误(三个组件未被引用,tsc 仍编译它们;无 lint 报错) + +- [ ] **Step 5: Commit(逻辑切分点)** + +```bash +git add src/workflow/panel/TabsBar.tsx src/workflow/panel/PhaseSidebar.tsx src/workflow/panel/AgentList.tsx +git commit -m "feat(workflow): 新增 TabsBar/PhaseSidebar/AgentList 展示组件" +``` + +--- + +## Task 6: 重写 `WorkflowsPanel` + 删旧组件 + 修测试 import + +**Files:** +- Modify: `src/workflow/panel/WorkflowsPanel.tsx`(整体重写) +- Delete: `src/workflow/panel/WorkflowList.tsx` +- Delete: `src/workflow/panel/WorkflowDetail.tsx` +- Modify: `src/workflow/__tests__/WorkflowsPanel.test.tsx:4`(`STATUS_DOT` import 改源) + +- [ ] **Step 1: 重写 `src/workflow/panel/WorkflowsPanel.tsx`** + +```tsx +import React, { useEffect, useState, useSyncExternalStore } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; +import { getWorkflowService } from '../service.js'; +import type { RunProgress } from '../progress/store.js'; +import { AgentList } from './AgentList.js'; +import { PhaseSidebar } from './PhaseSidebar.js'; +import { TabsBar } from './TabsBar.js'; +import { + type FocusColumn, + type WorkflowKeyboardHandlers, + useWorkflowKeyboard, +} from './useWorkflowKeyboard.js'; +import { ALL_PHASE, filterAgentsByPhase, mergePhases } from './selectors.js'; + +/** + * 夹紧选中索引到有效区间(空列表→0;越界→末位;负/NaN→0)。 + * 抽成模块级纯函数:面板内调用 + 单测覆盖同一逻辑,避免行为漂移。 + */ +export function clampSelected(selected: number, len: number): number { + if (len === 0) return 0; + const n = Math.trunc(selected); + if (Number.isNaN(n) || n < 0) return 0; + return Math.min(n, len - 1); +} + +/** + * /workflows 主面板:三区焦点模型(顶 tab + 左 phase 侧栏 + 右 agent 列表)。 + * + * - useSyncExternalStore 订阅 WorkflowService(store 返回稳定快照,无变更不重渲染)。 + * - 焦点状态:activeRunId / focusColumn('phases'|'agents') / selectedPhaseIndex(0=All) / selectedAgentIndex。 + * - 键位:Tab 切 run · ←/→ 切焦点列 · ↑/↓ 列内移动 · x kill · r resume · q/Esc 退出。 + */ +export function WorkflowsPanel({ + onDone, + context, +}: { + onDone: LocalJSXCommandOnDone; + context: LocalJSXCommandContext; +}): React.ReactNode { + const svc = getWorkflowService(); + const runs = useSyncExternalStore( + svc.subscribe, + () => svc.listRuns(), + () => [], + ); + + const [activeRunId, setActiveRunId] = useState(null); + const [focusColumn, setFocusColumn] = useState('phases'); + const [selectedPhaseIndex, setSelectedPhaseIndex] = useState(0); + const [selectedAgentIndex, setSelectedAgentIndex] = useState(0); + + // runs 变化时:activeRunId 失效(被 kill / 首次)→ 夹紧到首个 + useEffect(() => { + if (runs.length === 0) { + if (activeRunId !== null) setActiveRunId(null); + return; + } + if (!runs.some(r => r.runId === activeRunId)) { + setActiveRunId(runs[0]!.runId); + } + }, [runs, activeRunId]); + + const focused: RunProgress | undefined = runs.find(r => r.runId === activeRunId); + const phases = focused ? mergePhases(focused) : []; + // 侧栏含 All 行:phases 数组前补一项 → 总行数 = phases.length + 1 + const phaseRowCount = phases.length + 1; + const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount); + + // 选中 phase title(0 = All = undefined) + const selectedPhaseTitle = + clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title; + + const visibleAgents = focused + ? filterAgentsByPhase(focused.agents, selectedPhaseTitle) + : []; + const clampedAgent = clampSelected(selectedAgentIndex, visibleAgents.length); + + const switchTab = (runId: string): void => { + setActiveRunId(runId); + setFocusColumn('phases'); + setSelectedPhaseIndex(0); + setSelectedAgentIndex(0); + }; + + const nextTab = (): void => { + if (runs.length === 0) return; + const idx = runs.findIndex(r => r.runId === activeRunId); + const next = runs[(idx + 1) % runs.length]!; + switchTab(next.runId); + }; + const prevTab = (): void => { + if (runs.length === 0) return; + const idx = runs.findIndex(r => r.runId === activeRunId); + const next = runs[(idx - 1 + runs.length) % runs.length]!; + switchTab(next.runId); + }; + + const handlers: WorkflowKeyboardHandlers = { + nextTab, + prevTab, + focusLeft: () => setFocusColumn('phases'), + focusRight: () => setFocusColumn('agents'), + moveUp: () => { + if (focusColumn === 'phases') + setSelectedPhaseIndex(s => clampSelected(s - 1, phaseRowCount)); + else setSelectedAgentIndex(s => clampSelected(s - 1, visibleAgents.length)); + }, + moveDown: () => { + if (focusColumn === 'phases') + setSelectedPhaseIndex(s => clampSelected(s + 1, phaseRowCount)); + else setSelectedAgentIndex(s => clampSelected(s + 1, visibleAgents.length)); + }, + killFocused: () => { + if (focused) svc.kill(focused.runId); + }, + resumeFocused: () => { + if (!focused) return; + const canUseTool = context.canUseTool; + if (!canUseTool) { + onDone('resume 需要 canUseTool 上下文,请在主会话中用 / resume 重试。'); + return; + } + void svc + .launch( + { resumeFromRunId: focused.runId, name: focused.workflowName }, + context, + canUseTool, + ) + .catch(e => onDone(`resume 失败:${(e as Error).message}`)); + }, + newRun: () => + onDone('Tip: 用 / 启动命名 workflow,或通过 Workflow 工具带 name 参数。'), + quit: () => onDone(), + }; + useWorkflowKeyboard(handlers); + + const running = runs.filter(r => r.status === 'running').length; + const done = runs.length - running; + const phaseHeader = selectedPhaseTitle ?? ALL_PHASE; + + return ( + + + Workflows + + {running} running · {done} done + + + + + + + + + + + PHASES + + + + + + + AGENTS · {phaseHeader} + + + + + + + + Tab 切 run · ←/→ 切焦点 · ↑/↓ 移动 · x kill · r resume · q quit + + + + ); +} +``` + +- [ ] **Step 2: 删除旧组件** + +Run: +```bash +rm src/workflow/panel/WorkflowList.tsx src/workflow/panel/WorkflowDetail.tsx +``` + +- [ ] **Step 3: 修 `WorkflowsPanel.test.tsx` 的 import(第 2-4 行)** + +把: +```ts +import type { RunProgress } from '../progress/store.js'; +import { clampSelected } from '../panel/WorkflowsPanel.js'; +import { STATUS_DOT } from '../panel/WorkflowList.js'; +``` +改为: +```ts +import type { RunProgress } from '../progress/store.js'; +import { clampSelected } from '../panel/WorkflowsPanel.js'; +import { STATUS_DOT } from '../panel/status.js'; +``` + +- [ ] **Step 4: 更新 `WorkflowsPanel.test.tsx` 的 `RunProgress` 字段契约用例(第 28-47 行)** + +旧用例构造 `RunProgress` 时缺 `declaredPhases`,tsc 会报错。补字段: + +把第 29-38 行的 `const run: RunProgress = { ... }` 改为: +```ts + const run: RunProgress = { + runId: 'r1', + workflowName: 'review', + status: 'running', + phases: [{ title: 'Find', status: 'done' }], + declaredPhases: ['Find', 'Review'], + currentPhase: 'Review', + agents: [{ id: 1, label: 'review:api', phase: 'Review', status: 'running' }], + agentCount: 1, + updatedAt: 1, + }; +``` + +同样补第 51-61 行(completed)和第 62-72 行(failed)的 `declaredPhases: []`。 + +- [ ] **Step 5: precheck** + +Run: `bun run precheck` +Expected: 零错误。重点核对: +- `STATUS_DOT` import 已切到 `status.js`,无悬空引用。 +- `WorkflowList.tsx` / `WorkflowDetail.tsx` 删除后无残留 import(grep 已确认仅 WorkflowsPanel 与 test 引用,均已处理)。 +- `clampSelected` 契约测试仍绿。 + +- [ ] **Step 6: Commit(逻辑切分点)** + +```bash +git add -A src/workflow/panel/ src/workflow/__tests__/WorkflowsPanel.test.tsx +git commit -m "refactor(workflow): WorkflowsPanel 重写为三区焦点模型 + 删旧双栏组件" +``` + +--- + +## Task 7: 文档更新 + 全量 precheck + +**Files:** +- Modify: `docs/features/workflow-scripts.md:138-148`(§六) + +- [ ] **Step 1: 更新 `docs/features/workflow-scripts.md` §六** + +把第 138-148 行(§六「监控面板:`/workflows`」整段)替换为: + +```markdown +## 六、监控面板:`/workflows` + +`/workflows` 打开三区焦点面板(local-jsx,全屏): + +- **顶部 tabs**:每个 run 一个 tab(状态圆点 + workflow 名 + `#runId短码`);同名脚本多次跑会多个 tab。 +- **左 phase 侧栏**:`All` + 合并 meta 声明的 phase(未启动 `○` pending 灰)与实际 phase(`●` running / `✓` done);选中即决定右栏筛选。 +- **右 agent 列表**:按选中 phase 过滤;状态色 + 行尾文字(`running` / `object` / `text` / `dead`)。 + +**键位**:`Tab`/`Shift+Tab` 切 run · `←`/`→` 切左右焦点列(phases ↔ agents)· `↑`/`↓` 列内移动 · `r` resume · `x` kill · `n` 新建提示 · `q`/`Esc` 退出。 + +**视觉**:无内框,左右一条竖线分隔;聚焦列标题橙粗;选中/光标行铺橙底(`backgroundColor`),文字色不变。 + +进度按引擎 `agentId` 精确关联 `agent_done`(解决并发 LIFO 竞态)。pending phase 来自 `run_started` 事件携带的 `meta.phases`,store 落地 `declaredPhases`,面板 `mergePhases` 合并。`useSyncExternalStore` 订阅 `WorkflowService`,稳定快照,无变更不重渲染。 +``` + +- [ ] **Step 2: 全量 precheck** + +Run: `bun run precheck` +Expected: 零错误(typecheck + lint:fix + 全量 test) + +- [ ] **Step 3: 里程碑检查点 —— 向用户确认是否提交 Task 4-7** + +组件层 + 文档完成。按项目约定,此处询问用户是否提交。 + +--- + +## Self-Review(计划作者已完成) + +**1. Spec coverage** — 对照 spec 各节: +- §4 数据模型(declaredPhases)→ Task 1 ✓ +- §4 gap 补充(outputShape,为 §8 object 标记服务)→ Task 1 ✓ +- §5/§8 视觉(tab/phase/agent 状态映射 + agentVisual)→ Task 2 ✓ +- §6 焦点状态机 + 筛选语义 + tabLabel → Task 3(selectors)+ Task 6(WorkflowsPanel 状态)✓ +- §6 键位表 → Task 4(routeWorkflowKey + handlers)✓ +- §7 组件拆分(TabsBar/PhaseSidebar/AgentList/status/selectors)→ Task 2/3/5 ✓ +- §7 删 WorkflowList/WorkflowDetail + 修 test import → Task 6 ✓ +- §9 测试(纯函数 TDD,无 ink-testing-library)→ Task 1-4 ✓ +- §10 里程碑 M1-M4 → Task 1(M1) / 2-3(M2 纯逻辑) / 4-6(M2 组件) / 7(M3 测试+M4 文档) ✓ + +**2. Placeholder scan** — 无 TBD/TODO/"add error handling"/"similar to"。每个代码步给完整代码。 + +**3. Type consistency** — +- `MergedPhase`(selectors.ts 定义)在 PhaseSidebar.tsx 引用一致 ✓ +- `AgentVisual` / `agentVisual`(status.ts)在 AgentList.tsx 引用一致 ✓ +- `FocusColumn` / `WorkflowKeyboardHandlers`(useWorkflowKeyboard.ts)在 WorkflowsPanel.tsx 引用一致 ✓ +- `declaredPhases` / `outputShape` 在 store.ts 定义、selectors.test/WorkflowsPanel.test 构造一致 ✓ +- `ALL_PHASE` 常量在 selectors.ts 定义、PhaseSidebar/WorkflowsPanel 引用一致 ✓ +- `routeWorkflowKey` 返回的 action union 与 handlers 方法名一一对应 ✓ + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-13-workflow-panel-redesign.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — 每个 Task 派一个新 subagent,Task 间做 spec/quality 两段 review,迭代快。 + +**2. Inline Execution** — 在本会话按 Task 顺序执行,批次推进、检查点停下 review。 + +两种方式都遵循项目约定:`git commit` 仅在你明确要求时执行(Task 末尾的 commit step 是逻辑切分点,默认不自动提交,里程碑末尾统一问你)。 + +选哪种? diff --git a/docs/superpowers/plans/2026-06-13-workflow-tui-ultracode.md b/docs/superpowers/plans/2026-06-13-workflow-tui-ultracode.md new file mode 100644 index 000000000..dfae63486 --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-workflow-tui-ultracode.md @@ -0,0 +1,2022 @@ +# Workflow 集成层重写 + `/workflows` 面板 + `/ultracode` skill 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在引擎包地基上全量重写 `src/workflow/` 集成层(Service 门面 + 单一深度 `claude-code` 后端 + 进度 bus/reducer),交付 `/workflows` 双栏扁平面板与 `/ultracode` 知识 skill。 + +**Architecture:** `WorkflowService` 单例持有共享 `WorkflowPorts`(含 `agentAdapterRegistry`——引擎 hooks 已优先用它);`claudeCodeBackend` 是唯一 `AgentAdapter`,深度从活会话解析 provider/model/agentType/tools;进度走 `progressBus`(多订阅)→ `progressStore` reducer(按 `agentId` 精确关联,修旧 LIFO 竞态);面板 `useSyncExternalStore` 订阅 store。引擎唯一微调:给 `agent_started`/`agent_done` 加 `agentId`。 + +**Tech Stack:** TypeScript strict、Bun(`bun:test`)、Zod、React/Ink(`@anthropic/ink`)、`useSyncExternalStore`。 + +**Spec:** `docs/superpowers/specs/2026-06-13-workflow-tui-ultracode-design.md` + +--- + +## 关键外部接口(已核实,计划代码据此编写) + +- `WorkflowPorts`(`packages/workflow-engine/src/ports.ts`):`{ agentRunner, agentAdapterRegistry?, progressEmitter, taskRegistrar, journalStore, permissionGate, logger, hostFactory }`。**hooks 已优先用 `agentAdapterRegistry`**(`engine/hooks.ts:87-94`),省略则回退 `agentRunner`。 +- `AgentAdapter`(`agentAdapter.ts`):`{ id, capabilities: {structuredOutput, tools?, stream?}, run(params, ctx: {host, signal, runId}), initialize?(), dispose?() }`。`AgentAdapterRegistry`:`register/default/route/resolve/has/get/initializeAll/disposeAll`。 +- `runWorkflow({script, args?, runId, workflowName?, ports, host, signal, cwd, budgetTotal, resume?, scriptChanged?})` → `WorkflowRunResult`。 +- `createWorkflowTool(ports)` → `WorkflowToolDescriptor`(`call(input, context, canUseTool, parentMessage, onProgress?) → {data:{output}}`)。 +- `parseScript`、`createFileJournalStore(dir)`、`resolveNamedWorkflow(dir, name)`、`listNamedWorkflows(dir)`、`createHostHandle/unwrapHostHandle`、`WORKFLOW_DIR_NAME='.claude/workflows'`、`WORKFLOW_RUNS_DIR='.claude/workflow-runs'`。 +- 核心:`runAgent({agentDefinition, promptMessages, toolUseContext, canUseTool, isAsync, querySource, availableTools, override:{agentId, model?}})`(async generator);`assembleToolPool(permissionContext, mcpTools)`(`src/tools.ts`);`finalizeAgentTool(messages, agentId, {prompt, resolvedAgentModel, isBuiltInAgent, startTime, agentType, isAsync})`(`.content`/`.usage.output_tokens`/`.totalTokens`);`isBuiltInAgent`、`BuiltInAgentDefinition`、`AgentDefinition`(`loadAgentsDir`)。 +- `LocalWorkflowTask`:`registerLocalWorkflowTask(setAppState, {description, workflowName, workflowFile, summary?, toolUseId?, agentId?, abortController?}) → taskId`;`completeWorkflowTask/failWorkflowTask/killWorkflowTask(taskId, setAppState)`。 +- `buildTool(def)`(`src/Tool.ts`);`Tool.call(args, context, canUseTool, parentMessage, onProgress?)`。 +- local-jsx 命令:`{ type:'local-jsx', name, description, isEnabled?, load: () => Promise<{call}> }`,`call: (onDone, context: ToolUseContext & LocalJSXCommandContext, args) => Promise`。 +- 注册点(**保留导出名/路径即零改动**):`src/tools.ts:152`(`require('./workflow/wiring.js').createWorkflowToolCore()`)、`src/commands.ts:95`(`require('./commands/workflows/index.js')` 默认导出)、`src/commands.ts:480`(`require('./workflow/namedWorkflowCommands.js').getWorkflowCommands`)、`src/constants/tools.ts:35`(`WORKFLOW_TOOL_NAME`)、`src/tasks.ts:9`、`src/components/permissions/PermissionRequest.tsx:48,51`。 + +## 文件结构 + +**引擎包改动(M1)** +- Modify `packages/workflow-engine/src/types.ts` — `agent_started`/`agent_done` 加 `agentId`。 +- Modify `packages/workflow-engine/src/engine/context.ts` — `SharedResources` 加 `agentIdSeq`。 +- Modify `packages/workflow-engine/src/engine/hooks.ts` — 盖戳 `agentId`。 +- Test `packages/workflow-engine/src/__tests__/agentId.test.ts`。 + +**src/workflow 集成层(M2–M5)** +- Create `src/workflow/progress/bus.ts` — 类型化发布/订阅。 +- Create `src/workflow/progress/store.ts` — `RunProgress`/`AgentProgress` 类型 + reducer(按 agentId)。 +- Create `src/workflow/backends/claudeCodeBackend.ts` — `AgentAdapter` + 体系解析 helpers。 +- Create `src/workflow/registry.ts` — 建 `AgentAdapterRegistry`(单 adapter)。 +- Create `src/workflow/ports.ts` — 组装 `WorkflowPorts`(含 `agentAdapterRegistry`、taskRegistrar bindings)。 +- Create `src/workflow/service.ts` — `WorkflowService` 单例。 +- Rewrite `src/workflow/wiring.ts`(保留 `createWorkflowToolCore` 导出)。 +- Delete `src/workflow/adapter.ts`、`src/workflow/progressStore.ts`。 +- Keep `src/workflow/hostHandle.ts`、`namedWorkflowCommands.ts`、`WorkflowPermissionRequest.tsx`。 + +**面板(M6)** +- Create `src/workflow/panel/WorkflowList.tsx`、`WorkflowDetail.tsx`、`useWorkflowKeyboard.ts`、`WorkflowsPanel.tsx`。 +- Rewrite `src/commands/workflows/index.ts`(local-jsx)。 +- Modify `src/components/tasks/BackgroundTasksDialog.tsx` — 去 `WorkflowDetailDialog`。 +- Delete `src/components/tasks/WorkflowDetailDialog.tsx`。 + +**skill + 文档(M7–M8)** +- Create `src/skills/bundled/ultracode/SKILL.md`。 +- Update `docs/features/workflow-scripts.md`。 + +--- + +## Phase M1:引擎进度事件加 `agentId` + +### Task 1:`ProgressEvent` 加 `agentId` 字段 + +**Files:** +- Modify: `packages/workflow-engine/src/types.ts:69-76` + +- [ ] **Step 1:改 `agent_started`/`agent_done` 变体加 `agentId: number`** + +把 `types.ts` 中的: + +```ts + | { type: 'agent_started'; runId: string; label?: string; phase?: string } + | { + type: 'agent_done' + runId: string + label?: string + phase?: string + result: AgentRunResult + } +``` + +替换为: + +```ts + | { + type: 'agent_started' + runId: string + agentId: number + label?: string + phase?: string + } + | { + type: 'agent_done' + runId: string + agentId: number + label?: string + phase?: string + result: AgentRunResult + } +``` + +- [ ] **Step 2:类型检查** + +Run: `cd packages/workflow-engine && bunx tsc --noEmit 2>&1 | head` +Expected: 报错指向 `engine/hooks.ts` 的 `emit({ type: 'agent_started'/'agent_done', ... })` 缺 `agentId`(预期,Task 3 修复)。 + +### Task 2:`SharedResources` 加 `agentIdSeq` + +**Files:** +- Modify: `packages/workflow-engine/src/engine/context.ts:10-15, 32-41` + +- [ ] **Step 1:类型加字段 + 初始化** + +把 `SharedResources` 类型: + +```ts +export type SharedResources = { + semaphore: Semaphore + budget: Budget + agentCountBox: { value: number } + depth: number +} +``` + +替换为: + +```ts +export type SharedResources = { + semaphore: Semaphore + budget: Budget + agentCountBox: { value: number } + /** agent() 调用的递增序号,盖戳 agent_started/agent_done 供进度精确关联。子 workflow 共享。 */ + agentIdSeq: { value: number } + depth: number +} +``` + +把 `createSharedResources`: + +```ts + return { + semaphore: new Semaphore(maxConcurrency()), + budget: new Budget(budgetTotal), + agentCountBox: { value: 0 }, + depth: 0, + } +``` + +替换为: + +```ts + return { + semaphore: new Semaphore(maxConcurrency()), + budget: new Budget(budgetTotal), + agentCountBox: { value: 0 }, + agentIdSeq: { value: 0 }, + depth: 0, + } +``` + +### Task 3:hooks 盖戳 `agentId` + +**Files:** +- Modify: `packages/workflow-engine/src/engine/hooks.ts:21-31, 45-108` + +- [ ] **Step 1:`HookProgressInit` 的 agent 变体加 `agentId`** + +把: + +```ts +type HookProgressInit = + | { type: 'phase_started'; phase: string } + | { type: 'phase_done'; phase: string } + | { type: 'agent_started'; label?: string; phase?: string } + | { + type: 'agent_done' + label?: string + phase?: string + result: AgentRunResult + } + | { type: 'log'; message: string } +``` + +替换为: + +```ts +type HookProgressInit = + | { type: 'phase_started'; phase: string } + | { type: 'phase_done'; phase: string } + | { type: 'agent_started'; agentId: number; label?: string; phase?: string } + | { + type: 'agent_done' + agentId: number + label?: string + phase?: string + result: AgentRunResult + } + | { type: 'log'; message: string } +``` + +- [ ] **Step 2:`agent()` 内分配并盖戳 `agentId`** + +把 `agent` 函数体中(`budget.assertCanSpend()` 之后、`const params` 之前)插入 id 分配,并给三处 `emit` 加 `agentId`。当前: + +```ts + r.budget.assertCanSpend() + + const params: AgentRunParams = { prompt, ...opts } + const key = agentCallKey(prompt, params) + const label = opts.label as string | undefined + const phase = + (opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined + + // journal 命中 → 直接返回缓存 + if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) { + const entry = ctx.journal[ctx.journalIndex]! + if (entry.key === key) { + ctx.journalIndex++ + emit({ type: 'agent_done', label, phase, result: entry.result }) + return resultToOutput(entry.result) + } +``` + +替换为: + +```ts + r.budget.assertCanSpend() + + // 每次 agent() 调用分配唯一 id(含 journal 命中),盖戳 started/done 供 reducer 精确关联 + const agentId = r.agentIdSeq.value++ + + const params: AgentRunParams = { prompt, ...opts } + const key = agentCallKey(prompt, params) + const label = opts.label as string | undefined + const phase = + (opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined + + // journal 命中 → 直接返回缓存 + if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) { + const entry = ctx.journal[ctx.journalIndex]! + if (entry.key === key) { + ctx.journalIndex++ + emit({ type: 'agent_done', agentId, label, phase, result: entry.result }) + return resultToOutput(entry.result) + } +``` + +把 live 分支两处 emit: + +```ts + ctx.resources.agentCountBox.value++ + emit({ type: 'agent_started', label, phase }) +``` + +替换为: + +```ts + ctx.resources.agentCountBox.value++ + emit({ type: 'agent_started', agentId, label, phase }) +``` + +把: + +```ts + emit({ type: 'agent_done', label, phase, result }) +``` + +替换为: + +```ts + emit({ type: 'agent_done', agentId, label, phase, result }) +``` + +- [ ] **Step 3:类型检查 + 全包测试** + +Run: `cd packages/workflow-engine && bunx tsc --noEmit && bun test 2>&1 | tail -5` +Expected: 类型零错误;现有测试仍 PASS(既有 hooks 测试不校验 agentId)。 + +- [ ] **Step 4:写 agentId 配对回归测试** + +Create `packages/workflow-engine/src/__tests__/agentId.test.ts`: + +```ts +import { expect, test } from 'bun:test' +import { createEngineContext } from '../engine/context.js' +import { makeHooks } from '../engine/hooks.js' +import { createBufferingEmitter } from '../progress/events.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import type { AgentRunParams, AgentRunResult } from '../types.js' + +function build(results: Map) { + const { emitter, events } = createBufferingEmitter() + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async (p: AgentRunParams) => results.get(p.prompt) ?? { kind: 'dead' }, + }, + progressEmitter: emitter, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, fail: () => {}, kill: () => {}, pendingAction: () => null, + }, + journalStore: { read: async () => [], append: async () => {}, truncate: async () => {} }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + signal: new AbortController().signal, cwd: '/tmp', budgetTotal: null, + }), + } + const ctx = createEngineContext({ + ports, host: createHostHandle(null), signal: new AbortController().signal, + runId: 'r', workflowName: 'w', cwd: '/tmp', budgetTotal: null, + }) + return { ctx, events, hooks: makeHooks(ctx, async () => null) } +} + +test('并发 agent 各自拿到唯一 agentId,started/done 配对', async () => { + const ok = (out: string): AgentRunResult => ({ kind: 'ok', output: out, usage: { outputTokens: 1 } }) + const { ctx, events, hooks } = build(new Map([['a', ok('1')], ['b', ok('2')]])) + // 并发跑两个 agent + await hooks.parallel([() => hooks.agent('a'), () => hooks.agent('b')]) + const started = events.filter(e => e.type === 'agent_started') + const done = events.filter(e => e.type === 'agent_done') + expect(started).toHaveLength(2) + expect(done).toHaveLength(2) + // 每个 started 都有数值 agentId + const ids = started.map(e => (e as { agentId: number }).agentId) + expect(new Set(ids).size).toBe(2) // 唯一 + // 每个 done 的 agentId 都能在 started 里找到 + for (const d of done as Array<{ agentId: number }>) { + expect(ids).toContain(d.agentId) + } + // 计数与序号推进 + expect(ctx.resources.agentIdSeq.value).toBe(2) +}) + +test('agentId 单调递增', async () => { + const ok = (out: string): AgentRunResult => ({ kind: 'ok', output: out, usage: { outputTokens: 1 } }) + const { events, hooks } = build(new Map([['a', ok('1')], ['b', ok('2')], ['c', ok('3')]])) + await hooks.agent('a'); await hooks.agent('b'); await hooks.agent('c') + const ids = events + .filter(e => e.type === 'agent_started') + .map(e => (e as { agentId: number }).agentId) + expect(ids).toEqual([0, 1, 2]) +}) +``` + +- [ ] **Step 5:运行测试** + +Run: `cd packages/workflow-engine && bun test src/__tests__/agentId.test.ts` +Expected: 2 PASS。 + +- [ ] **Step 6:提交** + +```bash +git add packages/workflow-engine/src/types.ts packages/workflow-engine/src/engine/context.ts packages/workflow-engine/src/engine/hooks.ts packages/workflow-engine/src/__tests__/agentId.test.ts +git commit -m "feat(workflow-engine): stamp agentId on agent_started/agent_done for exact progress correlation" +``` + +--- + +## Phase M2:进度 bus + store + +### Task 4:进度事件总线 `progress/bus.ts` + +**Files:** +- Create: `src/workflow/progress/bus.ts` +- Test: `src/workflow/__tests__/progressBus.test.ts` + +- [ ] **Step 1:写失败测试** + +Create `src/workflow/__tests__/progressBus.test.ts`: + +```ts +import { expect, test, mock } from 'bun:test' +import { createProgressBus } from '../progress/bus.js' + +test('emit 广播给所有订阅者', () => { + const bus = createProgressBus() + const a = mock(() => {}) + const b = mock(() => {}) + bus.subscribe(a) + bus.subscribe(b) + const ev = { type: 'log' as const, runId: 'r', message: 'hi' } + bus.emit(ev) + expect(a).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledWith(ev) +}) + +test('subscribe 返回取消订阅', () => { + const bus = createProgressBus() + const fn = mock(() => {}) + const unsub = bus.subscribe(fn) + unsub() + bus.emit({ type: 'log', runId: 'r', message: 'x' }) + expect(fn).not.toHaveBeenCalled() +}) +``` + +- [ ] **Step 2:运行确认失败** + +Run: `bun test src/workflow/__tests__/progressBus.test.ts` +Expected: FAIL(模块不存在)。 + +- [ ] **Step 3:实现 `bus.ts`** + +Create `src/workflow/progress/bus.ts`: + +```ts +import type { ProgressEvent } from '@claude-code-best/workflow-engine' + +/** 类型化进度事件总线。引擎 progressEmitter.emit → 广播给所有订阅者(store / 遥测)。 */ +export type ProgressBus = { + emit(event: ProgressEvent): void + subscribe(listener: (event: ProgressEvent) => void): () => void +} + +export function createProgressBus(): ProgressBus { + const listeners = new Set<(event: ProgressEvent) => void>() + return { + emit(event) { + for (const fn of listeners) fn(event) + }, + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} +``` + +- [ ] **Step 4:运行测试** + +Run: `bun test src/workflow/__tests__/progressBus.test.ts` +Expected: 2 PASS。 + +- [ ] **Step 5:提交** + +```bash +git add src/workflow/progress/bus.ts src/workflow/__tests__/progressBus.test.ts +git commit -m "feat(workflow): add typed progress event bus" +``` + +### Task 5:进度 reducer `progress/store.ts`(按 agentId 关联) + +**Files:** +- Create: `src/workflow/progress/store.ts` +- Test: `src/workflow/__tests__/progressStore.test.ts` + +- [ ] **Step 1:写失败测试(含并发 agentId 关联回归)** + +Create `src/workflow/__tests__/progressStore.test.ts`: + +```ts +import { expect, test } from 'bun:test' +import { createProgressBus, type ProgressBus } from '../progress/bus.js' +import { createProgressStoreFromBus } from '../progress/store.js' +import type { ProgressEvent, AgentRunResult } from '@claude-code-best/workflow-engine' + +const ok = (o: string): AgentRunResult => ({ kind: 'ok', output: o, usage: { outputTokens: 1 } }) + +function newStore() { + const bus: ProgressBus = createProgressBus() + return { bus, store: createProgressStoreFromBus(bus) } +} + +function ev(e: Omit): ProgressEvent { + return e +} + +test('run_started 建条目;phase_started/done 更新 phases', () => { + const { bus, store } = newStore() + bus.emit(ev({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })) + bus.emit(ev({ type: 'phase_started', runId: 'r1', phase: 'A' })) + bus.emit(ev({ type: 'phase_started', runId: 'r1', phase: 'B' })) + bus.emit(ev({ type: 'phase_done', runId: 'r1', phase: 'A' })) + const r = store.get('r1')! + expect(r.phases.map(p => [p.title, p.status])).toEqual([['A', 'done'], ['B', 'running']]) + expect(r.currentPhase).toBe('B') +}) + +test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', () => { + const { bus, store } = newStore() + bus.emit(ev({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })) + // 两个并发 agent,started 顺序 0,1,但 done 顺序 1,0(颠倒) + bus.emit(ev({ type: 'agent_started', runId: 'r1', agentId: 0, label: 'a', phase: 'A' })) + bus.emit(ev({ type: 'agent_started', runId: 'r1', agentId: 1, label: 'b', phase: 'A' })) + bus.emit(ev({ type: 'agent_done', runId: 'r1', agentId: 1, label: 'b', phase: 'A', result: ok('b-out') })) + bus.emit(ev({ type: 'agent_done', runId: 'r1', agentId: 0, label: 'a', phase: 'A', result: ok('a-out') })) + const agents = store.get('r1')!.agents + // 各自按 id 落位,不串 + expect(agents.find(x => x.id === 0)?.status).toBe('done') + expect(agents.find(x => x.id === 1)?.status).toBe('done') + expect(agents.find(x => x.id === 0)?.label).toBe('a') + expect(agents.find(x => x.id === 1)?.label).toBe('b') +}) + +test('journal 命中(仅 agent_done 无 started)按 id 补建 done 条目', () => { + const { bus, store } = newStore() + bus.emit(ev({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })) + bus.emit(ev({ type: 'agent_done', runId: 'r1', agentId: 7, label: 'c', phase: 'A', result: ok('c') })) + const a = store.get('r1')!.agents.find(x => x.id === 7)! + expect(a.status).toBe('done') +}) + +test('run_done 终态 + list 排序 + subscribe 通知', () => { + const { bus, store } = newStore() + let calls = 0 + store.subscribe(() => calls++) + bus.emit(ev({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null })) + bus.emit(ev({ type: 'run_done', runId: 'r1', status: 'completed', returnValue: 42 })) + const r = store.get('r1')! + expect(r.status).toBe('completed') + expect(r.returnValue).toBe(42) + expect(store.list().map(x => x.runId)).toEqual(['r1']) + expect(calls).toBeGreaterThanOrEqual(2) +}) +``` + +- [ ] **Step 2:运行确认失败** + +Run: `bun test src/workflow/__tests__/progressStore.test.ts` +Expected: FAIL(`../progress/store.js` 无导出)。 + +- [ ] **Step 3:实现 `store.ts`** + +Create `src/workflow/progress/store.ts`: + +```ts +import type { ProgressEvent } from '@claude-code-best/workflow-engine' +import type { ProgressBus } from './bus.js' + +export type AgentProgress = { + /** 引擎盖戳的唯一 id,精确关联 started/done(修旧 LIFO 竞态)。 */ + id: number + label?: string + phase?: string + status: 'running' | 'done' + resultKind?: string +} + +export type RunProgress = { + runId: string + workflowName: string + status: 'running' | 'completed' | 'failed' | 'killed' + phases: Array<{ title: string; status: 'running' | 'done' }> + currentPhase: string | null + agents: AgentProgress[] + agentCount: number + returnValue?: unknown + error?: string + updatedAt: number +} + +export type ProgressStore = { + apply(event: ProgressEvent): void + list(): RunProgress[] + get(runId: string): RunProgress | undefined + /** 供 useSyncExternalStore:返回稳定引用,无变更时同一数组。 */ + subscribe(listener: () => void): () => void + getSnapshot(): RunProgress[] +} + +/** 从 bus 构造 reactive store:订阅 bus,归约事件,通知 React 订阅者。 */ +export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore { + const byId = new Map() + let snapshot: RunProgress[] = [] + const listeners = new Set<() => void>() + + const notify = (): void => { + snapshot = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt) + for (const fn of listeners) fn() + } + + const ensure = (runId: string, workflowName: string): RunProgress => { + let p = byId.get(runId) + if (!p) { + p = { + runId, workflowName, status: 'running', phases: [], currentPhase: null, + agents: [], agentCount: 0, updatedAt: Date.now(), + } + byId.set(runId, p) + } + return p + } + + const apply = (event: ProgressEvent): void => { + const runId = event.runId + const p = ensure(runId, 'workflowName' in event ? event.workflowName : 'workflow') + p.updatedAt = Date.now() + switch (event.type) { + case 'run_started': + p.workflowName = event.workflowName + p.status = 'running' + break + case 'phase_started': + if (!p.phases.some(ph => ph.title === event.phase)) { + p.phases.push({ title: event.phase, status: 'running' }) + } + p.currentPhase = event.phase + break + case 'phase_done': + for (const ph of p.phases) if (ph.title === event.phase) ph.status = 'done' + if (p.currentPhase === event.phase) p.currentPhase = null + break + case 'agent_started': { + // 按 id upsert(幂等) + let a = p.agents.find(x => x.id === event.agentId) + if (!a) { + a = { id: event.agentId, label: event.label, phase: event.phase, status: 'running' } + p.agents.push(a) + p.agentCount++ + } else { + a.status = 'running'; a.label = event.label; a.phase = event.phase + } + break + } + case 'agent_done': { + // 按 id 精确落位;无 started(journal 命中)则补建 done 条目 + let a = p.agents.find(x => x.id === event.agentId) + if (!a) { + a = { id: event.agentId, label: event.label, phase: event.phase, status: 'done' } + p.agents.push(a) + } else { + a.status = 'done'; a.resultKind = event.result.kind + } + break + } + case 'log': + break + case 'run_done': + p.status = event.status + if (event.returnValue !== undefined) p.returnValue = event.returnValue + if (event.error !== undefined) p.error = event.error + break + } + notify() + } + + bus.subscribe(apply) + return { + apply, + list: () => snapshot, + get: id => byId.get(id), + subscribe: fn => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + getSnapshot: () => snapshot, + } +} +``` + +- [ ] **Step 4:运行测试** + +Run: `bun test src/workflow/__tests__/progressStore.test.ts` +Expected: 4 PASS。 + +- [ ] **Step 5:提交** + +```bash +git add src/workflow/progress/store.ts src/workflow/__tests__/progressStore.test.ts +git commit -m "feat(workflow): progress store keyed by agentId (fixes concurrent correlation race)" +``` + +--- + +## Phase M3:后端 + Registry + ports + +### Task 6:深度后端 `backends/claudeCodeBackend.ts` + +**Files:** +- Create: `src/workflow/backends/claudeCodeBackend.ts` +- Test: `src/workflow/__tests__/claudeCodeBackend.test.ts` + +> 说明:把旧 `adapter.ts` 的 `runWorkflowSubAgent` 逻辑抽成 `AgentAdapter`,并加 agentType→真实注册表、model→映射解析。 + +- [ ] **Step 1:写失败测试(mock `runAgent`/`assembleToolPool`/`finalizeAgentTool`)** + +Create `src/workflow/__tests__/claudeCodeBackend.test.ts`: + +```ts +import { expect, test, mock } from 'bun:test' + +// mock 底层依赖(不 mock 被测业务模块) +mock.module('@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', () => ({ + runAgent: async function* () { + yield { type: 'assistant', message: { content: [{ type: 'text', text: 'agent-text' }] } } + }, +})) +mock.module('@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js', () => ({ + finalizeAgentTool: () => ({ + content: [{ type: 'text', text: 'agent-text' }], + usage: { output_tokens: 42 }, + totalTokens: 42, + }), + isBuiltInAgent: () => true, +})) +mock.module('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js', () => ({ + isBuiltInAgent: () => true, +})) +mock.module('../tools.js', () => ({ + assembleToolPool: () => ({ tools: [] }), +})) +mock.module('../utils/messages.js', () => ({ + createUserMessage: (o: { content: string }) => ({ role: 'user', content: o.content }), + extractTextContent: (_c: unknown, sep: string) => 'agent-text', +})) +mock.module('../utils/uuid.js', () => ({ createAgentId: () => 'agent-1' })) +mock.module('../services/analytics/index.js', () => ({ logEvent: () => {} })) +mock.module('../utils/debug.js', () => ({ logForDebugging: () => {} })) + +import { claudeCodeBackend } from '../backends/claudeCodeBackend.js' +import { makeHostHandle } from '../hostHandle.js' + +function ctx() { + return { host: makeHostHandle({ + toolUseContext: { + options: { agentDefinitions: { activeAgents: [] }, querySource: 'workflow', mainLoopModel: 'm' }, + getAppState: () => ({ toolPermissionContext: { mode: 'acceptEdits', alwaysAllowRules: {} }, mcp: { tools: [] } }), + } as never, + canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never, + parentMessage: undefined, + }), signal: new AbortController().signal, runId: 'r1' } +} + +test('文本 agent → ok + token 计量', async () => { + const res = await claudeCodeBackend.run({ prompt: 'do it' }, ctx()) + expect(res.kind).toBe('ok') + if (res.kind === 'ok') { + expect(res.output).toBe('agent-text') + expect(res.usage.outputTokens).toBe(42) + } +}) + +test('runAgent 抛错 → dead', async () => { + mock.module('@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', () => ({ + runAgent: async function* () { throw new Error('boom') }, + })) + const res = await claudeCodeBackend.run({ prompt: 'fail' }, ctx()) + expect(res.kind).toBe('dead') +}) + +test('id 与 capabilities 形状', () => { + expect(claudeCodeBackend.id).toBe('claude-code') + expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true) +}) +``` + +- [ ] **Step 2:运行确认失败** + +Run: `bun test src/workflow/__tests__/claudeCodeBackend.test.ts` +Expected: FAIL(模块不存在)。 + +- [ ] **Step 3:实现 `claudeCodeBackend.ts`** + +Create `src/workflow/backends/claudeCodeBackend.ts`: + +```ts +import { + type AgentAdapter, + type AgentAdapterContext, + type AgentRunParams, + type AgentRunResult, +} from '@claude-code-best/workflow-engine' +import { assembleToolPool } from '../../tools.js' +import { finalizeAgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js' +import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js' +import { + isBuiltInAgent, + type AgentDefinition, + type BuiltInAgentDefinition, +} from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' +import { createUserMessage, extractTextContent } from '../../utils/messages.js' +import { createAgentId } from '../../utils/uuid.js' +import { logForDebugging } from '../../utils/debug.js' +import { logEvent } from '../../services/analytics/index.js' +import type { Message } from '../../types/message.js' +import type { ToolUseContext } from '../../Tool.js' +import { readHostBundle } from '../hostHandle.js' + +/** workflow 子 agent 的兜底定义(agentType 未命中真实注册表时用)。 */ +const WORKFLOW_AGENT: BuiltInAgentDefinition = { + agentType: 'workflow-worker', + whenToUse: 'workflow 脚本内 agent() 钩子派发的子任务', + tools: ['*'], + source: 'built-in', + baseDir: 'built-in', + getSystemPrompt: () => + 'You are a workflow sub-agent. Complete the task concisely; your final text is the return value relayed to the workflow.', +} + +/** agentType → 真实 agent 注册表(activeAgents 命中即用,否则兜底)。 */ +function resolveAgentDefinition( + agentType: string | undefined, + toolUseContext: ToolUseContext, +): AgentDefinition { + if (!agentType) return WORKFLOW_AGENT + const found = toolUseContext.options.agentDefinitions.activeAgents.find( + a => a.agentType === agentType, + ) + return found ?? WORKFLOW_AGENT +} + +/** model 别名 → 当前 provider 实际 model id。v1 直传(保留映射扩展点)。 */ +function mapWorkflowModel(model: string | undefined): string | undefined { + return model +} + +/** 从 agent 最终消息中提取 StructuredOutput 产出的 JSON 对象;失败返回 null。 */ +function extractStructuredOutput( + content: Array<{ type: string; text?: string }>, +): unknown | null { + for (const block of content) { + if (block.type === 'text' && block.text) { + const trimmed = block.text.trim() + const start = trimmed.indexOf('{') + const end = trimmed.lastIndexOf('}') + if (start >= 0 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)) + } catch { + // 继续 + } + } + } + } + return null +} + +/** 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。 */ +export const claudeCodeBackend: AgentAdapter = { + id: 'claude-code', + capabilities: { structuredOutput: true, tools: true }, + + async run(params: AgentRunParams, ctx: AgentAdapterContext): Promise { + const { toolUseContext, canUseTool } = readHostBundle(ctx.host) + const appState = toolUseContext.getAppState() + const agentDef = resolveAgentDefinition(params.agentType, toolUseContext) + const model = mapWorkflowModel(params.model) + const agentId = createAgentId() + + const workerPermissionContext = { + ...appState.toolPermissionContext, + mode: agentDef.permissionMode ?? 'acceptEdits', + } + const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools) + + const promptText = params.schema + ? `${params.prompt}\n\nYou MUST return your final answer by calling the StructuredOutput tool with a value matching this JSON Schema:\n${JSON.stringify(params.schema)}` + : params.prompt + const promptMessages = [createUserMessage({ content: promptText })] + const messages: Message[] = [] + const startTime = Date.now() + + try { + for await (const msg of runAgent({ + agentDefinition: agentDef, + promptMessages, + toolUseContext, + canUseTool, + isAsync: true, + querySource: toolUseContext.options.querySource ?? 'workflow', + availableTools: workerTools, + override: { agentId, ...(model ? { model: model as never } : {}) }, + ...(params.maxTokens ? { maxTokens: params.maxTokens as never } : {}), + })) { + messages.push(msg as Message) + } + } catch (e) { + logForDebugging(`workflow sub-agent error: ${(e as Error).message}`) + return { kind: 'dead' } + } + + const finalized = finalizeAgentTool(messages, agentId, { + prompt: params.prompt, + resolvedAgentModel: toolUseContext.options.mainLoopModel, + isBuiltInAgent: isBuiltInAgent(agentDef), + startTime, + agentType: agentDef.agentType, + isAsync: true, + }) + const outputTokens = finalized.usage?.output_tokens ?? finalized.totalTokens ?? 0 + logEvent('tengu_workflow_agent', { + agentType: agentDef.agentType, ok: true, outputTokens, + }) + + if (params.schema) { + const structured = extractStructuredOutput(finalized.content) + if (structured === null) return { kind: 'dead' } + return { kind: 'ok', output: structured as object, usage: { outputTokens } } + } + const text = extractTextContent(finalized.content, '\n') + return { kind: 'ok', output: text, usage: { outputTokens } } + }, +} +``` + +- [ ] **Step 4:运行测试** + +Run: `bun test src/workflow/__tests__/claudeCodeBackend.test.ts` +Expected: 3 PASS。 + +- [ ] **Step 5:提交** + +```bash +git add src/workflow/backends/claudeCodeBackend.ts src/workflow/__tests__/claudeCodeBackend.test.ts +git commit -m "feat(workflow): claude-code AgentAdapter (deep AppState/provider/agent resolution)" +``` + +### Task 7:Registry + ports 组装 + +**Files:** +- Create: `src/workflow/registry.ts` +- Create: `src/workflow/ports.ts` +- Test: `src/workflow/__tests__/ports.test.ts` + +- [ ] **Step 1:写失败测试** + +Create `src/workflow/__tests__/ports.test.ts`: + +```ts +import { expect, test } from 'bun:test' +import { buildRegistry } from '../registry.js' +import { createWorkflowPorts } from '../ports.js' +import { createProgressBus } from '../progress/bus.js' +import { createProgressStoreFromBus } from '../progress/store.js' + +test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => { + const reg = buildRegistry() + expect(reg.has('claude-code')).toBe(true) + expect(reg.resolve({ prompt: 'x' }).id).toBe('claude-code') + expect(reg.resolve({ prompt: 'x', agentType: 'whatever' }).id).toBe('claude-code') +}) + +test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 progressEmitter→bus)', () => { + const bus = createProgressBus() + const store = createProgressStoreFromBus(bus) + const ports = createWorkflowPorts({ bus, store }) + expect(ports.agentAdapterRegistry).toBeDefined() + expect(ports.agentAdapterRegistry!.resolve({ prompt: 'x' }).id).toBe('claude-code') + expect(typeof ports.taskRegistrar.register).toBe('function') + expect(typeof ports.hostFactory).toBe('function') +}) +``` + +- [ ] **Step 2:运行确认失败** + +Run: `bun test src/workflow/__tests__/ports.test.ts` +Expected: FAIL(模块不存在)。 + +- [ ] **Step 3:实现 `registry.ts`** + +Create `src/workflow/registry.ts`: + +```ts +import { type AgentAdapterRegistry } from '@claude-code-best/workflow-engine' +import { claudeCodeBackend } from './backends/claudeCodeBackend.js' + +/** + * 构建多后端 registry。v1(depth B)只注册单一 claude-code adapter 为默认, + * 不预填路由规则——扩第二个 provider adapter 时再补 .route(...)。 + */ +export function buildRegistry(): AgentAdapterRegistry { + const reg = new AgentAdapterRegistry() + reg.register(claudeCodeBackend).default('claude-code') + return reg +} +``` + +> 注:`AgentAdapterRegistry` 是 class(引擎导出),`new` 可用。 + +- [ ] **Step 4:实现 `ports.ts`** + +Create `src/workflow/ports.ts`: + +```ts +import { + createFileJournalStore, + type ProgressEvent, + type WorkflowPorts, +} from '@claude-code-best/workflow-engine' +import { getCwd } from '../utils/cwd.js' +import { logForDebugging } from '../utils/debug.js' +import { getProjectRoot } from '../bootstrap/state.js' +import { logEvent } from '../services/analytics/index.js' +import { + registerLocalWorkflowTask, + completeWorkflowTask, + failWorkflowTask, + killWorkflowTask, +} from '../tasks/LocalWorkflowTask/LocalWorkflowTask.js' +import { makeHostHandle, readHostBundle, type WorkflowHostBundle } from './hostHandle.js' +import { buildRegistry } from './registry.js' +import type { ProgressBus } from './progress/bus.js' +import type { ProgressStore } from './progress/store.js' +import type { SetAppState } from '../Task.js' + +type RunBinding = { + runId: string + taskId: string + setAppState: SetAppState + abortController: AbortController + workflowName: string +} + +/** 每次工具调用从 toolUseContext 构造 WorkflowHostContext。 */ +function makeHostFactory(): WorkflowPorts['hostFactory'] { + return ({ context, canUseTool, parentMessage }) => { + const ctx = context as WorkflowHostBundle['toolUseContext'] + return { + handle: makeHostHandle({ + toolUseContext: ctx, + canUseTool: canUseTool as WorkflowHostBundle['canUseTool'], + parentMessage: parentMessage as WorkflowHostBundle['parentMessage'], + agentId: ctx.agentId, + }), + cwd: getCwd(), + budgetTotal: null, // turn 级预算注入点(未来从 settings 读) + toolUseId: ctx.toolUseId, + } + } +} + +/** + * 组装完整 WorkflowPorts。bus/store 由调用方传入(service 单例共享)。 + * taskRegistrar 维护 runId → RunBinding 供 kill 路由。 + */ +export function createWorkflowPorts(opts: { + bus: ProgressBus + store: ProgressStore +}): WorkflowPorts { + const bindings = new Map() + const runsDir = `${getProjectRoot()}/.claude/workflow-runs` + const registry = buildRegistry() + + // 遥测订阅(独立于 store) + opts.bus.subscribe((e: ProgressEvent) => { + if (e.type === 'run_done') { + logEvent('tengu_workflow_done', { status: e.status, runId: e.runId }) + } + }) + + return { + hostFactory: makeHostFactory(), + agentAdapterRegistry: registry, + + progressEmitter: { + emit(event) { + opts.bus.emit(event) // → store reducer + 遥测 + }, + }, + + taskRegistrar: { + register(regOpts, host) { + const bundle = readHostBundle(host) + const setAppState = + bundle.toolUseContext.setAppStateForTasks ?? bundle.toolUseContext.setAppState + const abortController = new AbortController() + const taskId = registerLocalWorkflowTask(setAppState, { + description: regOpts.summary ?? regOpts.workflowName, + workflowName: regOpts.workflowName, + workflowFile: regOpts.workflowFile ?? '', + summary: regOpts.summary, + ...(regOpts.toolUseId ? { toolUseId: regOpts.toolUseId } : {}), + abortController, + }) + const runId = regOpts.runId ?? taskId + bindings.set(runId, { + runId, taskId, setAppState, abortController, workflowName: regOpts.workflowName, + }) + logEvent('tengu_workflow_started', { runId }) + return { runId, signal: abortController.signal } + }, + complete(runId, summary) { + const b = bindings.get(runId) + if (!b) return + completeWorkflowTask(b.taskId, b.setAppState) + logForDebugging(`workflow ${runId} completed: ${summary ?? ''}`) + }, + fail(runId, error) { + const b = bindings.get(runId) + if (!b) return + failWorkflowTask(b.taskId, b.setAppState) + logForDebugging(`workflow ${runId} failed: ${error}`) + }, + kill(runId) { + const b = bindings.get(runId) + if (!b) return + killWorkflowTask(b.taskId, b.setAppState) // abort controller 内置 + }, + pendingAction() { + return null // v1:skip/retry 不接线(seam 保留) + }, + }, + + journalStore: createFileJournalStore(runsDir), + + permissionGate: { + // 引擎用 ctx.signal(register 返回的 AbortController)判 abort + isAborted: () => false, + }, + + logger: { + debug: msg => logForDebugging(msg), + event: name => logForDebugging(`workflow event: ${name}`), + }, + } +} +``` + +- [ ] **Step 5:运行测试** + +Run: `bun test src/workflow/__tests__/ports.test.ts` +Expected: 2 PASS。 + +- [ ] **Step 6:提交** + +```bash +git add src/workflow/registry.ts src/workflow/ports.ts src/workflow/__tests__/ports.test.ts +git commit -m "feat(workflow): AgentAdapterRegistry + WorkflowPorts assembly" +``` + +--- + +## Phase M4:Service 门面 + +### Task 8:`WorkflowService` 单例 + +**Files:** +- Create: `src/workflow/service.ts` +- Test: `src/workflow/__tests__/service.test.ts` + +- [ ] **Step 1:写失败测试(mock 端口,无 LLM)** + +Create `src/workflow/__tests__/service.test.ts`: + +```ts +import { expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// service 用真实 ports(registry/bus/store)+ mock taskRegistrar;不触发 LLM(registry adapter 被 mock) +mock.module('../backends/claudeCodeBackend.js', () => ({ + claudeCodeBackend: { + id: 'claude-code', + capabilities: { structuredOutput: true }, + async run() { + return { kind: 'ok', output: 'mock-out', usage: { outputTokens: 1 } } + }, + }, +})) +mock.module('../utils/cwd.js', () => ({ getCwd: () => '/tmp' })) +mock.module('../bootstrap/state.js', () => ({ getProjectRoot: () => '/tmp' })) +mock.module('../services/analytics/index.js', () => ({ logEvent: () => {} })) +mock.module('../utils/debug.js', () => ({ logForDebugging: () => {} })) +mock.module('../tasks/LocalWorkflowTask/LocalWorkflowTask.js', () => ({ + registerLocalWorkflowTask: () => 'task-1', + completeWorkflowTask: () => {}, failWorkflowTask: () => {}, killWorkflowTask: () => {}, +})) +mock.module('../tools.js', () => ({ assembleToolPool: () => ({ tools: [] }) })) + +import { getWorkflowService } from '../service.js' + +function tmpRuns() { + return mkdtemp(join(tmpdir(), 'wf-svc-')) +} + +test('launch → completed,store 出现该 run;kill 走 taskRegistrar', async () => { + const dir = await tmpRuns() + try { + process.env.WORKFLOW_RUNS_DIR = dir + const svc = getWorkflowService() + const { runId } = await svc.launch( + { script: `return agent('compute')` }, + { /* toolUseContext stub */ } as never, + (() => Promise.resolve({ behavior: 'allow' })) as never, + ) + // 等待 detached run + await new Promise(r => setTimeout(r, 60)) + const r = svc.getRun(runId) + expect(r).toBeDefined() + expect(['completed', 'running']).toContain(r!.status) + } finally { + await rm(dir, { recursive: true, force: true }) + delete process.env.WORKFLOW_RUNS_DIR + } +}) + +test('listNamed 委托 namedWorkflows(空目录→[])', async () => { + const svc = getWorkflowService() + const names = await svc.listNamed(join(tmpdir(), 'wf-nope-' + Math.random())) + expect(names).toEqual([]) +}) + +test('subscribe 返回取消订阅', () => { + const svc = getWorkflowService() + let n = 0 + const unsub = svc.subscribe(() => n++) + unsub() + expect(typeof unsub).toBe('function') + expect(n).toBe(0) +}) +``` + +> 注:`mock` 需在顶部导入:把 `import { expect, test, mock } from 'bun:test'`(首行)。`launch` 的第三参为 canUseTool。 + +- [ ] **Step 2:运行确认失败** + +Run: `bun test src/workflow/__tests__/service.test.ts` +Expected: FAIL(`../service.js` 不存在)。 + +- [ ] **Step 3:实现 `service.ts`** + +Create `src/workflow/service.ts`: + +```ts +import { + createFileJournalStore, + createHostHandle, + parseScript, + runWorkflow, + type WorkflowHostContext, + type WorkflowInput, + type WorkflowPorts, + WORKFLOW_DIR_NAME, + resolveNamedWorkflow, + listNamedWorkflows, +} from '@claude-code-best/workflow-engine' +import { getCwd } from '../utils/cwd.js' +import { logForDebugging } from '../utils/debug.js' +import { getProjectRoot } from '../bootstrap/state.js' +import { logEvent } from '../services/analytics/index.js' +import { makeHostHandle, type WorkflowHostBundle } from './hostHandle.js' +import { createProgressBus } from './progress/bus.js' +import { createProgressStoreFromBus, type ProgressStore } from './progress/store.js' +import { createWorkflowPorts } from './ports.js' +import type { CanUseToolFn } from '../hooks/useCanUseTool.js' +import type { ToolUseContext } from '../Tool.js' +import type { RunProgress } from './progress/store.js' + +export type WorkflowService = { + /** 共享端口(工具描述符用)。 */ + ports: WorkflowPorts + /** 面板/工具启动 workflow:解析脚本 → register → detached runWorkflow。 */ + launch( + input: Pick, + toolUseContext: ToolUseContext, + canUseTool: CanUseToolFn, + ): Promise<{ runId: string }> + kill(runId: string): void + listRuns(): RunProgress[] + getRun(runId: string): RunProgress | undefined + subscribe(listener: () => void): () => void + listNamed(workflowDir?: string): Promise +} + +let cached: WorkflowService | null = null + +/** 进程单例。工具与面板共享同一 ports/registry/store。 */ +export function getWorkflowService(): WorkflowService { + if (cached) return cached + const bus = createProgressBus() + const store: ProgressStore = createProgressStoreFromBus(bus) + const ports = createWorkflowPorts({ bus, store }) + cached = makeService(ports, store) + return cached +} + +/** 测试用:注入 ports。 */ +export function makeService(ports: WorkflowPorts, store: ProgressStore): WorkflowService { + const runsDir = () => + process.env.WORKFLOW_RUNS_DIR ?? `${getProjectRoot()}/.claude/workflow-runs` + + const buildHost = ( + toolUseContext: ToolUseContext, + canUseTool: CanUseToolFn, + ): WorkflowHostContext => ({ + handle: makeHostHandle({ + toolUseContext, + canUseTool, + parentMessage: undefined, + agentId: toolUseContext.agentId, + } as WorkflowHostBundle), + cwd: getCwd(), + budgetTotal: null, + toolUseId: toolUseContext.toolUseId, + }) + + async function resolveSource(input: { + script?: string; name?: string; scriptPath?: string + }): Promise<{ script: string; workflowFile?: string; workflowName: string }> { + if (input.script) return { script: input.script, workflowName: input.name ?? 'workflow' } + if (input.scriptPath) { + const { readFile } = await import('node:fs/promises') + return { + script: await readFile(input.scriptPath, 'utf-8'), + workflowFile: input.scriptPath, + workflowName: input.name ?? 'workflow', + } + } + if (input.name) { + const found = await resolveNamedWorkflow(join(getCwd(), WORKFLOW_DIR_NAME), input.name) + if (!found) throw new Error(`命名 workflow "${input.name}" 未找到(查找 ${WORKFLOW_DIR_NAME}/)`) + return { script: found.content, workflowFile: found.path, workflowName: input.name } + } + throw new Error('必须提供 script、name 或 scriptPath 之一') + } + + return { + ports, + + async launch(input, toolUseContext, canUseTool) { + const { script, workflowFile, workflowName } = await resolveSource(input) + try { + parseScript(script) // 快速校验,失败抛 + } catch (e) { + throw new Error(`脚本校验失败:${(e as Error).message}`) + } + const host = buildHost(toolUseContext, canUseTool) + const { runId, signal } = ports.taskRegistrar.register( + { + workflowName, + ...(workflowFile ? { workflowFile } : {}), + ...(input.description ? { summary: input.description } : {}), + ...(host.toolUseId ? { toolUseId: host.toolUseId } : {}), + ...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}), + }, + host.handle, + ) + // detached 执行 + void runWorkflow({ + script, + ...(input.args !== undefined ? { args: input.args } : {}), + runId, + workflowName, + ports, + host: host.handle, + signal, + cwd: host.cwd, + budgetTotal: host.budgetTotal, + ...(input.resumeFromRunId ? { resume: true } : {}), + }).then(result => { + if (result.status === 'completed') ports.taskRegistrar.complete(runId) + else if (result.status === 'failed') ports.taskRegistrar.fail(runId, result.error ?? 'failed') + else ports.taskRegistrar.kill(runId) + }).catch(e => ports.taskRegistrar.fail(runId, (e as Error).message)) + logForDebugging(`workflow launched: ${runId} (${workflowName})`) + return { runId } + }, + + kill(runId) { + ports.taskRegistrar.kill(runId) + }, + listRuns: () => store.list(), + getRun: id => store.get(id), + subscribe: fn => store.subscribe(fn), + async listNamed(workflowDir) { + return listNamedWorkflows(workflowDir ?? join(getCwd(), WORKFLOW_DIR_NAME)) + }, + } +} + +// 兼容:旧 ports.ts 用 createFileJournalStore(已由 ports.ts 内部用;此处保留导入以备测试覆盖) +export { createHostHandle, createFileJournalStore } +export type { WorkflowInput } +``` + +> 注:`createFileJournalStore`/`createHostHandle` 在 service 里未直接用(ports.ts 用),re-export 仅防 lint 误报未用导入;若 `bunx tsc` 报未使用,删除该行 re-export。 + +- [ ] **Step 4:运行测试** + +Run: `bun test src/workflow/__tests__/service.test.ts` +Expected: 3 PASS。若 `launch` 测试因 mock 路径不匹配而 fail,检查 `mock.module` 的 specifier 与 `service.ts` 实际 import 路径一致。 + +- [ ] **Step 5:提交** + +```bash +git add src/workflow/service.ts src/workflow/__tests__/service.test.ts +git commit -m "feat(workflow): WorkflowService facade (launch/kill/subscribe/listNamed)" +``` + +--- + +## Phase M5:工具 wiring + 去 WorkflowDetailDialog + +### Task 9:重写 `wiring.ts`(走 service) + +**Files:** +- Rewrite: `src/workflow/wiring.ts` + +- [ ] **Step 1:整体替换 `wiring.ts`** + +Replace entire `src/workflow/wiring.ts` with: + +```ts +import { + createWorkflowTool, + type WorkflowToolDescriptor, +} from '@claude-code-best/workflow-engine' +import { buildTool, type Tool } from '../Tool.js' +import { getWorkflowService } from './service.js' + +/** + * 把引擎自包含描述符适配为 buildTool 兼容的 Tool。 + * 描述符统一走 service 单例(共享 ports/registry/store)。 + */ +function buildWorkflowTool(): Tool { + const { ports } = getWorkflowService() + const descriptor: WorkflowToolDescriptor = createWorkflowTool(ports) + return buildTool({ + name: descriptor.name, + maxResultSizeChars: 50_000, + inputSchema: descriptor.inputSchema, + isEnabled: () => descriptor.isEnabled(), + isReadOnly: input => descriptor.isReadOnly(input), + isConcurrencySafe: () => true, + async description() { + return descriptor.description() + }, + async prompt() { + return descriptor.prompt() + }, + async call(input, context, canUseTool, parentMessage, onProgress) { + const result = await descriptor.call( + input, context, canUseTool, parentMessage, onProgress, + ) + return { data: result.data } + }, + renderToolUseMessage: input => descriptor.renderToolUseMessage(input), + mapToolResultToToolResultBlockParam: (data, toolUseId) => + descriptor.mapToolResultToToolResultBlockParam(data, toolUseId), + }) +} + +// 单例:tools.ts 注册与 PermissionRequest 引用需为同一实例(switch 按引用匹配)。 +let cached: Tool | null = null + +export function createWorkflowToolCore(): Tool { + if (!cached) cached = buildWorkflowTool() + return cached +} +``` + +- [ ] **Step 2:删除旧 `adapter.ts` 与 `progressStore.ts`** + +```bash +git rm src/workflow/adapter.ts src/workflow/progressStore.ts +``` + +> 校验无残留引用:`grep -rn "workflow/adapter\|workflow/progressStore" src` 应仅命中本计划新增的 progress/ 目录(`progress/store.ts` 路径不同,不算)。若命中旧路径引用,改为新模块。 + +- [ ] **Step 3:类型检查 + lint** + +Run: `bunx tsc --noEmit 2>&1 | grep -E "workflow|error" | head` +Expected: 零错误(`createWorkflowToolCore`/`createWorkflowAdapter` 旧引用已清除——`wiring.ts` 不再 import adapter)。 + +- [ ] **Step 4:提交** + +```bash +git add src/workflow/wiring.ts +git commit -m "refactor(workflow): wiring via WorkflowService singleton; drop legacy adapter/progressStore" +``` + +### Task 10:`BackgroundTasksDialog` 去 `WorkflowDetailDialog` + +**Files:** +- Modify: `src/components/tasks/BackgroundTasksDialog.tsx:110-112, 443-463` + +- [ ] **Step 1:读当前 local_workflow 渲染分支** + +Run: `sed -n '108,120p;440,465p' src/components/tasks/BackgroundTasksDialog.tsx` +确认 line 110-112 的 `WorkflowDetailDialog` 条件导入、line 443-463 的 `case 'local_workflow'` 渲染 ``。 + +- [ ] **Step 2:移除 `WorkflowDetailDialog` 导入** + +把(约 110-112 行): + +```ts +const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') + ? (require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js')).WorkflowDetailDialog + : null; +``` + +替换为: + +```ts +// WorkflowDetailDialog 已移除:workflow 详情改由 /workflows 面板展示。 +``` + +- [ ] **Step 3:`case 'local_workflow'` 改为内联摘要 + /workflows 提示** + +把(约 443 行起的)`case 'local_workflow':` 分支中渲染 `` 的部分,替换为内联摘要(具体 JSX 视 Step 1 读到的实际结构而定,保留外层容器与 `key`)。示例替换(若原结构为 `return `): + +```tsx + case 'local_workflow': + if (!task) return null; + return ( + + {task.workflowName} + + {task.status} · {task.summary ?? task.description} + + 用 /workflows 查看阶段与 agent 实时进度 + + ); +``` + +> 注:`Box`/`Text` 已在该文件顶部从 `@anthropic/ink` 导入(确认存在;若无则补 `import { Box, Text } from '@anthropic/ink'`)。 + +- [ ] **Step 4:删除 `WorkflowDetailDialog.tsx`** + +```bash +git rm src/components/tasks/WorkflowDetailDialog.tsx +``` + +- [ ] **Step 5:校验无残留引用** + +Run: `grep -rn "WorkflowDetailDialog" src` +Expected: 无输出(或仅注释)。 + +- [ ] **Step 6:类型检查 + 测试** + +Run: `bunx tsc --noEmit 2>&1 | grep -iE "backgroundtasks|workflow" | head` +Expected: 零错误。 + +- [ ] **Step 7:提交** + +```bash +git add src/components/tasks/BackgroundTasksDialog.tsx +git commit -m "refactor(tasks): drop WorkflowDetailDialog; workflow detail now in /workflows panel" +``` + +- [ ] **Step 8:里程碑 M5 全量 precheck** + +Run: `bun run precheck` +Expected: typecheck + lint fix + test 全绿。 + +```bash +git commit --allow-empty -m "chore(workflow): M5 integration switch — precheck green" +``` + +--- + +## Phase M6:`/workflows` 双栏面板 + +### Task 11:`WorkflowList`(左栏) + +**Files:** +- Create: `src/workflow/panel/WorkflowList.tsx` + +- [ ] **Step 1:实现左栏扁平列表** + +Create `src/workflow/panel/WorkflowList.tsx`: + +```tsx +import React from 'react' +import { Box, Text } from '@anthropic/ink' +import type { RunProgress } from '../progress/store.js' + +const STATUS_DOT: Record = { + running: '●', completed: '✓', failed: '✗', killed: '■', +} + +type Props = { + runs: RunProgress[] + named: string[] + selected: number +} + +/** 左栏:扁平 workflow 列表(状态点+名+当前 phase+计数)+ NAMED 区。 */ +export function WorkflowList({ runs, named, selected }: Props): React.ReactNode { + const rows = runs + return ( + + {rows.length === 0 ? ( + No active runs. + ) : ( + rows.map((r, i) => ( + + + {i === selected ? '▸ ' : ' '} + + + {STATUS_DOT[r.status]} + + {r.workflowName.padEnd(20).slice(0, 20)} + + {' '} + {r.currentPhase ?? (r.status === 'completed' ? 'done' : r.status)}{' '} + {r.agents.length}/{r.agentCount} + + + )) + )} + {named.length > 0 && ( + + Named: + {' ' + named.join(' · ')} + + )} + + ) +} +``` + +### Task 12:`WorkflowDetail`(右栏) + +**Files:** +- Create: `src/workflow/panel/WorkflowDetail.tsx` + +- [ ] **Step 1:实现右栏 phase 横条 + 扁平 agent 列表** + +Create `src/workflow/panel/WorkflowDetail.tsx`: + +```tsx +import React from 'react' +import { Box, Text } from '@anthropic/ink' +import type { AgentProgress, RunProgress } from '../progress/store.js' + +function phaseMark(status: 'running' | 'done'): string { + return status === 'done' ? '✓' : '●' +} + +function agentMark(a: AgentProgress): string { + if (a.status === 'done') return a.resultKind === 'ok' ? '✓' : a.resultKind === 'dead' ? '✗' : '✓' + return '●' +} + +type Props = { run: RunProgress | undefined } + +/** 右栏:聚焦 workflow 的 phase 横条 + 扁平 agent 列表。 */ +export function WorkflowDetail({ run }: Props): React.ReactNode { + if (!run) { + return ( + 选择左侧一个 workflow,或按 n 启动命名 workflow。 + ) + } + return ( + + + {run.workflowName} + + {' ' + (run.status === 'running' ? '● running' : run.status)} + + + {run.phases.length > 0 && ( + + Phases + + {run.phases.map(p => `${phaseMark(p.status)}${p.title}`).join(' ')} + + + )} + {run.agents.length > 0 && ( + + {run.agents.map(a => ( + + {agentMark(a)} + {(a.label ?? `agent-${a.id}`).padEnd(16).slice(0, 16)} + {a.phase ?? ''} + + ))} + + )} + {run.status !== 'running' && run.returnValue != null && ( + + → {String(run.returnValue).slice(0, 80)} + + )} + {run.error && ( + + {run.error} + + )} + + ) +} +``` + +### Task 13:键位 hook `useWorkflowKeyboard` + +**Files:** +- Create: `src/workflow/panel/useWorkflowKeyboard.ts` + +- [ ] **Step 1:实现键位(j/k/r/x/n/q)** + +Create `src/workflow/panel/useWorkflowKeyboard.ts`: + +```ts +import { useEffect } from 'react' +import type { useInput } from '@anthropic/ink' + +type Actions = { + move: (delta: number) => void + resume: () => void + kill: () => void + newNamed: () => void + quit: () => void +} + +/** 绑定 j/k/r/x/n/q/esc。input/useInput 由 @anthropic/ink 提供。 */ +export function useWorkflowKeyboard( + input: ReturnType, + actions: Actions, +): void { + useEffect(() => { + const handler = (key: string): void => { + switch (key) { + case 'j': actions.move(1); break + case 'k': actions.move(-1); break + case 'r': actions.resume(); break + case 'x': actions.kill(); break + case 'n': actions.newNamed(); break + case 'q': + case 'escape': actions.quit(); break + } + } + const off = input(handler) + return () => { off?.() } + }, [input, actions]) +} +``` + +> 注:`@anthropic/ink` 的 `useInput` 签名以仓库实际为准;若它是 hook 形式(`useInput((input, key) => {...})`),改为在 `WorkflowsPanel` 内直接 `useInput` 并把 `actions` 内联(见 Task 14 备选)。本 hook 适用于"返回注册函数"形态。 + +### Task 14:`WorkflowsPanel` + local-jsx 命令 + +**Files:** +- Create: `src/workflow/panel/WorkflowsPanel.tsx` +- Rewrite: `src/commands/workflows/index.ts` +- Test: `src/workflow/__tests__/WorkflowsPanel.test.tsx` + +- [ ] **Step 1:实现面板(useSyncExternalStore 订阅 service)** + +Create `src/workflow/panel/WorkflowsPanel.tsx`: + +```tsx +import React, { useState, useSyncExternalStore } from 'react' +import { Box, Text, useInput } from '@anthropic/ink' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import type { ToolUseContext } from '../../Tool.js' +import { getWorkflowService } from '../service.js' +import { WorkflowList } from './WorkflowList.js' +import { WorkflowDetail } from './WorkflowDetail.js' + +type Ctx = ToolUseContext & { /* LocalJSXCommandContext 扩展,按需 */ } + +export function WorkflowsPanel({ + onDone, + context, + args, +}: { + onDone: LocalJSXCommandOnDone + context: Ctx + args: string +}): React.ReactNode { + const svc = getWorkflowService() + const runs = useSyncExternalStore(svc.subscribe, () => svc.listRuns(), () => []) + const [named, setNamed] = useState([]) + const [selected, setSelected] = useState(0) + + // 初次加载命名 workflow 列表 + if (named.length === 0 && runs.length === 0) { + void svc.listNamed().then(setNamed).catch(() => {}) + } + + const focused = runs[Math.min(selected, Math.max(0, runs.length - 1))] + + useInput((input, key) => { + if (input === 'j') setSelected(s => Math.min(runs.length - 1, s + 1)) + else if (input === 'k') setSelected(s => Math.max(0, s - 1)) + else if (input === 'x' && focused) svc.kill(focused.runId) + else if (input === 'r' && focused) { + // resume:用当前会话上下文重跑(读 journal) + void svc.launch({ resumeFromRunId: focused.runId, name: focused.workflowName }, context, context.options.canUseTool ?? (() => Promise.resolve({ behavior: 'allow' })) as never) + } else if (input === 'n') { + // 简化:提示用户输入命名 workflow;完整选择器留作后续 + onDone('Tip: 用 / 启动命名 workflow,或通过 Workflow 工具带 name 参数。') + } else if (input === 'q' || key.escape) { + onDone() + } + }) + + return ( + + + Workflows + {runs.filter(r => r.status === 'running').length} running · {runs.filter(r => r.status !== 'running').length} done + + + + + + + j/k run · r resume · x kill · n new · q quit + + + ) +} +``` + +> 注:`context.options.canUseTool` 字段名以实际 `ToolUseContext` 为准;若不同,改用面板自带的会话权限解析(与 `useCanUseTool` 一致)。`borderStyle="round"` 等 prop 以 `@anthropic/ink` 支持为准。 + +- [ ] **Step 2:重写命令为 local-jsx** + +Replace entire `src/commands/workflows/index.ts`: + +```ts +import type { Command } from '../../types/command.js' + +const workflows = { + type: 'local-jsx', + name: 'workflows', + description: 'Workflow 监控面板:实时 run/phase/agent 进度,键盘控制', + isEnabled: undefined, + load: () => import('../../workflow/panel/WorkflowsPanel.js'), +} satisfies Command + +export default workflows +``` + +> 注:`load` 返回的模块须有 `call`(`LocalJSXCommandModule`)。若 `WorkflowsPanel` 导出的是组件而非 `{call}`,补一个 `panelCall.ts`: + +Create `src/workflow/panel/panelCall.ts`: + +```ts +import React from 'react' +import { WorkflowsPanel } from './WorkflowsPanel.js' +import type { LocalJSXCommandCall } from '../../../types/command.js' + +export const call: LocalJSXCommandCall = async (onDone, context, args) => + React.createElement(WorkflowsPanel, { onDone, context, args }) +``` + +并把命令 `load` 改为 `() => import('../../workflow/panel/panelCall.js')`。 + +- [ ] **Step 3:写面板测试** + +Create `src/workflow/__tests__/WorkflowsPanel.test.tsx`: + +```tsx +import { expect, test } from 'bun:test' +import React from 'react' +import { render } from 'ink-testing-library' +// 注:若 ink-testing-library 不可用,改用 @anthropic/ink 的 test 工具或快照 store 状态 + +// 直接测纯函数:聚焦选择逻辑 +function focusAt(runs: { runId: string }[], selected: number) { + return runs[Math.min(selected, Math.max(0, runs.length - 1))] +} + +test('focus clamp 到有效区间', () => { + const runs = [{ runId: 'a' }, { runId: 'b' }] + expect(focusAt(runs, 5)?.runId).toBe('b') + expect(focusAt(runs, -3)?.runId).toBe('a') + expect(focusAt(runs, 0)?.runId).toBe('a') +}) +``` + +> 注:ink 组件交互测试受 `@anthropic/ink` test harness 可用性约束;至少覆盖选择/夹紧纯逻辑。若仓库已有 ink-testing-library 依赖,补 `render()` 快照测试。 + +- [ ] **Step 4:类型检查 + 运行** + +Run: `bunx tsc --noEmit 2>&1 | grep -iE "panel|workflows" | head` +Expected: 零错误。 + +Run: `bun test src/workflow/__tests__/WorkflowsPanel.test.tsx` +Expected: PASS。 + +- [ ] **Step 5:里程碑 M6 precheck** + +Run: `bun run precheck` +Expected: 全绿。 + +- [ ] **Step 6:提交** + +```bash +git add src/workflow/panel/ src/commands/workflows/index.ts src/workflow/__tests__/WorkflowsPanel.test.tsx +git commit -m "feat(workflow): /workflows dual-pane monitoring + control panel (local-jsx)" +``` + +--- + +## Phase M7:`/ultracode` skill + +### Task 15:`SKILL.md` playbook + +**Files:** +- Create: `src/skills/bundled/ultracode/SKILL.md` + +- [ ] **Step 1:写 skill 内容** + +Create `src/skills/bundled/ultracode/SKILL.md`: + +```markdown +--- +name: ultracode +description: 进入多 agent workflow 编排模式——何时用 workflow、编排原语、质量模式、确定性约束、后端路由、resume/budget、文件与命令。调用即把这套工作法注入上下文。 +user-invocable: true +--- + +# UltraCode — 多 agent workflow 编排工作法 + +## 何时用 Workflow 工具 + +用,当任务满足任一: +- 可**分解/并行**(多文件、多维度、可独立推进的子任务)。 +- 需要**多视角置信**(如审查:先生成再对抗式验证)。 +- **规模超单上下文**(大迁移、广度审计)。 +- 需要 **resume / 可审计**(journal 重放、确定性回放)。 + +**不要用**:琐碎单文件改、单次问答、一次 Read 能解决的事——直接做。 + +## 编排原语(脚本内可用) + +- `agent(prompt, opts?)` — 派发一个子 agent;返回其最终文本(或 schema 对象)。 +- `parallel([()=>…])` — 并发跑,单项抛错 → `null`,其余保留。**无 barrier**。 +- `pipeline(items, stage1, stage2, …)` — 每个 item 链式过各 stage(item 间无 barrier,stage 间顺序)。 +- `phase(title)` — 标记阶段(进度面板按此展示)。 +- `log(msg)` — 进度日志。 +- `workflow(name|{scriptPath}, args?)` — 嵌套一层子 workflow(仅允许一层)。 + +## 确定性约束(关键) + +脚本内**禁用** `Date.now()` / `Math.random()` / 无参 `new Date()`(破坏 resume)。 +时间戳/随机种子经 `args` 传入。`export const meta = {...}` 必须是**纯字面量**。 + +## 质量模式(每种给最小片段) + +- **Adversarial verify**:`parallel([()=>agent(claim), ()=>agent(refute)])`,多数 refute 即弃。 +- **Loop-until-dry**:`while (fresh.length) { found = await parallel(...); fresh = dedup(found) }`。 +- **Multi-modal sweep**:多个 agent 各用不同搜索角度。 +- **Judge panel**:N 个独立方案 → 评分 → 取胜者嫁接他者亮点。 +- **Completeness critic**:末尾一个 agent 问"还缺什么"。 + +## 后端路由 + +`AgentAdapterRegistry` 按 model/agentType 路由。v1 默认 `claude-code` 后端(深度读会话 provider/model/agent 体系)。`agent({model:'claude-haiku-*', agentType:'Explore'})` 走真实注册表。 + +## resume / budget + +- `resumeFromRunId: ''` — 重放 journal,已完成 agent() 秒回。 +- `budget.total` — token 硬顶(默认无限);`budget.spent()/remaining()` 读。 + +## 文件与命令 + +- 脚本目录:`.claude/workflows/.ts|js|mjs` → 自动成 `/` 命令。 +- run 记录:`.claude/workflow-runs//journal.jsonl`。 +- 监控面板:`/workflows`(双栏:左 run 列表,右 phase+agent;j/k/r/x/n/q)。 +- 工具:`Workflow`(input: `script`/`name`/`scriptPath`/`args`/`resumeFromRunId`)。 +``` + +- [ ] **Step 2:验证被发现为 `/ultracode`** + +Run: `FEATURE_WORKFLOW_SCRIPTS=1 bun run dev` 然后 REPL 输入 `/ultracode`(或单测 `getSkillDirCommands` 含 ultracode)。最小校验: + +Run: `grep -rn "ultracode" src/skills/bundled/` +Expected: 命中 SKILL.md。 + +- [ ] **Step 3:提交** + +```bash +git add src/skills/bundled/ultracode/SKILL.md +git commit -m "feat(workflow): /ultracode knowledge skill (orchestration playbook)" +``` + +--- + +## Phase M8:文档 + +### Task 16:更新 workflow-scripts 文档 + +**Files:** +- Modify: `docs/features/workflow-scripts.md` + +- [ ] **Step 1:补面板与 skill 说明** + +在 `docs/features/workflow-scripts.md` 末尾追加: + +```markdown +## 监控面板:`/workflows` + +`/workflows` 打开双栏监控面板:左栏扁平 workflow 列表(状态点+名+当前 phase+agent 计数),右栏聚焦 workflow 的 phase 横条 + 扁平 agent 列表。键位:`j/k` 选 run、`r` resume、`x` kill、`n` 新建、`q` 退出。进度按引擎 `agentId` 精确关联。 + +## `/ultracode` skill + +`/ultracode` 注入多 agent workflow 编排工作法(何时用、原语、质量模式、确定性约束、路由、resume/budget)。纯知识,零运行时副作用。 +``` + +- [ ] **Step 2:提交** + +```bash +git add docs/features/workflow-scripts.md +git commit -m "docs(workflow): document /workflows panel and /ultracode skill" +``` + +--- + +## 收尾 + +- [ ] **最终全量 precheck** + +Run: `bun run precheck` +Expected: typecheck + lint fix + test 全绿。 + +- [ ] **(可选)端到端冒烟** + +Run: `FEATURE_WORKFLOW_SCRIPTS=1 bun run dev`,REPL 内: +1. `/ultracode` → 注入 playbook。 +2. 通过 Workflow 工具 `name: <某命名 workflow>` 启动。 +3. `/workflows` → 看到该 run,j/k 选中,右栏显示 phase/agent 实时刷新。 +4. `x` kill → run 变 killed。 + +--- + +## 自查(写作后) + +- **Spec 覆盖**:①引擎 agentId(Task 1-3)②bus+store(4-5)③深度后端(6)④registry+ports(7)⑤service(8)⑥wiring+去 DetailDialog(9-10)⑦面板(11-14)⑧ultracode(15)⑨文档(16)— 全覆盖。 +- **注册点零改动**:tools.ts/commands.ts/constants/tasks/PermissionRequest 保留导出名即兼容(已在 Task 9 校验无残留旧引用)。 +- **类型一致性**:`agentId: number` 贯穿 types→hooks→store;`WorkflowService`/`ProgressStore` 方法名一致;`claudeCodeBackend.id='claude-code'` 与 registry default 一致。 +- **已知 TODO(非占位,是边界)**:`useInput` 签名以 `@anthropic/ink` 实际为准(Task 13/14 已给备选 `panelCall.ts` 与内联 `useInput` 两套);`context.options.canUseTool` 字段名待确认(Task 14 已注明回退)。 diff --git a/docs/superpowers/reviews/2026-06-13-workflow-engine-commit-0768d4dc-review.md b/docs/superpowers/reviews/2026-06-13-workflow-engine-commit-0768d4dc-review.md new file mode 100644 index 000000000..6ad3ceb4f --- /dev/null +++ b/docs/superpowers/reviews/2026-06-13-workflow-engine-commit-0768d4dc-review.md @@ -0,0 +1,159 @@ +# Commit 审查报告:0768d4dc8f69023b55adf2f5c176c766640600cb + +- **Commit**: `0768d4dc8f69023b55adf2f5c176c766640600cb` +- **Title**: `feat(workflow): add workflow engine, /workflows panel, /ultracode skill` +- **Author**: claude-code-best +- **Date**: 2026-06-13 +- **规模**: 90 文件,+12925 / -833 +- **审查日期**: 2026-06-13 +- **审查方法**: 多视角对抗式 workflow 编排(7 个并行 reviewer → consolidator 合并 → refuter 反驳 → final judge),journal `run_id = wtujwahzf` + +--- + +## TL;DR + +这个 commit 引入的 workflow engine **架构干净、引擎层测试覆盖率高**,但**脚本沙箱和路径校验存在真实漏洞**,并且在本次审查过程中**我亲身实证发现了多个 judge report 没覆盖的 host 集成 bug**(其中包括 workflow 状态变更通知根本没有接进 host 通知系统,导致"完成时自动通知"承诺落空)。受信 LLM 威胁模型下无严格 blocker,但建议合并前修 4 项。 + +**严重度计数**(综合 judge + 我的实证): +- CRITICAL: 0 +- HIGH: 2 +- MEDIUM: 9 +- LOW: 4 +- INFO: 6 + +--- + +## 审查方法 + +用 commit 自身引入的 workflow engine 跑了一个对抗式审查 workflow: + +1. **Phase 1 — MultiPerspectiveScan**: 7 个并行 reviewer(architecture / runtime / types / test-quality / integration / security / removal-docs),用 Explore agentType,独立扫各自维度 +2. **Phase 2 — Consolidation**: opus consolidator 合并去重,按主题归类 +3. **Phase 3 — AdversarialRefutation**: general-purpose refuter 对每个 CRITICAL/HIGH 用新证据反驳 +4. **Phase 4 — FinalReport**: opus judge 综合输出最终报告 + +journal 完整 10 条 agent 记录在 `.claude/workflow-runs/wtujwahzf/journal.jsonl`。 + +**审查过程中实证发现的额外 bug**(judge 没覆盖,因为我正好用这个引擎跑审查才暴露):见下一节。 + +--- + +## 我实证发现的 bug(judge report 之外) + +这些是跑审查过程中亲身踩到的,judge 的 7 个 reviewer 没看到,因为这些 bug 涉及 host 集成层(`src/workflow/*`、`src/tasks/LocalWorkflowTask/*`)和实际工具调用语义,需要"真正用一次"才能暴露。 + +### [HIGH] `args` schema 回归:旧 `z.string()` → 新 `z.unknown()`,prompt 未同步 + +- **文件**: `packages/workflow-engine/src/tool/schema.ts:14-19`、`packages/workflow-engine/src/tool/WorkflowTool.ts:38-49, 114` +- **现象**: 调用 Workflow 工具传 `args: {"commit": "..."}`,脚本里 `args.commit === undefined`。子 agent 端到端复现:当 args 是 object 时全链路 OK;是 string 时丢字段。 +- **根因**: 旧 `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts`(本 commit 删除)的 schema 是 `args: z.string().optional()`,模型按旧契约发字符串。本 commit 改成 `z.unknown().optional()` 但 prompt 没强约束"必须传对象",模型继续按旧契约发字符串 → 运行时 `args` 是 string → 脚本里 `args.commit` 拿不到。 +- **影响**: 任何依赖 `args` 透传的命名 workflow 都会拿到 undefined 字段,直接 throw 或 silently 拿不到参数。我不得不在脚本里把 commit hash 写死绕过。 +- **修复方向**: + - `WorkflowTool.call` 加防御:`if (typeof input.args === 'string') input.args = JSON.parse(input.args)` + - 或 schema 用 `z.preprocess((v) => typeof v === 'string' ? JSON.parse(v) : v, z.unknown())` + - 同步 prompt:明确"args 必须是 JSON 对象,禁止传字符串化的 JSON" + +### [HIGH] Workflow 状态变更通知未接入 host 通知系统 + +- **文件**: `packages/workflow-engine/src/tool/WorkflowTool.ts:127-140`、`src/workflow/ports.ts:84-135`、`src/workflow/wiring.ts` +- **现象**: WorkflowTool 的工具返回文本承诺"完成时会自动通知。用 /workflows 查看实时进度。",但本次审查中: + - smoke test (`w17jmnsq3`) 完成时,我没收到任何 task-notification + - review-commit (`wtujwahzf`) 完成时,我没收到任何 task-notification,是用户手动告诉我"结束了"我才知道 + - 失败的 review-commit (`wpv9nu2eo`、`w2tvwj0ka`) 也没收到失败通知 + - 同期启动的 Agent 工具(非 workflow)完成时**有**收到 `` +- **根因**: 引擎确实通过 `ports.progressEmitter.emit({ type: 'run_done', ... })` 发了事件,`taskRegistrar.complete/fail/kill` 也被调了,但**没有任何代码把这些事件桥接到 host 的通知机制**(AgentTool 完成时通过 `runAgent.ts` 的 finally 触发 task-notification)。Workflow tool detached 执行后,host 没有订阅 taskRegistrar 的状态变更。 +- **影响**: 任何 workflow(特别是耗时长的)跑完用户都不知道;用户必须主动 `/workflows` 查看;workflow 失败时用户完全感知不到。这直接违背了 commit message 和 prompt 中"完成时会自动通知"的承诺。 +- **修复方向**: + - 在 `src/workflow/wiring.ts`(或 host bundle 构造处)订阅 `WorkflowService.subscribe`,对 `status` 从 `running` → `completed/failed/killed` 的转换发 host 通知 + - 或在 `WorkflowTool.ts:124` 的 `.then(result => onFinish(...))` 内,根据 result.status 触发 host notification(参考 `runAgent.ts` 的 task-notification 路径) + +### [MEDIUM] `failWorkflowTask` 丢弃 error message + +- **文件**: `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts:96-107` +- **现象**: workflow 失败时 progress store 的 `RunProgress.error` 字段在 `/workflows` 面板能看到(`WorkflowDetail.tsx:63-67` 渲染 `run.error`),但 `BackgroundTasksDialog` 用的 `LocalWorkflowTask` 状态对象没有 error 字段——`failWorkflowTask(taskId, setAppState)` 完全丢弃 error。两套状态系统不一致。 +- **影响**: 用户在 `BackgroundTasksDialog` 看到 workflow 标记为 failed,但不知道为什么 failed;必须切到 `/workflows` panel 才能看到 error 文字。 +- **修复方向**: `failWorkflowTask` 签名加 `error?: string` 参数,存入 `LocalWorkflowTaskState`,并在 `BackgroundTasksDialog` 渲染。 + +### [LOW] WorkflowTool 的 run_id 提示与实际 run 目录解析路径不一致 + +- **文件**: `src/workflow/ports.ts:69`、`packages/workflow-engine/src/tool/WorkflowTool.ts:121` +- **现象**: `WorkflowTool.ts:121` 的 `cwd: host.cwd` 来自 `getCwd()`(运行时 cwd,可能在 worktree 切换时变化);而 `ports.ts:69` 的 `runsDir = ${getProjectRoot()}/.claude/workflow-runs` 用的是 session 启动时的 project root。两者在某些路径下不一致(如 mid-session `EnterWorktreeTool`)。 +- **影响**: 命名 workflow 文件解析(用 cwd)和 journal 持久化路径(用 projectRoot)可能落到不同目录,调试时混乱。 +- **修复方向**: 统一用 `getProjectRoot()`,或在文档里明确两者的语义差异。 + +--- + +## Judge 报告核心 finding + +### HIGH:脚本沙箱可被动态 `import()` 绕过 + +- **文件**: `packages/workflow-engine/src/engine/script.ts:166-221` +- **问题**: `assertScriptBody` 只屏蔽**静态** `import` 语句(regex `/^\s*import\b/m`),但 `new AsyncFunction()` 体内可 `await import('node:child_process')`、可直接访问 `process.env` / `Buffer` / `globalThis`。Node 和 Bun 实测都能逃逸。 +- **降级理由**: LLM 本就有 `BashTool`(`src/constants/tools.ts:139`),沙箱逃逸不扩大能力;但破坏了 resume 的确定性假设 + 未来若引入半信任脚本源会致命。 +- **修复**: `import(` 加进 regex 黑名单 + 文档明确"沙箱保确定性,不保安全"。 + +### MEDIUM(7 项,按价值排序) + +1. **`scriptPath` 任意文件读,无路径校验** — `WorkflowTool.ts:184-188`、`service.ts:104-109`。`input.scriptPath` 来自 LLM,无 containment check,可读 `/etc/passwd`、`~/.ssh/id_rsa`。`FileReadTool` 已有此能力,但 `scriptPath` 绕过权限提示。 +2. **命名 workflow 路径遍历** — `namedWorkflows.ts:18-19`。`name` 参数未过滤 `../`,`name = "../../etc/passwd"` 可逃出 `workflowDir`(虽然 `.ts/.js/.mjs` 扩展名限制缓解了利用)。 +3. **Budget 检查竞态** — `hooks.ts:53, 95-106`。`assertCanSpend()` 在 semaphore 之前,N 个并发都能过检 → 实测 4 并发 100 token budget 实花 200(100% 超支)。默认 `budget = null` 时不触发,显式设 budget 才暴露。 +4. **`parallel`/`pipeline` 静默吞错** — `hooks.ts:126-134, 148-160`。`catch {}` 完全无日志,workflow 作者无法知道 agent 为何失败。"null on error"契约本身是对的,但应该 log。 +5. **双重类型断言掩盖 schema/type 漂移** — `WorkflowTool.ts:56`。`workflowInputSchema as unknown as z.ZodType`,应该 `export type WorkflowInput = z.infer`。 +6. **Service 层测试 mock adapter 永远返回 ok** — `service.test.ts:39-68`。`fakePorts()` 永远返回 `{kind: 'ok', output: 'mock-out'}`,service 层的失败路由(`service.ts:164-173`)未测。 +7. **Journal 并发写入顺序非确定** — `hooks.ts:111-113`。`push` + `index++` 同步原子,但 `await append()` 落盘顺序是完成顺序而非调用顺序。resume 时若并发完成顺序不同,key 不匹配 → journal 失效 → 全重跑。**对 parallel workflow 来说 resume 几乎无效**。 + +### LOW / INFO + +- LOW: Semaphore permit 在 abort 时延迟释放(queued waiter 阻塞至 permit 到来) +- LOW: `WorkflowsPanel.tsx:40-45` 的 `useSyncExternalStore` 无 error boundary +- LOW: WorkflowService singleton 无 shutdown 清理 +- INFO: `AgentRunParams.schema` 用 `object` 而非 `Record` +- INFO: `WorkflowInputSchema` 类型未从 package index 导出 +- INFO: 旧 `builtin-tools/WorkflowTool` 删除干净,无残留 import +- INFO: workflow-engine 包零 host 依赖(只 ajv + zod) +- INFO: HostHandle 用 Symbol-based opacity 是合理的 seam + +### 被反驳的发现(refuter 用新证据推翻) + +- ~~**CRITICAL**: 并发 journal 索引腐蚀~~ — 误判 JS 单线程执行模型。`push` 和 `index++` 之间无 `await`,不可被抢占。 +- ~~**HIGH**: 键盘 stale reference 竞态~~ — 误判 `useEventCallback` 语义。`usehooks-ts` 的 ref 在 layout phase 同步更新,键盘 handler 总能拿到最新 `focused`。 +- ~~**HIGH**: sub-agent 默认 `acceptEdits` 权限~~ — 全代码库约定(`resumeAgent.ts:161` 同样写法),非 workflow 特有漏洞。 + +--- + +## 做得好的地方 + +1. **架构干净**:workflow-engine 包零 host 依赖(只 ajv + zod),教科书级 hexagonal。所有 host 交互通过注入的 `Ports` / `HostHandle`。 +2. **Journal 离散检测健壮**:`hooks.ts:65-81` 的 key mismatch → 优雅降级到全重跑,不会产生错误结果。 +3. **Budget API 设计良好**:`Budget` 类的 `assertCanSpend` / `addOutputTokens` / `remaining` API 表面正确(虽然实现有竞态),后续加 reservation 机制容易。 +4. **Engine 层测试覆盖扎实**:`hooks.test.ts` 覆盖 dead / skipped / budget exhaust / abort / adapter 错误 / parallel-pipeline error suppression,这是 engine 层该有的覆盖深度。 +5. **旧代码删除干净**:commit 正确删除 `builtin-tools/WorkflowTool`,保留 `bundled/` 作为扩展点,更新 `biome.json` 排除项匹配新架构,无残留 import。 +6. **设计文档完备**:`docs/features/workflow-scripts.md`、`docs/superpowers/specs/2026-06-12-workflow-engine-design.md`、`docs/superpowers/plans/2026-06-12-workflow-engine.md` 配套齐全。 + +--- + +## 推荐 merge 前修复(按优先级) + +1. **[HIGH] Workflow 状态变更通知接入 host** — 在 `src/workflow/wiring.ts` 订阅 `WorkflowService.subscribe`,对 status 转换发 host notification;这是 commit message 和 prompt 已承诺但未实现的功能。 +2. **[HIGH] `args` schema 防御性 parse** — `WorkflowTool.call` 加 `if (typeof input.args === 'string') JSON.parse(...)` + 同步 prompt。 +3. **[HIGH] 脚本沙箱黑名单加 `import(`** — `script.ts:166` 一行修复 + 文档明确"沙箱保确定性不保安全"。 +4. **[MEDIUM] `scriptPath` / `name` 路径校验** — containment check,拒绝 `../`、绝对路径越界。 +5. **[MEDIUM] `failWorkflowTask` 保存 error** — 签名加 error 参数,存入 task state,与 progress store 对齐。 +6. **[MEDIUM] `assertCanSpend()` 挪到 semaphore critical section 内** — 关闭 budget 超支竞态。 +7. **[MEDIUM] service.test.ts 加 dead/skipped 路由测试** — 关闭 service 层失败路由覆盖盲区。 +8. **[MEDIUM] `WorkflowInput = z.infer`** — 消除双重断言,防 schema/type 漂移。 + +前 5 项都是几行到几十行的小改动,建议合并前完成。第 6-8 项可以 follow-up。 + +--- + +## 审查过程的元观察(dogfooding 发现) + +用 commit 自身引入的 workflow engine 跑这个审查,等于把引擎当 dogfood。除了上述具体 bug,还有一些元观察: + +- **"完成时自动通知"承诺落空**是最影响用户体验的一条——workflow 跑完了用户不知道,跑挂了用户也不知道,必须主动 `/workflows`。这违背了工具描述里写的契约。 +- **journal 落盘路径与命名 workflow 解析路径用了不同根**(`getProjectRoot()` vs `getCwd()`),调试时容易找不到 journal 文件。 +- **smoke test 能跑通、review-commit 不能跑通**——区别在于 review-commit 读 `args.commit`,这暴露了 schema 回归。说明现有测试覆盖(即使是 99.65% 的引擎覆盖率)无法替代真实使用场景的 dogfooding。 +- **refuter 反驳掉 2 个 CRITICAL/HIGH** 是对抗式审查的价值证明:单 reviewer 视角会基于错误假设(JS 并发模型、React ref 语义)报假阳性,多一层反驳能纠偏。 + +完整 journal(10 条 agent 输出):`.claude/workflow-runs/wtujwahzf/journal.jsonl` diff --git a/docs/superpowers/specs/2026-06-12-workflow-engine-design.md b/docs/superpowers/specs/2026-06-12-workflow-engine-design.md new file mode 100644 index 000000000..43b71a3dc --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-workflow-engine-design.md @@ -0,0 +1,231 @@ +# Workflow Engine — 重建设计 + +- 日期:2026-06-12 +- 状态:已通过 brainstorming,待 writing-plans +- 范围:把被掏空的「清单推进」版 WorkflowTool 重建为**完整忠实的确定性 JS 脚本编排引擎**,并**独立成包**,解除与核心层的深度依赖。 + +## 1. 背景与现状 + +当前 `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts` 是个被阉割的版本:把 `.claude/workflows/` 里的 `.md`/`.yaml` 解析成清单,靠模型手动调用 `advance` 推进,**没有任何子 agent 编排能力**。 + +真正的 Workflow 能力是一个**确定性 JS 脚本编排引擎**:后台执行脚本,提供 `agent()`/`parallel()`/`pipeline()`/`phase()`/`log()` 钩子,真正 spawn 子 agent,支持 schema 校验、并发上限、journaling/resume、token budget、进度流。 + +### 可复用的现有基础设施 + +- `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts`:完整的后台任务生命周期(register/complete/fail/kill/skip/retry/orphan 清理)。**完好,复用**。 +- `packages/builtin-tools/src/tools/AgentTool/runAgent.ts`:子 agent 执行核心(async generator,接收 `agentDefinition`+`promptMessages`+`toolUseContext`+`canUseTool`,运行完整 query 循环)。**作为 `agent()` 钩子后端**。 +- `assembleToolPool`(`src/tools.ts`):构建子 agent 工具池。 +- `finalizeAgentTool` / `extractTextContent`(`agentToolUtils.ts`):抽取 agent 最终消息 + usage。 +- `WorkflowPermissionRequest.tsx`:权限 UI(核心侧 React,复用)。 +- `tools.ts` 已用 `WORKFLOW_SCRIPTS` feature flag 接好注册位;`constants/tools.ts` 的 `CORE_TOOLS` 在 flag 开启时含 `workflow`。 + +## 2. 关键决策(brainstorming 结论) + +1. **范围**:完整忠实引擎——全部钩子 + schema 结构化输出 + 并发上限(16/1000/4096)+ journaling/resume + token budget + worktree 隔离 + named-workflow 加载 + 进度流到 `/workflows`。 +2. **包边界**:**严格端口适配(依赖倒置)**。`packages/workflow-engine/` 零 `src/*` / `builtin-tools` 运行时导入;只声明端口接口;核心侧提供一个 adapter 模块实现这些接口;`tools.ts` 装配时注入。 +3. **文件模型**:`.claude/workflows/.ts|.js|.mjs` 脚本文件 → 命名 workflow(`Workflow` 工具 `name` 参数解析到它)+ 生成 `/` 斜杠命令;`/workflows` 变为实时进度查看器。**删除** 现有 `.md`/`.yaml` 清单逻辑。 +4. **执行路径**:**async 函数包装 + 信号量 + 注入端口**(方案 A)。进程内 async 模型,与 `runAgent` 的 async generator 天然契合,端口可 mock 测试。不用 `vm` 沙箱或 worker 进程。 + +## 3. 架构与依赖方向 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ packages/workflow-engine/ ← 新包,零 src/* 运行时导入 │ +│ 声明端口(接口),持有引擎/钩子/并发/journal/budget/schema │ +│ + 自包含的 WorkflowTool 描述符(zod schema/desc/prompt) │ +└──────────────▲──────────────────────────▲───────────────────┘ + │ 实现(implements) │ 注入(DI) +┌──────────────┴──────────────────────────┴───────────────────┐ +│ src/workflow/ ← 核心侧薄层 │ +│ adapter.ts: 用 runAgent/assembleToolPool/LocalWorkflowTask │ +│ /AppState 实现端口 │ +│ wiring.ts: createWorkflowTool(adapter) → 适配为 Tool │ +│ 注册到 tools.ts(WORKFLOW_SCRIPTS flag 之后) │ +└─────────────────────────────────────────────────────────────┘ +``` + +包**不认识** `buildTool` / `toolUseContext` / `runAgent` / `Message` 类型。仅通过端口接口与不透明 host 句柄对话。 + +### 端口契约(包内 `ports.ts`) + +| 端口 | 职责 | 核心侧 adapter 实现 | +|---|---|---| +| `AgentRunner` | `agent()` 后端:`runAgentToResult(params, hostHandle) → AgentRunResult` | 委托 `runAgent` + `assembleToolPool`;schema 时注入 StructuredOutput 工具;`finalizeAgentTool` 抽取最终消息 + usage | +| `ProgressEmitter` | `emit(event)` 推进度事件 | 写 `LocalWorkflowTaskState.progress` + `rootSetAppState` | +| `TaskRegistrar` | 后台任务生命周期 + 读 `pendingAgentAction` | 复用 `LocalWorkflowTask` API | +| `JournalStore` | journal 读写(按 runId) | 文件 fs(`.claude/workflow-runs//journal.jsonl`),走端口便于 mock | +| `PermissionGate` | `agent()` 前置权限/取消检查 | abort signal + `pendingAgentAction` | +| `Logger` | 调试日志 + 遥测 | `logForDebugging` / `logEvent` | + +**不透明 host 句柄**:`HostHandle = { readonly __workflowHost: unique symbol }`。核心侧每次工具调用构造一个句柄(内含 `toolUseContext`/`canUseTool`/`agentId` 等),包内绝不检视,只透传给 `AgentRunner`;adapter 把它 cast 回核心上下文。包对核心类型零依赖的唯一缝隙,且是不透明的。 + +### 包结构 + +``` +packages/workflow-engine/ + package.json @claude-code-best/workflow-engine (workspace:*) + tsconfig.json + src/ + index.ts 公共导出 + ports.ts 端口接口 + HostHandle + types.ts 纯类型(WorkflowInput/Run/JournalEntry/ProgressEvent/AgentRunParams…) + tool/ + WorkflowTool.ts createWorkflowTool(ports) → 自包含描述符 + schema.ts 输入 schema(script/name/scriptPath/args/resumeFromRunId/desc/title) + constants.ts WORKFLOW_TOOL_NAME 等 + engine/ + runWorkflow.ts 引擎入口:校验/包装/执行/journal/resume + context.ts 执行上下文(端口/信号量/budget/journal/计数器/host) + hooks.ts agent/parallel/pipeline/phase/log/workflow 实现 + script.ts meta 字面量提取 + async 包装 + 沙箱 shim + concurrency.ts Semaphore + 上限(16 / 1000 总 / 4096 每次调用) + journal.ts hash + 读/写 journal + budget.ts budget 累加器(total/spent/remaining) + structuredOutput.ts JSON Schema → 结果校验(纯函数) + namedWorkflows.ts name → .claude/workflows/.ts|js|mjs 解析(仅 fs) + constants.ts 目录/上限常量 + progress/events.ts ProgressEvent 类型 + emit 委托 + __tests__/ … +``` + +核心侧薄层:`src/workflow/adapter.ts` + `src/workflow/wiring.ts`;`packages/builtin-tools` 从新包 re-export 描述符。 + +## 4. 引擎内部 + +### 4.1 钩子语义 + +| 钩子 | 语义 | 失败行为 | +|---|---|---| +| `agent(prompt, opts?)` | 取信号量 → 查 journal(命中即返回缓存)→ 调 `AgentRunner` → 写 journal → 返回 | 终态 API 错耗尽重试 → `null`(不抛) | +| `parallel(thunks)` | **屏障**:`Promise.all` 所有 thunk(每个内部各自过信号量);wall-clock = 最慢项 | 单项抛错/agent 错 → 该项 `null`;调用本身永不 reject | +| `pipeline(items, …stages)` | **无屏障**:每项跑 `stage1→stage2→…` 异步链,多链并发;stage 回调收 `(prevResult, originalItem, index)` | 某 stage 抛错 → 该项 `null`、跳过后续 stage | +| `phase(title)` | 开启新阶段,后续 agent/log 归入该组直到下次 `phase()` | — | +| `log(message)` | 向用户发一行旁白进度 | — | +| `workflow(nameOrRef, args?)` | 内联跑子 workflow,返回其返回值;共享并发/计数/budget;`/workflows` 显示为 `▸ name` 组 | 子 workflow 内再嵌套 → 抛错(仅一层) | + +`agent` 的 `opts`:`label`、`phase`(显式分组)、`schema`(JSON Schema)、`model`、`isolation:'worktree'`、`agentType`(自定义子 agent 类型)、`allowedTools`。 + +- 无 schema 返回 `string`;有 schema 返回校验对象;用户 skip / agent 终态死亡 → 返回 `null`。 + +### 4.2 并发与上限(`concurrency.ts`) + +- `Semaphore` 许可数 = `min(16, cpuCores - 2)`;`agent()` 取 1。 +- 单个 workflow 生命周期**总 agent 数 ≤ 1000** → 超出抛错。 +- 单次 `parallel`/`pipeline` 调用 **items ≤ 4096** → 超出抛错(显式错误,不静默截断)。 + +### 4.3 Journal / Resume(`journal.ts`) + +- journal = 按**执行顺序**的 `{ key, result }` 列表,存 `.claude/workflow-runs//journal.jsonl`。 +- `key` = `hash(prompt + canonical(opts 去掉 label/phase 等纯展示字段))`。 +- 命中:`agent()` 先算 key,与 journal 下一项 key 比对 → **匹配则返回缓存并前进**,不匹配则丢弃后续 journal、现场重跑。 +- 因 JS 去掉 `Date.now`/`random` 后确定,执行顺序确定 → 自然得到「最长未变前缀命中、首个发散点之后全重跑」。 +- `resumeFromRunId`:载入该 run 的 journal 重放。脚本源码 hash 一致 → 100% 命中;脚本改动 → 全重跑。脚本 hash 存入 run 记录。 + +### 4.4 Budget(`budget.ts`) + +- `budget.total`:来自用户 `+500k` 式 turn 级 token 指令,由 **host/turn 上下文注入**(adapter 从 turn 的 token 指令读取,经 `HostHandle` 传入),**不是** 工具 input 参数。无指令则 `null`。 +- `budget.spent()`:本 turn 所有 agent 输出 token 之和(`AgentRunResult.usage`,adapter 从 subagent usage 填)。 +- `budget.remaining()`:`max(0, total - spent)`,无 total 则 `Infinity`。 +- **硬上限**:`spent()` 达 `total` 后,`agent()` 抛错。预算是主循环与 workflow 共享池。 + +### 4.7 AgentRunResult 类型(`types.ts`) + +`AgentRunner.runAgentToResult` 的返回,包内明确定义为联合类型: + +```ts +type AgentRunResult = + | { kind: 'ok'; output: string | object; usage: { outputTokens: number } } + | { kind: 'skipped' } // 用户 skip → agent() 返回 null + | { kind: 'dead' } // 终态 API 错耗尽重试 → agent() 返回 null +``` + +`output` 为 `string`(无 schema)或已校验对象(有 schema)。`agent()` 据此映射:`ok`→返回 output,`skipped`/`dead`→返回 `null`。 + +### 4.5 脚本包装与沙箱(`script.ts`) + +1. 提取 `export const meta = { … }`——**必须是纯字面量**(无变量/插值/展开),解析为对象;缺失或非字面量 → 抛错。 +2. 剥离 `export const meta` 语句。 +3. 剩余 body(含顶层 `return`)包进 `async function(agent, parallel, pipeline, phase, log, workflow, args, budget, Date, Math){ }`。 +4. 以**抛异常的 shim** 传入 `Date`(`now()`/无参 `new Date()` 抛)、`Math`(`random()` 抛)——靠函数参数 shadow 全局,使裸 `Date.now()` 命中 shim。这是确定性保障,非密码学级沙箱(与真实引擎意图一致:阻断 resume 破坏性的非确定性)。 +5. meta 的 `phases` 可用于进度预声明(可选)。 + +### 4.6 进度事件(`progress/events.ts`) + +`ProgressEmitter.emit(event)` 类型:`run_started`、`phase_started/done`、`agent_started/done{label,phase,result摘要}`、`log`、`run_done{returnValue/status}`。adapter 写入 task 进度结构 + AppState,`/workflows` 视图消费。 + +## 5. 错误处理 + +| 场景 | 行为 | +|---|---| +| 脚本无 `meta` / `meta` 非字面量 / 语法错 | 引擎抛错 → task `failed` → 通知带错误信息 | +| `Date.now`/`Math.random`/`new Date()` | shim 抛 → 冒泡为脚本错误 → task failed | +| `agent()` 终态 API 错(重试耗尽) | 返回 `null`,**不杀** workflow | +| `parallel`/`pipeline` 单项抛错 | 该项 `null`,workflow 继续 | +| budget 耗尽 | `agent()` 抛错(脚本可 try/catch) | +| 并发/1000/4096 上限 | 抛错 | +| kill(abort) | signal 传播;`agent()` 检查 signal;workflow 停;task `killed`;通知 partial | +| 工具调用层(`call`)脚本非法 | 直接返回错误给模型(不进后台) | + +## 6. 测试策略 + +包内全量单测,**无需真实 LLM**(mock 端口——解耦的核心收益): + +- `engine.test.ts`:mock `AgentRunner`(按 prompt 返回预设结果)端到端跑脚本,断言返回值 + 进度事件序列。 +- `hooks.test.ts`:parallel 单项错→null、pipeline 无屏障顺序、agent schema 校验、skip/dead→null。 +- `concurrency.test.ts`:信号量限并发、1000/4096 上限抛错。 +- `journal.test.ts`:hash 稳定、resume 命中前缀、脚本变更全重跑、中途发散重跑尾部。 +- `budget.test.ts`:spent 累加、触顶抛错。 +- `script.test.ts`:meta 字面量提取、非字面量/语法错、shim 抛。 +- `structuredOutput.test.ts`、`namedWorkflows.test.ts`。 + +核心侧最小冒烟:adapter 用 `runAgent` 真接线的重 mock 测试;wiring 注册测试。重量级逻辑都在包内。可选:`tests/integration/` 加一个 workflow tool-chain 集成测试(feature-gated)。 + +## 7. 核心侧实现 + +### 7.1 adapter(`src/workflow/adapter.ts`) + +`createWorkflowAdapter()` 返回端口实现: + +- **AgentRunner.runAgentToResult(params, hostHandle)**:cast 句柄→`{toolUseContext, canUseTool, assistantMessage}`;按 `params.agentType` 从 registry 解析 agentDefinition(缺省=通用 workflow 子 agent);`assembleToolPool`;有 schema→注入 StructuredOutput 工具+系统指令;调 `runAgent` 收消息→`finalizeAgentTool` 抽 text+usage;schema→解析校验返回对象;处理 `pendingAgentAction`(skip)→`null`、终态死亡→`null`;返回 `{kind:'ok', text/object, usage}`。 +- **ProgressEmitter**:写 `LocalWorkflowTaskState.progress` + `rootSetAppState`。 +- **TaskRegistrar**:复用现有 `registerLocalWorkflowTask/complete/fail/kill` + 读 `pendingAgentAction`。 +- **JournalStore / Logger / PermissionGate**:fs / `logForDebugging`+`logEvent` / abort+pendingAction。 + +### 7.2 wiring(`src/workflow/wiring.ts`) + +- `createWorkflowTool()`:建 adapter → 调包的 `createWorkflowTool(adapter)` 得描述符 → 包成 `buildTool` 兼容 `Tool` 返回。 +- `tools.ts`:`const WorkflowTool = feature('WORKFLOW_SCRIPTS') ? require('./workflow/wiring.js').createWorkflowTool() : null`(替换现有清单版)。 + +`call` 流程:校验脚本(inline/file/named 解析)→ meta 校验失败直接返错给模型 → 持久化脚本 + 算 hash → resume 则载入 run+journal → 注册后台 task → **立即返回 `{runId, scriptPath}`** → 脱离执行引擎、流进度 → 完成时 complete + 通知(返回值/错误)。 + +## 8. 现有文件迁移 + +| 文件 | 处理 | +|---|---| +| `builtin-tools/.../WorkflowTool/WorkflowTool.ts`(清单版) | 删除,逻辑移入新包 | +| `constants.ts`(WORKFLOW_TOOL_NAME) | 移入包 `tool/constants.ts`,core 侧 re-export | +| `WorkflowPermissionRequest.tsx`(React UI) | 移到 `src/workflow/`(依赖 src 权限组件,属核心侧) | +| `createWorkflowCommand.ts`(.md/.yaml 扫描) | 改为扫 `.ts/.js/.mjs` → 生成 `/` 命令,调用时以脚本启动引擎 | +| `bundled/index.ts`(no-op) | 保留为包的 bundled-workflow 扩展点 | +| `src/utils/workflowRuns.ts`(清单记录) | 重写为 run+journal 模型(或并入包 JournalStore) | +| `src/commands/workflows/index.ts` | 改为**实时进度查看器**,复用 `WorkflowDetailDialog.tsx` | +| `src/tasks.ts` LocalWorkflowTask 门控 | 保持不变 | +| `constants/tools.ts` CORE_TOOLS 含 `workflow` | 保持 | + +## 9. 工作分解(writing-plans 将细化) + +1. 新建包 `packages/workflow-engine/`(package.json/tsconfig/类型/端口/常量)。 +2. 引擎核心:script 包装、concurrency、journal、budget、structuredOutput、namedWorkflows。 +3. 钩子实现 + runWorkflow 编排 + 进度事件。 +4. 自包含工具描述符(schema/desc/prompt/result 映射)。 +5. 包内全量单测。 +6. 核心侧 adapter + wiring + 句柄构造。 +7. 迁移现有文件、改 `/workflows` 为进度查看器、改 named-workflow 命令。 +8. `bun run precheck` 零错误;手动 dev 冒烟。 + +## 10. 非目标 / 风险 + +- **非密码学沙箱**:函数参数 shadow 全局 `Date`/`Math`,`globalThis.Date` 仍可达。可接受——目标是阻断 resume 破坏性的非确定性,不是隔离恶意代码。若未来需强隔离再上 `vm`/worker(方案 B/C)。 +- **resume 正确性依赖确定性执行**:用户脚本若绕过 shim 用 `globalThis.Date` 制造非确定性,resume 可能命中错缓存。属可接受的边界,文档提示。 +- **预算共享语义**:`budget.spent()` 与主循环的 token 计数共享,需 adapter 正确上报 subagent usage;若 provider 不报 usage 则 budget 降级为 `Infinity`。 +- **StructuredOutput 工具**:核心侧需存在/实现一个按 JSON Schema 强制结构化输出的子 agent 工具(注入 + 解析)。若当前无现成实现,wiring 阶段补一个最小版本。 diff --git a/docs/superpowers/specs/2026-06-13-workflow-panel-redesign.md b/docs/superpowers/specs/2026-06-13-workflow-panel-redesign.md new file mode 100644 index 000000000..a63f2bcb6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-workflow-panel-redesign.md @@ -0,0 +1,200 @@ +# `/workflows` 面板重设计:顶 tab + 左 phase 侧栏 + 右 agent 列表 + +> 状态:草案(待用户 review → writing-plans 产出实施计划) +> 日期:2026-06-13 +> 关联:上一期整体设计 `docs/superpowers/specs/2026-06-13-workflow-tui-ultracode-design.md`(其 §9 双栏面板已实现,本 spec 取代该 §9 的面板部分) + +--- + +## 1. 背景与现状 + +上一期整体设计已落地:`WorkflowService` 门面、`claude-code` AgentAdapter、进度 bus+store、引擎 `agentId` 关联、`/ultracode` skill 全部实现完成。`/workflows` 面板按旧 spec §9 实现为**双栏**: + +- `src/workflow/panel/WorkflowsPanel.tsx`:左栏 `WorkflowList`(扁平 run 列表)+ 右栏 `WorkflowDetail`(phase 横条 + 扁平 agent 列表)。 +- 键位 `j/k` 在左栏选 run,选中即聚焦、右栏随之切换。 + +**问题**:监控「单个 run 内多 phase / 多 agent」时,左右是「run 列表 vs 单 run 详情」——切换 run 与查看 agent 共用一对键位;phase 仅一行横条,无法按 phase 筛选 agent;多个 run 间切换要上下翻列表。 + +本 spec 把面板**原地重写**为三区焦点模型:**顶部 run tab + 左 phase 筛选侧栏 + 右 agent 列表**,贴合「聚焦一个 run → 按 phase 收窄 → 看 agent 状态」的实际监控动线。 + +## 2. 目标与非目标 + +**目标** + +1. 顶 tab 按 **run**(同名脚本多次跑会多个 tab,标签附 runId 短码消歧如 `review-changes#a3f`)。 +2. 左 phase 侧栏:合并 `meta` 声明 phase(pending `○`)与 store phase(running `●` / done `✓`)+ 一个固定 `All` 项;选中即决定右栏筛选。 +3. 右 agent 列表:按选中 phase 过滤(`All` 则全显);状态用颜色 + 文字标记(`object` / `text` / `dead`)。 +4. 焦点轮转键位:`Tab`/`Shift+Tab` 切 run、`←/→` 切 phases↔agents、`↑/↓` 列内移动、`x` kill / `r` resume / `q`/`Esc` quit。 +5. 视觉极简:无内框,左右栏中间**一条竖线**;选中/光标行用**底色条**(`backgroundColor`,非反白);聚焦列标题橙粗、非聚焦灰。 +6. 显示 **pending phase**(meta 声明但未启动)。 + +**非目标** + +- 不改引擎包(`run_started` 已携带 `meta.phases`,见 §3)。 +- 不动 `service`/`registry`/`backends`/`ports`/`wiring`/Workflow 工具/`/ultracode`。 +- 不做 per-agent 操作 UI(仅 run 级 `kill`/`resume`)。 +- 不改 `BackgroundTasksDialog`(Shift+Down)跳转协议。 +- 不做 agent 输出详情抽屉(留未来)。 + +## 3. 关键发现:零引擎改动 + +`ProgressEvent.run_started` **已携带** `meta: WorkflowMeta | null`(`packages/workflow-engine/src/types.ts:60-66`,emit 点 `engine/runWorkflow.ts:72-77`),且 `WorkflowMeta.phases` 已是 `Array<{ title: string; detail?: string }>`(`types.ts:22-27`)。 + +→ pending phase 所需数据全在事件流里。面板只需让 store 在 `run_started` 时落地 `declaredPhases`,再与 store 的 `run.phases`(running/done)合并即可。**不触碰引擎包**。 + +## 4. 数据模型变更(`src/workflow/progress/store.ts`) + +- `RunProgress` 新增字段: + + ```ts + declaredPhases: string[] // 来自 run_started.meta.phases[].title;无 meta → [] + ``` + +- reducer `run_started` 分支补一行(当前第 74-77 行只用 `event.workflowName`,忽略 `event.meta`): + + ```ts + case 'run_started': + p.workflowName = event.workflowName + p.status = 'running' + p.declaredPhases = event.meta?.phases?.map(ph => ph.title) ?? [] + break + ``` + +- `ensure()` 初始化 `declaredPhases: []`。 +- 其余 reducer 分支、`AgentProgress`、快照排序逻辑不变。 + +**测试**(`progress/store.test.ts` 或对应测试文件): +- `run_started` 带 `meta.phases` → `declaredPhases` 落地且顺序保留。 +- `run_started` 的 `meta` 为 `null` → `declaredPhases === []`。 +- 已有 `agentId` 关联、phase 切换、`run_done` 终态用例保持绿。 + +## 5. 面板布局(定稿 ASCII) + +焦点在 PHASES(默认进入态): + +``` +╭─ Workflows ──────────────────────────── 2 running · 3 done ─╮ +│ │ +│ ● review-changes ✓ find-bugs ● migrate-auth │ +│ ═════════════════ ← Tab / Shift+Tab 切 │ +│ │ +│ PHASES │ AGENTS · Review │ +│ │ │ +│ ✓ Find 3/3 │ ● review:bugs running │ +│ ▓▶● Review 2/5▓ │ ● review:perf running │ +│ ○ Verify 0/2 │ ✓ review:sec object │ +│ │ ✗ review:api dead │ +│ All 10 │ ✓ review:auth text │ +│ │ │ +│ Tab 切 run · ←/→ 切焦点 · ↑/↓ 移动 · x kill · q quit │ +╰─────────────────────────────────────────────────────────────╯ +``` + +按 `→` 焦点到 AGENTS(`PHASES` 标题变灰、`AGENTS` 变橙、光标行铺底色): + +``` + phases (灰) │ AGENTS · Review (橙) + │ + ✓ Find 3/3 │ ● review:bugs running + ● Review 2/5 │ ▓● review:perf running ▓ ← 光标行底色 + ○ Verify 0/2 │ ✓ review:sec object + All 10 │ ✗ review:api dead +``` + +## 6. 焦点与键位状态机 + +**面板状态**(`WorkflowsPanel` 内 `useState`): + +| 状态 | 含义 | 默认 | +|---|---|---| +| `activeRunId` | 当前 tab 的 runId | 首个 run(无则 null) | +| `focusColumn` | `'phases'` \| `'agents'` | `'phases'`(该 run 无任何 phase 则 `'agents'`) | +| `selectedPhaseIndex` | phase 侧栏选中项(`0` = `All`) | `0` | +| `selectedAgentIndex` | agent 列表光标行 | `0` | + +**键位**: + +| 键 | 作用 | +|---|---| +| `Tab` / `Shift+Tab` | 切顶部 run tab(正/反);切 tab 时重置 `selectedPhaseIndex=0`、`selectedAgentIndex=0`、`focusColumn` 回默认 | +| `←` / `→` | `phases` ↔ `agents` 焦点切换(tabs 不参与左右,由 `Tab` 管) | +| `↑` / `↓` | 当前焦点列内移动选中(phase 改筛选;agent 滚光标) | +| `x` | kill 当前 tab 的 run | +| `r` | resume 当前 tab 的 run(缺 `canUseTool` 时 `onDone` 提示用 `/ resume`) | +| `q` / `Esc` | 退出面板 | + +**夹紧**:复用 `WorkflowsPanel` 已导出的 `clampSelected`——切 tab / 列表变动后把 `selectedPhaseIndex`、`selectedAgentIndex` 夹到有效区间。 + +**筛选语义**:`selectedPhaseIndex===0`(`All`)→ 右栏显示全部 agent;否则按 `phase === 选中 phase title` 过滤。 + +## 7. 组件拆分(`src/workflow/panel/`) + +| 文件 | 动作 | 职责 | +|---|---|---| +| `WorkflowsPanel.tsx` | 重写 | 订阅 store、持焦点状态、渲染 `TabsBar` + 左右双栏、绑 `useWorkflowKeyboard`;保留导出 `clampSelected` | +| `TabsBar.tsx` | 新建 | 顶部 run tab 行(状态点 + 名 + runId 短码;当前 tab 橙色 `═══` 下划线) | +| `PhaseSidebar.tsx` | 新建 | 左 phase 列表:`All` + 合并 `declaredPhases`(pending `○`)与 `run.phases`(`●`/`✓`),每行附 `done/total` agent 计数 | +| `AgentList.tsx` | 新建 | 右 agent 列表:按选中 phase 过滤;状态色 + 行尾 `object`/`text`/`dead` 文字标记 | +| `status.ts` | 新建 | 共享状态→字符/颜色映射(`STATUS_DOT`、phase/agent mark 函数),三组件复用 | +| `useWorkflowKeyboard.ts` | 改写 | 焦点模型键位(见 §6) | +| `WorkflowList.tsx` | 删除 | run 列表职责迁入 `TabsBar` | +| `WorkflowDetail.tsx` | 删除 | phase+agent 职责拆入 `PhaseSidebar`+`AgentList` | +| `panelCall.ts` | 不变 | local-jsx 入口仍渲染 `WorkflowsPanel` | + +**外部接口不变**:`/workflows` 命令注册、`panelCall`、`getWorkflowService()` 订阅协议、`BackgroundTasksDialog` 跳转均不动。 + +## 8. 视觉规则 + +- **无内框**:左右两栏中间一条 `│` 竖线,仅此一条分割线;最外层保留最朴素的 round border 界定面板。 +- **聚焦列**:标题 `claude` 橙粗体;非聚焦列标题 `subtle` 灰。 +- **选中/光标行**:整行铺 `backgroundColor="claude"` 橙底(ASCII 用 `▓` 示意),**文字色不变**,状态点保留各自颜色。 +- **状态色**(沿用现有 Ink theme token,无新增): + + | 元素 | 状态 | 字符 | 颜色 | + |---|---|---|---| + | Tab (run) | running | `●` | `warning` | + | | completed | `✓` | `success` | + | | failed | `✗` | `error` | + | | killed | `■` | `subtle` | + | | 当前 | `═══` | `claude` 下划线 | + | Phase | running | `●` | `warning` | + | | done | `✓` | `success` | + | | pending | `○` | `subtle` | + | | 选中 | `▶` | `claude` + 底色 | + | Agent | running | `●` | `warning` | + | | done·text | `✓` | `success` + 行尾 `text` | + | | done·object | `✓` | `success` + 行尾 `object` | + | | dead | `✗` | `error` + 行尾 `dead` | + +- **object 标记**:行尾纯文字 `object`(不用 `◆` 符号)。 +- **左窄右宽**:phase 栏约 20%、agent 栏约 80%(或固定 phase 栏 ~20 字符,agent 栏吃剩余宽度)。 + +## 9. 测试策略 + +- **store**:`declaredPhases` 落地 + null meta 回归(§4)。 +- **面板**(`WorkflowsPanel.test.tsx`,ink-testing-library,遵循仓库 mock 规范): + - 多 run → tab 渲染 + 当前 tab 下划线;`Tab`/`Shift+Tab` 切换且重置子选择。 + - `←/→` 切 `focusColumn`(标题颜色 / 光标落点)。 + - phase 侧栏选中 → 右栏 agent 按 phase 过滤;`All` 显全部。 + - pending phase(`declaredPhases` 有、store 无)显示 `○`。 + - 选中行/光标行底色条(断言对应 ``)。 + - `x` kill、`r` resume(mock service)、`q`/`Esc` 退出。 + - 空态(无 run):占位文案 + `n` 提示。 + - 订阅刷新:store 变更后面板重渲染(agent 状态 running→done)。 +- **回归**:`bun run precheck` 零错误;现有 workflow 集成测试(canonical scripts / review / loop / resume)保持绿。 + +## 10. 里程碑与提交切分 + +每个里程碑结束 `bun run precheck` 必须零错误。 + +1. **M1 store**:`RunProgress.declaredPhases` + reducer `run_started` 落地 + 测试。 +2. **M2 panel 组件**:新建 `status.ts` / `TabsBar` / `PhaseSidebar` / `AgentList`;`WorkflowsPanel` 重写为焦点状态机;`useWorkflowKeyboard` 改焦点模型;删除 `WorkflowList` / `WorkflowDetail`。 +3. **M3 测试**:`WorkflowsPanel.test.tsx` 全量用例 + precheck 绿。 +4. **M4 文档**:`docs/features/workflow-scripts.md` §六 更新为三区布局/键位;旧 spec §六/§9 加注「面板部分已被 `2026-06-13-workflow-panel-redesign.md` 取代」。 + +## 11. 未做 / 未来工作 + +- per-agent skip/retry 的 UI 接线(引擎 seam 已在)。 +- agent 详情抽屉:选中 agent 后展开其 prompt/输出/token。 +- 多 run 并排对比视图。 +- `declaredPhases` 与实际 `phase()` 调用不一致时的告警(如脚本声明了 phase 却没调用)。 diff --git a/docs/superpowers/specs/2026-06-13-workflow-tui-ultracode-design.md b/docs/superpowers/specs/2026-06-13-workflow-tui-ultracode-design.md new file mode 100644 index 000000000..a5de45299 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-workflow-tui-ultracode-design.md @@ -0,0 +1,287 @@ +# Workflow 集成层重写 + `/workflows` 面板 + `/ultracode` skill 设计 + +> 状态:草案(待 writing-plans 据此产出实施计划) +> 日期:2026-06-13 +> 关联:上一期引擎重建计划 `docs/superpowers/plans/2026-06-12-workflow-engine.md`、spec `docs/superpowers/specs/2026-06-12-workflow-engine-design.md` + +--- + +## 1. 背景与现状 + +引擎包 `packages/workflow-engine/`(`@claude-code-best/workflow-engine`)已重建完成:`runWorkflow`、hooks(`agent`/`parallel`/`pipeline`/`phase`/`log`/`workflow`)、journal 确定性 resume、budget、concurrency、structuredOutput、`AgentAdapter` + `AgentAdapterRegistry`(commit `c2253dcb`)、端口契约(`WorkflowPorts`)与自包含工具描述符(`createWorkflowTool`),单测覆盖 99.65%。 + +`src/` 侧的集成层(`src/workflow/`)虽已接上引擎,但**没有用上引擎的全部能力**,且 TUI/命令层是占位质量: + +- `src/workflow/adapter.ts`:硬编码单一 `WORKFLOW_AGENT`(不查 `AgentAdapterRegistry`,也没接真实 agent 注册表);`taskRegistrar.pendingAction` 恒返回 `null`(skip/retry 未接线);`permissionGate.isAborted` 恒 `false`;`budgetTotal` 恒 `null`;末尾有 `_AppStateUsed` 这类抑制未用导入的补丁。 +- `src/workflow/progressStore.ts`:`agent_done` 把"最后一个 running 的 agent"标完成——并发下会标错(真竞态)。 +- `/workflows`:`local` 命令,返回**纯文本**清单,不是监控面板——本设计将其原地重写为全屏面板。 +- `/ultracode`:**不存在**。 + +本设计把 `src/workflow/` 集成层**全量重写**,使其真正用上引擎能力,并交付全屏监控+控制面板与 ultracode 启动 skill。 + +## 2. 目标与非目标 + +**目标** + +1. 全量重写 `src/workflow/` 集成层(引擎包为地基,不动其核心)。 +2. 后端为单一 `claude-code` `AgentAdapter`,但**深度接入会话体系**:provider/model/agentType/tools/telemetry 全从活的 `AppState` 解析。 +3. 把 `/workflows` **原地重写**为全屏**双栏**面板:左栏=各 workflow 的阶段树(光标移动),右栏=聚焦 workflow 的 agent 运行状况 + 基础信息;监控 + 控制(启动命名/resume/kill/展开)。 +4. 新增 `/ultracode` **纯知识 prompt skill**:把 workflow 编排工作法注入上下文,零运行时副作用。 +5. 旧 `/workflows` 文本命令重写为面板;接线点切换到新 wiring,外部 `Tool`/命令接口不变。 + +**非目标** + +- 不改引擎包核心逻辑(唯一例外:给进度事件加 `agentId`,见 §5)。 +- 不实现多 provider adapter(v1 单后端;Registry 留扩展点但不预填路由规则)。 +- 不做 per-agent skip/retry 的 UI 接线(引擎 seam 保留,见 §12)。 +- 不翻转 `ultracode` 运行时行为开关(纯知识 skill)。 +- 不做跨进程持久化的进度恢复(live runs 留内存;resume 走 journal)。 + +## 3. 范围与迁移清单 + +**新建** + +| 路径 | 职责 | +|---|---| +| `src/workflow/service.ts` | `WorkflowService` 单例门面 | +| `src/workflow/registry.ts` | 建 `AgentAdapterRegistry`,注册单一 `claude-code` adapter | +| `src/workflow/backends/claudeCodeBackend.ts` | 深度集成的 `AgentAdapter`(runAgent 委托 + 体系解析) | +| `src/workflow/backends/types.ts` | 后端/host 解析类型 | +| `src/workflow/ports.ts` | 组装 `WorkflowPorts`(registry + 任务生命周期 + journal + progress bus) | +| `src/workflow/progress/bus.ts` | 类型化发布/订阅事件总线 | +| `src/workflow/progress/store.ts` | reducer:`ProgressEvent` → `RunProgress[]`(按 `agentId` 关联) | +| `src/workflow/panel/WorkflowsPanel.tsx` | 双栏全屏面板(local-jsx) | +| `src/workflow/panel/WorkflowList.tsx` / `WorkflowDetail.tsx` / `useWorkflowKeyboard.ts` | 左栏 workflow 扁平列表 / 右栏 phase 条+agent 列表 / 键位 | +| `src/skills/bundled/ultracode/SKILL.md` | `/ultracode` 知识 skill | + +**重写(整体替换,非打补丁)** + +- `src/workflow/adapter.ts` → 拆解进 `backends/`+`ports.ts`+`registry.ts` +- `src/workflow/wiring.ts` → 薄包装,走 `service` +- `src/workflow/progressStore.ts` → 拆进 `progress/{bus,store}.ts` +- `src/workflow/hostHandle.ts` → 清理(保留不透明 bundle 语义) +- `src/workflow/namedWorkflowCommands.ts` → 重写(扫 `.claude/workflows/` → `/`) +- `src/commands/workflows/index.ts` → 原地重写:`local` 文本命令 → `local-jsx` 面板入口(命令名仍为 `workflows`) + +**改接线点(接口不变,换实现来源)** + +`src/tools.ts`、`src/commands.ts`、`src/tasks.ts`、`src/constants/tools.ts`、`src/utils/permissions/classifierDecision.ts`、`src/components/permissions/PermissionRequest.tsx`、`src/components/tasks/BackgroundTasksDialog.tsx`(workflow 详情入口改为打开 `/workflows `)。 + +**删除** + +- `src/components/tasks/WorkflowDetailDialog.tsx`(详情视图被 `/workflows` 右栏 `WorkflowDetail` 取代;逻辑并入,`BackgroundTasksDialog` 改为跳转 `/workflows`)。 + +**引擎微调** + +- `packages/workflow-engine/src/types.ts`、`src/engine/hooks.ts`:`agent_started`/`agent_done` 加 `agentId: number`(见 §5)。 + +## 4. 架构总览 + +``` +src/workflow/ +├─ service.ts # launch/resume/kill/listRuns/getRun/subscribe/listNamed +├─ registry.ts # AgentAdapterRegistry(单一 claude-code adapter,default 路由) +├─ hostHandle.ts # 不透明 host bundle(toolUseContext/canUseTool/parentMessage/agentId) +├─ ports.ts # WorkflowPorts = { hostFactory, agentRunner(registry), progressEmitter(bus+store), taskRegistrar, journalStore, permissionGate, logger } +├─ backends/ +│ ├─ claudeCodeBackend.ts # AgentAdapter:深度解析 + runAgent 委托 +│ └─ types.ts +├─ progress/ +│ ├─ bus.ts # emit→多订阅者(store / 面板 / 遥测) +│ └─ store.ts # RunProgress[] reducer(agentId 关联) +├─ panel/ +│ ├─ WorkflowsPanel.tsx # 双栏,useSyncExternalStore 订阅 store +│ ├─ WorkflowList.tsx # 左栏:扁平 workflow 列表(名字+状态+当前 phase+计数) +│ ├─ WorkflowDetail.tsx # 右栏:聚焦 workflow 的 phase 横条 + 扁平 agent 列表 +│ └─ useWorkflowKeyboard.ts +├─ wiring.ts # createWorkflowToolCore(): buildTool(引擎描述符) +└─ namedWorkflowCommands.ts # 扫描→/ +``` + +**依赖方向**:`panel` 与 `wiring`(工具)只依赖 `service`;`service` 依赖 `registry`+`ports`+`progress`+引擎;`backends` 依赖 `hostHandle`+核心 `runAgent`。引擎包零 `src/*` 导入不变。 + +## 5. 引擎微调:进度事件加 `agentId` + +当前 `agent_started`/`agent_done` 只带 `label`/`phase`,reducer 只能 LIFO 猜匹配。改为: + +```ts +// packages/workflow-engine/src/types.ts(变体加字段) +| { type: 'agent_started'; runId: string; agentId: number; label?: string; phase?: string } +| { type: 'agent_done'; runId: string; agentId: number; label?: string; phase?: string; result: AgentRunResult } +``` + +`makeHooks`(`engine/hooks.ts`)维护引擎内递增计数器(非脚本沙箱内,可用普通计数器,不受 Date/Math 禁令影响),在 `agent()` 内为每次调用分配 `agentId`,同时盖戳 `agent_started` 与 `agent_done`。`pipeline`/`parallel` 内并发调用各自独立 id,reducer 按 id 精确落位。补 `hooks.test.ts`:并发 agent 的 started/done id 配对回归。 + +## 6. WorkflowService + +```ts +type HostContext = { handle: HostHandle; cwd: string; budgetTotal: number | null; toolUseId?: string } + +type WorkflowService = { + launch(opts: { + source: { script: string } | { name: string } | { scriptPath: string } + args?: unknown + hostContext: HostContext // 调用方构造(工具/面板各自) + description?: string + resumeFromRunId?: string + }): Promise<{ runId: string }> // 立即返回,后台 detached + resume(runId: string, hostContext: HostContext): Promise + kill(runId: string): void // AbortController.abort() → WorkflowAbortedError → killed + listRuns(): RunProgress[] + getRun(runId: string): RunProgress | undefined + subscribe(listener: () => void): () => void // 供 useSyncExternalStore + listNamed(): Promise // 委托 namedWorkflows +} +``` + +**数据流**:`launch` → 解析脚本源 → `parseScript` 快速校验 → 注册 `LocalWorkflowTask`(拿 runId + AbortSignal)→ `progress.bus.emit(run_started)` → `runWorkflow({ ports, host, signal, runId, ... })` detached → 引擎经 hooks 发 `ProgressEvent` → `ports.progressEmitter.emit` 同时喂 `bus`(订阅者)与 `store`(reducer)→ 面板 `useSyncExternalStore` 重渲染。 + +**host context 来源(关键解耦)**:service 不自造 host,由调用方传 `HostContext`: + +- **工具路径**:`wiring.ts` 的 `call` 用引擎 `ports.hostFactory({ context, canUseTool, parentMessage })` 构造(沿用现状)。 +- **面板路径**:`/workflows` 是 local-jsx,回调拿 `ToolUseContext`;面板用它 + 会话 `canUseTool`(按当前权限模式)构造 host,使面板启动的 workflow 子 agent 享有与主会话一致的工具池与权限。 + +单例:`service`、`ports`、`registry`、`bus`、`store` 全进程共享,保证工具与面板同源(修掉旧"每实例一套 adapter/bindings"的隐患)。 + +## 7. 后端深度集成(depth B:单一 adapter,深度读体系) + +`claudeCodeBackend.ts` 实现引擎 `AgentAdapter` 接口,`run(params, ctx)` 内**主动从活会话体系解析**,再委托核心 `runAgent`: + +```ts +// backends/claudeCodeBackend.ts(签名级草图) +export const claudeCodeBackend: AgentAdapter = { + id: 'claude-code', + capabilities: { structuredOutput: true, modelOverride: true }, + async run(params: AgentRunParams, ctx: AgentAdapterContext): Promise { + const { toolUseContext, canUseTool } = unwrapHostBundle(ctx.host) + const appState = toolUseContext.getAppState() + + // 1) agentType → 真实 agent 注册表(不再硬编码 WORKFLOW_AGENT) + const agentDef = resolveAgentDefinition(params.agentType, toolUseContext) // activeAgents 命中;WORKFLOW_AGENT 兜底 + + // 2) model → provider 模型映射 + const resolvedModel = params.model ? mapWorkflowModel(params.model, appState) : undefined + + // 3) 工具池(活权限上下文) + const tools = assembleToolPool(workerPermissionContext(appState, agentDef), appState.mcp.tools) + + // 4) schema → StructuredOutput 指令;prompt 组装 + // 5) runAgent({ agentDefinition, promptMessages, toolUseContext, canUseTool, + // isAsync: true, availableTools: tools, override: { agentId, model: resolvedModel } }) + // 6) finalizeAgentTool → 取 outputTokens / 文本 / 结构化对象 → AgentRunResult + // 失败 → { kind: 'dead' } + }, +} +``` + +要点: + +- **provider 感知**:`mapWorkflowModel` 走 `src/utils/model/` 把 `claude-haiku-*` 这类别名解析为当前 provider 的实际 model id;provider 来自 `src/utils/model/providers.ts` 的会话判定。 +- **agentType → 真实注册表**:`resolveAgentDefinition` 查 `toolUseContext.options.agentDefinitions.activeAgents`,命中即用(Explore/code-reviewer 等内置 + 用户 agent);未命中或无 `agentType` 退 `WORKFLOW_AGENT` 兜底。 +- **工具池/权限**:worker 权限上下文取 agent 定义或 `acceptEdits`,`assembleToolPool` 生成。 +- **遥测/token**:`finalizeAgentTool` 的 `usage.output_tokens` 喂 engine budget;`logEvent('tengu_workflow_agent', {…})` 逐 agent 计量。 +- **Registry**:`registry.ts` = `new AgentAdapterRegistry().register(claudeCodeBackend).default('claude-code')`。`ports.agentRunner.runAgentToResult = (params, host) => registry.resolve(params).run(params, { host })`。v1 不预填路由规则(depth B:单 adapter,不预留多 provider 路由)。 + +## 8. 进度模型(bus + store + agentId 关联) + +- `progress/bus.ts`:`createProgressBus()` 返回 `{ emit(event), subscribe(fn) }`。emit 广播给所有订阅者(store、面板、遥测)。替换旧"只有 in-memory Map"的单消费者模型。 +- `progress/store.ts`:`RunProgress[]` reducer,沿用 `RunProgress` 形状(runId/status/phases/currentPhase/agents/logs/agentCount/returnValue/error/updatedAt)。新增 `AgentProgress.id: number`;`agent_done` 按 `event.agentId` 精确匹配 `agents[].id`(修掉旧 LIFO 竞态)。`subscribe()` 暴露给 React `useSyncExternalStore`。 +- 状态为进程内(live runs);resume 读磁盘 journal(`.claude/workflow-runs//journal.jsonl`)。 + +## 9. `/workflows` 双栏面板(左列表 / 右 phase+agent) + +`/workflows` 命令**原地重写**为 `local-jsx`(替换原文本命令),渲染**双栏**面板:走 `FullscreenLayout.modal` 路径(底部锚定、向上生长,`maxHeight ≈ terminalRows`,留 2 行 transcript peek,与 `/model`、`/config` 一致),`useSyncExternalStore` 订阅 `service.subscribe` 实时刷新。**左栏=扁平 workflow 列表(极简),右栏=聚焦 workflow 的 phase 横条 + 扁平 agent 列表**。无树、无嵌套。 + +``` +Workflows · 2 running · 1 done q quit + +▸ ● review-pipeline Verify 2/3 8/12 + ● smoke-test Pong 3/3 + ✓ code-audit done 11/11 + + Named: research-report · smoke + +───────────────────────────────────────────────── +review-pipeline ● running + + Phases ✓Find ✓Review ●Verify + ● verify:api 1.2k · verify:db — + ✓ find:src 3.1k ✓ verify:auth 2.0k + +j/k run · r resume · x kill · n new +``` + +**导航模型**:左栏是扁平 workflow 列表——每行一个 run(状态点 + 名称 + 当前 phase + `done/total` agent 计数),光标 `▸` 用 `j/k` 上下选 run,选中即聚焦、右栏随之切换。底部 NAMED 区(`service.listNamed()`,`n` 启动)。无展开/收起、无嵌套。 + +**组件** + +- `WorkflowList.tsx`:左栏。`service.listRuns()` → 每行 `●`/`✓` 状态点 + workflow 名 + 当前 phase + agent 计数;底部 NAMED。 +- `WorkflowDetail.tsx`:右栏。一行头(workflow 名 + 状态)+ **Phases 横条**(`✓`/`●`/`○` 内联)+ **扁平 agent 列表**(每项状态符 + label + token,自动换行排版,不嵌套)。终态显示 `returnValue`/`error`。 +- `useWorkflowKeyboard.ts`:键位见下。 + +**键位**:`j/k` 选 run · `r` resume 聚焦 workflow(读 journal)· `x` kill · `n` 选命名 workflow 启动 · `q`/`esc` 经 `onDone()` 关闭。空 run 时左栏聚焦 NAMED,右栏给"新建脚本到 `.claude/workflows/`"提示。 + +**颜色(Impeccable 体系)**:running = Claude Orange `#D77757` 动态点;done = 绿;failed = 红;killed = 灰;底栏键位 `subtle`。 + +**与 `WorkflowDetailDialog.tsx` 的关系**:该旧组件删除,详情逻辑并入右栏 `WorkflowDetail`;`BackgroundTasksDialog`(Shift+Down)保留为后台任务总览,其 workflow 详情跳转改为打开 `/workflows `,面板以该 run 为初始聚焦。 + +**命令注册**:`src/commands/workflows/index.ts` 导出 `local-jsx` 命令(`load: () => import('../../workflow/panel/WorkflowsPanel.js')`),在 `src/commands.ts` 经 `feature('WORKFLOW_SCRIPTS')` 条件注册(替换原文本 `workflowsCmd`)。 + +## 10. Workflow 工具 wiring + +`wiring.ts` 仍薄:`createWorkflowToolCore(): Tool = buildTool(引擎描述符)`,描述符 = `createWorkflowTool(service.ports)`。保持 `Tool` 接口(name/inputSchema/isEnabled/isReadOnly/description/prompt/call/renderToolUseMessage/mapToolResultToToolResultBlockParam)。**关键变化**:描述符不再各自 `createWorkflowAdapter()`,统一走 `service` 单例。工具 `call` 返回 `run_id` + 提示"用 /workflows 查看实时进度"。工具仍在 `CORE_TOOLS`/`ALL_AGENT_DISALLOWED_TOOLS`,权限分类、`WorkflowPermissionRequest` 接新 wiring。 + +## 11. `/ultracode` skill + +`src/skills/bundled/ultracode/SKILL.md`,`type: prompt`、`user-invocable: true`(自动成 `/ultracode`)。内容 = 蒸馏后的 workflow 编排 playbook: + +- **frontmatter**:`name: ultracode`、`description: 进入多 agent workflow 编排模式:何时用、编排原语、质量模式、确定性约束、后端路由、resume/budget、文件与命令`、`user-invocable: true`。 +- **何时用 workflow**:可分解/并行、需多视角置信、规模超单上下文、需 resume/审计;何时**不**用(琐碎单文件、单次问答)。 +- **编排原语速查**:`agent`/`parallel`/`pipeline`/`phase`/`log`/`workflow` 语义与陷阱(pipeline 默认无 barrier、parallel 单项抛错→null、budget 硬上限、并发 cap、`MAX_TOTAL_AGENTS=1000`/`MAX_ITEMS_PER_CALL=4096`)。 +- **质量模式库**(每种给最小可运行片段):adversarial-verify(多数票 refute)、perspective-diverse verify、judge panel、loop-until-dry、multi-modal sweep、completeness critic。 +- **确定性约束**:脚本内禁 `Date.now()`/`Math.random()`(经 `args` 传时间戳/种子);`meta` 必须纯字面量。 +- **后端路由**:`AgentAdapterRegistry` 按 model/agentType 路由;v1 默认 `claude-code`,深度读会话 provider/model/agent 体系。 +- **resume/budget**:`resumeFromRunId` 重放 journal;`budget.total` 硬顶(默认无限)。 +- **文件与命令**:`.claude/workflows/`、`.claude/workflow-runs//journal.jsonl`、`/workflows` 面板、`/` 命名命令。 + +调用即注入上下文,**不改主循环、零运行时副作用**。 + +## 12. 错误处理 / 权限 / 生命周期 / 并发 / budget / skip-retry + +- **错误**:脚本语法/meta 错 → `parseScript` 即时返错(不进后台);agent 抛错 → `kind:'dead'`→`null`,workflow 继续(parallel/pipeline 容错);`WorkflowAbortedError` → `killed`;其它 → `failed`+error。终态走 `run_done` + `LocalWorkflowTask` complete/fail/kill。 +- **权限**:worker 用 `assembleToolPool(workerPermissionContext, mcp.tools)`,权限模式取 agent 定义或 `acceptEdits`;面板启动的 run 用面板 `ToolUseContext` 的 `canUseTool`。`WorkflowPermissionRequest.tsx` 保留并接新 wiring。 +- **生命周期/并发/budget**:复用引擎 `Semaphore`(`min(16, cores-2)`)、`MAX_TOTAL_AGENTS=1000`、`MAX_ITEMS_PER_CALL=4096`、`Budget`(默认 `null` 无限;可经 settings/env 注入 turn 级上限,留参数)。 +- **skip/retry(per-agent)**:引擎 `taskRegistrar.pendingAction` seam 保留;v1 返 `null`。面板控制诉求由 kill/resume 覆盖。 + +## 13. 测试策略 + +- **引擎**:`hooks.test.ts` 加"并发 agent 的 started/done id 配对"回归。 +- **集成层**(`src/workflow/__tests__/`): + - `service.test.ts`:launch→completed/failed/killed、resume 走 journal、kill 中止、subscribe 通知(mock 端口,无 LLM)。 + - `registry.test.ts`:默认路由命中 `claude-code`;`resolve` 对未知规则回落默认。 + - `claudeCodeBackend.test.ts`:agentType→真实定义命中/兜底;model→映射;失败→`dead`(mock `runAgent`)。 + - `progressStore.test.ts`:**并发 `agent_done` 按 `agentId` 精确关联**(回归旧竞态)、phase 切换、`run_done` 终态。 + - `WorkflowsPanel.test.tsx`(ink-testing-library):扁平列表渲染、光标 j/k 切换聚焦 workflow、右栏 phase 条+agent 列表、键位 x/r/n、空态、订阅刷新。 +- **回归**:`bun run precheck` 零错误;现有 workflow 集成测试(canonical scripts/review/loop/resume)仍绿。 +- 遵循仓库 mock 规范(共享 `tests/mocks/log.ts`、`debug.ts`;mock 底层 HTTP/副作用,不 mock 业务模块;注意 `mock.module` 进程全局污染,集成测试 mock axios 而非源 API 模块)。 + +## 14. 里程碑与提交切分 + +每个里程碑结束 `bun run precheck` 必须零错误。 + +1. **M1 引擎微调**:`ProgressEvent.agentId` + hooks 盖戳 + 单测。 +2. **M2 进度层**:`progress/bus.ts` + `store.ts`(agentId 关联)+ 测试。 +3. **M3 后端 + Registry + ports + hostHandle**:`claudeCodeBackend`(深度解析)、`registry`、`ports` 组装 + 测试。 +4. **M4 Service 门面**:`service.ts`(launch/resume/kill/subscribe/listNamed)+ 测试。 +5. **M5 工具 wiring 切换 + 接线点更新**:`wiring.ts` 走 service;更新 tools/commands/tasks/constants/classifier/PermissionRequest/BackgroundTasksDialog。`precheck` 绿。 +6. **M6 `/workflows` 面板(原地重写命令)**:panel 组件(`PhaseTree`/`AgentStatus`)+ 键位 + 把 `src/commands/workflows/` 重写为 local-jsx + 测试。 +7. **M7 `/ultracode` skill**:`SKILL.md` playbook。 +8. **M8 文档**:更新 `docs/features/workflow-scripts.md`,新增面板/skill 说明。 + +## 15. 未做 / 未来工作 + +- 多 provider adapter(OpenAI/Gemini/Grok/Bedrock/Vertex 等真后端 + model 路由分流)——引擎 Registry 机制本身在用(单 adapter),扩第二个 adapter 时再补 `route` 规则;本期按 depth B 不预填。 +- per-agent skip/retry 的 UI 接线(引擎 seam 已在)。 +- `ultracode` 运行时行为开关(默认倾向 Workflow 工具)——本期为纯知识 skill。 +- 跨进程/重启的 live 进度恢复(当前内存;resume 走 journal)。 +- `budgetTotal` 从 settings/env 注入 turn 级预算。 diff --git a/packages/builtin-tools/src/index.ts b/packages/builtin-tools/src/index.ts index c31d600b3..534e4a086 100644 --- a/packages/builtin-tools/src/index.ts +++ b/packages/builtin-tools/src/index.ts @@ -61,9 +61,14 @@ export { TeamDeleteTool } from './tools/TeamDeleteTool/TeamDeleteTool.js' export { TerminalCaptureTool } from './tools/TerminalCaptureTool/TerminalCaptureTool.js' export { VerifyPlanExecutionTool } from './tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js' export { WebBrowserTool } from './tools/WebBrowserTool/WebBrowserTool.js' -export { WorkflowTool } from './tools/WorkflowTool/WorkflowTool.js' +// WorkflowTool 实现已迁移到 @claude-code-best/workflow-engine(独立包,端口适配)。 +// 这里仅 re-export 工厂与常量,保持向后兼容。 +export { + createWorkflowTool, + WORKFLOW_TOOL_NAME, + type WorkflowToolDescriptor, +} from '@claude-code-best/workflow-engine' export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js' -export { getWorkflowCommands } from './tools/WorkflowTool/createWorkflowCommand.js' // Constants export { diff --git a/packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts b/packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts deleted file mode 100644 index 2ac3daa1b..000000000 --- a/packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { randomUUID } from 'crypto' -import { mkdir, readdir, readFile, writeFile } from 'fs/promises' -import { join, parse } from 'path' -import { z } from 'zod/v4' -import type { ToolResultBlockParam } from 'src/Tool.js' -import { buildTool } from 'src/Tool.js' -import { truncate } from 'src/utils/format.js' -import { safeParseJSON } from 'src/utils/json.js' -import { - WORKFLOW_DIR_NAME, - WORKFLOW_FILE_EXTENSIONS, - WORKFLOW_TOOL_NAME, -} from './constants.js' - -const WORKFLOW_RUNS_DIR = '.claude/workflow-runs' - -const inputSchema = z.object({ - workflow: z.string().describe('Name of the workflow to execute'), - args: z.string().optional().describe('Arguments to pass to the workflow'), - action: z - .enum(['start', 'status', 'advance', 'cancel', 'list']) - .optional() - .describe('Workflow action. Defaults to start.'), - run_id: z - .string() - .optional() - .describe('Workflow run id for status, advance, or cancel.'), -}) -type Input = typeof inputSchema -type WorkflowInput = z.infer - -type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled' - -type WorkflowStep = { - name: string - prompt: string - status: WorkflowStepStatus - startedAt?: number - completedAt?: number -} - -type WorkflowRun = { - runId: string - workflow: string - args?: string - status: 'running' | 'completed' | 'cancelled' - createdAt: number - updatedAt: number - currentStepIndex: number - steps: WorkflowStep[] -} - -type WorkflowOutput = { output: string } - -async function findWorkflowFile( - workflowDir: string, - workflow: string, -): Promise<{ path: string; content: string } | null> { - for (const ext of WORKFLOW_FILE_EXTENSIONS) { - const path = join(workflowDir, `${workflow}${ext}`) - try { - return { path, content: await readFile(path, 'utf-8') } - } catch { - // try next - } - } - return null -} - -async function listAvailableWorkflows(workflowDir: string): Promise { - try { - const files = await readdir(workflowDir) - return files - .filter(f => - WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase()), - ) - .map(f => parse(f).name) - .sort() - } catch { - return [] - } -} - -function workflowRunPath(cwd: string, runId: string): string { - return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`) -} - -async function readWorkflowRun( - cwd: string, - runId: string, -): Promise { - try { - const parsed = safeParseJSON( - await readFile(workflowRunPath(cwd, runId), 'utf-8'), - false, - ) as Partial | null - if ( - !parsed || - typeof parsed.runId !== 'string' || - typeof parsed.workflow !== 'string' || - !Array.isArray(parsed.steps) - ) { - return null - } - return parsed as WorkflowRun - } catch { - return null - } -} - -async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise { - await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true }) - await writeFile( - workflowRunPath(cwd, run.runId), - JSON.stringify(run, null, 2) + '\n', - 'utf-8', - ) -} - -async function listWorkflowRuns(cwd: string): Promise { - let files: string[] - try { - files = await readdir(join(cwd, WORKFLOW_RUNS_DIR)) - } catch { - return [] - } - const runs = await Promise.all( - files - .filter(f => f.endsWith('.json')) - .map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))), - ) - return runs - .filter((run): run is WorkflowRun => run !== null) - .sort((a, b) => b.updatedAt - a.updatedAt) -} - -function parseMarkdownSteps(content: string): WorkflowStep[] { - const steps: WorkflowStep[] = [] - for (const rawLine of content.split('\n')) { - const line = rawLine.trim() - const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/) - const bulletMatch = line.match(/^[-*]\s+(.+)$/) - const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/) - const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1] - if (!text) continue - steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' }) - } - return steps -} - -function parseYamlSteps(content: string): WorkflowStep[] { - const steps: WorkflowStep[] = [] - let current: Partial | null = null - const flush = () => { - if (!current) return - const prompt = current.prompt ?? current.name - if (current.name && prompt) { - steps.push({ - name: current.name, - prompt, - status: 'pending', - }) - } - current = null - } - - for (const rawLine of content.split('\n')) { - const line = rawLine.trim() - const stepText = line.match(/^-\s+(.+)$/)?.[1] - if (stepText) { - flush() - const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1] - current = { - name: inlineName ?? stepText, - prompt: inlineName ? undefined : stepText, - } - continue - } - const name = line.match(/^name:\s*(.+)$/)?.[1] - if (name) { - if (!current) current = {} - current.name = name - continue - } - const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2] - if (prompt) { - if (!current) current = {} - current.prompt = prompt - } - } - flush() - return steps -} - -function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] { - const ext = parse(filePath).ext.toLowerCase() - const steps = - ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content) - if (steps.length > 0) { - return steps - } - return [ - { - name: 'Execute workflow', - prompt: content.trim(), - status: 'pending', - }, - ] -} - -function formatStep(step: WorkflowStep, index: number): string { - return `Step ${index + 1}: ${step.name}\n${step.prompt}` -} - -function formatRunStatus(run: WorkflowRun): string { - const lines = [ - `Workflow run: ${run.runId}`, - `Workflow: ${run.workflow}`, - `Status: ${run.status}`, - `Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`, - `Steps: ${run.steps.length}`, - ] - for (let i = 0; i < run.steps.length; i += 1) { - const step = run.steps[i]! - lines.push(` ${i + 1}. [${step.status}] ${step.name}`) - } - return lines.join('\n') -} - -async function startWorkflow( - input: WorkflowInput, - cwd: string, -): Promise { - const workflowDir = join(cwd, WORKFLOW_DIR_NAME) - const found = await findWorkflowFile(workflowDir, input.workflow) - if (!found) { - const available = await listAvailableWorkflows(workflowDir) - const hint = - available.length > 0 - ? `\nAvailable workflows: ${available.join(', ')}` - : `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.` - return { output: `Error: Workflow "${input.workflow}" not found.${hint}` } - } - - const steps = parseWorkflowSteps(found.path, found.content) - const now = Date.now() - steps[0] = { ...steps[0]!, status: 'running', startedAt: now } - const run: WorkflowRun = { - runId: randomUUID(), - workflow: input.workflow, - ...(input.args ? { args: input.args } : {}), - status: 'running', - createdAt: now, - updatedAt: now, - currentStepIndex: 0, - steps, - } - await writeWorkflowRun(cwd, run) - - const argsSection = input.args ? `\n\nArguments:\n${input.args}` : '' - return { - output: [ - `Workflow run started`, - `run_id: ${run.runId}`, - `workflow: ${run.workflow}`, - '', - formatStep(steps[0]!, 0), - argsSection, - '', - `When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`, - ].join('\n'), - } -} - -async function getRunOrError( - cwd: string, - runId: string | undefined, -): Promise<{ run?: WorkflowRun; output?: string }> { - if (!runId) return { output: 'Error: run_id is required for this action.' } - const run = await readWorkflowRun(cwd, runId) - if (!run) return { output: `Error: Workflow run "${runId}" not found.` } - return { run } -} - -async function advanceWorkflow( - cwd: string, - runId: string | undefined, -): Promise { - const found = await getRunOrError(cwd, runId) - if (!found.run) return { output: found.output! } - const run = found.run - const now = Date.now() - const current = run.steps[run.currentStepIndex] - if (current && current.status === 'running') { - current.status = 'completed' - current.completedAt = now - } - const nextIndex = run.currentStepIndex + 1 - if (nextIndex >= run.steps.length) { - run.status = 'completed' - run.updatedAt = now - await writeWorkflowRun(cwd, run) - return { output: `Workflow completed\nrun_id: ${run.runId}` } - } - run.currentStepIndex = nextIndex - run.steps[nextIndex] = { - ...run.steps[nextIndex]!, - status: 'running', - startedAt: now, - } - run.updatedAt = now - await writeWorkflowRun(cwd, run) - return { - output: [ - `Next workflow step`, - `run_id: ${run.runId}`, - '', - formatStep(run.steps[nextIndex]!, nextIndex), - '', - `When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`, - ].join('\n'), - } -} - -async function cancelWorkflow( - cwd: string, - runId: string | undefined, -): Promise { - const found = await getRunOrError(cwd, runId) - if (!found.run) return { output: found.output! } - const run = found.run - const now = Date.now() - run.status = 'cancelled' - run.updatedAt = now - for (const step of run.steps) { - if (step.status === 'pending' || step.status === 'running') { - step.status = 'cancelled' - } - } - await writeWorkflowRun(cwd, run) - return { output: `Workflow cancelled\nrun_id: ${run.runId}` } -} - -async function listWorkflowRunsForOutput(cwd: string): Promise { - const runs = await listWorkflowRuns(cwd) - if (runs.length === 0) return { output: 'No workflow runs recorded.' } - return { - output: runs - .slice(0, 20) - .map( - run => - `${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`, - ) - .join('\n'), - } -} - -export const WorkflowTool = buildTool({ - name: WORKFLOW_TOOL_NAME, - searchHint: 'execute user-defined workflow scripts', - maxResultSizeChars: 50_000, - strict: true, - - inputSchema, - - async description() { - return 'Execute and track a user-defined workflow from .claude/workflows/' - }, - async prompt() { - return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps. - -Actions: -- start (default): create a persisted workflow run and return the first step to execute -- advance: mark the current step complete and return the next step -- status: inspect a workflow run by run_id -- cancel: cancel a workflow run -- list: list recent workflow runs - -Workflow run state is persisted in .claude/workflow-runs/.` - }, - userFacingName() { - return 'Workflow' - }, - isReadOnly(input) { - return input.action === 'status' || input.action === 'list' - }, - isEnabled() { - return true - }, - - renderToolUseMessage(input: Partial) { - const name = input.workflow ?? 'unknown' - const action = input.action ?? 'start' - return input.args - ? `Workflow: ${action} ${name} ${input.args}` - : `Workflow: ${action} ${name}` - }, - - mapToolResultToToolResultBlockParam( - content: WorkflowOutput, - toolUseID: string, - ): ToolResultBlockParam { - return { - tool_use_id: toolUseID, - type: 'tool_result', - content: truncate(content.output, 50_000), - } - }, - - async call(input: WorkflowInput) { - const cwd = process.cwd() - const action = input.action ?? 'start' - switch (action) { - case 'start': - return { data: await startWorkflow(input, cwd) } - case 'status': { - const found = await getRunOrError(cwd, input.run_id) - return { - data: { - output: found.run ? formatRunStatus(found.run) : found.output!, - }, - } - } - case 'advance': - return { data: await advanceWorkflow(cwd, input.run_id) } - case 'cancel': - return { data: await cancelWorkflow(cwd, input.run_id) } - case 'list': - return { data: await listWorkflowRunsForOutput(cwd) } - } - }, -}) diff --git a/packages/builtin-tools/src/tools/WorkflowTool/__tests__/WorkflowTool.test.ts b/packages/builtin-tools/src/tools/WorkflowTool/__tests__/WorkflowTool.test.ts deleted file mode 100644 index 88be3d9f9..000000000 --- a/packages/builtin-tools/src/tools/WorkflowTool/__tests__/WorkflowTool.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { WorkflowTool } from '../WorkflowTool' - -let cwd: string -let previousCwd: string - -beforeEach(async () => { - previousCwd = process.cwd() - cwd = join( - tmpdir(), - `workflow-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`, - ) - await mkdir(join(cwd, '.claude', 'workflows'), { recursive: true }) - process.chdir(cwd) -}) - -afterEach(async () => { - process.chdir(previousCwd) - await rm(cwd, { recursive: true, force: true }) -}) - -describe('WorkflowTool', () => { - test('starts a workflow run and persists step state', async () => { - await writeFile( - join(cwd, '.claude', 'workflows', 'release.md'), - ['# Release', '', '- [ ] Run tests', '- [ ] Build package'].join('\n'), - ) - - const result = await WorkflowTool.call({ workflow: 'release' }) - - expect(result.data.output).toContain('Workflow run started') - expect(result.data.output).toContain('Run tests') - const match = result.data.output.match(/run_id: ([a-f0-9-]+)/) - expect(match?.[1]).toBeString() - - const raw = await readFile( - join(cwd, '.claude', 'workflow-runs', `${match![1]}.json`), - 'utf-8', - ) - const run = JSON.parse(raw) - expect(run.workflow).toBe('release') - expect(run.status).toBe('running') - expect(run.steps).toHaveLength(2) - expect(run.steps[0].status).toBe('running') - expect(run.steps[1].status).toBe('pending') - }) - - test('advances a workflow run through completion', async () => { - await writeFile( - join(cwd, '.claude', 'workflows', 'audit.yaml'), - [ - 'steps:', - ' - name: Inspect', - ' prompt: Inspect the code', - ' - name: Verify', - ' prompt: Run focused tests', - ].join('\n'), - ) - - const started = await WorkflowTool.call({ workflow: 'audit' }) - const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]! - - const next = await WorkflowTool.call({ - workflow: 'audit', - action: 'advance', - run_id: runId, - }) - expect(next.data.output).toContain('Next workflow step') - expect(next.data.output).toContain('Run focused tests') - - const done = await WorkflowTool.call({ - workflow: 'audit', - action: 'advance', - run_id: runId, - }) - expect(done.data.output).toContain('Workflow completed') - }) - - test('lists and cancels workflow runs', async () => { - await writeFile( - join(cwd, '.claude', 'workflows', 'cleanup.md'), - '- Remove stale files', - ) - - const started = await WorkflowTool.call({ workflow: 'cleanup' }) - const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]! - - const listed = await WorkflowTool.call({ - workflow: 'cleanup', - action: 'list', - }) - expect(listed.data.output).toContain(runId) - - const cancelled = await WorkflowTool.call({ - workflow: 'cleanup', - action: 'cancel', - run_id: runId, - }) - expect(cancelled.data.output).toContain('Workflow cancelled') - }) -}) diff --git a/packages/builtin-tools/src/tools/WorkflowTool/constants.ts b/packages/builtin-tools/src/tools/WorkflowTool/constants.ts deleted file mode 100644 index 49249caf5..000000000 --- a/packages/builtin-tools/src/tools/WorkflowTool/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const WORKFLOW_TOOL_NAME = 'workflow' -export const WORKFLOW_DIR_NAME = '.claude/workflows' -export const WORKFLOW_FILE_EXTENSIONS = ['.yml', '.yaml', '.md'] diff --git a/packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts b/packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts deleted file mode 100644 index 02198a2c7..000000000 --- a/packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { readdir } from 'fs/promises' -import { join, parse } from 'path' -import type { Command } from 'src/types/command.js' -import { WORKFLOW_DIR_NAME, WORKFLOW_FILE_EXTENSIONS } from './constants.js' - -/** - * Scans .claude/workflows/ directory and creates Command objects for each workflow file. - * Each workflow file becomes a slash command (e.g. /workflow-name). - */ -export async function getWorkflowCommands(cwd: string): Promise { - const workflowDir = join(cwd, WORKFLOW_DIR_NAME) - let files: string[] - try { - files = await readdir(workflowDir) - } catch { - return [] - } - - const workflowFiles = files.filter(f => { - const ext = parse(f).ext.toLowerCase() - return WORKFLOW_FILE_EXTENSIONS.includes(ext) - }) - - return workflowFiles.map(file => { - const name = parse(file).name - return { - type: 'prompt' as const, - name, - description: `Run workflow: ${name}`, - kind: 'workflow' as const, - source: 'builtin' as const, - progressMessage: `Running workflow ${name}...`, - contentLength: 0, - async getPromptForCommand(args, _context) { - const { readFile } = await import('fs/promises') - const content = await readFile(join(workflowDir, file), 'utf-8') - return [ - { - type: 'text' as const, - text: `Execute this workflow:\n\n${content}${args ? `\n\nArguments: ${args}` : ''}`, - }, - ] - }, - } satisfies Command - }) -} diff --git a/packages/workflow-engine/examples/registry-demo.ts b/packages/workflow-engine/examples/registry-demo.ts new file mode 100644 index 000000000..53a3b2c2d --- /dev/null +++ b/packages/workflow-engine/examples/registry-demo.ts @@ -0,0 +1,124 @@ +/** + * registry 多后端路由演示(mock adapter,无需 API key)。 + * + * 两个 adapter:strong(被 researcher 路由命中)+ fast(默认)。 + * 脚本里 agent({agentType:'researcher'}) → strong,其余 → fast。 + * 证明 agent 后端可通过 AgentAdapterRegistry 插拔 + 路由,引擎不关心实现。 + * + * 用法:bun run packages/workflow-engine/examples/registry-demo.ts + */ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + AgentAdapterRegistry, + createFileJournalStore, + createHostHandle, + runWorkflow, + type AgentAdapter, + type AgentRunParams, + type AgentRunResult, + type WorkflowPorts, +} from '@claude-code-best/workflow-engine' + +const strongAdapter: AgentAdapter = { + id: 'strong', + capabilities: { structuredOutput: true, tools: true }, + async run(p: AgentRunParams): Promise { + return { + kind: 'ok', + output: `[strong] ← ${p.prompt}`, + usage: { outputTokens: 1 }, + } + }, +} + +const fastAdapter: AgentAdapter = { + id: 'fast', + capabilities: { structuredOutput: false }, + async run(p: AgentRunParams): Promise { + return { + kind: 'ok', + output: `[fast] ← ${p.prompt}`, + usage: { outputTokens: 1 }, + } + }, +} + +const registry = new AgentAdapterRegistry() + .register(strongAdapter) + .register(fastAdapter) + .route({ kind: 'agentType', agentType: 'researcher', adapter: 'strong' }) + .default('fast') + +const SCRIPT = ` +export const meta = { name: 'registry-demo', description: 'multi-adapter routing' } +phase('Route') +const research = await agent('深度调研任务', { agentType: 'researcher', label: 'research' }) +const quick = await agent('快速小任务', { label: 'quick' }) +return { research, quick } +` + +function makePorts(runsDir: string): WorkflowPorts { + return { + // registry 优先,agentRunner 仅作形状占位(不会被调到) + agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) }, + agentAdapterRegistry: registry, + progressEmitter: { + emit: e => { + if (e.type === 'phase_started') console.log(`\n━ phase: ${e.phase}`) + else if (e.type === 'agent_done') { + const out = + e.result.kind === 'ok' + ? String(e.result.output) + : `[${e.result.kind}]` + console.log(` ✓ ${e.label} → ${out}`) + } + }, + }, + taskRegistrar: { + register: () => ({ + runId: 'demo', + signal: new AbortController().signal, + }), + complete() {}, + fail() {}, + kill() {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(runsDir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: process.cwd(), + budgetTotal: null, + }), + } +} + +if (import.meta.main) { + await registry.initializeAll() + try { + const result = await runWorkflow({ + script: SCRIPT, + runId: `demo-${Date.now()}`, + ports: makePorts(join(tmpdir(), 'wf-registry-demo')), + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: process.cwd(), + budgetTotal: null, + }) + console.log(`\n■ ${result.status}`) + if (result.status === 'completed') { + const ret = result.returnValue as { research: string; quick: string } + console.log( + `research(agentType:researcher) → ${ret.research.startsWith('[strong]') ? 'strong adapter ✓' : '??'}`, + ) + console.log( + `quick(默认) → ${ret.quick.startsWith('[fast]') ? 'fast adapter ✓' : '??'}`, + ) + } + } finally { + await registry.disposeAll() + } +} diff --git a/packages/workflow-engine/examples/research-report/README.md b/packages/workflow-engine/examples/research-report/README.md new file mode 100644 index 000000000..f33039b25 --- /dev/null +++ b/packages/workflow-engine/examples/research-report/README.md @@ -0,0 +1,74 @@ +# research-report —— 库优先运行示例 + +用 `@claude-code-best/workflow-engine` **直接**运行一个 workflow,绕开 Workflow 工具与核心 `runAgent`。 + +## 状态 + +- **引擎层**:完整且测试覆盖 **99.65% 行 / 99.20% 函数**(workflow-engine 包 112 个 mock 测试全绿)。 +- **本 example**:编排逻辑(`parallel` / `pipeline` / `schema` / `args`)经 mock 端到端验证;**真实 LLM 已跑通**(直连 Anthropic SDK)。 +- **定位**:库 API 与引擎逻辑的**参考实现 + 冒烟示范**,不是生产服务——见下方「生产就绪」。 + +## 它演示了什么 + +- **库可独立使用**:`run.ts` 只 `import { runWorkflow, ... } from '@claude-code-best/workflow-engine'`,自己组装 7 个端口,不依赖 `src/` 任何核心模块。 +- **agent 后端直连 Anthropic SDK**:`agentRunner` 调 `client.messages.create`,子 agent = 一次模型调用(不经核心 `runAgent`、不经 Workflow 工具)。 +- **真实 LLM + 结构化输出**:`agent(schema)` → prompt 追加 JSON 指令 → 提取 JSON → `validateAgainstSchema`(Ajv)校验,失败回退 `dead`。 +- **引擎能力全覆盖**:`parallel`(屏障,多角度 fan-out)→ `pipeline`(无屏障,逐条深挖)→ `phase` / `log` / `args`。 + +## 运行 + +```bash +ANTHROPIC_API_KEY=sk-... \ + bun run packages/workflow-engine/examples/research-report/run.ts "Edge Computing" +``` + +环境变量: + +- `ANTHROPIC_API_KEY`(必填) +- `ANTHROPIC_MODEL`:默认 `claude-sonnet-4-5` +- `WORKFLOW_API_CONCURRENCY`:API 并发上限,默认 `3`(见下)。低 tier 可设 `1` 串行 +- `RESEARCH_RUNS_DIR`:journal 目录,默认 `~/.claude/workflow-runs`(resume 时复用) + +## 健壮性与排错 + +runner 内置了几项让真实 API 跑得稳的处理: + +- **API 并发限制**:`llmAgent` 经独立信号量限并发(默认 3),**独立于引擎的 CPU 级 semaphore**——LLM API 对并发远比 CPU 敏感,按 cores(可能 14)放并发会触发 429。用 `WORKFLOW_API_CONCURRENCY` 调。 +- **429/5xx 重试**:指数退避(500ms → 1s → 2s → 4s,最多 4 次);连接/超时错误也重试。 +- **SDK 日志关闭**:`new Anthropic({ logLevel: 'off' })`(options 优先级最高,压过 `ANTHROPIC_LOG` env)。否则 SDK 会打 `[log_xxxxx] sending request {…}` 这种完整请求 JSON。 +- **错误摘要精简**:失败只打 `HTTP 429 rate_limit_error` 这种短行,不打印含 request body 的整段 message。 +- **synthesize 防 JSON**:prompt 明确禁止把输入的 `deepFindings` JSON 原样粘进报告。 + +排错速查: + +| 现象 | 原因 / 处理 | +|------|------| +| `HTTP 429 ...` 频繁 | 降 `WORKFLOW_API_CONCURRENCY=1`(或 2) | +| agent `✗ [dead]` 多 | 模型未按 schema 返回 JSON;换更强模型或放宽 schema | +| `[log_xxx] sending request` 刷屏 | 不应再出现(已 `logLevel:'off'`);若仍出现检查 env 是否覆盖 | +| 报告被截断 | synthesize 已 `maxTokens:8192`;仍不够可改 workflow 脚本 | + +## 文件 + +| 文件 | 作用 | +|------|------| +| `research-report.workflow.mjs` | workflow 脚本(编排逻辑,纯 JS,引擎沙箱执行) | +| `run.ts` | runner:组装端口 + 直连 SDK + 运行 + 终端进度 | +| (同级 `../smoke.ts`) | 最小冒烟入口(3 次调用,秒级验证通路) | + +## 扩展点 + +- **联网调研**:给 `llmAgent` 的 `messages.create` 加 `tools: [{ type: 'web_search_20250305' }]`(Anthropic server-side web search),research 角度即可联网。 +- **命名命令复用**:把 `research-report.workflow.mjs` 复制到项目 `.claude/workflows/research-report.mjs`,即可通过 `/research-report` 或 Workflow 工具运行(同一脚本,两种入口)。 +- **token 预算**:`runWorkflow({ budgetTotal: 200000 })` 设上限;脚本内用 `budget.remaining()` 自适应规模。 +- **resume**:同 `runId` + `resume: true` 重放 journal,已完成的 agent 不重跑。 + +## 生产就绪(诚实) + +本 example 验证的是**库的 API 与引擎编排逻辑**,不是生产服务。要上生产还差: + +- **真实 LLM 压测**:长 workflow、大量并发、中断/resume 的真实场景验证(mock 覆盖不到模型行为)。 +- **核心 adapter 的 v1 延期项**:`budgetTotal` 注入、skip/retry UI、worktree 隔离、StructuredOutput 完整接入(本 example 用 prompt+JSON 解析,比核心真实路径弱)。 +- **错误恢复**:journal resume 只在 mock 验证过;真实中途崩溃的重放正确性未压测。 + +引擎核心逻辑(并发 / 预算 / journal / schema)有 99.65% 覆盖的 mock 测试兜底,可作为基础继续建。 diff --git a/packages/workflow-engine/examples/research-report/research-report.workflow.mjs b/packages/workflow-engine/examples/research-report/research-report.workflow.mjs new file mode 100644 index 000000000..b14473466 --- /dev/null +++ b/packages/workflow-engine/examples/research-report/research-report.workflow.mjs @@ -0,0 +1,124 @@ +// research-report.workflow.mjs +// 技术研究报告 workflow。 +// 由 run.ts 通过 @claude-code-best/workflow-engine 的 runWorkflow() 直接执行—— +// 不经 Workflow 工具、不经核心 runAgent。脚本内的 agent / parallel / pipeline / +// phase / log / args 均为引擎运行时注入的全局(见 src/engine/script.ts 的沙箱)。 +// +// 编排:多角度并行调研(parallel 屏障)→ 逐条深挖(pipeline 无屏障)→ 综合成报告。 + +export const meta = { + name: 'research-report', + description: + 'Multi-angle tech research → deep-read → synthesize into a Markdown report', + whenToUse: '调研一个技术主题:从多个角度并行研究、逐条深挖、综合成结构化报告', + phases: [ + { title: 'Research', detail: '多角度并行调研(parallel 屏障)' }, + { title: 'DeepRead', detail: '逐条深挖(pipeline 无屏障)' }, + { title: 'Synthesize', detail: '综合成 Markdown 报告' }, + ], +} + +// agent(schema) 让子 agent 返回「校验对象」而非纯文本。 +const ANGLE_SCHEMA = { + type: 'object', + required: ['angle', 'findings'], + properties: { + angle: { type: 'string', description: '本次调研的角度名' }, + findings: { + type: 'array', + items: { + type: 'object', + required: ['claim', 'evidence'], + properties: { + claim: { type: 'string', description: '一句话结论' }, + evidence: { type: 'string', description: '依据/来源/理由' }, + }, + }, + }, + }, +} + +const DEEP_SCHEMA = { + type: 'object', + required: ['claim', 'analysis', 'confidence'], + properties: { + claim: { type: 'string' }, + analysis: { type: 'string', description: '机理/前提/边界/反例' }, + confidence: { type: 'string', enum: ['high', 'medium', 'low'] }, + }, +} + +// ---- 输入(由 run.ts 通过 args 透传)---- +const topic = args.topic +if (typeof topic !== 'string' || topic.length === 0) { + throw new Error('research-report 需要 args.topic(研究主题字符串)') +} +const angles = + Array.isArray(args.angles) && args.angles.length > 0 + ? args.angles + : ['核心概念与原理', '主流方案与对比', '工程实践与权衡', '生态与趋势'] + +// ---- Phase 1:多角度并行调研。parallel = 屏障,等所有角度完成后才继续。---- +phase('Research') +log(`主题「${topic}」:${angles.length} 个角度并行调研中`) +const researched = await parallel( + angles.map( + a => () => + agent( + `你是资深技术研究分析师。针对技术主题「${topic}」,从「${a}」角度调研,给出该角度下 2-4 条最关键的技术发现,每条须附依据。`, + { label: `research:${a}`, phase: 'Research', schema: ANGLE_SCHEMA }, + ), + ), +) +// parallel 返回 (object|null)[]:skipped/dead 的角度为 null,过滤后展平 +const allFindings = researched + .filter(Boolean) + .flatMap(r => r.findings.map(f => ({ ...f, angle: r.angle }))) +log(`收集到 ${allFindings.length} 条发现,进入深挖`) + +if (allFindings.length === 0) { + return { + topic, + report: '(所有角度调研均失败,无可用发现)', + anglesCovered: 0, + findingsDeepened: 0, + } +} + +// ---- Phase 2:逐条深挖。pipeline = 无屏障,每条发现独立跑完所有 stage,互不等待。---- +phase('DeepRead') +const deepened = await pipeline( + allFindings, + f => + agent( + `针对以下技术发现,深入分析其机理、成立前提、适用边界与可能的反例:\n结论:${f.claim}\n依据:${f.evidence}\n角度:${f.angle}`, + { label: `deep:${f.angle}`, phase: 'DeepRead', schema: DEEP_SCHEMA }, + ), + // 第二个 stage:按置信度标注交叉价值(演示多 stage pipeline 链式传递)。 + // stage-1 若 dead 返回 null,这里显式守卫——避免对 null 取属性(否则被 pipeline + // 的 per-item catch 吞掉、整条静默丢失)。 + d => + d + ? { + ...d, + crossCutting: + d.confidence === 'high' ? '可作为报告主干' : '需谨慎引用或佐证', + } + : null, +) +const deepFindings = deepened.filter(Boolean) +log(`深挖完成 ${deepFindings.length}/${allFindings.length} 条`) + +// ---- Phase 3:综合成 Markdown 报告(无 schema → 返回纯文本)---- +phase('Synthesize') +const report = await agent( + `你是首席技术分析师。基于以下经深挖的技术发现,综合一份结构化研究报告(纯 Markdown 叙述)。\n要求:含摘要、分角度分析、关键结论、落地建议与风险;用自然语言陈述每条发现并标注 confidence。\n禁止:在报告中粘贴 JSON 代码块或原样引用下方输入数据。\n\n主题:${topic}\n\n深度发现(JSON,仅供你理解,不要原样输出):\n${JSON.stringify(deepFindings)}`, + { label: 'synthesize', phase: 'Synthesize', maxTokens: 8192 }, +) + +return { + topic, + report, + anglesCovered: angles.length, + findingsDeepened: deepFindings.length, +} diff --git a/packages/workflow-engine/examples/research-report/run.ts b/packages/workflow-engine/examples/research-report/run.ts new file mode 100644 index 000000000..1f5c8a881 --- /dev/null +++ b/packages/workflow-engine/examples/research-report/run.ts @@ -0,0 +1,313 @@ +/** + * research-report runner —— 直接用 @claude-code-best/workflow-engine 运行 workflow, + * 完全绕开 Workflow 工具与核心 runAgent。agent() 后端直连 Anthropic SDK + * (@anthropic-ai/sdk):子 agent = 一次 messages.create。 + * + * 用法: + * ANTHROPIC_API_KEY=sk-... \ + * bun run packages/workflow-engine/examples/research-report/run.ts "Edge Computing" + * + * 可选环境变量: + * ANTHROPIC_MODEL 模型名,默认 claude-sonnet-4-5 + * RESEARCH_RUNS_DIR journal 目录,默认 ~/.claude/workflow-runs(resume 复用) + */ +import Anthropic from '@anthropic-ai/sdk' +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + createFileJournalStore, + createHostHandle, + runWorkflow, + Semaphore, + validateAgainstSchema, + type AgentRunParams, + type AgentRunResult, + type ProgressEvent, + type WorkflowPorts, +} from '@claude-code-best/workflow-engine' + +const SCRIPT_FILE = `${import.meta.dir}/research-report.workflow.mjs` +const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5' +const MAX_TOKENS = 4096 + +// 终端着色(无第三方依赖) +const paint = { + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, + cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, + green: (s: string) => `\x1b[32m${s}\x1b[0m`, + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, + red: (s: string) => `\x1b[31m${s}\x1b[0m`, + bold: (s: string) => `\x1b[1m${s}\x1b[0m`, +} + +// client 由 main() 构造,llmAgent 闭包引用。null 守卫使 import 时不触发真实调用。 +const clientRef: { client: Anthropic | null } = { client: null } + +// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3)。 +// 用 WORKFLOW_API_CONCURRENCY 调整。 +const apiSem = new Semaphore( + Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3), +) + +/** 429/5xx/连接错误指数退避重试(500ms → 1s → 2s → 4s),最多 4 次。 */ +async function withRetry(fn: () => Promise, retries = 4): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await fn() + } catch (e) { + if (!isRetryable(e) || attempt >= retries) throw e + const wait = Math.min(500 * 2 ** attempt, 8000) + await new Promise(r => { + setTimeout(r, wait) + }) + } + } +} + +function isRetryable(e: unknown): boolean { + const err = e as { status?: number; name?: string } + if (err.status === 429) return true + if (typeof err.status === 'number' && err.status >= 500) return true + if (typeof err.name === 'string' && /Connection|Timeout/i.test(err.name)) { + return true + } + return false +} + +/** 精简错误摘要(避免打印整个含 request body 的 message)。 */ +function errSummary(e: unknown): string { + const err = e as { + status?: number + error?: { type?: string } + message?: string + } + if (err.status) return `HTTP ${err.status} ${err.error?.type ?? ''}`.trim() + return (err.message ?? 'unknown').slice(0, 120) +} + +/** + * 真实 LLM agentRunner:一次 messages.create(经 API 并发信号量 + 重试)。 + * schema 模式:prompt 追加 JSON 指令 → 取文本 → 提取 JSON → Ajv 校验 → 失败返回 dead。 + * 非 schema:返回纯文本。 + */ +async function llmAgent(params: AgentRunParams): Promise { + const client = clientRef.client + if (client === null) return { kind: 'dead' } + + const schemaInstruction = params.schema + ? '\n\n你必须以一个【单独的 JSON 对象】作为整段回答(不要 Markdown 代码围栏、不要任何解释),该对象须匹配如下 JSON Schema:\n' + + JSON.stringify(params.schema) + : '' + + const release = await apiSem.acquire() + try { + const resp = await withRetry(() => + client.messages.create({ + model: params.model ?? DEFAULT_MODEL, + max_tokens: params.maxTokens ?? MAX_TOKENS, + messages: [ + { role: 'user', content: params.prompt + schemaInstruction }, + ], + }), + ) + const outputTokens = resp.usage.output_tokens + const truncated = resp.stop_reason === 'max_tokens' + + if (params.schema) { + // 截断的 JSON 几乎必然不完整 → 直接判 dead(而非让解析模糊失败) + if (truncated) return { kind: 'dead' } + const text = resp.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') + .trim() + const parsed = extractJsonObject(text) + if (parsed === null) return { kind: 'dead' } + const { valid } = validateAgainstSchema(parsed, params.schema) + if (!valid) return { kind: 'dead' } + return { kind: 'ok', output: parsed as object, usage: { outputTokens } } + } + const text = resp.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') + .trim() + if (truncated) { + console.error( + paint.yellow(` ⚠ 输出被 max_tokens 截断(${outputTokens} tokens)`), + ) + } + return { kind: 'ok', output: text, usage: { outputTokens } } + } catch (e) { + console.error(paint.red(` ✗ ${errSummary(e)}`)) + return { kind: 'dead' } + } finally { + release() + } +} + +/** + * 容错 JSON 提取:去代码围栏 → 从首个 { 起做括号深度匹配(跳过字符串字面量与 + * 转义,仿 src/engine/script.ts 的 extractMeta),取配对的 {…} → JSON.parse。 + * 比 lastIndexOf('}') 稳健:正确处理 JSON 后散文里含 }、第二个对象、字符串内 }。 + */ +function extractJsonObject(text: string): unknown | null { + const stripped = text.replace(/```(?:json)?/gi, '').trim() + const start = stripped.indexOf('{') + if (start < 0) { + try { + return JSON.parse(stripped) + } catch { + return null + } + } + let depth = 0 + let inStr: string | null = null + for (let i = start; i < stripped.length; i++) { + const ch = stripped[i] + if (inStr) { + if (ch === '\\') i++ + else if (ch === inStr) inStr = null + continue + } + if (ch === '"' || ch === "'" || ch === '`') inStr = ch + else if (ch === '{') depth++ + else if (ch === '}') { + depth-- + if (depth === 0) { + try { + return JSON.parse(stripped.slice(start, i + 1)) + } catch { + return null + } + } + } + } + return null +} + +/** 内存版 taskRegistrar:不经核心 LocalWorkflowTask,仅维护 runId → AbortController。 */ +function makeTaskRegistrar(): WorkflowPorts['taskRegistrar'] { + const controllers = new Map() + return { + register(opts) { + const ac = new AbortController() + const runId = opts.runId ?? `research-${controllers.size + 1}` + controllers.set(runId, ac) + return { runId, signal: ac.signal } + }, + complete() {}, + fail() {}, + kill(runId) { + controllers.get(runId)?.abort() + }, + pendingAction() { + return null + }, + } +} + +/** 进度事件 → 终端实时打印。 */ +function printProgress(e: ProgressEvent): void { + switch (e.type) { + case 'run_started': + console.log(paint.bold(paint.cyan(`\n▶ ${e.workflowName}`))) + break + case 'phase_started': + console.log(paint.cyan(`\n━ phase: ${e.phase}`)) + break + case 'phase_done': + break + case 'agent_started': + console.log(` ${paint.dim('→')} ${e.label ?? 'agent'}`) + break + case 'agent_done': { + const tag = + e.result.kind === 'ok' + ? paint.green('✓') + : e.result.kind === 'skipped' + ? paint.yellow('⊘') + : paint.red('✗') + console.log( + ` ${tag} ${e.label ?? 'agent'} ${paint.dim(`[${e.result.kind}]`)}`, + ) + break + } + case 'log': + console.log(` ${paint.dim('·')} ${e.message}`) + break + case 'run_done': + console.log(paint.bold(`\n■ ${e.status}`)) + break + } +} + +/** 组装端口:agent 后端直连 SDK,其余为自包含实现,不触达核心层。 */ +function makePorts(runsDir: string): WorkflowPorts { + return { + agentRunner: { runAgentToResult: llmAgent }, + progressEmitter: { emit: printProgress }, + taskRegistrar: makeTaskRegistrar(), + journalStore: createFileJournalStore(runsDir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: process.cwd(), + budgetTotal: null, + }), + } +} + +async function main(): Promise { + const topic = process.argv[2] + if (!topic) { + console.error(paint.red('✗ 用法:run.ts <研究主题>')) + console.error(paint.dim(' 例:bun run run.ts "Edge Computing"')) + process.exit(1) + } + + clientRef.client = new Anthropic({ logLevel: 'off' }) + const runsDir = + process.env.RESEARCH_RUNS_DIR ?? join(homedir(), '.claude', 'workflow-runs') + const script = await readFile(SCRIPT_FILE, 'utf-8') + + const result = await runWorkflow({ + script, + args: { topic }, + runId: `research-${Date.now()}`, + ports: makePorts(runsDir), + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: process.cwd(), + budgetTotal: null, + }) + + if (result.status !== 'completed') { + console.error( + paint.red(`✗ workflow ${result.status}:${result.error ?? ''}`), + ) + process.exit(1) + } + const ret = result.returnValue as { + report?: string + topic?: string + anglesCovered?: number + findingsDeepened?: number + } + console.log( + paint.bold( + paint.green(`\n════════ 技术研究报告:${ret.topic ?? topic} ════════`), + ), + ) + console.log( + paint.dim( + `角度数=${ret.anglesCovered ?? '?'} 深挖=${ret.findingsDeepened ?? '?'}`, + ), + ) + console.log(ret.report ?? '(无报告输出)') +} + +// 仅作为脚本直接运行时启动(import 不触发,便于冒烟/复用端口工厂) +if (import.meta.main) { + await main() +} diff --git a/packages/workflow-engine/examples/smoke.ts b/packages/workflow-engine/examples/smoke.ts new file mode 100644 index 000000000..ef4bd09d9 --- /dev/null +++ b/packages/workflow-engine/examples/smoke.ts @@ -0,0 +1,251 @@ +/** + * 冒烟端到端入口 —— 真实 SDK + 引擎,最小验证端到端通路。 + * 3 次模型调用(2 角度并行 schema + 1 综合),秒级完成、低成本。 + * 覆盖:runWorkflow、parallel(屏障)、agent(schema) 结构化、agent 文本、进度事件。 + * + * 用法: + * ANTHROPIC_API_KEY=sk-... \ + * bun run packages/workflow-engine/examples/smoke.ts + * + * 可选:ANTHROPIC_MODEL(默认 claude-sonnet-4-5) + */ +import Anthropic from '@anthropic-ai/sdk' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + createFileJournalStore, + createHostHandle, + runWorkflow, + Semaphore, + validateAgainstSchema, + type AgentRunParams, + type AgentRunResult, + type ProgressEvent, + type WorkflowPorts, +} from '@claude-code-best/workflow-engine' + +const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5' +const clientRef: { client: Anthropic | null } = { client: null } + +const POINT_SCHEMA = { + type: 'object', + required: ['point'], + properties: { point: { type: 'string' } }, +} + +// 最小 workflow:2 角度并行(schema 结构化)→ 综合(文本)。脚本内用 + 拼接避免 ${}。 +const SMOKE_SCRIPT = + ` +export const meta = { name: 'smoke', description: 'minimal end-to-end smoke' } +phase('Smoke') +const angles = ['一句话定义', '一个最核心价值'] +const points = await parallel( + angles.map(a => () => + agent('用简短一句话(30 字内)说明 workflow 编排的「' + a + '」。', { + label: 'p:' + a, + schema: ` + + JSON.stringify(POINT_SCHEMA) + + `, + }), + ), +) +const clean = points.filter(Boolean) +const joined = clean.map(p => p.point).join(';') +const summary = await agent('把以下要点综合成一句中文结论。要点:' + joined, { + label: 'summary', +}) +return { points: clean, summary } +` + +// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3)。 +const apiSem = new Semaphore( + Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3), +) + +/** 429/5xx/连接错误指数退避重试,最多 4 次。 */ +async function withRetry(fn: () => Promise, retries = 4): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await fn() + } catch (e) { + if (!isRetryable(e) || attempt >= retries) throw e + const wait = Math.min(500 * 2 ** attempt, 8000) + await new Promise(r => { + setTimeout(r, wait) + }) + } + } +} + +function isRetryable(e: unknown): boolean { + const err = e as { status?: number; name?: string } + if (err.status === 429) return true + if (typeof err.status === 'number' && err.status >= 500) return true + if (typeof err.name === 'string' && /Connection|Timeout/i.test(err.name)) { + return true + } + return false +} + +function errSummary(e: unknown): string { + const err = e as { + status?: number + error?: { type?: string } + message?: string + } + if (err.status) return `HTTP ${err.status} ${err.error?.type ?? ''}`.trim() + return (err.message ?? 'unknown').slice(0, 120) +} + +async function llmAgent(params: AgentRunParams): Promise { + const client = clientRef.client + if (client === null) return { kind: 'dead' } + const schemaInstruction = params.schema + ? '\n\n以单独 JSON 对象回答(无围栏无解释),匹配 schema:\n' + + JSON.stringify(params.schema) + : '' + const release = await apiSem.acquire() + try { + const resp = await withRetry(() => + client.messages.create({ + model: params.model ?? DEFAULT_MODEL, + max_tokens: params.maxTokens ?? 1024, + messages: [ + { role: 'user', content: params.prompt + schemaInstruction }, + ], + }), + ) + const outputTokens = resp.usage.output_tokens + if (resp.stop_reason === 'max_tokens') return { kind: 'dead' } + const text = resp.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') + .trim() + if (params.schema) { + const parsed = extractJsonObject(text) + if (parsed === null) return { kind: 'dead' } + if (!validateAgainstSchema(parsed, params.schema).valid) { + return { kind: 'dead' } + } + return { kind: 'ok', output: parsed as object, usage: { outputTokens } } + } + return { kind: 'ok', output: text, usage: { outputTokens } } + } catch (e) { + console.error(` ✗ ${errSummary(e)}`) + return { kind: 'dead' } + } finally { + release() + } +} + +function extractJsonObject(text: string): unknown | null { + const stripped = text.replace(/```(?:json)?/gi, '').trim() + const start = stripped.indexOf('{') + if (start < 0) { + try { + return JSON.parse(stripped) + } catch { + return null + } + } + let depth = 0 + let inStr: string | null = null + for (let i = start; i < stripped.length; i++) { + const ch = stripped[i] + if (inStr) { + if (ch === '\\') i++ + else if (ch === inStr) inStr = null + continue + } + if (ch === '"' || ch === "'" || ch === '`') inStr = ch + else if (ch === '{') depth++ + else if (ch === '}') { + depth-- + if (depth === 0) { + try { + return JSON.parse(stripped.slice(start, i + 1)) + } catch { + return null + } + } + } + } + return null +} + +function makePorts(runsDir: string): WorkflowPorts { + return { + agentRunner: { runAgentToResult: llmAgent }, + progressEmitter: { + emit: (e: ProgressEvent) => { + if (e.type === 'phase_started') console.log(`\n━ phase: ${e.phase}`) + else if (e.type === 'agent_started') + console.log(` → ${e.label ?? 'agent'}`) + else if (e.type === 'agent_done') + console.log( + ` ${e.result.kind === 'ok' ? '✓' : '✗'} ${e.label ?? ''} [${e.result.kind}]`, + ) + else if (e.type === 'log') console.log(` · ${e.message}`) + }, + }, + taskRegistrar: { + register: () => ({ + runId: 'smoke', + signal: new AbortController().signal, + }), + complete() {}, + fail() {}, + kill() {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(runsDir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: process.cwd(), + budgetTotal: null, + }), + } +} + +async function main(): Promise { + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + console.error('✗ 缺少 ANTHROPIC_API_KEY 环境变量') + process.exit(1) + } + clientRef.client = new Anthropic({ apiKey, logLevel: 'off' }) + const runsDir = + process.env.RESEARCH_RUNS_DIR ?? join(homedir(), '.claude', 'workflow-runs') + + const result = await runWorkflow({ + script: SMOKE_SCRIPT, + args: {}, + runId: `smoke-${Date.now()}`, + ports: makePorts(runsDir), + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: process.cwd(), + budgetTotal: null, + }) + + if (result.status !== 'completed') { + console.error(`\n✗ FAIL:${result.status} ${result.error ?? ''}`) + process.exit(1) + } + const ret = result.returnValue as { + points: Array<{ point: string }> + summary: string + } + console.log('\n━━━━━━━━ 冒烟结果 ━━━━━━━━') + for (const p of ret.points) console.log(`• ${p.point}`) + console.log(`\n综合:${ret.summary}`) + console.log( + `\n✓ PASS:端到端通路正常(${ret.points.length} 要点 + 综合,3 次模型调用)`, + ) +} + +if (import.meta.main) { + await main() +} diff --git a/packages/workflow-engine/package.json b/packages/workflow-engine/package.json new file mode 100644 index 000000000..a13f05448 --- /dev/null +++ b/packages/workflow-engine/package.json @@ -0,0 +1,19 @@ +{ + "name": "@claude-code-best/workflow-engine", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "dependencies": { + "ajv": "^8.18.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@anthropic-ai/sdk": "^0.81.0" + } +} diff --git a/packages/workflow-engine/src/__tests__/WorkflowTool.test.ts b/packages/workflow-engine/src/__tests__/WorkflowTool.test.ts new file mode 100644 index 000000000..5891355f6 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/WorkflowTool.test.ts @@ -0,0 +1,490 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createWorkflowTool } from '../tool/WorkflowTool.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js' + +function mockPorts( + runsDir: string, + results: Map, +): { + ports: WorkflowPorts + events: ProgressEvent[] + runStatus: Map +} { + const events: ProgressEvent[] = [] + const runStatus = new Map() + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async (p: AgentRunParams) => + results.get(p.prompt) ?? { kind: 'dead' }, + }, + progressEmitter: { emit: e => void events.push(e) }, + taskRegistrar: { + register: () => ({ + runId: 'run-x', + signal: new AbortController().signal, + }), + complete: id => void runStatus.set(id, 'completed'), + fail: id => void runStatus.set(id, 'failed'), + kill: id => void runStatus.set(id, 'killed'), + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: runsDir, + budgetTotal: null, + }), + } + return { ports, events, runStatus } +} + +test('call 返回 launch 消息并在后台完成', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const { ports, runStatus } = mockPorts( + dir, + new Map([ + ['compute', { kind: 'ok', output: '42', usage: { outputTokens: 1 } }], + ]), + ) + const tool = createWorkflowTool(ports) + const res = await tool.call( + { script: `return agent('compute')` }, + undefined, + undefined, + undefined, + ) + expect(res.data.output).toContain('run_id: run-x') + await new Promise(r => { + setTimeout(r, 50) + }) + expect(runStatus.get('run-x')).toBe('completed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('缺少 script/name/scriptPath → 返回错误(不进后台)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const { ports, runStatus } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + const res = await tool.call({}, undefined, undefined, undefined) + expect(res.data.output).toMatch(/^Error:/) + expect(runStatus.size).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('脚本语法错 → 返回校验错误(不进后台)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const { ports, runStatus } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + const res = await tool.call( + { script: `return ((` }, + undefined, + undefined, + undefined, + ) + expect(res.data.output).toMatch(/校验失败|Error/) + expect(runStatus.size).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('name 解析到 .claude/workflows/.ts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) + await writeFile( + join(dir, '.claude', 'workflows', 'release.ts'), + `return agent('compute')`, + ) + const { ports, runStatus } = mockPorts( + dir, + new Map([ + ['compute', { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }], + ]), + ) + const tool = createWorkflowTool(ports) + const res = await tool.call( + { name: 'release' }, + undefined, + undefined, + undefined, + ) + expect(res.data.output).toContain('run_id') + await new Promise(r => { + setTimeout(r, 50) + }) + expect(runStatus.get('run-x')).toBe('completed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('renderToolUseMessage / mapToolResultToToolResultBlockParam', () => { + const dir = '/tmp' + const { ports } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + expect(tool.renderToolUseMessage({ name: 'release' })).toBe( + 'Workflow: release', + ) + const block = tool.mapToolResultToToolResultBlockParam( + { output: 'hi' }, + 'tu-1', + ) + expect(block.tool_use_id).toBe('tu-1') + expect(block.type).toBe('tool_result') + expect(block.content[0]!.text).toBe('hi') +}) + +test('scriptPath 解析到文件内容并后台执行', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const scriptFile = join(dir, 'external.ts') + await writeFile(scriptFile, `return agent('compute')`) + const { ports, runStatus } = mockPorts( + dir, + new Map([ + ['compute', { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }], + ]), + ) + const tool = createWorkflowTool(ports) + const res = await tool.call( + { scriptPath: scriptFile }, + undefined, + undefined, + undefined, + ) + expect(res.data.output).toContain('run_id') + expect(res.data.output).toContain('external.ts') + await new Promise(r => { + setTimeout(r, 50) + }) + expect(runStatus.get('run-x')).toBe('completed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('脚本运行时失败 → onFinish 路由到 fail', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const { ports, runStatus } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + await tool.call( + { script: `throw new Error('boom')` }, + undefined, + undefined, + undefined, + ) + await new Promise(r => { + setTimeout(r, 50) + }) + expect(runStatus.get('run-x')).toBe('failed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('元数据方法:description/prompt/renderToolUseMessage', async () => { + const { ports } = mockPorts('/tmp', new Map()) + const tool = createWorkflowTool(ports) + expect(tool.isEnabled()).toBe(true) + expect(tool.isReadOnly({})).toBe(false) + expect(await tool.description()).toBeTruthy() + expect(await tool.prompt()).toContain('Workflow') + expect(tool.renderToolUseMessage({})).toBe('Workflow: unknown') + expect(tool.renderToolUseMessage({ resumeFromRunId: 'r1' })).toBe( + 'Workflow resume: r1', + ) +}) + +test('name 不存在 → 返回错误(不进后台)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) + const { ports, runStatus } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + const res = await tool.call( + { name: 'nope' }, + undefined, + undefined, + undefined, + ) + expect(res.data.output).toMatch(/^Error:/) + expect(runStatus.size).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('workflow 被 abort → onFinish 路由 kill', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const runStatus = new Map() + const ac = new AbortController() + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async () => ({ + kind: 'ok', + output: 'x', + usage: { outputTokens: 1 }, + }), + }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ runId: 'run-x', signal: ac.signal }), + complete: id => void runStatus.set(id, 'completed'), + fail: id => void runStatus.set(id, 'failed'), + kill: id => void runStatus.set(id, 'killed'), + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: dir, + budgetTotal: null, + }), + } + ac.abort() + const tool = createWorkflowTool(ports) + await tool.call( + { script: `return agent('x')` }, + undefined, + undefined, + undefined, + ) + await new Promise(r => { + setTimeout(r, 50) + }) + expect(runStatus.get('run-x')).toBe('killed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('args 为 JSON 字符串化的对象时防御性 parse(向后兼容旧 z.string() 契约)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const capturedPrompts: unknown[] = [] + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async (p: AgentRunParams) => { + capturedPrompts.push(p.prompt) + return { kind: 'ok', output: 'done', usage: { outputTokens: 1 } } + }, + }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ + runId: 'run-x', + signal: new AbortController().signal, + }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: dir, + budgetTotal: null, + }), + } + const tool = createWorkflowTool(ports) + await tool.call( + { + script: `return agent(args.commit)`, + // 模拟旧契约下模型发送的字符串化 JSON + args: '{"commit":"abc123"}', + }, + undefined, + undefined, + undefined, + ) + await new Promise(r => { + setTimeout(r, 50) + }) + // 若 args 未归一化:args.commit === undefined(string 上无 commit 属性) + // 若 args 归一化:args.commit === 'abc123' + expect(capturedPrompts).toContain('abc123') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('args 为非合法 JSON 字符串时保持原值不抛', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const capturedPrompts: unknown[] = [] + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async (p: AgentRunParams) => { + capturedPrompts.push(p.prompt) + return { kind: 'ok', output: 'ok', usage: { outputTokens: 1 } } + }, + }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ + runId: 'run-x', + signal: new AbortController().signal, + }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: dir, + budgetTotal: null, + }), + } + const tool = createWorkflowTool(ports) + await tool.call( + { + // 脚本把 args 当字符串用:agent(args) → agent('hello') + script: `return agent(args)`, + args: 'hello', + }, + undefined, + undefined, + undefined, + ) + await new Promise(r => { + setTimeout(r, 50) + }) + // 'hello' 不是合法 JSON,应保持为字符串 + expect(capturedPrompts).toContain('hello') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('scriptPath 越界(resolve 后在 cwd 之外)→ 拒绝并报错(防任意文件读)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const subDir = join(dir, 'sub') + await mkdir(subDir, { recursive: true }) + // 在 subDir 之外(dir 内)放置一个脚本 + const outsideScript = join(dir, 'outside.ts') + await writeFile(outsideScript, `return agent('x')`) + // host.cwd = subDir,scriptPath 是 subDir 外的绝对路径 + const { ports, runStatus } = mockPorts(subDir, new Map()) + const tool = createWorkflowTool(ports) + const res = await tool.call( + { scriptPath: outsideScript }, + undefined, + undefined, + undefined, + ) + expect(res.data.output).toMatch(/^Error:/) + expect(res.data.output).toMatch(/越界|外|outside|contain/i) + expect(runStatus.size).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('name 含 ".." 路径段 → 拒绝(防路径遍历逃出 workflowDir)', async () => { + const outer = await mkdtemp(join(tmpdir(), 'wf-outer-')) + try { + // 在 outer 根下放置 evil.ts(在 .claude/workflows 之外) + await writeFile(join(outer, 'evil.ts'), `return agent('x')`) + await mkdir(join(outer, '.claude', 'workflows'), { recursive: true }) + const { ports, runStatus } = mockPorts(outer, new Map()) + const tool = createWorkflowTool(ports) + // name = '../../evil' → join 后逃离 workflows 目录到 outer/evil.ts + const res = await tool.call( + { name: '../../evil' }, + undefined, + undefined, + undefined, + ) + expect(res.data.output).toMatch(/^Error:/) + expect(runStatus.size).toBe(0) + } finally { + await rm(outer, { recursive: true, force: true }) + } +}) + +test('name 含路径分隔符或为绝对路径 → 拒绝', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) + const { ports } = mockPorts(dir, new Map()) + const tool = createWorkflowTool(ports) + for (const badName of ['foo/bar', '/etc/passwd', '..', '.']) { + const res = await tool.call( + { name: badName }, + undefined, + undefined, + undefined, + ) + expect(res.data.output).toMatch(/^Error:/) + } + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('returnValue 为对象 → complete(formatValue 走 JSON 分支)', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-tool-')) + try { + const { ports, runStatus } = mockPorts( + dir, + new Map([['x', { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }]]), + ) + const tool = createWorkflowTool(ports) + await tool.call( + { + script: `await agent('x')\nreturn { ok: true, n: 1 }`, + }, + undefined, + undefined, + undefined, + ) + await new Promise(r => { + setTimeout(r, 50) + }) + expect(runStatus.get('run-x')).toBe('completed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/workflow-engine/src/__tests__/agentAdapter.test.ts b/packages/workflow-engine/src/__tests__/agentAdapter.test.ts new file mode 100644 index 000000000..f868b55b4 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/agentAdapter.test.ts @@ -0,0 +1,155 @@ +import { expect, test } from 'bun:test' +import { + AgentAdapterRegistry, + AdapterNotFoundError, + type AgentAdapter, +} from '../agentAdapter.js' +import { createHostHandle } from '../ports.js' +import type { AgentRunParams, AgentRunResult } from '../types.js' + +function makeAdapter( + id: string, + result: AgentRunResult = { + kind: 'ok', + output: `out-${id}`, + usage: { outputTokens: 1 }, + }, +): AgentAdapter { + return { + id, + capabilities: { structuredOutput: true }, + async run() { + return result + }, + } +} + +const P = (over: Partial = {}): AgentRunParams => ({ + prompt: 'p', + ...over, +}) + +const CTX = { + host: createHostHandle(null), + signal: new AbortController().signal, + runId: 'r', +} + +test('resolve 默认走 default adapter,run 返回结果', async () => { + const reg = new AgentAdapterRegistry() + .register(makeAdapter('a')) + .register(makeAdapter('b')) + .default('a') + expect(reg.resolve(P()).id).toBe('a') + const r = await reg.resolve(P()).run(P(), CTX) + expect(r.kind).toBe('ok') +}) + +test('route agentType 命中优先于 default', () => { + const reg = new AgentAdapterRegistry() + .register(makeAdapter('default')) + .register(makeAdapter('research')) + .route({ kind: 'agentType', agentType: 'researcher', adapter: 'research' }) + .default('default') + expect(reg.resolve(P({ agentType: 'researcher' })).id).toBe('research') + expect(reg.resolve(P({ agentType: 'other' })).id).toBe('default') +}) + +test('route model 前缀匹配', () => { + const reg = new AgentAdapterRegistry() + .register(makeAdapter('cheap')) + .register(makeAdapter('strong')) + .route({ kind: 'model', pattern: 'claude-opus', adapter: 'strong' }) + .default('cheap') + expect(reg.resolve(P({ model: 'claude-opus-4' })).id).toBe('strong') + expect(reg.resolve(P({ model: 'claude-sonnet-4' })).id).toBe('cheap') + expect(reg.resolve(P()).id).toBe('cheap') // 无 model → default +}) + +test('route custom 谓词', () => { + const reg = new AgentAdapterRegistry() + .register(makeAdapter('main')) + .register(makeAdapter('special')) + .route({ + kind: 'custom', + match: p => p.prompt.includes('VIP'), + adapter: 'special', + }) + .default('main') + expect(reg.resolve(P({ prompt: 'handle VIP case' })).id).toBe('special') + expect(reg.resolve(P({ prompt: 'normal' })).id).toBe('main') +}) + +test('规则按顺序匹配(先命中先用)', () => { + const reg = new AgentAdapterRegistry() + .register(makeAdapter('a')) + .register(makeAdapter('b')) + .route({ kind: 'agentType', agentType: 'x', adapter: 'a' }) + .route({ kind: 'agentType', agentType: 'x', adapter: 'b' }) + expect(reg.resolve(P({ agentType: 'x' })).id).toBe('a') +}) + +test('规则命中的 adapter 未注册 → 跳过该规则继续匹配', () => { + const reg = new AgentAdapterRegistry() + .register(makeAdapter('real')) + .route({ kind: 'agentType', agentType: 'x', adapter: 'ghost' }) + .route({ kind: 'agentType', agentType: 'x', adapter: 'real' }) + expect(reg.resolve(P({ agentType: 'x' })).id).toBe('real') +}) + +test('无匹配且无 default → AdapterNotFoundError', () => { + const reg = new AgentAdapterRegistry().register(makeAdapter('a')) + expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError) +}) + +test('default 指向未注册的 adapter → 仍抛(不静默回退)', () => { + const reg = new AgentAdapterRegistry() + .register(makeAdapter('a')) + .default('missing') + expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError) +}) + +test('has / get', () => { + const reg = new AgentAdapterRegistry().register(makeAdapter('a')) + expect(reg.has('a')).toBe(true) + expect(reg.has('b')).toBe(false) + expect(reg.get('a')?.id).toBe('a') + expect(reg.get('b')).toBeUndefined() +}) + +test('initializeAll / disposeAll 触发 lifecycle(跳过未实现)', async () => { + const events: string[] = [] + const withLifecycle: AgentAdapter = { + id: 'a', + capabilities: { structuredOutput: false }, + async run() { + return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } } + }, + async initialize() { + events.push('init-a') + }, + async dispose() { + events.push('dispose-a') + }, + } + const noLifecycle = makeAdapter('b') // 无 initialize/dispose + const reg = new AgentAdapterRegistry() + .register(withLifecycle) + .register(noLifecycle) + await reg.initializeAll() + await reg.disposeAll() + expect(events).toEqual(['init-a', 'dispose-a']) +}) + +test('capabilities 声明可读', () => { + const adapter: AgentAdapter = { + id: 'a', + capabilities: { structuredOutput: true, tools: true, stream: false }, + async run() { + return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } } + }, + } + expect(adapter.capabilities.structuredOutput).toBe(true) + expect(adapter.capabilities.tools).toBe(true) + expect(adapter.capabilities.stream).toBe(false) +}) diff --git a/packages/workflow-engine/src/__tests__/agentId.test.ts b/packages/workflow-engine/src/__tests__/agentId.test.ts new file mode 100644 index 000000000..b2bae9e73 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/agentId.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from 'bun:test' +import { createEngineContext } from '../engine/context.js' +import { makeHooks } from '../engine/hooks.js' +import { createBufferingEmitter } from '../progress/events.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import type { AgentRunParams, AgentRunResult } from '../types.js' + +function build(results: Map) { + const { emitter, events } = createBufferingEmitter() + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async (p: AgentRunParams) => + results.get(p.prompt) ?? { kind: 'dead' }, + }, + progressEmitter: emitter, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + signal: new AbortController().signal, + cwd: '/tmp', + budgetTotal: null, + }), + } + const ctx = createEngineContext({ + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + runId: 'r', + workflowName: 'w', + cwd: '/tmp', + budgetTotal: null, + }) + return { ctx, events, hooks: makeHooks(ctx, async () => null) } +} + +test('并发 agent 各自拿到唯一 agentId,started/done 配对', async () => { + const ok = (out: string): AgentRunResult => ({ + kind: 'ok', + output: out, + usage: { outputTokens: 1 }, + }) + const { ctx, events, hooks } = build( + new Map([ + ['a', ok('1')], + ['b', ok('2')], + ]), + ) + await hooks.parallel([() => hooks.agent('a'), () => hooks.agent('b')]) + const started = events.filter(e => e.type === 'agent_started') + const done = events.filter(e => e.type === 'agent_done') + expect(started).toHaveLength(2) + expect(done).toHaveLength(2) + const ids = started.map(e => (e as { agentId: number }).agentId) + expect(new Set(ids).size).toBe(2) + for (const d of done as Array<{ agentId: number }>) { + expect(ids).toContain(d.agentId) + } + expect(ctx.resources.agentIdSeq.value).toBe(2) +}) + +test('agentId 单调递增', async () => { + const ok = (out: string): AgentRunResult => ({ + kind: 'ok', + output: out, + usage: { outputTokens: 1 }, + }) + const { events, hooks } = build( + new Map([ + ['a', ok('1')], + ['b', ok('2')], + ['c', ok('3')], + ]), + ) + await hooks.agent('a') + await hooks.agent('b') + await hooks.agent('c') + const ids = events + .filter(e => e.type === 'agent_started') + .map(e => (e as { agentId: number }).agentId) + expect(ids).toEqual([0, 1, 2]) +}) diff --git a/packages/workflow-engine/src/__tests__/budget.test.ts b/packages/workflow-engine/src/__tests__/budget.test.ts new file mode 100644 index 000000000..d84bdbacb --- /dev/null +++ b/packages/workflow-engine/src/__tests__/budget.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'bun:test' +import { Budget, BudgetExhaustedError } from '../engine/budget.js' + +test('total=null 时无限制', () => { + const b = new Budget(null) + expect(b.total).toBeNull() + expect(b.remaining()).toBe(Infinity) + b.addOutputTokens(999999) + expect(b.spent()).toBe(999999) + expect(() => b.assertCanSpend()).not.toThrow() +}) + +test('累加并触顶抛错', () => { + const b = new Budget(100) + expect(b.remaining()).toBe(100) + b.addOutputTokens(40) + expect(b.spent()).toBe(40) + expect(b.remaining()).toBe(60) + expect(() => b.assertCanSpend()).not.toThrow() + b.addOutputTokens(60) + expect(b.spent()).toBe(100) + expect(() => b.assertCanSpend()).toThrow(BudgetExhaustedError) +}) + +test('addOutputTokens 负值忽略', () => { + const b = new Budget(100) + b.addOutputTokens(-50) + expect(b.spent()).toBe(0) +}) diff --git a/packages/workflow-engine/src/__tests__/concurrency.test.ts b/packages/workflow-engine/src/__tests__/concurrency.test.ts new file mode 100644 index 000000000..0f89494d7 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/concurrency.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from 'bun:test' +import { Semaphore, maxConcurrency } from '../engine/concurrency.js' + +test('Semaphore 限制并发,permit 转移不泄漏', async () => { + const sem = new Semaphore(2) + let active = 0 + let peak = 0 + const task = async (): Promise => { + const release = await sem.acquire() + active++ + peak = Math.max(peak, active) + await new Promise(r => { + setTimeout(r, 10) + }) + active-- + release() + } + await Promise.all(Array.from({ length: 6 }, () => task())) + expect(peak).toBe(2) // 永不超过 permits +}) + +test('maxConcurrency 落在 [1, 16]', () => { + const n = maxConcurrency() + expect(n).toBeGreaterThanOrEqual(1) + expect(n).toBeLessThanOrEqual(16) +}) + +test('Semaphore(0) 至少 1 permit,acquire 不阻塞', async () => { + const sem = new Semaphore(0) + const release = await sem.acquire() + expect(release).toBeTypeOf('function') + release() +}) + +test('Semaphore 唤醒按 FIFO 顺序', async () => { + const sem = new Semaphore(1) + const order: string[] = [] + const first = await sem.acquire() + const p1 = sem.acquire().then(r => { + order.push('p1') + return r + }) + const p2 = sem.acquire().then(r => { + order.push('p2') + return r + }) + await new Promise(r => { + setTimeout(r, 5) + }) + expect(order).toEqual([]) + first() + await new Promise(r => { + setTimeout(r, 5) + }) + expect(order).toEqual(['p1']) + ;(await p1)() + await new Promise(r => { + setTimeout(r, 5) + }) + expect(order).toEqual(['p1', 'p2']) + ;(await p2)() +}) + +test('Semaphore.acquire 传 aborted signal → 立即 reject,不消耗 permit', async () => { + // 修复 L:queued waiter 在 abort 时必须立即 reject 而非等 permit。 + // 否则一个被取消的 agent 阻塞在 acquire(),permit 被消耗(transfer 给已死的 waiter), + // 实际并发能力降低;最坏情况下所有 waiter 都被取消,semaphore 还在排队等死掉的 waiter。 + const sem = new Semaphore(1) + const ac = new AbortController() + + // 占用唯一 permit + const first = await sem.acquire() + + // 排队的 waiter + const queued = sem.acquire(ac.signal) + await new Promise(r => { + setTimeout(r, 5) + }) + + // abort → waiter 应立即 reject + ac.abort() + await expect(queued).rejects.toThrow() + + // permit 无泄漏:释放 first 后,新 acquire 应能立即拿到(无 stale waiter 抢占) + first() + const third = await sem.acquire() + expect(third).toBeTypeOf('function') + third() +}) + +test('Semaphore.acquire 传已 aborted 的 signal → 同步 reject', async () => { + const sem = new Semaphore(1) + const ac = new AbortController() + ac.abort() + // 信号已 aborted,即使有 permit 也不应 acquire(语义:调用者已取消) + // 注意:当前实现先看 available,可能直接返回。本测试 lock "先 check abort"。 + // 若实现选择"permit 可用时优先发放",则此测试改为:acquire 成功,调用者后续检查 abort。 + // 当前实现选择前者:aborted signal 立即抛错,避免已死 agent 拿 permit。 + await expect(sem.acquire(ac.signal)).rejects.toThrow() +}) diff --git a/packages/workflow-engine/src/__tests__/context.test.ts b/packages/workflow-engine/src/__tests__/context.test.ts new file mode 100644 index 000000000..9390325f6 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/context.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from 'bun:test' +import { createBufferingEmitter } from '../progress/events.js' +import { + createEngineContext, + createSharedResources, +} from '../engine/context.js' +import { WorkflowError } from '../engine/errors.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' + +function mockPorts(): WorkflowPorts { + return { + agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: '/tmp', + budgetTotal: null, + }), + } +} + +test('createSharedResources 初始化预算与计数', () => { + const r = createSharedResources(100) + expect(r.budget.total).toBe(100) + expect(r.agentCountBox.value).toBe(0) + expect(r.depth).toBe(0) +}) + +test('createEngineContext 复制 journal 并重置游标', () => { + const journal = [ + { + key: 'k', + seq: 0, + result: { kind: 'ok' as const, output: 'x', usage: { outputTokens: 1 } }, + }, + ] + const ctx = createEngineContext({ + ports: mockPorts(), + host: createHostHandle(null), + signal: new AbortController().signal, + runId: 'r1', + workflowName: 'w', + cwd: '/tmp', + budgetTotal: null, + journal, + }) + expect(ctx.journal).toHaveLength(1) + expect(ctx.journalIndex).toBe(0) + expect(ctx.journalInvalidated).toBe(false) +}) + +test('createBufferingEmitter 收集事件', () => { + const { emitter, events } = createBufferingEmitter() + emitter.emit({ type: 'log', runId: 'r', message: 'hi' }) + expect(events).toHaveLength(1) +}) + +test('WorkflowError 可识别', () => { + const e = new WorkflowError('boom') + expect(e).toBeInstanceOf(Error) + expect(e.message).toBe('boom') +}) diff --git a/packages/workflow-engine/src/__tests__/errors.test.ts b/packages/workflow-engine/src/__tests__/errors.test.ts new file mode 100644 index 000000000..496ce6831 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/errors.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'bun:test' +import { WorkflowError, WorkflowAbortedError } from '../engine/errors.js' + +test('WorkflowError 携带消息与 name', () => { + const e = new WorkflowError('脚本错误') + expect(e).toBeInstanceOf(Error) + expect(e.message).toBe('脚本错误') + expect(e.name).toBe('WorkflowError') +}) + +test('WorkflowAbortedError 是可识别的取消错误', () => { + const e = new WorkflowAbortedError() + expect(e).toBeInstanceOf(Error) + expect(e.name).toBe('WorkflowAbortedError') + expect(e.message).toBeTruthy() +}) + +test('两类错误可被 instanceof 区分(互不混淆)', () => { + const a = new WorkflowError('x') + const b = new WorkflowAbortedError() + expect(a).toBeInstanceOf(WorkflowError) + expect(a).not.toBeInstanceOf(WorkflowAbortedError) + expect(b).toBeInstanceOf(WorkflowAbortedError) + expect(b).not.toBeInstanceOf(WorkflowError) +}) + +test('可作为普通 Error 在 catch 中捕获', () => { + const throwIt = (): never => { + throw new WorkflowAbortedError() + } + let caught: unknown = null + try { + throwIt() + } catch (e) { + caught = e + } + expect(caught).toBeInstanceOf(Error) + expect(caught).toBeInstanceOf(WorkflowAbortedError) +}) diff --git a/packages/workflow-engine/src/__tests__/events.test.ts b/packages/workflow-engine/src/__tests__/events.test.ts new file mode 100644 index 000000000..b184ae04b --- /dev/null +++ b/packages/workflow-engine/src/__tests__/events.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from 'bun:test' +import { + createBufferingEmitter, + createProgressEmitter, +} from '../progress/events.js' +import type { ProgressEvent } from '../types.js' + +const log = (message: string): ProgressEvent => + ({ type: 'log', runId: 'r', message }) as ProgressEvent +const phase = (p: string): ProgressEvent => + ({ type: 'phase_started', runId: 'r', phase: p }) as ProgressEvent + +test('createBufferingEmitter 按序收集所有事件', () => { + const { emitter, events } = createBufferingEmitter() + emitter.emit(log('a')) + emitter.emit(phase('P')) + expect(events).toHaveLength(2) + expect(events[0]).toEqual(log('a')) + expect(events[1]).toEqual(phase('P')) +}) + +test('createBufferingEmitter emit 返回 void(无返回值)', () => { + const { emitter } = createBufferingEmitter() + expect(emitter.emit(log('x'))).toBeUndefined() +}) + +test('createBufferingEmitter 各自独立(不共享缓冲)', () => { + const a = createBufferingEmitter() + const b = createBufferingEmitter() + a.emitter.emit(log('1')) + expect(a.events).toHaveLength(1) + expect(b.events).toHaveLength(0) +}) + +test('createProgressEmitter 转发事件到回调(按序、不缓冲)', () => { + const received: ProgressEvent[] = [] + const emitter = createProgressEmitter(e => void received.push(e)) + emitter.emit(log('a')) + emitter.emit(log('b')) + expect(received).toEqual([log('a'), log('b')]) +}) + +test('createProgressEmitter 回调同步触发', () => { + let seen = '' + const emitter = createProgressEmitter(e => { + seen = (e as { message: string }).message + }) + emitter.emit(log('sync')) + // emit 返回前回调已执行 + expect(seen).toBe('sync') +}) diff --git a/packages/workflow-engine/src/__tests__/hooks.test.ts b/packages/workflow-engine/src/__tests__/hooks.test.ts new file mode 100644 index 000000000..cef17ab0b --- /dev/null +++ b/packages/workflow-engine/src/__tests__/hooks.test.ts @@ -0,0 +1,426 @@ +import { expect, test } from 'bun:test' +import { AgentAdapterRegistry } from '../agentAdapter.js' +import { createEngineContext } from '../engine/context.js' +import { maxConcurrency, Semaphore } from '../engine/concurrency.js' +import { agentCallKey } from '../engine/journal.js' +import { makeHooks, type SubWorkflowRunner } from '../engine/hooks.js' +import { WorkflowError, WorkflowAbortedError } from '../engine/errors.js' +import { createBufferingEmitter } from '../progress/events.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import type { + AgentRunParams, + AgentRunResult, + JournalEntry, + ProgressEvent, +} from '../types.js' + +type CtxOverrides = Partial<{ + agentResults: Map + runner: (params: AgentRunParams) => Promise + pending: { kind: 'skip' | 'retry' } | null + journal: JournalEntry[] + budgetTotal: number | null + signal: AbortSignal + truncated: string[] + agentAdapterRegistry: AgentAdapterRegistry + loggerWarn: (msg: string) => void +}> + +function buildCtx(overrides: CtxOverrides = {}): { + ctx: ReturnType + events: ProgressEvent[] + hooks: ReturnType +} { + const { emitter, events } = createBufferingEmitter() + const results = overrides.agentResults ?? new Map() + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: overrides.runner + ? overrides.runner + : async (params: AgentRunParams) => + results.get(params.prompt) ?? { kind: 'dead' }, + }, + ...(overrides.agentAdapterRegistry + ? { agentAdapterRegistry: overrides.agentAdapterRegistry } + : {}), + progressEmitter: emitter, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => overrides.pending ?? null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async (id: string) => { + overrides.truncated?.push(id) + }, + }, + permissionGate: { isAborted: () => false }, + logger: { + debug: () => {}, + event: () => {}, + ...(overrides.loggerWarn ? { warn: overrides.loggerWarn } : {}), + }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: '/tmp', + budgetTotal: null, + }), + } + const ctx = createEngineContext({ + ports, + host: createHostHandle(null), + signal: overrides.signal ?? new AbortController().signal, + runId: 'r1', + workflowName: 'w', + cwd: '/tmp', + budgetTotal: overrides.budgetTotal ?? null, + journal: overrides.journal, + }) + const noopSub: SubWorkflowRunner = async () => null + return { ctx, events, hooks: makeHooks(ctx, noopSub) } +} + +test('agent 返回文本结果并计数', async () => { + const { ctx, hooks } = buildCtx({ + agentResults: new Map([ + ['hi', { kind: 'ok', output: 'hello', usage: { outputTokens: 5 } }], + ]), + }) + const out = await hooks.agent('hi') + expect(out).toBe('hello') + expect(ctx.resources.agentCountBox.value).toBe(1) +}) + +test('agent skipped → null 且不计数', async () => { + const { hooks } = buildCtx({ + agentResults: new Map([['hi', { kind: 'skipped' }]]), + }) + expect(await hooks.agent('hi')).toBeNull() +}) + +test('agent dead → null', async () => { + const { hooks } = buildCtx({ + agentResults: new Map([['hi', { kind: 'dead' }]]), + }) + expect(await hooks.agent('hi')).toBeNull() +}) + +test('agent journal 命中时不调用 runner', async () => { + let called = 0 + const { emitter } = createBufferingEmitter() + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async () => { + called++ + return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } } + }, + }, + progressEmitter: emitter, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: '/tmp', + budgetTotal: null, + }), + } + const key = agentCallKey('hi', { prompt: 'hi' }) + const ctx = createEngineContext({ + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + runId: 'r1', + workflowName: 'w', + cwd: '/tmp', + budgetTotal: null, + journal: [ + { + key, + seq: 0, + result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } }, + }, + ], + }) + const hooks = makeHooks(ctx, async () => null) + expect(await hooks.agent('hi')).toBe('cached') + expect(called).toBe(0) +}) + +test('agent 超过总数上限抛错', async () => { + const { hooks, ctx } = buildCtx() + ctx.resources.agentCountBox.value = 1000 + await expect(hooks.agent('hi')).rejects.toThrow(WorkflowError) +}) + +test('parallel 单项抛错 → null,其余保留', async () => { + const { hooks } = buildCtx() + const out = await hooks.parallel([ + async () => 'a', + async () => { + throw new Error('x') + }, + async () => 'c', + ]) + expect(out).toEqual(['a', null, 'c']) +}) + +test('parallel 单项抛错 → logger.warn 记录失败原因', async () => { + const warns: string[] = [] + const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) }) + await hooks.parallel([ + async () => 'a', + async () => { + throw new Error('boom-x') + }, + async () => 'c', + ]) + expect(warns.length).toBe(1) + expect(warns[0]).toMatch(/boom-x/) +}) + +test('pipeline 逐 stage 链式,stage 抛错 → null', async () => { + const { hooks } = buildCtx() + const out = await hooks.pipeline( + [1, 2], + n => Promise.resolve((n as number) + 1), + m => Promise.resolve((m as number) * 10), + ) + expect(out).toEqual([20, 30]) + const out2 = await hooks.pipeline( + [1], + () => Promise.reject(new Error('boom')), + m => Promise.resolve(m), + ) + expect(out2).toEqual([null]) +}) + +test('pipeline stage 抛错 → logger.warn 记录失败原因', async () => { + const warns: string[] = [] + const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) }) + await hooks.pipeline( + [1], + () => Promise.reject(new Error('stage-boom')), + m => Promise.resolve(m), + ) + expect(warns.length).toBe(1) + expect(warns[0]).toMatch(/stage-boom/) +}) + +test('pipeline 超 4096 抛错', async () => { + const { hooks } = buildCtx() + await expect( + hooks.pipeline(Array(4097), () => Promise.resolve(1)), + ).rejects.toThrow(WorkflowError) +}) + +test('phase 切换发射 phase_started/done;log 发射 log', () => { + const { hooks, events } = buildCtx() + hooks.phase('A') + hooks.log('hello') + hooks.phase('B') + expect(events.some(e => e.type === 'phase_started' && e.phase === 'A')).toBe( + true, + ) + expect(events.some(e => e.type === 'phase_done' && e.phase === 'A')).toBe( + true, + ) + expect(events.some(e => e.type === 'log' && e.message === 'hello')).toBe(true) + expect(events.some(e => e.type === 'phase_started' && e.phase === 'B')).toBe( + true, + ) +}) + +// ---- 边界与错误路径 ---- + +test('agent dead 也计入 agentCountBox', async () => { + const { hooks, ctx } = buildCtx({ + agentResults: new Map([['x', { kind: 'dead' }]]), + }) + await hooks.agent('x') + expect(ctx.resources.agentCountBox.value).toBe(1) +}) + +test('agent pendingAction=skip → null、不调 runner、不计数', async () => { + let called = 0 + const { hooks, ctx } = buildCtx({ + pending: { kind: 'skip' }, + runner: async () => { + called++ + return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } } + }, + }) + expect(await hooks.agent('x')).toBeNull() + expect(called).toBe(0) + expect(ctx.resources.agentCountBox.value).toBe(0) +}) + +test('agent journal key 发散 → invalidate 并 truncate', async () => { + const truncated: string[] = [] + const { hooks, ctx } = buildCtx({ + runner: async () => ({ + kind: 'ok', + output: 'live', + usage: { outputTokens: 1 }, + }), + journal: [ + { + key: 'stale-key', + seq: 0, + result: { kind: 'ok', output: 'old', usage: { outputTokens: 1 } }, + }, + ], + truncated, + }) + const out = await hooks.agent('different-prompt') + expect(out).toBe('live') + expect(truncated).toContain('r1') + expect(ctx.journalInvalidated).toBe(true) +}) + +test('agent 预算耗尽时抛错', async () => { + const { hooks, ctx } = buildCtx({ + budgetTotal: 10, + runner: async () => ({ + kind: 'ok', + output: 'x', + usage: { outputTokens: 1 }, + }), + }) + ctx.resources.budget.addOutputTokens(10) + await expect(hooks.agent('x')).rejects.toThrow() +}) + +test('agent 预算检查在 semaphore 临界区内(queued waiter 看到最新 spent)', async () => { + // 当 semaphore capacity < parallel agent 数时,部分 agent 会排队。 + // 旧 bug:assertCanSpend 在 acquire 之前,所有 waiter 入队时 spent=0 都过检; + // 后续 permit 释放后 waiter 直接跑 runner、扣预算,不再 re-check → 全部超支。 + // 修复:assertCanSpend 移入临界区,waiter 被唤醒后先看 spent 再决定是否跑。 + // 强制 capacity=1(serializing semaphore)确保 N>1 个 agent 必须排队。 + const { hooks, ctx } = buildCtx({ + budgetTotal: 10, + runner: async () => { + // 让 runner 慢一点,确保 waiter 真的排队 + await new Promise(r => { + setTimeout(r, 5) + }) + return { + kind: 'ok', + output: 'x', + usage: { outputTokens: 6 }, // 每次 6 token,2 次即超 10 + } + }, + }) + // 用单 permit semaphore 替换默认的,强制序列化 + ctx.resources.semaphore = new Semaphore(1) + const results = await hooks.parallel([ + () => hooks.agent('a'), + () => hooks.agent('b'), + () => hooks.agent('c'), + () => hooks.agent('d'), + ]) + // 至少 1 个 agent 被 parallel catch 成 null(assertCanSpend 抛错) + expect(results.some(r => r === null)).toBe(true) + // 不应 4 个全跑扣 24;上限是 at-most-one-over(前两个扣 12,后两个被拦) + expect(ctx.resources.budget.spent()).toBeLessThanOrEqual(12) +}) + +test('agent signal aborted → WorkflowAbortedError', async () => { + const ac = new AbortController() + ac.abort() + const { hooks } = buildCtx({ + signal: ac.signal, + runner: async () => ({ + kind: 'ok', + output: 'x', + usage: { outputTokens: 1 }, + }), + }) + await expect(hooks.agent('x')).rejects.toThrow(WorkflowAbortedError) +}) + +test('parallel 超过 4096 项抛错', async () => { + const { hooks } = buildCtx() + await expect( + hooks.parallel(Array.from({ length: 4097 }, () => async () => 1)), + ).rejects.toThrow(WorkflowError) +}) + +test('workflow() 嵌套超过一层抛错', async () => { + const { hooks, ctx } = buildCtx() + ctx.resources.depth = 1 + await expect(hooks.workflow('child')).rejects.toThrow(WorkflowError) +}) + +test('agent 并发受 semaphore 限制(不超 maxConcurrency)', async () => { + let active = 0 + let peak = 0 + const { hooks } = buildCtx({ + runner: async () => { + active++ + peak = Math.max(peak, active) + await new Promise(r => { + setTimeout(r, 5) + }) + active-- + return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } } + }, + }) + await hooks.parallel(Array.from({ length: 32 }, () => () => hooks.agent('p'))) + expect(peak).toBeLessThanOrEqual(maxConcurrency()) +}) + +test('agentAdapterRegistry 优先于 agentRunner(按路由分发到 adapter)', async () => { + const called: string[] = [] + const registry = new AgentAdapterRegistry() + .register({ + id: 'ad', + capabilities: { structuredOutput: true }, + async run() { + called.push('adapter') + return { + kind: 'ok', + output: 'from-adapter', + usage: { outputTokens: 1 }, + } + }, + }) + .default('ad') + const { hooks } = buildCtx({ + agentAdapterRegistry: registry, + runner: async () => { + called.push('runner') + return { kind: 'ok', output: 'from-runner', usage: { outputTokens: 1 } } + }, + }) + expect(await hooks.agent('x')).toBe('from-adapter') + expect(called).toEqual(['adapter']) +}) + +test('agentAdapterRegistry resolve 抛错 → agent 上抛(workflow failed)', async () => { + const registry = new AgentAdapterRegistry().default('missing') // 未注册 + const { hooks } = buildCtx({ + agentAdapterRegistry: registry, + runner: async () => ({ + kind: 'ok', + output: 'x', + usage: { outputTokens: 1 }, + }), + }) + await expect(hooks.agent('x')).rejects.toThrow() +}) diff --git a/packages/workflow-engine/src/__tests__/index.test.ts b/packages/workflow-engine/src/__tests__/index.test.ts new file mode 100644 index 000000000..dfa07c608 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/index.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from 'bun:test' +import * as wf from '../index.js' + +test('引擎核心 API 完整导出', () => { + expect(typeof wf.runWorkflow).toBe('function') + expect(typeof wf.parseScript).toBe('function') + expect(typeof wf.extractMeta).toBe('function') + expect(typeof wf.makeHooks).toBe('function') + expect(typeof wf.createEngineContext).toBe('function') + expect(typeof wf.createSharedResources).toBe('function') +}) + +test('端口 / host API 完整导出', () => { + expect(typeof wf.createHostHandle).toBe('function') + expect(typeof wf.isHostHandle).toBe('function') + expect(typeof wf.unwrapHostHandle).toBe('function') +}) + +test('持久化 / 结构化 / 命名 workflow / 进度 API 完整导出', () => { + expect(typeof wf.createFileJournalStore).toBe('function') + expect(typeof wf.agentCallKey).toBe('function') + expect(typeof wf.validateAgainstSchema).toBe('function') + expect(typeof wf.resolveNamedWorkflow).toBe('function') + expect(typeof wf.listNamedWorkflows).toBe('function') + expect(typeof wf.createBufferingEmitter).toBe('function') + expect(typeof wf.createProgressEmitter).toBe('function') +}) + +test('并发 / 预算 / 错误类完整导出', () => { + expect(typeof wf.Semaphore).toBe('function') + expect(typeof wf.maxConcurrency).toBe('function') + expect(typeof wf.Budget).toBe('function') + expect(typeof wf.BudgetExhaustedError).toBe('function') + expect(typeof wf.WorkflowError).toBe('function') + expect(typeof wf.WorkflowAbortedError).toBe('function') + expect(typeof wf.ScriptError).toBe('function') +}) + +test('工具描述符与输入 schema 导出', () => { + expect(typeof wf.createWorkflowTool).toBe('function') + expect(typeof wf.workflowInputSchema).toBe('object') + expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow') +}) + +test('引擎常量值稳定', () => { + expect(wf.WORKFLOW_DIR_NAME).toBe('.claude/workflows') + expect(wf.WORKFLOW_RUNS_DIR).toBe('.claude/workflow-runs') + expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow') + expect(wf.MAX_TOTAL_AGENTS).toBe(1000) + expect(wf.MAX_ITEMS_PER_CALL).toBe(4096) + expect(wf.MAX_CONCURRENCY_CAP).toBe(16) + expect(wf.MAX_CONCURRENCY_OFFSET).toBe(2) + expect(wf.WORKFLOW_SCRIPT_EXTENSIONS).toEqual(['.ts', '.js', '.mjs']) +}) + +test('createWorkflowTool 返回完整描述符形状', () => { + const tool = wf.createWorkflowTool({ + agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete() {}, + fail() {}, + kill() {}, + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: wf.createHostHandle(null), + cwd: '/tmp', + budgetTotal: null, + }), + }) + expect(tool.name).toBe('Workflow') + expect(tool.isEnabled()).toBe(true) + expect(tool.isReadOnly({})).toBe(false) + expect(typeof tool.call).toBe('function') + expect(typeof tool.description).toBe('function') + expect(typeof tool.prompt).toBe('function') + expect(typeof tool.renderToolUseMessage).toBe('function') + expect(typeof tool.mapToolResultToToolResultBlockParam).toBe('function') +}) diff --git a/packages/workflow-engine/src/__tests__/integration.test.ts b/packages/workflow-engine/src/__tests__/integration.test.ts new file mode 100644 index 000000000..6d87fce1c --- /dev/null +++ b/packages/workflow-engine/src/__tests__/integration.test.ts @@ -0,0 +1,282 @@ +/** + * 集成测试:用忠实 mock adapter 跑「规范 workflow 脚本」(来自 Workflow 工具定义的 + * canonical 模式:pipeline 无屏障 + parallel 屏障 + agent(schema) + phase)。 + * 验证引擎与真实 workflow 脚本语义兼容。 + */ +import { expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runWorkflow } from '../engine/runWorkflow.js' +import { createFileJournalStore } from '../engine/journal.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import { createBufferingEmitter } from '../progress/events.js' +import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js' + +function canonicalPorts(runsDir: string): { + ports: WorkflowPorts + events: ProgressEvent[] + agentCalls: AgentRunParams[] +} { + const { emitter, events } = createBufferingEmitter() + const agentCalls: AgentRunParams[] = [] + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async ( + params: AgentRunParams, + ): Promise => { + agentCalls.push(params) + const p = params.prompt + if (p.startsWith('review-')) { + return { + kind: 'ok', + output: { findings: [{ title: `${p}-finding`, file: 'a.ts' }] }, + usage: { outputTokens: 5 }, + } + } + if (p.startsWith('verify')) { + return { + kind: 'ok', + output: { isReal: true }, + usage: { outputTokens: 2 }, + } + } + return { kind: 'dead' } + }, + }, + progressEmitter: emitter, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(runsDir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: runsDir, + budgetTotal: null, + }), + } + return { ports, events, agentCalls } +} + +// 规范 review 模式(pipeline→parallel→verify→synthesize),逐字采用 Workflow 工具定义的写法。 +const CANONICAL_REVIEW_SCRIPT = ` +export const meta = { + name: 'review-changes', + description: 'Review changed files across dimensions, verify each finding', + phases: [{ title: 'Review' }, { title: 'Verify' }], +} +const DIMENSIONS = [ + { key: 'bugs', prompt: 'review-bugs' }, + { key: 'perf', prompt: 'review-perf' }, +] +const FINDINGS_SCHEMA = { type: 'object' } +const VERDICT_SCHEMA = { type: 'object' } + +phase('Review') +const results = await pipeline( + DIMENSIONS, + d => agent(d.prompt, { label: 'review:' + d.key, phase: 'Review', schema: FINDINGS_SCHEMA }), + review => parallel( + review.findings.map(f => () => + agent('verify: ' + f.title, { label: 'verify:' + f.file, phase: 'Verify', schema: VERDICT_SCHEMA }) + .then(v => ({ ...f, verdict: v })) + ) + ) +) +const all = results.flat().filter(Boolean) +const confirmed = all.filter(f => f.verdict && f.verdict.isReal) +return { confirmed, total: all.length } +` + +test('canonical review 脚本端到端兼容', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-int-')) + try { + const { ports, events, agentCalls } = canonicalPorts(dir) + const result = await runWorkflow({ + script: CANONICAL_REVIEW_SCRIPT, + runId: 'int-1', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + + expect(result.status).toBe('completed') + const ret = result.returnValue as { confirmed: unknown[]; total: number } + // 2 维度 × 1 finding,全部 isReal=true → confirmed=2, total=2 + expect(ret.total).toBe(2) + expect(ret.confirmed).toHaveLength(2) + // 2 个 review agent + 2 个 verify agent = 4 + expect(agentCalls).toHaveLength(4) + expect(agentCalls.filter(c => c.prompt.startsWith('review-'))).toHaveLength( + 2, + ) + expect(agentCalls.filter(c => c.prompt.startsWith('verify'))).toHaveLength( + 2, + ) + // 进度事件:run_started/done + phase Review/Verify + agent started/done + expect( + events.some( + e => e.type === 'run_started' && e.workflowName === 'review-changes', + ), + ).toBe(true) + expect( + events.some(e => e.type === 'run_done' && e.status === 'completed'), + ).toBe(true) + // 脚本显式调用一次 phase('Review');verify agent 的 phase:'Verify' 是展示标签,不发 phase_started + expect( + events.filter(e => e.type === 'phase_started' && e.phase === 'Review'), + ).toHaveLength(1) + expect(events.filter(e => e.type === 'agent_started')).toHaveLength(4) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('loop-until-dry 模式:连续两轮无新发现即收敛', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-int-')) + try { + let round = 0 + const { emitter, events } = createBufferingEmitter() + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async ( + p: AgentRunParams, + ): Promise => { + round++ + // 第 1-2 轮返回发现,第 3 轮起返回空 → 收敛 + const found = round <= 2 ? [{ b: round }] : [] + return { + kind: 'ok', + output: { bugs: found }, + usage: { outputTokens: 1 }, + } + }, + }, + progressEmitter: emitter, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(dir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: dir, + budgetTotal: null, + }), + } + const script = ` + const seen = [] + const confirmed = [] + let dry = 0 + while (dry < 2) { + const found = (await agent('find bugs')).bugs + const fresh = found.filter(b => !seen.includes(b.b)) + if (fresh.length === 0) { dry++; continue } + dry = 0 + for (const b of fresh) seen.push(b.b) + confirmed.push(...fresh) + } + return { confirmed } + ` + const result = await runWorkflow({ + script, + runId: 'int-2', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect(result.status).toBe('completed') + const ret = result.returnValue as { confirmed: { b: number }[] } + // 第1轮发现{b:1},第2轮发现{b:2}(fresh,因 seen=[1]),第3轮 found{b:3}? + // mock 按 round 计数:round1→{b:1}, round2→{b:2}, round3→[](found空) + // 但 round2 found=[{b:2}], seen=[1], fresh=[{b:2}] → confirmed=[{b:1},{b:2}], dry=0 + // round3 found=[] → fresh=[] → dry=1; round4 found=[] → dry=2 → 退出 + expect(ret.confirmed).toHaveLength(2) + expect( + events.some(e => e.type === 'run_done' && e.status === 'completed'), + ).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('resume 兼容:二次运行 journal 命中,agent 不重跑', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-int-')) + try { + let calls = 0 + const makePorts = (): WorkflowPorts => ({ + agentRunner: { + runAgentToResult: async () => { + calls++ + return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } } + }, + }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(dir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: dir, + budgetTotal: null, + }), + }) + const script = ` + phase('A') + const a = await agent('do-a') + const b = await agent('do-b') + return { a, b } + ` + // 第一次运行:2 个 agent 现场跑 + const first = await runWorkflow({ + script, + runId: 'int-3', + ports: makePorts(), + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect(first.status).toBe('completed') + expect(calls).toBe(2) + + // resume 同 runId:journal 命中,不重跑 + calls = 0 + const resumed = await runWorkflow({ + script, + runId: 'int-3', + ports: makePorts(), + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + resume: true, + }) + expect(resumed.status).toBe('completed') + expect(calls).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/workflow-engine/src/__tests__/journal.test.ts b/packages/workflow-engine/src/__tests__/journal.test.ts new file mode 100644 index 000000000..dc821eb98 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/journal.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { agentCallKey, createFileJournalStore } from '../engine/journal.js' +import type { AgentRunParams } from '../types.js' + +const base: AgentRunParams = { prompt: 'do something' } + +test('agentCallKey 对相同 prompt+params 稳定', () => { + expect(agentCallKey('p', base)).toBe(agentCallKey('p', base)) +}) + +test('agentCallKey 随 prompt 变化', () => { + expect(agentCallKey('p1', base)).not.toBe(agentCallKey('p2', base)) +}) + +test('agentCallKey 忽略纯展示字段 label/phase', () => { + const a = agentCallKey('p', { ...base, label: 'A', phase: 'ph1' }) + const b = agentCallKey('p', { ...base, label: 'B', phase: 'ph2' }) + expect(a).toBe(b) +}) + +test('FileJournalStore append → read 保序,truncate 清空', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-journal-')) + try { + const store = createFileJournalStore(dir) + const e1 = { + key: 'k1', + seq: 0, + result: { kind: 'ok' as const, output: 'x', usage: { outputTokens: 1 } }, + } + const e2 = { key: 'k2', seq: 1, result: { kind: 'dead' as const } } + await store.append('run-1', e1) + await store.append('run-1', e2) + const got = await store.read('run-1') + expect(got).toHaveLength(2) + expect(got[0]!.key).toBe('k1') + expect(got[1]!.result.kind).toBe('dead') + await store.truncate('run-1') + expect(await store.read('run-1')).toEqual([]) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('FileJournalStore read 按 seq 排序——parallel 完成顺序≠调用顺序时 resume 稳定', async () => { + // 并发完成顺序不确定:append 落盘 = completion 顺序;resume 时按调用顺序 + // 匹配 key。无 seq 排序 → 不同 run 的 key 顺序不同 → 几乎所有 key mismatch → + // 全重跑,journal 失效。修复:read() 按 seq 升序整理后再返回。 + const dir = await mkdtemp(join(tmpdir(), 'wf-journal-sort-')) + try { + const store = createFileJournalStore(dir) + await store.append('r1', { + key: 'late', + seq: 2, + result: { kind: 'ok', output: 'late', usage: { outputTokens: 1 } }, + }) + await store.append('r1', { + key: 'first', + seq: 0, + result: { kind: 'ok', output: 'first', usage: { outputTokens: 1 } }, + }) + await store.append('r1', { + key: 'mid', + seq: 1, + result: { kind: 'ok', output: 'mid', usage: { outputTokens: 1 } }, + }) + const got = await store.read('r1') + expect(got.map(e => e.key)).toEqual(['first', 'mid', 'late']) + expect(got.map(e => e.seq)).toEqual([0, 1, 2]) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('agentCallKey 随 schema 变化', () => { + const k0 = agentCallKey('p', { prompt: 'p' }) + const k1 = agentCallKey('p', { prompt: 'p', schema: { type: 'object' } }) + const k2 = agentCallKey('p', { prompt: 'p', schema: { type: 'array' } }) + expect(k1).not.toBe(k0) + expect(k1).not.toBe(k2) +}) + +test('agentCallKey 随 model 变化', () => { + expect(agentCallKey('p', { prompt: 'p', model: 'sonnet' })).not.toBe( + agentCallKey('p', { prompt: 'p', model: 'opus' }), + ) +}) + +test('agentCallKey 对 params 字段顺序稳定(canonical 排序)', () => { + const a = agentCallKey('p', { + prompt: 'p', + model: 'm', + schema: { type: 'object' }, + }) + const b = agentCallKey('p', { + schema: { type: 'object' }, + prompt: 'p', + model: 'm', + }) + expect(a).toBe(b) +}) + +test('FileJournalStore read 不存在的 run → []', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-journal-')) + try { + const store = createFileJournalStore(dir) + expect(await store.read('never-existed')).toEqual([]) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/workflow-engine/src/__tests__/namedWorkflows.test.ts b/packages/workflow-engine/src/__tests__/namedWorkflows.test.ts new file mode 100644 index 000000000..2d74f6c89 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/namedWorkflows.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + listNamedWorkflows, + resolveNamedWorkflow, +} from '../engine/namedWorkflows.js' + +test('按扩展名优先级解析命名 workflow', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) + try { + await writeFile( + join(dir, 'a.ts'), + 'export const meta = { name: "a", description: "d" }\nreturn 1', + ) + await writeFile(join(dir, 'b.js'), 'return 2') + await writeFile(join(dir, 'c.mjs'), 'return 3') + await writeFile(join(dir, 'ignore.md'), '# not a workflow') + + const a = await resolveNamedWorkflow(dir, 'a') + expect(a?.path.endsWith('a.ts')).toBe(true) + expect(a?.content).toContain('meta') + + expect(await resolveNamedWorkflow(dir, 'missing')).toBeNull() + + const names = await listNamedWorkflows(dir) + expect(names).toEqual(['a', 'b', 'c']) // 不含 .md + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('listNamedWorkflows 不存在目录返回空数组', async () => { + expect( + await listNamedWorkflows(join(tmpdir(), 'wf-nope-' + Date.now())), + ).toEqual([]) +}) + +test('resolveNamedWorkflow 在 .ts 缺失时降级到 .js/.mjs', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) + try { + await writeFile(join(dir, 'onlyjs.js'), 'return 1') + await writeFile(join(dir, 'onlymjs.mjs'), 'return 2') + expect( + (await resolveNamedWorkflow(dir, 'onlyjs'))?.path.endsWith('onlyjs.js'), + ).toBe(true) + expect( + (await resolveNamedWorkflow(dir, 'onlymjs'))?.path.endsWith( + 'onlymjs.mjs', + ), + ).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('listNamedWorkflows 返回排序后的名字', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) + try { + await writeFile(join(dir, 'zeta.ts'), 'return 1') + await writeFile(join(dir, 'alpha.js'), 'return 2') + await writeFile(join(dir, 'mid.mjs'), 'return 3') + expect(await listNamedWorkflows(dir)).toEqual(['alpha', 'mid', 'zeta']) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/workflow-engine/src/__tests__/paths.test.ts b/packages/workflow-engine/src/__tests__/paths.test.ts new file mode 100644 index 000000000..9f0d71d2e --- /dev/null +++ b/packages/workflow-engine/src/__tests__/paths.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from 'bun:test' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { containsPath, sanitizeWorkflowName } from '../engine/paths.js' + +test('containsPath: target 等于 base → true', () => { + const base = join(tmpdir(), 'a') + expect(containsPath(base, base)).toBe(true) +}) + +test('containsPath: target 在 base 内 → true', () => { + const base = join(tmpdir(), 'a') + const target = join(base, 'b', 'c.ts') + expect(containsPath(base, target)).toBe(true) +}) + +test('containsPath: target 在 base 之外(前缀假阳)→ false', () => { + // /tmp/foobar 不应被认为是 /tmp/foo 的子路径 + const base = join(tmpdir(), 'foo') + const target = join(tmpdir(), 'foobar', 'x.ts') + expect(containsPath(base, target)).toBe(false) +}) + +test('containsPath: target 用 .. 越界 → false', () => { + const base = join(tmpdir(), 'a', 'b') + const target = join(base, '..', 'outside.ts') + expect(containsPath(base, target)).toBe(false) +}) + +test('containsPath: 相对 target 相对 base 解析', () => { + const base = join(tmpdir(), 'a') + expect(containsPath(base, 'sub/file.ts')).toBe(true) + expect(containsPath(base, '../b/file.ts')).toBe(false) +}) + +test('sanitizeWorkflowName: 合法标识符 → 原值', () => { + expect(sanitizeWorkflowName('release')).toBe('release') + expect(sanitizeWorkflowName('my-workflow')).toBe('my-workflow') + expect(sanitizeWorkflowName('my_workflow_2')).toBe('my_workflow_2') +}) + +test('sanitizeWorkflowName: 含路径分隔符 → null', () => { + expect(sanitizeWorkflowName('foo/bar')).toBeNull() + expect(sanitizeWorkflowName('foo\\bar')).toBeNull() + expect(sanitizeWorkflowName('/abs/path')).toBeNull() +}) + +test('sanitizeWorkflowName: . / .. / 空 → null', () => { + expect(sanitizeWorkflowName('.')).toBeNull() + expect(sanitizeWorkflowName('..')).toBeNull() + expect(sanitizeWorkflowName('')).toBeNull() +}) + +test('sanitizeWorkflowName: 含 null 字节 → null', () => { + expect(sanitizeWorkflowName('evil\0.ts')).toBeNull() +}) diff --git a/packages/workflow-engine/src/__tests__/ports.test.ts b/packages/workflow-engine/src/__tests__/ports.test.ts new file mode 100644 index 000000000..184901d4a --- /dev/null +++ b/packages/workflow-engine/src/__tests__/ports.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'bun:test' +import { createHostHandle, isHostHandle, unwrapHostHandle } from '../ports.js' + +test('createHostHandle 包装任意 bundle 且对外不透明', () => { + const bundle = { secret: 'ctx', nested: { a: 1 } } + const handle = createHostHandle(bundle) + expect(isHostHandle(handle)).toBe(true) + // 包内不暴露 bundle —— handle 只有符号标记 + expect(Object.keys(handle)).toHaveLength(0) +}) + +test('普通对象不是 HostHandle', () => { + expect(isHostHandle({} as unknown)).toBe(false) + expect(isHostHandle(null)).toBe(false) +}) + +test('端口对象满足最小形状', () => { + // 编译期形状校验:以下赋值通过即说明端口契约自洽 + const noop = (): void => {} + const ports = { + agentRunner: { runAgentToResult: noop }, + progressEmitter: { emit: noop }, + taskRegistrar: { + register: () => ({ + runId: 'run-1', + signal: new AbortController().signal, + }), + complete: noop, + fail: noop, + kill: noop, + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { debug: noop, event: noop }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: '/tmp', + budgetTotal: null, + toolUseId: 'tu-1', + }), + } + expect(ports.taskRegistrar.register().runId).toBe('run-1') + expect(ports.hostFactory().toolUseId).toBe('tu-1') +}) + +test('unwrapHostHandle 取回原始 bundle(同引用)', () => { + const bundle = { secret: 'ctx', nested: { a: 1 } } + const handle = createHostHandle(bundle) + expect(unwrapHostHandle(handle)).toBe(bundle) +}) + +test('createHostHandle(null) 不透明且解包为 null', () => { + const handle = createHostHandle(null) + expect(isHostHandle(handle)).toBe(true) + expect(unwrapHostHandle(handle)).toBeNull() +}) diff --git a/packages/workflow-engine/src/__tests__/runWorkflow.test.ts b/packages/workflow-engine/src/__tests__/runWorkflow.test.ts new file mode 100644 index 000000000..334d649d3 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/runWorkflow.test.ts @@ -0,0 +1,423 @@ +import { expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runWorkflow } from '../engine/runWorkflow.js' +import { agentCallKey, createFileJournalStore } from '../engine/journal.js' +import { createHostHandle, type WorkflowPorts } from '../ports.js' +import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js' + +function portsWith( + runsDir: string, + results: Map, +): WorkflowPorts { + return { + agentRunner: { + runAgentToResult: async (p: AgentRunParams) => + results.get(p.prompt) ?? { kind: 'dead' }, + }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(runsDir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: runsDir, + budgetTotal: null, + }), + } +} + +function portsWithEvents( + runsDir: string, + results: Map, +): { ports: WorkflowPorts; events: ProgressEvent[] } { + const events: ProgressEvent[] = [] + return { + events, + ports: { + agentRunner: { + runAgentToResult: async (p: AgentRunParams) => + results.get(p.prompt) ?? { kind: 'dead' }, + }, + progressEmitter: { emit: e => void events.push(e) }, + taskRegistrar: { + register: () => ({ + runId: 'r', + signal: new AbortController().signal, + }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(runsDir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: runsDir, + budgetTotal: null, + }), + }, + } +} + +test('端到端:脚本返回 agent 结果,状态 completed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith( + dir, + new Map([ + ['compute', { kind: 'ok', output: '42', usage: { outputTokens: 3 } }], + ]), + ) + const result = await runWorkflow({ + script: `export const meta = { name: 't', description: 'd' }\nreturn agent('compute')`, + runId: 'run-1', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect(result.status).toBe('completed') + expect(result.returnValue).toBe('42') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('脚本语法错误 → failed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith(dir, new Map()) + const result = await runWorkflow({ + script: `export const meta = { name: 't', description: 'd' }\nreturn ((`, + runId: 'run-2', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect(result.status).toBe('failed') + expect(result.error).toBeTruthy() + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('resume:journal 命中则不调用 runner', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + let called = 0 + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async () => { + called++ + return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } } + }, + }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(dir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: dir, + budgetTotal: null, + }), + } + const key = agentCallKey('compute', { prompt: 'compute' }) + await ports.journalStore.append('run-3', { + key, + seq: 0, + result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } }, + }) + + const result = await runWorkflow({ + script: `return agent('compute')`, + runId: 'run-3', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + resume: true, + }) + expect(result.status).toBe('completed') + expect(result.returnValue).toBe('cached') + expect(called).toBe(0) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('abort → killed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith( + dir, + new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]), + ) + const ac = new AbortController() + ac.abort() + const result = await runWorkflow({ + script: `return agent('x')`, + runId: 'run-4', + ports, + host: createHostHandle(null), + signal: ac.signal, + cwd: dir, + budgetTotal: null, + }) + expect(result.status).toBe('killed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('workflow() 嵌套(一层)共享计数', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) + await writeFile( + join(dir, '.claude', 'workflows', 'child.ts'), + `return agent('child')\n// child workflow`, + ) + const ports = portsWith( + dir, + new Map([ + [ + 'child', + { kind: 'ok', output: 'child-out', usage: { outputTokens: 1 } }, + ], + ]), + ) + const result = await runWorkflow({ + script: `return workflow('child')`, + runId: 'run-5', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect(result.status).toBe('completed') + expect(result.returnValue).toBe('child-out') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +// ---- 边界与事件 ---- + +test('scriptChanged=true → truncate journal 并全量现场跑', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + let called = 0 + const ports: WorkflowPorts = { + agentRunner: { + runAgentToResult: async () => { + called++ + return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } } + }, + }, + progressEmitter: { emit: () => {} }, + taskRegistrar: { + register: () => ({ runId: 'r', signal: new AbortController().signal }), + complete: () => {}, + fail: () => {}, + kill: () => {}, + pendingAction: () => null, + }, + journalStore: createFileJournalStore(dir), + permissionGate: { isAborted: () => false }, + logger: { debug: () => {}, event: () => {} }, + hostFactory: () => ({ + handle: createHostHandle(null), + cwd: dir, + budgetTotal: null, + }), + } + const key = agentCallKey('compute', { prompt: 'compute' }) + await ports.journalStore.append('run-chg', { + key, + seq: 0, + result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } }, + }) + const result = await runWorkflow({ + script: `return agent('compute')`, + runId: 'run-chg', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + resume: true, + scriptChanged: true, + }) + expect(result.status).toBe('completed') + expect(result.returnValue).toBe('live') + expect(called).toBe(1) + // truncate 清空了旧 cached journal,现场 agent append 新 entry(live) + const final = await ports.journalStore.read('run-chg') + expect(final).toHaveLength(1) + expect((final[0]!.result as { output: string }).output).toBe('live') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('脚本运行时抛错(非语法错)→ failed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith(dir, new Map()) + const result = await runWorkflow({ + script: `throw new Error('boom at runtime')`, + runId: 'run-throw', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect(result.status).toBe('failed') + expect(result.error).toMatch(/boom/) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('发射 run_started(含 workflowName)与 run_done 事件', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const { ports, events } = portsWithEvents( + dir, + new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]), + ) + await runWorkflow({ + script: `return agent('x')`, + runId: 'run-ev', + workflowName: 'my-wf', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect( + events.some(e => e.type === 'run_started' && e.workflowName === 'my-wf'), + ).toBe(true) + expect( + events.some(e => e.type === 'run_done' && e.status === 'completed'), + ).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('未传 workflowName 时从 meta.name 推导', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const { ports, events } = portsWithEvents(dir, new Map()) + await runWorkflow({ + script: `export const meta = { name: 'from-meta', description: 'd' }\nreturn 1`, + runId: 'run-meta', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect( + events.some( + e => e.type === 'run_started' && e.workflowName === 'from-meta', + ), + ).toBe(true) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('budgetTotal 耗尽 → failed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith( + dir, + new Map([ + ['a', { kind: 'ok', output: '1', usage: { outputTokens: 5 } }], + ['b', { kind: 'ok', output: '2', usage: { outputTokens: 5 } }], + ]), + ) + const result = await runWorkflow({ + script: `await agent('a')\nreturn agent('b')`, + runId: 'run-budget', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: 5, + }) + expect(result.status).toBe('failed') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('workflow() 引用语法错的子脚本 → failed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + await mkdir(join(dir, '.claude', 'workflows'), { recursive: true }) + await writeFile(join(dir, '.claude', 'workflows', 'broken.ts'), `return ((`) + const ports = portsWith(dir, new Map()) + const result = await runWorkflow({ + script: `return workflow('broken')`, + runId: 'run-sub-err', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect(result.status).toBe('failed') + expect(result.error).toMatch(/子 workflow|脚本错误/) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('workflow() 引用不存在的 name → failed', async () => { + const dir = await mkdtemp(join(tmpdir(), 'wf-run-')) + try { + const ports = portsWith(dir, new Map()) + const result = await runWorkflow({ + script: `return workflow('ghost')`, + runId: 'run-sub-missing', + ports, + host: createHostHandle(null), + signal: new AbortController().signal, + cwd: dir, + budgetTotal: null, + }) + expect(result.status).toBe('failed') + expect(result.error).toMatch(/子 workflow|未找到/) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/workflow-engine/src/__tests__/schema.test.ts b/packages/workflow-engine/src/__tests__/schema.test.ts new file mode 100644 index 000000000..9db4a8b21 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/schema.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from 'bun:test' +import { workflowInputSchema } from '../tool/schema.js' + +test('空对象通过(所有字段 optional)', () => { + expect(workflowInputSchema.safeParse({}).success).toBe(true) +}) + +test('全部已知字段可填', () => { + const r = workflowInputSchema.safeParse({ + script: 'return 1', + name: 'release', + scriptPath: '/abs/x.ts', + args: { n: 1 }, + resumeFromRunId: 'run-1', + description: 'do thing', + title: 'T', + }) + expect(r.success).toBe(true) +}) + +test('args 接受任意 JSON 值(对象/数组/字符串/数字/布尔/null)', () => { + for (const args of [{ a: 1 }, [1, 2], 's', 42, true, null]) { + expect(workflowInputSchema.safeParse({ args }).success).toBe(true) + } +}) + +test('类型错误被拒(script/name/scriptPath 非字符串)', () => { + expect(workflowInputSchema.safeParse({ script: 123 }).success).toBe(false) + expect(workflowInputSchema.safeParse({ name: 42 }).success).toBe(false) + expect(workflowInputSchema.safeParse({ scriptPath: {} }).success).toBe(false) +}) + +test('resumeFromRunId/description/title 必须为字符串', () => { + expect(workflowInputSchema.safeParse({ resumeFromRunId: 1 }).success).toBe( + false, + ) + expect(workflowInputSchema.safeParse({ description: 1 }).success).toBe(false) + expect(workflowInputSchema.safeParse({ title: 1 }).success).toBe(false) +}) + +test('未知字段被 strip(zod 默认非 strict,safeParse 成功)', () => { + const r = workflowInputSchema.safeParse({ script: 'x', extra: 1 }) + expect(r.success).toBe(true) +}) diff --git a/packages/workflow-engine/src/__tests__/script.test.ts b/packages/workflow-engine/src/__tests__/script.test.ts new file mode 100644 index 000000000..40d851d8f --- /dev/null +++ b/packages/workflow-engine/src/__tests__/script.test.ts @@ -0,0 +1,168 @@ +import { expect, test } from 'bun:test' +import { + ScriptError, + extractMeta, + parseScript, + type WorkflowHooks, +} from '../engine/script.js' + +const stubHooks: WorkflowHooks = { + agent: async () => 'agent-result', + parallel: async thunks => + Promise.all( + thunks.map(async t => { + try { + return await t() + } catch { + return null + } + }), + ), + pipeline: async () => [], + phase: () => {}, + log: () => {}, + workflow: async () => null, +} + +test('extractMeta 提取纯字面量并剥离语句', () => { + const src = `export const meta = { name: 'x', description: 'y' }\nreturn 1` + const { meta, body } = extractMeta(src) + expect(meta?.name).toBe('x') + expect(meta?.description).toBe('y') + expect(body).not.toContain('export const meta') + expect(body).toContain('return 1') +}) + +test('extractMeta 无 meta 返回 null 且 body 不变', () => { + const src = `return 42` + const { meta, body } = extractMeta(src) + expect(meta).toBeNull() + expect(body).toBe(src) +}) + +test('extractMeta 拒绝非纯字面量(引用变量)', () => { + const src = `const x = 1\nexport const meta = { name: 'x', description: y }\nreturn 1` + expect(() => extractMeta(src)).toThrow(ScriptError) +}) + +test('parseScript 执行 body 顶层 return', async () => { + const { execute } = parseScript(`return args.n + 1`) + const out = await execute(stubHooks, { n: 41 }, { total: null }) + expect(out).toBe(42) +}) + +test('脚本中 Date.now() 抛非确定性错误', async () => { + const { execute } = parseScript(`return Date.now()`) + await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow( + /Date\.now/, + ) +}) + +test('脚本中 Math.random() 抛非确定性错误', async () => { + const { execute } = parseScript(`return Math.random()`) + await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow( + /Math\.random/, + ) +}) + +test('无参 new Date() 抛,有参 new Date() 可用', async () => { + const bad = parseScript(`return new Date()`) + await expect(bad.execute(stubHooks, {}, { total: null })).rejects.toThrow( + /new Date/, + ) + const good = parseScript( + `return new Date('2020-06-12T00:00:00Z').getUTCFullYear()`, + ) + await expect(good.execute(stubHooks, {}, { total: null })).resolves.toBe(2020) +}) + +// ---- meta 校验错误分支与嵌套 ---- + +test('extractMeta meta 为数组 → ScriptError', () => { + expect(() => extractMeta('export const meta = [1, 2]\nreturn 1')).toThrow( + ScriptError, + ) +}) + +test('extractMeta meta 缺 name → ScriptError', () => { + expect(() => + extractMeta('export const meta = { description: "d" }\nreturn 1'), + ).toThrow(ScriptError) +}) + +test('extractMeta meta 缺 description → ScriptError', () => { + expect(() => + extractMeta('export const meta = { name: "n" }\nreturn 1'), + ).toThrow(ScriptError) +}) + +test('extractMeta meta 大括号未闭合 → ScriptError', () => { + expect(() => + extractMeta('export const meta = { name: "n", description: "d"\nreturn 1'), + ).toThrow(ScriptError) +}) + +test('extractMeta 支持嵌套对象(phases 数组)', () => { + const src = `export const meta = { name: 'x', description: 'y', phases: [{ title: 'A' }, { title: 'B' }] }\nreturn 1` + const { meta } = extractMeta(src) + expect(meta?.name).toBe('x') + expect(meta?.phases).toHaveLength(2) + expect(meta?.phases?.[0]?.title).toBe('A') + expect(meta?.phases?.[1]?.title).toBe('B') +}) + +test('parseScript 语法错 → ScriptError', () => { + expect(() => parseScript('return ((')).toThrow(ScriptError) +}) + +test('parseScript 检测 import → 带指引的 ScriptError(不落泛化语法错)', () => { + expect(() => + parseScript( + `import { foo } from 'bar'\nexport const meta = { name: 'n', description: 'd' }\nreturn foo()`, + ), + ).toThrow(ScriptError) + expect(() => + parseScript( + `import { foo } from 'bar'\nexport const meta = { name: 'n', description: 'd' }\nreturn foo()`, + ), + ).toThrow(/不支持 import/) +}) + +test('parseScript 检测 meta 之外的多余 export → 带指引的 ScriptError', () => { + expect(() => + parseScript( + `export const meta = { name: 'n', description: 'd' }\nexport const X = 1\nreturn X`, + ), + ).toThrow(ScriptError) + expect(() => + parseScript( + `export const meta = { name: 'n', description: 'd' }\nexport const X = 1\nreturn X`, + ), + ).toThrow(/只允许一处 export const meta/) +}) + +test('parseScript 正常纯 JS 脚本(无 import/无多余 export)不被误拦', () => { + const { execute } = parseScript( + `export const meta = { name: 'n', description: 'd' }\nconst r = await agent('hi')\nreturn r`, + ) + expect(typeof execute).toBe('function') +}) + +test('parseScript 检测动态 import(...) → 带指引的 ScriptError(沙箱防逃逸)', () => { + expect(() => + parseScript( + `const cp = await import('node:child_process')\nreturn cp.execSync('id').toString()`, + ), + ).toThrow(ScriptError) + expect(() => + parseScript(`const cp = await import('node:child_process')\nreturn cp`), + ).toThrow(/import/) +}) + +test('parseScript 检测行中含 import 字符串字面量时不误拦(如 prompt 里出现 "import")', () => { + // 字符串里的 import 不应被静态 regex 拦——允许 prompt 包含 "import" 词 + const { execute } = parseScript( + `export const meta = { name: 'n', description: 'd' }\nconst r = await agent('please import this module')\nreturn r`, + ) + expect(typeof execute).toBe('function') +}) diff --git a/packages/workflow-engine/src/__tests__/structuredOutput.test.ts b/packages/workflow-engine/src/__tests__/structuredOutput.test.ts new file mode 100644 index 000000000..440264623 --- /dev/null +++ b/packages/workflow-engine/src/__tests__/structuredOutput.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'bun:test' +import { validateAgainstSchema } from '../engine/structuredOutput.js' + +const schema = { + type: 'object', + required: ['name', 'count'], + properties: { + name: { type: 'string' }, + count: { type: 'number' }, + }, + additionalProperties: false, +} + +test('合法对象通过', () => { + const { valid, errors } = validateAgainstSchema( + { name: 'a', count: 1 }, + schema, + ) + expect(valid).toBe(true) + expect(errors).toEqual([]) +}) + +test('缺字段失败', () => { + const { valid, errors } = validateAgainstSchema({ name: 'a' }, schema) + expect(valid).toBe(false) + expect(errors.length).toBeGreaterThan(0) +}) + +test('类型错误失败', () => { + const { valid } = validateAgainstSchema({ name: 'a', count: 'x' }, schema) + expect(valid).toBe(false) +}) + +test('同一 schema 复用缓存', () => { + validateAgainstSchema({ name: 'a', count: 1 }, schema) + // 第二次用同一 schema 对象应命中缓存(不抛错即可) + expect(validateAgainstSchema({ name: 'b', count: 2 }, schema).valid).toBe( + true, + ) +}) diff --git a/packages/workflow-engine/src/__tests__/types.test.ts b/packages/workflow-engine/src/__tests__/types.test.ts new file mode 100644 index 000000000..22866440b --- /dev/null +++ b/packages/workflow-engine/src/__tests__/types.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'bun:test' + +// 直接构造类型形状,验证 JSON 往返(resume 持久化的核心要求)。 +test('AgentRunResult ok 分支可 JSON 往返', () => { + const result = { + kind: 'ok' as const, + output: { confirmed: true }, + usage: { outputTokens: 42 }, + } + const round = JSON.parse(JSON.stringify(result)) + expect(round).toEqual(result) + expect(round.kind).toBe('ok') +}) + +test('AgentRunResult skipped/dead 分支可 JSON 往返', () => { + for (const kind of ['skipped', 'dead'] as const) { + const round = JSON.parse(JSON.stringify({ kind })) + expect(round.kind).toBe(kind) + } +}) + +test('JournalEntry 形状稳定', () => { + const entry = { + key: 'abc123', + result: { kind: 'ok', output: 'text', usage: { outputTokens: 1 } }, + } + const round = JSON.parse(JSON.stringify(entry)) + expect(round.key).toBe('abc123') + expect(round.result.kind).toBe('ok') +}) diff --git a/packages/workflow-engine/src/agentAdapter.ts b/packages/workflow-engine/src/agentAdapter.ts new file mode 100644 index 000000000..8c55e2398 --- /dev/null +++ b/packages/workflow-engine/src/agentAdapter.ts @@ -0,0 +1,138 @@ +// Agent 后端适配器抽象。引擎通过 registry 取 adapter 再调 run,不关心具体实现 +// (Anthropic SDK / 核心 runAgent / OpenAI / 本地模型 / mock 均为 adapter 的实现)。 +import type { AgentRunParams, AgentRunResult } from './types.js' +import type { HostHandle } from './ports.js' + +/** adapter 能力声明。引擎/脚本据此降级(如后端不支持 schema 则改文本 + 解析)。 */ +export type AgentAdapterCapabilities = { + /** 支持 schema 结构化输出(agent(schema) 直接返回对象)。 */ + structuredOutput: boolean + /** 支持工具调用(仅核心 agent 后端有)。 */ + tools?: boolean + /** 支持流式(v1 引擎不消费,预留)。 */ + stream?: boolean +} + +/** adapter.run 的上下文。 */ +export type AgentAdapterContext = { + /** 透传的不透明 host 句柄(核心 adapter 用;独立后端忽略)。 */ + host: HostHandle + /** 取消信号(与 workflow signal 一致)。 */ + signal: AbortSignal + /** 当前 workflow runId(日志/追踪用)。 */ + runId: string +} + +/** + * Agent 后端适配器。引擎只依赖此接口;具体后端实现它并注册到 registry。 + * initialize/dispose 为可选生命周期(连接池/资源管理),由调用方通过 + * registry.initializeAll/disposeAll 触发。 + */ +export interface AgentAdapter { + /** 唯一标识(registry 路由 / 日志)。 */ + readonly id: string + /** 能力声明。 */ + readonly capabilities: AgentAdapterCapabilities + /** 执行一次 agent 调用。 */ + run(params: AgentRunParams, ctx: AgentAdapterContext): Promise + /** 初始化(由 registry.initializeAll 触发)。 */ + initialize?(): Promise + /** 销毁(由 registry.disposeAll 触发)。 */ + dispose?(): Promise +} + +/** 路由规则:决定哪些 params 走哪个 adapter。按添加顺序匹配,先命中先用。 */ +export type AdapterRouteRule = + | { kind: 'agentType'; agentType: string; adapter: string } + | { kind: 'model'; pattern: string; adapter: string } + | { + kind: 'custom' + match: (params: AgentRunParams) => boolean + adapter: string + } + +/** registry 找不到匹配 adapter 时抛出。 */ +export class AdapterNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = 'AdapterNotFoundError' + } +} + +/** + * 多后端 registry。register 注册 adapter,route/default 配路由,resolve 按 + * 规则顺序匹配选 adapter。adapter 的 lifecycle(initialize/dispose)通过 + * initializeAll/disposeAll 统一触发(由调用方在运行前后调)。 + */ +export class AgentAdapterRegistry { + private readonly adapters = new Map() + private readonly rules: AdapterRouteRule[] = [] + private defaultId: string | null = null + + /** 注册一个 adapter(id 重复则覆盖)。链式。 */ + register(adapter: AgentAdapter): this { + this.adapters.set(adapter.id, adapter) + return this + } + + /** 设默认 adapter(无规则命中时用)。链式。 */ + default(adapterId: string): this { + this.defaultId = adapterId + return this + } + + /** 加一条路由规则(按添加顺序匹配)。链式。 */ + route(rule: AdapterRouteRule): this { + this.rules.push(rule) + return this + } + + has(id: string): boolean { + return this.adapters.has(id) + } + + get(id: string): AgentAdapter | undefined { + return this.adapters.get(id) + } + + /** 按规则匹配;第一个命中返回;无命中走 default;都没有抛 AdapterNotFoundError。 */ + resolve(params: AgentRunParams): AgentAdapter { + for (const rule of this.rules) { + if (matchRule(rule, params)) { + const hit = this.adapters.get(rule.adapter) + if (hit) return hit + } + } + if (this.defaultId) { + const fallback = this.adapters.get(this.defaultId) + if (fallback) return fallback + } + throw new AdapterNotFoundError( + `无 adapter 匹配(rules=${this.rules.length}, default=${this.defaultId ?? '无'})`, + ) + } + + /** 触发所有 adapter 的 initialize(跳过未实现的)。 */ + async initializeAll(): Promise { + for (const a of this.adapters.values()) { + await a.initialize?.() + } + } + + /** 触发所有 adapter 的 dispose(跳过未实现的)。 */ + async disposeAll(): Promise { + for (const a of this.adapters.values()) { + await a.dispose?.() + } + } +} + +function matchRule(rule: AdapterRouteRule, params: AgentRunParams): boolean { + if (rule.kind === 'agentType') return params.agentType === rule.agentType + if (rule.kind === 'model') { + return ( + typeof params.model === 'string' && params.model.startsWith(rule.pattern) + ) + } + return rule.match(params) // custom +} diff --git a/packages/workflow-engine/src/constants.ts b/packages/workflow-engine/src/constants.ts new file mode 100644 index 000000000..8e67baa41 --- /dev/null +++ b/packages/workflow-engine/src/constants.ts @@ -0,0 +1,26 @@ +// 引擎级常量。无运行时依赖。 + +/** + * Workflow 工具名。PascalCase 与系统其他工具(Agent/Bash/CronCreate…)一致, + * 否则大小写敏感的 toolMatchesName 会让模型自然的 select:Workflow 匹配失败。 + */ +export const WORKFLOW_TOOL_NAME = 'Workflow' + +/** 用户命名 workflow 文件目录(相对项目根)。 */ +export const WORKFLOW_DIR_NAME = '.claude/workflows' + +/** workflow run 持久化目录(journal + run 记录)。 */ +export const WORKFLOW_RUNS_DIR = '.claude/workflow-runs' + +/** 命名 workflow 支持的脚本扩展名(按优先级)。 */ +export const WORKFLOW_SCRIPT_EXTENSIONS = ['.ts', '.js', '.mjs'] as const + +/** 并发:信号量许可 = min(MAX_CONCURRENCY_CAP, cpuCores - MAX_CONCURRENCY_OFFSET)。 */ +export const MAX_CONCURRENCY_OFFSET = 2 +export const MAX_CONCURRENCY_CAP = 16 + +/** 单个 workflow 生命周期内 agent() 总数上限。 */ +export const MAX_TOTAL_AGENTS = 1000 + +/** 单次 parallel()/pipeline() 调用的 items 上限。 */ +export const MAX_ITEMS_PER_CALL = 4096 diff --git a/packages/workflow-engine/src/engine/budget.ts b/packages/workflow-engine/src/engine/budget.ts new file mode 100644 index 000000000..653076033 --- /dev/null +++ b/packages/workflow-engine/src/engine/budget.ts @@ -0,0 +1,36 @@ +export class BudgetExhaustedError extends Error { + constructor() { + super('workflow token budget 已耗尽(budget.total 达到上限)') + this.name = 'BudgetExhaustedError' + } +} + +/** + * Token 预算累加器。脚本通过 `budget.total / budget.spent() / budget.remaining()` + * 读取;agent() 调用前 assertCanSpend() 强制硬上限。 + */ +export class Budget { + private spentTokens = 0 + + constructor(readonly total: number | null) {} + + spent(): number { + return this.spentTokens + } + + remaining(): number { + return this.total == null + ? Infinity + : Math.max(0, this.total - this.spentTokens) + } + + addOutputTokens(n: number): void { + if (n > 0) this.spentTokens += n + } + + assertCanSpend(): void { + if (this.total != null && this.spentTokens >= this.total) { + throw new BudgetExhaustedError() + } + } +} diff --git a/packages/workflow-engine/src/engine/concurrency.ts b/packages/workflow-engine/src/engine/concurrency.ts new file mode 100644 index 000000000..63369c5c9 --- /dev/null +++ b/packages/workflow-engine/src/engine/concurrency.ts @@ -0,0 +1,77 @@ +import * as os from 'node:os' +import { MAX_CONCURRENCY_CAP, MAX_CONCURRENCY_OFFSET } from '../constants.js' + +/** + * 异步信号量。acquire() 返回一个 release 函数;permit 在 release 时直接 + * 转移给下一个等待者(available 不变),无等待者时才归还。permit 总数守恒。 + * + * acquire(signal?) 支持取消:signal 已 aborted 或在等待期间 abort 时立即 reject, + * waiter 从队列移除、不消耗 permit(避免被取消的 agent 占用并发槽)。 + */ +export class Semaphore { + private available: number + private readonly waiters: Array<{ + wake: () => void + cleanup: () => void + }> = [] + + constructor(permits: number) { + this.available = Math.max(1, Math.floor(permits)) + } + + async acquire(signal?: AbortSignal): Promise<() => void> { + if (signal?.aborted) { + throw new Error('Semaphore.acquire aborted (signal already aborted)') + } + if (this.available > 0) { + this.available -= 1 + return () => this.release() + } + return new Promise<() => void>((resolve, reject) => { + const onAbort = () => { + const idx = this.waiters.indexOf(entry) + if (idx >= 0) this.waiters.splice(idx, 1) + reject(new Error('Semaphore.acquire aborted')) + } + const wake = () => { + signal?.removeEventListener('abort', onAbort) + resolve(() => this.release()) + } + const entry = { + wake, + cleanup: () => signal?.removeEventListener('abort', onAbort), + } + signal?.addEventListener('abort', onAbort, { once: true }) + this.waiters.push(entry) + }) + } + + private release(): void { + const next = this.waiters.shift() + if (next) { + next.wake() // 直接转移 permit + } else { + this.available += 1 + } + } +} + +function cpuCores(): number { + const a = (os as { availableParallelism?: () => number }).availableParallelism + if (typeof a === 'function') { + try { + return a() + } catch { + // fallthrough + } + } + return os.cpus()?.length ?? 4 +} + +/** min(MAX_CONCURRENCY_CAP, cpuCores - MAX_CONCURRENCY_OFFSET),至少 1。 */ +export function maxConcurrency(): number { + return Math.max( + 1, + Math.min(MAX_CONCURRENCY_CAP, cpuCores() - MAX_CONCURRENCY_OFFSET), + ) +} diff --git a/packages/workflow-engine/src/engine/context.ts b/packages/workflow-engine/src/engine/context.ts new file mode 100644 index 000000000..c436e93f0 --- /dev/null +++ b/packages/workflow-engine/src/engine/context.ts @@ -0,0 +1,70 @@ +import type { HostHandle, WorkflowPorts } from '../ports.js' +import type { JournalEntry } from '../types.js' +import { Budget } from './budget.js' +import { Semaphore, maxConcurrency } from './concurrency.js' + +/** + * 可被子 workflow 共享的资源。嵌套时 semaphore/budget/agentCountBox 按引用共享, + * depth 在执行子 workflow 时临时 +1。 + */ +export type SharedResources = { + semaphore: Semaphore + budget: Budget + agentCountBox: { value: number } + /** agent() 调用的递增序号,盖戳 agent_started/agent_done 供进度精确关联。子 workflow 共享。 */ + agentIdSeq: { value: number } + depth: number +} + +/** 单次 workflow 运行的执行上下文。 */ +export type EngineContext = { + ports: WorkflowPorts + host: HostHandle + signal: AbortSignal + runId: string + workflowName: string + cwd: string + resources: SharedResources + journal: JournalEntry[] + journalIndex: number + journalInvalidated: boolean + currentPhase: string | null +} + +export function createSharedResources( + budgetTotal: number | null, +): SharedResources { + return { + semaphore: new Semaphore(maxConcurrency()), + budget: new Budget(budgetTotal), + agentCountBox: { value: 0 }, + agentIdSeq: { value: 0 }, + depth: 0, + } +} + +export function createEngineContext(opts: { + ports: WorkflowPorts + host: HostHandle + signal: AbortSignal + runId: string + workflowName: string + cwd: string + budgetTotal: number | null + journal?: JournalEntry[] +}): EngineContext { + const resources = createSharedResources(opts.budgetTotal) + return { + ports: opts.ports, + host: opts.host, + signal: opts.signal, + runId: opts.runId, + workflowName: opts.workflowName, + cwd: opts.cwd, + resources, + journal: opts.journal ? [...opts.journal] : [], + journalIndex: 0, + journalInvalidated: false, + currentPhase: null, + } +} diff --git a/packages/workflow-engine/src/engine/errors.ts b/packages/workflow-engine/src/engine/errors.ts new file mode 100644 index 000000000..9429e4183 --- /dev/null +++ b/packages/workflow-engine/src/engine/errors.ts @@ -0,0 +1,15 @@ +/** 引擎级可预期错误(脚本错、上限、嵌套)。 */ +export class WorkflowError extends Error { + constructor(message: string) { + super(message) + this.name = 'WorkflowError' + } +} + +/** workflow 被 abort(kill)。 */ +export class WorkflowAbortedError extends Error { + constructor() { + super('workflow 已被取消(abort)') + this.name = 'WorkflowAbortedError' + } +} diff --git a/packages/workflow-engine/src/engine/hooks.ts b/packages/workflow-engine/src/engine/hooks.ts new file mode 100644 index 000000000..d95af45d9 --- /dev/null +++ b/packages/workflow-engine/src/engine/hooks.ts @@ -0,0 +1,209 @@ +import { MAX_ITEMS_PER_CALL, MAX_TOTAL_AGENTS } from '../constants.js' +import type { + AgentRunParams, + AgentRunResult, + JournalEntry, + ProgressEvent, +} from '../types.js' +import type { EngineContext } from './context.js' +import { WorkflowAbortedError, WorkflowError } from './errors.js' +import { agentCallKey } from './journal.js' +import type { WorkflowHooks } from './script.js' + +/** workflow() 钩子的子 workflow 执行器(由 runWorkflow 注入,避免循环依赖)。 */ +export type SubWorkflowRunner = (opts: { + name?: string + scriptPath?: string + script?: string + args?: unknown +}) => Promise + +type HookProgressInit = + | { type: 'phase_started'; phase: string } + | { type: 'phase_done'; phase: string } + | { type: 'agent_started'; agentId: number; label?: string; phase?: string } + | { + type: 'agent_done' + agentId: number + label?: string + phase?: string + result: AgentRunResult + } + | { type: 'log'; message: string } + +export function makeHooks( + ctx: EngineContext, + runSubWorkflow: SubWorkflowRunner, +): WorkflowHooks { + // 所有进度事件自动注入 runId,供 adapter 路由到对应 task(多并发 workflow) + const emit = (init: HookProgressInit): void => { + ctx.ports.progressEmitter.emit({ + runId: ctx.runId, + ...init, + } as ProgressEvent) + } + + const agent: WorkflowHooks['agent'] = async (prompt, opts = {}) => { + const r = ctx.resources + if (r.agentCountBox.value >= MAX_TOTAL_AGENTS) { + throw new WorkflowError( + `workflow 超过 agent 总数上限 (${MAX_TOTAL_AGENTS})`, + ) + } + + // 每次 agent() 调用分配唯一 id(含 journal 命中),盖戳 started/done 供 reducer 精确关联 + const agentId = r.agentIdSeq.value++ + + const params: AgentRunParams = { prompt, ...opts } + const key = agentCallKey(prompt, params) + const label = opts.label as string | undefined + const phase = + (opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined + + // journal 命中 → 直接返回缓存 + if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) { + const entry = ctx.journal[ctx.journalIndex]! + if (entry.key === key) { + ctx.journalIndex++ + emit({ + type: 'agent_done', + agentId, + label, + phase, + result: entry.result, + }) + return resultToOutput(entry.result) + } + // 发散:丢弃后续 journal,后续全部现场跑 + ctx.journalInvalidated = true + ctx.journal = ctx.journal.slice(0, ctx.journalIndex) + await ctx.ports.journalStore.truncate(ctx.runId) + } + + let release: () => void + try { + release = await ctx.resources.semaphore.acquire(ctx.signal) + } catch { + // abort 期间在队列中等待:semaphore 已把 waiter 移除、未消耗 permit + throw new WorkflowAbortedError() + } + try { + if (ctx.signal.aborted) throw new WorkflowAbortedError() + // 预算检查在 semaphore 临界区内:queued waiter 被唤醒后看到最新 spent, + // 否则 N 个 waiter 入队时 spent=0 全过检,唤醒后无 re-check 全部超支。 + // journal 命中路径不扣预算,无需检查。 + r.budget.assertCanSpend() + + const pending = ctx.ports.taskRegistrar.pendingAction(ctx.runId) + if (pending?.kind === 'skip') { + const result: AgentRunResult = { kind: 'skipped' } + emit({ type: 'agent_done', agentId, label, phase, result }) + return null + } + + ctx.resources.agentCountBox.value++ + emit({ type: 'agent_started', agentId, label, phase }) + const registry = ctx.ports.agentAdapterRegistry + const result = registry + ? await registry.resolve(params).run(params, { + host: ctx.host, + signal: ctx.signal, + runId: ctx.runId, + }) + : await ctx.ports.agentRunner.runAgentToResult(params, ctx.host) + if (result.kind === 'ok') { + ctx.resources.budget.addOutputTokens(result.usage.outputTokens) + } + emit({ type: 'agent_done', agentId, label, phase, result }) + + const entry: JournalEntry = { key, seq: agentId, result } + // 关键:push 顺序 = 完成顺序(非调用顺序);read() 已按 seq 重排, + // 因此 resume 时调用顺序与 journal 顺序对齐,key 索引稳定。 + ctx.journal.push(entry) + ctx.journalIndex++ + await ctx.ports.journalStore.append(ctx.runId, entry) + return resultToOutput(result) + } finally { + release() + } + } + + const parallel: WorkflowHooks['parallel'] = async thunks => { + if (thunks.length > MAX_ITEMS_PER_CALL) { + throw new WorkflowError( + `parallel 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`, + ) + } + return Promise.all( + thunks.map(async (t, i) => { + try { + return await t() + } catch (e) { + // "null on error"契约不变,但应 log——否则 workflow 作者无法定位为何 agent 失败 + ctx.ports.logger.warn?.( + `parallel thunk #${i} failed: ${(e as Error).message}`, + ) + return null + } + }), + ) + } + + const pipeline: WorkflowHooks['pipeline'] = async ( + items: readonly T[], + ...stages: Array< + (prev: unknown, item: T, index: number) => Promise + > + ): Promise> => { + if (items.length > MAX_ITEMS_PER_CALL) { + throw new WorkflowError( + `pipeline 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`, + ) + } + return Promise.all( + items.map(async (item, index): Promise => { + try { + let prev: unknown = item + for (const stage of stages) { + prev = await stage(prev, item, index) + } + return prev as R + } catch (e) { + ctx.ports.logger.warn?.( + `pipeline item #${index} failed: ${(e as Error).message}`, + ) + return null + } + }), + ) + } + + const phase: WorkflowHooks['phase'] = title => { + if (ctx.currentPhase) { + emit({ type: 'phase_done', phase: ctx.currentPhase }) + } + ctx.currentPhase = title + emit({ type: 'phase_started', phase: title }) + } + + const log: WorkflowHooks['log'] = message => { + emit({ type: 'log', message }) + } + + const workflow: WorkflowHooks['workflow'] = async (nameOrRef, args) => { + if (ctx.resources.depth >= 1) { + throw new WorkflowError('workflow() 嵌套仅允许一层') + } + const sub: Parameters[0] = + typeof nameOrRef === 'string' + ? { name: nameOrRef } + : { scriptPath: nameOrRef.scriptPath } + return runSubWorkflow({ ...sub, args }) + } + + return { agent, parallel, pipeline, phase, log, workflow } +} + +function resultToOutput(result: AgentRunResult): unknown { + return result.kind === 'ok' ? result.output : null +} diff --git a/packages/workflow-engine/src/engine/journal.ts b/packages/workflow-engine/src/engine/journal.ts new file mode 100644 index 000000000..df7b2a07a --- /dev/null +++ b/packages/workflow-engine/src/engine/journal.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto' +import { appendFile, mkdir, readFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import type { JournalStore } from '../ports.js' +import type { AgentRunParams, JournalEntry } from '../types.js' + +/** 去掉纯展示字段后的规范化参数字符串。 */ +function canonicalParams(params: AgentRunParams): string { + const { label: _label, phase: _phase, ...rest } = params + const keys = Object.keys(rest).sort() + const sorted: Record = {} + for (const k of keys) sorted[k] = rest[k as keyof typeof rest] + return JSON.stringify(sorted) +} + +/** agent() 调用的确定性 key(prompt + 规范化 params 的 sha256)。 */ +export function agentCallKey(prompt: string, params: AgentRunParams): string { + return createHash('sha256') + .update(prompt + '\n' + canonicalParams(params)) + .digest('hex') +} + +/** 文件式 JournalStore(jsonl,每个 run 一个目录)。纯 fs,无核心依赖。 */ +export function createFileJournalStore(runsDir: string): JournalStore { + const pathOf = (runId: string) => join(runsDir, runId, 'journal.jsonl') + + return { + async read(runId): Promise { + try { + const raw = await readFile(pathOf(runId), 'utf-8') + const entries = raw + .split('\n') + .filter(line => line.trim().length > 0) + .map(line => JSON.parse(line) as JournalEntry) + // parallel 完成顺序 ≠ 调用顺序;按 seq 重排,使 resume 期间 key 索引稳定。 + // 缺 seq 的旧 entry 视为 0(保持向前兼容,最坏情况下退化为文件顺序)。 + return entries.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) + } catch { + return [] + } + }, + async append(runId, entry) { + await mkdir(join(runsDir, runId), { recursive: true }) + await appendFile(pathOf(runId), JSON.stringify(entry) + '\n', 'utf-8') + }, + async truncate(runId) { + await rm(join(runsDir, runId), { recursive: true, force: true }) + }, + } +} diff --git a/packages/workflow-engine/src/engine/namedWorkflows.ts b/packages/workflow-engine/src/engine/namedWorkflows.ts new file mode 100644 index 000000000..3f65e31e3 --- /dev/null +++ b/packages/workflow-engine/src/engine/namedWorkflows.ts @@ -0,0 +1,46 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join, parse, resolve } from 'node:path' +import { WORKFLOW_SCRIPT_EXTENSIONS } from '../constants.js' +import { containsPath } from './paths.js' + +type Ext = (typeof WORKFLOW_SCRIPT_EXTENSIONS)[number] + +function isScriptExt(ext: string): ext is Ext { + return (WORKFLOW_SCRIPT_EXTENSIONS as readonly string[]).includes( + ext.toLowerCase(), + ) +} + +/** 按 .ts → .js → .mjs 优先级解析命名 workflow 文件。 */ +export async function resolveNamedWorkflow( + workflowDir: string, + name: string, +): Promise<{ path: string; content: string } | null> { + for (const ext of WORKFLOW_SCRIPT_EXTENSIONS) { + const p = resolve(workflowDir, name + ext) + // 双保险:防止上层 sanitize 漏掉的边界 case 把路径遍历到 workflowDir 之外 + if (!containsPath(workflowDir, p)) return null + try { + return { path: p, content: await readFile(p, 'utf-8') } + } catch { + // 试下一个扩展名 + } + } + return null +} + +/** 列出目录下所有命名 workflow(不含非脚本文件)。 */ +export async function listNamedWorkflows( + workflowDir: string, +): Promise { + let files: string[] + try { + files = await readdir(workflowDir) + } catch { + return [] + } + return files + .filter(f => isScriptExt(parse(f).ext)) + .map(f => parse(f).name) + .sort() +} diff --git a/packages/workflow-engine/src/engine/paths.ts b/packages/workflow-engine/src/engine/paths.ts new file mode 100644 index 000000000..7744473d2 --- /dev/null +++ b/packages/workflow-engine/src/engine/paths.ts @@ -0,0 +1,26 @@ +import { resolve, sep } from 'node:path' + +/** + * 判断 target 解析后是否位于 base 之内(含等于 base)。 + * 相对 target 会相对 base 解析(不依赖 process.cwd)。 + * 用 `sep` 边界避免前缀假阳(如 `/foo` 不是 `/foobar` 的父目录)。 + */ +export function containsPath(base: string, target: string): boolean { + const resolvedBase = resolve(base) + const resolvedTarget = resolve(resolvedBase, target) + if (resolvedTarget === resolvedBase) return true + return resolvedTarget.startsWith(resolvedBase + sep) +} + +/** + * 校验命名 workflow 的 name 是否为合法标识符(拒绝路径遍历)。 + * 拒绝:含路径分隔符、null 字节、`.` / `..`。 + * 返回清洗后的 name,或 null 表示非法。 + */ +export function sanitizeWorkflowName(name: string): string | null { + if (typeof name !== 'string' || name.length === 0) return null + if (name.includes('/') || name.includes('\\')) return null + if (name.includes('\0')) return null + if (name === '.' || name === '..') return null + return name +} diff --git a/packages/workflow-engine/src/engine/runWorkflow.ts b/packages/workflow-engine/src/engine/runWorkflow.ts new file mode 100644 index 000000000..42dfc6bb0 --- /dev/null +++ b/packages/workflow-engine/src/engine/runWorkflow.ts @@ -0,0 +1,148 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { WORKFLOW_DIR_NAME } from '../constants.js' +import type { HostHandle, WorkflowPorts } from '../ports.js' +import type { JournalEntry, WorkflowRunResult } from '../types.js' +import { createEngineContext } from './context.js' +import { WorkflowAbortedError, WorkflowError } from './errors.js' +import { makeHooks, type SubWorkflowRunner } from './hooks.js' +import { resolveNamedWorkflow } from './namedWorkflows.js' +import { parseScript, type ParsedScript } from './script.js' + +export type RunWorkflowOptions = { + /** 已解析好的脚本源码。 */ + script: string + args?: unknown + runId: string + workflowName?: string + ports: WorkflowPorts + host: HostHandle + signal: AbortSignal + cwd: string + budgetTotal: number | null + /** resume:true 时载入既有 journal 重放。 */ + resume?: boolean + /** resume 时脚本源码 hash 是否变化。true 则忽略 journal 全重跑。 */ + scriptChanged?: boolean +} + +export async function runWorkflow( + opts: RunWorkflowOptions, +): Promise { + const { ports } = opts + + let parsed: ParsedScript + try { + parsed = parseScript(opts.script) + } catch (e) { + const error = (e as Error).message + ports.progressEmitter.emit({ + type: 'run_done', + runId: opts.runId, + status: 'failed', + error, + }) + return { status: 'failed', error } + } + + const workflowName = opts.workflowName ?? parsed.meta?.name ?? 'workflow' + + // 载入 journal(仅 resume 且脚本未变) + let journal: JournalEntry[] = [] + let journalInvalidated = false + if (opts.resume && !opts.scriptChanged) { + journal = await ports.journalStore.read(opts.runId) + } else if (opts.scriptChanged) { + await ports.journalStore.truncate(opts.runId) + journalInvalidated = true + } + + const ctx = createEngineContext({ + ports, + host: opts.host, + signal: opts.signal, + runId: opts.runId, + workflowName, + cwd: opts.cwd, + budgetTotal: opts.budgetTotal, + journal, + }) + if (journalInvalidated) ctx.journalInvalidated = true + + ports.progressEmitter.emit({ + type: 'run_started', + runId: opts.runId, + workflowName, + meta: parsed.meta, + }) + + // 子 workflow 执行器:复用同一 ctx(共享 journal/并发/预算/计数),临时 +1 depth + const runSubWorkflow: SubWorkflowRunner = async sub => { + const script = await resolveSubScript(sub, opts.cwd) + let subParsed: ParsedScript + try { + subParsed = parseScript(script) + } catch (e) { + throw new WorkflowError(`子 workflow 脚本错误:${(e as Error).message}`) + } + const prevDepth = ctx.resources.depth + ctx.resources.depth += 1 + try { + const subHooks = makeHooks(ctx, runSubWorkflow) + return await subParsed.execute(subHooks, sub.args, ctx.resources.budget) + } finally { + ctx.resources.depth = prevDepth + } + } + + const hooks = makeHooks(ctx, runSubWorkflow) + + try { + const returnValue = await parsed.execute( + hooks, + opts.args, + ctx.resources.budget, + ) + ports.progressEmitter.emit({ + type: 'run_done', + runId: opts.runId, + status: 'completed', + returnValue, + }) + return { status: 'completed', returnValue } + } catch (e) { + if (e instanceof WorkflowAbortedError) { + ports.progressEmitter.emit({ + type: 'run_done', + runId: opts.runId, + status: 'killed', + }) + return { status: 'killed' } + } + const error = (e as Error).message + ports.progressEmitter.emit({ + type: 'run_done', + runId: opts.runId, + status: 'failed', + error, + }) + return { status: 'failed', error } + } +} + +async function resolveSubScript( + sub: { name?: string; scriptPath?: string; script?: string }, + cwd: string, +): Promise { + if (sub.script) return sub.script + if (sub.scriptPath) return await readFile(sub.scriptPath, 'utf-8') + if (sub.name) { + const found = await resolveNamedWorkflow( + join(cwd, WORKFLOW_DIR_NAME), + sub.name, + ) + if (!found) throw new WorkflowError(`子 workflow "${sub.name}" 未找到`) + return found.content + } + throw new WorkflowError('workflow() 需要 name 或 scriptPath') +} diff --git a/packages/workflow-engine/src/engine/script.ts b/packages/workflow-engine/src/engine/script.ts new file mode 100644 index 000000000..db6be1b8a --- /dev/null +++ b/packages/workflow-engine/src/engine/script.ts @@ -0,0 +1,230 @@ +import type { WorkflowMeta } from '../types.js' + +export class ScriptError extends Error { + constructor(message: string) { + super(message) + this.name = 'ScriptError' + } +} + +/** 引擎注入脚本的钩子函数形状。 */ +export type WorkflowHooks = { + agent: (prompt: string, opts?: Record) => Promise + parallel: (thunks: Array<() => Promise>) => Promise> + pipeline: ( + items: readonly T[], + ...stages: Array< + (prev: unknown, item: T, index: number) => Promise + > + ) => Promise> + phase: (title: string) => void + log: (message: string) => void + workflow: ( + nameOrRef: string | { scriptPath: string }, + args?: unknown, + ) => Promise +} + +const META_RE = /export\s+const\s+meta\s*=\s*/ + +/** + * 提取 `export const meta = { ... }` 纯字面量。返回 meta 对象与剥离后的 body。 + * 字面量用无参 Function 求值——任何标识符引用都会抛 ReferenceError → 报「非纯字面量」。 + */ +export function extractMeta(source: string): { + meta: WorkflowMeta | null + body: string +} { + const match = META_RE.exec(source) + if (!match) return { meta: null, body: source } + + let i = match.index + match[0].length + while (i < source.length && /\s/.test(source[i]!)) i++ + if (source[i] !== '{') { + throw new ScriptError('meta 必须是对象字面量 `{ ... }`') + } + + // 大括号匹配(处理字符串/转义/嵌套) + let depth = 0 + const start = i + let inStr: string | null = null + for (; i < source.length; i++) { + const ch = source[i]! + if (inStr) { + if (ch === '\\') { + i++ + continue + } + if (ch === inStr) inStr = null + continue + } + if (ch === '"' || ch === "'" || ch === '`') { + inStr = ch + continue + } + if (ch === '{') depth++ + else if (ch === '}') { + depth-- + if (depth === 0) { + i++ + break + } + } + } + if (depth !== 0) throw new ScriptError('meta 字面量大括号未闭合') + + const literal = source.slice(start, i) + let metaObj: unknown + try { + // 无参 Function:纯字面量可求值;引用任何标识符 → ReferenceError + metaObj = new Function(`return (${literal})`)() + } catch (e) { + throw new ScriptError( + `meta 必须是纯字面量(无变量/函数调用/插值):${(e as Error).message}`, + ) + } + const meta = validateMeta(metaObj) + + // 剥离 meta 语句(含尾随分号与多余空行) + const body = (source.slice(0, match.index) + source.slice(i)).replace( + /[ \t]*;[ \t]*\n/, + '\n', + ) + return { meta, body } +} + +function validateMeta(v: unknown): WorkflowMeta { + if (typeof v !== 'object' || v === null || Array.isArray(v)) { + throw new ScriptError('meta 必须是对象') + } + const o = v as Record + if (typeof o.name !== 'string' || typeof o.description !== 'string') { + throw new ScriptError('meta 必须含字符串 name 与 description') + } + return o as unknown as WorkflowMeta +} + +// ---- 非确定性沙箱 shim ---- +class NonDeterministicError extends Error { + constructor(fn: string) { + super( + `${fn} 在 workflow 脚本中不可用(会破坏 resume 的确定性)。请通过 args 传入时间戳/随机种子。`, + ) + this.name = 'NonDeterministicError' + } +} + +function sandboxDate(): DateConstructor { + const fn = function (...args: unknown[]): Date { + if (args.length === 0) + throw new NonDeterministicError('Date.now()/new Date()') + return new (Date as unknown as DateConstructor)( + ...(args as [string | number | Date]), + ) + } as unknown as DateConstructor + fn.now = () => { + throw new NonDeterministicError('Date.now()') + } + fn.parse = Date.parse + fn.UTC = Date.UTC + return fn +} + +function sandboxMath(): Math { + return new Proxy(Math, { + get(target, prop, receiver) { + if (prop === 'random') { + return () => { + throw new NonDeterministicError('Math.random()') + } + } + return Reflect.get(target, prop, receiver) + }, + }) as Math +} + +const AsyncFunction = Object.getPrototypeOf(async function () {}) + .constructor as { + new (...args: string[]): (...args: unknown[]) => Promise +} + +export type ParsedScript = { + meta: WorkflowMeta | null + execute: ( + hooks: WorkflowHooks, + args: unknown, + budget: unknown, + ) => Promise +} + +/** 校验 + 包装脚本为可执行 async 函数(Date/Math 被 shim 覆盖)。 */ +/** + * 检测脚本 body 的常见违例(import / 多余 export),给出带指引的精准错误。 + * 否则会落到 AsyncFunction 的泛化「语法错误」,模型/用户难定位根因 + * (脚本是非 ESM 函数体、钩子已注入、引擎不转译 TS)。 + */ +function assertScriptBody(body: string): void { + if (/^\s*import\b/m.test(body)) { + throw new ScriptError( + 'workflow 脚本是 new AsyncFunction 的函数体(非 ESM 模块),不支持 import。' + + 'agent / parallel / pipeline / phase / log / workflow / args / budget 已作为形参注入,直接使用。', + ) + } + // 动态 import(...) 调用:沙箱仅保 resume 确定性不保安全,但应阻止明显的逃逸尝试。 + // 不锚定行首以捕获 `await import(...)`、`return import(...)` 等位置;要求 `import` 后紧跟 `(` 才拦截, + // 避免误伤字符串字面量里出现 "import" 词(如 agent('please import this module'))。 + if (/\bimport\s*\(/m.test(body)) { + throw new ScriptError( + 'workflow 脚本中禁止动态 import(...):会绕过 Date/Math 沙箱,破坏 resume 确定性。' + + '沙箱不保安全(与 LLM 同级信任),但禁止显式逃逸。需要外部依赖时通过 args 注入。', + ) + } + if (/^\s*export\b/m.test(body)) { + throw new ScriptError( + 'workflow 脚本只允许一处 export const meta = {...}(已被引擎提取)。' + + '请删除其余 export / export default;用顶层 return 返回结果。', + ) + } +} + +export function parseScript(source: string): ParsedScript { + const { meta, body } = extractMeta(source) + assertScriptBody(body) + let fn: (...args: unknown[]) => Promise + try { + fn = new AsyncFunction( + 'agent', + 'parallel', + 'pipeline', + 'phase', + 'log', + 'workflow', + 'args', + 'budget', + 'Date', + 'Math', + body, + ) + } catch (e) { + throw new ScriptError(`脚本语法错误:${(e as Error).message}`) + } + const sandboxedDate = sandboxDate() + const sandboxedMath = sandboxMath() + return { + meta, + async execute(hooks, args, budget) { + return fn( + hooks.agent, + hooks.parallel, + hooks.pipeline, + hooks.phase, + hooks.log, + hooks.workflow, + args, + budget, + sandboxedDate, + sandboxedMath, + ) + }, + } +} diff --git a/packages/workflow-engine/src/engine/structuredOutput.ts b/packages/workflow-engine/src/engine/structuredOutput.ts new file mode 100644 index 000000000..950a54018 --- /dev/null +++ b/packages/workflow-engine/src/engine/structuredOutput.ts @@ -0,0 +1,26 @@ +import { Ajv, type ValidateFunction } from 'ajv' + +const cache = new WeakMap() + +/** + * 用 JSON Schema 校验 agent 输出(Ajv,编译结果按 schema 对象缓存)。 + * 引擎对 adapter 返回的 schema 结果做二次校验,并用于测试。 + */ +export function validateAgainstSchema( + value: unknown, + schema: object, +): { valid: boolean; errors: string[] } { + let validate = cache.get(schema) + if (!validate) { + const ajv = new Ajv({ allErrors: true, strict: false }) + validate = ajv.compile(schema) as ValidateFunction + cache.set(schema, validate) + } + const valid = validate(value) as boolean + return { + valid, + errors: valid + ? [] + : (validate.errors ?? []).map(e => e.message ?? 'validation error'), + } +} diff --git a/packages/workflow-engine/src/index.ts b/packages/workflow-engine/src/index.ts new file mode 100644 index 000000000..1227993c3 --- /dev/null +++ b/packages/workflow-engine/src/index.ts @@ -0,0 +1,24 @@ +// @claude-code-best/workflow-engine +// 确定性 JS 脚本编排引擎。零核心层运行时依赖,通过端口适配与世界对话。 + +export * from './types.js' +export * from './constants.js' +export * from './ports.js' +export * from './agentAdapter.js' +export * from './engine/concurrency.js' +export * from './engine/script.js' +export * from './engine/journal.js' +export * from './engine/budget.js' +export * from './engine/structuredOutput.js' +export * from './engine/namedWorkflows.js' +export * from './engine/errors.js' +export * from './engine/context.js' +export * from './engine/hooks.js' +export * from './engine/runWorkflow.js' +export * from './progress/events.js' +export { + createWorkflowTool, + type WorkflowToolDescriptor, +} from './tool/WorkflowTool.js' +export { workflowInputSchema, type WorkflowInput } from './tool/schema.js' +export { WORKFLOW_TOOL_NAME } from './tool/constants.js' diff --git a/packages/workflow-engine/src/ports.ts b/packages/workflow-engine/src/ports.ts new file mode 100644 index 000000000..1b4778a20 --- /dev/null +++ b/packages/workflow-engine/src/ports.ts @@ -0,0 +1,134 @@ +import type { AgentAdapterRegistry } from './agentAdapter.js' +import type { + AgentRunParams, + AgentRunResult, + JournalEntry, + ProgressEvent, +} from './types.js' + +/** + * 不透明 host 句柄。核心侧每次工具调用构造一个,内含 toolUseContext/ + * canUseTool/parentMessage 等。包内绝不检视其内部,只透传给 AgentRunner。 + * 这是包与核心层之间唯一的耦合缝隙,且是不透明的。 + */ +const HOST_HANDLE = Symbol('workflow.hostHandle') + +export type HostBundle = unknown + +export type HostHandle = { readonly [HOST_HANDLE]: HostBundle } + +/** 核心 side hostFactory 用:把任意 bundle 包成不透明句柄。 */ +export function createHostHandle(bundle: HostBundle): HostHandle { + return { [HOST_HANDLE]: bundle } as HostHandle +} + +/** 类型守卫。 */ +export function isHostHandle(value: unknown): value is HostHandle { + return ( + typeof value === 'object' && + value !== null && + HOST_HANDLE in (value as object) + ) +} + +/** 核心 side adapter 用:解包(仅 adapter 应调用)。 */ +export function unwrapHostHandle(handle: HostHandle): HostBundle { + return (handle as { [k: symbol]: HostBundle })[HOST_HANDLE] +} + +/** agent() 钩子的后端。 */ +export type AgentRunner = { + runAgentToResult( + params: AgentRunParams, + host: HostHandle, + ): Promise +} + +/** 进度事件发射。 */ +export type ProgressEmitter = { + emit(event: ProgressEvent): void +} + +/** 后台任务生命周期。 */ +export type TaskRegistrar = { + /** + * 注册后台任务。adapter 创建 AbortController 并存入 task 状态, + * 返回 runId 与 signal(供引擎 detached 执行 + kill 中止用)。 + */ + register( + opts: { + workflowName: string + workflowFile?: string + summary?: string + toolUseId?: string + /** resume 时复用既有 runId(读其 journal)。省略则生成新 id。 */ + runId?: string + }, + host: HostHandle, + ): { runId: string; signal: AbortSignal } + complete(runId: string, summary?: string): void + fail(runId: string, error: string): void + kill(runId: string): void + /** 返回当前待处理的 skip/retry 动作,或 null。 */ + pendingAction(runId: string): { kind: 'skip' | 'retry' } | null +} + +/** journal 持久化。 */ +export type JournalStore = { + read(runId: string): Promise + append(runId: string, entry: JournalEntry): Promise + truncate(runId: string): Promise +} + +/** 取消/权限门。 */ +export type PermissionGate = { + isAborted(host: HostHandle): boolean +} + +/** 日志 + 遥测。 */ +export type Logger = { + debug(msg: string): void + event(name: string, metadata?: Record): void + /** + * 警告级日志(如 parallel/pipeline 单项失败被吞掉的错误)。 + * Optional:旧 ports 实现可省略;hooks 用 `?.()` 容错。 + */ + warn?(msg: string): void +} + +/** 引擎从 host 提取的可直接使用上下文(句柄 + 基本字段)。 */ +export type WorkflowHostContext = { + /** 透传给 AgentRunner 的不透明句柄(内含 toolUseContext/canUseTool/parentMessage)。 */ + handle: HostHandle + cwd: string + /** token 预算上限,null 表示无限制。 */ + budgetTotal: number | null + /** 核心 side 的工具调用 ID(透传给 task 注册)。 */ + toolUseId?: string +} + +/** + * 核心 side 提供:从工具调用的核心上下文构造 WorkflowHostContext。 + * 参数对包是不透明的(unknown);核心侧 hostFactory 知道真实类型。 + */ +export type HostFactory = (args: { + context: unknown + canUseTool: unknown + parentMessage: unknown +}) => WorkflowHostContext + +/** 所有端口的聚合。createWorkflowTool(ports) 注入。 */ +export type WorkflowPorts = { + agentRunner: AgentRunner + /** + * 多后端 adapter registry。提供时优先于 agentRunner——hooks.agent 按 registry + * 路由到 adapter.run;省略则回退 agentRunner(兼容旧用法)。 + */ + agentAdapterRegistry?: AgentAdapterRegistry + progressEmitter: ProgressEmitter + taskRegistrar: TaskRegistrar + journalStore: JournalStore + permissionGate: PermissionGate + logger: Logger + hostFactory: HostFactory +} diff --git a/packages/workflow-engine/src/progress/events.ts b/packages/workflow-engine/src/progress/events.ts new file mode 100644 index 000000000..b3e4e15e1 --- /dev/null +++ b/packages/workflow-engine/src/progress/events.ts @@ -0,0 +1,20 @@ +import type { ProgressEmitter } from '../ports.js' +import type { ProgressEvent } from '../types.js' + +export type { ProgressEvent } + +/** 从单个回调构造 ProgressEmitter。 */ +export function createProgressEmitter( + onEvent: (e: ProgressEvent) => void, +): ProgressEmitter { + return { emit: onEvent } +} + +/** 收集所有事件到数组(测试用)。 */ +export function createBufferingEmitter(): { + emitter: ProgressEmitter + events: ProgressEvent[] +} { + const events: ProgressEvent[] = [] + return { emitter: { emit: e => void events.push(e) }, events } +} diff --git a/packages/workflow-engine/src/tool/WorkflowTool.ts b/packages/workflow-engine/src/tool/WorkflowTool.ts new file mode 100644 index 000000000..12321daad --- /dev/null +++ b/packages/workflow-engine/src/tool/WorkflowTool.ts @@ -0,0 +1,232 @@ +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { z } from 'zod/v4' +import { WORKFLOW_DIR_NAME, WORKFLOW_TOOL_NAME } from '../constants.js' +import { resolveNamedWorkflow } from '../engine/namedWorkflows.js' +import { runWorkflow } from '../engine/runWorkflow.js' +import { parseScript } from '../engine/script.js' +import { containsPath, sanitizeWorkflowName } from '../engine/paths.js' +import type { WorkflowPorts } from '../ports.js' +import type { WorkflowRunResult } from '../types.js' +import { workflowInputSchema, type WorkflowInput } from './schema.js' + +/** 自包含工具描述符(核心 wiring 用 buildTool 包装它)。零核心层依赖。 */ +export type WorkflowToolDescriptor = { + name: string + inputSchema: z.ZodType + isEnabled: () => boolean + isReadOnly: (input: WorkflowInput) => boolean + description: () => Promise + prompt: () => Promise + renderToolUseMessage: (input: Partial) => string + call: ( + input: WorkflowInput, + context: unknown, + canUseTool: unknown, + parentMessage: unknown, + onProgress?: unknown, + ) => Promise<{ data: { output: string } }> + mapToolResultToToolResultBlockParam: ( + data: { output: string }, + toolUseId: string, + ) => { + tool_use_id: string + type: 'tool_result' + content: Array<{ type: 'text'; text: string }> + } +} + +const WORKFLOW_TOOL_PROMPT = `Use the Workflow tool to execute a workflow script that orchestrates multiple subagents deterministically. The script runs in the background; you receive a run_id immediately and are notified on completion. + +Provide the script inline via "script", or reference a named workflow via "name" (resolved from .claude/workflows/), or an existing file via "scriptPath". Pass "args" as a real JSON value (object/array/string), not a stringified string. + +Use "resumeFromRunId" to resume a prior run — completed agent() calls replay from the journal instantly. + +Script execution model (common pitfalls — getting these wrong is the #1 cause of script errors): the script is the body of \`new AsyncFunction\` — NOT an ESM module, and TypeScript is NOT transpiled. Therefore: +- Do NOT use \`import\` — \`agent\`, \`parallel\`, \`pipeline\`, \`phase\`, \`log\`, \`workflow\`, \`args\`, and \`budget\` are injected as parameters; reference them directly. +- Do NOT use TS type annotations, \`interface\`, \`enum\`, \`as\`, or generics — the engine does not transpile, so even a .ts file with type syntax fails to parse. +- Keep EXACTLY ONE \`export const meta = {...}\` (plain literal) and remove every other \`export\` / \`export default\`. +- Return the result with a top-level \`return\`. +Prefer .js / .mjs. See /ultracode for the full playbook and quality patterns.` + +export function createWorkflowTool( + ports: WorkflowPorts, +): WorkflowToolDescriptor { + return { + name: WORKFLOW_TOOL_NAME, + inputSchema: workflowInputSchema, + isEnabled: () => true, + isReadOnly: () => false, + + async description() { + return '执行一个 workflow 脚本,编排多个子 agent 完成任务' + }, + + async prompt() { + return WORKFLOW_TOOL_PROMPT + }, + + renderToolUseMessage(input) { + if (input.resumeFromRunId) + return `Workflow resume: ${input.resumeFromRunId}` + const id = + input.name ?? input.scriptPath ?? (input.script ? 'inline' : 'unknown') + return `Workflow: ${id}` + }, + + async call(input, context, canUseTool, parentMessage) { + const host = ports.hostFactory({ context, canUseTool, parentMessage }) + + // 解析脚本源 + let script: string + let workflowFile: string | undefined + try { + const resolved = await resolveScriptSource(input, host.cwd) + script = resolved.script + workflowFile = resolved.workflowFile + } catch (e) { + return { data: { output: `Error: ${(e as Error).message}` } } + } + + // 快速校验(meta + 语法),失败直接返错给模型,不进后台 + try { + parseScript(script) + } catch (e) { + return { + data: { output: `Error: 脚本校验失败:${(e as Error).message}` }, + } + } + + const workflowName = input.name ?? input.title ?? 'workflow' + const { runId, signal } = ports.taskRegistrar.register( + { + workflowName, + ...(workflowFile ? { workflowFile } : {}), + ...(input.description ? { summary: input.description } : {}), + ...(host.toolUseId ? { toolUseId: host.toolUseId } : {}), + ...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}), + }, + host.handle, + ) + + // detached 执行 + void runWorkflow({ + script, + ...(input.args !== undefined + ? { args: normalizeArgs(input.args) } + : {}), + runId, + workflowName, + ports, + host: host.handle, + signal, + cwd: host.cwd, + budgetTotal: host.budgetTotal, + ...(input.resumeFromRunId ? { resume: true } : {}), + }) + .then(result => onFinish(ports, result, runId)) + .catch(e => ports.taskRegistrar.fail(runId, (e as Error).message)) + + const scriptPath = workflowFile ?? `` + return { + data: { + output: [ + 'Workflow 已启动(后台执行)。', + `run_id: ${runId}`, + `workflow: ${workflowName}`, + `script: ${scriptPath}`, + '', + '完成时会自动通知。用 /workflows 查看实时进度。', + ].join('\n'), + }, + } + }, + + mapToolResultToToolResultBlockParam(data, toolUseId) { + return { + tool_use_id: toolUseId, + type: 'tool_result', + content: [{ type: 'text', text: data.output }], + } + }, + } +} + +function onFinish( + ports: WorkflowPorts, + result: WorkflowRunResult, + runId: string, +): void { + if (result.status === 'completed') { + const summary = + result.returnValue == null + ? '(no return value)' + : formatValue(result.returnValue) + ports.taskRegistrar.complete(runId, summary) + } else if (result.status === 'failed') { + ports.taskRegistrar.fail(runId, result.error ?? 'workflow failed') + } else { + ports.taskRegistrar.kill(runId) + } +} + +function formatValue(v: unknown): string { + if (typeof v === 'string') return v.slice(0, 500) + try { + return JSON.stringify(v).slice(0, 500) + } catch { + return String(v) + } +} + +/** + * 防御性归一化 args:旧 `z.string()` 契约下模型可能发送字符串化的 JSON 对象。 + * 仅当字符串能 JSON.parse 出对象/数组时归一化;纯字符串、数字等保留原值。 + */ +function normalizeArgs(raw: unknown): unknown { + if (typeof raw !== 'string') return raw + try { + const parsed: unknown = JSON.parse(raw) + if (typeof parsed === 'object' && parsed !== null) return parsed + return raw + } catch { + return raw + } +} + +async function resolveScriptSource( + input: WorkflowInput, + cwd: string, +): Promise<{ script: string; workflowFile?: string }> { + if (input.script) return { script: input.script } + if (input.scriptPath) { + const resolved = resolve(cwd, input.scriptPath) + if (!containsPath(cwd, resolved)) { + throw new Error( + `scriptPath "${input.scriptPath}" 越界(resolve 后 ${resolved} 不在 cwd ${cwd} 之内)`, + ) + } + return { + script: await readFile(resolved, 'utf-8'), + workflowFile: resolved, + } + } + if (input.name) { + if (sanitizeWorkflowName(input.name) === null) { + throw new Error( + `命名 workflow 名字 "${input.name}" 非法(含路径分隔符或为 . / ..)`, + ) + } + const found = await resolveNamedWorkflow( + join(cwd, WORKFLOW_DIR_NAME), + input.name, + ) + if (!found) { + throw new Error( + `命名 workflow "${input.name}" 未找到(查找目录 ${WORKFLOW_DIR_NAME}/)`, + ) + } + return { script: found.content, workflowFile: found.path } + } + throw new Error('必须提供 script、name 或 scriptPath 之一') +} diff --git a/packages/workflow-engine/src/tool/constants.ts b/packages/workflow-engine/src/tool/constants.ts new file mode 100644 index 000000000..2287a9d91 --- /dev/null +++ b/packages/workflow-engine/src/tool/constants.ts @@ -0,0 +1 @@ +export { WORKFLOW_TOOL_NAME } from '../constants.js' diff --git a/packages/workflow-engine/src/tool/schema.ts b/packages/workflow-engine/src/tool/schema.ts new file mode 100644 index 000000000..47c50a109 --- /dev/null +++ b/packages/workflow-engine/src/tool/schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod/v4' + +/** Workflow 工具输入 schema。args 为任意 JSON 值(对象/数组/字符串等)。 */ +export const workflowInputSchema = z.object({ + script: z + .string() + .optional() + .describe('自包含的 workflow 脚本源码(inline)'), + name: z + .string() + .optional() + .describe('命名 workflow,解析到 .claude/workflows/.ts|js|mjs'), + scriptPath: z.string().optional().describe('已有脚本文件的绝对路径'), + args: z + .unknown() + .optional() + .describe( + '透传给脚本的 args 全局变量。传真实 JSON 值(对象/数组/字符串),不要传 JSON 字符串。', + ), + resumeFromRunId: z + .string() + .optional() + .describe('resume 指定 run,重放 journal'), + description: z.string().optional().describe('本次调用的简短描述(3-5 词)'), + title: z.string().optional().describe('进度查看器标题'), +}) + +/** + * Workflow 工具输入类型——从 schema 派生,避免手工 type 与 schema 漂移。 + * 旧实现里 {@link WorkflowInput} 在 types.ts 手写、schema 在 schema.ts, + * 中间靠 `as unknown as z.ZodType` 双重断言连接——schema 改字段 + * 但 type 没动时 TS 不会报错。z.infer 后 schema/type 永远同步。 + */ +export type WorkflowInput = z.infer + +/** schema 的 typeof 类型(用于"以 schema 为准"的精确签名)。 */ +export type WorkflowInputSchema = typeof workflowInputSchema diff --git a/packages/workflow-engine/src/types.ts b/packages/workflow-engine/src/types.ts new file mode 100644 index 000000000..9dc3d9caa --- /dev/null +++ b/packages/workflow-engine/src/types.ts @@ -0,0 +1,83 @@ +// 纯类型定义。无运行时依赖。 +// WorkflowInput 已迁移到 tool/schema.ts,用 z.infer 派生避免与 schema 漂移。 + +/** 脚本 `export const meta = {...}` 的形状(必须是纯字面量)。 */ +export type WorkflowMeta = { + name: string + description: string + whenToUse?: string + phases?: Array<{ title: string; detail?: string }> +} + +/** agent() 传给 AgentRunner 的参数。 */ +export type AgentRunParams = { + prompt: string + /** JSON Schema;提供时 agent 返回校验对象而非文本。 */ + schema?: object + model?: string + /** 输出 token 上限(透传给 agent 后端,如 LLM 的 max_tokens)。 */ + maxTokens?: number + /** 自定义子 agent 类型(从 registry 解析)。 */ + agentType?: string + isolation?: 'worktree' + allowedTools?: string[] + /** 仅展示用,不计入 journal key。 */ + label?: string + /** 仅展示用,不计入 journal key。 */ + phase?: string +} + +/** AgentRunner 返回。 */ +export type AgentRunResult = + | { kind: 'ok'; output: string | object; usage: { outputTokens: number } } + | { kind: 'skipped' } + | { kind: 'dead' } + +/** journal 中单条记录。seq = agent() 调用序号,read() 据此重排以稳定 resume。 */ +export type JournalEntry = { + key: string + /** agent() 调用顺序(来自 agentIdSeq,跨 sub-workflow 单调递增)。 */ + seq: number + result: AgentRunResult +} + +/** 进度事件。所有变体携带 runId,供 adapter 路由到对应 task(多并发 workflow)。 */ +export type ProgressEvent = + | { + type: 'run_started' + runId: string + workflowName: string + meta: WorkflowMeta | null + } + | { type: 'phase_started'; runId: string; phase: string } + | { type: 'phase_done'; runId: string; phase: string } + | { + type: 'agent_started' + runId: string + agentId: number + label?: string + phase?: string + } + | { + type: 'agent_done' + runId: string + agentId: number + label?: string + phase?: string + result: AgentRunResult + } + | { type: 'log'; runId: string; message: string } + | { + type: 'run_done' + runId: string + status: 'completed' | 'failed' | 'killed' + returnValue?: unknown + error?: string + } + +/** 引擎运行结果。 */ +export type WorkflowRunResult = { + status: 'completed' | 'failed' | 'killed' + returnValue?: unknown + error?: string +} diff --git a/packages/workflow-engine/tsconfig.json b/packages/workflow-engine/tsconfig.json new file mode 100644 index 000000000..d06cbea8f --- /dev/null +++ b/packages/workflow-engine/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "types": ["bun"], + "lib": ["ESNext"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/src/commands.ts b/src/commands.ts index 508498c27..c121dce65 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -477,7 +477,7 @@ async function getSkills(cwd: string): Promise<{ /* eslint-disable @typescript-eslint/no-require-imports */ const getWorkflowCommands = feature('WORKFLOW_SCRIPTS') ? ( - require('@claude-code-best/builtin-tools/tools/WorkflowTool/createWorkflowCommand.js') as typeof import('@claude-code-best/builtin-tools/tools/WorkflowTool/createWorkflowCommand.js') + require('./workflow/namedWorkflowCommands.js') as typeof import('./workflow/namedWorkflowCommands.js') ).getWorkflowCommands : null /* eslint-enable @typescript-eslint/no-require-imports */ diff --git a/src/commands/workflows/index.ts b/src/commands/workflows/index.ts index 03a680c0a..c5fa4af5b 100644 --- a/src/commands/workflows/index.ts +++ b/src/commands/workflows/index.ts @@ -1,28 +1,11 @@ -import type { Command, LocalCommandCall } from '../../types/command.js' -import { getWorkflowCommands } from '@claude-code-best/builtin-tools/tools/WorkflowTool/createWorkflowCommand.js' -import { getCwd } from '../../utils/cwd.js' - -const call: LocalCommandCall = async (_args, _context) => { - const commands = await getWorkflowCommands(getCwd()) - if (commands.length === 0) { - return { - type: 'text', - value: - 'No workflows found. Add workflow files to .claude/workflows/ (YAML or Markdown).', - } - } - const list = commands - .map(cmd => ` /${cmd.name} - ${cmd.description}`) - .join('\n') - return { type: 'text', value: `Available workflows:\n${list}` } -} +import type { Command } from '../../types/command.js' const workflows = { - type: 'local', + type: 'local-jsx', name: 'workflows', - description: 'List available workflow scripts', - supportsNonInteractive: true, - load: () => Promise.resolve({ call }), + description: 'Workflow 监控面板:实时 run/phase/agent 进度,键盘控制', + // 延迟加载面板实现,避免启动时拉入 Ink/React 依赖。 + load: () => import('../../workflow/panel/panelCall.js'), } satisfies Command export default workflows diff --git a/src/components/permissions/PermissionRequest.tsx b/src/components/permissions/PermissionRequest.tsx index 6182624e8..1c1b73d00 100644 --- a/src/components/permissions/PermissionRequest.tsx +++ b/src/components/permissions/PermissionRequest.tsx @@ -45,14 +45,12 @@ const ReviewArtifactPermissionRequest = feature('REVIEW_ARTIFACT') : null; const WorkflowTool = feature('WORKFLOW_SCRIPTS') - ? ( - require('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowTool.js') as typeof import('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowTool.js') - ).WorkflowTool + ? (require('../../workflow/wiring.js') as typeof import('../../workflow/wiring.js')).createWorkflowToolCore() : null; const WorkflowPermissionRequest = feature('WORKFLOW_SCRIPTS') ? ( - require('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowPermissionRequest.js') as typeof import('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowPermissionRequest.js') + require('../../workflow/WorkflowPermissionRequest.js') as typeof import('../../workflow/WorkflowPermissionRequest.js') ).WorkflowPermissionRequest : null; diff --git a/src/components/tasks/BackgroundTasksDialog.tsx b/src/components/tasks/BackgroundTasksDialog.tsx index 9fdd89f1a..a0e8933ea 100644 --- a/src/components/tasks/BackgroundTasksDialog.tsx +++ b/src/components/tasks/BackgroundTasksDialog.tsx @@ -1,6 +1,5 @@ import { feature } from 'bun:bundle'; import figures from 'figures'; -import type { AgentId } from '../../types/ids.js'; import React, { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'; import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; @@ -107,15 +106,12 @@ type ListItem = // ~1.3K lines into external builds. Gate with feature() + require so the // bundler can dead-code-eliminate the branch. /* eslint-disable @typescript-eslint/no-require-imports */ -const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') - ? (require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js')).WorkflowDetailDialog - : null; +// WorkflowDetailDialog 已移除:workflow 详情改由 /workflows 面板展示。 const workflowTaskModule = feature('WORKFLOW_SCRIPTS') ? (require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js')) : null; const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null; -const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null; -const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null; +// skipWorkflowAgent / retryWorkflowAgent 仅由 /workflows 面板调用(原详情对话框已移除)。 // Relative path, not `src/...` path-mapping — Bun's DCE can statically // resolve + eliminate `./` requires, but path-mapped strings stay opaque // and survive as dead literals in the bundle. Matches tasks.ts pattern. @@ -440,29 +436,58 @@ export function BackgroundTasksDialog({ onDone, toolUseContext, initialDetailTas key={`teammate-${task.id}`} /> ); - case 'local_workflow': - if (!WorkflowDetailDialog) return null; + case 'local_workflow': { + // shift+下/Enter 进入的 workflow 详情。原 WorkflowDetailDialog 已移除, + // 详情改由 /workflows 面板展示,但此处仍需一个能退出的占位视图—— + // 否则用户进入后 Esc/←/q 全无效,卡死。照 MonitorMcpDetailDialog 模式: + // ←/Esc 返回(goBackToList:单任务关闭、多任务回列表),x kill(running)。 + const onKill = + task.status === 'running' && killWorkflowTask ? () => killWorkflowTask(task.id, setAppState) : undefined; return ( - void} - onKill={ - task.status === 'running' && killWorkflowTask ? () => killWorkflowTask(task.id, setAppState) : undefined - } - onSkipAgent={ - task.status === 'running' && skipWorkflowAgent - ? (agentId: string) => skipWorkflowAgent(task.id, agentId as AgentId, setAppState) - : undefined - } - onRetryAgent={ - task.status === 'running' && retryWorkflowAgent - ? (agentId: string) => retryWorkflowAgent(task.id, agentId as AgentId, setAppState) - : undefined - } - onBack={goBackToList} + + flexDirection="column" + tabIndex={0} + borderStyle="round" + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'left') { + e.preventDefault(); + goBackToList(); + } else if (e.key === 'x' && onKill) { + e.preventDefault(); + onKill(); + } + }} + > + + {task.status} + {task.summary ? ` · ${task.summary}` : ''} + + } + onCancel={goBackToList} + inputGuide={() => ( + + + + {onKill && } + + )} + > + {task.status === 'failed' && task.error ? ( + + 失败原因:{task.error} + 用 /workflows 查看阶段与 agent 实时进度 + + ) : ( + 用 /workflows 查看阶段与 agent 实时进度 + )} + + ); + } case 'monitor_mcp': if (!MonitorMcpDetailDialog) return null; return ( diff --git a/src/components/tasks/WorkflowDetailDialog.tsx b/src/components/tasks/WorkflowDetailDialog.tsx deleted file mode 100644 index fae990e1e..000000000 --- a/src/components/tasks/WorkflowDetailDialog.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useCallback } from 'react'; -import type { DeepImmutable } from 'src/types/utils.js'; -import { useElapsedTime } from '../../hooks/useElapsedTime.js'; -import { Box, Text, type KeyboardEvent } from '@anthropic/ink'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { LocalWorkflowTaskState } from '../../tasks/LocalWorkflowTask/LocalWorkflowTask.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; - -type Props = { - workflow: DeepImmutable; - onDone: (message?: string, options?: { display?: string }) => void; - onKill?: () => void; - onSkipAgent?: (agentId: string) => void; - onRetryAgent?: (agentId: string) => void; - onBack?: () => void; -}; - -/** - * Detail dialog for local workflow tasks shown in the Shift+Down background - * tasks overlay. Displays the workflow name, file, status, and output. - * Follows the DreamDetailDialog/ShellDetailDialog pattern. - */ -export function WorkflowDetailDialog({ - workflow, - onDone: _onDone, - onKill, - onSkipAgent: _onSkipAgent, - onRetryAgent: _onRetryAgent, - onBack, -}: Props): React.ReactNode { - const elapsedTime = useElapsedTime(workflow.startTime, workflow.status === 'running', 1000, 0); - - useKeybindings({}, { context: 'WorkflowDetail' }); - - const handleKeyDown = useCallback( - (e: KeyboardEvent): void => { - if (e.key === 'left' && onBack) { - e.preventDefault(); - onBack(); - } else if (e.key === 'x' && workflow.status === 'running' && onKill) { - e.preventDefault(); - onKill(); - } - }, - [onBack, onKill, workflow.status], - ); - - return ( - - - {elapsedTime} · {workflow.workflowName} - - } - onCancel={onBack ?? (() => {})} - inputGuide={() => ( - - {onBack && } - - {workflow.status === 'running' && onKill && } - - )} - > - - - Status:{' '} - {workflow.status === 'running' ? ( - running - ) : workflow.status === 'completed' ? ( - {workflow.status} - ) : ( - {workflow.status} - )} - - - Description: {workflow.description} - - - Workflow: {workflow.workflowName} - - - File: {workflow.workflowFile} - - {workflow.summary && ( - - Summary: {workflow.summary} - - )} - {workflow.output && ( - - Output: - {workflow.output} - - )} - - - - ); -} diff --git a/src/constants/tools.ts b/src/constants/tools.ts index be35a5c05..e5b056b1e 100644 --- a/src/constants/tools.ts +++ b/src/constants/tools.ts @@ -32,7 +32,7 @@ import { TEAM_DELETE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/Tea import { EXECUTE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExecuteTool/constants.js' import { ENTER_WORKTREE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/EnterWorktreeTool/constants.js' import { EXIT_WORKTREE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitWorktreeTool/constants.js' -import { WORKFLOW_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/WorkflowTool/constants.js' +import { WORKFLOW_TOOL_NAME } from '@claude-code-best/workflow-engine' import { CRON_CREATE_TOOL_NAME, CRON_DELETE_TOOL_NAME, @@ -165,6 +165,11 @@ export const CORE_TOOLS = new Set([ LSP_TOOL_NAME, // 'LSP' // Skills SKILL_TOOL_NAME, // 'Skill' + // Workflow orchestration — first-class primitive /ultracode directs the + // model to call directly. Kept core (not deferred) so it's always visible + // and callable without a SearchExtraTools round-trip. Registration itself + // is still feature-gated (feature('WORKFLOW_SCRIPTS')) in tools.ts. + WORKFLOW_TOOL_NAME, // 'Workflow' // Scheduling & monitoring SLEEP_TOOL_NAME, // 'Sleep' // Tool discovery (always loaded) diff --git a/src/main.tsx b/src/main.tsx index b34b2b4a7..dd06bc1a7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -753,6 +753,15 @@ export async function main() { process.on('exit', () => { resetCursor(); + // 杀掉所有 running workflow,避免孤儿 task 留在 AppState 里 + try { + const { peekWorkflowService } = require('./workflow/service.js') as { + peekWorkflowService: () => { shutdown: () => void } | null; + }; + peekWorkflowService()?.shutdown(); + } catch { + // workflow 未启用或已卸载——忽略 + } }); process.on('SIGINT', () => { // In print mode, print.ts registers its own SIGINT handler that aborts diff --git a/src/skills/bundled/__tests__/ultracode.test.ts b/src/skills/bundled/__tests__/ultracode.test.ts new file mode 100644 index 000000000..3dd3bbf38 --- /dev/null +++ b/src/skills/bundled/__tests__/ultracode.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import type { PromptCommand } from '../../../types/command.js' +import { clearBundledSkills, getBundledSkills } from '../../bundledSkills.js' +import { registerUltracodeSkill } from '../ultracode.js' + +// Command is a union; source/getPromptForCommand only exist on the prompt +// variant. Narrow via type assertion once we've confirmed type === 'prompt'. +function asPrompt(c: { type: string }): PromptCommand { + return c as unknown as PromptCommand +} + +// bundledSkills is a process-global registry (per CLAUDE.md mock/state rules, +// module-level singletons leak across test files in one bun test process). +// Clear after each test so `ultracode` never leaks into other suites that +// enumerate registered skills (e.g. skill-search prefetch discovery). +afterEach(() => { + clearBundledSkills() +}) + +describe('registerUltracodeSkill', () => { + test('registers a user-invocable prompt command named ultracode', () => { + clearBundledSkills() + registerUltracodeSkill() + + const skills = getBundledSkills() + const ultracode = skills.find(s => s.name === 'ultracode') + expect(ultracode).toBeDefined() + expect(ultracode!.type).toBe('prompt') + expect(ultracode!.userInvocable).toBe(true) + expect(ultracode!.whenToUse).toBeTruthy() + expect(ultracode!.description).toContain('workflow') + const promptCmd = asPrompt(ultracode!) + expect(promptCmd.source).toBe('bundled') + }) + + test('getPromptForCommand injects the orchestration playbook with key sections', async () => { + clearBundledSkills() + registerUltracodeSkill() + + const ultracode = getBundledSkills().find(s => s.name === 'ultracode')! + const blocks = await asPrompt(ultracode).getPromptForCommand( + '', + {} as never, + ) + expect(blocks).toHaveLength(1) + expect(blocks[0]!.type).toBe('text') + + const text = (blocks[0] as { type: 'text'; text: string }).text + expect(text).toContain('编排原语') + expect(text).toContain('parallel') + expect(text).toContain('pipeline') + expect(text).toContain('resumeFromRunId') + expect(text).toContain('AgentAdapterRegistry') + expect(text).toContain('确定性约束') + // 脚本执行模型约束(非 ESM / 禁 import / 禁 TS / 单 export / 顶层 return) + expect(text).toContain('脚本编写约束') + expect(text).toContain('不转译 TS') + expect(text).toContain('禁 `import`') + }) + + test('appends user-provided args to the prompt when given', async () => { + clearBundledSkills() + registerUltracodeSkill() + + const ultracode = getBundledSkills().find(s => s.name === 'ultracode')! + const blocks = await asPrompt(ultracode).getPromptForCommand( + '迁移 auth 模块', + {} as never, + ) + const text = (blocks[0] as { type: 'text'; text: string }).text + expect(text.endsWith('迁移 auth 模块\n')).toBe(true) + expect(text).toContain('用户输入') + }) + + test('is not gated behind USER_TYPE — registers with no env set', () => { + // No USER_TYPE env is configured in this test process. If the skill were + // ant-gated (like stuck.ts), it would not appear here. + const previousUserType = process.env.USER_TYPE + delete process.env.USER_TYPE + clearBundledSkills() + registerUltracodeSkill() + + const skills = getBundledSkills() + expect(skills.some(s => s.name === 'ultracode')).toBe(true) + + // Restore so we never mutate the process env for other test files. + if (previousUserType === undefined) delete process.env.USER_TYPE + else process.env.USER_TYPE = previousUserType + }) +}) diff --git a/src/skills/bundled/index.ts b/src/skills/bundled/index.ts index a389894e3..eb8041d72 100644 --- a/src/skills/bundled/index.ts +++ b/src/skills/bundled/index.ts @@ -9,6 +9,7 @@ import { registerRememberSkill } from './remember.js' import { registerSimplifySkill } from './simplify.js' import { registerSkillifySkill } from './skillify.js' import { registerStuckSkill } from './stuck.js' +import { registerUltracodeSkill } from './ultracode.js' import { registerCronDeleteSkill, registerCronListSkill } from './cronManage.js' import { registerLoopSkill } from './loop.js' import { registerDreamSkill } from './dream.js' @@ -35,6 +36,7 @@ export function initBundledSkills(): void { registerSimplifySkill() registerBatchSkill() registerStuckSkill() + registerUltracodeSkill() registerLoopSkill() registerCronListSkill() registerCronDeleteSkill() diff --git a/src/skills/bundled/ultracode.ts b/src/skills/bundled/ultracode.ts new file mode 100644 index 000000000..52c65cd25 --- /dev/null +++ b/src/skills/bundled/ultracode.ts @@ -0,0 +1,104 @@ +import { registerBundledSkill } from '../bundledSkills.js' + +/** + * /ultracode — 多 agent workflow 编排工作法(纯知识 prompt skill)。 + * + * 调用即把 workflow 编排手册注入上下文,零运行时副作用:不改主循环、 + * 不切换行为开关。用户/模型据此判断何时用 Workflow 工具、如何编排、 + * 如何保证质量与可恢复。 + * + * 通用 skill(非 ant-only),所有用户可用。 + */ +const ULTRACODE_PROMPT = `# /ultracode — 多 agent workflow 编排工作法 + +## 何时用 Workflow 工具 + +用,当任务满足任一: +- 可**分解 / 并行**(多文件、多维度、可独立推进的子任务)。 +- 需要**多视角置信**(如审查:先生成再对抗式验证)。 +- **规模超单上下文**(大迁移、广度审计、长尾枚举)。 +- 需要 **resume / 可审计**(journal 重放、确定性回放)。 + +**不要用**:琐碎单文件改、单次问答、一次 Read 能解决的事——直接做。 + +## 编排原语(workflow 脚本内可用) + +- \`agent(prompt, opts?)\` — 派发一个子 agent;返回其最终文本,或(带 \`opts.schema\` 时)schema 校验对象。可在 opts 指定 \`model\`、\`agentType\`、\`label\`、\`phase\`、\`schema\`。 +- \`parallel([() => agent(...), ...])\` — 并发跑 thunk 数组,等全部完成。**单项抛错 → 该项变 \`null\`**,其余保留。是 barrier。 +- \`pipeline(items, stage1, stage2, …)\` — 每个 item 链式过各 stage;**item 间无 barrier**(item A 可在 stage 3 时 item B 仍在 stage 1),stage 内顺序。单 item 某 stage 抛错 → 该 item \`null\`。 +- \`phase(title)\` — 标记阶段(监控面板按此展示进度分组)。 +- \`log(msg)\` — 进度日志(面板展示,无状态变更)。 +- \`workflow(name | { scriptPath }, args?)\` — 嵌套一层子 workflow(**仅允许一层**)。 + +## 脚本编写约束(引擎执行模型,违反直接报错) + +脚本是 \`new AsyncFunction\` 的**函数体**,不是 ESM 模块,引擎**不转译 TS**。这是脚本报错的首要原因,务必遵守: + +- **禁 \`import\`**:\`agent\`/\`parallel\`/\`pipeline\`/\`phase\`/\`log\`/\`workflow\` 与 \`args\`/\`budget\` 是注入的形参,直接用,不 import 任何东西。 +- **禁 TS 语法**:不要类型注解(\`x: number\`)、\`interface\`、\`enum\`、\`as\`、泛型——即便文件扩展名是 \`.ts\`,引擎不转译会原样报语法错。**推荐 \`.js\` / \`.mjs\`**。 +- **只允许一处 \`export const meta = {...}\`**(纯字面量,引擎正则提取剥离);不要 \`export\` 其他任何东西,不要 \`export default\`。 +- **顶层 \`return\` 返回结果**(函数体内 return 合法且必需)。 + +\`\`\`js +// .claude/workflows/review-changes.js ← 纯 JS,无类型注解 +export const meta = { name: 'review-changes', description: '按维度审查改动' } + +const DIMENSIONS = [{ key: 'bugs' }, { key: 'perf' }] +const results = await pipeline( + DIMENSIONS, + d => agent(\`审查 \${d.key}\`, { phase: 'Review' }), + r => parallel(((r && r.findings) || []).map(f => () => agent(\`验证 \${f}\`))), +) +return results.flat().filter(Boolean) +\`\`\` + +## 确定性约束(关键,违反则 resume 失效) + +脚本内**禁用** \`Date.now()\` / \`Math.random()\` / 无参 \`new Date()\`(破坏 journal 重放)。 +需要时间戳 / 随机种子时,经 \`args\` 传入。\`export const meta = { ... }\` 必须是**纯字面量**(无变量、函数调用、模板插值)。 + +上限(引擎硬限):单次 \`parallel\`/\`pipeline\` ≤ **4096** items;单个 workflow 总 **≤ 1000** agent;并发 cap = \`min(16, cores - 2)\`。 + +## 质量模式(每种给最小片段) + +- **Adversarial verify**:\`parallel([() => agent(claim), () => agent(refute)])\`,多数 refute 即弃。 +- **Perspective-diverse verify**:同一发现给多个 verifier 不同 lens(正确性 / 安全 / 复现),红队冗余抓不到的失败模式。 +- **Judge panel**:N 个独立方案 → 评分 → 取胜者,嫁接亚军亮点。 +- **Loop-until-dry**:\`while (fresh.length) { found = await parallel(...); fresh = dedup(found) }\`,连续 K 轮无新增即停。 +- **Multi-modal sweep**:多个 agent 各用不同搜索角度(按容器 / 按内容 / 按实体 / 按时间),互不可见。 +- **Completeness critic**:末尾一个 agent 问"还缺什么",其发现成为下一轮工作。 + +## 后端路由 + +\`AgentAdapterRegistry\` v1 为单后端(默认 \`claude-code\`)。由后端**内部**按 \`model\` / \`agentType\` 深度解析当前会话的 provider / model / agent 体系(registry 本身可配路由规则,v1 未配,恒落默认)。例:\`agent({ model: 'claude-haiku-4-5', agentType: 'Explore' })\` 经默认后端命中真实 agent 定义。 + +## resume / budget + +- \`resumeFromRunId: ''\` — 重放该 run 的 journal,已完成的 \`agent()\` 秒回缓存结果;首个发散点之后全部现场重跑。 +- \`budget.total\` — token 硬顶(默认 \`null\` = 无限);\`budget.spent()\` / \`budget.remaining()\` 读实时消耗。耗尽后再发 agent 抛错。 + +## 文件与命令 + +- 脚本目录:\`.claude/workflows/.ts|.js|.mjs\` → 自动成 \`/\` 命令。 +- run 记录:\`.claude/workflow-runs//journal.jsonl\`。 +- 监控面板:\`/workflows\`(双栏:左 run 列表,右 phase + agent;键位 j/k 选中、r resume、x kill、n 新建提示、q 退出)。 +- 工具:\`Workflow\`(input 字段:\`script\` / \`name\` / \`scriptPath\` / \`args\` / \`resumeFromRunId\`)。 +` + +export function registerUltracodeSkill(): void { + registerBundledSkill({ + name: 'ultracode', + description: + '进入多 agent workflow 编排模式:何时用、编排原语、质量模式、确定性约束、后端路由、resume/budget、文件与命令。', + whenToUse: + '任务可分解/并行、需多视角置信、规模超单上下文、或需 resume/可审计时,用 Workflow 工具编排多个子 agent。', + userInvocable: true, + async getPromptForCommand(args) { + let prompt = ULTRACODE_PROMPT + if (args) { + prompt += `\n## 用户输入\n\n${args}\n` + } + return [{ type: 'text', text: prompt }] + }, + }) +} diff --git a/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts b/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts index b6755ba80..74ea7987d 100644 --- a/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts +++ b/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts @@ -22,6 +22,8 @@ export type LocalWorkflowTaskState = TaskStateBase & { agentCount?: number /** Captured output from workflow execution. */ output?: string + /** Failure reason surfaced to BackgroundTasksDialog (parallels RunProgress.error). */ + error?: string /** Agent that spawned this task. Used for orphan cleanup. */ agentId?: AgentId /** Abort controller for cancellation. */ @@ -96,6 +98,7 @@ export function completeWorkflowTask( export function failWorkflowTask( taskId: string, setAppState: SetAppState, + error?: string, ): void { updateTaskState(taskId, setAppState, task => ({ ...task, @@ -103,6 +106,7 @@ export function failWorkflowTask( endTime: Date.now(), notified: true, abortController: undefined, + ...(error !== undefined ? { error } : {}), })) } diff --git a/src/tasks/LocalWorkflowTask/__tests__/LocalWorkflowTask.test.ts b/src/tasks/LocalWorkflowTask/__tests__/LocalWorkflowTask.test.ts new file mode 100644 index 000000000..d3d589f7c --- /dev/null +++ b/src/tasks/LocalWorkflowTask/__tests__/LocalWorkflowTask.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, mock, test } from 'bun:test' +import { debugMock } from '../../../../tests/mocks/debug.js' +import { logMock } from '../../../../tests/mocks/log.js' + +// ─── Mocks(仅 mock 有副作用的依赖链)─── + +mock.module('src/utils/debug.ts', debugMock) +mock.module('src/utils/log.ts', logMock) + +mock.module('src/constants/xml.js', () => ({ + TASK_NOTIFICATION_TAG: 'task_notification', + TASK_ID_TAG: 'task_id', + TOOL_USE_ID_TAG: 'tool_use_id', + OUTPUT_FILE_TAG: 'output_file', + STATUS_TAG: 'status', + SUMMARY_TAG: 'summary', + WORKTREE_TAG: 'worktree', + WORKTREE_PATH_TAG: 'worktree_path', + WORKTREE_BRANCH_TAG: 'worktree_branch', + TASK_TYPE_TAG: 'task_type', +})) + +mock.module('src/utils/messageQueueManager.js', () => ({ + enqueuePendingNotification: () => {}, +})) + +mock.module('src/utils/sdkEventQueue.js', () => ({ + enqueueSdkEvent: () => {}, +})) + +mock.module('src/utils/task/diskOutput.js', () => ({ + getTaskOutputDelta: async () => null, + getTaskOutputPath: (id: string) => `/tmp/${id}`, + evictTaskOutput: () => {}, + initTaskOutputAsSymlink: async () => {}, +})) + +// ─── Import after mocks ─── + +const { registerLocalWorkflowTask, failWorkflowTask } = await import( + '../LocalWorkflowTask.js' +) + +// ─── Helpers ─── + +type AppStateLike = { tasks: Record } +type SetAppStateLike = (f: (prev: AppStateLike) => AppStateLike) => void + +function createSetState(): { + setAppState: SetAppStateLike + getState: () => AppStateLike +} { + let state: AppStateLike = { tasks: {} } + return { + setAppState: f => { + state = f(state) + }, + getState: () => state, + } +} + +// ─── Tests ─── + +describe('failWorkflowTask', () => { + test('保存 error 字符串到 state(供 BackgroundTasksDialog 显示失败原因)', () => { + const { setAppState, getState } = createSetState() + const taskId = registerLocalWorkflowTask(setAppState as any, { + description: 'test', + workflowName: 'wf', + workflowFile: '/tmp/wf.ts', + }) + failWorkflowTask(taskId, setAppState as any, 'agent X 抛 Error: boom') + const task = getState().tasks[taskId] + expect(task.status).toBe('failed') + expect(task.error).toBe('agent X 抛 Error: boom') + }) + + test('不传 error 时 state.error 保持 undefined(向后兼容现有调用)', () => { + const { setAppState, getState } = createSetState() + const taskId = registerLocalWorkflowTask(setAppState as any, { + description: 'test', + workflowName: 'wf', + workflowFile: '/tmp/wf.ts', + }) + failWorkflowTask(taskId, setAppState as any) + const task = getState().tasks[taskId] + expect(task.status).toBe('failed') + expect(task.error).toBeUndefined() + }) +}) diff --git a/src/tools.ts b/src/tools.ts index 08f26429b..98eb0f8b2 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -150,11 +150,7 @@ const ListPeersTool = feature('UDS_INBOX') .ListPeersTool : null const WorkflowTool = feature('WORKFLOW_SCRIPTS') - ? (() => { - require('@claude-code-best/builtin-tools/tools/WorkflowTool/bundled/index.js').initBundledWorkflows() - return require('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowTool.js') - .WorkflowTool - })() + ? require('./workflow/wiring.js').createWorkflowToolCore() : null /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ import type { ToolPermissionContext } from './Tool.js' diff --git a/src/utils/permissions/classifierDecision.ts b/src/utils/permissions/classifierDecision.ts index aa5150115..a3858adb6 100644 --- a/src/utils/permissions/classifierDecision.ts +++ b/src/utils/permissions/classifierDecision.ts @@ -42,7 +42,7 @@ const VERIFY_PLAN_EXECUTION_TOOL_NAME = : null const WORKFLOW_TOOL_NAME = feature('WORKFLOW_SCRIPTS') ? ( - require('@claude-code-best/builtin-tools/tools/WorkflowTool/constants.js') as typeof import('@claude-code-best/builtin-tools/tools/WorkflowTool/constants.js') + require('@claude-code-best/workflow-engine') as typeof import('@claude-code-best/workflow-engine') ).WORKFLOW_TOOL_NAME : null /* eslint-enable @typescript-eslint/no-require-imports */ diff --git a/packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx b/src/workflow/WorkflowPermissionRequest.tsx similarity index 97% rename from packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx rename to src/workflow/WorkflowPermissionRequest.tsx index 8d78a043e..c5c40b361 100644 --- a/packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx +++ b/src/workflow/WorkflowPermissionRequest.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import { Box, Text, useTheme } from '@anthropic/ink'; -import { getTheme } from 'src/utils/theme.js'; +import { getTheme, type Theme } from 'src/utils/theme.js'; import { env } from 'src/utils/env.js'; import { shouldShowAlwaysAllowOptions } from 'src/utils/permissions/permissionsLoader.js'; import { logUnaryEvent } from 'src/utils/unaryLogging.js'; @@ -132,7 +132,7 @@ export function WorkflowPermissionRequest({ - + Execute workflow: {input.workflow} {input.args && Arguments: {input.args}} diff --git a/src/workflow/__tests__/WorkflowsPanel.test.tsx b/src/workflow/__tests__/WorkflowsPanel.test.tsx new file mode 100644 index 000000000..c808420bd --- /dev/null +++ b/src/workflow/__tests__/WorkflowsPanel.test.tsx @@ -0,0 +1,100 @@ +import { expect, test } from 'bun:test'; +import React from 'react'; +import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js'; +import type { RunProgress } from '../progress/store.js'; +import { call as panelCall } from '../panel/panelCall.js'; +import { clampSelected, WorkflowsPanel } from '../panel/WorkflowsPanel.js'; +import { STATUS_DOT } from '../panel/status.js'; + +// 纯函数:选中夹紧到有效区间(与面板内 clampSelected 同源)。 +test('clampSelected:空列表→0;越界→末位;负/NaN→0;正常→原值', () => { + expect(clampSelected(5, 0)).toBe(0); + expect(clampSelected(5, 3)).toBe(2); + expect(clampSelected(-3, 3)).toBe(0); + expect(clampSelected(1, 3)).toBe(1); + expect(clampSelected(0, 1)).toBe(0); + // NaN(如未初始化状态)安全回落到 0 + expect(clampSelected(Number.NaN, 3)).toBe(0); +}); + +// STATUS_DOT 覆盖四种状态,且均为可见圆点字符。 +test('STATUS_DOT 覆盖 running/completed/failed/killed 且为非空字符', () => { + const statuses = ['running', 'completed', 'failed', 'killed'] as const; + for (const s of statuses) { + expect(STATUS_DOT[s]).toBeTruthy(); + expect(STATUS_DOT[s].length).toBeGreaterThan(0); + } +}); + +// 进度数据形态契约:面板读取的字段在典型 RunProgress 上存在/可读, +// 防止 store.ts 结构漂移悄悄破坏面板渲染。 +test('RunProgress 字段契约:面板读取的 key 均存在', () => { + const run: RunProgress = { + runId: 'r1', + workflowName: 'review', + status: 'running', + phases: [{ title: 'Find', status: 'done' }], + currentPhase: 'Review', + agents: [{ id: 1, label: 'review:api', phase: 'Review', status: 'running' }], + agentCount: 1, + updatedAt: 1, + }; + // 面板 WorkflowList/Detail 读取的路径 + expect(run.status).toBe('running'); + expect(STATUS_DOT[run.status]).toBe('●'); + expect(run.currentPhase).toBe('Review'); + expect(run.agents.length).toBe(run.agentCount); + expect(run.phases[0]?.title).toBe('Find'); + expect(run.phases[0]?.status).toBe('done'); + expect(run.agents[0]?.label).toBe('review:api'); +}); + +// 完成/失败形态:returnValue / error 在非 running 时才显示。 +test('RunProgress 完成/失败形态:returnValue/error 可选', () => { + const completed: RunProgress = { + runId: 'r2', + workflowName: 'w', + status: 'completed', + phases: [], + currentPhase: null, + agents: [], + agentCount: 0, + returnValue: 'ok', + updatedAt: 2, + }; + const failed: RunProgress = { + runId: 'r3', + workflowName: 'w', + status: 'failed', + phases: [], + currentPhase: null, + agents: [], + agentCount: 0, + error: 'boom', + updatedAt: 3, + }; + expect(completed.returnValue).toBe('ok'); + expect(completed.error).toBeUndefined(); + expect(failed.error).toBe('boom'); + expect(failed.returnValue).toBeUndefined(); + expect(STATUS_DOT['completed']).toBe('✓'); + expect(STATUS_DOT['failed']).toBe('✗'); +}); + +// 修复 M:useSyncExternalStore / listNamed / 子组件抛错时不应击穿 REPL。 +// panelCall 必须把 WorkflowsPanel 包在 SentryErrorBoundary 里。 +test('panelCall 用 SentryErrorBoundary 包裹 WorkflowsPanel(修复 M 回归)', async () => { + const element = (await (panelCall as unknown as (a: unknown, b: unknown, c: unknown) => Promise)( + () => {}, + { canUseTool: undefined }, + '', + )) as React.ReactElement<{ name?: string; children: React.ReactNode }>; + expect(element.type).toBe(SentryErrorBoundary); + expect(element.props.name).toBe('WorkflowsPanel'); + const child = element.props.children as React.ReactElement<{ + onDone: () => void; + }>; + expect(child.type).toBe(WorkflowsPanel); + expect(React.isValidElement(child)).toBe(true); + expect(typeof child.props.onDone).toBe('function'); +}); diff --git a/src/workflow/__tests__/claudeCodeBackend.test.ts b/src/workflow/__tests__/claudeCodeBackend.test.ts new file mode 100644 index 000000000..72219dafa --- /dev/null +++ b/src/workflow/__tests__/claudeCodeBackend.test.ts @@ -0,0 +1,142 @@ +import { expect, test, mock } from 'bun:test' + +// 注意:mock specifier 必须解析到 impl 实际 import 的同一模块(bun mock.module +// 按解析后模块匹配)。impl 用 '@claude-code-best/builtin-tools/...' 与 'src/*' 别名 +// 路径导入,此处用相同 specifier。 +mock.module( + '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', + () => ({ + runAgent: async function* () { + yield { + type: 'assistant', + message: { content: [{ type: 'text', text: 'agent-text' }] }, + } + }, + }), +) +mock.module( + '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js', + () => ({ + finalizeAgentTool: () => ({ + content: [{ type: 'text', text: 'agent-text' }], + usage: { output_tokens: 42 }, + totalTokens: 42, + }), + }), +) +mock.module( + '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js', + () => ({ + isBuiltInAgent: () => true, + }), +) +mock.module('src/tools.js', () => ({ assembleToolPool: () => ({ tools: [] }) })) +mock.module('src/utils/messages.js', () => ({ + createUserMessage: (o: { content: string }) => ({ + role: 'user', + content: o.content, + }), + extractTextContent: () => 'agent-text', +})) +mock.module('src/utils/uuid.js', () => ({ createAgentId: () => 'agent-1' })) +mock.module('src/services/analytics/index.js', () => ({ logEvent: () => {} })) +mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {} })) + +import { + claudeCodeBackend, + resolveAgentDefinition, + mapWorkflowModel, + extractStructuredOutput, + WORKFLOW_AGENT, +} from '../backends/claudeCodeBackend.js' +import { makeHostHandle } from '../hostHandle.js' + +function ctx() { + return { + host: makeHostHandle({ + toolUseContext: { + options: { + agentDefinitions: { activeAgents: [] }, + querySource: 'workflow', + mainLoopModel: 'm', + }, + getAppState: () => ({ + toolPermissionContext: { + mode: 'acceptEdits', + alwaysAllowRules: {}, + }, + mcp: { tools: [] }, + }), + } as never, + canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never, + // run() 不读 parentMessage;用空对象占位满足 WorkflowHostBundle 类型。 + parentMessage: {} as never, + }), + signal: new AbortController().signal, + runId: 'r1', + } +} + +test('文本 agent → ok + token 计量', async () => { + const res = await claudeCodeBackend.run({ prompt: 'do it' }, ctx()) + expect(res.kind).toBe('ok') + if (res.kind === 'ok') { + expect(res.output).toBe('agent-text') + expect(res.usage.outputTokens).toBe(42) + } +}) + +test('runAgent 抛错 → dead', async () => { + // 覆盖 mock 让 runAgent 抛(last-write-wins) + mock.module( + '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js', + () => ({ + // biome-ignore lint/correctness/useYield: 故意抛错以测试 dead 分支(不 yield) + runAgent: async function* () { + throw new Error('boom') + }, + }), + ) + const res = await claudeCodeBackend.run({ prompt: 'fail' }, ctx()) + expect(res.kind).toBe('dead') +}) + +test('id 与 capabilities 形状', () => { + expect(claudeCodeBackend.id).toBe('claude-code') + expect(claudeCodeBackend.capabilities.structuredOutput).toBe(true) + expect(claudeCodeBackend.capabilities.tools).toBe(true) +}) + +test('resolveAgentDefinition:无 agentType → WORKFLOW_AGENT 兜底', () => { + const tuc = { + options: { agentDefinitions: { activeAgents: [] } }, + } as never + expect(resolveAgentDefinition(undefined, tuc)).toBe(WORKFLOW_AGENT) +}) + +test('resolveAgentDefinition:命中 activeAgents', () => { + const fake = { agentType: 'Explore', permissionMode: 'plan' } as never + const tuc = { + options: { agentDefinitions: { activeAgents: [fake] } }, + } as never + expect(resolveAgentDefinition('Explore', tuc)).toBe(fake) + // 未命中仍兜底 + expect(resolveAgentDefinition('Nope', tuc)).toBe(WORKFLOW_AGENT) +}) + +test('mapWorkflowModel 直传', () => { + expect(mapWorkflowModel(undefined)).toBeUndefined() + expect(mapWorkflowModel('claude-haiku-*')).toBe('claude-haiku-*') +}) + +test('extractStructuredOutput:合法 JSON 提取;非法返回 null', () => { + expect( + extractStructuredOutput([ + { type: 'text', text: 'prefix {"a":1,"b":2} suffix' }, + ]), + ).toEqual({ a: 1, b: 2 }) + expect( + extractStructuredOutput([{ type: 'text', text: 'no json here' }]), + ).toBeNull() + expect(extractStructuredOutput([])).toBeNull() +}) diff --git a/src/workflow/__tests__/notifications.test.ts b/src/workflow/__tests__/notifications.test.ts new file mode 100644 index 000000000..5088e9852 --- /dev/null +++ b/src/workflow/__tests__/notifications.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from 'bun:test' +import type { RunProgress } from '../progress/store.js' +import type { WorkflowService } from '../service.js' + +function makeMockService(runs: RunProgress[]): { + service: WorkflowService + emit: () => void + setRuns: (runs: RunProgress[]) => void +} { + let current = runs + const listeners = new Set<() => void>() + return { + service: { + ports: {}, + launch: async () => ({ runId: 'x' }), + kill: () => {}, + listRuns: () => current, + getRun: () => undefined, + subscribe: (fn: () => void) => { + listeners.add(fn) + return () => { + listeners.delete(fn) + } + }, + listNamed: async () => [], + } as unknown as WorkflowService, + emit: () => { + for (const fn of listeners) fn() + }, + setRuns: r => { + current = r + }, + } +} + +function makeRun( + runId: string, + status: RunProgress['status'], + overrides: Partial = {}, +): RunProgress { + return { + runId, + workflowName: 'wf', + status, + phases: [], + declaredPhases: [], + currentPhase: null, + agents: [], + agentCount: 0, + updatedAt: Date.now(), + ...overrides, + } +} + +describe('installWorkflowNotifications', () => { + test('running → completed 触发通知(含 workflow 名)', async () => { + const { installWorkflowNotifications } = await import('../notifications.js') + const { service, emit, setRuns } = makeMockService([ + makeRun('r1', 'running'), + ]) + const calls: string[] = [] + const unsubscribe = installWorkflowNotifications(service, msg => + calls.push(msg), + ) + + // 第一次 emit:listener 记录初始 running 状态,不发通知 + emit() + expect(calls.length).toBe(0) + + setRuns([makeRun('r1', 'completed')]) + emit() + + expect(calls.length).toBe(1) + expect(calls[0]).toMatch(/task-notification/) + expect(calls[0]).toMatch(/completed successfully/) + expect(calls[0]).toMatch(/"wf"/) + unsubscribe() + }) + + test('running → failed 触发通知,含 error 文字', async () => { + const { installWorkflowNotifications } = await import('../notifications.js') + const { service, emit, setRuns } = makeMockService([ + makeRun('r1', 'running'), + ]) + const calls: string[] = [] + installWorkflowNotifications(service, msg => calls.push(msg)) + + emit() // 记录初始 running + setRuns([makeRun('r1', 'failed', { error: 'agent X boom' })]) + emit() + + expect(calls.length).toBe(1) + expect(calls[0]).toMatch(/failed/) + expect(calls[0]).toMatch(/agent X boom/) + }) + + test('running → killed 触发通知', async () => { + const { installWorkflowNotifications } = await import('../notifications.js') + const { service, emit, setRuns } = makeMockService([ + makeRun('r1', 'running'), + ]) + const calls: string[] = [] + installWorkflowNotifications(service, msg => calls.push(msg)) + + emit() // 记录初始 running + setRuns([makeRun('r1', 'killed')]) + emit() + + expect(calls.length).toBe(1) + expect(calls[0]).toMatch(/was stopped/) + }) + + test('初次见到 run(无 prev)不发通知(避免启动时通知历史 run)', async () => { + const { installWorkflowNotifications } = await import('../notifications.js') + const { service, emit, setRuns } = makeMockService([]) + const calls: string[] = [] + installWorkflowNotifications(service, msg => calls.push(msg)) + + // 启动后第一次 emit,看到 r1 已 completed——不应通知(不是从 running 转换来) + setRuns([makeRun('r1', 'completed')]) + emit() + + expect(calls.length).toBe(0) + }) + + test('running → running 不发通知', async () => { + const { installWorkflowNotifications } = await import('../notifications.js') + const { service, emit, setRuns } = makeMockService([ + makeRun('r1', 'running'), + ]) + const calls: string[] = [] + installWorkflowNotifications(service, msg => calls.push(msg)) + + emit() // 记录初始 running + setRuns([makeRun('r1', 'running', { agentCount: 1 })]) + emit() + + expect(calls.length).toBe(0) + }) + + test('已 completed 的 run 再次 emit 不重复通知', async () => { + const { installWorkflowNotifications } = await import('../notifications.js') + const { service, emit, setRuns } = makeMockService([ + makeRun('r1', 'running'), + ]) + const calls: string[] = [] + installWorkflowNotifications(service, msg => calls.push(msg)) + + emit() // 记录初始 running + setRuns([makeRun('r1', 'completed')]) + emit() + expect(calls.length).toBe(1) + + emit() + expect(calls.length).toBe(1) + }) + + test('unsubscribe 后不再发通知', async () => { + const { installWorkflowNotifications } = await import('../notifications.js') + const { service, emit, setRuns } = makeMockService([ + makeRun('r1', 'running'), + ]) + const calls: string[] = [] + const unsubscribe = installWorkflowNotifications(service, msg => + calls.push(msg), + ) + + emit() // 记录初始 running + unsubscribe() + setRuns([makeRun('r1', 'completed')]) + emit() + + expect(calls.length).toBe(0) + }) +}) diff --git a/src/workflow/__tests__/ports.test.ts b/src/workflow/__tests__/ports.test.ts new file mode 100644 index 000000000..95847da6c --- /dev/null +++ b/src/workflow/__tests__/ports.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from 'bun:test' +// 注意:本测试不 mock bootstrap/state、utils/cwd、analytics、debug。 +// 原因:mock.module 是进程全局的(last-write-wins),mock 这些公共模块会污染 +// 同进程其他测试(如 src/commands/__tests__/autonomy.test.ts 经其依赖链 import +// 真实 bootstrap/state)。ports 在测试环境下能正常解析 getProjectRoot/getCwd, +// logEvent/logForDebugging 在 sink 未 attach 时为静默 no-op,无需 mock。 + +import { buildRegistry } from '../registry.js' +import { createWorkflowPorts } from '../ports.js' +import { createProgressBus } from '../progress/bus.js' +import { createProgressStoreFromBus } from '../progress/store.js' +import { getProjectRoot } from '../../bootstrap/state.js' +import type { SetAppState } from '../../Task.js' +import type { AppState } from '../../state/AppState.tsx' + +test('buildRegistry 注册 claude-code 为默认且 resolve 命中', () => { + const reg = buildRegistry() + expect(reg.has('claude-code')).toBe(true) + expect(reg.resolve({ prompt: 'x' }).id).toBe('claude-code') + expect(reg.resolve({ prompt: 'x', agentType: 'whatever' }).id).toBe( + 'claude-code', + ) +}) + +test('createWorkflowPorts 组装完整端口(含 agentAdapterRegistry 与 progressEmitter→bus)', () => { + const bus = createProgressBus() + const store = createProgressStoreFromBus(bus) + const ports = createWorkflowPorts({ bus, store }) + + expect(ports.agentAdapterRegistry).toBeDefined() + expect(ports.agentAdapterRegistry!.resolve({ prompt: 'x' }).id).toBe( + 'claude-code', + ) + expect(typeof ports.taskRegistrar.register).toBe('function') + expect(typeof ports.taskRegistrar.kill).toBe('function') + expect(typeof ports.hostFactory).toBe('function') + // agentRunner 兜底字段仍存在(WorkflowPorts 必填) + expect(ports.agentRunner).toBeDefined() + expect(typeof ports.agentRunner.runAgentToResult).toBe('function') + + // progressEmitter 经 bus → store:发一个 run_started,store 能看到 + ports.progressEmitter.emit({ + type: 'run_started', + runId: 't', + workflowName: 'w', + meta: null, + }) + expect(store.get('t')?.workflowName).toBe('w') +}) + +test('taskRegistrar.register/complete/kill 经 RunBinding 路由(真 setAppState,无 mock)', () => { + const bus = createProgressBus() + const store = createProgressStoreFromBus(bus) + const ports = createWorkflowPorts({ bus, store }) + + // 真 setAppState:用一个本地 AppState 对象承载 tasks,registerTask 走真实代码路径。 + const state = { tasks: {} } as unknown as AppState + const setAppState: SetAppState = f => { + Object.assign(state, f(state)) + } + + const hostCtx = ports.hostFactory({ + context: { + agentId: 'a-1', + toolUseId: 'tu-1', + setAppState, + }, + canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never, + parentMessage: {} as never, + }) + + const { runId, signal } = ports.taskRegistrar.register( + { + workflowName: 'wf', + summary: 'summary', + workflowFile: 'wf.ts', + toolUseId: 'tu-1', + }, + hostCtx.handle, + ) + expect(typeof runId).toBe('string') + expect(signal).toBeInstanceOf(AbortSignal) + + // complete/fail/kill 不抛(RunBinding 命中) + expect(() => ports.taskRegistrar.complete(runId, 'done')).not.toThrow() + expect(() => ports.taskRegistrar.kill(runId)).not.toThrow() + // 未知 runId 安全 no-op + expect(() => ports.taskRegistrar.complete('nope')).not.toThrow() + expect(ports.taskRegistrar.pendingAction('nope')).toBeNull() + + // 终态后 binding 回收:再次 complete 同 runId 应安全 no-op(不抛错、不重复调用 workflow task fn) + ports.taskRegistrar.complete(runId) + ports.taskRegistrar.kill(runId) +}) + +test('hostFactory.cwd 与 journalStore 同根(getProjectRoot)—— 修复 K 回归', () => { + // 历史 bug:hostFactory.cwd 用 getCwd()、journalStore 用 getProjectRoot(), + // 用户进入 worktree/子目录时两者不同 → 命名 workflow 解析与 journal 落盘不同步。 + // 修复后两者都用 projectRoot,本测试 lock-in 该选择,防止回归。 + const bus = createProgressBus() + const store = createProgressStoreFromBus(bus) + const ports = createWorkflowPorts({ bus, store }) + const hostCtx = ports.hostFactory({ + context: { agentId: 'a', toolUseId: 'tu' }, + canUseTool: (() => Promise.resolve({ behavior: 'allow' })) as never, + parentMessage: {} as never, + }) + expect(hostCtx.cwd).toBe(getProjectRoot()) +}) diff --git a/src/workflow/__tests__/progressBus.test.ts b/src/workflow/__tests__/progressBus.test.ts new file mode 100644 index 000000000..7109b025f --- /dev/null +++ b/src/workflow/__tests__/progressBus.test.ts @@ -0,0 +1,23 @@ +import { expect, test, mock } from 'bun:test' +import { createProgressBus } from '../progress/bus.js' + +test('emit 广播给所有订阅者', () => { + const bus = createProgressBus() + const a = mock(() => {}) + const b = mock(() => {}) + bus.subscribe(a) + bus.subscribe(b) + const ev = { type: 'log' as const, runId: 'r', message: 'hi' } + bus.emit(ev) + expect(a).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledWith(ev) +}) + +test('subscribe 返回取消订阅', () => { + const bus = createProgressBus() + const fn = mock(() => {}) + const unsub = bus.subscribe(fn) + unsub() + bus.emit({ type: 'log', runId: 'r', message: 'x' }) + expect(fn).not.toHaveBeenCalled() +}) diff --git a/src/workflow/__tests__/progressStore.test.ts b/src/workflow/__tests__/progressStore.test.ts new file mode 100644 index 000000000..ee9b108af --- /dev/null +++ b/src/workflow/__tests__/progressStore.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from 'bun:test' +import { createProgressBus, type ProgressBus } from '../progress/bus.js' +import { createProgressStoreFromBus } from '../progress/store.js' +import type { AgentRunResult } from '@claude-code-best/workflow-engine' + +const ok = (o: string): AgentRunResult => ({ + kind: 'ok', + output: o, + usage: { outputTokens: 1 }, +}) + +function newStore() { + const bus: ProgressBus = createProgressBus() + return { bus, store: createProgressStoreFromBus(bus) } +} + +test('run_started 建条目;phase_started/done 更新 phases', () => { + const { bus, store } = newStore() + bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) + bus.emit({ type: 'phase_started', runId: 'r1', phase: 'A' }) + bus.emit({ type: 'phase_started', runId: 'r1', phase: 'B' }) + bus.emit({ type: 'phase_done', runId: 'r1', phase: 'A' }) + const r = store.get('r1')! + expect(r.phases.map(p => [p.title, p.status])).toEqual([ + ['A', 'done'], + ['B', 'running'], + ]) + expect(r.currentPhase).toBe('B') +}) + +test('并发 agent_done 按 agentId 精确关联(回归旧 LIFO 竞态)', () => { + const { bus, store } = newStore() + bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) + bus.emit({ + type: 'agent_started', + runId: 'r1', + agentId: 0, + label: 'a', + phase: 'A', + }) + bus.emit({ + type: 'agent_started', + runId: 'r1', + agentId: 1, + label: 'b', + phase: 'A', + }) + bus.emit({ + type: 'agent_done', + runId: 'r1', + agentId: 1, + label: 'b', + phase: 'A', + result: ok('b-out'), + }) + bus.emit({ + type: 'agent_done', + runId: 'r1', + agentId: 0, + label: 'a', + phase: 'A', + result: ok('a-out'), + }) + const agents = store.get('r1')!.agents + expect(agents.find(x => x.id === 0)?.status).toBe('done') + expect(agents.find(x => x.id === 1)?.status).toBe('done') + expect(agents.find(x => x.id === 0)?.label).toBe('a') + expect(agents.find(x => x.id === 1)?.label).toBe('b') +}) + +test('journal 命中(仅 agent_done 无 started)按 id 补建 done 条目', () => { + const { bus, store } = newStore() + bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) + bus.emit({ + type: 'agent_done', + runId: 'r1', + agentId: 7, + label: 'c', + phase: 'A', + result: ok('c'), + }) + const a = store.get('r1')!.agents.find(x => x.id === 7)! + expect(a.status).toBe('done') +}) + +test('run_done 终态 + list 排序 + subscribe 通知', () => { + const { bus, store } = newStore() + let calls = 0 + store.subscribe(() => calls++) + bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) + bus.emit({ + type: 'run_done', + runId: 'r1', + status: 'completed', + returnValue: 42, + }) + const r = store.get('r1')! + expect(r.status).toBe('completed') + expect(r.returnValue).toBe(42) + expect(store.list().map(x => x.runId)).toEqual(['r1']) + expect(calls).toBe(2) +}) + +test('run_done failed 终态记录 error', () => { + const { bus, store } = newStore() + bus.emit({ type: 'run_started', runId: 'r2', workflowName: 'w', meta: null }) + bus.emit({ type: 'run_done', runId: 'r2', status: 'failed', error: 'boom' }) + const r = store.get('r2')! + expect(r.status).toBe('failed') + expect(r.error).toBe('boom') +}) + +test('log 事件不触发 notify', () => { + const { bus, store } = newStore() + let calls = 0 + store.subscribe(() => calls++) + bus.emit({ type: 'run_started', runId: 'r3', workflowName: 'w', meta: null }) + const before = calls + bus.emit({ type: 'log', runId: 'r3', message: 'hi' }) + expect(calls).toBe(before) // log 不应触发 notify +}) + +test('run_started 落地 declaredPhases(来自 meta.phases,顺序保留)', () => { + const { bus, store } = newStore() + bus.emit({ + type: 'run_started', + runId: 'r1', + workflowName: 'w', + meta: { + name: 'w', + description: 'd', + phases: [{ title: 'Find' }, { title: 'Review' }, { title: 'Verify' }], + }, + }) + expect(store.get('r1')!.declaredPhases).toEqual(['Find', 'Review', 'Verify']) +}) + +test('run_started meta 为 null → declaredPhases = []', () => { + const { bus, store } = newStore() + bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) + expect(store.get('r1')!.declaredPhases).toEqual([]) +}) + +test('agent_done 落地 outputShape(ok·object / ok·text / dead 无)', () => { + const { bus, store } = newStore() + bus.emit({ type: 'run_started', runId: 'r1', workflowName: 'w', meta: null }) + bus.emit({ type: 'agent_started', runId: 'r1', agentId: 0, phase: 'A' }) + bus.emit({ type: 'agent_started', runId: 'r1', agentId: 1, phase: 'A' }) + bus.emit({ type: 'agent_started', runId: 'r1', agentId: 2, phase: 'A' }) + bus.emit({ + type: 'agent_done', + runId: 'r1', + agentId: 0, + phase: 'A', + result: { kind: 'ok', output: { x: 1 }, usage: { outputTokens: 1 } }, + }) + bus.emit({ + type: 'agent_done', + runId: 'r1', + agentId: 1, + phase: 'A', + result: { kind: 'ok', output: 'hi', usage: { outputTokens: 1 } }, + }) + bus.emit({ + type: 'agent_done', + runId: 'r1', + agentId: 2, + phase: 'A', + result: { kind: 'dead' }, + }) + const agents = store.get('r1')!.agents + expect(agents.find(a => a.id === 0)?.outputShape).toBe('object') + expect(agents.find(a => a.id === 1)?.outputShape).toBe('text') + expect(agents.find(a => a.id === 2)?.outputShape).toBeUndefined() +}) diff --git a/src/workflow/__tests__/selectors.test.ts b/src/workflow/__tests__/selectors.test.ts new file mode 100644 index 000000000..5fb09aac2 --- /dev/null +++ b/src/workflow/__tests__/selectors.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from 'bun:test' +import type { AgentProgress, RunProgress } from '../progress/store.js' +import { + ALL_PHASE, + mergePhases, + filterAgentsByPhase, + tabLabel, +} from '../panel/selectors.js' + +function run(partial: Partial): RunProgress { + return { + runId: 'r1', + workflowName: 'w', + status: 'running', + phases: [], + declaredPhases: [], + currentPhase: null, + agents: [], + agentCount: 0, + updatedAt: 1, + ...partial, + } +} + +test('mergePhases:声明顺序优先,实际 phase 追加未声明的,计数 done/total', () => { + const r = run({ + declaredPhases: ['Find', 'Review', 'Verify'], + phases: [ + { title: 'Find', status: 'done' }, + { title: 'Review', status: 'running' }, + ], + agents: [ + { + id: 1, + phase: 'Find', + status: 'done', + resultKind: 'ok', + outputShape: 'text', + }, + { id: 2, phase: 'Find', status: 'done', resultKind: 'dead' }, + { id: 3, phase: 'Review', status: 'running' }, + ], + }) + expect(mergePhases(r)).toEqual([ + { title: 'Find', status: 'done', done: 2, total: 2 }, + { title: 'Review', status: 'running', done: 0, total: 1 }, + { title: 'Verify', status: 'pending', done: 0, total: 0 }, + ]) +}) + +test('mergePhases:实际出现但未声明的 phase 追加到末尾', () => { + const r = run({ + declaredPhases: ['Find'], + phases: [ + { title: 'Find', status: 'done' }, + { title: 'Adhoc', status: 'running' }, + ], + agents: [], + }) + expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc']) +}) + +test('filterAgentsByPhase:All / undefined → 全部;指定 → 仅该 phase', () => { + const agents: AgentProgress[] = [ + { id: 1, phase: 'A', status: 'running' }, + { + id: 2, + phase: 'B', + status: 'done', + resultKind: 'ok', + outputShape: 'text', + }, + ] + expect(filterAgentsByPhase(agents, undefined)).toHaveLength(2) + expect(filterAgentsByPhase(agents, ALL_PHASE)).toHaveLength(2) + expect(filterAgentsByPhase(agents, 'A')).toEqual([agents[0]]) +}) + +test('tabLabel:workflow 名 + runId 后 4 位短码', () => { + expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def') +}) diff --git a/src/workflow/__tests__/service.test.ts b/src/workflow/__tests__/service.test.ts new file mode 100644 index 000000000..15353b862 --- /dev/null +++ b/src/workflow/__tests__/service.test.ts @@ -0,0 +1,335 @@ +import { expect, test } from 'bun:test' +// DI 模式:不使用 mock.module(进程全局、last-write-wins,会污染同进程其他测试如 +// autonomy.test.ts)。改为手工构造 FAKE WorkflowPorts:registry.run 返回固定 ok +// 结果,taskRegistrar 维护 abort 绑定,journalStore 内存空实现。真实 runWorkflow +// 因此跑完且无需 LLM 或 mock。 + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { makeService, __resetWorkflowServiceForTests } from '../service.js' +import { createProgressBus } from '../progress/bus.js' +import { createProgressStoreFromBus } from '../progress/store.js' +import type { + AgentRunResult, + ProgressEvent, + WorkflowPorts, +} from '@claude-code-best/workflow-engine' + +// 构造 FAKE ports:registry.run 返回固定 AgentRunResult,taskRegistrar 带 binding, +// journalStore 内存空实现。progressEmitter.emit → bus.emit(store 已在构造时订阅 bus)。 +// 注意:runWorkflow 自身会发 run_started/run_done;taskRegistrar 只管 abort 绑定, +// 不重复发事件(避免 store reducer 收到重复 run_done)。 +type RegistrarCall = + | { kind: 'complete'; runId: string; summary?: string } + | { kind: 'fail'; runId: string; error?: string } + | { kind: 'kill'; runId: string } + +function fakePorts( + opts: { + /** adapter.run 抛错(模拟 agent 后端崩溃)。 */ + adapterThrow?: string + /** adapter.run 返回值(默认 ok)。 */ + adapterResult?: AgentRunResult + /** agentRunner.runAgentToResult 返回值(fallback 路径,默认 throw)。 */ + runnerResult?: AgentRunResult + } = {}, +): { + ports: WorkflowPorts + store: ReturnType + killed: string[] + /** taskRegistrar 调用记录(complete/fail/kill)。 */ + calls: RegistrarCall[] +} { + const bus = createProgressBus() + const store = createProgressStoreFromBus(bus) + const killed: string[] = [] + const calls: RegistrarCall[] = [] + const bindings = new Map() + let seq = 0 + const ports = { + // hostFactory 实际不被 service.launch 路径调用(service 自建 host handle), + // 但 WorkflowPorts 类型要求存在;保留一个最小实现。 + hostFactory: () => ({ + handle: {} as never, + cwd: '/tmp', + budgetTotal: null, + toolUseId: 'tu', + }), + agentAdapterRegistry: { + resolve: () => ({ + id: 'claude-code', + capabilities: { structuredOutput: true }, + run: + opts.adapterThrow !== undefined + ? async (): Promise => { + throw new Error(opts.adapterThrow) + } + : async (): Promise => + opts.adapterResult ?? { + kind: 'ok', + output: 'mock-out', + usage: { outputTokens: 1 }, + }, + }), + }, + agentRunner: { + runAgentToResult: + opts.runnerResult !== undefined + ? async () => opts.runnerResult + : async () => { + throw new Error('should not reach') + }, + }, + progressEmitter: { + emit: (e: ProgressEvent) => bus.emit(e), + }, + taskRegistrar: { + register: ({ workflowName }: { workflowName: string }) => { + const abort = new AbortController() + seq += 1 + const runId = `run-${seq}` + bindings.set(runId, { abort }) + return { runId, signal: abort.signal } + }, + complete: (runId: string, summary?: string) => { + calls.push({ kind: 'complete', runId, summary }) + }, + fail: (runId: string, error?: string) => { + calls.push({ kind: 'fail', runId, error }) + }, + kill: (runId: string) => { + killed.push(runId) + calls.push({ kind: 'kill', runId }) + bindings.get(runId)?.abort.abort() + }, + pendingAction: () => null, + }, + journalStore: { + read: async () => [], + append: async () => {}, + truncate: async () => {}, + }, + permissionGate: { isAborted: () => false }, + logger: { + debug: () => {}, + event: () => {}, + warn: () => {}, + }, + } as unknown as WorkflowPorts + return { ports, store, killed, calls } +} + +const stubTUC = { agentId: 'a1', toolUseId: 'tu' } as never +const stubCanUseTool = (() => Promise.resolve({ behavior: 'allow' })) as never + +/** 等待 detached runWorkflow 完成(detached 调用,需让微任务/宏任务排空)。 */ +async function settle(): Promise { + await new Promise(r => setTimeout(r, 60)) +} + +test('launch → completed;store 出现该 run', async () => { + __resetWorkflowServiceForTests() + const { ports, store } = fakePorts() + const svc = makeService(ports, store) + const { runId } = await svc.launch( + { script: `return agent('compute')` }, + stubTUC, + stubCanUseTool, + ) + await settle() + const r = svc.getRun(runId) + expect(r).toBeDefined() + // detached 执行可能在 settle 窗口内仍 running,或已 completed——两者皆可接受。 + expect(['completed', 'running']).toContain(r!.status) + expect(r!.workflowName).toBe('workflow') +}) + +test('kill 走 taskRegistrar.kill', async () => { + __resetWorkflowServiceForTests() + const { ports, store, killed } = fakePorts() + const svc = makeService(ports, store) + const { runId } = await svc.launch( + { script: `return agent('x')` }, + stubTUC, + stubCanUseTool, + ) + svc.kill(runId) + expect(killed).toContain(runId) +}) + +test('listRuns/subscribe 来自 store', () => { + __resetWorkflowServiceForTests() + const { ports, store } = fakePorts() + const svc = makeService(ports, store) + expect(svc.listRuns()).toEqual([]) + let n = 0 + const unsub = svc.subscribe(() => { + n++ + }) + expect(typeof unsub).toBe('function') + unsub() + expect(n).toBe(0) +}) + +test('listNamed 委托 namedWorkflows(空目录→[];有文件→列出)', async () => { + __resetWorkflowServiceForTests() + const { ports, store } = fakePorts() + const svc = makeService(ports, store) + // 不存在的目录 → [] + const empty = await svc.listNamed( + join(tmpdir(), `wf-nope-${Math.random().toString(36).slice(2)}`), + ) + expect(empty).toEqual([]) + // 有命名文件的目录 → 列出 name(去扩展名,排序) + const dir = await mkdtemp(join(tmpdir(), 'wf-named-')) + try { + await writeFile( + join(dir, 'a.ts'), + 'export const meta = { name: "a", description: "d" }\nreturn 1', + ) + await writeFile(join(dir, 'b.js'), 'return 2') + const names = await svc.listNamed(dir) + expect(names).toEqual(['a', 'b']) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('缺 script/name/scriptPath → 抛错', async () => { + __resetWorkflowServiceForTests() + const { ports, store } = fakePorts() + const svc = makeService(ports, store) + await expect(svc.launch({}, stubTUC, stubCanUseTool)).rejects.toThrow( + /script|name|scriptPath/, + ) +}) + +test('scriptPath 读取文件内容并校验', async () => { + __resetWorkflowServiceForTests() + const { ports, store } = fakePorts() + const svc = makeService(ports, store) + const dir = await mkdtemp(join(tmpdir(), 'wf-path-')) + const file = join(dir, 's.ts') + try { + await writeFile(file, `return agent('from-file')`) + const { runId } = await svc.launch( + { scriptPath: file }, + stubTUC, + stubCanUseTool, + ) + await settle() + const r = svc.getRun(runId) + expect(r).toBeDefined() + expect(['completed', 'running']).toContain(r!.status) + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) + +test('parseScript 校验失败 → launch 抛错', async () => { + __resetWorkflowServiceForTests() + const { ports, store } = fakePorts() + const svc = makeService(ports, store) + // 触发 ScriptError:meta 字面量缺 description(validateMeta 要求 name+description 均为字符串) + await expect( + svc.launch( + { script: `export const meta = { name: "x" }\nreturn 1` }, + stubTUC, + stubCanUseTool, + ), + ).rejects.toThrow(/校验失败/) +}) + +// ---- 服务层失败路由覆盖(审查 gap:.then/.catch → taskRegistrar 路径)---- + +test('脚本运行抛错 → service 路由到 taskRegistrar.fail,带 error 文本', async () => { + __resetWorkflowServiceForTests() + const { ports, store, calls } = fakePorts() + const svc = makeService(ports, store) + await svc.launch( + { script: `throw new Error('script boom')` }, + stubTUC, + stubCanUseTool, + ) + await settle() + const fail = calls.find(c => c.kind === 'fail') + expect(fail).toBeDefined() + expect(fail?.kind === 'fail' && fail.error).toMatch(/script boom/) +}) + +test('adapter 抛错 → service 通过 .catch 路径路由到 taskRegistrar.fail', async () => { + __resetWorkflowServiceForTests() + const { ports, store, calls } = fakePorts({ adapterThrow: 'adapter boom' }) + const svc = makeService(ports, store) + await svc.launch({ script: `return agent('x')` }, stubTUC, stubCanUseTool) + await settle() + const fail = calls.find(c => c.kind === 'fail') + expect(fail).toBeDefined() + // adapter throw → runWorkflow 的内部 try/catch 转 failed status,error 透传; + // 或透传到 detached promise 的 .catch。两者最终都进 taskRegistrar.fail。 + expect(fail?.kind === 'fail' && fail.error).toMatch(/adapter boom/) +}) + +test('脚本正常完成 → service 路由到 taskRegistrar.complete', async () => { + __resetWorkflowServiceForTests() + const { ports, store, calls } = fakePorts() + const svc = makeService(ports, store) + await svc.launch({ script: `return agent('x')` }, stubTUC, stubCanUseTool) + await settle() + expect(calls.some(c => c.kind === 'complete')).toBe(true) +}) + +// ---- 修复 N:shutdown 清理 ---- + +test('shutdown 杀掉所有 running run(taskRegistrar.kill 调用每个)', async () => { + __resetWorkflowServiceForTests() + const { ports, store, killed } = fakePorts() + // 让 adapter 慢一点,settle 期间 run 仍在 running + const slowPorts = { + ...ports, + agentAdapterRegistry: { + resolve: () => ({ + id: 'claude-code', + capabilities: { structuredOutput: true }, + run: async (): Promise => { + await new Promise(r => setTimeout(r, 200)) + return { kind: 'ok', output: 'slow', usage: { outputTokens: 1 } } + }, + }), + }, + } as unknown as typeof ports + const slowSvc = makeService(slowPorts, store) + const { runId: a } = await slowSvc.launch( + { script: `return agent('a')` }, + stubTUC, + stubCanUseTool, + ) + const { runId: b } = await slowSvc.launch( + { script: `return agent('b')` }, + stubTUC, + stubCanUseTool, + ) + killed.length = 0 + slowSvc.shutdown() + expect(killed).toContain(a) + expect(killed).toContain(b) +}) + +test('shutdown 不重复杀已完成 run;幂等(多次调用安全)', async () => { + __resetWorkflowServiceForTests() + const { ports, store, killed } = fakePorts() + const svc = makeService(ports, store) + const { runId } = await svc.launch( + { script: `return agent('x')` }, + stubTUC, + stubCanUseTool, + ) + await settle() // 完成 + killed.length = 0 + svc.shutdown() + // 已完成的不应再被 kill + expect(killed).not.toContain(runId) + // 幂等 + expect(() => svc.shutdown()).not.toThrow() +}) diff --git a/src/workflow/__tests__/status.test.ts b/src/workflow/__tests__/status.test.ts new file mode 100644 index 000000000..8592674b5 --- /dev/null +++ b/src/workflow/__tests__/status.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'bun:test' +import type { AgentProgress, RunProgress } from '../progress/store.js' +import { + STATUS_DOT, + RUN_STATUS_COLOR, + PHASE_MARK, + PHASE_COLOR, + agentVisual, +} from '../panel/status.js' + +test('STATUS_DOT / RUN_STATUS_COLOR 覆盖四种 run 状态且为非空字符', () => { + const statuses: RunProgress['status'][] = [ + 'running', + 'completed', + 'failed', + 'killed', + ] + for (const s of statuses) { + expect(STATUS_DOT[s].length).toBeGreaterThan(0) + expect(RUN_STATUS_COLOR[s]).toBeTruthy() + } + expect(STATUS_DOT.running).toBe('●') + expect(STATUS_DOT.completed).toBe('✓') + expect(STATUS_DOT.failed).toBe('✗') + expect(STATUS_DOT.killed).toBe('■') +}) + +test('PHASE_MARK / PHASE_COLOR 覆盖 running/done/pending', () => { + expect(PHASE_MARK.running).toBe('●') + expect(PHASE_MARK.done).toBe('✓') + expect(PHASE_MARK.pending).toBe('○') + expect(PHASE_COLOR.pending).toBe('subtle') +}) + +test('agentVisual:running → ● warning running', () => { + const a: AgentProgress = { id: 1, status: 'running' } + expect(agentVisual(a)).toEqual({ + mark: '●', + color: 'warning', + suffix: 'running', + }) +}) + +test('agentVisual:done·object → ✓ success object', () => { + const a: AgentProgress = { + id: 1, + status: 'done', + resultKind: 'ok', + outputShape: 'object', + } + expect(agentVisual(a)).toEqual({ + mark: '✓', + color: 'success', + suffix: 'object', + }) +}) + +test('agentVisual:done·text → ✓ success text', () => { + const a: AgentProgress = { + id: 1, + status: 'done', + resultKind: 'ok', + outputShape: 'text', + } + expect(agentVisual(a)).toEqual({ + mark: '✓', + color: 'success', + suffix: 'text', + }) +}) + +test('agentVisual:dead → ✗ error dead', () => { + const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' } + expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' }) +}) diff --git a/src/workflow/__tests__/useWorkflowKeyboard.test.ts b/src/workflow/__tests__/useWorkflowKeyboard.test.ts new file mode 100644 index 000000000..9ea8fa55e --- /dev/null +++ b/src/workflow/__tests__/useWorkflowKeyboard.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'bun:test' +import { routeWorkflowKey } from '../panel/useWorkflowKeyboard.js' + +test('Tab → nextTab;Shift+Tab → prevTab', () => { + expect(routeWorkflowKey('', { tab: true })).toBe('nextTab') + expect(routeWorkflowKey('', { tab: true, shift: true })).toBe('prevTab') +}) + +test('q / Esc → quit', () => { + expect(routeWorkflowKey('q', {})).toBe('quit') + expect(routeWorkflowKey('', { escape: true })).toBe('quit') +}) + +test('x → kill;r → resume;n → newRun', () => { + expect(routeWorkflowKey('x', {})).toBe('kill') + expect(routeWorkflowKey('r', {})).toBe('resume') + expect(routeWorkflowKey('n', {})).toBe('newRun') +}) + +test('←/→ 切焦点列;↑/↓ 列内移动', () => { + expect(routeWorkflowKey('', { leftArrow: true })).toBe('focusLeft') + expect(routeWorkflowKey('', { rightArrow: true })).toBe('focusRight') + expect(routeWorkflowKey('', { upArrow: true })).toBe('moveUp') + expect(routeWorkflowKey('', { downArrow: true })).toBe('moveDown') +}) + +test('无关输入 → null', () => { + expect(routeWorkflowKey('z', {})).toBeNull() + expect(routeWorkflowKey('', {})).toBeNull() +}) diff --git a/src/workflow/backends/claudeCodeBackend.ts b/src/workflow/backends/claudeCodeBackend.ts new file mode 100644 index 000000000..d75d3c823 --- /dev/null +++ b/src/workflow/backends/claudeCodeBackend.ts @@ -0,0 +1,158 @@ +// 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。 +// 实现 AgentAdapter 接口,由 registry(U5)注册并路由。 +import { + type AgentAdapter, + type AgentAdapterContext, + type AgentRunParams, + type AgentRunResult, +} from '@claude-code-best/workflow-engine' +import { assembleToolPool } from '../../tools.js' +import { finalizeAgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js' +import { runAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js' +import { + isBuiltInAgent, + type AgentDefinition, + type BuiltInAgentDefinition, +} from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' +import { createUserMessage, extractTextContent } from '../../utils/messages.js' +import { createAgentId } from '../../utils/uuid.js' +import { logForDebugging } from '../../utils/debug.js' +import { logEvent } from '../../services/analytics/index.js' +import type { ModelAlias } from '../../utils/model/aliases.js' +import type { Message } from '../../types/message.js' +import type { ToolUseContext } from '../../Tool.js' +import { readHostBundle } from '../hostHandle.js' + +/** workflow 子 agent 的兜底定义(agentType 未命中真实注册表时用)。 */ +export const WORKFLOW_AGENT: BuiltInAgentDefinition = { + agentType: 'workflow-worker', + whenToUse: 'workflow 脚本内 agent() 钩子派发的子任务', + tools: ['*'], + source: 'built-in', + baseDir: 'built-in', + getSystemPrompt: () => + 'You are a workflow sub-agent. Complete the task concisely; your final text is the return value relayed to the workflow.', +} + +/** agentType → 真实 agent 注册表(activeAgents 命中即用,否则兜底)。已导出便于单测。 */ +export function resolveAgentDefinition( + agentType: string | undefined, + toolUseContext: ToolUseContext, +): AgentDefinition { + if (!agentType) return WORKFLOW_AGENT + const found = toolUseContext.options.agentDefinitions.activeAgents.find( + a => a.agentType === agentType, + ) + return found ?? WORKFLOW_AGENT +} + +/** model 别名 → 当前 provider 实际 model id。v1 直传(保留映射扩展点)。已导出便于单测。 */ +export function mapWorkflowModel( + model: string | undefined, +): string | undefined { + return model +} + +/** 从 agent 最终消息中提取 StructuredOutput 产出的 JSON 对象;失败返回 null。已导出便于单测。 */ +export function extractStructuredOutput( + content: Array<{ type: string; text?: string }>, +): unknown | null { + for (const block of content) { + if (block.type === 'text' && block.text) { + const trimmed = block.text.trim() + const start = trimmed.indexOf('{') + const end = trimmed.lastIndexOf('}') + if (start >= 0 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)) + } catch { + // 继续尝试下一个文本块 + } + } + } + } + return null +} + +/** 深度集成后端:从活会话解析 agent/model/tools,委托核心 runAgent。 */ +export const claudeCodeBackend: AgentAdapter = { + id: 'claude-code', + capabilities: { structuredOutput: true, tools: true }, + + async run( + params: AgentRunParams, + ctx: AgentAdapterContext, + ): Promise { + const { toolUseContext, canUseTool } = readHostBundle(ctx.host) + const appState = toolUseContext.getAppState() + const agentDef = resolveAgentDefinition(params.agentType, toolUseContext) + const model = mapWorkflowModel(params.model) + const agentId = createAgentId() + + const workerPermissionContext = { + ...appState.toolPermissionContext, + mode: agentDef.permissionMode ?? 'acceptEdits', + } + const workerTools = assembleToolPool( + workerPermissionContext, + appState.mcp.tools, + ) + + // schema → 通过 prompt 追加 JSON Schema 指令(非交互模式 StructuredOutput 已启用) + const promptText = params.schema + ? `${params.prompt}\n\nYou MUST return your final answer by calling the StructuredOutput tool with a value matching this JSON Schema:\n${JSON.stringify(params.schema)}` + : params.prompt + + const promptMessages = [createUserMessage({ content: promptText })] + const messages: Message[] = [] + const startTime = Date.now() + + try { + for await (const msg of runAgent({ + agentDefinition: agentDef, + promptMessages, + toolUseContext, + canUseTool, + isAsync: true, + querySource: toolUseContext.options.querySource ?? 'workflow', + availableTools: workerTools, + override: { agentId }, + // runAgent 的 model 是顶层 ModelAlias;workflow 的 model 是任意别名串, + // 类型上不兼容,运行时由 provider 层解析。双重断言透传(优于 as any/never)。 + ...(model ? { model: model as unknown as ModelAlias } : {}), + })) { + messages.push(msg as Message) + } + } catch (e) { + logForDebugging( + `workflow sub-agent error (${agentDef.agentType}): ${(e as Error).message}`, + ) + logEvent('tengu_workflow_agent', { ok: 0 }) + return { kind: 'dead' } + } + + const finalized = finalizeAgentTool(messages, agentId, { + prompt: params.prompt, + resolvedAgentModel: toolUseContext.options.mainLoopModel, + isBuiltInAgent: isBuiltInAgent(agentDef), + startTime, + agentType: agentDef.agentType, + isAsync: true, + }) + const outputTokens = + finalized.usage?.output_tokens ?? finalized.totalTokens ?? 0 + logEvent('tengu_workflow_agent', { ok: 1, outputTokens }) + + if (params.schema) { + const structured = extractStructuredOutput(finalized.content) + if (structured === null) return { kind: 'dead' } + return { + kind: 'ok', + output: structured as object, + usage: { outputTokens }, + } + } + const text = extractTextContent(finalized.content, '\n') + return { kind: 'ok', output: text, usage: { outputTokens } } + }, +} diff --git a/src/workflow/hostHandle.ts b/src/workflow/hostHandle.ts new file mode 100644 index 000000000..b9905784c --- /dev/null +++ b/src/workflow/hostHandle.ts @@ -0,0 +1,42 @@ +import { + createHostHandle, + unwrapHostHandle, + type HostHandle, +} from '@claude-code-best/workflow-engine' +import type { CanUseToolFn } from '../hooks/useCanUseTool.js' +import type { AssistantMessage } from '../types/message.js' +import type { AgentId } from '../types/ids.js' +import type { ToolUseContext } from '../Tool.js' + +/** HostHandle 内含的不透明 bundle(核心侧解包后使用)。 */ +export type WorkflowHostBundle = { + toolUseContext: ToolUseContext + canUseTool: CanUseToolFn + parentMessage?: AssistantMessage + agentId?: AgentId +} + +/** + * 共享:从 toolUseContext/canUseTool 构造 host bundle。 + * parentMessage 可选(面板启动路径无——claudeCodeBackend 从不读它)。 + */ +export function buildHostBundle( + toolUseContext: WorkflowHostBundle['toolUseContext'], + canUseTool: WorkflowHostBundle['canUseTool'], + parentMessage?: AssistantMessage, +): WorkflowHostBundle { + return { + toolUseContext, + canUseTool, + ...(parentMessage !== undefined ? { parentMessage } : {}), + agentId: toolUseContext.agentId, + } +} + +export function makeHostHandle(bundle: WorkflowHostBundle): HostHandle { + return createHostHandle(bundle) +} + +export function readHostBundle(handle: HostHandle): WorkflowHostBundle { + return unwrapHostHandle(handle) as WorkflowHostBundle +} diff --git a/src/workflow/namedWorkflowCommands.ts b/src/workflow/namedWorkflowCommands.ts new file mode 100644 index 000000000..053050fc3 --- /dev/null +++ b/src/workflow/namedWorkflowCommands.ts @@ -0,0 +1,34 @@ +import { join } from 'node:path' +import { + listNamedWorkflows, + WORKFLOW_DIR_NAME, +} from '@claude-code-best/workflow-engine' +import type { Command } from '../types/command.js' +import { getProjectRoot } from '../bootstrap/state.js' + +/** 扫描 .claude/workflows/ 下 *.ts|*.js|*.mjs,每个生成一个 / 命令。 */ +export async function getWorkflowCommands( + cwd: string = getProjectRoot(), +): Promise { + const dir = join(cwd, WORKFLOW_DIR_NAME) + const names = await listNamedWorkflows(dir) + return names.map(name => ({ + type: 'prompt', + name, + description: `Run workflow: ${name}`, + kind: 'workflow', + source: 'builtin', + progressMessage: `Running workflow ${name}...`, + contentLength: 0, + async getPromptForCommand(args, _context) { + const argText = + typeof args === 'string' && args ? `\n\nArguments: ${args}` : '' + return [ + { + type: 'text', + text: `Run the "${name}" workflow now by calling the Workflow tool with name="${name}".${argText}`, + }, + ] + }, + })) +} diff --git a/src/workflow/notifications.ts b/src/workflow/notifications.ts new file mode 100644 index 000000000..be7994b81 --- /dev/null +++ b/src/workflow/notifications.ts @@ -0,0 +1,87 @@ +/** + * Workflow 状态变更通知桥接。 + * + * 引擎通过 progressEmitter.emit({ type: 'run_done', ... }) 发事件, + * progress/store reducer 把状态记到 RunProgress。但旧实现没有任何代码 + * 把状态转换桥接到 host 通知机制——WorkflowTool 返回文本承诺的"完成时 + * 会自动通知"实际落空。 + * + * 本模块订阅 WorkflowService.subscribe,监听 status 从 running → + * completed/failed/killed 的转换,通过注入的 notifier 回调发 host + * notification(默认走 enqueuePendingNotification task-notification mode)。 + */ +import { + STATUS_TAG, + SUMMARY_TAG, + TASK_ID_TAG, + TASK_NOTIFICATION_TAG, + TASK_TYPE_TAG, +} from '../constants/xml.js' +import { enqueuePendingNotification } from '../utils/messageQueueManager.js' +import type { RunProgress } from './progress/store.js' +import type { WorkflowService } from './service.js' + +const WORKFLOW_TASK_TYPE = 'local_workflow' + +/** 通知发送器抽象(便于测试注入 spy)。 */ +export type WorkflowNotifier = (message: string) => void + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'killed', +]) + +/** 默认通知器:走 host message queue 的 task-notification 模式。 */ +const defaultNotifier: WorkflowNotifier = message => { + enqueuePendingNotification({ value: message, mode: 'task-notification' }) +} + +export function installWorkflowNotifications( + service: WorkflowService, + notify: WorkflowNotifier = defaultNotifier, +): () => void { + const prevStatus = new Map() + + const unsubscribe = service.subscribe(() => { + const runs = service.listRuns() + for (const run of runs) { + const prev = prevStatus.get(run.runId) + // 初次见到这个 run:仅记录当前状态,不发通知 + // (避免安装时把已有历史 run 当作新通知触发) + if (prev === undefined) { + prevStatus.set(run.runId, run.status) + continue + } + // 状态变化 + 进入终态 → 发通知 + if (prev !== run.status && TERMINAL_STATUSES.has(run.status)) { + notify(buildMessage(run)) + } + prevStatus.set(run.runId, run.status) + } + }) + + return () => { + unsubscribe() + prevStatus.clear() + } +} + +function buildMessage(run: RunProgress): string { + const statusText = + run.status === 'completed' + ? 'completed successfully' + : run.status === 'failed' + ? 'failed' + : 'was stopped' + const errorSuffix = + run.status === 'failed' && run.error ? `: ${run.error}` : '' + const summary = `Workflow "${run.workflowName}" ${statusText}${errorSuffix}` + + return `<${TASK_NOTIFICATION_TAG}> +<${TASK_ID_TAG}>${run.runId} +<${TASK_TYPE_TAG}>${WORKFLOW_TASK_TYPE} +<${STATUS_TAG}>${run.status} +<${SUMMARY_TAG}>${summary} +` +} diff --git a/src/workflow/panel/AgentList.tsx b/src/workflow/panel/AgentList.tsx new file mode 100644 index 000000000..71b77a639 --- /dev/null +++ b/src/workflow/panel/AgentList.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Theme } from '@anthropic/ink'; +import type { AgentProgress } from '../progress/store.js'; +import { agentVisual } from './status.js'; + +const LABEL_WIDTH = 18; + +/** + * 右 agent 列表(已按选中 phase 过滤)。 + * 光标行铺橙底;每行:标记 + label + 行尾状态文字(running/object/text/dead)。 + */ +export function AgentList({ + agents, + selectedIndex, +}: { + agents: AgentProgress[]; + selectedIndex: number; +}): React.ReactNode { + if (agents.length === 0) { + return (no agents in this phase); + } + return ( + + {agents.map((a, i) => { + const v = agentVisual(a); + const selected = i === selectedIndex; + const label = (a.label ?? `agent-${a.id}`).slice(0, LABEL_WIDTH).padEnd(LABEL_WIDTH); + return ( + + + {v.mark} {label} {v.suffix} + + + ); + })} + + ); +} diff --git a/src/workflow/panel/PhaseSidebar.tsx b/src/workflow/panel/PhaseSidebar.tsx new file mode 100644 index 000000000..09fffb18f --- /dev/null +++ b/src/workflow/panel/PhaseSidebar.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Theme } from '@anthropic/ink'; +import type { AgentProgress } from '../progress/store.js'; +import { PHASE_COLOR, PHASE_MARK, type PhaseStatus } from './status.js'; +import { ALL_PHASE, type MergedPhase } from './selectors.js'; + +type PhaseRow = { + title: string; + status?: PhaseStatus; + done: number; + total: number; +}; + +/** + * 左 phase 侧栏:第一行 All(汇总 done/total),其后 merged phases(含 pending ○)。 + * 选中行铺橙底(文字色不变);selectedIndex=0 表示 All。 + */ +export function PhaseSidebar({ + phases, + agents, + selectedIndex, +}: { + phases: MergedPhase[]; + agents: AgentProgress[]; + selectedIndex: number; +}): React.ReactNode { + const totalAgents = agents.length; + const doneAgents = agents.filter(a => a.status === 'done').length; + const rows: PhaseRow[] = [{ title: ALL_PHASE, done: doneAgents, total: totalAgents }, ...phases]; + + return ( + + {rows.map((row, i) => { + const selected = i === selectedIndex; + const mark = row.status ? PHASE_MARK[row.status] : ' '; + const color = row.status ? (PHASE_COLOR[row.status] as keyof Theme) : undefined; + return ( + + + {selected ? '▶' : ' '} + {mark} {row.title.padEnd(10)} {row.done}/{row.total} + + + ); + })} + + ); +} diff --git a/src/workflow/panel/TabsBar.tsx b/src/workflow/panel/TabsBar.tsx new file mode 100644 index 000000000..15c650e21 --- /dev/null +++ b/src/workflow/panel/TabsBar.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Theme } from '@anthropic/ink'; +import type { RunProgress } from '../progress/store.js'; +import { RUN_STATUS_COLOR, STATUS_DOT } from './status.js'; +import { tabLabel } from './selectors.js'; + +/** + * 顶部 run tab 行:每个 run 一个 tab(状态点 + 名 + #短码)。 + * 当前 tab 用橙色 ═ 下划线高亮。 + */ +export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunId: string | null }): React.ReactNode { + if (runs.length === 0) { + return (no runs); + } + return ( + + {runs.map(r => { + const active = r.runId === activeRunId; + const label = tabLabel(r.workflowName, r.runId); + const underline = '═'.repeat(label.length + 2); + return ( + + + {STATUS_DOT[r.status]} + + + {label} + + + {active ? underline : ''} + + ); + })} + + ); +} diff --git a/src/workflow/panel/WorkflowsPanel.tsx b/src/workflow/panel/WorkflowsPanel.tsx new file mode 100644 index 000000000..e991c402e --- /dev/null +++ b/src/workflow/panel/WorkflowsPanel.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState, useSyncExternalStore } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; +import { getWorkflowService } from '../service.js'; +import type { RunProgress } from '../progress/store.js'; +import { AgentList } from './AgentList.js'; +import { PhaseSidebar } from './PhaseSidebar.js'; +import { TabsBar } from './TabsBar.js'; +import { type FocusColumn, type WorkflowKeyboardHandlers, useWorkflowKeyboard } from './useWorkflowKeyboard.js'; +import { ALL_PHASE, filterAgentsByPhase, mergePhases } from './selectors.js'; + +/** + * 夹紧选中索引到有效区间(空列表→0;越界→末位;负/NaN→0)。 + * 抽成模块级纯函数:面板内调用 + 单测覆盖同一逻辑,避免行为漂移。 + */ +export function clampSelected(selected: number, len: number): number { + if (len === 0) return 0; + const n = Math.trunc(selected); + if (Number.isNaN(n) || n < 0) return 0; + return Math.min(n, len - 1); +} + +/** + * /workflows 主面板:三区焦点模型(顶 tab + 左 phase 侧栏 + 右 agent 列表)。 + * + * - useSyncExternalStore 订阅 WorkflowService(store 返回稳定快照,无变更不重渲染)。 + * - 焦点状态:activeRunId / focusColumn('phases'|'agents') / selectedPhaseIndex(0=All) / selectedAgentIndex。 + * - 键位:Tab 切 run · ←/→ 切焦点列 · ↑/↓ 列内移动 · x kill · r resume · q/Esc 退出。 + */ +export function WorkflowsPanel({ + onDone, + context, +}: { + onDone: LocalJSXCommandOnDone; + context: LocalJSXCommandContext; +}): React.ReactNode { + const svc = getWorkflowService(); + const runs = useSyncExternalStore( + svc.subscribe, + () => svc.listRuns(), + () => [], + ); + + const [activeRunId, setActiveRunId] = useState(null); + const [focusColumn, setFocusColumn] = useState('phases'); + const [selectedPhaseIndex, setSelectedPhaseIndex] = useState(0); + const [selectedAgentIndex, setSelectedAgentIndex] = useState(0); + + // runs 变化时:activeRunId 失效(被 kill / 首次)→ 夹紧到首个 + useEffect(() => { + if (runs.length === 0) { + if (activeRunId !== null) setActiveRunId(null); + return; + } + if (!runs.some(r => r.runId === activeRunId)) { + setActiveRunId(runs[0]!.runId); + } + }, [runs, activeRunId]); + + const focused: RunProgress | undefined = runs.find(r => r.runId === activeRunId); + const phases = focused ? mergePhases(focused) : []; + // 侧栏含 All 行:phases 数组前补一项 → 总行数 = phases.length + 1 + const phaseRowCount = phases.length + 1; + const clampedPhase = clampSelected(selectedPhaseIndex, phaseRowCount); + + // 选中 phase title(0 = All = undefined) + const selectedPhaseTitle = clampedPhase === 0 ? undefined : phases[clampedPhase - 1]?.title; + + const visibleAgents = focused ? filterAgentsByPhase(focused.agents, selectedPhaseTitle) : []; + const clampedAgent = clampSelected(selectedAgentIndex, visibleAgents.length); + + const switchTab = (runId: string): void => { + setActiveRunId(runId); + setFocusColumn('phases'); + setSelectedPhaseIndex(0); + setSelectedAgentIndex(0); + }; + + const nextTab = (): void => { + if (runs.length === 0) return; + const idx = runs.findIndex(r => r.runId === activeRunId); + const next = runs[(idx + 1) % runs.length]!; + switchTab(next.runId); + }; + const prevTab = (): void => { + if (runs.length === 0) return; + const idx = runs.findIndex(r => r.runId === activeRunId); + const next = runs[(idx - 1 + runs.length) % runs.length]!; + switchTab(next.runId); + }; + + const handlers: WorkflowKeyboardHandlers = { + nextTab, + prevTab, + focusLeft: () => setFocusColumn('phases'), + focusRight: () => setFocusColumn('agents'), + moveUp: () => { + if (focusColumn === 'phases') setSelectedPhaseIndex(s => clampSelected(s - 1, phaseRowCount)); + else setSelectedAgentIndex(s => clampSelected(s - 1, visibleAgents.length)); + }, + moveDown: () => { + if (focusColumn === 'phases') setSelectedPhaseIndex(s => clampSelected(s + 1, phaseRowCount)); + else setSelectedAgentIndex(s => clampSelected(s + 1, visibleAgents.length)); + }, + killFocused: () => { + if (focused) svc.kill(focused.runId); + }, + resumeFocused: () => { + if (!focused) return; + const canUseTool = context.canUseTool; + if (!canUseTool) { + onDone('resume 需要 canUseTool 上下文,请在主会话中用 / resume 重试。'); + return; + } + void svc + .launch({ resumeFromRunId: focused.runId, name: focused.workflowName }, context, canUseTool) + .catch(e => onDone(`resume 失败:${(e as Error).message}`)); + }, + newRun: () => onDone('Tip: 用 / 启动命名 workflow,或通过 Workflow 工具带 name 参数。'), + quit: () => onDone(), + }; + useWorkflowKeyboard(handlers); + + const running = runs.filter(r => r.status === 'running').length; + const done = runs.length - running; + const phaseHeader = selectedPhaseTitle ?? ALL_PHASE; + + return ( + + + Workflows + + {running} running · {done} done + + + + + + + + + + + PHASES + + + + + + + AGENTS · {phaseHeader} + + + + + + + Tab 切 run · ←/→ 切焦点 · ↑/↓ 移动 · x kill · r resume · q quit + + + ); +} diff --git a/src/workflow/panel/panelCall.tsx b/src/workflow/panel/panelCall.tsx new file mode 100644 index 000000000..22997f214 --- /dev/null +++ b/src/workflow/panel/panelCall.tsx @@ -0,0 +1,16 @@ +import type { LocalJSXCommandCall } from '../../types/command.js'; +import { SentryErrorBoundary } from '../../components/SentryErrorBoundary.js'; +import { WorkflowsPanel } from './WorkflowsPanel.js'; + +/** + * /workflows 的 local-jsx call:构造面板元素返回给 Ink 渲染。 + * + * 用 SentryErrorBoundary 包裹:useSyncExternalStore / listNamed / 子组件 + * 抛错时不让异常击穿到 REPL 顶层导致整个会话崩溃;boundary 落到本地错误卡片。 + * onDone/context 由命令运行时注入;args 未使用(面板无参数化行为)。 + */ +export const call: LocalJSXCommandCall = async (onDone, context, _args) => ( + + + +); diff --git a/src/workflow/panel/selectors.ts b/src/workflow/panel/selectors.ts new file mode 100644 index 000000000..7df92d855 --- /dev/null +++ b/src/workflow/panel/selectors.ts @@ -0,0 +1,60 @@ +import type { AgentProgress, RunProgress } from '../progress/store.js' +import type { PhaseStatus } from './status.js' + +/** 「不筛选」固定项的 title(侧栏第一行)。 */ +export const ALL_PHASE = 'All' + +/** 合并后的 phase(含 pending),带该 phase 下 agent 的 done/total 计数。 */ +export type MergedPhase = { + title: string + status: PhaseStatus + done: number + total: number +} + +/** + * 合并 declaredPhases(meta 声明)与 run.phases(实际 running/done): + * - 声明顺序优先;未在 declared 但实际出现的 phase 追加末尾。 + * - 实际无记录 → pending;否则取实际 status。 + * - done/total = 该 phase 下 done / 全部 agent 数。 + */ +export function mergePhases( + run: Pick, +): MergedPhase[] { + const actualByTitle = new Map(run.phases.map(p => [p.title, p])) + const seen = new Set() + const out: MergedPhase[] = [] + const push = (title: string): void => { + if (seen.has(title)) return + seen.add(title) + const actual = actualByTitle.get(title) + const status: PhaseStatus = !actual ? 'pending' : actual.status + const inPhase = run.agents.filter(a => a.phase === title) + out.push({ + title, + status, + done: inPhase.filter(a => a.status === 'done').length, + total: inPhase.length, + }) + } + for (const t of run.declaredPhases) push(t) + for (const p of run.phases) push(p.title) + return out +} + +/** + * 按选中 phase 筛选 agent。 + * selectedPhase 为 undefined 或 ALL_PHASE → 全部。 + */ +export function filterAgentsByPhase( + agents: AgentProgress[], + selectedPhase: string | undefined, +): AgentProgress[] { + if (selectedPhase === undefined || selectedPhase === ALL_PHASE) return agents + return agents.filter(a => a.phase === selectedPhase) +} + +/** tab 标签:workflow 名 + `#` + runId 末 4 位(同名 run 消歧)。 */ +export function tabLabel(workflowName: string, runId: string): string { + return `${workflowName}#${runId.slice(-4)}` +} diff --git a/src/workflow/panel/status.ts b/src/workflow/panel/status.ts new file mode 100644 index 000000000..e712125f6 --- /dev/null +++ b/src/workflow/panel/status.ts @@ -0,0 +1,53 @@ +import type { AgentProgress, RunProgress } from '../progress/store.js' + +/** run 状态 → 圆点字符(顶部 tab 用)。 */ +export const STATUS_DOT: Record = { + running: '●', + completed: '✓', + failed: '✗', + killed: '■', +} + +/** run 状态 → ink theme 颜色 token(沿用现有 WorkflowList 配色)。 */ +export const RUN_STATUS_COLOR: Record = { + running: 'warning', + completed: 'success', + failed: 'error', + killed: 'subtle', +} + +/** phase 在侧栏的合并状态(含 pending:meta 声明但未启动)。 */ +export type PhaseStatus = 'running' | 'done' | 'pending' + +export const PHASE_MARK: Record = { + running: '●', + done: '✓', + pending: '○', +} + +export const PHASE_COLOR: Record = { + running: 'warning', + done: 'success', + pending: 'subtle', +} + +/** agent 行的视觉三件套:标记字符 + 颜色 + 行尾文字后缀。 */ +export type AgentVisual = { mark: string; color: string; suffix: string } + +/** + * agent 状态 → 视觉。 + * - running → ● warning + * - done·dead → ✗ error + * - done·ok:outputShape='object' → object;否则 text + */ +export function agentVisual(a: AgentProgress): AgentVisual { + if (a.status === 'running') + return { mark: '●', color: 'warning', suffix: 'running' } + if (a.resultKind === 'dead') + return { mark: '✗', color: 'error', suffix: 'dead' } + return { + mark: '✓', + color: 'success', + suffix: a.outputShape === 'object' ? 'object' : 'text', + } +} diff --git a/src/workflow/panel/useWorkflowKeyboard.ts b/src/workflow/panel/useWorkflowKeyboard.ts new file mode 100644 index 000000000..b7b4987f6 --- /dev/null +++ b/src/workflow/panel/useWorkflowKeyboard.ts @@ -0,0 +1,105 @@ +import { useInput } from '@anthropic/ink' + +/** 焦点所在列。 */ +export type FocusColumn = 'phases' | 'agents' + +/** useInput 的 key 对象子集(仅声明用到的字段,避免耦合 ink Key 类型)。 */ +type KeyEvent = { + tab?: boolean + shift?: boolean + escape?: boolean + leftArrow?: boolean + rightArrow?: boolean + upArrow?: boolean + downArrow?: boolean +} + +/** 键 → 动作(纯函数,便于单测;无渲染依赖)。 */ +export type WorkflowKeyAction = + | 'nextTab' + | 'prevTab' + | 'focusLeft' + | 'focusRight' + | 'moveUp' + | 'moveDown' + | 'kill' + | 'resume' + | 'newRun' + | 'quit' + +export function routeWorkflowKey( + input: string, + key: KeyEvent, +): WorkflowKeyAction | null { + // @anthropic/ink 的 key.tab 对 Tab 键置 true;个别环境回落到 '\t' + if (key.tab || input === '\t') return key.shift ? 'prevTab' : 'nextTab' + if (key.escape || input === 'q') return 'quit' + if (input === 'x') return 'kill' + if (input === 'r') return 'resume' + if (input === 'n') return 'newRun' + if (key.leftArrow) return 'focusLeft' + if (key.rightArrow) return 'focusRight' + if (key.upArrow) return 'moveUp' + if (key.downArrow) return 'moveDown' + return null +} + +/** 焦点模型回调(WorkflowsPanel 注入)。 */ +export type WorkflowKeyboardHandlers = { + nextTab: () => void + prevTab: () => void + focusLeft: () => void + focusRight: () => void + moveUp: () => void + moveDown: () => void + killFocused: () => void + resumeFocused: () => void + newRun: () => void + quit: () => void +} + +/** + * /workflows 面板键位(焦点轮转模型): + * - Tab / Shift+Tab:切顶部 run tab + * - ← / →:phases ↔ agents 焦点切换 + * - ↑ / ↓:当前焦点列内移动 + * - x kill · r resume · n new · q / Esc quit + */ +export function useWorkflowKeyboard(h: WorkflowKeyboardHandlers): void { + useInput((input, key) => { + const action = routeWorkflowKey(input, key as KeyEvent) + if (action === null) return + switch (action) { + case 'nextTab': + h.nextTab() + break + case 'prevTab': + h.prevTab() + break + case 'focusLeft': + h.focusLeft() + break + case 'focusRight': + h.focusRight() + break + case 'moveUp': + h.moveUp() + break + case 'moveDown': + h.moveDown() + break + case 'kill': + h.killFocused() + break + case 'resume': + h.resumeFocused() + break + case 'newRun': + h.newRun() + break + case 'quit': + h.quit() + break + } + }) +} diff --git a/src/workflow/ports.ts b/src/workflow/ports.ts new file mode 100644 index 000000000..680518d96 --- /dev/null +++ b/src/workflow/ports.ts @@ -0,0 +1,165 @@ +import { + createFileJournalStore, + type ProgressEvent, + type WorkflowPorts, +} from '@claude-code-best/workflow-engine' +import { logForDebugging } from '../utils/debug.js' +import { getProjectRoot } from '../bootstrap/state.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import { + completeWorkflowTask, + failWorkflowTask, + killWorkflowTask, + registerLocalWorkflowTask, +} from '../tasks/LocalWorkflowTask/LocalWorkflowTask.js' +import { + buildHostBundle, + makeHostHandle, + readHostBundle, + type WorkflowHostBundle, +} from './hostHandle.js' +import { buildRegistry } from './registry.js' +import type { ProgressBus } from './progress/bus.js' +import type { ProgressStore } from './progress/store.js' +import type { SetAppState } from '../Task.js' +import type { AssistantMessage } from '../types/message.js' + +type RunBinding = { + runId: string + taskId: string + setAppState: SetAppState + abortController: AbortController + workflowName: string +} + +/** 每次工具调用从 toolUseContext 构造 WorkflowHostContext。 */ +function makeHostFactory(): WorkflowPorts['hostFactory'] { + return ({ context, canUseTool, parentMessage }) => { + const ctx = context as WorkflowHostBundle['toolUseContext'] & { + agentId?: string + } + return { + handle: makeHostHandle( + buildHostBundle( + ctx, + canUseTool as WorkflowHostBundle['canUseTool'], + parentMessage as AssistantMessage | undefined, + ), + ), + // 用 projectRoot 而非 getCwd():与 journalStore 的 runsDir 同根, + // 否则用户进入 worktree/子目录时命名 workflow 解析与 journal 落盘不同步。 + // 引擎内部 ctx.cwd 仅用于解析(scriptPath/name),不影响 agent 执行 cwd + // (agent 通过 host bundle 内的 toolUseContext 拿到自己的 cwd)。 + cwd: getProjectRoot(), + budgetTotal: null, // turn 级预算注入点(未来从 settings 读) + ...(ctx.toolUseId ? { toolUseId: ctx.toolUseId } : {}), + } + } +} + +/** + * 组装完整 WorkflowPorts。bus/store 由调用方传入(service 单例共享)。 + * taskRegistrar 维护 runId → RunBinding 供 kill 路由。 + */ +export function createWorkflowPorts(opts: { + bus: ProgressBus + store: ProgressStore +}): WorkflowPorts { + const bindings = new Map() + const runsDir = `${getProjectRoot()}/.claude/workflow-runs` + const registry = buildRegistry() + + // 遥测订阅(独立于 store)。LogEventMetadata 只接受 boolean/number/undefined, + // runId 为字符串——用 analytics 模块自带的 brand cast(已验证非代码/路径)放行。 + opts.bus.subscribe((e: ProgressEvent) => { + if (e.type === 'run_done') { + logEvent('tengu_workflow_done', { + status: e.status === 'completed' ? 0 : e.status === 'failed' ? 1 : 2, + runId: + e.runId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + } + }) + + const taskRegistrar: WorkflowPorts['taskRegistrar'] = { + register(regOpts, host) { + const bundle = readHostBundle(host) + const setAppState = + bundle.toolUseContext.setAppStateForTasks ?? + bundle.toolUseContext.setAppState + const abortController = new AbortController() + const taskId = registerLocalWorkflowTask(setAppState, { + description: regOpts.summary ?? regOpts.workflowName, + workflowName: regOpts.workflowName, + workflowFile: regOpts.workflowFile ?? '', + summary: regOpts.summary, + ...(regOpts.toolUseId ? { toolUseId: regOpts.toolUseId } : {}), + abortController, + }) + const runId = regOpts.runId ?? taskId + bindings.set(runId, { + runId, + taskId, + setAppState, + abortController, + workflowName: regOpts.workflowName, + }) + logForDebugging( + `workflow task registered: ${runId} (${regOpts.workflowName})`, + ) + return { runId, signal: abortController.signal } + }, + complete(runId, summary) { + const b = bindings.get(runId) + if (!b) return + completeWorkflowTask(b.taskId, b.setAppState) + logForDebugging(`workflow ${runId} completed: ${summary ?? ''}`) + bindings.delete(runId) + }, + fail(runId, error) { + const b = bindings.get(runId) + if (!b) return + failWorkflowTask(b.taskId, b.setAppState, error) + logForDebugging(`workflow ${runId} failed: ${error}`) + bindings.delete(runId) + }, + kill(runId) { + const b = bindings.get(runId) + if (!b) return + killWorkflowTask(b.taskId, b.setAppState) // 内部 abort controller + bindings.delete(runId) + }, + pendingAction() { + return null // v1:skip/retry 不接线(seam 保留) + }, + } + + return { + hostFactory: makeHostFactory(), + agentAdapterRegistry: registry, + agentRunner: { + // 死代码兜底:hooks 始终走 agentAdapterRegistry(ports 必设)。若到此说明 registry 未注册——fail-fast。 + async runAgentToResult() { + throw new Error( + 'workflow agentRunner fallback reached — agentAdapterRegistry must be set on ports', + ) + }, + }, + progressEmitter: { + emit(event) { + opts.bus.emit(event) // → store reducer + 遥测 + }, + }, + taskRegistrar, + journalStore: createFileJournalStore(runsDir), + permissionGate: { isAborted: () => false }, // 引擎用 ctx.signal 判 abort + logger: { + debug: msg => logForDebugging(msg), + warn: msg => logForDebugging(`[workflow warn] ${msg}`), + event: name => logForDebugging(`workflow event: ${name}`), + }, + } +} diff --git a/src/workflow/progress/bus.ts b/src/workflow/progress/bus.ts new file mode 100644 index 000000000..92d760cf0 --- /dev/null +++ b/src/workflow/progress/bus.ts @@ -0,0 +1,20 @@ +import type { ProgressEvent } from '@claude-code-best/workflow-engine' + +/** 类型化进度事件总线。引擎 progressEmitter.emit → 广播给所有订阅者(store / 遥测)。 */ +export type ProgressBus = { + emit(event: ProgressEvent): void + subscribe(listener: (event: ProgressEvent) => void): () => void +} + +export function createProgressBus(): ProgressBus { + const listeners = new Set<(event: ProgressEvent) => void>() + return { + emit(event) { + for (const fn of listeners) fn(event) + }, + subscribe(listener) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} diff --git a/src/workflow/progress/store.ts b/src/workflow/progress/store.ts new file mode 100644 index 000000000..53a1655bf --- /dev/null +++ b/src/workflow/progress/store.ts @@ -0,0 +1,166 @@ +import type { ProgressEvent } from '@claude-code-best/workflow-engine' +import type { ProgressBus } from './bus.js' + +export type AgentProgress = { + /** 引擎盖戳的唯一 id,精确关联 started/done(修旧 LIFO 竞态)。 */ + id: number + label?: string + phase?: string + status: 'running' | 'done' + resultKind?: string + /** 仅 done·ok 时有意义:output 是对象→'object',否则→'text'。dead/skipped 无。 */ + outputShape?: 'text' | 'object' +} + +export type RunProgress = { + runId: string + workflowName: string + status: 'running' | 'completed' | 'failed' | 'killed' + phases: Array<{ title: string; status: 'running' | 'done' }> + /** 来自 run_started.meta.phases[].title;面板据此显示 pending(○) phase。无 meta → []。 */ + declaredPhases: string[] + currentPhase: string | null + agents: AgentProgress[] + agentCount: number + returnValue?: unknown + error?: string + updatedAt: number +} + +export type ProgressStore = { + apply(event: ProgressEvent): void + list(): RunProgress[] + get(runId: string): RunProgress | undefined + /** 供 useSyncExternalStore:返回稳定引用,无变更时同一数组。 */ + subscribe(listener: () => void): () => void + getSnapshot(): RunProgress[] +} + +/** 从 bus 构造 reactive store:订阅 bus,归约事件,通知 React 订阅者。 */ +export function createProgressStoreFromBus(bus: ProgressBus): ProgressStore { + const byId = new Map() + let snapshot: RunProgress[] = [] + const listeners = new Set<() => void>() + + const notify = (): void => { + snapshot = [...byId.values()].sort((a, b) => b.updatedAt - a.updatedAt) + for (const fn of listeners) fn() + } + + const ensure = (runId: string, workflowName: string): RunProgress => { + let p = byId.get(runId) + if (!p) { + p = { + runId, + workflowName, + status: 'running', + phases: [], + declaredPhases: [], + currentPhase: null, + agents: [], + agentCount: 0, + updatedAt: Date.now(), + } + byId.set(runId, p) + } + return p + } + + const apply = (event: ProgressEvent): void => { + // log 不产生可见状态变更(面板无日志视图):早退,避免无谓的快照重建与 React 重渲染 + if (event.type === 'log') return + const runId = event.runId + const p = ensure( + runId, + 'workflowName' in event ? event.workflowName : 'workflow', + ) + p.updatedAt = Date.now() + switch (event.type) { + case 'run_started': + p.workflowName = event.workflowName + p.status = 'running' + p.declaredPhases = event.meta?.phases?.map(ph => ph.title) ?? [] + break + case 'phase_started': + if (!p.phases.some(ph => ph.title === event.phase)) { + p.phases.push({ title: event.phase, status: 'running' }) + } + p.currentPhase = event.phase + break + case 'phase_done': + for (const ph of p.phases) + if (ph.title === event.phase) ph.status = 'done' + if (p.currentPhase === event.phase) p.currentPhase = null + break + case 'agent_started': { + let a = p.agents.find(x => x.id === event.agentId) + if (!a) { + a = { + id: event.agentId, + label: event.label, + phase: event.phase, + status: 'running', + } + p.agents.push(a) + p.agentCount = p.agents.length + } else { + a.status = 'running' + a.label = event.label + a.phase = event.phase + } + break + } + case 'agent_done': { + let a = p.agents.find(x => x.id === event.agentId) + if (!a) { + a = { + id: event.agentId, + label: event.label, + phase: event.phase, + status: 'done', + ...(event.result.kind === 'ok' + ? { + outputShape: + typeof event.result.output === 'object' && + event.result.output !== null + ? ('object' as const) + : ('text' as const), + } + : {}), + } + p.agents.push(a) + p.agentCount = p.agents.length + } else { + a.status = 'done' + a.resultKind = event.result.kind + if (event.result.kind === 'ok') { + a.outputShape = + typeof event.result.output === 'object' && + event.result.output !== null + ? 'object' + : 'text' + } + } + break + } + case 'run_done': + p.status = event.status + if (event.returnValue !== undefined) p.returnValue = event.returnValue + if (event.error !== undefined) p.error = event.error + break + } + notify() + } + + bus.subscribe(apply) + return { + apply, + list: () => snapshot, + get: id => byId.get(id), + subscribe: fn => { + listeners.add(fn) + return () => listeners.delete(fn) + }, + getSnapshot: () => snapshot, + } +} diff --git a/src/workflow/registry.ts b/src/workflow/registry.ts new file mode 100644 index 000000000..e7f91fa7f --- /dev/null +++ b/src/workflow/registry.ts @@ -0,0 +1,12 @@ +import { AgentAdapterRegistry } from '@claude-code-best/workflow-engine' +import { claudeCodeBackend } from './backends/claudeCodeBackend.js' + +/** + * 构建多后端 registry。v1(depth B)只注册单一 claude-code adapter 为默认, + * 不预填路由规则——扩第二个 provider adapter 时再补 .route(...)。 + */ +export function buildRegistry(): AgentAdapterRegistry { + const reg = new AgentAdapterRegistry() + reg.register(claudeCodeBackend).default('claude-code') + return reg +} diff --git a/src/workflow/service.ts b/src/workflow/service.ts new file mode 100644 index 000000000..5f67739c3 --- /dev/null +++ b/src/workflow/service.ts @@ -0,0 +1,224 @@ +import { + listNamedWorkflows, + parseScript, + resolveNamedWorkflow, + runWorkflow, + WORKFLOW_DIR_NAME, + type WorkflowHostContext, + type WorkflowInput, + type WorkflowPorts, +} from '@claude-code-best/workflow-engine' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { getProjectRoot } from '../bootstrap/state.js' +import { logForDebugging } from '../utils/debug.js' +import { buildHostBundle, makeHostHandle } from './hostHandle.js' +import { installWorkflowNotifications } from './notifications.js' +import { createProgressBus } from './progress/bus.js' +import { + createProgressStoreFromBus, + type ProgressStore, + type RunProgress, +} from './progress/store.js' +import { createWorkflowPorts } from './ports.js' +import type { CanUseToolFn } from '../hooks/useCanUseTool.js' +import type { ToolUseContext } from '../Tool.js' + +/** + * WorkflowService:工具(U7)与面板(U9)共享的唯一入口。 + * + * - `ports`:共享的 WorkflowPorts,工具描述符透传给引擎。 + * - `launch`:解析脚本 → parseScript 快速校验 → taskRegistrar.register(拿 runId+signal) + * → detached runWorkflow → 结束后 complete/fail/kill。 + * - `kill/listRuns/getRun/subscribe/listNamed`:面板与工具的辅助查询。 + */ +export type WorkflowService = { + /** 共享端口(工具描述符用)。 */ + ports: WorkflowPorts + /** 面板/工具启动 workflow:解析脚本 → register → detached runWorkflow。 */ + launch( + input: Pick< + WorkflowInput, + | 'script' + | 'name' + | 'scriptPath' + | 'args' + | 'description' + | 'resumeFromRunId' + | 'title' + >, + toolUseContext: ToolUseContext, + canUseTool: CanUseToolFn, + ): Promise<{ runId: string }> + kill(runId: string): void + /** + * 进程退出 / 配置卸载时清理:杀掉所有 running run,避免孤儿 task。 + * 已完成/失败的 run 不受影响。幂等——多次调用安全。 + */ + shutdown(): void + listRuns(): RunProgress[] + getRun(runId: string): RunProgress | undefined + subscribe(listener: () => void): () => void + listNamed(workflowDir?: string): Promise +} + +let cached: WorkflowService | null = null + +/** 进程单例。工具与面板共享同一 ports/registry/store。 */ +export function getWorkflowService(): WorkflowService { + if (cached) return cached + const bus = createProgressBus() + const store = createProgressStoreFromBus(bus) + const ports = createWorkflowPorts({ bus, store }) + const service = makeService(ports, store) + // 安装状态变更通知桥接(commit 0768d4dc 承诺但旧实现落空的"完成时自动通知") + installWorkflowNotifications(service) + cached = service + return cached +} + +/** + * 构造 service(注入 ports + store)。 + * + * 生产路径用 {@link getWorkflowService};测试用本函数直接注入 fake ports, + * 避免触碰真实的 getProjectRoot/getCwd/analytics 等模块级副作用。 + */ +export function makeService( + ports: WorkflowPorts, + store: ProgressStore, +): WorkflowService { + const buildHost = ( + toolUseContext: ToolUseContext, + canUseTool: CanUseToolFn, + ): WorkflowHostContext => ({ + handle: makeHostHandle(buildHostBundle(toolUseContext, canUseTool)), + // 用 projectRoot 与 ports.ts hostFactory / journalStore 保持同根; + // 进入 worktree/子目录时不会让命名 workflow 解析与 journal 落盘不同步。 + cwd: getProjectRoot(), + budgetTotal: null, // turn 级预算注入点(未来从 settings 读) + toolUseId: toolUseContext.toolUseId, + }) + + async function resolveSource(input: { + script?: string + name?: string + scriptPath?: string + }): Promise<{ + script: string + workflowFile?: string + workflowName: string + }> { + if (input.script) { + return { script: input.script, workflowName: 'workflow' } + } + if (input.scriptPath) { + return { + script: await readFile(input.scriptPath, 'utf-8'), + workflowFile: input.scriptPath, + workflowName: 'workflow', + } + } + if (input.name) { + const dir = join(getProjectRoot(), WORKFLOW_DIR_NAME) + const found = await resolveNamedWorkflow(dir, input.name) + if (!found) { + throw new Error( + `命名 workflow "${input.name}" 未找到(查找 ${WORKFLOW_DIR_NAME}/)`, + ) + } + return { + script: found.content, + workflowFile: found.path, + workflowName: input.name, + } + } + throw new Error('必须提供 script、name 或 scriptPath 之一') + } + + return { + ports, + + async launch(input, toolUseContext, canUseTool) { + const { script, workflowFile, workflowName } = await resolveSource(input) + try { + parseScript(script) + } catch (e) { + throw new Error(`脚本校验失败:${(e as Error).message}`) + } + + const host = buildHost(toolUseContext, canUseTool) + const { runId, signal } = ports.taskRegistrar.register( + { + workflowName, + ...(workflowFile ? { workflowFile } : {}), + ...(input.description ? { summary: input.description } : {}), + ...(host.toolUseId ? { toolUseId: host.toolUseId } : {}), + ...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}), + }, + host.handle, + ) + + // detached:不 await,让调用方立即拿到 runId;结束路由到 registrar。 + void runWorkflow({ + script, + ...(input.args !== undefined ? { args: input.args } : {}), + runId, + workflowName, + ports, + host: host.handle, + signal, + cwd: host.cwd, + budgetTotal: host.budgetTotal, + ...(input.resumeFromRunId ? { resume: true } : {}), + }) + .then(result => { + if (result.status === 'completed') { + ports.taskRegistrar.complete(runId) + } else if (result.status === 'failed') { + ports.taskRegistrar.fail(runId, result.error ?? 'failed') + } else { + ports.taskRegistrar.kill(runId) + } + }) + .catch(e => ports.taskRegistrar.fail(runId, (e as Error).message)) + + logForDebugging(`workflow launched: ${runId} (${workflowName})`) + return { runId } + }, + + kill(runId) { + ports.taskRegistrar.kill(runId) + }, + + shutdown() { + // 仅杀 running:已完成/失败的 run taskRegistrar 已回收 binding,kill 是 no-op。 + // taskRegistrar.kill 对未知 runId 安全 no-op,因此幂等——多次 shutdown 不重复抛错。 + for (const run of store.list()) { + if (run.status === 'running') ports.taskRegistrar.kill(run.runId) + } + }, + + listRuns: () => store.list(), + getRun: id => store.get(id), + subscribe: fn => store.subscribe(fn), + + async listNamed(workflowDir) { + return listNamedWorkflows( + workflowDir ?? join(getProjectRoot(), WORKFLOW_DIR_NAME), + ) + }, + } +} + +/** 测试用:重置单例(避免跨用例污染)。 */ +export function __resetWorkflowServiceForTests(): void { + cached = null +} + +/** + * 返回已实例化的 service(不创建)。进程退出 / 配置卸载时用本函数 peek, + * 没用过 workflow 则 cached 仍为 null——避免在 exit hook 里副作用地创建 bus/ports。 + */ +export function peekWorkflowService(): WorkflowService | null { + return cached +} diff --git a/src/workflow/wiring.ts b/src/workflow/wiring.ts new file mode 100644 index 000000000..f58d73b24 --- /dev/null +++ b/src/workflow/wiring.ts @@ -0,0 +1,50 @@ +import { + createWorkflowTool, + type WorkflowToolDescriptor, +} from '@claude-code-best/workflow-engine' +import { buildTool, type Tool } from '../Tool.js' +import { getWorkflowService } from './service.js' + +/** + * 把引擎自包含描述符适配为 buildTool 兼容的 Tool。 + * 描述符统一走 service 单例(共享 ports/registry/store)。 + */ +function buildWorkflowTool(): Tool { + const { ports } = getWorkflowService() + const descriptor: WorkflowToolDescriptor = createWorkflowTool(ports) + return buildTool({ + name: descriptor.name, + maxResultSizeChars: 50_000, + inputSchema: descriptor.inputSchema, + isEnabled: () => descriptor.isEnabled(), + isReadOnly: input => descriptor.isReadOnly(input), + isConcurrencySafe: () => true, + async description() { + return descriptor.description() + }, + async prompt() { + return descriptor.prompt() + }, + async call(input, context, canUseTool, parentMessage, onProgress) { + const result = await descriptor.call( + input, + context, + canUseTool, + parentMessage, + onProgress, + ) + return { data: result.data } + }, + renderToolUseMessage: input => descriptor.renderToolUseMessage(input), + mapToolResultToToolResultBlockParam: (data, toolUseId) => + descriptor.mapToolResultToToolResultBlockParam(data, toolUseId), + }) +} + +// 单例:tools.ts 注册与 PermissionRequest 引用需为同一实例(switch 按引用匹配)。 +let cached: Tool | null = null + +export function createWorkflowToolCore(): Tool { + if (!cached) cached = buildWorkflowTool() + return cached +} diff --git a/tsconfig.json b/tsconfig.json index bcc029849..6790e3be8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,13 @@ "@claude-code-best/agent-tools/*": ["./packages/agent-tools/src/*"], "@claude-code-best/agent-tools": ["./packages/agent-tools/src/index.ts"], "@claude-code-best/weixin/*": ["./packages/weixin/src/*"], - "@claude-code-best/weixin": ["./packages/weixin/src/index.ts"] + "@claude-code-best/weixin": ["./packages/weixin/src/index.ts"], + "@claude-code-best/workflow-engine/*": [ + "./packages/workflow-engine/src/*" + ], + "@claude-code-best/workflow-engine": [ + "./packages/workflow-engine/src/index.ts" + ] } }, "include": [