@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.
- package/README.md +4 -4
- package/npm/lib/runtime.mjs +29 -13
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/skills/qingflow-cli/SKILL.md +92 -410
- package/skills/qingflow-cli/manifest.yaml +1 -1
- package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -2
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
- package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +13 -5
- package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +5 -3
- package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +1 -1
- package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +1 -1
- package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1665 -1738
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
- package/skills/qingflow-cli/reference/data/README.md +32 -0
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
- package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
- package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
- package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
- package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
- package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
- package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
- package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +48 -46
- package/skills/qingflow-common/SKILL.md +10 -17
- package/src/qingflow_mcp/app_form_compat.py +3 -0
- package/src/qingflow_mcp/app_form_validator.py +16 -3
- package/src/qingflow_mcp/backend_client.py +142 -12
- package/src/qingflow_mcp/builder_facade/app_form.py +360 -68
- package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +61 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +5 -9
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +8 -15
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +43 -12
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +58 -24
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +8 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +5 -10
- package/src/qingflow_mcp/builder_facade/service.py +749 -6
- package/src/qingflow_mcp/builder_facade/workflow_spec.py +154 -2
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/auth.py +1 -1
- package/src/qingflow_mcp/cli/commands/builder.py +57 -41
- package/src/qingflow_mcp/cli/commands/data.py +134 -0
- package/src/qingflow_mcp/cli/commands/record.py +1 -1
- package/src/qingflow_mcp/cli/context.py +4 -1
- package/src/qingflow_mcp/cli/formatters.py +159 -4
- package/src/qingflow_mcp/cli/main.py +4 -1
- package/src/qingflow_mcp/config.py +60 -0
- package/src/qingflow_mcp/public_surface.py +9 -0
- package/src/qingflow_mcp/resource_uri.py +241 -0
- package/src/qingflow_mcp/response_trim.py +53 -5
- package/src/qingflow_mcp/server.py +2 -0
- package/src/qingflow_mcp/server_app_builder.py +57 -2
- package/src/qingflow_mcp/server_app_user.py +10 -3
- package/src/qingflow_mcp/tools/ai_builder_tools.py +374 -41
- package/src/qingflow_mcp/tools/app_tools.py +52 -13
- package/src/qingflow_mcp/tools/csv_export.py +168 -0
- package/src/qingflow_mcp/tools/data_tools.py +284 -0
- package/src/qingflow_mcp/tools/login_link_tools.py +34 -6
- package/src/qingflow_mcp/tools/record_tools.py +11 -46
- 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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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=
|
|
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
|