feat: dynamic-workflow 来了 (#1271)

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

将 feat/sdk-backend 分支中 workflow 相关的 20 个 commit 压缩为单 commit:

- 工作流引擎核心:phase / agent / parallel / pipeline 编排原语(packages/workflow-engine/)
- /workflows 面板:三区焦点布局(顶部 run tabs + 左侧 phase 侧栏 + 右侧 agent 列表)
- /ultracode skill:多 agent workflow 编排入口
- 进度存储 / journal / notification 系统
- WorkflowService 生命周期管理 + SentryErrorBoundary
- 脚本沙箱:禁用 dynamic import()、JSON args 防御性归一化
- journal 与 named-workflow 路径统一在 projectRoot
- 错误处理:parallel/pipeline hooks 错误日志、failure routing、semaphore abort
- workflow 工具升级为 core 工具 + PascalCase 命名

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>

* feat(workflow): 复刻 ultracode 手册并修复 worktree/inline/opt-in 三处缺口

围绕 ultracode skill 审查 agent 系统一致性后:
- ultracode.ts: 用系统提示版完整 Workflow 编排手册替换中文精简版
- HIGH#1 isolation:'worktree': claudeCodeBackend.run() 用 createAgentWorktree +
  runWithCwdOverride 包裹 runAgent + finally 清理实现真正的 cwd 隔离;slug 用
  sha256(runId:agentId) 派生以匹配 cleanupStaleAgentWorktrees 清理正则
  (修 runId 为 w+base36 非 UUID 导致的泄漏盲区);worktree.ts 注释同步修正
- HIGH#2 inline 持久化: 新增 persistInlineScript,WorkflowTool + service 两条
  inline 路径对称持久化到 .claude/workflow-runs/<runId>/script.js,返回可复用
  scriptPath(闭环 inline→编辑→scriptPath 重提迭代循环)
- HIGH#3 opt-in 分工: ultracode/WorkflowTool/effort 注明 session reminder 由
  harness 注入,repo 内无 ultracode 信号,保持 feature('WORKFLOW_SCRIPTS') +
  isEnabled 两层 gate,不自造注入
- 测试: 新增 persistInline.test.ts;扩展 claudeCodeBackend(isolation 4 用例)/
  WorkflowTool(inline)/service(scriptPath)/ultracode(harness)

含配套 workflow engine/panel 完善与 run-state-persistence design doc。

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(workflow): run 终态落盘 state.json 支持跨重启恢复

终态 RunProgress(含 returnValue/error)此前只在内存 ProgressStore,进程
重启即丢失。本次让其落盘到 .claude/workflow-runs/<runId>/state.json,使
(a) 重启后可按 runId 取 return、(b) /workflows 面板跨重启展示历史 run。
跨进程 resume 明确不在范围。

- persistence.ts: getRunsDir/writeRunState/readRunState/listPersistedRuns
  + attachRunStatePersistence;原子覆盖写(tmp+rename),读容错(缺文件/
  损坏/schemaVersion 不符 → null),写 best-effort(IO 失败只 log warn)
- progress/store.ts: 加 hydrate(run) 直接注入磁盘 run(已存在 runId 跳过,
  内存优先)
- service.ts: getWorkflowService() 接线 attachRunStatePersistence(bus,
  store) 订阅 run_done(completed/failed/killed 三态共用,shutdown-kill
  也走同路径,无需额外钩子);WorkflowService 加 getRunAsync(id) 内存
  miss→读盘 fallback(不注入内存)+ loadPersistedRuns() 扫盘 hydrate
  (persistedLoaded flag 守护幂等)
- panel/WorkflowsPanel.tsx: mount 时调一次 loadPersistedRuns(重 mount
  不重复)
- ports.ts: runsDir 改用 getRunsDir() 消除拼接重复
- 测试: persistence.test.ts(11)/runStatePersistence.test.ts(5)/
  progressStore(2)/service(5)/WorkflowsPanel(1) 共 24 个新测试;
  precheck 5629 pass / 0 fail

设计偏离: 计划原写 monkey-patch getRunsDir 指向 tmpdir,Bun ESM namespace
不可变不可行;改用可选 runsDirProvider 参数(默认 getRunsDir)DI 注入,
加到 attachRunStatePersistence 与 makeService(cwdOverride 之后第 4 参),
与现有 cwdOverride 模式一致。makeService 的 cwdOverride 保持不变,不破坏
inline 持久化特性。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): 默认并发降为 3 并支持 per-run maxConcurrency 注入

- DEFAULT_MAX_CONCURRENCY=3 替代旧的 min(16, cores-2);MAX_CONCURRENCY_CAP=16 保留为用户输入的绝对上限
- 新增 clampMaxConcurrency() 处理 undefined/<1/>CAP 边界
- WorkflowInput schema 新增 maxConcurrency: number.int().min(1).max(16).optional()
- 引擎层 context/runWorkflow 全链路透传:semaphore 容量来自 per-run 入参
- WorkflowTool prompt 增加指引:fan-out 场景先用 AskUserQuestion 与用户确认并发再启动
- 同步 ultracode skill + audit workflow spec 的并发文字(删 cpu-cores 公式)
- 同步 docs/features/workflow-scripts.md 旧公式

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 面板 UI 字符串英文化

WorkflowsPanel 中 4 处面向用户的中文(onDone 错误消息、键位提示行)
改为英文;其他面板组件(AgentList/TabsBar)原本已是英文。代码注释
保留中文,与 workflow 模块惯例一致。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): 中断系统(x 杀单 agent / K 杀整个 workflow,Dialog 二次确认)

- claudeCodeBackend 桥接 ctx.signal → runAgent.override.abortController(修 'x' 无效根因:abort 到不了内部 fetch)
- AbortError 识别为 throw WorkflowAbortedError(不再吞成 dead,workflow 能感知被 kill)
- ports.taskRegistrar 加 registerAgentAbort/unregisterAgentAbort/killAgent;service.killAgent(runId, agentId) 精确中断
- 面板键位:'x' 杀当前 agent(agents 列聚焦时) / 'K' 杀整个 workflow;Dialog 二次确认 + confirm 模式吞导航键防误触
- 新增测试 8 项(backend signal bridge / hooks inject / ports killAgent / service killAgent)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(workflow): ultracode skill 加 model tier 选择指引(haiku/sonnet/opus/best 场景匹配)

补足 agent() 已有 model 参数缺的判断依据:列出 4 个 tier 的成本/延迟量级和典型场景,
明确"无法 articulate 为什么换 tier 就 omit"的 rule of thumb。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): maxConcurrency≠3 必须先 AskUserQuestion(默认 3 推荐值)

把 fan-out 时才问改成任何 maxConcurrency≠3 都必须问。
唯一例外:用户在当前会话已明确说过并发数("use 6" / "maxConcurrency 9")。
prompt (WorkflowTool.ts) + skill (ultracode.ts) + audit spec 三处同步。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): agent 失败自动重试一次(dead 或非 abort throw)

- hooks.agent 包装 invokeBackend:第一次 dead 或非 abort throw → 重试一次
- WorkflowAbortedError(kill)不重试——是用户意图
- registry.resolve 配置错(AdapterNotFoundError 等)在 try 外直接上抛,不走重试——
  配置问题重试无意义且掩盖 bug
- 重试仍失败:dead 保持 dead;throw 降级 dead(不击穿 workflow,
  与 parallel/pipeline null-on-error 契约一致)
- budget 不重复扣:dead 不 addOutputTokens,重试 ok 才扣一次
- 新增 7 项 hooks 层重试测试 + 1 项 service 层降级测试

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 面板 label 截断保留 #数字 后缀(同 dim 多 finding 可区分)

audit workflow 用 verify:\${dim}#\${findingIdx} 命名 verify agent。
旧逻辑 slice(0, 18) 从右切把 #idx 全吃了——同 dimension 多 finding
肉眼无法区分。新逻辑:含 #数字 后缀时保留后缀,前缀截断 + … 省略号。

例:verify:correctness#0 → verify:correctn…#0
   verify:architecture#15 → verify:archite…#15

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): kill 整个 workflow 后立即回主 chat

run_done→store→notifications.ts 的通知路径已有,但 confirmYes 后面板继续
挂着挡住主 chat,用户看不到"已停止"反馈。kill 后调 onDone() 立即退出面板,
让主 chat 的 `Workflow "<name>" was stopped` 通知直接可见。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): agent dead 带 reason/detail + prompt 加压 StructuredOutput

12 agent audit workflow 8 个 dead,journal 只记 {kind:"dead"} 无信息,
事后无法区分 "agent 没产 StructuredOutput" vs "runAgent 抛错"。
证据指向主因:sonnet 长 tool chain 后忘记调 StructuredOutput,
extractStructuredOutput 返回 null 即降级 dead。

- types.ts: AgentRunResult.dead 加可选 reason/detail 字段
  (no-structured-output / runagent-threw / worktree-failed / unknown)
  兼容旧 journal(均 optional)。
- claudeCodeBackend.ts: 三处 dead 填 reason + detail;
  no-structured-output 把 finalized 文本前 200 字符做 detail,
  让日志/面板能立刻看到 agent 最后说了什么。
