@fieldwangai/agentflow 0.1.25

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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/README.zh-CN.md +201 -0
  4. package/agents/agentflow-node-executor-code.md +32 -0
  5. package/agents/agentflow-node-executor-planning.md +32 -0
  6. package/agents/agentflow-node-executor-requirement.md +32 -0
  7. package/agents/agentflow-node-executor-test.md +32 -0
  8. package/agents/agentflow-node-executor-ui.md +32 -0
  9. package/agents/agentflow-node-executor.md +32 -0
  10. package/agents/agents.json +8 -0
  11. package/agents/en/agentflow-node-executor.md +32 -0
  12. package/agents/zh/agentflow-node-executor.md +32 -0
  13. package/bin/agentflow.mjs +52 -0
  14. package/bin/ensure-workspace-reference.mjs +35 -0
  15. package/bin/lib/agent-runners.mjs +1199 -0
  16. package/bin/lib/agents-path.mjs +61 -0
  17. package/bin/lib/api-runner.mjs +361 -0
  18. package/bin/lib/apply.mjs +852 -0
  19. package/bin/lib/catalog-agents.mjs +300 -0
  20. package/bin/lib/catalog-flows.mjs +532 -0
  21. package/bin/lib/composer-agent.mjs +884 -0
  22. package/bin/lib/composer-flow-instances.mjs +68 -0
  23. package/bin/lib/composer-flow-skeleton.mjs +334 -0
  24. package/bin/lib/composer-flow-validate.mjs +47 -0
  25. package/bin/lib/composer-log.mjs +197 -0
  26. package/bin/lib/composer-model-router.mjs +160 -0
  27. package/bin/lib/composer-node-schema.mjs +299 -0
  28. package/bin/lib/composer-planner.mjs +749 -0
  29. package/bin/lib/composer-script-ops.mjs +233 -0
  30. package/bin/lib/composer-skill-router.mjs +384 -0
  31. package/bin/lib/flow-import.mjs +305 -0
  32. package/bin/lib/flow-normalize.mjs +71 -0
  33. package/bin/lib/flow-write.mjs +395 -0
  34. package/bin/lib/help.mjs +139 -0
  35. package/bin/lib/hub-login.mjs +54 -0
  36. package/bin/lib/hub-publish.mjs +159 -0
  37. package/bin/lib/hub-remote.mjs +189 -0
  38. package/bin/lib/hub.mjs +299 -0
  39. package/bin/lib/i18n.mjs +233 -0
  40. package/bin/lib/locales/en.json +344 -0
  41. package/bin/lib/locales/zh.json +344 -0
  42. package/bin/lib/log.mjs +37 -0
  43. package/bin/lib/main.mjs +611 -0
  44. package/bin/lib/model-config.mjs +118 -0
  45. package/bin/lib/model-lists.mjs +188 -0
  46. package/bin/lib/node-exec-context.mjs +336 -0
  47. package/bin/lib/node-execute.mjs +513 -0
  48. package/bin/lib/normalize-node-tool-command.mjs +97 -0
  49. package/bin/lib/paths.mjs +216 -0
  50. package/bin/lib/pipeline-scripts.mjs +41 -0
  51. package/bin/lib/recent-runs.mjs +173 -0
  52. package/bin/lib/run-apply-active-lock.mjs +82 -0
  53. package/bin/lib/run-events.mjs +85 -0
  54. package/bin/lib/run-node-statuses-from-disk.mjs +85 -0
  55. package/bin/lib/schedule-config.mjs +227 -0
  56. package/bin/lib/scheduler.mjs +312 -0
  57. package/bin/lib/table.mjs +4 -0
  58. package/bin/lib/terminal.mjs +42 -0
  59. package/bin/lib/ui-print.mjs +94 -0
  60. package/bin/lib/ui-server.mjs +2113 -0
  61. package/bin/lib/workspace-tree.mjs +266 -0
  62. package/bin/lib/workspace.mjs +180 -0
  63. package/bin/pipeline/build-node-prompt.mjs +179 -0
  64. package/bin/pipeline/check-cache.mjs +191 -0
  65. package/bin/pipeline/check-flow.mjs +543 -0
  66. package/bin/pipeline/collect-nodes.mjs +212 -0
  67. package/bin/pipeline/compute-cache-md5.mjs +177 -0
  68. package/bin/pipeline/ensure-run-dir.mjs +71 -0
  69. package/bin/pipeline/extract-thinking.mjs +308 -0
  70. package/bin/pipeline/gc.mjs +129 -0
  71. package/bin/pipeline/get-env.mjs +83 -0
  72. package/bin/pipeline/get-exec-id.mjs +145 -0
  73. package/bin/pipeline/get-ready-nodes.mjs +435 -0
  74. package/bin/pipeline/get-resolved-values.mjs +337 -0
  75. package/bin/pipeline/load-key.mjs +62 -0
  76. package/bin/pipeline/parse-bool.mjs +33 -0
  77. package/bin/pipeline/parse-flow.mjs +698 -0
  78. package/bin/pipeline/post-process-control-if.mjs +23 -0
  79. package/bin/pipeline/post-process-node.mjs +490 -0
  80. package/bin/pipeline/pre-process-node.mjs +449 -0
  81. package/bin/pipeline/resolve-inputs.mjs +201 -0
  82. package/bin/pipeline/run-log.mjs +34 -0
  83. package/bin/pipeline/run-tool-nodejs.mjs +160 -0
  84. package/bin/pipeline/save-key.mjs +93 -0
  85. package/bin/pipeline/snapshot-prior-round.mjs +70 -0
  86. package/bin/pipeline/validate-flow.mjs +825 -0
  87. package/bin/pipeline/validate-for-ui.mjs +226 -0
  88. package/bin/pipeline/validate-script-output.mjs +130 -0
  89. package/bin/pipeline/write-result.mjs +182 -0
  90. package/builtin/nodes/agent_subAgent.md +14 -0
  91. package/builtin/nodes/control_agent_toBool.md +20 -0
  92. package/builtin/nodes/control_anyOne.md +17 -0
  93. package/builtin/nodes/control_end.md +11 -0
  94. package/builtin/nodes/control_if.md +20 -0
  95. package/builtin/nodes/control_start.md +11 -0
  96. package/builtin/nodes/control_toBool.md +21 -0
  97. package/builtin/nodes/provide_file.md +11 -0
  98. package/builtin/nodes/provide_str.md +11 -0
  99. package/builtin/nodes/tool_get_env.md +14 -0
  100. package/builtin/nodes/tool_load_key.md +20 -0
  101. package/builtin/nodes/tool_nodejs.md +40 -0
  102. package/builtin/nodes/tool_print.md +14 -0
  103. package/builtin/nodes/tool_save_key.md +20 -0
  104. package/builtin/nodes/tool_user_ask.md +23 -0
  105. package/builtin/nodes/tool_user_check.md +22 -0
  106. package/builtin/pipelines/module-migrate/flow.yaml +819 -0
  107. package/builtin/pipelines/module-migrate/scripts/check_imports.mjs +700 -0
  108. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Makefile +362 -0
  109. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/node_modules/node-addon-api/node_addon_api_except.stamp.d +1 -0
  110. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/bindings/node/binding.o.d +17 -0
  111. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/src/parser.o.d +5 -0
  112. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/src/scanner.o.d +8 -0
  113. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/tree_sitter_kotlin_binding.node.d +1 -0
  114. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/node_modules/node-addon-api/node_addon_api_except.stamp +0 -0
  115. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/bindings/node/binding.o +0 -0
  116. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/src/parser.o +0 -0
  117. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/src/scanner.o +0 -0
  118. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/tree_sitter_kotlin_binding.node +0 -0
  119. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/binding.Makefile +6 -0
  120. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/gyp-mac-tool +768 -0
  121. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api.Makefile +6 -0
  122. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api.target.mk +122 -0
  123. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api_except.target.mk +126 -0
  124. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api_maybe.target.mk +122 -0
  125. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/tree_sitter_kotlin_binding.target.mk +203 -0
  126. package/builtin/pipelines/new/flow.yaml +545 -0
  127. package/builtin/pipelines/new/scripts/check-flow.mjs +9 -0
  128. package/builtin/pipelines/new/scripts/collect-nodes.mjs +211 -0
  129. package/builtin/pipelines/scripts/adjust-node-positions.mjs +113 -0
  130. package/builtin/web-ui/dist/agentflow-icon.svg +23 -0
  131. package/builtin/web-ui/dist/assets/index-CZkUPcXE.css +1 -0
  132. package/builtin/web-ui/dist/assets/index-DkkhNESc.js +190 -0
  133. package/builtin/web-ui/dist/index.html +24 -0
  134. package/package.json +67 -0
  135. package/reference/flow-control-capabilities.md +274 -0
  136. package/reference/flow-layout.md +84 -0
  137. package/reference/flow-prompt-handler-check.md +12 -0
  138. package/reference/flow-result-semantics.md +14 -0
