fix(codeflow-canonical): canonical 把 while 的 `child_flow` 改名成 `childFlow`,而 `_emit_while` 只读 snake——新 build 产物在 console `/source` 恒 unavailable,plaita-ai emit 往返断链;顺带 While 每次解析误报 unknown key、`--format ir` 摘要漏数循环体
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 25/100
Research direction
Start in plaita/dsl/codeflow/_emit.py, where _emit_while reads child_flow while _emit_collection reads childFlow, and in plaita/dsl/codeflow/_canonical.py, where the rename happens. Reproduce by building a while flow in canonical format and calling emit_source on it. First decide which key form the emitter should accept, since the compiler comment in _stmt.py conflicts with the canonical rename. Then fix LEGACY_KEYS in plaita/node/loop.py and count_nodes in _canonical.py. Done when the canonical round trip emits @flow source, count_nodes reports the true node count, and the unknown-key warning is gone.
Written by the indexing model from the issue text.
Description
优先级 P1 · 依赖:无
基线 HEAD = 65f48f2
Summary
python -m plaita build 默认输出 canonical 形态(plaita/cli.py:46-48,--format 的
default="canonical")。canonical 由本批新增的 plaita/dsl/codeflow/_canonical.py 生成,它在
:47-48 把 IR 中 while 节点的 child_flow 子图键改名成 childFlow;但 emit_source 的 while
分支(plaita/dsl/codeflow/_emit.py:371-372)只读 snake 的 child_flow,取到空 dict 后落进
_emit_inline_child 的 raise EmitError("childFlow 缺 start 节点")(_emit.py:428-430)。
于是含 while 的 canonical 产物在所有 emit_source 消费端都无法反编译成 @flow 源码,
其中就包括同一批 65f48f2 新增的 console「无 metadata.source 时反编译兜底」。
证据 1:新增的 canonical 序列化器把 while 的 child_flow 改名成 childFlow
plaita/dsl/codeflow/_canonical.py:47-48
if key == "child_flow":
out["childFlow"] = _canonical_scope(value)
该改名是刻意设计,同文件 :6-9 自述目标:
1. **与 console 发布的 definition 同构**——``type`` 首键、嵌套子图统一
``childFlow``(含 while 节点的 ``child_flow`` 改名)、节点顶层 None 剔除
(pydantic 对 Optional 字段「不写该键」与显式 null 不等价:显式 None 会让
list/dict 字段校验直接失败,而「不写」是安全的引擎默认回填)。
_canonical.py 由本批 1ad55ca(feat(cli): python -m plaita build——@flow 编译落盘统一入口)
新增,git log --diff-filter=A -- plaita/dsl/codeflow/_canonical.py 只命中该提交。
证据 2:既有 while 发射器只认 snake,取空即抛
plaita/dsl/codeflow/_emit.py:371-372
def _emit_while(self, node: Dict[str, Any], outer_stop: Set[str]) -> List[str]:
child_ir = node.get("child_flow") or {}
同一提交/同一文件的集合节点分支读的却是 camel(:386),两者各认一形:
def _emit_collection(self, node: Dict[str, Any], outer_stop: Set[str]) -> List[str]:
kind = node["type"].upper()
child_ir = node.get("childFlow") or {}
canonical while 的落点,plaita/dsl/codeflow/_emit.py:428-430:
start = next((n for n in child_ir.get("nodes", []) if n.get("type") == "start"), None)
if start is None:
raise EmitError("childFlow 缺 start 节点")
证据 3:编译器刻意保持 snake,与新增 canonical 直接冲突
plaita/dsl/codeflow/_stmt.py:196-198
# While 模型只认 child_flow(无 childFlow camelCase 兼容键,
# 2026-09-30 实测:写 childFlow 会被 schema 当未知键静默忽略)。
"child_flow": child_flow,
证据 4:console 新增端点在异常分支把反编译失败直接降级为 unavailable
plaita-console/backend/api/flows.py:278-285(该端点由 65f48f2 新增)
if defn.get("nodes"):
try:
from plaita.dsl.codeflow import emit_source
return {"source_kind": "decompiled", "source": emit_source(defn)}
except Exception as e: # noqa: BLE001 — 不可表达的构造如实回原因
return {"source_kind": "unavailable", "source": None,
"reason": f"emit_source 失败: {e}"}
PUT 侧守卫只到 Flow.model_validate,不校验可反编译,故 canonical while 定义能正常入库
(plaita-console/backend/api/flows.py:119-128):
def _validate_definition(definition: str) -> None:
"""用 Flow.model_validate 强校验 definition,失败抛 422。"""
try:
data = json.loads(definition)
except json.JSONDecodeError as e:
raise HTTPException(status_code=422, detail=f"definition 非合法 JSON: {e}")
try:
Flow.model_validate(data)
except Exception as e:
raise HTTPException(status_code=422, detail=f"Flow 校验失败: {e}")
证据 5:emit_source 的三个生产调用点都把 definition 原样传入,全仓无键形归一化
$ grep -rn "emit_source(" --include='*.py' plaita/ plaita-console/backend/ plaita-ai/ | grep -v tests
plaita/dsl/codeflow/_emit.py:6: compile_source(emit_source(ir)) ≡ ir (语义等价)
plaita/dsl/codeflow/_emit.py:602:def emit_source(ir: Dict[str, Any]) -> str:
plaita/dsl/codeflow/_emit.py:605: ``compile_source(emit_source(ir))`` 与 ``ir`` 语义等价(口径见模块
plaita-console/backend/api/flows.py:282: return {"source_kind": "decompiled", "source": emit_source(defn)}
plaita-ai/plaita_ai/supervisor.py:166: return emit_source(ir)
plaita-ai/plaita_ai/mcp/server.py:114: source = emit_source(ir)
三个消费者均无 childFlow ↔ child_flow 归一化:
plaita-console/backend/api/flows.py:282(上引)直接emit_source(defn);plaita-ai/plaita_ai/mcp/server.py:110-114——ir = json.loads(definition)后原样emit_source(ir);plaita-ai/plaita_ai/supervisor.py:150-166—— definition 为 dict/JSON 串时原样送emit_source(ir),
失败静默返回空串。
$ grep -rn "childFlow" --include='*.py' plaita-console/backend/ plaita-ai/ | grep -v tests
plaita-console/backend/api/copilot.py:45: branches[].next;子流程放 childFlow(完整子 Flow,含自身 start/end)。
plaita-console/backend/services/flow_agent.py:42: branches[].next;子流程放 childFlow(完整子 Flow,含自身 start/end)。
plaita-console/backend/services/dryrun.py:247: 2026-09 安全评审 P0 修复:原实现只沿固定键(childFlow/flows/branches/children)
三处均为提示词/注释文本,非归一化代码;服务端不存在把 childFlow 折回 child_flow 的路径
(唯一的 childFlow: 'child_flow' 映射在 console 前端
plaita-console/frontend/src/components/flow/schemaForm/schemaUtils.ts:57-59 的 LEGACY_ALIASES,
由 plaita-console/frontend/src/stores/flowEditor.ts:454-456 调用,服务端不可达)。
证据 6:While 每次解析误报 unknown key(告警白名单被覆写掉)
plaita/node/loop.py:470-473:
condition: Optional[Union[Condition, ConditionGroup]] = None
max_iterations: int = 1000
LEGACY_KEYS: ClassVar[frozenset] = frozenset({"maxIterations"})
该 LEGACY_KEYS 覆写掉基类 plaita/node/child.py:42-44 的声明:
# setup_child_flow 消费的 camelCase 键(child_flow 字段无 alias)——
# 不登记会误报 unknown-key warning,诱导 AI 作者"修复"正确的流程
LEGACY_KEYS: ClassVar[frozenset] = frozenset({"childFlow"})
而告警白名单 = 声明字段 ∪ alias ∪ LEGACY_KEYS(plaita/node/basic.py:31-34, 53):
known = _known_input_keys(cls)
unknown = sorted(k for k in values if isinstance(k, str) and k not in known)
if unknown:
_logger.warning(
keys |= set(getattr(cls, "LEGACY_KEYS", ()) or ())
证据 7:运行期不受影响(本缺陷不含语义篡改/数据丢失)
canonical 的 childFlow 仍被正确消费——plaita/node/child.py:21-25 的 before-validator 同时读两形:
child_flow = values.get("childFlow") or values.get("child_flow")
if isinstance(child_flow, str):
values["child_flow"] = Flow.model_validate_json(child_flow)
elif isinstance(child_flow, dict):
values["child_flow"] = Flow.model_validate(child_flow)
故「will be IGNORED」是假话,缺陷仅限于反向发射 / 告警噪音 / 摘要计数。
证据 8:--format ir 的节点计数同样漏数循环体
plaita/dsl/codeflow/_canonical.py:113-127 只沿 childFlow 下钻:
def count_nodes(doc: Dict[str, Any]) -> int:
"""统计文档里的节点总数(含 childFlow / parallel 分支子图,供 CLI 摘要)。"""
total = 0
stack: List[Dict[str, Any]] = [doc]
while stack:
scope = stack.pop()
for node in scope.get("nodes", []):
total += 1
child = node.get("childFlow")
if isinstance(child, dict):
stack.append(child)
for branch in node.get("branches", []) or []:
if isinstance(branch, dict) and isinstance(branch.get("flow"), dict):
stack.append(branch["flow"])
return total
而 CLI 在 --format ir 时把 IR 文档交给它(plaita/cli.py:116 选 doc,
:140 与 :145 是真正调用 count_nodes 的两处):
doc = ir if args.format == "ir" else to_canonical(ir)
print(f"OK {out_path} 与源码编译产物逐字节一致"
f"({count_nodes(doc)} nodes, {len(text)} bytes)")
print(f"compiled -> {out_path}(format={args.format}, "
f"{count_nodes(doc)} nodes, {len(text)} bytes)")
证据 9:新增测试未覆盖该形状
plaita-console/backend/tests/console/test_flow_source_api.py:26-37 的 _echo_def 只有 start/end,
没有任何 while/childFlow 形状的 definition,故反编译兜底对 while 的缺口未被测到:
def _echo_def(flow_id: str = "echo") -> str:
return json.dumps(
{
"flow_id": flow_id,
"desc": "echo",
"inputType": {"dataType": "object"},
"nodes": [
{"type": "start", "id": "start", "next": "end"},
{"type": "end", "id": "end", "output": "$INPUT.name", "resultType": "success"},
],
}
)
tests/unit/test_codeflow_canonical.py:92-97 则正面断言该改名,:147-156 只在 canonical doc
上验 count_nodes——全套新测试没有任何 canonical → emit_source 往返用例:
def test_while_node_renames_child_flow(self):
while_node = _find(self.doc["nodes"], "while")[0]
self.assertIn("childFlow", while_node)
self.assertNotIn("child_flow", while_node)
复现 A:emit 往返(/tmp/plaita-audit-venv/bin/python,editable plaita[dev,all])
$ cd /tmp/verify-while && /tmp/plaita-audit-venv/bin/python -m plaita build while_flow.py --format ir -o ir.json
compiled -> ir.json(format=ir, 3 nodes, 1277 bytes)
$ /tmp/plaita-audit-venv/bin/python -m plaita build while_flow.py --format canonical -o canonical.json
compiled -> canonical.json(format=canonical, 5 nodes, 1276 bytes)
while_flow.py 内容:
def use_while(INPUT):
while item.n > 0:
return {"n": item.n - 1}
return "done"
同一对产物送 emit_source / Flow.model_validate / count_nodes(stdout+stderr 合并):
WARNING plaita: flow None structural check (non-fatal, flow still runs): [nodes] 流程缺少 type=start 入口节点(共 2 个节点)。运行期这将抛 FlowStartMissingError——请添加 start 节点(FlowBuilder 用 start_with,@flow 前端自动补)。
WARNING plaita.node.schema: While 'n_gt_0': unknown config keys ['childFlow'] will be IGNORED (possible typo?). Known keys: ['child_flow', 'condition', 'desc', 'error_handler', 'id', 'input', 'maxIterations', 'max_iterations', 'name', 'next', 'output', 'source_line', 'timeout', 'timeout_handler', 'type']
WARNING plaita: flow use_while structural check (non-fatal, flow still runs): [nodes] 流程缺少 type=start 入口节点(共 3 个节点)。运行期这将抛 FlowStartMissingError——请添加 start 节点(FlowBuilder 用 start_with,@flow 前端自动补)。
count_nodes ir/canonical = 3 5
CANONICAL OK
--- IR emit ---
@flow('use_while')
def use_while(INPUT):
for item in WHILE(item.n > 0, id='n_gt_0'):
return {'n': F.sub(item.n, 1)}
return 'done'
--- CANONICAL emit ---
EmitError childFlow 缺 start 节点
同一 while flow,IR 形态 emit 正常、canonical 形态抛 EmitError——差异完全由键形造成;
count_nodes 报 3,而两形态实际节点数都是 5。
复现 B:console 端到端
把 canonical.json(plaita build 默认产物,无 metadata.source)经
PUT /api/flows/whileflow/versions/0.0.1 落真 console backend(sqlite flow_store),再
GET /api/flows/whileflow/versions/0.0.1/source:
PUT status: 200 {"flow_id":"whileflow","version":"0.0.1","status":"draft","definition":"{\n \"runtime\": \"python\",\n \"flow_id\": \"use_while\",\n \"inputType\": {\n \"dataType\": \"object\"\n },\n \"nodes\": [\n {\n \"type\": \"start\",\n \"id\": \"start\",\n \"next\": \"n_gt_0\"\n }
GET status: 200
GET body: {"source_kind": "unavailable", "source": null, "reason": "emit_source 失败: childFlow 缺 start 节点"}
1 passed in 1.15s
即同一提交新增的「无 metadata.source 时反编译兜底」对含 while 的新产物落到 unavailable。
反证:该告警对集合节点属既有行为,本条是 canonical 把 while 也推进同一形态
用同一 venv 编译 for item in MAP(INPUT.nums) 的 flow 并取 IR 形态送 Flow.model_validate:
WARNING plaita.node.schema: Map '_n1': unknown config keys ['childFlow'] will be IGNORED (possible typo?). Known keys: ['async', 'child_flow', 'collection', 'concurrent', 'desc', 'error_handler', 'id', 'input', 'item_type', 'max_concurrent', 'name', 'next', 'output', 'source_line', 'timeout', 'timeout_handler', 'type']
集合节点的 IR 本就用 childFlow,故这部分属旧账;canonical 只在 while 上新增该触发。
影响
触发形态:任何用 python -m plaita build(默认 canonical、且未加 --embed-source)编译含
while 的 @flow,并把产物发布或喂给消费端的部署。plaita build 自身不调用 emit_source,
所以「CLI 单跑」不触发;触发需要产物进入 console 或 plaita-ai 的 emit 入口——而这正是本批把
canonical 定为「大仓子仓编译脚本收敛的统一后端」后要走的标准链路。
- console 源码页签恒不可用(生产可达):
GET /api/flows/{id}/versions/{ver}/source对含 while
的定义永远返回source_kind="unavailable",reason 为emit_source 失败: childFlow 缺 start 节点。
65f48f2 这次交付的卖点(无内嵌源码也能看 codeflow)在标准while构造上直接不工作;PUT 侧守卫
只做Flow.model_validate,不会提前拦下不可反编译的定义。 - plaita-ai 往返断链(生产可达):MCP
flow_from_json(mcp/server.py:114)会返回
ok: false+definition cannot be expressed as @flow;supervisor 的_emit_current_source
(supervisor.py:166)则静默降级为空串,LLM 退回只看 JSON definition,链路上不产生任何告警,
表现为「AI 改流程时代码能力无声消失」。 - 不可观测性加剧:失败被折叠成一个
unavailable,调用方无法从端点形态区分「构造本就不支持」
与「键形不一致的实现缺陷」。 - 误导性告警(噪音):canonical 产物每次被
Flow.model_validate解析都打
While 'n_gt_0': unknown config keys ['childFlow'] will be IGNORED,文案明确断言「将被忽略」,
会诱导作者去「修」一个本来正确的流程。 - CI 摘要数字失真:
--format ir打印3 nodes而实际 5,--check的 OK 行同理;用节点数做
断言或人工比对的流水线会误判。 - 不含安全/数据面后果:运行期解析、执行、console 落库与 worker 加载全部正常(证据 7),
无 RCE、无数据丢失、无静默语义篡改。
不修的后果:canonical 是本批推出的默认产物格式,含 while 的产物在源码视图与 plaita-ai 往返上
永久不可用,且失败以「正常降级」的姿态出现,不会有任何红灯。
建议
- 最小修法(一处):
plaita/dsl/codeflow/_emit.py:372同时接受两形——
child_ir = node.get("child_flow") or node.get("childFlow") or {}。这可立即解除 console 与
plaita-ai 三处的硬失败,与_emit_collection(:386读 camel)的既有写法对齐。 - 消除误报:
plaita/node/loop.py:473的LEGACY_KEYS补上childFlow
(frozenset({"maxIterations", "childFlow"})),与plaita/node/child.py:42-44的既有声明一致;
否则basic.py的未知键告警会持续对 canonical 产物说假话。 - 计数对齐:
plaita/dsl/codeflow/_canonical.py:121的count_nodes同样兼容child_flow与
childFlow,使count_nodes(ir)与count_nodes(to_canonical(ir))相等(CLI 的--format ir
分支直接把 IR 传给它)。 - 根因方向(不强制在本单完成):
emit_source入口做一次键形归一化,避免 IR(snake)与
canonical(camel)两套键形散落到每个节点分支各认一形;若决定保留 canonical 的改名,则
_canonical.py:6-9自述的「与 console 发布的 definition 同构」需要先与 console 实际形态对齐
——console 前端自己把childFlow归一成child_flow,自带示例定义也用"child_flow"
(plaita-console/backend/services/examples.py:50的 map 子图)。
验收条件:
- 新增用例:对一份含
while的 canonical doc(可用to_canonical(compile_source(...))构造)
调emit_source,断言不抛异常;再把返回源码compile_source回来,断言 while 节点与其
child_flow子图语义等价(对齐_emit.py:6的 round-trip 契约)。 - 扩展
plaita-console/backend/tests/console/test_flow_source_api.py:在_echo_def之外补一个
含 while 的 definition 用例,断言body["source_kind"] == "decompiled"且
"WHILE(" in body["source"]——当前该文件只有 start/end 形状,本缺口零覆盖。 - 告警:
caplog断言Flow.model_validate(canonical_while_doc)不再产生
unknown config keys ['childFlow']。 - 计数:断言
count_nodes(ir) == count_nodes(to_canonical(ir))(同一 while flow)。 - 复现脚本应变成:
emit_source(canonical.json)输出for item in WHILE(item.n > 0, id='n_gt_0'):;
GET .../source返回source_kind="decompiled"。
边界
- 不重复既有单:
gh issue list -R jeffkit/plaita --state all --limit 100 --json number,title,state
全量 34 条按canonical|childFlow|child_flow|count_nodes|to_canonical|emit_source|反编译|源码视图
扫描零命中,对 issue 台账为新。#22(CLOSED,code 沙箱后端白名单两个生产入口未接线)根因无关;本批213e557
(fix(cli): 生产入口补 allowed_backends,过 plaita#22 白名单门)只是顺带过门,与本条的
键形不一致是两回事。#29(CLOSED,code 节点language="js"绕开沙箱)与#31(OPEN,HTTP 节点 SSRF)
属节点安全类,与本条无交集。
- 与同期草案 33/34/35 的区别:那三条是
_emit.py作为发射器自身读取错误——条件
eq/True被反成not x、事件分支只读 camelCase 的eventType、end忽略resultType;
它们消费的是键形本来正确的 IR。本条引入方是新增的 canonical 序列化器改键
(_canonical.py:47-48,本批1ad55ca新增),_emit.py在本批ea53e46..65f48f2区间内
无任何改动(git log ea53e46..65f48f2 -- plaita/dsl/codeflow/_emit.py为空)——位点与归因都不同。 - 与同期草案 36 的区别:36 在编译侧
_stmt.py——while 条件/while 体/集合体构造子作用域时
漏传ctx.childflows,产物因此少东西;本条在序列化侧改键,导致反向发射读不到东西。 - 不做:不顺手重构
emit_source的整体键形体系(第 4 点只给方向,不要求本单落地);不改
while 的运行期解析(当前正常);不覆盖switch/parallel等其它构造;不处理 console 前端
不消费unavailable.reason的展示问题(那是独立缺陷,修它只能把本条 reason 显示出来,
修不好反编译)。 - 可达性前置:本条在常规部署下可达,无 feature flag 遮挡(console 的 flows router 由
plaita-console/backend/main.py:270的_mount_admin(flows.router, "flows")无条件挂载,
只挂 Admin API Key 依赖)。唯一能挡住 console 侧的情形是所有产物都强制
带--embed-source(此时走authoritative分支),但 plaita-ai 的 MCP / supervisor 两个 emit
入口仍会命中。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 4h 24m
- Merged PRs (30d)
- 4
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
webrecorder/warcio#209 ·
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Jeffy123-zhu/logspecter#20 ·
-
Difficulty 1/5 Under an hour Newbie friendliness 85/100
Maintainers usually reply within 1 day