- claudeCodeBackend.ts: schema 模式 prompt 首尾各放一次
  StructuredOutput 强制要求,针对 sonnet 长 tool chain 后忘记收尾。
- hooks.ts: retry 日志带 reason;retry 仍 throw 时降级 dead 也填
  reason=runagent-threw + detail。
- types.test.ts: 加 reason JSON 往返 + 旧 journal 兼容测试。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): schema 模式弃用 StructuredOutput 工具契约,改鲁棒 JSON 文本解析

上一轮 70a2f76 把"agent 长 tool chain 后忘调 StructuredOutput"当作死因,
加 prompt 头尾双强制。但实测跑 5 个 review agent 4 个 dead,detail 全是
"StructuredOutput tool is not available as a deferred tool"——根因是
该工具从未注入 workflow sub-agent 的工具集(assembleToolPool 默认池不含,
只有 stop_hook 路径 execAgentHook.ts 显式 createStructuredOutputTool())。
prompt 反复要求调一个不可达的工具,agent 困扰、长篇辩解、最终没产 JSON。

- claudeCodeBackend.ts:
  - extractStructuredOutput 重写:括号栈扫描替代 indexOf/lastIndexOf,
    处理嵌套对象、字符串内的括号、转义符;新增 fenced code block
    优先路径(```json / ```),多 JSON 块取第一个 parse 成功的;
    只返回 plain object(拒 array/number/string/null)。不做语法修复
    (尾逗号/单引号/注释)——避免在字符串内误改(如 "http://" 被 // 注释正则吃)。
  - schema 模式 prompt 简化:删首尾双 STRUCTURED OUTPUT 强制(600+ token),
    改成指示 agent 在最后文本块 emit raw JSON;明确告知"StructuredOutput
    is not available in this environment",消除调用幻觉。
- hooks.ts: detail.slice 用 typeof === 'string' 守卫;catch 块用
  e instanceof Error ? e.message : String(e)(旧 journal / 第三方 adapter
  可能写非 string detail,直接 .slice 会抛 TypeError 击穿日志)。
- claudeCodeBackend.test.ts: +9 测试覆盖 fenced / 嵌套 / 字符串内括号 /
  转义引号 / 多块取首 / 类型守卫 / 损坏 JSON。

precheck: 5663 pass / 0 fail。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): 新增 /effort 交互面板设计 spec

设计要点:
- /effort 无参 → 横向 slider 面板(low/medium/high/xhigh/max/ultracode)
- ←/→ 移动光标,Enter 确认,Esc 取消
- ultracode 仅视觉占位,确认后提示走 /ultracode <context>
- env override 时双标记 + 顶部警告
- 模型不支持时面板禁用
- 两阶段交付:先基础面板 commit,再做 ultracode 波纹动画

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): 新增 EffortPanel 基础面板实施计划(第一阶段)

按 TDD 分 6 个 task:纯函数状态 → keybinding 注册 → 组件 → 命令挂载 → 分支测试 → precheck。
波纹动画在第二阶段单独 commit。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): plan 补 q/ctrl+c 取消绑定,对齐 spec §5 状态机

verifier 抓到的 gap:spec §5 写明 Esc / Ctrl+C / q 都是取消事件,
但 plan Task 2.3 只绑了 escape。补上 q 和 ctrl+c → effortPanel:cancel。
同时把 Step 2.2 直接写成 6 个 action 版本(home/end),删除迂回表达。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): plan 修订执行前 review 发现的 5 处 gap

- Task 3.3 EffortPanel.tsx 草稿:Faster/Smarter padEnd 语法错乱重写;
  useKeybindings import 路径从 @anthropic/ink 修正为 ../../keybindings/useKeybinding.js;
  移除冗余 renderSeparatorLine;保留 renderPaddedLine
- Task 5.2 computeConfirmOutcome 改为注入 ApplyFn 模式:
  避免 effortPanelState → effort.tsx → EffortPanel 循环依赖;
  测试可注入 mockApply,无需 mock settings
- Step 5.3 测试代码对齐注入版签名

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

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

* feat(keybindings): 注册 EffortPanel context 与 6 个 action

绑定 ←/→/h/l/home/end/enter/escape/q/ctrl+c 到 effortPanel:* action。
与 ModelPicker context 范式一致,避免左右键被全局 keybinding 拦截。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 实现 EffortPanel 组件主体(渲染 + 键盘交互 + 确认/取消分支)

- 横向 slider 布局:Faster ↔ Smarter 两极,6 档刻度
- useKeybindings 注册 EffortPanel context(←/→/h/l/home/end/enter/escape/q/ctrl+c)
- Enter 在 5 档之一 → 调 executeEffort 写 settings + AppState
- Enter 在 ultracode → 输出引导文案,不写状态
- Esc/q → "Effort unchanged."
- env override 时顶部黄色警告
- computeConfirmOutcome 注入 ApplyFn,便于测试(Task 5 补测试)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

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

* test(effort): 补 computeConfirmOutcome 分支测试(注入 mockApply)

- ultracode → kind=ultracode-hint,不调 applyFn
- low → kind=apply,message/effortUpdate 来自 applyFn
- applyFn 返回无 effortUpdate 时 outcome.effortUpdate 为 undefined
- CANCEL_MESSAGE / ULTRACODE_HINT 常量

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 测试里 cursor cast 为 EffortValue,避免 PanelPosition 含 ultracode 触发 TS 错误

computeConfirmOutcome 的 ApplyFn 契约要求 EffortValue,但测试 mockApply 接收 PanelPosition。
实际运行时 computeConfirmOutcome 在 ultracode 档位走 hint 分支不会调 applyFn,
cast 安全。precheck 全量通过:5688 tests / 0 fail。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 面板对齐与配色修复

- 对齐:用 Box width={SEGMENT} + justifyContent="center" 让 ▲ 与档位名严格居中对齐,
  替代之前 string padEnd(11) 与 SEGMENT=12 不一致导致的 1 列偏移
- 配色:所有面板文字改用 theme.claude(Claude Orange rgb(215,119,87)),
  替代终端默认紫;分隔线/副标签/底栏用 theme.subtle;env 警告用 theme.warning
- 光标档位的档位名也加粗,强化视觉焦点

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 面板文字改紫色,ULTRACODE_HINT 英文化

- 颜色:theme.claude(橙)→ theme.purple_FOR_SUBAGENTS_ONLY(Purple 600, rgb(147,51,234)),
  覆盖标题、Faster/Smarter、▲、档位名
- ULTRACODE_HINT:中文 → 英文
  "ultracode is not an effort level. Use /ultracode <context> to start a multi-agent workflow."

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 统一用色版——选中 suggestion(蓝),未选中 subtle(灰)

弃用 purple_FOR_SUBAGENTS_ONLY(subagent 专用)。改与项目其他面板一致:
- 选中档位 + ▲:color="suggestion"(Medium blue rgb(87,105,247))+ bold
- 未选中档位 + 空 ▲ 占位:color="subtle"(Light gray rgb(175,175,175))
- 标题 / Faster / Smarter:color="suggestion"
- 分隔线 / 副标签 / 底栏:color="subtle"

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 终态前补发 phase_done,面板自动退出 running→terminal 转换

runWorkflow:脚本结束时 hook.phase 不会触发最后一个 phase 的 phase_done,
UI 左栏会永远显示 running。三路径(completed/killed/failed)统一在 run_done
之前补发 emitTerminalPhaseDone。

WorkflowsPanel:抽 isRunTerminatedTransition 纯函数判定 running → terminal,
面板 useEffect 检测到转换后自动退出聚焦。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 波纹动画纯函数 pickChar/computeRippleLine/mergeLayers + 18 测试

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): useRippleFrame hook 包装 useAnimationFrame,按需订阅时钟

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): EffortPanel 集成波纹背景——cursor 停在 ultracode 时切换波纹模式

仅在 cursor === 'ultracode' 时启用 useRippleFrame,渲染 5 行波纹背景
+ overlay 文字(Faster/Smarter、分隔线、▲、档位名、副标签)。
其余档位保持原 PlainContent 渲染路径不动。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* refactor(effort): 波纹动画从字符密度改为颜色渐变

按原版风格把波纹背景从 INTENSITY_CHARS 密度字符('·∙░▒▓')改为
suggestion 系颜色渐变(transparent → 暗深紫蓝 → suggestion → 高光):

rippleAnimation.ts:
- 删除 pickChar / INTENSITY_CHARS / WAVE_PEAK_CHARS / mergeLayers
- 新增 intensityToColor(intensity) → 'transparent' | '#xxxxxx'
- 新增 computeRippleCells 返回 Cell[](每位置 char+color)
- 新增 applyOverlaysToCells(cells, overlays) 替代 mergeLayers
- 新增 cellsToSegments(cells) 合并相邻同色段(减少 Text 节点)

EffortPanel.tsx:
- RippleContent 用 cells→segments→tokens 渲染
- 空格段用 BaseText backgroundColor 染色块(纯色块视觉)
- 文字段用 Text color 染色(亮色突出)
- tokens 按空格/文字二次拆分,避免混合段渲染歧义

测试: 29 个 rippleAnimation 测试覆盖 intensityToColor 边界、
computeRippleCells 长度/震源/衰减、applyOverlaysToCells 覆盖/截断/
防御式拷贝、cellsToSegments 合并逻辑。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 波纹参数调优——铺满左侧 + 速度调慢 + 全面板有底色

