@qingflow-tech/qingflow-app-builder-mcp 1.0.9 → 1.0.11

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.
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from copy import deepcopy
4
4
  import json
5
5
  import time
6
+ from typing import Any
6
7
 
7
8
  from pydantic import ValidationError
8
9
 
@@ -49,6 +50,15 @@ from ..builder_facade.models import (
49
50
  ViewsPlanRequest,
50
51
  )
51
52
  from ..builder_facade.service import AiBuilderFacade, INTEGRATION_OUTPUT_TARGET_FIELD_TYPES
53
+ from ..solution.compiler.icon_utils import (
54
+ GENERIC_WORKSPACE_ICON_NAMES,
55
+ WORKSPACE_ICON_COLORS,
56
+ WORKSPACE_ICON_NAMES,
57
+ normalize_workspace_icon_name,
58
+ validate_workspace_icon_choice,
59
+ workspace_icon_catalog_payload,
60
+ workspace_icon_config,
61
+ )
52
62
  from .app_tools import AppTools
53
63
  from .base import ToolBase, tool_cn_name
54
64
  from .custom_button_tools import CustomButtonTools
@@ -70,6 +80,19 @@ def _normalize_builder_view_key(value: str) -> str:
70
80
 
71
81
 
72
82
  PUBLIC_STABLE_FLOW_NODE_TYPES = ["start", "approve", "fill", "copy", "webhook", "end"]
83
+ BUILDER_APPLY_SCHEMA_VERSION = "builder.apply.v1"
84
+ BUILDER_APPLY_TOOL_NAMES = {
85
+ "package_apply",
86
+ "app_schema_apply",
87
+ "app_layout_apply",
88
+ "app_flow_apply",
89
+ "app_views_apply",
90
+ "app_custom_buttons_apply",
91
+ "app_associated_resources_apply",
92
+ "app_charts_apply",
93
+ "portal_apply",
94
+ "app_publish_verify",
95
+ }
73
96
 
74
97
 
75
98
  class AiBuilderTools(ToolBase):
@@ -104,6 +127,14 @@ class AiBuilderTools(ToolBase):
104
127
  def builder_tool_contract(tool_name: str = "") -> JSONObject:
105
128
  return self.builder_tool_contract(tool_name=tool_name)
106
129
 
130
+ @mcp.tool()
131
+ def workspace_icon_catalog_get(profile: str = DEFAULT_PROFILE) -> JSONObject:
132
+ return self.workspace_icon_catalog_get(profile=profile)
133
+
134
+ @mcp.tool()
135
+ def package_list(profile: str = DEFAULT_PROFILE, trial_status: str = "all", query: str = "") -> JSONObject:
136
+ return self.package_list(profile=profile, trial_status=trial_status, query=query)
137
+
107
138
  @mcp.tool()
108
139
  def package_get(profile: str = DEFAULT_PROFILE, package_id: int = 0) -> JSONObject:
109
140
  return self.package_get(profile=profile, package_id=package_id)
@@ -340,7 +371,32 @@ class AiBuilderTools(ToolBase):
340
371
  add_fields: list[JSONObject] | None = None,
341
372
  update_fields: list[JSONObject] | None = None,
342
373
  remove_fields: list[JSONObject] | None = None,
374
+ apps: list[JSONObject] | None = None,
343
375
  ) -> JSONObject:
376
+ if apps:
377
+ if app_key or app_name or app_title or add_fields or update_fields or remove_fields:
378
+ return _config_failure(
379
+ tool_name="app_schema_apply",
380
+ message="app_schema_apply multi-app mode accepts package_id/create_if_missing plus apps only.",
381
+ fix_hint="Use `apps` for batch mode, or use the single-app arguments without `apps`.",
382
+ )
383
+ if package_id is None:
384
+ return _config_failure(
385
+ tool_name="app_schema_apply",
386
+ message="app_schema_apply multi-app mode requires package_id.",
387
+ fix_hint="Pass package_id and apps[].app_name for new apps, or apps[].app_key for existing apps.",
388
+ )
389
+ return self.app_schema_apply(
390
+ profile=profile,
391
+ package_id=package_id,
392
+ visibility=visibility,
393
+ create_if_missing=create_if_missing,
394
+ publish=publish,
395
+ apps=apps,
396
+ add_fields=[],
397
+ update_fields=[],
398
+ remove_fields=[],
399
+ )
344
400
  has_app_key = bool((app_key or "").strip())
345
401
  has_app_name = bool((app_name or "").strip())
346
402
  has_app_title = bool((app_title or "").strip())
@@ -372,6 +428,7 @@ class AiBuilderTools(ToolBase):
372
428
  add_fields=add_fields or [],
373
429
  update_fields=update_fields or [],
374
430
  remove_fields=remove_fields or [],
431
+ apps=[],
375
432
  )
376
433
 
377
434
  @mcp.tool()
@@ -443,9 +500,12 @@ class AiBuilderTools(ToolBase):
443
500
  profile: str = DEFAULT_PROFILE,
444
501
  dash_key: str = "",
445
502
  dash_name: str = "",
503
+ name: str = "",
446
504
  package_id: int | None = None,
447
505
  publish: bool = True,
448
506
  sections: list[JSONObject] | None = None,
507
+ pages: list[JSONObject] | None = None,
508
+ layout_preset: str = "",
449
509
  visibility: JSONObject | None = None,
450
510
  auth: JSONObject | None = None,
451
511
  icon: str | None = None,
@@ -453,10 +513,14 @@ class AiBuilderTools(ToolBase):
453
513
  hide_copyright: bool | None = None,
454
514
  dash_global_config: JSONObject | None = None,
455
515
  config: JSONObject | None = None,
516
+ payload: JSONObject | None = None,
456
517
  ) -> JSONObject:
518
+ payload = payload if isinstance(payload, dict) else {}
457
519
  has_dash_key = bool((dash_key or "").strip())
458
- has_dash_name = bool((dash_name or "").strip())
459
- has_package_id = package_id is not None
520
+ effective_dash_name = (dash_name or name or str(payload.get("dash_name") or payload.get("dashName") or payload.get("name") or "")).strip()
521
+ has_dash_name = bool(effective_dash_name)
522
+ effective_package_id = package_id if package_id is not None else payload.get("package_id") or payload.get("packageId") or payload.get("package_tag_id")
523
+ has_package_id = effective_package_id is not None
460
524
  if has_dash_key and has_package_id:
461
525
  return _config_failure(
462
526
  tool_name="portal_apply",
@@ -473,9 +537,12 @@ class AiBuilderTools(ToolBase):
473
537
  profile=profile,
474
538
  dash_key=dash_key,
475
539
  dash_name=dash_name,
540
+ name=name,
476
541
  package_id=package_id,
477
542
  publish=publish,
478
543
  sections=sections or [],
544
+ pages=pages or [],
545
+ layout_preset=layout_preset,
479
546
  visibility=visibility,
480
547
  auth=auth,
481
548
  icon=icon,
@@ -483,6 +550,7 @@ class AiBuilderTools(ToolBase):
483
550
  hide_copyright=hide_copyright,
484
551
  dash_global_config=dash_global_config,
485
552
  config=config or {},
553
+ payload=payload,
486
554
  )
487
555
 
488
556
  @mcp.tool()
@@ -498,14 +566,14 @@ class AiBuilderTools(ToolBase):
498
566
  )
499
567
 
500
568
  @tool_cn_name("分组列表查询")
501
- def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
569
+ def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
502
570
  """执行分组与包相关逻辑。"""
503
- normalized_args = {"trial_status": trial_status}
571
+ normalized_args = {"trial_status": trial_status, "query": query}
504
572
  return _safe_tool_call(
505
- lambda: self._facade.package_list(profile=profile, trial_status=trial_status),
573
+ lambda: self._facade.package_list(profile=profile, trial_status=trial_status, query=query),
506
574
  error_code="PACKAGE_LIST_FAILED",
507
575
  normalized_args=normalized_args,
508
- suggested_next_call={"tool_name": "package_list", "arguments": {"profile": profile, "trial_status": trial_status}},
576
+ suggested_next_call={"tool_name": "package_list", "arguments": {"profile": profile, "trial_status": trial_status, "query": query}},
509
577
  )
510
578
 
511
579
  @tool_cn_name("分组解析")
@@ -550,6 +618,7 @@ class AiBuilderTools(ToolBase):
550
618
  "verification": {},
551
619
  "verified": False,
552
620
  }
621
+ contract = _builder_contract_with_apply_output(lookup_name, contract)
553
622
  return {
554
623
  "status": "success",
555
624
  "error_code": None,
@@ -571,6 +640,27 @@ class AiBuilderTools(ToolBase):
571
640
  "contract": contract,
572
641
  }
573
642
 
643
+ @tool_cn_name("工作区图标目录")
644
+ def workspace_icon_catalog_get(self, *, profile: str = DEFAULT_PROFILE) -> JSONObject:
645
+ """读取应用、应用包、门户可用的工作区图标候选。"""
646
+ catalog = workspace_icon_catalog_payload()
647
+ return {
648
+ "status": "success",
649
+ "error_code": None,
650
+ "recoverable": False,
651
+ "message": "loaded workspace icon catalog",
652
+ "profile": profile,
653
+ "icon_names": catalog["icon_names"],
654
+ "icon_colors": catalog["icon_colors"],
655
+ "generic_icon_names": catalog["generic_icon_names"],
656
+ "common_examples": catalog["common_examples"],
657
+ "notes": catalog["notes"],
658
+ "count": len(catalog["icon_names"]),
659
+ "color_count": len(catalog["icon_colors"]),
660
+ "warnings": [],
661
+ "verification": {"source": "backend AiBuildConstant ICON_NAMES/ICON_COLORS"},
662
+ }
663
+
574
664
  @tool_cn_name("分组创建")
575
665
  def package_create(
576
666
  self,
@@ -647,7 +737,18 @@ class AiBuilderTools(ToolBase):
647
737
  try:
648
738
  visibility_patch = VisibilityPatch.model_validate(visibility)
649
739
  except ValidationError as exc:
650
- return _visibility_validation_failure(str(exc), tool_name="package_apply", exc=exc)
740
+ return _attach_builder_apply_envelope(
741
+ "package_apply",
742
+ _visibility_validation_failure(str(exc), tool_name="package_apply", exc=exc),
743
+ )
744
+ icon_failure = _validate_workspace_icon_for_builder(
745
+ tool_name="package_apply",
746
+ icon=icon,
747
+ color=color,
748
+ creating=package_id is None and bool(create_if_missing),
749
+ )
750
+ if icon_failure is not None:
751
+ return _attach_builder_apply_envelope("package_apply", icon_failure)
651
752
  normalized_args = {
652
753
  "package_id": package_id,
653
754
  **({"package_name": package_name} if str(package_name or "").strip() else {}),
@@ -658,7 +759,7 @@ class AiBuilderTools(ToolBase):
658
759
  **({"items": deepcopy(items)} if items is not None else {}),
659
760
  "allow_detach": bool(allow_detach),
660
761
  }
661
- return _publicize_package_fields(_safe_tool_call(
762
+ result = _publicize_package_fields(_safe_tool_call(
662
763
  lambda: self._facade.package_apply(
663
764
  profile=profile,
664
765
  package_id=package_id,
@@ -674,6 +775,7 @@ class AiBuilderTools(ToolBase):
674
775
  normalized_args=normalized_args,
675
776
  suggested_next_call={"tool_name": "package_apply", "arguments": {"profile": profile, **normalized_args}},
676
777
  ))
778
+ return _attach_builder_apply_envelope("package_apply", result)
677
779
 
678
780
  @tool_cn_name("分组更新")
679
781
  def package_update(
@@ -914,7 +1016,7 @@ class AiBuilderTools(ToolBase):
914
1016
  try:
915
1017
  request = CustomButtonsApplyRequest.model_validate(raw_request)
916
1018
  except ValidationError as exc:
917
- return _validation_failure(
1019
+ return _attach_builder_apply_envelope("app_custom_buttons_apply", _validation_failure(
918
1020
  str(exc),
919
1021
  tool_name="app_custom_buttons_apply",
920
1022
  exc=exc,
@@ -936,14 +1038,14 @@ class AiBuilderTools(ToolBase):
936
1038
  "view_configs": [],
937
1039
  },
938
1040
  },
939
- )
1041
+ ))
940
1042
  normalized_args = request.model_dump(mode="json")
941
- return _safe_tool_call(
1043
+ return _attach_builder_apply_envelope("app_custom_buttons_apply", _safe_tool_call(
942
1044
  lambda: self._facade.app_custom_buttons_apply(profile=profile, request=request),
943
1045
  error_code="CUSTOM_BUTTONS_APPLY_FAILED",
944
1046
  normalized_args=normalized_args,
945
1047
  suggested_next_call={"tool_name": "app_custom_buttons_apply", "arguments": {"profile": profile, **normalized_args}},
946
- )
1048
+ ))
947
1049
 
948
1050
  @tool_cn_name("应用关联资源声明式应用")
949
1051
  def app_associated_resources_apply(
@@ -969,7 +1071,7 @@ class AiBuilderTools(ToolBase):
969
1071
  try:
970
1072
  request = AssociatedResourcesApplyRequest.model_validate(raw_request)
971
1073
  except ValidationError as exc:
972
- return _validation_failure(
1074
+ return _attach_builder_apply_envelope("app_associated_resources_apply", _validation_failure(
973
1075
  str(exc),
974
1076
  tool_name="app_associated_resources_apply",
975
1077
  exc=exc,
@@ -996,14 +1098,14 @@ class AiBuilderTools(ToolBase):
996
1098
  ],
997
1099
  },
998
1100
  },
999
- )
1101
+ ))
1000
1102
  normalized_args = request.model_dump(mode="json", exclude_none=True)
1001
- return _safe_tool_call(
1103
+ return _attach_builder_apply_envelope("app_associated_resources_apply", _safe_tool_call(
1002
1104
  lambda: self._facade.app_associated_resources_apply(profile=profile, request=request),
1003
1105
  error_code="ASSOCIATED_RESOURCES_APPLY_FAILED",
1004
1106
  normalized_args=normalized_args,
1005
1107
  suggested_next_call={"tool_name": "app_associated_resources_apply", "arguments": {"profile": profile, **normalized_args}},
1006
- )
1108
+ ))
1007
1109
 
1008
1110
  @tool_cn_name("应用按钮列表")
1009
1111
  def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
@@ -1534,8 +1636,19 @@ class AiBuilderTools(ToolBase):
1534
1636
  add_fields: list[JSONObject],
1535
1637
  update_fields: list[JSONObject],
1536
1638
  remove_fields: list[JSONObject],
1639
+ apps: list[JSONObject] | None = None,
1537
1640
  ) -> JSONObject:
1538
1641
  """执行应用相关逻辑。"""
1642
+ if apps:
1643
+ result = self._app_schema_apply_multi(
1644
+ profile=profile,
1645
+ package_id=package_id,
1646
+ visibility=visibility,
1647
+ create_if_missing=create_if_missing,
1648
+ publish=publish,
1649
+ apps=apps,
1650
+ )
1651
+ return _attach_builder_apply_envelope("app_schema_apply", result)
1539
1652
  result = self._app_schema_apply_once(
1540
1653
  profile=profile,
1541
1654
  app_key=app_key,
@@ -1551,7 +1664,7 @@ class AiBuilderTools(ToolBase):
1551
1664
  update_fields=update_fields,
1552
1665
  remove_fields=remove_fields,
1553
1666
  )
1554
- return self._retry_after_self_lock_release(
1667
+ result = self._retry_after_self_lock_release(
1555
1668
  profile=profile,
1556
1669
  result=result,
1557
1670
  retry_call=lambda: self._app_schema_apply_once(
@@ -1570,6 +1683,291 @@ class AiBuilderTools(ToolBase):
1570
1683
  remove_fields=remove_fields,
1571
1684
  ),
1572
1685
  )
1686
+ return _attach_builder_apply_envelope("app_schema_apply", result)
1687
+
1688
+ def _app_schema_apply_multi(
1689
+ self,
1690
+ *,
1691
+ profile: str,
1692
+ package_id: int | None,
1693
+ visibility: JSONObject | None,
1694
+ create_if_missing: bool,
1695
+ publish: bool,
1696
+ apps: list[JSONObject],
1697
+ ) -> JSONObject:
1698
+ normalized_args: JSONObject = {
1699
+ "package_id": package_id,
1700
+ "create_if_missing": create_if_missing,
1701
+ "publish": publish,
1702
+ "apps": deepcopy(apps),
1703
+ }
1704
+ if visibility is not None:
1705
+ normalized_args["visibility"] = deepcopy(visibility)
1706
+ if package_id is None:
1707
+ return _config_failure(
1708
+ tool_name="app_schema_apply",
1709
+ message="app_schema_apply multi-app mode requires package_id.",
1710
+ fix_hint="Pass package_id and apps[].app_name for new apps, or apps[].app_key for existing apps.",
1711
+ )
1712
+ if not apps:
1713
+ return _config_failure(
1714
+ tool_name="app_schema_apply",
1715
+ message="app_schema_apply multi-app mode requires non-empty apps.",
1716
+ fix_hint="Pass apps as a non-empty list of app schema items.",
1717
+ )
1718
+ icon_errors: list[JSONObject] = []
1719
+ seen_new_app_icons: dict[str, int] = {}
1720
+ for index, raw_item in enumerate(apps):
1721
+ if not isinstance(raw_item, dict):
1722
+ continue
1723
+ app_key = str(raw_item.get("app_key") or raw_item.get("appKey") or "").strip()
1724
+ creating_item = not app_key
1725
+ icon_failure = _validate_workspace_icon_for_builder(
1726
+ tool_name="app_schema_apply",
1727
+ icon=str(raw_item.get("icon") or ""),
1728
+ color=str(raw_item.get("color") or ""),
1729
+ creating=creating_item,
1730
+ )
1731
+ if icon_failure is not None:
1732
+ icon_errors.append(
1733
+ {
1734
+ "index": index,
1735
+ "row_number": index + 1,
1736
+ "error_code": icon_failure.get("error_code"),
1737
+ "message": icon_failure.get("message"),
1738
+ "details": icon_failure.get("details"),
1739
+ }
1740
+ )
1741
+ continue
1742
+ _ok, _error_code, _message, icon_details = validate_workspace_icon_choice(
1743
+ icon=str(raw_item.get("icon") or ""),
1744
+ color=str(raw_item.get("color") or ""),
1745
+ require_explicit=creating_item,
1746
+ disallow_generic=creating_item,
1747
+ )
1748
+ normalized_icon = str(icon_details.get("normalized_icon") or "").strip()
1749
+ if creating_item and normalized_icon:
1750
+ if normalized_icon in seen_new_app_icons:
1751
+ icon_errors.append(
1752
+ {
1753
+ "index": index,
1754
+ "row_number": index + 1,
1755
+ "error_code": "DUPLICATE_WORKSPACE_ICON_IN_BATCH",
1756
+ "message": f"apps[{index}] reuses icon '{normalized_icon}' from apps[{seen_new_app_icons[normalized_icon]}]",
1757
+ "details": {
1758
+ "icon": normalized_icon,
1759
+ "first_index": seen_new_app_icons[normalized_icon],
1760
+ "duplicate_index": index,
1761
+ "icon_catalog_command": "qingflow --json builder icon catalog",
1762
+ },
1763
+ }
1764
+ )
1765
+ else:
1766
+ seen_new_app_icons[normalized_icon] = index
1767
+ if icon_errors:
1768
+ return _config_failure(
1769
+ tool_name="app_schema_apply",
1770
+ error_code="WORKSPACE_ICON_BATCH_INVALID",
1771
+ message="one or more apps have invalid workspace icon configuration",
1772
+ fix_hint="Call `qingflow --json builder icon catalog`, choose a distinct non-template icon and color for each new app, then retry.",
1773
+ details={"icon_errors": icon_errors},
1774
+ allowed_values={
1775
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
1776
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
1777
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
1778
+ },
1779
+ )
1780
+
1781
+ client_key_to_app_key: dict[str, str] = {}
1782
+ created_app_keys: list[str] = []
1783
+ results: list[JSONObject] = []
1784
+ any_write_executed = False
1785
+ client_keys: set[str] = set()
1786
+
1787
+ for index, raw_item in enumerate(apps):
1788
+ if not isinstance(raw_item, dict):
1789
+ results.append(_multi_app_item_failure(index, raw_item, "INVALID_APP_ITEM", "apps[] items must be objects"))
1790
+ continue
1791
+ item = deepcopy(raw_item)
1792
+ client_key = str(item.get("client_key") or item.get("clientKey") or "").strip()
1793
+ app_name = str(item.get("app_name") or item.get("appTitle") or item.get("app_title") or item.get("title") or "").strip()
1794
+ app_key = str(item.get("app_key") or item.get("appKey") or "").strip()
1795
+ if client_key:
1796
+ if client_key in client_keys:
1797
+ results.append(_multi_app_item_failure(index, item, "DUPLICATE_CLIENT_KEY", f"duplicate client_key '{client_key}'"))
1798
+ continue
1799
+ client_keys.add(client_key)
1800
+ if not app_key and not app_name:
1801
+ results.append(_multi_app_item_failure(index, item, "APP_SELECTOR_REQUIRED", "apps[] requires app_key or app_name"))
1802
+ continue
1803
+
1804
+ initial_add_fields, deferred_add_fields = _split_multi_app_initial_add_fields(item, is_new_app=not bool(app_key))
1805
+ item["_deferred_add_fields"] = deferred_add_fields
1806
+ shell = self._app_schema_apply_once(
1807
+ profile=profile,
1808
+ app_key=app_key,
1809
+ package_id=package_id if not app_key else None,
1810
+ app_name=app_name,
1811
+ app_title="",
1812
+ icon=str(item.get("icon") or ""),
1813
+ color=str(item.get("color") or ""),
1814
+ visibility=item.get("visibility", visibility),
1815
+ create_if_missing=create_if_missing and not app_key,
1816
+ publish=publish and not deferred_add_fields,
1817
+ add_fields=initial_add_fields,
1818
+ update_fields=[],
1819
+ remove_fields=[],
1820
+ )
1821
+ public_shell = _publicize_package_fields(shell)
1822
+ resolved_key = str(public_shell.get("app_key") or "").strip()
1823
+ if public_shell.get("status") not in {"success", "partial_success"} or not resolved_key:
1824
+ results.append({
1825
+ "index": index,
1826
+ "row_number": index + 1,
1827
+ "client_key": client_key or None,
1828
+ "app_name": app_name or None,
1829
+ "app_key": resolved_key or app_key or None,
1830
+ "status": "failed",
1831
+ "stage": "resolve_or_create_shell",
1832
+ "error_code": public_shell.get("error_code") or "APP_SHELL_APPLY_FAILED",
1833
+ "message": public_shell.get("message") or "app shell resolve/create failed",
1834
+ "safe_to_retry": not any_write_executed,
1835
+ })
1836
+ continue
1837
+ if bool(public_shell.get("created")):
1838
+ created_app_keys.append(resolved_key)
1839
+ if _schema_apply_result_has_write(public_shell):
1840
+ any_write_executed = True
1841
+ if client_key:
1842
+ client_key_to_app_key[client_key] = resolved_key
1843
+ results.append({
1844
+ "index": index,
1845
+ "row_number": index + 1,
1846
+ "client_key": client_key or None,
1847
+ "app_name": str(public_shell.get("app_name_after") or public_shell.get("app_name") or app_name or "").strip() or None,
1848
+ "app_key": resolved_key,
1849
+ "status": "shell_ready",
1850
+ "created": bool(public_shell.get("created")),
1851
+ "shell_result": public_shell,
1852
+ "shell_field_diff": public_shell.get("field_diff") or {},
1853
+ "shell_field_diff_details": public_shell.get("field_diff_details") or {},
1854
+ "deferred_add_fields": deferred_add_fields,
1855
+ })
1856
+
1857
+ final_items: list[JSONObject] = []
1858
+ for index, raw_item in enumerate(apps):
1859
+ existing = next((item for item in results if item.get("index") == index), None)
1860
+ if not existing or existing.get("status") != "shell_ready":
1861
+ if existing:
1862
+ final_items.append(existing)
1863
+ continue
1864
+ item = deepcopy(raw_item)
1865
+ app_key = str(existing.get("app_key") or "").strip()
1866
+ try:
1867
+ compiled_item = _compile_multi_app_schema_item_refs(item, client_key_to_app_key)
1868
+ except ValueError as error:
1869
+ final_items.append({
1870
+ **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
1871
+ "status": "failed",
1872
+ "stage": "compile_relation_refs",
1873
+ "error_code": "TARGET_APP_REF_NOT_FOUND",
1874
+ "message": str(error),
1875
+ "safe_to_retry": False,
1876
+ })
1877
+ any_write_executed = True
1878
+ continue
1879
+
1880
+ deferred_add_fields = (
1881
+ _compiled_multi_app_deferred_add_fields(compiled_item, existing)
1882
+ if bool(existing.get("created"))
1883
+ else list(compiled_item.get("add_fields") or [])
1884
+ )
1885
+ update_fields = list(compiled_item.get("update_fields") or [])
1886
+ remove_fields = list(compiled_item.get("remove_fields") or [])
1887
+ if bool(existing.get("created")) and not deferred_add_fields and not update_fields and not remove_fields:
1888
+ shell_result = existing.get("shell_result") if isinstance(existing.get("shell_result"), dict) else {}
1889
+ item_status = shell_result.get("status") if shell_result.get("status") in {"success", "partial_success"} else "failed"
1890
+ shell_field_diff = existing.get("shell_field_diff") if isinstance(existing.get("shell_field_diff"), dict) else {}
1891
+ shell_field_diff_details = existing.get("shell_field_diff_details") if isinstance(existing.get("shell_field_diff_details"), dict) else {}
1892
+ final_items.append({
1893
+ **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
1894
+ "status": item_status,
1895
+ "stage": "schema_apply",
1896
+ "field_diff": shell_field_diff,
1897
+ "field_diff_details": shell_field_diff_details,
1898
+ "shell_field_diff": shell_field_diff,
1899
+ "shell_field_diff_details": shell_field_diff_details,
1900
+ "published": bool(shell_result.get("published")),
1901
+ "verified": bool(shell_result.get("verified")),
1902
+ "error_code": shell_result.get("error_code"),
1903
+ "message": shell_result.get("message"),
1904
+ "safe_to_retry": False,
1905
+ })
1906
+ continue
1907
+
1908
+ field_result = self._app_schema_apply_once(
1909
+ profile=profile,
1910
+ app_key=app_key,
1911
+ package_id=None,
1912
+ app_name=str(compiled_item.get("app_name") or compiled_item.get("appTitle") or compiled_item.get("app_title") or ""),
1913
+ app_title="",
1914
+ icon=str(compiled_item.get("icon") or ""),
1915
+ color=str(compiled_item.get("color") or ""),
1916
+ visibility=compiled_item.get("visibility"),
1917
+ create_if_missing=False,
1918
+ publish=publish,
1919
+ add_fields=deferred_add_fields,
1920
+ update_fields=update_fields,
1921
+ remove_fields=remove_fields,
1922
+ )
1923
+ public_result = _publicize_package_fields(field_result)
1924
+ if _schema_apply_result_has_write(public_result):
1925
+ any_write_executed = True
1926
+ item_status = public_result.get("status") if public_result.get("status") in {"success", "partial_success"} else "failed"
1927
+ shell_field_diff = existing.get("shell_field_diff") if isinstance(existing.get("shell_field_diff"), dict) else {}
1928
+ shell_field_diff_details = existing.get("shell_field_diff_details") if isinstance(existing.get("shell_field_diff_details"), dict) else {}
1929
+ field_diff = _merge_schema_field_diffs(shell_field_diff, public_result.get("field_diff") or {})
1930
+ field_diff_details = _merge_schema_field_diffs(shell_field_diff_details, public_result.get("field_diff_details") or {})
1931
+ final_items.append({
1932
+ **{key: existing.get(key) for key in ("index", "row_number", "client_key", "app_name", "app_key", "created")},
1933
+ "status": item_status,
1934
+ "stage": "schema_apply",
1935
+ "field_diff": field_diff,
1936
+ "field_diff_details": field_diff_details,
1937
+ "shell_field_diff": shell_field_diff,
1938
+ "shell_field_diff_details": shell_field_diff_details,
1939
+ "published": bool(public_result.get("published")),
1940
+ "verified": bool(public_result.get("verified")),
1941
+ "error_code": public_result.get("error_code"),
1942
+ "message": public_result.get("message"),
1943
+ "safe_to_retry": False,
1944
+ })
1945
+
1946
+ succeeded = sum(1 for item in final_items if item.get("status") in {"success", "partial_success"})
1947
+ failed = len(final_items) - succeeded
1948
+ overall_status = "success" if failed == 0 else ("partial_success" if succeeded > 0 or any_write_executed else "failed")
1949
+ return {
1950
+ "status": overall_status,
1951
+ "mode": "multi_app",
1952
+ "total": len(apps),
1953
+ "succeeded": succeeded,
1954
+ "failed": failed,
1955
+ "created_app_keys": created_app_keys,
1956
+ "write_executed": any_write_executed,
1957
+ "safe_to_retry": not any_write_executed,
1958
+ "package_id": package_id,
1959
+ "publish_requested": publish,
1960
+ "apps": final_items,
1961
+ "normalized_args": normalized_args,
1962
+ "verification": {
1963
+ "all_apps_succeeded": failed == 0,
1964
+ "created_app_count": len(created_app_keys),
1965
+ },
1966
+ "request_id": None,
1967
+ "error_code": None if overall_status != "failed" else "MULTI_APP_SCHEMA_APPLY_FAILED",
1968
+ "recoverable": overall_status != "success",
1969
+ "message": "multi-app schema apply completed" if overall_status != "failed" else "multi-app schema apply failed",
1970
+ }
1573
1971
 
1574
1972
  def _app_schema_apply_once(
1575
1973
  self,
@@ -1590,6 +1988,14 @@ class AiBuilderTools(ToolBase):
1590
1988
  ) -> JSONObject:
1591
1989
  """执行内部辅助逻辑。"""
1592
1990
  effective_app_name = app_name or app_title
1991
+ icon_failure = _validate_workspace_icon_for_builder(
1992
+ tool_name="app_schema_apply",
1993
+ icon=icon,
1994
+ color=color,
1995
+ creating=not bool(str(app_key or "").strip()) and bool(create_if_missing),
1996
+ )
1997
+ if icon_failure is not None:
1998
+ return icon_failure
1593
1999
  plan_result = self._rewrite_plan_result_for_apply(
1594
2000
  result=self.app_schema_plan(
1595
2001
  profile=profile,
@@ -1685,7 +2091,7 @@ class AiBuilderTools(ToolBase):
1685
2091
  publish=publish,
1686
2092
  sections=sections,
1687
2093
  )
1688
- return self._retry_after_self_lock_release(
2094
+ result = self._retry_after_self_lock_release(
1689
2095
  profile=profile,
1690
2096
  result=result,
1691
2097
  retry_call=lambda: self._app_layout_apply_once(
@@ -1696,6 +2102,7 @@ class AiBuilderTools(ToolBase):
1696
2102
  sections=sections,
1697
2103
  ),
1698
2104
  )
2105
+ return _attach_builder_apply_envelope("app_layout_apply", result)
1699
2106
 
1700
2107
  def _app_layout_apply_once(self, *, profile: str, app_key: str, mode: str = "merge", publish: bool = True, sections: list[JSONObject]) -> JSONObject:
1701
2108
  """执行内部辅助逻辑。"""
@@ -1775,7 +2182,7 @@ class AiBuilderTools(ToolBase):
1775
2182
  nodes=nodes,
1776
2183
  transitions=transitions,
1777
2184
  )
1778
- return self._retry_after_self_lock_release(
2185
+ result = self._retry_after_self_lock_release(
1779
2186
  profile=profile,
1780
2187
  result=result,
1781
2188
  retry_call=lambda: self._app_flow_apply_once(
@@ -1787,6 +2194,7 @@ class AiBuilderTools(ToolBase):
1787
2194
  transitions=transitions,
1788
2195
  ),
1789
2196
  )
2197
+ return _attach_builder_apply_envelope("app_flow_apply", result)
1790
2198
 
1791
2199
  def _app_flow_apply_once(
1792
2200
  self,
@@ -1886,7 +2294,7 @@ class AiBuilderTools(ToolBase):
1886
2294
  patch_views=patch_views or [],
1887
2295
  remove_views=remove_views,
1888
2296
  )
1889
- return self._retry_after_self_lock_release(
2297
+ result = self._retry_after_self_lock_release(
1890
2298
  profile=profile,
1891
2299
  result=result,
1892
2300
  retry_call=lambda: self._app_views_apply_once(
@@ -1898,6 +2306,7 @@ class AiBuilderTools(ToolBase):
1898
2306
  patch_views=patch_views or [],
1899
2307
  ),
1900
2308
  )
2309
+ return _attach_builder_apply_envelope("app_views_apply", result)
1901
2310
 
1902
2311
  def _app_views_apply_once(
1903
2312
  self,
@@ -2053,7 +2462,7 @@ class AiBuilderTools(ToolBase):
2053
2462
  }
2054
2463
  )
2055
2464
  except ValidationError as exc:
2056
- return _visibility_validation_failure(
2465
+ return _attach_builder_apply_envelope("app_charts_apply", _visibility_validation_failure(
2057
2466
  str(exc),
2058
2467
  tool_name="app_charts_apply",
2059
2468
  exc=exc,
@@ -2067,14 +2476,14 @@ class AiBuilderTools(ToolBase):
2067
2476
  "reorder_chart_ids": [],
2068
2477
  },
2069
2478
  },
2070
- )
2479
+ ))
2071
2480
  normalized_args = request.model_dump(mode="json")
2072
- return _safe_tool_call(
2481
+ return _attach_builder_apply_envelope("app_charts_apply", _safe_tool_call(
2073
2482
  lambda: self._facade.chart_apply(profile=profile, request=request),
2074
2483
  error_code="CHART_APPLY_FAILED",
2075
2484
  normalized_args=normalized_args,
2076
2485
  suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
2077
- )
2486
+ ))
2078
2487
 
2079
2488
  @tool_cn_name("门户配置应用")
2080
2489
  def portal_apply(
@@ -2083,9 +2492,12 @@ class AiBuilderTools(ToolBase):
2083
2492
  profile: str,
2084
2493
  dash_key: str = "",
2085
2494
  dash_name: str = "",
2495
+ name: str = "",
2086
2496
  package_id: int | None = None,
2087
2497
  publish: bool = True,
2088
2498
  sections: list[JSONObject] | None = None,
2499
+ pages: list[JSONObject] | None = None,
2500
+ layout_preset: str = "",
2089
2501
  visibility: JSONObject | None = None,
2090
2502
  auth: JSONObject | None = None,
2091
2503
  icon: str | None = None,
@@ -2093,27 +2505,46 @@ class AiBuilderTools(ToolBase):
2093
2505
  hide_copyright: bool | None = None,
2094
2506
  dash_global_config: JSONObject | None = None,
2095
2507
  config: JSONObject | None = None,
2508
+ payload: JSONObject | None = None,
2096
2509
  ) -> JSONObject:
2097
2510
  """执行门户相关逻辑。"""
2511
+ request_payload: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
2512
+ if dash_key:
2513
+ request_payload["dash_key"] = dash_key
2514
+ if dash_name:
2515
+ request_payload["dash_name"] = dash_name
2516
+ elif name:
2517
+ request_payload["name"] = name
2518
+ if package_id is not None:
2519
+ request_payload["package_id"] = package_id
2520
+ if "publish" not in request_payload or publish is False:
2521
+ request_payload["publish"] = publish
2522
+ if sections:
2523
+ request_payload["sections"] = sections
2524
+ if pages:
2525
+ request_payload["pages"] = pages
2526
+ if layout_preset:
2527
+ request_payload["layout_preset"] = layout_preset
2528
+ if visibility is not None:
2529
+ request_payload["visibility"] = visibility
2530
+ if auth is not None:
2531
+ request_payload["auth"] = auth
2532
+ if icon is not None:
2533
+ request_payload["icon"] = icon
2534
+ if color is not None:
2535
+ request_payload["color"] = color
2536
+ if hide_copyright is not None:
2537
+ request_payload["hide_copyright"] = hide_copyright
2538
+ if dash_global_config is not None:
2539
+ request_payload["dash_global_config"] = dash_global_config
2540
+ if config:
2541
+ merged_config = dict(request_payload.get("config") or {}) if isinstance(request_payload.get("config"), dict) else {}
2542
+ merged_config.update(config)
2543
+ request_payload["config"] = merged_config
2098
2544
  try:
2099
- request = PortalApplyRequest.model_validate(
2100
- {
2101
- "dash_key": dash_key or None,
2102
- "dash_name": dash_name or None,
2103
- "package_tag_id": package_id,
2104
- "publish": publish,
2105
- "sections": sections or [],
2106
- "visibility": visibility,
2107
- "auth": auth,
2108
- "icon": icon,
2109
- "color": color,
2110
- "hide_copyright": hide_copyright,
2111
- "dash_global_config": dash_global_config,
2112
- "config": config or {},
2113
- }
2114
- )
2545
+ request = PortalApplyRequest.model_validate(request_payload)
2115
2546
  except ValidationError as exc:
2116
- return _visibility_validation_failure(
2547
+ return _attach_builder_apply_envelope("portal_apply", _visibility_validation_failure(
2117
2548
  str(exc),
2118
2549
  tool_name="portal_apply",
2119
2550
  exc=exc,
@@ -2124,6 +2555,7 @@ class AiBuilderTools(ToolBase):
2124
2555
  "dash_name": dash_name or "业务门户",
2125
2556
  "package_id": package_id or 1001,
2126
2557
  "publish": True,
2558
+ "layout_preset": "dashboard_2col",
2127
2559
  "sections": [
2128
2560
  {
2129
2561
  "title": "经营概览",
@@ -2133,15 +2565,24 @@ class AiBuilderTools(ToolBase):
2133
2565
  ],
2134
2566
  },
2135
2567
  },
2136
- )
2568
+ ))
2137
2569
  normalized_args = request.model_dump(mode="json")
