@qingflow-tech/qingflow-cli 1.1.48 → 1.1.50-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +29 -13
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/skills/qingflow-cli/SKILL.md +92 -410
  6. package/skills/qingflow-cli/manifest.yaml +1 -1
  7. package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  16. package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +13 -5
  17. package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +5 -3
  18. package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +1 -1
  19. package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +1 -1
  20. package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1665 -1738
  21. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  22. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  23. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  27. package/skills/qingflow-cli/reference/data/README.md +32 -0
  28. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
  29. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  30. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  31. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  32. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  33. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  34. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  35. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  36. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  37. package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +48 -46
  38. package/skills/qingflow-common/SKILL.md +10 -17
  39. package/src/qingflow_mcp/backend_client.py +142 -12
  40. package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
  41. package/src/qingflow_mcp/builder_facade/service.py +762 -6
  42. package/src/qingflow_mcp/builder_facade/workflow_spec.py +154 -2
  43. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  44. package/src/qingflow_mcp/cli/commands/auth.py +1 -1
  45. package/src/qingflow_mcp/cli/commands/builder.py +57 -41
  46. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  47. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  48. package/src/qingflow_mcp/cli/commands/task.py +10 -12
  49. package/src/qingflow_mcp/cli/commands/workspace.py +20 -7
  50. package/src/qingflow_mcp/cli/context.py +4 -1
  51. package/src/qingflow_mcp/cli/formatters.py +177 -21
  52. package/src/qingflow_mcp/cli/main.py +4 -1
  53. package/src/qingflow_mcp/cli/pagination.py +44 -0
  54. package/src/qingflow_mcp/config.py +60 -0
  55. package/src/qingflow_mcp/public_surface.py +9 -0
  56. package/src/qingflow_mcp/resource_uri.py +241 -0
  57. package/src/qingflow_mcp/response_trim.py +56 -5
  58. package/src/qingflow_mcp/server.py +2 -0
  59. package/src/qingflow_mcp/server_app_builder.py +57 -2
  60. package/src/qingflow_mcp/server_app_user.py +10 -3
  61. package/src/qingflow_mcp/tools/ai_builder_tools.py +374 -41
  62. package/src/qingflow_mcp/tools/app_tools.py +52 -13
  63. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  64. package/src/qingflow_mcp/tools/data_tools.py +284 -0
  65. package/src/qingflow_mcp/tools/login_link_tools.py +34 -6
  66. package/src/qingflow_mcp/tools/record_tools.py +85 -61
  67. package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
  68. package/src/qingflow_mcp/tools/task_context_tools.py +21 -4
  69. package/src/qingflow_mcp/tools/workspace_tools.py +9 -12
@@ -16,9 +16,11 @@ from urllib.parse import quote_plus, unquote_plus
16
16
  from uuid import uuid4
17
17
 
18
18
  from ..backend_client import BackendRequestContext
19
+ from ..config import get_flow_edit_maintenance_threshold
19
20
  from ..errors import QingflowApiError, backend_code_int, backend_code_value_int, is_auth_like_error
20
21
  from ..json_types import JSONObject
21
22
  from ..list_type_labels import RECORD_LIST_TYPE_LABELS, SYSTEM_VIEW_ID_TO_LIST_TYPE
23
+ from ..resource_uri import validate_positive_resource_id, validate_resource_identity
22
24
  from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
23
25
  from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
24
26
  from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
@@ -36,6 +38,7 @@ from ..tools.role_tools import RoleTools
36
38
  from ..tools.solution_tools import SolutionTools
37
39
  from ..tools.view_tools import ViewTools
38
40
  from ..tools.workflow_tools import WorkflowTools
41
+ from . import flow_validation
39
42
  from . import workflow_spec as workflow_spec_api
40
43
  from .button_style_catalog import button_style_catalog_payload
41
44
  from .models import (
@@ -393,10 +396,370 @@ class AiBuilderFacade:
393
396
  "verified": True,
394
397
  "app_key": app_key,
395
398
  }
