@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.
Files changed (37) hide show
  1. package/README.md +105 -6
  2. package/dist/agentic/graph.d.ts +50 -0
  3. package/dist/agentic/graph.js +0 -2
  4. package/dist/agentic/nodes/architect.d.ts +5 -0
  5. package/dist/agentic/nodes/architect.js +8 -22
  6. package/dist/agentic/nodes/engineer.d.ts +15 -0
  7. package/dist/agentic/nodes/engineer.js +25 -4
  8. package/dist/agentic/nodes/qa.d.ts +1 -0
  9. package/dist/agentic/nodes/qa.js +280 -45
  10. package/dist/agentic/nodes/reviewer.d.ts +4 -0
  11. package/dist/agentic/nodes/reviewer.js +71 -13
  12. package/dist/agentic/nodes/supervisor.js +2 -3
  13. package/dist/agentic/state.d.ts +1 -0
  14. package/dist/agentic/state.js +4 -0
  15. package/dist/commands/create.js +37 -3
  16. package/dist/commands/doc.js +1 -1
  17. package/dist/commands/fixture.d.ts +12 -0
  18. package/dist/commands/fixture.js +258 -0
  19. package/dist/commands/test.d.ts +63 -4
  20. package/dist/commands/test.js +1179 -90
  21. package/dist/fixture-schema.json +162 -0
  22. package/dist/resources/node-definitions-fallback.json +185 -8
  23. package/dist/resources/node-test-hints.json +188 -0
  24. package/dist/resources/workflow-test-fixtures.json +42 -0
  25. package/dist/services/ai.service.d.ts +42 -0
  26. package/dist/services/ai.service.js +271 -21
  27. package/dist/services/node-definitions.service.d.ts +1 -0
  28. package/dist/services/node-definitions.service.js +4 -11
  29. package/dist/utils/config.js +2 -0
  30. package/dist/utils/fixtureManager.d.ts +28 -0
  31. package/dist/utils/fixtureManager.js +41 -0
  32. package/dist/utils/n8nClient.d.ts +27 -0
  33. package/dist/utils/n8nClient.js +169 -5
  34. package/dist/utils/spinner.d.ts +17 -0
  35. package/dist/utils/spinner.js +52 -0
  36. package/oclif.manifest.json +49 -1
  37. package/package.json +2 -2
@@ -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
- // Strip credentials from all nodes n8n 2.x refuses to activate ("publish")
36
- // a workflow that references credentials that don't exist on the instance.
37
- // Structural validation can still run; only live execution of credentialed
38
- // nodes will be skipped/fail, which is expected for an ephemeral test.
39
- const strippedNodes = targetWorkflow.nodes.map((node) => {
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
- await client.activateWorkflow(createdWorkflowId);
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
- console.log(theme.info(`🧪 Running Scenario: ${theme.value(scenario.name)}...`));
86
- const response = await fetch(webhookUrl, {
87
- method: 'POST',
88
- headers: { 'Content-Type': 'application/json' },
89
- body: JSON.stringify(scenario.payload)
90
- });
91
- if (!response.ok) {
92
- validationErrors.push(`Scenario "${scenario.name}" failed to trigger: ${response.status}`);
93
- continue;
94
- }
95
- // 5. Verify Execution for this scenario
96
- const executionStartTime = Date.now();
97
- let executionFound = false;
98
- const maxPoll = 15;
99
- for (let i = 0; i < maxPoll; i++) {
100
- await new Promise(r => setTimeout(r, 2000));
101
- const executions = await client.getWorkflowExecutions(createdWorkflowId);
102
- const recentExec = executions.find((e) => new Date(e.startedAt).getTime() > (executionStartTime - 5000));
103
- if (recentExec) {
104
- executionFound = true;
105
- const fullExec = await client.getExecution(recentExec.id);
106
- if (fullExec.status === 'success') {
107
- console.log(theme.success(` ✔ Passed`));
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
- else {
110
- const errorMsg = fullExec.data?.resultData?.error?.message || "Unknown flow failure";
111
- validationErrors.push(`Scenario "${scenario.name}" Failed: ${errorMsg}`);
112
- console.log(theme.error(` ✘ Failed: ${errorMsg}`));
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
- break;
298
+ // evaluation.action === 'escalate' or fix attempt failed — fall through
115
299
  }
116
- }
117
- if (!executionFound) {
118
- validationErrors.push(`Scenario "${scenario.name}": No execution detected after trigger.`);
119
- console.log(theme.warn(` ⚠ No execution detected.`));
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
- console.error(theme.error(`QA Node Error: ${errorMsg}`));
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
- // It's an orphan unless it's a known trigger-like node
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
- console.log(theme.warn(`[Reviewer] Validated disconnection: Node "${node.name || 'Unnamed'}" has no incoming connections.`));
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
- // console.log(theme.success("Reviewer passed the blueprint."));
165
+ // Return the shimmed workflow if we auto-fixed orphaned nodes
108
166
  return {
109
167
  validationStatus: 'passed',
110
- // Clear errors from previous runs
111
- validationErrors: []
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
- console.log(theme.success(`Supervisor selected: ${bestCandidate.name || "Unnamed Workflow"} (candidate ${evaluation.selectedIndex + 1})`));
15
- console.log(theme.agent(` ${evaluation.reason}`));
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],
@@ -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
  }>;
@@ -30,4 +30,8 @@ export const TeamState = Annotation.Root({
30
30
  }),
31
31
  userFeedback: (Annotation),
32
32
  testScenarios: (Annotation),
33
+ maxRevisions: Annotation({
34
+ value: (_x, y) => y ?? 3,
35
+ default: () => 3,
36
+ }),
33
37
  });
@@ -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
  }