@josephyan/qingflow-cli 1.1.4 → 1.1.5

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 (154) hide show
  1. package/README.md +7 -3
  2. package/docs/local-agent-install.md +57 -6
  3. package/entry_point.py +1 -1
  4. package/npm/bin/qingflow-skills.mjs +5 -0
  5. package/npm/bin/qingflow.mjs +1 -34
  6. package/npm/lib/runtime.mjs +21 -101
  7. package/npm/scripts/postinstall.mjs +1 -10
  8. package/package.json +3 -2
  9. package/pyproject.toml +1 -1
  10. package/skills/qingflow-cli/SKILL.md +58 -44
  11. package/skills/qingflow-cli/manifest.yaml +1 -1
  12. package/skills/qingflow-cli/reference/00-INDEX.md +35 -0
  13. package/skills/qingflow-cli/reference/builder/10-build-single-app.md +38 -0
  14. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +39 -0
  15. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md → builder/30-schema-fields.md} +52 -10
  16. package/skills/qingflow-cli/reference/builder/40-layout.md +52 -0
  17. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md → builder/50-views.md} +39 -15
  18. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_CHARTS_WORKFLOW.md → builder/60-charts.md} +36 -13
  19. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_PORTAL_WORKFLOW.md → builder/70-portal.md} +36 -13
  20. package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +41 -0
  21. package/skills/qingflow-cli/reference/builder/90-workflow.md +34 -0
  22. package/skills/qingflow-cli/reference/builder/99-publish-verify.md +46 -0
  23. package/skills/qingflow-cli/reference/builder/README.md +41 -0
  24. package/skills/qingflow-cli/reference/builder/code-integrations/README.md +130 -0
  25. package/skills/qingflow-cli/reference/builder/code-integrations/code-block.md +66 -0
  26. package/skills/qingflow-cli/reference/builder/code-integrations/q-linker.md +77 -0
  27. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md → builder/reference/app-delivery-sop.md} +26 -16
  28. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/README.md +293 -0
  29. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/build-complete-system.md +809 -0
  30. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/build-single-app.md +830 -0
  31. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/complete-system-development-guide.md +123 -0
  32. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/create-app.md +182 -0
  33. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/environments.md +63 -0
  34. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/flow-actors-and-permissions.md +142 -0
  35. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/gotchas.md +108 -0
  36. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/match-rules.md +114 -0
  37. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/public-surface-sync.md +75 -0
  38. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/single-app-development-guide.md +58 -0
  39. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/solution-playbooks.md +52 -0
  40. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/tool-selection.md +107 -0
  41. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/update-flow.md +7 -0
  42. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/update-layout.md +7 -0
  43. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/update-schema.md +7 -0
  44. package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/update-views.md +7 -0
  45. package/skills/qingflow-cli/reference/builder/workflow/01-overview.md +45 -0
  46. package/skills/qingflow-cli/reference/builder/workflow/02-update-mode.md +53 -0
  47. package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +57 -0
  48. package/skills/qingflow-cli/reference/builder/workflow/04-stage1-business-modeling.md +131 -0
  49. package/skills/qingflow-cli/reference/builder/workflow/05-stage2-members-roles.md +29 -0
  50. package/skills/qingflow-cli/reference/builder/workflow/06-stage3-build-spec.md +165 -0
  51. package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +33 -0
  52. package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +51 -0
  53. package/skills/qingflow-cli/reference/builder/workflow/09-stage6-summary.md +88 -0
  54. package/skills/qingflow-cli/reference/builder/workflow/10-node-config-reference.md +93 -0
  55. package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +15 -0
  56. package/skills/qingflow-cli/reference/builder/workflow/README.md +88 -0
  57. package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1754 -0
  58. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_ADMIN_CHEATSHEET.md → core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md} +3 -3
  59. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md → core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md} +6 -6
  60. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_EXPLORATION_REPORT.md → core/QINGFLOW_CLI_EXPLORATION_REPORT.md} +2 -2
  61. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_FIELD_DATA_TYPES.md → core/QINGFLOW_CLI_FIELD_DATA_TYPES.md} +11 -11
  62. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_MEMBER_CHEATSHEET.md → core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md} +4 -4
  63. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md → core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md} +4 -4
  64. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md → record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md} +3 -3
  65. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md +31 -0
  66. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md → record/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md} +4 -4
  67. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md → record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md} +7 -7
  68. package/skills/qingflow-cli/reference/record/analysis/README.md +130 -0
  69. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +91 -0
  70. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +112 -0
  71. package/skills/qingflow-cli/reference/record/analysis/business-context.md +74 -0
  72. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +69 -0
  73. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +106 -0
  74. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +172 -0
  75. package/skills/qingflow-cli/reference/record/analysis/report-format.md +76 -0
  76. package/skills/qingflow-cli/reference/record/insert/README.md +75 -0
  77. package/skills/qingflow-cli/reference/{QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md → task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md} +5 -5
  78. package/skills/qingflow-cli/reference/task/ops/README.md +131 -0
  79. package/skills/qingflow-cli/reference/task/ops/environments.md +43 -0
  80. package/skills/qingflow-cli/reference/task/ops/workflow-usage.md +26 -0
  81. package/skills/qingflow-cli/scripts/validate_system_build_summary.py +124 -0
  82. package/skills/qingflow-cli/scripts/workflow/diff_flow_spec.py +275 -0
  83. package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +605 -0
  84. package/skills/qingflow-mcp-setup/SKILL.md +115 -0
  85. package/skills/qingflow-mcp-setup/agents/openai.yaml +4 -0
  86. package/skills/qingflow-mcp-setup/references/claude-desktop.md +34 -0
  87. package/skills/qingflow-mcp-setup/references/environments.md +62 -0
  88. package/skills/qingflow-mcp-setup/references/generic-stdio.md +32 -0
  89. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +38 -0
  90. package/src/qingflow_mcp/__init__.py +1 -1
  91. package/src/qingflow_mcp/__main__.py +6 -2
  92. package/src/qingflow_mcp/builder_facade/models.py +282 -102
  93. package/src/qingflow_mcp/builder_facade/service.py +4166 -929
  94. package/src/qingflow_mcp/cli/commands/builder.py +316 -298
  95. package/src/qingflow_mcp/cli/commands/chart.py +1 -1
  96. package/src/qingflow_mcp/cli/commands/common.py +12 -3
  97. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  98. package/src/qingflow_mcp/cli/commands/imports.py +3 -3
  99. package/src/qingflow_mcp/cli/commands/portal.py +2 -2
  100. package/src/qingflow_mcp/cli/commands/record.py +101 -27
  101. package/src/qingflow_mcp/cli/commands/task.py +28 -47
  102. package/src/qingflow_mcp/cli/commands/view.py +1 -1
  103. package/src/qingflow_mcp/cli/context.py +0 -3
  104. package/src/qingflow_mcp/cli/formatters.py +784 -16
  105. package/src/qingflow_mcp/cli/main.py +117 -33
  106. package/src/qingflow_mcp/errors.py +43 -2
  107. package/src/qingflow_mcp/public_surface.py +26 -17
  108. package/src/qingflow_mcp/response_trim.py +81 -17
  109. package/src/qingflow_mcp/server.py +14 -12
  110. package/src/qingflow_mcp/server_app_builder.py +65 -21
  111. package/src/qingflow_mcp/server_app_user.py +22 -16
  112. package/src/qingflow_mcp/session_store.py +11 -7
  113. package/src/qingflow_mcp/solution/compiler/__init__.py +3 -1
  114. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  115. package/src/qingflow_mcp/solution/executor.py +245 -18
  116. package/src/qingflow_mcp/tools/ai_builder_tools.py +1780 -406
  117. package/src/qingflow_mcp/tools/app_tools.py +184 -43
  118. package/src/qingflow_mcp/tools/approval_tools.py +197 -35
  119. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  120. package/src/qingflow_mcp/tools/code_block_tools.py +298 -40
  121. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  122. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  123. package/src/qingflow_mcp/tools/export_tools.py +244 -34
  124. package/src/qingflow_mcp/tools/feedback_tools.py +9 -0
  125. package/src/qingflow_mcp/tools/file_tools.py +9 -3
  126. package/src/qingflow_mcp/tools/import_tools.py +336 -49
  127. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  128. package/src/qingflow_mcp/tools/package_tools.py +118 -6
  129. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  130. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  131. package/src/qingflow_mcp/tools/record_tools.py +1141 -356
  132. package/src/qingflow_mcp/tools/resource_read_tools.py +188 -39
  133. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  134. package/src/qingflow_mcp/tools/solution_tools.py +59 -45
  135. package/src/qingflow_mcp/tools/task_context_tools.py +662 -158
  136. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  137. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  138. package/src/qingflow_mcp/tools/workflow_tools.py +48 -4
  139. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
  140. /package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_MATCH_RULES.md → builder/reference/match-rules.md} +0 -0
  141. /package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_WORKSPACE_ICONS.md → builder/reference/workspace-icons.md} +0 -0
  142. /package/skills/qingflow-cli/reference/{charts_remove.example.json → examples/charts/charts_remove.example.json} +0 -0
  143. /package/skills/qingflow-cli/reference/{charts_reorder.example.json → examples/charts/charts_reorder.example.json} +0 -0
  144. /package/skills/qingflow-cli/reference/{charts_upsert_bar.example.json → examples/charts/charts_upsert_bar.example.json} +0 -0
  145. /package/skills/qingflow-cli/reference/{charts_upsert_dashboard_starter.example.json → examples/charts/charts_upsert_dashboard_starter.example.json} +0 -0
  146. /package/skills/qingflow-cli/reference/{charts_upsert_minimal.example.json → examples/charts/charts_upsert_minimal.example.json} +0 -0
  147. /package/skills/qingflow-cli/reference/{portal_sections_all_types.example.json → examples/portal/portal_sections_all_types.example.json} +0 -0
  148. /package/skills/qingflow-cli/reference/{portal_sections_five_types.example.json → examples/portal/portal_sections_five_types.example.json} +0 -0
  149. /package/skills/qingflow-cli/reference/{portal_sections_standard_workbench.example.json → examples/portal/portal_sections_standard_workbench.example.json} +0 -0
  150. /package/skills/qingflow-cli/reference/{_batch_schema_complex.json → examples/schema/_batch_schema_complex.json} +0 -0
  151. /package/skills/qingflow-cli/reference/{_batch_schema_scalar.json → examples/schema/_batch_schema_scalar.json} +0 -0
  152. /package/skills/qingflow-cli/reference/{schema_add_fields_minimal.example.json → examples/schema/schema_add_fields_minimal.example.json} +0 -0
  153. /package/skills/qingflow-cli/reference/{schema_apply_add_fields_all_types.json → examples/schema/schema_apply_add_fields_all_types.json} +0 -0
  154. /package/skills/qingflow-cli/reference/{views_upsert_table_minimal.example.json → examples/views/views_upsert_table_minimal.example.json} +0 -0
@@ -0,0 +1,605 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 验证工作流 spec JSON 是否符合 WorkflowSpecDTO JSON Schema,
4
+ 并执行自定义约束检查(DAG、单 applicant、gateway 条件等)。
5
+
6
+ 用法:
7
+ python3 validate_flow_spec.py <spec_file.json> --schema <schema_file.json>
8
+
9
+ schema 文件应在运行时通过 qingflow builder flow schema --json 动态获取。
10
+ """
11
+
12
+ import json
13
+ import sys
14
+ import os
15
+ from collections import deque
16
+
17
+ try:
18
+ import jsonschema
19
+ except ImportError:
20
+ print("ERROR: jsonschema 未安装,请执行: pip install jsonschema")
21
+ sys.exit(1)
22
+
23
+
24
+ def load_json(path):
25
+ with open(path, 'r', encoding='utf-8') as f:
26
+ return json.load(f)
27
+
28
+
29
+ def validate_schema(spec, schema):
30
+ """使用 jsonschema 进行基础结构校验。
31
+ 注意:官方 schema 中 NodeAttrsDTO 的 oneOf 多态判别过于严格
32
+ (GatewayAttrsDTO 和 ApplicantAttrsDTO 缺少 required 导致误匹配),
33
+ 因此这里将 attrs 的 oneOf 替换为宽松的 anyOf,类型专属校验由自定义 check 函数完成。"""
34
+ import copy
35
+ relaxed = copy.deepcopy(schema)
36
+ # 修复 NodeAttrsDTO 的 oneOf → anyOf
37
+ if '$defs' in relaxed and 'NodeAttrsDTO' in relaxed['$defs']:
38
+ if 'oneOf' in relaxed['$defs']['NodeAttrsDTO']:
39
+ relaxed['$defs']['NodeAttrsDTO']['anyOf'] = relaxed['$defs']['NodeAttrsDTO'].pop('oneOf')
40
+ # 修复 WorkflowNodeDTO.attrs 的 oneOf → anyOf
41
+ if '$defs' in relaxed and 'WorkflowNodeDTO' in relaxed['$defs']:
42
+ attrs = relaxed['$defs']['WorkflowNodeDTO']['properties'].get('attrs', {})
43
+ if 'oneOf' in attrs:
44
+ attrs['anyOf'] = attrs.pop('oneOf')
45
+ validator = jsonschema.Draft202012Validator(relaxed)
46
+ errors = sorted(validator.iter_errors(spec), key=lambda e: e.path)
47
+ return errors
48
+
49
+
50
+ def check_dag(nodes, edges):
51
+ """检查边是否构成 DAG(无环)"""
52
+ node_ids = {n['id'] for n in nodes}
53
+ adj = {nid: [] for nid in node_ids}
54
+ for e in edges:
55
+ f, t = e['from'], e['to']
56
+ if f not in node_ids:
57
+ return [f"edge from={f} to={t}: from 节点不存在于 nodes 中"]
58
+ if t not in node_ids:
59
+ return [f"edge from={f} to={t}: to 节点不存在于 nodes 中"]
60
+ adj[f].append(t)
61
+
62
+ # Kahn's algorithm for topological sort
63
+ indeg = {nid: 0 for nid in node_ids}
64
+ for e in edges:
65
+ indeg[e['to']] += 1
66
+
67
+ q = deque([nid for nid, d in indeg.items() if d == 0])
68
+ visited = 0
69
+ while q:
70
+ u = q.popleft()
71
+ visited += 1
72
+ for v in adj[u]:
73
+ indeg[v] -= 1
74
+ if indeg[v] == 0:
75
+ q.append(v)
76
+
77
+ if visited != len(node_ids):
78
+ return ["检测到环路:工作流边中存在环,不符合 DAG 要求"]
79
+ return []
80
+
81
+
82
+ def check_applicant(nodes):
83
+ """检查有且仅有一个 applicant 节点"""
84
+ applicants = [n for n in nodes if n.get('type') == 'applicant']
85
+ if len(applicants) == 0:
86
+ return ["缺少 applicant 节点:工作流必须包含一个申请人节点作为入口"]
87
+ if len(applicants) > 1:
88
+ ids = [a['id'] for a in applicants]
89
+ return [f"存在多个 applicant 节点: {ids},只允许一个"]
90
+ return []
91
+
92
+
93
+ def check_gateway_conditions(nodes, edges):
94
+ """检查 gateway 出边条件"""
95
+ errors = []
96
+ gateway_ids = {n['id'] for n in nodes if n.get('type') == 'gateway'}
97
+ node_ids = {n['id'] for n in nodes}
98
+
99
+ for gw_id in gateway_ids:
100
+ out_edges = [e for e in edges if e.get('from') == gw_id]
101
+ gw_node = next((n for n in nodes if n['id'] == gw_id), None)
102
+ gw_mode = (gw_node or {}).get('attrs', {}).get('mode', '')
103
+ if gw_mode == 'join':
104
+ # join gateway 允许 0 或 1 条出边(流程终点或汇合后继续)
105
+ pass
106
+ elif len(out_edges) < 2:
107
+ errors.append(f"Gateway {gw_id} 出边数={len(out_edges)},需要至少 2 条出边")
108
+ continue
109
+
110
+ # 检查每条出边
111
+ for e in out_edges:
112
+ cond = e.get('condition')
113
+ if not cond or not isinstance(cond, dict):
114
+ continue
115
+ kind = cond.get('kind', '')
116
+ if kind != 'rules':
117
+ continue
118
+ auto_judges = cond.get('autoJudges', [])
119
+ if not auto_judges or not isinstance(auto_judges, list) or len(auto_judges) == 0:
120
+ errors.append(
121
+ f"Gateway {gw_id} → {e['to']} 边 kind=rules 但 autoJudges 为空"
122
+ )
123
+ continue
124
+ for gi, group in enumerate(auto_judges):
125
+ if not isinstance(group, list):
126
+ errors.append(
127
+ f"Gateway {gw_id} → {e['to']} autoJudges[{gi}] 不是数组(应为 OR 组)"
128
+ )
129
+ continue
130
+ for ri, rule in enumerate(group):
131
+ if not isinstance(rule, dict):
132
+ errors.append(
133
+ f"Gateway {gw_id} → {e['to']} autoJudges[{gi}][{ri}] 不是对象"
134
+ )
135
+ continue
136
+ if 'fieldId' not in rule or 'judgeType' not in rule:
137
+ errors.append(
138
+ f"Gateway {gw_id} → {e['to']} autoJudges[{gi}][{ri}] 缺少 fieldId 或 judgeType"
139
+ )
140
+
141
+ return errors
142
+
143
+
144
+ VALID_RESPONSIBLE_TYPES = {
145
+ 'user', 'dept', 'role', 'applicant', 'leader',
146
+ 'formEmail', 'formMember', 'formDept', 'nodeLeader'
147
+ }
148
+
149
+
150
+ def check_responsible_item(item, node_label):
151
+ """检查单个 MemberRefDTO 项"""
152
+ errors = []
153
+ if not isinstance(item, dict):
154
+ errors.append(f"{node_label} responsible 中包含非对象项")
155
+ return errors
156
+ if 'type' not in item:
157
+ errors.append(f"{node_label} responsible 项缺少 type")
158
+ return errors
159
+ rtype = item.get('type')
160
+ if rtype not in VALID_RESPONSIBLE_TYPES:
161
+ errors.append(
162
+ f"{node_label} responsible type='{rtype}' 不合法,可选: {sorted(VALID_RESPONSIBLE_TYPES)}"
163
+ )
164
+ return errors
165
+ # 检查各类型的必填 ID 字段
166
+ if rtype == 'user' and 'uid' not in item:
167
+ errors.append(f"{node_label} responsible type='user' 缺少 uid(整数)")
168
+ if rtype == 'dept' and 'deptId' not in item:
169
+ errors.append(f"{node_label} responsible type='dept' 缺少 deptId(整数)")
170
+ if rtype == 'role' and 'roleId' not in item:
171
+ errors.append(f"{node_label} responsible type='role' 缺少 roleId(整数)")
172
+ if rtype in ('leader', 'formEmail', 'formMember', 'formDept') and 'queId' not in item:
173
+ errors.append(f"{node_label} responsible type='{rtype}' 缺少 queId(整数)")
174
+ return errors
175
+
176
+
177
+ def check_responsible_array(attrs, node_label):
178
+ """检查 responsible 是否为非空 MemberRefDTO 数组"""
179
+ errors = []
180
+ responsible = attrs.get('responsible')
181
+ if responsible is None:
182
+ return errors # 外层已报缺字段
183
+ if not isinstance(responsible, list):
184
+ errors.append(
185
+ f"{node_label} responsible 必须是数组,当前为 {type(responsible).__name__}"
186
+ )
187
+ return errors
188
+ if len(responsible) == 0:
189
+ errors.append(f"{node_label} responsible 数组至少包含 1 个元素")
190
+ return errors
191
+ for item in responsible:
192
+ errors.extend(check_responsible_item(item, node_label))
193
+ return errors
194
+
195
+
196
+ def check_approval_nodes(nodes):
197
+ """检查审批节点必要字段"""
198
+ errors = []
199
+ for n in nodes:
200
+ if n.get('type') != 'approval':
201
+ continue
202
+ attrs = n.get('attrs', {})
203
+ node_label = f"审批节点 {n['id']} ({n.get('name','')})"
204
+ if 'responsible' not in attrs:
205
+ errors.append(f"{node_label} 缺少 responsible(审批人)")
206
+ else:
207
+ errors.extend(check_responsible_array(attrs, node_label))
208
+ if 'approveType' not in attrs:
209
+ errors.append(f"{node_label} 缺少 approveType")
210
+ if 'auditUserType' not in attrs:
211
+ errors.append(f"{node_label} 缺少 auditUserType")
212
+ return errors
213
+
214
+
215
+ def check_filling_nodes(nodes):
216
+ """检查填写节点必要字段"""
217
+ errors = []
218
+ for n in nodes:
219
+ if n.get('type') != 'filling':
220
+ continue
221
+ attrs = n.get('attrs', {})
222
+ node_label = f"填写节点 {n['id']} ({n.get('name','')})"
223
+ if 'responsible' not in attrs:
224
+ errors.append(f"{node_label} 缺少 responsible(处理人)")
225
+ else:
226
+ errors.extend(check_responsible_array(attrs, node_label))
227
+ return errors
228
+
229
+
230
+ def check_automation_nodes(nodes, edges):
231
+ """检查自动化节点配置"""
232
+ errors = []
233
+ node_ids = {n['id'] for n in nodes}
234
+ for n in nodes:
235
+ if n.get('type') != 'automation':
236
+ continue
237
+ attrs = n.get('attrs', {})
238
+ sub_type = attrs.get('subType', '')
239
+ if not sub_type:
240
+ errors.append(f"自动化节点 {n['id']} ({n.get('name','')}) 缺少 subType")
241
+ continue
242
+
243
+ if sub_type == 'update':
244
+ if 'appKey' not in attrs:
245
+ errors.append(f"自动化节点 {n['id']} update 缺少 appKey")
246
+ if 'targetFilterRules' not in attrs:
247
+ errors.append(f"自动化节点 {n['id']} update 缺少 targetFilterRules")
248
+ if 'fieldMappings' not in attrs:
249
+ errors.append(f"自动化节点 {n['id']} update 缺少 fieldMappings")
250
+ elif sub_type == 'add':
251
+ if 'appKey' not in attrs:
252
+ errors.append(f"自动化节点 {n['id']} add 缺少 appKey")
253
+ if 'fieldMappings' not in attrs:
254
+ errors.append(f"自动化节点 {n['id']} add 缺少 fieldMappings")
255
+ elif sub_type == 'send_email':
256
+ if 'mailConfig' not in attrs:
257
+ errors.append(f"自动化节点 {n['id']} send_email 缺少 mailConfig")
258
+ if 'mailReceivers' not in attrs:
259
+ errors.append(f"自动化节点 {n['id']} send_email 缺少 mailReceivers")
260
+ elif sub_type == 'http_request':
261
+ if 'remoteRequestConfig' not in attrs:
262
+ errors.append(f"自动化节点 {n['id']} http_request 缺少 remoteRequestConfig")
263
+ elif sub_type == 'ai_':
264
+ if 'aiConfig' not in attrs:
265
+ errors.append(f"自动化节点 {n['id']} AI 节点缺少 aiConfig")
266
+ return errors
267
+
268
+
269
+ def check_cc_nodes(nodes):
270
+ """检查抄送节点必要字段"""
271
+ errors = []
272
+ for n in nodes:
273
+ if n.get('type') != 'cc':
274
+ continue
275
+ attrs = n.get('attrs', {})
276
+ if 'receivers' not in attrs:
277
+ errors.append(f"抄送节点 {n['id']} ({n.get('name','')}) 缺少 receivers")
278
+ return errors
279
+
280
+
281
+ def check_orphan_nodes(nodes, edges):
282
+ """检查是否有孤立节点(无入边也无出边,且非 applicant)"""
283
+ node_ids = {n['id'] for n in nodes}
284
+ has_in = set()
285
+ has_out = set()
286
+ for e in edges:
287
+ has_in.add(e['to'])
288
+ has_out.add(e['from'])
289
+
290
+ errors = []
291
+ applicants = {n['id'] for n in nodes if n.get('type') == 'applicant'}
292
+ for n in nodes:
293
+ nid = n['id']
294
+ if nid in applicants:
295
+ if nid in has_in:
296
+ errors.append(f"申请人节点 {nid} 不应有入边")
297
+ continue
298
+ if nid not in has_in:
299
+ errors.append(f"节点 {nid} ({n.get('name','')}) 缺少入边(孤立节点)")
300
+ return errors
301
+
302
+
303
+ def check_minimal_modification(old_spec, new_spec):
304
+ """更新模式下检查最小修改原则:ID 稳定性、无冗余删建、修改范围合理"""
305
+ warnings = []
306
+ errors = []
307
+
308
+ old_nodes = old_spec.get('nodes', [])
309
+ new_nodes = new_spec.get('nodes', [])
310
+
311
+ old_by_id = {n['id']: n for n in old_nodes}
312
+ new_by_id = {n['id']: n for n in new_nodes}
313
+
314
+ old_ids = set(old_by_id.keys())
315
+ new_ids = set(new_by_id.keys())
316
+
317
+ deleted_ids = old_ids - new_ids
318
+ added_ids = new_ids - old_ids
319
+ common_ids = old_ids & new_ids
320
+
321
+ # 1. 检测节点 ID 变更(内容相同但 ID 不同 —— 可能是不必要的重建)
322
+ def node_identity(n):
323
+ return json.dumps({
324
+ 'type': n.get('type', ''),
325
+ 'name': n.get('name', ''),
326
+ 'attrs': n.get('attrs', {}),
327
+ }, sort_keys=True, ensure_ascii=False)
328
+
329
+ old_identities = {}
330
+ for n in old_nodes:
331
+ ident = node_identity(n)
332
+ old_identities.setdefault(ident, []).append(n['id'])
333
+
334
+ for n in new_nodes:
335
+ if n['id'] in added_ids:
336
+ ident = node_identity(n)
337
+ if ident in old_identities and old_identities[ident]:
338
+ old_id = old_identities[ident][0]
339
+ if old_id in deleted_ids:
340
+ errors.append(
341
+ f"[最小修改] 节点 {old_id} → {n['id']} ({n.get('name','')}):"
342
+ f"内容未变但 ID 已变更,可能导致后端不支持配置丢失"
343
+ )
344
+
345
+ # 2. 检测类型变更(type 不同视为破坏性修改)
346
+ for nid in common_ids:
347
+ old_type = old_by_id[nid].get('type')
348
+ new_type = new_by_id[nid].get('type')
349
+ if old_type != new_type:
350
+ warnings.append(
351
+ f"[最小修改] 节点 {nid} type 从 {old_type} 变为 {new_type},"
352
+ f"可能丢失原类型专属配置"
353
+ )
354
+
355
+ # 3. 检测旧边删除 + 新边添加(from/to 相同但 condition 不同 → 应该是修改而非删建)
356
+ def extract_edges(spec):
357
+ e = spec.get('edges', [])
358
+ if isinstance(e, dict) and 'edges' in e:
359
+ return e['edges']
360
+ if isinstance(e, list):
361
+ return e
362
+ return []
363
+
364
+ old_edges = extract_edges(old_spec)
365
+ new_edges = extract_edges(new_spec)
366
+
367
+ old_edge_keys = {(e['from'], e['to']) for e in old_edges}
368
+ new_edge_keys = {(e['from'], e['to']) for e in new_edges}
369
+
370
+ deleted_edges = old_edge_keys - new_edge_keys
371
+ added_edges = new_edge_keys - old_edge_keys
372
+
373
+ if deleted_edges:
374
+ warnings.append(
375
+ f"[最小修改] 删除了 {len(deleted_edges)} 条边,请确认是否为业务需要"
376
+ )
377
+ if added_edges:
378
+ warnings.append(
379
+ f"[最小修改] 新增了 {len(added_edges)} 条边,请确认是否按预期新增"
380
+ )
381
+
382
+ # 4. 汇总
383
+ if deleted_ids:
384
+ warnings.append(
385
+ f"[最小修改] 删除了 {len(deleted_ids)} 个节点: {sorted(deleted_ids)},请确认是否为业务需要"
386
+ )
387
+
388
+ return errors, warnings
389
+
390
+
391
+ def check_required_fields(nodes, edges):
392
+ """检查所有节点必要字段"""
393
+ errors = []
394
+ for n in nodes:
395
+ for field in ['id', 'type', 'name', 'attrs']:
396
+ if field not in n:
397
+ errors.append(f"节点缺少必要字段 {field}: {n.get('id','?')}")
398
+ for e in edges:
399
+ for field in ['from', 'to']:
400
+ if field not in e:
401
+ errors.append(f"边缺少必要字段 {field}: {e}")
402
+ return errors
403
+
404
+
405
+ def normalize_spec(spec):
406
+ """将 spec 标准化:处理 edges 可能在 edges 或直接在顶层的情况"""
407
+ if 'edges' in spec:
408
+ e = spec['edges']
409
+ if isinstance(e, dict) and 'edges' in e:
410
+ return spec['nodes'], e['edges']
411
+ elif isinstance(e, list):
412
+ return spec['nodes'], e
413
+ # edges 可能直接是数组
414
+ if isinstance(spec.get('edges'), list):
415
+ return spec['nodes'], spec['edges']
416
+ return spec.get('nodes', []), []
417
+
418
+
419
+ def inject_attrs_type(spec):
420
+ """Schema 中 attrs 的多态判别依赖 type 字段,但实际数据中 type 在父节点。
421
+ 为满足 schema 校验,将 node.type 注入到 node.attrs.type 中(仅校验用,不修改原数据)。"""
422
+ import copy
423
+ spec = copy.deepcopy(spec)
424
+ for n in spec.get('nodes', []):
425
+ if 'attrs' in n and isinstance(n['attrs'], dict) and 'type' not in n['attrs']:
426
+ n['attrs']['type'] = n.get('type', '')
427
+ return spec
428
+
429
+
430
+ def strip_nulls(obj, parent_key=None):
431
+ """递归删除对象中的 null 值字段,但保留必要字段(如 name)并给默认值。"""
432
+ if isinstance(obj, dict):
433
+ result = {}
434
+ for k, v in obj.items():
435
+ if v is None:
436
+ if k == 'name':
437
+ # name 是必要字段,使用 id 作为默认名称
438
+ result[k] = obj.get('id', 'unnamed')
439
+ elif k == 'role':
440
+ # role 是可选字符串,null 时跳过
441
+ continue
442
+ else:
443
+ continue
444
+ else:
445
+ result[k] = strip_nulls(v, k)
446
+ return result
447
+ elif isinstance(obj, list):
448
+ return [strip_nulls(v) for v in obj]
449
+ return obj
450
+
451
+
452
+ def normalize_auto_judges(spec):
453
+ """后端 get 返回的 autoJudges 可能是 [{"rules": [...]}] 格式,
454
+ 将其规范化为 OR-of-AND 二维数组 [[{...}]] 格式。"""
455
+ import copy
456
+ spec = copy.deepcopy(spec)
457
+ edges = spec.get('edges', {})
458
+ edge_list = edges.get('edges', []) if isinstance(edges, dict) else edges
459
+ if isinstance(edge_list, list):
460
+ for e in edge_list:
461
+ cond = e.get('condition')
462
+ if not cond or not isinstance(cond, dict):
463
+ continue
464
+ judges = cond.get('autoJudges')
465
+ if not judges or not isinstance(judges, list):
466
+ continue
467
+ normalized = []
468
+ for item in judges:
469
+ if isinstance(item, dict) and 'rules' in item:
470
+ # 格式: {"rules": [...]} → 展开为 [...]
471
+ normalized.append(item['rules'])
472
+ elif isinstance(item, list):
473
+ normalized.append(item)
474
+ if normalized:
475
+ cond['autoJudges'] = normalized
476
+ return spec
477
+
478
+
479
+ def main():
480
+ if len(sys.argv) < 2:
481
+ print("用法: python3 validate_flow_spec.py <spec_file.json> [--schema <schema_file.json>]")
482
+ sys.exit(1)
483
+
484
+ spec_file = sys.argv[1]
485
+ schema_file = None
486
+ previous_file = None
487
+ for i, arg in enumerate(sys.argv):
488
+ if arg == '--schema' and i + 1 < len(sys.argv):
489
+ schema_file = sys.argv[i + 1]
490
+ if arg == '--previous' and i + 1 < len(sys.argv):
491
+ previous_file = sys.argv[i + 1]
492
+
493
+ if schema_file is None:
494
+ print("用法: python3 validate_flow_spec.py <spec_file.json> --schema <schema_file.json> [--previous <old_spec.json>]")
495
+ print("提示: schema 文件通过 'qingflow builder flow schema --json > tmp/flow_schema.json' 动态获取")
496
+ sys.exit(1)
497
+
498
+ try:
499
+ spec = load_json(spec_file)
500
+ except Exception as e:
501
+ print(f"FATAL: 无法读取 spec 文件 {spec_file}: {e}")
502
+ sys.exit(1)
503
+
504
+ try:
505
+ schema = load_json(schema_file)
506
+ except Exception as e:
507
+ print(f"FATAL: 无法读取 schema 文件 {schema_file}: {e}")
508
+ sys.exit(1)
509
+
510
+ all_errors = []
511
+ warnings = []
512
+
513
+ # 1. 预处理:null 值清理 + autoJudges 格式规范化
514
+ spec = strip_nulls(spec)
515
+ spec = normalize_auto_judges(spec)
516
+
517
+ # 2. JSON Schema 校验(先注入 attrs.type 以匹配多态判别)
518
+ spec_for_validation = inject_attrs_type(spec)
519
+ schema_errors = validate_schema(spec_for_validation, schema)
520
+ if schema_errors:
521
+ for err in schema_errors:
522
+ path = '.'.join(str(p) for p in err.path) if err.path else '(root)'
523
+ all_errors.append(f"[Schema] {path}: {err.message}")
524
+
525
+ # 2. 提取 nodes 和 edges
526
+ nodes, edges = normalize_spec(spec)
527
+ if not nodes:
528
+ all_errors.append("[结构] spec 中未找到 nodes 数组")
529
+ if not edges:
530
+ all_errors.append("[结构] spec 中未找到 edges 数组")
531
+
532
+ if nodes and edges:
533
+ # 3. 必要字段检查
534
+ all_errors.extend(check_required_fields(nodes, edges))
535
+
536
+ # 4. DAG 检查(回退边/驳回边可能导致环,降级为警告)
537
+ dag_errors = check_dag(nodes, edges)
538
+ if dag_errors:
539
+ warnings.extend(dag_errors)
540
+ warnings.append("DAG 环通常由回退边(如审批驳回、gateway 回退)引起,可能是合法的业务需求")
541
+
542
+ # 5. 单 applicant 检查
543
+ all_errors.extend(check_applicant(nodes))
544
+
545
+ # 6. Gateway 条件检查
546
+ all_errors.extend(check_gateway_conditions(nodes, edges))
547
+
548
+ # 7. 审批节点检查
549
+ all_errors.extend(check_approval_nodes(nodes))
550
+
551
+ # 8. 填写节点检查
552
+ all_errors.extend(check_filling_nodes(nodes))
553
+
554
+ # 9. 自动化节点检查
555
+ all_errors.extend(check_automation_nodes(nodes, edges))
556
+
557
+ # 10. 抄送节点检查
558
+ all_errors.extend(check_cc_nodes(nodes))
559
+
560
+ # 11. 孤立节点检查
561
+ all_errors.extend(check_orphan_nodes(nodes, edges))
562
+
563
+ # 12. 更新模式:最小修改原则检查
564
+ if previous_file:
565
+ try:
566
+ prev_spec = load_json(previous_file)
567
+ mod_errors, mod_warnings = check_minimal_modification(prev_spec, spec)
568
+ all_errors.extend(mod_errors)
569
+ warnings.extend(mod_warnings)
570
+ except Exception as e:
571
+ warnings.append(f"无法读取 previous spec {previous_file}: {e},跳过最小修改检查")
572
+
573
+ # 统计
574
+ node_types = {}
575
+ for n in nodes:
576
+ t = n.get('type', 'unknown')
577
+ node_types[t] = node_types.get(t, 0) + 1
578
+
579
+ print("=" * 60)
580
+ print(f"工作流 Spec 验证报告: {spec_file}")
581
+ print(f"节点总数: {len(nodes)}")
582
+ for t, c in sorted(node_types.items()):
583
+ print(f" {t}: {c}")
584
+ print(f"边总数: {len(edges)}")
585
+ print(f"错误数: {len(all_errors)}")
586
+ print(f"警告数: {len(warnings)}")
587
+ print("=" * 60)
588
+
589
+ if all_errors:
590
+ print("\n❌ 错误:")
591
+ for e in all_errors:
592
+ print(f" - {e}")
593
+ else:
594
+ print("\n✅ 所有检查通过")
595
+
596
+ if warnings:
597
+ print("\n⚠️ 警告:")
598
+ for w in warnings:
599
+ print(f" - {w}")
600
+
601
+ sys.exit(1 if all_errors else 0)
602
+
603
+
604
+ if __name__ == '__main__':
605
+ main()