用户反馈三个问题:
1. "低峰部分没有颜色变化" → intensity ≤ 0.1 返回 transparent 导致波谷
   位置看不见。改为永不返回 transparent,最低档 #0a0d1a 作为面板
   底色(暗紫黑海洋),波峰在底色上流动。
2. "波浪速度太快" → time 系数 0.012 → 0.004(约 1/3 速)。波峰移动
   速度从 34 cell/s 降到 11 cell/s,每帧颜色变化从 45% 降到 36%。
3. "波浪只到中间部分,没覆盖左侧" → falloff 覆盖半径 40 → 90。
   震源 x=65,左侧 dist=65 < 90,波纹可达最左端(约 30-50% 覆盖)。

色阶调整:
- 删除 transparent 档,新增 #0a0d1a 作最暗档(底色)
- 最高档从 #8aa0ff(高光)改为 #5769F7(suggestion),避免与
  文字 overlay 同色互相吞噬
- 7 档颜色:#0a0d1a → #15182b → #1f2543 → #2a3360 →
  #3a4582 → #4a5bb0 → #5769F7

测试:删除 transparent 期望,改为期望具体颜色(#0a0d1a 等)。
新增"覆盖半径扩大"测试验证 dist=65 仍有非最暗颜色。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 波纹 v3 — 去黑边 + 删中心高频涟漪 + y 轴覆盖快捷键行

用户反馈三个问题:
1. "黑色边感觉不太对" — 最暗档 #0a0d1a (rgb 10,13,26) 太接近纯黑,
   远端波谷看起来像硬黑边。改为 #1a1f3a (rgb 26,31,58),紫蓝感
   更强而非纯黑。
2. "中心的快速波纹有点奇怪" — 删除震源附近 dist<6 的高频涟漪叠加
   (time*0.02,5 倍主波纹频率)。原本想让震源附近"水波感"更强,
   实际效果像"快速闪烁"反而突兀。主波纹已经足够,无需叠加。
3. "y 方向覆盖快捷键" — RippleContent 新增 y=2 行渲染快捷键 overlay
   ("←/→ adjust · Enter confirm · Esc cancel")。PlainContent 路径
   保持原 Box marginTop=1 + Text 渲染。

色阶调整(紫蓝感更强):
- #1a1f3a (原 #0a0d1a) — 最暗档
- #1f2543 / #252c55 / #2e3870 / #3a4582 / #4a5bb0 / #5769F7
  (中间档略调亮度,保持平滑过渡)

测试:震源点测试更新为"time=0 时波谷最暗,time 推进后扫过波峰变亮",
反映删除高频涟漪后的纯主波纹行为。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore(workflow): 工作流相关代码中文文案全部英文化

源码(src/workflow/ + packages/workflow-engine/src/)的中文注释、
用户可见错误消息、字符串字面量;测试文件的标题与注释;同步 6 条
硬编码断言到英文化后的错误消息。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 波纹 v4 — 平滑波 + 全色环旋转 + 淡入淡出 + 宽度自适应

- 波函数改 (sin+1)/2:消除 max(0,sin) 平直暗带(约 6 行宽)
- 主色相连续旋转(0.03°/ms,12s/圈全色环):蓝→紫→品红→红→橙→黄→绿→青
- 文字 overlay 同步色相旋转(rotateHue 应用到 Faster/▲/档位名/分隔线/副标签)
- 淡入淡出动画:fadeColor/fadeCells + fade 状态机 ~300ms 进出过渡
- 副标签固定 ultracode 段下方,不跟随光标移动
- 顶部/底部各加一行纯波纹行,视觉一致
- 宽度自适应终端列数:窄则 72,宽则铺满(computeSegment/computeRippleSourceX)
- 快捷键改 plain Text,不参与波纹背景渲染
- 新增 18 测试(fadeColor/fadeCells/rotateHue/getHueShiftAtTime)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* refactor: remove CYBER_RISK_MITIGATION_REMINDER from FileReadTool

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>

* fix: prevent ReDoS in extractMeta regex by anchoring to splice boundary

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>

* chore: 更新脚本

---------

Co-authored-by: glm-5.1 <zai-org@claude-code-best.win>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-14 18:13:49 +08:00
committed by GitHub
parent 3e3e1de81b
commit 58ee6419b1
130 changed files with 23347 additions and 885 deletions

View File

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

View File

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

View File

@@ -1,145 +0,0 @@
import React, { useCallback, useMemo } from 'react';
import { Box, Text, useTheme } from '@anthropic/ink';
import { getTheme } from 'src/utils/theme.js';
import { env } from 'src/utils/env.js';
import { shouldShowAlwaysAllowOptions } from 'src/utils/permissions/permissionsLoader.js';
import { logUnaryEvent } from 'src/utils/unaryLogging.js';
import { PermissionDialog } from 'src/components/permissions/PermissionDialog.js';
import { PermissionPrompt, type PermissionPromptOption } from 'src/components/permissions/PermissionPrompt.js';
import type { PermissionRequestProps } from 'src/components/permissions/PermissionRequest.js';
import { PermissionRuleExplanation } from 'src/components/permissions/PermissionRuleExplanation.js';
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no';
/**
* Permission request UI for the WorkflowTool. Asks the user to confirm
* executing a workflow script.
* Follows the MonitorPermissionRequest / FallbackPermissionRequest pattern.
*/
export function WorkflowPermissionRequest({
toolUseConfirm,
onDone,
onReject,
workerBadge,
}: PermissionRequestProps): React.ReactNode {
const [themeName] = useTheme();
const theme = getTheme(themeName);
const input = toolUseConfirm.input as {
workflow: string;
args?: string;
};
const showAlwaysAllowOptions = useMemo(() => shouldShowAlwaysAllowOptions(), []);
const options: PermissionPromptOption<OptionValue>[] = useMemo(() => {
const opts: PermissionPromptOption<OptionValue>[] = [
{
label: 'Yes',
value: 'yes',
feedbackConfig: { type: 'accept' as const },
},
];
if (showAlwaysAllowOptions) {
opts.push({
label: (
<Text>
Yes, and don{'\u2019'}t ask again for <Text bold>{toolUseConfirm.tool.name}</Text> commands
</Text>
),
value: 'yes-dont-ask-again',
});
}
opts.push({
label: 'No',
value: 'no',
feedbackConfig: { type: 'reject' as const },
});
return opts;
}, [showAlwaysAllowOptions, toolUseConfirm.tool.name]);
const handleSelect = useCallback(
(value: OptionValue, feedback?: string) => {
switch (value) {
case 'yes':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
});
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback);
onDone();
break;
case 'yes-dont-ask-again':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
});
toolUseConfirm.onAllow(toolUseConfirm.input, [
{
type: 'addRules',
rules: [{ toolName: toolUseConfirm.tool.name }],
behavior: 'allow',
destination: 'localSettings',
},
]);
onDone();
break;
case 'no':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
});
toolUseConfirm.onReject(feedback);
onReject();
onDone();
break;
}
},
[toolUseConfirm, onDone, onReject],
);
const handleCancel = useCallback(() => {
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
});
toolUseConfirm.onReject();
onReject();
onDone();
}, [toolUseConfirm, onDone, onReject]);
return (
<PermissionDialog title="Workflow" workerBadge={workerBadge}>
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text bold color={theme.permission as any}>
Execute workflow: {input.workflow}
</Text>
{input.args && <Text dimColor>Arguments: {input.args}</Text>}
</Box>
<PermissionRuleExplanation permissionResult={toolUseConfirm.permissionResult} toolType="command" />
<PermissionPrompt<OptionValue> options={options} onSelect={handleSelect} onCancel={handleCancel} />
</Box>
</PermissionDialog>
);
}

View File