@@ -0,0 +1,825 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 统一流程校验:合并 check-flow 与 validate-for-ui 逻辑(最大集)。
4
+ * 用法:agentflow apply -ai validate-flow <workspaceRoot> <flowName> <flowDir> [uuid]
5
+ * 或由 agentflow validate <FlowName> [uuid] 调用;传 uuid 时写入 runDir/intermediate/validation.json。
6
+ * 输出:stdout 单行 JSON { ok, errors, warnings, validation: { edgeTypeMismatch, nodeRoleMissing, nodeModelMissing }, report? }
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { fileURLToPath } from "url";
12
+ import yaml from "js-yaml";
13
+
14
+ import { getModelListsAbs, getRunDir, getUserAgentsJsonAbs, getUserPipelinesRoot } from "../lib/paths.mjs";
15
+ import { getFlowDir } from "../lib/workspace.mjs";
16
+
17
+ /** 槽位合法 type 集合(英文为 builtin/nodes 标准;中文为遗留兼容,新写代码统一英文) */
18
+ const VALID_SLOT_TYPES = new Set(["node", "text", "file", "bool", "节点", "文本", "文件", "布尔"]);
19
+ const CANONICAL_SLOT_TYPES = "node|text|file|bool";
20
+
21
+ /** 与前端 flowFormat.VALID_ROLES + 内置 id 一致 */
22
+ const VALID_ROLE_KEYS = ["requirement", "planning", "code", "test", "normal"];
23
+ const ROLE_ZH_TO_KEY = {
24
+ 需求拆解: "requirement",
25
+ 技术规划: "planning",
26
+ 代码执行: "code",
27
+ 测试回归: "test",
28
+ 普通: "normal",
29
+ };
30
+ const VALID_ROLES = new Set([
31
+ ...VALID_ROLE_KEYS,
32
+ ...Object.keys(ROLE_ZH_TO_KEY),
33
+ "前端/UI",
34
+ "agentflow-node-executor-requirement",
35
+ "agentflow-node-executor-planning",
36
+ "agentflow-node-executor-code",
37
+ "agentflow-node-executor-test",
38
+ "agentflow-node-executor",
39
+ "agentflow-node-executor-ui",
40
+ ]);
41
+
42
+ /** 由 definitionId 推导 type(与 parse-flow 一致) */
43
+ function definitionIdToType(definitionId) {
44
+ const id = (definitionId || "").toLowerCase();
45
+ if (id.startsWith("control_")) return "control";
46
+ if (id.startsWith("agent_")) return "agent";
47
+ if (id.startsWith("provide_")) return "provide";
48
+ if (id.startsWith("tool_")) return "agent";
49
+ return "agent";
50
+ }
51
+
52
+ function getSlotsFromInstance(inst) {
53
+ const result = { inputNames: [], outputNames: [], inputTypes: [], outputTypes: [] };
54
+ if (!inst || typeof inst !== "object") return result;
55
+ const inp = Array.isArray(inst.input) ? inst.input : [];
56
+ const out = Array.isArray(inst.output) ? inst.output : [];
57
+ for (let i = 0; i < inp.length; i++) {
58
+ const slot = inp[i];
59
+ const name = (slot && slot.name != null ? String(slot.name).trim() : "") || `input-${i}`;
60
+ const type = slot && (slot.type != null) ? String(slot.type).trim() : "";
61
+ result.inputNames.push(name);
62
+ result.inputTypes.push(type);
63
+ }
64
+ for (let i = 0; i < out.length; i++) {
65
+ const slot = out[i];
66
+ const name = (slot && slot.name != null ? String(slot.name).trim() : "") || `output-${i}`;
67
+ const type = slot && (slot.type != null) ? String(slot.type).trim() : "";
68
+ result.outputNames.push(name);
69
+ result.outputTypes.push(type);
70
+ }
71
+ return result;
72
+ }
73
+
74
+ function loadFlowYaml(flowDir) {
75
+ const filePath = path.join(flowDir, "flow.yaml");
76
+ if (!fs.existsSync(filePath)) return null;
77
+ try {
78
+ const raw = fs.readFileSync(filePath, "utf-8");
79
+ const data = yaml.load(raw);
80
+ if (!data || typeof data !== "object") return null;
81
+ const instances = data.instances && typeof data.instances === "object" ? data.instances : {};
82
+ const edgesRaw = Array.isArray(data.edges) ? data.edges : [];
83
+ const nodeIds = new Set(Object.keys(instances));
84
+ for (const e of edgesRaw) {
85
+ if (e?.source) nodeIds.add(e.source);
86
+ if (e?.target) nodeIds.add(e.target);
87
+ }
88
+ const nodes = Array.from(nodeIds).map((id) => {
89
+ const inst = instances[id] || {};
90
+ const definitionId = inst.definitionId != null ? String(inst.definitionId) : id;
91
+ return {
92
+ id,
93
+ type: definitionIdToType(definitionId),
94
+ label: inst.label != null ? String(inst.label) : id,
95
+ definitionId,
96
+ modelType: inst.modelType != null ? String(inst.modelType) : "Auto",
97
+ };
98
+ });
99
+ const edges = edgesRaw
100
+ .filter((e) => e?.source && e?.target)
101
+ .map((e) => ({
102
+ source: String(e.source),
103
+ target: String(e.target),
104
+ sourceHandle: e.sourceHandle ?? null,
105
+ targetHandle: e.targetHandle ?? null,
106
+ }));
107
+ return { nodes, edges, instances };
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function loadModelLists(_workspaceRoot) {
114
+ const p = getModelListsAbs();
115
+ if (!fs.existsSync(p)) return { cursor: [], opencode: [] };
116
+ try {
117
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
118
+ return {
119
+ cursor: Array.isArray(data.cursor) ? data.cursor : [],
120
+ opencode: Array.isArray(data.opencode) ? data.opencode : [],
121
+ };
122
+ } catch {
123
+ return { cursor: [], opencode: [] };
124
+ }
125
+ }
126
+
127
+ function loadCustomRoleIds(_workspaceRoot) {
128
+ const agentsPath = getUserAgentsJsonAbs();
129
+ if (!fs.existsSync(agentsPath)) return new Set();
130
+ try {
131
+ const data = JSON.parse(fs.readFileSync(agentsPath, "utf-8"));
132
+ const list = Array.isArray(data) ? data : (data.agents || []);
133
+ return new Set(list.filter((a) => a && typeof a.id === "string" && a.id.trim()).map((a) => a.id.trim()));
134
+ } catch {
135
+ return new Set();
136
+ }
137
+ }
138
+
139
+ function toEdgeId(e) {
140
+ return `${e.source}__${e.sourceHandle || "output-0"}__${e.target}__${e.targetHandle || "input-0"}`;
141
+ }
142
+
143
+ function extractPlaceholders(body) {
144
+ const list = [];
145
+ const re = /\$\{([^}]*)\}/g;
146
+ let match;
147
+ while ((match = re.exec(body)) !== null) {
148
+ const inner = match[1].trim();
149
+ if (inner) list.push(inner);
150
+ }
151
+ return list;
152
+ }
153
+
154
+ function escapeRegex(s) {
155
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
156
+ }
157
+
158
+ function warnSlotNameNotWrappedInDollarBraces(body, inputNames, outputNames, warnings) {
159
+ const allSlotNames = [...new Set([...inputNames, ...outputNames])];
160
+ if (allSlotNames.length === 0) return;
161
+ const bodyWithoutDollarBraces = body.replace(/\$\{[^}]*\}/g, "\x00\x00");
162
+ for (const name of allSlotNames) {
163
+ const escaped = escapeRegex(name);
164
+ if (new RegExp(`\`${escaped}\``).test(bodyWithoutDollarBraces)) {
165
+ warnings.push(`槽位名 "${name}" 被反引号包裹,应使用 \${${name}} 或 \${input.${name}}/\${output.${name}} 引用`);
166
+ }
167
+ if (new RegExp(`"${escaped}"`).test(bodyWithoutDollarBraces)) {
168
+ warnings.push(`槽位名 "${name}" 被双引号包裹,应使用 \${${name}} 或 \${input.${name}}/\${output.${name}} 引用`);
169
+ }
170
+ if (new RegExp(`'${escaped}'`).test(bodyWithoutDollarBraces)) {
171
+ warnings.push(`槽位名 "${name}" 被单引号包裹,应使用 \${${name}} 或 \${input.${name}}/\${output.${name}} 引用`);
172
+ }
173
+ }
174
+ }
175
+
176
+ function validatePlaceholdersInBody(nodeId, filePath, body, inputNames, outputNames) {
177
+ const errors = [];
178
+ const warnings = [];
179
+ const inputSet = new Set(inputNames);
180
+ const outputSet = new Set(outputNames);
181
+ const placeholders = extractPlaceholders(body);
182
+
183
+ for (const ph of placeholders) {
184
+ if (ph.startsWith("input.")) {
185
+ const name = ph.slice(6).trim();
186
+ if (!name) {
187
+ errors.push(`占位符 \${${ph}} 格式错误,应为 \${input.槽位名}`);
188
+ continue;
189
+ }
190
+ if (!inputSet.has(name)) {
191
+ if (outputSet.has(name)) {
192
+ errors.push(`占位符 \${${ph}}:槽位 "${name}" 为 output,应使用 \${output.${name}} 或 \${${name}}`);
193
+ } else {
194
+ errors.push(`占位符 \${${ph}}:未定义的 input 槽位 "${name}",应在 frontmatter 的 input 中声明`);
195
+ }
196
+ }
197
+ continue;
198
+ }
199
+ if (ph.startsWith("output.")) {
200
+ const name = ph.slice(7).trim();
201
+ if (!name) {
202
+ errors.push(`占位符 \${${ph}} 格式错误,应为 \${output.槽位名}`);
203
+ continue;
204
+ }
205
+ if (!outputSet.has(name)) {
206
+ if (inputSet.has(name)) {
207
+ errors.push(`占位符 \${${ph}}:槽位 "${name}" 为 input,应使用 \${input.${name}} 或 \${${name}}`);
208
+ } else {
209
+ errors.push(`占位符 \${${ph}}:未定义的 output 槽位 "${name}",应在 frontmatter 的 output 中声明`);
210
+ }
211
+ }
212
+ continue;
213
+ }
214
+ if (inputSet.has(ph) || outputSet.has(ph)) continue;
215
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(ph)) {
216
+ warnings.push(`占位符 \${${ph}} 含非常规标识符,请确认是否为槽位引用`);
217
+ }
218
+ }
219
+ warnSlotNameNotWrappedInDollarBraces(body, inputNames, outputNames, warnings);
220
+ const prefix = `[${nodeId}] ${filePath}: `;
221
+ return {
222
+ errors: errors.map((e) => prefix + e),
223
+ warnings: warnings.map((w) => prefix + w),
224
+ };
225
+ }
226
+
227
+ function topoSort(nodes, edges) {
228
+ const idToIndex = new Map(nodes.map((n, i) => [n.id, i]));
229
+ const n = nodes.length;
230
+ const outEdges = Array.from({ length: n }, () => []);
231
+ const inDeg = new Array(n).fill(0);
232
+ for (const e of edges) {
233
+ const u = idToIndex.get(e.source);
234
+ const v = idToIndex.get(e.target);
235
+ if (u == null || v == null) continue;
236
+ outEdges[u].push(v);
237
+ inDeg[v]++;
238
+ }
239
+ const queue = [];
240
+ for (let i = 0; i < n; i++) if (inDeg[i] === 0) queue.push(i);
241
+ const order = [];
242
+ let visited = 0;
243
+ while (queue.length) {
244
+ const u = queue.shift();
245
+ order.push(nodes[u].id);
246
+ visited++;
247
+ for (const v of outEdges[u]) {
248
+ inDeg[v]--;
249
+ if (inDeg[v] === 0) queue.push(v);
250
+ }
251
+ }
252
+ const hasCycle = visited !== n;
253
+ return { order, hasCycle };
254
+ }
255
+
256
+ /**
257
+ * 结构校验核心(不含边类型一致,边类型由下方 computeValidation 统一产出)。
258
+ */
259
+ function checkFlowCore(nodes, edges, flowDir, nodeIdToSlots, getNodeBody, instances = null) {
260
+ const errors = [];
261
+ const warnings = [];
262
+ const nodeIds = new Set(nodes.map((n) => n.id));
263
+
264
+ /* 提醒:input/output 槽位未填写 name 时建议补全,便于引用 */
265
+ if (instances && typeof instances === "object") {
266
+ for (const n of nodes) {
267
+ const inst = instances[n.id];
268
+ if (!inst) continue;
269
+ const inp = Array.isArray(inst.input) ? inst.input : [];
270
+ const out = Array.isArray(inst.output) ? inst.output : [];
271
+ inp.forEach((slot, i) => {
272
+ const name = slot && slot.name != null ? String(slot.name).trim() : "";
273
+ if (!name) warnings.push(`节点 "${n.id}" 的 input 第 ${i + 1} 项未填写 name,建议补全(如 value)以便 \${name} 引用`);
274
+ });
275
+ out.forEach((slot, i) => {
276
+ const name = slot && slot.name != null ? String(slot.name).trim() : "";
277
+ if (!name) warnings.push(`节点 "${n.id}" 的 output 第 ${i + 1} 项未填写 name,建议补全(如 value)以便 \${name} 引用`);
278
+ });
279
+ }
280
+ }
281
+
282
+ const definitionIds = nodes.map((n) => n.definitionId).filter(Boolean);
283
+ const hasStart = definitionIds.some((d) => d === "control_start");
284
+ const hasEnd = definitionIds.some((d) => d === "control_end");
285
+ if (!hasStart) errors.push("流程必须包含一个 definitionId 为 control_start 的节点");
286
+ if (!hasEnd) errors.push("流程必须包含一个 definitionId 为 control_end 的节点");
287
+
288
+ for (let i = 0; i < edges.length; i++) {
289
+ const e = edges[i];
290
+ if (!e.source) errors.push(`edge ${i + 1}: 缺少 source`);
291
+ else if (!nodeIds.has(e.source)) errors.push(`edge ${i + 1}: source "${e.source}" 不在 nodes 中`);
292
+ if (!e.target) errors.push(`edge ${i + 1}: 缺少 target`);
293
+ else if (!nodeIds.has(e.target)) errors.push(`edge ${i + 1}: target "${e.target}" 不在 nodes 中`);
294
+ if (!e.sourceHandle && e.source) {
295
+ warnings.push(`edge ${i + 1} (${e.source} -> ${e.target}): 缺少 sourceHandle,建议补全为 output-0`);
296
+ }
297
+ if (!e.targetHandle && e.target) {
298
+ warnings.push(`edge ${i + 1} (${e.source} -> ${e.target}): 缺少 targetHandle,建议补全为 input-0`);
299
+ }
300
+
301
+ if (e.source && nodeIds.has(e.source)) {
302
+ const outSlots = nodeIdToSlots[e.source]?.outputNames ?? [];
303
+ const maxOut = outSlots.length - 1;
304
+ if (e.sourceHandle) {
305
+ const outMatch = e.sourceHandle.match(/^output-(\d+)$/);
306
+ if (!outMatch) {
307
+ errors.push(`edge ${i + 1} (${e.source} -> ${e.target}): sourceHandle 应为 output-N 格式,当前为 "${e.sourceHandle}"`);
308
+ } else {
309
+ const idx = parseInt(outMatch[1], 10);
310
+ if (idx < 0 || idx > maxOut) {
311
+ const slotList = maxOut >= 0 ? outSlots.join(", ") : "(无 output 槽位)";
312
+ const range = maxOut < 0 ? "(无有效 output 槽位)" : maxOut === 0 ? "output-0" : `output-0 ~ output-${maxOut}`;
313
+ errors.push(
314
+ `edge ${i + 1} (${e.source} -> ${e.target}): sourceHandle "${e.sourceHandle}" 超出源节点 output 槽位 [${slotList}],有效范围: ${range}`
315
+ );
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ if (e.target && nodeIds.has(e.target)) {
322
+ const inSlots = nodeIdToSlots[e.target]?.inputNames ?? [];
323
+ const maxIn = inSlots.length - 1;
324
+ if (e.targetHandle) {
325
+ const inMatch = e.targetHandle.match(/^input-(\d+)$/);
326
+ if (!inMatch) {
327
+ errors.push(`edge ${i + 1} (${e.source} -> ${e.target}): targetHandle 应为 input-N 格式,当前为 "${e.targetHandle}"`);
328
+ } else {
329
+ const idx = parseInt(inMatch[1], 10);
330
+ if (idx < 0 || idx > maxIn) {
331
+ const slotList = maxIn >= 0 ? inSlots.join(", ") : "(无 input 槽位)";
332
+ const range = maxIn < 0 ? "(无有效 input 槽位)" : maxIn === 0 ? "input-0" : `input-0 ~ input-${maxIn}`;
333
+ errors.push(
334
+ `edge ${i + 1} (${e.source} -> ${e.target}): targetHandle "${e.targetHandle}" 超出目标节点 input 槽位 [${slotList}],有效范围: ${range}`
335
+ );
336
+ }
337
+ }
338
+ }
339
+ }
340
+ // 边类型一致检查(同 type 才能连)
341
+ if (
342
+ e.source && e.target &&
343
+ nodeIds.has(e.source) && nodeIds.has(e.target) &&
344
+ e.sourceHandle && e.targetHandle
345
+ ) {
346
+ const outMatch = e.sourceHandle.match(/^output-(\d+)$/);
347
+ const inMatch = e.targetHandle.match(/^input-(\d+)$/);
348
+ if (outMatch && inMatch) {
349
+ const outIdx = parseInt(outMatch[1], 10);
350
+ const inIdx = parseInt(inMatch[1], 10);
351
+ const srcSlots = nodeIdToSlots[e.source];
352
+ const tgtSlots = nodeIdToSlots[e.target];
353
+ const srcType = (srcSlots?.outputTypes?.[outIdx] ?? "").trim();
354
+ const tgtType = (tgtSlots?.inputTypes?.[inIdx] ?? "").trim();
355
+ // 中文别名归一化(兼容旧画布)
356
+ const norm = (t) => {
357
+ if (t === "节点") return "node";
358
+ if (t === "文本") return "text";
359
+ if (t === "文件") return "file";
360
+ if (t === "布尔") return "bool";
361
+ return t;
362
+ };
363
+ const sNorm = norm(srcType);
364
+ const tNorm = norm(tgtType);
365
+ if (sNorm && tNorm && sNorm !== tNorm) {
366
+ const srcSlotName = srcSlots?.outputNames?.[outIdx] ?? `output-${outIdx}`;
367
+ const tgtSlotName = tgtSlots?.inputNames?.[inIdx] ?? `input-${inIdx}`;
368
+ errors.push(
369
+ `edge ${i + 1}: 边类型不匹配 — ${e.source}.${srcSlotName}:${sNorm} → ${e.target}.${tgtSlotName}:${tNorm}(不允许跨类型连线,type 必须一致)`
370
+ );
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ /* 槽位与 edge 对应:Web UI 同步逻辑见 builtin/web-ui/src/flowSlotEdgeWarnings.js(computeSlotEdgeWarnings) */
377
+ const incomingByNode = new Map();
378
+ const outgoingByNode = new Map();
379
+ for (const e of edges) {
380
+ if (e.target && nodeIds.has(e.target)) {
381
+ if (!incomingByNode.has(e.target)) incomingByNode.set(e.target, new Set());
382
+ if (e.targetHandle) incomingByNode.get(e.target).add(e.targetHandle);
383
+ }
384
+ if (e.source && nodeIds.has(e.source)) {
385
+ if (!outgoingByNode.has(e.source)) outgoingByNode.set(e.source, new Set());
386
+ if (e.sourceHandle) outgoingByNode.get(e.source).add(e.sourceHandle);
387
+ }
388
+ }
389
+ for (const n of nodes) {
390
+ const defId = n.definitionId || "";
391
+ const slots = nodeIdToSlots[n.id] || { inputNames: [], outputNames: [] };
392
+ const inCount = slots.inputNames.length;
393
+ const outCount = slots.outputNames.length;
394
+ const skipInputCheck = defId === "control_start" || defId.startsWith("provide_");
395
+ if (!skipInputCheck && inCount > 0) {
396
+ for (let i = 0; i < inCount; i++) {
397
+ const h = `input-${i}`;
398
+ const hasIn = incomingByNode.get(n.id)?.has(h);
399
+ if (!hasIn) {
400
+ const slotName = slots.inputNames[i] || h;
401
+ warnings.push(`节点 "${n.id}" 的 input 槽位 "${slotName}"(${h})无对应 edge 连接`);
402
+ }
403
+ }
404
+ }
405
+ const skipOutputCheck = defId === "control_end";
406
+ if (!skipOutputCheck && outCount > 0) {
407
+ for (let i = 0; i < outCount; i++) {
408
+ const h = `output-${i}`;
409
+ const hasOut = outgoingByNode.get(n.id)?.has(h);
410
+ if (!hasOut) {
411
+ const slotName = slots.outputNames[i] || h;
412
+ warnings.push(`节点 "${n.id}" 的 output 槽位 "${slotName}"(${h})无对应 edge 连接`);
413
+ }
414
+ }
415
+ }
416
+ }
417
+
418
+ for (const n of nodes) {
419
+ const slots = nodeIdToSlots[n.id] || { inputNames: [], outputNames: [] };
420
+ const body = (getNodeBody(n) || "").trim();
421
+ if (!body) continue;
422
+ const fileLabel = `flow.yaml (${n.id})`;
423
+ const { errors: placeErrors, warnings: placeWarnings } = validatePlaceholdersInBody(
424
+ n.id,
425
+ fileLabel,
426
+ body,
427
+ slots.inputNames,
428
+ slots.outputNames
429
+ );
430
+ errors.push(...placeErrors);
431
+ warnings.push(...placeWarnings);
432
+ }
433
+
434
+ const normType = (t) => {
435
+ if (t === "节点") return "node";
436
+ if (t === "文本") return "text";
437
+ if (t === "文件") return "file";
438
+ if (t === "布尔") return "bool";
439
+ return (t || "").trim();
440
+ };
441
+
442
+ if (instances && typeof instances === "object") {
443
+ for (const n of nodes) {
444
+ const inst = instances[n.id];
445
+ if (!inst) continue;
446
+ const defId = inst.definitionId || n.definitionId || "";
447
+
448
+ // tool_nodejs / control_toBool 必须有 script
449
+ if (defId === "tool_nodejs" || defId === "control_toBool") {
450
+ const script = inst.script != null ? String(inst.script).trim() : "";
451
+ const body = inst.body != null ? String(inst.body).trim() : "";
452
+ if (!script && body) {
453
+ errors.push(
454
+ `节点 "${n.id}"(${defId})缺少 script 字段:body 中的自然语言不会被执行,必须添加可执行的 script` +
455
+ (defId === "tool_nodejs" ? ",或改用 agent_subAgent" : ",或改用 control_agent_toBool")
456
+ );
457
+ } else if (!script && !body) {
458
+ errors.push(
459
+ `节点 "${n.id}"(${defId})既无 script 也无 body,节点无法执行,必须添加 script 字段`
460
+ );
461
+ }
462
+
463
+ // script 必须引用所有非 node/bool 类型的 input 和 output 引脚
464
+ if (script) {
465
+ const inp = Array.isArray(inst.input) ? inst.input : [];
466
+ const out = Array.isArray(inst.output) ? inst.output : [];
467
+ const placeholders = extractPlaceholders(script);
468
+ const phSet = new Set(placeholders);
469
+ for (const slot of inp) {
470
+ const slotName = (slot && slot.name != null ? String(slot.name).trim() : "");
471
+ const slotType = normType((slot && slot.type != null ? String(slot.type).trim() : ""));
472
+ if (!slotName || slotType === "node") continue;
473
+ if (!phSet.has(slotName) && !phSet.has(`input.${slotName}`)) {
474
+ errors.push(
475
+ `节点 "${n.id}"(${defId})script 未引用 input 引脚 "${slotName}"(type: ${slotType}),` +
476
+ `应在 script 中添加 \${${slotName}} 传入数据`
477
+ );
478
+ }
479
+ }
480
+ for (const slot of out) {
481
+ const slotName = (slot && slot.name != null ? String(slot.name).trim() : "");
482
+ const slotType = normType((slot && slot.type != null ? String(slot.type).trim() : ""));
483
+ if (!slotName || slotType === "node") continue;
484
+ if (!phSet.has(slotName) && !phSet.has(`output.${slotName}`)) {
485
+ errors.push(
486
+ `节点 "${n.id}"(${defId})script 未引用 output 引脚 "${slotName}"(type: ${slotType}),` +
487
+ `应在 script 中添加 \${${slotName}} 接收输出文件路径并直接写入`
488
+ );
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ // provide_str / provide_file output 类型校验
495
+ if (defId === "provide_str") {
496
+ const out = Array.isArray(inst.output) ? inst.output : [];
497
+ if (out.length !== 1) {
498
+ errors.push(`节点 "${n.id}"(provide_str)output 必须仅有 1 个槽位(value:text),当前 ${out.length} 个`);
499
+ } else {
500
+ const t0 = normType(out[0] && out[0].type);
501
+ if (t0 !== "text") {
502
+ errors.push(`节点 "${n.id}"(provide_str)output[0].type 必须为 \`text\`,当前为 \`${t0 || "(空)"}\``);
503
+ }
504
+ }
505
+ }
506
+ if (defId === "provide_file") {
507
+ const out = Array.isArray(inst.output) ? inst.output : [];
508
+ if (out.length !== 1) {
509
+ errors.push(`节点 "${n.id}"(provide_file)output 必须仅有 1 个槽位(value:file),当前 ${out.length} 个`);
510
+ } else {
511
+ const t0 = normType(out[0] && out[0].type);
512
+ if (t0 !== "file") {
513
+ errors.push(`节点 "${n.id}"(provide_file)output[0].type 必须为 \`file\`,当前为 \`${t0 || "(空)"}\``);
514
+ }
515
+ }
516
+ }
517
+ }
518
+ }
519
+
520
+ const { order, hasCycle } = topoSort(nodes, edges);
521
+ const cycleNodes = hasCycle ? nodes.filter((n) => !order.includes(n.id)).map((n) => n.id) : [];
522
+
523
+ const startNodes = nodes.filter((n) => n.definitionId === "control_start").map((n) => n.id);
524
+ const endNodes = nodes.filter((n) => n.definitionId === "control_end").map((n) => n.id);
525
+ const nodeOnlyEdges = [];
526
+ for (const e of edges) {
527
+ if (!e.source || !e.target || !nodeIds.has(e.source) || !nodeIds.has(e.target)) continue;
528
+ const outMatch = e.sourceHandle?.match(/^output-(\d+)$/);
529
+ const inMatch = e.targetHandle?.match(/^input-(\d+)$/);
530
+ if (!outMatch || !inMatch) continue;
531
+ const outIdx = parseInt(outMatch[1], 10);
532
+ const inIdx = parseInt(inMatch[1], 10);
533
+ const srcType = (nodeIdToSlots[e.source]?.outputTypes?.[outIdx] ?? "").trim();
534
+ const tgtType = (nodeIdToSlots[e.target]?.inputTypes?.[inIdx] ?? "").trim();
535
+ if ((srcType === "node" || srcType === "节点") && (tgtType === "node" || tgtType === "节点")) {
536
+ nodeOnlyEdges.push({ source: e.source, target: e.target });
537
+ }
538
+ }
539
+ const nodeReachable = new Set();
540
+ const queue = [...startNodes];
541
+ for (const id of startNodes) nodeReachable.add(id);
542
+ while (queue.length) {
543
+ const cur = queue.shift();
544
+ for (const e of nodeOnlyEdges) {
545
+ if (e.source !== cur || nodeReachable.has(e.target)) continue;
546
+ nodeReachable.add(e.target);
547
+ queue.push(e.target);
548
+ }
549
+ }
550
+ if (endNodes.length > 0 && !endNodes.some((id) => nodeReachable.has(id))) {
551
+ errors.push(
552
+ "从 control_start 到 control_end 无纯「节点」边构成的路径(仅存在文件/文本边),图不可达。请为 start→end 中间节点补充节点类型(节点→节点)的 edge。"
553
+ );
554
+ }
555
+ const nodeReachableOptional = (defId) =>
556
+ defId === "control_start" || defId === "control_end" || defId.startsWith("provide_") || defId === "tool_load_key" || defId === "tool_save_key" || defId === "tool_get_env";
557
+ for (const n of nodes) {
558
+ const defId = n.definitionId || "";
559
+ if (nodeReachableOptional(defId)) continue;
560
+ if (!nodeReachable.has(n.id)) {
561
+ warnings.push(
562
+ `节点 "${n.id}"(${n.label || n.id})无节点边可达:仅通过文件/文本边连接,缺少从 start 到该节点的节点链路,执行顺序无法保证`
563
+ );
564
+ }
565
+ }
566
+
567
+ if (hasCycle && cycleNodes.length > 0) {
568
+ const cycleSet = new Set(cycleNodes);
569
+ const idToNode = new Map(nodes.map((n) => [n.id, n]));
570
+ for (const nodeId of cycleNodes) {
571
+ // 仅看「节点→节点」边:上游是 provide_file 等时不算环外入边,避免误判
572
+ const incomingFromOutside = nodeOnlyEdges.filter((e) => e.target === nodeId && !cycleSet.has(e.source));
573
+ if (incomingFromOutside.length > 0) {
574
+ const node = idToNode.get(nodeId);
575
+ const defId = node?.definitionId || "";
576
+ if (defId !== "control_anyOne") {
577
+ errors.push(
578
+ `拓扑存在环时,环的入口节点必须是 control_anyOne。节点 "${nodeId}"(definitionId: ${defId})有来自环外的入边,应改为 control_anyOne 节点。`
579
+ );
580
+ }
581
+ }
582
+ }
583
+ }
584
+
585
+ const unreachableNodeIds = nodes
586
+ .filter(
587
+ (n) =>
588
+ n.definitionId &&
589
+ !nodeReachableOptional(n.definitionId) &&
590
+ !nodeReachable.has(n.id)
591
+ )
592
+ .map((n) => n.id);
593
+
594
+ const report = {
595
+ nodesCount: nodes.length,
596
+ edgesCount: edges.length,
597
+ order,
598
+ hasCycle,
599
+ cycleNodes,
600
+ hasStart,
601
+ hasEnd,
602
+ nodeReachable: nodeReachable.size,
603
+ unreachableNodeIds,
604
+ };
605
+
606
+ return { errors, warnings, report };
607
+ }
608
+
609
+ /**
610
+ * 产出 edgeTypeMismatch、nodeRoleMissing、nodeModelMissing(供前端标红),并返回对应 errors 文案。
611
+ */
612
+ function computeValidation(loaded, workspaceRoot) {
613
+ const { nodes, edges, instances } = loaded;
614
+ const edgeTypeMismatch = [];
615
+ const nodeRoleMissing = [];
616
+ const nodeModelMissing = [];
617
+ const validationErrors = [];
618
+
619
+ const nodeIds = new Set(nodes.map((n) => n.id));
620
+ const nodeIdToSlots = {};
621
+ for (const n of nodes) {
622
+ nodeIdToSlots[n.id] = getSlotsFromInstance(instances[n.id]);
623
+ }
624
+
625
+ for (const e of edges) {
626
+ if (!nodeIds.has(e.source) || !nodeIds.has(e.target)) continue;
627
+ const sh = e.sourceHandle ?? "output-0";
628
+ const th = e.targetHandle ?? "input-0";
629
+ const outMatch = sh.match(/^output-(\d+)$/);
630
+ const inMatch = th.match(/^input-(\d+)$/);
631
+ if (!outMatch || !inMatch) continue;
632
+ const outIdx = parseInt(outMatch[1], 10);
633
+ const inIdx = parseInt(inMatch[1], 10);
634
+ const srcSlots = nodeIdToSlots[e.source];
635
+ const tgtSlots = nodeIdToSlots[e.target];
636
+ const srcType = (srcSlots?.outputTypes?.[outIdx] ?? "").trim();
637
+ const tgtType = (tgtSlots?.inputTypes?.[inIdx] ?? "").trim();
638
+ if (srcType && tgtType && srcType !== tgtType) {
639
+ const edgeId = toEdgeId({ ...e, sourceHandle: sh, targetHandle: th });
640
+ edgeTypeMismatch.push(edgeId);
641
+ validationErrors.push(`边类型不一致: ${e.source} ${sh} -> ${e.target} ${th}`);
642
+ }
643
+ }
644
+
645
+ // 槽位 type 白名单:拒绝 "string"/"str"/"文字" 等非法值,避免下游 type 比较静默失配
646
+ for (const n of nodes) {
647
+ const slots = nodeIdToSlots[n.id];
648
+ if (!slots) continue;
649
+ const checkSlot = (kind, idx, type) => {
650
+ const t = (type || "").trim();
651
+ if (t === "" || VALID_SLOT_TYPES.has(t)) return;
652
+ validationErrors.push(
653
+ `节点 "${n.id}" ${kind}-${idx} 的 type "${t}" 非法(合法值:${CANONICAL_SLOT_TYPES})`
654
+ );
655
+ };
656
+ (slots.outputTypes || []).forEach((t, i) => checkSlot("output", i, t));
657
+ (slots.inputTypes || []).forEach((t, i) => checkSlot("input", i, t));
658
+ }
659
+
660
+ // provide_* 仅作数据源,不得连入控制链(node→node 边)
661
+ for (const e of edges) {
662
+ if (!nodeIds.has(e.source) || !nodeIds.has(e.target)) continue;
663
+ const sh = e.sourceHandle ?? "output-0";
664
+ const th = e.targetHandle ?? "input-0";
665
+ const outIdx = parseInt((sh.match(/^output-(\d+)$/) || [])[1] ?? "-1", 10);
666
+ const inIdx = parseInt((th.match(/^input-(\d+)$/) || [])[1] ?? "-1", 10);
667
+ if (outIdx < 0 || inIdx < 0) continue;
668
+ const srcDef = (instances[e.source]?.definitionId || "").trim();
669
+ const tgtDef = (instances[e.target]?.definitionId || "").trim();
670
+ const srcType = (nodeIdToSlots[e.source]?.outputTypes?.[outIdx] ?? "").trim();
671
+ const tgtType = (nodeIdToSlots[e.target]?.inputTypes?.[inIdx] ?? "").trim();
672
+ const isNodeEdge = (srcType === "node" || srcType === "节点") && (tgtType === "node" || tgtType === "节点");
673
+ if (!isNodeEdge) continue;
674
+ if (srcDef.startsWith("provide_") || tgtDef.startsWith("provide_")) {
675
+ validationErrors.push(
676
+ `provide_* 节点不得出现在控制链上(node→node 边):${e.source} ${sh} -> ${e.target} ${th};provide 仅作数据源,请改连下游 text/file 数据槽`
677
+ );
678
+ }
679
+ }
680
+
681
+ const validRoles = new Set(VALID_ROLES);
682
+ if (workspaceRoot) {
683
+ for (const id of loadCustomRoleIds(workspaceRoot)) validRoles.add(id);
684
+ }
685
+ for (const n of nodes) {
686
+ const role = (instances[n.id] && instances[n.id].role != null) ? String(instances[n.id].role).trim() : "";
687
+ if (role && !validRoles.has(role)) {
688
+ nodeRoleMissing.push(n.id);
689
+ validationErrors.push(`节点角色未配置或不在允许列表: ${n.id}`);
690
+ }
691
+ }
692
+
693
+ const root = workspaceRoot ? path.resolve(workspaceRoot) : path.dirname(loaded._flowDir || ".");
694
+ const { cursor: cursorList, opencode: opencodeList } = loadModelLists(root);
695
+ const opencodeSet = new Set((opencodeList || []).map((s) => String(s).trim()));
696
+ const cursorSet = new Set(
697
+ (cursorList || []).map((s) => {
698
+ const t = String(s).trim();
699
+ const first = t.split(/\s+-/)[0].trim();
700
+ return first || t;
701
+ })
702
+ );
703
+ for (const n of nodes) {
704
+ const model = (instances[n.id] && instances[n.id].model != null) ? String(instances[n.id].model).trim() : "";
705
+ if (!model) continue;
706
+ let valid = false;
707
+ if (model.startsWith("opencode:")) {
708
+ valid = opencodeSet.has(model.slice(9).trim());
709
+ } else {
710
+ const cursorId = model.startsWith("cursor:") ? model.slice(7).trim() : model;
711
+ valid = cursorSet.has(cursorId) || (cursorList || []).some((c) => String(c).trim() === model || String(c).trim().startsWith(cursorId + " "));
712
+ }
713
+ if (!valid) {
714
+ nodeModelMissing.push(n.id);
715
+ validationErrors.push(`节点模型未配置或不在模型列表: ${n.id}`);
716
+ }
717
+ }
718
+
719
+ return {
720
+ validation: { edgeTypeMismatch, nodeRoleMissing, nodeModelMissing },
721
+ validationErrors,
722
+ };
723
+ }
724
+
725
+ export function runValidateFlow(flowDir, workspaceRoot) {
726
+ const loaded = loadFlowYaml(flowDir);
727
+ if (!loaded) {
728
+ return {
729
+ ok: false,
730
+ errors: [`未找到 flow.yaml:${path.join(flowDir, "flow.yaml")}`],
731
+ warnings: [],
732
+ validation: { edgeTypeMismatch: [], nodeRoleMissing: [], nodeModelMissing: [] },
733
+ report: null,
734
+ };
735
+ }
736
+ const { nodes, edges, instances } = loaded;
737
+ if (nodes.length === 0) {
738
+ return {
739
+ ok: false,
740
+ errors: ["flow.yaml 必须包含 instances 且至少一个节点"],
741
+ warnings: [],
742
+ validation: { edgeTypeMismatch: [], nodeRoleMissing: [], nodeModelMissing: [] },
743
+ report: null,
744
+ };
745
+ }
746
+
747
+ const nodeIdToSlots = {};
748
+ for (const n of nodes) {
749
+ nodeIdToSlots[n.id] = getSlotsFromInstance(instances[n.id]);
750
+ }
751
+ const getNodeBody = (n) => (instances[n.id] && instances[n.id].body != null ? String(instances[n.id].body) : "");
752
+
753
+ const { errors: structureErrors, warnings, report } = checkFlowCore(nodes, edges, flowDir, nodeIdToSlots, getNodeBody, instances);
754
+
755
+ loaded._flowDir = flowDir;
756
+ const { validation, validationErrors } = computeValidation(loaded, workspaceRoot);
757
+
758
+ const errors = [...structureErrors, ...validationErrors];
759
+ const ok = errors.length === 0;
760
+
761
+ return {
762
+ ok,
763
+ errors,
764
+ warnings,
765
+ validation,
766
+ report,
767
+ };
768
+ }
769
+
770
+ function resolveFlowDir(workspaceRoot, flowName, flowDirArg) {
771
+ if (flowDirArg != null && flowDirArg !== "") {
772
+ const p = path.resolve(flowDirArg);
773
+ if (fs.existsSync(path.join(p, "flow.yaml"))) return p;
774
+ }
775
+ const found = getFlowDir(workspaceRoot, flowName);
776
+ if (found) return found;
777
+ return path.join(getUserPipelinesRoot(), flowName);
778
+ }
779
+
780
+ function main() {
781
+ const argv = process.argv.slice(2);
782
+ if (argv.length < 2) {
783
+ console.error(JSON.stringify({ ok: false, error: "Usage: validate-flow.mjs <workspaceRoot> <flowName> [flowDir] [uuid]" }));
784
+ process.exit(1);
785
+ }
786
+ const workspaceRoot = path.resolve(argv[0]);
787
+ const flowName = argv[1];
788
+ let flowDirArg = argv[2];
789
+ let uuid = null;
790
+ if (argv.length >= 4 && /^\d{14}$/.test(String(argv[3]).trim())) {
791
+ uuid = String(argv[3]).trim();
792
+ } else if (argv.length === 3 && /^\d{14}$/.test(String(argv[2]).trim())) {
793
+ uuid = String(argv[2]).trim();
794
+ flowDirArg = null;
795
+ }
796
+ const flowDir = resolveFlowDir(workspaceRoot, flowName, flowDirArg);
797
+
798
+ if (!fs.existsSync(path.join(flowDir, "flow.yaml"))) {
799
+ console.error(JSON.stringify({ ok: false, error: "flow.yaml not found in " + flowDir }));
800
+ process.exit(1);
801
+ }
802
+
803
+ const result = runValidateFlow(flowDir, workspaceRoot);
804
+
805
+ if (uuid && flowName) {
806
+ const runDir = getRunDir(workspaceRoot, flowName, uuid);
807
+ const intermediateDir = path.join(runDir, "intermediate");
808
+ try {
809
+ fs.mkdirSync(intermediateDir, { recursive: true });
810
+ fs.writeFileSync(path.join(intermediateDir, "validation.json"), JSON.stringify(result, null, 2), "utf-8");
811
+ } catch (err) {
812
+ console.error(JSON.stringify({ ok: false, error: err.message }));
813
+ process.exit(1);
814
+ }
815
+ }
816
+
817
+ console.log(JSON.stringify(result));
818
+ process.exit(result.ok ? 0 : 1);
819
+ }
820
+
821
+ const isValidateFlowCli =
822
+ process.argv[1] && path.resolve(fileURLToPath(import.meta.url)) === path.resolve(process.argv[1]);
823
+ if (isValidateFlowCli) {
824
+ main();
825
+ }