@qingflow-tech/qingflow-app-user-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 (89) hide show
  1. package/README.md +9 -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 +255 -0
  10. package/skills/qingflow-app-builder/agents/openai.yaml +4 -0
  11. package/skills/qingflow-app-builder/references/create-app.md +149 -0
  12. package/skills/qingflow-app-builder/references/environments.md +63 -0
  13. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  14. package/skills/qingflow-app-builder/references/gotchas.md +107 -0
  15. package/skills/qingflow-app-builder/references/match-rules.md +114 -0
  16. package/skills/qingflow-app-builder/references/public-surface-sync.md +75 -0
  17. package/skills/qingflow-app-builder/references/solution-playbooks.md +52 -0
  18. package/skills/qingflow-app-builder/references/tool-selection.md +99 -0
  19. package/skills/qingflow-app-builder/references/update-flow.md +158 -0
  20. package/skills/qingflow-app-builder/references/update-layout.md +68 -0
  21. package/skills/qingflow-app-builder/references/update-schema.md +72 -0
  22. package/skills/qingflow-app-builder/references/update-views.md +284 -0
  23. package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
  24. package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
  25. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
  26. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
  27. package/skills/qingflow-app-user/SKILL.md +12 -11
  28. package/skills/qingflow-app-user/references/data-gotchas.md +2 -2
  29. package/skills/qingflow-app-user/references/public-surface-sync.md +3 -3
  30. package/skills/qingflow-app-user/references/record-patterns.md +5 -5
  31. package/skills/qingflow-app-user/references/workflow-usage.md +4 -5
  32. package/skills/qingflow-mcp-setup/SKILL.md +113 -0
  33. package/skills/qingflow-mcp-setup/agents/openai.yaml +4 -0
  34. package/skills/qingflow-mcp-setup/references/claude-desktop.md +34 -0
  35. package/skills/qingflow-mcp-setup/references/environments.md +62 -0
  36. package/skills/qingflow-mcp-setup/references/generic-stdio.md +32 -0
  37. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +38 -0
  38. package/skills/qingflow-record-analysis/SKILL.md +6 -7
  39. package/skills/qingflow-record-analysis/manifest.yaml +10 -0
  40. package/skills/qingflow-record-delete/SKILL.md +5 -3
  41. package/skills/qingflow-record-import/SKILL.md +6 -2
  42. package/skills/qingflow-record-insert/SKILL.md +48 -4
  43. package/skills/qingflow-record-insert/manifest.yaml +6 -0
  44. package/skills/qingflow-record-update/SKILL.md +36 -24
  45. package/skills/qingflow-task-ops/SKILL.md +25 -25
  46. package/skills/qingflow-task-ops/references/environments.md +0 -1
  47. package/skills/qingflow-task-ops/references/workflow-usage.md +4 -6
  48. package/src/qingflow_mcp/__main__.py +6 -2
  49. package/src/qingflow_mcp/builder_facade/models.py +41 -2
  50. package/src/qingflow_mcp/builder_facade/service.py +2743 -423
  51. package/src/qingflow_mcp/cli/commands/app.py +3 -16
  52. package/src/qingflow_mcp/cli/commands/builder.py +30 -4
  53. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  54. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  55. package/src/qingflow_mcp/cli/commands/record.py +54 -11
  56. package/src/qingflow_mcp/cli/context.py +0 -3
  57. package/src/qingflow_mcp/cli/formatters.py +238 -8
  58. package/src/qingflow_mcp/cli/main.py +47 -3
  59. package/src/qingflow_mcp/errors.py +43 -2
  60. package/src/qingflow_mcp/public_surface.py +24 -16
  61. package/src/qingflow_mcp/response_trim.py +119 -12
  62. package/src/qingflow_mcp/server.py +17 -14
  63. package/src/qingflow_mcp/server_app_builder.py +29 -7
  64. package/src/qingflow_mcp/server_app_user.py +23 -24
  65. package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
  66. package/src/qingflow_mcp/solution/executor.py +112 -15
  67. package/src/qingflow_mcp/tools/ai_builder_tools.py +497 -65
  68. package/src/qingflow_mcp/tools/app_tools.py +237 -51
  69. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  70. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  71. package/src/qingflow_mcp/tools/code_block_tools.py +296 -39
  72. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  73. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  74. package/src/qingflow_mcp/tools/export_tools.py +230 -33
  75. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  76. package/src/qingflow_mcp/tools/import_tools.py +293 -40
  77. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  78. package/src/qingflow_mcp/tools/package_tools.py +134 -8
  79. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  80. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  81. package/src/qingflow_mcp/tools/record_tools.py +2305 -442
  82. package/src/qingflow_mcp/tools/resource_read_tools.py +191 -39
  83. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  84. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  85. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  86. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  87. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  88. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  89. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -14,13 +14,13 @@ from urllib.parse import quote_plus, unquote_plus
14
14
  from uuid import uuid4
15
15
 
16
16
  from ..backend_client import BackendRequestContext
17
- from ..errors import QingflowApiError
17
+ from ..errors import QingflowApiError, backend_code_int, backend_code_value_int, is_auth_like_error
18
18
  from ..json_types import JSONObject
19
19
  from ..list_type_labels import RECORD_LIST_TYPE_LABELS, SYSTEM_VIEW_ID_TO_LIST_TYPE
20
20
  from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
21
21
  from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
22
22
  from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
23
- from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults
23
+ from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults, workspace_icon_config
24
24
  from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
25
25
  from ..solution.executor import _build_viewgraph_questions, _compact_dict, extract_field_map
26
26
  from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
@@ -374,7 +374,18 @@ class AiBuilderFacade:
374
374
  if existing.get("error_code") == "AMBIGUOUS_PACKAGE":
375
375
  return existing
376
376
  if existing.get("error_code") == "PACKAGE_RESOLVE_FAILED":
377
- if existing.get("backend_code") not in {40002, 40027}:
377
+ existing_details = existing.get("details") if isinstance(existing.get("details"), dict) else {}
378
+ existing_transport_error = (
379
+ existing_details.get("transport_error") if isinstance(existing_details.get("transport_error"), dict) else {}
380
+ )
381
+ existing_error = QingflowApiError(
382
+ category=str(existing_transport_error.get("category") or ""),
383
+ message=str(existing.get("message") or ""),
384
+ backend_code=existing.get("backend_code"),
385
+ http_status=existing.get("http_status"),
386
+ request_id=existing.get("request_id"),
387
+ )
388
+ if is_auth_like_error(existing_error) or backend_code_value_int(existing.get("backend_code")) not in {40002, 40027}:
378
389
  return existing
379
390
  lookup_permission_blocked = {
380
391
  "backend_code": existing.get("backend_code"),
@@ -468,7 +479,17 @@ class AiBuilderFacade:
468
479
  try:
469
480
  detail_result = self.packages.package_get(profile=profile, tag_id=effective_package_id, include_raw=True)
470
481
  except (QingflowApiError, RuntimeError) as error:
471
- detail_read_error = _coerce_api_error(error)
482
+ api_error = _coerce_api_error(error)
483
+ if _is_optional_builder_lookup_error(api_error):
484
+ detail_read_error = api_error
485
+ else:
486
+ return _failed_from_api_error(
487
+ "PACKAGE_GET_FAILED",
488
+ api_error,
489
+ normalized_args=normalized_args,
490
+ details={"package_id": effective_package_id},
491
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": effective_package_id}},
492
+ )
472
493
 
473
494
  detail = detail_result.get("result") if isinstance(detail_result, dict) and isinstance(detail_result.get("result"), dict) else {}
474
495
  base = base_result.get("result") if isinstance(base_result.get("result"), dict) else {}
@@ -476,14 +497,17 @@ class AiBuilderFacade:
476
497
  source = detail if detail else base
477
498
  layout_tag_items = _select_package_layout_tag_items(detail=detail, base=base)
478
499
  warnings: list[JSONObject] = []
500
+ if isinstance(detail_result, dict) and isinstance(detail_result.get("warnings"), list):
501
+ warnings.extend(deepcopy(item) for item in detail_result.get("warnings", []) if isinstance(item, dict))
479
502
  if detail_read_error is not None:
480
503
  warnings.append(
481
- {
482
- "code": "PACKAGE_DETAIL_READ_DEGRADED",
483
- "message": "package_get used baseInfo because the package detail endpoint was not readable",
484
- "backend_code": detail_read_error.backend_code,
485
- "http_status": detail_read_error.http_status,
486
- }
504
+ _warning(
505
+ "PACKAGE_DETAIL_READ_DEGRADED",
506
+ "package_get used baseInfo because the package detail endpoint was not readable",
507
+ backend_code=detail_read_error.backend_code,
508
+ http_status=detail_read_error.http_status,
509
+ request_id=detail_read_error.request_id,
510
+ )
487
511
  )
488
512
  public_items = _public_package_items_from_tag_items(layout_tag_items)
489
513
  item_count = summary.get("itemCount")
@@ -624,8 +648,34 @@ class AiBuilderFacade:
624
648
  if layout_result.get("status") not in {"success", "partial_success"}:
625
649
  return _apply_permission_outcomes(layout_result, *permission_outcomes)
626
650
 
651
+ write_executed = bool(
652
+ created
653
+ or (
654
+ metadata_requested
655
+ and isinstance(update_result, dict)
656
+ and update_result.get("status") in {"success", "partial_success"}
657
+ and not bool(update_result.get("noop"))
658
+ )
659
+ or (
660
+ items is not None
661
+ and isinstance(layout_result, dict)
662
+ and layout_result.get("status") in {"success", "partial_success"}
663
+ and not bool(layout_result.get("noop"))
664
+ )
665
+ )
627
666
  verification = self.package_get(profile=profile, package_id=effective_package_id)
628
667
  if verification.get("status") != "success":
668
+ if write_executed:
669
+ return _apply_permission_outcomes(
670
+ _post_write_readback_pending_result(
671
+ error_code="PACKAGE_READBACK_PENDING",
672
+ message="applied package; final package readback is unavailable",
673
+ normalized_args=normalized_args,
674
+ details={"package_id": effective_package_id, "verification_result": verification},
675
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": effective_package_id}},
676
+ ),
677
+ *permission_outcomes,
678
+ )
629
679
  return _apply_permission_outcomes(verification, *permission_outcomes)
630
680
  expected_visibility = None
631
681
  if visibility is not None:
@@ -643,6 +693,11 @@ class AiBuilderFacade:
643
693
  layout_verified = True
644
694
  if items is not None and layout_result is not None:
645
695
  layout_verified = bool(layout_result.get("verified"))
696
+ layout_error_code = (
697
+ str(layout_result.get("error_code") or "").strip()
698
+ if isinstance(layout_result, dict) and layout_result.get("error_code")
699
+ else None
700
+ )
646
701
  response_verification: JSONObject = {
647
702
  "package_exists": True,
648
703
  "package_created": created,
@@ -660,21 +715,36 @@ class AiBuilderFacade:
660
715
  if key in update_verification:
661
716
  response_verification[key] = deepcopy(update_verification.get(key))
662
717
  response_verified = metadata_verified and layout_verified and response_verification.get("visibility_verified") is not False
718
+ response_warnings = []
719
+ if isinstance(layout_result, dict) and isinstance(layout_result.get("warnings"), list):
720
+ response_warnings.extend(deepcopy(layout_result.get("warnings") or []))
663
721
  response: JSONObject = {
664
722
  "status": "success" if response_verified else "partial_success",
665
- "error_code": None,
723
+ "error_code": None if response_verified else layout_error_code,
666
724
  "recoverable": False,
667
725
  "message": "applied package" if response_verified else "applied package with unverified readback",
668
726
  "normalized_args": normalized_args,
669
727
  "missing_fields": [],
670
728
  "allowed_values": {},
671
- "details": {"layout_result": layout_result} if layout_result is not None else {},
729
+ "details": {
730
+ **({"layout_result": layout_result} if layout_result is not None else {}),
731
+ **(
732
+ {"layout_write_error": layout_result.get("details", {}).get("write_error")}
733
+ if isinstance(layout_result, dict)
734
+ and isinstance(layout_result.get("details"), dict)
735
+ and isinstance(layout_result.get("details", {}).get("write_error"), dict)
736
+ else {}
737
+ ),
738
+ },
672
739
  "request_id": None,
673
740
  "suggested_next_call": None,
674
741
  "noop": not (created or metadata_requested or items is not None),
675
- "warnings": [],
742
+ "warnings": response_warnings,
676
743
  "verification": response_verification,
677
744
  "verified": response_verified,
745
+ "write_executed": write_executed,
746
+ "write_succeeded": write_executed,
747
+ "safe_to_retry": not write_executed,
678
748
  **{
679
749
  key: deepcopy(value)
680
750
  for key, value in verification.items()
@@ -719,7 +789,6 @@ class AiBuilderFacade:
719
789
  if _coerce_positive_int(tag_id) is None:
720
790
  return _failed("TAG_ID_REQUIRED", "tag_id must be positive", normalized_args=normalized_args, suggested_next_call=None)
721
791
  try:
722
- current = self.packages.package_get(profile=profile, tag_id=tag_id, include_raw=True)
723
792
  current_base = self.packages.package_get_base(profile=profile, tag_id=tag_id, include_raw=True)
724
793
  except (QingflowApiError, RuntimeError) as error:
725
794
  api_error = _coerce_api_error(error)
@@ -730,8 +799,37 @@ class AiBuilderFacade:
730
799
  details={"tag_id": tag_id},
731
800
  suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "tag_id": tag_id}},
732
801
  )
733
- raw_current = current.get("result") if isinstance(current.get("result"), dict) else {}
802
+ current: JSONObject | None = None
803
+ detail_read_error: QingflowApiError | None = None
804
+ try:
805
+ current = self.packages.package_get(profile=profile, tag_id=tag_id, include_raw=True)
806
+ except (QingflowApiError, RuntimeError) as error:
807
+ api_error = _coerce_api_error(error)
808
+ if _is_optional_builder_lookup_error(api_error):
809
+ detail_read_error = api_error
810
+ else:
811
+ return _failed_from_api_error(
812
+ "PACKAGE_UPDATE_FAILED",
813
+ api_error,
814
+ normalized_args=normalized_args,
815
+ details={"tag_id": tag_id},
816
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "tag_id": tag_id}},
817
+ )
818
+ raw_current = current.get("result") if isinstance(current, dict) and isinstance(current.get("result"), dict) else {}
734
819
  raw_current_base = current_base.get("result") if isinstance(current_base.get("result"), dict) else {}
820
+ warnings: list[JSONObject] = []
821
+ if isinstance(current, dict) and isinstance(current.get("warnings"), list):
822
+ warnings.extend(deepcopy(item) for item in current.get("warnings", []) if isinstance(item, dict))
823
+ if detail_read_error is not None:
824
+ warnings.append(
825
+ _warning(
826
+ "PACKAGE_DETAIL_READ_DEGRADED",
827
+ "package_update used baseInfo because the package detail endpoint was not readable",
828
+ backend_code=detail_read_error.backend_code,
829
+ http_status=detail_read_error.http_status,
830
+ request_id=detail_read_error.request_id,
831
+ )
832
+ )
735
833
  current_name = str(raw_current.get("tagName") or raw_current_base.get("tagName") or "").strip() or None
736
834
  desired_name = str(package_name or current_name or "").strip() or current_name or "未命名应用包"
737
835
  desired_icon = encode_workspace_icon_with_defaults(
@@ -772,7 +870,13 @@ class AiBuilderFacade:
772
870
  )
773
871
  verification = self.package_get(profile=profile, package_id=tag_id)
774
872
  if verification.get("status") != "success":
775
- return verification
873
+ return _post_write_readback_pending_result(
874
+ error_code="PACKAGE_UPDATE_READBACK_PENDING",
875
+ message="updated package; package readback is unavailable",
876
+ normalized_args=normalized_args,
877
+ details={"tag_id": tag_id, "package_id": tag_id, "verification_result": verification},
878
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": tag_id}},
879
+ )
776
880
  package_name_verified = str(verification.get("package_name") or "").strip() == desired_name
777
881
  package_icon_verified = str(verification.get("icon") or "").strip() == desired_icon
778
882
  visibility_verified = _visibility_matches_expected(
@@ -792,7 +896,7 @@ class AiBuilderFacade:
792
896
  "request_id": update_result.get("request_id") if isinstance(update_result, dict) else None,
793
897
  "suggested_next_call": None if verified else {"tool_name": "package_get", "arguments": {"profile": profile, "package_id": tag_id}},
794
898
  "noop": False,
795
- "warnings": [],
899
+ "warnings": warnings,
796
900
  "verification": {
797
901
  "package_exists": True,
798
902
  "package_name_verified": package_name_verified,
@@ -800,6 +904,8 @@ class AiBuilderFacade:
800
904
  "visibility_verified": visibility_verified,
801
905
  },
802
906
  "verified": verified,
907
+ "write_executed": True,
908
+ "safe_to_retry": False,
803
909
  **{
804
910
  key: deepcopy(value)
805
911
  for key, value in verification.items()
@@ -876,16 +982,25 @@ class AiBuilderFacade:
876
982
  "suggested_next_call": None,
877
983
  "noop": False,
878
984
  "verification": {},
985
+ "write_executed": True,
986
+ "safe_to_retry": False,
879
987
  }
880
988
 
881
- def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
989
+ def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
882
990
  listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
991
+ raw_items = listed.get("items") if isinstance(listed.get("items"), list) else []
992
+ items = [_publicize_package_list_item(item) for item in raw_items if isinstance(item, dict)]
993
+ normalized_query = str(query or "").strip()
994
+ if normalized_query:
995
+ filtered_items = [item for item in items if _package_list_item_matches_query(item, normalized_query)]
996
+ else:
997
+ filtered_items = items
883
998
  return {
884
999
  "status": "success",
885
1000
  "error_code": None,
886
1001
  "recoverable": False,
887
1002
  "message": "listed packages",
888
- "normalized_args": {"trial_status": trial_status},
1003
+ "normalized_args": {"trial_status": trial_status, "query": normalized_query},
889
1004
  "missing_fields": [],
890
1005
  "allowed_values": {},
891
1006
  "details": {},
@@ -894,8 +1009,12 @@ class AiBuilderFacade:
894
1009
  "noop": False,
895
1010
  "verification": {},
896
1011
  "trial_status": trial_status,
897
- "items": listed.get("items") if isinstance(listed.get("items"), list) else [],
898
- "count": listed.get("count") or 0,
1012
+ "query": normalized_query,
1013
+ "items": filtered_items,
1014
+ "count": len(filtered_items),
1015
+ "matched_count": len(filtered_items),
1016
+ "unfiltered_count": len(items),
1017
+ "filter_mode": "local_packages",
899
1018
  "source_shape": listed.get("source_shape"),
900
1019
  "retried": bool(listed.get("retried", False)),
901
1020
  }
@@ -916,6 +1035,14 @@ class AiBuilderFacade:
916
1035
  current_detail_result = self.packages.package_get(profile=profile, tag_id=package_id, include_raw=True)
917
1036
  except (QingflowApiError, RuntimeError) as detail_error:
918
1037
  detail_api_error = _coerce_api_error(detail_error)
1038
+ if not _is_optional_builder_lookup_error(detail_api_error):
1039
+ return _failed_from_api_error(
1040
+ "PACKAGE_LAYOUT_READ_FAILED",
1041
+ detail_api_error,
1042
+ normalized_args=normalized_args,
1043
+ details={"package_id": package_id},
1044
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
1045
+ )
919
1046
  try:
920
1047
  current_base_result = self.packages.package_get_base(profile=profile, tag_id=package_id, include_raw=True)
921
1048
  except (QingflowApiError, RuntimeError) as base_error:
@@ -996,7 +1123,9 @@ class AiBuilderFacade:
996
1123
  permission_outcomes: list[PermissionCheckOutcome] = []
997
1124
  needs_group_create = any(_coerce_positive_int(group.get("group_id")) is None for group in desired_groups)
998
1125
  needs_group_delete = bool(deleted_group_ids)
999
- needs_edit_app = bool(normalized_items)
1126
+ # sortGroupUnderPackage is always called for a layout apply and is guarded by
1127
+ # backend MoveGroupAuth, which maps to package editAppStatus.
1128
+ needs_edit_app = True
1000
1129
  for required_permission in (
1001
1130
  (["add_app"] if needs_group_create else [])
1002
1131
  + (["edit_app"] if needs_edit_app else [])
@@ -1094,13 +1223,47 @@ class AiBuilderFacade:
1094
1223
  except (QingflowApiError, RuntimeError) as error:
1095
1224
  api_error = _coerce_api_error(error)
1096
1225
  return _apply_permission_outcomes(
1097
- _failed_from_api_error(
1098
- "PACKAGE_GROUP_DELETE_FAILED",
1099
- api_error,
1100
- normalized_args=normalized_args,
1101
- details={"package_id": package_id, "group_id": group_id},
1102
- suggested_next_call=None,
1103
- ),
1226
+ {
1227
+ "status": "partial_success",
1228
+ "error_code": "PACKAGE_APPLY_PARTIAL",
1229
+ "recoverable": True,
1230
+ "message": "package layout sort was applied, but a later group delete failed",
1231
+ "normalized_args": normalized_args,
1232
+ "missing_fields": [],
1233
+ "allowed_values": {},
1234
+ "details": {
1235
+ "package_id": package_id,
1236
+ "group_id": group_id,
1237
+ "group_operations": group_operations,
1238
+ "sort_result": sort_result,
1239
+ "write_error": {
1240
+ "message": api_error.message,
1241
+ "transport_error": _transport_error_payload(api_error),
1242
+ },
1243
+ },
1244
+ "request_id": api_error.request_id,
1245
+ "backend_code": api_error.backend_code,
1246
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
1247
+ "suggested_next_call": None,
1248
+ "noop": False,
1249
+ "warnings": [
1250
+ _warning(
1251
+ "PACKAGE_WRITE_INCOMPLETE_AFTER_PARTIAL_WRITE",
1252
+ "package layout write was partially applied before a later write step failed; do not blindly repeat the same package apply",
1253
+ **_transport_error_payload(api_error),
1254
+ )
1255
+ ],
1256
+ "verification": {
1257
+ "layout_applied": True,
1258
+ "write_incomplete": True,
1259
+ "metadata_unverified": True,
1260
+ },
1261
+ "verified": False,
1262
+ "package_id": package_id,
1263
+ "write_executed": True,
1264
+ "write_succeeded": True,
1265
+ "safe_to_retry": False,
1266
+ },
1104
1267
  *permission_outcomes,
1105
1268
  )
1106
1269
  group_operations.append({"action": "delete", "group_id": group_id})
@@ -1155,6 +1318,25 @@ class AiBuilderFacade:
1155
1318
  details={"query": requested},
1156
1319
  suggested_next_call={"tool_name": "member_search", "arguments": {"profile": profile, **normalized_args}},
1157
1320
  )
1321
+ if listed.get("status") == "failed" and listed.get("error_code") == "CONTACT_DIRECTORY_PERMISSION_DENIED":
1322
+ return _failed(
1323
+ "CONTACT_DIRECTORY_PERMISSION_DENIED",
1324
+ str(listed.get("message") or "Contact-directory management data is not readable for the current user."),
1325
+ normalized_args=normalized_args,
1326
+ details={
1327
+ "query": requested,
1328
+ "permission_boundary": "contact_directory",
1329
+ "fix_hint": (
1330
+ "This builder member lookup uses the contact-directory management route. "
1331
+ "For record member/department field candidates, use record_member_candidates "
1332
+ "or record_department_candidates instead."
1333
+ ),
1334
+ },
1335
+ suggested_next_call=None,
1336
+ request_id=listed.get("request_id") if isinstance(listed.get("request_id"), str) else None,
1337
+ backend_code=listed.get("backend_code"),
1338
+ http_status=listed.get("http_status"),
1339
+ )
1158
1340
  items = []
1159
1341
  for item in _extract_directory_items(listed):
1160
1342
  uid = _coerce_positive_int(item.get("uid") or item.get("id"))
@@ -1200,6 +1382,23 @@ class AiBuilderFacade:
1200
1382
  details={"keyword": requested},
1201
1383
  suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, **normalized_args}},
1202
1384
  )
1385
+ if listed.get("status") == "failed":
1386
+ permission_boundary = "contact_role" if listed.get("error_code") == "CONTACT_ROLE_PERMISSION_DENIED" else None
1387
+ return _failed(
1388
+ str(listed.get("error_code") or "ROLE_SEARCH_FAILED"),
1389
+ str(listed.get("message") or "role search failed"),
1390
+ normalized_args=normalized_args,
1391
+ details={
1392
+ "keyword": requested,
1393
+ "permission_boundary": permission_boundary,
1394
+ },
1395
+ suggested_next_call=None
1396
+ if permission_boundary
1397
+ else {"tool_name": "role_search", "arguments": {"profile": profile, **normalized_args}},
1398
+ backend_code=listed.get("backend_code"),
1399
+ request_id=listed.get("request_id"),
1400
+ http_status=listed.get("http_status"),
1401
+ )
1203
1402
  page = listed.get("page") if isinstance(listed.get("page"), dict) else {}
1204
1403
  raw_items = page.get("list") if isinstance(page.get("list"), list) else []
1205
1404
  items = []
@@ -1272,6 +1471,8 @@ class AiBuilderFacade:
1272
1471
  "role_id": exact[0]["role_id"],
1273
1472
  "role_name": exact[0]["role_name"],
1274
1473
  "role_icon": exact[0].get("role_icon"),
1474
+ "write_executed": False,
1475
+ "safe_to_retry": True,
1275
1476
  }
1276
1477
  if len(exact) > 1:
1277
1478
  return _failed(
@@ -1332,6 +1533,8 @@ class AiBuilderFacade:
1332
1533
  "role_id": role_id,
1333
1534
  "role_name": requested_name,
1334
1535
  "role_icon": normalized_args["role_icon"],
1536
+ "write_executed": True,
1537
+ "safe_to_retry": False,
1335
1538
  }
1336
1539
 
1337
1540
  def _resolve_role_references(
@@ -1362,6 +1565,18 @@ class AiBuilderFacade:
1362
1565
  if not requested:
1363
1566
  continue
1364
1567
  matches_result = self.role_search(profile=profile, keyword=requested, page_num=1, page_size=50)
1568
+ if matches_result.get("status") != "success":
1569
+ issues.append(
1570
+ {
1571
+ "kind": "role",
1572
+ "value": requested,
1573
+ "error_code": matches_result.get("error_code") or "ROLE_SEARCH_FAILED",
1574
+ "message": matches_result.get("message"),
1575
+ "backend_code": matches_result.get("backend_code"),
1576
+ "request_id": matches_result.get("request_id"),
1577
+ }
1578
+ )
1579
+ continue
1365
1580
  items = matches_result.get("items", []) if matches_result.get("status") == "success" else []
1366
1581
  exact = [item for item in items if isinstance(item, dict) and item.get("role_name") == requested]
1367
1582
  if len(exact) != 1:
@@ -1423,6 +1638,18 @@ class AiBuilderFacade:
1423
1638
  if not requested:
1424
1639
  continue
1425
1640
  matches = self.member_search(profile=profile, query=requested, page_num=1, page_size=50, contain_disable=False)
1641
+ if matches.get("status") != "success":
1642
+ issues.append(
1643
+ {
1644
+ "kind": "member_email",
1645
+ "value": requested,
1646
+ "error_code": matches.get("error_code") or "MEMBER_SEARCH_FAILED",
1647
+ "message": matches.get("message"),
1648
+ "backend_code": matches.get("backend_code"),
1649
+ "request_id": matches.get("request_id"),
1650
+ }
1651
+ )
1652
+ continue
1426
1653
  items = matches.get("items", []) if matches.get("status") == "success" else []
1427
1654
  exact = [item for item in items if isinstance(item, dict) and str(item.get("email") or "").strip().lower() == requested.lower()]
1428
1655
  if len(exact) != 1:
@@ -1442,6 +1669,18 @@ class AiBuilderFacade:
1442
1669
  if not requested:
1443
1670
  continue
1444
1671
  matches = self.member_search(profile=profile, query=requested, page_num=1, page_size=50, contain_disable=False)
1672
+ if matches.get("status") != "success":
1673
+ issues.append(
1674
+ {
1675
+ "kind": "member_name",
1676
+ "value": requested,
1677
+ "error_code": matches.get("error_code") or "MEMBER_SEARCH_FAILED",
1678
+ "message": matches.get("message"),
1679
+ "backend_code": matches.get("backend_code"),
1680
+ "request_id": matches.get("request_id"),
1681
+ }
1682
+ )
1683
+ continue
1445
1684
  items = matches.get("items", []) if matches.get("status") == "success" else []
1446
1685
  exact = [item for item in items if isinstance(item, dict) and str(item.get("name") or "").strip() == requested]
1447
1686
  if len(exact) != 1:
@@ -1470,23 +1709,6 @@ class AiBuilderFacade:
1470
1709
  seen_ids: set[int] = set()
1471
1710
  if not dept_ids and not dept_names:
1472
1711
  return {"department_entries": resolved, "issues": issues}
1473
- listed = self.directory.directory_list_all_departments(
1474
- profile=profile,
1475
- parent_dept_id=None,
1476
- max_depth=20,
1477
- max_items=5000,
1478
- )
1479
- items = _extract_directory_items(listed)
1480
- by_id: dict[int, dict[str, Any]] = {}
1481
- by_name: dict[str, list[dict[str, Any]]] = {}
1482
- for item in items:
1483
- dept_id = _coerce_positive_int(item.get("deptId") or item.get("id"))
1484
- if dept_id is None:
1485
- continue
1486
- by_id[dept_id] = item
1487
- dept_name = str(item.get("deptName") or item.get("departName") or item.get("name") or "").strip()
1488
- if dept_name:
1489
- by_name.setdefault(dept_name, []).append(item)
1490
1712
 
1491
1713
  def add_department(item: dict[str, Any], *, fallback_name: str | None = None) -> None:
1492
1714
  dept_id = _coerce_positive_int(item.get("deptId") or item.get("id"))
@@ -1506,7 +1728,40 @@ class AiBuilderFacade:
1506
1728
  normalized = _coerce_positive_int(dept_id)
1507
1729
  if normalized is None:
1508
1730
  continue
1509
- add_department(by_id.get(normalized) or {"deptId": normalized}, fallback_name=str(normalized))
1731
+ add_department({"deptId": normalized}, fallback_name=str(normalized))
1732
+
1733
+ if not dept_names:
1734
+ return {"department_entries": resolved, "issues": issues}
1735
+
1736
+ listed = self.directory.directory_list_all_departments(
1737
+ profile=profile,
1738
+ parent_dept_id=None,
1739
+ max_depth=20,
1740
+ max_items=5000,
1741
+ )
1742
+ if listed.get("status") == "failed" and listed.get("error_code") == "CONTACT_DIRECTORY_PERMISSION_DENIED":
1743
+ for dept_name in dept_names:
1744
+ requested = str(dept_name or "").strip()
1745
+ if not requested:
1746
+ continue
1747
+ issues.append(
1748
+ {
1749
+ "kind": "department_name",
1750
+ "value": requested,
1751
+ "error_code": "CONTACT_DIRECTORY_PERMISSION_DENIED",
1752
+ "message": "department names require contact-directory lookup; pass dept_ids or grant directory access",
1753
+ "backend_code": listed.get("backend_code"),
1754
+ "request_id": listed.get("request_id"),
1755
+ }
1756
+ )
1757
+ return {"department_entries": resolved, "issues": issues}
1758
+
1759
+ items = _extract_directory_items(listed)
1760
+ by_name: dict[str, list[dict[str, Any]]] = {}
1761
+ for item in items:
1762
+ dept_name = str(item.get("deptName") or item.get("departName") or item.get("name") or "").strip()
1763
+ if dept_name:
1764
+ by_name.setdefault(dept_name, []).append(item)
1510
1765
 
1511
1766
  for dept_name in dept_names:
1512
1767
  requested = str(dept_name or "").strip()
@@ -1573,6 +1828,18 @@ class AiBuilderFacade:
1573
1828
  page_size=100,
1574
1829
  simple=True,
1575
1830
  )
