@qingflow-tech/qingflow-app-user-mcp 1.0.39 → 1.0.41

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 (28) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-app-builder/SKILL.md +18 -7
  5. package/skills/qingflow-app-builder/references/complete-system-development-guide.md +59 -0
  6. package/skills/qingflow-app-builder/references/create-app.md +13 -7
  7. package/skills/qingflow-app-builder/references/gotchas.md +6 -0
  8. package/skills/qingflow-app-builder/references/single-app-development-guide.md +47 -0
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +10 -0
  10. package/skills/qingflow-app-builder/references/tool-selection.md +2 -2
  11. package/skills/qingflow-app-builder-code-integrations/SKILL.md +2 -0
  12. package/skills/qingflow-app-user/SKILL.md +2 -0
  13. package/skills/qingflow-mcp-setup/SKILL.md +2 -0
  14. package/skills/qingflow-record-analysis/SKILL.md +3 -1
  15. package/skills/qingflow-record-delete/SKILL.md +2 -0
  16. package/skills/qingflow-record-import/SKILL.md +29 -0
  17. package/skills/qingflow-record-insert/SKILL.md +24 -1
  18. package/skills/qingflow-record-update/SKILL.md +3 -0
  19. package/skills/qingflow-task-ops/SKILL.md +2 -0
  20. package/src/qingflow_mcp/builder_facade/models.py +183 -0
  21. package/src/qingflow_mcp/builder_facade/service.py +722 -74
  22. package/src/qingflow_mcp/cli/commands/builder.py +62 -2
  23. package/src/qingflow_mcp/cli/commands/common.py +12 -3
  24. package/src/qingflow_mcp/cli/formatters.py +1 -0
  25. package/src/qingflow_mcp/cli/main.py +2 -0
  26. package/src/qingflow_mcp/response_trim.py +1 -0
  27. package/src/qingflow_mcp/tools/ai_builder_tools.py +515 -22
  28. package/src/qingflow_mcp/tools/record_tools.py +28 -2
@@ -22,6 +22,7 @@ from ..builder_facade.models import (
22
22
  ChartApplyRequest,
23
23
  CustomButtonsApplyRequest,
24
24
  CustomButtonPatch,
25
+ FIELD_TYPE_ALIASES,
25
26
  FIELD_TYPE_ID_ALIASES,
26
27
  FieldPatch,
27
28
  FieldRemovePatch,
@@ -383,7 +384,7 @@ class AiBuilderTools(ToolBase):
383
384
  remove_fields: list[JSONObject] | None = None,
384
385
  apps: list[JSONObject] | None = None,
385
386
  ) -> JSONObject:
386
- if apps:
387
+ if apps is not None:
387
388
  if app_key or app_name or app_title or add_fields or update_fields or remove_fields:
388
389
  return _config_failure(
389
390
  tool_name="app_schema_apply",
@@ -1718,8 +1719,18 @@ class AiBuilderTools(ToolBase):
1718
1719
  icon_color=icon_color,
1719
1720
  icon_config=icon_config,
1720
1721
  )
1721
- if apps:
1722
- apps = [_normalize_schema_app_item(item) if isinstance(item, dict) else item for item in apps]
1722
+ if apps is not None:
1723
+ normalized_apps_payload = _normalize_schema_apps_argument(
1724
+ tool_name="app_schema_apply",
1725
+ package_id=package_id,
1726
+ apps=apps,
1727
+ )
1728
+ failure = normalized_apps_payload.get("failure")
1729
+ if isinstance(failure, dict):
1730
+ return _attach_builder_apply_envelope("app_schema_apply", failure)
1731
+ package_id = normalized_apps_payload.get("package_id") # type: ignore[assignment]
1732
+ apps = normalized_apps_payload.get("apps") # type: ignore[assignment]
1733
+ input_warnings = list(normalized_apps_payload.get("warnings") or [])
1723
1734
  result = self._app_schema_apply_multi(
1724
1735
  profile=profile,
1725
1736
  package_id=package_id,
@@ -1728,6 +1739,10 @@ class AiBuilderTools(ToolBase):
1728
1739
  publish=publish,
1729
1740
  apps=apps,
1730
1741
  )
1742
+ if input_warnings:
1743
+ result_warnings = list(result.get("warnings") or [])
1744
+ result_warnings.extend(deepcopy(input_warnings))
1745
+ result["warnings"] = result_warnings
1731
1746
  return _attach_builder_apply_envelope("app_schema_apply", result)
