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