1831
+ if listed.get("status") == "failed":
1832
+ issues.append(
1833
+ {
1834
+ "kind": "external_member_email",
1835
+ "value": requested,
1836
+ "error_code": listed.get("error_code") or "EXTERNAL_MEMBER_SEARCH_FAILED",
1837
+ "message": listed.get("message"),
1838
+ "backend_code": listed.get("backend_code"),
1839
+ "request_id": listed.get("request_id"),
1840
+ }
1841
+ )
1842
+ continue
1576
1843
  items = _extract_directory_items(listed)
1577
1844
  exact = [
1578
1845
  item
@@ -1607,6 +1874,9 @@ class AiBuilderFacade:
1607
1874
  elif error_code.endswith("_NOT_FOUND"):
1608
1875
  public_code = "VISIBILITY_SUBJECT_NOT_FOUND"
1609
1876
  message = f"{kind} '{requested_value}' was not found in the visibility directory"
1877
+ elif "PERMISSION_DENIED" in error_code or error_code.endswith("_FAILED"):
1878
+ public_code = "VISIBILITY_SUBJECT_LOOKUP_FAILED"
1879
+ message = f"{kind} '{requested_value}' could not be resolved because the visibility directory lookup failed"
1610
1880
  else:
1611
1881
  public_code = "VISIBILITY_SUBJECT_UNSUPPORTED"
1612
1882
  message = f"{kind} visibility selector is unsupported"
@@ -2022,6 +2292,9 @@ class AiBuilderFacade:
2022
2292
  "tag_id": tag_id,
2023
2293
  "tag_ids_after": tag_ids_after,
2024
2294
  "attached": attached,
2295
+ "write_executed": not already_attached,
2296
+ "write_succeeded": not already_attached or attached,
2297
+ "safe_to_retry": bool(already_attached),
2025
2298
  }
2026
2299
  if verification_error is not None:
2027
2300
  response["details"]["verification_error"] = _transport_error_payload(verification_error)
@@ -2134,6 +2407,8 @@ class AiBuilderFacade:
2134
2407
  "verification": {"released": True},
2135
2408
  "app_key": app_key,
2136
2409
  "released": True,
2410
+ "write_executed": True,
2411
+ "safe_to_retry": False,
2137
2412
  }
2138
2413
 
2139
2414
  def app_resolve(
@@ -2214,11 +2489,20 @@ class AiBuilderFacade:
2214
2489
  if not requested:
2215
2490
  return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", suggested_next_call=None)
2216
2491
  if package_tag_id is not None and package_tag_id > 0:
2217
- package_matches = self._resolve_app_matches_in_package(
2218
- profile=profile,
2219
- app_name=requested,
2220
- package_tag_id=package_tag_id,
2221
- )
2492
+ try:
2493
+ package_matches = self._resolve_app_matches_in_package(
2494
+ profile=profile,
2495
+ app_name=requested,
2496
+ package_tag_id=package_tag_id,
2497
+ )
2498
+ except (QingflowApiError, RuntimeError) as exc:
2499
+ api_error = _coerce_api_error(exc)
2500
+ return _failed_from_api_error(
2501
+ "APP_RESOLVE_FAILED",
2502
+ api_error,
2503
+ details={"app_name": requested, "package_tag_id": package_tag_id, "match_scope": "package"},
2504
+ suggested_next_call=None,
2505
+ )
2222
2506
  if len(package_matches) == 1:
2223
2507
  match = package_matches[0]
2224
2508
  return {
@@ -2243,12 +2527,50 @@ class AiBuilderFacade:
2243
2527
  details={"app_name": requested, "package_tag_id": package_tag_id, "matches": package_matches},
2244
2528
  suggested_next_call=None,
2245
2529
  )
2530
+ try:
2531
+ visible_matches = self._resolve_app_matches_in_visible_apps(
2532
+ profile=profile,
2533
+ app_name=requested,
2534
+ package_tag_id=package_tag_id,
2535
+ )
2536
+ except (QingflowApiError, RuntimeError) as exc:
2537
+ api_error = _coerce_api_error(exc)
2538
+ return _failed_from_api_error(
2539
+ "APP_RESOLVE_FAILED",
2540
+ api_error,
2541
+ details={"app_name": requested, "package_tag_id": package_tag_id, "match_scope": "visible_apps"},
2542
+ suggested_next_call=None,
2543
+ )
2544
+ if len(visible_matches) == 1:
2545
+ match = visible_matches[0]
2546
+ return {
2547
+ "status": "success",
2548
+ "error_code": None,
2549
+ "recoverable": False,
2550
+ "message": "resolved app",
2551
+ "normalized_args": {"app_name": requested, "package_tag_id": package_tag_id},
2552
+ "missing_fields": [],
2553
+ "allowed_values": {},
2554
+ "details": {"match_scope": "visible_apps"},
2555
+ "request_id": None,
2556
+ "suggested_next_call": None,
2557
+ "noop": False,
2558
+ "verification": {},
2559
+ **match,
2560
+ }
2561
+ if len(visible_matches) > 1:
2562
+ return _failed(
2563
+ "AMBIGUOUS_APP",
2564
+ f"multiple apps matched '{requested}'",
2565
+ details={"app_name": requested, "package_tag_id": package_tag_id, "matches": visible_matches},
2566
+ suggested_next_call=None,
2567
+ )
2246
2568
  search_error: QingflowApiError | None = None
2247
2569
  try:
2248
2570
  search = self.apps.app_search(profile=profile, keyword=requested, page_num=1, page_size=200)
2249
2571
  except (QingflowApiError, RuntimeError) as exc:
2250
2572
  api_error = _coerce_api_error(exc)
2251
- if package_tag_id is None or package_tag_id <= 0 or api_error.backend_code not in {40002, 40027}:
2573
+ if is_auth_like_error(api_error) or package_tag_id is None or package_tag_id <= 0 or backend_code_int(api_error) not in {40002, 40027}:
2252
2574
  return _failed_from_api_error(
2253
2575
  "APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
2254
2576
  api_error,
@@ -2257,6 +2579,7 @@ class AiBuilderFacade:
2257
2579
  )
2258
2580
  search = {}
2259
2581
  search_error = api_error
2582
+ search_permission_blocked = _search_permission_blocked_from_warnings(search) if isinstance(search, dict) else None
2260
2583
  apps = search.get("apps") if isinstance(search.get("apps"), list) else []
2261
2584
  matches = []
2262
2585
  for item in apps:
@@ -2275,8 +2598,16 @@ class AiBuilderFacade:
2275
2598
  if package_tag_id is not None and package_tag_id > 0:
2276
2599
  try:
2277
2600
  base = self.apps.app_get_base(profile=profile, app_key=candidate_key, include_raw=True)
2278
- except (QingflowApiError, RuntimeError):
2279
- continue
2601
+ except (QingflowApiError, RuntimeError) as exc:
2602
+ api_error = _coerce_api_error(exc)
2603
+ if _is_optional_builder_lookup_error(api_error):
2604
+ continue
2605
+ return _failed_from_api_error(
2606
+ "APP_RESOLVE_FAILED",
2607
+ api_error,
2608
+ details={"app_name": requested, "candidate_app_key": candidate_key, "package_tag_id": package_tag_id},
2609
+ suggested_next_call=None,
2610
+ )
2280
2611
  result = base.get("result") if isinstance(base.get("result"), dict) else {}
2281
2612
  resolved_tag_ids = _coerce_int_list(result.get("tagIds"))
2282
2613
  if resolved_tag_ids:
@@ -2290,12 +2621,7 @@ class AiBuilderFacade:
2290
2621
  "tag_ids": tag_ids,
2291
2622
  }
2292
2623
  )
2293
- if not matches and package_tag_id is not None and package_tag_id > 0 and search_error is not None:
2294
- visible_matches = self._resolve_app_matches_in_visible_apps(
2295
- profile=profile,
2296
- app_name=requested,
2297
- package_tag_id=package_tag_id,
2298
- )
2624
+ if not matches and search_error is not None:
2299
2625
  if len(visible_matches) == 1:
2300
2626
  match = visible_matches[0]
2301
2627
  return {
@@ -2355,6 +2681,14 @@ class AiBuilderFacade:
2355
2681
  if search_error is not None
2356
2682
  else {}
2357
2683
  ),
2684
+ **(
2685
+ {
2686
+ "search_permission_blocked": search_permission_blocked,
2687
+ "match_scope": "visible_apps_fallback",
2688
+ }
2689
+ if search_permission_blocked is not None
2690
+ else {}
2691
+ ),
2358
2692
  },
2359
2693
  suggested_next_call=None,
2360
2694
  )
@@ -2540,21 +2874,45 @@ class AiBuilderFacade:
2540
2874
  normalized_args = request.model_dump(mode="json")
2541
2875
  app_key = request.app_key
2542
2876
  permission_outcomes: list[PermissionCheckOutcome] = []
2543
- permission_outcome = self._guard_app_permission(
2544
- profile=profile,
2545
- app_key=app_key,
2546
- required_permission="edit_app",
2547
- normalized_args=normalized_args,
2548
- )
2549
- if permission_outcome.block is not None:
2550
- return permission_outcome.block
2551
- permission_outcomes.append(permission_outcome)
2877
+ button_write_intent = bool(request.upsert_buttons or request.patch_buttons or request.remove_buttons)
2878
+ if button_write_intent:
2879
+ permission_outcome = self._guard_app_permission(
2880
+ profile=profile,
2881
+ app_key=app_key,
2882
+ required_permission="edit_app",
2883
+ normalized_args=normalized_args,
2884
+ )
2885
+ if permission_outcome.block is not None:
2886
+ return permission_outcome.block
2887
+ permission_outcomes.append(permission_outcome)
2888
+ if request.view_configs:
2889
+ permission_outcome = self._guard_app_permission(
2890
+ profile=profile,
2891
+ app_key=app_key,
2892
+ required_permission="view_manage",
2893
+ normalized_args=normalized_args,
2894
+ )
2895
+ if permission_outcome.block is not None:
2896
+ return permission_outcome.block
2897
+ permission_outcomes.append(permission_outcome)
2552
2898
 
2553
2899
  def finalize(response: JSONObject) -> JSONObject:
2554
2900
  return _apply_permission_outcomes(response, *permission_outcomes)
2555
2901
 
2902
+ view_config_refs = [
2903
+ binding.button_ref
2904
+ for config in request.view_configs
2905
+ for binding in config.buttons
2906
+ ]
2907
+ needs_button_inventory = button_write_intent or any(
2908
+ _coerce_positive_int(ref) is None for ref in view_config_refs
2909
+ )
2556
2910
  try:
2557
- existing_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
2911
+ existing_buttons = (
2912
+ self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
2913
+ if needs_button_inventory
2914
+ else []
2915
+ )
2558
2916
  except (QingflowApiError, RuntimeError) as error:
2559
2917
  api_error = _coerce_api_error(error)
2560
2918
  return finalize(_failed_from_api_error(
@@ -2785,19 +3143,22 @@ class AiBuilderFacade:
2785
3143
  )
2786
3144
  )
2787
3145
 
2788
- edit_version_no, edit_context_error = self._ensure_app_edit_context(
2789
- profile=profile,
2790
- app_key=app_key,
2791
- normalized_args=normalized_args,
2792
- failure_code="CUSTOM_BUTTON_APPLY_FAILED",
2793
- )
2794
- if edit_context_error is not None:
2795
- return finalize(edit_context_error)
2796
-
3146
+ edit_version_no = None
3147
+ if button_write_intent:
3148
+ edit_version_no, edit_context_error = self._ensure_app_edit_context(
3149
+ profile=profile,
3150
+ app_key=app_key,
3151
+ normalized_args=normalized_args,
3152
+ failure_code="CUSTOM_BUTTON_APPLY_FAILED",
3153
+ )
3154
+ if edit_context_error is not None:
3155
+ return finalize(edit_context_error)
3156
+
2797
3157
  created: list[dict[str, Any]] = []
2798
3158
  updated: list[dict[str, Any]] = []
2799
3159
  removed: list[dict[str, Any]] = []
2800
3160
  failed: list[dict[str, Any]] = []
3161
+ readback_errors: list[JSONObject] = []
2801
3162
  client_key_map: dict[str, int] = {}
2802
3163
  write_executed = False
2803
3164
 
@@ -2820,7 +3181,15 @@ class AiBuilderFacade:
2820
3181
  ]
2821
3182
  if len(matches) == 1:
2822
3183
  button_id = _coerce_positive_int(matches[0].get("button_id"))
2823
- except (QingflowApiError, RuntimeError):
3184
+ except (QingflowApiError, RuntimeError) as error:
3185
+ readback_errors.append(
3186
+ {
3187
+ "resource": "custom_buttons",
3188
+ "phase": "create_id_lookup",
3189
+ "index": op["index"],
3190
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
3191
+ }
3192
+ )
2824
3193
  button_id = None
2825
3194
  entry = {
2826
3195
  "index": op["index"],
@@ -2868,13 +3237,29 @@ class AiBuilderFacade:
2868
3237
  try:
2869
3238
  write_executed = True
2870
3239
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
3240
+ delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
2871
3241
  removed.append(
2872
3242
  {
2873
3243
  "index": op["index"],
2874
3244
  "operation": "remove",
2875
- "status": "success",
3245
+ "status": delete_readback.get("status") or "readback_pending",
2876
3246
  "button_id": button_id,
2877
3247
  "button_text": selector.button_text or (existing_by_id.get(button_id) or {}).get("button_text"),
3248
+ "delete_executed": True,
3249
+ "readback_status": delete_readback.get("readback_status"),
3250
+ "safe_to_retry_delete": False,
3251
+ **(
3252
+ {
3253
+ "error_code": delete_readback.get("error_code"),
3254
+ "message": delete_readback.get("message"),
3255
+ "request_id": delete_readback.get("request_id"),
3256
+ "backend_code": delete_readback.get("backend_code"),
3257
+ "http_status": delete_readback.get("http_status"),
3258
+ "transport_error": delete_readback.get("transport_error"),
3259
+ }
3260
+ if delete_readback.get("readback_status") != "deleted"
3261
+ else {}
3262
+ ),
2878
3263
  }
2879
3264
  )
2880
3265
  except (QingflowApiError, RuntimeError) as error:
@@ -2892,12 +3277,21 @@ class AiBuilderFacade:
2892
3277
  }
2893
3278
  )
2894
3279
 
3280
+ needs_button_list_readback = bool(created or updated or (request.view_configs and needs_button_inventory))
2895
3281
  readback_buttons: list[dict[str, Any]] = []
2896
3282
  readback_failed = False
2897
- try:
2898
- readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
2899
- except (QingflowApiError, RuntimeError):
2900
- readback_failed = True
3283
+ if needs_button_list_readback:
3284
+ try:
3285
+ readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
3286
+ except (QingflowApiError, RuntimeError) as error:
3287
+ readback_errors.append(
3288
+ {
3289
+ "resource": "custom_buttons",
3290
+ "phase": "final_list",
3291
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
3292
+ }
3293
+ )
3294
+ readback_failed = True
2901
3295
  readback_ids = {
2902
3296
  button_id
2903
3297
  for item in readback_buttons
@@ -2918,10 +3312,12 @@ class AiBuilderFacade:
2918
3312
  for item in removed
2919
3313
  if _coerce_positive_int(item.get("button_id")) is not None
2920
3314
  ]
3315
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
3316
+ remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
2921
3317
  verified = (
2922
3318
  not readback_failed
2923
3319
  and all(button_id in readback_ids for button_id in created_ids + updated_ids)
2924
- and all(button_id not in readback_ids for button_id in removed_ids)
3320
+ and removed_verified
2925
3321
  and not failed
2926
3322
  and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
2927
3323
  )
@@ -2980,6 +3376,20 @@ class AiBuilderFacade:
2980
3376
  else "custom button writes all failed or produced no confirmed result; application was not published",
2981
3377
  )
2982
3378
  )
3379
+ if remove_readback_pending:
3380
+ warnings.append(
3381
+ _warning(
3382
+ "CUSTOM_BUTTON_DELETE_READBACK_PENDING",
3383
+ "custom button delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
3384
+ )
3385
+ )
3386
+ if readback_errors:
3387
+ warnings.append(
3388
+ _warning(
3389
+ "CUSTOM_BUTTON_READBACK_UNAVAILABLE",
3390
+ "custom button write was executed but readback is unavailable",
3391
+ )
3392
+ )
2983
3393
  response = {
2984
3394
  "status": status,
2985
3395
  "error_code": error_code,
@@ -2995,6 +3405,7 @@ class AiBuilderFacade:
2995
3405
  "edit_version_no": edit_version_no,
2996
3406
  "button_ids_by_client_key": client_key_map,
2997
3407
  "readback_failed": readback_failed,
3408
+ **({"readback_errors": readback_errors} if readback_errors else {}),
2998
3409
  "compiled_match_rules": {
2999
3410
  str(index): _summarize_compiled_match_rules(config.get("que_relation") or [])
3000
3411
  for index, config in compiled_add_data_configs.items()
@@ -3009,7 +3420,9 @@ class AiBuilderFacade:
3009
3420
  "readback_loaded": not readback_failed,
3010
3421
  "created_verified": not readback_failed and all(button_id in readback_ids for button_id in created_ids),
3011
3422
  "updated_verified": not readback_failed and all(button_id in readback_ids for button_id in updated_ids),
3012
- "removed_verified": not readback_failed and all(button_id not in readback_ids for button_id in removed_ids),
3423
+ "removed_verified": removed_verified,
3424
+ "remove_readback_pending": remove_readback_pending,
3425
+ "removed_readback_results": deepcopy(removed),
3013
3426
  "view_button_bindings_verified": view_config_verified,
3014
3427
  },
3015
3428
  "verified": verified,
@@ -3026,7 +3439,15 @@ class AiBuilderFacade:
3026
3439
  "write_succeeded": write_succeeded,
3027
3440
  "safe_to_retry": not write_executed,
3028
3441
  }
3029
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=write_succeeded, response=response, edit_version_no=edit_version_no))
3442
+ return finalize(
3443
+ self._append_publish_result(
3444
+ profile=profile,
3445
+ app_key=app_key,
3446
+ publish=bool(write_succeeded and button_write_intent),
3447
+ response=response,
3448
+ edit_version_no=edit_version_no,
3449
+ )
3450
+ )
3030
3451
 
3031
3452
  def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
3032
3453
  normalized_args = {"app_key": app_key}
@@ -3195,6 +3616,9 @@ class AiBuilderFacade:
3195
3616
  "verified": False,
3196
3617
  "app_key": app_key,
3197
3618
  "button_id": button_id,
3619
+ "write_executed": True,
3620
+ "write_succeeded": True,
3621
+ "safe_to_retry": False,
3198
3622
  }
3199
3623
  if _is_permission_restricted_api_error(api_error):
3200
3624
  response = _apply_permission_outcomes(
@@ -3308,6 +3732,9 @@ class AiBuilderFacade:
3308
3732
  "verified": False,
3309
3733
  "app_key": app_key,
3310
3734
  "button_id": button_id,
3735
+ "write_executed": True,
3736
+ "write_succeeded": True,
3737
+ "safe_to_retry": False,
3311
3738
  }
3312
3739
  if _is_permission_restricted_api_error(api_error):
