Skip to content

工作流引擎 ​

适用端:后端 + 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.java1363编译器:DSL → SAA CompiledGraph
framework/agent/core/engine/compiler/DslParser.java71Jackson 解析 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.java71执行器 SPI(只有两个方法,见下)
framework/agent/core/engine/executor/NodeExecutorRegistry.java63按 type() 收集执行器
framework/agent/core/engine/executor/impl/*.java(10 个)106010 种类型的执行器
framework/agent/core/engine/io/NodeIoContract.java46813 种类型的输入/输出端口契约登记表(只描述、不执行)
framework/agent/core/engine/context/WorkflowContext.java108单次执行的上下文(executionId / threadName / 身份快照 / metrics)
framework/agent/core/engine/context/NodeMetrics.java—节点级 token 计量
framework/agent/core/engine/trace/NodeTraceRecorder.java225写 agent_node_execution(沙箱上下文里 no-op)
framework/agent/core/engine/support/StateTemplateResolver.java244解析 {{$.var}} 模板
framework/agent/core/engine/support/StatePathNavigator.java—按路径取 state 值
framework/agent/core/engine/support/ScriptSandbox.java276script 节点的 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.java91form_event 触发(@TransactionalEventListener(AFTER_COMMIT))
framework/agent/core/engine/config/WorkflowEngineConfiguration.java—SAA bean 装配(MysqlSaver 等)
framework/agent/core/validate/WorkflowDslValidator.java5800V 系列校验规则(34 个号,缺 V17),全模块最大的单文件
framework/agent/core/loop/AgentLoopGraphBuilder.java694agent_loop → 环形子图
framework/agent/core/loop/IterationGraphBuilder.java247iteration → 循环子图
service/workflow/AgentWorkflowDefServiceImpl.java1258定义 CRUD / 发布 / 版本 / 强删
service/workflow/AgentWorkflowExecutionServiceImpl.java986执行 / resume / cancel / 事件广播
service/workflow/AgentWorkflowSandboxServiceImpl.java—沙箱试跑(去持久化、去配额)
service/workflow/AgentWebhookTriggerServiceImpl.java—匿名 webhook 入站 + 验签
controller/admin/workflow/AgentWorkflowDefController.java403定义侧 20 个端点
controller/admin/workflow/AgentWorkflowController.java157执行侧 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显示名分组执行器行数
1tool工具基础ToolNodeExecutor83
2llm大模型基础LlmNodeExecutor102
3knowledge_retrieval知识检索基础KnowledgeRetrievalNodeExecutor143
4script脚本基础ScriptNodeExecutor90
5condition.rule规则条件分支与并行RuleConditionNodeExecutor194
6condition.llmLLM 条件分支与并行LlmConditionNodeExecutor133
7parallel并行分支与并行ParallelNodeExecutor(no-op)55
8iteration循环遍历分支与并行❌ IterationGraphBuilder 合成子图247
9hitl.confirm人工确认人工介入HitlConfirmNodeExecutor71
10hitl.bpm_approvalBPM 审批人工介入HitlBpmApprovalNodeExecutor131
11wait等待人工介入WaitNodeExecutor(不等待)54
12agent_loop自主循环高级❌ AgentLoopGraphBuilder 合成子图677
13sub_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:

java
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 行,只有两个成员):

java
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_namevarchar(128) UNI,格式 t{tenant}:{execId},SAA checkpoint 的寻址键
suspend_node挂起时正在等待的闸口节点 id;NULL = 非挂起态,或取不到 checkpoint 快照(刻意不回落成错值)
dropped_keysresume 时会被静默丢弃、且作者能自己修的 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_released 0→1, 而该 thread 名下仍有 2 行 graph_checkpoint。 🔴 故任何报告都不得把本方法的效果写成「checkpoint 清零」。

实际后果两条:

  1. graph_checkpoint 会随执行次数单调增长,强删定义也带不走它。要清理只能自己写 DDL/DML, 而那要先删 GRAPH_THREAD 行(ON DELETE CASCADE)——属于「裸删别人的表」,需另行评估。
  2. @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:

java
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 之前逐字一致(完全向后兼容); 只有显式『设为主版本』后,生效版本才切到被指定的那一版。」

🔴 「发布了」与「现在跑的是哪一版」是两个问题

四个查询各有各的口径,读代码时极易混:

方法过滤回答的问题
selectPublishedByCodestatus=PUBLISHED + 上述排序现在跑的是哪一版(执行入口)
selectByCodeAndVersion不带 statussub_workflow pin 到具体版本时用;DISABLED 版本仍可被已 pin 的调用跑到,DRAFT 不可
selectLatestByCode不带 status,取 version 最大发布动作要看「最新那一版现在是 DRAFT 还是已 PUBLISHED」
selectPublishedVersionsstatus=PUBLISHED,同排序版本列表页

⚠️ 由此推出一个反直觉的行为:把一个 PUBLISHED 版本 disable 掉之后, selectPublishedByCode 根本选不到它,生效版本会自动回落到次新的 PUBLISHED 版本 (AgentWorkflowDefMapper.java:101-107 逐字写明)。停用不等于停摆。

agent_workflow_def 上与触发相关的三列(都是 DSL trigger 的投影,为了能被索引扫到):

列语义
trigger_typechat / form_event / schedule / webhook
trigger_keyform_event = formCode:eventType;schedule = cron 表达式
trigger_next_fire_atschedule 下次应触发时刻;轮询器扫 <=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=none DSL(落库校验放开前建的) 到这里会一律拒收:这是刻意的。

java
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」当成一次性的笔误去改,每次都要重数。

它服务三个调用方,口径必须一致:

  1. POST /agent/workflow-def/validate——设计器与发布预检
  2. POST /agent/workflow-def/publish——发布闸门(拒发布抛 WORKFLOW_DEF_VALIDATE_FAILED)
  3. 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_VARSV19「写而未声明」对它们一律不报注册了却不豁免 ⟹ V19 恒报假警报,作者照文案把这一格删掉、把流程改坏

🔴 引用执行器常量而不是抄字面量——这条纪律曾被自己违反过

:32-49 逐字记录了两次漂移:

  1. tool/llm 的 defaultName 曾写着 null(= 必须用户命名),而两个执行器一直在用 tool_result/llm_output 兜底。于是所有「读契约做分析」的代码都看不见这两个键—— WorkflowDslValidator 建 producers 表时漏掉它们,合法的 {{$.tool_result}} 被误报成悬空引用; 污点溯源同理漏报。
  2. 三个 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 多版本共存) ​

列类型说明
codevarchar(64) NOT NULL业务编码,同一 code 多版本共存
versionint NOT NULL版本号,单调递增
statusvarchar(16) NOT NULLDRAFT / PUBLISHED / DISABLED
primary_flagbit(1) NOT NULL是否主版本;默认 0 = 按 version 最大生效(向后兼容)
sourcevarchar(16) NOT NULLMANUAL = 管理员在流程编排页手动新增;AUTO = 规划器/助手对话由大模型生成后落库
dslmediumtext NOT NULLWorkflow DSL v1 JSON 原文
trigger_type / trigger_key / trigger_next_fire_at—DSL trigger 的投影,见核心机制第五条
owner_user_idbigint 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_namevarchar(128) UNI,t{tenant}:{execId}
current_node当前/最后停留的节点 id
variable_snapshotmediumtext,变量快照 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_idllm 节点实际使用的模型(故障切换后可追溯真正跑的是哪个)
prompt_tokens / completion_tokens / total_tokens / token_sourcePROVIDER 厂商回传 / ESTIMATED 本地估算
input_snapshot / output_snapshotmediumtext,节点入参/出参 JSON 快照
compensate_ref / before_image / idempotency_keySaga 反做三件套;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:347SSE 沙箱试跑(真烧 token)
GET/version-list:372—
POST/rollback:382回滚到某版本
POST/set-primary:392设为主版本

执行侧 AgentWorkflowController(/agent/workflow,7 个端点) ​

方法路径行说明
POST/run-stream:68SSE,Flux<CommonResult<WorkflowEvent>>
POST/resume-stream:87SSE,恢复 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 消费。

怎么扩展 ​

加一种节点类型(有执行器) ​

  1. 在 engine/executor/impl/ 下写 XxxNodeExecutor implements NodeExecutor, @Component 标注,type() 返回类型串。 🔴 执行器不能加 SAA 节点/边(SPI 硬约束:1 个 DSL 节点 ↔ 1 个 SAA 节点)。 要结构性建边就得改编译器(像 parallel 那样)。
  2. 在 NodeIoContract 里 register(new Contract("xxx", "显示名", "分组", ...)), 声明输入端口与输出端口。🔴 输出端口的 defaultName 必须引用执行器的常量,不抄字面量 (强制点 NodeIoContractTest#mainSourcesHaveNoDefaultNameLiterals 扫 src/main 全部源码)。
  3. 在 WorkflowDslValidator 里加一条 validateXxxConfig,并在 V 编号序列里给它一个号。 🔴 错误文案是写给模型看的自修正提示,要点名具体节点 id、要给出可操作的修法。
  4. 若输出端口带默认名,KeyStrategyFactoryGenerator 与 RESUME_REGISTERED_VARS 会自动跟上 (都从 defaultOutputNames() 派生),不需要手改清单。
  5. 前端:nodeIoContract.ts 镜像 + NODE_TYPE_META + nodeConfigSchema.ts(面向用户的提示语在这一份, 不是镜像里的 InPort.hint——那个在前端没有消费者)+ nodeSummary.ts。 ⚠️ 四处都是手抄,没有自动 diff,见坑 4。
  6. 补测试:NodeIoContractRuntimeReadScanTest 的双向差集会自动覆盖实读键; 默认输出名的「真跑执行器」比对目前只有 tool/llm 两型有,新类型要自己加。

加一种「合成子图」类型(无执行器) ​

参考 agent_loop(AgentLoopGraphBuilder,694 行)与 iteration(IterationGraphBuilder,247 行):

  1. 写一个 XxxGraphBuilder,程序化搭出 StateGraph。
  2. 把类型串加进 WorkflowDslValidator.GRAPH_BUILDER_TYPES:274 (否则 isKnownType:4251 会拒)。
  3. 在 DslToStateGraphCompiler 里加 addXxxNode(...) 分支,graph.addNode(nodeId, childGraph)。
  4. 🔴 内部 state 键必须做命名空间隔离:AgentLoopGraphBuilder 用 __agentloop_<nodeId>_* 前缀, 多个同类节点才互不串台。全部注册为 REPLACE(避开 append-merge 的复杂度)。
  5. 🔴 所有入 state 的值必须是纯 JSON 可序列化类型(ArrayList/LinkedHashMap/String/Integer/Boolean)—— 工具结果、模型 calls 都以字符串落 state,不放 Hutool 的 JSONObject/JSONArray, 以免 checkpoint 序列化踩坑(DSL §3.2 硬约束)。
  6. 子节点事件以 <nodeId>-<internalId> 上浮父流,trace 命名空间同前缀。
  7. NodeIoContract 里这三个类型的实读实写键在各自的 GraphBuilder / 编译器里, 契约只登记「给人当产出引用」的那些。

加一种触发器 ​

现有四种:chat / form_event / schedule / webhook。

  1. TriggerDTO 加类型;WorkflowTriggerExtractor 加抽取(含落库侧的非法值拦截)。
  2. 无头触发走 WorkflowTriggerInvoker#invokeHeadless:以定义发布者身份 AgentExecutionContext.ofSystem(ownerUserId) 跑,owner 缺失 fail-close 返回 false。
  3. 🔴 入站 body 必须经 UntrustedInputSanitizer 按污点源分档限长——第二道闸覆盖三个源, 新触发器若接收外部数据就是第四个源,得在 AgentSanitizeProperties 里给它一档。
  4. agent_workflow_def 的 trigger_type / trigger_key 投影与三个索引要跟上。
  5. 若要限流,参考 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 ​

  1. 🔴 13 种节点类型,不是 10 种。 按 executor/impl/ 目录清点会少 agent_loop/iteration/sub_workflow 三个合成子图类型。
  2. 🔴 wait 执行器不等待,parallel 执行器什么都不做。 两者都是刻意的,理由见 核心机制第二条。按名字读代码会以为它们坏了而去「修好」它们。
  3. 🔴 checkpoint「释放」不删快照行。 graph_checkpoint 随执行次数单调增长,强删定义也带不走它。 DELETE_CHECKPOINTS 在 SAA 1.1.2.2 里是死常量。
  4. 🔴 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 个角色)清账之前只能用超管账号跑。
  5. 🔴 沙箱真烧 token、真读库。 「不计配额」不等于「不产生消耗」,账单上不记名。
  6. 🔴 input_snapshot / output_snapshot 无长度守卫,mediumtext 16 MiB 上限, 每执行每节点各一份,且 agent_node_execution 没有保留期清理任务。
  7. 🔴 工作流 llm 节点的 token 不进 agent_usage_log。 LlmNodeExecutor 把用量写进 ctx.getMetrics() → agent_node_execution,不写计费表。所以 TENANT/USER 维度配额对工作流里的 LLM 消耗全瞎。详见计量与配额。
  8. ⚠️ webhook 第二道闸(叶子限长)是静默的。 调用方拿 200 + data:true, 合法业务数据被截成半截后下游拿着残缺数据继续跑。观测路径只有服务端那条 warn。
  9. ⚠️ 停用某个 PUBLISHED 版本会让生效版本自动回落到次新的 PUBLISHED 版本。停用不等于停摆。
  10. ⚠️ 两个调度器的开关前缀不是 enlev.agent。 WorkflowScheduleTriggerScheduler.java:59,87-88 与 WorkflowWaitResumeScheduler.java:56,86-87 用的是 agent.workflow.*(无 enlev. 前缀), 6 个 key 全部 matchIfMissing = true / 带默认值,且在所有 yaml 中均无配置——全跑默认值。 运维在 enlev.agent 下面找不到它们。
  11. ⚠️ sub_workflow 的变量映射是重命名,不是硬隔离。 子图技术上仍见未映射的父键。
  12. ⚠️ 契约一致性第 ③ 档(实写键全集)无强制点。 写错一个 defaultName, 除 NodeIoContractTest 抽查到的那几条外不会有测试变红。
  13. ⚠️ variable_snapshot 是可查询投影,不是 resume 源。 resume 读的是 graph_checkpoint。 改前者不影响恢复行为,改后者才会。
  14. ⚠️ suspend_node 为 NULL 有两种含义:非挂起态,或取不到 checkpoint 快照。 注释写明「刻意不回落成错值」,所以别把 NULL 一律读成「没挂起」。
  15. ⚠️ webhook body 仍会被 ApiAccessLogInterceptor 记进访问日志(台账 N32)。 验签挡住了未授权调用,挡不住日志里的明文 body。

fail-closed(用户会来报) ​

  1. 编译期即校验:类型没执行器 → WORKFLOW_NODE_EXECUTOR_NOT_FOUND; 边指向不存在节点 / id 重复 / 图为空 → WORKFLOW_DSL_INVALID。
  2. 发布闸门:WORKFLOW_DEF_VALIDATE_FAILED,文案用「;」拼全部 error (所以前端才需要 publishGate,见怎么扩展)。
  3. agent_loop 不收敛主动抛错:WORKFLOW_AGENT_LOOP_NOT_CONVERGED(1_041_006_007)。
  4. plan-as-workflow 生成到轮次上限即失败:AgentPlanProperties.asWorkflowMaxRounds = 6, 超限 → WORKFLOW_PLAN_DSL_GENERATION_FAILED(ErrorCodeConstants.java:725 称其与上一条 「同款 fail-close 纪律(方案 §7:暴露问题而不硬猜)」)。
  5. webhook 不存在免签分支:authType 缺失 / none / 未知一律拒。限流器在 Redis 故障时同样 fail-close。
  6. 无头启动缺 owner 即跳过:owner_user_id 为空 → invokeHeadless 返回 false,不执行。
  7. schedule 触发 CAS 至多一次:竞争失败方直接放弃,「宁可丢一次触发,不要重复副作用」。
  8. 释放 checkpoint 真失败时整单中止:WORKFLOW_DEF_DELETE_CHECKPOINT_FAILED,业务行一行未动。
  9. cancel 之后不可 resume:CANCELLED 不在 isResumable 的白名单里, 撞上来抛 WORKFLOW_EXECUTION_STATE_INVALID,消息里带当时真实的状态,不猜。
  10. BPM 审批回调身份不全就跳过自动唤醒:AgentBpmApprovalListener#onApplicationEvent:66 在 !ctx.isComplete() 时不派发事件,流程留在 INTERRUPTED 等人工 resume—— 宁可不自动推进,也不在身份不明时写状态。
  11. 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 会随执行次数单调增长」——由「释放只 UPDATE graph_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 脱敏。

相关页面 ​