@qingflow-tech/qingflow-cli 1.1.49 → 1.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +29 -13
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/skills/qingflow-cli/SKILL.md +91 -410
  6. package/skills/qingflow-cli/manifest.yaml +1 -1
  7. package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  16. package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +13 -5
  17. package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +5 -3
  18. package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +1 -1
  19. package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +1 -1
  20. package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1665 -1738
  21. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  22. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  23. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  27. package/skills/qingflow-cli/reference/data/README.md +32 -0
  28. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
  29. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  30. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  31. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  32. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  33. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  34. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  35. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  36. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  37. package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +48 -46
  38. package/skills/qingflow-common/SKILL.md +10 -17
  39. package/src/qingflow_mcp/__init__.py +1 -1
  40. package/src/qingflow_mcp/backend_client.py +142 -12
  41. package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
  42. package/src/qingflow_mcp/builder_facade/service.py +749 -6
  43. package/src/qingflow_mcp/builder_facade/workflow_spec.py +155 -3
  44. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  45. package/src/qingflow_mcp/cli/commands/auth.py +1 -1
  46. package/src/qingflow_mcp/cli/commands/builder.py +57 -41
  47. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  48. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  49. package/src/qingflow_mcp/cli/context.py +4 -1
  50. package/src/qingflow_mcp/cli/formatters.py +159 -4
  51. package/src/qingflow_mcp/cli/main.py +4 -1
  52. package/src/qingflow_mcp/config.py +60 -0
  53. package/src/qingflow_mcp/public_surface.py +9 -0
  54. package/src/qingflow_mcp/resource_uri.py +241 -0
  55. package/src/qingflow_mcp/response_trim.py +53 -5
  56. package/src/qingflow_mcp/server.py +2 -0
  57. package/src/qingflow_mcp/server_app_builder.py +57 -2
  58. package/src/qingflow_mcp/server_app_user.py +10 -3
  59. package/src/qingflow_mcp/tools/ai_builder_tools.py +374 -41
  60. package/src/qingflow_mcp/tools/app_tools.py +52 -13
  61. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  62. package/src/qingflow_mcp/tools/data_tools.py +284 -0
  63. package/src/qingflow_mcp/tools/login_link_tools.py +34 -6
  64. package/src/qingflow_mcp/tools/record_tools.py +11 -46
  65. package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: qingflow-common
3
- description: 解释轻流公共业务概念并选择 CLI 或 MCP 入口。用于能力比较、记录与任务操作、表单与流程搭建的任务分流,以及从 AI 对话进入页面查看结果、继续编辑、处理业务或进入工作区的场景。
3
+ description: Explain shared Qingflow concepts, choose the CLI or MCP surface, and route resource work or page-link delivery to the relevant domain Skill.
4
4
  ---
5
5
 
6
6
  # Qingflow Common Concepts
@@ -19,7 +19,8 @@ Do not require both surfaces. If only one is installed, complete the workflow on
19
19
  ## Shared Concepts
20
20
 
21
21
  - **AppForm** is the versioned declarative definition for an application form: app metadata, fields, sections, layout, settings, and field configuration. The same JSON declaration is used by CLI and Qingflow MCP.
22
- - **Record** is runtime data written to an existing app. Use the record schema before an insert, update, import, or analysis workflow.
22
+ - **Data** 是既有应用中的普通业务数据。查询、单条读取和直接增删改默认使用视图无关的 data 入口,并以当次 schema、字段能力和访问范围为准。
23
+ - **Record** 用于详情页附件/图片、完整日志、导入、代码块,以及用户明确指定的 record 命令;不要把普通应用数据工作路由到 record。
23
24
  - **Task** is a workflow work item. Prefer task operations for approval, transfer, rollback, and assignee actions.
24
25
  - **Builder resource** is package, app, view, chart, portal, navigation, or workflow configuration. It is separate from record data.
25
26
  - **Schema version** is part of the public contract. Pin it for one AppForm or workflow operation and do not combine values from different versions.
@@ -30,11 +31,13 @@ Do not require both surfaces. If only one is installed, complete the workflow on
30
31
  | --- | --- | --- |
31
32
  | Form schema/read/apply/delete | `qingflow --json builder app-form ...` / `builder app delete` | `app_form_schema`, `app_form_get`, `app_form_apply`, `app_delete` |
32
33
  | AppForm static validation | `qingflow --json builder app-form validate --file ...` | Performed inside `app_form_apply`; no separate tool |
33
- | Record and task work | `qingflow --json record ...` / `task ...` | Qingflow MCP record and task tools |
34
+ | 普通应用数据查询与直接增删改 | `qingflow --json data ...` | Qingflow MCP `data_query/get/insert/update/delete` |
35
+ | 日志、附件、导入、代码块或用户指定的 record 命令 | `qingflow --json record ...` / `import ...` | Qingflow MCP 对应 record/import 专项工具 |
36
+ | 待办与流程动作 | `qingflow --json task ...` | Qingflow MCP task tools |
34
37
  | 免登录打开网页或指定业务页面 | `qingflow --json auth login-link [--redirect-path PATH]` | `get_login_link` |
35
38
  | Package, view, portal, chart, workflow | `qingflow --json builder ...` | Qingflow MCP configuration tools |
36
39
 
37
- The operation names and payload schemas are defined by the public surface. Use the current Schema or tool input schema as the source of truth, not a legacy command name or backend payload.
40
+ The operation names and payload schemas are defined by the public surface. Use the current Schema or tool input schema as the source of truth, not another command's shape or a backend payload.
38
41
 
39
42
  ## Package Boundaries
40
43
 
@@ -43,20 +46,10 @@ The operation names and payload schemas are defined by the public surface. Use t
43
46
 
44
47
  Each package includes this common Skill. The CLI package installs its CLI guidance; the MCP package installs every MCP domain Skill. The source of every packaged Skill is `qingflow-support/mcp-server/skills/`.
45
48
 
46
- ## 从 AI 对话进入轻流页面
49
+ ## Deliver Qingflow Page Links
47
50
 
48
- 用户需要查看结果、继续编辑、处理业务或进入工作区页面时,MCP 调用 `get_login_link`,CLI 调用 `qingflow --json auth login-link`。纯数据查询无需页面,不自动生成链接。
49
-
50
- - 目标路径使用用户提供或业务工具确认的站内路径,不根据应用、记录标识猜测地址。
51
- - 用户明确指定页面但路径未知时继续定位或说明限制,不猜测,也不静默替换成首页。搜索未命中时继续搜索 `login` 或查看服务工具列表,不擅自打开固定正式站点;失败时如实说明。
52
-
53
- - 默认使用当前会话及已选工作区;需要指定会话时使用 MCP 的 `profile` 或 CLI 的 `--profile`。
54
- - `redirect_path` 可选,默认空:省略时进入工作区首页;传入时仅支持以单个 `/` 开头的站内业务路径,可带 query/hash。不得传完整 URL、外部地址、认证入口或多层编码路径。
55
- - 示例:`get_login_link({})`;指定目标示例:`get_login_link({"redirect_path":"/index/app/123?view=abc"})`。示例标识仅作格式展示,实际目标必须来自用户提供或已经确认的页面信息,不得编造应用、记录标识。
56
- - 临近交付时再生成,使用返回的 `url`,文案贴合“查看记录”“继续编辑”等上下文,不展示工作区数字 ID 或原始 JSON。
57
- - CLI 示例:`qingflow --json auth login-link`;指定目标使用 `qingflow --profile default --json auth login-link --redirect-path "/index/app/123?view=abc"`,同样不得编造实际路径。
58
- - 链接短时有效且仅可使用一次,失效后重新获取,不修改或拼造链接;只交付给请求用户,不写入公共文档或日志。
59
- - 用户要求“给我链接”时仅交付,不提前消费;要求“打开”时才使用可用浏览器打开。浏览器不可用时提供可点击链接,不假称已经打开。
51
+ - After creating, changing, or installing resources, finish with the result and a clickable entry for each requested primary result. Use confirmed stable `uri` values, deduplicate them, and link only confirmed successes. Do not auto-issue links for data-only answers, intermediate reads, failed or deleted resources, or when the user declines links.
52
+ - When browser login is unknown, issue one fresh link per displayed destination near delivery through the installed surface. Never guess, replace, open, or consume a destination unless authorized. If issuance fails, keep the business result and offer the stable URI as requiring login when available. Follow the CLI Skill or MCP tool description for signing details.
60
53
 
61
54
  ## Route Next
62
55
 
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b1099"
8
+ _FALLBACK_VERSION = "0.2.0b1101"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+ import os
2
3
 
3
4
  from dataclasses import dataclass
4
5
  from threading import Event
@@ -9,7 +10,14 @@ from uuid import uuid4
9
10
 
10
11
  import httpx
11
12
 
12
- from .config import DEFAULT_USER_AGENT, get_default_qf_version, get_timeout_seconds, normalize_base_url
13
+ from .config import (
14
+ DEFAULT_USER_AGENT,
15
+ get_default_qf_version,
16
+ get_http_retry_attempts,
17
+ get_http_retry_base_seconds,
18
+ get_timeout_seconds,
19
+ normalize_base_url,
20
+ )
13
21
  from .errors import QingflowApiError
14
22
  from .json_types import JSONObject, JSONScalar, JSONValue
15
23
 
@@ -65,6 +73,7 @@ class BackendClient:
65
73
  params: JSONObject | None = None,
66
74
  json_body: JSONValue = None,
67
75
  unwrap: bool = True,
76
+ unwrap_inner_envelope: bool = True,
68
77
  qf_version: str | None = None,
69
78
  ) -> JSONValue:
70
79
  return self.public_request_with_meta(
@@ -74,6 +83,7 @@ class BackendClient:
74
83
  params=params,
75
84
  json_body=json_body,
76
85
  unwrap=unwrap,
86
+ unwrap_inner_envelope=unwrap_inner_envelope,
77
87
  qf_version=qf_version,
78
88
  ).data
79
89
 
@@ -86,6 +96,7 @@ class BackendClient:
86
96
  params: JSONObject | None = None,
87
97
  json_body: JSONValue = None,
88
98
  unwrap: bool = True,
99
+ unwrap_inner_envelope: bool = True,
89
100
  qf_version: str | None = None,
90
101
  ) -> BackendResponse:
91
102
  return self._request_with_meta(
@@ -95,6 +106,7 @@ class BackendClient:
95
106
  json_body=json_body,
96
107
  headers=self._base_headers(None, None, qf_version=qf_version),
97
108
  unwrap=unwrap,
109
+ unwrap_inner_envelope=unwrap_inner_envelope,
98
110
  )
99
111
 
100
112
  def public_request_with_headers(
@@ -106,6 +118,7 @@ class BackendClient:
106
118
  params: JSONObject | None = None,
107
119
  json_body: JSONValue = None,
108
120
  unwrap: bool = True,
121
+ unwrap_inner_envelope: bool = True,
109
122
  qf_version: str | None = None,
110
123
  headers: dict[str, str] | None = None,
111
124
  ) -> BackendResponse:
@@ -119,6 +132,7 @@ class BackendClient:
119
132
  json_body=json_body,
120
133
  headers=request_headers,
121
134
  unwrap=unwrap,
135
+ unwrap_inner_envelope=unwrap_inner_envelope,
122
136
  )
123
137
 
124
138
  def request(
@@ -130,6 +144,7 @@ class BackendClient:
130
144
  params: JSONObject | None = None,
131
145
  json_body: JSONValue = None,
132
146
  unwrap: bool = True,
147
+ unwrap_inner_envelope: bool = True,
133
148
  ) -> JSONValue:
134
149
  return self.request_with_meta(
135
150
  method,
@@ -138,6 +153,7 @@ class BackendClient:
138
153
  params=params,
139
154
  json_body=json_body,
140
155
  unwrap=unwrap,
156
+ unwrap_inner_envelope=unwrap_inner_envelope,
141
157
  ).data
142
158
 
143
159
  def request_with_meta(
@@ -149,6 +165,7 @@ class BackendClient:
149
165
  params: JSONObject | None = None,
150
166
  json_body: JSONValue = None,
151
167
  unwrap: bool = True,
168
+ unwrap_inner_envelope: bool = True,
152
169
  ) -> BackendResponse:
153
170
  return self._request_with_meta(
154
171
  method,
@@ -162,6 +179,7 @@ class BackendClient:
162
179
  qf_version=context.qf_version,
163
180
  ),
164
181
  unwrap=unwrap,
182
+ unwrap_inner_envelope=unwrap_inner_envelope,
165
183
  )
166
184
 
167
185
  def stream_request(
@@ -353,6 +371,7 @@ class BackendClient:
353
371
  data: dict[str, Any] | None = None,
354
372
  files: dict[str, tuple[str, bytes, str | None]] | None = None,
355
373
  unwrap: bool = True,
374
+ unwrap_inner_envelope: bool = True,
356
375
  ) -> JSONValue:
357
376
  return self.request_multipart_with_meta(
358
377
  method,
@@ -361,6 +380,7 @@ class BackendClient:
361
380
  data=data,
362
381
  files=files,
363
382
  unwrap=unwrap,
383
+ unwrap_inner_envelope=unwrap_inner_envelope,
364
384
  ).data
365
385
 
366
386
  def request_multipart_with_meta(
@@ -372,6 +392,7 @@ class BackendClient:
372
392
  data: dict[str, Any] | None = None,
373
393
  files: dict[str, tuple[str, bytes, str | None]] | None = None,
374
394
  unwrap: bool = True,
395
+ unwrap_inner_envelope: bool = True,
375
396
  ) -> BackendResponse:
376
397
  headers = self._base_headers(
377
398
  context.token,
@@ -392,7 +413,12 @@ class BackendClient:
392
413
  )
393
414
  except httpx.RequestError as exc:
394
415
  raise QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
395
- parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
416
+ parsed = self._parse_response(
417
+ response,
418
+ headers["Qf-Request-Id"],
419
+ unwrap=unwrap,
420
+ unwrap_inner_envelope=unwrap_inner_envelope,
421
+ )
396
422
  return BackendResponse(
397
423
  data=parsed,
398
424
  headers=dict(response.headers),
@@ -646,13 +672,26 @@ class BackendClient:
646
672
  json_body: JSONValue,
647
673
  headers: dict[str, str],
648
674
  unwrap: bool,
675
+ unwrap_inner_envelope: bool = True,
649
676
  ) -> BackendResponse:
650
- attempts = 2 if method.upper() == "GET" else 1
677
+ # 评测沉淀的传输层重试回流(eval_replay_flow._http_json 同款):
678
+ # 3 次尝试 + 线性退避(base × attempt,即 3s/6s),env 可配。
679
+ # GET/HEAD 任何网络错误可重试;写方法(POST/PUT/PATCH/DELETE)仅
680
+ # ConnectError/ConnectTimeout(请求确证未发出)可重试,避免双重写。
681
+ # HTTP 状态码/业务码错误由 _parse_response 抛出,不属网络错误,不重试。
682
+ normalized_method = method.upper()
683
+ attempts = get_http_retry_attempts()
684
+ retry_base_seconds = get_http_retry_base_seconds()
651
685
  last_error: QingflowApiError | None = None
652
- for _ in range(attempts):
686
+ for attempt in range(1, attempts + 1):
653
687
  try:
654
- response = self._client.request(method.upper(), url, params=params, json=json_body, headers=headers)
655
- parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
688
+ response = self._client.request(normalized_method, url, params=params, json=json_body, headers=headers)
689
+ parsed = self._parse_response(
690
+ response,
691
+ headers["Qf-Request-Id"],
692
+ unwrap=unwrap,
693
+ unwrap_inner_envelope=unwrap_inner_envelope,
694
+ )
656
695
  return BackendResponse(
657
696
  data=parsed,
658
697
  headers=dict(response.headers),
@@ -661,7 +700,18 @@ class BackendClient:
661
700
  qf_response_version=self._extract_response_qf_version(response.headers),
662
701
  )
663
702
  except httpx.RequestError as exc:
664
- last_error = QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
703
+ retryable = normalized_method in {"GET", "HEAD"} or isinstance(
704
+ exc, (httpx.ConnectError, httpx.ConnectTimeout)
705
+ )
706
+ last_error = QingflowApiError(
707
+ category="network",
708
+ message=str(exc),
709
+ request_id=headers["Qf-Request-Id"],
710
+ details={"retry_attempts": attempt},
711
+ )
712
+ if not retryable or attempt >= attempts:
713
+ break
714
+ sleep(retry_base_seconds * attempt)
665
715
  assert last_error is not None
666
716
  raise last_error
667
717
 
@@ -695,7 +745,14 @@ class BackendClient:
695
745
  except httpx.RequestError as exc:
696
746
  raise QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
697
747
 
698
- def _parse_response(self, response: httpx.Response, request_id: str, *, unwrap: bool) -> JSONValue:
748
+ def _parse_response(
749
+ self,
750
+ response: httpx.Response,
751
+ request_id: str,
752
+ *,
753
+ unwrap: bool,
754
+ unwrap_inner_envelope: bool = True,
755
+ ) -> JSONValue:
699
756
  payload: JSONValue
700
757
  try:
701
758
  payload = response.json()
@@ -708,12 +765,25 @@ class BackendClient:
708
765
  backend_code=self._extract_code(payload),
709
766
  request_id=request_id,
710
767
  http_status=response.status_code,
768
+ details=self._extract_error_details(payload),
711
769
  )
712
770
  if not unwrap:
713
771
  return payload
714
- return self._unwrap_payload(payload, request_id, response.status_code)
772
+ return self._unwrap_payload(
773
+ payload,
774
+ request_id,
775
+ response.status_code,
776
+ unwrap_inner_envelope=unwrap_inner_envelope,
777
+ )
715
778
 
