@josephyan/qingflow-app-builder-mcp 1.1.20 → 1.1.22

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.
@@ -9,6 +9,7 @@ import random
9
9
  import re
10
10
  import string
11
11
  import tempfile
12
+ import time
12
13
  from typing import Any, NoReturn, cast
13
14
  from urllib.parse import quote_plus, unquote_plus
14
15
  from uuid import uuid4
@@ -107,6 +108,7 @@ from .models import (
107
108
 
108
109
  BUILDER_PORTAL_LIST_DETAIL_VERIFY_LIMIT = 50
109
110
  CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE = 8
111
+ CUSTOM_BUTTON_VIEW_CONFIG_READBACK_RETRY_SECONDS = 0.4
110
112
 
111
113
 
112
114
  QUESTION_TYPE_TO_FIELD_TYPE: dict[int, str] = {
@@ -292,6 +294,12 @@ class AiBuilderFacade:
292
294
  def _with_backend_context(self, profile: str, handler):
293
295
  return self.apps._run(profile, lambda _session_profile, context: handler(context))
294
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
+
295
303
  def flow_get_schema(self, *, profile: str, schema_version: str | None = None) -> JSONObject:
296
304
  normalized_args = {"schema_version": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION}
297
305
  try:
@@ -405,22 +413,50 @@ class AiBuilderFacade:
405
413
  "idempotency_key": idempotency_key,
406
414
  "schema_version": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION,
407
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
408
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()
409
447
  permission_outcome = self._guard_app_permission(
410
448
  profile=profile,
411
449
  app_key=app_key,
412
450
  required_permission="data_manage",
413
451
  normalized_args=normalized_args,
414
452
  )
453
+ permission_ms += _duration_ms(permission_started_at)
415
454
  if permission_outcome.block is not None:
416
- return permission_outcome.block
455
+ return finish(permission_outcome.block)
417
456
  permission_outcomes.append(permission_outcome)
418
457
 
419
- def finalize(response: JSONObject) -> JSONObject:
420
- return _apply_permission_outcomes(response, *permission_outcomes)
421
-
422
458
  if not isinstance(spec, dict) or not spec:
423
- return finalize(
459
+ return finish(
424
460
  _failed(
425
461
  "FLOW_SPEC_REQUIRED",
426
462
  "spec must be a non-empty WorkflowSpecDTO object",
@@ -435,13 +471,15 @@ class AiBuilderFacade:
435
471
  "spec": spec,
436
472
  }
437
473
  try:
474
+ flow_spec_apply_started_at = time.perf_counter()
438
475
  apply_result = self._with_backend_context(
439
476
  profile,
440
477
  lambda context: workflow_spec_api.post_apply(self.apps.backend, context, apply_body=apply_body),
441
478
  )
442
479
  except (QingflowApiError, RuntimeError) as error:
480
+ flow_spec_apply_ms += _duration_ms(flow_spec_apply_started_at)
443
481
  api_error = _coerce_api_error(error)
444
- return finalize(
482
+ return finish(
445
483
  _failed_from_api_error(
446
484
  "FLOW_SPEC_APPLY_FAILED",
447
485
  api_error,
@@ -450,15 +488,18 @@ class AiBuilderFacade:
450
488
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
451
489
  )
452
490
  )
491
+ flow_spec_apply_ms += _duration_ms(flow_spec_apply_started_at)
453
492
  if not isinstance(apply_result, dict):
454
493
  apply_result = {"result": apply_result}
494
+ flow_spec_verify_started_at = time.perf_counter()
455
495
  verification, verify_warnings, verified = workflow_spec_api.verify_apply_response(
456
496
  input_spec=spec,
457
497
  apply_result=apply_result,
458
498
  )
499
+ flow_spec_verify_ms += _duration_ms(flow_spec_verify_started_at)
459
500
  backend_status = str(apply_result.get("status") or "SUCCESS").upper()
460
501
  if backend_status not in {"SUCCESS", "OK"}:
461
- return finalize(
502
+ return finish(
462
503
  _failed(
463
504
  "FLOW_SPEC_APPLY_FAILED",
464
505
  str(apply_result.get("message") or "workflow spec apply failed"),
@@ -494,7 +535,7 @@ class AiBuilderFacade:
494
535
  "write_succeeded": True,
495
536
  "safe_to_retry": False,
496
537
  }
497
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
538
+ return finish_with_publish(response)
498
539
 
499
540
  def package_resolve(self, *, profile: str, package_name: str) -> JSONObject:
500
541
  requested = str(package_name or "").strip()
@@ -795,7 +836,6 @@ class AiBuilderFacade:
795
836
  profile: str,
796
837
  package_id: int | None = None,
797
838
  package_name: str | None = None,
798
- create_if_missing: bool = False,
799
839
  icon: str | None = None,
800
840
  color: str | None = None,
801
841
  visibility: VisibilityPatch | None = None,
@@ -806,7 +846,6 @@ class AiBuilderFacade:
806
846
  normalized_args: JSONObject = {
807
847
  "package_id": package_id,
808
848
  **({"package_name": requested_name} if requested_name else {}),
809
- "create_if_missing": bool(create_if_missing),
810
849
  **({"icon": icon} if icon else {}),
811
850
  **({"color": color} if color else {}),
812
851
  **({"visibility": visibility.model_dump(mode="json")} if visibility is not None else {}),
@@ -818,39 +857,119 @@ class AiBuilderFacade:
818
857
  create_result: JSONObject | None = None
819
858
  update_result: JSONObject | None = None
820
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
821
877
 
822
878
  if effective_package_id is None:
823
- if not create_if_missing:
824
- return _failed(
825
- "PACKAGE_ID_REQUIRED",
826
- "package_id is required unless create_if_missing=true",
827
- normalized_args=normalized_args,
828
- suggested_next_call=None,
829
- )
830
879
  if not requested_name:
831
880
  return _failed(
832
881
  "PACKAGE_NAME_REQUIRED",
833
- "package_name is required when create_if_missing=true",
882
+ "package_name is required when creating a package without package_id",
834
883
  normalized_args=normalized_args,
835
884
  suggested_next_call=None,
836
885
  )
837
- create_result = self.package_create(
838
- profile=profile,
839
- package_name=requested_name,
840
- icon=icon,
841
- color=color,
842
- visibility=visibility,
843
- )
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
+ }
844
961
  if create_result.get("status") not in {"success", "partial_success"}:
845
- 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))
846
963
  effective_package_id = _coerce_positive_int(create_result.get("tag_id") or create_result.get("package_id"))
847
964
  if effective_package_id is None:
848
- return _failed(
849
- "PACKAGE_CREATE_UNVERIFIED",
850
- "created package but could not verify package_id",
851
- normalized_args=normalized_args,
852
- details={"create_result": create_result},
853
- 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
+ )
854
973
  )
855
974
  normalized_args["package_id"] = effective_package_id
856
975
  created = True
@@ -866,6 +985,7 @@ class AiBuilderFacade:
866
985
  if edit_tag_outcome.block is not None:
867
986
  return edit_tag_outcome.block
868
987
  permission_outcomes.append(edit_tag_outcome)
988
+ update_started_at = time.perf_counter()
869
989
  update_result = self.package_update(
870
990
  profile=profile,
871
991
  tag_id=effective_package_id,
@@ -874,18 +994,22 @@ class AiBuilderFacade:
874
994
  color=color,
875
995
  visibility=visibility,
876
996
  )
997
+ duration_breakdown_ms["package_update_ms"] += int((time.perf_counter() - update_started_at) * 1000)
877
998
  if update_result.get("status") not in {"success", "partial_success"}:
878
- 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))
879
1000
 
880
1001
  layout_result: JSONObject | None = None
881
1002
  if items is not None:
882
1003
  if not isinstance(items, list):
883
- return _failed(
884
- "PACKAGE_ITEMS_INVALID",
885
- "items must be a list",
886
- normalized_args=normalized_args,
887
- 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
+ )
888
1011
  )
1012
+ items_started_at = time.perf_counter()
889
1013
  layout_result = self._apply_package_items(
890
1014
  profile=profile,
891
1015
  package_id=effective_package_id,
@@ -893,6 +1017,7 @@ class AiBuilderFacade:
893
1017
  allow_detach=allow_detach,
894
1018
  normalized_args=normalized_args,
895
1019
  )
1020
+ duration_breakdown_ms["package_items_ms"] += int((time.perf_counter() - items_started_at) * 1000)
896
1021
  if layout_result.get("status") not in {"success", "partial_success"}:
897
1022
  prior_package_write_executed = bool(
898
1023
  created
@@ -929,8 +1054,8 @@ class AiBuilderFacade:
929
1054
  )
930
1055
  partial["package_id"] = effective_package_id
931
1056
  partial["layout_failed"] = True
932
- return _apply_permission_outcomes(partial, *permission_outcomes)
933
- 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))
934
1059
 
935
1060
  write_executed = bool(
936
1061
  created
@@ -947,10 +1072,120 @@ class AiBuilderFacade:
947
1072
  and not bool(layout_result.get("noop"))
948
1073
  )
949
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()
950
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)
951
1186
  if verification.get("status") != "success":
952
1187
  if write_executed:
953
- return _apply_permission_outcomes(
1188
+ return finish_duration(_apply_permission_outcomes(
954
1189
  _post_write_readback_pending_result(
955
1190
  error_code="PACKAGE_READBACK_PENDING",
956
1191
  message="applied package; final package readback is unavailable",
@@ -959,8 +1194,8 @@ class AiBuilderFacade:
959
1194
  suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": effective_package_id}},
960
1195
  ),
961
1196
  *permission_outcomes,
962
- )
963
- return _apply_permission_outcomes(verification, *permission_outcomes)
1197
+ ))
1198
+ return finish_duration(_apply_permission_outcomes(verification, *permission_outcomes))
964
1199
  expected_visibility = None
965
1200
  if visibility is not None:
966
1201
  try:
@@ -1051,7 +1286,7 @@ class AiBuilderFacade:
1051
1286
  }
1052
1287
  },
1053
1288
  }
1054
- return _apply_permission_outcomes(response, *permission_outcomes)
1289
+ return finish_duration(_apply_permission_outcomes(response, *permission_outcomes))
1055
1290
 
1056
1291
  def package_update(
1057
1292
  self,
@@ -1312,6 +1547,72 @@ class AiBuilderFacade:
1312
1547
  allow_detach: bool,
1313
1548
  normalized_args: JSONObject,
1314
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
+
1315
1616
  current_detail_result: JSONObject | None = None
1316
1617
  current_base_result: JSONObject | None = None
1317
1618
  detail_api_error: QingflowApiError | None = None
@@ -1387,16 +1688,6 @@ class AiBuilderFacade:
1387
1688
  suggested_next_call=None,
1388
1689
  )
1389
1690
 
1390
- duplicate_resources = _find_duplicate_package_resources(normalized_items)
1391
- if duplicate_resources:
1392
- return _failed(
1393
- "PACKAGE_LAYOUT_DUPLICATE_ITEM",
1394
- "items contains duplicate apps or portals",
1395
- normalized_args=normalized_args,
1396
- details={"duplicates": [{"type": item[0], "id": item[1]} for item in duplicate_resources]},
1397
- suggested_next_call=None,
1398
- )
1399
-
1400
1691
  current_groups = {int(spec["group_id"]): str(spec["name"] or "").strip() for spec in current_group_specs}
1401
1692
  desired_groups = _collect_public_package_group_specs(normalized_items)
1402
1693
  desired_group_ids = {
@@ -3157,47 +3448,107 @@ class AiBuilderFacade:
3157
3448
  def app_custom_buttons_apply(self, *, profile: str, request: CustomButtonsApplyRequest) -> JSONObject:
3158
3449
  normalized_args = request.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
3159
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
3160
3462
  permission_outcomes: list[PermissionCheckOutcome] = []
3161
3463
  button_write_intent = bool(request.upsert_buttons or request.patch_buttons or request.remove_buttons)
3162
3464
  if button_write_intent:
3465
+ permission_started_at = time.perf_counter()
3163
3466
  permission_outcome = self._guard_app_permission(
3164
3467
  profile=profile,
3165
3468
  app_key=app_key,
3166
3469
  required_permission="edit_app",
3167
3470
  normalized_args=normalized_args,
3168
3471
  )
3472
+ permission_ms += _duration_ms(permission_started_at)
3169
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
+ )
3170
3479
  return permission_outcome.block
3171
3480
  permission_outcomes.append(permission_outcome)
3172
3481
  if request.view_configs:
3482
+ permission_started_at = time.perf_counter()
3173
3483
  permission_outcome = self._guard_app_permission(
3174
3484
  profile=profile,
3175
3485
  app_key=app_key,
3176
3486
  required_permission="view_manage",
3177
3487
  normalized_args=normalized_args,
3178
3488
  )
3489
+ permission_ms += _duration_ms(permission_started_at)
3179
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
+ )
3180
3496
  return permission_outcome.block
3181
3497
  permission_outcomes.append(permission_outcome)
3182
3498
 
3183
3499
  def finalize(response: JSONObject) -> JSONObject:
3184
- 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
3185
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
+ )
3186
3527
  view_config_refs = [
3187
3528
  binding.button_ref
3188
3529
  for config in request.view_configs
3189
3530
  for binding in config.buttons
3190
3531
  ]
3191
- needs_button_inventory = button_write_intent or any(
3192
- _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
3193
3542
  )
3194
3543
  try:
3544
+ button_inventory_started_at = time.perf_counter()
3195
3545
  existing_buttons = (
3196
3546
  self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
3197
3547
  if needs_button_inventory
3198
3548
  else []
3199
3549
  )
3200
3550
  except (QingflowApiError, RuntimeError) as error:
3551
+ button_inventory_ms += _duration_ms(button_inventory_started_at)
3201
3552
  api_error = _coerce_api_error(error)
3202
3553
  return finalize(_failed_from_api_error(
3203
3554
  "CUSTOM_BUTTON_LIST_FAILED",
@@ -3206,7 +3557,10 @@ class AiBuilderFacade:
3206
3557
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_buttons", error=api_error),
3207
3558
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
3208
3559
  ))
3560
+ button_inventory_ms += _duration_ms(button_inventory_started_at)
3561
+ app_name_started_at = time.perf_counter()
3209
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)
3210
3564
 