3313
3740
  response = _apply_permission_outcomes(
@@ -3374,30 +3801,35 @@ class AiBuilderFacade:
3374
3801
  return finalize(edit_context_error)
3375
3802
 
3376
3803
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
3804
+ delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
3805
+ verified = delete_readback.get("readback_status") == "deleted"
3377
3806
  return finalize(
3378
3807
  self._append_publish_result(
3379
3808
  profile=profile,
3380
3809
  app_key=app_key,
3381
3810
  publish=True,
3382
3811
  response={
3383
- "status": "success",
3384
- "error_code": None,
3385
- "recoverable": False,
3386
- "message": "deleted custom button",
3812
+ "status": "success" if verified else "partial_success",
3813
+ "error_code": None if verified else delete_readback.get("error_code") or "CUSTOM_BUTTON_DELETE_READBACK_PENDING",
3814
+ "recoverable": not verified,
3815
+ "message": "deleted custom button" if verified else "custom button delete completed; readback pending",
3387
3816
  "normalized_args": normalized_args,
3388
3817
  "missing_fields": [],
3389
3818
  "allowed_values": {},
3390
3819
  "details": {},
3391
- "request_id": None,
3392
- "suggested_next_call": None,
3820
+ "request_id": delete_readback.get("request_id"),
3821
+ "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3393
3822
  "noop": False,
3394
- "warnings": [],
3395
- "verification": {"custom_button_deleted": True},
3396
- "verified": True,
3823
+ "warnings": [] if verified else [_warning("CUSTOM_BUTTON_DELETE_READBACK_PENDING", "custom button delete was sent, but deletion readback is not fully verified")],
3824
+ "verification": {"custom_button_deleted": verified, "delete_readback": delete_readback},
3825
+ "verified": verified,
3397
3826
  "app_key": app_key,
3398
3827
  "button_id": button_id,
3399
3828
  "edit_version_no": edit_version_no,
3400
- "deleted": True,
3829
+ "deleted": verified,
3830
+ "delete_executed": True,
3831
+ "readback_status": delete_readback.get("readback_status"),
3832
+ "safe_to_retry_delete": False,
3401
3833
  },
3402
3834
  )
3403
3835
  )
@@ -3641,15 +4073,32 @@ class AiBuilderFacade:
3641
4073
  normalized_args = request.model_dump(mode="json", exclude_none=True)
3642
4074
  app_key = request.app_key
3643
4075
  permission_outcomes: list[PermissionCheckOutcome] = []
3644
- permission_outcome = self._guard_app_permission(
3645
- profile=profile,
3646
- app_key=app_key,
3647
- required_permission="data_manage",
3648
- normalized_args=normalized_args,
4076
+ resource_write_intent = bool(
4077
+ request.upsert_resources
4078
+ or request.patch_resources
4079
+ or request.remove_associated_item_ids
4080
+ or request.reorder_associated_item_ids
3649
4081
  )
3650
- if permission_outcome.block is not None:
3651
- return permission_outcome.block
3652
- permission_outcomes.append(permission_outcome)
4082
+ if resource_write_intent:
4083
+ permission_outcome = self._guard_app_permission(
4084
+ profile=profile,
4085
+ app_key=app_key,
4086
+ required_permission="edit_app",
4087
+ normalized_args=normalized_args,
4088
+ )
4089
+ if permission_outcome.block is not None:
4090
+ return permission_outcome.block
4091
+ permission_outcomes.append(permission_outcome)
4092
+ if request.view_configs:
4093
+ permission_outcome = self._guard_app_permission(
4094
+ profile=profile,
4095
+ app_key=app_key,
4096
+ required_permission="view_manage",
4097
+ normalized_args=normalized_args,
4098
+ )
4099
+ if permission_outcome.block is not None:
4100
+ return permission_outcome.block
4101
+ permission_outcomes.append(permission_outcome)
3653
4102
 
3654
4103
  def finalize(response: JSONObject) -> JSONObject:
3655
4104
  return _apply_permission_outcomes(response, *permission_outcomes)
@@ -3912,14 +4361,16 @@ class AiBuilderFacade:
3912
4361
  }
3913
4362
  return finalize(response)
3914
4363
 
3915
- edit_version_no, edit_context_error = self._ensure_app_edit_context(
3916
- profile=profile,
3917
- app_key=app_key,
3918
- normalized_args=normalized_args,
3919
- failure_code="ASSOCIATED_RESOURCES_APPLY_FAILED",
3920
- )
3921
- if edit_context_error is not None:
3922
- return finalize(edit_context_error)
4364
+ edit_version_no = None
4365
+ if resource_write_intent:
4366
+ edit_version_no, edit_context_error = self._ensure_app_edit_context(
4367
+ profile=profile,
4368
+ app_key=app_key,
4369
+ normalized_args=normalized_args,
4370
+ failure_code="ASSOCIATED_RESOURCES_APPLY_FAILED",
4371
+ )
4372
+ if edit_context_error is not None:
4373
+ return finalize(edit_context_error)
3923
4374
 
3924
4375
  created: list[dict[str, Any]] = []
3925
4376
  updated: list[dict[str, Any]] = []
@@ -3928,6 +4379,8 @@ class AiBuilderFacade:
3928
4379
  reordered: list[int] = []
3929
4380
  view_config_results: list[dict[str, Any]] = []
3930
4381
  failed: list[dict[str, Any]] = []
4382
+ readback_errors: list[JSONObject] = []
4383
+ verification_errors: list[JSONObject] = []
3931
4384
  write_executed = False
3932
4385
 
3933
4386
  for op in upsert_ops:
@@ -3940,20 +4393,33 @@ class AiBuilderFacade:
3940
4393
  client_key_to_id[str(patch.client_key)] = item_id
3941
4394
  elif op["operation"] == "create":
3942
4395
  write_executed = True
3943
- self._associated_resource_create(
4396
+ create_result = self._associated_resource_create(
3944
4397
  profile=profile,
3945
4398
  app_key=app_key,
3946
4399
  patch=patch,
3947
4400
  match_rules_override=compiled_resource_match_rules.get(int(op["index"])),
3948
4401
  )
3949
- readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
3950
- matches = [
3951
- item
3952
- for item in readback_resources
3953
- if _associated_resource_matches_patch(item, patch)
3954
- and _coerce_positive_int(item.get("associated_item_id")) is not None
3955
- ]
3956
- created_id = _coerce_positive_int(matches[0].get("associated_item_id")) if len(matches) == 1 else None
4402
+ created_id = _extract_associated_resource_id_from_result(create_result)
4403
+ try:
4404
+ readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4405
+ matches = [
4406
+ item
4407
+ for item in readback_resources
4408
+ if _associated_resource_matches_patch(item, patch)
4409
+ and _coerce_positive_int(item.get("associated_item_id")) is not None
4410
+ ]
4411
+ readback_id = _coerce_positive_int(matches[0].get("associated_item_id")) if len(matches) == 1 else None
4412
+ if readback_id is not None:
4413
+ created_id = readback_id
4414
+ except (QingflowApiError, RuntimeError) as error:
4415
+ readback_errors.append(
4416
+ {
4417
+ "resource": "associated_resources",
4418
+ "phase": "create_id_lookup",
4419
+ "index": op["index"],
4420
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
4421
+ }
4422
+ )
3957
4423
  created.append(_associated_resource_result_entry("create", op["index"], patch, associated_item_id=created_id))
3958
4424
  if created_id is not None and patch.client_key:
3959
4425
  client_key_to_id[str(patch.client_key)] = created_id
@@ -3989,7 +4455,16 @@ class AiBuilderFacade:
3989
4455
  try:
3990
4456
  write_executed = True
3991
4457
  self._associated_resource_delete(profile=profile, app_key=app_key, associated_item_id=item_id)
3992
- removed.append({"associated_item_id": item_id, "status": "success"})
4458
+ removed.append(
4459
+ {
4460
+ "associated_item_id": item_id,
4461
+ "operation": "remove",
4462
+ "status": "readback_pending",
4463
+ "delete_executed": True,
4464
+ "readback_status": "unavailable",
4465
+ "safe_to_retry_delete": False,
4466
+ }
4467
+ )
3993
4468
  except (QingflowApiError, RuntimeError) as error:
3994
4469
  api_error = _coerce_api_error(error)
3995
4470
  failed.append({"operation": "remove", "associated_item_id": item_id, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
@@ -4003,10 +4478,17 @@ class AiBuilderFacade:
4003
4478
  api_error = _coerce_api_error(error)
4004
4479
  failed.append({"operation": "reorder", "associated_item_ids": reorder_ids, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_REORDER_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4005
4480
 
4006
- try:
4007
- resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4008
- except (QingflowApiError, RuntimeError):
4009
- resources_after = []
4481
+ resources_after: list[dict[str, Any]] = []
4482
+ resources_after_loaded = False
4483
+ resources_after_readback_failed = False
4484
+ if request.view_configs:
4485
+ try:
4486
+ resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4487
+ resources_after_loaded = True
4488
+ except (QingflowApiError, RuntimeError) as error:
4489
+ readback_errors.append({"resource": "associated_resources", "phase": "pre_view_config", "transport_error": _transport_error_payload(_coerce_api_error(error))})
4490
+ resources_after = []
4491
+ resources_after_readback_failed = True
4010
4492
 
4011
4493
  for index, view_config in enumerate(request.view_configs):
4012
4494
  resolved_view_config_ids = resolved_associated_item_refs.get(f"view_configs[{index}].associated_item_ids", [])
@@ -4066,7 +4548,15 @@ class AiBuilderFacade:
4066
4548
  available_resources=refreshed_resources,
4067
4549
  )
4068
4550
  verified_config = _associated_resources_config_matches(expected_config, actual_config)
4069
- except (QingflowApiError, RuntimeError):
4551
+ except (QingflowApiError, RuntimeError) as error:
4552
+ verification_errors.append(
4553
+ {
4554
+ "resource": "view_associated_resources_config",
4555
+ "phase": "view_config_readback",
4556
+ "view_key": view_config.view_key,
4557
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
4558
+ }
4559
+ )
4070
4560
  actual_config = {}
4071
4561
  verified_config = False
4072
4562
  view_config_results.append({"index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "success" if verified_config else "partial_success", "associated_resources_verified": verified_config, "expected": expected_config, "actual": actual_config})
@@ -4074,21 +4564,31 @@ class AiBuilderFacade:
4074
4564
  api_error = _coerce_api_error(error)
4075
4565
  failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4076
4566
 
4077
- final_resources: list[dict[str, Any]] = []
4078
- readback_failed = False
4079
- try:
4080
- final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4081
- except (QingflowApiError, RuntimeError):
4082
- readback_failed = True
4567
+ final_resources: list[dict[str, Any]] = resources_after if resources_after_loaded else []
4568
+ readback_failed = resources_after_readback_failed
4569
+ if not resources_after_loaded and not resources_after_readback_failed:
4570
+ try:
4571
+ final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4572
+ except (QingflowApiError, RuntimeError) as error:
4573
+ readback_errors.append({"resource": "associated_resources", "phase": "final_pool", "transport_error": _transport_error_payload(_coerce_api_error(error))})
4574
+ readback_failed = True
4083
4575
  final_by_id = _associated_resource_index(final_resources)
4576
+ if removed:
4577
+ removed = self._verify_associated_resources_deleted_by_pool(
4578
+ deleted_items=removed,
4579
+ resources=final_resources,
4580
+ readback_failed=readback_failed,
4581
+ )
4084
4582
  created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
4085
4583
  updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
4086
4584
  unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
4087
4585
  removed_ids = [int(item["associated_item_id"]) for item in removed if _coerce_positive_int(item.get("associated_item_id")) is not None]
4586
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
4587
+ remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
4088
4588
  pool_verified = (
4089
4589
  not readback_failed
4090
4590
  and all(item_id in final_by_id for item_id in created_ids + updated_ids + unchanged_ids)
4091
- and all(item_id not in final_by_id for item_id in removed_ids)
4591
+ and removed_verified
4092
4592
  and not failed
4093
4593
  and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in created)
4094
4594
  )
@@ -4123,6 +4623,27 @@ class AiBuilderFacade:
4123
4623
  else "associated resource writes all failed or produced no confirmed result; application was not published",
4124
4624
  )
4125
4625
  )
4626
+ if remove_readback_pending:
4627
+ warnings.append(
4628
+ _warning(
4629
+ "ASSOCIATED_RESOURCE_DELETE_READBACK_PENDING",
4630
+ "associated resource delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
4631
+ )
4632
+ )
4633
+ if readback_errors:
4634
+ warnings.append(
4635
+ _warning(
4636
+ "ASSOCIATED_RESOURCES_READBACK_UNAVAILABLE",
4637
+ "associated resource write was executed but readback is unavailable",
4638
+ )
4639
+ )
4640
+ if verification_errors:
4641
+ warnings.append(
4642
+ _warning(
4643
+ "ASSOCIATED_RESOURCE_VIEW_CONFIG_VERIFICATION_UNAVAILABLE",
4644
+ "associated resource view config write was executed but verification readback is unavailable",
4645
+ )
4646
+ )
4126
4647
  response = {
4127
4648
  "status": status,
4128
4649
  "error_code": error_code,
@@ -4135,6 +4656,8 @@ class AiBuilderFacade:
4135
4656
  "edit_version_no": edit_version_no,
4136
4657
  "associated_item_ids_by_client_key": client_key_to_id,
4137
4658
  "readback_failed": readback_failed,
4659
+ **({"readback_errors": readback_errors} if readback_errors else {}),
4660
+ **({"verification_errors": verification_errors} if verification_errors else {}),
4138
4661
  "compiled_match_rules": {
4139
4662
  str(index): _summarize_compiled_match_rules(rules)
4140
4663
  for index, rules in compiled_resource_match_rules.items()
@@ -4145,7 +4668,14 @@ class AiBuilderFacade:
4145
4668
  "suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
4146
4669
  "noop": False,
4147
4670
  "warnings": warnings,
4148
- "verification": {"associated_resources_verified": pool_verified, "associated_resource_view_configs_verified": view_configs_verified, "readback_loaded": not readback_failed},
4671
+ "verification": {
4672
+ "associated_resources_verified": pool_verified,
4673
+ "associated_resource_view_configs_verified": view_configs_verified,
4674
+ "readback_loaded": not readback_failed,
4675
+ "removed_verified": removed_verified,
4676
+ "remove_readback_pending": remove_readback_pending,
4677
+ "removed_readback_results": deepcopy(removed),
4678
+ },
4149
4679
  "verified": verified,
4150
4680
  "app_key": app_key,
4151
4681
  "app_name": app_name,
@@ -4163,11 +4693,39 @@ class AiBuilderFacade:
4163
4693
  "safe_to_retry": not write_executed,
4164
4694
  "associated_resources": final_resources,
4165
4695
  }
4166
- response = self._append_publish_result(profile=profile, app_key=app_key, publish=write_succeeded, response=response, edit_version_no=edit_version_no)
4696
+ response = self._append_publish_result(
4697
+ profile=profile,
4698
+ app_key=app_key,
4699
+ publish=bool(write_succeeded and resource_write_intent),
4700
+ response=response,
4701
+ edit_version_no=edit_version_no,
4702
+ )
4167
4703
  if response.get("published") and view_config_results:
4168
4704
  try:
4169
4705
  post_publish_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4170
- except (QingflowApiError, RuntimeError):
4706
+ except (QingflowApiError, RuntimeError) as error:
4707
+ details = response.get("details") if isinstance(response.get("details"), dict) else {}
4708
+ response["details"] = details
4709
+ post_publish_readback_errors = details.get("readback_errors") if isinstance(details.get("readback_errors"), list) else []
4710
+ post_publish_readback_errors.append(
4711
+ {
4712
+ "resource": "associated_resources",
4713
+ "phase": "post_publish_pool",
4714
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
4715
+ }
4716
+ )
4717
+ details["readback_errors"] = post_publish_readback_errors
4718
+ warnings = response.get("warnings")
4719
+ if not isinstance(warnings, list):
4720
+ warnings = []
4721
+ response["warnings"] = warnings
4722
+ if not any(isinstance(warning, dict) and warning.get("code") == "ASSOCIATED_RESOURCES_READBACK_UNAVAILABLE" for warning in warnings):
4723
+ warnings.append(
4724
+ _warning(
4725
+ "ASSOCIATED_RESOURCES_READBACK_UNAVAILABLE",
4726
+ "associated resource write was executed but readback is unavailable",
4727
+ )
4728
+ )
4171
4729
  post_publish_resources = final_resources
4172
4730
  if post_publish_resources:
4173
4731
  response["associated_resources"] = post_publish_resources
@@ -4185,7 +4743,30 @@ class AiBuilderFacade:
4185
4743
  config if isinstance(config, dict) else {},
4186
4744
  available_resources=post_publish_resources,
4187
4745
  )
4188
- except (QingflowApiError, RuntimeError):
4746
+ except (QingflowApiError, RuntimeError) as error:
4747
+ details = response.get("details") if isinstance(response.get("details"), dict) else {}
4748
+ response["details"] = details
4749
+ post_publish_verification_errors = details.get("verification_errors") if isinstance(details.get("verification_errors"), list) else []
4750
+ post_publish_verification_errors.append(
4751
+ {
4752
+ "resource": "view_associated_resources_config",
4753
+ "phase": "post_publish_view_config_readback",
4754
+ "view_key": view_key,
4755
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
4756
+ }
4757
+ )
4758
+ details["verification_errors"] = post_publish_verification_errors
4759
+ warnings = response.get("warnings")
4760
+ if not isinstance(warnings, list):
4761
+ warnings = []
4762
+ response["warnings"] = warnings
4763
+ if not any(isinstance(warning, dict) and warning.get("code") == "ASSOCIATED_RESOURCE_VIEW_CONFIG_VERIFICATION_UNAVAILABLE" for warning in warnings):
4764
+ warnings.append(
4765
+ _warning(
4766
+ "ASSOCIATED_RESOURCE_VIEW_CONFIG_VERIFICATION_UNAVAILABLE",
4767
+ "associated resource view config write was executed but verification readback is unavailable",
4768
+ )
4769
+ )
4189
4770
  continue
4190
4771
  if _associated_resources_config_matches(expected_config, actual_config):
4191
4772
  result["status"] = "success"
@@ -4235,11 +4816,14 @@ class AiBuilderFacade:
4235
4816
  *,
4236
4817
  profile: str,
4237
4818
  app_name: str,
4238
- package_tag_id: int,
4819
+ package_tag_id: int | None,
4239
4820
  ) -> list[JSONObject]:
4240
4821
  try:
4241
4822
  listing = self.apps.app_list(profile=profile, ship_auth=False)
4242
- except (QingflowApiError, RuntimeError):
4823
+ except (QingflowApiError, RuntimeError) as exc:
4824
+ api_error = _coerce_api_error(exc)
4825
+ if not _is_optional_builder_lookup_error(api_error):
4826
+ raise
4243
4827
  return []
4244
4828
  items = listing.get("items") if isinstance(listing.get("items"), list) else []
4245
4829
  matches: list[JSONObject] = []
@@ -4257,7 +4841,7 @@ class AiBuilderFacade:
4257
4841
  tag_id = _coerce_positive_int(item.get("tag_id"))
4258
4842
  if tag_id is not None and tag_id not in tag_ids:
4259
4843
  tag_ids.append(tag_id)
4260
- if package_tag_id not in tag_ids:
4844
+ if package_tag_id is not None and package_tag_id > 0 and package_tag_id not in tag_ids:
4261
4845
  continue
4262
4846
  seen_app_keys.add(candidate_key)
4263
4847
  matches.append({"app_key": candidate_key, "app_name": title, "tag_ids": tag_ids})
@@ -4272,7 +4856,10 @@ class AiBuilderFacade:
4272
4856
  ) -> list[JSONObject]:
4273
4857
  try:
4274
4858
  package_result = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
4275
- except (QingflowApiError, RuntimeError):
4859
+ except (QingflowApiError, RuntimeError) as exc:
4860
+ api_error = _coerce_api_error(exc)
4861
+ if not _is_optional_builder_lookup_error(api_error):
4862
+ raise
4276
4863
  return []
4277
4864
  raw_package = package_result.get("result") if isinstance(package_result.get("result"), dict) else {}
4278
4865
  tag_items = raw_package.get("tagItems") if isinstance(raw_package.get("tagItems"), list) else []
@@ -4311,23 +4898,15 @@ class AiBuilderFacade:
4311
4898
  "tag_ids": _coerce_int_list(base.get("tagIds")),
4312
4899
  "can_edit_app": _coerce_optional_bool(base.get("editItemStatus")),
4313
4900
  "can_manage_data": _coerce_optional_bool(base.get("dataManageStatus")),
4901
+ "can_manage_views": (
4902
+ _coerce_optional_bool(base.get("beingViewManageStatus"))
4903
+ if "beingViewManageStatus" in base
4904
+ else _coerce_optional_bool(base.get("dataManageStatus"))
4905
+ ),
4314
4906
  "can_delete_app": _coerce_optional_bool(base.get("deleteItemStatus")),
4315
4907
  "can_copy_app": _coerce_optional_bool(base.get("copyAppStatus")),
4316
4908
  }
4317
4909
 
4318
- def _derive_can_edit_app_base(self, *, profile: str, permission_summary: JSONObject) -> bool:
4319
- if permission_summary.get("can_edit_app") is not True:
4320
- return False
4321
- tag_ids = _coerce_int_list(permission_summary.get("tag_ids"))
4322
- for tag_id in tag_ids:
4323
- try:
4324
- package_permission = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
4325
- except (QingflowApiError, RuntimeError):
4326
- return False
4327
- if package_permission.get("can_edit_tag") is not True:
4328
- return False
4329
- return True
4330
-
4331
4910
  def _read_portal_permission_summary(self, *, dash_key: str, portal_result: dict[str, Any]) -> JSONObject:
4332
4911
  tag_ids = _coerce_int_list(portal_result.get("tagIds"))
4333
4912
  if not tag_ids:
@@ -4352,42 +4931,45 @@ class AiBuilderFacade:
4352
4931
  app_key: str,
4353
4932
  required_permission: str,
4354
4933
  normalized_args: JSONObject,
4934
+ permission_summary: JSONObject | None = None,
4355
4935
  ) -> PermissionCheckOutcome:
4356
- try:
4357
- permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
4358
- except (QingflowApiError, RuntimeError) as error:
4359
- api_error = _coerce_api_error(error)
4360
- if _is_permission_restricted_api_error(api_error):
4361
- return _permission_skip_outcome(
4362
- scope="app",
4363
- target={"app_key": app_key},
4364
- required_permission=required_permission,
4365
- transport_error=_transport_error_payload(api_error),
4366
- )
4367
- return PermissionCheckOutcome(
4368
- block=_failed(
4369
- "APP_PERMISSION_UNVERIFIED",
4370
- "could not confirm current user's builder permissions for this app",
4371
- normalized_args=normalized_args,
4372
- details={
4373
- "app_key": app_key,
4374
- "required_permission": required_permission,
4375
- "permission_read_error": {
4376
- "message": api_error.message,
4377
- "http_status": api_error.http_status,
4378
- "backend_code": api_error.backend_code,
4379
- "category": api_error.category,
4936
+ if permission_summary is None:
4937
+ try:
4938
+ permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
4939
+ except (QingflowApiError, RuntimeError) as error:
4940
+ api_error = _coerce_api_error(error)
4941
+ if _is_permission_restricted_api_error(api_error):
4942
+ return _permission_skip_outcome(
4943
+ scope="app",
4944
+ target={"app_key": app_key},
4945
+ required_permission=required_permission,
4946
+ transport_error=_transport_error_payload(api_error),
4947
+ )
4948
+ return PermissionCheckOutcome(
4949
+ block=_failed(
4950
+ "APP_PERMISSION_UNVERIFIED",
4951
+ "could not confirm current user's builder permissions for this app",
4952
+ normalized_args=normalized_args,
4953
+ details={
4954
+ "app_key": app_key,
4955
+ "required_permission": required_permission,
4956
+ "permission_read_error": {
4957
+ "message": api_error.message,
4958
+ "http_status": api_error.http_status,
4959
+ "backend_code": api_error.backend_code,
4960
+ "category": api_error.category,
4961
+ },
4380
4962
  },
4381
- },
4382
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
4383
- request_id=api_error.request_id,
4384
- backend_code=api_error.backend_code,
4385
- http_status=None if api_error.http_status == 404 else api_error.http_status,
4963
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
4964
+ request_id=api_error.request_id,
4965
+ backend_code=api_error.backend_code,
4966
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
4967
+ )
4386
4968
  )
4387
- )
4388
4969
  permission_key = {
4389
4970
  "edit_app": "can_edit_app",
4390
4971
  "data_manage": "can_manage_data",
4972
+ "view_manage": "can_manage_views",
4391
4973
  }.get(required_permission)
4392
4974
  if permission_key is None:
4393
4975
  return PermissionCheckOutcome()
@@ -4401,12 +4983,15 @@ class AiBuilderFacade:
4401
4983
  )
4402
4984
  if permission_value is not False:
4403
4985
  return PermissionCheckOutcome()
4404
- error_code = "EDIT_APP_UNAUTHORIZED" if required_permission == "edit_app" else "DATA_MANAGE_UNAUTHORIZED"
4405
- message = (
4406
- "current user does not have builder edit-app permission on this app"
4407
- if required_permission == "edit_app"
4408
- else "current user does not have data-management permission on this app"
4409
- )
4986
+ if required_permission == "edit_app":
4987
+ error_code = "EDIT_APP_UNAUTHORIZED"
4988
+ message = "current user does not have builder edit-app permission on this app"
4989
+ elif required_permission == "view_manage":
4990
+ error_code = "VIEW_MANAGE_UNAUTHORIZED"
4991
+ message = "current user does not have view-management permission on this app"
4992
+ else:
4993
+ error_code = "DATA_MANAGE_UNAUTHORIZED"
4994
+ message = "current user does not have data-management permission on this app"
4410
4995
  return PermissionCheckOutcome(
4411
4996
  block=_failed(
4412
4997
  error_code,
@@ -4569,38 +5154,78 @@ class AiBuilderFacade:
4569
5154
  details={"app_key": app_key},
4570
5155
  suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
4571
5156
  )
4572
- views, views_unavailable = self._load_views_result(
5157
+ views, views_unavailable, views_read_error = self._load_views_result(
4573
5158
  profile=profile,
4574
5159
  app_key=app_key,
4575
5160
  tolerate_404=True,
4576
5161
  tolerate_permission_restricted=True,
5162
+ include_error=True,
4577
5163
  )
4578
5164
  view_summaries = _summarize_views(views)
5165
+ readback_errors: list[JSONObject] = []
4579
5166
  charts_unavailable = False
4580
5167
  try:
4581
5168
  chart_items, _chart_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
4582
5169
  chart_summaries = _summarize_charts(chart_items)
4583
- except (QingflowApiError, RuntimeError):
5170
+ except (QingflowApiError, RuntimeError) as error:
4584
5171
  charts_unavailable = True
4585
5172
  chart_summaries = []
5173
+ readback_errors.append(
5174
+ {
5175
+ "resource": "charts",
5176
+ "phase": "summary",
5177
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
5178
+ }
5179
+ )
4586
5180
  associated_resources_unavailable = False
4587
5181
  try:
4588
5182
  associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4589
- except (QingflowApiError, RuntimeError):
5183
+ except (QingflowApiError, RuntimeError) as error:
4590
5184
  associated_resources_unavailable = True
4591
5185
  associated_resources = []
5186
+ readback_errors.append(
5187
+ {
5188
+ "resource": "associated_resources",
5189
+ "phase": "summary",
5190
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
5191
+ }
5192
+ )
4592
5193
  custom_buttons_unavailable = False
4593
5194
  try:
4594
5195
  custom_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
4595
- except (QingflowApiError, RuntimeError):
5196
+ except (QingflowApiError, RuntimeError) as error:
4596
5197
  custom_buttons_unavailable = True
4597
5198
  custom_buttons = []
4598
- workflow, workflow_unavailable = self._load_workflow_result(
5199
+ readback_errors.append(
5200
+ {
5201
+ "resource": "custom_buttons",
5202
+ "phase": "summary",
5203
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
5204
+ }
5205
+ )
5206
+ workflow, workflow_unavailable, workflow_read_error = self._load_workflow_result(
4599
5207
  profile=profile,
4600
5208
  app_key=app_key,
4601
5209
  tolerate_404=True,
4602
5210
  tolerate_permission_restricted=True,
5211
+ include_error=True,
4603
5212
  )
5213
+ if views_read_error is not None:
5214
+ readback_errors.append(
5215
+ {
5216
+ "resource": "views",
5217
+ "phase": "summary",
5218
+ "transport_error": _transport_error_payload(views_read_error),
5219
+ }
5220
+ )
5221
+ if workflow_read_error is not None:
5222
+ readback_errors.append(
5223
+ {
5224
+ "resource": "workflow",
5225
+ "phase": "summary",
5226
+ "transport_error": _transport_error_payload(workflow_read_error),
5227
+ }
5228
+ )
4604
5229
  verification_hints = _build_verification_hints(
4605
5230
  tag_ids=_coerce_int_list(base_result.get("tagIds")),
4606
5231
  fields=parsed["fields"],
@@ -4634,12 +5259,14 @@ class AiBuilderFacade:
4634
5259
  or base_result.get("name")
4635
5260
  or app_key
4636
5261
  ).strip() or app_key
5262
+ app_icon = str(base_result.get("appIcon") or "").strip() or None
4637
5263
  response = AppReadSummaryResponse(
4638
5264
  app_key=app_key,
4639
5265
  app_name=app_name,
4640
5266
  name=app_name,
4641
5267
  title=app_name,
4642
- app_icon=str(base_result.get("appIcon") or "").strip() or None,
5268
+ app_icon=app_icon,
5269
+ icon_config=workspace_icon_config(app_icon),
4643
5270
  visibility=_public_visibility_from_member_auth(base_result.get("auth")),
4644
5271
  tag_ids=_coerce_int_list(base_result.get("tagIds")),
4645
5272
  publish_status=base_result.get("appPublishStatus"),
@@ -4666,7 +5293,7 @@ class AiBuilderFacade:
4666
5293
  "normalized_args": {"app_key": app_key},
4667
5294
  "missing_fields": [],
4668
5295
  "allowed_values": {},
4669
- "details": {},
5296
+ "details": {"readback_errors": readback_errors} if readback_errors else {},
4670
5297
  "request_id": None,
4671
5298
  "suggested_next_call": None,
4672
5299
  "noop": False,
@@ -4695,13 +5322,54 @@ class AiBuilderFacade:
4695
5322
  if not result.get("suggested_next_call"):
4696
5323
  result["suggested_next_call"] = {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}}
4697
5324
  return result
4698
- permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
5325
+ try:
5326
+ permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
5327
+ except (QingflowApiError, RuntimeError) as error:
5328
+ api_error = _coerce_api_error(error)
5329
+ if not _is_permission_restricted_api_error(api_error):
5330
+ raise
5331
+ result["message"] = "read app config summary; editability unverified"
5332
+ result["editability"] = {
5333
+ "can_edit_app_base": None,
5334
+ "can_edit_form": None,
5335
+ "can_edit_flow": None,
5336
+ "can_edit_views": None,
5337
+ "can_edit_charts": None,
5338
+ }
5339
+ details = result.get("details") if isinstance(result.get("details"), dict) else {}
5340
+ permission_read_errors = details.get("permission_read_errors") if isinstance(details.get("permission_read_errors"), list) else []
5341
+ permission_read_errors.append(
5342
+ {
5343
+ "resource": "app_permission",
5344
+ "app_key": app_key,
5345
+ "required_permission": None,
5346
+ "transport_error": _transport_error_payload(api_error),
5347
+ }
5348
+ )
5349
+ details["permission_read_errors"] = permission_read_errors
5350
+ details["permission_check_skipped"] = True
5351
+ result["details"] = details
5352
+ warnings = result.get("warnings") if isinstance(result.get("warnings"), list) else []
5353
+ warnings.append(
5354
+ _warning(
5355
+ "APP_EDITABILITY_UNAVAILABLE",
5356
+ "could not confirm current user's builder permissions for this app; app summary remains available",
5357
+ app_key=app_key,
5358
+ **_transport_error_payload(api_error),
5359
+ )
5360
+ )
5361
+ result["warnings"] = warnings
5362
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
5363
+ verification["editability_unavailable"] = True
5364
+ result["verification"] = verification
5365
+ result["verified"] = False
5366
+ return result
4699
5367
  result["message"] = "read app config summary"
4700
5368
  result["editability"] = {
4701
- "can_edit_app_base": self._derive_can_edit_app_base(profile=profile, permission_summary=permission_summary),
5369
+ "can_edit_app_base": permission_summary.get("can_edit_app"),
4702
5370
  "can_edit_form": permission_summary.get("can_edit_app"),
4703
- "can_edit_flow": permission_summary.get("can_manage_data"),
4704
- "can_edit_views": permission_summary.get("can_manage_data"),
5371
+ "can_edit_flow": permission_summary.get("can_edit_app"),
5372
+ "can_edit_views": permission_summary.get("can_manage_views"),
4705
5373
  "can_edit_charts": permission_summary.get("can_manage_data"),
4706
5374
  }
4707
5375
  return result
@@ -4909,6 +5577,8 @@ class AiBuilderFacade:
4909
5577
  "would_update": bool(update_fields),
4910
5578
  },
4911
5579
  "verified": True,
5580
+ "write_executed": False,
5581
+ "safe_to_retry": True,
4912
5582
  "app_key": app_key,
4913
5583
  "apply": False,
4914
5584
  "repair_plan": plans,
@@ -4935,6 +5605,8 @@ class AiBuilderFacade:
4935
5605
  "applied": False,
4936
5606
  },
4937
5607
  "verified": True,
5608
+ "write_executed": False,
5609
+ "safe_to_retry": True,
4938
5610
  "app_key": app_key,
4939
5611
  "apply": True,
4940
5612
  "repair_plan": plans,
@@ -4952,6 +5624,7 @@ class AiBuilderFacade:
4952
5624
  )
4953
5625
  if apply_result.get("status") == "failed":
4954
5626
  return apply_result
5627
+ verification_error: JSONObject | None = None
4955
5628
  try:
4956
5629
  reread = self._load_base_schema_state(profile=profile, app_key=app_key)
4957
5630
  verified_fields = cast(list[dict[str, Any]], reread["parsed"].get("fields") or [])
@@ -4972,17 +5645,36 @@ class AiBuilderFacade:
4972
5645
  if plan["would_update"]:
4973
5646
  plan["applied"] = True
4974
5647
  applied_fields.append(plan["field_name"])
4975
- except (QingflowApiError, RuntimeError):
4976
- pass
5648
+ except (QingflowApiError, RuntimeError) as error:
5649
+ verification_error = _transport_error_payload(_coerce_api_error(error))
4977
5650
  apply_result["message"] = "repaired code block fields"
4978
5651
  apply_result["apply"] = True
4979
5652
  apply_result["repair_plan"] = plans
4980
5653
  apply_result["applied_fields"] = applied_fields
5654
+ if verification_error is None:
5655
+ existing_details = apply_result.get("details") if isinstance(apply_result.get("details"), dict) else {}
5656
+ existing_verification_error = existing_details.get("verification_error") if isinstance(existing_details, dict) else None
5657
+ if isinstance(existing_verification_error, dict):
5658
+ verification_error = deepcopy(existing_verification_error)
5659
+ if verification_error is not None:
5660
+ details = apply_result.get("details") if isinstance(apply_result.get("details"), dict) else {}
5661
+ details = deepcopy(details)
5662
+ details["code_block_repair_verification_error"] = verification_error
5663
+ apply_result["details"] = details
5664
+ warnings = apply_result.get("warnings") if isinstance(apply_result.get("warnings"), list) else []
5665
+ apply_result["warnings"] = [
5666
+ *warnings,
5667
+ _warning(
5668
+ "CODE_BLOCK_REPAIR_VERIFICATION_UNAVAILABLE",
5669
+ "code block repair write was executed but post-write verification readback is unavailable",
5670
+ ),
5671
+ ]
4981
5672
  apply_result["verification"] = {
4982
5673
  **(apply_result.get("verification") if isinstance(apply_result.get("verification"), dict) else {}),
4983
5674
  "code_block_fields_scanned": len(plans),
4984
5675
  "would_update": bool(update_fields),
4985
5676
  "applied": bool(applied_fields),
5677
+ **({"code_block_repair_verification_unavailable": True} if verification_error is not None else {}),
4986
5678
  }
4987
5679
  return apply_result
4988
5680
 
@@ -5023,10 +5715,43 @@ class AiBuilderFacade:
5023
5715
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
5024
5716
  )