@@ -1,432 +0,0 @@
import { randomUUID } from 'crypto'
import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
import { join, parse } from 'path'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { truncate } from 'src/utils/format.js'
import { safeParseJSON } from 'src/utils/json.js'
import {
WORKFLOW_DIR_NAME,
WORKFLOW_FILE_EXTENSIONS,
WORKFLOW_TOOL_NAME,
} from './constants.js'
const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
const inputSchema = z.object({
workflow: z.string().describe('Name of the workflow to execute'),
args: z.string().optional().describe('Arguments to pass to the workflow'),
action: z
.enum(['start', 'status', 'advance', 'cancel', 'list'])
.optional()
.describe('Workflow action. Defaults to start.'),
run_id: z
.string()
.optional()
.describe('Workflow run id for status, advance, or cancel.'),
})
type Input = typeof inputSchema
type WorkflowInput = z.infer<Input>
type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled'
type WorkflowStep = {
name: string
prompt: string
status: WorkflowStepStatus
startedAt?: number
completedAt?: number
}
type WorkflowRun = {
runId: string
workflow: string
args?: string
status: 'running' | 'completed' | 'cancelled'
createdAt: number
updatedAt: number
currentStepIndex: number
steps: WorkflowStep[]
}
type WorkflowOutput = { output: string }
async function findWorkflowFile(
workflowDir: string,
workflow: string,
): Promise<{ path: string; content: string } | null> {
for (const ext of WORKFLOW_FILE_EXTENSIONS) {
const path = join(workflowDir, `${workflow}${ext}`)
try {
return { path, content: await readFile(path, 'utf-8') }
} catch {
// try next
}
}
return null
}
async function listAvailableWorkflows(workflowDir: string): Promise<string[]> {
try {
const files = await readdir(workflowDir)
return files
.filter(f =>
WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase()),
)
.map(f => parse(f).name)
.sort()
} catch {
return []
}
}
function workflowRunPath(cwd: string, runId: string): string {
return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`)
}
async function readWorkflowRun(
cwd: string,
runId: string,
): Promise<WorkflowRun | null> {
try {
const parsed = safeParseJSON(
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
false,
) as Partial<WorkflowRun> | null
if (
!parsed ||
typeof parsed.runId !== 'string' ||
typeof parsed.workflow !== 'string' ||
!Array.isArray(parsed.steps)
) {
return null
}
return parsed as WorkflowRun
} catch {
return null
}
}
async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise<void> {
await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true })
await writeFile(
workflowRunPath(cwd, run.runId),
JSON.stringify(run, null, 2) + '\n',
'utf-8',
)
}
async function listWorkflowRuns(cwd: string): Promise<WorkflowRun[]> {
let files: string[]
try {
files = await readdir(join(cwd, WORKFLOW_RUNS_DIR))
} catch {
return []
}
const runs = await Promise.all(
files
.filter(f => f.endsWith('.json'))
.map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))),
)
return runs
.filter((run): run is WorkflowRun => run !== null)
.sort((a, b) => b.updatedAt - a.updatedAt)
}
function parseMarkdownSteps(content: string): WorkflowStep[] {
const steps: WorkflowStep[] = []
for (const rawLine of content.split('\n')) {
const line = rawLine.trim()
const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/)
const bulletMatch = line.match(/^[-*]\s+(.+)$/)
const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/)
const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1]
if (!text) continue
steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' })
}
return steps
}
function parseYamlSteps(content: string): WorkflowStep[] {
const steps: WorkflowStep[] = []
let current: Partial<WorkflowStep> | null = null
const flush = () => {
if (!current) return
const prompt = current.prompt ?? current.name
if (current.name && prompt) {
steps.push({
name: current.name,
prompt,
status: 'pending',
})
}
current = null
}
for (const rawLine of content.split('\n')) {
const line = rawLine.trim()
const stepText = line.match(/^-\s+(.+)$/)?.[1]
if (stepText) {
flush()
const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1]
current = {
name: inlineName ?? stepText,
prompt: inlineName ? undefined : stepText,
}
continue
}
const name = line.match(/^name:\s*(.+)$/)?.[1]
if (name) {
if (!current) current = {}
current.name = name
continue
}
const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2]
if (prompt) {
if (!current) current = {}
current.prompt = prompt
}
}
flush()
return steps
}
function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] {
const ext = parse(filePath).ext.toLowerCase()
const steps =
ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content)
if (steps.length > 0) {
return steps
}
return [
{
name: 'Execute workflow',
prompt: content.trim(),
status: 'pending',
},
]
}
function formatStep(step: WorkflowStep, index: number): string {
return `Step ${index + 1}: ${step.name}\n${step.prompt}`
}
function formatRunStatus(run: WorkflowRun): string {
const lines = [
`Workflow run: ${run.runId}`,
`Workflow: ${run.workflow}`,
`Status: ${run.status}`,
`Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`,
`Steps: ${run.steps.length}`,
]
for (let i = 0; i < run.steps.length; i += 1) {
const step = run.steps[i]!
lines.push(` ${i + 1}. [${step.status}] ${step.name}`)
}
return lines.join('\n')
}
async function startWorkflow(
input: WorkflowInput,
cwd: string,
): Promise<WorkflowOutput> {
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
const found = await findWorkflowFile(workflowDir, input.workflow)
if (!found) {
const available = await listAvailableWorkflows(workflowDir)
const hint =
available.length > 0
? `\nAvailable workflows: ${available.join(', ')}`
: `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.`
return { output: `Error: Workflow "${input.workflow}" not found.${hint}` }
}
const steps = parseWorkflowSteps(found.path, found.content)
const now = Date.now()
steps[0] = { ...steps[0]!, status: 'running', startedAt: now }
const run: WorkflowRun = {
runId: randomUUID(),
workflow: input.workflow,
...(input.args ? { args: input.args } : {}),
status: 'running',
createdAt: now,
updatedAt: now,
currentStepIndex: 0,
steps,
}
await writeWorkflowRun(cwd, run)
const argsSection = input.args ? `\n\nArguments:\n${input.args}` : ''
return {
output: [
`Workflow run started`,
`run_id: ${run.runId}`,
`workflow: ${run.workflow}`,
'',
formatStep(steps[0]!, 0),
argsSection,
'',
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
].join('\n'),
}
}
async function getRunOrError(
cwd: string,
runId: string | undefined,
): Promise<{ run?: WorkflowRun; output?: string }> {
if (!runId) return { output: 'Error: run_id is required for this action.' }
const run = await readWorkflowRun(cwd, runId)
if (!run) return { output: `Error: Workflow run "${runId}" not found.` }
return { run }
}
async function advanceWorkflow(
cwd: string,
runId: string | undefined,
): Promise<WorkflowOutput> {
const found = await getRunOrError(cwd, runId)
if (!found.run) return { output: found.output! }
const run = found.run
const now = Date.now()
const current = run.steps[run.currentStepIndex]
if (current && current.status === 'running') {
current.status = 'completed'
current.completedAt = now
}
const nextIndex = run.currentStepIndex + 1
if (nextIndex >= run.steps.length) {
run.status = 'completed'
run.updatedAt = now
await writeWorkflowRun(cwd, run)
return { output: `Workflow completed\nrun_id: ${run.runId}` }
}
run.currentStepIndex = nextIndex
run.steps[nextIndex] = {
...run.steps[nextIndex]!,
status: 'running',
startedAt: now,
}
run.updatedAt = now
await writeWorkflowRun(cwd, run)
return {
output: [
`Next workflow step`,
`run_id: ${run.runId}`,
'',
formatStep(run.steps[nextIndex]!, nextIndex),
'',
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
].join('\n'),
}
}
async function cancelWorkflow(
cwd: string,
runId: string | undefined,
): Promise<WorkflowOutput> {
const found = await getRunOrError(cwd, runId)
if (!found.run) return { output: found.output! }
const run = found.run
const now = Date.now()
run.status = 'cancelled'
run.updatedAt = now
for (const step of run.steps) {
if (step.status === 'pending' || step.status === 'running') {
step.status = 'cancelled'
}
}
await writeWorkflowRun(cwd, run)
return { output: `Workflow cancelled\nrun_id: ${run.runId}` }
}
async function listWorkflowRunsForOutput(cwd: string): Promise<WorkflowOutput> {
const runs = await listWorkflowRuns(cwd)
if (runs.length === 0) return { output: 'No workflow runs recorded.' }
return {
output: runs
.slice(0, 20)
.map(
run =>
`${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`,
)
.join('\n'),
}
}
export const WorkflowTool = buildTool({
name: WORKFLOW_TOOL_NAME,
searchHint: 'execute user-defined workflow scripts',
maxResultSizeChars: 50_000,
strict: true,
inputSchema,
async description() {
return 'Execute and track a user-defined workflow from .claude/workflows/'
},
async prompt() {
return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps.
Actions:
- start (default): create a persisted workflow run and return the first step to execute
- advance: mark the current step complete and return the next step
- status: inspect a workflow run by run_id
- cancel: cancel a workflow run
- list: list recent workflow runs
Workflow run state is persisted in .claude/workflow-runs/.`
},
userFacingName() {
return 'Workflow'
},
isReadOnly(input) {
return input.action === 'status' || input.action === 'list'
},
isEnabled() {
return true
},
renderToolUseMessage(input: Partial<WorkflowInput>) {
const name = input.workflow ?? 'unknown'
const action = input.action ?? 'start'
return input.args
? `Workflow: ${action} ${name} ${input.args}`
: `Workflow: ${action} ${name}`
},
mapToolResultToToolResultBlockParam(
content: WorkflowOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: truncate(content.output, 50_000),
}
},
async call(input: WorkflowInput) {
const cwd = process.cwd()
const action = input.action ?? 'start'
switch (action) {
case 'start':
return { data: await startWorkflow(input, cwd) }
case 'status': {
const found = await getRunOrError(cwd, input.run_id)
return {
data: {
output: found.run ? formatRunStatus(found.run) : found.output!,
},
}
}
case 'advance':
return { data: await advanceWorkflow(cwd, input.run_id) }
case 'cancel':
return { data: await cancelWorkflow(cwd, input.run_id) }
case 'list':
return { data: await listWorkflowRunsForOutput(cwd) }
}
},
})

View File