716
- def _unwrap_payload(self, payload: JSONValue, request_id: str, http_status: int) -> JSONValue:
779
+ def _unwrap_payload(
780
+ self,
781
+ payload: JSONValue,
782
+ request_id: str,
783
+ http_status: int,
784
+ *,
785
+ unwrap_inner_envelope: bool = True,
786
+ ) -> JSONValue:
717
787
  if not isinstance(payload, dict):
718
788
  return payload
719
789
  if "success" in payload:
@@ -725,7 +795,12 @@ class BackendClient:
725
795
  request_id=request_id,
726
796
  http_status=http_status,
727
797
  )
728
- return self._extract_success_data(payload)
798
+ return self._guard_inner_envelope(
799
+ self._extract_success_data(payload),
800
+ request_id,
801
+ http_status,
802
+ unwrap_inner_envelope=unwrap_inner_envelope,
803
+ )
729
804
  code = self._extract_code(payload)
730
805
  if code not in (None, 0, "0"):
731
806
  raise QingflowApiError(
@@ -736,9 +811,44 @@ class BackendClient:
736
811
  http_status=http_status,
737
812
  )
738
813
  if code in (0, "0"):
739
- return self._extract_success_data(payload)
814
+ return self._guard_inner_envelope(
815
+ self._extract_success_data(payload),
816
+ request_id,
817
+ http_status,
818
+ unwrap_inner_envelope=unwrap_inner_envelope,
819
+ )
740
820
  return payload
741
821
 
822
+ def _guard_inner_envelope(
823
+ self,
824
+ extracted: JSONValue,
825
+ request_id: str,
826
+ http_status: int,
827
+ *,
828
+ unwrap_inner_envelope: bool,
829
+ ) -> JSONValue:
830
+ """双层信封防御:后端错误经 ResponseResult.failure(code, message, body) 转为
831
+ ``HTTP 200 + 外层 code=0 + data={code, message}`` 形态(内层错误对象被塞进 data)。
832
+
833
+ 外层信封检查通过后,若提取出的业务 payload 本身携带错误 code,则视为业务失败
834
+ 抛 QingflowApiError,而不是把错误对象当“业务数据”上抛(评测 r2 实测 219 个读面
835
+ gap 全为此形态)。逐调用方可传 ``unwrap_inner_envelope=False`` 豁免(当某 API 的
836
+ data.code 是合法业务字段时)。
837
+ """
838
+ if not unwrap_inner_envelope or not isinstance(extracted, dict):
839
+ return extracted
840
+ inner_code = self._extract_code(extracted)
841
+ if inner_code in (None, 0, "0"):
842
+ return extracted
843
+ raise QingflowApiError(
844
+ category="backend",
845
+ message=self._extract_message(extracted) or "Qingflow request failed (inner envelope)",
846
+ backend_code=inner_code,
847
+ request_id=request_id,
848
+ http_status=http_status,
849
+ details={"envelope": "inner"},
850
+ )
851
+
742
852
  def _extract_success_data(self, payload: JSONObject) -> JSONValue:
743
853
  for key in ("result", "data", "page", "obj"):
744
854
  if key in payload:
@@ -759,6 +869,23 @@ class BackendClient:
759
869
  return payload.get("code", payload.get("errCode"))
760
870
  return None
761
871
 
872
+ def _extract_error_details(self, payload: JSONValue) -> JSONObject | None:
873
+ """Best-effort extraction of the backend structured error body.
874
+
875
+ Qingflow ``ResponseResult.failure(code, message, data)`` serializes the structured error
876
+ into ``data`` (older/other endpoints may use ``result``), e.g. workflow-spec validation
877
+ bodies with ``code`` / ``path`` / ``suggestion`` and negotiation payloads like
878
+ ``currentSpec``. Keeping it on the error lets facades surface backend negotiation
879
+ contexts instead of swallowing them.
880
+ """
881
+ if not isinstance(payload, dict):
882
+ return None
883
+ for key in ("result", "data"):
884
+ body = payload.get(key)
885
+ if isinstance(body, dict):
886
+ return dict(body)
887
+ return None
888
+
762
889
  def _base_headers(
763
890
  self,
764
891
  token: str | None,
@@ -778,6 +905,9 @@ class BackendClient:
778
905
  headers["token"] = token
779
906
  if ws_id is not None:
780
907
  headers["wsId"] = str(ws_id)
908
+ # #147/r8: benchmark replay mode flag——backend skips authored topology overrides
909
+ if os.environ.get("EVAL_PRESERVE_TOPOLOGY", "").lower() in ("1", "true", "yes"):
910
+ headers["X-Preserve-Topology"] = "true"
781
911
  return headers
782
912
 
783
913
  def _resolve_qf_version(self, explicit_qf_version: str | None) -> tuple[str | None, str]: