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

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 (59) hide show
  1. package/README.md +6 -3
  2. package/docs/local-agent-install.md +54 -3
  3. package/entry_point.py +1 -1
  4. package/npm/bin/qingflow-skills.mjs +5 -0
  5. package/npm/lib/runtime.mjs +304 -13
  6. package/npm/scripts/postinstall.mjs +1 -5
  7. package/package.json +3 -2
  8. package/pyproject.toml +1 -1
  9. package/skills/qingflow-app-builder/SKILL.md +12 -12
  10. package/skills/qingflow-app-builder/references/create-app.md +3 -3
  11. package/skills/qingflow-app-builder/references/environments.md +1 -1
  12. package/skills/qingflow-app-builder/references/gotchas.md +1 -1
  13. package/skills/qingflow-app-builder/references/public-surface-sync.md +75 -0
  14. package/skills/qingflow-app-builder/references/tool-selection.md +6 -5
  15. package/skills/qingflow-app-builder/references/update-views.md +1 -1
  16. package/skills/qingflow-app-builder-code-integrations/SKILL.md +3 -3
  17. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +1 -1
  18. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +1 -1
  19. package/src/qingflow_mcp/__main__.py +6 -2
  20. package/src/qingflow_mcp/builder_facade/models.py +11 -0
  21. package/src/qingflow_mcp/builder_facade/service.py +1488 -288
  22. package/src/qingflow_mcp/cli/commands/builder.py +2 -2
  23. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  24. package/src/qingflow_mcp/cli/commands/imports.py +1 -1
  25. package/src/qingflow_mcp/cli/commands/record.py +91 -19
  26. package/src/qingflow_mcp/cli/context.py +0 -3
  27. package/src/qingflow_mcp/cli/formatters.py +206 -7
  28. package/src/qingflow_mcp/cli/main.py +47 -3
  29. package/src/qingflow_mcp/errors.py +43 -2
  30. package/src/qingflow_mcp/public_surface.py +21 -15
  31. package/src/qingflow_mcp/response_trim.py +74 -13
  32. package/src/qingflow_mcp/server.py +11 -9
  33. package/src/qingflow_mcp/server_app_builder.py +3 -2
  34. package/src/qingflow_mcp/server_app_user.py +19 -13
  35. package/src/qingflow_mcp/session_store.py +11 -7
  36. package/src/qingflow_mcp/solution/executor.py +112 -15
  37. package/src/qingflow_mcp/tools/ai_builder_tools.py +36 -11
  38. package/src/qingflow_mcp/tools/app_tools.py +184 -43
  39. package/src/qingflow_mcp/tools/approval_tools.py +196 -34
  40. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  41. package/src/qingflow_mcp/tools/code_block_tools.py +298 -40
  42. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  43. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  44. package/src/qingflow_mcp/tools/export_tools.py +244 -34
  45. package/src/qingflow_mcp/tools/file_tools.py +7 -3
  46. package/src/qingflow_mcp/tools/import_tools.py +336 -49
  47. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  48. package/src/qingflow_mcp/tools/package_tools.py +118 -6
  49. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  50. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  51. package/src/qingflow_mcp/tools/record_tools.py +1067 -349
  52. package/src/qingflow_mcp/tools/resource_read_tools.py +188 -39
  53. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  54. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  55. package/src/qingflow_mcp/tools/task_context_tools.py +569 -119
  56. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  57. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  58. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  59. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
@@ -14,7 +14,7 @@ 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
@@ -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,6 +982,8 @@ 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
989
  def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
@@ -927,6 +1035,14 @@ class AiBuilderFacade:
927
1035
  current_detail_result = self.packages.package_get(profile=profile, tag_id=package_id, include_raw=True)
928
1036
  except (QingflowApiError, RuntimeError) as detail_error:
929
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
+ )
930
1046
  try:
931
1047
  current_base_result = self.packages.package_get_base(profile=profile, tag_id=package_id, include_raw=True)
932
1048
  except (QingflowApiError, RuntimeError) as base_error:
@@ -1007,7 +1123,9 @@ class AiBuilderFacade:
1007
1123
  permission_outcomes: list[PermissionCheckOutcome] = []
1008
1124
  needs_group_create = any(_coerce_positive_int(group.get("group_id")) is None for group in desired_groups)
1009
1125
  needs_group_delete = bool(deleted_group_ids)
1010
- 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
1011
1129
  for required_permission in (
1012
1130
  (["add_app"] if needs_group_create else [])
1013
1131
  + (["edit_app"] if needs_edit_app else [])
@@ -1105,13 +1223,47 @@ class AiBuilderFacade:
1105
1223
  except (QingflowApiError, RuntimeError) as error:
1106
1224
  api_error = _coerce_api_error(error)
1107
1225
  return _apply_permission_outcomes(
1108
- _failed_from_api_error(
1109
- "PACKAGE_GROUP_DELETE_FAILED",
1110
- api_error,
1111
- normalized_args=normalized_args,
1112
- details={"package_id": package_id, "group_id": group_id},
1113
- suggested_next_call=None,
1114
- ),
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
+ },
1115
1267
  *permission_outcomes,
1116
1268
  )
1117
1269
  group_operations.append({"action": "delete", "group_id": group_id})
@@ -1166,6 +1318,25 @@ class AiBuilderFacade:
1166
1318
  details={"query": requested},
1167
1319
  suggested_next_call={"tool_name": "member_search", "arguments": {"profile": profile, **normalized_args}},
1168
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
+ )
1169
1340
  items = []
1170
1341
  for item in _extract_directory_items(listed):
1171
1342
  uid = _coerce_positive_int(item.get("uid") or item.get("id"))
@@ -1211,6 +1382,23 @@ class AiBuilderFacade:
1211
1382
  details={"keyword": requested},
1212
1383
  suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, **normalized_args}},
1213
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
+ )
1214
1402
  page = listed.get("page") if isinstance(listed.get("page"), dict) else {}
1215
1403
  raw_items = page.get("list") if isinstance(page.get("list"), list) else []
1216
1404
  items = []
@@ -1283,6 +1471,8 @@ class AiBuilderFacade:
1283
1471
  "role_id": exact[0]["role_id"],
1284
1472
  "role_name": exact[0]["role_name"],
1285
1473
  "role_icon": exact[0].get("role_icon"),
1474
+ "write_executed": False,
1475
+ "safe_to_retry": True,
1286
1476
  }
1287
1477
  if len(exact) > 1:
1288
1478
  return _failed(
@@ -1343,6 +1533,8 @@ class AiBuilderFacade:
1343
1533
  "role_id": role_id,
1344
1534
  "role_name": requested_name,
1345
1535
  "role_icon": normalized_args["role_icon"],
1536
+ "write_executed": True,
1537
+ "safe_to_retry": False,
1346
1538
  }
1347
1539
 
1348
1540
  def _resolve_role_references(
@@ -1373,6 +1565,18 @@ class AiBuilderFacade:
1373
1565
  if not requested:
1374
1566
  continue
1375
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
1376
1580
  items = matches_result.get("items", []) if matches_result.get("status") == "success" else []
1377
1581
  exact = [item for item in items if isinstance(item, dict) and item.get("role_name") == requested]
1378
1582
  if len(exact) != 1:
@@ -1434,6 +1638,18 @@ class AiBuilderFacade:
1434
1638
  if not requested:
1435
1639
  continue
1436
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
1437
1653
  items = matches.get("items", []) if matches.get("status") == "success" else []
1438
1654
  exact = [item for item in items if isinstance(item, dict) and str(item.get("email") or "").strip().lower() == requested.lower()]
1439
1655
  if len(exact) != 1:
@@ -1453,6 +1669,18 @@ class AiBuilderFacade:
1453
1669
  if not requested:
1454
1670
  continue
1455
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
1456
1684
  items = matches.get("items", []) if matches.get("status") == "success" else []
1457
1685
  exact = [item for item in items if isinstance(item, dict) and str(item.get("name") or "").strip() == requested]
1458
1686
  if len(exact) != 1:
@@ -1481,23 +1709,6 @@ class AiBuilderFacade:
1481
1709
  seen_ids: set[int] = set()
1482
1710
  if not dept_ids and not dept_names:
1483
1711
  return {"department_entries": resolved, "issues": issues}
1484
- listed = self.directory.directory_list_all_departments(
1485
- profile=profile,
1486
- parent_dept_id=None,
1487
- max_depth=20,
1488
- max_items=5000,
1489
- )
1490
- items = _extract_directory_items(listed)
1491
- by_id: dict[int, dict[str, Any]] = {}
1492
- by_name: dict[str, list[dict[str, Any]]] = {}
1493
- for item in items:
1494
- dept_id = _coerce_positive_int(item.get("deptId") or item.get("id"))
1495
- if dept_id is None:
1496
- continue
1497
- by_id[dept_id] = item
1498
- dept_name = str(item.get("deptName") or item.get("departName") or item.get("name") or "").strip()
1499
- if dept_name:
1500
- by_name.setdefault(dept_name, []).append(item)
1501
1712
 
1502
1713
  def add_department(item: dict[str, Any], *, fallback_name: str | None = None) -> None:
1503
1714
  dept_id = _coerce_positive_int(item.get("deptId") or item.get("id"))
@@ -1517,7 +1728,40 @@ class AiBuilderFacade:
1517
1728
  normalized = _coerce_positive_int(dept_id)
1518
1729
  if normalized is None:
1519
1730
  continue
1520
- 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)
1521
1765
 
