Files
claude-code/packages/workflow-engine/src/engine/hooks.ts
claude-code-best d236880bc3 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>
2026-06-13 20:07:18 +08:00

210 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { MAX_ITEMS_PER_CALL, MAX_TOTAL_AGENTS } from '../constants.js'
import type {
AgentRunParams,
AgentRunResult,
JournalEntry,
ProgressEvent,
} from '../types.js'
import type { EngineContext } from './context.js'
import { WorkflowAbortedError, WorkflowError } from './errors.js'
import { agentCallKey } from './journal.js'
import type { WorkflowHooks } from './script.js'
/** workflow() 钩子的子 workflow 执行器(由 runWorkflow 注入,避免循环依赖)。 */
export type SubWorkflowRunner = (opts: {
name?: string
scriptPath?: string
script?: string
args?: unknown
}) => Promise<unknown>
type HookProgressInit =
| { type: 'phase_started'; phase: string }
| { type: 'phase_done'; phase: string }
| { type: 'agent_started'; agentId: number; label?: string; phase?: string }
| {
type: 'agent_done'
agentId: number
label?: string
phase?: string
result: AgentRunResult
}
| { type: 'log'; message: string }
export function makeHooks(
ctx: EngineContext,
runSubWorkflow: SubWorkflowRunner,
): WorkflowHooks {
// 所有进度事件自动注入 runId供 adapter 路由到对应 task多并发 workflow
const emit = (init: HookProgressInit): void => {
ctx.ports.progressEmitter.emit({
runId: ctx.runId,
...init,
} as ProgressEvent)
}
const agent: WorkflowHooks['agent'] = async (prompt, opts = {}) => {
const r = ctx.resources
if (r.agentCountBox.value >= MAX_TOTAL_AGENTS) {
throw new WorkflowError(
`workflow 超过 agent 总数上限 (${MAX_TOTAL_AGENTS})`,
)
}
// 每次 agent() 调用分配唯一 id含 journal 命中),盖戳 started/done 供 reducer 精确关联
const agentId = r.agentIdSeq.value++
const params: AgentRunParams = { prompt, ...opts }
const key = agentCallKey(prompt, params)
const label = opts.label as string | undefined
const phase =
(opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined
// journal 命中 → 直接返回缓存
if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) {
const entry = ctx.journal[ctx.journalIndex]!
if (entry.key === key) {
ctx.journalIndex++
emit({
type: 'agent_done',
agentId,
label,
phase,
result: entry.result,
})
return resultToOutput(entry.result)
}
// 发散:丢弃后续 journal后续全部现场跑
ctx.journalInvalidated = true
ctx.journal = ctx.journal.slice(0, ctx.journalIndex)
await ctx.ports.journalStore.truncate(ctx.runId)
}
let release: () => void
try {
release = await ctx.resources.semaphore.acquire(ctx.signal)
} catch {
// abort 期间在队列中等待semaphore 已把 waiter 移除、未消耗 permit
throw new WorkflowAbortedError()
}
try {
if (ctx.signal.aborted) throw new WorkflowAbortedError()
// 预算检查在 semaphore 临界区内queued waiter 被唤醒后看到最新 spent
// 否则 N 个 waiter 入队时 spent=0 全过检,唤醒后无 re-check 全部超支。
// journal 命中路径不扣预算,无需检查。
r.budget.assertCanSpend()
const pending = ctx.ports.taskRegistrar.pendingAction(ctx.runId)
if (pending?.kind === 'skip') {
const result: AgentRunResult = { kind: 'skipped' }
emit({ type: 'agent_done', agentId, label, phase, result })
return null
}
ctx.resources.agentCountBox.value++
emit({ type: 'agent_started', agentId, label, phase })
const registry = ctx.ports.agentAdapterRegistry
const result = registry
? await registry.resolve(params).run(params, {
host: ctx.host,
signal: ctx.signal,
runId: ctx.runId,
})
: await ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
if (result.kind === 'ok') {
ctx.resources.budget.addOutputTokens(result.usage.outputTokens)
}
emit({ type: 'agent_done', agentId, label, phase, result })
const entry: JournalEntry = { key, seq: agentId, result }
// 关键push 顺序 = 完成顺序非调用顺序read() 已按 seq 重排,
// 因此 resume 时调用顺序与 journal 顺序对齐key 索引稳定。
ctx.journal.push(entry)
ctx.journalIndex++
await ctx.ports.journalStore.append(ctx.runId, entry)
return resultToOutput(result)
} finally {
release()
}
}
const parallel: WorkflowHooks['parallel'] = async thunks => {
if (thunks.length > MAX_ITEMS_PER_CALL) {
throw new WorkflowError(
`parallel 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`,
)
}
return Promise.all(
thunks.map(async (t, i) => {
try {
return await t()
} catch (e) {
// "null on error"契约不变,但应 log——否则 workflow 作者无法定位为何 agent 失败
ctx.ports.logger.warn?.(
`parallel thunk #${i} failed: ${(e as Error).message}`,
)
return null
}
}),
)
}
const pipeline: WorkflowHooks['pipeline'] = async <T, R>(
items: readonly T[],
...stages: Array<
(prev: unknown, item: T, index: number) => Promise<unknown>
>
): Promise<Array<R | null>> => {
if (items.length > MAX_ITEMS_PER_CALL) {
throw new WorkflowError(
`pipeline 超过单次调用 items 上限 (${MAX_ITEMS_PER_CALL})`,
)
}
return Promise.all(
items.map(async (item, index): Promise<R | null> => {
try {
let prev: unknown = item
for (const stage of stages) {
prev = await stage(prev, item, index)
}
return prev as R
} catch (e) {
ctx.ports.logger.warn?.(
`pipeline item #${index} failed: ${(e as Error).message}`,
)
return null
}
}),
)
}
const phase: WorkflowHooks['phase'] = title => {
if (ctx.currentPhase) {
emit({ type: 'phase_done', phase: ctx.currentPhase })
}
ctx.currentPhase = title
emit({ type: 'phase_started', phase: title })
}
const log: WorkflowHooks['log'] = message => {
emit({ type: 'log', message })
}
const workflow: WorkflowHooks['workflow'] = async (nameOrRef, args) => {
if (ctx.resources.depth >= 1) {
throw new WorkflowError('workflow() 嵌套仅允许一层')
}
const sub: Parameters<SubWorkflowRunner>[0] =
typeof nameOrRef === 'string'
? { name: nameOrRef }
: { scriptPath: nameOrRef.scriptPath }
return runSubWorkflow({ ...sub, args })
}
return { agent, parallel, pipeline, phase, log, workflow }
}
function resultToOutput(result: AgentRunResult): unknown {
return result.kind === 'ok' ? result.output : null
}