workflow loader 与 phase 生命周期 深度解析
从 deer-workflow 源码逐段确认该模块的契约、执行路径、事件语义和失败边界。
导入链与契约
workflow loader 与 phase 生命周期 的第一层证据来自 src/flow/workflow.ts:90、src/flow/workflow.ts:94、src/flow/workflow.ts:97、src/flow/workflow.ts:101 与 src/flow/context.ts:30:这些行把入口、上下文、错误或配置集中在一个小范围内,说明 deer-workflow 倾向把复杂度留在明确契约里,而不是散到调用者脚本。这里的设计价值是让 Agent 自动化可以被测试、被日志系统消费,也能被 CLI 和库调用共享。
90export async function workflow<TOutput = unknown, TArgs = unknown>( 91 target: WorkflowTarget, 92 args?: TArgs, 93): Promise<TOutput> { 94 const parentContext = getWorkflowContext(); 95 const depth = parentContext ? parentContext.depth + 1 : 0; 96 97 if (depth > MAX_NESTED_WORKFLOW_DEPTH) { 98 throw new WorkflowNestingError(depth); 99 } 100 101 const scriptPath = resolveWorkflowPath(target, parentContext); 102 const context: WorkflowExecutionContext<TArgs | undefined> = { 103 id: crypto.randomUUID(), 104 parentId: parentContext?.id, 105 depth, 106 scriptPath, 107 args, 108 }; 109 110 return runInWorkflowContext(context, async () => { 111 const startedAt = performance.now(); 112 emitWorkflowEvent({ 113 type: "workflow:start", 114 ...toWorkflowEventContext(context), 115 }); 116 117 try { 118 const definition = await loadWorkflowDefinition< 119 TArgs | undefined, 120 TOutput 121 >(scriptPath); 122 if (definition.meta !== undefined) { 123 emitWorkflowEvent({ 124 type: "workflow:meta", 125 ...toWorkflowEventContext(context), 126 meta: definition.meta, 127 }); 128 } 129 const result = await definition.handler(args, context); 130 endCurrentPhase(context); 131 emitWorkflowEvent({ 132 type: "workflow:end", 133 ...toWorkflowEventContext(context), 134 durationMs: elapsedMilliseconds(startedAt), 135 }); 136 return result; 137 } catch (error) { 138 endCurrentPhase(context); 139 emitWorkflowEvent({ 140 type: "workflow:error", 141 ...toWorkflowEventContext(context), 142 durationMs: elapsedMilliseconds(startedAt), 143 error: serializeWorkflowError(error), 144 }); 145 throw error; 146 } 147 });
核心实现,逐段拆
▸入口与状态
第二层证据是 src/flow/workflow.ts:118、src/flow/workflow.ts:129、src/flow/workflow.ts:137、src/flow/phase.ts:34 与 src/flow/phase.ts:49。这些引用展示了失败路径、事件路径或参数路径如何闭环:不是只在 happy path 发一个结果,而是把输入解析、阶段切换、stdout/stderr、schema 或 duration 都纳入可解释的运行轨迹。
34export function phase(title: string): void { 35 const normalizedTitle = title.trim(); 36 if (!normalizedTitle) { 37 throw new TypeError("phase() requires a non-empty title."); 38 } 39 40 const context = getWorkflowContext(); 41 if (!context) { 42 throw new PhaseContextError(); 43 } 44 45 if (context.phase === normalizedTitle) { 46 return; 47 } 48 49 endCurrentPhase(context); 50 context.phase = normalizedTitle; 51 phaseStartTimes.set(context, performance.now()); 52 emitWorkflowEvent({ 53 type: "workflow:phase:start", 54 ...toWorkflowEventContext(context), 55 phase: normalizedTitle, 56 }); 57} 58 59/** 60 * Reads the active Workflow's current phase. 61 * 62 * @returns The phase title, or `undefined` outside a Workflow or before the 63 * first call to {@link phase}. 64 */ 65export function getCurrentPhase(): string | undefined { 66 return getWorkflowContext()?.phase; 67} 68 69/** 70 * Ends the active phase and emits its lifecycle event. 71 * 72 * @internal 73 */ 74export function endCurrentPhase( 75 context: WorkflowExecutionContext = requireWorkflowContext(), 76): void { 77 const currentPhase = context.phase; 78 if (!currentPhase) { 79 return; 80 } 81 82 const startedAt = phaseStartTimes.get(context); 83 context.phase = undefined; 84 phaseStartTimes.delete(context); 85 86 emitWorkflowEvent({ 87 type: "workflow:phase:end", 88 ...toWorkflowEventContext(context), 89 phase: currentPhase, 90 durationMs: elapsedMilliseconds(startedAt), 91 });
▸失败与边界
第三层证据来自 src/flow/workflow.ts:232、src/flow/workflow.ts:246、src/flow/workflow.ts:271、tests/flow/workflow.test.ts:175 与 tests/flow/workflow.test.ts:187。这些行说明这个模块的边界主要靠类型和测试守住:当 workflow 嵌套、metadata、JSON input、CLI 命令或 Agent 子进程出现异常时,源码会抛出具名错误或保留原始诊断,而不是吞掉上下文。
220 value: unknown, 221 scriptPath: string, 222): WorkflowMeta | undefined { 223 if (value === undefined) { 224 return undefined; 225 } 226 227 if (!isModuleRecord(value)) { 228 throw invalidWorkflowMeta(scriptPath, "meta must be an object"); 229 } 230 231 const { name, description, phases } = value; 232 if (typeof name !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { 233 throw invalidWorkflowMeta(scriptPath, "meta.name must be kebab-case"); 234 } 235 if ( 236 typeof description !== "string" || 237 !description.trim() || 238 /[\r\n]/.test(description) || 239 hasControlCharacters(description) 240 ) { 241 throw invalidWorkflowMeta( 242 scriptPath, 243 "meta.description must be a non-empty, one-line string", 244 ); 245 } 246 if (!Array.isArray(phases) || phases.length === 0) { 247 throw invalidWorkflowMeta( 248 scriptPath, 249 "meta.phases must be a non-empty array", 250 ); 251 } 252 253 const titles = phases.map((phase, index) => { 254 if ( 255 !isModuleRecord(phase) || 256 typeof phase.title !== "string" || 257 !phase.title.trim() || 258 hasControlCharacters(phase.title) 259 ) { 260 throw invalidWorkflowMeta( 261 scriptPath, 262 `meta.phases[${index}].title must be a non-empty string`, 263 ); 264 } 265 return phase.title.trim(); 266 }); 267 if (new Set(titles).size !== titles.length) { 268 throw invalidWorkflowMeta(scriptPath, "meta.phases titles must be unique"); 269 } 270 271 const exampleArgs = parseExampleArgs(value.exampleArgs, scriptPath); 272 return Object.freeze({ 273 name, 274 description: description.trim(), 275 phases: Object.freeze(titles.map((title) => Object.freeze({ title }))), 276 ...(exampleArgs === undefined ? {} : { exampleArgs }), 277 }); 278} 279 280function parseExampleArgs( 281 value: unknown, 282 scriptPath: string, 283): Readonly<Record<string, WorkflowExampleValue>> | undefined { 284 if (value === undefined) { 285 return undefined; 286 } 287 if (!isModuleRecord(value) || Array.isArray(value)) { 288 throw invalidWorkflowMeta( 289 scriptPath, 290 "meta.exampleArgs must be a JSON-safe object", 291 ); 292 } 293 294 return Object.freeze( 295 Object.fromEntries( 296 Object.entries(value).map(([key, item]) => [ 297 key, 298 cloneExampleValue(item, scriptPath), 299 ]), 300 ), 301 ); 302} 303 304function cloneExampleValue( 305 value: unknown, 306 scriptPath: string, 307): WorkflowExampleValue { 308 if ( 309 value === null || 310 typeof value === "boolean" || 311 typeof value === "number" || 312 typeof value === "string" 313 ) { 314 if (typeof value === "number" && !Number.isFinite(value)) { 315 throw invalidWorkflowMeta( 316 scriptPath, 317 "meta.exampleArgs must contain only finite JSON numbers", 318 ); 319 } 320 return value; 321 } 322 if (Array.isArray(value)) { 323 return Object.freeze( 324 value.map((item) => cloneExampleValue(item, scriptPath)), 325 ); 326 } 327 if (isModuleRecord(value)) { 328 return Object.freeze( 329 Object.fromEntries( 330 Object.entries(value).map(([key, item]) => [ 331 key, 332 cloneExampleValue(item, scriptPath), 333 ]), 334 ), 335 ); 336 } 337 throw invalidWorkflowMeta( 338 scriptPath, 339 "meta.exampleArgs must contain only JSON-safe values", 340 ); 341} 342 343function invalidWorkflowMeta( 344 scriptPath: string, 345 detail: string, 346): WorkflowLoadError { 347 return new WorkflowLoadError( 348 scriptPath, 349 `Invalid Workflow metadata at ${scriptPath}: ${detail}.`, 350 );
关键流程
▸端到端调用路径
最后看 src/flow/phase.ts:52、src/flow/phase.ts:74、src/flow/context.ts:42、tests/flow/workflow.test.ts:199 与 tests/flow/workflow.test.ts:205。这组证据把实现和测试连起来:源码定义行为,测试断言事件顺序、错误闭合和返回值结构。对真实 coding-agent 平台来说,这比 README 示例更有价值,因为它回答了失败时系统还剩下哪些可观察信号。
边界 / 失败模式
- 源码把错误路径显式化,但调用方仍要决定 null、throw 和 retry 的业务语义。
- CLI adapter 依赖宿主机安装对应命令,测试中通过 stub 覆盖,生产中还需要版本治理。
- 事件流是同步 emitter,监听器自身不能做阻塞重活。