fix(console): 反编译兜底降级不完整——`unavailable` 的 reason 与所有失败被静默吞掉,且 decompiled 模式下说明文案仍称「行号与 source_line 对应」
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- Half a day
- Newbie friendliness
- 50/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- react, typescript
- Domain
- developer-experience, frontend
Research direction
Start in plaita-console/frontend/src/pages/FlowEditor.tsx around lines 205-217, where the .then handles only the decompiled branch and the .catch swallows failures. Then read SourceViewPanel.tsx (the banner at 75-79, the format-dependent copy at 106-112) and api.ts:653-657, where reason is typed but never read. Done means an unavailable response shows its reason in the UI, and the decompiled view no longer shows the contradictory 'line numbers match source_line' sentence. The backend test tests/console/test_flow_source_api.py must still report 4 passed.
Written by the indexing model from the issue text.
Description
优先级 P2 · 依赖:无
基线 HEAD = 65f48f2
Summary
65f48f2 新增的源码视图兜底端点 GET /flows/{flow_id}/versions/{version}/source 把「反编译不可用」的原因
如实算进响应(source_kind="unavailable" + reason),但前端只接 decompiled 一个分支——unavailable
与一切请求失败被静默吞成同一个「无 @flow 页签」;同时新增的反编译横幅与同屏既有的说明文案互相否定。
- 后端如实给出
reason(plaita-console/backend/api/flows.py:283-287,逐字):
except Exception as e: # noqa: BLE001 — 不可表达的构造如实回原因
return {"source_kind": "unavailable", "source": None,
"reason": f"emit_source 失败: {e}"}
return {"source_kind": "unavailable", "source": None,
"reason": "定义缺失或不可解析"}
该端点整体为 65f48f2 新增(flows.py:256-287)。
- 前端降级只认
decompiled(plaita-console/frontend/src/pages/FlowEditor.tsx:205-217,逐字):
if (!src) {
// 无内嵌源码(画布 flow / 存量 JSON):后端 emit_source 反编译兜底。
// 失败(不可表达的构造)保持无页签;竞态用 cancelled 防旧版本回写。
let cancelled = false
api.getFlowVersionSource(flowId, versionParam).then((res) => {
if (cancelled) return
if (res.source_kind === 'decompiled' && res.source) {
setFlowSource(res.source)
setSourceKind('decompiled')
useFlowEditor.setState({ hasFlowSource: true, sourceKind: 'decompiled' })
}
}).catch(() => { /* 反编译不可用:如实不显示 @flow 页签 */ })
sourceFetchCancelled = () => { cancelled = true }
}
:211 的 res.source_kind === 'decompiled' && res.source 是 .then 里唯一的判断,unavailable
(以及任何非 decompiled 返回)落空;:216 的 .catch(() => {}) 再把 404(后端版本旧 / 路由不存在)、
5xx、网络失败压成同一个静默结果。两者对用户是同一种表现。
reason在前端只被声明类型、无消费点(plaita-console/frontend/src/services/api.ts:653-657,逐字):
async getFlowVersionSource(
flowId: string,
version: string,
): Promise<{ source_kind: 'authoritative' | 'decompiled' | 'unavailable'; source: string | null; reason?: string }> {
return request(`/flows/${flowId}/versions/${version}/source`)
},
反证命令与输出(在 65f48f2 工作区实跑,逐字照抄):
$ grep -rn "reason" --include="*.ts" --include="*.tsx" plaita-console/frontend/src
plaita-console/frontend/src/components/flow/AiGenerateDialog.tsx:13: reason?: string
plaita-console/frontend/src/components/flow/AiGenerateDialog.tsx:54: setErrorMsg(ev.reason || '多轮自纠失败')
plaita-console/frontend/src/pages/Supervisor.tsx:22: promotion_ticket?: { version: string; command: string; reason: string }
plaita-console/frontend/src/pages/ExecutionDetail.tsx:109: const messageKey = ['message', 'msg', 'error', 'exception', 'detail', 'reason'].find(
plaita-console/frontend/src/services/api.ts:656: ): Promise<{ source_kind: 'authoritative' | 'decompiled' | 'unavailable'; source: string | null; reason?: string }> {
$ grep -rn "\.reason" --include="*.ts" --include="*.tsx" plaita-console/frontend/src
plaita-console/frontend/src/components/flow/AiGenerateDialog.tsx:54: setErrorMsg(ev.reason || '多轮自纠失败')
除 api.ts:656 的类型声明本身,其余命中都属别的功能(AI 多轮自纠事件、Supervisor 的 promotion_ticket、
ExecutionDetail 的事件消息键名);唯一读 .reason 的 AiGenerateDialog.tsx:54 读的是 AI 对话框事件,
不是 /source 响应。全前端无一处消费 /source 的 reason。
- 新增横幅与既有说明文案在 decompiled 下互相否定。
新增横幅(plaita-console/frontend/src/components/flow/SourceViewPanel.tsx:75-79,逐字;由 65f48f2 加入):
{sourceKind === 'decompiled' && (
<p className="mb-3 px-2 py-1.5 rounded bg-amber-500/10 border border-amber-500/30 text-[11px] leading-relaxed text-amber-300">
反编译等效源码(由定义经 emit_source 重构):非仓内权威源码——无注释、
id/行号不对应原文件,节点行号跳转停用。权威源码请看仓库里的 .py(发布时
用 <code>plaita build --embed-source</code> 可内嵌)。
</p>
)}
同一面板的说明段落(SourceViewPanel.tsx:106-112,逐字)只按 format 选取,不看 sourceKind:
<p className="text-xs text-dark-400 mb-2">
{format === 'yaml'
? 'YAML:配置文件首选,支持注释。保存到文件用 .yaml / .yml 后缀,Flow.from_file 可直接加载。'
: format === 'json'
? 'JSON:与可视化编排工具互通的格式,Flow.from_string 可直接加载。'
: '权威 @flow 源码(节点 id 即变量名 / 条件语义);行号与画布节点 source_line 对应。'}
</p>
配合两处条件(同样逐字):
SourceViewPanel.tsx:28——const [format, setFormat] = useState<Format>(source ? 'flow' : 'yaml')SourceViewPanel.tsx:65——const formats: Format[] = source ? ['flow', 'yaml', 'json'] : ['yaml', 'json']
decompiled 时 source 恰好非空 → formats 含 'flow',且面板挂载时 format 初始即 'flow',
用户点开 @flow 页签必然同屏看到 :77-78(「id/行号不对应原文件」)与 :111
(「行号与画布节点 source_line 对应」)两句互相否定的话。
- 受损是文案可信度而非错误跳转——跳转按钮已按
authoritative门禁
(plaita-console/frontend/src/components/flow/NodeConfigDrawer.tsx:317,逐字):
{hasFlowSource && sourceKind === 'authoritative' && nodeSourceLine != null && (
复现(/tmp/plaita-audit-venv/bin/python,Python 3.12 + editable plaita[dev,all],cwd = plaita 仓,
HEAD = 65f48f2,输出逐字照抄):
① 直接调 emit_source——不可表达构造只落成 reason 的来源,不会抛到 HTTP:
$ /tmp/plaita-audit-venv/bin/python - <<'PY'
from plaita.dsl.codeflow import emit_source
sw = {'flow_id': 'sw', 'nodes': [
{'type': 'start', 'id': 'start', 'next': 'sw1'},
{'type': 'switch', 'id': 'sw1', 'cases': [{'when': '$INPUT.x', 'next': 'end'}], 'default': 'end'},
{'type': 'end', 'id': 'end', 'output': '$INPUT.x'}]}
pl = {'flow_id': 'pl', 'nodes': [
{'type': 'start', 'id': 's', 'next': 'p'},
{'type': 'parallel', 'id': 'p', 'branches': [{'next': 'e1'}, {'name': 'b', 'next': 'e2'}]},
{'type': 'end', 'id': 'e1', 'output': '$INPUT.x'},
{'type': 'end', 'id': 'e2', 'output': '$INPUT.y'}]}
for label, d in (('switch', sw), ('parallel-missing-name', pl)):
try:
emit_source(d); print(label, '-> ok')
except Exception as e:
print(f"{label}: {type(e).__name__}: {e}")
PY
switch: EmitError: 节点类型 'switch' 不可表达为 @flow 源码
parallel-missing-name: KeyError: 'name'
② 端点端到端(FastAPI TestClient,sqlite 临时库,仿 tests/console/test_flow_source_api.py 的 client fixture):
PUT: 200
GET switch: {'source_kind': 'unavailable', 'source': None, 'reason': "emit_source 失败: 节点类型 'switch' 不可表达为 @flow 源码"}
PUT: 200
GET parallel: {'source_kind': 'unavailable', 'source': None, 'reason': "emit_source 失败: 'name'"}
即 console 自己校验通过的合法定义(PUT 200)在 /source 上返回 200 + unavailable + reason,
前端对其的反应与网络失败完全一致:不出现 @flow 页签,也没有任何提示。
③ 后端既有测试断言 reason 是接口契约的一部分:
$ cd plaita-console/backend && /tmp/plaita-audit-venv/bin/python -m pytest tests/console/test_flow_source_api.py -q
4 passed, 22 warnings in 1.62s
其中 plaita-console/backend/tests/console/test_flow_source_api.py:90-100 的
test_unavailable_reports_reason_not_500 逐字断言:
def test_unavailable_reports_reason_not_500(client: TestClient):
# 非 object 的 inputType 是 emit_source 明确不支持的构造(EmitError)
d = json.loads(_echo_def("weird"))
d["inputType"] = {"dataType": "string"}
_put_version(client, "weird", json.dumps(d))
r = client.get("/api/flows/weird/versions/0.0.1/source")
assert r.status_code == 200, r.text
body = r.json()
assert body["source_kind"] == "unavailable"
assert body["source"] is None
assert "emit_source" in body.get("reason", "")
说明 reason 由后端专门计算、专门测试,前端零消费属接线缺口。
局限:前端无 JS 测试框架(frontend 无 vitest 配置与测试文件),:77-78 与 :111 的同屏矛盾为代码路径
推证(已逐行核对 format / formats / source 三处条件),未做浏览器点击复现。
影响
- 谁会踩到:任何在 console 里打开「画布手搓 flow / 存量 JSON」版本源码页的人——正是本兜底功能的目标人群。
只要定义里有一个emit_source表达不了的构造(switch节点、PARALLEL分支缺name、非 object 的
inputType),/source就返回unavailable,而界面表现与后端 404 / 网络断开完全相同:@flow 页签不出现,
没有一句话说明为什么。 - 代价(可诊断性):作者看到的是「这个版本没有 @flow 页签」,只能去猜是没嵌源码、没发布、还是前端坏了。
后端已经算好、能直接指明「节点类型 'switch' 不可表达为 @flow 源码」的reason被丢弃,排障只能进后端日志
或手工 curl 该端点。 - 代价(文案可信度):decompiled 模式下点开 @flow 页签,同屏上方横幅写「id/行号不对应原文件,节点行号跳转停用」,
下方说明写「行号与画布节点 source_line 对应」。用户按哪句行动都可能:相信后者会预期行号能对齐,
而真实行号来自反编译结果、与任何仓内文件无关。 - 生产可达:是,且是 console 主路径。链路为 FlowEditor 载入版本定义 → 定义无
metadata.source
→FlowEditor.tsx:205-217调api.getFlowVersionSource→ 后端flows.py:256-287;已实测 PUT200
的 switch / parallel 定义即触发unavailable(另含 404 与网络失败)。 - 不修的后果:兜底功能对「能反编译」的定义可用、对「不能反编译」的定义退化成静默无页签,
且用户无法区分「后端说不行」与「前端根本没连上」;新横幅与旧说明在 decompiled 下持续互相否定。
建议
- 前端补
unavailable分支(FlowEditor.tsx:209-216):.then里除decompiled外增加
res.source_kind === 'unavailable'的处理,把res.reason落到一个可见状态(例如 store 新增
sourceUnavailableReason,在SourceViewPanel顶部渲染一行说明),不要继续静默丢弃。 - 区分失败类别(
FlowEditor.tsx:216的.catch):至少要能在 console 侧留一条可观测记录。
若产品上坚持「不可用就不显示页签」,也应把reason呈现在页面某处,而不是让 404 / 5xx / 网络错误
与unavailable同形。 - 修文案矛盾(
SourceViewPanel.tsx:106-112)::111那句按sourceKind分支——
authoritative保留原文,decompiled改为「行号来自反编译结果,不对应任何仓内文件」,与:75-79横幅一致。
最小修法是把:106的三元条件从只看format改成同时看sourceKind。 - 验收条件(可执行、可断言):
- 后端无需改动,
tests/console/test_flow_source_api.py保持4 passed不变。 - 前端若引入 vitest:为
SourceViewPanel加一条测试——传sourceKind="decompiled"且source非空,
断言渲染文本不含「行号与画布节点 source_line 对应」;传sourceKind="authoritative"时含该句。 - 为降级分支加一条断言(可用 store 状态代替组件测试):mock
api.getFlowVersionSource返回
{source_kind: 'unavailable', source: null, reason: "emit_source 失败: 节点类型 'switch' 不可表达为 @flow 源码"},
断言该reason最终出现在可渲染状态中,而不是hasFlowSource保持false且无任何提示。 - 复现脚本的期望变化:上文 ② 的
GET switch仍应返回同样的reason(后端行为不变),
但前端要把它显示出来;③ 的4 passed保持不变。
- 后端无需改动,
边界
- 只修 console 前端对
/source降级结果的接线与文案一致性。不改flows.py的端点契约
(source_kind/reason已由后端测试钉住),不改emit_source的表达能力(switch与PARALLEL缺name
属它的已知边界),不动 YAML/JSON 页签的渲染与复制行为。 - 同批次草案 33 / 34 / 35 修的是
plaita/dsl/codeflow/_emit.py的 IR→@flow发射器保真
(== True判别、event 别名、end result 类型),属「反编译出来的源码本身不对」;本条修的是
新的降级链路对「反编译不出来」的处理(reason未接 + 同屏文案自相矛盾),位点在
plaita-console/frontend/,不触碰_emit.py,两者不应合并。 - 同批次草案 47 修
plaita/node/decide.py的 case 节点name缺失裸KeyError(节点构造期)。
本条实测到的PARALLEL分支KeyError: 'name'落点在plaita/dsl/codeflow/_emit.py:515
(branch_items.append(f"{branch['name']!r}: {child_name}")),与 47 不同文件、不同位点;
该KeyError在本条里只是触发unavailable的一种输入,不作为独立缺陷在本单修复。 - 与同批
new-console-source[1](/source的 decompiled 兜底从不校验等价性却对外宣称「语义等效」)相邻:
那条质疑「反编译结果是否真的等效」,本条质疑「反编译失败的结果被前端吞掉 + 同屏文案互相否定」;
缺陷与修法都不同(那条加 round-trip 校验,本条补unavailable分支 / 展示reason+ 按sourceKind分支文案)。
若维护者希望把「反编译兜底对用户的契约」收敛成一条,本条可作为那条的第二面并入。 - 与既有单的关系:
- 对
jeffkit/plaita全部既有单(含 CLOSED)逐条比对:无 console 源码面板 /source_kind/
decompiled/unavailable//source条目,本条不与任何既有单同根。 - #22 / #23 / #24 / #30 已 CLOSED:分别是 code 沙箱后端白名单未接线、start 消息无租约、
集群档凭据租户路由断裂、GATE/CAPTURE/AGENTRUN 子进程 env 白名单未推广,均在 nodes / worker / tenants 层,
与本条(console 前端源码视图)零代码行重叠。 - #50 / #52 仍 OPEN,均在 worker 的队列投递与事件回扫 / 终态链路,与本条不同根、不同文件;
它们的 OPEN 状态不构成本条的依赖。
- 对
- 不做的事:不顺手重构
SourceViewPanel的 YAML/JSON 渲染、不给unavailable增加新的后端字段、
不改getFlowVersionSource的返回类型形状、不为本单引入前端测试框架(仓库暂无 vitest 时,验收以代码路径
断言与手工步骤为准)。 - 未验证项:前端无 JS 测试框架,decompiled 下
:77-78与:111同屏为逐行代码推证,未做浏览器点击复现;
unavailable时hasFlowSource保持false、面板根本不出现(这正是问题的一面),故上述文案矛盾只出现在
source非空的 decompiled 路径。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 1h 55m
- Merged PRs (30d)
- 13
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.
More from jeffkit/plaita
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
Maintainers usually reply within 1 day
-
Difficulty 5/5 Over a week Newbie friendliness 45/100
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 56/100
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 43/100
jeffkit/plaita#345 · 1 comment ·
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
btclib-org/btclib-wallet#587 · 1 comment ·
Maintainers usually reply within 1 day
-
documentation
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Judgment-Pack/judgment-pack-spec#275 ·
Maintainers usually reply within 1 day
-
ai-generated
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
Maintainers usually reply within 1 day
-
documentation good first issue priority: low size: XS
Difficulty 1/5 1-3 hours Newbie friendliness 92/100
yeongseon/azure-functions-doctor-python#651 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
Changaco/python-libarchive-c#146 ·