fix(spec,service-automation,lint): flow metadata is canonicalized inside structured regions (#4347) - #4381
Merged
Merged
Conversation
…ide structured regions (#4347) `registerFlow` canonicalizes a stored flow through three passes — the ADR-0087 conversion table, `FlowSchema.parse`, and the ADR-0032 predicate validation — and every one of them walked `flow.nodes` / `flow.edges` only. An ADR-0031 container keeps a whole sub-graph in its open `config`, so all three stopped at the container and metadata came out position-dependent: the same node converted at the top level and did not one level in, and the same predicate was stored as a `{ dialect: 'cel', source }` envelope on a top-level edge and left a bare string on a loop-body edge. - `mapFlowNodes` recurses into `loop.config.body`, `parallel.config.branches[]` and `try_catch.config.try`/`.catch`, to any depth. Notice paths carry the region. Matters most for the conversions that change behaviour rather than spelling: a `webhook` callout inside a loop body kept a type no executor owns, and a `delete_record` kept `config.filters`, leaving the canonical `filter` the executor reads absent. - New `normalizeControlFlowRegions`, called at the load seam after `validateControlFlow`: each region parses through its own schema, recursively, so nested edges and nodes carry the same canonical shapes as top-level ones. A region that does not parse is left untouched, so which flows register is unchanged. - New `collectFlowGraphs` yields a flow's own graph plus every nested region with a scope label. Both predicate validators iterate it — the engine's `validateFlowExpressions` and lint's `validateStackExpressions` — so the `{record.x}` brace-trap they exist to catch is caught inside a region too. - `evaluateCondition`'s legacy `{var}` path refuses an unresolved dotted reference instead of comparing it as a string: `'oppRecord.amount > 500000'` compared `'o'` against `'5'` and was constantly true regardless of the amount. The `try/catch { return false }` around that block goes with it — nothing in it throws, so it guarded nothing and would have swallowed the refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HSBbKMdDgHjGpQdrvQUQXj
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
July 31, 2026 10:31
This was referenced Jul 31, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…4401) (#4408) A region is a sub-graph inside `FlowNodeSchema.config`, an open `z.record`. Nothing in the type system says which key on which node type holds one, so every pass that needs to reach a region node has to be told — and within one week three of them were told separately, by two changes that were each correct on their own (#4381 and #4388): mapFlowNodes (ADR-0087 conversions) spec FLOW_REGION_SLOTS validateControlFlow / normalizeControlFlowRegions / collectFlowGraphs spec regionSlotsOf walkFlowNodes (lint flow rules) lint REGION_SLOTS Each pinned its own copy with its own reconciliation test. So every copy was protected from drifting away from the schemas, and nothing would have failed if the copies drifted from each other — while adding a fourth construct meant editing three places, and missing one reproduces exactly the silent blind spot #4347 and #4380 were both filed about. - New `@objectstack/spec/automation` export `FLOW_REGION_SLOTS` (+ the `FLOW_REGION_SLOTS_BY_TYPE` / `FLOW_REGION_CONFIG_KEYS` views) is the only statement of the fact. Import-free module, so `spec/conversions/walk.ts` can read it and stay the pure shape walker it was written as; mapping a slot onto the Zod schema its value parses as stays in `control-flow.zod.ts`. - The three reconciliation tests collapse into `region-slots.test.ts`, keeping the strongest of them (#4388's): it derives each construct's region keys BEHAVIOURALLY, by asking the config schema what it accepts in a region shape, rather than reading names off `.shape`. It also probes every other exported `*ConfigSchema`, so a new region-bearing construct cannot be added without either declaring its slots or failing here. The three walks are deliberately left separate: different inputs (parsed vs raw authored records), different units (a graph, a node, a copy-on-write rewritten tree), and the lint one formats human diagnostic trails from node labels — consumer logic, not protocol (Prime Directive #2). No behaviour change: every existing test passes unchanged. Claude-Session: https://claude.ai/code/session_01HSBbKMdDgHjGpQdrvQUQXj Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4347.
问题
registerFlow用三道 pass 归一化存量 flow —— ADR-0087 转换表、FlowSchema.parse、ADR-0032 谓词校验 —— 而这三道都只遍历flow.nodes/flow.edges。ADR-0031 的容器节点把整张子图放在自己开放的config里(loop.config.body、parallel.config.branches[]、try_catch.config.try/.catch),于是三道 pass 都停在容器边界上,元数据变成了随嵌套深度而变:{ dialect: 'cel', source }信封,在 loop body 的边上留成裸字符串。报告方(hotcrm)因此上线了三个"哑火"的 sweep:每次运行都
success: true,查询正确、选中的记录也完全正确,然后什么都不做,日志里也没有任何痕迹 —— 不去断言写入的记录,就无法把"这次没活干"和"这个门根本没打开"区分开。与 issue 描述的一处出入(已核实)
issue 里那段 repro 在当前
main上已经能跑通:loop/parallel/try_catch三个执行器在 #4277 之后都会在运行时用parseNodeConfig解析自己的 config,而LoopConfigSchema.body就是FlowRegionSchema,所以 region 边的谓词在执行时会被归一化,门是开的。真正仍然存在的缺陷是 issue 标题所指的那个:转换表压根到不了 region 里的节点,以及所有"不重新 parse 就读 region"的消费方(Studio 设计器、
getFlow、版本历史)看到的仍是未归一化的形状。本 PR 附带的第一个测试就是在 fix 之前失败、fix 之后通过的复现。改动
packages/spec—mapFlowNodes递归进 regionconversions/walk.ts现在按容器类型 + region 形状双重判定后下探,支持任意深度嵌套(loop→try_catch→loop)。notice 的 path 会带上 region 位置:这对两条改变行为而非拼写的转换最关键:loop body 里的
webhook/http_request保留了没有任何执行器认领的 type(整个 run 直接失败);delete_record保留了config.filters,导致执行器读的规范键filter根本不存在 —— 这正是flow-node-crud-filter-alias当初要防的"条件被抹掉"风险。槽位表声明在
walk.ts本地(该模块是纯形状 walker,刻意不引入 schema),由一个 reconciliation 测试钉死在LOOP_NODE_TYPE/PARALLEL_NODE_TYPE/TRY_CATCH_NODE_TYPE和三个 config schema 的 shape 上,所以新增第四种容器构造时不会悄悄漏掉这里。packages/spec— 新增normalizeControlFlowRegions在 load seam 上、
validateControlFlow之后调用:每个 region 走自己的 schema 解析(递归,因为 region 会嵌套),使嵌套的边和节点与顶层携带同样的规范形状。解析不通过的 region 原样保留 —— 拒绝畸形 region 仍然是validateControlFlow的职责,所以"哪些 flow 能注册"没有变化。packages/spec— 新增collectFlowGraphs返回 flow 自身的图 + 每个嵌套 region,各带一个 scope 标签。两个谓词校验器都改成遍历它:引擎的
validateFlowExpressions和@objectstack/lint的validateStackExpressions。于是它们本来要抓的{record.x}花括号陷阱(#1491)在 loop body 里也会被抓到,并指明位置:此前这种写法能过
objectstack validate、能过注册,只在运行时炸掉,而那条诊断信息恰好被压掉了。service-automation— 收紧 legacy{var}路径(issue 的"次要加固")evaluateCondition的模板兜底路径现在拒绝未解析的点号引用,而不是把它当字符串比:—— 一个报告成功、却从来没有真正拦截过任何东西的门。抛错信息带上源码和修法(换成 CEL 信封,或者本来就想用
{var}方言的话把引用括起来)。同时移除了那段
try { … } catch { return false }:块内没有任何会抛的语句(indexOf/slice/Number/compareValues都是全函数),它什么也没保护,却会把新的拒绝原样吞回成"静默的错误答案" —— 正是 ADR-0032 §1c 为 CEL 路径立下的那条规矩。只拒绝带点的引用。裸词比较是刻意保留的:
'{status} == active'是有文档的 legacy 写法,替换之后两边都是普通词;小数字面量(1.5)也不是引用。测试
新增 3 个测试文件(共 32 个用例),每个都是对称性断言 —— 同样的元数据,一份放顶层、一份放 loop body,结果必须一致:
packages/spec/src/conversions/region-walk.test.ts— 转换进入 loop / parallel / try_catch,三层嵌套,copy-on-write 保持(未改动的分支保持同一引用),形状门控(http节点的config.body请求体不会被误当作子图),恶意type: 'constructor'不会走到Object.prototype,自引用 region 不会无限递归,以及槽位表的 reconciliation。packages/spec/src/automation/region-normalization.test.ts— 嵌套谓词被信封化并与顶层一致、parallel 分支的name不被吞掉、幂等 + copy-on-write、解析失败的 region 原样返回、collectFlowGraphs的 scope 串联。packages/services/service-automation/src/nested-region-parity.test.ts— 走真正的registerFlowseam:issue 的 repro(裸字符串与显式信封两种写法都存下信封、都开门、条件为假时仍然不开门),转换后的嵌套webhook节点真的被执行(测试里只注册规范 type 的执行器,未转换就会失败),嵌套 CRUD 别名归一化,以及 loop body 里的花括号陷阱现在会被注册期拒绝。packages/lint/src/validate-expressions.test.ts— 作者期(objectstack validate)的对应用例。验证
pnpm buildpnpm testpnpm typecheckpnpm lintpnpm --filter @objectstack/spec check:generatedcheck:api-surface/check:exported-anyapi-surface.json已重新生成:新增 3 个导出)刻意不做的事
validateNodeTypes和validateNodeConfigKeys仍然只走顶层节点。前者是 soft-fail(warn),后者是 hard-fail —— 让它进 region 有可能拒绝现有 flow,属于独立的行为变更,不该顺手塞进这个 PR。转换现在能到达 region,本 PR 针对的别名场景已经覆盖到了。Generated by Claude Code