@@ -1,104 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { WorkflowTool } from '../WorkflowTool'
let cwd: string
let previousCwd: string
beforeEach(async () => {
previousCwd = process.cwd()
cwd = join(
tmpdir(),
`workflow-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
await mkdir(join(cwd, '.claude', 'workflows'), { recursive: true })
process.chdir(cwd)
})
afterEach(async () => {
process.chdir(previousCwd)
await rm(cwd, { recursive: true, force: true })
})
describe('WorkflowTool', () => {
test('starts a workflow run and persists step state', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'release.md'),
['# Release', '', '- [ ] Run tests', '- [ ] Build package'].join('\n'),
)
const result = await WorkflowTool.call({ workflow: 'release' })
expect(result.data.output).toContain('Workflow run started')
expect(result.data.output).toContain('Run tests')
const match = result.data.output.match(/run_id: ([a-f0-9-]+)/)
expect(match?.[1]).toBeString()
const raw = await readFile(
join(cwd, '.claude', 'workflow-runs', `${match![1]}.json`),
'utf-8',
)
const run = JSON.parse(raw)
expect(run.workflow).toBe('release')
expect(run.status).toBe('running')
expect(run.steps).toHaveLength(2)
expect(run.steps[0].status).toBe('running')
expect(run.steps[1].status).toBe('pending')
})
test('advances a workflow run through completion', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'audit.yaml'),
[
'steps:',
' - name: Inspect',
' prompt: Inspect the code',
' - name: Verify',
' prompt: Run focused tests',
].join('\n'),
)
const started = await WorkflowTool.call({ workflow: 'audit' })
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
const next = await WorkflowTool.call({
workflow: 'audit',
action: 'advance',
run_id: runId,
})
expect(next.data.output).toContain('Next workflow step')
expect(next.data.output).toContain('Run focused tests')
const done = await WorkflowTool.call({
workflow: 'audit',
action: 'advance',
run_id: runId,
})
expect(done.data.output).toContain('Workflow completed')
})
test('lists and cancels workflow runs', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'cleanup.md'),
'- Remove stale files',
)
const started = await WorkflowTool.call({ workflow: 'cleanup' })
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
const listed = await WorkflowTool.call({
workflow: 'cleanup',
action: 'list',
})
expect(listed.data.output).toContain(runId)
const cancelled = await WorkflowTool.call({
workflow: 'cleanup',
action: 'cancel',
run_id: runId,
})
expect(cancelled.data.output).toContain('Workflow cancelled')
})
})

View File

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

View File

@@ -1,3 +0,0 @@
export const WORKFLOW_TOOL_NAME = 'workflow'
export const WORKFLOW_DIR_NAME = '.claude/workflows'
export const WORKFLOW_FILE_EXTENSIONS = ['.yml', '.yaml', '.md']

View File

@@ -1,46 +0,0 @@
import { readdir } from 'fs/promises'
import { join, parse } from 'path'
import type { Command } from 'src/types/command.js'
import { WORKFLOW_DIR_NAME, WORKFLOW_FILE_EXTENSIONS } from './constants.js'
/**
* Scans .claude/workflows/ directory and creates Command objects for each workflow file.
* Each workflow file becomes a slash command (e.g. /workflow-name).
*/
export async function getWorkflowCommands(cwd: string): Promise<Command[]> {
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
let files: string[]
try {
files = await readdir(workflowDir)
} catch {
return []
}
const workflowFiles = files.filter(f => {
const ext = parse(f).ext.toLowerCase()
return WORKFLOW_FILE_EXTENSIONS.includes(ext)
})
return workflowFiles.map(file => {
const name = parse(file).name
return {
type: 'prompt' as const,
name,
description: `Run workflow: ${name}`,
kind: 'workflow' as const,
source: 'builtin' as const,
progressMessage: `Running workflow ${name}...`,
contentLength: 0,
async getPromptForCommand(args, _context) {
const { readFile } = await import('fs/promises')
const content = await readFile(join(workflowDir, file), 'utf-8')
return [
{
type: 'text' as const,
text: `Execute this workflow:\n\n${content}${args ? `\n\nArguments: ${args}` : ''}`,
},
]
},
} satisfies Command
})
}

View File

@@ -0,0 +1,124 @@
/**
* registry 多后端路由演示mock adapter无需 API key
*
* 两个 adapterstrong被 researcher 路由命中)+ fast默认
* 脚本里 agent({agentType:'researcher'}) → strong其余 → fast。
* 证明 agent 后端可通过 AgentAdapterRegistry 插拔 + 路由,引擎不关心实现。
*
* 用法bun run packages/workflow-engine/examples/registry-demo.ts
*/
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
AgentAdapterRegistry,
createFileJournalStore,
createHostHandle,
runWorkflow,
type AgentAdapter,
type AgentRunParams,
type AgentRunResult,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
const strongAdapter: AgentAdapter = {
id: 'strong',
capabilities: { structuredOutput: true, tools: true },
async run(p: AgentRunParams): Promise<AgentRunResult> {
return {
kind: 'ok',
output: `[strong] ← ${p.prompt}`,
usage: { outputTokens: 1 },
}
},
}
const fastAdapter: AgentAdapter = {
id: 'fast',
capabilities: { structuredOutput: false },
async run(p: AgentRunParams): Promise<AgentRunResult> {
return {
kind: 'ok',
output: `[fast] ← ${p.prompt}`,
usage: { outputTokens: 1 },
}
},
}
const registry = new AgentAdapterRegistry()
.register(strongAdapter)
.register(fastAdapter)
.route({ kind: 'agentType', agentType: 'researcher', adapter: 'strong' })
.default('fast')
const SCRIPT = `
export const meta = { name: 'registry-demo', description: 'multi-adapter routing' }
phase('Route')
const research = await agent('深度调研任务', { agentType: 'researcher', label: 'research' })
const quick = await agent('快速小任务', { label: 'quick' })
return { research, quick }
`
function makePorts(runsDir: string): WorkflowPorts {
return {
// registry 优先agentRunner 仅作形状占位(不会被调到)
agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) },
agentAdapterRegistry: registry,
progressEmitter: {
emit: e => {
if (e.type === 'phase_started') console.log(`\n━ phase: ${e.phase}`)
else if (e.type === 'agent_done') {
const out =
e.result.kind === 'ok'
? String(e.result.output)
: `[${e.result.kind}]`
console.log(`${e.label}${out}`)
}
},
},
taskRegistrar: {
register: () => ({
runId: 'demo',
signal: new AbortController().signal,
}),
complete() {},
fail() {},
kill() {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: process.cwd(),
budgetTotal: null,
}),
}
}
if (import.meta.main) {
await registry.initializeAll()
try {
const result = await runWorkflow({
script: SCRIPT,
runId: `demo-${Date.now()}`,
ports: makePorts(join(tmpdir(), 'wf-registry-demo')),
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: process.cwd(),
budgetTotal: null,
})
console.log(`\n■ ${result.status}`)
if (result.status === 'completed') {
const ret = result.returnValue as { research: string; quick: string }
console.log(
`research(agentType:researcher) → ${ret.research.startsWith('[strong]') ? 'strong adapter ✓' : '??'}`,
)
console.log(
`quick(默认) → ${ret.quick.startsWith('[fast]') ? 'fast adapter ✓' : '??'}`,
)
}
} finally {
await registry.disposeAll()
}
}

View File

@@ -0,0 +1,74 @@
# research-report —— 库优先运行示例
`@claude-code-best/workflow-engine` **直接**运行一个 workflow绕开 Workflow 工具与核心 `runAgent`
## 状态
- **引擎层**:完整且测试覆盖 **99.65% 行 / 99.20% 函数**workflow-engine 包 112 个 mock 测试全绿)。
- **本 example**:编排逻辑(`parallel` / `pipeline` / `schema` / `args`)经 mock 端到端验证;**真实 LLM 已跑通**(直连 Anthropic SDK
- **定位**:库 API 与引擎逻辑的**参考实现 + 冒烟示范**,不是生产服务——见下方「生产就绪」。
## 它演示了什么
- **库可独立使用**`run.ts``import { runWorkflow, ... } from '@claude-code-best/workflow-engine'`,自己组装 7 个端口,不依赖 `src/` 任何核心模块。
- **agent 后端直连 Anthropic SDK**`agentRunner``client.messages.create`,子 agent = 一次模型调用(不经核心 `runAgent`、不经 Workflow 工具)。
- **真实 LLM + 结构化输出**`agent(schema)` → prompt 追加 JSON 指令 → 提取 JSON → `validateAgainstSchema`Ajv校验失败回退 `dead`
- **引擎能力全覆盖**`parallel`(屏障,多角度 fan-out`pipeline`(无屏障,逐条深挖)→ `phase` / `log` / `args`
## 运行
```bash
ANTHROPIC_API_KEY=sk-... \
bun run packages/workflow-engine/examples/research-report/run.ts "Edge Computing"
```
环境变量:
- `ANTHROPIC_API_KEY`(必填)
- `ANTHROPIC_MODEL`:默认 `claude-sonnet-4-5`
- `WORKFLOW_API_CONCURRENCY`API 并发上限,默认 `3`(见下)。低 tier 可设 `1` 串行
- `RESEARCH_RUNS_DIR`journal 目录,默认 `~/.claude/workflow-runs`resume 时复用)
## 健壮性与排错
runner 内置了几项让真实 API 跑得稳的处理:
- **API 并发限制**`llmAgent` 经独立信号量限并发(默认 3**独立于引擎的 CPU 级 semaphore**——LLM API 对并发远比 CPU 敏感,按 cores可能 14放并发会触发 429。用 `WORKFLOW_API_CONCURRENCY` 调。
- **429/5xx 重试**指数退避500ms → 1s → 2s → 4s最多 4 次);连接/超时错误也重试。
- **SDK 日志关闭**`new Anthropic({ logLevel: 'off' })`options 优先级最高,压过 `ANTHROPIC_LOG` env。否则 SDK 会打 `[log_xxxxx] sending request {…}` 这种完整请求 JSON。
- **错误摘要精简**:失败只打 `HTTP 429 rate_limit_error` 这种短行,不打印含 request body 的整段 message。
- **synthesize 防 JSON**prompt 明确禁止把输入的 `deepFindings` JSON 原样粘进报告。
排错速查:
| 现象 | 原因 / 处理 |
|------|------|
| `HTTP 429 ...` 频繁 | 降 `WORKFLOW_API_CONCURRENCY=1`(或 2 |
| agent `✗ [dead]` 多 | 模型未按 schema 返回 JSON换更强模型或放宽 schema |
| `[log_xxx] sending request` 刷屏 | 不应再出现(已 `logLevel:'off'`);若仍出现检查 env 是否覆盖 |
| 报告被截断 | synthesize 已 `maxTokens:8192`;仍不够可改 workflow 脚本 |
## 文件
| 文件 | 作用 |
|------|------|
| `research-report.workflow.mjs` | workflow 脚本(编排逻辑,纯 JS引擎沙箱执行 |
| `run.ts` | runner组装端口 + 直连 SDK + 运行 + 终端进度 |
| (同级 `../smoke.ts` | 最小冒烟入口3 次调用,秒级验证通路) |
## 扩展点
- **联网调研**:给 `llmAgent``messages.create``tools: [{ type: 'web_search_20250305' }]`Anthropic server-side web searchresearch 角度即可联网。
- **命名命令复用**:把 `research-report.workflow.mjs` 复制到项目 `.claude/workflows/research-report.mjs`,即可通过 `/research-report` 或 Workflow 工具运行(同一脚本,两种入口)。
- **token 预算**`runWorkflow({ budgetTotal: 200000 })` 设上限;脚本内用 `budget.remaining()` 自适应规模。
- **resume**:同 `runId` + `resume: true` 重放 journal已完成的 agent 不重跑。
## 生产就绪(诚实)
本 example 验证的是**库的 API 与引擎编排逻辑**,不是生产服务。要上生产还差:
- **真实 LLM 压测**:长 workflow、大量并发、中断/resume 的真实场景验证mock 覆盖不到模型行为)。
- **核心 adapter 的 v1 延期项**`budgetTotal` 注入、skip/retry UI、worktree 隔离、StructuredOutput 完整接入(本 example 用 prompt+JSON 解析,比核心真实路径弱)。
- **错误恢复**journal resume 只在 mock 验证过;真实中途崩溃的重放正确性未压测。
引擎核心逻辑(并发 / 预算 / journal / schema有 99.65% 覆盖的 mock 测试兜底,可作为基础继续建。

View File

@@ -0,0 +1,124 @@
// research-report.workflow.mjs
// 技术研究报告 workflow。
// 由 run.ts 通过 @claude-code-best/workflow-engine 的 runWorkflow() 直接执行——
// 不经 Workflow 工具、不经核心 runAgent。脚本内的 agent / parallel / pipeline /
// phase / log / args 均为引擎运行时注入的全局(见 src/engine/script.ts 的沙箱)。
//
// 编排多角度并行调研parallel 屏障)→ 逐条深挖pipeline 无屏障)→ 综合成报告。
export const meta = {
name: 'research-report',
description:
'Multi-angle tech research → deep-read → synthesize into a Markdown report',
whenToUse: '调研一个技术主题:从多个角度并行研究、逐条深挖、综合成结构化报告',
phases: [
{ title: 'Research', detail: '多角度并行调研parallel 屏障)' },
{ title: 'DeepRead', detail: '逐条深挖pipeline 无屏障)' },
{ title: 'Synthesize', detail: '综合成 Markdown 报告' },
],
}
// agent(schema) 让子 agent 返回「校验对象」而非纯文本。
const ANGLE_SCHEMA = {
type: 'object',
required: ['angle', 'findings'],
properties: {
angle: { type: 'string', description: '本次调研的角度名' },
findings: {
type: 'array',
items: {
type: 'object',
required: ['claim', 'evidence'],
properties: {
claim: { type: 'string', description: '一句话结论' },
evidence: { type: 'string', description: '依据/来源/理由' },
},
},
},
},
}
const DEEP_SCHEMA = {
type: 'object',
required: ['claim', 'analysis', 'confidence'],
properties: {
claim: { type: 'string' },
analysis: { type: 'string', description: '机理/前提/边界/反例' },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
},
}
// ---- 输入(由 run.ts 通过 args 透传)----
const topic = args.topic
if (typeof topic !== 'string' || topic.length === 0) {
throw new Error('research-report 需要 args.topic研究主题字符串')
}
const angles =
Array.isArray(args.angles) && args.angles.length > 0
? args.angles
: ['核心概念与原理', '主流方案与对比', '工程实践与权衡', '生态与趋势']
// ---- Phase 1多角度并行调研。parallel = 屏障,等所有角度完成后才继续。----
phase('Research')
log(`主题「${topic}」:${angles.length} 个角度并行调研中`)
const researched = await parallel(
angles.map(
a => () =>
agent(
`你是资深技术研究分析师。针对技术主题「${topic}」,从「${a}」角度调研,给出该角度下 2-4 条最关键的技术发现,每条须附依据。`,
{ label: `research:${a}`, phase: 'Research', schema: ANGLE_SCHEMA },
),
),
)
// parallel 返回 (object|null)[]skipped/dead 的角度为 null过滤后展平
const allFindings = researched
.filter(Boolean)
.flatMap(r => r.findings.map(f => ({ ...f, angle: r.angle })))
log(`收集到 ${allFindings.length} 条发现,进入深挖`)
if (allFindings.length === 0) {
return {
topic,
report: '(所有角度调研均失败,无可用发现)',
anglesCovered: 0,
findingsDeepened: 0,
}
}
// ---- Phase 2逐条深挖。pipeline = 无屏障,每条发现独立跑完所有 stage互不等待。----
phase('DeepRead')
const deepened = await pipeline(
allFindings,
f =>
agent(
`针对以下技术发现,深入分析其机理、成立前提、适用边界与可能的反例:\n结论:${f.claim}\n依据:${f.evidence}\n角度:${f.angle}`,
{ label: `deep:${f.angle}`, phase: 'DeepRead', schema: DEEP_SCHEMA },
),
// 第二个 stage按置信度标注交叉价值演示多 stage pipeline 链式传递)。
// stage-1 若 dead 返回 null这里显式守卫——避免对 null 取属性(否则被 pipeline
// 的 per-item catch 吞掉、整条静默丢失)。
d =>
d
? {
...d,
crossCutting:
d.confidence === 'high' ? '可作为报告主干' : '需谨慎引用或佐证',
}
: null,
)
const deepFindings = deepened.filter(Boolean)
log(`深挖完成 ${deepFindings.length}/${allFindings.length}`)
// ---- Phase 3综合成 Markdown 报告(无 schema → 返回纯文本)----
phase('Synthesize')
const report = await agent(
`你是首席技术分析师。基于以下经深挖的技术发现,综合一份结构化研究报告(纯 Markdown 叙述)。\n要求:含摘要、分角度分析、关键结论、落地建议与风险;用自然语言陈述每条发现并标注 confidence。\n禁止:在报告中粘贴 JSON 代码块或原样引用下方输入数据。\n\n主题:${topic}\n\n深度发现JSON仅供你理解不要原样输出\n${JSON.stringify(deepFindings)}`,
{ label: 'synthesize', phase: 'Synthesize', maxTokens: 8192 },
)
return {
topic,
report,
anglesCovered: angles.length,
findingsDeepened: deepFindings.length,
}

View File

@@ -0,0 +1,313 @@
/**
* research-report runner —— 直接用 @claude-code-best/workflow-engine 运行 workflow
* 完全绕开 Workflow 工具与核心 runAgent。agent() 后端直连 Anthropic SDK
* @anthropic-ai/sdk子 agent = 一次 messages.create。
*
* 用法:
* ANTHROPIC_API_KEY=sk-... \
* bun run packages/workflow-engine/examples/research-report/run.ts "Edge Computing"
*
* 可选环境变量:
* ANTHROPIC_MODEL 模型名,默认 claude-sonnet-4-5
* RESEARCH_RUNS_DIR journal 目录,默认 ~/.claude/workflow-runsresume 复用)
*/
import Anthropic from '@anthropic-ai/sdk'
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
createFileJournalStore,
createHostHandle,
runWorkflow,
Semaphore,
validateAgainstSchema,
type AgentRunParams,
type AgentRunResult,
type ProgressEvent,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
const SCRIPT_FILE = `${import.meta.dir}/research-report.workflow.mjs`
const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5'
const MAX_TOKENS = 4096
// 终端着色(无第三方依赖)
const paint = {
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
}
// client 由 main() 构造llmAgent 闭包引用。null 守卫使 import 时不触发真实调用。
const clientRef: { client: Anthropic | null } = { client: null }
// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3
// 用 WORKFLOW_API_CONCURRENCY 调整。
const apiSem = new Semaphore(
Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3),
)
/** 429/5xx/连接错误指数退避重试500ms → 1s → 2s → 4s最多 4 次。 */
async function withRetry<T>(fn: () => Promise<T>, retries = 4): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn()
} catch (e) {
if (!isRetryable(e) || attempt >= retries) throw e
const wait = Math.min(500 * 2 ** attempt, 8000)
await new Promise(r => {
setTimeout(r, wait)
})
}
}
}
function isRetryable(e: unknown): boolean {
const err = e as { status?: number; name?: string }
if (err.status === 429) return true
if (typeof err.status === 'number' && err.status >= 500) return true
if (typeof err.name === 'string' && /Connection|Timeout/i.test(err.name)) {
return true
}
return false
}
/** 精简错误摘要(避免打印整个含 request body 的 message。 */
function errSummary(e: unknown): string {
const err = e as {
status?: number
error?: { type?: string }
message?: string
}
if (err.status) return `HTTP ${err.status} ${err.error?.type ?? ''}`.trim()
return (err.message ?? 'unknown').slice(0, 120)
}
/**
* 真实 LLM agentRunner一次 messages.create经 API 并发信号量 + 重试)。
* schema 模式prompt 追加 JSON 指令 → 取文本 → 提取 JSON → Ajv 校验 → 失败返回 dead。
* 非 schema返回纯文本。
*/
async function llmAgent(params: AgentRunParams): Promise<AgentRunResult> {
const client = clientRef.client
if (client === null) return { kind: 'dead' }
const schemaInstruction = params.schema
? '\n\n你必须以一个【单独的 JSON 对象】作为整段回答(不要 Markdown 代码围栏、不要任何解释),该对象须匹配如下 JSON Schema\n' +
JSON.stringify(params.schema)
: ''
const release = await apiSem.acquire()
try {
const resp = await withRetry(() =>
client.messages.create({
model: params.model ?? DEFAULT_MODEL,
max_tokens: params.maxTokens ?? MAX_TOKENS,
messages: [
{ role: 'user', content: params.prompt + schemaInstruction },
],
}),
)
const outputTokens = resp.usage.output_tokens
const truncated = resp.stop_reason === 'max_tokens'
if (params.schema) {
// 截断的 JSON 几乎必然不完整 → 直接判 dead而非让解析模糊失败
if (truncated) return { kind: 'dead' }
const text = resp.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
const parsed = extractJsonObject(text)
if (parsed === null) return { kind: 'dead' }
const { valid } = validateAgainstSchema(parsed, params.schema)
if (!valid) return { kind: 'dead' }
return { kind: 'ok', output: parsed as object, usage: { outputTokens } }
}
const text = resp.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
if (truncated) {
console.error(
paint.yellow(` ⚠ 输出被 max_tokens 截断(${outputTokens} tokens`),
)
}
return { kind: 'ok', output: text, usage: { outputTokens } }
} catch (e) {
console.error(paint.red(`${errSummary(e)}`))
return { kind: 'dead' }
} finally {
release()
}
}
/**
* 容错 JSON 提取:去代码围栏 → 从首个 { 起做括号深度匹配(跳过字符串字面量与
* 转义,仿 src/engine/script.ts 的 extractMeta取配对的 {…} → JSON.parse。
* 比 lastIndexOf('}') 稳健:正确处理 JSON 后散文里含 }、第二个对象、字符串内 }。
*/
function extractJsonObject(text: string): unknown | null {
const stripped = text.replace(/```(?:json)?/gi, '').trim()
const start = stripped.indexOf('{')
if (start < 0) {
try {
return JSON.parse(stripped)
} catch {
return null
}
}
let depth = 0
let inStr: string | null = null
for (let i = start; i < stripped.length; i++) {
const ch = stripped[i]
if (inStr) {
if (ch === '\\') i++
else if (ch === inStr) inStr = null
continue
}
if (ch === '"' || ch === "'" || ch === '`') inStr = ch
else if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth === 0) {
try {
return JSON.parse(stripped.slice(start, i + 1))
} catch {
return null
}
}
}
}
return null
}
/** 内存版 taskRegistrar不经核心 LocalWorkflowTask仅维护 runId → AbortController。 */
function makeTaskRegistrar(): WorkflowPorts['taskRegistrar'] {
const controllers = new Map<string, AbortController>()
return {
register(opts) {
const ac = new AbortController()
const runId = opts.runId ?? `research-${controllers.size + 1}`
controllers.set(runId, ac)
return { runId, signal: ac.signal }
},
complete() {},
fail() {},
kill(runId) {
controllers.get(runId)?.abort()
},
pendingAction() {
return null
},
}
}
/** 进度事件 → 终端实时打印。 */
function printProgress(e: ProgressEvent): void {
switch (e.type) {
case 'run_started':
console.log(paint.bold(paint.cyan(`\n▶ ${e.workflowName}`)))
break
case 'phase_started':
console.log(paint.cyan(`\n━ phase: ${e.phase}`))
break
case 'phase_done':
break
case 'agent_started':
console.log(` ${paint.dim('→')} ${e.label ?? 'agent'}`)
break
case 'agent_done': {
const tag =
e.result.kind === 'ok'
? paint.green('✓')
: e.result.kind === 'skipped'
? paint.yellow('⊘')
: paint.red('✗')
console.log(
` ${tag} ${e.label ?? 'agent'} ${paint.dim(`[${e.result.kind}]`)}`,
)
break
}
case 'log':
console.log(` ${paint.dim('·')} ${e.message}`)
break
case 'run_done':
console.log(paint.bold(`\n■ ${e.status}`))
break
}
}
/** 组装端口agent 后端直连 SDK其余为自包含实现不触达核心层。 */
function makePorts(runsDir: string): WorkflowPorts {
return {
agentRunner: { runAgentToResult: llmAgent },
progressEmitter: { emit: printProgress },
taskRegistrar: makeTaskRegistrar(),
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: process.cwd(),
budgetTotal: null,
}),
}
}
async function main(): Promise<void> {
const topic = process.argv[2]
if (!topic) {
console.error(paint.red('✗ 用法run.ts <研究主题>'))
console.error(paint.dim(' 例bun run run.ts "Edge Computing"'))
process.exit(1)
}
clientRef.client = new Anthropic({ logLevel: 'off' })
const runsDir =
process.env.RESEARCH_RUNS_DIR ?? join(homedir(), '.claude', 'workflow-runs')
const script = await readFile(SCRIPT_FILE, 'utf-8')
const result = await runWorkflow({
script,
args: { topic },
runId: `research-${Date.now()}`,
ports: makePorts(runsDir),
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: process.cwd(),
budgetTotal: null,
})
if (result.status !== 'completed') {
console.error(
paint.red(`✗ workflow ${result.status}${result.error ?? ''}`),
)
process.exit(1)
}
const ret = result.returnValue as {
report?: string
topic?: string
anglesCovered?: number
findingsDeepened?: number
}
console.log(
paint.bold(
paint.green(`\n════════ 技术研究报告:${ret.topic ?? topic} ════════`),
),
)
console.log(
paint.dim(
`角度数=${ret.anglesCovered ?? '?'} 深挖=${ret.findingsDeepened ?? '?'}`,
),
)
console.log(ret.report ?? '(无报告输出)')
}
// 仅作为脚本直接运行时启动import 不触发,便于冒烟/复用端口工厂)
if (import.meta.main) {
await main()
}

View File

@@ -0,0 +1,251 @@
/**
* 冒烟端到端入口 —— 真实 SDK + 引擎,最小验证端到端通路。
* 3 次模型调用2 角度并行 schema + 1 综合),秒级完成、低成本。
* 覆盖runWorkflow、parallel屏障、agent(schema) 结构化、agent 文本、进度事件。
*
* 用法:
* ANTHROPIC_API_KEY=sk-... \
* bun run packages/workflow-engine/examples/smoke.ts
*
* 可选ANTHROPIC_MODEL默认 claude-sonnet-4-5
*/
import Anthropic from '@anthropic-ai/sdk'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
createFileJournalStore,
createHostHandle,
runWorkflow,
Semaphore,
validateAgainstSchema,
type AgentRunParams,
type AgentRunResult,
type ProgressEvent,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5'
const clientRef: { client: Anthropic | null } = { client: null }
const POINT_SCHEMA = {
type: 'object',
required: ['point'],
properties: { point: { type: 'string' } },
}
// 最小 workflow2 角度并行schema 结构化)→ 综合(文本)。脚本内用 + 拼接避免 ${}。
const SMOKE_SCRIPT =
`
export const meta = { name: 'smoke', description: 'minimal end-to-end smoke' }
phase('Smoke')
const angles = ['一句话定义', '一个最核心价值']
const points = await parallel(
angles.map(a => () =>
agent('用简短一句话30 字内)说明 workflow 编排的「' + a + '」。', {
label: 'p:' + a,
schema: ` +
JSON.stringify(POINT_SCHEMA) +
`,
}),
),
)
const clean = points.filter(Boolean)
const joined = clean.map(p => p.point).join('')
const summary = await agent('把以下要点综合成一句中文结论。要点:' + joined, {
label: 'summary',
})
return { points: clean, summary }
`
// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3
const apiSem = new Semaphore(
Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3),
)
/** 429/5xx/连接错误指数退避重试,最多 4 次。 */
async function withRetry<T>(fn: () => Promise<T>, retries = 4): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn()
} catch (e) {
if (!isRetryable(e) || attempt >= retries) throw e
const wait = Math.min(500 * 2 ** attempt, 8000)
await new Promise(r => {
setTimeout(r, wait)
})
}
}
}
function isRetryable(e: unknown): boolean {
const err = e as { status?: number; name?: string }
if (err.status === 429) return true
if (typeof err.status === 'number' && err.status >= 500) return true
if (typeof err.name === 'string' && /Connection|Timeout/i.test(err.name)) {
return true
}
return false
}
function errSummary(e: unknown): string {
const err = e as {
status?: number
error?: { type?: string }
message?: string
}
if (err.status) return `HTTP ${err.status} ${err.error?.type ?? ''}`.trim()
return (err.message ?? 'unknown').slice(0, 120)
}
async function llmAgent(params: AgentRunParams): Promise<AgentRunResult> {
const client = clientRef.client
if (client === null) return { kind: 'dead' }
const schemaInstruction = params.schema
? '\n\n以单独 JSON 对象回答(无围栏无解释),匹配 schema\n' +
JSON.stringify(params.schema)
: ''
const release = await apiSem.acquire()
try {
const resp = await withRetry(() =>
client.messages.create({
model: params.model ?? DEFAULT_MODEL,
max_tokens: params.maxTokens ?? 1024,
messages: [
{ role: 'user', content: params.prompt + schemaInstruction },
],
}),
)
const outputTokens = resp.usage.output_tokens
if (resp.stop_reason === 'max_tokens') return { kind: 'dead' }
const text = resp.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
if (params.schema) {
const parsed = extractJsonObject(text)
if (parsed === null) return { kind: 'dead' }
if (!validateAgainstSchema(parsed, params.schema).valid) {
return { kind: 'dead' }
}
return { kind: 'ok', output: parsed as object, usage: { outputTokens } }
}
return { kind: 'ok', output: text, usage: { outputTokens } }
} catch (e) {
console.error(`${errSummary(e)}`)
return { kind: 'dead' }
} finally {
release()
}
}
function extractJsonObject(text: string): unknown | null {
const stripped = text.replace(/```(?:json)?/gi, '').trim()
const start = stripped.indexOf('{')
if (start < 0) {
try {
return JSON.parse(stripped)
} catch {
return null
}
}
let depth = 0
let inStr: string | null = null
for (let i = start; i < stripped.length; i++) {
const ch = stripped[i]
if (inStr) {
if (ch === '\\') i++
else if (ch === inStr) inStr = null
continue
}
if (ch === '"' || ch === "'" || ch === '`') inStr = ch
else if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth === 0) {
try {
return JSON.parse(stripped.slice(start, i + 1))
} catch {
return null
}
}
}
}
return null
}
function makePorts(runsDir: string): WorkflowPorts {
return {
agentRunner: { runAgentToResult: llmAgent },
progressEmitter: {
emit: (e: ProgressEvent) => {
if (e.type === 'phase_started') console.log(`\n━ phase: ${e.phase}`)
else if (e.type === 'agent_started')
console.log(`${e.label ?? 'agent'}`)
else if (e.type === 'agent_done')
console.log(
` ${e.result.kind === 'ok' ? '✓' : '✗'} ${e.label ?? ''} [${e.result.kind}]`,
)
else if (e.type === 'log') console.log(` · ${e.message}`)
},
},
taskRegistrar: {
register: () => ({
runId: 'smoke',
signal: new AbortController().signal,
}),
complete() {},
fail() {},
kill() {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: process.cwd(),
budgetTotal: null,
}),
}
}
async function main(): Promise<void> {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) {
console.error('✗ 缺少 ANTHROPIC_API_KEY 环境变量')
process.exit(1)
}
clientRef.client = new Anthropic({ apiKey, logLevel: 'off' })
const runsDir =
process.env.RESEARCH_RUNS_DIR ?? join(homedir(), '.claude', 'workflow-runs')
const result = await runWorkflow({
script: SMOKE_SCRIPT,
args: {},
runId: `smoke-${Date.now()}`,
ports: makePorts(runsDir),
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: process.cwd(),
budgetTotal: null,
})
if (result.status !== 'completed') {
console.error(`\n✗ FAIL${result.status} ${result.error ?? ''}`)
process.exit(1)
}
const ret = result.returnValue as {
points: Array<{ point: string }>
summary: string
}
console.log('\n━━━━━━━━ 冒烟结果 ━━━━━━━━')
for (const p of ret.points) console.log(`${p.point}`)
console.log(`\n综合${ret.summary}`)
console.log(
`\n✓ PASS端到端通路正常${ret.points.length} 要点 + 综合3 次模型调用)`,
)
}
if (import.meta.main) {
await main()
}