2138
2570
  normalized_args["package_id"] = normalized_args.pop("package_tag_id", package_id)
2139
- return _publicize_package_fields(_safe_tool_call(
2571
+ icon_failure = _validate_workspace_icon_for_builder(
2572
+ tool_name="portal_apply",
2573
+ icon=str(request.icon or ""),
2574
+ color=str(request.color or ""),
2575
+ creating=not bool(str(request.dash_key or "").strip()),
2576
+ )
2577
+ if icon_failure is not None:
2578
+ return _attach_builder_apply_envelope("portal_apply", icon_failure)
2579
+ result = _publicize_package_fields(_safe_tool_call(
2140
2580
  lambda: self._facade.portal_apply(profile=profile, request=request),
2141
2581
  error_code="PORTAL_APPLY_FAILED",
2142
2582
  normalized_args=normalized_args,
2143
2583
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
2144
2584
  ))
2585
+ return _attach_builder_apply_envelope("portal_apply", result)
2145
2586
 
2146
2587
  @tool_cn_name("应用发布校验")
2147
2588
  def app_publish_verify(
@@ -2159,7 +2600,7 @@ class AiBuilderTools(ToolBase):
2159
2600
  normalized_args=normalized_args,
2160
2601
  suggested_next_call={"tool_name": "app_publish_verify", "arguments": {"profile": profile, **normalized_args}},
2161
2602
  ))
2162
- return _publicize_package_fields(self._retry_after_self_lock_release(
2603
+ result = _publicize_package_fields(self._retry_after_self_lock_release(
2163
2604
  profile=profile,
2164
2605
  result=result,
2165
2606
  retry_call=lambda: self._facade.app_publish_verify(
@@ -2168,6 +2609,7 @@ class AiBuilderTools(ToolBase):
2168
2609
  expected_package_tag_id=expected_package_id,
2169
2610
  ),
2170
2611
  ))
2612
+ return _attach_builder_apply_envelope("app_publish_verify", result)
2171
2613
 
2172
2614
  def _retry_after_self_lock_release(self, *, profile: str, result: JSONObject, retry_call) -> JSONObject:
2173
2615
  """执行内部辅助逻辑。"""
@@ -2286,6 +2728,128 @@ class AiBuilderTools(ToolBase):
2286
2728
  return rewritten
2287
2729
 
2288
2730
 
2731
+ def _multi_app_item_failure(index: int, item: object, error_code: str, message: str) -> JSONObject:
2732
+ app_name = None
2733
+ client_key = None
2734
+ app_key = None
2735
+ if isinstance(item, dict):
2736
+ app_name = str(item.get("app_name") or item.get("appTitle") or item.get("app_title") or item.get("title") or "").strip() or None
2737
+ client_key = str(item.get("client_key") or item.get("clientKey") or "").strip() or None
2738
+ app_key = str(item.get("app_key") or item.get("appKey") or "").strip() or None
2739
+ return {
2740
+ "index": index,
2741
+ "row_number": index + 1,
2742
+ "client_key": client_key,
2743
+ "app_name": app_name,
2744
+ "app_key": app_key,
2745
+ "status": "failed",
2746
+ "stage": "validate_item",
2747
+ "error_code": error_code,
2748
+ "message": message,
2749
+ "safe_to_retry": True,
2750
+ }
2751
+
2752
+
2753
+ def _compile_multi_app_schema_item_refs(item: JSONObject, client_key_to_app_key: dict[str, str]) -> JSONObject:
2754
+ compiled = deepcopy(item)
2755
+
2756
+ def visit(value):
2757
+ if isinstance(value, list):
2758
+ return [visit(entry) for entry in value]
2759
+ if not isinstance(value, dict):
2760
+ return value
2761
+ payload = {key: visit(entry) for key, entry in value.items()}
2762
+ ref = (
2763
+ payload.pop("target_app_ref", None)
2764
+ or payload.pop("targetAppRef", None)
2765
+ or payload.pop("target_app_client_key", None)
2766
+ or payload.pop("targetAppClientKey", None)
2767
+ )
2768
+ if ref is not None:
2769
+ ref_key = str(ref or "").strip()
2770
+ target_app_key = client_key_to_app_key.get(ref_key)
2771
+ if not target_app_key:
2772
+ raise ValueError(f"target_app_ref '{ref_key}' did not match any apps[].client_key")
2773
+ payload["target_app_key"] = target_app_key
2774
+ return payload
2775
+
2776
+ return visit(compiled)
2777
+
2778
+
2779
+ def _split_multi_app_initial_add_fields(item: JSONObject, *, is_new_app: bool) -> tuple[list[JSONObject], list[JSONObject]]:
2780
+ add_fields = _multi_app_list_value(item, "add_fields", "addFields")
2781
+ if not is_new_app:
2782
+ return [], add_fields
2783
+ initial: list[JSONObject] = []
2784
+ deferred: list[JSONObject] = []
2785
+ for field in add_fields:
2786
+ if _contains_multi_app_target_ref(field):
2787
+ deferred.append(field)
2788
+ else:
2789
+ initial.append(field)
2790
+ return initial, deferred
2791
+
2792
+
2793
+ def _compiled_multi_app_deferred_add_fields(compiled_item: JSONObject, existing_result: JSONObject) -> list[JSONObject]:
2794
+ deferred = existing_result.get("deferred_add_fields")
2795
+ if not isinstance(deferred, list):
2796
+ return list(compiled_item.get("add_fields") or [])
2797
+ deferred_names = {str(item.get("name") or item.get("title") or item.get("label") or "").strip() for item in deferred if isinstance(item, dict)}
2798
+ if not deferred_names:
2799
+ return []
2800
+ return [
2801
+ deepcopy(field)
2802
+ for field in list(compiled_item.get("add_fields") or [])
2803
+ if isinstance(field, dict)
2804
+ and str(field.get("name") or field.get("title") or field.get("label") or "").strip() in deferred_names
2805
+ ]
2806
+
2807
+
2808
+ def _multi_app_list_value(item: JSONObject, *keys: str) -> list[JSONObject]:
2809
+ for key in keys:
2810
+ value = item.get(key)
2811
+ if isinstance(value, list):
2812
+ return [deepcopy(entry) for entry in value if isinstance(entry, dict)]
2813
+ return []
2814
+
2815
+
2816
+ def _contains_multi_app_target_ref(value: object) -> bool:
2817
+ if isinstance(value, list):
2818
+ return any(_contains_multi_app_target_ref(item) for item in value)
2819
+ if not isinstance(value, dict):
2820
+ return False
2821
+ for key, entry in value.items():
2822
+ if key in {"target_app_ref", "targetAppRef", "target_app_client_key", "targetAppClientKey"}:
2823
+ return True
2824
+ if _contains_multi_app_target_ref(entry):
2825
+ return True
2826
+ return False
2827
+
2828
+
2829
+ def _merge_schema_field_diffs(*diffs: object) -> JSONObject:
2830
+ merged: JSONObject = {"added": [], "updated": [], "removed": []}
2831
+ for diff in diffs:
2832
+ if not isinstance(diff, dict):
2833
+ continue
2834
+ for key in ("added", "updated", "removed"):
2835
+ values = diff.get(key)
2836
+ if not isinstance(values, list):
2837
+ continue
2838
+ for value in values:
2839
+ if value not in merged[key]:
2840
+ merged[key].append(value)
2841
+ return merged
2842
+
2843
+
2844
+ def _schema_apply_result_has_write(result: JSONObject) -> bool:
2845
+ if bool(result.get("created")) or bool(result.get("published")) or bool(result.get("app_base_updated")):
2846
+ return True
2847
+ field_diff = result.get("field_diff")
2848
+ if isinstance(field_diff, dict):
2849
+ return any(bool(field_diff.get(key)) for key in ("added", "updated", "removed"))
2850
+ return False
2851
+
2852
+
2289
2853
  def _validation_failure(
2290
2854
  detail: str,
2291
2855
  *,
@@ -2366,20 +2930,34 @@ def _visibility_validation_failure(
2366
2930
  return result
2367
2931
 
2368
2932
 
2369
- def _config_failure(*, tool_name: str, message: str, fix_hint: str) -> JSONObject:
2933
+ def _config_failure(
2934
+ *,
2935
+ tool_name: str,
2936
+ message: str,
2937
+ fix_hint: str,
2938
+ error_code: str = "CONFIG_ERROR",
2939
+ details: JSONObject | None = None,
2940
+ allowed_values: JSONObject | None = None,
2941
+ ) -> JSONObject:
2370
2942
  contract = _BUILDER_TOOL_CONTRACTS.get(tool_name or "")
2943
+ public_allowed_values = deepcopy(contract.get("allowed_values", {})) if isinstance(contract, dict) else {}
2944
+ if allowed_values:
2945
+ public_allowed_values.update(deepcopy(allowed_values))
2946
+ public_details: JSONObject = {
2947
+ "fix_hint": fix_hint,
2948
+ "allowed_keys": deepcopy(contract.get("allowed_keys", [])) if isinstance(contract, dict) else [],
2949
+ }
2950
+ if details:
2951
+ public_details.update(deepcopy(details))
2371
2952
  return {
2372
2953
  "status": "failed",
2373
- "error_code": "CONFIG_ERROR",
2954
+ "error_code": error_code,
2374
2955
  "recoverable": True,
2375
2956
  "message": message,
2376
2957
  "normalized_args": {},
2377
2958
  "missing_fields": [],
2378
- "allowed_values": deepcopy(contract.get("allowed_values", {})) if isinstance(contract, dict) else {},
2379
- "details": {
2380
- "fix_hint": fix_hint,
2381
- "allowed_keys": deepcopy(contract.get("allowed_keys", [])) if isinstance(contract, dict) else [],
2382
- },
2959
+ "allowed_values": public_allowed_values,
2960
+ "details": public_details,
2383
2961
  "suggested_next_call": None,
2384
2962
  "request_id": None,
2385
2963
  "backend_code": None,
@@ -2389,6 +2967,52 @@ def _config_failure(*, tool_name: str, message: str, fix_hint: str) -> JSONObjec
2389
2967
  }
2390
2968
 
2391
2969
 
2970
+ def _workspace_icon_config_failure(
2971
+ *,
2972
+ tool_name: str,
2973
+ error_code: str,
2974
+ message: str,
2975
+ details: JSONObject,
2976
+ ) -> JSONObject:
2977
+ return _config_failure(
2978
+ tool_name=tool_name,
2979
+ error_code=error_code,
2980
+ message=message,
2981
+ fix_hint="Call `qingflow --json builder icon catalog`, choose an explicit non-template icon and color, then retry.",
2982
+ details=details,
2983
+ allowed_values={
2984
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
2985
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
2986
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
2987
+ },
2988
+ )
2989
+
2990
+
2991
+ def _validate_workspace_icon_for_builder(
2992
+ *,
2993
+ tool_name: str,
2994
+ icon: str | None,
2995
+ color: str | None,
2996
+ creating: bool,
2997
+ ) -> JSONObject | None:
2998
+ if not creating and not (str(icon or "").strip() or str(color or "").strip()):
2999
+ return None
3000
+ ok, error_code, message, details = validate_workspace_icon_choice(
3001
+ icon=icon,
3002
+ color=color,
3003
+ require_explicit=creating,
3004
+ disallow_generic=creating,
3005
+ )
3006
+ if ok:
3007
+ return None
3008
+ return _workspace_icon_config_failure(
3009
+ tool_name=tool_name,
3010
+ error_code=error_code or "WORKSPACE_ICON_INVALID",
3011
+ message=message or "invalid workspace icon configuration",
3012
+ details=details,
3013
+ )
3014
+
3015
+
2392
3016
  def _safe_tool_call(
2393
3017
  call,
2394
3018
  *,
@@ -2437,6 +3061,7 @@ def _publicize_package_fields(value):
2437
3061
  "tag_ids_after": "package_ids_after",
2438
3062
  "tag_name": "package_name",
2439
3063
  "tag_icon": "icon",
3064
+ "iconConfig": "icon_config",
2440
3065
  "package_tag_id": "package_id",
2441
3066
  "package_tag_ids": "package_ids",
2442
3067
  "expected_package_tag_id": "expected_package_id",
@@ -2448,6 +3073,742 @@ def _publicize_package_fields(value):
2448
3073
  return public
2449
3074
 
2450
3075
 
3076
+ def _builder_contract_with_apply_output(tool_name: str, contract: JSONObject) -> JSONObject:
3077
+ public = deepcopy(contract)
3078
+ if tool_name not in BUILDER_APPLY_TOOL_NAMES:
3079
+ return public
3080
+ notes = public.setdefault("execution_notes", [])
3081
+ if isinstance(notes, list):
3082
+ note = "apply/write output includes schema_version, operation, summary, and resources[]; UI and agents should read resources[].id/key/name first and use legacy fields only for compatibility/debugging"
3083
+ if note not in notes:
3084
+ notes.append(note)
3085
+ public["output_contract"] = {
3086
+ "schema_version": BUILDER_APPLY_SCHEMA_VERSION,
3087
+ "preferred_ui_fields": ["operation", "summary", "resources"],
3088
+ "resource_fields": ["resource_type", "operation", "status", "id", "key", "name", "ids", "parent", "icon_config", "error_code", "message"],
3089
+ "legacy_fields_preserved": True,
3090
+ }
3091
+ return public
3092
+
3093
+
3094
+ def _attach_builder_apply_envelope(tool_name: str, payload: JSONObject) -> JSONObject:
3095
+ if not isinstance(payload, dict):
3096
+ return payload
3097
+ resources = _builder_apply_resources(tool_name, payload)
3098
+ payload["schema_version"] = BUILDER_APPLY_SCHEMA_VERSION
3099
+ payload["operation"] = tool_name
3100
+ payload["resources"] = resources
3101
+ payload["summary"] = _builder_apply_summary(payload, resources)
3102
+ return payload
3103
+
3104
+
3105
+ def _builder_apply_summary(payload: JSONObject, resources: list[JSONObject]) -> JSONObject:
3106
+ status = str(payload.get("status") or "")
3107
+ failed = sum(1 for item in resources if str(item.get("status") or "") == "failed")
3108
+ created = sum(1 for item in resources if str(item.get("operation") or "") == "created" and str(item.get("status") or "") != "failed")
3109
+ removed = sum(1 for item in resources if str(item.get("operation") or "") == "removed" and str(item.get("status") or "") != "failed")
3110
+ updated = sum(
3111
+ 1
3112
+ for item in resources
3113
+ if str(item.get("status") or "") != "failed"
3114
+ and str(item.get("operation") or "") in {"updated", "layout_updated", "workflow_updated", "verified", "published"}
3115
+ )
3116
+ published_value = payload.get("published")
3117
+ if published_value is None:
3118
+ publish_requested = payload.get("publish_requested")
3119
+ published_value = bool(publish_requested) and status in {"success", "partial_success"}
3120
+ verified_value = payload.get("verified")
3121
+ if verified_value is None:
3122
+ verification = payload.get("verification")
3123
+ verified_value = status == "success" and failed == 0 and _builder_verification_truthy(verification)
3124
+ summary: JSONObject = {
3125
+ "total": len(resources),
3126
+ "created": created,
3127
+ "updated": updated,
3128
+ "removed": removed,
3129
+ "failed": failed,
3130
+ "published": bool(published_value),
3131
+ "verified": bool(verified_value),
3132
+ }
3133
+ if "write_executed" in payload:
3134
+ summary["write_executed"] = bool(payload.get("write_executed"))
3135
+ if "safe_to_retry" in payload:
3136
+ summary["safe_to_retry"] = bool(payload.get("safe_to_retry"))
3137
+ return summary
3138
+
3139
+
3140
+ def _builder_verification_truthy(value: object) -> bool:
3141
+ if value is None:
3142
+ return True
3143
+ if isinstance(value, bool):
3144
+ return value
3145
+ if isinstance(value, dict):
3146
+ booleans = [item for item in value.values() if isinstance(item, bool)]
3147
+ return all(booleans) if booleans else True
3148
+ return True
3149
+
3150
+
3151
+ def _builder_apply_resources(tool_name: str, payload: JSONObject) -> list[JSONObject]:
3152
+ resources: list[JSONObject]
3153
+ if tool_name == "package_apply":
3154
+ resources = _builder_package_resources(payload)
3155
+ elif tool_name == "app_schema_apply":
3156
+ resources = _builder_schema_resources(payload)
3157
+ elif tool_name == "app_layout_apply":
3158
+ resources = [_builder_app_resource(payload, operation="layout_updated")]
3159
+ elif tool_name == "app_flow_apply":
3160
+ resources = [_builder_app_resource(payload, operation="workflow_updated")]
3161
+ elif tool_name == "app_views_apply":
3162
+ resources = _builder_view_resources(payload)
3163
+ elif tool_name == "app_charts_apply":
3164
+ resources = _builder_chart_resources(payload)
3165
+ elif tool_name == "portal_apply":
3166
+ resources = _builder_portal_resources(payload)
3167
+ elif tool_name == "app_custom_buttons_apply":
3168
+ resources = _builder_button_resources(payload)
3169
+ elif tool_name == "app_associated_resources_apply":
3170
+ resources = _builder_associated_resource_resources(payload)
3171
+ elif tool_name == "app_publish_verify":
3172
+ resources = [_builder_app_resource(payload, operation="verified")]
3173
+ else:
3174
+ resources = []
3175
+ if not resources and _builder_status(payload, "") == "failed" and _builder_apply_tool_is_app_scoped(tool_name):
3176
+ app_key = _builder_payload_app_key(payload)
3177
+ if app_key not in (None, ""):
3178
+ resources = [_builder_app_resource(payload, operation="failed")]
3179
+ return resources
3180
+
3181
+
3182
+ def _builder_apply_tool_is_app_scoped(tool_name: str) -> bool:
3183
+ return tool_name in {
3184
+ "app_schema_apply",
3185
+ "app_layout_apply",
3186
+ "app_flow_apply",
3187
+ "app_views_apply",
3188
+ "app_custom_buttons_apply",
3189
+ "app_associated_resources_apply",
3190
+ "app_charts_apply",
3191
+ "app_publish_verify",
3192
+ }
3193
+
3194
+
3195
+ def _builder_status(payload_or_item: JSONObject, fallback: str = "success") -> str:
3196
+ status = str(payload_or_item.get("status") or fallback or "success")
3197
+ return status
3198
+
3199
+
3200
+ def _builder_operation(value: object, fallback: str = "updated") -> str:
3201
+ raw = str(value or fallback or "updated").strip()
3202
+ mapping = {
3203
+ "create": "created",
3204
+ "created": "created",
3205
+ "add": "created",
3206
+ "update": "updated",
3207
+ "updated": "updated",
3208
+ "patch": "updated",
3209
+ "remove": "removed",
3210
+ "removed": "removed",
3211
+ "delete": "removed",
3212
+ "deleted": "removed",
3213
+ "unchanged": "unchanged",
3214
+ "failed": "failed",
3215
+ }
3216
+ return mapping.get(raw, raw)
3217
+
3218
+
3219
+ def _builder_parent(resource_type: str, *, key: object = None, name: object = None, id_value: object = None) -> JSONObject:
3220
+ return {
3221
+ "resource_type": resource_type,
3222
+ "id": id_value,
3223
+ "key": str(key) if key not in (None, "") else None,
3224
+ "name": str(name) if name not in (None, "") else None,
3225
+ }
3226
+
3227
+
3228
+ def _builder_app_parent(payload: JSONObject) -> JSONObject | None:
3229
+ app_key = payload.get("app_key") or payload.get("appKey")
3230
+ app_name = payload.get("app_name_after") or payload.get("app_name") or payload.get("appTitle") or payload.get("app_title")
3231
+ if app_key in (None, "") and app_name in (None, ""):
3232
+ return None
3233
+ return _builder_parent("app", key=app_key, name=app_name)
3234
+
3235
+
3236
+ def _builder_icon_config(raw_icon: object = None, *, icon: object = None, color: object = None) -> JSONObject | None:
3237
+ raw = str(raw_icon).strip() if raw_icon not in (None, "") else ""
3238
+ explicit_icon = str(icon).strip() if icon not in (None, "") else ""
3239
+ explicit_color = str(color).strip() if color not in (None, "") else ""
3240
+ if raw:
3241
+ if raw.startswith("{") and raw.endswith("}"):
3242
+ config = workspace_icon_config(raw)
3243
+ else:
3244
+ config = {
3245
+ "icon_name": normalize_workspace_icon_name(raw),
3246
+ "icon_color": explicit_color or None,
3247
+ "icon_text": None,
3248
+ "raw": raw,
3249
+ }
3250
+ if any(config.get(key) for key in ("icon_name", "icon_color", "icon_text", "raw")):
3251
+ return config
3252
+ if explicit_icon or explicit_color:
3253
+ return {
3254
+ "icon_name": normalize_workspace_icon_name(explicit_icon) if explicit_icon else None,
3255
+ "icon_color": explicit_color or None,
3256
+ "icon_text": None,
3257
+ "raw": None,
3258
+ }
3259
+ return None
3260
+
3261
+
3262
+ def _builder_container_icon_config(container: object, *, raw_keys: tuple[str, ...], icon_keys: tuple[str, ...] = ("icon",), color_keys: tuple[str, ...] = ("color",)) -> JSONObject | None:
3263
+ if not isinstance(container, dict):
3264
+ return None
3265
+ raw_icon = next((container.get(key) for key in raw_keys if container.get(key) not in (None, "")), None)
3266
+ icon = next((container.get(key) for key in icon_keys if container.get(key) not in (None, "")), None)
3267
+ color = next((container.get(key) for key in color_keys if container.get(key) not in (None, "")), None)
3268
+ return _builder_icon_config(raw_icon, icon=icon, color=color)
3269
+
3270
+
3271
+ def _builder_resource(
3272
+ *,
3273
+ resource_type: str,
3274
+ operation: str,
3275
+ status: str,
3276
+ id_value: object = None,
3277
+ key: object = None,
3278
+ name: object = None,
3279
+ ids: JSONObject | None = None,
3280
+ parent: JSONObject | None = None,
3281
+ icon_config: JSONObject | None = None,
3282
+ error_code: object = None,
3283
+ message: object = None,
3284
+ ) -> JSONObject:
3285
+ resource = {
3286
+ "resource_type": resource_type,
3287
+ "operation": operation,
3288
+ "status": status,
3289
+ "id": id_value,
3290
+ "key": str(key) if key not in (None, "") else None,
3291
+ "name": str(name) if name not in (None, "") else None,
3292
+ "ids": ids or {},
3293
+ "parent": parent,
3294
+ "error_code": str(error_code) if error_code not in (None, "") else None,
3295
+ "message": str(message) if message not in (None, "") else None,
3296
+ }
3297
+ if icon_config:
3298
+ resource["icon_config"] = icon_config
3299
+ return resource
3300
+
3301
+
3302
+ def _builder_app_resource(payload: JSONObject, *, operation: str) -> JSONObject:
3303
+ status = _builder_status(payload, "success")
3304
+ if status == "failed":
3305
+ operation = "failed"
3306
+ app_key = _builder_payload_app_key(payload)
3307
+ app_name = _builder_payload_app_name(payload)
3308
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3309
+ icon_config = (
3310
+ _builder_container_icon_config(payload, raw_keys=("app_icon", "appIcon"))
3311
+ or _builder_container_icon_config(normalized_args, raw_keys=("app_icon", "appIcon", "icon"))
3312
+ )
3313
+ return _builder_resource(
3314
+ resource_type="app",
3315
+ operation=operation,
3316
+ status=status,
3317
+ key=app_key,
3318
+ name=app_name,
3319
+ ids={"app_key": app_key} if app_key not in (None, "") else {},
3320
+ icon_config=icon_config,
3321
+ error_code=payload.get("error_code"),
3322
+ message=payload.get("message") if status == "failed" else None,
3323
+ )
3324
+
3325
+
3326
+ def _builder_payload_app_key(payload: JSONObject) -> object:
3327
+ return _builder_payload_identity_value(payload, ("app_key", "appKey"))
3328
+
3329
+
3330
+ def _builder_payload_app_name(payload: JSONObject) -> object:
3331
+ return _builder_payload_identity_value(payload, ("app_name_after", "app_name", "appName", "appTitle", "app_title", "name", "title"))
3332
+
3333
+
3334
+ def _builder_payload_identity_value(payload: JSONObject, keys: tuple[str, ...]) -> object:
3335
+ for key in keys:
3336
+ value = payload.get(key)
3337
+ if value not in (None, ""):
3338
+ return value
3339
+ for container_key in ("normalized_args", "canonical_arguments"):
3340
+ container = payload.get(container_key)
3341
+ if isinstance(container, dict):
3342
+ for key in keys:
3343
+ value = container.get(key)
3344
+ if value not in (None, ""):
3345
+ return value
3346
+ details = payload.get("details")
3347
+ if isinstance(details, dict):
3348
+ for container_key in ("normalized_args", "canonical_arguments"):
3349
+ container = details.get(container_key)
3350
+ if isinstance(container, dict):
3351
+ for key in keys:
3352
+ value = container.get(key)
3353
+ if value not in (None, ""):
3354
+ return value
3355
+ return None
3356
+
3357
+
3358
+ def _builder_package_resources(payload: JSONObject) -> list[JSONObject]:
3359
+ package_id = payload.get("package_id") or payload.get("id")
3360
+ package_name = payload.get("package_name") or payload.get("name")
3361
+ status = _builder_status(payload, "success")
3362
+ operation = "failed" if status == "failed" else ("created" if bool(payload.get("created")) else "updated")
3363
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3364
+ icon_config = (
3365
+ _builder_container_icon_config(payload, raw_keys=("icon", "tagIcon", "tag_icon"))
3366
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "tagIcon", "tag_icon"))
3367
+ )
3368
+ return [
3369
+ _builder_resource(
3370
+ resource_type="package",
3371
+ operation=operation,
3372
+ status=status,
3373
+ id_value=package_id,
3374
+ key=str(package_id) if package_id not in (None, "") else None,
3375
+ name=package_name,
3376
+ ids={"package_id": package_id} if package_id not in (None, "") else {},
3377
+ icon_config=icon_config,
3378
+ error_code=payload.get("error_code"),
3379
+ message=payload.get("message") if status == "failed" else None,
3380
+ )
3381
+ ]
3382
+
3383
+
3384
+ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3385
+ if payload.get("mode") == "multi_app" and isinstance(payload.get("apps"), list):
3386
+ resources: list[JSONObject] = []
3387
+ package_id = payload.get("package_id")
3388
+ package_parent = _builder_parent("package", id_value=package_id, key=package_id) if package_id not in (None, "") else None
3389
+ for item in payload.get("apps") or []:
3390
+ if not isinstance(item, dict):
3391
+ continue
3392
+ status = _builder_status(item, "success")
3393
+ operation = "failed" if status == "failed" else ("created" if bool(item.get("created")) else "updated")
3394
+ parent = _builder_parent("app", key=item.get("app_key"), name=item.get("app_name"))
3395
+ icon_config = (
3396
+ _builder_container_icon_config(item, raw_keys=("app_icon", "appIcon", "icon"))
3397
+ or _builder_container_icon_config(item.get("shell_result"), raw_keys=("app_icon", "appIcon", "icon"))
3398
+ )
3399
+ resources.append(
3400
+ _builder_resource(
3401
+ resource_type="app",
3402
+ operation=operation,
3403
+ status=status,
3404
+ key=item.get("app_key"),
3405
+ name=item.get("app_name"),
3406
+ ids={
3407
+ **({"app_key": item.get("app_key")} if item.get("app_key") else {}),
3408
+ **({"package_id": package_id} if package_id not in (None, "") else {}),
3409
+ },
3410
+ parent=package_parent,
3411
+ icon_config=icon_config,
3412
+ error_code=item.get("error_code"),
3413
+ message=item.get("message") if status == "failed" else None,
3414
+ )
3415
+ )
3416
+ resources.extend(_builder_field_resources(item.get("field_diff_details") or item.get("field_diff"), parent=parent))
3417
+ return resources
3418
+
3419
+ status = _builder_status(payload, "success")
3420
+ operation = "failed" if status == "failed" else ("created" if bool(payload.get("created")) else "updated")
3421
+ app_key = payload.get("app_key")
3422
+ app_name = payload.get("app_name_after") or payload.get("app_name")
3423
+ parent = _builder_parent("app", key=app_key, name=app_name)
3424
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3425
+ icon_config = (
3426
+ _builder_container_icon_config(payload, raw_keys=("app_icon", "appIcon", "icon"))
3427
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "app_icon", "appIcon"))
3428
+ )
3429
+ resources = [
3430
+ _builder_resource(
3431
+ resource_type="app",
3432
+ operation=operation,
3433
+ status=status,
3434
+ key=app_key,
3435
+ name=app_name,
3436
+ ids={"app_key": app_key} if app_key else {},
3437
+ icon_config=icon_config,
3438
+ error_code=payload.get("error_code"),
3439
+ message=payload.get("message") if status == "failed" else None,
3440
+ )
3441
+ ]
3442
+ resources.extend(_builder_field_resources(payload.get("field_diff_details") or payload.get("field_diff"), parent=parent))
3443
+ return resources
3444
+
3445
+
3446
+ def _builder_field_resources(field_diff: object, *, parent: JSONObject | None) -> list[JSONObject]:
3447
+ if not isinstance(field_diff, dict):
3448
+ return []
3449
+ resources: list[JSONObject] = []
3450
+ for key, operation in (("added", "created"), ("updated", "updated"), ("removed", "removed")):
3451
+ for field in field_diff.get(key) or []:
3452
+ if isinstance(field, dict):
3453
+ name = field.get("name") or field.get("title") or field.get("field_name") or field.get("queTitle")
3454
+ field_id = field.get("field_id") or field.get("queId")
3455
+ que_id = field.get("que_id") or field.get("queId")
3456
+ else:
3457
+ name = field
3458
+ field_id = None
3459
+ que_id = None
3460
+ resource_id = que_id if que_id not in (None, "") else field_id
3461
+ resources.append(
3462
+ _builder_resource(
3463
+ resource_type="field",
3464
+ operation=operation,
3465
+ status="success",
3466
+ id_value=resource_id,
3467
+ key=field_id if field_id not in (None, "") else name,
3468
+ name=name,
3469
+ ids={
3470
+ **({"field_id": field_id} if field_id not in (None, "") else {}),
3471
+ **({"que_id": que_id} if que_id not in (None, "") else {}),
3472
+ **({"app_key": parent.get("key")} if isinstance(parent, dict) and parent.get("key") else {}),
3473
+ },
3474
+ parent=parent,
3475
+ )
3476
+ )
3477
+ return resources
3478
+
3479
+
3480
+ def _builder_view_resources(payload: JSONObject) -> list[JSONObject]:
3481
+ diff = payload.get("views_diff") if isinstance(payload.get("views_diff"), dict) else {}
3482
+ verification_by_name = _builder_view_verification_by_name(payload)
3483
+ parent = _builder_app_parent(payload)
3484
+ resources: list[JSONObject] = []
3485
+ for key, operation in (("created", "created"), ("updated", "updated"), ("removed", "removed")):
3486
+ for item in diff.get(key) or []:
3487
+ name, view_key, status, error_code, message = _builder_view_identity(item, verification_by_name)
3488
+ resources.append(
3489
+ _builder_resource(
3490
+ resource_type="view",
3491
+ operation=operation,
3492
+ status=status,
3493
+ key=view_key,
3494
+ name=name,
3495
+ ids={
3496
+ **({"view_key": view_key} if view_key else {}),
3497
+ **({"app_key": payload.get("app_key")} if payload.get("app_key") else {}),
3498
+ },
3499
+ parent=parent,
3500
+ error_code=error_code,
3501
+ message=message,
3502
+ )
3503
+ )
3504
+ for item in diff.get("failed") or []:
3505
+ name, view_key, _status, error_code, message = _builder_view_identity(item, verification_by_name)
3506
+ resources.append(
3507
+ _builder_resource(
3508
+ resource_type="view",
3509
+ operation="failed",
3510
+ status="failed",
3511
+ key=view_key,
3512
+ name=name,
3513
+ ids={
3514
+ **({"view_key": view_key} if view_key else {}),
3515
+ **({"app_key": payload.get("app_key")} if payload.get("app_key") else {}),
3516
+ },
3517
+ parent=parent,
3518
+ error_code=error_code,
3519
+ message=message,
3520
+ )
3521
+ )
3522
+ return resources
3523
+
3524
+
3525
+ def _builder_view_verification_by_name(payload: JSONObject) -> dict[str, JSONObject]:
3526
+ verification = payload.get("verification")
3527
+ by_view = verification.get("by_view") if isinstance(verification, dict) else None
3528
+ result: dict[str, JSONObject] = {}
3529
+ if isinstance(by_view, list):
3530
+ for item in by_view:
3531
+ if isinstance(item, dict):
3532
+ name = str(item.get("name") or "").strip()
3533
+ if name:
3534
+ result[name] = item
3535
+ return result
3536
+
3537
+
3538
+ def _builder_view_identity(item: object, verification_by_name: dict[str, JSONObject]) -> tuple[str | None, str | None, str, object, object]:
3539
+ verification: JSONObject | None = None
3540
+ if isinstance(item, dict):
3541
+ name = item.get("name") or item.get("view_name") or item.get("viewName")
3542
+ view_key = item.get("view_key") or item.get("viewKey")
3543
+ status = str(item.get("status") or "success")
3544
+ error_code = item.get("error_code")
3545
+ message = item.get("message")
3546
+ else:
3547
+ name = str(item) if item not in (None, "") else None
3548
+ view_key = None
3549
+ status = "success"
3550
+ error_code = None
3551
+ message = None
3552
+ if name and not view_key:
3553
+ verification = verification_by_name.get(str(name))
3554
+ if isinstance(verification, dict):
3555
+ view_key = verification.get("view_key") or verification.get("viewKey")
3556
+ if not view_key:
3557
+ matching = verification.get("matching_view_keys")
3558
+ if isinstance(matching, list) and matching:
3559
+ view_key = matching[0]
3560
+ if name and verification is None:
3561
+ verification = verification_by_name.get(str(name))
3562
+ if isinstance(verification, dict):
3563
+ verification_status = str(verification.get("status") or "").strip()
3564
+ if verification_status in {"removed", "readback_pending"}:
3565
+ status = "readback_pending"
3566
+ if verification_status == "removed":
3567
+ status = "removed"
3568
+ error_code = error_code or verification.get("error_code")
3569
+ message = message or verification.get("message")
3570
+ return (
3571
+ str(name) if name not in (None, "") else None,
3572
+ str(view_key) if view_key not in (None, "") else None,
3573
+ status,
3574
+ error_code,
3575
+ message,
3576
+ )
3577
+
3578
+
3579
+ def _builder_chart_resources(payload: JSONObject) -> list[JSONObject]:
3580
+ parent = _builder_app_parent(payload)
3581
+ resources: list[JSONObject] = []
3582
+ for item in payload.get("chart_results") or []:
3583
+ if not isinstance(item, dict):
3584
+ continue
3585
+ status = str(item.get("status") or "success")
3586
+ operation = _builder_operation(item.get("operation") or status, fallback="updated")
3587
+ if status == "failed":
3588
+ operation = "failed"
3589
+ resource_status = "failed" if status == "failed" else ("readback_pending" if status == "readback_pending" else "success")
3590
+ chart_id = item.get("chart_id") or item.get("chartId")
3591
+ chart_key = item.get("chart_key") or item.get("chartKey")
3592
+ resources.append(
3593
+ _builder_resource(
3594
+ resource_type="chart",
3595
+ operation=operation,
3596
+ status=resource_status,
3597
+ id_value=chart_id,
3598
+ key=chart_key or chart_id,
3599
+ name=item.get("name") or item.get("chart_name") or item.get("chartName"),
3600
+ ids={
3601
+ **({"chart_id": chart_id} if chart_id not in (None, "") else {}),
3602
+ **({"chart_key": chart_key} if chart_key else {}),
3603
+ **({"app_key": payload.get("app_key")} if payload.get("app_key") else {}),
3604
+ **({"chart_type": item.get("chart_type")} if item.get("chart_type") else {}),
3605
+ },
3606
+ parent=parent,
3607
+ error_code=item.get("error_code"),
3608
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3609
+ )
3610
+ )
3611
+ return resources
3612
+
3613
+
3614
+ def _builder_portal_resources(payload: JSONObject) -> list[JSONObject]:
3615
+ status = _builder_status(payload, "success")
3616
+ draft_result = payload.get("draft_result") if isinstance(payload.get("draft_result"), dict) else {}
3617
+ live_result = payload.get("live_result") if isinstance(payload.get("live_result"), dict) else {}
3618
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3619
+ dash_key = (
3620
+ payload.get("dash_key")
3621
+ or payload.get("dashKey")
3622
+ or draft_result.get("dashKey")
3623
+ or draft_result.get("dash_key")
3624
+ or live_result.get("dashKey")
3625
+ or live_result.get("dash_key")
3626
+ )
3627
+ dash_name = (
3628
+ payload.get("dash_name")
3629
+ or payload.get("dashName")
3630
+ or payload.get("name")
3631
+ or draft_result.get("dashName")
3632
+ or draft_result.get("dash_name")
3633
+ or draft_result.get("name")
3634
+ or live_result.get("dashName")
3635
+ or live_result.get("dash_name")
3636
+ or live_result.get("name")
3637
+ or normalized_args.get("dash_name")
3638
+ or normalized_args.get("dashName")
3639
+ )
3640
+ package_id = payload.get("package_id") or normalized_args.get("package_id") or normalized_args.get("package_tag_id")
3641
+ if package_id in (None, "") and isinstance(draft_result.get("tags"), list) and draft_result.get("tags"):
3642
+ first_tag = draft_result.get("tags")[0]
3643
+ if isinstance(first_tag, dict):
3644
+ package_id = first_tag.get("tagId") or first_tag.get("tag_id") or first_tag.get("id")
3645
+ operation = "failed" if status == "failed" else ("created" if bool(payload.get("created")) else "updated")
3646
+ parent = None
3647
+ if package_id:
3648
+ parent = _builder_parent("package", id_value=package_id, key=package_id)
3649
+ icon_config = (
3650
+ _builder_container_icon_config(payload, raw_keys=("dash_icon", "dashIcon", "icon"))
3651
+ or _builder_container_icon_config(draft_result, raw_keys=("dashIcon", "dash_icon", "icon"))
3652
+ or _builder_container_icon_config(live_result, raw_keys=("dashIcon", "dash_icon", "icon"))
3653
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "dash_icon", "dashIcon"))
3654
+ )
3655
+ return [
3656
+ _builder_resource(
3657
+ resource_type="portal",
3658
+ operation=operation,
3659
+ status=status,
3660
+ key=dash_key,
3661
+ name=dash_name,
3662
+ ids={
3663
+ **({"dash_key": dash_key} if dash_key else {}),
3664
+ **({"package_id": package_id} if package_id else {}),
3665
+ },
3666
+ parent=parent,
3667
+ icon_config=icon_config,
3668
+ error_code=payload.get("error_code"),
3669
+ message=payload.get("message") if status == "failed" else None,
3670
+ )
3671
+ ]
3672
+
3673
+
3674
+ def _builder_button_resources(payload: JSONObject) -> list[JSONObject]:
3675
+ parent = _builder_app_parent(payload)
3676
+ resources: list[JSONObject] = []
3677
+ for key, operation in (("created", "created"), ("updated", "updated"), ("removed", "removed"), ("failed", "failed")):
3678
+ for item in payload.get(key) or []:
3679
+ if not isinstance(item, dict):
3680
+ continue
3681
+ status = "failed" if operation == "failed" or item.get("status") == "failed" else str(item.get("status") or "success")
3682
+ button_id = item.get("button_id") or item.get("buttonId")
3683
+ resources.append(
3684
+ _builder_resource(
3685
+ resource_type="button",
3686
+ operation=operation if operation != "failed" else "failed",
3687
+ status=status,
3688
+ id_value=button_id,
3689
+ key=button_id,
3690
+ name=item.get("button_text") or item.get("buttonText"),
3691
+ ids={
3692
+ **({"button_id": button_id} if button_id not in (None, "") else {}),
3693
+ **({"app_key": payload.get("app_key")} if payload.get("app_key") else {}),
3694
+ },
3695
+ parent=parent,
3696
+ error_code=item.get("error_code"),
3697
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3698
+ )
3699
+ )
3700
+ for item in payload.get("view_configs") or []:
3701
+ if isinstance(item, dict):
3702
+ status = str(item.get("status") or "success")
3703
+ view_key = item.get("view_key") or item.get("viewKey")
3704
+ resources.append(
3705
+ _builder_resource(
3706
+ resource_type="button_binding",
3707
+ operation="updated" if status != "failed" else "failed",
3708
+ status=status,
3709
+ key=view_key,
3710
+ name=item.get("view_name") or item.get("viewName") or view_key,
3711
+ ids={
3712
+ **({"view_key": view_key} if view_key else {}),
3713
+ **({"app_key": payload.get("app_key")} if payload.get("app_key") else {}),
3714
+ },
3715
+ parent=parent,
3716
+ error_code=item.get("error_code"),
3717
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3718
+ )
3719
+ )
3720
+ return resources
3721
+
3722
+
3723
+ def _builder_associated_resource_resources(payload: JSONObject) -> list[JSONObject]:
3724
+ parent = _builder_app_parent(payload)
3725
+ readback_by_id = _builder_associated_resource_readback_by_id(payload)
3726
+ resources: list[JSONObject] = []
3727
+ for key, operation in (
3728
+ ("created", "created"),
3729
+ ("updated", "updated"),
3730
+ ("unchanged", "unchanged"),
3731
+ ("removed", "removed"),
3732
+ ("failed", "failed"),
3733
+ ):
3734
+ for item in payload.get(key) or []:
3735
+ if not isinstance(item, dict):
3736
+ continue
3737
+ status = "failed" if operation == "failed" or item.get("status") == "failed" else str(item.get("status") or "success")
3738
+ associated_item_id = item.get("associated_item_id") or item.get("associatedItemId")
3739
+ readback = readback_by_id.get(str(associated_item_id)) if associated_item_id not in (None, "") else None
3740
+ view_key = item.get("view_key") or item.get("viewKey") or (readback or {}).get("view_key") or (readback or {}).get("viewKey")
3741
+ chart_key = item.get("chart_key") or item.get("chartKey") or (readback or {}).get("chart_key") or (readback or {}).get("chartKey")
3742
+ target_app_key = item.get("target_app_key") or (readback or {}).get("target_app_key")
3743
+ name = item.get("name") or item.get("resource_name") or (readback or {}).get("name") or view_key or chart_key
3744
+ resources.append(
3745
+ _builder_resource(
3746
+ resource_type="associated_resource",
3747
+ operation=operation if operation != "failed" else "failed",
3748
+ status=status,
3749
+ id_value=associated_item_id,
3750
+ key=view_key or chart_key or associated_item_id,
3751
+ name=name,
3752
+ ids={
3753
+ **({"associated_item_id": associated_item_id} if associated_item_id not in (None, "") else {}),
3754
+ **({"app_key": payload.get("app_key")} if payload.get("app_key") else {}),
3755
+ **({"target_app_key": target_app_key} if target_app_key else {}),
3756
+ **({"view_key": view_key} if view_key else {}),
3757
+ **({"chart_key": chart_key} if chart_key else {}),
3758
+ },
3759
+ parent=parent,
3760
+ error_code=item.get("error_code"),
3761
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3762
+ )
3763
+ )
3764
+ for item in payload.get("view_configs") or []:
3765
+ if isinstance(item, dict):
3766
+ status = str(item.get("status") or "success")
3767
+ view_key = item.get("view_key") or item.get("viewKey")
3768
+ resources.append(
3769
+ _builder_resource(
3770
+ resource_type="associated_resource_binding",
3771
+ operation="updated" if status != "failed" else "failed",
3772
+ status=status,
3773
+ key=view_key,
3774
+ name=item.get("view_name") or item.get("viewName") or view_key,
3775
+ ids={
3776
+ **({"view_key": view_key} if view_key else {}),
3777
+ **({"app_key": payload.get("app_key")} if payload.get("app_key") else {}),
3778
+ },
3779
+ parent=parent,
3780
+ error_code=item.get("error_code"),
3781
+ message=item.get("message") if status == "failed" else None,
3782
+ )
3783
+ )
3784
+ return resources
3785
+
3786
+
3787
+ def _builder_associated_resource_readback_by_id(payload: JSONObject) -> dict[str, JSONObject]:
3788
+ result: dict[str, JSONObject] = {}
3789
+
3790
+ def collect(items: object) -> None:
3791
+ if not isinstance(items, list):
3792
+ return
3793
+ for item in items:
3794
+ if not isinstance(item, dict):
3795
+ continue
3796
+ associated_item_id = item.get("associated_item_id") or item.get("associatedItemId")
3797
+ if associated_item_id in (None, ""):
3798
+ continue
3799
+ result[str(associated_item_id)] = item
3800
+
3801
+ collect(payload.get("associated_resources"))
3802
+ for config in payload.get("view_configs") or []:
3803
+ if not isinstance(config, dict):
3804
+ continue
3805
+ for key in ("actual", "expected"):
3806
+ value = config.get(key)
3807
+ if isinstance(value, dict):
3808
+ collect(value.get("items"))
3809
+ return result
3810
+
3811
+
2451
3812
  def _coerce_api_error(error: Exception) -> QingflowApiError:
2452
3813
  if isinstance(error, QingflowApiError):
2453
3814
  return error
@@ -2596,6 +3957,40 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2596
3957
  "tool_name": "chart_get",
2597
3958
  },
2598
3959
  },
3960
+ "workspace_icon_catalog_get": {
3961
+ "allowed_keys": [],
3962
+ "aliases": {},
3963
+ "allowed_values": {
3964
+ "icon": list(WORKSPACE_ICON_NAMES),
3965
+ "color": list(WORKSPACE_ICON_COLORS),
3966
+ "generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
3967
+ },
3968
+ "execution_notes": [
3969
+ "read this before creating app packages, apps, or portals when choosing supported workspace icons",
3970
+ "the CLI validates icon/color candidates but does not infer business defaults from resource names",
3971
+ "new app/package/portal creation requires explicit non-template icon + color",
3972
+ ],
3973
+ "minimal_example": {
3974
+ "profile": "default",
3975
+ },
3976
+ },
3977
+ "package_list": {
3978
+ "allowed_keys": ["trial_status", "query"],
3979
+ "aliases": {"trialStatus": "trial_status", "keyword": "query"},
3980
+ "allowed_values": {"trial_status": ["all"]},
3981
+ "execution_notes": [
3982
+ "lists app packages visible to the current builder profile by calling backend GET /tag?trialStatus=...",
3983
+ "query is applied locally to package_id/tag_id/package_name/tag_name after /tag returns",
3984
+ "does not fall back to app list because app list cannot represent empty packages, duplicate package names, or package-level permissions",
3985
+ "returns package_id/package_name plus compatible tag_id/tag_name; use package_get for package detail before editing",
3986
+ "permission failures are returned as PACKAGE_LIST_FAILED with backend transport details",
3987
+ ],
3988
+ "minimal_example": {
3989
+ "profile": "default",
3990
+ "trial_status": "all",
3991
+ "query": "产品研发",
3992
+ },
3993
+ },
2599
3994
  "package_get": {
2600
3995
  "allowed_keys": ["package_id"],
2601
3996
  "aliases": {"packageId": "package_id"},
@@ -2620,9 +4015,17 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2620
4015
  "iconColor": "color",
2621
4016
  "allowDetach": "allow_detach",
2622
4017
  },