1522
1766
  for dept_name in dept_names:
1523
1767
  requested = str(dept_name or "").strip()
@@ -1584,6 +1828,18 @@ class AiBuilderFacade:
1584
1828
  page_size=100,
1585
1829
  simple=True,
1586
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
1587
1843
  items = _extract_directory_items(listed)
1588
1844
  exact = [
1589
1845
  item
@@ -1618,6 +1874,9 @@ class AiBuilderFacade:
1618
1874
  elif error_code.endswith("_NOT_FOUND"):
1619
1875
  public_code = "VISIBILITY_SUBJECT_NOT_FOUND"
1620
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"
1621
1880
  else:
1622
1881
  public_code = "VISIBILITY_SUBJECT_UNSUPPORTED"
1623
1882
  message = f"{kind} visibility selector is unsupported"
@@ -2033,6 +2292,9 @@ class AiBuilderFacade:
2033
2292
  "tag_id": tag_id,
2034
2293
  "tag_ids_after": tag_ids_after,
2035
2294
  "attached": attached,
2295
+ "write_executed": not already_attached,
2296
+ "write_succeeded": not already_attached or attached,
2297
+ "safe_to_retry": bool(already_attached),
2036
2298
  }
2037
2299
  if verification_error is not None:
2038
2300
  response["details"]["verification_error"] = _transport_error_payload(verification_error)
@@ -2145,6 +2407,8 @@ class AiBuilderFacade:
2145
2407
  "verification": {"released": True},
2146
2408
  "app_key": app_key,
2147
2409
  "released": True,
2410
+ "write_executed": True,
2411
+ "safe_to_retry": False,
2148
2412
  }
2149
2413
 
2150
2414
  def app_resolve(
@@ -2225,11 +2489,20 @@ class AiBuilderFacade:
2225
2489
  if not requested:
2226
2490
  return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", suggested_next_call=None)
2227
2491
  if package_tag_id is not None and package_tag_id > 0:
2228
- package_matches = self._resolve_app_matches_in_package(
2229
- profile=profile,
2230
- app_name=requested,
2231
- package_tag_id=package_tag_id,
2232
- )
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
+ )
2233
2506
  if len(package_matches) == 1:
2234
2507
  match = package_matches[0]
2235
2508
  return {
@@ -2254,12 +2527,50 @@ class AiBuilderFacade:
2254
2527
  details={"app_name": requested, "package_tag_id": package_tag_id, "matches": package_matches},
2255
2528
  suggested_next_call=None,
2256
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
+ )
2257
2568
  search_error: QingflowApiError | None = None
2258
2569
  try:
2259
2570
  search = self.apps.app_search(profile=profile, keyword=requested, page_num=1, page_size=200)
2260
2571
  except (QingflowApiError, RuntimeError) as exc:
2261
2572
  api_error = _coerce_api_error(exc)
2262
- 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}:
2263
2574
  return _failed_from_api_error(
2264
2575
  "APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
2265
2576
  api_error,
@@ -2268,6 +2579,7 @@ class AiBuilderFacade:
2268
2579
  )
2269
2580
  search = {}
2270
2581
  search_error = api_error
2582
+ search_permission_blocked = _search_permission_blocked_from_warnings(search) if isinstance(search, dict) else None
2271
2583
  apps = search.get("apps") if isinstance(search.get("apps"), list) else []
2272
2584
  matches = []
2273
2585
  for item in apps:
@@ -2286,8 +2598,16 @@ class AiBuilderFacade:
2286
2598
  if package_tag_id is not None and package_tag_id > 0:
2287
2599
  try:
2288
2600
  base = self.apps.app_get_base(profile=profile, app_key=candidate_key, include_raw=True)
2289
- except (QingflowApiError, RuntimeError):
2290
- 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
+ )
2291
2611
  result = base.get("result") if isinstance(base.get("result"), dict) else {}
2292
2612
  resolved_tag_ids = _coerce_int_list(result.get("tagIds"))
2293
2613
  if resolved_tag_ids:
@@ -2301,12 +2621,7 @@ class AiBuilderFacade:
2301
2621
  "tag_ids": tag_ids,
2302
2622
  }
2303
2623
  )
2304
- if not matches and package_tag_id is not None and package_tag_id > 0 and search_error is not None:
2305
- visible_matches = self._resolve_app_matches_in_visible_apps(
2306
- profile=profile,
2307
- app_name=requested,
2308
- package_tag_id=package_tag_id,
2309
- )
2624
+ if not matches and search_error is not None:
2310
2625
  if len(visible_matches) == 1:
2311
2626
  match = visible_matches[0]
2312
2627
  return {
@@ -2366,6 +2681,14 @@ class AiBuilderFacade:
2366
2681
  if search_error is not None
2367
2682
  else {}
2368
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
+ ),
2369
2692
  },
2370
2693
  suggested_next_call=None,
2371
2694
  )
@@ -2551,21 +2874,45 @@ class AiBuilderFacade:
2551
2874
  normalized_args = request.model_dump(mode="json")
2552
2875
  app_key = request.app_key
2553
2876
  permission_outcomes: list[PermissionCheckOutcome] = []
2554
- permission_outcome = self._guard_app_permission(
2555
- profile=profile,
2556
- app_key=app_key,
2557
- required_permission="edit_app",
2558
- normalized_args=normalized_args,
2559
- )
2560
- if permission_outcome.block is not None:
2561
- return permission_outcome.block
2562
- 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)
2563
2898
 
2564
2899
  def finalize(response: JSONObject) -> JSONObject:
2565
2900
  return _apply_permission_outcomes(response, *permission_outcomes)
2566
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
+ )
2567
2910
  try:
2568
- 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
+ )
2569
2916
  except (QingflowApiError, RuntimeError) as error:
2570
2917
  api_error = _coerce_api_error(error)
2571
2918
  return finalize(_failed_from_api_error(
@@ -2796,19 +3143,22 @@ class AiBuilderFacade:
2796
3143
  )
2797
3144
  )
2798
3145
 
2799
- edit_version_no, edit_context_error = self._ensure_app_edit_context(
2800
- profile=profile,
2801
- app_key=app_key,
2802
- normalized_args=normalized_args,
2803
- failure_code="CUSTOM_BUTTON_APPLY_FAILED",
2804
- )
2805
- if edit_context_error is not None:
2806
- return finalize(edit_context_error)
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)
2807
3156
 
2808
3157
  created: list[dict[str, Any]] = []
2809
3158
  updated: list[dict[str, Any]] = []
2810
3159
  removed: list[dict[str, Any]] = []
2811
3160
  failed: list[dict[str, Any]] = []
3161
+ readback_errors: list[JSONObject] = []
2812
3162
  client_key_map: dict[str, int] = {}
2813
3163
  write_executed = False
2814
3164
 
@@ -2831,7 +3181,15 @@ class AiBuilderFacade:
2831
3181
  ]
2832
3182
  if len(matches) == 1:
2833
3183
  button_id = _coerce_positive_int(matches[0].get("button_id"))
2834
- 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
+ )
2835
3193
  button_id = None
2836
3194
  entry = {
2837
3195
  "index": op["index"],
@@ -2919,13 +3277,20 @@ class AiBuilderFacade:
2919
3277
  }
2920
3278
  )
2921
3279
 
2922
- needs_button_list_readback = bool(created or updated or request.view_configs)
3280
+ needs_button_list_readback = bool(created or updated or (request.view_configs and needs_button_inventory))
2923
3281
  readback_buttons: list[dict[str, Any]] = []
2924
3282
  readback_failed = False
2925
3283
  if needs_button_list_readback:
2926
3284
  try:
2927
3285
  readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
2928
- except (QingflowApiError, RuntimeError):
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
+ )
2929
3294
  readback_failed = True
2930
3295
  readback_ids = {
2931
3296
  button_id
@@ -3018,6 +3383,13 @@ class AiBuilderFacade:
3018
3383
  "custom button delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
3019
3384
  )
3020
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
+ )
3021
3393
  response = {
3022
3394
  "status": status,
3023
3395
  "error_code": error_code,
@@ -3033,6 +3405,7 @@ class AiBuilderFacade:
3033
3405
  "edit_version_no": edit_version_no,
3034
3406
  "button_ids_by_client_key": client_key_map,
3035
3407
  "readback_failed": readback_failed,
3408
+ **({"readback_errors": readback_errors} if readback_errors else {}),
3036
3409
  "compiled_match_rules": {
3037
3410
  str(index): _summarize_compiled_match_rules(config.get("que_relation") or [])
3038
3411
  for index, config in compiled_add_data_configs.items()
@@ -3066,7 +3439,15 @@ class AiBuilderFacade:
3066
3439
  "write_succeeded": write_succeeded,
3067
3440
  "safe_to_retry": not write_executed,
3068
3441
  }
3069
- 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
+ )
3070
3451
 
3071
3452
  def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
3072
3453
  normalized_args = {"app_key": app_key}
@@ -3235,6 +3616,9 @@ class AiBuilderFacade:
3235
3616
  "verified": False,
3236
3617
  "app_key": app_key,
3237
3618
  "button_id": button_id,
3619
+ "write_executed": True,
3620
+ "write_succeeded": True,
3621
+ "safe_to_retry": False,
3238
3622
  }
3239
3623
  if _is_permission_restricted_api_error(api_error):
3240
3624
  response = _apply_permission_outcomes(
@@ -3348,6 +3732,9 @@ class AiBuilderFacade:
3348
3732
  "verified": False,
3349
3733
  "app_key": app_key,
3350
3734
  "button_id": button_id,
3735
+ "write_executed": True,
3736
+ "write_succeeded": True,
3737
+ "safe_to_retry": False,
3351
3738
  }
3352
3739
  if _is_permission_restricted_api_error(api_error):
3353
3740
  response = _apply_permission_outcomes(
@@ -3686,15 +4073,32 @@ class AiBuilderFacade:
3686
4073
  normalized_args = request.model_dump(mode="json", exclude_none=True)
3687
4074
  app_key = request.app_key
3688
4075
  permission_outcomes: list[PermissionCheckOutcome] = []
3689
- permission_outcome = self._guard_app_permission(
3690
- profile=profile,
3691
- app_key=app_key,
3692
- required_permission="data_manage",
3693
- 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
3694
4081
  )
3695
- if permission_outcome.block is not None:
3696
- return permission_outcome.block
3697
- 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)
3698
4102
 
3699
4103
  def finalize(response: JSONObject) -> JSONObject:
3700
4104
  return _apply_permission_outcomes(response, *permission_outcomes)
@@ -3957,14 +4361,16 @@ class AiBuilderFacade:
3957
4361
  }
3958
4362
  return finalize(response)
3959
4363
 
3960
- edit_version_no, edit_context_error = self._ensure_app_edit_context(
3961
- profile=profile,
3962
- app_key=app_key,
3963
- normalized_args=normalized_args,
3964
- failure_code="ASSOCIATED_RESOURCES_APPLY_FAILED",
3965
- )
3966
- if edit_context_error is not None:
3967
- 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)
3968
4374
 
3969
4375
  created: list[dict[str, Any]] = []
3970
4376
  updated: list[dict[str, Any]] = []
@@ -3973,6 +4379,8 @@ class AiBuilderFacade:
3973
4379
  reordered: list[int] = []
3974
4380
  view_config_results: list[dict[str, Any]] = []
3975
4381
  failed: list[dict[str, Any]] = []
4382
+ readback_errors: list[JSONObject] = []
4383
+ verification_errors: list[JSONObject] = []
3976
4384
  write_executed = False
3977
4385
 
3978
4386
  for op in upsert_ops:
@@ -3985,20 +4393,33 @@ class AiBuilderFacade:
3985
4393
  client_key_to_id[str(patch.client_key)] = item_id
3986
4394
  elif op["operation"] == "create":
3987
4395
  write_executed = True
3988
- self._associated_resource_create(
4396
+ create_result = self._associated_resource_create(
3989
4397
  profile=profile,
3990
4398
  app_key=app_key,
3991
4399
  patch=patch,
3992
4400
  match_rules_override=compiled_resource_match_rules.get(int(op["index"])),
3993
4401
  )
3994
- readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
3995
- matches = [
3996
- item
3997
- for item in readback_resources
3998
- if _associated_resource_matches_patch(item, patch)
3999
- and _coerce_positive_int(item.get("associated_item_id")) is not None
4000
- ]
4001
- 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
+ )
4002
4423
  created.append(_associated_resource_result_entry("create", op["index"], patch, associated_item_id=created_id))
4003
4424
  if created_id is not None and patch.client_key:
4004
4425
  client_key_to_id[str(patch.client_key)] = created_id
@@ -4064,7 +4485,8 @@ class AiBuilderFacade:
4064
4485
  try:
4065
4486
  resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4066
4487
  resources_after_loaded = True
4067
- except (QingflowApiError, RuntimeError):
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))})
4068
4490
  resources_after = []
4069
4491
  resources_after_readback_failed = True
4070
4492
 
@@ -4126,7 +4548,15 @@ class AiBuilderFacade:
4126
4548
  available_resources=refreshed_resources,
4127
4549
  )
4128
4550
  verified_config = _associated_resources_config_matches(expected_config, actual_config)
4129
- 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
+ )
4130
4560
  actual_config = {}
4131
4561
  verified_config = False
4132
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})
@@ -4139,7 +4569,8 @@ class AiBuilderFacade:
4139
4569
  if not resources_after_loaded and not resources_after_readback_failed:
4140
4570
  try:
4141
4571
  final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4142
- except (QingflowApiError, RuntimeError):
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))})
4143
4574
  readback_failed = True
4144
4575
  final_by_id = _associated_resource_index(final_resources)
4145
4576
  if removed:
@@ -4199,6 +4630,20 @@ class AiBuilderFacade:
4199
4630
  "associated resource delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
4200
4631
  )
4201
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
+ )
4202
4647
  response = {
4203
4648
  "status": status,
4204
4649
  "error_code": error_code,
@@ -4211,6 +4656,8 @@ class AiBuilderFacade:
4211
4656
  "edit_version_no": edit_version_no,
4212
4657
  "associated_item_ids_by_client_key": client_key_to_id,
4213
4658
  "readback_failed": readback_failed,
4659
+ **({"readback_errors": readback_errors} if readback_errors else {}),
4660
+ **({"verification_errors": verification_errors} if verification_errors else {}),
4214
4661
  "compiled_match_rules": {
4215
4662
  str(index): _summarize_compiled_match_rules(rules)
4216
4663
  for index, rules in compiled_resource_match_rules.items()
@@ -4246,11 +4693,39 @@ class AiBuilderFacade:
4246
4693
  "safe_to_retry": not write_executed,
4247
4694
  "associated_resources": final_resources,
4248
4695
  }
4249
- 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
+ )
4250
4703
  if response.get("published") and view_config_results:
4251
4704
  try:
4252
4705
  post_publish_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4253
- 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
+ )
4254
4729
  post_publish_resources = final_resources
4255
4730
  if post_publish_resources:
4256
4731
  response["associated_resources"] = post_publish_resources
@@ -4268,7 +4743,30 @@ class AiBuilderFacade:
4268
4743
  config if isinstance(config, dict) else {},
4269
4744
  available_resources=post_publish_resources,
4270
4745
  )
4271
- 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
+ )
4272
4770
  continue
4273
4771
  if _associated_resources_config_matches(expected_config, actual_config):
4274
4772
  result["status"] = "success"
@@ -4318,11 +4816,14 @@ class AiBuilderFacade:
4318
4816
  *,
4319
4817
  profile: str,
4320
4818
  app_name: str,
4321
- package_tag_id: int,
4819
+ package_tag_id: int | None,
4322
4820
  ) -> list[JSONObject]:
4323
4821
  try:
4324
4822
  listing = self.apps.app_list(profile=profile, ship_auth=False)
4325
- 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
4326
4827
  return []
4327
4828
  items = listing.get("items") if isinstance(listing.get("items"), list) else []
4328
4829
  matches: list[JSONObject] = []
@@ -4340,7 +4841,7 @@ class AiBuilderFacade:
4340
4841
  tag_id = _coerce_positive_int(item.get("tag_id"))
4341
4842
  if tag_id is not None and tag_id not in tag_ids:
4342
4843
  tag_ids.append(tag_id)
4343
- 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:
4344
4845
  continue
4345
4846
  seen_app_keys.add(candidate_key)
4346
4847
  matches.append({"app_key": candidate_key, "app_name": title, "tag_ids": tag_ids})
@@ -4355,7 +4856,10 @@ class AiBuilderFacade:
4355
4856
  ) -> list[JSONObject]:
4356
4857
  try:
4357
4858
  package_result = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
4358
- 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
4359
4863
  return []
4360
4864
  raw_package = package_result.get("result") if isinstance(package_result.get("result"), dict) else {}
4361
4865
  tag_items = raw_package.get("tagItems") if isinstance(raw_package.get("tagItems"), list) else []
@@ -4394,23 +4898,15 @@ class AiBuilderFacade:
4394
4898
  "tag_ids": _coerce_int_list(base.get("tagIds")),
4395
4899
  "can_edit_app": _coerce_optional_bool(base.get("editItemStatus")),
4396
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
+ ),
4397
4906
  "can_delete_app": _coerce_optional_bool(base.get("deleteItemStatus")),
4398
4907
  "can_copy_app": _coerce_optional_bool(base.get("copyAppStatus")),
4399
4908
  }
4400
4909
 
4401
- def _derive_can_edit_app_base(self, *, profile: str, permission_summary: JSONObject) -> bool:
4402
- if permission_summary.get("can_edit_app") is not True:
4403
- return False
4404
- tag_ids = _coerce_int_list(permission_summary.get("tag_ids"))
4405
- for tag_id in tag_ids:
4406
- try:
4407
- package_permission = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
4408
- except (QingflowApiError, RuntimeError):
4409
- return False
4410
- if package_permission.get("can_edit_tag") is not True:
4411
- return False
4412
- return True
4413
-
4414
4910
  def _read_portal_permission_summary(self, *, dash_key: str, portal_result: dict[str, Any]) -> JSONObject:
4415
4911
  tag_ids = _coerce_int_list(portal_result.get("tagIds"))
4416
4912
  if not tag_ids:
@@ -4435,42 +4931,45 @@ class AiBuilderFacade:
4435
4931
  app_key: str,
4436
4932
  required_permission: str,
4437
4933
  normalized_args: JSONObject,
4934
+ permission_summary: JSONObject | None = None,
4438
4935
  ) -> PermissionCheckOutcome:
4439
- try:
4440
- permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
4441
- except (QingflowApiError, RuntimeError) as error:
4442
- api_error = _coerce_api_error(error)
4443
- if _is_permission_restricted_api_error(api_error):
4444
- return _permission_skip_outcome(
4445
- scope="app",
4446
- target={"app_key": app_key},
4447
- required_permission=required_permission,
4448
- transport_error=_transport_error_payload(api_error),
4449
- )
4450
- return PermissionCheckOutcome(
4451
- block=_failed(
4452
- "APP_PERMISSION_UNVERIFIED",
4453
- "could not confirm current user's builder permissions for this app",
4454
- normalized_args=normalized_args,
4455
- details={
4456
- "app_key": app_key,
4457
- "required_permission": required_permission,
4458
- "permission_read_error": {
4459
- "message": api_error.message,
4460
- "http_status": api_error.http_status,
4461
- "backend_code": api_error.backend_code,
4462
- "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
+ },
4463
4962
  },
4464
- },
4465
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
4466
- request_id=api_error.request_id,
4467
- backend_code=api_error.backend_code,
4468
- 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
+ )
4469
4968
  )
4470
- )
4471
4969
  permission_key = {
4472
4970
  "edit_app": "can_edit_app",
4473
4971
  "data_manage": "can_manage_data",
4972
+ "view_manage": "can_manage_views",
4474
4973
  }.get(required_permission)
4475
4974
  if permission_key is None:
4476
4975
  return PermissionCheckOutcome()
@@ -4484,12 +4983,15 @@ class AiBuilderFacade:
4484
4983
  )
4485
4984
  if permission_value is not False:
4486
4985
  return PermissionCheckOutcome()
4487
- error_code = "EDIT_APP_UNAUTHORIZED" if required_permission == "edit_app" else "DATA_MANAGE_UNAUTHORIZED"
4488
- message = (
4489
- "current user does not have builder edit-app permission on this app"
4490
- if required_permission == "edit_app"
4491
- else "current user does not have data-management permission on this app"
4492
- )
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"
4493
4995
  return PermissionCheckOutcome(
4494
4996
  block=_failed(
4495
4997
  error_code,
@@ -4652,38 +5154,78 @@ class AiBuilderFacade:
4652
5154
  details={"app_key": app_key},
4653
5155
  suggested_next_call={"tool_name": "app_resolve", "arguments": {"profile": profile, "app_key": app_key}},
4654
5156
  )
4655
- views, views_unavailable = self._load_views_result(
5157
+ views, views_unavailable, views_read_error = self._load_views_result(
4656
5158
  profile=profile,
4657
5159
  app_key=app_key,
4658
5160
  tolerate_404=True,
4659
5161
  tolerate_permission_restricted=True,
5162
+ include_error=True,
4660
5163
  )
4661
5164
  view_summaries = _summarize_views(views)
5165
+ readback_errors: list[JSONObject] = []
4662
5166
  charts_unavailable = False
4663
5167
  try:
4664
5168
  chart_items, _chart_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
4665
5169
  chart_summaries = _summarize_charts(chart_items)
4666
- except (QingflowApiError, RuntimeError):
5170
+ except (QingflowApiError, RuntimeError) as error:
4667
5171
  charts_unavailable = True
4668
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
+ )
4669
5180
  associated_resources_unavailable = False
4670
5181
  try:
4671
5182
  associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4672
- except (QingflowApiError, RuntimeError):
5183
+ except (QingflowApiError, RuntimeError) as error:
4673
5184
  associated_resources_unavailable = True
4674
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
+ )
4675
5193
  custom_buttons_unavailable = False
4676
5194
  try:
4677
5195
  custom_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
4678
- except (QingflowApiError, RuntimeError):
5196
+ except (QingflowApiError, RuntimeError) as error:
4679
5197
  custom_buttons_unavailable = True
4680
5198
  custom_buttons = []
4681
- 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(
4682
5207
  profile=profile,
4683
5208
  app_key=app_key,
4684
5209
  tolerate_404=True,
4685
5210
  tolerate_permission_restricted=True,
5211
+ include_error=True,
4686
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
+ )
4687
5229
  verification_hints = _build_verification_hints(
4688
5230
  tag_ids=_coerce_int_list(base_result.get("tagIds")),
4689
5231
  fields=parsed["fields"],
@@ -4751,7 +5293,7 @@ class AiBuilderFacade:
4751
5293
  "normalized_args": {"app_key": app_key},
4752
5294
  "missing_fields": [],
4753
5295
  "allowed_values": {},
4754
- "details": {},
5296
+ "details": {"readback_errors": readback_errors} if readback_errors else {},
4755
5297
  "request_id": None,
4756
5298
  "suggested_next_call": None,
4757
5299
  "noop": False,
@@ -4780,13 +5322,54 @@ class AiBuilderFacade:
4780
5322
  if not result.get("suggested_next_call"):
4781
5323
  result["suggested_next_call"] = {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}}
4782
5324
  return result
4783
- 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
4784
5367
  result["message"] = "read app config summary"
4785
5368
  result["editability"] = {
4786
- "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"),
4787
5370
  "can_edit_form": permission_summary.get("can_edit_app"),
4788
- "can_edit_flow": permission_summary.get("can_manage_data"),
4789
- "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"),
4790
5373
  "can_edit_charts": permission_summary.get("can_manage_data"),
4791
5374
  }
4792
5375
  return result
@@ -4994,6 +5577,8 @@ class AiBuilderFacade:
4994
5577
  "would_update": bool(update_fields),
4995
5578
  },
4996
5579
  "verified": True,
5580
+ "write_executed": False,
5581
+ "safe_to_retry": True,
4997
5582
  "app_key": app_key,
4998
5583
  "apply": False,
4999
5584
  "repair_plan": plans,
@@ -5020,6 +5605,8 @@ class AiBuilderFacade:
5020
5605
  "applied": False,
5021
5606
  },
5022
5607
  "verified": True,
5608
+ "write_executed": False,
5609
+ "safe_to_retry": True,
5023
5610
  "app_key": app_key,
5024
5611
  "apply": True,
5025
5612
  "repair_plan": plans,
@@ -5037,6 +5624,7 @@ class AiBuilderFacade:
5037
5624
  )
5038
5625
  if apply_result.get("status") == "failed":
5039
5626
  return apply_result
5627
+ verification_error: JSONObject | None = None
5040
5628
  try:
5041
5629
  reread = self._load_base_schema_state(profile=profile, app_key=app_key)
5042
5630
  verified_fields = cast(list[dict[str, Any]], reread["parsed"].get("fields") or [])
@@ -5057,17 +5645,36 @@ class AiBuilderFacade:
5057
5645
  if plan["would_update"]:
5058
5646
  plan["applied"] = True
5059
5647
  applied_fields.append(plan["field_name"])
5060
- except (QingflowApiError, RuntimeError):
5061
- pass
5648
+ except (QingflowApiError, RuntimeError) as error:
5649
+ verification_error = _transport_error_payload(_coerce_api_error(error))
5062
5650
  apply_result["message"] = "repaired code block fields"
5063
5651
  apply_result["apply"] = True
5064
5652
  apply_result["repair_plan"] = plans
5065
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
+ ]
5066
5672
  apply_result["verification"] = {
5067
5673
  **(apply_result.get("verification") if isinstance(apply_result.get("verification"), dict) else {}),
5068
5674
  "code_block_fields_scanned": len(plans),
5069
5675
  "would_update": bool(update_fields),
5070
5676
  "applied": bool(applied_fields),
5677
+ **({"code_block_repair_verification_unavailable": True} if verification_error is not None else {}),
5071
5678
  }
5072
5679
  return apply_result
5073
5680
 
@@ -5120,6 +5727,17 @@ class AiBuilderFacade:
5120
5727
  )
5121
5728
  except (QingflowApiError, RuntimeError) as error:
5122
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
+ )
5123
5741
  warnings.append(
5124
5742
  _warning(
5125
5743
  "QINGBI_FIELDS_READ_FAILED",
@@ -5388,13 +6006,23 @@ class AiBuilderFacade:
5388
6006
  continue
5389
6007
  try:
5390
6008
  portal_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
5391
- 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
+ )
5392
6019
  permission_verified = False
5393
6020
  warnings.append(
5394
6021
  _warning(
5395
6022
  "PORTAL_PERMISSION_READ_UNAVAILABLE",
5396
6023
  f"builder portal_list skipped `{dash_key}` because portal detail readback was unavailable during permission verification",
5397
6024
  dash_key=dash_key,
6025
+ **_transport_error_payload(api_error),
5398
6026
  )
5399
6027
  )
5400
6028
  continue
@@ -5446,8 +6074,10 @@ class AiBuilderFacade:
5446
6074
  sorted_items = self.charts.qingbi_report_list_sorted(profile=profile, app_key=app_key, page_num=1, page_size=500).get("items") or []
5447
6075
  if isinstance(sorted_items, list):
5448
6076
  return sorted_items, "sorted"
5449
- except (QingflowApiError, RuntimeError):
5450
- 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
5451
6081
  fallback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
5452
6082
  return list(fallback_items) if isinstance(fallback_items, list) else [], "fallback"
5453
6083
 
@@ -5890,6 +6520,7 @@ class AiBuilderFacade:
5890
6520
  if button_id is not None:
5891
6521
  button_inventory[button_id] = item
5892
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)
5893
6524
  write_executed = False
5894
6525
  write_succeeded = False
5895
6526
  all_verified = True
@@ -5941,6 +6572,7 @@ class AiBuilderFacade:
5941
6572
  button_inventory=button_inventory,
