@josephyan/qingflow-cli 0.2.0-beta.82 → 0.2.0-beta.84

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,59 @@ 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
+ )
453
+ public_items = _public_package_items_from_tag_items(source.get("tagItems") or base.get("tagItems"))
454
+ item_count = summary.get("itemCount")
455
+ if not isinstance(item_count, int) or item_count < 0 or (item_count == 0 and public_items):
456
+ item_count = len(public_items)
428
457
  return {
429
458
  "status": "success",
430
459
  "error_code": None,
431
460
  "recoverable": False,
432
- "message": "read package detail",
461
+ "message": "read package",
433
462
  "normalized_args": normalized_args,
434
463
  "missing_fields": [],
435
464
  "allowed_values": {},
@@ -437,21 +466,183 @@ class AiBuilderFacade:
437
466
  "request_id": None,
438
467
  "suggested_next_call": None,
439
468
  "noop": False,
440
- "warnings": [],
469
+ "warnings": warnings,
441
470
  "verification": {"package_exists": True},
442
471
  "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"),
447
- "item_count": summary.get("itemCount"),
448
- "item_preview": deepcopy(summary.get("itemPreview") or []),
472
+ "package_id": _coerce_positive_int(source.get("tagId") or base.get("tagId")) or effective_package_id,
473
+ "package_name": str(source.get("tagName") or base.get("tagName") or "").strip() or None,
474
+ "icon": str(source.get("tagIcon") or base.get("tagIcon") or "").strip() or None,
475
+ "publish_status": source.get("publishStatus") if source.get("publishStatus") is not None else base.get("publishStatus"),
476
+ "item_count": item_count,
449
477
  "add_app_status": base.get("addAppStatus") if base.get("addAppStatus") is not None else summary.get("addAppStatus"),
450
478
  "edit_app_status": base.get("editAppStatus") if base.get("editAppStatus") is not None else summary.get("editAppStatus"),
451
479
  "del_app_status": base.get("delAppStatus") if base.get("delAppStatus") is not None else summary.get("delAppStatus"),
452
480
  "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")),
