Skip to content

bug(service-automation): flow 绑定失败的告警把 Zod issue 数组塞进单行日志,读者只拿到一个孤零零的 [ #5048

Description

@os-zhuang

在 17.0.0-rc.2 上实测;诊断源自 hotcrm 侧的 objectstack-ai/hotcrm#653

症状

AutomationServicePlugin 的三个 flow 绑定点都用同一种方式渲染错误:把 err.message 直接插进一条单行 logger.warn

packages/services/service-automationdist/index.js 中对应的三处(源码里是 syncFlowsFromProtocol / resyncFlowsFromProtocol / start() 的 ObjectQL registry pull):

ctx.logger.warn(
  `[Automation] cold-boot flow bind: failed to register ${def.name}: ${err.message}`
);

registerFlowFlowSchema 解析,#4001 关闭了 metadata schema 之后,未知键是抛出而不是被丢弃。于是 err.message 是一个 Zod issue 数组的多行 JSON dump,它的第一行就是一个 [。CLI 的 boot diagnostics 每条记录只打一行,结果是:

WARN [Automation] cold-boot flow bind: failed to register campaign_enrollment: [
WARN [Automation] cold-boot flow bind: failed to register case_escalation: [
WARN [Automation] cold-boot flow bind: failed to register case_escalation_on_create: [
… 一共 24 条,每个 flow 一条 …

告警点了名字,却没说问题。一次启动的阅读成本,零信息量。

为什么现在报

触发它的那个缺陷(cloud#971,getMetaItems_diagnostics 装饰喂回严格 schema)已经在 rc.2 由 stripReadDecorations 在读取缝合处修好了,所以今天不再有 flow 绑定失败。但渲染缺陷本身一行未动:下一个真正绑不上的 flow,仍然只会给出一个 [

这不是假设。cloud#971 这个缺陷之所以能横跨整条 rc.1 发布线没人发现,唯一原因就是它的症状不可读——绑定是加性的,boot pull 已经注册过一遍,record-change 插件又是第二条绑定路径,于是"24 条谁也看不懂的告警"是它对外的全部表现。是第 2 个缺陷让第 1 个缺陷得以隐身。

实测复现(rc.2)

在 hotcrm 的 worktree 里,把 rc.2 的 strip 临时改回 rc.1 的行为(只改 node_modules,不提交):

// readFlowDefsFromProtocol
- return stripReadDecorations(doc);
+ return doc;

然后 npx objectstack dev --fresh -p 41877

$ grep -c "cold-boot flow bind" boot4.log
24
$ grep -m1 "cold-boot flow bind" boot4.log
2026-08-04T00:38:50.385Z WARN [Automation] cold-boot flow bind: failed to register campaign_enrollment: [

再把那一行日志里的换行替换成可见字符重跑一次,才看到它其实一直想说的话:

[
  {
    "code": "unrecognized_keys",
    "keys": [
      "_diagnostics"
    ],
    "path": [],
    "message": "Unrecognized key(s) on this flow: `_diagnostics`. Until #4001 these were dropped silently — the flow still parsed, so a trigger binding or config the author wrote was quietly ignored."
  }
]

这条信息里有维护者需要的一切:哪个键、在哪条路径、为什么现在才抛。它一直都在,只是从来没走出过日志的第一行。

建议的修法

不要靠 err.message 的字符串形态传递结构化信息——把 Zod issues 作为结构化数据交给 logger 的第二个参数,让它像其他告警一样被序列化成一行 JSON(WARN Failed to evaluate default expression {"object":…} 这类在同一次启动里是完整可读的,因为换行被包在 JSON 字符串里转义了)。大致方向:

} catch (err) {
  ctx.logger.warn('[Automation] cold-boot flow bind: failed to register flow', {
    flow: def.name,
    // ZodError 有结构化的 issues;其余错误退回单行 message
    issues: err?.issues,
    error: err?.issues ? undefined : String(err?.message ?? err),
  });
}

要点是类别而不是这一处:三个绑定点(cold-boot bind、metadata:reloaded re-sync、boot pull)以及 readFlowDefsFromProtocolgetMetaItems 失败的那条 warn,都是同一个 ${err.message} 单行插值形态,都会在错误恰好是 ZodError 时退化成一个 [。建议一起改。

顺带一提,这与 #4632 立下的规矩同源:一个降级如果代价是"没人能读懂发生了什么",它的日志就还不够格。被截断的诊断比没有诊断更贵——它要你付一次启动,然后什么都不告诉你。

影响面

已在 hotcrm 侧钉住的部分

hotcrm 加了 test/flow-cold-boot-rebind.test.ts:把每个 flow 过一遍真实的 AutomationEngine.registerFlow,失败时打印完整的 issue 数组。它管不到平台的日志行,但至少保证 HotCRM 的 flow 不会成为那条日志所隐藏的东西。平台侧这一半只能在这里修。

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions