@josephyan/qingflow-app-user-mcp 1.1.21 → 1.1.23

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.
@@ -108,6 +108,7 @@ from .models import (
108
108
 
109
109
  BUILDER_PORTAL_LIST_DETAIL_VERIFY_LIMIT = 50
110
110
  CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE = 8
111
+ CUSTOM_BUTTON_VIEW_CONFIG_READBACK_RETRY_SECONDS = 0.4
111
112
 
112
113
 
113
114
  QUESTION_TYPE_TO_FIELD_TYPE: dict[int, str] = {
@@ -293,6 +294,12 @@ class AiBuilderFacade:
293
294
  def _with_backend_context(self, profile: str, handler):
294
295
  return self.apps._run(profile, lambda _session_profile, context: handler(context))
295
296
 
297
+ def _read_app_base_raw_direct(self, *, profile: str, app_key: str) -> JSONObject:
298
+ return self._with_backend_context(
299
+ profile,
300
+ lambda context: self.apps.backend.request("GET", context, f"/app/{app_key}/baseInfo"),
301
+ )
302
+
296
303
  def flow_get_schema(self, *, profile: str, schema_version: str | None = None) -> JSONObject:
297
304
  normalized_args = {"schema_version": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION}
298
305
  try:
@@ -406,22 +413,50 @@ class AiBuilderFacade:
406
413
  "idempotency_key": idempotency_key,
407
414
  "schema_version": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION,
408
415
  }
416
+ apply_started_at = time.perf_counter()
417
+ permission_ms = 0
418
+ flow_spec_apply_ms = 0
419
+ flow_spec_verify_ms = 0
420
+ publish_ms = 0
409
421
  permission_outcomes: list[PermissionCheckOutcome] = []
422
+ def finalize(response: JSONObject) -> JSONObject:
423
+ return _apply_permission_outcomes(response, *permission_outcomes)
424
+
425
+ def attach_duration(response: JSONObject) -> JSONObject:
426
+ _merge_duration_breakdown(
427
+ response,
428
+ permission_ms=permission_ms,
429
+ flow_spec_apply_ms=flow_spec_apply_ms,
430
+ flow_spec_verify_ms=flow_spec_verify_ms,
431
+ publish_ms=publish_ms,
432
+ total_ms=_duration_ms(apply_started_at),
433
+ )
434
+ return response
435
+
436
+ def finish(response: JSONObject) -> JSONObject:
437
+ return finalize(attach_duration(response))
438
+
439
+ def finish_with_publish(response: JSONObject) -> JSONObject:
440
+ nonlocal publish_ms
441
+ publish_started_at = time.perf_counter()
442
+ published_response = self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
443
+ publish_ms += _duration_ms(publish_started_at)
444
+ return finish(published_response)
445
+
446
+ permission_started_at = time.perf_counter()
410
447
  permission_outcome = self._guard_app_permission(
411
448
  profile=profile,
412
449
  app_key=app_key,
413
450
  required_permission="data_manage",
414
451
  normalized_args=normalized_args,
415
452
  )
453
+ permission_ms += _duration_ms(permission_started_at)
416
454
  if permission_outcome.block is not None:
417
- return permission_outcome.block
455
+ return finish(permission_outcome.block)
418
456
  permission_outcomes.append(permission_outcome)
419
457
 
420
- def finalize(response: JSONObject) -> JSONObject:
421
- return _apply_permission_outcomes(response, *permission_outcomes)
422
-
423
458
  if not isinstance(spec, dict) or not spec:
424
- return finalize(
459
+ return finish(
425
460
  _failed(
426
461
  "FLOW_SPEC_REQUIRED",
427
462
  "spec must be a non-empty WorkflowSpecDTO object",
@@ -436,13 +471,15 @@ class AiBuilderFacade:
436
471
  "spec": spec,
437
472
  }
438
473
  try:
474
+ flow_spec_apply_started_at = time.perf_counter()
439
475
  apply_result = self._with_backend_context(
440
476
  profile,
441
477
  lambda context: workflow_spec_api.post_apply(self.apps.backend, context, apply_body=apply_body),
442
478
  )
443
479
  except (QingflowApiError, RuntimeError) as error:
480
+ flow_spec_apply_ms += _duration_ms(flow_spec_apply_started_at)
444
481
  api_error = _coerce_api_error(error)
445
- return finalize(
482
+ return finish(
446
483
  _failed_from_api_error(
447
484
  "FLOW_SPEC_APPLY_FAILED",
448
485
  api_error,
@@ -451,15 +488,18 @@ class AiBuilderFacade:
451
488
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
452
489
  )
453
490
  )
491
+ flow_spec_apply_ms += _duration_ms(flow_spec_apply_started_at)
454
492
  if not isinstance(apply_result, dict):
455
493
  apply_result = {"result": apply_result}
494
+ flow_spec_verify_started_at = time.perf_counter()
456
495
  verification, verify_warnings, verified = workflow_spec_api.verify_apply_response(
457
496
  input_spec=spec,
458
497
  apply_result=apply_result,
459
498
  )
499
+ flow_spec_verify_ms += _duration_ms(flow_spec_verify_started_at)
460
500
  backend_status = str(apply_result.get("status") or "SUCCESS").upper()
461
501
  if backend_status not in {"SUCCESS", "OK"}:
462
- return finalize(
502
+ return finish(
463
503
  _failed(
464
504
  "FLOW_SPEC_APPLY_FAILED",
465
505
  str(apply_result.get("message") or "workflow spec apply failed"),
@@ -495,7 +535,7 @@ class AiBuilderFacade:
495
535
  "write_succeeded": True,
496
536
  "safe_to_retry": False,
497
537
  }
498
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
538
+ return finish_with_publish(response)
499
539
 
500
540
  def package_resolve(self, *, profile: str, package_name: str) -> JSONObject:
501
541
  requested = str(package_name or "").strip()
@@ -796,7 +836,6 @@ class AiBuilderFacade:
796
836
  profile: str,
797
837
  package_id: int | None = None,
798
838
  package_name: str | None = None,
799
- create_if_missing: bool = False,
800
839
  icon: str | None = None,
801
840
  color: str | None = None,
802
841
  visibility: VisibilityPatch | None = None,
@@ -807,7 +846,6 @@ class AiBuilderFacade:
807
846
  normalized_args: JSONObject = {
808
847
  "package_id": package_id,
809
848
  **({"package_name": requested_name} if requested_name else {}),
810
- "create_if_missing": bool(create_if_missing),
811
849
  **({"icon": icon} if icon else {}),
812
850
  **({"color": color} if color else {}),
813
851
  **({"visibility": visibility.model_dump(mode="json")} if visibility is not None else {}),
@@ -819,39 +857,119 @@ class AiBuilderFacade:
819
857
  create_result: JSONObject | None = None
820
858
  update_result: JSONObject | None = None
821
859
  permission_outcomes: list[PermissionCheckOutcome] = []
860
+ duration_started_at = time.perf_counter()
861
+ duration_breakdown_ms: dict[str, int] = {
862
+ "package_create_ms": 0,
863
+ "package_update_ms": 0,
864
+ "package_items_ms": 0,
865
+ "package_readback_ms": 0,
866
+ }
867
+
868
+ def finish_duration(response: JSONObject) -> JSONObject:
869
+ duration = dict(duration_breakdown_ms)
870
+ duration["total_ms"] = int((time.perf_counter() - duration_started_at) * 1000)
871
+ existing_duration = response.get("duration_breakdown_ms")
872
+ if isinstance(existing_duration, dict):
873
+ duration.update({str(key): value for key, value in existing_duration.items()})
874
+ duration["total_ms"] = int((time.perf_counter() - duration_started_at) * 1000)
875
+ response["duration_breakdown_ms"] = duration
876
+ return response
822
877
 
823
878
  if effective_package_id is None:
824
- if not create_if_missing:
825
- return _failed(
826
- "PACKAGE_ID_REQUIRED",
827
- "package_id is required unless create_if_missing=true",
828
- normalized_args=normalized_args,
829
- suggested_next_call=None,
830
- )
831
879
  if not requested_name:
832
880
  return _failed(
833
881
  "PACKAGE_NAME_REQUIRED",
834
- "package_name is required when create_if_missing=true",
882
+ "package_name is required when creating a package without package_id",
835
883
  normalized_args=normalized_args,
836
884
  suggested_next_call=None,
837
885
  )
838
- create_result = self.package_create(
839
- profile=profile,
840
- package_name=requested_name,
841
- icon=icon,
842
- color=color,
843
- visibility=visibility,
844
- )
886
+ create_started_at = time.perf_counter()
887
+ try:
888
+ desired_tag_icon = encode_workspace_icon_with_defaults(
889
+ icon=icon,
890
+ color=color,
891
+ title=requested_name,
892
+ fallback_icon_name="files-folder",
893
+ )
894
+ desired_auth = self._compile_visibility_to_member_auth(profile=profile, visibility=visibility)
895
+ created_payload = self.packages.package_create(
896
+ profile=profile,
897
+ payload={"tagName": requested_name, "tagIcon": desired_tag_icon, "auth": desired_auth},
898
+ )
899
+ except VisibilityResolutionError as error:
900
+ duration_breakdown_ms["package_create_ms"] += int((time.perf_counter() - create_started_at) * 1000)
901
+ return finish_duration(
902
+ _failed(
903
+ error.error_code,
904
+ error.message,
905
+ normalized_args=normalized_args,
906
+ details=error.details,
907
+ suggested_next_call=None,
908
+ )
909
+ )
910
+ except (QingflowApiError, RuntimeError) as error:
911
+ duration_breakdown_ms["package_create_ms"] += int((time.perf_counter() - create_started_at) * 1000)
912
+ api_error = _coerce_api_error(error)
913
+ return finish_duration(
914
+ _failed_from_api_error(
915
+ "PACKAGE_CREATE_FAILED",
916
+ api_error,
917
+ normalized_args=normalized_args,
918
+ details={"package_name": requested_name},
919
+ suggested_next_call={
920
+ "tool_name": "package_apply",
921
+ "arguments": {
922
+ "profile": profile,
923
+ "package_name": requested_name,
924
+ **({"icon": icon} if icon else {}),
925
+ **({"color": color} if color else {}),
926
+ },
927
+ },
928
+ )
929
+ )
930
+ duration_breakdown_ms["package_create_ms"] += int((time.perf_counter() - create_started_at) * 1000)
931
+ result = created_payload.get("result") if isinstance(created_payload.get("result"), dict) else {}
932
+ tag_id = _coerce_positive_int(result.get("tagId"))
933
+ tag_name = str(result.get("tagName") or requested_name).strip() or requested_name
934
+ tag_icon = str(result.get("tagIcon") or desired_tag_icon or "").strip() or None
935
+ verified = tag_id is not None
936
+ create_result = {
937
+ "status": "success" if verified else "partial_success",
938
+ "error_code": None if verified else "PACKAGE_CREATE_UNVERIFIED",
939
+ "recoverable": not verified,
940
+ "message": "created package" if verified else "created package but could not verify package_id",
941
+ "normalized_args": normalized_args,
942
+ "missing_fields": [],
943
+ "allowed_values": {},
944
+ "details": {"direct_submit": True},
945
+ "request_id": created_payload.get("request_id") if isinstance(created_payload, dict) else None,
946
+ "suggested_next_call": None
947
+ if verified
948
+ else {"tool_name": "package_get", "arguments": {"profile": profile, "package_id": tag_id}},
949
+ "noop": False,
950
+ "warnings": [],
951
+ "verification": {"tag_id_verified": verified, "direct_submit": True},
952
+ "verified": verified,
953
+ "tag_id": tag_id,
954
+ "tag_name": tag_name,
955
+ "tag_icon": tag_icon,
956
+ "visibility": _public_visibility_from_member_auth(desired_auth),
957
+ "write_executed": True,
958
+ "write_succeeded": True,
959
+ "safe_to_retry": False,
960
+ }
845
961
  if create_result.get("status") not in {"success", "partial_success"}:
846
- return _publicize_package_apply_failure(create_result, profile=profile, normalized_args=normalized_args)
962
+ return finish_duration(_publicize_package_apply_failure(create_result, profile=profile, normalized_args=normalized_args))
847
963
  effective_package_id = _coerce_positive_int(create_result.get("tag_id") or create_result.get("package_id"))
848
964
  if effective_package_id is None:
849
- return _failed(
850
- "PACKAGE_CREATE_UNVERIFIED",
851
- "created package but could not verify package_id",
852
- normalized_args=normalized_args,
853
- details={"create_result": create_result},
854
- suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
965
+ return finish_duration(
966
+ _failed(
967
+ "PACKAGE_CREATE_UNVERIFIED",
968
+ "created package but could not verify package_id",
969
+ normalized_args=normalized_args,
970
+ details={"create_result": create_result},
971
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
972
+ )
855
973
  )
856
974
  normalized_args["package_id"] = effective_package_id
857
975
  created = True
@@ -867,6 +985,7 @@ class AiBuilderFacade:
867
985
  if edit_tag_outcome.block is not None:
868
986
  return edit_tag_outcome.block
869
987
  permission_outcomes.append(edit_tag_outcome)
988
+ update_started_at = time.perf_counter()
870
989
  update_result = self.package_update(
871
990
  profile=profile,
872
991
  tag_id=effective_package_id,
@@ -875,18 +994,22 @@ class AiBuilderFacade:
875
994
  color=color,
876
995
  visibility=visibility,
877
996
  )
997
+ duration_breakdown_ms["package_update_ms"] += int((time.perf_counter() - update_started_at) * 1000)
878
998
  if update_result.get("status") not in {"success", "partial_success"}:
879
- return _publicize_package_apply_failure(update_result, profile=profile, normalized_args=normalized_args)
999
+ return finish_duration(_publicize_package_apply_failure(update_result, profile=profile, normalized_args=normalized_args))
880
1000
 
881
1001
  layout_result: JSONObject | None = None
882
1002
  if items is not None:
883
1003
  if not isinstance(items, list):
884
- return _failed(
885
- "PACKAGE_ITEMS_INVALID",
886
- "items must be a list",
887
- normalized_args=normalized_args,
888
- suggested_next_call=None,
1004
+ return finish_duration(
1005
+ _failed(
1006
+ "PACKAGE_ITEMS_INVALID",
1007
+ "items must be a list",
1008
+ normalized_args=normalized_args,
1009
+ suggested_next_call=None,
1010
+ )
889
1011
  )
1012
+ items_started_at = time.perf_counter()
890
1013
  layout_result = self._apply_package_items(
891
1014
  profile=profile,
892
1015
  package_id=effective_package_id,
@@ -894,6 +1017,7 @@ class AiBuilderFacade:
894
1017
  allow_detach=allow_detach,
895
1018
  normalized_args=normalized_args,
896
1019
  )
1020
+ duration_breakdown_ms["package_items_ms"] += int((time.perf_counter() - items_started_at) * 1000)
897
1021
  if layout_result.get("status") not in {"success", "partial_success"}:
898
1022
  prior_package_write_executed = bool(
899
1023
  created
@@ -930,8 +1054,8 @@ class AiBuilderFacade:
930
1054
  )
931
1055
  partial["package_id"] = effective_package_id
932
1056
  partial["layout_failed"] = True
933
- return _apply_permission_outcomes(partial, *permission_outcomes)
934
- return _apply_permission_outcomes(layout_result, *permission_outcomes)
1057
+ return finish_duration(_apply_permission_outcomes(partial, *permission_outcomes))
1058
+ return finish_duration(_apply_permission_outcomes(layout_result, *permission_outcomes))
935
1059
 
936
1060
  write_executed = bool(
937
1061
  created
@@ -948,10 +1072,120 @@ class AiBuilderFacade:
948
1072
  and not bool(layout_result.get("noop"))
949
1073
  )
950
1074
  )
1075
+ if items is None and (created or (metadata_requested and isinstance(update_result, dict))):
1076
+ metadata_result = create_result if created else update_result
1077
+ metadata_verified = bool(metadata_result.get("verified")) if isinstance(metadata_result, dict) else False
1078
+ metadata_verification = metadata_result.get("verification") if isinstance(metadata_result, dict) and isinstance(metadata_result.get("verification"), dict) else {}
1079
+ response_verification: JSONObject = {
1080
+ "package_exists": True,
1081
+ "package_created": created,
1082
+ "layout_applied": False,
1083
+ "metadata_verified": metadata_verified,
1084
+ **deepcopy(metadata_verification),
1085
+ }
1086
+ response: JSONObject = {
1087
+ "status": "success" if metadata_verified else "partial_success",
1088
+ "error_code": None if metadata_verified else (metadata_result.get("error_code") if isinstance(metadata_result, dict) else "PACKAGE_READBACK_PENDING"),
1089
+ "recoverable": not metadata_verified,
1090
+ "message": "applied package" if metadata_verified else "applied package with unverified readback",
1091
+ "normalized_args": normalized_args,
1092
+ "missing_fields": [],
1093
+ "allowed_values": {},
1094
+ "details": {
1095
+ "metadata_result": metadata_result,
1096
+ "final_readback_skipped": True,
1097
+ "skip_reason": "metadata_result_already_verified" if metadata_verified else "metadata_result_is_partial",
1098
+ },
1099
+ "request_id": metadata_result.get("request_id") if isinstance(metadata_result, dict) else None,
1100
+ "suggested_next_call": None
1101
+ if metadata_verified
1102
+ else {"tool_name": "package_get", "arguments": {"profile": profile, "package_id": effective_package_id}},
1103
+ "noop": False,
1104
+ "warnings": deepcopy(metadata_result.get("warnings") or []) if isinstance(metadata_result, dict) and isinstance(metadata_result.get("warnings"), list) else [],
1105
+ "verification": response_verification,
1106
+ "verified": metadata_verified,
1107
+ "write_executed": write_executed,
1108
+ "write_succeeded": write_executed,
1109
+ "safe_to_retry": not write_executed,
1110
+ }
1111
+ if isinstance(metadata_result, dict):
1112
+ response.update(
1113
+ {
1114
+ key: deepcopy(value)
1115
+ for key, value in metadata_result.items()
1116
+ if key
1117
+ not in {
1118
+ "status",
1119
+ "error_code",
1120
+ "recoverable",
1121
+ "message",
1122
+ "normalized_args",
1123
+ "missing_fields",
1124
+ "allowed_values",
1125
+ "details",
1126
+ "request_id",
1127
+ "suggested_next_call",
1128
+ "noop",
1129
+ "warnings",
1130
+ "verification",
1131
+ "verified",
1132
+ "write_executed",
1133
+ "write_succeeded",
1134
+ "safe_to_retry",
1135
+ }
1136
+ }
1137
+ )
1138
+ return finish_duration(_apply_permission_outcomes(response, *permission_outcomes))
1139
+
1140
+ layout_readback_skippable = (
1141
+ items is not None
1142
+ and not created
1143
+ and not metadata_requested
1144
+ and isinstance(layout_result, dict)
1145
+ and bool(layout_result.get("verified"))
1146
+ and isinstance(layout_result.get("details"), dict)
1147
+ and bool(layout_result["details"].get("current_read_skipped"))
1148
+ )
1149
+ if layout_readback_skippable:
1150
+ response: JSONObject = {
1151
+ "status": "success",
1152
+ "error_code": None,
1153
+ "recoverable": False,
1154
+ "message": "applied package",
1155
+ "normalized_args": normalized_args,
1156
+ "missing_fields": [],
1157
+ "allowed_values": {},
1158
+ "details": {
1159
+ "layout_result": layout_result,
1160
+ "final_readback_skipped": True,
1161
+ "skip_reason": "layout_result_already_verified",
1162
+ },
1163
+ "request_id": layout_result.get("request_id") if isinstance(layout_result.get("request_id"), str) else None,
1164
+ "suggested_next_call": None,
1165
+ "noop": False,
1166
+ "warnings": deepcopy(layout_result.get("warnings") or []) if isinstance(layout_result.get("warnings"), list) else [],
1167
+ "verification": {
1168
+ "package_exists": True,
1169
+ "package_created": False,
1170
+ "layout_applied": True,
1171
+ "layout_verified": True,
1172
+ "metadata_verified": True,
1173
+ "final_readback_skipped": True,
1174
+ },
1175
+ "verified": True,
1176
+ "package_id": effective_package_id,
1177
+ "write_executed": write_executed,
1178
+ "write_succeeded": write_executed,
1179
+ "safe_to_retry": not write_executed,
1180
+ }
1181
+ return finish_duration(_apply_permission_outcomes(response, *permission_outcomes))
1182
+
1183
+ readback_started_at = time.perf_counter()
951
1184
  verification = self.package_get(profile=profile, package_id=effective_package_id)
1185
+ duration_breakdown_ms["package_readback_ms"] += int((time.perf_counter() - readback_started_at) * 1000)
952
1186
  if verification.get("status") != "success":
953
1187
  if write_executed:
954
- return _apply_permission_outcomes(
1188
+ return finish_duration(_apply_permission_outcomes(
955
1189
  _post_write_readback_pending_result(
956
1190
  error_code="PACKAGE_READBACK_PENDING",
957
1191
  message="applied package; final package readback is unavailable",
@@ -960,8 +1194,8 @@ class AiBuilderFacade:
960
1194
  suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": effective_package_id}},
961
1195
  ),
962
1196
  *permission_outcomes,
963
- )
964
- return _apply_permission_outcomes(verification, *permission_outcomes)
1197
+ ))
1198
+ return finish_duration(_apply_permission_outcomes(verification, *permission_outcomes))
965
1199
  expected_visibility = None
966
1200
  if visibility is not None:
967
1201
  try:
@@ -1052,7 +1286,7 @@ class AiBuilderFacade:
1052
1286
  }
1053
1287
  },
1054
1288
  }
1055
- return _apply_permission_outcomes(response, *permission_outcomes)
1289
+ return finish_duration(_apply_permission_outcomes(response, *permission_outcomes))
1056
1290
 
1057
1291
  def package_update(
1058
1292
  self,
@@ -1313,6 +1547,72 @@ class AiBuilderFacade:
1313
1547
  allow_detach: bool,
1314
1548
  normalized_args: JSONObject,
1315
1549
  ) -> JSONObject:
1550
+ duplicate_resources = _find_duplicate_package_resources(items)
1551
+ if duplicate_resources:
1552
+ return _failed(
1553
+ "PACKAGE_LAYOUT_DUPLICATE_ITEM",
1554
+ "items contains duplicate apps or portals",
1555
+ normalized_args=normalized_args,
1556
+ details={"duplicates": [{"type": item[0], "id": item[1]} for item in duplicate_resources]},
1557
+ suggested_next_call=None,
1558
+ )
1559
+
1560
+ desired_groups = _collect_public_package_group_specs(items)
1561
+ if allow_detach and not desired_groups:
1562
+ permission_outcome = self._guard_package_permission(
1563
+ profile=profile,
1564
+ tag_id=package_id,
1565
+ required_permission="edit_app",
1566
+ normalized_args=normalized_args,
1567
+ )
1568
+ if permission_outcome.block is not None:
1569
+ return permission_outcome.block
1570
+ try:
1571
+ backend_items = _backend_package_items_from_public_items(items, {})
1572
+ except ValueError as error:
1573
+ return _failed(
1574
+ "PACKAGE_LAYOUT_INVALID",
1575
+ str(error),
1576
+ normalized_args=normalized_args,
1577
+ details={"package_id": package_id},
1578
+ suggested_next_call=None,
1579
+ )
1580
+ try:
1581
+ sort_result = self.packages.package_sort_items(profile=profile, tag_id=package_id, tag_items=backend_items)
1582
+ except (QingflowApiError, RuntimeError) as error:
1583
+ api_error = _coerce_api_error(error)
1584
+ return _failed_from_api_error(
1585
+ "PACKAGE_LAYOUT_SORT_FAILED",
1586
+ api_error,
1587
+ normalized_args=normalized_args,
1588
+ details={"package_id": package_id, "current_read_skipped": True},
1589
+ suggested_next_call=None,
1590
+ )
1591
+ return _apply_permission_outcomes(
1592
+ {
1593
+ "status": "success",
1594
+ "error_code": None,
1595
+ "recoverable": False,
1596
+ "message": "applied package items",
1597
+ "normalized_args": normalized_args,
1598
+ "missing_fields": [],
1599
+ "allowed_values": {},
1600
+ "details": {
1601
+ "group_operations": [],
1602
+ "sort_result": sort_result,
1603
+ "current_read_skipped": True,
1604
+ },
1605
+ "request_id": sort_result.get("request_id") if isinstance(sort_result, dict) else None,
1606
+ "suggested_next_call": None,
1607
+ "noop": False,
1608
+ "warnings": [],
1609
+ "verification": {"layout_applied": True, "current_read_skipped": True},
1610
+ "verified": True,
1611
+ "package_id": package_id,
1612
+ },
1613
+ permission_outcome,
1614
+ )
1615
+
1316
1616
  current_detail_result: JSONObject | None = None
1317
1617
  current_base_result: JSONObject | None = None
1318
1618
  detail_api_error: QingflowApiError | None = None
@@ -1388,16 +1688,6 @@ class AiBuilderFacade:
1388
1688
  suggested_next_call=None,
1389
1689
  )
1390
1690
 
1391
- duplicate_resources = _find_duplicate_package_resources(normalized_items)
1392
- if duplicate_resources:
1393
- return _failed(
1394
- "PACKAGE_LAYOUT_DUPLICATE_ITEM",
1395
- "items contains duplicate apps or portals",
1396
- normalized_args=normalized_args,
1397
- details={"duplicates": [{"type": item[0], "id": item[1]} for item in duplicate_resources]},
1398
- suggested_next_call=None,
1399
- )
1400
-
1401
1691
  current_groups = {int(spec["group_id"]): str(spec["name"] or "").strip() for spec in current_group_specs}
1402
1692
  desired_groups = _collect_public_package_group_specs(normalized_items)
1403
1693
  desired_group_ids = {
@@ -3158,47 +3448,107 @@ class AiBuilderFacade:
3158
3448
  def app_custom_buttons_apply(self, *, profile: str, request: CustomButtonsApplyRequest) -> JSONObject:
3159
3449
  normalized_args = request.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
3160
3450
  app_key = request.app_key
3451
+ apply_started_at = time.perf_counter()
3452
+ permission_ms = 0
3453
+ button_inventory_ms = 0
3454
+ app_name_read_ms = 0
3455
+ button_patch_hydration_ms = 0
3456
+ add_data_compile_ms = 0
3457
+ edit_context_ms = 0
3458
+ button_write_ms = 0
3459
+ button_readback_ms = 0
3460
+ view_config_ms = 0
3461
+ publish_ms = 0
3161
3462
  permission_outcomes: list[PermissionCheckOutcome] = []
3162
3463
  button_write_intent = bool(request.upsert_buttons or request.patch_buttons or request.remove_buttons)
3163
3464
  if button_write_intent:
3465
+ permission_started_at = time.perf_counter()
3164
3466
  permission_outcome = self._guard_app_permission(
3165
3467
  profile=profile,
3166
3468
  app_key=app_key,
3167
3469
  required_permission="edit_app",
3168
3470
  normalized_args=normalized_args,
3169
3471
  )
3472
+ permission_ms += _duration_ms(permission_started_at)
3170
3473
  if permission_outcome.block is not None:
3474
+ _merge_duration_breakdown(
3475
+ permission_outcome.block,
3476
+ permission_ms=permission_ms,
3477
+ total_ms=_duration_ms(apply_started_at),
3478
+ )
3171
3479
  return permission_outcome.block
3172
3480
  permission_outcomes.append(permission_outcome)
3173
3481
  if request.view_configs:
3482
+ permission_started_at = time.perf_counter()
3174
3483
  permission_outcome = self._guard_app_permission(
3175
3484
  profile=profile,
3176
3485
  app_key=app_key,
3177
3486
  required_permission="view_manage",
3178
3487
  normalized_args=normalized_args,
3179
3488
  )
3489
+ permission_ms += _duration_ms(permission_started_at)
3180
3490
  if permission_outcome.block is not None:
3491
+ _merge_duration_breakdown(
3492
+ permission_outcome.block,
3493
+ permission_ms=permission_ms,
3494
+ total_ms=_duration_ms(apply_started_at),
3495
+ )
3181
3496
  return permission_outcome.block
3182
3497
  permission_outcomes.append(permission_outcome)
3183
3498
 
3184
3499
  def finalize(response: JSONObject) -> JSONObject:
3185
- return _apply_permission_outcomes(response, *permission_outcomes)
3500
+ response = _apply_permission_outcomes(response, *permission_outcomes)
3501
+ _merge_duration_breakdown(
3502
+ response,
3503
+ permission_ms=permission_ms,
3504
+ button_inventory_ms=button_inventory_ms,
3505
+ app_name_read_ms=app_name_read_ms,
3506
+ button_patch_hydration_ms=button_patch_hydration_ms,
3507
+ add_data_compile_ms=add_data_compile_ms,
3508
+ edit_context_ms=edit_context_ms,
3509
+ button_write_ms=button_write_ms,
3510
+ button_readback_ms=button_readback_ms,
3511
+ view_config_ms=view_config_ms,
3512
+ publish_ms=publish_ms,
3513
+ total_ms=_duration_ms(apply_started_at),
3514
+ )
3515
+ return response
3186
3516
 
3517
+ same_call_client_keys = {
3518
+ str(patch.client_key or "").strip()
3519
+ for patch in request.upsert_buttons
3520
+ if str(patch.client_key or "").strip()
3521
+ }
3522
+ all_upserts_are_named_creates = bool(request.upsert_buttons) and all(
3523
+ str(patch.client_key or "").strip()
3524
+ and _coerce_positive_int(patch.button_id) is None
3525
+ for patch in request.upsert_buttons
3526
+ )
3187
3527
  view_config_refs = [
3188
3528
  binding.button_ref
3189
3529
  for config in request.view_configs
3190
3530
  for binding in config.buttons
3191
3531
  ]
3192
- needs_button_inventory = button_write_intent or any(
3193
- _coerce_positive_int(ref) is None for ref in view_config_refs
3532
+ view_refs_need_inventory = any(
3533
+ _coerce_positive_int(ref) is None and str(ref or "").strip() not in same_call_client_keys
3534
+ for ref in view_config_refs
3535
+ )
3536
+ needs_button_inventory = (
3537
+ bool(request.patch_buttons)
3538
+ or bool(request.remove_buttons)
3539
+ or any(_coerce_positive_int(patch.button_id) is not None for patch in request.upsert_buttons)
3540
+ or (bool(request.upsert_buttons) and not all_upserts_are_named_creates)
3541
+ or view_refs_need_inventory
3194
3542
  )
3195
3543
  try:
3544
+ button_inventory_started_at = time.perf_counter()
3196
3545
  existing_buttons = (
3197
3546
  self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
3198
3547
  if needs_button_inventory
3199
3548
  else []
3200
3549
  )
3201
3550
  except (QingflowApiError, RuntimeError) as error:
3551
+ button_inventory_ms += _duration_ms(button_inventory_started_at)
3202
3552
  api_error = _coerce_api_error(error)
3203
3553
  return finalize(_failed_from_api_error(
3204
3554
  "CUSTOM_BUTTON_LIST_FAILED",
@@ -3207,7 +3557,10 @@ class AiBuilderFacade:
3207
3557
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_buttons", error=api_error),
3208
3558
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3209
3559
  ))
3560
+ button_inventory_ms += _duration_ms(button_inventory_started_at)
3561
+ app_name_started_at = time.perf_counter()
3210
3562
  app_name = self._read_app_name_for_builder_output(profile=profile, app_key=app_key)
3563
+ app_name_read_ms += _duration_ms(app_name_started_at)
3211
3564
 
3212
3565
  existing_by_id = {
3213
3566
  button_id: item
@@ -3222,6 +3575,7 @@ class AiBuilderFacade:
3222
3575
 
3223
3576
  upsert_buttons = list(request.upsert_buttons)
3224
3577
  if request.patch_buttons:
3578
+ button_patch_started_at = time.perf_counter()
3225
3579
  expanded_buttons, patch_issues, patch_results = self._expand_custom_button_partial_patches(
3226
3580
  profile=profile,
3227
3581
  app_key=app_key,
@@ -3229,6 +3583,7 @@ class AiBuilderFacade:
3229
3583
  existing_by_text=existing_by_text,
3230
3584
  patch_buttons=request.patch_buttons,
3231
3585
  )
3586
+ button_patch_hydration_ms += _duration_ms(button_patch_started_at)
3232
3587
  if patch_issues:
3233
3588
  return finalize(
3234
3589
  _failed(
@@ -3246,11 +3601,13 @@ class AiBuilderFacade:
3246
3601
  ]
3247
3602
  normalized_args["patch_results"] = patch_results
3248
3603
 
3604
+ add_data_compile_started_at = time.perf_counter()
3249
3605
  compiled_add_data_configs, add_data_issues = self._compile_custom_button_semantic_add_data_configs(
3250
3606
  profile=profile,
3251
3607
  app_key=app_key,
3252
3608
  patches=upsert_buttons,
3253
3609
  )
3610
+ add_data_compile_ms += _duration_ms(add_data_compile_started_at)
3254
3611
  upsert_ops: list[dict[str, Any]] = []
3255
3612
  remove_ops: list[dict[str, Any]] = []
3256
3613
  blocking_issues: list[dict[str, Any]] = []
@@ -3433,12 +3790,14 @@ class AiBuilderFacade:
3433
3790
 
3434
3791
  edit_version_no = None
3435
3792
  if button_write_intent:
3793
+ edit_context_started_at = time.perf_counter()
3436
3794
  edit_version_no, edit_context_error = self._ensure_app_edit_context(
3437
3795
  profile=profile,
3438
3796
  app_key=app_key,
3439
3797
  normalized_args=normalized_args,
3440
3798
  failure_code="CUSTOM_BUTTON_APPLY_FAILED",
3441
3799
  )
3800
+ edit_context_ms += _duration_ms(edit_context_started_at)
3442
3801
  if edit_context_error is not None:
3443
3802
  return finalize(edit_context_error)
3444
3803
 
@@ -3450,6 +3809,7 @@ class AiBuilderFacade:
3450
3809
  client_key_map: dict[str, int] = {}
3451
3810
  write_executed = False
3452
3811
 
3812
+ button_write_started_at = time.perf_counter()
3453
3813
  for op in upsert_ops:
3454
3814
  patch = cast(CustomButtonUpsertPatch, op["patch"])
3455
3815
  patch_payload = _serialize_custom_button_payload(patch, add_data_config_override=compiled_add_data_configs.get(int(op["index"])))
@@ -3460,7 +3820,9 @@ class AiBuilderFacade:
3460
3820
  button_id = _extract_custom_button_id(result.get("result"))
3461
3821
  if button_id is None:
3462
3822
  try:
3823
+ button_readback_started_at = time.perf_counter()
3463
3824
  readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
3825
+ button_readback_ms += _duration_ms(button_readback_started_at)
3464
3826
  matches = [
3465
3827
  item
3466
3828
  for item in readback_buttons
@@ -3470,6 +3832,7 @@ class AiBuilderFacade:
3470
3832
  if len(matches) == 1:
3471
3833
  button_id = _coerce_positive_int(matches[0].get("button_id"))
3472
3834
  except (QingflowApiError, RuntimeError) as error:
3835
+ button_readback_ms += _duration_ms(button_readback_started_at)
3473
3836
  readback_errors.append(
3474
3837
  {
3475
3838
  "resource": "custom_buttons",
@@ -3525,7 +3888,9 @@ class AiBuilderFacade:
3525
3888
  try:
3526
3889
  write_executed = True
3527
3890
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
3891
+ button_readback_started_at = time.perf_counter()
3528
3892
  delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
3893
+ button_readback_ms += _duration_ms(button_readback_started_at)
3529
3894
  removed.append(
3530
3895
  {
3531
3896
  "index": op["index"],
@@ -3565,13 +3930,41 @@ class AiBuilderFacade:
3565
3930
  }
3566
3931
  )
3567
3932
 
3568
- needs_button_list_readback = bool(created or updated or (request.view_configs and needs_button_inventory))
3933
+ button_write_ms += _duration_ms(button_write_started_at)
3934
+ created_ids = [
3935
+ int(item["button_id"])
3936
+ for item in created
3937
+ if _coerce_positive_int(item.get("button_id")) is not None
3938
+ ]
3939
+ updated_ids = [
3940
+ int(item["button_id"])
3941
+ for item in updated
3942
+ if _coerce_positive_int(item.get("button_id")) is not None
3943
+ ]
3944
+ removed_ids = [
3945
+ int(item["button_id"])
3946
+ for item in removed
3947
+ if _coerce_positive_int(item.get("button_id")) is not None
3948
+ ]
3949
+ same_call_view_binding_verifies_buttons = bool(request.view_configs) and not needs_button_inventory
3950
+ final_readback_skipped = (
3951
+ bool(created or updated)
3952
+ and (not request.view_configs or same_call_view_binding_verifies_buttons)
3953
+ and not removed
3954
+ and not failed
3955
+ and not readback_errors
3956
+ and all(_coerce_positive_int(item.get("button_id")) is not None for item in [*created, *updated])
3957
+ )
3958
+ needs_button_list_readback = bool(created or updated or (request.view_configs and needs_button_inventory)) and not final_readback_skipped
3569
3959
  readback_buttons: list[dict[str, Any]] = []
3570
3960
  readback_failed = False
3571
3961
  if needs_button_list_readback:
3572
3962
  try:
3963
+ button_readback_started_at = time.perf_counter()
3573
3964
  readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
3965
+ button_readback_ms += _duration_ms(button_readback_started_at)
3574
3966
  except (QingflowApiError, RuntimeError) as error:
3967
+ button_readback_ms += _duration_ms(button_readback_started_at)
3575
3968
  readback_errors.append(
3576
3969
  {
3577
3970
  "resource": "custom_buttons",
@@ -3585,26 +3978,14 @@ class AiBuilderFacade:
3585
3978
  for item in readback_buttons
3586
3979
  if (button_id := _coerce_positive_int(item.get("button_id"))) is not None
3587
3980
  }
3588
- created_ids = [
3589
- int(item["button_id"])
3590
- for item in created
3591
- if _coerce_positive_int(item.get("button_id")) is not None
3592
- ]
3593
- updated_ids = [
3594
- int(item["button_id"])
3595
- for item in updated
3596
- if _coerce_positive_int(item.get("button_id")) is not None
3597
- ]
3598
- removed_ids = [
3599
- int(item["button_id"])
3600
- for item in removed
3601
- if _coerce_positive_int(item.get("button_id")) is not None
3602
- ]
3603
3981
  removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
3604
3982
  remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
3983
+ created_verified = final_readback_skipped or (not readback_failed and all(button_id in readback_ids for button_id in created_ids))
3984
+ updated_verified = final_readback_skipped or (not readback_failed and all(button_id in readback_ids for button_id in updated_ids))
3605
3985
  verified = (
3606
- not readback_failed
3607
- and all(button_id in readback_ids for button_id in created_ids + updated_ids)
3986
+ (final_readback_skipped or not readback_failed)
3987
+ and created_verified
3988
+ and updated_verified
3608
3989
  and removed_verified
3609
3990
  and not failed
3610
3991
  and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
@@ -3616,7 +3997,9 @@ class AiBuilderFacade:
3616
3997
  view_config_verified = True
3617
3998
  view_config_write_executed = False
3618
3999
  view_config_write_succeeded = False
4000
+ created_verified_by_view_binding = False
3619
4001
  if request.view_configs:
4002
+ view_config_started_at = time.perf_counter()
3620
4003
  view_config_result = self._apply_custom_button_view_configs(
3621
4004
  profile=profile,
3622
4005
  app_key=app_key,
@@ -3628,6 +4011,7 @@ class AiBuilderFacade:
3628
4011
  updated_ids=updated_ids,
3629
4012
  removed_ids=removed_ids,
3630
4013
  )
4014
+ view_config_ms += _duration_ms(view_config_started_at)
3631
4015
  view_config_results = list(view_config_result.get("view_configs") or [])
3632
4016
  view_config_failed = list(view_config_result.get("failed") or [])
3633
4017
  view_config_warnings = list(view_config_result.get("warnings") or [])
@@ -3640,6 +4024,38 @@ class AiBuilderFacade:
3640
4024
  if view_config_warnings:
3641
4025
  if not isinstance(view_config_warnings, list):
3642
4026
  view_config_warnings = []
4027
+ if not created_verified and created_ids and view_config_verified:
4028
+ bound_custom_button_ids = {
4029
+ button_id
4030
+ for view_config in view_config_results
4031
+ if isinstance(view_config, dict)
4032
+ for button in (view_config.get("actual_buttons") or [])
4033
+ if isinstance(button, dict)
4034
+ and str(button.get("button_type") or "").strip().upper() == "CUSTOM"
4035
+ and (button_id := _coerce_positive_int(button.get("button_id"))) is not None
4036
+ }
4037
+ expected_custom_button_ids = {
4038
+ button_id
4039
+ for view_config in view_config_results
4040
+ if isinstance(view_config, dict) and bool(view_config.get("verified_by_write_ack"))
4041
+ for button in (view_config.get("expected_buttons") or [])
4042
+ if isinstance(button, dict)
4043
+ and str(button.get("button_type") or "").strip().upper() == "CUSTOM"
4044
+ and (button_id := _coerce_positive_int(button.get("button_id"))) is not None
4045
+ }
4046
+ created_verified_by_view_binding = all(
4047
+ button_id in bound_custom_button_ids or button_id in expected_custom_button_ids
4048
+ for button_id in created_ids
4049
+ )
4050
+ if created_verified_by_view_binding:
4051
+ created_verified = True
4052
+ custom_buttons_verified = (
4053
+ (final_readback_skipped or not readback_failed)
4054
+ and updated_verified
4055
+ and removed_verified
4056
+ and not failed
4057
+ and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
4058
+ )
3643
4059
  verified = custom_buttons_verified and view_config_verified
3644
4060
  write_succeeded = bool(created or updated or removed or view_config_write_succeeded)
3645
4061
  status = "success" if verified else ("partial_success" if write_succeeded else "failed")
@@ -3693,6 +4109,10 @@ class AiBuilderFacade:
3693
4109
  "edit_version_no": edit_version_no,
3694
4110
  "button_ids_by_client_key": client_key_map,
3695
4111
  "readback_failed": readback_failed,
4112
+ "initial_inventory_read_skipped": not needs_button_inventory,
4113
+ "final_readback_skipped": final_readback_skipped,
4114
+ "same_call_view_binding_verifies_buttons": same_call_view_binding_verifies_buttons,
4115
+ "created_verified_by_view_binding": created_verified_by_view_binding,
3696
4116
  **({"readback_errors": readback_errors} if readback_errors else {}),
3697
4117
  "compiled_match_rules": {
3698
4118
  str(index): _summarize_compiled_match_rules(config.get("que_relation") or [])
@@ -3705,9 +4125,10 @@ class AiBuilderFacade:
3705
4125
  "warnings": warnings,
3706
4126
  "verification": {
3707
4127
  "custom_buttons_verified": custom_buttons_verified,
3708
- "readback_loaded": not readback_failed,
3709
- "created_verified": not readback_failed and all(button_id in readback_ids for button_id in created_ids),
3710
- "updated_verified": not readback_failed and all(button_id in readback_ids for button_id in updated_ids),
4128
+ "readback_loaded": False if final_readback_skipped else not readback_failed,
4129
+ "final_readback_skipped": final_readback_skipped,
4130
+ "created_verified": created_verified,
4131
+ "updated_verified": updated_verified,
3711
4132
  "removed_verified": removed_verified,
3712
4133
  "remove_readback_pending": remove_readback_pending,
3713
4134
  "removed_readback_results": deepcopy(removed),
@@ -3727,14 +4148,17 @@ class AiBuilderFacade:
3727
4148
  "write_succeeded": write_succeeded,
3728
4149
  "safe_to_retry": not write_executed,
3729
4150
  }
4151
+ publish_started_at = time.perf_counter()
4152
+ published_response = self._append_publish_result(
4153
+ profile=profile,
4154
+ app_key=app_key,
4155
+ publish=bool(write_succeeded and button_write_intent),
4156
+ response=response,
4157
+ edit_version_no=edit_version_no,
4158
+ )
4159
+ publish_ms += _duration_ms(publish_started_at)
3730
4160
  return finalize(
3731
- self._append_publish_result(
3732
- profile=profile,
3733
- app_key=app_key,
3734
- publish=bool(write_succeeded and button_write_intent),
3735
- response=response,
3736
- edit_version_no=edit_version_no,
3737
- )
4161
+ published_response
3738
4162
  )
3739
4163
 
3740
4164
  def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
@@ -4360,6 +4784,18 @@ class AiBuilderFacade:
4360
4784
  def app_associated_resources_apply(self, *, profile: str, request: AssociatedResourcesApplyRequest) -> JSONObject:
4361
4785
  normalized_args = request.model_dump(mode="json", exclude_none=True)
4362
4786
  app_key = request.app_key
4787
+ apply_started_at = time.perf_counter()
4788
+ permission_ms = 0
4789
+ resource_inventory_ms = 0
4790
+ app_name_read_ms = 0
4791
+ resource_patch_hydration_ms = 0
4792
+ match_mapping_compile_ms = 0
4793
+ edit_context_ms = 0
4794
+ resource_write_ms = 0
4795
+ resource_readback_ms = 0
4796
+ view_config_ms = 0
4797
+ publish_ms = 0
4798
+ post_publish_verify_ms = 0
4363
4799
  permission_outcomes: list[PermissionCheckOutcome] = []
4364
4800
  resource_write_intent = bool(
4365
4801
  request.upsert_resources
@@ -4368,32 +4804,97 @@ class AiBuilderFacade:
4368
4804
  or request.reorder_associated_item_ids
4369
4805
  )
4370
4806
  if resource_write_intent:
4807
+ permission_started_at = time.perf_counter()
4371
4808
  permission_outcome = self._guard_app_permission(
4372
4809
  profile=profile,
4373
4810
  app_key=app_key,
4374
4811
  required_permission="edit_app",
4375
4812
  normalized_args=normalized_args,
4376
4813
  )
4814
+ permission_ms += _duration_ms(permission_started_at)
4377
4815
  if permission_outcome.block is not None:
4816
+ _merge_duration_breakdown(
4817
+ permission_outcome.block,
4818
+ permission_ms=permission_ms,
4819
+ total_ms=_duration_ms(apply_started_at),
4820
+ )
4378
4821
  return permission_outcome.block
4379
4822
  permission_outcomes.append(permission_outcome)
4380
4823
  if request.view_configs:
4824
+ permission_started_at = time.perf_counter()
4381
4825
  permission_outcome = self._guard_app_permission(
4382
4826
  profile=profile,
4383
4827
  app_key=app_key,
4384
4828
  required_permission="view_manage",
4385
4829
  normalized_args=normalized_args,
4386
4830
  )
4831
+ permission_ms += _duration_ms(permission_started_at)
4387
4832
  if permission_outcome.block is not None:
4833
+ _merge_duration_breakdown(
4834
+ permission_outcome.block,
4835
+ permission_ms=permission_ms,
4836
+ total_ms=_duration_ms(apply_started_at),
4837
+ )
4388
4838
  return permission_outcome.block
4389
4839
  permission_outcomes.append(permission_outcome)
4390
4840
 
4391
4841
  def finalize(response: JSONObject) -> JSONObject:
4392
- return _apply_permission_outcomes(response, *permission_outcomes)
4842
+ response = _apply_permission_outcomes(response, *permission_outcomes)
4843
+ _merge_duration_breakdown(
4844
+ response,
4845
+ permission_ms=permission_ms,
4846
+ resource_inventory_ms=resource_inventory_ms,
4847
+ app_name_read_ms=app_name_read_ms,
4848
+ resource_patch_hydration_ms=resource_patch_hydration_ms,
4849
+ match_mapping_compile_ms=match_mapping_compile_ms,
4850
+ edit_context_ms=edit_context_ms,
4851
+ resource_write_ms=resource_write_ms,
4852
+ resource_readback_ms=resource_readback_ms,
4853
+ view_config_ms=view_config_ms,
4854
+ publish_ms=publish_ms,
4855
+ post_publish_verify_ms=post_publish_verify_ms,
4856
+ total_ms=_duration_ms(apply_started_at),
4857
+ )
4858
+ return response
4393
4859
 
4860
+ same_call_resource_client_keys = {
4861
+ str(patch.client_key or "").strip()
4862
+ for patch in request.upsert_resources
4863
+ if str(patch.client_key or "").strip()
4864
+ }
4865
+ all_resource_upserts_are_named_creates = bool(request.upsert_resources) and all(
4866
+ str(patch.client_key or "").strip()
4867
+ and _coerce_positive_int(patch.associated_item_id) is None
4868
+ for patch in request.upsert_resources
4869
+ )
4870
+ view_config_refs_need_inventory = any(
4871
+ str(ref or "").strip() not in same_call_resource_client_keys
4872
+ for config in request.view_configs
4873
+ for ref in config.associated_item_refs
4874
+ if str(ref or "").strip()
4875
+ )
4876
+ view_config_ids_need_inventory = any(
4877
+ bool(config.associated_item_ids)
4878
+ for config in request.view_configs
4879
+ )
4880
+ needs_resource_inventory = (
4881
+ bool(request.patch_resources)
4882
+ or bool(request.remove_associated_item_ids)
4883
+ or bool(request.reorder_associated_item_ids)
4884
+ or any(_coerce_positive_int(patch.associated_item_id) is not None for patch in request.upsert_resources)
4885
+ or (bool(request.upsert_resources) and not all_resource_upserts_are_named_creates)
4886
+ or view_config_refs_need_inventory
4887
+ or view_config_ids_need_inventory
4888
+ )
4394
4889
  try:
4395
- existing_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key, include_raw=True)
4890
+ resource_inventory_started_at = time.perf_counter()
4891
+ existing_resources = (
4892
+ self._load_associated_resources_for_builder(profile=profile, app_key=app_key, include_raw=True)
4893
+ if needs_resource_inventory
4894
+ else []
4895
+ )
4396
4896
  except (QingflowApiError, RuntimeError) as error:
4897
+ resource_inventory_ms += _duration_ms(resource_inventory_started_at)
4397
4898
  api_error = _coerce_api_error(error)
4398
4899
  return finalize(_failed_from_api_error(
4399
4900
  "ASSOCIATED_RESOURCES_READ_FAILED",
@@ -4402,7 +4903,10 @@ class AiBuilderFacade:
4402
4903
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="associated_resources", error=api_error),
4403
4904
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
4404
4905
  ))
4906
+ resource_inventory_ms += _duration_ms(resource_inventory_started_at)
4907
+ app_name_started_at = time.perf_counter()
4405
4908
  app_name = self._read_app_name_for_builder_output(profile=profile, app_key=app_key)
4909
+ app_name_read_ms += _duration_ms(app_name_started_at)
4406
4910
 
4407
4911
  existing_by_id = _associated_resource_index(existing_resources)
4408
4912
  blocking_issues: list[dict[str, Any]] = []
@@ -4415,10 +4919,12 @@ class AiBuilderFacade:
4415
4919
 
4416
4920
  upsert_resources = list(request.upsert_resources)
4417
4921
  if request.patch_resources:
4922
+ resource_patch_started_at = time.perf_counter()
4418
4923
  expanded_resources, patch_issues, patch_results = self._expand_associated_resource_partial_patches(
4419
4924
  existing_by_id=existing_by_id,
4420
4925
  patch_resources=request.patch_resources,
4421
4926
  )
4927
+ resource_patch_hydration_ms += _duration_ms(resource_patch_started_at)
4422
4928
  if patch_issues:
4423
4929
  return finalize(
4424
4930
  _failed(
@@ -4433,11 +4939,13 @@ class AiBuilderFacade:
4433
4939
  normalized_args["upsert_resources"] = [patch.model_dump(mode="json") for patch in upsert_resources]
4434
4940
  normalized_args["patch_results"] = patch_results
4435
4941
 
4942
+ match_mapping_compile_started_at = time.perf_counter()
4436
4943
  compiled_resource_match_rules, resource_match_issues = self._compile_associated_resource_semantic_match_mappings(
4437
4944
  profile=profile,
4438
4945
  app_key=app_key,
4439
4946
  patches=upsert_resources,
4440
4947
  )
4948
+ match_mapping_compile_ms += _duration_ms(match_mapping_compile_started_at)
4441
4949
  normalized_args["compiled_match_rules"] = {
4442
4950
  str(index): _summarize_compiled_match_rules(rules)
4443
4951
  for index, rules in compiled_resource_match_rules.items()
@@ -4651,12 +5159,14 @@ class AiBuilderFacade:
4651
5159
 
4652
5160
  edit_version_no = None
4653
5161
  if resource_write_intent:
5162
+ edit_context_started_at = time.perf_counter()
4654
5163
  edit_version_no, edit_context_error = self._ensure_app_edit_context(
4655
5164
  profile=profile,
4656
5165
  app_key=app_key,
4657
5166
  normalized_args=normalized_args,
4658
5167
  failure_code="ASSOCIATED_RESOURCES_APPLY_FAILED",
4659
5168
  )
5169
+ edit_context_ms += _duration_ms(edit_context_started_at)
4660
5170
  if edit_context_error is not None:
4661
5171
  return finalize(edit_context_error)
4662
5172
 
@@ -4671,6 +5181,7 @@ class AiBuilderFacade:
4671
5181
  verification_errors: list[JSONObject] = []
4672
5182
  write_executed = False
4673
5183
 
5184
+ resource_write_started_at = time.perf_counter()
4674
5185
  for op in upsert_ops:
4675
5186
  patch = cast(AssociatedResourceUpsertPatch, op["patch"])
4676
5187
  try:
@@ -4688,26 +5199,30 @@ class AiBuilderFacade:
4688
5199
  match_rules_override=compiled_resource_match_rules.get(int(op["index"])),
4689
5200
  )
4690
5201
  created_id = _extract_associated_resource_id_from_result(create_result)
4691
- try:
4692
- readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4693
- matches = [
4694
- item
4695
- for item in readback_resources
4696
- if _associated_resource_matches_patch(item, patch)
4697
- and _coerce_positive_int(item.get("associated_item_id")) is not None
4698
- ]
4699
- readback_id = _coerce_positive_int(matches[0].get("associated_item_id")) if len(matches) == 1 else None
4700
- if readback_id is not None:
4701
- created_id = readback_id
4702
- except (QingflowApiError, RuntimeError) as error:
4703
- readback_errors.append(
4704
- {
4705
- "resource": "associated_resources",
4706
- "phase": "create_id_lookup",
4707
- "index": op["index"],
4708
- "transport_error": _transport_error_payload(_coerce_api_error(error)),
4709
- }
4710
- )
5202
+ if created_id is None:
5203
+ try:
5204
+ resource_readback_started_at = time.perf_counter()
5205
+ readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
5206
+ resource_readback_ms += _duration_ms(resource_readback_started_at)
5207
+ matches = [
5208
+ item
5209
+ for item in readback_resources
5210
+ if _associated_resource_matches_patch(item, patch)
5211
+ and _coerce_positive_int(item.get("associated_item_id")) is not None
5212
+ ]
5213
+ readback_id = _coerce_positive_int(matches[0].get("associated_item_id")) if len(matches) == 1 else None
5214
+ if readback_id is not None:
5215
+ created_id = readback_id
5216
+ except (QingflowApiError, RuntimeError) as error:
5217
+ resource_readback_ms += _duration_ms(resource_readback_started_at)
5218
+ readback_errors.append(
5219
+ {
5220
+ "resource": "associated_resources",
5221
+ "phase": "create_id_lookup",
5222
+ "index": op["index"],
5223
+ "transport_error": _transport_error_payload(_coerce_api_error(error)),
5224
+ }
5225
+ )
4711
5226
  created.append(_associated_resource_result_entry("create", op["index"], patch, associated_item_id=created_id))
4712
5227
  if created_id is not None and patch.client_key:
4713
5228
  client_key_to_id[str(patch.client_key)] = created_id
@@ -4765,15 +5280,19 @@ class AiBuilderFacade:
4765
5280
  except (QingflowApiError, RuntimeError) as error:
4766
5281
  api_error = _coerce_api_error(error)
4767
5282
  failed.append({"operation": "reorder", "associated_item_ids": reorder_ids, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_REORDER_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
5283
+ resource_write_ms += _duration_ms(resource_write_started_at)
4768
5284
 
4769
5285
  resources_after: list[dict[str, Any]] = []
4770
5286
  resources_after_loaded = False
4771
5287
  resources_after_readback_failed = False
4772
5288
  if request.view_configs:
4773
5289
  try:
5290
+ resource_readback_started_at = time.perf_counter()
4774
5291
  resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
5292
+ resource_readback_ms += _duration_ms(resource_readback_started_at)
4775
5293
  resources_after_loaded = True
4776
5294
  except (QingflowApiError, RuntimeError) as error:
5295
+ resource_readback_ms += _duration_ms(resource_readback_started_at)
4777
5296
  readback_errors.append({"resource": "associated_resources", "phase": "pre_view_config", "transport_error": _transport_error_payload(_coerce_api_error(error))})
4778
5297
  resources_after = []
4779
5298
  resources_after_readback_failed = True
@@ -4819,6 +5338,7 @@ class AiBuilderFacade:
4819
5338
  failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "issues": issues})
4820
5339
  continue
4821
5340
  try:
5341
+ view_config_started_at = time.perf_counter()
4822
5342
  write_executed = True
4823
5343
  self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
4824
5344
  try:
@@ -4848,16 +5368,35 @@ class AiBuilderFacade:
4848
5368
  actual_config = {}
4849
5369
  verified_config = False
4850
5370
  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})
5371
+ view_config_ms += _duration_ms(view_config_started_at)
4851
5372
  except (QingflowApiError, RuntimeError) as error:
5373
+ view_config_ms += _duration_ms(view_config_started_at)
4852
5374
  api_error = _coerce_api_error(error)
4853
5375
  failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
4854
5376
 
5377
+ created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
5378
+ updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
5379
+ unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
5380
+ final_readback_skipped = (
5381
+ bool(created or updated)
5382
+ and not request.view_configs
5383
+ and not removed
5384
+ and not reordered
5385
+ and not failed
5386
+ and not readback_errors
5387
+ and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in [*created, *updated])
5388
+ )
4855
5389
  final_resources: list[dict[str, Any]] = resources_after if resources_after_loaded else []
4856
5390
  readback_failed = resources_after_readback_failed
4857
- if not resources_after_loaded and not resources_after_readback_failed:
5391
+ if final_readback_skipped:
5392
+ final_resources = []
5393
+ elif not resources_after_loaded and not resources_after_readback_failed:
4858
5394
  try:
5395
+ resource_readback_started_at = time.perf_counter()
4859
5396
  final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
5397
+ resource_readback_ms += _duration_ms(resource_readback_started_at)
4860
5398
  except (QingflowApiError, RuntimeError) as error:
5399
+ resource_readback_ms += _duration_ms(resource_readback_started_at)
4861
5400
  readback_errors.append({"resource": "associated_resources", "phase": "final_pool", "transport_error": _transport_error_payload(_coerce_api_error(error))})
4862
5401
  readback_failed = True
4863
5402
  final_by_id = _associated_resource_index(final_resources)
@@ -4867,15 +5406,16 @@ class AiBuilderFacade:
4867
5406
  resources=final_resources,
4868
5407
  readback_failed=readback_failed,
4869
5408
  )
4870
- created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
4871
- updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
4872
- unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
4873
5409
  removed_ids = [int(item["associated_item_id"]) for item in removed if _coerce_positive_int(item.get("associated_item_id")) is not None]
4874
5410
  removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
4875
5411
  remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
4876
- pool_verified = (
5412
+ pool_items_verified = final_readback_skipped or (
4877
5413
  not readback_failed
4878
5414
  and all(item_id in final_by_id for item_id in created_ids + updated_ids + unchanged_ids)
5415
+ )
5416
+ pool_verified = (
5417
+ (final_readback_skipped or not readback_failed)
5418
+ and pool_items_verified
4879
5419
  and removed_verified
4880
5420
  and not failed
4881
5421
  and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in created)
@@ -4944,6 +5484,8 @@ class AiBuilderFacade:
4944
5484
  "edit_version_no": edit_version_no,
4945
5485
  "associated_item_ids_by_client_key": client_key_to_id,
4946
5486
  "readback_failed": readback_failed,
5487
+ "initial_inventory_read_skipped": not needs_resource_inventory,
5488
+ "final_readback_skipped": final_readback_skipped,
4947
5489
  **({"readback_errors": readback_errors} if readback_errors else {}),
4948
5490
  **({"verification_errors": verification_errors} if verification_errors else {}),
4949
5491
  "compiled_match_rules": {
@@ -4959,7 +5501,8 @@ class AiBuilderFacade:
4959
5501
  "verification": {
4960
5502
  "associated_resources_verified": pool_verified,
4961
5503
  "associated_resource_view_configs_verified": view_configs_verified,
4962
- "readback_loaded": not readback_failed,
5504
+ "readback_loaded": False if final_readback_skipped else not readback_failed,
5505
+ "final_readback_skipped": final_readback_skipped,
4963
5506
  "removed_verified": removed_verified,
4964
5507
  "remove_readback_pending": remove_readback_pending,
4965
5508
  "removed_readback_results": deepcopy(removed),
@@ -4981,6 +5524,7 @@ class AiBuilderFacade:
4981
5524
  "safe_to_retry": not write_executed,
4982
5525
  "associated_resources": final_resources,
4983
5526
  }
5527
+ publish_started_at = time.perf_counter()
4984
5528
  response = self._append_publish_result(
4985
5529
  profile=profile,
4986
5530
  app_key=app_key,
@@ -4988,7 +5532,9 @@ class AiBuilderFacade:
4988
5532
  response=response,
4989
5533
  edit_version_no=edit_version_no,
4990
5534
  )
5535
+ publish_ms += _duration_ms(publish_started_at)
4991
5536
  if response.get("published") and view_config_results:
5537
+ post_publish_started_at = time.perf_counter()
4992
5538
  try:
4993
5539
  post_publish_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4994
5540
  except (QingflowApiError, RuntimeError) as error:
@@ -5097,6 +5643,7 @@ class AiBuilderFacade:
5097
5643
  response["recoverable"] = False
5098
5644
  response["message"] = "applied associated resources patch"
5099
5645
  response["suggested_next_call"] = None
5646
+ post_publish_verify_ms += _duration_ms(post_publish_started_at)
5100
5647
  return finalize(response)
5101
5648
 
5102
5649
  def _resolve_app_matches_in_visible_apps(
@@ -6153,32 +6700,52 @@ class AiBuilderFacade:
6153
6700
  idempotency_key: str | None = None,
6154
6701
  schema_version: str | None = None,
6155
6702
  ) -> JSONObject:
6703
+ patch_started_at = time.perf_counter()
6704
+ flow_patch_read_ms = 0
6705
+ flow_patch_compile_ms = 0
6706
+ flow_patch_apply_ms = 0
6707
+
6708
+ def attach_patch_duration(response: JSONObject) -> JSONObject:
6709
+ _merge_duration_breakdown(
6710
+ response,
6711
+ flow_patch_read_ms=flow_patch_read_ms,
6712
+ flow_patch_compile_ms=flow_patch_compile_ms,
6713
+ flow_patch_apply_ms=flow_patch_apply_ms,
6714
+ total_ms=_duration_ms(patch_started_at),
6715
+ )
6716
+ return response
6717
+
6156
6718
  try:
6719
+ flow_patch_read_started_at = time.perf_counter()
6157
6720
  current = self.flow_get(profile=profile, app_key=app_key)
6158
6721
  except (QingflowApiError, RuntimeError) as error:
6722
+ flow_patch_read_ms += _duration_ms(flow_patch_read_started_at)
6159
6723
  api_error = _coerce_api_error(error)
6160
- return _failed_from_api_error(
6724
+ return attach_patch_duration(_failed_from_api_error(
6161
6725
  "FLOW_READ_FAILED",
6162
6726
  api_error,
6163
6727
  normalized_args={"app_key": app_key},
6164
6728
  details={"app_key": app_key},
6165
6729
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6166
- )
6730
+ ))
6731
+ flow_patch_read_ms += _duration_ms(flow_patch_read_started_at)
6167
6732
  if str(current.get("status") or "").strip().lower() != "success":
6168
- return _failed(
6733
+ return attach_patch_duration(_failed(
6169
6734
  "FLOW_READ_FAILED",
6170
6735
  current.get("message") or "failed to read current flow",
6171
6736
  normalized_args={"app_key": app_key},
6172
6737
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6173
- )
6738
+ ))
6739
+ flow_patch_compile_started_at = time.perf_counter()
6174
6740
  spec = deepcopy(current.get("spec") or {})
6175
6741
  if not isinstance(spec, dict) or not spec:
6176
- return _failed(
6742
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6743
+ return attach_patch_duration(_failed(
6177
6744
  "FLOW_PATCH_FAILED",
6178
6745
  "current flow has no spec; use full app_flow_apply with spec first",
6179
6746
  normalized_args={"app_key": app_key, "patch_nodes": patch_nodes},
6180
6747
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, "app_key": app_key}},
6181
- )
6748
+ ))
6182
6749
  nodes = spec.get("nodes") if isinstance(spec.get("nodes"), list) else []
6183
6750
  node_by_id: dict[str, dict[str, Any]] = {}
6184
6751
  for node in nodes:
@@ -6205,30 +6772,35 @@ class AiBuilderFacade:
6205
6772
  for key in unset_payload:
6206
6773
  target.pop(str(key), None)
6207
6774
  if invalid_items:
6208
- return _failed(
6775
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6776
+ return attach_patch_duration(_failed(
6209
6777
  "FLOW_PATCH_INVALID",
6210
6778
  "patch_nodes[] contains invalid items",
6211
6779
  normalized_args={"app_key": app_key, "invalid_items": invalid_items},
6212
6780
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6213
- )
6781
+ ))
6214
6782
  if not_found:
6215
- return _failed(
6783
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6784
+ return attach_patch_duration(_failed(
6216
6785
  "FLOW_NODE_NOT_FOUND",
6217
6786
  f"patch_nodes[] referenced node id(s) not found in current flow: {not_found}",
6218
6787
  normalized_args={"app_key": app_key, "not_found_ids": not_found},
6219
6788
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6220
- )
6789
+ ))
6221
6790
  edge_container = spec.get("edges") if isinstance(spec.get("edges"), dict) else {}
6222
6791
  edges = edge_container.get("edges") if isinstance(edge_container, dict) else None
6223
6792
  transitions = spec.get("transitions") if isinstance(spec.get("transitions"), list) else None
6224
6793
  if not edges and not transitions:
6225
- return _failed(
6794
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6795
+ return attach_patch_duration(_failed(
6226
6796
  "FLOW_PATCH_EMPTY_GRAPH_UNSUPPORTED",
6227
6797
  "current flow spec has no edges; patch_nodes cannot safely save this backend shape",
6228
6798
  normalized_args={"app_key": app_key, "patch_nodes": patch_nodes},
6229
6799
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, "app_key": app_key}},
6230
- )
6231
- return self.flow_apply(
6800
+ ))
6801
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6802
+ flow_patch_apply_started_at = time.perf_counter()
6803
+ result = self.flow_apply(
6232
6804
  profile=profile,
6233
6805
  app_key=app_key,
6234
6806
  spec=spec,
@@ -6236,6 +6808,8 @@ class AiBuilderFacade:
6236
6808
  idempotency_key=idempotency_key,
6237
6809
  schema_version=schema_version,
6238
6810
  )
6811
+ flow_patch_apply_ms += _duration_ms(flow_patch_apply_started_at)
6812
+ return attach_patch_duration(result)
6239
6813
 
6240
6814
  def _components_to_sections_payload(self, components: list[dict[str, Any]]) -> list[dict[str, Any]]:
6241
6815
  result: list[dict[str, Any]] = []
@@ -7474,19 +8048,35 @@ class AiBuilderFacade:
7474
8048
  all_verified = False
7475
8049
  continue
7476
8050
  try:
7477
- verify_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
7478
- verify_config = verify_response.get("result") if isinstance(verify_response.get("result"), dict) else {}
7479
8051
  expected_summary = _normalize_expected_view_buttons_for_compare(
7480
8052
  effective_merged_dtos,
7481
8053
  custom_button_details_by_id=button_inventory,
7482
8054
  )
7483
- actual_summary = _normalize_view_buttons_for_compare(verify_config)
7484
- comparison = _compare_view_button_summaries(
7485
- expected=expected_summary,
7486
- actual=actual_summary,
7487
- pending_custom_button_ids=set(created_ids),
8055
+ actual_summary: list[dict[str, Any]] = []
8056
+ comparison: dict[str, Any] = {"verified": False, "custom_button_readback_pending": False}
8057
+ readback_retry_count = 0
8058
+ while True:
8059
+ verify_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
8060
+ verify_config = verify_response.get("result") if isinstance(verify_response.get("result"), dict) else {}
8061
+ actual_summary = _normalize_view_buttons_for_compare(verify_config)
8062
+ comparison = _compare_view_button_summaries(
8063
+ expected=expected_summary,
8064
+ actual=actual_summary,
8065
+ pending_custom_button_ids=set(created_ids),
8066
+ )
8067
+ if not comparison.get("custom_button_readback_pending") or readback_retry_count >= 1:
8068
+ break
8069
+ readback_retry_count += 1
8070
+ time.sleep(CUSTOM_BUTTON_VIEW_CONFIG_READBACK_RETRY_SECONDS)
8071
+ pending_custom_button_readback = bool(comparison.get("custom_button_readback_pending"))
8072
+ accepted_with_delayed_custom_button_readback = (
8073
+ pending_custom_button_readback
8074
+ and write_succeeded
8075
+ and unsupported_list_issue is None
7488
8076
  )
7489
- verified = bool(comparison.get("verified")) and unsupported_list_issue is None
8077
+ verified = (
8078
+ bool(comparison.get("verified")) or accepted_with_delayed_custom_button_readback
8079
+ ) and unsupported_list_issue is None
7490
8080
  if not verified:
7491
8081
  all_verified = False
7492
8082
  results.append(
@@ -7501,12 +8091,14 @@ class AiBuilderFacade:
7501
8091
  "view_buttons_verified": verified,
7502
8092
  "supported_buttons_verified": bool(comparison.get("verified")) if unsupported_list_issue is not None else None,
7503
8093
  "unsupported_placements": ["list"] if unsupported_list_issue is not None else [],
7504
- "custom_button_readback_pending": bool(comparison.get("custom_button_readback_pending")),
8094
+ "custom_button_readback_pending": pending_custom_button_readback,
8095
+ "verified_by_write_ack": accepted_with_delayed_custom_button_readback,
8096
+ "readback_retry_count": readback_retry_count,
7505
8097
  "expected_buttons": expected_summary,
7506
8098
  "actual_buttons": actual_summary,
7507
8099
  }
7508
8100
  )
7509
- if comparison.get("custom_button_readback_pending"):
8101
+ if pending_custom_button_readback and not accepted_with_delayed_custom_button_readback:
7510
8102
  warnings.append(_warning("VIEW_CUSTOM_BUTTON_READBACK_PENDING", "view config write landed but custom button readback is delayed"))
7511
8103
  except (QingflowApiError, RuntimeError) as error:
7512
8104
  api_error = _coerce_api_error(error)
@@ -7959,7 +8551,6 @@ class AiBuilderFacade:
7959
8551
  app_key=request.app_key,
7960
8552
  app_name=request.app_name,
7961
8553
  package_tag_id=request.package_tag_id,
7962
- create_if_missing=request.create_if_missing,
7963
8554
  )
7964
8555
  if target.get("status") == "failed":
7965
8556
  target.setdefault("normalized_args", normalized_args)
@@ -8231,7 +8822,6 @@ class AiBuilderFacade:
8231
8822
  "arguments": {
8232
8823
  "profile": profile,
8233
8824
  "app_key": request.app_key,
8234
- "create_if_missing": False,
8235
8825
  "add_fields": [{"name": "状态", "type": "select", "options": ["草稿", "进行中", "已完成"], "required": True}],
8236
8826
  "update_fields": [],
8237
8827
  "remove_fields": [],
@@ -8581,7 +9171,6 @@ class AiBuilderFacade:
8581
9171
  icon: str | None = None,
8582
9172
  color: str | None = None,
8583
9173
  visibility: VisibilityPatch | None = None,
8584
- create_if_missing: bool = False,
8585
9174
  publish: bool = True,
8586
9175
  add_fields: list[FieldPatch],
8587
9176
  update_fields: list[FieldUpdatePatch],
@@ -8604,7 +9193,6 @@ class AiBuilderFacade:
8604
9193
  "icon": icon,
8605
9194
  "color": color,
8606
9195
  "visibility": visibility.model_dump(mode="json") if visibility is not None else None,
8607
- "create_if_missing": create_if_missing,
8608
9196
  "publish": publish,
8609
9197
  "add_fields": [patch.model_dump(mode="json") for patch in add_fields],
8610
9198
  "update_fields": [patch.model_dump(mode="json") for patch in update_fields],
@@ -8629,25 +9217,30 @@ class AiBuilderFacade:
8629
9217
  permission_outcomes: list[PermissionCheckOutcome] = []
8630
9218
  requested_inline_layout = bool(requested_inline_layout_sections)
8631
9219
  requested_field_changes = bool(add_fields or update_fields or remove_fields or requested_inline_layout)
8632
- resolved: JSONObject
8633
- if app_key:
8634
- resolved = self.app_resolve(profile=profile, app_key=app_key)
8635
- elif app_name:
8636
- resolved = self.app_resolve(profile=profile, app_name=app_name, package_tag_id=package_tag_id)
8637
- else:
8638
- return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", normalized_args=normalized_args, suggested_next_call=None)
8639
9220
 
8640
9221
  def finalize(response: JSONObject) -> JSONObject:
8641
9222
  return _apply_permission_outcomes(response, *permission_outcomes)
8642
9223
 
8643
- if resolved.get("status") == "failed":
8644
- if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
8645
- resolved["normalized_args"] = normalized_args
8646
- if not create_if_missing or app_key or resolved.get("error_code") != "APP_NOT_FOUND":
8647
- return finalize(resolved)
9224
+ resolved: JSONObject
9225
+ if app_key:
9226
+ resolved = self.app_resolve(profile=profile, app_key=app_key)
9227
+ else:
9228
+ requested_app_name_for_create = str(app_name or "").strip()
8648
9229
  permission_tag_id = _coerce_positive_int(package_tag_id)
9230
+ if not requested_app_name_for_create:
9231
+ return _failed(
9232
+ "APP_NAME_REQUIRED",
9233
+ "app_name is required when creating an app without app_key",
9234
+ normalized_args=normalized_args,
9235
+ suggested_next_call=None,
9236
+ )
8649
9237
  if permission_tag_id is None:
8650
- permission_tag_id = 0
9238
+ return _failed(
9239
+ "PACKAGE_ID_REQUIRED",
9240
+ "package_id is required when creating an app without app_key",
9241
+ normalized_args=normalized_args,
9242
+ suggested_next_call=None,
9243
+ )
8651
9244
  add_permission_outcome = self._guard_package_permission(
8652
9245
  profile=profile,
8653
9246
  tag_id=permission_tag_id,
@@ -8659,8 +9252,8 @@ class AiBuilderFacade:
8659
9252
  permission_outcomes.append(add_permission_outcome)
8660
9253
  resolved = self._create_target_app_shell(
8661
9254
  profile=profile,
8662
- app_name=app_name,
8663
- package_tag_id=package_tag_id,
9255
+ app_name=requested_app_name_for_create,
9256
+ package_tag_id=permission_tag_id,
8664
9257
  icon=icon,
8665
9258
  color=color,
8666
9259
  auth=desired_auth,
@@ -8673,6 +9266,11 @@ class AiBuilderFacade:
8673
9266
  if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
8674
9267
  resolved["normalized_args"] = normalized_args
8675
9268
  return finalize(resolved)
9269
+
9270
+ if resolved.get("status") == "failed":
9271
+ if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
9272
+ resolved["normalized_args"] = normalized_args
9273
+ return finalize(resolved)
8676
9274
  resolved_outcome = _permission_outcome_from_result(resolved)
8677
9275
  if resolved_outcome is not None:
8678
9276
  permission_outcomes.append(resolved_outcome)
@@ -9611,71 +10209,113 @@ class AiBuilderFacade:
9611
10209
  "sections": requested_sections,
9612
10210
  "publish": publish,
9613
10211
  }
10212
+ apply_started_at = time.perf_counter()
10213
+ permission_ms = 0
10214
+ schema_read_ms = 0
10215
+ layout_compile_ms = 0
10216
+ edit_version_ms = 0
10217
+ layout_write_ms = 0
10218
+ layout_readback_ms = 0
10219
+ publish_ms = 0
9614
10220
  permission_outcomes: list[PermissionCheckOutcome] = []
10221
+ def finalize(response: JSONObject) -> JSONObject:
10222
+ return _apply_permission_outcomes(response, *permission_outcomes)
10223
+
10224
+ def attach_duration(response: JSONObject) -> JSONObject:
10225
+ _merge_duration_breakdown(
10226
+ response,
10227
+ permission_ms=permission_ms,
10228
+ schema_read_ms=schema_read_ms,
10229
+ layout_compile_ms=layout_compile_ms,
10230
+ edit_version_ms=edit_version_ms,
10231
+ layout_write_ms=layout_write_ms,
10232
+ layout_readback_ms=layout_readback_ms,
10233
+ publish_ms=publish_ms,
10234
+ total_ms=_duration_ms(apply_started_at),
10235
+ )
10236
+ return response
10237
+
10238
+ def finish(response: JSONObject) -> JSONObject:
10239
+ return finalize(attach_duration(response))
10240
+
10241
+ def finish_with_publish(response: JSONObject) -> JSONObject:
10242
+ nonlocal publish_ms
10243
+ publish_started_at = time.perf_counter()
10244
+ published_response = self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
10245
+ publish_ms += _duration_ms(publish_started_at)
10246
+ return finish(published_response)
10247
+
10248
+ permission_started_at = time.perf_counter()
9615
10249
  permission_outcome = self._guard_app_permission(
9616
10250
  profile=profile,
9617
10251
  app_key=app_key,
9618
10252
  required_permission="edit_app",
9619
10253
  normalized_args=normalized_args,
9620
10254
  )
10255
+ permission_ms += _duration_ms(permission_started_at)
9621
10256
  if permission_outcome.block is not None:
9622
- return permission_outcome.block
10257
+ return finish(permission_outcome.block)
9623
10258
  permission_outcomes.append(permission_outcome)
9624
10259
 
9625
- def finalize(response: JSONObject) -> JSONObject:
9626
- return _apply_permission_outcomes(response, *permission_outcomes)
9627
-
9628
10260
  try:
10261
+ schema_read_started_at = time.perf_counter()
9629
10262
  schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
9630
10263
  except (QingflowApiError, RuntimeError) as error:
10264
+ schema_read_ms += _duration_ms(schema_read_started_at)
9631
10265
  api_error = _coerce_api_error(error)
9632
- return finalize(_failed_from_api_error(
10266
+ return finish(_failed_from_api_error(
9633
10267
  "LAYOUT_READ_FAILED",
9634
10268
  api_error,
9635
10269
  normalized_args=normalized_args,
9636
10270
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="schema", error=api_error),
9637
10271
  suggested_next_call={"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
9638
10272
  ))
10273
+ schema_read_ms += _duration_ms(schema_read_started_at)
10274
+ layout_compile_started_at = time.perf_counter()
9639
10275
  app_name = str(schema_result.get("formTitle") or schema_result.get("title") or schema_result.get("appName") or app_key).strip() or app_key
9640
10276
  parsed = _parse_schema(schema_result)
9641
10277
  current_fields = parsed["fields"]
9642
10278
  requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
9643
10279
  normalized_args["sections"] = requested_sections
9644
10280
  if missing_selectors:
9645
- return _failed(
10281
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10282
+ return finish(_failed(
9646
10283
  "UNKNOWN_LAYOUT_FIELD",
9647
10284
  "layout references unknown field selectors",
9648
10285
  normalized_args=normalized_args,
9649
10286
  details={"unknown_selectors": missing_selectors},
9650
10287
  missing_fields=[str(item) for item in missing_selectors],
9651
10288
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
9652
- )
10289
+ ))
9653
10290
  fields_by_name = {field["name"]: field for field in current_fields}
9654
10291
  seen: list[str] = []
9655
10292
  for section in requested_sections:
9656
10293
  for row in section.get("rows", []):
9657
10294
  for field_name in row:
9658
10295
  if field_name not in fields_by_name:
9659
- return _failed(
10296
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10297
+ return finish(_failed(
9660
10298
  "UNKNOWN_LAYOUT_FIELD",
9661
10299
  f"layout references unknown field '{field_name}'",
9662
10300
  normalized_args=normalized_args,
9663
10301
  details={"field_name": field_name},
9664
10302
  suggested_next_call={"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
9665
- )
10303
+ ))
9666
10304
  if field_name in seen:
9667
- return _failed(
10305
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10306
+ return finish(_failed(
9668
10307
  "DUPLICATE_LAYOUT_FIELD",
9669
10308
  f"layout references field '{field_name}' more than once",
9670
10309
  normalized_args=normalized_args,
9671
10310
  details={"field_name": field_name},
9672
10311
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
9673
- )
10312
+ ))
9674
10313
  seen.append(field_name)
9675
10314
  expected = {field["name"] for field in current_fields}
9676
10315
  if mode == LayoutApplyMode.replace and set(seen) != expected:
9677
10316
  missing = sorted(expected.difference(seen))
9678
- return _failed(
10317
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10318
+ return finish(_failed(
9679
10319
  "INCOMPLETE_LAYOUT",
9680
10320
  "layout must reference every current field exactly once in replace mode",
9681
10321
  normalized_args=normalized_args,
@@ -9692,7 +10332,7 @@ class AiBuilderFacade:
9692
10332
  "tool_name": "app_layout_apply",
9693
10333
  "arguments": {"profile": profile, "app_key": app_key, "mode": "merge", "sections": requested_sections},
9694
10334
  },
9695
- )
10335
+ ))
9696
10336
  merged = _merge_layout(
9697
10337
  current_layout=parsed["layout"],
9698
10338
  requested_sections=requested_sections,
@@ -9704,6 +10344,7 @@ class AiBuilderFacade:
9704
10344
  else merged["layout"]
9705
10345
  )
9706
10346
  if _layouts_equal(parsed["layout"], target_layout):
10347
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
9707
10348
  response = {
9708
10349
  "status": "success",
9709
10350
  "error_code": None,
@@ -9732,21 +10373,26 @@ class AiBuilderFacade:
9732
10373
  "write_succeeded": False,
9733
10374
  "safe_to_retry": True,
9734
10375
  }
9735
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
10376
+ return finish_with_publish(response)
9736
10377
  payload = _build_form_payload_from_existing_schema(
9737
10378
  current_schema=schema_result,
9738
10379
  layout=target_layout,
9739
10380
  )
10381
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10382
+ edit_version_started_at = time.perf_counter()
9740
10383
  payload["editVersionNo"] = self._resolve_form_edit_version(
9741
10384
  profile=profile,
9742
10385
  app_key=app_key,
9743
10386
  current_schema=schema_result,
9744
10387
  )
10388
+ edit_version_ms += _duration_ms(edit_version_started_at)
9745
10389
  applied_layout = target_layout
9746
10390
  fallback_applied = None
9747
10391
  try:
10392
+ layout_write_started_at = time.perf_counter()
9748
10393
  self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=payload)
9749
10394
  except (QingflowApiError, RuntimeError) as error:
10395
+ layout_write_ms += _duration_ms(layout_write_started_at)
9750
10396
  api_error = _coerce_api_error(error)
9751
10397
  if backend_code_int(api_error) == 400 and target_layout.get("sections"):
9752
10398
  flattened_layout = _flatten_layout_sections(target_layout)
@@ -9760,12 +10406,15 @@ class AiBuilderFacade:
9760
10406
  current_schema=schema_result,
9761
10407
  )
9762
10408
  try:
10409
+ fallback_write_started_at = time.perf_counter()
9763
10410
  self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=fallback_payload)
10411
+ layout_write_ms += _duration_ms(fallback_write_started_at)
9764
10412
  applied_layout = flattened_layout
9765
10413
  fallback_applied = "flatten_sections"
9766
10414
  except (QingflowApiError, RuntimeError) as fallback_error:
10415
+ layout_write_ms += _duration_ms(fallback_write_started_at)
9767
10416
  api_fallback_error = _coerce_api_error(fallback_error)
9768
- return finalize(_failed_from_api_error(
10417
+ return finish(_failed_from_api_error(
9769
10418
  "LAYOUT_APPLY_FAILED",
9770
10419
  api_fallback_error,
9771
10420
  normalized_args=normalized_args,
@@ -9780,7 +10429,7 @@ class AiBuilderFacade:
9780
10429
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
9781
10430
  ))
9782
10431
  else:
9783
- return finalize(_failed_from_api_error(
10432
+ return finish(_failed_from_api_error(
9784
10433
  "LAYOUT_APPLY_FAILED",
9785
10434
  api_error,
9786
10435
  normalized_args=normalized_args,
@@ -9792,9 +10441,13 @@ class AiBuilderFacade:
9792
10441
  },
9793
10442
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
9794
10443
  ))
10444
+ else:
10445
+ layout_write_ms += _duration_ms(layout_write_started_at)
9795
10446
  try:
10447
+ layout_readback_started_at = time.perf_counter()
9796
10448
  verified_schema, _verified_schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
9797
10449
  except (QingflowApiError, RuntimeError) as error:
10450
+ layout_readback_ms += _duration_ms(layout_readback_started_at)
9798
10451
  api_error = _coerce_api_error(error)
9799
10452
  response = {
9800
10453
  "status": "partial_success",
@@ -9834,7 +10487,8 @@ class AiBuilderFacade:
9834
10487
  "write_succeeded": True,
9835
10488
  "safe_to_retry": False,
9836
10489
  }
9837
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
10490
+ return finish_with_publish(response)
10491
+ layout_readback_ms += _duration_ms(layout_readback_started_at)
9838
10492
  verified_layout = _parse_schema(verified_schema)["layout"]
9839
10493
  layout_verified = _layouts_equal(verified_layout, applied_layout) or _layouts_semantically_equal(verified_layout, applied_layout)
9840
10494
  raw_layout_has_content = _schema_has_layout_content(verified_schema)
@@ -9848,24 +10502,21 @@ class AiBuilderFacade:
9848
10502
  warnings.append(_warning("LAYOUT_SUMMARY_UNVERIFIED", "layout summary is incomplete relative to raw schema readback"))
9849
10503
  if fallback_applied is not None and layout_verified and layout_summary_verified:
9850
10504
  warnings.append(_warning("LAYOUT_FALLBACK_APPLIED", "layout readback normalized sectioned layout into flat layout while preserving field placement"))
10505
+ result_verified = layout_verified
9851
10506
  response = {
9852
- "status": "success" if layout_verified and layout_summary_verified else "partial_success",
10507
+ "status": "success" if result_verified else "partial_success",
9853
10508
  "error_code": (
9854
10509
  None
9855
- if layout_verified and layout_summary_verified
9856
- else "LAYOUT_SUMMARY_UNVERIFIED"
9857
- if not layout_summary_verified
10510
+ if result_verified
9858
10511
  else "LAYOUT_READBACK_MISMATCH"
9859
10512
  ),
9860
- "recoverable": not (layout_verified and layout_summary_verified),
10513
+ "recoverable": not result_verified,
9861
10514
  "message": (
9862
10515
  "applied app layout with flattened section fallback"
9863
10516
  if layout_verified and layout_summary_verified and fallback_applied is not None
9864
10517
  else
9865
10518
  "applied app layout"
9866
- if layout_verified and layout_summary_verified
9867
- else "applied app layout; raw layout verified but compact summary is incomplete"
9868
- if layout_verified and not layout_summary_verified
10519
+ if layout_verified
9869
10520
  else "applied app layout with flattened section fallback"
9870
10521
  if fallback_applied is not None
9871
10522
  else "applied app layout; readback did not fully match the requested layout"
@@ -9888,12 +10539,12 @@ class AiBuilderFacade:
9888
10539
  "auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
9889
10540
  "fallback_applied": fallback_applied,
9890
10541
  },
9891
- "verified": layout_verified,
10542
+ "verified": result_verified,
9892
10543
  "write_executed": True,
9893
10544
  "write_succeeded": True,
9894
10545
  "safe_to_retry": False,
9895
10546
  }
9896
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
10547
+ return finish_with_publish(response)
9897
10548
 
9898
10549
  def app_flow_apply(
9899
10550
  self,
@@ -9912,22 +10563,54 @@ class AiBuilderFacade:
9912
10563
  "transitions": transitions,
9913
10564
  "publish": publish,
9914
10565
  }
10566
+ apply_started_at = time.perf_counter()
10567
+ permission_ms = 0
10568
+ flow_state_read_ms = 0
10569
+ flow_compile_ms = 0
10570
+ flow_write_ms = 0
10571
+ flow_readback_ms = 0
10572
+ publish_ms = 0
9915
10573
  permission_outcomes: list[PermissionCheckOutcome] = []
10574
+ def finalize(response: JSONObject) -> JSONObject:
10575
+ return _apply_permission_outcomes(response, *permission_outcomes)
10576
+
10577
+ def attach_duration(response: JSONObject) -> JSONObject:
10578
+ _merge_duration_breakdown(
10579
+ response,
10580
+ permission_ms=permission_ms,
10581
+ flow_state_read_ms=flow_state_read_ms,
10582
+ flow_compile_ms=flow_compile_ms,
10583
+ flow_write_ms=flow_write_ms,
10584
+ flow_readback_ms=flow_readback_ms,
10585
+ publish_ms=publish_ms,
10586
+ total_ms=_duration_ms(apply_started_at),
10587
+ )
10588
+ return response
10589
+
10590
+ def finish(response: JSONObject) -> JSONObject:
10591
+ return finalize(attach_duration(response))
10592
+
10593
+ def finish_with_publish(response: JSONObject) -> JSONObject:
10594
+ nonlocal publish_ms
10595
+ publish_started_at = time.perf_counter()
10596
+ published_response = self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
10597
+ publish_ms += _duration_ms(publish_started_at)
10598
+ return finish(published_response)
10599
+
10600
+ permission_started_at = time.perf_counter()
9916
10601
  permission_outcome = self._guard_app_permission(
9917
10602
  profile=profile,
9918
10603
  app_key=app_key,
9919
10604
  required_permission="edit_app",
9920
10605
  normalized_args=normalized_args,
9921
10606
  )
10607
+ permission_ms += _duration_ms(permission_started_at)
9922
10608
  if permission_outcome.block is not None:
9923
- return permission_outcome.block
10609
+ return finish(permission_outcome.block)
9924
10610
  permission_outcomes.append(permission_outcome)
9925
10611
 
9926
- def finalize(response: JSONObject) -> JSONObject:
9927
- return _apply_permission_outcomes(response, *permission_outcomes)
9928
-
9929
10612
  if mode != "replace":
9930
- return finalize(_failed(
10613
+ return finish(_failed(
9931
10614
  "UNSUPPORTED_FLOW_MODE",
9932
10615
  "only mode='replace' is supported",
9933
10616
  normalized_args=normalized_args,
@@ -9935,17 +10618,21 @@ class AiBuilderFacade:
9935
10618
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
9936
10619
  ))
9937
10620
  try:
10621
+ flow_state_read_started_at = time.perf_counter()
9938
10622
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
9939
10623
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
9940
10624
  except (QingflowApiError, RuntimeError) as error:
10625
+ flow_state_read_ms += _duration_ms(flow_state_read_started_at)
9941
10626
  api_error = _coerce_api_error(error)
9942
- return finalize(_failed_from_api_error(
10627
+ return finish(_failed_from_api_error(
9943
10628
  "FLOW_READ_FAILED",
9944
10629
  api_error,
9945
10630
  normalized_args=normalized_args,
9946
10631
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
9947
10632
  suggested_next_call={"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}},
9948
10633
  ))
10634
+ flow_state_read_ms += _duration_ms(flow_state_read_started_at)
10635
+ flow_compile_started_at = time.perf_counter()
9949
10636
  app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_key).strip() or app_key
9950
10637
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
9951
10638
  current_fields = _parse_schema(schema)["fields"]
@@ -9954,7 +10641,8 @@ class AiBuilderFacade:
9954
10641
  unsupported_nodes = self._unsupported_public_flow_nodes(nodes=public_nodes)
9955
10642
  normalized_args["nodes"] = public_nodes
9956
10643
  if unsupported_nodes:
9957
- return _failed(
10644
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10645
+ return finish(_failed(
9958
10646
  "FLOW_NODE_TYPE_UNSUPPORTED",
9959
10647
  "app_flow_apply currently supports only linear workflows; branch and condition nodes are disabled because the backend workflow route is not front-end stable for these node types.",
9960
10648
  normalized_args=normalized_args,
@@ -9964,7 +10652,7 @@ class AiBuilderFacade:
9964
10652
  "disabled_node_types": sorted(DISABLED_PUBLIC_FLOW_NODE_TYPES),
9965
10653
  },
9966
10654
  suggested_next_call={"tool_name": "builder_tool_contract", "arguments": {"tool_name": "app_flow_apply"}},
9967
- )
10655
+ ))
9968
10656
  if resolution_issues:
9969
10657
  first_issue = resolution_issues[0]
9970
10658
  suggested_call = None
@@ -9974,13 +10662,14 @@ class AiBuilderFacade:
9974
10662
  suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
9975
10663
  elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
9976
10664
  suggested_call = {"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}}
9977
- return _failed(
10665
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10666
+ return finish(_failed(
9978
10667
  first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
9979
10668
  "workflow contains unresolved assignees or field permissions",
9980
10669
  normalized_args=normalized_args,
9981
10670
  details={"issues": resolution_issues},
9982
10671
  suggested_next_call=suggested_call,
9983
- )
10672
+ ))
9984
10673
  assignee_required_nodes = [
9985
10674
  node.get("id")
9986
10675
  for node in normalized_nodes
@@ -9991,19 +10680,21 @@ class AiBuilderFacade:
9991
10680
  )
9992
10681
  ]
9993
10682
  if assignee_required_nodes:
9994
- return _failed(
10683
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10684
+ return finish(_failed(
9995
10685
  "FLOW_ASSIGNEE_REQUIRED",
9996
10686
  "workflow approval/fill/copy nodes must declare at least one role or member assignee",
9997
10687
  normalized_args=normalized_args,
9998
10688
  details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
9999
10689
  suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
10000
- )
10690
+ ))
10001
10691
  workflow_spec = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
10002
10692
  if workflow_spec.get("status") == "failed":
10003
10693
  workflow_spec["normalized_args"] = normalized_args
10004
10694
  workflow_spec.setdefault("request_id", None)
10005
10695
  workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}}
10006
- return workflow_spec
10696
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10697
+ return finish(workflow_spec)
10007
10698
  desired_node_count = len([node for node in normalized_nodes if node.get("type") != "end"])
10008
10699
  build_id = f"facade-flow-{uuid4().hex[:12]}"
10009
10700
  previous_build_home = os.getenv("QINGFLOW_MCP_BUILD_HOME")
@@ -10028,6 +10719,8 @@ class AiBuilderFacade:
10028
10719
  "entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
10029
10720
  }
10030
10721
  assembly.set_stage_spec("app_flow", flow_stage_spec)
10722
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10723
+ flow_write_started_at = time.perf_counter()
10031
10724
  stage = self.solutions.solution_build_flow(
10032
10725
  profile=profile,
10033
10726
  mode="apply",
@@ -10037,6 +10730,7 @@ class AiBuilderFacade:
10037
10730
  run_label=None,
10038
10731
  repair_patch={},
10039
10732
  )
10733
+ flow_write_ms += _duration_ms(flow_write_started_at)
10040
10734
  finally:
10041
10735
  if previous_build_home is None:
10042
10736
  os.environ.pop("QINGFLOW_MCP_BUILD_HOME", None)
@@ -10058,8 +10752,10 @@ class AiBuilderFacade:
10058
10752
  suggested_next_call["tool_name"] = "app_flow_apply"
10059
10753
  suggested_next_call["arguments"] = arguments
10060
10754
  failed["suggested_next_call"] = suggested_next_call
10061
- return finalize(failed)
10755
+ return finish(failed)
10756
+ flow_readback_started_at = time.perf_counter()
10062
10757
  verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
10758
+ flow_readback_ms += _duration_ms(flow_readback_started_at)
10063
10759
  workflow_structure_verified = bool(verified_nodes) and _workflow_nodes_semantically_equal(
10064
10760
  current_workflow=verified_nodes,
10065
10761
  requested_nodes=normalized_nodes,
@@ -10121,7 +10817,7 @@ class AiBuilderFacade:
10121
10817
  "write_succeeded": True,
10122
10818
  "safe_to_retry": False,
10123
10819
  }
10124
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
10820
+ return finish_with_publish(response)
10125
10821
 
10126
10822
  def _extract_view_action_button_patch_intents(
10127
10823
  self,
@@ -10547,12 +11243,65 @@ class AiBuilderFacade:
10547
11243
  "remove_views": list(remove_views),
10548
11244
  "publish": publish,
10549
11245
  }
11246
+ apply_started_at = time.perf_counter()
11247
+ permission_ms = 0
11248
+ view_state_read_ms = 0
11249
+ custom_button_inventory_ms = 0
11250
+ associated_resource_inventory_ms = 0
11251
+ view_mutation_ms = 0
11252
+ action_buttons_ms = 0
11253
+ view_readback_ms = 0
11254
+ view_verification_ms = 0
11255
+ publish_ms = 0
11256
+ publish_already_satisfied_by_action_buttons = False
11257
+ permission_outcomes: list[PermissionCheckOutcome] = []
11258
+
11259
+ def finalize(response: JSONObject) -> JSONObject:
11260
+ return _apply_permission_outcomes(response, *permission_outcomes)
11261
+
11262
+ def attach_duration(response: JSONObject) -> JSONObject:
11263
+ _merge_duration_breakdown(
11264
+ response,
11265
+ permission_ms=permission_ms,
11266
+ view_state_read_ms=view_state_read_ms,
11267
+ custom_button_inventory_ms=custom_button_inventory_ms,
11268
+ associated_resource_inventory_ms=associated_resource_inventory_ms,
11269
+ view_mutation_ms=view_mutation_ms,
11270
+ action_buttons_ms=action_buttons_ms,
11271
+ view_readback_ms=view_readback_ms,
11272
+ view_verification_ms=view_verification_ms,
11273
+ publish_ms=publish_ms,
11274
+ total_ms=_duration_ms(apply_started_at),
11275
+ )
11276
+ return response
11277
+
11278
+ def finish(response: JSONObject) -> JSONObject:
11279
+ return finalize(attach_duration(response))
11280
+
11281
+ def finish_with_publish(response: JSONObject) -> JSONObject:
11282
+ nonlocal publish_ms
11283
+ if publish and publish_already_satisfied_by_action_buttons and not bool(response.get("noop")):
11284
+ verification = response.get("verification")
11285
+ if not isinstance(verification, dict):
11286
+ verification = {}
11287
+ response["verification"] = verification
11288
+ response["publish_requested"] = True
11289
+ response["publish_skipped"] = True
11290
+ response["publish_skip_reason"] = "already_published_by_action_buttons"
11291
+ response["published"] = True
11292
+ verification["published"] = True
11293
+ return finish(response)
11294
+ publish_started_at = time.perf_counter()
11295
+ published_response = self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
11296
+ publish_ms += _duration_ms(publish_started_at)
11297
+ return finish(published_response)
11298
+
10550
11299
  has_action_button_intent = any(patch.action_buttons is not None for patch in upsert_views) or any(
10551
11300
  any(key in (patch.set or {}) for key in ("action_buttons", "actionButtons"))
10552
11301
  for patch in patch_views
10553
11302
  )
10554
11303
  if has_action_button_intent and not publish:
10555
- return _failed(
11304
+ return finish(_failed(
10556
11305
  "VIEW_ACTION_BUTTONS_REQUIRE_PUBLISH",
10557
11306
  "app_views_apply action_buttons require publish=true because the underlying custom button writer may publish after successful button writes",
10558
11307
  normalized_args=normalized_args,
@@ -10562,7 +11311,7 @@ class AiBuilderFacade:
10562
11311
  "reason": "action_buttons are compiled through app_custom_buttons_apply",
10563
11312
  },
10564
11313
  suggested_next_call={"tool_name": "app_views_apply", "arguments": {"profile": profile, **{**normalized_args, "publish": True}}},
10565
- )
11314
+ ))
10566
11315
  if not upsert_views and not patch_views and not remove_views:
10567
11316
  response = {
10568
11317
  "status": "success",
@@ -10590,10 +11339,10 @@ class AiBuilderFacade:
10590
11339
  "write_succeeded": False,
10591
11340
  "safe_to_retry": True,
10592
11341
  }
10593
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
10594
- permission_outcomes: list[PermissionCheckOutcome] = []
11342
+ return finish_with_publish(response)
10595
11343
  app_permission_summary: JSONObject | None = None
10596
11344
  app_permission_error: QingflowApiError | None = None
11345
+ permission_started_at = time.perf_counter()
10597
11346
  try:
10598
11347
  app_permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
10599
11348
  except (QingflowApiError, RuntimeError) as error:
@@ -10637,26 +11386,27 @@ class AiBuilderFacade:
10637
11386
  normalized_args=normalized_args,
10638
11387
  permission_summary=app_permission_summary,
10639
11388
  )
11389
+ permission_ms += _duration_ms(permission_started_at)
10640
11390
  if permission_outcome.block is not None:
10641
- return permission_outcome.block
11391
+ return finish(permission_outcome.block)
10642
11392
  permission_outcomes.append(permission_outcome)
10643
11393
 
10644
- def finalize(response: JSONObject) -> JSONObject:
10645
- return _apply_permission_outcomes(response, *permission_outcomes)
10646
-
10647
11394
  try:
11395
+ view_state_read_started_at = time.perf_counter()
10648
11396
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
10649
11397
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
10650
11398
  existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
10651
11399
  except (QingflowApiError, RuntimeError) as error:
11400
+ view_state_read_ms += _duration_ms(view_state_read_started_at)
10652
11401
  api_error = _coerce_api_error(error)
10653
- return finalize(_failed_from_api_error(
11402
+ return finish(_failed_from_api_error(
10654
11403
  "VIEWS_READ_FAILED",
10655
11404
  api_error,
10656
11405
  normalized_args=normalized_args,
10657
11406
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
10658
11407
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10659
11408
  ))
11409
+ view_state_read_ms += _duration_ms(view_state_read_started_at)
10660
11410
  app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or "").strip() or None
10661
11411
  existing_views = existing_views or []
10662
11412
  existing_by_key: dict[str, dict[str, Any]] = {}
@@ -10680,7 +11430,7 @@ class AiBuilderFacade:
10680
11430
  if action_button_patch_results:
10681
11431
  normalized_args["action_button_patch_results"] = action_button_patch_results
10682
11432
  if action_button_patch_issues:
10683
- return finalize(
11433
+ return finish(
10684
11434
  _failed(
10685
11435
  "VIEW_ACTION_BUTTON_PATCH_FAILED",
10686
11436
  "one or more patch_views action_buttons entries could not be resolved; no write was executed",
@@ -10705,6 +11455,7 @@ class AiBuilderFacade:
10705
11455
  transport_error=_transport_error_payload(app_permission_error),
10706
11456
  )
10707
11457
  else:
11458
+ create_permission_started_at = time.perf_counter()
10708
11459
  create_permission_outcome = self._guard_app_permission(
10709
11460
  profile=profile,
10710
11461
  app_key=app_key,
@@ -10712,13 +11463,14 @@ class AiBuilderFacade:
10712
11463
  normalized_args=normalized_args,
10713
11464
  permission_summary=app_permission_summary,
10714
11465
  )
11466
+ permission_ms += _duration_ms(create_permission_started_at)
10715
11467
  if create_permission_outcome.block is not None:
10716
11468
  details = create_permission_outcome.block.get("details")
10717
11469
  if isinstance(details, dict):
10718
11470
  details["operation"] = "view_create"
10719
11471
  details["view_names"] = creating_view_names
10720
11472
  details["also_required_permission"] = "view_manage"
10721
- return create_permission_outcome.block
11473
+ return finish(create_permission_outcome.block)
10722
11474
  permission_outcomes.append(create_permission_outcome)
10723
11475
  parsed_schema = _parse_schema(schema)
10724
11476
  field_names = {field["name"] for field in parsed_schema["fields"]}
@@ -10757,6 +11509,7 @@ class AiBuilderFacade:
10757
11509
  custom_button_details_by_id: dict[int, dict[str, Any]] = {}
10758
11510
  if requires_custom_button_validation:
10759
11511
  try:
11512
+ custom_button_inventory_started_at = time.perf_counter()
10760
11513
  button_listing = self.buttons.custom_button_list(
10761
11514
  profile=profile,
10762
11515
  app_key=app_key,
@@ -10764,8 +11517,9 @@ class AiBuilderFacade:
10764
11517
  include_raw=False,
10765
11518
  )
10766
11519
  except (QingflowApiError, RuntimeError) as error:
11520
+ custom_button_inventory_ms += _duration_ms(custom_button_inventory_started_at)
10767
11521
  api_error = _coerce_api_error(error)
10768
- return finalize(
11522
+ return finish(
10769
11523
  _failed_from_api_error(
10770
11524
  "CUSTOM_BUTTON_LIST_FAILED",
10771
11525
  api_error,
@@ -10774,6 +11528,7 @@ class AiBuilderFacade:
10774
11528
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10775
11529
  )
10776
11530
  )
11531
+ custom_button_inventory_ms += _duration_ms(custom_button_inventory_started_at)
10777
11532
  valid_custom_button_ids = {
10778
11533
  button_id
10779
11534
  for item in (button_listing.get("items") or [])
@@ -10787,6 +11542,7 @@ class AiBuilderFacade:
10787
11542
  }
10788
11543
  for button_id in sorted(referenced_custom_button_ids):
10789
11544
  try:
11545
+ custom_button_detail_started_at = time.perf_counter()
10790
11546
  detail = self.buttons.custom_button_get(
10791
11547
  profile=profile,
10792
11548
  app_key=app_key,
@@ -10794,7 +11550,9 @@ class AiBuilderFacade:
10794
11550
  being_draft=True,
10795
11551
  include_raw=False,
10796
11552
  )
11553
+ custom_button_inventory_ms += _duration_ms(custom_button_detail_started_at)
10797
11554
  except (QingflowApiError, RuntimeError) as error:
11555
+ custom_button_inventory_ms += _duration_ms(custom_button_detail_started_at)
10798
11556
  api_error = _coerce_api_error(error)
10799
11557
  if _is_optional_builder_lookup_error(api_error):
10800
11558
  continue
@@ -10813,7 +11571,7 @@ class AiBuilderFacade:
10813
11571
  },
10814
11572
  )
10815
11573
  failed.update({"write_executed": False, "write_succeeded": False, "safe_to_retry": True})
10816
- return finalize(failed)
11574
+ return finish(failed)
10817
11575
  detail_result = detail.get("result")
10818
11576
  if isinstance(detail_result, dict):
10819
11577
  custom_button_details_by_id[button_id] = _normalize_custom_button_detail(detail_result)
@@ -10821,10 +11579,12 @@ class AiBuilderFacade:
10821
11579
  associated_resources: list[dict[str, Any]] = []
10822
11580
  if requires_associated_resource_validation:
10823
11581
  try:
11582
+ associated_resource_inventory_started_at = time.perf_counter()
10824
11583
  associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
10825
11584
  except (QingflowApiError, RuntimeError) as error:
11585
+ associated_resource_inventory_ms += _duration_ms(associated_resource_inventory_started_at)
10826
11586
  api_error = _coerce_api_error(error)
10827
- return finalize(
11587
+ return finish(
10828
11588
  _failed_from_api_error(
10829
11589
  "ASSOCIATED_RESOURCES_READ_FAILED",
10830
11590
  api_error,
@@ -10833,6 +11593,7 @@ class AiBuilderFacade:
10833
11593
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10834
11594
  )
10835
11595
  )
11596
+ associated_resource_inventory_ms += _duration_ms(associated_resource_inventory_started_at)
10836
11597
  removed: list[str] = []
10837
11598
  removed_keys: set[str] = set()
10838
11599
  view_results: list[dict[str, Any]] = []
@@ -10853,6 +11614,7 @@ class AiBuilderFacade:
10853
11614
  }
10854
11615
  )
10855
11616
 
11617
+ view_mutation_started_at = time.perf_counter()
10856
11618
  for selector in remove_views:
10857
11619
  selector_text = str(selector or "").strip()
10858
11620
  if not selector_text:
@@ -11489,6 +12251,7 @@ class AiBuilderFacade:
11489
12251
  failed_views.append(failure_entry)
11490
12252
  view_results.append(failure_entry)
11491
12253
  continue
12254
+ view_mutation_ms += _duration_ms(view_mutation_started_at)
11492
12255
  successful_action_view_keys = {
11493
12256
  str(item.get("view_key") or "").strip()
11494
12257
  for item in view_results
@@ -11510,11 +12273,13 @@ class AiBuilderFacade:
11510
12273
  runnable_action_button_intents = [
11511
12274
  intent for intent in action_button_intents if id(intent) not in skipped_action_button_intent_ids
11512
12275
  ]
12276
+ action_buttons_started_at = time.perf_counter()
11513
12277
  action_buttons_result = self._apply_view_action_buttons(
11514
12278
  profile=profile,
11515
12279
  app_key=app_key,
11516
12280
  intents=runnable_action_button_intents,
11517
12281
  )
12282
+ action_buttons_ms += _duration_ms(action_buttons_started_at)
11518
12283
  if skipped_action_button_intents:
11519
12284
  action_buttons_result.setdefault("skipped_due_to_view_write_failure", [])
11520
12285
  if isinstance(action_buttons_result["skipped_due_to_view_write_failure"], list):
@@ -11530,6 +12295,11 @@ class AiBuilderFacade:
11530
12295
  )
11531
12296
  action_button_write_executed = bool(action_buttons_result.get("write_executed"))
11532
12297
  action_button_write_succeeded = bool(action_buttons_result.get("write_succeeded"))
12298
+ publish_already_satisfied_by_action_buttons = bool(
12299
+ publish
12300
+ and action_buttons_result.get("publish_requested") is True
12301
+ and action_buttons_result.get("published") is True
12302
+ )
11533
12303
  action_buttons_verification = action_buttons_result.get("verification") if isinstance(action_buttons_result.get("verification"), dict) else {}
11534
12304
  action_buttons_verified = (
11535
12305
  bool(action_buttons_result.get("verified", True))
@@ -11544,16 +12314,19 @@ class AiBuilderFacade:
11544
12314
  verified_views_unavailable = False
11545
12315
  if needs_view_list_readback:
11546
12316
  try:
12317
+ view_readback_started_at = time.perf_counter()
11547
12318
  verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
11548
12319
  except (QingflowApiError, RuntimeError) as error:
12320
+ view_readback_ms += _duration_ms(view_readback_started_at)
11549
12321
  api_error = _coerce_api_error(error)
11550
- return finalize(_failed_from_api_error(
12322
+ return finish(_failed_from_api_error(
11551
12323
  "VIEWS_READ_FAILED",
11552
12324
  api_error,
11553
12325
  normalized_args=normalized_args,
11554
12326
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
11555
12327
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
11556
12328
  ))
12329
+ view_readback_ms += _duration_ms(view_readback_started_at)
11557
12330
  verified_names = {
11558
12331
  _extract_view_name(item)
11559
12332
  for item in (verified_view_result or [])
@@ -11583,6 +12356,22 @@ class AiBuilderFacade:
11583
12356
  button_mismatches: list[dict[str, Any]] = []
11584
12357
  custom_button_readback_pending = False
11585
12358
  custom_button_readback_pending_entries: list[dict[str, Any]] = []
12359
+ view_config_readback_cache: dict[str, tuple[dict[str, Any], QingflowApiError | None]] = {}
12360
+
12361
+ def read_view_config_for_verification(view_key: str) -> tuple[dict[str, Any], QingflowApiError | None]:
12362
+ cached = view_config_readback_cache.get(view_key)
12363
+ if cached is not None:
12364
+ return cached
12365
+ try:
12366
+ config_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
12367
+ config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
12368
+ cached = (config_result, None)
12369
+ except (QingflowApiError, RuntimeError) as error:
12370
+ cached = ({}, _coerce_api_error(error))
12371
+ view_config_readback_cache[view_key] = cached
12372
+ return cached
12373
+
12374
+ view_verification_started_at = time.perf_counter()
11586
12375
  for item in view_results:
11587
12376
  status = str(item.get("status") or "")
11588
12377
  name = str(item.get("name") or "")
@@ -11629,8 +12418,9 @@ class AiBuilderFacade:
11629
12418
  verification_by_view.append(verification_entry)
11630
12419
  continue
11631
12420
  try:
11632
- config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
11633
- config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
12421
+ config_result, config_error = read_view_config_for_verification(verification_key)
12422
+ if config_error is not None:
12423
+ raise config_error
11634
12424
  actual_filters = _normalize_view_filter_groups_for_compare(config_result.get("viewgraphLimit"))
11635
12425
  expected_filter_summary = _normalize_view_filter_groups_for_compare(expected_filters)
11636
12426
  expected_data_scope = "CUSTOM" if expected_filter_summary else "ALL"
@@ -11706,8 +12496,9 @@ class AiBuilderFacade:
11706
12496
  verification_by_view.append(verification_entry)
11707
12497
  continue
11708
12498
  try:
11709
- config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
11710
- config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
12499
+ config_result, config_error = read_view_config_for_verification(verification_key)
12500
+ if config_error is not None:
12501
+ raise config_error
11711
12502
  actual_query_conditions = _normalize_view_query_conditions_for_compare(config_result)
11712
12503
  query_conditions_verified = actual_query_conditions == expected_query_conditions
11713
12504
  verification_entry["query_conditions_verified"] = query_conditions_verified
@@ -11754,8 +12545,9 @@ class AiBuilderFacade:
11754
12545
  verification_by_view.append(verification_entry)
11755
12546
  continue
11756
12547
  try:
11757
- config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
11758
- config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
12548
+ config_result, config_error = read_view_config_for_verification(verification_key)
12549
+ if config_error is not None:
12550
+ raise config_error
11759
12551
  actual_associated_resources = _extract_view_associated_resources_config(
11760
12552
  config_result,
11761
12553
  available_resources=associated_resources,
@@ -11808,8 +12600,9 @@ class AiBuilderFacade:
11808
12600
  verification_by_view.append(verification_entry)
11809
12601
  continue
11810
12602
  try:
11811
- config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
11812
- config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
12603
+ config_result, config_error = read_view_config_for_verification(verification_key)
12604
+ if config_error is not None:
12605
+ raise config_error
11813
12606
  actual_buttons = _normalize_view_buttons_for_compare(config_result)
11814
12607
  button_comparison = _compare_view_button_summaries(
11815
12608
  expected=expected_buttons,
@@ -11893,6 +12686,7 @@ class AiBuilderFacade:
11893
12686
  "error_code": item.get("error_code"),
11894
12687
  }
11895
12688
  )
12689
+ view_verification_ms += _duration_ms(view_verification_started_at)
11896
12690
  removed_delete_results = [
11897
12691
  item
11898
12692
  for item in view_results
@@ -11992,7 +12786,7 @@ class AiBuilderFacade:
11992
12786
  "write_succeeded": bool(created or updated or removed or action_button_write_succeeded),
11993
12787
  "safe_to_retry": not bool(created or updated or removed or action_button_write_executed),
11994
12788
  }
11995
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
12789
+ return finish_with_publish(response)
11996
12790
  warnings: list[dict[str, Any]] = []
11997
12791
  if filter_readback_pending or filter_mismatches:
11998
12792
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
@@ -12092,7 +12886,7 @@ class AiBuilderFacade:
12092
12886
  "write_succeeded": bool(created or updated or removed or action_button_write_succeeded),
12093
12887
  "safe_to_retry": not bool(created or updated or removed or action_button_write_executed),
12094
12888
  }
12095
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
12889
+ return finish_with_publish(response)
12096
12890
 
12097
12891
  def app_publish_verify(
12098
12892
  self,
@@ -12101,87 +12895,132 @@ class AiBuilderFacade:
12101
12895
  app_key: str,
12102
12896
  expected_package_tag_id: int | None = None,
12103
12897
  ) -> JSONObject:
12898
+ apply_started_at = time.perf_counter()
12899
+ initial_base_read_ms = 0
12900
+ initial_views_read_ms = 0
12901
+ edit_version_ms = 0
12902
+ publish_ms = 0
12903
+ base_readback_ms = 0
12904
+ views_readback_ms = 0
12104
12905
  normalized_args = {"app_key": app_key, "expected_package_tag_id": expected_package_tag_id}
12906
+
12907
+ def finish(response: JSONObject) -> JSONObject:
12908
+ _merge_duration_breakdown(
12909
+ response,
12910
+ initial_base_read_ms=initial_base_read_ms,
12911
+ initial_views_read_ms=initial_views_read_ms,
12912
+ edit_version_ms=edit_version_ms,
12913
+ publish_ms=publish_ms,
12914
+ base_readback_ms=base_readback_ms,
12915
+ views_readback_ms=views_readback_ms,
12916
+ total_ms=_duration_ms(apply_started_at),
12917
+ )
12918
+ return response
12919
+
12920
+ initial_base_read_started_at = time.perf_counter()
12105
12921
  try:
12106
12922
  base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
12107
12923
  except (QingflowApiError, RuntimeError) as error:
12924
+ initial_base_read_ms += _duration_ms(initial_base_read_started_at)
12108
12925
  api_error = _coerce_api_error(error)
12109
- return _failed_from_api_error(
12110
- "APP_READ_FAILED",
12111
- api_error,
12112
- normalized_args=normalized_args,
12113
- details={"app_key": app_key},
12114
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
12926
+ return finish(
12927
+ _failed_from_api_error(
12928
+ "APP_READ_FAILED",
12929
+ api_error,
12930
+ normalized_args=normalized_args,
12931
+ details={"app_key": app_key},
12932
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
12933
+ )
12115
12934
  )
12935
+ initial_base_read_ms += _duration_ms(initial_base_read_started_at)
12116
12936
  tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
12117
12937
  app_name_before = str(base_before.get("formTitle") or base_before.get("title") or base_before.get("appName") or "").strip() or None
12118
12938
  already_published = bool(base_before.get("appPublishStatus") in {1, 2})
12119
12939
  package_already_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_before
12940
+ initial_views_read_started_at = time.perf_counter()
12120
12941
  try:
12121
12942
  views_before, views_before_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
12122
12943
  except (QingflowApiError, RuntimeError) as error:
12944
+ initial_views_read_ms += _duration_ms(initial_views_read_started_at)
12123
12945
  api_error = _coerce_api_error(error)
12124
- return _failed_from_api_error(
12125
- "VIEWS_READ_FAILED",
12126
- api_error,
12127
- normalized_args=normalized_args,
12128
- details={"app_key": app_key},
12129
- suggested_next_call={"tool_name": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
12946
+ return finish(
12947
+ _failed_from_api_error(
12948
+ "VIEWS_READ_FAILED",
12949
+ api_error,
12950
+ normalized_args=normalized_args,
12951
+ details={"app_key": app_key},
12952
+ suggested_next_call={"tool_name": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
12953
+ )
12130
12954
  )
12955
+ initial_views_read_ms += _duration_ms(initial_views_read_started_at)
12131
12956
  views_before = views_before or []
12132
12957
  if already_published and package_already_attached is not False and isinstance(views_before, list) and not views_before_unavailable:
12133
- return {
12134
- "status": "success",
12135
- "error_code": None,
12136
- "recoverable": False,
12137
- "message": "app already published and verified",
12138
- "normalized_args": normalized_args,
12139
- "missing_fields": [],
12140
- "allowed_values": {},
12141
- "details": {},
12142
- "request_id": None,
12143
- "suggested_next_call": None,
12144
- "noop": True,
12145
- "warnings": [],
12146
- "verification": {"published": True, "package_attached": package_already_attached, "views_ok": True},
12147
- "app_key": app_key,
12148
- "app_name": app_name_before,
12149
- "published": True,
12150
- "package_attached": package_already_attached,
12151
- "tag_ids_after": tag_ids_before,
12152
- "views_ok": True,
12153
- "verified": True,
12154
- "write_executed": False,
12155
- "write_succeeded": False,
12156
- "safe_to_retry": True,
12157
- }
12958
+ return finish(
12959
+ {
12960
+ "status": "success",
12961
+ "error_code": None,
12962
+ "recoverable": False,
12963
+ "message": "app already published and verified",
12964
+ "normalized_args": normalized_args,
12965
+ "missing_fields": [],
12966
+ "allowed_values": {},
12967
+ "details": {},
12968
+ "request_id": None,
12969
+ "suggested_next_call": None,
12970
+ "noop": True,
12971
+ "warnings": [],
12972
+ "verification": {"published": True, "package_attached": package_already_attached, "views_ok": True},
12973
+ "app_key": app_key,
12974
+ "app_name": app_name_before,
12975
+ "published": True,
12976
+ "package_attached": package_already_attached,
12977
+ "tag_ids_after": tag_ids_before,
12978
+ "views_ok": True,
12979
+ "verified": True,
12980
+ "write_executed": False,
12981
+ "write_succeeded": False,
12982
+ "safe_to_retry": True,
12983
+ }
12984
+ )
12985
+ edit_version_started_at = time.perf_counter()
12158
12986
  try:
12159
12987
  version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
12160
12988
  except (QingflowApiError, RuntimeError) as error:
12989
+ edit_version_ms += _duration_ms(edit_version_started_at)
12161
12990
  api_error = _coerce_api_error(error)
12162
- return _failed_from_api_error(
12163
- "PUBLISH_PRECHECK_FAILED",
12164
- api_error,
12165
- normalized_args=normalized_args,
12166
- details={"app_key": app_key, "phase": "prepare_publish_edit_version"},
12167
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
12991
+ return finish(
12992
+ _failed_from_api_error(
12993
+ "PUBLISH_PRECHECK_FAILED",
12994
+ api_error,
12995
+ normalized_args=normalized_args,
12996
+ details={"app_key": app_key, "phase": "prepare_publish_edit_version"},
12997
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
12998
+ )
12168
12999
  )
13000
+ edit_version_ms += _duration_ms(edit_version_started_at)
12169
13001
  edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
13002
+ publish_started_at = time.perf_counter()
12170
13003
  try:
12171
13004
  self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
12172
13005
  self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
12173
13006
  except (QingflowApiError, RuntimeError) as error:
13007
+ publish_ms += _duration_ms(publish_started_at)
12174
13008
  api_error = _coerce_api_error(error)
12175
- return _failed_from_api_error(
12176
- "PUBLISH_FAILED",
12177
- api_error,
12178
- normalized_args=normalized_args,
12179
- details={"app_key": app_key, "edit_version_no": edit_version_no},
12180
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
13009
+ return finish(
13010
+ _failed_from_api_error(
13011
+ "PUBLISH_FAILED",
13012
+ api_error,
13013
+ normalized_args=normalized_args,
13014
+ details={"app_key": app_key, "edit_version_no": edit_version_no},
13015
+ suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
13016
+ )
12181
13017
  )
13018
+ publish_ms += _duration_ms(publish_started_at)
13019
+ base_readback_started_at = time.perf_counter()
12182
13020
  try:
12183
13021
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
12184
13022
  except (QingflowApiError, RuntimeError) as error:
13023
+ base_readback_ms += _duration_ms(base_readback_started_at)
12185
13024
  api_error = _coerce_api_error(error)
12186
13025
  result = _post_write_readback_pending_result(
12187
13026
  error_code="PUBLISH_READBACK_PENDING",
@@ -12194,13 +13033,16 @@ class AiBuilderFacade:
12194
13033
  http_status=None if api_error.http_status == 404 else api_error.http_status,
12195
13034
  )
12196
13035
  result.update({"app_key": app_key, "published": None, "verified": False})
12197
- return result
13036
+ return finish(result)
13037
+ base_readback_ms += _duration_ms(base_readback_started_at)
12198
13038
  tag_ids_after = _coerce_int_list(base.get("tagIds"))
12199
13039
  app_name_after = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_name_before or "").strip() or None
12200
13040
  package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
13041
+ views_readback_started_at = time.perf_counter()
12201
13042
  try:
12202
13043
  views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
12203
13044
  except (QingflowApiError, RuntimeError) as error:
13045
+ views_readback_ms += _duration_ms(views_readback_started_at)
12204
13046
  api_error = _coerce_api_error(error)
12205
13047
  result = _post_write_readback_pending_result(
12206
13048
  error_code="VIEWS_READBACK_PENDING",
@@ -12213,7 +13055,8 @@ class AiBuilderFacade:
12213
13055
  http_status=None if api_error.http_status == 404 else api_error.http_status,
12214
13056
  )
12215
13057
  result.update({"app_key": app_key, "app_name": app_name_after, "published": bool(base.get("appPublishStatus") in {1, 2}), "verified": False})
12216
- return result
13058
+ return finish(result)
13059
+ views_readback_ms += _duration_ms(views_readback_started_at)
12217
13060
  views = views or []
12218
13061
  views_ok = isinstance(views, list) and not views_unavailable
12219
13062
  verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
@@ -12222,36 +13065,38 @@ class AiBuilderFacade:
12222
13065
  views_unavailable=views_unavailable,
12223
13066
  verified=verified,
12224
13067
  )
12225
- return {
12226
- "status": "success" if verified else "partial_success",
12227
- "error_code": None if not views_unavailable else "VIEWS_READBACK_PENDING",
12228
- "recoverable": bool(views_unavailable),
12229
- "message": "published and verified app" if not views_unavailable else "published app; views readback pending",
12230
- "normalized_args": normalized_args,
12231
- "missing_fields": [],
12232
- "allowed_values": {},
12233
- "details": {},
12234
- "request_id": None,
12235
- "suggested_next_call": None
12236
- if package_attached is not False
12237
- else {
12238
- "tool_name": "package_attach_app",
12239
- "arguments": {"profile": profile, "tag_id": expected_package_tag_id, "app_key": app_key},
12240
- },
12241
- "noop": False,
12242
- "warnings": warnings,
12243
- "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
12244
- "app_key": app_key,
12245
- "app_name": app_name_after,
12246
- "published": bool(base.get("appPublishStatus") in {1, 2}),
12247
- "package_attached": package_attached,
12248
- "tag_ids_after": tag_ids_after,
12249
- "views_ok": views_ok,
12250
- "verified": verified,
12251
- "write_executed": True,
12252
- "write_succeeded": True,
12253
- "safe_to_retry": False,
12254
- }
13068
+ return finish(
13069
+ {
13070
+ "status": "success" if verified else "partial_success",
13071
+ "error_code": None if not views_unavailable else "VIEWS_READBACK_PENDING",
13072
+ "recoverable": bool(views_unavailable),
13073
+ "message": "published and verified app" if not views_unavailable else "published app; views readback pending",
13074
+ "normalized_args": normalized_args,
13075
+ "missing_fields": [],
13076
+ "allowed_values": {},
13077
+ "details": {},
13078
+ "request_id": None,
13079
+ "suggested_next_call": None
13080
+ if package_attached is not False
13081
+ else {
13082
+ "tool_name": "package_attach_app",
13083
+ "arguments": {"profile": profile, "tag_id": expected_package_tag_id, "app_key": app_key},
13084
+ },
13085
+ "noop": False,
13086
+ "warnings": warnings,
13087
+ "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
13088
+ "app_key": app_key,
13089
+ "app_name": app_name_after,
13090
+ "published": bool(base.get("appPublishStatus") in {1, 2}),
13091
+ "package_attached": package_attached,
13092
+ "tag_ids_after": tag_ids_after,
13093
+ "views_ok": views_ok,
13094
+ "verified": verified,
13095
+ "write_executed": True,
13096
+ "write_succeeded": True,
13097
+ "safe_to_retry": False,
13098
+ }
13099
+ )
12255
13100
 
12256
13101
  def _expand_chart_partial_patches(
12257
13102
  self,
@@ -12535,6 +13380,12 @@ class AiBuilderFacade:
12535
13380
  }
12536
13381
 
12537
13382
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
13383
+ apply_started_at = time.perf_counter()
13384
+ chart_inventory_ms: int | None = None
13385
+ chart_upsert_ms: int | None = None
13386
+ chart_delete_ms: int | None = None
13387
+ chart_reorder_ms: int | None = None
13388
+ chart_readback_ms: int | None = None
12538
13389
  normalized_args = request.model_dump(mode="json")
12539
13390
  permission_outcomes: list[PermissionCheckOutcome] = []
12540
13391
  app_result = self.app_resolve(profile=profile, app_key=request.app_key)
@@ -12556,21 +13407,51 @@ class AiBuilderFacade:
12556
13407
  permission_outcomes.append(permission_outcome)
12557
13408
 
12558
13409
  def finalize(response: JSONObject) -> JSONObject:
13410
+ _merge_duration_breakdown(
13411
+ response,
13412
+ chart_inventory_ms=chart_inventory_ms,
13413
+ chart_upsert_ms=chart_upsert_ms,
13414
+ chart_delete_ms=chart_delete_ms,
13415
+ chart_reorder_ms=chart_reorder_ms,
13416
+ chart_readback_ms=chart_readback_ms,
13417
+ total_ms=_duration_ms(apply_started_at),
13418
+ )
12559
13419
  return _apply_permission_outcomes(response, *permission_outcomes)
12560
13420
 
12561
13421
  fields: list[dict[str, Any]] = []
12562
13422
  qingbi_fields: list[Any] = []
12563
13423
  existing_chart_items: list[Any] = []
12564
13424
  existing_chart_list_source: str | None = None
12565
- needs_chart_inventory = bool(request.upsert_charts or request.patch_charts or request.reorder_chart_ids)
13425
+ upsert_charts = list(request.upsert_charts)
13426
+ direct_create_upserts = [
13427
+ patch for patch in upsert_charts if not str(patch.chart_id or "").strip()
13428
+ ]
13429
+ explicit_target_upserts = [
13430
+ patch for patch in upsert_charts if str(patch.chart_id or "").strip()
13431
+ ]
13432
+ pure_direct_create = bool(direct_create_upserts) and len(direct_create_upserts) == len(upsert_charts) and not (
13433
+ request.patch_charts or request.remove_chart_ids or request.reorder_chart_ids
13434
+ )
13435
+ chart_inventory_skipped = False
13436
+ chart_final_readback_skipped = False
13437
+ needs_schema_and_qingbi_fields = bool(upsert_charts or request.patch_charts)
13438
+ needs_existing_chart_inventory = bool(explicit_target_upserts or request.patch_charts or request.reorder_chart_ids)
13439
+ needs_chart_inventory = bool(needs_schema_and_qingbi_fields or needs_existing_chart_inventory)
12566
13440
  if needs_chart_inventory:
13441
+ chart_inventory_started_at = time.perf_counter()
12567
13442
  try:
12568
- schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
12569
- parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
12570
- fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
12571
- qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
12572
- existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
13443
+ if needs_schema_and_qingbi_fields:
13444
+ schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
13445
+ parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
13446
+ fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
13447
+ qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
13448
+ if needs_existing_chart_inventory:
13449
+ existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
13450
+ elif direct_create_upserts:
13451
+ chart_inventory_skipped = True
13452
+ existing_chart_list_source = "skipped_for_direct_create"
12573
13453
  except (QingflowApiError, RuntimeError) as error:
13454
+ chart_inventory_ms = _duration_ms(chart_inventory_started_at)
12574
13455
  api_error = _coerce_api_error(error)
12575
13456
  return finalize(_failed_from_api_error(
12576
13457
  "CHART_APPLY_FAILED",
@@ -12579,6 +13460,7 @@ class AiBuilderFacade:
12579
13460
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
12580
13461
  suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
12581
13462
  ))
13463
+ chart_inventory_ms = _duration_ms(chart_inventory_started_at)
12582
13464
 
12583
13465
  field_lookup = _build_public_field_lookup(fields)
12584
13466
  qingbi_fields_by_id = {
@@ -12605,7 +13487,6 @@ class AiBuilderFacade:
12605
13487
  continue
12606
13488
  existing_by_name.setdefault(item_name, []).append(deepcopy(item))
12607
13489
 
12608
- upsert_charts = list(request.upsert_charts)
12609
13490
  if request.patch_charts:
12610
13491
  expanded_charts, patch_issues, patch_results = self._expand_chart_partial_patches(
12611
13492
  profile=profile,
@@ -12628,67 +13509,37 @@ class AiBuilderFacade:
12628
13509
  normalized_args["upsert_charts"] = [patch.model_dump(mode="json") for patch in upsert_charts]
12629
13510
  normalized_args["patch_results"] = patch_results
12630
13511
 
12631
- if len(upsert_charts) > CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE:
12632
- batching_context = _chart_apply_batching_context(
12633
- request=request,
12634
- upsert_charts=upsert_charts,
12635
- batch_size=CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE,
12636
- )
12637
- first_batch = batching_context["suggested_batch_payloads"][0]
12638
- return finalize({
12639
- "status": "failed",
12640
- "error_code": "CHART_UPSERT_BATCH_TOO_LARGE",
12641
- "recoverable": True,
12642
- "message": (
12643
- f"upsert_charts contains {len(upsert_charts)} items; split into batches of "
12644
- f"{CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE} or fewer before writing"
12645
- ),
12646
- "normalized_args": normalized_args,
12647
- "missing_fields": [],
12648
- "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
12649
- "details": batching_context,
12650
- "request_id": None,
12651
- "suggested_next_call": {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **first_batch}},
12652
- "backend_code": None,
12653
- "http_status": None,
12654
- "noop": False,
12655
- "warnings": [
12656
- _warning(
12657
- "CHART_UPSERT_BATCH_SIZE_BLOCKED",
12658
- "large chart upsert batches are blocked before write to avoid timeout and unknown write state",
12659
- upsert_count=len(upsert_charts),
12660
- max_upsert_count=CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE,
12661
- )
12662
- ],
12663
- "verification": {
12664
- "charts_verified": False,
12665
- "readback_unavailable": False,
12666
- "readback_before_retry": False,
12667
- "chart_delete_readback_results": [],
12668
- "chart_order_verified": True,
12669
- "chart_list_source": existing_chart_list_source,
12670
- },
12671
- "app_key": app_key,
12672
- "app_name": app_name,
12673
- "chart_results": [],
12674
- "verified": False,
12675
- "write_executed": False,
12676
- "write_succeeded": False,
12677
- "safe_to_retry": True,
12678
- "next_action": "split_upsert_charts_and_retry",
12679
- })
13512
+ large_upsert_warnings = (
13513
+ [
13514
+ _warning(
13515
+ "CHART_UPSERT_BATCH_SIZE_RECOMMENDED",
13516
+ "large chart upsert batch accepted; monitor duration_breakdown_ms and readback before retrying",
13517
+ upsert_count=len(upsert_charts),
13518
+ recommended_upsert_count=CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE,
13519
+ )
13520
+ ]
13521
+ if len(upsert_charts) > CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE
13522
+ else []
13523
+ )
12680
13524
 
12681
13525
  chart_results: list[dict[str, Any]] = []
12682
13526
  created_ids: list[str] = []
12683
13527
  updated_ids: list[str] = []
12684
13528
  removed_ids: list[str] = []
12685
13529
  failed_items: list[dict[str, Any]] = []
13530
+ skipped_after_timeout_count = 0
13531
+ stopped_after_timeout = False
12686
13532
  delete_readback_issues: list[dict[str, Any]] = []
13533
+ write_attempted = False
13534
+ created_ids_from_create_response: list[str] = []
13535
+ create_id_readback_fallback_used = False
12687
13536
 
12688
- for patch in upsert_charts:
13537
+ chart_upsert_started_at = time.perf_counter()
13538
+ for patch_index, patch in enumerate(upsert_charts):
12689
13539
  chart_id = ""
12690
13540
  target_type = ""
12691
13541
  config_payload: dict[str, Any] | None = None
13542
+ patch_write_attempted = False
12692
13543
  try:
12693
13544
  dataset_source = _chart_patch_dataset_source_type(patch)
12694
13545
  if dataset_source:
@@ -12708,7 +13559,7 @@ class AiBuilderFacade:
12708
13559
  existing = existing_by_id.get(str(patch.chart_id))
12709
13560
  if existing is None:
12710
13561
  raise ValueError(f"chart_id '{patch.chart_id}' was not found under app '{app_key}'")
12711
- if existing is None:
13562
+ if existing is None and needs_existing_chart_inventory:
12712
13563
  name_matches = list(existing_by_name.get(patch.name) or [])
12713
13564
  if len(name_matches) > 1:
12714
13565
  raise ValueError(
@@ -12737,6 +13588,7 @@ class AiBuilderFacade:
12737
13588
  )
12738
13589
  if existing is None:
12739
13590
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
13591
+ chart_id = temp_chart_id
12740
13592
  create_payload = {
12741
13593
  "chartId": temp_chart_id,
12742
13594
  "chartName": patch.name,
@@ -12750,9 +13602,13 @@ class AiBuilderFacade:
12750
13602
  "editAuthType": "ws",
12751
13603
  "editAuthIncludeSubDept": True,
12752
13604
  }
13605
+ write_attempted = True
13606
+ patch_write_attempted = True
12753
13607
  create_result = self.charts.qingbi_report_create(profile=profile, payload=create_payload).get("result") or {}
12754
13608
  created_chart_id = _extract_chart_identifier(create_result or {})
13609
+ created_id_from_response = bool(created_chart_id)
12755
13610
  if not created_chart_id:
13611
+ create_id_readback_fallback_used = True
12756
13612
  try:
12757
13613
  refreshed_items, _ = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
12758
13614
  refreshed_matches = _find_charts_by_name(
@@ -12774,6 +13630,8 @@ class AiBuilderFacade:
12774
13630
  )
12775
13631
  chart_id = created_chart_id
12776
13632
  created_ids.append(chart_id)
13633
+ if created_id_from_response:
13634
+ created_ids_from_create_response.append(chart_id)
12777
13635
  created_chart = {
12778
13636
  "chartId": chart_id,
12779
13637
  "chartName": patch.name,
@@ -12783,6 +13641,8 @@ class AiBuilderFacade:
12783
13641
  existing_by_name.setdefault(patch.name, []).append(deepcopy(created_chart))
12784
13642
  elif existing_name != patch.name or existing_type != target_type or isinstance(chart_visible_auth, dict):
12785
13643
  base_info = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
13644
+ write_attempted = True
13645
+ patch_write_attempted = True
12786
13646
  self.charts.qingbi_report_update_base(
12787
13647
  profile=profile,
12788
13648
  chart_id=chart_id,
@@ -12821,11 +13681,15 @@ class AiBuilderFacade:
12821
13681
 
12822
13682
  config_updated = False
12823
13683
  if existing is None or config_update_requested:
13684
+ write_attempted = True
13685
+ patch_write_attempted = True
12824
13686
  self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload or {})
12825
13687
  config_updated = True
12826
13688
  if existing is not None and chart_id not in updated_ids and config_updated:
12827
13689
  updated_ids.append(chart_id)
12828
13690
  if patch.question_config:
13691
+ write_attempted = True
13692
+ patch_write_attempted = True
12829
13693
  self._request_backend(
12830
13694
  profile=profile,
12831
13695
  method="POST",
@@ -12833,6 +13697,8 @@ class AiBuilderFacade:
12833
13697
  json_body=patch.question_config,
12834
13698
  )
12835
13699
  if patch.user_config:
13700
+ write_attempted = True
13701
+ patch_write_attempted = True
12836
13702
  self._request_backend(
12837
13703
  profile=profile,
12838
13704
  method="POST",
@@ -12872,9 +13738,30 @@ class AiBuilderFacade:
12872
13738
  }
12873
13739
  failed_items.append(failure)
12874
13740
  chart_results.append(failure)
13741
+ if api_error is not None and patch_write_attempted and _is_uncertain_write_transport_error(api_error):
13742
+ stopped_after_timeout = True
13743
+ remaining_upserts = upsert_charts[patch_index + 1 :]
13744
+ skipped_after_timeout_count = len(remaining_upserts)
13745
+ for remaining_patch in remaining_upserts:
13746
+ skipped = {
13747
+ "chart_id": str(remaining_patch.chart_id or ""),
13748
+ "name": remaining_patch.name,
13749
+ "chart_type": remaining_patch.chart_type.value,
13750
+ "status": "skipped",
13751
+ "error_code": "CHART_APPLY_SKIPPED_AFTER_TIMEOUT",
13752
+ "message": "skipped because a previous chart write timed out with uncertain backend state; read back charts before retrying remaining items",
13753
+ "depends_on_failed_chart": patch.name,
13754
+ "readback_before_retry": True,
13755
+ }
13756
+ failed_items.append(skipped)
13757
+ chart_results.append(skipped)
13758
+ break
13759
+ chart_upsert_ms = _duration_ms(chart_upsert_started_at)
12875
13760
 
13761
+ chart_delete_started_at = time.perf_counter()
12876
13762
  for chart_id in request.remove_chart_ids:
12877
13763
  try:
13764
+ write_attempted = True
12878
13765
  self.charts.qingbi_report_delete(profile=profile, chart_id=chart_id)
12879
13766
  removed_ids.append(chart_id)
12880
13767
  delete_result = self._verify_chart_deleted_by_id(profile=profile, chart_id=chart_id)
@@ -12896,8 +13783,10 @@ class AiBuilderFacade:
12896
13783
  }
12897
13784
  failed_items.append(failure)
12898
13785
  chart_results.append(failure)
13786
+ chart_delete_ms = _duration_ms(chart_delete_started_at)
12899
13787
 
12900
13788
  reordered = False
13789
+ chart_reorder_started_at = time.perf_counter()
12901
13790
  if request.reorder_chart_ids:
12902
13791
  try:
12903
13792
  current_order = [
@@ -12907,6 +13796,7 @@ class AiBuilderFacade:
12907
13796
  ]
12908
13797
  desired_display_order = list(request.reorder_chart_ids) + [chart_id for chart_id in current_order if chart_id not in request.reorder_chart_ids]
12909
13798
  backend_reorder_ids = list(reversed(desired_display_order))
13799
+ write_attempted = True
12910
13800
  self.charts.qingbi_report_reorder(profile=profile, app_key=app_key, chart_ids=backend_reorder_ids)
12911
13801
  reordered = True
12912
13802
  except (QingflowApiError, RuntimeError) as error:
@@ -12921,17 +13811,34 @@ class AiBuilderFacade:
12921
13811
  }
12922
13812
  failed_items.append(failure)
12923
13813
  chart_results.append(failure)
13814
+ chart_reorder_ms = _duration_ms(chart_reorder_started_at)
12924
13815
 
12925
13816
  noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
12926
- write_executed = bool(created_ids or updated_ids or removed_ids or reordered)
12927
- write_succeeded = write_executed
12928
- needs_list_readback = bool(created_ids or updated_ids or reordered)
13817
+ confirmed_write = bool(created_ids or updated_ids or removed_ids or reordered)
13818
+ write_executed = bool(confirmed_write or write_attempted)
13819
+ write_succeeded = confirmed_write
13820
+ chart_final_readback_skipped = bool(
13821
+ pure_direct_create
13822
+ and created_ids
13823
+ and not updated_ids
13824
+ and not removed_ids
13825
+ and not reordered
13826
+ and not failed_items
13827
+ and not create_id_readback_fallback_used
13828
+ and len(created_ids_from_create_response) == len(created_ids) == len(direct_create_upserts)
13829
+ )
13830
+ needs_list_readback = bool(created_ids or updated_ids or reordered) and not chart_final_readback_skipped
12929
13831
  delete_readback_unavailable = any(item.get("readback_status") == "unavailable" for item in delete_readback_issues)
12930
13832
  deletes_verified = not delete_readback_issues
12931
13833
  readback_unavailable = False
12932
13834
  readback_error: QingflowApiError | None = None
12933
13835
  readback_list_source: str | None = existing_chart_list_source
12934
- if needs_list_readback:
13836
+ if chart_final_readback_skipped:
13837
+ verified = True
13838
+ chart_readback_ms = 0
13839
+ readback_list_source = "skipped_for_direct_create"
13840
+ elif needs_list_readback:
13841
+ chart_readback_started_at = time.perf_counter()
12935
13842
  try:
12936
13843
  readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
12937
13844
  readback_ids = {
@@ -12954,8 +13861,10 @@ class AiBuilderFacade:
12954
13861
  verified = False
12955
13862
  readback_unavailable = True
12956
13863
  readback_list_source = None
13864
+ chart_readback_ms = _duration_ms(chart_readback_started_at)
12957
13865
  else:
12958
13866
  verified = True
13867
+ chart_readback_ms = 0
12959
13868
  verified = verified and deletes_verified
12960
13869
  any_readback_unavailable = readback_unavailable or delete_readback_unavailable
12961
13870
 
@@ -12983,6 +13892,10 @@ class AiBuilderFacade:
12983
13892
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
12984
13893
  "details": {
12985
13894
  "per_chart_results": chart_results,
13895
+ "stopped_after_timeout": stopped_after_timeout,
13896
+ "skipped_after_timeout_count": skipped_after_timeout_count,
13897
+ "inventory_readback_skipped": chart_inventory_skipped,
13898
+ "final_readback_skipped": chart_final_readback_skipped,
12986
13899
  **retry_context,
12987
13900
  **({"readback_error": _transport_error_payload(readback_error)} if readback_error is not None else {}),
12988
13901
  },
@@ -12991,7 +13904,7 @@ class AiBuilderFacade:
12991
13904
  "backend_code": failed_items[0].get("backend_code") or (readback_error.backend_code if readback_error is not None else None),
12992
13905
  "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),
12993
13906
  "noop": noop,
12994
- "warnings": _chart_apply_warnings(
13907
+ "warnings": large_upsert_warnings + _chart_apply_warnings(
12995
13908
  failed_items=failed_items,
12996
13909
  readback_unavailable=readback_unavailable,
12997
13910
  verified=False if failed_items else verified,
@@ -13004,6 +13917,8 @@ class AiBuilderFacade:
13004
13917
  "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
13005
13918
  "chart_order_verified": False if request.reorder_chart_ids else True,
13006
13919
  "chart_list_source": readback_list_source or existing_chart_list_source,
13920
+ "inventory_readback_skipped": chart_inventory_skipped,
13921
+ "final_readback_skipped": chart_final_readback_skipped,
13007
13922
  },
13008
13923
  "app_key": app_key,
13009
13924
  "app_name": app_name,
@@ -13031,13 +13946,18 @@ class AiBuilderFacade:
13031
13946
  "normalized_args": normalized_args,
13032
13947
  "missing_fields": [],
13033
13948
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
13034
- "details": {"readback_error": _transport_error_payload(readback_error)} if readback_error is not None else {},
13949
+ "details": {
13950
+ "inventory_readback_skipped": chart_inventory_skipped,
13951
+ "final_readback_skipped": chart_final_readback_skipped,
13952
+ **({"readback_skip_reason": "direct_create_response_ids"} if chart_final_readback_skipped else {}),
13953
+ **({"readback_error": _transport_error_payload(readback_error)} if readback_error is not None else {}),
13954
+ },
13035
13955
  "request_id": readback_error.request_id if readback_error is not None else None,
13036
13956
  "suggested_next_call": None if result_verified else pending_suggestion,
13037
13957
  "backend_code": readback_error.backend_code if readback_error is not None else None,
13038
13958
  "http_status": None if readback_error is None or readback_error.http_status == 404 else readback_error.http_status,
13039
13959
  "noop": noop,
13040
- "warnings": _chart_apply_warnings(
13960
+ "warnings": large_upsert_warnings + _chart_apply_warnings(
13041
13961
  failed_items=[],
13042
13962
  readback_unavailable=False if noop else readback_unavailable,
13043
13963
  verified=result_verified,
@@ -13049,6 +13969,8 @@ class AiBuilderFacade:
13049
13969
  "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
13050
13970
  "chart_order_verified": (readback_list_source == "sorted" and result_verified) if request.reorder_chart_ids else True,
13051
13971
  "chart_list_source": existing_chart_list_source if noop else readback_list_source,
13972
+ "inventory_readback_skipped": chart_inventory_skipped,
13973
+ "final_readback_skipped": chart_final_readback_skipped,
13052
13974
  },
13053
13975
  "app_key": app_key,
13054
13976
  "app_name": app_name,
@@ -13060,6 +13982,15 @@ class AiBuilderFacade:
13060
13982
  })
13061
13983
 
13062
13984
  def portal_apply(self, *, profile: str, request: PortalApplyRequest) -> JSONObject:
13985
+ apply_started_at = time.perf_counter()
13986
+ portal_initial_read_ms: int | None = None
13987
+ portal_create_ms: int | None = None
13988
+ portal_component_build_ms: int | None = None
13989
+ portal_update_ms: int | None = None
13990
+ portal_base_info_update_ms: int | None = None
13991
+ portal_draft_readback_ms: int | None = None
13992
+ portal_publish_ms: int | None = None
13993
+ portal_live_readback_ms: int | None = None
13063
13994
  normalized_args = request.model_dump(mode="json")
13064
13995
  permission_outcomes: list[PermissionCheckOutcome] = []
13065
13996
  dash_key = str(request.dash_key or "").strip()
@@ -13089,8 +14020,10 @@ class AiBuilderFacade:
13089
14020
  suggested_next_call=None,
13090
14021
  )
13091
14022
  try:
14023
+ portal_initial_read_started_at = time.perf_counter()
13092
14024
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") if dash_key else {}
13093
14025
  except (QingflowApiError, RuntimeError) as error:
14026
+ portal_initial_read_ms = _duration_ms(portal_initial_read_started_at)
13094
14027
  api_error = _coerce_api_error(error)
13095
14028
  return _failed(
13096
14029
  "PORTAL_APPLY_FAILED",
@@ -13102,6 +14035,7 @@ class AiBuilderFacade:
13102
14035
  backend_code=api_error.backend_code,
13103
14036
  http_status=None if api_error.http_status == 404 else api_error.http_status,
13104
14037
  )
14038
+ portal_initial_read_ms = _duration_ms(portal_initial_read_started_at) if dash_key else 0
13105
14039
  if not isinstance(base_payload, dict):
13106
14040
  base_payload = {}
13107
14041
  if not creating:
@@ -13116,6 +14050,18 @@ class AiBuilderFacade:
13116
14050
  permission_outcomes.append(portal_permission_outcome)
13117
14051
 
13118
14052
  def finalize(response: JSONObject) -> JSONObject:
14053
+ _merge_duration_breakdown(
14054
+ response,
14055
+ portal_initial_read_ms=portal_initial_read_ms,
14056
+ portal_create_ms=portal_create_ms,
14057
+ portal_component_build_ms=portal_component_build_ms,
14058
+ portal_update_ms=portal_update_ms,
14059
+ portal_base_info_update_ms=portal_base_info_update_ms,
14060
+ portal_draft_readback_ms=portal_draft_readback_ms,
14061
+ portal_publish_ms=portal_publish_ms,
14062
+ portal_live_readback_ms=portal_live_readback_ms,
14063
+ total_ms=_duration_ms(apply_started_at),
14064
+ )
13119
14065
  return _apply_permission_outcomes(response, *permission_outcomes)
13120
14066
  target_package_tag_id = request.package_tag_id
13121
14067
  if target_package_tag_id is None:
@@ -13175,7 +14121,9 @@ class AiBuilderFacade:
13175
14121
  config=request.config,
13176
14122
  base_payload=None,
13177
14123
  )
14124
+ portal_create_started_at = time.perf_counter()
13178
14125
  create_result = self.portals.portal_create(profile=profile, payload=create_payload)
14126
+ portal_create_ms = _duration_ms(portal_create_started_at)
13179
14127
  write_executed = True
13180
14128
  created = create_result.get("result") if isinstance(create_result.get("result"), dict) else {}
13181
14129
  dash_key = str(created.get("dashKey") or "")
@@ -13208,36 +14156,61 @@ class AiBuilderFacade:
13208
14156
  base_payload=base_payload,
13209
14157
  )
13210
14158
  if sections_requested:
14159
+ portal_component_build_started_at = time.perf_counter()
13211
14160
  component_payload, layout_metadata, inline_chart_results = self._build_portal_components_from_sections(
13212
14161
  profile=profile,
13213
14162
  sections=request.sections,
13214
14163
  layout_preset=request.layout_preset,
13215
14164
  inline_chart_results=inline_chart_results,
13216
14165
  )
14166
+ portal_component_build_ms = _duration_ms(portal_component_build_started_at)
13217
14167
  layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload, layout_metadata=layout_metadata)
13218
14168
  update_payload["components"] = component_payload
14169
+ portal_update_started_at = time.perf_counter()
13219
14170
  self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
14171
+ portal_update_ms = _duration_ms(portal_update_started_at)
13220
14172
  write_executed = True
13221
- self.portals.portal_update_base_info(
13222
- profile=profile,
13223
- dash_key=dash_key,
13224
- payload={
13225
- "dashName": update_payload.get("dashName"),
13226
- "dashIcon": update_payload.get("dashIcon"),
13227
- "auth": deepcopy(update_payload.get("auth")),
13228
- "tags": deepcopy(update_payload.get("tags") or []),
13229
- },
13230
- )
13231
- write_executed = True
14173
+ else:
14174
+ portal_component_build_ms = 0
14175
+ portal_update_ms = 0
14176
+ base_info_update_requested = bool(
14177
+ (not creating and not sections_requested)
14178
+ or request.dash_name is not None
14179
+ or request.icon is not None
14180
+ or request.color is not None
14181
+ or request.visibility is not None
14182
+ or request.auth is not None
14183
+ or request.package_tag_id is not None
14184
+ )
14185
+ if base_info_update_requested:
14186
+ portal_base_info_update_started_at = time.perf_counter()
14187
+ self.portals.portal_update_base_info(
14188
+ profile=profile,
14189
+ dash_key=dash_key,
14190
+ payload={
14191
+ "dashName": update_payload.get("dashName"),
14192
+ "dashIcon": update_payload.get("dashIcon"),
14193
+ "auth": deepcopy(update_payload.get("auth")),
14194
+ "tags": deepcopy(update_payload.get("tags") or []),
14195
+ },
14196
+ )
14197
+ portal_base_info_update_ms = _duration_ms(portal_base_info_update_started_at)
14198
+ write_executed = True
14199
+ else:
14200
+ portal_base_info_update_ms = 0
13232
14201
  try:
14202
+ portal_draft_readback_started_at = time.perf_counter()
13233
14203
  draft_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
13234
14204
  except (QingflowApiError, RuntimeError) as read_error:
14205
+ portal_draft_readback_ms = _duration_ms(portal_draft_readback_started_at)
13235
14206
  api_read_error = _coerce_api_error(read_error)
13236
14207
  draft_readback_error = {
13237
14208
  "phase": "portal_draft_readback",
13238
14209
  "transport_error": _transport_error_payload(api_read_error),
13239
14210
  }
13240
14211
  draft_result = {}
14212
+ else:
14213
+ portal_draft_readback_ms = _duration_ms(portal_draft_readback_started_at)
13241
14214
  except (QingflowApiError, RuntimeError, ValueError) as error:
13242
14215
  api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
13243
14216
  inline_chart_write_executed = any(bool(item.get("write_executed")) for item in inline_chart_results)
@@ -13313,9 +14286,12 @@ class AiBuilderFacade:
13313
14286
  publish_error: JSONObject | None = None
13314
14287
  if request.publish:
13315
14288
  try:
14289
+ portal_publish_started_at = time.perf_counter()
13316
14290
  self.portals.portal_publish(profile=profile, dash_key=dash_key)
14291
+ portal_publish_ms = _duration_ms(portal_publish_started_at)
13317
14292
  published = True
13318
14293
  except (QingflowApiError, RuntimeError) as error:
14294
+ portal_publish_ms = _duration_ms(portal_publish_started_at)
13319
14295
  api_error = _coerce_api_error(error)
13320
14296
  publish_failed = True
13321
14297
  publish_error = {
@@ -13324,13 +14300,20 @@ class AiBuilderFacade:
13324
14300
  }
13325
14301
  if published:
13326
14302
  try:
14303
+ portal_live_readback_started_at = time.perf_counter()
13327
14304
  live_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=False).get("result") or {}
13328
14305
  except (QingflowApiError, RuntimeError) as read_error:
14306
+ portal_live_readback_ms = _duration_ms(portal_live_readback_started_at)
13329
14307
  api_read_error = _coerce_api_error(read_error)
13330
14308
  live_readback_error = {
13331
14309
  "phase": "portal_live_readback",
13332
14310
  "transport_error": _transport_error_payload(api_read_error),
13333
14311
  }
14312
+ else:
14313
+ portal_live_readback_ms = _duration_ms(portal_live_readback_started_at)
14314
+ else:
14315
+ portal_publish_ms = 0
14316
+ portal_live_readback_ms = 0
13334
14317
 
13335
14318
  draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
13336
14319
  expected_count = len(request.sections) if sections_requested else None
@@ -13469,55 +14452,79 @@ class AiBuilderFacade:
13469
14452
  })
13470
14453
 
13471
14454
  def portal_delete(self, *, profile: str, dash_key: str) -> JSONObject:
14455
+ apply_started_at = time.perf_counter()
14456
+ portal_delete_ms = 0
14457
+ portal_delete_readback_ms = 0
13472
14458
  normalized_args = {"dash_key": dash_key}
14459
+
14460
+ def finish(response: JSONObject) -> JSONObject:
14461
+ _merge_duration_breakdown(
14462
+ response,
14463
+ portal_delete_ms=portal_delete_ms,
14464
+ portal_delete_readback_ms=portal_delete_readback_ms,
14465
+ total_ms=_duration_ms(apply_started_at),
14466
+ )
14467
+ return response
14468
+
13473
14469
  dash_key = str(dash_key or "").strip()
13474
14470
  if not dash_key:
13475
- return _failed(
13476
- "PORTAL_DASH_KEY_REQUIRED",
13477
- "dash_key is required when deleting a portal",
13478
- normalized_args=normalized_args,
13479
- missing_fields=["dash_key"],
13480
- suggested_next_call={"tool_name": "portal_delete", "arguments": {"profile": profile, "dash_key": "DASH_KEY"}},
14471
+ return finish(
14472
+ _failed(
14473
+ "PORTAL_DASH_KEY_REQUIRED",
14474
+ "dash_key is required when deleting a portal",
14475
+ normalized_args=normalized_args,
14476
+ missing_fields=["dash_key"],
14477
+ suggested_next_call={"tool_name": "portal_delete", "arguments": {"profile": profile, "dash_key": "DASH_KEY"}},
14478
+ )
13481
14479
  )
14480
+ portal_delete_started_at = time.perf_counter()
13482
14481
  try:
13483
14482
  self.portals.portal_delete(profile=profile, dash_key=dash_key)
13484
14483
  except (QingflowApiError, RuntimeError) as error:
14484
+ portal_delete_ms += _duration_ms(portal_delete_started_at)
13485
14485
  api_error = _coerce_api_error(error)
13486
- return _failed_from_api_error(
13487
- "PORTAL_DELETE_FAILED",
13488
- api_error,
13489
- normalized_args=normalized_args,
13490
- details={"dash_key": dash_key},
13491
- suggested_next_call={"tool_name": "portal_delete", "arguments": {"profile": profile, **normalized_args}},
14486
+ return finish(
14487
+ _failed_from_api_error(
14488
+ "PORTAL_DELETE_FAILED",
14489
+ api_error,
14490
+ normalized_args=normalized_args,
14491
+ details={"dash_key": dash_key},
14492
+ suggested_next_call={"tool_name": "portal_delete", "arguments": {"profile": profile, **normalized_args}},
14493
+ )
13492
14494
  )
14495
+ portal_delete_ms += _duration_ms(portal_delete_started_at)
13493
14496
 
14497
+ portal_delete_readback_started_at = time.perf_counter()
13494
14498
  delete_readback = self._verify_portal_deleted_by_key(profile=profile, dash_key=dash_key)
14499
+ portal_delete_readback_ms += _duration_ms(portal_delete_readback_started_at)
13495
14500
  verified = delete_readback.get("readback_status") == "deleted"
13496
- return {
13497
- "status": "success" if verified else "partial_success",
13498
- "error_code": None if verified else delete_readback.get("error_code") or "PORTAL_DELETE_READBACK_PENDING",
13499
- "recoverable": not verified,
13500
- "message": "deleted portal" if verified else "portal delete completed; readback pending",
13501
- "normalized_args": normalized_args,
13502
- "missing_fields": [],
13503
- "allowed_values": {},
13504
- "details": {} if verified else {"delete_readback": delete_readback},
13505
- "request_id": delete_readback.get("request_id"),
13506
- "backend_code": delete_readback.get("backend_code"),
13507
- "http_status": delete_readback.get("http_status"),
13508
- "suggested_next_call": None if verified else {"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key}},
13509
- "noop": False,
13510
- "warnings": [] if verified else [_warning("PORTAL_DELETE_READBACK_PENDING", "portal delete was sent, but deletion readback is not fully verified")],
13511
- "verification": {"portal_deleted": verified, "delete_readback": delete_readback},
13512
- "verified": verified,
13513
- "dash_key": dash_key,
13514
- "deleted": verified,
13515
- "delete_executed": True,
13516
- "readback_status": delete_readback.get("readback_status"),
13517
- "safe_to_retry_delete": False,
13518
- "write_executed": True,
13519
- "safe_to_retry": False,
13520
- }
14501
+ return finish(
14502
+ {
14503
+ "status": "success" if verified else "partial_success",
14504
+ "error_code": None if verified else delete_readback.get("error_code") or "PORTAL_DELETE_READBACK_PENDING",
14505
+ "recoverable": not verified,
14506
+ "message": "deleted portal" if verified else "portal delete completed; readback pending",
14507
+ "normalized_args": normalized_args,
14508
+ "missing_fields": [],
14509
+ "allowed_values": {},
14510
+ "details": {} if verified else {"delete_readback": delete_readback},
14511
+ "request_id": delete_readback.get("request_id"),
14512
+ "backend_code": delete_readback.get("backend_code"),
14513
+ "http_status": delete_readback.get("http_status"),
14514
+ "suggested_next_call": None if verified else {"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key}},
14515
+ "noop": False,
14516
+ "warnings": [] if verified else [_warning("PORTAL_DELETE_READBACK_PENDING", "portal delete was sent, but deletion readback is not fully verified")],
14517
+ "verification": {"portal_deleted": verified, "delete_readback": delete_readback},
14518
+ "verified": verified,
14519
+ "dash_key": dash_key,
14520
+ "deleted": verified,
14521
+ "delete_executed": True,
14522
+ "readback_status": delete_readback.get("readback_status"),
14523
+ "safe_to_retry_delete": False,
14524
+ "write_executed": True,
14525
+ "safe_to_retry": False,
14526
+ }
14527
+ )
13521
14528
 
13522
14529
  def _verify_portal_deleted_by_key(self, *, profile: str, dash_key: str) -> JSONObject:
13523
14530
  try:
@@ -13920,36 +14927,30 @@ class AiBuilderFacade:
13920
14927
  app_key: str,
13921
14928
  app_name: str,
13922
14929
  package_tag_id: int | None,
13923
- create_if_missing: bool,
13924
14930
  ) -> JSONObject:
13925
14931
  if app_key:
13926
14932
  return self.app_resolve(profile=profile, app_key=app_key)
13927
14933
  if app_name:
13928
- resolved = self.app_resolve(profile=profile, app_name=app_name, package_tag_id=package_tag_id)
13929
- if resolved.get("status") == "success":
13930
- return resolved
13931
- if not create_if_missing:
13932
- return resolved
13933
- elif not create_if_missing:
14934
+ return {
14935
+ "status": "success",
14936
+ "error_code": None,
14937
+ "recoverable": False,
14938
+ "message": "target app would be created",
14939
+ "normalized_args": {},
14940
+ "missing_fields": [],
14941
+ "allowed_values": {},
14942
+ "details": {},
14943
+ "request_id": None,
14944
+ "suggested_next_call": None,
14945
+ "noop": False,
14946
+ "verification": {},
14947
+ "app_key": "",
14948
+ "app_name": app_name,
14949
+ "tag_ids": [package_tag_id] if package_tag_id else [],
14950
+ "would_create": True,
14951
+ }
14952
+ else:
13934
14953
  return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", suggested_next_call=None)
13935
- return {
13936
- "status": "success",
13937
- "error_code": None,
13938
- "recoverable": False,
13939
- "message": "target app would be created",
13940
- "normalized_args": {},
13941
- "missing_fields": [],
13942
- "allowed_values": {},
13943
- "details": {},
13944
- "request_id": None,
13945
- "suggested_next_call": None,
13946
- "noop": False,
13947
- "verification": {},
13948
- "app_key": "",
13949
- "app_name": app_name or "未命名应用",
13950
- "tag_ids": [package_tag_id] if package_tag_id else [],
13951
- "would_create": True,
13952
- }
13953
14954
 
13954
14955
  def _create_target_app_shell(
13955
14956
  self,
@@ -14018,7 +15019,7 @@ class AiBuilderFacade:
14018
15019
  if not new_app_key:
14019
15020
  return _failed("APP_CREATE_FAILED", "failed to create app shell", details={"result": result}, suggested_next_call=None)
14020
15021
  try:
14021
- base = self.apps.app_get_base(profile=profile, app_key=new_app_key, include_raw=True).get("result") or {}
15022
+ base = self._read_app_base_raw_direct(profile=profile, app_key=new_app_key)
14022
15023
  except (QingflowApiError, RuntimeError) as error:
14023
15024
  api_error = _coerce_api_error(error)
14024
15025
  if api_error.http_status != 404:
@@ -14211,12 +15212,104 @@ class AiBuilderFacade:
14211
15212
  resolved_components: list[dict[str, Any]] = []
14212
15213
  layout_metadata: list[dict[str, Any]] = []
14213
15214
  inline_chart_results = inline_chart_results if inline_chart_results is not None else []
15215
+ inline_chart_resolutions: dict[int, dict[str, Any]] = {}
15216
+ inline_chart_result_by_section: dict[int, dict[str, Any]] = {}
15217
+ inline_chart_failures: list[str] = []
15218
+ inline_chart_batches: dict[str, list[tuple[int, PortalSectionPatch, ChartUpsertPatch]]] = {}
15219
+ for section_index, section in enumerate(sections):
15220
+ if section.source_type != "chart" or section.chart is None:
15221
+ continue
15222
+ inline_chart_payload = section.chart.model_dump(mode="json", exclude={"app_key"})
15223
+ inline_chart_batches.setdefault(section.chart.app_key, []).append(
15224
+ (section_index, section, ChartUpsertPatch.model_validate(inline_chart_payload))
15225
+ )
15226
+ for app_key, batch_entries in inline_chart_batches.items():
15227
+ chart_request = ChartApplyRequest(
15228
+ app_key=app_key,
15229
+ upsert_charts=[patch for _, _, patch in batch_entries],
15230
+ patch_charts=[],
15231
+ remove_chart_ids=[],
15232
+ reorder_chart_ids=[],
15233
+ )
15234
+ chart_apply = self.chart_apply(profile=profile, request=chart_request)
15235
+ chart_items = chart_apply.get("chart_results") if isinstance(chart_apply.get("chart_results"), list) else []
15236
+ matched_chart_item_indexes: set[int] = set()
15237
+ for batch_index, (section_index, section, patch) in enumerate(batch_entries):
15238
+ result_item: dict[str, Any] | None = None
15239
+ if (
15240
+ batch_index < len(chart_items)
15241
+ and batch_index not in matched_chart_item_indexes
15242
+ and isinstance(chart_items[batch_index], dict)
15243
+ and str(chart_items[batch_index].get("name") or "").strip() == patch.name
15244
+ ):
15245
+ result_item = chart_items[batch_index]
15246
+ matched_chart_item_indexes.add(batch_index)
15247
+ if result_item is None:
15248
+ for item_index, item in enumerate(chart_items):
15249
+ if item_index in matched_chart_item_indexes or not isinstance(item, dict):
15250
+ continue
15251
+ if str(item.get("name") or "").strip() == patch.name:
15252
+ result_item = item
15253
+ matched_chart_item_indexes.add(item_index)
15254
+ break
15255
+ if (
15256
+ result_item is None
15257
+ and batch_index < len(chart_items)
15258
+ and batch_index not in matched_chart_item_indexes
15259
+ and isinstance(chart_items[batch_index], dict)
15260
+ ):
15261
+ result_item = chart_items[batch_index]
15262
+ matched_chart_item_indexes.add(batch_index)
15263
+ successful_chart = (
15264
+ result_item
15265
+ if isinstance(result_item, dict)
15266
+ and str(result_item.get("status") or "") in {"created", "updated"}
15267
+ and str(result_item.get("chart_id") or "").strip()
15268
+ else None
15269
+ )
15270
+ inline_chart_result_by_section[section_index] = {
15271
+ "title": section.title,
15272
+ "app_key": app_key,
15273
+ "chart_name": section.chart.name,
15274
+ "chart_type": section.chart.chart_type.value,
15275
+ "status": str(successful_chart.get("status") or "") if isinstance(successful_chart, dict) else "failed",
15276
+ "chart_id": str(successful_chart.get("chart_id") or "") if isinstance(successful_chart, dict) else "",
15277
+ "chart_apply_status": chart_apply.get("status"),
15278
+ "write_executed": bool(chart_apply.get("write_executed")),
15279
+ "batch_size": len(batch_entries),
15280
+ "batch_index": batch_index,
15281
+ **(
15282
+ {"duration_breakdown_ms": deepcopy(chart_apply.get("duration_breakdown_ms"))}
15283
+ if isinstance(chart_apply.get("duration_breakdown_ms"), dict)
15284
+ else {}
15285
+ ),
15286
+ **({"error_code": chart_apply.get("error_code")} if chart_apply.get("error_code") else {}),
15287
+ **({"message": chart_apply.get("message")} if chart_apply.get("message") else {}),
15288
+ }
15289
+ if not isinstance(successful_chart, dict):
15290
+ inline_chart_failures.append(
15291
+ f"inline chart '{section.chart.name}' could not be created or updated for portal section '{section.title}'"
15292
+ )
15293
+ continue
15294
+ inline_chart_resolutions[section_index] = {
15295
+ "chart_id": str(successful_chart.get("chart_id") or "").strip(),
15296
+ "chart_name": str(successful_chart.get("name") or section.chart.name).strip(),
15297
+ "app_key": app_key,
15298
+ "chart_type": section.chart.chart_type.value,
15299
+ }
15300
+ if inline_chart_result_by_section:
15301
+ inline_chart_results.extend(
15302
+ deepcopy(inline_chart_result_by_section[section_index])
15303
+ for section_index in sorted(inline_chart_result_by_section)
15304
+ )
15305
+ if inline_chart_failures:
15306
+ raise ValueError(inline_chart_failures[0])
14214
15307
  pc_x = 0
14215
15308
  pc_y = 0
14216
15309
  pc_row_height = 0
14217
15310
  mobile_y = 0
14218
15311
  app_title_cache: dict[str, str] = {}
14219
- for section in sections:
15312
+ for section_index, section in enumerate(sections):
14220
15313
  position_payload: dict[str, Any]
14221
15314
  if section.position is None:
14222
15315
  position_payload, pc_x, pc_y, pc_row_height, mobile_y = _portal_component_position_public(
@@ -14238,46 +15331,9 @@ class AiBuilderFacade:
14238
15331
  component: dict[str, Any]
14239
15332
  if section.source_type == "chart":
14240
15333
  if section.chart is not None:
14241
- inline_chart_payload = section.chart.model_dump(mode="json", exclude={"app_key"})
14242
- chart_request = ChartApplyRequest(
14243
- app_key=section.chart.app_key,
14244
- upsert_charts=[ChartUpsertPatch.model_validate(inline_chart_payload)],
14245
- patch_charts=[],
14246
- remove_chart_ids=[],
14247
- reorder_chart_ids=[],
14248
- )
14249
- chart_apply = self.chart_apply(profile=profile, request=chart_request)
14250
- chart_items = chart_apply.get("chart_results") if isinstance(chart_apply.get("chart_results"), list) else []
14251
- successful_chart = next(
14252
- (
14253
- item
14254
- for item in chart_items
14255
- if isinstance(item, dict)
14256
- and str(item.get("status") or "") in {"created", "updated"}
14257
- and str(item.get("chart_id") or "").strip()
14258
- ),
14259
- None,
14260
- )
14261
- inline_chart_results.append({
14262
- "title": section.title,
14263
- "app_key": section.chart.app_key,
14264
- "chart_name": section.chart.name,
14265
- "chart_type": section.chart.chart_type.value,
14266
- "status": str(successful_chart.get("status") or "") if isinstance(successful_chart, dict) else "failed",
14267
- "chart_id": str(successful_chart.get("chart_id") or "") if isinstance(successful_chart, dict) else "",
14268
- "chart_apply_status": chart_apply.get("status"),
14269
- "write_executed": bool(chart_apply.get("write_executed")),
14270
- **({"error_code": chart_apply.get("error_code")} if chart_apply.get("error_code") else {}),
14271
- **({"message": chart_apply.get("message")} if chart_apply.get("message") else {}),
14272
- })
14273
- if not isinstance(successful_chart, dict):
15334
+ resolved_chart = inline_chart_resolutions.get(section_index)
15335
+ if not isinstance(resolved_chart, dict):
14274
15336
  raise ValueError(f"inline chart '{section.chart.name}' could not be created or updated for portal section '{section.title}'")
14275
- resolved_chart = {
14276
- "chart_id": str(successful_chart.get("chart_id") or "").strip(),
14277
- "chart_name": str(successful_chart.get("name") or section.chart.name).strip(),
14278
- "app_key": section.chart.app_key,
14279
- "chart_type": section.chart.chart_type.value,
14280
- }
14281
15337
  else:
14282
15338
  resolved_chart = _resolve_chart_reference(
14283
15339
  charts=self.charts,
@@ -18907,7 +19963,11 @@ def _verify_relation_readback_by_name(
18907
19963
  for item in cast(list[Any], actual.get("visible_fields") or [])
18908
19964
  if isinstance(item, dict) and str(item.get("name") or "").strip()
18909
19965
  ]
18910
- if expected_visible_names != actual_visible_names:
19966
+ if not _relation_visible_field_names_match(
19967
+ expected_visible_names=expected_visible_names,
19968
+ actual_visible_names=actual_visible_names,
19969
+ display_field_name=expected_display_name,
19970
+ ):
18911
19971
  return False
18912
19972
  return True
18913
19973
 
@@ -18925,6 +19985,36 @@ def _relation_field_names(values: object) -> list[str]:
18925
19985
  return names
18926
19986
 
18927
19987
 
19988
+ def _relation_visible_field_names_match(
19989
+ *,
19990
+ expected_visible_names: list[str],
19991
+ actual_visible_names: list[str],
19992
+ display_field_name: str | None = None,
19993
+ ) -> bool:
19994
+ if expected_visible_names == actual_visible_names:
19995
+ return True
19996
+
19997
+ def dedupe(values: list[str]) -> list[str]:
19998
+ result: list[str] = []
19999
+ for value in values:
20000
+ normalized = str(value or "").strip()
20001
+ if normalized and normalized not in result:
20002
+ result.append(normalized)
20003
+ return result
20004
+
20005
+ expected = dedupe(expected_visible_names)
20006
+ actual = dedupe(actual_visible_names)
20007
+ if expected == actual:
20008
+ return True
20009
+ if set(expected) != set(actual):
20010
+ return False
20011
+ display = str(display_field_name or "").strip()
20012
+ if display and actual and actual[0] == display and display in expected:
20013
+ expected_display_first = [display, *[name for name in expected if name != display]]
20014
+ return actual == expected_display_first
20015
+ return False
20016
+
20017
+
18928
20018
  def _relation_field_public_selector(value: object) -> dict[str, Any] | None:
18929
20019
  if not isinstance(value, dict):
18930
20020
  return None
@@ -18983,13 +20073,18 @@ def _schema_relation_readback_matrix(
18983
20073
  actual_display = _relation_field_public_selector((actual or {}).get("display_field"))
18984
20074
  expected_visible_names = _relation_field_names((expected or degraded or {}).get("visible_fields"))
18985
20075
  actual_visible_names = _relation_field_names((actual or {}).get("visible_fields"))
20076
+ display_name = str((expected_display or {}).get("name") or "").strip() or None
18986
20077
 
18987
20078
  checks = {
18988
20079
  "field_exists": isinstance(actual, dict),
18989
20080
  "target_app_key": expected_target == actual_target,
18990
20081
  "relation_mode": expected_mode == actual_mode,
18991
20082
  "display_field": (expected_display or {}).get("name") == (actual_display or {}).get("name"),
18992
- "visible_fields": expected_visible_names == actual_visible_names,
20083
+ "visible_fields": _relation_visible_field_names_match(
20084
+ expected_visible_names=expected_visible_names,
20085
+ actual_visible_names=actual_visible_names,
20086
+ display_field_name=display_name,
20087
+ ),
18993
20088
  }
18994
20089
  readback_verified = all(checks.values())
18995
20090
  if not isinstance(actual, dict):
@@ -23802,8 +24897,8 @@ def _build_form_payload_for_edit_fields(
23802
24897
  parsed_section_id = _coerce_positive_int(section.get("section_id"))
23803
24898
  if parsed_section_id is not None:
23804
24899
  wrapper["sectionId"] = parsed_section_id
23805
- elif template is None and section.get("section_id") is not None:
23806
- wrapper["sectionId"] = section.get("section_id")
24900
+ elif template is None and _coerce_positive_int(wrapper.get("sectionId")) is None:
24901
+ wrapper.pop("sectionId", None)
23807
24902
  wrapper["innerQuestions"] = inner_rows
23808
24903
  form_rows.append([wrapper])
23809
24904
 
@@ -27678,7 +28773,6 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
27678
28773
  "arguments": {
27679
28774
  "profile": profile,
27680
28775
  "app_key": app_key,
27681
- "create_if_missing": False,
27682
28776
  "add_fields": [
27683
28777
  {
27684
28778
  "name": "状态",