2623
- "allowed_values": deepcopy(_VISIBILITY_ALLOWED_VALUES),
4018
+ "allowed_values": {
4019
+ **deepcopy(_VISIBILITY_ALLOWED_VALUES),
4020
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
4021
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
4022
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
4023
+ },
2624
4024
  "execution_notes": [
2625
4025
  "create or update package metadata, visibility, grouping, and ordering in one call",
4026
+ "creating a package requires explicit icon + color; icon=template is blocked because it is too generic",
4027
+ "updating a package preserves existing icon/color when omitted; explicit icon/color values are still validated",
4028
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
2626
4029
  "metadata keys omitted on update are preserved",
2627
4030
  "package_id maps internally to backend tagId; do not use tag_id in public calls",
2628
4031
  "items is a full package layout tree; omitting existing app/portal items is blocked unless allow_detach=true",
@@ -2646,7 +4049,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2646
4049
  "profile": "default",
2647
4050
  "package_name": "项目管理",
2648
4051
  "create_if_missing": True,
2649
- "icon": "files-folder",
4052
+ "icon": "briefcase",
2650
4053
  "color": "azure",
2651
4054
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
2652
4055
  },
@@ -2759,7 +4162,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2759
4162
  },
2760
4163
  "execution_notes": [
2761
4164
  "use this read-only tool before button writes when an agent needs a supported icon or color choice",
2762
- "current frontend only supports template icons and template colors from this catalog",
4165
+ "current frontend only supports button icons and button colors from this catalog",
2763
4166
  "text/icon color is unified through text_color; there is no separate icon_color",
2764
4167
  ],