1732
1747
  result = self._app_schema_apply_once(
1733
1748
  profile=profile,
@@ -1803,9 +1818,20 @@ class AiBuilderTools(ToolBase):
1803
1818
  if not apps:
1804
1819
  return _config_failure(
1805
1820
  tool_name="app_schema_apply",
1821
+ error_code="APPS_FILE_EMPTY",
1806
1822
  message="app_schema_apply multi-app mode requires non-empty apps.",
1807
1823
  fix_hint="Pass apps as a non-empty list of app schema items.",
1808
1824
  )
1825
+ shape_failure = _schema_apps_shape_failure(tool_name="app_schema_apply", apps=apps)
1826
+ if shape_failure is not None:
1827
+ return shape_failure
1828
+ static_validation_failure = _multi_app_static_validation_failure(
1829
+ tool_name="app_schema_apply",
1830
+ apps=apps,
1831
+ create_if_missing=create_if_missing,
1832
+ )
1833
+ if static_validation_failure is not None:
1834
+ return static_validation_failure
1809
1835
  icon_errors: list[JSONObject] = []
1810
1836
  seen_new_app_icons: dict[str, int] = {}
1811
1837
  for index, raw_item in enumerate(apps):
@@ -1874,6 +1900,9 @@ class AiBuilderTools(ToolBase):
1874
1900
  created_app_keys: list[str] = []
1875
1901
  results: list[JSONObject] = []
1876
1902
  any_write_executed = False
1903
+ any_write_may_have_succeeded = False
1904
+ pending_readback_app_keys: list[str] = []
1905
+ pending_readback_app_names: list[str] = []
1877
1906
  client_keys: set[str] = set()
1878
1907
 
1879
1908
  for index, raw_item in enumerate(apps):
@@ -1913,25 +1942,47 @@ class AiBuilderTools(ToolBase):
1913
1942
  public_shell = _publicize_package_fields(shell)
1914
1943
  resolved_key = str(public_shell.get("app_key") or "").strip()
1915
1944
  shell_write_executed = _schema_apply_result_has_write(public_shell)
1945
+ shell_write_may_have_succeeded = _schema_apply_result_may_have_write(public_shell)
1916
1946
  if shell_write_executed:
1917
1947
  any_write_executed = True
1948
+ if shell_write_may_have_succeeded:
1949
+ any_write_may_have_succeeded = True
1918
1950
  if public_shell.get("status") not in {"success", "partial_success"} or not resolved_key:
1951
+ pending_readback = bool(shell_write_may_have_succeeded or shell_write_executed) and (
1952
+ str(public_shell.get("next_action") or "") == "readback_before_retry"
1953
+ or bool((public_shell.get("verification") if isinstance(public_shell.get("verification"), dict) else {}).get("readback_before_retry"))
1954
+ or not resolved_key
1955
+ )
1956
+ item_status = "pending_readback" if pending_readback else "failed"
1957
+ if pending_readback:
1958
+ if resolved_key:
1959
+ pending_readback_app_keys.append(resolved_key)
1960
+ if app_name:
1961
+ pending_readback_app_names.append(app_name)
1919
1962
  results.append({
1920
1963
  "index": index,
1921
1964
  "row_number": index + 1,
1922
1965
  "client_key": client_key or None,
1923
1966
  "app_name": app_name or None,
1924
1967
  "app_key": resolved_key or app_key or None,
1925
- "status": "failed",
1968
+ "status": item_status,
1926
1969
  "stage": "resolve_or_create_shell",
1927
1970
  "error_code": public_shell.get("error_code") or "APP_SHELL_APPLY_FAILED",
1928
1971
  "message": public_shell.get("message") or "app shell resolve/create failed",
1929
1972
  "write_executed": shell_write_executed,
1930
- "safe_to_retry": not shell_write_executed and not any_write_executed,
1973
+ "write_may_have_succeeded": bool(shell_write_may_have_succeeded or shell_write_executed),
1974
+ "safe_to_retry": False if pending_readback else (not shell_write_executed and not any_write_executed),
1975
+ **({"next_action": "readback_before_retry"} if pending_readback else {}),
1976
+ **({"verification": public_shell.get("verification")} if isinstance(public_shell.get("verification"), dict) else {}),
1977
+ **({"created": True} if pending_readback and create_if_missing and not app_key else {}),
1931
1978
  })
1932
1979
  continue
1933
1980
  if bool(public_shell.get("created")):
1934
1981
  created_app_keys.append(resolved_key)
1982
+ if shell_write_may_have_succeeded and str(public_shell.get("next_action") or "") == "readback_before_retry":
1983
+ pending_readback_app_keys.append(resolved_key)
1984
+ if app_name:
1985
+ pending_readback_app_names.append(app_name)
1935
1986
  if client_key:
1936
1987
  client_key_to_app_key[client_key] = resolved_key
1937
1988
  resolved_name = str(public_shell.get("app_name_after") or public_shell.get("app_name") or app_name or "").strip()
@@ -1998,7 +2049,11 @@ class AiBuilderTools(ToolBase):
1998
2049
  "verified": bool(shell_result.get("verified")),
1999
2050
  "error_code": shell_result.get("error_code"),
2000
2051
  "message": shell_result.get("message"),
2052
+ "write_executed": _schema_apply_result_has_write(shell_result),
2053
+ "write_may_have_succeeded": _schema_apply_result_may_have_write(shell_result),
2001
2054
  "safe_to_retry": False,
2055
+ **({"next_action": shell_result.get("next_action")} if shell_result.get("next_action") else {}),
2056
+ **({"verification": shell_result.get("verification")} if isinstance(shell_result.get("verification"), dict) else {}),
2002
2057
  })
2003
2058
  continue
2004
2059
 
@@ -2020,11 +2075,18 @@ class AiBuilderTools(ToolBase):
2020
2075
  public_result = _publicize_package_fields(field_result)
2021
2076
  if _schema_apply_result_has_write(public_result):
2022
2077
  any_write_executed = True
2078
+ if _schema_apply_result_may_have_write(public_result):
2079
+ any_write_may_have_succeeded = True
2023
2080
  item_status = public_result.get("status") if public_result.get("status") in {"success", "partial_success"} else "failed"
2024
2081
  shell_field_diff = existing.get("shell_field_diff") if isinstance(existing.get("shell_field_diff"), dict) else {}
2025
2082
  shell_field_diff_details = existing.get("shell_field_diff_details") if isinstance(existing.get("shell_field_diff_details"), dict) else {}
2026
2083
  field_diff = _merge_schema_field_diffs(shell_field_diff, public_result.get("field_diff") or {})
2027
2084
  field_diff_details = _merge_schema_field_diffs(shell_field_diff_details, public_result.get("field_diff_details") or {})
2085
+ if _schema_apply_result_may_have_write(public_result) and str(public_result.get("next_action") or "") == "readback_before_retry":
2086
+ if app_key:
2087
+ pending_readback_app_keys.append(app_key)
2088
+ if existing.get("app_name"):
2089
+ pending_readback_app_names.append(str(existing.get("app_name")))
2028
2090
  final_items.append({
2029
2091
  **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
2030
2092
  "status": item_status,
@@ -2037,21 +2099,33 @@ class AiBuilderTools(ToolBase):
2037
2099
  "verified": bool(public_result.get("verified")),
2038
2100
  "error_code": public_result.get("error_code"),
2039
2101
  "message": public_result.get("message"),
2102
+ "write_executed": _schema_apply_result_has_write(public_result),
2103
+ "write_may_have_succeeded": _schema_apply_result_may_have_write(public_result),
2040
2104
  "safe_to_retry": False,
2105
+ **({"next_action": public_result.get("next_action")} if public_result.get("next_action") else {}),
2106
+ **({"verification": public_result.get("verification")} if isinstance(public_result.get("verification"), dict) else {}),
2041
2107
  })
