@josephyan/qingflow-cli 0.2.0-beta.58 → 0.2.0-beta.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +3 -2
  2. package/docs/local-agent-install.md +9 -0
  3. package/npm/bin/qingflow.mjs +1 -1
  4. package/npm/lib/runtime.mjs +156 -21
  5. package/package.json +1 -1
  6. package/pyproject.toml +1 -1
  7. package/src/qingflow_mcp/builder_facade/service.py +670 -191
  8. package/src/qingflow_mcp/cli/commands/app.py +16 -16
  9. package/src/qingflow_mcp/cli/commands/auth.py +19 -16
  10. package/src/qingflow_mcp/cli/commands/builder.py +124 -162
  11. package/src/qingflow_mcp/cli/commands/common.py +21 -95
  12. package/src/qingflow_mcp/cli/commands/imports.py +42 -34
  13. package/src/qingflow_mcp/cli/commands/record.py +131 -133
  14. package/src/qingflow_mcp/cli/commands/task.py +43 -44
  15. package/src/qingflow_mcp/cli/commands/workspace.py +10 -10
  16. package/src/qingflow_mcp/cli/context.py +35 -32
  17. package/src/qingflow_mcp/cli/formatters.py +124 -121
  18. package/src/qingflow_mcp/cli/main.py +52 -17
  19. package/src/qingflow_mcp/server_app_builder.py +122 -190
  20. package/src/qingflow_mcp/server_app_user.py +63 -662
  21. package/src/qingflow_mcp/solution/executor.py +63 -4
  22. package/src/qingflow_mcp/tools/solution_tools.py +115 -3
  23. package/src/qingflow_mcp/ops/__init__.py +0 -3
  24. package/src/qingflow_mcp/ops/apps.py +0 -64
  25. package/src/qingflow_mcp/ops/auth.py +0 -121
  26. package/src/qingflow_mcp/ops/base.py +0 -290
  27. package/src/qingflow_mcp/ops/builder.py +0 -357
  28. package/src/qingflow_mcp/ops/context.py +0 -120
  29. package/src/qingflow_mcp/ops/directory.py +0 -171
  30. package/src/qingflow_mcp/ops/feedback.py +0 -49
  31. package/src/qingflow_mcp/ops/files.py +0 -78
  32. package/src/qingflow_mcp/ops/imports.py +0 -140
  33. package/src/qingflow_mcp/ops/records.py +0 -415
  34. package/src/qingflow_mcp/ops/tasks.py +0 -171
  35. package/src/qingflow_mcp/ops/workspace.py +0 -76
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from copy import deepcopy
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
5
  import json
6
6
  import os
7
7
  import re
@@ -129,6 +129,14 @@ class ResolvedApp:
129
129
  tag_ids: list[int]
130
130
 
131
131
 
132
+ @dataclass(slots=True)
133
+ class PermissionCheckOutcome:
134
+ block: JSONObject | None = None
135
+ warnings: list[dict[str, Any]] = field(default_factory=list)
136
+ details: JSONObject = field(default_factory=dict)
137
+ verification: JSONObject = field(default_factory=dict)
138
+
139
+
132
140
  class AiBuilderFacade:
133
141
  def __init__(
134
142
  self,
@@ -161,7 +169,18 @@ class AiBuilderFacade:
161
169
  "package_name is required",
162
170
  suggested_next_call=None,
163
171
  )
164
- listing = self.packages.package_list(profile=profile, trial_status="all", include_raw=False)
172
+ normalized_args = {"package_name": requested}
173
+ try:
174
+ listing = self.packages.package_list(profile=profile, trial_status="all", include_raw=False)
175
+ except (QingflowApiError, RuntimeError) as error:
176
+ api_error = _coerce_api_error(error)
177
+ return _failed_from_api_error(
178
+ "PACKAGE_RESOLVE_FAILED",
179
+ api_error,
180
+ normalized_args=normalized_args,
181
+ details={"package_name": requested},
182
+ suggested_next_call={"tool_name": "package_list", "arguments": {"profile": profile, "trial_status": "all"}},
183
+ )
165
184
  items = listing.get("items") if isinstance(listing.get("items"), list) else []
166
185
  matches = [
167
186
  {"tag_id": item.get("tagId"), "tag_name": item.get("tagName")}
@@ -212,6 +231,7 @@ class AiBuilderFacade:
212
231
  suggested_next_call=None,
213
232
  )
214
233
  existing = self.package_resolve(profile=profile, package_name=requested)
234
+ lookup_permission_blocked = None
215
235
  if existing.get("status") == "success":
216
236
  return {
217
237
  "status": "success",
@@ -231,6 +251,16 @@ class AiBuilderFacade:
231
251
  }
232
252
  if existing.get("error_code") == "AMBIGUOUS_PACKAGE":
233
253
  return existing
254
+ if existing.get("error_code") == "PACKAGE_RESOLVE_FAILED":
255
+ if existing.get("backend_code") not in {40002, 40027}:
256
+ return existing
257
+ lookup_permission_blocked = {
258
+ "backend_code": existing.get("backend_code"),
259
+ "http_status": existing.get("http_status"),
260
+ "request_id": existing.get("request_id"),
261
+ }
262
+ elif existing.get("error_code") not in {"PACKAGE_NOT_FOUND"}:
263
+ return existing
234
264
  try:
235
265
  created = self.packages.package_create(profile=profile, payload={"tagName": requested})
236
266
  except (QingflowApiError, RuntimeError) as error:
@@ -239,7 +269,10 @@ class AiBuilderFacade:
239
269
  "PACKAGE_CREATE_FAILED",
240
270
  api_error,
241
271
  normalized_args=normalized_args,
242
- details={"package_name": requested},
272
+ details={
273
+ "package_name": requested,
274
+ **({"lookup_permission_blocked": lookup_permission_blocked} if lookup_permission_blocked is not None else {}),
275
+ },
243
276
  suggested_next_call={"tool_name": "package_create", "arguments": {"profile": profile, "package_name": requested}},
244
277
  )
245
278
  result = created.get("result") if isinstance(created.get("result"), dict) else {}
@@ -259,7 +292,7 @@ class AiBuilderFacade:
259
292
  "normalized_args": normalized_args,
260
293
  "missing_fields": [],
261
294
  "allowed_values": {},
262
- "details": {},
295
+ "details": {"lookup_permission_blocked": lookup_permission_blocked} if lookup_permission_blocked is not None else {},
263
296
  "request_id": None,
264
297
  "suggested_next_call": None
265
298
  if verified
@@ -779,21 +812,53 @@ class AiBuilderFacade:
779
812
  app_key: str,
780
813
  app_title: str = "",
781
814
  ) -> JSONObject:
815
+ normalized_args = {"tag_id": tag_id, "app_key": app_key, "app_title": app_title}
782
816
  if _coerce_positive_int(tag_id) is None:
783
817
  return _failed("TAG_ID_REQUIRED", "tag_id must be positive", suggested_next_call=None)