5025
5717
  parsed = state["parsed"]
5718
+ warnings: list[dict[str, Any]] = []
5719
+ field_lookup = _build_public_field_lookup(cast(list[dict[str, Any]], parsed["fields"]))
5720
+ chart_fields: list[dict[str, Any]] = []
5721
+ try:
5722
+ qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
5723
+ chart_fields = _compact_public_chart_fields_read(
5724
+ app_key=app_key,
5725
+ qingbi_fields=[item for item in qingbi_fields if isinstance(item, dict)],
5726
+ field_lookup=field_lookup,
5727
+ )
5728
+ except (QingflowApiError, RuntimeError) as error:
5729
+ api_error = _coerce_api_error(error)
5730
+ if is_auth_like_error(api_error) or (
5731
+ backend_code_int(api_error) not in {40002, 40027, 404}
5732
+ and api_error.http_status != 404
5733
+ ):
5734
+ return _failed_from_api_error(
5735
+ "APP_GET_FIELDS_FAILED",
5736
+ api_error,
5737
+ normalized_args={"app_key": app_key},
5738
+ details={"app_key": app_key, "resource": "qingbi_fields"},
5739
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
5740
+ )
5741
+ warnings.append(
5742
+ _warning(
5743
+ "QINGBI_FIELDS_READ_FAILED",
5744
+ "form fields were read, but QingBI chart fields could not be read; chart configuration should use chart_fields when available",
5745
+ backend_code=api_error.backend_code,
5746
+ request_id=api_error.request_id,
5747
+ )
5748
+ )
5026
5749
  response = AppFieldsReadResponse(
5027
5750
  app_key=app_key,
5028
5751
  fields=[_compact_public_field_read(field=field, layout=parsed["layout"]) for field in parsed["fields"]],
5029
5752
  field_count=len(parsed["fields"]),
5753
+ chart_fields=chart_fields,
5754
+ chart_field_count=len(chart_fields),
5030
5755
  form_settings=_form_settings_from_schema(state["schema"], parsed["fields"]),
5031
5756
  )
5032
5757
  return {
@@ -5041,7 +5766,7 @@ class AiBuilderFacade:
5041
5766
  "request_id": None,
5042
5767
  "suggested_next_call": None,
5043
5768
  "noop": False,
5044
- "warnings": [],
5769
+ "warnings": warnings,
5045
5770
  "verification": {"app_exists": True},
5046
5771
  "verified": True,
5047
5772
  **response.model_dump(mode="json"),
@@ -5281,13 +6006,23 @@ class AiBuilderFacade:
5281
6006
  continue
5282
6007
  try:
5283
6008
  portal_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
5284
- except (QingflowApiError, RuntimeError):
6009
+ except (QingflowApiError, RuntimeError) as error:
6010
+ api_error = _coerce_api_error(error)
6011
+ if not _is_optional_builder_lookup_error(api_error):
6012
+ return _failed_from_api_error(
6013
+ "PORTAL_LIST_FAILED",
6014
+ api_error,
6015
+ normalized_args={},
6016
+ details={"dash_key": dash_key, "resource": "portal_detail"},
6017
+ suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key}},
6018
+ )
5285
6019
  permission_verified = False
5286
6020
  warnings.append(
5287
6021
  _warning(
5288
6022
  "PORTAL_PERMISSION_READ_UNAVAILABLE",
5289
6023
  f"builder portal_list skipped `{dash_key}` because portal detail readback was unavailable during permission verification",
5290
6024
  dash_key=dash_key,
6025
+ **_transport_error_payload(api_error),
5291
6026
  )
5292
6027
  )
5293
6028
  continue
@@ -5339,8 +6074,10 @@ class AiBuilderFacade:
5339
6074
  sorted_items = self.charts.qingbi_report_list_sorted(profile=profile, app_key=app_key, page_num=1, page_size=500).get("items") or []
5340
6075
  if isinstance(sorted_items, list):
5341
6076
  return sorted_items, "sorted"
5342
- except (QingflowApiError, RuntimeError):
5343
- pass
6077
+ except (QingflowApiError, RuntimeError) as exc:
6078
+ api_error = _coerce_api_error(exc)
6079
+ if not _is_optional_builder_lookup_error(api_error):
6080
+ raise
5344
6081
  fallback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
5345
6082
  return list(fallback_items) if isinstance(fallback_items, list) else [], "fallback"
5346
6083
 
@@ -5783,6 +6520,7 @@ class AiBuilderFacade:
5783
6520
  if button_id is not None:
5784
6521
  button_inventory[button_id] = item
5785
6522
  valid_custom_button_ids = (set(button_inventory) | set(created_ids) | set(updated_ids)) - set(removed_ids)
6523
+ allow_unverified_numeric_button_ids = not bool(valid_custom_button_ids)
5786
6524
  write_executed = False
5787
6525
  write_succeeded = False
5788
6526
  all_verified = True
@@ -5834,6 +6572,7 @@ class AiBuilderFacade:
5834
6572
  button_inventory=button_inventory,
5835
6573
  valid_custom_button_ids=valid_custom_button_ids,
5836
6574
  reason_path=f"view_configs[{config_index}].buttons[{button_index}].button_ref",
6575
+ allow_unverified_numeric_id=allow_unverified_numeric_button_ids,
5837
6576
  )
5838
6577
  if ref_issue:
5839
6578
  config_issues.append(ref_issue)
@@ -5845,6 +6584,7 @@ class AiBuilderFacade:
5845
6584
  binding=view_binding,
5846
6585
  current_fields_by_name=current_fields_by_name,
5847
6586
  valid_custom_button_ids=valid_custom_button_ids,
6587
+ allow_unverified_custom_button_id=allow_unverified_numeric_button_ids,
5848
6588
  )
5849
6589
  if binding_issues:
5850
6590
  config_issues.extend(binding_issues)
@@ -6034,6 +6774,7 @@ class AiBuilderFacade:
6034
6774
  details={"dash_key": dash_key, "being_draft": being_draft},
6035
6775
  suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
6036
6776
  )
6777
+ dash_icon = str(result.get("dashIcon") or "").strip() or None
6037
6778
  response = PortalGetResponse(
6038
6779
  dash_key=dash_key,
6039
6780
  being_draft=being_draft,
@@ -6047,7 +6788,8 @@ class AiBuilderFacade:
6047
6788
  )
6048
6789
  if tag_id is not None
6049
6790
  ],
6050
- dash_icon=str(result.get("dashIcon") or "").strip() or None,
6791
+ dash_icon=dash_icon,
6792
+ icon_config=workspace_icon_config(dash_icon),
6051
6793
  hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
6052
6794
  visibility=_public_visibility_from_member_auth(result.get("auth")),
6053
6795
  auth=deepcopy(result.get("auth")) if isinstance(result.get("auth"), dict) else {},
@@ -6086,6 +6828,7 @@ class AiBuilderFacade:
6086
6828
  details={"dash_key": dash_key, "being_draft": being_draft},
6087
6829
  suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
6088
6830
  )
6831
+ dash_icon = str(result.get("dashIcon") or "").strip() or None
6089
6832
  response = PortalReadSummaryResponse(
6090
6833
  dash_key=dash_key,
6091
6834
  being_draft=being_draft,
@@ -6099,7 +6842,8 @@ class AiBuilderFacade:
6099
6842
  )
6100
6843
  if tag_id is not None
6101
6844
  ],
6102
- dash_icon=str(result.get("dashIcon") or "").strip() or None,
6845
+ dash_icon=dash_icon,
6846
+ icon_config=workspace_icon_config(dash_icon),
6103
6847
  hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
6104
6848
  config_keys=sorted(str(key) for key in (result.get("config") or {}).keys()) if isinstance(result.get("config"), dict) else [],
6105
6849
  dash_global_config_keys=sorted(str(key) for key in (result.get("dashGlobalConfig") or {}).keys()) if isinstance(result.get("dashGlobalConfig"), dict) else [],
@@ -6138,6 +6882,7 @@ class AiBuilderFacade:
6138
6882
  )
6139
6883
 
6140
6884
  warnings: list[dict[str, Any]] = []
6885
+ readback_errors: list[JSONObject] = []
6141
6886
  verification = {
6142
6887
  "view_exists": True,
6143
6888
  "base_info_verified": True,
@@ -6148,39 +6893,133 @@ class AiBuilderFacade:
6148
6893
 
6149
6894
  base_info: dict[str, Any] = {}
6150
6895
  try:
6151
- base_info_payload = self.views.view_get_base_info(profile=profile, viewgraph_key=view_key, passcode=None).get("result") or {}
6896
+ base_info_response = self.views.view_get_base_info(profile=profile, viewgraph_key=view_key, passcode=None)
6897
+ base_info_payload = base_info_response.get("result") or {}
6152
6898
  if isinstance(base_info_payload, dict):
6153
6899
  base_info = deepcopy(base_info_payload)
6154
- except (QingflowApiError, RuntimeError):
6900
+ base_info_verification = (
6901
+ base_info_response.get("verification")
6902
+ if isinstance(base_info_response.get("verification"), dict)
6903
+ else {}
6904
+ )
6905
+ if base_info_verification.get("base_info_verified") is False:
6906
+ verification["base_info_verified"] = False
6907
+ for warning in base_info_response.get("warnings") or []:
6908
+ if not isinstance(warning, dict):
6909
+ continue
6910
+ warnings.append(deepcopy(warning))
6911
+ readback_errors.append(
6912
+ {
6913
+ "resource": "view_base_info",
6914
+ "phase": "view_get",
6915
+ "view_key": view_key,
6916
+ "transport_error": {
6917
+ "http_status": warning.get("http_status"),
6918
+ "backend_code": warning.get("backend_code"),
6919
+ "category": warning.get("category"),
6920
+ "request_id": warning.get("request_id"),
6921
+ },
6922
+ }
6923
+ )
6924
+ except (QingflowApiError, RuntimeError) as error:
6155
6925
  verification["base_info_verified"] = False
6156
- warnings.append(_warning("VIEW_BASE_INFO_UNAVAILABLE", "view base info readback is unavailable"))
6926
+ api_error = _coerce_api_error(error)
6927
+ if not _is_optional_builder_lookup_error(api_error):
6928
+ return _failed_from_api_error(
6929
+ "VIEW_GET_FAILED",
6930
+ api_error,
6931
+ normalized_args={"view_key": view_key},
6932
+ details={"view_key": view_key, "resource": "view_base_info"},
6933
+ suggested_next_call={"tool_name": "view_get", "arguments": {"profile": profile, "view_key": view_key}},
6934
+ )
6935
+ readback_errors.append(
6936
+ {
6937
+ "resource": "view_base_info",
6938
+ "phase": "view_get",
6939
+ "view_key": view_key,
6940
+ "transport_error": _transport_error_payload(api_error),
6941
+ }
6942
+ )
6943
+ warnings.append(_warning("VIEW_BASE_INFO_UNAVAILABLE", "view base info readback is unavailable", **_transport_error_payload(api_error)))
6157
6944
 
6158
6945
  questions: list[dict[str, Any]] = []
6159
6946
  try:
6160
6947
  questions_payload = self.views.view_list_questions(profile=profile, viewgraph_key=view_key).get("result") or []
6161
6948
  if isinstance(questions_payload, list):
6162
6949
  questions = [deepcopy(item) for item in questions_payload if isinstance(item, dict)]
6163
- except (QingflowApiError, RuntimeError):
6950
+ except (QingflowApiError, RuntimeError) as error:
6164
6951
  verification["questions_verified"] = False
6165
- warnings.append(_warning("VIEW_QUESTIONS_UNAVAILABLE", "view question list readback is unavailable"))
6952
+ api_error = _coerce_api_error(error)
6953
+ if not _is_optional_builder_lookup_error(api_error):
6954
+ return _failed_from_api_error(
6955
+ "VIEW_GET_FAILED",
6956
+ api_error,
6957
+ normalized_args={"view_key": view_key},
6958
+ details={"view_key": view_key, "resource": "view_questions"},
6959
+ suggested_next_call={"tool_name": "view_get", "arguments": {"profile": profile, "view_key": view_key}},
6960
+ )
6961
+ readback_errors.append(
6962
+ {
6963
+ "resource": "view_questions",
6964
+ "phase": "view_get",
6965
+ "view_key": view_key,
6966
+ "transport_error": _transport_error_payload(api_error),
6967
+ }
6968
+ )
6969
+ warnings.append(_warning("VIEW_QUESTIONS_UNAVAILABLE", "view question list readback is unavailable", **_transport_error_payload(api_error)))
6166
6970
 
6167
6971
  associations: list[dict[str, Any]] = []
6168
6972
  try:
6169
6973
  associations_payload = self.views.view_list_associations(profile=profile, viewgraph_key=view_key).get("result") or []
6170
6974
  if isinstance(associations_payload, list):
6171
6975
  associations = [deepcopy(item) for item in associations_payload if isinstance(item, dict)]
6172
- except (QingflowApiError, RuntimeError):
6976
+ except (QingflowApiError, RuntimeError) as error:
6173
6977
  verification["associations_verified"] = False
6174
- warnings.append(_warning("VIEW_ASSOCIATIONS_UNAVAILABLE", "view association list readback is unavailable"))
6978
+ api_error = _coerce_api_error(error)
6979
+ if not _is_optional_builder_lookup_error(api_error):
6980
+ return _failed_from_api_error(
6981
+ "VIEW_GET_FAILED",
6982
+ api_error,
6983
+ normalized_args={"view_key": view_key},
6984
+ details={"view_key": view_key, "resource": "view_associations"},
6985
+ suggested_next_call={"tool_name": "view_get", "arguments": {"profile": profile, "view_key": view_key}},
6986
+ )
6987
+ readback_errors.append(
6988
+ {
6989
+ "resource": "view_associations",
6990
+ "phase": "view_get",
6991
+ "view_key": view_key,
6992
+ "transport_error": _transport_error_payload(api_error),
6993
+ }
6994
+ )
6995
+ warnings.append(_warning("VIEW_ASSOCIATIONS_UNAVAILABLE", "view association list readback is unavailable", **_transport_error_payload(api_error)))
6175
6996
 
6176
6997
  app_key = str(_first_present(config, "appKey", "formKey") or _first_present(base_info, "appKey", "formKey") or "").strip()
6177
6998
  associated_resources: list[dict[str, Any]] = []
6178
6999
  if app_key:
6179
7000
  try:
6180
7001
  associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
6181
- except (QingflowApiError, RuntimeError):
7002
+ except (QingflowApiError, RuntimeError) as error:
6182
7003
  verification["associated_resources_verified"] = False
6183
- warnings.append(_warning("VIEW_ASSOCIATED_RESOURCES_UNAVAILABLE", "view associated resource pool readback is unavailable"))
7004
+ api_error = _coerce_api_error(error)
7005
+ if not _is_optional_builder_lookup_error(api_error):
7006
+ return _failed_from_api_error(
7007
+ "VIEW_GET_FAILED",
7008
+ api_error,
7009
+ normalized_args={"view_key": view_key},
7010
+ details={"view_key": view_key, "app_key": app_key, "resource": "associated_resources"},
7011
+ suggested_next_call={"tool_name": "view_get", "arguments": {"profile": profile, "view_key": view_key}},
7012
+ )
7013
+ readback_errors.append(
7014
+ {
7015
+ "resource": "associated_resources",
7016
+ "phase": "view_get",
7017
+ "view_key": view_key,
7018
+ "app_key": app_key,
7019
+ "transport_error": _transport_error_payload(api_error),
7020
+ }
7021
+ )
7022
+ warnings.append(_warning("VIEW_ASSOCIATED_RESOURCES_UNAVAILABLE", "view associated resource pool readback is unavailable", **_transport_error_payload(api_error)))
6184
7023
  associated_resources_config = _extract_view_associated_resources_config(
6185
7024
  config if isinstance(config, dict) else {},
6186
7025
  available_resources=associated_resources,
@@ -6213,7 +7052,7 @@ class AiBuilderFacade:
6213
7052
  "normalized_args": {"view_key": view_key},
6214
7053
  "missing_fields": [],
6215
7054
  "allowed_values": {},
6216
- "details": {},
7055
+ "details": {"readback_errors": readback_errors} if readback_errors else {},
6217
7056
  "request_id": None,
6218
7057
  "suggested_next_call": None,
6219
7058
  "noop": False,
@@ -6250,15 +7089,34 @@ class AiBuilderFacade:
6250
7089
  )
6251
7090
 
6252
7091
  try:
6253
- config = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id).get("result") or {}
7092
+ config_response = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id)
7093
+ config = config_response.get("result") or {}
7094
+ config_warnings = config_response.get("warnings") if isinstance(config_response.get("warnings"), list) else []
7095
+ warnings.extend(item for item in config_warnings if isinstance(item, dict))
7096
+ config_verification = (
7097
+ config_response.get("verification") if isinstance(config_response.get("verification"), dict) else {}
7098
+ )
7099
+ if config_verification:
7100
+ verification.update(config_verification)
6254
7101
  except (QingflowApiError, RuntimeError) as error:
7102
+ api_error = _coerce_api_error(error)
7103
+ if not _is_optional_builder_lookup_error(api_error) and backend_code_int(api_error) != 81007:
7104
+ return _failed_from_api_error(
7105
+ "CHART_GET_FAILED",
7106
+ api_error,
7107
+ normalized_args={"chart_id": chart_id},
7108
+ details={"chart_id": chart_id, "resource": "chart_config"},
7109
+ suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": chart_id}},
7110
+ )
6255
7111
  fallback_config: dict[str, Any] | None = None
7112
+ fallback_api_error: QingflowApiError | None = None
6256
7113
  try:
6257
7114
  data_fallback = self.charts.qingbi_report_get_data(profile=profile, chart_id=chart_id, payload={}).get("result") or {}
6258
7115
  config_from_data = data_fallback.get("config") if isinstance(data_fallback, dict) else None
6259
7116
  if isinstance(config_from_data, dict):
6260
7117
  fallback_config = deepcopy(config_from_data)
6261
- except (QingflowApiError, RuntimeError):
7118
+ except (QingflowApiError, RuntimeError) as fallback_error:
7119
+ fallback_api_error = _coerce_api_error(fallback_error)
6262
7120
  fallback_config = None
6263
7121
  if isinstance(fallback_config, dict):
6264
7122
  config = fallback_config
@@ -6269,12 +7127,17 @@ class AiBuilderFacade:
6269
7127
  )
6270
7128
  )
6271
7129
  else:
6272
- api_error = _coerce_api_error(error)
7130
+ details: JSONObject = {
7131
+ "chart_id": chart_id,
7132
+ "config_error": _transport_error_payload(api_error),
7133
+ }
7134
+ if fallback_api_error is not None:
7135
+ details["data_fallback_error"] = _transport_error_payload(fallback_api_error)
6273
7136
  return _failed_from_api_error(
6274
7137
  "CHART_GET_FAILED",
6275
7138
  api_error,
6276
7139
  normalized_args={"chart_id": chart_id},
6277
- details={"chart_id": chart_id},
7140
+ details=details,
6278
7141
  suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": chart_id}},
6279
7142
  )
6280
7143
 
@@ -6986,16 +7849,6 @@ class AiBuilderFacade:
6986
7849
  if add_permission_outcome.block is not None:
6987
7850
  return add_permission_outcome.block
6988
7851
  permission_outcomes.append(add_permission_outcome)
6989
- if requested_field_changes:
6990
- edit_permission_outcome = self._guard_package_permission(
6991
- profile=profile,
6992
- tag_id=permission_tag_id,
6993
- required_permission="edit_app",
6994
- normalized_args=normalized_args,
6995
- )
6996
- if edit_permission_outcome.block is not None:
6997
- return edit_permission_outcome.block
6998
- permission_outcomes.append(edit_permission_outcome)
6999
7852
  resolved = self._create_target_app_shell(
7000
7853
  profile=profile,
7001
7854
  app_name=app_name,
@@ -7095,6 +7948,9 @@ class AiBuilderFacade:
7095
7948
  "created": True,
7096
7949
  "field_diff": {"added": [], "updated": [], "removed": []},
7097
7950
  "verified": True,
7951
+ "write_executed": True,
7952
+ "write_succeeded": True,
7953
+ "safe_to_retry": False,
7098
7954
  "tag_ids_after": list(target.tag_ids),
7099
7955
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
7100
7956
  "publish_requested": False,
@@ -7323,7 +8179,34 @@ class AiBuilderFacade:
7323
8179
  )
7324
8180
 
7325
8181
  if not added and not updated and not removed and not normalized_code_block_fields and not data_display_selection.has_any and not bool(resolved.get("created")):
7326
- base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
8182
+ try:
8183
+ base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
8184
+ except (QingflowApiError, RuntimeError) as error:
8185
+ api_error = _coerce_api_error(error)
8186
+ if bool(visual_result.get("updated")):
8187
+ return finalize(_post_write_readback_pending_result(
8188
+ error_code="APP_BASE_READBACK_PENDING",
8189
+ message="updated app base metadata; app base readback is unavailable",
8190
+ normalized_args=normalized_args,
8191
+ details={
8192
+ "app_key": target.app_key,
8193
+ "verification_error": _transport_error_payload(api_error),
8194
+ "app_name_after": effective_app_name,
8195
+ "app_base_updated": True,
8196
+ "field_diff": {"added": [], "updated": [], "removed": []},
8197
+ },
8198
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": target.app_key}},
8199
+ request_id=api_error.request_id,
8200
+ backend_code=api_error.backend_code,
8201
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
8202
+ ))
8203
+ return finalize(_failed_from_api_error(
8204
+ "APP_BASE_READBACK_FAILED",
8205
+ api_error,
8206
+ normalized_args=normalized_args,
8207
+ details=_with_state_read_blocked_details({"app_key": target.app_key}, resource="app_base", error=api_error),
8208
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": target.app_key}},
8209
+ ))
7327
8210
  tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
7328
8211
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
7329
8212
  actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
@@ -7361,6 +8244,9 @@ class AiBuilderFacade:
7361
8244
  "created": False,
7362
8245
  "field_diff": {"added": [], "updated": [], "removed": []},
7363
8246
  "verified": verified,
8247
+ "write_executed": bool(visual_result.get("updated")),
8248
+ "write_succeeded": bool(visual_result.get("updated")),
8249
+ "safe_to_retry": not bool(visual_result.get("updated")),
7364
8250
  "tag_ids_after": tag_ids_after,
7365
8251
  "package_attached": package_attached,
7366
8252
  }
@@ -7398,7 +8284,7 @@ class AiBuilderFacade:
7398
8284
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
7399
8285
  except (QingflowApiError, RuntimeError) as error:
7400
8286
  api_error = _coerce_api_error(error)
7401
- if api_error.backend_code == 49614:
8287
+ if backend_code_int(api_error) == 49614:
7402
8288
  return _failed(
7403
8289
  "MULTIPLE_RELATION_FIELDS_UNSUPPORTED",
7404
8290
  "backend currently rejects apps with more than one relation field; keep one real relation field and use text/reference summary fields for additional cross-object links.",
@@ -7570,6 +8456,9 @@ class AiBuilderFacade:
7570
8456
  after_fields=current_fields,
7571
8457
  ),
7572
8458
  "verified": False,
8459
+ "write_executed": True,
8460
+ "write_succeeded": True,
8461
+ "safe_to_retry": False,
7573
8462
  "tag_ids_after": [],
7574
8463
  "package_attached": None,
7575
8464
  }
@@ -7675,8 +8564,7 @@ class AiBuilderFacade:
7675
8564
  response["details"] = details
7676
8565
  details["verification_error"] = {
7677
8566
  "message": verification_error.message,
7678
- "http_status": verification_error.http_status,
7679
- "backend_code": verification_error.backend_code,
8567
+ **_transport_error_payload(verification_error),
7680
8568
  }
7681
8569
  return finalize(response)
7682
8570
 
@@ -7813,6 +8701,9 @@ class AiBuilderFacade:
7813
8701
  "fallback_applied": None,
7814
8702
  },
7815
8703
  "verified": True,
8704
+ "write_executed": False,
8705
+ "write_succeeded": False,
8706
+ "safe_to_retry": True,
7816
8707
  }
7817
8708
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
7818
8709
  payload = _build_form_payload_from_existing_schema(
@@ -7830,7 +8721,7 @@ class AiBuilderFacade:
7830
8721
  self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=payload)
7831
8722
  except (QingflowApiError, RuntimeError) as error:
7832
8723
  api_error = _coerce_api_error(error)
7833
- if api_error.backend_code == 400 and target_layout.get("sections"):
8724
+ if backend_code_int(api_error) == 400 and target_layout.get("sections"):
7834
8725
  flattened_layout = _flatten_layout_sections(target_layout)
7835
8726
  fallback_payload = _build_form_payload_from_existing_schema(
7836
8727
  current_schema=schema_result,
@@ -7885,12 +8776,22 @@ class AiBuilderFacade:
7885
8776
  "message": "applied app layout; layout readback pending",
7886
8777
  "normalized_args": normalized_args,
7887
8778
  "missing_fields": [],
7888
- "allowed_values": {"modes": ["merge", "replace"]},
7889
- "details": {},
7890
- "request_id": None,
8779
+ "allowed_values": {"modes": ["merge", "replace"]},
8780
+ "details": {"readback_error": _transport_error_payload(api_error)},
8781
+ "request_id": api_error.request_id,
8782
+ "backend_code": api_error.backend_code,
8783
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
7891
8784
  "suggested_next_call": {"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
7892
8785
  "noop": False,
7893
- "warnings": [],
8786
+ "warnings": [
8787
+ _warning(
8788
+ "READBACK_UNAVAILABLE_AFTER_WRITE",
8789
+ "write was executed but layout readback is unavailable",
8790
+ backend_code=api_error.backend_code,
8791
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
8792
+ request_id=api_error.request_id,
8793
+ )
8794
+ ],
7894
8795
  "verification": {"layout_verified": False, "layout_summary_verified": False, "layout_read_unavailable": True},
7895
8796
  "app_key": app_key,
7896
8797
  "app_name": app_name,
@@ -7902,8 +8803,10 @@ class AiBuilderFacade:
7902
8803
  "fallback_applied": fallback_applied,
7903
8804
  },
7904
8805
  "verified": False,
8806
+ "write_executed": True,
8807
+ "write_succeeded": True,
8808
+ "safe_to_retry": False,
7905
8809
  }
7906
- response["request_id"] = api_error.request_id
7907
8810
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
7908
8811
  verified_layout = _parse_schema(verified_schema)["layout"]
7909
8812
  layout_verified = _layouts_equal(verified_layout, applied_layout) or _layouts_semantically_equal(verified_layout, applied_layout)
@@ -7959,6 +8862,9 @@ class AiBuilderFacade:
7959
8862
  "fallback_applied": fallback_applied,
7960
8863
  },
7961
8864
  "verified": layout_verified,
8865
+ "write_executed": True,
8866
+ "write_succeeded": True,
8867
+ "safe_to_retry": False,
7962
8868
  }
7963
8869
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
7964
8870
 
@@ -7983,7 +8889,7 @@ class AiBuilderFacade:
7983
8889
  permission_outcome = self._guard_app_permission(
7984
8890
  profile=profile,
7985
8891
  app_key=app_key,
7986
- required_permission="data_manage",
8892
+ required_permission="edit_app",
7987
8893
  normalized_args=normalized_args,
7988
8894
  )
7989
8895
  if permission_outcome.block is not None:
@@ -8184,6 +9090,9 @@ class AiBuilderFacade:
8184
9090
  "app_name": app_name,
8185
9091
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
8186
9092
  "verified": workflow_verified,
9093
+ "write_executed": True,
9094
+ "write_succeeded": True,
9095
+ "safe_to_retry": False,
8187
9096
  }
8188
9097
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
8189
9098
 
@@ -8228,15 +9137,57 @@ class AiBuilderFacade:
8228
9137
  "app_key": app_key,
8229
9138
  "views_diff": {"created": [], "updated": [], "removed": []},
8230
9139
  "verified": True,
9140
+ "write_executed": False,
9141
+ "write_succeeded": False,
9142
+ "safe_to_retry": True,
8231
9143
  }
8232
9144
  return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
8233
9145
  permission_outcomes: list[PermissionCheckOutcome] = []