2042
2108
 
2109
+ pending_readback = sum(1 for item in final_items if item.get("status") == "pending_readback")
2043
2110
  succeeded = sum(1 for item in final_items if item.get("status") in {"success", "partial_success"})
2044
- failed = len(final_items) - succeeded
2045
- overall_status = "success" if failed == 0 else ("partial_success" if succeeded > 0 or any_write_executed else "failed")
2046
- return {
2111
+ failed = sum(1 for item in final_items if item.get("status") == "failed")
2112
+ partial = sum(1 for item in final_items if item.get("status") == "partial_success")
2113
+ overall_status = (
2114
+ "success"
2115
+ if failed == 0 and pending_readback == 0 and partial == 0
2116
+ else ("partial_success" if succeeded > 0 or pending_readback > 0 or any_write_executed or any_write_may_have_succeeded else "failed")
2117
+ )
2118
+ uncertain_write = any_write_may_have_succeeded or pending_readback > 0
2119
+ response: JSONObject = {
2047
2120
  "status": overall_status,
2048
2121
  "mode": "multi_app",
2049
2122
  "total": len(apps),
2050
2123
  "succeeded": succeeded,
2051
2124
  "failed": failed,
2125
+ "pending_readback": pending_readback,
2052
2126
  "created_app_keys": created_app_keys,
2053
2127
  "write_executed": any_write_executed,
2054
- "safe_to_retry": not any_write_executed,
2128
+ "safe_to_retry": not (any_write_executed or uncertain_write),
2055
2129
  "package_id": package_id,
2056
2130
  "publish_requested": publish,
2057
2131
  "apps": final_items,
@@ -2059,12 +2133,23 @@ class AiBuilderTools(ToolBase):
2059
2133
  "verification": {
2060
2134
  "all_apps_succeeded": failed == 0,
2061
2135
  "created_app_count": len(created_app_keys),
2136
+ "pending_readback_count": pending_readback,
2062
2137
  },
2063
2138
  "request_id": None,
2064
2139
  "error_code": None if overall_status != "failed" else "MULTI_APP_SCHEMA_APPLY_FAILED",
2065
2140
  "recoverable": overall_status != "success",
2066
- "message": "multi-app schema apply completed" if overall_status != "failed" else "multi-app schema apply failed",
2141
+ "message": (
2142
+ "multi-app schema apply needs readback before retry"
2143
+ if pending_readback > 0
2144
+ else ("multi-app schema apply completed" if overall_status != "failed" else "multi-app schema apply failed")
2145
+ ),
2067
2146
  }
2147
+ if uncertain_write:
2148
+ response["write_may_have_succeeded"] = True
2149
+ response["next_action"] = "readback_before_retry"
2150
+ response["pending_readback_app_keys"] = list(dict.fromkeys(pending_readback_app_keys))
2151
+ response["pending_readback_app_names"] = list(dict.fromkeys(pending_readback_app_names))
2152
+ return response
2068
2153
 
2069
2154
  def _app_schema_apply_once(
2070
2155
  self,
@@ -2594,12 +2679,12 @@ class AiBuilderTools(ToolBase):
2594
2679
  suggested_next_call={
2595
2680
  "tool_name": "app_charts_apply",
2596
2681
  "arguments": {
2597
- "profile": profile,
2598
- "app_key": app_key,
2599
- "upsert_charts": [{"name": "销售总量", "chart_type": "target", "indicator_field_ids": []}],
2600
- "remove_chart_ids": [],
2601
- "reorder_chart_ids": [],
2602
- },
2682
+ "profile": profile,
2683
+ "app_key": app_key,
2684
+ "upsert_charts": [{"name": "销售总量", "chart_type": "target", "metric": "count(*)"}],
2685
+ "remove_chart_ids": [],
2686
+ "reorder_chart_ids": [],
2687
+ },
2603
2688
  },
2604
2689
  ))
2605
2690
  normalized_args = request.model_dump(mode="json")
@@ -2932,6 +3017,365 @@ def _normalize_schema_app_item(item: JSONObject) -> JSONObject:
2932
3017
  return normalized
2933
3018
 
2934
3019
 
