@qingflow-tech/qingflow-cli 1.1.49 → 1.1.50-beta.2

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 (96) 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 +2 -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/20-build-complete-system.md +2 -2
  17. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
  18. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
  19. package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +13 -5
  20. package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +5 -3
  21. package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +1 -1
  22. package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +1 -1
  23. package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1665 -1738
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  27. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  28. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  29. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  30. package/skills/qingflow-cli/reference/data/README.md +32 -0
  31. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
  32. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  33. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  34. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  35. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  36. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  37. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  38. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  39. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  40. package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +48 -46
  41. package/skills/qingflow-common/SKILL.md +10 -17
  42. package/src/qingflow_mcp/app_form_compat.py +3 -0
  43. package/src/qingflow_mcp/app_form_validator.py +16 -3
  44. package/src/qingflow_mcp/backend_client.py +142 -12
  45. package/src/qingflow_mcp/builder_facade/app_form.py +360 -68
  46. package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
  47. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
  48. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +61 -27
  49. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +3 -7
  50. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +3 -7
  51. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +5 -9
  52. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +3 -7
  53. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +8 -15
  54. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +3 -7
  55. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +3 -7
  56. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +3 -7
  57. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +3 -7
  58. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +3 -7
  59. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +3 -7
  60. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +3 -7
  61. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +3 -7
  62. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +3 -7
  63. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +3 -7
  64. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +3 -7
  65. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +43 -12
  66. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
  67. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +3 -7
  68. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +3 -7
  69. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +58 -24
  70. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +3 -7
  71. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +8 -0
  72. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +5 -10
  73. package/src/qingflow_mcp/builder_facade/service.py +749 -6
  74. package/src/qingflow_mcp/builder_facade/workflow_spec.py +154 -2
  75. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  76. package/src/qingflow_mcp/cli/commands/auth.py +1 -1
  77. package/src/qingflow_mcp/cli/commands/builder.py +57 -41
  78. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  79. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  80. package/src/qingflow_mcp/cli/context.py +4 -1
  81. package/src/qingflow_mcp/cli/formatters.py +159 -4
  82. package/src/qingflow_mcp/cli/main.py +4 -1
  83. package/src/qingflow_mcp/config.py +60 -0
  84. package/src/qingflow_mcp/public_surface.py +9 -0
  85. package/src/qingflow_mcp/resource_uri.py +241 -0
  86. package/src/qingflow_mcp/response_trim.py +53 -5
  87. package/src/qingflow_mcp/server.py +2 -0
  88. package/src/qingflow_mcp/server_app_builder.py +57 -2
  89. package/src/qingflow_mcp/server_app_user.py +10 -3
  90. package/src/qingflow_mcp/tools/ai_builder_tools.py +374 -41
  91. package/src/qingflow_mcp/tools/app_tools.py +52 -13
  92. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  93. package/src/qingflow_mcp/tools/data_tools.py +284 -0
  94. package/src/qingflow_mcp/tools/login_link_tools.py +34 -6
  95. package/src/qingflow_mcp/tools/record_tools.py +11 -46
  96. package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
@@ -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:
@@ -6763,6 +7320,17 @@ class AiBuilderFacade:
6763
7320
  normalized_args={"app_key": app_key},
6764
7321
  suggested_next_call={"tool_name": "app_flow_get", "arguments": {"profile": profile, "app_key": app_key}},
6765
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.
6766
7334
  flow_patch_compile_started_at = time.perf_counter()
6767
7335
  spec = deepcopy(current.get("spec") or {})
6768
7336
  if not isinstance(spec, dict) or not spec:
@@ -6827,6 +7395,7 @@ class AiBuilderFacade:
6827
7395
  ))
6828
7396
  flow_patch_compile_ms += _duration_ms(flow_patch_compile_started_at)
6829
7397
  flow_patch_apply_started_at = time.perf_counter()
7398
+ patch_base_version = current.get("draft_version")
6830
7399
  result = self.flow_apply(
6831
7400
  profile=profile,
6832
7401
  app_key=app_key,
@@ -6834,6 +7403,8 @@ class AiBuilderFacade:
6834
7403
  publish=publish,
6835
7404
  idempotency_key=idempotency_key,
6836
7405
  schema_version=schema_version,
7406
+ _prefetched_current_flow=current,
7407
+ base_version=patch_base_version if isinstance(patch_base_version, int) else None,
6837
7408
  )
6838
7409
  flow_patch_apply_ms += _duration_ms(flow_patch_apply_started_at)
6839
7410
  return attach_patch_duration(result)
@@ -16510,6 +17081,106 @@ def _custom_button_upsert_payload_from_detail(
16510
17081
  return _compact_dict(payload)
16511
17082
 
16512
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
+
16513
17184
  def _failed(
16514
17185
  error_code: str,
16515
17186
  message: str,
@@ -16559,10 +17230,12 @@ def _failed_from_api_error(
16559
17230
  effective_error_code = "AUTH_REQUIRED"
16560
17231
  elif backend_code_int(error) == 40074:
16561
17232
  effective_error_code = "APP_EDIT_LOCKED"
17233
+ elif _backend_result_code(error) == "BASE_VERSION_CONFLICT":
17234
+ effective_error_code = "FLOW_SPEC_VERSION_CONFLICT"
16562
17235
  else:
16563
17236
  effective_error_code = error_code
16564
17237
  public_message = _public_error_message(effective_error_code, error)
16565
- public_http_status = None if error.http_status == 404 else error.http_status
17238
+ # #162 D5:404 不再抹除 http_status,错误信封保留真实传输层事实
16566
17239
  merged_details = dict(details or {})
16567
17240
  if backend_code_int(error) == 40074:
16568
17241
  owner = _extract_edit_lock_owner(error.message)
@@ -16591,6 +17264,14 @@ def _failed_from_api_error(
16591
17264
  "category": error.category,
16592
17265
  },
16593
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)
16594
17275
  result = _failed(
16595
17276
  effective_error_code,
16596
17277
  public_message,
@@ -16602,7 +17283,7 @@ def _failed_from_api_error(
16602
17283
  suggested_next_call=suggested_next_call,
16603
17284
  request_id=error.request_id,
16604
17285
  backend_code=error.backend_code,
16605
- http_status=public_http_status,
17286
+ http_status=error.http_status,
16606
17287
  )
16607
17288
  if _is_environment_quota_code(error.backend_code):
16608
17289
  _mark_environment_quota_block(
@@ -17071,6 +17752,21 @@ def _coerce_api_error(error: Exception) -> QingflowApiError:
17071
17752
  return QingflowApiError(category="runtime", message=str(error))
17072
17753
 
17073
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
+
17074
17770
  def _public_error_message(error_code: str, error: QingflowApiError) -> str:
17075
17771
  if backend_code_int(error) == 40074 or error_code == "APP_EDIT_LOCKED":
17076
17772
  owner = _extract_edit_lock_owner(error.message)
@@ -17080,6 +17776,10 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
17080
17776
  return "app is currently locked by another active editor session"
17081
17777
  if error.http_status != 404:
17082
17778
  return error.message
17779
+ # 404 优先保留后端原始 message(agent 排障依据);仅当原始 message 为空时
17780
+ # 才落多路由环境泛化兑底(#162 D5:不再默认泛化丢失真实原因)。
17781
+ if error.message:
17782
+ return f"{error.message} (HTTP 404)"
17083
17783
  mapping = {
17084
17784
  "APP_READ_FAILED": "app base or schema is unavailable in the current route",
17085
17785
  "FIELDS_READ_FAILED": "app fields are unavailable in the current route",
@@ -17161,6 +17861,49 @@ def _coerce_positive_int(value: Any) -> int | None:
17161
17861
  return None
17162
17862
 
17163
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
+
17164
17907
  def _coerce_nonnegative_int(value: Any) -> int | None:
17165
17908
  if isinstance(value, bool) or value is None:
17166
17909
  return None