8234
- permission_outcome = self._guard_app_permission(
8235
- profile=profile,
8236
- app_key=app_key,
8237
- required_permission="data_manage",
8238
- normalized_args=normalized_args,
8239
- )
9146
+ app_permission_summary: JSONObject | None = None
9147
+ app_permission_error: QingflowApiError | None = None
9148
+ try:
9149
+ app_permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
9150
+ except (QingflowApiError, RuntimeError) as error:
9151
+ app_permission_error = _coerce_api_error(error)
9152
+ app_permission_summary = None
9153
+ if app_permission_error is not None:
9154
+ if _is_permission_restricted_api_error(app_permission_error):
9155
+ permission_outcome = _permission_skip_outcome(
9156
+ scope="app",
9157
+ target={"app_key": app_key},
9158
+ required_permission="view_manage",
9159
+ transport_error=_transport_error_payload(app_permission_error),
9160
+ )
9161
+ else:
9162
+ permission_outcome = PermissionCheckOutcome(
9163
+ block=_failed(
9164
+ "APP_PERMISSION_UNVERIFIED",
9165
+ "could not confirm current user's builder permissions for this app",
9166
+ normalized_args=normalized_args,
9167
+ details={
9168
+ "app_key": app_key,
9169
+ "required_permission": "view_manage",
9170
+ "permission_read_error": {
9171
+ "message": app_permission_error.message,
9172
+ "http_status": app_permission_error.http_status,
9173
+ "backend_code": app_permission_error.backend_code,
9174
+ "category": app_permission_error.category,
9175
+ },
9176
+ },
9177
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
9178
+ request_id=app_permission_error.request_id,
9179
+ backend_code=app_permission_error.backend_code,
9180
+ http_status=None if app_permission_error.http_status == 404 else app_permission_error.http_status,
9181
+ )
9182
+ )
9183
+ else:
9184
+ permission_outcome = self._guard_app_permission(
9185
+ profile=profile,
9186
+ app_key=app_key,
9187
+ required_permission="view_manage",
9188
+ normalized_args=normalized_args,
9189
+ permission_summary=app_permission_summary,
9190
+ )
8240
9191
  if permission_outcome.block is not None:
8241
9192
  return permission_outcome.block
8242
9193
  permission_outcomes.append(permission_outcome)
@@ -8269,6 +9220,35 @@ class AiBuilderFacade:
8269
9220
  if name and key:
8270
9221
  existing_by_key[key] = view
8271
9222
  existing_by_name.setdefault(name, []).append(view)
9223
+ creating_view_names = [
9224
+ patch.name
9225
+ for patch in upsert_views
9226
+ if not patch.view_key and not existing_by_name.get(patch.name)
9227
+ ]
9228
+ if creating_view_names:
9229
+ if app_permission_error is not None and _is_permission_restricted_api_error(app_permission_error):
9230
+ create_permission_outcome = _permission_skip_outcome(
9231
+ scope="app",
9232
+ target={"app_key": app_key},
9233
+ required_permission="data_manage",
9234
+ transport_error=_transport_error_payload(app_permission_error),
9235
+ )
9236
+ else:
9237
+ create_permission_outcome = self._guard_app_permission(
9238
+ profile=profile,
9239
+ app_key=app_key,
9240
+ required_permission="data_manage",
9241
+ normalized_args=normalized_args,
9242
+ permission_summary=app_permission_summary,
9243
+ )
9244
+ if create_permission_outcome.block is not None:
9245
+ details = create_permission_outcome.block.get("details")
9246
+ if isinstance(details, dict):
9247
+ details["operation"] = "view_create"
9248
+ details["view_names"] = creating_view_names
9249
+ details["also_required_permission"] = "view_manage"
9250
+ return create_permission_outcome.block
9251
+ permission_outcomes.append(create_permission_outcome)
8272
9252
  parsed_schema = _parse_schema(schema)
8273
9253
  field_names = {field["name"] for field in parsed_schema["fields"]}
8274
9254
  if patch_views:
@@ -8343,8 +9323,26 @@ class AiBuilderFacade:
8343
9323
  being_draft=True,
8344
9324
  include_raw=False,
8345
9325
  )
8346
- except (QingflowApiError, RuntimeError):
8347
- continue
9326
+ except (QingflowApiError, RuntimeError) as error:
9327
+ api_error = _coerce_api_error(error)
9328
+ if _is_optional_builder_lookup_error(api_error):
9329
+ continue
9330
+ failed = _failed_from_api_error(
9331
+ "CUSTOM_BUTTON_DETAIL_READ_FAILED",
9332
+ api_error,
9333
+ normalized_args=normalized_args,
9334
+ details=_with_state_read_blocked_details(
9335
+ {"app_key": app_key, "button_id": button_id},
9336
+ resource="custom_button",
9337
+ error=api_error,
9338
+ ),
9339
+ suggested_next_call={
9340
+ "tool_name": "app_custom_button_get",
9341
+ "arguments": {"profile": profile, "app_key": app_key, "button_id": button_id},
9342
+ },
9343
+ )
9344
+ failed.update({"write_executed": False, "write_succeeded": False, "safe_to_retry": True})
9345
+ return finalize(failed)
8348
9346
  detail_result = detail.get("result")
8349
9347
  if isinstance(detail_result, dict):
8350
9348
  custom_button_details_by_id[button_id] = _normalize_custom_button_detail(detail_result)
@@ -8404,13 +9402,56 @@ class AiBuilderFacade:
8404
9402
  if len(matches) == 1:
8405
9403
  key = _extract_view_key(matches[0])
8406
9404
  removed_name = _extract_view_name(matches[0]) or selector_text
8407
- self.views.view_delete(profile=profile, viewgraph_key=key)
8408
- removed.append(removed_name)
8409
- if key:
8410
- removed_keys.add(key)
8411
- existing_by_key.pop(key, None)
8412
- existing_by_name.pop(removed_name, None)
8413
- view_results.append({"name": removed_name, "view_key": key, "type": None, "status": "removed"})
9405
+ try:
9406
+ self.views.view_delete(profile=profile, viewgraph_key=key)
9407
+ delete_readback = self._verify_view_deleted_by_key(profile=profile, view_key=key)
9408
+ removed.append(removed_name)
9409
+ if key:
9410
+ removed_keys.add(key)
9411
+ existing_by_key.pop(key, None)
9412
+ existing_by_name.pop(removed_name, None)
9413
+ view_results.append(
9414
+ {
9415
+ "name": removed_name,
9416
+ "view_key": key,
9417
+ "type": None,
9418
+ "status": delete_readback.get("status") or "readback_pending",
9419
+ "operation": "delete",
9420
+ "delete_executed": True,
9421
+ "readback_status": delete_readback.get("readback_status"),
9422
+ "safe_to_retry_delete": False,
9423
+ **(
9424
+ {
9425
+ "error_code": delete_readback.get("error_code"),
9426
+ "message": delete_readback.get("message"),
9427
+ "request_id": delete_readback.get("request_id"),
9428
+ "backend_code": delete_readback.get("backend_code"),
9429
+ "http_status": delete_readback.get("http_status"),
9430
+ "transport_error": delete_readback.get("transport_error"),
9431
+ }
9432
+ if delete_readback.get("readback_status") != "deleted"
9433
+ else {}
9434
+ ),
9435
+ }
9436
+ )
9437
+ except (QingflowApiError, RuntimeError) as error:
9438
+ api_error = _coerce_api_error(error)
9439
+ failed_view = {
9440
+ "name": removed_name,
9441
+ "view_key": key,
9442
+ "type": None,
9443
+ "status": "failed",
9444
+ "operation": "delete",
9445
+ "delete_executed": False,
9446
+ "safe_to_retry_delete": True,
9447
+ "error_code": "VIEW_DELETE_FAILED",
9448
+ "message": _public_error_message("VIEW_APPLY_FAILED", api_error),
9449
+ "request_id": api_error.request_id,
9450
+ "backend_code": api_error.backend_code,
9451
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
9452
+ }
9453
+ failed_views.append(failed_view)
9454
+ view_results.append(deepcopy(failed_view))
8414
9455
  created: list[str] = []
8415
9456
  updated: list[str] = []
8416
9457
  existing_view_list = [
@@ -8776,7 +9817,7 @@ class AiBuilderFacade:
8776
9817
  except (QingflowApiError, RuntimeError) as error:
8777
9818
  api_error = _coerce_api_error(error)
8778
9819
  should_retry_minimal = operation_phase != "default_view_apply_config_sync" and (
8779
- api_error.backend_code == 48104
9820
+ backend_code_int(api_error) == 48104
8780
9821
  or (patch.type.value == "table" and bool(patch.filters) and api_error.http_status is not None and api_error.http_status >= 500)
8781
9822
  )
8782
9823
  if should_retry_minimal:
@@ -8950,17 +9991,21 @@ class AiBuilderFacade:
8950
9991
  failed_views.append(failure_entry)
8951
9992
  view_results.append(failure_entry)
8952
9993
  continue
8953
- try:
8954
- verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
8955
- except (QingflowApiError, RuntimeError) as error:
8956
- api_error = _coerce_api_error(error)
8957
- return finalize(_failed_from_api_error(
8958
- "VIEWS_READ_FAILED",
8959
- api_error,
8960
- normalized_args=normalized_args,
8961
- details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
8962
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
8963
- ))
9994
+ needs_view_list_readback = bool(created or updated)
9995
+ verified_view_result: list[dict[str, Any]] | None = []
9996
+ verified_views_unavailable = False
9997
+ if needs_view_list_readback:
9998
+ try:
9999
+ verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
10000
+ except (QingflowApiError, RuntimeError) as error:
10001
+ api_error = _coerce_api_error(error)
10002
+ return finalize(_failed_from_api_error(
10003
+ "VIEWS_READ_FAILED",
10004
+ api_error,
10005
+ normalized_args=normalized_args,
10006
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
10007
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10008
+ ))
8964
10009
  verified_names = {
8965
10010
  _extract_view_name(item)
8966
10011
  for item in (verified_view_result or [])
@@ -9248,8 +10293,27 @@ class AiBuilderFacade:
9248
10293
  "view_key": item.get("view_key"),
9249
10294
  "type": item.get("type"),
9250
10295
  "status": "removed",
9251
- "present_in_readback": None if verified_views_unavailable else name in verified_names,
9252
- "removed_verified": None if verified_views_unavailable else name not in verified_names,
10296
+ "present_in_readback": False,
10297
+ "removed_verified": True,
10298
+ "delete_executed": bool(item.get("delete_executed")),
10299
+ "readback_status": item.get("readback_status") or "deleted",
10300
+ "safe_to_retry_delete": False,
10301
+ }
10302
+ )
10303
+ elif status == "readback_pending" and item.get("operation") == "delete":
10304
+ readback_status = str(item.get("readback_status") or "unavailable")
10305
+ verification_by_view.append(
10306
+ {
10307
+ "name": name,
10308
+ "view_key": item.get("view_key"),
10309
+ "type": item.get("type"),
10310
+ "status": "readback_pending",
10311
+ "present_in_readback": True if readback_status == "still_exists" else None,
10312
+ "removed_verified": False,
10313
+ "delete_executed": bool(item.get("delete_executed")),
10314
+ "readback_status": readback_status,
10315
+ "safe_to_retry_delete": False,
10316
+ "error_code": item.get("error_code"),
9253
10317
  }
9254
10318
  )
9255
10319
  else:
@@ -9262,10 +10326,17 @@ class AiBuilderFacade:
9262
10326
  "error_code": item.get("error_code"),
9263
10327
  }
9264
10328
  )
10329
+ removed_delete_results = [
10330
+ item
10331
+ for item in view_results
10332
+ if item.get("operation") == "delete" and bool(item.get("delete_executed"))
10333
+ ]
10334
+ removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed_delete_results)
10335
+ delete_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed_delete_results)
9265
10336
  verified = (
9266
10337
  (not verified_views_unavailable)
9267
10338
  and all(name in verified_names for name in created + updated)
9268
- and all(name not in verified_names for name in removed)
10339
+ and removed_verified
9269
10340
  )
9270
10341
  view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
9271
10342
  view_query_conditions_verified = verified and not query_condition_readback_pending and not query_condition_mismatches
@@ -9342,6 +10413,9 @@ class AiBuilderFacade:
9342
10413
  "app_name": app_name,
9343
10414
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
9344
10415
  "verified": verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified,
10416
+ "write_executed": bool(created or updated or removed),
10417
+ "write_succeeded": bool(created or updated or removed),
10418
+ "safe_to_retry": not bool(created or updated or removed),
9345
10419
  }
9346
10420
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
9347
10421
  warnings: list[dict[str, Any]] = []
@@ -9360,6 +10434,13 @@ class AiBuilderFacade:
9360
10434
  "system buttons verified, but draft custom button bindings are not fully visible through view readback yet",
9361
10435
  )
9362
10436
  )
10437
+ if delete_readback_pending:
10438
+ warnings.append(
10439
+ _warning(
10440
+ "VIEW_DELETE_READBACK_PENDING",
10441
+ "view delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
10442
+ )
10443
+ )
9363
10444
  all_verified = verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified
9364
10445
  response = {
9365
10446
  "status": "success" if all_verified else "partial_success",
@@ -9407,6 +10488,7 @@ class AiBuilderFacade:
9407
10488
  "query_condition_readback_pending": query_condition_readback_pending,
9408
10489
  "associated_resource_readback_pending": associated_resource_readback_pending,
9409
10490
  "button_readback_pending": button_readback_pending,
10491
+ "delete_readback_pending": delete_readback_pending,
9410
10492
  "custom_button_readback_pending": custom_button_readback_pending,
9411
10493
  "custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
9412
10494
  "by_view": verification_by_view,
@@ -9415,6 +10497,9 @@ class AiBuilderFacade:
9415
10497
  "app_name": app_name,
9416
10498
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
9417
10499
  "verified": all_verified,
10500
+ "write_executed": bool(created or updated or removed),
10501
+ "write_succeeded": bool(created or updated or removed),
10502
+ "safe_to_retry": not bool(created or updated or removed),
9418
10503
  }
9419
10504
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
9420
10505
 
@@ -9475,8 +10560,21 @@ class AiBuilderFacade:
9475
10560
  "tag_ids_after": tag_ids_before,
9476
10561
  "views_ok": True,
9477
10562
  "verified": True,
10563
+ "write_executed": False,
10564
+ "write_succeeded": False,
10565
+ "safe_to_retry": True,
9478
10566
  }
9479
- version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
10567
+ try:
10568
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
10569
+ except (QingflowApiError, RuntimeError) as error:
10570
+ api_error = _coerce_api_error(error)
10571
+ return _failed_from_api_error(
10572
+ "PUBLISH_PRECHECK_FAILED",
10573
+ api_error,
10574
+ normalized_args=normalized_args,
10575
+ details={"app_key": app_key, "phase": "prepare_publish_edit_version"},
10576
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10577
+ )
9480
10578
  edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
9481
10579
  try:
9482
10580
  self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
@@ -9494,13 +10592,18 @@ class AiBuilderFacade:
9494
10592
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
9495
10593
  except (QingflowApiError, RuntimeError) as error:
9496
10594
  api_error = _coerce_api_error(error)
9497
- return _failed_from_api_error(
9498
- "APP_READ_FAILED",
9499
- api_error,
10595
+ result = _post_write_readback_pending_result(
10596
+ error_code="PUBLISH_READBACK_PENDING",
10597
+ message="published app; app base readback is unavailable",
9500
10598
  normalized_args=normalized_args,
9501
- details={"app_key": app_key},
10599
+ details={"app_key": app_key, "edit_version_no": edit_version_no, "readback_error": _transport_error_payload(api_error)},
9502
10600
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10601
+ request_id=api_error.request_id,
10602
+ backend_code=api_error.backend_code,
10603
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
9503
10604
  )
10605
+ result.update({"app_key": app_key, "published": None, "verified": False})
10606
+ return result
9504
10607
  tag_ids_after = _coerce_int_list(base.get("tagIds"))
9505
10608
  app_name_after = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_name_before or "").strip() or None
9506
10609
  package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
@@ -9508,13 +10611,18 @@ class AiBuilderFacade:
9508
10611
  views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
9509
10612
  except (QingflowApiError, RuntimeError) as error:
9510
10613
  api_error = _coerce_api_error(error)
