@josephyan/qingflow-cli 0.2.0-beta.81 → 0.2.0-beta.83

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.
@@ -406,30 +406,55 @@ class AiBuilderFacade:
406
406
  "visibility": _public_visibility_from_member_auth(desired_auth),
407
407
  }
408
408
 
409
- def package_get(self, *, profile: str, tag_id: int) -> JSONObject:
410
- normalized_args = {"tag_id": tag_id}
411
- if _coerce_positive_int(tag_id) is None:
412
- return _failed("TAG_ID_REQUIRED", "tag_id must be positive", normalized_args=normalized_args, suggested_next_call=None)
409
+ def package_get(self, *, profile: str, package_id: int | None = None, tag_id: int | None = None) -> JSONObject:
410
+ effective_package_id = _coerce_positive_int(package_id if package_id is not None else tag_id)
411
+ normalized_args = {"package_id": effective_package_id or package_id or tag_id}
412
+ if effective_package_id is None:
413
+ return _failed(
414
+ "PACKAGE_ID_REQUIRED",
415
+ "package_id must be positive",
416
+ normalized_args=normalized_args,
417
+ suggested_next_call=None,
418
+ )
419
+ base_result: JSONObject
413
420
  try:
414
- detail_result = self.packages.package_get(profile=profile, tag_id=tag_id, include_raw=True)
415
- base_result = self.packages.package_get_base(profile=profile, tag_id=tag_id, include_raw=True)
421
+ base_result = self.packages.package_get_base(profile=profile, tag_id=effective_package_id, include_raw=True)
416
422
  except (QingflowApiError, RuntimeError) as error:
417
423
  api_error = _coerce_api_error(error)
418
424
  return _failed_from_api_error(
419
425
  "PACKAGE_GET_FAILED",
420
426
  api_error,
421
427
  normalized_args=normalized_args,
422
- details={"tag_id": tag_id},
423
- suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "tag_id": tag_id}},
428
+ details={"package_id": effective_package_id},
429
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": effective_package_id}},
424
430
  )
425
- detail = detail_result.get("result") if isinstance(detail_result.get("result"), dict) else {}
431
+
432
+ detail_result: JSONObject | None = None
433
+ detail_read_error: QingflowApiError | None = None
434
+ try:
435
+ detail_result = self.packages.package_get(profile=profile, tag_id=effective_package_id, include_raw=True)
436
+ except (QingflowApiError, RuntimeError) as error:
437
+ detail_read_error = _coerce_api_error(error)
438
+
439
+ detail = detail_result.get("result") if isinstance(detail_result, dict) and isinstance(detail_result.get("result"), dict) else {}
426
440
  base = base_result.get("result") if isinstance(base_result.get("result"), dict) else {}