3020
+ def _schema_apps_expected_shape() -> JSONObject:
3021
+ return {
3022
+ "package_id": 1001,
3023
+ "apps": [
3024
+ {
3025
+ "client_key": "employee",
3026
+ "app_name": "员工花名册",
3027
+ "icon": "business-personalcard",
3028
+ "color": "emerald",
3029
+ "add_fields": [{"name": "员工名称", "type": "text", "as_data_title": True}],
3030
+ }
3031
+ ],
3032
+ }
3033
+
3034
+
3035
+ def _schema_apps_expected_shape_json() -> str:
3036
+ return (
3037
+ '{"package_id":1001,"apps":[{"client_key":"employee","app_name":"员工花名册",'
3038
+ '"icon":"business-personalcard","color":"emerald","add_fields":[{"name":"员工名称","type":"text","as_data_title":true}]}]}'
3039
+ )
3040
+
3041
+
3042
+ def _is_schema_apps_wrapper_item(item: object) -> bool:
3043
+ return isinstance(item, dict) and "apps" in item
3044
+
3045
+
3046
+ def _schema_apps_shape_failure(*, tool_name: str, apps: list[JSONObject]) -> JSONObject | None:
3047
+ for index, item in enumerate(apps):
3048
+ if _is_schema_apps_wrapper_item(item):
3049
+ return _config_failure(
3050
+ tool_name=tool_name,
3051
+ error_code="APPS_FILE_SHAPE_INVALID",
3052
+ message="apps[] items must be app schema items, not package wrapper objects.",
3053
+ fix_hint=(
3054
+ "For MCP pass package_id separately and apps=[{app_name, icon, color, add_fields}]. "
3055
+ 'For CLI use --apps-file with {"package_id":1001,"apps":[...]}. '
3056
+ "Do not pass multiple {package_id, apps} wrapper objects inside apps[]."
3057
+ ),
3058
+ details={
3059
+ "index": index,
3060
+ "row_number": index + 1,
3061
+ "expected_shape": _schema_apps_expected_shape(),
3062
+ "expected_shape_json": _schema_apps_expected_shape_json(),
3063
+ },
3064
+ )
3065
+ return None
3066
+
3067
+
3068
+ def _normalize_schema_apps_argument(*, tool_name: str, package_id: int | None, apps: list[JSONObject]) -> JSONObject:
3069
+ normalized_package_id = package_id
3070
+ normalized_apps: list[JSONObject] = apps
3071
+ warnings: list[JSONObject] = []
3072
+
3073
+ if len(apps) == 1 and _is_schema_apps_wrapper_item(apps[0]):
3074
+ wrapper = apps[0]
3075
+ wrapper_apps = wrapper.get("apps")
3076
+ if not isinstance(wrapper_apps, list):
3077
+ return {
3078
+ "failure": _config_failure(
3079
+ tool_name=tool_name,
3080
+ error_code="APPS_FILE_SHAPE_INVALID",
3081
+ message="apps singleton wrapper requires an apps array.",
3082
+ fix_hint='Use {"package_id":1001,"apps":[...]} in CLI, or pass MCP package_id=1001 and apps=[...].',
3083
+ details={
3084
+ "expected_shape": _schema_apps_expected_shape(),
3085
+ "expected_shape_json": _schema_apps_expected_shape_json(),
3086
+ },
3087
+ )
3088
+ }
3089
+ if normalized_package_id is None and wrapper.get("package_id") is not None:
3090
+ try:
3091
+ normalized_package_id = int(wrapper.get("package_id"))
3092
+ except (TypeError, ValueError):
3093
+ return {
3094
+ "failure": _config_failure(
3095
+ tool_name=tool_name,
3096
+ error_code="APPS_FILE_SHAPE_INVALID",
3097
+ message="apps singleton wrapper package_id must be an integer.",
3098
+ fix_hint="Pass package_id as a number at the top level.",
3099
+ details={
3100
+ "expected_shape": _schema_apps_expected_shape(),
3101
+ "expected_shape_json": _schema_apps_expected_shape_json(),
3102
+ },
3103
+ )
3104
+ }
3105
+ normalized_apps = wrapper_apps
3106
+ warnings.append(
3107
+ {
3108
+ "code": "APPS_FILE_WRAPPER_ARRAY_UNWRAPPED",
3109
+ "message": "apps was a singleton wrapper array; normalized it to package_id + apps.",
3110
+ }
3111
+ )
3112
+ else:
3113
+ failure = _schema_apps_shape_failure(tool_name=tool_name, apps=apps)
3114
+ if failure is not None:
3115
+ return {"failure": failure}
3116
+
3117
+ normalized_items: list[JSONObject] = []
3118
+ for item in normalized_apps:
3119
+ normalized_items.append(_normalize_schema_app_item(item) if isinstance(item, dict) else item)
3120
+ return {"package_id": normalized_package_id, "apps": normalized_items, "warnings": warnings}
3121
+
3122
+
3123
+ def _multi_app_item_app_name(item: JSONObject) -> str:
3124
+ return str(item.get("app_name") or item.get("appName") or item.get("appTitle") or item.get("app_title") or item.get("title") or "").strip()
3125
+
3126
+
3127
+ def _multi_app_item_app_key(item: JSONObject) -> str:
3128
+ return str(item.get("app_key") or item.get("appKey") or "").strip()
3129
+
3130
+
3131
+ def _multi_app_item_client_key(item: JSONObject) -> str:
3132
+ return str(item.get("client_key") or item.get("clientKey") or "").strip()
3133
+
3134
+
3135
+ def _field_name_for_static_validation(field: JSONObject) -> str:
3136
+ return str(field.get("name") or field.get("title") or field.get("label") or "").strip()
3137
+
3138
+
3139
+ def _field_type_for_static_validation(field: JSONObject) -> str:
3140
+ return str(field.get("type") or field.get("type_id") or field.get("typeId") or "").strip()
3141
+
3142
+
3143
+ def _is_data_title_field(field: JSONObject) -> bool:
3144
+ return bool(field.get("as_data_title") or field.get("asDataTitle"))
3145
+
3146
+
3147
+ def _collect_multi_app_target_refs(value: object, *, path: str) -> list[JSONObject]:
3148
+ refs: list[JSONObject] = []
3149
+ if isinstance(value, list):
3150
+ for index, entry in enumerate(value):
3151
+ refs.extend(_collect_multi_app_target_refs(entry, path=f"{path}[{index}]"))
3152
+ return refs
3153
+ if not isinstance(value, dict):
3154
+ return refs
3155
+ for key, entry in value.items():
3156
+ current_path = f"{path}.{key}"
3157
+ if key in {"target_app_ref", "targetAppRef", "target_app_client_key", "targetAppClientKey"}:
3158
+ refs.append({"kind": "target_app_ref", "value": str(entry or "").strip(), "path": current_path})
3159
+ continue
3160
+ if key in {"target_app", "targetApp"}:
3161
+ refs.append({"kind": "target_app", "value": str(entry or "").strip(), "path": current_path})
3162
+ continue
3163
+ refs.extend(_collect_multi_app_target_refs(entry, path=current_path))
3164
+ return refs
3165
+
3166
+
3167
+ def _multi_app_static_validation_failure(
3168
+ *,
3169
+ tool_name: str,
3170
+ apps: list[JSONObject],
3171
+ create_if_missing: bool,
3172
+ ) -> JSONObject | None:
3173
+ issues: list[JSONObject] = []
3174
+ client_key_indexes: dict[str, int] = {}
3175
+ app_name_indexes: dict[str, list[int]] = {}
3176
+
3177
+ for index, item in enumerate(apps):
3178
+ if not isinstance(item, dict):
3179
+ continue
3180
+ app_name = _multi_app_item_app_name(item)
3181
+ client_key = _multi_app_item_client_key(item)
3182
+ if client_key:
3183
+ first_index = client_key_indexes.get(client_key)
3184
+ if first_index is not None:
3185
+ issues.append(
3186
+ {
3187
+ "index": index,
3188
+ "row_number": index + 1,
3189
+ "path": f"apps[{index}].client_key",
3190
+ "error_code": "DUPLICATE_CLIENT_KEY",
3191
+ "message": f"duplicate client_key '{client_key}' also appears at apps[{first_index}]",
3192
+ "fix_hint": "Use a unique stable client_key for each app item, then reference relations through target_app_ref.",
3193
+ "details": {"client_key": client_key, "first_index": first_index, "duplicate_index": index},
3194
+ }
3195
+ )
3196
+ else:
3197
+ client_key_indexes[client_key] = index
3198
+ if app_name:
3199
+ app_name_indexes.setdefault(app_name, []).append(index)
3200
+
3201
+ for app_name, indexes in sorted(app_name_indexes.items()):
3202
+ if len(indexes) <= 1:
3203
+ continue
3204
+ issues.append(
3205
+ {
3206
+ "index": indexes[1],
3207
+ "row_number": indexes[1] + 1,
3208
+ "path": f"apps[{indexes[1]}].app_name",
3209
+ "error_code": "DUPLICATE_APP_NAME",
3210
+ "message": f"duplicate app_name '{app_name}' appears at apps{indexes}",
3211
+ "fix_hint": "Use unique app_name values in one multi-app payload, or use app_key for existing apps.",
3212
+ "details": {"app_name": app_name, "indexes": indexes},
3213
+ }
3214
+ )
3215
+
3216
+ for index, item in enumerate(apps):
3217
+ if not isinstance(item, dict):
3218
+ continue
3219
+ app_key = _multi_app_item_app_key(item)
3220
+ app_name = _multi_app_item_app_name(item)
3221
+ new_app_item = not bool(app_key)
3222
+ if not app_key and not app_name:
3223
+ issues.append(
3224
+ {
3225
+ "index": index,
3226
+ "row_number": index + 1,
3227
+ "path": f"apps[{index}]",
3228
+ "error_code": "APP_SELECTOR_REQUIRED",
3229
+ "message": "apps[] item requires app_key or app_name",
3230
+ "fix_hint": "For new apps pass app_name; for existing apps pass app_key.",
3231
+ }
3232
+ )
3233
+ continue
3234
+ if new_app_item and not create_if_missing:
3235
+ issues.append(
3236
+ {
3237
+ "index": index,
3238
+ "row_number": index + 1,
3239
+ "path": f"apps[{index}]",
3240
+ "error_code": "CREATE_IF_MISSING_REQUIRED",
3241
+ "message": "new multi-app items require create_if_missing=true",
3242
+ "fix_hint": "Set create_if_missing=true, or pass app_key for an existing app.",
3243
+ }
3244
+ )
3245
+
3246
+ add_fields = _multi_app_list_value(item, "add_fields", "addFields")
3247
+ if new_app_item:
3248
+ title_fields: list[tuple[int, JSONObject]] = [
3249
+ (field_index, field)
3250
+ for field_index, field in enumerate(add_fields)
3251
+ if isinstance(field, dict) and _is_data_title_field(field)
3252
+ ]
3253
+ if not title_fields:
3254
+ issues.append(
3255
+ {
3256
+ "index": index,
3257
+ "row_number": index + 1,
3258
+ "path": f"apps[{index}].add_fields",
3259
+ "error_code": "MISSING_DATA_TITLE_FIELD",
3260
+ "message": "new apps must define exactly one top-level data title field",
3261
+ "fix_hint": "Mark one readable top-level field with as_data_title=true, for example {'name':'客户名称','type':'text','as_data_title':true}.",
3262
+ "details": {"suggested_field_names": [_field_name_for_static_validation(field) for field in add_fields[:8] if isinstance(field, dict)]},
3263
+ }
3264
+ )
3265
+ elif len(title_fields) > 1:
3266
+ issues.append(
3267
+ {
3268
+ "index": index,
3269
+ "row_number": index + 1,
3270
+ "path": f"apps[{index}].add_fields",
3271
+ "error_code": "MULTIPLE_DATA_TITLE_FIELDS",
3272
+ "message": "new apps can mark only one top-level field as data title",
3273
+ "fix_hint": "Keep as_data_title=true on exactly one top-level field.",
3274
+ "details": {
3275
+ "fields": [
3276
+ {
3277
+ "field_index": field_index,
3278
+ "name": _field_name_for_static_validation(field),
3279
+ "type": _field_type_for_static_validation(field),
3280
+ }
3281
+ for field_index, field in title_fields
3282
+ ]
3283
+ },
3284
+ }
3285
+ )
3286
+ else:
3287
+ field_index, title_field = title_fields[0]
3288
+ if _field_type_for_static_validation(title_field) in {"subtable", "table"}:
3289
+ issues.append(
3290
+ {
3291
+ "index": index,
3292
+ "row_number": index + 1,
3293
+ "path": f"apps[{index}].add_fields[{field_index}]",
3294
+ "error_code": "INVALID_DATA_TITLE_FIELD",
3295
+ "message": "data title must be a top-level non-subtable field",
3296
+ "fix_hint": "Move as_data_title=true to a normal top-level text/number/date-like field.",
3297
+ "details": {
3298
+ "field_name": _field_name_for_static_validation(title_field),
3299
+ "field_type": _field_type_for_static_validation(title_field),
3300
+ },
3301
+ }
3302
+ )
3303
+ if _contains_multi_app_target_ref(title_field):
3304
+ issues.append(
3305
+ {
3306
+ "index": index,
3307
+ "row_number": index + 1,
3308
+ "path": f"apps[{index}].add_fields[{field_index}]",
3309
+ "error_code": "DATA_TITLE_FIELD_DEFERRED_BY_TARGET_REF",
3310
+ "message": "data title cannot be a relation field that depends on target_app_ref/target_app in the same multi-app call",
3311
+ "fix_hint": "Use a normal title field such as 客户名称/项目名称 as data title; keep relation fields as non-title fields.",
3312
+ "details": {"field_name": _field_name_for_static_validation(title_field)},
3313
+ }
3314
+ )
3315
+
3316
+ for ref in _collect_multi_app_target_refs(item, path=f"apps[{index}]"):
3317
+ ref_value = str(ref.get("value") or "").strip()
3318
+ if not ref_value:
3319
+ issues.append(
3320
+ {
3321
+ "index": index,
3322
+ "row_number": index + 1,
3323
+ "path": ref.get("path"),
3324
+ "error_code": "TARGET_APP_REFERENCE_EMPTY",
3325
+ "message": "target app reference cannot be empty",
3326
+ "fix_hint": "Use target_app_ref with another apps[].client_key, or target_app with a unique apps[].app_name.",
3327
+ }
3328
+ )
3329
+ elif ref.get("kind") == "target_app_ref" and ref_value not in client_key_indexes:
3330
+ issues.append(
3331
+ {
3332
+ "index": index,
3333
+ "row_number": index + 1,
3334
+ "path": ref.get("path"),
3335
+ "error_code": "TARGET_APP_REF_UNRESOLVED",
3336
+ "message": f"target_app_ref '{ref_value}' does not match any apps[].client_key in this payload",
3337
+ "fix_hint": "Set target_app_ref to one of apps[].client_key, or use target_app_key for an already-known existing app.",
3338
+ "details": {"target_app_ref": ref_value, "available_client_keys": sorted(client_key_indexes.keys())},
3339
+ }
3340
+ )
3341
+ elif ref.get("kind") == "target_app":
3342
+ matching_indexes = app_name_indexes.get(ref_value, [])
3343
+ if not matching_indexes:
3344
+ issues.append(
3345
+ {
3346
+ "index": index,
3347
+ "row_number": index + 1,
3348
+ "path": ref.get("path"),
3349
+ "error_code": "TARGET_APP_NAME_UNRESOLVED",
3350
+ "message": f"target_app '{ref_value}' does not match any apps[].app_name in this payload",
3351
+ "fix_hint": "Prefer target_app_ref with a stable apps[].client_key, or make target_app match an app_name exactly.",
3352
+ "details": {"target_app": ref_value, "available_app_names": sorted(app_name_indexes.keys())},
3353
+ }
3354
+ )
3355
+ elif len(matching_indexes) > 1:
3356
+ issues.append(
3357
+ {
3358
+ "index": index,
3359
+ "row_number": index + 1,
3360
+ "path": ref.get("path"),
3361
+ "error_code": "TARGET_APP_NAME_AMBIGUOUS",
3362
+ "message": f"target_app '{ref_value}' matches multiple apps[].app_name values",
3363
+ "fix_hint": "Use target_app_ref with a unique apps[].client_key instead of target_app.",
3364
+ "details": {"target_app": ref_value, "matching_indexes": matching_indexes},
3365
+ }
3366
+ )
3367
+
3368
+ if not issues:
3369
+ return None
3370
+ return _config_failure(
3371
+ tool_name=tool_name,
3372
+ error_code="MULTI_APP_STATIC_VALIDATION_FAILED",
3373
+ message="multi-app schema payload has static errors; fix apps[] before writing.",
3374
+ fix_hint="Before creating app shells, ensure each new app has exactly one data title, unique client_key/app_name, and relation refs match apps[].client_key or app_name.",
3375
+ details={"issues": issues, "expected_shape": _schema_apps_expected_shape()},
3376
+ )
3377
+
3378
+
2935
3379
  def _compile_multi_app_schema_item_refs(
2936
3380
  item: JSONObject,
2937
3381
  client_key_to_app_key: dict[str, str],
@@ -3046,6 +3490,15 @@ def _schema_apply_result_has_write(result: JSONObject) -> bool:
3046
3490
  return False
3047
3491
 
3048
3492
 
3493
+ def _schema_apply_result_may_have_write(result: JSONObject) -> bool:
3494
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
3495
+ return bool(
3496
+ result.get("write_may_have_succeeded")
3497
+ or str(result.get("next_action") or "") == "readback_before_retry"
3498
+ or verification.get("readback_before_retry")
3499
+ )
3500
+
3501
+
3049
3502
  def _validation_failure(
3050
3503
  detail: str,
3051
3504
  *,
@@ -3520,8 +3973,14 @@ def _builder_apply_summary(payload: JSONObject, resources: list[JSONObject]) ->
3520
3973
  }
3521
3974
  if "write_executed" in payload:
3522
3975
  summary["write_executed"] = bool(payload.get("write_executed"))
3976
+ if "write_may_have_succeeded" in payload:
3977
+ summary["write_may_have_succeeded"] = bool(payload.get("write_may_have_succeeded"))
3523
3978
  if "safe_to_retry" in payload:
3524
3979
  summary["safe_to_retry"] = bool(payload.get("safe_to_retry"))
3980
+ if payload.get("next_action"):
3981
+ summary["next_action"] = payload.get("next_action")
3982
+ if payload.get("pending_readback") is not None:
3983
+ summary["pending_readback"] = payload.get("pending_readback")
3525
3984
  return summary
3526
3985
 
3527
3986
 
@@ -3808,7 +4267,7 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3808
4267
  parent=package_parent,
3809
4268
  icon_config=icon_config,
3810
4269
  error_code=item.get("error_code"),
3811
- message=item.get("message") if status == "failed" else None,
4270
+ message=item.get("message") if status in {"failed", "pending_readback"} else None,
3812
4271
  )