9511
- return _failed_from_api_error(
9512
- "VIEWS_READ_FAILED",
9513
- api_error,
10614
+ result = _post_write_readback_pending_result(
10615
+ error_code="VIEWS_READBACK_PENDING",
10616
+ message="published app; views readback is unavailable",
9514
10617
  normalized_args=normalized_args,
9515
- details={"app_key": app_key},
10618
+ details={"app_key": app_key, "edit_version_no": edit_version_no, "readback_error": _transport_error_payload(api_error)},
9516
10619
  suggested_next_call={"tool_name": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
10620
+ request_id=api_error.request_id,
10621
+ backend_code=api_error.backend_code,
10622
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
9517
10623
  )
10624
+ result.update({"app_key": app_key, "app_name": app_name_after, "published": bool(base.get("appPublishStatus") in {1, 2}), "verified": False})
10625
+ return result
9518
10626
  views = views or []
9519
10627
  views_ok = isinstance(views, list) and not views_unavailable
9520
10628
  verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
@@ -9549,6 +10657,9 @@ class AiBuilderFacade:
9549
10657
  "tag_ids_after": tag_ids_after,
9550
10658
  "views_ok": views_ok,
9551
10659
  "verified": verified,
10660
+ "write_executed": True,
10661
+ "write_succeeded": True,
10662
+ "safe_to_retry": False,
9552
10663
  }
9553
10664
 
9554
10665
  def _expand_chart_partial_patches(
@@ -9671,6 +10782,167 @@ class AiBuilderFacade:
9671
10782
  )
9672
10783
  return expanded, issues, results
9673
10784
 
10785
+ def _verify_view_deleted_by_key(self, *, profile: str, view_key: str) -> JSONObject:
10786
+ try:
10787
+ self.views.view_get_config(profile=profile, viewgraph_key=view_key)
10788
+ except (QingflowApiError, RuntimeError) as error:
10789
+ api_error = _coerce_api_error(error)
10790
+ if _delete_readback_is_not_found(api_error):
10791
+ return {
10792
+ "view_key": view_key,
10793
+ "operation": "delete",
10794
+ "status": "removed",
10795
+ "delete_executed": True,
10796
+ "readback_status": "deleted",
10797
+ "safe_to_retry_delete": False,
10798
+ }
10799
+ return {
10800
+ "view_key": view_key,
10801
+ "operation": "delete",
10802
+ "status": "readback_pending",
10803
+ "delete_executed": True,
10804
+ "readback_status": "unavailable",
10805
+ "safe_to_retry_delete": False,
10806
+ "error_code": "VIEW_DELETE_READBACK_UNAVAILABLE",
10807
+ "message": "delete request completed, but view existence could not be verified by view_key readback",
10808
+ "request_id": api_error.request_id,
10809
+ "backend_code": api_error.backend_code,
10810
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
10811
+ "transport_error": _transport_error_payload(api_error),
10812
+ }
10813
+ return {
10814
+ "view_key": view_key,
10815
+ "operation": "delete",
10816
+ "status": "readback_pending",
10817
+ "delete_executed": True,
10818
+ "readback_status": "still_exists",
10819
+ "safe_to_retry_delete": False,
10820
+ "error_code": "VIEW_DELETE_READBACK_STILL_EXISTS",
10821
+ "message": "delete request completed, but the view still exists during view_key readback",
10822
+ }
10823
+
10824
+ def _verify_custom_button_deleted_by_id(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
10825
+ try:
10826
+ def runner(_: Any, context: BackendRequestContext) -> object:
10827
+ return self.buttons.backend.request(
10828
+ "GET",
10829
+ context,
10830
+ f"/app/{app_key}/customButton/{button_id}",
10831
+ params={"beingDraft": True},
10832
+ )
10833
+
10834
+ self.buttons._run(profile, runner)
10835
+ except (QingflowApiError, RuntimeError) as error:
10836
+ api_error = _coerce_api_error(error)
10837
+ if _delete_readback_is_not_found(api_error):
10838
+ return {
10839
+ "button_id": button_id,
10840
+ "operation": "delete",
10841
+ "status": "removed",
10842
+ "delete_executed": True,
10843
+ "readback_status": "deleted",
10844
+ "safe_to_retry_delete": False,
10845
+ }
10846
+ return {
10847
+ "button_id": button_id,
10848
+ "operation": "delete",
10849
+ "status": "readback_pending",
10850
+ "delete_executed": True,
10851
+ "readback_status": "unavailable",
10852
+ "safe_to_retry_delete": False,
10853
+ "error_code": "CUSTOM_BUTTON_DELETE_READBACK_UNAVAILABLE",
10854
+ "message": "delete request completed, but custom button existence could not be verified by button_id readback",
10855
+ "request_id": api_error.request_id,
10856
+ "backend_code": api_error.backend_code,
10857
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
10858
+ "transport_error": _transport_error_payload(api_error),
10859
+ }
10860
+ return {
10861
+ "button_id": button_id,
10862
+ "operation": "delete",
10863
+ "status": "readback_pending",
10864
+ "delete_executed": True,
10865
+ "readback_status": "still_exists",
10866
+ "safe_to_retry_delete": False,
10867
+ "error_code": "CUSTOM_BUTTON_DELETE_READBACK_STILL_EXISTS",
10868
+ "message": "delete request completed, but the custom button still exists during button_id readback",
10869
+ }
10870
+
10871
+ def _verify_associated_resources_deleted_by_pool(
10872
+ self,
10873
+ *,
10874
+ deleted_items: list[JSONObject],
10875
+ resources: list[dict[str, Any]],
10876
+ readback_failed: bool,
10877
+ ) -> list[JSONObject]:
10878
+ existing_by_id = _associated_resource_index(resources) if not readback_failed else {}
10879
+ verified_items: list[JSONObject] = []
10880
+ for item in deleted_items:
10881
+ associated_item_id = _coerce_positive_int(item.get("associated_item_id"))
10882
+ verified = deepcopy(item)
10883
+ verified["operation"] = "remove"
10884
+ verified["delete_executed"] = True
10885
+ verified["safe_to_retry_delete"] = False
10886
+ if associated_item_id is None or readback_failed:
10887
+ verified["status"] = "readback_pending"
10888
+ verified["readback_status"] = "unavailable"
10889
+ verified["error_code"] = "ASSOCIATED_RESOURCE_DELETE_READBACK_UNAVAILABLE"
10890
+ verified["message"] = "delete request completed, but associated resource pool readback is unavailable"
10891
+ elif associated_item_id in existing_by_id:
10892
+ verified["status"] = "readback_pending"
10893
+ verified["readback_status"] = "still_exists"
10894
+ verified["error_code"] = "ASSOCIATED_RESOURCE_DELETE_READBACK_STILL_EXISTS"
10895
+ verified["message"] = "delete request completed, but the associated resource still exists in pool readback"
10896
+ else:
10897
+ verified["status"] = "removed"
10898
+ verified["readback_status"] = "deleted"
10899
+ verified.pop("error_code", None)
10900
+ verified.pop("message", None)
10901
+ verified_items.append(verified)
10902
+ return verified_items
10903
+
10904
+ def _verify_chart_deleted_by_id(self, *, profile: str, chart_id: str) -> JSONObject:
10905
+ base_result: dict[str, Any] | None = None
10906
+ try:
10907
+ raw = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
10908
+ base_result = raw if isinstance(raw, dict) else {}
10909
+ except (QingflowApiError, RuntimeError) as error:
10910
+ api_error = _coerce_api_error(error)
10911
+ if _chart_delete_readback_is_not_found(api_error):
10912
+ return {
10913
+ "chart_id": chart_id,
10914
+ "operation": "delete",
10915
+ "status": "removed",
10916
+ "delete_executed": True,
10917
+ "readback_status": "deleted",
10918
+ "safe_to_retry_delete": False,
10919
+ }
10920
+ return {
10921
+ "chart_id": chart_id,
10922
+ "operation": "delete",
10923
+ "status": "readback_pending",
10924
+ "delete_executed": True,
10925
+ "readback_status": "unavailable",
10926
+ "safe_to_retry_delete": False,
10927
+ "error_code": "CHART_DELETE_READBACK_UNAVAILABLE",
10928
+ "message": "delete request completed, but chart existence could not be verified by chart_id readback",
10929
+ "request_id": api_error.request_id,
10930
+ "backend_code": api_error.backend_code,
10931
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
10932
+ "transport_error": _transport_error_payload(api_error),
10933
+ }
10934
+ return {
10935
+ "chart_id": chart_id,
10936
+ "operation": "delete",
10937
+ "status": "readback_pending",
10938
+ "delete_executed": True,
10939
+ "readback_status": "still_exists",
10940
+ "safe_to_retry_delete": False,
10941
+ "error_code": "CHART_DELETE_READBACK_STILL_EXISTS",
10942
+ "message": "delete request completed, but the chart still exists during chart_id readback",
10943
+ "readback_name": base_result.get("chartName") or base_result.get("name") if isinstance(base_result, dict) else None,
10944
+ }
10945
+
9674
10946
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
9675
10947
  normalized_args = request.model_dump(mode="json")
9676
10948
  permission_outcomes: list[PermissionCheckOutcome] = []
@@ -9695,21 +10967,27 @@ class AiBuilderFacade:
9695
10967
  def finalize(response: JSONObject) -> JSONObject:
9696
10968
  return _apply_permission_outcomes(response, *permission_outcomes)
9697
10969
 
9698
- try:
9699
- schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
9700
- parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
9701
- fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
9702
- qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
9703
- existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
9704
- except (QingflowApiError, RuntimeError) as error:
9705
- api_error = _coerce_api_error(error)
9706
- return finalize(_failed_from_api_error(
9707
- "CHART_APPLY_FAILED",
9708
- api_error,
9709
- normalized_args=normalized_args,
9710
- details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
9711
- suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
9712
- ))
10970
+ fields: list[dict[str, Any]] = []
10971
+ qingbi_fields: list[Any] = []
10972
+ existing_chart_items: list[Any] = []
10973
+ existing_chart_list_source: str | None = None
10974
+ needs_chart_inventory = bool(request.upsert_charts or request.patch_charts or request.reorder_chart_ids)
10975
+ if needs_chart_inventory:
10976
+ try:
10977
+ schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
10978
+ parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
10979
+ fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
10980
+ qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
10981
+ existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
10982
+ except (QingflowApiError, RuntimeError) as error:
10983
+ api_error = _coerce_api_error(error)
10984
+ return finalize(_failed_from_api_error(
10985
+ "CHART_APPLY_FAILED",
10986
+ api_error,
10987
+ normalized_args=normalized_args,
10988
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
10989
+ suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
10990
+ ))
9713
10991
 
9714
10992
  field_lookup = _build_public_field_lookup(fields)
9715
10993
  qingbi_fields_by_id = {
@@ -9717,6 +10995,11 @@ class AiBuilderFacade:
9717
10995
  for item in qingbi_fields
9718
10996
  if isinstance(item, dict) and item.get("fieldId")
9719
10997
  }
10998
+ chart_field_lookup = _build_qingbi_chart_field_lookup(
10999
+ app_key=app_key,
11000
+ qingbi_fields=[item for item in qingbi_fields if isinstance(item, dict)],
11001
+ field_lookup=field_lookup,
11002
+ )
9720
11003
  existing_by_id = {
9721
11004
  _extract_chart_identifier(item): deepcopy(item)
9722
11005
  for item in existing_chart_items
@@ -9759,8 +11042,12 @@ class AiBuilderFacade:
9759
11042
  updated_ids: list[str] = []
9760
11043
  removed_ids: list[str] = []
9761
11044
  failed_items: list[dict[str, Any]] = []
11045
+ delete_readback_issues: list[dict[str, Any]] = []
9762
11046
 
9763
11047
  for patch in upsert_charts:
11048
+ chart_id = ""
11049
+ target_type = ""
11050
+ config_payload: dict[str, Any] | None = None
9764
11051
  try:
9765
11052
  dataset_source = _chart_patch_dataset_source_type(patch)
9766
11053
  if dataset_source:
@@ -9799,6 +11086,14 @@ class AiBuilderFacade:
9799
11086
  f"existing chart '{chart_id or patch.name}' uses dataset report source '{existing_source_type}' and is not supported for update yet. "
9800
11087
  "Update it in QingBI directly, then attach the existing report with app_associated_resources_apply using report_source='dataset'."
9801
11088
  )
11089
+ if existing is None or config_update_requested:
11090
+ config_payload = _build_public_chart_config_payload(
11091
+ patch=patch,
11092
+ app_key=app_key,
11093
+ field_lookup=field_lookup,
11094
+ chart_field_lookup=chart_field_lookup,
11095
+ qingbi_fields_by_id=qingbi_fields_by_id,
11096
+ )
9802
11097
  if existing is None:
9803
11098
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
9804
11099
  create_payload = {
@@ -9817,18 +11112,21 @@ class AiBuilderFacade:
9817
11112
  create_result = self.charts.qingbi_report_create(profile=profile, payload=create_payload).get("result") or {}
9818
11113
  created_chart_id = _extract_chart_identifier(create_result or {})
9819
11114
  if not created_chart_id:
9820
- refreshed_items, _ = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
9821
- refreshed_matches = _find_charts_by_name(
9822
- refreshed_items,
9823
- chart_name=patch.name,
9824
- chart_type=target_type,
9825
- )
9826
- if len(refreshed_matches) == 1:
9827
- created_chart_id = _extract_chart_identifier(refreshed_matches[0])
9828
- elif len(refreshed_matches) > 1:
9829
- raise ValueError(
9830
- f"created chart '{patch.name}' could not be uniquely resolved from readback; supply chart_id on the next update"
11115
+ try:
11116
+ refreshed_items, _ = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
11117
+ refreshed_matches = _find_charts_by_name(
11118
+ refreshed_items,
11119
+ chart_name=patch.name,
11120
+ chart_type=target_type,
9831
11121
  )
11122
+ if len(refreshed_matches) == 1:
11123
+ created_chart_id = _extract_chart_identifier(refreshed_matches[0])
11124
+ elif len(refreshed_matches) > 1:
11125
+ raise ValueError(
11126
+ f"created chart '{patch.name}' could not be uniquely resolved from readback; supply chart_id on the next update"
11127
+ )
11128
+ except (QingflowApiError, RuntimeError):
11129
+ created_chart_id = temp_chart_id
9832
11130
  if not created_chart_id:
9833
11131
  raise ValueError(
9834
11132
  f"created chart '{patch.name}' did not return a real chart_id and could not be confirmed from readback"
@@ -9882,13 +11180,7 @@ class AiBuilderFacade:
9882
11180
 
9883
11181
  config_updated = False
9884
11182
  if existing is None or config_update_requested:
9885
- config_payload = _build_public_chart_config_payload(
9886
- patch=patch,
9887
- app_key=app_key,
9888
- field_lookup=field_lookup,
9889
- qingbi_fields_by_id=qingbi_fields_by_id,
9890
- )
9891
- self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
11183
+ self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload or {})
9892
11184
  config_updated = True
9893
11185
  if existing is not None and chart_id not in updated_ids and config_updated:
9894
11186
  updated_ids.append(chart_id)
@@ -9918,11 +11210,21 @@ class AiBuilderFacade:
9918
11210
  )
9919
11211
  except (QingflowApiError, RuntimeError, ValueError, VisibilityResolutionError) as error:
9920
11212
  api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
11213
+ diagnostics = (
11214
+ error.diagnostics
11215
+ if isinstance(error, ChartRuleViolation)
11216
+ else _explain_chart_backend_validation_error(api_error=api_error, chart_type=target_type or patch.chart_type.value, payload=config_payload)
11217
+ if api_error is not None
11218
+ else None
11219
+ )
9921
11220
  failure = {
9922
- "chart_id": str(locals().get("chart_id") or patch.chart_id or ""),
11221
+ "chart_id": str(chart_id or patch.chart_id or ""),
9923
11222
  "name": patch.name,
11223
+ "chart_type": patch.chart_type.value,
9924
11224
  "status": "failed",
9925
- "message": str(error),
11225
+ "error_code": diagnostics.get("rule_code") if isinstance(diagnostics, dict) else "CHART_APPLY_FAILED",
11226
+ "message": str(diagnostics.get("message") if isinstance(diagnostics, dict) and diagnostics.get("message") else error),
11227
+ "diagnostics": diagnostics,
9926
11228
  "request_id": api_error.request_id if api_error else None,
9927
11229
  "backend_code": api_error.backend_code if api_error else None,
9928
11230
  "http_status": None if api_error is None or api_error.http_status == 404 else api_error.http_status,
@@ -9934,12 +11236,18 @@ class AiBuilderFacade:
9934
11236
  try:
9935
11237
  self.charts.qingbi_report_delete(profile=profile, chart_id=chart_id)
9936
11238
  removed_ids.append(chart_id)
9937
- chart_results.append({"chart_id": chart_id, "status": "removed"})
11239
+ delete_result = self._verify_chart_deleted_by_id(profile=profile, chart_id=chart_id)
11240
+ if delete_result.get("readback_status") != "deleted":
11241
+ delete_readback_issues.append(delete_result)
11242
+ chart_results.append(delete_result)
9938
11243
  except (QingflowApiError, RuntimeError) as error:
9939
11244
  api_error = _coerce_api_error(error)
9940
11245
  failure = {
9941
11246
  "chart_id": chart_id,
11247
+ "operation": "delete",
9942
11248
  "status": "failed",
11249
+ "delete_executed": False,
11250
+ "safe_to_retry_delete": True,
9943
11251
  "message": _public_error_message("CHART_APPLY_FAILED", api_error),
9944
11252
  "request_id": api_error.request_id,
9945
11253
  "backend_code": api_error.backend_code,
@@ -9974,30 +11282,41 @@ class AiBuilderFacade:
9974
11282
  chart_results.append(failure)
9975
11283
 
9976
11284
  noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
9977
- try:
9978
- readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
9979
- readback_ids = {
9980
- _extract_chart_identifier(item)
9981
- for item in readback_items
9982
- if isinstance(item, dict) and _extract_chart_identifier(item)
9983
- }
9984
- verified = (
9985
- all(chart_id in readback_ids for chart_id in created_ids + updated_ids)
9986
- and all(chart_id not in readback_ids for chart_id in removed_ids)
9987
- )
9988
- if request.reorder_chart_ids:
9989
- ordered_readback = [
11285
+ write_executed = bool(created_ids or updated_ids or removed_ids or reordered)
11286
+ write_succeeded = write_executed
11287
+ needs_list_readback = bool(created_ids or updated_ids or reordered)
11288
+ delete_readback_unavailable = any(item.get("readback_status") == "unavailable" for item in delete_readback_issues)
11289
+ deletes_verified = not delete_readback_issues
11290
+ readback_unavailable = False
11291
+ readback_error: QingflowApiError | None = None
11292
+ readback_list_source: str | None = existing_chart_list_source
11293
+ if needs_list_readback:
11294
+ try:
11295
+ readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
11296
+ readback_ids = {
9990
11297
  _extract_chart_identifier(item)
9991
11298
  for item in readback_items
9992
11299
  if isinstance(item, dict) and _extract_chart_identifier(item)
9993
- ]
9994
- requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
9995
- verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
9996
- readback_unavailable = False
9997
- except (QingflowApiError, RuntimeError):
9998
- verified = False
9999
- readback_unavailable = True
10000
- readback_list_source = None
11300
+ }
11301
+ verified = all(chart_id in readback_ids for chart_id in created_ids + updated_ids)
11302
+ if request.reorder_chart_ids:
11303
+ ordered_readback = [
11304
+ _extract_chart_identifier(item)
11305
+ for item in readback_items
11306
+ if isinstance(item, dict) and _extract_chart_identifier(item)
11307
+ ]
11308
+ requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
11309
+ verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
11310
+ readback_unavailable = False
11311
+ except (QingflowApiError, RuntimeError) as error:
11312
+ readback_error = _coerce_api_error(error)
11313
+ verified = False
11314
+ readback_unavailable = True
11315
+ readback_list_source = None
11316
+ else:
11317
+ verified = True
11318
+ verified = verified and deletes_verified
11319
+ any_readback_unavailable = readback_unavailable or delete_readback_unavailable
10001
11320
 
10002
11321
  if failed_items:
10003
11322
  successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
@@ -10009,56 +11328,79 @@ class AiBuilderFacade:
10009
11328
  "normalized_args": normalized_args,
10010
11329
  "missing_fields": [],
10011
11330
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
10012
- "details": {"per_chart_results": chart_results},
10013
- "request_id": failed_items[0].get("request_id"),
11331
+ "details": {
11332
+ "per_chart_results": chart_results,
11333
+ **({"readback_error": _transport_error_payload(readback_error)} if readback_error is not None else {}),
11334
+ },
11335
+ "request_id": failed_items[0].get("request_id") or (readback_error.request_id if readback_error is not None else None),
10014
11336
  "suggested_next_call": {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
10015
- "backend_code": failed_items[0].get("backend_code"),
10016
- "http_status": failed_items[0].get("http_status"),
11337
+ "backend_code": failed_items[0].get("backend_code") or (readback_error.backend_code if readback_error is not None else None),
11338
+ "http_status": failed_items[0].get("http_status") or (None if readback_error is None or readback_error.http_status == 404 else readback_error.http_status),
10017
11339
  "noop": noop,
10018
11340
  "warnings": _chart_apply_warnings(
10019
11341
  failed_items=failed_items,
10020
11342
  readback_unavailable=readback_unavailable,
10021
11343
  verified=False if failed_items else verified,
11344
+ delete_readback_issues=delete_readback_issues,
10022
11345
  ),
10023
11346
  "verification": {
10024
11347
  "charts_verified": False if failed_items else verified,
10025
- "readback_unavailable": readback_unavailable,
10026
- "chart_order_verified": False if request.reorder_chart_ids else (readback_list_source == "sorted"),
11348
+ "readback_unavailable": any_readback_unavailable,
11349
+ "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
11350
+ "chart_order_verified": False if request.reorder_chart_ids else True,
10027
11351
  "chart_list_source": readback_list_source or existing_chart_list_source,
10028
11352
  },
10029
11353
  "app_key": app_key,
10030
11354
  "app_name": app_name,
10031
11355
  "chart_results": chart_results,
10032
11356
  "verified": False if failed_items else verified,
11357
+ "write_executed": write_executed,
11358
+ "write_succeeded": write_succeeded,
11359
+ "safe_to_retry": not write_executed,
10033
11360
  })
10034
11361
  result_verified = verified or noop
11362
+ pending_delete = bool(delete_readback_issues)
11363
+ pending_error_code = "CHART_DELETE_READBACK_PENDING" if pending_delete and not readback_unavailable else "CHART_READBACK_PENDING"
11364
+ pending_message = "applied chart operations; delete readback pending" if pending_delete else "applied chart operations; readback pending"
11365
+ pending_suggestion = (
11366
+ {"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": str(delete_readback_issues[0].get("chart_id") or "CHART_ID")}}
11367
+ if pending_delete
11368
+ else {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}}
11369
+ )
10035
11370
  return finalize({
10036
11371
  "status": "success" if result_verified else "partial_success",
10037
- "error_code": None if result_verified else "CHART_READBACK_PENDING",
11372
+ "error_code": None if result_verified else pending_error_code,
10038
11373
  "recoverable": not result_verified,
10039
- "message": "no chart changes requested" if noop else ("applied chart operations" if verified else "applied chart operations; readback pending"),
11374
+ "message": "no chart changes requested" if noop else ("applied chart operations" if verified else pending_message),
10040
11375
  "normalized_args": normalized_args,
10041
11376
  "missing_fields": [],
10042
11377
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
10043
- "details": {},
10044
- "request_id": None,
10045
- "suggested_next_call": None if result_verified else {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
11378
+ "details": {"readback_error": _transport_error_payload(readback_error)} if readback_error is not None else {},
11379
+ "request_id": readback_error.request_id if readback_error is not None else None,
11380
+ "suggested_next_call": None if result_verified else pending_suggestion,
11381
+ "backend_code": readback_error.backend_code if readback_error is not None else None,
11382
+ "http_status": None if readback_error is None or readback_error.http_status == 404 else readback_error.http_status,
10046
11383
  "noop": noop,
10047
11384
  "warnings": _chart_apply_warnings(
10048
11385
  failed_items=[],
10049
11386
  readback_unavailable=False if noop else readback_unavailable,
10050
11387
  verified=result_verified,
11388
+ delete_readback_issues=delete_readback_issues,
10051
11389
  ),
10052
11390
  "verification": {
10053
11391
  "charts_verified": result_verified,
10054
- "readback_unavailable": False if noop else readback_unavailable,
10055
- "chart_order_verified": True if noop and not request.reorder_chart_ids else (readback_list_source == "sorted" and result_verified if request.reorder_chart_ids else readback_list_source == "sorted"),
11392
+ "readback_unavailable": False if noop else any_readback_unavailable,
11393
+ "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
11394
+ "chart_order_verified": (readback_list_source == "sorted" and result_verified) if request.reorder_chart_ids else True,
10056
11395
  "chart_list_source": existing_chart_list_source if noop else readback_list_source,
10057
11396
  },
10058
11397
  "app_key": app_key,
10059
11398
  "app_name": app_name,
10060
11399
  "chart_results": chart_results,
10061
11400
  "verified": result_verified,
11401
+ "write_executed": write_executed,
11402
+ "write_succeeded": write_succeeded,
11403
+ "safe_to_retry": not write_executed,
10062
11404
  })
10063
11405
 
10064
11406
  def portal_apply(self, *, profile: str, request: PortalApplyRequest) -> JSONObject:
@@ -10139,15 +11481,6 @@ class AiBuilderFacade:
10139
11481
  if package_add_outcome.block is not None:
10140
11482
  return package_add_outcome.block
10141
11483
  permission_outcomes.append(package_add_outcome)
10142
- package_edit_outcome = self._guard_package_permission(
10143
- profile=profile,
10144
- tag_id=target_package_tag_id,
10145
- required_permission="edit_app",
10146
- normalized_args=normalized_args,
10147
- )
10148
- if package_edit_outcome.block is not None:
10149
- return package_edit_outcome.block
10150
- permission_outcomes.append(package_edit_outcome)
10151
11484
  if not sections_requested:
10152
11485
  unsupported_base_only_keys: list[str] = []
10153
11486
  if request.hide_copyright is not None:
@@ -10167,7 +11500,12 @@ class AiBuilderFacade:
10167
11500
  },
10168
11501
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10169
11502
  )
11503
+ write_executed = False
11504
+ draft_readback_error: JSONObject | None = None
11505
+ live_readback_error: JSONObject | None = None
11506
+ update_payload: dict[str, Any] = {}
10170
11507
  try:
11508
+ layout_diagnostics: dict[str, Any] = _empty_portal_layout_diagnostics()
10171
11509
  if creating:
10172
11510
  create_payload = _build_public_portal_base_payload(
10173
11511
  dash_name=request.dash_name or "未命名门户",
@@ -10181,6 +11519,7 @@ class AiBuilderFacade:
10181
11519
  base_payload=None,
10182
11520
  )
10183
11521
  create_result = self.portals.portal_create(profile=profile, payload=create_payload)
11522
+ write_executed = True
10184
11523
  created = create_result.get("result") if isinstance(create_result.get("result"), dict) else {}
10185
11524
  dash_key = str(created.get("dashKey") or "")
10186
11525
  if not dash_key:
@@ -10191,7 +11530,15 @@ class AiBuilderFacade:
10191
11530
  details={"create_result": created},
10192
11531
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10193
11532
  )
10194
- base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
11533
+ try:
11534
+ base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
11535
+ except (QingflowApiError, RuntimeError) as read_error:
11536
+ api_read_error = _coerce_api_error(read_error)
11537
+ draft_readback_error = {
11538
+ "phase": "created_portal_draft_readback",
11539
+ "transport_error": _transport_error_payload(api_read_error),
11540
+ }
11541
+ base_payload = {}
10195
11542
  update_payload = _build_public_portal_base_payload(
10196
11543
  dash_name=request.dash_name or str(base_payload.get("dashName") or "").strip() or "未命名门户",
10197
11544
  package_tag_id=target_package_tag_id,
@@ -10204,9 +11551,15 @@ class AiBuilderFacade:
10204
11551
  base_payload=base_payload,
10205
11552
  )
10206
11553
  if sections_requested:
10207
- component_payload = self._build_portal_components_from_sections(profile=profile, sections=request.sections)
11554
+ component_payload = self._build_portal_components_from_sections(
11555
+ profile=profile,
11556
+ sections=request.sections,
11557
+ layout_preset=request.layout_preset,
11558
+ )
11559
+ layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload)
10208
11560
  update_payload["components"] = component_payload
10209
11561
  self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
11562
+ write_executed = True
10210
11563
  self.portals.portal_update_base_info(
10211
11564
  profile=profile,
10212
11565
  dash_key=dash_key,
@@ -10217,9 +11570,70 @@ class AiBuilderFacade:
10217
11570
  "tags": deepcopy(update_payload.get("tags") or []),
10218
11571
  },
10219
11572
  )
10220
- draft_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
11573
+ write_executed = True
11574
+ try:
11575
+ draft_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
11576
+ except (QingflowApiError, RuntimeError) as read_error:
11577
+ api_read_error = _coerce_api_error(read_error)
11578
+ draft_readback_error = {
11579
+ "phase": "portal_draft_readback",
11580
+ "transport_error": _transport_error_payload(api_read_error),
11581
+ }
11582
+ draft_result = {}
10221
11583
  except (QingflowApiError, RuntimeError, ValueError) as error:
10222
11584
  api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
11585
+ if write_executed:
11586
+ transport_error = _transport_error_payload(api_error) if api_error is not None else None
11587
+ warning = _warning(
11588
+ "PORTAL_WRITE_INCOMPLETE_AFTER_PARTIAL_WRITE",
11589
+ "one or more portal write steps executed before a later write step failed",
11590
+ )
11591
+ if transport_error is not None:
11592
+ for key in ("backend_code", "http_status", "request_id"):
11593
+ if transport_error.get(key) is not None:
11594
+ warning[key] = transport_error.get(key)
11595
+ return finalize({
11596
+ "status": "partial_success",
11597
+ "error_code": "PORTAL_APPLY_PARTIAL",
11598
+ "recoverable": True,
11599
+ "message": "some portal write steps executed; a later portal write step failed",
11600
+ "normalized_args": normalized_args,
11601
+ "missing_fields": [],
11602
+ "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
11603
+ "details": {
11604
+ "dash_key": dash_key or None,
11605
+ "write_error": (
11606
+ {"message": api_error.message, "transport_error": transport_error}
11607
+ if api_error is not None
11608
+ else {"message": str(error)}
11609
+ ),
11610
+ },
11611
+ "request_id": api_error.request_id if api_error else None,
11612
+ "backend_code": api_error.backend_code if api_error else None,
11613
+ "http_status": None if api_error is None or api_error.http_status == 404 else api_error.http_status,
11614
+ "suggested_next_call": {"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key}},
11615
+ "noop": False,
11616
+ "warnings": [warning],
11617
+ "verification": {
11618
+ "draft_verified": False,
11619
+ "draft_metadata_verified": False,
11620
+ "live_verified": None,
11621
+ "live_metadata_verified": None,
11622
+ "published": False,
11623
+ "publish_failed": False,
11624
+ "write_incomplete": True,
11625
+ "readback_unavailable": False,
11626
+ "metadata_unverified": True,
11627
+ },
11628
+ "dash_key": dash_key,
11629
+ "dash_name": update_payload.get("dashName") if isinstance(update_payload, dict) else None,
11630
+ "package_id": target_package_tag_id,
11631
+ "created": creating,
11632
+ "published": False,
11633
+ "verified": False,
11634
+ "write_executed": True,
11635
+ "safe_to_retry": False,
11636
+ })
10223
11637
  return _failed(
10224
11638
  "PORTAL_APPLY_FAILED",
10225
11639
  _public_error_message("PORTAL_APPLY_FAILED", api_error) if api_error else str(error),
@@ -10234,19 +11648,35 @@ class AiBuilderFacade:
10234
11648
  live_result: dict[str, Any] | None = None
10235
11649
  published = False
10236
11650
  publish_failed = False
11651
+ publish_error: JSONObject | None = None
10237
11652
  if request.publish:
10238
11653
  try:
10239
11654
  self.portals.portal_publish(profile=profile, dash_key=dash_key)
10240
11655
  published = True
10241
- live_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=False).get("result") or {}
10242
- except (QingflowApiError, RuntimeError):
11656
+ except (QingflowApiError, RuntimeError) as error:
11657
+ api_error = _coerce_api_error(error)
10243
11658
  publish_failed = True
11659
+ publish_error = {
11660
+ "message": api_error.message,
11661
+ "transport_error": _transport_error_payload(api_error),
11662
+ }
11663
+ if published:
11664
+ try:
11665
+ live_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=False).get("result") or {}
11666
+ except (QingflowApiError, RuntimeError) as read_error:
11667
+ api_read_error = _coerce_api_error(read_error)
11668
+ live_readback_error = {
11669
+ "phase": "portal_live_readback",
11670
+ "transport_error": _transport_error_payload(api_read_error),
11671
+ }
10244
11672
 
10245
11673
  draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
10246
11674
  expected_count = len(request.sections) if sections_requested else None
10247
11675
  draft_verified = isinstance(draft_result, dict) and (
10248
11676
  expected_count is None or (isinstance(draft_components, list) and len(draft_components) == expected_count)
10249
11677
  )
11678
+ if draft_readback_error is not None:
11679
+ draft_verified = False
10250
11680
  draft_meta_verified, draft_meta_mismatches = _verify_portal_readback(
10251
11681
  actual=draft_result,
10252
11682
  expected_payload=update_payload,
@@ -10274,6 +11704,8 @@ class AiBuilderFacade:
10274
11704
  )
10275
11705
  )
10276
11706
  )
11707
+ if live_readback_error is not None:
11708
+ live_verified = False
10277
11709
  live_meta_verified, live_meta_mismatches = _verify_portal_readback(
10278
11710
  actual=live_result,
10279
11711
  expected_payload=update_payload,
@@ -10307,6 +11739,24 @@ class AiBuilderFacade:
10307
11739
  live_meta_verified=live_meta_verified,
10308
11740
  publish_requested=request.publish,
10309
11741
  )
11742
+ details_payload = {
11743
+ "verification_mismatches": {
11744
+ "draft": draft_meta_mismatches,
11745
+ "live": live_meta_mismatches,
11746
+ },
11747
+ **({"publish_error": publish_error} if publish_error is not None else {}),
11748
+ **({"draft_readback_error": draft_readback_error} if draft_readback_error is not None else {}),
11749
+ **({"live_readback_error": live_readback_error} if live_readback_error is not None else {}),
11750
+ }
11751
+ readback_transport_error = _readback_transport_error_from_details(details_payload)
11752
+ if draft_readback_error is not None or live_readback_error is not None:
11753
+ warning = _warning("READBACK_UNAVAILABLE_AFTER_WRITE", "write was executed but portal readback is unavailable")
11754
+ if readback_transport_error is not None:
11755
+ for key in ("backend_code", "http_status", "request_id"):
11756
+ if readback_transport_error.get(key) is not None:
11757
+ warning[key] = readback_transport_error.get(key)
11758
+ warnings.append(warning)
11759
+ warnings.extend(_portal_layout_warning_items(layout_diagnostics))
10310
11760
  return finalize({
10311
11761
  "status": status,
10312
11762
  "error_code": error_code,
@@ -10323,16 +11773,14 @@ class AiBuilderFacade:
10323
11773
  "normalized_args": normalized_args,
10324
11774
  "missing_fields": [],
10325
11775
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
10326
- "details": {
10327
- "verification_mismatches": {
10328
- "draft": draft_meta_mismatches,
10329
- "live": live_meta_mismatches,
10330
- }
10331
- },
10332
- "request_id": None,
11776
+ "details": details_payload,
11777
+ "request_id": (readback_transport_error or {}).get("request_id"),
11778
+ "backend_code": (readback_transport_error or {}).get("backend_code"),
11779
+ "http_status": (readback_transport_error or {}).get("http_status"),
10333
11780
  "suggested_next_call": None if verified else {"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10334
11781
  "noop": False,
10335
11782
  "warnings": warnings,
11783
+ "layout_diagnostics": layout_diagnostics,
10336
11784
  "verification": {
10337
11785
  "draft_verified": draft_verified,
10338
11786
  "draft_metadata_verified": draft_meta_verified,
@@ -10340,6 +11788,8 @@ class AiBuilderFacade:
10340
11788
  "live_metadata_verified": live_meta_verified,
10341
11789
  "published": published,
10342
11790
  "publish_failed": publish_failed,
11791
+ "readback_unavailable": draft_readback_error is not None or live_readback_error is not None,
11792
+ "metadata_unverified": draft_readback_error is not None or live_readback_error is not None,
10343
11793
  },
10344
11794
  "dash_key": dash_key,
10345
11795
  "dash_name": update_payload.get("dashName"),
@@ -10347,6 +11797,8 @@ class AiBuilderFacade:
10347
11797
  "created": creating,
10348
11798
  "published": published,
10349
11799
  "verified": verified,
11800
+ "write_executed": write_executed or published,
11801
+ "safe_to_retry": not (write_executed or published),
10350
11802
  "draft_result": draft_result,
10351
11803
  "live_result": live_result,
10352
11804
  })
@@ -10354,7 +11806,17 @@ class AiBuilderFacade:
10354
11806
  def _publish_current_edit_version(self, *, profile: str, app_key: str, edit_version_no: int | None = None) -> JSONObject:
10355
11807
  normalized_args = {"app_key": app_key}
10356
11808
  if edit_version_no is None:
10357
- version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
11809
+ try:
11810
+ version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
11811
+ except (QingflowApiError, RuntimeError) as error:
11812
+ api_error = _coerce_api_error(error)
11813
+ return _failed_from_api_error(
11814
+ "PUBLISH_PRECHECK_FAILED",
11815
+ api_error,
11816
+ normalized_args=normalized_args,
11817
+ details={"app_key": app_key, "phase": "prepare_publish_edit_version"},
11818
+ suggested_next_call={"tool_name": "app_publish_verify", "arguments": {"profile": profile, "app_key": app_key}},
11819
+ )
10358
11820
  edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
10359
11821
  try:
10360
11822
  self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
@@ -10488,6 +11950,7 @@ class AiBuilderFacade:
10488
11950
  response["status"] = "partial_success"
10489
11951
  response["error_code"] = response.get("error_code") or publish_result.get("error_code")
10490
11952
  response["recoverable"] = True
11953
+ response["verified"] = False
10491
11954
  response["message"] = f"{response.get('message') or 'apply succeeded'}; publish failed"
10492
11955
  if not response.get("suggested_next_call"):
10493
11956
  response["suggested_next_call"] = publish_result.get("suggested_next_call")
@@ -10576,7 +12039,8 @@ class AiBuilderFacade:
10576
12039
  app_key: str,
10577
12040
  tolerate_404: bool,
10578
12041
  tolerate_permission_restricted: bool = False,
10579
- ) -> tuple[Any, bool]:
12042
+ include_error: bool = False,
12043
+ ) -> tuple[Any, bool] | tuple[Any, bool, QingflowApiError | None]:
10580
12044
  try:
10581
12045
  views = self.views.view_list_flat(profile=profile, app_key=app_key)
10582
12046
  except (QingflowApiError, RuntimeError) as error:
@@ -10598,18 +12062,19 @@ class AiBuilderFacade:
10598
12062
  )
10599
12063
  )
10600
12064
  ):
10601
- return [], True
12065
+ return ([], True, legacy_api_error) if include_error else ([], True)
10602
12066
  raise
10603
12067
  legacy_result = legacy_views.get("result")
10604
12068
  if _is_view_collection_shape(legacy_result):
10605
- return _normalize_view_collection(legacy_result), False
12069
+ result = _normalize_view_collection(legacy_result)
12070
+ return (result, False, api_error) if include_error else (result, False)
10606
12071
  if tolerate_404:
10607
- return [], True
12072
+ return ([], True, api_error) if include_error else ([], True)
10608
12073
  raise error
10609
12074
  raise
10610
12075
  normalized_views = _normalize_view_collection(views.get("result"))
10611
12076
  if normalized_views:
10612
- return normalized_views, False
12077
+ return (normalized_views, False, None) if include_error else (normalized_views, False)
10613
12078
  try:
10614
12079
  legacy_views = self.views.view_list(profile=profile, app_key=app_key)
10615
12080
  except (QingflowApiError, RuntimeError) as legacy_error:
@@ -10624,11 +12089,12 @@ class AiBuilderFacade:
10624
12089
  )
10625
12090
  )
10626
12091
  ):
10627
- return normalized_views, False
12092
+ return (normalized_views, False, legacy_api_error) if include_error else (normalized_views, False)
10628
12093
  raise
10629
12094
  legacy_result = legacy_views.get("result")
10630
12095
  legacy_normalized = _normalize_view_collection(legacy_result)
10631
- return legacy_normalized or normalized_views, False
12096
+ result = legacy_normalized or normalized_views
12097
+ return (result, False, None) if include_error else (result, False)
10632
12098
 
10633
12099
  def _load_workflow_result(
10634
12100
  self,
@@ -10637,7 +12103,8 @@ class AiBuilderFacade:
10637
12103
  app_key: str,
10638
12104
  tolerate_404: bool,
10639
12105
  tolerate_permission_restricted: bool = False,
10640
- ) -> tuple[Any, bool]:
12106
+ include_error: bool = False,
12107
+ ) -> tuple[Any, bool] | tuple[Any, bool, QingflowApiError | None]:
10641
12108
  try:
10642
12109
  workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
10643
12110
  except (QingflowApiError, RuntimeError) as error:
@@ -10646,9 +12113,10 @@ class AiBuilderFacade:
10646
12113
  api_error.http_status == 404
10647
12114
  or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
10648
12115
  ):
10649
- return [], True
12116
+ return ([], True, api_error) if include_error else ([], True)
10650
12117
  raise
10651
- return workflow.get("result"), False
12118
+ result = workflow.get("result")
12119
+ return (result, False, None) if include_error else (result, False)
10652
12120
 
10653
12121
  def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
10654
12122
  state = self._load_base_schema_state(profile=profile, app_key=app_key)
@@ -10943,6 +12411,7 @@ class AiBuilderFacade:
10943
12411
  *,
10944
12412
  profile: str,
10945
12413
  sections: list[PortalSectionPatch],
12414
+ layout_preset: str | None = None,
10946
12415
  ) -> list[dict[str, Any]]:
10947
12416
  resolved_components: list[dict[str, Any]] = []
10948
12417
  pc_x = 0