427
- summary = detail_result.get("summary") if isinstance(detail_result.get("summary"), dict) else {}
441
+ summary = detail_result.get("summary") if isinstance(detail_result, dict) and isinstance(detail_result.get("summary"), dict) else {}
442
+ source = detail if detail else base
443
+ warnings: list[JSONObject] = []
444
+ if detail_read_error is not None:
445
+ warnings.append(
446
+ {
447
+ "code": "PACKAGE_DETAIL_READ_DEGRADED",
448
+ "message": "package_get used baseInfo because the package detail endpoint was not readable",
449
+ "backend_code": detail_read_error.backend_code,
450
+ "http_status": detail_read_error.http_status,
451
+ }
452
+ )
428
453
  return {
429
454
  "status": "success",
430
455
  "error_code": None,
431
456
  "recoverable": False,
432
- "message": "read package detail",
457
+ "message": "read package",
433
458
  "normalized_args": normalized_args,
434
459
  "missing_fields": [],
435
460
  "allowed_values": {},
@@ -437,21 +462,183 @@ class AiBuilderFacade:
437
462
  "request_id": None,
438
463
  "suggested_next_call": None,
439
464
  "noop": False,
440
- "warnings": [],
465
+ "warnings": warnings,
441
466
  "verification": {"package_exists": True},
442
467
  "verified": True,
443
- "tag_id": _coerce_positive_int(detail.get("tagId") or base.get("tagId")) or tag_id,
444
- "tag_name": str(detail.get("tagName") or base.get("tagName") or "").strip() or None,
445
- "tag_icon": str(detail.get("tagIcon") or base.get("tagIcon") or "").strip() or None,
446
- "publish_status": detail.get("publishStatus") if detail.get("publishStatus") is not None else base.get("publishStatus"),
468
+ "package_id": _coerce_positive_int(source.get("tagId") or base.get("tagId")) or effective_package_id,
469
+ "package_name": str(source.get("tagName") or base.get("tagName") or "").strip() or None,
470
+ "icon": str(source.get("tagIcon") or base.get("tagIcon") or "").strip() or None,
471
+ "publish_status": source.get("publishStatus") if source.get("publishStatus") is not None else base.get("publishStatus"),
447
472
  "item_count": summary.get("itemCount"),
448
- "item_preview": deepcopy(summary.get("itemPreview") or []),
449
473
  "add_app_status": base.get("addAppStatus") if base.get("addAppStatus") is not None else summary.get("addAppStatus"),
450
474
  "edit_app_status": base.get("editAppStatus") if base.get("editAppStatus") is not None else summary.get("editAppStatus"),
451
475
  "del_app_status": base.get("delAppStatus") if base.get("delAppStatus") is not None else summary.get("delAppStatus"),
452
476
  "edit_tag_status": base.get("editTagStatus") if base.get("editTagStatus") is not None else summary.get("editTagStatus"),
453
- "visibility": _public_visibility_from_member_auth(base.get("auth") or detail.get("auth")),
477
+ "visibility": _public_visibility_from_member_auth(base.get("auth") or source.get("auth")),
478
+ "items": _public_package_items_from_tag_items(source.get("tagItems") or base.get("tagItems")),
479
+ }
480
+
481
+ def package_apply(
482
+ self,
483
+ *,
484
+ profile: str,
485
+ package_id: int | None = None,
486
+ package_name: str | None = None,
487
+ create_if_missing: bool = False,
488
+ icon: str | None = None,
489
+ color: str | None = None,
490
+ visibility: VisibilityPatch | None = None,
491
+ items: list[dict[str, Any]] | None = None,
492
+ allow_detach: bool = False,
493
+ ) -> JSONObject:
494
+ requested_name = str(package_name or "").strip()
495
+ normalized_args: JSONObject = {
496
+ "package_id": package_id,
497
+ **({"package_name": requested_name} if requested_name else {}),
498
+ "create_if_missing": bool(create_if_missing),
499
+ **({"icon": icon} if icon else {}),
500
+ **({"color": color} if color else {}),
501
+ **({"visibility": visibility.model_dump(mode="json")} if visibility is not None else {}),
502
+ **({"items": deepcopy(items)} if items is not None else {}),
503
+ "allow_detach": bool(allow_detach),
504
+ }
505
+ effective_package_id = _coerce_positive_int(package_id)
506
+ created = False
507
+ permission_outcomes: list[PermissionCheckOutcome] = []
508
+
509
+ if effective_package_id is None:
510
+ if not create_if_missing:
511
+ return _failed(
512
+ "PACKAGE_ID_REQUIRED",
513
+ "package_id is required unless create_if_missing=true",
514
+ normalized_args=normalized_args,
515
+ suggested_next_call=None,
516
+ )
517
+ if not requested_name:
518
+ return _failed(
519
+ "PACKAGE_NAME_REQUIRED",
520
+ "package_name is required when create_if_missing=true",
521
+ normalized_args=normalized_args,
522
+ suggested_next_call=None,
523
+ )
524
+ create_result = self.package_create(
525
+ profile=profile,
526
+ package_name=requested_name,
527
+ icon=icon,
528
+ color=color,
529
+ visibility=visibility,
530
+ )
531
+ if create_result.get("status") not in {"success", "partial_success"}:
532
+ return _publicize_package_apply_failure(create_result, profile=profile, normalized_args=normalized_args)
533
+ effective_package_id = _coerce_positive_int(create_result.get("tag_id") or create_result.get("package_id"))
534
+ if effective_package_id is None:
535
+ return _failed(
536
+ "PACKAGE_CREATE_UNVERIFIED",
537
+ "created package but could not verify package_id",
538
+ normalized_args=normalized_args,
539
+ details={"create_result": create_result},
540
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
541
+ )
542
+ normalized_args["package_id"] = effective_package_id
543
+ created = True
544
+
545
+ metadata_requested = bool(requested_name or icon or color or visibility is not None)
546
+ if metadata_requested and not created:
547
+ edit_tag_outcome = self._guard_package_permission(
548
+ profile=profile,
549
+ tag_id=effective_package_id,
550
+ required_permission="edit_tag",
551
+ normalized_args=normalized_args,
552
+ )
553
+ if edit_tag_outcome.block is not None:
554
+ return edit_tag_outcome.block
555
+ permission_outcomes.append(edit_tag_outcome)
556
+ update_result = self.package_update(
557
+ profile=profile,
558
+ tag_id=effective_package_id,
559
+ package_name=requested_name or None,
560
+ icon=icon,
561
+ color=color,
562
+ visibility=visibility,
563
+ )
564
+ if update_result.get("status") not in {"success", "partial_success"}:
565
+ return _publicize_package_apply_failure(update_result, profile=profile, normalized_args=normalized_args)
566
+
567
+ layout_result: JSONObject | None = None
568
+ if items is not None:
569
+ if not isinstance(items, list):
570
+ return _failed(
571
+ "PACKAGE_ITEMS_INVALID",
572
+ "items must be a list",
573
+ normalized_args=normalized_args,
574
+ suggested_next_call=None,
575
+ )
576
+ layout_result = self._apply_package_items(
577
+ profile=profile,
578
+ package_id=effective_package_id,
579
+ items=items,
580
+ allow_detach=allow_detach,
581
+ normalized_args=normalized_args,
582
+ )
583
+ if layout_result.get("status") not in {"success", "partial_success"}:
584
+ return _apply_permission_outcomes(layout_result, *permission_outcomes)
585
+
586
+ verification = self.package_get(profile=profile, package_id=effective_package_id)
587
+ if verification.get("status") != "success":
588
+ return _apply_permission_outcomes(verification, *permission_outcomes)
589
+ expected_visibility = None
590
+ if visibility is not None:
591
+ try:
592
+ expected_visibility = _public_visibility_from_member_auth(
593
+ self._compile_visibility_to_member_auth(profile=profile, visibility=visibility)
594
+ )
595
+ except VisibilityResolutionError:
596
+ expected_visibility = None
597
+ response: JSONObject = {
598
+ "status": "success",
599
+ "error_code": None,
600
+ "recoverable": False,
601
+ "message": "applied package",
602
+ "normalized_args": normalized_args,
603
+ "missing_fields": [],
604
+ "allowed_values": {},
605
+ "details": {"layout_result": layout_result} if layout_result is not None else {},
606
+ "request_id": None,
607
+ "suggested_next_call": None,
608
+ "noop": not (created or metadata_requested or items is not None),
609
+ "warnings": [],
610
+ "verification": {
611
+ "package_exists": True,
612
+ "package_created": created,
613
+ "layout_applied": items is not None,
614
+ "visibility_verified": None
615
+ if expected_visibility is None
616
+ else _visibility_matches_expected(verification.get("visibility"), expected_visibility),
617
+ },
618
+ "verified": True,
619
+ **{
620
+ key: deepcopy(value)
621
+ for key, value in verification.items()
622
+ if key
623
+ not in {
624
+ "status",
625
+ "error_code",
626
+ "recoverable",
627
+ "message",
628
+ "normalized_args",
629
+ "missing_fields",
630
+ "allowed_values",
631
+ "details",
632
+ "request_id",
633
+ "suggested_next_call",
634
+ "noop",
635
+ "warnings",
636
+ "verification",
637
+ "verified",
638
+ }
639
+ },
454
640
  }
641
+ return _apply_permission_outcomes(response, *permission_outcomes)
455
642
 
456
643
  def package_update(
457
644
  self,
@@ -524,7 +711,7 @@ class AiBuilderFacade:
524
711
  details={"tag_id": tag_id},
525
712
  suggested_next_call={"tool_name": "package_update", "arguments": {"profile": profile, **normalized_args}},
526
713
  )
527
- verification = self.package_get(profile=profile, tag_id=tag_id)
714
+ verification = self.package_get(profile=profile, package_id=tag_id)
528
715
  if verification.get("status") != "success":
529
716
  return verification
530
717
  return {
@@ -542,7 +729,10 @@ class AiBuilderFacade:
542
729
  "warnings": [],
543
730
  "verification": {
544
731
  "package_exists": True,
545
- "visibility_verified": verification.get("visibility") == _public_visibility_from_member_auth(desired_auth),
732
+ "visibility_verified": _visibility_matches_expected(
733
+ verification.get("visibility"),
734
+ _public_visibility_from_member_auth(desired_auth),
735
+ ),
546
736
  },
547
737
  "verified": True,
548
738
  **{
@@ -645,6 +835,208 @@ class AiBuilderFacade:
645
835
  "retried": bool(listed.get("retried", False)),
646
836
  }
647
837
 
838
+ def _apply_package_items(
839
+ self,
840
+ *,
841
+ profile: str,
842
+ package_id: int,
843
+ items: list[dict[str, Any]],
844
+ allow_detach: bool,
845
+ normalized_args: JSONObject,
846
+ ) -> JSONObject:
847
+ current_result: JSONObject | None = None
848
+ try:
849
+ current_result = self.packages.package_get(profile=profile, tag_id=package_id, include_raw=True)
850
+ except (QingflowApiError, RuntimeError) as detail_error:
851
+ detail_api_error = _coerce_api_error(detail_error)
852
+ try:
853
+ current_result = self.packages.package_get_base(profile=profile, tag_id=package_id, include_raw=True)
854
+ except (QingflowApiError, RuntimeError) as base_error:
855
+ api_error = _coerce_api_error(base_error)
856
+ return _failed_from_api_error(
857
+ "PACKAGE_LAYOUT_READ_FAILED",
858
+ api_error,
859
+ normalized_args=normalized_args,
860
+ details={
861
+ "package_id": package_id,
862
+ "detail_read_error": _transport_error_payload(detail_api_error),
863
+ },
864
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
865
+ )
866
+ current_raw = current_result.get("result") if isinstance(current_result.get("result"), dict) else {}
867
+ raw_tag_items = current_raw.get("tagItems")
868
+ if not isinstance(raw_tag_items, list):
869
+ return _failed(
870
+ "PACKAGE_LAYOUT_UNREADABLE",
871
+ "package items could not be read safely",
872
+ normalized_args=normalized_args,
873
+ details={"package_id": package_id},
874
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
875
+ )
876
+ current_items = raw_tag_items
877
+
878
+ current_resources = _flatten_package_resource_identities(current_items, public=False)
879
+ desired_resources = _flatten_package_resource_identities(items, public=True)
880
+ missing_resources = sorted(current_resources - desired_resources)
881
+ if missing_resources and not allow_detach:
882
+ return _failed(
883
+ "PACKAGE_LAYOUT_ITEMS_MISSING",
884
+ "items omits existing apps or portals; pass allow_detach=true to remove them",
885
+ normalized_args=normalized_args,
886
+ details={"package_id": package_id, "missing_items": [{"type": item[0], "id": item[1]} for item in missing_resources]},
887
+ suggested_next_call=None,
888
+ )
889
+
890
+ duplicate_resources = _find_duplicate_package_resources(items)
891
+ if duplicate_resources:
892
+ return _failed(
893
+ "PACKAGE_LAYOUT_DUPLICATE_ITEM",
894
+ "items contains duplicate apps or portals",
895
+ normalized_args=normalized_args,
896
+ details={"duplicates": [{"type": item[0], "id": item[1]} for item in duplicate_resources]},
897
+ suggested_next_call=None,
898
+ )
899
+
900
+ current_groups = _collect_backend_package_groups(current_items)
901
+ desired_groups = _collect_public_package_group_specs(items)
902
+ desired_group_ids = {
903
+ group_id for group_id in (_coerce_positive_int(group.get("group_id")) for group in desired_groups) if group_id is not None
904
+ }
905
+ deleted_group_ids = sorted(set(current_groups) - desired_group_ids)
906
+
907
+ permission_outcomes: list[PermissionCheckOutcome] = []
908
+ needs_group_create = any(_coerce_positive_int(group.get("group_id")) is None for group in desired_groups)
909
+ needs_group_delete = bool(deleted_group_ids)
910
+ needs_edit_app = bool(items)
911
+ for required_permission in (
912
+ (["add_app"] if needs_group_create else [])
913
+ + (["edit_app"] if needs_edit_app else [])
914
+ + (["delete_app"] if needs_group_delete else [])
915
+ ):
916
+ outcome = self._guard_package_permission(
917
+ profile=profile,
918
+ tag_id=package_id,
919
+ required_permission=required_permission,
920
+ normalized_args=normalized_args,
921
+ )
922
+ if outcome.block is not None:
923
+ return outcome.block
924
+ permission_outcomes.append(outcome)
925
+
926
+ group_ids_by_path: dict[tuple[int, ...], int] = {}
927
+ group_operations: list[JSONObject] = []
928
+ for group in desired_groups:
929
+ path = tuple(group.get("path") or ())
930
+ group_id = _coerce_positive_int(group.get("group_id"))
931
+ group_name = str(group.get("name") or "").strip()
932
+ if not group_name:
933
+ return _failed(
934
+ "PACKAGE_GROUP_NAME_REQUIRED",
935
+ "group items require name",
936
+ normalized_args=normalized_args,
937
+ details={"path": list(path)},
938
+ suggested_next_call=None,
939
+ )
940
+ if group_id is None:
941
+ try:
942
+ created = self.packages.package_group_create(profile=profile, tag_id=package_id, group_name=group_name)
943
+ except (QingflowApiError, RuntimeError) as error:
944
+ api_error = _coerce_api_error(error)
945
+ return _failed_from_api_error(
946
+ "PACKAGE_GROUP_CREATE_FAILED",
947
+ api_error,
948
+ normalized_args=normalized_args,
949
+ details={"package_id": package_id, "group_name": group_name},
950
+ suggested_next_call=None,
951
+ )
952
+ group_id = _extract_package_group_id(created)
953
+ if group_id is None:
954
+ return _failed(
955
+ "PACKAGE_GROUP_CREATE_UNVERIFIED",
956
+ "created package group but could not read group_id",
957
+ normalized_args=normalized_args,
958
+ details={"package_id": package_id, "group_name": group_name, "create_result": created},
959
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
960
+ )
961
+ group_operations.append({"action": "create", "group_id": group_id, "name": group_name})
962
+ elif current_groups.get(group_id) != group_name:
963
+ try:
964
+ self.packages.package_group_update(profile=profile, tag_id=package_id, group_id=group_id, group_name=group_name)
965
+ except (QingflowApiError, RuntimeError) as error:
966
+ api_error = _coerce_api_error(error)
967
+ return _failed_from_api_error(
968
+ "PACKAGE_GROUP_UPDATE_FAILED",
969
+ api_error,
970
+ normalized_args=normalized_args,
971
+ details={"package_id": package_id, "group_id": group_id, "group_name": group_name},
972
+ suggested_next_call=None,
973
+ )
974
+ group_operations.append({"action": "update", "group_id": group_id, "name": group_name})
975
+ group_ids_by_path[path] = group_id
976
+
977
+ try:
978
+ backend_items = _backend_package_items_from_public_items(items, group_ids_by_path)
979
+ except ValueError as error:
980
+ return _failed(
981
+ "PACKAGE_LAYOUT_INVALID",
982
+ str(error),
983
+ normalized_args=normalized_args,
984
+ details={"package_id": package_id},
985
+ suggested_next_call=None,
986
+ )
987
+ for group_id in deleted_group_ids:
988
+ backend_items.append({"itemType": 3, "groupId": group_id, "title": current_groups.get(group_id) or "", "subItems": []})
989
+
990
+ try:
991
+ sort_result = self.packages.package_sort_items(profile=profile, tag_id=package_id, tag_items=backend_items)
992
+ except (QingflowApiError, RuntimeError) as error:
993
+ api_error = _coerce_api_error(error)
994
+ return _failed_from_api_error(
995
+ "PACKAGE_LAYOUT_SORT_FAILED",
996
+ api_error,
997
+ normalized_args=normalized_args,
998
+ details={"package_id": package_id},
999
+ suggested_next_call=None,
1000
+ )
1001
+
1002
+ for group_id in deleted_group_ids:
1003
+ try:
1004
+ self.packages.package_group_delete(profile=profile, tag_id=package_id, group_id=group_id)
1005
+ except (QingflowApiError, RuntimeError) as error:
1006
+ api_error = _coerce_api_error(error)
1007
+ return _apply_permission_outcomes(
1008
+ _failed_from_api_error(
1009
+ "PACKAGE_GROUP_DELETE_FAILED",
1010
+ api_error,
1011
+ normalized_args=normalized_args,
1012
+ details={"package_id": package_id, "group_id": group_id},
1013
+ suggested_next_call=None,
1014
+ ),
1015
+ *permission_outcomes,
1016
+ )
1017
+ group_operations.append({"action": "delete", "group_id": group_id})
1018
+
1019
+ return _apply_permission_outcomes(
1020
+ {
1021
+ "status": "success",
1022
+ "error_code": None,
1023
+ "recoverable": False,
1024
+ "message": "applied package items",
1025
+ "normalized_args": normalized_args,
1026
+ "missing_fields": [],
1027
+ "allowed_values": {},
1028
+ "details": {"group_operations": group_operations, "sort_result": sort_result},
1029
+ "request_id": sort_result.get("request_id") if isinstance(sort_result, dict) else None,
1030
+ "suggested_next_call": None,
1031
+ "noop": False,
1032
+ "warnings": [],
1033
+ "verification": {"layout_applied": True},
1034
+ "verified": True,
1035
+ "package_id": package_id,
1036
+ },
1037
+ *permission_outcomes,
1038
+ )
1039
+
648
1040
  def member_search(self, *, profile: str, query: str, page_num: int = 1, page_size: int = 20, contain_disable: bool = False) -> JSONObject:
649
1041
  requested = str(query or "").strip()
650
1042
  normalized_args = {
@@ -2502,6 +2894,11 @@ class AiBuilderFacade:
2502
2894
  "PACKAGE_EDIT_APP_UNAUTHORIZED",
2503
2895
  "current user does not have package edit-app permission on this package",
2504
2896
  ),
2897
+ "delete_app": (
2898
+ "can_delete_app",
2899
+ "PACKAGE_DELETE_APP_UNAUTHORIZED",
2900
+ "current user does not have package delete-app permission on this package",
2901
+ ),
2505
2902
  "edit_tag": (
2506
2903
  "can_edit_tag",
2507
2904
  "PACKAGE_EDIT_TAG_UNAUTHORIZED",
@@ -8688,6 +9085,50 @@ def _visibility_summary(visibility: dict[str, Any]) -> dict[str, Any]:
8688
9085
  }
8689
9086
 
8690
9087
 
9088
+ def _visibility_matches_expected(actual: Any, expected: Any) -> bool:
9089
+ if not isinstance(actual, dict) or not isinstance(expected, dict):
9090
+ return False
9091
+ if str(actual.get("mode") or "") != str(expected.get("mode") or ""):
9092
+ return False
9093
+ if str(actual.get("external_mode") or "") != str(expected.get("external_mode") or ""):
9094
+ return False
9095
+
9096
+ actual_selectors = actual.get("selectors") if isinstance(actual.get("selectors"), dict) else {}
9097
+ expected_selectors = expected.get("selectors") if isinstance(expected.get("selectors"), dict) else {}
9098
+ actual_external = actual.get("external_selectors") if isinstance(actual.get("external_selectors"), dict) else {}
9099
+ expected_external = expected.get("external_selectors") if isinstance(expected.get("external_selectors"), dict) else {}
9100
+
9101
+ def sorted_values(source: dict[str, Any], key: str) -> list[str]:
9102
+ return sorted(str(item) for item in (source.get(key) or []) if item is not None and str(item).strip())
9103
+
9104
+ for key in ("member_uids", "dept_ids", "role_ids"):
9105
+ if sorted_values(actual_selectors, key) != sorted_values(expected_selectors, key):
9106
+ return False
9107
+ for key in ("member_ids", "dept_ids"):
9108
+ if sorted_values(actual_external, key) != sorted_values(expected_external, key):
9109
+ return False
9110
+
9111
+ # Backends commonly enrich id-based selectors with names/emails on readback; require
9112
+ # caller-specified text selectors only when no id selector anchors that subject.
9113
+ text_selector_pairs = (
9114
+ (actual_selectors, expected_selectors, "member_uids", "member_emails"),
9115
+ (actual_selectors, expected_selectors, "member_uids", "member_names"),
9116
+ (actual_selectors, expected_selectors, "dept_ids", "dept_names"),
9117
+ (actual_selectors, expected_selectors, "role_ids", "role_names"),
9118
+ (actual_external, expected_external, "member_ids", "member_emails"),
9119
+ )
9120
+ for actual_group, expected_group, id_key, text_key in text_selector_pairs:
9121
+ if sorted_values(expected_group, id_key):
9122
+ continue
9123
+ expected_text = sorted_values(expected_group, text_key)
9124
+ if expected_text and sorted_values(actual_group, text_key) != expected_text:
9125
+ return False
9126
+
9127
+ if "include_sub_departs" in expected_selectors and actual_selectors.get("include_sub_departs") != expected_selectors.get("include_sub_departs"):
9128
+ return False
9129
+ return True
9130
+
9131
+
8691
9132
  def _mapping_contains(actual: Any, expected: Any) -> bool:
8692
9133
  if isinstance(expected, dict):
8693
9134
  if not isinstance(actual, dict):
@@ -11857,6 +12298,259 @@ def _tag_items_include_app(tag_items: Any, app_key: str) -> bool:
11857
12298
  return any(str(item.get("appKey") or "") == app_key for item in tag_items if isinstance(item, dict))
11858
12299
 
11859
12300
 
12301
+ def _public_package_items_from_tag_items(tag_items: Any) -> list[JSONObject]:
12302
+ if not isinstance(tag_items, list):
12303
+ return []
12304
+ public_items: list[JSONObject] = []
12305
+ for item in tag_items:
12306
+ if not isinstance(item, dict):
12307
+ continue
12308
+ item_type = _coerce_positive_int(item.get("itemType"))
12309
+ if item_type == 1:
12310
+ app_key = str(item.get("appKey") or "").strip()
12311
+ if not app_key:
12312
+ continue
12313
+ public_items.append(
12314
+ _compact_dict(
12315
+ {
12316
+ "type": "app",
12317
+ "app_key": app_key,
12318
+ "title": str(item.get("title") or item.get("formTitle") or "").strip() or None,
12319
+ "form_id": item.get("formId"),
12320
+ }
12321
+ )
12322
+ )
12323
+ continue
12324
+ if item_type == 2:
12325
+ dash_key = str(item.get("dashKey") or item.get("pageKey") or "").strip()
12326
+ if not dash_key:
12327
+ continue
12328
+ public_items.append(
12329
+ _compact_dict(
12330
+ {
12331
+ "type": "portal",
12332
+ "dash_key": dash_key,
12333
+ "title": str(item.get("title") or item.get("dashName") or "").strip() or None,
12334
+ }
12335
+ )
12336
+ )
12337
+ continue
12338
+ if item_type == 3:
12339
+ group_id = _coerce_positive_int(item.get("groupId"))
12340
+ public_items.append(
12341
+ _compact_dict(
12342
+ {
12343
+ "type": "group",
12344
+ "group_id": group_id,
12345
+ "name": str(item.get("title") or item.get("groupName") or "").strip() or None,
12346
+ "items": _public_package_items_from_tag_items(item.get("subItems")),
12347
+ }
12348
+ )
12349
+ )
12350
+ return public_items
12351
+
12352
+
12353
+ def _flatten_package_resource_identities(items: Any, *, public: bool) -> set[tuple[str, str]]:
12354
+ flattened: set[tuple[str, str]] = set()
12355
+
12356
+ def walk(value: Any) -> None:
12357
+ if isinstance(value, list):
12358
+ for child in value:
12359
+ walk(child)
12360
+ return
12361
+ if not isinstance(value, dict):
12362
+ return
12363
+ if public:
12364
+ item_type = str(value.get("type") or "").strip().lower()
12365
+ if item_type == "app" or value.get("app_key") or value.get("appKey"):
12366
+ app_key = str(value.get("app_key") or value.get("appKey") or "").strip()
12367
+ if app_key:
12368
+ flattened.add(("app", app_key))
12369
+ elif item_type == "portal" or value.get("dash_key") or value.get("dashKey"):
12370
+ dash_key = str(value.get("dash_key") or value.get("dashKey") or "").strip()
12371
+ if dash_key:
12372
+ flattened.add(("portal", dash_key))
12373
+ walk(value.get("items"))
12374
+ return
12375
+ item_type = _coerce_positive_int(value.get("itemType"))
12376
+ if item_type == 1:
12377
+ app_key = str(value.get("appKey") or "").strip()
12378
+ if app_key:
12379
+ flattened.add(("app", app_key))
12380
+ elif item_type == 2:
12381
+ dash_key = str(value.get("dashKey") or value.get("pageKey") or "").strip()
12382
+ if dash_key:
12383
+ flattened.add(("portal", dash_key))
12384
+ walk(value.get("subItems"))
12385
+
12386
+ walk(items)
12387
+ return flattened
12388
+
12389
+
12390
+ def _find_duplicate_package_resources(items: Any) -> list[tuple[str, str]]:
12391
+ seen: set[tuple[str, str]] = set()
12392
+ duplicates: set[tuple[str, str]] = set()
12393
+
12394
+ def walk(value: Any) -> None:
12395
+ if isinstance(value, list):
12396
+ for child in value:
12397
+ walk(child)
12398
+ return
12399
+ if not isinstance(value, dict):
12400
+ return
12401
+ item_type = str(value.get("type") or "").strip().lower()
12402
+ identity: tuple[str, str] | None = None
12403
+ if item_type == "app" or value.get("app_key") or value.get("appKey"):
12404
+ app_key = str(value.get("app_key") or value.get("appKey") or "").strip()
12405
+ if app_key:
12406
+ identity = ("app", app_key)
12407
+ elif item_type == "portal" or value.get("dash_key") or value.get("dashKey"):
12408
+ dash_key = str(value.get("dash_key") or value.get("dashKey") or "").strip()
12409
+ if dash_key:
12410
+ identity = ("portal", dash_key)
12411
+ if identity is not None:
12412
+ if identity in seen:
12413
+ duplicates.add(identity)
12414
+ seen.add(identity)
12415
+ walk(value.get("items"))
12416
+
12417
+ walk(items)
12418
+ return sorted(duplicates)
12419
+
12420
+
12421
+ def _collect_backend_package_groups(tag_items: Any) -> dict[int, str]:
12422
+ groups: dict[int, str] = {}
12423
+
12424
+ def walk(value: Any) -> None:
12425
+ if isinstance(value, list):
12426
+ for child in value:
12427
+ walk(child)
12428
+ return
12429
+ if not isinstance(value, dict):
12430
+ return
12431
+ if _coerce_positive_int(value.get("itemType")) == 3:
12432
+ group_id = _coerce_positive_int(value.get("groupId"))
12433
+ if group_id is not None:
12434
+ groups[group_id] = str(value.get("title") or value.get("groupName") or "").strip()
12435
+ walk(value.get("subItems"))
12436
+
12437
+ walk(tag_items)
12438
+ return groups
12439
+
12440
+
12441
+ def _collect_public_package_group_specs(items: Any, *, path: tuple[int, ...] = ()) -> list[JSONObject]:
12442
+ if not isinstance(items, list):
12443
+ return []
12444
+ groups: list[JSONObject] = []
12445
+ for index, item in enumerate(items):
12446
+ if not isinstance(item, dict):
12447
+ continue
12448
+ item_type = str(item.get("type") or "").strip().lower()
12449
+ child_path = (*path, index)
12450
+ if item_type == "group" or isinstance(item.get("items"), list):
12451
+ groups.append(
12452
+ {
12453
+ "path": list(child_path),
12454
+ "group_id": _coerce_positive_int(item.get("group_id") or item.get("groupId")),
12455
+ "name": str(item.get("name") or item.get("title") or item.get("group_name") or "").strip(),
12456
+ }
12457
+ )
12458
+ groups.extend(_collect_public_package_group_specs(item.get("items"), path=child_path))
12459
+ return groups
12460
+
12461
+
12462
+ 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]:
12463
+ backend_items: list[JSONObject] = []
12464
+ for index, item in enumerate(items):
12465
+ if not isinstance(item, dict):
12466
+ raise ValueError(f"items[{index}] must be an object")
12467
+ item_type = str(item.get("type") or "").strip().lower()
12468
+ child_path = (*path, index)
12469
+ if item_type == "app" or item.get("app_key") or item.get("appKey"):
12470
+ app_key = str(item.get("app_key") or item.get("appKey") or "").strip()
12471
+ if not app_key:
12472
+ raise ValueError(f"items[{index}].app_key is required")
12473
+ backend_items.append(
12474
+ _compact_dict(
12475
+ {
12476
+ "itemType": 1,
12477
+ "appKey": app_key,
12478
+ "title": str(item.get("title") or item.get("name") or "").strip() or None,
12479
+ "formId": item.get("form_id") or item.get("formId"),
12480
+ }
12481
+ )
12482
+ )
12483
+ continue
12484
+ if item_type == "portal" or item.get("dash_key") or item.get("dashKey"):
12485
+ dash_key = str(item.get("dash_key") or item.get("dashKey") or "").strip()
12486
+ if not dash_key:
12487
+ raise ValueError(f"items[{index}].dash_key is required")
12488
+ backend_items.append(
12489
+ _compact_dict(
12490
+ {
12491
+ "itemType": 2,
12492
+ "dashKey": dash_key,
12493
+ "title": str(item.get("title") or item.get("name") or "").strip() or None,
12494
+ }
12495
+ )
12496
+ )
12497
+ continue
12498
+ if item_type == "group" or isinstance(item.get("items"), list):
12499
+ group_id = group_ids_by_path.get(child_path) or _coerce_positive_int(item.get("group_id") or item.get("groupId"))
12500
+ if group_id is None:
12501
+ raise ValueError(f"items[{index}].group_id is required after group creation")
12502
+ group_name = str(item.get("name") or item.get("title") or item.get("group_name") or "").strip()
12503
+ if not group_name:
12504
+ raise ValueError(f"items[{index}].name is required")
12505
+ child_items = item.get("items") if isinstance(item.get("items"), list) else []
12506
+ backend_items.append(
12507
+ {
12508
+ "itemType": 3,
12509
+ "groupId": group_id,
12510
+ "title": group_name,
12511
+ "subItems": _backend_package_items_from_public_items(child_items, group_ids_by_path, path=child_path),
12512
+ }
12513
+ )
12514
+ continue
12515
+ raise ValueError(f"items[{index}].type must be app, portal, or group")
12516
+ return backend_items
12517
+
12518
+
12519
+ def _extract_package_group_id(value: Any) -> int | None:
12520
+ if isinstance(value, dict):
12521
+ direct = _coerce_positive_int(value.get("groupId") or value.get("group_id") or value.get("id"))
12522
+ if direct is not None:
12523
+ return direct
12524
+ for nested_key in ("result", "data", "group"):
12525
+ nested = value.get(nested_key)
12526
+ nested_id = _extract_package_group_id(nested)
12527
+ if nested_id is not None:
12528
+ return nested_id
12529
+ if isinstance(value, list):
12530
+ for item in value:
12531
+ nested_id = _extract_package_group_id(item)
12532
+ if nested_id is not None:
12533
+ return nested_id
12534
+ return None
12535
+
12536
+
12537
+ def _publicize_package_apply_failure(result: JSONObject, *, profile: str, normalized_args: JSONObject) -> JSONObject:
12538
+ public_result = deepcopy(result)
12539
+ public_result["normalized_args"] = deepcopy(normalized_args)
12540
+ suggested = public_result.get("suggested_next_call")
12541
+ if isinstance(suggested, dict):
12542
+ public_result["suggested_next_call"] = {
12543
+ "tool_name": "package_apply",
12544
+ "arguments": {"profile": profile, **deepcopy(normalized_args)},
12545
+ }
12546
+ for key in ("tag_id", "tag_name", "tag_icon"):
12547
+ public_result.pop(key, None)
12548
+ details = public_result.get("details")
12549
+ if isinstance(details, dict) and "tag_id" in details:
12550
+ details["package_id"] = details.pop("tag_id")
12551
+ return public_result
12552
+
12553
+
11860
12554
  def _verify_package_attachment(packages: PackageTools, *, profile: str, tag_id: int, app_key: str, attempts: int = 2) -> JSONObject:
11861
12555
  last_result: JSONObject = {"result": {}}
11862
12556
  for _ in range(max(attempts, 1)):