View File

@@ -0,0 +1,19 @@
{
"name": "@claude-code-best/workflow-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"dependencies": {
"ajv": "^8.18.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.81.0"
}
}

View File

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

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

View File

@@ -0,0 +1,94 @@
import { expect, test } from 'bun:test'
import { createEngineContext } from '../engine/context.js'
import { makeHooks } from '../engine/hooks.js'
import { createBufferingEmitter } from '../progress/events.js'
import { createHostHandle, type WorkflowPorts } from '../ports.js'
import type { AgentRunParams, AgentRunResult } from '../types.js'
function build(results: Map<string, AgentRunResult>) {
const { emitter, events } = createBufferingEmitter()
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async (p: AgentRunParams) =>
results.get(p.prompt) ?? { kind: 'dead' },
},
progressEmitter: emitter,
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
signal: new AbortController().signal,
cwd: '/tmp',
budgetTotal: null,
}),
}
const ctx = createEngineContext({
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r',
workflowName: 'w',
cwd: '/tmp',
budgetTotal: null,
})
return { ctx, events, hooks: makeHooks(ctx, async () => null) }
}
test('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])
})

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

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

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

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

View File

@@ -0,0 +1,51 @@
import { expect, test } from 'bun:test'
import {
createBufferingEmitter,
createProgressEmitter,
} from '../progress/events.js'
import type { ProgressEvent } from '../types.js'
const log = (message: string): ProgressEvent =>
({ type: 'log', runId: 'r', message }) as ProgressEvent
const phase = (p: string): ProgressEvent =>
({ type: 'phase_started', runId: 'r', phase: p }) as ProgressEvent
test('createBufferingEmitter 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')
})

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

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

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