3813
4272
  )
3814
4273
  resources.extend(_builder_field_resources(item.get("field_diff_details") or item.get("field_diff"), parent=parent))
@@ -4438,7 +4897,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4438
4897
  "execution_notes": [
4439
4898
  "create or update package metadata, visibility, grouping, and ordering in one call",
4440
4899
  "creating a package requires explicit icon + color; icon=template is blocked because it is too generic",
4441
- "icon can be passed as icon/color, icon_name/icon_color, icon_config, or icon={name,color}; all forms normalize to icon/color",
4900
+ "agent-facing primary icon shape is icon + color; icon_name/icon_color, icon_config, and icon={name,color} are compatibility aliases that normalize to icon/color",
4442
4901
  "updating a package preserves existing icon/color when omitted; explicit icon/color values are still validated",
4443
4902
  "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
4444
4903
  "metadata keys omitted on update are preserved",
@@ -5011,6 +5470,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5011
5470
  },
5012
5471
  "allowed_values": {
5013
5472
  "field.type": [member.value for member in PublicFieldType],
5473
+ "field.type_aliases": {alias: field_type.value for alias, field_type in sorted(FIELD_TYPE_ALIASES.items())},
5014
5474
  "field.relation_mode": [member.value for member in PublicRelationMode],
5015
5475
  "field.department_scope.mode": ["all", "custom"],
5016
5476
  "field.code_block_binding.outputs.target_field.type": list(INTEGRATION_OUTPUT_TARGET_FIELD_TYPES),
@@ -5024,13 +5484,16 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5024
5484
  "create mode: package_id + app_name + create_if_missing=true",
5025
5485
  "create mode follows backend CreateAppBean: package add_app permission is checked on the target package; package edit_app is not required for the create precheck",
5026
5486
  "multi-app mode: pass package_id + create_if_missing + apps[]; do not mix apps with top-level app_key/app_name/add_fields/update_fields/remove_fields",
5487
+ "CLI --apps-file primary shape is {package_id, apps:[...]}; raw app arrays and singleton wrapper arrays are compatibility paths, not recommended examples",
5488
+ "multi-app mode preflights static errors before writing: duplicate client_key/app_name, missing data title on new apps, create_if_missing omissions, and unresolved target_app_ref/target_app",
5027
5489
  "multi-app relation fields may use target_app_ref to point at another apps[].client_key; the tool creates/resolves app shells first and compiles it to target_app_key",
5028
5490
  "multi-app relation fields may also use target_app with another apps[].app_name; prefer target_app_ref/client_key when names may collide",
5029
5491
  "multi-app mode is not transactional; read created_app_keys and apps[].status before retrying, and retry only failed app items",
5030
5492
  "create mode defaults new app visibility to workspace/not when visibility is omitted; edit mode preserves current visibility when omitted",
5031
5493
  "create mode requires explicit icon + color; icon=template is blocked because it is too generic",
5032
- "icon can be passed as icon/color, icon_name/icon_color, icon_config, or icon={name,color}; all forms normalize to icon/color",
5494
+ "agent-facing primary icon shape is icon + color; icon_name/icon_color, icon_config, and icon={name,color} are compatibility aliases that normalize to icon/color",
5033
5495
  "multi-app create mode requires each new app item to include a distinct non-template icon and a valid color",
5496
+ "agent-friendly field type aliases are normalized before writing: multiline/multiline_text/textarea -> long_text; select/single_choice/dropdown -> single_select; multi_choice/multiple_choice/checkbox -> multi_select",
5034
5497
  "single_select and multi_select options accept strings or objects such as {label,value}; builder normalizes them to option labels before writing",
5035
5498
  "edit mode preserves existing icon/color when omitted; explicit icon/color values are still validated",
5036
5499
  "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
@@ -5042,6 +5505,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5042
5505
  "relation_mode=multiple maps to referenceConfig.optionalDataNum=0",
5043
5506
  "relation fields now require both display_field and visible_fields in MCP/CLI payloads",
5044
5507
  "if relation target metadata lookup is blocked by 40161/40002/40027, explicit display_field.name and visible_fields[].name let builder degrade verification and still continue schema write",
5508
+ "relation write readback returns details.relation_readback_matrix; mismatches include details.relation_repair_plan with a minimal update_fields relation patch and data_impact",
5045
5509
  "update_fields[].set.subfield_updates is the safe patch path for editing existing subtable child fields without rebuilding the entire subtable",
5046
5510
  "subfield_updates only supports safe child overlays: name, required, description, and nested subfield_updates",
5047
5511
  "set.subfields remains the full replace/rebuild path for a subtable and is higher risk when hidden relation/reference children exist",
@@ -5660,6 +6124,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5660
6124
  "use this before app_schema_apply when you need exact field definitions",
5661
6125
  "also returns chart_fields from QingBI datasource fields; app_charts_apply field selectors should use chart_fields because record/schema-visible fields and QingBI fields are not the same schema",
5662
6126
  "chart_fields[].field_id supports field_<queId> selectors, while chart_fields[].bi_field_id is the raw QingBI fieldId accepted by report configs",
6127
+ "chart_fields[].chart_apply_examples contains copyable semantic app_charts_apply snippets such as count_by_field, filtered_count, and numeric sum_metric",
5663
6128
  "subtable fields include nested subfields using the same compact field shape",
5664
6129
  ],
