feat(workflow): add workflow engine, /workflows panel, /ultracode skill

将 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 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-13 20:07:18 +08:00
parent 91cffe16e2
commit d236880bc3
106 changed files with 16127 additions and 834 deletions

View File

@@ -6,7 +6,12 @@
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
"includes": [
"**",
"!!**/dist",
"!!**/.claude/workflows",
"!!**/*.workflow.mjs"
]
},
"formatter": {
"enabled": true,

View File

@@ -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=="],

View File

@@ -1,102 +1,183 @@
# WORKFLOW_SCRIPTS — 工作流自动化
# WORKFLOW_SCRIPTS — 确定性多 agent 工作流编排
> Feature Flag: `FEATURE_WORKFLOW_SCRIPTS=1`
> 实现状态:全部 Stub7 个文件),布线完整
> 引用数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/<name>.ts Workflow 工具name/script/scriptPath/args/resumeFromRunId
namedWorkflowCommands.ts src/workflow/wiring.ts (createWorkflowToolCore)
/<name> 命令发现)
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/` 生成 `/<name>` 命令 |
| 权限请求 | `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/<name>.js|.mjs`(也接受 `.ts`,但**引擎不转译 TS**,含类型注解会报语法错——推荐 `.js`/`.mjs`),自动成为 `/<name>` 命令。
```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` 工具启动 workflowinput schema 见引擎包 `tool/schema.ts`
## 六、文件索引
| 字段 | 说明 |
|------|------|
| `script` | 内联脚本字符串 |
| `name` | 命名 workflow 名(对应 `.claude/workflows/<name>` |
| `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/<runId>/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` | `/<name>` 命令发现 |
| `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 / 并发) |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
# Commit 审查报告0768d4dc8f69023b55adf2f5c176c766640600cb
- **Commit**: `0768d4dc8f69023b55adf2f5c176c766640600cb`
- **Title**: `feat(workflow): add workflow engine, /workflows panel, /ultracode skill`
- **Author**: claude-code-best <claude-code-best@proton.me>
- **Date**: 2026-06-13
- **规模**: 90 文件,+12925 / -833
- **审查日期**: 2026-06-13
- **审查方法**: 多视角对抗式 workflow 编排7 个并行 reviewer → consolidator 合并 → refuter 反驳 → final judgejournal `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 个并行 reviewerarchitecture / 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 没覆盖,因为我正好用这个引擎跑审查才暴露):见下一节。
---
## 我实证发现的 bugjudge 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完成时**有**收到 `<task-notification>`
- **根因**: 引擎确实通过 `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 黑名单 + 文档明确"沙箱保确定性,不保安全"。
### MEDIUM7 项,按价值排序)
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 实花 200100% 超支)。默认 `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<WorkflowInput>`,应该 `export type WorkflowInput = z.infer<typeof workflowInputSchema>`
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<string, unknown>`
- 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<typeof workflowInputSchema>`** — 消除双重断言,防 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 语义)报假阳性,多一层反驳能纠偏。
完整 journal10 条 agent 输出):`.claude/workflow-runs/wtujwahzf/journal.jsonl`

View File

@@ -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/<name>.ts|.js|.mjs` 脚本文件 → 命名 workflow`Workflow` 工具 `name` 参数解析到它)+ 生成 `/<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.tsWORKFLOW_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/<runId>/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 输入 schemascript/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/<name>.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/<runId>/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){ <body> }`
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 上限 | 抛错 |
| killabort | signal 传播;`agent()` 检查 signalworkflow 停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+usageschema→解析校验返回对象处理 `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` → 生成 `/<name>` 命令,调用时以脚本启动引擎 |
| `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 阶段补一个最小版本。

View File

@@ -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` 声明 phasepending `○`)与 store phaserunning `●` / 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` 提示用 `/<name> 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 无)显示 ``。
- 选中行/光标行底色条(断言对应 `<Text backgroundColor>`)。
- `x` kill、`r` resumemock 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 却没调用)。

View File

@@ -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 adapterv1 单后端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/``/<name>`
- `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 <runId>`)。
**删除**
- `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 adapterdefault 路由)
├─ hostHandle.ts # 不透明 host bundletoolUseContext/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[] reduceragentId 关联)
├─ panel/
│ ├─ WorkflowsPanel.tsx # 双栏useSyncExternalStore 订阅 store
│ ├─ WorkflowList.tsx # 左栏:扁平 workflow 列表(名字+状态+当前 phase+计数)
│ ├─ WorkflowDetail.tsx # 右栏:聚焦 workflow 的 phase 横条 + 扁平 agent 列表
│ └─ useWorkflowKeyboard.ts
├─ wiring.ts # createWorkflowToolCore(): buildTool(引擎描述符)
└─ namedWorkflowCommands.ts # 扫描→/<name>
```
**依赖方向**`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` 内并发调用各自独立 idreducer 按 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<void>
kill(runId: string): void // AbortController.abort() → WorkflowAbortedError → killed
listRuns(): RunProgress[]
getRun(runId: string): RunProgress | undefined
subscribe(listener: () => void): () => void // 供 useSyncExternalStore
listNamed(): Promise<string[]> // 委托 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<AgentRunResult> {
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 idprovider 来自 `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 runsresume 读磁盘 journal`.claude/workflow-runs/<runId>/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 <runId>`,面板以该 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/<runId>/journal.jsonl``/workflows` 面板、`/<name>` 命名命令。
调用即注入上下文,**不改主循环、零运行时副作用**。
## 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/retryper-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 adapterOpenAI/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 级预算。

View File

@@ -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 {

View File

@@ -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<Input>
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<string[]> {
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<WorkflowRun | null> {
try {
const parsed = safeParseJSON(
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
false,
) as Partial<WorkflowRun> | 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<void> {
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<WorkflowRun[]> {
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<WorkflowStep> | 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<WorkflowOutput> {
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<WorkflowOutput> {
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<WorkflowOutput> {
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<WorkflowOutput> {
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<WorkflowInput>) {
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) }
}
},
})

View File

@@ -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')
})
})

View File

@@ -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']

View File

@@ -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<Command[]> {
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
})
}

View File

@@ -0,0 +1,124 @@
/**
* registry 多后端路由演示mock adapter无需 API key
*
* 两个 adapterstrong被 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<AgentRunResult> {
return {
kind: 'ok',
output: `[strong] ← ${p.prompt}`,
usage: { outputTokens: 1 },
}
},
}
const fastAdapter: AgentAdapter = {
id: 'fast',
capabilities: { structuredOutput: false },
async run(p: AgentRunParams): Promise<AgentRunResult> {
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()
}
}

View File

@@ -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 searchresearch 角度即可联网。
- **命名命令复用**:把 `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 测试兜底,可作为基础继续建。

View File

@@ -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,
}

View File

@@ -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-runsresume 复用)
*/
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<T>(fn: () => Promise<T>, retries = 4): Promise<T> {
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<AgentRunResult> {
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<string, AbortController>()
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<void> {
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()
}

View File

@@ -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' } },
}
// 最小 workflow2 角度并行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<T>(fn: () => Promise<T>, retries = 4): Promise<T> {
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<AgentRunResult> {
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<void> {
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()
}

View File

@@ -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"
}
}

View File

@@ -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<string, AgentRunResult>,
): {
ports: WorkflowPorts
events: ProgressEvent[]
runStatus: Map<string, string>
} {
const events: ProgressEvent[] = []
const runStatus = new Map<string, string>()
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/<name>.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<string, string>()
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 === undefinedstring 上无 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 = subDirscriptPath 是 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 为对象 → completeformatValue 走 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 })
}
})

View File

@@ -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> = {}): AgentRunParams => ({
prompt: 'p',
...over,
})
const CTX = {
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r',
}
test('resolve 默认走 default adapterrun 返回结果', 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)
})

View File

@@ -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<string, AgentRunResult>) {
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 各自拿到唯一 agentIdstarted/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])
})

View File

@@ -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)
})

View File

@@ -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<void> => {
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 permitacquire 不阻塞', 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 () => {
// 修复 Lqueued 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()
})

View File

@@ -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')
})

View File

@@ -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)
})

View File

@@ -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')
})

View File

@@ -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<string, AgentRunResult>
runner: (params: AgentRunParams) => Promise<AgentRunResult>
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<typeof createEngineContext>
events: ProgressEvent[]
hooks: ReturnType<typeof makeHooks>
} {
const { emitter, events } = createBufferingEmitter()
const results = overrides.agentResults ?? new Map<string, AgentRunResult>()
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/donelog 发射 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 会排队。
// 旧 bugassertCanSpend 在 acquire 之前,所有 waiter 入队时 spent=0 都过检;
// 后续 permit 释放后 waiter 直接跑 runner、扣预算不再 re-check → 全部超支。
// 修复assertCanSpend 移入临界区waiter 被唤醒后先看 spent 再决定是否跑。
// 强制 capacity=1serializing 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 token2 次即超 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 成 nullassertCanSpend 抛错)
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()
})

View File

@@ -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')
})

View File

@@ -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<AgentRunResult> => {
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<AgentRunResult> => {
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 同 runIdjournal 命中,不重跑
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 })
}
})

View File

@@ -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 })
}
})

View File

@@ -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 })
}
})

View File

@@ -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()
})

View File

@@ -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()
})

View File

@@ -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<string, AgentRunResult>,
): 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<string, AgentRunResult>,
): { 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('resumejournal 命中则不调用 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 新 entrylive
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 })
}
})

View File

@@ -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('未知字段被 stripzod 默认非 strictsafeParse 成功)', () => {
const r = workflowInputSchema.safeParse({ script: 'x', extra: 1 })
expect(r.success).toBe(true)
})

View File

@@ -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')
})

View File

@@ -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,
)
})

View File

@@ -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')
})

View File

@@ -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<AgentRunResult>
/** 初始化(由 registry.initializeAll 触发)。 */
initialize?(): Promise<void>
/** 销毁(由 registry.disposeAll 触发)。 */
dispose?(): Promise<void>
}
/** 路由规则:决定哪些 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 注册 adapterroute/default 配路由resolve 按
* 规则顺序匹配选 adapter。adapter 的 lifecycleinitialize/dispose通过
* initializeAll/disposeAll 统一触发(由调用方在运行前后调)。
*/
export class AgentAdapterRegistry {
private readonly adapters = new Map<string, AgentAdapter>()
private readonly rules: AdapterRouteRule[] = []
private defaultId: string | null = null
/** 注册一个 adapterid 重复则覆盖)。链式。 */
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<void> {
for (const a of this.adapters.values()) {
await a.initialize?.()
}
}
/** 触发所有 adapter 的 dispose跳过未实现的。 */
async disposeAll(): Promise<void> {
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
}

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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),
)
}

View File

@@ -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,
}
}

View File

@@ -0,0 +1,15 @@
/** 引擎级可预期错误(脚本错、上限、嵌套)。 */
export class WorkflowError extends Error {
constructor(message: string) {
super(message)
this.name = 'WorkflowError'
}
}
/** workflow 被 abortkill。 */
export class WorkflowAbortedError extends Error {
constructor() {
super('workflow 已被取消abort')
this.name = 'WorkflowAbortedError'
}
}

View File

@@ -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<unknown>
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 <T, R>(
items: readonly T[],
...stages: Array<
(prev: unknown, item: T, index: number) => Promise<unknown>
>
): Promise<Array<R | null>> => {
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<R | null> => {
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<SubWorkflowRunner>[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
}

View File

@@ -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<string, unknown> = {}
for (const k of keys) sorted[k] = rest[k as keyof typeof rest]
return JSON.stringify(sorted)
}
/** agent() 调用的确定性 keyprompt + 规范化 params 的 sha256。 */
export function agentCallKey(prompt: string, params: AgentRunParams): string {
return createHash('sha256')
.update(prompt + '\n' + canonicalParams(params))
.digest('hex')
}
/** 文件式 JournalStorejsonl每个 run 一个目录)。纯 fs无核心依赖。 */
export function createFileJournalStore(runsDir: string): JournalStore {
const pathOf = (runId: string) => join(runsDir, runId, 'journal.jsonl')
return {
async read(runId): Promise<JournalEntry[]> {
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 })
},
}
}

View File

@@ -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<string[]> {
let files: string[]
try {
files = await readdir(workflowDir)
} catch {
return []
}
return files
.filter(f => isScriptExt(parse(f).ext))
.map(f => parse(f).name)
.sort()
}

View File

@@ -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
}

View File

