@qingflow-tech/qingflow-cli 1.1.49 → 1.1.50-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/npm/lib/runtime.mjs +29 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -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/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/backend_client.py +142 -12
- package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
- 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
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""Offline WorkflowSpec validation (product module).
|
|
2
|
+
|
|
3
|
+
从 ``skills/qingflow-workflow-builder/scripts/validate_flow_spec.py`` 提炼为产品模块,
|
|
4
|
+
skill 脚本改为薄壳引用本模块,防止双源漂移。
|
|
5
|
+
|
|
6
|
+
校验口径(镜像后端 SpecGraphValidator 与评测结构检查 ``_structural_issues``):
|
|
7
|
+
|
|
8
|
+
- JSON Schema 校验:schema 由调用方动态拉取(``/api/workflow/spec/schema``),
|
|
9
|
+
``NodeAttrsDTO`` 与 ``WorkflowNodeDTO.attrs`` 的 oneOf 多态判别放宽为 anyOf
|
|
10
|
+
(判别字段缺失导致误匹配,类型专属校验由自定义 check 完成);
|
|
11
|
+
- 必要字段、单 applicant、>1 节点须非空边;
|
|
12
|
+
- gateway 条件 / approval / filling / automation / cc / 孤立节点 /
|
|
13
|
+
入口出边与 sync 深度检查(与 skill 现有实现一致);
|
|
14
|
+
- **不做 DAG 无环**:驳回边 / 回退边是合法业务语义(eval r2 复盘已固化),
|
|
15
|
+
检测到环时仅产出 warning,不算 issue。
|
|
16
|
+
|
|
17
|
+
红线:本模块是纯函数集合,不做任何网络调用;唯一网络调用(schema 拉取)
|
|
18
|
+
由 facade ``flow_validate`` 完成,绝无 POST / 写。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections import deque
|
|
24
|
+
from copy import deepcopy
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import jsonschema
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
VALID_RESPONSIBLE_TYPES = {
|
|
32
|
+
"user",
|
|
33
|
+
"dept",
|
|
34
|
+
"role",
|
|
35
|
+
"applicant",
|
|
36
|
+
"leader",
|
|
37
|
+
"formEmail",
|
|
38
|
+
"formMember",
|
|
39
|
+
"formDept",
|
|
40
|
+
"nodeLeader",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class FlowValidationResult:
|
|
46
|
+
"""离线校验结果:issues 非空即失败,warnings 不影响结论。"""
|
|
47
|
+
|
|
48
|
+
issues: list[str] = field(default_factory=list)
|
|
49
|
+
warnings: list[str] = field(default_factory=list)
|
|
50
|
+
node_count: int = 0
|
|
51
|
+
edge_count: int = 0
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def ok(self) -> bool:
|
|
55
|
+
return not self.issues
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# 预处理
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def strip_nulls(obj: Any) -> Any:
|
|
64
|
+
"""递归删除对象中的 null 值字段,但保留必要字段(如 name)并给默认值。"""
|
|
65
|
+
if isinstance(obj, dict):
|
|
66
|
+
result: dict[str, Any] = {}
|
|
67
|
+
for key, value in obj.items():
|
|
68
|
+
if value is None:
|
|
69
|
+
if key == "name":
|
|
70
|
+
# name 是必要字段,使用 id 作为默认名称
|
|
71
|
+
result[key] = obj.get("id", "unnamed")
|
|
72
|
+
# role 等可选字段为 null 时直接跳过
|
|
73
|
+
continue
|
|
74
|
+
result[key] = strip_nulls(value)
|
|
75
|
+
return result
|
|
76
|
+
if isinstance(obj, list):
|
|
77
|
+
return [strip_nulls(item) for item in obj]
|
|
78
|
+
return obj
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def normalize_auto_judges(spec: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
"""后端 get 返回的 autoJudges 可能是 [{"rules": [...]}] 格式。
|
|
83
|
+
|
|
84
|
+
将其规范化为 OR-of-AND 二维数组 [[{...}]] 格式。
|
|
85
|
+
"""
|
|
86
|
+
edges = spec.get("edges", {})
|
|
87
|
+
edge_list = edges.get("edges", []) if isinstance(edges, dict) else edges
|
|
88
|
+
if isinstance(edge_list, list):
|
|
89
|
+
for edge in edge_list:
|
|
90
|
+
condition = edge.get("condition") if isinstance(edge, dict) else None
|
|
91
|
+
if not isinstance(condition, dict):
|
|
92
|
+
continue
|
|
93
|
+
judges = condition.get("autoJudges")
|
|
94
|
+
if not judges or not isinstance(judges, list):
|
|
95
|
+
continue
|
|
96
|
+
normalized: list[Any] = []
|
|
97
|
+
for item in judges:
|
|
98
|
+
if isinstance(item, dict) and "rules" in item:
|
|
99
|
+
# 格式: {"rules": [...]} → 展开为 [...]
|
|
100
|
+
normalized.append(item["rules"])
|
|
101
|
+
elif isinstance(item, list):
|
|
102
|
+
normalized.append(item)
|
|
103
|
+
if normalized:
|
|
104
|
+
condition["autoJudges"] = normalized
|
|
105
|
+
return spec
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def inject_attrs_type(spec: dict[str, Any]) -> dict[str, Any]:
|
|
109
|
+
"""Schema 中 attrs 的多态判别依赖 type 字段,但实际数据中 type 在父节点。
|
|
110
|
+
|
|
111
|
+
为满足 schema 校验,将 node.type 注入到 node.attrs.type 中(仅校验用,不改原数据)。
|
|
112
|
+
"""
|
|
113
|
+
mutated = deepcopy(spec)
|
|
114
|
+
for node in mutated.get("nodes", []) or []:
|
|
115
|
+
if isinstance(node, dict) and isinstance(node.get("attrs"), dict) and "type" not in node["attrs"]:
|
|
116
|
+
node["attrs"]["type"] = node.get("type", "")
|
|
117
|
+
return mutated
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def normalize_spec(spec: dict[str, Any]) -> tuple[list[Any], list[Any]]:
|
|
121
|
+
"""提取 (nodes, edges):edges 可能是 {"edges": [...]} 容器或直接数组。"""
|
|
122
|
+
edges = spec.get("edges")
|
|
123
|
+
if isinstance(edges, dict) and isinstance(edges.get("edges"), list):
|
|
124
|
+
return list(spec.get("nodes") or []), list(edges["edges"])
|
|
125
|
+
if isinstance(edges, list):
|
|
126
|
+
return list(spec.get("nodes") or []), edges
|
|
127
|
+
return list(spec.get("nodes") or []), []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def relax_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
"""放宽 NodeAttrsDTO / WorkflowNodeDTO.attrs 的 oneOf → anyOf。
|
|
132
|
+
|
|
133
|
+
官方 schema 的 oneOf 多态判别过于严格(GatewayAttrsDTO 和 ApplicantAttrsDTO
|
|
134
|
+
缺少 required 导致误匹配),类型专属校验由自定义 check 函数完成。
|
|
135
|
+
"""
|
|
136
|
+
relaxed = deepcopy(schema)
|
|
137
|
+
defs = relaxed.get("$defs")
|
|
138
|
+
if isinstance(defs, dict):
|
|
139
|
+
node_attrs = defs.get("NodeAttrsDTO")
|
|
140
|
+
if isinstance(node_attrs, dict) and "oneOf" in node_attrs:
|
|
141
|
+
node_attrs["anyOf"] = node_attrs.pop("oneOf")
|
|
142
|
+
workflow_node = defs.get("WorkflowNodeDTO")
|
|
143
|
+
if isinstance(workflow_node, dict):
|
|
144
|
+
properties = workflow_node.get("properties")
|
|
145
|
+
attrs = properties.get("attrs") if isinstance(properties, dict) else None
|
|
146
|
+
if isinstance(attrs, dict) and "oneOf" in attrs:
|
|
147
|
+
attrs["anyOf"] = attrs.pop("oneOf")
|
|
148
|
+
return relaxed
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# 校验检查
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def schema_issues(spec: dict[str, Any], schema: dict[str, Any]) -> list[str]:
|
|
157
|
+
"""JSON Schema 校验(先注入 attrs.type 以匹配多态判别)。"""
|
|
158
|
+
validator = jsonschema.Draft202012Validator(relax_schema(schema))
|
|
159
|
+
errors = sorted(validator.iter_errors(spec), key=lambda error: error.path)
|
|
160
|
+
return [
|
|
161
|
+
f"[Schema] {'.'.join(str(part) for part in error.path) if error.path else '(root)'}: {error.message}"
|
|
162
|
+
for error in errors
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def check_required_fields(nodes: list[Any], edges: list[Any]) -> list[str]:
|
|
167
|
+
issues: list[str] = []
|
|
168
|
+
for node in nodes:
|
|
169
|
+
if not isinstance(node, dict):
|
|
170
|
+
issues.append(f"nodes[] 含非对象项: {node!r}")
|
|
171
|
+
continue
|
|
172
|
+
for field_name in ("id", "type", "name", "attrs"):
|
|
173
|
+
if field_name not in node:
|
|
174
|
+
issues.append(f"节点缺少必要字段 {field_name}: {node.get('id', '?')}")
|
|
175
|
+
for edge in edges:
|
|
176
|
+
if not isinstance(edge, dict):
|
|
177
|
+
issues.append(f"edges[] 含非对象项: {edge!r}")
|
|
178
|
+
continue
|
|
179
|
+
for field_name in ("from", "to"):
|
|
180
|
+
if field_name not in edge:
|
|
181
|
+
issues.append(f"边缺少必要字段 {field_name}: {edge}")
|
|
182
|
+
return issues
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def check_nonempty_graph(nodes: list[Any], edges: list[Any]) -> list[str]:
|
|
186
|
+
""">1 节点的图必须有非空边(镜像后端 SpecGraphValidator / eval 结构口径)。"""
|
|
187
|
+
if len(nodes) > 1 and not edges:
|
|
188
|
+
return [f"[结构] 节点数 {len(nodes)} > 1 但 edges 为空:多节点流程必须显式声明边"]
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check_cycle_warnings(nodes: list[Any], edges: list[Any]) -> list[str]:
|
|
193
|
+
"""环仅作为 warning(驳回边 / 回退边是合法业务语义,不作为错误)。"""
|
|
194
|
+
node_ids = {str(node.get("id")) for node in nodes if isinstance(node, dict) and node.get("id") is not None}
|
|
195
|
+
adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
|
|
196
|
+
for edge in edges:
|
|
197
|
+
if not isinstance(edge, dict):
|
|
198
|
+
continue
|
|
199
|
+
source = str(edge.get("from"))
|
|
200
|
+
target = str(edge.get("to"))
|
|
201
|
+
if source in node_ids and target in node_ids:
|
|
202
|
+
adjacency[source].append(target)
|
|
203
|
+
indegree = {node_id: 0 for node_id in node_ids}
|
|
204
|
+
for edge in edges:
|
|
205
|
+
if isinstance(edge, dict) and str(edge.get("to")) in node_ids:
|
|
206
|
+
indegree[str(edge.get("to"))] += 1
|
|
207
|
+
queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
|
|
208
|
+
visited = 0
|
|
209
|
+
while queue:
|
|
210
|
+
current = queue.popleft()
|
|
211
|
+
visited += 1
|
|
212
|
+
for target in adjacency[current]:
|
|
213
|
+
indegree[target] -= 1
|
|
214
|
+
if indegree[target] == 0:
|
|
215
|
+
queue.append(target)
|
|
216
|
+
if visited != len(node_ids):
|
|
217
|
+
return ["检测到环路:可能由回退边(如审批驳回、gateway 回退)引起,通常是合法的业务需求"]
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def check_applicant(nodes: list[Any]) -> list[str]:
|
|
222
|
+
"""检查有且仅有一个 applicant 节点。"""
|
|
223
|
+
applicants = [node for node in nodes if isinstance(node, dict) and node.get("type") == "applicant"]
|
|
224
|
+
if not applicants:
|
|
225
|
+
return ["缺少 applicant 节点:工作流必须包含一个申请人节点作为入口"]
|
|
226
|
+
if len(applicants) > 1:
|
|
227
|
+
ids = [str(applicant.get("id")) for applicant in applicants]
|
|
228
|
+
return [f"存在多个 applicant 节点: {ids},只允许一个"]
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def check_gateway_conditions(nodes: list[Any], edges: list[Any]) -> list[str]:
|
|
233
|
+
"""检查 gateway 出边条件。"""
|
|
234
|
+
issues: list[str] = []
|
|
235
|
+
gateway_ids = {str(node.get("id")) for node in nodes if isinstance(node, dict) and node.get("type") == "gateway"}
|
|
236
|
+
nodes_by_id = {
|
|
237
|
+
str(node.get("id")): node
|
|
238
|
+
for node in nodes
|
|
239
|
+
if isinstance(node, dict) and node.get("id") is not None
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for gateway_id in sorted(gateway_ids):
|
|
243
|
+
out_edges = [edge for edge in edges if isinstance(edge, dict) and str(edge.get("from")) == gateway_id]
|
|
244
|
+
gateway_node = nodes_by_id.get(gateway_id) or {}
|
|
245
|
+
attrs = gateway_node.get("attrs") if isinstance(gateway_node.get("attrs"), dict) else {}
|
|
246
|
+
gateway_mode = str(attrs.get("mode") or "")
|
|
247
|
+
# join gateway 允许 0 或 1 条出边(流程终点或汇合后继续)
|
|
248
|
+
if gateway_mode != "join" and len(out_edges) < 2:
|
|
249
|
+
issues.append(f"Gateway {gateway_id} 出边数={len(out_edges)},需要至少 2 条出边")
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
for edge in out_edges:
|
|
253
|
+
condition = edge.get("condition")
|
|
254
|
+
if not isinstance(condition, dict):
|
|
255
|
+
continue
|
|
256
|
+
if str(condition.get("kind") or "") != "rules":
|
|
257
|
+
continue
|
|
258
|
+
target = str(edge.get("to"))
|
|
259
|
+
auto_judges = condition.get("autoJudges")
|
|
260
|
+
if not isinstance(auto_judges, list) or not auto_judges:
|
|
261
|
+
issues.append(f"Gateway {gateway_id} → {target} 边 kind=rules 但 autoJudges 为空")
|
|
262
|
+
continue
|
|
263
|
+
for group_index, group in enumerate(auto_judges):
|
|
264
|
+
if not isinstance(group, list):
|
|
265
|
+
issues.append(f"Gateway {gateway_id} → {target} autoJudges[{group_index}] 不是数组(应为 OR 组)")
|
|
266
|
+
continue
|
|
267
|
+
for rule_index, rule in enumerate(group):
|
|
268
|
+
if not isinstance(rule, dict):
|
|
269
|
+
issues.append(f"Gateway {gateway_id} → {target} autoJudges[{group_index}][{rule_index}] 不是对象")
|
|
270
|
+
continue
|
|
271
|
+
if "fieldId" not in rule or "judgeType" not in rule:
|
|
272
|
+
issues.append(
|
|
273
|
+
f"Gateway {gateway_id} → {target} autoJudges[{group_index}][{rule_index}] 缺少 fieldId 或 judgeType"
|
|
274
|
+
)
|
|
275
|
+
return issues
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _check_responsible_item(item: Any, node_label: str) -> list[str]:
|
|
279
|
+
issues: list[str] = []
|
|
280
|
+
if not isinstance(item, dict):
|
|
281
|
+
issues.append(f"{node_label} responsible 中包含非对象项")
|
|
282
|
+
return issues
|
|
283
|
+
if "type" not in item:
|
|
284
|
+
issues.append(f"{node_label} responsible 项缺少 type")
|
|
285
|
+
return issues
|
|
286
|
+
responsible_type = item.get("type")
|
|
287
|
+
if responsible_type not in VALID_RESPONSIBLE_TYPES:
|
|
288
|
+
issues.append(
|
|
289
|
+
f"{node_label} responsible type='{responsible_type}' 不合法,可选: {sorted(VALID_RESPONSIBLE_TYPES)}"
|
|
290
|
+
)
|
|
291
|
+
return issues
|
|
292
|
+
if responsible_type == "user" and "uid" not in item:
|
|
293
|
+
issues.append(f"{node_label} responsible type='user' 缺少 uid(整数)")
|
|
294
|
+
if responsible_type == "dept" and "deptId" not in item:
|
|
295
|
+
issues.append(f"{node_label} responsible type='dept' 缺少 deptId(整数)")
|
|
296
|
+
if responsible_type == "role" and "roleId" not in item:
|
|
297
|
+
issues.append(f"{node_label} responsible type='role' 缺少 roleId(整数)")
|
|
298
|
+
if responsible_type in ("leader", "formEmail", "formMember", "formDept") and "queId" not in item:
|
|
299
|
+
issues.append(f"{node_label} responsible type='{responsible_type}' 缺少 queId(整数)")
|
|
300
|
+
return issues
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _check_responsible_array(attrs: dict[str, Any], node_label: str) -> list[str]:
|
|
304
|
+
issues: list[str] = []
|
|
305
|
+
responsible = attrs.get("responsible")
|
|
306
|
+
if responsible is None:
|
|
307
|
+
return issues # 外层已报缺字段
|
|
308
|
+
if not isinstance(responsible, list):
|
|
309
|
+
issues.append(f"{node_label} responsible 必须是数组,当前为 {type(responsible).__name__}")
|
|
310
|
+
return issues
|
|
311
|
+
if not responsible:
|
|
312
|
+
issues.append(f"{node_label} responsible 数组至少包含 1 个元素")
|
|
313
|
+
return issues
|
|
314
|
+
for item in responsible:
|
|
315
|
+
issues.extend(_check_responsible_item(item, node_label))
|
|
316
|
+
return issues
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def check_approval_nodes(nodes: list[Any]) -> list[str]:
|
|
320
|
+
"""检查审批节点必要字段。"""
|
|
321
|
+
issues: list[str] = []
|
|
322
|
+
for node in nodes:
|
|
323
|
+
if not isinstance(node, dict) or node.get("type") != "approval":
|
|
324
|
+
continue
|
|
325
|
+
attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {}
|
|
326
|
+
node_label = f"审批节点 {node.get('id')} ({node.get('name', '')})"
|
|
327
|
+
if "responsible" not in attrs:
|
|
328
|
+
issues.append(f"{node_label} 缺少 responsible(审批人)")
|
|
329
|
+
else:
|
|
330
|
+
issues.extend(_check_responsible_array(attrs, node_label))
|
|
331
|
+
if "approveType" not in attrs:
|
|
332
|
+
issues.append(f"{node_label} 缺少 approveType")
|
|
333
|
+
if "auditUserType" not in attrs:
|
|
334
|
+
issues.append(f"{node_label} 缺少 auditUserType")
|
|
335
|
+
return issues
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def check_filling_nodes(nodes: list[Any]) -> list[str]:
|
|
339
|
+
"""检查填写节点必要字段。"""
|
|
340
|
+
issues: list[str] = []
|
|
341
|
+
for node in nodes:
|
|
342
|
+
if not isinstance(node, dict) or node.get("type") != "filling":
|
|
343
|
+
continue
|
|
344
|
+
attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {}
|
|
345
|
+
node_label = f"填写节点 {node.get('id')} ({node.get('name', '')})"
|
|
346
|
+
if "responsible" not in attrs:
|
|
347
|
+
issues.append(f"{node_label} 缺少 responsible(处理人)")
|
|
348
|
+
else:
|
|
349
|
+
issues.extend(_check_responsible_array(attrs, node_label))
|
|
350
|
+
return issues
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def check_automation_nodes(nodes: list[Any]) -> list[str]:
|
|
354
|
+
"""检查自动化节点配置。"""
|
|
355
|
+
issues: list[str] = []
|
|
356
|
+
for node in nodes:
|
|
357
|
+
if not isinstance(node, dict) or node.get("type") != "automation":
|
|
358
|
+
continue
|
|
359
|
+
attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {}
|
|
360
|
+
node_label = f"自动化节点 {node.get('id')} ({node.get('name', '')})"
|
|
361
|
+
sub_type = str(attrs.get("subType") or "")
|
|
362
|
+
if not sub_type:
|
|
363
|
+
issues.append(f"{node_label} 缺少 subType")
|
|
364
|
+
continue
|
|
365
|
+
if sub_type == "update":
|
|
366
|
+
if "appKey" not in attrs:
|
|
367
|
+
issues.append(f"{node_label} update 缺少 appKey")
|
|
368
|
+
if "targetFilterRules" not in attrs:
|
|
369
|
+
issues.append(f"{node_label} update 缺少 targetFilterRules")
|
|
370
|
+
if "fieldMappings" not in attrs:
|
|
371
|
+
issues.append(f"{node_label} update 缺少 fieldMappings")
|
|
372
|
+
elif sub_type == "add":
|
|
373
|
+
if "appKey" not in attrs:
|
|
374
|
+
issues.append(f"{node_label} add 缺少 appKey")
|
|
375
|
+
if "fieldMappings" not in attrs:
|
|
376
|
+
issues.append(f"{node_label} add 缺少 fieldMappings")
|
|
377
|
+
elif sub_type == "send_email":
|
|
378
|
+
if "mailConfig" not in attrs:
|
|
379
|
+
issues.append(f"{node_label} send_email 缺少 mailConfig")
|
|
380
|
+
if "mailReceivers" not in attrs:
|
|
381
|
+
issues.append(f"{node_label} send_email 缺少 mailReceivers")
|
|
382
|
+
elif sub_type == "http_request":
|
|
383
|
+
if "remoteRequestConfig" not in attrs:
|
|
384
|
+
issues.append(f"{node_label} http_request 缺少 remoteRequestConfig")
|
|
385
|
+
elif sub_type == "ai_":
|
|
386
|
+
if "aiConfig" not in attrs:
|
|
387
|
+
issues.append(f"{node_label} AI 节点缺少 aiConfig")
|
|
388
|
+
return issues
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def check_cc_nodes(nodes: list[Any]) -> list[str]:
|
|
392
|
+
"""检查抄送节点必要字段。"""
|
|
393
|
+
issues: list[str] = []
|
|
394
|
+
for node in nodes:
|
|
395
|
+
if not isinstance(node, dict) or node.get("type") != "cc":
|
|
396
|
+
continue
|
|
397
|
+
attrs = node.get("attrs") if isinstance(node.get("attrs"), dict) else {}
|
|
398
|
+
if "receivers" not in attrs:
|
|
399
|
+
issues.append(f"抄送节点 {node.get('id')} 缺少 receivers")
|
|
400
|
+
return issues
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def check_orphan_nodes(nodes: list[Any], edges: list[Any]) -> list[str]:
|
|
404
|
+
"""检查孤立节点(无入边且非 applicant);applicant 节点不应有入边。"""
|
|
405
|
+
has_incoming = {str(edge.get("to")) for edge in edges if isinstance(edge, dict)}
|
|
406
|
+
applicants = {
|
|
407
|
+
str(node.get("id"))
|
|
408
|
+
for node in nodes
|
|
409
|
+
if isinstance(node, dict) and node.get("type") == "applicant"
|
|
410
|
+
}
|
|
411
|
+
issues: list[str] = []
|
|
412
|
+
for node in nodes:
|
|
413
|
+
if not isinstance(node, dict):
|
|
414
|
+
continue
|
|
415
|
+
node_id = str(node.get("id"))
|
|
416
|
+
if node_id in applicants:
|
|
417
|
+
if node_id in has_incoming:
|
|
418
|
+
issues.append(f"申请人节点 {node_id} 不应有入边")
|
|
419
|
+
continue
|
|
420
|
+
if node_id not in has_incoming:
|
|
421
|
+
issues.append(f"节点 {node_id} ({node.get('name', '')}) 缺少入边(孤立节点)")
|
|
422
|
+
return issues
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def check_routing_entry_and_sync(nodes: list[Any], edges: list[Any]) -> list[str]:
|
|
426
|
+
"""检查入口出边和需要继续流转节点的 sync 标记。"""
|
|
427
|
+
outgoing: dict[str, list[str]] = {}
|
|
428
|
+
for edge in edges:
|
|
429
|
+
if isinstance(edge, dict):
|
|
430
|
+
outgoing.setdefault(str(edge.get("from")), []).append(str(edge.get("to")))
|
|
431
|
+
issues: list[str] = []
|
|
432
|
+
for node in nodes:
|
|
433
|
+
if not isinstance(node, dict):
|
|
434
|
+
continue
|
|
435
|
+
node_id = str(node.get("id"))
|
|
436
|
+
node_type = node.get("type")
|
|
437
|
+
node_label = f"节点 {node_id} ({node.get('name', '')})"
|
|
438
|
+
has_outgoing = bool(outgoing.get(node_id))
|
|
439
|
+
if node_type == "applicant" and not has_outgoing:
|
|
440
|
+
issues.append(f"申请人入口节点 {node_id} 没有出边,提交后流程不会进入下一节点")
|
|
441
|
+
if has_outgoing and node.get("sync") is not True:
|
|
442
|
+
issues.append(f"{node_label} 存在出边但未显式 sync=true,后端可能停止继续路由")
|
|
443
|
+
return issues
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
# 更新模式:最小修改原则(skill --previous 检查)
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _minimal_spec_edges(spec: dict[str, Any]) -> list[tuple[Any, Any]]:
|
|
452
|
+
edges = spec.get("edges")
|
|
453
|
+
if isinstance(edges, dict) and isinstance(edges.get("edges"), list):
|
|
454
|
+
return [(edge.get("from"), edge.get("to")) for edge in edges["edges"] if isinstance(edge, dict)]
|
|
455
|
+
if isinstance(edges, list):
|
|
456
|
+
return [(edge.get("from"), edge.get("to")) for edge in edges if isinstance(edge, dict)]
|
|
457
|
+
return []
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _node_identity(node: dict[str, Any]) -> str:
|
|
461
|
+
import json
|
|
462
|
+
|
|
463
|
+
return json.dumps(
|
|
464
|
+
{"type": node.get("type", ""), "name": node.get("name", ""), "attrs": node.get("attrs", {})},
|
|
465
|
+
sort_keys=True,
|
|
466
|
+
ensure_ascii=False,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def check_minimal_modification(
|
|
471
|
+
old_spec: dict[str, Any],
|
|
472
|
+
new_spec: dict[str, Any],
|
|
473
|
+
) -> tuple[list[str], list[str]]:
|
|
474
|
+
"""更新模式下检查最小修改原则:ID 稳定性、无冗余删建、修改范围合理。"""
|
|
475
|
+
issues: list[str] = []
|
|
476
|
+
warnings: list[str] = []
|
|
477
|
+
|
|
478
|
+
old_nodes = [node for node in (old_spec.get("nodes") or []) if isinstance(node, dict)]
|
|
479
|
+
new_nodes = [node for node in (new_spec.get("nodes") or []) if isinstance(node, dict)]
|
|
480
|
+
old_by_id = {str(node.get("id")): node for node in old_nodes}
|
|
481
|
+
new_by_id = {str(node.get("id")): node for node in new_nodes}
|
|
482
|
+
old_ids = set(old_by_id)
|
|
483
|
+
new_ids = set(new_by_id)
|
|
484
|
+
deleted_ids = old_ids - new_ids
|
|
485
|
+
added_ids = new_ids - old_ids
|
|
486
|
+
|
|
487
|
+
# 1. 节点 ID 变更(内容相同但 ID 不同 —— 可能是不必要的重建)
|
|
488
|
+
old_identities: dict[str, list[str]] = {}
|
|
489
|
+
for node in old_nodes:
|
|
490
|
+
old_identities.setdefault(_node_identity(node), []).append(str(node.get("id")))
|
|
491
|
+
for node in new_nodes:
|
|
492
|
+
node_id = str(node.get("id"))
|
|
493
|
+
if node_id in added_ids:
|
|
494
|
+
identity = _node_identity(node)
|
|
495
|
+
if old_identities.get(identity):
|
|
496
|
+
old_id = old_identities[identity][0]
|
|
497
|
+
if old_id in deleted_ids:
|
|
498
|
+
issues.append(
|
|
499
|
+
f"[最小修改] 节点 {old_id} → {node_id} ({node.get('name', '')}):"
|
|
500
|
+
f"内容未变但 ID 已变更,可能导致后端不支持配置丢失"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# 2. 类型变更(type 不同视为破坏性修改)
|
|
504
|
+
for node_id in old_ids & new_ids:
|
|
505
|
+
if old_by_id[node_id].get("type") != new_by_id[node_id].get("type"):
|
|
506
|
+
warnings.append(
|
|
507
|
+
f"[最小修改] 节点 {node_id} type 从 {old_by_id[node_id].get('type')} 变为 "
|
|
508
|
+
f"{new_by_id[node_id].get('type')},可能丢失原类型专属配置"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# 3. 边删建(from/to 相同但 condition 不同 → 应该是修改而非删建)
|
|
512
|
+
old_edge_keys = set(_minimal_spec_edges(old_spec))
|
|
513
|
+
new_edge_keys = set(_minimal_spec_edges(new_spec))
|
|
514
|
+
deleted_edges = old_edge_keys - new_edge_keys
|
|
515
|
+
added_edges = new_edge_keys - old_edge_keys
|
|
516
|
+
if deleted_edges:
|
|
517
|
+
warnings.append(f"[最小修改] 删除了 {len(deleted_edges)} 条边,请确认是否为业务需要")
|
|
518
|
+
if added_edges:
|
|
519
|
+
warnings.append(f"[最小修改] 新增了 {len(added_edges)} 条边,请确认是否按预期新增")
|
|
520
|
+
|
|
521
|
+
# 4. 节点删除
|
|
522
|
+
if deleted_ids:
|
|
523
|
+
warnings.append(f"[最小修改] 删除了 {len(deleted_ids)} 个节点: {sorted(deleted_ids)},请确认是否为业务需要")
|
|
524
|
+
|
|
525
|
+
return issues, warnings
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
# 汇总入口
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def validate_spec(spec: dict[str, Any], schema: dict[str, Any]) -> FlowValidationResult:
|
|
534
|
+
"""对单个 WorkflowSpec 执行全量离线校验。
|
|
535
|
+
|
|
536
|
+
``schema`` 须为 ``/api/workflow/spec/schema`` 的动态拉取结果(relaxed 由本函数内部处理)。
|
|
537
|
+
"""
|
|
538
|
+
result = FlowValidationResult()
|
|
539
|
+
if not isinstance(spec, dict):
|
|
540
|
+
result.issues.append("[结构] spec 必须是 JSON 对象(WorkflowSpecDTO)")
|
|
541
|
+
return result
|
|
542
|
+
|
|
543
|
+
# 预处理:null 值清理 + autoJudges 格式规范化(不改调用方原数据)
|
|
544
|
+
normalized = normalize_auto_judges(strip_nulls(deepcopy(spec)))
|
|
545
|
+
nodes, edges = normalize_spec(normalized)
|
|
546
|
+
result.node_count = len(nodes)
|
|
547
|
+
result.edge_count = len(edges)
|
|
548
|
+
|
|
549
|
+
result.issues.extend(schema_issues(inject_attrs_type(normalized), schema))
|
|
550
|
+
|
|
551
|
+
if not nodes:
|
|
552
|
+
result.issues.append("[结构] spec 中未找到 nodes 数组")
|
|
553
|
+
if not edges:
|
|
554
|
+
# 单节点(applicant-only)流程允许无边;多节点必须显式声明边。
|
|
555
|
+
result.issues.extend(check_nonempty_graph(nodes, edges))
|
|
556
|
+
|
|
557
|
+
if nodes:
|
|
558
|
+
result.issues.extend(check_required_fields(nodes, edges))
|
|
559
|
+
result.issues.extend(check_applicant(nodes))
|
|
560
|
+
result.issues.extend(check_gateway_conditions(nodes, edges))
|
|
561
|
+
result.issues.extend(check_approval_nodes(nodes))
|
|
562
|
+
result.issues.extend(check_filling_nodes(nodes))
|
|
563
|
+
result.issues.extend(check_automation_nodes(nodes))
|
|
564
|
+
result.issues.extend(check_cc_nodes(nodes))
|
|
565
|
+
result.issues.extend(check_orphan_nodes(nodes, edges))
|
|
566
|
+
result.issues.extend(check_routing_entry_and_sync(nodes, edges))
|
|
567
|
+
if nodes and edges:
|
|
568
|
+
result.warnings.extend(check_cycle_warnings(nodes, edges))
|
|
569
|
+
return result
|