@@ -10959,9 +12428,15 @@ class AiBuilderFacade:
10959
12428
  pc_y=pc_y,
10960
12429
  pc_row_height=pc_row_height,
10961
12430
  mobile_y=mobile_y,
12431
+ layout_preset=layout_preset,
10962
12432
  )
10963
12433
  else:
10964
- position_payload = _portal_position_payload(section.position)
12434
+ position_payload = _portal_position_payload(section.position, inferred_mobile_y=mobile_y)
12435
+ mobile = position_payload.get("mobile") if isinstance(position_payload.get("mobile"), dict) else {}
12436
+ mobile_y = max(
12437
+ mobile_y,
12438
+ int(mobile.get("y") or 0) + int(mobile.get("rows") or 0),
12439
+ )
10965
12440
  dash_style = deepcopy(section.dash_style_config) if isinstance(section.dash_style_config, dict) else None
10966
12441
  component: dict[str, Any]
10967
12442
  if section.source_type == "chart":
@@ -11794,10 +13269,11 @@ def _resolve_custom_button_view_button_ref(
11794
13269
  button_inventory: dict[int, dict[str, Any]],
11795
13270
  valid_custom_button_ids: set[int],
11796
13271
  reason_path: str,
13272
+ allow_unverified_numeric_id: bool = False,
11797
13273
  ) -> tuple[int | None, dict[str, Any] | None]:
11798
13274
  explicit_id = _coerce_positive_int(button_ref)
11799
13275
  if explicit_id is not None:
11800
- if explicit_id in valid_custom_button_ids:
13276
+ if explicit_id in valid_custom_button_ids or allow_unverified_numeric_id:
11801
13277
  return explicit_id, None