2765
4168
  "minimal_example": {
@@ -2861,6 +4264,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
2861
4264
  "default placements are header and detail; header maps to frontend top buttons",
2862
4265
  "placement=list configures backend INSIDE row/list buttons; header maps to TOP and detail maps to DETAIL",
2863
4266
  "remove_buttons supports button_id or exact unique button_text",
4267
+ "after a remove_buttons DELETE is sent, the tool verifies deletion by single button_id readback; removed[] returns delete_executed, readback_status, and safe_to_retry_delete=false",
4268
+ "if a removed button returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
2864
4269
  "all operations share one edit context and publish after at least one write succeeds; there is no draft-only mode for this tool",
2865
4270
  "background_color and text_color cannot both be white",
2866
4271
  ],
@@ -3003,6 +4408,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3003
4408
  "match_mappings.source_field accepts source schema fields plus system fields 数据ID(-17) and 编号(0); match_mappings compiles to backend matchRules",
3004
4409
  "do not write raw match_rules unless preserving a legacy backend config; match_mappings and match_rules are mutually exclusive",
3005
4410
  "client_key only lets a view_config reference a resource created earlier in the same apply call through associated_item_refs; it is not persisted and cannot deduplicate later apply calls",
4411
+ "remove_associated_item_ids sends DELETE and verifies deletion with one associated-resource pool readback because the backend has no confirmed single-item GET; removed[] returns delete_executed, readback_status, and safe_to_retry_delete=false",
4412
+ "if an associated resource delete returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
3006
4413
  "this tool publishes after at least one write succeeds; there is no draft-only mode",