784
- package_permission_block = self._guard_package_permission(
818
+ permission_outcomes: list[PermissionCheckOutcome] = []
819
+ package_permission_outcome = self._guard_package_permission(
785
820
  profile=profile,
786
821
  tag_id=tag_id,
787
822
  required_permission="edit_app",
788
- normalized_args={"tag_id": tag_id, "app_key": app_key, "app_title": app_title},
823
+ normalized_args=normalized_args,
789
824
  )
790
- if package_permission_block is not None:
791
- return package_permission_block
825
+ if package_permission_outcome.block is not None:
826
+ return package_permission_outcome.block
827
+ permission_outcomes.append(package_permission_outcome)
828
+
829
+ def finalize(response: JSONObject) -> JSONObject:
830
+ return _apply_permission_outcomes(response, *permission_outcomes)
831
+
792
832
  resolved = self.app_resolve(profile=profile, app_key=app_key)
793
833
  if resolved.get("status") == "failed":
794
- return resolved
795
- base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
796
- tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
834
+ return finalize(resolved)
835
+ resolved_outcome = _permission_outcome_from_result(resolved)
836
+ if resolved_outcome is not None:
837
+ permission_outcomes.append(resolved_outcome)
838
+ try:
839
+ base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
840
+ tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
841
+ except (QingflowApiError, RuntimeError) as error:
842
+ api_error = _coerce_api_error(error)
843
+ tag_ids_before = []
844
+ if _is_permission_restricted_api_error(api_error):
845
+ permission_outcomes.append(
846
+ _verification_read_outcome(
847
+ resource="app",
848
+ target={"app_key": app_key},
849
+ transport_error=api_error,
850
+ )
851
+ )
852
+ else:
853
+ return finalize(
854
+ _failed_from_api_error(
855
+ "PACKAGE_ATTACH_FAILED",
856
+ api_error,
857
+ normalized_args=normalized_args,
858
+ details={"tag_id": tag_id, "app_key": app_key},
859
+ suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
860
+ )
861
+ )
797
862
  already_attached = tag_id in tag_ids_before
798
863
  try:
799
864
  self._attach_app_to_package(
@@ -804,33 +869,72 @@ class AiBuilderFacade:
804
869
  )
805
870
  except (QingflowApiError, RuntimeError) as error:
806
871
  api_error = _coerce_api_error(error)
807
- return _failed_from_api_error(
808
- "PACKAGE_ATTACH_FAILED",
809
- api_error,
810
- details={"tag_id": tag_id, "app_key": app_key},
811
- suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
872
+ return finalize(
873
+ _failed_from_api_error(
874
+ "PACKAGE_ATTACH_FAILED",
875
+ api_error,
876
+ normalized_args=normalized_args,
877
+ details=_with_state_read_blocked_details(
878
+ {"tag_id": tag_id, "app_key": app_key},
879
+ resource="package",
880
+ error=api_error,
881
+ ),
882
+ suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
883
+ )
812
884
  )
813
- base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
814
- tag_ids_after = _coerce_int_list(base.get("tagIds"))
815
- attached = tag_id in tag_ids_after
816
- return {
885
+ verification_error: QingflowApiError | None = None
886
+ try:
887
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
888
+ tag_ids_after = _coerce_int_list(base.get("tagIds"))
889
+ attached = tag_id in tag_ids_after
890
+ except (QingflowApiError, RuntimeError) as error:
891
+ verification_error = _coerce_api_error(error)
892
+ tag_ids_after = []
893
+ attached = False
894
+ if _is_permission_restricted_api_error(verification_error):
895
+ permission_outcomes.append(
896
+ _verification_read_outcome(
897
+ resource="app",
898
+ target={"app_key": app_key},
899
+ transport_error=verification_error,
900
+ )
901
+ )
902
+ else:
903
+ return finalize(
904
+ _failed_from_api_error(
905
+ "PACKAGE_ATTACH_FAILED",
906
+ verification_error,
907
+ normalized_args=normalized_args,
908
+ details={"tag_id": tag_id, "app_key": app_key},
909
+ suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
910
+ )
911
+ )
912
+ response = {
817
913
  "status": "success" if attached else "partial_success",
818
- "error_code": None,
819
- "recoverable": False,
820
- "message": "attached app to package" if attached else "app attachment could not be verified",
821
- "normalized_args": {"tag_id": tag_id, "app_key": app_key, "app_title": app_title},
914
+ "error_code": None if attached else "PACKAGE_ATTACH_READBACK_PENDING",
915
+ "recoverable": verification_error is not None or not attached,
916
+ "message": "attached app to package" if attached else "app attachment could not be fully verified",
917
+ "normalized_args": normalized_args,
822
918
  "missing_fields": [],
823
919
  "allowed_values": {},
824
920
  "details": {},
825
- "request_id": None,
921
+ "request_id": verification_error.request_id if verification_error is not None else None,
826
922
  "suggested_next_call": None if attached else {"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
827
923
  "noop": already_attached,
828
- "verification": {"tag_ids_before": tag_ids_before, "tag_ids_after": tag_ids_after},
924
+ "verification": {
925
+ "tag_ids_before": tag_ids_before,
926
+ "tag_ids_after": tag_ids_after,
927
+ "attachment_verified": attached if verification_error is None else None,
928
+ "readback_unavailable": verification_error is not None,
929
+ },
829
930
  "app_key": app_key,
830
931
  "tag_id": tag_id,
831
932
  "tag_ids_after": tag_ids_after,
832
933
  "attached": attached,
833
934
  }
935
+ if verification_error is not None:
936
+ response["details"]["verification_error"] = _transport_error_payload(verification_error)
937
+ return finalize(response)
834
938
 
835
939
  def app_release_edit_lock_if_mine(
836
940
  self,
@@ -954,6 +1058,42 @@ class AiBuilderFacade:
954
1058
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
955
1059
  except (QingflowApiError, RuntimeError) as exc:
956
1060
  api_error = _coerce_api_error(exc)
1061
+ if _is_permission_restricted_api_error(api_error):
1062
+ return {
1063
+ "status": "success",
1064
+ "error_code": None,
1065
+ "recoverable": False,
1066
+ "message": "resolved app key; metadata unverified",
1067
+ "normalized_args": {"app_key": app_key},
1068
+ "missing_fields": [],
1069
+ "allowed_values": {},
1070
+ "details": {
1071
+ "match_scope": "app_key",
1072
+ "lookup_permission_blocked": {
1073
+ "scope": "app",
1074
+ "target": {"app_key": app_key},
1075
+ "required_permission": None,
1076
+ "transport_error": _transport_error_payload(api_error),
1077
+ },
1078
+ "permission_check_skipped": True,
1079
+ },
1080
+ "request_id": api_error.request_id,
1081
+ "suggested_next_call": {"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1082
+ "noop": False,
1083
+ "warnings": [
1084
+ _warning(
1085
+ "PERMISSION_CHECK_SKIPPED",
1086
+ "app metadata lookup was permission-restricted; continuing with explicit app_key",
1087
+ scope="app",
1088
+ app_key=app_key,
1089
+ )
1090
+ ],
1091
+ "verification": {"metadata_unverified": True},
1092
+ "app_key": app_key,
1093
+ "app_name": app_key,
1094
+ "tag_ids": [],
1095
+ "publish_status": None,
1096
+ }
957
1097
  return _failed_from_api_error(
958
1098
  "APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
959
1099
  api_error,
@@ -1012,7 +1152,20 @@ class AiBuilderFacade:
1012
1152
  details={"app_name": requested, "package_tag_id": package_tag_id, "matches": package_matches},
1013
1153
  suggested_next_call=None,
1014
1154
  )
1015
- search = self.apps.app_search(profile=profile, keyword=requested, page_num=1, page_size=200)
1155
+ search_error: QingflowApiError | None = None
1156
+ try:
1157
+ search = self.apps.app_search(profile=profile, keyword=requested, page_num=1, page_size=200)
1158
+ except (QingflowApiError, RuntimeError) as exc:
1159
+ api_error = _coerce_api_error(exc)
1160
+ if package_tag_id is None or package_tag_id <= 0 or api_error.backend_code not in {40002, 40027}:
1161
+ return _failed_from_api_error(
1162
+ "APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
1163
+ api_error,
1164
+ details={"app_name": requested, "package_tag_id": package_tag_id},
1165
+ suggested_next_call=None,
1166
+ )
1167
+ search = {}
1168
+ search_error = api_error
1016
1169
  apps = search.get("apps") if isinstance(search.get("apps"), list) else []
1017
1170
  matches = []
1018
1171
  for item in apps:
@@ -1046,11 +1199,72 @@ class AiBuilderFacade:
1046
1199
  "tag_ids": tag_ids,
1047
1200
  }
1048
1201
  )
1202
+ if not matches and package_tag_id is not None and package_tag_id > 0 and search_error is not None:
1203
+ visible_matches = self._resolve_app_matches_in_visible_apps(
1204
+ profile=profile,
1205
+ app_name=requested,
1206
+ package_tag_id=package_tag_id,
1207
+ )
1208
+ if len(visible_matches) == 1:
1209
+ match = visible_matches[0]
1210
+ return {
1211
+ "status": "success",
1212
+ "error_code": None,
1213
+ "recoverable": False,
1214
+ "message": "resolved app",
1215
+ "normalized_args": {"app_name": requested, "package_tag_id": package_tag_id},
1216
+ "missing_fields": [],
1217
+ "allowed_values": {},
1218
+ "details": {
1219
+ "match_scope": "visible_apps",
1220
+ "search_permission_blocked": {
1221
+ "backend_code": search_error.backend_code,
1222
+ "http_status": search_error.http_status,
1223
+ "request_id": search_error.request_id,
1224
+ },
1225
+ },
1226
+ "request_id": None,
1227
+ "suggested_next_call": None,
1228
+ "noop": False,
1229
+ "verification": {},
1230
+ **match,
1231
+ }
1232
+ if len(visible_matches) > 1:
1233
+ return _failed(
1234
+ "AMBIGUOUS_APP",
1235
+ f"multiple apps matched '{requested}' inside package {package_tag_id}",
1236
+ details={
1237
+ "app_name": requested,
1238
+ "package_tag_id": package_tag_id,
1239
+ "matches": visible_matches,
1240
+ "search_permission_blocked": {
1241
+ "backend_code": search_error.backend_code,
1242
+ "http_status": search_error.http_status,
1243
+ "request_id": search_error.request_id,
1244
+ },
1245
+ },
1246
+ suggested_next_call=None,
1247
+ )
1049
1248
  if not matches:
1050
1249
  return _failed(
1051
1250
  "APP_NOT_FOUND",
1052
1251
  f"app '{requested}' was not found",
1053
- details={"app_name": requested, "package_tag_id": package_tag_id},
1252
+ details={
1253
+ "app_name": requested,
1254
+ "package_tag_id": package_tag_id,
1255
+ **(
1256
+ {
1257
+ "search_permission_blocked": {
1258
+ "backend_code": search_error.backend_code,
1259
+ "http_status": search_error.http_status,
1260
+ "request_id": search_error.request_id,
1261
+ },
1262
+ "match_scope": "visible_apps_fallback",
1263
+ }
1264
+ if search_error is not None
1265
+ else {}
1266
+ ),
1267
+ },
1054
1268
  suggested_next_call=None,
1055
1269
  )
1056
1270
  if len(matches) > 1:
@@ -1077,6 +1291,39 @@ class AiBuilderFacade:
1077
1291
  **match,
1078
1292
  }
1079
1293
 
1294
+ def _resolve_app_matches_in_visible_apps(
1295
+ self,
1296
+ *,
1297
+ profile: str,
1298
+ app_name: str,
1299
+ package_tag_id: int,
1300
+ ) -> list[JSONObject]:
1301
+ try:
1302
+ listing = self.apps.app_list(profile=profile, ship_auth=False)
1303
+ except (QingflowApiError, RuntimeError):
1304
+ return []
1305
+ items = listing.get("items") if isinstance(listing.get("items"), list) else []
1306
+ matches: list[JSONObject] = []
1307
+ seen_app_keys: set[str] = set()
1308
+ for item in items:
1309
+ if not isinstance(item, dict):
1310
+ continue
1311
+ title = str(item.get("title") or item.get("app_name") or "").strip()
1312
+ if title != app_name:
1313
+ continue
1314
+ candidate_key = str(item.get("app_key") or item.get("appKey") or "").strip()
1315
+ if not candidate_key or candidate_key in seen_app_keys:
1316
+ continue
1317
+ tag_ids = _coerce_int_list(item.get("tag_ids"))
1318
+ tag_id = _coerce_positive_int(item.get("tag_id"))
1319
+ if tag_id is not None and tag_id not in tag_ids:
1320
+ tag_ids.append(tag_id)
1321
+ if package_tag_id not in tag_ids:
1322
+ continue
1323
+ seen_app_keys.add(candidate_key)
1324
+ matches.append({"app_key": candidate_key, "app_name": title, "tag_ids": tag_ids})
1325
+ return matches
1326
+
1080
1327
  def _resolve_app_matches_in_package(
1081
1328
  self,
1082
1329
  *,
@@ -1153,67 +1400,73 @@ class AiBuilderFacade:
1153
1400
  app_key: str,
1154
1401
  required_permission: str,
1155
1402
  normalized_args: JSONObject,
1156
- ) -> JSONObject | None:
1403
+ ) -> PermissionCheckOutcome:
1157
1404
  try:
1158
1405
  permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
1159
1406
  except (QingflowApiError, RuntimeError) as error:
1160
1407
  api_error = _coerce_api_error(error)
1161
- return _failed(
1162
- "APP_PERMISSION_UNVERIFIED",
1163
- "could not confirm current user's builder permissions for this app",
1164
- normalized_args=normalized_args,
1165
- details={
1166
- "app_key": app_key,
1167
- "required_permission": required_permission,
1168
- "permission_read_error": {
1169
- "message": api_error.message,
1170
- "http_status": api_error.http_status,
1171
- "backend_code": api_error.backend_code,
1172
- "category": api_error.category,
1408
+ if _is_permission_restricted_api_error(api_error):
1409
+ return _permission_skip_outcome(
1410
+ scope="app",
1411
+ target={"app_key": app_key},
1412
+ required_permission=required_permission,
1413
+ transport_error=_transport_error_payload(api_error),
1414
+ )
1415
+ return PermissionCheckOutcome(
1416
+ block=_failed(
1417
+ "APP_PERMISSION_UNVERIFIED",
1418
+ "could not confirm current user's builder permissions for this app",
1419
+ normalized_args=normalized_args,
1420
+ details={
1421
+ "app_key": app_key,
1422
+ "required_permission": required_permission,
1423
+ "permission_read_error": {
1424
+ "message": api_error.message,
1425
+ "http_status": api_error.http_status,
1426
+ "backend_code": api_error.backend_code,
1427
+ "category": api_error.category,
1428
+ },
1173
1429
  },
1174
- },
1175
- suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1176
- request_id=api_error.request_id,
1177
- backend_code=api_error.backend_code,
1178
- http_status=None if api_error.http_status == 404 else api_error.http_status,
1430
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1431
+ request_id=api_error.request_id,
1432
+ backend_code=api_error.backend_code,
1433
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
1434
+ )
1179
1435
  )
1180
1436
  permission_key = {
1181
1437
  "edit_app": "can_edit_app",
1182
1438
  "data_manage": "can_manage_data",
1183
1439
  }.get(required_permission)
1184
1440
  if permission_key is None:
1185
- return None
1441
+ return PermissionCheckOutcome()
1186
1442
  permission_value = permission_summary.get(permission_key)
1187
1443
  if permission_value is None:
1188
- return _failed(
1189
- "APP_PERMISSION_UNVERIFIED",
1190
- "could not confirm current user's builder permissions for this app",
1191
- normalized_args=normalized_args,
1192
- details={
1193
- "app_key": app_key,
1194
- "required_permission": required_permission,
1195
- "permission_summary": permission_summary,
1196
- },
1197
- suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1444
+ return _permission_skip_outcome(
1445
+ scope="app",
1446
+ target={"app_key": app_key},
1447
+ required_permission=required_permission,
1448
+ permission_summary=permission_summary,
1198
1449
  )
1199
1450
  if permission_value is not False:
1200
- return None
1451
+ return PermissionCheckOutcome()
1201
1452
  error_code = "EDIT_APP_UNAUTHORIZED" if required_permission == "edit_app" else "DATA_MANAGE_UNAUTHORIZED"
1202
1453
  message = (
1203
1454
  "current user does not have builder edit-app permission on this app"
1204
1455
  if required_permission == "edit_app"
1205
1456
  else "current user does not have data-management permission on this app"
1206
1457
  )
1207
- return _failed(
1208
- error_code,
1209
- message,
1210
- normalized_args=normalized_args,
1211
- details={
1212
- "app_key": app_key,
1213
- "required_permission": required_permission,
1214
- "permission_summary": permission_summary,
1215
- },
1216
- suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1458
+ return PermissionCheckOutcome(
1459
+ block=_failed(
1460
+ error_code,
1461
+ message,
1462
+ normalized_args=normalized_args,
1463
+ details={
1464
+ "app_key": app_key,
1465
+ "required_permission": required_permission,
1466
+ "permission_summary": permission_summary,
1467
+ },
1468
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1469
+ )
1217
1470
  )
1218
1471
 
1219
1472
  def _guard_package_permission(
@@ -1223,29 +1476,38 @@ class AiBuilderFacade:
1223
1476
  tag_id: int,
1224
1477
  required_permission: str,
1225
1478
  normalized_args: JSONObject,
1226
- ) -> JSONObject | None:
1479
+ ) -> PermissionCheckOutcome:
1227
1480
  try:
1228
1481
  permission_summary = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
1229
1482
  except (QingflowApiError, RuntimeError) as error:
1230
1483
  api_error = _coerce_api_error(error)
1231
- return _failed(
1232
- "PACKAGE_PERMISSION_UNVERIFIED",
1233
- "could not confirm current user's builder permissions for this package",
1234
- normalized_args=normalized_args,
1235
- details={
1236
- "tag_id": tag_id,
1237
- "required_permission": required_permission,
1238
- "permission_read_error": {
1239
- "message": api_error.message,
1240
- "http_status": api_error.http_status,
1241
- "backend_code": api_error.backend_code,
1242
- "category": api_error.category,
1484
+ if _is_permission_restricted_api_error(api_error):
1485
+ return _permission_skip_outcome(
1486
+ scope="package",
1487
+ target={"tag_id": tag_id},
1488
+ required_permission=required_permission,
1489
+ transport_error=_transport_error_payload(api_error),
1490
+ )
1491
+ return PermissionCheckOutcome(
1492
+ block=_failed(
1493
+ "PACKAGE_PERMISSION_UNVERIFIED",
1494
+ "could not confirm current user's builder permissions for this package",
1495
+ normalized_args=normalized_args,
1496
+ details={
1497
+ "tag_id": tag_id,
1498
+ "required_permission": required_permission,
1499
+ "permission_read_error": {
1500
+ "message": api_error.message,
1501
+ "http_status": api_error.http_status,
1502
+ "backend_code": api_error.backend_code,
1503
+ "category": api_error.category,
1504
+ },
1243
1505
  },
1244
- },
1245
- suggested_next_call={"tool_name": "package_get_base", "arguments": {"profile": profile, "tag_id": tag_id}},
1246
- request_id=api_error.request_id,
1247
- backend_code=api_error.backend_code,
1248
- http_status=None if api_error.http_status == 404 else api_error.http_status,
1506
+ suggested_next_call={"tool_name": "package_get_base", "arguments": {"profile": profile, "tag_id": tag_id}},
1507
+ request_id=api_error.request_id,
1508
+ backend_code=api_error.backend_code,
1509
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
1510
+ )
1249
1511
  )
1250
1512
  permission_specs = {
1251
1513
  "add_app": (
@@ -1266,12 +1528,21 @@ class AiBuilderFacade:
1266
1528
  }
1267
1529
  permission_key, error_code, message = permission_specs.get(required_permission, (None, None, None))
1268
1530
  if permission_key is None:
1269
- return None
1531
+ return PermissionCheckOutcome()
1270
1532
  permission_value = permission_summary.get(permission_key)
1271
1533
  if permission_value is None:
1272
- return _failed(
1273
- "PACKAGE_PERMISSION_UNVERIFIED",
1274
- "could not confirm current user's builder permissions for this package",
1534
+ return _permission_skip_outcome(
1535
+ scope="package",
1536
+ target={"tag_id": tag_id},
1537
+ required_permission=required_permission,
1538
+ permission_summary=permission_summary,
1539
+ )
1540
+ if permission_value is not False:
1541
+ return PermissionCheckOutcome()
1542
+ return PermissionCheckOutcome(
1543
+ block=_failed(
1544
+ error_code or "PACKAGE_PERMISSION_UNVERIFIED",
1545
+ message or "current user does not have the required package permission",
1275
1546
  normalized_args=normalized_args,
1276
1547
  details={
1277
1548
  "tag_id": tag_id,
@@ -1280,18 +1551,6 @@ class AiBuilderFacade:
1280
1551
  },
1281
1552
  suggested_next_call={"tool_name": "package_get_base", "arguments": {"profile": profile, "tag_id": tag_id}},
1282
1553
  )
1283
- if permission_value is not False:
1284
- return None
1285
- return _failed(
1286
- error_code or "PACKAGE_PERMISSION_UNVERIFIED",
1287
- message or "current user does not have the required package permission",
1288
- normalized_args=normalized_args,
1289
- details={
1290
- "tag_id": tag_id,
1291
- "required_permission": required_permission,
1292
- "permission_summary": permission_summary,
1293
- },
1294
- suggested_next_call={"tool_name": "package_get_base", "arguments": {"profile": profile, "tag_id": tag_id}},
1295
1554
  )
1296
1555
 
1297
1556
  def _guard_portal_permission(
@@ -1301,25 +1560,26 @@ class AiBuilderFacade:
1301
1560
  dash_key: str,
1302
1561
  normalized_args: JSONObject,
1303
1562
  portal_result: dict[str, Any],
1304
- ) -> JSONObject | None:
1563
+ ) -> PermissionCheckOutcome:
1305
1564
  permission_summary = self._read_portal_permission_summary(dash_key=dash_key, portal_result=portal_result)
1306
1565
  permission_value = permission_summary.get("can_edit_portal")
1307
1566
  if permission_value is None:
1308
- return _failed(
1309
- "PORTAL_PERMISSION_UNVERIFIED",
1310
- "could not confirm current user's builder permissions for this portal",
1567
+ return _permission_skip_outcome(
1568
+ scope="portal",
1569
+ target={"dash_key": dash_key},
1570
+ required_permission="edit_portal",
1571
+ permission_summary=permission_summary,
1572
+ )
1573
+ if permission_value is not False:
1574
+ return PermissionCheckOutcome()
1575
+ return PermissionCheckOutcome(
1576
+ block=_failed(
1577
+ "PORTAL_EDIT_UNAUTHORIZED",
1578
+ "current user does not have builder edit permission on this portal",
1311
1579
  normalized_args=normalized_args,
1312
1580
  details={"dash_key": dash_key, "permission_summary": permission_summary},
1313
1581
  suggested_next_call={"tool_name": "portal_read_summary", "arguments": {"profile": profile, "dash_key": dash_key}},
1314
1582
  )
1315
- if permission_value is not False:
1316
- return None
1317
- return _failed(
1318
- "PORTAL_EDIT_UNAUTHORIZED",
1319
- "current user does not have builder edit permission on this portal",
1320
- normalized_args=normalized_args,
1321
- details={"dash_key": dash_key, "permission_summary": permission_summary},
1322
- suggested_next_call={"tool_name": "portal_read_summary", "arguments": {"profile": profile, "dash_key": dash_key}},
1323
1583
  )
1324
1584
 
1325
1585
  def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
@@ -2094,6 +2354,7 @@ class AiBuilderFacade:
2094
2354
  "update_fields": [patch.model_dump(mode="json") for patch in update_fields],
2095
2355
  "remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
2096
2356
  }
2357
+ permission_outcomes: list[PermissionCheckOutcome] = []
2097
2358
  requested_field_changes = bool(add_fields or update_fields or remove_fields)
2098
2359
  resolved: JSONObject
2099
2360
  if app_key:
@@ -2102,31 +2363,37 @@ class AiBuilderFacade:
2102
2363
  resolved = self.app_resolve(profile=profile, app_name=app_name, package_tag_id=package_tag_id)
2103
2364
  else:
2104
2365
  return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", normalized_args=normalized_args, suggested_next_call=None)
2366
+
2367
+ def finalize(response: JSONObject) -> JSONObject:
2368
+ return _apply_permission_outcomes(response, *permission_outcomes)
2369
+
2105
2370
  if resolved.get("status") == "failed":
2106
2371
  if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
2107
2372
  resolved["normalized_args"] = normalized_args
2108
2373
  if not create_if_missing or app_key or resolved.get("error_code") != "APP_NOT_FOUND":
2109
- return resolved
2374
+ return finalize(resolved)
2110
2375
  permission_tag_id = _coerce_positive_int(package_tag_id)
2111
2376
  if permission_tag_id is None:
2112
2377
  permission_tag_id = 0
2113
- add_permission_block = self._guard_package_permission(
2378
+ add_permission_outcome = self._guard_package_permission(
2114
2379
  profile=profile,
2115
2380
  tag_id=permission_tag_id,
2116
2381
  required_permission="add_app",
2117
2382
  normalized_args=normalized_args,
2118
2383
  )
2119
- if add_permission_block is not None:
2120
- return add_permission_block
2384
+ if add_permission_outcome.block is not None:
2385
+ return add_permission_outcome.block
2386
+ permission_outcomes.append(add_permission_outcome)
2121
2387
  if requested_field_changes:
2122
- edit_permission_block = self._guard_package_permission(
2388
+ edit_permission_outcome = self._guard_package_permission(
2123
2389
  profile=profile,
2124
2390
  tag_id=permission_tag_id,
2125
2391
  required_permission="edit_app",
2126
2392
  normalized_args=normalized_args,
2127
2393
  )
2128
- if edit_permission_block is not None:
2129
- return edit_permission_block
2394
+ if edit_permission_outcome.block is not None:
2395
+ return edit_permission_outcome.block
2396
+ permission_outcomes.append(edit_permission_outcome)
2130
2397
  resolved = self._create_target_app_shell(
2131
2398
  profile=profile,
2132
2399
  app_name=app_name,
@@ -2135,23 +2402,27 @@ class AiBuilderFacade:
2135
2402
  if resolved.get("status") == "failed":
2136
2403
  if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
2137
2404
  resolved["normalized_args"] = normalized_args
2138
- return resolved
2405
+ return finalize(resolved)
2406
+ resolved_outcome = _permission_outcome_from_result(resolved)
2407
+ if resolved_outcome is not None:
2408
+ permission_outcomes.append(resolved_outcome)
2139
2409
  target = ResolvedApp(
2140
2410
  app_key=str(resolved["app_key"]),
2141
2411
  app_name=str(resolved["app_name"]),
2142
2412
  tag_ids=_coerce_int_list(resolved.get("tag_ids")),
2143
2413
  )
2144
2414
  if not bool(resolved.get("created")):
2145
- permission_block = self._guard_app_permission(
2415
+ permission_outcome = self._guard_app_permission(
2146
2416
  profile=profile,
2147
2417
  app_key=target.app_key,
2148
2418
  required_permission="edit_app",
2149
2419
  normalized_args=normalized_args,
2150
2420
  )
2151
- if permission_block is not None:
2152
- return permission_block
2421
+ if permission_outcome.block is not None:
2422
+ return permission_outcome.block
2423
+ permission_outcomes.append(permission_outcome)
2153
2424
  if bool(resolved.get("created")) and not requested_field_changes:
2154
- return {
2425
+ return finalize({
2155
2426
  "status": "success",
2156
2427
  "error_code": None,
2157
2428
  "recoverable": False,
@@ -2178,21 +2449,21 @@ class AiBuilderFacade:
2178
2449
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
2179
2450
  "publish_requested": False,
2180
2451
  "published": False,
2181
- }
2452
+ })
2182
2453
  schema_readback_delayed = False
2183
2454
  try:
2184
2455
  schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
2185
2456
  except (QingflowApiError, RuntimeError) as error:
2186
2457
  api_error = _coerce_api_error(error)
2187
2458
  if not bool(resolved.get("created")) or api_error.http_status != 404:
2188
- return _failed_from_api_error(
2459
+ return finalize(_failed_from_api_error(
2189
2460
  "SCHEMA_READBACK_FAILED",
2190
2461
  api_error,
2191
2462
  normalized_args=normalized_args,
2192
2463
  allowed_values={"field_types": [item.value for item in PublicFieldType]},
2193
- details={"app_key": target.app_key},
2464
+ details=_with_state_read_blocked_details({"app_key": target.app_key}, resource="schema", error=api_error),
2194
2465
  suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": target.app_key}},
2195
- )
2466
+ ))
2196
2467
  schema_result = _empty_schema_result(target.app_name)
2197
2468
  _schema_source = "synthetic_new_app"
2198
2469
  schema_readback_delayed = True
@@ -2310,7 +2581,7 @@ class AiBuilderFacade:
2310
2581
  "package_attached": package_attached,
2311
2582
  }
2312
2583
  response["details"]["relation_field_count"] = relation_field_count
2313
- return self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
2584
+ return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
2314
2585
 
2315
2586
  payload = _build_form_payload_from_fields(
2316
2587
  title=schema_result.get("formTitle") or target.app_name,
@@ -2449,7 +2720,7 @@ class AiBuilderFacade:
2449
2720
  "http_status": verification_error.http_status,
2450
2721
  "backend_code": verification_error.backend_code,
2451
2722
  }
2452
- return response
2723
+ return finalize(response)
2453
2724
 
2454
2725
  def app_layout_apply(
2455
2726
  self,
@@ -2467,25 +2738,31 @@ class AiBuilderFacade:
2467
2738
  "sections": requested_sections,
2468
2739
  "publish": publish,
2469
2740
  }
2470
- permission_block = self._guard_app_permission(
2741
+ permission_outcomes: list[PermissionCheckOutcome] = []
2742
+ permission_outcome = self._guard_app_permission(
2471
2743
  profile=profile,
2472
2744
  app_key=app_key,
2473
2745
  required_permission="edit_app",
2474
2746
  normalized_args=normalized_args,
2475
2747
  )
2476
- if permission_block is not None:
2477
- return permission_block
2748
+ if permission_outcome.block is not None:
2749
+ return permission_outcome.block
2750
+ permission_outcomes.append(permission_outcome)
2751
+
2752
+ def finalize(response: JSONObject) -> JSONObject:
2753
+ return _apply_permission_outcomes(response, *permission_outcomes)
2754
+
2478
2755
  try:
2479
2756
  schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2480
2757
  except (QingflowApiError, RuntimeError) as error:
2481
2758
  api_error = _coerce_api_error(error)
2482
- return _failed_from_api_error(
2759
+ return finalize(_failed_from_api_error(
2483
2760
  "LAYOUT_READ_FAILED",
2484
2761
  api_error,
2485
2762
  normalized_args=normalized_args,
2486
- details={"app_key": app_key},
2763
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="schema", error=api_error),
2487
2764
  suggested_next_call={"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
2488
- )
2765
+ ))
2489
2766
  parsed = _parse_schema(schema_result)
2490
2767
  current_fields = parsed["fields"]
2491
2768
  requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
@@ -2577,7 +2854,7 @@ class AiBuilderFacade:
2577
2854
  },
2578
2855
  "verified": True,
2579
2856
  }
2580
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2857
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
2581
2858
  payload = _build_form_payload_from_existing_schema(
2582
2859
  current_schema=schema_result,
2583
2860
  layout=target_layout,
@@ -2610,7 +2887,7 @@ class AiBuilderFacade:
2610
2887
  fallback_applied = "flatten_sections"
2611
2888
  except (QingflowApiError, RuntimeError) as fallback_error:
2612
2889
  api_fallback_error = _coerce_api_error(fallback_error)
2613
- return _failed_from_api_error(
2890
+ return finalize(_failed_from_api_error(
2614
2891
  "LAYOUT_APPLY_FAILED",
2615
2892
  api_fallback_error,
2616
2893
  normalized_args=normalized_args,
@@ -2623,9 +2900,9 @@ class AiBuilderFacade:
2623
2900
  "fallback_layout": flattened_layout,
2624
2901
  },
2625
2902
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
2626
- )
2903
+ ))
2627
2904
  else:
2628
- return _failed_from_api_error(
2905
+ return finalize(_failed_from_api_error(
2629
2906
  "LAYOUT_APPLY_FAILED",
2630
2907
  api_error,
2631
2908
  normalized_args=normalized_args,
@@ -2636,7 +2913,7 @@ class AiBuilderFacade:
2636
2913
  "current_field_names": [field["name"] for field in current_fields],
2637
2914
  },
2638
2915
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
2639
- )
2916
+ ))
2640
2917
  try:
2641
2918
  verified_schema, _verified_schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2642
2919
  except (QingflowApiError, RuntimeError) as error:
@@ -2666,7 +2943,7 @@ class AiBuilderFacade:
2666
2943
  "verified": False,
2667
2944
  }
2668
2945
  response["request_id"] = api_error.request_id
2669
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2946
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
2670
2947
  verified_layout = _parse_schema(verified_schema)["layout"]
2671
2948
  layout_verified = _layouts_equal(verified_layout, applied_layout) or _layouts_semantically_equal(verified_layout, applied_layout)
2672
2949
  raw_layout_has_content = _schema_has_layout_content(verified_schema)
@@ -2716,7 +2993,7 @@ class AiBuilderFacade:
2716
2993
  },
2717
2994
  "verified": layout_verified,
2718
2995
  }
2719
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2996
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
2720
2997
 
2721
2998
  def app_flow_apply(
2722
2999
  self,
@@ -2735,34 +3012,40 @@ class AiBuilderFacade:
2735
3012
  "transitions": transitions,
2736
3013
  "publish": publish,
2737
3014
  }
2738
- permission_block = self._guard_app_permission(
3015
+ permission_outcomes: list[PermissionCheckOutcome] = []
3016
+ permission_outcome = self._guard_app_permission(
2739
3017
  profile=profile,
2740
3018
  app_key=app_key,
2741
3019
  required_permission="data_manage",
2742
3020
  normalized_args=normalized_args,
2743
3021
  )
2744
- if permission_block is not None:
2745
- return permission_block
3022
+ if permission_outcome.block is not None:
3023
+ return permission_outcome.block
3024
+ permission_outcomes.append(permission_outcome)
3025
+
3026
+ def finalize(response: JSONObject) -> JSONObject:
3027
+ return _apply_permission_outcomes(response, *permission_outcomes)
3028
+
2746
3029
  if mode != "replace":
2747
- return _failed(
3030
+ return finalize(_failed(
2748
3031
  "UNSUPPORTED_FLOW_MODE",
2749
3032
  "only mode='replace' is supported",
2750
3033
  normalized_args=normalized_args,
2751
3034
  allowed_values={"modes": ["replace"]},
2752
3035
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
2753
- )
3036
+ ))
2754
3037
  try:
2755
3038
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2756
3039
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2757
3040
  except (QingflowApiError, RuntimeError) as error:
2758
3041
  api_error = _coerce_api_error(error)
2759
- return _failed_from_api_error(
3042
+ return finalize(_failed_from_api_error(
2760
3043
  "FLOW_READ_FAILED",
2761
3044
  api_error,
2762
3045
  normalized_args=normalized_args,
2763
- details={"app_key": app_key},
3046
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
2764
3047
  suggested_next_call={"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}},
2765
- )
3048
+ ))
2766
3049
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
2767
3050
  current_fields = _parse_schema(schema)["fields"]
2768
3051
  normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
@@ -2874,7 +3157,7 @@ class AiBuilderFacade:
2874
3157
  suggested_next_call["tool_name"] = "app_flow_apply"
2875
3158
  suggested_next_call["arguments"] = arguments
2876
3159
  failed["suggested_next_call"] = suggested_next_call
2877
- return failed
3160
+ return finalize(failed)
2878
3161
  verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
2879
3162
  workflow_structure_verified = bool(verified_nodes) and _workflow_nodes_semantically_equal(
2880
3163
  current_workflow=verified_nodes,
@@ -2933,7 +3216,7 @@ class AiBuilderFacade:
2933
3216
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
2934
3217
  "verified": workflow_verified,
2935
3218
  }
2936
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3219
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
2937
3220
 
2938
3221
  def app_views_apply(
2939
3222
  self,
@@ -2970,27 +3253,33 @@ class AiBuilderFacade:
2970
3253
  "verified": True,
2971
3254
  }
2972
3255
  return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2973
- permission_block = self._guard_app_permission(
3256
+ permission_outcomes: list[PermissionCheckOutcome] = []
3257
+ permission_outcome = self._guard_app_permission(
2974
3258
  profile=profile,
2975
3259
  app_key=app_key,
2976
3260
  required_permission="data_manage",
2977
3261
  normalized_args=normalized_args,
2978
3262
  )
2979
- if permission_block is not None:
2980
- return permission_block
3263
+ if permission_outcome.block is not None:
3264
+ return permission_outcome.block
3265
+ permission_outcomes.append(permission_outcome)
3266
+
3267
+ def finalize(response: JSONObject) -> JSONObject:
3268
+ return _apply_permission_outcomes(response, *permission_outcomes)
3269
+
2981
3270
  try:
2982
3271
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2983
3272
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2984
3273
  existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
2985
3274
  except (QingflowApiError, RuntimeError) as error:
2986
3275
  api_error = _coerce_api_error(error)
2987
- return _failed_from_api_error(
3276
+ return finalize(_failed_from_api_error(
2988
3277
  "VIEWS_READ_FAILED",
2989
3278
  api_error,
2990
3279
  normalized_args=normalized_args,
2991
- details={"app_key": app_key},
3280
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
2992
3281
  suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2993
- )
3282
+ ))
2994
3283
  existing_views = existing_views or []
2995
3284
  existing_by_key: dict[str, dict[str, Any]] = {}
2996
3285
  existing_by_name: dict[str, list[dict[str, Any]]] = {}
@@ -3343,13 +3632,13 @@ class AiBuilderFacade:
3343
3632
  verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
3344
3633
  except (QingflowApiError, RuntimeError) as error:
3345
3634
  api_error = _coerce_api_error(error)
3346
- return _failed_from_api_error(
3635
+ return finalize(_failed_from_api_error(
3347
3636
  "VIEWS_READ_FAILED",
3348
3637
  api_error,
3349
3638
  normalized_args=normalized_args,
3350
- details={"app_key": app_key},
3639
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
3351
3640
  suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3352
- )
3641
+ ))
3353
3642
  verified_names = {
3354
3643
  _extract_view_name(item)
3355
3644
  for item in (verified_view_result or [])
@@ -3504,7 +3793,7 @@ class AiBuilderFacade:
3504
3793
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
3505
3794
  "verified": verified and view_filters_verified,
3506
3795
  }
3507
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3796
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3508
3797
  warnings: list[dict[str, Any]] = []
3509
3798
  if filter_readback_pending or filter_mismatches:
3510
3799
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
@@ -3538,7 +3827,7 @@ class AiBuilderFacade:
3538
3827
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
3539
3828
  "verified": verified and view_filters_verified,
3540
3829
  }
3541
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3830
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3542
3831
 
3543
3832
  def app_publish_verify(
3544
3833
  self,
@@ -3671,18 +3960,27 @@ class AiBuilderFacade:
3671
3960
 
3672
3961
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
3673
3962
  normalized_args = request.model_dump(mode="json")
3963
+ permission_outcomes: list[PermissionCheckOutcome] = []
3674
3964
  app_result = self.app_resolve(profile=profile, app_key=request.app_key)
3675
3965
  if app_result.get("status") != "success":
3676
3966
  return app_result
3967
+ resolved_outcome = _permission_outcome_from_result(app_result)
3968
+ if resolved_outcome is not None:
3969
+ permission_outcomes.append(resolved_outcome)
3677
3970
  app_key = str(app_result.get("app_key") or request.app_key)
3678
- permission_block = self._guard_app_permission(
3971
+ permission_outcome = self._guard_app_permission(
3679
3972
  profile=profile,
3680
3973
  app_key=app_key,
3681
3974
  required_permission="data_manage",
3682
3975
  normalized_args=normalized_args,
3683
3976
  )
3684
- if permission_block is not None:
3685
- return permission_block
3977
+ if permission_outcome.block is not None:
3978
+ return permission_outcome.block
3979
+ permission_outcomes.append(permission_outcome)
3980
+
3981
+ def finalize(response: JSONObject) -> JSONObject:
3982
+ return _apply_permission_outcomes(response, *permission_outcomes)
3983
+
3686
3984
  try:
3687
3985
  schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
3688
3986
  parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
@@ -3691,13 +3989,13 @@ class AiBuilderFacade:
3691
3989
  existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
3692
3990
  except (QingflowApiError, RuntimeError) as error:
3693
3991
  api_error = _coerce_api_error(error)
3694
- return _failed_from_api_error(
3992
+ return finalize(_failed_from_api_error(
3695
3993
  "CHART_APPLY_FAILED",
3696
3994
  api_error,
3697
3995
  normalized_args=normalized_args,
3698
- details={"app_key": app_key},
3996
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
3699
3997
  suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
3700
- )
3998
+ ))
3701
3999
 
3702
4000
  field_lookup = _build_public_field_lookup(fields)
3703
4001
  qingbi_fields_by_id = {
@@ -3937,7 +4235,7 @@ class AiBuilderFacade:
3937
4235
 
3938
4236
  if failed_items:
3939
4237
  successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
3940
- return {
4238
+ return finalize({
3941
4239
  "status": "partial_success" if successful_changes else "failed",
3942
4240
  "error_code": "CHART_APPLY_PARTIAL" if successful_changes else "CHART_APPLY_FAILED",
3943
4241
  "recoverable": True,
@@ -3965,9 +4263,9 @@ class AiBuilderFacade:
3965
4263
  "app_key": app_key,
3966
4264
  "chart_results": chart_results,
3967
4265
  "verified": False if failed_items else verified,
3968
- }
4266
+ })
3969
4267
  result_verified = verified or noop
3970
- return {
4268
+ return finalize({
3971
4269
  "status": "success" if result_verified else "partial_success",
3972
4270
  "error_code": None if result_verified else "CHART_READBACK_PENDING",
3973
4271
  "recoverable": not result_verified,
@@ -3993,10 +4291,11 @@ class AiBuilderFacade:
3993
4291
  "app_key": app_key,
3994
4292
  "chart_results": chart_results,
3995
4293
  "verified": result_verified,
3996
- }
4294
+ })
3997
4295
 
3998
4296
  def portal_apply(self, *, profile: str, request: PortalApplyRequest) -> JSONObject:
3999
4297
  normalized_args = request.model_dump(mode="json")
4298
+ permission_outcomes: list[PermissionCheckOutcome] = []
4000
4299
  dash_key = str(request.dash_key or "").strip()
4001
4300
  creating = not dash_key
4002
4301
  verify_dash_name = creating or request.dash_name is not None
@@ -4009,24 +4308,31 @@ class AiBuilderFacade:
4009
4308
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") if dash_key else {}
4010
4309
  except (QingflowApiError, RuntimeError) as error:
4011
4310
  api_error = _coerce_api_error(error)
4012
- return _failed_from_api_error(
4311
+ return _failed(
4013
4312
  "PORTAL_APPLY_FAILED",
4014
- api_error,
4313
+ _public_error_message("PORTAL_APPLY_FAILED", api_error),
4015
4314
  normalized_args=normalized_args,
4016
- details={"dash_key": dash_key or None},
4315
+ details=_with_state_read_blocked_details({"dash_key": dash_key or None}, resource="portal", error=api_error),
4017
4316
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4317
+ request_id=api_error.request_id,
4318
+ backend_code=api_error.backend_code,
4319
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
4018
4320
  )
4019
4321
  if not isinstance(base_payload, dict):
4020
4322
  base_payload = {}
4021
4323
  if not creating:
4022
- portal_permission_block = self._guard_portal_permission(
4324
+ portal_permission_outcome = self._guard_portal_permission(
4023
4325
  profile=profile,
4024
4326
  dash_key=dash_key,
4025
4327
  normalized_args=normalized_args,
4026
4328
  portal_result=base_payload,
4027
4329
  )
4028
- if portal_permission_block is not None:
4029
- return portal_permission_block
4330
+ if portal_permission_outcome.block is not None:
4331
+ return portal_permission_outcome.block
4332
+ permission_outcomes.append(portal_permission_outcome)
4333
+
4334
+ def finalize(response: JSONObject) -> JSONObject:
4335
+ return _apply_permission_outcomes(response, *permission_outcomes)
4030
4336
  target_package_tag_id = request.package_tag_id
4031
4337
  if target_package_tag_id is None:
4032
4338
  target_package_tag_id = _coerce_positive_int(((base_payload.get("tags") or [{}])[0] if isinstance(base_payload.get("tags"), list) and base_payload.get("tags") else {}).get("tagId"))
@@ -4038,22 +4344,24 @@ class AiBuilderFacade:
4038
4344
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4039
4345
  )
4040
4346
  if creating:
4041
- package_add_block = self._guard_package_permission(
4347
+ package_add_outcome = self._guard_package_permission(
4042
4348
  profile=profile,
4043
4349
  tag_id=target_package_tag_id,
4044
4350
  required_permission="add_app",
4045
4351
  normalized_args=normalized_args,
4046
4352
  )
4047
- if package_add_block is not None:
4048
- return package_add_block
4049
- package_edit_block = self._guard_package_permission(
4353
+ if package_add_outcome.block is not None:
4354
+ return package_add_outcome.block
4355
+ permission_outcomes.append(package_add_outcome)
4356
+ package_edit_outcome = self._guard_package_permission(
4050
4357
  profile=profile,
4051
4358
  tag_id=target_package_tag_id,
4052
4359
  required_permission="edit_app",
4053
4360
  normalized_args=normalized_args,
4054
4361
  )
4055
- if package_edit_block is not None:
4056
- return package_edit_block
4362
+ if package_edit_outcome.block is not None:
4363
+ return package_edit_outcome.block
4364
+ permission_outcomes.append(package_edit_outcome)
4057
4365
  try:
4058
4366
  if creating:
4059
4367
  create_payload = _build_public_portal_base_payload(
@@ -4110,7 +4418,7 @@ class AiBuilderFacade:
4110
4418
  "PORTAL_APPLY_FAILED",
4111
4419
  _public_error_message("PORTAL_APPLY_FAILED", api_error) if api_error else str(error),
4112
4420
  normalized_args=normalized_args,
4113
- details={"dash_key": dash_key or None},
4421
+ details=_with_state_read_blocked_details({"dash_key": dash_key or None}, resource="portal", error=api_error) if api_error is not None else {"dash_key": dash_key or None},
4114
4422
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4115
4423
  request_id=api_error.request_id if api_error else None,
4116
4424
  backend_code=api_error.backend_code if api_error else None,
@@ -4184,7 +4492,7 @@ class AiBuilderFacade:
4184
4492
  live_meta_verified=live_meta_verified,
4185
4493
  publish_requested=request.publish,
4186
4494
  )
4187
- return {
4495
+ return finalize({
4188
4496
  "status": status,
4189
4497
  "error_code": error_code,
4190
4498
  "recoverable": not verified,
@@ -4215,7 +4523,7 @@ class AiBuilderFacade:
4215
4523
  "verified": verified,
4216
4524
  "draft_result": draft_result,
4217
4525
  "live_result": live_result,
4218
- }
4526
+ })
4219
4527
 
4220
4528
  def _publish_current_edit_version(self, *, profile: str, app_key: str) -> JSONObject:
4221
4529
  normalized_args = {"app_key": app_key}
@@ -4780,6 +5088,177 @@ def _failed_from_api_error(
4780
5088
  )
4781
5089
 
4782
5090
 
5091
+ def _transport_error_payload(error: QingflowApiError) -> JSONObject:
5092
+ return {
5093
+ "http_status": error.http_status,
5094
+ "backend_code": error.backend_code,
5095
+ "category": error.category,
5096
+ "request_id": error.request_id,
5097
+ }
5098
+
5099
+
5100
+ def _is_permission_restricted_api_error(error: QingflowApiError) -> bool:
5101
+ return error.backend_code in {40002, 40027}
5102
+
5103
+
5104
+ def _append_response_detail(details: JSONObject, *, key: str, value: Any) -> None:
5105
+ if value is None:
5106
+ return
5107
+ copied_value = deepcopy(value)
5108
+ existing = details.get(key)
5109
+ if existing is None:
5110
+ details[key] = copied_value
5111
+ return
5112
+ if isinstance(existing, list):
5113
+ existing.append(copied_value)
5114
+ return
5115
+ details[key] = [existing, copied_value]
5116
+
5117
+
5118
+ def _permission_skip_outcome(
5119
+ *,
5120
+ scope: str,
5121
+ target: JSONObject,
5122
+ required_permission: str | None,
5123
+ transport_error: JSONObject | None = None,
5124
+ permission_summary: JSONObject | None = None,
5125
+ ) -> PermissionCheckOutcome:
5126
+ lookup_payload: JSONObject = {
5127
+ "scope": scope,
5128
+ "target": deepcopy(target),
5129
+ "required_permission": required_permission,
5130
+ }
5131
+ if transport_error:
5132
+ lookup_payload["transport_error"] = deepcopy(transport_error)
5133
+ if permission_summary:
5134
+ lookup_payload["permission_summary"] = deepcopy(permission_summary)
5135
+ warning_message = (
5136
+ "builder permission lookup was permission-restricted; continuing with downstream operations"
5137
+ if transport_error
5138
+ else "builder permission summary was incomplete; continuing with downstream operations"
5139
+ )
5140
+ return PermissionCheckOutcome(
5141
+ warnings=[
5142
+ _warning(
5143
+ "PERMISSION_CHECK_SKIPPED",
5144
+ warning_message,
5145
+ scope=scope,
5146
+ required_permission=required_permission,
5147
+ )
5148
+ ],
5149
+ details={
5150
+ "lookup_permission_blocked": lookup_payload,
5151
+ "permission_check_skipped": True,
5152
+ },
5153
+ verification={"metadata_unverified": True},
5154
+ )
5155
+
5156
+
5157
+ def _verification_read_outcome(
5158
+ *,
5159
+ resource: str,
5160
+ target: JSONObject,
5161
+ transport_error: QingflowApiError,
5162
+ ) -> PermissionCheckOutcome:
5163
+ return PermissionCheckOutcome(
5164
+ warnings=[
5165
+ _warning(
5166
+ "VERIFICATION_READ_UNAVAILABLE",
5167
+ "post-write verification readback was permission-restricted",
5168
+ resource=resource,
5169
+ )
5170
+ ],
5171
+ details={
5172
+ "lookup_permission_blocked": {
5173
+ "scope": resource,
5174
+ "target": deepcopy(target),
5175
+ "phase": "verification",
5176
+ "transport_error": _transport_error_payload(transport_error),
5177
+ },
5178
+ "permission_check_skipped": True,
5179
+ },
5180
+ verification={"metadata_unverified": True},
5181
+ )
5182
+
5183
+
5184
+ def _permission_outcome_from_result(result: JSONObject) -> PermissionCheckOutcome | None:
5185
+ details = result.get("details") if isinstance(result.get("details"), dict) else {}
5186
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
5187
+ warnings = result.get("warnings") if isinstance(result.get("warnings"), list) else []
5188
+ filtered_details: JSONObject = {}
5189
+ if details.get("lookup_permission_blocked") is not None:
5190
+ filtered_details["lookup_permission_blocked"] = deepcopy(details["lookup_permission_blocked"])
5191
+ if details.get("permission_check_skipped") is not None:
5192
+ filtered_details["permission_check_skipped"] = bool(details.get("permission_check_skipped"))
5193
+ filtered_verification: JSONObject = {}
5194
+ if verification.get("metadata_unverified") is not None:
5195
+ filtered_verification["metadata_unverified"] = bool(verification.get("metadata_unverified"))
5196
+ filtered_warnings = [
5197
+ deepcopy(item)
5198
+ for item in warnings
5199
+ if isinstance(item, dict) and str(item.get("code") or "") in {"PERMISSION_CHECK_SKIPPED", "VERIFICATION_READ_UNAVAILABLE"}
5200
+ ]
5201
+ if not filtered_details and not filtered_verification and not filtered_warnings:
5202
+ return None
5203
+ return PermissionCheckOutcome(
5204
+ warnings=filtered_warnings,
5205
+ details=filtered_details,
5206
+ verification=filtered_verification,
5207
+ )
5208
+
5209
+
5210
+ def _apply_permission_outcomes(response: JSONObject, *outcomes: PermissionCheckOutcome) -> JSONObject:
5211
+ if not isinstance(response, dict):
5212
+ return response
5213
+ details = response.get("details")
5214
+ if not isinstance(details, dict):
5215
+ details = {}
5216
+ response["details"] = details
5217
+ verification = response.get("verification")
5218
+ if not isinstance(verification, dict):
5219
+ verification = {}
5220
+ response["verification"] = verification
5221
+ warnings = response.get("warnings")
5222
+ if not isinstance(warnings, list):
5223
+ warnings = []
5224
+ response["warnings"] = warnings
5225
+ for outcome in outcomes:
5226
+ if not isinstance(outcome, PermissionCheckOutcome):
5227
+ continue
5228
+ for key, value in outcome.details.items():
5229
+ if key in {"lookup_permission_blocked", "state_read_blocked"}:
5230
+ _append_response_detail(details, key=key, value=value)
5231
+ elif key == "permission_check_skipped":
5232
+ details[key] = bool(details.get(key)) or bool(value)
5233
+ elif key not in details:
5234
+ details[key] = deepcopy(value)
5235
+ for key, value in outcome.verification.items():
5236
+ verification[key] = deepcopy(value)
5237
+ for warning in outcome.warnings:
5238
+ if warning not in warnings:
5239
+ warnings.append(deepcopy(warning))
5240
+ return response
5241
+
5242
+
5243
+ def _with_state_read_blocked_details(
5244
+ details: JSONObject | None,
5245
+ *,
5246
+ resource: str,
5247
+ error: QingflowApiError,
5248
+ ) -> JSONObject:
5249
+ merged = deepcopy(details) if isinstance(details, dict) else {}
5250
+ if _is_permission_restricted_api_error(error):
5251
+ _append_response_detail(
5252
+ merged,
5253
+ key="state_read_blocked",
5254
+ value={
5255
+ "resource": resource,
5256
+ "transport_error": _transport_error_payload(error),
5257
+ },
5258
+ )
5259
+ return merged
5260
+
5261
+
4783
5262
  def _from_stage_failure(stage: JSONObject, *, fallback_tool: str) -> JSONObject:
4784
5263
  return {
4785
5264
  "status": "failed",