@@ -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
/** resumetrue 时载入既有 journal 重放。 */
resume?: boolean
/** resume 时脚本源码 hash 是否变化。true 则忽略 journal 全重跑。 */
scriptChanged?: boolean
}
export async function runWorkflow(
opts: RunWorkflowOptions,
): Promise<WorkflowRunResult> {
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<string> {
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')
}

View File

@@ -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<string, unknown>) => Promise<unknown>
parallel: <T>(thunks: Array<() => Promise<T>>) => Promise<Array<T | null>>
pipeline: <T, R>(
items: readonly T[],
...stages: Array<
(prev: unknown, item: T, index: number) => Promise<unknown>
>
) => Promise<Array<R | null>>
phase: (title: string) => void
log: (message: string) => void
workflow: (
nameOrRef: string | { scriptPath: string },
args?: unknown,
) => Promise<unknown>
}
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<string, unknown>
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<unknown>
}
export type ParsedScript = {
meta: WorkflowMeta | null
execute: (
hooks: WorkflowHooks,
args: unknown,
budget: unknown,
) => Promise<unknown>
}
/** 校验 + 包装脚本为可执行 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<unknown>
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,
)
},
}
}

View File

@@ -0,0 +1,26 @@
import { Ajv, type ValidateFunction } from 'ajv'
const cache = new WeakMap<object, ValidateFunction>()
/**
* 用 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'),
}
}

View File

@@ -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'

View File

@@ -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<AgentRunResult>
}
/** 进度事件发射。 */
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<JournalEntry[]>
append(runId: string, entry: JournalEntry): Promise<void>
truncate(runId: string): Promise<void>
}
/** 取消/权限门。 */
export type PermissionGate = {
isAborted(host: HostHandle): boolean
}
/** 日志 + 遥测。 */
export type Logger = {
debug(msg: string): void
event(name: string, metadata?: Record<string, unknown>): 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
}

View File

@@ -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 }
}

View File

@@ -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<WorkflowInput>
isEnabled: () => boolean
isReadOnly: (input: WorkflowInput) => boolean
description: () => Promise<string>
prompt: () => Promise<string>
renderToolUseMessage: (input: Partial<WorkflowInput>) => 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 ?? `<inline run ${runId}>`
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 之一')
}

View File

@@ -0,0 +1 @@
export { WORKFLOW_TOOL_NAME } from '../constants.js'

View File

@@ -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/<name>.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<WorkflowInput>` 双重断言连接——schema 改字段
* 但 type 没动时 TS 不会报错。z.infer 后 schema/type 永远同步。
*/
export type WorkflowInput = z.infer<typeof workflowInputSchema>
/** schema 的 typeof 类型(用于"以 schema 为准"的精确签名)。 */
export type WorkflowInputSchema = typeof workflowInputSchema

View File

@@ -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
}

View File

@@ -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"]
}

View File

@@ -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 */

View File

@@ -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

View File

@@ -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;

View File

@@ -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 killrunning
const onKill =
task.status === 'running' && killWorkflowTask ? () => killWorkflowTask(task.id, setAppState) : undefined;
return (
<WorkflowDetailDialog
workflow={task}
onDone={onDone as (message?: string, options?: { display?: string }) => 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}
<Box
key={`workflow-${task.id}`}
/>
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();
}
}}
>
<Dialog
title={task.workflowName}
subtitle={
<Text dimColor>
{task.status}
{task.summary ? ` · ${task.summary}` : ''}
</Text>
}
onCancel={goBackToList}
inputGuide={() => (
<Byline>
<KeyboardShortcutHint shortcut="←" action="go back" />
<KeyboardShortcutHint shortcut="Esc" action="close" />
{onKill && <KeyboardShortcutHint shortcut="x" action="stop" />}
</Byline>
)}
>
{task.status === 'failed' && task.error ? (
<Box flexDirection="column">
<Text color="error">{task.error}</Text>
<Text color="subtle"> /workflows agent </Text>
</Box>
) : (
<Text color="subtle"> /workflows agent </Text>
)}
</Dialog>
</Box>
);
}
case 'monitor_mcp':
if (!MonitorMcpDetailDialog) return null;
return (

View File

@@ -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<LocalWorkflowTaskState>;
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 (
<Box flexDirection="column" tabIndex={0} borderStyle="round" onKeyDown={handleKeyDown}>
<Dialog
title="Workflow"
subtitle={
<Text dimColor>
{elapsedTime} · {workflow.workflowName}
</Text>
}
onCancel={onBack ?? (() => {})}
inputGuide={() => (
<Byline>
{onBack && <KeyboardShortcutHint shortcut={'\u2190'} action="go back" />}
<KeyboardShortcutHint shortcut="Esc" action="close" />
{workflow.status === 'running' && onKill && <KeyboardShortcutHint shortcut="x" action="stop" />}
</Byline>
)}
>
<Box flexDirection="column" gap={1}>
<Text>
<Text bold>Status:</Text>{' '}
{workflow.status === 'running' ? (
<Text color="ansi:green">running</Text>
) : workflow.status === 'completed' ? (
<Text color="ansi:green">{workflow.status}</Text>
) : (
<Text color="ansi:red">{workflow.status}</Text>
)}
</Text>
<Text>
<Text bold>Description:</Text> {workflow.description}
</Text>
<Text>
<Text bold>Workflow:</Text> {workflow.workflowName}
</Text>
<Text>
<Text bold>File:</Text> {workflow.workflowFile}
</Text>
{workflow.summary && (
<Text>
<Text bold>Summary:</Text> {workflow.summary}
</Text>
)}
{workflow.output && (
<Box flexDirection="column">
<Text bold>Output:</Text>
<Text dimColor>{workflow.output}</Text>
</Box>
)}
</Box>
</Dialog>
</Box>
);
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
})
})

View File

@@ -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()

View File

@@ -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 1stage 内顺序。单 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: '<id>'\` — 重放该 run 的 journal已完成的 \`agent()\` 秒回缓存结果;首个发散点之后全部现场重跑。
- \`budget.total\` — token 硬顶(默认 \`null\` = 无限);\`budget.spent()\` / \`budget.remaining()\` 读实时消耗。耗尽后再发 agent 抛错。
## 文件与命令
- 脚本目录:\`.claude/workflows/<name>.ts|.js|.mjs\` → 自动成 \`/<name>\` 命令。
- run 记录:\`.claude/workflow-runs/<runId>/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 }]
},
})
}

View File

@@ -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<LocalWorkflowTaskState>(taskId, setAppState, task => ({
...task,
@@ -103,6 +106,7 @@ export function failWorkflowTask(
endTime: Date.now(),
notified: true,
abortController: undefined,
...(error !== undefined ? { error } : {}),
}))
}

View File

@@ -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<string, any> }
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()
})
})

View File

@@ -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'

View File

@@ -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 */

View File

@@ -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({
<PermissionDialog title="Workflow" workerBadge={workerBadge}>
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text bold color={theme.permission as any}>
<Text bold color={theme.permission as keyof Theme}>
Execute workflow: {input.workflow}
</Text>
{input.args && <Text dimColor>Arguments: {input.args}</Text>}

View File

@@ -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('✗');
});
// 修复 MuseSyncExternalStore / 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<React.ReactNode>)(
() => {},
{ 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');
});

View File

@@ -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()
})

View File

@@ -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> = {},
): 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),
)
// 第一次 emitlistener 记录初始 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)
})
})

View File

@@ -0,0 +1,109 @@
import { expect, test } from 'bun:test'
// 注意:本测试不 mock bootstrap/state、utils/cwd、analytics、debug。
// 原因mock.module 是进程全局的last-write-winsmock 这些公共模块会污染
// 同进程其他测试(如 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_startedstore 能看到
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 对象承载 tasksregisterTask 走真实代码路径。
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 回归', () => {
// 历史 bughostFactory.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())
})

View File

@@ -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()
})

View File

@@ -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 落地 outputShapeok·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()
})

View File

@@ -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>): 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('filterAgentsByPhaseAll / 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('tabLabelworkflow 名 + runId 后 4 位短码', () => {
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
})

View File

@@ -0,0 +1,335 @@
import { expect, test } from 'bun:test'
// DI 模式:不使用 mock.module进程全局、last-write-wins会污染同进程其他测试如
// autonomy.test.ts。改为手工构造 FAKE WorkflowPortsregistry.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 portsregistry.run 返回固定 AgentRunResulttaskRegistrar 带 binding
// journalStore 内存空实现。progressEmitter.emit → bus.emitstore 已在构造时订阅 bus
// 注意runWorkflow 自身会发 run_started/run_donetaskRegistrar 只管 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<typeof createProgressStoreFromBus>
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<string, { abort: AbortController }>()
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<AgentRunResult> => {
throw new Error(opts.adapterThrow)
}
: async (): Promise<AgentRunResult> =>
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<void> {
await new Promise(r => setTimeout(r, 60))
}
test('launch → completedstore 出现该 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)
// 触发 ScriptErrormeta 字面量缺 descriptionvalidateMeta 要求 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 statuserror 透传;
// 或透传到 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)
})
// ---- 修复 Nshutdown 清理 ----
test('shutdown 杀掉所有 running runtaskRegistrar.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<AgentRunResult> => {
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()
})

View File

@@ -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('agentVisualrunning → ● warning running', () => {
const a: AgentProgress = { id: 1, status: 'running' }
expect(agentVisual(a)).toEqual({
mark: '●',
color: 'warning',
suffix: 'running',
})
})
test('agentVisualdone·object → ✓ success object', () => {
const a: AgentProgress = {
id: 1,
status: 'done',
resultKind: 'ok',
outputShape: 'object',
}
expect(agentVisual(a)).toEqual({
mark: '✓',
color: 'success',
suffix: 'object',
})
})
test('agentVisualdone·text → ✓ success text', () => {
const a: AgentProgress = {
id: 1,
status: 'done',
resultKind: 'ok',
outputShape: 'text',
}
expect(agentVisual(a)).toEqual({
mark: '✓',
color: 'success',
suffix: 'text',
})
})
test('agentVisualdead → ✗ error dead', () => {
const a: AgentProgress = { id: 1, status: 'done', resultKind: 'dead' }
expect(agentVisual(a)).toEqual({ mark: '✗', color: 'error', suffix: 'dead' })
})

View File

@@ -0,0 +1,30 @@
import { expect, test } from 'bun:test'
import { routeWorkflowKey } from '../panel/useWorkflowKeyboard.js'
test('Tab → nextTabShift+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 → killr → resumen → 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()
})

View File

@@ -0,0 +1,158 @@
// 深度集成后端:从活会话解析 agent/model/tools委托核心 runAgent。
// 实现 AgentAdapter 接口,由 registryU5注册并路由。
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<AgentRunResult> {
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 是顶层 ModelAliasworkflow 的 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 } }
},
}

View File

@@ -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
}

View File

@@ -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每个生成一个 /<name> 命令。 */
export async function getWorkflowCommands(
cwd: string = getProjectRoot(),
): Promise<Command[]> {
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}`,
},
]
},
}))
}