396
- if isinstance(payload, dict):
397
- response.update(payload)
399
+ # 受控提取(#162 D7/D8):不再裸 response.update(payload),避免后端驼峰键
400
+ # 与后端大写 status="SUCCESS" 污染桥接层信封(顶层只保留 spec 本体 + 白名单
401
+ # 蛇形元数据键);spec 缺失/为空时显式失败(对齐评测口径 EMPTY_SPEC)。
402
+ spec = payload.get("spec") if isinstance(payload, dict) else None
403
+ if isinstance(spec, dict) and spec:
404
+ response["spec"] = spec
405
+ for snake_key, camel_key in (
406
+ ("source", "source"),
407
+ ("schema_version", "schemaVersion"),
408
+ ("version_id", "versionId"),
409
+ ("snapshot_type", "snapshotType"),
410
+ ("mapping_summary", "mappingSummary"),
411
+ ("draft_version", "draftVersion"),
412
+ ):
413
+ if camel_key in payload:
414
+ response[snake_key] = payload[camel_key]
415
+ elif isinstance(payload, dict) and payload and "nodes" in payload:
416
+ # 兑底:spec 直接在 data 根级(评测脚本同口径)
417
+ response["spec"] = payload
418
+ else:
419
+ return _failed(
420
+ "FLOW_SPEC_GET_FAILED",
421
+ "no data.spec in response",
422
+ normalized_args=normalized_args,
423
+ details={"app_key": app_key},
424
+ suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
425
+ backend_code="EMPTY_SPEC",
426
+ )
427
+ return _apply_permission_outcomes(response, permission_outcome)
428
+
429
+ def flow_validate(
430
+ self,
431
+ *,
432
+ profile: str,
433
+ spec: dict[str, Any],
434
+ schema_version: str | None = None,
435
+ ) -> JSONObject:
436
+ """离线校验 WorkflowSpec(apply 前预检 / dry-run)。
437
+
438
+ 唯一网络调用是 schema 拉取(GET /api/workflow/spec/schema),
439
+ 绝不 POST / 写;服务端写面全量语义校验(字段引用存在性等)超出
440
+ 离线能力,作为已知限制记录,后端真 dryRun 需要时另立后端 issue。
441
+ """
442
+ effective_schema_version = schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION
443
+ normalized_args = {"spec": spec, "schema_version": effective_schema_version}
444
+ try:
445
+ schema_payload = self._with_backend_context(
446
+ profile,
447
+ lambda context: workflow_spec_api.fetch_schema(
448
+ self.apps.backend,
449
+ context,
450
+ schema_version=schema_version,
451
+ ),
452
+ )
453
+ except (QingflowApiError, RuntimeError) as error:
454
+ api_error = _coerce_api_error(error)
455
+ failure = _failed_from_api_error(
456
+ "FLOW_SPEC_SCHEMA_FAILED",
457
+ api_error,
458
+ normalized_args=normalized_args,
459
+ details={},
460
+ suggested_next_call={"tool_name": "app_flow_get_schema", "arguments": {"profile": profile, **normalized_args}},
461
+ )
462
+ failure.update({
463
+ "category": "validation",
464
+ "ok": False,
465
+ "issues": [str(failure.get("message") or "workflow spec schema fetch failed")],
466
+ "schema_version": effective_schema_version,
467
+ })
468
+ return failure
469
+ if not isinstance(schema_payload, dict):
470
+ schema_payload = {}
471
+ result = flow_validation.validate_spec(spec if isinstance(spec, dict) else {}, schema_payload)
472
+ ok = result.ok
473
+ return {
474
+ "category": "validation",
475
+ "status": "success" if ok else "failed",
476
+ "error_code": None if ok else "FLOW_SPEC_INVALID",
477
+ "recoverable": True,
478
+ "ok": ok,
479
+ "message": (
480
+ "workflow spec passed offline validation"
481
+ if ok
482
+ else f"workflow spec failed offline validation with {len(result.issues)} issue(s)"
483
+ ),
484
+ "normalized_args": normalized_args,
485
+ "missing_fields": [],
486
+ "allowed_values": {},
487
+ "details": {
488
+ "issue_count": len(result.issues),
489
+ "warning_count": len(result.warnings),
490
+ "node_count": result.node_count,
491
+ "edge_count": result.edge_count,
492
+ "write_executed": False,
493
+ },
494
+ "request_id": None,
495
+ "suggested_next_call": (
496
+ None
497
+ if ok
498
+ else {"tool_name": "app_flow_get_schema", "arguments": {"profile": profile}}
499
+ ),
500
+ "noop": True,
501
+ "warnings": result.warnings,
502
+ "issues": result.issues,
503
+ "node_count": result.node_count,
504
+ "edge_count": result.edge_count,
505
+ "schema_version": effective_schema_version,
506
+ "write_executed": False,
507
+ "safe_to_retry": True,
508
+ }
509
+
510
+ def flow_versions(
511
+ self,
512
+ *,
513
+ profile: str,
514
+ app_key: str,
515
+ ) -> JSONObject:
516
+ """列出某应用的 WorkflowSpec 版本快照(GET /api/workflow/spec/versions)。"""
517
+ normalized_args = {"app_key": app_key}
518
+ permission_outcome = self._guard_app_permission(
519
+ profile=profile,
520
+ app_key=app_key,
521
+ required_permission="data_manage",
522
+ normalized_args=normalized_args,
523
+ )
524
+ if permission_outcome.block is not None:
525
+ return permission_outcome.block
526
+ try:
527
+ payload = self._with_backend_context(
528
+ profile,
529
+ lambda context: workflow_spec_api.fetch_versions(
530
+ self.apps.backend,
531
+ context,
532
+ app_key=app_key,
533
+ ),
534
+ )
535
+ except (QingflowApiError, RuntimeError) as error:
536
+ api_error = _coerce_api_error(error)
537
+ return _failed_from_api_error(
538
+ "FLOW_VERSIONS_FAILED",
539
+ api_error,
540
+ normalized_args=normalized_args,
541
+ details={"app_key": app_key},
542
+ suggested_next_call={"tool_name": "app_flow_list_versions", "arguments": {"profile": profile, "app_key": app_key}},
543
+ )
544
+ entries = workflow_spec_api.normalize_version_entries(payload)
545
+ items = [_public_flow_version_entry(entry) for entry in entries]
546
+ response = {
547
+ "status": "success",
548
+ "error_code": None,
549
+ "recoverable": False,
550
+ "message": "listed workflow spec versions",
551
+ "normalized_args": normalized_args,
552
+ "missing_fields": [],
553
+ "allowed_values": {},
554
+ "details": {"versions": entries},
555
+ "request_id": None,
556
+ "suggested_next_call": None,
557
+ "noop": False,
558
+ "warnings": list(permission_outcome.warnings),
559
+ "verification": {"versions_loaded": True, "count": len(items)},
560
+ "verified": True,
561
+ "app_key": app_key,
562
+ "items": items,
563
+ "count": len(items),
564
+ }
565
+ return _apply_permission_outcomes(response, permission_outcome)
566
+
567
+ def flow_restore_version(
568
+ self,
569
+ *,
570
+ profile: str,
571
+ app_key: str,
572
+ version_id: str,
573
+ idempotency_key: str | None = None,
574
+ schema_version: str | None = None,
575
+ ) -> JSONObject:
576
+ """回滚到指定版本快照(POST /api/workflow/spec:restore)。
577
+
578
+ 链路:权限(data_manage) → 目标版本预读(fetch_spec version_id)→
579
+ 维护闸门(契约 1 对齐:目标版本节点数 > 阈值拦截)→ POST restore →
580
+ 回读当前 spec 与目标版本比对。restore 是草稿域写操作,不自动发布。
581
+ """
582
+ normalized_args = {
583
+ "app_key": app_key,
584
+ "version_id": version_id,
585
+ "idempotency_key": idempotency_key,
586
+ "schema_version": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION,
587
+ }
588
+ permission_outcome = self._guard_app_permission(
589
+ profile=profile,
590
+ app_key=app_key,
591
+ required_permission="data_manage",
592
+ normalized_args=normalized_args,
593
+ )
594
+ if permission_outcome.block is not None:
595
+ return permission_outcome.block
596
+ requested_version_id = str(version_id or "").strip()
597
+ if not requested_version_id:
598
+ return _apply_permission_outcomes(
599
+ _failed(
600
+ "FLOW_VERSION_ID_REQUIRED",
601
+ "version_id is required for flow restore",
602
+ normalized_args=normalized_args,
603
+ missing_fields=["version_id"],
604
+ suggested_next_call={"tool_name": "app_flow_list_versions", "arguments": {"profile": profile, "app_key": app_key}},
605
+ ),
606
+ permission_outcome,
607
+ )
608
+ # 目标版本预读:闸门判定 + 回滚后回读比对基线(复用既有单点读)
609
+ try:
610
+ target_flow = self._with_backend_context(
611
+ profile,
612
+ lambda context: workflow_spec_api.fetch_spec(
613
+ self.apps.backend,
614
+ context,
615
+ app_key=app_key,
616
+ version_id=requested_version_id,
617
+ ),
618
+ )
619
+ except (QingflowApiError, RuntimeError) as error:
620
+ api_error = _coerce_api_error(error)
621
+ not_found = api_error.http_status == 404 or "not found" in str(api_error.message or "").lower()
622
+ return _apply_permission_outcomes(
623
+ _failed_from_api_error(
624
+ "FLOW_VERSION_NOT_FOUND" if not_found else "FLOW_RESTORE_FAILED",
625
+ api_error,
626
+ normalized_args=normalized_args,
627
+ details={"app_key": app_key, "version_id": requested_version_id},
628
+ suggested_next_call={"tool_name": "app_flow_list_versions", "arguments": {"profile": profile, "app_key": app_key}},
629
+ ),
630
+ permission_outcome,
631
+ )
632
+ target_spec = target_flow.get("spec") if isinstance(target_flow, dict) else None
633
+ # 维护闸门(契约 1):restore 后流程即目标版本形态,按目标版本节点数判定
634
+ gate_block = _flow_edit_maintenance_gate(
635
+ {"spec": target_spec} if isinstance(target_spec, dict) else {},
636
+ app_key=app_key,
637
+ normalized_args=normalized_args,
638
+ )
639
+ if gate_block is not None:
640
+ return _apply_permission_outcomes(gate_block, permission_outcome)
641
+ restore_body = {
642
+ "appKey": app_key,
643
+ "versionId": requested_version_id,
644
+ "idempotencyKey": idempotency_key or str(uuid4()),
645
+ "schemaVersion": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION,
646
+ }
647
+ try:
648
+ restore_result = self._with_backend_context(
649
+ profile,
650
+ lambda context: workflow_spec_api.post_restore(
651
+ self.apps.backend,
652
+ context,
653
+ restore_body=restore_body,
654
+ ),
655
+ )
656
+ except (QingflowApiError, RuntimeError) as error:
657
+ api_error = _coerce_api_error(error)
658
+ return _apply_permission_outcomes(
659
+ _failed_from_api_error(
660
+ "FLOW_RESTORE_FAILED",
661
+ api_error,
662
+ normalized_args=normalized_args,
663
+ details={"app_key": app_key, "version_id": requested_version_id},
664
+ suggested_next_call={"tool_name": "app_flow_list_versions", "arguments": {"profile": profile, "app_key": app_key}},
665
+ ),
666
+ permission_outcome,
667
+ )
668
+ if not isinstance(restore_result, dict):
669
+ restore_result = {"result": restore_result}
670
+ backend_status = str(restore_result.get("status") or "SUCCESS").upper()
671
+ if backend_status not in {"SUCCESS", "OK"}:
672
+ return _apply_permission_outcomes(
673
+ _failed(
674
+ "FLOW_RESTORE_FAILED",
675
+ str(restore_result.get("message") or "workflow spec restore failed"),
676
+ normalized_args=normalized_args,
677
+ details={"app_key": app_key, "version_id": requested_version_id, "restore_result": restore_result},
678
+ suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
679
+ ),
680
+ permission_outcome,
681
+ )
682
+ # 回读确认:当前 spec 应与目标版本一致(验收 2)
683
+ warnings: list[dict[str, Any]] = []
684
+ verification: JSONObject = {"restore_post_succeeded": True}
685
+ verified: bool | None = None
686
+ try:
687
+ readback = self._with_backend_context(
688
+ profile,
689
+ lambda context: workflow_spec_api.fetch_spec(
690
+ self.apps.backend,
691
+ context,
692
+ app_key=app_key,
693
+ ),
694
+ )
695
+ readback_spec = readback.get("spec") if isinstance(readback, dict) else None
696
+ verified = isinstance(readback_spec, dict) and readback_spec == target_spec
697
+ verification["readback_matches_target"] = verified
698
+ if verified:
699
+ verification["restored_spec_matches_target"] = True
700
+ else:
701
+ verification["restored_spec_matches_target"] = False
702
+ warnings.append(
703
+ {
704
+ "code": "FLOW_RESTORE_VERIFY_FAILED",
705
+ "message": "readback spec does not match the target version snapshot",
706
+ }
707
+ )
708
+ except (QingflowApiError, RuntimeError) as error:
709
+ api_error = _coerce_api_error(error)
710
+ verification["readback_matches_target"] = None
711
+ verification["readback_error"] = str(api_error.message or "readback failed")
712
+ warnings.append(
713
+ {
714
+ "code": "FLOW_RESTORE_READBACK_UNAVAILABLE",
715
+ "message": "restore succeeded but readback verification is unavailable",
716
+ }
717
+ )
718
+ if verified is True:
719
+ status = "success"
720
+ error_code = None
721
+ message = "restored workflow spec to target version"
722
+ recoverable = False
723
+ suggested_next_call = None
724
+ elif verified is False:
725
+ status = "partial_success"
726
+ error_code = "FLOW_RESTORE_VERIFY_FAILED"
727
+ message = "restored workflow spec; readback does not match the target version"
728
+ recoverable = True
729
+ suggested_next_call = {"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}}
398
730
  else:
399
- response["result"] = payload
731
+ status = "partial_success"
732
+ error_code = "FLOW_RESTORE_READBACK_UNAVAILABLE"
733
+ message = "restored workflow spec; readback verification unavailable"
734
+ recoverable = True
735
+ suggested_next_call = {"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}}
736
+ response = {
737
+ "status": status,
738
+ "error_code": error_code,
739
+ "recoverable": recoverable,
740
+ "message": message,
741
+ "normalized_args": normalized_args,
742
+ "missing_fields": [],
743
+ "allowed_values": {},
744
+ "details": {
745
+ "app_key": app_key,
746
+ "version_id": requested_version_id,
747
+ "restore_result": restore_result,
748
+ },
749
+ "request_id": None,
750
+ "suggested_next_call": suggested_next_call,
751
+ "noop": False,
752
+ "warnings": warnings,
753
+ "verification": verification,
754
+ "verified": verified is True,
755
+ "app_key": app_key,
756
+ "restored_from_version_id": requested_version_id,
757
+ "current_version_id": restore_result.get("currentVersionId"),
758
+ "draft_version": restore_result.get("draftVersion"),
759
+ "write_executed": True,
760
+ "write_succeeded": True,
761
+ "safe_to_retry": False,
762
+ }
400
763
  return _apply_permission_outcomes(response, permission_outcome)
401
764
 
402
765
  def flow_apply(
@@ -408,6 +771,58 @@ class AiBuilderFacade:
408
771
  publish: bool = True,
409
772
  idempotency_key: str | None = None,
410
773
  schema_version: str | None = None,
774
+ confirm_ticket: str | None = None,
775
+ base_version: int | None = None,
776
+ _prefetched_current_flow: JSONObject | None = None,
777
+ ) -> JSONObject:
778
+ """Apply a complete WorkflowSpec, retrying once on transient business codes.
779
+
780
+ 评测沉淀的瞬态业务码重试回流(eval_replay_flow._TRANSIENT_CODES 同款,
781
+ 视图基准 PERMISSION 318 教训):apply 主调用失败且 error_code 属于白名单时
782
+ sleep 后重试 1 次;闸门失败 / 权限失败 / 输入校验失败不在白名单,不重试。
783
+ """
784
+ response = self._flow_apply_core(
785
+ profile=profile,
786
+ app_key=app_key,
787
+ spec=spec,
788
+ publish=publish,
789
+ idempotency_key=idempotency_key,
790
+ schema_version=schema_version,
791
+ confirm_ticket=confirm_ticket,
792
+ base_version=base_version,
793
+ prefetched_current_flow=_prefetched_current_flow,
794
+ )
795
+ if not _is_transient_flow_failure(response):
796
+ return response
797
+ time.sleep(_FLOW_TRANSIENT_RETRY_DELAY_SECONDS)
798
+ response = self._flow_apply_core(
799
+ profile=profile,
800
+ app_key=app_key,
801
+ spec=spec,
802
+ publish=publish,
803
+ idempotency_key=idempotency_key,
804
+ schema_version=schema_version,
805
+ confirm_ticket=confirm_ticket,
806
+ base_version=base_version,
807
+ prefetched_current_flow=_prefetched_current_flow,
808
+ )
809
+ if not isinstance(response.get("details"), dict):
810
+ response["details"] = {}
811
+ response["details"]["transient_retried"] = True
812
+ return response
813
+
814
+ def _flow_apply_core(
815
+ self,
816
+ *,
817
+ profile: str,
818
+ app_key: str,
819
+ spec: dict[str, Any],
820
+ publish: bool,
821
+ idempotency_key: str | None,
822
+ schema_version: str | None,
823
+ confirm_ticket: str | None,
824
+ base_version: int | None,
825
+ prefetched_current_flow: JSONObject | None,
411
826
  ) -> JSONObject:
412
827
  normalized_args = {
413
828
  "app_key": app_key,
@@ -415,9 +830,12 @@ class AiBuilderFacade:
415
830
  "publish": publish,
416
831
  "idempotency_key": idempotency_key,
417
832
  "schema_version": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION,
833
+ "confirm_ticket": confirm_ticket,
834
+ "base_version": base_version,
418
835
  }
419
836
  apply_started_at = time.perf_counter()
420
837
  permission_ms = 0
838
+ flow_maintenance_gate_ms = 0
421
839
  flow_spec_apply_ms = 0
422
840
  flow_spec_verify_ms = 0
423
841
  publish_ms = 0
@@ -429,6 +847,7 @@ class AiBuilderFacade:
429
847
  _merge_duration_breakdown(
430
848
  response,
431
849
  permission_ms=permission_ms,
850
+ flow_maintenance_gate_ms=flow_maintenance_gate_ms,
432
851
  flow_spec_apply_ms=flow_spec_apply_ms,
433
852
  flow_spec_verify_ms=flow_spec_verify_ms,
434
853
  publish_ms=publish_ms,
@@ -467,12 +886,37 @@ class AiBuilderFacade:
467
886
  suggested_next_call={"tool_name": "app_flow_get_schema", "arguments": {"profile": profile}},
468
887
  )
469
888
  )
889
+ # BEGIN temporary flow edit maintenance guard: remove this block when flow editing is restored.
890
+ # 闸门下沉到 facade:CLI / MCP / 进程内评测的唯一共同必经点。
891
+ # flow_patch_nodes 等已有前置读的调用方可用 prefetched_current_flow 复用读结果,零新增请求。
892
+ gate_started_at = time.perf_counter()
893
+ current_flow = prefetched_current_flow
894
+ if current_flow is None:
895
+ current_flow = self.flow_get(profile=profile, app_key=app_key)
896
+ flow_maintenance_gate_ms += _duration_ms(gate_started_at)
897
+ if str(current_flow.get("status") or "").strip().lower() != "success":
898
+ return finish(current_flow)
899
+ gate_block = _flow_edit_maintenance_gate(
900
+ current_flow,
901
+ app_key=app_key,
902
+ normalized_args=normalized_args,
903
+ )
904
+ if gate_block is not None:
905
+ return finish(gate_block)
906
+ # END temporary flow edit maintenance guard.
470
907
  apply_body = {
471
908
  "appKey": app_key,
472
909
  "idempotencyKey": idempotency_key or str(uuid4()),
473
910
  "schemaVersion": schema_version or workflow_spec_api.DEFAULT_SCHEMA_VERSION,
474
911
  "spec": spec,
475
912
  }
913
+ if confirm_ticket:
914
+ # one-time confirmation ticket from a previous CONFIRMATION_REQUIRED preview
915
+ apply_body["confirmTicket"] = confirm_ticket
916
+ if base_version is not None:
917
+ # optimistic lock: echo the draftVersion read from app_flow_get so a concurrent
918
+ # editor save between get and apply surfaces as BASE_VERSION_CONFLICT (409)
919
+ apply_body["baseVersionNo"] = base_version
476
920
  try:
477
921
  flow_spec_apply_started_at = time.perf_counter()
478
922
  apply_result = self._with_backend_context(
@@ -494,6 +938,45 @@ class AiBuilderFacade:
494
938
  flow_spec_apply_ms += _duration_ms(flow_spec_apply_started_at)
495
939
  if not isinstance(apply_result, dict):
496
940
  apply_result = {"result": apply_result}
941
+ # Two-phase destructive-apply protocol (#149): intercept BEFORE the SUCCESS/OK whitelist
942
+ # (which would swallow it as FLOW_SPEC_APPLY_FAILED) and BEFORE publish — a confirmation
943
+ # gate result must not trigger publishing, must not be an error envelope, and must surface
944
+ # the ticket + preview to the agent untouched.
945
+ if workflow_spec_api.is_confirmation_required(apply_result):
946
+ summary = workflow_spec_api.confirmation_required_summary(apply_result)
947
+ retry_arguments: JSONObject = {"profile": profile, "app_key": app_key, "spec": spec}
948
+ if summary.get("confirm_ticket"):
949
+ retry_arguments["confirm_ticket"] = summary.get("confirm_ticket")
950
+ response = {
951
+ "status": "confirmation_required",
952
+ "error_code": None,
953
+ "recoverable": True,
954
+ "message": str(
955
+ summary.get("message")
956
+ or "destructive flow apply requires confirmation; nothing was changed"
957
+ ),
958
+ "normalized_args": normalized_args,
959
+ "missing_fields": [],
960
+ "allowed_values": {},
961
+ "details": {
962
+ "apply_result": apply_result,
963
+ "confirmation": summary,
964
+ },
965
+ "request_id": None,
966
+ "suggested_next_call": {
967
+ "tool_name": "app_flow_apply",
968
+ "arguments": retry_arguments,
969
+ },
970
+ "noop": False,
971
+ "warnings": [],
972
+ "verification": {"applied_spec_available": False, "confirmation_required": True},
973
+ "verified": False,
974
+ "app_key": app_key,
975
+ "write_executed": False,
976
+ "write_succeeded": False,
977
+ "safe_to_retry": True,
978
+ }
979
+ return finish(response)
497
980
  flow_spec_verify_started_at = time.perf_counter()
498
981
  verification, verify_warnings, verified = workflow_spec_api.verify_apply_response(
499
982
  input_spec=spec,
@@ -1491,6 +1974,74 @@ class AiBuilderFacade:
1491
1974
  details={"solution_key": requested_solution_key},
1492
1975
  suggested_next_call={"tool_name": "solution_install", "arguments": {"profile": profile, **normalized_args}},
1493
1976
  )
1977
+ raw_result = created.get("result") if isinstance(created.get("result"), dict) else {}
1978
+ raw_tag_ids = raw_result.get("tagIds")
1979
+ raw_app_keys = raw_result.get("appKeys")
1980
+ tag_ids, tag_ids_complete = _unique_resource_tag_ids(raw_tag_ids)
1981
+ app_keys, app_keys_complete = _unique_resource_app_keys(raw_app_keys)
1982
+ resource_list_complete = tag_ids_complete and app_keys_complete
1983
+ resources: list[JSONObject] = []
1984
+ warnings: list[JSONObject] = []
1985
+
1986
+ for tag_id in tag_ids:
1987
+ name = None
1988
+ try:
1989
+ detail = self.packages.package_get(profile=profile, tag_id=tag_id, include_raw=False)
1990
+ package = detail.get("result") if isinstance(detail.get("result"), dict) else {}
1991
+ name = str(package.get("tagName") or package.get("tag_name") or "").strip() or None
1992
+ except (QingflowApiError, RuntimeError):
1993
+ pass
1994
+ if not name:
1995
+ warnings.append(_warning("RESOURCE_NAME_UNAVAILABLE", "installed package name could not be read", package_id=tag_id))
1996
+ resources.append(
1997
+ _compact_dict(
1998
+ {
1999
+ "resource_type": "package",
2000
+ "operation": "installed",
2001
+ "status": "success",
2002
+ "id": tag_id,
2003
+ "key": str(tag_id),
2004
+ "name": name,
2005
+ "ids": {"package_id": tag_id},
2006
+ }
2007
+ )
2008
+ )
2009
+
2010
+ for app_key in app_keys:
2011
+ name = None
2012
+ parent = None
2013
+ try:
2014
+ detail = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=False)
2015
+ app = detail.get("result") if isinstance(detail.get("result"), dict) else {}
2016
+ name = str(app.get("formTitle") or app.get("appName") or "").strip() or None
2017
+ matching_tag_ids = [tag_id for tag_id in _unique_positive_ints(app.get("tagIds")) if tag_id in tag_ids]
2018
+ if len(matching_tag_ids) == 1:
2019
+ parent = {"resource_type": "package", "id": matching_tag_ids[0], "key": str(matching_tag_ids[0])}
2020
+ except (QingflowApiError, RuntimeError):
2021
+ pass
2022
+ if not name:
2023
+ warnings.append(_warning("RESOURCE_NAME_UNAVAILABLE", "installed application name could not be read", app_key=app_key))
2024
+ resources.append(
2025
+ _compact_dict(
2026
+ {
2027
+ "resource_type": "app",
2028
+ "operation": "installed",
2029
+ "status": "success",
2030
+ "key": app_key,
2031
+ "name": name,
2032
+ "ids": {"app_key": app_key},
2033
+ "parent": parent,
2034
+ }
2035
+ )
2036
+ )
2037
+
2038
+ if not resource_list_complete:
2039
+ warnings.append(
2040
+ _warning(
2041
+ "RESOURCE_LIST_INCOMPLETE",
2042
+ "solution installation succeeded, but its returned package/application identity list was incomplete",
2043
+ )
2044
+ )
1494
2045
  return {
1495
2046
  "status": "success",
1496
2047
  "error_code": None,
@@ -1503,9 +2054,15 @@ class AiBuilderFacade:
1503
2054
  "request_id": created.get("request_id"),
1504
2055
  "suggested_next_call": None,
1505
2056
  "noop": False,
1506
- "verification": {},
2057
+ "warnings": warnings,
2058
+ "verified": False,
2059
+ "verification": {
2060
+ "resource_list_complete": resource_list_complete,
2061
+ "business_verified": False,
2062
+ },
1507
2063
  "write_executed": True,
1508
2064
  "safe_to_retry": False,
2065
+ "resources": resources,
1509
2066
  }
1510
2067
 
1511
2068
  def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
@@ -1933,6 +2490,12 @@ class AiBuilderFacade:
1933
2490
  "error_code": None,
1934
2491
  "recoverable": False,
1935
2492
  "message": "resolved members",
2493
+ "pagination": {
2494
+ **(listed.get("data", {}).get("pagination", {})),
2495
+ "page": page_num,
2496
+ "page_size": page_size,
2497
+ "returned_items": len(items),
2498
+ },
1936
2499
  "normalized_args": normalized_args,
1937
2500
  "missing_fields": [],
1938
2501
  "allowed_values": {},
@@ -1998,6 +2561,13 @@ class AiBuilderFacade:
1998
2561
  "error_code": None,
1999
2562
  "recoverable": False,
2000
2563
  "message": "resolved roles",
2564
+ "pagination": {
2565
+ "page": page_num,
2566
+ "page_size": page_size,
2567
+ "returned_items": len(items),
2568
+ "reported_total": page.get("total"),
2569
+ "page_amount": page.get("pageAmount"),
2570
+ },
2001
2571
  "normalized_args": normalized_args,
2002
2572
  "missing_fields": [],
2003
2573
  "allowed_values": {},
@@ -6750,6 +7320,17 @@ class AiBuilderFacade:
6750
7320
  normalized_args={"app_key": app_key},
6751
7321
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6752
7322
  ))
7323
+ # BEGIN temporary flow edit maintenance guard: remove this block when flow editing is restored.
7324
+ # 既有 current 读之后的闸门判定(零新增请求);通过后把 current 传给
7325
+ # flow_apply 复用,避免二次读。
7326
+ gate_block = _flow_edit_maintenance_gate(
7327
+ current,
7328
+ app_key=app_key,
7329
+ normalized_args={"app_key": app_key},
7330
+ )
7331
+ if gate_block is not None:
7332
+ return attach_patch_duration(gate_block)
7333
+ # END temporary flow edit maintenance guard.
6753
7334
  flow_patch_compile_started_at = time.perf_counter()
6754
7335
  spec = deepcopy(current.get("spec") or {})
6755
7336
  if not isinstance(spec, dict) or not spec:
@@ -6814,6 +7395,7 @@ class AiBuilderFacade:
6814
7395
  ))
6815
7396
  flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6816
7397
  flow_patch_apply_started_at = time.perf_counter()
7398
+ patch_base_version = current.get("draft_version")
6817
7399
  result = self.flow_apply(
6818
7400
  profile=profile,
6819
7401
  app_key=app_key,
@@ -6821,6 +7403,8 @@ class AiBuilderFacade:
6821
7403
  publish=publish,
6822
7404
  idempotency_key=idempotency_key,
6823
7405
  schema_version=schema_version,
7406
+ _prefetched_current_flow=current,
7407
+ base_version=patch_base_version if isinstance(patch_base_version, int) else None,
6824
7408
  )
