工作流引擎
适用端:后端 + Web | 类型:机制
定制功能
本页全部内容属于本项目自研的 enlev-module-agent,上游 yudao 没有对应实现。 上游的 enlev-module-bpm 是 Flowable 审批流,与本页讲的「AI 编排 DAG」是两套东西, 不要混:BPM 处理人的审批链,工作流引擎处理节点的数据流,两者只在 hitl.bpm_approval 这一个节点类型上交汇。
定位:说清「一份 DSL JSON 怎么变成一张能跑、能挂起、能恢复、能追溯的图」—— 13 种节点类型各自的实现位置、编译器怎么把它们搭成 SAA 图、checkpoint 挂起与 resume 的真实语义、 发布与生效为什么不是一回事、以及 34 条校验规则卡在哪一层。
前置阅读:架构总览(AgentExecutionContext 身份快照)、 ReAct 循环与流式对话(agent_loop 节点的另一个宿主)、 工具体系(tool 节点与 agent_loop 都经 AgentToolExecutor 收口)。
它解决什么问题
低代码平台的用户要的是「拖几个框连起来就能跑」。要做到这件事,中间必须有一层 把用户的图翻译成某个执行引擎能跑的东西。本项目选的引擎是 spring-ai-alibaba-graph-core(下称 SAA, 本项目用 1.1.2.2),它提供 StateGraph / CompiledGraph / checkpoint / 中断恢复。
🔴 引擎不是全自研的,是「DSL + 编译器 + 执行器 SPI」适配到 SAA 上
DslToStateGraphCompiler.java:57 的类 javadoc 第一句就写明: 「DSL JSON → SAA CompiledGraph 的编译器(适配层核心,DSL §1 原则 2「引擎中立的可编译性」)」。
这层适配买到的东西是「换引擎只改编译器」,付出的代价是必须接受 SAA 的原生约束, 而其中两条会直接影响用户能画出什么图:
- 1 个 DSL 节点 ↔ 1 个 SAA 节点(SPI 硬约束)。所以
parallel的执行器加不了边, fan-out/fan-in 只能在建图期展开。 - SAA compile 会把子图内联展开成多节点链,破坏「各分支直接汇聚同一 join」的收敛规则。 所以
sub_workflow不能作 parallel 分支,多节点并行分支在 1.1.2.2 下无解,expandParallel对此编译期拒绝。
代码在哪
引擎代码集中在 framework/agent/core/engine/,按职责分了 9 个子包(compiler / config / context / dsl / executor / io / support / trace / trigger):
⚠️ 下表「行数」列是快照,会随后端提交漂(2026-10-25 复核就有 4 项对不上:WorkflowDslValidator 5341→5800、ScriptSandbox 192→276、AgentLoopGraphBuilder 677→694、executor/impl 合计 1056→1060,本页已订正)。要拿它当判据前先 wc -l 重数;「职责」列才是这页要传的信息,别为了对齐行数去改它。
路径(enlev-module-agent/src/main/java/cn/enlev/ainb/module/agent/) | 行数 | 职责 |
|---|---|---|
framework/agent/core/engine/compiler/DslToStateGraphCompiler.java | 1363 | 编译器:DSL → SAA CompiledGraph |
framework/agent/core/engine/compiler/DslParser.java | 71 | Jackson 解析 DSL(ignoreUnknown) |
framework/agent/core/engine/compiler/KeyStrategyFactoryGenerator.java | — | 生成 state 键的 merge 策略(全 REPLACE) |
framework/agent/core/engine/dsl/*.java(7 个 DTO) | — | WorkflowDefDTO / NodeDTO / EdgeDTO / TriggerDTO / SettingsDTO / VariableDTO / GraphReachability |
framework/agent/core/engine/executor/NodeExecutor.java | 71 | 执行器 SPI(只有两个方法,见下) |
framework/agent/core/engine/executor/NodeExecutorRegistry.java | 63 | 按 type() 收集执行器 |
framework/agent/core/engine/executor/impl/*.java(10 个) | 1060 | 10 种类型的执行器 |
framework/agent/core/engine/io/NodeIoContract.java | 468 | 13 种类型的输入/输出端口契约登记表(只描述、不执行) |
framework/agent/core/engine/context/WorkflowContext.java | 108 | 单次执行的上下文(executionId / threadName / 身份快照 / metrics) |
framework/agent/core/engine/context/NodeMetrics.java | — | 节点级 token 计量 |
framework/agent/core/engine/trace/NodeTraceRecorder.java | 225 | 写 agent_node_execution(沙箱上下文里 no-op) |
framework/agent/core/engine/support/StateTemplateResolver.java | 244 | 解析 {{$.var}} 模板 |
framework/agent/core/engine/support/StatePathNavigator.java | — | 按路径取 state 值 |
framework/agent/core/engine/support/ScriptSandbox.java | 276 | script 节点的 JS 沙箱 |
framework/agent/core/engine/trigger/WorkflowTriggerInvoker.java | — | 无头触发(webhook / form_event / schedule) |
framework/agent/core/engine/trigger/WorkflowTriggerExtractor.java | — | 从 DSL 抽触发配置 |
framework/agent/core/engine/trigger/FormDataChangedTriggerListener.java | 91 | form_event 触发(@TransactionalEventListener(AFTER_COMMIT)) |
framework/agent/core/engine/config/WorkflowEngineConfiguration.java | — | SAA bean 装配(MysqlSaver 等) |
framework/agent/core/validate/WorkflowDslValidator.java | 5800 | V 系列校验规则(34 个号,缺 V17),全模块最大的单文件 |
framework/agent/core/loop/AgentLoopGraphBuilder.java | 694 | agent_loop → 环形子图 |
framework/agent/core/loop/IterationGraphBuilder.java | 247 | iteration → 循环子图 |
service/workflow/AgentWorkflowDefServiceImpl.java | 1258 | 定义 CRUD / 发布 / 版本 / 强删 |
service/workflow/AgentWorkflowExecutionServiceImpl.java | 986 | 执行 / resume / cancel / 事件广播 |
service/workflow/AgentWorkflowSandboxServiceImpl.java | — | 沙箱试跑(去持久化、去配额) |
service/workflow/AgentWebhookTriggerServiceImpl.java | — | 匿名 webhook 入站 + 验签 |
controller/admin/workflow/AgentWorkflowDefController.java | 403 | 定义侧 20 个端点 |
controller/admin/workflow/AgentWorkflowController.java | 157 | 执行侧 7 个端点 |
前端(ainb-ui-admin-vue3/src/):
| 路径 | 职责 |
|---|---|
views/agent/workflow/editor/ | 设计器画布、属性面板、editorModel.ts |
views/agent/workflow/editor/nodeIoContract.ts | 🔴 后端契约的手抄镜像,见坑 4 |
views/agent/workflow/nodeConfigSchema.ts | 属性面板的字段 schema(面向用户的那份提示语在这里) |
views/agent/workflow/nodeSummary.ts | 节点摘要文案 |
views/agent/workflow/publishGate.ts | 发布预检判定(纯模块) |
api/agent/workflow/index.ts | 执行侧 API |
api/agent/planner/index.ts | 🔴 WorkflowEventVO 住在这里,被 workflow/index.ts 再导出,形成仅类型的循环引用 |
核心机制
一、13 种节点类型:10 个有执行器,3 个是编译期合成的子图
NodeIoContract.java 是 13 种类型的唯一登记表,按声明顺序分四组 (all() 返回 List.copyOf(CONTRACTS.values()),保持顺序):
| # | type | 显示名 | 分组 | 执行器 | 行数 |
|---|---|---|---|---|---|
| 1 | tool | 工具 | 基础 | ToolNodeExecutor | 83 |
| 2 | llm | 大模型 | 基础 | LlmNodeExecutor | 102 |
| 3 | knowledge_retrieval | 知识检索 | 基础 | KnowledgeRetrievalNodeExecutor | 143 |
| 4 | script | 脚本 | 基础 | ScriptNodeExecutor | 90 |
| 5 | condition.rule | 规则条件 | 分支与并行 | RuleConditionNodeExecutor | 194 |
| 6 | condition.llm | LLM 条件 | 分支与并行 | LlmConditionNodeExecutor | 133 |
| 7 | parallel | 并行 | 分支与并行 | ParallelNodeExecutor(no-op) | 55 |
| 8 | iteration | 循环遍历 | 分支与并行 | ❌ IterationGraphBuilder 合成子图 | 247 |
| 9 | hitl.confirm | 人工确认 | 人工介入 | HitlConfirmNodeExecutor | 71 |
| 10 | hitl.bpm_approval | BPM 审批 | 人工介入 | HitlBpmApprovalNodeExecutor | 131 |
| 11 | wait | 等待 | 人工介入 | WaitNodeExecutor(不等待) | 54 |
| 12 | agent_loop | 自主循环 | 高级 | ❌ AgentLoopGraphBuilder 合成子图 | 677 |
| 13 | sub_workflow | 子流程 | 高级 | ❌ 子图本身就是节点动作 | — |
⚠️ 是 13 种,不是 10 种——按执行器目录清点会少数 3 个
engine/executor/impl/ 下正好 10 个文件,很容易得出「平台支持 10 种节点」的结论。 差的 3 个(agent_loop / iteration / sub_workflow)没有执行器,由编译器在建图期合成子图。
WorkflowDslValidator.java:4672 自己就把这条口径钉住了: 「今天两个方向差集都为空(手抄 10 + GRAPH_BUILDER_TYPES 3 = 契约 13)」。 GRAPH_BUILDER_TYPES 定义在 :274:
private static final Set<String> GRAPH_BUILDER_TYPES = Set.of("sub_workflow", "agent_loop", "iteration");三处独立实现对得上:后端 NodeIoContract.types()(13)、 WorkflowDslValidator 的 isKnownType:4251-4253(GRAPH_BUILDER_TYPES.contains(type) || nodeExecutorRegistry.supports(type))、 前端 NODE_TYPE_META(13)。
🔴 上面这两个行号与方法名都已订正过:本页上一版写的是 isSupportedType:4078,而文件里现在压根没有叫 isSupportedType 的方法(grep -n isSupportedType 命中 0),它叫 isKnownType。引用一个不存在的成员比引用一个漂移的行号更坏——行号错了读者还能顺着上下文找到,名字错了读者会以为「这条口径已经被删了」。这也是本页所有 :行号 都只当线索、不当判据的原因。
🔴 派生优于手抄:knownTypesHint() 的一条实测教训
WorkflowDslValidator.java:4666-4682(knownTypesHint() 的 javadoc,方法体在 :4684)逐字记录了为什么「type 未知」的错误文案不能手抄类型清单:
🔴 派生自
NodeIoContract#all(),不手抄:原先这里手抄了 10 个字面量, 与契约那 13 型之间没有任何比对(台账 #101(a))——契约加第 14 型时这里会静默少提示一个, 而少提示的后果是模型照着提示改、改完仍报「不被支持」,来回打转。
以及一条更细的:
⚠️ 必须用
all()而不能用types():前者是List.copyOf(values()),保持声明顺序 (按「基础 / 分支与并行 / 人工介入 / 高级」分组,与前端目录同序); 后者是Set.copyOf(keySet()),无序——用它就等于把顺序抖动从 3 个扩到 13 个。 🔴 抖动是实测出来的,不是推演:Set.of的迭代顺序掺了每次 JVM 启动都不同的 SALT, 把原实现原样跑 12 次得到 5 种不同的串(那 3 个的排列计数 3+3+1+4+1=12, 而List.of那 10 个恒为 1 种)。这句文案是喂给模型自我纠错的, 顺序不稳还会让任何想逐字钉住它的测试变成偶发红(flaky 通常被当环境问题忽略掉)。
强制点是 KnownTypesHintSingleSourceTest(4 条 / 220 行)的源码扫描——不许改回同值手抄。
⚠️ 本页上一版把这段引作 :4493-4508,且引文中间悄悄省掉了「排列计数 3+3+1+4+1=12」那半句。行号漂了是常态,省引文不是:标了「逐字」就得逐字,要删得写「……」,否则读者拿它去比对源码会以为源码被改过。
执行器 SPI 极简(NodeExecutor.java,71 行,只有两个成员):
public interface NodeExecutor {
Map<String, Object> execute(NodeDTO node, WorkflowContext ctx) throws Exception;
}外加一个 ROUTE_KEY_PREFIX 常量(条件节点用它写路由键 __route_<id>)。
二、两个「什么都不做」的执行器,各有一条硬理由
这是全模块最容易被误读的两处——按名字读代码会以为它们坏了。
wait:本执行器不等待
WaitNodeExecutor.java:16-24 逐字:
🔴 本执行器「不等待」—— 等待是 checkpoint 挂起天然实现的
编译器对每个 wait 节点登记
interruptBefore(id):SAA 在执行到该节点之前挂起、checkpoint 落库、 stream 正常结束(末元素是InterruptionMetadata)。挂起期不占线程、可跨进程跨重启都在 (与hitl.confirm同机制)。谁来触发 resume 取决于 waitType:duration 由 Service 算resume_at落库、WorkflowWaitResumeScheduler扫resume_at<=now到点自动 resume;event 由 Service 落event_key、 外部系统POST /agent/workflow/event按 key 匹配后 resume。resume 之后本执行器才被调用, 它只把「等过了」记一笔标记后放行下游。所以这里没有任何 sleep / 轮询,且与 waitType 无关。
支持 waitType=duration(定时)与 waitType=event(外部事件)两种。 waitType=hitl 不做——它等价 hitl.confirm 节点,编译期 validateWaitConfig 明确拒。
parallel:no-op pass-through,并行语义不在这里
ParallelNodeExecutor.java:13-25 逐字:
🔴 本执行器是 no-op pass-through,并行语义不在这里
fan-out/fan-in 是建图期的结构,由
DslToStateGraphCompiler读 parallel 节点的config.branches展开成 SAA 原生边:addEdge(par, branchNodes)分发、addEdge(branchNodes, joinTarget)汇聚屏障(=join=all)。受 SAA 原生约束,MVP 分支为单节点 (多节点链需子图封装 → 增量2b sub_workflow)。执行器不能加 SAA 节点/边 (SPI 的硬约束:1 个 DSL 节点 ↔ 1 个 SAA 节点),所以它只负责:
- 作为 fan-out 的单一锚点——图里真实存在的一个节点,任何前驱(静态边 / 条件边 routeMap / START 兜底)都能指向它,无需为并行改写路由表;
- 在
agent_node_execution留一条 0-token 的 trace,作为用户在设计器里看得见的「分叉点」。
condition.* 是同构的分工:执行器管节点自身语义,编译器管结构性建边。 条件节点因此是「SAA 节点(算路由)+ 条件边(读路由)」两半, :70-73 的理由是「把『可能调模型、要记 trace』的决策放进节点,边只做纯读映射。 这样 condition.llm 的 token 能正常归因,条件边保持廉价」。
三、每次执行编译一张新图,不缓存
DslToStateGraphCompiler.java:60-64 逐字:
🔴 每次执行编译一张新图,不缓存
节点 lambda 要闭包捕获本次执行的 executionId / threadName / 身份快照,才能把 trace 落到正确的执行实例上。缓存图会让这些执行级标识串台。StateGraph 的构建+编译是纯内存操作, 开销可忽略;真正重的 MysqlSaver 是共享 bean,不随图重建。
所以「编译慢」不是这条路的瓶颈,MysqlSaver 才是共享的那部分。
编译期即校验,不留到运行时(:66-70):节点类型没有对应执行器 → registry.get 抛 WORKFLOW_NODE_EXECUTOR_NOT_FOUND;边指向不存在的节点、节点 id 重复、图为空 → 抛 WORKFLOW_DSL_INVALID。「这些都是『配置错误』,必须在跑第一个节点之前就报清楚, 而不是执行到一半 NPE。」
sub_workflow 的入/出参映射是父侧合成的 plumbing 节点
SAA 原生子图整包喂入、共享 key 空间,无入/出参过滤钩子。所以显式变量映射不改子图, 而是在父侧合成两个无 trace 的节点(同 iteration 的 _guard / _advance): _<sw>_in 按 inputMap 把父值写进子流程入参名(子图跑前), _<sw>_out 按 outputMap 把子结果写回父变量名(子图跑后)。 X→sw→Y 于是被改写成 X→_in→sw→_out→Y。
🔴 这是重命名/显式契约而非硬隔离——子图技术上仍见未映射的父键(SAA 整包喂入所致)。 硬物理隔离需放弃原生子图,代价是丢掉子图 SSE 上浮,留后续。
四、checkpoint:挂起、resume、以及「释放 ≠ 删掉」
hitl.confirm / hitl.bpm_approval / wait 三种节点靠编译器登记 interruptBefore(id) 实现挂起。 挂起时:checkpoint 落库、stream 正常结束(末元素是 InterruptionMetadata)、 执行状态置 INTERRUPTED、不占线程、可跨进程跨重启。
agent_workflow_execution 上有三列为这件事服务:
| 列 | 语义 |
|---|---|
thread_name | varchar(128) UNI,格式 t{tenant}:{execId},SAA checkpoint 的寻址键 |
suspend_node | 挂起时正在等待的闸口节点 id;NULL = 非挂起态,或取不到 checkpoint 快照(刻意不回落成错值) |
dropped_keys | resume 时会被静默丢弃、且作者能自己修的 state 键集(JSON 数组);挂起时算、resume 时重算覆盖 |
🔴 「释放 checkpoint」不删任何快照行——2026-10-08 现场 + 字节码双向实测
AgentWorkflowDefServiceImpl.java:648-668 是全模块取证最扎实的一段注释,逐字:
⚠️ 「释放」不是「删掉」—— 2026-10-08 现场 + 字节码双向实测
MysqlSaver#releasedCheckpoints只发一条 SQL:UPDATE GRAPH_THREAD SET is_released = TRUE WHERE thread_name = ? AND is_released = FALSE。 它不碰GRAPH_CHECKPOINT——快照行留在库里,只是从此读不出来 (MysqlSaver的 SELECT 带t.is_released != TRUE)。 🔴 而在 1.1.2.2 里根本不存在能物理删快照的代码路径:MysqlSaver声明的DELETE_CHECKPOINTS(DELETE FROM GRAPH_CHECKPOINT WHERE checkpoint_id = ?) 是个死常量——javap -p -c全文里它在方法体中的命中数为 0; 两个rollback(Connection, …)重载做的是 JDBC 的Connection.rollback()(回滚未提交的事务),也不删行。 唯一真能带走快照的是GRAPH_THREAD行上的外键ON DELETE CASCADE, 而那要先删GRAPH_THREAD——SAA 没给这个接口,本平台也不该裸删别人的表。现场对账(样本 def #584、其执行 #175 对应的那个 thread):强删后
is_released0→1, 而该 thread 名下仍有 2 行graph_checkpoint。 🔴 故任何报告都不得把本方法的效果写成「checkpoint 清零」。
实际后果两条:
graph_checkpoint会随执行次数单调增长,强删定义也带不走它。要清理只能自己写 DDL/DML, 而那要先删GRAPH_THREAD行(ON DELETE CASCADE)——属于「裸删别人的表」,需另行评估。@Transactional回滚不了这条 UPDATE(AgentWorkflowDefService.java:194逐字强调)。 所以释放被刻意排在级联软删之前:真失败时整单中止(WORKFLOW_DEF_DELETE_CHECKPOINT_FAILED) 且业务行一行未动,「中止」等于「什么都没发生」。 否则会先删掉执行行、把thread_name这个唯一寻址键一起抹掉, 剩下的快照就成了永远查不出属于谁、也没有任何接口能再删的孤儿。
释放时两类失败必须分开对待(:668-679),否则会变成「一次失败就永远删不掉」:
| SAA 抛的异常 | 含义 | 本平台处置 |
|---|---|---|
IllegalStateException("Thread '%s' not found or already released") | 目标状态已达成 | 记一行 warn 后继续。拦下来会造成死结:一单里两条执行、第二条释放失败 ⟹ 整单中止 ⟹ 重跑时第一条已是 released ⟹ 再抛 ⟹ 这行定义永远删不掉 |
其余(Exception("Unable to release checkpoint", cause)) | 真失败(连不上库、SQL 报错) | 整单中止 |
⚠️ 这条区分认的是 SAA 抛的异常类型。哪天它换了类型,这里退化成「一律中止」—— 方向是安全的那一边(宁可不删,不可删一半)。
thread_name 为空时跳过并记一行 warn:没有寻址键,SAA 那边本来也不可能有它的快照, 这不是失败,不该把整单拦下来。
五、发布 ≠ 生效:primary_flag 与版本回落
同一个 code 可以有多个 PUBLISHED 版本(createDef 出新版本不停用旧版)。 执行入口固定走 AgentWorkflowDefMapper.selectPublishedByCode:32:
default AgentWorkflowDefDO selectPublishedByCode(String code) {
return selectOne(new LambdaQueryWrapperX<AgentWorkflowDefDO>()
.eq(AgentWorkflowDefDO::getCode, code)
.eq(AgentWorkflowDefDO::getStatus, WorkflowDefStatusEnum.PUBLISHED.getStatus())
.orderByDesc(AgentWorkflowDefDO::getPrimaryFlag)
.orderByDesc(AgentWorkflowDefDO::getVersion)
.last("LIMIT 1"));
}排序 primary_flag DESC, version DESC 的语义(:29-31 逐字): 「🔴 主版本(增量14b)优先,无主版本时退化成『version 最大』。 存量行 primary_flag 全为默认 0/false,故排序结果与增量14b 之前逐字一致(完全向后兼容); 只有显式『设为主版本』后,生效版本才切到被指定的那一版。」
🔴 「发布了」与「现在跑的是哪一版」是两个问题
四个查询各有各的口径,读代码时极易混:
| 方法 | 过滤 | 回答的问题 |
|---|---|---|
selectPublishedByCode | status=PUBLISHED + 上述排序 | 现在跑的是哪一版(执行入口) |
selectByCodeAndVersion | 不带 status | sub_workflow pin 到具体版本时用;DISABLED 版本仍可被已 pin 的调用跑到,DRAFT 不可 |
selectLatestByCode | 不带 status,取 version 最大 | 发布动作要看「最新那一版现在是 DRAFT 还是已 PUBLISHED」 |
selectPublishedVersions | status=PUBLISHED,同排序 | 版本列表页 |
⚠️ 由此推出一个反直觉的行为:把一个 PUBLISHED 版本 disable 掉之后, selectPublishedByCode 根本选不到它,生效版本会自动回落到次新的 PUBLISHED 版本 (AgentWorkflowDefMapper.java:101-107 逐字写明)。停用不等于停摆。
agent_workflow_def 上与触发相关的三列(都是 DSL trigger 的投影,为了能被索引扫到):
| 列 | 语义 |
|---|---|
trigger_type | chat / form_event / schedule / webhook |
trigger_key | form_event = formCode:eventType;schedule = cron 表达式 |
trigger_next_fire_at | schedule 下次应触发时刻;轮询器扫 <=now 触发并 CAS 推进 |
owner_user_id | 🔴 无头触发的执行身份 = 定义发布者用户号;为空 fail-close 跳过 |
CAS 推进的取向是「宁可丢一次触发,不要重复副作用」——竞争失败方直接放弃。
六、webhook:不存在免签分支
AgentWebhookTriggerServiceImpl.java:43-46 与 :149-153 逐字:
③ authType 缺失、为
none、或是运行期不认识的值(DSL 被直改等)→ 同样拒。 本类不存在免签分支:不鉴权的 webhook 等于把发布者身份挂在公网上(入站 body 会一路流进 state → prompt → BPM formVars),「靠不可猜的 path 保安全」这个前提已被证伪 (path 会进日志、进前端 URL、进浏览器历史与代理缓存)。
🔴 只认
hmac/token两种。没有none分支,缺省也不再归一化成 none—— 空值与未知值一律落到default被拒。历史遗留的authType=noneDSL(落库校验放开前建的) 到这里会一律拒收:这是刻意的。
private void verifyAuth(Map<String, Object> config, String rawBody, String signature, String token) {
String rawAuthType = str(config, "authType");
// 空白归一成空串(不是 none):既躲开 switch(null) 的 NPE,又让它落进 default 被拒
String authType = StrUtil.isBlank(rawAuthType) ? "" : rawAuthType.toLowerCase();
String secret = str(config, "secret");
switch (authType) {
case "hmac" -> {
// 配了 hmac 却没 secret(DSL 被直改绕过落库校验)→ fail-close,绝不退化成免签
if (StrUtil.isBlank(secret) || StrUtil.isBlank(signature)) {
throw exception(WORKFLOW_WEBHOOK_SIGNATURE_INVALID);
}
String expected = hmacSha256Hex(secret, rawBody == null ? "" : rawBody);
if (!constantTimeEquals(expected, normalizeSignature(signature))) {
throw exception(WORKFLOW_WEBHOOK_SIGNATURE_INVALID);
}
}
case "token" -> { /* 同款 fail-close */ }
// 缺省 / none / 任何运行期不认识的 authType:fail-close(绝不默认免签放行)
default -> throw exception(WORKFLOW_WEBHOOK_SIGNATURE_INVALID);
}
}签名比较用 MessageDigest.isEqual(:34 逐字:「不随首个不同字节早退,杜绝按字节计时推断正确签名的时序侧信道」), 封装在 constantTimeEquals:219。
🔴 入站 body 有两道闸,只有第一道会让调用方知情
:47-61 的类 javadoc 把分工写得很清楚:
| 闸 | 位置 | 量什么 | 阈值 | 动作 | 调用方知情? |
|---|---|---|---|---|---|
| 第一道 | AgentWebhookTriggerServiceImpl(HTTP 层) | 整个 body 的字符数 | enlev.agent.webhook.max-body-length,默认 1 MiB(application.yaml:466) | 整条拒,抛 WORKFLOW_WEBHOOK_BODY_TOO_LARGE | ✅ 拿到非零 code |
| 第二道 | WorkflowTriggerInvoker#invokeHeadless(进 state 前) | 每个字符串叶子 | webhook 档 32 KiB(AgentSanitizeProperties.java:56) | 静默截断 + 剥控制字符 | ❌ 仍是 200 + data:true |
第一道排在最前,连 path 都还没反查。第二道覆盖 webhook / form_event / schedule 三个源, 故不可能只在 webhook 这一类里做。
⚠️ 必须知情的空隙(逐字):「body 整体没超第一道、但某个叶子超了第二道时,对调用方而言是静默截断 (HTTP 200、data:true、响应体里没有任何提示)。这是刻意取舍而不是疏漏—— 要把『被截了』告诉调用方就得改 CommonResult<Boolean> 的返回形状, 而那个形状已经是对外契约……改了会打断存量对接方。观测路径是服务端那条 warn(含源、原长、上限),排障时按它查。」
⚠️ 第一道闸不护内存:走到这里时 Spring 已把整个 body 读进 String 了, 而 application/json 不受 Tomcat maxPostSize 约束(容器层默认没有字节上限)。 它的职责是「别为注定被削减的请求付解析代价」+「让调用方知情」。
七、沙箱试跑:去持久化,但真烧 token
AgentWorkflowSandboxServiceImpl.java:34-42 逐字:
投递纪律、事件模型与
AgentWorkflowExecutionServiceImpl同源,但去掉所有持久化与配额: 不建执行实例、不 settle、不写 trace(NodeTraceRecorder在 sandbox 上下文里 no-op)、不计配额。 用内存 checkpoint(MemorySaver)而非持久MysqlSaver,跑完即弃。🔴 命中中断节点(hitl / wait)即结束本次沙箱——v1 不支持沙箱内 resume, 前端据
INTERRUPTED事件显示「沙箱在此停止」banner。
🔴 沙箱不是 mock:llm 节点真调模型、tool 节点真调只读工具
「不计配额」指的是不写 agent_quota 的用量判定,不是「不产生消耗」。 沙箱里 llm / condition.llm / agent_loop 走的是同一个 AiModelApi, 真发请求、真烧厂商 token;tool 节点走同一个 AgentToolExecutor,真读库。 只有写工具被挡住——但那是 agent_loop 的只读闸在挡,不是沙箱在挡(见ReAct 循环)。
两个合成常量说明了它的定位:SANDBOX_EXECUTION_ID = -1L(绝不落库,仅占位使事件结构与正式执行一致)、 SANDBOX_TIMEOUT_SECONDS = 120(比正式执行的 600s 短,「试跑不该长时间占线程」)。
所以「在沙箱里点一下试试」是一次真实计费的动作,只是账单上不记名。
八、V 系列:校验规则全在一个 5800 行的文件里
WorkflowDslValidator.java 是全模块最大的单文件,34 个规则号(V1~V16 + V18~V35)、33 个 private void validate* 方法。
🔴 这两个数不相等,也不该相等:规则号在这个文件里只存在于注释中(排除注释行后 grep '"[^"]*V[0-9]\+' 命中 0,2026-10-25 复核仍成立),一个方法可以承载多个号——validateToolRefs 一个方法就管着 V1/V2/V3 三个号。所以别拿方法数当号数。
🔴 号域是 34 个,不是 36 个——而「36」是全文 grep 必然得出的错答案
V17 从来不是一条规则(它「结构上不可触发」这件事记在 ChangeManifest#untrustedInputs 与 UntrustedInputTracer 的类注释里,校验器这边压根没落地过);V36 是下一个可用号,还没被占用。
所以 grep -o "V[0-9]\+" WorkflowDslValidator.java | sort -u 数出「36 个号、V1~V36 无缺口」——多出来的两个正是 V17 与 V36,它们只以散文形式出现在别的规则的注释里。权威口径写在 WorkflowDslValidator:5349-5441,也就是埋在 V20 自己的 javadoc 里(按内容定位,别抄行号:搜「顺带钉一个实测口径(缺陷 D4-07)」那句);复算方法、以及「全文 grep 必然自污染」这条禁令是怎么被论证的,见 Plan 预览确认与 HITL · V 号段。
本页初版就写着「33 条编号规则(V1–V33)」,取证方法正是上面那条被明令禁用的全文 grep;同批写作的 Plan 预览确认与 HITL 先犯了同一个错、然后顺着那段 javadoc 纠了回来,两页一度互相矛盾。教训不是「grep 不可靠」,而是:规则号只存在于注释里的文件,注释自己会提到别的号,全文匹配必然自污染。
⚠️ 这个错答案还会跟着号域一起长大:本页上一版钉的是「31 个号 / grep 得 33」,V33~V35 落地后就成了「34 个号 / grep 得 36」,而「grep 出来的数 = 真号数 + 2」这个偏差量反倒一直没变——它精确等于权威段自己点名的两个非规则号。所以别把「36」当成一次性的笔误去改,每次都要重数。
它服务三个调用方,口径必须一致:
POST /agent/workflow-def/validate——设计器与发布预检POST /agent/workflow-def/publish——发布闸门(拒发布抛WORKFLOW_DEF_VALIDATE_FAILED)- plan-as-workflow 生成器的「生成-校验-自修正」循环——校验结果会回灌给模型
第 3 条是这个文件为什么这么大的真正原因:每条规则的错误文案都是写给模型看的自修正提示, 不是写给人看的报错。所以文案里要带「已知类型全集」(knownTypesHint())、 要带「该取哪个数组」(V26,见工具体系)、要点名具体节点 id。
validate 与 manifest 的权限都归 query,不归 run
AgentWorkflowDefController.java:264 逐字: 「权限归 query 而不是 run:清单只读,与 validate/node-io-contract 同一档(run 是副作用面)。」
九、NodeIoContract:只描述、绝不参与运行时
NodeIoContract.java:17-30 把边界划得很清楚:
这是一层只描述、不执行的元数据:告诉编排者「某类节点该往哪个 config 键填什么(输入端口)、 跑完会往哪个变量名写什么(输出端口)、类型是什么」。前端据此画出「类型化端口」、让用户在下游输入框里 「点选上游产出」而不是手打
{{$.xxx}}。🔴 关键纪律:本登记表绝不参与运行时。节点仍按各自 Executor 从 DSL 的 config / variables 里读写 扁平状态;这里描述的「端口」最终都被编译/填写成既有的
{{$.var}}引用与config.outputVar。 因此对存量已发布流程 100% 向后兼容。
它的输出端口默认名同时是两处的派生源(defaultOutputNames():455),两者必须同源:
| 消费者 | 用途 | 差集的后果 |
|---|---|---|
KeyStrategyFactoryGenerator#generate | 逐个 addStrategy(REPLACE)。不注册的话 resume 时 OverAllState.input(checkpointState) 会把它过滤掉 | 豁免了却没注册 ⟹ 值在恢复瞬间被静默丢弃而校验器一路绿灯 |
WorkflowDslValidator.RESUME_REGISTERED_VARS | V19「写而未声明」对它们一律不报 | 注册了却不豁免 ⟹ V19 恒报假警报,作者照文案把这一格删掉、把流程改坏 |
🔴 引用执行器常量而不是抄字面量——这条纪律曾被自己违反过
:32-49 逐字记录了两次漂移:
tool/llm的defaultName曾写着 null(= 必须用户命名),而两个执行器一直在用tool_result/llm_output兜底。于是所有「读契约做分析」的代码都看不见这两个键——WorkflowDslValidator建 producers 表时漏掉它们,合法的{{$.tool_result}}被误报成悬空引用; 污点溯源同理漏报。- 三个 hitl 回调键(
hitl_approved/bpm_approved/bpm_status)一直抄的是字面量, 而同样这三个键在RESUME_REGISTERED_VARS与KeyStrategyFactoryGenerator里引的都是执行器常量。 第四个读者AgentAssistantServiceImpl.HITL_APPROVE_VAR(聊天内恢复)同样抄着字面量, 靠一句「必须与执行器一致」的注释当链接。
强制点是 NodeIoContractTest#mainSourcesHaveNoDefaultNameLiterals,它扫 src/main 全部源码而不只是本文件: 「只扫契约的话,第四个读者改完就没有强制点了,谁把它改回同值字面量全套测试照样绿—— 而那一处的后果最重(写错键 = 用户点了同意却按拒绝走完、执行状态还报 SUCCEEDED)。 之所以必须扫源码:值比对在引用常量之后会退化成『常量等于常量』的恒真断言。」
引用常量不违反「绝不参与运行时」:那几个常量是 static final String 字面量,属编译期常量表达式, javac 会把值内联进本类的常量池(JLS 15.28 / 13.1)。已用 javap -c 核实: 本类字节码 0 处 NodeExecutor 引用,七个字面量直接躺在 <clinit> 里。
⚠️ 契约 ↔ 运行期实读实写键的一致性,强制点分三档,别一概而论
:76-88 逐字:
| 档 | 覆盖 | 强制点 |
|---|---|---|
| ① 实读键全集 | 全部 13 型 | ✅ NodeIoContractRuntimeReadScanTest(台账 #97-b)扫源码做双向差集,少登记一个运行期真在读的键、或多登记一个没人读的键,都会红 |
| ② 默认输出名 | 只有 tool / llm 两型 | ⚠️ NodeIoContractRuntimeAlignmentTest 走「真跑执行器」比对实际写入键,其余类型跑不了的理由在它自己的 javadoc 里逐条列着 |
| ③ 实写键全集(其余类型) | — | ❌ 仍无强制点,属 S9-①。这一档写错一个 defaultName,除 NodeIoContractTest 抽查到的那几条外不会有测试变红 |
登记判据也值得记一句:原先是「只覆盖用户在属性面板上填的那几个」, 结果正是漏登记的成因——modelId/temperature/maxTokens 这类键面板上没有格子、运行期却真的生效, 作者既改不动、也不知道能改。⟹ 判据改成「运行期读了就得登记」,与面板上有没有格子无关。
数据模型
三张表,DDL 见 docs/.generated/tables-agent.md。
agent_workflow_def——定义(同 code 多版本共存)
| 列 | 类型 | 说明 |
|---|---|---|
code | varchar(64) NOT NULL | 业务编码,同一 code 多版本共存 |
version | int NOT NULL | 版本号,单调递增 |
status | varchar(16) NOT NULL | DRAFT / PUBLISHED / DISABLED |
primary_flag | bit(1) NOT NULL | 是否主版本;默认 0 = 按 version 最大生效(向后兼容) |
source | varchar(16) NOT NULL | MANUAL = 管理员在流程编排页手动新增;AUTO = 规划器/助手对话由大模型生成后落库 |
dsl | mediumtext NOT NULL | Workflow DSL v1 JSON 原文 |
trigger_type / trigger_key / trigger_next_fire_at | — | DSL trigger 的投影,见核心机制第五条 |
owner_user_id | bigint NULL | 无头触发的执行身份;为空 fail-close 跳过 |
索引:idx_tenant_code_status(tenant_id, code, status)、idx_tenant_next_fire(tenant_id, trigger_next_fire_at)、 idx_tenant_trigger(tenant_id, trigger_type, trigger_key)、PRIMARY(id)。
⚠️ (tenant_id, code, version) 上没有唯一索引——同 code 同 version 的两行在库层面拦不住, 靠 createDef 的应用层逻辑保证。
agent_workflow_execution——执行实例
关键列见核心机制第四条的表。另有:
| 列 | 说明 |
|---|---|
workflow_version | 绑定的定义版本(快照,定义后续升版不影响已跑的执行) |
thread_name | varchar(128) UNI,t{tenant}:{execId} |
current_node | 当前/最后停留的节点 id |
variable_snapshot | mediumtext,变量快照 JSON(可查询投影,非 resume 源——resume 读的是 graph_checkpoint) |
recovery | 失败恢复策略:FORWARD 续跑 / COMPENSATE 反做 |
failed_at_node | 失败所在节点 id |
agent_node_execution——逐节点 trace
| 列 | 说明 |
|---|---|
execution_id + node_id | 定位;索引 idx_tenant_execution(tenant_id, execution_id, id) |
node_name | 执行当时的 DSL node.name 快照;流程后续改名不回填,老行为 NULL |
parent_node_id | 所属 sub_workflow/iteration/agent_loop 节点的裸 id;NULL = 顶层节点。🔴 非自引用外键:那三种节点自身不落 trace 行 |
attempt | 第几次尝试,从 1 起 |
model_id | llm 节点实际使用的模型(故障切换后可追溯真正跑的是哪个) |
prompt_tokens / completion_tokens / total_tokens / token_source | PROVIDER 厂商回传 / ESTIMATED 本地估算 |
input_snapshot / output_snapshot | mediumtext,节点入参/出参 JSON 快照 |
compensate_ref / before_image / idempotency_key | Saga 反做三件套;idempotency_key 上有 UNI 索引 uk_idempotency,反做重放时去重 |
🔴 input_snapshot / output_snapshot 没有任何应用层长度守卫
两列都是 mediumtext(上限 16 MiB),AgentNodeExecutionDO.java:110-111 就是两行裸声明, 没有注释、没有 @TableField 长度约束,NodeTraceRecorder 里也没有截断逻辑。
后果:一个 tool 节点若调了 form_data_page 拉回一大页数据, 或一个 script 节点返回一个大对象,整个 JSON 会原样落进这两列。 每一次执行、每一个节点各一份,而 agent_node_execution 没有保留期清理任务 (对比 agent_usage_log 至少有个 AgentUsageLogRetentionJob,虽然那个也没注册,见计量与配额)。
失效方向:静默。表现是库体积单调增长、trace 列表页查询变慢, 不会有任何报错。要修得在 NodeTraceRecorder 落库前加截断, 但那会牺牲「trace 能完整还原当时状态」这个能力——需要权衡,不能顺手加。
接口清单
定义侧 AgentWorkflowDefController(/agent/workflow-def,20 个端点)
| 方法 | 路径 | 行 | 说明 |
|---|---|---|---|
| POST | /create | :90 | 新建定义(落 DRAFT) |
| POST | /copy | :97 | 复制 |
| PUT | /update | :110 | 改 DSL |
| DELETE | /delete | :118 | 软删(先释放 checkpoint,见核心机制第四条) |
| DELETE | /delete-list | :146 | 批量软删 |
| POST | /force-delete | :178 | 🔴 强删(含 checkpoint 释放,回滚不了那条 UPDATE) |
| GET | /get | :187 | — |
| GET | /page | :196 | — |
| POST | /publish | :208 | 发布(过校验闸门) |
| POST | /disable | :216 | ⚠️ 停用后生效版本会回落到次新的 PUBLISHED |
| POST | /validate | :225 | 返回 200 + 结构化 {passed, errors, warnings} |
| POST | /manifest | :257 | 变更清单(权限归 query) |
| GET | /audit-all | :294 | — |
| GET | /node-io-contract | :304 | 🔴 零前端调用者,见坑 4 |
| GET | /name-catalog | :319 | 节点/工具显示名目录 |
| GET | /planner-adoption-stat | :337 | 规划采纳率统计 |
| POST | /sandbox-stream | :347 | SSE 沙箱试跑(真烧 token) |
| GET | /version-list | :372 | — |
| POST | /rollback | :382 | 回滚到某版本 |
| POST | /set-primary | :392 | 设为主版本 |
执行侧 AgentWorkflowController(/agent/workflow,7 个端点)
| 方法 | 路径 | 行 | 说明 |
|---|---|---|---|
| POST | /run-stream | :68 | SSE,Flux<CommonResult<WorkflowEvent>> |
| POST | /resume-stream | :87 | SSE,恢复 INTERRUPTED 的执行 |
| POST | /event | :105 | 外部事件广播(waitType=event 的唤醒源);「广播」语义:匹配 status=INTERRUPTED 且 event_key=eventKey 的执行(可 0/1/N 条),逐条 resume |
| POST | /cancel | :115 | 🔴 CANCELLED 不可恢复——resume 的 isResumable 守卫只认 INTERRUPTED |
| GET | /execution/get | :128 | 前端刷新后凭 agent_message.execution_id 重建确认卡片 |
| GET | /execution/page | :137 | — |
| GET | /node-trace/list | :147 | 逐节点 trace |
匿名入站
POST /agent/workflow/webhook/{path}(@PermitAll)——agent 模块唯一的匿名可达面, 见核心机制第六条与安全与提示注入。
WorkflowEvent 事件词表(5 种)
定义在 AgentWorkflowExecutionService.java:130:META / NODE / INTERRUPTED / DONE / ERROR。 ERROR 同样是「刻意做成正常事件而非断连,理由同助手 ERROR」。
✅ 执行通道的事件词表前后端零分歧
与助手通道(9 种 AgentChatEvent,前端有 6 处字段级分歧)形成对比, WorkflowEvent 这 5 种在前端 api/agent/planner/index.ts 的 WorkflowEventVO 里逐字对得上, 详见前端 SSE 消费。
怎么扩展
加一种节点类型(有执行器)
- 在
engine/executor/impl/下写XxxNodeExecutor implements NodeExecutor,@Component标注,type()返回类型串。 🔴 执行器不能加 SAA 节点/边(SPI 硬约束:1 个 DSL 节点 ↔ 1 个 SAA 节点)。 要结构性建边就得改编译器(像parallel那样)。 - 在
NodeIoContract里register(new Contract("xxx", "显示名", "分组", ...)), 声明输入端口与输出端口。🔴 输出端口的defaultName必须引用执行器的常量,不抄字面量 (强制点NodeIoContractTest#mainSourcesHaveNoDefaultNameLiterals扫 src/main 全部源码)。 - 在
WorkflowDslValidator里加一条validateXxxConfig,并在 V 编号序列里给它一个号。 🔴 错误文案是写给模型看的自修正提示,要点名具体节点 id、要给出可操作的修法。 - 若输出端口带默认名,
KeyStrategyFactoryGenerator与RESUME_REGISTERED_VARS会自动跟上 (都从defaultOutputNames()派生),不需要手改清单。 - 前端:
nodeIoContract.ts镜像 +NODE_TYPE_META+nodeConfigSchema.ts(面向用户的提示语在这一份, 不是镜像里的InPort.hint——那个在前端没有消费者)+nodeSummary.ts。 ⚠️ 四处都是手抄,没有自动 diff,见坑 4。 - 补测试:
NodeIoContractRuntimeReadScanTest的双向差集会自动覆盖实读键; 默认输出名的「真跑执行器」比对目前只有tool/llm两型有,新类型要自己加。
加一种「合成子图」类型(无执行器)
参考 agent_loop(AgentLoopGraphBuilder,694 行)与 iteration(IterationGraphBuilder,247 行):
- 写一个
XxxGraphBuilder,程序化搭出StateGraph。 - 把类型串加进
WorkflowDslValidator.GRAPH_BUILDER_TYPES:274(否则isKnownType:4251会拒)。 - 在
DslToStateGraphCompiler里加addXxxNode(...)分支,graph.addNode(nodeId, childGraph)。 - 🔴 内部 state 键必须做命名空间隔离:
AgentLoopGraphBuilder用__agentloop_<nodeId>_*前缀, 多个同类节点才互不串台。全部注册为 REPLACE(避开 append-merge 的复杂度)。 - 🔴 所有入 state 的值必须是纯 JSON 可序列化类型(
ArrayList/LinkedHashMap/String/Integer/Boolean)—— 工具结果、模型 calls 都以字符串落 state,不放 Hutool 的JSONObject/JSONArray, 以免 checkpoint 序列化踩坑(DSL §3.2 硬约束)。 - 子节点事件以
<nodeId>-<internalId>上浮父流,trace 命名空间同前缀。 NodeIoContract里这三个类型的实读实写键在各自的 GraphBuilder / 编译器里, 契约只登记「给人当产出引用」的那些。
加一种触发器
现有四种:chat / form_event / schedule / webhook。
TriggerDTO加类型;WorkflowTriggerExtractor加抽取(含落库侧的非法值拦截)。- 无头触发走
WorkflowTriggerInvoker#invokeHeadless:以定义发布者身份AgentExecutionContext.ofSystem(ownerUserId)跑,owner 缺失 fail-close 返回 false。 - 🔴 入站 body 必须经
UntrustedInputSanitizer按污点源分档限长——第二道闸覆盖三个源, 新触发器若接收外部数据就是第四个源,得在AgentSanitizeProperties里给它一档。 agent_workflow_def的trigger_type/trigger_key投影与三个索引要跟上。- 若要限流,参考
AgentWebhookRateLimiter(Redisson,两层 AND:IP 维度 + path 维度)。 ⚠️ IP 取自X-Forwarded-For/X-Real-IP等客户端可控的头, 真正承重的是 path 那一层,两层必须 AND 不能 OR。
改发布预检的判定
publishGate.ts 是纯模块(不 import Vue),两个宿主共用:列表页 handlePublish、 聊天确认卡 handleConfirmWorkflow。三种 verdict:
action | 含义 |
|---|---|
publish | 体检通过,可以发;warnings 带出来让宿主提示「有 N 条建议调整」 |
block | 有阻断错误,不发,且必须把 errors 逐条渲染出来 |
publish-blind | 🔴 预检自己没跑成(取不到 DSL / 接口 403 / 网络错)→ 降级成「照发」而不是「堵死」 |
降级的理由(publishGate.ts:53-59 逐字):「预检是为了让人读懂才存在的一层, 它坏了不该反过来变成一道没人授权过的新闸门——尤其 403 是权限配置问题(台账 #70), 与这条流程合不合法毫无关系。后端闸门仍是权威,真不合法它照样拒。」
🔴 预检不是「少一次往返」的优化,而是唯一读得完的通道
publishGate.ts:6-19 逐字:后端闸门拒发布时抛 WORKFLOW_DEF_VALIDATE_FAILED, 文案里用「;」把 error 逐条拼成一长串。src/config/axios/service.ts 的响应拦截器对业务错误码只做一件事: ElNotification.error({ title: msg })——一个 4.5 秒自动消失的通知标题。 一份可能有七八条、每条几十字的修正清单塞在那里,用户读不完它就没了,只能反复点发布反复看它闪。
所以「逐条列出要改什么」这件事,只能由前端在发请求之前做完: /validate 返回的是 200 + 结构化 {passed, errors, warnings},天生就是一组可以直接逐条渲染的数组。
⚠️ 别把这件事与台账 C2 混为一谈,否则会误判本模块可以删:C2 修的是那个分支原来 Promise.reject('error')(reject 出去的是字面量字符串,调用方 catch 里连 e.message 都是 undefined), 现已改成 reject(new Error(msg)),文字到得了调用方了。 但通知本身仍是 4.5 秒的标题——所以本模块的存在理由没变。
判定抽成纯模块的理由同样值得记:「vitest 从不 mount .vue(本目录既有约定:逻辑放 .ts,配一个同名 .spec.ts)。 判定写在组件里就等于零单测覆盖——而这段逻辑里最容易写错的两条恰恰是『反了就会出人命』的方向: 把 warning 也当成阻断(存量 20+ 行一夜发不出去),以及预检自己坏了就把人堵死(该降级成照发)。」
坑与注意
静默 / fail-open
- 🔴 13 种节点类型,不是 10 种。 按
executor/impl/目录清点会少agent_loop/iteration/sub_workflow三个合成子图类型。 - 🔴
wait执行器不等待,parallel执行器什么都不做。 两者都是刻意的,理由见 核心机制第二条。按名字读代码会以为它们坏了而去「修好」它们。 - 🔴 checkpoint「释放」不删快照行。
graph_checkpoint随执行次数单调增长,强删定义也带不走它。DELETE_CHECKPOINTS在 SAA 1.1.2.2 里是死常量。 - 🔴
nodeIoContract.ts是手抄镜像,零自动 diff。 前端那份自己的注释写得最狠(:8-14): 「同步目前只靠人 +nodeIoContract.spec.ts钉值:没有任何测试真去拉GET /agent/workflow-def/node-io-contract跟这份镜像做 diff……实测更彻底:src/下没有任何一处调用那个后端端点 (零前端调用者),UI 用的就是这份镜像 ⟹ 两份漂了连运行期信号都没有。」 台账 #104 已记录修法(照tests/e2e/form-widget-contract-sync.spec.ts的先例升成一条 e2e), ⚠️ 但那条 e2e 在台账 #70(agent:workflow:query授权给 0 个角色)清账之前只能用超管账号跑。 - 🔴 沙箱真烧 token、真读库。 「不计配额」不等于「不产生消耗」,账单上不记名。
- 🔴
input_snapshot/output_snapshot无长度守卫,mediumtext16 MiB 上限, 每执行每节点各一份,且agent_node_execution没有保留期清理任务。 - 🔴 工作流
llm节点的 token 不进agent_usage_log。LlmNodeExecutor把用量写进ctx.getMetrics()→agent_node_execution,不写计费表。所以 TENANT/USER 维度配额对工作流里的 LLM 消耗全瞎。详见计量与配额。 - ⚠️ webhook 第二道闸(叶子限长)是静默的。 调用方拿
200 + data:true, 合法业务数据被截成半截后下游拿着残缺数据继续跑。观测路径只有服务端那条 warn。 - ⚠️ 停用某个 PUBLISHED 版本会让生效版本自动回落到次新的 PUBLISHED 版本。停用不等于停摆。
- ⚠️ 两个调度器的开关前缀不是
enlev.agent。WorkflowScheduleTriggerScheduler.java:59,87-88与WorkflowWaitResumeScheduler.java:56,86-87用的是agent.workflow.*(无enlev.前缀), 6 个 key 全部matchIfMissing = true/ 带默认值,且在所有 yaml 中均无配置——全跑默认值。 运维在enlev.agent下面找不到它们。 - ⚠️
sub_workflow的变量映射是重命名,不是硬隔离。 子图技术上仍见未映射的父键。 - ⚠️ 契约一致性第 ③ 档(实写键全集)无强制点。 写错一个
defaultName, 除NodeIoContractTest抽查到的那几条外不会有测试变红。 - ⚠️
variable_snapshot是可查询投影,不是 resume 源。 resume 读的是graph_checkpoint。 改前者不影响恢复行为,改后者才会。 - ⚠️
suspend_node为 NULL 有两种含义:非挂起态,或取不到 checkpoint 快照。 注释写明「刻意不回落成错值」,所以别把 NULL 一律读成「没挂起」。 - ⚠️ webhook body 仍会被
ApiAccessLogInterceptor记进访问日志(台账 N32)。 验签挡住了未授权调用,挡不住日志里的明文 body。
fail-closed(用户会来报)
- 编译期即校验:类型没执行器 →
WORKFLOW_NODE_EXECUTOR_NOT_FOUND; 边指向不存在节点 / id 重复 / 图为空 →WORKFLOW_DSL_INVALID。 - 发布闸门:
WORKFLOW_DEF_VALIDATE_FAILED,文案用「;」拼全部 error (所以前端才需要publishGate,见怎么扩展)。 agent_loop不收敛主动抛错:WORKFLOW_AGENT_LOOP_NOT_CONVERGED(1_041_006_007)。- plan-as-workflow 生成到轮次上限即失败:
AgentPlanProperties.asWorkflowMaxRounds = 6, 超限 →WORKFLOW_PLAN_DSL_GENERATION_FAILED(ErrorCodeConstants.java:725称其与上一条 「同款 fail-close 纪律(方案 §7:暴露问题而不硬猜)」)。 - webhook 不存在免签分支:
authType缺失 /none/ 未知一律拒。限流器在 Redis 故障时同样 fail-close。 - 无头启动缺 owner 即跳过:
owner_user_id为空 →invokeHeadless返回 false,不执行。 - schedule 触发 CAS 至多一次:竞争失败方直接放弃,「宁可丢一次触发,不要重复副作用」。
- 释放 checkpoint 真失败时整单中止:
WORKFLOW_DEF_DELETE_CHECKPOINT_FAILED,业务行一行未动。 cancel之后不可 resume:CANCELLED不在isResumable的白名单里, 撞上来抛WORKFLOW_EXECUTION_STATE_INVALID,消息里带当时真实的状态,不猜。- BPM 审批回调身份不全就跳过自动唤醒:
AgentBpmApprovalListener#onApplicationEvent:66在!ctx.isComplete()时不派发事件,流程留在INTERRUPTED等人工 resume—— 宁可不自动推进,也不在身份不明时写状态。 waitType=hitl编译期明确拒:它等价hitl.confirm节点。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 测试 | 覆盖 |
|---|---|
NodeIoContractTest#mainSourcesHaveNoDefaultNameLiterals | 🔴 扫 src/main 全部源码,禁止把执行器常量改回同值字面量(值比对会退化成恒真断言,只能扫源码) |
NodeIoContractRuntimeReadScanTest | 契约 ↔ 运行期实读键双向差集(台账 #97-b)。刻意扫源码而不是跑执行器:键是字符串字面量,运行期只有配了那个键的 DSL 才走到读取分支 |
NodeIoContractRuntimeAlignmentTest | 「真跑执行器」比对实际写入键——只覆盖 tool / llm 两型 |
KnownTypesHintSingleSourceTest | 钉住 knownTypesHint() 派生自 NodeIoContract.all(),不许改回同值手抄 |
AgentLoopGraphBuilderTest(11 @Test) | 环形子图:收敛、末轮 fail-close、只读闸 |
IterationGraphBuilderTest(7 @Test) | 循环子图 |
WorkflowTriggerInvokerTest | 无头触发 |
WorkflowWaitResumeSchedulerTest | 定时唤醒 |
WorkflowDslPlannerPromptTest | 🔴 把提示词里那段 JS 的 MAIN = [...] 抠出来与 ToolResultShapes.MAIN_LIST_KEYS 逐字比对,且把整段 pickRows 丢进真沙箱跑实测形状 |
前端 nodeIoContract.spec.ts / publishGate.spec.ts / nodeConfigSchema.spec.ts / dslGateFixtures.spec.ts | 镜像钉值、预检判定、面板 schema、闸门夹具 |
运行:cd ainb && mvn test -pl enlev-module-agent;前端 cd ainb-ui-admin-vue3 && pnpm test:unit。
这个模块的守卫测试密度明显高于其它模块
NodeIoContract 一份元数据表配了三条不同角度的守卫(源码扫描 / 运行期实读双向差集 / 真跑执行器比对), 理由是它的读者有四个(契约自己、KeyStrategyFactoryGenerator、RESUME_REGISTERED_VARS、 AgentAssistantServiceImpl.HITL_APPROVE_VAR),而四个读者的差集就是缺陷。
对比ReAct 循环那边 8 个测试文件里 7 个是 Mockito 装配测试, 工作流这一侧的守卫明显更硬。这不是巧合:契约漂移的后果是「用户点了同意却按拒绝走完、 执行状态还报 SUCCEEDED」,属于查不出原因的那一类。
未证实
读源码读到、但没能进一步验证的
- 🔴 「SAA 的
recursionLimit命中时是静默截断而非抛错」——这是「循环上界必须自己数 step」 这个设计的前提,出处是AgentLoopGraphBuilder.java:63-67与AgentLoopProperties.java:29引用的「S1 spike 实测」。 全仓库find -iname "*spike*"0 命中,enlev-module-agent下也没有任何.md, spike 报告没有实体文件,无法在本仓库内复核。模块内大量引用SPIKE SR-1/SR-2/验证①②③/P0 spike(分布在AgentNodeExecutionDO、NodeMetrics、LlmNodeExecutor、RuleConditionNodeExecutor、NodeExecutor、NodeTraceRecorder、HitlConfirmNodeExecutor、DslToStateGraphCompiler、WorkflowEngineConfiguration、AgentLoopGraphBuilder、IterationGraphBuilder、AgentLoopProperties、pom.xml), 全部只有注释断言。 - 🔴
MysqlSaver的字节码取证文件不在仓库里。:659引用的_logs/r10/28-mysqlsaver-release-bytecode.log属于_logs/, 而_logs/按项目纪律一律不进 git(里面可能含 webhook 打进来的不可信载荷)。 所以「DELETE_CHECKPOINTS命中数为 0」这条结论的证据无法随仓库分发,只能信注释。 - 🔴 现场对账样本(def #584、执行 #175、该 thread 名下 2 行
graph_checkpoint) 是数据库运行期事实, 本轮无 DB 访问,未证实。 - 「SAA compile 会把子图内联展开成多节点链,破坏收敛规则」(
:84-87)—— 这是对 SAA 1.1.2.2 内部行为的断言,expandParallel的编译期拒绝是基于它写的, 但本轮没有读 SAA 的源码或跑实测复核。 WorkflowDslValidator其余规则的具体判定未逐条读:本页只核实了 V3(只读闸同口径)、V19(写而未声明)、 V26(结果形状)三条的内容。号域规模已经查实(34 个号,权威在:5349-5441,见第八节那个 warning), 但 5800 行里另外 31 条的判定逻辑没读过。- 正式执行的 600s 超时:只在
AgentWorkflowSandboxServiceImpl.java:52的注释里作为对比出现 (「比正式执行的 600s 短」),本轮未定位到那个 600 的实际配置位置。
推理得出、未取证的
- 「
graph_checkpoint会随执行次数单调增长」——由「释放只 UPDATEgraph_thread」+ 「不存在物理删快照的代码路径」两条已证实事实推出,没有做过实际的行数增长观测。 - 「沙箱真烧 token」——由「沙箱与正式执行同源、只去掉持久化与配额」+ 「
NodeTraceRecorder在 sandbox 上下文里 no-op(只是不写 trace)」推出。 没有实际跑过一次沙箱并比对厂商账单。 - 「
(tenant_id, code, version)上没有唯一索引」——由docs/.generated/tables-agent.md里agent_workflow_def的索引清单(只有三个idx_*+PRIMARY)推出, 没有直接读ainb-init.sql的 DDL 原文核对。 - 「webhook body 仍会被
ApiAccessLogInterceptor记进访问日志」——来自台账 N32, 本轮未读enlev-framework侧的拦截器源码确认它是否对@PermitAll端点也生效、 以及是否有 body 脱敏。
相关页面
- 架构总览——
AgentExecutionContext与「预创建线程池上租户身份为 null」那个坑 - 工具体系——
tool节点与agent_loop的执行收口、V26 依赖的结果形状契约 - ReAct 循环与流式对话——
agent_loop的另一个宿主,以及为什么两份循环骨架不合并 - Plan 与人工确认——plan-as-workflow 生成器怎么产出这里的 DSL
- 安全与提示注入——webhook 入站 body 的污点分档与数据围栏
- 计量与配额——为什么工作流 LLM 节点的 token 不进计费表
- 前端 SSE 消费——
WorkflowEvent5 种类型的消费侧 - 权限体系总览——
agent:workflow:*权限串与台账 #70