3007
4414
  "visible=false hides the associated-resource area without clearing previous selected ids; visible=true with limit_type=all shows the whole app-level pool",
3008
4415
  ],
@@ -3069,17 +4476,22 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3069
4476
  "field.type": [member.value for member in PublicFieldType],
3070
4477
  "field.relation_mode": [member.value for member in PublicRelationMode],
3071
4478
  "field_type_ids": sorted(FIELD_TYPE_ID_ALIASES.keys()),
4479
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
4480
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
4481
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
3072
4482
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
3073
4483
  },
3074
4484
  "execution_notes": [
3075
4485
  "create mode may set visibility for the new app; edit mode may update visibility on an existing app",
4486
+ "create mode should include explicit non-template icon + color; apply mode enforces this before writing",
4487
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
3076
4488
  *_VISIBILITY_EXECUTION_NOTES,
3077
4489
  ],
3078
4490
  "minimal_example": {
3079
4491
  "profile": "default",
3080
4492
  "app_name": "研发项目管理",
3081
4493
  "package_id": 1001,
3082
- "icon": "template",
4494
+ "icon": "briefcase",
3083
4495
  "color": "emerald",
3084
4496
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
3085
4497
  "create_if_missing": True,
@@ -3105,11 +4517,40 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3105
4517
  },
3106
4518
  },
3107
4519
  "app_schema_apply": {
3108
- "allowed_keys": ["app_key", "package_id", "app_name", "icon", "color", "visibility", "create_if_missing", "publish", "add_fields", "update_fields", "remove_fields"],
4520
+ "allowed_keys": [
4521
+ "app_key",
4522
+ "package_id",
4523
+ "app_name",
4524
+ "icon",
4525
+ "color",
4526
+ "visibility",
4527
+ "create_if_missing",
4528
+ "publish",
4529
+ "add_fields",
4530
+ "update_fields",
4531
+ "remove_fields",
4532
+ "apps",
4533
+ "apps[].client_key",
4534
+ "apps[].app_key",
4535
+ "apps[].app_name",
4536
+ "apps[].icon",
4537
+ "apps[].color",
4538
+ "apps[].visibility",
4539
+ "apps[].add_fields",
4540
+ "apps[].update_fields",
4541
+ "apps[].remove_fields",
4542
+ "apps[].add_fields[].target_app_ref",
4543
+ ],
3109
4544
  "aliases": {
3110
4545
  "app_title": "app_name",
3111
4546
  "title": "app_name",
3112
4547
  "packageId": "package_id",
4548
+ "apps[].clientKey": "apps[].client_key",
4549
+ "apps[].appKey": "apps[].app_key",
4550
+ "apps[].appName": "apps[].app_name",
4551
+ "apps[].appTitle": "apps[].app_name",
4552
+ "field.targetAppRef": "field.target_app_ref",
4553
+ "field.targetAppClientKey": "field.target_app_ref",
3113
4554
  "field.title": "field.name",
3114
4555
  "field.label": "field.name",
3115
4556
  "field.fields": "field.subfields",
@@ -3146,7 +4587,14 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3146
4587
  "use exactly one resource mode",
3147
4588
  "edit mode: app_key, optional app_name to rename the existing app",
3148
4589
  "create mode: package_id + app_name + create_if_missing=true",
4590
+ "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",
4591
+ "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",
4592
+ "multi-app mode is not transactional; read created_app_keys and apps[].status before retrying, and retry only failed app items",
3149
4593
  "create mode defaults new app visibility to workspace/not when visibility is omitted; edit mode preserves current visibility when omitted",
4594
+ "create mode requires explicit icon + color; icon=template is blocked because it is too generic",
4595
+ "multi-app create mode requires each new app item to include a distinct non-template icon and a valid color",
4596
+ "edit mode preserves existing icon/color when omitted; explicit icon/color values are still validated",
4597
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
3150
4598
  *_VISIBILITY_EXECUTION_NOTES,
3151
4599
  "update_fields is the field-level partial update path; it reads current form schema and preserves untouched field config",
3152
4600
  "multiple relation fields are backend-risky; read verification.relation_field_limit_verified and warnings before declaring the schema stable",
@@ -3171,7 +4619,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3171
4619
  "profile": "default",
3172
4620
  "app_name": "研发项目管理",
3173
4621
  "package_id": 1001,
3174
- "icon": "template",
4622
+ "icon": "briefcase",
3175
4623
  "color": "emerald",
3176
4624
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
3177
4625
  "create_if_missing": True,
@@ -3183,6 +4631,40 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3183
4631
  "update_fields": [],
3184
4632
  "remove_fields": [],
3185
4633
  },
4634
+ "multi_app_example": {
4635
+ "profile": "default",
4636
+ "package_id": 1001,
4637
+ "create_if_missing": True,
4638
+ "publish": True,
4639
+ "apps": [
4640
+ {
4641
+ "client_key": "employee",
4642
+ "app_name": "员工花名册",
4643
+ "icon": "business-personalcard",
4644
+ "color": "emerald",
4645
+ "add_fields": [
4646
+ {"name": "员工名称", "type": "text", "as_data_title": True},
4647
+ {"name": "员工照片", "type": "attachment", "as_data_cover": True},
4648
+ ],
4649
+ },
4650
+ {
4651
+ "client_key": "worklog",
4652
+ "app_name": "工时表",
4653
+ "icon": "clock",
4654
+ "color": "blue",
4655
+ "add_fields": [
4656
+ {"name": "工时标题", "type": "text", "as_data_title": True},
4657
+ {
4658
+ "name": "关联员工",
4659
+ "type": "relation",
4660
+ "target_app_ref": "employee",
4661
+ "display_field": {"name": "员工名称"},
4662
+ "visible_fields": [{"name": "员工名称"}],
4663
+ },
4664
+ ],
4665
+ },
4666
+ ],
4667
+ },
3186
4668
  "rename_example": {
3187
4669
  "profile": "default",
3188
4670
  "app_key": "APP_PROJECT",
@@ -3451,7 +4933,25 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3451
4933
  },
3452
4934
  },
3453
4935
  "app_views_plan": {
3454
- "allowed_keys": ["app_key", "upsert_views", "patch_views", "remove_views", "preset", "upsert_views[].view_key", "upsert_views[].buttons", "upsert_views[].visibility", "upsert_views[].query_conditions", "patch_views[].view_key", "patch_views[].name", "patch_views[].set", "patch_views[].unset"],
4936
+ "allowed_keys": [
4937
+ "app_key",
4938
+ "upsert_views",
4939
+ "patch_views",
4940
+ "remove_views",
4941
+ "preset",
4942
+ "upsert_views[].view_key",
4943
+ "upsert_views[].name",
4944
+ "upsert_views[].type",
4945
+ "upsert_views[].columns",
4946
+ "upsert_views[].filters",
4947
+ "upsert_views[].buttons",
4948
+ "upsert_views[].visibility",
4949
+ "upsert_views[].query_conditions",
4950
+ "patch_views[].view_key",
4951
+ "patch_views[].name",
4952
+ "patch_views[].set",
4953
+ "patch_views[].unset",
4954
+ ],
3455
4955
  "aliases": {
3456
4956
  "fields": "columns",
3457
4957
  "column_names": "columns",
@@ -3483,7 +4983,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3483
4983
  "view.type": [member.value for member in PublicViewType],
3484
4984
  "view.filter.operator": [member.value for member in ViewFilterOperator],
3485
4985
  "view.buttons.button_type": ["SYSTEM", "CUSTOM"],
3486
- "view.buttons.config_type": ["TOP", "DETAIL"],
4986
+ "view.buttons.config_type": ["TOP", "DETAIL", "INSIDE"],
3487
4987
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
3488
4988
  },
3489
4989
  "execution_notes": [
@@ -3491,7 +4991,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3491
4991
  "filters are saved fixed filters that apply when the view opens; query_conditions configure the frontend query panel and only apply after a user enters query values",
3492
4992
  "upsert_views[].query_conditions.rows is a layout matrix of field names; it is compiled to backend queryCondition queIds",
3493
4993
  "use patch_views for partial parameter replacement on existing views; the tool reads current config, merges patch_views[].set/unset, then submits the backend full-save payload internally",
3494
- "view associated report/view display is now configured through app_associated_resources_apply, not app_views_apply",
4994
+ "remove_views accepts a raw view_key or an exact unique view name; after DELETE the tool verifies deletion by single view_key readback, not by a full app view list",
4995
+ "deleted views return verification.by_view[].delete_executed, readback_status, and safe_to_retry_delete=false; if readback is pending, do not blindly repeat the delete",
4996
+ "new views created by app_views_apply default associated report/view display to visible with limit_type=all; existing views preserve their current associated display unless patch_views/upsert_views explicitly changes associated_resources",
4997
+ "associated report/view resource pool and per-view selected resources are configured through app_associated_resources_apply; app_views_apply only keeps legacy associated_resources input compatible",
3495
4998
  "for multi-value operators such as in, pass values as a list; value may also be used as an alias when it already contains a list",
3496
4999
  *_VISIBILITY_EXECUTION_NOTES,
3497
5000
  ],
@@ -3554,7 +5057,25 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3554
5057
  },
3555
5058
  },
3556
5059
  "app_views_apply": {
3557
- "allowed_keys": ["app_key", "publish", "upsert_views", "patch_views", "remove_views", "upsert_views[].view_key", "upsert_views[].buttons", "upsert_views[].visibility", "upsert_views[].query_conditions", "patch_views[].view_key", "patch_views[].name", "patch_views[].set", "patch_views[].unset"],
5060
+ "allowed_keys": [
5061
+ "app_key",
5062
+ "publish",
5063
+ "upsert_views",
5064
+ "patch_views",
5065
+ "remove_views",
5066
+ "upsert_views[].view_key",
5067
+ "upsert_views[].name",
5068
+ "upsert_views[].type",
5069
+ "upsert_views[].columns",
5070
+ "upsert_views[].filters",
5071
+ "upsert_views[].buttons",
5072
+ "upsert_views[].visibility",
5073
+ "upsert_views[].query_conditions",
5074
+ "patch_views[].view_key",
5075
+ "patch_views[].name",
5076
+ "patch_views[].set",
5077
+ "patch_views[].unset",
5078
+ ],
3558
5079
  "aliases": {
3559
5080
  "fields": "columns",
3560
5081
  "column_names": "columns",
@@ -3585,7 +5106,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3585
5106
  "view.type": [member.value for member in PublicViewType],
3586
5107
  "view.filter.operator": [member.value for member in ViewFilterOperator],
3587
5108
  "view.buttons.button_type": ["SYSTEM", "CUSTOM"],
3588
- "view.buttons.config_type": ["TOP", "DETAIL"],
5109
+ "view.buttons.config_type": ["TOP", "DETAIL", "INSIDE"],
3589
5110
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
3590
5111
  },
3591
5112
  "execution_notes": [
@@ -3598,7 +5119,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3598
5119
  "filters are saved fixed filters that apply when the view opens; query_conditions configure the frontend query panel and only apply after a user enters query values",
3599
5120
  "upsert_views[].query_conditions.rows is a layout matrix of field names; it is compiled to backend queryCondition queIds",
3600
5121
  "use patch_views for partial parameter replacement on existing views; the public update mode is patch even though the backend save is still a full view payload",
3601
- "view associated report/view display is now configured through app_associated_resources_apply; app_views_apply keeps legacy associated_resources input compatible but it is no longer the recommended public contract",
5122
+ "remove_views accepts a raw view_key or an exact unique view name; after DELETE the tool verifies deletion by single view_key readback, not by a full app view list",
5123
+ "deleted views return verification.by_view[].delete_executed, readback_status, and safe_to_retry_delete=false; if readback is pending, do not blindly repeat the delete",
5124
+ "new views created by app_views_apply default associated report/view display to visible with limit_type=all; existing views preserve their current associated display unless patch_views/upsert_views explicitly changes associated_resources",
5125
+ "associated report/view resource pool and per-view selected resources are configured through app_associated_resources_apply; app_views_apply keeps legacy associated_resources input compatible but it is no longer the recommended public contract",
3602
5126
  "for multi-value operators such as in, pass values as a list; value may also be used as an alias when it already contains a list",
3603
5127
  *_VISIBILITY_EXECUTION_NOTES,
3604
5128
  ],
@@ -3690,6 +5214,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3690
5214
  "execution_notes": [
3691
5215
  "returns compact current field configuration for one app",
3692
5216
  "use this before app_schema_apply when you need exact field definitions",
5217
+ "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",
5218
+ "chart_fields[].field_id supports field_<queId> selectors, while chart_fields[].bi_field_id is the raw QingBI fieldId accepted by report configs",
3693
5219
  "subtable fields include nested subfields using the same compact field shape",
3694
5220
  ],
3695
5221
  "minimal_example": {
@@ -3811,6 +5337,12 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3811
5337
  "successful create results must return a real backend chart_id",
3812
5338
  "upsert_charts[].visibility compiles to QingBI base visibleAuth only",
3813
5339
  "visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
5340
+ "chart dimension/metric/filter/query fields are resolved from app_get_fields.chart_fields (QingBI datasource fields), not record schema or form-only fields",
5341
+ "system fields such as 申请人/申请时间/编号 are usable only when they appear in chart_fields; otherwise app_charts_apply returns CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
5342
+ "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",
5343
+ "chart rule failures return chart_results[].diagnostics with rule_code, expected, actual, offending_fields, and next_action; backend 81002/81005 are translated when possible",
5344
+ "remove_chart_ids deletes by chart_id and verifies each deleted chart with single chart_id readback; pure delete does not read the full chart list",
5345
+ "if delete readback is unavailable or still finds the chart, chart_results[] returns delete_executed=true, readback_status, and safe_to_retry_delete=false; do not blindly repeat delete",
3814
5346
  *_VISIBILITY_EXECUTION_NOTES,
3815
5347
  ],
3816
5348
  "minimal_example": {
@@ -3854,9 +5386,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3854
5386
  },
3855
5387
  },
3856
5388
  "portal_apply": {
3857
- "allowed_keys": ["dash_key", "dash_name", "package_id", "publish", "sections", "visibility", "auth", "icon", "color", "hide_copyright", "dash_global_config", "config"],
5389
+ "allowed_keys": ["dash_key", "dash_name", "name", "package_id", "publish", "sections", "pages", "payload", "layout_preset", "visibility", "auth", "icon", "color", "hide_copyright", "dash_global_config", "config"],
3858
5390
  "aliases": {
3859
5391
  "packageId": "package_id",
5392
+ "name": "dash_name",
3860
5393
  "sourceType": "source_type",
3861
5394
  "chartRef": "chart_ref",
3862
5395
  "viewRef": "view_ref",
@@ -3869,20 +5402,34 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3869
5402
  "viewRef": "view_ref",
3870
5403
  "dashStyleConfigBO": "dash_style_config",
3871
5404
  },
3872
- "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"], **deepcopy(_VISIBILITY_ALLOWED_VALUES)},
3873
- "execution_notes": [
3874
- "use exactly one resource mode",
3875
- "update mode: dash_key",
3876
- "create mode: package_id + dash_name",
3877
- "portal_apply uses replace semantics for sections",
3878
- "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
3879
- "portal section-level patch is not exposed; supplying sections means full sections replacement",
3880
- "remove a section by omitting it from the new sections list",
3881
- "package_id is required when creating a new portal",
3882
- "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
5405
+ "allowed_values": {
5406
+ "section.source_type": ["chart", "view", "grid", "filter", "text", "link"],
5407
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
5408
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
5409
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
5410
+ **deepcopy(_VISIBILITY_ALLOWED_VALUES),
5411
+ },
5412
+ "execution_notes": [
5413
+ "use exactly one resource mode",
5414
+ "update mode: dash_key",
5415
+ "create mode: package_id + dash_name",
5416
+ "create mode requires explicit icon + color; icon=template is blocked because it is too generic",
5417
+ "edit mode preserves existing icon/color when omitted; explicit icon/color values are still validated",
5418
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
5419
+ "portal_apply uses replace semantics for sections",
5420
+ "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
5421
+ "portal section-level patch is not exposed; supplying sections means full sections replacement",
5422
+ "remove a section by omitting it from the new sections list",
5423
+ "package_id is required when creating a new portal",
5424
+ "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
3883
5425
  "chart_ref resolves by chart_id first, then exact unique chart_name",
3884
5426
  "view_ref resolves by view_key first, then exact unique view_name",
5427
+ "pc layout uses a 24-column grid; mobile layout uses a 6-column grid",
5428
+ "if unsure about layout, omit position or use layout_preset=auto/dashboard_2col/dashboard_3col",
5429
+ "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",
5430
+ "x=0/6 with cols=6 only occupies the left half of the pc portal and triggers PORTAL_LAYOUT_HALF_WIDTH",
3885
5431
  "position.pc/mobile is the canonical portal layout shape",
5432
+ "compat payload accepts name -> dash_name and single pages[0].components -> sections",
3886
5433
  "visibility is the canonical public auth shape; auth is kept only as a deprecated compatibility alias",
3887
5434
  "passing visibility and auth together is rejected as VISIBILITY_CONFLICT",
3888
5435
  *_VISIBILITY_EXECUTION_NOTES,
@@ -3891,7 +5438,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3891
5438
  "profile": "default",
3892
5439
  "dash_name": "经营门户",
3893
5440
  "package_id": 1001,
5441
+ "icon": "view-grid",
5442
+ "color": "blue",
3894
5443
  "publish": True,
5444
+ "layout_preset": "dashboard_2col",
3895
5445
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
3896
5446
  "sections": [
3897
5447
  {
@@ -3905,6 +5455,23 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3905
5455
  }
3906
5456
  ],
3907
5457
  },
5458
+ "compat_payload_example": {
5459
+ "name": "经营门户",
5460
+ "package_id": 1001,
5461
+ "layout_preset": "dashboard_2col",
5462
+ "pages": [
5463
+ {
5464
+ "title": "经营总览",
5465
+ "components": [
5466
+ {
5467
+ "title": "销售趋势",
5468
+ "source_type": "chart",
5469
+ "chart_ref": {"app_key": "APP_KEY", "chart_id": "CHART_ID"},
5470
+ }
5471
+ ],
5472
+ }
5473
+ ],
5474
+ },
3908
5475
  "minimal_section_example": {
3909
5476
  "title": "订单概览",
3910
5477
  "source_type": "view",