@qingflow-tech/qingflow-app-builder-mcp 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +6 -3
  2. package/docs/local-agent-install.md +54 -3
  3. package/entry_point.py +1 -1
  4. package/npm/bin/qingflow-skills.mjs +5 -0
  5. package/npm/lib/runtime.mjs +304 -13
  6. package/npm/scripts/postinstall.mjs +1 -5
  7. package/package.json +3 -2
  8. package/pyproject.toml +1 -1
  9. package/skills/qingflow-app-builder/SKILL.md +12 -12
  10. package/skills/qingflow-app-builder/references/create-app.md +3 -3
  11. package/skills/qingflow-app-builder/references/environments.md +1 -1
  12. package/skills/qingflow-app-builder/references/gotchas.md +1 -1
  13. package/skills/qingflow-app-builder/references/public-surface-sync.md +75 -0
  14. package/skills/qingflow-app-builder/references/tool-selection.md +6 -5
  15. package/skills/qingflow-app-builder/references/update-views.md +1 -1
  16. package/skills/qingflow-app-builder-code-integrations/SKILL.md +3 -3
  17. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +1 -1
  18. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +1 -1
  19. package/src/qingflow_mcp/__main__.py +6 -2
  20. package/src/qingflow_mcp/builder_facade/models.py +41 -2
  21. package/src/qingflow_mcp/builder_facade/service.py +2743 -423
  22. package/src/qingflow_mcp/cli/commands/app.py +3 -16
  23. package/src/qingflow_mcp/cli/commands/builder.py +30 -4
  24. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  25. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  26. package/src/qingflow_mcp/cli/commands/record.py +54 -11
  27. package/src/qingflow_mcp/cli/context.py +0 -3
  28. package/src/qingflow_mcp/cli/formatters.py +238 -8
  29. package/src/qingflow_mcp/cli/main.py +47 -3
  30. package/src/qingflow_mcp/errors.py +43 -2
  31. package/src/qingflow_mcp/public_surface.py +24 -16
  32. package/src/qingflow_mcp/response_trim.py +119 -12
  33. package/src/qingflow_mcp/server.py +17 -14
  34. package/src/qingflow_mcp/server_app_builder.py +29 -7
  35. package/src/qingflow_mcp/server_app_user.py +23 -24
  36. package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
  37. package/src/qingflow_mcp/solution/executor.py +112 -15
  38. package/src/qingflow_mcp/tools/ai_builder_tools.py +497 -65
  39. package/src/qingflow_mcp/tools/app_tools.py +237 -51
  40. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  41. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  42. package/src/qingflow_mcp/tools/code_block_tools.py +296 -39
  43. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  44. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  45. package/src/qingflow_mcp/tools/export_tools.py +230 -33
  46. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  47. package/src/qingflow_mcp/tools/import_tools.py +293 -40
  48. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  49. package/src/qingflow_mcp/tools/package_tools.py +134 -8
  50. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  51. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  52. package/src/qingflow_mcp/tools/record_tools.py +2305 -442
  53. package/src/qingflow_mcp/tools/resource_read_tools.py +191 -39
  54. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  55. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  56. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  57. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  58. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  59. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  60. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -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
 
@@ -14,7 +15,7 @@ from ..builder_facade.button_style_catalog import (
14
15
  )
15
16
  from ..public_surface import public_builder_contract_tool_names
16
17
  from ..config import DEFAULT_PROFILE
17
- from ..errors import QingflowApiError
18
+ from ..errors import QingflowApiError, backend_code_int
18
19
  from ..json_types import JSONObject
19
20
  from ..builder_facade.models import (
20
21
  AssociatedResourcesApplyRequest,
@@ -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
@@ -117,6 +127,14 @@ class AiBuilderTools(ToolBase):
117
127
  def builder_tool_contract(tool_name: str = "") -> JSONObject:
118
128
  return self.builder_tool_contract(tool_name=tool_name)
119
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
+
120
138
  @mcp.tool()
121
139
  def package_get(profile: str = DEFAULT_PROFILE, package_id: int = 0) -> JSONObject:
122
140
  return self.package_get(profile=profile, package_id=package_id)
@@ -482,9 +500,12 @@ class AiBuilderTools(ToolBase):
482
500
  profile: str = DEFAULT_PROFILE,
483
501
  dash_key: str = "",
484
502
  dash_name: str = "",
503
+ name: str = "",
485
504
  package_id: int | None = None,
486
505
  publish: bool = True,
487
506
  sections: list[JSONObject] | None = None,
507
+ pages: list[JSONObject] | None = None,
508
+ layout_preset: str = "",
488
509
  visibility: JSONObject | None = None,
489
510
  auth: JSONObject | None = None,
490
511
  icon: str | None = None,
@@ -492,10 +513,14 @@ class AiBuilderTools(ToolBase):
492
513
  hide_copyright: bool | None = None,
493
514
  dash_global_config: JSONObject | None = None,
494
515
  config: JSONObject | None = None,
516
+ payload: JSONObject | None = None,
495
517
  ) -> JSONObject:
518
+ payload = payload if isinstance(payload, dict) else {}
496
519
  has_dash_key = bool((dash_key or "").strip())
497
- has_dash_name = bool((dash_name or "").strip())
498
- 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
499
524
  if has_dash_key and has_package_id:
500
525
  return _config_failure(
501
526
  tool_name="portal_apply",
@@ -512,9 +537,12 @@ class AiBuilderTools(ToolBase):
512
537
  profile=profile,
513
538
  dash_key=dash_key,
514
539
  dash_name=dash_name,
540
+ name=name,
515
541
  package_id=package_id,
516
542
  publish=publish,
517
543
  sections=sections or [],
544
+ pages=pages or [],
545
+ layout_preset=layout_preset,
518
546
  visibility=visibility,
519
547
  auth=auth,
520
548
  icon=icon,
@@ -522,6 +550,7 @@ class AiBuilderTools(ToolBase):
522
550
  hide_copyright=hide_copyright,
523
551
  dash_global_config=dash_global_config,
524
552
  config=config or {},
553
+ payload=payload,
525
554
  )
526
555
 
527
556
  @mcp.tool()
@@ -537,14 +566,14 @@ class AiBuilderTools(ToolBase):
537
566
  )
538
567
 
539
568
  @tool_cn_name("分组列表查询")
540
- 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:
541
570
  """执行分组与包相关逻辑。"""
542
- normalized_args = {"trial_status": trial_status}
571
+ normalized_args = {"trial_status": trial_status, "query": query}
543
572
  return _safe_tool_call(
544
- 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),
545
574
  error_code="PACKAGE_LIST_FAILED",
546
575
  normalized_args=normalized_args,
547
- 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}},
548
577
  )
549
578
 
550
579
  @tool_cn_name("分组解析")
@@ -611,6 +640,27 @@ class AiBuilderTools(ToolBase):
611
640
  "contract": contract,
612
641
  }
613
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
+
614
664
  @tool_cn_name("分组创建")
615
665
  def package_create(
616
666
  self,
@@ -691,6 +741,14 @@ class AiBuilderTools(ToolBase):
691
741
  "package_apply",
692
742
  _visibility_validation_failure(str(exc), tool_name="package_apply", exc=exc),
693
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)
694
752
  normalized_args = {
695
753
  "package_id": package_id,
696
754
  **({"package_name": package_name} if str(package_name or "").strip() else {}),
@@ -796,16 +854,23 @@ class AiBuilderTools(ToolBase):
796
854
  contain_disable: bool = False,
797
855
  ) -> JSONObject:
798
856
  """执行工具方法逻辑。"""
857
+ normalized_query = str(query or "").strip()
799
858
  normalized_args = {
800
- "query": query,
859
+ "query": normalized_query,
801
860
  "page_num": page_num,
802
861
  "page_size": page_size,
803
862
  "contain_disable": contain_disable,
804
863
  }
864
+ if not normalized_query:
865
+ return _config_failure(
866
+ tool_name="member_search",
867
+ message="query is required for member_search; builder member lookup is a contact-directory path, not a record candidate fallback.",
868
+ fix_hint="For record member/department field ambiguity, use record member-candidates / department-candidates instead.",
869
+ )
805
870
  return _safe_tool_call(
806
871
  lambda: self._facade.member_search(
807
872
  profile=profile,
808
- query=query,
873
+ query=normalized_query,
809
874
  page_num=page_num,
810
875
  page_size=page_size,
811
876
  contain_disable=contain_disable,
@@ -818,9 +883,16 @@ class AiBuilderTools(ToolBase):
818
883
  @tool_cn_name("角色检索")
819
884
  def role_search(self, *, profile: str, keyword: str, page_num: int = 1, page_size: int = 20) -> JSONObject:
820
885
  """执行角色相关逻辑。"""
821
- normalized_args = {"keyword": keyword, "page_num": page_num, "page_size": page_size}
886
+ normalized_keyword = str(keyword or "").strip()
887
+ normalized_args = {"keyword": normalized_keyword, "page_num": page_num, "page_size": page_size}
888
+ if not normalized_keyword:
889
+ return _config_failure(
890
+ tool_name="role_search",
891
+ message="keyword is required for role_search; builder role lookup is a contact-management path, not a record candidate fallback.",
892
+ fix_hint="For record member/department field ambiguity, use record member-candidates / department-candidates instead.",
893
+ )
822
894
  return _safe_tool_call(
823
- lambda: self._facade.role_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size),
895
+ lambda: self._facade.role_search(profile=profile, keyword=normalized_keyword, page_num=page_num, page_size=page_size),
824
896
  error_code="ROLE_SEARCH_FAILED",
825
897
  normalized_args=normalized_args,
826
898
  suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, **normalized_args}},
@@ -1657,6 +1729,68 @@ class AiBuilderTools(ToolBase):
1657
1729
  message="app_schema_apply multi-app mode requires non-empty apps.",
1658
1730
  fix_hint="Pass apps as a non-empty list of app schema items.",
1659
1731
  )
1732
+ icon_errors: list[JSONObject] = []
1733
+ seen_new_app_icons: dict[str, int] = {}
1734
+ for index, raw_item in enumerate(apps):
1735
+ if not isinstance(raw_item, dict):
1736
+ continue
1737
+ app_key = str(raw_item.get("app_key") or raw_item.get("appKey") or "").strip()
1738
+ creating_item = not app_key
1739
+ icon_failure = _validate_workspace_icon_for_builder(
1740
+ tool_name="app_schema_apply",
1741
+ icon=str(raw_item.get("icon") or ""),
1742
+ color=str(raw_item.get("color") or ""),
1743
+ creating=creating_item,
1744
+ )
1745
+ if icon_failure is not None:
1746
+ icon_errors.append(
1747
+ {
1748
+ "index": index,
1749
+ "row_number": index + 1,
1750
+ "error_code": icon_failure.get("error_code"),
1751
+ "message": icon_failure.get("message"),
1752
+ "details": icon_failure.get("details"),
1753
+ }
1754
+ )
1755
+ continue
1756
+ _ok, _error_code, _message, icon_details = validate_workspace_icon_choice(
1757
+ icon=str(raw_item.get("icon") or ""),
1758
+ color=str(raw_item.get("color") or ""),
1759
+ require_explicit=creating_item,
1760
+ disallow_generic=creating_item,
1761
+ )
1762
+ normalized_icon = str(icon_details.get("normalized_icon") or "").strip()
1763
+ if creating_item and normalized_icon:
1764
+ if normalized_icon in seen_new_app_icons:
1765
+ icon_errors.append(
1766
+ {
1767
+ "index": index,
1768
+ "row_number": index + 1,
1769
+ "error_code": "DUPLICATE_WORKSPACE_ICON_IN_BATCH",
1770
+ "message": f"apps[{index}] reuses icon '{normalized_icon}' from apps[{seen_new_app_icons[normalized_icon]}]",
1771
+ "details": {
1772
+ "icon": normalized_icon,
1773
+ "first_index": seen_new_app_icons[normalized_icon],
1774
+ "duplicate_index": index,
1775
+ "icon_catalog_command": "qingflow --json builder icon catalog",
1776
+ },
1777
+ }
1778
+ )
1779
+ else:
1780
+ seen_new_app_icons[normalized_icon] = index
1781
+ if icon_errors:
1782
+ return _config_failure(
1783
+ tool_name="app_schema_apply",
1784
+ error_code="WORKSPACE_ICON_BATCH_INVALID",
1785
+ message="one or more apps have invalid workspace icon configuration",
1786
+ fix_hint="Call `qingflow --json builder icon catalog`, choose a distinct non-template icon and color for each new app, then retry.",
1787
+ details={"icon_errors": icon_errors},
1788
+ allowed_values={
1789
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
1790
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
1791
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
1792
+ },
1793
+ )
1660
1794
 
1661
1795
  client_key_to_app_key: dict[str, str] = {}
1662
1796
  created_app_keys: list[str] = []
@@ -1700,6 +1834,9 @@ class AiBuilderTools(ToolBase):
1700
1834
  )
1701
1835
  public_shell = _publicize_package_fields(shell)
1702
1836
  resolved_key = str(public_shell.get("app_key") or "").strip()
1837
+ shell_write_executed = _schema_apply_result_has_write(public_shell)
1838
+ if shell_write_executed:
1839
+ any_write_executed = True
1703
1840
  if public_shell.get("status") not in {"success", "partial_success"} or not resolved_key:
1704
1841
  results.append({
1705
1842
  "index": index,
@@ -1711,13 +1848,12 @@ class AiBuilderTools(ToolBase):
1711
1848
  "stage": "resolve_or_create_shell",
1712
1849
  "error_code": public_shell.get("error_code") or "APP_SHELL_APPLY_FAILED",
1713
1850
  "message": public_shell.get("message") or "app shell resolve/create failed",
1714
- "safe_to_retry": not any_write_executed,
1851
+ "write_executed": shell_write_executed,
1852
+ "safe_to_retry": not shell_write_executed and not any_write_executed,
1715
1853
  })
1716
1854
  continue
1717
1855
  if bool(public_shell.get("created")):
1718
1856
  created_app_keys.append(resolved_key)
1719
- if _schema_apply_result_has_write(public_shell):
1720
- any_write_executed = True
1721
1857
  if client_key:
1722
1858
  client_key_to_app_key[client_key] = resolved_key
1723
1859
  results.append({
@@ -1868,6 +2004,14 @@ class AiBuilderTools(ToolBase):
1868
2004
  ) -> JSONObject:
1869
2005
  """执行内部辅助逻辑。"""
1870
2006
  effective_app_name = app_name or app_title
2007
+ icon_failure = _validate_workspace_icon_for_builder(
2008
+ tool_name="app_schema_apply",
2009
+ icon=icon,
2010
+ color=color,
2011
+ creating=not bool(str(app_key or "").strip()) and bool(create_if_missing),
2012
+ )
2013
+ if icon_failure is not None:
2014
+ return icon_failure
1871
2015
  plan_result = self._rewrite_plan_result_for_apply(
1872
2016
  result=self.app_schema_plan(
1873
2017
  profile=profile,
@@ -2364,9 +2508,12 @@ class AiBuilderTools(ToolBase):
2364
2508
  profile: str,
2365
2509
  dash_key: str = "",
2366
2510
  dash_name: str = "",
2511
+ name: str = "",
2367
2512
  package_id: int | None = None,
2368
2513
  publish: bool = True,
2369
2514
  sections: list[JSONObject] | None = None,
2515
+ pages: list[JSONObject] | None = None,
2516
+ layout_preset: str = "",
2370
2517
  visibility: JSONObject | None = None,
2371
2518
  auth: JSONObject | None = None,
2372
2519
  icon: str | None = None,
@@ -2374,25 +2521,44 @@ class AiBuilderTools(ToolBase):
2374
2521
  hide_copyright: bool | None = None,
2375
2522
  dash_global_config: JSONObject | None = None,
2376
2523
  config: JSONObject | None = None,
2524
+ payload: JSONObject | None = None,
2377
2525
  ) -> JSONObject:
2378
2526
  """执行门户相关逻辑。"""
2527
+ request_payload: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {}
2528
+ if dash_key:
2529
+ request_payload["dash_key"] = dash_key
2530
+ if dash_name:
2531
+ request_payload["dash_name"] = dash_name
2532
+ elif name:
2533
+ request_payload["name"] = name
2534
+ if package_id is not None:
2535
+ request_payload["package_id"] = package_id
2536
+ if "publish" not in request_payload or publish is False:
2537
+ request_payload["publish"] = publish
2538
+ if sections:
2539
+ request_payload["sections"] = sections
2540
+ if pages:
2541
+ request_payload["pages"] = pages
2542
+ if layout_preset:
2543
+ request_payload["layout_preset"] = layout_preset
2544
+ if visibility is not None:
2545
+ request_payload["visibility"] = visibility
2546
+ if auth is not None:
2547
+ request_payload["auth"] = auth
2548
+ if icon is not None:
2549
+ request_payload["icon"] = icon
2550
+ if color is not None:
2551
+ request_payload["color"] = color
2552
+ if hide_copyright is not None:
2553
+ request_payload["hide_copyright"] = hide_copyright
2554
+ if dash_global_config is not None:
2555
+ request_payload["dash_global_config"] = dash_global_config
2556
+ if config:
2557
+ merged_config = dict(request_payload.get("config") or {}) if isinstance(request_payload.get("config"), dict) else {}
2558
+ merged_config.update(config)
2559
+ request_payload["config"] = merged_config
2379
2560
  try:
2380
- request = PortalApplyRequest.model_validate(
2381
- {
2382
- "dash_key": dash_key or None,
2383
- "dash_name": dash_name or None,
2384
- "package_tag_id": package_id,
2385
- "publish": publish,
2386
- "sections": sections or [],
2387
- "visibility": visibility,
2388
- "auth": auth,
2389
- "icon": icon,
2390
- "color": color,
2391
- "hide_copyright": hide_copyright,
2392
- "dash_global_config": dash_global_config,
2393
- "config": config or {},
2394
- }
2395
- )
2561
+ request = PortalApplyRequest.model_validate(request_payload)
2396
2562
  except ValidationError as exc:
2397
2563
  return _attach_builder_apply_envelope("portal_apply", _visibility_validation_failure(
2398
2564
  str(exc),
@@ -2405,6 +2571,7 @@ class AiBuilderTools(ToolBase):
2405
2571
  "dash_name": dash_name or "业务门户",
2406
2572
  "package_id": package_id or 1001,
2407
2573
  "publish": True,
2574
+ "layout_preset": "dashboard_2col",
2408
2575
  "sections": [
2409
2576
  {
2410
2577
  "title": "经营概览",
@@ -2417,6 +2584,14 @@ class AiBuilderTools(ToolBase):
2417
2584
  ))
2418
2585
  normalized_args = request.model_dump(mode="json")
2419
2586
  normalized_args["package_id"] = normalized_args.pop("package_tag_id", package_id)
2587
+ icon_failure = _validate_workspace_icon_for_builder(
2588
+ tool_name="portal_apply",
2589
+ icon=str(request.icon or ""),
2590
+ color=str(request.color or ""),
2591
+ creating=not bool(str(request.dash_key or "").strip()),
2592
+ )
2593
+ if icon_failure is not None:
2594
+ return _attach_builder_apply_envelope("portal_apply", icon_failure)
2420
2595
  result = _publicize_package_fields(_safe_tool_call(
2421
2596
  lambda: self._facade.portal_apply(profile=profile, request=request),
2422
2597
  error_code="PORTAL_APPLY_FAILED",
@@ -2683,6 +2858,8 @@ def _merge_schema_field_diffs(*diffs: object) -> JSONObject:
2683
2858
 
2684
2859
 
2685
2860
  def _schema_apply_result_has_write(result: JSONObject) -> bool:
2861
+ if "write_executed" in result:
2862
+ return bool(result.get("write_executed"))
2686
2863
  if bool(result.get("created")) or bool(result.get("published")) or bool(result.get("app_base_updated")):
2687
2864
  return True
2688
2865
  field_diff = result.get("field_diff")
@@ -2771,20 +2948,34 @@ def _visibility_validation_failure(
2771
2948
  return result
2772
2949
 
2773
2950
 
2774
- def _config_failure(*, tool_name: str, message: str, fix_hint: str) -> JSONObject:
2951
+ def _config_failure(
2952
+ *,
2953
+ tool_name: str,
2954
+ message: str,
2955
+ fix_hint: str,
2956
+ error_code: str = "CONFIG_ERROR",
2957
+ details: JSONObject | None = None,
2958
+ allowed_values: JSONObject | None = None,
2959
+ ) -> JSONObject:
2775
2960
  contract = _BUILDER_TOOL_CONTRACTS.get(tool_name or "")
2961
+ public_allowed_values = deepcopy(contract.get("allowed_values", {})) if isinstance(contract, dict) else {}
2962
+ if allowed_values:
2963
+ public_allowed_values.update(deepcopy(allowed_values))
2964
+ public_details: JSONObject = {
2965
+ "fix_hint": fix_hint,
2966
+ "allowed_keys": deepcopy(contract.get("allowed_keys", [])) if isinstance(contract, dict) else [],
2967
+ }
2968
+ if details:
2969
+ public_details.update(deepcopy(details))
2776
2970
  return {
2777
2971
  "status": "failed",
2778
- "error_code": "CONFIG_ERROR",
2972
+ "error_code": error_code,
2779
2973
  "recoverable": True,
2780
2974
  "message": message,
2781
2975
  "normalized_args": {},
2782
2976
  "missing_fields": [],
2783
- "allowed_values": deepcopy(contract.get("allowed_values", {})) if isinstance(contract, dict) else {},
2784
- "details": {
2785
- "fix_hint": fix_hint,
2786
- "allowed_keys": deepcopy(contract.get("allowed_keys", [])) if isinstance(contract, dict) else [],
2787
- },
2977
+ "allowed_values": public_allowed_values,
2978
+ "details": public_details,
2788
2979
  "suggested_next_call": None,
2789
2980
  "request_id": None,
2790
2981
  "backend_code": None,
@@ -2794,6 +2985,52 @@ def _config_failure(*, tool_name: str, message: str, fix_hint: str) -> JSONObjec
2794
2985
  }
2795
2986
 
2796
2987
 
2988
+ def _workspace_icon_config_failure(
2989
+ *,
2990
+ tool_name: str,
2991
+ error_code: str,
2992
+ message: str,
2993
+ details: JSONObject,
2994
+ ) -> JSONObject:
2995
+ return _config_failure(
2996
+ tool_name=tool_name,
2997
+ error_code=error_code,
2998
+ message=message,
2999
+ fix_hint="Call `qingflow --json builder icon catalog`, choose an explicit non-template icon and color, then retry.",
3000
+ details=details,
3001
+ allowed_values={
3002
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
3003
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
3004
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
3005
+ },
3006
+ )
3007
+
3008
+
3009
+ def _validate_workspace_icon_for_builder(
3010
+ *,
3011
+ tool_name: str,
3012
+ icon: str | None,
3013
+ color: str | None,
3014
+ creating: bool,
3015
+ ) -> JSONObject | None:
3016
+ if not creating and not (str(icon or "").strip() or str(color or "").strip()):
3017
+ return None
3018
+ ok, error_code, message, details = validate_workspace_icon_choice(
3019
+ icon=icon,
3020
+ color=color,
3021
+ require_explicit=creating,
3022
+ disallow_generic=creating,
3023
+ )
3024
+ if ok:
3025
+ return None
3026
+ return _workspace_icon_config_failure(
3027
+ tool_name=tool_name,
3028
+ error_code=error_code or "WORKSPACE_ICON_INVALID",
3029
+ message=message or "invalid workspace icon configuration",
3030
+ details=details,
3031
+ )
3032
+
3033
+
2797
3034
  def _safe_tool_call(
2798
3035
  call,
2799
3036
  *,
@@ -2842,6 +3079,7 @@ def _publicize_package_fields(value):
2842
3079
  "tag_ids_after": "package_ids_after",
2843
3080
  "tag_name": "package_name",
2844
3081
  "tag_icon": "icon",
3082
+ "iconConfig": "icon_config",
2845
3083
  "package_tag_id": "package_id",
2846
3084
  "package_tag_ids": "package_ids",
2847
3085
  "expected_package_tag_id": "expected_package_id",
@@ -2865,7 +3103,7 @@ def _builder_contract_with_apply_output(tool_name: str, contract: JSONObject) ->
2865
3103
  public["output_contract"] = {
2866
3104
  "schema_version": BUILDER_APPLY_SCHEMA_VERSION,
2867
3105
  "preferred_ui_fields": ["operation", "summary", "resources"],
2868
- "resource_fields": ["resource_type", "operation", "status", "id", "key", "name", "ids", "parent", "error_code", "message"],
3106
+ "resource_fields": ["resource_type", "operation", "status", "id", "key", "name", "ids", "parent", "icon_config", "error_code", "message"],
2869
3107
  "legacy_fields_preserved": True,
2870
3108
  }
2871
3109
  return public
@@ -3013,6 +3251,41 @@ def _builder_app_parent(payload: JSONObject) -> JSONObject | None:
3013
3251
  return _builder_parent("app", key=app_key, name=app_name)
3014
3252
 
3015
3253
 
3254
+ def _builder_icon_config(raw_icon: object = None, *, icon: object = None, color: object = None) -> JSONObject | None:
3255
+ raw = str(raw_icon).strip() if raw_icon not in (None, "") else ""
3256
+ explicit_icon = str(icon).strip() if icon not in (None, "") else ""
3257
+ explicit_color = str(color).strip() if color not in (None, "") else ""
3258
+ if raw:
3259
+ if raw.startswith("{") and raw.endswith("}"):
3260
+ config = workspace_icon_config(raw)
3261
+ else:
3262
+ config = {
3263
+ "icon_name": normalize_workspace_icon_name(raw),
3264
+ "icon_color": explicit_color or None,
3265
+ "icon_text": None,
3266
+ "raw": raw,
3267
+ }
3268
+ if any(config.get(key) for key in ("icon_name", "icon_color", "icon_text", "raw")):
3269
+ return config
3270
+ if explicit_icon or explicit_color:
3271
+ return {
3272
+ "icon_name": normalize_workspace_icon_name(explicit_icon) if explicit_icon else None,
3273
+ "icon_color": explicit_color or None,
3274
+ "icon_text": None,
3275
+ "raw": None,
3276
+ }
3277
+ return None
3278
+
3279
+
3280
+ def _builder_container_icon_config(container: object, *, raw_keys: tuple[str, ...], icon_keys: tuple[str, ...] = ("icon",), color_keys: tuple[str, ...] = ("color",)) -> JSONObject | None:
3281
+ if not isinstance(container, dict):
3282
+ return None
3283
+ raw_icon = next((container.get(key) for key in raw_keys if container.get(key) not in (None, "")), None)
3284
+ icon = next((container.get(key) for key in icon_keys if container.get(key) not in (None, "")), None)
3285
+ color = next((container.get(key) for key in color_keys if container.get(key) not in (None, "")), None)
3286
+ return _builder_icon_config(raw_icon, icon=icon, color=color)
3287
+
3288
+
3016
3289
  def _builder_resource(
3017
3290
  *,
3018
3291
  resource_type: str,
@@ -3023,10 +3296,11 @@ def _builder_resource(
3023
3296
  name: object = None,
3024
3297
  ids: JSONObject | None = None,
3025
3298
  parent: JSONObject | None = None,
3299
+ icon_config: JSONObject | None = None,
3026
3300
  error_code: object = None,
3027
3301
  message: object = None,
3028
3302
  ) -> JSONObject:
3029
- return {
3303
+ resource = {
3030
3304
  "resource_type": resource_type,
3031
3305
  "operation": operation,
3032
3306
  "status": status,
@@ -3038,6 +3312,9 @@ def _builder_resource(
3038
3312
  "error_code": str(error_code) if error_code not in (None, "") else None,
3039
3313
  "message": str(message) if message not in (None, "") else None,
3040
3314
  }
3315
+ if icon_config:
3316
+ resource["icon_config"] = icon_config
3317
+ return resource
3041
3318
 
3042
3319
 
3043
3320
  def _builder_app_resource(payload: JSONObject, *, operation: str) -> JSONObject:
@@ -3046,6 +3323,11 @@ def _builder_app_resource(payload: JSONObject, *, operation: str) -> JSONObject:
3046
3323
  operation = "failed"
3047
3324
  app_key = _builder_payload_app_key(payload)
3048
3325
  app_name = _builder_payload_app_name(payload)
3326
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3327
+ icon_config = (
3328
+ _builder_container_icon_config(payload, raw_keys=("app_icon", "appIcon"))
3329
+ or _builder_container_icon_config(normalized_args, raw_keys=("app_icon", "appIcon", "icon"))
3330
+ )
3049
3331
  return _builder_resource(
3050
3332
  resource_type="app",
3051
3333
  operation=operation,
@@ -3053,6 +3335,7 @@ def _builder_app_resource(payload: JSONObject, *, operation: str) -> JSONObject:
3053
3335
  key=app_key,
3054
3336
  name=app_name,
3055
3337
  ids={"app_key": app_key} if app_key not in (None, "") else {},
3338
+ icon_config=icon_config,
3056
3339
  error_code=payload.get("error_code"),
3057
3340
  message=payload.get("message") if status == "failed" else None,
3058
3341
  )
@@ -3095,6 +3378,11 @@ def _builder_package_resources(payload: JSONObject) -> list[JSONObject]:
3095
3378
  package_name = payload.get("package_name") or payload.get("name")
3096
3379
  status = _builder_status(payload, "success")
3097
3380
  operation = "failed" if status == "failed" else ("created" if bool(payload.get("created")) else "updated")
3381
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3382
+ icon_config = (
3383
+ _builder_container_icon_config(payload, raw_keys=("icon", "tagIcon", "tag_icon"))
3384
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "tagIcon", "tag_icon"))
3385
+ )
3098
3386
  return [
3099
3387
  _builder_resource(
3100
3388
  resource_type="package",
@@ -3104,6 +3392,7 @@ def _builder_package_resources(payload: JSONObject) -> list[JSONObject]:
3104
3392
  key=str(package_id) if package_id not in (None, "") else None,
3105
3393
  name=package_name,
3106
3394
  ids={"package_id": package_id} if package_id not in (None, "") else {},
3395
+ icon_config=icon_config,
3107
3396
  error_code=payload.get("error_code"),
3108
3397
  message=payload.get("message") if status == "failed" else None,
3109
3398
  )
@@ -3121,6 +3410,10 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3121
3410
  status = _builder_status(item, "success")
3122
3411
  operation = "failed" if status == "failed" else ("created" if bool(item.get("created")) else "updated")
3123
3412
  parent = _builder_parent("app", key=item.get("app_key"), name=item.get("app_name"))
3413
+ icon_config = (
3414
+ _builder_container_icon_config(item, raw_keys=("app_icon", "appIcon", "icon"))
3415
+ or _builder_container_icon_config(item.get("shell_result"), raw_keys=("app_icon", "appIcon", "icon"))
3416
+ )
3124
3417
  resources.append(
3125
3418
  _builder_resource(
3126
3419
  resource_type="app",
@@ -3133,6 +3426,7 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3133
3426
  **({"package_id": package_id} if package_id not in (None, "") else {}),
3134
3427
  },
3135
3428
  parent=package_parent,
3429
+ icon_config=icon_config,
3136
3430
  error_code=item.get("error_code"),
3137
3431
  message=item.get("message") if status == "failed" else None,
3138
3432
  )
@@ -3145,6 +3439,11 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3145
3439
  app_key = payload.get("app_key")
3146
3440
  app_name = payload.get("app_name_after") or payload.get("app_name")
3147
3441
  parent = _builder_parent("app", key=app_key, name=app_name)
3442
+ normalized_args = payload.get("normalized_args") if isinstance(payload.get("normalized_args"), dict) else {}
3443
+ icon_config = (
3444
+ _builder_container_icon_config(payload, raw_keys=("app_icon", "appIcon", "icon"))
3445
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "app_icon", "appIcon"))
3446
+ )
3148
3447
  resources = [
3149
3448
  _builder_resource(
3150
3449
  resource_type="app",
@@ -3153,6 +3452,7 @@ def _builder_schema_resources(payload: JSONObject) -> list[JSONObject]:
3153
3452
  key=app_key,
3154
3453
  name=app_name,
3155
3454
  ids={"app_key": app_key} if app_key else {},
3455
+ icon_config=icon_config,
3156
3456
  error_code=payload.get("error_code"),
3157
3457
  message=payload.get("message") if status == "failed" else None,
3158
3458
  )
@@ -3254,6 +3554,7 @@ def _builder_view_verification_by_name(payload: JSONObject) -> dict[str, JSONObj
3254
3554
 
3255
3555
 
3256
3556
  def _builder_view_identity(item: object, verification_by_name: dict[str, JSONObject]) -> tuple[str | None, str | None, str, object, object]:
3557
+ verification: JSONObject | None = None
3257
3558
  if isinstance(item, dict):
3258
3559
  name = item.get("name") or item.get("view_name") or item.get("viewName")
3259
3560
  view_key = item.get("view_key") or item.get("viewKey")
@@ -3274,6 +3575,16 @@ def _builder_view_identity(item: object, verification_by_name: dict[str, JSONObj
3274
3575
  matching = verification.get("matching_view_keys")
3275
3576
  if isinstance(matching, list) and matching:
3276
3577
  view_key = matching[0]
3578
+ if name and verification is None:
3579
+ verification = verification_by_name.get(str(name))
3580
+ if isinstance(verification, dict):
3581
+ verification_status = str(verification.get("status") or "").strip()
3582
+ if verification_status in {"removed", "readback_pending"}:
3583
+ status = "readback_pending"
3584
+ if verification_status == "removed":
3585
+ status = "removed"
3586
+ error_code = error_code or verification.get("error_code")
3587
+ message = message or verification.get("message")
3277
3588
  return (
3278
3589
  str(name) if name not in (None, "") else None,
3279
3590
  str(view_key) if view_key not in (None, "") else None,
@@ -3290,16 +3601,17 @@ def _builder_chart_resources(payload: JSONObject) -> list[JSONObject]:
3290
3601
  if not isinstance(item, dict):
3291
3602
  continue
3292
3603
  status = str(item.get("status") or "success")
3293
- operation = _builder_operation(status, fallback="updated")
3604
+ operation = _builder_operation(item.get("operation") or status, fallback="updated")
3294
3605
  if status == "failed":
3295
3606
  operation = "failed"
3607
+ resource_status = "failed" if status == "failed" else ("readback_pending" if status == "readback_pending" else "success")
3296
3608
  chart_id = item.get("chart_id") or item.get("chartId")
3297
3609
  chart_key = item.get("chart_key") or item.get("chartKey")
3298
3610
  resources.append(
3299
3611
  _builder_resource(
3300
3612
  resource_type="chart",
3301
3613
  operation=operation,
3302
- status="failed" if status == "failed" else "success",
3614
+ status=resource_status,
3303
3615
  id_value=chart_id,
3304
3616
  key=chart_key or chart_id,
3305
3617
  name=item.get("name") or item.get("chart_name") or item.get("chartName"),
@@ -3311,7 +3623,7 @@ def _builder_chart_resources(payload: JSONObject) -> list[JSONObject]:
3311
3623
  },
3312
3624
  parent=parent,
3313
3625
  error_code=item.get("error_code"),
3314
- message=item.get("message") if status == "failed" else None,
3626
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3315
3627
  )
3316
3628
  )
3317
3629
  return resources
@@ -3352,6 +3664,12 @@ def _builder_portal_resources(payload: JSONObject) -> list[JSONObject]:
3352
3664
  parent = None
3353
3665
  if package_id:
3354
3666
  parent = _builder_parent("package", id_value=package_id, key=package_id)
3667
+ icon_config = (
3668
+ _builder_container_icon_config(payload, raw_keys=("dash_icon", "dashIcon", "icon"))
3669
+ or _builder_container_icon_config(draft_result, raw_keys=("dashIcon", "dash_icon", "icon"))
3670
+ or _builder_container_icon_config(live_result, raw_keys=("dashIcon", "dash_icon", "icon"))
3671
+ or _builder_container_icon_config(normalized_args, raw_keys=("icon", "dash_icon", "dashIcon"))
3672
+ )
3355
3673
  return [
3356
3674
  _builder_resource(
3357
3675
  resource_type="portal",
@@ -3364,6 +3682,7 @@ def _builder_portal_resources(payload: JSONObject) -> list[JSONObject]:
3364
3682
  **({"package_id": package_id} if package_id else {}),
3365
3683
  },
3366
3684
  parent=parent,
3685
+ icon_config=icon_config,
3367
3686
  error_code=payload.get("error_code"),
3368
3687
  message=payload.get("message") if status == "failed" else None,
3369
3688
  )
@@ -3393,7 +3712,7 @@ def _builder_button_resources(payload: JSONObject) -> list[JSONObject]:
3393
3712
  },
3394
3713
  parent=parent,
3395
3714
  error_code=item.get("error_code"),
3396
- message=item.get("message") if status == "failed" else None,
3715
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3397
3716
  )
3398
3717
  )
3399
3718
  for item in payload.get("view_configs") or []:
@@ -3413,7 +3732,7 @@ def _builder_button_resources(payload: JSONObject) -> list[JSONObject]:
3413
3732
  },
3414
3733
  parent=parent,
3415
3734
  error_code=item.get("error_code"),
3416
- message=item.get("message") if status == "failed" else None,
3735
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3417
3736
  )
3418
3737
  )
3419
3738
  return resources
@@ -3457,7 +3776,7 @@ def _builder_associated_resource_resources(payload: JSONObject) -> list[JSONObje
3457
3776
  },
3458
3777
  parent=parent,
3459
3778
  error_code=item.get("error_code"),
3460
- message=item.get("message") if status == "failed" else None,
3779
+ message=item.get("message") if status in {"failed", "readback_pending"} else None,
3461
3780
  )
3462
3781
  )
3463
3782
  for item in payload.get("view_configs") or []:
@@ -3530,7 +3849,7 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
3530
3849
 
3531
3850
 
3532
3851
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
3533
- if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
3852
+ if backend_code_int(error) == 40074 or error_code == "APP_EDIT_LOCKED":
3534
3853
  return "app is currently locked by another active editor session"
3535
3854
  if error.http_status != 404:
3536
3855
  return error.message
@@ -3656,6 +3975,40 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3656
3975
  "tool_name": "chart_get",
3657
3976
  },
3658
3977
  },
3978
+ "workspace_icon_catalog_get": {
3979
+ "allowed_keys": [],
3980
+ "aliases": {},
3981
+ "allowed_values": {
3982
+ "icon": list(WORKSPACE_ICON_NAMES),
3983
+ "color": list(WORKSPACE_ICON_COLORS),
3984
+ "generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
3985
+ },
3986
+ "execution_notes": [
3987
+ "read this before creating app packages, apps, or portals when choosing supported workspace icons",
3988
+ "the CLI validates icon/color candidates but does not infer business defaults from resource names",
3989
+ "new app/package/portal creation requires explicit non-template icon + color",
3990
+ ],
3991
+ "minimal_example": {
3992
+ "profile": "default",
3993
+ },
3994
+ },
3995
+ "package_list": {
3996
+ "allowed_keys": ["trial_status", "query"],
3997
+ "aliases": {"trialStatus": "trial_status", "keyword": "query"},
3998
+ "allowed_values": {"trial_status": ["all"]},
3999
+ "execution_notes": [
4000
+ "lists app packages visible to the current builder profile by calling backend GET /tag?trialStatus=...",
4001
+ "query is applied locally to package_id/tag_id/package_name/tag_name after /tag returns",
4002
+ "does not fall back to app list because app list cannot represent empty packages, duplicate package names, or package-level permissions",
4003
+ "returns package_id/package_name plus compatible tag_id/tag_name; use package_get for package detail before editing",
4004
+ "permission failures are returned as PACKAGE_LIST_FAILED with backend transport details",
4005
+ ],
4006
+ "minimal_example": {
4007
+ "profile": "default",
4008
+ "trial_status": "all",
4009
+ "query": "产品研发",
4010
+ },
4011
+ },
3659
4012
  "package_get": {
3660
4013
  "allowed_keys": ["package_id"],
3661
4014
  "aliases": {"packageId": "package_id"},
@@ -3680,13 +4033,22 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3680
4033
  "iconColor": "color",
3681
4034
  "allowDetach": "allow_detach",
3682
4035
  },
3683
- "allowed_values": deepcopy(_VISIBILITY_ALLOWED_VALUES),
4036
+ "allowed_values": {
4037
+ **deepcopy(_VISIBILITY_ALLOWED_VALUES),
4038
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
4039
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
4040
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
4041
+ },
3684
4042
  "execution_notes": [
3685
4043
  "create or update package metadata, visibility, grouping, and ordering in one call",
4044
+ "creating a package requires explicit icon + color; icon=template is blocked because it is too generic",
4045
+ "updating a package preserves existing icon/color when omitted; explicit icon/color values are still validated",
4046
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
3686
4047
  "metadata keys omitted on update are preserved",
3687
4048
  "package_id maps internally to backend tagId; do not use tag_id in public calls",
3688
4049
  "items is a full package layout tree; omitting existing app/portal items is blocked unless allow_detach=true",
3689
4050
  "item shapes: {type:'app', app_key}, {type:'portal', dash_key}, or {type:'group', group_id?, name, items:[...]}",
4051
+ "layout apply calls backend package ordering (MoveGroupAuth), so it requires package edit_app permission even when items=[] only clears/deletes existing groups",
3690
4052
  *_VISIBILITY_EXECUTION_NOTES,
3691
4053
  ],
3692
4054
  "minimal_example": {
@@ -3706,7 +4068,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3706
4068
  "profile": "default",
3707
4069
  "package_name": "项目管理",
3708
4070
  "create_if_missing": True,
3709
- "icon": "files-folder",
4071
+ "icon": "briefcase",
3710
4072
  "color": "azure",
3711
4073
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
3712
4074
  },
@@ -3819,7 +4181,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3819
4181
  },
3820
4182
  "execution_notes": [
3821
4183
  "use this read-only tool before button writes when an agent needs a supported icon or color choice",
3822
- "current frontend only supports template icons and template colors from this catalog",
4184
+ "current frontend only supports button icons and button colors from this catalog",
3823
4185
  "text/icon color is unified through text_color; there is no separate icon_color",
3824
4186
  ],
3825
4187
  "minimal_example": {
@@ -3913,6 +4275,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3913
4275
  "field_mappings.source_field accepts source schema fields and supported system fields: 数据ID/row_record_id/apply_id/_id maps to current record id (-17), 编号/record_number maps to visible record number (0)",
3914
4276
  "to fill a target relation field with the current source record, map source_field='数据ID' to the target relation field; default_values is for static constants, not dynamic current-record values",
3915
4277
  "do not write raw que_relation unless maintaining a legacy config; field_mappings/default_values and que_relation are mutually exclusive",
4278
+ "permission split follows backend routes: upsert_buttons/patch_buttons/remove_buttons require EditAppAuth; view_configs also requires ViewManagementAuth (beingViewManageStatus), which falls back to DataManageAuth when advanced app permissions are not enabled",
3916
4279
  "view_configs binds custom buttons into views in the same apply call; button_ref may be a same-call client_key, a button_id, or an exact unique existing button_text",
3917
4280
  "view_configs[].view_key is the raw builder view key from app_get.views[].view_key; do not pass record-data view_id values like custom:VIEW_KEY",
3918
4281
  "view_configs[].buttons is required in merge mode; omitting buttons is blocked to avoid no-op writes and accidental publish",
@@ -3921,6 +4284,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
3921
4284
  "default placements are header and detail; header maps to frontend top buttons",
3922
4285
  "placement=list configures backend INSIDE row/list buttons; header maps to TOP and detail maps to DETAIL",
3923
4286
  "remove_buttons supports button_id or exact unique button_text",
4287
+ "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",
4288
+ "if a removed button returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
3924
4289
  "all operations share one edit context and publish after at least one write succeeds; there is no draft-only mode for this tool",
3925
4290
  "background_color and text_color cannot both be white",
3926
4291
  ],
@@ -4054,6 +4419,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4054
4419
  "this tool manages Qingflow in-app associated report/view display; it does not create or edit QingBI report bodies/configs",
4055
4420
  "create or edit app-source BI report bodies first with app_charts_apply, then attach the resulting chart_id here with graph_type=chart; dataset BI reports can only be attached when they already exist",
4056
4421
  "this is the default associated report/view path; it manages both the app-level associated resource pool and per-view display config",
4422
+ "permission split follows backend routes: upsert_resources/patch_resources/remove/reorder require EditAppAuth; view_configs require ViewManagementAuth (beingViewManageStatus), which falls back to DataManageAuth when advanced app permissions are not enabled",
4057
4423
  "use patch_resources for partial parameter replacement on existing associated resources; the tool reads the current resource including backend-required raw fields, merges patch_resources[].set/unset, then submits the backend full-save payload internally",
4058
4424
  "associated_item_id is form_asos_chart.id from app_get.associated_resources[].associated_item_id; view_configs/remove/reorder may also pass an existing associated resource's chart_id/chart_key/view_key and the tool resolves it to the internal id",
4059
4425
  "before creating an associated resource, read app_get.associated_resources and reuse an existing item with patch_resources when target_app_key + view_key/chart_key already matches; repeated upsert_resources without associated_item_id can create duplicate associated items",
@@ -4063,6 +4429,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4063
4429
  "match_mappings.source_field accepts source schema fields plus system fields 数据ID(-17) and 编号(0); match_mappings compiles to backend matchRules",
4064
4430
  "do not write raw match_rules unless preserving a legacy backend config; match_mappings and match_rules are mutually exclusive",
4065
4431
  "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",
4432
+ "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",
4433
+ "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",
4066
4434
  "this tool publishes after at least one write succeeds; there is no draft-only mode",
4067
4435
  "visible=false hides the associated-resource area without clearing previous selected ids; visible=true with limit_type=all shows the whole app-level pool",
4068
4436
  ],
@@ -4129,17 +4497,22 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4129
4497
  "field.type": [member.value for member in PublicFieldType],
4130
4498
  "field.relation_mode": [member.value for member in PublicRelationMode],
4131
4499
  "field_type_ids": sorted(FIELD_TYPE_ID_ALIASES.keys()),
4500
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
4501
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
4502
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
4132
4503
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
4133
4504
  },
4134
4505
  "execution_notes": [
4135
4506
  "create mode may set visibility for the new app; edit mode may update visibility on an existing app",
4507
+ "create mode should include explicit non-template icon + color; apply mode enforces this before writing",
4508
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
4136
4509
  *_VISIBILITY_EXECUTION_NOTES,
4137
4510
  ],
4138
4511
  "minimal_example": {
4139
4512
  "profile": "default",
4140
4513
  "app_name": "研发项目管理",
4141
4514
  "package_id": 1001,
4142
- "icon": "template",
4515
+ "icon": "briefcase",
4143
4516
  "color": "emerald",
4144
4517
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
4145
4518
  "create_if_missing": True,
@@ -4235,10 +4608,15 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4235
4608
  "use exactly one resource mode",
4236
4609
  "edit mode: app_key, optional app_name to rename the existing app",
4237
4610
  "create mode: package_id + app_name + create_if_missing=true",
4611
+ "create mode follows backend CreateAppBean: package add_app permission is checked on the target package; package edit_app is not required for the create precheck",
4238
4612
  "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",
4239
4613
  "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",
4240
4614
  "multi-app mode is not transactional; read created_app_keys and apps[].status before retrying, and retry only failed app items",
4241
4615
  "create mode defaults new app visibility to workspace/not when visibility is omitted; edit mode preserves current visibility when omitted",
4616
+ "create mode requires explicit icon + color; icon=template is blocked because it is too generic",
4617
+ "multi-app create mode requires each new app item to include a distinct non-template icon and a valid color",
4618
+ "edit mode preserves existing icon/color when omitted; explicit icon/color values are still validated",
4619
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
4242
4620
  *_VISIBILITY_EXECUTION_NOTES,
4243
4621
  "update_fields is the field-level partial update path; it reads current form schema and preserves untouched field config",
4244
4622
  "multiple relation fields are backend-risky; read verification.relation_field_limit_verified and warnings before declaring the schema stable",
@@ -4263,7 +4641,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4263
4641
  "profile": "default",
4264
4642
  "app_name": "研发项目管理",
4265
4643
  "package_id": 1001,
4266
- "icon": "template",
4644
+ "icon": "briefcase",
4267
4645
  "color": "emerald",
4268
4646
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
4269
4647
  "create_if_missing": True,
@@ -4284,6 +4662,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4284
4662
  {
4285
4663
  "client_key": "employee",
4286
4664
  "app_name": "员工花名册",
4665
+ "icon": "business-personalcard",
4666
+ "color": "emerald",
4287
4667
  "add_fields": [
4288
4668
  {"name": "员工名称", "type": "text", "as_data_title": True},
4289
4669
  {"name": "员工照片", "type": "attachment", "as_data_cover": True},
@@ -4292,6 +4672,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4292
4672
  {
4293
4673
  "client_key": "worklog",
4294
4674
  "app_name": "工时表",
4675
+ "icon": "clock",
4676
+ "color": "blue",
4295
4677
  "add_fields": [
4296
4678
  {"name": "工时标题", "type": "text", "as_data_title": True},
4297
4679
  {
@@ -4627,10 +5009,13 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4627
5009
  **deepcopy(_VISIBILITY_ALLOWED_VALUES),
4628
5010
  },
4629
5011
  "execution_notes": [
5012
+ "creating a new view follows backend createViewgraphConfig and requires both ViewManagementAuth and DataManageAuth; updating/deleting existing views only requires ViewManagementAuth",
4630
5013
  "upsert_views[].visibility may set per-view visibility; omit it to preserve an existing view's auth or default a new view to workspace/not",
4631
5014
  "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",
4632
5015
  "upsert_views[].query_conditions.rows is a layout matrix of field names; it is compiled to backend queryCondition queIds",
4633
5016
  "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",
5017
+ "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",
5018
+ "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",
4634
5019
  "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",
4635
5020
  "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",
4636
5021
  "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",
@@ -4749,6 +5134,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4749
5134
  },
4750
5135
  "execution_notes": [
4751
5136
  "apply may return partial_success when some views land and others fail",
5137
+ "creating a new view follows backend createViewgraphConfig and requires both ViewManagementAuth and DataManageAuth; updating/deleting existing views only requires ViewManagementAuth",
4752
5138
  "when duplicate view names exist, supply view_key to target the exact view",
4753
5139
  "read back app_get after any failed or partial view apply",
4754
5140
  "view existence verification and saved-filter verification are separate; treat filters as unverified until verification.view_filters_verified is true",
@@ -4757,6 +5143,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4757
5143
  "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",
4758
5144
  "upsert_views[].query_conditions.rows is a layout matrix of field names; it is compiled to backend queryCondition queIds",
4759
5145
  "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",
5146
+ "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",
5147
+ "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",
4760
5148
  "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",
4761
5149
  "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",
4762
5150
  "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",
@@ -4832,8 +5220,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4832
5220
  "returns builder-side app map: base summary, editability, field/view/chart/button counts, compact views, compact charts, custom_buttons, and app-level associated_resources",
4833
5221
  "use this as the default builder discovery read before view_get/chart_get/apply detail work",
4834
5222
  "editability is route-aware builder capability summary, not end-user data visibility",
4835
- "can_edit_app_base covers app base-info writes such as app_name, icon, and visibility",
4836
- "can_edit_form covers form/schema routes only and does not imply app base-info writes",
5223
+ "can_edit_app_base covers app base-info writes such as app_name, icon, and visibility; it follows backend EditAppAuth and does not require package edit_tag",
5224
+ "can_edit_form covers form/schema routes and also follows backend EditAppAuth",
4837
5225
  "returns normalized app visibility when backend auth is readable",
4838
5226
  "custom_buttons[].button_id is the id required by app_custom_buttons_apply view_configs[].buttons[].button_ref",
4839
5227
  "associated_resources[].associated_item_id is the internal id; app_associated_resources_apply.view_configs/remove/reorder may also pass an existing resource's chart_id/chart_key/view_key",
@@ -4850,6 +5238,8 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4850
5238
  "execution_notes": [
4851
5239
  "returns compact current field configuration for one app",
4852
5240
  "use this before app_schema_apply when you need exact field definitions",
5241
+ "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",
5242
+ "chart_fields[].field_id supports field_<queId> selectors, while chart_fields[].bi_field_id is the raw QingBI fieldId accepted by report configs",
4853
5243
  "subtable fields include nested subfields using the same compact field shape",
4854
5244
  ],
4855
5245
  "minimal_example": {
@@ -4971,6 +5361,12 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4971
5361
  "successful create results must return a real backend chart_id",
4972
5362
  "upsert_charts[].visibility compiles to QingBI base visibleAuth only",
4973
5363
  "visibility-only updates keep the existing chart config and do not rewrite rawDataConfigDTO.authInfo",
5364
+ "chart dimension/metric/filter/query fields are resolved from app_get_fields.chart_fields (QingBI datasource fields), not record schema or form-only fields",
5365
+ "system fields such as 申请人/申请时间/编号 are usable only when they appear in chart_fields; otherwise app_charts_apply returns CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
5366
+ "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",
5367
+ "chart rule failures return chart_results[].diagnostics with rule_code, expected, actual, offending_fields, and next_action; backend 81002/81005 are translated when possible",
5368
+ "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",
5369
+ "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",
4974
5370
  *_VISIBILITY_EXECUTION_NOTES,
4975
5371
  ],
4976
5372
  "minimal_example": {
@@ -5014,9 +5410,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5014
5410
  },
5015
5411
  },
5016
5412
  "portal_apply": {
5017
- "allowed_keys": ["dash_key", "dash_name", "package_id", "publish", "sections", "visibility", "auth", "icon", "color", "hide_copyright", "dash_global_config", "config"],
5413
+ "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"],
5018
5414
  "aliases": {
5019
5415
  "packageId": "package_id",
5416
+ "name": "dash_name",
5020
5417
  "sourceType": "source_type",
5021
5418
  "chartRef": "chart_ref",
5022
5419
  "viewRef": "view_ref",
@@ -5029,20 +5426,35 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5029
5426
  "viewRef": "view_ref",
5030
5427
  "dashStyleConfigBO": "dash_style_config",
5031
5428
  },
5032
- "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"], **deepcopy(_VISIBILITY_ALLOWED_VALUES)},
5033
- "execution_notes": [
5034
- "use exactly one resource mode",
5035
- "update mode: dash_key",
5036
- "create mode: package_id + dash_name",
5037
- "portal_apply uses replace semantics for sections",
5038
- "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
5039
- "portal section-level patch is not exposed; supplying sections means full sections replacement",
5040
- "remove a section by omitting it from the new sections list",
5041
- "package_id is required when creating a new portal",
5042
- "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
5429
+ "allowed_values": {
5430
+ "section.source_type": ["chart", "view", "grid", "filter", "text", "link"],
5431
+ "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
5432
+ "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
5433
+ "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
5434
+ **deepcopy(_VISIBILITY_ALLOWED_VALUES),
5435
+ },
5436
+ "execution_notes": [
5437
+ "use exactly one resource mode",
5438
+ "update mode: dash_key",
5439
+ "create mode: package_id + dash_name",
5440
+ "create mode follows backend DashCtrl.createDash: package add_app permission is checked on the target package; package edit_app is not required for the create precheck",
5441
+ "create mode requires explicit icon + color; icon=template is blocked because it is too generic",
5442
+ "edit mode preserves existing icon/color when omitted; explicit icon/color values are still validated",
5443
+ "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
5444
+ "portal_apply uses replace semantics for sections",
5445
+ "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
5446
+ "portal section-level patch is not exposed; supplying sections means full sections replacement",
5447
+ "remove a section by omitting it from the new sections list",
5448
+ "package_id is required when creating a new portal",
5449
+ "publish=false only guarantees draft and base-info updates; it does not claim live has changed",
5043
5450
  "chart_ref resolves by chart_id first, then exact unique chart_name",
5044
5451
  "view_ref resolves by view_key first, then exact unique view_name",
5452
+ "pc layout uses a 24-column grid; mobile layout uses a 6-column grid",
5453
+ "if unsure about layout, omit position or use layout_preset=auto/dashboard_2col/dashboard_3col",
5454
+ "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",
5455
+ "x=0/6 with cols=6 only occupies the left half of the pc portal and triggers PORTAL_LAYOUT_HALF_WIDTH",
5045
5456
  "position.pc/mobile is the canonical portal layout shape",
5457
+ "compat payload accepts name -> dash_name and single pages[0].components -> sections",
5046
5458
  "visibility is the canonical public auth shape; auth is kept only as a deprecated compatibility alias",
5047
5459
  "passing visibility and auth together is rejected as VISIBILITY_CONFLICT",
5048
5460
  *_VISIBILITY_EXECUTION_NOTES,
@@ -5051,7 +5463,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5051
5463
  "profile": "default",
5052
5464
  "dash_name": "经营门户",
5053
5465
  "package_id": 1001,
5466
+ "icon": "view-grid",
5467
+ "color": "blue",
5054
5468
  "publish": True,
5469
+ "layout_preset": "dashboard_2col",
5055
5470
  "visibility": deepcopy(_VISIBILITY_WORKSPACE_EXAMPLE),
5056
5471
  "sections": [
5057
5472
  {
@@ -5065,6 +5480,23 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5065
5480
  }
5066
5481
  ],
5067
5482
  },
5483
+ "compat_payload_example": {
5484
+ "name": "经营门户",
5485
+ "package_id": 1001,
5486
+ "layout_preset": "dashboard_2col",
5487
+ "pages": [
5488
+ {
5489
+ "title": "经营总览",
5490
+ "components": [
5491
+ {
5492
+ "title": "销售趋势",
5493
+ "source_type": "chart",
5494
+ "chart_ref": {"app_key": "APP_KEY", "chart_id": "CHART_ID"},
5495
+ }
5496
+ ],
5497
+ }
5498
+ ],
5499
+ },
5068
5500
  "minimal_section_example": {
5069
5501
  "title": "订单概览",
5070
5502
  "source_type": "view",