5665
6130
  "minimal_example": {
@@ -5755,7 +6220,22 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5755
6220
  },
5756
6221
  },
5757
6222
  "app_charts_apply": {
5758
- "allowed_keys": ["app_key", "upsert_charts", "patch_charts", "remove_chart_ids", "reorder_chart_ids", "upsert_charts[].visibility", "patch_charts[].chart_id", "patch_charts[].name", "patch_charts[].set", "patch_charts[].unset"],
6223
+ "allowed_keys": [
6224
+ "app_key",
6225
+ "upsert_charts",
6226
+ "patch_charts",
6227
+ "remove_chart_ids",
6228
+ "reorder_chart_ids",
6229
+ "upsert_charts[].metric",
6230
+ "upsert_charts[].metrics",
6231
+ "upsert_charts[].group_by",
6232
+ "upsert_charts[].where",
6233
+ "upsert_charts[].visibility",
6234
+ "patch_charts[].chart_id",
6235
+ "patch_charts[].name",
6236
+ "patch_charts[].set",
6237
+ "patch_charts[].unset",
6238
+ ],
5759
6239
  "aliases": {
5760
6240
  "patchCharts": "patch_charts",
5761
6241
  "chart.id": "chart.chart_id",
@@ -5763,6 +6243,11 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5763
6243
  "chart.dimension_fields": "chart.dimension_field_ids",
5764
6244
  "chart.indicator_fields": "chart.indicator_field_ids",
5765
6245
  "chart.metric_field_ids": "chart.indicator_field_ids",
6246
+ "chart.dimensions": "chart.group_by",
6247
+ "chart.groupBy": "chart.group_by",
6248
+ "chart.where": "chart.filters",
6249
+ "chart.metric.operation": "chart.metric.op",
6250
+ "chart.metric.aggregation": "chart.metric.op",
5766
6251
  "chart.filter.op": "chart.filter.operator",
5767
6252
  },
5768
6253
  "allowed_values": {
@@ -5783,6 +6268,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5783
6268
  "upsert_charts[].visibility compiles to QingBI base visibleAuth only",
5784
6269
  "visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
5785
6270
  "chart dimension/metric/filter/query fields are resolved from app_get_fields.chart_fields (QingBI datasource fields), not record schema or form-only fields",
6271
+ "preferred chart metric DSL is SQL-like: metric='count(*)', metric='sum(金额)', metrics=['sum(金额)'], group_by=['状态'], where=[{field, op, value}]",
6272
+ "legacy dimension_field_ids/indicator_field_ids/config.aggregate remain supported as advanced compatibility input, but should not be the first choice for agents",
6273
+ "chart_get returns semantic group_by and metrics; raw QingBI config is diagnostic detail",
5786
6274
  "system fields such as 申请人/申请时间/编号 are usable only when they appear in chart_fields; otherwise app_charts_apply returns CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
5787
6275
  "low-frequency chart types have local prevalidation: gauge requires 0 dimensions and 2 non-duplicated metrics; histogram requires at most 1 dimension and exactly 1 plain numeric metric",
5788
6276
  "chart rule failures return chart_results[].diagnostics with rule_code, expected, actual, offending_fields, and next_action; backend 81002/81005 are translated when possible",
@@ -5793,7 +6281,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5793
6281
  "minimal_example": {
5794
6282
  "profile": "default",
5795
6283
  "app_key": "APP_KEY",
5796
- "upsert_charts": [{"name": "数据总量", "chart_type": "target", "indicator_field_ids": [], "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE)}],
6284
+ "upsert_charts": [{"name": "数据总量", "chart_type": "target", "metric": "count(*)", "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE)}],
5797
6285
  "patch_charts": [{"chart_id": "CHART_ID", "set": {"name": "数据总量-新版"}}],
5798
6286
  "remove_chart_ids": [],
5799
6287
  "reorder_chart_ids": [],
@@ -5840,9 +6328,11 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5840
6328
  "viewRef": "view_ref",
5841
6329
  "dashStyleConfigBO": "dash_style_config",
5842
6330
  },
5843
- "section_allowed_keys": ["title", "source_type", "position", "dash_style_config", "config", "chart_ref", "view_ref", "text", "url"],
6331
+ "section_allowed_keys": ["title", "source_type", "role", "position", "dash_style_config", "config", "chart_ref", "view_ref", "text", "url"],
5844
6332
  "section_aliases": {
5845
6333
  "sourceType": "source_type",
6334
+ "zone": "role",
6335
+ "sectionRole": "role",
5846
6336
  "chartRef": "chart_ref",
5847
6337
  "viewRef": "view_ref",
5848
6338
  "dashStyleConfigBO": "dash_style_config",
@@ -5874,6 +6364,9 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5874
6364
  "if unsure about layout, omit position or use layout_preset=auto/dashboard_2col/dashboard_3col",
5875
6365
  "two-column pc layout should use x=0/12 with cols=12; three-column pc layout should use x=0/8/16 with cols=8",
5876
6366
  "x=0/6 with cols=6 only occupies the left half of the pc portal and triggers PORTAL_LAYOUT_HALF_WIDTH",
6367
+ "grid sections must include config.items; an empty grid triggers PORTAL_GRID_ITEMS_EMPTY because the frontend only shows an empty entry container",
6368
+ "metric portal sections may set role=metric; role=metric requires a target/indicator chart and otherwise triggers PORTAL_METRIC_SECTION_CHART_TYPE_MISMATCH",
6369
+ "standard workbench count diagnostics warn when metric cards are not 4-6, BI charts are not 2-3, or business views are not 1-2",
5877
6370
  "position.pc/mobile is the canonical portal layout shape",
5878
6371
  "compat payload accepts name -> dash_name and single pages[0].components -> sections",
5879
6372
  "visibility is the canonical public auth shape; auth is kept only as a deprecated compatibility alias",