3211
3565
  existing_by_id = {
3212
3566
  button_id: item
@@ -3221,6 +3575,7 @@ class AiBuilderFacade:
3221
3575
 
3222
3576
  upsert_buttons = list(request.upsert_buttons)
3223
3577
  if request.patch_buttons:
3578
+ button_patch_started_at = time.perf_counter()
3224
3579
  expanded_buttons, patch_issues, patch_results = self._expand_custom_button_partial_patches(
3225
3580
  profile=profile,
3226
3581
  app_key=app_key,
@@ -3228,6 +3583,7 @@ class AiBuilderFacade:
3228
3583
  existing_by_text=existing_by_text,
3229
3584
  patch_buttons=request.patch_buttons,
3230
3585
  )
3586
+ button_patch_hydration_ms += _duration_ms(button_patch_started_at)
3231
3587
  if patch_issues:
3232
3588
  return finalize(
3233
3589
  _failed(
@@ -3245,11 +3601,13 @@ class AiBuilderFacade:
3245
3601
  ]
3246
3602
  normalized_args["patch_results"] = patch_results
3247
3603
 
3604
+ add_data_compile_started_at = time.perf_counter()
3248
3605
  compiled_add_data_configs, add_data_issues = self._compile_custom_button_semantic_add_data_configs(
3249
3606
  profile=profile,
3250
3607
  app_key=app_key,
3251
3608
  patches=upsert_buttons,
3252
3609
  )
3610
+ add_data_compile_ms += _duration_ms(add_data_compile_started_at)
3253
3611
  upsert_ops: list[dict[str, Any]] = []
3254
3612
  remove_ops: list[dict[str, Any]] = []
3255
3613
  blocking_issues: list[dict[str, Any]] = []
@@ -3432,12 +3790,14 @@ class AiBuilderFacade:
3432
3790
 
3433
3791
  edit_version_no = None
3434
3792
  if button_write_intent:
3793
+ edit_context_started_at = time.perf_counter()
3435
3794
  edit_version_no, edit_context_error = self._ensure_app_edit_context(
3436
3795
  profile=profile,
3437
3796
  app_key=app_key,
3438
3797
  normalized_args=normalized_args,
3439
3798
  failure_code="CUSTOM_BUTTON_APPLY_FAILED",
3440
3799
  )
3800
+ edit_context_ms += _duration_ms(edit_context_started_at)
3441
3801
  if edit_context_error is not None:
3442
3802
  return finalize(edit_context_error)
3443
3803
 
@@ -3449,6 +3809,7 @@ class AiBuilderFacade:
3449
3809
  client_key_map: dict[str, int] = {}
3450
3810
  write_executed = False
3451
3811
 
3812
+ button_write_started_at = time.perf_counter()
3452
3813
  for op in upsert_ops:
3453
3814
  patch = cast(CustomButtonUpsertPatch, op["patch"])
3454
3815
  patch_payload = _serialize_custom_button_payload(patch, add_data_config_override=compiled_add_data_configs.get(int(op["index"])))
@@ -3459,7 +3820,9 @@ class AiBuilderFacade:
3459
3820
  button_id = _extract_custom_button_id(result.get("result"))
3460
3821
  if button_id is None:
3461
3822
  try:
3823
+ button_readback_started_at = time.perf_counter()
3462
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)
3463
3826
  matches = [
3464
3827
  item
3465
3828
  for item in readback_buttons
@@ -3469,6 +3832,7 @@ class AiBuilderFacade:
3469
3832
  if len(matches) == 1:
3470
3833
  button_id = _coerce_positive_int(matches[0].get("button_id"))
3471
3834
  except (QingflowApiError, RuntimeError) as error:
3835
+ button_readback_ms += _duration_ms(button_readback_started_at)
3472
3836
  readback_errors.append(
3473
3837
  {
3474
3838
  "resource": "custom_buttons",
@@ -3524,7 +3888,9 @@ class AiBuilderFacade:
3524
3888
  try:
3525
3889
  write_executed = True
3526
3890
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
3891
+ button_readback_started_at = time.perf_counter()
3527
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)
3528
3894
  removed.append(
3529
3895
  {
3530
3896
  "index": op["index"],
@@ -3564,13 +3930,41 @@ class AiBuilderFacade:
3564
3930
  }
3565
3931
  )
3566
3932
 
3567
- 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
3568
3959
  readback_buttons: list[dict[str, Any]] = []
3569
3960
  readback_failed = False
3570
3961
  if needs_button_list_readback:
3571
3962
  try:
3963
+ button_readback_started_at = time.perf_counter()
3572
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)
3573
3966
  except (QingflowApiError, RuntimeError) as error:
3967
+ button_readback_ms += _duration_ms(button_readback_started_at)
3574
3968
  readback_errors.append(
3575
3969
  {
3576
3970
  "resource": "custom_buttons",
@@ -3584,26 +3978,14 @@ class AiBuilderFacade:
3584
3978
  for item in readback_buttons
3585
3979
  if (button_id := _coerce_positive_int(item.get("button_id"))) is not None
3586
3980
  }
3587
- created_ids = [
3588
- int(item["button_id"])
3589
- for item in created
3590
- if _coerce_positive_int(item.get("button_id")) is not None
3591
- ]
3592
- updated_ids = [
3593
- int(item["button_id"])
3594
- for item in updated
3595
- if _coerce_positive_int(item.get("button_id")) is not None
3596
- ]
3597
- removed_ids = [
3598
- int(item["button_id"])
3599
- for item in removed
3600
- if _coerce_positive_int(item.get("button_id")) is not None
3601
- ]
3602
3981
  removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
3603
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))
3604
3985
  verified = (
3605
- not readback_failed
3606
- 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
3607
3989
  and removed_verified
3608
3990
  and not failed
3609
3991
  and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
@@ -3615,7 +3997,9 @@ class AiBuilderFacade:
3615
3997
  view_config_verified = True
3616
3998
  view_config_write_executed = False
3617
3999
  view_config_write_succeeded = False
4000
+ created_verified_by_view_binding = False
3618
4001
  if request.view_configs:
4002
+ view_config_started_at = time.perf_counter()
3619
4003
  view_config_result = self._apply_custom_button_view_configs(
3620
4004
  profile=profile,
3621
4005
  app_key=app_key,
@@ -3627,6 +4011,7 @@ class AiBuilderFacade:
3627
4011
  updated_ids=updated_ids,
3628
4012
  removed_ids=removed_ids,
3629
4013
  )
4014
+ view_config_ms += _duration_ms(view_config_started_at)
3630
4015
  view_config_results = list(view_config_result.get("view_configs") or [])
3631
4016
  view_config_failed = list(view_config_result.get("failed") or [])
3632
4017
  view_config_warnings = list(view_config_result.get("warnings") or [])
@@ -3639,6 +4024,38 @@ class AiBuilderFacade:
3639
4024
  if view_config_warnings:
3640
4025
  if not isinstance(view_config_warnings, list):
3641
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
+ )
3642
4059
  verified = custom_buttons_verified and view_config_verified
3643
4060
  write_succeeded = bool(created or updated or removed or view_config_write_succeeded)
3644
4061
  status = "success" if verified else ("partial_success" if write_succeeded else "failed")
@@ -3692,6 +4109,10 @@ class AiBuilderFacade:
3692
4109
  "edit_version_no": edit_version_no,
3693
4110
  "button_ids_by_client_key": client_key_map,
3694
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,
3695
4116
  **({"readback_errors": readback_errors} if readback_errors else {}),
3696
4117
  "compiled_match_rules": {
3697
4118
  str(index): _summarize_compiled_match_rules(config.get("que_relation") or [])
@@ -3704,9 +4125,10 @@ class AiBuilderFacade:
3704
4125
  "warnings": warnings,
3705
4126
  "verification": {
3706
4127
  "custom_buttons_verified": custom_buttons_verified,
3707
- "readback_loaded": not readback_failed,
3708
- "created_verified": not readback_failed and all(button_id in readback_ids for button_id in created_ids),
3709
- "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,
3710
4132
  "removed_verified": removed_verified,
3711
4133
  "remove_readback_pending": remove_readback_pending,
3712
4134
  "removed_readback_results": deepcopy(removed),
@@ -3726,14 +4148,17 @@ class AiBuilderFacade:
3726
4148
  "write_succeeded": write_succeeded,
3727
4149
  "safe_to_retry": not write_executed,
3728
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)
3729
4160
  return finalize(
3730
- self._append_publish_result(
3731
- profile=profile,
3732
- app_key=app_key,
3733
- publish=bool(write_succeeded and button_write_intent),
3734
- response=response,
3735
- edit_version_no=edit_version_no,
3736
- )
4161
+ published_response
3737
4162
  )
3738
4163
 
3739
4164
  def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
@@ -4359,6 +4784,18 @@ class AiBuilderFacade:
4359
4784
  def app_associated_resources_apply(self, *, profile: str, request: AssociatedResourcesApplyRequest) -> JSONObject:
4360
4785
  normalized_args = request.model_dump(mode="json", exclude_none=True)
4361
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
4362
4799
  permission_outcomes: list[PermissionCheckOutcome] = []
4363
4800
  resource_write_intent = bool(
4364
4801
  request.upsert_resources
@@ -4367,32 +4804,97 @@ class AiBuilderFacade:
4367
4804
  or request.reorder_associated_item_ids
4368
4805
  )
4369
4806
  if resource_write_intent:
4807
+ permission_started_at = time.perf_counter()
4370
4808
  permission_outcome = self._guard_app_permission(
4371
4809
  profile=profile,
4372
4810
  app_key=app_key,
4373
4811
  required_permission="edit_app",
4374
4812
  normalized_args=normalized_args,
4375
4813
  )
4814
+ permission_ms += _duration_ms(permission_started_at)
4376
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
+ )
4377
4821
  return permission_outcome.block
4378
4822
  permission_outcomes.append(permission_outcome)
4379
4823
  if request.view_configs:
4824
+ permission_started_at = time.perf_counter()
4380
4825
  permission_outcome = self._guard_app_permission(
4381
4826
  profile=profile,
4382
4827
  app_key=app_key,
4383
4828
  required_permission="view_manage",
4384
4829
  normalized_args=normalized_args,
4385
4830
  )
4831
+ permission_ms += _duration_ms(permission_started_at)
4386
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
+ )
4387
4838
  return permission_outcome.block
4388
4839
  permission_outcomes.append(permission_outcome)
4389
4840
 
4390
4841
  def finalize(response: JSONObject) -> JSONObject:
4391
- 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
4392
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
+ )
4393
4889
  try:
4394
- 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
+ )
4395
4896
  except (QingflowApiError, RuntimeError) as error:
4897
+ resource_inventory_ms += _duration_ms(resource_inventory_started_at)
4396
4898
  api_error = _coerce_api_error(error)
4397
4899
  return finalize(_failed_from_api_error(
4398
4900
  "ASSOCIATED_RESOURCES_READ_FAILED",
@@ -4401,7 +4903,10 @@ class AiBuilderFacade:
4401
4903
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="associated_resources", error=api_error),
4402
4904
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
4403
4905
  ))
4906
+ resource_inventory_ms += _duration_ms(resource_inventory_started_at)
4907
+ app_name_started_at = time.perf_counter()
4404
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)
4405
4910
 
4406
4911
  existing_by_id = _associated_resource_index(existing_resources)
4407
4912
  blocking_issues: list[dict[str, Any]] = []
@@ -4414,10 +4919,12 @@ class AiBuilderFacade:
4414
4919
 
4415
4920
  upsert_resources = list(request.upsert_resources)
4416
4921
  if request.patch_resources:
4922
+ resource_patch_started_at = time.perf_counter()
4417
4923
  expanded_resources, patch_issues, patch_results = self._expand_associated_resource_partial_patches(
4418
4924
  existing_by_id=existing_by_id,
4419
4925
  patch_resources=request.patch_resources,
4420
4926
  )
4927
+ resource_patch_hydration_ms += _duration_ms(resource_patch_started_at)
4421
4928
  if patch_issues:
4422
4929
  return finalize(
4423
4930
  _failed(
@@ -4432,11 +4939,13 @@ class AiBuilderFacade:
4432
4939
  normalized_args["upsert_resources"] = [patch.model_dump(mode="json") for patch in upsert_resources]
4433
4940
  normalized_args["patch_results"] = patch_results
4434
4941
 
4942
+ match_mapping_compile_started_at = time.perf_counter()
4435
4943
  compiled_resource_match_rules, resource_match_issues = self._compile_associated_resource_semantic_match_mappings(
4436
4944
  profile=profile,
4437
4945
  app_key=app_key,
4438
4946
  patches=upsert_resources,
4439
4947
  )
4948
+ match_mapping_compile_ms += _duration_ms(match_mapping_compile_started_at)
4440
4949
  normalized_args["compiled_match_rules"] = {
4441
4950
  str(index): _summarize_compiled_match_rules(rules)
4442
4951
  for index, rules in compiled_resource_match_rules.items()
@@ -4650,12 +5159,14 @@ class AiBuilderFacade:
4650
5159
 
4651
5160
  edit_version_no = None
4652
5161
  if resource_write_intent:
5162
+ edit_context_started_at = time.perf_counter()
4653
5163
  edit_version_no, edit_context_error = self._ensure_app_edit_context(
4654
5164
  profile=profile,
4655
5165
  app_key=app_key,
4656
5166
  normalized_args=normalized_args,
4657
5167
  failure_code="ASSOCIATED_RESOURCES_APPLY_FAILED",
4658
5168
  )
5169
+ edit_context_ms += _duration_ms(edit_context_started_at)
4659
5170
  if edit_context_error is not None:
4660
5171
  return finalize(edit_context_error)
4661
5172
 
@@ -4670,6 +5181,7 @@ class AiBuilderFacade:
4670
5181
  verification_errors: list[JSONObject] = []
4671
5182
  write_executed = False
4672
5183
 
5184
+ resource_write_started_at = time.perf_counter()
4673
5185
  for op in upsert_ops:
4674
5186
  patch = cast(AssociatedResourceUpsertPatch, op["patch"])
4675
5187
  try:
@@ -4687,26 +5199,30 @@ class AiBuilderFacade:
4687
5199
  match_rules_override=compiled_resource_match_rules.get(int(op["index"])),
4688
5200
  )
4689
5201
  created_id = _extract_associated_resource_id_from_result(create_result)
4690
- try:
4691
- readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4692
- matches = [
4693
- item
4694
- for item in readback_resources
4695
- if _associated_resource_matches_patch(item, patch)
4696
- and _coerce_positive_int(item.get("associated_item_id")) is not None
4697
- ]
4698
- readback_id = _coerce_positive_int(matches[0].get("associated_item_id")) if len(matches) == 1 else None
4699
- if readback_id is not None:
4700
- created_id = readback_id
4701
- except (QingflowApiError, RuntimeError) as error:
4702
- readback_errors.append(
4703
- {
4704
- "resource": "associated_resources",
4705
- "phase": "create_id_lookup",
4706
- "index": op["index"],
4707
- "transport_error": _transport_error_payload(_coerce_api_error(error)),
4708
- }
4709
- )
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
+ )
4710
5226
  created.append(_associated_resource_result_entry("create", op["index"], patch, associated_item_id=created_id))
4711
5227
  if created_id is not None and patch.client_key:
4712
5228
  client_key_to_id[str(patch.client_key)] = created_id
@@ -4764,15 +5280,19 @@ class AiBuilderFacade:
4764
5280
  except (QingflowApiError, RuntimeError) as error:
4765
5281
  api_error = _coerce_api_error(error)
4766
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)
4767
5284
 
4768
5285
  resources_after: list[dict[str, Any]] = []
4769
5286
  resources_after_loaded = False
4770
5287
  resources_after_readback_failed = False
4771
5288
  if request.view_configs:
4772
5289
  try:
5290
+ resource_readback_started_at = time.perf_counter()
4773
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)
4774
5293
  resources_after_loaded = True
4775
5294
  except (QingflowApiError, RuntimeError) as error:
5295
+ resource_readback_ms += _duration_ms(resource_readback_started_at)
4776
5296
  readback_errors.append({"resource": "associated_resources", "phase": "pre_view_config", "transport_error": _transport_error_payload(_coerce_api_error(error))})
4777
5297
  resources_after = []
4778
5298
  resources_after_readback_failed = True
@@ -4818,6 +5338,7 @@ class AiBuilderFacade:
4818
5338
  failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "issues": issues})
4819
5339
  continue
4820
5340
  try:
5341
+ view_config_started_at = time.perf_counter()
4821
5342
  write_executed = True
4822
5343
  self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
4823
5344
  try:
@@ -4847,16 +5368,35 @@ class AiBuilderFacade:
4847
5368
  actual_config = {}
4848
5369
  verified_config = False
4849
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)
4850
5372
  except (QingflowApiError, RuntimeError) as error:
5373
+ view_config_ms += _duration_ms(view_config_started_at)
4851
5374
  api_error = _coerce_api_error(error)
4852
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)})
4853
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
+ )
4854
5389
  final_resources: list[dict[str, Any]] = resources_after if resources_after_loaded else []
4855
5390
  readback_failed = resources_after_readback_failed
4856
- 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:
4857
5394
  try:
5395
+ resource_readback_started_at = time.perf_counter()
4858
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)
4859
5398
  except (QingflowApiError, RuntimeError) as error:
5399
+ resource_readback_ms += _duration_ms(resource_readback_started_at)
4860
5400
  readback_errors.append({"resource": "associated_resources", "phase": "final_pool", "transport_error": _transport_error_payload(_coerce_api_error(error))})
4861
5401
  readback_failed = True
4862
5402
  final_by_id = _associated_resource_index(final_resources)
@@ -4866,15 +5406,16 @@ class AiBuilderFacade:
4866
5406
  resources=final_resources,
4867
5407
  readback_failed=readback_failed,
4868
5408
  )
4869
- created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
4870
- updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
4871
- unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
4872
5409
  removed_ids = [int(item["associated_item_id"]) for item in removed if _coerce_positive_int(item.get("associated_item_id")) is not None]
4873
5410
  removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
4874
5411
  remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