5942
6573
  valid_custom_button_ids=valid_custom_button_ids,
5943
6574
  reason_path=f"view_configs[{config_index}].buttons[{button_index}].button_ref",
6575
+ allow_unverified_numeric_id=allow_unverified_numeric_button_ids,
5944
6576
  )
5945
6577
  if ref_issue:
5946
6578
  config_issues.append(ref_issue)
@@ -5952,6 +6584,7 @@ class AiBuilderFacade:
5952
6584
  binding=view_binding,
5953
6585
  current_fields_by_name=current_fields_by_name,
5954
6586
  valid_custom_button_ids=valid_custom_button_ids,
6587
+ allow_unverified_custom_button_id=allow_unverified_numeric_button_ids,
5955
6588
  )
5956
6589
  if binding_issues:
5957
6590
  config_issues.extend(binding_issues)
@@ -6249,6 +6882,7 @@ class AiBuilderFacade:
6249
6882
  )
6250
6883
 
6251
6884
  warnings: list[dict[str, Any]] = []
6885
+ readback_errors: list[JSONObject] = []
6252
6886
  verification = {
6253
6887
  "view_exists": True,
6254
6888
  "base_info_verified": True,
@@ -6259,39 +6893,133 @@ class AiBuilderFacade:
6259
6893
 
6260
6894
  base_info: dict[str, Any] = {}
6261
6895
  try:
6262
- 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 {}
6263
6898
  if isinstance(base_info_payload, dict):
6264
6899
  base_info = deepcopy(base_info_payload)
6265
- 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:
6266
6925
  verification["base_info_verified"] = False
6267
- 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)))
6268
6944
 
6269
6945
  questions: list[dict[str, Any]] = []
6270
6946
  try:
6271
6947
  questions_payload = self.views.view_list_questions(profile=profile, viewgraph_key=view_key).get("result") or []
6272
6948
  if isinstance(questions_payload, list):
6273
6949
  questions = [deepcopy(item) for item in questions_payload if isinstance(item, dict)]
6274
- except (QingflowApiError, RuntimeError):
6950
+ except (QingflowApiError, RuntimeError) as error:
6275
6951
  verification["questions_verified"] = False
6276
- 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)))
6277
6970
 
6278
6971
  associations: list[dict[str, Any]] = []
6279
6972
  try:
6280
6973
  associations_payload = self.views.view_list_associations(profile=profile, viewgraph_key=view_key).get("result") or []
6281
6974
  if isinstance(associations_payload, list):
6282
6975
  associations = [deepcopy(item) for item in associations_payload if isinstance(item, dict)]
6283
- except (QingflowApiError, RuntimeError):
6976
+ except (QingflowApiError, RuntimeError) as error:
6284
6977
  verification["associations_verified"] = False
6285
- 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)))
6286
6996
 
6287
6997
  app_key = str(_first_present(config, "appKey", "formKey") or _first_present(base_info, "appKey", "formKey") or "").strip()
6288
6998
  associated_resources: list[dict[str, Any]] = []
6289
6999
  if app_key:
6290
7000
  try:
6291
7001
  associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
6292
- except (QingflowApiError, RuntimeError):
7002
+ except (QingflowApiError, RuntimeError) as error:
6293
7003
  verification["associated_resources_verified"] = False
6294
- 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)))
6295
7023
  associated_resources_config = _extract_view_associated_resources_config(
6296
7024
  config if isinstance(config, dict) else {},
6297
7025
  available_resources=associated_resources,
@@ -6324,7 +7052,7 @@ class AiBuilderFacade:
6324
7052
  "normalized_args": {"view_key": view_key},
6325
7053
  "missing_fields": [],
6326
7054
  "allowed_values": {},
6327
- "details": {},
7055
+ "details": {"readback_errors": readback_errors} if readback_errors else {},
6328
7056
  "request_id": None,
6329
7057
  "suggested_next_call": None,
6330
7058
  "noop": False,
@@ -6361,15 +7089,34 @@ class AiBuilderFacade:
6361
7089
  )
6362
7090
 
6363
7091
  try:
6364
- 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)
6365
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
+ )
6366
7111
  fallback_config: dict[str, Any] | None = None
7112
+ fallback_api_error: QingflowApiError | None = None
6367
7113
  try:
6368
7114
  data_fallback = self.charts.qingbi_report_get_data(profile=profile, chart_id=chart_id, payload={}).get("result") or {}
6369
7115
  config_from_data = data_fallback.get("config") if isinstance(data_fallback, dict) else None
6370
7116
  if isinstance(config_from_data, dict):
6371
7117
  fallback_config = deepcopy(config_from_data)
6372
- except (QingflowApiError, RuntimeError):
7118
+ except (QingflowApiError, RuntimeError) as fallback_error:
7119
+ fallback_api_error = _coerce_api_error(fallback_error)
6373
7120
  fallback_config = None
6374
7121
  if isinstance(fallback_config, dict):
6375
7122
  config = fallback_config
@@ -6380,12 +7127,17 @@ class AiBuilderFacade:
6380
7127
  )
6381
7128
  )
6382
7129
  else:
6383
- 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)
6384
7136
  return _failed_from_api_error(
6385
7137
  "CHART_GET_FAILED",
6386
7138
  api_error,
6387
7139
  normalized_args={"chart_id": chart_id},
6388
- details={"chart_id": chart_id},
7140
+ details=details,
6389
7141
  suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": chart_id}},
6390
7142
  )
6391
7143
 
@@ -7097,16 +7849,6 @@ class AiBuilderFacade:
7097
7849
  if add_permission_outcome.block is not None:
7098
7850
  return add_permission_outcome.block
7099
7851
  permission_outcomes.append(add_permission_outcome)
7100
- if requested_field_changes:
7101
- edit_permission_outcome = self._guard_package_permission(
7102
- profile=profile,
7103
- tag_id=permission_tag_id,
7104
- required_permission="edit_app",
7105
- normalized_args=normalized_args,
7106
- )
7107
- if edit_permission_outcome.block is not None:
7108
- return edit_permission_outcome.block
7109
- permission_outcomes.append(edit_permission_outcome)
7110
7852
  resolved = self._create_target_app_shell(
7111
7853
  profile=profile,
7112
7854
  app_name=app_name,
@@ -7206,6 +7948,9 @@ class AiBuilderFacade:
7206
7948
  "created": True,
7207
7949
  "field_diff": {"added": [], "updated": [], "removed": []},
7208
7950
  "verified": True,
7951
+ "write_executed": True,
7952
+ "write_succeeded": True,
7953
+ "safe_to_retry": False,
7209
7954
  "tag_ids_after": list(target.tag_ids),
7210
7955
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
7211
7956
  "publish_requested": False,
@@ -7434,7 +8179,34 @@ class AiBuilderFacade:
7434
8179
  )
7435
8180
 
7436
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")):
7437
- 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
+ ))
7438
8210
  tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
7439
8211
  package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
7440
8212
  actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
@@ -7472,6 +8244,9 @@ class AiBuilderFacade:
7472
8244
  "created": False,
7473
8245
  "field_diff": {"added": [], "updated": [], "removed": []},
7474
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")),
7475
8250
  "tag_ids_after": tag_ids_after,
7476
8251
  "package_attached": package_attached,
7477
8252
  }
@@ -7509,7 +8284,7 @@ class AiBuilderFacade:
7509
8284
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
7510
8285
  except (QingflowApiError, RuntimeError) as error:
7511
8286
  api_error = _coerce_api_error(error)
7512
- if api_error.backend_code == 49614:
8287
+ if backend_code_int(api_error) == 49614:
7513
8288
  return _failed(
7514
8289
  "MULTIPLE_RELATION_FIELDS_UNSUPPORTED",
7515
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.",
@@ -7681,6 +8456,9 @@ class AiBuilderFacade:
7681
8456
  after_fields=current_fields,
7682
8457
  ),
7683
8458
  "verified": False,
8459
+ "write_executed": True,
8460
+ "write_succeeded": True,
8461
+ "safe_to_retry": False,
7684
8462
  "tag_ids_after": [],
7685
8463
  "package_attached": None,
7686
8464
  }
@@ -7786,8 +8564,7 @@ class AiBuilderFacade:
7786
8564
  response["details"] = details
7787
8565
  details["verification_error"] = {
7788
8566
  "message": verification_error.message,
7789
- "http_status": verification_error.http_status,
7790
- "backend_code": verification_error.backend_code,
8567
+ **_transport_error_payload(verification_error),
7791
8568
  }
7792
8569
  return finalize(response)
7793
8570
 
@@ -7924,6 +8701,9 @@ class AiBuilderFacade:
7924
8701
  "fallback_applied": None,
7925
8702
  },
7926
8703
  "verified": True,
8704
+ "write_executed": False,
8705
+ "write_succeeded": False,
8706
+ "safe_to_retry": True,
7927
8707
  }
7928
8708
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
7929
8709
  payload = _build_form_payload_from_existing_schema(
@@ -7941,7 +8721,7 @@ class AiBuilderFacade:
7941
8721
  self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=payload)
7942
8722
  except (QingflowApiError, RuntimeError) as error:
7943
8723
  api_error = _coerce_api_error(error)
7944
- if api_error.backend_code == 400 and target_layout.get("sections"):
8724
+ if backend_code_int(api_error) == 400 and target_layout.get("sections"):
7945
8725
  flattened_layout = _flatten_layout_sections(target_layout)