View File

@@ -0,0 +1,113 @@
import { expect, test } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { agentCallKey, createFileJournalStore } from '../engine/journal.js'
import type { AgentRunParams } from '../types.js'
const base: AgentRunParams = { prompt: 'do something' }
test('agentCallKey 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 })
}
})

View File

@@ -0,0 +1,68 @@
import { expect, test } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
listNamedWorkflows,
resolveNamedWorkflow,
} from '../engine/namedWorkflows.js'
test('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 })
}
})

View File

@@ -0,0 +1,56 @@
import { expect, test } from 'bun:test'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { containsPath, sanitizeWorkflowName } from '../engine/paths.js'
test('containsPath: target 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()
})

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

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

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

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

View File

@@ -0,0 +1,168 @@
import { expect, test } from 'bun:test'
import {
ScriptError,
extractMeta,
parseScript,
type WorkflowHooks,
} from '../engine/script.js'
const stubHooks: WorkflowHooks = {
agent: async () => 'agent-result',
parallel: async thunks =>
Promise.all(
thunks.map(async t => {
try {
return await t()
} catch {
return null
}
}),
),
pipeline: async () => [],
phase: () => {},
log: () => {},
workflow: async () => null,
}
test('extractMeta 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')
})

View File

@@ -0,0 +1,40 @@
import { expect, test } from 'bun:test'
import { validateAgainstSchema } from '../engine/structuredOutput.js'
const schema = {
type: 'object',
required: ['name', 'count'],
properties: {
name: { type: 'string' },
count: { type: 'number' },
},
additionalProperties: false,
}
test('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,
)
})

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

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

View 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

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

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

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

View 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'
}
}

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

View File

@@ -0,0 +1,50 @@
import { createHash } from 'node:crypto'
import { appendFile, mkdir, readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import type { JournalStore } from '../ports.js'
import type { AgentRunParams, JournalEntry } from '../types.js'
/** 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 })
},
}
}

View File

@@ -0,0 +1,46 @@
import { readFile, readdir } from 'node:fs/promises'
import { join, parse, resolve } from 'node:path'
import { WORKFLOW_SCRIPT_EXTENSIONS } from '../constants.js'
import { containsPath } from './paths.js'
type Ext = (typeof WORKFLOW_SCRIPT_EXTENSIONS)[number]
function isScriptExt(ext: string): ext is Ext {
return (WORKFLOW_SCRIPT_EXTENSIONS as readonly string[]).includes(
ext.toLowerCase(),
)
}
/** 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()
}

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

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

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

View 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'),
}
}

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

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

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

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

View File

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

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

View 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

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

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"types": ["bun"],
"lib": ["ESNext"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}