4875
- pool_verified = (
5412
+ pool_items_verified = final_readback_skipped or (
4876
5413
  not readback_failed
4877
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
4878
5419
  and removed_verified
4879
5420
  and not failed
4880
5421
  and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in created)
@@ -4943,6 +5484,8 @@ class AiBuilderFacade:
4943
5484
  "edit_version_no": edit_version_no,
4944
5485
  "associated_item_ids_by_client_key": client_key_to_id,
4945
5486
  "readback_failed": readback_failed,
5487
+ "initial_inventory_read_skipped": not needs_resource_inventory,
5488
+ "final_readback_skipped": final_readback_skipped,
4946
5489
  **({"readback_errors": readback_errors} if readback_errors else {}),
4947
5490
  **({"verification_errors": verification_errors} if verification_errors else {}),
4948
5491
  "compiled_match_rules": {
@@ -4958,7 +5501,8 @@ class AiBuilderFacade:
4958
5501
  "verification": {
4959
5502
  "associated_resources_verified": pool_verified,
4960
5503
  "associated_resource_view_configs_verified": view_configs_verified,
4961
- "readback_loaded": not readback_failed,
5504
+ "readback_loaded": False if final_readback_skipped else not readback_failed,
5505
+ "final_readback_skipped": final_readback_skipped,
4962
5506
  "removed_verified": removed_verified,
4963
5507
  "remove_readback_pending": remove_readback_pending,
4964
5508
  "removed_readback_results": deepcopy(removed),
@@ -4980,6 +5524,7 @@ class AiBuilderFacade:
4980
5524
  "safe_to_retry": not write_executed,
4981
5525
  "associated_resources": final_resources,
4982
5526
  }
5527
+ publish_started_at = time.perf_counter()
4983
5528
  response = self._append_publish_result(
4984
5529
  profile=profile,
4985
5530
  app_key=app_key,
@@ -4987,7 +5532,9 @@ class AiBuilderFacade:
4987
5532
  response=response,
4988
5533
  edit_version_no=edit_version_no,
4989
5534
  )
5535
+ publish_ms += _duration_ms(publish_started_at)
4990
5536
  if response.get("published") and view_config_results:
5537
+ post_publish_started_at = time.perf_counter()
4991
5538
  try:
4992
5539
  post_publish_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
4993
5540
  except (QingflowApiError, RuntimeError) as error:
@@ -5096,6 +5643,7 @@ class AiBuilderFacade:
5096
5643
  response["recoverable"] = False
5097
5644
  response["message"] = "applied associated resources patch"
5098
5645
  response["suggested_next_call"] = None
5646
+ post_publish_verify_ms += _duration_ms(post_publish_started_at)
5099
5647
  return finalize(response)
5100
5648
 
5101
5649
  def _resolve_app_matches_in_visible_apps(
@@ -6152,32 +6700,52 @@ class AiBuilderFacade:
6152
6700
  idempotency_key: str | None = None,
6153
6701
  schema_version: str | None = None,
6154
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
+
6155
6718
  try:
6719
+ flow_patch_read_started_at = time.perf_counter()
6156
6720
  current = self.flow_get(profile=profile, app_key=app_key)
6157
6721
  except (QingflowApiError, RuntimeError) as error:
6722
+ flow_patch_read_ms += _duration_ms(flow_patch_read_started_at)
6158
6723
  api_error = _coerce_api_error(error)
6159
- return _failed_from_api_error(
6724
+ return attach_patch_duration(_failed_from_api_error(
6160
6725
  "FLOW_READ_FAILED",
6161
6726
  api_error,
6162
6727
  normalized_args={"app_key": app_key},
6163
6728
  details={"app_key": app_key},
6164
6729
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6165
- )
6730
+ ))
6731
+ flow_patch_read_ms += _duration_ms(flow_patch_read_started_at)
6166
6732
  if str(current.get("status") or "").strip().lower() != "success":
6167
- return _failed(
6733
+ return attach_patch_duration(_failed(
6168
6734
  "FLOW_READ_FAILED",
6169
6735
  current.get("message") or "failed to read current flow",
6170
6736
  normalized_args={"app_key": app_key},
6171
6737
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6172
- )
6738
+ ))
6739
+ flow_patch_compile_started_at = time.perf_counter()
6173
6740
  spec = deepcopy(current.get("spec") or {})
6174
6741
  if not isinstance(spec, dict) or not spec:
6175
- return _failed(
6742
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6743
+ return attach_patch_duration(_failed(
6176
6744
  "FLOW_PATCH_FAILED",
6177
6745
  "current flow has no spec; use full app_flow_apply with spec first",
6178
6746
  normalized_args={"app_key": app_key, "patch_nodes": patch_nodes},
6179
6747
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, "app_key": app_key}},
6180
- )
6748
+ ))
6181
6749
  nodes = spec.get("nodes") if isinstance(spec.get("nodes"), list) else []
6182
6750
  node_by_id: dict[str, dict[str, Any]] = {}
6183
6751
  for node in nodes:
@@ -6204,30 +6772,35 @@ class AiBuilderFacade:
6204
6772
  for key in unset_payload:
6205
6773
  target.pop(str(key), None)
6206
6774
  if invalid_items:
6207
- return _failed(
6775
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6776
+ return attach_patch_duration(_failed(
6208
6777
  "FLOW_PATCH_INVALID",
6209
6778
  "patch_nodes[] contains invalid items",
6210
6779
  normalized_args={"app_key": app_key, "invalid_items": invalid_items},
6211
6780
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6212
- )
6781
+ ))
6213
6782
  if not_found:
6214
- return _failed(
6783
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6784
+ return attach_patch_duration(_failed(
6215
6785
  "FLOW_NODE_NOT_FOUND",
6216
6786
  f"patch_nodes[] referenced node id(s) not found in current flow: {not_found}",
6217
6787
  normalized_args={"app_key": app_key, "not_found_ids": not_found},
6218
6788
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6219
- )
6789
+ ))
6220
6790
  edge_container = spec.get("edges") if isinstance(spec.get("edges"), dict) else {}
6221
6791
  edges = edge_container.get("edges") if isinstance(edge_container, dict) else None
6222
6792
  transitions = spec.get("transitions") if isinstance(spec.get("transitions"), list) else None
6223
6793
  if not edges and not transitions:
6224
- return _failed(
6794
+ flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6795
+ return attach_patch_duration(_failed(
6225
6796
  "FLOW_PATCH_EMPTY_GRAPH_UNSUPPORTED",
6226
6797
  "current flow spec has no edges; patch_nodes cannot safely save this backend shape",
6227
6798
  normalized_args={"app_key": app_key, "patch_nodes": patch_nodes},
6228
6799
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, "app_key": app_key}},
6229
- )
6230
- 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(
6231
6804
  profile=profile,
6232
6805
  app_key=app_key,
6233
6806
  spec=spec,
@@ -6235,6 +6808,8 @@ class AiBuilderFacade:
6235
6808
  idempotency_key=idempotency_key,
6236
6809
  schema_version=schema_version,
6237
6810
  )
6811
+ flow_patch_apply_ms += _duration_ms(flow_patch_apply_started_at)
6812
+ return attach_patch_duration(result)
6238
6813
 
6239
6814
  def _components_to_sections_payload(self, components: list[dict[str, Any]]) -> list[dict[str, Any]]:
6240
6815
  result: list[dict[str, Any]] = []
@@ -7473,19 +8048,35 @@ class AiBuilderFacade:
7473
8048
  all_verified = False
7474
8049
  continue
7475
8050
  try:
7476
- verify_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
7477
- verify_config = verify_response.get("result") if isinstance(verify_response.get("result"), dict) else {}
7478
8051
  expected_summary = _normalize_expected_view_buttons_for_compare(
7479
8052
  effective_merged_dtos,
7480
8053
  custom_button_details_by_id=button_inventory,
7481
8054
  )
7482
- actual_summary = _normalize_view_buttons_for_compare(verify_config)
7483
- comparison = _compare_view_button_summaries(
7484
- expected=expected_summary,
7485
- actual=actual_summary,
7486
- 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
7487
8076
  )
7488
- 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
7489
8080
  if not verified:
7490
8081
  all_verified = False
7491
8082
  results.append(
@@ -7500,12 +8091,14 @@ class AiBuilderFacade:
7500
8091
  "view_buttons_verified": verified,
7501
8092
  "supported_buttons_verified": bool(comparison.get("verified")) if unsupported_list_issue is not None else None,
7502
8093
  "unsupported_placements": ["list"] if unsupported_list_issue is not None else [],
7503
- "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,
7504
8097
  "expected_buttons": expected_summary,
7505
8098
  "actual_buttons": actual_summary,
7506
8099
  }
7507
8100
  )
7508
- if comparison.get("custom_button_readback_pending"):
8101
+ if pending_custom_button_readback and not accepted_with_delayed_custom_button_readback:
7509
8102
  warnings.append(_warning("VIEW_CUSTOM_BUTTON_READBACK_PENDING", "view config write landed but custom button readback is delayed"))
7510
8103
  except (QingflowApiError, RuntimeError) as error:
7511
8104
  api_error = _coerce_api_error(error)
@@ -7958,7 +8551,6 @@ class AiBuilderFacade:
7958
8551
  app_key=request.app_key,
7959
8552
  app_name=request.app_name,
7960
8553
  package_tag_id=request.package_tag_id,
7961
- create_if_missing=request.create_if_missing,
7962
8554
  )
7963
8555
  if target.get("status") == "failed":
7964
8556
  target.setdefault("normalized_args", normalized_args)
@@ -8230,7 +8822,6 @@ class AiBuilderFacade:
8230
8822
  "arguments": {
8231
8823
  "profile": profile,
8232
8824
  "app_key": request.app_key,
8233
- "create_if_missing": False,
8234
8825
  "add_fields": [{"name": "状态", "type": "select", "options": ["草稿", "进行中", "已完成"], "required": True}],
8235
8826
  "update_fields": [],
8236
8827
  "remove_fields": [],
@@ -8580,12 +9171,21 @@ class AiBuilderFacade:
8580
9171
  icon: str | None = None,
8581
9172
  color: str | None = None,
8582
9173
  visibility: VisibilityPatch | None = None,
8583
- create_if_missing: bool = False,
8584
9174
  publish: bool = True,
8585
9175
  add_fields: list[FieldPatch],
8586
9176
  update_fields: list[FieldUpdatePatch],
8587
9177
  remove_fields: list[FieldRemovePatch],
9178
+ inline_form_layout_sections: list[LayoutSectionPatch] | None = None,
8588
9179
  ) -> JSONObject:
9180
+ apply_started_at = time.perf_counter()
9181
+ schema_write_ms: int | None = None
9182
+ inline_layout_ms: int | None = None
9183
+ publish_ms: int | None = None
9184
+ readback_ms: int | None = None
9185
+ requested_inline_layout_sections = [
9186
+ section.model_dump(mode="json", exclude_none=True)
9187
+ for section in (inline_form_layout_sections or [])
9188
+ ]
8589
9189
  normalized_args = {
8590
9190
  "app_key": app_key,
8591
9191
  "package_tag_id": package_tag_id,
@@ -8593,12 +9193,13 @@ class AiBuilderFacade:
8593
9193
  "icon": icon,
8594
9194
  "color": color,
8595
9195
  "visibility": visibility.model_dump(mode="json") if visibility is not None else None,
8596
- "create_if_missing": create_if_missing,
8597
9196
  "publish": publish,
8598
9197
  "add_fields": [patch.model_dump(mode="json") for patch in add_fields],
8599
9198
  "update_fields": [patch.model_dump(mode="json") for patch in update_fields],
8600
9199
  "remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
8601
9200
  }
9201
+ if requested_inline_layout_sections:
9202
+ normalized_args["inline_form_layout_sections"] = requested_inline_layout_sections
8602
9203
  try:
8603
9204
  desired_auth = (
8604
9205
  self._compile_visibility_to_member_auth(profile=profile, visibility=visibility)
@@ -8614,26 +9215,32 @@ class AiBuilderFacade:
8614
9215
  suggested_next_call=None,
8615
9216
  )
8616
9217
  permission_outcomes: list[PermissionCheckOutcome] = []
8617
- requested_field_changes = bool(add_fields or update_fields or remove_fields)
8618
- resolved: JSONObject
8619
- if app_key:
8620
- resolved = self.app_resolve(profile=profile, app_key=app_key)
8621
- elif app_name:
8622
- resolved = self.app_resolve(profile=profile, app_name=app_name, package_tag_id=package_tag_id)
8623
- else:
8624
- return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", normalized_args=normalized_args, suggested_next_call=None)
9218
+ requested_inline_layout = bool(requested_inline_layout_sections)
9219
+ requested_field_changes = bool(add_fields or update_fields or remove_fields or requested_inline_layout)
8625
9220
 
8626
9221
  def finalize(response: JSONObject) -> JSONObject:
8627
9222
  return _apply_permission_outcomes(response, *permission_outcomes)
8628
9223
 
8629
- if resolved.get("status") == "failed":
8630
- if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
8631
- resolved["normalized_args"] = normalized_args
8632
- if not create_if_missing or app_key or resolved.get("error_code") != "APP_NOT_FOUND":
8633
- 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()
8634
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
+ )
8635
9237
  if permission_tag_id is None:
8636
- 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
+ )
8637
9244
  add_permission_outcome = self._guard_package_permission(
8638
9245
  profile=profile,
8639
9246
  tag_id=permission_tag_id,
@@ -8645,8 +9252,8 @@ class AiBuilderFacade:
8645
9252
  permission_outcomes.append(add_permission_outcome)
8646
9253
  resolved = self._create_target_app_shell(
8647
9254
  profile=profile,
8648
- app_name=app_name,
8649
- package_tag_id=package_tag_id,
9255
+ app_name=requested_app_name_for_create,
9256
+ package_tag_id=permission_tag_id,
8650
9257
  icon=icon,
8651
9258
  color=color,
8652
9259
  auth=desired_auth,
@@ -8659,6 +9266,11 @@ class AiBuilderFacade:
8659
9266
  if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
8660
9267
  resolved["normalized_args"] = normalized_args
8661
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)
8662
9274
  resolved_outcome = _permission_outcome_from_result(resolved)
8663
9275
  if resolved_outcome is not None:
8664
9276
  permission_outcomes.append(resolved_outcome)
@@ -8950,6 +9562,59 @@ class AiBuilderFacade:
8950
9562
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
8951
9563
  )
8952
9564
 
9565
+ inline_form_layout_target: dict[str, Any] | None = None
9566
+ inline_form_layout_auto_added_fields: list[str] = []
9567
+ inline_form_layout_changed = False
9568
+ if requested_inline_layout:
9569
+ inline_layout_started_at = time.perf_counter()
9570
+ resolved_inline_sections, missing_selectors = _resolve_layout_sections_to_names(
9571
+ requested_inline_layout_sections,
9572
+ current_fields,
9573
+ )
9574
+ normalized_args["inline_form_layout_sections"] = resolved_inline_sections
9575
+ if missing_selectors:
9576
+ return _failed(
9577
+ "UNKNOWN_LAYOUT_FIELD",
9578
+ "inline form layout references unknown field selectors",
9579
+ normalized_args=normalized_args,
9580
+ details={"unknown_selectors": missing_selectors},
9581
+ missing_fields=[str(item) for item in missing_selectors],
9582
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
9583
+ )
9584
+ fields_by_name = {field["name"]: field for field in current_fields}
9585
+ seen_layout_fields: list[str] = []
9586
+ for section in resolved_inline_sections:
9587
+ for row in section.get("rows", []):
9588
+ for field_name in row:
9589
+ if field_name not in fields_by_name:
9590
+ return _failed(
9591
+ "UNKNOWN_LAYOUT_FIELD",
9592
+ f"inline form layout references unknown field '{field_name}'",
9593
+ normalized_args=normalized_args,
9594
+ details={"field_name": field_name},
9595
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
9596
+ )
9597
+ if field_name in seen_layout_fields:
9598
+ return _failed(
9599
+ "DUPLICATE_LAYOUT_FIELD",
9600
+ f"inline form layout references field '{field_name}' more than once",
9601
+ normalized_args=normalized_args,
9602
+ details={"field_name": field_name},
9603
+ suggested_next_call={"tool_name": "app_schema_apply", "arguments": {"profile": profile, **normalized_args}},
9604
+ )
9605
+ seen_layout_fields.append(field_name)
9606
+ merged_inline_layout = _merge_layout(
9607
+ current_layout=layout,
9608
+ requested_sections=resolved_inline_sections,
9609
+ all_field_names=[field["name"] for field in current_fields],
9610
+ )
9611
+ inline_form_layout_auto_added_fields = list(merged_inline_layout.get("auto_added_fields") or [])
9612
+ inline_form_layout_target = cast(dict[str, Any], merged_inline_layout["layout"])
9613
+ inline_form_layout_changed = not _layouts_equal(layout, inline_form_layout_target)
9614
+ if inline_form_layout_changed:
9615
+ layout = inline_form_layout_target
9616
+ inline_layout_ms = _duration_ms(inline_layout_started_at)
9617
+
8953
9618
  schema_write_requested = bool(
8954
9619
  added
8955
9620
  or updated
@@ -8957,6 +9622,7 @@ class AiBuilderFacade:
8957
9622
  or normalized_code_block_fields
8958
9623
  or data_display_selection.has_any
8959
9624
  or bool(resolved.get("created"))
9625
+ or inline_form_layout_changed
8960
9626
  )
8961
9627
  if schema_write_requested:
8962
9628
  try:
@@ -9006,7 +9672,8 @@ class AiBuilderFacade:
9006
9672
  else []
9007
9673
  )
9008
9674
 
9009
- if not added and not updated and not removed and not normalized_code_block_fields and not data_display_selection.has_any and not bool(resolved.get("created")):
9675
+ if not schema_write_requested:
9676
+ readback_started_at = time.perf_counter()
9010
9677
  try:
9011
9678
  base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
9012
9679
  except (QingflowApiError, RuntimeError) as error:
@@ -9042,6 +9709,7 @@ class AiBuilderFacade:
9042
9709
  expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
9043
9710
  app_icon_verified = True if expected_app_icon is None else actual_app_icon == expected_app_icon
9044
9711
  app_base_verified = actual_app_name == effective_app_name and app_icon_verified
9712
+ readback_ms = _duration_ms(readback_started_at)
9045
9713
  verified = app_base_verified and relation_target_metadata_verified
9046
9714
  response = {
9047
9715
  "status": "success" if verified else "partial_success",
@@ -9079,11 +9747,34 @@ class AiBuilderFacade:
9079
9747
  "package_attached": package_attached,
9080
9748
  }
9081
9749
  response["details"]["relation_field_count"] = relation_field_count
9750
+ if requested_inline_layout:
9751
+ response["inline_form_layout"] = {
9752
+ "requested": True,
9753
+ "section_count": len(requested_inline_layout_sections),
9754
+ "app_key": target.app_key,
9755
+ "applied": True,
9756
+ "applied_in_schema": True,
9757
+ "layout_status": "success",
9758
+ "message": "layout already matched requested state",
9759
+ "auto_added_fields": inline_form_layout_auto_added_fields,
9760
+ }
9761
+ response["verification"]["inline_form_layout_verified"] = True
9082
9762
  if normalized_code_block_fields:
9083
9763
  response["normalized_code_block_output_assignment"] = True
9084
9764
  response["normalized_code_block_fields"] = normalized_code_block_fields
9085
9765
  response = _apply_permission_outcomes(response, relation_permission_outcome)
9086
- return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
9766
+ publish_started_at = time.perf_counter()
9767
+ response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
9768
+ publish_ms = _duration_ms(publish_started_at)
9769
+ _merge_duration_breakdown(
9770
+ response,
9771
+ schema_write_ms=0,
9772
+ inline_layout_ms=inline_layout_ms,
9773
+ publish_ms=publish_ms,
9774
+ readback_ms=readback_ms,
9775
+ total_ms=_duration_ms(apply_started_at),
9776
+ )
9777
+ return finalize(response)
9087
9778
 
9088
9779
  payload = (
9089
9780
  _build_form_payload_from_fields(
@@ -9109,7 +9800,9 @@ class AiBuilderFacade:
9109
9800
  current_schema=schema_result,
9110
9801
  )
9111
9802
  try:
9803
+ schema_write_started_at = time.perf_counter()
9112
9804
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=payload)
9805
+ schema_write_ms = _duration_ms(schema_write_started_at)
9113
9806
  except (QingflowApiError, RuntimeError) as error:
9114
9807
  api_error = _coerce_api_error(error)
9115
9808
  if backend_code_int(api_error) == 49614:
@@ -9271,7 +9964,9 @@ class AiBuilderFacade:
9271
9964
  current_schema=rebound_schema,
9272
9965
  )
9273
9966
  try:
9967
+ schema_write_started_at = time.perf_counter()
9274
9968
  self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=rebound_payload)
9969
+ schema_write_ms = (schema_write_ms or 0) + _duration_ms(schema_write_started_at)
9275
9970
  except (QingflowApiError, RuntimeError) as error:
9276
9971
  api_error = _coerce_api_error(error)
9277
9972
  return _failed_from_api_error(
@@ -9334,6 +10029,22 @@ class AiBuilderFacade:
9334
10029
  "package_attached": None,
9335
10030
  }
9336
10031
  response["details"]["relation_field_count"] = relation_field_count
10032
+ if requested_inline_layout:
10033
+ response["inline_form_layout"] = {
10034
+ "requested": True,
10035
+ "section_count": len(requested_inline_layout_sections),
10036
+ "app_key": target.app_key,
10037
+ "applied": True,
10038
+ "applied_in_schema": True,
10039
+ "layout_status": "success",
10040
+ "message": (
10041
+ "applied inline form layout inside schema write"
10042
+ if inline_form_layout_changed
10043
+ else "layout already matched requested state"
10044
+ ),
10045
+ "auto_added_fields": inline_form_layout_auto_added_fields,
10046
+ }
10047
+ response["verification"]["inline_form_layout_verified"] = False
9337
10048
  if normalized_code_block_fields:
9338
10049
  response["normalized_code_block_output_assignment"] = True
9339
10050
  response["normalized_code_block_fields"] = normalized_code_block_fields
@@ -9342,11 +10053,14 @@ class AiBuilderFacade:
9342
10053
  if schema_readback_delayed_error is not None:
9343
10054
  response["details"]["schema_readback_delayed_error"] = schema_readback_delayed_error
9344
10055
  response = _apply_permission_outcomes(response, relation_permission_outcome)
10056
+ publish_started_at = time.perf_counter()
9345
10057
  response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
10058
+ publish_ms = _duration_ms(publish_started_at)
9346
10059
  verification_ok = False
9347
10060
  tag_ids_after: list[int] = []
9348
10061
  package_attached: bool | None = None
9349
10062
  verification_error: QingflowApiError | None = None
10063
+ readback_started_at = time.perf_counter()
9350
10064
  try:
9351
10065
  verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
9352
10066
  verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
@@ -9380,6 +10094,14 @@ class AiBuilderFacade:
9380
10094
  response["verification"].update(data_display_verification)
9381
10095
  if data_display_verification.get("data_display_config_verified") is False:
9382
10096
  verification_ok = False
10097
+ if requested_inline_layout and inline_form_layout_target is not None:
10098
+ verified_layout = verified.get("layout") if isinstance(verified.get("layout"), dict) else {}
10099
+ inline_layout_verified = _layouts_equal(verified_layout, inline_form_layout_target) or _layouts_semantically_equal(
10100
+ verified_layout,
10101
+ inline_form_layout_target,
10102
+ )
10103
+ response["verification"]["inline_form_layout_verified"] = inline_layout_verified
10104
+ verification_ok = verification_ok and inline_layout_verified
9383
10105
  if relation_degraded_expectations:
9384
10106
  relation_verified_fields = cast(list[dict[str, Any]], verified["schema"]["fields"])
9385
10107
  try:
@@ -9412,6 +10134,7 @@ class AiBuilderFacade:
9412
10134
  tag_ids_after = []
9413
10135
  package_attached = None if package_tag_id is None else False
9414
10136
  app_base_verified = False
10137
+ readback_ms = _duration_ms(readback_started_at)
9415
10138
  response["verification"]["fields_verified"] = verification_ok
9416
10139
  response["verification"]["package_attached"] = package_attached
9417
10140
  response["verification"]["app_visuals_verified"] = app_base_verified
@@ -9460,6 +10183,14 @@ class AiBuilderFacade:
9460
10183
  "message": verification_error.message,
9461
10184
  **_transport_error_payload(verification_error),
9462
10185
  }
10186
+ _merge_duration_breakdown(
10187
+ response,
10188
+ schema_write_ms=schema_write_ms,
10189
+ inline_layout_ms=inline_layout_ms,
10190
+ publish_ms=publish_ms,
10191
+ readback_ms=readback_ms,
10192
+ total_ms=_duration_ms(apply_started_at),
10193
+ )
9463
10194
  return finalize(response)
9464
10195
 
9465
10196
  def app_layout_apply(
@@ -9478,71 +10209,113 @@ class AiBuilderFacade:
9478
10209
  "sections": requested_sections,
9479
10210
  "publish": publish,
9480
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
9481
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()
9482
10249
  permission_outcome = self._guard_app_permission(
9483
10250
  profile=profile,
9484
10251
  app_key=app_key,
9485
10252
  required_permission="edit_app",
9486
10253
  normalized_args=normalized_args,
9487
10254
  )
10255
+ permission_ms += _duration_ms(permission_started_at)
9488
10256
  if permission_outcome.block is not None:
9489
- return permission_outcome.block
10257
+ return finish(permission_outcome.block)
9490
10258
  permission_outcomes.append(permission_outcome)
9491
10259
 
9492
- def finalize(response: JSONObject) -> JSONObject:
9493
- return _apply_permission_outcomes(response, *permission_outcomes)
9494
-
9495
10260
  try:
10261
+ schema_read_started_at = time.perf_counter()
9496
10262
  schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
9497
10263
  except (QingflowApiError, RuntimeError) as error:
10264
+ schema_read_ms += _duration_ms(schema_read_started_at)
9498
10265
  api_error = _coerce_api_error(error)
9499
- return finalize(_failed_from_api_error(
10266
+ return finish(_failed_from_api_error(
9500
10267
  "LAYOUT_READ_FAILED",
9501
10268
  api_error,
9502
10269
  normalized_args=normalized_args,
9503
10270
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="schema", error=api_error),
9504
10271
  suggested_next_call={"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
9505
10272
  ))
10273
+ schema_read_ms += _duration_ms(schema_read_started_at)
10274
+ layout_compile_started_at = time.perf_counter()
9506
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
9507
10276
  parsed = _parse_schema(schema_result)
9508
10277
  current_fields = parsed["fields"]
9509
10278
  requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
9510
10279
  normalized_args["sections"] = requested_sections
9511
10280
  if missing_selectors:
9512
- return _failed(
10281
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10282
+ return finish(_failed(
9513
10283
  "UNKNOWN_LAYOUT_FIELD",
9514
10284
  "layout references unknown field selectors",
9515
10285
  normalized_args=normalized_args,
9516
10286
  details={"unknown_selectors": missing_selectors},
9517
10287
  missing_fields=[str(item) for item in missing_selectors],
9518
10288
  suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
9519
- )
10289
+ ))
9520
10290
  fields_by_name = {field["name"]: field for field in current_fields}
9521
10291
  seen: list[str] = []
9522
10292
  for section in requested_sections:
9523
10293
  for row in section.get("rows", []):
9524
10294
  for field_name in row:
9525
10295
  if field_name not in fields_by_name:
9526
- return _failed(
10296
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10297
+ return finish(_failed(
9527
10298
  "UNKNOWN_LAYOUT_FIELD",
9528
10299
  f"layout references unknown field '{field_name}'",
9529
10300
  normalized_args=normalized_args,
9530
10301
  details={"field_name": field_name},
9531
10302
  suggested_next_call={"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
9532
- )
10303
+ ))
9533
10304
  if field_name in seen:
9534
- return _failed(
10305
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10306
+ return finish(_failed(
9535
10307
  "DUPLICATE_LAYOUT_FIELD",
9536
10308
  f"layout references field '{field_name}' more than once",
9537
10309
  normalized_args=normalized_args,
9538
10310
  details={"field_name": field_name},
9539
10311
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
9540
- )
10312
+ ))
9541
10313
  seen.append(field_name)
9542
10314
  expected = {field["name"] for field in current_fields}
9543
10315
  if mode == LayoutApplyMode.replace and set(seen) != expected:
9544
10316
  missing = sorted(expected.difference(seen))
9545
- return _failed(
10317
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10318
+ return finish(_failed(
9546
10319
  "INCOMPLETE_LAYOUT",
9547
10320
  "layout must reference every current field exactly once in replace mode",
9548
10321
  normalized_args=normalized_args,
@@ -9559,7 +10332,7 @@ class AiBuilderFacade:
9559
10332
  "tool_name": "app_layout_apply",
9560
10333
  "arguments": {"profile": profile, "app_key": app_key, "mode": "merge", "sections": requested_sections},
9561
10334
  },
9562
- )
10335
+ ))
9563
10336
  merged = _merge_layout(
9564
10337
  current_layout=parsed["layout"],
9565
10338
  requested_sections=requested_sections,
@@ -9571,6 +10344,7 @@ class AiBuilderFacade:
9571
10344
  else merged["layout"]
9572
10345
  )
9573
10346
  if _layouts_equal(parsed["layout"], target_layout):
10347
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
9574
10348
  response = {
9575
10349
  "status": "success",
9576
10350
  "error_code": None,
@@ -9599,21 +10373,26 @@ class AiBuilderFacade:
9599
10373
  "write_succeeded": False,
9600
10374
  "safe_to_retry": True,
9601
10375
  }
9602
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
10376
+ return finish_with_publish(response)
9603
10377
  payload = _build_form_payload_from_existing_schema(
9604
10378
  current_schema=schema_result,
9605
10379
  layout=target_layout,
9606
10380
  )
10381
+ layout_compile_ms += _duration_ms(layout_compile_started_at)
10382
+ edit_version_started_at = time.perf_counter()
9607
10383
  payload["editVersionNo"] = self._resolve_form_edit_version(
9608
10384
  profile=profile,
9609
10385
  app_key=app_key,
9610
10386
  current_schema=schema_result,
9611
10387
  )
10388
+ edit_version_ms += _duration_ms(edit_version_started_at)
9612
10389
  applied_layout = target_layout
9613
10390
  fallback_applied = None
9614
10391
  try:
10392
+ layout_write_started_at = time.perf_counter()
9615
10393
  self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=payload)
9616
10394
  except (QingflowApiError, RuntimeError) as error:
10395
+ layout_write_ms += _duration_ms(layout_write_started_at)
9617
10396
  api_error = _coerce_api_error(error)
9618
10397
  if backend_code_int(api_error) == 400 and target_layout.get("sections"):
9619
10398
  flattened_layout = _flatten_layout_sections(target_layout)
@@ -9627,12 +10406,15 @@ class AiBuilderFacade:
9627
10406
  current_schema=schema_result,
9628
10407
  )
9629
10408
  try:
10409
+ fallback_write_started_at = time.perf_counter()
9630
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)
9631
10412
  applied_layout = flattened_layout
9632
10413
  fallback_applied = "flatten_sections"
9633
10414
  except (QingflowApiError, RuntimeError) as fallback_error:
10415
+ layout_write_ms += _duration_ms(fallback_write_started_at)
9634
10416
  api_fallback_error = _coerce_api_error(fallback_error)
9635
- return finalize(_failed_from_api_error(
10417
+ return finish(_failed_from_api_error(
9636
10418
  "LAYOUT_APPLY_FAILED",
9637
10419
  api_fallback_error,
9638
10420
  normalized_args=normalized_args,
@@ -9647,7 +10429,7 @@ class AiBuilderFacade:
9647
10429
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
9648
10430
  ))
9649
10431
  else:
9650
- return finalize(_failed_from_api_error(
10432
+ return finish(_failed_from_api_error(
9651
10433
  "LAYOUT_APPLY_FAILED",
9652
10434
  api_error,
9653
10435
  normalized_args=normalized_args,
@@ -9659,9 +10441,13 @@ class AiBuilderFacade:
9659
10441
  },
9660
10442
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
9661
10443
  ))
10444
+ else:
10445
+ layout_write_ms += _duration_ms(layout_write_started_at)
9662
10446
  try:
10447
+ layout_readback_started_at = time.perf_counter()
9663
10448
  verified_schema, _verified_schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
9664
10449
  except (QingflowApiError, RuntimeError) as error:
10450
+ layout_readback_ms += _duration_ms(layout_readback_started_at)
9665
10451
  api_error = _coerce_api_error(error)
9666
10452
  response = {
9667
10453
  "status": "partial_success",
@@ -9701,7 +10487,8 @@ class AiBuilderFacade:
9701
10487
  "write_succeeded": True,
9702
10488
  "safe_to_retry": False,
9703
10489
  }
9704
- 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)
9705
10492
  verified_layout = _parse_schema(verified_schema)["layout"]
9706
10493
  layout_verified = _layouts_equal(verified_layout, applied_layout) or _layouts_semantically_equal(verified_layout, applied_layout)
9707
10494
  raw_layout_has_content = _schema_has_layout_content(verified_schema)
@@ -9715,24 +10502,21 @@ class AiBuilderFacade:
9715
10502
  warnings.append(_warning("LAYOUT_SUMMARY_UNVERIFIED", "layout summary is incomplete relative to raw schema readback"))
9716
10503
  if fallback_applied is not None and layout_verified and layout_summary_verified:
9717
10504
  warnings.append(_warning("LAYOUT_FALLBACK_APPLIED", "layout readback normalized sectioned layout into flat layout while preserving field placement"))
10505
+ result_verified = layout_verified
9718
10506
  response = {
9719
- "status": "success" if layout_verified and layout_summary_verified else "partial_success",
10507
+ "status": "success" if result_verified else "partial_success",
9720
10508
  "error_code": (
9721
10509
  None
9722
- if layout_verified and layout_summary_verified
9723
- else "LAYOUT_SUMMARY_UNVERIFIED"
9724
- if not layout_summary_verified
10510
+ if result_verified
9725
10511
  else "LAYOUT_READBACK_MISMATCH"
9726
10512
  ),
9727
- "recoverable": not (layout_verified and layout_summary_verified),
10513
+ "recoverable": not result_verified,
9728
10514
  "message": (
9729
10515
  "applied app layout with flattened section fallback"
9730
10516
  if layout_verified and layout_summary_verified and fallback_applied is not None
9731
10517
  else
9732
10518
  "applied app layout"
9733
- if layout_verified and layout_summary_verified
9734
- else "applied app layout; raw layout verified but compact summary is incomplete"
9735
- if layout_verified and not layout_summary_verified
10519
+ if layout_verified
9736
10520
  else "applied app layout with flattened section fallback"
9737
10521
  if fallback_applied is not None
9738
10522
  else "applied app layout; readback did not fully match the requested layout"
@@ -9755,12 +10539,12 @@ class AiBuilderFacade:
9755
10539
  "auto_added_fields": merged["auto_added_fields"] if mode == LayoutApplyMode.merge else [],
9756
10540
  "fallback_applied": fallback_applied,
9757
10541
  },
9758
- "verified": layout_verified,
10542
+ "verified": result_verified,
9759
10543
  "write_executed": True,
9760
10544
  "write_succeeded": True,
9761
10545
  "safe_to_retry": False,
9762
10546
  }
9763
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
10547
+ return finish_with_publish(response)
9764
10548
 
9765
10549
  def app_flow_apply(
9766
10550
  self,
@@ -9779,22 +10563,54 @@ class AiBuilderFacade:
9779
10563
  "transitions": transitions,
9780
10564
  "publish": publish,
9781
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
9782
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()
9783
10601
  permission_outcome = self._guard_app_permission(
9784
10602
  profile=profile,
9785
10603
  app_key=app_key,
9786
10604
  required_permission="edit_app",
9787
10605
  normalized_args=normalized_args,
9788
10606
  )
10607
+ permission_ms += _duration_ms(permission_started_at)
9789
10608
  if permission_outcome.block is not None:
9790
- return permission_outcome.block
10609
+ return finish(permission_outcome.block)
9791
10610
  permission_outcomes.append(permission_outcome)
9792
10611
 
9793
- def finalize(response: JSONObject) -> JSONObject:
9794
- return _apply_permission_outcomes(response, *permission_outcomes)
9795
-
9796
10612
  if mode != "replace":
9797
- return finalize(_failed(
10613
+ return finish(_failed(
9798
10614
  "UNSUPPORTED_FLOW_MODE",
9799
10615
  "only mode='replace' is supported",
9800
10616
  normalized_args=normalized_args,
@@ -9802,17 +10618,21 @@ class AiBuilderFacade:
9802
10618
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
9803
10619
  ))
9804
10620
  try:
10621
+ flow_state_read_started_at = time.perf_counter()
9805
10622
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
9806
10623
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
9807
10624
  except (QingflowApiError, RuntimeError) as error:
10625
+ flow_state_read_ms += _duration_ms(flow_state_read_started_at)
9808
10626
  api_error = _coerce_api_error(error)
9809
- return finalize(_failed_from_api_error(
10627
+ return finish(_failed_from_api_error(
9810
10628
  "FLOW_READ_FAILED",
9811
10629
  api_error,
9812
10630
  normalized_args=normalized_args,
9813
10631
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
9814
10632
  suggested_next_call={"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}},
9815
10633
  ))
10634
+ flow_state_read_ms += _duration_ms(flow_state_read_started_at)
10635
+ flow_compile_started_at = time.perf_counter()
9816
10636
  app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_key).strip() or app_key
9817
10637
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
9818
10638
  current_fields = _parse_schema(schema)["fields"]
@@ -9821,7 +10641,8 @@ class AiBuilderFacade:
9821
10641
  unsupported_nodes = self._unsupported_public_flow_nodes(nodes=public_nodes)
9822
10642
  normalized_args["nodes"] = public_nodes
9823
10643
  if unsupported_nodes:
9824
- return _failed(
10644
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10645
+ return finish(_failed(
9825
10646
  "FLOW_NODE_TYPE_UNSUPPORTED",
9826
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.",
9827
10648
  normalized_args=normalized_args,
@@ -9831,7 +10652,7 @@ class AiBuilderFacade:
9831
10652
  "disabled_node_types": sorted(DISABLED_PUBLIC_FLOW_NODE_TYPES),
9832
10653
  },
9833
10654
  suggested_next_call={"tool_name": "builder_tool_contract", "arguments": {"tool_name": "app_flow_apply"}},
9834
- )
10655
+ ))
9835
10656
  if resolution_issues:
9836
10657
  first_issue = resolution_issues[0]
9837
10658
  suggested_call = None
@@ -9841,13 +10662,14 @@ class AiBuilderFacade:
9841
10662
  suggested_call = {"tool_name": "member_search", "arguments": {"profile": profile, "query": first_issue.get("value") or ""}}
9842
10663
  elif first_issue.get("kind") in {"editable_fields", "condition_fields"}:
9843
10664
  suggested_call = {"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}}
9844
- return _failed(
10665
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10666
+ return finish(_failed(
9845
10667
  first_issue.get("error_code") or "FLOW_ASSIGNEE_UNRESOLVED",
9846
10668
  "workflow contains unresolved assignees or field permissions",
9847
10669
  normalized_args=normalized_args,
9848
10670
  details={"issues": resolution_issues},
9849
10671
  suggested_next_call=suggested_call,
9850
- )
10672
+ ))
9851
10673
  assignee_required_nodes = [
9852
10674
  node.get("id")
9853
10675
  for node in normalized_nodes
@@ -9858,19 +10680,21 @@ class AiBuilderFacade:
9858
10680
  )
9859
10681
  ]
9860
10682
  if assignee_required_nodes:
9861
- return _failed(
10683
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10684
+ return finish(_failed(
9862
10685
  "FLOW_ASSIGNEE_REQUIRED",
9863
10686
  "workflow approval/fill/copy nodes must declare at least one role or member assignee",
9864
10687
  normalized_args=normalized_args,
9865
10688
  details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
9866
10689
  suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
9867
- )
10690
+ ))
9868
10691
  workflow_spec = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
9869
10692
  if workflow_spec.get("status") == "failed":
9870
10693
  workflow_spec["normalized_args"] = normalized_args
9871
10694
  workflow_spec.setdefault("request_id", None)
9872
10695
  workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}}
9873
- return workflow_spec
10696
+ flow_compile_ms += _duration_ms(flow_compile_started_at)
10697
+ return finish(workflow_spec)
9874
10698
  desired_node_count = len([node for node in normalized_nodes if node.get("type") != "end"])
9875
10699
  build_id = f"facade-flow-{uuid4().hex[:12]}"
9876
10700
  previous_build_home = os.getenv("QINGFLOW_MCP_BUILD_HOME")
@@ -9895,6 +10719,8 @@ class AiBuilderFacade:
9895
10719
  "entities": [{"entity_id": entity["entity_id"], "workflow": workflow_spec["workflow"]}],
9896
10720
  }
9897
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()
9898
10724
  stage = self.solutions.solution_build_flow(
9899
10725
  profile=profile,
9900
10726
  mode="apply",
@@ -9904,6 +10730,7 @@ class AiBuilderFacade:
9904
10730
  run_label=None,
9905
10731
  repair_patch={},
9906
10732
  )
10733
+ flow_write_ms += _duration_ms(flow_write_started_at)
9907
10734
  finally:
9908
10735
  if previous_build_home is None:
9909
10736
  os.environ.pop("QINGFLOW_MCP_BUILD_HOME", None)
@@ -9925,8 +10752,10 @@ class AiBuilderFacade:
9925
10752
  suggested_next_call["tool_name"] = "app_flow_apply"
9926
10753
  suggested_next_call["arguments"] = arguments
9927
10754
  failed["suggested_next_call"] = suggested_next_call
9928
- return finalize(failed)
10755
+ return finish(failed)
10756
+ flow_readback_started_at = time.perf_counter()
9929
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)
9930
10759
  workflow_structure_verified = bool(verified_nodes) and _workflow_nodes_semantically_equal(
9931
10760
  current_workflow=verified_nodes,
9932
10761
  requested_nodes=normalized_nodes,
@@ -9988,7 +10817,7 @@ class AiBuilderFacade:
9988
10817
  "write_succeeded": True,
9989
10818
  "safe_to_retry": False,
9990
10819
  }
9991
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
10820
+ return finish_with_publish(response)
9992
10821
 
9993
10822
  def _extract_view_action_button_patch_intents(
9994
10823
  self,
@@ -10414,12 +11243,65 @@ class AiBuilderFacade:
10414
11243
  "remove_views": list(remove_views),
10415
11244
  "publish": publish,
10416
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
+
10417
11299
  has_action_button_intent = any(patch.action_buttons is not None for patch in upsert_views) or any(
10418
11300
  any(key in (patch.set or {}) for key in ("action_buttons", "actionButtons"))
10419
11301
  for patch in patch_views
10420
11302
  )
10421
11303
  if has_action_button_intent and not publish:
10422
- return _failed(
11304
+ return finish(_failed(
10423
11305
  "VIEW_ACTION_BUTTONS_REQUIRE_PUBLISH",
10424
11306
  "app_views_apply action_buttons require publish=true because the underlying custom button writer may publish after successful button writes",
10425
11307
  normalized_args=normalized_args,
@@ -10429,7 +11311,7 @@ class AiBuilderFacade:
10429
11311
  "reason": "action_buttons are compiled through app_custom_buttons_apply",
10430
11312
  },
10431
11313
  suggested_next_call={"tool_name": "app_views_apply", "arguments": {"profile": profile, **{**normalized_args, "publish": True}}},
10432
- )
11314
+ ))
10433
11315
  if not upsert_views and not patch_views and not remove_views:
10434
11316
  response = {
10435
11317
  "status": "success",
@@ -10457,10 +11339,10 @@ class AiBuilderFacade:
10457
11339
  "write_succeeded": False,
10458
11340
  "safe_to_retry": True,
10459
11341
  }
10460
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
10461
- permission_outcomes: list[PermissionCheckOutcome] = []
11342
+ return finish_with_publish(response)
10462
11343
  app_permission_summary: JSONObject | None = None
10463
11344
  app_permission_error: QingflowApiError | None = None
11345
+ permission_started_at = time.perf_counter()
10464
11346
  try:
10465
11347
  app_permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
10466
11348
  except (QingflowApiError, RuntimeError) as error:
@@ -10504,26 +11386,27 @@ class AiBuilderFacade:
10504
11386
  normalized_args=normalized_args,
10505
11387
  permission_summary=app_permission_summary,
10506
11388
  )
11389
+ permission_ms += _duration_ms(permission_started_at)
10507
11390
  if permission_outcome.block is not None:
10508
- return permission_outcome.block
11391
+ return finish(permission_outcome.block)
10509
11392
  permission_outcomes.append(permission_outcome)
10510
11393
 
10511
- def finalize(response: JSONObject) -> JSONObject:
10512
- return _apply_permission_outcomes(response, *permission_outcomes)
10513
-
10514
11394
  try:
11395
+ view_state_read_started_at = time.perf_counter()
10515
11396
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
10516
11397
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
10517
11398
  existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
10518
11399
  except (QingflowApiError, RuntimeError) as error:
11400
+ view_state_read_ms += _duration_ms(view_state_read_started_at)
10519
11401
  api_error = _coerce_api_error(error)
10520
- return finalize(_failed_from_api_error(
11402
+ return finish(_failed_from_api_error(
10521
11403
  "VIEWS_READ_FAILED",
10522
11404
  api_error,
10523
11405
  normalized_args=normalized_args,
10524
11406
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
10525
11407
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10526
11408
  ))
11409
+ view_state_read_ms += _duration_ms(view_state_read_started_at)
10527
11410
  app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or "").strip() or None
10528
11411
  existing_views = existing_views or []
10529
11412
  existing_by_key: dict[str, dict[str, Any]] = {}
@@ -10547,7 +11430,7 @@ class AiBuilderFacade:
10547
11430
  if action_button_patch_results:
10548
11431
  normalized_args["action_button_patch_results"] = action_button_patch_results
10549
11432
  if action_button_patch_issues:
10550
- return finalize(
11433
+ return finish(
10551
11434
  _failed(
10552
11435
  "VIEW_ACTION_BUTTON_PATCH_FAILED",
10553
11436
  "one or more patch_views action_buttons entries could not be resolved; no write was executed",
@@ -10572,6 +11455,7 @@ class AiBuilderFacade:
10572
11455
  transport_error=_transport_error_payload(app_permission_error),
10573
11456
  )
10574
11457
  else:
11458
+ create_permission_started_at = time.perf_counter()
10575
11459
  create_permission_outcome = self._guard_app_permission(
10576
11460
  profile=profile,
10577
11461
  app_key=app_key,
@@ -10579,13 +11463,14 @@ class AiBuilderFacade:
10579
11463
  normalized_args=normalized_args,
10580
11464
  permission_summary=app_permission_summary,
10581
11465
  )
11466
+ permission_ms += _duration_ms(create_permission_started_at)
10582
11467
  if create_permission_outcome.block is not None:
10583
11468
  details = create_permission_outcome.block.get("details")
10584
11469
  if isinstance(details, dict):
10585
11470
  details["operation"] = "view_create"
10586
11471
  details["view_names"] = creating_view_names
10587
11472
  details["also_required_permission"] = "view_manage"
10588
- return create_permission_outcome.block
11473
+ return finish(create_permission_outcome.block)
10589
11474
  permission_outcomes.append(create_permission_outcome)
10590
11475
  parsed_schema = _parse_schema(schema)
10591
11476
  field_names = {field["name"] for field in parsed_schema["fields"]}
@@ -10624,6 +11509,7 @@ class AiBuilderFacade:
10624
11509
  custom_button_details_by_id: dict[int, dict[str, Any]] = {}
10625
11510
  if requires_custom_button_validation:
10626
11511
  try:
11512
+ custom_button_inventory_started_at = time.perf_counter()
10627
11513
  button_listing = self.buttons.custom_button_list(
10628
11514
  profile=profile,
10629
11515
  app_key=app_key,
@@ -10631,8 +11517,9 @@ class AiBuilderFacade:
10631
11517
  include_raw=False,
10632
11518
  )
10633
11519
  except (QingflowApiError, RuntimeError) as error:
11520
+ custom_button_inventory_ms += _duration_ms(custom_button_inventory_started_at)
10634
11521
  api_error = _coerce_api_error(error)
10635
- return finalize(
11522
+ return finish(
10636
11523
  _failed_from_api_error(
10637
11524
  "CUSTOM_BUTTON_LIST_FAILED",
10638
11525
  api_error,
@@ -10641,6 +11528,7 @@ class AiBuilderFacade:
10641
11528
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10642
11529
  )
10643
11530
  )
11531
+ custom_button_inventory_ms += _duration_ms(custom_button_inventory_started_at)
10644
11532
  valid_custom_button_ids = {
10645
11533
  button_id
10646
11534
  for item in (button_listing.get("items") or [])
@@ -10654,6 +11542,7 @@ class AiBuilderFacade:
10654
11542
  }
10655
11543
  for button_id in sorted(referenced_custom_button_ids):
10656
11544
  try:
11545
+ custom_button_detail_started_at = time.perf_counter()
10657
11546
  detail = self.buttons.custom_button_get(
10658
11547
  profile=profile,
10659
11548
  app_key=app_key,
@@ -10661,7 +11550,9 @@ class AiBuilderFacade:
10661
11550
  being_draft=True,
10662
11551
  include_raw=False,
10663
11552
  )
11553
+ custom_button_inventory_ms += _duration_ms(custom_button_detail_started_at)
10664
11554
  except (QingflowApiError, RuntimeError) as error:
11555
+ custom_button_inventory_ms += _duration_ms(custom_button_detail_started_at)
10665
11556
  api_error = _coerce_api_error(error)
10666
11557
  if _is_optional_builder_lookup_error(api_error):
10667
11558
  continue
@@ -10680,7 +11571,7 @@ class AiBuilderFacade:
10680
11571
  },
10681
11572
  )
10682
11573
  failed.update({"write_executed": False, "write_succeeded": False, "safe_to_retry": True})
10683
- return finalize(failed)
11574
+ return finish(failed)
10684
11575
  detail_result = detail.get("result")
10685
11576
  if isinstance(detail_result, dict):
10686
11577
  custom_button_details_by_id[button_id] = _normalize_custom_button_detail(detail_result)
@@ -10688,10 +11579,12 @@ class AiBuilderFacade:
10688
11579
  associated_resources: list[dict[str, Any]] = []
10689
11580
  if requires_associated_resource_validation:
10690
11581
  try:
11582
+ associated_resource_inventory_started_at = time.perf_counter()
10691
11583
  associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
10692
11584
  except (QingflowApiError, RuntimeError) as error:
11585
+ associated_resource_inventory_ms += _duration_ms(associated_resource_inventory_started_at)
10693
11586
  api_error = _coerce_api_error(error)
10694
- return finalize(
11587
+ return finish(
10695
11588
  _failed_from_api_error(
10696
11589
  "ASSOCIATED_RESOURCES_READ_FAILED",
10697
11590
  api_error,
@@ -10700,6 +11593,7 @@ class AiBuilderFacade:
10700
11593
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
10701
11594
  )
10702
11595
  )
11596
+ associated_resource_inventory_ms += _duration_ms(associated_resource_inventory_started_at)
10703
11597
  removed: list[str] = []
10704
11598
  removed_keys: set[str] = set()
10705
11599
  view_results: list[dict[str, Any]] = []
@@ -10720,6 +11614,7 @@ class AiBuilderFacade:
10720
11614
  }
10721
11615
  )
10722
11616
 
11617
+ view_mutation_started_at = time.perf_counter()
10723
11618
  for selector in remove_views:
10724
11619
  selector_text = str(selector or "").strip()
10725
11620
  if not selector_text:
@@ -11356,6 +12251,7 @@ class AiBuilderFacade:
11356
12251
  failed_views.append(failure_entry)
11357
12252
  view_results.append(failure_entry)
11358
12253
  continue
12254
+ view_mutation_ms += _duration_ms(view_mutation_started_at)
11359
12255
  successful_action_view_keys = {
11360
12256
  str(item.get("view_key") or "").strip()
11361
12257
  for item in view_results
@@ -11377,11 +12273,13 @@ class AiBuilderFacade:
11377
12273
  runnable_action_button_intents = [
11378
12274
  intent for intent in action_button_intents if id(intent) not in skipped_action_button_intent_ids
11379
12275
  ]
12276
+ action_buttons_started_at = time.perf_counter()
11380
12277
  action_buttons_result = self._apply_view_action_buttons(
11381
12278
  profile=profile,
11382
12279
  app_key=app_key,
11383
12280
  intents=runnable_action_button_intents,
11384
12281
  )
12282
+ action_buttons_ms += _duration_ms(action_buttons_started_at)
11385
12283
  if skipped_action_button_intents:
11386
12284
  action_buttons_result.setdefault("skipped_due_to_view_write_failure", [])
11387
12285
  if isinstance(action_buttons_result["skipped_due_to_view_write_failure"], list):
@@ -11397,6 +12295,11 @@ class AiBuilderFacade:
11397
12295
  )
11398
12296
  action_button_write_executed = bool(action_buttons_result.get("write_executed"))
11399
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
+ )
11400
12303
  action_buttons_verification = action_buttons_result.get("verification") if isinstance(action_buttons_result.get("verification"), dict) else {}
11401
12304
  action_buttons_verified = (
11402
12305
  bool(action_buttons_result.get("verified", True))
@@ -11411,16 +12314,19 @@ class AiBuilderFacade:
11411
12314
  verified_views_unavailable = False
11412
12315
  if needs_view_list_readback:
11413
12316
  try:
12317
+ view_readback_started_at = time.perf_counter()
11414
12318
  verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
11415
12319
  except (QingflowApiError, RuntimeError) as error:
12320
+ view_readback_ms += _duration_ms(view_readback_started_at)
11416
12321
  api_error = _coerce_api_error(error)
11417
- return finalize(_failed_from_api_error(
12322
+ return finish(_failed_from_api_error(
11418
12323
  "VIEWS_READ_FAILED",
11419
12324
  api_error,
11420
12325
  normalized_args=normalized_args,
11421
12326
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
11422
12327
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
11423
12328
  ))
12329
+ view_readback_ms += _duration_ms(view_readback_started_at)
11424
12330
  verified_names = {
11425
12331
  _extract_view_name(item)
11426
12332
  for item in (verified_view_result or [])
@@ -11450,6 +12356,22 @@ class AiBuilderFacade:
11450
12356
  button_mismatches: list[dict[str, Any]] = []
11451
12357
  custom_button_readback_pending = False
11452
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()
11453
12375
  for item in view_results:
11454
12376
  status = str(item.get("status") or "")
11455
12377
  name = str(item.get("name") or "")
@@ -11496,8 +12418,9 @@ class AiBuilderFacade:
11496
12418
  verification_by_view.append(verification_entry)
11497
12419
  continue
11498
12420
  try:
11499
- config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
11500
- 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
11501
12424
  actual_filters = _normalize_view_filter_groups_for_compare(config_result.get("viewgraphLimit"))
11502
12425
  expected_filter_summary = _normalize_view_filter_groups_for_compare(expected_filters)
11503
12426
  expected_data_scope = "CUSTOM" if expected_filter_summary else "ALL"
@@ -11573,8 +12496,9 @@ class AiBuilderFacade:
11573
12496
  verification_by_view.append(verification_entry)
11574
12497
  continue
11575
12498
  try:
11576
- config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
11577
- 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
11578
12502
  actual_query_conditions = _normalize_view_query_conditions_for_compare(config_result)
11579
12503
  query_conditions_verified = actual_query_conditions == expected_query_conditions
11580
12504
  verification_entry["query_conditions_verified"] = query_conditions_verified
@@ -11621,8 +12545,9 @@ class AiBuilderFacade:
11621
12545
  verification_by_view.append(verification_entry)
11622
12546
  continue
11623
12547
  try:
11624
- config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
11625
- 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
11626
12551
  actual_associated_resources = _extract_view_associated_resources_config(
11627
12552
  config_result,
11628
12553
  available_resources=associated_resources,
@@ -11675,8 +12600,9 @@ class AiBuilderFacade:
11675
12600
  verification_by_view.append(verification_entry)
11676
12601
  continue
11677
12602
  try:
11678
- config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
11679
- 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
11680
12606
  actual_buttons = _normalize_view_buttons_for_compare(config_result)
11681
12607
  button_comparison = _compare_view_button_summaries(
11682
12608
  expected=expected_buttons,
@@ -11760,6 +12686,7 @@ class AiBuilderFacade:
11760
12686
  "error_code": item.get("error_code"),
11761
12687
  }
11762
12688
  )
12689
+ view_verification_ms += _duration_ms(view_verification_started_at)
11763
12690
  removed_delete_results = [
11764
12691
  item
11765
12692
  for item in view_results
@@ -11859,7 +12786,7 @@ class AiBuilderFacade:
11859
12786
  "write_succeeded": bool(created or updated or removed or action_button_write_succeeded),
11860
12787
  "safe_to_retry": not bool(created or updated or removed or action_button_write_executed),
11861
12788
  }
11862
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
12789
+ return finish_with_publish(response)
11863
12790
  warnings: list[dict[str, Any]] = []
11864
12791
  if filter_readback_pending or filter_mismatches:
11865
12792
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
@@ -11959,7 +12886,7 @@ class AiBuilderFacade:
11959
12886
  "write_succeeded": bool(created or updated or removed or action_button_write_succeeded),
11960
12887
  "safe_to_retry": not bool(created or updated or removed or action_button_write_executed),
11961
12888
  }
11962
- return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
12889
+ return finish_with_publish(response)
11963
12890
 
11964
12891
  def app_publish_verify(
11965
12892
  self,
@@ -11968,87 +12895,132 @@ class AiBuilderFacade:
11968
12895
  app_key: str,
11969
12896
  expected_package_tag_id: int | None = None,
11970
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
11971
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()
11972
12921
  try:
11973
12922
  base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
11974
12923
  except (QingflowApiError, RuntimeError) as error:
12924
+ initial_base_read_ms += _duration_ms(initial_base_read_started_at)
11975
12925
  api_error = _coerce_api_error(error)
11976
- return _failed_from_api_error(
11977
- "APP_READ_FAILED",
11978
- api_error,
11979
- normalized_args=normalized_args,
11980
- details={"app_key": app_key},
11981
- 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
+ )
11982
12934
  )
12935
+ initial_base_read_ms += _duration_ms(initial_base_read_started_at)
11983
12936
  tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
11984
12937
  app_name_before = str(base_before.get("formTitle") or base_before.get("title") or base_before.get("appName") or "").strip() or None
11985
12938
  already_published = bool(base_before.get("appPublishStatus") in {1, 2})
11986
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()
11987
12941
  try:
11988
12942
  views_before, views_before_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
11989
12943
  except (QingflowApiError, RuntimeError) as error:
12944
+ initial_views_read_ms += _duration_ms(initial_views_read_started_at)
11990
12945
  api_error = _coerce_api_error(error)
11991
- return _failed_from_api_error(
11992
- "VIEWS_READ_FAILED",
11993
- api_error,
11994
- normalized_args=normalized_args,
11995
- details={"app_key": app_key},
11996
- 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
+ )
11997
12954
  )
12955
+ initial_views_read_ms += _duration_ms(initial_views_read_started_at)
11998
12956
  views_before = views_before or []
11999
12957
  if already_published and package_already_attached is not False and isinstance(views_before, list) and not views_before_unavailable:
12000
- return {
12001
- "status": "success",
12002
- "error_code": None,
12003
- "recoverable": False,
12004
- "message": "app already published and verified",
12005
- "normalized_args": normalized_args,
12006
- "missing_fields": [],
12007
- "allowed_values": {},
12008
- "details": {},
12009
- "request_id": None,
12010
- "suggested_next_call": None,
12011
- "noop": True,
12012
- "warnings": [],
12013
- "verification": {"published": True, "package_attached": package_already_attached, "views_ok": True},
12014
- "app_key": app_key,
12015
- "app_name": app_name_before,
12016
- "published": True,
12017
- "package_attached": package_already_attached,
12018
- "tag_ids_after": tag_ids_before,
12019
- "views_ok": True,
12020
- "verified": True,
12021
- "write_executed": False,
12022
- "write_succeeded": False,
12023
- "safe_to_retry": True,
12024
- }
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()
12025
12986
  try:
12026
12987
  version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
12027
12988
  except (QingflowApiError, RuntimeError) as error:
12989
+ edit_version_ms += _duration_ms(edit_version_started_at)
12028
12990
  api_error = _coerce_api_error(error)
12029
- return _failed_from_api_error(
12030
- "PUBLISH_PRECHECK_FAILED",
12031
- api_error,
12032
- normalized_args=normalized_args,
12033
- details={"app_key": app_key, "phase": "prepare_publish_edit_version"},
12034
- 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
+ )
12035
12999
  )
13000
+ edit_version_ms += _duration_ms(edit_version_started_at)
12036
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()
12037
13003
  try:
12038
13004
  self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
12039
13005
  self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
12040
13006
  except (QingflowApiError, RuntimeError) as error:
13007
+ publish_ms += _duration_ms(publish_started_at)
12041
13008
  api_error = _coerce_api_error(error)
12042
- return _failed_from_api_error(
12043
- "PUBLISH_FAILED",
12044
- api_error,
12045
- normalized_args=normalized_args,
12046
- details={"app_key": app_key, "edit_version_no": edit_version_no},
12047
- 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
+ )
12048
13017
  )
13018
+ publish_ms += _duration_ms(publish_started_at)
13019
+ base_readback_started_at = time.perf_counter()
12049
13020
  try:
12050
13021
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
12051
13022
  except (QingflowApiError, RuntimeError) as error:
13023
+ base_readback_ms += _duration_ms(base_readback_started_at)
12052
13024
  api_error = _coerce_api_error(error)
12053
13025
  result = _post_write_readback_pending_result(
12054
13026
  error_code="PUBLISH_READBACK_PENDING",
@@ -12061,13 +13033,16 @@ class AiBuilderFacade:
12061
13033
  http_status=None if api_error.http_status == 404 else api_error.http_status,
12062
13034
  )
12063
13035
  result.update({"app_key": app_key, "published": None, "verified": False})
12064
- return result
13036
+ return finish(result)
13037
+ base_readback_ms += _duration_ms(base_readback_started_at)
12065
13038
  tag_ids_after = _coerce_int_list(base.get("tagIds"))
12066
13039
  app_name_after = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_name_before or "").strip() or None
12067
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()
12068
13042
  try:
12069
13043
  views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
12070
13044
  except (QingflowApiError, RuntimeError) as error:
13045
+ views_readback_ms += _duration_ms(views_readback_started_at)
12071
13046
  api_error = _coerce_api_error(error)
12072
13047
  result = _post_write_readback_pending_result(
12073
13048
  error_code="VIEWS_READBACK_PENDING",
@@ -12080,7 +13055,8 @@ class AiBuilderFacade:
12080
13055
  http_status=None if api_error.http_status == 404 else api_error.http_status,
12081
13056
  )
12082
13057
  result.update({"app_key": app_key, "app_name": app_name_after, "published": bool(base.get("appPublishStatus") in {1, 2}), "verified": False})
12083
- return result
13058
+ return finish(result)
13059
+ views_readback_ms += _duration_ms(views_readback_started_at)
12084
13060
  views = views or []
12085
13061
  views_ok = isinstance(views, list) and not views_unavailable
12086
13062
  verified = bool(base.get("appPublishStatus") in {1, 2}) and (package_attached is not False) and views_ok
@@ -12089,36 +13065,38 @@ class AiBuilderFacade:
12089
13065
  views_unavailable=views_unavailable,
12090
13066
  verified=verified,
12091
13067
  )
12092
- return {
12093
- "status": "success" if verified else "partial_success",
12094
- "error_code": None if not views_unavailable else "VIEWS_READBACK_PENDING",
12095
- "recoverable": bool(views_unavailable),
12096
- "message": "published and verified app" if not views_unavailable else "published app; views readback pending",
12097
- "normalized_args": normalized_args,
12098
- "missing_fields": [],
12099
- "allowed_values": {},
12100
- "details": {},
12101
- "request_id": None,
12102
- "suggested_next_call": None
12103
- if package_attached is not False
12104
- else {
12105
- "tool_name": "package_attach_app",
12106
- "arguments": {"profile": profile, "tag_id": expected_package_tag_id, "app_key": app_key},
12107
- },
12108
- "noop": False,
12109
- "warnings": warnings,
12110
- "verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
12111
- "app_key": app_key,
12112
- "app_name": app_name_after,
12113
- "published": bool(base.get("appPublishStatus") in {1, 2}),
12114
- "package_attached": package_attached,
12115
- "tag_ids_after": tag_ids_after,
12116
- "views_ok": views_ok,
12117
- "verified": verified,
12118
- "write_executed": True,
12119
- "write_succeeded": True,
12120
- "safe_to_retry": False,
12121
- }
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
+ )
12122
13100
 
