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