mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
24 Commits
v2.6.10
...
fix/ripgre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2d511b53 | ||
|
|
9d6a98dd06 | ||
|
|
2714bbf812 | ||
|
|
21e42e24b1 | ||
|
|
58ee6419b1 | ||
|
|
3e3e1de81b | ||
|
|
5bfe6fa590 | ||
|
|
91cffe16e2 | ||
|
|
c4dd45f8df | ||
|
|
b5beafb9bf | ||
|
|
e897385a7e | ||
|
|
83e891d7b2 | ||
|
|
bee711f431 | ||
|
|
4d930eb4eb | ||
|
|
2567e77d37 | ||
|
|
fac16dab0a | ||
|
|
e77bfa662e | ||
|
|
1faedff25d | ||
|
|
be0c65678d | ||
|
|
a972ed795c | ||
|
|
9947ae75da | ||
|
|
6b205f5798 | ||
|
|
7e3d825f0e | ||
|
|
a077ec8d85 |
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -47,14 +47,15 @@ jobs:
|
||||
test -s coverage/lcov.info
|
||||
grep -q '^SF:' coverage/lcov.info
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
files: ./coverage/lcov.info
|
||||
disable_search: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
# codecov 坏了,老是失败,先注释掉
|
||||
# - name: Upload coverage to Codecov
|
||||
# if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
# uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25
|
||||
# with:
|
||||
# fail_ci_if_error: true
|
||||
# files: ./coverage/lcov.info
|
||||
# disable_search: true
|
||||
# token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build:vite
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!!**/dist"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!!**/dist",
|
||||
"!!**/.claude/workflows",
|
||||
"!!**/*.workflow.mjs"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
13
bun.lock
13
bun.lock
@@ -332,6 +332,17 @@
|
||||
"qrcode": "^1.5.4",
|
||||
},
|
||||
},
|
||||
"packages/workflow-engine": {
|
||||
"name": "@claude-code-best/workflow-engine",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.18.0",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"@inquirer/prompts": "8.4.2",
|
||||
@@ -586,6 +597,8 @@
|
||||
|
||||
"@claude-code-best/weixin": ["@claude-code-best/weixin@workspace:packages/weixin"],
|
||||
|
||||
"@claude-code-best/workflow-engine": ["@claude-code-best/workflow-engine@workspace:packages/workflow-engine"],
|
||||
|
||||
"@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.6 MiB |
@@ -87,6 +87,7 @@
|
||||
"docs/internals/sentry-setup",
|
||||
"docs/internals/hidden-features",
|
||||
"docs/internals/ant-only-world",
|
||||
"docs/internals/session-transcript-persistence",
|
||||
"docs/features/debug-mode",
|
||||
"docs/features/buddy"
|
||||
]
|
||||
|
||||
@@ -1,86 +1,216 @@
|
||||
---
|
||||
title: "协调者与蜂群模式 - 多 Agent 高级编排"
|
||||
description: "从源码角度解析 Claude Code 多 Agent 协作:Coordinator Mode 的 System Prompt 设计、Worker 生命周期、Task 通信协议和 Swarm 蜂群的任务分配机制。"
|
||||
keywords: ["协调者模式", "蜂群模式", "Agent Swarm", "多 Agent 协作", "任务编排"]
|
||||
title: "协调者与蜂群模式:多 Agent 编排机制"
|
||||
description: "从源码角度拆解 Claude Code 的 Coordinator Mode、Agent Teams / Swarm、subagent、teammate、Mailbox、Task 工具、runtime task、状态恢复与排障路径。"
|
||||
keywords: ["协调者模式", "蜂群模式", "Agent Swarm", "Agent Teams", "多 Agent 协作", "任务编排", "Mailbox", "Subagent"]
|
||||
---
|
||||
|
||||
{/* 本章目标:从源码角度揭示 Coordinator Mode 和 Agent Swarms 的架构设计 */}
|
||||
Claude Code 里有很多看起来都叫“多 Agent”的东西:`Agent` 工具、fork agent、Coordinator Mode、Agent Teams / Swarm、remote agent、后台 runtime task、`TaskCreate` 任务白板。它们共享部分底层设施,但不是同一个抽象。
|
||||
|
||||
## 两种协作模式的架构差异
|
||||
这篇文档解决的是跨机制理解问题:当你看到一个任务被“派出去”、一个 teammate 变成 idle、一个 `<task-notification>` 回到主线程、一个 team 目录还在但 teammate 不跑了,应该知道它属于哪套机制、状态放在哪里、通信走哪条路、哪些东西能恢复。
|
||||
|
||||
| 维度 | Coordinator Mode | Agent Swarms |
|
||||
|------|-----------------|--------------|
|
||||
| **门控** | `feature('COORDINATOR_MODE')` + `CLAUDE_CODE_COORDINATOR_MODE=1` | `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` 环境变量 |
|
||||
| **拓扑** | 星型:Coordinator 居中,Worker 外围 | 星型+P2P 混合:Team Lead 协调,Teammate 间可直接通信 |
|
||||
| **角色** | 明确分工:Coordinator 编排、Worker 执行 | Team Lead 协调 + Teammate 自主认领任务 |
|
||||
| **通信** | `SendMessage` 定向通信 + `<task-notification>` | Mailbox 消息系统(message / broadcast) |
|
||||
| **适用** | 需要集中决策的复杂任务 | 并行度高、需要 Teammate 间直接协作的任务 |
|
||||
## 全局心智模型
|
||||
|
||||
两者不是互斥的——理论上 Coordinator Mode 可以在 Agent Teams 架构之上运行(概念层叠加,非嵌套团队),将 Coordinator 作为特殊的 Team Lead,但这部分集成(`workerAgent.ts` 中的 `getCoordinatorAgents`)目前为 stub 实现,尚未完整落地。
|
||||
最短心智模型是:
|
||||
|
||||
## Coordinator Mode:星型编排架构
|
||||
|
||||
### 激活机制
|
||||
|
||||
```typescript
|
||||
// src/coordinator/coordinatorMode.ts:36
|
||||
export function isCoordinatorMode(): boolean {
|
||||
if (feature('COORDINATOR_MODE')) {
|
||||
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
|
||||
}
|
||||
return false // 外部构建始终 false
|
||||
}
|
||||
```text
|
||||
Agent 是派人干活。
|
||||
TaskCreate 是往白板上贴任务卡。
|
||||
Runtime Task 是正在跑的人或远端人影。
|
||||
Coordinator 是星型编排器。
|
||||
Swarm 是有成员、有邮箱、有任务白板的团队。
|
||||
```
|
||||
|
||||
Coordinator Mode 需要双重门控:构建时 `feature('COORDINATOR_MODE')` 和运行时环境变量。`matchSessionMode()` 在会话恢复时自动同步模式状态——如果恢复的会话是 coordinator 模式,它会翻转环境变量以确保一致性。
|
||||
先把几个词压平:
|
||||
|
||||
### Coordinator 的工具集
|
||||
| 概念 | 本质 | 入口 | 状态位置 | 结果回路 |
|
||||
|---|---|---|---|---|
|
||||
| 普通 sync subagent | 一次性前台 `Agent` tool call | `Agent({ subagent_type })` | foreground `LocalAgentTask` | 当前 turn 的 `tool_result` |
|
||||
| 普通 async subagent | 一次性后台 agent | `Agent({ subagent_type, async: true })` 或自动后台化 | `AppState.tasks` + sidechain | `async_launched` + `<task-notification>` |
|
||||
| fork agent | 继承父上下文和 exact tools 的后台分支 | 省略 `subagent_type` 且 fork gate 满足 | `LocalAgentTask` + `.meta.json` | `<task-notification>` |
|
||||
| coordinator worker | Coordinator 派出的 `worker` async subagent | Coordinator 调 `Agent({ subagent_type: "worker" })` | `LocalAgentTask` | `<task-notification>` + `SendMessage(to: agentId)` |
|
||||
| swarm teammate | 长生命周期团队成员 | `Agent({ name, team_name?, prompt })` | `InProcessTeammateTask` 或 pane member | mailbox by name,可 idle 后继续 |
|
||||
| remote agent | 远端执行体的本地镜像 | `Agent(..., isolation: "remote")` | `RemoteAgentTask` + remote sidecar | CCR events / polling |
|
||||
| work item task | 共享任务白板条目 | `TaskCreate/Update/List/Get` | `~/.claude/tasks/<taskListId>/*.json` | teammate / lead 认领和更新 |
|
||||
| runtime task | 正在运行或曾运行的后台执行体 | agent、shell、workflow、remote 等入口 | `AppState.tasks` | UI、spinner、resume、kill |
|
||||
|
||||
Coordinator 被剥夺了所有"动手"工具,只保留编排能力:
|
||||
## 系统分层
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| **Agent** | 启动新 Worker(`subagent_type: "worker"`) |
|
||||
| **SendMessage** | 向已有 Worker 发送后续指令 |
|
||||
| **TaskStop** | 中途停止走错方向的 Worker |
|
||||
| **subscribe_pr_activity** | 订阅 GitHub PR 事件(review comments、CI 结果) |
|
||||
多 Agent 系统可以看成五层,每层回答一个问题:
|
||||
|
||||
Coordinator **不写代码、不读文件、不执行命令**——它的核心职责是:理解需求、分配任务、综合结果,以及在无需工具时直接回答用户问题。
|
||||
| 层 | 回答的问题 | 典型对象 |
|
||||
|---|---|---|
|
||||
| 入口层 | 用户或模型通过什么工具启动动作 | `/coordinator`、`AgentTool`、`TeamCreate`、`SendMessage`、`TaskUpdate` |
|
||||
| 编排层 | 谁负责拆解、派发、控制和综合 | Coordinator、Team Lead、AgentTool routing |
|
||||
| 运行层 | 谁真正执行或代表执行状态 | `LocalAgentTask`、`InProcessTeammateTask`、`RemoteAgentTask` |
|
||||
| 通信层 | 结果和控制信号如何回流 | `tool_result`、`<task-notification>`、mailbox、CCR events |
|
||||
| 持久化层 | 进程重启后还能看见什么 | session JSONL、sidechain、team config、task files、inbox、sidecar meta |
|
||||
|
||||
### Worker 的工具权限
|
||||
|
||||
Worker 的可用工具由 `getCoordinatorUserContext()`(`coordinatorMode.ts:80`)动态注入到 System Prompt:
|
||||
|
||||
```typescript
|
||||
// 简化模式下:只有 Bash + Read + Edit
|
||||
const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
||||
? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME]
|
||||
: Array.from(ASYNC_AGENT_ALLOWED_TOOLS)
|
||||
.filter(name => !INTERNAL_WORKER_TOOLS.has(name))
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["入口层<br/>slash command / AgentTool / Team tools / SendMessage"] --> B["编排层<br/>Coordinator / Team Lead / AgentTool routing"]
|
||||
B --> C["运行层<br/>LocalAgentTask / RemoteAgentTask / InProcessTeammateTask"]
|
||||
C --> D["通信层<br/>tool_result / task-notification / mailbox / CCR events"]
|
||||
D --> E["持久化层<br/>session JSONL / sidechain / team config / tasks / inboxes / sidecar meta"]
|
||||
```
|
||||
|
||||
`INTERNAL_WORKER_TOOLS`(TeamCreate、TeamDelete、SendMessage、SyntheticOutput)被显式排除——Worker 不能嵌套创建团队或发送消息,防止不可控的递归。
|
||||
这五层不是一一对应关系。Coordinator worker 在运行层是 `LocalAgentTask`,通信层靠 `<task-notification>` 和 `SendMessage(to: agentId)`;Swarm teammate 在运行层可能是 `InProcessTeammateTask`,通信层靠 mailbox;remote agent 在运行层是本地 `RemoteAgentTask` 镜像,真实执行状态来自 CCR。
|
||||
|
||||
### Scratchpad:跨 Worker 的共享知识库
|
||||
## 什么时候用哪套机制
|
||||
|
||||
当 `isScratchpadGateEnabled()`(内部检查 `tengu_scratch` feature gate)启用时,Workers 获得一个 Scratchpad 目录,Coordinator 通过其系统上下文知晓该目录的存在:
|
||||
| 场景 | 推荐机制 | 为什么 |
|
||||
|---|---|---|
|
||||
| 需要一个主脑拆解、派发、综合、纠偏 | Coordinator Mode | 主线程被限制为编排器,减少直接上手乱改。 |
|
||||
| 多个任务相对独立,需要长期队友持续领任务 | Agent Teams / Swarm | 有 team config、mailbox、shared task list。 |
|
||||
| 只想派一个专家研究或修改 | 普通 subagent | 成本低、模型路径短、结果直接回当前 turn 或后台通知。 |
|
||||
| 想复制当前上下文做并行探索 | fork agent | 继承父上下文和 exact tools,适合分支探索。 |
|
||||
| 想把工作放到远端环境执行 | remote agent | 本地只保留 `RemoteAgentTask` 镜像,执行在 CCR。 |
|
||||
|
||||
```
|
||||
Scratchpad 目录:
|
||||
- Workers 可自由读写,无需权限审批
|
||||
- 用于持久化的跨 Worker 知识
|
||||
- 结构由 Coordinator 决定(无固定格式)
|
||||
两个常见误判:
|
||||
|
||||
| 误判 | 更好的选择 |
|
||||
|---|---|
|
||||
| “我要并行,所以一定用 Swarm” | 如果只是一次性研究/验证,用 async subagent 或 Coordinator worker 更轻。 |
|
||||
| “我要团队,所以 Coordinator 就够了” | 如果需要成员持续认领共享任务、互相发消息、保留 team 状态,用 Swarm。 |
|
||||
|
||||
## 两种多 Agent 拓扑
|
||||
|
||||
Coordinator 和 Swarm 都是多 Agent,但控制权和状态模型完全不同。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph CoordinatorMode["Coordinator Mode"]
|
||||
U1["用户"] --> C["Coordinator 主 Claude"]
|
||||
C -->|Agent worker| W1["worker A<br/>LocalAgentTask"]
|
||||
C -->|Agent worker| W2["worker B<br/>LocalAgentTask"]
|
||||
W1 -->|task-notification| C
|
||||
W2 -->|task-notification| C
|
||||
C -->|SendMessage to agentId| W1
|
||||
end
|
||||
|
||||
subgraph SwarmMode["Agent Teams / Swarm"]
|
||||
U2["用户"] --> L["Team Lead"]
|
||||
L --> TF["TeamFile config.json"]
|
||||
L --> TB["Shared TaskList"]
|
||||
L -->|Agent name| T1["teammate researcher"]
|
||||
L -->|Agent name| T2["teammate tester"]
|
||||
T1 <--> M1["Mailbox inbox JSON"]
|
||||
T2 <--> M2["Mailbox inbox JSON"]
|
||||
T1 --> TB
|
||||
T2 --> TB
|
||||
end
|
||||
```
|
||||
|
||||
这是一个关键的协作原语——Worker A 的研究结果可以写入 Scratchpad,Worker B 直接读取,无需通过 Coordinator 中转。
|
||||
| 维度 | Coordinator Mode | Agent Teams / Swarm |
|
||||
|---|---|---|
|
||||
| 拓扑 | 星型:Coordinator 居中,worker 外围 | 团队型:Team Lead + named teammates + mailbox + task list |
|
||||
| 主 Claude 角色 | 只编排,不直接执行 | 可以直接执行,也可以作为 team lead 管理团队 |
|
||||
| 执行者 | built-in `worker` async subagent | teammate,可能是 in-process,也可能是 pane-based |
|
||||
| 通信方式 | `<task-notification>`,必要时 `SendMessage(to: agentId)` | mailbox by name,支持 P2P、broadcast、structured protocol |
|
||||
| 任务协作 | 不以 `TeamCreate/TaskList` 为核心 | `TeamFile` + shared task list + mailbox |
|
||||
| 恢复模型 | mode 在主 transcript,worker 是 local agent sidechain | team/task/inbox 文件可保留;in-process runner 不完整恢复 |
|
||||
|
||||
### `<task-notification>` 通信协议
|
||||
Coordinator Mode 不是 Swarm 的特殊 Team Lead。它共享 `AgentTool`、`LocalAgentTask`、`SendMessage` 等设施,但不使用 `TeamCreate/TeamDelete/TaskList/TaskUpdate` 作为核心团队协作机制。
|
||||
|
||||
Worker 完成后,Coordinator 收到 XML 格式的通知:
|
||||
## Coordinator Mode 五段状态机
|
||||
|
||||
Coordinator Mode 的核心设计是把主 Claude 降级为编排器:主线程不直接 `Read/Edit/Bash`,而是拆任务、派 worker、综合结果、必要时停止或继续 worker。
|
||||
|
||||
### 1. 启用状态机
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["feature COORDINATOR_MODE?"] -->|no| B["Coordinator unavailable"]
|
||||
A -->|yes| C["/coordinator command"]
|
||||
C --> D{"target mode?"}
|
||||
D -->|enable| E["set CLAUDE_CODE_COORDINATOR_MODE=1"]
|
||||
D -->|disable| F["delete CLAUDE_CODE_COORDINATOR_MODE"]
|
||||
E --> G["save mode metadata"]
|
||||
F --> G
|
||||
G --> H["inject mode reminder"]
|
||||
```
|
||||
|
||||
两层条件都满足才算进入 Coordinator:
|
||||
|
||||
| 条件 | 作用 |
|
||||
|---|---|
|
||||
| `feature("COORDINATOR_MODE")` | 构建/运行 feature gate。 |
|
||||
| `CLAUDE_CODE_COORDINATOR_MODE=1` | 当前进程实际进入 coordinator。 |
|
||||
|
||||
### 2. 恢复状态机
|
||||
|
||||
Coordinator mode 是会话属性,写在主 session JSONL 的 `mode` entry 中:
|
||||
|
||||
```jsonl
|
||||
{"type":"mode","sessionId":"...","mode":"coordinator"}
|
||||
```
|
||||
|
||||
resume 时会把当前环境和 transcript 中的 mode 对齐:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["load transcript mode metadata"] --> B{"env matches transcript mode?"}
|
||||
B -->|yes| C["continue"]
|
||||
B -->|no, transcript=coordinator| D["set CLAUDE_CODE_COORDINATOR_MODE=1"]
|
||||
B -->|no, transcript=normal| E["delete CLAUDE_CODE_COORDINATOR_MODE"]
|
||||
D --> F["emit warning + refresh agent definitions"]
|
||||
E --> F
|
||||
```
|
||||
|
||||
这避免用户在 normal 环境恢复 coordinator 会话,或反过来把普通会话误当 coordinator 运行。
|
||||
|
||||
### 3. Prompt 状态机
|
||||
|
||||
Coordinator prompt 不是只看 env。交互 REPL 侧大致优先级是:
|
||||
|
||||
| 优先级 | 来源 | 说明 |
|
||||
|---|---|---|
|
||||
| 1 | override system prompt | 最高优先级。 |
|
||||
| 2 | coordinator prompt | `isCoordinatorMode()` 且没有 `mainThreadAgentDefinition` 时使用。 |
|
||||
| 3 | main-thread agent prompt | `--agent` / settings agent。 |
|
||||
| 4 | custom/default prompt | 普通主线程 prompt。 |
|
||||
| 5 | append prompt | 追加型补充。 |
|
||||
|
||||
风险点是 `--agent` 和 Coordinator 混用:可能出现工具池已经按 coordinator 过滤,但 system prompt 不是 coordinator 的不一致。
|
||||
|
||||
Headless 也要单独看。当前 headless 路径明确做了 coordinator 工具过滤,并注入 coordinator user context;但 system prompt 组装路径和交互 REPL 不完全相同,应把它当成需要复核的边界,而不是默认等同交互路径。
|
||||
|
||||
### 4. 工具过滤状态机
|
||||
|
||||
Coordinator 主线程和 worker 的工具池不同:
|
||||
|
||||
| 角色 | 工具池 | 设计目的 |
|
||||
|---|---|---|
|
||||
| Coordinator 主线程 | `Agent`、`SendMessage`、`TaskStop`、`SyntheticOutput`、PR activity 订阅类 MCP 工具 | 只编排,不直接执行。 |
|
||||
| worker | `ASYNC_AGENT_ALLOWED_TOOLS`,排除 `TeamCreate`、`TeamDelete`、`SendMessage`、`SyntheticOutput` | 执行任务,但不能继续嵌套编排。 |
|
||||
| simple mode worker | `Bash`、`Read`、`Edit` | 降低工具面,适合简单执行路径。 |
|
||||
| MCP 工具 | 按已连接 server 注入 worker context | 让 worker 能使用外部能力,但由工具池控制边界。 |
|
||||
| scratchpad | gate 开启时提供 scratchpad 目录 | 允许跨 worker 共享临时知识。 |
|
||||
|
||||
交互路径主要走 `mergeAndFilterTools()`;headless 路径会在主入口直接应用 coordinator 工具过滤;worker 工具池由 `AgentTool` 独立组装,不继承主线程被过滤后的工具池。
|
||||
|
||||
### 5. Worker lifecycle
|
||||
|
||||
Coordinator 下 `Agent(worker)` 会被强制异步:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Coordinator calls Agent(worker)"] --> B["AgentTool marks shouldRunAsync"]
|
||||
B --> C["registerAsyncAgent"]
|
||||
C --> D["runAsyncAgentLifecycle"]
|
||||
D --> E{"final status"}
|
||||
E -->|completed| F["enqueue completed task-notification"]
|
||||
E -->|failed| G["enqueue failed task-notification"]
|
||||
E -->|killed| H["enqueue killed task-notification"]
|
||||
F --> I["command queue injects into next turn"]
|
||||
G --> I
|
||||
H --> I
|
||||
```
|
||||
|
||||
`<task-notification>` 是 user-role message,但不是用户输入。Coordinator prompt 必须把它当成 worker 结果信号:
|
||||
|
||||
```xml
|
||||
<task-notification>
|
||||
<task-id>agent-a1b</task-id> ← Worker 的 agentId
|
||||
<task-id>agent-a1b</task-id>
|
||||
<status>completed|failed|killed</status>
|
||||
<summary>Agent "Investigate auth bug" completed</summary>
|
||||
<result>Found null pointer in src/auth/validate.ts:42...</result>
|
||||
@@ -92,160 +222,430 @@ Worker 完成后,Coordinator 收到 XML 格式的通知:
|
||||
</task-notification>
|
||||
```
|
||||
|
||||
通知以 `user-role message` 形式送达,Coordinator 通过 `<task-notification>` 标签区分它和用户消息。`<task-id>` 用于 `SendMessage` 的 `to` 参数,实现定向续传。
|
||||
Coordinator 的关键约束是“综合而不是转发”。worker 看不到用户和 coordinator 的完整对话,所以 prompt 必须自包含:
|
||||
|
||||
### Coordinator 的核心职责:综合(Synthesis)
|
||||
|
||||
Coordinator System Prompt(`coordinatorMode.ts:111-369`,约 260 行)明确要求 Coordinator **不能懒惰地委派理解**:
|
||||
|
||||
```
|
||||
反模式(禁止):
|
||||
"Based on your findings, fix the auth bug"
|
||||
→ 把理解的责任推给了 Worker
|
||||
|
||||
正确做法:
|
||||
"Fix the null pointer in src/auth/validate.ts:42.
|
||||
The user field on Session (src/auth/types.ts:15) is
|
||||
undefined when sessions expire but the token remains cached.
|
||||
Add a null check before user.id access."
|
||||
→ Coordinator 自己理解了问题,给出精确指令
|
||||
```text
|
||||
Fix the null pointer in src/auth/validate.ts:42.
|
||||
Session.user can be undefined when the session expires but the token remains cached.
|
||||
Add a null check before user.id access; if null, return 401 with "Session expired".
|
||||
Run validate.test.ts and report the commit hash.
|
||||
```
|
||||
|
||||
这是 Coordinator Mode 最核心的设计约束:Coordinator 必须先理解,再分配。
|
||||
反模式是:
|
||||
|
||||
## Agent Teams (Swarm):蜂群式协作
|
||||
|
||||
Swarm 模式基于任务系统 V2(详见[任务管理](../tools/task-management.mdx)),核心机制是**共享任务列表 + 竞争认领 + Mailbox 消息系统**:
|
||||
|
||||
### 团队初始化
|
||||
|
||||
```
|
||||
Team Lead 创建团队(TeamCreateTool)
|
||||
↓
|
||||
设置 teamName → setLeaderTeamName()
|
||||
↓
|
||||
所有 Teammate 自动获得相同的 taskListId
|
||||
↓
|
||||
Teammate 启动时:
|
||||
1. CLAUDE_CODE_TASK_LIST_ID 环境变量(显式覆盖)
|
||||
2. Teammate 上下文的 teamName(共享 Lead 的任务列表)
|
||||
3. CLAUDE_CODE_TEAM_NAME 环境变量
|
||||
4. Lead 设置的 teamName
|
||||
5. getSessionId()(兜底)
|
||||
```text
|
||||
Based on your findings, fix it.
|
||||
```
|
||||
|
||||
多级优先级确保了 Team Lead 和所有 Teammate 指向同一个任务列表,无需额外协调。
|
||||
### Coordinator 边界与排错
|
||||
|
||||
### 架构组件
|
||||
| 现象 | 可能原因 | 处理方式 |
|
||||
|---|---|---|
|
||||
| Coordinator 主线程不能读文件或跑命令 | 工具池被过滤,这是预期行为 | 派 `worker`,把文件、错误、验收标准写入 worker prompt。 |
|
||||
| `--agent` 后 coordinator 行为不一致 | agent prompt 优先级压过 coordinator prompt,但工具仍可能被过滤 | 避免混用,或确认当前 system prompt 来源。 |
|
||||
| worker 还在跑但方向错 | runtime task 仍是 `running` | 用 `TaskStop` 停止;会产生 `killed` notification。 |
|
||||
| worker 完成但结论不够 | 已经结束的一次性 async agent | 更推荐 fresh worker;只有需要保留 sidechain 时才 `SendMessage` 续跑。 |
|
||||
| `SendMessage` 失败 | 找不到 agent、缺 sidechain transcript、message 缺 `summary` | 查 agentId/name、sidechain `.jsonl/.meta.json`,plain text message 记得带 `summary`。 |
|
||||
| coordinator 下没有 `worker` | non-interactive 下禁用了 built-in agents | 检查 `CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS`。 |
|
||||
|
||||
官方 Agent Teams 架构定义了四个核心组件:
|
||||
## Swarm 完整状态机
|
||||
|
||||
| 组件 | 角色 |
|
||||
|------|------|
|
||||
| **Team Lead** | 创建团队、分配任务、综合结果的主 Claude Code 会话 |
|
||||
| **Teammate** | 独立的 Claude Code 实例,各自拥有独立的上下文窗口 |
|
||||
| **Task List** | 共享的任务列表,Teammate 竞争认领和完成 |
|
||||
| **Mailbox** | 消息系统,支持 Teammate 间直接通信 |
|
||||
Swarm 的核心是团队,而不是一次 `Agent` 调用。`TeamCreate` 建 team,`Agent({ name })` 加 teammate,`TaskCreate/Update/List/Get` 提供任务白板,`SendMessage` 和 mailbox 提供通信与控制。
|
||||
|
||||
### Mailbox 消息系统
|
||||
当前实现默认启用 Agent Teams;设置 `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED` 才会关闭。
|
||||
|
||||
官方架构中的 Mailbox 是 Teammate 间通信的核心原语,支持两种消息模式(`broadcast` 模式来自源码推断,官方文档未明确细分):
|
||||
### 团队生命周期
|
||||
|
||||
| 模式 | 作用 | 场景 |
|
||||
|------|------|------|
|
||||
| **message** | 定向发送给指定 Teammate | 传递具体指令、请求协作 |
|
||||
| **broadcast** | 广播给所有 Teammate | 全局通知、状态同步 |
|
||||
|
||||
Mailbox 的关键特性:
|
||||
- **自动投递**:消息自动送达目标 Teammate 的对话上下文
|
||||
- **空闲通知**(TeammateIdle):Teammate 完成当前任务进入空闲时,自动通过 Mailbox 通知 Team Lead
|
||||
- **直接通信**:与 Coordinator Mode 不同,Teammate 之间可以直接通信,无需经过 Lead 中转
|
||||
|
||||
### Hook 事件
|
||||
|
||||
Agent Teams 提供三个关键 Hook 事件,用于在团队生命周期中注入自定义逻辑:
|
||||
|
||||
| Hook | 触发时机 | 典型用途 |
|
||||
|------|---------|---------|
|
||||
| **TaskCreated** | 新任务添加到任务列表时 | 自动分配、优先级排序 |
|
||||
| **TaskCompleted** | 任务标记为完成时 | 结果通知、依赖解锁 |
|
||||
| **TeammateIdle** | Teammate 完成所有任务进入空闲时 | Lead 重新分配、动态扩缩容 |
|
||||
|
||||
### 限制
|
||||
|
||||
当前 Agent Teams 实现的限制:
|
||||
- **不支持嵌套团队**:Teammate 不能再创建子团队
|
||||
- **每 session 一个团队**:一个会话只能属于一个团队
|
||||
- **Lead 固定**:Team Lead 创建后不可更换
|
||||
- **不支持 in-process Teammate 的会话恢复**:进程重启后 in-process 类型 Teammate 的状态丢失
|
||||
|
||||
### 持久化存储
|
||||
|
||||
团队状态通过文件系统持久化,确保进程重启后可恢复:
|
||||
|
||||
```
|
||||
~/.claude/teams/{team-name}/config.json ← 团队配置
|
||||
~/.claude/tasks/{team-name}/ ← 共享任务列表(文件锁保护)
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["NoTeam"] -->|TeamCreate| B["TeamReady leader"]
|
||||
B -->|AgentTool name + team| C["SpawnResolving"]
|
||||
C --> D{"backend"}
|
||||
D -->|in-process| E["InProcessTeammateTask registered"]
|
||||
D -->|pane-based| F["terminal pane spawned"]
|
||||
E --> G["TeamMemberRegistered"]
|
||||
F --> G
|
||||
G --> H["TeammateRunning"]
|
||||
H -->|turn complete| I["IdleNotification"]
|
||||
I --> J["TeammateIdle"]
|
||||
J -->|mailbox message| H
|
||||
J -->|unowned unblocked task| K["claim task + TaskUpdate in_progress"]
|
||||
K --> H
|
||||
H -->|shutdown_request| L["model approves or rejects"]
|
||||
J -->|shutdown_request| L
|
||||
L -->|approved| M["cleanup member / unassign task"]
|
||||
L -->|rejected| J
|
||||
B -->|TeamDelete| N["request active teammate shutdown"]
|
||||
N --> O["wait optional wait_ms"]
|
||||
O --> P["cleanup team dir / task dir / AppState"]
|
||||
P --> A
|
||||
```
|
||||
|
||||
### 任务认领与竞争
|
||||
关键不变量:
|
||||
|
||||
`claimTask()` 是 Agent Teams 的核心并发原语:
|
||||
| 不变量 | 含义 |
|
||||
|---|---|
|
||||
| roster 扁平 | teammate 内禁止再 spawn teammate,避免团队嵌套。 |
|
||||
| mailbox 按 name 寻址 | inbox 路径是 `teamName + agentName`,不是 agentId。 |
|
||||
| task list 是共享白板 | `TaskCreate` 只写 pending task,不启动执行体。 |
|
||||
| shutdown 不是强杀 | shutdown request 会交给模型处理,approve 后才 graceful shutdown。 |
|
||||
| TeamFile 是跨进程事实源 | `AppState.teamContext` 是 leader UI 的投影。 |
|
||||
|
||||
```
|
||||
Teammate A 调用 TaskList → 发现 task #3 是 pending
|
||||
Teammate B 同时发现 task #3 是 pending
|
||||
↓
|
||||
两者同时尝试 TaskUpdate(task #3, {status: "in_progress"})
|
||||
↓
|
||||
文件锁保证原子性:
|
||||
- 第一个写入者获得 owner 锁定
|
||||
- 第二个写入者收到 already_claimed 错误
|
||||
↓
|
||||
获得任务的 teammate 执行工作
|
||||
↓
|
||||
完成后 TaskUpdate(task #3, {status: "completed"})
|
||||
→ 依赖此任务的其他任务自动解锁
|
||||
→ tool_result 提示 "Call TaskList to find your next task"
|
||||
### 存储拓扑
|
||||
|
||||
Swarm 的核心状态在 `~/.claude/teams` 和 `~/.claude/tasks`:
|
||||
|
||||
```text
|
||||
~/.claude/
|
||||
teams/
|
||||
<team-name>/
|
||||
config.json
|
||||
inboxes/
|
||||
<agent-name>.json
|
||||
tasks/
|
||||
<team-name>/
|
||||
.highwatermark
|
||||
1.json
|
||||
2.json
|
||||
...
|
||||
```
|
||||
|
||||
### Teammate 的生命周期管理
|
||||
| 文件或结构 | 内容 |
|
||||
|---|---|
|
||||
| `TeamFile` | `name`、`leadAgentId`、`leadSessionId`、`hiddenPaneIds`、`teamAllowedPaths`、`members[]`。 |
|
||||
| `TeamFile.members[]` | `agentId`、`name`、`agentType`、`model`、`color`、`backendType`、`isActive`、`mode`、`worktreePath`、`sessionId`。 |
|
||||
| task JSON | `id`、`subject`、`description`、`activeForm`、`owner`、`status`、`blocks`、`blockedBy`、`metadata`。 |
|
||||
| mailbox JSON | 普通消息、协议消息、已读状态、颜色和摘要等。 |
|
||||
|
||||
```
|
||||
Teammate 异常退出
|
||||
↓
|
||||
unassignTeammateTasks()
|
||||
→ 扫描任务列表,找到 owner === teammateName 的未完成任务
|
||||
→ 重置为 pending + owner=undefined
|
||||
↓
|
||||
Team Lead 感知途径:
|
||||
1. 任务状态变化(pending 重置)—— 通过共享任务列表
|
||||
2. Mailbox 空闲通知(TeammateIdle hook)—— Teammate 停止时自动通知 Lead
|
||||
↓
|
||||
Team Lead 重新分配任务或创建新 Teammate
|
||||
### TeamCreate 到 teammate 的链路
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant L as TeamLead
|
||||
participant TC as TeamCreate
|
||||
participant TF as TeamFile
|
||||
participant TL as TaskList
|
||||
participant A as AgentTool
|
||||
participant B as Backend
|
||||
participant M as Mailbox
|
||||
|
||||
L->>TC: create team
|
||||
TC->>TF: write config with lead member
|
||||
TC->>TL: reset task list
|
||||
TC->>L: set leader team context
|
||||
L->>A: Agent with teammate name
|
||||
A->>B: spawn in-process or pane
|
||||
B->>TF: append member
|
||||
B->>M: write initial prompt if needed
|
||||
B->>L: teammate spawned
|
||||
```
|
||||
|
||||
## 任务类型全景
|
||||
`TeamCreate` 不只是写 `config.json`。它还会注册 session cleanup、重置 team 对应 task list、设置 `leaderTeamName`,并把 leader 投影到 `AppState.teamContext`。
|
||||
|
||||
支撑多 Agent 协作的是 7 种任务类型(`src/tasks/types.ts`):
|
||||
`AgentTool` 遇到 `team_name/current teamContext + name` 时走 teammate spawn 分支,不走普通 `runAgent()`。`spawnTeammate()` 会解析 team、唯一化 name、选择 backend、更新 `AppState.teamContext.teammates`,再追加 `TeamFile.members`。
|
||||
|
||||
| 任务类型 | 运行位置 | 状态管理 | 适用场景 |
|
||||
|----------|---------|---------|---------|
|
||||
| **LocalAgentTask** | 本地子进程 | `LocalAgentTaskState` | 标准子 Agent 任务 |
|
||||
| **LocalShellTask** | 本地 shell | `LocalShellTaskState` | 后台 shell 命令 |
|
||||
| **InProcessTeammateTask** | 同进程内 | `InProcessTeammateTaskState` | 轻量级进程内队友 |
|
||||
| **RemoteAgentTask** | 远程服务器 | `RemoteAgentTaskState` | 分布式 Agent(CCR) |
|
||||
| **DreamTask** | 后台静默 | `DreamTaskState` | 后台自主整理记忆 |
|
||||
| **LocalWorkflowTask** | 本地 | `LocalWorkflowTaskState` | 工作流编排 |
|
||||
| **MonitorMcpTask** | 本地 | `MonitorMcpTaskState` | MCP 监控任务 |
|
||||
### in-process vs pane-based teammate
|
||||
|
||||
`InProcessTeammateTask` 与 `LocalAgentTask` 的关键差异:前者共享进程的内存空间和基础设施状态(如 MCP 连接池),但有独立的对话上下文和工具权限;后者是完全隔离的子进程,启动开销更大但更安全。
|
||||
| 维度 | in-process teammate | pane-based teammate |
|
||||
|---|---|---|
|
||||
| 运行位置 | leader 同进程 | 独立终端 pane / CLI 进程 |
|
||||
| 启动方式 | 注册 `InProcessTeammateTask`,启动 `runInProcessTeammate()` | 创建 tmux / iTerm2 / Windows Terminal pane |
|
||||
| 消息消费 | runner 自己约 500ms poll mailbox | leader / teammate 侧 `useInboxPoller()` 约 1s poll |
|
||||
| 输入路径 | teammate view 输入进入 `pendingUserMessages` | 普通 mailbox prompt 进入 teammate 进程 |
|
||||
| 处理优先级 | shutdown > team-lead message > peer message > unowned task claim | poller 按消息类型路由,空闲时自动开一轮 |
|
||||
| UI | spinner tree、footer pills、detail dialog、teammate transcript view | footer TeamStatus、TeamsDialog、pane 状态 |
|
||||
| 恢复 | runner、AbortController、pending queue 在内存,进程重启不能完整恢复 | pane 进程可能还在;leader 侧 backend map 不持久化,恢复是 best-effort |
|
||||
| 删除 | 需要当前 AppState task / AbortController | 通过 backend 写 shutdown request,等待 teammate approve / cleanup |
|
||||
|
||||
## Coordinator vs Agent Teams 的选择
|
||||
## AgentTool 分流决策树
|
||||
|
||||
| 场景 | 推荐模式 | 原因 |
|
||||
|------|---------|------|
|
||||
| "重构认证系统,需要多模块协调" | Coordinator | 需要集中决策,Worker 间有依赖 |
|
||||
| "修复 10 个独立的 lint 警告" | Agent Teams | 任务独立,Teammate 可完全并行 |
|
||||
| "研究方案 A 和方案 B,然后选一个实现" | Coordinator | 先并行研究,再集中决策 |
|
||||
| "在大仓库中搜索所有 TODO 并分类" | Agent Teams | 无依赖,各自领任务即可 |
|
||||
`AgentTool.call()` 是多 Agent 入口最复杂的分叉点。同一个 `Agent` 工具会根据参数和上下文走不同运行时:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["AgentTool.call"] --> B{"name + team context?"}
|
||||
B -->|yes| C["spawnTeammate"]
|
||||
B -->|no| D{"isolation=remote?"}
|
||||
D -->|yes| E["registerRemoteAgentTask"]
|
||||
D -->|no| F{"fork route?"}
|
||||
F -->|yes| G["register async LocalAgentTask as fork"]
|
||||
F -->|no| H{"shouldRunAsync?"}
|
||||
H -->|yes| I["register async LocalAgentTask"]
|
||||
H -->|no| J["foreground LocalAgentTask + tool_result"]
|
||||
```
|
||||
|
||||
| 路由 | 触发条件 | 结果 |
|
||||
|---|---|---|
|
||||
| teammate | 有 `name`,且存在 `team_name` 或当前 `teamContext` | `spawnTeammate()`,返回 `teammate_spawned`。 |
|
||||
| remote | `isolation: "remote"` | 注册 `RemoteAgentTask`,本地保存 remote sidecar。 |
|
||||
| fork | 省略 `subagent_type` 且 fork gate/上下文允许 | 强制后台 local agent,继承父上下文和 exact tools。 |
|
||||
| async local | 显式 async、Coordinator worker、或自动后台条件满足 | 返回 `async_launched`,完成后注入 `<task-notification>`。 |
|
||||
| sync local | 默认前台一次性 subagent | 当前 tool call 返回 `tool_result`。 |
|
||||
|
||||
所以文档里不能把“Agent”写成一个单一概念:同一个工具入口下面至少有五条运行路径。
|
||||
|
||||
## 通信路径对照
|
||||
|
||||
多 Agent 的通信路径决定了结果是否进入当前 turn、是否持久化、能不能 resume。
|
||||
|
||||
| 通信路径 | 发送者 | 接收者 | 用途 | 持久化/恢复 |
|
||||
|---|---|---|---|---|
|
||||
| `tool_result` | sync subagent | 当前 assistant turn | 一次性前台结果 | 写入主 transcript。 |
|
||||
| `<task-notification>` | async local agent / coordinator worker | 主线程下一 turn | 后台完成/失败/被杀通知 | 来自 `LocalAgentTask` lifecycle 和 sidechain。 |
|
||||
| `SendMessage(to: agentId)` | Coordinator 或用户 | local agent task | 继续 running/stopped worker | running 时排队;stopped 时尝试 sidechain resume。 |
|
||||
| `SendMessage(to: teammateName)` | lead / teammate | teammate mailbox | Swarm 普通通信 | 写 inbox JSON,按 name 寻址。 |
|
||||
| `SendMessage(to: "*")` | lead / teammate | team members | Swarm broadcast | 写多个 inbox;structured message 不能 broadcast。 |
|
||||
| structured mailbox protocol | lead / teammate / runtime | 特定 teammate 或 lead | permission、plan、shutdown、mode、task assignment | 保持 unread 给 poller 路由,不应被普通 attachment 吞掉。 |
|
||||
| CCR events / polling | remote runtime | `RemoteAgentTask` | remote agent 状态和结果 | 本地 sidecar + 远端 session 状态。 |
|
||||
|
||||
### SendMessage 路由
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["SendMessage(to)"] --> B{"cross-session scheme?"}
|
||||
B -->|yes| C["UDS / LAN / bridge plain text"]
|
||||
B -->|no| D{"matches LocalAgentTask?"}
|
||||
D -->|running| E["queuePendingMessage"]
|
||||
D -->|stopped or evicted| F["resumeAgentBackground from sidechain"]
|
||||
D -->|no| G{"to == * ?"}
|
||||
G -->|yes| H["broadcast team mailbox"]
|
||||
G -->|no| I{"structured protocol?"}
|
||||
I -->|yes| J["write protocol message"]
|
||||
I -->|no| K["write teammate mailbox"]
|
||||
```
|
||||
|
||||
plain text `SendMessage` 要带 `summary`。structured message 不能 broadcast,也不能跨 `uds/bridge/tcp` session。单 session 下 teammate name 是裸 name,`to` 不应写成含 `@` 的跨域地址。
|
||||
|
||||
## Mailbox 协议表
|
||||
|
||||
Mailbox 路径是:
|
||||
|
||||
```text
|
||||
~/.claude/teams/<team-name>/inboxes/<agent-name>.json
|
||||
```
|
||||
|
||||
它有 lock、原子 rename、大小上限和压缩策略:
|
||||
|
||||
| 限制 | 值 |
|
||||
|---|---|
|
||||
| 单条 text | 64KB |
|
||||
| mailbox 文件 | 4MB |
|
||||
| retained bytes | 2MB |
|
||||
| 普通 message 保留 | 最多 1000 条 |
|
||||
| read message 保留 | 最多 200 条 |
|
||||
| unread protocol message 保留 | 最多 2000 条 |
|
||||
|
||||
协议消息不只是“聊天”:
|
||||
|
||||
| 消息类型 | 典型发送者 | 典型接收者 | 消费者 | 是否应进入普通 LLM context |
|
||||
|---|---|---|---|---|
|
||||
| plain text | lead / teammate | teammate / lead | mailbox attachment 或 prompt handler | 是 |
|
||||
| broadcast | lead / teammate | team members | mailbox attachment 或 prompt handler | 是 |
|
||||
| `task_assignment` | `TaskUpdate` | new owner | teammate poller / runner | 通常作为任务触发,不应当成普通闲聊 |
|
||||
| `permission_request/response` | teammate / lead | lead / teammate | `useInboxPoller` + permission UI queue | 否 |
|
||||
| `sandbox_permission_request/response` | teammate / sandbox host | lead / teammate | permission sync | 否 |
|
||||
| `plan_approval_request/response` | teammate / lead | lead / teammate | plan approval path | 否 |
|
||||
| `shutdown_request/approved/rejected` | lead / teammate | teammate / lead | backend / runner / poller | 否 |
|
||||
| `mode_set_request` | lead | teammate | permission mode sync | 否 |
|
||||
| `team_permission_update` | lead | team members | permission sync | 否 |
|
||||
| idle notification | teammate runner | lead | UI / lead poller | 通常否 |
|
||||
|
||||
一个重要边界:mailbox attachment 只消费非结构化消息;结构化协议消息应保持 unread,交给 `useInboxPoller` 或 in-process runner 路由。否则权限、plan、shutdown 可能被当成普通上下文吞掉。
|
||||
|
||||
## Task 不是 Runtime Task
|
||||
|
||||
`TaskCreate` 的 task 和 `LocalAgentTask` 的 task 是两套模型。
|
||||
|
||||
| 名称 | 源码类型 | 存储 | 状态 | 谁消费 |
|
||||
|---|---|---|---|---|
|
||||
| work item task | `src/utils/tasks.ts` 的 `Task` | `~/.claude/tasks/<taskListId>/<id>.json` | `pending/in_progress/completed` | Task tools、TaskList UI、teammate 认领 |
|
||||
| runtime task | `TaskStateBase` 子类型 | `AppState.tasks`,部分有 sidecar/output | `running/completed/failed/killed` 等 | UI、spinner、background selector、kill/resume |
|
||||
|
||||
共享任务生命周期:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["TaskCreate"] --> B["pending task JSON"]
|
||||
B --> C["TaskList"]
|
||||
C --> D["Teammate chooses work"]
|
||||
D --> E["TaskUpdate status=in_progress owner=me"]
|
||||
E --> F["execute work"]
|
||||
F --> G["TaskUpdate status=completed"]
|
||||
G --> H["TaskCompleted hooks"]
|
||||
G --> I["tool_result hints: call TaskList for next task"]
|
||||
```
|
||||
|
||||
`TaskUpdate` 在 Swarm 下有增强:
|
||||
|
||||
| 行为 | 说明 |
|
||||
|---|---|
|
||||
| teammate 标记 `in_progress` 且 owner 为空 | 自动把 owner 设为当前 teammate name。 |
|
||||
| owner 变化 | 写 `task_assignment` 到新 owner mailbox。 |
|
||||
| status -> `completed` | 执行 TaskCompleted hooks。 |
|
||||
| teammate 完成任务 | tool result 追加提示:立刻 `TaskList` 找下一项。 |
|
||||
| 主线程完成 3+ 任务且没有 verification | 在 feature gate 下追加 verification nudge。 |
|
||||
|
||||
runtime task 类型包括:
|
||||
|
||||
| 类型 | 运行位置 | 典型场景 |
|
||||
|---|---|---|
|
||||
| `LocalAgentTask` | 本地子 agent | 普通后台 agent、fork、coordinator worker。 |
|
||||
| `InProcessTeammateTask` | 同进程 runner | in-process teammate。 |
|
||||
| `RemoteAgentTask` | CCR remote session | remote agent。 |
|
||||
| `LocalShellTask` | 本地 shell | 后台 shell。 |
|
||||
| `LocalWorkflowTask` | 本地 workflow | workflow 编排。 |
|
||||
| `DreamTask` | 后台静默 | memory dream。 |
|
||||
| `MonitorMcpTask` | 本地监控 | MCP monitor。 |
|
||||
|
||||
## 持久化与恢复矩阵
|
||||
|
||||
恢复能力取决于状态放在哪里。最重要的区别是:能看到状态不等于能继续运行。
|
||||
|
||||
| 机制 | 持久化 | resume 后能看到 | resume 后能继续跑 | 边界 |
|
||||
|---|---|---|---|---|
|
||||
| main session | 主 session JSONL | 对话链、metadata、mode | 是,按主会话恢复 | 受 compact/branch/leaf 影响。 |
|
||||
| coordinator mode | 主 session JSONL 的 `mode` entry | 当前会话模式 | 是,`matchSessionMode()` 会切 env | prompt/tool 状态仍受当前启动参数影响。 |
|
||||
| coordinator worker | local agent sidechain + `.meta.json` | agent task 身份和历史 | 通常可 `resumeAgentBackground()` | 缺 sidechain/meta 或工具定义变化会失败。 |
|
||||
| ordinary/fork subagent | local agent sidechain + `.meta.json` | agent 历史 | 可恢复,fork 依赖 `agentType:"fork"` | fork 恢复需要 metadata 正确。 |
|
||||
| remote agent | `remote-agents/remote-agent-<taskId>.meta.json` + CCR | remote task 镜像 | 取决于 CCR session 状态 | 404/archive 会删除 sidecar。 |
|
||||
| team config | `~/.claude/teams/<team>/config.json` | team/member roster | 不代表 teammate runner 还活 | `TeamFile` 是事实源,`AppState` 是投影。 |
|
||||
| mailbox | `~/.claude/teams/<team>/inboxes/*.json` | 未读普通/协议消息 | 可继续投递 | structured message 需要 poller/runner 正确消费。 |
|
||||
| shared tasks | `~/.claude/tasks/<team>/*.json` | task list / owner / status | 可继续认领/更新 | owner 可能指向已经不活跃的 teammate。 |
|
||||
| in-process teammate runner | leader 进程内存 | 不能完整看到 runner 内态 | 不能完整跨进程恢复 | AbortController、pending queue、recent messages 都在内存。 |
|
||||
| pane-based teammate | 外部 pane + transcript + team file | 可能仍可见 | best-effort | leader 侧 backend map 不持久化,active/kill 依赖 pane 状态。 |
|
||||
|
||||
调试时可以按这个顺序问:
|
||||
|
||||
1. 文件还在吗?
|
||||
2. `AppState` 投影还在吗?
|
||||
3. runtime task 还在 `running` 吗?
|
||||
4. 通信通道还可用吗?
|
||||
5. sidechain / inbox / remote sidecar 是否足够恢复?
|
||||
|
||||
## 用户可见状态如何投影
|
||||
|
||||
UI 展示的是不同状态源的投影,不是单一真相。
|
||||
|
||||
| UI | 数据源 | 能说明什么 | 不能说明什么 |
|
||||
|---|---|---|---|
|
||||
| TaskListV2 | task files + `teamContext` | work item task、owner、状态 | owner 对应 teammate 一定还活。 |
|
||||
| TeammateSpinnerTree | running in-process teammates | 当前 leader 进程内的 teammate 活动 | pane-based teammate 或历史 teammate 全部状态。 |
|
||||
| TeammateSpinnerLine | `InProcessTeammateTaskState` | idle、approval、stopping、tool/token、最近消息 | 完整 transcript。 |
|
||||
| BackgroundAgentSelector | backgrounded `LocalAgentTask` | 可选择的本地后台 agent | remote/shell/workflow/in-process teammate。 |
|
||||
| agent transcript view | `viewingAgentTaskId` | local agent 或 in-process teammate 的可视化对话 | pane teammate 的完整外部进程状态。 |
|
||||
| TeamsDialog / TeamStatus | `AppState.teamContext` + team file | 团队成员展示、管理、kill/shutdown/mode | runner 一定可恢复。 |
|
||||
|
||||
pane-based team 主要通过 footer TeamStatus 和 TeamsDialog 管理:Enter 查看,`k` kill,`s` shutdown,`p` prune idle,Shift+Tab 切 permission mode。in-process teammate 的 transcript view 输入会进 `pendingUserMessages`,不是写 mailbox。
|
||||
|
||||
## 两条端到端场景
|
||||
|
||||
### 复杂 bug 用 Coordinator
|
||||
|
||||
| 步骤 | 发生了什么 | 运行体 | 通信 | 持久化 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 用户提出复杂 bug | 主会话 | user message | main JSONL |
|
||||
| 2 | Coordinator 拆成调查、实现、验证 | Coordinator 主线程 | `Agent(worker)` | main JSONL + task state |
|
||||
| 3 | worker 异步执行 | `LocalAgentTask` | tool calls | sidechain JSONL |
|
||||
| 4 | worker 完成 | `LocalAgentTask` | `<task-notification>` | notification queue / main turn |
|
||||
| 5 | Coordinator 综合 root cause | 主线程 | assistant reasoning | main JSONL |
|
||||
| 6 | 需要修正方向 | 同一个或新 worker | `SendMessage(to: agentId, summary, message)` 或 fresh `Agent` | sidechain / new sidechain |
|
||||
| 7 | 汇总给用户 | 主线程 | assistant message | main JSONL |
|
||||
|
||||
这个流程没有 `TeamCreate`,也不依赖 shared task list。
|
||||
|
||||
### 长期并行任务用 Swarm
|
||||
|
||||
| 步骤 | 发生了什么 | 状态源 | 通信 |
|
||||
|---|---|---|---|
|
||||
| 1 | `TeamCreate({ team_name })` | `teams/<team>/config.json` + `tasks/<team>` | tool result |
|
||||
| 2 | `TaskCreate` 多个工作项 | task JSON | Task tools |
|
||||
| 3 | `Agent({ name: "researcher" })` | TeamFile member + backend task/pane | initial prompt |
|
||||
| 4 | teammate 认领任务 | task JSON owner/status | `TaskUpdate` |
|
||||
| 5 | lead 发消息 | inbox JSON | `SendMessage(to: teammateName)` |
|
||||
| 6 | teammate 完成一轮 | runner/poller 状态 | idle notification |
|
||||
| 7 | teammate 继续领任务 | task list | `TaskList` / claim |
|
||||
| 8 | `TeamDelete({ wait_ms })` | team/task dirs cleanup | shutdown request / response |
|
||||
|
||||
这个流程里 team、task list 和 mailbox 是核心。teammate 输出不会自动给 lead;需要 `SendMessage` 或明确的协议消息。
|
||||
|
||||
## 失败与排障矩阵
|
||||
|
||||
| 现象 | 先查什么 | 常见原因 | 处理 |
|
||||
|---|---|---|---|
|
||||
| Coordinator worker 结果没回来 | `AppState.tasks[agentId]`、notification queue、sidechain | worker 仍 running、failed、被 killed、notification 尚未进入下一 turn | 等下一 turn;或看 sidechain / task status。 |
|
||||
| `SendMessage(to: agentId)` 找不到 worker | agentId/name、sidechain `.jsonl/.meta.json` | agent 被 evict、metadata 缺失、传了 teammate name | 用正确 raw agentId;必要时新开 worker。 |
|
||||
| `SendMessage(to: teammate)` 失败 | teamContext、team file、inbox path | teammate name 拼错、当前 session 无 team、用了含 `@` 地址 | 用当前 team 内裸 teammate name。 |
|
||||
| plain text `SendMessage` 校验失败 | 参数 | 缺 `summary` | 补 `summary`。 |
|
||||
| structured message 没生效 | inbox read 状态、poller | 被当普通 attachment 标 read,或 consumer 没跑 | 确认 structured message 保持 unread,poller/runner 活着。 |
|
||||
| 任务不显示 | `leaderTeamName`、`getTaskListId()`、tasks dir | lead/teammate 指向不同 task list | 查 env/teamName/sessionId 优先级。 |
|
||||
| task 被认领但没人执行 | task owner、team member active、runner/pane | owner teammate 不活跃或 runner 丢失 | 重新分配 owner,或重启 teammate。 |
|
||||
| TeamDelete 拒绝清理 | `TeamFile.members[].isActive` | 仍有 active teammate | 先 graceful shutdown,或确认后手动清理。 |
|
||||
| resume 后 team 在但 teammate 不跑 | team file、runner/pane 状态 | in-process runner 在旧进程内,不能恢复 | 重新 spawn teammate 或用现有 mailbox/task 重新编排。 |
|
||||
| pane teammate 似乎还在但 UI 不准 | paneId、backendType、backend map | leader 侧 `spawnedTeammates` map 不持久化 | 以 TeamFile + pane 实际状态为准,best-effort 管理。 |
|
||||
| permission/plan 卡住 | leader inbox、permission UI queue、protocol response | leader poller 没消费,或 response 没写回 | 查 `useInboxPoller` 和对应 inbox。 |
|
||||
| remote agent resume 失败 | remote sidecar、CCR session | session 404 / archived | 接受 sidecar 清理,重新创建 remote agent。 |
|
||||
|
||||
## 常见误区
|
||||
|
||||
| 误区 | 正确理解 |
|
||||
|---|---|
|
||||
| Coordinator 就是 Swarm 的 Team Lead | 不是。Coordinator worker 是 async subagent,不是 teammate。 |
|
||||
| Swarm 必须设置 `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` | 当前实现默认启用;用 `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS_DISABLED` 关闭。 |
|
||||
| `TaskCreate` 创建了一个运行中的 agent | 它只创建 work item JSON;运行体是 `LocalAgentTask` / `InProcessTeammateTask` 等。 |
|
||||
| teammate 完成一轮后结果自动给 lead | 不一定。teammate 需要通过 `SendMessage` 沟通;runner 也会发送 idle notification。 |
|
||||
| mailbox 按 agentId 寻址 | Swarm mailbox 按 teammate name 寻址。 |
|
||||
| BackgroundAgentSelector 会列出所有后台任务 | 它只列 backgrounded `LocalAgentTask`,不列 remote/shell/workflow/in-process teammate。 |
|
||||
| `TeamUpdate` 是一个工具 | 当前源码没有独立 `TeamUpdateTool`;团队成员更新分散在 spawn、teamHelpers、dialogs 中。 |
|
||||
| `SyntheticOutput` 是 Swarm 内部通信工具 | 它主要用于结构化输出,不是 Team 协作核心。 |
|
||||
| shutdown request 是强杀 | 不是,它是模型处理的 graceful shutdown 协议。 |
|
||||
| in-process teammate 可以像 local agent 一样跨进程 resume | 不行,runner 运行态在内存中,进程重启后不能完整恢复。 |
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
这篇文档是跨机制总览。需要深入某条链路时,优先看专题文档:
|
||||
|
||||
| 想深入 | 阅读 |
|
||||
|---|---|
|
||||
| `AgentTool` 参数、sync/async/fork、通知队列 | `docs/agent/sub-agents.mdx` |
|
||||
| Task V2 数据模型、锁、高水位、owner、hooks | `docs/tools/task-management.mdx` |
|
||||
| JSONL transcript、sidechain、compact、resume、remote sidecar | `docs/internals/session-transcript-persistence.md` |
|
||||
| Coordinator feature 的单独说明 | `docs/features/coordinator-mode.md` |
|
||||
| worktree 隔离 | `docs/agent/worktree-isolation.mdx` |
|
||||
|
||||
## 源码入口索引
|
||||
|
||||
| 问题 | 从这里看 |
|
||||
|---|---|
|
||||
| coordinator mode 检测、恢复、prompt、context | `src/coordinator/coordinatorMode.ts` |
|
||||
| `/coordinator` 命令 | `src/commands/coordinator.ts` |
|
||||
| coordinator worker 定义 | `src/coordinator/workerAgent.ts` |
|
||||
| system prompt 选择 | `src/utils/systemPrompt.ts` |
|
||||
| coordinator 工具过滤 | `src/utils/toolPool.ts` |
|
||||
| coordinator mode 持久化 | `src/utils/sessionStorage.ts` 的 `mode` entry / `saveMode()` |
|
||||
| AgentTool 路由 | `packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx` |
|
||||
| subagent query loop | `packages/builtin-tools/src/tools/AgentTool/runAgent.ts` |
|
||||
| async local agent lifecycle | `packages/builtin-tools/src/tools/AgentTool/agentToolUtils.ts` |
|
||||
| local agent runtime task | `src/tasks/LocalAgentTask/LocalAgentTask.tsx` |
|
||||
| remote agent runtime task | `src/tasks/RemoteAgentTask/RemoteAgentTask.tsx` |
|
||||
| agent resume | `packages/builtin-tools/src/tools/AgentTool/resumeAgent.ts` |
|
||||
| task stop | `packages/builtin-tools/src/tools/TaskStopTool/TaskStopTool.ts`、`src/tasks/stopTask.ts` |
|
||||
| team gate | `src/utils/agentSwarmsEnabled.ts` |
|
||||
| team file helpers | `src/utils/swarm/teamHelpers.ts` |
|
||||
| TeamCreate | `packages/builtin-tools/src/tools/TeamCreateTool/TeamCreateTool.ts` |
|
||||
| TeamDelete | `packages/builtin-tools/src/tools/TeamDeleteTool/TeamDeleteTool.ts` |
|
||||
| spawn teammate | `packages/builtin-tools/src/tools/shared/spawnMultiAgent.ts` |
|
||||
| in-process teammate spawn | `src/utils/swarm/spawnInProcess.ts` |
|
||||
| in-process teammate runner | `src/utils/swarm/inProcessRunner.ts` |
|
||||
| pane backend | `src/utils/swarm/backends/PaneBackendExecutor.ts` |
|
||||
| teammate AsyncLocalStorage identity | `src/utils/teammateContext.ts` |
|
||||
| mailbox | `src/utils/teammateMailbox.ts` |
|
||||
| permission sync | `src/utils/swarm/permissionSync.ts` |
|
||||
| SendMessage routing | `packages/builtin-tools/src/tools/SendMessageTool/SendMessageTool.ts` |
|
||||
| shared task list | `src/utils/tasks.ts` |
|
||||
| Task tools | `packages/builtin-tools/src/tools/TaskCreateTool`、`TaskUpdateTool`、`TaskListTool`、`TaskGetTool` |
|
||||
| inbox polling | `src/hooks/useInboxPoller.ts` |
|
||||
| swarm initialization | `src/hooks/useSwarmInitialization.ts` |
|
||||
| teammate view | `src/state/teammateViewHelpers.ts`、`src/screens/REPL.tsx` |
|
||||
| teammate spinner | `src/components/Spinner/TeammateSpinnerTree.tsx`、`TeammateSpinnerLine.tsx` |
|
||||
| team dialog/status | `src/components/teams/TeamsDialog.tsx`、`src/components/teams/TeamStatus.tsx` |
|
||||
| background local agent selector | `src/hooks/useBackgroundAgentTasks.ts`、`src/components/tasks/BackgroundAgentSelector.tsx` |
|
||||
|
||||
@@ -7,6 +7,322 @@ sourceRef: "3ec5675 (2026-04-08)"
|
||||
|
||||
{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */}
|
||||
|
||||
首先要区分claude code的多种交互方式
|
||||
|
||||
REPL关注交互形态,SDK关注接入方式,ACP则关注通信协议。
|
||||
|
||||
### 🆚 核心概念对比
|
||||
|
||||
| 维度 | 🖥️ REPL (交互形态) | 🧩 SDK (接入方式) | 🌉 ACP (通信协议) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **是什么** | 供开发者直接在终端使用的**交互式对话环境** | 面向开发者的**程序化调用库**,供集成到其他应用 | 一种**开放式的通信标准**,连接不同AI Agent与编辑器 |
|
||||
| **使用方式** | 1. 直接在终端输入`claude`命令<br>2. 进入专用界面(基于React Ink渲染)<br>3. 通过斜杠命令(如`/help`)交互 | 1. 在自己的Node.js/Python项目中安装SDK包(如`npm install claude-code-sdk`)<br>2. 通过API发送查询 | 1. 通过ACP适配器(如`claude-code-acp`)启动Claude Code<br>2. 供编辑器通过ACP协议与其通信 |
|
||||
| **典型场景** | 开发者日常编写代码时,随时向其提问、修改代码或执行任务 | 将Claude Code的核心能力(对话、工具执行等)集成到自动化脚本、CI/CD流程或其他应用的后台中 | 将Claude Code的能力集成到JetBrains IDE、Zed等第三方编辑器中,利用其UI交互功能 |
|
||||
| **主要特点** | - **面向人**:交互式、直观<br>- **功能完整**:可使用所有内置工具,并支持MCP集成<br>- **处理复杂任务**:可自主规划、执行多步操作 | - **面向程序**:编程化、可集成<br>- **轻量级**:不依赖Claude Code的完整运行时<br>- **由你控制**:适合在自有应用中实现自动化 | - **标准化**:统一不同Agent与编辑器间的通信<br>- **双向通信**:Agent可主动向编辑器请求文件、执行命令等<br>- **与编辑器深度整合**:能完全复用Claude Code的能力 |
|
||||
|
||||
其中的 🧩 SDK (接入方式) 与 🌉 ACP (通信协议)采用如下QueryEngine实现会话管理
|
||||
|
||||
作为一个对话终端(🖥️ REPL 交互形态模式),则使用的是 onQueryImpl 在 src/screens/REPL.tsx 中调用 query() 函数
|
||||
|
||||
对于REPL 交互形态模式的调用链路如下
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
onSubmit (REPL.tsx)
|
||||
↓
|
||||
handlePromptSubmit (handlePromptSubmit.ts)
|
||||
↓
|
||||
executeUserInput (handlePromptSubmit.ts)
|
||||
↓
|
||||
onQuery (REPL.tsx)
|
||||
↓
|
||||
onQueryImpl (REPL.tsx)
|
||||
↓
|
||||
query (query.ts) ← 在这里调用
|
||||
```
|
||||
|
||||
其中
|
||||
|
||||
query 函数是 Agentic Loop 的核心实现,包含 while(true) 循环处理对话回合 query.ts:460-522
|
||||
|
||||
onQueryImpl 是 REPL(Read-Eval-Print Loop)中与 AI 模型交互的核心控制器,它负责:
|
||||
|
||||
1.环境准备(IDE、诊断、权限)
|
||||
|
||||
2.会话标题的首次生成
|
||||
|
||||
3.构建动态系统提示和用户上下文
|
||||
|
||||
4.执行流式查询并实时更新 UI
|
||||
|
||||
5.收集性能指标和最终清理
|
||||
|
||||
## `onQueryImpl` 方法的详细解析
|
||||
以下是对 `onQueryImpl` 方法的详细解析。该方法是一个 React `useCallback` 包装的异步函数,负责处理用户消息到 AI 模型(Claude)的**完整查询流程**,包括预处理、系统提示构建、工具上下文准备、流式查询执行、后处理与指标记录。
|
||||
|
||||
---
|
||||
|
||||
### 一、函数签名与参数
|
||||
|
||||
```typescript
|
||||
const onQueryImpl = useCallback(
|
||||
async (
|
||||
messagesIncludingNewMessages: MessageType[],
|
||||
newMessages: MessageType[],
|
||||
abortController: AbortController,
|
||||
shouldQuery: boolean,
|
||||
additionalAllowedTools: string[],
|
||||
mainLoopModelParam: string,
|
||||
effort?: EffortValue,
|
||||
) => { ... },
|
||||
[ ...dependencies ]
|
||||
)
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `messagesIncludingNewMessages` | 包含新增消息的完整消息列表,用于构建模型输入 |
|
||||
| `newMessages` | 本次新增的消息(例如用户刚输入的文本或附件) |
|
||||
| `abortController` | 用于取消当前查询的控制器 |
|
||||
| `shouldQuery` | 是否真正执行查询;若为 `false` 则跳过模型调用(例如处理无效斜杠命令、手动 compact 等) |
|
||||
| `additionalAllowedTools` | 本轮查询额外允许的工具列表(通常来自 Skill 的 frontmatter) |
|
||||
| `mainLoopModelParam` | 指定本次使用的主模型参数(如 `'claude-3-opus'`) |
|
||||
| `effort` | 可选,覆盖全局的“努力程度”值(用于控制模型推理深度) |
|
||||
|
||||
---
|
||||
|
||||
### 二、总体执行流程
|
||||
|
||||
下图概括了函数的主要分支与关键步骤:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["开始"] --> B{shouldQuery?}
|
||||
B -- true --> C["IDE集成:刷新MCP客户端,诊断追踪,关闭差异视图"]
|
||||
B -- false --> D["仅处理compact边界/重置状态并返回"]
|
||||
C --> E["标记项目onboarding完成"]
|
||||
E --> F["尝试生成会话标题(仅一次)"]
|
||||
F --> G["将additionalAllowedTools写入全局权限store"]
|
||||
G --> H["获取ToolUseContext(含最新工具/MCP)"]
|
||||
H --> I["如有effort,临时覆盖getAppState中的effortValue"]
|
||||
I --> J["并行执行:系统提示/用户上下文/系统上下文/自动模式检查"]
|
||||
J --> K["构建有效系统提示"]
|
||||
K --> L["重置各类耗时计时器"]
|
||||
L --> M["执行query生成器,流式处理事件"]
|
||||
M --> N["若BUDDY开启,触发companion观察者"]
|
||||
N --> O["若UDS_INBOX且中断,记录错误"]
|
||||
O --> P["ant用户:收集API指标并插入指标消息"]
|
||||
P --> Q["重置加载状态,输出性能报告,调用onTurnComplete"]
|
||||
Q --> R["结束"]
|
||||
D --> R
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 三、核心逻辑详解
|
||||
|
||||
#### 3.1 IDE 集成与诊断(仅 `shouldQuery = true`)
|
||||
|
||||
```typescript
|
||||
const freshClients = mergeClients(initialMcpClients, store.getState().mcp.clients);
|
||||
diagnosticTracker.handleQueryStart(freshClients);
|
||||
const ideClient = getConnectedIdeClient(freshClients);
|
||||
if (ideClient) closeOpenDiffs(ideClient);
|
||||
```
|
||||
|
||||
- 从 store 中获取最新的 MCP 客户端(因为 `useManageMCPConnections` 可能在闭包捕获后更新了状态)。
|
||||
- 通知诊断追踪器查询开始。
|
||||
- 若存在已连接的 IDE 客户端,关闭所有打开的差异视图(清理环境)。
|
||||
|
||||
#### 3.2 会话标题生成(仅一次)
|
||||
|
||||
```typescript
|
||||
if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) {
|
||||
const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta);
|
||||
const text = getContentText(firstUserMessage.message.content);
|
||||
if (text && !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ... ) {
|
||||
haikuTitleAttemptedRef.current = true;
|
||||
generateSessionTitle(text, ...).then(title => setHaikuTitle(title));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 仅当全局标题未禁用、当前无任何标题且从未尝试过时执行。
|
||||
- 从新增消息中提取第一条**非元用户消息**的真实文本。
|
||||
- 跳过合成面包屑(如 slash 命令输出、skill 扩展标记等)。
|
||||
- 异步调用 `generateSessionTitle`,结果通过 `setHaikuTitle` 保存;失败则重置 ref 允许重试。
|
||||
|
||||
#### 3.3 权限工具覆盖写入 Store
|
||||
|
||||
```typescript
|
||||
store.setState(prev => {
|
||||
const cur = prev.toolPermissionContext.alwaysAllowRules.command;
|
||||
if (cur === additionalAllowedTools || (cur?.length === ...)) return prev;
|
||||
return { ...prev, toolPermissionContext: { ...prev.toolPermissionContext, alwaysAllowRules: { ...prev.toolPermissionContext.alwaysAllowRules, command: additionalAllowedTools } } };
|
||||
});
|
||||
```
|
||||
|
||||
- 将本轮 `additionalAllowedTools` 写入全局 store 的 `toolPermissionContext.alwaysAllowRules.command`。
|
||||
- 用于限定本轮查询中可用的工具集(例如 Skill 专属工具)。
|
||||
- 通过浅比较避免不必要的状态更新。
|
||||
- 即使在 `shouldQuery=false` 时也会执行(例如 forked 命令需要此权限信息),但原代码位置在 `shouldQuery` 分支**之前**,所以始终会更新。
|
||||
|
||||
#### 3.4 `shouldQuery = false` 分支
|
||||
|
||||
```typescript
|
||||
if (!shouldQuery) {
|
||||
if (newMessages.some(isCompactBoundaryMessage)) {
|
||||
setConversationId(randomUUID());
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) proactiveModule?.setContextBlocked(false);
|
||||
}
|
||||
resetLoadingState();
|
||||
setAbortController(null);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- 处理不需要实际调用模型的情况(如用户输入了无效斜杠命令,或者手动 `/compact` 等)。
|
||||
- 若新消息中包含 **compact 边界消息**(压缩边界),则:
|
||||
- 生成新的 `conversationId`,促使 UI 中消息行组件重新挂载。
|
||||
- 若开启了 PROACTIVE/KAIROS 特性,清除上下文阻塞标志(恢复主动提示)。
|
||||
- 最后重置加载状态并清空 abortController。
|
||||
|
||||
#### 3.5 查询前置准备(`shouldQuery = true`)
|
||||
|
||||
##### 3.5.1 获取 ToolUseContext
|
||||
|
||||
```typescript
|
||||
const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam);
|
||||
const { tools: freshTools, mcpClients: freshMcpClients } = toolUseContext.options;
|
||||
```
|
||||
|
||||
- `getToolUseContext` 内部会从 store 中读取最新的 tools 和 MCP 客户端配置,确保闭包捕获的旧值不会导致遗漏新连接的工具或 MCP 服务器。
|
||||
|
||||
##### 3.5.2 Effort 覆盖(临时)
|
||||
|
||||
```typescript
|
||||
if (effort !== undefined) {
|
||||
const previousGetAppState = toolUseContext.getAppState;
|
||||
toolUseContext.getAppState = () => ({ ...previousGetAppState(), effortValue: effort });
|
||||
}
|
||||
```
|
||||
|
||||
- 如果传入了 `effort` 参数,临时覆盖 `getAppState` 返回的 `effortValue`。
|
||||
- 作用域**仅限于本轮查询**,不影响全局 store,避免后台 Agent 或 UI 组件误读到该临时值。
|
||||
|
||||
##### 3.5.3 并行获取提示与上下文
|
||||
|
||||
```typescript
|
||||
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
||||
undefined,
|
||||
feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(...) : undefined,
|
||||
getSystemPrompt(freshTools, mainLoopModelParam, additionalWorkingDirectories, freshMcpClients),
|
||||
getUserContext(),
|
||||
getSystemContext(),
|
||||
]);
|
||||
```
|
||||
|
||||
- 并行执行以下任务以节省时间:
|
||||
- **自动模式断路器**:如果启用了转录分类器,检查并可能禁用快速模式(`fastMode`)。
|
||||
- **系统提示**:基于最新工具、模型参数、额外工作目录、MCP 客户端生成。
|
||||
- **用户上下文**:如当前工作区、环境变量等。
|
||||
- **系统上下文**:如操作系统、终端信息等。
|
||||
|
||||
##### 3.5.4 增强用户上下文
|
||||
|
||||
```typescript
|
||||
const userContext = {
|
||||
...baseUserContext,
|
||||
...getCoordinatorUserContext(freshMcpClients, getScratchpadDir()),
|
||||
...((feature('PROACTIVE') || feature('KAIROS')) && proactiveModule?.isProactiveActive() && !terminalFocusRef.current
|
||||
? { terminalFocus: 'The terminal is unfocused — the user is not actively watching.' }
|
||||
: {}),
|
||||
};
|
||||
```
|
||||
|
||||
- 合并基本用户上下文、协调器上下文(与 MCP 协作相关)、以及可选的终端焦点状态(当 proactive 特性激活且终端未聚焦时,提示模型用户未在观看)。
|
||||
|
||||
##### 3.5.5 构建最终系统提示
|
||||
|
||||
```typescript
|
||||
const systemPrompt = buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition,
|
||||
toolUseContext,
|
||||
customSystemPrompt,
|
||||
defaultSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
});
|
||||
```
|
||||
|
||||
- 整合主线程 Agent 定义、工具上下文、自定义系统提示、默认系统提示以及需要追加的内容。
|
||||
|
||||
#### 3.6 执行查询与流式事件处理
|
||||
|
||||
```typescript
|
||||
resetTurnHookDuration(); resetTurnToolDuration(); resetTurnClassifierDuration();
|
||||
for await (const event of query({ messages, systemPrompt, userContext, systemContext, canUseTool, toolUseContext, querySource })) {
|
||||
onQueryEvent(event);
|
||||
}
|
||||
```
|
||||
|
||||
- 重置本轮钩子、工具、分类器的耗时计时器。
|
||||
- 调用 `query` 生成器函数(负责与模型 API 通信并返回 SSE 事件流)。
|
||||
- 遍历每个事件并调用 `onQueryEvent`(通常用于更新 UI 消息列表、处理工具调用等)。
|
||||
|
||||
#### 3.7 后处理与指标收集
|
||||
|
||||
##### 3.7.1 BUDDY 特性(companion 反应)
|
||||
|
||||
```typescript
|
||||
if (feature('BUDDY') && typeof fireCompanionObserver === 'function') {
|
||||
fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => ({ ...prev, companionReaction: reaction })));
|
||||
}
|
||||
```
|
||||
|
||||
- 将当前消息列表传递给 companion 观察者,并根据返回的反应更新全局状态。
|
||||
|
||||
##### 3.7.2 UDS_INBOX 中断处理
|
||||
|
||||
```typescript
|
||||
if (feature('UDS_INBOX') && abortController.signal.aborted) {
|
||||
pipeReturnHadErrorRef.current = true;
|
||||
relayPipeMessage({ type: 'error', data: 'Slave request was interrupted before completion.' });
|
||||
}
|
||||
```
|
||||
|
||||
- 若因中断导致查询未完成,标记错误并通过管道中继消息。
|
||||
|
||||
##### 3.7.3 Ant 内部用户的 API 指标记录
|
||||
|
||||
```typescript
|
||||
if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) {
|
||||
const entries = apiMetricsRef.current;
|
||||
const ttfts = entries.map(e => e.ttftMs);
|
||||
const otpsValues = entries.map(e => { /* 计算每请求的 OTPs */ });
|
||||
const isMultiRequest = entries.length > 1;
|
||||
// 创建 API 指标消息并添加到消息列表
|
||||
setMessages(prev => [...prev, createApiMetricsMessage({ ttftMs: isMultiRequest ? median(ttfts) : ttfts[0], ... })]);
|
||||
}
|
||||
```
|
||||
|
||||
- 仅当用户类型为 `'ant'` 且存在 API 指标记录时执行。
|
||||
- 收集每次请求的 **首字节时间 (TTFT)** 和 **每秒输出 Token 数 (OTPS)**。
|
||||
- 若本轮包含多次请求(例如工具调用循环),计算中位数(P50)后存入指标消息。
|
||||
- 同时记录钩子耗时、工具耗时、分类器耗时、本轮总时长、配置写入次数等。
|
||||
|
||||
##### 3.7.4 重置与清理
|
||||
|
||||
```typescript
|
||||
resetLoadingState();
|
||||
logQueryProfileReport();
|
||||
await onTurnComplete?.(messagesRef.current);
|
||||
```
|
||||
|
||||
- 重置加载状态(隐藏 loading 指示器)。
|
||||
- 输出查询性能报告(如果调试标志启用)。
|
||||
- 调用外部传入的 `onTurnComplete` 回调,并传递完整消息列表(通常用于触发后续行为如自动滚动、保存会话等)。
|
||||
|
||||
|
||||
## 单轮 vs 多轮:架构层面的差异
|
||||
|
||||
- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束
|
||||
@@ -28,7 +344,7 @@ QueryEngine 内部状态(src/QueryEngine.ts 构造函数)
|
||||
|
||||
## QueryEngine 的核心方法:submitMessage()
|
||||
|
||||
每次用户输入一条消息,REPL 或 SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:
|
||||
每次用户输入一条消息,SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:
|
||||
|
||||
```typescript
|
||||
// src/QueryEngine.ts — QueryEngine.submitMessage() 简化流程
|
||||
|
||||
@@ -1,102 +1,183 @@
|
||||
# WORKFLOW_SCRIPTS — 工作流自动化
|
||||
# WORKFLOW_SCRIPTS — 确定性多 agent 工作流编排
|
||||
|
||||
> Feature Flag: `FEATURE_WORKFLOW_SCRIPTS=1`
|
||||
> 实现状态:全部 Stub(7 个文件),布线完整
|
||||
> 引用数:10
|
||||
> Feature Flag:`FEATURE_WORKFLOW_SCRIPTS=1`
|
||||
> 引擎包:[`@claude-code-best/workflow-engine`](../../packages/workflow-engine/)(确定性 JS 脚本编排,零核心层运行时依赖)
|
||||
> 集成层:[`src/workflow/`](../../src/workflow/)
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
WORKFLOW_SCRIPTS 实现基于文件的多步自动化工作流。用户可以定义 YAML/JSON 格式的工作流描述文件,系统将其解析为可执行的多 agent 步骤序列。提供 `/workflows` 命令管理和触发工作流。
|
||||
WORKFLOW_SCRIPTS 让 Claude Code 用**确定性 JavaScript 脚本**编排多个子 agent:可分解/并行、多视角置信、规模超单上下文、可 resume/可审计。
|
||||
|
||||
- **编排原语**:`agent` / `parallel` / `pipeline` / `phase` / `log` / `workflow`(见引擎包)。
|
||||
- **确定性**:脚本在受限沙箱内执行,禁用 `Date.now()` / `Math.random()` / 无参 `new Date()`,保证 journal 可重放。
|
||||
- **深度后端**:单一 `claude-code` AgentAdapter 接入当前会话体系(provider / model / agentType / 工具),workflow 内的 `agent()` 调用真实子 agent。
|
||||
- **监控面板**:`/workflows` 双栏实时面板(见 §六)。
|
||||
- **编排手册**:`/ultracode` 注入编排工作法(见 §七)。
|
||||
|
||||
> 历史说明:早期版本为 YAML/JSON DSL + 全 Stub 实现(`WorkflowDetailDialog` 等),已全量重写为引擎驱动的 JS 方案。
|
||||
|
||||
## 二、实现架构
|
||||
|
||||
### 2.1 模块状态
|
||||
|
||||
| 模块 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| WorkflowTool | `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts` | **部分实现** — tool schema + 渲染完整,call 返回运行时缺失提示 |
|
||||
| Workflow 权限 | `packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx` | **部分实现** — 权限请求组件 |
|
||||
| 常量 | `packages/builtin-tools/src/tools/WorkflowTool/constants.ts` | **实现** — 工具名 + 目录名 + 文件扩展名常量 |
|
||||
| 命令创建 | `packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts` | **实现** — 扫描 .claude/workflows/ 目录创建 Command 对象 |
|
||||
| 捆绑工作流 | `packages/builtin-tools/src/tools/WorkflowTool/bundled/index.ts` | **实现** — 内置工作流初始化 |
|
||||
| 本地工作流任务 | `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts` | **Stub** — 类型 + 空操作 |
|
||||
| UI 任务组件 | `src/components/tasks/src/tasks/LocalWorkflowTask/` | **Stub** — 空导出 |
|
||||
| 详情对话框 | `src/components/tasks/WorkflowDetailDialog.ts` | **Stub** — 返回 null |
|
||||
| 任务注册 | `src/tasks.ts` | **布线** — 动态加载 |
|
||||
| 工具注册 | `src/tools.ts` | **布线** — 动态加载 + bundled 工作流初始化 (行 131-134,235) |
|
||||
| 命令注册 | `src/commands.ts` | **布线** — `/workflows` 命令 (行 93-95,395,460) |
|
||||
|
||||
### 2.2 预期数据流
|
||||
|
||||
```
|
||||
用户定义工作流(YAML/JSON 文件)
|
||||
│
|
||||
▼
|
||||
/workflows 命令发现工作流文件
|
||||
│
|
||||
▼
|
||||
createWorkflowCommand() 解析为 Command 对象 [需要实现]
|
||||
│
|
||||
▼
|
||||
WorkflowTool 执行工作流 [需要实现]
|
||||
│
|
||||
├── 步骤 1: Agent({ task: "..." })
|
||||
├── 步骤 2: Agent({ task: "..." })
|
||||
└── 步骤 N: Agent({ task: "..." })
|
||||
│
|
||||
▼
|
||||
LocalWorkflowTask 协调步骤执行 [需要实现]
|
||||
│
|
||||
▼
|
||||
WorkflowDetailDialog 显示进度 [需要实现]
|
||||
.claude/workflows/<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 默认 = `DEFAULT_MAX_CONCURRENCY`(3),可经 Workflow 工具的 `maxConcurrency` 入参覆盖,绝对上限 `MAX_CONCURRENCY_CAP`(16)。
|
||||
|
||||
## 四、编写 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` 工具启动 workflow(input 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` 默认许可 3(`DEFAULT_MAX_CONCURRENCY`),可经 Workflow 工具的 `maxConcurrency` 入参 per-run 覆盖(钳到 `[1, MAX_CONCURRENCY_CAP=16]`)。
|
||||
- **错误**:脚本语法/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 / 并发) |
|
||||
|
||||
828
docs/internals/session-transcript-persistence.md
Normal file
828
docs/internals/session-transcript-persistence.md
Normal file
@@ -0,0 +1,828 @@
|
||||
# JSONL Transcript 会话持久化与恢复机制
|
||||
|
||||
本文梳理 Claude Code 基于 JSONL transcript 的会话持久化、恢复、错误恢复、上下文压缩、分支、subagent、fork agent 和 remote agent 逻辑。
|
||||
|
||||
这不是按文件罗列的源码笔记,而是一份机制手册:先建立心智模型,再看数据结构、生命周期、异常路径和源码入口。
|
||||
|
||||
## 怎么读
|
||||
|
||||
| 如果你想看 | 建议先读 |
|
||||
|---|---|
|
||||
| 为什么 resume 能恢复到正确位置 | `总览`、`读取与链路重建`、`恢复入口` |
|
||||
| 为什么 compact 后历史还在但模型看不到 | `上下文视图`、`Compact 与投影` |
|
||||
| 为什么 subagent 不污染主会话 | `存储拓扑`、`Subagent 与 Fork Agent` |
|
||||
| `/branch`、`--fork-session`、`/fork` 有什么区别 | `分支与 Fork 对比` |
|
||||
| 崩溃、超限、取消后如何恢复 | `错误恢复矩阵` |
|
||||
|
||||
## 总览
|
||||
|
||||
Claude Code 的本地会话核心是 append-only JSONL。每一行是一个 `Entry`,但恢复时不会按文件顺序重放整个文件,而是:
|
||||
|
||||
1. 把 transcript message 放入 `uuid -> message` map。
|
||||
2. 把 metadata entry 放入各自 map 或数组。
|
||||
3. 选择最新 leaf。
|
||||
4. 从 leaf 沿 `parentUuid` 回溯,得到当前有效链。
|
||||
5. 应用 compact、snip、preserved segment、content replacement 等投影。
|
||||
6. 恢复 sessionId、worktree、mode、agent setting、任务状态等内存状态。
|
||||
|
||||
核心不变量:
|
||||
|
||||
| 不变量 | 含义 |
|
||||
|---|---|
|
||||
| JSONL 尽量 append-only | compact、branch、sidechain 都优先追加新 entry,不直接改旧历史。 |
|
||||
| `uuid/parentUuid` 决定世界线 | 文件顺序只说明写入顺序,真正恢复靠链路回溯。 |
|
||||
| metadata 不参与主链 | title、tag、worktree、content replacement 等通过 sessionId/messageId/agentId 合并。 |
|
||||
| compact 不删除历史 | 它追加 boundary,模型视图从最后一个 boundary 后开始。 |
|
||||
| subagent 是 sidechain | 子 agent 的完整对话在独立 JSONL,父会话只看到 Agent tool 的结果/通知。 |
|
||||
| remote agent 不是 sidechain | remote agent 本地只保存 sidecar 身份,执行状态来自 CCR。 |
|
||||
|
||||
### 系统分层
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[磁盘层<br/>append-only JSONL + sidecar metadata] --> B[链路层<br/>uuid / parentUuid / leaf]
|
||||
B --> C[投影层<br/>compact / snip / tool_result budget / context-collapse]
|
||||
C --> D[恢复层<br/>deserialize / interrupt detection / metadata restore]
|
||||
D --> E[运行层<br/>REPL / QueryEngine / AgentTask / RemoteTask]
|
||||
```
|
||||
|
||||
### 存储拓扑
|
||||
|
||||
```text
|
||||
~/.claude/projects/<project-key>/
|
||||
<sessionId>.jsonl
|
||||
<sessionId>/
|
||||
subagents/
|
||||
agent-<agentId>.jsonl
|
||||
agent-<agentId>.meta.json
|
||||
<subdir>/
|
||||
agent-<agentId>.jsonl
|
||||
agent-<agentId>.meta.json
|
||||
remote-agents/
|
||||
remote-agent-<taskId>.meta.json
|
||||
```
|
||||
|
||||
| 文件 | 生成函数 | 用途 |
|
||||
|---|---|---|
|
||||
| `<sessionId>.jsonl` | `getTranscriptPath()` | 主会话 transcript。 |
|
||||
| `subagents/agent-<agentId>.jsonl` | `getAgentTranscriptPath(agentId)` | 本地 subagent / fork agent sidechain。 |
|
||||
| `subagents/agent-<agentId>.meta.json` | `getAgentMetadataPath(agentId)` | agentType、worktreePath、description。 |
|
||||
| `remote-agents/remote-agent-<taskId>.meta.json` | `getRemoteAgentMetadataPath(taskId)` | remote CCR session 身份,用于恢复 polling。 |
|
||||
|
||||
## 核心源码地图
|
||||
|
||||
| 机制 | 主要文件 |
|
||||
|---|---|
|
||||
| Entry 类型 | `src/types/logs.ts` |
|
||||
| 路径、写入、读取、链路重建 | `src/utils/sessionStorage.ts` |
|
||||
| 大文件流式读取 | `src/utils/sessionStoragePortable.ts` |
|
||||
| CLI resume 加载和中断检测 | `src/utils/conversationRecovery.ts` |
|
||||
| session 切换和状态恢复 | `src/utils/sessionRestore.ts` |
|
||||
| SDK/headless query 写 transcript | `src/QueryEngine.ts` |
|
||||
| API query loop、compact、错误恢复 | `src/query.ts` |
|
||||
| compact 实现 | `src/services/compact/*` |
|
||||
| context-collapse stub 与持久化接口 | `src/services/contextCollapse/*` |
|
||||
| `/branch` | `src/commands/branch/branch.ts` |
|
||||
| `/fork` | `src/commands/fork/fork.tsx` |
|
||||
| AgentTool 和 subagent | `packages/builtin-tools/src/tools/AgentTool/*` |
|
||||
| 通用 forked side query | `src/utils/forkedAgent.ts` |
|
||||
| remote agent task | `src/tasks/RemoteAgentTask/RemoteAgentTask.tsx` |
|
||||
|
||||
## 数据模型
|
||||
|
||||
`Entry` 定义在 `src/types/logs.ts`,可以分为三大类。
|
||||
|
||||
| 类别 | 典型 type | 是否进入 `parentUuid` 链 | key | 恢复用途 |
|
||||
|---|---|---:|---|---|
|
||||
| transcript message | `user`、`assistant`、`attachment`、`system` | 是 | `uuid` | 重建对话链、模型上下文、UI scrollback。 |
|
||||
| session metadata | `custom-title`、`tag`、`mode`、`worktree-state`、`pr-link`、`agent-setting` | 否 | `sessionId` | 恢复标题、标签、模式、worktree、PR、agent 设置。 |
|
||||
| message metadata | `file-history-snapshot`、`attribution-snapshot`、`summary` | 否 | `messageId` 或 `leafUuid` | 恢复文件历史、归因、摘要。 |
|
||||
| replacement metadata | `content-replacement` | 否 | `sessionId` + optional `agentId` | 恢复大 tool_result 的替换决策。 |
|
||||
| context-collapse metadata | `marble-origami-commit`、`marble-origami-snapshot` | 否 | `sessionId` | 预留 context-collapse 恢复接口;当前实现为 stub。 |
|
||||
| queue/task metadata | `queue-operation`、`task-summary`、`speculation-accept` | 否 | 各自字段 | 恢复队列、任务摘要、推测接受统计。 |
|
||||
|
||||
### TranscriptMessage 字段
|
||||
|
||||
真正参与链路的是 `TranscriptMessage`:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `uuid` | 当前消息 ID。 |
|
||||
| `parentUuid` | 链路父节点,恢复时沿它回溯。 |
|
||||
| `logicalParentUuid` | compact boundary 等断链场景保留逻辑父节点。 |
|
||||
| `sessionId` | 所属主 session。 |
|
||||
| `cwd` | 写入时工作目录。 |
|
||||
| `timestamp` | 写入时间。 |
|
||||
| `version` | CLI 版本。 |
|
||||
| `gitBranch` | 写入时 git 分支。 |
|
||||
| `isSidechain` | 是否是 subagent sidechain。 |
|
||||
| `agentId` | sidechain 所属 agent。 |
|
||||
| `teamName/agentName/agentColor` | swarm / teammate 展示元数据。 |
|
||||
|
||||
### JSONL 示例
|
||||
|
||||
主会话消息:
|
||||
|
||||
```jsonl
|
||||
{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s1","isSidechain":false,"cwd":"D:\\vibe\\claude-code","message":{"role":"user","content":"修复测试"}}
|
||||
{"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":"s1","isSidechain":false,"message":{"role":"assistant","content":[{"type":"text","text":"我来检查。"}]}}
|
||||
```
|
||||
|
||||
sidechain 消息:
|
||||
|
||||
```jsonl
|
||||
{"type":"user","uuid":"u2","parentUuid":null,"sessionId":"s1","isSidechain":true,"agentId":"ag1","message":{"role":"user","content":"分析 compact 路径"}}
|
||||
```
|
||||
|
||||
agent 的 `content-replacement`:
|
||||
|
||||
```jsonl
|
||||
{"type":"content-replacement","sessionId":"s1","agentId":"ag1","replacements":[{"messageUuid":"u2","toolUseId":"toolu_...","blockIndex":0,"kind":"persisted"}]}
|
||||
```
|
||||
|
||||
compact boundary:
|
||||
|
||||
```jsonl
|
||||
{"type":"system","subtype":"compact_boundary","uuid":"b1","parentUuid":"a9","logicalParentUuid":"a9","sessionId":"s1","compactMetadata":{"trigger":"auto","preTokens":182000,"messagesSummarized":94}}
|
||||
```
|
||||
|
||||
## 写入生命周期
|
||||
|
||||
### 总流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant QE as QueryEngine
|
||||
participant SS as sessionStorage.Project
|
||||
participant FS as JSONL
|
||||
participant API as query()/API
|
||||
|
||||
User->>QE: ask(messages)
|
||||
QE->>SS: recordTranscript(user messages)
|
||||
SS->>SS: clean + dedup + insertMessageChain
|
||||
SS->>SS: appendEntry / enqueueWrite
|
||||
SS-->>FS: drain queue append JSONL
|
||||
QE->>API: start query loop
|
||||
API-->>QE: assistant/user/system compact_boundary
|
||||
QE->>SS: recordTranscript(streamed messages)
|
||||
QE->>SS: flushSessionStorage before result when needed
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
| 设计 | 为什么 |
|
||||
|---|---|
|
||||
| 用户输入先写 transcript,再进 API | 进程在 API 前崩溃时,resume 仍能看到用户 prompt。 |
|
||||
| assistant streaming 写入多为 fire-and-forget | 不阻塞 token streaming。 |
|
||||
| result 前按需 flush | 避免 SDK/桌面端拿到 result 后立即杀进程导致尾部丢失。 |
|
||||
| `progress` 不参与链路 | 高频 progress tick 不应该制造分叉或膨胀 transcript。 |
|
||||
|
||||
### 主会话写入
|
||||
|
||||
入口:`recordTranscript(messages, teamInfo?, startingParentUuidHint?, allMessages?)`。
|
||||
|
||||
流程:
|
||||
|
||||
1. `cleanMessagesForLogging()` 过滤 UI-only 或不应持久化的消息。
|
||||
2. `getSessionMessages(sessionId)` 读取当前 session 已有 UUID set。
|
||||
3. 对未写过的消息调用 `insertMessageChain()`。
|
||||
4. `insertMessageChain()` 补 `parentUuid/sessionId/cwd/timestamp/version/gitBranch/isSidechain`。
|
||||
5. `appendEntry()` 进入 per-file queue。
|
||||
|
||||
去重不是简单丢弃所有重复:如果 prefix 中某些消息已写过,写入器会推进 `startingParentUuid`,确保后续新消息接在正确父节点后。
|
||||
|
||||
### 写队列、materialize 和 flush
|
||||
|
||||
`Project` 内部维护 per-file queue:
|
||||
|
||||
| 机制 | 细节 |
|
||||
|---|---|
|
||||
| `writeQueues` | `Map<filePath, entry[]>`,按文件聚合写入。 |
|
||||
| drain timer | 默认 100ms;CCR/remote persistence 场景约 10ms。 |
|
||||
| queue 上限 | 单队列超过 1000 条会丢弃最老 queued entry 并 resolve,防止内存无限增长。 |
|
||||
| chunk 上限 | 单次 JSONL append chunk 约 100MB。 |
|
||||
| `flushSessionStorage()` | 取消 timer,等待 active drain 和 tracked writes。 |
|
||||
|
||||
`sessionFile` 初始为 `null`。这时 title、tag、mode、worktree 等 metadata 先存在内存或 `pendingEntries` 中。第一次出现 `user` 或 `assistant` 时,`materializeSessionFile()` 才创建 session 文件,然后:
|
||||
|
||||
1. 写入缓存 metadata。
|
||||
2. 回放 pending entries。
|
||||
3. 之后所有 entry 正常 append。
|
||||
|
||||
这样可以避免“只打开 CLI 没说话”也产生 metadata-only session,污染 `/resume` 列表。
|
||||
|
||||
### sidechain 写入
|
||||
|
||||
subagent 使用 `recordSidechainTranscript(messages, agentId, startingParentUuid?)`。
|
||||
|
||||
它底层仍走 `insertMessageChain()`,但写入字段不同:
|
||||
|
||||
```ts
|
||||
isSidechain: true
|
||||
agentId: agentId
|
||||
```
|
||||
|
||||
`appendEntry()` 遇到 `isSidechain && agentId` 的 transcript message,会把它路由到:
|
||||
|
||||
```text
|
||||
<project>/<sessionId>/subagents/agent-<agentId>.jsonl
|
||||
```
|
||||
|
||||
如果 `content-replacement` 带 `agentId`,也会路由到该 agent 的 sidechain JSONL,而不是主 session JSONL。
|
||||
|
||||
一个很重要的例外:sidechain 写入不会用主 session UUID set 做去重。fork agent 会复用父会话消息 UUID 来继承上下文;如果按主 session 去重,会把继承上下文从 sidechain 中误删,导致 agent resume 时只剩子 prompt。
|
||||
|
||||
## 读取与链路重建
|
||||
|
||||
### 从 JSONL 到有效链
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[loadTranscriptFile(file)] --> B[readTranscriptForLoad<br/>大文件按 chunk 读]
|
||||
B --> C[parseJSONL Entry]
|
||||
C --> D[messages Map uuid->TranscriptMessage]
|
||||
C --> E[metadata maps/arrays]
|
||||
D --> F[progress bridge / preserved relink / snip removal]
|
||||
F --> G[select leaf]
|
||||
G --> H[buildConversationChain]
|
||||
H --> I[recoverOrphanedParallelToolResults]
|
||||
I --> J[LogOption or agent transcript]
|
||||
```
|
||||
|
||||
`loadTranscriptFile(filePath, opts?)` 产出:
|
||||
|
||||
| 输出 | 用途 |
|
||||
|---|---|
|
||||
| `messages` | `uuid -> TranscriptMessage`。 |
|
||||
| `leafUuids` | 候选 leaf。 |
|
||||
| title/tag/mode/worktree/PR maps | session metadata。 |
|
||||
| `fileHistorySnapshots` / `attributionSnapshots` | 文件状态恢复。 |
|
||||
| `contentReplacements` | 主线程 replacement records。 |
|
||||
| `agentContentReplacements` | `agentId -> replacement records`。 |
|
||||
| `contextCollapseCommits` / `contextCollapseSnapshot` | context-collapse 恢复输入。 |
|
||||
|
||||
### leaf 与 parent 链
|
||||
|
||||
`buildConversationChain(messages, leaf)`:
|
||||
|
||||
1. 从 leaf 开始。
|
||||
2. 读取 `parentUuid`。
|
||||
3. 找到父消息并继续回溯。
|
||||
4. 检测 parent cycle,避免无限循环。
|
||||
5. reverse 成正序 transcript。
|
||||
6. 补回并行 tool_use 形成的 DAG 分支。
|
||||
|
||||
一个简化例子:
|
||||
|
||||
```text
|
||||
u1 <- a1 <- u2 <- a2
|
||||
^
|
||||
leaf
|
||||
|
||||
恢复链: a2 -> u2 -> a1 -> u1
|
||||
正序链: u1, a1, u2, a2
|
||||
```
|
||||
|
||||
文件顺序不等于有效链。branch、rewind、streaming fallback 都可能让 JSONL 里有死分支;恢复只选择当前 leaf 所在世界线。
|
||||
|
||||
### metadata 合并规则
|
||||
|
||||
| metadata | 合并方式 | 说明 |
|
||||
|---|---|---|
|
||||
| `custom-title`、`tag`、`mode`、`worktree-state`、`pr-link`、`agent-setting` | sessionId keyed,通常 last-wins | 恢复最新 session 状态。 |
|
||||
| `file-history-snapshot`、`attribution-snapshot` | messageId keyed / array | 恢复文件历史与归因。 |
|
||||
| `content-replacement` | append array | 多轮 replacement 决策都要保留。 |
|
||||
| `agentContentReplacements` | agentId keyed + append array | agent resume 重建 sidechain replacement state。 |
|
||||
| `marble-origami-commit` | ordered array | 顺序有语义,后一个 commit 可能引用前一个 summary。 |
|
||||
| `marble-origami-snapshot` | last-wins | staged snapshot 只恢复最新状态。 |
|
||||
|
||||
### 大文件读取优化
|
||||
|
||||
transcript 可增长到几百 MB 甚至 GB,读取路径有几层防护。
|
||||
|
||||
| 优化 | 位置 | 目的 |
|
||||
|---|---|---|
|
||||
| chunk 读取 | `readTranscriptForLoad()` | 避免一次性读爆内存。 |
|
||||
| fd 层跳过大 metadata | `readTranscriptForLoad()` | `attribution-snapshot` 等大 entry 不进入 buffer。 |
|
||||
| compact 前缀跳过 | `readTranscriptForLoad()` | 遇到非 preserved compact boundary 后,只保留 boundary 后内容。 |
|
||||
| pre-boundary metadata scan | `scanPreBoundaryMetadata()` | compact 前被跳过时,仍保留 title/tag/mode/worktree/PR 等展示信息。 |
|
||||
| byte-level dead branch 裁剪 | `walkChainBeforeParse()` | JSON.parse 前只拼 active chain 和 metadata,跳过 dead fork/rewind branch。 |
|
||||
| lite read 限制 | `MAX_TRANSCRIPT_READ_BYTES` | 直接读 raw transcript 的调用超过约 50MB 要避开。 |
|
||||
|
||||
`walkChainBeforeParse()` 只有预计能丢掉至少一半 buffer 时才做 concat,避免优化本身变成额外成本。
|
||||
|
||||
### preserved segment 与 snip
|
||||
|
||||
compact boundary 可以带 `compactMetadata.preservedSegment`。恢复时 `applyPreservedSegmentRelinks()` 会:
|
||||
|
||||
1. 验证 `tailUuid -> headUuid` 链是否完整。
|
||||
2. 把 preserved segment 的 head 接到 compact anchor 后。
|
||||
3. 把 anchor 的其他 children 接到 preserved tail。
|
||||
4. 删除最后一个 boundary 前且不属于 preserved segment 的旧消息。
|
||||
5. 清零 preserved assistant 的 usage,避免恢复后马上又触发 autocompact。
|
||||
|
||||
示意:
|
||||
|
||||
```text
|
||||
compact 前: old... -> anchor -> head -> ... -> tail -> next
|
||||
compact 后: boundary/summary -> head -> ... -> tail -> next
|
||||
```
|
||||
|
||||
`snip` 和 compact 不同:compact 截断前缀,snip 删除中段。JSONL 不能真的删除旧行,所以 `applySnipRemovals()` 在内存 map 中删除 `removedUuids`,再把 dangling `parentUuid` 重连到最近未删除祖先。
|
||||
|
||||
### 旧链路修复
|
||||
|
||||
| 问题 | 修复 |
|
||||
|---|---|
|
||||
| legacy `progress` 曾进入 parent 链 | `progressBridge` 把指向 progress 的 parent 改回 progress 的真实父节点。 |
|
||||
| parent cycle | `buildConversationChain()` 检测 cycle,记录并返回 partial chain。 |
|
||||
| 并行 tool_use 形成 DAG | `recoverOrphanedParallelToolResults()` 按 assistant `message.id` 和 tool_result parent 关系补回 sibling。 |
|
||||
| streaming fallback 孤儿尾巴 | tombstone 触发 `removeTranscriptMessage(uuid)` 删除失败 attempt。 |
|
||||
|
||||
## 恢复入口
|
||||
|
||||
### 入口矩阵
|
||||
|
||||
| 入口 | 加载源 | 是否复用原 sessionId | 是否 adopt 原 JSONL | 特点 |
|
||||
|---|---|---:|---:|---|
|
||||
| `--continue` | 当前目录最近 session | 是 | 是 | 跳过仍 live 的 bg/daemon 非 interactive session。 |
|
||||
| `--resume <uuid>` | 指定 session | 是 | 是 | 也支持 custom title / 搜索词 / picker。 |
|
||||
| `--resume <jsonl>` | 指定 JSONL 文件 | 是 | 是 | Ant 内部/print path 支持。 |
|
||||
| `--fork-session` + resume | 旧 session messages | 否 | 否 | 保持新 sessionId,把旧消息作为新 session 初始内容。 |
|
||||
| `--resume-session-at <message.id>` | print/headless resume | 取决于 resume | 取决于 resume | 截断到指定 assistant message。 |
|
||||
| REPL `/resume` | picker / log option | 是或 fork | 是或否 | 会跑 SessionEnd/SessionStart hooks,切换 UI state。 |
|
||||
|
||||
### CLI resume 流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[main.tsx --continue/--resume] --> B[loadConversationForResume]
|
||||
B --> C[load log or transcript]
|
||||
C --> D[deserializeMessagesWithInterruptDetection]
|
||||
D --> E[processSessionStartHooks]
|
||||
E --> F[processResumedConversation]
|
||||
F --> G{fork session?}
|
||||
G -- no --> H[switchSession + adoptResumedSessionFile]
|
||||
G -- yes --> I[keep fresh sessionId + seed content replacement]
|
||||
H --> J[restore mode/worktree/agent/context-collapse/cost]
|
||||
I --> J
|
||||
J --> K[start REPL or print]
|
||||
```
|
||||
|
||||
核心函数:
|
||||
|
||||
| 函数 | 责任 |
|
||||
|---|---|
|
||||
| `loadConversationForResume()` | 统一加载最近 session、sessionId、LogOption 或 JSONL path;补 lite log;复制 plan/file history;做 consistency check;反序列化和中断检测;返回 metadata。 |
|
||||
| `processResumedConversation()` | CLI interactive 启动恢复;切换或 fork session;恢复 cost、worktree、mode、agent setting、context-collapse、attribution。 |
|
||||
| `restoreSessionStateFromLog()` | 恢复 AppState 侧状态:file history、attribution、context-collapse、TodoWrite todos。 |
|
||||
|
||||
### REPL `/resume`
|
||||
|
||||
REPL 内 resume 比 CLI 启动路径多了“从当前 session 切换到另一个 session”的工作:
|
||||
|
||||
1. 清理目标 log messages。
|
||||
2. 当前 session 跑 SessionEnd hooks。
|
||||
3. 目标 session 跑 SessionStart resume hooks。
|
||||
4. 保存当前 session cost,恢复目标 session cost。
|
||||
5. `switchSession(sessionId, dirname(fullPath))` 原子切换 sessionId + project dir。
|
||||
6. `resetSessionFilePointer()` 并恢复 metadata cache。
|
||||
7. 非 fork 时退出上一次 worktree,恢复目标 worktree,`adoptResumedSessionFile()`。
|
||||
8. fork 时不接管原 transcript,不退出当前 worktree。
|
||||
9. 重建 content replacement state。
|
||||
10. 恢复 remote/local task 状态。
|
||||
11. 替换 messages、清 tool JSX、清输入框。
|
||||
|
||||
### 中断检测矩阵
|
||||
|
||||
`deserializeMessagesWithInterruptDetection()` 会先清理历史消息:
|
||||
|
||||
| 清理 | 目的 |
|
||||
|---|---|
|
||||
| legacy attachment 迁移 | 兼容旧 transcript。 |
|
||||
| 非法 `permissionMode` 删除 | 防止跨 build 的无效枚举进入运行态。 |
|
||||
| unresolved tool_use 过滤 | 避免 API 报 tool_use/tool_result 不配对。 |
|
||||
| orphaned thinking-only assistant 过滤 | 避免中断 streaming 留下孤儿 thinking block。 |
|
||||
| whitespace-only assistant 过滤 | 避免取消时留下空白 assistant。 |
|
||||
|
||||
然后看最后一个 turn-relevant message:
|
||||
|
||||
| 最后有效消息 | 结果 | 额外动作 |
|
||||
|---|---|---|
|
||||
| assistant | `none` | streaming 持久化里 stop_reason 常为 null,不能靠它判断未完成。 |
|
||||
| 普通 user | `interrupted_prompt` | 插入 `NO_RESPONSE_REQUESTED` sentinel 保持 API-valid。 |
|
||||
| meta user / compact summary user | `none` | 不把内部控制消息当用户新请求。 |
|
||||
| tool_result user | 通常 `interrupted_turn` | 例外:Brief/SendUserMessage/SendUserFile terminal tool_result 视为完成。 |
|
||||
| attachment | `interrupted_turn` | 追加 meta user:`Continue from where you left off.` |
|
||||
| system/progress/API error assistant | 跳过 | 不作为 turn 完成判断依据。 |
|
||||
|
||||
`interrupted_turn` 会统一转换为 `interrupted_prompt`,让上层只处理一种“需要续跑”的状态。
|
||||
|
||||
## 错误恢复矩阵
|
||||
|
||||
| 场景 | 处理策略 | transcript 影响 |
|
||||
|---|---|---|
|
||||
| API 前进程崩溃 | 用户 prompt 已由 `QueryEngine.ask()` 先写入。 | resume 看到普通 user,触发 `interrupted_prompt`。 |
|
||||
| streaming fallback 产生孤儿 assistant | yield tombstone,REPL 移除 UI message 并调用 `removeTranscriptMessage(uuid)`。 | 优先只改 JSONL 尾部 64KB;大文件目标不在尾部时跳过慢 rewrite。 |
|
||||
| prompt-too-long / media-too-large | streaming 阶段先 withheld;先 context-collapse drain,再 reactive compact;失败才暴露错误。 | compact 成功则写 boundary/summary 并重试;失败才写 API error message。 |
|
||||
| max_output_tokens | 先提高 max output override;仍失败则注入内部 recovery prompt 续写;耗尽才暴露错误。 | 内部 retry prompt 不一定成为普通 transcript,取决于是否 yield 到外层。 |
|
||||
| auto compact 关闭但到 blocking limit | 直接 yield prompt-too-long 风格 API error。 | 保留用户手动 `/compact` 空间。 |
|
||||
| abort during streaming/tools | 补齐缺失 tool_result,必要时 yield user interruption message。 | `reason === interrupt` 时跳过 interruption message,因为后续 queued user message 已提供上下文。 |
|
||||
| stop hook blocking | 把 hook blocking error 加入 state 后重试。 | 有 reactive compact guard,避免 hook/error/compact 无限循环。 |
|
||||
| compact boundary 指向未落盘 tail | QueryEngine 写 boundary 前强制补写 preserved tail 前的消息。 | 避免恢复时 boundary 引用不存在 UUID。 |
|
||||
| subagent transcript 尾部不完整 | `resumeAgentBackground()` 再次过滤 unresolved tool_use、orphan thinking、空白 assistant。 | 避免恢复 agent 后 API 请求非法。 |
|
||||
|
||||
## 上下文视图
|
||||
|
||||
同一份消息在系统里有四种视图,不要混在一起:
|
||||
|
||||
| 视图 | 内容 | 谁使用 |
|
||||
|---|---|---|
|
||||
| Raw transcript | JSONL 中所有 entry,包括旧历史、dead branch、metadata、sidechain。 | 磁盘持久化和审计。 |
|
||||
| UI scrollback | REPL 当前展示的消息,可能保留 compact 前历史和 collapsed UI group。 | 终端 UI。 |
|
||||
| Active query view | `getMessagesAfterCompactBoundary()` 后的消息,默认再投影 snip。 | `query.ts` 上下文管理。 |
|
||||
| API wire view | `normalizeMessagesForAPI()` 后,过滤 system boundary、修复 tool pairing、插入 cache edits。 | Anthropic/OpenAI/Gemini 等 API client。 |
|
||||
|
||||
每轮 query 的 active context 顺序:
|
||||
|
||||
1. `getMessagesAfterCompactBoundary(messages)`:取最近 compact boundary 之后的 active slice,默认叠加 snip 投影。
|
||||
2. 删除旧 `toolUseResult` 原始 payload,只保留 API 需要的 `message.content`。
|
||||
3. `applyToolResultBudget()`:过大的 tool_result 替换为 preview/stub,并写 `content-replacement`。
|
||||
4. `snipCompactIfNeeded()`:`HISTORY_SNIP` 下删除中段历史。
|
||||
5. `microcompactMessages()`:time-based microcompact,再 cached microcompact。
|
||||
6. `contextCollapse.applyCollapsesIfNeeded()`:当前为 identity stub。
|
||||
7. `autoCompactIfNeeded()`:主动 compact,优先 session memory compact。
|
||||
8. predictive autocompact:API 前估算本 turn 增长,必要时提前 compact。
|
||||
9. API 真实超限后:context-collapse drain,再 reactive compact。
|
||||
|
||||
## Compact 与投影
|
||||
|
||||
### Compact 类型对比
|
||||
|
||||
| 类型 | 触发 | 摘要来源 | 是否调用 compact API | 是否保留尾段 | 失败策略 |
|
||||
|---|---|---|---:|---:|---|
|
||||
| manual compact | `/compact` | compact summary API 或 session memory | 取决于路径 | 取决于 full/partial/SM | 显示失败或回退传统 compact。 |
|
||||
| auto compact | token 阈值 | 先 session memory,后 summary API | 取决于路径 | 取决于路径 | 连续失败 circuit breaker,默认 3 次后停止自动 compact。 |
|
||||
| predictive compact | API 前估算增长 | 同 auto compact | 取决于路径 | 取决于路径 | 失败则继续原请求或走后续错误恢复。 |
|
||||
| reactive compact | API 真实 413/media error 后 | `compactConversation()` | 是 | 当前 wrapper 取决于 compact 实现 | `hasAttemptedReactiveCompact` 防循环。 |
|
||||
| session memory compact | manual/auto 前置尝试 | session memory 文件 | 否 | 是 | 若 post-compact 仍超阈值,放弃并回退传统 compact。 |
|
||||
| microcompact | time/cached 小型压缩 | 局部清理或 API cache edit | 不一定 | 不适用 | 通常不改变 JSONL 主历史。 |
|
||||
| snip | `HISTORY_SNIP` | 删除中段 | 否 | 保留前后上下文 | 通过 snip metadata 投影,不物理删旧行。 |
|
||||
|
||||
### Compact 结果形态
|
||||
|
||||
传统 compact 会生成:
|
||||
|
||||
1. `compact_boundary` system message。
|
||||
2. compact summary user message。
|
||||
3. post-compact attachments,例如当前文件、计划模式、技能、MCP/tool schema delta、hook 结果。
|
||||
|
||||
简化 before/after:
|
||||
|
||||
```text
|
||||
Raw/UI:
|
||||
u1, a1, u2, a2, ... u99, a99,
|
||||
system:compact_boundary,
|
||||
user:compact summary,
|
||||
attachment:current files,
|
||||
u100
|
||||
|
||||
Active query view:
|
||||
system:compact_boundary,
|
||||
user:compact summary,
|
||||
attachment:current files,
|
||||
u100
|
||||
|
||||
API wire view:
|
||||
user:compact summary,
|
||||
attachment/content,
|
||||
u100
|
||||
```
|
||||
|
||||
boundary 本身是 system message,最后会被 API normalization 过滤;它的价值主要在本地投影、恢复和统计。
|
||||
|
||||
### Boundary metadata
|
||||
|
||||
`createCompactBoundaryMessage()` 写:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `compactMetadata.trigger` | `manual` 或 `auto`。 |
|
||||
| `compactMetadata.preTokens` | compact 前 token 数。 |
|
||||
| `compactMetadata.userContext` | 用户手动 compact 的额外说明。 |
|
||||
| `compactMetadata.messagesSummarized` | 被总结消息数量。 |
|
||||
| `logicalParentUuid` | compact 前最后消息,用于逻辑追踪。 |
|
||||
|
||||
后续路径还会补:
|
||||
|
||||
| 字段 | 来源 | 作用 |
|
||||
|---|---|---|
|
||||
| `preCompactDiscoveredTools` | traditional/SM compact | 恢复 deferred tool schema 可见性。 |
|
||||
| `preservedSegment.{headUuid,anchorUuid,tailUuid}` | partial/SM compact | 恢复时把保留尾段接到 boundary 后。 |
|
||||
|
||||
### Tool result budget 与 content replacement
|
||||
|
||||
大 tool_result 不一定直接进入后续上下文。`applyToolResultBudget()` 会按 API-level user message 聚合预算,必要时把大块内容持久化并替换成较小 preview/stub。
|
||||
|
||||
关键点:
|
||||
|
||||
| 点 | 说明 |
|
||||
|---|---|
|
||||
| replacement decision 会落 JSONL | `recordContentReplacement()` 写 `content-replacement`。 |
|
||||
| 主线程和 agent 分开 | 无 `agentId` 写主 JSONL;有 `agentId` 写 sidechain JSONL。 |
|
||||
| resume 会重建 replacement state | 避免恢复后同一大结果又变回完整内容,导致 token 暴涨或 prompt cache 失配。 |
|
||||
| `--fork-session` 会 seed records | fork 新 session 时复制 replacement 决策到新 session。 |
|
||||
|
||||
### Session memory compact
|
||||
|
||||
`sessionMemoryCompact.ts` 是传统 summary compact 前的实验路径。流程:
|
||||
|
||||
1. 等待 session memory extraction 完成。
|
||||
2. 读取 session memory 文件。
|
||||
3. 有 `lastSummarizedMessageId` 时,从其后保留安全尾段;否则把 resumed session 视为已有 memory summary。
|
||||
4. 调整切点,避免断开 tool_use/tool_result 或 thinking blocks。
|
||||
5. 创建标准 `compact_boundary` + summary user message。
|
||||
6. 若 post-compact token count 仍超过阈值,放弃并回退传统 compact。
|
||||
|
||||
因为产物仍是标准 `CompactionResult`,下游写 transcript 和恢复逻辑与传统 compact 共用。
|
||||
|
||||
### Context-collapse 当前状态
|
||||
|
||||
本仓库保留了 context-collapse 的持久化接口,但核心实现是 stub:
|
||||
|
||||
| 模块 | 当前行为 |
|
||||
|---|---|
|
||||
| `contextCollapse/index.ts` | `applyCollapsesIfNeeded()` 返回原 messages;`recoverFromOverflow()` 返回 committed=0;`isWithheldPromptTooLong()` 恒 false。 |
|
||||
| `contextCollapse/operations.ts` | `projectView()` 是 identity。 |
|
||||
| `contextCollapse/persist.ts` | `restoreFromEntries()` 是 no-op。 |
|
||||
|
||||
已预留 JSONL entry:
|
||||
|
||||
| Entry | 写入接口 | 内容 |
|
||||
|---|---|---|
|
||||
| `marble-origami-commit` | `recordContextCollapseCommit()` | `collapseId`、summary UUID/content、archived span 边界。 |
|
||||
| `marble-origami-snapshot` | `recordContextCollapseSnapshot()` | staged spans、armed、lastSpawnTokens。 |
|
||||
|
||||
loader 会收集这些 entry;遇到 compact boundary 时会清空旧 commits/snapshot,避免它们引用已被 compact 丢弃的 UUID。
|
||||
|
||||
所以当前真实生效的上下文缩减主要是 compact、session memory compact、tool_result budget、microcompact 和 snip;context-collapse 只是接口已接好。
|
||||
|
||||
### Compact 后清理
|
||||
|
||||
`runPostCompactCleanup(querySource)` 总是清:
|
||||
|
||||
- microcompact state。
|
||||
- system prompt sections。
|
||||
- classifier approvals。
|
||||
- speculative bash checks。
|
||||
- beta tracing。
|
||||
- session messages memo cache。
|
||||
- compact cleanup callbacks。
|
||||
- `COMMIT_ATTRIBUTION` 下异步 sweep file-content cache。
|
||||
|
||||
只在主线程 compact 清:
|
||||
|
||||
- context-collapse store。
|
||||
- `getUserContext` cache。
|
||||
- memory files cache。
|
||||
|
||||
原因:subagent 和主线程同进程,共享模块级状态。`agent:*` compact 如果清主线程 context-collapse 或 memory cache,会破坏父会话状态。
|
||||
|
||||
它明确不清 `resetSentSkillNames()`,避免 compact 后重新注入完整 skill listing,浪费 token 和 prompt cache。
|
||||
|
||||
## 分支与 Fork 对比
|
||||
|
||||
| 入口 | 本质 | 是否新主 session | 是否 subagent | 持久化位置 | 父会话看到什么 | 恢复方式 |
|
||||
|---|---|---:|---:|---|---|---|
|
||||
| `/branch` | 复制当前主 transcript 成新 JSONL | 是 | 否 | `<newSessionId>.jsonl` | 直接切到新分支会话 | 普通 session resume。 |
|
||||
| `--fork-session` | resume/continue 时把旧消息作为新 session 初始消息 | 是 | 否 | 新 session 首次写入时 materialize | 启动即在新 session 中继续 | 新 session resume。 |
|
||||
| `/fork <directive>` | slash wrapper,调用 AgentTool fork | 否 | 是 | `subagents/agent-<id>.jsonl` + `.meta.json` | fork started + task notification | `resumeAgentBackground()`。 |
|
||||
| `AgentTool({ fork: true })` | Tool 层 fork 子 agent | 否 | 是 | `subagents/agent-<id>.jsonl` + `.meta.json` | sync final tool_result 或 async notification | `resumeAgentBackground()`。 |
|
||||
| 普通 AgentTool async | 后台本地 subagent | 否 | 是 | `subagents/agent-<id>.jsonl` + `.meta.json` | `async_launched` + task notification | `resumeAgentBackground()`。 |
|
||||
| remote AgentTool | CCR remote session | 否 | 远端 | `remote-agents/*.meta.json` | remote task output/notification | `restoreRemoteAgentTasks()` + CCR。 |
|
||||
|
||||
### `/branch`
|
||||
|
||||
`/branch` 创建新 session 文件,不是在原 JSONL 里追加 branch marker。
|
||||
|
||||
流程:
|
||||
|
||||
1. 生成新的 sessionId。
|
||||
2. 读取当前 transcript 文件。
|
||||
3. 过滤主会话消息,排除 `isSidechain` 和非 transcript entry。
|
||||
4. 复制消息并重写 `sessionId`。
|
||||
5. 重新串 `parentUuid`。
|
||||
6. 添加 `forkedFrom: { sessionId, messageUuid }`。
|
||||
7. 复制原 session 的 `content-replacement` entry 并改成新 sessionId。
|
||||
8. 写入 `<newSessionId>.jsonl`。
|
||||
9. 构造 `LogOption` 并让 REPL resume 到新分支。
|
||||
|
||||
### `--fork-session`
|
||||
|
||||
`--fork-session` 只改变 resume 的 ownership:
|
||||
|
||||
| 非 fork resume | fork-session resume |
|
||||
|---|---|
|
||||
| 切到旧 sessionId。 | 保持启动时 fresh sessionId。 |
|
||||
| `adoptResumedSessionFile()` 接管旧 JSONL。 | 不接管旧 JSONL。 |
|
||||
| 后续继续 append 到旧 transcript。 | 后续 materialize 成新 transcript。 |
|
||||
| 原 session 继续增长。 | 原 session 不被写入。 |
|
||||
|
||||
如果旧 session 有 `content-replacement`,会先把 records seed 到新 session,避免大 tool_result 的替换状态丢失。
|
||||
|
||||
## Subagent 与 Fork Agent
|
||||
|
||||
### 普通 subagent
|
||||
|
||||
普通 AgentTool subagent 最终走 `runAgent()`:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Parent as 父会话
|
||||
participant Tool as AgentTool
|
||||
participant Agent as runAgent
|
||||
participant Side as sidechain JSONL
|
||||
participant Task as LocalAgentTask
|
||||
|
||||
Parent->>Tool: assistant tool_use Agent
|
||||
Tool->>Agent: start sync or async
|
||||
Agent->>Side: record initialMessages
|
||||
Agent->>Side: record assistant/user/progress/compact_boundary
|
||||
alt sync foreground
|
||||
Agent-->>Tool: final result
|
||||
Tool-->>Parent: Agent tool_result
|
||||
else async/background
|
||||
Tool-->>Parent: async_launched tool_result
|
||||
Agent-->>Task: complete
|
||||
Task-->>Parent: <task-notification>
|
||||
end
|
||||
```
|
||||
|
||||
父会话通常只记录:
|
||||
|
||||
- Agent tool_use。
|
||||
- Agent tool_result。
|
||||
- async launch result。
|
||||
- task notification。
|
||||
- 必要 progress。
|
||||
|
||||
完整子 agent 内部工具调用和消息在 sidechain JSONL 中,不会混进主会话 active context。
|
||||
|
||||
### Fork agent
|
||||
|
||||
fork agent 是 AgentTool 的一种特殊 subagent。它继承父上下文、system prompt、tools、model 和 thinking config,目标是让多个子 agent 共享尽可能长的 byte-identical prompt cache prefix。
|
||||
|
||||
关键实现:
|
||||
|
||||
| 继承内容 | 实现 |
|
||||
|---|---|
|
||||
| system prompt | 优先使用 `toolUseContext.renderedSystemPrompt`,没有才 fallback 重建。 |
|
||||
| tools | 使用父 `toolUseContext.options.tools`,`useExactTools: true`。 |
|
||||
| model | `FORK_AGENT.model = "inherit"`。 |
|
||||
| thinking/non-interactive | 通过 exact tool/options 继承,避免 cache key 分叉。 |
|
||||
| messages | `forkContextMessages = toolUseContext.messages`。 |
|
||||
|
||||
`buildForkedMessages()` 负责构造 cache-friendly 尾部:
|
||||
|
||||
```text
|
||||
parent history...
|
||||
assistant: [text/thinking/tool_use A/tool_use B/...]
|
||||
user:
|
||||
tool_result for A = "Fork started — processing in background"
|
||||
tool_result for B = "Fork started — processing in background"
|
||||
directive = "<this fork's task>"
|
||||
```
|
||||
|
||||
多个 fork child 的长前缀相同,只有最后 directive 不同。
|
||||
|
||||
限制:
|
||||
|
||||
| 限制 | 原因 |
|
||||
|---|---|
|
||||
| 需要 `FORK_SUBAGENT` feature。 | 功能门控。 |
|
||||
| coordinator mode 禁用。 | coordinator 已有自己的编排模型。 |
|
||||
| non-interactive session 禁用。 | fork subagent 偏交互式后台任务模型。 |
|
||||
| fork child 禁止递归 fork。 | 防止无限 fork;通过 querySource 和 boilerplate tag 检测。 |
|
||||
| resume fork agent 不再传 `forkContextMessages`。 | sidechain 已包含父上下文切片,重复传会造成重复 tool_use id。 |
|
||||
|
||||
### `runForkedAgent()` 不是 AgentTool fork
|
||||
|
||||
`src/utils/forkedAgent.ts` 的 `runForkedAgent()` 是内部 cache-safe side query 工具,用于 session memory、prompt suggestion、summary 等。它复用父 system/user/system context、tools、messages,可选 `skipTranscript`,但默认不写 AgentTool metadata,也不是用户可继续对话的 AgentTool fork。
|
||||
|
||||
## Agent 恢复
|
||||
|
||||
本地 agent 恢复入口是 `resumeAgentBackground()`。
|
||||
|
||||
流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[user continues agent] --> B[getAgentTranscript(agentId)]
|
||||
B --> C[load sidechain JSONL + build chain]
|
||||
C --> D[readAgentMetadata(agentId)]
|
||||
D --> E[filter unresolved tool_use/thinking/blank assistant]
|
||||
E --> F[reconstruct content replacement state]
|
||||
F --> G{metadata.worktreePath exists?}
|
||||
G -- yes --> H[runWithCwdOverride(worktreePath)]
|
||||
G -- no --> I[parent cwd]
|
||||
H --> J[register async LocalAgentTask]
|
||||
I --> J
|
||||
J --> K[continue query loop]
|
||||
```
|
||||
|
||||
恢复时:
|
||||
|
||||
| 状态 | 来源 |
|
||||
|---|---|
|
||||
| agent transcript | `agent-<agentId>.jsonl`。 |
|
||||
| agent type | `agent-<agentId>.meta.json`。 |
|
||||
| fork/general agent 选择 | metadata `agentType`。 |
|
||||
| worktree cwd | metadata `worktreePath`,目录不存在则回退父 cwd。 |
|
||||
| content replacement | sidechain records + parent live state gap-fill。 |
|
||||
| task UI | 重新注册 async task。 |
|
||||
|
||||
## Remote Agent 恢复
|
||||
|
||||
remote CCR agent 不靠本地 sidechain 继续执行。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Tool as AgentTool
|
||||
participant R as RemoteAgentTask
|
||||
participant Sidecar as remote-agents meta
|
||||
participant CCR as CCR session
|
||||
participant REPL as REPL resume
|
||||
|
||||
Tool->>CCR: teleportToRemote()
|
||||
Tool->>R: registerRemoteAgentTask()
|
||||
R->>Sidecar: write remote-agent-<taskId>.meta.json
|
||||
REPL->>Sidecar: restoreRemoteAgentTasks()
|
||||
REPL->>CCR: fetchSession(sessionId)
|
||||
alt running
|
||||
REPL->>R: rebuild RemoteAgentTaskState + polling
|
||||
else 404/archive
|
||||
REPL->>Sidecar: delete sidecar
|
||||
end
|
||||
```
|
||||
|
||||
差异:
|
||||
|
||||
| 本地 subagent | remote agent |
|
||||
|---|---|
|
||||
| 有完整 sidechain JSONL。 | 没有本地执行 transcript。 |
|
||||
| resume 可继续 API 对话。 | resume 只恢复 polling。 |
|
||||
| 状态来自 JSONL + `.meta.json`。 | 状态来自 CCR session + local sidecar。 |
|
||||
| 完成后本地 sidechain 仍可审计。 | 完成/archived 后 sidecar 会删除。 |
|
||||
|
||||
## 常见误区
|
||||
|
||||
| 误区 | 正确理解 |
|
||||
|---|---|
|
||||
| JSONL 顺序就是会话顺序 | 恢复靠 leaf + `parentUuid`,不是简单顺序 replay。 |
|
||||
| compact 删除了旧历史 | compact 追加 boundary;旧历史仍在 raw transcript。 |
|
||||
| boundary 会发给模型 | boundary 是本地 system marker,API normalization 会过滤。 |
|
||||
| `/branch` 和 `/fork` 都是 fork | `/branch` 是新主 session;`/fork` 是 fork subagent sidechain。 |
|
||||
| `--fork-session` 等于 `/branch` | 它不是复制文件命令,而是 resume 时保持 fresh session ownership。 |
|
||||
| subagent 消息会进入主上下文 | 父会话只看到 Agent tool result/notification,完整内部消息在 sidechain。 |
|
||||
| remote agent 有本地 sidechain | remote 只有 sidecar 身份,执行状态来自 CCR。 |
|
||||
| context-collapse 已经真实压缩上下文 | 当前仓库中 context-collapse 核心实现是 stub。 |
|
||||
|
||||
## 源码入口索引
|
||||
|
||||
| 问题 | 从这里看 |
|
||||
|---|---|
|
||||
| Entry union 有哪些类型 | `src/types/logs.ts` 的 `Entry`。 |
|
||||
| 主 transcript 路径 | `src/utils/sessionStorage.ts` 的 `getTranscriptPath()`。 |
|
||||
| subagent transcript 路径 | `getAgentTranscriptPath(agentId)`。 |
|
||||
| remote sidecar 路径 | `getRemoteAgentsDir()` / `getRemoteAgentMetadataPath()`。 |
|
||||
| 主写入 | `recordTranscript()`。 |
|
||||
| sidechain 写入 | `recordSidechainTranscript()`。 |
|
||||
| write queue | `Project.enqueueWrite()` / `drainWriteQueue()` / `flush()`。 |
|
||||
| lazy materialize | `Project.materializeSessionFile()`。 |
|
||||
| tombstone 删除 | `removeTranscriptMessage()` / `Project.removeMessageByUuid()`。 |
|
||||
| 读取 transcript | `loadTranscriptFile()`。 |
|
||||
| 大文件读取 | `readTranscriptForLoad()` in `sessionStoragePortable.ts`。 |
|
||||
| dead branch 裁剪 | `walkChainBeforeParse()`。 |
|
||||
| parent 链重建 | `buildConversationChain()`。 |
|
||||
| parallel tool_result 补回 | `recoverOrphanedParallelToolResults()`。 |
|
||||
| preserved segment | `applyPreservedSegmentRelinks()`。 |
|
||||
| snip removal | `applySnipRemovals()`。 |
|
||||
| CLI resume 加载 | `loadConversationForResume()`。 |
|
||||
| resume 状态切换 | `processResumedConversation()`。 |
|
||||
| AppState 恢复 | `restoreSessionStateFromLog()`。 |
|
||||
| 中断检测 | `deserializeMessagesWithInterruptDetection()`。 |
|
||||
| active context | `getMessagesAfterCompactBoundary()`。 |
|
||||
| query context pipeline | `src/query.ts`。 |
|
||||
| compact boundary | `createCompactBoundaryMessage()`。 |
|
||||
| auto compact | `autoCompactIfNeeded()` / `shouldAutoCompact()`。 |
|
||||
| session memory compact | `src/services/compact/sessionMemoryCompact.ts`。 |
|
||||
| reactive compact | `src/services/compact/reactiveCompact.ts`。 |
|
||||
| post compact cleanup | `runPostCompactCleanup()`。 |
|
||||
| context-collapse stub | `src/services/contextCollapse/*`。 |
|
||||
| `/branch` | `src/commands/branch/branch.ts`。 |
|
||||
| `/fork` | `src/commands/fork/fork.tsx`。 |
|
||||
| AgentTool fork | `AgentTool.tsx` + `forkSubagent.ts`。 |
|
||||
| 普通 subagent 运行 | `runAgent.ts`。 |
|
||||
| agent resume | `resumeAgent.ts`。 |
|
||||
| remote task restore | `restoreRemoteAgentTasks()`。 |
|
||||
3388
docs/superpowers/plans/2026-06-12-workflow-engine.md
Normal file
3388
docs/superpowers/plans/2026-06-12-workflow-engine.md
Normal file
File diff suppressed because it is too large
Load Diff
1170
docs/superpowers/plans/2026-06-13-workflow-panel-redesign.md
Normal file
1170
docs/superpowers/plans/2026-06-13-workflow-panel-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
1113
docs/superpowers/plans/2026-06-13-workflow-run-state-persistence.md
Normal file
1113
docs/superpowers/plans/2026-06-13-workflow-run-state-persistence.md
Normal file
File diff suppressed because it is too large
Load Diff
2022
docs/superpowers/plans/2026-06-13-workflow-tui-ultracode.md
Normal file
2022
docs/superpowers/plans/2026-06-13-workflow-tui-ultracode.md
Normal file
File diff suppressed because it is too large
Load Diff
897
docs/superpowers/plans/2026-06-14-effort-panel-basic.md
Normal file
897
docs/superpowers/plans/2026-06-14-effort-panel-basic.md
Normal file
@@ -0,0 +1,897 @@
|
||||
# EffortPanel 基础面板实施计划(第一阶段)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 `/effort` 无参调用升级为横向 slider 选择面板,覆盖 `low/medium/high/xhigh/max/ultracode` 六档,`←/→` 移动光标、`Enter` 确认、`Esc` 取消。
|
||||
|
||||
**Architecture:** 新增自包含 `EffortPanel` React 组件 + 纯函数状态模块;键盘交互走项目既有的 `useKeybindings` + 自定义 `EffortPanel` keybinding context(与 `ModelPicker` 范式一致);不修改 `src/utils/effort.ts`,复用其纯函数;改造 `src/commands/effort/effort.tsx` 的 `call()`,仅无参时挂载面板。
|
||||
|
||||
**Tech Stack:** Bun + TypeScript + React (Ink via `@anthropic/ink`) + `bun:test` + Biome
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-14-effort-panel-design.md`
|
||||
|
||||
**范围:** 仅第一阶段(基础面板 + 键盘交互 + env override 警告 + ultracode 文案分支)。波纹动画在第二阶段单独 commit,不在本计划内。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 状态 | 责任 |
|
||||
|---|---|---|
|
||||
| `src/components/EffortPanel/effortPanelState.ts` | 新增 | `PanelPosition` 类型 + 纯函数(`moveLeft`/`moveRight`/`home`/`end`/`getInitialCursor`/`PANEL_POSITIONS`),可独立单测 |
|
||||
| `src/components/EffortPanel/EffortPanel.tsx` | 新增 | 面板 React 组件:渲染布局 + `useKeybindings` + Enter/Esc 分支 + 调 `executeEffort` |
|
||||
| `src/components/EffortPanel/__tests__/effortPanelState.test.ts` | 新增 | 纯函数单测 |
|
||||
| `src/components/EffortPanel/__tests__/EffortPanel.test.tsx` | 新增 | 组件渲染 + 分支测试 |
|
||||
| `src/keybindings/schema.ts` | 修改 | 在 `KeybindingAction` 联合类型里追加 4 个 `effortPanel:*` action |
|
||||
| `src/keybindings/defaultBindings.ts` | 修改 | 追加 `EffortPanel` context 绑定(`←/→/enter/escape/home/end`)|
|
||||
| `src/keybindings/__tests__/`(如已有 schema/defaultBindings 测试)| 修改(如有) | 追加新 context 的回归断言 |
|
||||
| `src/commands/effort/effort.tsx` | 修改 | `call()` 在 `args === ''` 时返回 `<EffortPanel>`;其他路径不变 |
|
||||
|
||||
**不修改的文件:** `src/utils/effort.ts`、`src/commands/effort/index.ts`、`src/state/AppState.tsx`。
|
||||
|
||||
---
|
||||
|
||||
## Task 1:纯函数状态模块(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/EffortPanel/effortPanelState.ts`
|
||||
- Test: `src/components/EffortPanel/__tests__/effortPanelState.test.ts`
|
||||
|
||||
- [ ] **Step 1.1: 写失败测试(基础导出与边界)**
|
||||
|
||||
Create `src/components/EffortPanel/__tests__/effortPanelState.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
END_POSITION,
|
||||
HOME_POSITION,
|
||||
PANEL_POSITIONS,
|
||||
type PanelPosition,
|
||||
getInitialCursor,
|
||||
isUltracode,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
} from '../effortPanelState.js'
|
||||
|
||||
describe('effortPanelState', () => {
|
||||
test('PANEL_POSITIONS 顺序为 low → ultracode', () => {
|
||||
expect(PANEL_POSITIONS).toEqual([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
'ultracode',
|
||||
])
|
||||
})
|
||||
|
||||
test('moveLeft 在 low 处保持 low', () => {
|
||||
expect(moveLeft('low')).toBe('low')
|
||||
})
|
||||
|
||||
test('moveLeft 正常左移', () => {
|
||||
expect(moveLeft('high')).toBe('medium')
|
||||
expect(moveLeft('ultracode')).toBe('max')
|
||||
})
|
||||
|
||||
test('moveRight 在 ultracode 处保持 ultracode', () => {
|
||||
expect(moveRight('ultracode')).toBe('ultracode')
|
||||
})
|
||||
|
||||
test('moveRight 正常右移', () => {
|
||||
expect(moveRight('medium')).toBe('high')
|
||||
expect(moveRight('max')).toBe('ultracode')
|
||||
})
|
||||
|
||||
test('HOME_POSITION 等于 low', () => {
|
||||
expect(HOME_POSITION).toBe('low')
|
||||
})
|
||||
|
||||
test('END_POSITION 等于 ultracode', () => {
|
||||
expect(END_POSITION).toBe('ultracode')
|
||||
})
|
||||
|
||||
test('isUltracode 守卫', () => {
|
||||
expect(isUltracode('ultracode')).toBe(true)
|
||||
expect(isUltracode('max')).toBe(false)
|
||||
})
|
||||
|
||||
test('getInitialCursor:env override 存在时返回 env 值(若是合法档位)', () => {
|
||||
expect(getInitialCursor({ envOverride: 'high', appStateEffort: 'medium', displayed: 'high' })).toBe('high')
|
||||
})
|
||||
|
||||
test('getInitialCursor:env 为 null(unset)时用 displayed', () => {
|
||||
expect(getInitialCursor({ envOverride: null, appStateEffort: undefined, displayed: 'medium' })).toBe('medium')
|
||||
})
|
||||
|
||||
test('getInitialCursor:env undefined 时用 displayed', () => {
|
||||
expect(getInitialCursor({ envOverride: undefined, appStateEffort: 'high', displayed: 'high' })).toBe('high')
|
||||
})
|
||||
|
||||
test('getInitialCursor:env 是数值(ant-only)时落回 displayed', () => {
|
||||
// 数值不是合法 PanelPosition,回退
|
||||
expect(getInitialCursor({ envOverride: 75, appStateEffort: 'medium', displayed: 'medium' })).toBe('medium')
|
||||
})
|
||||
|
||||
test('PanelPosition 类型编译期检查(隐式)', () => {
|
||||
const p: PanelPosition = 'xhigh'
|
||||
expect(p).toBe('xhigh')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: 运行测试,确认失败**
|
||||
|
||||
Run: `bun test src/components/EffortPanel/__tests__/effortPanelState.test.ts`
|
||||
Expected: FAIL,错误形如 `Cannot find module '../effortPanelState.js'`
|
||||
|
||||
- [ ] **Step 1.3: 实现纯函数模块**
|
||||
|
||||
Create `src/components/EffortPanel/effortPanelState.ts`:
|
||||
|
||||
```ts
|
||||
import type { EffortValue } from '../../../utils/effort.js'
|
||||
|
||||
/**
|
||||
* 光标在面板上的位置。仅面板内部使用,不进入 AppState / settings / API。
|
||||
* 'ultracode' 不是 EffortLevel;它在本面板里仅作视觉占位与文案引导。
|
||||
*/
|
||||
export type PanelPosition =
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'xhigh'
|
||||
| 'max'
|
||||
| 'ultracode'
|
||||
|
||||
export const PANEL_POSITIONS: readonly PanelPosition[] = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
'ultracode',
|
||||
] as const
|
||||
|
||||
export const HOME_POSITION: PanelPosition = 'low'
|
||||
export const END_POSITION: PanelPosition = 'ultracode'
|
||||
|
||||
const NON_ULTRACODE_POSITIONS: readonly PanelPosition[] = PANEL_POSITIONS.filter(
|
||||
p => p !== 'ultracode',
|
||||
)
|
||||
|
||||
/**
|
||||
* 判断一个 EffortValue 是否可作为面板光标位置。
|
||||
* 数值(ant-only)和 ultracode 都不是合法 PanelPosition(ultracode 由面板内部产生)。
|
||||
*/
|
||||
function isPanelPosition(value: unknown): value is PanelPosition {
|
||||
return typeof value === 'string' && (PANEL_POSITIONS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把非 ultracode 的 string EffortValue 收窄为 PanelPosition 的前 5 档。
|
||||
* 用于 env override 与 appState 的归一化。
|
||||
*/
|
||||
function normalizeToPanelPosition(value: EffortValue | null | undefined): PanelPosition | undefined {
|
||||
if (value === null || value === undefined) return undefined
|
||||
if (typeof value === 'number') return undefined
|
||||
if (isPanelPosition(value) && value !== 'ultracode') {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function moveLeft(cursor: PanelPosition): PanelPosition {
|
||||
const idx = PANEL_POSITIONS.indexOf(cursor)
|
||||
if (idx <= 0) return PANEL_POSITIONS[0]
|
||||
return PANEL_POSITIONS[idx - 1]
|
||||
}
|
||||
|
||||
export function moveRight(cursor: PanelPosition): PanelPosition {
|
||||
const idx = PANEL_POSITIONS.indexOf(cursor)
|
||||
if (idx === -1 || idx >= PANEL_POSITIONS.length - 1) {
|
||||
return PANEL_POSITIONS[PANEL_POSITIONS.length - 1]
|
||||
}
|
||||
return PANEL_POSITIONS[idx + 1]
|
||||
}
|
||||
|
||||
export function isUltracode(cursor: PanelPosition): boolean {
|
||||
return cursor === 'ultracode'
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定面板挂载时的初始光标位置。
|
||||
* 优先级:env override(若是合法档位)> displayed level(已是 fallback 'high' 之后)
|
||||
*
|
||||
* @param envOverride getEffortEnvOverride() 的返回值:EffortValue | null | undefined
|
||||
* @param appStateEffort AppState.effortValue
|
||||
* @param displayed getDisplayedEffortLevel(model, appStateEffort) —— 必传,避免此处再依赖 model
|
||||
*/
|
||||
export function getInitialCursor(args: {
|
||||
envOverride: EffortValue | null | undefined
|
||||
appStateEffort: EffortValue | undefined
|
||||
displayed: PanelPosition
|
||||
}): PanelPosition {
|
||||
const fromEnv = normalizeToPanelPosition(args.envOverride)
|
||||
if (fromEnv !== undefined) return fromEnv
|
||||
// displayed 已经是 EffortLevel(不含 ultracode),合法
|
||||
return args.displayed
|
||||
}
|
||||
|
||||
// 保留导出,便于将来测试扩展
|
||||
export { NON_ULTRACODE_POSITIONS }
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4: 运行测试,确认通过**
|
||||
|
||||
Run: `bun test src/components/EffortPanel/__tests__/effortPanelState.test.ts`
|
||||
Expected: PASS(所有 11 个 test 通过)
|
||||
|
||||
- [ ] **Step 1.5: 类型 + lint 检查**
|
||||
|
||||
Run: `bunx tsc --noEmit && bunx biome check src/components/EffortPanel/`
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 1.6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/EffortPanel/effortPanelState.ts src/components/EffortPanel/__tests__/effortPanelState.test.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(effort): 新增 EffortPanel 纯函数状态模块(PanelPosition + 移动/初始光标)
|
||||
|
||||
仅含纯函数与类型,无 React/Ink 依赖,便于单测。
|
||||
- PANEL_POSITIONS:low → medium → high → xhigh → max → ultracode
|
||||
- moveLeft/moveRight:边界钳制(low 不再左移、ultracode 不再右移)
|
||||
- getInitialCursor:env override > displayed level
|
||||
|
||||
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2:注册 EffortPanel keybinding context
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/keybindings/schema.ts`(在 `KeybindingAction` 联合类型追加 6 个 action)
|
||||
- Modify: `src/keybindings/defaultBindings.ts`(追加 `EffortPanel` context 块)
|
||||
|
||||
- [ ] **Step 2.1: 检查 schema.ts 现有结构与校验测试**
|
||||
|
||||
Run: `grep -n "modelPicker:" src/keybindings/schema.ts`
|
||||
Expected: 看到三行 `modelPicker:decreaseEffort/increaseEffort/toggle1M`,附近就是合适的插入位置。
|
||||
|
||||
Run: `ls src/keybindings/__tests__/ 2>/dev/null`
|
||||
Expected: 查看是否有 schema/defaultBindings 的回归测试文件(决定是否需要补断言)。
|
||||
|
||||
- [ ] **Step 2.2: 在 schema.ts 追加 6 个 action**
|
||||
|
||||
打开 `src/keybindings/schema.ts`,找到 `// Model picker actions (ant-only)` 块(约 line 153-156),在它**后面**追加:
|
||||
|
||||
```ts
|
||||
// Effort panel actions (slash /effort without args)
|
||||
'effortPanel:decrease',
|
||||
'effortPanel:increase',
|
||||
'effortPanel:home',
|
||||
'effortPanel:end',
|
||||
'effortPanel:confirm',
|
||||
'effortPanel:cancel',
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3: 在 defaultBindings.ts 追加 EffortPanel context**
|
||||
|
||||
打开 `src/keybindings/defaultBindings.ts`,找到 `ModelPicker` 块(约 line 320-328),在它**后面**(`Select` 块之前)追加:
|
||||
|
||||
```ts
|
||||
// Effort panel (slash /effort without args)
|
||||
{
|
||||
context: 'EffortPanel',
|
||||
bindings: {
|
||||
left: 'effortPanel:decrease',
|
||||
right: 'effortPanel:increase',
|
||||
h: 'effortPanel:decrease',
|
||||
l: 'effortPanel:increase',
|
||||
home: 'effortPanel:home',
|
||||
end: 'effortPanel:end',
|
||||
enter: 'effortPanel:confirm',
|
||||
escape: 'effortPanel:cancel',
|
||||
q: 'effortPanel:cancel',
|
||||
'ctrl+c': 'effortPanel:cancel',
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
注意:
|
||||
- `q` 与 `escape` / `ctrl+c` 都映射到 `effortPanel:cancel`,与 spec §5 状态机一致。
|
||||
- Ink 的 useInput 默认在 ctrl+c 时退出进程;但项目 useKeybindings 系统会先拦截 ctrl+c(参考 `useInput` 源码中 `if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC)` 分支)。若实施时发现 ctrl+c 仍直接退出进程,**降级为只绑 q + escape**,并在 commit message 里注明。
|
||||
- Step 2.2 的 6 个 action(含 `home/end`)与此处的 8 个绑定一一对应。
|
||||
|
||||
- [ ] **Step 2.4: 类型 + lint 检查**
|
||||
|
||||
Run: `bunx tsc --noEmit`
|
||||
Expected: 0 errors(如果 schema 校验是 type-level 的,新增 action 会被识别)
|
||||
|
||||
Run: `bun test src/keybindings/ 2>/dev/null`
|
||||
Expected: 已有测试不破。
|
||||
|
||||
- [ ] **Step 2.5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/keybindings/schema.ts src/keybindings/defaultBindings.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(keybindings): 注册 EffortPanel context 与 6 个 action
|
||||
|
||||
绑定 ←/→/h/l/home/end/enter/escape 到 effortPanel:* action。
|
||||
与 ModelPicker context 范式一致,避免左右键被全局 keybinding 拦截。
|
||||
|
||||
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3:实现 EffortPanel React 组件
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/EffortPanel/EffortPanel.tsx`
|
||||
- Create: `src/components/EffortPanel/__tests__/EffortPanel.test.tsx`
|
||||
|
||||
- [ ] **Step 3.1: 写失败测试(渲染基础形态)**
|
||||
|
||||
Create `src/components/EffortPanel/__tests__/EffortPanel.test.tsx`:
|
||||
|
||||
```tsx
|
||||
import { describe, expect, mock, test } from 'bun:test'
|
||||
import React from 'react'
|
||||
import { render } from '../../../test-utils/ink-render.js'
|
||||
import { EffortPanel } from '../EffortPanel.js'
|
||||
|
||||
// 复用项目共享 mock(避免 bootstrap/state 副作用)
|
||||
mock.module('src/utils/log.ts', () => {
|
||||
const { logMock } = require('../../../../tests/mocks/log')
|
||||
return logMock()
|
||||
})
|
||||
|
||||
const baseProps = {
|
||||
model: 'claude-opus-4-7',
|
||||
appStateEffort: undefined as undefined | string,
|
||||
onDone: () => {},
|
||||
}
|
||||
|
||||
describe('EffortPanel 渲染', () => {
|
||||
test('显示标题 Effort、两极 Faster/Smarter、6 个档位、底栏提示', () => {
|
||||
const { stdout } = render(<EffortPanel {...baseProps} appStateEffort={undefined} />)
|
||||
const out = stdout.join('')
|
||||
expect(out).toContain('Effort')
|
||||
expect(out).toContain('Faster')
|
||||
expect(out).toContain('Smarter')
|
||||
expect(out).toContain('low')
|
||||
expect(out).toContain('medium')
|
||||
expect(out).toContain('high')
|
||||
expect(out).toContain('xhigh')
|
||||
expect(out).toContain('max')
|
||||
expect(out).toContain('ultracode')
|
||||
expect(out).toContain('xhigh + workflows')
|
||||
expect(out).toContain('←/→ adjust')
|
||||
expect(out).toContain('Enter confirm')
|
||||
expect(out).toContain('Esc cancel')
|
||||
})
|
||||
|
||||
test('光标 ▲ 初始指向当前生效档(high)', () => {
|
||||
const { stdout } = render(<EffortPanel {...baseProps} appStateEffort="high" />)
|
||||
// 找到 high 那一行上方有 ▲
|
||||
expect(stdout.join('')).toContain('▲')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
> 注:`ink-render.js` 路径在 Step 3.2 探查;如项目无现成 helper,退化为不依赖渲染的纯逻辑测试(仅测 onDone 分支回调)。
|
||||
|
||||
- [ ] **Step 3.2: 探查 Ink 测试 helper**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
find src packages -name "*.ts*" -path "*test*" -exec grep -l "render.*Ink\|@anthropic/ink" {} \; 2>/dev/null | head -5
|
||||
grep -rn "render(" src/components/**/__tests__/*.tsx 2>/dev/null | head -10
|
||||
```
|
||||
|
||||
Expected:要么找到现成 helper(用之),要么确认项目里 Ink 组件测试都用"调用 onDone 回调断言"而非 ink render。如果后者,**Step 3.1 改写为回调断言式测试**(见 Step 3.3 备注)。
|
||||
|
||||
- [ ] **Step 3.3: 实现组件**
|
||||
|
||||
Create `src/components/EffortPanel/EffortPanel.tsx`:
|
||||
|
||||
```tsx
|
||||
import * as React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import {
|
||||
type EffortValue,
|
||||
getDisplayedEffortLevel,
|
||||
getEffortEnvOverride,
|
||||
} from '../../utils/effort.js'
|
||||
import {
|
||||
type PanelPosition,
|
||||
getInitialCursor,
|
||||
isUltracode,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
PANEL_POSITIONS,
|
||||
} from './effortPanelState.js'
|
||||
import { executeEffort } from '../../commands/effort/effort.js'
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
|
||||
import { useSetAppState } from '../../state/AppState.js'
|
||||
|
||||
// 终端 ≥ 80 cols 时使用;窄屏适配第二阶段处理
|
||||
const PANEL_WIDTH = 76
|
||||
|
||||
type Props = {
|
||||
appStateEffort: EffortValue | undefined
|
||||
onDone: (message: string) => void
|
||||
}
|
||||
|
||||
// ▲ 落在每档中心列:均匀分布
|
||||
function cursorColumn(cursor: PanelPosition): number {
|
||||
const segment = Math.floor(PANEL_WIDTH / PANEL_POSITIONS.length)
|
||||
const idx = PANEL_POSITIONS.indexOf(cursor)
|
||||
return segment * idx + Math.floor(segment / 2)
|
||||
}
|
||||
|
||||
function renderPaddedLine(cursor: PanelPosition): string {
|
||||
const col = cursorColumn(cursor)
|
||||
// ▲ 上方的"分隔线 + 光标"行:左侧 ─,到列处 ▲,右侧继续 ─
|
||||
return `${'─'.repeat(col)}▲${'─'.repeat(Math.max(0, PANEL_WIDTH - col - 1))}`
|
||||
}
|
||||
|
||||
export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
const model = useMainLoopModel()
|
||||
|
||||
const envOverride = getEffortEnvOverride()
|
||||
const displayed = getDisplayedEffortLevel(model, appStateEffort)
|
||||
const initialCursor = getInitialCursor({ envOverride, appStateEffort, displayed })
|
||||
|
||||
const [cursor, setCursor] = React.useState<PanelPosition>(initialCursor)
|
||||
const [done, setDone] = React.useState(false)
|
||||
|
||||
const handleConfirm = React.useCallback(() => {
|
||||
if (done) return
|
||||
setDone(true)
|
||||
|
||||
if (isUltracode(cursor)) {
|
||||
onDone(
|
||||
'ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const result = executeEffort(cursor)
|
||||
if (result.effortUpdate) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
effortValue: result.effortUpdate!.value,
|
||||
}))
|
||||
}
|
||||
onDone(result.message)
|
||||
}, [cursor, done, onDone, setAppState])
|
||||
|
||||
const handleCancel = React.useCallback(() => {
|
||||
if (done) return
|
||||
setDone(true)
|
||||
onDone('Effort unchanged.')
|
||||
}, [done, onDone])
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'effortPanel:decrease': () => setCursor(c => moveLeft(c)),
|
||||
'effortPanel:increase': () => setCursor(c => moveRight(c)),
|
||||
'effortPanel:home': () => setCursor('low'),
|
||||
'effortPanel:end': () => setCursor('ultracode'),
|
||||
'effortPanel:confirm': handleConfirm,
|
||||
'effortPanel:cancel': handleCancel,
|
||||
},
|
||||
{ context: 'EffortPanel' },
|
||||
)
|
||||
|
||||
const envActive = envOverride !== null && envOverride !== undefined
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
|
||||
|
||||
// 两极文字行:左 Faster + 中间空格 + 右 Smarter
|
||||
const fasterLen = 'Faster'.length
|
||||
const smarterLen = 'Smarter'.length
|
||||
const gap = Math.max(0, PANEL_WIDTH - fasterLen - smarterLen)
|
||||
const poleLine = `Faster${' '.repeat(gap)}Smarter`
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text bold>Effort</Text>
|
||||
{envActive && (
|
||||
<Text color="yellow">
|
||||
⚠ CLAUDE_CODE_EFFORT_LEVEL={envRaw} overrides this session
|
||||
</Text>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>{poleLine}</Text>
|
||||
</Box>
|
||||
<Text>{renderPaddedLine(cursor)}</Text>
|
||||
<Text>
|
||||
{PANEL_POSITIONS.map(p => (p as string).padEnd(11)).join('').trimEnd()}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{' '.repeat(Math.max(0, PANEL_WIDTH - 'xhigh + workflows'.length))}
|
||||
xhigh + workflows
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>←/→ adjust · Enter confirm · Esc cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ 对齐是粗糙实现(padEnd 11 假设每档名宽度 ≤ 11;实际 'ultracode' = 9 字符,OK;'xhigh' = 5)。第一版允许略微错位,视觉精度在第二阶段调优。重点是:标题、6 档名、底栏提示、▲ 标记必须出现。
|
||||
|
||||
> **Step 3.3 备注(如无 ink render helper):** Step 5 走纯函数抽取方案测分支;渲染层只做"包含字符串"断言。
|
||||
|
||||
- [ ] **Step 3.4: 运行测试,确认通过**
|
||||
|
||||
Run: `bun test src/components/EffortPanel/__tests__/EffortPanel.test.tsx`
|
||||
Expected: PASS
|
||||
|
||||
如失败:检查 `useKeybindings` import 路径、`executeEffort` 是否能从 effort.tsx 导出(必要时在 effort.tsx 加 `export`)、`useMainLoopModel` hook 是否在测试环境工作(可能需要 mock)。
|
||||
|
||||
- [ ] **Step 3.5: 类型 + lint 检查**
|
||||
|
||||
Run: `bunx tsc --noEmit && bunx biome check src/components/EffortPanel/`
|
||||
Expected: 0 errors(如有 lint 警告,按提示修;`useKeybindings` 未使用变量之类的需移除)
|
||||
|
||||
- [ ] **Step 3.6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/EffortPanel/EffortPanel.tsx src/components/EffortPanel/__tests__/EffortPanel.test.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(effort): 实现 EffortPanel 组件主体(渲染 + 键盘交互 + 确认/取消分支)
|
||||
|
||||
- 横向 slider 布局:Faster ↔ Smarter 两极,6 档刻度
|
||||
- useKeybindings 注册 EffortPanel context,←/→/h/l/home/end/enter/escape
|
||||
- Enter 在 5 档之一 → 调 executeEffort 写 settings + AppState
|
||||
- Enter 在 ultracode → 输出引导文案,不写状态
|
||||
- Esc → "Effort unchanged."
|
||||
- env override 时顶部黄色警告
|
||||
|
||||
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4:改造 `/effort` 命令挂载面板
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/commands/effort/effort.tsx`
|
||||
|
||||
- [ ] **Step 4.1: 阅读现状**
|
||||
|
||||
Run: `cat src/commands/effort/effort.tsx`
|
||||
确认 `call()` 当前签名与 `ShowCurrentEffort` / `ApplyEffortAndClose` 组件结构。无参分支当前走 `<ShowCurrentEffort>`。
|
||||
|
||||
- [ ] **Step 4.2: 改造 call() 无参分支**
|
||||
|
||||
打开 `src/commands/effort/effort.tsx`,找到 `call()` 函数(约 line 153-169)。在文件顶部新增 import:
|
||||
|
||||
```tsx
|
||||
import { EffortPanel } from '../../components/EffortPanel/EffortPanel.js'
|
||||
```
|
||||
|
||||
把 `call()` 改为(替换无参分支):
|
||||
|
||||
```tsx
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: unknown,
|
||||
args?: string,
|
||||
): Promise<React.ReactNode> {
|
||||
args = args?.trim() || ''
|
||||
|
||||
if (COMMON_HELP_ARGS.includes(args)) {
|
||||
onDone(
|
||||
'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extended reasoning beyond high, short of max; including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning; maps to xhigh for ChatGPT Codex models\n- auto: Use the default effort level for your model',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 无参 / /effort current / /effort status:原行为是显示当前档位;
|
||||
// 现在拆分:完全无参 → 打开面板;current/status → 仍显示文本
|
||||
if (args === '') {
|
||||
return <EffortPanelWrapper onDone={onDone} />
|
||||
}
|
||||
|
||||
if (args === 'current' || args === 'status') {
|
||||
return <ShowCurrentEffort onDone={onDone} />
|
||||
}
|
||||
|
||||
const result = executeEffort(args)
|
||||
return <ApplyEffortAndClose result={result} onDone={onDone} />
|
||||
}
|
||||
```
|
||||
|
||||
在文件底部追加 `EffortPanelWrapper`(桥接面板到 AppState 与 onDone):
|
||||
|
||||
```tsx
|
||||
function EffortPanelWrapper({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (result: string) => void
|
||||
}): React.ReactNode {
|
||||
const effortValue = useAppState(s => s.effortValue)
|
||||
return <EffortPanel appStateEffort={effortValue} onDone={onDone} />
|
||||
}
|
||||
```
|
||||
|
||||
注意:`EffortPanel` 内部已经自己读 model + env override + 写 AppState,所以 wrapper 只是把 `effortValue` 透传。
|
||||
|
||||
- [ ] **Step 4.3: 类型 + lint 检查**
|
||||
|
||||
Run: `bunx tsc --noEmit && bunx biome check src/commands/effort/`
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4.4: 手动验证(pipe mode 快速跑)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
echo "/effort" | bun run src/entrypoints/cli.tsx -p 2>&1 | head -30
|
||||
```
|
||||
|
||||
Expected:看到面板渲染输出(标题 Effort、6 档、底栏提示)。pipe 模式下键盘交互不能测,只验证渲染。
|
||||
|
||||
> 如果 pipe 模式不渲染面板(因为非交互式 TTY),改成 `bun run dev` 手测。
|
||||
|
||||
- [ ] **Step 4.5: 跑相关测试**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
bun test src/commands/effort/ 2>/dev/null
|
||||
bun test tests/integration/message-pipeline* 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: 已有测试不破。
|
||||
|
||||
- [ ] **Step 4.6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/commands/effort/effort.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(effort): /effort 无参时挂载 EffortPanel 交互面板
|
||||
|
||||
- 无参 → <EffortPanelWrapper> 透传 AppState.effortValue
|
||||
- current/status → 仍显示文本(不变)
|
||||
- 有参 → 直跳 executeEffort(不变)
|
||||
- help/-h/--help → 不变
|
||||
|
||||
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5:补集成测试(键盘交互 + 分支)
|
||||
|
||||
**Files:**
|
||||
- Modify/Create: `src/components/EffortPanel/__tests__/EffortPanel.test.tsx`(在 Task 3 基础上追加)
|
||||
|
||||
- [ ] **Step 5.1: 决定测试路径(二选一)**
|
||||
|
||||
Ink 组件键盘测试在项目里没有现成 helper(已通过 Task 3.2 探查确认)。直接走 **Step 5.2 的纯函数抽取方案**——把确认/取消决策逻辑抽到 `effortPanelState.ts`,用纯函数测试覆盖分支。键盘 → handler 的连接由 `useKeybindings` 注册保证,**不**单独测(与 `ModelPicker` 测试策略一致)。
|
||||
|
||||
- [ ] **Step 5.2: 抽取确认/取消为可测纯函数(注入 applyFn 避免循环依赖)**
|
||||
|
||||
把 `handleConfirm`/`handleCancel` 的决策逻辑抽到 `effortPanelState.ts`,**接受 `applyFn` 作为参数注入**,避免 `effortPanelState.ts` → `effort.tsx` → `EffortPanel.tsx` → `effortPanelState.ts` 的循环依赖,也避免测试触碰真实 settings。
|
||||
|
||||
在 `effortPanelState.ts` 末尾追加:
|
||||
|
||||
```ts
|
||||
export type ConfirmOutcome =
|
||||
| {
|
||||
kind: 'apply'
|
||||
message: string
|
||||
effortUpdate?: { value: EffortValue | undefined }
|
||||
}
|
||||
| { kind: 'ultracode-hint'; message: string }
|
||||
|
||||
export type ApplyFn = (
|
||||
cursor: PanelPosition,
|
||||
) => { message: string; effortUpdate?: { value: EffortValue | undefined } }
|
||||
|
||||
export const ULTRACODE_HINT =
|
||||
'ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。'
|
||||
|
||||
export const CANCEL_MESSAGE = 'Effort unchanged.'
|
||||
|
||||
export function computeConfirmOutcome(cursor: PanelPosition, applyFn: ApplyFn): ConfirmOutcome {
|
||||
if (isUltracode(cursor)) {
|
||||
return { kind: 'ultracode-hint', message: ULTRACODE_HINT }
|
||||
}
|
||||
const result = applyFn(cursor)
|
||||
return {
|
||||
kind: 'apply',
|
||||
message: result.message,
|
||||
effortUpdate: result.effortUpdate,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后在 `EffortPanel.tsx` 里改用:
|
||||
|
||||
```tsx
|
||||
// 顶部 import 新增
|
||||
import {
|
||||
type PanelPosition,
|
||||
computeConfirmOutcome,
|
||||
getInitialCursor,
|
||||
isUltracode, // 不再需要,computeConfirmOutcome 内部已用
|
||||
moveLeft,
|
||||
moveRight,
|
||||
PANEL_POSITIONS,
|
||||
} from './effortPanelState.js'
|
||||
import { executeEffort } from '../../commands/effort/effort.js'
|
||||
|
||||
// handleConfirm 改为
|
||||
const handleConfirm = React.useCallback(() => {
|
||||
if (done) return
|
||||
setDone(true)
|
||||
const outcome = computeConfirmOutcome(cursor, executeEffort)
|
||||
if (outcome.kind === 'apply' && outcome.effortUpdate) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
effortValue: outcome.effortUpdate!.value,
|
||||
}))
|
||||
}
|
||||
onDone(outcome.message)
|
||||
}, [cursor, done, onDone, setAppState])
|
||||
|
||||
// handleCancel 改为
|
||||
const handleCancel = React.useCallback(() => {
|
||||
if (done) return
|
||||
setDone(true)
|
||||
onDone(CANCEL_MESSAGE)
|
||||
}, [done, onDone])
|
||||
```
|
||||
|
||||
注意 import 里也加 `CANCEL_MESSAGE`。
|
||||
|
||||
- [ ] **Step 5.3: 写分支测试(用注入版纯函数)**
|
||||
|
||||
在 `effortPanelState.test.ts` 末尾追加:
|
||||
|
||||
```ts
|
||||
import {
|
||||
CANCEL_MESSAGE,
|
||||
computeConfirmOutcome,
|
||||
ULTRACODE_HINT,
|
||||
type ApplyFn,
|
||||
} from '../effortPanelState.js'
|
||||
|
||||
describe('computeConfirmOutcome', () => {
|
||||
const mockApply: ApplyFn = cursor => ({
|
||||
message: `applied:${cursor}`,
|
||||
effortUpdate: { value: cursor as any },
|
||||
})
|
||||
|
||||
test('ultracode → kind=ultracode-hint,含 /ultracode 引导', () => {
|
||||
const out = computeConfirmOutcome('ultracode', mockApply)
|
||||
expect(out.kind).toBe('ultracode-hint')
|
||||
if (out.kind === 'ultracode-hint') {
|
||||
expect(out.message).toBe(ULTRACODE_HINT)
|
||||
expect(out.message).toContain('/ultracode')
|
||||
}
|
||||
})
|
||||
|
||||
test('low → kind=apply,message 来自 applyFn,effortUpdate 透传', () => {
|
||||
const out = computeConfirmOutcome('low', mockApply)
|
||||
expect(out.kind).toBe('apply')
|
||||
if (out.kind === 'apply') {
|
||||
expect(out.message).toBe('applied:low')
|
||||
expect(out.effortUpdate?.value).toBe('low')
|
||||
}
|
||||
})
|
||||
|
||||
test('high → apply 路径不调 ultracode 分支', () => {
|
||||
const out = computeConfirmOutcome('high', mockApply)
|
||||
expect(out.kind).toBe('apply')
|
||||
})
|
||||
})
|
||||
|
||||
test('常量字符串', () => {
|
||||
expect(CANCEL_MESSAGE).toBe('Effort unchanged.')
|
||||
expect(ULTRACODE_HINT).toContain('/ultracode <context>')
|
||||
})
|
||||
```
|
||||
|
||||
注意:因注入 mockApply,**完全不需要 mock settings**——这是注入方案的最大红利。
|
||||
|
||||
- [ ] **Step 5.4: 跑测试**
|
||||
|
||||
Run: `bun test src/components/EffortPanel/__tests__/`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5.5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/EffortPanel/
|
||||
git commit -m "$(cat <<'EOF'
|
||||
test(effort): 补 EffortPanel 分支测试(ultracode 引导 / 取消文案 / apply 路径)
|
||||
|
||||
抽 computeConfirmOutcome 为纯函数便于测试,避开 Ink 键盘事件模拟。
|
||||
|
||||
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6:precheck 全量 + 验收
|
||||
|
||||
**Files:** 无修改
|
||||
|
||||
- [ ] **Step 6.1: 跑 precheck**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: typecheck + lint fix + test 全绿,零错误
|
||||
|
||||
如有失败:按错误信息修,**不要**用 `as any` 或 `// biome-ignore` 绕过(除非确实是反编译代码遗留问题)。
|
||||
|
||||
- [ ] **Step 6.2: 手动验收**
|
||||
|
||||
Run: `bun run dev`
|
||||
输入 `/effort`,确认:
|
||||
- 面板出现,光标 `▲` 停在当前生效档
|
||||
- `←` / `→` 移动光标,到边界(low / ultracode)不再继续
|
||||
- Enter 在 high 时输出 `Set effort level to high: ...`
|
||||
- 把光标移到 ultracode,Enter → 输出引导文案
|
||||
- Esc → 输出 `Effort unchanged.`
|
||||
- 设 `CLAUDE_CODE_EFFORT_LEVEL=high bun run dev`,再 `/effort` → 顶部黄色警告
|
||||
- `/effort low`、`/effort auto`、`/effort current`、`/effort help` 仍按原行为工作
|
||||
|
||||
- [ ] **Step 6.3: 推送(可选,等用户决定)**
|
||||
|
||||
Run: `git log --oneline -10` 检查 commit 历史
|
||||
Run: `git push` (**仅在用户确认后**)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 清单
|
||||
|
||||
实施完毕后,对照 spec 自检:
|
||||
|
||||
- [ ] §4 文件结构:`EffortPanel/`、`effortPanelState.ts`、测试文件都存在
|
||||
- [ ] §5 交互:←/→/Home/End/Enter/Esc/q 全部实现;触发与初始光标正确
|
||||
- [ ] §5 分支 A:5 档 Enter 调 executeEffort
|
||||
- [ ] §5 分支 B:ultracode Enter 输出引导文案
|
||||
- [ ] §5 取消:`Effort unchanged.`
|
||||
- [ ] §6 视觉:标题、Faster/Smarter、6 档、ultracode 副标签、底栏提示
|
||||
- [ ] §6 双标记:env override 时 cursor `▲` 与 active `(high) active` 同时显示(如未实现双标记,作为已知缺陷,第二阶段补)
|
||||
- [ ] §6 模型不支持:禁用面板,仅 Esc 可退出(如未实现,第二阶段补,但 spec 写明要实现)
|
||||
- [ ] §9 边界:env override、模型不支持、settings 写入失败(沿用 executeEffort 现有错误路径)
|
||||
- [ ] §10 测试:纯函数 + 组件 + 分支
|
||||
- [ ] precheck 零错误
|
||||
- [ ] 两阶段切分清晰:本计划只做基础,波纹动画第二阶段
|
||||
|
||||
---
|
||||
|
||||
## 已知首版可接受简化
|
||||
|
||||
为了控制首版范围,以下细节**允许暂时不完美**,第二阶段或后续 commit 再调:
|
||||
|
||||
1. `▲` 与档位文字的对齐(窄屏 / 不同终端宽度下可能错位)
|
||||
2. 双标记 `(high) active` 的精确渲染(首版可只显示 cursor `▲`,env override 顶部警告保证用户知情)
|
||||
3. 模型不支持时的禁用态(首版可允许面板仍可操作,但顶部加提示)
|
||||
4. 终端 < 60 cols 的垂直布局退化
|
||||
5. 数字键 1-6 快速跳转(spec 中标为可选增强,本计划不做)
|
||||
|
||||
这些不影响主功能,第一版以"能用、稳定、可提交"为目标。
|
||||
492
docs/superpowers/plans/2026-06-15-ripgrep-system-fallback.md
Normal file
492
docs/superpowers/plans/2026-06-15-ripgrep-system-fallback.md
Normal file
@@ -0,0 +1,492 @@
|
||||
# Ripgrep System Fallback Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make `getRipgrepConfig()` automatically fall back to system `rg` on `PATH` when the builtin/bundled ripgrep is missing (e.g. on Android/Termux), and surface the fallback via `/doctor` plus a one-time startup warning.
|
||||
|
||||
**Architecture:** Add an `existsSync` check on the builtin rg path before returning it. If missing, query `findExecutable('rg', [])`; if found, use system rg with a new human-readable `note` field on `RipgrepConfig` / `getRipgrepStatus()`. Consumers (`/doctor`, startup) read `note` and render it. No new modes — `mode` stays `'system' | 'builtin' | 'embedded'`; `note` carries the fallback narrative.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun runtime, `bun:test`, Biome, lodash `memoize`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-15-ripgrep-system-fallback-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `src/utils/ripgrep.ts` — extend `RipgrepConfig` type with `note?`; extend internal `ripgrepStatus` singleton with `note?`; extend `getRipgrepStatus()` return type with `note?`; rewrite the `builtin` branch of `getRipgrepConfig()` to add `existsSync` + system-rg fallback; sync `note` into the singleton inside `testRipgrepOnFirstUse`.
|
||||
- **Create** `src/utils/__tests__/ripgrepConfig.test.ts` — five-branch decision coverage for `getRipgrepConfig()`.
|
||||
- **Modify** `src/utils/doctorDiagnostic.ts` — propagate `note` from `getRipgrepStatus()` into the diagnostic object.
|
||||
- **Modify** `src/screens/Doctor.tsx` — render `note` in the `Search:` line.
|
||||
- **Modify** `src/entrypoints/init.ts` — emit a one-time stderr warning when `note` is set.
|
||||
|
||||
Each file has a single clear responsibility and changes stay inside that file's existing role.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend types with optional `note` field (no behavior change)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/ripgrep.ts:22-27` (type), `:29-63` (function — minimal shape only), `:523-527` (singleton), `:533-544` (public getter)
|
||||
|
||||
This task only adds the optional field everywhere it's needed and populates it with `undefined` for existing branches. Behavior stays identical. Task 2 fills in the real values.
|
||||
|
||||
- [ ] **Step 1: Extend `RipgrepConfig` type**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 22-27.
|
||||
|
||||
```ts
|
||||
type RipgrepConfig = {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
command: string
|
||||
args: string[]
|
||||
argv0?: string
|
||||
note?: string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the `ripgrepStatus` singleton shape**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 522-527.
|
||||
|
||||
```ts
|
||||
// Singleton to store ripgrep availability status
|
||||
let ripgrepStatus: {
|
||||
working: boolean
|
||||
lastTested: number
|
||||
config: RipgrepConfig
|
||||
note?: string
|
||||
} | null = null
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend `getRipgrepStatus()` return type**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 533-544.
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Get ripgrep status and configuration info
|
||||
* Returns current configuration immediately, with working status if available
|
||||
*/
|
||||
export function getRipgrepStatus(): {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
path: string
|
||||
working: boolean | null // null if not yet tested
|
||||
note?: string
|
||||
} {
|
||||
const config = getRipgrepConfig()
|
||||
return {
|
||||
mode: config.mode,
|
||||
path: config.command,
|
||||
working: ripgrepStatus?.working ?? null,
|
||||
note: ripgrepStatus?.note ?? config.note,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify typecheck**
|
||||
|
||||
Run: `bunx tsc --noEmit`
|
||||
Expected: 0 errors. (All `note` fields are optional; existing code is unaffected.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/ripgrep.ts
|
||||
git commit -m "refactor: add optional note field to RipgrepConfig and getRipgrepStatus"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Implement fallback decision in `getRipgrepConfig()` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/ripgrep.ts:1-20` (imports), `:56-63` (builtin branch)
|
||||
- Test: `src/utils/__tests__/ripgrepConfig.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test file**
|
||||
|
||||
Create `src/utils/__tests__/ripgrepConfig.test.ts` with this exact content:
|
||||
|
||||
```ts
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
// Mock shared side-effect modules. log.ts pulls in bootstrap/state which has
|
||||
// realpathSync side effects at import time. See project CLAUDE.md "Mock 使用规范".
|
||||
mock.module('src/utils/log.ts', () => ({
|
||||
logError: () => {},
|
||||
logEvent: () => {},
|
||||
}))
|
||||
mock.module('src/utils/debug.ts', () => ({
|
||||
logForDebugging: () => {},
|
||||
}))
|
||||
|
||||
// Overridable fakes. Defaults match the "builtin exists" happy path on the
|
||||
// runner's actual platform (no process.platform override — avoids polluting
|
||||
// other tests in the same Bun process, see CLAUDE.md mock contamination note).
|
||||
let fakeExistsSync = (): boolean => true
|
||||
let fakeWhich: string | null = '/usr/local/bin/rg'
|
||||
let fakeBundled = false
|
||||
|
||||
mock.module('node:fs', () => ({
|
||||
existsSync: (p: string) => fakeExistsSync(p),
|
||||
realpathSync: (p: string) => p,
|
||||
constants: {},
|
||||
}))
|
||||
mock.module('src/utils/which.ts', () => ({
|
||||
whichSync: () => fakeWhich,
|
||||
}))
|
||||
mock.module('src/utils/bundledMode.ts', () => ({
|
||||
isInBundledMode: () => fakeBundled,
|
||||
}))
|
||||
mock.module('src/utils/envUtils.ts', () => ({
|
||||
isEnvDefinedFalsy: (v: string | undefined) =>
|
||||
v !== undefined &&
|
||||
['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim()),
|
||||
isEnvTruthy: (v: string | undefined) =>
|
||||
v !== undefined &&
|
||||
['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim()),
|
||||
}))
|
||||
mock.module('src/utils/distRoot.ts', () => ({
|
||||
distRoot: '/fake/dist',
|
||||
}))
|
||||
mock.module('os', () => ({
|
||||
homedir: () => '/fake/home',
|
||||
tmpdir: () => '/tmp',
|
||||
}))
|
||||
// Disable memoize so each call re-evaluates with current fakes.
|
||||
mock.module('lodash-es/memoize.js', () => ({
|
||||
default: <T extends (...args: any[]) => any>(fn: T): T => fn,
|
||||
}))
|
||||
|
||||
const { getRipgrepConfig } = await import('../ripgrep.ts')
|
||||
|
||||
describe('getRipgrepConfig', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
fakeExistsSync = () => true
|
||||
fakeWhich = '/usr/local/bin/rg'
|
||||
fakeBundled = false
|
||||
delete process.env.USE_BUILTIN_RIPGREP
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
test('USE_BUILTIN_RIPGREP=0 with system rg -> mode=system, no note', () => {
|
||||
process.env.USE_BUILTIN_RIPGREP = '0'
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('system')
|
||||
expect(cfg.command).toBe('rg')
|
||||
expect(cfg.note).toBeUndefined()
|
||||
})
|
||||
|
||||
test('bundled mode -> mode=embedded, no note', () => {
|
||||
fakeBundled = true
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('embedded')
|
||||
expect(cfg.note).toBeUndefined()
|
||||
})
|
||||
|
||||
test('builtin path exists -> mode=builtin, no note', () => {
|
||||
fakeExistsSync = () => true
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('builtin')
|
||||
expect(cfg.note).toBeUndefined()
|
||||
})
|
||||
|
||||
test('builtin missing + system rg available -> mode=system, note set', () => {
|
||||
fakeExistsSync = () => false
|
||||
fakeWhich = '/usr/local/bin/rg'
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('system')
|
||||
expect(cfg.command).toBe('rg')
|
||||
expect(typeof cfg.note).toBe('string')
|
||||
expect(cfg.note).toContain('fallback')
|
||||
// Note contains process.platform verbatim — assert the substring shape,
|
||||
// not a specific platform, so the test is portable.
|
||||
expect(cfg.note).toMatch(/builtin rg unavailable on \w+, using system rg/)
|
||||
})
|
||||
|
||||
test('builtin missing + system rg missing -> mode=builtin, note set', () => {
|
||||
fakeExistsSync = () => false
|
||||
fakeWhich = null
|
||||
const cfg = getRipgrepConfig()
|
||||
expect(cfg.mode).toBe('builtin')
|
||||
expect(typeof cfg.note).toBe('string')
|
||||
expect(cfg.note).toContain('no ripgrep available')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
|
||||
Expected: The fourth and fifth tests FAIL — currently `getRipgrepConfig()` returns `mode='builtin'` with no `note` when the builtin path is missing, instead of falling back to system rg.
|
||||
|
||||
- [ ] **Step 3: Add `existsSync` import to `ripgrep.ts`**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 1-2.
|
||||
|
||||
```ts
|
||||
import type { ChildProcess, ExecFileException } from 'child_process'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rewrite the builtin branch with fallback logic**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 56-63.
|
||||
|
||||
```ts
|
||||
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
|
||||
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
|
||||
|
||||
// Builtin binary missing (e.g. Android/Termux, or incomplete install).
|
||||
// Fall back to system rg on PATH. If neither is available, keep the
|
||||
// (nonexistent) builtin path so upper layers still see ENOENT, but
|
||||
// surface a human-readable note so /doctor and startup can explain.
|
||||
if (!existsSync(command)) {
|
||||
const { cmd: systemPath } = findExecutable('rg', [])
|
||||
if (systemPath !== 'rg') {
|
||||
return {
|
||||
mode: 'system',
|
||||
command: 'rg',
|
||||
args: [],
|
||||
note: `fallback: builtin rg unavailable on ${process.platform}, using system rg`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'builtin',
|
||||
command,
|
||||
args: [],
|
||||
note: `no ripgrep available on ${process.platform}; install via apt/pkg/brew or set USE_BUILTIN_RIPGREP=0`,
|
||||
}
|
||||
}
|
||||
|
||||
return { mode: 'builtin', command, args: [] }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
|
||||
Expected: PASS (5/5).
|
||||
|
||||
- [ ] **Step 6: Run full precheck to ensure no regression**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: 0 typecheck errors, 0 lint errors, all tests pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/ripgrep.ts src/utils/__tests__/ripgrepConfig.test.ts
|
||||
git commit -m "feat: ripgrep falls back to system rg when builtin binary missing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Sync `note` into the singleton inside `testRipgrepOnFirstUse`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/ripgrep.ts:549-615`
|
||||
|
||||
Currently `testRipgrepOnFirstUse` writes `ripgrepStatus = { working, lastTested, config }` without `note`. The new `getRipgrepStatus()` in Task 1 already falls back to `config.note` if the singleton has none, so this task is mostly belt-and-suspenders: persist the note explicitly so consumers reading the singleton directly also see it.
|
||||
|
||||
- [ ] **Step 1: Update the success-path assignment**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 592-596.
|
||||
|
||||
```ts
|
||||
ripgrepStatus = {
|
||||
working,
|
||||
lastTested: Date.now(),
|
||||
config,
|
||||
note: config.note,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the catch-path assignment**
|
||||
|
||||
File: `src/utils/ripgrep.ts`, replace lines 608-612.
|
||||
|
||||
```ts
|
||||
ripgrepStatus = {
|
||||
working: false,
|
||||
lastTested: Date.now(),
|
||||
config,
|
||||
note: config.note,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run precheck**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/ripgrep.ts
|
||||
git commit -m "refactor: persist ripgrep config.note in testRipgrepOnFirstUse singleton"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Propagate `note` through `/doctor`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/utils/doctorDiagnostic.ts:588-597`
|
||||
- Modify: `src/screens/Doctor.tsx:224-232`
|
||||
|
||||
- [ ] **Step 1: Extend the diagnostic object**
|
||||
|
||||
File: `src/utils/doctorDiagnostic.ts`, replace lines 588-597.
|
||||
|
||||
```ts
|
||||
// Get ripgrep status and configuration info
|
||||
const ripgrepStatusRaw = getRipgrepStatus()
|
||||
|
||||
// Provide simple ripgrep status info
|
||||
const ripgrepStatus = {
|
||||
working: ripgrepStatusRaw.working ?? true, // Assume working if not yet tested
|
||||
mode: ripgrepStatusRaw.mode,
|
||||
systemPath:
|
||||
ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
|
||||
note: ripgrepStatusRaw.note ?? null,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Render `note` in Doctor.tsx**
|
||||
|
||||
File: `src/screens/Doctor.tsx`, replace lines 224-232.
|
||||
|
||||
```tsx
|
||||
<Text>
|
||||
└ Search: {diagnostic.ripgrepStatus.working ? 'OK' : 'Not working'} (
|
||||
{diagnostic.ripgrepStatus.mode === 'embedded'
|
||||
? 'bundled'
|
||||
: diagnostic.ripgrepStatus.mode === 'builtin'
|
||||
? 'vendor'
|
||||
: diagnostic.ripgrepStatus.systemPath || 'system'}
|
||||
)
|
||||
</Text>
|
||||
{diagnostic.ripgrepStatus.note && (
|
||||
<Text color="warning">
|
||||
└ Note: {diagnostic.ripgrepStatus.note}
|
||||
</Text>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run precheck (lint + typecheck)**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Manual smoke check (optional)**
|
||||
|
||||
Run: `bun run dev -- doctor 2>&1 | grep -i search`
|
||||
Expected: prints the `Search:` line; on dev machine `note` should be empty so no `Note:` line appears.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils/doctorDiagnostic.ts src/screens/Doctor.tsx
|
||||
git commit -m "feat: /doctor shows ripgrep fallback note"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Emit one-time startup warning from `init.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/entrypoints/init.ts:240-243`
|
||||
|
||||
- [ ] **Step 1: Add the warning right before `profileCheckpoint('init_function_end')`**
|
||||
|
||||
File: `src/entrypoints/init.ts`, replace lines 240-243.
|
||||
|
||||
```ts
|
||||
// Surface ripgrep fallback (e.g. Android/Termux) once per session.
|
||||
// Goes to stderr so it doesn't corrupt pipe-mode (`-p`) stdout.
|
||||
try {
|
||||
const { getRipgrepStatus } = await import('../utils/ripgrep.js')
|
||||
const status = getRipgrepStatus()
|
||||
if (status.note) {
|
||||
process.stderr.write(`[ripgrep] ${status.note}\n`)
|
||||
}
|
||||
} catch {
|
||||
// Ripgrep status is best-effort; never block init.
|
||||
}
|
||||
|
||||
logForDiagnosticsNoPII('info', 'init_completed', {
|
||||
duration_ms: Date.now() - initStartTime,
|
||||
})
|
||||
profileCheckpoint('init_function_end')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run precheck**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 3: Manual smoke check**
|
||||
|
||||
Simulate fallback by pointing vendor at a missing path is non-trivial; instead verify no warning fires on the dev machine (where builtin exists):
|
||||
Run: `bun run dev -- --version`
|
||||
Expected: `[ripgrep]` line does NOT appear on stderr.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/entrypoints/init.ts
|
||||
git commit -m "feat: warn on stderr when ripgrep falls back to system rg"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final full precheck + verification
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run full precheck**
|
||||
|
||||
Run: `bun run precheck`
|
||||
Expected: `XXXX pass / 0 fail`, 0 typecheck errors, 0 lint errors.
|
||||
|
||||
- [ ] **Step 2: Verify the five-branch test still passes**
|
||||
|
||||
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
|
||||
Expected: PASS (5/5).
|
||||
|
||||
- [ ] **Step 3: Verify decision logic via REPL sanity (optional)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
bun -e "import('./src/utils/ripgrep.ts').then(m => console.log(JSON.stringify(m.getRipgrepStatus(), null, 2)))"
|
||||
```
|
||||
Expected on macOS dev machine: `mode: "builtin"`, `note: undefined`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
**Spec coverage:**
|
||||
- Decision chain with 5 branches → Task 2 ✓
|
||||
- `note` field on `RipgrepConfig` / singleton / `getRipgrepStatus()` → Tasks 1, 3 ✓
|
||||
- `/doctor` rendering → Task 4 ✓
|
||||
- Startup warning → Task 5 ✓
|
||||
- Tests for 5 branches → Task 2 Step 1 ✓
|
||||
- Acceptance criteria 1-5 cross-checked against spec section "Acceptance Criteria"
|
||||
|
||||
**Placeholder scan:** None. Each step contains the exact code or command.
|
||||
|
||||
**Type consistency:** `note?: string` consistently used across `RipgrepConfig`, `ripgrepStatus` singleton, `getRipgrepStatus()` return, `doctorDiagnostic.ripgrepStatus.note`. In Doctor.tsx the diagnostic object's `note` is `string | null` (Task 4 Step 1 uses `?? null`), accessed with a truthy check (`{note && ...}`) which handles both `null` and `undefined`.
|
||||
|
||||
**Mock hygiene note:** Task 2's test mocks `node:fs`, `src/utils/which.ts`, `src/utils/bundledMode.ts`, `src/utils/envUtils.ts`, `src/utils/distRoot.ts`, `os`, and `lodash-es/memoize.js`. These are process-global mocks (Bun limitation — see project CLAUDE.md "Mock 使用规范"). The test file lives at `src/utils/__tests__/ripgrepConfig.test.ts` and there is no existing `ripgrep.test.ts` to collide with, so no contamination risk.
|
||||
@@ -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 judge),journal `run_id = wtujwahzf`
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
这个 commit 引入的 workflow engine **架构干净、引擎层测试覆盖率高**,但**脚本沙箱和路径校验存在真实漏洞**,并且在本次审查过程中**我亲身实证发现了多个 judge report 没覆盖的 host 集成 bug**(其中包括 workflow 状态变更通知根本没有接进 host 通知系统,导致"完成时自动通知"承诺落空)。受信 LLM 威胁模型下无严格 blocker,但建议合并前修 4 项。
|
||||
|
||||
**严重度计数**(综合 judge + 我的实证):
|
||||
- CRITICAL: 0
|
||||
- HIGH: 2
|
||||
- MEDIUM: 9
|
||||
- LOW: 4
|
||||
- INFO: 6
|
||||
|
||||
---
|
||||
|
||||
## 审查方法
|
||||
|
||||
用 commit 自身引入的 workflow engine 跑了一个对抗式审查 workflow:
|
||||
|
||||
1. **Phase 1 — MultiPerspectiveScan**: 7 个并行 reviewer(architecture / runtime / types / test-quality / integration / security / removal-docs),用 Explore agentType,独立扫各自维度
|
||||
2. **Phase 2 — Consolidation**: opus consolidator 合并去重,按主题归类
|
||||
3. **Phase 3 — AdversarialRefutation**: general-purpose refuter 对每个 CRITICAL/HIGH 用新证据反驳
|
||||
4. **Phase 4 — FinalReport**: opus judge 综合输出最终报告
|
||||
|
||||
journal 完整 10 条 agent 记录在 `.claude/workflow-runs/wtujwahzf/journal.jsonl`。
|
||||
|
||||
**审查过程中实证发现的额外 bug**(judge 没覆盖,因为我正好用这个引擎跑审查才暴露):见下一节。
|
||||
|
||||
---
|
||||
|
||||
## 我实证发现的 bug(judge report 之外)
|
||||
|
||||
这些是跑审查过程中亲身踩到的,judge 的 7 个 reviewer 没看到,因为这些 bug 涉及 host 集成层(`src/workflow/*`、`src/tasks/LocalWorkflowTask/*`)和实际工具调用语义,需要"真正用一次"才能暴露。
|
||||
|
||||
### [HIGH] `args` schema 回归:旧 `z.string()` → 新 `z.unknown()`,prompt 未同步
|
||||
|
||||
- **文件**: `packages/workflow-engine/src/tool/schema.ts:14-19`、`packages/workflow-engine/src/tool/WorkflowTool.ts:38-49, 114`
|
||||
- **现象**: 调用 Workflow 工具传 `args: {"commit": "..."}`,脚本里 `args.commit === undefined`。子 agent 端到端复现:当 args 是 object 时全链路 OK;是 string 时丢字段。
|
||||
- **根因**: 旧 `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts`(本 commit 删除)的 schema 是 `args: z.string().optional()`,模型按旧契约发字符串。本 commit 改成 `z.unknown().optional()` 但 prompt 没强约束"必须传对象",模型继续按旧契约发字符串 → 运行时 `args` 是 string → 脚本里 `args.commit` 拿不到。
|
||||
- **影响**: 任何依赖 `args` 透传的命名 workflow 都会拿到 undefined 字段,直接 throw 或 silently 拿不到参数。我不得不在脚本里把 commit hash 写死绕过。
|
||||
- **修复方向**:
|
||||
- `WorkflowTool.call` 加防御:`if (typeof input.args === 'string') input.args = JSON.parse(input.args)`
|
||||
- 或 schema 用 `z.preprocess((v) => typeof v === 'string' ? JSON.parse(v) : v, z.unknown())`
|
||||
- 同步 prompt:明确"args 必须是 JSON 对象,禁止传字符串化的 JSON"
|
||||
|
||||
### [HIGH] Workflow 状态变更通知未接入 host 通知系统
|
||||
|
||||
- **文件**: `packages/workflow-engine/src/tool/WorkflowTool.ts:127-140`、`src/workflow/ports.ts:84-135`、`src/workflow/wiring.ts`
|
||||
- **现象**: WorkflowTool 的工具返回文本承诺"完成时会自动通知。用 /workflows 查看实时进度。",但本次审查中:
|
||||
- smoke test (`w17jmnsq3`) 完成时,我没收到任何 task-notification
|
||||
- review-commit (`wtujwahzf`) 完成时,我没收到任何 task-notification,是用户手动告诉我"结束了"我才知道
|
||||
- 失败的 review-commit (`wpv9nu2eo`、`w2tvwj0ka`) 也没收到失败通知
|
||||
- 同期启动的 Agent 工具(非 workflow)完成时**有**收到 `<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 黑名单 + 文档明确"沙箱保确定性,不保安全"。
|
||||
|
||||
### MEDIUM(7 项,按价值排序)
|
||||
|
||||
1. **`scriptPath` 任意文件读,无路径校验** — `WorkflowTool.ts:184-188`、`service.ts:104-109`。`input.scriptPath` 来自 LLM,无 containment check,可读 `/etc/passwd`、`~/.ssh/id_rsa`。`FileReadTool` 已有此能力,但 `scriptPath` 绕过权限提示。
|
||||
2. **命名 workflow 路径遍历** — `namedWorkflows.ts:18-19`。`name` 参数未过滤 `../`,`name = "../../etc/passwd"` 可逃出 `workflowDir`(虽然 `.ts/.js/.mjs` 扩展名限制缓解了利用)。
|
||||
3. **Budget 检查竞态** — `hooks.ts:53, 95-106`。`assertCanSpend()` 在 semaphore 之前,N 个并发都能过检 → 实测 4 并发 100 token budget 实花 200(100% 超支)。默认 `budget = null` 时不触发,显式设 budget 才暴露。
|
||||
4. **`parallel`/`pipeline` 静默吞错** — `hooks.ts:126-134, 148-160`。`catch {}` 完全无日志,workflow 作者无法知道 agent 为何失败。"null on error"契约本身是对的,但应该 log。
|
||||
5. **双重类型断言掩盖 schema/type 漂移** — `WorkflowTool.ts:56`。`workflowInputSchema as unknown as z.ZodType<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 语义)报假阳性,多一层反驳能纠偏。
|
||||
|
||||
完整 journal(10 条 agent 输出):`.claude/workflow-runs/wtujwahzf/journal.jsonl`
|
||||
231
docs/superpowers/specs/2026-06-12-workflow-engine-design.md
Normal file
231
docs/superpowers/specs/2026-06-12-workflow-engine-design.md
Normal 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.ts(WORKFLOW_SCRIPTS flag 之后) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
包**不认识** `buildTool` / `toolUseContext` / `runAgent` / `Message` 类型。仅通过端口接口与不透明 host 句柄对话。
|
||||
|
||||
### 端口契约(包内 `ports.ts`)
|
||||
|
||||
| 端口 | 职责 | 核心侧 adapter 实现 |
|
||||
|---|---|---|
|
||||
| `AgentRunner` | `agent()` 后端:`runAgentToResult(params, hostHandle) → AgentRunResult` | 委托 `runAgent` + `assembleToolPool`;schema 时注入 StructuredOutput 工具;`finalizeAgentTool` 抽取最终消息 + usage |
|
||||
| `ProgressEmitter` | `emit(event)` 推进度事件 | 写 `LocalWorkflowTaskState.progress` + `rootSetAppState` |
|
||||
| `TaskRegistrar` | 后台任务生命周期 + 读 `pendingAgentAction` | 复用 `LocalWorkflowTask` API |
|
||||
| `JournalStore` | journal 读写(按 runId) | 文件 fs(`.claude/workflow-runs/<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 输入 schema(script/name/scriptPath/args/resumeFromRunId/desc/title)
|
||||
constants.ts WORKFLOW_TOOL_NAME 等
|
||||
engine/
|
||||
runWorkflow.ts 引擎入口:校验/包装/执行/journal/resume
|
||||
context.ts 执行上下文(端口/信号量/budget/journal/计数器/host)
|
||||
hooks.ts agent/parallel/pipeline/phase/log/workflow 实现
|
||||
script.ts meta 字面量提取 + async 包装 + 沙箱 shim
|
||||
concurrency.ts Semaphore + 上限(16 / 1000 总 / 4096 每次调用)
|
||||
journal.ts hash + 读/写 journal
|
||||
budget.ts budget 累加器(total/spent/remaining)
|
||||
structuredOutput.ts JSON Schema → 结果校验(纯函数)
|
||||
namedWorkflows.ts name → .claude/workflows/<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 上限 | 抛错 |
|
||||
| kill(abort) | signal 传播;`agent()` 检查 signal;workflow 停;task `killed`;通知 partial |
|
||||
| 工具调用层(`call`)脚本非法 | 直接返回错误给模型(不进后台) |
|
||||
|
||||
## 6. 测试策略
|
||||
|
||||
包内全量单测,**无需真实 LLM**(mock 端口——解耦的核心收益):
|
||||
|
||||
- `engine.test.ts`:mock `AgentRunner`(按 prompt 返回预设结果)端到端跑脚本,断言返回值 + 进度事件序列。
|
||||
- `hooks.test.ts`:parallel 单项错→null、pipeline 无屏障顺序、agent schema 校验、skip/dead→null。
|
||||
- `concurrency.test.ts`:信号量限并发、1000/4096 上限抛错。
|
||||
- `journal.test.ts`:hash 稳定、resume 命中前缀、脚本变更全重跑、中途发散重跑尾部。
|
||||
- `budget.test.ts`:spent 累加、触顶抛错。
|
||||
- `script.test.ts`:meta 字面量提取、非字面量/语法错、shim 抛。
|
||||
- `structuredOutput.test.ts`、`namedWorkflows.test.ts`。
|
||||
|
||||
核心侧最小冒烟:adapter 用 `runAgent` 真接线的重 mock 测试;wiring 注册测试。重量级逻辑都在包内。可选:`tests/integration/` 加一个 workflow tool-chain 集成测试(feature-gated)。
|
||||
|
||||
## 7. 核心侧实现
|
||||
|
||||
### 7.1 adapter(`src/workflow/adapter.ts`)
|
||||
|
||||
`createWorkflowAdapter()` 返回端口实现:
|
||||
|
||||
- **AgentRunner.runAgentToResult(params, hostHandle)**:cast 句柄→`{toolUseContext, canUseTool, assistantMessage}`;按 `params.agentType` 从 registry 解析 agentDefinition(缺省=通用 workflow 子 agent);`assembleToolPool`;有 schema→注入 StructuredOutput 工具+系统指令;调 `runAgent` 收消息→`finalizeAgentTool` 抽 text+usage;schema→解析校验返回对象;处理 `pendingAgentAction`(skip)→`null`、终态死亡→`null`;返回 `{kind:'ok', text/object, usage}`。
|
||||
- **ProgressEmitter**:写 `LocalWorkflowTaskState.progress` + `rootSetAppState`。
|
||||
- **TaskRegistrar**:复用现有 `registerLocalWorkflowTask/complete/fail/kill` + 读 `pendingAgentAction`。
|
||||
- **JournalStore / Logger / PermissionGate**:fs / `logForDebugging`+`logEvent` / abort+pendingAction。
|
||||
|
||||
### 7.2 wiring(`src/workflow/wiring.ts`)
|
||||
|
||||
- `createWorkflowTool()`:建 adapter → 调包的 `createWorkflowTool(adapter)` 得描述符 → 包成 `buildTool` 兼容 `Tool` 返回。
|
||||
- `tools.ts`:`const WorkflowTool = feature('WORKFLOW_SCRIPTS') ? require('./workflow/wiring.js').createWorkflowTool() : null`(替换现有清单版)。
|
||||
|
||||
`call` 流程:校验脚本(inline/file/named 解析)→ meta 校验失败直接返错给模型 → 持久化脚本 + 算 hash → resume 则载入 run+journal → 注册后台 task → **立即返回 `{runId, scriptPath}`** → 脱离执行引擎、流进度 → 完成时 complete + 通知(返回值/错误)。
|
||||
|
||||
## 8. 现有文件迁移
|
||||
|
||||
| 文件 | 处理 |
|
||||
|---|---|
|
||||
| `builtin-tools/.../WorkflowTool/WorkflowTool.ts`(清单版) | 删除,逻辑移入新包 |
|
||||
| `constants.ts`(WORKFLOW_TOOL_NAME) | 移入包 `tool/constants.ts`,core 侧 re-export |
|
||||
| `WorkflowPermissionRequest.tsx`(React UI) | 移到 `src/workflow/`(依赖 src 权限组件,属核心侧) |
|
||||
| `createWorkflowCommand.ts`(.md/.yaml 扫描) | 改为扫 `.ts/.js/.mjs` → 生成 `/<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 阶段补一个最小版本。
|
||||
200
docs/superpowers/specs/2026-06-13-workflow-panel-redesign.md
Normal file
200
docs/superpowers/specs/2026-06-13-workflow-panel-redesign.md
Normal 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` 声明 phase(pending `○`)与 store phase(running `●` / done `✓`)+ 一个固定 `All` 项;选中即决定右栏筛选。
|
||||
3. 右 agent 列表:按选中 phase 过滤(`All` 则全显);状态用颜色 + 文字标记(`object` / `text` / `dead`)。
|
||||
4. 焦点轮转键位:`Tab`/`Shift+Tab` 切 run、`←/→` 切 phases↔agents、`↑/↓` 列内移动、`x` kill / `r` resume / `q`/`Esc` quit。
|
||||
5. 视觉极简:无内框,左右栏中间**一条竖线**;选中/光标行用**底色条**(`backgroundColor`,非反白);聚焦列标题橙粗、非聚焦灰。
|
||||
6. 显示 **pending phase**(meta 声明但未启动)。
|
||||
|
||||
**非目标**
|
||||
|
||||
- 不改引擎包(`run_started` 已携带 `meta.phases`,见 §3)。
|
||||
- 不动 `service`/`registry`/`backends`/`ports`/`wiring`/Workflow 工具/`/ultracode`。
|
||||
- 不做 per-agent 操作 UI(仅 run 级 `kill`/`resume`)。
|
||||
- 不改 `BackgroundTasksDialog`(Shift+Down)跳转协议。
|
||||
- 不做 agent 输出详情抽屉(留未来)。
|
||||
|
||||
## 3. 关键发现:零引擎改动
|
||||
|
||||
`ProgressEvent.run_started` **已携带** `meta: WorkflowMeta | null`(`packages/workflow-engine/src/types.ts:60-66`,emit 点 `engine/runWorkflow.ts:72-77`),且 `WorkflowMeta.phases` 已是 `Array<{ title: string; detail?: string }>`(`types.ts:22-27`)。
|
||||
|
||||
→ pending phase 所需数据全在事件流里。面板只需让 store 在 `run_started` 时落地 `declaredPhases`,再与 store 的 `run.phases`(running/done)合并即可。**不触碰引擎包**。
|
||||
|
||||
## 4. 数据模型变更(`src/workflow/progress/store.ts`)
|
||||
|
||||
- `RunProgress` 新增字段:
|
||||
|
||||
```ts
|
||||
declaredPhases: string[] // 来自 run_started.meta.phases[].title;无 meta → []
|
||||
```
|
||||
|
||||
- reducer `run_started` 分支补一行(当前第 74-77 行只用 `event.workflowName`,忽略 `event.meta`):
|
||||
|
||||
```ts
|
||||
case 'run_started':
|
||||
p.workflowName = event.workflowName
|
||||
p.status = 'running'
|
||||
p.declaredPhases = event.meta?.phases?.map(ph => ph.title) ?? []
|
||||
break
|
||||
```
|
||||
|
||||
- `ensure()` 初始化 `declaredPhases: []`。
|
||||
- 其余 reducer 分支、`AgentProgress`、快照排序逻辑不变。
|
||||
|
||||
**测试**(`progress/store.test.ts` 或对应测试文件):
|
||||
- `run_started` 带 `meta.phases` → `declaredPhases` 落地且顺序保留。
|
||||
- `run_started` 的 `meta` 为 `null` → `declaredPhases === []`。
|
||||
- 已有 `agentId` 关联、phase 切换、`run_done` 终态用例保持绿。
|
||||
|
||||
## 5. 面板布局(定稿 ASCII)
|
||||
|
||||
焦点在 PHASES(默认进入态):
|
||||
|
||||
```
|
||||
╭─ Workflows ──────────────────────────── 2 running · 3 done ─╮
|
||||
│ │
|
||||
│ ● review-changes ✓ find-bugs ● migrate-auth │
|
||||
│ ═════════════════ ← Tab / Shift+Tab 切 │
|
||||
│ │
|
||||
│ PHASES │ AGENTS · Review │
|
||||
│ │ │
|
||||
│ ✓ Find 3/3 │ ● review:bugs running │
|
||||
│ ▓▶● Review 2/5▓ │ ● review:perf running │
|
||||
│ ○ Verify 0/2 │ ✓ review:sec object │
|
||||
│ │ ✗ review:api dead │
|
||||
│ All 10 │ ✓ review:auth text │
|
||||
│ │ │
|
||||
│ Tab 切 run · ←/→ 切焦点 · ↑/↓ 移动 · x kill · q quit │
|
||||
╰─────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
按 `→` 焦点到 AGENTS(`PHASES` 标题变灰、`AGENTS` 变橙、光标行铺底色):
|
||||
|
||||
```
|
||||
phases (灰) │ AGENTS · Review (橙)
|
||||
│
|
||||
✓ Find 3/3 │ ● review:bugs running
|
||||
● Review 2/5 │ ▓● review:perf running ▓ ← 光标行底色
|
||||
○ Verify 0/2 │ ✓ review:sec object
|
||||
All 10 │ ✗ review:api dead
|
||||
```
|
||||
|
||||
## 6. 焦点与键位状态机
|
||||
|
||||
**面板状态**(`WorkflowsPanel` 内 `useState`):
|
||||
|
||||
| 状态 | 含义 | 默认 |
|
||||
|---|---|---|
|
||||
| `activeRunId` | 当前 tab 的 runId | 首个 run(无则 null) |
|
||||
| `focusColumn` | `'phases'` \| `'agents'` | `'phases'`(该 run 无任何 phase 则 `'agents'`) |
|
||||
| `selectedPhaseIndex` | phase 侧栏选中项(`0` = `All`) | `0` |
|
||||
| `selectedAgentIndex` | agent 列表光标行 | `0` |
|
||||
|
||||
**键位**:
|
||||
|
||||
| 键 | 作用 |
|
||||
|---|---|
|
||||
| `Tab` / `Shift+Tab` | 切顶部 run tab(正/反);切 tab 时重置 `selectedPhaseIndex=0`、`selectedAgentIndex=0`、`focusColumn` 回默认 |
|
||||
| `←` / `→` | `phases` ↔ `agents` 焦点切换(tabs 不参与左右,由 `Tab` 管) |
|
||||
| `↑` / `↓` | 当前焦点列内移动选中(phase 改筛选;agent 滚光标) |
|
||||
| `x` | kill 当前 tab 的 run |
|
||||
| `r` | resume 当前 tab 的 run(缺 `canUseTool` 时 `onDone` 提示用 `/<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` resume(mock service)、`q`/`Esc` 退出。
|
||||
- 空态(无 run):占位文案 + `n` 提示。
|
||||
- 订阅刷新:store 变更后面板重渲染(agent 状态 running→done)。
|
||||
- **回归**:`bun run precheck` 零错误;现有 workflow 集成测试(canonical scripts / review / loop / resume)保持绿。
|
||||
|
||||
## 10. 里程碑与提交切分
|
||||
|
||||
每个里程碑结束 `bun run precheck` 必须零错误。
|
||||
|
||||
1. **M1 store**:`RunProgress.declaredPhases` + reducer `run_started` 落地 + 测试。
|
||||
2. **M2 panel 组件**:新建 `status.ts` / `TabsBar` / `PhaseSidebar` / `AgentList`;`WorkflowsPanel` 重写为焦点状态机;`useWorkflowKeyboard` 改焦点模型;删除 `WorkflowList` / `WorkflowDetail`。
|
||||
3. **M3 测试**:`WorkflowsPanel.test.tsx` 全量用例 + precheck 绿。
|
||||
4. **M4 文档**:`docs/features/workflow-scripts.md` §六 更新为三区布局/键位;旧 spec §六/§9 加注「面板部分已被 `2026-06-13-workflow-panel-redesign.md` 取代」。
|
||||
|
||||
## 11. 未做 / 未来工作
|
||||
|
||||
- per-agent skip/retry 的 UI 接线(引擎 seam 已在)。
|
||||
- agent 详情抽屉:选中 agent 后展开其 prompt/输出/token。
|
||||
- 多 run 并排对比视图。
|
||||
- `declaredPhases` 与实际 `phase()` 调用不一致时的告警(如脚本声明了 phase 却没调用)。
|
||||
@@ -0,0 +1,191 @@
|
||||
# Workflow Run State Persistence — Design
|
||||
|
||||
**Date**: 2026-06-13
|
||||
**Status**: Approved (brainstorming), pending implementation plan
|
||||
**Related**: `2026-06-12-workflow-engine-design.md`, `2026-06-13-workflow-panel-redesign.md`
|
||||
|
||||
## 问题陈述
|
||||
|
||||
Workflow 脚本的 `return` 值和终态 `RunProgress`(status / agents / phases / returnValue / error)只活在 `ProgressStore`(`src/workflow/progress/store.ts`)的内存 Map 里。一旦 Claude Code 进程关闭/重启,全部丢失。
|
||||
|
||||
已落盘的 `.claude/workflow-runs/<runId>/journal.jsonl` 只记录每个 `agent()` 调用的结构化结果,**不**包含脚本顶层 `return` 值,也无法重建 `/workflows` 面板需要的 `RunProgress` 摘要。重启后面板为空,对话 agent 也无法按 runId 取回 return 值。
|
||||
|
||||
## 目标
|
||||
|
||||
- **(a) 重启后按 runId 取 return** — 对话 agent 在新进程里能拿到已完成 run 的 `returnValue` 与 `error`。
|
||||
- **(b) 面板跨重启展示历史** — `/workflows` 面板重启后能列出历史 run 及其状态/agents/phases/耗时。
|
||||
|
||||
## 非目标
|
||||
|
||||
- **(c) 跨进程 resume 明确排除** — 不重建 abort controller、agent binding、未完成 phase 的中间态。当前 resume 机制(同进程内 journal replay)保持不变;跨进程续跑是独立大特性,不在本 spec 范围。
|
||||
- **自动清理** — `.claude/workflow-runs/` 持续累积,依赖项目 `.gitignore` 与用户手动清理。生命周期管理是后续特性。
|
||||
|
||||
## 架构
|
||||
|
||||
新增一个 host 侧持久化模块 + 三处接入点。**引擎层 `@claude-code-best/workflow-engine` 零改动**——持久化是 host 侧关注,不污染引擎接口。
|
||||
|
||||
### 组件
|
||||
|
||||
| 文件 | 改动 | 职责 |
|
||||
|---|---|---|
|
||||
| `src/workflow/persistence.ts` | 新增 | `writeRunState` / `readRunState` / `listPersistedRuns`;原子覆盖写(tmp + rename);`getRunsDir()` 统一 runsDir 来源 |
|
||||
| `src/workflow/progress/store.ts` | 改 | 新增 `hydrate(run: RunProgress): void` —— 绕过 bus 直接注入磁盘 run(用于 `loadPersistedRuns`) |
|
||||
| `src/workflow/service.ts` | 改 | 订阅 bus `run_done` → `writeRunState`;`getRun(id)` 内存 miss → `readRunState` fallback;新增 `loadPersistedRuns(): Promise<void>` |
|
||||
| `src/workflow/panel/WorkflowsPanel.tsx` | 改 | mount 时调一次 `svc.loadPersistedRuns()`(flag 在 service 单例内部守护,panel 无脑调,重复调用是 no-op) |
|
||||
| `src/workflow/ports.ts` | 改 | `${getProjectRoot()}/.claude/workflow-runs` 提取为 `getRunsDir()` 共享(消除重复拼接,与 persistence.ts 同源) |
|
||||
|
||||
## 数据流
|
||||
|
||||
### 写入(终态触发,单一入口覆盖 A+ 所有终态)
|
||||
|
||||
```
|
||||
engine runWorkflow
|
||||
└─ progressEmitter.emit({type:'run_done', status, returnValue, error})
|
||||
└─ bus.emit
|
||||
├─ store.apply(event) [store 先订阅,内存 RunProgress 已更新]
|
||||
└─ service 订阅 listener [后订阅,store.get(runId) 拿到最新快照]
|
||||
└─ writeRunState(runsDir, runId, snapshot)
|
||||
└─ writeFile(state.json.tmp) → rename(state.json) [原子]
|
||||
```
|
||||
|
||||
**订阅顺序**:bus 是 `Set<listener>`,注册顺序 = 触发顺序。`createProgressStoreFromBus(bus)` 在 service 创建之前先订阅 store;service 后订阅。因此 service 的 `run_done` listener 执行时,`store.get(event.runId)` 已是 apply 后的最新值,直接序列化写盘即可。
|
||||
|
||||
**为什么不需要单独的 shutdown 钩子**:`taskRegistrar.kill` → `abortController.abort()` → `runWorkflow` 看到 signal → 发 `run_done killed` → 走同一个订阅。`service.shutdown()` 显式 kill running run 时同样触发 `run_done`。三种终态(completed / failed / killed)共用一个写盘入口。
|
||||
|
||||
### 读取① — 面板跨重启展示
|
||||
|
||||
```
|
||||
CLI 重启 → 用户 /workflows → WorkflowsPanel mount
|
||||
└─ useEffect: svc.loadPersistedRuns() [service 内部 persistedLoaded flag 守护,仅一次实际扫盘]
|
||||
└─ listPersistedRuns(runsDir) [扫所有子目录的 state.json]
|
||||
└─ store.hydrate(run) [已存在的 runId 跳过,内存优先]
|
||||
```
|
||||
|
||||
**`persistedLoaded` flag 归属**:放在 `WorkflowService` 单例上(`makeService` 闭包变量),不是 panel 模块级。理由:service 是进程单例,flag 跟随单例生命周期最稳;panel 可能多次 mount/unmount,flag 在 service 上可避免重复扫盘。panel `useEffect` 无脑调 `loadPersistedRuns()`,service 内部判断"已加载过则立即返回 resolved Promise"。
|
||||
|
||||
### 读取② — agent 按 runId 取 return
|
||||
|
||||
```
|
||||
service.getRun(id)
|
||||
├─ store.get(id) 命中 → 返回(本次会话的 run)
|
||||
└─ miss → readRunState(runsDir, id) → 返回(历史 run,不注入内存)
|
||||
```
|
||||
|
||||
**不注入内存的取舍**:历史 run 进入内存会污染本次会话的 store / 面板列表语义("内存 = 本次会话产生的 run"这条不变量要保留)。代价是同会话内反复查同一历史 run 会反复读盘——可接受(查询频率低,文件小)。
|
||||
|
||||
## state.json 格式
|
||||
|
||||
包一层 `schemaVersion` 留 migration 空间,payload 是终态 `RunProgress` 全字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"run": {
|
||||
"runId": "w12tp1rrk",
|
||||
"workflowName": "audit-agent-system-vs-ultracode",
|
||||
"status": "completed",
|
||||
"phases": [
|
||||
{"title": "Review", "status": "done"},
|
||||
{"title": "Verify", "status": "done"}
|
||||
],
|
||||
"declaredPhases": ["Review", "Verify"],
|
||||
"currentPhase": null,
|
||||
"agents": [
|
||||
{
|
||||
"id": 1,
|
||||
"label": "review:hooks",
|
||||
"phase": "Review",
|
||||
"status": "done",
|
||||
"outputShape": "object",
|
||||
"tokenCount": 12345,
|
||||
"toolCount": 3,
|
||||
"model": "claude-sonnet-4-6"
|
||||
}
|
||||
],
|
||||
"agentCount": 11,
|
||||
"returnValue": {"dimensionsAudited": 9, "confirmedCount": 2, "confirmed": []},
|
||||
"startedAt": 1718277600000,
|
||||
"updatedAt": 1718278000000,
|
||||
"description": "Audit workflow engine against ultracode skill spec"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段决策
|
||||
|
||||
- `agents[]` 写完整 `AgentProgress`(含 `label` / `phase` / `status` / `tokenCount` / `toolCount` / `model` / `outputShape` / `resultKind`),**不含 agent 实际 output 内容**——output 已在 `journal.jsonl`,避免冗余。
|
||||
- 失败 run 的 `error` 字段直接进 `run.error`(`RunProgress` 已有该字段)。
|
||||
- `returnValue?: unknown` 原样序列化,**不截断**。用户对自己的 return 大小负责(脚本若 return 整个数据库 dump,磁盘占用自负)。
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| `writeRunState` IO 失败(磁盘满 / 权限) | `logForDebugging('[workflow warn] ...')` 吞掉,**不阻断 workflow 完成**——workflow 本身已成功,持久化失败只意味着重启后取不到,可接受 |
|
||||
| `readRunState` 文件不存在 | 返回 `null`,调用方按 miss 处理 |
|
||||
| `readRunState` JSON 解析失败 | 返回 `null`,log warn,当 miss(不崩) |
|
||||
| `readRunState` schema 结构不匹配(缺字段/类型错) | 返回 `null`,log warn,当 miss |
|
||||
| `schemaVersion` 未来不匹配 | 当前是 `1`,无迁移链,任何非 1 的版本 → 返回 `null` 当 miss(向前兼容兜底)。未来升级版本时再引入迁移函数链 |
|
||||
| 原子写中途崩溃 | `writeFile(state.json.tmp)` + `rename(tmp, state.json)`,rename 原子;最坏留下 `.tmp` 文件,下次写覆盖 |
|
||||
| `loadPersistedRuns` 扫到子目录无 `state.json`(只有 journal) | 跳过,不报错(半残 run) |
|
||||
| `loadPersistedRuns` 扫到某 `state.json` 损坏 | 跳过该单个文件,继续扫其余(一个坏文件不阻塞整体加载) |
|
||||
|
||||
## 关键不变量
|
||||
|
||||
1. **内存 run 永远优先于磁盘 run** — `store.hydrate` 跳过已存在 runId;`getRun` 内存命中则不读盘。
|
||||
2. **磁盘是纯终态快照** — 本次会话 running 中的 run 不写盘;进程在 run 终态前被 SIGKILL/断电/crash,该 run 在磁盘上缺失(连 `run_done` 都来不及发)。这是 A+ 接受的边缘情况。
|
||||
3. **磁盘 run 不注入 `getRun` 路径的内存** — 只有 `loadPersistedRuns`(面板 mount)会 hydrate;`getRun` fallback 仅返回,不 hydrate。
|
||||
4. **持久化失败不阻断 workflow** — 写盘是 best-effort,IO 异常只 log 不抛。
|
||||
5. **引擎层零改动** — 所有持久化逻辑在 host 侧(`src/workflow/`),引擎 `@claude-code-best/workflow-engine` 接口不变。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### `src/workflow/__tests__/persistence.test.ts`(新增)— 纯 fs,用 tmpdir
|
||||
|
||||
- `writeRunState` → `readRunState` 往返一致(含 `returnValue` 为对象 / 数组 / 字符串 / null 各形态)
|
||||
- `writeRunState` 原子性:构造 tmp 残留场景,验证 `state.json` 要么完整要么不存在,无半写
|
||||
- `readRunState` 损坏 JSON / 缺文件 / schemaVersion 不符 / 必需字段缺失 → 均返回 `null`
|
||||
- `listPersistedRuns` 扫多子目录、跳过无 `state.json` 的目录、跳过损坏文件、按 `updatedAt` 降序返回
|
||||
|
||||
### `src/workflow/__tests__/store.test.ts`(扩展)
|
||||
|
||||
- `hydrate(run)` 注入新 runId → `get` 命中、`list` 含该项
|
||||
- `hydrate(run)` 已存在 runId → 跳过(内存值不被磁盘覆盖)
|
||||
- `hydrate` 后 `subscribe` listener 被通知
|
||||
|
||||
### `src/workflow/__tests__/service.test.ts`(新增 / 扩展)— 注入 fake bus / ports / tmpdir
|
||||
|
||||
- bus emit `run_done completed` + returnValue → `readRunState(runId)` 命中且 returnValue 一致
|
||||
- bus emit `run_done failed` + error → state.json 写入 status=failed + error 字段
|
||||
- bus emit `run_done killed` → state.json 写入 status=killed
|
||||
- bus emit `run_done` 但 `writeRunState` 抛 IO 错 → service 不抛、其他订阅者(store)仍正常
|
||||
- `getRun(id)` 内存命中 → 不读盘(spy 断言 readRunState 未被调)
|
||||
- `getRun(id)` 内存 miss + 磁盘命中 → 返回磁盘值;再次 `getRun(id)` 仍读盘(未注入内存)
|
||||
- `getRun(id)` 内存 miss + 磁盘 miss → 返回 undefined
|
||||
- `loadPersistedRuns()` 扫盘后 `listRuns()` 含历史 run;已有内存 runId 不被磁盘覆盖
|
||||
|
||||
### `src/workflow/__tests__/WorkflowsPanel.test.tsx`(扩展)
|
||||
|
||||
- WorkflowsPanel mount → 调一次 `loadPersistedRuns`(spy 断言调用次数 = 1)
|
||||
- 重复 mount / 重渲染 → 不重复调用(`persistedLoaded` flag 防重入)
|
||||
|
||||
### 回归
|
||||
|
||||
- `bun test src/workflow/` 全套通过
|
||||
- `bun run precheck` 零错误(typecheck + lint fix + test)
|
||||
|
||||
## 实现顺序提示(供 writing-plans 展开)
|
||||
|
||||
1. `persistence.ts` + 单测(最底层,无依赖)
|
||||
2. `store.ts` 加 `hydrate` + 单测
|
||||
3. `ports.ts` 提取 `getRunsDir()`
|
||||
4. `service.ts` 订阅 `run_done` + `getRun` fallback + `loadPersistedRuns` + 单测
|
||||
5. `WorkflowsPanel.tsx` mount 触发 + 测试
|
||||
6. 全量 `precheck`
|
||||
|
||||
## 未来工作(明确不在本 spec)
|
||||
|
||||
- **跨进程 resume (c)** — 需重建 agent binding / abort / 中间态,独立特性
|
||||
- **生命周期管理** — 数量 cap / 时间 cap / 手动清理命令
|
||||
- **return 值大小限制** — 若发现滥用,再加 schema 级 cap 与截断策略
|
||||
- **schema migration 链** — 当 `schemaVersion` 升到 2 时再引入
|
||||
@@ -0,0 +1,287 @@
|
||||
# Workflow 集成层重写 + `/workflows` 面板 + `/ultracode` skill 设计
|
||||
|
||||
> 状态:草案(待 writing-plans 据此产出实施计划)
|
||||
> 日期:2026-06-13
|
||||
> 关联:上一期引擎重建计划 `docs/superpowers/plans/2026-06-12-workflow-engine.md`、spec `docs/superpowers/specs/2026-06-12-workflow-engine-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与现状
|
||||
|
||||
引擎包 `packages/workflow-engine/`(`@claude-code-best/workflow-engine`)已重建完成:`runWorkflow`、hooks(`agent`/`parallel`/`pipeline`/`phase`/`log`/`workflow`)、journal 确定性 resume、budget、concurrency、structuredOutput、`AgentAdapter` + `AgentAdapterRegistry`(commit `c2253dcb`)、端口契约(`WorkflowPorts`)与自包含工具描述符(`createWorkflowTool`),单测覆盖 99.65%。
|
||||
|
||||
`src/` 侧的集成层(`src/workflow/`)虽已接上引擎,但**没有用上引擎的全部能力**,且 TUI/命令层是占位质量:
|
||||
|
||||
- `src/workflow/adapter.ts`:硬编码单一 `WORKFLOW_AGENT`(不查 `AgentAdapterRegistry`,也没接真实 agent 注册表);`taskRegistrar.pendingAction` 恒返回 `null`(skip/retry 未接线);`permissionGate.isAborted` 恒 `false`;`budgetTotal` 恒 `null`;末尾有 `_AppStateUsed` 这类抑制未用导入的补丁。
|
||||
- `src/workflow/progressStore.ts`:`agent_done` 把"最后一个 running 的 agent"标完成——并发下会标错(真竞态)。
|
||||
- `/workflows`:`local` 命令,返回**纯文本**清单,不是监控面板——本设计将其原地重写为全屏面板。
|
||||
- `/ultracode`:**不存在**。
|
||||
|
||||
本设计把 `src/workflow/` 集成层**全量重写**,使其真正用上引擎能力,并交付全屏监控+控制面板与 ultracode 启动 skill。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
**目标**
|
||||
|
||||
1. 全量重写 `src/workflow/` 集成层(引擎包为地基,不动其核心)。
|
||||
2. 后端为单一 `claude-code` `AgentAdapter`,但**深度接入会话体系**:provider/model/agentType/tools/telemetry 全从活的 `AppState` 解析。
|
||||
3. 把 `/workflows` **原地重写**为全屏**双栏**面板:左栏=各 workflow 的阶段树(光标移动),右栏=聚焦 workflow 的 agent 运行状况 + 基础信息;监控 + 控制(启动命名/resume/kill/展开)。
|
||||
4. 新增 `/ultracode` **纯知识 prompt skill**:把 workflow 编排工作法注入上下文,零运行时副作用。
|
||||
5. 旧 `/workflows` 文本命令重写为面板;接线点切换到新 wiring,外部 `Tool`/命令接口不变。
|
||||
|
||||
**非目标**
|
||||
|
||||
- 不改引擎包核心逻辑(唯一例外:给进度事件加 `agentId`,见 §5)。
|
||||
- 不实现多 provider adapter(v1 单后端;Registry 留扩展点但不预填路由规则)。
|
||||
- 不做 per-agent skip/retry 的 UI 接线(引擎 seam 保留,见 §12)。
|
||||
- 不翻转 `ultracode` 运行时行为开关(纯知识 skill)。
|
||||
- 不做跨进程持久化的进度恢复(live runs 留内存;resume 走 journal)。
|
||||
|
||||
## 3. 范围与迁移清单
|
||||
|
||||
**新建**
|
||||
|
||||
| 路径 | 职责 |
|
||||
|---|---|
|
||||
| `src/workflow/service.ts` | `WorkflowService` 单例门面 |
|
||||
| `src/workflow/registry.ts` | 建 `AgentAdapterRegistry`,注册单一 `claude-code` adapter |
|
||||
| `src/workflow/backends/claudeCodeBackend.ts` | 深度集成的 `AgentAdapter`(runAgent 委托 + 体系解析) |
|
||||
| `src/workflow/backends/types.ts` | 后端/host 解析类型 |
|
||||
| `src/workflow/ports.ts` | 组装 `WorkflowPorts`(registry + 任务生命周期 + journal + progress bus) |
|
||||
| `src/workflow/progress/bus.ts` | 类型化发布/订阅事件总线 |
|
||||
| `src/workflow/progress/store.ts` | reducer:`ProgressEvent` → `RunProgress[]`(按 `agentId` 关联) |
|
||||
| `src/workflow/panel/WorkflowsPanel.tsx` | 双栏全屏面板(local-jsx) |
|
||||
| `src/workflow/panel/WorkflowList.tsx` / `WorkflowDetail.tsx` / `useWorkflowKeyboard.ts` | 左栏 workflow 扁平列表 / 右栏 phase 条+agent 列表 / 键位 |
|
||||
| `src/skills/bundled/ultracode/SKILL.md` | `/ultracode` 知识 skill |
|
||||
|
||||
**重写(整体替换,非打补丁)**
|
||||
|
||||
- `src/workflow/adapter.ts` → 拆解进 `backends/`+`ports.ts`+`registry.ts`
|
||||
- `src/workflow/wiring.ts` → 薄包装,走 `service`
|
||||
- `src/workflow/progressStore.ts` → 拆进 `progress/{bus,store}.ts`
|
||||
- `src/workflow/hostHandle.ts` → 清理(保留不透明 bundle 语义)
|
||||
- `src/workflow/namedWorkflowCommands.ts` → 重写(扫 `.claude/workflows/` → `/<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 adapter,default 路由)
|
||||
├─ hostHandle.ts # 不透明 host bundle(toolUseContext/canUseTool/parentMessage/agentId)
|
||||
├─ ports.ts # WorkflowPorts = { hostFactory, agentRunner(registry), progressEmitter(bus+store), taskRegistrar, journalStore, permissionGate, logger }
|
||||
├─ backends/
|
||||
│ ├─ claudeCodeBackend.ts # AgentAdapter:深度解析 + runAgent 委托
|
||||
│ └─ types.ts
|
||||
├─ progress/
|
||||
│ ├─ bus.ts # emit→多订阅者(store / 面板 / 遥测)
|
||||
│ └─ store.ts # RunProgress[] reducer(agentId 关联)
|
||||
├─ panel/
|
||||
│ ├─ WorkflowsPanel.tsx # 双栏,useSyncExternalStore 订阅 store
|
||||
│ ├─ WorkflowList.tsx # 左栏:扁平 workflow 列表(名字+状态+当前 phase+计数)
|
||||
│ ├─ WorkflowDetail.tsx # 右栏:聚焦 workflow 的 phase 横条 + 扁平 agent 列表
|
||||
│ └─ useWorkflowKeyboard.ts
|
||||
├─ wiring.ts # createWorkflowToolCore(): buildTool(引擎描述符)
|
||||
└─ namedWorkflowCommands.ts # 扫描→/<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` 内并发调用各自独立 id,reducer 按 id 精确落位。补 `hooks.test.ts`:并发 agent 的 started/done id 配对回归。
|
||||
|
||||
## 6. WorkflowService
|
||||
|
||||
```ts
|
||||
type HostContext = { handle: HostHandle; cwd: string; budgetTotal: number | null; toolUseId?: string }
|
||||
|
||||
type WorkflowService = {
|
||||
launch(opts: {
|
||||
source: { script: string } | { name: string } | { scriptPath: string }
|
||||
args?: unknown
|
||||
hostContext: HostContext // 调用方构造(工具/面板各自)
|
||||
description?: string
|
||||
resumeFromRunId?: string
|
||||
}): Promise<{ runId: string }> // 立即返回,后台 detached
|
||||
resume(runId: string, hostContext: HostContext): Promise<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 id;provider 来自 `src/utils/model/providers.ts` 的会话判定。
|
||||
- **agentType → 真实注册表**:`resolveAgentDefinition` 查 `toolUseContext.options.agentDefinitions.activeAgents`,命中即用(Explore/code-reviewer 等内置 + 用户 agent);未命中或无 `agentType` 退 `WORKFLOW_AGENT` 兜底。
|
||||
- **工具池/权限**:worker 权限上下文取 agent 定义或 `acceptEdits`,`assembleToolPool` 生成。
|
||||
- **遥测/token**:`finalizeAgentTool` 的 `usage.output_tokens` 喂 engine budget;`logEvent('tengu_workflow_agent', {…})` 逐 agent 计量。
|
||||
- **Registry**:`registry.ts` = `new AgentAdapterRegistry().register(claudeCodeBackend).default('claude-code')`。`ports.agentRunner.runAgentToResult = (params, host) => registry.resolve(params).run(params, { host })`。v1 不预填路由规则(depth B:单 adapter,不预留多 provider 路由)。
|
||||
|
||||
## 8. 进度模型(bus + store + agentId 关联)
|
||||
|
||||
- `progress/bus.ts`:`createProgressBus()` 返回 `{ emit(event), subscribe(fn) }`。emit 广播给所有订阅者(store、面板、遥测)。替换旧"只有 in-memory Map"的单消费者模型。
|
||||
- `progress/store.ts`:`RunProgress[]` reducer,沿用 `RunProgress` 形状(runId/status/phases/currentPhase/agents/logs/agentCount/returnValue/error/updatedAt)。新增 `AgentProgress.id: number`;`agent_done` 按 `event.agentId` 精确匹配 `agents[].id`(修掉旧 LIFO 竞态)。`subscribe()` 暴露给 React `useSyncExternalStore`。
|
||||
- 状态为进程内(live runs);resume 读磁盘 journal(`.claude/workflow-runs/<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/retry(per-agent)**:引擎 `taskRegistrar.pendingAction` seam 保留;v1 返 `null`。面板控制诉求由 kill/resume 覆盖。
|
||||
|
||||
## 13. 测试策略
|
||||
|
||||
- **引擎**:`hooks.test.ts` 加"并发 agent 的 started/done id 配对"回归。
|
||||
- **集成层**(`src/workflow/__tests__/`):
|
||||
- `service.test.ts`:launch→completed/failed/killed、resume 走 journal、kill 中止、subscribe 通知(mock 端口,无 LLM)。
|
||||
- `registry.test.ts`:默认路由命中 `claude-code`;`resolve` 对未知规则回落默认。
|
||||
- `claudeCodeBackend.test.ts`:agentType→真实定义命中/兜底;model→映射;失败→`dead`(mock `runAgent`)。
|
||||
- `progressStore.test.ts`:**并发 `agent_done` 按 `agentId` 精确关联**(回归旧竞态)、phase 切换、`run_done` 终态。
|
||||
- `WorkflowsPanel.test.tsx`(ink-testing-library):扁平列表渲染、光标 j/k 切换聚焦 workflow、右栏 phase 条+agent 列表、键位 x/r/n、空态、订阅刷新。
|
||||
- **回归**:`bun run precheck` 零错误;现有 workflow 集成测试(canonical scripts/review/loop/resume)仍绿。
|
||||
- 遵循仓库 mock 规范(共享 `tests/mocks/log.ts`、`debug.ts`;mock 底层 HTTP/副作用,不 mock 业务模块;注意 `mock.module` 进程全局污染,集成测试 mock axios 而非源 API 模块)。
|
||||
|
||||
## 14. 里程碑与提交切分
|
||||
|
||||
每个里程碑结束 `bun run precheck` 必须零错误。
|
||||
|
||||
1. **M1 引擎微调**:`ProgressEvent.agentId` + hooks 盖戳 + 单测。
|
||||
2. **M2 进度层**:`progress/bus.ts` + `store.ts`(agentId 关联)+ 测试。
|
||||
3. **M3 后端 + Registry + ports + hostHandle**:`claudeCodeBackend`(深度解析)、`registry`、`ports` 组装 + 测试。
|
||||
4. **M4 Service 门面**:`service.ts`(launch/resume/kill/subscribe/listNamed)+ 测试。
|
||||
5. **M5 工具 wiring 切换 + 接线点更新**:`wiring.ts` 走 service;更新 tools/commands/tasks/constants/classifier/PermissionRequest/BackgroundTasksDialog。`precheck` 绿。
|
||||
6. **M6 `/workflows` 面板(原地重写命令)**:panel 组件(`PhaseTree`/`AgentStatus`)+ 键位 + 把 `src/commands/workflows/` 重写为 local-jsx + 测试。
|
||||
7. **M7 `/ultracode` skill**:`SKILL.md` playbook。
|
||||
8. **M8 文档**:更新 `docs/features/workflow-scripts.md`,新增面板/skill 说明。
|
||||
|
||||
## 15. 未做 / 未来工作
|
||||
|
||||
- 多 provider adapter(OpenAI/Gemini/Grok/Bedrock/Vertex 等真后端 + model 路由分流)——引擎 Registry 机制本身在用(单 adapter),扩第二个 adapter 时再补 `route` 规则;本期按 depth B 不预填。
|
||||
- per-agent skip/retry 的 UI 接线(引擎 seam 已在)。
|
||||
- `ultracode` 运行时行为开关(默认倾向 Workflow 工具)——本期为纯知识 skill。
|
||||
- 跨进程/重启的 live 进度恢复(当前内存;resume 走 journal)。
|
||||
- `budgetTotal` 从 settings/env 注入 turn 级预算。
|
||||
394
docs/superpowers/specs/2026-06-14-effort-panel-design.md
Normal file
394
docs/superpowers/specs/2026-06-14-effort-panel-design.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Effort 交互面板(EffortPanel)设计
|
||||
|
||||
**日期**: 2026-06-14
|
||||
**作者**: brainstorming session 产物
|
||||
**状态**: 待实施
|
||||
**关联**: `src/commands/effort/`、`src/utils/effort.ts`、`src/components/EffortPanel/`(新增)
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
把当前的 `/effort` slash 命令从纯文本式交互升级为终端内的可视化选择面板。
|
||||
|
||||
- 触发:`/effort`(无参)打开面板;`/effort <level>` 直跳路径保留
|
||||
- 视觉:横向 slider,两端标 `Faster` / `Smarter`,刻度为 `low / medium / high / xhigh / max / ultracode`
|
||||
- 交互:`←/→` 移动光标,`Enter` 确认,`Esc` 取消
|
||||
- ultracode 仅作视觉占位,确认后提示用户走 `/ultracode <context>` 启动
|
||||
- 第二阶段加波纹动画(详见 §6)
|
||||
|
||||
## 2. 用户故事
|
||||
|
||||
- 作为开发者,我希望按 `/effort` 就能可视化地选择努力等级,而不用记 5 个枚举值
|
||||
- 作为高频用户,我希望 `/effort high` 这种直跳仍可用,避免脚本/习惯被打断
|
||||
- 作为设置了 `CLAUDE_CODE_EFFORT_LEVEL` 的用户,我希望面板提示我"env 优先级更高",而不是默默忽略我的选择
|
||||
- 作为想试 ultracode 的用户,我希望面板让我知道这个"档位"存在,但落地要走它自己的命令
|
||||
|
||||
## 3. 不在本期范围
|
||||
|
||||
- 不修改 `EffortValue` / `EffortLevel` 类型
|
||||
- 不修改 `src/utils/effort.ts` 的任何纯函数
|
||||
- 不新增专用全局热键(仅通过 `/effort` 触发)
|
||||
- 不在面板里包含 `auto` 选项(仍走 `/effort auto`)
|
||||
- 不真正"启用 ultracode"——面板对 ultracode 仅作视觉提示与文案引导
|
||||
|
||||
## 4. 架构与文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── commands/effort/
|
||||
│ ├── effort.tsx ← 改造:call() 在 args 为空时返回 <EffortPanel>,
|
||||
│ │ 有参时维持原 executeEffort() 路径
|
||||
│ └── index.ts ← 不变
|
||||
├── components/EffortPanel/
|
||||
│ ├── EffortPanel.tsx ← 新增:面板主体(渲染 + 键盘交互 + onDone 通道)
|
||||
│ ├── effortPanelState.ts ← 新增:纯函数 reducer(移动光标、确定选项),
|
||||
│ │ 抽离便于单测
|
||||
│ └── __tests__/
|
||||
│ ├── EffortPanel.test.tsx ← 渲染 / 键盘交互 / env 警告 / ultracode 提示
|
||||
│ └── effortPanelState.test.ts ← reducer 纯函数测试
|
||||
```
|
||||
|
||||
### 复用清单(不重写)
|
||||
|
||||
- `executeEffort()` / `setEffortValue()` / `unsetEffortLevel()`:留在 `effort.tsx`,面板确认时调用
|
||||
- `EFFORT_LEVELS` / `getDisplayedEffortLevel()` / `getEffortEnvOverride()` / `getEffortValueDescription()` / `modelSupportsEffort()`:从 `src/utils/effort.ts` 直接 import
|
||||
- `useInput` 或 `useKeyboard`:从 `@anthropic/ink` 取
|
||||
- `<ApplyEffortAndClose>` 组件:作为面板 Enter 后的"写入并退出"流程组件复用(或迁入 EffortPanel 内部)
|
||||
|
||||
### 类型层面
|
||||
|
||||
不动 `EffortValue` / `EffortLevel`。面板内部用一个新类型 `PanelPosition` 表示光标位置:
|
||||
|
||||
```ts
|
||||
type PanelPosition = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultracode';
|
||||
```
|
||||
|
||||
它仅在面板内部使用,不进入 AppState、不进入 settings.json、不参与 API 调用。
|
||||
|
||||
## 5. 交互流程
|
||||
|
||||
### 触发与初始光标
|
||||
|
||||
```
|
||||
/effort<回车>(无参)
|
||||
→ call() 检测 args === ''
|
||||
→ 渲染 <EffortPanel onDone={onDone} appStateEffort={effortValue} model={model} />
|
||||
→ 光标初始位置:
|
||||
env override 存在时 → env 设定的档位(让用户立刻看到生效值)
|
||||
否则 → getDisplayedEffortLevel(model, appStateEffort)
|
||||
```
|
||||
|
||||
### 状态机
|
||||
|
||||
```
|
||||
状态:{ cursor: PanelPosition }
|
||||
|
||||
事件:
|
||||
← (ArrowLeft) → cursor 左移一位(low 处不左移,保持 low)
|
||||
→ (ArrowRight) → cursor 右移一位(ultracode 处不右移,保持 ultracode)
|
||||
Home / h → cursor = low
|
||||
End / l → cursor = ultracode
|
||||
Enter → 确认分支(见下)
|
||||
Esc / Ctrl+C / q → 取消,onDone("Effort unchanged.")
|
||||
```
|
||||
|
||||
### 确认后的两条分支
|
||||
|
||||
**分支 A:cursor ∈ {low, medium, high, xhigh, max}**
|
||||
|
||||
```
|
||||
调 executeEffort(cursor)
|
||||
→ setEffortValue 写 settings + AppState
|
||||
→ 拿到 result.message
|
||||
onDone(result.message)
|
||||
```
|
||||
|
||||
(与现有 `/effort high` 完全一致的消息体例,含 env override 警告)
|
||||
|
||||
**分支 B:cursor === 'ultracode'**
|
||||
|
||||
```
|
||||
不调 executeEffort
|
||||
onDone("ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。")
|
||||
```
|
||||
|
||||
### 取消路径
|
||||
|
||||
不调 executeEffort、不写 AppState、不写 settings。`onDone("Effort unchanged.")`。
|
||||
|
||||
### 不变路径(仍走原 effort.tsx 逻辑)
|
||||
|
||||
- `/effort low|medium|high|xhigh|max`:直跳
|
||||
- `/effort auto|unset`:unsetEffortLevel
|
||||
- `/effort help|-h|--help`:help 文本
|
||||
- `/effort current|status`:ShowCurrentEffort
|
||||
|
||||
### 焦点与键盘独占
|
||||
|
||||
面板挂载时通过 Ink `useInput` 抢占键盘;卸载时自动释放(与 `AskUserQuestionPermissionRequest` 一致)。
|
||||
|
||||
## 6. 视觉布局
|
||||
|
||||
### 基本形态(无 env override)
|
||||
|
||||
```
|
||||
Effort
|
||||
|
||||
Faster Smarter
|
||||
─────────────────────────▲──────────────────────────────────────────────
|
||||
low medium high xhigh max ultracode
|
||||
xhigh + workflows
|
||||
|
||||
←/→ adjust · Enter confirm · Esc cancel
|
||||
```
|
||||
|
||||
### 视觉规则
|
||||
|
||||
| 元素 | 规则 |
|
||||
|---|---|
|
||||
| `▲` 光标 | 跟随 cursor 状态移动,永远指向当前 cursor 位置 |
|
||||
| 当前生效档位(active) | 当 cursor ≠ active 时,active 档渲染为加粗 + 旁标 `(active)`;当 cursor === active 时只显示 `▲`,避免双标记 |
|
||||
| ultracode 副标签 | 固定字符串 `xhigh + workflows`,dim 色 |
|
||||
| 两极文字 `Faster` / `Smarter` | 与面板等宽左右对齐;中间用一行 `─` 填充 |
|
||||
| 底栏提示 | `←/→ adjust · Enter confirm · Esc cancel`,dim 色 |
|
||||
| 标题 `Effort` | 加粗,居中或左对齐 |
|
||||
|
||||
### 双标记渲染(cursor ≠ active)
|
||||
|
||||
env override 时会出现,例如:
|
||||
|
||||
```
|
||||
Effort
|
||||
⚠ CLAUDE_CODE_EFFORT_LEVEL=high overrides this session
|
||||
|
||||
Faster Smarter
|
||||
────────────────────────▲────────────────────────▲──────────────────────
|
||||
low medium (high) active xhigh max ultracode
|
||||
xhigh + workflows
|
||||
|
||||
←/→ adjust · Enter confirm · Esc cancel
|
||||
```
|
||||
|
||||
- `▲` 上方:cursor 位置(xhigh)
|
||||
- `(high) active`:env 锁定的真实生效档位
|
||||
|
||||
两个标记视觉上必须区分:cursor 用三角符号,active 用括号文字 + 颜色。
|
||||
|
||||
### 模型不支持 effort 时(`modelSupportsEffort(model) === false`)
|
||||
|
||||
```
|
||||
Effort
|
||||
|
||||
当前模型 <model> 不支持 effort 参数。面板已禁用。
|
||||
|
||||
Faster Smarter
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
low medium high xhigh max ultracode
|
||||
|
||||
Esc to close
|
||||
```
|
||||
|
||||
光标不显示,左右键无效,Enter 无效,只能 Esc 退出。
|
||||
|
||||
### 终端窄屏(< 60 cols)适配
|
||||
|
||||
简化策略:宽度 < 60 时退化为垂直列表,每档一行;否则保持横向 slider。这一项**不阻塞首版**,先按横向渲染,必要时溢出,后续看实际效果再调。
|
||||
|
||||
## 7. 背景波纹动画(第二阶段,单独 commit)
|
||||
|
||||
### 触发条件
|
||||
|
||||
仅在 cursor 停在 `ultracode` 时启动波纹;移开时立即停止(不淡出,干脆)。常态零干扰。
|
||||
|
||||
### 视觉概念
|
||||
|
||||
ultracode 是面板的"能量溢出口"。波纹从 ultracode 字符位置(右下区域)为震源,向左/向上辐射同心圆波,铺满整个面板的留白区域(文字字符之间的空隙、`─` 分隔线的空白段)。文字层永远清晰可读。
|
||||
|
||||
### 字符集(强度 → 字符)
|
||||
|
||||
| 强度 | 字符 |
|
||||
|---|---|
|
||||
| 0.0 | ` ` (空格) |
|
||||
| 0.1 | `·` |
|
||||
| 0.3 | `∙` |
|
||||
| 0.5 | `░` |
|
||||
| 0.7 | `▒` |
|
||||
| 0.9 | `▓` |
|
||||
| 波峰 | `~` → `◌` → `○` → `◑` → `●` 循环 |
|
||||
|
||||
### 波纹数学
|
||||
|
||||
```
|
||||
对每个字符格:
|
||||
dx = x - sourceX
|
||||
dy = (y - sourceY) * 1.5
|
||||
dist = sqrt(dx*dx + dy*dy)
|
||||
|
||||
phase = dist * 0.4 - time * 0.012
|
||||
wave = sin(phase)
|
||||
falloff = max(0, 1 - dist / 40)
|
||||
intensity = max(0, wave) * falloff
|
||||
|
||||
if (dist < 6): // 震源附近高频涟漪
|
||||
intensity = max(intensity, 0.5 + 0.5 * sin(time * 0.02 - dist * 1.2))
|
||||
|
||||
char = pick(intensity)
|
||||
```
|
||||
|
||||
参数上线后调。
|
||||
|
||||
### 渲染策略(双层不冲突)
|
||||
|
||||
Ink 不支持真正的 z-index 层叠,用**字符替换**模拟:
|
||||
|
||||
1. 每帧生成 `height × width` 字符矩阵(背景层)
|
||||
2. 渲染每个面板行时,先取该行对应的波纹字符序列,然后在文字字符应该出现的位置**覆盖**背景字符
|
||||
3. 文字字符永远胜出,波纹只占空隙
|
||||
|
||||
### 实现位置
|
||||
|
||||
新增(第二阶段):
|
||||
- `src/components/EffortPanel/rippleAnimation.ts` — `pickChar` / `computeRippleLine` / `mergeLayers` 纯函数
|
||||
- `src/components/EffortPanel/useRippleFrame.ts` — hook,内部调 `useAnimationFrame(60)` 返回当前帧矩阵
|
||||
- 在 `EffortPanel.tsx` 的 render 中叠加(仅 cursor === 'ultracode' 时启用)
|
||||
|
||||
### 性能预算
|
||||
|
||||
- 面板 80×10 = 800 格,每帧 800 次 sin/sqrt ≈ 0.05ms
|
||||
- Ink 重绘 10 行 `<Text>` 节点,与现有 Spinner 同量级
|
||||
- 帧率 16fps,`useAnimationFrame` 自带 viewport 不可见暂停 + 失焦减速
|
||||
|
||||
### 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|---|---|
|
||||
| 波纹干扰文字可读性 | 文字字符覆盖背景字符,永远胜出;波纹颜色用 `theme.textDisabled` |
|
||||
| 终端窄屏 < 60 cols | sourceX 跟随 ultracode 实际位置;窄屏时降级为单行波纹 |
|
||||
| 性能(旧机器) | `useAnimationFrame` 已自带暂停/减速 |
|
||||
| 测试稳定性 | 字符选择是纯函数,可固定 `time` 注入做帧快照测试 |
|
||||
|
||||
## 8. 数据流
|
||||
|
||||
### 状态来源
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ src/state/AppState.tsx │
|
||||
│ effortValue: EffortValue | undefined │
|
||||
└─────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ useAppState(s => s.effortValue)
|
||||
│
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ EffortPanel.tsx │
|
||||
│ props: appStateEffort, model, onDone │
|
||||
│ local: cursor: PanelPosition │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Enter 确认
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ executeEffort(cursor) │
|
||||
│ → updateSettingsForSource('userSettings', …) │
|
||||
│ → logEvent('tengu_effort_command', …) │
|
||||
│ → 返回 { message, effortUpdate? } │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│
|
||||
│ <ApplyEffortAndClose> setAppState(...)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ onDone(result.message) │
|
||||
│ → REPL 渲染 assistant 消息 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 优先级链(不修改)
|
||||
|
||||
```
|
||||
env CLAUDE_CODE_EFFORT_LEVEL > AppState.effortValue > model default
|
||||
```
|
||||
|
||||
面板只写 AppState + settings.json,不直接操作 env。env 存在时,面板可操作但顶部警告(详见 §6 双标记)。
|
||||
|
||||
## 9. 边界与错误处理
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 模型不支持 effort | 面板挂载但禁用,文字提示 + 仅允许 Esc(详见 §6) |
|
||||
| env override 设定 | 顶部加黄色警告行 `⚠ CLAUDE_CODE_EFFORT_LEVEL=<value> overrides this session`;光标可移动;Enter 仍写 settings 但顶部警告解释生效值不变 |
|
||||
| cursor === 'ultracode' 时 Enter | 走分支 B,输出引导文案,不调 executeEffort |
|
||||
| settings 写入失败(磁盘满/权限) | `executeEffort` 现有错误路径会返回 `result.error`,面板沿用,onDone 输出错误消息 |
|
||||
| 终端窄屏 < 60 cols | 退化为垂直列表,不阻塞首版 |
|
||||
| 用户按 Ctrl+C 之外的中断信号 | 视同 Esc,`onDone("Effort unchanged.")` |
|
||||
| 面板挂载后 AppState 被外部改变(如 `/model` 切换) | cursor **不订阅** active 变化,挂载时计算一次初始值后只跟随用户操作。若用户切了 model 想看新档位,关掉面板重开即可。简化实现,行为可预测 |
|
||||
|
||||
## 10. 测试计划
|
||||
|
||||
### 纯函数(`effortPanelState.test.ts`)
|
||||
|
||||
- `moveLeft(cursor)` 在 low 处保持 low
|
||||
- `moveRight(cursor)` 在 ultracode 处保持 ultracode
|
||||
- `home(cursor)` / `end(cursor)` 边界
|
||||
- `getInitialCursor(appStateEffort, envOverride, model)` 优先级
|
||||
- `isUltracode(cursor)` 守卫
|
||||
|
||||
### 组件(`EffortPanel.test.tsx`)
|
||||
|
||||
渲染:
|
||||
- 无 env 时显示基本形态
|
||||
- env override 时顶部警告 + 双标记
|
||||
- 模型不支持时禁用面板
|
||||
- ultracode 副标签 `xhigh + workflows` 出现
|
||||
|
||||
键盘:
|
||||
- `←` 移动光标、`→` 移动光标、`Home/End` 跳转
|
||||
- Enter 在普通档位 → 调用 executeEffort、onDone 收到正确 message
|
||||
- Enter 在 ultracode → 不调 executeEffort、onDone 收到引导文案
|
||||
- Esc → 不调 executeEffort、onDone 收到 `"Effort unchanged."`
|
||||
|
||||
集成(`effort.tsx` 的 call 函数):
|
||||
- 无参 → 返回 `<EffortPanel>` JSX
|
||||
- 有参 → 不渲染面板,走 executeEffort
|
||||
|
||||
### 波纹相关(第二阶段)
|
||||
|
||||
- `pickChar(intensity)` 各强度边界
|
||||
- `computeRippleLine` 固定 time 快照
|
||||
- `mergeLayers` 文字覆盖背景、文字字符永远胜出
|
||||
- `useRippleFrame` 仅在 cursor === 'ultracode' 时订阅时钟
|
||||
|
||||
## 11. 实现阶段划分(两个 commit)
|
||||
|
||||
### Commit 1:基础面板(先做)
|
||||
|
||||
- 新增 `src/components/EffortPanel/EffortPanel.tsx`
|
||||
- 新增 `src/components/EffortPanel/effortPanelState.ts`
|
||||
- 新增 `src/components/EffortPanel/__tests__/EffortPanel.test.tsx`
|
||||
- 新增 `src/components/EffortPanel/__tests__/effortPanelState.test.ts`
|
||||
- 改造 `src/commands/effort/effort.tsx`:无参时返回 `<EffortPanel>`,有参维持原状
|
||||
- 运行 `bun run precheck`,必须零错误通过
|
||||
- commit message: `feat(effort): /effort 无参时打开横向 slider 选择面板`
|
||||
|
||||
### Commit 2:波纹动画(基础稳定后再做)
|
||||
|
||||
- 新增 `src/components/EffortPanel/rippleAnimation.ts`
|
||||
- 新增 `src/components/EffortPanel/useRippleFrame.ts`
|
||||
- 新增对应测试
|
||||
- 在 `EffortPanel.tsx` 中叠加渲染(仅 cursor === 'ultracode' 时)
|
||||
- 运行 `bun run precheck`
|
||||
- commit message: `feat(effort): ultracode 档位铺满波纹背景动画`
|
||||
|
||||
两阶段切开的好处:动画是创意工作,可能在调参上反复;基础功能稳定后即使动画翻车也能直接 revert 第二个 commit,不影响主功能。
|
||||
|
||||
## 12. 验收清单
|
||||
|
||||
- [ ] `/effort` 无参打开面板,光标停在当前生效档
|
||||
- [ ] `←/→` 移动光标,到边界不再继续
|
||||
- [ ] Enter 在 5 档之一时写 settings + AppState + 输出与 `/effort X` 同款消息
|
||||
- [ ] Enter 在 ultracode 时输出引导文案,不写任何状态
|
||||
- [ ] Esc 时不写任何状态,输出 `"Effort unchanged."`
|
||||
- [ ] env override 时顶部警告 + 双标记
|
||||
- [ ] 模型不支持时面板禁用,仅 Esc 可退出
|
||||
- [ ] `/effort low|auto|help|current` 等原有路径行为不变
|
||||
- [ ] `bun run precheck` 零错误
|
||||
@@ -0,0 +1,132 @@
|
||||
# Ripgrep System Fallback — Design
|
||||
|
||||
**Date:** 2026-06-15
|
||||
**Status:** Approved (pending spec review)
|
||||
**Topic:** Make ripgrep gracefully degrade to system `rg` when the bundled/builtin binary is unavailable on the current platform (e.g. Android/Termux).
|
||||
|
||||
## Problem
|
||||
|
||||
`src/utils/ripgrep.ts` `getRipgrepConfig()` has three resolution branches:
|
||||
|
||||
1. `USE_BUILTIN_RIPGREP=0` → look up `rg` on `PATH`
|
||||
2. `isInBundledMode()` → bun-internal embedded rg
|
||||
3. Otherwise → `vendor/ripgrep/<arch>-<platform>/rg` (builtin)
|
||||
|
||||
On Android/Termux, all three fail:
|
||||
|
||||
- The user has not opted into system rg.
|
||||
- Bun does not publish Android builds, so `isInBundledMode()` is false.
|
||||
- `scripts/postinstall.cjs:81` throws `Unsupported platform: android`, so no builtin binary is ever downloaded. `vendor/ripgrep/` contains no `arm64-android` directory.
|
||||
|
||||
Net effect: spawn of a nonexistent path → `ENOENT` → user sees "ripgrep 缺失" with no recovery path other than manually setting `USE_BUILTIN_RIPGREP=0`. The discovery pipeline (`Grep`/`Glob` tools, file suggestions, hooks) all fail in the same way.
|
||||
|
||||
More generally, the same breakage occurs on any platform where the builtin binary is missing for any reason (incomplete install, custom platform, deleted vendor directory). The current code has no graceful degradation.
|
||||
|
||||
## Goals
|
||||
|
||||
- On any platform, when the builtin/bundled ripgrep is unavailable, automatically fall back to `rg` on `PATH`.
|
||||
- Surface the fallback clearly to the user via `/doctor` and a one-line startup warning, so they understand why they are not on the bundled rg and what to do if the system rg is also missing.
|
||||
- Do not change behavior on platforms where the builtin rg works (macOS, Linux, Windows).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Downloading or shipping an Android-native ripgrep binary.
|
||||
- Adding a REPL persistent status indicator.
|
||||
- Touching `USE_BUILTIN_RIPGREP` semantics for users who already opt into system rg.
|
||||
- Modifying build / `postinstall.cjs` platform mapping.
|
||||
|
||||
## Design
|
||||
|
||||
### Decision chain (`getRipgrepConfig`)
|
||||
|
||||
The function gains an existence check and a system-rg fallback. The order of existing branches is preserved.
|
||||
|
||||
```
|
||||
1. USE_BUILTIN_RIPGREP=0 (user-opt) → system rg mode='system' note=undefined
|
||||
2. isInBundledMode() → bun embedded rg mode='embedded' note=undefined
|
||||
3. Compute builtin path; existsSync(rgPath)?
|
||||
✓ true → builtin rg mode='builtin' note=undefined
|
||||
✓ false → findExecutable('rg', [])
|
||||
✓ found → system rg (auto fallback) mode='system' note='fallback: builtin rg unavailable on <platform>, using system rg'
|
||||
✗ missing → keep builtin path (let upper layer ENOENT) mode='builtin' note='no ripgrep available on <platform>; install via apt/pkg/brew/...'
|
||||
```
|
||||
|
||||
Rationale for the missing-system-rg branch returning the (nonexistent) builtin path: it preserves the historical spawn behavior so existing error-handling paths in `ripGrepRaw` and callers continue to see `ENOENT`. The new `note` field carries the human-readable explanation; the spawn itself still fails the same way.
|
||||
|
||||
`existsSync` is a single synchronous syscall; `getRipgrepConfig` is already memoized via lodash, so the cost is paid once per process.
|
||||
|
||||
### Status API (`getRipgrepStatus`)
|
||||
|
||||
```ts
|
||||
type RipgrepStatus = {
|
||||
mode: 'system' | 'builtin' | 'embedded' // unchanged
|
||||
path: string // unchanged
|
||||
working: boolean | null // unchanged
|
||||
note?: string // NEW — human-readable hint
|
||||
}
|
||||
```
|
||||
|
||||
The internal `ripgrepStatus` singleton also gains `note?: string`. `testRipgrepOnFirstUse` propagates the note from the active config.
|
||||
|
||||
The `note` value is sourced from `getRipgrepConfig()` (the source of truth), so the API remains a single read; no second lookup.
|
||||
|
||||
### UI — `/doctor`
|
||||
|
||||
`src/screens/Doctor.tsx` renders the existing `Search:` line plus the note when present. Two example outputs:
|
||||
|
||||
```
|
||||
Search: OK (system rg fallback — builtin ripgrep unavailable on android)
|
||||
Search: Not working (no ripgrep available on android — install via apt/pkg/brew)
|
||||
```
|
||||
|
||||
`src/utils/doctorDiagnostic.ts` extends the `ripgrepStatus` object it returns to include `note`.
|
||||
|
||||
### UI — startup warning
|
||||
|
||||
A single check near the end of `src/entrypoints/init.ts` reads `getRipgrepStatus()`. If `note` is set, it writes one line to stderr:
|
||||
|
||||
```
|
||||
[ripgrep] fallback: builtin rg unavailable on android, using system rg
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- Non-blocking — does not throw or exit.
|
||||
- Fires at most once per process (memoized config + idempotent init).
|
||||
- Goes to stderr so it does not corrupt pipe mode (`-p`) stdout.
|
||||
- No retry, no telemetry beyond existing `tengu_ripgrep_availability`.
|
||||
|
||||
### Testing
|
||||
|
||||
New test file `src/utils/__tests__/ripgrepDecision.test.ts` (or extend an existing one) covers the five branches:
|
||||
|
||||
1. `USE_BUILTIN_RIPGREP=0` and `rg` on PATH → `mode='system'`, `note=undefined`.
|
||||
2. `isInBundledMode()` → `mode='embedded'`, `note=undefined`.
|
||||
3. Builtin path exists → `mode='builtin'`, `note=undefined`.
|
||||
4. Builtin path missing, `rg` on PATH → `mode='system'`, `note` set.
|
||||
5. Builtin path missing, `rg` not on PATH → `mode='builtin'`, `note` set (path is the nonexistent builtin path).
|
||||
|
||||
Mocks: `existsSync` (via `fs` module), `findExecutable`, `isInBundledMode`, `process.env.USE_BUILTIN_RIPGREP`, `process.platform`. Follow the project's mock conventions (see `tests/mocks/`); no business-module mocking.
|
||||
|
||||
Existing `doctorDiagnostic` tests: extend to assert `note` is propagated; update any snapshots.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Behavior preservation on supported platforms:** the `existsSync` check only changes the path when the builtin file is genuinely absent. On macOS/Linux/Windows the builtin binary always exists post-install, so the decision chain resolves to `mode='builtin'` exactly as today. Verified by the test for branch 3.
|
||||
- **`note` field addition is backward-compatible:** optional field; existing consumers ignore it.
|
||||
- **Memoization:** `getRipgrepConfig` is memoized for the process lifetime. If a user installs ripgrep mid-session, the fallback will not trigger until restart. Acceptable — matches existing behavior for `USE_BUILTIN_RIPGREP` changes.
|
||||
- **Platform string in `note`:** uses `process.platform` directly (`'android'`, `'linux'`, `'darwin'`, `'win32'`). No translation; the message is diagnostic, not user-facing marketing copy.
|
||||
|
||||
## Out of Scope (YAGNI)
|
||||
|
||||
- Android prebuilt binary download.
|
||||
- Persistent REPL status indicator.
|
||||
- Build-time vendor changes.
|
||||
- Telemetry beyond what `testRipgrepOnFirstUse` already emits.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- On a platform where the builtin rg binary is missing and `rg` is on `PATH`, `getRipgrepStatus()` returns `mode='system'`, `path=<resolved system rg>`, `note` set to a non-empty human-readable string.
|
||||
- On a platform where neither builtin nor system rg is available, `/doctor` displays `Not working` plus the install hint.
|
||||
- The startup warning fires exactly once per session when `note` is set.
|
||||
- All existing ripgrep tests pass unchanged on macOS/Linux dev machines.
|
||||
- `bun run precheck` is green.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "2.6.10",
|
||||
"version": "2.7.0",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -20,6 +20,7 @@ export { FileEditTool } from './tools/FileEditTool/FileEditTool.js'
|
||||
export { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
|
||||
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
|
||||
export { GlobTool } from './tools/GlobTool/GlobTool.js'
|
||||
export { GoalTool } from './tools/GoalTool/GoalTool.js'
|
||||
export { GrepTool } from './tools/GrepTool/GrepTool.js'
|
||||
export { LSPTool } from './tools/LSPTool/LSPTool.js'
|
||||
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
|
||||
@@ -61,9 +62,16 @@ 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'
|
||||
export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js'
|
||||
export { getWorkflowCommands } from './tools/WorkflowTool/createWorkflowCommand.js'
|
||||
// WorkflowTool 实现已迁移到 @claude-code-best/workflow-engine(独立包,端口适配)。
|
||||
// 注意:本 commit 移除了 builtin-tools 的 WorkflowTool 值导出和 getWorkflowCommands。
|
||||
// - WorkflowTool 工厂:改由 @claude-code-best/workflow-engine 的 createWorkflowTool 提供
|
||||
// - getWorkflowCommands:已移除,功能迁至 src/workflow/namedWorkflowCommands.ts
|
||||
// 第三方若从本包 import 这两个符号,需切换到新路径。
|
||||
export {
|
||||
createWorkflowTool,
|
||||
WORKFLOW_TOOL_NAME,
|
||||
type WorkflowToolDescriptor,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
|
||||
// Constants
|
||||
export {
|
||||
|
||||
@@ -52,7 +52,6 @@ import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
import { isAutoMemFile } from 'src/utils/memoryFileDetection.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import { getCanonicalName, getMainLoopModel } from 'src/utils/model/model.js'
|
||||
import {
|
||||
mapNotebookCellsToToolResult,
|
||||
readNotebook,
|
||||
@@ -409,9 +408,7 @@ export const FileReadTool = buildTool({
|
||||
renderToolResultMessage,
|
||||
// UI.tsx:140 — ALL types render summary chrome only: "Read N lines",
|
||||
// "Read image (42KB)". Never the content itself. The model-facing
|
||||
// serialization (below) sends content + CYBER_RISK_MITIGATION_REMINDER
|
||||
// + line prefixes; UI shows none of it. Nothing to index. Caught by
|
||||
// the render-fidelity test when this initially claimed file.content.
|
||||
// serialization (below) sends content + line prefixes; UI shows none of it.
|
||||
extractSearchText() {
|
||||
return ''
|
||||
},
|
||||
@@ -694,12 +691,7 @@ export const FileReadTool = buildTool({
|
||||
let content: string
|
||||
|
||||
if (data.file.content) {
|
||||
content =
|
||||
memoryFileFreshnessPrefix(data) +
|
||||
formatFileLines(data.file) +
|
||||
(shouldIncludeFileReadMitigation()
|
||||
? CYBER_RISK_MITIGATION_REMINDER
|
||||
: '')
|
||||
content = memoryFileFreshnessPrefix(data) + formatFileLines(data.file)
|
||||
} else {
|
||||
// Determine the appropriate warning message
|
||||
content =
|
||||
@@ -727,17 +719,6 @@ function formatFileLines(file: { content: string; startLine: number }): string {
|
||||
return addLineNumbers(file)
|
||||
}
|
||||
|
||||
export const CYBER_RISK_MITIGATION_REMINDER =
|
||||
'\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n'
|
||||
|
||||
// Models where cyber risk mitigation should be skipped
|
||||
const MITIGATION_EXEMPT_MODELS = new Set(['claude-opus-4-6'])
|
||||
|
||||
function shouldIncludeFileReadMitigation(): boolean {
|
||||
const shortName = getCanonicalName(getMainLoopModel())
|
||||
return !MITIGATION_EXEMPT_MODELS.has(shortName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-channel from call() to mapToolResultToToolResultBlockParam: mtime
|
||||
* of auto-memory files, keyed by the `data` object identity. Avoids
|
||||
|
||||
253
packages/builtin-tools/src/tools/GoalTool/GoalTool.ts
Normal file
253
packages/builtin-tools/src/tools/GoalTool/GoalTool.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { z } from 'zod/v4'
|
||||
import { buildTool, type ToolDef } from 'src/Tool.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import {
|
||||
completeGoal,
|
||||
formatGoalElapsed,
|
||||
formatGoalStatusLabel,
|
||||
getGoal,
|
||||
recordBlockedAttempt,
|
||||
} from 'src/services/goal/goalState.js'
|
||||
import { persistCurrentGoal } from 'src/services/goal/goalStorage.js'
|
||||
import { GOAL_TOOL_NAME } from './constants.js'
|
||||
import { DESCRIPTION, generatePrompt } from './prompt.js'
|
||||
|
||||
function toolLog(msg: string): void {
|
||||
try {
|
||||
const { logForDebugging } =
|
||||
require('src/utils/debug.js') as typeof import('src/utils/debug.js')
|
||||
logForDebugging(`[goal] tool: ${msg}`)
|
||||
} catch {
|
||||
/* debug not available */
|
||||
}
|
||||
}
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
action: z
|
||||
.enum(['get', 'update'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Action to perform: "get" to read status, "update" to mark complete or blocked. Defaults to "update" if status is provided, otherwise "get".',
|
||||
),
|
||||
status: z
|
||||
.enum(['complete', 'blocked'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Required for "update". Only "complete" or "blocked" are accepted.',
|
||||
),
|
||||
reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Explanation for the status change. Required for "update".'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
goal: z
|
||||
.object({
|
||||
objective: z.string(),
|
||||
status: z.string(),
|
||||
tokensUsed: z.number(),
|
||||
tokenBudget: z.number().nullable(),
|
||||
elapsed: z.string(),
|
||||
turnsExecuted: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
message: z.string().optional(),
|
||||
report: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
export type Input = z.infer<InputSchema>
|
||||
export type Output = z.infer<OutputSchema>
|
||||
|
||||
function buildGoalSnapshot() {
|
||||
const goal = getGoal()
|
||||
if (!goal) return undefined
|
||||
return {
|
||||
objective: goal.objective,
|
||||
status: formatGoalStatusLabel(goal.status),
|
||||
tokensUsed: goal.tokensUsed,
|
||||
tokenBudget: goal.tokenBudget,
|
||||
elapsed: formatGoalElapsed(goal),
|
||||
turnsExecuted: goal.turnsExecuted,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompletionReport(): string {
|
||||
const goal = getGoal()
|
||||
if (!goal) return ''
|
||||
const budget =
|
||||
goal.tokenBudget !== null
|
||||
? `Token usage: ${goal.tokensUsed} / ${goal.tokenBudget}`
|
||||
: `Token usage: ${goal.tokensUsed}`
|
||||
return [
|
||||
'Goal achieved — usage report:',
|
||||
` ${budget}`,
|
||||
` Active time: ${formatGoalElapsed(goal)}`,
|
||||
` Continuation turns: ${goal.turnsExecuted}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export const GoalTool = buildTool({
|
||||
name: GOAL_TOOL_NAME,
|
||||
searchHint: 'get or update the active goal (complete/blocked)',
|
||||
maxResultSizeChars: 10_000,
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return generatePrompt()
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
userFacingName() {
|
||||
return 'Goal'
|
||||
},
|
||||
shouldDefer: true,
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
isReadOnly(input: Input) {
|
||||
const action = input.action ?? (input.status ? 'update' : 'get')
|
||||
return action === 'get'
|
||||
},
|
||||
toAutoClassifierInput(input: Input) {
|
||||
const action = input.action ?? (input.status ? 'update' : 'get')
|
||||
if (action === 'get') return 'get goal status'
|
||||
return `update goal: ${input.status} — ${input.reason ?? ''}`
|
||||
},
|
||||
async checkPermissions(input: Input) {
|
||||
return { behavior: 'allow' as const, updatedInput: input }
|
||||
},
|
||||
renderToolUseMessage(input: Input) {
|
||||
const action = input.action ?? (input.status ? 'update' : 'get')
|
||||
if (action === 'get') return 'Checking goal status…'
|
||||
return `Updating goal: ${input.status}${input.reason ? ` — ${input.reason}` : ''}`
|
||||
},
|
||||
renderToolResultMessage(output: Output) {
|
||||
if (output.error) return `Goal error: ${output.error}`
|
||||
if (output.report) return output.report
|
||||
if (output.goal) {
|
||||
return `Goal "${output.goal.objective}" — ${output.goal.status}`
|
||||
}
|
||||
return output.message ?? 'Done'
|
||||
},
|
||||
renderToolUseRejectedMessage() {
|
||||
return 'Goal operation rejected'
|
||||
},
|
||||
async call(input: Input): Promise<{ data: Output }> {
|
||||
const action = input.action ?? (input.status ? 'update' : 'get')
|
||||
toolLog(
|
||||
`called: action=${action}${input.status ? ` status=${input.status}` : ''}${input.reason ? ` reason="${input.reason.slice(0, 60)}"` : ''}`,
|
||||
)
|
||||
if (action === 'get') {
|
||||
const snapshot = buildGoalSnapshot()
|
||||
if (!snapshot) {
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
message:
|
||||
'No active goal. The user can set one with `/goal <objective>`.',
|
||||
},
|
||||
}
|
||||
}
|
||||
return { data: { success: true, goal: snapshot } }
|
||||
}
|
||||
|
||||
// action === 'update'
|
||||
if (!input.status) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
error:
|
||||
'The "status" field is required for update. Use "complete" or "blocked".',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const goal = getGoal()
|
||||
if (!goal) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
error: 'No active goal to update.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (input.status === 'complete') {
|
||||
const report = buildCompletionReport()
|
||||
completeGoal()
|
||||
persistCurrentGoal()
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
goal: buildGoalSnapshot(),
|
||||
report,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// status === 'blocked'
|
||||
const reason = input.reason ?? 'unspecified blocker'
|
||||
const result = recordBlockedAttempt(reason)
|
||||
if (!result) {
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
error: 'Goal is not in a state that accepts blocked attempts.',
|
||||
},
|
||||
}
|
||||
}
|
||||
persistCurrentGoal()
|
||||
|
||||
if (result.status === 'blocked') {
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
goal: buildGoalSnapshot(),
|
||||
message: `Goal marked as blocked after ${result.attempts} consecutive attempts. Reason: ${reason}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
goal: buildGoalSnapshot(),
|
||||
message: `Blocked attempt ${result.attempts} recorded. The goal remains active — the same condition must persist for 3 consecutive turns before it is marked blocked.`,
|
||||
},
|
||||
}
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) {
|
||||
if (content.error) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result' as const,
|
||||
content: `Error: ${content.error}`,
|
||||
is_error: true,
|
||||
}
|
||||
}
|
||||
const parts: string[] = []
|
||||
if (content.message) parts.push(content.message)
|
||||
if (content.report) parts.push(content.report)
|
||||
if (content.goal) parts.push(jsonStringify(content.goal))
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result' as const,
|
||||
content: parts.join('\n') || 'Done',
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
1
packages/builtin-tools/src/tools/GoalTool/constants.ts
Normal file
1
packages/builtin-tools/src/tools/GoalTool/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const GOAL_TOOL_NAME = 'GoalTool'
|
||||
38
packages/builtin-tools/src/tools/GoalTool/prompt.ts
Normal file
38
packages/builtin-tools/src/tools/GoalTool/prompt.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const DESCRIPTION =
|
||||
'Get or update the active goal status. The model may only mark a goal as "complete" or "blocked".'
|
||||
|
||||
export function generatePrompt(): string {
|
||||
return `Use this tool to interact with the active thread goal.
|
||||
|
||||
## Actions
|
||||
|
||||
### get
|
||||
Returns the current goal state (objective, status, token usage, elapsed time, turns executed).
|
||||
No input required beyond \`action: "get"\`.
|
||||
|
||||
### update
|
||||
Transition the goal to a terminal status. Only two values are accepted:
|
||||
- **complete** — All requirements are verified (see Completion Audit below).
|
||||
- **blocked** — An insurmountable obstacle has persisted for 3+ consecutive turns (see Blocked Audit below).
|
||||
|
||||
When marking complete, provide a brief \`reason\` summarising what was achieved.
|
||||
When marking blocked, provide a \`reason\` describing the specific blocker.
|
||||
|
||||
## Completion Audit (required before marking complete)
|
||||
1. Derive concrete requirements from the objective.
|
||||
2. Preserve the original scope — do not redefine success around existing work.
|
||||
3. For every requirement, identify authoritative evidence (test output, file content, command result).
|
||||
4. Treat tests and manifests as evidence only after confirming they cover the requirement.
|
||||
5. Treat uncertain or indirect evidence as "not achieved".
|
||||
6. The audit must PROVE completion, not merely fail to find remaining work.
|
||||
|
||||
## Blocked Audit (required before marking blocked)
|
||||
1. The same blocking condition must persist across at least 3 consecutive continuation turns.
|
||||
2. "Difficult", "slow", or "partially incomplete" is NOT blocked.
|
||||
3. Only genuinely insurmountable obstacles qualify (missing credentials, external service down, etc.).
|
||||
|
||||
## Important
|
||||
- You cannot pause, resume, or clear a goal — only the user can do that via \`/goal\`.
|
||||
- If no goal is active, \`get\` returns a message saying so; \`update\` returns an error.
|
||||
- On completion, the tool result includes a usage report (tokens, time, turns).`
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
// Bundled workflow initialization.
|
||||
// Called by tools.ts when WORKFLOW_SCRIPTS feature flag is enabled.
|
||||
// Sets up any pre-bundled workflow scripts that ship with the CLI.
|
||||
|
||||
/**
|
||||
* Initialize bundled workflows. Called once at startup when the
|
||||
* WORKFLOW_SCRIPTS feature flag is active. This is the hook point
|
||||
* for registering any workflow scripts that are compiled into the
|
||||
* binary (as opposed to user-authored ones in .claude/workflows/).
|
||||
*/
|
||||
export function initBundledWorkflows(): void {
|
||||
// Bundled workflows are registered here at startup.
|
||||
// Currently a no-op — all workflows are user-authored in .claude/workflows/.
|
||||
// This function exists as the extension point for future built-in workflows.
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
124
packages/workflow-engine/examples/registry-demo.ts
Normal file
124
packages/workflow-engine/examples/registry-demo.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* registry 多后端路由演示(mock adapter,无需 API key)。
|
||||
*
|
||||
* 两个 adapter:strong(被 researcher 路由命中)+ fast(默认)。
|
||||
* 脚本里 agent({agentType:'researcher'}) → strong,其余 → fast。
|
||||
* 证明 agent 后端可通过 AgentAdapterRegistry 插拔 + 路由,引擎不关心实现。
|
||||
*
|
||||
* 用法:bun run packages/workflow-engine/examples/registry-demo.ts
|
||||
*/
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
AgentAdapterRegistry,
|
||||
createFileJournalStore,
|
||||
createHostHandle,
|
||||
runWorkflow,
|
||||
type AgentAdapter,
|
||||
type AgentRunParams,
|
||||
type AgentRunResult,
|
||||
type WorkflowPorts,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
|
||||
const strongAdapter: AgentAdapter = {
|
||||
id: 'strong',
|
||||
capabilities: { structuredOutput: true, tools: true },
|
||||
async run(p: AgentRunParams): Promise<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()
|
||||
}
|
||||
}
|
||||
74
packages/workflow-engine/examples/research-report/README.md
Normal file
74
packages/workflow-engine/examples/research-report/README.md
Normal 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 search),research 角度即可联网。
|
||||
- **命名命令复用**:把 `research-report.workflow.mjs` 复制到项目 `.claude/workflows/research-report.mjs`,即可通过 `/research-report` 或 Workflow 工具运行(同一脚本,两种入口)。
|
||||
- **token 预算**:`runWorkflow({ budgetTotal: 200000 })` 设上限;脚本内用 `budget.remaining()` 自适应规模。
|
||||
- **resume**:同 `runId` + `resume: true` 重放 journal,已完成的 agent 不重跑。
|
||||
|
||||
## 生产就绪(诚实)
|
||||
|
||||
本 example 验证的是**库的 API 与引擎编排逻辑**,不是生产服务。要上生产还差:
|
||||
|
||||
- **真实 LLM 压测**:长 workflow、大量并发、中断/resume 的真实场景验证(mock 覆盖不到模型行为)。
|
||||
- **核心 adapter 的 v1 延期项**:`budgetTotal` 注入、skip/retry UI、worktree 隔离、StructuredOutput 完整接入(本 example 用 prompt+JSON 解析,比核心真实路径弱)。
|
||||
- **错误恢复**:journal resume 只在 mock 验证过;真实中途崩溃的重放正确性未压测。
|
||||
|
||||
引擎核心逻辑(并发 / 预算 / journal / schema)有 99.65% 覆盖的 mock 测试兜底,可作为基础继续建。
|
||||
@@ -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,
|
||||
}
|
||||
313
packages/workflow-engine/examples/research-report/run.ts
Normal file
313
packages/workflow-engine/examples/research-report/run.ts
Normal 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-runs(resume 复用)
|
||||
*/
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
createFileJournalStore,
|
||||
createHostHandle,
|
||||
runWorkflow,
|
||||
Semaphore,
|
||||
validateAgainstSchema,
|
||||
type AgentRunParams,
|
||||
type AgentRunResult,
|
||||
type ProgressEvent,
|
||||
type WorkflowPorts,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
|
||||
const SCRIPT_FILE = `${import.meta.dir}/research-report.workflow.mjs`
|
||||
const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5'
|
||||
const MAX_TOKENS = 4096
|
||||
|
||||
// 终端着色(无第三方依赖)
|
||||
const paint = {
|
||||
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
||||
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
||||
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
||||
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
||||
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
||||
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
||||
}
|
||||
|
||||
// client 由 main() 构造,llmAgent 闭包引用。null 守卫使 import 时不触发真实调用。
|
||||
const clientRef: { client: Anthropic | null } = { client: null }
|
||||
|
||||
// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3)。
|
||||
// 用 WORKFLOW_API_CONCURRENCY 调整。
|
||||
const apiSem = new Semaphore(
|
||||
Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3),
|
||||
)
|
||||
|
||||
/** 429/5xx/连接错误指数退避重试(500ms → 1s → 2s → 4s),最多 4 次。 */
|
||||
async function withRetry<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()
|
||||
}
|
||||
251
packages/workflow-engine/examples/smoke.ts
Normal file
251
packages/workflow-engine/examples/smoke.ts
Normal 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' } },
|
||||
}
|
||||
|
||||
// 最小 workflow:2 角度并行(schema 结构化)→ 综合(文本)。脚本内用 + 拼接避免 ${}。
|
||||
const SMOKE_SCRIPT =
|
||||
`
|
||||
export const meta = { name: 'smoke', description: 'minimal end-to-end smoke' }
|
||||
phase('Smoke')
|
||||
const angles = ['一句话定义', '一个最核心价值']
|
||||
const points = await parallel(
|
||||
angles.map(a => () =>
|
||||
agent('用简短一句话(30 字内)说明 workflow 编排的「' + a + '」。', {
|
||||
label: 'p:' + a,
|
||||
schema: ` +
|
||||
JSON.stringify(POINT_SCHEMA) +
|
||||
`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const clean = points.filter(Boolean)
|
||||
const joined = clean.map(p => p.point).join(';')
|
||||
const summary = await agent('把以下要点综合成一句中文结论。要点:' + joined, {
|
||||
label: 'summary',
|
||||
})
|
||||
return { points: clean, summary }
|
||||
`
|
||||
|
||||
// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3)。
|
||||
const apiSem = new Semaphore(
|
||||
Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3),
|
||||
)
|
||||
|
||||
/** 429/5xx/连接错误指数退避重试,最多 4 次。 */
|
||||
async function withRetry<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()
|
||||
}
|
||||
19
packages/workflow-engine/package.json
Normal file
19
packages/workflow-engine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
527
packages/workflow-engine/src/__tests__/WorkflowTool.test.ts
Normal file
527
packages/workflow-engine/src/__tests__/WorkflowTool.test.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, mkdir, readFile, 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 returns launch message and completes in background', 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('inline script persists to run directory, returns real scriptPath', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const { ports } = mockPorts(
|
||||
dir,
|
||||
new Map([['x', { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }]]),
|
||||
)
|
||||
const tool = createWorkflowTool(ports)
|
||||
const res = await tool.call(
|
||||
{ script: `return agent('x')` },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
const expectedPath = join(
|
||||
dir,
|
||||
'.claude',
|
||||
'workflow-runs',
|
||||
'run-x',
|
||||
'script.js',
|
||||
)
|
||||
expect(res.data.output).toContain(expectedPath)
|
||||
expect(await readFile(expectedPath, 'utf-8')).toBe(`return agent('x')`)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('missing script/name/scriptPath → returns error (does not enter background)', 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('script syntax error → returns validation error (does not enter background)', 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(/validation failed|Error/i)
|
||||
expect(runStatus.size).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('name resolves to .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 resolves to file content and runs in background', 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('script runtime failure → onFinish routes to 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('metadata methods: 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('prompt includes default concurrency 3 + AskUserQuestion guidance', async () => {
|
||||
const { ports } = mockPorts('/tmp', new Map())
|
||||
const tool = createWorkflowTool(ports)
|
||||
const p = await tool.prompt()
|
||||
expect(p).toMatch(/default is 3/i)
|
||||
expect(p).toMatch(/maxConcurrency/i)
|
||||
expect(p).toMatch(/AskUserQuestion/i)
|
||||
})
|
||||
|
||||
test('name does not exist → returns error (does not enter background)', 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 aborted → onFinish routes to 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 defensively parses when a JSON-stringified object (backward compatible with old z.string() contract)', 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)`,
|
||||
// simulate stringified JSON sent by model under old contract
|
||||
args: '{"commit":"abc123"}',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
// if args not normalized: args.commit === undefined (string has no commit property)
|
||||
// if args normalized: args.commit === 'abc123'
|
||||
expect(capturedPrompts).toContain('abc123')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('args keeps original value for non-legal JSON string without throwing', 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(
|
||||
{
|
||||
// script uses args as a string: agent(args) → agent('hello')
|
||||
script: `return agent(args)`,
|
||||
args: 'hello',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 50)
|
||||
})
|
||||
// 'hello' is not valid JSON, should be kept as a string
|
||||
expect(capturedPrompts).toContain('hello')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('scriptPath out of bounds (resolved outside cwd) → rejected with error (prevents arbitrary file read)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
|
||||
try {
|
||||
const subDir = join(dir, 'sub')
|
||||
await mkdir(subDir, { recursive: true })
|
||||
// place a script outside subDir (inside dir)
|
||||
const outsideScript = join(dir, 'outside.ts')
|
||||
await writeFile(outsideScript, `return agent('x')`)
|
||||
// host.cwd = subDir, scriptPath is an absolute path outside 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(/out of bounds|outside|not within/i)
|
||||
expect(runStatus.size).toBe(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('name contains ".." path segment → rejected (prevents path traversal escaping workflowDir)', async () => {
|
||||
const outer = await mkdtemp(join(tmpdir(), 'wf-outer-'))
|
||||
try {
|
||||
// place evil.ts at outer root (outside .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' → after join escapes the workflows directory to 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 contains path separators or is absolute → rejected', 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 is an object → complete (formatValue takes JSON branch)', 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 })
|
||||
}
|
||||
})
|
||||
156
packages/workflow-engine/src/__tests__/agentAdapter.test.ts
Normal file
156
packages/workflow-engine/src/__tests__/agentAdapter.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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',
|
||||
agentId: 1,
|
||||
}
|
||||
|
||||
test('resolve goes to default adapter, run returns result', 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 hit takes priority over 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 prefix match', () => {
|
||||
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') // no model → default
|
||||
})
|
||||
|
||||
test('route custom predicate', () => {
|
||||
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('rules match in order (first hit wins)', () => {
|
||||
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('rule-matched adapter not registered → skip that rule and continue matching', () => {
|
||||
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('no match and no default → AdapterNotFoundError', () => {
|
||||
const reg = new AgentAdapterRegistry().register(makeAdapter('a'))
|
||||
expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError)
|
||||
})
|
||||
|
||||
test('default points to an unregistered adapter → still throws (no silent fallback)', () => {
|
||||
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 triggers lifecycle (skips unimplemented)', 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') // no 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 declaration is readable', () => {
|
||||
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)
|
||||
})
|
||||
94
packages/workflow-engine/src/__tests__/agentId.test.ts
Normal file
94
packages/workflow-engine/src/__tests__/agentId.test.ts
Normal 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('concurrent agents each get a unique agentId, started/done are paired', 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 increases monotonically', 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])
|
||||
})
|
||||
29
packages/workflow-engine/src/__tests__/budget.test.ts
Normal file
29
packages/workflow-engine/src/__tests__/budget.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { Budget, BudgetExhaustedError } from '../engine/budget.js'
|
||||
|
||||
test('total=null means unlimited', () => {
|
||||
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('accumulates and throws when cap exceeded', () => {
|
||||
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 ignores negative values', () => {
|
||||
const b = new Budget(100)
|
||||
b.addOutputTokens(-50)
|
||||
expect(b.spent()).toBe(0)
|
||||
})
|
||||
119
packages/workflow-engine/src/__tests__/concurrency.test.ts
Normal file
119
packages/workflow-engine/src/__tests__/concurrency.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import {
|
||||
clampMaxConcurrency,
|
||||
Semaphore,
|
||||
maxConcurrency,
|
||||
} from '../engine/concurrency.js'
|
||||
import { DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY_CAP } from '../constants.js'
|
||||
|
||||
test('Semaphore limits concurrency, permit transfer does not leak', 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) // never exceeds permits
|
||||
})
|
||||
|
||||
test('maxConcurrency returns DEFAULT_MAX_CONCURRENCY (=3)', () => {
|
||||
expect(maxConcurrency()).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(maxConcurrency()).toBe(3)
|
||||
})
|
||||
|
||||
test('clampMaxConcurrency: undefined/NaN→DEFAULT; <1→1; >CAP→CAP; normal value kept', () => {
|
||||
expect(clampMaxConcurrency(undefined)).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(clampMaxConcurrency(Number.NaN)).toBe(DEFAULT_MAX_CONCURRENCY)
|
||||
expect(clampMaxConcurrency(0)).toBe(1)
|
||||
expect(clampMaxConcurrency(-3)).toBe(1)
|
||||
expect(clampMaxConcurrency(MAX_CONCURRENCY_CAP + 100)).toBe(
|
||||
MAX_CONCURRENCY_CAP,
|
||||
)
|
||||
expect(clampMaxConcurrency(5)).toBe(5)
|
||||
expect(clampMaxConcurrency(1)).toBe(1)
|
||||
expect(clampMaxConcurrency(MAX_CONCURRENCY_CAP)).toBe(MAX_CONCURRENCY_CAP)
|
||||
// decimal truncation (Semaphore already does Math.max(1, Math.floor); clampMaxConcurrency explicitly truncs)
|
||||
expect(clampMaxConcurrency(2.9)).toBe(2)
|
||||
})
|
||||
|
||||
test('Semaphore(0) has at least 1 permit, acquire does not block', async () => {
|
||||
const sem = new Semaphore(0)
|
||||
const release = await sem.acquire()
|
||||
expect(release).toBeTypeOf('function')
|
||||
release()
|
||||
})
|
||||
|
||||
test('Semaphore wakes up in FIFO order', 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 with an aborted signal → immediately rejects, no permit consumed', async () => {
|
||||
// Fix L: a queued waiter on abort must reject immediately instead of waiting for a permit.
|
||||
// Otherwise a cancelled agent blocks on acquire(), the permit is consumed (transferred to a dead waiter),
|
||||
// reducing actual concurrency capacity; in the worst case all waiters are cancelled while the semaphore still queues for dead waiters.
|
||||
const sem = new Semaphore(1)
|
||||
const ac = new AbortController()
|
||||
|
||||
// occupy the only permit
|
||||
const first = await sem.acquire()
|
||||
|
||||
// queued waiter
|
||||
const queued = sem.acquire(ac.signal)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
|
||||
// abort → waiter should reject immediately
|
||||
ac.abort()
|
||||
await expect(queued).rejects.toThrow()
|
||||
|
||||
// no permit leak: after releasing first, a new acquire should get it immediately (no stale waiter preemption)
|
||||
first()
|
||||
const third = await sem.acquire()
|
||||
expect(third).toBeTypeOf('function')
|
||||
third()
|
||||
})
|
||||
|
||||
test('Semaphore.acquire with an already aborted signal → synchronous reject', async () => {
|
||||
const sem = new Semaphore(1)
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
// signal already aborted, should not acquire even if a permit is available (semantics: caller already cancelled)
|
||||
// Note: current implementation checks available first and may return directly. This test locks "check abort first".
|
||||
// If the implementation chose "prefer granting when permit available", this test would change to: acquire succeeds, caller checks abort later.
|
||||
// Current implementation chose the former: aborted signal throws immediately, preventing dead agents from grabbing permits.
|
||||
await expect(sem.acquire(ac.signal)).rejects.toThrow()
|
||||
})
|
||||
139
packages/workflow-engine/src/__tests__/context.test.ts
Normal file
139
packages/workflow-engine/src/__tests__/context.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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 initializes budget and counts', () => {
|
||||
const r = createSharedResources(100)
|
||||
expect(r.budget.total).toBe(100)
|
||||
expect(r.agentCountBox.value).toBe(0)
|
||||
expect(r.depth).toBe(0)
|
||||
})
|
||||
|
||||
test('createSharedResources: maxConcurrency controls semaphore permits', async () => {
|
||||
// default permits = DEFAULT_MAX_CONCURRENCY = 3: after 4 acquires the 4th is pending
|
||||
const r1 = createSharedResources(null)
|
||||
const releases1: Array<() => void> = []
|
||||
for (let i = 0; i < 3; i++) releases1.push(await r1.semaphore.acquire())
|
||||
let fourthResolved = false
|
||||
const pending = r1.semaphore.acquire().then(r => {
|
||||
fourthResolved = true
|
||||
return r
|
||||
})
|
||||
await new Promise(res => {
|
||||
setTimeout(res, 5)
|
||||
})
|
||||
expect(fourthResolved).toBe(false)
|
||||
releases1[0]!() // release one, the fourth should be woken up
|
||||
releases1.push(await pending)
|
||||
for (const rel of releases1) rel()
|
||||
|
||||
// explicit maxConcurrency=2: the 3rd acquire is pending
|
||||
const r2 = createSharedResources(null, 2)
|
||||
const releases2: Array<() => void> = []
|
||||
releases2.push(await r2.semaphore.acquire())
|
||||
releases2.push(await r2.semaphore.acquire())
|
||||
let thirdResolved = false
|
||||
const pending2 = r2.semaphore.acquire().then(r => {
|
||||
thirdResolved = true
|
||||
return r
|
||||
})
|
||||
await new Promise(res => {
|
||||
setTimeout(res, 5)
|
||||
})
|
||||
expect(thirdResolved).toBe(false)
|
||||
releases2[0]!()
|
||||
releases2.push(await pending2)
|
||||
for (const rel of releases2) rel()
|
||||
})
|
||||
|
||||
test('createEngineContext passes maxConcurrency through to resources.semaphore', async () => {
|
||||
const ctx = createEngineContext({
|
||||
ports: mockPorts(),
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
runId: 'r-mc',
|
||||
workflowName: 'w',
|
||||
cwd: '/tmp',
|
||||
budgetTotal: null,
|
||||
maxConcurrency: 1,
|
||||
})
|
||||
// maxConcurrency=1: the second acquire should be pending
|
||||
const first = await ctx.resources.semaphore.acquire()
|
||||
let secondResolved = false
|
||||
const pending = ctx.resources.semaphore.acquire().then(r => {
|
||||
secondResolved = true
|
||||
return r
|
||||
})
|
||||
await new Promise(res => {
|
||||
setTimeout(res, 5)
|
||||
})
|
||||
expect(secondResolved).toBe(false)
|
||||
first()
|
||||
await pending
|
||||
})
|
||||
|
||||
test('createEngineContext copies journal and resets cursor', () => {
|
||||
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 collects events', () => {
|
||||
const { emitter, events } = createBufferingEmitter()
|
||||
emitter.emit({ type: 'log', runId: 'r', message: 'hi' })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('WorkflowError is recognizable', () => {
|
||||
const e = new WorkflowError('boom')
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.message).toBe('boom')
|
||||
})
|
||||
39
packages/workflow-engine/src/__tests__/errors.test.ts
Normal file
39
packages/workflow-engine/src/__tests__/errors.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { WorkflowError, WorkflowAbortedError } from '../engine/errors.js'
|
||||
|
||||
test('WorkflowError carries message and name', () => {
|
||||
const e = new WorkflowError('script error')
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.message).toBe('script error')
|
||||
expect(e.name).toBe('WorkflowError')
|
||||
})
|
||||
|
||||
test('WorkflowAbortedError is a recognizable cancellation error', () => {
|
||||
const e = new WorkflowAbortedError()
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.name).toBe('WorkflowAbortedError')
|
||||
expect(e.message).toBeTruthy()
|
||||
})
|
||||
|
||||
test('the two error types can be distinguished by instanceof (not confused)', () => {
|
||||
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('can be caught as a plain Error in a catch block', () => {
|
||||
const throwIt = (): never => {
|
||||
throw new WorkflowAbortedError()
|
||||
}
|
||||
let caught: unknown = null
|
||||
try {
|
||||
throwIt()
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Error)
|
||||
expect(caught).toBeInstanceOf(WorkflowAbortedError)
|
||||
})
|
||||
51
packages/workflow-engine/src/__tests__/events.test.ts
Normal file
51
packages/workflow-engine/src/__tests__/events.test.ts
Normal 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 collects all events in order', () => {
|
||||
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 returns void (no return value)', () => {
|
||||
const { emitter } = createBufferingEmitter()
|
||||
expect(emitter.emit(log('x'))).toBeUndefined()
|
||||
})
|
||||
|
||||
test('createBufferingEmitter instances are independent (no shared buffer)', () => {
|
||||
const a = createBufferingEmitter()
|
||||
const b = createBufferingEmitter()
|
||||
a.emitter.emit(log('1'))
|
||||
expect(a.events).toHaveLength(1)
|
||||
expect(b.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('createProgressEmitter forwards events to callback (in order, no buffering)', () => {
|
||||
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 triggers callback synchronously', () => {
|
||||
let seen = ''
|
||||
const emitter = createProgressEmitter(e => {
|
||||
seen = (e as { message: string }).message
|
||||
})
|
||||
emitter.emit(log('sync'))
|
||||
// callback already executed before emit returns
|
||||
expect(seen).toBe('sync')
|
||||
})
|
||||
614
packages/workflow-engine/src/__tests__/hooks.test.ts
Normal file
614
packages/workflow-engine/src/__tests__/hooks.test.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
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
|
||||
// taskRegistrar agent-level abort binding (agent kill bridge).
|
||||
// When provided, buildCtx injects it into ports.taskRegistrar; hooks.agent pushes the closure into adapterCtx.
|
||||
registerAgentAbort: (
|
||||
runId: string,
|
||||
agentId: number,
|
||||
ac: AbortController,
|
||||
) => void
|
||||
unregisterAgentAbort: (runId: string, agentId: number) => 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,
|
||||
...(overrides.registerAgentAbort
|
||||
? { registerAgentAbort: overrides.registerAgentAbort }
|
||||
: {}),
|
||||
...(overrides.unregisterAgentAbort
|
||||
? { unregisterAgentAbort: overrides.unregisterAgentAbort }
|
||||
: {}),
|
||||
},
|
||||
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 returns text result and counts', 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 and not counted', 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()
|
||||
})
|
||||
|
||||
// Retry: dead or non-abort throw both get one retry chance; WorkflowAbortedError (kill) is not retried.
|
||||
// Retry still fails: dead stays dead; throw degrades to dead (does not break the workflow, hooks.agent returns null).
|
||||
test('agent dead → retry once succeeds → ok', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
calls++
|
||||
return calls === 1
|
||||
? { kind: 'dead' as const }
|
||||
: {
|
||||
kind: 'ok' as const,
|
||||
output: 'recovered',
|
||||
usage: { outputTokens: 5 },
|
||||
}
|
||||
},
|
||||
})
|
||||
expect(await hooks.agent('p')).toBe('recovered')
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('agent dead → retry still dead → final null (dead stays dead)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
calls++
|
||||
return { kind: 'dead' as const }
|
||||
},
|
||||
loggerWarn: () => {},
|
||||
})
|
||||
expect(await hooks.agent('p')).toBeNull()
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('agent non-abort throw → retry once succeeds → ok', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
calls++
|
||||
if (calls === 1) throw new Error('transient network')
|
||||
return {
|
||||
kind: 'ok' as const,
|
||||
output: 'recovered',
|
||||
usage: { outputTokens: 3 },
|
||||
}
|
||||
},
|
||||
loggerWarn: () => {},
|
||||
})
|
||||
expect(await hooks.agent('p')).toBe('recovered')
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('agent non-abort throw → retry still throws → degrade to dead (returns null, does not break workflow)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
calls++
|
||||
throw new Error('persistent')
|
||||
},
|
||||
loggerWarn: () => {},
|
||||
})
|
||||
expect(await hooks.agent('p')).toBeNull()
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test('agent throw WorkflowAbortedError → no retry, rethrow directly (kill does not allow retry)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
calls++
|
||||
throw new WorkflowAbortedError()
|
||||
},
|
||||
})
|
||||
await expect(hooks.agent('p')).rejects.toBeInstanceOf(WorkflowAbortedError)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('agent ok → no retry (calls=1, saves a backend round-trip)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
calls++
|
||||
return {
|
||||
kind: 'ok' as const,
|
||||
output: 'first-try',
|
||||
usage: { outputTokens: 1 },
|
||||
}
|
||||
},
|
||||
})
|
||||
expect(await hooks.agent('p')).toBe('first-try')
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('agent skipped → no retry (user actively skips, no retry)', async () => {
|
||||
let calls = 0
|
||||
const { hooks } = buildCtx({
|
||||
runner: async () => {
|
||||
calls++
|
||||
return { kind: 'skipped' as const }
|
||||
},
|
||||
})
|
||||
expect(await hooks.agent('p')).toBeNull()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('agent journal hit does not call 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 exceeding total cap throws', async () => {
|
||||
const { hooks, ctx } = buildCtx()
|
||||
ctx.resources.agentCountBox.value = 1000
|
||||
await expect(hooks.agent('hi')).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('parallel single item throws → null, others kept', 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 single item throws → logger.warn records the failure reason', 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 chains stage by stage, stage throws → 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 throws → logger.warn records the failure reason', 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 over 4096 throws', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
await expect(
|
||||
hooks.pipeline(Array(4097), () => Promise.resolve(1)),
|
||||
).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('phase switch emits phase_started/done; log emits 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,
|
||||
)
|
||||
})
|
||||
|
||||
// ---- boundary and error paths ----
|
||||
|
||||
test('agent dead also counts in 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, does not call runner, not counted', 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 diverges → invalidate and 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 throws when budget exhausted', 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 budget check inside semaphore critical section (queued waiter sees latest spent)', async () => {
|
||||
// When semaphore capacity < parallel agent count, some agents will queue.
|
||||
// Old bug: assertCanSpend was before acquire, all waiters entered the queue with spent=0 and passed the check;
|
||||
// after permits released waiters ran the runner and deducted the budget without re-checking → all over-spent.
|
||||
// Fix: assertCanSpend moved into the critical section; waiters check spent after being woken before deciding to run.
|
||||
// Force capacity=1 (serializing semaphore) to ensure N>1 agents must queue.
|
||||
const { hooks, ctx } = buildCtx({
|
||||
budgetTotal: 10,
|
||||
runner: async () => {
|
||||
// make the runner a bit slow to ensure waiters truly queue
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 5)
|
||||
})
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 6 }, // 6 tokens each, 2 runs exceed 10
|
||||
}
|
||||
},
|
||||
})
|
||||
// replace the default semaphore with a single-permit one, forcing serialization
|
||||
ctx.resources.semaphore = new Semaphore(1)
|
||||
const results = await hooks.parallel([
|
||||
() => hooks.agent('a'),
|
||||
() => hooks.agent('b'),
|
||||
() => hooks.agent('c'),
|
||||
() => hooks.agent('d'),
|
||||
])
|
||||
// at least 1 agent is caught as null by parallel (assertCanSpend throws)
|
||||
expect(results.some(r => r === null)).toBe(true)
|
||||
// not all 4 should run and spend 24; the cap is at-most-one-over (first two spend 12, last two blocked)
|
||||
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 over 4096 items throws', async () => {
|
||||
const { hooks } = buildCtx()
|
||||
await expect(
|
||||
hooks.parallel(Array.from({ length: 4097 }, () => async () => 1)),
|
||||
).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('workflow() nesting beyond one level throws', async () => {
|
||||
const { hooks, ctx } = buildCtx()
|
||||
ctx.resources.depth = 1
|
||||
await expect(hooks.workflow('child')).rejects.toThrow(WorkflowError)
|
||||
})
|
||||
|
||||
test('agent concurrency bounded by semaphore (does not exceed 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 takes priority over agentRunner (dispatched to adapter by route)', 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 throws → agent rethrows (workflow failed)', async () => {
|
||||
const registry = new AgentAdapterRegistry().default('missing') // not registered
|
||||
const { hooks } = buildCtx({
|
||||
agentAdapterRegistry: registry,
|
||||
runner: async () => ({
|
||||
kind: 'ok',
|
||||
output: 'x',
|
||||
usage: { outputTokens: 1 },
|
||||
}),
|
||||
})
|
||||
await expect(hooks.agent('x')).rejects.toThrow()
|
||||
})
|
||||
|
||||
// service.kill(runId, agentId) bridge: hooks.agent must inject taskRegistrar's
|
||||
// registerAgentAbort/unregisterAgentAbort into adapterCtx (bound to the current runId).
|
||||
// The backend puts the agentAbort controller into a Map based on this; service.kill aborts precisely by agentId.
|
||||
test('agentAdapter ctx injects registerAgentAbort/unregisterAgentAbort (bound to runId, forwards to taskRegistrar)', async () => {
|
||||
const registered: Array<{
|
||||
runId: string
|
||||
agentId: number
|
||||
controller: AbortController
|
||||
}> = []
|
||||
const unregistered: Array<{ runId: string; agentId: number }> = []
|
||||
// capture the ctx hooks pass to the adapter (verify register/unregister are injected and bound to runId)
|
||||
let capturedCtx: {
|
||||
registerAgentAbort?: (id: number, ac: AbortController) => void
|
||||
unregisterAgentAbort?: (id: number) => void
|
||||
agentId: number
|
||||
runId: string
|
||||
} | null = null
|
||||
const registry = new AgentAdapterRegistry()
|
||||
.register({
|
||||
id: 'ad',
|
||||
capabilities: { structuredOutput: true },
|
||||
async run(_params, ctx) {
|
||||
capturedCtx = ctx
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
})
|
||||
.default('ad')
|
||||
const { hooks } = buildCtx({
|
||||
agentAdapterRegistry: registry,
|
||||
registerAgentAbort: (runId, agentId, controller) =>
|
||||
registered.push({ runId, agentId, controller }),
|
||||
unregisterAgentAbort: (runId, agentId) =>
|
||||
unregistered.push({ runId, agentId }),
|
||||
})
|
||||
await hooks.agent('x')
|
||||
// ctx contains register/unregister (closure bound to runId='r1')
|
||||
expect(capturedCtx).not.toBeNull()
|
||||
expect(typeof capturedCtx!.registerAgentAbort).toBe('function')
|
||||
expect(typeof capturedCtx!.unregisterAgentAbort).toBe('function')
|
||||
// simulate backend call: the injected closure forwards (agentId, controller) to taskRegistrar,
|
||||
// and auto-fills runId='r1' (backend does not need to know runId)
|
||||
const ac = new AbortController()
|
||||
capturedCtx!.registerAgentAbort!(7, ac)
|
||||
capturedCtx!.unregisterAgentAbort!(7)
|
||||
expect(registered).toEqual([{ runId: 'r1', agentId: 7, controller: ac }])
|
||||
expect(unregistered).toEqual([{ runId: 'r1', agentId: 7 }])
|
||||
})
|
||||
|
||||
test('taskRegistrar does not provide registerAgentAbort → adapterCtx also lacks it (hooks do not error)', async () => {
|
||||
// without registerAgentAbort/unregisterAgentAbort overrides → buildCtx does not inject taskRegistrar either
|
||||
// hooks skip via optional chaining; adapterCtx lacks these two fields
|
||||
let capturedCtx: object | null = null
|
||||
const registry = new AgentAdapterRegistry()
|
||||
.register({
|
||||
id: 'ad',
|
||||
capabilities: { structuredOutput: true },
|
||||
async run(_params, ctx) {
|
||||
capturedCtx = ctx
|
||||
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
|
||||
},
|
||||
})
|
||||
.default('ad')
|
||||
const { hooks } = buildCtx({ agentAdapterRegistry: registry })
|
||||
await hooks.agent('x')
|
||||
expect(capturedCtx).not.toBeNull()
|
||||
expect(
|
||||
(capturedCtx! as Record<string, unknown>).registerAgentAbort,
|
||||
).toBeUndefined()
|
||||
})
|
||||
89
packages/workflow-engine/src/__tests__/index.test.ts
Normal file
89
packages/workflow-engine/src/__tests__/index.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import * as wf from '../index.js'
|
||||
|
||||
test('engine core API fully exported', () => {
|
||||
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('ports / host API fully exported', () => {
|
||||
expect(typeof wf.createHostHandle).toBe('function')
|
||||
expect(typeof wf.isHostHandle).toBe('function')
|
||||
expect(typeof wf.unwrapHostHandle).toBe('function')
|
||||
})
|
||||
|
||||
test('persistence / structured output / named workflow / progress API fully exported', () => {
|
||||
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('concurrency / budget / error classes fully exported', () => {
|
||||
expect(typeof wf.Semaphore).toBe('function')
|
||||
expect(typeof wf.maxConcurrency).toBe('function')
|
||||
expect(typeof wf.clampMaxConcurrency).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('tool descriptor and input schema exported', () => {
|
||||
expect(typeof wf.createWorkflowTool).toBe('function')
|
||||
expect(typeof wf.workflowInputSchema).toBe('object')
|
||||
expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow')
|
||||
})
|
||||
|
||||
test('engine constant values are stable', () => {
|
||||
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.DEFAULT_MAX_CONCURRENCY).toBe(3)
|
||||
expect(wf.WORKFLOW_SCRIPT_EXTENSIONS).toEqual(['.ts', '.js', '.mjs'])
|
||||
})
|
||||
|
||||
test('createWorkflowTool returns complete descriptor shape', () => {
|
||||
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')
|
||||
})
|
||||
282
packages/workflow-engine/src/__tests__/integration.test.ts
Normal file
282
packages/workflow-engine/src/__tests__/integration.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Integration test: runs the canonical workflow script (canonical pattern from the Workflow tool definition:
|
||||
* pipeline without barrier + parallel barrier + agent(schema) + phase) with a faithful mock adapter.
|
||||
* Verifies the engine is semantically compatible with real workflow scripts.
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
|
||||
// canonical review pattern (pipeline→parallel→verify→synthesize), verbatim from the Workflow tool definition.
|
||||
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 script end-to-end compatibility', 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 dimensions × 1 finding, all isReal=true → confirmed=2, total=2
|
||||
expect(ret.total).toBe(2)
|
||||
expect(ret.confirmed).toHaveLength(2)
|
||||
// 2 review agents + 2 verify agents = 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,
|
||||
)
|
||||
// progress events: 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)
|
||||
// script explicitly calls phase('Review') once; the verify agent's phase:'Verify' is a display label, does not emit 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 pattern: two consecutive rounds with no new findings converges', 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++
|
||||
// rounds 1-2 return findings, round 3+ returns empty → converges
|
||||
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 }[] }
|
||||
// round1 finds {b:1}, round2 finds {b:2} (fresh, since seen=[1]), round3 found{b:3}?
|
||||
// mock counts by round: round1→{b:1}, round2→{b:2}, round3→[] (found empty)
|
||||
// but 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 → exits
|
||||
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 compatibility: second run hits journal, agents do not re-run', 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 }
|
||||
`
|
||||
// first run: 2 agents run live
|
||||
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 same runId: journal hit, no re-run
|
||||
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 })
|
||||
}
|
||||
})
|
||||
113
packages/workflow-engine/src/__tests__/journal.test.ts
Normal file
113
packages/workflow-engine/src/__tests__/journal.test.ts
Normal 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 stable for same prompt+params', () => {
|
||||
expect(agentCallKey('p', base)).toBe(agentCallKey('p', base))
|
||||
})
|
||||
|
||||
test('agentCallKey varies with prompt', () => {
|
||||
expect(agentCallKey('p1', base)).not.toBe(agentCallKey('p2', base))
|
||||
})
|
||||
|
||||
test('agentCallKey ignores display-only fields 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 preserves order, truncate clears', 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 sorts by seq — resume stable when parallel completion order ≠ call order', async () => {
|
||||
// Concurrent completion order is non-deterministic: append-to-disk = completion order; on resume, key matching uses call order.
|
||||
// Without seq sorting → different runs have different key orders → nearly all keys mismatch →
|
||||
// everything re-runs, journal becomes useless. Fix: read() re-orders by ascending seq before returning.
|
||||
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 varies with 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 varies with model', () => {
|
||||
expect(agentCallKey('p', { prompt: 'p', model: 'sonnet' })).not.toBe(
|
||||
agentCallKey('p', { prompt: 'p', model: 'opus' }),
|
||||
)
|
||||
})
|
||||
|
||||
test('agentCallKey stable across params field order (canonical sort)', () => {
|
||||
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 for non-existent 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 })
|
||||
}
|
||||
})
|
||||
@@ -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('resolves named workflow by extension priority', 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']) // excludes .md
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listNamedWorkflows returns empty array for non-existent directory', async () => {
|
||||
expect(
|
||||
await listNamedWorkflows(join(tmpdir(), 'wf-nope-' + Date.now())),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('resolveNamedWorkflow falls back to .js/.mjs when .ts is missing', 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 returns sorted names', 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 })
|
||||
}
|
||||
})
|
||||
56
packages/workflow-engine/src/__tests__/paths.test.ts
Normal file
56
packages/workflow-engine/src/__tests__/paths.test.ts
Normal 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 equals base → true', () => {
|
||||
const base = join(tmpdir(), 'a')
|
||||
expect(containsPath(base, base)).toBe(true)
|
||||
})
|
||||
|
||||
test('containsPath: target inside base → true', () => {
|
||||
const base = join(tmpdir(), 'a')
|
||||
const target = join(base, 'b', 'c.ts')
|
||||
expect(containsPath(base, target)).toBe(true)
|
||||
})
|
||||
|
||||
test('containsPath: target outside base (prefix false positive) → false', () => {
|
||||
// /tmp/foobar should not be considered a subpath of /tmp/foo
|
||||
const base = join(tmpdir(), 'foo')
|
||||
const target = join(tmpdir(), 'foobar', 'x.ts')
|
||||
expect(containsPath(base, target)).toBe(false)
|
||||
})
|
||||
|
||||
test('containsPath: target using .. out of bounds → false', () => {
|
||||
const base = join(tmpdir(), 'a', 'b')
|
||||
const target = join(base, '..', 'outside.ts')
|
||||
expect(containsPath(base, target)).toBe(false)
|
||||
})
|
||||
|
||||
test('containsPath: relative target resolved against base', () => {
|
||||
const base = join(tmpdir(), 'a')
|
||||
expect(containsPath(base, 'sub/file.ts')).toBe(true)
|
||||
expect(containsPath(base, '../b/file.ts')).toBe(false)
|
||||
})
|
||||
|
||||
test('sanitizeWorkflowName: valid identifier → original value', () => {
|
||||
expect(sanitizeWorkflowName('release')).toBe('release')
|
||||
expect(sanitizeWorkflowName('my-workflow')).toBe('my-workflow')
|
||||
expect(sanitizeWorkflowName('my_workflow_2')).toBe('my_workflow_2')
|
||||
})
|
||||
|
||||
test('sanitizeWorkflowName: contains path separators → null', () => {
|
||||
expect(sanitizeWorkflowName('foo/bar')).toBeNull()
|
||||
expect(sanitizeWorkflowName('foo\\bar')).toBeNull()
|
||||
expect(sanitizeWorkflowName('/abs/path')).toBeNull()
|
||||
})
|
||||
|
||||
test('sanitizeWorkflowName: . / .. / empty → null', () => {
|
||||
expect(sanitizeWorkflowName('.')).toBeNull()
|
||||
expect(sanitizeWorkflowName('..')).toBeNull()
|
||||
expect(sanitizeWorkflowName('')).toBeNull()
|
||||
})
|
||||
|
||||
test('sanitizeWorkflowName: contains null byte → null', () => {
|
||||
expect(sanitizeWorkflowName('evil\0.ts')).toBeNull()
|
||||
})
|
||||
41
packages/workflow-engine/src/__tests__/persistInline.test.ts
Normal file
41
packages/workflow-engine/src/__tests__/persistInline.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { persistInlineScript } from '../tool/persistInline.js'
|
||||
|
||||
test('persists to <cwd>/.claude/workflow-runs/<runId>/script.js and returns path', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
|
||||
try {
|
||||
const path = await persistInlineScript('return 1', 'r1', dir)
|
||||
expect(path).toBe(join(dir, '.claude', 'workflow-runs', 'r1', 'script.js'))
|
||||
expect(await readFile(path, 'utf-8')).toBe('return 1')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('same runId repeated writes overwrite (mkdir idempotent, no error)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
|
||||
try {
|
||||
await persistInlineScript('first', 'r2', dir)
|
||||
const path = await persistInlineScript('second', 'r2', dir)
|
||||
expect(await readFile(path, 'utf-8')).toBe('second')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('different runId do not interfere (independent subdirectories)', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
|
||||
try {
|
||||
const p1 = await persistInlineScript('a', 'run-a', dir)
|
||||
const p2 = await persistInlineScript('b', 'run-b', dir)
|
||||
expect(p1).not.toBe(p2)
|
||||
expect(await readFile(p1, 'utf-8')).toBe('a')
|
||||
expect(await readFile(p2, 'utf-8')).toBe('b')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
61
packages/workflow-engine/src/__tests__/ports.test.ts
Normal file
61
packages/workflow-engine/src/__tests__/ports.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { createHostHandle, isHostHandle, unwrapHostHandle } from '../ports.js'
|
||||
|
||||
test('createHostHandle wraps any bundle and is opaque externally', () => {
|
||||
const bundle = { secret: 'ctx', nested: { a: 1 } }
|
||||
const handle = createHostHandle(bundle)
|
||||
expect(isHostHandle(handle)).toBe(true)
|
||||
// bundle is not exposed externally — handle only has a symbol marker
|
||||
expect(Object.keys(handle)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('plain object is not a HostHandle', () => {
|
||||
expect(isHostHandle({} as unknown)).toBe(false)
|
||||
expect(isHostHandle(null)).toBe(false)
|
||||
})
|
||||
|
||||
test('ports object satisfies the minimal shape', () => {
|
||||
// compile-time shape validation: the assignment below passing means the ports contract is self-consistent
|
||||
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 retrieves the original bundle (same reference)', () => {
|
||||
const bundle = { secret: 'ctx', nested: { a: 1 } }
|
||||
const handle = createHostHandle(bundle)
|
||||
expect(unwrapHostHandle(handle)).toBe(bundle)
|
||||
})
|
||||
|
||||
test('createHostHandle(null) is opaque and unwraps to null', () => {
|
||||
const handle = createHostHandle(null)
|
||||
expect(isHostHandle(handle)).toBe(true)
|
||||
expect(unwrapHostHandle(handle)).toBeNull()
|
||||
})
|
||||
568
packages/workflow-engine/src/__tests__/runWorkflow.test.ts
Normal file
568
packages/workflow-engine/src/__tests__/runWorkflow.test.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
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('end-to-end: script returns agent result, status 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('script syntax error → failed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
const ports = portsWith(dir, new Map())
|
||||
const result = await runWorkflow({
|
||||
script: `export const meta = { name: 't', description: 'd' }\nreturn ((`,
|
||||
runId: 'run-2',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toBeTruthy()
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resume: journal hit skips runner call', 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() nesting (one level) shares counts', 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 })
|
||||
}
|
||||
})
|
||||
|
||||
// ---- boundary and events ----
|
||||
|
||||
test('scriptChanged=true → truncate journal and run all live', 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 cleared the old cached journal, live agent appends a new entry
|
||||
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('script runtime throw (non-syntax error) → 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('emits run_started (with workflowName) and run_done events', 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 })
|
||||
}
|
||||
})
|
||||
|
||||
// Emit phase_done for currentPhase before terminal state: hook.phase only emits the previous phase's done on switch,
|
||||
// the last phase has no subsequent switch → the UI left panel would show running forever. Verify all three paths re-emit.
|
||||
test('re-emit phase_done for currentPhase before terminal state (completed path)', 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: `phase('Review')\nreturn agent('x')`,
|
||||
runId: 'run-phase-done',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
// Both phase_started and phase_done for Review should be present (done from re-emit before terminal)
|
||||
expect(
|
||||
events.some(e => e.type === 'phase_started' && e.phase === 'Review'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
events.some(e => e.type === 'phase_done' && e.phase === 'Review'),
|
||||
).toBe(true)
|
||||
// Order: phase_done must precede run_done (reducer is order-independent, but the event stream is clearer this way)
|
||||
const lastPhaseDone = Math.max(
|
||||
0,
|
||||
...events.map((e, i) => (e.type === 'phase_done' ? i : -1)),
|
||||
)
|
||||
const runDoneIdx = events.findIndex(e => e.type === 'run_done')
|
||||
expect(runDoneIdx).toBeGreaterThan(0)
|
||||
expect(lastPhaseDone).toBeLessThan(runDoneIdx)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('re-emit phase_done for currentPhase before terminal state (killed path)', 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 } }]]),
|
||||
)
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
await runWorkflow({
|
||||
script: `phase('Run')\nreturn agent('x')`,
|
||||
runId: 'run-kill-phase',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: ac.signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
expect(events.some(e => e.type === 'phase_done' && e.phase === 'Run')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
events.some(e => e.type === 'run_done' && e.status === 'killed'),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('no phase() call → terminal does not re-emit phase_done (currentPhase is null)', 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-no-phase',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
})
|
||||
// No phase() → currentPhase is null → terminal does not re-emit phase_done
|
||||
expect(events.some(e => e.type === 'phase_done')).toBe(false)
|
||||
expect(events.some(e => e.type === 'phase_started')).toBe(false)
|
||||
expect(
|
||||
events.some(e => e.type === 'run_done' && e.status === 'completed'),
|
||||
).toBe(true)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('derives workflowName from meta.name when not passed', 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 exhausted → 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('maxConcurrency passthrough: parallel agents bounded by run-level concurrency slots', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
|
||||
try {
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const ports: WorkflowPorts = {
|
||||
agentRunner: {
|
||||
runAgentToResult: async () => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, 8)
|
||||
})
|
||||
active--
|
||||
return { kind: 'ok', output: 'x', 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 result = await runWorkflow({
|
||||
script: `return parallel(Array.from({length: 8}, () => () => agent('p')))`,
|
||||
runId: 'run-mc',
|
||||
ports,
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: dir,
|
||||
budgetTotal: null,
|
||||
maxConcurrency: 2,
|
||||
})
|
||||
expect(result.status).toBe('completed')
|
||||
expect(peak).toBeLessThanOrEqual(2)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() references a syntactically broken sub-script → 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(/Sub-workflow|script error/i)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('workflow() references a non-existent 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(/Sub-workflow|not found/i)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
62
packages/workflow-engine/src/__tests__/schema.test.ts
Normal file
62
packages/workflow-engine/src/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { workflowInputSchema } from '../tool/schema.js'
|
||||
|
||||
test('empty object passes (all fields optional)', () => {
|
||||
expect(workflowInputSchema.safeParse({}).success).toBe(true)
|
||||
})
|
||||
|
||||
test('all known fields can be filled', () => {
|
||||
const r = workflowInputSchema.safeParse({
|
||||
script: 'return 1',
|
||||
name: 'release',
|
||||
scriptPath: '/abs/x.ts',
|
||||
args: { n: 1 },
|
||||
resumeFromRunId: 'run-1',
|
||||
description: 'do thing',
|
||||
title: 'T',
|
||||
maxConcurrency: 3,
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
test('args accepts any JSON value (object/array/string/number/boolean/null)', () => {
|
||||
for (const args of [{ a: 1 }, [1, 2], 's', 42, true, null]) {
|
||||
expect(workflowInputSchema.safeParse({ args }).success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('type errors rejected (script/name/scriptPath not strings)', () => {
|
||||
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 must be strings', () => {
|
||||
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('unknown fields are stripped (zod default non-strict, safeParse succeeds)', () => {
|
||||
const r = workflowInputSchema.safeParse({ script: 'x', extra: 1 })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
test('maxConcurrency: integers 1-16 valid; 0/17/decimal/non-number rejected', () => {
|
||||
for (const n of [1, 3, 5, 16]) {
|
||||
expect(workflowInputSchema.safeParse({ maxConcurrency: n }).success).toBe(
|
||||
true,
|
||||
)
|
||||
}
|
||||
for (const bad of [0, -1, 17, 100, 1.5, '3', NaN]) {
|
||||
expect(workflowInputSchema.safeParse({ maxConcurrency: bad }).success).toBe(
|
||||
false,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('maxConcurrency optional (safeParse succeeds when omitted)', () => {
|
||||
expect(workflowInputSchema.safeParse({ script: 'x' }).success).toBe(true)
|
||||
})
|
||||
168
packages/workflow-engine/src/__tests__/script.test.ts
Normal file
168
packages/workflow-engine/src/__tests__/script.test.ts
Normal 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 extracts plain literals and strips the statement', () => {
|
||||
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 returns null when no meta and body unchanged', () => {
|
||||
const src = `return 42`
|
||||
const { meta, body } = extractMeta(src)
|
||||
expect(meta).toBeNull()
|
||||
expect(body).toBe(src)
|
||||
})
|
||||
|
||||
test('extractMeta rejects non-plain literals (variable references)', () => {
|
||||
const src = `const x = 1\nexport const meta = { name: 'x', description: y }\nreturn 1`
|
||||
expect(() => extractMeta(src)).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('parseScript executes top-level return of body', async () => {
|
||||
const { execute } = parseScript(`return args.n + 1`)
|
||||
const out = await execute(stubHooks, { n: 41 }, { total: null })
|
||||
expect(out).toBe(42)
|
||||
})
|
||||
|
||||
test('Date.now() in script throws non-determinism error', async () => {
|
||||
const { execute } = parseScript(`return Date.now()`)
|
||||
await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow(
|
||||
/Date\.now/,
|
||||
)
|
||||
})
|
||||
|
||||
test('Math.random() in script throws non-determinism error', async () => {
|
||||
const { execute } = parseScript(`return Math.random()`)
|
||||
await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow(
|
||||
/Math\.random/,
|
||||
)
|
||||
})
|
||||
|
||||
test('no-arg new Date() throws, but new Date(arg) is allowed', 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 validation error branches and nesting ----
|
||||
|
||||
test('extractMeta meta is array → ScriptError', () => {
|
||||
expect(() => extractMeta('export const meta = [1, 2]\nreturn 1')).toThrow(
|
||||
ScriptError,
|
||||
)
|
||||
})
|
||||
|
||||
test('extractMeta meta missing name → ScriptError', () => {
|
||||
expect(() =>
|
||||
extractMeta('export const meta = { description: "d" }\nreturn 1'),
|
||||
).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('extractMeta meta missing description → ScriptError', () => {
|
||||
expect(() =>
|
||||
extractMeta('export const meta = { name: "n" }\nreturn 1'),
|
||||
).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('extractMeta meta unclosed braces → ScriptError', () => {
|
||||
expect(() =>
|
||||
extractMeta('export const meta = { name: "n", description: "d"\nreturn 1'),
|
||||
).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('extractMeta supports nested objects (phases array)', () => {
|
||||
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 syntax error → ScriptError', () => {
|
||||
expect(() => parseScript('return ((')).toThrow(ScriptError)
|
||||
})
|
||||
|
||||
test('parseScript detects import → guided ScriptError (not a generic syntax error)', () => {
|
||||
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 is not supported/)
|
||||
})
|
||||
|
||||
test('parseScript detects extra export beyond meta → guided 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(/allow only one export const meta/)
|
||||
})
|
||||
|
||||
test('parseScript does not misfire on normal plain JS scripts (no import / no extra 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 detects dynamic import(...) → guided ScriptError (sandbox anti-escape)', () => {
|
||||
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 does not misfire when a line contains the import string literal (e.g. prompt contains "import")', () => {
|
||||
// import inside a string should not be caught by the static regex — prompt may contain the word "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')
|
||||
})
|
||||
@@ -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('valid object passes', () => {
|
||||
const { valid, errors } = validateAgainstSchema(
|
||||
{ name: 'a', count: 1 },
|
||||
schema,
|
||||
)
|
||||
expect(valid).toBe(true)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('missing field fails', () => {
|
||||
const { valid, errors } = validateAgainstSchema({ name: 'a' }, schema)
|
||||
expect(valid).toBe(false)
|
||||
expect(errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('type error fails', () => {
|
||||
const { valid } = validateAgainstSchema({ name: 'a', count: 'x' }, schema)
|
||||
expect(valid).toBe(false)
|
||||
})
|
||||
|
||||
test('same schema reuses cache', () => {
|
||||
validateAgainstSchema({ name: 'a', count: 1 }, schema)
|
||||
// second use of the same schema object should hit cache (not throwing is enough)
|
||||
expect(validateAgainstSchema({ name: 'b', count: 2 }, schema).valid).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
52
packages/workflow-engine/src/__tests__/types.test.ts
Normal file
52
packages/workflow-engine/src/__tests__/types.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
// Directly construct type shapes to verify JSON round-trip (core requirement for resume persistence).
|
||||
test('AgentRunResult ok branch can JSON round-trip', () => {
|
||||
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 branch can JSON round-trip', () => {
|
||||
for (const kind of ['skipped', 'dead'] as const) {
|
||||
const round = JSON.parse(JSON.stringify({ kind }))
|
||||
expect(round.kind).toBe(kind)
|
||||
}
|
||||
})
|
||||
|
||||
// dead carries optional reason/detail: journal persistence preserves cause of death for post-hoc audit / panel display.
|
||||
test('AgentRunResult dead with reason/detail can JSON round-trip', () => {
|
||||
const dead = {
|
||||
kind: 'dead' as const,
|
||||
reason: 'no-structured-output' as const,
|
||||
detail: 'finalize content has no StructuredOutput tool_use or JSON text',
|
||||
}
|
||||
const round = JSON.parse(JSON.stringify(dead))
|
||||
expect(round).toEqual(dead)
|
||||
expect(round.kind).toBe('dead')
|
||||
expect(round.reason).toBe('no-structured-output')
|
||||
})
|
||||
|
||||
// Backward compatible with old journals: reason/detail both optional, missing is still valid dead.
|
||||
test('AgentRunResult dead without reason is still valid (backward compatible with old journal)', () => {
|
||||
const legacy = { kind: 'dead' as const }
|
||||
const round = JSON.parse(JSON.stringify(legacy))
|
||||
expect(round.kind).toBe('dead')
|
||||
expect(round.reason).toBeUndefined()
|
||||
expect(round.detail).toBeUndefined()
|
||||
})
|
||||
|
||||
test('JournalEntry shape is stable', () => {
|
||||
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')
|
||||
})
|
||||
165
packages/workflow-engine/src/agentAdapter.ts
Normal file
165
packages/workflow-engine/src/agentAdapter.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// Agent backend adapter abstraction. The engine takes an adapter from the registry via resolve then calls run; it does not care about the concrete implementation
|
||||
// (Anthropic SDK / core runAgent / OpenAI / local model / mock are all adapter implementations).
|
||||
import type {
|
||||
AgentProgressUpdate,
|
||||
AgentRunParams,
|
||||
AgentRunResult,
|
||||
} from './types.js'
|
||||
import type { HostHandle } from './ports.js'
|
||||
|
||||
/** Adapter capability declaration. The engine/script degrades based on this (e.g. if the backend does not support schema, switch to text + parse). */
|
||||
export type AgentAdapterCapabilities = {
|
||||
/** Supports schema structured output (agent(schema) returns an object directly). */
|
||||
structuredOutput: boolean
|
||||
/** Supports tool calling (only the core agent backend has this). */
|
||||
tools?: boolean
|
||||
/** Supports streaming (the v1 engine does not consume it; reserved). */
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
/** Context for adapter.run. */
|
||||
export type AgentAdapterContext = {
|
||||
/** Opaque host handle passed through (used by the core adapter; ignored by standalone backends). */
|
||||
host: HostHandle
|
||||
/** Cancellation signal (same as the workflow signal). */
|
||||
signal: AbortSignal
|
||||
/** Current workflow runId (for logging/tracing). */
|
||||
runId: string
|
||||
/**
|
||||
* Engine-layer agent sequence number (incremented by hooks.agentIdSeq; same source as panel RunProgress.agents[].id).
|
||||
* Note: this is a different concept from the core AgentId (a string, used for sub-agent tracking) created internally by the backend;
|
||||
* do not mix them. This field is the key for registerAgentAbort/unregisterAgentAbort, so that service
|
||||
* .kill(runId, agentId) can precisely route to the AbortController created by the backend.
|
||||
*/
|
||||
agentId: number
|
||||
/**
|
||||
* In-progress reporting (called by the backend loop as it accumulates tokens/tools). Optional: standalone backends may not implement it;
|
||||
* the engine emits the agent_progress event based on this (closure carries agentId/runId for correlation), and the panel refreshes in real time.
|
||||
*/
|
||||
onProgress?: (update: AgentProgressUpdate) => void
|
||||
/**
|
||||
* Register an agent-level AbortController (optional). The backend calls this after creating the controller to inject it into a Map,
|
||||
* so that service.kill(runId, agentId) can precisely abort a single agent without affecting others.
|
||||
* Injected by hooks.agent before backend.run is called.
|
||||
*/
|
||||
registerAgentAbort?: (agentId: number, ac: AbortController) => void
|
||||
/**
|
||||
* Unregister an agent-level AbortController (called when the agent completes or fails; idempotent).
|
||||
* Paired with registerAgentAbort.
|
||||
*/
|
||||
unregisterAgentAbort?: (agentId: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent backend adapter. The engine only depends on this interface; concrete backends implement it and register into the registry.
|
||||
* initialize/dispose are optional lifecycle hooks (connection pool / resource management), triggered by the caller via
|
||||
* registry.initializeAll/disposeAll.
|
||||
*/
|
||||
export interface AgentAdapter {
|
||||
/** Unique identifier (registry routing / logging). */
|
||||
readonly id: string
|
||||
/** Capability declaration. */
|
||||
readonly capabilities: AgentAdapterCapabilities
|
||||
/** Execute one agent call. */
|
||||
run(params: AgentRunParams, ctx: AgentAdapterContext): Promise<AgentRunResult>
|
||||
/** Initialize (triggered by registry.initializeAll). */
|
||||
initialize?(): Promise<void>
|
||||
/** Dispose (triggered by registry.disposeAll). */
|
||||
dispose?(): Promise<void>
|
||||
}
|
||||
|
||||
/** Routing rule: decides which params go to which adapter. Matched in insertion order; first hit wins. */
|
||||
export type AdapterRouteRule =
|
||||
| { kind: 'agentType'; agentType: string; adapter: string }
|
||||
| { kind: 'model'; pattern: string; adapter: string }
|
||||
| {
|
||||
kind: 'custom'
|
||||
match: (params: AgentRunParams) => boolean
|
||||
adapter: string
|
||||
}
|
||||
|
||||
/** Thrown when the registry cannot find a matching adapter. */
|
||||
export class AdapterNotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'AdapterNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-backend registry. register registers an adapter, route/default configure routing, and resolve picks an adapter by
|
||||
* matching rules in order. The adapter lifecycle (initialize/dispose) is triggered uniformly via
|
||||
* initializeAll/disposeAll (called by the caller before/after the run).
|
||||
*/
|
||||
export class AgentAdapterRegistry {
|
||||
private readonly adapters = new Map<string, AgentAdapter>()
|
||||
private readonly rules: AdapterRouteRule[] = []
|
||||
private defaultId: string | null = null
|
||||
|
||||
/** Register an adapter (duplicate id overwrites). Chainable. */
|
||||
register(adapter: AgentAdapter): this {
|
||||
this.adapters.set(adapter.id, adapter)
|
||||
return this
|
||||
}
|
||||
|
||||
/** Set the default adapter (used when no rule matches). Chainable. */
|
||||
default(adapterId: string): this {
|
||||
this.defaultId = adapterId
|
||||
return this
|
||||
}
|
||||
|
||||
/** Add a routing rule (matched in insertion order). Chainable. */
|
||||
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)
|
||||
}
|
||||
|
||||
/** Match by rules; return the first hit; if no hit, go to default; if neither, throw 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(
|
||||
`No adapter matched (rules=${this.rules.length}, default=${this.defaultId ?? 'none'})`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Trigger initialize on all adapters (skips unimplemented ones). */
|
||||
async initializeAll(): Promise<void> {
|
||||
for (const a of this.adapters.values()) {
|
||||
await a.initialize?.()
|
||||
}
|
||||
}
|
||||
|
||||
/** Trigger dispose on all adapters (skips unimplemented ones). */
|
||||
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 rule
|
||||
}
|
||||
32
packages/workflow-engine/src/constants.ts
Normal file
32
packages/workflow-engine/src/constants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Engine-level constants. No runtime dependencies.
|
||||
|
||||
/**
|
||||
* Workflow tool name. PascalCase matches the system's other tools (Agent/Bash/CronCreate…),
|
||||
* otherwise the case-sensitive toolMatchesName would fail on the model's natural select:Workflow.
|
||||
*/
|
||||
export const WORKFLOW_TOOL_NAME = 'Workflow'
|
||||
|
||||
/** Directory for user-named workflow files (relative to project root). */
|
||||
export const WORKFLOW_DIR_NAME = '.claude/workflows'
|
||||
|
||||
/** Persistence directory for workflow runs (journal + run records). */
|
||||
export const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
|
||||
|
||||
/** Supported script extensions for named workflows (in priority order). */
|
||||
export const WORKFLOW_SCRIPT_EXTENSIONS = ['.ts', '.js', '.mjs'] as const
|
||||
|
||||
/**
|
||||
* Concurrency: default semaphore permits per workflow run.
|
||||
* History: previously used min(CAP, cpuCores - 2); changed to a fixed default of 3 — to avoid fanning out a dozen agents at once on multi-core machines.
|
||||
* A single run can override this via the Workflow tool's maxConcurrency input (still clamped by CAP).
|
||||
*/
|
||||
export const DEFAULT_MAX_CONCURRENCY = 3
|
||||
|
||||
/** Absolute cap on user-supplied maxConcurrency (anti-abuse). */
|
||||
export const MAX_CONCURRENCY_CAP = 16
|
||||
|
||||
/** Total cap on agent() calls within a single workflow lifecycle. */
|
||||
export const MAX_TOTAL_AGENTS = 1000
|
||||
|
||||
/** Items cap per single parallel()/pipeline() call. */
|
||||
export const MAX_ITEMS_PER_CALL = 4096
|
||||
36
packages/workflow-engine/src/engine/budget.ts
Normal file
36
packages/workflow-engine/src/engine/budget.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export class BudgetExhaustedError extends Error {
|
||||
constructor() {
|
||||
super('workflow token budget exhausted (budget.total reached the cap)')
|
||||
this.name = 'BudgetExhaustedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token budget accumulator. The script reads via `budget.total / budget.spent() / budget.remaining()`;
|
||||
* assertCanSpend() enforces a hard cap before each agent() call.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
73
packages/workflow-engine/src/engine/concurrency.ts
Normal file
73
packages/workflow-engine/src/engine/concurrency.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY_CAP } from '../constants.js'
|
||||
|
||||
/**
|
||||
* Async semaphore. acquire() returns a release function; on release the permit is transferred
|
||||
* directly to the next waiter (available stays unchanged), and only returned when there is no waiter. The total number of permits is conserved.
|
||||
*
|
||||
* acquire(signal?) supports cancellation: when the signal is already aborted or aborts while waiting, it rejects immediately,
|
||||
* the waiter is removed from the queue, and no permit is consumed (to avoid a canceled agent holding a concurrency slot).
|
||||
*/
|
||||
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() // transfer the permit directly
|
||||
} else {
|
||||
this.available += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default concurrency for the current process (backward-compatible entry; for a specific run, use clampMaxConcurrency to handle user input). */
|
||||
export function maxConcurrency(): number {
|
||||
return DEFAULT_MAX_CONCURRENCY
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the "user-supplied maxConcurrency" to legal permits.
|
||||
* - undefined / NaN → DEFAULT_MAX_CONCURRENCY
|
||||
* - <1 → 1 (at least one concurrency slot, otherwise the workflow cannot progress)
|
||||
* - >MAX_CONCURRENCY_CAP → MAX_CONCURRENCY_CAP
|
||||
* - otherwise the truncated original value
|
||||
*/
|
||||
export function clampMaxConcurrency(n: number | undefined): number {
|
||||
if (n === undefined || Number.isNaN(n)) return DEFAULT_MAX_CONCURRENCY
|
||||
return Math.max(1, Math.min(Math.trunc(n), MAX_CONCURRENCY_CAP))
|
||||
}
|
||||
73
packages/workflow-engine/src/engine/context.ts
Normal file
73
packages/workflow-engine/src/engine/context.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { HostHandle, WorkflowPorts } from '../ports.js'
|
||||
import type { JournalEntry } from '../types.js'
|
||||
import { Budget } from './budget.js'
|
||||
import { Semaphore, clampMaxConcurrency } from './concurrency.js'
|
||||
|
||||
/**
|
||||
* Resources that can be shared by sub-workflows. When nesting, semaphore/budget/agentCountBox are shared by reference,
|
||||
* and depth is temporarily +1 while executing a sub-workflow.
|
||||
*/
|
||||
export type SharedResources = {
|
||||
semaphore: Semaphore
|
||||
budget: Budget
|
||||
agentCountBox: { value: number }
|
||||
/** Increasing sequence number for agent() calls; stamps agent_started/agent_done for precise progress correlation. Shared across sub-workflows. */
|
||||
agentIdSeq: { value: number }
|
||||
depth: number
|
||||
}
|
||||
|
||||
/** Execution context for a single workflow run. */
|
||||
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,
|
||||
maxConcurrency?: number,
|
||||
): SharedResources {
|
||||
return {
|
||||
semaphore: new Semaphore(clampMaxConcurrency(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
|
||||
/** Concurrency slots for a single run; undefined → DEFAULT_MAX_CONCURRENCY. Clamped by clampMaxConcurrency. */
|
||||
maxConcurrency?: number
|
||||
journal?: JournalEntry[]
|
||||
}): EngineContext {
|
||||
const resources = createSharedResources(opts.budgetTotal, opts.maxConcurrency)
|
||||
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,
|
||||
}
|
||||
}
|
||||
15
packages/workflow-engine/src/engine/errors.ts
Normal file
15
packages/workflow-engine/src/engine/errors.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Engine-level expected errors (script errors, caps, nesting). */
|
||||
export class WorkflowError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'WorkflowError'
|
||||
}
|
||||
}
|
||||
|
||||
/** workflow was aborted (killed). */
|
||||
export class WorkflowAbortedError extends Error {
|
||||
constructor() {
|
||||
super('workflow has been aborted')
|
||||
this.name = 'WorkflowAbortedError'
|
||||
}
|
||||
}
|
||||
300
packages/workflow-engine/src/engine/hooks.ts
Normal file
300
packages/workflow-engine/src/engine/hooks.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { MAX_ITEMS_PER_CALL, MAX_TOTAL_AGENTS } from '../constants.js'
|
||||
import type {
|
||||
AgentProgressUpdate,
|
||||
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'
|
||||
|
||||
/** Sub-workflow executor for the workflow() hook (injected by runWorkflow to avoid circular dependencies). */
|
||||
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: 'agent_progress'
|
||||
agentId: number
|
||||
label?: string
|
||||
phase?: string
|
||||
tokenCount: number
|
||||
toolCount: number
|
||||
}
|
||||
| { type: 'log'; message: string }
|
||||
|
||||
export function makeHooks(
|
||||
ctx: EngineContext,
|
||||
runSubWorkflow: SubWorkflowRunner,
|
||||
): WorkflowHooks {
|
||||
// All progress events auto-inject runId so the adapter can route them to the corresponding task (multiple concurrent workflows)
|
||||
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 exceeds total agent cap (${MAX_TOTAL_AGENTS})`,
|
||||
)
|
||||
}
|
||||
|
||||
// Assign a unique id to each agent() call (including journal hits); stamp started/done so the reducer can associate them precisely
|
||||
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 hit -> return cached result directly
|
||||
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)
|
||||
}
|
||||
// Divergence: discard subsequent journal entries; everything from here on runs live
|
||||
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 {
|
||||
// Queued wait during abort: the semaphore already removed the waiter and did not consume a permit
|
||||
throw new WorkflowAbortedError()
|
||||
}
|
||||
try {
|
||||
if (ctx.signal.aborted) throw new WorkflowAbortedError()
|
||||
// Budget check inside the semaphore critical section: a queued waiter sees the latest spent when woken,
|
||||
// otherwise N waiters enqueued while spent=0 all pass the check and overspend on wake-up without re-check.
|
||||
// Journal-hit path does not charge budget and needs no check.
|
||||
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
|
||||
// onProgress closure: the backend loop accumulates token/tool counts -> emits an agent_progress event (carrying agentId for association)
|
||||
const onProgress = (update: AgentProgressUpdate): void => {
|
||||
emit({ type: 'agent_progress', agentId, label, phase, ...update })
|
||||
}
|
||||
// Inject agent-level AbortController register/unregister: the backend creates the controller then calls
|
||||
// registerAgentAbort to inject ports-layer bindings; service.kill(runId, agentId) uses this to
|
||||
// precisely abort a single agent. When the registry is absent (agentRunner fallback path), there is no backend middle layer,
|
||||
// and agentAbortControllers at the ports layer is always empty — single-agent kill degrades to a no-op on this path.
|
||||
const adapterCtx = registry
|
||||
? {
|
||||
host: ctx.host,
|
||||
signal: ctx.signal,
|
||||
runId: ctx.runId,
|
||||
agentId,
|
||||
onProgress,
|
||||
...(ctx.ports.taskRegistrar.registerAgentAbort
|
||||
? {
|
||||
registerAgentAbort: (
|
||||
id: number,
|
||||
ac: AbortController,
|
||||
): void => {
|
||||
ctx.ports.taskRegistrar.registerAgentAbort?.(
|
||||
ctx.runId,
|
||||
id,
|
||||
ac,
|
||||
)
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(ctx.ports.taskRegistrar.unregisterAgentAbort
|
||||
? {
|
||||
unregisterAgentAbort: (id: number): void => {
|
||||
ctx.ports.taskRegistrar.unregisterAgentAbort?.(
|
||||
ctx.runId,
|
||||
id,
|
||||
)
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null
|
||||
// resolve is outside the try: configuration errors (e.g. AdapterNotFoundError) propagate directly without retry —
|
||||
// this is a workflow configuration problem, not a transient backend failure; retrying is meaningless and would mask the bug.
|
||||
const adapter = registry ? registry.resolve(params) : null
|
||||
const invokeBackend = (): Promise<AgentRunResult> =>
|
||||
adapter
|
||||
? adapter.run(params, adapterCtx!)
|
||||
: ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
|
||||
|
||||
// Auto-retry once on failure: dead (terminal API error after retries) or a non-abort throw
|
||||
// both get one retry chance; WorkflowAbortedError (kill) is not retried — it is the user's intent.
|
||||
// If retry still fails: dead stays dead; a throw degrades to dead (one agent must not take down the workflow).
|
||||
// budget is not double-charged: dead does not call addOutputTokens; retry-ok charges once (at the final ok).
|
||||
// dead.reason is passed through to the log: no-structured-output (the agent's final text block did not produce plain-object JSON)
|
||||
// is a high-frequency cause of death; logging detail lets you immediately see what the agent last said.
|
||||
// detail is wrapped with String() defensively: old journals or third-party adapters may write non-strings (corrupted data),
|
||||
// and calling .slice directly would throw a TypeError that pierces the logging path.
|
||||
let result: AgentRunResult
|
||||
try {
|
||||
result = await invokeBackend()
|
||||
if (result.kind === 'dead') {
|
||||
const detailStr =
|
||||
typeof result.detail === 'string' ? result.detail : ''
|
||||
ctx.ports.logger.warn?.(
|
||||
`agent "${label ?? `#${agentId}`}" returned dead` +
|
||||
(result.reason ? ` (${result.reason})` : '') +
|
||||
(detailStr ? `: ${detailStr.slice(0, 150)}` : '') +
|
||||
'; retrying once',
|
||||
)
|
||||
result = await invokeBackend()
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof WorkflowAbortedError) throw e
|
||||
const eMsg = e instanceof Error ? e.message : String(e)
|
||||
ctx.ports.logger.warn?.(
|
||||
`agent "${label ?? `#${agentId}`}" threw (${eMsg}); retrying once`,
|
||||
)
|
||||
try {
|
||||
result = await invokeBackend()
|
||||
} catch (e2) {
|
||||
if (e2 instanceof WorkflowAbortedError) throw e2
|
||||
// Retry still threw: degrade to dead (keep the workflow going; hooks.agent returns null)
|
||||
result = {
|
||||
kind: 'dead',
|
||||
reason: 'runagent-threw',
|
||||
detail: e2 instanceof Error ? e2.message : String(e2),
|
||||
}
|
||||
}
|
||||
}
|
||||
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 }
|
||||
// Key point: push order = completion order (not call order); read() already re-sorts by seq,
|
||||
// so during resume the call order aligns with the journal order and the key index stays stable.
|
||||
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 exceeds the per-call items cap (${MAX_ITEMS_PER_CALL})`,
|
||||
)
|
||||
}
|
||||
return Promise.all(
|
||||
thunks.map(async (t, i) => {
|
||||
try {
|
||||
return await t()
|
||||
} catch (e) {
|
||||
// The "null on error" contract is unchanged, but it should log — otherwise the workflow author cannot locate why an agent failed
|
||||
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 exceeds the per-call items cap (${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() nesting allows only one level')
|
||||
}
|
||||
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
|
||||
}
|
||||
50
packages/workflow-engine/src/engine/journal.ts
Normal file
50
packages/workflow-engine/src/engine/journal.ts
Normal 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'
|
||||
|
||||
/** Canonical parameter string after removing display-only fields. */
|
||||
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)
|
||||
}
|
||||
|
||||
/** Determinism key for an agent() call (sha256 of prompt + canonical params). */
|
||||
export function agentCallKey(prompt: string, params: AgentRunParams): string {
|
||||
return createHash('sha256')
|
||||
.update(prompt + '\n' + canonicalParams(params))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/** File-based JournalStore (jsonl, one directory per run). Pure fs, no core dependencies. */
|
||||
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 completion order ≠ call order; re-sort by seq so the key index is stable during resume.
|
||||
// Old entries missing seq are treated as 0 (forward compatibility; worst case degrades to file order).
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
46
packages/workflow-engine/src/engine/namedWorkflows.ts
Normal file
46
packages/workflow-engine/src/engine/namedWorkflows.ts
Normal 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(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolve a named workflow file by priority .ts → .js → .mjs. */
|
||||
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)
|
||||
// Double safeguard: prevents edge cases missed by the upper-layer sanitize from traversing paths outside workflowDir
|
||||
if (!containsPath(workflowDir, p)) return null
|
||||
try {
|
||||
return { path: p, content: await readFile(p, 'utf-8') }
|
||||
} catch {
|
||||
// try the next extension
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** List all named workflows in the directory (excluding non-script files). */
|
||||
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()
|
||||
}
|
||||
26
packages/workflow-engine/src/engine/paths.ts
Normal file
26
packages/workflow-engine/src/engine/paths.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { resolve, sep } from 'node:path'
|
||||
|
||||
/**
|
||||
* Determine whether target, after resolution, is within base (including equal to base).
|
||||
* Relative targets are resolved against base (does not depend on process.cwd).
|
||||
* Uses the `sep` boundary to avoid false prefix positives (e.g. `/foo` is not the parent of `/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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether the named workflow name is a legal identifier (reject path traversal).
|
||||
* Rejects: path separators, null bytes, `.` / `..`.
|
||||
* Returns the sanitized name, or null for illegal.
|
||||
*/
|
||||
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
|
||||
}
|
||||
156
packages/workflow-engine/src/engine/runWorkflow.ts
Normal file
156
packages/workflow-engine/src/engine/runWorkflow.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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 = {
|
||||
/** Already-resolved script source code. */
|
||||
script: string
|
||||
args?: unknown
|
||||
runId: string
|
||||
workflowName?: string
|
||||
ports: WorkflowPorts
|
||||
host: HostHandle
|
||||
signal: AbortSignal
|
||||
cwd: string
|
||||
budgetTotal: number | null
|
||||
/** Concurrency slots for a single run; undefined → DEFAULT_MAX_CONCURRENCY. */
|
||||
maxConcurrency?: number
|
||||
/** resume: when true, load the existing journal and replay. */
|
||||
resume?: boolean
|
||||
/** Whether the script source hash changed on resume. When true, ignore the journal and re-run everything. */
|
||||
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'
|
||||
|
||||
// Load the journal (only on resume and when the script is unchanged)
|
||||
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,
|
||||
maxConcurrency: opts.maxConcurrency,
|
||||
journal,
|
||||
})
|
||||
if (journalInvalidated) ctx.journalInvalidated = true
|
||||
|
||||
ports.progressEmitter.emit({
|
||||
type: 'run_started',
|
||||
runId: opts.runId,
|
||||
workflowName,
|
||||
meta: parsed.meta,
|
||||
})
|
||||
|
||||
// Sub-workflow executor: reuses the same ctx (sharing journal/concurrency/budget/counters), temporarily +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(
|
||||
`Sub-workflow script error: ${(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)
|
||||
|
||||
// hook.phase only emits phase_done for the previous phase when switching phases; when the script ends,
|
||||
// currentPhase is the last phase, and there is no subsequent phase() to trigger its phase_done → the left pane of the UI
|
||||
// would stay running forever (the agent list already shows ✓ done). Emit one before the terminal state — shared by all paths.
|
||||
const emitTerminalPhaseDone = (): void => {
|
||||
if (!ctx.currentPhase) return
|
||||
ports.progressEmitter.emit({
|
||||
type: 'phase_done',
|
||||
runId: opts.runId,
|
||||
phase: ctx.currentPhase,
|
||||
})
|
||||
}
|
||||
|
||||
let result: WorkflowRunResult
|
||||
try {
|
||||
const returnValue = await parsed.execute(
|
||||
hooks,
|
||||
opts.args,
|
||||
ctx.resources.budget,
|
||||
)
|
||||
result = { status: 'completed', returnValue }
|
||||
} catch (e) {
|
||||
if (e instanceof WorkflowAbortedError) {
|
||||
result = { status: 'killed' }
|
||||
} else {
|
||||
result = { status: 'failed', error: (e as Error).message }
|
||||
}
|
||||
}
|
||||
emitTerminalPhaseDone()
|
||||
ports.progressEmitter.emit({
|
||||
type: 'run_done',
|
||||
runId: opts.runId,
|
||||
...result,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
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(`Sub-workflow "${sub.name}" not found`)
|
||||
return found.content
|
||||
}
|
||||
throw new WorkflowError('workflow() requires name or scriptPath')
|
||||
}
|
||||
229
packages/workflow-engine/src/engine/script.ts
Normal file
229
packages/workflow-engine/src/engine/script.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { WorkflowMeta } from '../types.js'
|
||||
|
||||
export class ScriptError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ScriptError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape of the hook functions the engine injects into a script. */
|
||||
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*/
|
||||
|
||||
/**
|
||||
* Extract the `export const meta = { ... }` pure literal. Returns the meta object and the stripped body.
|
||||
* The literal is evaluated with a parameter-less Function — any identifier reference throws ReferenceError → reported as "not a plain literal".
|
||||
*/
|
||||
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 must be an object literal `{ ... }`')
|
||||
}
|
||||
|
||||
// Brace matching (handles strings / escapes / nesting)
|
||||
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 literal braces are not closed')
|
||||
|
||||
const literal = source.slice(start, i)
|
||||
let metaObj: unknown
|
||||
try {
|
||||
// Parameter-less Function: a plain literal can be evaluated; referencing any identifier → ReferenceError
|
||||
metaObj = new Function(`return (${literal})`)()
|
||||
} catch (e) {
|
||||
throw new ScriptError(
|
||||
`meta must be a plain literal (no variable/function calls/interpolation): ${(e as Error).message}`,
|
||||
)
|
||||
}
|
||||
const meta = validateMeta(metaObj)
|
||||
|
||||
// Strip the meta statement (including trailing semicolon and extra blank lines)
|
||||
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 must be an object')
|
||||
}
|
||||
const o = v as Record<string, unknown>
|
||||
if (typeof o.name !== 'string' || typeof o.description !== 'string') {
|
||||
throw new ScriptError('meta must include string name and description')
|
||||
}
|
||||
return o as unknown as WorkflowMeta
|
||||
}
|
||||
|
||||
// ---- Non-determinism sandbox shim ----
|
||||
class NonDeterministicError extends Error {
|
||||
constructor(fn: string) {
|
||||
super(
|
||||
`${fn} is not available in workflow scripts (would break resume determinism). Pass timestamps/random seeds via 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>
|
||||
}
|
||||
|
||||
/** Validate + wrap the script as an executable async function (Date/Math are shimmed). */
|
||||
/**
|
||||
* Detect common violations in the script body (import / extra export) and produce precise errors with guidance.
|
||||
* Otherwise it would fall through to AsyncFunction's generic "syntax error", making it hard for the model/user to pinpoint the root cause
|
||||
* (the script is a non-ESM function body, hooks are already injected, and the engine does not transpile TS).
|
||||
*/
|
||||
function assertScriptBody(body: string): void {
|
||||
if (/^\s*import\b/m.test(body)) {
|
||||
throw new ScriptError(
|
||||
'workflow scripts are the body of new AsyncFunction (not ESM modules); import is not supported. ' +
|
||||
'agent / parallel / pipeline / phase / log / workflow / args / budget are injected as parameters — use them directly.',
|
||||
)
|
||||
}
|
||||
// Dynamic import(...) calls: the sandbox only preserves resume determinism, not security, but obvious escape attempts should be blocked.
|
||||
// Not anchored to the start of a line so it can catch `await import(...)`, `return import(...)`, etc.; requires `import` followed by `(` to intercept,
|
||||
// avoiding false positives where the word "import" appears inside a string literal (e.g. agent('please import this module')).
|
||||
if (/\bimport\s*\(/m.test(body)) {
|
||||
throw new ScriptError(
|
||||
'dynamic import(...) is forbidden in workflow scripts: it bypasses the Date/Math sandbox and breaks resume determinism. ' +
|
||||
'The sandbox does not guarantee security (same trust level as the LLM), but explicit escapes are prohibited. Inject external dependencies via args.',
|
||||
)
|
||||
}
|
||||
if (/^\s*export\b/m.test(body)) {
|
||||
throw new ScriptError(
|
||||
'workflow scripts allow only one export const meta = {...} (already extracted by the engine). ' +
|
||||
'Remove other export / export default statements; use top-level return for the result.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(`Script syntax error: ${(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,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
26
packages/workflow-engine/src/engine/structuredOutput.ts
Normal file
26
packages/workflow-engine/src/engine/structuredOutput.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Ajv, type ValidateFunction } from 'ajv'
|
||||
|
||||
const cache = new WeakMap<object, ValidateFunction>()
|
||||
|
||||
/**
|
||||
* Validate agent output against a JSON Schema (Ajv, compilation result cached by schema object).
|
||||
* The engine performs secondary validation on the schema result returned by the adapter, and uses it for tests.
|
||||
*/
|
||||
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'),
|
||||
}
|
||||
}
|
||||
25
packages/workflow-engine/src/index.ts
Normal file
25
packages/workflow-engine/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// @claude-code-best/workflow-engine
|
||||
// Deterministic JS script orchestration engine. Zero core-layer runtime dependencies; talks to the world via port adapters.
|
||||
|
||||
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 { persistInlineScript } from './tool/persistInline.js'
|
||||
export { WORKFLOW_TOOL_NAME } from './tool/constants.js'
|
||||
149
packages/workflow-engine/src/ports.ts
Normal file
149
packages/workflow-engine/src/ports.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { AgentAdapterRegistry } from './agentAdapter.js'
|
||||
import type {
|
||||
AgentRunParams,
|
||||
AgentRunResult,
|
||||
JournalEntry,
|
||||
ProgressEvent,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Opaque host handle. The core side constructs one per tool call, containing toolUseContext/
|
||||
* canUseTool/parentMessage, etc. The package never inspects its internals; it only passes it through to the AgentRunner.
|
||||
* This is the only coupling seam between the package and the core layer, and it is opaque.
|
||||
*/
|
||||
const HOST_HANDLE = Symbol('workflow.hostHandle')
|
||||
|
||||
export type HostBundle = unknown
|
||||
|
||||
export type HostHandle = { readonly [HOST_HANDLE]: HostBundle }
|
||||
|
||||
/** Used by the core-side hostFactory: wraps any bundle into an opaque handle. */
|
||||
export function createHostHandle(bundle: HostBundle): HostHandle {
|
||||
return { [HOST_HANDLE]: bundle } as HostHandle
|
||||
}
|
||||
|
||||
/** Type guard. */
|
||||
export function isHostHandle(value: unknown): value is HostHandle {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
HOST_HANDLE in (value as object)
|
||||
)
|
||||
}
|
||||
|
||||
/** Used by the core-side adapter: unwraps (only the adapter should call this). */
|
||||
export function unwrapHostHandle(handle: HostHandle): HostBundle {
|
||||
return (handle as { [k: symbol]: HostBundle })[HOST_HANDLE]
|
||||
}
|
||||
|
||||
/** Backend for the agent() hook. */
|
||||
export type AgentRunner = {
|
||||
runAgentToResult(
|
||||
params: AgentRunParams,
|
||||
host: HostHandle,
|
||||
): Promise<AgentRunResult>
|
||||
}
|
||||
|
||||
/** Progress event emitter. */
|
||||
export type ProgressEmitter = {
|
||||
emit(event: ProgressEvent): void
|
||||
}
|
||||
|
||||
/** Background task lifecycle. */
|
||||
export type TaskRegistrar = {
|
||||
/**
|
||||
* Register a background task. The adapter creates an AbortController and stores it in task state,
|
||||
* returning runId and signal (for the engine to execute detached + kill to abort).
|
||||
*/
|
||||
register(
|
||||
opts: {
|
||||
workflowName: string
|
||||
workflowFile?: string
|
||||
summary?: string
|
||||
toolUseId?: string
|
||||
/** On resume, reuse the existing runId (read its journal). Omit to generate a new 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
|
||||
/**
|
||||
* Register an agent-level AbortController. Called by the backend when starting an agent, so that service
|
||||
* .kill(runId, agentId) can precisely abort a single agent (without affecting other agents in the same run).
|
||||
* Idempotent: re-registering with the same agentId overwrites.
|
||||
*/
|
||||
registerAgentAbort?(runId: string, agentId: number, ac: AbortController): void
|
||||
/**
|
||||
* Unregister an agent-level AbortController (called when the agent completes/fails; idempotent).
|
||||
*/
|
||||
unregisterAgentAbort?(runId: string, agentId: number): void
|
||||
/**
|
||||
* Abort a single agent. Returns whether it hit (false = agent already completed/does not exist).
|
||||
* Does not affect other agents in the same run; the workflow continues (the aborted agent returns dead → null).
|
||||
*/
|
||||
killAgent?(runId: string, agentId: number): boolean
|
||||
/** Returns the current pending skip/retry action, or null. */
|
||||
pendingAction(runId: string): { kind: 'skip' | 'retry' } | null
|
||||
}
|
||||
|
||||
/** Journal persistence. */
|
||||
export type JournalStore = {
|
||||
read(runId: string): Promise<JournalEntry[]>
|
||||
append(runId: string, entry: JournalEntry): Promise<void>
|
||||
truncate(runId: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Cancellation / permission gate. */
|
||||
export type PermissionGate = {
|
||||
isAborted(host: HostHandle): boolean
|
||||
}
|
||||
|
||||
/** Logging + telemetry. */
|
||||
export type Logger = {
|
||||
debug(msg: string): void
|
||||
event(name: string, metadata?: Record<string, unknown>): void
|
||||
/**
|
||||
* Warning-level log (e.g. errors swallowed when a single parallel/pipeline item fails).
|
||||
* Optional: old ports implementations may omit it; hooks tolerate it with `?.()`.
|
||||
*/
|
||||
warn?(msg: string): void
|
||||
}
|
||||
|
||||
/** Ready-to-use context the engine extracts from the host (handle + basic fields). */
|
||||
export type WorkflowHostContext = {
|
||||
/** Opaque handle passed through to the AgentRunner (contains toolUseContext/canUseTool/parentMessage). */
|
||||
handle: HostHandle
|
||||
cwd: string
|
||||
/** Token budget cap; null means unlimited. */
|
||||
budgetTotal: number | null
|
||||
/** Core-side tool-use id (passed through to task registration). */
|
||||
toolUseId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Provided by the core side: constructs a WorkflowHostContext from the tool call's core context.
|
||||
* The arguments are opaque to the package (unknown); the core-side hostFactory knows the real types.
|
||||
*/
|
||||
export type HostFactory = (args: {
|
||||
context: unknown
|
||||
canUseTool: unknown
|
||||
parentMessage: unknown
|
||||
}) => WorkflowHostContext
|
||||
|
||||
/** Aggregate of all ports. Injected into createWorkflowTool(ports). */
|
||||
export type WorkflowPorts = {
|
||||
agentRunner: AgentRunner
|
||||
/**
|
||||
* Multi-backend adapter registry. When provided, takes precedence over agentRunner — hooks.agent routes
|
||||
* to adapter.run via the registry; when omitted, falls back to agentRunner (backward compatibility).
|
||||
*/
|
||||
agentAdapterRegistry?: AgentAdapterRegistry
|
||||
progressEmitter: ProgressEmitter
|
||||
taskRegistrar: TaskRegistrar
|
||||
journalStore: JournalStore
|
||||
permissionGate: PermissionGate
|
||||
logger: Logger
|
||||
hostFactory: HostFactory
|
||||
}
|
||||
20
packages/workflow-engine/src/progress/events.ts
Normal file
20
packages/workflow-engine/src/progress/events.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ProgressEmitter } from '../ports.js'
|
||||
import type { ProgressEvent } from '../types.js'
|
||||
|
||||
export type { ProgressEvent }
|
||||
|
||||
/** Construct a ProgressEmitter from a single callback. */
|
||||
export function createProgressEmitter(
|
||||
onEvent: (e: ProgressEvent) => void,
|
||||
): ProgressEmitter {
|
||||
return { emit: onEvent }
|
||||
}
|
||||
|
||||
/** Collect all events into an array (for tests). */
|
||||
export function createBufferingEmitter(): {
|
||||
emitter: ProgressEmitter
|
||||
events: ProgressEvent[]
|
||||
} {
|
||||
const events: ProgressEvent[] = []
|
||||
return { emitter: { emit: e => void events.push(e) }, events }
|
||||
}
|
||||
261
packages/workflow-engine/src/tool/WorkflowTool.ts
Normal file
261
packages/workflow-engine/src/tool/WorkflowTool.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
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'
|
||||
import { persistInlineScript } from './persistInline.js'
|
||||
|
||||
/** Self-contained tool descriptor (core wiring wraps it with buildTool). Zero core-layer dependencies. */
|
||||
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.
|
||||
|
||||
Concurrency: default is 3 (hard ceiling 16). OMIT maxConcurrency to use 3. To set maxConcurrency to ANY value other than 3, you MUST first ask the user via AskUserQuestion — propose 3 / 6 / 9 (or other tiers matching the fan-out width) with 3 marked "(Recommended)". The ONLY exception: the user has ALREADY specified a concurrency number in this session ("use 6", "maxConcurrency 9") — then honor it without re-asking. Never silently raise concurrency above 3 just because the workflow fans out; 3 is the recommended default.
|
||||
|
||||
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,
|
||||
// No per-session runtime opt-in gate here: the "ultracode is on for the
|
||||
// session" signal is injected by the harness (claude.ai/client), not held
|
||||
// in any repo state. This tool is compiled in/out via feature('WORKFLOW_SCRIPTS')
|
||||
// in src/tools.ts; beyond that it is always enabled when present.
|
||||
isEnabled: () => true,
|
||||
isReadOnly: () => false,
|
||||
|
||||
async description() {
|
||||
return 'Execute a workflow script that orchestrates multiple subagents to complete a task'
|
||||
},
|
||||
|
||||
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 })
|
||||
|
||||
// Resolve the script source
|
||||
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}` } }
|
||||
}
|
||||
|
||||
// Quick validation (meta + syntax): on failure return an error to the model directly, do not enter the background
|
||||
try {
|
||||
parseScript(script)
|
||||
} catch (e) {
|
||||
return {
|
||||
data: {
|
||||
output: `Error: script validation failed: ${(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,
|
||||
)
|
||||
|
||||
// Inline entry: persist the script to the run directory and return a reusable path (the
|
||||
// inline -> persist -> edit -> resubmit-as-scriptPath iteration loop promised by the ultracode skill).
|
||||
// On write failure degrade to a placeholder + warn, do not abort the run (script is already in memory).
|
||||
if (!workflowFile && input.script) {
|
||||
try {
|
||||
workflowFile = await persistInlineScript(
|
||||
input.script,
|
||||
runId,
|
||||
host.cwd,
|
||||
)
|
||||
} catch (e) {
|
||||
ports.logger.warn?.(
|
||||
`inline script persist failed: ${(e as Error).message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Detached execution
|
||||
void runWorkflow({
|
||||
script,
|
||||
...(input.args !== undefined
|
||||
? { args: normalizeArgs(input.args) }
|
||||
: {}),
|
||||
runId,
|
||||
workflowName,
|
||||
ports,
|
||||
host: host.handle,
|
||||
signal,
|
||||
cwd: host.cwd,
|
||||
budgetTotal: host.budgetTotal,
|
||||
...(input.maxConcurrency !== undefined
|
||||
? { maxConcurrency: input.maxConcurrency }
|
||||
: {}),
|
||||
...(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 started (running in the background).',
|
||||
`run_id: ${runId}`,
|
||||
`workflow: ${workflowName}`,
|
||||
`script: ${scriptPath}`,
|
||||
'',
|
||||
'You will be notified on completion. Use /workflows to view live progress.',
|
||||
].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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensively normalize args: under the legacy `z.string()` contract the model may send a stringified JSON object.
|
||||
* Only normalize when the string JSON.parses to an object/array; plain strings, numbers, etc. are preserved as-is.
|
||||
*/
|
||||
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}" is out of bounds (after resolve, ${resolved} is not within cwd ${cwd})`,
|
||||
)
|
||||
}
|
||||
return {
|
||||
script: await readFile(resolved, 'utf-8'),
|
||||
workflowFile: resolved,
|
||||
}
|
||||
}
|
||||
if (input.name) {
|
||||
if (sanitizeWorkflowName(input.name) === null) {
|
||||
throw new Error(
|
||||
`Named workflow name "${input.name}" is invalid (contains path separators or is . / ..)`,
|
||||
)
|
||||
}
|
||||
const found = await resolveNamedWorkflow(
|
||||
join(cwd, WORKFLOW_DIR_NAME),
|
||||
input.name,
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`Named workflow "${input.name}" not found (looked in ${WORKFLOW_DIR_NAME}/)`,
|
||||
)
|
||||
}
|
||||
return { script: found.content, workflowFile: found.path }
|
||||
}
|
||||
throw new Error('One of script, name, or scriptPath must be provided')
|
||||
}
|
||||
1
packages/workflow-engine/src/tool/constants.ts
Normal file
1
packages/workflow-engine/src/tool/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WORKFLOW_TOOL_NAME } from '../constants.js'
|
||||
28
packages/workflow-engine/src/tool/persistInline.ts
Normal file
28
packages/workflow-engine/src/tool/persistInline.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { WORKFLOW_RUNS_DIR } from '../constants.js'
|
||||
|
||||
/**
|
||||
* Persist an inline workflow script to the run directory so the caller can
|
||||
* iterate via `scriptPath` + `resumeFromRunId` without resending the full script
|
||||
* (the round-trip the ultracode skill promises for the inline entry path).
|
||||
*
|
||||
* Mirrors engine/journal.ts: writes directly via node:fs/promises (no port) to
|
||||
* `<cwd>/<WORKFLOW_RUNS_DIR>/<runId>/script.js` — the same directory as
|
||||
* journal.jsonl, so journalStore.truncate(runId) cleans it up alongside the journal.
|
||||
*
|
||||
* Fixed filename `script.js`: parseScript ignores the extension and the runId
|
||||
* already makes the directory unique, so a stable name aids muscle memory.
|
||||
*/
|
||||
export async function persistInlineScript(
|
||||
script: string,
|
||||
runId: string,
|
||||
cwd: string,
|
||||
): Promise<string> {
|
||||
const dir = join(cwd, WORKFLOW_RUNS_DIR, runId)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'script.js')
|
||||
await writeFile(filePath, script, 'utf-8')
|
||||
return filePath
|
||||
}
|
||||
52
packages/workflow-engine/src/tool/schema.ts
Normal file
52
packages/workflow-engine/src/tool/schema.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod/v4'
|
||||
|
||||
/** Workflow tool input schema. args is any JSON value (object/array/string/etc.). */
|
||||
export const workflowInputSchema = z.object({
|
||||
script: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Self-contained workflow script source (inline)'),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Named workflow, resolved to .claude/workflows/<name>.ts|js|mjs'),
|
||||
scriptPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Absolute path to an existing script file'),
|
||||
args: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe(
|
||||
'The args global variable passed through to the script. Pass a real JSON value (object/array/string), not a JSON string.',
|
||||
),
|
||||
resumeFromRunId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Resume the specified run, replaying the journal'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A short description of this invocation (3-5 words)'),
|
||||
title: z.string().optional().describe('Progress viewer title'),
|
||||
maxConcurrency: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(16)
|
||||
.optional()
|
||||
.describe(
|
||||
'Concurrency cap for agent(). Defaults to 3 (max 16). When the workflow contains heavy parallel/pipeline fan-out, you may confirm the desired concurrency with the user via AskUserQuestion before launching.',
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow tool input type — derived from the schema to avoid hand-written type/schema drift.
|
||||
* In the old implementation {@link WorkflowInput} was hand-written in types.ts and the schema in schema.ts,
|
||||
* bridged by a `as unknown as z.ZodType<WorkflowInput>` double assertion — when the schema changed fields
|
||||
* but the type did not, TS would not flag it. With z.infer, schema/type stay in sync forever.
|
||||
*/
|
||||
export type WorkflowInput = z.infer<typeof workflowInputSchema>
|
||||
|
||||
/** typeof type of the schema (used for "schema is the source of truth" precise signatures). */
|
||||
export type WorkflowInputSchema = typeof workflowInputSchema
|
||||
130
packages/workflow-engine/src/types.ts
Normal file
130
packages/workflow-engine/src/types.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
// Pure type definitions. No runtime dependencies.
|
||||
// WorkflowInput has been migrated to tool/schema.ts and derived via z.infer to avoid drift from the schema.
|
||||
|
||||
/** Shape of the script's `export const meta = {...}` (must be a plain literal). */
|
||||
export type WorkflowMeta = {
|
||||
name: string
|
||||
description: string
|
||||
whenToUse?: string
|
||||
phases?: Array<{ title: string; detail?: string }>
|
||||
}
|
||||
|
||||
/** Parameters passed by agent() to the AgentRunner. */
|
||||
export type AgentRunParams = {
|
||||
prompt: string
|
||||
/** JSON Schema; when provided, agent returns a validated object instead of text. */
|
||||
schema?: object
|
||||
model?: string
|
||||
/** Output token cap (passed through to the agent backend, e.g. LLM max_tokens). */
|
||||
maxTokens?: number
|
||||
/** Custom subagent type (resolved from the registry). */
|
||||
agentType?: string
|
||||
isolation?: 'worktree'
|
||||
allowedTools?: string[]
|
||||
/** Display-only; not part of the journal key. */
|
||||
label?: string
|
||||
/** Display-only; not part of the journal key. */
|
||||
phase?: string
|
||||
}
|
||||
|
||||
/** Progress snapshot while the agent is running (onProgress callback payload; backend loop accumulates tokens/tools). */
|
||||
export type AgentProgressUpdate = {
|
||||
tokenCount: number
|
||||
toolCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by AgentRunner. The ok variant carries model/toolCount for panel display (optional; standalone backends may leave them blank).
|
||||
*
|
||||
* dead carries optional reason/detail: the journal history only records `{kind:"dead"}` with no info,
|
||||
* so during debugging you cannot distinguish "agent finished but produced no StructuredOutput" from "runAgent threw".
|
||||
* reason lets the hooks retry log, the panel, and post-hoc auditing see the cause of death immediately.
|
||||
*/
|
||||
export type AgentRunResult =
|
||||
| {
|
||||
kind: 'ok'
|
||||
output: string | object
|
||||
usage: { outputTokens: number }
|
||||
/** The actually-resolved model id (display-only). */
|
||||
model?: string
|
||||
/** Number of tool calls during the agent run. */
|
||||
toolCount?: number
|
||||
/** Total context tokens at completion (display-only; same basis as the real-time agent_progress). */
|
||||
tokenCount?: number
|
||||
}
|
||||
| { kind: 'skipped' }
|
||||
| {
|
||||
kind: 'dead'
|
||||
/**
|
||||
* Cause-of-death classification for log aggregation / post-hoc auditing. Optional for backward compatibility with old journals.
|
||||
* - no-structured-output: agent finished but finalize content has no StructuredOutput (neither called tools nor produced JSON in text)
|
||||
* - runagent-threw: runAgent threw a non-abort error (API failure / context overflow / runtime error)
|
||||
* - worktree-failed: isolation:'worktree' creation failed (fail-closed degradation)
|
||||
* - unknown: unclassified (compatible with old backends / third-party adapters)
|
||||
*/
|
||||
reason?:
|
||||
| 'no-structured-output'
|
||||
| 'runagent-threw'
|
||||
| 'worktree-failed'
|
||||
| 'unknown'
|
||||
/** Detail (error message / text preview) for logs; not shown to end users. */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/** A single record in the journal. seq = agent() call sequence number; read() re-sorts by it to stabilize resume. */
|
||||
export type JournalEntry = {
|
||||
key: string
|
||||
/** agent() call order (from agentIdSeq; monotonically increasing across sub-workflows). */
|
||||
seq: number
|
||||
result: AgentRunResult
|
||||
}
|
||||
|
||||
/** Progress events. All variants carry runId so the adapter can route to the corresponding task (multiple concurrent workflows). */
|
||||
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: 'agent_progress'
|
||||
runId: string
|
||||
agentId: number
|
||||
label?: string
|
||||
phase?: string
|
||||
tokenCount: number
|
||||
toolCount: number
|
||||
}
|
||||
| { type: 'log'; runId: string; message: string }
|
||||
| {
|
||||
type: 'run_done'
|
||||
runId: string
|
||||
status: 'completed' | 'failed' | 'killed'
|
||||
returnValue?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Engine run result. */
|
||||
export type WorkflowRunResult = {
|
||||
status: 'completed' | 'failed' | 'killed'
|
||||
returnValue?: unknown
|
||||
error?: string
|
||||
}
|
||||
17
packages/workflow-engine/tsconfig.json
Normal file
17
packages/workflow-engine/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -95,4 +95,7 @@ export const DEFAULT_BUILD_FEATURES = [
|
||||
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
|
||||
// Autofix PR
|
||||
'AUTOFIX_PR', // /autofix-pr 命令(fork 引入;docs/jira/AUTOFIX-PR-001.md 承诺默认开启)
|
||||
// Persistent thread goal command — auto-continuation, JSONL persistence,
|
||||
// strict completion/blocked audit. See src/services/goal.
|
||||
'GOAL',
|
||||
] as const
|
||||
|
||||
@@ -73,7 +73,7 @@ function isAddressed(messages: Message[], name: string): boolean {
|
||||
) {
|
||||
const m = messages[i]
|
||||
if (m?.type !== 'user') continue
|
||||
const content = (m as any).message?.content
|
||||
const content = m.message?.content
|
||||
if (typeof content === 'string' && pattern.test(content)) return true
|
||||
}
|
||||
return false
|
||||
@@ -89,7 +89,7 @@ function buildTranscript(messages: Message[]): string {
|
||||
.filter(m => m.type === 'user' || m.type === 'assistant')
|
||||
.map(m => {
|
||||
const role = m.type === 'user' ? 'user' : 'claude'
|
||||
const content = (m as any).message?.content
|
||||
const content = m.message?.content
|
||||
const text =
|
||||
typeof content === 'string'
|
||||
? content.slice(0, 300)
|
||||
|
||||
@@ -381,7 +381,7 @@ export class CCRClient {
|
||||
if (!result.ok) {
|
||||
throw new RetryableError(
|
||||
'client event POST failed',
|
||||
(result as any).retryAfterMs,
|
||||
result.retryAfterMs,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -404,7 +404,7 @@ export class CCRClient {
|
||||
if (!result.ok) {
|
||||
throw new RetryableError(
|
||||
'internal event POST failed',
|
||||
(result as any).retryAfterMs,
|
||||
result.retryAfterMs,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -433,10 +433,7 @@ export class CCRClient {
|
||||
'delivery batch',
|
||||
)
|
||||
if (!result.ok) {
|
||||
throw new RetryableError(
|
||||
'delivery POST failed',
|
||||
(result as any).retryAfterMs,
|
||||
)
|
||||
throw new RetryableError('delivery POST failed', result.retryAfterMs)
|
||||
}
|
||||
},
|
||||
baseDelayMs: 500,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { context, contextNonInteractive } from './commands/context/index.js'
|
||||
import diff from './commands/diff/index.js'
|
||||
import doctor from './commands/doctor/index.js'
|
||||
import memory from './commands/memory/index.js'
|
||||
import mode from './commands/mode/index.js'
|
||||
import help from './commands/help/index.js'
|
||||
import ide from './commands/ide/index.js'
|
||||
import init from './commands/init.js'
|
||||
@@ -161,6 +162,11 @@ const poor = feature('POOR')
|
||||
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
|
||||
).default
|
||||
: null
|
||||
const goalCmd = feature('GOAL')
|
||||
? (
|
||||
require('./commands/goal/index.js') as typeof import('./commands/goal/index.js')
|
||||
).default
|
||||
: null
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import thinkback from './commands/thinkback/index.js'
|
||||
import thinkbackPlay from './commands/thinkback-play/index.js'
|
||||
@@ -327,6 +333,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
mcp,
|
||||
memory,
|
||||
mobile,
|
||||
mode,
|
||||
model,
|
||||
outputStyle,
|
||||
remoteEnv,
|
||||
@@ -360,6 +367,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
...(forkCmd ? [forkCmd] : []),
|
||||
...(buddy ? [buddy] : []),
|
||||
...(poor ? [poor] : []),
|
||||
...(goalCmd ? [goalCmd] : []),
|
||||
...(proactive ? [proactive] : []),
|
||||
...(monitorCmd ? [monitorCmd] : []),
|
||||
...(coordinatorCmd ? [coordinatorCmd] : []),
|
||||
@@ -475,7 +483,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 */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { EffortPanel } from '../../components/EffortPanel/EffortPanel.js';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
@@ -161,9 +162,18 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
|
||||
}
|
||||
|
||||
if (!args || args === 'current' || args === 'status') {
|
||||
return <ShowCurrentEffort onDone={onDone} />;
|
||||
if (args === 'current' || args === 'status') {
|
||||
return <ShowCurrentEffort onDone={onDone} />;
|
||||
}
|
||||
// 完全无参 → 打开交互面板
|
||||
return <EffortPanelWrapper onDone={onDone} />;
|
||||
}
|
||||
|
||||
const result = executeEffort(args);
|
||||
return <ApplyEffortAndClose result={result} onDone={onDone} />;
|
||||
}
|
||||
|
||||
function EffortPanelWrapper({ onDone }: { onDone: (result: string) => void }): React.ReactNode {
|
||||
const effortValue = useAppState(s => s.effortValue);
|
||||
return <EffortPanel appStateEffort={effortValue} onDone={onDone} />;
|
||||
}
|
||||
|
||||
75
src/commands/goal/GoalReplaceConfirmDialog.tsx
Normal file
75
src/commands/goal/GoalReplaceConfirmDialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Confirmation dialog shown when the user runs `/goal <objective>`
|
||||
* while a non-complete goal is already active.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
|
||||
import type { GoalState } from 'src/types/logs.js';
|
||||
import { Select } from 'src/components/CustomSelect/index.js';
|
||||
import { PermissionDialog } from 'src/components/permissions/PermissionDialog.js';
|
||||
import { formatGoalElapsed, formatGoalStatusLabel } from 'src/services/goal/goalState.js';
|
||||
|
||||
type Props = {
|
||||
currentGoal: GoalState;
|
||||
newObjective: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function GoalReplaceConfirmDialog({ currentGoal, newObjective, onConfirm, onCancel }: Props): React.ReactNode {
|
||||
function handleResponse(value: 'yes' | 'no'): void {
|
||||
if (value === 'yes') onConfirm();
|
||||
else onCancel();
|
||||
}
|
||||
|
||||
const tokensDisplay =
|
||||
currentGoal.tokenBudget !== null
|
||||
? `${currentGoal.tokensUsed} / ${currentGoal.tokenBudget}`
|
||||
: `${currentGoal.tokensUsed}`;
|
||||
|
||||
return (
|
||||
<PermissionDialog color="warning" title="Replace active goal?">
|
||||
<Box flexDirection="column" marginTop={1} paddingX={1}>
|
||||
<Text>A goal is already in progress. Replacing it will reset all progress and counters.</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>Current goal:</Text>
|
||||
<Text>
|
||||
<Text dimColor>· Objective: </Text>
|
||||
{currentGoal.objective}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>· Status: </Text>
|
||||
{formatGoalStatusLabel(currentGoal.status)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>· Time: </Text>
|
||||
{formatGoalElapsed(currentGoal)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>· Tokens: </Text>
|
||||
{tokensDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>New objective:</Text>
|
||||
<Text>{newObjective}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Yes, replace the goal', value: 'yes' as const },
|
||||
{ label: 'No, keep the current goal', value: 'no' as const },
|
||||
]}
|
||||
onChange={handleResponse}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
);
|
||||
}
|
||||
207
src/commands/goal/goal.tsx
Normal file
207
src/commands/goal/goal.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* `/goal` slash command — set, view, or control the persistent thread
|
||||
* goal that drives auto-continuation across turns.
|
||||
*
|
||||
* Subcommands
|
||||
* -----------
|
||||
* `/goal` -> show current status
|
||||
* `/goal status` -> alias of bare `/goal`
|
||||
* `/goal clear` -> remove the active goal (persists tombstone)
|
||||
* `/goal pause` -> pause auto-continuation
|
||||
* `/goal resume` -> resume from paused state
|
||||
* `/goal continue` -> reset turn counter after max-turns and continue
|
||||
* `/goal complete` -> mark complete (manual override; tools usually do this)
|
||||
* `/goal <objective>` -> set a new goal; if one is already active and not
|
||||
* complete, a confirmation dialog appears first.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
|
||||
import type { LocalJSXCommandContext } from 'src/commands.js';
|
||||
import {
|
||||
clearGoal,
|
||||
completeGoal,
|
||||
continueGoalFromMaxTurns,
|
||||
formatGoalElapsed,
|
||||
formatGoalStatusLabel,
|
||||
getGoal,
|
||||
incrementGoalTurns,
|
||||
MAX_GOAL_TURNS,
|
||||
pauseGoal,
|
||||
resumeGoal,
|
||||
setGoal,
|
||||
} from 'src/services/goal/goalState.js';
|
||||
import { persistCurrentGoal, persistGoalClear } from 'src/services/goal/goalStorage.js';
|
||||
import type { LocalJSXCommandOnDone } from 'src/types/command.js';
|
||||
import { removeByFilter } from 'src/utils/messageQueueManager.js';
|
||||
import { GoalReplaceConfirmDialog } from './GoalReplaceConfirmDialog.js';
|
||||
|
||||
const MAX_OBJECTIVE_CHARS = 4000;
|
||||
const MAX_DISPLAY_CHARS = 80;
|
||||
|
||||
function truncateForDisplay(objective: string): string {
|
||||
const firstLine = objective.split('\n')[0] ?? objective;
|
||||
if (firstLine.length <= MAX_DISPLAY_CHARS) return firstLine;
|
||||
return firstLine.slice(0, MAX_DISPLAY_CHARS) + '…';
|
||||
}
|
||||
|
||||
function drainGoalContinuationQueue(): void {
|
||||
removeByFilter(cmd => cmd.origin === 'goal-continuation' || cmd.origin === 'goal-budget-limit');
|
||||
}
|
||||
|
||||
function formatGoalStatus(): string {
|
||||
const goal = getGoal();
|
||||
if (!goal) {
|
||||
return 'No active goal. Set one with `/goal <objective>`.';
|
||||
}
|
||||
const tokens = goal.tokenBudget !== null ? `${goal.tokensUsed} / ${goal.tokenBudget}` : `${goal.tokensUsed}`;
|
||||
const lines = [
|
||||
`Goal: ${goal.objective}`,
|
||||
`Status: ${formatGoalStatusLabel(goal.status)}`,
|
||||
`Time: ${formatGoalElapsed(goal)}`,
|
||||
`Tokens: ${tokens}`,
|
||||
`Continuation turns: ${goal.turnsExecuted}`,
|
||||
];
|
||||
|
||||
if (goal.status === 'max_turns') {
|
||||
lines.push(
|
||||
`Hint: Max continuation turns reached (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset and continue.`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function applySetGoal(objective: string): string {
|
||||
setGoal(objective);
|
||||
incrementGoalTurns();
|
||||
persistCurrentGoal();
|
||||
return 'Goal set.';
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const trimmed = args.trim();
|
||||
|
||||
if (!trimmed || trimmed.toLowerCase() === 'status') {
|
||||
onDone(formatGoalStatus(), { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (lower === 'clear') {
|
||||
const cleared = clearGoal();
|
||||
if (cleared) {
|
||||
persistGoalClear();
|
||||
drainGoalContinuationQueue();
|
||||
}
|
||||
onDone(cleared ? 'Goal cleared.' : 'No active goal to clear.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lower === 'pause') {
|
||||
const g = pauseGoal();
|
||||
if (g) {
|
||||
persistCurrentGoal();
|
||||
drainGoalContinuationQueue();
|
||||
}
|
||||
onDone(g ? 'Goal paused.' : 'No active goal to pause.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lower === 'resume') {
|
||||
const current = getGoal();
|
||||
if (current?.status === 'max_turns') {
|
||||
onDone(
|
||||
`Goal reached max continuation turns (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset turn counter and continue.`,
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const g = resumeGoal();
|
||||
if (g) persistCurrentGoal();
|
||||
onDone(g ? 'Goal resumed.' : 'No paused goal to resume.', {
|
||||
display: 'system',
|
||||
shouldQuery: Boolean(g),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lower === 'continue') {
|
||||
const g = continueGoalFromMaxTurns();
|
||||
if (g) persistCurrentGoal();
|
||||
onDone(
|
||||
g
|
||||
? `Goal continuation counter reset (0/${MAX_GOAL_TURNS}). Continuing...`
|
||||
: 'Current goal is not in max-turns state.',
|
||||
{
|
||||
display: 'system',
|
||||
shouldQuery: Boolean(g),
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lower === 'complete') {
|
||||
const g = completeGoal();
|
||||
if (g) {
|
||||
persistCurrentGoal();
|
||||
drainGoalContinuationQueue();
|
||||
}
|
||||
onDone(g ? 'Goal marked complete.' : 'No active goal to complete.', {
|
||||
display: 'system',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_OBJECTIVE_CHARS) {
|
||||
onDone(
|
||||
`Goal objective is too long (${trimmed.length} chars; limit ${MAX_OBJECTIVE_CHARS}). Save the detailed instructions to a file and reference it from a shorter objective.`,
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = getGoal();
|
||||
const needsConfirmation = existing && existing.status !== 'complete';
|
||||
|
||||
if (!needsConfirmation) {
|
||||
const summary = applySetGoal(trimmed);
|
||||
onDone(summary, {
|
||||
display: 'system',
|
||||
shouldQuery: true,
|
||||
displayArgs: truncateForDisplay(trimmed),
|
||||
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GoalReplaceConfirmDialog
|
||||
currentGoal={existing}
|
||||
newObjective={trimmed}
|
||||
onConfirm={() => {
|
||||
drainGoalContinuationQueue();
|
||||
const summary = applySetGoal(trimmed);
|
||||
onDone(summary, {
|
||||
display: 'system',
|
||||
shouldQuery: true,
|
||||
displayArgs: truncateForDisplay(trimmed),
|
||||
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
onDone('Kept the current goal. New objective discarded.', {
|
||||
display: 'system',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
src/commands/goal/index.ts
Normal file
13
src/commands/goal/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Command } from 'src/commands.js'
|
||||
|
||||
const goal = {
|
||||
type: 'local-jsx',
|
||||
name: 'goal',
|
||||
description:
|
||||
'Set or view a persistent goal that drives auto-continuation across turns',
|
||||
argumentHint: '[<objective> | status | clear | pause | resume | complete]',
|
||||
bridgeSafe: false,
|
||||
load: () => import('./goal.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default goal
|
||||
13
src/commands/mode/index.ts
Normal file
13
src/commands/mode/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const mode = {
|
||||
type: 'local-jsx',
|
||||
name: 'mode',
|
||||
description:
|
||||
'Switch interaction mode (default, gentle, sharp, workhorse, token-saver, super-ai)',
|
||||
isEnabled: () => true,
|
||||
argumentHint: '<mode-slug>',
|
||||
load: () => import('./mode.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default mode
|
||||
79
src/commands/mode/mode.tsx
Normal file
79
src/commands/mode/mode.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Select } from '../../components/CustomSelect/select.js';
|
||||
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getCurrentModeSlug, listModes, setCurrentMode } from '../../modes/store.js';
|
||||
|
||||
function ModePicker({ onDone }: { onDone: LocalJSXCommandOnDone }) {
|
||||
const modes = listModes();
|
||||
const currentSlug = getCurrentModeSlug();
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
modes.map(m => ({
|
||||
label: (
|
||||
<Text>
|
||||
{m.icon} {m.name}{' '}
|
||||
<Text dimColor>
|
||||
({m.slug}) — {m.description}
|
||||
</Text>
|
||||
</Text>
|
||||
),
|
||||
value: m.slug,
|
||||
})),
|
||||
[modes],
|
||||
);
|
||||
|
||||
function handleSelect(slug: string) {
|
||||
setCurrentMode(slug);
|
||||
const target = modes.find(m => m.slug === slug);
|
||||
onDone(`${target?.icon} Mode switched to: ${target?.name} (${target?.slug}) — ${target?.description}`, {
|
||||
display: 'system',
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
onDone('Mode selection cancelled.', { display: 'system' });
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color="remember" bold>
|
||||
Select mode
|
||||
</Text>
|
||||
<Text dimColor>Arrow keys to navigate, Enter to select, Esc to cancel.</Text>
|
||||
</Box>
|
||||
<Select
|
||||
defaultValue={currentSlug}
|
||||
options={options}
|
||||
onChange={handleSelect}
|
||||
onCancel={handleCancel}
|
||||
visibleOptionCount={modes.length}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||
const slug = args?.trim().toLowerCase();
|
||||
|
||||
if (slug) {
|
||||
const modes = listModes();
|
||||
const target = modes.find(m => m.slug === slug);
|
||||
if (!target) {
|
||||
const available = modes.map(m => `${m.icon} ${m.slug} — ${m.description}`).join('\n');
|
||||
onDone(`Unknown mode: "${slug}"\n\nAvailable modes:\n${available}`, {
|
||||
display: 'system',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCurrentMode(slug);
|
||||
onDone(`${target.icon} Mode switched to: ${target.name} (${target.slug}) — ${target.description}`, {
|
||||
display: 'system',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return <ModePicker onDone={onDone} />;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
|
||||
import { openBrowser } from '../utils/browser.js';
|
||||
import { logError } from '../utils/log.js';
|
||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
|
||||
import { CHINA_LLM_PROVIDERS, type ProviderPreset, resolveChinaProviderBaseURL } from 'src/utils/chinaLlmProviders.js';
|
||||
import { Select } from './CustomSelect/select.js';
|
||||
import { Spinner } from './Spinner.js';
|
||||
import TextInput from './TextInput.js';
|
||||
@@ -65,6 +66,10 @@ type OAuthStatus =
|
||||
opusModel: string;
|
||||
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
|
||||
} // Gemini Generate Content API platform
|
||||
| { state: 'china_provider_select'; activeIndex: number } // China LLM: pick provider
|
||||
| { state: 'china_mode_select'; provider: ProviderPreset; activeIndex: number } // China LLM: pick access mode
|
||||
| { state: 'china_model_select'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; activeIndex: number } // China LLM: pick model
|
||||
| { state: 'china_apikey'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; modelId: string; apiKey: string } // China LLM: enter API key
|
||||
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
|
||||
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
|
||||
| { state: 'creating_api_key' } // Got access token, creating API key
|
||||
@@ -272,7 +277,9 @@ export function ConsoleOAuthFlow({
|
||||
throw new Error((orgResult as { valid: false; message: string }).message);
|
||||
}
|
||||
// Reset modelType to anthropic when using OAuth login
|
||||
updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any);
|
||||
updateSettingsForSource('userSettings', { modelType: 'anthropic' } as unknown as Parameters<
|
||||
typeof updateSettingsForSource
|
||||
>[1]);
|
||||
|
||||
setOAuthStatus({ state: 'success' });
|
||||
void sendNotification(
|
||||
@@ -455,6 +462,15 @@ function OAuthStatusMessage({
|
||||
),
|
||||
value: 'openai_chat_api',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
China LLM Providers · <Text dimColor>DeepSeek, Zhipu GLM, Qwen, MiMo</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'china_providers',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
@@ -534,6 +550,9 @@ function OAuthStatusMessage({
|
||||
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
|
||||
activeField: 'base_url',
|
||||
});
|
||||
} else if (value === 'china_providers') {
|
||||
logEvent('tengu_china_providers_selected', {});
|
||||
setOAuthStatus({ state: 'china_provider_select', activeIndex: 0 });
|
||||
} else if (value === 'chatgpt_subscription') {
|
||||
logEvent('tengu_chatgpt_subscription_selected', {});
|
||||
setOAuthStatus({
|
||||
@@ -662,9 +681,9 @@ function OAuthStatusMessage({
|
||||
if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model;
|
||||
if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model;
|
||||
const { error } = updateSettingsForSource('userSettings', {
|
||||
modelType: 'anthropic' as any,
|
||||
modelType: 'anthropic',
|
||||
env,
|
||||
} as any);
|
||||
} as unknown as Parameters<typeof updateSettingsForSource>[1]);
|
||||
if (error) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
@@ -1153,9 +1172,9 @@ function OAuthStatusMessage({
|
||||
if (finalVals.sonnet_model) env.GEMINI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model;
|
||||
if (finalVals.opus_model) env.GEMINI_DEFAULT_OPUS_MODEL = finalVals.opus_model;
|
||||
const { error } = updateSettingsForSource('userSettings', {
|
||||
modelType: 'gemini' as any,
|
||||
modelType: 'gemini',
|
||||
env,
|
||||
} as any);
|
||||
} as unknown as Parameters<typeof updateSettingsForSource>[1]);
|
||||
if (error) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
@@ -1272,6 +1291,274 @@ function OAuthStatusMessage({
|
||||
);
|
||||
}
|
||||
|
||||
case 'china_provider_select': {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>Select China LLM Provider</Text>
|
||||
<Text dimColor>Direct connection, no proxy needed. All providers are OpenAI-compatible.</Text>
|
||||
<Box>
|
||||
<Select
|
||||
options={CHINA_LLM_PROVIDERS.map(p => ({
|
||||
label: (
|
||||
<Text>
|
||||
{p.icon} {p.label} · <Text dimColor>{p.description}</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: p.id,
|
||||
}))}
|
||||
onChange={value => {
|
||||
const provider = CHINA_LLM_PROVIDERS.find(p => p.id === value);
|
||||
if (!provider) return;
|
||||
logEvent('tengu_china_provider_selected', {});
|
||||
if (provider.codingPlan) {
|
||||
setOAuthStatus({ state: 'china_mode_select', provider, activeIndex: 0 });
|
||||
} else {
|
||||
setOAuthStatus({ state: 'china_model_select', provider, mode: 'api', activeIndex: 0 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'china_mode_select': {
|
||||
const { provider } = oauthStatus;
|
||||
const modeOptions = [
|
||||
{ id: 'api' as const, label: 'Pay-as-you-go (API)', desc: 'Top up freely, pay per use' },
|
||||
{ id: 'coding-plan' as const, label: 'Coding Plan', desc: 'Fixed monthly fee, high usage' },
|
||||
];
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>
|
||||
{provider.icon} {provider.label} — Select Access Mode
|
||||
</Text>
|
||||
<Box>
|
||||
<Select
|
||||
options={modeOptions.map(m => ({
|
||||
label: (
|
||||
<Text>
|
||||
{m.label} · <Text dimColor>{m.desc}</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: m.id,
|
||||
}))}
|
||||
onChange={value => {
|
||||
logEvent('tengu_china_mode_selected', {});
|
||||
setOAuthStatus({
|
||||
state: 'china_model_select',
|
||||
provider,
|
||||
mode: value as 'api' | 'coding-plan',
|
||||
activeIndex: 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Text dimColor>
|
||||
No plan? Select "Pay-as-you-go"
|
||||
{provider.id === 'zhipu' ? ' · GLM-4.7-Flash is free forever' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'china_model_select': {
|
||||
const { provider, mode: accessMode } = oauthStatus;
|
||||
const models = provider.models;
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>
|
||||
{provider.icon} {provider.label} — Select Model
|
||||
</Text>
|
||||
<Box>
|
||||
<Select
|
||||
options={[
|
||||
...models.map(m => {
|
||||
const priceLabel =
|
||||
m.inputPricePerMTok === 0 && m.outputPricePerMTok === 0
|
||||
? 'Free'
|
||||
: `¥${m.inputPricePerMTok}/¥${m.outputPricePerMTok}`;
|
||||
const tagLabel = m.tags?.length ? ` [${m.tags.join(', ')}]` : '';
|
||||
return {
|
||||
label: (
|
||||
<Text>
|
||||
{m.label} ·{' '}
|
||||
<Text dimColor>
|
||||
{priceLabel} · {m.contextWindow}
|
||||
{tagLabel}
|
||||
</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: m.id,
|
||||
};
|
||||
}),
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
✏️ Custom model
|
||||
<Text dimColor> · enter model name manually</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: '__custom__',
|
||||
},
|
||||
]}
|
||||
onChange={value => {
|
||||
logEvent('tengu_china_model_selected', {});
|
||||
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: value, apiKey: '' });
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'china_apikey': {
|
||||
const { provider, mode: accessMode, modelId } = oauthStatus;
|
||||
|
||||
const [chinaKeyValue, setChinaKeyValue] = useState('');
|
||||
const [chinaKeyCursor, setChinaKeyCursor] = useState(0);
|
||||
const [chinaKeyError, setChinaKeyError] = useState<string | null>(null);
|
||||
|
||||
const doChinaSave = useCallback(() => {
|
||||
const effectiveModelId = modelId === '__custom__' ? chinaKeyValue.trim() : modelId;
|
||||
if (!effectiveModelId) {
|
||||
setChinaKeyError(modelId === '__custom__' ? 'Please enter a model name' : 'Please enter an API key');
|
||||
return;
|
||||
}
|
||||
if (modelId === '__custom__') {
|
||||
logEvent('tengu_china_custom_model_entered', {});
|
||||
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: effectiveModelId, apiKey: '' });
|
||||
setChinaKeyValue('');
|
||||
setChinaKeyError(null);
|
||||
return;
|
||||
}
|
||||
if (!chinaKeyValue.trim()) {
|
||||
setChinaKeyError('Please enter an API key');
|
||||
return;
|
||||
}
|
||||
const baseUrl = resolveChinaProviderBaseURL(provider.id, accessMode);
|
||||
const env: Record<string, string | undefined> = {
|
||||
OPENAI_AUTH_MODE: undefined,
|
||||
OPENAI_BASE_URL: baseUrl,
|
||||
OPENAI_API_KEY: chinaKeyValue.trim(),
|
||||
OPENAI_DEFAULT_SONNET_MODEL: modelId,
|
||||
OPENAI_DEFAULT_HAIKU_MODEL: modelId,
|
||||
OPENAI_DEFAULT_OPUS_MODEL: modelId,
|
||||
};
|
||||
const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
|
||||
modelType: 'openai',
|
||||
env: env as unknown as Record<string, string>,
|
||||
};
|
||||
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
|
||||
if (error) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: 'Failed to save settings. Please try again.',
|
||||
toRetry: { state: 'china_apikey', provider, mode: accessMode, modelId, apiKey: chinaKeyValue },
|
||||
});
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(env)) {
|
||||
if (v === undefined) {
|
||||
delete process.env[k];
|
||||
} else {
|
||||
process.env[k] = v;
|
||||
}
|
||||
}
|
||||
logEvent('tengu_china_login_success', {});
|
||||
setOAuthStatus({ state: 'success' });
|
||||
void onDone();
|
||||
}
|
||||
}, [chinaKeyValue, provider, accessMode, modelId, onDone, setOAuthStatus]);
|
||||
|
||||
useKeybinding(
|
||||
'confirm:no',
|
||||
() => {
|
||||
setOAuthStatus({ state: 'china_model_select', provider, mode: accessMode, activeIndex: 0 });
|
||||
},
|
||||
{ context: 'Confirmation' },
|
||||
);
|
||||
|
||||
const isCustomModelEntry = modelId === '__custom__';
|
||||
const allModels = CHINA_LLM_PROVIDERS.flatMap(p =>
|
||||
p.models.map(m => ({ id: m.id, label: m.label, provider: p.label })),
|
||||
);
|
||||
const modelSuggestions = isCustomModelEntry
|
||||
? chinaKeyValue.trim()
|
||||
? allModels.filter(m => m.id.toLowerCase().includes(chinaKeyValue.trim().toLowerCase()))
|
||||
: allModels
|
||||
: [];
|
||||
const keyPage = isCustomModelEntry
|
||||
? provider.apiKeyPage
|
||||
: accessMode === 'coding-plan' && provider.codingPlan
|
||||
? provider.codingPlan.purchasePage
|
||||
: provider.apiKeyPage;
|
||||
const keyFormat = isCustomModelEntry
|
||||
? provider.keyFormat
|
||||
: accessMode === 'coding-plan' && provider.codingPlan
|
||||
? provider.codingPlan.keyFormat
|
||||
: provider.keyFormat;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>
|
||||
{provider.icon} {provider.label} {isCustomModelEntry ? '— Custom Model' : 'API Key'}
|
||||
</Text>
|
||||
<Box flexDirection="column" gap={0}>
|
||||
{isCustomModelEntry ? (
|
||||
<Text dimColor> Enter any model ID supported by this provider. Browse models: {provider.modelsPage}</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text dimColor> Get your key: {keyPage}</Text>
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
{accessMode === 'coding-plan' ? 'Use your Coding Plan credential here' : provider.freeTier}
|
||||
</Text>
|
||||
<Text dimColor> Key format: {keyFormat}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{isCustomModelEntry ? 'Model name: ' : 'API Key: '}</Text>
|
||||
<TextInput
|
||||
value={chinaKeyValue}
|
||||
onChange={v => {
|
||||
setChinaKeyValue(v);
|
||||
setChinaKeyError(null);
|
||||
}}
|
||||
onSubmit={doChinaSave}
|
||||
cursorOffset={chinaKeyCursor}
|
||||
onChangeCursorOffset={setChinaKeyCursor}
|
||||
columns={useTerminalSize().columns - 12}
|
||||
mask={isCustomModelEntry ? undefined : '*'}
|
||||
focus={true}
|
||||
/>
|
||||
</Box>
|
||||
{chinaKeyError ? <Text color="error">{chinaKeyError}</Text> : null}
|
||||
{isCustomModelEntry && modelSuggestions.length > 0 && (
|
||||
<Box flexDirection="column" gap={0}>
|
||||
<Text dimColor>{chinaKeyValue.trim() ? 'Matching models:' : 'Known models:'}</Text>
|
||||
{modelSuggestions.map(m => (
|
||||
<Text key={m.id} dimColor>
|
||||
{' '}
|
||||
{m.id}{' '}
|
||||
<Text>
|
||||
({m.label} — {m.provider})
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Text dimColor>
|
||||
{isCustomModelEntry ? 'Enter to continue · Esc to go back' : 'Enter to confirm · Esc to go back'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
case 'platform_setup':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
|
||||
408
src/components/EffortPanel/EffortPanel.tsx
Normal file
408
src/components/EffortPanel/EffortPanel.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import * as React from 'react';
|
||||
import { BaseText, Box, Text, useTerminalSize } from '@anthropic/ink';
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js';
|
||||
import { type EffortValue, getDisplayedEffortLevel, getEffortEnvOverride } from '../../utils/effort.js';
|
||||
import {
|
||||
type PanelPosition,
|
||||
CANCEL_MESSAGE,
|
||||
computeConfirmOutcome,
|
||||
getInitialCursor,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
PANEL_POSITIONS,
|
||||
} from './effortPanelState.js';
|
||||
import { executeEffort } from '../../commands/effort/effort.js';
|
||||
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
||||
import { useSetAppState } from '../../state/AppState.js';
|
||||
import { useRippleFrame } from './useRippleFrame.js';
|
||||
import {
|
||||
TRANSPARENT,
|
||||
type Overlay,
|
||||
type Segment,
|
||||
applyOverlaysToCells,
|
||||
cellsToSegments,
|
||||
computeRippleCells,
|
||||
fadeCells,
|
||||
getHueShiftAtTime,
|
||||
rotateHue,
|
||||
} from './rippleAnimation.js';
|
||||
|
||||
/**
|
||||
* 每档最小宽度(足够装下 'ultracode' 9 字符 + 居中留白)。
|
||||
* 当终端窄时使用此值,保证最低可读性。
|
||||
*/
|
||||
const MIN_SEGMENT = 12;
|
||||
|
||||
const SUBLABEL_ULTRACODE = 'xhigh + workflows';
|
||||
|
||||
// 颜色:与项目主题对齐(suggestion=Medium blue #5769F7)。
|
||||
const COLOR_LABEL_SELECTED = '#5769F7'; // 选中档位(suggestion)
|
||||
const COLOR_LABEL_DEFAULT = '#7a8eff'; // 未选中档位(淡紫蓝,与波纹背景协调)
|
||||
const COLOR_OVERLAY = '#5769F7'; // Faster / Smarter / ▲ 等 overlay 文字
|
||||
|
||||
// 淡入淡出每帧步长:60ms 间隔下 5 帧达到目标 ≈ 300ms 动画时长。
|
||||
const FADE_STEP = 0.2;
|
||||
|
||||
// 波纹震源 y 坐标(相对波纹区域坐标系,y=0 是档位名行)。
|
||||
const RIPPLE_SOURCE_Y = 0;
|
||||
|
||||
/**
|
||||
* 根据终端宽度计算每档实际宽度(SEGMENT)。
|
||||
*
|
||||
* 规则:
|
||||
* - 留出 paddingX={1} 的左右各 1 列 → 可用宽度 = columns - 2
|
||||
* - 若可用宽度 <= MIN_SEGMENT * 6(72),用 MIN_SEGMENT(保持当前窄布局)
|
||||
* - 否则铺满:floor(可用宽度 / 6)
|
||||
*
|
||||
* 即"窄则不变,宽则铺满"。最小宽度保证 'ultracode' 9 字符能正常显示。
|
||||
*/
|
||||
function computeSegment(terminalColumns: number): number {
|
||||
const available = terminalColumns - 2; // paddingX={1} 两侧
|
||||
const minNeeded = MIN_SEGMENT * PANEL_POSITIONS.length;
|
||||
if (available <= minNeeded) return MIN_SEGMENT;
|
||||
return Math.floor(available / PANEL_POSITIONS.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算波纹震源 x 坐标(ultracode 段内 'ultracode' 标签的中心列)。
|
||||
*
|
||||
* 'ultracode' 是 9 字符,在 SEGMENT 列内居中:
|
||||
* offset = floor((SEGMENT - 9) / 2)
|
||||
* labelCenter = SEGMENT * 5 + offset + 4 (4 是 9 字符串的中心偏移)
|
||||
*
|
||||
* SEGMENT=12 → 60 + 1 + 4 = 65(与历史值一致)
|
||||
* SEGMENT=20 → 100 + 5 + 4 = 109
|
||||
*/
|
||||
function computeRippleSourceX(segment: number): number {
|
||||
const LABEL_LEN = 9; // 'ultracode'
|
||||
const offset = Math.max(0, Math.floor((segment - LABEL_LEN) / 2));
|
||||
const labelCenter = Math.floor(LABEL_LEN / 2); // 4
|
||||
return segment * (PANEL_POSITIONS.length - 1) + offset + labelCenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算某段 idx 内居中文字的起始列。
|
||||
* 动态 segment:textLen 字符在 segment 列内居中。
|
||||
*/
|
||||
function segmentTextStartX(idx: number, textLen: number, segment: number): number {
|
||||
return segment * idx + Math.max(0, Math.floor((segment - textLen) / 2));
|
||||
}
|
||||
|
||||
type Props = {
|
||||
appStateEffort: EffortValue | undefined;
|
||||
onDone: (message: string) => void;
|
||||
};
|
||||
|
||||
export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode {
|
||||
const setAppState = useSetAppState();
|
||||
const model = useMainLoopModel();
|
||||
const { columns } = useTerminalSize();
|
||||
|
||||
// 自适应宽度:根据终端列数计算每档宽度。
|
||||
// 终端变化(resize)时 columns 改变 → 重新计算 → 重渲染。
|
||||
const segment = React.useMemo(() => computeSegment(columns), [columns]);
|
||||
const panelWidth = segment * PANEL_POSITIONS.length;
|
||||
const rippleSourceX = React.useMemo(() => computeRippleSourceX(segment), [segment]);
|
||||
|
||||
const envOverride = getEffortEnvOverride();
|
||||
const displayed = getDisplayedEffortLevel(model, appStateEffort);
|
||||
const initialCursor = getInitialCursor({ envOverride, appStateEffort, displayed });
|
||||
|
||||
const [cursor, setCursor] = React.useState<PanelPosition>(initialCursor);
|
||||
const [done, setDone] = React.useState(false);
|
||||
|
||||
const isOnUltracode = cursor === 'ultracode';
|
||||
const [fade, setFade] = React.useState(0);
|
||||
// 仍在波纹模式:cursor 在 ultracode,或退出动画未结束(fade > 0)
|
||||
const showingRipple = isOnUltracode || fade > 0.001;
|
||||
const [rippleRef, time] = useRippleFrame(showingRipple);
|
||||
|
||||
// 淡入淡出驱动:每 tick(time 推进)朝目标步进 FADE_STEP。
|
||||
// 退出动画完成后 fade 归零,showingRipple 变 false,时钟停止订阅。
|
||||
React.useEffect(() => {
|
||||
if (!showingRipple) return;
|
||||
const target = isOnUltracode ? 1 : 0;
|
||||
setFade(prev => {
|
||||
if (prev === target) return prev;
|
||||
const next = target > prev ? prev + FADE_STEP : prev - FADE_STEP;
|
||||
return target > prev ? Math.min(target, next) : Math.max(target, next);
|
||||
});
|
||||
}, [time, isOnUltracode, showingRipple]);
|
||||
|
||||
const handleConfirm = React.useCallback(() => {
|
||||
if (done) return;
|
||||
setDone(true);
|
||||
const outcome = computeConfirmOutcome(cursor, executeEffort);
|
||||
if (outcome.kind === 'apply' && outcome.effortUpdate) {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
effortValue: outcome.effortUpdate!.value,
|
||||
}));
|
||||
}
|
||||
onDone(outcome.message);
|
||||
}, [cursor, done, onDone, setAppState]);
|
||||
|
||||
const handleCancel = React.useCallback(() => {
|
||||
if (done) return;
|
||||
setDone(true);
|
||||
onDone(CANCEL_MESSAGE);
|
||||
}, [done, onDone]);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'effortPanel:decrease': () => setCursor(c => moveLeft(c)),
|
||||
'effortPanel:increase': () => setCursor(c => moveRight(c)),
|
||||
'effortPanel:home': () => setCursor('low'),
|
||||
'effortPanel:end': () => setCursor('ultracode'),
|
||||
'effortPanel:confirm': handleConfirm,
|
||||
'effortPanel:cancel': handleCancel,
|
||||
},
|
||||
{ context: 'EffortPanel' },
|
||||
);
|
||||
|
||||
const envActive = envOverride !== null && envOverride !== undefined;
|
||||
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
|
||||
|
||||
// 波纹行 cells 计算:返回该行所有 cell(含 overlay 文字)
|
||||
// fade 控制背景颜色亮度(0 → 全 transparent,1 → 完整波纹)。
|
||||
// 文字 overlay 也乘以 fade,让进入/退出动画整体淡入淡出。
|
||||
const renderRippleRow = React.useCallback(
|
||||
(relY: number, overlays: Overlay[]): Segment[] => {
|
||||
const cells = computeRippleCells({
|
||||
y: relY + RIPPLE_SOURCE_Y,
|
||||
width: panelWidth,
|
||||
time,
|
||||
sourceX: rippleSourceX,
|
||||
sourceY: RIPPLE_SOURCE_Y,
|
||||
});
|
||||
const overlayed = applyOverlaysToCells(cells, overlays);
|
||||
const faded = fadeCells(overlayed, fade);
|
||||
return cellsToSegments(faded);
|
||||
},
|
||||
[time, fade, panelWidth, rippleSourceX],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box ref={rippleRef} flexDirection="column" paddingX={1} width={panelWidth + 2}>
|
||||
<Text bold color="suggestion">
|
||||
Effort
|
||||
</Text>
|
||||
{envActive && <Text color="warning">{`⚠ CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session`}</Text>}
|
||||
{showingRipple ? (
|
||||
<RippleContent
|
||||
renderRow={renderRippleRow}
|
||||
cursor={cursor}
|
||||
fade={fade}
|
||||
segment={segment}
|
||||
panelWidth={panelWidth}
|
||||
time={time}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PlainContent cursor={cursor} segment={segment} panelWidth={panelWidth} />
|
||||
<Box marginTop={1}>
|
||||
<Text color="subtle">←/→ adjust · Enter confirm · Esc cancel</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 普通模式(无波纹)----
|
||||
|
||||
function PlainContent({
|
||||
cursor,
|
||||
segment,
|
||||
panelWidth,
|
||||
}: {
|
||||
cursor: PanelPosition;
|
||||
segment: number;
|
||||
panelWidth: number;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Box marginTop={1} flexDirection="row" justifyContent="space-between">
|
||||
<Text color="suggestion">Faster</Text>
|
||||
<Text color="suggestion">Smarter</Text>
|
||||
</Box>
|
||||
<Text color="subtle">{'─'.repeat(panelWidth)}</Text>
|
||||
<Box flexDirection="row">
|
||||
{PANEL_POSITIONS.map(p => (
|
||||
<Box key={`cursor-${p}`} width={segment} justifyContent="center">
|
||||
<Text bold color={cursor === p ? 'suggestion' : 'subtle'}>
|
||||
{cursor === p ? '▲' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
{PANEL_POSITIONS.map(p => (
|
||||
<Box key={`label-${p}`} width={segment} justifyContent="center">
|
||||
<Text bold={cursor === p} color={cursor === p ? 'suggestion' : 'subtle'}>
|
||||
{p}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width={segment * (PANEL_POSITIONS.length - 1)} />
|
||||
<Box width={segment} justifyContent="center">
|
||||
<Text color="subtle">{SUBLABEL_ULTRACODE}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 波纹模式(cursor === 'ultracode')----
|
||||
//
|
||||
// 渲染策略:
|
||||
// - 每行先 computeRippleCells 算出强度→颜色的 cell 数组(背景为空格 + 颜色)
|
||||
// - applyOverlaysToCells 把文字 overlay(Faster/▲/档位名/副标签)写入对应 cell
|
||||
// - cellsToSegments 合并相邻同色段
|
||||
// - 渲染层遍历 segments:每个段判断是"空格波纹段"还是"文字段"
|
||||
// - 空格段:用 backgroundColor 把空格染成色块(pure color block)
|
||||
// - 文字段:用 color 染色文字(背景保持终端默认,让文字最清晰)
|
||||
// - 混合段(既有空格又有文字,少见):拆为前后两个 Text
|
||||
//
|
||||
// 注意:Segment 内可能同时有空格和非空格字符(如 " Faster " 居中文字)。
|
||||
// 这种段用 color 渲染时,空格部分不显示色块——视觉上"色块断裂"。
|
||||
// 解决:渲染时把 segment 按字符类型二次拆分(runs of whitespace vs non-whitespace)。
|
||||
|
||||
type RippleContentProps = {
|
||||
renderRow: (relY: number, overlays: Overlay[]) => Segment[];
|
||||
cursor: PanelPosition;
|
||||
fade: number;
|
||||
segment: number;
|
||||
panelWidth: number;
|
||||
time: number;
|
||||
};
|
||||
|
||||
function RippleContent({ renderRow, cursor, segment, panelWidth, time }: RippleContentProps): React.ReactNode {
|
||||
// 光标索引跟随 cursor(退出动画期间 cursor 已移到别处,
|
||||
// 让 ▲ overlay 跟着移走,ultracode 段恢复普通背景色)。
|
||||
const cursorIdx = PANEL_POSITIONS.indexOf(cursor);
|
||||
// 副标签固定在 ultracode 段下方,不跟随光标移动。
|
||||
const ultracodeIdx = PANEL_POSITIONS.length - 1;
|
||||
|
||||
// 文字颜色跟随波浪色相旋转:取当前 time 的 hueShift,
|
||||
// 应用到所有 overlay 颜色,让文字与背景色环保持同步。
|
||||
const hueShift = getHueShiftAtTime(time);
|
||||
const overlayColor = rotateHue(COLOR_OVERLAY, hueShift);
|
||||
const labelSelectedColor = rotateHue(COLOR_LABEL_SELECTED, hueShift);
|
||||
const labelDefaultColor = rotateHue(COLOR_LABEL_DEFAULT, hueShift);
|
||||
|
||||
const fasterOverlay: Overlay = { text: 'Faster', x: 0, color: overlayColor };
|
||||
const smarterOverlay: Overlay = {
|
||||
text: 'Smarter',
|
||||
x: panelWidth - 'Smarter'.length,
|
||||
color: overlayColor,
|
||||
};
|
||||
const separatorOverlay: Overlay = {
|
||||
text: '─'.repeat(panelWidth),
|
||||
x: 0,
|
||||
color: labelDefaultColor,
|
||||
};
|
||||
const cursorOverlay: Overlay = {
|
||||
text: '▲',
|
||||
x: segmentTextStartX(cursorIdx, 1, segment),
|
||||
color: overlayColor,
|
||||
};
|
||||
const labelOverlays: Overlay[] = PANEL_POSITIONS.map((p, idx) => ({
|
||||
text: p,
|
||||
x: segmentTextStartX(idx, p.length, segment),
|
||||
color: p === cursor ? labelSelectedColor : labelDefaultColor,
|
||||
}));
|
||||
const sublabelOverlay: Overlay = {
|
||||
text: SUBLABEL_ULTRACODE,
|
||||
x: segmentTextStartX(ultracodeIdx, SUBLABEL_ULTRACODE.length, segment),
|
||||
color: labelDefaultColor,
|
||||
};
|
||||
|
||||
// 各行 y 坐标(相对震源 RIPPLE_SOURCE_Y = 档位名行)
|
||||
// y=-4: 顶部纯波纹行(视觉一致,无 overlay)
|
||||
// y=-3: Faster/Smarter
|
||||
// y=-2: 分隔线
|
||||
// y=-1: ▲
|
||||
// y=0: 档位名(震源)
|
||||
// y=1: 副标签
|
||||
// y=2: 底部纯波纹行(视觉一致,无 overlay)
|
||||
//
|
||||
// 快捷键行:plain Text,不参与波纹渲染(无背景动画),紧贴底部波纹行。
|
||||
return (
|
||||
<>
|
||||
<RippleRow segments={renderRow(-4, [])} />
|
||||
<RippleRow segments={renderRow(-3, [fasterOverlay, smarterOverlay])} />
|
||||
<RippleRow segments={renderRow(-2, [separatorOverlay])} />
|
||||
<RippleRow segments={renderRow(-1, [cursorOverlay])} />
|
||||
<RippleRow segments={renderRow(0, labelOverlays)} />
|
||||
<RippleRow segments={renderRow(1, [sublabelOverlay])} />
|
||||
<RippleRow segments={renderRow(2, [])} />
|
||||
<Text color={COLOR_LABEL_DEFAULT}>←/→ adjust · Enter confirm · Esc cancel</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一行波纹 segments。
|
||||
*
|
||||
* 每个 segment 可能含空格 + 文字混合(如 " Faster "):
|
||||
* - 空格部分用 backgroundColor 染色块(波纹颜色)
|
||||
* - 文字部分用 color 染色(亮色,背景保持终端默认)
|
||||
*
|
||||
* 简化策略:遍历 segment 字符,按"是否为空格"二次拆分为 token。
|
||||
* 相邻同类型 token 合并,避免 React key 爆炸。
|
||||
*/
|
||||
function RippleRow({ segments }: { segments: Segment[] }): React.ReactNode {
|
||||
const tokens: Array<{ text: string; kind: 'space' | 'text'; color: string }> = [];
|
||||
for (const seg of segments) {
|
||||
// 拆分 seg.text 为空格段和非空格段
|
||||
let buf = '';
|
||||
let bufIsSpace: boolean | null = null;
|
||||
const flush = (): void => {
|
||||
if (buf === '' || bufIsSpace === null) return;
|
||||
tokens.push({
|
||||
text: buf,
|
||||
kind: bufIsSpace ? 'space' : 'text',
|
||||
color: seg.color,
|
||||
});
|
||||
buf = '';
|
||||
bufIsSpace = null;
|
||||
};
|
||||
for (const ch of seg.text) {
|
||||
const isSpace = ch === ' ';
|
||||
if (bufIsSpace === null) {
|
||||
buf = ch;
|
||||
bufIsSpace = isSpace;
|
||||
} else if (isSpace === bufIsSpace) {
|
||||
buf += ch;
|
||||
} else {
|
||||
flush();
|
||||
buf = ch;
|
||||
bufIsSpace = isSpace;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{tokens.map((tok, i) =>
|
||||
tok.kind === 'space' ? (
|
||||
tok.color === TRANSPARENT ? (
|
||||
<BaseText key={i}>{tok.text}</BaseText>
|
||||
) : (
|
||||
<BaseText key={i} backgroundColor={tok.color as `#${string}`}>
|
||||
{tok.text}
|
||||
</BaseText>
|
||||
)
|
||||
) : (
|
||||
<Text key={i} color={tok.color as `#${string}`} bold>
|
||||
{tok.text}
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
24
src/components/EffortPanel/__tests__/EffortPanel.test.tsx
Normal file
24
src/components/EffortPanel/__tests__/EffortPanel.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import React from 'react';
|
||||
import { EffortPanel } from '../EffortPanel.js';
|
||||
|
||||
// EffortPanel 是 UI 组件,渲染依赖链(useMainLoopModel / GrowthBook / settings)
|
||||
// 在测试环境模拟成本高且脆化。本文件只做"组件契约"sanity check:
|
||||
// 1) 默认导出为有效 React 组件
|
||||
// 2) 接收正确 props 类型(编译期保证)
|
||||
// 3) onDone 类型为 (message: string) => void
|
||||
//
|
||||
// 渲染输出与键盘交互通过 Step 6.2 手动验收覆盖;
|
||||
// 确认/取消分支通过 computeConfirmOutcome 纯函数测试覆盖(见 effortPanelState.test.ts)。
|
||||
|
||||
test('EffortPanel 是有效 React 组件', () => {
|
||||
expect(typeof EffortPanel).toBe('function');
|
||||
});
|
||||
|
||||
test('EffortPanel 接受 props 并返回 React element(不挂载)', () => {
|
||||
const element = React.createElement(EffortPanel, {
|
||||
appStateEffort: undefined,
|
||||
onDone: () => {},
|
||||
});
|
||||
expect(React.isValidElement(element)).toBe(true);
|
||||
});
|
||||
163
src/components/EffortPanel/__tests__/effortPanelState.test.ts
Normal file
163
src/components/EffortPanel/__tests__/effortPanelState.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { EffortValue } from '../../../utils/effort.js'
|
||||
import {
|
||||
CANCEL_MESSAGE,
|
||||
type ApplyFn,
|
||||
ULTRACODE_HINT,
|
||||
END_POSITION,
|
||||
HOME_POSITION,
|
||||
PANEL_POSITIONS,
|
||||
type PanelPosition,
|
||||
computeConfirmOutcome,
|
||||
getInitialCursor,
|
||||
isUltracode,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
} from '../effortPanelState.js'
|
||||
|
||||
describe('effortPanelState', () => {
|
||||
test('PANEL_POSITIONS 顺序为 low → ultracode', () => {
|
||||
expect(PANEL_POSITIONS).toEqual([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
'ultracode',
|
||||
])
|
||||
})
|
||||
|
||||
test('moveLeft 在 low 处保持 low', () => {
|
||||
expect(moveLeft('low')).toBe('low')
|
||||
})
|
||||
|
||||
test('moveLeft 正常左移', () => {
|
||||
expect(moveLeft('high')).toBe('medium')
|
||||
expect(moveLeft('ultracode')).toBe('max')
|
||||
})
|
||||
|
||||
test('moveRight 在 ultracode 处保持 ultracode', () => {
|
||||
expect(moveRight('ultracode')).toBe('ultracode')
|
||||
})
|
||||
|
||||
test('moveRight 正常右移', () => {
|
||||
expect(moveRight('medium')).toBe('high')
|
||||
expect(moveRight('max')).toBe('ultracode')
|
||||
})
|
||||
|
||||
test('HOME_POSITION 等于 low', () => {
|
||||
expect(HOME_POSITION).toBe('low')
|
||||
})
|
||||
|
||||
test('END_POSITION 等于 ultracode', () => {
|
||||
expect(END_POSITION).toBe('ultracode')
|
||||
})
|
||||
|
||||
test('isUltracode 守卫', () => {
|
||||
expect(isUltracode('ultracode')).toBe(true)
|
||||
expect(isUltracode('max')).toBe(false)
|
||||
})
|
||||
|
||||
test('getInitialCursor:env override 为合法档位时返回 env 值', () => {
|
||||
expect(
|
||||
getInitialCursor({
|
||||
envOverride: 'high',
|
||||
appStateEffort: 'medium',
|
||||
displayed: 'high',
|
||||
}),
|
||||
).toBe('high')
|
||||
})
|
||||
|
||||
test('getInitialCursor:env 为 null(unset)时用 displayed', () => {
|
||||
expect(
|
||||
getInitialCursor({
|
||||
envOverride: null,
|
||||
appStateEffort: undefined,
|
||||
displayed: 'medium',
|
||||
}),
|
||||
).toBe('medium')
|
||||
})
|
||||
|
||||
test('getInitialCursor:env undefined 时用 displayed', () => {
|
||||
expect(
|
||||
getInitialCursor({
|
||||
envOverride: undefined,
|
||||
appStateEffort: 'high',
|
||||
displayed: 'high',
|
||||
}),
|
||||
).toBe('high')
|
||||
})
|
||||
|
||||
test('getInitialCursor:env 是数值(ant-only)时落回 displayed', () => {
|
||||
// 数值不是合法 PanelPosition,回退
|
||||
expect(
|
||||
getInitialCursor({
|
||||
envOverride: 75,
|
||||
appStateEffort: 'medium',
|
||||
displayed: 'medium',
|
||||
}),
|
||||
).toBe('medium')
|
||||
})
|
||||
|
||||
test('PanelPosition 类型编译期检查(隐式)', () => {
|
||||
const p: PanelPosition = 'xhigh'
|
||||
expect(p).toBe('xhigh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeConfirmOutcome', () => {
|
||||
const mockApply: ApplyFn = cursor => ({
|
||||
message: `applied:${cursor}`,
|
||||
// 测试里 cursor 是 PanelPosition(含 ultracode),但 ApplyFn 的契约要求 EffortValue。
|
||||
// 实际运行时 mockApply 只会被 computeConfirmOutcome 在非 ultracode 档位调用,
|
||||
// 因此 cast 是安全的。生产代码用真 executeEffort 不会出现 ultracode。
|
||||
effortUpdate: { value: cursor as unknown as EffortValue },
|
||||
})
|
||||
|
||||
test('ultracode → kind=ultracode-hint,含 /ultracode 引导', () => {
|
||||
const out = computeConfirmOutcome('ultracode', mockApply)
|
||||
expect(out.kind).toBe('ultracode-hint')
|
||||
if (out.kind === 'ultracode-hint') {
|
||||
expect(out.message).toBe(ULTRACODE_HINT)
|
||||
expect(out.message).toContain('/ultracode')
|
||||
}
|
||||
})
|
||||
|
||||
test('ultracode 不调 applyFn(不会被副作用触发)', () => {
|
||||
let called = false
|
||||
const spy: ApplyFn = c => {
|
||||
called = true
|
||||
return { message: `applied:${c}` }
|
||||
}
|
||||
computeConfirmOutcome('ultracode', spy)
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
test('low → kind=apply,message 来自 applyFn,effortUpdate 透传', () => {
|
||||
const out = computeConfirmOutcome('low', mockApply)
|
||||
expect(out.kind).toBe('apply')
|
||||
if (out.kind === 'apply') {
|
||||
expect(out.message).toBe('applied:low')
|
||||
expect(out.effortUpdate?.value).toBe('low')
|
||||
}
|
||||
})
|
||||
|
||||
test('high → apply 路径不调 ultracode 分支', () => {
|
||||
const out = computeConfirmOutcome('high', mockApply)
|
||||
expect(out.kind).toBe('apply')
|
||||
})
|
||||
|
||||
test('applyFn 返回无 effortUpdate 时,outcome.effortUpdate 为 undefined', () => {
|
||||
const noUpdate: ApplyFn = c => ({ message: `applied:${c}` })
|
||||
const out = computeConfirmOutcome('medium', noUpdate)
|
||||
expect(out.kind).toBe('apply')
|
||||
if (out.kind === 'apply') {
|
||||
expect(out.effortUpdate).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('常量字符串', () => {
|
||||
expect(CANCEL_MESSAGE).toBe('Effort unchanged.')
|
||||
expect(ULTRACODE_HINT).toContain('/ultracode <context>')
|
||||
})
|
||||
501
src/components/EffortPanel/__tests__/rippleAnimation.test.ts
Normal file
501
src/components/EffortPanel/__tests__/rippleAnimation.test.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type Cell,
|
||||
type Overlay,
|
||||
TRANSPARENT,
|
||||
applyOverlaysToCells,
|
||||
cellsToSegments,
|
||||
computeRippleCells,
|
||||
fadeCells,
|
||||
fadeColor,
|
||||
getHueShiftAtTime,
|
||||
intensityToColor,
|
||||
rotateHue,
|
||||
} from '../rippleAnimation.js'
|
||||
|
||||
describe('intensityToColor', () => {
|
||||
test('intensity=0 → 最暗档(不再是 transparent,作面板底色)', () => {
|
||||
expect(intensityToColor(0)).toBe('#1a1f3a')
|
||||
})
|
||||
|
||||
test('intensity < 0 钳到 0 → 最暗档', () => {
|
||||
expect(intensityToColor(-0.5)).toBe('#1a1f3a')
|
||||
})
|
||||
|
||||
test('intensity > 0 → 永远是 #hex 颜色字符串(不返回 transparent)', () => {
|
||||
for (const v of [0.05, 0.1, 0.2, 0.5, 0.8]) {
|
||||
const c = intensityToColor(v)
|
||||
expect(c).not.toBe(TRANSPARENT)
|
||||
expect(c).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('intensity > 1 钳到 1 → 最高强度颜色', () => {
|
||||
expect(intensityToColor(1.5)).toBe(intensityToColor(1))
|
||||
})
|
||||
|
||||
test('intensity 单调递增 → 颜色档位递增(至少 3 档)', () => {
|
||||
const samples = [0.2, 0.4, 0.6, 0.8, 1.0]
|
||||
const colors = samples.map(intensityToColor)
|
||||
const unique = new Set(colors)
|
||||
expect(unique.size).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('intensity=1 → suggestion 档(波峰最高档)', () => {
|
||||
expect(intensityToColor(1)).toBe('#5769F7')
|
||||
})
|
||||
|
||||
test('hueShift=0 → 与无 hueShift 相同(快路径)', () => {
|
||||
for (const v of [0, 0.2, 0.5, 0.8, 1]) {
|
||||
expect(intensityToColor(v, 0)).toBe(intensityToColor(v))
|
||||
}
|
||||
})
|
||||
|
||||
test('hueShift ≠ 0 → 返回不同颜色(但仍是合法 hex)', () => {
|
||||
const base = intensityToColor(0.8)
|
||||
const shifted = intensityToColor(0.8, 30)
|
||||
expect(shifted).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
expect(shifted).not.toBe(base)
|
||||
})
|
||||
|
||||
test('hueShift 180° → 大致补色(亮色变暗色族)', () => {
|
||||
// #5769F7 ≈ HSL(233, 91, 65),旋转 180° → HSL(53, 91, 65) ≈ 黄色系
|
||||
const shifted = intensityToColor(1, 180)
|
||||
expect(shifted).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
// 不再是蓝紫族(R 分量应明显大于 B 分量)
|
||||
const r = parseInt(shifted.slice(1, 3), 16)
|
||||
const b = parseInt(shifted.slice(5, 7), 16)
|
||||
expect(r).toBeGreaterThan(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateHue', () => {
|
||||
test('hueShift=0 → 原样返回(快路径,无 round-trip 误差)', () => {
|
||||
expect(rotateHue('#5769F7', 0)).toBe('#5769F7')
|
||||
expect(rotateHue('#1a1f3a', 0)).toBe('#1a1f3a')
|
||||
})
|
||||
|
||||
test('旋转 360° → 等同原色(一圈回起点,大小写无关)', () => {
|
||||
expect(rotateHue('#5769F7', 360).toLowerCase()).toBe('#5769f7')
|
||||
expect(rotateHue('#5769F7', -360).toLowerCase()).toBe('#5769f7')
|
||||
})
|
||||
|
||||
test('旋转 ±n*360° → 等同原色(任意整圈)', () => {
|
||||
expect(rotateHue('#3a4582', 720).toLowerCase()).toBe('#3a4582')
|
||||
expect(rotateHue('#3a4582', -1080).toLowerCase()).toBe('#3a4582')
|
||||
})
|
||||
|
||||
test('灰度色(saturation=0)旋转后不变', () => {
|
||||
// #808080 = (128,128,128),saturation=0,旋转无意义
|
||||
expect(rotateHue('#808080', 90)).toBe('#808080')
|
||||
})
|
||||
|
||||
test('非法 hex → 原样返回(防御式)', () => {
|
||||
expect(rotateHue('not-a-color', 90)).toBe('not-a-color')
|
||||
expect(rotateHue('#123', 90)).toBe('#123')
|
||||
})
|
||||
|
||||
test('旋转后保持 6 位 hex 格式', () => {
|
||||
const rotated = rotateHue('#5769F7', 45)
|
||||
expect(rotated).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHueShiftAtTime', () => {
|
||||
test('time=0 → 0', () => {
|
||||
expect(getHueShiftAtTime(0)).toBe(0)
|
||||
})
|
||||
|
||||
test('time > 0 → 在 [0, 360) 范围内(连续旋转,非负)', () => {
|
||||
for (const t of [100, 500, 1000, 2000, 5000, 10000, 50000, 100000]) {
|
||||
const shift = getHueShiftAtTime(t)
|
||||
expect(shift).toBeGreaterThanOrEqual(0)
|
||||
expect(shift).toBeLessThan(360)
|
||||
}
|
||||
})
|
||||
|
||||
test('time 推进 → hueShift 单调递增(模 360)', () => {
|
||||
// 在一个周期内(12000ms),hueShift 应单调递增
|
||||
const samples = [0, 1000, 2000, 3000, 4000, 5000, 6000]
|
||||
const shifts = samples.map(getHueShiftAtTime)
|
||||
for (let i = 1; i < shifts.length; i++) {
|
||||
expect(shifts[i]).toBeGreaterThan(shifts[i - 1])
|
||||
}
|
||||
})
|
||||
|
||||
test('周期 12000ms(time=12000 应回到 0,模 360)', () => {
|
||||
// 12000ms * 0.03 = 360,% 360 = 0
|
||||
const shift = getHueShiftAtTime(12000)
|
||||
expect(shift).toBe(0)
|
||||
})
|
||||
|
||||
test('半周期 6000ms → hueShift=180(对面色相)', () => {
|
||||
// 6000ms * 0.03 = 180
|
||||
expect(getHueShiftAtTime(6000)).toBe(180)
|
||||
})
|
||||
|
||||
test('四分之一周期 3000ms → hueShift=90', () => {
|
||||
expect(getHueShiftAtTime(3000)).toBe(90)
|
||||
})
|
||||
|
||||
test('多周期循环:time=24000 等同 time=0', () => {
|
||||
expect(getHueShiftAtTime(24000)).toBe(0)
|
||||
expect(getHueShiftAtTime(36000)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeRippleCells', () => {
|
||||
test('返回数组长度等于 width', () => {
|
||||
const cells = computeRippleCells({
|
||||
y: 2,
|
||||
width: 30,
|
||||
time: 100,
|
||||
sourceX: 25,
|
||||
sourceY: 2,
|
||||
})
|
||||
expect(cells.length).toBe(30)
|
||||
})
|
||||
|
||||
test('每个 cell 的 char 是空格', () => {
|
||||
const cells = computeRippleCells({
|
||||
y: 0,
|
||||
width: 10,
|
||||
time: 0,
|
||||
sourceX: 5,
|
||||
sourceY: 0,
|
||||
})
|
||||
for (const cell of cells) {
|
||||
expect(cell.char).toBe(' ')
|
||||
}
|
||||
})
|
||||
|
||||
test('每个 cell 的 color 是合法字符串', () => {
|
||||
const cells = computeRippleCells({
|
||||
y: 0,
|
||||
width: 10,
|
||||
time: 0,
|
||||
sourceX: 5,
|
||||
sourceY: 0,
|
||||
})
|
||||
for (const cell of cells) {
|
||||
expect(typeof cell.color).toBe('string')
|
||||
expect(
|
||||
cell.color === TRANSPARENT || /^#[0-9a-fA-F]{6}$/.test(cell.color),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('width=0 → 空数组', () => {
|
||||
expect(
|
||||
computeRippleCells({ y: 0, width: 0, time: 0, sourceX: 0, sourceY: 0 }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('width<0 → 空数组', () => {
|
||||
expect(
|
||||
computeRippleCells({ y: 0, width: -5, time: 0, sourceX: 0, sourceY: 0 }),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('震源点 time=0 时为中间档((sin+1)/2 → intensity=0.5),time 推进后扫过波峰/波谷', () => {
|
||||
// v5 平滑波:dist=0,time=0 时 phase=0,sin(0)=0,(0+1)/2=0.5 → intensity=0.5 → 中间档
|
||||
const t0 = computeRippleCells({
|
||||
y: 5,
|
||||
width: 11,
|
||||
time: 0,
|
||||
sourceX: 5,
|
||||
sourceY: 5,
|
||||
})
|
||||
// 0.5 * 7 = 3.5, floor = 3, RIPPLE_COLOR_STOPS[3] = '#2e3870'
|
||||
expect(t0[5].color).toBe('#2e3870')
|
||||
|
||||
// time 推进,phase 变化,震源会扫过波峰(亮档)和波谷(暗档)
|
||||
const t1 = computeRippleCells({
|
||||
y: 5,
|
||||
width: 11,
|
||||
time: 1500,
|
||||
sourceX: 5,
|
||||
sourceY: 5,
|
||||
})
|
||||
// 不同 time 不同颜色(动画推进)
|
||||
expect(t1[5].color).not.toBe('#2e3870')
|
||||
})
|
||||
|
||||
test('覆盖半径扩大:dist=65(左侧远端)仍有非最暗颜色', () => {
|
||||
// 震源 x=65,远端 x=0 → dist=65
|
||||
// falloff = max(0, 1 - 65/90) = 0.278,波峰时 intensity ≈ 0.278
|
||||
// 应映射到非最暗档(#15182b 或更亮)
|
||||
const cells = computeRippleCells({
|
||||
y: 0,
|
||||
width: 66,
|
||||
time: 0,
|
||||
sourceX: 65,
|
||||
sourceY: 0,
|
||||
})
|
||||
// 第 0 列 dist=65,time=0 时 phase = 65*0.35 = 22.75 rad
|
||||
// sin(22.75) ≈ -0.59 → wave = 0 → intensity = 0 → 最暗档
|
||||
// 但 time 推进时波峰会扫过此处,强度变高
|
||||
// 这里只验证 cell 有合法颜色(最暗档也算合法)
|
||||
expect(cells[0].color).toMatch(/^#[0-9a-fA-F]{6}$/)
|
||||
// 推进 time 后,左侧应出现非最暗颜色(波峰扫过)
|
||||
const t1 = computeRippleCells({
|
||||
y: 0,
|
||||
width: 66,
|
||||
time: 2000,
|
||||
sourceX: 65,
|
||||
sourceY: 0,
|
||||
})
|
||||
const nonDarkest = t1.filter(c => c.color !== '#1a1f3a')
|
||||
expect(nonDarkest.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('time 推进时颜色分布变化(动画效果)', () => {
|
||||
const t0 = computeRippleCells({
|
||||
y: 2,
|
||||
width: 30,
|
||||
time: 0,
|
||||
sourceX: 25,
|
||||
sourceY: 2,
|
||||
})
|
||||
const t1 = computeRippleCells({
|
||||
y: 2,
|
||||
width: 30,
|
||||
time: 500,
|
||||
sourceX: 25,
|
||||
sourceY: 2,
|
||||
})
|
||||
// 至少有一个位置颜色不同
|
||||
const diffs = t0.filter((c, i) => c.color !== t1[i].color)
|
||||
expect(diffs.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyOverlaysToCells', () => {
|
||||
function makeCells(colors: string[]): Cell[] {
|
||||
return colors.map(c => ({ char: ' ', color: c }))
|
||||
}
|
||||
|
||||
test('无 overlay 时原样返回(但为新数组)', () => {
|
||||
const cells = makeCells(['#111', '#222', '#333'])
|
||||
const out = applyOverlaysToCells(cells, [])
|
||||
expect(out).toEqual(cells)
|
||||
expect(out).not.toBe(cells) // 防御式拷贝
|
||||
})
|
||||
|
||||
test('overlay 替换 char 但保留底层 color(color 未指定时)', () => {
|
||||
const cells = makeCells([
|
||||
TRANSPARENT,
|
||||
TRANSPARENT,
|
||||
TRANSPARENT,
|
||||
TRANSPARENT,
|
||||
])
|
||||
const overlays: Overlay[] = [{ text: 'hi', x: 1 }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[1].char).toBe('h')
|
||||
expect(out[2].char).toBe('i')
|
||||
expect(out[1].color).toBe(TRANSPARENT) // 保留底层色
|
||||
expect(out[0].char).toBe(' ')
|
||||
})
|
||||
|
||||
test('overlay 指定 color 时同时覆盖 char + color', () => {
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [{ text: 'AB', x: 0, color: '#5769F7' }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[0]).toEqual({ char: 'A', color: '#5769F7' })
|
||||
expect(out[1]).toEqual({ char: 'B', color: '#5769F7' })
|
||||
expect(out[2]).toEqual({ char: ' ', color: TRANSPARENT })
|
||||
})
|
||||
|
||||
test('overlay 超出右边界被截断', () => {
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [{ text: 'abcdef', x: 1 }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[0].char).toBe(' ')
|
||||
expect(out[1].char).toBe('a')
|
||||
expect(out[2].char).toBe('b')
|
||||
// 'cdef' 被截断
|
||||
})
|
||||
|
||||
test('overlay x 为负数 → 从开头截断(不向左溢出)', () => {
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [{ text: 'abc', x: -1 }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[0].char).toBe('b') // 跳过 'a','b' 占 0
|
||||
expect(out[1].char).toBe('c')
|
||||
expect(out[2].char).toBe(' ')
|
||||
})
|
||||
|
||||
test('多个 overlay 后者覆盖前者(同位置)', () => {
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [
|
||||
{ text: 'AAA', x: 0, color: '#111' },
|
||||
{ text: 'B', x: 1, color: '#222' },
|
||||
]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out[0]).toEqual({ char: 'A', color: '#111' })
|
||||
expect(out[1]).toEqual({ char: 'B', color: '#222' }) // 第二个 overlay 覆盖
|
||||
expect(out[2]).toEqual({ char: 'A', color: '#111' })
|
||||
})
|
||||
|
||||
test('overlay 起始位置 >= 数组长度 → 完全跳过', () => {
|
||||
const cells = makeCells([TRANSPARENT, TRANSPARENT])
|
||||
const overlays: Overlay[] = [{ text: 'X', x: 5 }]
|
||||
const out = applyOverlaysToCells(cells, overlays)
|
||||
expect(out.every(c => c.char === ' ')).toBe(true)
|
||||
})
|
||||
|
||||
test('不修改原数组(防御式拷贝)', () => {
|
||||
const cells = makeCells([TRANSPARENT])
|
||||
const snapshot = cells.map(c => ({ ...c }))
|
||||
applyOverlaysToCells(cells, [{ text: 'X', x: 0 }])
|
||||
expect(cells).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cellsToSegments', () => {
|
||||
test('空数组 → 空数组', () => {
|
||||
expect(cellsToSegments([])).toEqual([])
|
||||
})
|
||||
|
||||
test('单 cell → 单段', () => {
|
||||
const cells: Cell[] = [{ char: 'a', color: '#111' }]
|
||||
expect(cellsToSegments(cells)).toEqual([{ text: 'a', color: '#111' }])
|
||||
})
|
||||
|
||||
test('全部同色 → 合并为一段', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: 'a', color: '#111' },
|
||||
{ char: 'b', color: '#111' },
|
||||
{ char: 'c', color: '#111' },
|
||||
]
|
||||
expect(cellsToSegments(cells)).toEqual([{ text: 'abc', color: '#111' }])
|
||||
})
|
||||
|
||||
test('颜色交替 → 每个独立段', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: 'a', color: '#111' },
|
||||
{ char: 'b', color: '#222' },
|
||||
{ char: 'c', color: '#111' },
|
||||
]
|
||||
expect(cellsToSegments(cells)).toEqual([
|
||||
{ text: 'a', color: '#111' },
|
||||
{ text: 'b', color: '#222' },
|
||||
{ text: 'c', color: '#111' },
|
||||
])
|
||||
})
|
||||
|
||||
test('相邻同色段合并,不同色段分开', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: 'a', color: TRANSPARENT },
|
||||
{ char: 'b', color: TRANSPARENT },
|
||||
{ char: 'X', color: '#5769F7' },
|
||||
{ char: 'Y', color: '#5769F7' },
|
||||
{ char: 'c', color: TRANSPARENT },
|
||||
]
|
||||
expect(cellsToSegments(cells)).toEqual([
|
||||
{ text: 'ab', color: TRANSPARENT },
|
||||
{ text: 'XY', color: '#5769F7' },
|
||||
{ text: 'c', color: TRANSPARENT },
|
||||
])
|
||||
})
|
||||
|
||||
test('段文本拼接顺序保持原顺序', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: '1', color: '#111' },
|
||||
{ char: '2', color: '#111' },
|
||||
{ char: '3', color: '#111' },
|
||||
]
|
||||
expect(cellsToSegments(cells)[0].text).toBe('123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fadeColor', () => {
|
||||
test('fade=1 → 原色(不变)', () => {
|
||||
expect(fadeColor('#5769F7', 1)).toBe('#5769f7')
|
||||
})
|
||||
|
||||
test('fade=0 → TRANSPARENT(cell 不渲染)', () => {
|
||||
expect(fadeColor('#5769F7', 0)).toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('fade ≤ 0.01 → TRANSPARENT(阈值)', () => {
|
||||
expect(fadeColor('#5769F7', 0.01)).toBe(TRANSPARENT)
|
||||
expect(fadeColor('#5769F7', 0.009)).toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('fade=0.5 → RGB 各分量减半', () => {
|
||||
// #5769F7 = (87, 105, 247),减半 → (44, 53, 124) = #2c357c
|
||||
// Math.round(87*0.5)=44, Math.round(105*0.5)=53, Math.round(247*0.5)=124
|
||||
expect(fadeColor('#5769F7', 0.5)).toBe('#2c357c')
|
||||
})
|
||||
|
||||
test('TRANSPARENT 输入 → 原样返回(不处理)', () => {
|
||||
expect(fadeColor(TRANSPARENT, 1)).toBe(TRANSPARENT)
|
||||
expect(fadeColor(TRANSPARENT, 0.5)).toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('非法 hex 格式 → 原样返回(防御式)', () => {
|
||||
expect(fadeColor('not-a-color', 0.5)).toBe('not-a-color')
|
||||
expect(fadeColor('#123', 0.5)).toBe('#123') // 非 6 位 hex
|
||||
})
|
||||
|
||||
test('fade < 0 钳到 0 → TRANSPARENT', () => {
|
||||
expect(fadeColor('#5769F7', -0.5)).toBe(TRANSPARENT)
|
||||
})
|
||||
|
||||
test('fade > 1 钳到 1 → 原色', () => {
|
||||
expect(fadeColor('#5769F7', 1.5)).toBe('#5769f7')
|
||||
})
|
||||
|
||||
test('结果始终为 6 位 hex(前导零补全)', () => {
|
||||
// #010203 = (1, 2, 3),fade=0.5 → Math.round 后为 (1, 1, 2) = #010102
|
||||
// 但 1*0.5 = 0.5, Math.round(0.5) = 1( banker's rounding 在 JS 中是 round half up)
|
||||
// 验证格式:6 位 hex
|
||||
const result = fadeColor('#010203', 0.5)
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fadeCells', () => {
|
||||
test('空数组 → 空数组', () => {
|
||||
expect(fadeCells([], 0.5)).toEqual([])
|
||||
})
|
||||
|
||||
test('每个 cell 的颜色按 fade 缩放,char 保留', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: ' ', color: '#5769F7' },
|
||||
{ char: 'A', color: '#ffffff' },
|
||||
]
|
||||
const out = fadeCells(cells, 0.5)
|
||||
expect(out[0]).toEqual({ char: ' ', color: '#2c357c' })
|
||||
// #ffffff = (255, 255, 255),fade=0.5 → (128, 128, 128) = #808080
|
||||
expect(out[1]).toEqual({ char: 'A', color: '#808080' })
|
||||
})
|
||||
|
||||
test('不修改原数组(防御式拷贝)', () => {
|
||||
const cells: Cell[] = [{ char: ' ', color: '#5769F7' }]
|
||||
const snapshot = cells.map(c => ({ ...c }))
|
||||
fadeCells(cells, 0.5)
|
||||
expect(cells).toEqual(snapshot)
|
||||
})
|
||||
|
||||
test('TRANSPARENT cell 保持 TRANSPARENT', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: ' ', color: TRANSPARENT },
|
||||
{ char: ' ', color: '#5769F7' },
|
||||
]
|
||||
const out = fadeCells(cells, 0.5)
|
||||
expect(out[0].color).toBe(TRANSPARENT)
|
||||
expect(out[1].color).toBe('#2c357c')
|
||||
})
|
||||
|
||||
test('fade=0 → 所有非 transparent 颜色变 TRANSPARENT', () => {
|
||||
const cells: Cell[] = [
|
||||
{ char: ' ', color: '#5769F7' },
|
||||
{ char: ' ', color: '#1a1f3a' },
|
||||
]
|
||||
const out = fadeCells(cells, 0)
|
||||
expect(out[0].color).toBe(TRANSPARENT)
|
||||
expect(out[1].color).toBe(TRANSPARENT)
|
||||
})
|
||||
})
|
||||
126
src/components/EffortPanel/effortPanelState.ts
Normal file
126
src/components/EffortPanel/effortPanelState.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { EffortValue } from '../../utils/effort.js'
|
||||
|
||||
/**
|
||||
* 光标在面板上的位置。仅面板内部使用,不进入 AppState / settings / API。
|
||||
* 'ultracode' 不是 EffortLevel;它在本面板里仅作视觉占位与文案引导。
|
||||
*/
|
||||
export type PanelPosition =
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'xhigh'
|
||||
| 'max'
|
||||
| 'ultracode'
|
||||
|
||||
export const PANEL_POSITIONS: readonly PanelPosition[] = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
'ultracode',
|
||||
] as const
|
||||
|
||||
export const HOME_POSITION: PanelPosition = 'low'
|
||||
export const END_POSITION: PanelPosition = 'ultracode'
|
||||
|
||||
/**
|
||||
* 判断一个值是否可作为面板光标位置(不含 ultracode,因 ultracode 仅由面板内部产生)。
|
||||
*/
|
||||
function isNonUltracodePosition(
|
||||
value: unknown,
|
||||
): value is Exclude<PanelPosition, 'ultracode'> {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value !== 'ultracode' &&
|
||||
(PANEL_POSITIONS as readonly string[]).includes(value)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 EffortValue 归一化为面板可用的光标位置。
|
||||
* - null / undefined / 数值(ant-only)/ ultracode → undefined(让上层用 displayed)
|
||||
* - 合法 string 档位 → 返回该档位
|
||||
*/
|
||||
function normalizeToPanelPosition(
|
||||
value: EffortValue | null | undefined,
|
||||
): PanelPosition | undefined {
|
||||
if (value === null || value === undefined) return undefined
|
||||
if (typeof value === 'number') return undefined
|
||||
if (isNonUltracodePosition(value)) {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function moveLeft(cursor: PanelPosition): PanelPosition {
|
||||
const idx = PANEL_POSITIONS.indexOf(cursor)
|
||||
if (idx <= 0) return PANEL_POSITIONS[0]
|
||||
return PANEL_POSITIONS[idx - 1]
|
||||
}
|
||||
|
||||
export function moveRight(cursor: PanelPosition): PanelPosition {
|
||||
const idx = PANEL_POSITIONS.indexOf(cursor)
|
||||
if (idx === -1 || idx >= PANEL_POSITIONS.length - 1) {
|
||||
return PANEL_POSITIONS[PANEL_POSITIONS.length - 1]
|
||||
}
|
||||
return PANEL_POSITIONS[idx + 1]
|
||||
}
|
||||
|
||||
export function isUltracode(cursor: PanelPosition): boolean {
|
||||
return cursor === 'ultracode'
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定面板挂载时的初始光标位置。
|
||||
* 优先级:env override(若是合法档位)> displayed level
|
||||
*
|
||||
* @param envOverride getEffortEnvOverride() 的返回值:EffortValue | null | undefined
|
||||
* @param appStateEffort AppState.effortValue
|
||||
* @param displayed getDisplayedEffortLevel(model, appStateEffort) —— 必传,避免此处再依赖 model
|
||||
*/
|
||||
export function getInitialCursor(args: {
|
||||
envOverride: EffortValue | null | undefined
|
||||
appStateEffort: EffortValue | undefined
|
||||
displayed: PanelPosition
|
||||
}): PanelPosition {
|
||||
const fromEnv = normalizeToPanelPosition(args.envOverride)
|
||||
if (fromEnv !== undefined) return fromEnv
|
||||
// displayed 已经是 EffortLevel(不含 ultracode),合法
|
||||
return args.displayed
|
||||
}
|
||||
|
||||
// ---- 确认/取消决策(注入 ApplyFn 避免循环依赖 + 便于测试)----
|
||||
|
||||
export type ConfirmOutcome =
|
||||
| {
|
||||
kind: 'apply'
|
||||
message: string
|
||||
effortUpdate?: { value: EffortValue | undefined }
|
||||
}
|
||||
| { kind: 'ultracode-hint'; message: string }
|
||||
|
||||
export type ApplyFn = (cursor: PanelPosition) => {
|
||||
message: string
|
||||
effortUpdate?: { value: EffortValue | undefined }
|
||||
}
|
||||
|
||||
export const ULTRACODE_HINT =
|
||||
'ultracode is not an effort level. Use /ultracode <context> to start a multi-agent workflow.'
|
||||
|
||||
export const CANCEL_MESSAGE = 'Effort unchanged.'
|
||||
|
||||
export function computeConfirmOutcome(
|
||||
cursor: PanelPosition,
|
||||
applyFn: ApplyFn,
|
||||
): ConfirmOutcome {
|
||||
if (isUltracode(cursor)) {
|
||||
return { kind: 'ultracode-hint', message: ULTRACODE_HINT }
|
||||
}
|
||||
const result = applyFn(cursor)
|
||||
return {
|
||||
kind: 'apply',
|
||||
message: result.message,
|
||||
effortUpdate: result.effortUpdate,
|
||||
}
|
||||
}
|
||||
361
src/components/EffortPanel/rippleAnimation.ts
Normal file
361
src/components/EffortPanel/rippleAnimation.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* EffortPanel ultracode 档位的背景波纹动画 —— 纯函数模块(颜色驱动)。
|
||||
*
|
||||
* 设计:
|
||||
* - 仅在 cursor 停在 ultracode 时启动(订阅时钟由 useRippleFrame 控制)
|
||||
* - 震源:面板右下(ultracode 字符位置),向左/上辐射同心圆波
|
||||
* - 每位置强度(0~1)→ 颜色(suggestion 系暗紫蓝渐变)
|
||||
* - 文字 overlay 在波纹之上(last-write-wins,颜色可单独指定)
|
||||
*
|
||||
* 渲染模型:每位置一个 cell(char + color),相邻同色合并为 segment。
|
||||
* 渲染层用 Box flexDirection="row" + 多个 Text 段输出(每段一个 color)。
|
||||
*
|
||||
* 所有函数纯:相同入参 → 相同出参,便于单测 + 帧快照。
|
||||
*/
|
||||
|
||||
/**
|
||||
* suggestion 系颜色梯度(暗背景 → suggestion 色)。
|
||||
*
|
||||
* 设计:所有强度都映射到具体颜色(不返回 transparent),让整面板都是
|
||||
* "暗紫蓝海洋"作为底色,波峰在底色上流动。这样波纹颜色变化更明显,
|
||||
* 波谷也有暗色(不会"消失")。
|
||||
*
|
||||
* 最暗档用 #1a1f3a(紫黑,亮度 ~12%),不是纯黑——避免远端波谷
|
||||
* 看起来像"硬黑边"。波峰最高升到 suggestion (#5769F7),避免与
|
||||
* 文字 overlay(也用 suggestion 系)同色互相吞噬。
|
||||
*
|
||||
* 这些是 base 颜色(hueShift=0 时返回)。生产代码会传 hueShift 让
|
||||
* 整个梯度绕色相环旋转,制造主色随时间漂移的视觉效果。
|
||||
*/
|
||||
const RIPPLE_COLOR_STOPS = [
|
||||
'#1a1f3a', // 0.00 ~ 0.14 — 最暗(紫黑底色,非纯黑)
|
||||
'#1f2543', // 0.14 ~ 0.28
|
||||
'#252c55', // 0.28 ~ 0.42
|
||||
'#2e3870', // 0.42 ~ 0.56
|
||||
'#3a4582', // 0.56 ~ 0.70
|
||||
'#4a5bb0', // 0.70 ~ 0.84
|
||||
'#5769F7', // 0.84 ~ 1.00 — suggestion (波峰)
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 色相连续旋转速度(度/ms)。
|
||||
* 周期 = 360 / 0.03 = 12000ms = 12s,远慢于波纹相位(~1.6s),
|
||||
* 让主色漂移感"ambient"而非"动画"。
|
||||
*
|
||||
* 连续旋转(非 sin 振荡)让色相 0~360° 全色环都被访问:
|
||||
* 蓝 233° → 紫 270° → 品红 300° → 红 0° → 橙 30° → 黄 60° →
|
||||
* 绿 120° → 青 180° → 蓝 233°(一圈)。
|
||||
*/
|
||||
const HUE_ROTATION_DEG_PER_MS = 0.03
|
||||
|
||||
/**
|
||||
* hex → {h, s, l}(h 单位度,s/l 为 0~1)。
|
||||
*
|
||||
* 标准 RGB → HSL 转换。非法 hex(非 #rrggbb)→ h=0, s=0, l=0(黑)。
|
||||
*/
|
||||
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return { h: 0, s: 0, l: 0 }
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const l = (max + min) / 2
|
||||
const d = max - min
|
||||
if (d === 0) return { h: 0, s: 0, l }
|
||||
const s = d / (1 - Math.abs(2 * l - 1))
|
||||
let h: number
|
||||
if (max === r) {
|
||||
h = 60 * (((g - b) / d) % 6)
|
||||
} else if (max === g) {
|
||||
h = 60 * ((b - r) / d + 2)
|
||||
} else {
|
||||
h = 60 * ((r - g) / d + 4)
|
||||
}
|
||||
if (h < 0) h += 360
|
||||
return { h, s, l }
|
||||
}
|
||||
|
||||
/**
|
||||
* {h, s, l} → hex。
|
||||
*
|
||||
* 标准 HSL → RGB 转换。h 自动 mod 360 处理。
|
||||
*/
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const hNorm = ((h % 360) + 360) % 360
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s
|
||||
const hPrime = hNorm / 60
|
||||
const x = c * (1 - Math.abs((hPrime % 2) - 1))
|
||||
let r = 0
|
||||
let g = 0
|
||||
let b = 0
|
||||
if (hPrime < 1) {
|
||||
r = c
|
||||
g = x
|
||||
} else if (hPrime < 2) {
|
||||
r = x
|
||||
g = c
|
||||
} else if (hPrime < 3) {
|
||||
g = c
|
||||
b = x
|
||||
} else if (hPrime < 4) {
|
||||
g = x
|
||||
b = c
|
||||
} else if (hPrime < 5) {
|
||||
r = x
|
||||
b = c
|
||||
} else {
|
||||
r = c
|
||||
b = x
|
||||
}
|
||||
const m = l - c / 2
|
||||
const toHex = (v: number): string =>
|
||||
Math.round((v + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 hex 颜色绕色相环旋转 hueShift 度。
|
||||
*
|
||||
* 保持饱和度和亮度不变,仅旋转 hue。用于让 RIPPLE_COLOR_STOPS 整体
|
||||
* 漂移到不同色相(蓝→青→紫→蓝循环),制造主色随时间变化的效果。
|
||||
*
|
||||
* 非法 hex 原样返回(防御式)。
|
||||
*/
|
||||
export function rotateHue(hex: string, hueShift: number): string {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return hex
|
||||
if (hueShift === 0) return hex // 快路径:避免无意义 round-trip
|
||||
const { h, s, l } = hexToHsl(hex)
|
||||
return hslToHex(h + hueShift, s, l)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 time 计算当前色相偏移(度,连续旋转)。
|
||||
*
|
||||
* 返回值始终在 [0, 360) 区间,单调递增(模 360)。
|
||||
* 周期约 12s 一圈,覆盖完整色环。
|
||||
*/
|
||||
export function getHueShiftAtTime(time: number): number {
|
||||
return (time * HUE_ROTATION_DEG_PER_MS) % 360
|
||||
}
|
||||
|
||||
/**
|
||||
* 强度(任意实数)→ 颜色字符串。
|
||||
*
|
||||
* 钳到 [0, 1],按 RIPPLE_COLOR_STOPS 分级。永不返回 transparent。
|
||||
* intensity=0 → 最暗档(#1a1f3a,作为面板底色)。
|
||||
*
|
||||
* @param hueShift 整个色阶绕色相环旋转的度数(0 = base 颜色)。
|
||||
* 生产代码传 getHueShiftAtTime(time) 实现主色漂移。
|
||||
* 测试代码传 0(默认)获得确定性输出。
|
||||
*/
|
||||
export function intensityToColor(intensity: number, hueShift = 0): string {
|
||||
const v = intensity < 0 ? 0 : intensity > 1 ? 1 : intensity
|
||||
const idx = Math.min(
|
||||
RIPPLE_COLOR_STOPS.length - 1,
|
||||
Math.floor(v * RIPPLE_COLOR_STOPS.length),
|
||||
)
|
||||
const base = RIPPLE_COLOR_STOPS[idx]
|
||||
return hueShift === 0 ? base : rotateHue(base, hueShift)
|
||||
}
|
||||
|
||||
/**
|
||||
* 'transparent' 字面量。intensityToColor 永不返回它(保留为兼容性导出)。
|
||||
* 渲染层可用此常量做语义判定(如 cell 是 overlay 文字而非波纹背景)。
|
||||
*/
|
||||
export const TRANSPARENT = 'transparent'
|
||||
|
||||
/**
|
||||
* 单位置 cell:char + color。
|
||||
* - color 为 'transparent' 时渲染层不染色(背景保持终端默认)。
|
||||
* - 文字 overlay cell 用具体颜色(suggestion / warning 等)。
|
||||
*/
|
||||
export type Cell = {
|
||||
char: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染段:相邻同 color 的 cells 合并。
|
||||
* 减少 React Text 节点数量(一行从 72 个 Text 降到 ~5-10 个)。
|
||||
*/
|
||||
export type Segment = {
|
||||
text: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字 overlay:在某行的 x 位置覆盖 text 字符串。
|
||||
* - color undefined 时保留底层波纹 cell 自身颜色(仅替换 char)
|
||||
* - color 指定时同时覆盖 char + color
|
||||
*
|
||||
* 后渲染的 overlay 在相同位置覆盖先渲染的(last-write-wins)。
|
||||
*/
|
||||
export type Overlay = {
|
||||
text: string
|
||||
/** 起始列;可为负(前缀被截断) */
|
||||
x: number
|
||||
/** overlay 字符颜色;undefined = 保留底层波纹颜色 */
|
||||
color?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 波纹背景字符。
|
||||
* 用空格让背景留空、只靠 color 染色(视觉上像"颜色斑点")。
|
||||
* 空格宽度稳定(永远 1 列),不像可变宽度 unicode 字符。
|
||||
*/
|
||||
const RIPPLE_BG_CHAR = ' '
|
||||
|
||||
/**
|
||||
* 计算面板某一行 y 的完整波纹 cell 列表。
|
||||
*
|
||||
* 波纹数学(v6.1 — 平滑呼吸 + 主色全色环旋转):
|
||||
* dx = x - sourceX
|
||||
* dy = (y - sourceY) * 1.5 (y 方向视觉拉伸,行高 > 字宽)
|
||||
* dist = sqrt(dx² + dy²)
|
||||
* phase = dist * 0.35 - time * 0.004 (速度调慢至原 1/3)
|
||||
* wave = (sin(phase) + 1) / 2 ([−1,1] → [0,1],平滑无平带)
|
||||
* falloff = max(0, 1 - dist / 90) (覆盖半径扩到 90)
|
||||
* intensity = wave * falloff
|
||||
* hueShift = (time * 0.03) % 360 (连续旋转,12s 一圈全色环)
|
||||
* color = intensityToColor(intensity, hueShift)
|
||||
*
|
||||
* v6.1 改 hueShift 为连续旋转(v6 是 sin±25° 振荡,色域太窄到不了
|
||||
* 红黄)。现在每 12s 走完一圈完整色环:蓝→紫→品红→红→橙→黄→绿→青→蓝。
|
||||
* 两个时间常数(相位 0.004 vs hue 0.03)解耦,让"流动"和"变色"不同步。
|
||||
*
|
||||
* 每位置强度经 intensityToColor → 颜色字符串(永不 transparent),写入 cell。
|
||||
*
|
||||
* @returns 长度严格等于 width 的 Cell 数组
|
||||
*/
|
||||
export function computeRippleCells(args: {
|
||||
y: number
|
||||
width: number
|
||||
time: number
|
||||
sourceX: number
|
||||
sourceY: number
|
||||
}): Cell[] {
|
||||
const { y, width, time, sourceX, sourceY } = args
|
||||
if (width <= 0) return []
|
||||
|
||||
const hueShift = getHueShiftAtTime(time)
|
||||
|
||||
const cells: Cell[] = new Array(width)
|
||||
for (let x = 0; x < width; x++) {
|
||||
const dx = x - sourceX
|
||||
const dy = (y - sourceY) * 1.5
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// 主波纹相位(速度调慢:原 0.012 → 0.004,约 1/3 速)
|
||||
const phase = dist * 0.35 - time * 0.004
|
||||
// 平滑呼吸:[−1,1] → [0,1],无平带,无双倍频率
|
||||
const wave = (Math.sin(phase) + 1) / 2
|
||||
|
||||
// 距离衰减(覆盖半径扩到 90:原 40)
|
||||
const falloff = Math.max(0, 1 - dist / 90)
|
||||
const intensity = wave * falloff
|
||||
|
||||
cells[x] = {
|
||||
char: RIPPLE_BG_CHAR,
|
||||
color: intensityToColor(intensity, hueShift),
|
||||
}
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 overlays 文字覆盖到 cells。
|
||||
*
|
||||
* 行为:
|
||||
* - 文字字符永远胜出(替换底层 cell.char)
|
||||
* - overlay.color 为 undefined 时保留底层 cell.color(仅替换 char)
|
||||
* - overlay.color 指定时同时覆盖 char + color
|
||||
* - 超出右边界的文字被截断
|
||||
* - x 为负时跳过前 |x| 个字符
|
||||
*
|
||||
* 不修改原数组,返回新数组(防御式拷贝)。
|
||||
*/
|
||||
export function applyOverlaysToCells(
|
||||
cells: Cell[],
|
||||
overlays: Overlay[],
|
||||
): Cell[] {
|
||||
const out: Cell[] = cells.map(c => ({ ...c }))
|
||||
for (const overlay of overlays) {
|
||||
const start = overlay.x
|
||||
if (start >= out.length) continue
|
||||
for (let i = 0; i < overlay.text.length; i++) {
|
||||
const targetIdx = start + i
|
||||
if (targetIdx < 0) continue
|
||||
if (targetIdx >= out.length) break
|
||||
out[targetIdx] = {
|
||||
char: overlay.text[i],
|
||||
color: overlay.color ?? out[targetIdx].color,
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并相邻同色 cells 为 segments。
|
||||
*
|
||||
* 用于减少渲染节点:一行 72 cells 可能只有 5-10 个颜色变化点,
|
||||
* 合并后只需渲染 N 个 Text 段而非 N 个单字符 Text。
|
||||
*/
|
||||
export function cellsToSegments(cells: Cell[]): Segment[] {
|
||||
if (cells.length === 0) return []
|
||||
const segments: Segment[] = []
|
||||
let current: Segment = { text: cells[0].char, color: cells[0].color }
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const cell = cells[i]
|
||||
if (cell.color === current.color) {
|
||||
current.text += cell.char
|
||||
} else {
|
||||
segments.push(current)
|
||||
current = { text: cell.char, color: cell.color }
|
||||
}
|
||||
}
|
||||
segments.push(current)
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 hex 颜色按 fade 因子(0~1)缩放亮度。
|
||||
*
|
||||
* 用于进入/退出动画:
|
||||
* - fade ≤ 0.01 → TRANSPARENT(cell 不渲染背景,等同终端默认)
|
||||
* - fade = 0.5 → 颜色 RGB 各分量减半(暗紫蓝)
|
||||
* - fade = 1 → 原色(完整波纹)
|
||||
*
|
||||
* 非法 hex(非 #rrggbb 格式)原样返回(防御式)。
|
||||
*/
|
||||
export function fadeColor(color: string, fade: number): string {
|
||||
if (color === TRANSPARENT) return TRANSPARENT
|
||||
const f = fade < 0 ? 0 : fade > 1 ? 1 : fade
|
||||
if (f <= 0.01) return TRANSPARENT
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return color
|
||||
const r = parseInt(color.slice(1, 3), 16)
|
||||
const g = parseInt(color.slice(3, 5), 16)
|
||||
const b = parseInt(color.slice(5, 7), 16)
|
||||
const fr = Math.round(r * f)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
const fg = Math.round(g * f)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
const fb = Math.round(b * f)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${fr}${fg}${fb}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 把整行 cells 的颜色按 fade 缩放(用于进入/退出动画)。
|
||||
*
|
||||
* 不修改原数组,返回新数组。
|
||||
*/
|
||||
export function fadeCells(cells: Cell[], fade: number): Cell[] {
|
||||
return cells.map(c => ({ char: c.char, color: fadeColor(c.color, fade) }))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user