12123
13101
  def _expand_chart_partial_patches(
12124
13102
  self,
@@ -12402,6 +13380,12 @@ class AiBuilderFacade:
12402
13380
  }
12403
13381
 
12404
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
12405
13389
  normalized_args = request.model_dump(mode="json")
12406
13390
  permission_outcomes: list[PermissionCheckOutcome] = []
12407
13391
  app_result = self.app_resolve(profile=profile, app_key=request.app_key)
@@ -12423,21 +13407,51 @@ class AiBuilderFacade:
12423
13407
  permission_outcomes.append(permission_outcome)
12424
13408
 
12425
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
+ )
12426
13419
  return _apply_permission_outcomes(response, *permission_outcomes)
12427
13420
 
12428
13421
  fields: list[dict[str, Any]] = []
12429
13422
  qingbi_fields: list[Any] = []
12430
13423
  existing_chart_items: list[Any] = []
12431
13424
  existing_chart_list_source: str | None = None
12432
- 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)
12433
13440
  if needs_chart_inventory:
13441
+ chart_inventory_started_at = time.perf_counter()
12434
13442
  try:
12435
- schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
12436
- parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
12437
- fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
12438
- qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
12439
- 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"
12440
13453
  except (QingflowApiError, RuntimeError) as error:
13454
+ chart_inventory_ms = _duration_ms(chart_inventory_started_at)
12441
13455
  api_error = _coerce_api_error(error)
12442
13456
  return finalize(_failed_from_api_error(
12443
13457
  "CHART_APPLY_FAILED",
@@ -12446,6 +13460,7 @@ class AiBuilderFacade:
12446
13460
  details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
12447
13461
  suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
12448
13462
  ))
13463
+ chart_inventory_ms = _duration_ms(chart_inventory_started_at)
12449
13464
 
12450
13465
  field_lookup = _build_public_field_lookup(fields)
12451
13466
  qingbi_fields_by_id = {
@@ -12472,7 +13487,6 @@ class AiBuilderFacade:
12472
13487
  continue
12473
13488
  existing_by_name.setdefault(item_name, []).append(deepcopy(item))
12474
13489
 
12475
- upsert_charts = list(request.upsert_charts)
12476
13490
  if request.patch_charts:
12477
13491
  expanded_charts, patch_issues, patch_results = self._expand_chart_partial_patches(
12478
13492
  profile=profile,
@@ -12495,67 +13509,37 @@ class AiBuilderFacade:
12495
13509
  normalized_args["upsert_charts"] = [patch.model_dump(mode="json") for patch in upsert_charts]
12496
13510
  normalized_args["patch_results"] = patch_results
12497
13511
 
12498
- if len(upsert_charts) > CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE:
12499
- batching_context = _chart_apply_batching_context(
12500
- request=request,
12501
- upsert_charts=upsert_charts,
12502
- batch_size=CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE,
12503
- )
12504
- first_batch = batching_context["suggested_batch_payloads"][0]
12505
- return finalize({
12506
- "status": "failed",
12507
- "error_code": "CHART_UPSERT_BATCH_TOO_LARGE",
12508
- "recoverable": True,
12509
- "message": (
12510
- f"upsert_charts contains {len(upsert_charts)} items; split into batches of "
12511
- f"{CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE} or fewer before writing"
12512
- ),
12513
- "normalized_args": normalized_args,
12514
- "missing_fields": [],
12515
- "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
12516
- "details": batching_context,
12517
- "request_id": None,
12518
- "suggested_next_call": {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **first_batch}},
12519
- "backend_code": None,
12520
- "http_status": None,
12521
- "noop": False,
12522
- "warnings": [
12523
- _warning(
12524
- "CHART_UPSERT_BATCH_SIZE_BLOCKED",
12525
- "large chart upsert batches are blocked before write to avoid timeout and unknown write state",
12526
- upsert_count=len(upsert_charts),
12527
- max_upsert_count=CHART_APPLY_RECOMMENDED_UPSERT_BATCH_SIZE,
12528
- )
12529
- ],
12530
- "verification": {
12531
- "charts_verified": False,
12532
- "readback_unavailable": False,
12533
- "readback_before_retry": False,
12534
- "chart_delete_readback_results": [],
12535
- "chart_order_verified": True,
12536
- "chart_list_source": existing_chart_list_source,
12537
- },
12538
- "app_key": app_key,
12539
- "app_name": app_name,
12540
- "chart_results": [],
12541
- "verified": False,
12542
- "write_executed": False,
12543
- "write_succeeded": False,
12544
- "safe_to_retry": True,
12545
- "next_action": "split_upsert_charts_and_retry",
12546
- })
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
+ )
12547
13524
 
12548
13525
  chart_results: list[dict[str, Any]] = []
12549
13526
  created_ids: list[str] = []
12550
13527
  updated_ids: list[str] = []
12551
13528
  removed_ids: list[str] = []
12552
13529
  failed_items: list[dict[str, Any]] = []
13530
+ skipped_after_timeout_count = 0
13531
+ stopped_after_timeout = False
12553
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
12554
13536
 
12555
- for patch in upsert_charts:
13537
+ chart_upsert_started_at = time.perf_counter()
13538
+ for patch_index, patch in enumerate(upsert_charts):
12556
13539
  chart_id = ""
12557
13540
  target_type = ""
12558
13541
  config_payload: dict[str, Any] | None = None
13542
+ patch_write_attempted = False
12559
13543
  try:
12560
13544
  dataset_source = _chart_patch_dataset_source_type(patch)
12561
13545
  if dataset_source:
@@ -12575,7 +13559,7 @@ class AiBuilderFacade:
12575
13559
  existing = existing_by_id.get(str(patch.chart_id))
12576
13560
  if existing is None:
12577
13561
  raise ValueError(f"chart_id '{patch.chart_id}' was not found under app '{app_key}'")
12578
- if existing is None:
13562
+ if existing is None and needs_existing_chart_inventory:
12579
13563
  name_matches = list(existing_by_name.get(patch.name) or [])
12580
13564
  if len(name_matches) > 1:
12581
13565
  raise ValueError(
@@ -12604,6 +13588,7 @@ class AiBuilderFacade:
12604
13588
  )
12605
13589
  if existing is None:
12606
13590
  temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
13591
+ chart_id = temp_chart_id
12607
13592
  create_payload = {
12608
13593
  "chartId": temp_chart_id,
12609
13594
  "chartName": patch.name,
@@ -12617,9 +13602,13 @@ class AiBuilderFacade:
12617
13602
  "editAuthType": "ws",
12618
13603
  "editAuthIncludeSubDept": True,
12619
13604
  }
13605
+ write_attempted = True
13606
+ patch_write_attempted = True
12620
13607
  create_result = self.charts.qingbi_report_create(profile=profile, payload=create_payload).get("result") or {}
12621
13608
  created_chart_id = _extract_chart_identifier(create_result or {})
13609
+ created_id_from_response = bool(created_chart_id)
12622
13610
  if not created_chart_id:
13611
+ create_id_readback_fallback_used = True
12623
13612
  try:
12624
13613
  refreshed_items, _ = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
12625
13614
  refreshed_matches = _find_charts_by_name(
@@ -12641,6 +13630,8 @@ class AiBuilderFacade:
12641
13630
  )
12642
13631
  chart_id = created_chart_id
12643
13632
  created_ids.append(chart_id)
13633
+ if created_id_from_response:
13634
+ created_ids_from_create_response.append(chart_id)
12644
13635
  created_chart = {
12645
13636
  "chartId": chart_id,
12646
13637
  "chartName": patch.name,
@@ -12650,6 +13641,8 @@ class AiBuilderFacade:
12650
13641
  existing_by_name.setdefault(patch.name, []).append(deepcopy(created_chart))
12651
13642
  elif existing_name != patch.name or existing_type != target_type or isinstance(chart_visible_auth, dict):
12652
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
12653
13646
  self.charts.qingbi_report_update_base(
12654
13647
  profile=profile,
12655
13648
  chart_id=chart_id,
@@ -12688,11 +13681,15 @@ class AiBuilderFacade:
12688
13681
 
12689
13682
  config_updated = False
12690
13683
  if existing is None or config_update_requested:
13684
+ write_attempted = True
13685
+ patch_write_attempted = True
12691
13686
  self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload or {})
12692
13687
  config_updated = True
12693
13688
  if existing is not None and chart_id not in updated_ids and config_updated:
12694
13689
  updated_ids.append(chart_id)
12695
13690
  if patch.question_config:
13691
+ write_attempted = True
13692
+ patch_write_attempted = True
12696
13693
  self._request_backend(
12697
13694
  profile=profile,
12698
13695
  method="POST",
@@ -12700,6 +13697,8 @@ class AiBuilderFacade:
12700
13697
  json_body=patch.question_config,
12701
13698
  )
12702
13699
  if patch.user_config:
13700
+ write_attempted = True
13701
+ patch_write_attempted = True
12703
13702
  self._request_backend(
12704
13703
  profile=profile,
12705
13704
  method="POST",
@@ -12739,9 +13738,30 @@ class AiBuilderFacade:
12739
13738
  }
12740
13739
  failed_items.append(failure)
12741
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)
12742
13760
 
13761
+ chart_delete_started_at = time.perf_counter()
12743
13762
  for chart_id in request.remove_chart_ids:
12744
13763
  try:
13764
+ write_attempted = True
12745
13765
  self.charts.qingbi_report_delete(profile=profile, chart_id=chart_id)
12746
13766
  removed_ids.append(chart_id)
12747
13767
  delete_result = self._verify_chart_deleted_by_id(profile=profile, chart_id=chart_id)
@@ -12763,8 +13783,10 @@ class AiBuilderFacade:
12763
13783
  }
12764
13784
  failed_items.append(failure)
12765
13785
  chart_results.append(failure)
13786
+ chart_delete_ms = _duration_ms(chart_delete_started_at)
12766
13787
 
12767
13788
  reordered = False
13789
+ chart_reorder_started_at = time.perf_counter()
12768
13790
  if request.reorder_chart_ids:
12769
13791
  try:
12770
13792
  current_order = [
@@ -12774,6 +13796,7 @@ class AiBuilderFacade:
12774
13796
  ]
12775
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]
12776
13798
  backend_reorder_ids = list(reversed(desired_display_order))
13799
+ write_attempted = True
12777
13800
  self.charts.qingbi_report_reorder(profile=profile, app_key=app_key, chart_ids=backend_reorder_ids)
12778
13801
  reordered = True
12779
13802
  except (QingflowApiError, RuntimeError) as error:
@@ -12788,17 +13811,34 @@ class AiBuilderFacade:
12788
13811
  }
12789
13812
  failed_items.append(failure)
12790
13813
  chart_results.append(failure)
13814
+ chart_reorder_ms = _duration_ms(chart_reorder_started_at)
12791
13815
 
12792
13816
  noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
12793
- write_executed = bool(created_ids or updated_ids or removed_ids or reordered)
12794
- write_succeeded = write_executed
12795
- 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
12796
13831
  delete_readback_unavailable = any(item.get("readback_status") == "unavailable" for item in delete_readback_issues)
12797
13832
  deletes_verified = not delete_readback_issues
12798
13833
  readback_unavailable = False
12799
13834
  readback_error: QingflowApiError | None = None
12800
13835
  readback_list_source: str | None = existing_chart_list_source
12801
- 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()
12802
13842
  try:
12803
13843
  readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
12804
13844
  readback_ids = {
@@ -12821,8 +13861,10 @@ class AiBuilderFacade:
12821
13861
  verified = False
12822
13862
  readback_unavailable = True
12823
13863
  readback_list_source = None
13864
+ chart_readback_ms = _duration_ms(chart_readback_started_at)
12824
13865
  else:
12825
13866
  verified = True
13867
+ chart_readback_ms = 0
12826
13868
  verified = verified and deletes_verified
12827
13869
  any_readback_unavailable = readback_unavailable or delete_readback_unavailable
12828
13870
 
@@ -12850,6 +13892,10 @@ class AiBuilderFacade:
12850
13892
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
12851
13893
  "details": {
12852
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,
12853
13899
  **retry_context,
12854
13900
  **({"readback_error": _transport_error_payload(readback_error)} if readback_error is not None else {}),
12855
13901
  },
@@ -12858,7 +13904,7 @@ class AiBuilderFacade:
12858
13904
  "backend_code": failed_items[0].get("backend_code") or (readback_error.backend_code if readback_error is not None else None),
12859
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),
12860
13906
  "noop": noop,
12861
- "warnings": _chart_apply_warnings(
13907
+ "warnings": large_upsert_warnings + _chart_apply_warnings(
12862
13908
  failed_items=failed_items,
12863
13909
  readback_unavailable=readback_unavailable,
12864
13910
  verified=False if failed_items else verified,
@@ -12871,6 +13917,8 @@ class AiBuilderFacade:
12871
13917
  "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
12872
13918
  "chart_order_verified": False if request.reorder_chart_ids else True,
12873
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,
12874
13922
  },
12875
13923
  "app_key": app_key,
12876
13924
  "app_name": app_name,
@@ -12898,13 +13946,18 @@ class AiBuilderFacade:
12898
13946
  "normalized_args": normalized_args,
12899
13947
  "missing_fields": [],
12900
13948
  "allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
12901
- "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
+ },
12902
13955
  "request_id": readback_error.request_id if readback_error is not None else None,
12903
13956
  "suggested_next_call": None if result_verified else pending_suggestion,
12904
13957
  "backend_code": readback_error.backend_code if readback_error is not None else None,
12905
13958
  "http_status": None if readback_error is None or readback_error.http_status == 404 else readback_error.http_status,
12906
13959
  "noop": noop,
12907
- "warnings": _chart_apply_warnings(
13960
+ "warnings": large_upsert_warnings + _chart_apply_warnings(
12908
13961
  failed_items=[],
12909
13962
  readback_unavailable=False if noop else readback_unavailable,
12910
13963
  verified=result_verified,
@@ -12916,6 +13969,8 @@ class AiBuilderFacade:
12916
13969
  "chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
12917
13970
  "chart_order_verified": (readback_list_source == "sorted" and result_verified) if request.reorder_chart_ids else True,
12918
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,
12919
13974
  },
12920
13975
  "app_key": app_key,
12921
13976
  "app_name": app_name,
@@ -12927,6 +13982,15 @@ class AiBuilderFacade:
12927
13982
  })
12928
13983
 
12929
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
12930
13994
  normalized_args = request.model_dump(mode="json")
12931
13995
  permission_outcomes: list[PermissionCheckOutcome] = []
12932
13996
  dash_key = str(request.dash_key or "").strip()
@@ -12956,8 +14020,10 @@ class AiBuilderFacade:
12956
14020
  suggested_next_call=None,
12957
14021
  )
12958
14022
  try:
14023
+ portal_initial_read_started_at = time.perf_counter()
12959
14024
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") if dash_key else {}
12960
14025
  except (QingflowApiError, RuntimeError) as error:
14026
+ portal_initial_read_ms = _duration_ms(portal_initial_read_started_at)
12961
14027
  api_error = _coerce_api_error(error)
12962
14028
  return _failed(
12963
14029
  "PORTAL_APPLY_FAILED",
@@ -12969,6 +14035,7 @@ class AiBuilderFacade:
12969
14035
  backend_code=api_error.backend_code,
12970
14036
  http_status=None if api_error.http_status == 404 else api_error.http_status,
12971
14037
  )