7946
8726
  fallback_payload = _build_form_payload_from_existing_schema(
7947
8727
  current_schema=schema_result,
@@ -7997,11 +8777,21 @@ class AiBuilderFacade:
7997
8777
  "normalized_args": normalized_args,
7998
8778
  "missing_fields": [],
7999
8779
  "allowed_values": {"modes": ["merge", "replace"]},
8000
- "details": {},
8001
- "request_id": None,
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,
8002
8784
  "suggested_next_call": {"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
8003
8785
  "noop": False,
8004
- "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
+ ],
8005
8795
  "verification": {"layout_verified": False, "layout_summary_verified": False, "layout_read_unavailable": True},
8006
8796
  "app_key": app_key,
8007
8797
  "app_name": app_name,
@@ -8013,8 +8803,10 @@ class AiBuilderFacade:
8013
8803
  "fallback_applied": fallback_applied,
8014
8804
  },
8015
8805
  "verified": False,
8806
+ "write_executed": True,
8807
+ "write_succeeded": True,
8808
+ "safe_to_retry": False,
8016
8809
  }
8017
- response["request_id"] = api_error.request_id
8018
8810
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
8019
8811
  verified_layout = _parse_schema(verified_schema)["layout"]
8020
8812
  layout_verified = _layouts_equal(verified_layout, applied_layout) or _layouts_semantically_equal(verified_layout, applied_layout)
@@ -8070,6 +8862,9 @@ class AiBuilderFacade:
8070
8862
  "fallback_applied": fallback_applied,
8071
8863
  },
8072
8864
  "verified": layout_verified,
8865
+ "write_executed": True,
8866
+ "write_succeeded": True,
8867
+ "safe_to_retry": False,
8073
8868
  }
8074
8869
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
8075
8870
 
@@ -8094,7 +8889,7 @@ class AiBuilderFacade:
8094
8889
  permission_outcome = self._guard_app_permission(
8095
8890
  profile=profile,
8096
8891
  app_key=app_key,
8097
- required_permission="data_manage",
8892
+ required_permission="edit_app",
8098
8893
  normalized_args=normalized_args,
8099
8894
  )
8100
8895
  if permission_outcome.block is not None:
@@ -8295,6 +9090,9 @@ class AiBuilderFacade:
8295
9090
  "app_name": app_name,
8296
9091
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
8297
9092
  "verified": workflow_verified,
9093
+ "write_executed": True,
9094
+ "write_succeeded": True,
9095
+ "safe_to_retry": False,
8298
9096
  }
8299
9097
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
8300
9098
 
@@ -8339,15 +9137,57 @@ class AiBuilderFacade:
8339
9137
  "app_key": app_key,
8340
9138
  "views_diff": {"created": [], "updated": [], "removed": []},
8341
9139
  "verified": True,
9140
+ "write_executed": False,
9141
+ "write_succeeded": False,
9142
+ "safe_to_retry": True,
8342
9143
  }
8343
9144
  return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
8344
9145
  permission_outcomes: list[PermissionCheckOutcome] = []
8345
- permission_outcome = self._guard_app_permission(
8346
- profile=profile,
8347
- app_key=app_key,
8348
- required_permission="data_manage",
8349
- normalized_args=normalized_args,
8350
- )
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
+ )
8351
9191
  if permission_outcome.block is not None:
8352
9192
  return permission_outcome.block
8353
9193
  permission_outcomes.append(permission_outcome)
@@ -8380,6 +9220,35 @@ class AiBuilderFacade:
8380
9220
  if name and key:
8381
9221
  existing_by_key[key] = view
8382
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)
8383
9252
  parsed_schema = _parse_schema(schema)
8384
9253
  field_names = {field["name"] for field in parsed_schema["fields"]}
8385
9254
  if patch_views:
@@ -8454,8 +9323,26 @@ class AiBuilderFacade:
8454
9323
  being_draft=True,
8455
9324
  include_raw=False,
8456
9325
  )
8457
- except (QingflowApiError, RuntimeError):
8458
- 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)
8459
9346
  detail_result = detail.get("result")
8460
9347
  if isinstance(detail_result, dict):
8461
9348
  custom_button_details_by_id[button_id] = _normalize_custom_button_detail(detail_result)
@@ -8930,7 +9817,7 @@ class AiBuilderFacade:
8930
9817
  except (QingflowApiError, RuntimeError) as error:
8931
9818
  api_error = _coerce_api_error(error)
8932
9819
  should_retry_minimal = operation_phase != "default_view_apply_config_sync" and (
8933
- api_error.backend_code == 48104
9820
+ backend_code_int(api_error) == 48104
8934
9821
  or (patch.type.value == "table" and bool(patch.filters) and api_error.http_status is not None and api_error.http_status >= 500)
8935
9822
  )
8936
9823
  if should_retry_minimal:
@@ -9526,6 +10413,9 @@ class AiBuilderFacade:
9526
10413
  "app_name": app_name,
9527
10414
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
9528
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),
9529
10419
  }
9530
10420
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
9531
10421
  warnings: list[dict[str, Any]] = []
@@ -9607,6 +10497,9 @@ class AiBuilderFacade:
9607
10497
  "app_name": app_name,
9608
10498
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
9609
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),
9610
10503
  }
9611
10504
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
9612
10505
 
@@ -9667,8 +10560,21 @@ class AiBuilderFacade:
9667
10560
  "tag_ids_after": tag_ids_before,
9668
10561
  "views_ok": True,
9669
10562
  "verified": True,
10563
+ "write_executed": False,
10564
+ "write_succeeded": False,
10565
+ "safe_to_retry": True,
9670
10566
  }
9671
- 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
+ )
9672
10578
  edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
9673
10579
  try:
9674
10580
  self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
@@ -9686,13 +10592,18 @@ class AiBuilderFacade:
9686
10592
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
9687
10593
  except (QingflowApiError, RuntimeError) as error:
9688
10594
  api_error = _coerce_api_error(error)
9689
- return _failed_from_api_error(
9690
- "APP_READ_FAILED",
9691
- api_error,
10595
+ result = _post_write_readback_pending_result(
10596
+ error_code="PUBLISH_READBACK_PENDING",
10597
+ message="published app; app base readback is unavailable",
9692
10598
  normalized_args=normalized_args,
9693
- details={"app_key": app_key},
10599
+ details={"app_key": app_key, "edit_version_no": edit_version_no, "readback_error": _transport_error_payload(api_error)},
9694
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,
9695
10604
  )
10605
+ result.update({"app_key": app_key, "published": None, "verified": False})
10606
+ return result
9696
10607
  tag_ids_after = _coerce_int_list(base.get("tagIds"))
9697
10608
  app_name_after = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_name_before or "").strip() or None
9698
10609
  package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
@@ -9700,13 +10611,18 @@ class AiBuilderFacade:
9700
10611
  views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
9701
10612
  except (QingflowApiError, RuntimeError) as error:
9702
10613
  api_error = _coerce_api_error(error)
