Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

fix(codeflow-canonical): canonical 把 while 的 `child_flow` 改名成 `childFlow`,而 `_emit_while` 只读 snake——新 build 产物在 console `/source` 恒 unavailable,plaita-ai emit 往返断链;顺带 While 每次解析误报 unknown key、`--format ir` 摘要漏数循环体

Closed
#89 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
25/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
python
Domain
compilers

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 往返上
永久不可用,且失败以「正常降级」的姿态出现,不会有任何红灯。

建议

  1. 最小修法(一处):plaita/dsl/codeflow/_emit.py:372 同时接受两形——
    child_ir = node.get("child_flow") or node.get("childFlow") or {}。这可立即解除 console 与
    plaita-ai 三处的硬失败,与 _emit_collection(:386 读 camel)的既有写法对齐。
  2. 消除误报:plaita/node/loop.py:473 的 LEGACY_KEYS 补上 childFlow
    (frozenset({"maxIterations", "childFlow"})),与 plaita/node/child.py:42-44 的既有声明一致;
    否则 basic.py 的未知键告警会持续对 canonical 产物说假话。
  3. 计数对齐:plaita/dsl/codeflow/_canonical.py:121 的 count_nodes 同样兼容 child_flow 与
    childFlow,使 count_nodes(ir) 与 count_nodes(to_canonical(ir)) 相等(CLI 的 --format ir
    分支直接把 IR 传给它)。
  4. 根因方向(不强制在本单完成):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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.