11802
13278
  return None, {
11803
13279
  "error_code": "UNKNOWN_CUSTOM_BUTTON",
@@ -12074,11 +13550,16 @@ def _failed_from_api_error(
12074
13550
  suggested_next_call: JSONObject | None = None,
12075
13551
  recoverable: bool = True,
12076
13552
  ) -> JSONObject:
12077
- effective_error_code = "APP_EDIT_LOCKED" if error.backend_code == 40074 else error_code
13553
+ if is_auth_like_error(error):
13554
+ effective_error_code = "AUTH_REQUIRED"
13555
+ elif backend_code_int(error) == 40074:
13556
+ effective_error_code = "APP_EDIT_LOCKED"
13557
+ else:
13558
+ effective_error_code = error_code
12078
13559
  public_message = _public_error_message(effective_error_code, error)
12079
13560
  public_http_status = None if error.http_status == 404 else error.http_status
12080
13561
  merged_details = dict(details or {})
12081
- if error.backend_code == 40074:
13562
+ if backend_code_int(error) == 40074:
12082
13563
  owner = _extract_edit_lock_owner(error.message)
12083
13564
  merged_details.setdefault("lock_owner_name", owner.get("lock_owner_name"))
12084
13565
  merged_details.setdefault("lock_owner_email", owner.get("lock_owner_email"))
@@ -12120,6 +13601,91 @@ def _failed_from_api_error(
12120
13601
  )
12121
13602
 
12122
13603
 
13604
+ def _post_write_readback_pending_result(
13605
+ *,
13606
+ error_code: str,
13607
+ message: str,
13608
+ normalized_args: JSONObject | None = None,
13609
+ details: JSONObject | None = None,
13610
+ suggested_next_call: JSONObject | None = None,
13611
+ request_id: str | None = None,
13612
+ backend_code: Any = None,
13613
+ http_status: int | None = None,
13614
+ ) -> JSONObject:
13615
+ effective_details = details or {}
13616
+ transport_error = _readback_transport_error_from_details(effective_details)
13617
+ effective_backend_code = backend_code if backend_code is not None else (transport_error or {}).get("backend_code")
13618
+ effective_http_status = http_status if http_status is not None else (transport_error or {}).get("http_status")
13619
+ effective_request_id = request_id if request_id is not None else (transport_error or {}).get("request_id")
13620
+ warning = _warning("READBACK_UNAVAILABLE_AFTER_WRITE", "write was executed but post-write readback is unavailable")
13621
+ for key, value in (
13622
+ ("backend_code", effective_backend_code),
13623
+ ("http_status", effective_http_status),
13624
+ ("request_id", effective_request_id),
13625
+ ):
13626
+ if value is not None:
13627
+ warning[key] = value
13628
+ return {
13629
+ "status": "partial_success",
13630
+ "error_code": error_code,
13631
+ "recoverable": True,
13632
+ "message": message,
13633
+ "normalized_args": normalized_args or {},
13634
+ "missing_fields": [],
13635
+ "allowed_values": {},
13636
+ "details": effective_details,
13637
+ "suggested_next_call": suggested_next_call,
13638
+ "request_id": effective_request_id,
13639
+ "backend_code": effective_backend_code,
13640
+ "http_status": effective_http_status,
13641
+ "noop": False,
13642
+ "warnings": [warning],
13643
+ "verification": {
13644
+ "readback_unavailable": True,
13645
+ "metadata_unverified": True,
13646
+ },
13647
+ "verified": False,
13648
+ "write_executed": True,
13649
+ "write_succeeded": True,
13650
+ "safe_to_retry": False,
13651
+ }
13652
+
13653
+
13654
+ def _readback_transport_error_from_details(details: JSONObject) -> JSONObject | None:
13655
+ direct = details.get("transport_error")
13656
+ if isinstance(direct, dict):
13657
+ return direct
13658
+ for key in (
13659
+ "readback_error",
13660
+ "verification_error",
13661
+ "draft_readback_error",
13662
+ "live_readback_error",
13663
+ "state_read_blocked",
13664
+ ):
13665
+ value = details.get(key)
13666
+ if isinstance(value, dict) and isinstance(value.get("transport_error"), dict):
13667
+ return value.get("transport_error")
13668
+ verification_result = details.get("verification_result")
13669
+ if isinstance(verification_result, dict):
13670
+ verification_details = verification_result.get("details")
13671
+ if isinstance(verification_details, dict):
13672
+ nested = verification_details.get("transport_error")
13673
+ if isinstance(nested, dict):
13674
+ payload = dict(nested)
13675
+ for key in ("backend_code", "http_status", "request_id", "category"):
13676
+ if payload.get(key) is None and verification_result.get(key) is not None:
13677
+ payload[key] = verification_result.get(key)
13678
+ return payload
13679
+ payload = {
13680
+ key: verification_result.get(key)
13681
+ for key in ("backend_code", "http_status", "request_id", "category")
13682
+ if verification_result.get(key) is not None
13683
+ }
13684
+ if payload:
13685
+ return payload
13686
+ return None
13687
+
13688
+
12123
13689
  def _transport_error_payload(error: QingflowApiError) -> JSONObject:
12124
13690
  return {
12125
13691
  "http_status": error.http_status,
@@ -12130,7 +13696,30 @@ def _transport_error_payload(error: QingflowApiError) -> JSONObject:
12130
13696
 
12131
13697
 
12132
13698
  def _is_permission_restricted_api_error(error: QingflowApiError) -> bool:
12133
- return error.backend_code in {40002, 40027}
13699
+ if is_auth_like_error(error):
13700
+ return False
13701
+ return backend_code_int(error) in {40002, 40027}
13702
+
13703
+
13704
+ def _is_optional_builder_lookup_error(error: QingflowApiError) -> bool:
13705
+ if is_auth_like_error(error):
13706
+ return False
13707
+ return backend_code_int(error) in {40002, 40027, 404} or error.http_status == 404
13708
+
13709
+
13710
+ def _search_permission_blocked_from_warnings(payload: JSONObject) -> JSONObject | None:
13711
+ warnings = payload.get("warnings")
13712
+ if not isinstance(warnings, list):
13713
+ return None
13714
+ for item in warnings:
13715
+ if not isinstance(item, dict) or item.get("code") != "APP_SEARCH_FALLBACK_VISIBLE_APPS":
13716
+ continue
13717
+ return {
13718
+ "backend_code": item.get("backend_code"),
13719
+ "http_status": item.get("http_status"),
13720
+ "request_id": item.get("request_id"),
13721
+ }
13722
+ return None
12134
13723
 
12135
13724
 
12136
13725
  def _append_response_detail(details: JSONObject, *, key: str, value: Any) -> None:
@@ -12331,7 +13920,7 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
12331
13920
 
12332
13921
 
12333
13922
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
12334
- if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
13923
+ if backend_code_int(error) == 40074 or error_code == "APP_EDIT_LOCKED":
12335
13924
  owner = _extract_edit_lock_owner(error.message)
12336
13925
  owner_label = owner.get("lock_owner_email") or owner.get("lock_owner_name")
12337
13926
  if owner_label:
@@ -12360,6 +13949,30 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
12360
13949
  return mapping.get(error_code, "requested builder resource is unavailable in the current route")
12361
13950
 
12362
13951
 
13952
+ def _chart_delete_readback_is_not_found(error: QingflowApiError) -> bool:
13953
+ return _delete_readback_is_not_found(error)
13954
+
13955
+
13956
+ def _delete_readback_is_not_found(error: QingflowApiError) -> bool:
13957
+ if is_auth_like_error(error):
13958
+ return False
13959
+ backend_code = backend_code_int(error)
13960
+ if error.http_status == 404 or backend_code in {404, 40038, 81007}:
13961
+ return True
13962
+ message = str(error.message or "").lower()
13963
+ return any(
13964
+ marker in message
13965
+ for marker in (
13966
+ "object not exist",
13967
+ "not found",
13968
+ "not exist",
13969
+ "does not exist",
13970
+ "不存在",
13971
+ "未找到",
13972
+ )
13973
+ )
13974
+
13975
+
12363
13976
  def _extract_edit_lock_owner(message: str) -> JSONObject:
12364
13977
  text = str(message or "").strip()
12365
13978
  if not text:
@@ -12664,18 +14277,20 @@ def _build_public_dimension_fields(
12664
14277
  *,
12665
14278
  app_key: str,
12666
14279
  field_lookup: dict[str, dict[str, Any]],
14280
+ chart_field_lookup: dict[str, Any],
12667
14281
  qingbi_fields_by_id: dict[str, dict[str, Any]],
14282
+ chart_type: str = "chart",
12668
14283
  ) -> list[dict[str, Any]]:
12669
14284
  dimensions: list[dict[str, Any]] = []
12670
14285
  for selector in selectors:
12671
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12672
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12673
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
14286
+ qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="dimension")
14287
+ field_id = _chart_field_id(qingbi_field)
14288
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12674
14289
  dimensions.append(
12675
14290
  {
12676
14291
  "fieldId": field_id,
12677
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12678
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
14292
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14293
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12679
14294
  "orderType": "default",
12680
14295
  "alignType": "left",
12681
14296
  "dateFormat": "yyyy-MM-dd",
@@ -12717,27 +14332,540 @@ def _default_public_total_metric() -> dict[str, Any]:
12717
14332
  }
12718
14333
 
12719
14334
 
14335
+ _QINGBI_TOTAL_FIELD_ID = ":-100"
14336
+ _QINGBI_DECIMAL_FIELD_TYPES = {"decimal", "number", "numeric", "amount", "integer", "int", "long", "double", "float"}
14337
+
14338
+
14339
+ class ChartRuleViolation(ValueError):
14340
+ def __init__(self, diagnostics: dict[str, Any]) -> None:
14341
+ self.diagnostics = diagnostics
14342
+ super().__init__(str(diagnostics.get("message") or diagnostics.get("next_action") or "chart rule violation"))
14343
+
14344
+
14345
+ def _chart_field_id(field: dict[str, Any]) -> str:
14346
+ return str(field.get("fieldId") or field.get("field_id") or "").strip()
14347
+
14348
+
14349
+ def _chart_field_name(field: dict[str, Any]) -> str | None:
14350
+ name = str(field.get("fieldName") or field.get("field_name") or "").strip()
14351
+ return name or None
14352
+
14353
+
14354
+ def _chart_fields(payload: dict[str, Any], key: str) -> list[dict[str, Any]]:
14355
+ value = payload.get(key)
14356
+ if not isinstance(value, list):
14357
+ return []
14358
+ return [item for item in value if isinstance(item, dict)]
14359
+
14360
+
14361
+ def _chart_field_summary(field: dict[str, Any]) -> dict[str, Any]:
14362
+ return _compact_dict(
14363
+ {
14364
+ "field_id": _chart_field_id(field),
14365
+ "field_name": _chart_field_name(field),
14366
+ "field_type": field.get("fieldType") or field.get("field_type"),
14367
+ "field_source": field.get("fieldSource") or field.get("field_source"),
14368
+ "bi_formula_type": field.get("biFormulaType") or field.get("bi_formula_type"),
14369
+ "aggre_field_id": field.get("aggreFieldId") or field.get("aggre_field_id"),
14370
+ }
14371
+ )
14372
+
14373
+
14374
+ def _chart_duplicate_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
14375
+ seen: dict[str, dict[str, Any]] = {}
14376
+ duplicates: dict[str, dict[str, Any]] = {}
14377
+ for field in fields:
14378
+ field_id = _chart_field_id(field)
14379
+ if not field_id:
14380
+ continue
14381
+ if field_id in seen:
14382
+ duplicates[field_id] = _chart_field_summary(field)
14383
+ else:
14384
+ seen[field_id] = field
14385
+ return list(duplicates.values())
14386
+
14387
+
14388
+ def _chart_rule_diagnostics(
14389
+ *,
14390
+ rule_code: str,
14391
+ chart_type: str,
14392
+ message: str,
14393
+ expected: str,
14394
+ actual: dict[str, Any],
14395
+ next_action: str,
14396
+ offending_fields: list[dict[str, Any]] | None = None,
14397
+ ) -> dict[str, Any]:
14398
+ return _compact_dict(
14399
+ {
14400
+ "rule_code": rule_code,
14401
+ "chart_type": chart_type,
14402
+ "message": message,
14403
+ "expected": expected,
14404
+ "actual": actual,
14405
+ "offending_fields": offending_fields or [],
14406
+ "next_action": next_action,
14407
+ }
14408
+ )
14409
+
14410
+
14411
+ def _raise_chart_rule(
14412
+ *,
14413
+ rule_code: str,
14414
+ chart_type: str,
14415
+ message: str,
14416
+ expected: str,
14417
+ actual: dict[str, Any],
14418
+ next_action: str,
14419
+ offending_fields: list[dict[str, Any]] | None = None,
14420
+ ) -> None:
14421
+ raise ChartRuleViolation(
14422
+ _chart_rule_diagnostics(
14423
+ rule_code=rule_code,
14424
+ chart_type=chart_type,
14425
+ message=message,
14426
+ expected=expected,
14427
+ actual=actual,
14428
+ next_action=next_action,
14429
+ offending_fields=offending_fields,
14430
+ )
14431
+ )
14432
+
14433
+
14434
+ _QINGBI_TOTAL_FIELD_ALIASES = {_QINGBI_TOTAL_FIELD_ID, "数据总量", "data_total", "total", "count"}
14435
+
14436
+
14437
+ def _qingbi_field_que_id(*, app_key: str, field_id: Any) -> int | None:
14438
+ raw = str(field_id or "").strip()
14439
+ if not raw or raw == _QINGBI_TOTAL_FIELD_ID:
14440
+ return None
14441
+ if raw.startswith("field_"):
14442
+ return _coerce_positive_int(raw.removeprefix("field_"))
14443
+ if raw.startswith(f"{app_key}:"):
14444
+ return _coerce_positive_int(raw.split(":", 1)[1])
14445
+ if ":" in raw:
14446
+ return _coerce_positive_int(raw.rsplit(":", 1)[1])
14447
+ return _coerce_positive_int(raw)
14448
+
14449
+
14450
+ def _dedupe_qingbi_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
14451
+ deduped: list[dict[str, Any]] = []
14452
+ seen: set[str] = set()
14453
+ for field in fields:
14454
+ field_id = _chart_field_id(field)
14455
+ key = field_id or json.dumps(field, sort_keys=True, ensure_ascii=False, default=str)
14456
+ if key in seen:
14457
+ continue
14458
+ seen.add(key)
14459
+ deduped.append(field)
14460
+ return deduped
14461
+
14462
+
14463
+ def _build_qingbi_chart_field_lookup(
14464
+ *,
14465
+ app_key: str,
14466
+ qingbi_fields: list[dict[str, Any]],
14467
+ field_lookup: dict[str, dict[str, Any]],
14468
+ ) -> dict[str, Any]:
14469
+ by_selector: dict[str, list[dict[str, Any]]] = {}
14470
+ form_by_que_id = field_lookup.get("by_que_id") or {}
14471
+
14472
+ def add_selector(key: Any, field: dict[str, Any]) -> None:
14473
+ normalized = str(key or "").strip()
14474
+ if not normalized:
14475
+ return
14476
+ by_selector.setdefault(normalized, []).append(field)
14477
+ lower = normalized.lower()
14478
+ if lower != normalized:
14479
+ by_selector.setdefault(lower, []).append(field)
14480
+
14481
+ for raw_field in qingbi_fields:
14482
+ if not isinstance(raw_field, dict):
14483
+ continue
14484
+ field_id = _chart_field_id(raw_field)
14485
+ if not field_id:
14486
+ continue
14487
+ field = deepcopy(raw_field)
14488
+ que_id = _qingbi_field_que_id(app_key=app_key, field_id=field_id)
14489
+ form_field = form_by_que_id.get(que_id) if que_id is not None else None
14490
+ if isinstance(form_field, dict):
14491
+ field["_public_form_field"] = deepcopy(form_field)
14492
+ if not _chart_field_name(field) and isinstance(form_field, dict) and form_field.get("name"):
14493
+ field["fieldName"] = form_field.get("name")
14494
+
14495
+ add_selector(field_id, field)
14496
+ if que_id is not None:
14497
+ add_selector(que_id, field)
14498
+ add_selector(f"field_{que_id}", field)
14499
+ if isinstance(form_field, dict):
14500
+ add_selector(form_field.get("field_id"), field)
14501
+ title = _chart_field_name(field)
14502
+ if title:
14503
+ add_selector(title, field)
14504
+ return {"by_selector": by_selector}
14505
+
14506
+
14507
+ def _compact_public_chart_fields_read(
14508
+ *,
14509
+ app_key: str,
14510
+ qingbi_fields: list[dict[str, Any]],
14511
+ field_lookup: dict[str, dict[str, Any]],
14512
+ ) -> list[dict[str, Any]]:
14513
+ form_by_que_id = field_lookup.get("by_que_id") or {}
14514
+ compact_fields: list[dict[str, Any]] = []
14515
+ seen: set[str] = set()
14516
+ for field in qingbi_fields:
14517
+ if not isinstance(field, dict):
14518
+ continue
14519
+ bi_field_id = _chart_field_id(field)
14520
+ if not bi_field_id or bi_field_id in seen:
14521
+ continue
14522
+ seen.add(bi_field_id)
14523
+ que_id = _qingbi_field_que_id(app_key=app_key, field_id=bi_field_id)
14524
+ form_field = form_by_que_id.get(que_id) if que_id is not None else None
14525
+ public_field_id = (
14526
+ str(form_field.get("field_id"))
14527
+ if isinstance(form_field, dict) and form_field.get("field_id")
14528
+ else f"field_{que_id}"
14529
+ if que_id is not None
14530
+ else bi_field_id
14531
+ )
14532
+ title = _chart_field_name(field) or (
14533
+ str(form_field.get("name")) if isinstance(form_field, dict) and form_field.get("name") else bi_field_id
14534
+ )
14535
+ compact_fields.append(
14536
+ _compact_dict(
14537
+ {
14538
+ "field_id": public_field_id,
14539
+ "que_id": que_id,
14540
+ "bi_field_id": bi_field_id,
14541
+ "title": title,
14542
+ "field_type": field.get("fieldType") or field.get("field_type"),
14543
+ "system_field": bool(que_id is not None and not isinstance(form_field, dict)),
14544
+ "available_for_charts": True,
14545
+ }
14546
+ )
14547
+ )
14548
+ return compact_fields
14549
+
14550
+
14551
+ def _chart_field_candidates(
14552
+ selector: Any,
14553
+ *,
14554
+ chart_field_lookup: dict[str, Any],
14555
+ ) -> list[dict[str, Any]]:
14556
+ raw = str(selector or "").strip()
14557
+ if not raw:
14558
+ return []
14559
+ by_selector = chart_field_lookup.get("by_selector") if isinstance(chart_field_lookup.get("by_selector"), dict) else {}
14560
+ return _dedupe_qingbi_fields(list(by_selector.get(raw) or by_selector.get(raw.lower()) or []))
14561
+
14562
+
14563
+ def _resolve_qingbi_chart_field(
14564
+ selector: Any,
14565
+ *,
14566
+ chart_field_lookup: dict[str, Any],
14567
+ chart_type: str,
14568
+ role: str,
14569
+ ) -> dict[str, Any]:
14570
+ raw = str(selector or "").strip()
14571
+ if not raw:
14572
+ _raise_chart_rule(
14573
+ rule_code="CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
14574
+ chart_type=chart_type,
14575
+ message="chart field selector cannot be empty",
14576
+ expected="use a field from app_get_fields.chart_fields",
14577
+ actual={"selector": raw, "role": role},
14578
+ next_action="Call app_get_fields and choose a field from chart_fields for chart dimensions, metrics, filters, or query conditions.",
14579
+ )
14580
+ if raw in _QINGBI_TOTAL_FIELD_ALIASES or raw.lower() in _QINGBI_TOTAL_FIELD_ALIASES:
14581
+ if role == "metric":
14582
+ return _default_public_total_metric()
14583
+ _raise_chart_rule(
14584
+ rule_code="CHART_TOTAL_FIELD_NOT_ALLOWED",
14585
+ chart_type=chart_type,
14586
+ message="数据总量 is only valid as a metric field, not as a dimension/filter/query field",
14587
+ expected="use 数据总量 only in indicator_field_ids or omit metrics for count-style charts",
14588
+ actual={"selector": raw, "role": role},
14589
+ next_action="Choose a real QingBI field from app_get_fields.chart_fields for dimensions, filters, and query conditions.",
14590
+ offending_fields=[{"field_id": _QINGBI_TOTAL_FIELD_ID, "field_name": "数据总量"}],
14591
+ )
14592
+ candidates = _chart_field_candidates(raw, chart_field_lookup=chart_field_lookup)
14593
+ if not candidates:
14594
+ _raise_chart_rule(
14595
+ rule_code="CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
14596
+ chart_type=chart_type,
14597
+ message=f"field '{raw}' was not found in QingBI datasource fields for this app",
14598
+ expected="chart fields must come from app_get_fields.chart_fields, not record schema or form-only fields",
14599
+ actual={"selector": raw, "role": role},
14600
+ next_action="Call app_get_fields and choose a field from chart_fields; if the system field is absent there, QingBI cannot use it for this report.",
14601
+ )
14602
+ if len(candidates) > 1:
14603
+ _raise_chart_rule(
14604
+ rule_code="CHART_FIELD_AMBIGUOUS",
14605
+ chart_type=chart_type,
14606
+ message=f"field '{raw}' matched multiple QingBI datasource fields",
14607
+ expected="use an unambiguous field selector such as bi_field_id or field_<queId>",
14608
+ actual={"selector": raw, "role": role, "candidate_count": len(candidates)},
14609
+ next_action="Use one of the returned candidate bi_field_id values or field_<queId> selectors.",
14610
+ offending_fields=[_chart_field_summary(item) for item in candidates],
14611
+ )
14612
+ return deepcopy(candidates[0])
14613
+
14614
+
14615
+ def _check_chart_slot_duplicates(*, chart_type: str, payload: dict[str, Any], slot_names: list[str]) -> None:
14616
+ for slot_name in slot_names:
14617
+ duplicates = _chart_duplicate_fields(_chart_fields(payload, slot_name))
14618
+ if duplicates:
14619
+ _raise_chart_rule(
14620
+ rule_code="CHART_FIELD_ID_REPEAT",
14621
+ chart_type=chart_type,
14622
+ message=f"{chart_type} chart has duplicate field ids in {slot_name}",
14623
+ expected=f"{slot_name} must not contain duplicated fieldId values",
14624
+ actual={"slot": slot_name, "duplicate_count": len(duplicates)},
14625
+ offending_fields=duplicates,
14626
+ next_action="Use different fields for this slot, or remove the duplicated field before retrying.",
14627
+ )
14628
+
14629
+
14630
+ def _histogram_metric_issue(metric: dict[str, Any]) -> dict[str, Any] | None:
14631
+ field_id = _chart_field_id(metric)
14632
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
14633
+ return {
14634
+ "rule_code": "HISTOGRAM_DEFAULT_TOTAL_METRIC_UNSUPPORTED",
14635
+ "message": "histogram cannot use 数据总量 as its metric",
14636
+ "next_action": "Pass one explicit numeric field in indicator_field_ids and set config.aggregate such as sum/avg.",
14637
+ }
14638
+ field_type = str(metric.get("fieldType") or metric.get("field_type") or "").strip().lower()
14639
+ if field_type not in _QINGBI_DECIMAL_FIELD_TYPES:
14640
+ return {
14641
+ "rule_code": "HISTOGRAM_METRIC_FIELD_TYPE_UNSUPPORTED",
14642
+ "message": "histogram metric must be a numeric field",
14643
+ "next_action": "Choose one number/amount field as indicator_field_ids for histogram.",
14644
+ }
14645
+ field_source = str(metric.get("fieldSource") or metric.get("field_source") or "").strip().lower()
14646
+ bi_formula_type = str(metric.get("biFormulaType") or metric.get("bi_formula_type") or "").strip().lower()
14647
+ aggre_field_id = str(metric.get("aggreFieldId") or metric.get("aggre_field_id") or "").strip()
14648
+ if field_source == "formula" and (bi_formula_type in {"chart_agg", "agg"} or aggre_field_id):
14649
+ return {
14650
+ "rule_code": "HISTOGRAM_AGG_FORMULA_METRIC_UNSUPPORTED",
14651
+ "message": "histogram metric cannot be an aggregate formula field",
14652
+ "next_action": "Choose a plain numeric field, not an aggregate formula field.",
14653
+ }
14654
+ return None
14655
+
14656
+
14657
+ def _validate_public_chart_payload_rules(payload: dict[str, Any]) -> None:
14658
+ chart_type = str(payload.get("chartType") or "").strip().lower()
14659
+ dimensions = _chart_fields(payload, "selectedDimensions")
14660
+ metrics = _chart_fields(payload, "selectedMetrics")
14661
+ _check_chart_slot_duplicates(
14662
+ chart_type=chart_type,
14663
+ payload=payload,
14664
+ slot_names=[
14665
+ "selectedDimensions",
14666
+ "selectedMetrics",
14667
+ "xDimensions",
14668
+ "yDimensions",
14669
+ "xMetrics",
14670
+ "yMetrics",
14671
+ "leftMetrics",
14672
+ "rightMetrics",
14673
+ ],
14674
+ )
14675
+
14676
+ if chart_type == "gauge":
14677
+ if dimensions:
14678
+ _raise_chart_rule(
14679
+ rule_code="GAUGE_DIMENSION_NOT_ALLOWED",
14680
+ chart_type=chart_type,
14681
+ message="gauge chart must not have dimensions",
14682
+ expected="0 dimensions",
14683
+ actual={"dimension_count": len(dimensions)},
14684
+ offending_fields=[_chart_field_summary(field) for field in dimensions],
14685
+ next_action="Remove dimension_field_ids for gauge. The CLI clears public dimensions, but custom selectedDimensions in config must also be removed.",
14686
+ )
14687
+ if len(metrics) != 2:
14688
+ _raise_chart_rule(
14689
+ rule_code="GAUGE_METRIC_COUNT_INVALID",
14690
+ chart_type=chart_type,
14691
+ message="gauge chart requires exactly two metrics",
14692
+ expected="exactly 2 non-duplicated metrics; one real metric plus 数据总量 is allowed",
14693
+ actual={"metric_count": len(metrics), "metric_field_ids": [_chart_field_id(field) for field in metrics]},
14694
+ offending_fields=[_chart_field_summary(field) for field in metrics],
14695
+ next_action="Pass two different indicator_field_ids, or pass one explicit real numeric metric so the CLI can pair it with 数据总量.",
14696
+ )
14697
+ elif chart_type == "histogram":
14698
+ if len(dimensions) > 1:
14699
+ _raise_chart_rule(
14700
+ rule_code="HISTOGRAM_DIMENSION_COUNT_INVALID",
14701
+ chart_type=chart_type,
14702
+ message="histogram chart supports at most one dimension",
14703
+ expected="0 or 1 dimension",
14704
+ actual={"dimension_count": len(dimensions)},
14705
+ offending_fields=[_chart_field_summary(field) for field in dimensions],
14706
+ next_action="Keep at most one dimension_field_ids value for histogram.",
14707
+ )
14708
+ if len(metrics) != 1:
14709
+ _raise_chart_rule(
14710
+ rule_code="HISTOGRAM_METRIC_COUNT_INVALID",
14711
+ chart_type=chart_type,
14712
+ message="histogram chart requires exactly one explicit metric",
14713
+ expected="exactly 1 plain numeric metric",
14714
+ actual={"metric_count": len(metrics), "metric_field_ids": [_chart_field_id(field) for field in metrics]},
14715
+ offending_fields=[_chart_field_summary(field) for field in metrics],
14716
+ next_action="Pass exactly one numeric field in indicator_field_ids; histogram cannot rely on the default count metric.",
14717
+ )
14718
+ issue = _histogram_metric_issue(metrics[0])
14719
+ if issue:
14720
+ _raise_chart_rule(
14721
+ rule_code=str(issue["rule_code"]),
14722
+ chart_type=chart_type,
14723
+ message=str(issue["message"]),
14724
+ expected="one plain decimal metric; not 数据总量 and not aggregate formula",
14725
+ actual={"metric": _chart_field_summary(metrics[0])},
14726
+ offending_fields=[_chart_field_summary(metrics[0])],
14727
+ next_action=str(issue["next_action"]),
14728
+ )
14729
+ elif chart_type == "heatmap":
14730
+ if len(dimensions) != 2 or len(metrics) != 1:
14731
+ _raise_chart_rule(
14732
+ rule_code="HEATMAP_FIELD_COUNT_INVALID",
14733
+ chart_type=chart_type,
14734
+ message="heatmap chart requires two dimensions and one metric",
14735
+ expected="2 dimensions and 1 metric",
14736
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
14737
+ next_action="Pass exactly two dimension_field_ids and one indicator_field_ids value for heatmap.",
14738
+ )
14739
+ elif chart_type == "waterfall":
14740
+ if len(dimensions) != 1 or len(metrics) != 1:
14741
+ _raise_chart_rule(
14742
+ rule_code="WATERFALL_FIELD_COUNT_INVALID",
14743
+ chart_type=chart_type,
14744
+ message="waterfall chart requires one dimension and one metric",
14745
+ expected="1 dimension and 1 metric",
14746
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
14747
+ next_action="Pass exactly one dimension_field_ids value and one indicator_field_ids value for waterfall.",
14748
+ )
14749
+ elif chart_type == "treemap":
14750
+ if len(dimensions) < 1 or len(dimensions) > 2 or len(metrics) != 1:
14751
+ _raise_chart_rule(
14752
+ rule_code="TREEMAP_FIELD_COUNT_INVALID",
14753
+ chart_type=chart_type,
14754
+ message="treemap chart requires one or two dimensions and one metric",
14755
+ expected="1-2 dimensions and 1 metric",
14756
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
14757
+ next_action="Pass one or two dimension_field_ids values and exactly one indicator_field_ids value for treemap.",
14758
+ )
14759
+ elif chart_type == "map":
14760
+ if len(dimensions) != 1 or len(metrics) != 1:
14761
+ _raise_chart_rule(
14762
+ rule_code="MAP_FIELD_COUNT_INVALID",
14763
+ chart_type=chart_type,
14764
+ message="map chart requires one dimension and one metric",
14765
+ expected="1 dimension and 1 metric",
14766
+ actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
14767
+ next_action="Pass exactly one location/address dimension and one metric for map.",
14768
+ )
14769
+ elif chart_type == "scatter":
14770
+ x_metrics = _chart_fields(payload, "xMetrics")
14771
+ y_metrics = _chart_fields(payload, "yMetrics")
14772
+ if not dimensions or len(x_metrics) != 1 or len(y_metrics) != 1:
14773
+ _raise_chart_rule(
14774
+ rule_code="SCATTER_FIELD_COUNT_INVALID",
14775
+ chart_type=chart_type,
14776
+ message="scatter chart requires at least one dimension, one x metric, and one y metric",
14777
+ expected=">=1 dimensions, exactly 1 x metric and 1 y metric",
14778
+ actual={"dimension_count": len(dimensions), "x_metric_count": len(x_metrics), "y_metric_count": len(y_metrics)},
14779
+ next_action="Pass at least one dimension_field_ids value and one or two indicator_field_ids values for scatter.",
14780
+ )
14781
+ elif chart_type == "dualaxes":
14782
+ left_metrics = _chart_fields(payload, "leftMetrics")
14783
+ right_metrics = _chart_fields(payload, "rightMetrics")
14784
+ if not dimensions or (not left_metrics and not right_metrics):
14785
+ _raise_chart_rule(
14786
+ rule_code="DUALAXES_FIELD_COUNT_INVALID",
14787
+ chart_type=chart_type,
14788
+ message="dualaxes chart requires at least one dimension and at least one metric axis",
14789
+ expected=">=1 dimensions and at least one left/right metric",
14790
+ actual={"dimension_count": len(dimensions), "left_metric_count": len(left_metrics), "right_metric_count": len(right_metrics)},
14791
+ next_action="Pass at least one dimension_field_ids value and one or two indicator_field_ids values for dualaxes.",
14792
+ )
14793
+
14794
+
14795
+ def _explain_chart_backend_validation_error(
14796
+ *,
14797
+ api_error: QingflowApiError,
14798
+ chart_type: str,
14799
+ payload: dict[str, Any] | None,
14800
+ ) -> dict[str, Any] | None:
14801
+ backend_code = backend_code_int(api_error)
14802
+ if backend_code not in {81002, 81005}:
14803
+ return None
14804
+ chart_type = str(chart_type or (payload or {}).get("chartType") or "").strip().lower()
14805
+ if isinstance(payload, dict):
14806
+ try:
14807
+ _validate_public_chart_payload_rules(payload)
14808
+ except ChartRuleViolation as violation:
14809
+ return violation.diagnostics
14810
+ if backend_code == 81005:
14811
+ duplicate_fields: list[dict[str, Any]] = []
14812
+ if isinstance(payload, dict):
14813
+ for slot_name in [
14814
+ "selectedDimensions",
14815
+ "selectedMetrics",
14816
+ "xDimensions",
14817
+ "yDimensions",
14818
+ "xMetrics",
14819
+ "yMetrics",
14820
+ "leftMetrics",
14821
+ "rightMetrics",
14822
+ ]:
14823
+ duplicate_fields.extend(_chart_duplicate_fields(_chart_fields(payload, slot_name)))
14824
+ return _chart_rule_diagnostics(
14825
+ rule_code="CHART_FIELD_ID_REPEAT",
14826
+ chart_type=chart_type,
14827
+ message="QingBI rejected the chart because one field id is repeated in a chart slot",
14828
+ expected="field ids must be unique within each dimension/metric slot",
14829
+ actual={"backend_code": backend_code},
14830
+ offending_fields=duplicate_fields,
14831
+ next_action="Remove duplicated fields or pass two different explicit metrics before retrying.",
14832
+ )
14833
+ return _chart_rule_diagnostics(
14834
+ rule_code="WRONG_METRIC_COUNT_OR_TYPE",
14835
+ chart_type=chart_type,
14836
+ message="QingBI rejected the chart because metric count or metric type does not satisfy this chart type",
14837
+ expected="use the chart-type metric count/type rules from builder charts documentation",
14838
+ actual={"backend_code": backend_code},
14839
+ next_action="Check indicator_field_ids count and field types; for histogram use exactly one plain numeric metric, and for gauge use two non-duplicated metrics.",
14840
+ )
14841
+
14842
+
12720
14843
  def _build_public_metric_fields(
12721
14844
  selectors: list[str],
12722
14845
  *,
12723
14846
  app_key: str,
12724
14847
  field_lookup: dict[str, dict[str, Any]],
14848
+ chart_field_lookup: dict[str, Any],
12725
14849
  qingbi_fields_by_id: dict[str, dict[str, Any]],
12726
14850
  aggregate: str,
14851
+ chart_type: str = "chart",
12727
14852
  ) -> list[dict[str, Any]]:
12728
14853
  normalized_aggregate = str(aggregate or "count").strip().lower()
12729
14854
  if normalized_aggregate == "count" or not selectors:
12730
14855
  return [_default_public_total_metric()]
12731
14856
  metrics: list[dict[str, Any]] = []
12732
14857
  for selector in selectors:
12733
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12734
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12735
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
14858
+ qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="metric")
14859
+ field_id = _chart_field_id(qingbi_field)
14860
+ if field_id == _QINGBI_TOTAL_FIELD_ID:
14861
+ metrics.append(qingbi_field)
14862
+ continue
14863
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12736
14864
  metrics.append(
12737
14865
  {
12738
14866
  "fieldId": field_id,
12739
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12740
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
14867
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14868
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12741
14869
  "orderType": "default",
12742
14870
  "alignType": "left",
12743
14871
  "dateFormat": "yyyy-MM-dd",
@@ -12754,6 +14882,8 @@ def _build_public_metric_fields(
12754
14882
  "supId": qingbi_field.get("supId"),
12755
14883
  "beingTable": bool(qingbi_field.get("beingTable", False)),
12756
14884
  "returnType": qingbi_field.get("returnType"),
14885
+ "biFormulaType": qingbi_field.get("biFormulaType"),
14886
+ "aggreFieldId": qingbi_field.get("aggreFieldId"),
12757
14887
  }
12758
14888
  )
12759
14889
  return metrics or [_default_public_total_metric()]
@@ -12783,7 +14913,9 @@ def _build_public_chart_filter_matrix(
12783
14913
  *,
12784
14914
  app_key: str,
12785
14915
  field_lookup: dict[str, dict[str, Any]],
14916
+ chart_field_lookup: dict[str, Any],
12786
14917
  qingbi_fields_by_id: dict[str, dict[str, Any]],
14918
+ chart_type: str = "chart",
12787
14919
  ) -> list[list[dict[str, Any]]]:
12788
14920
  if not rules:
12789
14921
  return []
@@ -12799,16 +14931,21 @@ def _build_public_chart_filter_matrix(
12799
14931
  ViewFilterOperator.not_empty.value: 16,
12800
14932
  }
12801
14933
  for rule in rules:
12802
- field = _resolve_public_field(getattr(rule, "field_name", None), field_lookup=field_lookup)
12803
- field_id = _bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id)
12804
- qingbi_field = deepcopy(qingbi_fields_by_id.get(field_id, {}))
14934
+ qingbi_field = _resolve_qingbi_chart_field(
14935
+ getattr(rule, "field_name", None),
14936
+ chart_field_lookup=chart_field_lookup,
14937
+ chart_type=chart_type,
14938
+ role="filter",
14939
+ )
14940
+ field_id = _chart_field_id(qingbi_field)
14941
+ form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
12805
14942
  operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
12806
14943
  values = list(getattr(rule, "values", []) or [])
12807
14944
  group.append(
12808
14945
  {
12809
14946
  "fieldId": field_id,
12810
- "fieldName": qingbi_field.get("fieldName") or field.get("name"),
12811
- "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(field.get("type") or "")),
14947
+ "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
14948
+ "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
12812
14949
  "judgeType": judge_map.get(operator, 0),
12813
14950
  "judgeValues": values,
12814
14951
  "matchType": 1,
@@ -12822,6 +14959,7 @@ def _build_public_chart_config_payload(
12822
14959
  patch: ChartUpsertPatch,
12823
14960
  app_key: str,
12824
14961
  field_lookup: dict[str, dict[str, Any]],
14962
+ chart_field_lookup: dict[str, Any],
12825
14963
  qingbi_fields_by_id: dict[str, dict[str, Any]],
12826
14964
  ) -> dict[str, Any]:
12827
14965
  config = deepcopy(patch.config)
@@ -12840,12 +14978,19 @@ def _build_public_chart_config_payload(
12840
14978
  patch.filters,
12841
14979
  app_key=app_key,
12842
14980
  field_lookup=field_lookup,
14981
+ chart_field_lookup=chart_field_lookup,
12843
14982
  qingbi_fields_by_id=qingbi_fields_by_id,
14983
+ chart_type=patch.chart_type.value,
12844
14984
  )
12845
14985
  query_condition_field_ids = []
12846
14986
  for selector in list(config.pop("query_condition_field_ids", []) or []):
12847
- field = _resolve_public_field(selector, field_lookup=field_lookup)
12848
- query_condition_field_ids.append(_bi_field_id_for_field(app_key=app_key, field=field, qingbi_fields_by_id=qingbi_fields_by_id))
14987
+ field = _resolve_qingbi_chart_field(
14988
+ selector,
14989
+ chart_field_lookup=chart_field_lookup,
14990
+ chart_type=patch.chart_type.value,
14991
+ role="query_condition",
14992
+ )
14993
+ query_condition_field_ids.append(_chart_field_id(field))
12849
14994
  backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
12850
14995
  if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
12851
14996
  raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
@@ -12853,14 +14998,18 @@ def _build_public_chart_config_payload(
12853
14998
  patch.dimension_field_ids,
12854
14999
  app_key=app_key,
12855
15000
  field_lookup=field_lookup,
15001
+ chart_field_lookup=chart_field_lookup,
12856
15002
  qingbi_fields_by_id=qingbi_fields_by_id,
15003
+ chart_type=patch.chart_type.value,
12857
15004
  )
12858
15005
  selected_metrics = _build_public_metric_fields(
12859
15006
  patch.indicator_field_ids,
12860
15007
  app_key=app_key,
12861
15008
  field_lookup=field_lookup,
15009
+ chart_field_lookup=chart_field_lookup,
12862
15010
  qingbi_fields_by_id=qingbi_fields_by_id,
12863
15011
  aggregate=aggregate,
15012
+ chart_type=patch.chart_type.value,
12864
15013
  )
12865
15014
  payload: dict[str, Any] = {
12866
15015
  "chartName": patch.name,
@@ -12914,6 +15063,7 @@ def _build_public_chart_config_payload(
12914
15063
  if key in config:
12915
15064
  payload[key] = deepcopy(config.pop(key))
12916
15065
  payload.update(config)
15066
+ _validate_public_chart_payload_rules(payload)
12917
15067
  return payload
12918
15068
 
12919
15069
 
@@ -13089,7 +15239,9 @@ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None =
13089
15239
  return deepcopy(candidates[-1])
13090
15240
 
13091
15241
 
13092
- def _portal_position_payload(position: Any) -> dict[str, Any]:
15242
+ def _portal_position_payload(position: Any, *, inferred_mobile_y: int = 0) -> dict[str, Any]:
15243
+ mobile_provided = bool(getattr(position, "mobile_provided", False))
15244
+ mobile_rows = int(getattr(position, "mobile_h", 8)) if mobile_provided else int(getattr(position, "pc_h", 8))
13093
15245
  return {
13094
15246
  "pc": {
13095
15247
  "x": int(getattr(position, "pc_x", 0)),
@@ -13098,10 +15250,10 @@ def _portal_position_payload(position: Any) -> dict[str, Any]:
13098
15250
  "rows": int(getattr(position, "pc_h", 8)),
13099
15251
  },
13100
15252
  "mobile": {
13101
- "x": int(getattr(position, "mobile_x", 0)),
13102
- "y": int(getattr(position, "mobile_y", 0)),
13103
- "cols": int(getattr(position, "mobile_w", 12)),
13104
- "rows": int(getattr(position, "mobile_h", 8)),
15253
+ "x": int(getattr(position, "mobile_x", 0)) if mobile_provided else 0,
15254
+ "y": int(getattr(position, "mobile_y", 0)) if mobile_provided else int(inferred_mobile_y),
15255
+ "cols": int(getattr(position, "mobile_w", 6)) if mobile_provided else 6,
15256
+ "rows": mobile_rows,
13105
15257
  },
13106
15258
  }
13107
15259
 
@@ -13113,6 +15265,7 @@ def _portal_component_position_public(
13113
15265
  pc_y: int,
13114
15266
  pc_row_height: int,
13115
15267
  mobile_y: int,
15268
+ layout_preset: str | None = None,
13116
15269
  ) -> tuple[dict[str, Any], int, int, int, int]:
13117
15270
  source_name = str(source_type or "").lower()
13118
15271
  if source_name == "filter":
@@ -13131,8 +15284,11 @@ def _portal_component_position_public(
13131
15284
  cols = 12
13132
15285
  rows = 2
13133
15286
  else:
13134
- cols = 8
13135
- rows = 4
15287
+ if layout_preset == "dashboard_2col":
15288
+ cols = 12
15289
+ else:
15290
+ cols = 8
15291
+ rows = 6
13136
15292
  if cols == 24:
13137
15293
  if pc_x != 0:
13138
15294
  pc_y += pc_row_height
@@ -13155,6 +15311,73 @@ def _portal_component_position_public(
13155
15311
  return position, next_pc_x, next_pc_y, next_row_height, mobile_y + rows
13156
15312
 
13157
15313
 
15314
+ def _empty_portal_layout_diagnostics() -> dict[str, Any]:
15315
+ return {
15316
+ "pc_grid_columns": 24,
15317
+ "mobile_grid_columns": 6,
15318
+ "section_count": 0,
15319
+ "explicit_position_count": 0,
15320
+ "max_pc_right": None,
15321
+ "safe_for_display": True,
15322
+ "warnings": [],
15323
+ }
15324
+
15325
+
15326
+ def _portal_layout_diagnostics(sections: list[PortalSectionPatch], components: list[dict[str, Any]]) -> dict[str, Any]:
15327
+ diagnostics = _empty_portal_layout_diagnostics()
15328
+ diagnostics["section_count"] = len(sections)
15329
+ explicit_count = sum(1 for section in sections if section.position is not None)
15330
+ diagnostics["explicit_position_count"] = explicit_count
15331
+ pc_positions: list[dict[str, Any]] = []
15332
+ warnings: list[dict[str, Any]] = []
15333
+ for index, component in enumerate(components):
15334
+ if not isinstance(component, dict):
15335
+ continue
15336
+ position = component.get("position") if isinstance(component.get("position"), dict) else {}
15337
+ pc = position.get("pc") if isinstance(position.get("pc"), dict) else {}
15338
+ if pc:
15339
+ pc_positions.append(pc)
15340
+ section = sections[index] if index < len(sections) else None
15341
+ source_type = str(getattr(section, "source_type", "") or "").lower() if section is not None else ""
15342
+ title = str(getattr(section, "title", "") or "").strip() if section is not None else None
15343
+ cols = int(pc.get("cols") or 0)
15344
+ rows = int(pc.get("rows") or 0)
15345
+ if source_type == "chart" and (cols < 8 or rows < 5):
15346
+ warnings.append(_warning(
15347
+ "PORTAL_CHART_CARD_TOO_SMALL",
15348
+ "chart portal card is too small; use at least pc.cols >= 8 and pc.rows >= 5, preferably rows >= 6",
15349
+ section_index=index,
15350
+ title=title,
15351
+ pc=deepcopy(pc),
15352
+ ))
15353
+ if section is not None and section.position is not None and not bool(getattr(section.position, "mobile_provided", False)):
15354
+ warnings.append(_warning(
15355
+ "PORTAL_MOBILE_POSITION_MISSING",
15356
+ "pc position was provided without mobile position; mobile layout was generated with 6-column grid",
15357
+ section_index=index,
15358
+ title=title,
15359
+ ))
15360
+ max_right = None
15361
+ if pc_positions:
15362
+ max_right = max(int(position.get("x") or 0) + int(position.get("cols") or 0) for position in pc_positions)
15363
+ diagnostics["max_pc_right"] = max_right
15364
+ if len(pc_positions) > 1 and max_right is not None and max_right <= 12:
15365
+ warnings.append(_warning(
15366
+ "PORTAL_LAYOUT_HALF_WIDTH",
15367
+ "portal components only occupy the left half of the 24-column pc grid; this looks like a 12-column layout was used",
15368
+ max_pc_right=max_right,
15369
+ fix_hint="Use x=0/12 with cols=12 for two columns, x=0/8/16 with cols=8 for three columns, or omit position/use layout_preset.",
15370
+ ))
15371
+ diagnostics["warnings"] = warnings
15372
+ diagnostics["safe_for_display"] = not any(item.get("code") in {"PORTAL_LAYOUT_HALF_WIDTH", "PORTAL_CHART_CARD_TOO_SMALL"} for item in warnings)
15373
+ return diagnostics
15374
+
15375
+
15376
+ def _portal_layout_warning_items(layout_diagnostics: dict[str, Any]) -> list[dict[str, Any]]:
15377
+ warnings = layout_diagnostics.get("warnings") if isinstance(layout_diagnostics, dict) else None
15378
+ return [deepcopy(item) for item in warnings if isinstance(item, dict)] if isinstance(warnings, list) else []
15379
+
15380
+
13158
15381
  def _resolve_chart_reference(*, charts: QingbiReportTools, profile: str, ref: Any) -> dict[str, Any]:
13159
15382
  app_key = str(getattr(ref, "app_key", "") or "").strip()
13160
15383
  chart_id = str(getattr(ref, "chart_id", "") or "").strip()
@@ -13858,7 +16081,9 @@ def _department_scope_equal(left: Any, right: Any) -> bool:
13858
16081
 
13859
16082
 
13860
16083
  def _is_relation_target_metadata_read_restricted_api_error(error: QingflowApiError) -> bool:
13861
- return error.backend_code in {40002, 40027, 40161}
16084
+ if is_auth_like_error(error):
16085
+ return False
16086
+ return backend_code_int(error) in {40002, 40027, 40161}
13862
16087
 
13863
16088
 
13864
16089
  def _relation_target_field_matches(left: dict[str, Any], right: dict[str, Any]) -> bool:
@@ -16768,13 +18993,38 @@ def _publish_verify_warnings(*, package_attached: bool | None, views_unavailable
16768
18993
  return warnings
16769
18994
 
16770
18995
 
16771
- def _chart_apply_warnings(*, failed_items: list[dict[str, Any]], readback_unavailable: bool, verified: bool) -> list[dict[str, Any]]:
18996
+ def _chart_apply_warnings(
18997
+ *,
18998
+ failed_items: list[dict[str, Any]],
18999
+ readback_unavailable: bool,
19000
+ verified: bool,
19001
+ delete_readback_issues: list[dict[str, Any]] | None = None,
19002
+ ) -> list[dict[str, Any]]:
16772
19003
  warnings: list[dict[str, Any]] = []
19004
+ delete_readback_issues = delete_readback_issues or []
16773
19005
  if failed_items:
16774
19006
  warnings.append(_warning("CHART_OPERATION_FAILED", "one or more chart operations failed", failed_count=len(failed_items)))
19007
+ still_exists = [item for item in delete_readback_issues if item.get("readback_status") == "still_exists"]
19008
+ unavailable = [item for item in delete_readback_issues if item.get("readback_status") == "unavailable"]
19009
+ if still_exists:
19010
+ warnings.append(
19011
+ _warning(
19012
+ "CHART_DELETE_READBACK_STILL_EXISTS",
19013
+ "one or more delete requests completed, but chart_id readback still found the chart",
19014
+ chart_ids=[item.get("chart_id") for item in still_exists if item.get("chart_id")],
19015
+ )
19016
+ )
19017
+ if unavailable:
19018
+ warnings.append(
19019
+ _warning(
19020
+ "CHART_DELETE_READBACK_UNAVAILABLE",
19021
+ "one or more delete requests completed, but chart_id readback was unavailable",
19022
+ chart_ids=[item.get("chart_id") for item in unavailable if item.get("chart_id")],
19023
+ )
19024
+ )
16775
19025
  if readback_unavailable:
16776
19026
  warnings.append(_warning("CHART_READBACK_PENDING", "chart readback is unavailable after apply"))
16777
- elif not verified and not failed_items:
19027
+ elif not verified and not failed_items and not delete_readback_issues:
16778
19028
  warnings.append(_warning("CHART_VERIFICATION_INCOMPLETE", "chart apply completed but verification is incomplete"))
16779
19029
  return warnings
16780
19030
 
@@ -17273,6 +19523,56 @@ def _package_resource_signature(items: Any, *, public: bool) -> tuple[tuple[str,
17273
19523
  return tuple(sorted(_flatten_package_resource_identities(items, public=public)))
17274
19524
 
17275
19525
 
19526
+ def _publicize_package_list_item(item: dict[str, Any]) -> JSONObject:
19527
+ package_id = _coerce_positive_int(item.get("package_id") or item.get("packageId") or item.get("tag_id") or item.get("tagId"))
19528
+ raw_package_id = package_id if package_id is not None else item.get("package_id") or item.get("packageId") or item.get("tag_id") or item.get("tagId")
19529
+ package_name = str(item.get("package_name") or item.get("packageName") or item.get("tag_name") or item.get("tagName") or "").strip()
19530
+ raw_items = item.get("tagItems") if isinstance(item.get("tagItems"), list) else None
19531
+ item_count = None
19532
+ raw_item_count = item.get("item_count") if "item_count" in item else item.get("itemCount")
19533
+ try:
19534
+ if raw_item_count is not None:
19535
+ coerced_count = int(raw_item_count)
19536
+ if coerced_count >= 0:
19537
+ item_count = coerced_count
19538
+ except (TypeError, ValueError):
19539
+ item_count = None
19540
+ if item_count is None and raw_items is not None:
19541
+ item_count = len(raw_items)
19542
+ tag_icon = item.get("tag_icon") if "tag_icon" in item else item.get("tagIcon")
19543
+ return {
19544
+ "package_id": raw_package_id,
19545
+ "package_name": package_name,
19546
+ "tag_id": raw_package_id,
19547
+ "tag_name": package_name,
19548
+ "publish_status": item.get("publish_status") if "publish_status" in item else item.get("publishStatus"),
19549
+ "being_trial": item.get("being_trial") if "being_trial" in item else item.get("beingTrial"),
19550
+ "item_count": item_count,
19551
+ "item_preview": deepcopy(item.get("item_preview") if "item_preview" in item else item.get("itemPreview") or []),
19552
+ "tag_icon": tag_icon,
19553
+ "icon_config": workspace_icon_config(str(tag_icon).strip() if tag_icon not in (None, "") else None),
19554
+ "permissions": {
19555
+ "can_add_app": item.get("can_add_app") if "can_add_app" in item else item.get("addAppStatus"),
19556
+ "can_edit_app": item.get("can_edit_app") if "can_edit_app" in item else item.get("editAppStatus"),
19557
+ "can_delete_app": item.get("can_delete_app") if "can_delete_app" in item else item.get("delAppStatus"),
19558
+ "can_edit_package": item.get("can_edit_package") if "can_edit_package" in item else item.get("editTagStatus"),
19559
+ },
19560
+ }
19561
+
19562
+
19563
+ def _package_list_item_matches_query(item: dict[str, Any], query: str) -> bool:
19564
+ needle = str(query or "").strip().casefold()
19565
+ if not needle:
19566
+ return True
19567
+ haystacks = (
19568
+ item.get("package_id"),
19569
+ item.get("tag_id"),
19570
+ item.get("package_name"),
19571
+ item.get("tag_name"),
19572
+ )
19573
+ return any(needle in str(value or "").casefold() for value in haystacks)
19574
+
19575
+
17276
19576
  def _backend_package_items_from_public_items(items: list[dict[str, Any]], group_ids_by_path: dict[tuple[int, ...], int], *, path: tuple[int, ...] = ()) -> list[JSONObject]:
17277
19577
  backend_items: list[JSONObject] = []
17278
19578
  for index, item in enumerate(items):
@@ -19662,8 +21962,13 @@ def _serialize_view_button_binding(
19662
21962
  binding: ViewButtonBindingPatch,
19663
21963
  current_fields_by_name: dict[str, dict[str, Any]],
19664
21964
  valid_custom_button_ids: set[int],
21965
+ allow_unverified_custom_button_id: bool = False,
19665
21966
  ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
19666
- if binding.button_type == PublicViewButtonType.custom and binding.button_id not in valid_custom_button_ids:
21967
+ if (
21968
+ binding.button_type == PublicViewButtonType.custom
21969
+ and binding.button_id not in valid_custom_button_ids
21970
+ and not allow_unverified_custom_button_id
21971
+ ):
19667
21972
  return {}, [
19668
21973
  {
19669
21974
  "error_code": "UNKNOWN_CUSTOM_BUTTON",
@@ -19785,6 +22090,7 @@ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
19785
22090
  "dash_key": dash_key or None,
19786
22091
  "dash_name": dash_name or None,
19787
22092
  "dash_icon": dash_icon,
22093
+ "icon_config": workspace_icon_config(dash_icon),
19788
22094
  "package_tag_ids": package_tag_ids,
19789
22095
  }
19790
22096
  )
@@ -20967,6 +23273,20 @@ def _associated_resource_patch_has_match_config(patch: AssociatedResourceUpsertP
20967
23273
  return bool(patch.match_mappings) or bool(patch.match_rules) or "match_mappings" in fields_set
20968
23274
 
20969
23275
 
23276
+ def _extract_associated_resource_id_from_result(result: Any) -> int | None:
23277
+ if isinstance(result, dict):
23278
+ for key in ("associated_item_id", "associatedItemId", "asosChartId", "id"):
23279
+ item_id = _coerce_positive_int(result.get(key))
23280
+ if item_id is not None:
23281
+ return item_id
23282
+ nested = result.get("result") or result.get("data")
23283
+ if nested is not None and nested is not result:
23284
+ return _extract_associated_resource_id_from_result(nested)
23285
+ if isinstance(result, list) and len(result) == 1:
23286
+ return _extract_associated_resource_id_from_result(result[0])
23287
+ return None
23288
+
23289
+
20970
23290
  def _serialize_associated_resource_match_rules(match_rules: list[Any]) -> list[list[dict[str, Any]]]:
20971
23291
  if not match_rules:
20972
23292
  return []