9703
- return _failed_from_api_error(
9704
- "VIEWS_READ_FAILED",
9705
- api_error,
10614
+ result = _post_write_readback_pending_result(
10615
+ error_code="VIEWS_READBACK_PENDING",
10616
+ message="published app; views readback is unavailable",
9706
10617
  normalized_args=normalized_args,
9707
- details={"app_key": app_key},
10618
+ details={"app_key": app_key, "edit_version_no": edit_version_no, "readback_error": _transport_error_payload(api_error)},
9708
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,
9709
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
9710
10626
  views = views or []
9711
10627
  views_ok = isinstance(views, list) and not views_unavailable
9712
10628
  verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
@@ -9741,6 +10657,9 @@ class AiBuilderFacade:
9741
10657
  "tag_ids_after": tag_ids_after,
9742
10658
  "views_ok": views_ok,
9743
10659
  "verified": verified,
10660
+ "write_executed": True,
10661
+ "write_succeeded": True,
10662
+ "safe_to_retry": False,
9744
10663
  }
9745
10664
 
9746
10665
  def _expand_chart_partial_patches(
@@ -9904,7 +10823,15 @@ class AiBuilderFacade:
9904
10823
 
9905
10824
  def _verify_custom_button_deleted_by_id(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
9906
10825
  try:
9907
- self.buttons.custom_button_get(profile=profile, app_key=app_key, button_id=button_id, being_draft=True, include_raw=False)
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)
9908
10835
  except (QingflowApiError, RuntimeError) as error:
9909
10836
  api_error = _coerce_api_error(error)
9910
10837
  if _delete_readback_is_not_found(api_error):
@@ -10185,18 +11112,21 @@ class AiBuilderFacade:
10185
11112
  create_result = self.charts.qingbi_report_create(profile=profile, payload=create_payload).get("result") or {}
10186
11113
  created_chart_id = _extract_chart_identifier(create_result or {})
10187
11114
  if not created_chart_id:
10188
- refreshed_items, _ = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
10189
- refreshed_matches = _find_charts_by_name(
10190
- refreshed_items,
10191
- chart_name=patch.name,
10192
- chart_type=target_type,
10193
- )
10194
- if len(refreshed_matches) == 1:
10195
- created_chart_id = _extract_chart_identifier(refreshed_matches[0])
10196
- elif len(refreshed_matches) > 1:
10197
- raise ValueError(
10198
- 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,
10199
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
10200
11130
  if not created_chart_id:
10201
11131
  raise ValueError(
10202
11132
  f"created chart '{patch.name}' did not return a real chart_id and could not be confirmed from readback"
@@ -10352,10 +11282,13 @@ class AiBuilderFacade:
10352
11282
  chart_results.append(failure)
10353
11283
 
10354
11284
  noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
11285
+ write_executed = bool(created_ids or updated_ids or removed_ids or reordered)
11286
+ write_succeeded = write_executed
10355
11287
  needs_list_readback = bool(created_ids or updated_ids or reordered)
10356
11288
  delete_readback_unavailable = any(item.get("readback_status") == "unavailable" for item in delete_readback_issues)
10357
11289
  deletes_verified = not delete_readback_issues
10358
11290
  readback_unavailable = False
11291
+ readback_error: QingflowApiError | None = None
10359
11292
  readback_list_source: str | None = existing_chart_list_source
10360
11293
  if needs_list_readback:
10361
11294
  try:
@@ -10375,7 +11308,8 @@ class AiBuilderFacade:
10375
11308
  requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
10376
11309
  verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
10377
11310
  readback_unavailable = False
10378
- except (QingflowApiError, RuntimeError):
11311
+ except (QingflowApiError, RuntimeError) as error:
11312
+ readback_error = _coerce_api_error(error)
10379
11313
  verified = False
10380
11314
  readback_unavailable = True
10381
11315
  readback_list_source = None
@@ -10394,11 +11328,14 @@ class AiBuilderFacade:
10394
11328
  "normalized_args": normalized_args,
10395
11329
  "missing_fields": [],
10396
11330
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
10397
- "details": {"per_chart_results": chart_results},
10398
- "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),
10399
11336
  "suggested_next_call": {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
10400
- "backend_code": failed_items[0].get("backend_code"),
10401
- "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),
10402
11339
  "noop": noop,
10403
11340
  "warnings": _chart_apply_warnings(
10404
11341
  failed_items=failed_items,
@@ -10417,6 +11354,9 @@ class AiBuilderFacade:
10417
11354
  "app_name": app_name,
10418
11355
  "chart_results": chart_results,
10419
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,
10420
11360
  })
10421
11361
  result_verified = verified or noop
10422
11362
  pending_delete = bool(delete_readback_issues)
@@ -10435,9 +11375,11 @@ class AiBuilderFacade:
10435
11375
  "normalized_args": normalized_args,
10436
11376
  "missing_fields": [],
10437
11377
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
10438
- "details": {},
10439
- "request_id": None,
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,
10440
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,
10441
11383
  "noop": noop,
10442
11384
  "warnings": _chart_apply_warnings(
10443
11385
  failed_items=[],
@@ -10456,6 +11398,9 @@ class AiBuilderFacade:
10456
11398
  "app_name": app_name,
10457
11399
  "chart_results": chart_results,
10458
11400
  "verified": result_verified,
11401
+ "write_executed": write_executed,
11402
+ "write_succeeded": write_succeeded,
11403
+ "safe_to_retry": not write_executed,
10459
11404
  })
10460
11405
 
10461
11406
  def portal_apply(self, *, profile: str, request: PortalApplyRequest) -> JSONObject:
@@ -10536,15 +11481,6 @@ class AiBuilderFacade:
10536
11481
  if package_add_outcome.block is not None:
10537
11482
  return package_add_outcome.block
10538
11483
  permission_outcomes.append(package_add_outcome)
10539
- package_edit_outcome = self._guard_package_permission(
10540
- profile=profile,
10541
- tag_id=target_package_tag_id,
10542
- required_permission="edit_app",
10543
- normalized_args=normalized_args,
10544
- )
10545
- if package_edit_outcome.block is not None:
10546
- return package_edit_outcome.block
10547
- permission_outcomes.append(package_edit_outcome)
10548
11484
  if not sections_requested:
10549
11485
  unsupported_base_only_keys: list[str] = []
10550
11486
  if request.hide_copyright is not None:
@@ -10564,6 +11500,10 @@ class AiBuilderFacade:
10564
11500
  },
10565
11501
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10566
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] = {}
10567
11507
  try:
10568
11508
  layout_diagnostics: dict[str, Any] = _empty_portal_layout_diagnostics()
10569
11509
  if creating:
@@ -10579,6 +11519,7 @@ class AiBuilderFacade:
10579
11519
  base_payload=None,
10580
11520
  )
10581
11521
  create_result = self.portals.portal_create(profile=profile, payload=create_payload)
11522
+ write_executed = True
10582
11523
  created = create_result.get("result") if isinstance(create_result.get("result"), dict) else {}
10583
11524
  dash_key = str(created.get("dashKey") or "")
10584
11525
  if not dash_key:
@@ -10589,7 +11530,15 @@ class AiBuilderFacade:
10589
11530
  details={"create_result": created},
10590
11531
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10591
11532
  )
10592
- 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 = {}
10593
11542
  update_payload = _build_public_portal_base_payload(
10594
11543
  dash_name=request.dash_name or str(base_payload.get("dashName") or "").strip() or "未命名门户",
10595
11544
  package_tag_id=target_package_tag_id,
@@ -10610,6 +11559,7 @@ class AiBuilderFacade:
10610
11559
  layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload)
10611
11560
  update_payload["components"] = component_payload
10612
11561
  self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
11562
+ write_executed = True
10613
11563
  self.portals.portal_update_base_info(
10614
11564
  profile=profile,
10615
11565
  dash_key=dash_key,
@@ -10620,9 +11570,70 @@ class AiBuilderFacade:
10620
11570
  "tags": deepcopy(update_payload.get("tags") or []),
10621
11571
  },
10622
11572
  )
10623
- 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 = {}
10624
11583
  except (QingflowApiError, RuntimeError, ValueError) as error:
10625
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
+ })
10626
11637
  return _failed(
10627
11638
  "PORTAL_APPLY_FAILED",
10628
11639
  _public_error_message("PORTAL_APPLY_FAILED", api_error) if api_error else str(error),
@@ -10637,19 +11648,35 @@ class AiBuilderFacade:
10637
11648
  live_result: dict[str, Any] | None = None
10638
11649
  published = False
10639
11650
  publish_failed = False
11651
+ publish_error: JSONObject | None = None
10640
11652
  if request.publish:
10641
11653
  try:
10642
11654
  self.portals.portal_publish(profile=profile, dash_key=dash_key)
10643
11655
  published = True
10644
- live_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=False).get("result") or {}
10645
- except (QingflowApiError, RuntimeError):
11656
+ except (QingflowApiError, RuntimeError) as error:
11657
+ api_error = _coerce_api_error(error)
10646
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
+ }
10647
11672
 
10648
11673
  draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
10649
11674
  expected_count = len(request.sections) if sections_requested else None
10650
11675
  draft_verified = isinstance(draft_result, dict) and (
10651
11676
  expected_count is None or (isinstance(draft_components, list) and len(draft_components) == expected_count)
10652
11677
  )
11678
+ if draft_readback_error is not None:
11679
+ draft_verified = False
10653
11680
  draft_meta_verified, draft_meta_mismatches = _verify_portal_readback(
10654
11681
  actual=draft_result,
10655
11682
  expected_payload=update_payload,
@@ -10677,6 +11704,8 @@ class AiBuilderFacade:
10677
11704
  )
10678
11705
  )
10679
11706
  )
11707
+ if live_readback_error is not None:
11708
+ live_verified = False
10680
11709
  live_meta_verified, live_meta_mismatches = _verify_portal_readback(
10681
11710
  actual=live_result,
10682
11711
  expected_payload=update_payload,
@@ -10710,6 +11739,23 @@ class AiBuilderFacade:
10710
11739
  live_meta_verified=live_meta_verified,
10711
11740
  publish_requested=request.publish,
10712
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)
10713
11759
  warnings.extend(_portal_layout_warning_items(layout_diagnostics))
10714
11760
  return finalize({
10715
11761
  "status": status,
@@ -10727,13 +11773,10 @@ class AiBuilderFacade:
10727
11773
  "normalized_args": normalized_args,
10728
11774
  "missing_fields": [],
10729
11775
  "allowed_values": {"section.source_type": ["chart", "view", "grid", "filter", "text", "link"]},
10730
- "details": {
10731
- "verification_mismatches": {
10732
- "draft": draft_meta_mismatches,
10733
- "live": live_meta_mismatches,
10734
- }
10735
- },
10736
- "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"),
10737
11780
  "suggested_next_call": None if verified else {"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
10738
11781
  "noop": False,
10739
11782
  "warnings": warnings,
@@ -10745,6 +11788,8 @@ class AiBuilderFacade:
10745
11788
  "live_metadata_verified": live_meta_verified,
10746
11789
  "published": published,
10747
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,
10748
11793
  },
10749
11794
  "dash_key": dash_key,
10750
11795
  "dash_name": update_payload.get("dashName"),
@@ -10752,6 +11797,8 @@ class AiBuilderFacade:
10752
11797
  "created": creating,
10753
11798
  "published": published,
10754
11799
  "verified": verified,
11800
+ "write_executed": write_executed or published,
11801
+ "safe_to_retry": not (write_executed or published),
10755
11802
  "draft_result": draft_result,
10756
11803
  "live_result": live_result,
10757
11804
  })
@@ -10759,7 +11806,17 @@ class AiBuilderFacade:
10759
11806
  def _publish_current_edit_version(self, *, profile: str, app_key: str, edit_version_no: int | None = None) -> JSONObject:
10760
11807
  normalized_args = {"app_key": app_key}
10761
11808
  if edit_version_no is None:
10762
- 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
+ )
10763
11820
  edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