View File

@@ -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<RunProgress['status']> = 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<string, RunProgress['status'] | undefined>()
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_ID_TAG}>
<${TASK_TYPE_TAG}>${WORKFLOW_TASK_TYPE}</${TASK_TYPE_TAG}>
<${STATUS_TAG}>${run.status}</${STATUS_TAG}>
<${SUMMARY_TAG}>${summary}</${SUMMARY_TAG}>
</${TASK_NOTIFICATION_TAG}>`
}

View File

@@ -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 <Text color="subtle">(no agents in this phase)</Text>;
}
return (
<Box flexDirection="column">
{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 (
<Box key={a.id}>
<Text backgroundColor={selected ? 'claude' : undefined}>
<Text color={v.color as keyof Theme}>{v.mark}</Text> {label} <Text color="subtle">{v.suffix}</Text>
</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -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 (
<Box flexDirection="column">
{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 (
<Box key={row.title}>
<Text backgroundColor={selected ? 'claude' : undefined} color={color}>
{selected ? '▶' : ' '}
{mark} {row.title.padEnd(10)} {row.done}/{row.total}
</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -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 <Text color="subtle">(no runs)</Text>;
}
return (
<Box>
{runs.map(r => {
const active = r.runId === activeRunId;
const label = tabLabel(r.workflowName, r.runId);
const underline = '═'.repeat(label.length + 2);
return (
<Box key={r.runId} flexDirection="column" marginRight={2}>
<Box>
<Text color={RUN_STATUS_COLOR[r.status] as keyof Theme}>{STATUS_DOT[r.status]}</Text>
<Text> </Text>
<Text color={active ? 'claude' : undefined} bold={active}>
{label}
</Text>
</Box>
<Text color={active ? 'claude' : undefined}>{active ? underline : ''}</Text>
</Box>
);
})}
</Box>
);
}

View File

@@ -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 订阅 WorkflowServicestore 返回稳定快照,无变更不重渲染)。
* - 焦点状态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<string | null>(null);
const [focusColumn, setFocusColumn] = useState<FocusColumn>('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 title0 = 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 上下文,请在主会话中用 /<name> resume 重试。');
return;
}
void svc
.launch({ resumeFromRunId: focused.runId, name: focused.workflowName }, context, canUseTool)
.catch(e => onDone(`resume 失败:${(e as Error).message}`));
},
newRun: () => onDone('Tip: 用 /<name> 启动命名 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 (
<Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
<Box justifyContent="space-between">
<Text bold>Workflows</Text>
<Text color="subtle">
{running} running · {done} done
</Text>
</Box>
<Box marginTop={1}>
<TabsBar runs={runs} activeRunId={activeRunId} />
</Box>
<Box flexDirection="row" marginTop={1}>
<Box width="25%" flexDirection="column">
<Text color={focusColumn === 'phases' ? 'claude' : 'subtle'} bold>
PHASES
</Text>
<PhaseSidebar phases={phases} agents={focused?.agents ?? []} selectedIndex={clampedPhase} />
</Box>
<Text color="subtle"></Text>
<Box flexGrow={1} flexDirection="column">
<Text color={focusColumn === 'agents' ? 'claude' : 'subtle'} bold>
AGENTS · {phaseHeader}
</Text>
<AgentList agents={visibleAgents} selectedIndex={clampedAgent} />
</Box>
</Box>
<Box marginTop={1}>
<Text color="subtle">Tab run · / · / · x kill · r resume · q quit</Text>
</Box>
</Box>
);
}

View File

@@ -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) => (
<SentryErrorBoundary name="WorkflowsPanel">
<WorkflowsPanel onDone={onDone} context={context} />
</SentryErrorBoundary>
);

View File

@@ -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
}
/**
* 合并 declaredPhasesmeta 声明)与 run.phases实际 running/done
* - 声明顺序优先;未在 declared 但实际出现的 phase 追加末尾。
* - 实际无记录 → pending否则取实际 status。
* - done/total = 该 phase 下 done / 全部 agent 数。
*/
export function mergePhases(
run: Pick<RunProgress, 'declaredPhases' | 'phases' | 'agents'>,
): MergedPhase[] {
const actualByTitle = new Map(run.phases.map(p => [p.title, p]))
const seen = new Set<string>()
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)}`
}

View File

@@ -0,0 +1,53 @@
import type { AgentProgress, RunProgress } from '../progress/store.js'
/** run 状态 → 圆点字符(顶部 tab 用)。 */
export const STATUS_DOT: Record<RunProgress['status'], string> = {
running: '●',
completed: '✓',
failed: '✗',
killed: '■',
}
/** run 状态 → ink theme 颜色 token沿用现有 WorkflowList 配色)。 */
export const RUN_STATUS_COLOR: Record<RunProgress['status'], string> = {
running: 'warning',
completed: 'success',
failed: 'error',
killed: 'subtle',
}
/** phase 在侧栏的合并状态(含 pendingmeta 声明但未启动)。 */
export type PhaseStatus = 'running' | 'done' | 'pending'
export const PHASE_MARK: Record<PhaseStatus, string> = {
running: '●',
done: '✓',
pending: '○',
}
export const PHASE_COLOR: Record<PhaseStatus, string> = {
running: 'warning',
done: 'success',
pending: 'subtle',
}
/** agent 行的视觉三件套:标记字符 + 颜色 + 行尾文字后缀。 */
export type AgentVisual = { mark: string; color: string; suffix: string }
/**
* agent 状态 → 视觉。
* - running → ● warning
* - done·dead → ✗ error
* - done·okoutputShape='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',
}
}

View File

@@ -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
}
})
}

165
src/workflow/ports.ts Normal file
View File

@@ -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<string, RunBinding>()
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 // v1skip/retry 不接线seam 保留)
},
}
return {
hostFactory: makeHostFactory(),
agentAdapterRegistry: registry,
agentRunner: {
// 死代码兜底hooks 始终走 agentAdapterRegistryports 必设)。若到此说明 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}`),
},
}
}

Some files were not shown because too many files have changed in this diff Show More