481
+ "visibility": _public_visibility_from_member_auth(base.get("auth") or source.get("auth")),
482
+ "items": public_items,
483
+ }
484
+
485
+ def package_apply(
486
+ self,
487
+ *,
488
+ profile: str,
489
+ package_id: int | None = None,
490
+ package_name: str | None = None,
491
+ create_if_missing: bool = False,
492
+ icon: str | None = None,
493
+ color: str | None = None,
494
+ visibility: VisibilityPatch | None = None,
495
+ items: list[dict[str, Any]] | None = None,
496
+ allow_detach: bool = False,
497
+ ) -> JSONObject:
498
+ requested_name = str(package_name or "").strip()
499
+ normalized_args: JSONObject = {
500
+ "package_id": package_id,
501
+ **({"package_name": requested_name} if requested_name else {}),
502
+ "create_if_missing": bool(create_if_missing),
503
+ **({"icon": icon} if icon else {}),
504
+ **({"color": color} if color else {}),
505
+ **({"visibility": visibility.model_dump(mode="json")} if visibility is not None else {}),
506
+ **({"items": deepcopy(items)} if items is not None else {}),
507
+ "allow_detach": bool(allow_detach),
508
+ }
509
+ effective_package_id = _coerce_positive_int(package_id)
510
+ created = False
511
+ permission_outcomes: list[PermissionCheckOutcome] = []
512
+
513
+ if effective_package_id is None:
514
+ if not create_if_missing:
515
+ return _failed(
516
+ "PACKAGE_ID_REQUIRED",
517
+ "package_id is required unless create_if_missing=true",
518
+ normalized_args=normalized_args,
519
+ suggested_next_call=None,
520
+ )
521
+ if not requested_name:
522
+ return _failed(
523
+ "PACKAGE_NAME_REQUIRED",
524
+ "package_name is required when create_if_missing=true",
525
+ normalized_args=normalized_args,
526
+ suggested_next_call=None,
527
+ )
528
+ create_result = self.package_create(
529
+ profile=profile,
530
+ package_name=requested_name,
531
+ icon=icon,
532
+ color=color,
533
+ visibility=visibility,
534
+ )
535
+ if create_result.get("status") not in {"success", "partial_success"}:
536
+ return _publicize_package_apply_failure(create_result, profile=profile, normalized_args=normalized_args)
537
+ effective_package_id = _coerce_positive_int(create_result.get("tag_id") or create_result.get("package_id"))
538
+ if effective_package_id is None:
539
+ return _failed(
540
+ "PACKAGE_CREATE_UNVERIFIED",
541
+ "created package but could not verify package_id",
542
+ normalized_args=normalized_args,
543
+ details={"create_result": create_result},
544
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
545
+ )
546
+ normalized_args["package_id"] = effective_package_id
547
+ created = True
548
+
549
+ metadata_requested = bool(requested_name or icon or color or visibility is not None)
550
+ if metadata_requested and not created:
551
+ edit_tag_outcome = self._guard_package_permission(
552
+ profile=profile,
553
+ tag_id=effective_package_id,
554
+ required_permission="edit_tag",
555
+ normalized_args=normalized_args,
556
+ )
557
+ if edit_tag_outcome.block is not None:
558
+ return edit_tag_outcome.block
559
+ permission_outcomes.append(edit_tag_outcome)
560
+ update_result = self.package_update(
561
+ profile=profile,
562
+ tag_id=effective_package_id,
563
+ package_name=requested_name or None,
564
+ icon=icon,
565
+ color=color,
566
+ visibility=visibility,
567
+ )
568
+ if update_result.get("status") not in {"success", "partial_success"}:
569
+ return _publicize_package_apply_failure(update_result, profile=profile, normalized_args=normalized_args)
570
+
571
+ layout_result: JSONObject | None = None
572
+ if items is not None:
573
+ if not isinstance(items, list):
574
+ return _failed(
575
+ "PACKAGE_ITEMS_INVALID",
576
+ "items must be a list",
577
+ normalized_args=normalized_args,
578
+ suggested_next_call=None,
579
+ )
580
+ layout_result = self._apply_package_items(
581
+ profile=profile,
582
+ package_id=effective_package_id,
583
+ items=items,
584
+ allow_detach=allow_detach,
585
+ normalized_args=normalized_args,
586
+ )
587
+ if layout_result.get("status") not in {"success", "partial_success"}:
588
+ return _apply_permission_outcomes(layout_result, *permission_outcomes)
589
+
590
+ verification = self.package_get(profile=profile, package_id=effective_package_id)
591
+ if verification.get("status") != "success":
592
+ return _apply_permission_outcomes(verification, *permission_outcomes)
593
+ expected_visibility = None
594
+ if visibility is not None:
595
+ try:
596
+ expected_visibility = _public_visibility_from_member_auth(
597
+ self._compile_visibility_to_member_auth(profile=profile, visibility=visibility)
598
+ )
599
+ except VisibilityResolutionError:
600
+ expected_visibility = None
601
+ response: JSONObject = {
602
+ "status": "success",
603
+ "error_code": None,
604
+ "recoverable": False,
605
+ "message": "applied package",
606
+ "normalized_args": normalized_args,
607
+ "missing_fields": [],
608
+ "allowed_values": {},
609
+ "details": {"layout_result": layout_result} if layout_result is not None else {},
610
+ "request_id": None,
611
+ "suggested_next_call": None,
612
+ "noop": not (created or metadata_requested or items is not None),
613
+ "warnings": [],
614
+ "verification": {
615
+ "package_exists": True,
616
+ "package_created": created,
617
+ "layout_applied": items is not None,
618
+ "visibility_verified": None
619
+ if expected_visibility is None
620
+ else _visibility_matches_expected(verification.get("visibility"), expected_visibility),
621
+ },
622
+ "verified": True,
623
+ **{
624
+ key: deepcopy(value)
625
+ for key, value in verification.items()
626
+ if key
627
+ not in {
628
+ "status",
629
+ "error_code",
630
+ "recoverable",
631
+ "message",
632
+ "normalized_args",
633
+ "missing_fields",
634
+ "allowed_values",
635
+ "details",
636
+ "request_id",
637
+ "suggested_next_call",
638
+ "noop",
639
+ "warnings",
640
+ "verification",
641
+ "verified",
642
+ }
643
+ },
454
644
  }
645
+ return _apply_permission_outcomes(response, *permission_outcomes)
455
646
 
456
647
  def package_update(
457
648
  self,
@@ -524,7 +715,7 @@ class AiBuilderFacade:
524
715
  details={"tag_id": tag_id},
525
716
  suggested_next_call={"tool_name": "package_update", "arguments": {"profile": profile, **normalized_args}},
526
717
  )
527
- verification = self.package_get(profile=profile, tag_id=tag_id)
718
+ verification = self.package_get(profile=profile, package_id=tag_id)
528
719
  if verification.get("status") != "success":
529
720
  return verification