10764
11821
  try:
10765
11822
  self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
@@ -10893,6 +11950,7 @@ class AiBuilderFacade:
10893
11950
  response["status"] = "partial_success"
10894
11951
  response["error_code"] = response.get("error_code") or publish_result.get("error_code")
10895
11952
  response["recoverable"] = True
11953
+ response["verified"] = False
10896
11954
  response["message"] = f"{response.get('message') or 'apply succeeded'}; publish failed"
10897
11955
  if not response.get("suggested_next_call"):
10898
11956
  response["suggested_next_call"] = publish_result.get("suggested_next_call")
@@ -10981,7 +12039,8 @@ class AiBuilderFacade:
10981
12039
  app_key: str,
10982
12040
  tolerate_404: bool,
10983
12041
  tolerate_permission_restricted: bool = False,
10984
- ) -> tuple[Any, bool]:
12042
+ include_error: bool = False,
12043
+ ) -> tuple[Any, bool] | tuple[Any, bool, QingflowApiError | None]:
10985
12044
  try:
10986
12045
  views = self.views.view_list_flat(profile=profile, app_key=app_key)
10987
12046
  except (QingflowApiError, RuntimeError) as error:
@@ -11003,18 +12062,19 @@ class AiBuilderFacade:
11003
12062
  )
11004
12063
  )
11005
12064
  ):
11006
- return [], True
12065
+ return ([], True, legacy_api_error) if include_error else ([], True)
11007
12066
  raise
11008
12067
  legacy_result = legacy_views.get("result")
11009
12068
  if _is_view_collection_shape(legacy_result):
11010
- 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)
11011
12071
  if tolerate_404:
11012
- return [], True
12072
+ return ([], True, api_error) if include_error else ([], True)
11013
12073
  raise error
11014
12074
  raise
11015
12075
  normalized_views = _normalize_view_collection(views.get("result"))
11016
12076
  if normalized_views:
11017
- return normalized_views, False
12077
+ return (normalized_views, False, None) if include_error else (normalized_views, False)
11018
12078
  try:
11019
12079
  legacy_views = self.views.view_list(profile=profile, app_key=app_key)
11020
12080
  except (QingflowApiError, RuntimeError) as legacy_error:
@@ -11029,11 +12089,12 @@ class AiBuilderFacade:
11029
12089
  )
11030
12090
  )
11031
12091
  ):
11032
- return normalized_views, False
12092
+ return (normalized_views, False, legacy_api_error) if include_error else (normalized_views, False)
11033
12093
  raise
11034
12094
  legacy_result = legacy_views.get("result")
11035
12095
  legacy_normalized = _normalize_view_collection(legacy_result)
11036
- 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)
11037
12098
 
11038
12099
  def _load_workflow_result(
11039
12100
  self,
@@ -11042,7 +12103,8 @@ class AiBuilderFacade:
11042
12103
  app_key: str,
11043
12104
  tolerate_404: bool,
11044
12105
  tolerate_permission_restricted: bool = False,
11045
- ) -> tuple[Any, bool]:
12106
+ include_error: bool = False,
12107
+ ) -> tuple[Any, bool] | tuple[Any, bool, QingflowApiError | None]:
11046
12108
  try:
11047
12109
  workflow = self.workflows.workflow_list_nodes(profile=profile, app_key=app_key)
11048
12110
  except (QingflowApiError, RuntimeError) as error:
@@ -11051,9 +12113,10 @@ class AiBuilderFacade:
11051
12113
  api_error.http_status == 404
11052
12114
  or (tolerate_permission_restricted and _is_permission_restricted_api_error(api_error))
11053
12115
  ):
11054
- return [], True
12116
+ return ([], True, api_error) if include_error else ([], True)
11055
12117
  raise
11056
- return workflow.get("result"), False
12118
+ result = workflow.get("result")
12119
+ return (result, False, None) if include_error else (result, False)
11057
12120
 
11058
12121
  def _load_app_state(self, *, profile: str, app_key: str) -> dict[str, Any]:
11059
12122
  state = self._load_base_schema_state(profile=profile, app_key=app_key)
@@ -12206,10 +13269,11 @@ def _resolve_custom_button_view_button_ref(
12206
13269
  button_inventory: dict[int, dict[str, Any]],
12207
13270
  valid_custom_button_ids: set[int],
12208
13271
  reason_path: str,
13272
+ allow_unverified_numeric_id: bool = False,
12209
13273
  ) -> tuple[int | None, dict[str, Any] | None]:
12210
13274
  explicit_id = _coerce_positive_int(button_ref)
12211
13275
  if explicit_id is not None:
12212
- if explicit_id in valid_custom_button_ids:
13276
+ if explicit_id in valid_custom_button_ids or allow_unverified_numeric_id:
12213
13277
  return explicit_id, None
12214
13278
  return None, {
12215
13279
  "error_code": "UNKNOWN_CUSTOM_BUTTON",
@@ -12486,11 +13550,16 @@ def _failed_from_api_error(
12486
13550
  suggested_next_call: JSONObject | None = None,
12487
13551
  recoverable: bool = True,
12488
13552
  ) -> JSONObject:
12489
- 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
12490
13559
  public_message = _public_error_message(effective_error_code, error)
12491
13560
  public_http_status = None if error.http_status == 404 else error.http_status
12492
13561
  merged_details = dict(details or {})
12493
- if error.backend_code == 40074:
13562
+ if backend_code_int(error) == 40074:
12494
13563
  owner = _extract_edit_lock_owner(error.message)
12495
13564
  merged_details.setdefault("lock_owner_name", owner.get("lock_owner_name"))
12496
13565
  merged_details.setdefault("lock_owner_email", owner.get("lock_owner_email"))
@@ -12532,6 +13601,91 @@ def _failed_from_api_error(
12532
13601
  )
12533
13602
 
12534
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
+
12535
13689
  def _transport_error_payload(error: QingflowApiError) -> JSONObject:
12536
13690
  return {
12537
13691
  "http_status": error.http_status,
@@ -12542,7 +13696,30 @@ def _transport_error_payload(error: QingflowApiError) -> JSONObject:
12542
13696
 
12543
13697
 
12544
13698
  def _is_permission_restricted_api_error(error: QingflowApiError) -> bool:
12545
- 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
12546
13723
 
12547
13724
 
12548
13725
  def _append_response_detail(details: JSONObject, *, key: str, value: Any) -> None:
@@ -12743,7 +13920,7 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
12743
13920
 
12744
13921
 
12745
13922
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
12746
- if error.backend_code == 40074 or error_code == "APP_EDIT_LOCKED":
13923
+ if backend_code_int(error) == 40074 or error_code == "APP_EDIT_LOCKED":
12747
13924
  owner = _extract_edit_lock_owner(error.message)
12748
13925
  owner_label = owner.get("lock_owner_email") or owner.get("lock_owner_name")
12749
13926
  if owner_label:
@@ -12777,7 +13954,9 @@ def _chart_delete_readback_is_not_found(error: QingflowApiError) -> bool:
12777
13954
 
12778
13955
 
12779
13956
  def _delete_readback_is_not_found(error: QingflowApiError) -> bool:
12780
- backend_code = int(error.backend_code or 0) if str(error.backend_code or "").isdigit() else error.backend_code
13957
+ if is_auth_like_error(error):
13958
+ return False
13959
+ backend_code = backend_code_int(error)
12781
13960
  if error.http_status == 404 or backend_code in {404, 40038, 81007}:
12782
13961
  return True
12783
13962
  message = str(error.message or "").lower()
@@ -13619,7 +14798,7 @@ def _explain_chart_backend_validation_error(
13619
14798
  chart_type: str,
13620
14799
  payload: dict[str, Any] | None,
13621
14800
  ) -> dict[str, Any] | None:
13622
- backend_code = api_error.backend_code
14801
+ backend_code = backend_code_int(api_error)
13623
14802
  if backend_code not in {81002, 81005}:
13624
14803
  return None
13625
14804
  chart_type = str(chart_type or (payload or {}).get("chartType") or "").strip().lower()
@@ -14902,7 +16081,9 @@ def _department_scope_equal(left: Any, right: Any) -> bool:
14902
16081
 
14903
16082
 
14904
16083
  def _is_relation_target_metadata_read_restricted_api_error(error: QingflowApiError) -> bool:
14905
- 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}
14906
16087
 
14907
16088
 
14908
16089
  def _relation_target_field_matches(left: dict[str, Any], right: dict[str, Any]) -> bool:
@@ -20781,8 +21962,13 @@ def _serialize_view_button_binding(
20781
21962
  binding: ViewButtonBindingPatch,
20782
21963
  current_fields_by_name: dict[str, dict[str, Any]],
20783
21964
  valid_custom_button_ids: set[int],
21965
+ allow_unverified_custom_button_id: bool = False,
20784
21966
  ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
20785
- 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
+ ):
20786
21972
  return {}, [
20787
21973
  {
20788
21974
  "error_code": "UNKNOWN_CUSTOM_BUTTON",
@@ -22087,6 +23273,20 @@ def _associated_resource_patch_has_match_config(patch: AssociatedResourceUpsertP
22087
23273
  return bool(patch.match_mappings) or bool(patch.match_rules) or "match_mappings" in fields_set
22088
23274
 
22089
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
+
22090
23290
  def _serialize_associated_resource_match_rules(match_rules: list[Any]) -> list[list[dict[str, Any]]]:
22091
23291
  if not match_rules:
22092
23292
  return []