6825
7409
  flow_patch_apply_ms += _duration_ms(flow_patch_apply_started_at)
6826
7410
  return attach_patch_duration(result)
@@ -16497,6 +17081,106 @@ def _custom_button_upsert_payload_from_detail(
16497
17081
  return _compact_dict(payload)
16498
17082
 
16499
17083
 
17084
+ # 评测沉淀的瞬态业务码重试(视图基准 PERMISSION 318 教训,design §3 强制约束):
17085
+ # apply 失败且 error_code 属于白名单时重试 1 次——权限读失败/权限数据传播延迟
17086
+ # 是可自愈的瞬态故障,裸抛会让长跑场景整轮报废。
17087
+ _FLOW_TRANSIENT_RETRY_CODES = {"APP_PERMISSION_UNVERIFIED", "PERMISSION_DENIED"}
17088
+ _FLOW_TRANSIENT_RETRY_DELAY_SECONDS = 2.0
17089
+
17090
+
17091
+ def _is_transient_flow_failure(response: JSONObject) -> bool:
17092
+ if str(response.get("status") or "").strip().lower() != "failed":
17093
+ return False
17094
+ return str(response.get("error_code") or "") in _FLOW_TRANSIENT_RETRY_CODES
17095
+
17096
+
17097
+ def _public_flow_version_entry(entry: JSONObject) -> JSONObject:
17098
+ """后端版本条目(camelCase)→ 公开稳定形状(snake_case),None 字段丢弃。
17099
+
17100
+ 兼容后端 entity(createdAt 毫秒时间戳)与 meta(timestamp 字符串)两种条目形态。
17101
+ """
17102
+ from datetime import datetime, timezone
17103
+
17104
+ created_at_ms: int | None = None
17105
+ raw_created_at = entry.get("createdAt")
17106
+ if isinstance(raw_created_at, bool):
17107
+ raw_created_at = None
17108
+ if isinstance(raw_created_at, (int, float)):
17109
+ created_at_ms = int(raw_created_at)
17110
+ elif isinstance(raw_created_at, str) and raw_created_at.isdigit():
17111
+ created_at_ms = int(raw_created_at)
17112
+ created_at_iso: str | None = None
17113
+ if created_at_ms is not None:
17114
+ try:
17115
+ created_at_iso = datetime.fromtimestamp(created_at_ms / 1000, tz=timezone.utc).isoformat()
17116
+ except (OverflowError, OSError, ValueError):
17117
+ created_at_iso = None
17118
+ elif isinstance(entry.get("timestamp"), str):
17119
+ created_at_iso = str(entry["timestamp"])
17120
+ item: JSONObject = {
17121
+ "version_id": entry.get("versionId"),
17122
+ "snapshot_type": entry.get("snapshotType"),
17123
+ "created_at_ms": created_at_ms,
17124
+ "created_at_iso": created_at_iso,
17125
+ "operator_name": entry.get("operatorName"),
17126
+ "spec_hash": entry.get("specHash"),
17127
+ "is_current": entry.get("isCurrent"),
17128
+ "idempotency_key": entry.get("idempotencyKey"),
17129
+ }
17130
+ return {key: value for key, value in item.items() if value is not None}
17131
+
17132
+
17133
+ # BEGIN temporary flow edit maintenance guard: remove this helper block when flow editing is restored.
17134
+ def _flow_spec_node_count(flow_result: JSONObject) -> int | None:
17135
+ spec = flow_result.get("spec")
17136
+ if not isinstance(spec, dict):
17137
+ return None
17138
+ nodes = spec.get("nodes")
17139
+ return len(nodes) if isinstance(nodes, list) else None
17140
+
17141
+
17142
+ def _flow_edit_maintenance_gate(
17143
+ current_flow_result: JSONObject,
17144
+ *,
17145
+ app_key: str,
17146
+ normalized_args: JSONObject,
17147
+ ) -> JSONObject | None:
17148
+ """流程编辑维护闸门:返回 None 放行,返回失败信封拦截。
17149
+
17150
+ CLI / MCP / 进程内评测统一在 facade 层判定;阈值经
17151
+ get_flow_edit_maintenance_threshold() 读取(off/disabled/none 关闭)。
17152
+ """
17153
+ threshold = get_flow_edit_maintenance_threshold()
17154
+ if threshold is None:
17155
+ return None
17156
+ node_count = _flow_spec_node_count(current_flow_result)
17157
+ if node_count is None:
17158
+ return _failed(
17159
+ "FLOW_STATE_UNAVAILABLE",
17160
+ "无法读取当前流程节点,未执行流程变更。",
17161
+ normalized_args=normalized_args,
17162
+ details={
17163
+ "app_key": app_key,
17164
+ "fix_hint": "当前流程状态无法确认,未执行流程变更。请稍后重试。",
17165
+ },
17166
+ )
17167
+ if node_count > threshold:
17168
+ return _failed(
17169
+ "FLOW_EDIT_UNDER_MAINTENANCE",
17170
+ "功能维护中,暂时不允许处理",
17171
+ recoverable=True,
17172
+ normalized_args=normalized_args,
17173
+ details={
17174
+ "app_key": app_key,
17175
+ "current_node_count": node_count,
17176
+ "maintenance_node_threshold": threshold,
17177
+ "fix_hint": f"当前流程节点超过 {threshold} 个,流程编辑功能维护中;请稍后重试。",
17178
+ },
17179
+ )
17180
+ return None
17181
+ # END temporary flow edit maintenance guard.
17182
+
17183
+
16500
17184
  def _failed(
16501
17185
  error_code: str,
16502
17186
  message: str,
@@ -16546,10 +17230,12 @@ def _failed_from_api_error(
16546
17230
  effective_error_code = "AUTH_REQUIRED"
16547
17231
  elif backend_code_int(error) == 40074:
16548
17232
  effective_error_code = "APP_EDIT_LOCKED"
17233
+ elif _backend_result_code(error) == "BASE_VERSION_CONFLICT":
17234
+ effective_error_code = "FLOW_SPEC_VERSION_CONFLICT"
16549
17235
  else:
16550
17236
  effective_error_code = error_code
16551
17237
  public_message = _public_error_message(effective_error_code, error)
16552
- public_http_status = None if error.http_status == 404 else error.http_status
17238
+ # #162 D5:404 不再抹除 http_status,错误信封保留真实传输层事实
16553
17239
  merged_details = dict(details or {})
16554
17240
  if backend_code_int(error) == 40074:
16555
17241
  owner = _extract_edit_lock_owner(error.message)
@@ -16578,6 +17264,14 @@ def _failed_from_api_error(
16578
17264
  "category": error.category,
16579
17265
  },
16580
17266
  )
17267
+ backend_result = _backend_result_body(error)
17268
+ if isinstance(backend_result, dict):
17269
+ for key, value in backend_result.items():
17270
+ # backend structured error body (e.g. BASE_VERSION_CONFLICT negotiation payload)
17271
+ # merges in without overriding facade-provided details
17272
+ if value is not None:
17273
+ merged_details.setdefault(key, value)
17274
+ merged_details.setdefault("backend_result", backend_result)
16581
17275
  result = _failed(
16582
17276
  effective_error_code,
16583
17277
  public_message,
@@ -16589,7 +17283,7 @@ def _failed_from_api_error(
16589
17283
  suggested_next_call=suggested_next_call,
16590
17284
  request_id=error.request_id,
16591
17285
  backend_code=error.backend_code,
16592
- http_status=public_http_status,
17286
+ http_status=error.http_status,
16593
17287
  )
16594
17288
  if _is_environment_quota_code(error.backend_code):
16595
17289
  _mark_environment_quota_block(
@@ -17058,6 +17752,21 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
17058
17752
  return QingflowApiError(category="runtime", message=str(error))
17059
17753
 
17060
17754
 
17755
+ def _backend_result_body(error: QingflowApiError) -> JSONObject | None:
17756
+ """Structured backend error body extracted by BackendClient (``ResponseResult.result``)."""
17757
+ details = error.details
17758
+ return details if isinstance(details, dict) else None
17759
+
17760
+
17761
+ def _backend_result_code(error: QingflowApiError) -> str | None:
17762
+ body = _backend_result_body(error)
17763
+ if isinstance(body, dict):
17764
+ code = body.get("code")
17765
+ if code is not None:
17766
+ return str(code)
17767
+ return None
17768
+
17769
+
17061
17770
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
17062
17771
  if backend_code_int(error) == 40074 or error_code == "APP_EDIT_LOCKED":
17063
17772
  owner = _extract_edit_lock_owner(error.message)
@@ -17067,6 +17776,10 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
17067
17776
  return "app is currently locked by another active editor session"
17068
17777
  if error.http_status != 404:
17069
17778
  return error.message
17779
+ # 404 优先保留后端原始 message(agent 排障依据);仅当原始 message 为空时
17780
+ # 才落多路由环境泛化兑底(#162 D5:不再默认泛化丢失真实原因)。
17781
+ if error.message:
17782
+ return f"{error.message} (HTTP 404)"
17070
17783
  mapping = {
17071
17784
  "APP_READ_FAILED": "app base or schema is unavailable in the current route",
17072
17785
  "FIELDS_READ_FAILED": "app fields are unavailable in the current route",
@@ -17148,6 +17861,49 @@ def _coerce_positive_int(value: Any) -> int | None:
17148
17861
  return None
17149
17862
 
17150
17863
 
17864
+ def _unique_positive_ints(value: Any) -> list[int]:
17865
+ if not isinstance(value, list):
17866
+ return []
17867
+ result: list[int] = []
17868
+ for item in value:
17869
+ number = _coerce_positive_int(item)
17870
+ if number is not None and number not in result:
17871
+ result.append(number)
17872
+ return result
17873
+
17874
+
17875
+ def _unique_resource_tag_ids(value: Any) -> tuple[list[int], bool]:
17876
+ if not isinstance(value, list):
17877
+ return [], False
17878
+ result: list[int] = []
17879
+ complete = True
17880
+ for item in value:
17881
+ try:
17882
+ tag_id = validate_positive_resource_id(item, "package_id")
17883
+ except (ValueError, UnicodeError):
17884
+ complete = False
17885
+ continue
17886
+ if tag_id not in result:
17887
+ result.append(tag_id)
17888
+ return result, complete
17889
+
17890
+
17891
+ def _unique_resource_app_keys(value: Any) -> tuple[list[str], bool]:
17892
+ if not isinstance(value, list):
17893
+ return [], False
17894
+ result: list[str] = []
17895
+ complete = True
17896
+ for item in value:
17897
+ try:
17898
+ app_key = validate_resource_identity(item, "app_key")
17899
+ except (ValueError, UnicodeError):
17900
+ complete = False
17901
+ continue
17902
+ if app_key not in result:
17903
+ result.append(app_key)
17904
+ return result, complete
17905
+
17906
+
17151
17907
  def _coerce_nonnegative_int(value: Any) -> int | None:
17152
17908
  if isinstance(value, bool) or value is None:
17153
17909
  return None