530
721
  return {
@@ -648,6 +839,217 @@ class AiBuilderFacade:
648
839
  "retried": bool(listed.get("retried", False)),
649
840
  }
650
841
 
842
+ def _apply_package_items(
843
+ self,
844
+ *,
845
+ profile: str,
846
+ package_id: int,
847
+ items: list[dict[str, Any]],
848
+ allow_detach: bool,
849
+ normalized_args: JSONObject,
850
+ ) -> JSONObject:
851
+ current_result: JSONObject | None = None
852
+ try:
853
+ current_result = self.packages.package_get(profile=profile, tag_id=package_id, include_raw=True)
854
+ except (QingflowApiError, RuntimeError) as detail_error:
855
+ detail_api_error = _coerce_api_error(detail_error)
856
+ try:
857
+ current_result = self.packages.package_get_base(profile=profile, tag_id=package_id, include_raw=True)
858
+ except (QingflowApiError, RuntimeError) as base_error:
859
+ api_error = _coerce_api_error(base_error)
860
+ return _failed_from_api_error(
861
+ "PACKAGE_LAYOUT_READ_FAILED",
862
+ api_error,
863
+ normalized_args=normalized_args,
864
+ details={
865
+ "package_id": package_id,
866
+ "detail_read_error": _transport_error_payload(detail_api_error),
867
+ },
868
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
869
+ )
870
+ current_raw = current_result.get("result") if isinstance(current_result.get("result"), dict) else {}
871
+ raw_tag_items = current_raw.get("tagItems")
872
+ if not isinstance(raw_tag_items, list):
873
+ return _failed(
874
+ "PACKAGE_LAYOUT_UNREADABLE",
875
+ "package items could not be read safely",
876
+ normalized_args=normalized_args,
877
+ details={"package_id": package_id},
878
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
879
+ )
880
+ current_items = raw_tag_items
881
+
882
+ current_group_specs = _collect_backend_package_group_specs(current_items)
883
+ normalized_items, group_resolution_issues = _align_public_package_group_ids(items, current_group_specs=current_group_specs)
884
+ if group_resolution_issues:
885
+ return _failed(
886
+ "PACKAGE_GROUP_AMBIGUOUS",
887
+ "items contains group names that match multiple existing package groups; pass explicit group_id to disambiguate",
888
+ normalized_args=normalized_args,
889
+ details={"package_id": package_id, "issues": group_resolution_issues},
890
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
891
+ )
892
+
893
+ current_resources = _flatten_package_resource_identities(current_items, public=False)
894
+ desired_resources = _flatten_package_resource_identities(normalized_items, public=True)
895
+ missing_resources = sorted(current_resources - desired_resources)
896
+ if missing_resources and not allow_detach:
897
+ return _failed(
898
+ "PACKAGE_LAYOUT_ITEMS_MISSING",
899
+ "items omits existing apps or portals; pass allow_detach=true to remove them",
900
+ normalized_args=normalized_args,
901
+ details={"package_id": package_id, "missing_items": [{"type": item[0], "id": item[1]} for item in missing_resources]},
902
+ suggested_next_call=None,
903
+ )
904
+
905
+ duplicate_resources = _find_duplicate_package_resources(normalized_items)
906
+ if duplicate_resources:
907
+ return _failed(
908
+ "PACKAGE_LAYOUT_DUPLICATE_ITEM",
909
+ "items contains duplicate apps or portals",
910
+ normalized_args=normalized_args,
911
+ details={"duplicates": [{"type": item[0], "id": item[1]} for item in duplicate_resources]},
912
+ suggested_next_call=None,
913
+ )
914
+
915
+ current_groups = {int(spec["group_id"]): str(spec["name"] or "").strip() for spec in current_group_specs}
916
+ desired_groups = _collect_public_package_group_specs(normalized_items)
917
+ desired_group_ids = {
918
+ group_id for group_id in (_coerce_positive_int(group.get("group_id")) for group in desired_groups) if group_id is not None
919
+ }
920
+ deleted_group_ids = sorted(set(current_groups) - desired_group_ids)
921
+
922
+ permission_outcomes: list[PermissionCheckOutcome] = []
923
+ needs_group_create = any(_coerce_positive_int(group.get("group_id")) is None for group in desired_groups)
924
+ needs_group_delete = bool(deleted_group_ids)
925
+ needs_edit_app = bool(normalized_items)
926
+ for required_permission in (
927
+ (["add_app"] if needs_group_create else [])
928
+ + (["edit_app"] if needs_edit_app else [])
929
+ + (["delete_app"] if needs_group_delete else [])
930
+ ):
931
+ outcome = self._guard_package_permission(
932
+ profile=profile,
933
+ tag_id=package_id,
934
+ required_permission=required_permission,
935
+ normalized_args=normalized_args,
936
+ )
937
+ if outcome.block is not None:
938
+ return outcome.block
939
+ permission_outcomes.append(outcome)
940
+
941
+ group_ids_by_path: dict[tuple[int, ...], int] = {}
942
+ group_operations: list[JSONObject] = []
943
+ for group in desired_groups:
944
+ path = tuple(group.get("path") or ())
945
+ group_id = _coerce_positive_int(group.get("group_id"))
946
+ group_name = str(group.get("name") or "").strip()
947
+ if not group_name:
948
+ return _failed(
949
+ "PACKAGE_GROUP_NAME_REQUIRED",
950
+ "group items require name",
951
+ normalized_args=normalized_args,
952
+ details={"path": list(path)},
953
+ suggested_next_call=None,
954
+ )
955
+ if group_id is None:
956
+ try:
957
+ created = self.packages.package_group_create(profile=profile, tag_id=package_id, group_name=group_name)
958
+ except (QingflowApiError, RuntimeError) as error:
959
+ api_error = _coerce_api_error(error)
960
+ return _failed_from_api_error(
961
+ "PACKAGE_GROUP_CREATE_FAILED",
962
+ api_error,
963
+ normalized_args=normalized_args,
964
+ details={"package_id": package_id, "group_name": group_name},
965
+ suggested_next_call=None,
966
+ )
967
+ group_id = _extract_package_group_id(created)
968
+ if group_id is None:
969
+ return _failed(
970
+ "PACKAGE_GROUP_CREATE_UNVERIFIED",
971
+ "created package group but could not read group_id",
972
+ normalized_args=normalized_args,
973
+ details={"package_id": package_id, "group_name": group_name, "create_result": created},
974
+ suggested_next_call={"tool_name": "package_get", "arguments": {"profile": profile, "package_id": package_id}},
975
+ )
976
+ group_operations.append({"action": "create", "group_id": group_id, "name": group_name})
977
+ elif current_groups.get(group_id) != group_name:
978
+ try:
979
+ self.packages.package_group_update(profile=profile, tag_id=package_id, group_id=group_id, group_name=group_name)
980
+ except (QingflowApiError, RuntimeError) as error:
981
+ api_error = _coerce_api_error(error)
982
+ return _failed_from_api_error(
983
+ "PACKAGE_GROUP_UPDATE_FAILED",
984
+ api_error,
985
+ normalized_args=normalized_args,
986
+ details={"package_id": package_id, "group_id": group_id, "group_name": group_name},
987
+ suggested_next_call=None,
988
+ )
989
+ group_operations.append({"action": "update", "group_id": group_id, "name": group_name})
990
+ group_ids_by_path[path] = group_id
991
+
992
+ try:
993
+ backend_items = _backend_package_items_from_public_items(normalized_items, group_ids_by_path)
994
+ except ValueError as error:
995
+ return _failed(
996
+ "PACKAGE_LAYOUT_INVALID",
997
+ str(error),
998
+ normalized_args=normalized_args,
999
+ details={"package_id": package_id},
1000
+ suggested_next_call=None,
1001
+ )
1002
+
1003
+ try:
1004
+ sort_result = self.packages.package_sort_items(profile=profile, tag_id=package_id, tag_items=backend_items)
1005
+ except (QingflowApiError, RuntimeError) as error:
1006
+ api_error = _coerce_api_error(error)
1007
+ return _failed_from_api_error(
1008
+ "PACKAGE_LAYOUT_SORT_FAILED",
1009
+ api_error,
1010
+ normalized_args=normalized_args,
1011
+ details={"package_id": package_id},
1012
+ suggested_next_call=None,
1013
+ )
1014
+
1015
+ for group_id in deleted_group_ids:
1016
+ try:
1017
+ self.packages.package_group_delete(profile=profile, tag_id=package_id, group_id=group_id)
1018
+ except (QingflowApiError, RuntimeError) as error:
1019
+ api_error = _coerce_api_error(error)
1020
+ return _apply_permission_outcomes(
1021
+ _failed_from_api_error(
1022
+ "PACKAGE_GROUP_DELETE_FAILED",
1023
+ api_error,
1024
+ normalized_args=normalized_args,
1025
+ details={"package_id": package_id, "group_id": group_id},
1026
+ suggested_next_call=None,
1027
+ ),
1028
+ *permission_outcomes,
1029
+ )
1030
+ group_operations.append({"action": "delete", "group_id": group_id})
1031
+
1032
+ return _apply_permission_outcomes(
1033
+ {
1034
+ "status": "success",
1035
+ "error_code": None,
1036
+ "recoverable": False,
1037
+ "message": "applied package items",
1038
+ "normalized_args": normalized_args,
1039
+ "missing_fields": [],
1040
+ "allowed_values": {},
1041
+ "details": {"group_operations": group_operations, "sort_result": sort_result},
1042
+ "request_id": sort_result.get("request_id") if isinstance(sort_result, dict) else None,
1043
+ "suggested_next_call": None,
1044
+ "noop": False,
1045
+ "warnings": [],
1046
+ "verification": {"layout_applied": True},
1047
+ "verified": True,
1048
+ "package_id": package_id,
1049
+ },
1050
+ *permission_outcomes,
1051
+ )
1052
+
651
1053
  def member_search(self, *, profile: str, query: str, page_num: int = 1, page_size: int = 20, contain_disable: bool = False) -> JSONObject:
652
1054
  requested = str(query or "").strip()
653
1055
  normalized_args = {
@@ -2505,6 +2907,11 @@ class AiBuilderFacade:
2505
2907
  "PACKAGE_EDIT_APP_UNAUTHORIZED",
2506
2908
  "current user does not have package edit-app permission on this package",
2507
2909
  ),
2910
+ "delete_app": (
2911
+ "can_delete_app",
2912
+ "PACKAGE_DELETE_APP_UNAUTHORIZED",
2913
+ "current user does not have package delete-app permission on this package",
2914
+ ),
2508
2915
  "edit_tag": (
2509
2916
  "can_edit_tag",
2510
2917
  "PACKAGE_EDIT_TAG_UNAUTHORIZED",
@@ -11904,6 +12311,358 @@ def _tag_items_include_app(tag_items: Any, app_key: str) -> bool:
11904
12311
  return any(str(item.get("appKey") or "") == app_key for item in tag_items if isinstance(item, dict))
11905
12312
 
11906
12313
 
12314
+ def _public_package_items_from_tag_items(tag_items: Any) -> list[JSONObject]:
12315
+ if not isinstance(tag_items, list):
12316
+ return []
12317
+ public_items: list[JSONObject] = []
12318
+ for item in tag_items:
12319
+ if not isinstance(item, dict):
12320
+ continue
12321
+ item_type = _coerce_positive_int(item.get("itemType"))
12322
+ if item_type == 1:
12323
+ app_key = str(item.get("appKey") or "").strip()
12324
+ if not app_key:
12325
+ continue
12326
+ public_items.append(
12327
+ _compact_dict(
12328
+ {
12329
+ "type": "app",
12330
+ "app_key": app_key,
12331
+ "title": str(item.get("title") or item.get("formTitle") or "").strip() or None,
12332
+ "form_id": item.get("formId"),
12333
+ }
12334
+ )
12335
+ )
12336
+ continue
12337
+ if item_type == 2:
12338
+ dash_key = str(item.get("dashKey") or item.get("pageKey") or "").strip()
12339
+ if not dash_key:
12340
+ continue
12341
+ public_items.append(
12342
+ _compact_dict(
12343
+ {
12344
+ "type": "portal",
12345
+ "dash_key": dash_key,
12346
+ "title": str(item.get("title") or item.get("dashName") or "").strip() or None,
12347
+ }
12348
+ )
12349
+ )
12350
+ continue
12351
+ if item_type == 3:
12352
+ group_id = _coerce_positive_int(item.get("groupId"))
12353
+ public_items.append(
12354
+ _compact_dict(
12355
+ {
12356
+ "type": "group",
12357
+ "group_id": group_id,
12358
+ "name": str(item.get("title") or item.get("groupName") or "").strip() or None,
12359
+ "items": _public_package_items_from_tag_items(item.get("subItems")),
12360
+ }
12361
+ )
12362
+ )
12363
+ return public_items
12364
+
12365
+
12366
+ def _flatten_package_resource_identities(items: Any, *, public: bool) -> set[tuple[str, str]]:
12367
+ flattened: set[tuple[str, str]] = set()
12368
+
12369
+ def walk(value: Any) -> None:
12370
+ if isinstance(value, list):
12371
+ for child in value:
12372
+ walk(child)
12373
+ return
12374
+ if not isinstance(value, dict):
12375
+ return
12376
+ if public:
12377
+ item_type = str(value.get("type") or "").strip().lower()
12378
+ if item_type == "app" or value.get("app_key") or value.get("appKey"):
12379
+ app_key = str(value.get("app_key") or value.get("appKey") or "").strip()
12380
+ if app_key:
12381
+ flattened.add(("app", app_key))
12382
+ elif item_type == "portal" or value.get("dash_key") or value.get("dashKey"):
12383
+ dash_key = str(value.get("dash_key") or value.get("dashKey") or "").strip()
12384
+ if dash_key:
12385
+ flattened.add(("portal", dash_key))
12386
+ walk(value.get("items"))
12387
+ return
12388
+ item_type = _coerce_positive_int(value.get("itemType"))
12389
+ if item_type == 1:
12390
+ app_key = str(value.get("appKey") or "").strip()
12391
+ if app_key:
12392
+ flattened.add(("app", app_key))
12393
+ elif item_type == 2:
12394
+ dash_key = str(value.get("dashKey") or value.get("pageKey") or "").strip()
12395
+ if dash_key:
12396
+ flattened.add(("portal", dash_key))
12397
+ walk(value.get("subItems"))
12398
+
12399
+ walk(items)
12400
+ return flattened
12401
+
12402
+
12403
+ def _find_duplicate_package_resources(items: Any) -> list[tuple[str, str]]:
12404
+ seen: set[tuple[str, str]] = set()
12405
+ duplicates: set[tuple[str, str]] = set()
12406
+
12407
+ def walk(value: Any) -> None:
12408
+ if isinstance(value, list):
12409
+ for child in value:
12410
+ walk(child)
12411
+ return
12412
+ if not isinstance(value, dict):
12413
+ return
12414
+ item_type = str(value.get("type") or "").strip().lower()
12415
+ identity: tuple[str, str] | None = None
12416
+ if item_type == "app" or value.get("app_key") or value.get("appKey"):
12417
+ app_key = str(value.get("app_key") or value.get("appKey") or "").strip()
12418
+ if app_key:
12419
+ identity = ("app", app_key)
12420
+ elif item_type == "portal" or value.get("dash_key") or value.get("dashKey"):
12421
+ dash_key = str(value.get("dash_key") or value.get("dashKey") or "").strip()
12422
+ if dash_key:
12423
+ identity = ("portal", dash_key)
12424
+ if identity is not None:
12425
+ if identity in seen:
12426
+ duplicates.add(identity)
12427
+ seen.add(identity)
12428
+ walk(value.get("items"))
12429
+
12430
+ walk(items)
12431
+ return sorted(duplicates)
12432
+
12433
+
12434
+ def _collect_backend_package_groups(tag_items: Any) -> dict[int, str]:
12435
+ return {
12436
+ int(spec["group_id"]): str(spec["name"] or "").strip()
12437
+ for spec in _collect_backend_package_group_specs(tag_items)
12438
+ if _coerce_positive_int(spec.get("group_id")) is not None
12439
+ }
12440
+
12441
+
12442
+ def _collect_backend_package_group_specs(tag_items: Any, *, path: tuple[int, ...] = ()) -> list[JSONObject]:
12443
+ if not isinstance(tag_items, list):
12444
+ return []
12445
+ groups: list[JSONObject] = []
12446
+ for index, item in enumerate(tag_items):
12447
+ if not isinstance(item, dict):
12448
+ continue
12449
+ item_type = _coerce_positive_int(item.get("itemType"))
12450
+ child_path = (*path, index)
12451
+ if item_type == 3:
12452
+ group_id = _coerce_positive_int(item.get("groupId"))
12453
+ if group_id is None:
12454
+ continue
12455
+ child_items = item.get("subItems") if isinstance(item.get("subItems"), list) else []
12456
+ groups.append(
12457
+ {
12458
+ "path": list(child_path),
12459
+ "group_id": group_id,
12460
+ "name": str(item.get("title") or item.get("groupName") or "").strip(),
12461
+ "resource_signature": _package_resource_signature(child_items, public=False),
12462
+ }
12463
+ )
12464
+ groups.extend(_collect_backend_package_group_specs(child_items, path=child_path))
12465
+ return groups
12466
+
12467
+
12468
+ def _collect_public_package_group_specs(items: Any, *, path: tuple[int, ...] = ()) -> list[JSONObject]:
12469
+ if not isinstance(items, list):
12470
+ return []
12471
+ groups: list[JSONObject] = []
12472
+ for index, item in enumerate(items):
12473
+ if not isinstance(item, dict):
12474
+ continue
12475
+ item_type = str(item.get("type") or "").strip().lower()
12476
+ child_path = (*path, index)
12477
+ if item_type == "group" or isinstance(item.get("items"), list):
12478
+ groups.append(
12479
+ {
12480
+ "path": list(child_path),
12481
+ "group_id": _coerce_positive_int(item.get("group_id") or item.get("groupId")),
12482
+ "name": str(item.get("name") or item.get("title") or item.get("group_name") or "").strip(),
12483
+ }
12484
+ )
12485
+ groups.extend(_collect_public_package_group_specs(item.get("items"), path=child_path))
12486
+ return groups
12487
+
12488
+
12489
+ def _align_public_package_group_ids(
12490
+ items: list[dict[str, Any]],
12491
+ *,
12492
+ current_group_specs: list[JSONObject],
12493
+ ) -> tuple[list[dict[str, Any]], list[JSONObject]]:
12494
+ normalized_items = deepcopy(items)
12495
+ used_group_ids: set[int] = set()
12496
+ issues: list[JSONObject] = []
12497
+
12498
+ def walk(nodes: list[dict[str, Any]], *, path: tuple[int, ...] = ()) -> None:
12499
+ for index, node in enumerate(nodes):
12500
+ if not isinstance(node, dict):
12501
+ continue
12502
+ item_type = str(node.get("type") or "").strip().lower()
12503
+ child_path = (*path, index)
12504
+ child_items = node.get("items") if isinstance(node.get("items"), list) else []
12505
+ if item_type == "group" or isinstance(node.get("items"), list):
12506
+ explicit_group_id = _coerce_positive_int(node.get("group_id") or node.get("groupId"))
12507
+ if explicit_group_id is not None:
12508
+ node["group_id"] = explicit_group_id
12509
+ used_group_ids.add(explicit_group_id)
12510
+ else:
12511
+ group_name = str(node.get("name") or node.get("title") or node.get("group_name") or "").strip()
12512
+ matched_group, ambiguity = _match_existing_package_group(
12513
+ group_name=group_name,
12514
+ child_items=child_items,
12515
+ path=child_path,
12516
+ current_group_specs=current_group_specs,
12517
+ used_group_ids=used_group_ids,
12518
+ )
12519
+ if ambiguity is not None:
12520
+ issues.append(ambiguity)
12521
+ elif matched_group is not None:
12522
+ matched_group_id = _coerce_positive_int(matched_group.get("group_id"))
12523
+ if matched_group_id is not None:
12524
+ node["group_id"] = matched_group_id
12525
+ used_group_ids.add(matched_group_id)
12526
+ walk(child_items, path=child_path)
12527
+
12528
+ walk(normalized_items)
12529
+ return normalized_items, issues
12530
+
12531
+
12532
+ def _match_existing_package_group(
12533
+ *,
12534
+ group_name: str,
12535
+ child_items: list[dict[str, Any]],
12536
+ path: tuple[int, ...],
12537
+ current_group_specs: list[JSONObject],
12538
+ used_group_ids: set[int],
12539
+ ) -> tuple[JSONObject | None, JSONObject | None]:
12540
+ desired_signature = _package_resource_signature(child_items, public=True)
12541
+ candidates = [
12542
+ spec
12543
+ for spec in current_group_specs
12544
+ if _coerce_positive_int(spec.get("group_id")) is not None
12545
+ and int(spec["group_id"]) not in used_group_ids
12546
+ and str(spec.get("name") or "").strip() == group_name
12547
+ ]
12548
+ exact_matches = [spec for spec in candidates if spec.get("resource_signature") == desired_signature]
12549
+ if len(exact_matches) == 1:
12550
+ return exact_matches[0], None
12551
+ path_matches = [spec for spec in candidates if tuple(spec.get("path") or ()) == path]
12552
+ if len(path_matches) == 1:
12553
+ return path_matches[0], None
12554
+ if len(candidates) == 1:
12555
+ return candidates[0], None
12556
+ ambiguous_matches = exact_matches if len(exact_matches) > 1 else candidates if len(candidates) > 1 else []
12557
+ if ambiguous_matches:
12558
+ return None, {
12559
+ "path": list(path),
12560
+ "name": group_name,
12561
+ "candidate_group_ids": [
12562
+ int(spec["group_id"])
12563
+ for spec in ambiguous_matches
12564
+ if _coerce_positive_int(spec.get("group_id")) is not None
12565
+ ],
12566
+ }
12567
+ return None, None
12568
+
12569
+
12570
+ def _package_resource_signature(items: Any, *, public: bool) -> tuple[tuple[str, str], ...]:
12571
+ return tuple(sorted(_flatten_package_resource_identities(items, public=public)))
12572
+
12573
+
12574
+ 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]:
12575
+ backend_items: list[JSONObject] = []
12576
+ for index, item in enumerate(items):
12577
+ if not isinstance(item, dict):
12578
+ raise ValueError(f"items[{index}] must be an object")
12579
+ item_type = str(item.get("type") or "").strip().lower()
12580
+ child_path = (*path, index)
12581
+ if item_type == "app" or item.get("app_key") or item.get("appKey"):
12582
+ app_key = str(item.get("app_key") or item.get("appKey") or "").strip()
12583
+ if not app_key:
12584
+ raise ValueError(f"items[{index}].app_key is required")
12585
+ backend_items.append(
12586
+ _compact_dict(
12587
+ {
12588
+ "itemType": 1,
12589
+ "appKey": app_key,
12590
+ "title": str(item.get("title") or item.get("name") or "").strip() or None,
12591
+ "formId": item.get("form_id") or item.get("formId"),
12592
+ }
12593
+ )
12594
+ )
12595
+ continue
12596
+ if item_type == "portal" or item.get("dash_key") or item.get("dashKey"):
12597
+ dash_key = str(item.get("dash_key") or item.get("dashKey") or "").strip()
12598
+ if not dash_key:
12599
+ raise ValueError(f"items[{index}].dash_key is required")
12600
+ backend_items.append(
12601
+ _compact_dict(
12602
+ {
12603
+ "itemType": 2,
12604
+ "dashKey": dash_key,
12605
+ "title": str(item.get("title") or item.get("name") or "").strip() or None,
12606
+ }
12607
+ )
12608
+ )
12609
+ continue
12610
+ if item_type == "group" or isinstance(item.get("items"), list):
12611
+ group_id = group_ids_by_path.get(child_path) or _coerce_positive_int(item.get("group_id") or item.get("groupId"))
12612
+ if group_id is None:
12613
+ raise ValueError(f"items[{index}].group_id is required after group creation")
12614
+ group_name = str(item.get("name") or item.get("title") or item.get("group_name") or "").strip()
12615
+ if not group_name:
12616
+ raise ValueError(f"items[{index}].name is required")
12617
+ child_items = item.get("items") if isinstance(item.get("items"), list) else []
12618
+ backend_items.append(
12619
+ {
12620
+ "itemType": 3,
12621
+ "groupId": group_id,
12622
+ "title": group_name,
12623
+ "subItems": _backend_package_items_from_public_items(child_items, group_ids_by_path, path=child_path),
12624
+ }
12625
+ )
12626
+ continue
12627
+ raise ValueError(f"items[{index}].type must be app, portal, or group")
12628
+ return backend_items
12629
+
12630
+
12631
+ def _extract_package_group_id(value: Any) -> int | None:
12632
+ if isinstance(value, dict):
12633
+ direct = _coerce_positive_int(value.get("groupId") or value.get("group_id") or value.get("id"))
12634
+ if direct is not None:
12635
+ return direct
12636
+ for nested_key in ("result", "data", "group"):
12637
+ nested = value.get(nested_key)
12638
+ nested_id = _extract_package_group_id(nested)
12639
+ if nested_id is not None:
12640
+ return nested_id
12641
+ if isinstance(value, list):
12642
+ for item in value:
12643
+ nested_id = _extract_package_group_id(item)
12644
+ if nested_id is not None:
12645
+ return nested_id
12646
+ return None
12647
+
12648
+
12649
+ def _publicize_package_apply_failure(result: JSONObject, *, profile: str, normalized_args: JSONObject) -> JSONObject:
12650
+ public_result = deepcopy(result)
12651
+ public_result["normalized_args"] = deepcopy(normalized_args)
12652
+ suggested = public_result.get("suggested_next_call")
12653
+ if isinstance(suggested, dict):
12654
+ public_result["suggested_next_call"] = {
12655
+ "tool_name": "package_apply",
12656
+ "arguments": {"profile": profile, **deepcopy(normalized_args)},
12657
+ }
12658
+ for key in ("tag_id", "tag_name", "tag_icon"):
12659
+ public_result.pop(key, None)
12660
+ details = public_result.get("details")
12661
+ if isinstance(details, dict) and "tag_id" in details:
12662
+ details["package_id"] = details.pop("tag_id")
12663
+ return public_result
12664
+
12665
+
11907
12666
  def _verify_package_attachment(packages: PackageTools, *, profile: str, tag_id: int, app_key: str, attempts: int = 2) -> JSONObject:
11908
12667
  last_result: JSONObject = {"result": {}}
11909
12668
  for _ in range(max(attempts, 1)):