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

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

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

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-13 20:07:18 +08:00
parent 91cffe16e2
commit d236880bc3
106 changed files with 16127 additions and 834 deletions

View File

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