14038
+ portal_initial_read_ms = _duration_ms(portal_initial_read_started_at) if dash_key else 0
12972
14039
  if not isinstance(base_payload, dict):
12973
14040
  base_payload = {}
12974
14041
  if not creating:
@@ -12983,6 +14050,18 @@ class AiBuilderFacade:
12983
14050
  permission_outcomes.append(portal_permission_outcome)
12984
14051
 
12985
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
+ )
12986
14065
  return _apply_permission_outcomes(response, *permission_outcomes)
12987
14066
  target_package_tag_id = request.package_tag_id
12988
14067
  if target_package_tag_id is None:
@@ -13042,7 +14121,9 @@ class AiBuilderFacade:
13042
14121
  config=request.config,
13043
14122
  base_payload=None,
13044
14123
  )
14124
+ portal_create_started_at = time.perf_counter()
13045
14125
  create_result = self.portals.portal_create(profile=profile, payload=create_payload)
14126
+ portal_create_ms = _duration_ms(portal_create_started_at)
13046
14127
  write_executed = True
13047
14128
  created = create_result.get("result") if isinstance(create_result.get("result"), dict) else {}
13048
14129
  dash_key = str(created.get("dashKey") or "")
@@ -13075,36 +14156,61 @@ class AiBuilderFacade:
13075
14156
  base_payload=base_payload,
13076
14157
  )
13077
14158
  if sections_requested:
14159
+ portal_component_build_started_at = time.perf_counter()
13078
14160
  component_payload, layout_metadata, inline_chart_results = self._build_portal_components_from_sections(
13079
14161
  profile=profile,
13080
14162
  sections=request.sections,
13081
14163
  layout_preset=request.layout_preset,
13082
14164
  inline_chart_results=inline_chart_results,
13083
14165
  )
14166
+ portal_component_build_ms = _duration_ms(portal_component_build_started_at)
13084
14167
  layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload, layout_metadata=layout_metadata)
13085
14168
  update_payload["components"] = component_payload
14169
+ portal_update_started_at = time.perf_counter()
13086
14170
  self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
14171
+ portal_update_ms = _duration_ms(portal_update_started_at)
13087
14172
  write_executed = True
13088
- self.portals.portal_update_base_info(
13089
- profile=profile,
13090
- dash_key=dash_key,
13091
- payload={
13092
- "dashName": update_payload.get("dashName"),
13093
- "dashIcon": update_payload.get("dashIcon"),
13094
- "auth": deepcopy(update_payload.get("auth")),
13095
- "tags": deepcopy(update_payload.get("tags") or []),
13096
- },
13097
- )
13098
- 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
13099
14201
  try:
14202
+ portal_draft_readback_started_at = time.perf_counter()
13100
14203
  draft_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") or {}
13101
14204
  except (QingflowApiError, RuntimeError) as read_error:
14205
+ portal_draft_readback_ms = _duration_ms(portal_draft_readback_started_at)
13102
14206
  api_read_error = _coerce_api_error(read_error)
13103
14207
  draft_readback_error = {
13104
14208
  "phase": "portal_draft_readback",
13105
14209
  "transport_error": _transport_error_payload(api_read_error),
13106
14210
  }
13107
14211
  draft_result = {}
14212
+ else:
14213
+ portal_draft_readback_ms = _duration_ms(portal_draft_readback_started_at)
13108
14214
  except (QingflowApiError, RuntimeError, ValueError) as error:
13109
14215
  api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
13110
14216
  inline_chart_write_executed = any(bool(item.get("write_executed")) for item in inline_chart_results)
@@ -13180,9 +14286,12 @@ class AiBuilderFacade:
13180
14286
  publish_error: JSONObject | None = None
13181
14287
  if request.publish:
13182
14288
  try:
14289
+ portal_publish_started_at = time.perf_counter()
13183
14290
  self.portals.portal_publish(profile=profile, dash_key=dash_key)
14291
+ portal_publish_ms = _duration_ms(portal_publish_started_at)
13184
14292
  published = True
13185
14293
  except (QingflowApiError, RuntimeError) as error:
14294
+ portal_publish_ms = _duration_ms(portal_publish_started_at)
13186
14295
  api_error = _coerce_api_error(error)
13187
14296
  publish_failed = True
13188
14297
  publish_error = {
@@ -13191,13 +14300,20 @@ class AiBuilderFacade:
13191
14300
  }
13192
14301
  if published:
13193
14302
  try:
14303
+ portal_live_readback_started_at = time.perf_counter()
13194
14304
  live_result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=False).get("result") or {}
13195
14305
  except (QingflowApiError, RuntimeError) as read_error:
14306
+ portal_live_readback_ms = _duration_ms(portal_live_readback_started_at)
13196
14307
  api_read_error = _coerce_api_error(read_error)
13197
14308
  live_readback_error = {
13198
14309
  "phase": "portal_live_readback",
13199
14310
  "transport_error": _transport_error_payload(api_read_error),
13200
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
13201
14317
 
13202
14318
  draft_components = draft_result.get("components") if isinstance(draft_result, dict) else None
13203
14319
  expected_count = len(request.sections) if sections_requested else None
@@ -13336,55 +14452,79 @@ class AiBuilderFacade:
13336
14452
  })
13337
14453
 
13338
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
13339
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
+
13340
14469
  dash_key = str(dash_key or "").strip()
13341
14470
  if not dash_key:
13342
- return _failed(
13343
- "PORTAL_DASH_KEY_REQUIRED",
13344
- "dash_key is required when deleting a portal",
13345
- normalized_args=normalized_args,
13346
- missing_fields=["dash_key"],
13347
- 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
+ )
13348
14479
  )
14480
+ portal_delete_started_at = time.perf_counter()
13349
14481
  try:
13350
14482
  self.portals.portal_delete(profile=profile, dash_key=dash_key)
13351
14483
  except (QingflowApiError, RuntimeError) as error:
14484
+ portal_delete_ms += _duration_ms(portal_delete_started_at)
13352
14485
  api_error = _coerce_api_error(error)
13353
- return _failed_from_api_error(
13354
- "PORTAL_DELETE_FAILED",
13355
- api_error,
13356
- normalized_args=normalized_args,
13357
- details={"dash_key": dash_key},
13358
- 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
+ )
13359
14494
  )
14495
+ portal_delete_ms += _duration_ms(portal_delete_started_at)
13360
14496
 
14497
+ portal_delete_readback_started_at = time.perf_counter()
13361
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)
13362
14500
  verified = delete_readback.get("readback_status") == "deleted"
13363
- return {
13364
- "status": "success" if verified else "partial_success",
13365
- "error_code": None if verified else delete_readback.get("error_code") or "PORTAL_DELETE_READBACK_PENDING",
13366
- "recoverable": not verified,
13367
- "message": "deleted portal" if verified else "portal delete completed; readback pending",
13368
- "normalized_args": normalized_args,
13369
- "missing_fields": [],
13370
- "allowed_values": {},
13371
- "details": {} if verified else {"delete_readback": delete_readback},
13372
- "request_id": delete_readback.get("request_id"),
13373
- "backend_code": delete_readback.get("backend_code"),
13374
- "http_status": delete_readback.get("http_status"),
13375
- "suggested_next_call": None if verified else {"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key}},
13376
- "noop": False,
13377
- "warnings": [] if verified else [_warning("PORTAL_DELETE_READBACK_PENDING", "portal delete was sent, but deletion readback is not fully verified")],
13378
- "verification": {"portal_deleted": verified, "delete_readback": delete_readback},
13379
- "verified": verified,
13380
- "dash_key": dash_key,
13381
- "deleted": verified,
13382
- "delete_executed": True,
13383
- "readback_status": delete_readback.get("readback_status"),
13384
- "safe_to_retry_delete": False,
13385
- "write_executed": True,
13386
- "safe_to_retry": False,
13387
- }
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
+ )
13388
14528
 
13389
14529
  def _verify_portal_deleted_by_key(self, *, profile: str, dash_key: str) -> JSONObject:
13390
14530
  try:
@@ -13787,36 +14927,30 @@ class AiBuilderFacade:
13787
14927
  app_key: str,
13788
14928
  app_name: str,
13789
14929
  package_tag_id: int | None,
13790
- create_if_missing: bool,
13791
14930
  ) -> JSONObject:
13792
14931
  if app_key:
13793
14932
  return self.app_resolve(profile=profile, app_key=app_key)
13794
14933
  if app_name:
13795
- resolved = self.app_resolve(profile=profile, app_name=app_name, package_tag_id=package_tag_id)
13796
- if resolved.get("status") == "success":
13797
- return resolved
13798
- if not create_if_missing:
13799
- return resolved
13800
- 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:
13801
14953
  return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", suggested_next_call=None)
13802
- return {
13803
- "status": "success",
13804
- "error_code": None,
13805
- "recoverable": False,
13806
- "message": "target app would be created",
13807
- "normalized_args": {},
13808
- "missing_fields": [],
13809
- "allowed_values": {},
13810
- "details": {},
13811
- "request_id": None,
13812
- "suggested_next_call": None,
13813
- "noop": False,
13814
- "verification": {},
13815
- "app_key": "",
13816
- "app_name": app_name or "未命名应用",
13817
- "tag_ids": [package_tag_id] if package_tag_id else [],
13818
- "would_create": True,
13819
- }
13820
14954
 
13821
14955
  def _create_target_app_shell(
13822
14956
  self,
@@ -13885,7 +15019,7 @@ class AiBuilderFacade:
13885
15019
  if not new_app_key:
13886
15020
  return _failed("APP_CREATE_FAILED", "failed to create app shell", details={"result": result}, suggested_next_call=None)
13887
15021
  try:
13888
- 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)
13889
15023
  except (QingflowApiError, RuntimeError) as error:
13890
15024
  api_error = _coerce_api_error(error)
13891
15025
  if api_error.http_status != 404:
@@ -14078,12 +15212,104 @@ class AiBuilderFacade:
14078
15212
  resolved_components: list[dict[str, Any]] = []
14079
15213
  layout_metadata: list[dict[str, Any]] = []
14080
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])
14081
15307
  pc_x = 0
14082
15308
  pc_y = 0
14083
15309
  pc_row_height = 0
14084
15310
  mobile_y = 0
14085
15311
  app_title_cache: dict[str, str] = {}
14086
- for section in sections:
15312
+ for section_index, section in enumerate(sections):
14087
15313
  position_payload: dict[str, Any]
14088
15314
  if section.position is None:
14089
15315
  position_payload, pc_x, pc_y, pc_row_height, mobile_y = _portal_component_position_public(
@@ -14105,46 +15331,9 @@ class AiBuilderFacade:
14105
15331
  component: dict[str, Any]
14106
15332
  if section.source_type == "chart":
14107
15333
  if section.chart is not None:
14108
- inline_chart_payload = section.chart.model_dump(mode="json", exclude={"app_key"})
14109
- chart_request = ChartApplyRequest(
14110
- app_key=section.chart.app_key,
14111
- upsert_charts=[ChartUpsertPatch.model_validate(inline_chart_payload)],
14112
- patch_charts=[],
14113
- remove_chart_ids=[],
14114
- reorder_chart_ids=[],
14115
- )
14116
- chart_apply = self.chart_apply(profile=profile, request=chart_request)
14117
- chart_items = chart_apply.get("chart_results") if isinstance(chart_apply.get("chart_results"), list) else []
14118
- successful_chart = next(
14119
- (
14120
- item
14121
- for item in chart_items
14122
- if isinstance(item, dict)
14123
- and str(item.get("status") or "") in {"created", "updated"}
14124
- and str(item.get("chart_id") or "").strip()
14125
- ),
14126
- None,
14127
- )
14128
- inline_chart_results.append({
14129
- "title": section.title,
14130
- "app_key": section.chart.app_key,
14131
- "chart_name": section.chart.name,
14132
- "chart_type": section.chart.chart_type.value,
14133
- "status": str(successful_chart.get("status") or "") if isinstance(successful_chart, dict) else "failed",
14134
- "chart_id": str(successful_chart.get("chart_id") or "") if isinstance(successful_chart, dict) else "",
14135
- "chart_apply_status": chart_apply.get("status"),
14136
- "write_executed": bool(chart_apply.get("write_executed")),
14137
- **({"error_code": chart_apply.get("error_code")} if chart_apply.get("error_code") else {}),
14138
- **({"message": chart_apply.get("message")} if chart_apply.get("message") else {}),
14139
- })
14140
- if not isinstance(successful_chart, dict):
15334
+ resolved_chart = inline_chart_resolutions.get(section_index)
15335
+ if not isinstance(resolved_chart, dict):
14141
15336
  raise ValueError(f"inline chart '{section.chart.name}' could not be created or updated for portal section '{section.title}'")
14142
- resolved_chart = {
14143
- "chart_id": str(successful_chart.get("chart_id") or "").strip(),
14144
- "chart_name": str(successful_chart.get("name") or section.chart.name).strip(),
14145
- "app_key": section.chart.app_key,
14146
- "chart_type": section.chart.chart_type.value,
14147
- }
14148
15337
  else:
14149
15338
  resolved_chart = _resolve_chart_reference(
14150
15339
  charts=self.charts,
@@ -18774,7 +19963,11 @@ def _verify_relation_readback_by_name(
18774
19963
  for item in cast(list[Any], actual.get("visible_fields") or [])
18775
19964
  if isinstance(item, dict) and str(item.get("name") or "").strip()
18776
19965
  ]
18777
- 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
+ ):
18778
19971
  return False
18779
19972
  return True
18780
19973
 
@@ -18792,6 +19985,36 @@ def _relation_field_names(values: object) -> list[str]:
18792
19985
  return names
18793
19986
 
18794
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
+
18795
20018
  def _relation_field_public_selector(value: object) -> dict[str, Any] | None:
18796
20019
  if not isinstance(value, dict):
18797
20020
  return None
@@ -18850,13 +20073,18 @@ def _schema_relation_readback_matrix(
18850
20073
  actual_display = _relation_field_public_selector((actual or {}).get("display_field"))
18851
20074
  expected_visible_names = _relation_field_names((expected or degraded or {}).get("visible_fields"))
18852
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
18853
20077
 
18854
20078
  checks = {
18855
20079
  "field_exists": isinstance(actual, dict),
18856
20080
  "target_app_key": expected_target == actual_target,
18857
20081
  "relation_mode": expected_mode == actual_mode,
18858
20082
  "display_field": (expected_display or {}).get("name") == (actual_display or {}).get("name"),
18859
- "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
+ ),
18860
20088
  }
18861
20089
  readback_verified = all(checks.values())
18862
20090
  if not isinstance(actual, dict):
@@ -21636,6 +22864,21 @@ def _layouts_semantically_equal(left: dict[str, Any], right: dict[str, Any]) ->
21636
22864
  return _layout_field_sequence(left) == _layout_field_sequence(right)
21637
22865
 
21638
22866
 
22867
+ def _duration_ms(started_at: float) -> int:
22868
+ return max(0, int((time.perf_counter() - started_at) * 1000))
22869
+
22870
+
22871
+ def _merge_duration_breakdown(response: JSONObject, **items: int | None) -> None:
22872
+ breakdown = response.get("duration_breakdown_ms")
22873
+ if not isinstance(breakdown, dict):
22874
+ breakdown = {}
22875
+ response["duration_breakdown_ms"] = breakdown
22876
+ for key, value in items.items():
22877
+ if value is None:
22878
+ continue
22879
+ breakdown[key] = int(value)
22880
+
22881
+
21639
22882
  def _find_field_section_id(layout: dict[str, Any], field_name: str) -> str | None:
21640
22883
  for section in layout.get("sections", []) or []:
21641
22884
  for row in section.get("rows", []) or []:
@@ -27530,7 +28773,6 @@ def _normalize_flow_stage_failure(stage: JSONObject, *, profile: str, app_key: s
27530
28773
  "arguments": {
27531
28774
  "profile": profile,
27532
28775
  "app_key": app_key,
27533
- "create_if_missing": False,
27534
28776
  "add_fields": [
27535
28777
  {
27536
28778
  "name": "状态",