@lhi/n8m 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -6
- package/dist/agentic/graph.d.ts +50 -0
- package/dist/agentic/graph.js +0 -2
- package/dist/agentic/nodes/architect.d.ts +5 -0
- package/dist/agentic/nodes/architect.js +8 -22
- package/dist/agentic/nodes/engineer.d.ts +15 -0
- package/dist/agentic/nodes/engineer.js +25 -4
- package/dist/agentic/nodes/qa.d.ts +1 -0
- package/dist/agentic/nodes/qa.js +280 -45
- package/dist/agentic/nodes/reviewer.d.ts +4 -0
- package/dist/agentic/nodes/reviewer.js +71 -13
- package/dist/agentic/nodes/supervisor.js +2 -3
- package/dist/agentic/state.d.ts +1 -0
- package/dist/agentic/state.js +4 -0
- package/dist/commands/create.js +37 -3
- package/dist/commands/doc.js +1 -1
- package/dist/commands/fixture.d.ts +12 -0
- package/dist/commands/fixture.js +258 -0
- package/dist/commands/test.d.ts +63 -4
- package/dist/commands/test.js +1179 -90
- package/dist/fixture-schema.json +162 -0
- package/dist/resources/node-definitions-fallback.json +185 -8
- package/dist/resources/node-test-hints.json +188 -0
- package/dist/resources/workflow-test-fixtures.json +42 -0
- package/dist/services/ai.service.d.ts +42 -0
- package/dist/services/ai.service.js +271 -21
- package/dist/services/node-definitions.service.d.ts +1 -0
- package/dist/services/node-definitions.service.js +4 -11
- package/dist/utils/config.js +2 -0
- package/dist/utils/fixtureManager.d.ts +28 -0
- package/dist/utils/fixtureManager.js +41 -0
- package/dist/utils/n8nClient.d.ts +27 -0
- package/dist/utils/n8nClient.js +169 -5
- package/dist/utils/spinner.d.ts +17 -0
- package/dist/utils/spinner.js +52 -0
- package/oclif.manifest.json +49 -1
- package/package.json +2 -2
package/dist/agentic/nodes/qa.js
CHANGED
|
@@ -2,6 +2,7 @@ import { AIService } from "../../services/ai.service.js";
|
|
|
2
2
|
import { ConfigManager } from "../../utils/config.js";
|
|
3
3
|
import { N8nClient } from "../../utils/n8nClient.js";
|
|
4
4
|
import { theme } from "../../utils/theme.js";
|
|
5
|
+
import { Spinner } from "../../utils/spinner.js";
|
|
5
6
|
export const qaNode = async (state) => {
|
|
6
7
|
const aiService = AIService.getInstance();
|
|
7
8
|
const workflowJson = state.workflowJson;
|
|
@@ -19,6 +20,7 @@ export const qaNode = async (state) => {
|
|
|
19
20
|
const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
20
21
|
let createdWorkflowId = null;
|
|
21
22
|
const validationErrors = [];
|
|
23
|
+
let healedNodes = null; // set when inline fixes mutate deployed nodes
|
|
22
24
|
try {
|
|
23
25
|
// 2. Prepare Workflow Data (Extract from state structure)
|
|
24
26
|
// engineerNode returns { workflows: [ { name, nodes, connections } ] }
|
|
@@ -32,11 +34,11 @@ export const qaNode = async (state) => {
|
|
|
32
34
|
// Drop timezone — sanitizeSettings in N8nClient strips it unconditionally
|
|
33
35
|
const rawSettings = { ...(targetWorkflow.settings || {}) };
|
|
34
36
|
delete rawSettings.timezone;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
const strippedNodes =
|
|
37
|
+
// Shim external-network nodes FIRST (credentials present on the original node
|
|
38
|
+
// are the signal that a node calls an external service), then strip credentials
|
|
39
|
+
// from whatever remains so n8n doesn't reject the workflow at activation time.
|
|
40
|
+
const shimmedNodes = N8nClient.shimNetworkNodes(targetWorkflow.nodes.filter((node) => node != null));
|
|
41
|
+
const strippedNodes = shimmedNodes.map((node) => {
|
|
40
42
|
const { credentials: _creds, ...rest } = node;
|
|
41
43
|
return rest;
|
|
42
44
|
});
|
|
@@ -58,7 +60,6 @@ export const qaNode = async (state) => {
|
|
|
58
60
|
rootPayload.connections = shimmed.connections;
|
|
59
61
|
}
|
|
60
62
|
// 3. Deploy Ephemeral Workflow
|
|
61
|
-
console.log(theme.agent(`Deploying ephemeral root: ${rootPayload.name}...`));
|
|
62
63
|
const result = await client.createWorkflow(rootPayload.name, rootPayload);
|
|
63
64
|
createdWorkflowId = result.id;
|
|
64
65
|
// 4. Determine Test Scenarios
|
|
@@ -73,52 +74,272 @@ export const qaNode = async (state) => {
|
|
|
73
74
|
const mockPayload = await aiService.generateMockData(context);
|
|
74
75
|
scenarios = [{ name: "Default Test", payload: mockPayload }];
|
|
75
76
|
}
|
|
77
|
+
// Track whether we mutated the deployed workflow so we can surface the healed JSON
|
|
76
78
|
const webhookNode = rootPayload.nodes.find((n) => n.type === 'n8n-nodes-base.webhook');
|
|
77
79
|
if (webhookNode) {
|
|
78
80
|
const path = webhookNode.parameters?.path;
|
|
79
81
|
if (path) {
|
|
80
|
-
// Activate for webhook testing
|
|
81
|
-
|
|
82
|
+
// Activate for webhook testing — n8n validates the workflow at this point
|
|
83
|
+
try {
|
|
84
|
+
await client.activateWorkflow(createdWorkflowId);
|
|
85
|
+
}
|
|
86
|
+
catch (activateErr) {
|
|
87
|
+
const raw = activateErr.message || String(activateErr);
|
|
88
|
+
// Try to extract a clean message from a JSON error body
|
|
89
|
+
let reason = raw;
|
|
90
|
+
const jsonMatch = raw.match(/\{.*\}/s);
|
|
91
|
+
if (jsonMatch) {
|
|
92
|
+
try {
|
|
93
|
+
reason = JSON.parse(jsonMatch[0]).message ?? raw;
|
|
94
|
+
}
|
|
95
|
+
catch { /* keep raw */ }
|
|
96
|
+
}
|
|
97
|
+
const msg = `Activation rejected by n8n: ${reason}`;
|
|
98
|
+
validationErrors.push(msg);
|
|
99
|
+
console.log(theme.fail(msg));
|
|
100
|
+
return { validationStatus: 'failed', validationErrors };
|
|
101
|
+
}
|
|
82
102
|
const baseUrl = new URL(n8nUrl).origin;
|
|
83
103
|
const webhookUrl = `${baseUrl}/webhook/${path}`;
|
|
84
104
|
for (const scenario of scenarios) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
let fixAttempted = false;
|
|
106
|
+
let binaryShimInjected = false;
|
|
107
|
+
let codeNodeFixApplied = false; // tracks whether a code_node_js fix was actually committed
|
|
108
|
+
let codeNodeFixAppliedName;
|
|
109
|
+
let mockDataShimApplied = false; // tracks whether mock-data shim replaced the Code node
|
|
110
|
+
// Up to 5 rounds: initial + fix + mock-shim + downstream + buffer
|
|
111
|
+
fixRound: for (let fixRound = 0; fixRound < 5; fixRound++) {
|
|
112
|
+
console.log(theme.agent(`Testing: ${scenario.name}${fixRound > 0 ? ` (retry ${fixRound})` : ''}`));
|
|
113
|
+
const response = await fetch(webhookUrl, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify(scenario.payload)
|
|
117
|
+
});
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
validationErrors.push(`Scenario "${scenario.name}" failed to trigger: ${response.status}`);
|
|
120
|
+
break fixRound;
|
|
121
|
+
}
|
|
122
|
+
// 5. Verify Execution for this scenario
|
|
123
|
+
const executionStartTime = Date.now();
|
|
124
|
+
let executionFound = false;
|
|
125
|
+
let scenarioErrorMsg = '';
|
|
126
|
+
const maxPoll = 15;
|
|
127
|
+
Spinner.start('Waiting for execution result');
|
|
128
|
+
for (let i = 0; i < maxPoll; i++) {
|
|
129
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
130
|
+
const executions = await client.getWorkflowExecutions(createdWorkflowId);
|
|
131
|
+
const recentExec = executions.find((e) => new Date(e.startedAt).getTime() > (executionStartTime - 5000));
|
|
132
|
+
if (recentExec) {
|
|
133
|
+
executionFound = true;
|
|
134
|
+
const fullExec = await client.getExecution(recentExec.id);
|
|
135
|
+
Spinner.stop();
|
|
136
|
+
if (fullExec.status === 'success') {
|
|
137
|
+
console.log(theme.done('Passed'));
|
|
138
|
+
break fixRound; // scenario passed — move to next
|
|
139
|
+
}
|
|
140
|
+
// Extract error details including failing node name
|
|
141
|
+
const execError = fullExec.data?.resultData?.error;
|
|
142
|
+
const nodeRef = execError?.node;
|
|
143
|
+
const failingNodeName = typeof nodeRef === 'string' ? nodeRef : nodeRef?.name ?? nodeRef?.type;
|
|
144
|
+
let rawMsg = execError?.message || '';
|
|
145
|
+
const topDesc = execError?.description ?? execError?.cause?.message;
|
|
146
|
+
if (rawMsg && topDesc && !rawMsg.includes(topDesc))
|
|
147
|
+
rawMsg = `${rawMsg} — ${topDesc}`;
|
|
148
|
+
if (!rawMsg) {
|
|
149
|
+
const runData = fullExec.data?.resultData?.runData;
|
|
150
|
+
if (runData) {
|
|
151
|
+
outer: for (const [nodeName, nodeRuns] of Object.entries(runData)) {
|
|
152
|
+
for (const run of nodeRuns) {
|
|
153
|
+
if (run?.error?.message) {
|
|
154
|
+
rawMsg = run.error.message;
|
|
155
|
+
const desc = run.error.description ?? run.error.cause?.message;
|
|
156
|
+
if (desc && !rawMsg.includes(desc))
|
|
157
|
+
rawMsg = `${rawMsg} — ${desc}`;
|
|
158
|
+
if (!failingNodeName)
|
|
159
|
+
scenarioErrorMsg = `[${nodeName}] ${rawMsg}`;
|
|
160
|
+
break outer;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
scenarioErrorMsg = scenarioErrorMsg || (failingNodeName ? `[${failingNodeName}] ${rawMsg}` : rawMsg) || 'Unknown flow failure';
|
|
167
|
+
console.log(theme.fail(`Failed: ${scenarioErrorMsg}`));
|
|
168
|
+
break; // exit poll loop, handle error below
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!executionFound) {
|
|
172
|
+
Spinner.stop();
|
|
173
|
+
validationErrors.push(`Scenario "${scenario.name}": No execution detected after trigger.`);
|
|
174
|
+
console.log(theme.warn('No execution detected after trigger.'));
|
|
175
|
+
break fixRound;
|
|
176
|
+
}
|
|
177
|
+
if (!scenarioErrorMsg)
|
|
178
|
+
break fixRound; // success path handled above
|
|
179
|
+
// ── AI-powered error evaluation & targeted self-healing ───────────────
|
|
180
|
+
if (!fixAttempted) {
|
|
181
|
+
const nodeNameMatch = scenarioErrorMsg.match(/^\[([^\]]+)\]/);
|
|
182
|
+
const failingName = nodeNameMatch?.[1];
|
|
183
|
+
const evaluation = await aiService.evaluateTestError(scenarioErrorMsg, rootPayload.nodes, failingName);
|
|
184
|
+
fixAttempted = true;
|
|
185
|
+
if (evaluation.action === 'structural_pass') {
|
|
186
|
+
console.log(theme.warn(`${evaluation.reason}: ${scenarioErrorMsg}`));
|
|
187
|
+
console.log(theme.done('Structural validation passed.'));
|
|
188
|
+
break fixRound; // pass — don't add to validationErrors
|
|
108
189
|
}
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
190
|
+
if (evaluation.action === 'fix_node') {
|
|
191
|
+
const targetName = evaluation.targetNodeName ?? failingName;
|
|
192
|
+
if (evaluation.nodeFixType === 'code_node_js') {
|
|
193
|
+
const target = rootPayload.nodes.find((n) => n.type === 'n8n-nodes-base.code' && (!targetName || n.name === targetName)) ?? rootPayload.nodes.find((n) => n.type === 'n8n-nodes-base.code');
|
|
194
|
+
if (target?.parameters?.jsCode) {
|
|
195
|
+
try {
|
|
196
|
+
console.log(theme.agent(`Self-healing Code node "${target.name}"...`));
|
|
197
|
+
target.parameters.jsCode = await aiService.fixCodeNodeJavaScript(target.parameters.jsCode, scenarioErrorMsg);
|
|
198
|
+
await client.updateWorkflow(createdWorkflowId, rootPayload);
|
|
199
|
+
healedNodes = rootPayload.nodes;
|
|
200
|
+
codeNodeFixApplied = true;
|
|
201
|
+
codeNodeFixAppliedName = target.name;
|
|
202
|
+
console.log(theme.muted('Code node fixed. Retesting...'));
|
|
203
|
+
continue fixRound; // retry
|
|
204
|
+
}
|
|
205
|
+
catch { /* fix failed — fall through to escalation */ }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else if (evaluation.nodeFixType === 'execute_command') {
|
|
209
|
+
const target = rootPayload.nodes.find((n) => n.type === 'n8n-nodes-base.executeCommand' && (!targetName || n.name === targetName)) ?? rootPayload.nodes.find((n) => n.type === 'n8n-nodes-base.executeCommand');
|
|
210
|
+
if (target?.parameters?.command) {
|
|
211
|
+
try {
|
|
212
|
+
console.log(theme.agent(`Self-healing Execute Command node "${target.name}"...`));
|
|
213
|
+
target.parameters.command = await aiService.fixExecuteCommandScript(target.parameters.command, scenarioErrorMsg);
|
|
214
|
+
await client.updateWorkflow(createdWorkflowId, rootPayload);
|
|
215
|
+
healedNodes = rootPayload.nodes;
|
|
216
|
+
console.log(theme.muted('Execute Command script fixed. Retesting...'));
|
|
217
|
+
continue fixRound; // retry
|
|
218
|
+
}
|
|
219
|
+
catch { /* fix failed — fall through to escalation */ }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (evaluation.nodeFixType === 'binary_field') {
|
|
223
|
+
const fieldMatch = scenarioErrorMsg.match(/has no binary field ['"]?(\w+)['"]?/i);
|
|
224
|
+
const expectedField = fieldMatch?.[1];
|
|
225
|
+
const failingNode = targetName
|
|
226
|
+
? rootPayload.nodes.find((n) => n.name === targetName)
|
|
227
|
+
: null;
|
|
228
|
+
// Delegate binary-field tracing to the AI — it traces the full graph
|
|
229
|
+
// (handling passthrough nodes like Merge, Set, IF) to find the actual
|
|
230
|
+
// binary-producing node and the field name it outputs.
|
|
231
|
+
console.log(theme.agent(`Tracing binary data flow to infer correct field name for "${targetName ?? failingName}"...`));
|
|
232
|
+
const correctField = await aiService.inferBinaryFieldNameFromWorkflow(targetName ?? failingName ?? 'unknown', rootPayload.nodes, targetWorkflow.connections || {});
|
|
233
|
+
if (failingNode && expectedField && correctField && correctField !== expectedField) {
|
|
234
|
+
const paramKey = Object.entries(failingNode.parameters || {})
|
|
235
|
+
.find(([, v]) => typeof v === 'string' && v === expectedField)?.[0];
|
|
236
|
+
if (paramKey) {
|
|
237
|
+
try {
|
|
238
|
+
console.log(theme.agent(`Fixing binary field "${failingName}": '${expectedField}' → '${correctField}' (${paramKey})...`));
|
|
239
|
+
failingNode.parameters[paramKey] = correctField;
|
|
240
|
+
await client.updateWorkflow(createdWorkflowId, rootPayload);
|
|
241
|
+
healedNodes = rootPayload.nodes;
|
|
242
|
+
console.log(theme.muted('Binary field name fixed. Retesting...'));
|
|
243
|
+
continue fixRound;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Inject a Code node shim that produces synthetic binary data so the
|
|
251
|
+
// downstream node can actually execute instead of structural-passing.
|
|
252
|
+
const shimField = correctField ?? expectedField ?? 'data';
|
|
253
|
+
console.log(theme.agent(`Injecting binary test shim for field "${shimField}" before "${targetName ?? failingName}"...`));
|
|
254
|
+
try {
|
|
255
|
+
const shimCode = aiService.generateBinaryShimCode(shimField);
|
|
256
|
+
const shimName = `[n8m:shim] Binary for ${targetName ?? failingName}`;
|
|
257
|
+
const shimPos = failingNode?.position ?? [500, 300];
|
|
258
|
+
const shimNode = {
|
|
259
|
+
id: `shim-binary-${Date.now()}`,
|
|
260
|
+
name: shimName,
|
|
261
|
+
type: 'n8n-nodes-base.code',
|
|
262
|
+
typeVersion: 2,
|
|
263
|
+
position: [shimPos[0] - 220, shimPos[1]],
|
|
264
|
+
parameters: { mode: 'runOnceForAllItems', jsCode: shimCode },
|
|
265
|
+
};
|
|
266
|
+
// Rewire: redirect connections pointing at the failing node to the shim,
|
|
267
|
+
// then add shim → failing node.
|
|
268
|
+
const failName = targetName ?? failingName ?? '';
|
|
269
|
+
const conns = JSON.parse(JSON.stringify(rootPayload.connections ?? {}));
|
|
270
|
+
for (const targets of Object.values(conns)) {
|
|
271
|
+
for (const segment of (targets?.main ?? [])) {
|
|
272
|
+
if (!Array.isArray(segment))
|
|
273
|
+
continue;
|
|
274
|
+
for (const conn of segment) {
|
|
275
|
+
if (conn?.node === failName)
|
|
276
|
+
conn.node = shimName;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
conns[shimName] = { main: [[{ node: failName, type: 'main', index: 0 }]] };
|
|
281
|
+
rootPayload.nodes = [...rootPayload.nodes, shimNode];
|
|
282
|
+
rootPayload.connections = conns;
|
|
283
|
+
await client.updateWorkflow(createdWorkflowId, rootPayload);
|
|
284
|
+
// Strip shim from healed nodes — it's a test artifact
|
|
285
|
+
healedNodes = rootPayload.nodes.filter((n) => !n.name?.startsWith('[n8m:shim]'));
|
|
286
|
+
binaryShimInjected = true;
|
|
287
|
+
console.log(theme.muted('Binary shim injected. Retesting...'));
|
|
288
|
+
continue fixRound;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Shim generation/injection failed — fall through to structural pass
|
|
292
|
+
}
|
|
293
|
+
console.log(theme.warn(`Binary data not available in test environment (upstream pipeline required): ${scenarioErrorMsg}`));
|
|
294
|
+
console.log(theme.done('Structural validation passed.'));
|
|
295
|
+
break fixRound;
|
|
296
|
+
}
|
|
113
297
|
}
|
|
114
|
-
|
|
298
|
+
// evaluation.action === 'escalate' or fix attempt failed — fall through
|
|
115
299
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
300
|
+
// A Code node still fails after its JS was patched.
|
|
301
|
+
// Try replacing it with hardcoded mock data so downstream nodes
|
|
302
|
+
// (e.g. Slack at the end of the flow) can still be exercised.
|
|
303
|
+
if (codeNodeFixApplied && !mockDataShimApplied) {
|
|
304
|
+
const shimTarget = rootPayload.nodes.find((n) => n.type === 'n8n-nodes-base.code' && n.name === codeNodeFixAppliedName);
|
|
305
|
+
if (shimTarget?.parameters?.jsCode) {
|
|
306
|
+
console.log(theme.agent(`"${codeNodeFixAppliedName}" still fails — replacing with mock data to continue test...`));
|
|
307
|
+
try {
|
|
308
|
+
shimTarget.parameters.jsCode = await aiService.shimCodeNodeWithMockData(shimTarget.parameters.jsCode);
|
|
309
|
+
await client.updateWorkflow(createdWorkflowId, rootPayload);
|
|
310
|
+
healedNodes = rootPayload.nodes;
|
|
311
|
+
mockDataShimApplied = true;
|
|
312
|
+
console.log(theme.muted(`"${codeNodeFixAppliedName}" replaced with mock data. Retesting...`));
|
|
313
|
+
continue fixRound;
|
|
314
|
+
}
|
|
315
|
+
catch { /* fall through to structural pass */ }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (codeNodeFixApplied || mockDataShimApplied) {
|
|
319
|
+
console.log(theme.warn(`Code node "${codeNodeFixAppliedName ?? 'unknown'}" relies on external APIs unavailable in test environment: ${scenarioErrorMsg}`));
|
|
320
|
+
console.log(theme.done('Structural validation passed.'));
|
|
321
|
+
break fixRound;
|
|
322
|
+
}
|
|
323
|
+
// Binary-field errors that survive fixAttempted (e.g. second round after a
|
|
324
|
+
// successful fix) indicate a test-environment limitation, not a workflow bug.
|
|
325
|
+
if (scenarioErrorMsg.match(/has no binary field/i)) {
|
|
326
|
+
console.log(theme.warn(`Binary data not available in test environment (upstream pipeline required): ${scenarioErrorMsg}`));
|
|
327
|
+
console.log(theme.done('Structural validation passed.'));
|
|
328
|
+
break fixRound;
|
|
329
|
+
}
|
|
330
|
+
// If a binary shim was injected and the downstream node still fails
|
|
331
|
+
// (e.g. invalid URL, credential errors from external APIs like Slack),
|
|
332
|
+
// that's a test-environment limitation, not a workflow bug.
|
|
333
|
+
if (binaryShimInjected) {
|
|
334
|
+
console.log(theme.warn(`External service error after binary shim (credentials/API required): ${scenarioErrorMsg}`));
|
|
335
|
+
console.log(theme.done('Structural validation passed.'));
|
|
336
|
+
break fixRound;
|
|
337
|
+
}
|
|
338
|
+
// Unfixable — escalate to engineer
|
|
339
|
+
validationErrors.push(`Scenario "${scenario.name}" Failed: ${scenarioErrorMsg}`);
|
|
340
|
+
break fixRound;
|
|
341
|
+
} // end fixRound
|
|
342
|
+
} // end scenarios
|
|
122
343
|
}
|
|
123
344
|
}
|
|
124
345
|
else {
|
|
@@ -142,21 +363,35 @@ export const qaNode = async (state) => {
|
|
|
142
363
|
}
|
|
143
364
|
catch (error) {
|
|
144
365
|
const errorMsg = error.message;
|
|
145
|
-
|
|
366
|
+
// Connectivity errors can't be fixed by modifying the workflow — rethrow so
|
|
367
|
+
// the graph surfaces a clear failure instead of looping through the engineer.
|
|
368
|
+
const isConnectivityError = errorMsg.includes('Cannot connect to n8n') ||
|
|
369
|
+
errorMsg.includes('fetch failed') ||
|
|
370
|
+
errorMsg.includes('ECONNREFUSED') ||
|
|
371
|
+
errorMsg.includes('ENOTFOUND');
|
|
372
|
+
if (isConnectivityError)
|
|
373
|
+
throw error;
|
|
146
374
|
validationErrors.push(errorMsg);
|
|
147
375
|
}
|
|
148
376
|
finally {
|
|
149
|
-
// Cleanup
|
|
150
377
|
if (createdWorkflowId) {
|
|
151
378
|
try {
|
|
152
379
|
await client.deleteWorkflow(createdWorkflowId);
|
|
153
|
-
console.log(theme.info(`Purged temporary workflow ${createdWorkflowId}`));
|
|
154
380
|
}
|
|
155
381
|
catch { /* intentionally empty */ }
|
|
156
382
|
}
|
|
157
383
|
}
|
|
384
|
+
// If we patched nodes inline (Code / Execute Command fixes), propagate the
|
|
385
|
+
// healed workflow back into state so the final saved workflow reflects the fix.
|
|
386
|
+
const healedWorkflow = healedNodes ? (() => {
|
|
387
|
+
const clone = JSON.parse(JSON.stringify(workflowJson));
|
|
388
|
+
const target = clone.workflows?.[0] ?? clone;
|
|
389
|
+
target.nodes = healedNodes;
|
|
390
|
+
return clone;
|
|
391
|
+
})() : undefined;
|
|
158
392
|
return {
|
|
159
393
|
validationStatus: validationErrors.length === 0 ? 'passed' : 'failed',
|
|
160
394
|
validationErrors,
|
|
395
|
+
...(healedWorkflow ? { workflowJson: healedWorkflow } : {}),
|
|
161
396
|
};
|
|
162
397
|
};
|
|
@@ -2,4 +2,8 @@ import { TeamState } from "../state.js";
|
|
|
2
2
|
export declare const reviewerNode: (state: typeof TeamState.State) => Promise<{
|
|
3
3
|
validationStatus: string;
|
|
4
4
|
validationErrors: string[];
|
|
5
|
+
} | {
|
|
6
|
+
workflowJson?: any;
|
|
7
|
+
validationStatus: string;
|
|
8
|
+
validationErrors: never[];
|
|
5
9
|
}>;
|
|
@@ -24,9 +24,12 @@ export const reviewerNode = async (state) => {
|
|
|
24
24
|
"n8n-nodes-base.gemini",
|
|
25
25
|
"cheerioHtml", "n8n-nodes-base.cheerioHtml"
|
|
26
26
|
];
|
|
27
|
-
nodes.forEach(node => {
|
|
27
|
+
nodes.filter(Boolean).forEach(node => {
|
|
28
|
+
if (!node.type) {
|
|
29
|
+
validationErrors.push(`A node is missing a "type" property — AI may have generated an incomplete node.`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
28
32
|
if (knownHallucinations.includes(node.type)) {
|
|
29
|
-
console.log(theme.warn(`[Reviewer] Detected hallucinated node type: ${node.type}`));
|
|
30
33
|
validationErrors.push(`Hallucinated node type detected: "${node.type}". Use standard n8n-nodes-base types.`);
|
|
31
34
|
}
|
|
32
35
|
// Check for empty names
|
|
@@ -38,7 +41,6 @@ export const reviewerNode = async (state) => {
|
|
|
38
41
|
if (!state.availableNodeTypes.includes(node.type)) {
|
|
39
42
|
// Double check it's not a known exception or recent core node
|
|
40
43
|
if (!node.type.startsWith('n8n-nodes-base.stick')) { // generic bypass for sticky notes etc if needed
|
|
41
|
-
console.log(theme.warn(`[Reviewer] Node type not found in instance: ${node.type}`));
|
|
42
44
|
validationErrors.push(`Node type "${node.type}" is not available on your n8n instance.`);
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -48,6 +50,10 @@ export const reviewerNode = async (state) => {
|
|
|
48
50
|
// Build adjacency list (which nodes are destinations?)
|
|
49
51
|
const destinations = new Set();
|
|
50
52
|
const connections = targetWorkflow.connections || {};
|
|
53
|
+
// Nodes that appear as keys in connections have at least one outgoing connection.
|
|
54
|
+
// Sub-nodes (AI models, tools, etc.) connect TO their parent this way — they should
|
|
55
|
+
// never be treated as orphans even though nothing connects back to them.
|
|
56
|
+
const sources = new Set(Object.keys(connections));
|
|
51
57
|
for (const sourceNode in connections) {
|
|
52
58
|
const outputConfig = connections[sourceNode];
|
|
53
59
|
// iterate over outputs (main, ai_tool, etc)
|
|
@@ -74,16 +80,13 @@ export const reviewerNode = async (state) => {
|
|
|
74
80
|
lower.includes('n8n-nodes-base.start') ||
|
|
75
81
|
lower.includes('n8n-nodes-base.poll');
|
|
76
82
|
};
|
|
83
|
+
const orphanedNodes = [];
|
|
77
84
|
nodes.forEach(node => {
|
|
78
85
|
// 2.1 Orphan Check
|
|
79
|
-
if (!destinations.has(node.name) && !isTrigger(node.type)) {
|
|
80
|
-
|
|
81
|
-
// Sticky notes and Merge nodes can be tricky, but generally Merge needs input.
|
|
82
|
-
if (!node.type.includes('StickyNote')) {
|
|
83
|
-
// Double check for "On Execution" (custom trigger name sometimes used)
|
|
86
|
+
if (!destinations.has(node.name) && !sources.has(node.name) && !isTrigger(node.type)) {
|
|
87
|
+
if (!node.type?.includes('StickyNote')) {
|
|
84
88
|
if (!node.name || (!node.name.toLowerCase().includes('trigger') && !node.name.toLowerCase().includes('webhook'))) {
|
|
85
|
-
|
|
86
|
-
validationErrors.push(`Node "${node.name || 'Unnamed'}" (${node.type || 'unknown type'}) is disconnected (orphaned). Connect it or remove it.`);
|
|
89
|
+
orphanedNodes.push(node);
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
92
|
}
|
|
@@ -96,6 +99,61 @@ export const reviewerNode = async (state) => {
|
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
101
|
});
|
|
102
|
+
// 2.3 Auto-Shim: Attempt to chain orphaned nodes rather than failing
|
|
103
|
+
let shimmedWorkflow = null;
|
|
104
|
+
if (orphanedNodes.length > 0) {
|
|
105
|
+
const triggerNodes = nodes.filter(n => isTrigger(n.type));
|
|
106
|
+
const actionableOrphans = orphanedNodes.filter(n => !n.type?.includes('StickyNote'));
|
|
107
|
+
// Sort orphans by x position (left-to-right layout order)
|
|
108
|
+
const sorted = [...actionableOrphans].sort((a, b) => (a.position?.[0] ?? 0) - (b.position?.[0] ?? 0));
|
|
109
|
+
// Build a patched connections object
|
|
110
|
+
const patchedConnections = { ...(targetWorkflow.connections || {}) };
|
|
111
|
+
// Find the last node in the existing chain (a source node whose targets don't include any orphan)
|
|
112
|
+
// Simplification: if there's a trigger with no outgoing connections, attach the first orphan to it
|
|
113
|
+
let attachToName = null;
|
|
114
|
+
if (triggerNodes.length > 0) {
|
|
115
|
+
const trigger = triggerNodes[0];
|
|
116
|
+
if (!patchedConnections[trigger.name]) {
|
|
117
|
+
attachToName = trigger.name;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Trigger already connects to something — find the tail of its chain
|
|
121
|
+
// Walk the chain and stop at the first node with no outgoing connection
|
|
122
|
+
let current = trigger.name;
|
|
123
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
124
|
+
const outgoing = patchedConnections[current];
|
|
125
|
+
if (!outgoing?.main?.[0]?.[0]?.node)
|
|
126
|
+
break;
|
|
127
|
+
const next = outgoing.main[0][0].node;
|
|
128
|
+
if (orphanedNodes.some(o => o.name === next))
|
|
129
|
+
break; // avoid infinite loop into orphans
|
|
130
|
+
current = next;
|
|
131
|
+
}
|
|
132
|
+
attachToName = current;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (attachToName && sorted.length > 0) {
|
|
136
|
+
// Attach first orphan to the chain tail
|
|
137
|
+
patchedConnections[attachToName] = {
|
|
138
|
+
main: [[{ node: sorted[0].name, type: 'main', index: 0 }]]
|
|
139
|
+
};
|
|
140
|
+
// Chain remaining orphans linearly
|
|
141
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
142
|
+
patchedConnections[sorted[i].name] = {
|
|
143
|
+
main: [[{ node: sorted[i + 1].name, type: 'main', index: 0 }]]
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
console.log(theme.done(`Auto-connected ${sorted.length} orphaned node(s).`));
|
|
147
|
+
shimmedWorkflow = { ...targetWorkflow, connections: patchedConnections };
|
|
148
|
+
// Don't push these to validationErrors — we fixed them
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Can't determine where to attach — escalate to engineer
|
|
152
|
+
for (const node of actionableOrphans) {
|
|
153
|
+
validationErrors.push(`Node "${node.name || 'Unnamed'}" (${node.type || 'unknown type'}) is disconnected (orphaned). Connect it or remove it.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
99
157
|
// 3. Credentials Check
|
|
100
158
|
// If we see an OpenAI node, warn if no credential ID is placeholder? (Skip for now)
|
|
101
159
|
if (validationErrors.length > 0) {
|
|
@@ -104,10 +162,10 @@ export const reviewerNode = async (state) => {
|
|
|
104
162
|
validationErrors: validationErrors,
|
|
105
163
|
};
|
|
106
164
|
}
|
|
107
|
-
//
|
|
165
|
+
// Return the shimmed workflow if we auto-fixed orphaned nodes
|
|
108
166
|
return {
|
|
109
167
|
validationStatus: 'passed',
|
|
110
|
-
|
|
111
|
-
|
|
168
|
+
validationErrors: [],
|
|
169
|
+
...(shimmedWorkflow ? { workflowJson: shimmedWorkflow } : {}),
|
|
112
170
|
};
|
|
113
171
|
};
|
|
@@ -6,13 +6,12 @@ export const supervisorNode = async (state) => {
|
|
|
6
6
|
// Fallback: use existing workflowJson if available
|
|
7
7
|
return {};
|
|
8
8
|
}
|
|
9
|
-
console.log(theme.agent(`Supervisor evaluating ${candidates.length} candidate(s)...`));
|
|
10
9
|
const aiService = AIService.getInstance();
|
|
11
10
|
const evaluation = await aiService.evaluateCandidates(state.userGoal, candidates);
|
|
12
11
|
const bestCandidate = candidates[evaluation.selectedIndex] ?? candidates[0];
|
|
13
12
|
const logEntry = `Supervisor: Selected candidate ${evaluation.selectedIndex + 1}/${candidates.length} ("${bestCandidate.name || 'Unnamed'}"). Reason: ${evaluation.reason}`;
|
|
14
|
-
|
|
15
|
-
console.log(theme.agent(`
|
|
13
|
+
const displayName = bestCandidate.name || bestCandidate.workflows?.[0]?.name || 'Unnamed Workflow';
|
|
14
|
+
console.log(theme.agent(`Selected: ${displayName}`));
|
|
16
15
|
return {
|
|
17
16
|
workflowJson: bestCandidate,
|
|
18
17
|
collaborationLog: [logEntry],
|
package/dist/agentic/state.d.ts
CHANGED
|
@@ -59,4 +59,5 @@ export declare const TeamState: import("@langchain/langgraph").AnnotationRoot<{
|
|
|
59
59
|
(annotation: import("@langchain/langgraph").SingleReducer<any[], any[]>): import("@langchain/langgraph").BinaryOperatorAggregate<any[], any[]>;
|
|
60
60
|
Root: <S extends import("@langchain/langgraph").StateDefinition>(sd: S) => import("@langchain/langgraph").AnnotationRoot<S>;
|
|
61
61
|
};
|
|
62
|
+
maxRevisions: import("@langchain/langgraph").BinaryOperatorAggregate<number, number>;
|
|
62
63
|
}>;
|
package/dist/agentic/state.js
CHANGED
package/dist/commands/create.js
CHANGED
|
@@ -8,6 +8,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
8
8
|
import { graph, resumeAgenticWorkflow } from '../agentic/graph.js';
|
|
9
9
|
import { promptMultiline } from '../utils/multilinePrompt.js';
|
|
10
10
|
import { DocService } from '../services/doc.service.js';
|
|
11
|
+
import { ConfigManager } from '../utils/config.js';
|
|
12
|
+
import { N8nClient } from '../utils/n8nClient.js';
|
|
11
13
|
export default class Create extends Command {
|
|
12
14
|
static args = {
|
|
13
15
|
description: Args.string({
|
|
@@ -76,7 +78,6 @@ export default class Create extends Command {
|
|
|
76
78
|
const threadId = randomUUID();
|
|
77
79
|
this.log(theme.info(`\nInitializing Agentic Workflow for: "${description}" (Session: ${threadId})`));
|
|
78
80
|
let lastWorkflowJson = null;
|
|
79
|
-
let lastSpec = null;
|
|
80
81
|
try {
|
|
81
82
|
const stream = await runAgenticWorkflowStream(description, threadId);
|
|
82
83
|
for await (const event of stream) {
|
|
@@ -86,7 +87,6 @@ export default class Create extends Command {
|
|
|
86
87
|
if (nodeName === 'architect') {
|
|
87
88
|
this.log(theme.agent(`🏗️ Architect: Blueprint designed.`));
|
|
88
89
|
if (stateUpdate.strategies && stateUpdate.strategies.length > 0) {
|
|
89
|
-
lastSpec = stateUpdate.strategies[0]; // Default to primary
|
|
90
90
|
this.log(theme.header('\nPROPOSED STRATEGIES:'));
|
|
91
91
|
stateUpdate.strategies.forEach((s, i) => {
|
|
92
92
|
this.log(`${i === 0 ? theme.success(' [Primary]') : theme.info(' [Alternative]')} ${theme.value(s.suggestedName)}`);
|
|
@@ -225,7 +225,6 @@ export default class Create extends Command {
|
|
|
225
225
|
const savedResources = [];
|
|
226
226
|
const docService = DocService.getInstance();
|
|
227
227
|
for (const workflow of workflows) {
|
|
228
|
-
const workflowName = workflow.name || (lastSpec && lastSpec.suggestedName) || 'generated-workflow';
|
|
229
228
|
const projectTitle = await docService.generateProjectTitle(workflow);
|
|
230
229
|
workflow.name = projectTitle; // Standardize name
|
|
231
230
|
const slug = docService.generateSlug(projectTitle);
|
|
@@ -243,6 +242,41 @@ export default class Create extends Command {
|
|
|
243
242
|
await fs.writeFile(path.join(targetDir, 'README.md'), fullDoc);
|
|
244
243
|
savedResources.push({ path: targetFile, name: projectTitle, original: workflow });
|
|
245
244
|
}
|
|
245
|
+
// 4. DEPLOY PROMPT
|
|
246
|
+
const deployConfig = await ConfigManager.load();
|
|
247
|
+
const n8nUrl = process.env.N8N_API_URL || deployConfig.n8nUrl;
|
|
248
|
+
const n8nKey = process.env.N8N_API_KEY || deployConfig.n8nKey;
|
|
249
|
+
if (n8nUrl && n8nKey) {
|
|
250
|
+
const { shouldDeploy } = await inquirer.prompt([{
|
|
251
|
+
type: 'confirm',
|
|
252
|
+
name: 'shouldDeploy',
|
|
253
|
+
message: 'Deploy validated workflow to n8n?',
|
|
254
|
+
default: true,
|
|
255
|
+
}]);
|
|
256
|
+
if (shouldDeploy) {
|
|
257
|
+
const { activate } = await inquirer.prompt([{
|
|
258
|
+
type: 'confirm',
|
|
259
|
+
name: 'activate',
|
|
260
|
+
message: 'Activate workflow after deployment?',
|
|
261
|
+
default: false,
|
|
262
|
+
}]);
|
|
263
|
+
const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
264
|
+
for (const { name, original } of savedResources) {
|
|
265
|
+
try {
|
|
266
|
+
const result = await client.createWorkflow(name, original);
|
|
267
|
+
this.log(theme.done(`Deployed: ${name} [ID: ${result.id}]`));
|
|
268
|
+
this.log(`${theme.label('Link')} ${theme.secondary(client.getWorkflowLink(result.id))}`);
|
|
269
|
+
if (activate) {
|
|
270
|
+
await client.activateWorkflow(result.id);
|
|
271
|
+
this.log(theme.info('Workflow activated.'));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
this.log(theme.error(`Deploy failed for "${name}": ${err.message}`));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
246
280
|
this.log(theme.done('Agentic Workflow Complete.'));
|
|
247
281
|
process.exit(0);
|
|
248
282
|
}
|